Merge pull request #2 from BaaRai/fix/pause-preserves-timeout-budget

fix(tasks): freeze the timeout budget while a task is paused
This commit is contained in:
BaaRai
2026-10-09 11:46:18 +02:00
committed by GitHub
4 changed files with 92 additions and 4 deletions
+4
View File
@@ -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;
+1 -1
View File
@@ -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
+9 -3
View File
@@ -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)
+78
View File
@@ -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())
}
}