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

@@ -205,6 +205,16 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
"[System: this task has been running long enough that only the most recent %d turns of its history are included above your context — earlier turns happened but aren't shown. If you need to know what was already tried or found, check search_knowledge/get_entity_knowledge (if you recorded it) rather than assuming it didn't happen.]",
historyWindowSize)))
}
// sawSetGoal / sawCompleteTask track whether this session has EVER framed
// itself as a structured task (set_goal) or already reached a terminal
// state (complete_task) — across both replayed history and this turn's
// own tool calls (updated again below as they happen live). Used by the
// end-of-turn safety net (plans/2026-07-11-task-completion-safety-net.md,
// fix 1): most sessions are a single trivial Q&A exchange that answers in
// text and never calls either tool, leaving agent_sessions.status stuck
// at its creation-time default forever. If a session never framed itself
// as a task, its first plain-text turn-end IS the task ending.
var sawSetGoal, sawCompleteTask bool
var lastAssistantCalls []persistedCall
for _, m := range history {
text := extractText(m.Content)
@@ -216,6 +226,12 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
messages = append(messages, assistantToolCallMessage(calls))
for _, c := range calls {
messages = append(messages, openai.ToolMessage(c.resultText(), c.id))
switch c.name {
case "set_goal":
sawSetGoal = true
case "complete_task":
sawCompleteTask = true
}
}
lastAssistantCalls = calls
}
@@ -358,6 +374,9 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
if len(msg.ToolCalls) == 0 {
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
if !sawSetGoal && !sawCompleteTask {
a.autoCompleteTrivialTask(ctx, sessionID, msg.Content)
}
emit(agentEvent{Type: "done", Data: map[string]any{
"session_id": sessionID,
"usage": acc.Usage,
@@ -377,6 +396,13 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
args = map[string]any{}
}
switch tc.Function.Name {
case "set_goal":
sawSetGoal = true
case "complete_task":
sawCompleteTask = true
}
emit(agentEvent{
Type: "tool_use",
Data: map[string]any{"name": tc.Function.Name, "args": args, "id": tc.ID},