@ssheleg/agent-sync 1.4.3 → 1.7.1

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.
@@ -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.4.3"
36
+ VERSION = "1.7.1"
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
- One credential file therefore serves the whole tree: local root first, then the
195
- superproject git reports, then plain parent directories.
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
- superproject = git("rev-parse", "--show-superproject-working-tree", cwd=root)
202
- if superproject:
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
- if not p.exists():
606
- p.parent.mkdir(parents=True, exist_ok=True)
607
- p.write_text("")
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
- with open(oid, "a") as fh:
612
- fh.write(line.rstrip("\n") + "\n")
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
- return p.read_text() if p.exists() else ""
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
- Path(oid).write_text(text)
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
- return p.read_text() if p.exists() else ""
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
- base = int(ev.get("value") or 0)
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": os.uname().nodename})
865
- empty_tree = git("hash-object", "-t", "tree", "/dev/null")
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 not expired:
1121
+ if time.time() <= parse_iso(held.get("ts", "")) + int(held.get("ttl", self.ttl)):
961
1122
  return False, held.get("run")
962
- lock.unlink(missing_ok=True)
963
-
964
- payload = json.dumps({"run": self.rid, "ts": now_iso(), "ttl": self.ttl,
965
- "repo": repo_name()})
966
- try:
967
- fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
968
- except FileExistsError:
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
- other = json.loads(lock.read_text()).get("run")
971
- except (json.JSONDecodeError, OSError):
972
- other = None
973
- return False, other
974
- with os.fdopen(fd, "w") as fh:
975
- fh.write(payload)
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.adapter.is_lease_authority:
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 keys:
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 True
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, _ = parse_log(self.adapter.log_read(oid))
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, _ = parse_log(self.adapter.log_read(oid))
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, _ = parse_log(self.adapter.log_read(oid))
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
- if self.adapter.is_lease_authority:
1129
- self.adapter.log_append(self.log_id("reservations"),
1130
- fmt_line("release_id", reg, self.rid, value=value))
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) -> None:
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) -> None:
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
- def _watermark(self, which: str) -> int:
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
- return int(json.loads(p.read_text()).get(which, 0))
1431
+ entry = json.loads(p.read_text()).get(which)
1173
1432
  except (OSError, ValueError, AttributeError):
1174
- return 0
1175
-
1176
- def _set_watermark(self, which: str, value: int) -> None:
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
- data[which] = value
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._watermark("signals")
1197
- fresh = signals[seen:] if len(signals) > seen else []
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._set_watermark("signals", len(signals))
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.glob(pattern)):
1209
- if path.is_file():
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
- if not line.lstrip().startswith("|"):
1217
- return None
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
- cells = self._row_cells(lines[i])
1353
- assert cells is not None
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) + "|\n"
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 = "") -> None:
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(Path(rel).match(p) for p in patterns):
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" {other} holds a lease right now" if other else ""
1602
- return False, (f"{rel} is a guarded registry file and this run holds no lease{who}. "
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
- if self.adapter.is_lease_authority:
1607
- events, _ = self.events("claims")
1608
- now = time.time()
1609
- for key in {e["key"] for e in events}:
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 -------------------------------------------------------------
@@ -1800,19 +2094,29 @@ class Sync:
1800
2094
  "## The cycle, per task",
1801
2095
  "",
1802
2096
  "```",
1803
- "statuswho else is working, and what changed while you were away",
2097
+ "merges → what landed while you were away",
2098
+ "status → who else is working, and what changed since you last looked",
1804
2099
  "reconcile → resolve every divergence BEFORE writing code",
1805
- "acquire ID take the lease; the claim tag in git is written through",
2100
+ "branch work happens on one; the integration branch is somebody",
2101
+ " else's stable base",
2102
+ "acquire ID → take the lease. On the integration branch the claim tag is",
2103
+ " written through to git; on any other branch the holder stays",
2104
+ " in the coordination plane, where `status` shows it to everyone",
1806
2105
  " … work …",
1807
2106
  "record → what you ACTUALLY built, with the decision id and files",
1808
2107
  " … update the git documents in the same change …",
1809
2108
  "reconcile → check both sides again",
1810
2109
  "board → regenerate the shared view",
1811
- "release ID on every path, including failure",
2110
+ "merge --key land the branch: conflicts checked first, the merge recorded,",
2111
+ " that lease released. Without a branch, `release ID` by hand",
2112
+ " — on every path, including failure",
1812
2113
  "```",
1813
2114
  "",
2115
+ f"This project's integration branch is `{self.integration_branch}`.",
2116
+ "",
1814
2117
  "Full doctrine ships with the skill: `references/two-sources.md`,",
1815
- "`references/lease-protocol.md`, `references/pipeline-binding.md`.",
2118
+ "`references/lease-protocol.md`, `references/branching.md`,",
2119
+ "`references/roadmap.md`, `references/pipeline-binding.md`.",
1816
2120
  ]
