diff --git a/db/schema.sql b/db/schema.sql index 71f948c..ee0b8bb 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -526,6 +526,7 @@ CREATE TABLE IF NOT EXISTS tasks ( parent_ref TEXT, timeout_seconds INTEGER NOT NULL DEFAULT 0, plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300, + plan_quiet_mode BOOLEAN NOT NULL DEFAULT true, coverage_enabled BOOLEAN NOT NULL DEFAULT true, pinned_at TIMESTAMPTZ, first_run_at TIMESTAMPTZ, @@ -543,6 +544,7 @@ CREATE TRIGGER trg_tasks_upd BEFORE UPDATE ON tasks FOR EACH ROW EXECUTE FUNCTION set_updated_at(); -- planner heartbeat trigger interval (seconds); backfills old databases. Default 300s (5min). See docs/planner-trigger-impl-plan.md ALTER TABLE tasks ADD COLUMN IF NOT EXISTS plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS plan_quiet_mode BOOLEAN NOT NULL DEFAULT true; -- Concurrency-limit hold state; backfills old databases. true=queued due to the concurrency limit, auto-starts when a slot frees up. ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queued BOOLEAN NOT NULL DEFAULT false; -- Asset coverage feature switch; backfills old databases. true (default)=compute/show test coverage, auto-accumulate test scope, diff --git a/db/tasks.go b/db/tasks.go index d84c315..afc52c2 100644 --- a/db/tasks.go +++ b/db/tasks.go @@ -46,6 +46,9 @@ type Task struct { // 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 PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` + // PlanQuietMode gates the planner quiet-mode (#86): edge-triggered rounds + // are skipped while work is in flight and the open-goal set is unchanged. + PlanQuietMode bool `json:"plan_quiet_mode"` // CoverageEnabled is the master switch of the "asset coverage" feature (default true). When false: // do not compute/show test coverage, do not auto-accumulate task_scope (source=auto), do not open // add_task_scope/list_untested_assets to the agent, and do not inject the coverage block into the @@ -154,6 +157,8 @@ type TaskCreateOptions struct { LLMProfileIDs []int64 TimeoutSeconds int PlanHeartbeatSeconds int + // PlanQuietMode is the per-task quiet-mode switch; nil = default on (#86). + PlanQuietMode *bool // CoverageEnabled is the "asset coverage" feature switch; nil=default on (true), letting // creation paths that do not care about the switch (orchestration spawn, old API) keep the // original behavior. Only web task creation can explicitly pass false to turn it off. @@ -219,6 +224,10 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload)); id := opts.LLMProfileIDs[0] active = &id } + quietMode := true + if opts.PlanQuietMode != nil { + quietMode = *opts.PlanQuietMode + } t := &Task{ Name: opts.Name, CategoryID: opts.CategoryID, CategoryName: categoryName, Description: description, Goal: goal, ExplorationID: expID, @@ -227,12 +236,13 @@ VALUES ($1, 'fact', $2, 0, 'origin', 'system')`, expID, string(originPayload)); SourceTaskIDs: append([]int64(nil), opts.SourceTaskIDs...), CompanyIDs: append([]int64(nil), opts.CompanyIDs...), TimeoutSeconds: opts.TimeoutSeconds, PlanHeartbeatSeconds: opts.PlanHeartbeatSeconds, + PlanQuietMode: quietMode, CoverageEnabled: coverageEnabled, } if err := tx.QueryRow(` -INSERT INTO tasks(name, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, coverage_enabled) -VALUES ($1,$2,$3,$4,$5,$6,$6,$7,$8,$9) -RETURNING id, status, paused, created_at`, opts.Name, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, coverageEnabled).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil { +INSERT INTO tasks(name, category_id, description, goal, exploration_id, llm_profile_id, active_llm_profile_id, timeout_seconds, plan_heartbeat_seconds, plan_quiet_mode, coverage_enabled) +VALUES ($1,$2,$3,$4,$5,$6,$6,$7,$8,$9,$10) +RETURNING id, status, paused, created_at`, opts.Name, opts.CategoryID, description, goal, expID, active, opts.TimeoutSeconds, opts.PlanHeartbeatSeconds, quietMode, coverageEnabled).Scan(&t.ID, &t.Status, &t.Paused, &t.CreatedAt); err != nil { return nil, err } if err := insertTaskRelations(tx, t.ID, opts.SourceTaskIDs); err != nil { @@ -348,11 +358,11 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error { const taskCols = `id, COALESCE(name,''), 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), 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(plan_quiet_mode,true), COALESCE(coverage_enabled,true), first_run_at, deadline_at` func scanTask(sc interface{ Scan(...any) error }) (*Task, error) { var t Task - if err := sc.Scan(&t.ID, &t.Name, &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.FirstRunAt, &t.DeadlineAt); err != nil { + if err := sc.Scan(&t.ID, &t.Name, &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.PlanQuietMode, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil { return nil, err } t.Pinned = t.PinnedAt != nil @@ -396,6 +406,8 @@ ORDER BY (pinned_at IS NOT NULL) DESC, pinned_at DESC NULLS LAST, id DESC`) type TaskPatch struct { Name *string Pinned *bool + // PlanQuietMode flips the per-task quiet-mode switch (#86); nil = leave unchanged. + PlanQuietMode *bool } // UpdateTask applies a partial task name/pin mutation and returns the updated row. @@ -407,9 +419,10 @@ func (d *DB) UpdateTask(id int64, patch TaskPatch) (*Task, error) { WHEN $4::boolean IS NULL THEN pinned_at WHEN $4::boolean THEN COALESCE(pinned_at, now()) ELSE NULL - END + END, + plan_quiet_mode = CASE WHEN $5::boolean THEN $6 ELSE plan_quiet_mode END WHERE id=$1 AND deleted_at IS NULL -RETURNING `+taskCols, id, patch.Name != nil, patch.Name, patch.Pinned)) +RETURNING `+taskCols, id, patch.Name != nil, patch.Name, patch.Pinned, patch.PlanQuietMode != nil, patch.PlanQuietMode)) if err == sql.ErrNoRows { return nil, nil } diff --git a/server/dto.go b/server/dto.go index 60332b7..1fc9e66 100644 --- a/server/dto.go +++ b/server/dto.go @@ -61,6 +61,7 @@ type TaskDTO struct { ArchiveBlockedBy string `json:"archive_blocked_by_task_id,omitempty"` CompanyIDs []int64 `json:"company_ids"` CoverageEnabled bool `json:"coverage_enabled"` // asset coverage feature switch (fixed at creation) + PlanQuietMode bool `json:"plan_quiet_mode"` // planner quiet-mode switch (#86), editable mid-run } // FindingSeverityDTO is the per-severity finding count shown in the task list (critical/high/medium/low). @@ -134,6 +135,7 @@ func taskDTO(t *Task, status string) TaskDTO { SourceTaskIDs: sourceIDs, CompanyIDs: lifecycle.CompanyIDs, CoverageEnabled: t.CoverageEnabled, + PlanQuietMode: t.PlanQuietMode, } } diff --git a/server/engine.go b/server/engine.go index 347776b..6e7dc29 100644 --- a/server/engine.go +++ b/server/engine.go @@ -882,7 +882,7 @@ func (e *Engine) plannerLoop(ctx context.Context, t *Task) { // they do not accumulate and let the heartbeat supervise; the next edge // after a goal opens or closes, or once the frontier drains and all // workers are idle, plans for real. - if src == "edge" && e.quietSkipEdge(t) { + if src == "edge" && t.PlanQuietMode && e.quietSkipEdge(t) { t.drainTriggers() log.Printf("[planner] task %s quiet: work in flight, goals unchanged, edge round skipped", t.ID) return diff --git a/server/manager.go b/server/manager.go index c28b4a0..5ddccd4 100644 --- a/server/manager.go +++ b/server/manager.go @@ -57,6 +57,7 @@ type Task struct { // Task-level timeout (see docs/task-timeout-and-settle-design.md). DeadlineAt/FirstRunAt are unix seconds, 0 = unset/not yet run. TimeoutSeconds int `json:"timeout_seconds"` PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner heartbeat trigger interval (seconds) + PlanQuietMode bool `json:"plan_quiet_mode"` // planner quiet-mode switch (#86), editable mid-run CoverageEnabled bool `json:"coverage_enabled"` // asset coverage feature switch (fixed at creation, on by default) FirstRunAt int64 `json:"first_run_at,omitempty"` DeadlineAt int64 `json:"deadline_at,omitempty"` @@ -844,6 +845,7 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter SourceTaskIDs: append([]int64(nil), pt.SourceTaskIDs...), CompanyIDs: append([]int64(nil), pt.CompanyIDs...), TimeoutSeconds: pt.TimeoutSeconds, PlanHeartbeatSeconds: pt.PlanHeartbeatSeconds, + PlanQuietMode: pt.PlanQuietMode, CoverageEnabled: pt.CoverageEnabled, FirstRunAt: unixOrZero(pt.FirstRunAt), DeadlineAt: unixOrZero(pt.DeadlineAt), Store: store, Guard: guard.NewWithInterceptor(ic), notify: make(chan struct{}, 1), @@ -873,6 +875,9 @@ func (m *Manager) UpdateTaskMetadata(taskID string, patch pgdb.TaskPatch) (*Task state.Name = updated.Name state.PinnedAt = unixOrZero(updated.PinnedAt) }) + // Live switch (#86): runRound reads the flag per round, so the next + // planning round (edge or heartbeat) already respects the new value. + task.PlanQuietMode = updated.PlanQuietMode return task, nil } diff --git a/server/planner_quiet_test.go b/server/planner_quiet_test.go index fb067d5..1c57db8 100644 --- a/server/planner_quiet_test.go +++ b/server/planner_quiet_test.go @@ -74,3 +74,51 @@ func TestPlannerQuietSkipEdge(t *testing.T) { t.Fatal("no active intent must allow planning") } } + +// The quiet-mode switch is editable mid-run (#86): PATCHing plan_quiet_mode +// updates both the stored row and the live Task the engine reads per round. +func TestPlannerQuietModeToggleMidRun(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = m.Close() }) + if m.pg == nil { + t.Skip("no postgres for this run") + } + + created, err := m.CreateTaskWithOptions("quiet-toggle probe", "goal", db.TaskCreateOptions{}) + if err != nil { + t.Fatal(err) + } + tt, ok := m.Task(created.ID) + if !ok { + t.Fatal("task not found") + } + if !tt.PlanQuietMode { + t.Fatal("quiet mode must default on") + } + + off := false + updated, err := m.UpdateTaskMetadata(created.ID, db.TaskPatch{PlanQuietMode: &off}) + if err != nil || updated == nil { + t.Fatalf("UpdateTaskMetadata: %v %v", updated, err) + } + live, ok := m.Task(created.ID) + if !ok || live.PlanQuietMode { + t.Fatal("mid-run toggle must turn the live flag off") + } + stored, err := m.pg.GetTask(mustTaskID(t, created.ID)) + if err != nil || stored == nil || stored.PlanQuietMode { + t.Fatalf("stored row must reflect the toggle: %v %v", stored, err) + } + + on := true + if _, err := m.UpdateTaskMetadata(created.ID, db.TaskPatch{PlanQuietMode: &on}); err != nil { + t.Fatal(err) + } + live, _ = m.Task(created.ID) + if !live.PlanQuietMode { + t.Fatal("toggle back on failed") + } +} diff --git a/server/server.go b/server/server.go index 35c3c1f..dfafb6e 100644 --- a/server/server.go +++ b/server/server.go @@ -1494,6 +1494,7 @@ type createTaskReq struct { CompanyIDs []int64 `json:"company_ids,omitempty"` // links the company scope and snapshots its current assets; does not copy assets or force intent generation TimeoutSeconds int `json:"timeout_seconds"` // task-level timeout (seconds); 0/omitted = no limit PlanHeartbeatSeconds int `json:"plan_heartbeat_seconds"` // planner heartbeat interval (seconds); 0/omitted = default 600 (10min); floor = default = 600, lower values are raised to 600 + PlanQuietMode *bool `json:"plan_quiet_mode,omitempty"` // planner quiet-mode switch (#86); omitted = default on; false = edge rounds always plan SeedFirstIntent *bool `json:"seed_first_intent,omitempty"` // issue one seed intent at creation (content = description + goal) so a worker can start right away without waiting for the first planner round; omitted/null = default off, the standard plan-then-execute flow. Pass true explicitly to enable (for CTF tasks often solved in one work, this skips the pre-start planner round). CoverageEnabled *bool `json:"coverage_enabled,omitempty"` // asset coverage feature; omitted/null = default on (true). false = disables coverage calculation/display/auto-accumulated scope + hides add_task_scope/list_untested_assets. company links are unaffected. // InterceptRules: task-level asset interception/allow rules (entered at creation, stored in task_intercept_rules, never the global table). @@ -1562,7 +1563,7 @@ func (s *Server) createTask(w http.ResponseWriter, r *http.Request) { t, err := s.m.CreateTaskWithOptions(req.Description, req.Goal, db.TaskCreateOptions{ Name: strings.TrimSpace(req.Name), CategoryID: req.CategoryID, SourceTaskIDs: sourceIDs, CompanyIDs: req.CompanyIDs, LLMProfileIDs: req.LLMProfileIDs, - TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds, + TimeoutSeconds: req.TimeoutSeconds, PlanHeartbeatSeconds: req.PlanHeartbeatSeconds, PlanQuietMode: req.PlanQuietMode, CoverageEnabled: req.CoverageEnabled, InterceptRules: interceptRules, }) diff --git a/server/task_metadata.go b/server/task_metadata.go index 6c35bd7..0cd4d21 100644 --- a/server/task_metadata.go +++ b/server/task_metadata.go @@ -25,8 +25,9 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) { } r.Body = http.MaxBytesReader(w, r.Body, maxTaskMetadataRequestSize) var request struct { - Name *string `json:"name"` - Pinned *bool `json:"pinned"` + Name *string `json:"name"` + Pinned *bool `json:"pinned"` + PlanQuietMode *bool `json:"plan_quiet_mode"` } if err := decode(r, &request); err != nil { var tooLarge *http.MaxBytesError @@ -37,8 +38,8 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) { } return } - if request.Name == nil && request.Pinned == nil { - writeErr(w, http.StatusBadRequest, "at least one of name or pinned must be provided") + if request.Name == nil && request.Pinned == nil && request.PlanQuietMode == nil { + writeErr(w, http.StatusBadRequest, "at least one of name, pinned or plan_quiet_mode must be provided") return } if request.Name != nil { @@ -53,7 +54,7 @@ func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) { } request.Name = &name } - task, err := s.m.UpdateTaskMetadata(taskID, db.TaskPatch{Name: request.Name, Pinned: request.Pinned}) + task, err := s.m.UpdateTaskMetadata(taskID, db.TaskPatch{Name: request.Name, Pinned: request.Pinned, PlanQuietMode: request.PlanQuietMode}) if err != nil { writeErr(w, http.StatusInternalServerError, err.Error()) return