From e430bef67a64a466b618ca0ac214e971f5a5b375 Mon Sep 17 00:00:00 2001 From: "Carlos D. Escobar-Valbuena" Date: Fri, 9 Oct 2026 07:51:33 -0500 Subject: [PATCH 1/4] =?UTF-8?q?fix(hooks):=20bg-task-stop-guard=20names=20?= =?UTF-8?q?a=20subagent=20wait=20condition=20that=20cannot=20match=20early?= =?UTF-8?q?=20=E2=80=94=20v0.45.1=20(BRO-2815)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfood on a real Paseo turn (agent acb1ab0b): the guard blocked and the session waited in the foreground, but its until-loop grepped the subagent's output file (its transcript, which holds its own prompt) for the expected token and matched in 2 s. The re-prompt now says so and names the completion condition: a whole-file grep for "stop_reason":"end_turn" (tail -n1 misses it: an attachment line follows). Test + 16th mutant pin the wording. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 17 +++++++++++++++++ VERSION | 2 +- hooks/bg-task-stop-guard.py | 7 +++++-- tests/bg-task-stop-guard.test.sh | 5 +++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed26386..c16e1a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.45.1 — 2026-10-09 + +### fix(hooks): bg-task-stop-guard names a subagent wait condition that cannot match early (BRO-2815) + +Dogfooded on a real Paseo turn (agent acb1ab0b, session cc0aedf2). The guard blocked the turn-end on a +background subagent, and the session waited in the foreground as told. But its until-loop grepped the +subagent's output file for the token it expected back. That file is the subagent's transcript, and it +already contains the subagent's own prompt, token included. So the loop matched within 2 s, and the model +reported a verdict it had not received yet. + +The re-prompt now says so. It names the condition that marks a finished subagent: the transcript holds the +final assistant message, `"stop_reason":"end_turn"`. Earlier turns carry `tool_use`. In that transcript +the line appeared once, at completion, followed by an attachment line, so the wait has to grep the whole +file; `tail -n1` misses it. + +`tests/bg-task-stop-guard.test.sh` pins the wording, and a 16th mutant that drops it is killed. + ## 0.45.0 — 2026-10-09 ### feat(hooks): under Paseo, a turn cannot end while the session's own background task is still running (BRO-2815) diff --git a/VERSION b/VERSION index bcce5d0..ff12df2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.45.0 +0.45.1 diff --git a/hooks/bg-task-stop-guard.py b/hooks/bg-task-stop-guard.py index e381484..b16e240 100755 --- a/hooks/bg-task-stop-guard.py +++ b/hooks/bg-task-stop-guard.py @@ -108,8 +108,11 @@ def reason(tasks): "the condition the task produces works, e.g. polling its output file or " "`gh pr checks`, with timeout under 10 minutes; run it again if it times out. " "A long leading `sleep` is refused by the harness.\n" - " - A subagent is done when its completion notification arrives. Its output " - "file can show an ended turn while a shell it left behind is still working.\n" + " - A subagent's output file is its transcript and already contains its own " + "prompt, so grepping it for a token from that prompt matches at once. Wait instead " + "until it holds the final assistant message (earlier turns carry tool_use): " + "`until grep -q '\"stop_reason\":\"end_turn\"' FILE; do sleep 5; done`. " + "A shell it left behind is a separate task.\n" " - For a CI wait, re-run `p9 watch ` in the foreground (exit 8 means run it again).\n" "If a task is meant to outlive this turn (a dev server, a standing monitor), say " "so in one line and end the turn. This guard never asks twice about the same task. " diff --git a/tests/bg-task-stop-guard.test.sh b/tests/bg-task-stop-guard.test.sh index a91d192..c0ab9a1 100755 --- a/tests/bg-task-stop-guard.test.sh +++ b/tests/bg-task-stop-guard.test.sh @@ -72,6 +72,10 @@ if [ "$RC" = 2 ] && grep -q "FOREGROUND" <<<"$ERR" && grep -q "subagent a269ed5d else bad "pending subagent under Paseo: rc=$RC"; fi if grep -q '"verdict": "BLOCK"' "$STATE/bg-task-guard.jsonl" 2>/dev/null; then ok "BLOCK logged"; else bad "BLOCK not logged"; fi +if grep -q '"stop_reason":"end_turn"' <<<"$ERR" && grep -q "already contains its own prompt" <<<"$ERR"; then + ok "re-prompt names the subagent completion condition, not a prompt token" +else bad "re-prompt lacks the subagent end_turn condition"; fi + run "$FX/shell-pending.json" "${PASEO[@]}" if [ "$RC" = 2 ] && grep -q "sleep 90; echo done" <<<"$ERR"; then ok "pending shell (or Monitor) under Paseo: exit 2, names the command"; else bad "pending shell under Paseo: rc=$RC"; fi @@ -215,6 +219,7 @@ if [ -z "$QUIET" ]; then "lifetime-cap|$G|LIFE_MAX = 20|LIFE_MAX = 99" "prompt-cap|$G|PROMPT_MAX = 2|PROMPT_MAX = 99" "id-key|$G|return str(t.get(\"id\") or|return str(__import__(\"time\").time_ns()) or str(t.get(\"id\") or" + "subagent-wait|$G|already contains its own |contains no " "opt-out|$G|== \"0\":|== \"never\":" "fail-closed|$G| except Exception: return 0| except Exception: From 16fa12fb343f3c67b418d29acc05bb4d460e5a34 Mon Sep 17 00:00:00 2001 From: "Carlos D. Escobar-Valbuena" Date: Fri, 9 Oct 2026 08:06:45 -0500 Subject: [PATCH 2/4] =?UTF-8?q?fix(hooks):=20bg-task-stop-guard=20P20=20ro?= =?UTF-8?q?und=201=20=E2=80=94=20the=20notification,=20not=20a=20transcrip?= =?UTF-8?q?t=20grep,=20says=20a=20subagent=20is=20done=20(BRO-2815)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stratum B round 1 (6, AXES 2,2,1,0,1) reproduced that end_turn matches early in 60/359 real subagent transcripts (SendMessage resumes, own-shell wakes) and never appears in 14 (synthetic session-limit end). No grep on the transcript proves completion: the re-prompt now makes the delivered the signal, a grep only a cue, and restores the left-behind-shell warning. CHANGELOG corrected. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 25 +++++++++++++++---------- hooks/bg-task-stop-guard.py | 12 +++++++----- tests/bg-task-stop-guard.test.sh | 8 ++++---- 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c16e1a5..ca88e06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,20 +2,25 @@ ## 0.45.1 — 2026-10-09 -### fix(hooks): bg-task-stop-guard names a subagent wait condition that cannot match early (BRO-2815) +### fix(hooks): bg-task-stop-guard says when a background subagent is actually done (BRO-2815) -Dogfooded on a real Paseo turn (agent acb1ab0b, session cc0aedf2). The guard blocked the turn-end on a +I dogfooded #134 on a real Paseo turn (agent acb1ab0b, session cc0aedf2). The guard blocked the turn-end on a background subagent, and the session waited in the foreground as told. But its until-loop grepped the -subagent's output file for the token it expected back. That file is the subagent's transcript, and it -already contains the subagent's own prompt, token included. So the loop matched within 2 s, and the model -reported a verdict it had not received yet. +subagent's output file for the token it expected back. That file is the subagent's transcript, and it already +holds the subagent's own prompt, so the loop matched in 2 s. The model then reported a verdict it had not +received; the real one landed 56 s later. -The re-prompt now says so. It names the condition that marks a finished subagent: the transcript holds the -final assistant message, `"stop_reason":"end_turn"`. Earlier turns carry `tool_use`. In that transcript -the line appeared once, at completion, followed by an attachment line, so the wait has to grep the whole -file; `tail -n1` misses it. +**No grep on that transcript proves a subagent is done.** My first fix proposed `"stop_reason":"end_turn"` as +the completion condition. Cross-review checked it against 400 real subagent transcripts: +- 60 of the 359 that contain `end_turn` have an earlier one. In 48 of those, a SendMessage resumed the agent + after an end_turn; in 7, the agent's own background shell woke it. +- 14 never write `end_turn` at all, because they end on a synthetic session-limit message. -`tests/bg-task-stop-guard.test.sh` pins the wording, and a 16th mutant that drops it is killed. +The re-prompt now says that a subagent is finished only when its `` for that task id has +been delivered. That notification arrives after a foreground call returns. A transcript grep is a cue to look +again, never proof. The warning about a shell left running behind an ended turn is back. + +`tests/bg-task-stop-guard.test.sh` pins the rule, and a 16th mutant that drops it is killed. ## 0.45.0 — 2026-10-09 diff --git a/hooks/bg-task-stop-guard.py b/hooks/bg-task-stop-guard.py index b16e240..3c54606 100755 --- a/hooks/bg-task-stop-guard.py +++ b/hooks/bg-task-stop-guard.py @@ -108,11 +108,13 @@ def reason(tasks): "the condition the task produces works, e.g. polling its output file or " "`gh pr checks`, with timeout under 10 minutes; run it again if it times out. " "A long leading `sleep` is refused by the harness.\n" - " - A subagent's output file is its transcript and already contains its own " - "prompt, so grepping it for a token from that prompt matches at once. Wait instead " - "until it holds the final assistant message (earlier turns carry tool_use): " - "`until grep -q '\"stop_reason\":\"end_turn\"' FILE; do sleep 5; done`. " - "A shell it left behind is a separate task.\n" + " - A subagent is finished only when its for that task id has " + "been delivered to you (it arrives after a foreground call returns). Its output file " + "is its transcript. That file already holds the subagent's own prompt, and an " + "end_turn in it can be followed by more work: a resume, or a shell the subagent " + "left running. So a grep on the file is a cue to look again, never proof the " + "subagent is done. Keep making bounded foreground waits until the notification " + "has arrived, and only then use the result.\n" " - For a CI wait, re-run `p9 watch ` in the foreground (exit 8 means run it again).\n" "If a task is meant to outlive this turn (a dev server, a standing monitor), say " "so in one line and end the turn. This guard never asks twice about the same task. " diff --git a/tests/bg-task-stop-guard.test.sh b/tests/bg-task-stop-guard.test.sh index c0ab9a1..8e60932 100755 --- a/tests/bg-task-stop-guard.test.sh +++ b/tests/bg-task-stop-guard.test.sh @@ -72,9 +72,9 @@ if [ "$RC" = 2 ] && grep -q "FOREGROUND" <<<"$ERR" && grep -q "subagent a269ed5d else bad "pending subagent under Paseo: rc=$RC"; fi if grep -q '"verdict": "BLOCK"' "$STATE/bg-task-guard.jsonl" 2>/dev/null; then ok "BLOCK logged"; else bad "BLOCK not logged"; fi -if grep -q '"stop_reason":"end_turn"' <<<"$ERR" && grep -q "already contains its own prompt" <<<"$ERR"; then - ok "re-prompt names the subagent completion condition, not a prompt token" -else bad "re-prompt lacks the subagent end_turn condition"; fi +if grep -q "only when its " <<<"$ERR" && grep -q "never proof" <<<"$ERR"; then + ok "re-prompt: a subagent is done at its notification; a transcript grep is never proof" +else bad "re-prompt lacks the subagent completion rule"; fi run "$FX/shell-pending.json" "${PASEO[@]}" if [ "$RC" = 2 ] && grep -q "sleep 90; echo done" <<<"$ERR"; then ok "pending shell (or Monitor) under Paseo: exit 2, names the command"; else bad "pending shell under Paseo: rc=$RC"; fi @@ -219,7 +219,7 @@ if [ -z "$QUIET" ]; then "lifetime-cap|$G|LIFE_MAX = 20|LIFE_MAX = 99" "prompt-cap|$G|PROMPT_MAX = 2|PROMPT_MAX = 99" "id-key|$G|return str(t.get(\"id\") or|return str(__import__(\"time\").time_ns()) or str(t.get(\"id\") or" - "subagent-wait|$G|already contains its own |contains no " + "subagent-wait|$G|never proof|proof" "opt-out|$G|== \"0\":|== \"never\":" "fail-closed|$G| except Exception: return 0| except Exception: From 988f4112eda49ff16c0e124be55c2d66c315ed44 Mon Sep 17 00:00:00 2001 From: "Carlos D. Escobar-Valbuena" Date: Fri, 9 Oct 2026 08:16:01 -0500 Subject: [PATCH 3/4] test(hooks): pin the restored left-running-shell warning; CHANGELOG sample source (BRO-2815) P20 round 2 minors (round 2: 10/10 APPROVE). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 +++++++--- tests/bg-task-stop-guard.test.sh | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca88e06..d4615af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,13 @@ holds the subagent's own prompt, so the loop matched in 2 s. The model then repo received; the real one landed 56 s later. **No grep on that transcript proves a subagent is done.** My first fix proposed `"stop_reason":"end_turn"` as -the completion condition. Cross-review checked it against 400 real subagent transcripts: -- 60 of the 359 that contain `end_turn` have an earlier one. In 48 of those, a SendMessage resumed the agent - after an end_turn; in 7, the agent's own background shell woke it. +the completion condition. Cross-review checked it against the 400 most recent of the 2,202 subagent +transcripts under `~/.claude/projects/*/*/subagents/`: +- 60 of the 359 that contain `end_turn` have an earlier one: + - in 48, a SendMessage resumed the agent after an end_turn; + - in 7, the agent's own background shell woke it; + - in 3, the final message was split across two lines; + - in 2, the agent kept working with no resume. - 14 never write `end_turn` at all, because they end on a synthetic session-limit message. The re-prompt now says that a subagent is finished only when its `` for that task id has diff --git a/tests/bg-task-stop-guard.test.sh b/tests/bg-task-stop-guard.test.sh index 8e60932..ef08bf9 100755 --- a/tests/bg-task-stop-guard.test.sh +++ b/tests/bg-task-stop-guard.test.sh @@ -72,7 +72,7 @@ if [ "$RC" = 2 ] && grep -q "FOREGROUND" <<<"$ERR" && grep -q "subagent a269ed5d else bad "pending subagent under Paseo: rc=$RC"; fi if grep -q '"verdict": "BLOCK"' "$STATE/bg-task-guard.jsonl" 2>/dev/null; then ok "BLOCK logged"; else bad "BLOCK not logged"; fi -if grep -q "only when its " <<<"$ERR" && grep -q "never proof" <<<"$ERR"; then +if grep -q "only when its " <<<"$ERR" && grep -q "never proof" <<<"$ERR" && grep -q "left running" <<<"$ERR"; then ok "re-prompt: a subagent is done at its notification; a transcript grep is never proof" else bad "re-prompt lacks the subagent completion rule"; fi @@ -220,6 +220,7 @@ if [ -z "$QUIET" ]; then "prompt-cap|$G|PROMPT_MAX = 2|PROMPT_MAX = 99" "id-key|$G|return str(t.get(\"id\") or|return str(__import__(\"time\").time_ns()) or str(t.get(\"id\") or" "subagent-wait|$G|never proof|proof" + "shell-warning|$G|a shell the subagent |the subagent " "opt-out|$G|== \"0\":|== \"never\":" "fail-closed|$G| except Exception: return 0| except Exception: From 406f0f6a986d7d2c0c0216cdb1de7910438cddf1 Mon Sep 17 00:00:00 2001 From: "Carlos D. Escobar-Valbuena" Date: Fri, 9 Oct 2026 08:25:49 -0500 Subject: [PATCH 4/4] test(hooks): shell-warning mutant removes the phrase it pins (BRO-2815) Co-Authored-By: Claude Opus 5.5 --- tests/bg-task-stop-guard.test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bg-task-stop-guard.test.sh b/tests/bg-task-stop-guard.test.sh index ef08bf9..be89992 100755 --- a/tests/bg-task-stop-guard.test.sh +++ b/tests/bg-task-stop-guard.test.sh @@ -220,7 +220,7 @@ if [ -z "$QUIET" ]; then "prompt-cap|$G|PROMPT_MAX = 2|PROMPT_MAX = 99" "id-key|$G|return str(t.get(\"id\") or|return str(__import__(\"time\").time_ns()) or str(t.get(\"id\") or" "subagent-wait|$G|never proof|proof" - "shell-warning|$G|a shell the subagent |the subagent " + "shell-warning|$G|left running|finished" "opt-out|$G|== \"0\":|== \"never\":" "fail-closed|$G| except Exception: return 0| except Exception: