@ssheleg/agent-sync 1.4.2 → 1.7.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 +359 -0
- package/README.md +33 -15
- package/agent-sync.example.json +3 -1
- package/package.json +5 -2
- package/plugins/agent-sync/.claude-plugin/plugin.json +3 -2
- package/plugins/agent-sync/hooks/guard.sh +63 -17
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +20 -25
- package/plugins/agent-sync/skills/agent-sync/references/branching.md +17 -4
- package/plugins/agent-sync/skills/agent-sync/references/earned-rules.md +9 -0
- package/plugins/agent-sync/skills/agent-sync/references/hooks.md +4 -4
- package/plugins/agent-sync/skills/agent-sync/references/lease-protocol.md +53 -16
- package/plugins/agent-sync/skills/agent-sync/references/pipeline-binding.md +70 -15
- package/plugins/agent-sync/skills/agent-sync/scripts/__pycache__/agent_sync.cpython-312.pyc +0 -0
- package/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +565 -152
|
@@ -18,6 +18,7 @@ from __future__ import annotations
|
|
|
18
18
|
import argparse
|
|
19
19
|
import json
|
|
20
20
|
import os
|
|
21
|
+
import platform
|
|
21
22
|
import random
|
|
22
23
|
import re
|
|
23
24
|
import stat
|
|
@@ -32,7 +33,7 @@ from datetime import datetime, timezone
|
|
|
32
33
|
from pathlib import Path
|
|
33
34
|
from typing import Any
|
|
34
35
|
|
|
35
|
-
VERSION = "1.
|
|
36
|
+
VERSION = "1.7.0"
|
|
36
37
|
|
|
37
38
|
CONFIG_PATH = Path(".claude/agent-sync.json")
|
|
38
39
|
ENV_FILE = Path(".env.agent-sync")
|
|
@@ -66,7 +67,6 @@ LOGS = {
|
|
|
66
67
|
"claims": "30 Claims",
|
|
67
68
|
"reservations": "40 Reservations",
|
|
68
69
|
"signals": "50 Signals",
|
|
69
|
-
"blockers": "60 Blockers",
|
|
70
70
|
# The as-built record: what agents actually implemented, as they implemented it.
|
|
71
71
|
# Git documentation says how it SHOULD be — written before the code and often
|
|
72
72
|
# without it. This says how it IS, derived from what was really written. They are
|
|
@@ -87,6 +87,24 @@ MAX_UNPARSEABLE = 0.02
|
|
|
87
87
|
DEFAULT_SETTLE = 3.0
|
|
88
88
|
DEFAULT_TTL = 2700
|
|
89
89
|
DEFAULT_RENEW = 300
|
|
90
|
+
# How long a steal section may be held before it is treated as abandoned. It covers two
|
|
91
|
+
# filesystem calls, so anything longer than this is a crashed process, not slow work.
|
|
92
|
+
STEAL_GRACE = 30
|
|
93
|
+
# How many signal identities `status` remembers as already shown. Bounded, with a floor
|
|
94
|
+
# timestamp beside it so the entries that fall out are not announced a second time.
|
|
95
|
+
SEEN_CAP = 500
|
|
96
|
+
|
|
97
|
+
# Every key `.claude/agent-sync.json` may carry. One list, and `agent-sync.schema.json`
|
|
98
|
+
# must agree with it exactly — the validator asserts that, because the two were already
|
|
99
|
+
# a second copy of each other once and disagreed: `check` called `mergeLog` (written by
|
|
100
|
+
# `init` itself) and `integrationBranch` (in the schema, in the example, read below)
|
|
101
|
+
# unknown keys that "will be ignored". Both statements were false, and the second was an
|
|
102
|
+
# instruction: an agent making `check` green deletes working configuration.
|
|
103
|
+
CONFIG_KEYS = frozenset({
|
|
104
|
+
"$schema", "backend", "leaseTtlSeconds", "renewIntervalSeconds", "gated",
|
|
105
|
+
"idRegisters", "guardedFiles", "claimTags", "gates", "mirror", "setupFile",
|
|
106
|
+
"leaseBackend", "leaseRemote", "settleSeconds", "integrationBranch", "mergeLog",
|
|
107
|
+
})
|
|
90
108
|
|
|
91
109
|
|
|
92
110
|
# --------------------------------------------------------------------------- utils
|
|
@@ -180,6 +198,51 @@ class Fail(Exception):
|
|
|
180
198
|
"""A failure the caller must see. Never swallowed into a success."""
|
|
181
199
|
|
|
182
200
|
|
|
201
|
+
_GLOB_CACHE: dict[str, re.Pattern[str]] = {}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def matches_glob(rel: str, pattern: str) -> bool:
|
|
205
|
+
"""Repo-root-anchored glob — ONE implementation, for the guard and for `check`.
|
|
206
|
+
|
|
207
|
+
They used to have two, and the two disagreed in both directions about the same
|
|
208
|
+
pattern. `Path.match` anchors at the RIGHT, so `docs/DECISIONS.md` also matched
|
|
209
|
+
`vendor/docs/DECISIONS.md` — a file `check` (which enumerates with `glob`) never saw
|
|
210
|
+
and never validated, guarded by a rule nobody wrote. And `Path.match` does not walk
|
|
211
|
+
`**` before Python 3.13, so `docs/**/*.md` guarded less than `check` reported it did.
|
|
212
|
+
|
|
213
|
+
A pattern that means two things means nothing, so the translation lives here: `**` is
|
|
214
|
+
zero or more directories, `*` and `?` never cross a separator, everything else is
|
|
215
|
+
literal, and the whole path must match from the repository root.
|
|
216
|
+
"""
|
|
217
|
+
rx = _GLOB_CACHE.get(pattern)
|
|
218
|
+
if rx is None:
|
|
219
|
+
parts: list[str] = []
|
|
220
|
+
for seg in pattern.strip("/").split("/"):
|
|
221
|
+
if seg == "**":
|
|
222
|
+
parts.append("(?:[^/]+/)*")
|
|
223
|
+
continue
|
|
224
|
+
out = ""
|
|
225
|
+
for ch in seg:
|
|
226
|
+
out += "[^/]*" if ch == "*" else "[^/]" if ch == "?" else re.escape(ch)
|
|
227
|
+
parts.append(out + "/")
|
|
228
|
+
body = "".join(parts)
|
|
229
|
+
rx = re.compile("^" + (body[:-1] if body.endswith("/") else body) + "$")
|
|
230
|
+
_GLOB_CACHE[pattern] = rx
|
|
231
|
+
return bool(rx.match(rel.replace(os.sep, "/")))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def glob_files(root: Path, pattern: str) -> list[Path]:
|
|
235
|
+
"""Every existing file the pattern covers, by the same rule the guard applies."""
|
|
236
|
+
skip = {".git", "node_modules", STATE_DIR.name}
|
|
237
|
+
out: list[Path] = []
|
|
238
|
+
for path in root.rglob("*"):
|
|
239
|
+
if skip & set(path.relative_to(root).parts):
|
|
240
|
+
continue
|
|
241
|
+
if path.is_file() and matches_glob(str(path.relative_to(root)), pattern):
|
|
242
|
+
out.append(path)
|
|
243
|
+
return out
|
|
244
|
+
|
|
245
|
+
|
|
183
246
|
# --------------------------------------------------------------------------- config
|
|
184
247
|
|
|
185
248
|
def find_env_file(root: Path) -> Path | None:
|
|
@@ -191,25 +254,34 @@ def find_env_file(root: Path) -> Path | None:
|
|
|
191
254
|
unable to see anyone: three agents entered from one umbrella, coordinating with
|
|
192
255
|
nobody, and each one saying `ungated` while believing it was configured.
|
|
193
256
|
|
|
194
|
-
|
|
195
|
-
|
|
257
|
+
So one credential file serves the whole tree — but only a tree git can vouch for.
|
|
258
|
+
The search is: `AGENT_SYNC_ENV` if set, then the local root, then each superproject
|
|
259
|
+
in turn. It used to continue into **plain parent directories** until something
|
|
260
|
+
matched, which meant a stray `.env.agent-sync` in a home or work directory silently
|
|
261
|
+
configured every project beneath it and pointed them all at one collection — a
|
|
262
|
+
coordination plane shared by projects with nothing to do with each other, discovered
|
|
263
|
+
by nobody, because a found file looks exactly like a configured one.
|
|
196
264
|
"""
|
|
265
|
+
explicit = os.environ.get("AGENT_SYNC_ENV")
|
|
266
|
+
if explicit:
|
|
267
|
+
path = Path(explicit).expanduser()
|
|
268
|
+
return path if path.exists() else None
|
|
269
|
+
|
|
197
270
|
local = root / ENV_FILE
|
|
198
271
|
if local.exists():
|
|
199
272
|
return local
|
|
200
273
|
|
|
201
|
-
|
|
202
|
-
|
|
274
|
+
seen: set[str] = set()
|
|
275
|
+
current = root
|
|
276
|
+
for _ in range(8): # a submodule chain, not the whole filesystem
|
|
277
|
+
superproject = git("rev-parse", "--show-superproject-working-tree", cwd=current)
|
|
278
|
+
if not superproject or superproject in seen:
|
|
279
|
+
break
|
|
280
|
+
seen.add(superproject)
|
|
203
281
|
candidate = Path(superproject) / ENV_FILE
|
|
204
282
|
if candidate.exists():
|
|
205
283
|
return candidate
|
|
206
|
-
|
|
207
|
-
for parent in root.resolve().parents:
|
|
208
|
-
candidate = parent / ENV_FILE
|
|
209
|
-
if candidate.exists():
|
|
210
|
-
return candidate
|
|
211
|
-
if (parent / ".git").exists() and (parent / CONFIG_PATH).exists():
|
|
212
|
-
break # a configured project that simply has no env file — stop here
|
|
284
|
+
current = Path(superproject)
|
|
213
285
|
return None
|
|
214
286
|
|
|
215
287
|
|
|
@@ -395,10 +467,6 @@ class Adapter:
|
|
|
395
467
|
return bool(self.capabilities["atomicAppend"]
|
|
396
468
|
and self.capabilities["totalOrderRead"])
|
|
397
469
|
|
|
398
|
-
@property
|
|
399
|
-
def is_exclusive(self) -> bool:
|
|
400
|
-
"""Whether a won lease is a guarantee or advice. Never assume the first."""
|
|
401
|
-
return bool(self.capabilities.get("exclusiveLease"))
|
|
402
470
|
|
|
403
471
|
|
|
404
472
|
class OutlineAdapter(Adapter):
|
|
@@ -600,27 +668,46 @@ class FsAdapter(Adapter):
|
|
|
600
668
|
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", path).strip("-").lower()
|
|
601
669
|
return self.base / f"{safe}.md"
|
|
602
670
|
|
|
671
|
+
# A store failure is the tool's own failure type, never a bare OSError. Callers
|
|
672
|
+
# catch `Fail` and turn it into one sentence a reader can act on; an OSError walks
|
|
673
|
+
# straight past them into `main`, and the agent is handed a Python traceback for a
|
|
674
|
+
# read-only directory — which it then reports as the state of the coordination plane.
|
|
603
675
|
def tree_ensure(self, path: str) -> str:
|
|
604
676
|
p = self._p(path)
|
|
605
|
-
|
|
606
|
-
p.
|
|
607
|
-
|
|
677
|
+
try:
|
|
678
|
+
if not p.exists():
|
|
679
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
680
|
+
p.write_text("")
|
|
681
|
+
except OSError as exc:
|
|
682
|
+
raise Fail(f"cannot open the local plane at {p}: {exc}") from exc
|
|
608
683
|
return str(p)
|
|
609
684
|
|
|
610
685
|
def log_append(self, oid: str, line: str) -> None:
|
|
611
|
-
|
|
612
|
-
|
|
686
|
+
try:
|
|
687
|
+
with open(oid, "a") as fh:
|
|
688
|
+
fh.write(line.rstrip("\n") + "\n")
|
|
689
|
+
except OSError as exc:
|
|
690
|
+
raise Fail(f"cannot append to {oid}: {exc}") from exc
|
|
613
691
|
|
|
614
692
|
def log_read(self, oid: str) -> str:
|
|
615
693
|
p = Path(oid)
|
|
616
|
-
|
|
694
|
+
try:
|
|
695
|
+
return p.read_text() if p.exists() else ""
|
|
696
|
+
except OSError as exc:
|
|
697
|
+
raise Fail(f"cannot read {oid}: {exc}") from exc
|
|
617
698
|
|
|
618
699
|
def doc_put(self, oid: str, text: str) -> None:
|
|
619
|
-
|
|
700
|
+
try:
|
|
701
|
+
Path(oid).write_text(text)
|
|
702
|
+
except OSError as exc:
|
|
703
|
+
raise Fail(f"cannot write {oid}: {exc}") from exc
|
|
620
704
|
|
|
621
705
|
def doc_get(self, oid: str) -> str:
|
|
622
706
|
p = Path(oid)
|
|
623
|
-
|
|
707
|
+
try:
|
|
708
|
+
return p.read_text() if p.exists() else ""
|
|
709
|
+
except OSError as exc:
|
|
710
|
+
raise Fail(f"cannot read {oid}: {exc}") from exc
|
|
624
711
|
|
|
625
712
|
def log_shards(self, prefix: str) -> list[str]:
|
|
626
713
|
stem = re.sub(r"[^A-Za-z0-9._-]+", "-", prefix).strip("-").lower()
|
|
@@ -709,7 +796,23 @@ def resolve_reservations(events: list[dict[str, str]], reg: str) -> tuple[int, l
|
|
|
709
796
|
if ev["key"] != reg:
|
|
710
797
|
continue
|
|
711
798
|
if ev["op"] == "base":
|
|
712
|
-
|
|
799
|
+
value = int(ev.get("value") or 0)
|
|
800
|
+
# A base only ever moves allocation FORWARD. Two runs opening the same register in
|
|
801
|
+
# the same minute both append the same seed, and a base that re-seated
|
|
802
|
+
# unconditionally would restart the count and hand the second run the id the first
|
|
803
|
+
# had just been given — the collision, arriving through the door built to prevent it.
|
|
804
|
+
if base is not None and value <= base + served:
|
|
805
|
+
continue
|
|
806
|
+
base = value
|
|
807
|
+
# A re-base restarts the count. Without this reset, it hands out `new_base + served`
|
|
808
|
+
# and skips as many ids as were served under the old one — and re-basing is not
|
|
809
|
+
# exotic: it is what happens whenever the register grew by a path other than this
|
|
810
|
+
# tool, which is the common case.
|
|
811
|
+
served = 0
|
|
812
|
+
# Ids freed below the new base are not free any more. The register moved past them, so
|
|
813
|
+
# something is written there now, and handing one back would be the very collision the
|
|
814
|
+
# re-base exists to prevent, arriving through the other door.
|
|
815
|
+
free = [f for f in free if f >= base]
|
|
713
816
|
continue
|
|
714
817
|
if base is None:
|
|
715
818
|
continue
|
|
@@ -738,7 +841,6 @@ class Sync:
|
|
|
738
841
|
self.adapter = make_adapter(self.cfg, self.root)
|
|
739
842
|
self.rid = run_id(self.root)
|
|
740
843
|
self.ttl = int(self.cfg.get("leaseTtlSeconds") or DEFAULT_TTL)
|
|
741
|
-
self.settle = float(self.cfg.get("settleSeconds") or DEFAULT_SETTLE)
|
|
742
844
|
|
|
743
845
|
@property
|
|
744
846
|
def gated(self) -> bool:
|
|
@@ -798,6 +900,20 @@ class Sync:
|
|
|
798
900
|
|
|
799
901
|
# Deterministic for every reader: time, then run, then position within a shard.
|
|
800
902
|
events.sort(key=lambda e: (e["ts"], e["run"], int(e["_i"])))
|
|
903
|
+
|
|
904
|
+
# Past the threshold the log is refused, not replayed. `MAX_UNPARSEABLE` was
|
|
905
|
+
# declared and never read: the only trace of this rule was a line on the board
|
|
906
|
+
# that printed a warning and returned 0, while SKILL.md, lease-protocol.md and the
|
|
907
|
+
# README all said a log this broken stops the run. Replaying it reports holders who
|
|
908
|
+
# do not exist and silence where the real ones are — which is strictly worse than
|
|
909
|
+
# refusing, because both look like an answer.
|
|
910
|
+
total = len(events) + bad
|
|
911
|
+
if total and bad / total > MAX_UNPARSEABLE:
|
|
912
|
+
raise Fail(
|
|
913
|
+
f"the {which} log is {bad}/{total} unparseable "
|
|
914
|
+
f"({bad / total:.0%}, over the {MAX_UNPARSEABLE:.0%} limit) — refusing to "
|
|
915
|
+
"replay it. Entry-shaped lines that do not match the grammar are counted, "
|
|
916
|
+
"never guessed at; fix or remove them (see references/lease-protocol.md)")
|
|
801
917
|
return events, bad
|
|
802
918
|
|
|
803
919
|
# -- leases ------------------------------------------------------------
|
|
@@ -861,8 +977,8 @@ class Sync:
|
|
|
861
977
|
return False, held.get("run")
|
|
862
978
|
|
|
863
979
|
payload = json.dumps({"run": self.rid, "ts": now_iso(), "ttl": self.ttl,
|
|
864
|
-
"repo": repo_name(), "host":
|
|
865
|
-
empty_tree = git("hash-object", "-t", "tree",
|
|
980
|
+
"repo": repo_name(), "host": platform.node()})
|
|
981
|
+
empty_tree = git("hash-object", "-t", "tree", os.devnull)
|
|
866
982
|
# A lease object is plumbing, not authorship, so it must not depend on the
|
|
867
983
|
# machine having a git identity. Without these `-c` flags `commit-tree`
|
|
868
984
|
# refuses wherever user.email is unset and cannot be auto-detected — CI
|
|
@@ -918,6 +1034,51 @@ class Sync:
|
|
|
918
1034
|
d.mkdir(parents=True, exist_ok=True)
|
|
919
1035
|
return d / f"{re.sub(r'[^A-Za-z0-9_-]', '-', key)}.lock"
|
|
920
1036
|
|
|
1037
|
+
def _steal_expired(self, lock: Path, payload: str) -> bool:
|
|
1038
|
+
"""Replace an expired lock — reap and create as ONE critical section.
|
|
1039
|
+
|
|
1040
|
+
They used to be two calls with a gap between them, and the gap is a hole in the
|
|
1041
|
+
exclusion: a second stealer that has already read the lock as expired removes the
|
|
1042
|
+
lock the first one just created, and both then hold what each believes is an
|
|
1043
|
+
exclusive lease. Twelve racing processes never showed it; a 300 ms delay injected
|
|
1044
|
+
between the two calls produced two winners out of two, and in production that
|
|
1045
|
+
delay is an ordinary scheduler hiccup.
|
|
1046
|
+
|
|
1047
|
+
`O_EXCL` on a second name makes the section itself exclusive, and the expiry is
|
|
1048
|
+
re-read INSIDE it — so a run that gets in after the winner sees a live lease and
|
|
1049
|
+
loses, rather than reaping the lease it just missed. The section covers two
|
|
1050
|
+
filesystem calls, so its own abandonment grace is short; without one, a crash
|
|
1051
|
+
between them would cost the key until somebody deleted a file by hand.
|
|
1052
|
+
"""
|
|
1053
|
+
guard = lock.with_name(lock.name + ".steal")
|
|
1054
|
+
try:
|
|
1055
|
+
if guard.exists() and time.time() - guard.stat().st_mtime > STEAL_GRACE:
|
|
1056
|
+
guard.unlink(missing_ok=True)
|
|
1057
|
+
except OSError:
|
|
1058
|
+
pass
|
|
1059
|
+
try:
|
|
1060
|
+
fd = os.open(str(guard), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
1061
|
+
except OSError:
|
|
1062
|
+
return False # another run is stealing this very lock
|
|
1063
|
+
try:
|
|
1064
|
+
os.close(fd)
|
|
1065
|
+
try:
|
|
1066
|
+
held = json.loads(lock.read_text())
|
|
1067
|
+
except (json.JSONDecodeError, OSError):
|
|
1068
|
+
held = {}
|
|
1069
|
+
if held and time.time() <= parse_iso(held.get("ts", "")) + int(
|
|
1070
|
+
held.get("ttl", self.ttl)):
|
|
1071
|
+
return False # renewed, or already stolen and live again
|
|
1072
|
+
lock.unlink(missing_ok=True)
|
|
1073
|
+
fd2 = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
1074
|
+
with os.fdopen(fd2, "w") as fh:
|
|
1075
|
+
fh.write(payload)
|
|
1076
|
+
return True
|
|
1077
|
+
except OSError:
|
|
1078
|
+
return False
|
|
1079
|
+
finally:
|
|
1080
|
+
guard.unlink(missing_ok=True)
|
|
1081
|
+
|
|
921
1082
|
def acquire(self, key: str) -> tuple[bool, str | None]:
|
|
922
1083
|
"""Exclusion comes from an atomic file create; the cloud carries the record.
|
|
923
1084
|
|
|
@@ -946,33 +1107,36 @@ class Sync:
|
|
|
946
1107
|
return won, holder
|
|
947
1108
|
|
|
948
1109
|
lock = self._local_lock(key)
|
|
1110
|
+
payload = json.dumps({"run": self.rid, "ts": now_iso(), "ttl": self.ttl,
|
|
1111
|
+
"repo": repo_name()})
|
|
949
1112
|
|
|
950
|
-
# Reap an expired lock first: it is a crashed run, not a live holder.
|
|
951
1113
|
if lock.exists():
|
|
952
1114
|
try:
|
|
953
1115
|
held = json.loads(lock.read_text())
|
|
954
1116
|
except (json.JSONDecodeError, OSError):
|
|
955
1117
|
held = {}
|
|
956
|
-
expired = time.time() > parse_iso(held.get("ts", "")) + int(held.get("ttl", self.ttl))
|
|
957
1118
|
if held.get("run") == self.rid:
|
|
958
1119
|
self._touch_renew()
|
|
959
1120
|
return True, self.rid
|
|
960
|
-
if
|
|
1121
|
+
if time.time() <= parse_iso(held.get("ts", "")) + int(held.get("ttl", self.ttl)):
|
|
961
1122
|
return False, held.get("run")
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1123
|
+
if not self._steal_expired(lock, payload):
|
|
1124
|
+
try:
|
|
1125
|
+
other = json.loads(lock.read_text()).get("run")
|
|
1126
|
+
except (json.JSONDecodeError, OSError):
|
|
1127
|
+
other = None
|
|
1128
|
+
return False, other
|
|
1129
|
+
else:
|
|
969
1130
|
try:
|
|
970
|
-
|
|
971
|
-
except
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1131
|
+
fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
1132
|
+
except FileExistsError:
|
|
1133
|
+
try:
|
|
1134
|
+
other = json.loads(lock.read_text()).get("run")
|
|
1135
|
+
except (json.JSONDecodeError, OSError):
|
|
1136
|
+
other = None
|
|
1137
|
+
return False, other
|
|
1138
|
+
with os.fdopen(fd, "w") as fh:
|
|
1139
|
+
fh.write(payload)
|
|
976
1140
|
|
|
977
1141
|
self._touch_renew()
|
|
978
1142
|
for n in self.write_claim(key, self.rid):
|
|
@@ -989,6 +1153,61 @@ class Sync:
|
|
|
989
1153
|
file=sys.stderr)
|
|
990
1154
|
return True, self.rid
|
|
991
1155
|
|
|
1156
|
+
def _refresh_lease(self, key: str) -> bool:
|
|
1157
|
+
"""Move the timestamp this lease is expired by, in the plane that arbitrates it.
|
|
1158
|
+
|
|
1159
|
+
This is what `renew` means, and for four minor versions it did not happen. `renew`
|
|
1160
|
+
appended `op=renew` to the RECORD plane — which has not decided a lease since
|
|
1161
|
+
1.0.0 — and touched a throttle file. The lock's own `ts` was written once, by
|
|
1162
|
+
`acquire`. So a run holding a lease lost it at TTL while still working: its own
|
|
1163
|
+
guard began denying it, and another run acquired the task it was in the middle of.
|
|
1164
|
+
The `PostToolUse` hook changed nothing, because there was nothing for it to move.
|
|
1165
|
+
"""
|
|
1166
|
+
if self.lease_mode == "git":
|
|
1167
|
+
sha, held = self._git_read_lease(key)
|
|
1168
|
+
if not sha or held.get("run") != self.rid:
|
|
1169
|
+
return False
|
|
1170
|
+
payload = json.dumps({**held, "ts": now_iso()})
|
|
1171
|
+
empty_tree = git("hash-object", "-t", "tree", os.devnull)
|
|
1172
|
+
made = subprocess.run(
|
|
1173
|
+
["git", "-c", "user.name=agent-sync", "-c", "user.email=agent-sync@localhost",
|
|
1174
|
+
"commit-tree", empty_tree],
|
|
1175
|
+
input=payload, capture_output=True, text=True)
|
|
1176
|
+
commit = made.stdout.strip()
|
|
1177
|
+
if not commit:
|
|
1178
|
+
return False
|
|
1179
|
+
# Against the exact object just read: a renewal must never overwrite a lease
|
|
1180
|
+
# somebody else took while this run was between the read and the push.
|
|
1181
|
+
r = subprocess.run(["git", "push", self._git_remote(),
|
|
1182
|
+
f"--force-with-lease={self._ref(key)}:{sha}",
|
|
1183
|
+
f"{commit}:{self._ref(key)}"], capture_output=True, text=True)
|
|
1184
|
+
if r.returncode != 0:
|
|
1185
|
+
print(f"note: could not renew {key} on the remote: {r.stderr.strip()[:160]}",
|
|
1186
|
+
file=sys.stderr)
|
|
1187
|
+
return False
|
|
1188
|
+
self._note_local(key, payload)
|
|
1189
|
+
return True
|
|
1190
|
+
|
|
1191
|
+
lock = self._local_lock(key)
|
|
1192
|
+
if not lock.exists():
|
|
1193
|
+
return False
|
|
1194
|
+
try:
|
|
1195
|
+
held = json.loads(lock.read_text())
|
|
1196
|
+
except (json.JSONDecodeError, OSError):
|
|
1197
|
+
return False
|
|
1198
|
+
if held.get("run") != self.rid:
|
|
1199
|
+
return False
|
|
1200
|
+
held["ts"] = now_iso()
|
|
1201
|
+
tmp = lock.with_name(f"{lock.name}.{os.getpid()}.tmp")
|
|
1202
|
+
try:
|
|
1203
|
+
tmp.write_text(json.dumps(held))
|
|
1204
|
+
tmp.replace(lock)
|
|
1205
|
+
except OSError as exc:
|
|
1206
|
+
tmp.unlink(missing_ok=True)
|
|
1207
|
+
print(f"note: could not renew {key} ({exc})", file=sys.stderr)
|
|
1208
|
+
return False
|
|
1209
|
+
return True
|
|
1210
|
+
|
|
992
1211
|
def renew(self, key: str | None = None) -> bool:
|
|
993
1212
|
marker = self.root / STATE_DIR / "last-renew"
|
|
994
1213
|
interval = int(self.cfg.get("renewIntervalSeconds") or DEFAULT_RENEW)
|
|
@@ -998,12 +1217,13 @@ class Sync:
|
|
|
998
1217
|
if not keys:
|
|
999
1218
|
self._touch_renew()
|
|
1000
1219
|
return False
|
|
1001
|
-
if self.
|
|
1220
|
+
renewed = [k for k in keys if self._refresh_lease(k)]
|
|
1221
|
+
if self.adapter.is_lease_authority and renewed:
|
|
1002
1222
|
oid = self.log_id("claims")
|
|
1003
|
-
for k in
|
|
1223
|
+
for k in renewed:
|
|
1004
1224
|
self.adapter.log_append(oid, fmt_line("renew", k, self.rid))
|
|
1005
1225
|
self._touch_renew()
|
|
1006
|
-
return
|
|
1226
|
+
return bool(renewed)
|
|
1007
1227
|
|
|
1008
1228
|
def _touch_renew(self) -> None:
|
|
1009
1229
|
marker = self.root / STATE_DIR / "last-renew"
|
|
@@ -1072,22 +1292,6 @@ class Sync:
|
|
|
1072
1292
|
mine.append(q.stem)
|
|
1073
1293
|
return sorted(mine)
|
|
1074
1294
|
|
|
1075
|
-
def _held_legacy(self) -> list[str]:
|
|
1076
|
-
if not self.adapter.is_lease_authority:
|
|
1077
|
-
d = self.root / STATE_DIR / "leases"
|
|
1078
|
-
out = []
|
|
1079
|
-
for p in (d.glob("*.lock") if d.exists() else []):
|
|
1080
|
-
try:
|
|
1081
|
-
if json.loads(p.read_text()).get("run") == self.rid:
|
|
1082
|
-
out.append(p.stem)
|
|
1083
|
-
except json.JSONDecodeError:
|
|
1084
|
-
continue
|
|
1085
|
-
return out
|
|
1086
|
-
events, _ = self.events("claims")
|
|
1087
|
-
now = time.time()
|
|
1088
|
-
keys = {e["key"] for e in events}
|
|
1089
|
-
return sorted(k for k in keys if resolve_holder(events, k, now) == self.rid)
|
|
1090
|
-
|
|
1091
1295
|
# -- ids ---------------------------------------------------------------
|
|
1092
1296
|
|
|
1093
1297
|
def reserve(self, reg: str) -> int:
|
|
@@ -1096,16 +1300,42 @@ class Sync:
|
|
|
1096
1300
|
f"backend '{self.adapter.name}' cannot reserve ids safely "
|
|
1097
1301
|
"(atomicAppend is false). Allocate by hand and record it, or configure a "
|
|
1098
1302
|
"cloud backend. Pretending would hand two agents the same id.")
|
|
1303
|
+
# Every shard, not just this run's. `log_id` returns the document THIS run writes,
|
|
1304
|
+
# and reading it alone was the whole defect: three runs each replayed a log
|
|
1305
|
+
# containing only their own lines, each seeded a base from the register, and each
|
|
1306
|
+
# was handed the same number — while `_leaks` on the same data, read merged,
|
|
1307
|
+
# reported the truth. Allocation is positional over the WHOLE log or it is nothing.
|
|
1099
1308
|
oid = self.log_id("reservations")
|
|
1100
|
-
events, _ =
|
|
1309
|
+
events, _ = self.events("reservations")
|
|
1101
1310
|
base, _free, _assign = resolve_reservations(events, reg)
|
|
1102
1311
|
if not base:
|
|
1103
1312
|
base = self._seed_base(reg)
|
|
1104
1313
|
self.adapter.log_append(oid, fmt_line("base", reg, self.rid, value=f"{base:04d}"))
|
|
1105
|
-
events, _ =
|
|
1314
|
+
events, _ = self.events("reservations")
|
|
1315
|
+
else:
|
|
1316
|
+
# The log knows what *this tool* handed out. The register knows what is actually
|
|
1317
|
+
# written, by every path including the ones that never touch this tool — a person
|
|
1318
|
+
# editing the file, another session's Doc Loop, a merge. The log alone therefore drifts
|
|
1319
|
+
# behind, silently and permanently, and hands out ids that already have a heading.
|
|
1320
|
+
#
|
|
1321
|
+
# This is the failure mode the whole mechanism exists to prevent, so the register is
|
|
1322
|
+
# consulted on every reserve and treated as a **floor**, never as a ceiling: it can only
|
|
1323
|
+
# push the allocation forward. Ids this tool reserved but nobody has written yet are not
|
|
1324
|
+
# in the register, so honouring it as a floor never revokes a live reservation.
|
|
1325
|
+
#
|
|
1326
|
+
# Probed rather than computed: the allocator is asked what it *would* hand out next, by
|
|
1327
|
+
# resolving a synthetic reserve. That keeps one implementation of the allocation rule
|
|
1328
|
+
# instead of a second copy here that can disagree with it.
|
|
1329
|
+
floor = self._seed_base(reg)
|
|
1330
|
+
probe = events + [{"op": "reserve", "key": reg, "run": "\x00probe", "value": ""}]
|
|
1331
|
+
_b, _f, probed = resolve_reservations(probe, reg)
|
|
1332
|
+
if probed and probed[-1][1] < floor:
|
|
1333
|
+
self.adapter.log_append(
|
|
1334
|
+
oid, fmt_line("base", reg, self.rid, value=f"{floor:04d}"))
|
|
1335
|
+
events, _ = self.events("reservations")
|
|
1106
1336
|
self.adapter.log_append(oid, fmt_line("reserve", reg, self.rid))
|
|
1107
1337
|
time.sleep(0.25 + random.random() * 0.15)
|
|
1108
|
-
events, _ =
|
|
1338
|
+
events, _ = self.events("reservations")
|
|
1109
1339
|
_b, _f, assignments = resolve_reservations(events, reg)
|
|
1110
1340
|
mine = [v for r, v in assignments if r == self.rid]
|
|
1111
1341
|
if not mine:
|
|
@@ -1125,9 +1355,18 @@ class Sync:
|
|
|
1125
1355
|
return int(m.group(1))
|
|
1126
1356
|
|
|
1127
1357
|
def release_id(self, reg: str, value: str) -> None:
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1358
|
+
"""Return an id to the pool — or say plainly that nothing recorded it.
|
|
1359
|
+
|
|
1360
|
+
On a backend that cannot order writes this used to do nothing and print
|
|
1361
|
+
"released" anyway. The id stayed a hole the board reports as a leak, and the only
|
|
1362
|
+
party who could have fixed that had been told it was handled."""
|
|
1363
|
+
if not self.adapter.is_lease_authority:
|
|
1364
|
+
raise Fail(
|
|
1365
|
+
f"backend '{self.adapter.name}' cannot record a released id "
|
|
1366
|
+
"(atomicAppend is false), so nothing was returned to the pool. Note it in "
|
|
1367
|
+
f"the register by hand, or configure a backend that can: {reg}-{value}")
|
|
1368
|
+
self.adapter.log_append(self.log_id("reservations"),
|
|
1369
|
+
fmt_line("release_id", reg, self.rid, value=value))
|
|
1131
1370
|
|
|
1132
1371
|
# -- journal / signals -------------------------------------------------
|
|
1133
1372
|
|
|
@@ -1148,39 +1387,72 @@ class Sync:
|
|
|
1148
1387
|
file=sys.stderr)
|
|
1149
1388
|
return False
|
|
1150
1389
|
|
|
1151
|
-
def journal(self, text: str) ->
|
|
1390
|
+
def journal(self, text: str) -> bool:
|
|
1152
1391
|
try:
|
|
1153
1392
|
oid = self.adapter.tree_ensure(f"20 Runs — {self.rid}")
|
|
1154
1393
|
self.adapter.log_append(oid, fmt_line(
|
|
1155
1394
|
"journal", self.rid, self.rid, sha=head_sha(),
|
|
1156
1395
|
note=text.replace("`", "'")[:400]))
|
|
1396
|
+
return True
|
|
1157
1397
|
except Fail as exc:
|
|
1158
1398
|
print(f"agent-sync: journal NOT published ({exc})", file=sys.stderr)
|
|
1399
|
+
return False
|
|
1159
1400
|
|
|
1160
|
-
def signal(self, dep: str, state: str) ->
|
|
1401
|
+
def signal(self, dep: str, state: str) -> bool:
|
|
1161
1402
|
allowed = {"filed", "accepted", "delivered", "closed", "refused"}
|
|
1162
1403
|
if state not in allowed:
|
|
1163
1404
|
raise Fail(f"state must be one of {sorted(allowed)}")
|
|
1164
|
-
self._publish("signals", fmt_line(
|
|
1405
|
+
return self._publish("signals", fmt_line(
|
|
1165
1406
|
"signal", dep, self.rid, state=state, repo=repo_name(), sha=head_sha()))
|
|
1166
1407
|
|
|
1167
1408
|
# -- awareness ---------------------------------------------------------
|
|
1168
1409
|
|
|
1169
|
-
|
|
1410
|
+
@staticmethod
|
|
1411
|
+
def _fingerprint(ev: dict[str, str]) -> str:
|
|
1412
|
+
return "|".join((ev.get("ts", ""), ev.get("run", ""), ev.get("key", ""),
|
|
1413
|
+
ev.get("op", ""), ev.get("state", "")))
|
|
1414
|
+
|
|
1415
|
+
def _seen(self, which: str, events: list[dict[str, str]]) -> tuple[set[str], str]:
|
|
1416
|
+
"""What this run has already been shown, by identity rather than by position.
|
|
1417
|
+
|
|
1418
|
+
This was an INDEX into a list re-sorted on every read. An entry appended by
|
|
1419
|
+
another run with an earlier timestamp — clock skew, or a shard that was
|
|
1420
|
+
unreachable a moment ago — lands before the mark, shifts everything after it, and
|
|
1421
|
+
is never reported: the slice hands back an entry already seen instead. The one
|
|
1422
|
+
section of `status` whose job is to say "this changed while you were away" went
|
|
1423
|
+
quiet about precisely the change that arrived late.
|
|
1424
|
+
|
|
1425
|
+
Returns the seen fingerprints and a floor timestamp. The floor bounds the file:
|
|
1426
|
+
older entries fell out of the kept window, and anything below it was necessarily
|
|
1427
|
+
shown in an earlier run.
|
|
1428
|
+
"""
|
|
1170
1429
|
p = self.root / STATE_DIR / "seen.json"
|
|
1171
1430
|
try:
|
|
1172
|
-
|
|
1431
|
+
entry = json.loads(p.read_text()).get(which)
|
|
1173
1432
|
except (OSError, ValueError, AttributeError):
|
|
1174
|
-
return
|
|
1175
|
-
|
|
1176
|
-
|
|
1433
|
+
return set(), ""
|
|
1434
|
+
if isinstance(entry, dict):
|
|
1435
|
+
return set(entry.get("fingerprints") or []), str(entry.get("floor") or "")
|
|
1436
|
+
if isinstance(entry, int):
|
|
1437
|
+
# The old index watermark meant "the first N were shown". Honour that reading
|
|
1438
|
+
# once, so upgrading does not re-announce a year of signals.
|
|
1439
|
+
return {self._fingerprint(e) for e in events[:entry]}, ""
|
|
1440
|
+
return set(), ""
|
|
1441
|
+
|
|
1442
|
+
def _set_seen(self, which: str, events: list[dict[str, str]]) -> None:
|
|
1177
1443
|
p = self.root / STATE_DIR / "seen.json"
|
|
1178
1444
|
p.parent.mkdir(parents=True, exist_ok=True)
|
|
1179
1445
|
try:
|
|
1180
1446
|
data = json.loads(p.read_text())
|
|
1181
1447
|
except (OSError, ValueError):
|
|
1182
1448
|
data = {}
|
|
1183
|
-
|
|
1449
|
+
kept = events[-SEEN_CAP:]
|
|
1450
|
+
# The floor exists ONLY to cover entries that fell out of the kept window. Setting
|
|
1451
|
+
# it whenever anything is remembered would re-create the bug in a new shape: an
|
|
1452
|
+
# entry that arrives with an older timestamp is below the floor, and gets filtered
|
|
1453
|
+
# out as "necessarily seen" when nothing has ever shown it.
|
|
1454
|
+
data[which] = {"fingerprints": [self._fingerprint(e) for e in kept],
|
|
1455
|
+
"floor": kept[0]["ts"] if len(events) > SEEN_CAP and kept else ""}
|
|
1184
1456
|
p.write_text(json.dumps(data))
|
|
1185
1457
|
|
|
1186
1458
|
def activity(self, limit: int = 6, mark_read: bool = True) -> dict[str, Any]:
|
|
@@ -1193,10 +1465,11 @@ class Sync:
|
|
|
1193
1465
|
others = {k: v for k, v in self.all_holdings().items() if v["run"] != self.rid}
|
|
1194
1466
|
|
|
1195
1467
|
signals, _ = self.events("signals")
|
|
1196
|
-
seen = self.
|
|
1197
|
-
fresh =
|
|
1468
|
+
seen, floor = self._seen("signals", signals)
|
|
1469
|
+
fresh = [e for e in signals
|
|
1470
|
+
if self._fingerprint(e) not in seen and e.get("ts", "") >= floor]
|
|
1198
1471
|
if mark_read:
|
|
1199
|
-
self.
|
|
1472
|
+
self._set_seen("signals", signals)
|
|
1200
1473
|
|
|
1201
1474
|
return {"others": others, "signals": signals[-limit:], "new_signals": fresh}
|
|
1202
1475
|
|
|
@@ -1205,20 +1478,35 @@ class Sync:
|
|
|
1205
1478
|
def _claim_targets(self, key: str) -> list[tuple[Path, dict[str, Any]]]:
|
|
1206
1479
|
out = []
|
|
1207
1480
|
for pattern, spec in (self.cfg.get("claimTags") or {}).items():
|
|
1208
|
-
for path in sorted(self.root
|
|
1209
|
-
|
|
1210
|
-
out.append((path, spec))
|
|
1481
|
+
for path in sorted(glob_files(self.root, pattern)):
|
|
1482
|
+
out.append((path, spec))
|
|
1211
1483
|
return out
|
|
1212
1484
|
|
|
1485
|
+
@staticmethod
|
|
1486
|
+
def _split_row(line: str) -> tuple[str, list[str], str] | None:
|
|
1487
|
+
"""(prefix, cells, suffix) — everything needed to put the row back untouched.
|
|
1488
|
+
|
|
1489
|
+
The row used to be rebuilt from its cells alone, so the indentation and the
|
|
1490
|
+
original line ending were dropped: a round-trip left a diff on a shared registry
|
|
1491
|
+
file that nobody made, on the one kind of file agents are told never to touch
|
|
1492
|
+
casually. `SKILL.md` promises `git diff` empty after acquire-then-release, and now
|
|
1493
|
+
the bytes outside the edited cell are carried through rather than reconstructed.
|
|
1494
|
+
"""
|
|
1495
|
+
stripped = line.lstrip()
|
|
1496
|
+
if not stripped.startswith("|"):
|
|
1497
|
+
return None
|
|
1498
|
+
prefix = line[:len(line) - len(stripped)]
|
|
1499
|
+
core = stripped.rstrip()
|
|
1500
|
+
suffix = stripped[len(core):]
|
|
1501
|
+
if core.endswith("|"):
|
|
1502
|
+
core, suffix = core[:-1], "|" + suffix
|
|
1503
|
+
return prefix, core[1:].split("|"), suffix
|
|
1504
|
+
|
|
1213
1505
|
@staticmethod
|
|
1214
1506
|
def _row_cells(line: str) -> list[str] | None:
|
|
1215
1507
|
"""Split a markdown table row, or None if this is not one."""
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
raw = line.strip()
|
|
1219
|
-
if raw.endswith("|"):
|
|
1220
|
-
raw = raw[:-1]
|
|
1221
|
-
return raw[1:].split("|")
|
|
1508
|
+
split = Sync._split_row(line)
|
|
1509
|
+
return split[1] if split else None
|
|
1222
1510
|
|
|
1223
1511
|
# -- branch discipline -------------------------------------------------
|
|
1224
1512
|
|
|
@@ -1343,14 +1631,29 @@ class Sync:
|
|
|
1343
1631
|
rel = path.relative_to(self.root)
|
|
1344
1632
|
if not hits:
|
|
1345
1633
|
continue
|
|
1634
|
+
# On RELEASE the id is the wrong key to search by. `acquire` wrote a marker naming
|
|
1635
|
+
# this run; by the time the work is done the id may appear in rows that did not exist
|
|
1636
|
+
# when it was taken — which is what happened on 2026-08-07, when a task id gained a
|
|
1637
|
+
# second mention mid-run and release refused, leaving `(claimed: r-…)` in a status cell
|
|
1638
|
+
# permanently: a live claim for a lease nobody holds, which is worse than no claim.
|
|
1639
|
+
# The marker is unambiguous however many rows mention the id, so narrow by it first.
|
|
1640
|
+
if holder is None and len(hits) > 1:
|
|
1641
|
+
marker = ((spec.get("held") or "{prev} (claimed: {holder})")
|
|
1642
|
+
.replace("{prev}", "").replace("{holder}", self.rid).strip())
|
|
1643
|
+
if marker:
|
|
1644
|
+
narrowed = [i for i in hits if marker in lines[i]]
|
|
1645
|
+
if len(narrowed) == 1:
|
|
1646
|
+
hits = narrowed
|
|
1647
|
+
|
|
1346
1648
|
if len(hits) > 1:
|
|
1347
1649
|
notes.append(f"{rel}: `{key}` appears in {len(hits)} table rows — refusing "
|
|
1348
1650
|
"to guess which one is the claim. Narrow the pattern or edit by hand")
|
|
1349
1651
|
continue
|
|
1350
1652
|
|
|
1351
1653
|
i = hits[0]
|
|
1352
|
-
|
|
1353
|
-
assert
|
|
1654
|
+
split = self._split_row(lines[i])
|
|
1655
|
+
assert split is not None
|
|
1656
|
+
row_prefix, cells, row_suffix = split
|
|
1354
1657
|
if idx < 0:
|
|
1355
1658
|
idx = len(cells) + idx
|
|
1356
1659
|
if not 0 <= idx < len(cells):
|
|
@@ -1383,7 +1686,7 @@ class Sync:
|
|
|
1383
1686
|
if not state.get(key):
|
|
1384
1687
|
state.pop(key, None)
|
|
1385
1688
|
|
|
1386
|
-
lines[i] = "|" + "|".join(cells) +
|
|
1689
|
+
lines[i] = row_prefix + "|" + "|".join(cells) + row_suffix
|
|
1387
1690
|
tmp = path.with_suffix(path.suffix + ".agent-sync.tmp")
|
|
1388
1691
|
tmp.write_text("".join(lines))
|
|
1389
1692
|
tmp.replace(path)
|
|
@@ -1442,9 +1745,9 @@ class Sync:
|
|
|
1442
1745
|
|
|
1443
1746
|
# -- as-built record and reconciliation ---------------------------------
|
|
1444
1747
|
|
|
1445
|
-
def record(self, text: str, decision: str = "", files: str = "") ->
|
|
1748
|
+
def record(self, text: str, decision: str = "", files: str = "") -> bool:
|
|
1446
1749
|
"""Append what was ACTUALLY built. Not a plan, not an intention."""
|
|
1447
|
-
self._publish("asbuilt", fmt_line(
|
|
1750
|
+
return self._publish("asbuilt", fmt_line(
|
|
1448
1751
|
"asbuilt", decision or "-", self.rid, repo=repo_name(), sha=head_sha(),
|
|
1449
1752
|
files=files.replace("`", "'")[:200],
|
|
1450
1753
|
note=text.replace("`", "'")[:400]))
|
|
@@ -1585,7 +1888,7 @@ class Sync:
|
|
|
1585
1888
|
def guard(self, path: str) -> tuple[bool, str]:
|
|
1586
1889
|
rel = os.path.relpath(os.path.abspath(path), str(self.root))
|
|
1587
1890
|
patterns = self.cfg.get("guardedFiles") or []
|
|
1588
|
-
if not any(
|
|
1891
|
+
if not any(matches_glob(rel, p) for p in patterns):
|
|
1589
1892
|
return True, "not a guarded file"
|
|
1590
1893
|
|
|
1591
1894
|
# A lease is required in every mode. What differs between backends is how
|
|
@@ -1597,29 +1900,20 @@ class Sync:
|
|
|
1597
1900
|
note = "" if self.gated else " (advisory: arbitrated locally only)"
|
|
1598
1901
|
return True, f"held by this run ({', '.join(held)}){note}"
|
|
1599
1902
|
|
|
1903
|
+
# Name the OTHER key, never just the other run. "r-x holds a lease right now"
|
|
1904
|
+
# beside a path reads as "r-x holds this file" — which is not what was checked,
|
|
1905
|
+
# and an agent that repeats it puts a fact in the transcript with no source.
|
|
1600
1906
|
other = self._any_other_holder()
|
|
1601
|
-
who = f"
|
|
1602
|
-
|
|
1907
|
+
who = (f" Another run ({other[0]}) holds {other[1]} — a different task, not this file."
|
|
1908
|
+
if other else "")
|
|
1909
|
+
return False, (f"{rel} is a guarded registry file and this run holds no lease.{who} "
|
|
1603
1910
|
f"Acquire one first: agent_sync.py acquire <TASK-ID>")
|
|
1604
1911
|
|
|
1605
|
-
def _any_other_holder(self) -> str | None:
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
holder = resolve_holder(events, key, now)
|
|
1611
|
-
if holder and holder != self.rid:
|
|
1612
|
-
return holder
|
|
1613
|
-
return None
|
|
1614
|
-
d = self.root / STATE_DIR / "leases"
|
|
1615
|
-
for p in (d.glob("*.lock") if d.exists() else []):
|
|
1616
|
-
try:
|
|
1617
|
-
held = json.loads(p.read_text())
|
|
1618
|
-
except (json.JSONDecodeError, OSError):
|
|
1619
|
-
continue
|
|
1620
|
-
if held.get("run") != self.rid and \
|
|
1621
|
-
time.time() <= parse_iso(held.get("ts", "")) + int(held.get("ttl", self.ttl)):
|
|
1622
|
-
return str(held.get("run"))
|
|
1912
|
+
def _any_other_holder(self) -> tuple[str, str] | None:
|
|
1913
|
+
"""(run, key) of some lease another run holds — both halves, or neither."""
|
|
1914
|
+
for key, holding in sorted(self.all_holdings().items()):
|
|
1915
|
+
if holding.get("run") and holding["run"] != self.rid:
|
|
1916
|
+
return str(holding["run"]), key
|
|
1623
1917
|
return None
|
|
1624
1918
|
|
|
1625
1919
|
# -- board -------------------------------------------------------------
|
|
@@ -2093,11 +2387,27 @@ def cmd_status(_args: argparse.Namespace) -> int:
|
|
|
2093
2387
|
|
|
2094
2388
|
# Who else is in here, and what landed while this run was away. Without this a
|
|
2095
2389
|
# lease only tells an agent it is blocked, never who by or on what.
|
|
2390
|
+
plane_broken = False
|
|
2391
|
+
|
|
2392
|
+
# The two logs this command reports on, checked before it reports on them. With a
|
|
2393
|
+
# local lease the claims log is a record rather than the source of holdings, so
|
|
2394
|
+
# nothing on the awareness path would have touched it — and `status` would print a
|
|
2395
|
+
# confident "other runs: none" over a log that cannot be replayed at all.
|
|
2396
|
+
for which in ("claims", "signals"):
|
|
2397
|
+
try:
|
|
2398
|
+
s.events(which)
|
|
2399
|
+
except Fail as exc:
|
|
2400
|
+
print(f"\n✗ {exc}")
|
|
2401
|
+
plane_broken = True
|
|
2402
|
+
|
|
2096
2403
|
try:
|
|
2097
2404
|
act = s.activity()
|
|
2098
2405
|
except Fail as exc:
|
|
2099
|
-
|
|
2406
|
+
# Not a warning to read past: with the plane unreadable this run cannot see who
|
|
2407
|
+
# else is working, which is the half of coordination that is not the lease.
|
|
2408
|
+
print(f"\n✗ could not read the coordination plane: {exc}")
|
|
2100
2409
|
act = {"others": {}, "signals": [], "new_signals": []}
|
|
2410
|
+
plane_broken = True
|
|
2101
2411
|
|
|
2102
2412
|
if act["others"]:
|
|
2103
2413
|
print("\n Other runs working this project right now:")
|
|
@@ -2131,6 +2441,33 @@ def cmd_status(_args: argparse.Namespace) -> int:
|
|
|
2131
2441
|
if drift:
|
|
2132
2442
|
print(f"\n Mirror drift ({len(drift)} page(s)): regenerate with `board --mirror`")
|
|
2133
2443
|
|
|
2444
|
+
if plane_broken:
|
|
2445
|
+
print("\nNEXT: repair the coordination plane — until it reads, this run is working")
|
|
2446
|
+
print(" blind to every other one.")
|
|
2447
|
+
return 1
|
|
2448
|
+
|
|
2449
|
+
# The same verdict `check` gives, from the command every session actually runs. Two
|
|
2450
|
+
# commands answering one question differently is how a broken setup stays invisible:
|
|
2451
|
+
# `status` used to print "NEXT: acquire a lease" on a project `check` called unhealthy.
|
|
2452
|
+
#
|
|
2453
|
+
# Reported BEFORE the task-pipeline gate, and the order is the point: this is a fact
|
|
2454
|
+
# about the project, that is a fact about the machine. Behind the gate, a defect in
|
|
2455
|
+
# the project stayed invisible on every machine without the dependency installed —
|
|
2456
|
+
# which is every CI runner, and is how this ordering was found.
|
|
2457
|
+
try:
|
|
2458
|
+
_ok, _warn, setup_problems = check_setup(root)
|
|
2459
|
+
except Fail as exc:
|
|
2460
|
+
setup_problems = [str(exc)]
|
|
2461
|
+
if setup_problems:
|
|
2462
|
+
print(f"\n✗ `check` reports {len(setup_problems)} problem(s) with this setup:")
|
|
2463
|
+
for problem in setup_problems[:4]:
|
|
2464
|
+
print(f" · {problem}")
|
|
2465
|
+
if len(setup_problems) > 4:
|
|
2466
|
+
print(f" · … and {len(setup_problems) - 4} more")
|
|
2467
|
+
print("\nNEXT: fix the setup before coordinating on it —")
|
|
2468
|
+
print(" agent_sync.py check")
|
|
2469
|
+
return 1
|
|
2470
|
+
|
|
2134
2471
|
if not pipeline_installed():
|
|
2135
2472
|
print("\n✗ task-pipeline is not installed. agent-sync binds to its stages and")
|
|
2136
2473
|
print(" will not improvise a substitute flow.")
|
|
@@ -2214,12 +2551,12 @@ def cmd_release_id(args: argparse.Namespace) -> int:
|
|
|
2214
2551
|
|
|
2215
2552
|
|
|
2216
2553
|
def cmd_journal(args: argparse.Namespace) -> int:
|
|
2217
|
-
Sync().journal(" ".join(args.text))
|
|
2218
|
-
return 0
|
|
2554
|
+
return 0 if Sync().journal(" ".join(args.text)) else 1
|
|
2219
2555
|
|
|
2220
2556
|
|
|
2221
2557
|
def cmd_signal(args: argparse.Namespace) -> int:
|
|
2222
|
-
Sync().signal(args.dep, args.state)
|
|
2558
|
+
if not Sync().signal(args.dep, args.state):
|
|
2559
|
+
return 1
|
|
2223
2560
|
print(f"{args.dep} → {args.state}")
|
|
2224
2561
|
return 0
|
|
2225
2562
|
|
|
@@ -2252,7 +2589,11 @@ def cmd_board(args: argparse.Namespace) -> int:
|
|
|
2252
2589
|
|
|
2253
2590
|
|
|
2254
2591
|
def cmd_record(args: argparse.Namespace) -> int:
|
|
2255
|
-
|
|
2592
|
+
# Non-zero when the entry did not land. Printing "recorded" over a stderr line saying
|
|
2593
|
+
# the opposite is how an agent ends up reporting an as-built record that does not exist.
|
|
2594
|
+
if not Sync().record(" ".join(args.text), decision=args.decision or "",
|
|
2595
|
+
files=args.files or ""):
|
|
2596
|
+
return 1
|
|
2256
2597
|
print("recorded")
|
|
2257
2598
|
return 0
|
|
2258
2599
|
|
|
@@ -2747,39 +3088,35 @@ def cmd_finish(args: argparse.Namespace) -> int:
|
|
|
2747
3088
|
return 0
|
|
2748
3089
|
|
|
2749
3090
|
|
|
2750
|
-
def
|
|
3091
|
+
def check_setup(root: Path) -> tuple[list[str], list[str], list[str]]:
|
|
2751
3092
|
"""Validate the whole setup, end to end, and refuse to call a broken one healthy.
|
|
2752
3093
|
|
|
2753
3094
|
Every item here failed for real at some point in this tool's own adoption. A glob
|
|
2754
3095
|
that matches nothing, a register pattern that matches nothing, a gate command that
|
|
2755
3096
|
does not exist, a snapshot nobody links — each looks like a working install and
|
|
2756
3097
|
protects nothing.
|
|
3098
|
+
|
|
3099
|
+
Returns `(ok, warn, problems)` rather than printing, because `status` reports the same
|
|
3100
|
+
verdict and the two must not be able to disagree. They did: `status` printed "NEXT:
|
|
3101
|
+
acquire a lease" and exited 0 on a project `check` called NOT healthy — and `status`
|
|
3102
|
+
is the command every session runs, so the defect had a place to hide in plain sight.
|
|
2757
3103
|
"""
|
|
2758
|
-
root = project_root()
|
|
2759
|
-
os.chdir(root)
|
|
2760
|
-
load_env_file(root)
|
|
2761
3104
|
problems: list[str] = []
|
|
2762
3105
|
warn: list[str] = []
|
|
2763
3106
|
ok: list[str] = []
|
|
2764
3107
|
|
|
2765
3108
|
cfg_path = root / CONFIG_PATH
|
|
2766
3109
|
if not cfg_path.exists():
|
|
2767
|
-
|
|
2768
|
-
return 1
|
|
3110
|
+
raise Fail("not initialised — run `adopt`, then `init`")
|
|
2769
3111
|
try:
|
|
2770
3112
|
cfg = json.loads(cfg_path.read_text())
|
|
2771
3113
|
except json.JSONDecodeError as exc:
|
|
2772
|
-
|
|
2773
|
-
return 1
|
|
3114
|
+
raise Fail(f"{CONFIG_PATH} is not valid JSON: {exc}") from exc
|
|
2774
3115
|
ok.append(f"config parses ({CONFIG_PATH})")
|
|
2775
3116
|
|
|
2776
3117
|
if cfg.get("backend") not in ("outline", "fs"):
|
|
2777
3118
|
problems.append(f"backend '{cfg.get('backend')}' is not a known adapter")
|
|
2778
|
-
|
|
2779
|
-
"gated", "idRegisters", "guardedFiles", "claimTags", "gates",
|
|
2780
|
-
"mirror", "setupFile", "leaseBackend", "leaseRemote",
|
|
2781
|
-
"settleSeconds"}
|
|
2782
|
-
for k in sorted(unknown):
|
|
3119
|
+
for k in sorted(set(cfg) - CONFIG_KEYS):
|
|
2783
3120
|
problems.append(f"config key '{k}' is not in the schema — it will be ignored")
|
|
2784
3121
|
|
|
2785
3122
|
# Registers must exist AND their allocation pattern must actually match.
|
|
@@ -2801,9 +3138,10 @@ def cmd_check(_args: argparse.Namespace) -> int:
|
|
|
2801
3138
|
else:
|
|
2802
3139
|
ok.append(f"register {reg} allocates from {spec['file']} ({reg}-{m.group(1)})")
|
|
2803
3140
|
|
|
2804
|
-
# A guard glob that matches nothing protects nothing.
|
|
3141
|
+
# A guard glob that matches nothing protects nothing. Resolved by the same function
|
|
3142
|
+
# the guard itself applies, so the two commands cannot disagree about one pattern.
|
|
2805
3143
|
for pattern in (cfg.get("guardedFiles") or []):
|
|
2806
|
-
hits =
|
|
3144
|
+
hits = glob_files(root, pattern)
|
|
2807
3145
|
if not hits:
|
|
2808
3146
|
problems.append(f"guarded pattern '{pattern}' matches no file — it guards nothing")
|
|
2809
3147
|
if cfg.get("guardedFiles"):
|
|
@@ -2812,7 +3150,7 @@ def cmd_check(_args: argparse.Namespace) -> int:
|
|
|
2812
3150
|
warn.append("no guarded files — nothing requires a lease in this repository")
|
|
2813
3151
|
|
|
2814
3152
|
for pattern, spec in (cfg.get("claimTags") or {}).items():
|
|
2815
|
-
files =
|
|
3153
|
+
files = glob_files(root, pattern)
|
|
2816
3154
|
if not files:
|
|
2817
3155
|
problems.append(f"claimTags pattern '{pattern}' matches no file")
|
|
2818
3156
|
continue
|
|
@@ -2860,8 +3198,14 @@ def cmd_check(_args: argparse.Namespace) -> int:
|
|
|
2860
3198
|
if not mirror.get("sources"):
|
|
2861
3199
|
problems.append("mirror is enabled with no sources — it renders nothing")
|
|
2862
3200
|
|
|
2863
|
-
# Identity and reachability.
|
|
3201
|
+
# Identity and reachability. Which file is in force is reported in every mode: it may
|
|
3202
|
+
# be the local one, a superproject's, or one named by AGENT_SYNC_ENV, and an operator
|
|
3203
|
+
# debugging a degraded run needs to know which of the three answered.
|
|
2864
3204
|
env = find_env_file(root)
|
|
3205
|
+
if env is not None and env.parent != root:
|
|
3206
|
+
ok.append(f"credentials file in force: {env} (outside this repository)")
|
|
3207
|
+
elif env is not None:
|
|
3208
|
+
ok.append(f"credentials file in force: {env}")
|
|
2865
3209
|
if cfg.get("backend") == "outline":
|
|
2866
3210
|
if env is None:
|
|
2867
3211
|
problems.append(f"no {ENV_FILE} found here or in any parent — the backend "
|
|
@@ -2919,18 +3263,32 @@ def cmd_check(_args: argparse.Namespace) -> int:
|
|
|
2919
3263
|
problems.append("no agent instruction file links the snapshot — agents will "
|
|
2920
3264
|
"not find it, and will infer the pipeline instead")
|
|
2921
3265
|
|
|
2922
|
-
#
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
3266
|
+
# Every log this project keeps must be replayable. A log past the unparseable limit is
|
|
3267
|
+
# refused by every reader, so a setup carrying one is broken however well it is wired —
|
|
3268
|
+
# and the failure used to be swallowed here by a bare `except Fail: pass`.
|
|
3269
|
+
try:
|
|
3270
|
+
s = Sync()
|
|
3271
|
+
except Fail as exc:
|
|
3272
|
+
problems.append(f"cannot open the project: {exc}")
|
|
3273
|
+
s = None
|
|
3274
|
+
if s is not None:
|
|
3275
|
+
for which in sorted(LOGS):
|
|
3276
|
+
try:
|
|
3277
|
+
s.events(which)
|
|
3278
|
+
except Fail as exc:
|
|
3279
|
+
problems.append(f"{which} log: {exc}")
|
|
3280
|
+
|
|
3281
|
+
# Baselines: without one, reconcile cannot separate history from new work.
|
|
3282
|
+
if regs:
|
|
3283
|
+
try:
|
|
3284
|
+
ev, _ = s.events("asbuilt")
|
|
3285
|
+
based = {e["key"] for e in ev if e["op"] == "baseline"}
|
|
3286
|
+
for reg in regs:
|
|
3287
|
+
if reg not in based:
|
|
3288
|
+
warn.append(f"register {reg} has no as-built baseline — "
|
|
3289
|
+
"run `reconcile --set-baseline` once")
|
|
3290
|
+
except Fail:
|
|
3291
|
+
pass # already reported above; one line per defect, not two
|
|
2934
3292
|
|
|
2935
3293
|
tracked_state = git("ls-files", "--", STATE_DIR)
|
|
2936
3294
|
if tracked_state:
|
|
@@ -2942,6 +3300,24 @@ def cmd_check(_args: argparse.Namespace) -> int:
|
|
|
2942
3300
|
else:
|
|
2943
3301
|
ok.append(f"{STATE_DIR}/ is not tracked")
|
|
2944
3302
|
|
|
3303
|
+
if cfg.get("settleSeconds") is not None:
|
|
3304
|
+
warn.append("settleSeconds is set but no shipped adapter reads it — it is retained "
|
|
3305
|
+
"for a backend that must wait for writes to become visible, and does "
|
|
3306
|
+
"nothing here")
|
|
3307
|
+
|
|
3308
|
+
return ok, warn, problems
|
|
3309
|
+
|
|
3310
|
+
|
|
3311
|
+
def cmd_check(_args: argparse.Namespace) -> int:
|
|
3312
|
+
root = project_root()
|
|
3313
|
+
os.chdir(root)
|
|
3314
|
+
load_env_file(root)
|
|
3315
|
+
try:
|
|
3316
|
+
ok, warn, problems = check_setup(root)
|
|
3317
|
+
except Fail as exc:
|
|
3318
|
+
print(f"✗ {exc}")
|
|
3319
|
+
return 1
|
|
3320
|
+
|
|
2945
3321
|
for line in ok:
|
|
2946
3322
|
print(f" ✓ {line}")
|
|
2947
3323
|
for line in warn:
|
|
@@ -3007,6 +3383,32 @@ def cmd_merge(args: argparse.Namespace) -> int:
|
|
|
3007
3383
|
|
|
3008
3384
|
git("fetch", "--quiet", "origin", target)
|
|
3009
3385
|
upstream = f"origin/{target}" if git("rev-parse", "--verify", "--quiet", f"origin/{target}") else target
|
|
3386
|
+
|
|
3387
|
+
# The conflict preflight, the diff and the merge must all be against the SAME base.
|
|
3388
|
+
# They were not: everything was measured against `origin/<target>` and the merge was
|
|
3389
|
+
# then made into the LOCAL `<target>`, which nothing advances. So `merge` printed the
|
|
3390
|
+
# staleness it had just measured, printed "✓ merged", wrote a merge-log entry and
|
|
3391
|
+
# released the lease — and the push was rejected as non-fast-forward. The work had not
|
|
3392
|
+
# landed, the log said it had, and the task was free for somebody else to take.
|
|
3393
|
+
if upstream != target:
|
|
3394
|
+
local_exists = bool(git("rev-parse", "--verify", "--quiet", f"refs/heads/{target}"))
|
|
3395
|
+
behind_local = git("rev-list", "--count", f"{target}..{upstream}") if local_exists else "0"
|
|
3396
|
+
ahead_local = git("rev-list", "--count", f"{upstream}..{target}") if local_exists else "0"
|
|
3397
|
+
if local_exists and behind_local not in ("", "0") and ahead_local not in ("", "0"):
|
|
3398
|
+
raise Fail(
|
|
3399
|
+
f"local {target} has diverged from {upstream} — {ahead_local} commit(s) here "
|
|
3400
|
+
f"that are not there, {behind_local} there that are not here. Reconcile it "
|
|
3401
|
+
f"first; merging into it would produce a branch nobody can push")
|
|
3402
|
+
if not local_exists or behind_local not in ("", "0"):
|
|
3403
|
+
moved = subprocess.run(
|
|
3404
|
+
["git", "update-ref", f"refs/heads/{target}",
|
|
3405
|
+
git("rev-parse", upstream)], capture_output=True, text=True)
|
|
3406
|
+
if moved.returncode != 0:
|
|
3407
|
+
raise Fail(f"could not fast-forward {target} to {upstream}: "
|
|
3408
|
+
f"{moved.stderr.strip()[:160]}")
|
|
3409
|
+
if local_exists:
|
|
3410
|
+
print(f" {target} fast-forwarded {behind_local} commit(s) to {upstream}")
|
|
3411
|
+
|
|
3010
3412
|
behind = git("rev-list", "--count", f"{branch}..{upstream}")
|
|
3011
3413
|
changed = [l for l in (git("diff", "--name-only", f"{upstream}...{branch}") or "").splitlines() if l]
|
|
3012
3414
|
stat = git("diff", "--shortstat", f"{upstream}...{branch}") or "no changes"
|
|
@@ -3061,7 +3463,18 @@ def cmd_merge(args: argparse.Namespace) -> int:
|
|
|
3061
3463
|
f"docs(merges): record {branch} → {target}"], capture_output=True)
|
|
3062
3464
|
print(f"✓ recorded in {rel_log}")
|
|
3063
3465
|
|
|
3064
|
-
|
|
3466
|
+
# Only the lease this merge landed. It used to release every lease the run held, which
|
|
3467
|
+
# is a different statement from the one the documentation makes and quietly frees work
|
|
3468
|
+
# that has not landed.
|
|
3469
|
+
held = s.held()
|
|
3470
|
+
if args.key:
|
|
3471
|
+
to_release = [args.key] if args.key in held else []
|
|
3472
|
+
if not to_release and held:
|
|
3473
|
+
print(f"note: this run does not hold {args.key}; leaving "
|
|
3474
|
+
f"{', '.join(held)} held")
|
|
3475
|
+
else:
|
|
3476
|
+
to_release = held
|
|
3477
|
+
for key in to_release:
|
|
3065
3478
|
s.release(key)
|
|
3066
3479
|
print(f"✓ released {key}")
|
|
3067
3480
|
|