1817
2121
  return "\n".join(L) + "\n"
1818
2122
 
@@ -2093,11 +2397,27 @@ def cmd_status(_args: argparse.Namespace) -> int:
2093
2397
 
2094
2398
  # Who else is in here, and what landed while this run was away. Without this a
2095
2399
  # lease only tells an agent it is blocked, never who by or on what.
2400
+ plane_broken = False
2401
+
2402
+ # The two logs this command reports on, checked before it reports on them. With a
2403
+ # local lease the claims log is a record rather than the source of holdings, so
2404
+ # nothing on the awareness path would have touched it — and `status` would print a
2405
+ # confident "other runs: none" over a log that cannot be replayed at all.
2406
+ for which in ("claims", "signals"):
2407
+ try:
2408
+ s.events(which)
2409
+ except Fail as exc:
2410
+ print(f"\n✗ {exc}")
2411
+ plane_broken = True
2412
+
2096
2413
  try:
2097
2414
  act = s.activity()
2098
2415
  except Fail as exc:
2099
- print(f"\n⚠ could not read the coordination plane: {exc}")
2416
+ # Not a warning to read past: with the plane unreadable this run cannot see who
2417
+ # else is working, which is the half of coordination that is not the lease.
2418
+ print(f"\n✗ could not read the coordination plane: {exc}")
2100
2419
  act = {"others": {}, "signals": [], "new_signals": []}
2420
+ plane_broken = True
2101
2421
 
2102
2422
  if act["others"]:
2103
2423
  print("\n Other runs working this project right now:")
@@ -2131,6 +2451,33 @@ def cmd_status(_args: argparse.Namespace) -> int:
2131
2451
  if drift:
2132
2452
  print(f"\n Mirror drift ({len(drift)} page(s)): regenerate with `board --mirror`")
2133
2453
 
2454
+ if plane_broken:
2455
+ print("\nNEXT: repair the coordination plane — until it reads, this run is working")
2456
+ print(" blind to every other one.")
2457
+ return 1
2458
+
2459
+ # The same verdict `check` gives, from the command every session actually runs. Two
2460
+ # commands answering one question differently is how a broken setup stays invisible:
2461
+ # `status` used to print "NEXT: acquire a lease" on a project `check` called unhealthy.
2462
+ #
2463
+ # Reported BEFORE the task-pipeline gate, and the order is the point: this is a fact
2464
+ # about the project, that is a fact about the machine. Behind the gate, a defect in
2465
+ # the project stayed invisible on every machine without the dependency installed —
2466
+ # which is every CI runner, and is how this ordering was found.
2467
+ try:
2468
+ _ok, _warn, setup_problems = check_setup(root)
2469
+ except Fail as exc:
2470
+ setup_problems = [str(exc)]
2471
+ if setup_problems:
2472
+ print(f"\n✗ `check` reports {len(setup_problems)} problem(s) with this setup:")
2473
+ for problem in setup_problems[:4]:
2474
+ print(f" · {problem}")
2475
+ if len(setup_problems) > 4:
2476
+ print(f" · … and {len(setup_problems) - 4} more")
2477
+ print("\nNEXT: fix the setup before coordinating on it —")
2478
+ print(" agent_sync.py check")
2479
+ return 1
2480
+
2134
2481
  if not pipeline_installed():
2135
2482
  print("\n✗ task-pipeline is not installed. agent-sync binds to its stages and")
2136
2483
  print(" will not improvise a substitute flow.")
@@ -2214,12 +2561,12 @@ def cmd_release_id(args: argparse.Namespace) -> int:
2214
2561
 
2215
2562
 
