@ssheleg/agent-sync 1.13.0 → 1.14.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,44 @@
1
+ ## v1.14.0 — expiry ended a lease and left the file, and every reader folded that away
2
+
3
+ **`status` reported `leases held: none` over three expired locks in the directory it had
4
+ just read.** Not a bug in one function: *every* reader of lease state folded the TTL into
5
+ the read. `held()` keeps only what is this run's **and** alive; `_lease_holder()` returns
6
+ `None` for an expired lock; `all_holdings()` drops it. All three are correct for
7
+ exclusion — an expired lease is not held — and all three therefore give one answer for
8
+ *expired* and for *absent*. There was no fourth reader, so nothing could tell the two
9
+ apart, and `finish` printed `✓ no lease left held` beside a two-day-old corpse.
10
+
11
+ Measured across the family when the conformance audit went looking: **17 expired lock
12
+ files across 9 checkouts, the oldest 3 days 11 hours.**
13
+
14
+ ### The mechanism
15
+
16
+ `classify_lock()` — pure, all arguments in — reads every `.agent-sync/leases/*.lock` as
17
+ `live` · `reapable` · `foreign` · `ambiguous`. `status` and `finish` report residue
18
+ instead of silence. Two new verbs: `residue` (report only, safe in any checkout) and
19
+ `reap`, which clears **only** provably-own spent state and refuses foreign or ambiguous
20
+ state out loud, with exit 1, even when it is named on the command line.
21
+
22
+ `reapable` requires four things together, and anything short of all four is reported and
23
+ never deleted: the lease is spent with a parseable clock; it records a run; that run is
24
+ this one; and the run id means something. That last clause carries the rule — a shell
25
+ with no session id is served one **shared** identity, so a matching run id under it
26
+ proves nothing and does not license a delete. In doubt: `ambiguous`.
27
+
28
+ **Teardown is verified by re-reading the directory**, not by trusting the delete's return
29
+ value. Driven by hand: with the lease directory made read-only, `reap` exits 1 with
30
+ *"MINE is STILL PRESENT after the delete … the teardown was not verified, whatever the
31
+ call returned"* — and the lock is still there, which is the point.
32
+
33
+ ### Standing instruction 9
34
+
35
+ *A predicate cannot report the condition it folds into its answer.* That is the class,
36
+ and it is now in `docs/evidence/retro.md` rather than in this entry alone.
37
+
38
+ Self-test 38 → 43 fixtures, every one detected. Latency measured, not assumed: `status`
39
+ 0.39 s against 0.31 s before; `guard` unchanged at 0.15 s — residue is not on the guard
40
+ path.
41
+
1
42
  ## v1.13.0 — the file carried two notions of *held* and they disagreed where it mattered
2
43
 
3
44
  **A lease from a run that died could not be cleared by any command.** Measured in the field:
package/README.md CHANGED
@@ -248,6 +248,8 @@ python3 "$SKILL_DIR/scripts/agent_sync.py" <command>
248
248
  | `guard <path>` | May this run write that path? Exit 0 = yes, 2 = no |
249
249
  | `board` | Regenerate the read-only board and the mirror from git |
250
250
  | `whoami` | Print this run's id and its held leases |
251
+ | `residue` | Expired locks still on disk, classified — this run's spent ones, and the foreign or ambiguously owned ones it reports and leaves alone |
252
+ | `reap [KEY…]` | Clear only the locks this run can prove it owns and has spent, then re-read the directory to confirm the teardown |
251
253
  | `merge` | Land this branch on the integration branch: conflicts checked first, merge log written, lease released |
252
254
  | `merges` | What landed while you were on your branch |
