@ssheleg/agent-sync 1.19.2 → 1.20.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.
@@ -33,7 +33,7 @@ from datetime import datetime, timezone
33
33
  from pathlib import Path
34
34
  from typing import Any
35
35
 
36
- VERSION = "1.19.2"
36
+ VERSION = "1.20.0"
37
37
 
38
38
  CONFIG_PATH = Path(".claude/agent-sync.json")
39
39
  ENV_FILE = Path(".env.agent-sync")
@@ -92,6 +92,10 @@ MAX_UNPARSEABLE = 0.02
92
92
  DEFAULT_SETTLE = 3.0
93
93
  DEFAULT_TTL = 2700
94
94
  DEFAULT_RENEW = 300
95
+ # How many times an id allocation may lose its race before the tool reports the
96
+ # contention instead of spinning. Each loss means another allocator moved the state
97
+ # between our read and our write — a bounded number of honest retries, never a loop.
98
+ RESERVE_RETRIES = 6
95
99
  # How long a steal section may be held before it is treated as abandoned. It covers two
96
100
  # filesystem calls, so anything longer than this is a crashed process, not slow work.
97
101
  STEAL_GRACE = 30
@@ -1314,11 +1318,25 @@ def spent(entry: dict[str, Any]) -> str:
1314
1318
 
1315
1319
 
1316
1320
  def resolve_reservations(events: list[dict[str, str]], reg: str) -> tuple[int, list[int], list[tuple[str, int]]]:
1317
- """Positional allocation over the log. Returns (base, free_list, assignments)."""
1321
+ """Allocation over the log. Returns (base, free_list, assignments).
1322
+
1323
+ Two kinds of reserve line coexist:
1324
+
1325
+ - `op=reserve` **with a `value=`** — a *receipt*: the number was allocated before
1326
+ the line was written (a git ref compare-and-swap, or a confirmed optimistic
1327
+ append), and replay never renumbers it. Shards arriving late, bases appended
1328
+ afterwards, permuted merge orders — none of them move a value that is already
1329
+ on its line. A receipt whose value is already live lost its race and gets no
1330
+ assignment: its run saw the loss on read-back and appended another line.
1331
+ - **bare** `op=reserve` — the legacy positional claim: free-list head, else
1332
+ `base + served`. Its value is a function of the merged order, which is exactly
1333
+ why every new writer stamps the value instead of leaving it to be computed.
1334
+ """
1318
1335
  base = None
1319
1336
  free: list[int] = []
1320
1337
  served = 0
1321
1338
  assignments: list[tuple[str, int]] = []
1339
+ live: set[int] = set()
1322
1340
  for ev in events:
1323
1341
  if ev["key"] != reg:
1324
1342
  continue
