@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.
- package/CHANGELOG.md +89 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/plugins/agent-sync/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-sync/commands/agent-sync.md +5 -2
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +93 -136
- package/plugins/agent-sync/skills/agent-sync/references/lease-protocol.md +66 -0
- package/plugins/agent-sync/skills/agent-sync/references/two-sources.md +10 -0
- package/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +585 -9
|
@@ -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.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
|
|
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
|
|
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
|
-
|
|
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
|
|
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)
|