2216
2563
  def cmd_journal(args: argparse.Namespace) -> int:
2217
- Sync().journal(" ".join(args.text))
2218
- return 0
2564
+ return 0 if Sync().journal(" ".join(args.text)) else 1
2219
2565
 
2220
2566
 
2221
2567
  def cmd_signal(args: argparse.Namespace) -> int:
2222
- Sync().signal(args.dep, args.state)
2568
+ if not Sync().signal(args.dep, args.state):
2569
+ return 1
2223
2570
  print(f"{args.dep} → {args.state}")
2224
2571
  return 0
2225
2572
 
@@ -2252,7 +2599,11 @@ def cmd_board(args: argparse.Namespace) -> int:
2252
2599
 
2253
2600
 
2254
2601
  def cmd_record(args: argparse.Namespace) -> int:
2255
- Sync().record(" ".join(args.text), decision=args.decision or "", files=args.files or "")
2602
+ # Non-zero when the entry did not land. Printing "recorded" over a stderr line saying
2603
+ # the opposite is how an agent ends up reporting an as-built record that does not exist.
2604
+ if not Sync().record(" ".join(args.text), decision=args.decision or "",
2605
+ files=args.files or ""):
2606
+ return 1
2256
2607
  print("recorded")
2257
2608
  return 0
2258
2609
 
@@ -2548,31 +2899,24 @@ annotate the old entry's status line. The body of the old entry stays as history
2548
2899
 
2549
2900
  AGENTS_SEED = """# AGENTS.md — working protocol
2550
2901
 
2551
- **Read [`{snapshot}`]({snapshot}) first.** It is generated from the live configuration and
2552
- states how documentation and coordination work here: which registers exist, which files
2553
- need a lease, which gates run, what is written where, and what is never deleted.
2554
-
2555
- ## Before you write anything
2556
-
2557
- Several agents may work this repository at once.
2558
-
2559
- 1. `agent-sync status` — who else is working, and what changed while you were away.
2560
- 2. `agent-sync reconcile` — the git documents say how it *should* be, the as-built record
2561
- says how it *is*. Resolve every divergence **before** writing code.
2562
- 3. `agent-sync acquire <TASK-ID>` — the guarded registers refuse an unleased write.
2563
- 4. Reserve any new id with `agent-sync reserve <REGISTER>` before writing it.
2564
-
2565
- ## When you finish
2902
+ **Read [`{snapshot}`]({snapshot}) first, and follow the cycle it states.** That file is
2903
+ generated from the live configuration: which registers exist, which files need a lease,
2904
+ which gates run, what is written where, what is never deleted, and the order to do it in.
2566
2905
 
2567
- 1. `agent-sync record` what you actually built, with the decision id and the files.
2568
- 2. Update the git documents in the **same** change.
2569
- 3. `agent-sync reconcile` again, then `agent-sync board`.
2570
- 4. `agent-sync release <TASK-ID>` on every path, including failure.
2906
+ This file deliberately does **not** restate that cycle. It is seeded once and never
2907
+ overwritten, so a copy of the protocol here would be frozen on the day the project was
2908
+ created while the tool moved on — and the two would disagree in front of an agent with no
2909
+ way to tell which is current. One fact, one home; the home is the generated snapshot,
2910
+ because it can be regenerated and `agent-sync check` fails when it goes stale.
2571
2911
 
2572
- ## The one rule that matters most
2912
+ ## The three that are true in every project
2573
2913
 
2574
- **No decision lives only in chat.** Record it in the decision register, propagate it to
2575
- every document it affects, and commit referencing the id.
2914
+ 1. **Several agents may work this repository at once.** `agent-sync status` before you
2915
+ start: who holds what, and what changed since you last looked.
2916
+ 2. **Work on a branch, and land it with `agent-sync merge`.** The integration branch is
2917
+ somebody else's stable base — nothing about work in flight is committed there.
2918
+ 3. **No decision lives only in chat.** Record it in the decision register, propagate it to
2919
+ every document it affects, and commit referencing the id.
2576
2920
  """
2577
2921
 
2578
2922
 
@@ -2747,43 +3091,52 @@ def cmd_finish(args: argparse.Namespace) -> int:
2747
3091
  return 0
2748
3092
 
2749
3093
 
