@ssheleg/agent-sync 1.13.0 → 1.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,7 +33,7 @@ from datetime import datetime, timezone
33
33
  from pathlib import Path
34
34
  from typing import Any
35
35
 
36
- VERSION = "1.13.0"
36
+ VERSION = "1.15.0"
37
37
 
38
38
  CONFIG_PATH = Path(".claude/agent-sync.json")
39
39
  ENV_FILE = Path(".env.agent-sync")
@@ -113,12 +113,24 @@ def now_iso() -> str:
113
113
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
114
114
 
115
115
 
116
- def parse_iso(ts: str) -> float:
116
+ def parse_iso_or_none(ts: str) -> float | None:
117
+ """The instant this string names, or None when it names none.
118
+
119
+ `parse_iso` folds an unreadable timestamp into 0.0, and every expiry test then reads
120
+ that as "expired in 1970". For exclusion that is the right answer — a lock whose clock
121
+ cannot be read must not go on holding a key. For residue it is the wrong one: *spent*
122
+ and *unreadable* are different verdicts, and only the first may be cleared.
123
+ """
117
124
  try:
118
125
  return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(
119
126
  tzinfo=timezone.utc).timestamp()
120
- except ValueError:
121
- return 0.0
127
+ except (TypeError, ValueError):
128
+ return None
129
+
130
+
131
+ def parse_iso(ts: str) -> float:
132
+ at = parse_iso_or_none(ts)
133
+ return 0.0 if at is None else at
122
134
 
123
135
 
124
136
  def git(*args: str, cwd: Path | None = None) -> str:
@@ -827,6 +839,157 @@ def resolve_holder(events: list[dict[str, str]], key: str, at: float) -> str | N
827
839
  return str(h["run"]) if h else None
828
840
 
829
841
 
842
+ # What a lock file turns out to be once the TTL has been applied to it. `live` is not
843
+ # residue; the other three are, and they are not interchangeable — the first may be
844
+ # cleared, the other two may only be reported.
845
+ LIVE = "live"
846
+ REAPABLE = "reapable"
847
+ FOREIGN = "foreign"
848
+ AMBIGUOUS = "ambiguous"
849
+
850
+ # The same three-way split for the OTHER plane: a claim tag written into a registry file.
851
+ # `held` there means the lease the tag names is still inside its TTL; `orphan` means it is
852
+ # not, and `disputed` means the lease is live under a different run than the tag names.
853
+ TAG_HELD = "held"
854
+ ORPHAN = "orphan"
855
+ DISPUTED = "disputed"
856
+
857
+ DEFAULT_CLAIM_TEMPLATE = "{prev} (claimed: {holder})"
858
+
859
+
860
+ def claim_marker_re(template: str) -> "re.Pattern[str] | None":
861
+ """A regex for this template's claim marker, capturing the run it names.
862
+
863
+ `write_claim` builds the marker by emptying `{prev}` and substituting the run id. Read
864
+ back, the run id is the one part not known in advance: it is the capture and everything
865
+ around it is literal, so one function defines the marker in both directions instead of
866
+ two spellings that can disagree.
867
+
868
+ `None` for a template with no `{holder}`. Such a marker names no owner, and a tag whose
869
+ owner cannot be read must never be cleared on somebody's behalf — the same rule
870
+ `classify_lock` applies to a lock that records no run.
871
+ """
872
+ marker = template.replace("{prev}", "").strip()
873
+ if "{holder}" not in marker:
874
+ return None
875
+ head, _, tail = marker.partition("{holder}")
876
+ return re.compile(re.escape(head) + r"(?P<holder>[^|\s]+)" + re.escape(tail))
877
+
878
+
879
+ def classify_lock(key: str, raw: str, *, rid: str, identity_is_strong: bool,
880
+ repo: str, host: str, default_ttl: int, at: float) -> dict[str, Any]:
881
+ """One lock file, read as a live lease or as one of three kinds of residue.
882
+
883
+ Pure — everything it needs is an argument — so each verdict below is a fixture rather
884
+ than a scenario somebody has to reproduce with two sessions and a clock.
885
+
886
+ **Why this function has to exist.** Every reader of lease state in this tool folds the
887
+ TTL into the read: `held()`, `_lease_holder()` and `all_holdings()` each answer *none*
888
+ for an expired lock and *none* for a lock that is not there. That is correct for
889
+ exclusion and it is why seventeen expired locks across nine repositories of one family
890
+ were reported by nothing — `status` printed `leases held: none` and `finish` printed
891
+ "no lease left held" standing on top of them. Expiry ends a lease. It does not remove
892
+ a file, and nothing here could see the difference.
893
+
894
+ **The split is the load-bearing part.** `reapable` means state this run can PROVE it
895
+ owns and has spent, and the proof is deliberately narrow:
896
+
897
+ - the lock is past its TTL — a live lease is held, not residue;
898
+ - it records a `run`, and that run is this one;
899
+ - **this run's identity is not the shared fallback.** `run_id()` keys its marker by
900
+ session, and a shell with no session id is served one shared entry whose own
901
+ docstring says the identity "is shared with any other session in this checkout".
902
+ Under that key a matching run id proves nothing, so it does not license a delete.
903
+ - its `repo` is this checkout, and its `host` — written by both lease modes since
904
+ AS-03, absent only in locks taken before it — is this machine.
905
+
906
+ Everything else is reported and left alone: `foreign` where it demonstrably belongs to
907
+ somebody else, `ambiguous` where the question cannot be answered at all. In doubt the
908
+ answer is `ambiguous`, never `reapable`.
909
+ """
910
+ out: dict[str, Any] = {"key": key, "state": AMBIGUOUS, "run": None, "repo": None,
911
+ "host": None, "ts": "", "expired_for": None, "why": ""}
912
+ try:
913
+ held = json.loads(raw)
914
+ except (json.JSONDecodeError, ValueError):
915
+ out["why"] = "the lock is not readable JSON, so nothing in it identifies an owner"
916
+ return out
917
+ if not isinstance(held, dict):
918
+ out["why"] = "the lock is not an object, so nothing in it identifies an owner"
919
+ return out
920
+
921
+ out["run"] = held.get("run") or None
922
+ out["repo"] = held.get("repo") or None
923
+ out["host"] = held.get("host") or None
924
+ out["ts"] = str(held.get("ts") or "")
925
+
926
+ taken = parse_iso_or_none(out["ts"])
927
+ if taken is None:
928
+ out["why"] = (f"its timestamp ({out['ts'] or 'absent'}) is not one, so whether this "
929
+ "lock is spent cannot be established")
930
+ return out
931
+ try:
932
+ ttl = int(held.get("ttl", default_ttl))
933
+ except (TypeError, ValueError):
934
+ out["why"] = (f"its ttl ({held.get('ttl')!r}) is not a number, so whether this lock "
935
+ "is spent cannot be established")
936
+ return out
937
+
938
+ expires = taken + ttl
939
+ if at <= expires:
940
+ out["state"] = LIVE
941
+ out["why"] = (f"held by {out['run'] or 'an unnamed run'} for another "
942
+ f"{int(expires - at)}s")
943
+ return out
944
+ out["expired_for"] = int(at - expires)
945
+
946
+ if not out["run"]:
947
+ out["why"] = "it records no run, so there is nobody it can be proved to belong to"
948
+ return out
949
+ if out["host"] and out["host"] != host:
950
+ out["state"] = FOREIGN
951
+ out["why"] = f"it was taken on {out['host']}, which is not this machine"
952
+ return out
953
+ if out["run"] != rid:
954
+ out["state"] = FOREIGN
955
+ out["why"] = f"it belongs to run {out['run']}, not to this one"
956
+ return out
957
+ if out["repo"] and out["repo"] != repo:
958
+ out["why"] = (f"its run id matches, but it names repository {out['repo']} while this "
959
+ f"checkout is {repo}")
960
+ return out
961
+ if not identity_is_strong:
962
+ out["why"] = ("its run id matches, but this run's identity is the shared fallback — "
963
+ "any other session in this checkout answers to the same id, so the "
964
+ "match proves nothing")
965
+ return out
966
+ out["state"] = REAPABLE
967
+ out["why"] = "this run took it and let it expire"
968
+ return out
969
+
970
+
971
+ def since(seconds: int) -> str:
972
+ """A duration an operator can act on."""
973
+ if seconds < 90:
974
+ return f"{seconds}s"
975
+ if seconds < 5400:
976
+ return f"{seconds // 60}m"
977
+ if seconds < 172800:
978
+ return f"{seconds // 3600}h"
979
+ return f"{seconds // 86400}d {(seconds % 86400) // 3600}h"
980
+
981
+
982
+ def spent(entry: dict[str, Any]) -> str:
983
+ """How long this lock has been residue — said once, so no surface can phrase it
984
+ differently. A lock whose clock cannot be read is not "expired an unknown time ago";
985
+ it is a lock nobody can say is spent, which is why it is never reaped."""
986
+ if entry["state"] == LIVE:
987
+ return "live"
988
+ if entry.get("expired_for") is None:
989
+ return "expiry could not be established"
990
+ return f"expired {since(entry['expired_for'])} ago"
991
+
992
+
830
993
  def resolve_reservations(events: list[dict[str, str]], reg: str) -> tuple[int, list[int], list[tuple[str, int]]]:
831
994
  """Positional allocation over the log. Returns (base, free_list, assignments)."""
832
995
  base = None
@@ -882,6 +1045,8 @@ class Sync:
882
1045
  self.adapter = make_adapter(self.cfg, self.root)
883
1046
  self.rid = run_id(self.root)
884
1047
  self.ttl = int(self.cfg.get("leaseTtlSeconds") or DEFAULT_TTL)
1048
+ self._identity: tuple[str, str] | None = None
1049
+ self._holders: dict[str, str | None] = {}
885
1050
 
886
1051
  @property
887
1052
  def gated(self) -> bool:
@@ -1148,8 +1313,14 @@ class Sync:
1148
1313
  return won, holder
1149
1314
 
1150
1315
  lock = self._local_lock(key)
1316
+ # `host` is written in BOTH lease modes (AS-03). It used to be the git mode's alone,
1317
+ # on the reasoning that a `local` lease is machine-local by construction — but the
1318
+ # *file* is not: a checkout on a synced or shared directory is read by two machines,
1319
+ # and `classify_lock` consumes `host` to decide `foreign`. Without it the classifier
1320
+ # had one fewer way to refuse, on 25 of the 25 locks this family had on disk
1321
+ # (2026-08-20). Absent stays legal: locks written before this line exist.
1151
1322
  payload = json.dumps({"run": self.rid, "ts": now_iso(), "ttl": self.ttl,
1152
- "repo": repo_name()})
1323
+ "repo": repo_name(), "host": platform.node()})
1153
1324
 
1154
1325
  if lock.exists():
1155
1326
  try:
@@ -1356,6 +1527,188 @@ class Sync:
1356
1527
  print(f"note: released locally, not published ({exc})", file=sys.stderr)
1357
1528
  return True
1358
1529
 
