Skip to content

fix(reconciler): defer mass reaps until a second pass agrees#61

Closed
jiunbae wants to merge 2 commits into
mainfrom
fix/reap-blast-radius-guard
Closed

fix(reconciler): defer mass reaps until a second pass agrees#61
jiunbae wants to merge 2 commits into
mainfrom
fix/reap-blast-radius-guard

Conversation

@jiunbae

@jiunbae jiunbae commented Jul 16, 2026

Copy link
Copy Markdown
Member

Why

A reconcile pass reaps every registry row whose pane is missing from the pane observation it was handed. This fires on a 30s timer, and the rows are hard-deleted — the cwd, model, and prompt they carried do not come back.

This actually happened on my box earlier today: 38 of 46 rows were wiped in a single tick and re-discovered seconds later as bare synthetic- shells with cwd: null. muxa watch showed "No agents tracked" in the window between.

68cfba4 fixed the known trigger — list_panes() collapsed a failed tmux call into an empty Vec, which reconcile could not distinguish from "no panes exist". Observations now carry completeness and an Incomplete one skips the reap entirely. That is the right fix and this PR does not touch it.

But completeness only covers the faults we know about. Anything that hands reconcile an empty-yet-Complete observation still wipes the registry in one unconfirmed tick, and the failure is silent and irreversible. This adds a backstop that does not depend on correctly classifying the fault.

What

A pass that would reap most of the pane-governed registry is held back until a second, independent pass proposes the same thing:

  • A transient fault resets the counter and the rows stay.
  • A genuine mass close simply converges one tick later.
  • Small sweeps (< 5 rows) are never guarded — closing the panes you had open is ordinary.
  • Deferrals log at WARN and surface as ReconcileReport::stale_reap_deferred, so the guard firing is visible rather than a mystery.

Only rows this sweep could actually reap count toward the ratio. pid-tracked rows answer to process liveness and paneless rows to the orphan sweep; counting either would inflate the denominator and let a wipe of every real pane row slip under the threshold.

Scope

muxa prune goes through prune_orphans and is unaffected — an explicit user command is never subject to the heuristic.

I considered making the reap a soft delete (flip to Stopped, let the GC collect) and deliberately did not: config.example.toml documents that closed panes disappear from muxa watch within seconds, and the picker view is meant to stay clean. Prompt history already lives outside the registry by design.

Tests

Four new tests, including the incident itself (transient_pane_blackout_does_not_wipe_registry) and proof the guard delays rather than blocks a real mass close. paneless_rows_do_not_dilute_the_guard was verified to fail against the naive denominator before the fix.

cargo test -p muxa --lib (422 passed), workspace clippy -D warnings, and fmt --check all clean. Also ran a patched muxad against the live tmux host on a scratch socket for several reconcile ticks: no false positives.

🤖 Generated with Claude Code

jiunbae and others added 2 commits July 16, 2026 18:19
A reconcile pass reaps every row whose pane is missing from the
observation it was handed. Completeness checks now stop a *known*
observation fault from presenting an empty host as fact, but they only
cover the faults we know about: anything that yields an empty-yet-
`Complete` observation still wipes the registry in a single tick, and the
rows are hard-deleted, so the cwd/model/prompt they carried are gone.

Hold back any pass that would reap most of the pane-governed registry
until a second, independent pass proposes the same thing. A transient
fault resets the counter and the rows stay; a genuine mass close simply
converges one tick later. Small registries stay unguarded — closing the
last two panes you had open is ordinary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-review of the previous commit found the guard's confirmation counter
was a bare number: it recorded that *a* mass reap had been proposed, never
which rows. Two consequences, both reproduced:

- A fault rolling across tmux servers defeated it entirely. Server A
  blacks out (rows %0-%5 deferred), then A recovers and B blacks out — a
  disjoint set — and that second set confirmed the first set's deferral
  and was reaped on its first and only proposal. Live rows, deleted by
  the guard's own confirmation. Agreement now means naming the same rows.

- `reconcile_observation` returned early on an `Incomplete` observation
  without touching the counter, so a deferral stayed armed indefinitely
  and "two consecutive passes" could be two faults minutes apart. An
  unusable reading now clears the watch: it is not agreement, and it is
  not the disagreement that resets one either.

Also stop the rescue from being hollow. A deferral means the pane set is
not to be believed, but the reconciler still fed that same set to
`update_workloads`, blanking the workload of every row the guard had just
saved. Gate the workload update on the same distrust, and surface
`stale_reap_deferred` in the tick log so a firing guard is visible.

Tests now pin the ratio (the boundary pair 5-of-10 defers, 5-of-11
reaps), the reset-on-disagree branch, the identity check, and the
workload rescue. Verified by mutation: deleting the ratio term, widening
the divisor, ignoring the fingerprint, or dropping either reset now fails
the suite — before this, deleting the ratio term outright left it green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jiunbae jiunbae force-pushed the fix/reap-blast-radius-guard branch from bf6086e to 2492516 Compare July 16, 2026 09:20
@jiunbae

jiunbae commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Closing unmerged. Two rounds of review say this shouldn't ship, and I agree.

What the reviews found

The guard's premise is that a pass proposing to reap most of the registry is more likely a bad observation than reality, so it should wait for a second pass to agree. Every attempt to define "agree" traded one failure mode for another:

  • Confirm by tally → two disjoint doomed sets confirm each other. A fault rolling across tmux servers (A blacks out, then B) had each pass confirm the previous one's unrelated proposal, reaping live rows named exactly once.
  • Confirm by set identity (the fix) → any churn in the doomed set resets confirmation. A user closing panes one at a time never converges: PROBE3 closed=8: reaped=0 remaining=10 — dead agents stay listed as Working for the whole teardown.
  • Clear the watch on Incomplete (needed, or two faults minutes apart count as consecutive) → a backend flapping Complete/Incomplete starves the reap forever: PROBE2 tick5: remaining=8 — panes the user genuinely closed are never removed.
  • And the ratio is re-evaluated per pass, so a partially recovering fault reaps the very rows the guard rescued one tick earlier, with zero confirmations: PROBE1 tick2: reaped=5.

Why it can't be patched

There is no signal at this layer that separates "the backend is lying" from "the user closed everything" — tmux's own no server running is a legitimate empty reading, so both arrive as empty-yet-Complete. The guard was trying to extract information that isn't in its input. Its own doc comment said so:

Telling those two apart needs a signal this layer doesn't have (is the socket still connectable? are the agent processes still alive?) and belongs in the backend, next to the completeness check.

That was right, and I should have stopped there instead of building the heuristic anyway.

Where this actually stands

The incident that motivated this — list_panes() collapsing a failed tmux call into an empty Vec, indistinguishable from "no panes exist" — is already fixed by the completeness model in 68cfba4, which is on main and running. This PR was speculative defense-in-depth against unknown faults, and it buys that at the cost of phantom rows, stale workloads, and non-converging teardowns. Net negative.

If we want real protection beyond 68cfba4, it belongs in the backend: a socket that still exists and connects but reports zero panes is suspicious and should read Incomplete, while no server running stays a legitimate empty Complete. That's a separate change, at the layer that can actually tell the difference.

@jiunbae jiunbae closed this Jul 16, 2026
@jiunbae jiunbae deleted the fix/reap-blast-radius-guard branch July 16, 2026 13:04
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.

1 participant