@@ -1341,19 +1359,42 @@ def resolve_reservations(events: list[dict[str, str]], reg: str) -> tuple[int, l
1341
1359
  # re-base exists to prevent, arriving through the other door.
1342
1360
  free = [f for f in free if f >= base]
1343
1361
  continue
1362
+ if ev["op"] == "reserve" and (ev.get("value") or ""):
1363
+ # A receipt is honoured even before any base line: the number is issued,
1364
+ # and ignoring it would let the positional path hand it out again.
1365
+ try:
1366
+ value = int(ev["value"])
1367
+ except ValueError:
1368
+ continue
1369
+ if value in live:
1370
+ continue
1371
+ live.add(value)
1372
+ assignments.append((ev["run"], value))
1373
+ if value in free:
1374
+ free.remove(value)
1375
+ elif base is not None and value >= base + served:
1376
+ # The positional count follows the receipt forward, so the next bare
1377
+ # reserve lands past it instead of on top of it.
1378
+ served = value - base + 1
1379
+ continue
1344
1380
  if base is None:
1345
1381
  continue
1346
1382
  if ev["op"] == "release_id":
1347
1383
  try:
1348
- free.append(int(ev.get("value") or 0))
1384
+ freed = int(ev.get("value") or 0)
1349
1385
  except ValueError:
1350
1386
  pass
1387
+ else:
1388
+ free.append(freed)
1389
+ live.discard(freed)
1351
1390
  elif ev["op"] == "reserve":
1352
1391
  if free:
1353
- assignments.append((ev["run"], free.pop(0)))
1392
+ value = free.pop(0)
1354
1393
  else:
1355
- assignments.append((ev["run"], base + served))
1394
+ value = base + served
1356
1395
  served += 1
1396
+ live.add(value)
1397
+ assignments.append((ev["run"], value))
1357
1398
  return (base or 0), free, assignments
1358
1399
 
1359
1400
 
@@ -1370,10 +1411,67 @@ class Sync:
1370
1411
  self.ttl = int(self.cfg.get("leaseTtlSeconds") or DEFAULT_TTL)
1371
1412
  self._identity: tuple[str, str] | None = None
1372
1413
  self._holders: dict[str, str | None] = {}
1414
+ # The generation THIS OBJECT acquired each key under (FIX-SY-03.02).
1415
+ # In-memory on purpose: a zombie session shares the run id and the
1416
+ # checkout with its replacement — the only thing it does NOT share is
1417
+ # this dict, which is exactly what makes it a fence.
1418
+ self._lease_gen: dict[str, int] = {}
1419
+
1420
+ def capabilities(self) -> dict:
1421
+ """Five SEPARATE capability fields — because `gated` was one boolean
1422
+ answering three unrelated questions (FIX-SY-06.01).
1423
+
1424
+ `lease_scope` where exclusion holds: cross-machine (git) vs
1425
+ machine-local (local). An advisory host must never
1426
+ read as enforced.
1427
+ `enforcement_mode` whether exclusion is REAL here: enforced only when the
1428
+ operator asked for it (cfg gated) AND the lease mode
1429
+ actually guarantees it; advisory otherwise.
1430
+ `awareness_scope` whether other agents can SEE this project's state:
1431
+ shared when the record plane carries a total order,
1432
+ isolated when it does not (a pure-fs project has no
1433
+ shared awareness).
1434
+ `identity_strength` how strongly a run identity is bound: strong when the
1435
+ lease is a cross-machine CAS, weak when it is a local
1436
+ advisory lock.
1437
+ `backend_health` `up` or `failed` — a backend that cannot be reached is
1438
+ NEVER reported active/green; its enforcement collapses
1439
+ to advisory and the failure is named.
1440
+ """
1441
+ # `preflight` is the one cheap live call that proves the backend is
1442
+ # reachable (a local `fs` returns "" and never raises; a cloud adapter
1443
+ # makes one request). A raise means the backend is unreachable RIGHT
1444
+ # NOW, which must collapse enforcement to advisory rather than show
1445
+ # green.
1446
+ health = "up"
1447
+ try:
1448
+ self.adapter.preflight()
1449
+ except Exception:
1450
+ health = "failed"
1451
+ asked = bool(self.cfg.get("gated", True))
1452
+ cross = self.lease_mode == "git"
1453
+ # ENFORCED means a real cross-machine compare-and-swap the operator
1454
+ # asked for, on a reachable backend. A machine-local lock is genuine
1455
+ # exclusion on THIS machine but advisory across machines, so it reports
1456
+ # 'advisory' with lease_scope carrying the local nuance — a host that is
1457
+ # only locally exclusive must never be described to a team as enforced.
1458
+ # A failed backend cannot enforce anything, whatever the config says.
1459
+ enforced = asked and cross and health == "up"
1460
+ return {
1461
+ "lease_scope": "cross-machine" if cross else "machine-local",
1462
+ "enforcement_mode": "enforced" if enforced else "advisory",
1463
+ "awareness_scope": "shared" if getattr(self.adapter, "is_lease_authority", False)
1464
+ else "isolated",
1465
+ "identity_strength": "strong" if (cross and health == "up") else "weak",
1466
+ "backend_health": health,
1467
+ }
1373
1468
 
1374
1469
  @property
1375
1470
  def gated(self) -> bool:
1376
- """Whether exclusion is real — decided by the lease mode, never by the record.
1471
+ """Legacy compatibility SUMMARY over the five capability fields
1472
+ (FIX-SY-06.01). Kept so old callers keep working, but it is derived from
1473
+ `capabilities()` now — it is true only when enforcement is real, so an
1474
+ advisory host and a failed backend both read as NOT gated.
1377
1475
 
1378
1476
  Until 1.2.4 this read the record adapter's capabilities, which stopped deciding
1379
1477
  leases in 1.0.0. Both directions were wrong: `outline` with a local lock reported
@@ -1381,7 +1479,7 @@ class Sync:
1381
1479
  `ungated` while every lease was a genuine cross-machine compare-and-swap. The
1382
1480
  plane carries the record; `leaseBackend` decides the lease.
1383
1481
  """
1384
- return bool(self.cfg.get("gated", True)) and self.lease_mode in LEASE_GUARANTEE
1482
+ return self.capabilities()["enforcement_mode"] == "enforced"
1385
1483
 
1386
1484
  def log_id(self, which: str) -> str:
1387
1485
  """This run's OWN shard. One writer per document, always.
@@ -1499,7 +1597,7 @@ class Sync:
1499
1597
  if held:
1500
1598
  if held.get("run") == self.rid:
1501
1599
  self._note_local(key, json.dumps(held))
1502
- self._touch_renew()
1600
+ self._touch_renew(key)
1503
1601
  return True, self.rid
1504
1602
  alive = time.time() <= parse_iso(held.get("ts", "")) + int(held.get("ttl", self.ttl))
1505
1603
  if alive:
@@ -1532,7 +1630,7 @@ class Sync:
1532
1630
  _s, now_held = self._git_read_lease(key)
1533
1631
  return False, now_held.get("run") or "another run"
1534
1632
  self._note_local(key, payload)
1535
- self._touch_renew()
1633
+ self._touch_renew(key)
1536
1634
  return True, self.rid
1537
1635
 
1538
1636
  def _git_release(self, key: str) -> None:
@@ -1550,6 +1648,107 @@ class Sync:
1550
1648
  print(f"note: could not release {key} on the remote: {r.stderr.strip()[:160]}",
1551
1649
  file=sys.stderr)
1552
1650
 
1651
+ # -- git id allocation: the same compare-and-swap, pointed at a counter -----
1652
+
1653
+ @staticmethod
1654
+ def _id_ref(reg: str) -> str:
1655
+ return "refs/agent-sync/ids/" + re.sub(r"[^A-Za-z0-9._-]+", "-", reg).strip("-")
1656
+
1657
+ def _git_reserve_id(self, reg: str, floor: int, rkey: str | None = None) -> tuple[int, str]:
1658
+ """Allocate the next id for `reg` by compare-and-swap on a remote ref.
1659
+ Returns (value, winning commit sha) — the sha is the receipt's revision.
1660
+
1661
+ The ref's tip commit body records the next free number. Winning the push IS
1662
+ the allocation: the remote accepts exactly one successor per tip, so two
1663
+ concurrent reserves cannot both take one number — the loser is rejected,
1664
+ re-reads the moved tip and takes the next. The value returned is immutable
1665
+ from that moment: no replay recomputes it, and the log line the caller
1666
+ writes afterwards is a receipt, never a claim.
1667
+
1668
+ `rkey` closes the crash window between winning the CAS and writing the
1669
+ receipt: each counter commit carries the reservation key it served and is
1670
+ parented on the tip it replaced, so a retry of the SAME reservation finds
1671
+ its own allocation in the ref's history and returns it — one key, one
1672
+ number, however many times the caller had to come back.
1673
+
1674
+ Retry is bounded by RESERVE_RETRIES: a remote that keeps moving under us is
1675
+ reported as contention, not spun on.
1676
+ """
1677
+ remote, ref = self._git_remote(), self._id_ref(reg)
1678
+ last_err = "push rejected"
1679
+ for _attempt in range(RESERVE_RETRIES):
1680
+ out = git("ls-remote", remote, ref)
1681
+ sha: str | None = None
1682
+ recorded = 0
1683
+ if out:
1684
+ sha = out.split()[0]
1685
+ git("fetch", "-q", remote, f"{ref}:refs/agent-sync/fetched-id")
1686
+ if rkey:
1687
+ # The crash window: allocation won, receipt never written. The
1688
+ # chain remembers which key each number was served to; a bounded
1689
+ # walk suffices, because a retry follows its crash closely.
1690
+ history = git("log", "-50", "--format=%H%x1f%B%x1e",
1691
+ "refs/agent-sync/fetched-id")
1692
+ for entry in history.split("\x1e"):
1693
+ entry = entry.strip()
1694
+ if not entry:
1695
+ continue
1696
+ commit_sha, _, body_text = entry.partition("\x1f")
1697
+ try:
1698
+ served = json.loads(body_text.strip())
1699
+ except (json.JSONDecodeError, ValueError):
1700
+ continue
1701
+ if served.get("rkey") == rkey and served.get("reg") == reg \
1702
+ and served.get("run") == self.rid:
1703
+ return int(served["next"]) - 1, commit_sha.strip()
1704
+ body = git("log", "-1", "--format=%B", sha) or git(
1705
+ "log", "-1", "--format=%B", "refs/agent-sync/fetched-id")
1706
+ try:
1707
+ recorded = int(json.loads(body.strip()).get("next", 0))
1708
+ except (json.JSONDecodeError, ValueError, TypeError, AttributeError):
1709
+ # An unreadable counter never resets allocation: the floor and the
1710
+ # issued receipts below still push it forward.
1711
+ recorded = 0
1712
+ # The register file and the log are floors, for the same reason the log
1713
+ # path consults the register: they know what was issued or written by
1714
+ # every path — including receipts from before this counter existed, and
1715
+ # a person's hand in the file. A floor only ever moves allocation forward.
1716
+ events, _ = self.events("reservations")
1717
+ _b, _f, assignments = resolve_reservations(events, reg)
1718
+ issued_next = max((v for _r, v in assignments), default=-1) + 1
1719
+ value = max(floor, recorded, issued_next)
1720
+ body_fields = {"reg": reg, "next": value + 1, "run": self.rid,
1721
+ "ts": now_iso(), "repo": repo_name(),
1722
+ "host": platform.node()}
1723
+ if rkey:
1724
+ body_fields["rkey"] = rkey
1725
+ payload = json.dumps(body_fields)
1726
+ empty_tree = git("hash-object", "-t", "tree", os.devnull)
1727
+ # Parented on the tip it replaces, so the ref keeps the chain a crashed
1728
+ # retry reads its own allocation back out of.
1729
+ tree_args = ["git", "-c", "user.name=agent-sync", "-c",
1730
+ "user.email=agent-sync@localhost", "commit-tree", empty_tree]
1731
+ if sha:
1732
+ tree_args += ["-p", "refs/agent-sync/fetched-id"]
1733
+ made = subprocess.run(tree_args, input=payload, capture_output=True, text=True)
1734
+ commit = made.stdout.strip()
1735
+ if not commit:
1736
+ detail = (made.stderr or "").strip().splitlines()
1737
+ why = detail[-1] if detail else "no output from git commit-tree"
1738
+ raise Fail(f"could not create the id object — is this a git repository? ({why})")
1739
+ args = ["git", "push", remote, f"{commit}:{ref}"]
1740
+ if sha: # moving an existing counter, and only that
1741
+ args.insert(2, f"--force-with-lease={ref}:{sha}")
1742
+ r = subprocess.run(args, capture_output=True, text=True)
1743
+ if r.returncode == 0:
1744
+ return value, commit
1745
+ err_lines = (r.stderr or "").strip().splitlines()
1746
+ last_err = err_lines[-1][:160] if err_lines else "push rejected"
1747
+ time.sleep(0.1 + random.random() * 0.2)
1748
+ raise Fail(
1749
+ f"reserve {reg}: the id ref on '{remote}' moved {RESERVE_RETRIES} times in a "
1750
+ f"row — another allocator is racing; retry ({last_err})")
1751
+
1553
1752
  @property
1554
1753
  def lease_mode(self) -> str:
1555
1754
  return self.cfg.get("leaseBackend") or "local"
@@ -1563,6 +1762,74 @@ class Sync:
1563
1762
  d.mkdir(parents=True, exist_ok=True)
1564
1763
  return d / f"{re.sub(r'[^A-Za-z0-9_-]', '-', key)}.lock"
1565
1764
 
1765
+ # A lock created empty by O_EXCL and filled by a LATER write has a window
1766
+ # where a competitor reads it as `{}` — not live, therefore stealable — and
1767
+ # steals it while the creator writes on into a now-unlinked inode: two
1768
+ # winners (SY-05). The cure is to PUBLISH an already-filled inode atomically,
1769
+ # so a reader never sees an empty lock, and to treat any empty/partial lock
1770
+ # that does appear as a creation-in-flight (a short grace) rather than as
1771
+ # expired.
1772
+ CREATE_GRACE_SECONDS = 10
1773
+
1774
+ def _publish_lock(self, lock: Path, body: dict) -> bool:
1775
+ """Atomically create `lock` already carrying `body`. Returns False if
1776
+ the lock already exists (someone else won the create). The bytes are
1777
+ written to a temp inode, fsync'd, then `os.link`ed onto the final name
1778
+ — link is a no-replace atomic create of a FULL inode, so no reader ever
1779
+ observes an empty lock."""
1780
+ tmp = lock.with_name(f"{lock.name}.{os.getpid()}.new")
1781
+ try:
1782
+ fd = os.open(str(tmp), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
1783
+ with os.fdopen(fd, "w") as fh:
1784
+ fh.write(json.dumps(body))
1785
+ fh.flush()
1786
+ os.fsync(fh.fileno())
1787
+ try:
1788
+ os.link(str(tmp), str(lock)) # atomic no-replace publish
1789
+ except FileExistsError:
1790
+ return False
1791
+ return True
1792
+ finally:
1793
+ try:
1794
+ os.unlink(str(tmp))
1795
+ except OSError:
1796
+ pass
1797
+
1798
+ def _enter_local_section(self, lock: Path):
1799
+ """The ONE critical section for every LOCAL lease mutation — steal, renew,
1800
+ release. O_EXCL on a second name is the OS-backed mutex between processes
1801
+ on this machine, and one section for every writer is what turns
1802
+ steal-vs-renew from a race into a sequence. Returns the guard path, or
1803
+ None when another run is inside (the caller defers, it never barges). A
1804
+ platform where the primitive itself fails raises `unsupported` out loud —
1805
+ an unlocked fallback would be exclusion by luck (SY-03)."""
1806
+ guard = lock.with_name(lock.name + ".steal")
1807
+ try:
1808
+ if guard.exists() and time.time() - guard.stat().st_mtime > STEAL_GRACE:
1809
+ guard.unlink(missing_ok=True)
1810
+ except OSError:
1811
+ pass
1812
+ try:
1813
+ fd = os.open(str(guard), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
1814
+ except FileExistsError:
1815
+ return None
1816
+ except OSError as exc:
1817
+ raise Fail(
1818
+ "the local lease critical section is unsupported here "
1819
+ f"({exc}) — an unlocked fallback would be exclusion by luck; set "
1820
+ "leaseBackend: \"git\" or configure a cloud backend") from exc
1821
+ os.close(fd)
1822
+ return guard
1823
+
1824
+ @staticmethod
1825
+ def _exit_local_section(guard) -> None:
1826
+ if guard is not None:
1827
+ guard.unlink(missing_ok=True)
1828
+
1829
+ @staticmethod
1830
+ def _key_of(lock: Path) -> str:
1831
+ return lock.name[:-len(".lock")] if lock.name.endswith(".lock") else lock.name
1832
+
1566
1833
  def _steal_expired(self, lock: Path, payload: str) -> bool:
1567
1834
  """Replace an expired lock — reap and create as ONE critical section.
1568
1835
 
@@ -1579,34 +1846,43 @@ class Sync:
1579
1846
  filesystem calls, so its own abandonment grace is short; without one, a crash
1580
1847
  between them would cost the key until somebody deleted a file by hand.
1581
1848
  """
1582
- guard = lock.with_name(lock.name + ".steal")
1583
- try:
1584
- if guard.exists() and time.time() - guard.stat().st_mtime > STEAL_GRACE:
1585
- guard.unlink(missing_ok=True)
1586
- except OSError:
1587
- pass
1849
+ guard = self._enter_local_section(lock)
1850
+ if guard is None:
1851
+ return False # another writer is inside the section
1588
1852
  try:
1589
- fd = os.open(str(guard), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
1590
- except OSError:
1591
- return False # another run is stealing this very lock
1592
- try:
1593
- os.close(fd)
1853
+ raw = ""
1594
1854
  try:
1595
- held = json.loads(lock.read_text())
1855
+ raw = lock.read_text()
1856
+ held = json.loads(raw)
1596
1857
  except (json.JSONDecodeError, OSError):
1597
1858
  held = {}
1598
- if held and time.time() <= parse_iso(held.get("ts", "")) + int(
1859
+ if not held:
1860
+ # Empty or unparseable: a creation in flight, not an expired
1861
+ # lease. Only YIELD it once it is older than the creation grace
1862
+ # — arbitrated by the file's own age, never by "the JSON is
1863
+ # empty so it is free" (SY-05).
1864
+ try:
1865
+ age = time.time() - os.stat(lock).st_mtime
1866
+ except OSError:
1867
+ return False
1868
+ if age < self.CREATE_GRACE_SECONDS:
1869
+ return False # let the creator finish
1870
+ elif time.time() <= parse_iso(held.get("ts", "")) + int(
1599
1871
  held.get("ttl", self.ttl)):
1600
1872
  return False # renewed, or already stolen and live again
1873
+ # The GENERATION moves on every ownership change, never on a renewal —
1874
+ # a reader holding a stale generation is holding a stale ownership.
1875
+ body = json.loads(payload)
1876
+ body["gen"] = int(held.get("gen", 0) or 0) + 1
1877
+ self._lease_gen[self._key_of(lock)] = body["gen"]
1601
1878
  lock.unlink(missing_ok=True)
1602
- fd2 = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
1603
- with os.fdopen(fd2, "w") as fh:
1604
- fh.write(payload)
1879
+ if not self._publish_lock(lock, body):
1880
+ return False # someone published between unlink and link
1605
1881
  return True
1606
1882
  except OSError:
1607
1883
  return False
1608
1884
  finally:
1609
- guard.unlink(missing_ok=True)
1885
+ self._exit_local_section(guard)
1610
1886
 
1611
1887
  def acquire(self, key: str) -> tuple[bool, str | None]:
1612
1888
  """Exclusion comes from an atomic file create; the cloud carries the record.
@@ -1651,6 +1927,21 @@ class Sync:
1651
1927
  except (json.JSONDecodeError, OSError):
1652
1928
  held = {}
1653
1929
  if held.get("run") == self.rid:
1930
+ # An EXPIRED own lease is not refreshed, it is re-taken: the run id
1931
+ # matches, but a replacement session shares the run id — only the
1932
+ # generation bump tells the two apart, and a refresh here would let
1933
+ # the older session's heartbeat keep resurrecting it (FIX-SY-03.02).
1934
+ if time.time() > parse_iso(held.get("ts", "")) + int(
1935
+ held.get("ttl", self.ttl)):
1936
+ if self._steal_expired(lock, payload):
1937
+ self._touch_renew(key)
1938
+ return True, self.rid
1939
+ try:
1940
+ other = json.loads(lock.read_text()).get("run")
1941
+ except (json.JSONDecodeError, OSError):
1942
+ other = None
1943
+ return False, other
1944
+ self._lease_gen[key] = int(held.get("gen", 0) or 0)
1654
1945
  # MOVE THE LOCK'S OWN `ts`, not just the throttle marker. This branch did
1655
1946
  # exactly what `_refresh_lease`'s docstring describes as the bug it exists
1656
1947
  # to have fixed — touch the throttle file and leave the timestamp the lease
@@ -1666,7 +1957,7 @@ class Sync:
1666
1957
  # That is the mechanism behind a lease expiring three times in one run
1667
1958
  # against a 450-step CI job on 2026-09-01.
1668
1959
  self._refresh_lease(key)
1669
- self._touch_renew()
1960
+ self._touch_renew(key)
1670
1961
  return True, self.rid
1671
1962
  if time.time() <= parse_iso(held.get("ts", "")) + int(held.get("ttl", self.ttl)):
1672
1963
  return False, held.get("run")
@@ -1677,18 +1968,17 @@ class Sync:
1677
1968
  other = None
1678
1969
  return False, other
1679
1970
  else:
1680
- try:
1681
- fd = os.open(str(lock), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
1682
- except FileExistsError:
1971
+ # Publish an already-filled lock atomically — no empty window a
1972
+ # competitor could read as free (SY-05).
1973
+ if not self._publish_lock(lock, {**json.loads(payload), "gen": 1}):
1683
1974
  try:
1684
1975
  other = json.loads(lock.read_text()).get("run")
1685
1976
  except (json.JSONDecodeError, OSError):
1686
1977
  other = None
1687
1978
  return False, other
1688
- with os.fdopen(fd, "w") as fh:
1689
- fh.write(payload)
1979
+ self._lease_gen[key] = 1
1690
1980
 
1691
- self._touch_renew()
1981
+ self._touch_renew(key)
1692
1982
  for n in self.write_claim(key, self.rid):
1693
1983
  print(f" {n}")
1694
1984
  # Record it for everyone else to see. A failure here costs visibility, never
@@ -1717,6 +2007,11 @@ class Sync:
1717
2007
  sha, held = self._git_read_lease(key)
1718
2008
  if not sha or held.get("run") != self.rid:
1719
2009
  return False
2010
+ if time.time() > parse_iso(held.get("ts", "")) + int(
2011
+ held.get("ttl", self.ttl)):
2012
+ print(f"note: {key}: this lease expired — a renewal does not "
2013
+ "resurrect it; acquire it again", file=sys.stderr)
2014
+ return False
1720
2015
  payload = json.dumps({**held, "ts": now_iso()})
1721
2016
  empty_tree = git("hash-object", "-t", "tree", os.devnull)
1722
2017
  made = subprocess.run(
@@ -1741,42 +2036,118 @@ class Sync:
1741
2036
  lock = self._local_lock(key)
1742
2037
  if not lock.exists():
1743
2038
  return False
1744
- try:
1745
- held = json.loads(lock.read_text())
1746
- except (json.JSONDecodeError, OSError):
1747
- return False
1748
- if held.get("run") != self.rid:
2039
+ guard = self._enter_local_section(lock)
2040
+ if guard is None:
2041
+ # A steal (or another writer) is inside the section. Deferring is the
2042
+ # serialization: rewriting the timestamp NOW would race the stealer's
2043
+ # own expiry re-read, and two writers on one lock file is the defect.
2044
+ print(f"note: {key}: another writer holds the local section — "
2045
+ "renewal deferred to the next heartbeat", file=sys.stderr)
1749
2046
  return False
1750
- held["ts"] = now_iso()
1751
2047
  tmp = lock.with_name(f"{lock.name}.{os.getpid()}.tmp")
1752
2048
  try:
2049
+ try:
2050
+ held = json.loads(lock.read_text())
2051
+ except (json.JSONDecodeError, OSError):
2052
+ return False
2053
+ if held.get("run") != self.rid:
2054
+ return False
2055
+ # An expired lease is DEAD, whoever's name is on it. Renewing it here
2056
+ # would resurrect what a stealer may already have decided is up for
2057
+ # grabs; the owner of an expired lease acquires again (FIX-SY-03.02).
2058
+ if time.time() > parse_iso(held.get("ts", "")) + int(
2059
+ held.get("ttl", self.ttl)):
2060
+ print(f"note: {key}: this lease expired — a renewal does not "
2061
+ "resurrect it; acquire it again", file=sys.stderr)
2062
+ return False
2063
+ # The generation fence: a replacement session shares the run id, so
2064
+ # the run check above cannot tell the old session from the new one —
2065
+ # only the generation this OBJECT acquired under can. A recorded
2066
+ # generation that no longer matches the lock's means a steal
2067
+ # completed after this session's acquire; its renewal must lose.
2068
+ lock_gen = int(held.get("gen", 0) or 0)
2069
+ expected = self._lease_gen.get(key)
2070
+ if expected is not None and lock_gen != expected:
2071
+ print(f"note: {key}: held under generation {expected}, the lock "
2072
+ f"is at {lock_gen} — a newer owner took it; acquire it "
2073
+ "again", file=sys.stderr)
2074
+ return False
2075
+ held["ts"] = now_iso() # the generation is OWNERSHIP's; a renewal keeps it
1753
2076
  tmp.write_text(json.dumps(held))
1754
2077
  tmp.replace(lock)
1755
2078
  except OSError as exc:
1756
2079
  tmp.unlink(missing_ok=True)
1757
2080
  print(f"note: could not renew {key} ({exc})", file=sys.stderr)
1758
2081
  return False
2082
+ finally:
2083
+ self._exit_local_section(guard)
1759
2084
  return True
1760
2085
 
1761
2086
  def renew(self, key: str | None = None) -> bool:
1762
- marker = self.root / STATE_DIR / "last-renew"
2087
+ """Refresh leases — each against ITS OWN throttle, never a shared one.
2088
+
2089
+ The throttle used to be one file per checkout. Run A touching it every
2090
+ hundred seconds meant run B's heartbeat read "renewed recently" for
2091
+ forty-five minutes straight, refreshed nothing, and B's lease expired
2092
+ under work in progress — one agent's activity suppressing every other
2093
+ run's renewals in the same checkout. The marker is now per (run, key):
2094
+ another agent's activity, and this run's OTHER keys, are invisible here,
2095
+ which is the point.
2096
+ """
1763
2097
  interval = int(self.cfg.get("renewIntervalSeconds") or DEFAULT_RENEW)
1764
- if marker.exists() and time.time() - marker.stat().st_mtime < interval:
2098
+ if key is not None:
2099
+ # An explicit renew answers for THIS key: a real refresh, or the
2100
+ # precise per-key reason there was none. It never hides behind the
2101
+ # heartbeat's throttle — the caller named the key on purpose.
2102
+ if self._refresh_lease(key):
2103
+ if self.adapter.is_lease_authority:
2104
+ self.adapter.log_append(self.log_id("claims"),
2105
+ fmt_line("renew", key, self.rid))
2106
+ self._touch_renew(key)
2107
+ return True
2108
+ age = self._renew_age(key)
2109
+ why = (f"its renewal marker is {int(age)}s old" if age is not None
2110
+ else "no renewal of it is on record for this run")
2111
+ print(f"note: could not renew {key} — this run does not hold it in the "
2112
+ f"lease plane ({why})", file=sys.stderr)
1765
2113
  return False
1766
- keys = [key] if key else self.held()
1767
- if not keys:
1768
- self._touch_renew()
2114
+ due = []
2115
+ for k in self.held():
2116
+ age = self._renew_age(k)
2117
+ if age is None or age >= interval:
2118
+ due.append(k)
2119
+ if not due:
1769
2120
  return False
1770
- renewed = [k for k in keys if self._refresh_lease(k)]
2121
+ renewed = [k for k in due if self._refresh_lease(k)]
1771
2122
  if self.adapter.is_lease_authority and renewed:
1772
2123
  oid = self.log_id("claims")
1773
2124
  for k in renewed:
1774
2125
  self.adapter.log_append(oid, fmt_line("renew", k, self.rid))
1775
- self._touch_renew()
2126
+ for k in renewed:
2127
+ self._touch_renew(k)
1776
2128
  return bool(renewed)
1777
2129
 
1778
- def _touch_renew(self) -> None:
1779
- marker = self.root / STATE_DIR / "last-renew"
2130
+ def _renew_marker(self, key: str) -> Path:
2131
+ safe = re.sub(r"[^A-Za-z0-9._-]+", "-", key).strip("-")
2132
+ return self.root / STATE_DIR / "renew" / f"{self.rid}--{safe}"
2133
+
2134
+ def _renew_age(self, key: str) -> float | None:
2135
+ """Seconds since THIS run last renewed THIS key, or None when it never has.
2136
+ The marker carries its timestamp in its bytes, not its mtime, so the same
2137
+ clock the throttle compares against is the one that wrote it."""
2138
+ try:
2139
+ ts = parse_iso_or_none(self._renew_marker(key).read_text().strip())
2140
+ except OSError:
2141
+ return None
2142
+ if ts is None:
2143
+ return None
2144
+ return time.time() - ts
2145
+
2146
+ def _touch_renew(self, key: str) -> None:
2147
+ """Stamp the (run, key) marker. Called on a successful refresh — and on
2148
+ acquire, whose fresh lease IS a renewal of exactly that key. Never a
2149
+ by-product of unrelated activity: that by-product was the defect."""
2150
+ marker = self._renew_marker(key)
1780
2151
  marker.parent.mkdir(parents=True, exist_ok=True)
1781
2152
  marker.write_text(now_iso())
1782
2153
 
@@ -1836,6 +2207,17 @@ class Sync:
1836
2207
  file=sys.stderr)
1837
2208
  return False
1838
2209
 
2210
+ # Releasing the last TASK key releases this run's resource claims too:
2211
+ # a file claim only ever rides under a task lease (FIX-SY-04.01), and
2212
+ # an orphaned one would hold the registry for a task already finished.
2213
+ if not key.startswith(self.RESOURCE_PREFIX):
2214
+ remaining = [k for k in self.held()
2215
+ if not k.startswith(self.RESOURCE_PREFIX) and k != key]
2216
+ if not remaining:
2217
+ for res in [k for k in self.held()
2218
+ if k.startswith(self.RESOURCE_PREFIX)]:
2219
+ self.release(res)
2220
+
1839
2221
  for n in self.write_claim(key, None):
1840
2222
  print(f" {n}")
1841
2223
  if self.lease_mode == "git":
@@ -2158,53 +2540,92 @@ class Sync:
2158
2540
 
2159
2541
  # -- ids ---------------------------------------------------------------
2160
2542
 
2161
- def reserve(self, reg: str) -> int:
2162
- if not self.adapter.is_lease_authority:
2543
+ def reserve(self, reg: str, rkey: str | None = None) -> int:
2544
+ if not (self.adapter.is_lease_authority or self.lease_is_cross_machine):
2163
2545
  raise Fail(
2164
2546
  f"backend '{self.adapter.name}' cannot reserve ids safely "
2165
- "(atomicAppend is false). Allocate by hand and record it, or configure a "
2166
- "cloud backend. Pretending would hand two agents the same id.")
2167
- # Every shard, not just this run's. `log_id` returns the document THIS run writes,
2168
- # and reading it alone was the whole defect: three runs each replayed a log
2169
- # containing only their own lines, each seeded a base from the register, and each
2170
- # was handed the same number — while `_leaks` on the same data, read merged,
2171
- # reported the truth. Allocation is positional over the WHOLE log or it is nothing.
2547
+ "(atomicAppend is false and the lease backend is not git). Allocate by "
2548
+ "hand and record it, set `leaseBackend: \"git\"`, use `reserve --offline` "
2549
+ "for a namespaced offline id, or configure a cloud backend. Pretending "
2550
+ "would hand two agents the same id.")
2551
+ # A named reservation retried is the SAME reservation. If this run's receipt
2552
+ # for the key is already in the merged log, hand its number back — a retry
2553
+ # that allocates again is how one crash costs two ids.
2554
+ if rkey:
2555
+ events, _ = self.events("reservations")
2556
+ for ev in events:
2557
+ if ev.get("op") == "reserve" and ev.get("key") == reg \
2558
+ and ev.get("run") == self.rid and ev.get("rkey") == rkey \
2559
+ and (ev.get("value") or ""):
2560
+ return int(ev["value"])
2561
+ # The register knows what is actually written, by every path including the ones
2562
+ # that never touch this tool — a person editing the file, another session's Doc
2563
+ # Loop, a merge. It is a **floor**, never a ceiling: it can only push allocation
2564
+ # forward, so honouring it never revokes a live reservation.
2565
+ floor = self._seed_base(reg)
2172
2566
  oid = self.log_id("reservations")
2173
- events, _ = self.events("reservations")
2174
- base, _free, _assign = resolve_reservations(events, reg)
2175
- if not base:
2176
- base = self._seed_base(reg)
2177
- self.adapter.log_append(oid, fmt_line("base", reg, self.rid, value=f"{base:04d}"))
2567
+ if self.lease_is_cross_machine:
2568
+ # Across machines the shards are git files, and a shard another machine has
2569
+ # not pushed yet is invisible here — positional replay over what IS visible
2570
+ # handed two machines the same number. The remote id ref is the one piece of
2571
+ # state both sides must move through, so the compare-and-swap on it is the
2572
+ # allocator; the line below is a receipt of a number already won.
2573
+ value, rev = self._git_reserve_id(reg, floor, rkey)
2574
+ # The receipt names its authority: which backend allocated, at which
2575
+ # revision, for which reservation key. A number nobody can trace to an
2576
+ # authority is a rumour with digits.
2577
+ self.adapter.log_append(
2578
+ oid, fmt_line("reserve", reg, self.rid, value=f"{value:04d}",
2579
+ backend="git", rev=rev[:12], rkey=rkey))
2580
+ return value
2581
+ # Total-order backends (atomicAppend): optimistic claim. Every shard, not just
2582
+ # this run's — `log_id` returns the document THIS run writes, and reading it
2583
+ # alone was the whole defect: three runs each replayed a log containing only
2584
+ # their own lines, and each was handed the same number. The claim carries its
2585
+ # value, so once it wins the read-back it can never be renumbered by a shard
2586
+ # that arrives later or a base appended afterwards.
2587
+ for _attempt in range(RESERVE_RETRIES):
2178
2588
  events, _ = self.events("reservations")
2179
- else:
2180
- # The log knows what *this tool* handed out. The register knows what is actually
2181
- # written, by every path including the ones that never touch this tool — a person
2182
- # editing the file, another session's Doc Loop, a merge. The log alone therefore drifts
2183
- # behind, silently and permanently, and hands out ids that already have a heading.
2184
- #
2185
- # This is the failure mode the whole mechanism exists to prevent, so the register is
2186
- # consulted on every reserve and treated as a **floor**, never as a ceiling: it can only
2187
- # push the allocation forward. Ids this tool reserved but nobody has written yet are not
2188
- # in the register, so honouring it as a floor never revokes a live reservation.
2189
- #
2190
- # Probed rather than computed: the allocator is asked what it *would* hand out next, by
2191
- # resolving a synthetic reserve. That keeps one implementation of the allocation rule
2192
- # instead of a second copy here that can disagree with it.
2193
- floor = self._seed_base(reg)
2194
- probe = events + [{"op": "reserve", "key": reg, "run": "\x00probe", "value": ""}]
2195
- _b, _f, probed = resolve_reservations(probe, reg)
2196
- if probed and probed[-1][1] < floor:
2589
+ base, _free, _assign = resolve_reservations(events, reg)
2590
+ if not base:
2197
2591
  self.adapter.log_append(
2198
2592
  oid, fmt_line("base", reg, self.rid, value=f"{floor:04d}"))
2199
2593
  events, _ = self.events("reservations")
2200
- self.adapter.log_append(oid, fmt_line("reserve", reg, self.rid))
2201
- time.sleep(0.25 + random.random() * 0.15)
2202
- events, _ = self.events("reservations")
2203
- _b, _f, assignments = resolve_reservations(events, reg)
2204
- mine = [v for r, v in assignments if r == self.rid]
2205
- if not mine:
2206
- raise Fail(f"reserve {reg}: the append did not read back — retry")
2207
- return mine[-1]
2594
+ else:
2595
+ # Probed rather than computed: the allocator is asked what it *would*
2596
+ # hand out next, by resolving a synthetic reserve. That keeps one
2597
+ # implementation of the allocation rule instead of a second copy here
2598
+ # that can disagree with it.
2599
+ probe = events + [{"op": "reserve", "key": reg, "run": "\x00probe",
2600
+ "value": ""}]
2601
+ _b, _f, probed = resolve_reservations(probe, reg)
2602
+ if probed and probed[-1][1] < floor:
2603
+ self.adapter.log_append(
2604
+ oid, fmt_line("base", reg, self.rid, value=f"{floor:04d}"))
2605
+ events, _ = self.events("reservations")
2606
+ probe = events + [{"op": "reserve", "key": reg, "run": "\x00probe",
2607
+ "value": ""}]
2608
+ _b, _f, probed = resolve_reservations(probe, reg)
2609
+ if not probed:
2610
+ raise Fail(f"reserve {reg}: could not resolve a candidate — the "
2611
+ "reservations log has no usable base; retry")
2612
+ candidate = probed[-1][1]
2613
+ self.adapter.log_append(
2614
+ oid, fmt_line("reserve", reg, self.rid, value=f"{candidate:04d}",
2615
+ backend="log", rkey=rkey))
2616
+ time.sleep(0.25 + random.random() * 0.15)
2617
+ events, _ = self.events("reservations")
2618
+ _b, _f, assignments = resolve_reservations(events, reg)
2619
+ winners = [r for r, v in assignments if v == candidate]
2620
+ if winners and winners[-1] == self.rid:
2621
+ return candidate
2622
+ if not any(r == self.rid and v == candidate for r, v in assignments) and \
2623
+ not winners:
2624
+ raise Fail(f"reserve {reg}: the append did not read back — retry")
2625
+ # Somebody's receipt for the same number sits earlier in the merged order.
2626
+ # That run keeps it; this one re-reads and takes the next number.
2627
+ raise Fail(f"reserve {reg}: lost the allocation race {RESERVE_RETRIES} times "
2628
+ "in a row — another allocator is racing; retry")
2208
2629
 
2209
2630
  def _seed_base(self, reg: str) -> int:
2210
2631
  spec = (self.cfg.get("idRegisters") or {}).get(reg)
@@ -2229,14 +2650,61 @@ class Sync:
2229
2650
  On a backend that cannot order writes this used to do nothing and print
2230
2651
  "released" anyway. The id stayed a hole the board reports as a leak, and the only
2231
2652
  party who could have fixed that had been told it was handled."""
2232
- if not self.adapter.is_lease_authority:
2653
+ if not (self.adapter.is_lease_authority or self.lease_is_cross_machine):
2233
2654
  raise Fail(
2234
2655
  f"backend '{self.adapter.name}' cannot record a released id "
2235
- "(atomicAppend is false), so nothing was returned to the pool. Note it in "
2236
- f"the register by hand, or configure a backend that can: {reg}-{value}")
2656
+ "(atomicAppend is false and the lease backend is not git), so nothing "
2657
+ "was returned to the pool. Note it in the register by hand, or "
2658
+ f"configure a backend that can: {reg}-{value}")
2237
2659
  self.adapter.log_append(self.log_id("reservations"),
2238
2660
  fmt_line("release_id", reg, self.rid, value=value))
2239
2661
 
2662
+ def reserve_offline(self, reg: str) -> str:
2663
+ """A namespaced id issued with NO global authority — `<REG>-o-<run>-<seq>`.
2664
+
2665
+ Deliberately SHAPED so it cannot collide with the numeric sequence: the
2666
+ global allocator hands out digits, this hands out an `o-` composite keyed
2667
+ by run identity. There is no fake global counter behind it, which is the
2668
+ point — an offline allocator that pretends to know the next number is the
2669
+ two-agents-one-id defect with extra steps. Map it onto a real number later
2670
+ with `map_offline`, append-only."""
2671
+ if (self.cfg.get("idRegisters") or {}).get(reg) is None:
2672
+ raise Fail(f"register '{reg}' is not declared in .claude/agent-sync.json")
2673
+ oid = self.log_id("reservations")
2674
+ events, _ = self.events("reservations")
2675
+ mine = sum(1 for ev in events if ev.get("op") == "reserve_offline"
2676
+ and ev.get("key") == reg and ev.get("run") == self.rid)
2677
+ suffix = re.sub(r"[^a-z0-9]", "", self.rid.lower())[-8:] or "anon"
2678
+ token = f"o-{suffix}-{mine + 1:03d}"
2679
+ self.adapter.log_append(oid, fmt_line("reserve_offline", reg, self.rid, value=token))
2680
+ return f"{reg}-{token}"
2681
+
2682
+ def map_offline(self, reg: str, offline_id: str, number: str) -> None:
2683
+ """Bind an offline id to a real, properly reserved number — append-only.
2684
+
2685
+ The same fact twice is one fact; a DIFFERENT number for a mapped id is
2686
+ refused, never rewritten — the documents already carrying the offline id
2687
+ were written against the first answer."""
2688
+ token = offline_id[len(reg) + 1:] if offline_id.startswith(f"{reg}-") else offline_id
2689
+ if not re.fullmatch(r"\d+", number):
2690
+ raise Fail(f"map_offline binds a NUMBER from `reserve` — {number!r} is not one")
2691
+ events, _ = self.events("reservations")
2692
+ issued = any(ev.get("op") == "reserve_offline" and ev.get("key") == reg
2693
+ and ev.get("value") == token for ev in events)
2694
+ if not issued:
2695
+ raise Fail(f"{reg}-{token} was never issued by reserve_offline — nothing to map")
2696
+ for ev in events:
2697
+ if ev.get("op") == "map_offline" and ev.get("key") == reg \
2698
+ and ev.get("value") == token:
2699
+ if ev.get("mapped") == number:
2700
+ return
2701
+ raise Fail(
2702
+ f"{reg}-{token} is already mapped to {reg}-{ev.get('mapped')} — the "
2703
+ "mapping is append-only; reserve a new number instead of rebinding")
2704
+ self.adapter.log_append(self.log_id("reservations"),
2705
+ fmt_line("map_offline", reg, self.rid,
2706
+ value=token, mapped=number))
2707
+
2240
2708
  # -- journal / signals -------------------------------------------------
2241
2709
 
2242
2710
  def _publish(self, which: str, line: str) -> bool:
@@ -2845,8 +3313,25 @@ class Sync:
2845
3313
 
2846
3314
  # -- guard -------------------------------------------------------------
2847
3315
 
3316
+ RESOURCE_PREFIX = "res--"
3317
+
3318
+ def resource_key(self, path: str) -> str:
3319
+ """Canonical repo identity + canonical path — the FILE's own key
3320
+ (FIX-SY-04.01). A task id names work; this names the thing two tasks
3321
+ would collide on, so two runs editing one register serialize on the
3322
+ register, not on whoever's task id sorts first."""
3323
+ rel = os.path.relpath(os.path.realpath(path), os.path.realpath(str(self.root)))
3324
+ # No dots: the local lock filename sanitizes them, and the key must
3325
+ # round-trip through `held()` byte-identical to its lock's stem.
3326
+ canon = re.sub(r"[^A-Za-z0-9_-]+", "-", rel).strip("-")
3327
+ repo = re.sub(r"[^A-Za-z0-9_-]+", "-", repo_name() or "repo")
3328
+ return f"{self.RESOURCE_PREFIX}{repo}--{canon}"[:120]
3329
+
2848
3330
  def guard(self, path: str) -> tuple[bool, str]:
2849
- rel = os.path.relpath(os.path.abspath(path), str(self.root))
3331
+ # realpath on BOTH sides: canonical identity is the point (SY-04) — a
3332
+ # /var vs /private/var symlink split makes one file two names, and a
3333
+ # guard that sees two names guards neither.
3334
+ rel = os.path.relpath(os.path.realpath(path), os.path.realpath(str(self.root)))
2850
3335
  patterns = self.cfg.get("guardedFiles") or []
2851
3336
  if not any(matches_glob(rel, p) for p in patterns):
2852
3337
  return True, "not a guarded file"
@@ -2855,10 +3340,30 @@ class Sync:
2855
3340
  # strongly it is arbitrated, and that is what `gated` reports — not whether
2856
3341
  # the check runs. A local lock file is genuine mutual exclusion between
2857
3342
  # agents on one machine; it is only across machines that fs cannot arbitrate.
3343
+ #
3344
+ # And a TASK lease is ownership of the task, never of the file
3345
+ # (FIX-SY-04.01): two runs holding two different task ids used to both
3346
+ # pass here and interleave writes to one shared registry. A guarded
3347
+ # file now also takes the file's OWN claim — resource identity =
3348
+ # canonical repo + canonical path — auto-claimed under the task lease,
3349
+ # so a single agent feels nothing while two agents on one file
3350
+ # serialize. Independent files carry independent keys and never
3351
+ # serialize without cause.
2858
3352
  held = self.held()
2859
- if held:
3353
+ task_keys = [k for k in held if not k.startswith(self.RESOURCE_PREFIX)]
3354
+ res = self.resource_key(path)
3355
+ if res in held:
3356
+ return True, f"resource claim held for {rel} ({res})"
3357
+ if task_keys:
3358
+ won, holder = self.acquire(res)
2860
3359
  note = "" if self.gated else " (advisory: arbitrated locally only)"
2861
- return True, f"held by this run ({', '.join(held)}){note}"
3360
+ if won:
3361
+ return True, (f"held by this run ({', '.join(task_keys)}); resource "
3362
+ f"claim taken for {rel}{note}")
3363
+ return False, (f"{rel}: another run ({holder}) holds this FILE's "
3364
+ f"resource claim ({res}) — a task lease authorizes the "
3365
+ f"task, not the file. Wait for the claim to release or "
3366
+ f"expire, then retry.")
2862
3367
 
2863
3368
  # Name the OTHER key, never just the other run. "r-x holds a lease right now"
2864
3369
  # beside a path reads as "r-x holds this file" — which is not what was checked,
@@ -3528,7 +4033,7 @@ def cmd_status(_args: argparse.Namespace) -> int:
3528
4033
  print(" agent_sync.py check")
3529
4034
  return 1
3530
4035
 
3531
- if not pipeline_installed():
4036
+ if not pipeline_installed(cfg=s.cfg):
3532
4037
  print("\n✗ task-pipeline is not installed. agent-sync binds to its stages and")
3533
4038
  print(" will not improvise a substitute flow.")
3534
4039
  print("\nNEXT:\n npx sshlg-skills install")
@@ -3539,13 +4044,41 @@ def cmd_status(_args: argparse.Namespace) -> int:
3539
4044
  return 0
3540
4045
 
3541
4046
 
3542
- def pipeline_installed() -> bool:
3543
- home = Path.home()
3544
- if list(home.glob(".claude/plugins/cache/task-pipeline/**/skills/task-pipeline/SKILL.md")):
3545
- return True
3546
- if (home / ".agents/skills/task-pipeline/SKILL.md").exists():
3547
- return True
3548
- return (home / ".claude/skills/task-pipeline/SKILL.md").exists()
4047
+ # Every host layout task-pipeline can be installed under — NOT just Claude's.
4048
+ # A detector that proves absence from ONE host's layout is wrong on a machine
4049
+ # whose task-pipeline lives in another host's cache (FIX-SY-08.01). The plugin
4050
+ # CACHE glob and the plain-skills path are listed per host; the shared hub is
4051
+ # host-agnostic. New host? add its two lines here, not a branch elsewhere.
4052
+ PIPELINE_HOST_LAYOUTS = (
4053
+ (".claude/plugins/cache/task-pipeline/**/skills/task-pipeline/SKILL.md", None),
4054
+ (".codex/plugins/cache/task-pipeline/**/skills/task-pipeline/SKILL.md", None),
4055
+ (".gemini/plugins/cache/task-pipeline/**/skills/task-pipeline/SKILL.md", None),
4056
+ (None, ".claude/skills/task-pipeline/SKILL.md"),
4057
+ (None, ".codex/skills/task-pipeline/SKILL.md"),
4058
+ (None, ".gemini/skills/task-pipeline/SKILL.md"),
4059
+ (None, ".agents/skills/task-pipeline/SKILL.md"), # the shared hub, host-agnostic
4060
+ )
4061
+
4062
+
4063
+ def pipeline_installed(home: "Path | None" = None, cfg: "dict | None" = None) -> bool:
4064
+ """Whether task-pipeline is reachable to THIS machine, across every host
4065
+ layout — or at an explicit path the operator configured (FIX-SY-08.01).
4066
+
4067
+ `home` is injectable so a test can point at a synthetic HOME with no side
4068
+ effects. An explicit `pipelinePath` in the config wins over discovery: a
4069
+ machine that resolved the skill some other way says so, and the detector
4070
+ does not overrule a stated fact with a filesystem guess.
4071
+ """
4072
+ home = home or Path.home()
4073
+ explicit = (cfg or {}).get("pipelinePath")
4074
+ if explicit:
4075
+ return (Path(explicit) if os.path.isabs(explicit) else home / explicit).exists()
4076
+ for glob_pat, direct in PIPELINE_HOST_LAYOUTS:
4077
+ if glob_pat and list(home.glob(glob_pat)):
4078
+ return True
4079
+ if direct and (home / direct).exists():
4080
+ return True
4081
+ return False
3549
4082
 
3550
4083
 
3551
4084
  def cmd_bootstrap(_args: argparse.Namespace) -> int:
@@ -3605,11 +4138,21 @@ def cmd_release(args: argparse.Namespace) -> int:
3605
4138
 
3606
4139
 
3607
4140
  def cmd_reserve(args: argparse.Namespace) -> int:
3608
- value = Sync().reserve(args.register)
4141
+ s = Sync()
4142
+ if getattr(args, "offline", False):
4143
+ print(s.reserve_offline(args.register))
4144
+ return 0
4145
+ value = s.reserve(args.register, rkey=getattr(args, "rkey", None))
3609
4146
  print(f"{args.register}-{value:04d}")
3610
4147
  return 0
3611
4148
 
3612
4149
 
4150
+ def cmd_map_offline(args: argparse.Namespace) -> int:
4151
+ Sync().map_offline(args.register, args.offline_id, args.number)
4152
+ print(f"mapped {args.offline_id} -> {args.register}-{args.number}")
4153
+ return 0
4154
+
4155
+
3613
4156
  def cmd_release_id(args: argparse.Namespace) -> int:
3614
4157
  Sync().release_id(args.register, args.value)
3615
4158
  print(f"released {args.register}-{args.value}")
@@ -4903,7 +5446,17 @@ def build_parser() -> argparse.ArgumentParser:
4903
5446
 
4904
5447
  rv = sub.add_parser("reserve", help="reserve the next id in a register")
4905
5448
  rv.add_argument("register")
5449
+ rv.add_argument("--key", dest="rkey", default=None,
5450
+ help="reservation key: a retry with the same key returns the SAME id")
5451
+ rv.add_argument("--offline", action="store_true",
5452
+ help="issue a namespaced offline id (no global authority; map it later)")
4906
5453
  rv.set_defaults(fn=cmd_reserve)
5454
+ mo = sub.add_parser("map-offline",
5455
+ help="bind an offline id to a reserved number, append-only")
5456
+ mo.add_argument("register")
5457
+ mo.add_argument("offline_id")
5458
+ mo.add_argument("number")
5459
+ mo.set_defaults(fn=cmd_map_offline)
4907
5460
 
4908
5461
  ri = sub.add_parser("release-id", help="return an id you did not write to git")
4909
5462
  ri.add_argument("register")