From 0f5e94c1503c9311a0e5baf575b39b834ad3172c Mon Sep 17 00:00:00 2001 From: Uros Pesic <129070018+v4lheru@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:06:22 +0200 Subject: [PATCH 1/3] fix(dispatch): resolve leadSessionId collisions by liveness, not name-sort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _resolve_aligned_team_name returned the alphabetically-first team dir among genuine leadSessionId==session_id matches — unrelated to which team the harness routes tasks to. One PACT session spawns many team dirs all stamped with the lead's session id, so the gate resolved the wrong (stale) team and reported 'no Task assigned to owner' after --resume/restart while TaskList showed tasks. Now collect all genuine matches and, on collision (len>=2), disambiguate via an identity-anchored, liveness-ordered ladder: caller default anchor -> own canonical name {session_id, session-, pact-} -> task-store child-mtime recency -> createdAt-max -> smallest name (parity with prior sorted()[0]). len==0 and len==1 paths are byte-identical (zero regression). New disk reads (_task_store_mtime, _safe_created_at) degrade the sort key to 0 without raising, preserving the never-raises totality contract; the outer bare-except stays a backstop. --- pact-plugin/hooks/shared/pact_context.py | 154 ++++++++++++++++- .../tests/test_team_name_detect_align.py | 162 ++++++++++++++++++ 2 files changed, 311 insertions(+), 5 deletions(-) diff --git a/pact-plugin/hooks/shared/pact_context.py b/pact-plugin/hooks/shared/pact_context.py index 6680f22e9..4ab910ceb 100644 --- a/pact-plugin/hooks/shared/pact_context.py +++ b/pact-plugin/hooks/shared/pact_context.py @@ -309,10 +309,109 @@ def get_pact_context() -> dict: return _cache +def _task_store_mtime(tasks_root: Path | None, name: str) -> float: + """Max child-mtime of the task store ``tasks_root//`` — a liveness + proxy for "the harness is actively routing TaskCreate into this team." + + TOTAL / never-raises: an absent store dir, an empty store, a stat that + raises (OSError), or an unresolvable ``tasks_root`` (None) all DEGRADE to + ``0.0`` — the neutral floor that makes this candidate lose the recency + tier without disturbing the never-raises contract of the caller. Only ever + used to compare genuine same-``leadSessionId`` collision candidates, so a + uniform ``0.0`` simply hands the decision to the next tier (createdAt, + then name). + """ + if tasks_root is None: + return 0.0 + try: + store = tasks_root / name + mtimes = [child.stat().st_mtime for child in store.iterdir()] + return max(mtimes) if mtimes else 0.0 + except Exception: + return 0.0 + + +def _safe_created_at(data: dict) -> int: + """Parse ``config.json['createdAt']`` (epoch millis) as a secondary + liveness proxy. TOTAL / never-raises: a missing key (``None``), a + non-numeric string, or any malformed value DEGRADES to ``0`` so the + candidate loses the createdAt tier and falls to the name tie-break.""" + try: + return int(data.get("createdAt")) + except (TypeError, ValueError): + return 0 + + +def _disambiguate_collision( + matches: list[tuple[str, dict]], + session_id: str, + default: str | None, + tasks_root: Path | None, +) -> str: + """Pick the LIVE team among >=2 genuine ``leadSessionId`` collisions. + + Applied ONLY when two-or-more team dirs pass the (unchanged) identity + predicate ``config.json['leadSessionId'] == session_id``. Ranks those + already-matched candidates by an IDENTITY-anchored, LIVENESS-ordered + ladder — it never promotes a foreign-id dir (the predicate stays the hard + filter upstream). ``matches`` arrives in name-sorted (ascending) order. + + Ladder (first decisive tier wins): + (1) ANCHOR — the caller-threaded ``default`` (the session's + harness-announced / persisted identity) when it is itself a genuine + match. The dominant ``--resume`` case; also the guarantee that an + active sub-team (which never shares the lead's own announced name) + cannot steal the lead's team even with a fresher task store. + (2) OWN-CANONICAL-NAME — a dir named one of this session's canonical + forms ``{session_id, "session-", "pact-"}`` (id8 reuses + ``generate_team_name``'s sanitizer). Recognizes the session's own + dir structurally in the divergent-launch case where ``default`` (a + ``session-``) is not among the matches (real dir == full UUID). + (3)+(4)+(5) — among genuinely ambiguous siblings, prefer max + task-store recency, then max ``createdAt``, then SMALLEST name. + + Tie-break DIRECTION (team-lead ruling): tier (5) is SMALLER-name-first, + matching today's ``sorted(...)[0]`` behavior byte-for-byte in the + all-signals-absent case. Implemented by iterating the name-ASCENDING + ``matches`` and replacing the incumbent ONLY on a STRICTLY-greater + (mtime, createdAt) key — so equal keys retain the earliest (smallest) + name. Pure / FS-read-only; every disk read is wrapped in the helpers + above to degrade, never raise. + """ + names = [m[0] for m in matches] + # (1) ANCHOR — honor the session's own announced/persisted identity. + if default is not None and default in names: + return default + # (2) OWN-CANONICAL-NAME — reuse generate_team_name's exact id8 sanitizer + # so the candidate matches the platform's dir name byte-for-byte. + id8 = re.sub(r"[^a-f0-9-]", "", (session_id or "")[:8]) + canonical = ( + {session_id, f"session-{id8}", f"pact-{id8}"} if id8 else {session_id} + ) + for name, _data in matches: # name-ascending -> deterministic first hit + if name in canonical: + return name + # (3)+(4)+(5) — liveness-ordered argmax with a SMALLER-name-first tie-break. + # Iterate name-ascending; replace only on a STRICTLY-greater recency key so + # a full (mtime, createdAt) tie keeps the earliest (smallest) name. + best_name = matches[0][0] + best_key = ( + _task_store_mtime(tasks_root, matches[0][0]), + _safe_created_at(matches[0][1]), + ) + for name, data in matches[1:]: + key = (_task_store_mtime(tasks_root, name), _safe_created_at(data)) + if key > best_key: + best_key = key + best_name = name + return best_name + + def _resolve_aligned_team_name( session_id: str, teams_dir: str | None = None, default: str | None = None, + tasks_dir: str | None = None, ) -> str: """Resolve the REAL platform team name for ``session_id`` by IDENTITY MATCH. @@ -333,6 +432,19 @@ def _resolve_aligned_team_name( ``leadSessionId`` and is rejected, so an ``id8`` collision cannot mis-resolve. + GENUINE-COLLISION DISAMBIGUATION (the split-brain fix): a single PACT + session mints MANY sibling team dirs (every ``Agent``/comPACT/orchestrate + call), and the platform stamps EVERY one with the lead's ``leadSessionId``. + So the predicate above is routinely satisfied by 2+ dirs. Rather than + returning the first NAME-sorted match (which is unrelated to which team the + harness actually routes tasks into — the historical bug), this collects ALL + genuine matches and, when there are >=2, delegates to + ``_disambiguate_collision`` which ranks them by an IDENTITY-anchored, + LIVENESS-ordered ladder (anchor on ``default`` -> own-canonical-name -> + task-store recency -> ``createdAt`` -> smallest name). The len==0 and len==1 + paths are byte-unchanged (zero regression); disambiguation is gated strictly + behind len>=2 and mutates NO disk state. + FAIL-SAFE DEFAULT: on no identity match (the team dir is half-formed — ``inboxes/`` present but ``config.json`` not yet written — or simply absent at a cold-start probe, or anything raises), return ``default``. @@ -390,7 +502,12 @@ def _resolve_aligned_team_name( teams_dir: Override the teams directory (for testing). Defaults to ``/teams``. default: Fail-safe return on no match / error. Defaults to the - persisted-context team_name when None. + persisted-context team_name when None. Also the tier-1 ANCHOR of + genuine-collision disambiguation (the session's announced identity). + tasks_dir: Override the task-store directory (for testing), symmetric + with ``teams_dir``. Defaults to ``/tasks``. Read + ONLY on the >=2-collision path as the task-store-recency liveness + signal; a read failure degrades that tier to 0.0, never raises. Returns: The identity-matched team dir name, else ``default`` (or the @@ -409,8 +526,13 @@ def _resolve_aligned_team_name( teams_root = Path(teams_dir) else: teams_root = get_claude_config_dir() / "teams" - # Sorted iteration -> deterministic resolution if (pathologically) - # two dirs claimed the same leadSessionId. + # Sorted iteration -> the matches list is built in name-ascending order, + # so name is the deterministic FINAL tie-break inside disambiguation. + # COLLECT every genuine match instead of returning the first: a single + # session routinely stamps its leadSessionId onto MANY sibling dirs, and + # returning the alphabetically-first one is the split-brain bug. The + # predicate itself is UNCHANGED. + matches: list[tuple[str, dict]] = [] for entry in sorted(teams_root.iterdir()): try: if not entry.is_dir(): @@ -419,17 +541,39 @@ def _resolve_aligned_team_name( data = json.loads(config_path.read_text(encoding="utf-8")) if data.get("leadSessionId") != session_id: continue - # Path-safety the matched dir name BEFORE returning it — a + # Path-safety the matched dir name BEFORE accepting it — a # tampered config could name a path-unsafe dir. On failure, # skip this entry and keep scanning (do not abort the search). name = entry.name if not is_safe_path_component(name): continue - return name + # Carry the parsed config so disambiguation reads createdAt + # without a second read_text of the same file. + matches.append((name, data)) except (OSError, json.JSONDecodeError, ValueError, TypeError, AttributeError): # This dir is unreadable / malformed — skip it, keep scanning # the rest. A single bad sibling must not abort detection. continue + if len(matches) == 1: + # FAST PATH — exactly one genuine match: byte-identical to the old + # return-first behavior, zero regression. + return matches[0][0] + if len(matches) >= 2: + # GENUINE COLLISION — disambiguate by the identity-anchored, + # liveness-ordered ladder. Resolve tasks_root lazily and ONLY here + # (the common len<=1 paths never touch the tasks tree). Wrap the + # resolution so a home-unresolvable RuntimeError degrades the + # recency tier to 0.0 rather than escaping into the outer except + # (which would drop us to `default` and lose the collision winner). + try: + if tasks_dir is not None: + tasks_root: Path | None = Path(tasks_dir) + else: + tasks_root = get_claude_config_dir() / "tasks" + except Exception: + tasks_root = None + return _disambiguate_collision(matches, session_id, fallback, tasks_root) + # len(matches) == 0 -> fall through to branch-2 / fallback UNCHANGED. # Branch-2: config-less full-UUID divergence (Desktop child / older-CLI # / print). The identity-match loop above missed because no team dir # carries a config.json with this leadSessionId — but the platform may diff --git a/pact-plugin/tests/test_team_name_detect_align.py b/pact-plugin/tests/test_team_name_detect_align.py index 12a1cebc6..006b246ea 100644 --- a/pact-plugin/tests/test_team_name_detect_align.py +++ b/pact-plugin/tests/test_team_name_detect_align.py @@ -1023,3 +1023,165 @@ def _boom(*a, **k): monkeypatch.setattr(pc, "_resolve_aligned_team_name", _boom) assert ctx_module.get_team_name() == first # served from cache, no re-call + + +# ══════════════════════════════════════════════════════════════════════════════ +# 10. GENUINE-COLLISION DISAMBIGUATION — the split-brain fix (CODE-phase smoke) +# ══════════════════════════════════════════════════════════════════════════════ +# +# These are SMOKE tests written by the backend-coder to confirm the ladder picks +# correctly and to PIN the two team-lead rulings (tasks_root via tasks_dir +# injection; tier-5 SMALLER-name-first parity with today's sorted()[0]). The +# COMPREHENSIVE §6 regression matrix (including the both-topologies integration +# test) is the test-engineer's deliverable — this class is intentionally small. + + +def _seed_collision_dir(teams_root, dir_name, *, lead_session_id, created_at=None): + """Seed teams//config.json with a MATCHING leadSessionId and an + optional createdAt (epoch millis). Mirrors _seed_team_dir but lets a test + set createdAt for the tier-4 probe.""" + team_dir = teams_root / dir_name + team_dir.mkdir(parents=True, exist_ok=True) + config = {"name": dir_name, "leadSessionId": lead_session_id, "members": []} + if created_at is not None: + config["createdAt"] = created_at + (team_dir / "config.json").write_text(json.dumps(config), encoding="utf-8") + return team_dir + + +def _touch_task_store(tasks_root, team_name, *, mtime): + """Create tasks//1.json and stamp its mtime — the tier-3 + task-store-recency signal (max child-mtime of the store).""" + store = tasks_root / team_name + store.mkdir(parents=True, exist_ok=True) + task_file = store / "1.json" + task_file.write_text("{}", encoding="utf-8") + os.utime(task_file, (mtime, mtime)) + return store + + +class TestGenuineCollisionDisambiguation: + """>=2 dirs share the session's leadSessionId — the resolver must return the + LIVE team, not the alphabetically-first sibling (the historical bug).""" + + def test_anchor_default_wins(self, ctx): + """(tier 1) When the caller-threaded `default` is itself a genuine match, + it wins outright — the dominant --resume case.""" + import shared.pact_context as pc + _ctx_module, teams_root = ctx + # 'aaa-first' sorts before the default; the OLD code would return it. + _seed_collision_dir(teams_root, "aaa-first", lead_session_id=LEAD_SID) + _seed_collision_dir(teams_root, SESSION_ID8_DIR, lead_session_id=LEAD_SID) + resolved = pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default=SESSION_ID8_DIR + ) + assert resolved == SESSION_ID8_DIR + + def test_anchor_beats_fresher_subteam_taskstore(self, ctx, tmp_path): + """(tier 1 > tier 3) Lead edge case #4: a sibling with a FRESHER task + store must NOT steal the lead's own announced team. Identity anchor + precedes the liveness heuristic.""" + import shared.pact_context as pc + _ctx_module, teams_root = ctx + tasks_root = tmp_path / ".claude" / "tasks" + _seed_collision_dir(teams_root, SESSION_ID8_DIR, lead_session_id=LEAD_SID) + _seed_collision_dir(teams_root, "zzz-subteam", lead_session_id=LEAD_SID) + # The sub-team has the freshest task store — irrelevant, anchor wins. + _touch_task_store(tasks_root, SESSION_ID8_DIR, mtime=1000.0) + _touch_task_store(tasks_root, "zzz-subteam", mtime=9999.0) + resolved = pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default=SESSION_ID8_DIR, + tasks_dir=str(tasks_root), + ) + assert resolved == SESSION_ID8_DIR + + def test_own_canonical_name_wins_when_default_absent(self, ctx): + """(tier 2) Divergent-launch: default ('session-') is NOT among the + matches; the dir named the full-UUID (a canonical form) is recognized + structurally among random-named siblings.""" + import shared.pact_context as pc + _ctx_module, teams_root = ctx + _seed_collision_dir(teams_root, "aaa-random", lead_session_id=LEAD_SID) + _seed_collision_dir(teams_root, FULL_UUID_DIR, lead_session_id=LEAD_SID) + # default is a name that does NOT exist on disk -> tier 1 misses. + resolved = pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="session-0001639f" + ) + assert resolved == FULL_UUID_DIR + + def test_taskstore_recency_breaks_tie(self, ctx, tmp_path): + """(tier 3) No anchor, no canonical match — the dir whose task store has + the freshest child-mtime wins (the team the harness routes tasks into).""" + import shared.pact_context as pc + _ctx_module, teams_root = ctx + tasks_root = tmp_path / ".claude" / "tasks" + _seed_collision_dir(teams_root, "aaa-random", lead_session_id=LEAD_SID) + _seed_collision_dir(teams_root, "bbb-random", lead_session_id=LEAD_SID) + _touch_task_store(tasks_root, "aaa-random", mtime=1000.0) + _touch_task_store(tasks_root, "bbb-random", mtime=5000.0) # freshest + resolved = pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="not-a-match", + tasks_dir=str(tasks_root), + ) + assert resolved == "bbb-random" + + def test_createdat_is_secondary(self, ctx, tmp_path): + """(tier 4) Task stores absent/tied -> max createdAt wins.""" + import shared.pact_context as pc + _ctx_module, teams_root = ctx + tasks_root = tmp_path / ".claude" / "tasks" # empty -> tier-3 all 0.0 + _seed_collision_dir(teams_root, "aaa-random", lead_session_id=LEAD_SID, + created_at=100) + _seed_collision_dir(teams_root, "bbb-random", lead_session_id=LEAD_SID, + created_at=999) # newest + resolved = pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="not-a-match", + tasks_dir=str(tasks_root), + ) + assert resolved == "bbb-random" + + def test_name_tiebreak_is_smaller_first(self, ctx, tmp_path): + """(tier 5, LEAD RULING) All signals absent/tied -> SMALLER name wins, + byte-for-byte parity with today's sorted()[0]. Pins the tie-break + DIRECTION.""" + import shared.pact_context as pc + _ctx_module, teams_root = ctx + tasks_root = tmp_path / ".claude" / "tasks" # empty + # No createdAt, no task stores -> pure name determinism. + _seed_collision_dir(teams_root, "aaa-first", lead_session_id=LEAD_SID) + _seed_collision_dir(teams_root, "zzz-last", lead_session_id=LEAD_SID) + resolved = pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="not-a-match", + tasks_dir=str(tasks_root), + ) + assert resolved == "aaa-first" # smaller name, NOT zzz-last + + def test_unreadable_taskstore_never_raises(self, ctx, tmp_path): + """(tier 6 totality) A task store whose stat raises must DEGRADE the + recency tier to 0.0, never raise — the ladder falls to createdAt/name.""" + import shared.pact_context as pc + _ctx_module, teams_root = ctx + _seed_collision_dir(teams_root, "aaa-random", lead_session_id=LEAD_SID, + created_at=100) + _seed_collision_dir(teams_root, "bbb-random", lead_session_id=LEAD_SID, + created_at=999) + # Point tasks_dir at a FILE (not a dir) so iterdir() raises NotADirectory. + bogus = tmp_path / "not-a-tasks-dir" + bogus.write_text("x", encoding="utf-8") + resolved = pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="not-a-match", + tasks_dir=str(bogus), + ) + # Did not raise; degraded to createdAt -> newest wins. + assert resolved == "bbb-random" + + def test_single_match_zero_regression(self, ctx): + """Exactly one genuine match -> unchanged return (fast path).""" + import shared.pact_context as pc + _ctx_module, teams_root = ctx + _seed_collision_dir(teams_root, FULL_UUID_DIR, lead_session_id=LEAD_SID) + _seed_collision_dir(teams_root, "foreign", lead_session_id=TMUX_SID) + resolved = pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="ctx-default" + ) + assert resolved == FULL_UUID_DIR From 2045f8573c94aadffbd1b6e2f47e1fd87b5dfb4f Mon Sep 17 00:00:00 2001 From: Uros Pesic <129070018+v4lheru@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:14:26 +0200 Subject: [PATCH 2/3] chore(session-end): retire dead leadSessionId claims to stop collision growth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single PACT session stamps its leadSessionId onto many team dirs (every Agent/comPACT/orchestrate spawn). Nothing retired them, so the same-id collision corpus grew every session — the input that made the resolver mis-pick. session_end now nulls leadSessionId on the ending session's NON-LIVE same-id siblings (skips the live team case-insensitively, skips foreign-id dirs and symlinks), via a config-only atomic rewrite — no rmtree, a separate pass from the registry prune and the TTL reaper. Idempotent and fail-closed: runs only when both session_id and team_name are known. Retired-count threaded into the cleanup_summary journal event. --- pact-plugin/hooks/session_end.py | 133 ++++++++++++++ .../test_session_end_leadsession_retire.py | 173 ++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 pact-plugin/tests/test_session_end_leadsession_retire.py diff --git a/pact-plugin/hooks/session_end.py b/pact-plugin/hooks/session_end.py index 481f86e1c..19d0267a8 100644 --- a/pact-plugin/hooks/session_end.py +++ b/pact-plugin/hooks/session_end.py @@ -24,6 +24,7 @@ import re import shutil import sys +import tempfile import time from pathlib import Path @@ -788,6 +789,121 @@ def _prune_registry_dead_teams( return pruned +def _atomic_write_config(target: Path, data: dict) -> None: + """Atomically rewrite ``target`` (a team ``config.json``) with ``data``. + + Temp file in the SAME directory + ``os.rename`` (crash-safe atomic replace), + 0o600 — mirrors ``pact_context.persist_context``. Raises on any failure so + the caller's per-dir guard counts the dir as a skip rather than a retirement + (a partial/failed write must never be recorded as success). The temp file is + cleaned up on failure. Not fail-safe by itself BY DESIGN — the caller owns + the never-raises boundary. + """ + parent = target.parent + fd, tmp_path = tempfile.mkstemp(dir=str(parent), prefix=".config-", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(data, f) + os.chmod(tmp_path, 0o600) + os.rename(tmp_path, str(target)) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + +def _retire_session_leadsessionid_claims( + current_session_id: str, + current_team_name: str, + teams_dir: Path | None = None, +) -> int: + """Retire (``leadSessionId = None``) the ENDING session's claim on its + NON-LIVE sibling team dirs — the SessionEnd hygiene half of the + resume-path split-brain fix (issue: dispatch-gate stale-team split-brain). + + A single PACT session stamps its ``leadSessionId`` onto MANY team dirs + (every Agent/comPACT/orchestrate mints one). Nothing retires those claims + today, so the collision corpus the resolver must disambiguate grows every + session. This pass mirrors ``pact-align.py`` step 4: when THIS session ends, + null out its ``leadSessionId`` on every sibling dir EXCEPT the live team — + collapsing the collision toward a single claimant for the next resume. + + Predicate for retiring a dir ``D`` (ALL must hold): + * ``D`` is a real directory (symlinks skipped — lstat semantics — so a + planted link cannot redirect the rewrite outside the teams root); + * ``D/config.json`` exists and parses; + * ``data['leadSessionId'] == current_session_id`` — ONLY this session's + claims. A dir claimed by a DIFFERENT (foreign) session is left + untouched; an already-retired ``None`` claim is likewise ``!=`` our id + and skipped, making a re-run a no-op (idempotent); + * ``D.name.lower() != current_team_name.lower()`` — the LIVE team KEEPS + its claim (pact-align step 3 parity), so the collision collapses to + exactly one live claimant. + + Action on a match: read-modify-write ``config.json`` setting + ``leadSessionId = None`` (JSON null), preserving all other fields, via an + atomic temp-file rename. NO ``rmtree`` — the platform owns dir teardown; we + retire the CLAIM, not the DIR (leaves ``session-*`` / random-named dirs in + place). A SEPARATE pass from ``_prune_registry_dead_teams`` (registry JSONL) + and ``cleanup_old_teams`` (TTL rmtree) — it touches only ``config.json``. + + FAIL-CLOSED: without BOTH ``current_session_id`` AND ``current_team_name`` + we cannot guarantee we would SKIP the live team, so we retire NOTHING (never + risk nulling the live team's own claim). Best-effort / never raises: a + per-dir ``try/except`` swallows unreadable/malformed configs and write races + so a retirement failure can never block session termination. + + Args: + current_session_id: the ending session's id (the claim to retire). + current_team_name: the live team dir name to PRESERVE (skip). + teams_dir: the live-teams root. Defaults to ``~/.claude/teams``. + + Returns: + Number of dirs whose ``leadSessionId`` was retired (0 on fail-closed + no-op, absent teams root, or nothing to retire). + """ + # FAIL-CLOSED guard (belt-and-suspenders with the main() callsite check). + if not current_session_id or not current_team_name: + return 0 + if teams_dir is None: + teams_dir = get_claude_config_dir() / "teams" + base = Path(teams_dir) + if not base.exists(): + return 0 + + live_name = current_team_name.lower() + retired = 0 + try: + for entry in base.iterdir(): + try: + # Skip symlinks (lstat) and non-dirs; skip the LIVE team. + if entry.is_symlink() or not entry.is_dir(): + continue + if entry.name.lower() == live_name: + continue + config_path = entry / "config.json" + data = json.loads(config_path.read_text(encoding="utf-8")) + # Only OUR session's claims — foreign ids and already-retired + # (None) claims are both != our id, so both are skipped. + if not isinstance(data, dict): + continue + if data.get("leadSessionId") != current_session_id: + continue + data["leadSessionId"] = None + _atomic_write_config(config_path, data) + retired += 1 + except (OSError, json.JSONDecodeError, ValueError, TypeError, + AttributeError): + # Unreadable / malformed / write race → skip this dir, keep + # going. A retirement failure must NEVER block termination. + continue + except OSError: + pass # iterdir race on the teams root → best-effort, never raise + return retired + + def main(): try: try: @@ -850,6 +966,22 @@ def main(): _prune_registry_dead_teams() + # Retire this session's leadSessionId claims on its NON-LIVE sibling + # team dirs (forward-hygiene for the resume-path split-brain fix). A + # SEPARATE pass from cleanup_old_teams (TTL rmtree) and + # _prune_registry_dead_teams (registry JSONL) — it rewrites only + # config.json's leadSessionId field, never rmtree's a dir, and keeps + # the LIVE team's claim intact. FAIL-CLOSED: run ONLY when BOTH our own + # session id and live team name are known, else we cannot guarantee we + # would SKIP the live team, so we retire nothing. Best-effort: the + # helper never raises; the count feeds the cleanup_summary audit. + leadsession_claims_retired = 0 + if current_session_id and current_team_name: + leadsession_claims_retired = _retire_session_leadsessionid_claims( + current_session_id=current_session_id, + current_team_name=current_team_name, + ) + # Assemble skip-set via the module-level helper — see # `_assemble_tasks_skip_set` for the full rationale on the three # platform-key channels and the positive-regex allowlist. The @@ -889,6 +1021,7 @@ def main(): tasks_ttl_days=_SESSION_MAX_AGE_DAYS, teams_ran=teams_reaper_ran, tasks_ran=tasks_reaper_ran, + leadsession_claims_retired=leadsession_claims_retired, )) except Exception as e: print(f"Hook warning (cleanup_summary journal): {e}", file=sys.stderr) diff --git a/pact-plugin/tests/test_session_end_leadsession_retire.py b/pact-plugin/tests/test_session_end_leadsession_retire.py new file mode 100644 index 000000000..3b0fb3306 --- /dev/null +++ b/pact-plugin/tests/test_session_end_leadsession_retire.py @@ -0,0 +1,173 @@ +"""Smoke tests for session_end's leadSessionId retirement pass +(_retire_session_leadsessionid_claims). + +CODE-phase smoke coverage (backend-coder) for Commit 2 of the resume-path +split-brain fix: on session end, retire (leadSessionId=None) the ending +session's claim on its NON-LIVE same-id sibling dirs — skipping the live team +and foreign-id dirs, rewriting config.json only (never rmtree), fail-closed +when either the session id or team name is unknown, idempotent on re-run, and +best-effort (a write error on one dir does not abort the pass). + +The COMPREHENSIVE §6 retirement matrix + the both-topologies integration test +are the test-engineer's deliverable — this file is intentionally small and +pins the invariants the lead named plus the load-bearing safety predicates. +""" + +import json + +import pytest + +from session_end import _retire_session_leadsessionid_claims + +SID = "0be9512d-5f6d-490e-bca3-b4ccd68f11f8" +FOREIGN_SID = "64f7b112-ef89-434c-a0c0-00a038002136" +LIVE_TEAM = "session-0be9512d" + + +@pytest.fixture +def retire_env(tmp_path): + """Isolated ~/.claude/teams tree. Returns a helper namespace to seed team + dirs with a chosen leadSessionId and to run the retirement pass.""" + teams_dir = tmp_path / ".claude" / "teams" + teams_dir.mkdir(parents=True) + + class _Env: + teams = teams_dir + + @staticmethod + def seed(name, *, lead_session_id, extra=None): + d = teams_dir / name + d.mkdir(parents=True, exist_ok=True) + config = {"name": name, "leadSessionId": lead_session_id, + "members": [], "createdAt": 1771543196549} + if extra: + config.update(extra) + (d / "config.json").write_text(json.dumps(config), encoding="utf-8") + return d + + @staticmethod + def lead_of(name): + data = json.loads( + (teams_dir / name / "config.json").read_text(encoding="utf-8") + ) + return data.get("leadSessionId") + + @staticmethod + def config_of(name): + return json.loads( + (teams_dir / name / "config.json").read_text(encoding="utf-8") + ) + + @staticmethod + def retire(session_id=SID, team_name=LIVE_TEAM): + return _retire_session_leadsessionid_claims( + current_session_id=session_id, + current_team_name=team_name, + teams_dir=teams_dir, + ) + + return _Env + + +def test_skips_live_team(retire_env): + """The live team KEEPS its leadSessionId (pact-align step 3 parity).""" + retire_env.seed(LIVE_TEAM, lead_session_id=SID) + retire_env.seed("agile-swinging-shore", lead_session_id=SID) + retired = retire_env.retire() + assert retired == 1 + assert retire_env.lead_of(LIVE_TEAM) == SID # live untouched + assert retire_env.lead_of("agile-swinging-shore") is None + + +def test_nulls_competing_claims(retire_env): + """Every NON-live dir sharing our session id is retired to None.""" + retire_env.seed(LIVE_TEAM, lead_session_id=SID) + retire_env.seed("aaa-sibling", lead_session_id=SID) + retire_env.seed("zzz-sibling", lead_session_id=SID) + retired = retire_env.retire() + assert retired == 2 + assert retire_env.lead_of("aaa-sibling") is None + assert retire_env.lead_of("zzz-sibling") is None + + +def test_ignores_foreign_session_claims(retire_env): + """A dir claimed by a DIFFERENT (live) session is left untouched.""" + retire_env.seed(LIVE_TEAM, lead_session_id=SID) + retire_env.seed("other-session-team", lead_session_id=FOREIGN_SID) + retired = retire_env.retire() + assert retired == 0 + assert retire_env.lead_of("other-session-team") == FOREIGN_SID + + +def test_idempotent_second_run_is_noop(retire_env): + """A re-run is a no-op: already-None claims are != our id, so skipped.""" + retire_env.seed(LIVE_TEAM, lead_session_id=SID) + retire_env.seed("sibling", lead_session_id=SID) + assert retire_env.retire() == 1 + assert retire_env.lead_of("sibling") is None + # Second pass finds nothing to retire. + assert retire_env.retire() == 0 + assert retire_env.lead_of("sibling") is None + + +def test_fail_closed_on_empty_session_id(retire_env): + """No session id → retire NOTHING (cannot guarantee skipping live team).""" + retire_env.seed(LIVE_TEAM, lead_session_id=SID) + retire_env.seed("sibling", lead_session_id=SID) + assert retire_env.retire(session_id="") == 0 + assert retire_env.lead_of("sibling") == SID # untouched + + +def test_fail_closed_on_empty_team_name(retire_env): + """No live team name → retire NOTHING (would risk nulling the live team).""" + retire_env.seed(LIVE_TEAM, lead_session_id=SID) + retire_env.seed("sibling", lead_session_id=SID) + assert retire_env.retire(team_name="") == 0 + assert retire_env.lead_of("sibling") == SID # untouched + + +def test_skips_live_team_case_insensitively(retire_env): + """Live-team skip is case-insensitive (get_team_name lowercases its value).""" + retire_env.seed("Session-0BE9512D", lead_session_id=SID) # mixed-case dir + retire_env.seed("sibling", lead_session_id=SID) + retired = retire_env.retire(team_name="session-0be9512d") + assert retired == 1 + assert retire_env.lead_of("Session-0BE9512D") == SID # still the live claim + assert retire_env.lead_of("sibling") is None + + +def test_does_not_rmtree_preserves_all_fields(retire_env): + """Retirement rewrites ONLY leadSessionId — the dir survives and every + other config field is preserved (pins 'retire the claim, not the dir').""" + retire_env.seed(LIVE_TEAM, lead_session_id=SID) + retire_env.seed("sibling", lead_session_id=SID, + extra={"description": "PACT session team", "custom": 42}) + retire_env.retire() + # Dir still present. + assert (retire_env.teams / "sibling").is_dir() + cfg = retire_env.config_of("sibling") + assert cfg["leadSessionId"] is None + assert cfg["description"] == "PACT session team" # preserved + assert cfg["custom"] == 42 # preserved + assert cfg["name"] == "sibling" # preserved + + +def test_best_effort_on_unreadable_config(retire_env): + """A dir with a malformed config is skipped; the pass still retires the + readable competitor and never raises.""" + retire_env.seed(LIVE_TEAM, lead_session_id=SID) + retire_env.seed("good-sibling", lead_session_id=SID) + bad = retire_env.teams / "bad-sibling" + bad.mkdir() + (bad / "config.json").write_text("{not valid json", encoding="utf-8") + retired = retire_env.retire() # must not raise + assert retired == 1 + assert retire_env.lead_of("good-sibling") is None + + +def test_missing_teams_root_returns_zero(tmp_path): + """Absent teams root → clean 0, no raise.""" + absent = tmp_path / "nope" / "teams" + assert _retire_session_leadsessionid_claims( + current_session_id=SID, current_team_name=LIVE_TEAM, teams_dir=absent + ) == 0 From 12d4e84e16f3d8f3de5b434066c32ab776477f7c Mon Sep 17 00:00:00 2001 From: Uros Pesic <129070018+v4lheru@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:38:05 +0200 Subject: [PATCH 3/3] test(dispatch): comprehensive regression suite for resume-team-splitbrain fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 33 regression tests on top of the coder's smoke tests, proving the fix closes the original split-brain and preserves every pinned invariant: - both-topologies integration through the real get_team_name gate (same-id --resume, changed-id restart stale-persist, same-id random-live-dir) — each asserts resolution is NOT the alphabetically-first sibling (the pre-fix bug); L2 agreement check vs pact-align.py steps 3+4. - strict tier precedence (1>2>3>4>5) incl. a single-corpus walk down all rungs. - invariant coverage: empty-SSOT fail-closed despite a disambiguable corpus (1), foreign-id never promoted (2), branch-2 config-less full-UUID behind len>=2 gate (4), never-raises under malformed createdAt + all-signals-broken (6), retirement registry-JSONL byte-identical / symlink-skip / non-dict skip / best-effort write-error / no-rmtree (7,8). - counter-test-by-revert: pre-fix source produces 23 resolver + 2 retirement failures, confirming the tests bite the fix. Full suite: 11175 passed (+29), 445 skipped, 1 pre-existing-unrelated failure (test_memory_cli, confirmed identical on 0f5e94c1^ baseline). IMPORT-HYGIENE PASS. --- .../test_resume_splitbrain_regression.py | 555 ++++++++++++++++++ .../test_session_end_retire_regression.py | 282 +++++++++ 2 files changed, 837 insertions(+) create mode 100644 pact-plugin/tests/test_resume_splitbrain_regression.py create mode 100644 pact-plugin/tests/test_session_end_retire_regression.py diff --git a/pact-plugin/tests/test_resume_splitbrain_regression.py b/pact-plugin/tests/test_resume_splitbrain_regression.py new file mode 100644 index 000000000..e0388e4eb --- /dev/null +++ b/pact-plugin/tests/test_resume_splitbrain_regression.py @@ -0,0 +1,555 @@ +"""Comprehensive TEST-phase regression matrix for the resume-path dispatch-gate +stale-team split-brain fix (Commit 1: pact_context._resolve_aligned_team_name +genuine-collision disambiguation). + +This file is the test-engineer's §6 deliverable. It builds ON — and deliberately +does NOT duplicate — the backend-coder's SMOKE class +``test_team_name_detect_align.py::TestGenuineCollisionDisambiguation`` (which +exercises each ladder tier once). Here we add what the smoke tests do NOT cover: + + * STRICT TIER PRECEDENCE walked in a single mutating corpus (tier1 > tier2 > + tier3 > tier4 > tier5), so a future edit that reorders the ladder turns red. + * The §6 BOTH-TOPOLOGIES integration test proving the ORIGINAL split-brain is + closed through the real ``get_team_name`` gate — a corpus whose live team is + NOT alphabetically-first now resolves to the LIVE team, in BOTH the same-id + ``--resume`` divergence AND the changed-id stale-persist restart topology. + * PINNED-INVARIANT regression guards exercised ON the new disambiguation path: + - Invariant 1: empty-SSOT still fails CLOSED even when a >=2 collision + corpus is present (disambiguation NEVER runs on an empty SSOT). + - Invariant 2: foreign-id dirs are NEVER promoted by the liveness ladder, + even when they carry a fresher task store / larger createdAt / the anchor + name (the predicate stays the hard filter upstream). + - Invariant 4: branch-2 config-less full-UUID fallback is intact behind the + len>=2 disambiguation gate. + - Invariant 6: never-raises totality holds under malformed createdAt and a + combined all-signals-broken corpus. + +Correctness target: ``~/.claude/pact-align.py`` steps 3+4 (make the live team +unambiguously identifiable). The both-topologies test is the L2 agreement check +that the tested behavior actually repairs the reported bug. + +Every behavioral assertion seeds a corpus that makes the assertion BITE +(non-vacuity): the "wrong" alphabetically-first sibling is always present, so a +green result is attributable to the ladder picking the live team, not to an +empty/absent corpus. +""" + +import json +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "hooks")) + + +# ── Real-shaped session ids + canonical dir-name schemes ────────────────────── +# LEAD_SID keys the dominant --resume topology. RESTART_SID models a freshly +# minted id for the changed-id restart topology. FOREIGN_SID is a live OTHER +# session whose dirs must never be promoted. +LEAD_SID = "0001639f-a74f-41c4-bd0b-93d9d206e7f7" +RESTART_SID = "7c3d9e10-aaaa-4bbb-8ccc-ddddeeeeffff" +FOREIGN_SID = "ffff8888-bbbb-4ccc-9ddd-eeeeeeeeeeee" + +LEAD_ID8 = "0001639f" +RESTART_ID8 = "7c3d9e10" + +FULL_UUID_DIR = LEAD_SID # divergent-launch: bare 36-char UUID +SESSION_ID8_DIR = f"session-{LEAD_ID8}" # 2.1.178+ CLI canonical +PACT_ID8_DIR = f"pact-{LEAD_ID8}" # legacy PACT-minted canonical +RESTART_LIVE_DIR = f"session-{RESTART_ID8}" # live canonical for the restart id + + +# ── seeding helpers (self-contained; mirror the coder's fixture shapes) ─────── + + +def _seed_dir(teams_root, dir_name, *, lead_session_id, created_at=None, + extra=None): + """Seed teams//config.json with a chosen leadSessionId and an + optional createdAt (epoch millis, tier-4 signal).""" + team_dir = teams_root / dir_name + team_dir.mkdir(parents=True, exist_ok=True) + config = {"name": dir_name, "leadSessionId": lead_session_id, "members": []} + if created_at is not None: + config["createdAt"] = created_at + if extra: + config.update(extra) + (team_dir / "config.json").write_text(json.dumps(config), encoding="utf-8") + return team_dir + + +def _touch_store(tasks_root, team_name, *, mtime): + """Create tasks//1.json stamped to `mtime` — the tier-3 + task-store-recency signal (max child-mtime of the store).""" + store = tasks_root / team_name + store.mkdir(parents=True, exist_ok=True) + f = store / "1.json" + f.write_text("{}", encoding="utf-8") + os.utime(f, (mtime, mtime)) + return store + + +@pytest.fixture +def ctx(monkeypatch, tmp_path): + """Fresh pact_context state: home -> tmp_path, caches cleared. Returns + (module, teams_root, tasks_root) with the real /.claude/{teams,tasks} + layout so an un-injected resolve resolves to them too.""" + import shared.pact_context as ctx_module + monkeypatch.setattr(Path, "home", lambda: tmp_path) + ctx_module.reset_for_tests() + teams_root = tmp_path / ".claude" / "teams" + teams_root.mkdir(parents=True, exist_ok=True) + tasks_root = tmp_path / ".claude" / "tasks" + tasks_root.mkdir(parents=True, exist_ok=True) + yield ctx_module, teams_root, tasks_root + ctx_module.reset_for_tests() + + +def _write_context(monkeypatch, ctx_module, tmp_path, *, team_name, session_id): + """Persist the pact-session-context.json SSOT and point the module at it.""" + ctx_path = tmp_path / "pact-session-context.json" + ctx_path.write_text( + json.dumps({ + "team_name": team_name, + "session_id": session_id, + "project_dir": str(tmp_path / "project"), + "plugin_root": str(tmp_path / "plugin"), + "started_at": "2026-01-01T00:00:00Z", + }), + encoding="utf-8", + ) + monkeypatch.setattr(ctx_module, "_context_path", ctx_path) + monkeypatch.setattr(ctx_module, "_cache", None) + monkeypatch.setattr(ctx_module, "_aligned_cache", None) + return ctx_path + + +# ══════════════════════════════════════════════════════════════════════════════ +# 1. STRICT TIER PRECEDENCE — the ladder walked top-to-bottom in one corpus +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestTierPrecedence: + """The smoke suite proves each tier fires in isolation. These prove the + ORDERING: when a higher tier CAN decide, it must win over every lower one, + and removing the higher signal walks the winner down exactly one rung.""" + + def test_tier1_anchor_beats_canonical_name(self, ctx): + """(1 > 2) When `default` is itself a genuine match, it wins even though a + canonical-named sibling (session-) is ALSO present. Anchor is the + session's announced identity; own-canonical-name is only a fallback for + when the anchor is absent.""" + import shared.pact_context as pc + _m, teams_root, _t = ctx + # 'random-live' is the announced/persisted default AND a genuine match. + _seed_dir(teams_root, "random-live", lead_session_id=LEAD_SID) + _seed_dir(teams_root, SESSION_ID8_DIR, lead_session_id=LEAD_SID) # canonical + resolved = pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="random-live" + ) + assert resolved == "random-live", "tier-1 anchor must outrank tier-2 canonical" + + def test_tier2_canonical_beats_taskstore_recency(self, ctx): + """(2 > 3) With NO anchor (default not on disk), a canonical-named dir + wins even though a non-canonical sibling has a strictly FRESHER task + store. Identity (structural own-name) precedes the liveness heuristic.""" + import shared.pact_context as pc + _m, teams_root, tasks_root = ctx + _seed_dir(teams_root, PACT_ID8_DIR, lead_session_id=LEAD_SID) # canonical + _seed_dir(teams_root, "zzz-fresher", lead_session_id=LEAD_SID) + _touch_store(tasks_root, PACT_ID8_DIR, mtime=1000.0) + _touch_store(tasks_root, "zzz-fresher", mtime=9999.0) # fresher — ignored + resolved = pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="not-on-disk", + tasks_dir=str(tasks_root), + ) + assert resolved == PACT_ID8_DIR, "tier-2 canonical must outrank tier-3 recency" + + def test_tier3_recency_beats_createdat(self, ctx): + """(3 > 4) No anchor, no canonical. The dir with the fresher task store + wins EVEN THOUGH it has the SMALLER createdAt — recency outranks + createdAt. Non-vacuity: the createdAt ordering is deliberately OPPOSITE + the recency ordering, so a green result can only come from recency.""" + import shared.pact_context as pc + _m, teams_root, tasks_root = ctx + _seed_dir(teams_root, "aaa-fresh-store", lead_session_id=LEAD_SID, + created_at=100) # smaller createdAt + _seed_dir(teams_root, "bbb-old-store", lead_session_id=LEAD_SID, + created_at=999) # larger createdAt + _touch_store(tasks_root, "aaa-fresh-store", mtime=9000.0) # freshest + _touch_store(tasks_root, "bbb-old-store", mtime=1000.0) + resolved = pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="not-a-match", + tasks_dir=str(tasks_root), + ) + assert resolved == "aaa-fresh-store", ( + "tier-3 recency must outrank tier-4 createdAt" + ) + + def test_tier4_createdat_beats_name(self, ctx): + """(4 > 5) No anchor, no canonical, task stores tied/absent. The dir with + the LARGER createdAt wins even though its name is lexicographically + LARGER (so tier-5 smaller-first would have picked the other). createdAt + outranks the name tie-break.""" + import shared.pact_context as pc + _m, teams_root, tasks_root = ctx # tasks_root empty -> tier-3 all 0.0 + _seed_dir(teams_root, "aaa-smaller-name", lead_session_id=LEAD_SID, + created_at=100) + _seed_dir(teams_root, "zzz-larger-name", lead_session_id=LEAD_SID, + created_at=999) # newest -> must win despite larger name + resolved = pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="not-a-match", + tasks_dir=str(tasks_root), + ) + assert resolved == "zzz-larger-name", ( + "tier-4 createdAt must outrank tier-5 name (else 'aaa' would win)" + ) + + def test_full_ladder_walks_down_one_rung_at_a_time(self, ctx, tmp_path): + """The ladder walked end-to-end in ONE corpus: with every signal present + the tier-1 anchor wins; removing each higher signal in turn walks the + winner down to tier-2 (canonical), tier-3 (recency), tier-4 (createdAt), + tier-5 (smallest name). A single coherent proof of the whole ordering. + + Corpus (all share LEAD_SID). Every dir carries a UNIFORM baseline + createdAt=100 so that once the higher signals are stripped the createdAt + tier is TIED and the name floor (tier-5) decides; only 'nnn-newest' is + elevated to expose tier-4: + * 'random-anchor' — the announced default (tier-1) + * SESSION_ID8_DIR — canonical own-name (tier-2) + * 'mmm-fresh' — freshest task store (tier-3) + * 'nnn-newest' — largest createdAt=9999 (tier-4) + * 'aaa-smallest' — lexicographically smallest name (tier-5 floor) + """ + import shared.pact_context as pc + _m, teams_root, tasks_root = ctx + _seed_dir(teams_root, "random-anchor", lead_session_id=LEAD_SID, + created_at=100) + _seed_dir(teams_root, SESSION_ID8_DIR, lead_session_id=LEAD_SID, + created_at=100) + _seed_dir(teams_root, "mmm-fresh", lead_session_id=LEAD_SID, + created_at=100) + _seed_dir(teams_root, "nnn-newest", lead_session_id=LEAD_SID, + created_at=9999) # elevated -> the ONLY tier-4 distinction + _seed_dir(teams_root, "aaa-smallest", lead_session_id=LEAD_SID, + created_at=100) + _touch_store(tasks_root, "mmm-fresh", mtime=9999.0) # freshest store + + def resolve(default): + return pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default=default, + tasks_dir=str(tasks_root), + ) + + # tier-1: anchor present -> wins over all lower signals. + assert resolve("random-anchor") == "random-anchor" + # Remove anchor from contention (default not on disk) -> tier-2 canonical. + assert resolve("not-on-disk") == SESSION_ID8_DIR + # Remove the canonical dir -> tier-3 freshest task store (mmm-fresh), + # which wins DESPITE nnn-newest's larger createdAt (recency > createdAt). + (teams_root / SESSION_ID8_DIR / "config.json").unlink() + (teams_root / SESSION_ID8_DIR).rmdir() + assert resolve("not-on-disk") == "mmm-fresh" + # Empty the fresh store (dir stays -> mtime 0.0) -> all recency tied -> + # tier-4 largest createdAt (nnn-newest) decides. + (tasks_root / "mmm-fresh" / "1.json").unlink() + assert resolve("not-on-disk") == "nnn-newest" + # Neutralize nnn-newest's createdAt (re-seed with none -> 0). Now every + # remaining dir is tied at createdAt=100 (nnn-newest=0) -> tier-5 smallest + # name decides. + _seed_dir(teams_root, "nnn-newest", lead_session_id=LEAD_SID) # no createdAt + assert resolve("not-on-disk") == "aaa-smallest" + + +# ══════════════════════════════════════════════════════════════════════════════ +# 2. §6 BOTH-TOPOLOGIES INTEGRATION — the original split-brain is CLOSED +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestBothTopologiesCollapseToLive: + """The headline regression: a session with multiple same-leadSessionId team + dirs where the harness-live team is NOT alphabetically-first now resolves to + the LIVE team — through the REAL ``get_team_name`` gate, in BOTH resume + topologies (PREPARE (B.3)). This is the L2 agreement check that the fix + actually repairs the reported bug (pact-align.py steps 3+4 north-star).""" + + def test_same_id_resume_collapses_to_live_via_anchor(self, ctx, monkeypatch, + tmp_path): + """TOPOLOGY 1 — same-id ``--resume`` (the dominant case). session_id is + PRESERVED; the persisted SSOT team_name is the live team. The corpus has + alphabetically-EARLIER stale siblings all claiming LEAD_SID (the historic + name-sort would have returned 'aaa-stale-subteam'). get_team_name anchors + on the persisted identity -> the LIVE team, NOT the alpha-first sibling.""" + ctx_module, teams_root, _t = ctx + # Stale siblings sort BEFORE the live team -> the pre-fix bug's wrong pick. + _seed_dir(teams_root, "aaa-stale-subteam", lead_session_id=LEAD_SID) + _seed_dir(teams_root, "bbb-stale-subteam", lead_session_id=LEAD_SID) + _seed_dir(teams_root, SESSION_ID8_DIR, lead_session_id=LEAD_SID) # live + _write_context(monkeypatch, ctx_module, tmp_path, + team_name=SESSION_ID8_DIR, session_id=LEAD_SID) + + resolved = ctx_module.get_team_name() + assert resolved == SESSION_ID8_DIR.lower(), "must collapse to the LIVE team" + assert resolved != "aaa-stale-subteam", ( + "the pre-fix alphabetically-first pick is exactly the split-brain bug" + ) + + def test_changed_id_restart_collapses_to_live_via_canonical(self, ctx, + monkeypatch, + tmp_path): + """TOPOLOGY 2 — changed-id restart / stale-persist (PREPARE (B.3), + hypothesis i). A FRESH session_id (RESTART_SID) is minted; the persisted + SSOT team_name is STALE (a prior session's name, NOT on disk), so the + tier-1 anchor MISSES. The new session already spawned sub-teams all + stamped RESTART_SID, and the live team is the canonical session-. + get_team_name recognizes the live team STRUCTURALLY via tier-2 + own-canonical-name -> the LIVE team, NOT the alpha-first sub-team.""" + ctx_module, teams_root, _t = ctx + _seed_dir(teams_root, "aaa-subteam", lead_session_id=RESTART_SID) + _seed_dir(teams_root, "bbb-subteam", lead_session_id=RESTART_SID) + _seed_dir(teams_root, RESTART_LIVE_DIR, lead_session_id=RESTART_SID) # live + # Persisted team_name is a STALE prior-session value NOT among the matches + # -> anchor cannot decide; the ladder must recover the live team. + _write_context(monkeypatch, ctx_module, tmp_path, + team_name="session-deadbeef", session_id=RESTART_SID) + + resolved = ctx_module.get_team_name() + assert resolved == RESTART_LIVE_DIR.lower(), ( + "changed-id restart must still collapse to the LIVE canonical team" + ) + assert resolved != "aaa-subteam" + + def test_same_id_resume_collapses_via_recency_when_no_identity_signal( + self, ctx, monkeypatch, tmp_path + ): + """TOPOLOGY 1 variant — the harness resumed into a RANDOM-named live dir + (neither the persisted default nor a canonical own-name), and is actively + routing tasks there. Anchor and canonical both miss; get_team_name must + collapse to the team the harness ACTUALLY writes tasks into (tier-3 + recency) — the semantically-live team — not the alpha-first sibling.""" + ctx_module, teams_root, tasks_root = ctx + _seed_dir(teams_root, "aaa-orphan", lead_session_id=LEAD_SID) + _seed_dir(teams_root, "mmm-live-random", lead_session_id=LEAD_SID) + _seed_dir(teams_root, "zzz-orphan", lead_session_id=LEAD_SID) + # Only the live dir has an actively-written task store. + _touch_store(tasks_root, "aaa-orphan", mtime=100.0) + _touch_store(tasks_root, "mmm-live-random", mtime=9999.0) # freshest = live + # Persisted team_name is a stale value NOT on disk -> anchor misses; no + # canonical dir present -> tier-2 misses; recency decides. + _write_context(monkeypatch, ctx_module, tmp_path, + team_name="session-stale00", session_id=LEAD_SID) + + resolved = ctx_module.get_team_name() + assert resolved == "mmm-live-random", ( + "must collapse to the team the harness is actively routing tasks into" + ) + assert resolved != "aaa-orphan" + + +# ══════════════════════════════════════════════════════════════════════════════ +# 3. PINNED-INVARIANT regression guards exercised ON the new disambiguation path +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestInvariantOneEmptySsotOnCollisionCorpus: + """Invariant 1 — empty-SSOT fails CLOSED. The new disambiguation lives INSIDE + the resolver, downstream of get_team_name's empty-SSOT short-circuit. So even + a rich >=2 collision corpus (which WOULD disambiguate to a live team) must + NOT be reached when the persisted team_name is empty.""" + + def test_empty_ssot_fails_closed_despite_disambiguable_collision( + self, ctx, monkeypatch, tmp_path + ): + """EMPTY persisted SSOT + a >=2 genuine collision corpus -> get_team_name + returns '' WITHOUT ever entering disambiguation. NON-VACUITY: the SAME + corpus is proven disambiguable at the resolver boundary (a non-'' live + team), so the '' is attributable to the fail-closed guard, not to an + empty/absent corpus.""" + import shared.pact_context as pc + ctx_module, teams_root, _t = ctx + _seed_dir(teams_root, "aaa-sibling", lead_session_id=LEAD_SID) + _seed_dir(teams_root, SESSION_ID8_DIR, lead_session_id=LEAD_SID) + # Empty persisted team_name -> the Option-B fail-closed short-circuit. + _write_context(monkeypatch, ctx_module, tmp_path, + team_name="", session_id=LEAD_SID) + + assert ctx_module.get_team_name() == "", "empty SSOT must fail closed" + + # NON-VACUITY: the corpus DOES disambiguate when reached directly. + assert pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default=SESSION_ID8_DIR + ) == SESSION_ID8_DIR + + +class TestInvariantTwoForeignNeverPromoted: + """Invariant 2 — identity-match stays collision-PROOF against FOREIGN ids. + The liveness ladder ranks ONLY dirs that already passed the + ``leadSessionId == session_id`` predicate. A foreign-id dir must never win, + even when it carries the freshest task store, the largest createdAt, OR a + name equal to the anchor default.""" + + def test_foreign_dir_with_fresher_store_and_newer_createdat_never_wins( + self, ctx, tmp_path + ): + """A foreign-id dir that is fresher AND newer than the genuine matches is + never promoted; a genuine (LEAD_SID) match wins. NON-VACUITY: the foreign + dir has the strictly-winning liveness signals, so a green result proves + the predicate filtered it out BEFORE ranking.""" + import shared.pact_context as pc + _m, teams_root, tasks_root = ctx + # Two genuine matches (a real >=2 collision) ... + _seed_dir(teams_root, "aaa-genuine", lead_session_id=LEAD_SID, + created_at=100) + _seed_dir(teams_root, "bbb-genuine", lead_session_id=LEAD_SID, + created_at=200) + # ... plus a FOREIGN dir that would win every liveness tier if considered. + _seed_dir(teams_root, "zzz-foreign", lead_session_id=FOREIGN_SID, + created_at=999999) + _touch_store(tasks_root, "aaa-genuine", mtime=100.0) + _touch_store(tasks_root, "bbb-genuine", mtime=200.0) + _touch_store(tasks_root, "zzz-foreign", mtime=999999.0) # freshest + + resolved = pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="not-a-match", + tasks_dir=str(tasks_root), + ) + assert resolved in {"aaa-genuine", "bbb-genuine"}, ( + "only a genuine LEAD_SID match may win" + ) + assert resolved != "zzz-foreign", "foreign id must never be promoted" + # It is 'bbb-genuine' specifically (larger createdAt among genuine). + assert resolved == "bbb-genuine" + + def test_anchor_default_naming_a_foreign_dir_is_not_honored(self, ctx): + """The tier-1 anchor returns `default` ONLY if it is itself a genuine + match. A `default` string that happens to name a FOREIGN-id dir must NOT + be honored — the anchor checks membership in the genuine-matches set.""" + import shared.pact_context as pc + _m, teams_root, _t = ctx + # 'shared-name' is a FOREIGN-id dir; the caller passes it as default. + _seed_dir(teams_root, "shared-name", lead_session_id=FOREIGN_SID) + # Two genuine matches force the >=2 disambiguation path. + _seed_dir(teams_root, "aaa-genuine", lead_session_id=LEAD_SID) + _seed_dir(teams_root, "bbb-genuine", lead_session_id=LEAD_SID) + resolved = pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="shared-name", + ) + assert resolved != "shared-name", ( + "anchor must not honor a default that names a foreign-id dir" + ) + assert resolved in {"aaa-genuine", "bbb-genuine"} + + +class TestInvariantFourBranchTwoIntact: + """Invariant 4 — the branch-2 config-less full-UUID Desktop/SDK fallback is + reached only on len(matches)==0 and is byte-unchanged. The len>=2 + disambiguation gate must not shadow or reorder it.""" + + def test_branch2_full_uuid_intact_when_no_config_match(self, ctx): + """No config.json carries LEAD_SID (len(matches)==0), but a config-less + teams// dir with inboxes/ IS present -> branch-2 resolves to + session_id. Disambiguation (gated len>=2) does not run and cannot shadow + this path.""" + import shared.pact_context as pc + _m, teams_root, _t = ctx + # Config-less full-UUID dir with an inboxes/ subdir (the branch-2 signal). + divergent = teams_root / FULL_UUID_DIR + divergent.mkdir(parents=True) + (divergent / "inboxes").mkdir() + # A noise sibling with a FOREIGN id (so it is not a match, len stays 0). + _seed_dir(teams_root, "aaa-foreign", lead_session_id=FOREIGN_SID) + resolved = pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="fallback" + ) + assert resolved == FULL_UUID_DIR, "branch-2 config-less fallback must fire" + + +class TestInvariantSixNeverRaisesOnDisambiguation: + """Invariant 6 — _resolve_aligned_team_name NEVER raises. Every new disk read + on the disambiguation path (task-store stat, createdAt parse) degrades to a + neutral key; nothing escapes. Smoke covers an unreadable task store; here we + cover malformed createdAt and a combined all-signals-broken corpus.""" + + @pytest.mark.parametrize("bad_created_at", [ + "not-a-number", None, "", [], {}, "12.5abc", + ]) + def test_malformed_createdat_degrades_to_name_never_raises( + self, ctx, tmp_path, bad_created_at + ): + """A malformed createdAt on the tie-deciding dirs degrades to 0 (tier-4 + neutral) and the ladder falls to the tier-5 smallest name — never raises. + Non-vacuity: both dirs carry the SAME malformed value so the decision can + only come from the name floor.""" + import shared.pact_context as pc + _m, teams_root, tasks_root = ctx # empty tasks_root -> tier-3 all 0.0 + _seed_dir(teams_root, "aaa-small", lead_session_id=LEAD_SID, + created_at=bad_created_at) + _seed_dir(teams_root, "zzz-large", lead_session_id=LEAD_SID, + created_at=bad_created_at) + resolved = pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="not-a-match", + tasks_dir=str(tasks_root), + ) + # Both createdAt degrade to 0 -> tier-5 smallest name wins deterministically. + assert resolved == "aaa-small" + + def test_combined_all_signals_broken_falls_to_name_never_raises( + self, ctx, tmp_path + ): + """Belt-and-suspenders: no anchor, no canonical, an UNREADABLE task store + (tasks_dir points at a FILE so iterdir raises), AND malformed createdAt on + every dir -> the resolver degrades every liveness tier and lands on the + tier-5 smallest name without raising.""" + import shared.pact_context as pc + _m, teams_root, _t = ctx + _seed_dir(teams_root, "aaa-x", lead_session_id=LEAD_SID, + created_at="garbage") + _seed_dir(teams_root, "mmm-y", lead_session_id=LEAD_SID, created_at=None) + _seed_dir(teams_root, "zzz-z", lead_session_id=LEAD_SID, + created_at={"nested": "bad"}) + # tasks_dir is a FILE, not a dir -> every _task_store_mtime iterdir raises. + bogus_tasks = tmp_path / "tasks-is-a-file" + bogus_tasks.write_text("x", encoding="utf-8") + resolved = pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default="not-a-match", + tasks_dir=str(bogus_tasks), + ) + assert resolved == "aaa-x", "all signals degraded -> smallest name, no raise" + + +# ══════════════════════════════════════════════════════════════════════════════ +# 4. COUNTER-MODEL — the disambiguation result is attributable to the NEW ladder +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestDisambiguationAttribution: + """A standing in-process non-vacuity guard: with the collision corpus fixed, + NEUTERING the disambiguation helper to the pre-fix "return first name-sorted" + behavior FLIPS the winner from the live team to the alphabetically-first + sibling. The paired intact+neutered assertions prove the correct pick is + caused by the new ladder, not by anything incidental in the fixture.""" + + def test_live_pick_is_attributable_to_disambiguation(self, ctx, monkeypatch): + """INTACT: anchor picks the live team over an alphabetically-earlier + sibling. NEUTERED: with _disambiguate_collision stubbed to return the + first (name-sorted) match — the pre-fix behavior — the SAME corpus + resolves to the alpha-first sibling. Mutually exclusive outcomes.""" + import shared.pact_context as pc + _m, teams_root, _t = ctx + _seed_dir(teams_root, "aaa-stale", lead_session_id=LEAD_SID) + _seed_dir(teams_root, SESSION_ID8_DIR, lead_session_id=LEAD_SID) # live + + # INTACT — anchor on the live team. + assert pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default=SESSION_ID8_DIR + ) == SESSION_ID8_DIR + + # NEUTER to the pre-fix "first name-sorted match wins". + monkeypatch.setattr( + pc, "_disambiguate_collision", + lambda matches, session_id, default, tasks_root: matches[0][0], + ) + assert pc._resolve_aligned_team_name( + LEAD_SID, teams_dir=str(teams_root), default=SESSION_ID8_DIR + ) == "aaa-stale", "neutered pre-fix behavior returns the alpha-first sibling" diff --git a/pact-plugin/tests/test_session_end_retire_regression.py b/pact-plugin/tests/test_session_end_retire_regression.py new file mode 100644 index 000000000..272c44bfd --- /dev/null +++ b/pact-plugin/tests/test_session_end_retire_regression.py @@ -0,0 +1,282 @@ +"""Comprehensive TEST-phase regression matrix for the SessionEnd leadSessionId +retirement pass (Commit 2: session_end._retire_session_leadsessionid_claims). + +Builds ON — and does NOT duplicate — the backend-coder's SMOKE file +``test_session_end_leadsession_retire.py`` (skip-live, null-competing, +foreign-untouched, idempotent, fail-closed, no-rmtree, best-effort-on-read, +missing-root). Here we add what the smoke tests do NOT cover: + + * §6 ``test_retire_leaves_registry_prune_untouched`` — invariant 7: the + retirement pass is SEPARATE from ``_prune_registry_dead_teams``; it rewrites + only team ``config.json`` files and never touches the registry JSONL. + * The coder's two DEFENSIVE hardenings that ship with NO test: + - symlink-skip (lstat semantics) — a planted symlinked team dir cannot + redirect the config rewrite outside the teams root (security). + - non-dict config skip — a config.json that parses to a list/str/number + (not an object) is skipped, never raises. + * best-effort on a WRITE error (smoke only covers a READ/parse error): a dir + whose config cannot be atomically rewritten is skipped and NOT counted as a + retirement, and the pass still retires the healthy competitor. + * A realistic MIXED corpus integration exercising every predicate branch at + once (live + competing + foreign + already-None + malformed + non-dict). + +Invariant 8 (no rmtree, name-shape gate unchanged) is pinned by the smoke +``test_does_not_rmtree_preserves_all_fields`` plus the mixed-corpus test here +asserting all dirs survive. +""" + +import json +import os +import stat +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "hooks")) + +from session_end import _retire_session_leadsessionid_claims # noqa: E402 + +SID = "0be9512d-5f6d-490e-bca3-b4ccd68f11f8" +FOREIGN_SID = "64f7b112-ef89-434c-a0c0-00a038002136" +LIVE_TEAM = "session-0be9512d" + + +@pytest.fixture +def env(tmp_path): + """Isolated ~/.claude/teams tree with seed/inspect/run helpers.""" + teams_dir = tmp_path / ".claude" / "teams" + teams_dir.mkdir(parents=True) + + class _Env: + teams = teams_dir + root = tmp_path + + @staticmethod + def seed(name, *, lead_session_id, extra=None): + d = teams_dir / name + d.mkdir(parents=True, exist_ok=True) + config = {"name": name, "leadSessionId": lead_session_id, + "members": [], "createdAt": 1771543196549} + if extra: + config.update(extra) + (d / "config.json").write_text(json.dumps(config), encoding="utf-8") + return d + + @staticmethod + def seed_raw(name, raw_text): + """Seed a config.json with arbitrary raw bytes (malformed / non-dict).""" + d = teams_dir / name + d.mkdir(parents=True, exist_ok=True) + (d / "config.json").write_text(raw_text, encoding="utf-8") + return d + + @staticmethod + def lead_of(name): + return json.loads( + (teams_dir / name / "config.json").read_text(encoding="utf-8") + ).get("leadSessionId") + + @staticmethod + def retire(session_id=SID, team_name=LIVE_TEAM): + return _retire_session_leadsessionid_claims( + current_session_id=session_id, + current_team_name=team_name, + teams_dir=teams_dir, + ) + + return _Env + + +# ══════════════════════════════════════════════════════════════════════════════ +# 1. §6 — Invariant 7: the retirement pass leaves the registry JSONL untouched +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestSeparateFromRegistryPrune: + """Invariant 7 — the leadSessionId retirement pass touches ONLY team + config.json files. It is a SEPARATE pass from _prune_registry_dead_teams, + which owns ~/.claude/pact-sessions/.teammate-registry.jsonl. Retirement must + never open, read, or rewrite the registry JSONL.""" + + def test_retire_leaves_registry_jsonl_byte_identical(self, env): + """Seed a registry JSONL alongside the teams tree, run retirement, and + assert the registry file is byte-for-byte unchanged (retirement does not + touch it) while a competing team's config WAS retired.""" + # A realistic registry file living under pact-sessions/ (a SIBLING of + # teams/, exactly where _prune_registry_dead_teams operates). + sessions_dir = env.root / ".claude" / "pact-sessions" + sessions_dir.mkdir(parents=True) + registry = sessions_dir / ".teammate-registry.jsonl" + registry_content = ( + json.dumps({"name": "alice", "team": LIVE_TEAM, + "session_id": SID}) + "\n" + + json.dumps({"name": "bob", "team": "agile-swinging-shore", + "session_id": SID}) + "\n" + ) + registry.write_text(registry_content, encoding="utf-8") + before = registry.read_bytes() + + env.seed(LIVE_TEAM, lead_session_id=SID) + env.seed("agile-swinging-shore", lead_session_id=SID) + + retired = env.retire() + + # The competing config WAS retired ... + assert retired == 1 + assert env.lead_of("agile-swinging-shore") is None + # ... but the registry JSONL is byte-identical (separate pass, untouched). + assert registry.read_bytes() == before, ( + "retirement must not touch the registry JSONL (invariant 7)" + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# 2. Coder DEFENSIVE hardenings that ship without a smoke test +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestSymlinkSkipSecurity: + """The pass skips symlinks via lstat semantics (``entry.is_symlink()``) so a + planted symlinked team dir cannot redirect the config rewrite outside the + teams root. This defensive hardening ships with no smoke coverage.""" + + def test_symlinked_competing_dir_is_not_rewritten(self, env, tmp_path): + """A symlink UNDER teams/ that points at an OUTSIDE real team dir claiming + our SID is SKIPPED — the outside target's config is NOT retired (its + leadSessionId is preserved), proving the rewrite cannot be redirected + through a link. A genuine in-tree competitor is still retired.""" + env.seed(LIVE_TEAM, lead_session_id=SID) + env.seed("real-competitor", lead_session_id=SID) + + # A real team dir OUTSIDE the teams root, claiming our SID. + outside = tmp_path / "outside-teams" / "planted" + outside.mkdir(parents=True) + (outside / "config.json").write_text( + json.dumps({"name": "planted", "leadSessionId": SID}), + encoding="utf-8", + ) + # A symlink inside teams/ pointing at it. + link = env.teams / "zzz-symlink" + try: + link.symlink_to(outside, target_is_directory=True) + except (OSError, NotImplementedError): + pytest.skip("symlinks not supported on this platform") + + retired = env.retire() + + # The genuine in-tree competitor was retired ... + assert env.lead_of("real-competitor") is None + # ... but the symlink target OUTSIDE the tree was NOT touched. + outside_lead = json.loads( + (outside / "config.json").read_text(encoding="utf-8") + ).get("leadSessionId") + assert outside_lead == SID, ( + "a symlinked dir must be skipped — the rewrite cannot follow a link" + ) + # Count reflects only the real in-tree retirement. + assert retired == 1 + + +class TestNonDictConfigSkip: + """A config.json that is valid JSON but NOT an object (list / string / + number) is skipped by the ``isinstance(data, dict)`` guard — never treated + as a claim, never raises.""" + + @pytest.mark.parametrize("raw", ["[]", '"a string"', "42", "null", "true"]) + def test_non_dict_config_is_skipped(self, env, raw): + """A non-dict config is skipped; the healthy competitor is still retired + and the pass never raises.""" + env.seed(LIVE_TEAM, lead_session_id=SID) + env.seed("good", lead_session_id=SID) + env.seed_raw("weird-nondict", raw) + + retired = env.retire() # must not raise + + assert env.lead_of("good") is None # healthy competitor retired + assert retired == 1 # only the healthy one counted + # The non-dict config is left exactly as written (not rewritten to null). + assert (env.teams / "weird-nondict" / "config.json").read_text( + encoding="utf-8" + ) == raw + + +# ══════════════════════════════════════════════════════════════════════════════ +# 3. Best-effort on a WRITE error (smoke covers only a READ/parse error) +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestBestEffortOnWriteError: + """A dir whose config parses fine but CANNOT be atomically rewritten (the + _atomic_write_config temp-file rename fails) must be skipped and NOT counted + as a retirement — a partial/failed write is never recorded as success — while + the pass still retires the healthy competitor and never raises.""" + + def test_unwritable_competing_dir_is_skipped_not_counted(self, env): + """Make a competing dir READ-ONLY (0o500) so mkstemp/rename inside it + raises; the pass skips it (not counted) and still retires the healthy + sibling. Restores perms in teardown so tmp cleanup succeeds.""" + env.seed(LIVE_TEAM, lead_session_id=SID) + env.seed("good-sibling", lead_session_id=SID) + locked = env.seed("locked-sibling", lead_session_id=SID) + + # Read-only dir: reading config.json still works, but creating the temp + # file for the atomic rewrite fails -> _atomic_write_config raises -> + # caller counts the dir as a skip, not a retirement. + os.chmod(locked, stat.S_IRUSR | stat.S_IXUSR) # 0o500 + try: + retired = env.retire() # must not raise + finally: + os.chmod(locked, stat.S_IRWXU) # restore for cleanup + + # The healthy sibling WAS retired; the locked one was NOT (still our SID). + assert env.lead_of("good-sibling") is None + assert env.lead_of("locked-sibling") == SID, ( + "an unwritable dir must be skipped, its claim left intact" + ) + # Count reflects only the successful rewrite. + assert retired == 1, "a failed write must not be counted as a retirement" + + +# ══════════════════════════════════════════════════════════════════════════════ +# 4. MIXED-corpus integration — every predicate branch exercised at once +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestMixedCorpusIntegration: + """A realistic end-of-session corpus hitting every branch of the predicate in + a single pass: the live team (skipped), two same-id competitors (retired), a + foreign-id dir (untouched), an already-retired None claim (no-op), a + malformed config (skipped), and a non-dict config (skipped). Asserts the + exact retired count, the correct per-dir outcomes, no rmtree, and no raise.""" + + def test_mixed_corpus_retires_only_live_competitors(self, env): + env.seed(LIVE_TEAM, lead_session_id=SID) # live -> skip + env.seed("aaa-competitor", lead_session_id=SID) # retire + env.seed("bbb-competitor", lead_session_id=SID) # retire + env.seed("foreign-team", lead_session_id=FOREIGN_SID) # untouched + env.seed("already-retired", lead_session_id=None) # no-op (!= SID) + env.seed_raw("malformed", "{not valid json") # skip + env.seed_raw("nondict", "[1, 2, 3]") # skip + + retired = env.retire() # must not raise + + # Exactly the two same-id competitors were retired. + assert retired == 2 + assert env.lead_of("aaa-competitor") is None + assert env.lead_of("bbb-competitor") is None + # Live team keeps its claim (pact-align step 3 parity). + assert env.lead_of(LIVE_TEAM) == SID + # Foreign session's claim untouched. + assert env.lead_of("foreign-team") == FOREIGN_SID + # Already-retired stays None (idempotent). + assert env.lead_of("already-retired") is None + # Malformed and non-dict left exactly as written. + assert (env.teams / "malformed" / "config.json").read_text( + encoding="utf-8") == "{not valid json" + assert (env.teams / "nondict" / "config.json").read_text( + encoding="utf-8") == "[1, 2, 3]" + # NO rmtree — every dir still present (invariant 8). + for name in ("aaa-competitor", "bbb-competitor", "foreign-team", + "already-retired", "malformed", "nondict", LIVE_TEAM): + assert (env.teams / name).is_dir()