SubagentStop hook re-emits next_step on completion_already_recorded, looping pipeline agents 6-8 extra turns #626
Labels
No labels
P0
P1
P2
P3
bug
category/code-change
category/cross-repo
category/eval-run
category/investigation
category/no-test-target
category/owner-ruling
category/prose-artifact
category/recurring-chore
category/structure
category/umbrella
crc-refactor
create
delete
enhancement
filed-by/agent
filed-by/user
frozen
lint-rule
needs-info
needs-triage
next
plugin/cc-architect
plugin/os
plugin/os-adr
plugin/os-aidd-lint
plugin/os-backlog
plugin/os-context
plugin/os-doc-hygiene
plugin/os-sdlc
plugin/os-vault
project/cc-os
ready-for-agent
ready-for-human
recurring
review
rule-proposal
update
waiting
wayfinder:grilling
wayfinder:map
wayfinder:map
wayfinder:research
wayfinder:task
wayfinder:task
wontfix
worklist/deviations
worklist/lint-rule
worklist/new-implement-build
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
jared/cc-os#626
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Hook::Pipe#relayprintshookSpecificOutput.additionalContextwith the runner's stdout on every SubagentStop. After an agent has settled its own dispatch, every later Stop hitsCompletionIntake#already_recorded_payload, which since ADR-0203 clause 2 carries the fullnext_step. The harness re-prompts the agent with that context, the agent restates it, and the cycle repeats until the harness gives up. Verified still present on main at599afa67(2026-09-24): pipe.rb:40-44 is unconditional; no source change since 2026-09-20.Observed
Implementation 10 on #557 (session e8c75cf9): suite-check dispatch 127 and programmer dispatch 125 each show one
subagent-stoptool call withcompletion_recorded, then the dispatch JSON repeated as plain text 6-8 times at ~2.3 s intervals. Cost ~15-20 s and the repeated tokens per dispatch.Reproduce
Approach
The runner decides what the hook may say; the hook stays a courier. On the hook path (no
--dispatch-id) thesubagent-stopcommand prints nothing when the dispatch is already complete, andPipe#relayemitshookSpecificOutputonly when stdout is non-empty. Exit 0 with no output lets the Stop complete.The agent already received
next_stepandfinal_outputon the stdout of its own dispatch-id call (ADR-0203 clause 1). The orchestrator's fallback for a missingnext_stepisos-sdlc-runner next SESSION_ID(implement SKILL.md "Missing next_step").Required work:
CompletionIntake#callreturns no payload on the late-hook-replay branch;already_recorded_payloadandALREADY_RECORDED_TYPEgo away.Pipe#relay(lib/os_sdlc/hook/pipe.rb), so it applies to all three callers ofrelay:hook/subagent_stop.rb:7,hook/subagent_start.rb:7, andhook/post_tool_use.rb:69(thenextsubcommand). This is intended — every event served byPipe#relayprints nothing when runner stdout is empty.ruby -Itests tests/hooks/subagent_stop_test.rb./os-adr:create; ADR-0144 crash fallback stays.docs/implementation-status/os-sdlc.md:24.Rejected: remove the SubagentStop hook from
hooks.json— loses the crash fallback (ADR-0144, #496).Rejected: relay once by marking the dispatch as relayed — needs a schema column for a re-prompt whose only consumer is the agent that already has the data.
Rejected: branch on
completion_already_recordedinsidePipe— puts runner response types into the hook layer.Acceptance criteria
subagent-stop --dispatch-id, when the SubagentStop hook fires, then the hook exits 0 with empty stdout and the agent transcript shows exactly one Stop and no repeated dispatch JSON.subagent-stop, when the SubagentStop hook fires, then the dispatch is still recorded (blank-content or parsed handoff) as today.Pipestill relays it asadditionalContextunchanged.additionalContextkey.Owner sketch
Non-goals
Whether the crash-fallback recording should itself relay anything (today the session-path payload has no
next_step; unchanged here). Charter## Final outputwording (ADR-0203 clause 3).Blast radius
ADR-0203 clause 2 is amended via
/os-adr:create; ADR-0144/0145 crash fallback is unchanged.Lookup#late_hook_replay?still queries the DB on the nil branch — out of scope for this ticket.The homonym
already_recordedingate_handoff_test.rb:77anddispatch_240_fixture.rbnames a different feature. Do not touch it.Once both retired tests are gone, the helper
tests/support/subagent_stop_cli.rb#already_recorded_payloadhas no callers; the test-writer deletes it.Retired tests
plugins/os-sdlc/tests/pipeline/cli_subagent_stop_test.rb#test_a_hook_stop_that_arrives_after_the_agent_already_recorded_exits_quietly
plugins/os-sdlc/tests/pipeline/cli_subagent_stop_test.rb#test_an_already_recorded_reply_still_carries_the_dispatch_id_and_next_step
Origin
599afa67Work started on branch ticket-626 via /os-sdlc:implement (session 91151d77).
Resolution
Done: A late SubagentStop for an already-settled dispatch prints nothing and exits 0. CompletionIntake#call returns nil on late_hook_replay?; already_recorded_payload and ALREADY_RECORDED_TYPE removed. Hook::Pipe#relay prints hookSpecificOutput only for non-empty runner stdout, for all three hook events. ADR-0213 amends ADR-0203 clause 2; status leaf updated.
Evidence: Branch ticket-626 commit
19b8a43a. os-sdlc implementation 27: 14 dispatches, suite-check pass (dispatch 314), contract-auditor pass (315). Retired: cli_subagent_stop_test.rb#test_a_hook_stop_that_arrives_after_the_agent_already_recorded_exits_quietly and #test_an_already_recorded_reply_still_carries_the_dispatch_id_and_next_step; helper already_recorded_payload deleted. New test in hooks/subagent_stop_test.rb for the empty-stdout path.Follow-ups: none; Lookup#late_hook_replay? still queries the DB on the nil branch, documented as out of scope in ADR-0213 and not worth a ticket. Live verification happens on the next implement run (#625).