2750
- def cmd_check(_args: argparse.Namespace) -> int:
3094
+ def check_setup(root: Path) -> tuple[list[str], list[str], list[str]]:
2751
3095
  """Validate the whole setup, end to end, and refuse to call a broken one healthy.
2752
3096
 
2753
3097
  Every item here failed for real at some point in this tool's own adoption. A glob
2754
3098
  that matches nothing, a register pattern that matches nothing, a gate command that
2755
3099
  does not exist, a snapshot nobody links — each looks like a working install and
2756
3100
  protects nothing.
3101
+
3102
+ Returns `(ok, warn, problems)` rather than printing, because `status` reports the same
3103
+ verdict and the two must not be able to disagree. They did: `status` printed "NEXT:
3104
+ acquire a lease" and exited 0 on a project `check` called NOT healthy — and `status`
3105
+ is the command every session runs, so the defect had a place to hide in plain sight.
2757
3106
  """
2758
- root = project_root()
2759
- os.chdir(root)
2760
- load_env_file(root)
2761
3107
  problems: list[str] = []
2762
3108
  warn: list[str] = []
2763
3109
  ok: list[str] = []
2764
3110
 
2765
3111
  cfg_path = root / CONFIG_PATH
2766
3112
  if not cfg_path.exists():
2767
- print("not initialised — run `adopt`, then `init`")
2768
- return 1
3113
+ raise Fail("not initialised — run `adopt`, then `init`")
2769
3114
  try:
2770
3115
  cfg = json.loads(cfg_path.read_text())
2771
3116
  except json.JSONDecodeError as exc:
2772
- print(f"{CONFIG_PATH} is not valid JSON: {exc}")
2773
- return 1
3117
+ raise Fail(f"{CONFIG_PATH} is not valid JSON: {exc}") from exc
2774
3118
  ok.append(f"config parses ({CONFIG_PATH})")
2775
3119
 
2776
3120
  if cfg.get("backend") not in ("outline", "fs"):
2777
3121
  problems.append(f"backend '{cfg.get('backend')}' is not a known adapter")
2778
- unknown = set(cfg) - {"$schema", "backend", "leaseTtlSeconds", "renewIntervalSeconds",
2779
- "gated", "idRegisters", "guardedFiles", "claimTags", "gates",
2780
- "mirror", "setupFile", "leaseBackend", "leaseRemote",
2781
- "settleSeconds"}
2782
- for k in sorted(unknown):
3122
+ for k in sorted(set(cfg) - CONFIG_KEYS):
2783
3123
  problems.append(f"config key '{k}' is not in the schema — it will be ignored")
2784
3124
 
2785
- # Registers must exist AND their allocation pattern must actually match.
3125
+ # Registers must exist, their allocation pattern must match — and the backend must be
3126
+ # able to hand an id out at all. A register declared on a plane whose `reserve` always
3127
+ # raises is a rule that protects nothing, which is the exact thing `check` promises to
3128
+ # refuse: the generated snapshot then instructs every agent to run `reserve <REG>`, and
3129
+ # the command cannot succeed in that project.
2786
3130
  regs = cfg.get("idRegisters") or {}
3131
+ if regs:
3132
+ backend = os.environ.get("AGENT_SYNC_BACKEND") or cfg.get("backend") or "fs"
3133
+ probe = make_adapter(cfg, root)
3134
+ if not probe.is_lease_authority:
3135
+ problems.append(
3136
+ f"{len(regs)} id register(s) declared, but backend '{probe.name}' cannot "
3137
+ f"reserve ids (atomicAppend is false){' — the configured backend is ' + backend + ' and it is not reachable, so runs degrade to fs' if backend != probe.name else ''}. "
3138
+ "`reserve` fails every time here; either configure a backend that can "
3139
+ "allocate, or remove the registers and allocate them in the parent repository")
2787
3140
  for reg, spec in sorted(regs.items()):
2788
3141
  f = root / spec.get("file", "")
2789
3142
  if not f.exists():
@@ -2801,9 +3154,10 @@ def cmd_check(_args: argparse.Namespace) -> int:
2801
3154
  else:
2802
3155
  ok.append(f"register {reg} allocates from {spec['file']} ({reg}-{m.group(1)})")
2803
3156
 
