Skip to content

Radio session corruption: unregister cascade → re-seed loses TAB_ID + LOADOUT#150

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

Radio session corruption: unregister cascade → re-seed loses TAB_ID + LOADOUT#150
martin-conur merged 3 commits into
mainfrom
task/issue-140

Conversation

@martin-conur

Copy link
Copy Markdown
Owner

Summary

  • Bug B (TAB_ID lost on re-seed): _zellij_tab_id_by_name now matches both the bare slug and the emoji-prefixed visible names (⏸️, ▶️, ❓︎) that _rename_tab paints. Symmetric fix across cmd_register, _ensure_session_file, and _rename_tab's lookups.
  • Bug C (LOADOUT lost on re-seed): cmd_register writes the loadout name to a sidecar (<sessions>/<role>.loadout); _ensure_session_file reads it during a mid-life re-seed. cmd_unregister sweeps the sidecar on real exit; the clear|resume skip-list leaves it intact, matching .info semantics.
  • Bug A (the cascade itself): instrumentation only — cmd_unregister now 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-30s SessionEnd storm.

All three were chained: a cascade wiped <role>.info_ensure_session_file re-seeded → TAB_ID= empty + LOADOUT=unknowncmd_send couldn't wake the worker → --auto workers 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.)
  • Drift check (tools/check-drift.sh runs as part of the suite) confirms all 7 radio scripts are byte-identical in the body region
  • Manual smoke: spawn task-work test-radio-140 ... --auto, trigger a few /compact / /clear cycles, confirm ~/.task-force/radio/sessions/<role>.info keeps a non-empty TAB_ID= and LOADOUT=claude-gh across the lifecycle
  • Manual smoke: tail ~/.task-force/radio/log during 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

… 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>
@martin-conur

Copy link
Copy Markdown
Owner Author