1530
+ @property
1531
+ def identity(self) -> tuple[str, str]:
1532
+ """(key, how) for this run's identity — resolved once, because it walks `ps`."""
1533
+ if self._identity is None:
1534
+ self._identity = _session_key()
1535
+ return self._identity
1536
+
1537
+ @property
1538
+ def identity_is_strong(self) -> bool:
1539
+ """Whether a matching run id is proof of anything.
1540
+
1541
+ False means `_session_key()` established nothing and `run_id()` served the shared
1542
+ entry — the identity every other session in this checkout is also given. Enough to
1543
+ coordinate with, and deliberately not enough to delete on.
1544
+ """
1545
+ return bool(self.identity[0])
1546
+
1547
+ def residue(self) -> list[dict[str, Any]]:
1548
+ """Every lock file in this checkout, classified — the live ones included.
1549
+
1550
+ The enumerating read the tool never had. `held()` below globs the same directory
1551
+ and keeps only what is BOTH this run's and alive, which makes it a liveness
1552
+ predicate: it cannot tell an empty directory from one full of corpses, and no
1553
+ other reader here can either.
1554
+ """
1555
+ d = self.root / STATE_DIR / "leases"
1556
+ now, host, repo = time.time(), platform.node(), repo_name()
1557
+ out: list[dict[str, Any]] = []
1558
+ for p in sorted(d.glob("*.lock") if d.exists() else []):
1559
+ try:
1560
+ raw = p.read_text()
1561
+ except OSError as exc:
1562
+ entry: dict[str, Any] = {
1563
+ "key": p.stem, "state": AMBIGUOUS, "run": None, "repo": None,
1564
+ "host": None, "ts": "", "expired_for": None,
1565
+ "why": f"the lock cannot be read ({exc})"}
1566
+ else:
1567
+ entry = classify_lock(p.stem, raw, rid=self.rid,
1568
+ identity_is_strong=self.identity_is_strong,
1569
+ repo=repo, host=host, default_ttl=self.ttl, at=now)
1570
+ entry["path"] = p
1571
+ out.append(entry)
1572
+ return out
1573
+
1574
+ def stale(self) -> list[dict[str, Any]]:
1575
+ """The residue only — every lock whose lease has already ended."""
1576
+ return [e for e in self.residue() if e["state"] != LIVE]
1577
+
1578
+ def reap(self, keys: list[str] | None = None) -> dict[str, list[dict[str, Any]]]:
1579
+ """Clear only `reapable` residue, and prove it went by LOOKING AGAIN.
1580
+
1581
+ The second observation is the whole point. `unlink` returns nothing and raises
1582
+ nothing on a filesystem where the entry survives the call — a read-only mount, an
1583
+ NFS write that never lands, a directory whose write bit was dropped between two
1584
+ commands, another process recreating the name — so a teardown that reports success
1585
+ out of its own return value is reporting the wish rather than the state. What comes
1586
+ back here is the difference between two reads of the directory.
1587
+
1588
+ Identity decides the second read, not absence: a key that came back as another
1589
+ run's live lease WAS torn down, and calling that a failure would teach an operator
1590
+ to ignore the one message that matters.
1591
+ """
1592
+ before = self.residue()
1593
+ named = None
1594
+ if keys:
1595
+ named = set()
1596
+ for k in keys:
1597
+ named.add(k)
1598
+ named.add(self._local_lock(k).stem)
1599
+ wanted = [e for e in before
1600
+ if e["state"] == REAPABLE and (named is None or e["key"] in named)]
1601
+ refused = [e for e in before
1602
+ if e["state"] != REAPABLE and named is not None and e["key"] in named]
1603
+ if named is not None:
1604
+ known = {e["key"] for e in before}
1605
+ for k in sorted(named - known):
1606
+ if self._local_lock(k).stem in known:
1607
+ continue
1608
+ refused.append({"key": k, "state": "absent", "run": None, "ts": "",
1609
+ "expired_for": None,
1610
+ "why": "there is no lock by that name in this checkout"})
1611
+
1612
+ for e in wanted:
1613
+ try:
1614
+ e["path"].unlink()
1615
+ except FileNotFoundError:
1616
+ pass
1617
+ except OSError as exc:
1618
+ e["error"] = str(exc)
1619
+
1620
+ after = {e["key"]: e for e in self.residue()}
1621
+ reaped, remaining = [], []
1622
+ for e in wanted:
1623
+ still = after.get(e["key"])
1624
+ if still is not None and still["run"] == e["run"] and still["ts"] == e["ts"]:
1625
+ remaining.append(e)
1626
+ else:
1627
+ reaped.append(e)
1628
+ return {"reaped": reaped, "remaining": remaining, "refused": refused,
1629
+ "left": [e for e in before if e["state"] in (FOREIGN, AMBIGUOUS)]}
1630
+
1631
+ def claim_tags_on_disk(self) -> list[dict[str, Any]]:
1632
+ """Every claim tag written into a registry file, with the run each one names.
1633
+
1634
+ The enumerating read for the OTHER plane, and it did not exist. `held()`,
1635
+ `_lease_holder()` and `residue()` all answer from the lease; nothing answered from
1636
+ the board — so a tag whose lease had ended was reported by no command at all.
1637
+ `release` printed success and changed nothing (`write_claim` had no saved cell to
1638
+ undo), `residue` said "nothing on disk", `status` said "leases held: none", and
1639
+ `reconcile` never mentioned it. Filed as ssheleg/agent-sync#5 and reproduced
1640
+ verbatim at 1.14.0.
1641
+ """
1642
+ out: list[dict[str, Any]] = []
1643
+ for pattern, spec in (self.cfg.get("claimTags") or {}).items():
1644
+ if spec.get("mode") != "cell":
1645
+ continue
1646
+ rx = claim_marker_re(spec.get("held") or DEFAULT_CLAIM_TEMPLATE)
1647
+ if rx is None:
1648
+ continue
1649
+ idx = int(spec.get("cell", -1))
1650
+ for path in sorted(glob_files(self.root, pattern)):
1651
+ if not path.is_file():
1652
+ continue
1653
+ try:
1654
+ lines = path.read_text().splitlines()
1655
+ except OSError:
1656
+ continue
1657
+ rel = str(path.relative_to(self.root))
1658
+ for n, line in enumerate(lines, 1):
1659
+ cells = self._row_cells(line)
1660
+ if not cells or not -len(cells) <= idx < len(cells):
1661
+ continue
1662
+ m = rx.search(cells[idx])
1663
+ if not m:
1664
+ continue
1665
+ out.append({"key": cells[0].strip(), "holder": m.group("holder"),
1666
+ "file": rel, "line": n, "cell": cells[idx].strip()})
1667
+ return out
1668
+
1669
+ def _holder_of(self, key: str) -> str | None:
1670
+ """`_lease_holder`, memoised for the length of one command.
1671
+
1672
+ One notion of held, and only one reader of it. Memoised because in git mode that
1673
+ reader is an `ls-remote`: a report over a board with several tags would otherwise
1674
+ pay a network round-trip per row. The bound is the number of TAGGED rows, not the
1675
+ size of the board — an untagged row is never looked up.
1676
+ """
1677
+ if key not in self._holders:
1678
+ self._holders[key] = self._lease_holder(key)
1679
+ return self._holders[key]
1680
+
1681
+ def orphan_claims(self) -> list[dict[str, Any]]:
1682
+ """Claim tags with no live lease behind them — one notion of held, both planes.
1683
+
1684
+ The TTL is the contract. A tag is a claim about a lease, so once `_lease_holder` —
1685
+ the single reader every other command already trusts — answers `None`, the tag is
1686
+ residue in the registry exactly as an expired lock is residue on disk, and is
1687
+ reported the same way.
1688
+
1689
+ A tag naming a run that still holds the lease is not residue and never appears
1690
+ here. A tag naming one run while the live lease belongs to another is `disputed`:
1691
+ reported, never cleared, because clearing it would edit a registry under a run
1692
+ that is still working.
1693
+ """
1694
+ out: list[dict[str, Any]] = []
1695
+ for e in self.claim_tags_on_disk():
1696
+ holder = self._holder_of(e["key"])
1697
+ if holder == e["holder"]:
1698
+ continue
1699
+ e = dict(e)
1700
+ if holder is None:
1701
+ e["state"] = ORPHAN
1702
+ e["why"] = (f"the tag names run {e['holder']}, and the {self.lease_mode} "
1703
+ f"lease plane holds no live lease for `{e['key']}` — the TTL "
1704
+ "has already ended it")
1705
+ else:
1706
+ e["state"] = DISPUTED
1707
+ e["why"] = (f"the tag names run {e['holder']} while the live lease for "
1708
+ f"`{e['key']}` is held by {holder}")
1709
+ out.append(e)
1710
+ return out
1711
+
1359
1712
  def held(self) -> list[str]:
1360
1713
  d = self.root / STATE_DIR / "leases"
1361
1714
  mine = []
@@ -1778,7 +2131,40 @@ class Sync:
1778
2131
  state.setdefault(key, {})[str(rel)] = current
1779
2132
  else:
1780
2133
  if saved is None:
1781
- continue # nothing of ours to undo
2134
+ # NOT "nothing to undo" that reading is ssheleg/agent-sync#5. The
2135
+ # state file is this run's memory of what it overwrote, and a tag
2136
+ # outlives it routinely: written by a run that died, by a session on
2137
+ # another machine, or with `.agent-sync/` wiped between the acquire and
2138
+ # the release. `release` then printed success, exited 0, and left
2139
+ # `(claimed: r-…)` on the board with nothing behind it — a claim no
2140
+ # command could reach.
2141
+ #
2142
+ # The TTL decides, exactly as it decides for a lock file: the lease
2143
+ # plane is asked who holds the key, and only a tag with no live lease
2144
+ # behind it is cleared. A live lease is somebody working, and its tag
2145
+ # is left alone whoever runs this.
2146
+ template = spec.get("held") or DEFAULT_CLAIM_TEMPLATE
2147
+ rx = claim_marker_re(template)
2148
+ m = rx.search(current) if rx else None
2149
+ if m is None:
2150
+ continue # no tag here either, so nothing to undo
2151
+ whose = m.group("holder")
2152
+ live = self._holder_of(key)
2153
+ if live is not None and live != self.rid:
2154
+ notes.append(
2155
+ f"{rel}: `{key}` carries a claim tag naming {whose}, and the "
2156
+ f"lease is live under {live} — left alone")
2157
+ continue
2158
+ stripped = (current[:m.start()] + current[m.end():]).strip()
2159
+ cells[idx] = f" {stripped} " if stripped else " "
2160
+ notes.append(
2161
+ f"{rel}: cleared an orphaned claim tag on `{key}` — it named run "
2162
+ f"{whose}, and no live lease stands behind it (ssheleg/agent-sync#5)")
2163
+ lines[i] = row_prefix + "|" + "|".join(cells) + row_suffix
2164
+ tmp = path.with_suffix(path.suffix + ".agent-sync.tmp")
2165
+ tmp.write_text("".join(lines))
2166
+ tmp.replace(path)
2167
+ continue
1782
2168
  # Restoring VERBATIM loses any edit made while the claim was held, and in
1783
2169
  # this family the claim cell IS the status cell — so `close then release`
1784
2170
  # silently reopened a row closed with evidence minutes earlier (B-35,
@@ -1820,14 +2206,18 @@ class Sync:
1820
2206
  that rewrites a shared registry file on its own is the exact mechanism that
1821
2207
  clobbers another agent's work, and it would do it from a hook, unattended.
1822
2208
  So this reports, and the agent writes.