2804
- # A guard glob that matches nothing protects nothing.
3157
+ # A guard glob that matches nothing protects nothing. Resolved by the same function
3158
+ # the guard itself applies, so the two commands cannot disagree about one pattern.
2805
3159
  for pattern in (cfg.get("guardedFiles") or []):
2806
- hits = [q for q in root.glob(pattern) if q.is_file()]
3160
+ hits = glob_files(root, pattern)
2807
3161
  if not hits:
2808
3162
  problems.append(f"guarded pattern '{pattern}' matches no file — it guards nothing")
2809
3163
  if cfg.get("guardedFiles"):
@@ -2812,7 +3166,7 @@ def cmd_check(_args: argparse.Namespace) -> int:
2812
3166
  warn.append("no guarded files — nothing requires a lease in this repository")
2813
3167
 
2814
3168
  for pattern, spec in (cfg.get("claimTags") or {}).items():
2815
- files = [q for q in root.glob(pattern) if q.is_file()]
3169
+ files = glob_files(root, pattern)
2816
3170
  if not files:
2817
3171
  problems.append(f"claimTags pattern '{pattern}' matches no file")
2818
3172
  continue
@@ -2860,8 +3214,14 @@ def cmd_check(_args: argparse.Namespace) -> int:
2860
3214
  if not mirror.get("sources"):
2861
3215
  problems.append("mirror is enabled with no sources — it renders nothing")
2862
3216
 
2863
- # Identity and reachability.
3217
+ # Identity and reachability. Which file is in force is reported in every mode: it may
3218
+ # be the local one, a superproject's, or one named by AGENT_SYNC_ENV, and an operator
3219
+ # debugging a degraded run needs to know which of the three answered.
2864
3220
  env = find_env_file(root)
3221
+ if env is not None and env.parent != root:
3222
+ ok.append(f"credentials file in force: {env} (outside this repository)")
3223
+ elif env is not None:
3224
+ ok.append(f"credentials file in force: {env}")
2865
3225
  if cfg.get("backend") == "outline":
2866
3226
  if env is None:
