fix(reconciler): defer mass reaps until a second pass agrees#61
fix(reconciler): defer mass reaps until a second pass agrees#61jiunbae wants to merge 2 commits into
Conversation
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>
bf6086e to
2492516
Compare
|
Closing unmerged. Two rounds of review say this shouldn't ship, and I agree. What the reviews foundThe 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:
Why it can't be patchedThere is no signal at this layer that separates "the backend is lying" from "the user closed everything" — tmux's own
That was right, and I should have stopped there instead of building the heuristic anyway. Where this actually standsThe incident that motivated this — 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 |
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 withcwd: null.muxa watchshowed "No agents tracked" in the window between.68cfba4fixed the known trigger —list_panes()collapsed a failed tmux call into an emptyVec, which reconcile could not distinguish from "no panes exist". Observations now carry completeness and anIncompleteone 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-
Completeobservation 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:
WARNand surface asReconcileReport::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 prunegoes throughprune_orphansand 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.tomldocuments that closed panes disappear frommuxa watchwithin 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_guardwas verified to fail against the naive denominator before the fix.cargo test -p muxa --lib(422 passed), workspaceclippy -D warnings, andfmt --checkall clean. Also ran a patchedmuxadagainst the live tmux host on a scratch socket for several reconcile ticks: no false positives.🤖 Generated with Claude Code