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