fix(agent): task completion safety net — stop tasks sticking at Running

Implements fixes 1-3 of plans/2026-07-11-task-completion-safety-net.md.
Confirmed live that 50/50 production sessions never reached a terminal
status because the model almost never calls complete_task, even for
trivial single-tool Q&A turns SOUL.md explicitly calls out as needing it.

- Inline safety net (agent.go): a session that never called set_goal never
  framed itself as a structured task, so its first plain-text turn-end IS
  the task ending — auto-complete it there instead of leaving status stuck
  at its creation default forever.
- Idle sweep (continue.go, new completion_nudges column): goal-bearing
  sessions that stall get one nudge, then auto-close with outcome=partial
  if the nudge goes unanswered, mirroring the pattern resumeSession already
  uses for a different stuck-session failure mode.

Fix 4 (backfill of the 50 already-stuck live sessions) is deliberately
separate — deferred until this is deployed and verified live, per the
plan's implementation order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 22:11:24 +02:00
parent e3850f6820
commit 3b9c75fa3f
6 changed files with 185 additions and 0 deletions

View File

@@ -32,6 +32,71 @@ func extractExecutionIDs(toolResult string) []uuid.UUID {
return out
}
// idleTaskThreshold is how long a goal-bearing session can sit non-terminal
// with no activity before the idle sweep nudges it, per
// plans/2026-07-11-task-completion-safety-net.md. Arbitrary starting point,
// not measured against real task durations — long enough that it won't fire
// mid-turn, short enough the board doesn't lie for hours.
const idleTaskThreshold = 15 * time.Minute
// runIdleSweepWorker is the safety net for case 2 of
// plans/2026-07-11-task-completion-safety-net.md: sessions that called
// set_goal (so the inline safety net in agent.go correctly left them alone,
// since they framed themselves as a real task) but then stalled without
// ever calling complete_task. Coarser than runContinuationWorker's 4s tick
// since "gone idle" is a much slower signal than "an execution just
// finished." Blocks until ctx is cancelled.
func (a *agent) runIdleSweepWorker(ctx context.Context) {
if a.store == nil {
slog.Warn("nomos: idle sweep worker disabled (no store)")
return
}
slog.Info("nomos: idle sweep worker started")
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
a.processIdleSweep(ctx)
}
}
}
// processIdleSweep nudges a stalled goal-bearing session once; if it's still
// non-terminal on the NEXT sweep (meaning the nudge itself went unanswered,
// not just that the model is still working), auto-closes it with a
// visible "auto-closed" outcome instead of leaving it stuck forever — same
// reasoning resumeSession already applies below for a different failure
// mode (a resume that produces no response at all).
func (a *agent) processIdleSweep(ctx context.Context) {
stale := a.store.staleGoalSessions(ctx, idleTaskThreshold, 5)
for _, s := range stale {
s := s
if s.CompletionNudges == 0 {
safego.Go("nomos:idle-nudge:"+s.ID, func() {
if err := a.store.bumpCompletionNudge(ctx, s.ID); err != nil {
slog.Error("nomos: idle nudge bump failed", "session", s.ID, "error", err)
return
}
note := fmt.Sprintf("[System: this task ('%s') has been idle for %s with no complete_task call. "+
"If the goal is done (or can't be completed), call complete_task now with the outcome and a "+
"one-line summary. If you're still genuinely working through the plan, ignore this and continue.]",
s.Goal, idleTaskThreshold)
a.resumeSession(ctx, s.ID, note)
})
continue
}
safego.Go("nomos:idle-autoclose:"+s.ID, func() {
summary := fmt.Sprintf("Auto-closed after %s idle with no response to a completion nudge.", idleTaskThreshold)
if err := a.store.completeTask(ctx, s.ID, "partial", summary); err != nil {
slog.Error("nomos: idle auto-close failed", "session", s.ID, "error", err)
}
})
}
}
// runContinuationWorker is the event loop that replaces the human typing
// "continue". It polls for gated executions that (a) were initiated by a chat
// session and (b) have just finished, and — while that agent has an open assent