Skip to content

bug(goal-loop): continuations are delivered mid-turn while the agent is still working #1024

Closed

Description

@Juliusolsson05

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.json shows 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:

  1. Background subagents (strongest). Claude Code's Agent tool runs subagents inside the same claude process, 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's pendingTools does not cover a background subagent, so the guard does not hold. This session had up to 3 background subagents running at a time.
  2. Long foreground tool calls. A 5–10 minute Bash command (a test run) leaves the stream idle between API requests. If the tool_use is not tracked into pendingTools for every tool type, for example MCP tools, the gap reads as a turn end.
  3. 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 Stop hook as the authoritative turn boundary. Agent Code already runs hook endpoints, for example /hooks/tldr/stop. Stop fires only when the MAIN agent's turn ends, and SubagentStop is 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

  1. added 2 commits that reference this issue on Sep 19, 2026
  2. Juliusolsson05 commented on Sep 19, 2026

    @Juliusolsson05
    OwnerAuthor

    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_ignored

    Mechanism:

    1. A proxy flow that is NOT the main conversation starts: a background subagent's API request or a Claude Code side request. The session is briefly promoted to working/requesting.
    2. The adapter classifies the flow as ignored and republishes idle to retract.
    3. If the main turn is in a local tool-execution gap (the last tool result is already recorded, so pendingTools is 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, and GoalLoopService delivers.
    4. After a delivery, markOwedATurn sets responding, 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).
    5. Bursts track background subagent activity: 31 deliveries in 17 minutes while 3 background reviewers ran.

    The flawed premise: for Claude, stream idle means "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):

    1. The turn boundary is the provider's Stop hook. It fires only for the main agent; SubagentStop is 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.
    2. Never deliver into busy input, whatever the trigger.
    3. The Claude proxy adapter must not let an ignored flow move the session phase at all: classify the flow before promoting it to 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions