SubagentStop hook re-emits next_step on completion_already_recorded, looping pipeline agents 6-8 extra turns #626

Closed
opened 2026-09-22 19:17:07 +00:00 by jared · 2 comments
Owner

Problem

Hook::Pipe#relay prints hookSpecificOutput.additionalContext with the runner's stdout on every SubagentStop. After an agent has settled its own dispatch, every later Stop hits CompletionIntake#already_recorded_payload, which since ADR-0203 clause 2 carries the full next_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 at 599afa67 (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-stop tool call with completion_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

/os-sdlc:implement <any ticket>   # then read the programmer agent transcript: the final JSON appears 6-8 times

Approach

The runner decides what the hook may say; the hook stays a courier. On the hook path (no --dispatch-id) the subagent-stop command prints nothing when the dispatch is already complete, and Pipe#relay emits hookSpecificOutput only when stdout is non-empty. Exit 0 with no output lets the Stop complete.

# CompletionIntake#call
return nil if @lookup.late_hook_replay?      # was: already_recorded_payload
# Pipe#relay
return if out.strip.empty?
puts JSON.generate(hookSpecificOutput: { hookEventName: @event_name, additionalContext: out.strip })

The agent already received next_step and final_output on the stdout of its own dispatch-id call (ADR-0203 clause 1). The orchestrator's fallback for a missing next_step is os-sdlc-runner next SESSION_ID (implement SKILL.md "Missing next_step").

Required work:

  1. CompletionIntake#call returns no payload on the late-hook-replay branch; already_recorded_payload and ALREADY_RECORDED_TYPE go away.
  2. The empty-stdout guard lives in the shared Pipe#relay (lib/os_sdlc/hook/pipe.rb), so it applies to all three callers of relay: hook/subagent_stop.rb:7, hook/subagent_start.rb:7, and hook/post_tool_use.rb:69 (the next subcommand). This is intended — every event served by Pipe#relay prints nothing when runner stdout is empty.
  3. New test: a SubagentStop event for a completed dispatch produces empty stdout and exit 0 from the hook — check: ruby -Itests tests/hooks/subagent_stop_test.rb.
  4. ADR: amend ADR-0203 clause 2 and its consequence "including the late-hook replay" via /os-adr:create; ADR-0144 crash fallback stays.
  5. History row and the current-shape line for SubagentStop in docs/implementation-status/os-sdlc.md:24.
  6. Live check on the next implement run: the agent transcript shows one Stop and no repeated JSON.

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_recorded inside Pipe — puts runner response types into the hook layer.

Acceptance criteria

  1. Given a dispatch the agent already settled through 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.
  2. Given an agent that stops without calling subagent-stop, when the SubagentStop hook fires, then the dispatch is still recorded (blank-content or parsed handoff) as today.
  3. Given the SubagentStart and PostToolUse hooks, when the runner prints a payload, then Pipe still relays it as additionalContext unchanged.
  4. Given implementation runs after the change, when a dispatch completes, then the ~15-20 s and the repeated tokens per dispatch are gone (compare transcript sizes with implementation 10).
  5. Given a SubagentStop for an already-recorded dispatch, when the hook runs, then it produces empty stdout and exit 0, and the hook prints no additionalContext key.

Owner sketch

module OsSdlc::Pipeline
  class CompletionIntake
    def call
      return nil if @lookup.late_hook_replay?
      ...
  end
end
module OsSdlc::Hook
  class Pipe
    def relay   # prints only when runner stdout is non-empty
  end
end

Non-goals

Whether the crash-fallback recording should itself relay anything (today the session-path payload has no next_step; unchanged here). Charter ## Final output wording (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_recorded in gate_handoff_test.rb:77 and dispatch_240_fixture.rb names a different feature. Do not touch it.

  • Once both retired tests are gone, the helper tests/support/subagent_stop_cli.rb#already_recorded_payload has 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

  • Trigger: transcript timing review, session 878aaacc; re-verified 2026-09-24 by reading pipe.rb:40-44 and completion_intake.rb on main 599afa67
  • Improvised this session: none
  • Chain: 6-8 repeated final messages ← hook relays additionalContext on every Stop ← ADR-0203 clause 2 made the already-recorded reply carry next_step so it is relayable ← DESIGN (docs/adr/0203)
  • Root candidate: this ticket
  • Where: plugins/os-sdlc/lib/os_sdlc/hook/pipe.rb, plugins/os-sdlc/lib/os_sdlc/pipeline/completion_intake.rb
  • Session: d37d9a9a-bb4c-456a-a0cf-294619c9128a
  • Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/d37d9a9a-bb4c-456a-a0cf-294619c9128a.jsonl
