Skip to content

Complete #140: cascade reasons fully skipped + AGENT sidecar (closes #151)#154

Merged
martin-conur merged 2 commits into
mainfrom
task/issue-151
Jun 2, 2026
Merged

Complete #140: cascade reasons fully skipped + AGENT sidecar (closes #151)#154
martin-conur merged 2 commits into
mainfrom
task/issue-151

Conversation

@martin-conur

Copy link
Copy Markdown
Owner

Summary

Completes the #140 fix that PR #150 left half-done. The diagnostic logging shipped in #150 surfaced the actual cascade signature on a long-running worker: 144 unregister calls in ~25s, payload literally a single y character, reason=<unset> — phantom invocations from somewhere upstream of Claude Code's documented SessionEnd hook (which always includes reason ∈ {clear, resume, logout, prompt_input_exit, other}). Each phantom call wiped ~/.task-force/radio/sessions/<role>.info; the sidecar mechanism from #150 wasn't reached on this code path; the next re-seed produced LOADOUT=unknown / AGENT=claude and cmd_send's tab-id-based wake-up died for the rest of the worker's life.

Diagnostic findings

From ~/.task-force/radio/log:

2026-06-02T20:08:35Z unregister: proceeding (reason=<unset>) role=worker-task-force-issue-148 payload=y
2026-06-02T20:08:35Z unregister role=worker-task-force-issue-148
…144 lines, all identical reason=<unset> payload=y…

Frequency table of payload= values across the log: 144 × y, 0 of anything else. Skip-list hits (clear/resume): 9. So 100% of unaccounted-for unregister calls are this single signature.

Changes

