mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-13 22:03:50 +08:00
feat(tasks): stop tasks that miss a configurable findings checkpoint
This commit is contained in:
1 parent
39a8f2c31a
commit
997d2d98c0
24 files changed
+812
-64
No files matched your search
@@ -71,6 +71,11 @@ type TaskDTO struct {
|
||||
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定)
|
||||
ShareGlobalAssets bool `json:"share_global_assets"`
|
||||
WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认
|
||||
|
||||
NoResultSeconds int `json:"no_result_seconds"`
|
||||
NoResultMinFindings int `json:"no_result_min_findings"`
|
||||
NoResultMinSeverity string `json:"no_result_min_severity"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
}
|
||||
|
||||
type ProxyReconnectDTO struct {
|
||||
@@ -166,6 +171,11 @@ func taskDTO(t *Task, status string) TaskDTO {
|
||||
CoverageEnabled: t.CoverageEnabled,
|
||||
ShareGlobalAssets: lifecycle.ShareGlobalAssets,
|
||||
WorkerCount: t.WorkerCount,
|
||||
|
||||
NoResultSeconds: t.NoResultSeconds,
|
||||
NoResultMinFindings: t.NoResultMinFindings,
|
||||
NoResultMinSeverity: t.NoResultMinSeverity,
|
||||
StopReason: lifecycle.StopReason,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ func TestSettleDrainAndGoalMetCausesAreDistinct(t *testing.T) {
|
||||
code string
|
||||
}{
|
||||
{agent.AbortSettleDrainTimeout, "settle_drain_timeout"},
|
||||
{agent.AbortNoResultStop, "no_result_stop"},
|
||||
{agent.AbortGoalMet, "goal_met"},
|
||||
} {
|
||||
e := NewEngine(nil)
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
func TestNoResultRemaining(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
state taskLifecycleState
|
||||
want time.Duration
|
||||
}{
|
||||
{"running", taskLifecycleState{Status: "running", ActiveRunSeconds: 20, RunSegmentStartedAt: 100}, 10 * time.Second},
|
||||
{"paused", taskLifecycleState{Status: "running", ActiveRunSeconds: 20, RunSegmentStartedAt: 100, Paused: true}, 40 * time.Second},
|
||||
{"queued", taskLifecycleState{Status: "running", ActiveRunSeconds: 20, RunSegmentStartedAt: 100, Queued: true}, 40 * time.Second},
|
||||
{"schedule closed", taskLifecycleState{Status: "paused", ActiveRunSeconds: 20, RunSegmentStartedAt: 100, PauseReason: "paused_by_schedule"}, 40 * time.Second},
|
||||
{"revived", taskLifecycleState{Status: "running", ActiveRunSeconds: 200, NoResultBaseSeconds: 200, RunSegmentStartedAt: 100}, 30 * time.Second},
|
||||
{"due", taskLifecycleState{Status: "running", ActiveRunSeconds: 60}, 0},
|
||||
{"overdue", taskLifecycleState{Status: "running", ActiveRunSeconds: 1000}, 0},
|
||||
{"no segment", taskLifecycleState{Status: "running"}, time.Minute},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := noResultRemaining(60, tc.state, 130); got != tc.want {
|
||||
t.Fatalf("remaining=%s, want %s", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoResultCoordinatorTerminalExitAllowsRevival(t *testing.T) {
|
||||
for _, status := range []string{"done", "failed", "timeout"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
task := &Task{ID: "1", Status: status, NoResultSeconds: 60}
|
||||
m := &Manager{tasks: map[string]*Task{task.ID: task}}
|
||||
e := NewEngine(m)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
e.startDeadlineCoordinator(ctx, task)
|
||||
e.runtimeMu.Lock()
|
||||
rt := e.runtimes[task.ID]
|
||||
e.runtimeMu.Unlock()
|
||||
done := make(chan struct{})
|
||||
go func() { rt.wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("terminal coordinator did not exit")
|
||||
}
|
||||
if _, exists := e.coordStarted.Load(task.ID); exists {
|
||||
t.Fatal("terminal exit retained coordinator marker")
|
||||
}
|
||||
task.updateLifecycle(func(s *taskLifecycleState) { s.Status = "running" })
|
||||
e.startDeadlineCoordinator(ctx, task)
|
||||
if _, exists := e.coordStarted.Load(task.ID); !exists {
|
||||
t.Fatal("revival failed to start coordinator")
|
||||
}
|
||||
cancel()
|
||||
rt.wg.Wait()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoResultCheckpointAndRevival(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer m.Close()
|
||||
for _, tc := range []struct {
|
||||
name, severity string
|
||||
wantStop bool
|
||||
}{
|
||||
{"no findings", "", true}, {"qualifying", "high", false}, {"below threshold", "low", true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
task, err := m.CreateTaskWithOptions("checkpoint test", "goal", db.TaskCreateOptions{NoResultSeconds: 60, NoResultMinFindings: 1, NoResultMinSeverity: "high"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) }()
|
||||
id, _ := strconv.ParseInt(task.ID, 10, 64)
|
||||
if err := m.SetTaskStatus(task.ID, "running"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.pg.Exec(`UPDATE tasks SET active_run_seconds=60 WHERE id=$1`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.syncTaskRuntime(task.ID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.severity != "" {
|
||||
if _, err := m.pg.AddFinding(id, 0, "test", "test", tc.severity, "", "", "test", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
e := NewEngine(m)
|
||||
stop, err := e.checkNoResultCheckpoint(task)
|
||||
if err != nil || stop != tc.wantStop {
|
||||
t.Fatalf("stop=%v err=%v", stop, err)
|
||||
}
|
||||
stored, err := m.pg.GetTask(id)
|
||||
if err != nil || stored.NoResultCheckedAt == nil {
|
||||
t.Fatalf("missing durable check: %+v, %v", stored, err)
|
||||
}
|
||||
// Reloading a checked task must preserve the one-shot decision.
|
||||
reloaded := taskFromPG(stored, task.Store, nil)
|
||||
if again, err := e.checkNoResultCheckpoint(reloaded); err != nil || again {
|
||||
t.Fatalf("rechecked: %v, %v", again, err)
|
||||
}
|
||||
if !stop {
|
||||
// Losing the qualifying finding later does not start a new check.
|
||||
if _, err := m.pg.Exec(`DELETE FROM findings WHERE task_id=$1`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.deadlineCoordinator(t.Context(), task)
|
||||
if m.TaskStatus(task.ID) != "running" {
|
||||
t.Fatal("passed task stopped")
|
||||
}
|
||||
return
|
||||
}
|
||||
// A finding arriving during settling cannot undo the decision.
|
||||
if _, err := m.pg.AddFinding(id, 0, "test", "late", "high", "", "", "test", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider := &scriptedLLMProvider{err: errors.New("test final planner unavailable")}
|
||||
planner := agent.NewPlanner(provider, "test", t.TempDir(), nil, 0, 1)
|
||||
e.SetAuthoritativeAgentResolver(func(*Task) (*agent.Planner, *agent.Worker) { return planner, &agent.Worker{} })
|
||||
e.settleTask(t.Context(), task, db.StopReasonNoResult)
|
||||
state := task.lifecycleSnapshot()
|
||||
if state.Status != "timeout" || state.StopReason != db.StopReasonNoResult || !e.isSettling(task.ID) {
|
||||
t.Fatalf("settled: %+v", state)
|
||||
}
|
||||
dto := taskDTO(task, "timeout")
|
||||
if dto.StopReason != db.StopReasonNoResult || dto.NoResultSeconds != 60 {
|
||||
t.Fatalf("DTO: %+v", dto)
|
||||
}
|
||||
if err := m.applyTaskAdmission(task.ID, "timeout", "running", false, "resume", false, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.resetTimeoutRevival(task.ID)
|
||||
state = task.lifecycleSnapshot()
|
||||
if state.NoResultCheckedAt != 0 || state.StopReason != "" || state.NoResultBaseSeconds != state.ActiveRunSeconds || noResultRemaining(60, state, time.Now().Unix()) != time.Minute || e.isSettling(task.ID) {
|
||||
t.Fatalf("revived: %+v", state)
|
||||
}
|
||||
stored, err = m.pg.GetTask(id)
|
||||
if err != nil || stored.NoResultCheckedAt != nil || stored.StopReason != "" || stored.NoResultBaseSeconds != 60 {
|
||||
t.Fatalf("revival not durable: %+v, %v", stored, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoResultCoordinatorStopsAndReleasesMarker(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer m.Close()
|
||||
task, err := m.CreateTaskWithOptions("coordinator stop", "goal", db.TaskCreateOptions{NoResultSeconds: 60})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _, _ = m.DeleteTask(task.ID, DeleteTaskOptions{}) }()
|
||||
id, _ := strconv.ParseInt(task.ID, 10, 64)
|
||||
if _, err := m.pg.Exec(`UPDATE tasks SET active_run_seconds=60 WHERE id=$1`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.syncTaskRuntime(task.ID, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := NewEngine(m)
|
||||
// Commit a failed checkpoint, then model a process restart before settling.
|
||||
if stop, err := e.checkNoResultCheckpoint(task); err != nil || !stop {
|
||||
t.Fatalf("initial decision: %v, %v", stop, err)
|
||||
}
|
||||
if _, err := m.pg.AddFinding(id, 0, "test", "late after decision", "high", "", "", "test", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, err := m.pg.GetTask(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task = taskFromPG(stored, task.Store, nil)
|
||||
m.mu.Lock()
|
||||
m.tasks[task.ID] = task
|
||||
m.mu.Unlock()
|
||||
e = NewEngine(m)
|
||||
provider := &scriptedLLMProvider{err: errors.New("test final planner unavailable")}
|
||||
planner := agent.NewPlanner(provider, "test", t.TempDir(), nil, 0, 1)
|
||||
e.SetAuthoritativeAgentResolver(func(*Task) (*agent.Planner, *agent.Worker) { return planner, &agent.Worker{} })
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
e.startDeadlineCoordinator(ctx, task)
|
||||
e.runtimeMu.Lock()
|
||||
rt := e.runtimes[task.ID]
|
||||
e.runtimeMu.Unlock()
|
||||
done := make(chan struct{})
|
||||
go func() { rt.wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("coordinator did not finish")
|
||||
}
|
||||
if m.TaskStatus(task.ID) != "timeout" || task.lifecycleSnapshot().StopReason != db.StopReasonNoResult {
|
||||
t.Fatal("coordinator did not stop task")
|
||||
}
|
||||
if _, exists := e.coordStarted.Load(task.ID); exists {
|
||||
t.Fatal("coordinator marker survived exit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskRejectsInvalidNoResultSeverity(t *testing.T) {
|
||||
s := &Server{}
|
||||
w := httptest.NewRecorder()
|
||||
s.createTask(w, httptest.NewRequest("POST", "/api/tasks", strings.NewReader(`{"description":"test","goal":"test","no_result_min_severity":"urgent"}`)))
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
+114
-33
@@ -125,11 +125,16 @@ func (e *Engine) clockCtx(base context.Context, t *Task, final bool) context.Con
|
||||
|
||||
// ---------- 协调器 ----------
|
||||
|
||||
func needsNoResultCoordinator(t *Task) bool {
|
||||
state := t.lifecycleSnapshot()
|
||||
return t.NoResultSeconds > 0 && (state.NoResultCheckedAt == 0 || state.StopReason == db.StopReasonNoResult)
|
||||
}
|
||||
|
||||
// startDeadlineCoordinator launches the per-task deadline timer once (idempotent).
|
||||
// Called from Run() and from the restart reload path, so non-active timeout tasks
|
||||
// still get settled after their deadline even without live planner/worker loops.
|
||||
func (e *Engine) startDeadlineCoordinator(ctx context.Context, t *Task) {
|
||||
if t == nil || t.TimeoutSeconds <= 0 {
|
||||
if t == nil || (t.TimeoutSeconds <= 0 && !needsNoResultCoordinator(t)) {
|
||||
return
|
||||
}
|
||||
e.deleteMu.RLock()
|
||||
@@ -137,55 +142,123 @@ func (e *Engine) startDeadlineCoordinator(ctx context.Context, t *Task) {
|
||||
e.deleteMu.RUnlock()
|
||||
return
|
||||
}
|
||||
if _, loaded := e.coordStarted.LoadOrStore(t.ID, true); loaded {
|
||||
token := new(int)
|
||||
if _, loaded := e.coordStarted.LoadOrStore(t.ID, token); loaded {
|
||||
e.deleteMu.RUnlock()
|
||||
return
|
||||
}
|
||||
rt := e.registerTaskRoutines(ctx, t.ID, 1)
|
||||
e.deleteMu.RUnlock()
|
||||
runTaskRoutine(rt, func(loopCtx context.Context) { e.deadlineCoordinator(loopCtx, t) })
|
||||
runTaskRoutine(rt, func(loopCtx context.Context) {
|
||||
defer func() {
|
||||
// A terminal task may be revived while this routine is returning. Only
|
||||
// remove our own marker, then ensure that revival has a coordinator.
|
||||
if e.coordStarted.CompareAndDelete(t.ID, token) && loopCtx.Err() == nil && !e.isSettling(t.ID) && !isTerminalStatus(e.m.TaskStatus(t.ID)) {
|
||||
e.startDeadlineCoordinator(ctx, t)
|
||||
}
|
||||
}()
|
||||
e.deadlineCoordinator(loopCtx, t)
|
||||
})
|
||||
}
|
||||
|
||||
// deadlineCoordinator waits until the task's absolute deadline, then runs the settle
|
||||
// sequence. Absolute wall-clock: it keeps counting through pauses.
|
||||
// deadlineCoordinator watches both the absolute deadline and the one-shot active
|
||||
// runtime checkpoint. Only the absolute deadline keeps counting through pauses.
|
||||
func (e *Engine) deadlineCoordinator(ctx context.Context, t *Task) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if ctx.Err() != nil || isTerminalStatus(e.m.TaskStatus(t.ID)) {
|
||||
return
|
||||
default:
|
||||
}
|
||||
if isTerminalStatus(e.m.TaskStatus(t.ID)) {
|
||||
return // already finished (goals met / failed) — nothing to time out
|
||||
state := t.lifecycleSnapshot()
|
||||
if state.NoResultCheckedAt > 0 && state.StopReason == db.StopReasonNoResult {
|
||||
// Recover a committed failed checkpoint interrupted during settling.
|
||||
e.settleTask(ctx, t, db.StopReasonNoResult)
|
||||
return
|
||||
}
|
||||
dl := e.taskDeadline(t)
|
||||
if dl <= 0 {
|
||||
if sleepCtx(ctx, deadlinePollInterval) { // not yet stamped (task hasn't really run)
|
||||
nap := deadlineMaxSleep
|
||||
if dl := e.taskDeadline(t); dl > 0 {
|
||||
remaining := time.Until(time.Unix(dl, 0))
|
||||
if remaining <= 0 {
|
||||
e.settleTask(ctx, t, "")
|
||||
return
|
||||
}
|
||||
continue
|
||||
nap = min(nap, remaining)
|
||||
} else if t.TimeoutSeconds > 0 {
|
||||
nap = deadlinePollInterval
|
||||
}
|
||||
if remaining := time.Until(time.Unix(dl, 0)); remaining > 0 {
|
||||
nap := remaining
|
||||
if nap > deadlineMaxSleep {
|
||||
nap = deadlineMaxSleep
|
||||
if t.NoResultSeconds > 0 && state.NoResultCheckedAt == 0 {
|
||||
remaining := noResultRemaining(t.NoResultSeconds, state, time.Now().Unix())
|
||||
if remaining <= 0 {
|
||||
stop, err := e.checkNoResultCheckpoint(t)
|
||||
if err != nil {
|
||||
log.Printf("[no-result] task %s 检查失败: %v", t.ID, err)
|
||||
nap = deadlinePollInterval // DB errors never count as zero findings.
|
||||
} else if stop {
|
||||
e.settleTask(ctx, t, db.StopReasonNoResult)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
nap = min(nap, remaining)
|
||||
}
|
||||
if sleepCtx(ctx, nap) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
e.settleTask(ctx, t)
|
||||
return
|
||||
if t.TimeoutSeconds <= 0 && (t.NoResultSeconds <= 0 || t.lifecycleSnapshot().NoResultCheckedAt > 0) {
|
||||
return
|
||||
}
|
||||
if sleepCtx(ctx, nap) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkNoResultCheckpoint serializes the decision with terminal transitions and
|
||||
// revival. Once marked, later findings cannot reverse the decision.
|
||||
func (e *Engine) checkNoResultCheckpoint(t *Task) (bool, error) {
|
||||
e.m.taskStateMu.Lock()
|
||||
defer e.m.taskStateMu.Unlock()
|
||||
state := t.lifecycleSnapshot()
|
||||
if state.NoResultCheckedAt > 0 || isTerminalStatus(state.Status) || noResultRemaining(t.NoResultSeconds, state, time.Now().Unix()) > 0 {
|
||||
return false, nil
|
||||
}
|
||||
id, err := strconv.ParseInt(t.ID, 10, 64)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
count, err := e.m.pg.CountQualifyingFindings(id, t.NoResultMinSeverity)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
minimum := max(1, t.NoResultMinFindings)
|
||||
stop := count < minimum
|
||||
checked, err := e.m.pg.MarkNoResultChecked(id, stop)
|
||||
if err != nil || checked == nil {
|
||||
return false, err
|
||||
}
|
||||
t.updateLifecycle(func(s *taskLifecycleState) {
|
||||
s.NoResultCheckedAt = checked.Unix()
|
||||
if stop {
|
||||
s.StopReason = db.StopReasonNoResult
|
||||
}
|
||||
})
|
||||
severity := t.NoResultMinSeverity
|
||||
if severity == "" {
|
||||
severity = "不限"
|
||||
} else {
|
||||
severity += "及以上"
|
||||
}
|
||||
summary := fmt.Sprintf("无成果检查通过:达标漏洞 %d/%d(等级%s),任务继续运行,之后不再检查", count, minimum, severity)
|
||||
if stop {
|
||||
runtime := taskRunSeconds(state.ActiveRunSeconds, state.RunSegmentStartedAt, checked.Unix(), state.Status, state.Paused, state.Queued) - state.NoResultBaseSeconds
|
||||
summary = fmt.Sprintf("无成果止损:已运行 %s,达标漏洞 %d/%d(等级%s),任务自动停止", time.Duration(runtime)*time.Second, count, minimum, severity)
|
||||
}
|
||||
e.emitActivity(t, db.Activity{Worker: "system", Kind: "text", Summary: summary})
|
||||
return stop, nil
|
||||
}
|
||||
|
||||
// settleTask runs the ordered settle sequence once (§4 steps ①–⑥).
|
||||
func (e *Engine) settleTask(ctx context.Context, t *Task) {
|
||||
func (e *Engine) settleTask(ctx context.Context, t *Task, reason string) {
|
||||
if !e.markSettling(t.ID) {
|
||||
return
|
||||
}
|
||||
log.Printf("[deadline] task %s 到达超时上限,进入收尾时序", t.ID)
|
||||
log.Printf("[deadline] task %s 进入收尾时序,原因=%s", t.ID, reason)
|
||||
|
||||
// ④ 等在跑 worker/planner drain(在跑 run 因夹逼的 MaxDuration 自行进收尾);
|
||||
// 超过 grace 仍未清空 → 硬 cancel 该任务 exec ctx(settling-aware 分支正确归类)。
|
||||
@@ -193,7 +266,11 @@ func (e *Engine) settleTask(ctx context.Context, t *Task) {
|
||||
for e.inflightCount(t.ID) > 0 {
|
||||
if time.Now().After(hardStop) {
|
||||
log.Printf("[deadline] task %s drain 超时(%s),硬取消在跑 run", t.ID, settleDrainGrace)
|
||||
e.cancelExec(t.ID, agent.AbortSettleDrainTimeout)
|
||||
cause := agent.AbortSettleDrainTimeout
|
||||
if reason == db.StopReasonNoResult {
|
||||
cause = agent.AbortNoResultStop
|
||||
}
|
||||
e.cancelExec(t.ID, cause)
|
||||
_ = sleepCtx(ctx, 3*time.Second) // 给 worker 分支一点时间落库/归类
|
||||
break
|
||||
}
|
||||
@@ -203,7 +280,7 @@ func (e *Engine) settleTask(ctx context.Context, t *Task) {
|
||||
}
|
||||
|
||||
// ⑤ 终局一轮 planner(任务超时词,最后目标判定,不产新意图)。
|
||||
met := e.runFinalPlannerRound(ctx, t)
|
||||
met := e.runFinalPlannerRound(ctx, t, reason)
|
||||
if !e.beginTaskOperation(t.ID) {
|
||||
return
|
||||
}
|
||||
@@ -215,7 +292,7 @@ func (e *Engine) settleTask(ctx context.Context, t *Task) {
|
||||
if met {
|
||||
status = "done"
|
||||
}
|
||||
won, err := e.m.SetTaskStatusGuarded(t.ID, status)
|
||||
won, err := e.m.SetTaskStatusGuardedReason(t.ID, status, reason)
|
||||
switch {
|
||||
case err != nil:
|
||||
log.Printf("[deadline] task %s 落终态失败: %v", t.ID, err)
|
||||
@@ -229,7 +306,7 @@ func (e *Engine) settleTask(ctx context.Context, t *Task) {
|
||||
// runFinalPlannerRound drives exactly ONE terminal planner round with the
|
||||
// task-timeout planner words (final goal judgment; no new intents). Waits for the
|
||||
// LLM to be ready (bounded by ctx) so a completable task isn't mis-judged timeout.
|
||||
func (e *Engine) runFinalPlannerRound(ctx context.Context, t *Task) (met bool) {
|
||||
func (e *Engine) runFinalPlannerRound(ctx context.Context, t *Task, reason string) (met bool) {
|
||||
if e.IsDeleting(t.ID) {
|
||||
return false
|
||||
}
|
||||
@@ -253,16 +330,20 @@ func (e *Engine) runFinalPlannerRound(ctx context.Context, t *Task) (met bool) {
|
||||
}
|
||||
defer e.decInflight(t.ID)
|
||||
emit := func(r db.Activity) { e.emitActivity(t, r) }
|
||||
label := "任务超时收尾"
|
||||
if reason == db.StopReasonNoResult {
|
||||
label = "无成果止损收尾"
|
||||
}
|
||||
e.emitActivity(t, db.Activity{Worker: "planner", Kind: "round",
|
||||
Summary: fmt.Sprintf("任务超时收尾·终局判定(第 %d 轮)", e.nextPlannerRound(t.ID))})
|
||||
Summary: fmt.Sprintf("%s·终局判定(第 %d 轮)", label, e.nextPlannerRound(t.ID))})
|
||||
tTaskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
||||
e.BeginLLMCall(t.ID)
|
||||
met, reason, err := planner.Plan(fctx, tTaskID, e.m.assets, t.Store, t.Goal, t.drainTriggers(), emit)
|
||||
met, decision, err := planner.Plan(fctx, tTaskID, e.m.assets, t.Store, t.Goal, t.drainTriggers(), emit)
|
||||
e.EndLLMCall(t.ID)
|
||||
if err != nil {
|
||||
log.Printf("[deadline] task %s 终局规划出错: %v", t.ID, err)
|
||||
} else if met {
|
||||
log.Printf("[deadline] task %s 终局判定目标达成: %s", t.ID, reason)
|
||||
log.Printf("[deadline] task %s 终局判定目标达成: %s", t.ID, decision)
|
||||
}
|
||||
return met
|
||||
}
|
||||
@@ -249,6 +249,10 @@ func (s *Server) admitTaskWhenScheduledWithGenerationChecked(t *Task, mode strin
|
||||
// matching Engine gates before either parking it in FIFO or starting work;
|
||||
// otherwise the old settling flag would make every worker skip forever.
|
||||
s.engine.resetTimeoutRevival(t.ID)
|
||||
} else if wasTerminal {
|
||||
// A final settle round may finish as done. Revival must release its
|
||||
// worker/planner barrier too, while preserving its deadline/checkpoint.
|
||||
s.engine.settling.Delete(t.ID)
|
||||
}
|
||||
if shouldQueue {
|
||||
if !wasQueued {
|
||||
|
||||
+54
-7
@@ -64,7 +64,13 @@ type Task struct {
|
||||
SourceTaskIDs []int64 `json:"source_task_ids,omitempty"`
|
||||
CompanyIDs []int64 `json:"company_ids,omitempty"`
|
||||
Status string `json:"status"` // persisted lifecycle status (done/failed/timeout 为终态;空/其它则由运行态推导)
|
||||
// 任务级超时(见 docs/任务级超时与收尾设计.md)。DeadlineAt/FirstRunAt 为 unix 秒,0=未设/未运行。
|
||||
// No-result options are immutable. Mutable checkpoint fields use lifecycleMu.
|
||||
NoResultSeconds int `json:"no_result_seconds"`
|
||||
NoResultMinFindings int `json:"no_result_min_findings"`
|
||||
NoResultMinSeverity string `json:"no_result_min_severity"`
|
||||
NoResultBaseSeconds int64 `json:"no_result_base_seconds"`
|
||||
NoResultCheckedAt int64 `json:"no_result_checked_at,omitempty"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒)
|
||||
CoverageEnabled bool `json:"coverage_enabled"` // 资产覆盖度功能开关(创建时定,默认开)
|
||||
@@ -111,6 +117,9 @@ type taskLifecycleState struct {
|
||||
CompletedAt int64
|
||||
FirstRunAt int64
|
||||
DeadlineAt int64
|
||||
NoResultBaseSeconds int64
|
||||
NoResultCheckedAt int64
|
||||
StopReason string
|
||||
ActiveRunSeconds int64
|
||||
RunSegmentStartedAt int64
|
||||
SourceTaskIDs []int64
|
||||
@@ -144,6 +153,7 @@ func (t *Task) lifecycleSnapshotLocked() taskLifecycleState {
|
||||
CompletedAt: t.CompletedAt,
|
||||
FirstRunAt: t.FirstRunAt,
|
||||
DeadlineAt: t.DeadlineAt,
|
||||
NoResultBaseSeconds: t.NoResultBaseSeconds, NoResultCheckedAt: t.NoResultCheckedAt, StopReason: t.StopReason,
|
||||
ActiveRunSeconds: t.ActiveRunSeconds,
|
||||
RunSegmentStartedAt: t.RunSegmentStartedAt,
|
||||
SourceTaskIDs: append([]int64(nil), t.SourceTaskIDs...),
|
||||
@@ -179,6 +189,9 @@ func (t *Task) updateLifecycle(update func(*taskLifecycleState)) {
|
||||
t.CompletedAt = state.CompletedAt
|
||||
t.FirstRunAt = state.FirstRunAt
|
||||
t.DeadlineAt = state.DeadlineAt
|
||||
t.NoResultBaseSeconds = state.NoResultBaseSeconds
|
||||
t.NoResultCheckedAt = state.NoResultCheckedAt
|
||||
t.StopReason = state.StopReason
|
||||
t.ActiveRunSeconds = state.ActiveRunSeconds
|
||||
t.RunSegmentStartedAt = state.RunSegmentStartedAt
|
||||
t.SourceTaskIDs = append(t.SourceTaskIDs[:0], state.SourceTaskIDs...)
|
||||
@@ -1154,8 +1167,10 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter
|
||||
LLMProfileIDs: append([]int64(nil), pt.LLMProfileIDs...), ActiveLLMProfileID: pt.ActiveLLMProfileID,
|
||||
LLMChainRevision: pt.LLMChainRevision,
|
||||
LLMFailoverState: pt.LLMFailoverState, LLMFailoverReason: pt.LLMFailoverReason,
|
||||
SourceTaskIDs: append([]int64(nil), pt.SourceTaskIDs...),
|
||||
CompanyIDs: append([]int64(nil), pt.CompanyIDs...),
|
||||
SourceTaskIDs: append([]int64(nil), pt.SourceTaskIDs...),
|
||||
CompanyIDs: append([]int64(nil), pt.CompanyIDs...),
|
||||
NoResultSeconds: pt.NoResultSeconds, NoResultMinFindings: pt.NoResultMinFindings, NoResultMinSeverity: pt.NoResultMinSeverity,
|
||||
NoResultBaseSeconds: pt.NoResultBaseSeconds, NoResultCheckedAt: unixOrZero(pt.NoResultCheckedAt), StopReason: pt.StopReason,
|
||||
TimeoutSeconds: pt.TimeoutSeconds, PlanHeartbeatSeconds: pt.PlanHeartbeatSeconds,
|
||||
CoverageEnabled: pt.CoverageEnabled, ShareGlobalAssets: pt.ShareGlobalAssets, WorkerCount: pt.WorkerCount,
|
||||
FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt),
|
||||
@@ -1490,7 +1505,9 @@ func (m *Manager) applyTaskAdmissionWithSchedule(id, expectedStatus, status stri
|
||||
mode = ""
|
||||
}
|
||||
|
||||
var queuedAt, completedAt, firstRunAt, deadlineAt, segmentStartedAt sql.NullTime
|
||||
var queuedAt, completedAt, firstRunAt, deadlineAt, segmentStartedAt, noResultCheckedAt sql.NullTime
|
||||
var noResultBaseSeconds int64
|
||||
var stopReason string
|
||||
var activeRunSeconds int64
|
||||
var committedMode string
|
||||
err = m.pg.QueryRow(`UPDATE tasks
|
||||
@@ -1513,6 +1530,15 @@ func (m *Manager) applyTaskAdmissionWithSchedule(id, expectedStatus, status stri
|
||||
WHEN ($5 AND queued AND queue_mode='bootstrap') OR $4='bootstrap' THEN 'bootstrap'
|
||||
ELSE 'resume'
|
||||
END,
|
||||
no_result_base_seconds=CASE
|
||||
WHEN $6='timeout' AND stop_reason='no_result' AND $2 NOT IN ('done','failed','timeout') THEN active_run_seconds
|
||||
ELSE no_result_base_seconds
|
||||
END,
|
||||
no_result_checked_at=CASE
|
||||
WHEN $6='timeout' AND stop_reason='no_result' AND $2 NOT IN ('done','failed','timeout') THEN NULL
|
||||
ELSE no_result_checked_at
|
||||
END,
|
||||
stop_reason=CASE WHEN $6 IN ('done','failed','timeout') AND $2 NOT IN ('done','failed','timeout') THEN '' ELSE stop_reason END,
|
||||
first_run_at=CASE
|
||||
WHEN $6='timeout' AND $2 NOT IN ('done','failed','timeout') THEN NULL
|
||||
ELSE first_run_at
|
||||
@@ -1524,8 +1550,8 @@ func (m *Manager) applyTaskAdmissionWithSchedule(id, expectedStatus, status stri
|
||||
,active_run_seconds=active_run_seconds + CASE WHEN ($3 OR $2 IN ('done','failed','timeout')) AND run_segment_started_at IS NOT NULL THEN GREATEST(0, FLOOR(EXTRACT(EPOCH FROM (now() - run_segment_started_at)))::bigint) ELSE 0 END
|
||||
,run_segment_started_at=CASE WHEN $3 OR $2 IN ('done','failed','timeout') THEN NULL ELSE run_segment_started_at END
|
||||
WHERE id=$1 AND deleted_at IS NULL AND status=$6
|
||||
RETURNING queued_at, queue_mode, completed_at, first_run_at, deadline_at, active_run_seconds, run_segment_started_at`, n, status, queued, mode, preservePosition, expectedStatus, manualUntil, preserveSchedule).
|
||||
Scan(&queuedAt, &committedMode, &completedAt, &firstRunAt, &deadlineAt, &activeRunSeconds, &segmentStartedAt)
|
||||
RETURNING queued_at, queue_mode, completed_at, first_run_at, deadline_at, active_run_seconds, run_segment_started_at, no_result_base_seconds, no_result_checked_at, stop_reason`, n, status, queued, mode, preservePosition, expectedStatus, manualUntil, preserveSchedule).
|
||||
Scan(&queuedAt, &committedMode, &completedAt, &firstRunAt, &deadlineAt, &activeRunSeconds, &segmentStartedAt, &noResultBaseSeconds, &noResultCheckedAt, &stopReason)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("task %s lifecycle changed before admission (expected status %q)", id, expectedStatus)
|
||||
}
|
||||
@@ -1557,6 +1583,12 @@ func (m *Manager) applyTaskAdmissionWithSchedule(id, expectedStatus, status stri
|
||||
if deadlineAt.Valid {
|
||||
state.DeadlineAt = deadlineAt.Time.Unix()
|
||||
}
|
||||
state.NoResultBaseSeconds = noResultBaseSeconds
|
||||
state.NoResultCheckedAt = 0
|
||||
if noResultCheckedAt.Valid {
|
||||
state.NoResultCheckedAt = noResultCheckedAt.Time.Unix()
|
||||
}
|
||||
state.StopReason = stopReason
|
||||
state.ActiveRunSeconds = activeRunSeconds
|
||||
state.RunSegmentStartedAt = 0
|
||||
if segmentStartedAt.Valid {
|
||||
@@ -1846,6 +1878,9 @@ func (m *Manager) syncTaskRuntime(id string, n int64) error {
|
||||
m.mu.Lock()
|
||||
if t := m.tasks[id]; t != nil {
|
||||
t.updateLifecycle(func(state *taskLifecycleState) {
|
||||
state.NoResultBaseSeconds = pt.NoResultBaseSeconds
|
||||
state.NoResultCheckedAt = unixOrZero(pt.NoResultCheckedAt)
|
||||
state.StopReason = pt.StopReason
|
||||
state.ActiveRunSeconds = pt.ActiveRunSeconds
|
||||
state.RunSegmentStartedAt = unixOrZero(pt.RunSegmentStartedAt)
|
||||
})
|
||||
@@ -1930,13 +1965,21 @@ func (m *Manager) StampTaskFirstRun(id string) (int64, error) {
|
||||
// (resolves the completed↔timeout race — first terminal writer wins). Reflects the
|
||||
// won status on the live handle. won=false means another terminal already stuck.
|
||||
func (m *Manager) SetTaskStatusGuarded(id, status string) (won bool, err error) {
|
||||
return m.SetTaskStatusGuardedReason(id, status, "")
|
||||
}
|
||||
|
||||
func (m *Manager) SetTaskStatusGuardedReason(id, status, reason string) (won bool, err error) {
|
||||
if status != "timeout" {
|
||||
reason = ""
|
||||
}
|
||||
|
||||
m.taskStateMu.Lock()
|
||||
defer m.taskStateMu.Unlock()
|
||||
n, err := strconv.ParseInt(id, 10, 64)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
active, started, won, err := m.pg.SetTerminalStatusGuardedRuntime(n, status)
|
||||
active, started, won, err := m.pg.SetTerminalStatusGuardedReasonRuntime(n, status, reason)
|
||||
if err != nil || !won {
|
||||
return won, err
|
||||
}
|
||||
@@ -1946,6 +1989,7 @@ func (m *Manager) SetTaskStatusGuarded(id, status string) (won bool, err error)
|
||||
if t := m.tasks[id]; t != nil {
|
||||
t.updateLifecycle(func(state *taskLifecycleState) {
|
||||
state.Status = status
|
||||
state.StopReason = reason
|
||||
state.ActiveRunSeconds = active
|
||||
state.RunSegmentStartedAt = unixOrZero(started)
|
||||
if pgdb.IsTerminal(status) {
|
||||
@@ -1985,6 +2029,9 @@ func (m *Manager) SetTaskStatus(id, status string) error {
|
||||
m.mu.Lock()
|
||||
if t := m.tasks[id]; t != nil {
|
||||
t.updateLifecycle(func(state *taskLifecycleState) {
|
||||
if !pgdb.IsTerminal(status) && pgdb.IsTerminal(state.Status) {
|
||||
state.StopReason = ""
|
||||
}
|
||||
state.Status = status
|
||||
state.ActiveRunSeconds = active
|
||||
state.RunSegmentStartedAt = unixOrZero(started)
|
||||
|
||||
+10
-3
@@ -239,6 +239,9 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
|
||||
"parent_ref": strParam("可选:父任务 id(做父子关联)"),
|
||||
"source_task_ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": fmt.Sprintf("可选:只读继承的来源任务 id 列表(最多 %d 个)。子任务可只读引用这些任务已探明的资产/结论作为起点;与 parent_ref 的纯父子指针不同,这是内容继承。", db.MaxTaskSourceCount)},
|
||||
"llm_profile_id": map[string]any{"type": "integer", "description": "可选:指定本子任务 planner/worker 用的 LLM 配置 id(见 list_llm_profiles);留空则继承父任务、再回退全局激活配置"},
|
||||
"no_result_seconds": map[string]any{"type": "integer", "description": "可选:累计实际运行这些秒后仅检查一次,不足达标漏洞数时停止。0=关闭,正数最低60秒;暂停和排队不计时"},
|
||||
"no_result_min_findings": map[string]any{"type": "integer", "description": "无成果止损最少独立漏洞数,默认1;误报不计"},
|
||||
"no_result_min_severity": map[string]any{"type": "string", "enum": []string{"", "low", "medium", "high", "critical"}, "description": "计数的最低漏洞等级(含以上),空=不限"},
|
||||
"timeout_seconds": map[string]any{"type": "integer", "description": "可选:任务级超时(秒)。到点后触发优雅收尾并进入 timeout 终态;留空或 0 = 不限时"},
|
||||
"plan_heartbeat_seconds": map[string]any{"type": "integer", "description": "可选:planner 心跳触发间隔(秒)。距上轮规划结束/任务开始满该值且期间无触发 → 触发一轮规划(兜底死锁 + 唤醒去监督飞行中的 worker)。留空或 0 = 默认 600(10min);"},
|
||||
"seed_first_intent": map[string]any{"type": "boolean", "description": "可选:对于简单任务可开启,创建时直接下发一条种子意图(内容=描述+目标)让 worker 免等首轮 planner 直接开跑测试;默认 false(走标准先规划再执行)。"},
|
||||
@@ -250,6 +253,9 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
|
||||
ParentRef string `json:"parent_ref"`
|
||||
SourceTaskIDs []string `json:"source_task_ids"`
|
||||
LLMProfileID json.RawMessage `json:"llm_profile_id"`
|
||||
NoResultSeconds int `json:"no_result_seconds"`
|
||||
NoResultMinFindings int `json:"no_result_min_findings"`
|
||||
NoResultMinSeverity string `json:"no_result_min_severity"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"`
|
||||
SeedFirstIntent bool `json:"seed_first_intent"`
|
||||
@@ -330,9 +336,10 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
|
||||
}
|
||||
}
|
||||
t, err := s.m.CreateTaskWithOptions(a.Description, a.Goal, db.TaskCreateOptions{
|
||||
Name: name,
|
||||
SourceTaskIDs: sourceIDs,
|
||||
LLMProfileIDs: llmIDs,
|
||||
Name: name,
|
||||
SourceTaskIDs: sourceIDs,
|
||||
LLMProfileIDs: llmIDs,
|
||||
NoResultSeconds: a.NoResultSeconds, NoResultMinFindings: a.NoResultMinFindings, NoResultMinSeverity: a.NoResultMinSeverity,
|
||||
TimeoutSeconds: a.TimeoutSeconds,
|
||||
PlanHeartbeatSeconds: a.PlanHeartbeatSeconds,
|
||||
})
|
||||
|
||||
+12
-4
@@ -1703,10 +1703,13 @@ type createTaskReq struct {
|
||||
CategoryID *int64 `json:"category_id,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Goal string `json:"goal"`
|
||||
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务的 LLM 配置;省略/null=用激活配置
|
||||
LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"` // 有序任务级配置链;第一项初始生效
|
||||
SourceTaskIDs []string `json:"source_task_ids,omitempty"` // 仅直接、只读继承的来源任务
|
||||
CompanyIDs []int64 `json:"company_ids,omitempty"` // 关联企业范围并快照关联当前企业资产;不复制资产或强制生成意图
|
||||
LLMProfileID *int64 `json:"llm_profile_id,omitempty"` // 指定运行本任务的 LLM 配置;省略/null=用激活配置
|
||||
LLMProfileIDs []int64 `json:"llm_profile_ids,omitempty"` // 有序任务级配置链;第一项初始生效
|
||||
SourceTaskIDs []string `json:"source_task_ids,omitempty"` // 仅直接、只读继承的来源任务
|
||||
CompanyIDs []int64 `json:"company_ids,omitempty"` // 关联企业范围并快照关联当前企业资产;不复制资产或强制生成意图
|
||||
NoResultSeconds int `json:"no_result_seconds"`
|
||||
NoResultMinFindings int `json:"no_result_min_findings"`
|
||||
NoResultMinSeverity string `json:"no_result_min_severity"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"` // 任务级超时(秒);0/省略=不限时
|
||||
PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner 心跳触发间隔(秒);0/省略=默认600(10min);下限=默认=600,低于自动抬到600
|
||||
SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // 创建时直接下发一条种子意图(内容=描述+目标),让 worker 免等首轮 planner 直接开跑;省略/null=默认关闭,走标准先规划再执行。显式传 true 才开(CTF 常一 work 解决时可省掉开跑前的 planner 轮)。
|
||||
@@ -1734,6 +1737,10 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if err := db.NormalizeNoResultOptions(&db.TaskCreateOptions{NoResultSeconds: req.NoResultSeconds, NoResultMinFindings: req.NoResultMinFindings, NoResultMinSeverity: req.NoResultMinSeverity}); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if req.TimeoutSeconds < 0 {
|
||||
req.TimeoutSeconds = 0
|
||||
}
|
||||
@@ -1794,6 +1801,7 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) {
|
||||
Schedule: schedule,
|
||||
Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID,
|
||||
SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs,
|
||||
NoResultSeconds: req.NoResultSeconds, NoResultMinFindings: req.NoResultMinFindings, NoResultMinSeverity: req.NoResultMinSeverity,
|
||||
TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds,
|
||||
CoverageEnabled: req.CoverageEnabled,
|
||||
ShareGlobalAssets: req.ShareGlobalAssets,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package server
|
||||
|
||||
import "time"
|
||||
|
||||
// taskRunSeconds computes displayed active runtime from persisted segment data.
|
||||
// Segment timestamps are unix seconds; a zero start means no active segment.
|
||||
func taskRunSeconds(active, segmentStarted, now int64, status string, paused, queued bool) int64 {
|
||||
@@ -23,3 +25,12 @@ func finalizeTaskRunSeconds(active, segmentStarted, now int64) (int64, int64) {
|
||||
}
|
||||
return active, 0
|
||||
}
|
||||
|
||||
// noResultRemaining excludes pauses, queue time and closed schedule windows by
|
||||
// using the same active execution segments as the task runtime display.
|
||||
func noResultRemaining(seconds int, state taskLifecycleState, now int64) time.Duration {
|
||||
runtime := taskRunSeconds(state.ActiveRunSeconds, state.RunSegmentStartedAt, now, state.Status, state.Paused, state.Queued)
|
||||
elapsed := max(int64(0), runtime-state.NoResultBaseSeconds)
|
||||
remaining := max(int64(0), int64(seconds)-elapsed)
|
||||
return time.Duration(remaining) * time.Second
|
||||
}
|
||||
Reference in new issue
Block a user