2209
+
2210
+ Both directions, since AS-04. It used to return on `if not held` — so divergence
2211
+ was reported only to a run holding a lease, and the one shape that needs reporting
2212
+ most, a tag with NO lease behind it, was structurally invisible: nobody holds it,
2213
+ so nobody could be told (ssheleg/agent-sync#5). The lease side is still keyed by
2214
+ what this run holds; the registry side is now swept whether it holds anything or not.
1823
2215
  """
1824
2216
  out: list[str] = []
1825
2217
  tags = self.cfg.get("claimTags") or {}
1826
2218
  if not tags:
1827
2219
  return out
1828
2220
  held = set(self.held())
1829
- if not held:
1830
- return out
1831
2221
  for pattern, spec in tags.items():
1832
2222
  for path in sorted(self.root.glob(pattern)):
1833
2223
  if not path.is_file():
@@ -1855,6 +2245,15 @@ class Sync:
1855
2245
  out.append(f"{rel}: cannot verify the claim tag for `{key}` — "
1856
2246
  f"`{spec['open']}` appears in the file but not on that "
1857
2247
  "id's line. Fix claimTags, or write the tag by hand")
2248
+
2249
+ # The other direction: a tag whose lease the TTL has already ended, or one naming a
2250
+ # run that is not the run holding the key. Reported to every session, not only to a
2251
+ # session that happens to hold a lease.
2252
+ for e in self.orphan_claims():
2253
+ remedy = ("`release` it" if e["state"] == ORPHAN
2254
+ else "ask the holder, and do not edit the row")
2255
+ out.append(f"{e['file']}:{e['line']}: `{e['key']}` [{e['state']}] — "
2256
+ f"{e['why']}. NEXT: {remedy}")
1858
2257
  return out
1859
2258
 
1860
2259
  # -- as-built record and reconciliation ---------------------------------
@@ -1994,6 +2393,15 @@ class Sync:
1994
2393
  "no id registers declared here, so register checks are not evaluated in "
1995
2394
  "this repository — run reconcile in the umbrella for those")
1996
2395
 
2396
+ # 4. A claim tag on the board whose lease the TTL has already ended. Mechanical,
2397
+ # two-plane divergence — the definition of what this command reports — and it
2398
+ # was the one kind `reconcile` never mentioned (ssheleg/agent-sync#5).
2399
+ for e in self.orphan_claims():
2400
+ findings.append({
2401
+ "kind": f"claim tag with no live lease ({e['state']})",
2402
+ "detail": f"{e['file']}:{e['line']} `{e['key']}` names {e['holder']}",
2403
+ "means": e["why"]})
2404
+
1997
2405
  self.backlog = notes_backlog
1998
2406
  return findings
1999
2407
 
@@ -2193,6 +2601,8 @@ class Sync:
2193
2601
  "| What was actually built, with its commit | as-built log | permanent, append-only |",
2194
2602
  "| Cross-repo dependency state | signal log | permanent, append-only |",
2195
2603
  "| Who holds a task right now | claims log | expires by TTL |",
2604
+ "| A lock left by a run that stopped | the lease directory | until it is "
2605
+ "reported and reaped |",
2196
2606
  "| Per-run narrative | that run's journal | permanent |",
2197
2607
  "| The board and these pages | generated | replaced on every regeneration |",
2198
2608
  "",
@@ -2224,6 +2634,10 @@ class Sync:
2224
2634
  "merge --key → land the branch: conflicts checked first, the merge recorded,",
2225
2635
  " that lease released. Without a branch, `release ID` by hand",
2226
2636
  " — on every path, including failure",
2637
+ "residue → what this run leaves on disk. Expiry ends a lease and leaves",
2638
+ " the file, so `status` and `finish` enumerate them; `reap`",
2639
+ " clears only what THIS run can prove it owns and has spent,",
2640
+ " and reports foreign or ambiguously owned locks untouched",
2227
2641
  "```",
2228
2642
  "",
2229
2643
  f"This project's integration branch is `{self.integration_branch}`.",
@@ -2509,6 +2923,49 @@ def cmd_status(_args: argparse.Namespace) -> int:
2509
2923
  return 1
2510
2924
  print(f" leases held : {', '.join(held) if held else 'none'}")
2511
2925
 
2926
+ # A run produces more than a diff, and what it leaves behind has to be reported by the
2927
+ # command every session runs. Until this line existed, `status` printed `leases held:
2928
+ # none` beside three expired locks in the directory it had just read — every reader of
2929
+ # lease state here applies the TTL, so "expired" and "absent" were one answer.
2930
+ stale = s.stale()
2931
+ reapable = [e for e in stale if e["state"] == REAPABLE]
2932
+ left_alone = [e for e in stale if e["state"] != REAPABLE]
2933
+ if not stale:
2934
+ print(" expired locks : none")
2935
+ else:
2936
+ print(f" expired locks : {len(stale)} — {len(reapable)} this run's to clear, "
2937
+ f"{len(left_alone)} left alone")
2938
+ print("\n Expired leases still on disk. Nobody holds these: the TTL ended the "
2939
+ "lease\n and left the file.")
2940
+ for e in stale[:6]:
2941
+ print(f" · {e['key']} [{e['state']}] {e['why']} ({spent(e)})")
2942
+ if len(stale) > 6:
2943
+ print(f" · … and {len(stale) - 6} more — agent_sync.py residue")
2944
+ if reapable:
2945
+ print(" Clear what this run owns: agent_sync.py reap")
2946
+ if left_alone:
2947
+ print(" The rest are foreign or ambiguously owned — reported, not touched.")
2948
+
2949
+ # The registry plane's residue, on the same footing as the lease plane's. A claim tag
2950
+ # is a claim about a lease, and until this line the TTL could end the lease while the
2951
+ # tag stayed on the board reading live — with `leases held: none` printed above it.
2952
+ try:
2953
+ orphans = s.orphan_claims()
2954
+ except Fail:
2955
+ orphans = []
2956
+ if not (s.cfg.get("claimTags") or {}):
2957
+ pass
2958
+ elif not orphans:
2959
+ print(" orphan claims : none")
2960
+ else:
2961
+ print(f" orphan claims : {len(orphans)} claim tag(s) with no live lease behind "
2962
+ "them")
2963
+ for e in orphans[:6]:
2964
+ print(f" · {e['file']}:{e['line']} {e['key']} [{e['state']}] {e['why']}")
2965
+ if len(orphans) > 6:
2966
+ print(f" · … and {len(orphans) - 6} more")
2967
+ print(" Clear one: agent_sync.py release <KEY>")
2968
+
2512
2969
  # Who else is in here, and what landed while this run was away. Without this a
2513
2970
  # lease only tells an agent it is blocked, never who by or on what.
2514
2971
  plane_broken = False
@@ -2554,7 +3011,7 @@ def cmd_status(_args: argparse.Namespace) -> int:
2554
3011
 
2555
3012
  claim_issues = s.claim_divergence()
2556
3013
  if claim_issues:
2557
- print("\n Claim tags not written through:")
3014
+ print("\n Claim tags out of step with the lease plane:")
2558
3015
  for c in claim_issues:
2559
3016
  print(f" ! {c}")
2560
3017
 
@@ -2674,6 +3131,97 @@ def cmd_release_id(args: argparse.Namespace) -> int:
2674
3131
  return 0
2675
3132
 
2676
3133
 
3134
+ def cmd_residue(_args: argparse.Namespace) -> int:
3135
+ """What this run leaves behind, classified — and never quietly cleared.
3136
+
3137
+ Reporting and clearing are two commands on purpose. This one is safe to run anywhere,
3138
+ including in somebody else's checkout, because it cannot remove anything.
3139
+ """
3140
+ s = Sync()
3141
+ entries = s.residue()
3142
+ print(f"run {s.rid} · identity from {s.identity[1]}")
3143
+ print(f"lease mode {s.lease_mode} · ttl {s.ttl}s · {len(entries)} lock file(s) in "
3144
+ f"{STATE_DIR}/leases\n")
3145
+ if not entries:
3146
+ print(" nothing in the lock directory — no lease has been taken in this checkout, "
3147
+ "or every\n one was released")
3148
+ for e in entries:
3149
+ print(f" {e['key']}")
3150
+ print(f" state : {e['state']}"
3151
+ + ("" if e["state"] == LIVE else f" ({spent(e)})"))
3152
+ print(f" run : {e['run'] or '—'} · repo {e['repo'] or '—'}"
3153
+ f"{' · host ' + e['host'] if e['host'] else ''}")
3154
+ print(f" why : {e['why']}")
3155
+ reapable = [e for e in entries if e["state"] == REAPABLE]
3156
+ other = [e for e in entries if e["state"] in (FOREIGN, AMBIGUOUS)]
3157
+ if entries:
3158
+ print()
3159
+ if reapable:
3160
+ print(f" {len(reapable)} reapable — this run's own, spent: agent_sync.py reap")
3161
+ if other:
3162
+ print(f" {len(other)} foreign or ambiguous — reported, never cleared from here. "
3163
+ "An expired\n lock in another run's name is that run's to explain, and a "
3164
+ "lock whose owner\n cannot be established is nobody's to delete.")
3165
+ if not reapable and not other:
3166
+ print(" no residue — every lock on disk is a live lease")
3167
+
3168
+ # The second plane. A claim tag is residue too, and reporting only the lock directory
3169
+ # was the whole of ssheleg/agent-sync#5: `residue` printed "nothing on disk" over a
3170
+ # board row reading `(claimed: r-…)` that no command could reach.
3171
+ if s.cfg.get("claimTags"):
3172
+ orphans = s.orphan_claims()
3173
+ tags = len(s.claim_tags_on_disk())
3174
+ print(f"\n claim tags: {tags} on disk, {len(orphans)} with no live lease behind them")
3175
+ for e in orphans:
3176
+ print(f" · {e['file']}:{e['line']} {e['key']} [{e['state']}]")
3177
+ print(f" why : {e['why']}")
3178
+ if e["state"] == ORPHAN:
3179
+ print(f" clear : agent_sync.py release {e['key']}")
3180
+ else:
3181
+ print(" leave : the lease is live under another run — ask the holder")
3182
+ if tags and not orphans:
3183
+ print(" every tag names the run that still holds its key")
3184
+ else:
3185
+ print("\n claim tags: not configured here, so none are swept "
3186
+ "(`claimTags` in the config)")
3187
+
3188
+ # AS-01a. Said where the report prints, not only on a board: a check that cannot look
3189
+ # must not read as one that looked.
3190
+ if s.lease_mode == "git":
3191
+ print("\n ⚠ INCOMPLETE IN THIS MODE. The read above walks "
3192
+ f"{STATE_DIR}/leases only, and in\n"
3193
+ " git mode the authority is `refs/agent-sync/leases/*` on "
3194
+ f"{s.cfg.get('leaseRemote') or 'origin'}.\n"
3195
+ " A ref won on ANOTHER machine leaves no local note here, so it is absent "
3196
+ "from this\n report — absent, not proven gone. Sweeping the refs is board row "
3197
+ "AS-01a; until it\n lands, enumerate them by hand:\n"
3198
+ f" git ls-remote {s.cfg.get('leaseRemote') or 'origin'} "
3199
+ "'refs/agent-sync/leases/*'")
3200
+ return 0
3201
+
3202
+
3203
+ def cmd_reap(args: argparse.Namespace) -> int:
3204
+ """Clear this run's spent locks, and verify the teardown by reading the state again."""
3205
+ s = Sync()
3206
+ result = s.reap(args.keys or None)
3207
+ for e in result["reaped"]:
3208
+ print(f" reaped {e['key']} — {e['why']}, confirmed gone by re-reading "
3209
+ f"{STATE_DIR}/leases")
3210
+ for e in result["remaining"]:
3211
+ detail = f" ({e['error']})" if e.get("error") else ""
3212
+ print(f" ✗ {e['key']} is STILL PRESENT after the delete{detail} — the teardown was "
3213
+ "not\n verified, whatever the call returned. Nothing was reported as "
3214
+ "cleared.", file=sys.stderr)
3215
+ for e in result["refused"]:
3216
+ print(f" · {e['key']} [{e['state']}] left alone — {e['why']}")
3217
+ if not args.keys:
3218
+ for e in result["left"]:
3219
+ print(f" · {e['key']} [{e['state']}] left alone — {e['why']}")
3220
+ if not result["reaped"] and not result["remaining"] and not result["refused"]:
3221
+ print(" nothing this run can prove it owns and has spent — nothing reaped")
3222
+ return 1 if result["remaining"] or result["refused"] else 0
3223
+
3224
+
2677
3225
  def cmd_journal(args: argparse.Namespace) -> int:
2678
3226
  return 0 if Sync().journal(" ".join(args.text)) else 1
2679
3227
 
@@ -3173,6 +3721,22 @@ def cmd_finish(args: argparse.Namespace) -> int:
3173
3721
  else:
3174
3722
  ok.append("no lease left held")
3175
3723
 
3724
+ # And the other half of that question, which for six versions nothing asked: what is
3725
+ # left on disk. `held()` answers `none` for a directory full of expired locks, so
3726
+ # "no lease left held" was printed beside a two-day-expired one. Proof of Done
3727
+ # records what remains — it does not license deleting all of it, so only what this
3728
+ # run can prove it owns is a problem to fix here.
3729
+ stale = s.stale()
3730
+ reapable = [e for e in stale if e["state"] == REAPABLE]
3731
+ left_alone = [e for e in stale if e["state"] != REAPABLE]
3732
+ if not stale:
3733
+ ok.append("no expired lock left behind")
3734
+ if reapable:
3735
+ problems.append(
3736
+ f"{len(reapable)} expired lock(s) this run owns are still on disk ("
3737
+ + ", ".join(e["key"] for e in reapable)
3738
+ + ") — clear them with `agent_sync.py reap`")
3739
+
3176
3740
  # 3. the declared gates, on request. They are the project's own commands and can be slow, so
3177
3741
  # running them is opt-in — but a `finish` that never ran them is a claim, not a check.
3178
3742
  if args.gates:
@@ -3193,6 +3757,11 @@ def cmd_finish(args: argparse.Namespace) -> int:
3193
3757
  print(f" \u2713 {line}")
3194
3758
  for line in problems:
3195
3759
  print(f" \u2717 {line}")
3760
+ if left_alone:
3761
+ print("\n Left alone — residue this run cannot prove it owns:")
3762
+ for e in left_alone:
3763
+ print(f" · {e['key']} [{e['state']}] {e['why']}")
3764
+ print(" Reported, not touched: another run's expired lock is that run's to explain.")
3196
3765
  print()
3197
3766
  if problems:
3198
3767
  print(f"{len(problems)} problem(s) — this work is not finished. The usual one is a "
@@ -3746,6 +4315,13 @@ def build_parser() -> argparse.ArgumentParser:
3746
4315
  sg.add_argument("state")
3747
4316
  sg.set_defaults(fn=cmd_signal)
3748
4317
 
4318
+ sub.add_parser("residue", help="expired locks left on disk, classified by who can "
4319
+ "prove they own them").set_defaults(fn=cmd_residue)
4320
+ rp = sub.add_parser("reap", help="clear expired locks this run provably owns; foreign "
4321
+ "and ambiguous ones are reported, never touched")
4322
+ rp.add_argument("keys", nargs="*", help="which to clear (default: every reapable one)")
4323
+ rp.set_defaults(fn=cmd_reap)
4324
+
3749
4325
  g = sub.add_parser("guard", help="may this run write that path? exit 2 = no")
3750
4326
  g.add_argument("path")
3751
4327
  g.set_defaults(fn=cmd_guard)