2867
3227
  problems.append(f"no {ENV_FILE} found here or in any parent — the backend "
@@ -2919,18 +3279,32 @@ def cmd_check(_args: argparse.Namespace) -> int:
2919
3279
  problems.append("no agent instruction file links the snapshot — agents will "
2920
3280
  "not find it, and will infer the pipeline instead")
2921
3281
 
2922
- # Baselines: without one, reconcile cannot separate history from new work.
2923
- if regs:
2924
- try:
2925
- s = Sync()
2926
- ev, _ = s.events("asbuilt")
2927
- based = {e["key"] for e in ev if e["op"] == "baseline"}
2928
- for reg in regs:
2929
- if reg not in based:
2930
- warn.append(f"register {reg} has no as-built baseline — "
2931
- "run `reconcile --set-baseline` once")
2932
- except Fail:
2933
- pass
3282
+ # Every log this project keeps must be replayable. A log past the unparseable limit is
3283
+ # refused by every reader, so a setup carrying one is broken however well it is wired —
3284
+ # and the failure used to be swallowed here by a bare `except Fail: pass`.
3285
+ try:
3286
+ s = Sync()
3287
+ except Fail as exc:
3288
+ problems.append(f"cannot open the project: {exc}")
3289
+ s = None
3290
+ if s is not None:
3291
+ for which in sorted(LOGS):
3292
+ try:
3293
+ s.events(which)
3294
+ except Fail as exc:
3295
+ problems.append(f"{which} log: {exc}")
3296
+
3297
+ # Baselines: without one, reconcile cannot separate history from new work.
3298
+ if regs:
3299
+ try:
3300
+ ev, _ = s.events("asbuilt")
3301
+ based = {e["key"] for e in ev if e["op"] == "baseline"}
3302
+ for reg in regs:
3303
+ if reg not in based:
3304
+ warn.append(f"register {reg} has no as-built baseline — "
3305
+ "run `reconcile --set-baseline` once")
3306
+ except Fail:
3307
+ pass # already reported above; one line per defect, not two
2934
3308
 
2935
3309
  tracked_state = git("ls-files", "--", STATE_DIR)
2936
3310
  if tracked_state:
@@ -2942,6 +3316,24 @@ def cmd_check(_args: argparse.Namespace) -> int:
2942
3316
  else:
2943
3317
  ok.append(f"{STATE_DIR}/ is not tracked")
2944
3318
 
3319
+ if cfg.get("settleSeconds") is not None:
3320
+ warn.append("settleSeconds is set but no shipped adapter reads it — it is retained "
3321
+ "for a backend that must wait for writes to become visible, and does "
3322
+ "nothing here")
3323
+
3324
+ return ok, warn, problems
3325
+
3326
+
3327
+ def cmd_check(_args: argparse.Namespace) -> int:
3328
+ root = project_root()
3329
+ os.chdir(root)
3330
+ load_env_file(root)
3331
+ try:
3332
+ ok, warn, problems = check_setup(root)
3333
+ except Fail as exc:
3334
+ print(f"✗ {exc}")
3335
+ return 1
3336
+
2945
3337
  for line in ok:
2946
3338
  print(f" ✓ {line}")
2947
3339
  for line in warn:
@@ -3007,6 +3399,32 @@ def cmd_merge(args: argparse.Namespace) -> int:
3007
3399
 
3008
3400
  git("fetch", "--quiet", "origin", target)
3009
3401
  upstream = f"origin/{target}" if git("rev-parse", "--verify", "--quiet", f"origin/{target}") else target
3402
+
3403
+ # The conflict preflight, the diff and the merge must all be against the SAME base.
3404
+ # They were not: everything was measured against `origin/<target>` and the merge was
3405
+ # then made into the LOCAL `<target>`, which nothing advances. So `merge` printed the
3406
+ # staleness it had just measured, printed "✓ merged", wrote a merge-log entry and
3407
+ # released the lease — and the push was rejected as non-fast-forward. The work had not
3408
+ # landed, the log said it had, and the task was free for somebody else to take.
3409
+ if upstream != target:
3410
+ local_exists = bool(git("rev-parse", "--verify", "--quiet", f"refs/heads/{target}"))
3411
+ behind_local = git("rev-list", "--count", f"{target}..{upstream}") if local_exists else "0"
3412
+ ahead_local = git("rev-list", "--count", f"{upstream}..{target}") if local_exists else "0"
3413
+ if local_exists and behind_local not in ("", "0") and ahead_local not in ("", "0"):
3414
+ raise Fail(
3415
+ f"local {target} has diverged from {upstream} — {ahead_local} commit(s) here "
3416
+ f"that are not there, {behind_local} there that are not here. Reconcile it "
3417
+ f"first; merging into it would produce a branch nobody can push")
3418
+ if not local_exists or behind_local not in ("", "0"):
3419
+ moved = subprocess.run(
3420
+ ["git", "update-ref", f"refs/heads/{target}",
3421
+ git("rev-parse", upstream)], capture_output=True, text=True)
3422
+ if moved.returncode != 0:
3423
+ raise Fail(f"could not fast-forward {target} to {upstream}: "
3424
+ f"{moved.stderr.strip()[:160]}")
3425
+ if local_exists:
3426
+ print(f" {target} fast-forwarded {behind_local} commit(s) to {upstream}")
3427
+
3010
3428
  behind = git("rev-list", "--count", f"{branch}..{upstream}")
3011
3429
  changed = [l for l in (git("diff", "--name-only", f"{upstream}...{branch}") or "").splitlines() if l]
3012
3430
  stat = git("diff", "--shortstat", f"{upstream}...{branch}") or "no changes"
@@ -3061,7 +3479,18 @@ def cmd_merge(args: argparse.Namespace) -> int:
3061
3479
  f"docs(merges): record {branch} → {target}"], capture_output=True)
3062
3480
  print(f"✓ recorded in {rel_log}")
3063
3481
 
3064
- for key in (s.held() or []):
3482
+ # Only the lease this merge landed. It used to release every lease the run held, which
3483
+ # is a different statement from the one the documentation makes and quietly frees work
3484
+ # that has not landed.
3485
+ held = s.held()
3486
+ if args.key:
3487
+ to_release = [args.key] if args.key in held else []
3488
+ if not to_release and held:
3489
+ print(f"note: this run does not hold {args.key}; leaving "
3490
+ f"{', '.join(held)} held")
3491
+ else:
3492
+ to_release = held
3493
+ for key in to_release:
3065
3494
  s.release(key)
3066
3495
  print(f"✓ released {key}")
3067
3496