253
255
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssheleg/agent-sync",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "Let concurrent coding agents share one project without colliding — leases with TTL, race-free id reservation, a run journal and a generated board, over a pluggable knowledge cloud.",
5
5
  "bin": {
6
6
  "agent-sync": "bin/agent-sync.js"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-sync",
3
3
  "displayName": "Agent Sync",
4
- "version": "1.13.0",
4
+ "version": "1.14.0",
5
5
  "description": "Coordination layer for multi-agent repositories — leases with TTL, race-free ID reservation, a run journal, a cross-repo signal feed and a generated board, over a pluggable knowledge cloud.",
6
6
  "author": {
7
7
  "name": "ssheleg",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Coordinate concurrent agents — initialise the shared knowledge store, check status, take a task, reserve an id, or regenerate the board.
3
- argument-hint: "[init|status|check|acquire <KEY>|release <KEY>|reserve <REG>|board|merge|finish]"
3
+ argument-hint: "[init|status|check|acquire <KEY>|release <KEY>|reserve <REG>|residue|board|merge|finish]"
4
4
  ---
5
5
 
6
6
  Invoke the `agent-sync` skill.
@@ -15,6 +15,9 @@ instance URL) or local files. Never guess that answer.
15
15
  If the project is already initialised, report status and name exactly one next
16
16
  action.
17
17
 
18
+ With `residue`, report the expired locks still on disk — what this run can prove it owns and
19
+ has spent, and the foreign or ambiguously owned ones it reports and leaves alone.
20
+
18
21
  With `finish`, run the end-of-work check instead: every repository clean, pushed and pointed at,
19
- and no lease left held. In a project of git submodules that is the one failure nobody sees — the
22
+ no lease left held, and what the run leaves behind named rather than assumed. In a project of git submodules that is the one failure nobody sees — the
20
23
  submodule is pushed and the parent still points at the commit before the work.
@@ -4,7 +4,7 @@ description: "Use when several coding agents work one repository at the same tim
4
4
  compatibility: "Requires the task-pipeline skill for its stages (npx sshlg-skills install). Needs python3 3.9+ (stdlib only, HTTP included - nothing to pip install) and bash for the hooks. The knowledge backend is configured per project; with none configured it degrades to git-file leases. Enforcement hooks are Claude Code only - on other agents the same checks run as a self-check."
5
5
  license: MIT
6
6
  metadata:
7
- version: "1.13.0"
7
+ version: "1.14.0"
8
8
  author: ssheleg
9
9
  ---
10
10
 
@@ -187,6 +187,8 @@ npx sshlg-skills install
187
187
  | `guard <path>` | Answer whether this run may write that path. Exit 0 = yes, 2 = no |
188
188
  | `board` | Regenerate the shared board and this repo's page. `--mirror` also renders the configured git docs into the plane |
189
189
  | `whoami` | Print this run's id and its held leases |
190
+ | `residue` | Expired locks still on disk: this run's spent ones, and the foreign or ambiguous ones it reports and never touches |
191
+ | `reap [KEY…]` | Clear only what this run **proves** it owns and has spent, then re-read the directory to confirm it went |
190
192
  | `setup` | Write the generated snapshot of how **this** project is wired, for agents to read |
191
193
  | `adopt` | Inspect an existing project and **propose** a config — writes nothing |
192
194
  | `merge` | Land this branch: local target fast-forwarded, conflicts checked **before** anything is touched, merge log written, the `--key` lease released. `--summary`, `--dry-run`, `--push` |
@@ -360,6 +362,7 @@ drift, not a formatting problem.
360
362
 
361
363
  - Append, read back, then act. Never rewrite a coordination document.
362
364
  - `release` what you `acquire`, on every path including failure.
365
+ - A run reports what it leaves behind: expiry ends a lease, not the file — `residue`.
363
366
  - Credentials never reach `argv`, a log line, or the repository.
364
367
  - Degrade out loud. `ungated` is an acceptable state; a false claim of enforcement is not.
365
368
  - Two agents in one checkout are two identities, or the lease is decoration.
@@ -9,6 +9,7 @@ when two agents disagree about who holds something.
9
9
  - [Acquiring — the third design, and the first that is true](#acquiring--the-third-design-and-the-first-that-is-true)
10
10
  - [Expiry and stealing](#expiry-and-stealing)
11
11
  - [Releasing](#releasing)
12
+ - [Residue — what expiry leaves behind](#residue--what-expiry-leaves-behind)
12
13
  - [Id reservation](#id-reservation)
13
14
  - [The lease is not the claim](#the-lease-is-not-the-claim)
14
15
 
@@ -143,6 +144,71 @@ and when.
143
144
  from active work until its TTL runs out, and during that window the task looks
144
145
  taken. Report the failure and release; do not hold the lease "in case".
145
146
 
147
+ ## Residue — what expiry leaves behind
148
+
149
+ **Expiry ends a lease. It does not remove a file.** Every reader of lease state in this
150
+ tool folds the TTL into the read — `held()`, `_lease_holder()` and `all_holdings()` each
151
+ answer *none* for an expired lock and *none* for a lock that is not there. That is exactly
152
+ right for exclusion, and it means an expired lock is not un-owned but **invisible**.
153
+
154
+ Measured across the nine repositories of one skill family on 2026-08-19: **seventeen lock
155
+ files, all seventeen expired**, the oldest by three days. `status` printed `leases held:
156
+ none` / `other runs: none holding anything` in a checkout holding three of them, and
157
+ `finish` printed `✓ no lease left held` beside a two-day-expired one. Nothing was wrong
158
+ with any of those answers. Nothing enumerated.
159
+
160
+ So `status` and `finish` report residue, `residue` prints it in full, and `reap` is the
161
+ only thing that removes any of it. The split between the two kinds is the mechanism:
162
+
163
+ | Verdict | What it means | What happens to it |
164
+ |---|---|---|
165
+ | `live` | still inside its TTL | held, not residue — untouched |
166
+ | `reapable` | this run PROVABLY owns it and has spent it | `reap` clears it |
167
+ | `foreign` | it demonstrably belongs to another run or another machine | reported, left alone |
168
+ | `ambiguous` | ownership or expiry cannot be established at all | reported, left alone |
169
+
170
+ **What makes ownership provable** — all four, or the verdict is not `reapable`:
171
+
172
+ 1. the lock is past its TTL (a live lease is held, not residue);
173
+ 2. it records a `run`, and that run is this one;
174
+ 3. **this run's identity is not the shared fallback.** `run_id()` keys its marker by
175
+ session; a shell with no session id is served one shared entry, and that identity is
176
+ shared with any other session in the same checkout. Under it a matching run id proves
177
+ nothing — so it does not license a delete, and every such lock is reported as
178
+ `ambiguous` instead;
179
+ 4. the lock's `repo` is this checkout and its `host` — written by the git mode, absent in
180
+ `local`, where the lease is machine-local by construction — is this machine.
181
+
182
+ In doubt the answer is `ambiguous`, never `reapable`. A classifier that resolves doubt by
183
+ deleting is worse than none, because it deletes under a claim of authority.
184
+
185
+ **Teardown is verified by re-reading the state, never by the delete's return value.**
186
+ `unlink` returns nothing and raises nothing on a filesystem where the entry survives the
187
+ call — a read-only mount, an NFS write that never lands, another process recreating the
188
+ name. `reap` therefore reads the lease directory again and compares `(run, ts)`: a lock
189
+ still there under the same identity is a **failure**, however cleanly the delete went, and
190
+ nothing is reported as cleared. A key that came back as another run's live lease *was*
191
+ torn down, and calling that a failure would teach an operator to ignore the one message
192
+ that matters.
193
+
194
+ `reap` never touches `foreign` or `ambiguous` state, including when it is named on the
195
+ command line: naming one is refused out loud with a non-zero exit, because an operator
196
+ reads a silent zero as done.
197
+
198
+ ### Why `release <KEY>` may reap a foreign corpse and `reap` may not
199
+
200
+ They look contradictory and they are not. `release` clears an expired lock in another
201
+ run's name — that is the remedy for the #4 incident, a lease measured at **604×** its
202
+ 2700-second TTL that no command could clear, and it is tested
203
+ (`test/claim_cell_test.py`). What makes it safe is the thing `reap` does not have: **a
204
+ person named that one key.** The reap is announced in the output, because the operator
205
+ asked to release *their* lease and is getting somebody else's corpse cleared as well.
206
+
207
+ `reap` is a sweep nobody named key by key. A sweep that deletes what it cannot prove it
208
+ owns is how one run destroys another's only record of work in progress — an agent that
209
+ stopped renewing may still be running. So the sweep refuses, and the two verbs keep two
210
+ contracts on purpose. Do not "align" them by widening `reap`.
211
+
146
212
  ## Id reservation
147
213
 
148
214
  Reading a "next free id" line from a file is not reserving it. Allocation is
@@ -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.14.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,129 @@ 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
+
851
+ def classify_lock(key: str, raw: str, *, rid: str, identity_is_strong: bool,
852
+ repo: str, host: str, default_ttl: int, at: float) -> dict[str, Any]:
853
+ """One lock file, read as a live lease or as one of three kinds of residue.
854
+
855
+ Pure — everything it needs is an argument — so each verdict below is a fixture rather
856
+ than a scenario somebody has to reproduce with two sessions and a clock.
857
+
858
+ **Why this function has to exist.** Every reader of lease state in this tool folds the
859
+ TTL into the read: `held()`, `_lease_holder()` and `all_holdings()` each answer *none*
860
+ for an expired lock and *none* for a lock that is not there. That is correct for
861
+ exclusion and it is why seventeen expired locks across nine repositories of one family
862
+ were reported by nothing — `status` printed `leases held: none` and `finish` printed
863
+ "no lease left held" standing on top of them. Expiry ends a lease. It does not remove
864
+ a file, and nothing here could see the difference.
865
+
866
+ **The split is the load-bearing part.** `reapable` means state this run can PROVE it
867
+ owns and has spent, and the proof is deliberately narrow:
868
+
869
+ - the lock is past its TTL — a live lease is held, not residue;
870
+ - it records a `run`, and that run is this one;
871
+ - **this run's identity is not the shared fallback.** `run_id()` keys its marker by
872
+ session, and a shell with no session id is served one shared entry whose own
873
+ docstring says the identity "is shared with any other session in this checkout".
874
+ Under that key a matching run id proves nothing, so it does not license a delete.
875
+ - its `repo` is this checkout, and its `host` — written by the git lease mode, absent
876
+ in local mode, where the lease is machine-local by construction — is this machine.
877
+
878
+ Everything else is reported and left alone: `foreign` where it demonstrably belongs to
879
+ somebody else, `ambiguous` where the question cannot be answered at all. In doubt the
880
+ answer is `ambiguous`, never `reapable`.
881
+ """
882
+ out: dict[str, Any] = {"key": key, "state": AMBIGUOUS, "run": None, "repo": None,
883
+ "host": None, "ts": "", "expired_for": None, "why": ""}
884
+ try:
885
+ held = json.loads(raw)
886
+ except (json.JSONDecodeError, ValueError):
887
+ out["why"] = "the lock is not readable JSON, so nothing in it identifies an owner"
888
+ return out
889
+ if not isinstance(held, dict):
890
+ out["why"] = "the lock is not an object, so nothing in it identifies an owner"
891
+ return out
892
+
893
+ out["run"] = held.get("run") or None
894
+ out["repo"] = held.get("repo") or None
895
+ out["host"] = held.get("host") or None
896
+ out["ts"] = str(held.get("ts") or "")
897
+
898
+ taken = parse_iso_or_none(out["ts"])
899
+ if taken is None:
900
+ out["why"] = (f"its timestamp ({out['ts'] or 'absent'}) is not one, so whether this "
901
+ "lock is spent cannot be established")
902
+ return out
903
+ try:
904
+ ttl = int(held.get("ttl", default_ttl))
905
+ except (TypeError, ValueError):
906
+ out["why"] = (f"its ttl ({held.get('ttl')!r}) is not a number, so whether this lock "
907
+ "is spent cannot be established")
908
+ return out
909
+
910
+ expires = taken + ttl
911
+ if at <= expires:
912
+ out["state"] = LIVE
913
+ out["why"] = (f"held by {out['run'] or 'an unnamed run'} for another "
914
+ f"{int(expires - at)}s")
915
+ return out
916
+ out["expired_for"] = int(at - expires)
917
+
918
+ if not out["run"]:
919
+ out["why"] = "it records no run, so there is nobody it can be proved to belong to"
920
+ return out
921
+ if out["host"] and out["host"] != host:
922
+ out["state"] = FOREIGN
923
+ out["why"] = f"it was taken on {out['host']}, which is not this machine"
924
+ return out
925
+ if out["run"] != rid:
926
+ out["state"] = FOREIGN
927
+ out["why"] = f"it belongs to run {out['run']}, not to this one"
928
+ return out
929
+ if out["repo"] and out["repo"] != repo:
930
+ out["why"] = (f"its run id matches, but it names repository {out['repo']} while this "
931
+ f"checkout is {repo}")
932
+ return out
933
+ if not identity_is_strong:
934
+ out["why"] = ("its run id matches, but this run's identity is the shared fallback — "
935
+ "any other session in this checkout answers to the same id, so the "
936
+ "match proves nothing")
937
+ return out
938
+ out["state"] = REAPABLE
939
+ out["why"] = "this run took it and let it expire"
940
+ return out
941
+
942
+
943
+ def since(seconds: int) -> str:
944
+ """A duration an operator can act on."""
945
+ if seconds < 90:
946
+ return f"{seconds}s"
947
+ if seconds < 5400:
948
+ return f"{seconds // 60}m"
949
+ if seconds < 172800:
950
+ return f"{seconds // 3600}h"
951
+ return f"{seconds // 86400}d {(seconds % 86400) // 3600}h"
952
+
953
+
954
+ def spent(entry: dict[str, Any]) -> str:
955
+ """How long this lock has been residue — said once, so no surface can phrase it
956
+ differently. A lock whose clock cannot be read is not "expired an unknown time ago";
957
+ it is a lock nobody can say is spent, which is why it is never reaped."""
958
+ if entry["state"] == LIVE:
959
+ return "live"
960
+ if entry.get("expired_for") is None:
961
+ return "expiry could not be established"
962
+ return f"expired {since(entry['expired_for'])} ago"
963
+
964
+
830
965
  def resolve_reservations(events: list[dict[str, str]], reg: str) -> tuple[int, list[int], list[tuple[str, int]]]:
831
966
  """Positional allocation over the log. Returns (base, free_list, assignments)."""
832
967
  base = None
@@ -882,6 +1017,7 @@ class Sync:
882
1017
  self.adapter = make_adapter(self.cfg, self.root)
883
1018
  self.rid = run_id(self.root)
884
1019
  self.ttl = int(self.cfg.get("leaseTtlSeconds") or DEFAULT_TTL)
1020
+ self._identity: tuple[str, str] | None = None
885
1021
 
886
1022
  @property
887
1023
  def gated(self) -> bool:
@@ -1356,6 +1492,107 @@ class Sync:
1356
1492
  print(f"note: released locally, not published ({exc})", file=sys.stderr)
1357
1493
  return True
1358
1494
 
1495
+ @property
1496
+ def identity(self) -> tuple[str, str]:
1497
+ """(key, how) for this run's identity — resolved once, because it walks `ps`."""
1498
+ if self._identity is None:
1499
+ self._identity = _session_key()
1500
+ return self._identity
1501
+
1502
+ @property
1503
+ def identity_is_strong(self) -> bool:
1504
+ """Whether a matching run id is proof of anything.
1505
+
1506
+ False means `_session_key()` established nothing and `run_id()` served the shared
1507
+ entry — the identity every other session in this checkout is also given. Enough to
1508
+ coordinate with, and deliberately not enough to delete on.
1509
+ """
1510
+ return bool(self.identity[0])
1511
+
1512
+ def residue(self) -> list[dict[str, Any]]:
1513
+ """Every lock file in this checkout, classified — the live ones included.
1514
+
1515
+ The enumerating read the tool never had. `held()` below globs the same directory
1516
+ and keeps only what is BOTH this run's and alive, which makes it a liveness
1517
+ predicate: it cannot tell an empty directory from one full of corpses, and no
1518
+ other reader here can either.
1519
+ """
1520
+ d = self.root / STATE_DIR / "leases"
1521
+ now, host, repo = time.time(), platform.node(), repo_name()
1522
+ out: list[dict[str, Any]] = []
1523
+ for p in sorted(d.glob("*.lock") if d.exists() else []):
1524
+ try:
1525
+ raw = p.read_text()
1526
+ except OSError as exc:
1527
+ entry: dict[str, Any] = {
1528
+ "key": p.stem, "state": AMBIGUOUS, "run": None, "repo": None,
1529
+ "host": None, "ts": "", "expired_for": None,
1530
+ "why": f"the lock cannot be read ({exc})"}
1531
+ else:
1532
+ entry = classify_lock(p.stem, raw, rid=self.rid,
1533
+ identity_is_strong=self.identity_is_strong,
1534
+ repo=repo, host=host, default_ttl=self.ttl, at=now)
1535
+ entry["path"] = p
1536
+ out.append(entry)
1537
+ return out
1538
+
1539
+ def stale(self) -> list[dict[str, Any]]:
1540
+ """The residue only — every lock whose lease has already ended."""
1541
+ return [e for e in self.residue() if e["state"] != LIVE]
1542
+
1543
+ def reap(self, keys: list[str] | None = None) -> dict[str, list[dict[str, Any]]]:
1544
+ """Clear only `reapable` residue, and prove it went by LOOKING AGAIN.
1545
+
1546
+ The second observation is the whole point. `unlink` returns nothing and raises
1547
+ nothing on a filesystem where the entry survives the call — a read-only mount, an
1548
+ NFS write that never lands, a directory whose write bit was dropped between two
1549
+ commands, another process recreating the name — so a teardown that reports success
1550
+ out of its own return value is reporting the wish rather than the state. What comes
1551
+ back here is the difference between two reads of the directory.
1552
+
1553
+ Identity decides the second read, not absence: a key that came back as another
1554
+ run's live lease WAS torn down, and calling that a failure would teach an operator
1555
+ to ignore the one message that matters.
1556
+ """
1557
+ before = self.residue()
1558
+ named = None
1559
+ if keys:
1560
+ named = set()
1561
+ for k in keys:
1562
+ named.add(k)
1563
+ named.add(self._local_lock(k).stem)
1564
+ wanted = [e for e in before
1565
+ if e["state"] == REAPABLE and (named is None or e["key"] in named)]
1566
+ refused = [e for e in before
1567
+ if e["state"] != REAPABLE and named is not None and e["key"] in named]
1568
+ if named is not None:
1569
+ known = {e["key"] for e in before}
1570
+ for k in sorted(named - known):
1571
+ if self._local_lock(k).stem in known:
1572
+ continue
1573
+ refused.append({"key": k, "state": "absent", "run": None, "ts": "",
1574
+ "expired_for": None,
1575
+ "why": "there is no lock by that name in this checkout"})
1576
+
1577
+ for e in wanted:
1578
+ try:
1579
+ e["path"].unlink()
1580
+ except FileNotFoundError:
1581
+ pass
1582
+ except OSError as exc:
1583
+ e["error"] = str(exc)
1584
+
1585
+ after = {e["key"]: e for e in self.residue()}
1586
+ reaped, remaining = [], []
1587
+ for e in wanted:
1588
+ still = after.get(e["key"])
1589
+ if still is not None and still["run"] == e["run"] and still["ts"] == e["ts"]:
1590
+ remaining.append(e)
1591
+ else:
1592
+ reaped.append(e)
1593
+ return {"reaped": reaped, "remaining": remaining, "refused": refused,
1594
+ "left": [e for e in before if e["state"] in (FOREIGN, AMBIGUOUS)]}
1595
+
1359
1596
  def held(self) -> list[str]:
1360
1597
  d = self.root / STATE_DIR / "leases"
1361
1598
  mine = []
@@ -2193,6 +2430,8 @@ class Sync:
2193
2430
  "| What was actually built, with its commit | as-built log | permanent, append-only |",
2194
2431
  "| Cross-repo dependency state | signal log | permanent, append-only |",
2195
2432
  "| Who holds a task right now | claims log | expires by TTL |",
2433
+ "| A lock left by a run that stopped | the lease directory | until it is "
2434
+ "reported and reaped |",
2196
2435
  "| Per-run narrative | that run's journal | permanent |",
2197
2436
  "| The board and these pages | generated | replaced on every regeneration |",
2198
2437
  "",
@@ -2224,6 +2463,10 @@ class Sync:
2224
2463
  "merge --key → land the branch: conflicts checked first, the merge recorded,",
2225
2464
  " that lease released. Without a branch, `release ID` by hand",
2226
2465
  " — on every path, including failure",
2466
+ "residue → what this run leaves on disk. Expiry ends a lease and leaves",
2467
+ " the file, so `status` and `finish` enumerate them; `reap`",
2468
+ " clears only what THIS run can prove it owns and has spent,",
2469
+ " and reports foreign or ambiguously owned locks untouched",
2227
2470
  "```",
2228
2471
  "",
2229
2472
  f"This project's integration branch is `{self.integration_branch}`.",
@@ -2509,6 +2752,29 @@ def cmd_status(_args: argparse.Namespace) -> int:
2509
2752
  return 1
2510
2753
  print(f" leases held : {', '.join(held) if held else 'none'}")
2511
2754
 
2755
+ # A run produces more than a diff, and what it leaves behind has to be reported by the
2756
+ # command every session runs. Until this line existed, `status` printed `leases held:
2757
+ # none` beside three expired locks in the directory it had just read — every reader of
2758
+ # lease state here applies the TTL, so "expired" and "absent" were one answer.
2759
+ stale = s.stale()
2760
+ reapable = [e for e in stale if e["state"] == REAPABLE]
2761
+ left_alone = [e for e in stale if e["state"] != REAPABLE]
2762
+ if not stale:
2763
+ print(" expired locks : none")
2764
+ else:
2765
+ print(f" expired locks : {len(stale)} — {len(reapable)} this run's to clear, "
2766
+ f"{len(left_alone)} left alone")
2767
+ print("\n Expired leases still on disk. Nobody holds these: the TTL ended the "
2768
+ "lease\n and left the file.")
2769
+ for e in stale[:6]:
2770
+ print(f" · {e['key']} [{e['state']}] {e['why']} ({spent(e)})")
2771
+ if len(stale) > 6:
2772
+ print(f" · … and {len(stale) - 6} more — agent_sync.py residue")
2773
+ if reapable:
2774
+ print(" Clear what this run owns: agent_sync.py reap")
2775
+ if left_alone:
2776
+ print(" The rest are foreign or ambiguously owned — reported, not touched.")
2777
+
2512
2778
  # Who else is in here, and what landed while this run was away. Without this a
2513
2779
  # lease only tells an agent it is blocked, never who by or on what.
2514
2780
  plane_broken = False
@@ -2674,6 +2940,64 @@ def cmd_release_id(args: argparse.Namespace) -> int:
2674
2940
  return 0
2675
2941
 
2676
2942
 
2943
+ def cmd_residue(_args: argparse.Namespace) -> int:
2944
+ """What this run leaves behind, classified — and never quietly cleared.
2945
+
2946
+ Reporting and clearing are two commands on purpose. This one is safe to run anywhere,
2947
+ including in somebody else's checkout, because it cannot remove anything.
2948
+ """
2949
+ s = Sync()
2950
+ entries = s.residue()
2951
+ print(f"run {s.rid} · identity from {s.identity[1]}")
2952
+ print(f"lease mode {s.lease_mode} · ttl {s.ttl}s · {len(entries)} lock file(s) in "
2953
+ f"{STATE_DIR}/leases\n")
2954
+ if not entries:
2955
+ print(" nothing on disk — no lease has been taken in this checkout, or every one "
2956
+ "was released")
2957
+ return 0
2958
+ for e in entries:
2959
+ print(f" {e['key']}")
2960
+ print(f" state : {e['state']}"
2961
+ + ("" if e["state"] == LIVE else f" ({spent(e)})"))
2962
+ print(f" run : {e['run'] or '—'} · repo {e['repo'] or '—'}"
2963
+ f"{' · host ' + e['host'] if e['host'] else ''}")
2964
+ print(f" why : {e['why']}")
2965
+ reapable = [e for e in entries if e["state"] == REAPABLE]
2966
+ other = [e for e in entries if e["state"] in (FOREIGN, AMBIGUOUS)]
2967
+ print()
2968
+ if reapable:
2969
+ print(f" {len(reapable)} reapable — this run's own, spent: agent_sync.py reap")
2970
+ if other:
2971
+ print(f" {len(other)} foreign or ambiguous — reported, never cleared from here. "
2972
+ "An expired\n lock in another run's name is that run's to explain, and a "
2973
+ "lock whose owner\n cannot be established is nobody's to delete.")
2974
+ if not reapable and not other:
2975
+ print(" no residue — every lock on disk is a live lease")
2976
+ return 0
2977
+
2978
+
2979
+ def cmd_reap(args: argparse.Namespace) -> int:
2980
+ """Clear this run's spent locks, and verify the teardown by reading the state again."""
2981
+ s = Sync()
2982
+ result = s.reap(args.keys or None)
2983
+ for e in result["reaped"]:
2984
+ print(f" reaped {e['key']} — {e['why']}, confirmed gone by re-reading "
2985
+ f"{STATE_DIR}/leases")
2986
+ for e in result["remaining"]:
2987
+ detail = f" ({e['error']})" if e.get("error") else ""
2988
+ print(f" ✗ {e['key']} is STILL PRESENT after the delete{detail} — the teardown was "
2989
+ "not\n verified, whatever the call returned. Nothing was reported as "
2990
+ "cleared.", file=sys.stderr)
2991
+ for e in result["refused"]:
2992
+ print(f" · {e['key']} [{e['state']}] left alone — {e['why']}")
2993
+ if not args.keys:
2994
+ for e in result["left"]:
2995
+ print(f" · {e['key']} [{e['state']}] left alone — {e['why']}")
2996
+ if not result["reaped"] and not result["remaining"] and not result["refused"]:
2997
+ print(" nothing this run can prove it owns and has spent — nothing reaped")
2998
+ return 1 if result["remaining"] or result["refused"] else 0
2999
+
3000
+
2677
3001
  def cmd_journal(args: argparse.Namespace) -> int:
2678
3002
  return 0 if Sync().journal(" ".join(args.text)) else 1
2679
3003
 
@@ -3173,6 +3497,22 @@ def cmd_finish(args: argparse.Namespace) -> int:
3173
3497
  else:
3174
3498
  ok.append("no lease left held")
3175
3499
 
3500
+ # And the other half of that question, which for six versions nothing asked: what is
3501
+ # left on disk. `held()` answers `none` for a directory full of expired locks, so
3502
+ # "no lease left held" was printed beside a two-day-expired one. Proof of Done
3503
+ # records what remains — it does not license deleting all of it, so only what this
3504
+ # run can prove it owns is a problem to fix here.
3505
+ stale = s.stale()
3506
+ reapable = [e for e in stale if e["state"] == REAPABLE]
3507
+ left_alone = [e for e in stale if e["state"] != REAPABLE]
3508
+ if not stale:
3509
+ ok.append("no expired lock left behind")
3510
+ if reapable:
3511
+ problems.append(
3512
+ f"{len(reapable)} expired lock(s) this run owns are still on disk ("
3513
+ + ", ".join(e["key"] for e in reapable)
3514
+ + ") — clear them with `agent_sync.py reap`")
3515
+
3176
3516
  # 3. the declared gates, on request. They are the project's own commands and can be slow, so
3177
3517
  # running them is opt-in — but a `finish` that never ran them is a claim, not a check.
3178
3518
  if args.gates:
@@ -3193,6 +3533,11 @@ def cmd_finish(args: argparse.Namespace) -> int:
3193
3533
  print(f" \u2713 {line}")
3194
3534
  for line in problems:
3195
3535
  print(f" \u2717 {line}")
3536
+ if left_alone:
3537
+ print("\n Left alone — residue this run cannot prove it owns:")
3538
+ for e in left_alone:
3539
+ print(f" · {e['key']} [{e['state']}] {e['why']}")
3540
+ print(" Reported, not touched: another run's expired lock is that run's to explain.")
3196
3541
  print()
3197
3542
  if problems:
3198
3543
  print(f"{len(problems)} problem(s) — this work is not finished. The usual one is a "
@@ -3746,6 +4091,13 @@ def build_parser() -> argparse.ArgumentParser:
3746
4091
  sg.add_argument("state")
3747
4092
  sg.set_defaults(fn=cmd_signal)
3748
4093
 
4094
+ sub.add_parser("residue", help="expired locks left on disk, classified by who can "
4095
+ "prove they own them").set_defaults(fn=cmd_residue)
4096
+ rp = sub.add_parser("reap", help="clear expired locks this run provably owns; foreign "
4097
+ "and ambiguous ones are reported, never touched")
4098
+ rp.add_argument("keys", nargs="*", help="which to clear (default: every reapable one)")
4099
+ rp.set_defaults(fn=cmd_reap)
4100
+
3749
4101
  g = sub.add_parser("guard", help="may this run write that path? exit 2 = no")
3750
4102
  g.add_argument("path")
3751
4103
  g.set_defaults(fn=cmd_guard)