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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user