Repository navigation
Description
Expected
A goal loop delivers its continuation prompt only when the agent has actually stopped, meaning its turn ended. Each continuation marks one real stop.
Actual
In a Claude Code session running a goal loop (started via goal_loop_start at 2026-09-19T05:31:10Z), continuations kept arriving while the agent was mid-turn and working continuously. They show up in the transcript as queued user messages ("sent while you were working") between tool calls.
~/.config/agent-code/goal-loop.jsonshows 33 continuations delivered between 05:31:10Z and 06:37:14Z, about one every 2 minutes, during what was effectively one unbroken working turn.- The owner noticed it: "some bs condition triggered the loop to continue to iteration 33 when you didn't even stop… must be subagents or something."
Impact
- The budget burns. 33 of 150 continuations went in about an hour with no real stop, so a long loop can hit its cap and pause while work is ongoing.
- Context bloat. Each delivery injects the full loop prompt (goal plus about 3 KB of instructions) into the live turn.
- Misleading. The agent is told "You stopped before this goal was complete" while it is still running.
Root cause (code reading; not yet reproduced in a test)
GoalLoopService treats any semantic working → idle transition as a turn boundary (src/main/goalLoop/GoalLoopService.ts, the path that calls scheduleContinueCheck). #1008 added a one-macrotask deferral for the Claude adapter's single-batch idle blip, but the session here produces real, lasting idle phases mid-turn. Ranked:
- Background subagents (strongest). Claude Code's Agent tool runs subagents inside the same
claudeprocess, so their API traffic flows through the same proxy. A subagent's request→response→stop reads as the parent session going working→idle. The parent turn is still open, and the parent'spendingToolsdoes not cover a background subagent, so the guard does not hold. This session had up to 3 background subagents running at a time. - Long foreground tool calls. A 5–10 minute Bash command (a test run) leaves the stream idle between API requests. If the
tool_useis not tracked intopendingToolsfor every tool type, for example MCP tools, the gap reads as a turn end. - Background task notifications. A background-task completion restarts the model mid-turn and can produce a short working→idle cycle.
Whichever it is, the design flaw is the same: the loop infers "turn ended" from stream phases, which are not turn-scoped for Claude Code.
Fix direction
- Claude: use Claude Code's
Stophook as the authoritative turn boundary. Agent Code already runs hook endpoints, for example/hooks/tldr/stop.Stopfires only when the MAIN agent's turn ends, andSubagentStopis separate. Stream phases can stay a fallback for providers without such a signal, never the primary one. - Every provider: deliver only when the agent's input is idle, with no queued input and the composer not in a working state, so a continuation can never enter a live turn.
- Tests: replay a recorded Claude session that runs a background subagent and a long tool call through the service, and assert exactly one continuation per real stop.
Environment
macOS, a dev build of main (after #1019), a Claude Code session in Agent Code, and a goal loop with maxContinuations 150.
Activity
docs(release): add #1024 (goal-loop mid-turn continuations) as thread…
docs(release): decompose #1024 around the Stop hook as the turn boundary
Juliusolsson05 commentedon Sep 19, 2026
Root cause confirmed from recordings (2026-09-19)
Evidence. The delivery times come from this session's own Claude transcript: 37 continuations, every one stored as
attachment/queued_command, meaning it arrived while the model was mid-turn. I aligned them with the session's feed recording (~/.config/agent-code/feed-debug/<session>.jsonl). In every sampled delivery (#1, #2, #12, #21, #28), the delivery (input blocked) is preceded by 4–60 ms by the same sequence:flow_selected→stream_phase→flow_ignoredMechanism:
requesting.idleto retract.pendingToolsis empty), that is a real working→idle edge that nothing retracts. fix(goal-loop): keyboard-dismiss the latched overlay and stop mid-turn double-delivery #1008's one-macrotask deferral cannot help, andGoalLoopServicedelivers.markOwedATurnsetsresponding, so the very next side-flow blip is another edge. That produced doubles 0.4–0.65 s apart (refactor: reshape tree, break up god-files, go all-absolute imports #1/Fix post-refactor permission and feed rendering regressions #2, Use stable palette for worktree badges #12/Prepare signed beta release workflow #13, Add interactive agent PTY debug terminal #21/Fix new agent placement overlay #22).The flawed premise: for Claude, stream
idlemeans "no main API request is streaming", not "the turn ended". Inside one turn it goes idle at every tool gap.Fix (in order of authority):
Stophook. It fires only for the main agent;SubagentStopis separate. Agent Code already receives Stop per registration at/hooks/tldr/stop. Only an allowed Stop counts, not one TLDR enforcement blocks. Install the hook whenever the goal_loop domain is on.requesting.Test: replay a recorded window with a side flow in a tool gap and assert zero deliveries until an allowed Stop arrives, then exactly one.
fix(goal-loop): drop the quiet-turn pause; Resume closes a turn only …
fix(goal-loop): bound the screen, trust the hook, and count streaming…