radio gc: dead-letter a dead role's undeliverable mail so its mailbox can be reclaimed - #209
Merged
Merged
Conversation
… can be reclaimed Mail addressed to a role that has exited was never delivered and never reclaimed: cmd_gc's whole-dir reclaim required _inbox_empty (added so the legacy `pm` backlog couldn't be swept out from under #182), so any dead role still holding unread mail was protected forever by the very sweep meant to clean it up — 342 messages across 171 dead roles on the reporting machine, with `gc --dry-run` claiming 0. Sampled, that mail is overwhelmingly PM->worker approved-and-merged / changes-requested replies that landed after the worker exited, which makes it the mechanism behind leaked worktrees: the worker never got the signal to run task-done. gc now inserts a stage between "undeliverable" and "reclaimed". A role that is dead (no session file or a >1h-stale heartbeat) AND past the cutoff AND still holding unread mail has that mail moved to dead-letter/<role>/ — original filename, id and frontmatter intact — after which the existing whole-dir reclaim proceeds unchanged. It is an archive, not a delete: nothing unread is ever rm'd, a failed move leaves the message in the inbox and drops gc back to the processed-only sweep, and the archive has no TTL of its own. Everything the old gate protected still holds: a live role's inbox is untouched at any age, a dead role inside the cutoff keeps its mail, and the literal `pm` role is exempt from dead-lettering only — once #182's adoption drains it, it is an ordinary dead role with an empty inbox and is reclaimed as it always could be. The count is surfaced where someone can act on it: a fresh PM register prints it once and consumes the report. That report is routed per PM, not global — $RADIO_HOME is shared machine-wide, so one sentinel would be eaten by whichever PM booted first. Each message is attributed to its `repo:` field, else the pm-* that sent it (the signal that actually fires, since none of the documented send commands pass --repo), else the dead role itself when that was a PM; anything else lands in an unscoped report any PM may claim, with role names always listed. A primary PM also claims its --also aliases' reports, since an alias address never fresh-registers. Docs: this retires #191's "there is no dead-letter queue" claim, true when written and now false. README section renamed to "Undelivered mail is never deleted" and rewritten, a sixth symptom row added, and the same paragraph + row replaced identically across .claude/gh-workflow.md and all 7 steering templates. Closes #201 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X3LnKWJDazLXoKgUSE3taE
martin-conur
force-pushed
the
task/radio-dead-letter
branch
from
September 15, 2026 00:39
967b876 to
9527ef0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #201
The bug
Mail addressed to a role that has exited is never delivered and never reclaimed.
cmd_gc's whole-directory reclaim required_inbox_empty— a guard added so the legacypmbacklog couldn't be swept out from under #182 — with no second stage behind it. The result: any dead role still holding unread mail was immortal, protected by the very sweep meant to clean it up. 342 messages across 171 dead roles on the reporting machine, withradio gc --dry-runreporting0 dead mailbox(es)against all of them.Sampled, that mail is overwhelmingly PM→worker
approved-and-merged/changes-requestedreplies that arrived after the worker exited. That is the mechanism behind leaked worktrees — the worker never received the signal telling it to clean up, so the worktree stayed on disk and the task item stayed un-Done. The leak was never atask-donebug; it was undelivered mail.Independent of the six radio tickets that shipped 2026-09-14 (#187 / #188 / #197 / #198 all address why a live role fails to be notified); this is the other half — what happens once the recipient is gone.
The fix
A stage between "undeliverable" and "reclaimed". A role that is dead (
_session_dead: no session file or a >1h-stale heartbeat) and past the cutoff and still holding unread mail has that mail moved to~/.task-force/radio/dead-letter/<role>/— original filename, id and frontmatter untouched, formalizing the layout of the hand-run 2026-09-14 sweeps — after which the existing whole-dir reclaim proceeds unchanged. It unblocks gc rather than working around it.It is an archive, not a delete. Nothing unread is ever
rm'd; a failedmkdir/mvleaves the message in the inbox and drops gc back to the processed-only sweep; the archive has no TTL of its own (expiring it would be a separate, explicitly-configured decision).Everything the old gate protected still holds:
pmrole is exempt from dead-lettering only, so radio: adopt/backfill the legacy literal-pm inbox at first pm-* register (deferred RC-5 from #165/PR #181) #182's one-time adoption can't be short-circuited. Once adoption drains it,pmis an ordinary dead role with an empty inbox and the reclaim reaps it as it always could — exempting the whole role would re-create this ticket's bug for one mailbox.radio gc --dry-runreports both the archive and the reclaim it would unblock without performing either, and the summary line gains a third counter. New log linegc: dead-lettered role=… msg=… owner=….Surfacing it
A fresh PM
registerprints the count once and consumes the report — a PM that learns its merge ping never landed can re-send or clean up manually, where silent immortality taught nobody anything. (gc runs insidecmd_registerin an output-suppressed subshell, so a sentinel carries the news across.)That report is routed per PM, not global.
$RADIO_HOMEis shared by every repo on the machine, so a single sentinel would be consumed by whichever PM booted first — a PM in repo A eating a report about repo B's roles, the same cross-repo class as #182's adoption bug. Each message is attributed, most explicit signal first:repo:field — what radio: adopt/backfill the legacy literal-pm inbox at first pm-* register (deferred RC-5 from #165/PR #181) #182 routes on, butcmd_sendonly writes it when--repowas passed and none of the documented send commands do, so on its own it would leave nearly every real message unscoped;from:, when the sender was apm-*— the signal that actually fires, since the stranded mail is overwhelmingly PM→worker and the PM that sent a ping is exactly who needs to know it never landed;Anything matching none of those lands in an unscoped report any PM may claim, matching #182's treatment of a repo-less backlog entry — and the role names are always listed, so a PM reading an unscoped entry can tell whose mail it was. A primary PM also claims the reports of any
--alsoaliases it answers for; an alias address never fresh-registers, so those would otherwise sit unread forever.Docs
This retires a claim #191's runbook made across nine files — "there is no dead-letter queue" — which was true when written (the #188 spec had wrongly assumed one shipped) and is now false. The README section is renamed "Undelivered mail is never deleted" and rewritten, a sixth symptom row is added ("The PM merged, but the worker never cleaned up its worktree"), the
radio gccommand-table row is updated, and the same paragraph + symptom row are replaced identically across.claude/gh-workflow.mdand all 7*/steering/*-workflow.example.mdtemplates — byte-identity within each loadout group preserved.The guarantee that survives is the one that matters: the message is written to the inbox before any wake is attempted, and nothing unread is ever deleted. It just gained a second address.
Verification
tests/radio_gc.bats: 37 → 43. New coverage for archive+reclaim, frontmatter/id preservation, live-role immunity at any age, inside-cutoff immunity,--dry-runreporting both actions, the summary counter, collision safety, logging, thepmempty-inbox reclaim arm, PM surfacing/consume-once, worker non-consumption, and five cross-repo routing cases (foreign PM does not consume,repo:outranksfrom:, dead-PM's-own-inbox, unattributable→unscoped-but-named, alias claim).tests/radio_runbook.bats:gc: dead-letteredadded toDOCUMENTED_GREPSso the new log string is pinned againstbin/radio; the undelivered-mail test now asserts the new claim and thatno dead-letter queueis gone from every doc.tools/check-drift.shclean,shellcheck -x bin/radioclean.$TASK_FORCE_HOME: dry-run, archive, reclaim, untouched live inbox, once-only PM report.Upgrading: re-run
task-init <loadout>in each configured repo to pick up the rewritten runbook section. No migration needed for existing mail — the firstradio gcafter upgrading archives it.🤖 Generated with Claude Code
https://claude.ai/code/session_01X3LnKWJDazLXoKgUSE3taE