Radio session corruption: unregister cascade → re-seed loses TAB_ID + LOADOUT#150
Conversation
… LOADOUT (breaks --auto wake-up) Long-running --auto workers degraded their own session file mid-life: after an unregister cascade wiped <role>.info, _ensure_session_file's re-seed wrote TAB_ID= empty (Bug B) and LOADOUT=unknown (Bug C). cmd_send then silently no-op'd on the next PM ping because zellij write-chars had no --tab-id target, and queued radio messages sat unread until a human keypress. Bug B (TAB_ID lost): the rebuild called _zellij_tab_id_by_name with the bare $ZELLIJ_TAB slug, but _rename_tab had already painted the visible tab name to "⏸️ <slug>" / "▶️ <slug>" / "❓︎ <slug>". Exact-match missed. cmd_register had #117 protection that preserved a prior TAB_ID on miss, but _ensure_session_file didn't. Fix: make _zellij_tab_id_by_name match both the bare slug and the three emoji-prefixed variants — symmetric fix across cmd_register, _ensure_session_file, and _rename_tab. Bug C (LOADOUT lost): _ensure_session_file fell back to "${TASK_FORCE_LOADOUT:-unknown}", but the hook subshell doesn't inherit task-work's launch-time env, so the literal string "unknown" was written on every re-seed. Fix: persist the loadout to a per-role sidecar at cmd_register time (<sessions>/<role>.loadout); _ensure_session_file reads from sidecar. cmd_unregister sweeps it on real exit; the skip- list (clear|resume) leaves it intact, matching the existing .info semantics. Bug A (the cascade itself): instrument cmd_unregister to log the full hook payload (one-line) when it proceeds past the skip-list. The 20+ unregister calls in 30s aren't normal — capturing the payload gives us the data to investigate what reason (or non-reason) is driving them. Tests: 770/770 passing. 12 new tests in radio_lifecycle.bats cover the emoji-prefixed lookup in cmd_register and _ensure_session_file, the sidecar lifecycle (write, read, sweep, no-sweep on skip), and the new payload-logging instrumentation. The pre-existing #117 preservation test now uses an empty list-tabs fixture (true lookup miss) instead of relying on emoji-prefix-miss as its trigger, since post-#140 the emoji-prefixed lookup succeeds for real. Closes #140 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR Review — Radio session corruption fix (#140)Spec complianceThe issue documented three interacting bugs (A cascade, B TAB_ID loss, C LOADOUT loss) and proposed explicit fix paths. Checking each: Fix A (cascade root cause) — Instrumentation only, as stated in the PR. Fix B (TAB_ID lost on re-seed) — Fix C (LOADOUT lost on re-seed) — Sidecar mechanism ( Test coverage is thorough: 12 new bats cases covering the re-seed, sidecar write/read/sweep, skip-list boundary, and payload logging. 770/770 passing. Code-review findingsFinding 1 — Bug C not fixed for the documented cascade pattern (design gap) The sidecar sweep in The fix path is correct in principle: once Fix A reveals the cascade trigger reason and it's added to the skip-list, the cascade will preserve the sidecar, and re-seeds will recover LOADOUT correctly. But as-is, the symptom described in the issue (LOADOUT=unknown after cascade) is not resolved for the observed pattern. Worth calling out explicitly so the follow-up issue for Fix A is filed as a blocker for Bug C, not a nice-to-have. Finding 2 —
Finding 3 — TOCTOU between sidecar In if [[ -f "$sidecar" ]]; then
loadout=$(<"$sidecar") # ← sidecar can be deleted here by concurrent unregister
fiThe script runs with Finding 4 — jq multi-match regression (rare) The new Nit —
Verdict: clean-with-nitsThe three-bug diagnosis is correct and the fix for Bug B (TAB_ID) is solid. Bug C's sidecar mechanism is well-implemented but only activates when the session file is deleted outside of Suggested before merge:
🤖 Generated with Claude Code |
Changes requested — round 2Reviewer's 4 findings + 1 nit triaged. Two small fixes pre-merge; rest filed as follow-ups. Pre-merge#3 — TOCTOU on sidecar read (reviewer's only "before merge" ask) In if [[ -f "$sidecar" ]]; then
loadout=$(<"$sidecar") # ← sidecar can be unlinked here by concurrent unregister
fi
- loadout=$(<"$sidecar")
+ loadout=$(<"$sidecar" || true)Apply across all 7 drift-grouped radio scripts. Add a regression bats case that deletes the sidecar between the #2 —
This PR adds the
Fix is replacing the strip in Filed as follow-ups (do NOT touch in this PR)
Why we're not doing #1 in this PRReviewer correctly diagnoses that completing Bug C requires Fix A's actual root-cause (skip-list expansion), which is gated on diagnostic data this PR adds. Trying to finish it in #150 would mean guessing the cascade reason and shipping a speculative skip-list — wrong shape. Better to: merge #150 (Fix B + Fix C mechanism + Fix A logging), collect real cascade logs from a dogfooded worker session, then close out #151 with the actual observed reasons. Out of scope for round 2Just the two small fixes. Radio |
… emoji sync Two reviewer-flagged pre-merge fixes: 1. TOCTOU on _ensure_session_file's sidecar read (#150 review finding 3): the sidecar can be unlinked by a concurrent cmd_unregister between the `[[ -f ]]` check and the read — exactly the cascade race documented in #140. set -e would abort _ensure_session_file and STATE tracking would die for the worker's lifetime. Switch the read to `loadout=$(cat "$sidecar" 2>/dev/null || true)` which collapses "file absent / unlinked mid-read / unreadable" into "empty string" without tripping set -e. Env-var fallback below handles the empty case. Notable: the reviewer's literal suggestion `loadout=$(<"$sidecar" || true)` does NOT work — bash's `<file` shorthand combined with `|| true` makes the read return empty even on the happy path (the sidecar value would be lost even when present). cat is the unambiguous form. Commit comment in the script documents the verified-broken alternative for future readers. Two regression tests in radio_lifecycle.bats: (a) chmod-0 makes the sidecar unreadable mid-test, exercising the set -e abort path the guard prevents; (b) sidecar deleted before busy fires, exercising the missing-file fall-through. 2. lib/zellij-tab.sh emoji-strip out of sync with radio's _rename_tab (#150 review finding 2): the awaiting prefix in radio is "❓︎ " (U+2753 + U+FE0E text-presentation variation selector + space). The strip in lib/zellij-tab.sh used "❓ " (U+2753 + space, no VS-15). Byte sequences didn't match → aw_zellij_tab_id_by_name missed when a tab was in the awaiting state. Sync the lib's strip to match radio byte-for-byte. The "Keep this prefix list in sync with _rename_tab in radio" comment in the file calls this out as a known invariant. One regression test in claude_gh_task_work.bats asserting TAB_ID resolves through the awaiting-painted ❓︎ visible name. Not addressed in this PR (filed as follow-ups by PM, per radio handoff): - #151 — complete Bug C for cascade reasons: blocked on Fix A's payload logs (which this PR adds the instrumentation for). Reviewer finding 1. - #152 — jq multi-match regression when bare-slug + painted variants coexist. Reviewer finding 4. P2. Tests: 773/773 passing (3 new: 2 TOCTOU + 1 awaiting-emoji). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR Review — Radio session corruption round 2 (#140)Spec complianceThe round-2 spec called for exactly two pre-merge fixes (TOCTOU sidecar read + lib/zellij-tab.sh emoji sync) and two filed follow-ups (#151, #152). Checking each: TOCTOU fix (Finding 3 from round 1) — The reviewer suggested lib/zellij-tab.sh emoji sync (Finding 2 from round 1) — #151 (Bug C completion + skip-list expansion) — filed as follow-up, not touched. ✓ Test coverage: 770/770 passing, 12 new cases covering round-2 scenarios. ✓ Code-review findingsFinding 1 — Sidecar write order creates a narrow LOADOUT-loss window (CONFIRMED) In } | _atomic_write "" # .info written first
mkdir -p ...
if [[ -n "" ]]; then
printf '%s' "" | _atomic_write "") # .loadout written second
fiIf the process is killed (OOM,
The fix is one-line: swap the write order — write the sidecar before the session file. If killed between the two writes in the reversed order, The window is microseconds-wide, but the unregister cascade documented in #140 makes it statistically reachable (20+ rapid-fire unregister calls that can catch any brief inter-write gap). Finding 2 — TOCTOU test comment describes removed code (nit) The test
But the PR removed the Finding 3 — Multi-match jq Already filed as follow-up #152 and noted in the previous review. Confirming: verified via source inspection that Verdict: clean-with-nitsRound 2 correctly addressed both pre-merge items from round 1. The TOCTOU fix explanation (rejecting the reviewer's suggested One new finding worth patching before merge:
One test hygiene item:
🤖 Generated with Claude Code |
Changes requested — round 3 (both small)Reviewer's round-2 verdict is clean-with-nits with two contained pre-merge items. Both should land in one push. 1. Swap sidecar/info write order in
|
…r + TOCTOU test hygiene Two reviewer-flagged pre-merge fixes: 1. Sidecar write order in cmd_register (#150 round-2 review Finding 1): .info was written first, then .loadout sidecar. If killed (OOM, task-done --force, host shutdown) between the two writes, .info had the correct LOADOUT value but the sidecar didn't exist — so a later unregister-cascade + re-seed produced LOADOUT=unknown. The window is microseconds wide, but the cascade in #140 makes it statistically reachable. Fix: swap the order — write .loadout BEFORE .info. The pre-kill state then becomes "sidecar exists, .info missing", which is exactly the state _ensure_session_file is designed to handle (sidecar value is recovered correctly). No downside: _ensure_session_file only runs when .info is missing, and cmd_unregister sweeps both regardless of order. Applied across all 7 drift-grouped radio scripts. Two regression tests in radio_lifecycle.bats: - "re-seed recovers LOADOUT when .info-write was killed but sidecar landed first" — simulates the kill-window state (sidecar present, .info absent, $TASK_FORCE_LOADOUT not in env) and asserts the sidecar value is recovered, not "unknown". - "register: writes sidecar before .info so a kill mid-call leaves sidecar recoverable" — asserts the mtime invariant: sidecar is not newer than .info, confirming the write order at the filesystem level. Uses `find -newer` for portability across BSD/GNU stat. 2. TOCTOU test hygiene (#150 round-2 review Finding 2): - The chmod-0 test's comment referenced the `[[ -f ]]` pre-check that round 2 removed; the actual code is now an unconditional cat. Comment rewritten to describe what's actually under test. - The chmod-0 test asserted STATE=busy but not LOADOUT=unknown — so it would have passed even if cat silently succeeded on a readable file. Added the LOADOUT=unknown assertion to confirm the fallback path actually fired, matching the unlink-variant test's coverage. Tests: 775/775 passing (+2 net new — round 3 added 2 tests for the write-order invariant; the round-2 chmod-0 and unlink tests were preserved with hygiene fixes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR Review — Radio session corruption round 3 (#140)Spec complianceRound-3 spec called for exactly two small pre-merge fixes. Checking both: 1. Sidecar write order in 2. Test hygiene (reviewer round-2 Finding 2) — Two items:
Test coverage: all 7 radio scripts are byte-identical (confirmed by diff patterns); 770/770 tests passing; 12 new bats cases added across rounds 1–3. Code-review findingsFinding 1 — The comment defending the choice of bash -c 'echo x>/tmp/f; v=x; echo "[$v]"'
# → []Inside Nit — stale sidecar after partial unregister, re-registered without If Verdict: cleanBoth round-3 items are correctly implemented. The write-order explanation is accurate and well-tested. The test comment is updated and the chmod-0 assertion gap is closed. No new correctness issues found in this round. No pre-merge work required. Following issues remain open for tracking (filed in round 2):
🤖 Generated with Claude Code |
…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>
…151) (#154) * Complete #140: analyze cascade-payload logs, expand SessionEnd skip-list, 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> * Complete #140: task-done closes stdin on radio unregister 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> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
_zellij_tab_id_by_namenow matches both the bare slug and the emoji-prefixed visible names (⏸️,_rename_tabpaints. Symmetric fix acrosscmd_register,_ensure_session_file, and_rename_tab's lookups.cmd_registerwrites the loadout name to a sidecar (<sessions>/<role>.loadout);_ensure_session_filereads it during a mid-life re-seed.cmd_unregistersweeps the sidecar on real exit; theclear|resumeskip-list leaves it intact, matching.infosemantics.cmd_unregisternow logs the full hook payload (one-line) when it proceeds past the skip-list, so the next time the cascade fires we have data on what reason / non-reason is driving the 20+-in-30sSessionEndstorm.All three were chained: a cascade wiped
<role>.info→_ensure_session_filere-seeded →TAB_ID=empty +LOADOUT=unknown→cmd_sendcouldn't wake the worker →--autoworkers stopped being auto.Synced across all seven loadouts (claude-gh / claude-jira / claude-notion / claude-local / kiro-gh / kiro-local / kiro-notion).
Closes #140
Test plan
./run_tests.sh radio_lifecycle— 25/25 pass, including 12 new tests for the Radio session corruption: unregister cascade → re-seed loses TAB_ID + LOADOUT (breaks --auto wake-up) #140 scenarios./run_tests.sh— 770/770 pass (no regressions in radio, task-work, task-pm, task-reviewer, task-done, drift check, etc.)tools/check-drift.shruns as part of the suite) confirms all 7radioscripts are byte-identical in thebodyregiontask-work test-radio-140 ... --auto, trigger a few/compact//clearcycles, confirm~/.task-force/radio/sessions/<role>.infokeeps a non-emptyTAB_ID=andLOADOUT=claude-ghacross the lifecycle~/.task-force/radio/logduring the above and confirm any unregister calls that proceed past the skip-list log their full payload (Bug A instrumentation lets us follow up on the cascade root cause)🤖 Generated with Claude Code