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/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_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_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 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() 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