PR Review — Radio session corruption fix (#140)

Spec compliance

The 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. cmd_unregister now logs the full payload when proceeding past the skip-list. The PR is explicit this is a data-gathering step, not a fix. ✓ matches spec.

Fix B (TAB_ID lost on re-seed)_zellij_tab_id_by_name now matches ⏸️ <slug>, ▶️ <slug>, and ❓︎ <slug> in addition to the bare name. The fix is symmetric across cmd_register and _ensure_session_file as the spec requested. Applied identically across all 7 loadout scripts; drift check confirms byte-for-byte parity. ✓ matches spec.

Fix C (LOADOUT lost on re-seed) — Sidecar mechanism (<role>.loadout) is implemented: written at register time, read during re-seed, swept on real exit, left intact on skip-list hits (clear|resume). However, see Finding 1 below — the sidecar is not preserved when the cascade fires with a non-skip reason (e.g. reason=logout or reason=<unset>), which is the empirically-observed cascade pattern from #140. The sidecar recovery only activates if the session file is deleted by something other than cmd_unregister. Partial fix — the full fix requires expanding the skip-list once Fix A's instrumentation identifies the cascade trigger. ⚠️ narrower than the spec implies.

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 findings

Finding 1 — Bug C not fixed for the documented cascade pattern (design gap)

The sidecar sweep in cmd_unregister (rm -f "$(_loadout_sidecar "$TASK_FORCE_ROLE")", line ~458) runs whenever the skip-list doesn't fire. The #140 log shows the cascade fires with non-skip reasons (empirically reason=logout, reason=<unset>). Each cascade call deletes both the session file AND the sidecar. When _ensure_session_file re-seeds, there is no sidecar to read — it falls back to ${TASK_FORCE_LOADOUT:-unknown}.

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 — lib/zellij-tab.sh emoji mismatch (pre-existing, newly exposed)

_rename_tab in radio writes the awaiting prefix as U+2753 U+FE0E + space (❓︎ with VS-15 text-presentation variation selector). lib/zellij-tab.sh's sub("^❓ "; "") strip (line ~49) uses U+2753 + space — no VS-15. These byte sequences don't match. When aw_zellij_tab_id_by_name runs against a tab in the awaiting state, it fails to strip the prefix and the lookup misses. This was not introduced by this PR (the mismatch predates it) but this PR adds the ❓︎ variant to radio's _zellij_tab_id_by_name and is the natural place to also sync lib/zellij-tab.sh. The issue spec says the fix should be "symmetric across … _rename_tab's tab-name lookups" — lib/zellij-tab.sh is a parallel implementation of the same lookup. There's a comment in the file saying "Keep this prefix list in sync with _rename_tab in radio" that was missed.

Finding 3 — TOCTOU between sidecar -f guard and read (narrow but real)

In _ensure_session_file:

if [[ -f "$sidecar" ]]; then
  loadout=$(<"$sidecar")   # ← sidecar can be deleted here by concurrent unregister
fi

The script runs with set -euo pipefail. If a concurrent cmd_unregister deletes the sidecar between the -f check and the $(<) read, the command substitution exits non-zero and set -e aborts _ensure_session_file. Session file is never written; STATE tracking stops for the worker's lifetime. Fix is one character: loadout=$(<"$sidecar" || true).

Finding 4 — jq multi-match regression (rare)

The new select() filter matches both "worker-foo" and "⏸️ worker-foo" when both are present. .[0] returns whichever appears first in zellij's output. If a user has a leftover bare-slug tab alongside the painted worker tab, the wrong tab ID can be returned. The old exact-match never had this ambiguity. Unlikely in practice but a genuine regression from the pre-fix semantics; worth adding a note or a filter that prefers the painted variant.

Nit — TASK_FORCE_AGENT has the same hook-subshell env problem as LOADOUT (pre-existing)

_ensure_session_file writes AGENT=${TASK_FORCE_AGENT:-claude}. $TASK_FORCE_AGENT is not available in the Claude Code hook subshell for the same reason $TASK_FORCE_LOADOUT wasn't. For kiro-* workers this means re-seeded sessions write AGENT=claude instead of AGENT=kiro. Currently AGENT is not consumed by any cmd_send path so there is no runtime impact — but if that changes, this becomes a silent bug. Not in scope for this PR (pre-existing) but worth tracking.


Verdict: clean-with-nits

The 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 cmd_unregister; for the cascade scenario it waits on Bug A's instrumentation to identify the skip-list addition. The TOCTOU race (Finding 3) is a one-line fix worth patching before merge. The lib/zellij-tab.sh emoji sync (Finding 2) is pre-existing but should be filed as a follow-up since the comment explicitly says to keep the prefix list in sync. Findings 1 and 4 are design-level observations rather than blockers.

Suggested before merge:

  • Fix the TOCTOU: loadout=$(<"$sidecar" || true) in _ensure_session_file (all 7 scripts)
  • File a follow-up issue: "Bug C fix is incomplete for non-skip cascade reasons — blocked on Bug A skip-list expansion"

🤖 Generated with Claude Code

@martin-conur

Copy link
Copy Markdown
Owner Author

Changes requested — round 2

Reviewer'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 _ensure_session_file:

if [[ -f "$sidecar" ]]; then
  loadout=$(<"$sidecar")   # ← sidecar can be unlinked here by concurrent unregister
fi

set -e aborts the function on the read failure; session file never written; STATE tracking dies for the worker's life. One-liner fix:

- loadout=$(<"$sidecar")
+ loadout=$(<"$sidecar" || true)

Apply across all 7 drift-grouped radio scripts. Add a regression bats case that deletes the sidecar between the -f check and the read (concurrent unregister simulation) and asserts _ensure_session_file still completes.

#2lib/zellij-tab.sh emoji-strip out of sync with radio's _rename_tab

_rename_tab writes the awaiting prefix as ❓︎ (U+2753 + U+FE0E variation selector + space). lib/zellij-tab.sh's strip uses (U+2753 + space, no VS-15). They don't match byte-for-byte → aw_zellij_tab_id_by_name fails to strip when a tab is in awaiting state.

This PR adds the ❓︎ variant to radio's lookup, but lib/zellij-tab.sh:49 (and the corresponding new lookup logic) needs the same fix to stay in sync. The file even has a comment saying so:

Keep this prefix list in sync with _rename_tab in radio.

Fix is replacing the strip in lib/zellij-tab.sh with the same three-variant strip (⏸️ / ▶️ / ❓︎ ), matching radio byte-for-byte. Single file, no drift group concerns.

Filed as follow-ups (do NOT touch in this PR)

Why we're not doing #1 in this PR

Reviewer 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 2

Just the two small fixes. Radio re-review-requested when ready.

… 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>
@martin-conur

Copy link
Copy Markdown
Owner Author

PR Review — Radio session corruption round 2 (#140)

Spec compliance

The 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 loadout=$(<"$sidecar" || true); the PR rejects this with a correct explanation: bash's $(<file) in a command substitution treats || true as operating on the substitution group exit status, not the read itself, so the file content is lost even on the happy path. The chosen fix (cat "$sidecar" 2>/dev/null || true) correctly collapses file-absent, mid-read-unlink, and unreadable into empty string without tripping set -e. Applied across all 7 radio scripts. ✓ matches spec.

lib/zellij-tab.sh emoji sync (Finding 2 from round 1)sub("^❓ "; "") (U+2753 + space) is updated to sub("^❓︎ "; "") (U+2753 + U+FE0E + space), matching _rename_tab's bytes in all 7 radio scripts. New bats test in claude_gh_task_work.bats (line 1114) exercises the awaiting-state lookup and passes. ✓ matches spec.

#151 (Bug C completion + skip-list expansion) — filed as follow-up, not touched. ✓
#152 (multi-match jq regression) — filed as follow-up, not touched. ✓

Test coverage: 770/770 passing, 12 new cases covering round-2 scenarios. ✓


Code-review findings

Finding 1 — Sidecar write order creates a narrow LOADOUT-loss window (CONFIRMED)

In cmd_register, the session file (.info) is written first via _atomic_write, then the loadout sidecar (.loadout) is written second:

} | _atomic_write ""            # .info written first
mkdir -p ...
if [[ -n "" ]]; then
  printf '%s' "" | _atomic_write "")  # .loadout written second
