Repository navigation
fix(goal-loop): wait for background work the agent will be woken by - #1224
Merged
Merged
Conversation
A goal loop continued the moment Claude's turn ended, even when the agent
had async subagents or run_in_background shells running that the CLI will
wake it for with a task-notification. Each continuation in that gap got
'still waiting' and cost one continuation (observed three in a row on
2026-09-22).
Claude Code 2.1.280+ reports this itself: the Stop hook payload carries
background_tasks, which its schema describes as the way to tell 'done' from
'paused waiting for background work'. Captured unedited from 2.1.282
(testing/fixtures/goal-loop-stop-hooks/): [] when idle, a running 'shell',
a running 'subagent', and a subagent's leftover shell after it finished.
- The hook host forwards background_tasks from a Stop (it parsed the body
and dropped the field). Absent or malformed means unknown and is not
forwarded, so an older CLI keeps today's behaviour.
- The loop holds its continuation ('background-work') while the last
allowed Stop reported a running/pending shell, subagent, workflow or MCP
task; the notification turn's own Stop replaces the set, and an empty
list releases it. Monitors and unknown task kinds are not held on (a
persistent monitor never ends).
- Background work is exempt from the 30-minute SILENCE pause, like a
running tool, because a paused loop would miss the notification turn;
the absolute 2-hour limit still applies. Cleared on a stalled-hold pause
(Resume means continue anyway) and when the session's process dies.
Codex needs no change: child final answers carry trigger_turn:false, so a
child finishing never wakes the parent (400 rollouts checked).
Tests replay the captured payloads through the service and, over real
HTTP, through the real MCP host. Mutation-checked: host dropping the
field, silence not exempt, holding on monitors.
Fixes #1138
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Juliusolsson05
added a commit
that referenced
this pull request
Sep 25, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #1224, verified against the 2.1.282 binary: the Monitor tool is a local_bash task and reaches the Stop payload as type "shell", so a persistent monitor (or a dev server started in the background) parked an unattended loop for the 2-hour limit, paused it, and re-parked it after every Resume. Each Stop's report now holds for GOAL_LOOP_BACKGROUND_WORK_LIMIT_MS (45 min) and then expires, so the continuation lands as it did before #1138 and the next Stop starts a fresh window. The silence exemption uses the same expiring answer, so a stale report no longer stretches a silent turn-open hold to 2 hours. A Stop without a readable report keeps the latch (only a parse failure reaches it; older CLIs never latch), and a list whose entries are all unreadable is unknown, not empty. Tests pin workflow, MCP task, pending, the recorded leftover-shell Stop, process death, the persistent-monitor cycle and the stale-report pause. All seven mutations are killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Juliusolsson05
added a commit
that referenced
this pull request
Sep 25, 2026
…ain comment Round-2 review of #1224: an unparseable Stop body and a list with no readable entry must keep the hold through the real host (mutating the host to coerce the list to [] now fails). The pauseStalledHold clear is reachable by a chain of refreshed reports, so its comment says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Steering note q5 on #1224: dropping one unreadable entry could turn "a workflow is still running" into "only a monitor is running" and release the hold. Any unreadable entry now makes the report unknown, which keeps the last report inside its 45-minute window. The host-path test posts a readable monitor plus an unreadable workflow after a held shell; restoring the all-unreadable-only guard fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem (#1138, class C1)
The goal loop continued the moment Claude's turn ended, even while the agent had async subagents or
run_in_backgroundshells that the CLI will wake it for with a task-notification. Each continuation in that gap got "still waiting" and cost one continuation (observed three in a row, 2026-09-22).Evidence (staged decomposition; details in the #1138 comment)
background_taskson the Stop hook payload. Its own schema: lets hooks distinguish 'session is done' from 'session is paused waiting for background work to wake it'.testing/fixtures/goal-loop-stop-hooks/claude-2.1.282.json:[]when idle;shell;subagent;shellafter it finished.trigger_turn:false, so a finishing child never wakes the parent. No change is needed there.Change
BuiltInMcpHttpHostforwardsbackground_tasksfrom a Stop. A missing or malformed field means unknown and isn't forwarded, so an older CLI behaves exactly as today.GoalLoopServiceholds (background-work) while the last allowed Stop reported a running or pending shell, subagent, workflow or MCP task. The notification turn's own Stop replaces the set, and an empty list releases the hold.Tests (fail-first, real payloads)
Mutation-checked, each individually: host drops the field, silence not exempt, hold on monitors. Each turns a test red.
Verification
npx tsc -bclean;src/main/goalLooppasses (4 files, 69 tests).Not verified here: a live overnight loop. The 11 % case (a notification already queued at Stop) isn't in
background_tasks; the CLI wakes the agent immediately there, so the existing turn-open gate handles it.Fixes #1138
🤖 Generated with Claude Code
Review disposition (A Pi, B Grok, C Pi; 2 rounds, plus steering)
shell(verified in the 2.1.282 binary); a never-ending shell parked the loop for 2 h, paused it, and re-parked after every Resumeac45391a): each report holds forGOAL_LOOP_BACKGROUND_WORK_LIMIT_MS(45 min), then DELIVERS; the next Stop opens a fresh window. Corpus: 92–94% of background tasks notify within 45 minbackgroundWorkHolds()474ae837)52bbd30a)Round 2: A, B and C all MERGE-READY. The steering note q5 was applied after round 2, with a test and a mutation check.