diff --git a/db/schema.sql b/db/schema.sql index 2df3959..bfb8d79 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -554,6 +554,10 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS coverage_enabled BOOLEAN NOT NULL DEF -- exploration that already owns goals/history. ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued_at TIMESTAMPTZ; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queue_mode TEXT NOT NULL DEFAULT ''; +-- Pause freeze for the task timeout budget: stamped when a task is paused; on +-- admission out of the paused state deadline_at is shifted forward by the paused +-- interval (now() - paused_at) so pause time does not consume the timeout (#1). +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ; -- Optional task name; backfills old databases. Empty string=unnamed; the frontend falls back to the description. ALTER TABLE tasks ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT ''; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL; diff --git a/db/tasks.go b/db/tasks.go index f235578..d84c315 100644 --- a/db/tasks.go +++ b/db/tasks.go @@ -41,7 +41,7 @@ type Task struct { // Task-level timeout (see the task-level timeout and wrap-up design doc). TimeoutSeconds int `json:"timeout_seconds"` // 0=no time limit FirstRunAt *time.Time `json:"first_run_at,omitempty"` // when it first actually started running (not created_at); nil=not yet run - DeadlineAt *time.Time `json:"deadline_at,omitempty"` // = first_run_at + timeout_seconds; nil=unlimited or not yet run + DeadlineAt *time.Time `json:"deadline_at,omitempty"` // = first_run_at + timeout_seconds, shifted forward by paused intervals on resume; nil=unlimited or not yet run // planner heartbeat trigger interval (seconds): when this much time has passed since the // last plan round ended / the task started, with no trigger in between → trigger a round. // Floor = default = 300 (5min); anything lower is raised to 300 (normalized in CreateTask). See docs/planner-trigger-impl-plan.md diff --git a/server/manager.go b/server/manager.go index 414837f..c28b4a0 100644 --- a/server/manager.go +++ b/server/manager.go @@ -1187,8 +1187,12 @@ func (m *Manager) ApplyTaskAdmission(id, expectedStatus, status string, queued b END, deadline_at=CASE WHEN $6='timeout' AND $2 NOT IN ('done','failed','timeout') THEN NULL + -- Leaving the paused state: credit the paused interval back onto the + -- deadline so the timeout budget only counts running time (#1). + WHEN paused_at IS NOT NULL AND deadline_at IS NOT NULL THEN deadline_at + (now() - paused_at) ELSE deadline_at - END + END, + paused_at=NULL 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). Scan(&queuedAt, &committedMode, &completedAt, &firstRunAt, &deadlineAt) @@ -1229,7 +1233,9 @@ func (m *Manager) ApplyTaskAdmission(id, expectedStatus, status string, queued b } // ApplyTaskPause atomically removes a task from the admission queue and records -// the user pause. queue_mode is intentionally retained so resuming a never-run +// the user pause. paused_at is stamped so the admission that follows can credit +// the paused interval back onto deadline_at — the timeout budget must not burn +// while the task is parked (#1). queue_mode is intentionally retained so resuming a never-run // bootstrap task still performs goal decomposition, but the next enqueue receives // a new queued_at timestamp and therefore moves to the FIFO tail. func (m *Manager) ApplyTaskPause(id string) error { @@ -1241,7 +1247,7 @@ func (m *Manager) ApplyTaskPause(id string) error { } var mode string err = m.pg.QueryRow(`UPDATE tasks - SET paused=true, queued=false, queued_at=NULL + SET paused=true, queued=false, queued_at=NULL, paused_at=now() WHERE id=$1 AND deleted_at IS NULL AND paused=false AND status NOT IN ('done','failed','timeout') RETURNING COALESCE(queue_mode,'')`, n).Scan(&mode) diff --git a/server/task_pause_deadline_test.go b/server/task_pause_deadline_test.go new file mode 100644 index 0000000..de1b056 --- /dev/null +++ b/server/task_pause_deadline_test.go @@ -0,0 +1,78 @@ +package server + +import ( + "database/sql" + "strconv" + "testing" + "time" +) + +// Pausing a task must freeze its wall-clock timeout budget: the admission that +// ends the pause credits the paused interval back onto deadline_at, so a task +// paused overnight (or while the machine is off) resumes with its remaining +// time instead of immediately settling into the timeout terminal state (#1). +func TestPauseShiftsTaskDeadlineOnResume(t *testing.T) { + s, _, _ := trafficEvidenceServer(t) + m := s.m + pg := s.m.pg + + task, err := m.CreateTask("pause deadline test", "verify the deadline is frozen while paused", nil, 3600, 0) + if err != nil { + t.Fatal(err) + } + id, err := strconv.ParseInt(task.ID, 10, 64) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { pg.Exec(`DELETE FROM tasks WHERE id=$1`, id) }) + + // A running task with a stamped deadline (first_run_at + 1h budget). + if _, err := m.StampTaskFirstRun(task.ID); err != nil { + t.Fatal(err) + } + if _, err := pg.Exec(`UPDATE tasks SET status='running' WHERE id=$1`, id); err != nil { + t.Fatal(err) + } + var before time.Time + if err := pg.QueryRow(`SELECT deadline_at FROM tasks WHERE id=$1`, id).Scan(&before); err != nil { + t.Fatal(err) + } + + // Pause stamps paused_at alongside the user pause. + if err := m.ApplyTaskPause(task.ID); err != nil { + t.Fatal(err) + } + var pausedAt sql.NullTime + if err := pg.QueryRow(`SELECT paused_at FROM tasks WHERE id=$1`, id).Scan(&pausedAt); err != nil { + t.Fatal(err) + } + if !pausedAt.Valid || pausedAt.Time.After(time.Now()) { + t.Fatalf("pause should stamp paused_at at the current time, got %v", pausedAt.Time) + } + + // Keep the task parked long enough for the credited interval to be measurable. + const paused = 1100 * time.Millisecond + time.Sleep(paused) + + // Admission out of the paused state (a paused task keeps its lifecycle status). + if err := m.ApplyTaskAdmission(task.ID, "running", "running", false, "resume", false); err != nil { + t.Fatal(err) + } + + var after time.Time + var cleared sql.NullTime + if err := pg.QueryRow(`SELECT deadline_at, paused_at FROM tasks WHERE id=$1`, id).Scan(&after, &cleared); err != nil { + t.Fatal(err) + } + if shifted := after.Sub(before); shifted < paused-100*time.Millisecond { + t.Fatalf("resume should credit the paused interval onto the deadline, got shift=%v (paused=%v)", shifted, paused) + } + if cleared.Valid { + t.Fatalf("admission should clear paused_at, got %v", cleared.Time) + } + // The live handle must mirror the shifted deadline (the engine clock context + // is built from it). + if got := task.lifecycleSnapshot().DeadlineAt; got != after.Unix() { + t.Fatalf("live handle deadline = %d, want the shifted %d", got, after.Unix()) + } +}