fi

If the process is killed (OOM, task-done --force, host shutdown) between these two writes, .info exists with the correct LOADOUT=claude-gh value, but .loadout is absent. The session file itself is fine — the problem only surfaces later:

  1. A subsequent unregister cascade fires → deletes .info; rm -f on the absent .loadout is a no-op.
  2. _ensure_session_file re-seeds: no .info, no .loadout, reviewer not in hook subshell env → writes LOADOUT=unknown.

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, .loadout exists and .info is absent — the next re-seed finds the sidecar and correctly recovers LOADOUT=claude-gh. There is no downside to the reversed order: _ensure_session_file only runs when .info is missing, and cmd_unregister sweeps the sidecar regardless of whether .info exists.

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 re-seed survives when sidecar is unlinked between [[ -f ]] and read (line 1284) has a comment that says:

We can't easily race a real unregister against the read in bats, but we can produce the same effect by replacing the read step with a deletion: a bash DEBUG trap that fires between [[ -f ]] and the read, deletes the sidecar…

But the PR removed the [[ -f "$sidecar" ]] guard entirely — the actual code now does an unconditional cat "$sidecar" 2>/dev/null || true with no pre-check. The race window the comment describes no longer exists in the form it portrays. A future reader will search for the [[ -f ]] guard referenced and not find it. Additionally, the chmod-0 test never asserts the fallback value (LOADOUT=unknown), only assert_success and STATE=busy — which would pass even if cat succeeded. The companion unlink-variant test (line 1324) is correct and asserts LOADOUT=unknown; this one should do the same.