## Problem `Hook::Pipe#relay` prints `hookSpecificOutput.additionalContext` with the runner's stdout on every SubagentStop. After an agent has settled its own dispatch, every later Stop hits `CompletionIntake#already_recorded_payload`, which since ADR-0203 clause 2 carries the full `next_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 at 599afa67 (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-stop` tool call with `completion_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 ``` /os-sdlc:implement <any ticket> # then read the programmer agent transcript: the final JSON appears 6-8 times ``` ## Approach The runner decides what the hook may say; the hook stays a courier. On the hook path (no `--dispatch-id`) the `subagent-stop` command prints nothing when the dispatch is already complete, and `Pipe#relay` emits `hookSpecificOutput` only when stdout is non-empty. Exit 0 with no output lets the Stop complete. ```ruby # CompletionIntake#call return nil if @lookup.late_hook_replay? # was: already_recorded_payload # Pipe#relay return if out.strip.empty? puts JSON.generate(hookSpecificOutput: { hookEventName: @event_name, additionalContext: out.strip }) ``` The agent already received `next_step` and `final_output` on the stdout of its own dispatch-id call (ADR-0203 clause 1). The orchestrator's fallback for a missing `next_step` is `os-sdlc-runner next SESSION_ID` (implement SKILL.md "Missing next_step"). Required work: 1. `CompletionIntake#call` returns no payload on the late-hook-replay branch; `already_recorded_payload` and `ALREADY_RECORDED_TYPE` go away. 2. The empty-stdout guard lives in the shared `Pipe#relay` (`lib/os_sdlc/hook/pipe.rb`), so it applies to all three callers of `relay`: `hook/subagent_stop.rb:7`, `hook/subagent_start.rb:7`, and `hook/post_tool_use.rb:69` (the `next` subcommand). This is intended — every event served by `Pipe#relay` prints nothing when runner stdout is empty. 3. New test: a SubagentStop event for a completed dispatch produces empty stdout and exit 0 from the hook — check: `ruby -Itests tests/hooks/subagent_stop_test.rb`. 4. ADR: amend ADR-0203 clause 2 and its consequence "including the late-hook replay" via `/os-adr:create`; ADR-0144 crash fallback stays. 5. History row and the current-shape line for SubagentStop in `docs/implementation-status/os-sdlc.md:24`. 6. Live check on the next implement run: the agent transcript shows one Stop and no repeated JSON. 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_recorded` inside `Pipe` — puts runner response types into the hook layer. ## Acceptance criteria 1. Given a dispatch the agent already settled through `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. 2. Given an agent that stops without calling `subagent-stop`, when the SubagentStop hook fires, then the dispatch is still recorded (blank-content or parsed handoff) as today. 3. Given the SubagentStart and PostToolUse hooks, when the runner prints a payload, then `Pipe` still relays it as `additionalContext` unchanged. 4. Given implementation runs after the change, when a dispatch completes, then the ~15-20 s and the repeated tokens per dispatch are gone (compare transcript sizes with implementation 10). 5. Given a SubagentStop for an already-recorded dispatch, when the hook runs, then it produces empty stdout and exit 0, and the hook prints no `additionalContext` key. ## Owner sketch ```ruby module OsSdlc::Pipeline class CompletionIntake def call return nil if @lookup.late_hook_replay? ... end end module OsSdlc::Hook class Pipe def relay # prints only when runner stdout is non-empty end end ``` ## Non-goals Whether the crash-fallback recording should itself relay anything (today the session-path payload has no `next_step`; unchanged here). Charter `## Final output` wording (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_recorded` in `gate_handoff_test.rb:77` and `dispatch_240_fixture.rb` names a different feature. Do not touch it. - Once both retired tests are gone, the helper `tests/support/subagent_stop_cli.rb#already_recorded_payload` has 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 - Trigger: transcript timing review, session 878aaacc; re-verified 2026-09-24 by reading pipe.rb:40-44 and completion_intake.rb on main 599afa67 - Improvised this session: none - Chain: 6-8 repeated final messages ← hook relays additionalContext on every Stop ← ADR-0203 clause 2 made the already-recorded reply carry next_step so it is relayable ← DESIGN (docs/adr/0203) - Root candidate: this ticket - Where: plugins/os-sdlc/lib/os_sdlc/hook/pipe.rb, plugins/os-sdlc/lib/os_sdlc/pipeline/completion_intake.rb - Session: d37d9a9a-bb4c-456a-a0cf-294619c9128a - Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/d37d9a9a-bb4c-456a-a0cf-294619c9128a.jsonl
Author
Owner

Work started on branch ticket-626 via /os-sdlc:implement (session 91151d77).

Work started on branch ticket-626 via /os-sdlc:implement (session 91151d77).
Author
Owner

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).

## 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).
jared closed this issue 2026-09-24 19:30:48 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jared/cc-os#626
No description provided.