fix(census): corroborate candidates before the uniqueness gate - #1587
Open
v4lheru wants to merge 2 commits into
Open
fix(census): corroborate candidates before the uniqueness gate#1587v4lheru wants to merge 2 commits into
v4lheru wants to merge 2 commits into
Conversation
The branch-3 resume census could not realign a resumed session whenever another Claude session was live, because it rejected on candidate COUNT before consulting the ownership proof. _resume_census_unique_candidate built its candidate set, then applied `if len(candidates) != 1: return None`. _resume_census_candidate_corroborated was only ever reached for an already-unique candidate, so its subject anchor never got the chance to discriminate. Two live team dirs inside RESUME_CENSUS_RECENCY_SECONDS were enough to kill the whole set. Observed live. _resolve_aligned_team_name returned the stale default while the census held exactly two candidates: session-7c6ea94d tasks mtime 143s lead cwd = ".../Yulia - KB project" session-b95ae762 tasks mtime 96s lead cwd = ".../CS - Churn Dashboard" The cwd subject anchor separates those two perfectly. The resumed session was left pointing at a team that does not exist, so every Agent dispatch was denied by a gate citing a phantom team, with no signal indicating why. Anyone running two sessions concurrently hit this on every resume. Corroborate every candidate first, then require exactly one survivor. The count rule is kept, not deleted: it now bounds the proof rather than the accident. SAFETY. For |C| <= 1 the two orders are provably identical, since the survivor set is a subset of the candidate set, so every existing zero-or-one-candidate fixture is byte-identical and the change is inert outside the >= 2 case. For |C| >= 2 the old order always returned None, and the new one can only return a candidate that passes the full ownership proof (lead entry by leadAgentId, agentId self-consistency against the dir name, cwd subject anchor, birth order). Nothing rejected on ownership grounds becomes admitted. QUALIFICATION, stated because it is a real cost and not a footnote: the raw-count rule was incidentally masking part of the residual that _resume_census_candidate_corroborated already documents, a sibling born between this session's end marker and its resume, running from the same directory. Today that sibling wins only as the sole candidate; after this change it can win alongside non-corroborating candidates. No new mis-alignment class, but a documented one becomes reachable in strictly more environments. Recorded at the residual's own site in the source. Deliberately NOT addressed by making birth order mandatory, since that skip exists to allow realignment when the journal has no end marker, and removing it would refuse legitimate realignments. BRANCH 2 INHERITS THIS. The #1509 preemption guard calls the same helper (pact_context.py:827), so a corroborated winner can now preempt a dead-looking own substrate in a >= 2 candidate world. That is intended. Branch 2 keeps its own stricter gates and any winner still has to corroborate; the alternative forks the census into two semantics and leaves branch 2 blind to the same defect. Also rewrites the RESUME_CENSUS_RECENCY_SECONDS comment, whose claim that "a mis-tuned window can only suppress, never mis-align" goes false here: widening the window can now convert a zero-survivor world into a one-survivor one. The constant itself is unchanged at 900. Tests: two new cells reproducing the concurrent-peer shape at the resolver and at the gate, plus a scope note on the existing two-candidate fail-safe cell. Reverting only this file turns both new cells red, each resolving to the stale default. Mutating `len(survivors) != 1` to a pick-first turns the fail-safe cell red. Suite unchanged otherwise: a set diff of failing node-ids against clean main shows zero new and zero fixed, the 143 pre-existing failures all being tests/test_telegram_* on a missing optional dependency.
Cells ii and xi of test_resume_team_realign_1507.py both describe a world with two live census candidates resolving to the stale default, but seeded AMBIG_TEAM member-less. _seed_team_store defaults corroborate=False, so that candidate carried no ownership claim and the world contained one claim, not two. Neither cell composed the situation its own name describes. The assertions could not have detected the difference either. AMBIG_TEAM has tasks=(), and an empty task store denies under EITHER resolution, so the deny each cell asserts is equally consistent with "failed safe" and with "realigned onto a candidate that happens to have no work". This surfaced while reordering the census to corroborate before counting (previous commit): under that change both worlds resolve to the live team, and both cells kept passing regardless. Probed directly rather than inferred from the assertions. Seeds AMBIG_TEAM with corroborate=True and this session's project dir as lead cwd, making it a genuine competing ownership claim and restoring each cell's stated premise. Cell ii additionally asserts get_team_name() == OLD_TEAM so the resolution is observed rather than inferred. Cell ii now bites: under a pick-first mutation of the survivor count it goes red, where on the previous commit's tree it survived that same mutation. That is the point of this commit. The preceding fix is green without it, but its central safety property is not guarded until this lands, so the two belong together. Cell xi does NOT newly bite, and this is a premise correction rather than a new guard. Its assertions concern message composition (the stale-block marker, the Working-recovery hint, the A-xor-B enumeration) and none of them discriminate which team was resolved, because the deny arrives either way off the empty store. The repair matters because otherwise the cell exercises message composition in a world it does not describe. No resolution assertion was added, since the cell's subject is the composer rather than the resolver. Both cells carry an in-cell FIXTURE REPAIRED note recording why the seed changed, so a later reader does not restore the member-less seed as a simplification.
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.
The bug
The branch-3 resume census cannot realign a resumed session whenever another Claude session is live, because it rejects candidates on COUNT before consulting the ownership proof.
_resume_census_unique_candidatebuilds its candidate set, then appliesif len(candidates) != 1: return None._resume_census_candidate_corroboratedis only ever reached for an already-unique candidate, so its cwd subject anchor never gets the chance to discriminate. Two live team dirs insideRESUME_CENSUS_RECENCY_SECONDSare enough to kill the whole set.Observed live, not inferred.
_resolve_aligned_team_namereturned the stale default while the census held exactly two candidates:The cwd anchor separates those perfectly. The resumed session was left pointing at a team that does not exist, so every
Agentdispatch was denied by a gate citing a phantom team, with nothing indicating why. The recorded team also propagates topact-session-context.json, the CLAUDE.md- Team:line and the hook's announced value, all from this one resolved name, so all three surfaces were wrong together.Still reproduces at 4.7.2:
hooks/shared/pact_context.pygates onif len(candidates) != 1:with no corroboration pass.The fix
Corroborate every candidate first, then require exactly one survivor. The count rule is kept rather than deleted, so it now bounds the proof rather than the accident.
Safety
For
|C| <= 1the two orders are provably identical, since the survivor set is a subset of the candidate set. Every existing zero-or-one-candidate fixture is byte-identical and the change is inert outside the>= 2case.For
|C| >= 2the old order always returnedNone, and the new one can only return a candidate passing the full ownership proof (lead entry byleadAgentId, agentId self-consistency against the dir name, cwd subject anchor, birth order). Nothing rejected on ownership grounds becomes admitted.Qualification, stated because it is a real cost
The raw-count rule was incidentally masking part of the residual that
_resume_census_candidate_corroboratedalready documents: a sibling born between this session's end marker and its resume, running from the same directory. Today that sibling wins only as the sole candidate; after this change it can win alongside non-corroborating candidates.No new mis-alignment class, but a documented one becomes reachable in strictly more environments. Recorded at the residual's own site in the source.
Deliberately NOT addressed by making birth order mandatory. That skip exists so realignment still works when the journal has no end marker, and removing it would refuse legitimate realignments to buy a narrow reduction in a known residual.
Branch 2 inherits this
The
#1509preemption guard calls the same helper (pact_context.py:827), so a corroborated winner can now preempt a dead-looking own substrate in a>= 2candidate world. That is intended. Branch 2 keeps its own stricter gates and any winner still has to corroborate. The alternative forks the census into two semantics and leaves branch 2 blind to the same defect.Also in commit 1
The
RESUME_CENSUS_RECENCY_SECONDScomment claimed "a mis-tuned window can only suppress, never mis-align". That goes false here, since widening the window can convert a zero-survivor world into a one-survivor one. Comment rewritten; the constant is unchanged at 900.Second commit: two fixtures that never built ambiguity
Reviewed separately on purpose, since it is a different ask.
Cells ii and xi of
test_resume_team_realign_1507.pyboth describe two live candidates resolving to the stale default, but seededAMBIG_TEAMmember-less (_seed_team_storedefaultscorroborate=False). That candidate carried no ownership claim, so the world contained one claim, not two. Neither cell composed the situation its own name describes.The assertions could not have detected the difference either:
AMBIG_TEAMhastasks=(), and an empty store denies under either resolution, so the deny is equally consistent with "failed safe" and "realigned onto a candidate that happens to have no work".Cell ii now bites under a pick-first mutation of the survivor count, where on commit 1's tree it survived that same mutation.
Cell xi does not newly bite, and this is a premise correction rather than a new guard. Its assertions concern message composition and none of them discriminate which team resolved. No resolution assertion was added, since its subject is the composer rather than the resolver.
Both carry an in-cell
FIXTURE REPAIREDnote so a later reader does not restore the member-less seed as a simplification.Verification
main: 143 failed / 14610 passed / 449 skipped. All 143 aretests/test_telegram_*on a missing optional dependency, none nearpact_context. Compared as a set diff of failing node-ids, not a count.pact_context.pyturns both regression cells red, each resolving to the stale default.len(survivors) != 1to a pick-first turns the fail-safe cell red, proving it is not vacuous.