Finding 3 — Multi-match jq .[0] ambiguity (PLAUSIBLE, acknowledged as #152)

Already filed as follow-up #152 and noted in the previous review. Confirming: verified via source inspection that cmd_send's wake-up path (line 553) reads TAB_ID= directly from the session file, bypassing _zellij_tab_id_by_name entirely — so the ambiguity has no impact on the primary message-delivery path. The lookup is only used during cmd_register and _ensure_session_file, both of which run before _rename_tab has painted the tab, so in normal operation there is only one matching entry. PLAUSIBLE only with manual out-of-band tab creation or a zellij bug. ✓ #152 is the right tracking vehicle.


Verdict: clean-with-nits

Round 2 correctly addressed both pre-merge items from round 1. The TOCTOU fix explanation (rejecting the reviewer's suggested $(<file || true) form) is accurate and well-documented. The emoji sync in lib/zellij-tab.sh is byte-correct and tested.

One new finding worth patching before merge:

  • Finding 1 — Swap write order in cmd_register: write .loadout sidecar before writing .info (applies to all 7 radio scripts). Closes the narrow kill-window where .info is written but .loadout never gets written.

One test hygiene item:

  • Finding 2 — Update the TOCTOU test comment (no [[ -f ]] guard exists anymore); add assert_output "LOADOUT=unknown" to the chmod-0 variant to match the unlink-variant test's assertion.

🤖 Generated with Claude Code

@martin-conur

Copy link
Copy Markdown
Owner Author

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 cmd_register

In cmd_register, .info is currently written first, then .loadout. If the process dies between the two writes (OOM, task-done --force, host shutdown), we get a session file with the correct LOADOUT value but no sidecar — and the next unregister-cascade + re-seed produces LOADOUT=unknown.

Fix: reverse the write order — write .loadout first, then .info. If killed between in the reversed order, the sidecar exists and the re-seed recovers LOADOUT correctly. No downside: _ensure_session_file only runs when .info is missing; cmd_unregister sweeps the sidecar regardless.

Apply across all 7 drift-grouped radio scripts (radio|body group). Add a bats case that simulates the kill (write the sidecar, no .info, run _ensure_session_file, assert LOADOUT is the sidecar value not unknown).

2. TOCTOU test hygiene

tests/radio_lifecycle.bats line ~1284 — the test re-seed survives when sidecar is unlinked between [[ -f ]] and read has a stale comment referencing the [[ -f "$sidecar" ]] guard that round 2 removed. The comment describes a race window that no longer exists in the code (we now do unconditional cat ... 2>/dev/null || true). Update the comment to describe the actual scenario.

Additionally, the chmod-0 variant doesn't assert LOADOUT=unknown — only assert_success + STATE=busy, which would pass even if cat succeeded on a readable file. The unlink variant correctly asserts LOADOUT=unknown; the chmod-0 variant should match.

Out of scope

Don't touch #151 / #152 / anything else. Radio re-review-requested when ready.

Session corruption reminder

Your worker session still has TAB_ID="" / LOADOUT=unknown from the very bug you're fixing. The radio ping for this round-3 ask will queue but won't auto-wake — user will manually nudge as before. Soon enough this ends.

…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>
@martin-conur

Copy link
Copy Markdown
Owner Author

PR Review — Radio session corruption round 3 (#140)

Spec compliance

Round-3 spec called for exactly two small pre-merge fixes. Checking both:

1. Sidecar write order in cmd_register (reviewer round-2 Finding 1) — Sidecar (.loadout) is now written via _atomic_write before the session file (.info). The pre-kill recovery table in the comment is correct: if the process is killed between the two writes in the new order, the sidecar exists and _ensure_session_file can recover LOADOUT; under the old order, the sidecar would never have been written and the re-seed would produce LOADOUT=unknown. Applied consistently across all 7 radio scripts. A new bats test (radio_lifecycle.bats line ~1491) simulates the mid-kill state (sidecar present, .info absent) and asserts LOADOUT=claude-gh is recovered. ✓ matches spec.

2. Test hygiene (reviewer round-2 Finding 2) — Two items:

  • TOCTOU test comment no longer references the [[ -f "$sidecar" ]] guard that round 2 removed. The updated comment accurately describes the unconditional cat … 2>/dev/null || true approach and the three failure modes it collapses. ✓
  • The chmod-0 variant (line ~1452) now asserts LOADOUT=unknown, confirming the env-var fallback fired rather than just that the script completed. Matches the unlink-variant test's assertion. ✓

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 findings

Finding 1 — $(<"$sidecar" || true) claim verified correct

The comment defending the choice of cat over $(<"$sidecar" || true) is accurate. Verified locally:

bash -c 'echo x>/tmp/f; v=x; echo "[$v]"'
# → []

Inside $(), <file is a redirect for a null command; the null command exits 0, || true never fires, and the subshell produces no output. cat is the correct unambiguous form. The explanation in the codebase is accurate and will save the next reader from re-deriving this.

Nit — stale sidecar after partial unregister, re-registered without --loadout

If cmd_unregister is killed between rm -f "$f" and rm -f "$(_loadout_sidecar …)", a stale .loadout file survives. On the next cmd_register for the same role with a --loadout arg, the sidecar is overwritten immediately (the new write happens before .info), so the stale value is harmless. The only remaining gap: if a re-registration without --loadout fires for the same role, the [[ -n "$loadout" ]] guard skips the sidecar write, the stale .loadout persists, and a subsequent cascade-triggered re-seed reads the wrong loadout. In practice every task-work invocation passes --loadout; only manual radio register calls without --loadout are affected. Low-impact, worth knowing about but not a blocker.


Verdict: clean

Both 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

@martin-conur martin-conur merged commit 40b1807 into main Jun 2, 2026
4 checks passed
@martin-conur martin-conur deleted the task/issue-140 branch June 2, 2026 19:54
martin-conur added a commit that referenced this pull request Jun 2, 2026
…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>
martin-conur added a commit that referenced this pull request Jun 2, 2026
…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>
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.

Radio session corruption: unregister cascade → re-seed loses TAB_ID + LOADOUT (breaks --auto wake-up)

1 participant