diff --git a/db/exploration.go b/db/exploration.go index 4b6aef3..f41ef99 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -1272,7 +1272,7 @@ func (d *DB) TokenTotalsAll() (map[int64]TokenUsage, error) { // LastActivityAll returns the unix time of the most recent activity per // exploration (exploration_id → max created_at epoch), one query for all tasks. // Persisted (unlike Engine.LastActivity's in-memory map), so it survives restarts -// and gives终态任务 a stable "ran until" time for computing run duration. +// and gives终态任务 a stable last-activity time for history views. func (d *DB) LastActivityAll() (map[int64]int64, error) { rows, err := d.Query(`SELECT exploration_id, EXTRACT(EPOCH FROM MAX(created_at))::bigint FROM activity GROUP BY exploration_id`) if err != nil { diff --git a/db/schema.sql b/db/schema.sql index e855921..7599e7c 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -528,6 +528,8 @@ CREATE TABLE IF NOT EXISTS tasks ( pinned_at TIMESTAMPTZ, first_run_at TIMESTAMPTZ, deadline_at TIMESTAMPTZ, + active_run_seconds BIGINT NOT NULL DEFAULT 0, + run_segment_started_at TIMESTAMPTZ, archived_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, @@ -562,6 +564,8 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS llm_chain_revision BIGINT NOT NULL DEFAULT 0; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS active_run_seconds BIGINT NOT NULL DEFAULT 0; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS run_segment_started_at TIMESTAMPTZ; CREATE INDEX IF NOT EXISTS idx_tasks_category ON tasks(category_id, created_at DESC) WHERE deleted_at IS NULL AND category_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_tasks_pinned ON tasks(pinned_at DESC) diff --git a/db/task_runtime_test.go b/db/task_runtime_test.go new file mode 100644 index 0000000..492343f --- /dev/null +++ b/db/task_runtime_test.go @@ -0,0 +1,172 @@ +package db + +import ( + "database/sql" + "testing" +) + +func TestTaskRunSegmentLifecycle(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + + task, err := d.CreateTask("runtime lifecycle", "runtime lifecycle test", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.DeleteTask(task.ID) }) + + active, started, err := d.StartTaskRunSegment(task.ID) + if err != nil { + t.Fatal(err) + } + if started == nil { + t.Fatal("StartTaskRunSegment did not open a segment") + } + if active != 0 { + t.Fatalf("new task active seconds=%d, want 0", active) + } + if _, err := d.Exec(`UPDATE tasks SET run_segment_started_at=now()-interval '3 seconds' WHERE id=$1`, task.ID); err != nil { + t.Fatal(err) + } + + active, started, err = d.FinalizeTaskRunSegment(task.ID) + if err != nil { + t.Fatal(err) + } + if active <= 0 || started != nil { + t.Fatalf("first finalize active=%d started=%v, want positive and NULL", active, started) + } + firstActive := active + + // Pause folds the open segment exactly once and closes it. Repeated + // finalization after wall-clock advancement remains a no-op. + if _, _, err := d.StartTaskRunSegment(task.ID); err != nil { + t.Fatal(err) + } + if _, err := d.Exec(`UPDATE tasks SET run_segment_started_at=now()-interval '3 seconds' WHERE id=$1`, task.ID); err != nil { + t.Fatal(err) + } + active, started, err = d.SetPausedRuntime(task.ID, true) + if err != nil { + t.Fatal(err) + } + if active <= firstActive || started != nil { + t.Fatalf("pause active=%d started=%v, want increase and NULL", active, started) + } + pausedActive := active + if active, started, err = d.FinalizeTaskRunSegment(task.ID); err != nil { + t.Fatal(err) + } else if active != pausedActive || started != nil { + t.Fatalf("repeated finalize after pause active=%d started=%v, want %d and NULL", active, started, pausedActive) + } + + // Resuming opens a fresh segment, whose elapsed time is added on top of the + // paused total rather than replacing it. + if _, _, err := d.SetPausedRuntime(task.ID, false); err != nil { + t.Fatal(err) + } + if _, started, err = d.StartTaskRunSegment(task.ID); err != nil { + t.Fatal(err) + } else if started == nil { + t.Fatal("resume did not open a segment") + } + if _, err := d.Exec(`UPDATE tasks SET run_segment_started_at=now()-interval '3 seconds' WHERE id=$1`, task.ID); err != nil { + t.Fatal(err) + } + active, started, err = d.FinalizeTaskRunSegment(task.ID) + if err != nil { + t.Fatal(err) + } + if active <= pausedActive || started != nil { + t.Fatalf("resume finalize active=%d started=%v, want increase and NULL", active, started) + } + + // Both terminal status paths fold an open segment and leave no live clock. + if _, started, err = d.StartTaskRunSegment(task.ID); err != nil { + t.Fatal(err) + } else if started == nil { + t.Fatal("terminal status setup did not open a segment") + } + if _, err := d.Exec(`UPDATE tasks SET run_segment_started_at=now()-interval '3 seconds' WHERE id=$1`, task.ID); err != nil { + t.Fatal(err) + } + beforeTerminal := active + active, started, err = d.SetStatusRuntime(task.ID, "done") + if err != nil { + t.Fatal(err) + } + if active <= beforeTerminal || started != nil { + t.Fatalf("SetStatusRuntime terminal active=%d started=%v, want increase and NULL", active, started) + } + + if _, _, err = d.SetStatusRuntime(task.ID, "running"); err != nil { + t.Fatal(err) + } + if _, started, err = d.StartTaskRunSegment(task.ID); err != nil { + t.Fatal(err) + } else if started == nil { + t.Fatal("guarded terminal setup did not open a segment") + } + if _, err := d.Exec(`UPDATE tasks SET run_segment_started_at=now()-interval '3 seconds' WHERE id=$1`, task.ID); err != nil { + t.Fatal(err) + } + beforeGuarded := active + active, started, won, err := d.SetTerminalStatusGuardedRuntime(task.ID, "failed") + if err != nil || !won { + t.Fatalf("SetTerminalStatusGuardedRuntime: active=%d started=%v won=%v err=%v", active, started, won, err) + } + if active <= beforeGuarded || started != nil { + t.Fatalf("guarded terminal active=%d started=%v, want increase and NULL", active, started) + } + finalActive := active + active, started, won, err = d.SetTerminalStatusGuardedRuntime(task.ID, "timeout") + if err != nil || won { + t.Fatalf("second guarded terminal: active=%d started=%v won=%v err=%v", active, started, won, err) + } + if active != finalActive || started != nil { + t.Fatalf("second guarded terminal returned active=%d started=%v, want unchanged active=%d and NULL segment", active, started, finalActive) + } + got, err := d.GetTask(task.ID) + if err != nil { + t.Fatal(err) + } + if got == nil || got.ActiveRunSeconds != finalActive || got.RunSegmentStartedAt != nil { + t.Fatalf("persisted terminal runtime=%+v, want active=%d and NULL segment", got, finalActive) + } +} + +func TestClearOpenTaskRunSegmentsPreservesAccumulatedRuntime(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + + task, err := d.CreateTask("runtime restart clear", "runtime restart clear test", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.DeleteTask(task.ID) }) + if _, err := d.Exec(`UPDATE tasks SET active_run_seconds=17, run_segment_started_at=now()-interval '1 hour' WHERE id=$1`, task.ID); err != nil { + t.Fatal(err) + } + + cleared, err := d.ClearOpenTaskRunSegments() + if err != nil { + t.Fatal(err) + } + if cleared < 1 { + t.Fatalf("ClearOpenTaskRunSegments cleared %d rows, want at least 1", cleared) + } + var active int64 + var started sql.NullTime + if err := d.QueryRow(`SELECT active_run_seconds, run_segment_started_at FROM tasks WHERE id=$1`, task.ID).Scan(&active, &started); err != nil { + t.Fatal(err) + } + if active != 17 || started.Valid { + t.Fatalf("restart clear changed runtime: active=%d started=%v, want 17 and NULL", active, started) + } +} diff --git a/db/tasks.go b/db/tasks.go index c17f1b6..ac5c19e 100644 --- a/db/tasks.go +++ b/db/tasks.go @@ -42,9 +42,11 @@ type Task struct { CreatedAt time.Time `json:"created_at"` CompletedAt *time.Time `json:"completed_at,omitempty"` // 进入终态(done/failed/timeout)的时刻;非终态为 nil // 任务级超时(见 docs/任务级超时与收尾设计.md)。 - TimeoutSeconds int `json:"timeout_seconds"` // 0=不限时 - FirstRunAt *time.Time `json:"first_run_at,omitempty"` // 首次真正开始运行的时刻(非 created_at);nil=尚未运行 - DeadlineAt *time.Time `json:"deadline_at,omitempty"` // = first_run_at + timeout_seconds;nil=不限或未运行 + TimeoutSeconds int `json:"timeout_seconds"` // 0=不限时 + FirstRunAt *time.Time `json:"first_run_at,omitempty"` // 首次真正开始运行的时刻(非 created_at);nil=尚未运行 + DeadlineAt *time.Time `json:"deadline_at,omitempty"` // = first_run_at + timeout_seconds;nil=不限或未运行 + ActiveRunSeconds int64 `json:"active_run_seconds"` + RunSegmentStartedAt *time.Time `json:"run_segment_started_at,omitempty"` // planner 心跳触发间隔(秒):距上轮 plan 结束/任务开始满该值且期间无触发 → 触发一轮。 // 下限=默认=300(5min),低于一律抬到 300(在 CreateTask 归一)。见 docs/planner-trigger-impl-plan.md PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` @@ -403,11 +405,11 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error { const taskCols = `id, COALESCE(name,''), remark, category_id, COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''), -description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), COALESCE(worker_count,0), first_run_at, deadline_at` +description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), COALESCE(worker_count,0), first_run_at, deadline_at, COALESCE(active_run_seconds,0), run_segment_started_at` func scanTask(sc interface{ Scan(...any) error }) (*Task, error) { var t Task - if err := sc.Scan(&t.ID, &t.Name, &t.Remark, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.WorkerCount, &t.FirstRunAt, &t.DeadlineAt); err != nil { + if err := sc.Scan(&t.ID, &t.Name, &t.Remark, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.WorkerCount, &t.FirstRunAt, &t.DeadlineAt, &t.ActiveRunSeconds, &t.RunSegmentStartedAt); err != nil { return nil, err } t.Pinned = t.PinnedAt != nil @@ -493,10 +495,67 @@ func (d *DB) GetTask(id int64) (*Task, error) { // SetPaused persists a task's paused flag. func (d *DB) SetPaused(id int64, paused bool) error { - _, err := d.Exec(`UPDATE tasks SET paused=$1 WHERE id=$2`, paused, id) + _, _, err := d.SetPausedRuntime(id, paused) return err } +// SetPausedRuntime persists a task's paused flag and, when pausing, folds any +// open execution segment into active_run_seconds. Keeping this in the DB helper +// makes direct callers safe even when they do not go through Manager pause +// orchestration. +func (d *DB) SetPausedRuntime(id int64, paused bool) (int64, *time.Time, error) { + var active int64 + var started *time.Time + err := d.QueryRow(`UPDATE tasks +SET paused=$1, + active_run_seconds=active_run_seconds + CASE WHEN $1 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 $1 THEN NULL ELSE run_segment_started_at END +WHERE id=$2 +RETURNING active_run_seconds, run_segment_started_at`, paused, id).Scan(&active, &started) + if err == sql.ErrNoRows { + return 0, nil, nil + } + return active, started, err +} + +// StartTaskRunSegment begins the currently active execution segment. It is +// idempotent and refuses to start while a task is paused or waiting in the +// concurrency queue. +func (d *DB) StartTaskRunSegment(id int64) (int64, *time.Time, error) { + var active int64 + var started *time.Time + err := d.QueryRow(`UPDATE tasks +SET run_segment_started_at=COALESCE(run_segment_started_at, now()) +WHERE id=$1 AND deleted_at IS NULL AND status IN ('created','running') AND NOT paused AND NOT queued +RETURNING active_run_seconds, run_segment_started_at`, id).Scan(&active, &started) + if err == sql.ErrNoRows { + err = d.QueryRow(`SELECT active_run_seconds, run_segment_started_at FROM tasks WHERE id=$1`, id).Scan(&active, &started) + if err == sql.ErrNoRows { + return 0, nil, nil + } + } + return active, started, err +} + +// FinalizeTaskRunSegment folds the current segment into active_run_seconds. +// A NULL segment makes this a safe no-op for repeated pause/terminal calls. +func (d *DB) FinalizeTaskRunSegment(id int64) (int64, *time.Time, error) { + var active int64 + var started *time.Time + err := d.QueryRow(`UPDATE tasks +SET active_run_seconds=active_run_seconds + GREATEST(0, FLOOR(EXTRACT(EPOCH FROM (now() - run_segment_started_at)))::bigint), + run_segment_started_at=NULL +WHERE id=$1 AND run_segment_started_at IS NOT NULL +RETURNING active_run_seconds, run_segment_started_at`, id).Scan(&active, &started) + if err == sql.ErrNoRows { + err = d.QueryRow(`SELECT active_run_seconds, run_segment_started_at FROM tasks WHERE id=$1`, id).Scan(&active, &started) + if err == sql.ErrNoRows { + return 0, nil, nil + } + } + return active, started, err +} + // Enqueue places a task at the tail of the persistent admission queue. Repeating // the operation while it is already queued keeps its original FIFO position. func (d *DB) Enqueue(id int64, mode string) error { @@ -506,6 +565,8 @@ func (d *DB) Enqueue(id int64, mode string) error { _, err := d.Exec(`UPDATE tasks SET queued=true, queued_at=CASE WHEN queued THEN COALESCE(queued_at, now()) ELSE now() END, + active_run_seconds=active_run_seconds + CASE WHEN 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=NULL, queue_mode=CASE WHEN queue_mode='bootstrap' OR $2='bootstrap' THEN 'bootstrap' ELSE 'resume' @@ -539,12 +600,27 @@ func (d *DB) SetQueued(id int64, queued bool) error { // stable); moving back to a non-terminal state clears it, so a re-run has no stale // finish time. func (d *DB) SetStatus(id int64, status string) error { - _, err := d.Exec(` + _, _, err := d.SetStatusRuntime(id, status) + return err +} + +// SetStatusRuntime updates a task status and returns the committed runtime +// fields from the same UPDATE. Terminal transitions finalize any open segment. +func (d *DB) SetStatusRuntime(id int64, status string) (int64, *time.Time, error) { + var active int64 + var started *time.Time + err := d.QueryRow(` UPDATE tasks SET status = $1, - completed_at = CASE WHEN $1 IN ('done','failed','timeout') THEN COALESCE(completed_at, now()) ELSE NULL END - WHERE id = $2`, status, id) - return err + completed_at = CASE WHEN $1 IN ('done','failed','timeout') THEN COALESCE(completed_at, now()) ELSE NULL END, + active_run_seconds = active_run_seconds + CASE WHEN $1 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 $1 IN ('done','failed','timeout') THEN NULL ELSE run_segment_started_at END + WHERE id = $2 + RETURNING active_run_seconds, run_segment_started_at`, status, id).Scan(&active, &started) + if err == sql.ErrNoRows { + return 0, nil, nil + } + return active, started, err } // SetTerminalStatusGuarded sets a terminal status only when the task is NOT already @@ -552,16 +628,43 @@ UPDATE tasks // Returns won=false (no error) when another terminal status already stuck — the // caller then leaves it alone. Non-terminal transitions / re-run still use SetStatus. func (d *DB) SetTerminalStatusGuarded(id int64, status string) (won bool, err error) { - res, err := d.Exec(` + _, _, won, err = d.SetTerminalStatusGuardedRuntime(id, status) + return won, err +} + +// SetTerminalStatusGuardedRuntime is the runtime-returning form used by the +// Manager so it can mirror the terminal clock before any follow-up hydration. +func (d *DB) SetTerminalStatusGuardedRuntime(id int64, status string) (active int64, started *time.Time, won bool, err error) { + err = d.QueryRow(` UPDATE tasks SET status = $1, - completed_at = COALESCE(completed_at, now()) - WHERE id = $2 AND status NOT IN ('done','failed','timeout')`, status, id) - if err != nil { - return false, err + completed_at = COALESCE(completed_at, now()), + active_run_seconds = active_run_seconds + CASE WHEN 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 = NULL + WHERE id = $2 AND status NOT IN ('done','failed','timeout') + RETURNING active_run_seconds, run_segment_started_at`, status, id).Scan(&active, &started) + if err == sql.ErrNoRows { + // Preserve the authoritative runtime fields for the losing terminal + // writer. The guarded transition itself is still a no-op. + err = d.QueryRow(`SELECT active_run_seconds, run_segment_started_at FROM tasks WHERE id=$1`, id).Scan(&active, &started) + if err == sql.ErrNoRows { + return 0, nil, false, nil + } + return active, started, false, err } - n, _ := res.RowsAffected() - return n > 0, nil + return active, started, err == nil, err +} + +// ClearOpenTaskRunSegments drops execution segments that were live when the +// process stopped. Their wall time is unknowable after a crash or deploy, so +// startup deliberately begins with no live segment; the engine starts a fresh +// segment only when it actually resumes work. +func (d *DB) ClearOpenTaskRunSegments() (int64, error) { + res, err := d.Exec(`UPDATE tasks SET run_segment_started_at=NULL WHERE run_segment_started_at IS NOT NULL`) + if err != nil { + return 0, err + } + return res.RowsAffected() } // StampFirstRun records a task's first-real-run moment and computes its absolute diff --git a/server/dto.go b/server/dto.go index 339c1f2..fabfd65 100644 --- a/server/dto.go +++ b/server/dto.go @@ -44,10 +44,13 @@ type TaskDTO struct { Goal string `json:"goal"` Status string `json:"status"` // created | running | paused | done | failed CreatedAt string `json:"created_at"` - CreatedUnix int64 `json:"created_unix"` // created_at as unix seconds (for run-duration calc) + CreatedUnix int64 `json:"created_unix"` CompletedAt string `json:"completed_at"` // RFC3339 finish time (done/failed); "" if unfinished CompletedUnix int64 `json:"completed_unix"` // completed_at as unix seconds (0 if unfinished) LastActivity int64 `json:"last_activity_unix"` // unix seconds of the last activity (0 if none) + RunSeconds int64 `json:"run_seconds"` + ActiveRunSeconds int64 `json:"active_run_seconds"` + RunSegmentStarted int64 `json:"run_segment_started_unix"` Paused bool `json:"paused"` Queued bool `json:"queued"` Tokens TokenTotalDTO `json:"tokens"` // whole-task token consumption @@ -107,6 +110,7 @@ func tokenTotalDTO(u db.TokenUsage) TokenTotalDTO { func taskDTO(t *Task, status string) TaskDTO { lifecycle := t.lifecycleSnapshot() + runSeconds := taskRunSeconds(lifecycle.ActiveRunSeconds, lifecycle.RunSegmentStartedAt, time.Now().Unix(), status, lifecycle.Paused, lifecycle.Queued) llmState := t.llmStateSnapshot() sourceIDs := make([]string, 0, len(lifecycle.SourceTaskIDs)) for _, id := range lifecycle.SourceTaskIDs { @@ -127,6 +131,9 @@ func taskDTO(t *Task, status string) TaskDTO { Status: status, CreatedAt: rfc3339(time.Unix(t.CreatedAt, 0)), CreatedUnix: t.CreatedAt, + RunSeconds: runSeconds, + ActiveRunSeconds: lifecycle.ActiveRunSeconds, + RunSegmentStarted: lifecycle.RunSegmentStartedAt, CompletedAt: completedRFC(lifecycle.CompletedAt), CompletedUnix: lifecycle.CompletedAt, Paused: lifecycle.Paused, diff --git a/server/engine.go b/server/engine.go index 7c61b74..04f610c 100644 --- a/server/engine.go +++ b/server/engine.go @@ -937,6 +937,9 @@ func (e *Engine) plannerLoop(ctx context.Context, t *Task) { return } defer e.decInflight(t.ID) + if err := e.m.StartTaskRunSegment(t.ID); err != nil { + log.Printf("[runtime] task %s start segment failed: %v", t.ID, err) + } e.stampFirstRun(t) // 首次真正规划 → 盖 first_run_at + 算 deadline(仅带 timeout 的任务) e.touch(t.ID) emit := func(r db.Activity) { e.emitActivity(t, r) } @@ -1086,6 +1089,9 @@ func (e *Engine) runIntent(ctx context.Context, t *Task, name string, worker *ag } return true } + if err := e.m.StartTaskRunSegment(t.ID); err != nil { + log.Printf("[runtime] task %s start segment failed: %v", t.ID, err) + } // per-work child context so the planner's kill_work can stop just this work. workCtx, workCancel := context.WithCancelCause(ectx) e.registerWork(intent.ID, workCancel) diff --git a/server/goals.go b/server/goals.go index 4603149..45b67f6 100644 --- a/server/goals.go +++ b/server/goals.go @@ -252,6 +252,11 @@ func (s *Server) hasReadyQueuedTask(excludeID string) bool { } func (s *Server) startAdmittedTask(t *Task, mode string) { + if s.engine.ReadyFor(t) { + if err := s.m.StartTaskRunSegment(t.ID); err != nil { + log.Printf("[runtime] task %s start segment failed: %v", t.ID, err) + } + } if mode == "bootstrap" { if !s.engine.beginTaskOperation(t.ID) { return diff --git a/server/manager.go b/server/manager.go index 5718eb3..bd7b1f3 100644 --- a/server/manager.go +++ b/server/manager.go @@ -63,6 +63,8 @@ type Task struct { WorkerCount int `json:"worker_count"` // 本任务 Worker 数;0=用全局默认 FirstRunAt int64 `json:"first_run_at,omitempty"` DeadlineAt int64 `json:"deadline_at,omitempty"` + ActiveRunSeconds int64 `json:"active_run_seconds"` + RunSegmentStartedAt int64 `json:"run_segment_started_at,omitempty"` Store *pgdb.ExplorationStore `json:"-"` Guard *guard.Guard `json:"-"` notify chan struct{} @@ -81,21 +83,23 @@ type Task struct { // updateLifecycle instead of reading or writing the corresponding Task fields // directly after the task has been published by Manager. type taskLifecycleState struct { - Name string - Remark string - PinnedAt int64 - Status string - Paused bool - Queued bool - QueuedAt int64 - QueueMode string - CompletedAt int64 - FirstRunAt int64 - DeadlineAt int64 - SourceTaskIDs []int64 - CompanyIDs []int64 - CategoryID *int64 - CategoryName string + Name string + Remark string + PinnedAt int64 + Status string + Paused bool + Queued bool + QueuedAt int64 + QueueMode string + CompletedAt int64 + FirstRunAt int64 + DeadlineAt int64 + ActiveRunSeconds int64 + RunSegmentStartedAt int64 + SourceTaskIDs []int64 + CompanyIDs []int64 + CategoryID *int64 + CategoryName string } func (t *Task) lifecycleSnapshot() taskLifecycleState { @@ -109,21 +113,23 @@ func (t *Task) lifecycleSnapshot() taskLifecycleState { func (t *Task) lifecycleSnapshotLocked() taskLifecycleState { return taskLifecycleState{ - Name: t.Name, - Remark: t.Remark, - PinnedAt: t.PinnedAt, - Status: t.Status, - Paused: t.Paused, - Queued: t.Queued, - QueuedAt: t.QueuedAt, - QueueMode: t.QueueMode, - CompletedAt: t.CompletedAt, - FirstRunAt: t.FirstRunAt, - DeadlineAt: t.DeadlineAt, - SourceTaskIDs: append([]int64(nil), t.SourceTaskIDs...), - CompanyIDs: append([]int64(nil), t.CompanyIDs...), - CategoryID: cloneInt64Ptr(t.CategoryID), - CategoryName: t.CategoryName, + Name: t.Name, + Remark: t.Remark, + PinnedAt: t.PinnedAt, + Status: t.Status, + Paused: t.Paused, + Queued: t.Queued, + QueuedAt: t.QueuedAt, + QueueMode: t.QueueMode, + CompletedAt: t.CompletedAt, + FirstRunAt: t.FirstRunAt, + DeadlineAt: t.DeadlineAt, + ActiveRunSeconds: t.ActiveRunSeconds, + RunSegmentStartedAt: t.RunSegmentStartedAt, + SourceTaskIDs: append([]int64(nil), t.SourceTaskIDs...), + CompanyIDs: append([]int64(nil), t.CompanyIDs...), + CategoryID: cloneInt64Ptr(t.CategoryID), + CategoryName: t.CategoryName, } } @@ -145,6 +151,8 @@ func (t *Task) updateLifecycle(update func(*taskLifecycleState)) { t.CompletedAt = state.CompletedAt t.FirstRunAt = state.FirstRunAt t.DeadlineAt = state.DeadlineAt + t.ActiveRunSeconds = state.ActiveRunSeconds + t.RunSegmentStartedAt = state.RunSegmentStartedAt t.SourceTaskIDs = append(t.SourceTaskIDs[:0], state.SourceTaskIDs...) t.CompanyIDs = append(t.CompanyIDs[:0], state.CompanyIDs...) t.CategoryID = cloneInt64Ptr(state.CategoryID) @@ -525,6 +533,13 @@ func NewManager(dir, proxyAddr string) (*Manager, error) { if err != nil { return nil, err } + // A live segment cannot be trusted across a process restart: its end time + // could be deployment/crash downtime. Clear it before tasks are restored so + // the next engine admission starts a fresh segment at actual execution time. + if _, err := pg.ClearOpenTaskRunSegments(); err != nil { + _ = pg.Close() + return nil, fmt.Errorf("clear stale task run segments: %w", err) + } if err := pg.RecoverFindingRetests(); err != nil { pg.Close() return nil, fmt.Errorf("recover finding retests: %w", err) @@ -1009,6 +1024,7 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter TimeoutSeconds: pt.TimeoutSeconds, PlanHeartbeatSeconds: pt.PlanHeartbeatSeconds, CoverageEnabled: pt.CoverageEnabled, WorkerCount: pt.WorkerCount, FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt), + ActiveRunSeconds: pt.ActiveRunSeconds, RunSegmentStartedAt: unixOrZero(pt.RunSegmentStartedAt), Store: store, Guard: guard.NewWithInterceptor(ic), notify: make(chan struct{}, 1), } } @@ -1289,13 +1305,16 @@ func (m *Manager) SetTaskPaused(id string, paused bool) error { if err != nil { return err } - if err := m.pg.SetPaused(n, paused); err != nil { + active, started, err := m.pg.SetPausedRuntime(n, paused) + if err != nil { return err } m.mu.Lock() if t := m.tasks[id]; t != nil { t.updateLifecycle(func(state *taskLifecycleState) { state.Paused = paused + state.ActiveRunSeconds = active + state.RunSegmentStartedAt = unixOrZero(started) }) } m.mu.Unlock() @@ -1331,7 +1350,8 @@ func (m *Manager) applyTaskAdmission(id, expectedStatus, status string, queued b mode = "" } - var queuedAt, completedAt, firstRunAt, deadlineAt sql.NullTime + var queuedAt, completedAt, firstRunAt, deadlineAt, segmentStartedAt sql.NullTime + var activeRunSeconds int64 var committedMode string err = m.pg.QueryRow(`UPDATE tasks SET status=$2, @@ -1360,9 +1380,11 @@ func (m *Manager) applyTaskAdmission(id, expectedStatus, status string, queued b WHEN $6='timeout' AND $2 NOT IN ('done','failed','timeout') THEN NULL ELSE deadline_at END + ,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`, n, status, queued, mode, preservePosition, expectedStatus, manualUntil). - Scan(&queuedAt, &committedMode, &completedAt, &firstRunAt, &deadlineAt) + 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). + Scan(&queuedAt, &committedMode, &completedAt, &firstRunAt, &deadlineAt, &activeRunSeconds, &segmentStartedAt) if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("task %s lifecycle changed before admission (expected status %q)", id, expectedStatus) } @@ -1393,6 +1415,11 @@ func (m *Manager) applyTaskAdmission(id, expectedStatus, status string, queued b if deadlineAt.Valid { state.DeadlineAt = deadlineAt.Time.Unix() } + state.ActiveRunSeconds = activeRunSeconds + state.RunSegmentStartedAt = 0 + if segmentStartedAt.Valid { + state.RunSegmentStartedAt = segmentStartedAt.Time.Unix() + } }) } m.mu.Unlock() @@ -1413,11 +1440,15 @@ func (m *Manager) ApplyTaskPauseOrigin(id string, scheduled bool) error { return err } var mode string + var activeRunSeconds int64 + var segmentStartedAt sql.NullTime err = m.pg.QueryRow(`UPDATE tasks - SET paused=true, queued=false, queued_at=NULL, schedule_paused=$2, schedule_manual_until=NULL + SET paused=true, queued=false, queued_at=NULL, schedule_paused=$2, schedule_manual_until=NULL, + active_run_seconds=active_run_seconds + CASE WHEN 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=NULL WHERE id=$1 AND deleted_at IS NULL AND paused=false AND status NOT IN ('done','failed','timeout') - RETURNING COALESCE(queue_mode,'')`, n, scheduled).Scan(&mode) + RETURNING COALESCE(queue_mode,''), active_run_seconds, run_segment_started_at`, n, scheduled).Scan(&mode, &activeRunSeconds, &segmentStartedAt) if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("task %s is unavailable for pause", id) } @@ -1431,6 +1462,11 @@ func (m *Manager) ApplyTaskPauseOrigin(id string, scheduled bool) error { state.Queued = false state.QueuedAt = 0 state.QueueMode = mode + state.ActiveRunSeconds = activeRunSeconds + state.RunSegmentStartedAt = 0 + if segmentStartedAt.Valid { + state.RunSegmentStartedAt = segmentStartedAt.Time.Unix() + } }) } m.mu.Unlock() @@ -1449,16 +1485,20 @@ func (m *Manager) EnqueueTask(id, mode string) error { return fmt.Errorf("invalid queue mode %q", mode) } var queuedAt time.Time + var activeRunSeconds int64 + var segmentStartedAt sql.NullTime var committedMode string err = m.pg.QueryRow(`UPDATE tasks SET queued=true, queued_at=CASE WHEN queued THEN COALESCE(queued_at, now()) ELSE now() END, + active_run_seconds=active_run_seconds + CASE WHEN 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=NULL, queue_mode=CASE WHEN queue_mode='bootstrap' OR $2='bootstrap' THEN 'bootstrap' ELSE 'resume' END WHERE id=$1 AND deleted_at IS NULL - RETURNING queued_at, queue_mode`, n, mode).Scan(&queuedAt, &committedMode) + RETURNING queued_at, queue_mode, active_run_seconds, run_segment_started_at`, n, mode).Scan(&queuedAt, &committedMode, &activeRunSeconds, &segmentStartedAt) if errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("task %s is unavailable for enqueue", id) } @@ -1471,6 +1511,80 @@ func (m *Manager) EnqueueTask(id, mode string) error { state.QueuedAt = queuedAt.UnixNano() state.Queued = true state.QueueMode = committedMode + state.ActiveRunSeconds = activeRunSeconds + state.RunSegmentStartedAt = 0 + if segmentStartedAt.Valid { + state.RunSegmentStartedAt = segmentStartedAt.Time.Unix() + } + }) + } + m.mu.Unlock() + return nil +} + +// StartTaskRunSegment records the start of actual execution, once the engine +// has admitted a task and is about to run planner/worker work. +func (m *Manager) StartTaskRunSegment(id string) error { + m.taskStateMu.Lock() + defer m.taskStateMu.Unlock() + n, err := strconv.ParseInt(id, 10, 64) + if err != nil { + return err + } + m.mu.RLock() + if t := m.tasks[id]; t != nil && t.lifecycleSnapshot().RunSegmentStartedAt > 0 { + m.mu.RUnlock() + return nil + } + m.mu.RUnlock() + active, started, err := m.pg.StartTaskRunSegment(n) + if err != nil { + return err + } + if started != nil { + m.mirrorTaskRuntime(id, active, started) + } + return nil +} + +// FinalizeTaskRunSegment is idempotent and is used by pause/terminal paths that +// may race with a late engine cancellation. +func (m *Manager) FinalizeTaskRunSegment(id string) error { + m.taskStateMu.Lock() + defer m.taskStateMu.Unlock() + n, err := strconv.ParseInt(id, 10, 64) + if err != nil { + return err + } + active, started, err := m.pg.FinalizeTaskRunSegment(n) + if err != nil { + return err + } + m.mirrorTaskRuntime(id, active, started) + return nil +} + +func (m *Manager) mirrorTaskRuntime(id string, active int64, started *time.Time) { + m.mu.Lock() + defer m.mu.Unlock() + if t := m.tasks[id]; t != nil { + t.updateLifecycle(func(state *taskLifecycleState) { + state.ActiveRunSeconds = active + state.RunSegmentStartedAt = unixOrZero(started) + }) + } +} + +func (m *Manager) syncTaskRuntime(id string, n int64) error { + pt, err := m.pg.GetTask(n) + if err != nil || pt == nil { + return err + } + m.mu.Lock() + if t := m.tasks[id]; t != nil { + t.updateLifecycle(func(state *taskLifecycleState) { + state.ActiveRunSeconds = pt.ActiveRunSeconds + state.RunSegmentStartedAt = unixOrZero(pt.RunSegmentStartedAt) }) } m.mu.Unlock() @@ -1559,20 +1673,30 @@ func (m *Manager) SetTaskStatusGuarded(id, status string) (won bool, err error) if err != nil { return false, err } - won, err = m.pg.SetTerminalStatusGuarded(n, status) + active, started, won, err := m.pg.SetTerminalStatusGuardedRuntime(n, status) if err != nil || !won { return won, err } + // Mirror the committed terminal transition before any optional hydration. + // This keeps the in-memory clock stopped even if GetTask later fails. m.mu.Lock() if t := m.tasks[id]; t != nil { t.updateLifecycle(func(state *taskLifecycleState) { state.Status = status - if state.CompletedAt == 0 { - state.CompletedAt = time.Now().Unix() + state.ActiveRunSeconds = active + state.RunSegmentStartedAt = unixOrZero(started) + if pgdb.IsTerminal(status) { + state.RunSegmentStartedAt = 0 + if state.CompletedAt == 0 { + state.CompletedAt = time.Now().Unix() + } } }) } m.mu.Unlock() + if err := m.syncTaskRuntime(id, n); err != nil { + return true, err + } return true, nil } @@ -1585,16 +1709,20 @@ func (m *Manager) SetTaskStatus(id, status string) error { if err != nil { return err } - if err := m.pg.SetStatus(n, status); err != nil { + active, started, err := m.pg.SetStatusRuntime(n, status) + if err != nil { return err } + // Mirror the successful UPDATE immediately. A later GetTask hydration is + // best-effort and must not reopen a terminal clock in memory. m.mu.Lock() if t := m.tasks[id]; t != nil { t.updateLifecycle(func(state *taskLifecycleState) { state.Status = status - // Mirror the DB's completed_at stamp on the live handle so the DTO shows - // the finish time without a reload (terminal -> stamp once; else clear). + state.ActiveRunSeconds = active + state.RunSegmentStartedAt = unixOrZero(started) if pgdb.IsTerminal(status) { + state.RunSegmentStartedAt = 0 if state.CompletedAt == 0 { state.CompletedAt = time.Now().Unix() } @@ -1604,6 +1732,9 @@ func (m *Manager) SetTaskStatus(id, status string) error { }) } m.mu.Unlock() + if err := m.syncTaskRuntime(id, n); err != nil { + return err + } return nil } diff --git a/server/orchestration.go b/server/orchestration.go index bca694b..33052c6 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -175,10 +175,9 @@ func (s *Server) delegateToTask(ctx context.Context, in json.RawMessage, pick fu func (s *Server) toolListTasks() actool.CoreTool { return roTool("list_tasks", - "列出所有任务(id/描述/目标/状态/运行时长/父任务/LLM 配置),编排 agent 用它掌握全局、看哪些任务卡太久、各自用哪个 LLM。运行时长:运行中=创建→现在,终态=创建→最后活动(秒)。llm_profile:任务 planner/worker 用的配置名,(激活配置)=跟随全局激活。", + "列出所有任务(id/描述/目标/状态/运行时长/父任务/LLM 配置),编排 agent 用它掌握全局、看哪些任务卡太久、各自用哪个 LLM。运行时长只累计实际执行时间,暂停、排队和终态后的时间不计入。llm_profile:任务 planner/worker 用的配置名,(激活配置)=跟随全局激活。", objSchema(map[string]any{}), func(context.Context, json.RawMessage) (actool.Result, error) { - lastAct, _ := s.m.PG().LastActivityAll() // id -> name to resolve each task's pinned LLM profile. profName := map[int64]string{} if profs, err := s.m.pg.ListProfiles(); err == nil { @@ -189,16 +188,8 @@ func (s *Server) toolListTasks() actool.CoreTool { out := make([]map[string]any, 0) for _, t := range s.m.List() { status := s.deriveTaskStatus(t) - end := lastAct[t.ExpID] - if live := s.engine.LastActivity(t.ID); live > end { - end = live - } - dur := int64(0) - if status == "running" { - dur = time.Now().Unix() - t.CreatedAt - } else if end > t.CreatedAt { - dur = end - t.CreatedAt - } + lifecycle := t.lifecycleSnapshot() + dur := taskRunSeconds(lifecycle.ActiveRunSeconds, lifecycle.RunSegmentStartedAt, time.Now().Unix(), status, lifecycle.Paused, lifecycle.Queued) row := map[string]any{"id": t.ID, "description": t.Description, "goal": t.Goal, "status": status, "run_seconds": dur} if t.ParentRef != "" { row["parent_ref"] = t.ParentRef diff --git a/server/server.go b/server/server.go index c0c7dad..6ee0cf5 100644 --- a/server/server.go +++ b/server/server.go @@ -1138,7 +1138,7 @@ func (s *Server) listTasks(w http.ResponseWriter, r *http.Request) { metric := metrics[t.ExpID] dto.Tokens = tokenTotalDTO(metric.Tokens) // prefer the live in-memory heartbeat (fresher) and fall back to the - // persisted max activity time (survives restarts) for run-duration display. + // persisted max activity time (survives restarts) for activity display. dto.LastActivity = metric.LastActivity if live := s.engine.LastActivity(t.ID); live > dto.LastActivity { dto.LastActivity = live diff --git a/server/task_runtime.go b/server/task_runtime.go new file mode 100644 index 0000000..4137d84 --- /dev/null +++ b/server/task_runtime.go @@ -0,0 +1,25 @@ +package server + +// 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 { + if active < 0 { + active = 0 + } + if status == "running" && !paused && !queued && segmentStarted > 0 && now > segmentStarted { + return active + now - segmentStarted + } + return active +} + +// finalizeTaskRunSeconds mirrors the database transition for deterministic unit +// tests and callers that need to reason about a segment without a live database. +func finalizeTaskRunSeconds(active, segmentStarted, now int64) (int64, int64) { + if active < 0 { + active = 0 + } + if segmentStarted > 0 && now > segmentStarted { + active += now - segmentStarted + } + return active, 0 +} diff --git a/server/task_runtime_test.go b/server/task_runtime_test.go new file mode 100644 index 0000000..9f5c9c0 --- /dev/null +++ b/server/task_runtime_test.go @@ -0,0 +1,35 @@ +package server + +import "testing" + +func TestTaskRunSecondsRunningSegmentAccumulates(t *testing.T) { + if got := taskRunSeconds(12, 100, 145, "running", false, false); got != 57 { + t.Fatalf("running duration = %d, want 57", got) + } +} + +func TestTaskRunSecondsPausedDoesNotGrow(t *testing.T) { + if got := taskRunSeconds(57, 100, 1000, "paused", true, false); got != 57 { + t.Fatalf("paused duration = %d, want 57", got) + } + if got := taskRunSeconds(57, 100, 1000, "running", false, true); got != 57 { + t.Fatalf("queued duration = %d, want 57", got) + } +} + +func TestTaskRunSecondsResumeContinuesFromPriorTotal(t *testing.T) { + active, segment := finalizeTaskRunSeconds(12, 100, 145) + if active != 57 || segment != 0 { + t.Fatalf("finalized segment = (%d,%d), want (57,0)", active, segment) + } + if got := taskRunSeconds(active, 200, 230, "running", false, false); got != 87 { + t.Fatalf("resumed duration = %d, want 87", got) + } +} + +func TestTaskRunSecondsTerminalFinalizesSegment(t *testing.T) { + active, segment := finalizeTaskRunSeconds(20, 500, 560) + if active != 80 || segment != 0 { + t.Fatalf("terminal finalization = (%d,%d), want (80,0)", active, segment) + } +} diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index 3737d6d..df88c10 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -188,19 +188,15 @@ function fmtDuration(sec: number): string { return `${s}s`; } -// taskDuration is the task's run duration in seconds: created → now while running, -// created → completion for a finished task, else created → last activity. 0 when it -// never ran (no activity yet). +// taskDuration counts only active execution segments. A live segment ticks locally; +// paused, queued, and terminal tasks stay fixed at their persisted active total. function taskDuration(task: Task, nowSec: number): number { - const start = task.created_unix ?? 0; - if (!start) return 0; - const end = - task.status === "running" - ? nowSec - : task.completed_unix && task.completed_unix > 0 - ? task.completed_unix - : (task.last_activity_unix ?? 0); - return end > start ? end - start : 0; + const active = Math.max(0, Math.floor(task.active_run_seconds ?? 0)); + const segmentStart = task.run_segment_started_unix ?? 0; + if (task.status === "running" && !task.paused && !task.queued && segmentStart > 0) { + return active + Math.max(0, Math.floor(nowSec - segmentStart)); + } + return active; } // deleteDetails takes only the countable part of the result so callers can pass an diff --git a/web/src/lib/mock/data.ts b/web/src/lib/mock/data.ts index 96dea32..8c31178 100644 --- a/web/src/lib/mock/data.ts +++ b/web/src/lib/mock/data.ts @@ -60,6 +60,9 @@ export const tasks: Task[] = [ status: "running", created_at: T("2026-07-24T09:12:00Z"), created_unix: 1785489120, + active_run_seconds: 28800, + run_segment_started_unix: 1785643102, + run_seconds: 28800, last_activity: T("2026-07-26T03:58:22Z"), last_activity_unix: 1785643102, paused: false, @@ -86,6 +89,9 @@ export const tasks: Task[] = [ status: "running", created_at: T("2026-07-25T14:05:00Z"), created_unix: 1785592500, + active_run_seconds: 14400, + run_segment_started_unix: 1785642010, + run_seconds: 14400, last_activity: T("2026-07-26T03:40:10Z"), last_activity_unix: 1785642010, paused: false, @@ -112,6 +118,9 @@ export const tasks: Task[] = [ status: "paused", created_at: T("2026-07-22T08:30:00Z"), created_unix: 1785313800, + active_run_seconds: 21600, + run_segment_started_unix: 0, + run_seconds: 21600, last_activity: T("2026-07-23T18:40:10Z"), last_activity_unix: 1785436810, paused: true, @@ -137,6 +146,9 @@ export const tasks: Task[] = [ status: "done", created_at: T("2026-07-18T08:00:00Z"), created_unix: 1784966400, + active_run_seconds: 86400, + run_segment_started_unix: 0, + run_seconds: 86400, completed_at: T("2026-07-20T22:11:00Z"), completed_unix: 1785190260, last_activity: T("2026-07-20T22:11:00Z"), diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index c979006..85b1882 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -951,6 +951,11 @@ function controlMockTask(id: string, action: "pause" | "resume"): BatchControlIt task.queued = false; task.status = "paused"; task.engine_mode = "paused"; + if (task.run_segment_started_unix && task.run_segment_started_unix > 0) { + task.active_run_seconds = (task.active_run_seconds ?? 0) + Math.max(0, Math.floor(Date.now() / 1000) - task.run_segment_started_unix); + task.run_seconds = task.active_run_seconds; + } + task.run_segment_started_unix = 0; } else { if (!task.paused && task.status !== "paused") { return { id, ok: false, status: task.status, error: "任务未暂停" }; @@ -959,6 +964,7 @@ function controlMockTask(id: string, action: "pause" | "resume"): BatchControlIt task.queued = false; task.status = "running"; task.engine_mode = "exploring"; + task.run_segment_started_unix = Math.floor(Date.now() / 1000); } return { id, ok: true, status: task.status, queued: false }; } @@ -1248,6 +1254,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re status: "created", created_at: now.toISOString(), created_unix: Math.floor(now.getTime() / 1000), + active_run_seconds: 0, + run_segment_started_unix: 0, + run_seconds: 0, paused: false, active: true, in_flight: 0, diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 0e44355..2ff5cd1 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -34,10 +34,13 @@ export interface Task { goal: string; status: TaskStatus; created_at: string; - created_unix?: number; // created_at as unix seconds (run-duration calc) + created_unix?: number; completed_at?: string; // RFC3339 finish time (done/failed); "" if unfinished completed_unix?: number; // completed_at as unix seconds (0/undef if unfinished) last_activity_unix?: number; // unix seconds of the last activity (0/undef if none) + run_seconds?: number; // backend-computed active runtime seconds + active_run_seconds?: number; // completed running segments in seconds + run_segment_started_unix?: number; // current running segment start; 0 when inactive paused?: boolean; queued?: boolean; active?: boolean;