Skip to content

radio gc: dead-letter a dead role's undeliverable mail so its mailbox can be reclaimed - #209

Merged
martin-conur merged 1 commit into
mainfrom
task/radio-dead-letter
Sep 15, 2026
Merged

martin-conur merged 1 commit into
mainfrom
task/radio-dead-letter

Conversation

@martin-conur

Copy link
Copy Markdown
Owner

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 legacy pm backlog 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, with radio gc --dry-run reporting 0 dead mailbox(es) against all of them.

Sampled, that mail is overwhelmingly PM→worker approved-and-merged / changes-requested replies 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 a task-done bug; 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 failed mkdir/mv leaves 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:

radio gc --dry-run reports both the archive and the reclaim it would unblock without performing either, and the summary line gains a third counter. New log line gc: dead-lettered role=… msg=… owner=….

Surfacing it

A fresh PM register prints 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 inside cmd_register in an output-suppressed subshell, so a sentinel carries the news across.)

That report is routed per PM, not global. $RADIO_HOME is 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:

  1. its 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, but cmd_send only writes it when --repo was passed and none of the documented send commands do, so on its own it would leave nearly every real message unscoped;
  2. its from:, when the sender was a pm-* — 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;
  3. the dead role itself, when that was a PM — the mirror case (a worker's report to a PM that had already exited).

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 --also aliases 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 gc command-table row is updated, and the same paragraph + symptom row are replaced identically across .claude/gh-workflow.md and all 7 */steering/*-workflow.example.md templates — 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-run reporting both actions, the summary counter, collision safety, logging, the pm empty-inbox reclaim arm, PM surfacing/consume-once, worker non-consumption, and five cross-repo routing cases (foreign PM does not consume, repo: outranks from:, dead-PM's-own-inbox, unattributable→unscoped-but-named, alias claim).
  • tests/radio_runbook.bats: gc: dead-lettered added to DOCUMENTED_GREPS so the new log string is pinned against bin/radio; the undelivered-mail test now asserts the new claim and that no dead-letter queue is gone from every doc.
  • Full suite 1031/1031, tools/check-drift.sh clean, shellcheck -x bin/radio clean.
  • Manual end-to-end against a scratch $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 first radio gc after upgrading archives it.

🤖 Generated with Claude Code

https://claude.ai/code/session_01X3LnKWJDazLXoKgUSE3taE

… 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
martin-conur merged commit fc2ac5a into main Sep 15, 2026
4 checks passed
@martin-conur
martin-conur deleted the task/radio-dead-letter branch September 15, 2026 18:56
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 gc: undeliverable mail is immortal — add a dead-letter stage so dead mailboxes can be reclaimed

1 participant