Three concrete changes in cmd_unregister + cmd_register + _ensure_session_file:

  1. cmd_unregister's skip-list now also short-circuits on empty .reason in the JSON payload. Treats the cascade pattern the same as clear/resume. logout / prompt_input_exit / other still proceed normally.

  2. AGENT sidecar parallel to the existing LOADOUT sidecar. Same hook-subshell unset problem: \$TASK_FORCE_AGENT isn't inherited into Claude Code's hook env, so re-seeds defaulted to AGENT=claude regardless of whether the worker was kiro-*. Same write/read/sweep semantics; cmd_register writes both sidecars BEFORE the .info (preserves the kill-window invariant from Radio session corruption: unregister cascade → re-seed loses TAB_ID + LOADOUT #150 round-3).

  3. cmd_unregister sweeps the new agent sidecar on real exit, leaves it alone on skip — symmetric to loadout sidecar behaviour.

All 7 radio scripts updated (drift-checked region:body). 7 new bats cases pin the new contract end-to-end, including a 20× cascade-burst that asserts LOADOUT/AGENT/TAB_ID all survive.

CHANGELOG

Single combined [Unreleased] Fixed entry covering #140 + #150 + #151 (the PR-#150-only entry never landed). Frames the three concrete fixes (Fix B, Fix C, cascade skip) as a single bug cluster — that's how it'll read to anyone debugging similar symptoms on v0.3.0.

Test plan

  • ./run_tests.sh radio_lifecycle — 38 lifecycle tests pass, including 7 new Complete #140: analyze cascade-payload logs, expand SessionEnd skip-list, restore LOADOUT/AGENT preservation #151 cases
  • ./run_tests.sh — full suite (~785 tests) passes, exit 0
  • ./tools/check-drift.sh — all 21 sentinel-region groups byte-identical
  • shellcheck claude-gh/bin/radio — clean
  • Smoke-test on a real --auto worker: run task-work --auto … long enough to trigger the cascade (PM workflow + several subagent ends), then cat ~/.task-force/radio/sessions/worker-*.info and confirm TAB_ID non-empty, LOADOUT correct, AGENT correct, tail ~/.task-force/radio/log shows no _ensure_session: re-seeded lines (PM verification step from the issue).

Closes #151.

🤖 Generated with Claude Code

@martin-conur

Copy link
Copy Markdown
Owner Author

Spec compliance

All 5 deliverables from #151 are present:

  1. Cascade diagnosis — PR body documents the smoking gun: 144 invocations of radio unregister in ~25 s, payload literally y, reason=<unset>. Evidence from real log lines is quoted. ✓
  2. Skip-list expansion — all 7 radio scripts (claude-gh, claude-jira, claude-local, claude-notion, kiro-gh, kiro-local, kiro-notion) add the "" case to cmd_unregister's reason switch. Drift-check in the test plan is noted as passing. ✓
  3. AGENT sidecar_agent_sidecar() / write in cmd_register / read in _ensure_session_file / sweep in cmd_unregister added in all 7 scripts, parallel to LOADOUT. ✓
  4. Tests — 11 new bats cases in tests/radio_lifecycle.bats (not 7 as the summary says — there are 11; either way, all the required scenarios are covered). Includes a 20× cascade-burst E2E test asserting LOADOUT, AGENT, and TAB_ID all survive. ✓
  5. CHANGELOG[Unreleased] Fixed entry updated to describe the three concrete fixes as one complete bug cluster. ✓

No missing deliverables. The spec's live smoke-test is left as an open checkbox in the PR description, which is appropriate for a real-session verification.


Code-review findings

Finding 1 — "" case skips even when jq is absent (pre-existing constraint, not a regression) — nit

File: claude-gh/bin/radio, line 485 (and all 6 other copies)

The "" cascade-skip is inside if [[ -n "$payload" ]] && command -v jq >/dev/null 2>&1. When jq is absent and the cascade fires (payload=y), the inner if is false → the case is never reached → execution falls through to the delete path. The fix only works when jq is installed.

This is a pre-existing constraint from before this PR (the clear|resume skip also requires jq), so it's not a regression. But it's worth noting: cascade protection silently degrades to no-op if jq is uninstalled. The PR comment says "manual invocations (no stdin) also proceed because [[ ! -t 0 ]] is false then" — that's correct, but doesn't address the jq-absent case. Not a blocker since jq is a hard runtime dependency, but the comment slightly overstates the protection.

Finding 2 — {"reason": null} also matches the "" case — defensively correct but subtly surprising

File: claude-gh/bin/radio, line 492

jq -r '.reason // empty' on {"reason": null} returns empty string (null is falsy in jq, so // empty triggers and jq -r renders it as empty). reason="", the "" case matches, and the call is treated as a cascade-skip.

This is actually the right behavior — reason: null isn't in the documented real-exit set — but it's non-obvious. Someone debugging a future "why didn't this unregister clean up?" scenario would need to know that null reason routes through the skip. No fix needed; a note in the comment would be sufficient. Not a blocker.

Finding 3 — E2E test hardcodes TAB_ID=7 — fragile coupling to stub internals

File: tests/radio_lifecycle.bats, line 1311 of the diff (the end-to-end: cascade burst test)

run grep "^TAB_ID=" "$sess"
assert_output "TAB_ID=7"

7 is the tab ID that seed_zellij_tabs worker-foo happens to seed for this test. If the stub helper is ever updated (different initial tab numbering, different seeding order), this assertion silently becomes wrong rather than failing loudly. The existing LOADOUT/AGENT assertions use literal values that come from --loadout and --agent args, which are test-local. The 7 comes from a black-box helper.

Not a correctness bug in production code. The fix would be something like capturing the registered TAB_ID and comparing against it rather than the literal, but that's a test refactor. Noting it so it doesn't cause confusion in a future maintenance pass.


Verdict

clean-with-nits

The core fix (skip-list + AGENT sidecar) is correct, symmetric, and comprehensively tested. All 7 radio scripts are in sync. The logic is sound: real exits (logout, prompt_input_exit, other) still flow through to the delete path; the cascade (reason empty or unparseable) and intra-session events (clear, resume) are all short-circuited. None of the three findings above block merge.

martin-conur and others added 2 commits June 2, 2026 18:32
…ist, restore LOADOUT/AGENT preservation

PR #150 left the cascade pattern from #140 unaddressed. The diagnostic
logging it shipped surfaced the actual cascade signature on a real worker
run: 144 unregister calls in ~25s, payload literally a single "y"
character, reason=<unset> — phantom invocations upstream of Claude Code's
documented SessionEnd hook (which always includes reason ∈ {clear,
resume, logout, prompt_input_exit, other}). Each phantom call wiped
.info, the sidecar mechanism from #150 wasn't reached, and the next
re-seed produced LOADOUT=unknown / AGENT=claude.

Three concrete changes in cmd_unregister + cmd_register + _ensure_session_file:

1. cmd_unregister's skip-list now also short-circuits on empty `.reason`
   in the JSON payload. Treats the cascade pattern the same as
   clear/resume — known non-real-exit events that must not wipe state.
   logout / prompt_input_exit / other still proceed normally.

2. AGENT sidecar parallel to the existing LOADOUT sidecar. Same
   hook-subshell unset problem: $TASK_FORCE_AGENT isn't inherited into
   Claude Code's hook env, so re-seeds defaulted to AGENT=claude
   regardless of whether the worker was kiro-*. Same write/read/sweep
   semantics; cmd_register writes both sidecars BEFORE the .info to
   preserve the kill-window invariant from #150 round-3.

3. cmd_unregister sweeps the new agent sidecar on real exit, leaves it
   alone on skip — symmetric to loadout sidecar behaviour.

All 7 radio scripts updated (drift-checked region:body). 7 new bats
cases pin the new contract end-to-end, including a 20× cascade-burst
that asserts LOADOUT/AGENT/TAB_ID all survive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
task-done invocations under `--force` typically have piped confirmation
input (`echo y | task-done --force`); that stdin was inherited by the
embedded `radio unregister` call. Pre-#151 it landed as garbage payload
that jq couldn't parse — radio fell through to the proceed-with-delete
path and cleanup worked. Post-#151 the same garbage payload routes to
the new cascade-skip case (empty `.reason`), so the session file is left
behind. Caught by tests/task_done.bats:assert_task_done_unregisters (7
loadout variants).

Fix: redirect the unregister's stdin from /dev/null in task-done so the
hook payload parsing sees an empty stdin and proceeds normally. All 7
task-done scripts updated (drift-checked: task-done-std/confirm-and-cleanup
and task-done-local/confirm-and-cleanup byte-identical within group).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@martin-conur martin-conur merged commit 8a1c961 into main Jun 2, 2026
4 checks passed
@martin-conur martin-conur deleted the task/issue-151 branch June 2, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Complete #140: analyze cascade-payload logs, expand SessionEnd skip-list, restore LOADOUT/AGENT preservation

1 participant