@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.
- package/CHANGELOG.md +44 -0
- package/package.json +1 -1
- package/plugins/agent-sync/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-sync/hooks/guard.sh +24 -0
- package/plugins/agent-sync/hooks/hooks.json +1 -1
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +44 -43
- package/plugins/agent-sync/skills/agent-sync/references/adapter-contract.md +32 -0
- package/plugins/agent-sync/skills/agent-sync/references/backend-fs.md +5 -0
- package/plugins/agent-sync/skills/agent-sync/references/lease-protocol.md +104 -14
- package/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +656 -103
|
@@ -33,7 +33,7 @@ from datetime import datetime, timezone
|
|
|
33
33
|
from pathlib import Path
|
|
34
34
|
from typing import Any
|
|
35
35
|
|
|
36
|
-
VERSION = "1.
|
|
36
|
+
VERSION = "1.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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
1392
|
+
value = free.pop(0)
|
|
1354
1393
|
else:
|
|
1355
|
-
|
|
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
|
-
"""
|
|
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
|
|
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 =
|
|
1583
|
-
|
|
1584
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1855
|
+
raw = lock.read_text()
|
|
1856
|
+
held = json.loads(raw)
|
|
1596
1857
|
except (json.JSONDecodeError, OSError):
|
|
1597
1858
|
held = {}
|
|
1598
|
-
if
|
|
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
|
-
|
|
1603
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1767
|
-
|
|
1768
|
-
self.
|
|
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
|
|
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
|
-
|
|
2126
|
+
for k in renewed:
|
|
2127
|
+
self._touch_renew(k)
|
|
1776
2128
|
return bool(renewed)
|
|
1777
2129
|
|
|
1778
|
-
def
|
|
1779
|
-
|
|
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
|
|
2166
|
-
"
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
#
|
|
2170
|
-
#
|
|
2171
|
-
#
|
|
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
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
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
|
-
|
|
2180
|
-
|
|
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
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
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
|
|
2236
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
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
|
-
|
|
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")
|