Skip to content

fix(census): corroborate candidates before the uniqueness gate - #1587

Open
v4lheru wants to merge 2 commits into
mainfrom
fix/census-corroborate-before-uniqueness
Open

fix(census): corroborate candidates before the uniqueness gate#1587
v4lheru wants to merge 2 commits into
mainfrom
fix/census-corroborate-before-uniqueness

Conversation

@v4lheru

@v4lheru v4lheru commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

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_candidate builds its candidate set, then applies if len(candidates) != 1: return None. _resume_census_candidate_corroborated is only ever reached for an already-unique candidate, so its cwd subject anchor never gets the chance to discriminate. Two live team dirs inside RESUME_CENSUS_RECENCY_SECONDS are enough to kill the whole set.

Observed live, not inferred. _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 anchor separates those 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 nothing indicating why. The recorded team also propagates to pact-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.py gates on if 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| <= 1 the 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 >= 2 case.

For |C| >= 2 the old order always returned None, and the new one can only return a candidate passing 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

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. 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 #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 in commit 1

The RESUME_CENSUS_RECENCY_SECONDS comment 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.py both describe two live candidates resolving to the stale default, but seeded AMBIG_TEAM member-less (_seed_team_store defaults corroborate=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_TEAM has tasks=(), 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 REPAIRED note so a later reader does not restore the member-less seed as a simplification.

Verification

  • Baseline on clean main: 143 failed / 14610 passed / 449 skipped. All 143 are tests/test_telegram_* on a missing optional dependency, none near pact_context. Compared as a set diff of failing node-ids, not a count.
  • After: 143 failed / 14613 passed. Zero new, zero fixed. The +3 are the new cells.
  • Sabotage: reverting only pact_context.py turns both regression cells red, each resolving to the stale default.
  • Reverse mutation: len(survivors) != 1 to a pick-first turns the fail-safe cell red, proving it is not vacuous.

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.
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