@ssheleg/agent-sync 1.15.0 → 1.16.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 +50 -0
- package/package.json +1 -1
- package/plugins/agent-sync/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +1 -1
- package/plugins/agent-sync/skills/agent-sync/references/lease-protocol.md +37 -0
- package/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +245 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
+
## Unreleased — both `AS-01` halves exercised outside their fixtures
|
|
2
|
+
|
|
3
|
+
The two rows sat at priority `unverified`: shipped, and confirmed by nothing but their own
|
|
4
|
+
fixtures. Both are now exercised against real state, and both hold. **Nothing in the tool
|
|
5
|
+
changed** — this records that the mechanism was watched working on something other than its
|
|
6
|
+
own test data, which is what `unverified` meant.
|
|
7
|
+
|
|
8
|
+
**The git plane, on a real remote.** A git-mode checkout was built against a local bare
|
|
9
|
+
remote, a lease taken at `ttl 2`, and its local note **deleted** — exactly the state a ref
|
|
10
|
+
won on another machine leaves behind, and the state the row said `residue` could not see.
|
|
11
|
+
`residue` printed *nothing in the lock directory* and then enumerated the git plane:
|
|
12
|
+
`refs/agent-sync/leases/DEMO-KEY @ 495370743e`, run `r-rverifya`, **expired 18s ago**,
|
|
13
|
+
`foreign`, closing with *1 ref(s) on the remote, 0 this run can prove it owns and has spent*.
|
|
14
|
+
|
|
15
|
+
**Reap, in both directions.** Reaping as a **different** run left the ref standing and said so
|
|
16
|
+
by name. Reaping as the run that took it removed it and reported *confirmed gone by re-reading
|
|
17
|
+
the remote* — the proof coming from `ls-remote`, not from a push's exit code.
|
|
18
|
+
|
|
19
|
+
**And on live residue.** `~/DATA/0xDEV` carries a genuine foreign expired lock —
|
|
20
|
+
`BLOG-SITEMAP`, run `r-blog-1429e`, **expired 4d 22h ago**. `reap` named it, said *left alone
|
|
21
|
+
— it belongs to run r-blog-1429e, not to this one*, and **deleted nothing**: both lock files
|
|
22
|
+
byte-identical afterwards. State a run cannot prove is its own is reported and untouched.
|
|
23
|
+
|
|
24
|
+
## v1.16.0 — the release that closes one version string over two trees
|
|
25
|
+
|
|
26
|
+
**281 lines of shipped behaviour had been sitting behind the tag.** `AS-01a` (the
|
|
27
|
+
git plane is swept, not only disclosed) and `AS-01b` (an ambiguous lock is
|
|
28
|
+
clearable by a person, per key, attributably) landed on `main`, were pinned by the
|
|
29
|
+
umbrella, and were never tagged. So three channels served `1.15.0` and two
|
|
30
|
+
different trees:
|
|
31
|
+
|
|
32
|
+
| channel | source | `agent_sync.py` |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| npm `@ssheleg/agent-sync@1.15.0` | the tag | 4344 lines |
|
|
35
|
+
| the plugin marketplace | the branch tip | 4575 lines |
|
|
36
|
+
| the skills CLI | the branch tip | 4575 lines |
|
|
37
|
+
|
|
38
|
+
All three reported `1.15.0`. `check_pins.py` was green throughout — correctly,
|
|
39
|
+
because it compares the version STRING, and the string did match. A version that
|
|
40
|
+
identifies two artefacts cannot be reasoned about, and the family's own invariant
|
|
41
|
+
("the pin is the promise") was satisfied to the letter while being false in
|
|
42
|
+
substance.
|
|
43
|
+
|
|
44
|
+
Found on 2026-08-23 by fetching the npm tarball and counting lines in all three
|
|
45
|
+
channels rather than trusting any of them. This release makes the number true.
|
|
46
|
+
|
|
47
|
+
Also in this range: the release workflow refuses a tag whose commit no clone can
|
|
48
|
+
reach, and the evidence ledger records both `AS-01` halves exercised on a real
|
|
49
|
+
remote rather than in a fixture.
|
|
50
|
+
|
|
1
51
|
## v1.15.0 — a claim tag outlived its lease, and no command reached it
|
|
2
52
|
|
|
3
53
|
**GitHub issue #5, filed 2026-08-17, reproduced verbatim at v1.14.0.** A board row shipped
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssheleg/agent-sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.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.
|
|
4
|
+
"version": "1.16.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",
|
|
@@ -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.
|
|
7
|
+
version: "1.16.0"
|
|
8
8
|
author: ssheleg
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -112,6 +112,21 @@ renewed in between.
|
|
|
112
112
|
|
|
113
113
|
The tool reports which guarantee is in force; it never implies the stronger one.
|
|
114
114
|
|
|
115
|
+
**Residue in this mode is on the remote, and the enumerating read goes there.** The local
|
|
116
|
+
directory holds a *note* — `_note_local` writes one only for the run that won the key on
|
|
117
|
+
this machine — so a ref won elsewhere leaves nothing to walk, and for a while `residue`
|
|
118
|
+
could print `nothing on disk` over an expired lease sitting on the remote. That is the one
|
|
119
|
+
shape a residue report must never take. `residue` and `reap` now sweep
|
|
120
|
+
`refs/agent-sync/leases/*` on the configured remote and classify each ref with **the same
|
|
121
|
+
classifier the local plane uses**, because whose lease this is must have one definition.
|
|
122
|
+
`reap` deletes with the same `--force-with-lease=<ref>:<sha>` compare-and-swap `release`
|
|
123
|
+
uses, and proves the ref went by re-reading `ls-remote` — a push's exit code is the wish,
|
|
124
|
+
the second read is the state.
|
|
125
|
+
|
|
126
|
+
**A remote that cannot be reached is `COULD NOT LOOK`, never an empty sweep.** The two read
|
|
127
|
+
identically to an operator and mean opposite things, so they are printed differently and
|
|
128
|
+
`reap` exits non-zero on the first.
|
|
129
|
+
|
|
115
130
|
## Expiry and stealing
|
|
116
131
|
|
|
117
132
|
A lock is expired when `now > ts + ttl` for the timestamp inside it.
|
|
@@ -182,6 +197,28 @@ only thing that removes any of it. The split between the two kinds is the mechan
|
|
|
182
197
|
In doubt the answer is `ambiguous`, never `reapable`. A classifier that resolves doubt by
|
|
183
198
|
deleting is worse than none, because it deletes under a claim of authority.
|
|
184
199
|
|
|
200
|
+
**And that has a consequence the classifier cannot fix: nobody could ever clear an
|
|
201
|
+
`ambiguous` lock.** A shell with no session id answers to the shared identity, so a
|
|
202
|
+
matching run id proves nothing, so the lock is `ambiguous`, so no run reaps it — for ever.
|
|
203
|
+
28 of them had accumulated on this machine by 2026-08-20, the oldest overdue by 21 days
|
|
204
|
+
against a 2700-second TTL, every one of them correctly refused.
|
|
205
|
+
|
|
206
|
+
`reap --i-own-this <key>` is the way out, and it is a **person's** decision rather than a
|
|
207
|
+
run's proof. M-50 forbids a RUN from deleting what it cannot establish; it does not forbid
|
|
208
|
+
an operator from deciding. Three things keep it a decision instead of a sweep:
|
|
209
|
+
|
|
210
|
+
* **named keys, and it refuses to run without them** — a blanket override is the sweep the
|
|
211
|
+
classifier exists to refuse, wearing a flag. Use `--` before a key that starts with a
|
|
212
|
+
dash: the tool slugifies guarded-file paths into keys like
|
|
213
|
+
`-claude-plugin-marketplace-json`, and a key the tool writes must be a key the tool can
|
|
214
|
+
address;
|
|
215
|
+
* **it refuses a LIVE lease** — residue is what it clears, and a live lease belongs to a run
|
|
216
|
+
that may still be working;
|
|
217
|
+
* **it prints the payload it destroyed** — run, timestamp, machine, how long expired — and
|
|
218
|
+
journals the decision where a record plane is configured. The classifier's verdict is
|
|
219
|
+
printed unchanged beside it, because the override does not make the lock provably yours;
|
|
220
|
+
it makes the deletion attributable.
|
|
221
|
+
|
|
185
222
|
**Teardown is verified by re-reading the state, never by the delete's return value.**
|
|
186
223
|
`unlink` returns nothing and raises nothing on a filesystem where the entry survives the
|
|
187
224
|
call — a read-only mount, an NFS write that never lands, another process recreating the
|
|
@@ -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.
|
|
36
|
+
VERSION = "1.16.0"
|
|
37
37
|
|
|
38
38
|
CONFIG_PATH = Path(".claude/agent-sync.json")
|
|
39
39
|
ENV_FILE = Path(".env.agent-sync")
|
|
@@ -1544,6 +1544,102 @@ class Sync:
|
|
|
1544
1544
|
"""
|
|
1545
1545
|
return bool(self.identity[0])
|
|
1546
1546
|
|
|
1547
|
+
# -- the git plane's own enumerating read (AS-01a) -------------------------
|
|
1548
|
+
|
|
1549
|
+
def _git_lease_refs(self) -> tuple[list[tuple[str, str]], str | None]:
|
|
1550
|
+
"""Every lease ref on the remote as (sha, key), or the reason it could not look.
|
|
1551
|
+
|
|
1552
|
+
The authority in git mode is `refs/agent-sync/leases/*` on the remote, and a ref
|
|
1553
|
+
won on another machine leaves NO local note — `_note_local` only fires for the run
|
|
1554
|
+
that won it here. So the local directory walk cannot see it, and for a while
|
|
1555
|
+
`residue` could print `nothing on disk` over an expired lease sitting on the
|
|
1556
|
+
remote. That is the one shape a residue report must never take.
|
|
1557
|
+
|
|
1558
|
+
The failure is returned rather than raised, because the caller has to be able to
|
|
1559
|
+
say `could not look` instead of `nothing there`.
|
|
1560
|
+
"""
|
|
1561
|
+
remote = self._git_remote()
|
|
1562
|
+
r = subprocess.run(["git", "ls-remote", remote, "refs/agent-sync/leases/*"],
|
|
1563
|
+
capture_output=True, text=True)
|
|
1564
|
+
if r.returncode != 0:
|
|
1565
|
+
why = (r.stderr or r.stdout or "").strip().splitlines()
|
|
1566
|
+
return [], (why[-1] if why else f"`git ls-remote {remote}` failed")
|
|
1567
|
+
out = []
|
|
1568
|
+
for line in r.stdout.strip().splitlines():
|
|
1569
|
+
parts = line.split()
|
|
1570
|
+
if len(parts) != 2 or not parts[1].startswith("refs/agent-sync/leases/"):
|
|
1571
|
+
continue
|
|
1572
|
+
out.append((parts[0], parts[1].rsplit("/", 1)[-1]))
|
|
1573
|
+
return out, None
|
|
1574
|
+
|
|
1575
|
+
def _git_lease_payload(self, sha: str) -> str:
|
|
1576
|
+
"""The lease object's body — the same JSON the local note carries.
|
|
1577
|
+
|
|
1578
|
+
One shape for both planes, so `classify_lock` is the only classifier in this file.
|
|
1579
|
+
A second one would be a second definition of *whose lease this is*, which is the
|
|
1580
|
+
question the whole tool exists to answer once.
|
|
1581
|
+
"""
|
|
1582
|
+
body = git("log", "-1", "--format=%B", sha)
|
|
1583
|
+
if body:
|
|
1584
|
+
return body.strip()
|
|
1585
|
+
# The object may not be local yet: the ref lives on the remote.
|
|
1586
|
+
subprocess.run(["git", "fetch", "-q", self._git_remote(), sha],
|
|
1587
|
+
capture_output=True, text=True)
|
|
1588
|
+
return (git("log", "-1", "--format=%B", sha) or "").strip()
|
|
1589
|
+
|
|
1590
|
+
def git_residue(self) -> tuple[list[dict[str, Any]], str | None]:
|
|
1591
|
+
"""The git plane, classified by the same rules as the local one."""
|
|
1592
|
+
refs, why = self._git_lease_refs()
|
|
1593
|
+
if why is not None:
|
|
1594
|
+
return [], why
|
|
1595
|
+
now, host, repo = time.time(), platform.node(), repo_name()
|
|
1596
|
+
out: list[dict[str, Any]] = []
|
|
1597
|
+
for sha, key in sorted(refs, key=lambda x: x[1]):
|
|
1598
|
+
raw = self._git_lease_payload(sha)
|
|
1599
|
+
if raw:
|
|
1600
|
+
entry = classify_lock(key, raw, rid=self.rid,
|
|
1601
|
+
identity_is_strong=self.identity_is_strong,
|
|
1602
|
+
repo=repo, host=host, default_ttl=self.ttl, at=now)
|
|
1603
|
+
else:
|
|
1604
|
+
entry = {"key": key, "state": AMBIGUOUS, "run": None, "repo": None,
|
|
1605
|
+
"host": None, "ts": "", "expired_for": None,
|
|
1606
|
+
"why": "the lease object carries no readable payload"}
|
|
1607
|
+
entry["plane"] = "git"
|
|
1608
|
+
entry["ref"] = self._ref(key)
|
|
1609
|
+
entry["sha"] = sha
|
|
1610
|
+
entry["path"] = None
|
|
1611
|
+
out.append(entry)
|
|
1612
|
+
return out, None
|
|
1613
|
+
|
|
1614
|
+
def git_reap(self, entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
1615
|
+
"""Delete a reapable lease ref, and prove it went by looking again.
|
|
1616
|
+
|
|
1617
|
+
Same compare-and-swap `_git_release` uses: `--force-with-lease=<ref>:<sha>` refuses
|
|
1618
|
+
if somebody won the key between the read and the delete, which is the whole reason
|
|
1619
|
+
the sha travels with the entry. And the verdict comes from a second `ls-remote`,
|
|
1620
|
+
not from the push's exit code — a teardown that reports success out of its own
|
|
1621
|
+
return value is reporting the wish rather than the state.
|
|
1622
|
+
"""
|
|
1623
|
+
done: list[dict[str, Any]] = []
|
|
1624
|
+
for e in entries:
|
|
1625
|
+
r = subprocess.run(["git", "push", self._git_remote(),
|
|
1626
|
+
f"--force-with-lease={e['ref']}:{e['sha']}",
|
|
1627
|
+
f":{e['ref']}"], capture_output=True, text=True)
|
|
1628
|
+
after, why = self._git_lease_refs()
|
|
1629
|
+
still = {k for _s, k in after}
|
|
1630
|
+
if why is not None:
|
|
1631
|
+
e["gone"] = None
|
|
1632
|
+
e["why_gone"] = f"could not re-read the remote to prove it went ({why})"
|
|
1633
|
+
elif e["key"] in still:
|
|
1634
|
+
e["gone"] = False
|
|
1635
|
+
e["why_gone"] = ((r.stderr or "").strip().splitlines() or
|
|
1636
|
+
["the ref is still on the remote"])[-1]
|
|
1637
|
+
else:
|
|
1638
|
+
e["gone"] = True
|
|
1639
|
+
e["why_gone"] = ""
|
|
1640
|
+
done.append(e)
|
|
1641
|
+
return done
|
|
1642
|
+
|
|
1547
1643
|
def residue(self) -> list[dict[str, Any]]:
|
|
1548
1644
|
"""Every lock file in this checkout, classified — the live ones included.
|
|
1549
1645
|
|
|
@@ -3185,24 +3281,48 @@ def cmd_residue(_args: argparse.Namespace) -> int:
|
|
|
3185
3281
|
print("\n claim tags: not configured here, so none are swept "
|
|
3186
3282
|
"(`claimTags` in the config)")
|
|
3187
3283
|
|
|
3188
|
-
# AS-01a.
|
|
3189
|
-
# must not read as one that
|
|
3284
|
+
# AS-01a. The sweep, and the disclosure is now about what it COULD NOT reach rather
|
|
3285
|
+
# than about a plane nobody read. A check that cannot look must not read as one that
|
|
3286
|
+
# looked — and a check that CAN look must not keep printing that it cannot.
|
|
3190
3287
|
if s.lease_mode == "git":
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3288
|
+
remote = s.cfg.get("leaseRemote") or "origin"
|
|
3289
|
+
refs, why = s.git_residue()
|
|
3290
|
+
print(f"\n git plane · {remote} · refs/agent-sync/leases/*")
|
|
3291
|
+
if why is not None:
|
|
3292
|
+
print(f" ⚠ COULD NOT LOOK — {why}")
|
|
3293
|
+
print(" So this is not an empty sweep: the refs may be there and unread. "
|
|
3294
|
+
"Fix the remote, or")
|
|
3295
|
+
print(f" enumerate by hand: git ls-remote {remote} "
|
|
3296
|
+
"'refs/agent-sync/leases/*'")
|
|
3297
|
+
elif not refs:
|
|
3298
|
+
print(" no lease refs on the remote — swept and empty, not unread")
|
|
3299
|
+
else:
|
|
3300
|
+
local_keys = {e["key"] for e in entries}
|
|
3301
|
+
reapable = [e for e in refs if e["state"] == REAPABLE]
|
|
3302
|
+
for e in refs:
|
|
3303
|
+
also = " · also noted locally" if e["key"] in local_keys else ""
|
|
3304
|
+
print(f" · {e['key']} [{e['state']}]{also}")
|
|
3305
|
+
print(f" ref : {e['ref']} @ {e['sha'][:10]}")
|
|
3306
|
+
if e.get("run"):
|
|
3307
|
+
print(f" run : {e['run']}"
|
|
3308
|
+
+ (f" · host {e['host']}" if e.get("host") else ""))
|
|
3309
|
+
# `spent()` says this once for both planes: a second phrasing here is a
|
|
3310
|
+
# second definition of what residue means.
|
|
3311
|
+
print(f" state : {spent(e)}")
|
|
3312
|
+
if e.get("why"):
|
|
3313
|
+
print(f" why : {e['why']}")
|
|
3314
|
+
if e["state"] == REAPABLE:
|
|
3315
|
+
print(f" clear : agent_sync.py reap {e['key']}")
|
|
3316
|
+
print(f" {len(refs)} ref(s) on the remote, {len(reapable)} this run can prove "
|
|
3317
|
+
"it owns and has spent")
|
|
3200
3318
|
return 0
|
|
3201
3319
|
|
|
3202
3320
|
|
|
3203
3321
|
def cmd_reap(args: argparse.Namespace) -> int:
|
|
3204
3322
|
"""Clear this run's spent locks, and verify the teardown by reading the state again."""
|
|
3205
3323
|
s = Sync()
|
|
3324
|
+
if getattr(args, "own", False):
|
|
3325
|
+
return _reap_by_operator_decision(s, args.keys)
|
|
3206
3326
|
result = s.reap(args.keys or None)
|
|
3207
3327
|
for e in result["reaped"]:
|
|
3208
3328
|
print(f" reaped {e['key']} — {e['why']}, confirmed gone by re-reading "
|
|
@@ -3217,9 +3337,116 @@ def cmd_reap(args: argparse.Namespace) -> int:
|
|
|
3217
3337
|
if not args.keys:
|
|
3218
3338
|
for e in result["left"]:
|
|
3219
3339
|
print(f" · {e['key']} [{e['state']}] left alone — {e['why']}")
|
|
3220
|
-
|
|
3340
|
+
# AS-01a. The git plane is a second place residue lives, and until this it was a
|
|
3341
|
+
# second place nothing could clear: `reap` walked the lock directory only, so an
|
|
3342
|
+
# expired ref this run had won stayed on the remote with no command able to reach it.
|
|
3343
|
+
git_bad = 0
|
|
3344
|
+
if s.lease_mode == "git":
|
|
3345
|
+
refs, why = s.git_residue()
|
|
3346
|
+
remote = s.cfg.get("leaseRemote") or "origin"
|
|
3347
|
+
if why is not None:
|
|
3348
|
+
print(f" ⚠ the git plane could not be read ({why}) — its refs are "
|
|
3349
|
+
"neither reaped nor reported clean.", file=sys.stderr)
|
|
3350
|
+
print(f" Enumerate by hand: git ls-remote {remote} "
|
|
3351
|
+
"'refs/agent-sync/leases/*'", file=sys.stderr)
|
|
3352
|
+
git_bad = 1
|
|
3353
|
+
else:
|
|
3354
|
+
named = set(args.keys or [])
|
|
3355
|
+
want = [e for e in refs if e["state"] == REAPABLE and (not named or e["key"] in named)]
|
|
3356
|
+
for e in s.git_reap(want):
|
|
3357
|
+
if e["gone"]:
|
|
3358
|
+
print(f" reaped {e['key']} on {remote} — {e['why']}, confirmed gone by "
|
|
3359
|
+
"re-reading the remote")
|
|
3360
|
+
else:
|
|
3361
|
+
print(f" ✗ {e['key']} is STILL on {remote} after the delete "
|
|
3362
|
+
f"({e['why_gone']}) — the compare-and-swap refused, which means "
|
|
3363
|
+
"somebody won it between the read and the delete",
|
|
3364
|
+
file=sys.stderr)
|
|
3365
|
+
git_bad = 1
|
|
3366
|
+
for e in refs:
|
|
3367
|
+
if e["state"] != REAPABLE and (not named or e["key"] in named):
|
|
3368
|
+
print(f" · {e['key']} [{e['state']}] on {remote} left alone — {e['why']}")
|
|
3369
|
+
if not refs:
|
|
3370
|
+
print(f" no lease refs on {remote} — swept and empty, not unread")
|
|
3371
|
+
if not result["reaped"] and not result["remaining"] and not result["refused"] \
|
|
3372
|
+
and s.lease_mode != "git":
|
|
3221
3373
|
print(" nothing this run can prove it owns and has spent — nothing reaped")
|
|
3222
|
-
return 1 if result["remaining"] or result["refused"] else 0
|
|
3374
|
+
return 1 if (result["remaining"] or result["refused"] or git_bad) else 0
|
|
3375
|
+
|
|
3376
|
+
|
|
3377
|
+
def _reap_by_operator_decision(s: "Sync", keys: list[str]) -> int:
|
|
3378
|
+
"""`--i-own-this` — the one path that clears state no RUN can prove is its own.
|
|
3379
|
+
|
|
3380
|
+
AS-01b. The classifier is right to refuse: a run whose identity is the shared fallback
|
|
3381
|
+
cannot prove a matching run id means anything, so every such lock is `ambiguous` and
|
|
3382
|
+
nothing reaps it. The consequence is that expired locks accumulate with no path out for
|
|
3383
|
+
anybody — 28 of them on this machine by 2026-08-20, the oldest overdue by 21 days
|
|
3384
|
+
against a 2700-second TTL.
|
|
3385
|
+
|
|
3386
|
+
M-50 forbids a RUN from deleting what it cannot prove. A person deciding, per key, is
|
|
3387
|
+
not a run guessing — so the decision is available, and three things make it a decision
|
|
3388
|
+
rather than a sweep:
|
|
3389
|
+
|
|
3390
|
+
* **it takes named keys and refuses to run without them.** A blanket override is the
|
|
3391
|
+
thing the classifier exists to prevent, wearing a flag;
|
|
3392
|
+
* **it refuses a LIVE lease.** Residue is what this clears; a live lease belongs to a
|
|
3393
|
+
run that may still be working, and taking it by hand is the collision the tool exists
|
|
3394
|
+
to prevent;
|
|
3395
|
+
* **it prints the payload it destroyed** — run, timestamp, machine — so the decision is
|
|
3396
|
+
auditable afterwards by somebody who was not there, and journals it where a record
|
|
3397
|
+
plane is configured.
|
|
3398
|
+
"""
|
|
3399
|
+
if not keys:
|
|
3400
|
+
print("reap --i-own-this needs the keys, one or more, by name.\n"
|
|
3401
|
+
" A blanket override is the sweep the classifier exists to refuse, wearing a "
|
|
3402
|
+
"flag.\n"
|
|
3403
|
+
" `agent_sync.py residue` lists what is there and why each one is unclearable.",
|
|
3404
|
+
file=sys.stderr)
|
|
3405
|
+
return 2
|
|
3406
|
+
|
|
3407
|
+
by_key = {}
|
|
3408
|
+
for e in s.residue():
|
|
3409
|
+
by_key.setdefault(e["key"], e)
|
|
3410
|
+
by_key.setdefault(s._local_lock(e["key"]).stem, e)
|
|
3411
|
+
|
|
3412
|
+
rc = 0
|
|
3413
|
+
for k in keys:
|
|
3414
|
+
e = by_key.get(k) or by_key.get(s._local_lock(k).stem)
|
|
3415
|
+
if e is None:
|
|
3416
|
+
print(f" · {k} — there is no lock by that name in this checkout", file=sys.stderr)
|
|
3417
|
+
rc = 1
|
|
3418
|
+
continue
|
|
3419
|
+
if e["state"] == LIVE:
|
|
3420
|
+
print(f" ✗ {e['key']} is LIVE under {e.get('run') or 'a run'}"
|
|
3421
|
+
f"{' on ' + e['host'] if e.get('host') else ''} — not cleared. An override "
|
|
3422
|
+
"is for residue;\n a live lease belongs to a run that may still be "
|
|
3423
|
+
"working. Ask the holder, or wait for the TTL.", file=sys.stderr)
|
|
3424
|
+
rc = 1
|
|
3425
|
+
continue
|
|
3426
|
+
had = (f"run {e.get('run') or 'unknown'}"
|
|
3427
|
+
f"{' · host ' + e['host'] if e.get('host') else ''}"
|
|
3428
|
+
f"{' · ' + e['ts'] if e.get('ts') else ''}"
|
|
3429
|
+
f" · {spent(e)}")
|
|
3430
|
+
try:
|
|
3431
|
+
e["path"].unlink()
|
|
3432
|
+
except OSError as exc:
|
|
3433
|
+
print(f" ✗ {e['key']} could not be removed ({exc})", file=sys.stderr)
|
|
3434
|
+
rc = 1
|
|
3435
|
+
continue
|
|
3436
|
+
# Proved gone by looking again, the same rule the ordinary reap follows.
|
|
3437
|
+
if any(x["key"] == e["key"] for x in s.residue()):
|
|
3438
|
+
print(f" ✗ {e['key']} is STILL PRESENT after the delete — the teardown was not "
|
|
3439
|
+
"verified, whatever the call returned", file=sys.stderr)
|
|
3440
|
+
rc = 1
|
|
3441
|
+
continue
|
|
3442
|
+
print(f" cleared {e['key']} by operator decision — it held {had}")
|
|
3443
|
+
print(f" the classifier called it `{e['state']}`, and that has not changed: this "
|
|
3444
|
+
"was a person's\n call, not a proof of ownership.")
|
|
3445
|
+
try:
|
|
3446
|
+
s.journal(f"reap --i-own-this {e['key']} — was {had}, classified {e['state']}")
|
|
3447
|
+
except Exception: # noqa: BLE001 - the record plane is optional
|
|
3448
|
+
pass
|
|
3449
|
+
return rc
|
|
3223
3450
|
|
|
3224
3451
|
|
|
3225
3452
|
def cmd_journal(args: argparse.Namespace) -> int:
|
|
@@ -4320,6 +4547,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
4320
4547
|
rp = sub.add_parser("reap", help="clear expired locks this run provably owns; foreign "
|
|
4321
4548
|
"and ambiguous ones are reported, never touched")
|
|
4322
4549
|
rp.add_argument("keys", nargs="*", help="which to clear (default: every reapable one)")
|
|
4550
|
+
rp.add_argument("--i-own-this", action="store_true", dest="own",
|
|
4551
|
+
help="clear the NAMED expired locks even though this run cannot prove "
|
|
4552
|
+
"they are its own — an operator's decision, per key, recorded. "
|
|
4553
|
+
"Refuses a live lease and refuses to run with no key.")
|
|
4323
4554
|
rp.set_defaults(fn=cmd_reap)
|
|
4324
4555
|
|
|
4325
4556
|
g = sub.add_parser("guard", help="may this run write that path? exit 2 = no")
|