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

@@ -508,6 +508,60 @@ func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary st
return nil
}
// staleGoalSession is a goal-bearing task that's gone idle without reaching
// a terminal state — the idle-sweep worker's work list (fix 2+3 of
// plans/2026-07-11-task-completion-safety-net.md).
type staleGoalSession struct {
ID string
Goal string
CompletionNudges int
}
// staleGoalSessions finds sessions that framed themselves as a real task
// (goal != '', so the inline safety net in agent.go intentionally left them
// alone) but have sat non-terminal past idleThreshold. completion_nudges
// tells the caller whether to nudge (0) or give up and auto-close (>=1) —
// see processIdleSweep in continue.go.
func (s *store) staleGoalSessions(ctx context.Context, idleThreshold time.Duration, limit int) []staleGoalSession {
if s == nil {
return nil
}
rows, err := s.pool.Query(ctx, `
SELECT id, goal, completion_nudges
FROM agent_sessions
WHERE goal <> ''
AND status IN ('active', 'planning', 'executing')
AND last_active_at < now() - ($1 * interval '1 second')
ORDER BY last_active_at
LIMIT $2`, idleThreshold.Seconds(), limit)
if err != nil {
return nil
}
defer rows.Close()
var out []staleGoalSession
for rows.Next() {
var s staleGoalSession
if err := rows.Scan(&s.ID, &s.Goal, &s.CompletionNudges); err == nil {
out = append(out, s)
}
}
return out
}
// bumpCompletionNudge records that the idle sweep nudged a stalled session,
// stamping last_active_at so it isn't picked up again until it's genuinely
// idle again (a fresh nudge shouldn't fire every tick while the model is
// mid-response to the previous one).
func (s *store) bumpCompletionNudge(ctx context.Context, sessionID string) error {
if s == nil {
return nil
}
_, err := s.pool.Exec(ctx, `
UPDATE agent_sessions SET completion_nudges = completion_nudges + 1, last_active_at = now()
WHERE id = $1`, sessionID)
return err
}
// planStep is a persisted plan step, as returned to the frontend for hydration
// (the panel otherwise only sees steps live via plan.proposed/plan.step.*).
type planStep struct {