@ssheleg/agent-sync 1.12.0 → 1.13.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 CHANGED
@@ -1,3 +1,44 @@
1
+ ## v1.13.0 — the file carried two notions of *held* and they disagreed where it mattered
2
+
3
+ **A lease from a run that died could not be cleared by any command.** Measured in the field:
4
+ three locks past their TTL, one of them by **604x**, and the only way out was deleting the file
5
+ by hand — which is the single thing a coordination tool exists to stop anybody doing. The
6
+ refusal itself was correct; there was simply no path.
7
+
8
+ The cause is one function. `acquire` has always known a TTL runs out — `_steal_expired` exists
9
+ for exactly that. `_lease_holder`, which `release` consults, read **any** lock file as held and
10
+ never looked at the timestamp. So an expired lease was *gone* for `acquire` and *eternal* for
11
+ `release`, and the file never noticed it was answering one question two ways.
12
+
13
+ `_lease_alive()` is now the one definition, used by both. `release` reaps an expired foreign
14
+ lease and **says whose it was**, because the operator asked to release their own lease and is
15
+ getting somebody else's corpse cleared alongside it.
16
+
17
+ **What did not change is the half that matters.** A lease inside its TTL is still refused, and
18
+ that is fixtured beside the reap so the two cannot drift. An unparseable timestamp is treated
19
+ as expired rather than as a licence to hold forever — a corrupt lock was the other way to reach
20
+ the same unclearable state.
21
+
22
+ Fixtures 6 → **9**, each driving the shipped script as a process against a real project.
23
+ Closes #4.
24
+
25
+ ### Also closed by measurement: #1
26
+
27
+ `claimTags` whole-row id matching made the claim unwritable on boards with a `Depends` column —
28
+ filed against **1.3.5**, and fixed by **B-42** on 2026-08-14 without anyone connecting the two.
29
+ Re-run against this version on the exact board shape the issue names (`| id | what | acceptance
30
+ | depends | decisions | status |`, with `T-02` and `T-03` both citing `T-01`):
31
+
32
+ ```
33
+ $ agent_sync.py acquire T-01
34
+ docs/ROADMAP.md: `T-01` claim written through
35
+ | T-01 | … | open (claimed: r-cca8b75db) |
36
+ | T-02 | … | open | ← untouched
37
+ | T-03 | … | open | ← untouched
38
+ ```
39
+
40
+ Closes #1.
41
+
1
42
  ## v1.12.0 — 246 kB of someone else's bytecode, in every install
2
43
 
3
44
  **The published tarball carried a `.pyc` both ignore files were written to exclude.**
@@ -422,6 +463,11 @@ plants and catches 27 distinct defects.
422
463
 
423
464
  ## v1.6.0
424
465
 
466
+ > **Never released on its own.** There is no `v1.6.0` tag and no `1.6.0` on npm,
467
+ > so `npm install @ssheleg/agent-sync@1.6.0` and `git checkout v1.6.0` both fail. This section
468
+ > describes work that shipped inside a later version. The note is here because
469
+ > the section reads as a release (2026-08-17, umbrella `B-71`).
470
+
425
471
  **Four guarantees that were described but not delivered, and the one number now stated once.**
426
472
  1.5.3 stopped the tool reporting things that were untrue; this release makes the properties it
427
473
  claims actually hold, each with a check that has been watched fail against the defect it exists to
@@ -495,6 +541,11 @@ planting each defect back.
495
541
 
496
542
  ## v1.5.3
497
543
 
544
+ > **Never released on its own.** There is no `v1.5.3` tag and no `1.5.3` on npm,
545
+ > so `npm install @ssheleg/agent-sync@1.5.3` and `git checkout v1.5.3` both fail. This section
546
+ > describes work that shipped inside a later version. The note is here because
547
+ > the section reads as a release (2026-08-17, umbrella `B-71`).
548
+
498
549
  **Five surfaces told the caller something that was not true.** An audit on 2026-08-10 ran the
499
550
  commands instead of reading them, and every finding below was reproduced before it was fixed. The
500
551
  validator was green throughout — which is the finding behind the findings, and the reason this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssheleg/agent-sync",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "Let concurrent coding agents share one project without colliding — leases with TTL, race-free id reservation, a run journal and a generated board, over a pluggable knowledge cloud.",
5
5
  "bin": {
6
6
  "agent-sync": "bin/agent-sync.js"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-sync",
3
3
  "displayName": "Agent Sync",
4
- "version": "1.12.0",
4
+ "version": "1.13.0",
5
5
  "description": "Coordination layer for multi-agent repositories — leases with TTL, race-free ID reservation, a run journal, a cross-repo signal feed and a generated board, over a pluggable knowledge cloud.",
6
6
  "author": {
7
7
  "name": "ssheleg",
@@ -4,7 +4,7 @@ description: "Use when several coding agents work one repository at the same tim
4
4
  compatibility: "Requires the task-pipeline skill for its stages (npx sshlg-skills install). Needs python3 3.9+ (stdlib only, HTTP included - nothing to pip install) and bash for the hooks. The knowledge backend is configured per project; with none configured it degrades to git-file leases. Enforcement hooks are Claude Code only - on other agents the same checks run as a self-check."
5
5
  license: MIT
6
6
  metadata:
7
- version: "1.12.0"
7
+ version: "1.13.0"
8
8
  author: ssheleg
9
9
  ---
10
10
 
@@ -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.12.0"
36
+ VERSION = "1.13.0"
37
37
 
38
38
  CONFIG_PATH = Path(".claude/agent-sync.json")
39
39
  ENV_FILE = Path(".env.agent-sync")
@@ -1271,18 +1271,44 @@ class Sync:
1271
1271
  marker.parent.mkdir(parents=True, exist_ok=True)
1272
1272
  marker.write_text(now_iso())
1273
1273
 
1274
+ @staticmethod
1275
+ def _lease_alive(held: dict, default_ttl: int) -> bool:
1276
+ """Is this lease still within its TTL? The ONE definition of held.
1277
+
1278
+ `acquire` has always answered this question — `_steal_expired` exists
1279
+ precisely to take a lease whose TTL has run out. `release` did not ask it,
1280
+ and read any lock file as held. So the file carried two notions of *held*
1281
+ and they disagreed exactly where it mattered: a lease from a run that died
1282
+ was expired for `acquire` and eternal for `release`, and no command could
1283
+ clear it. Measured in the field at **604x** its 2700-second TTL, released
1284
+ only by deleting the file by hand — which is the one thing a coordination
1285
+ tool exists to stop anybody doing.
1286
+ """
1287
+ try:
1288
+ return time.time() <= parse_iso(held.get("ts", "")) + int(held.get("ttl", default_ttl))
1289
+ except (ValueError, TypeError):
1290
+ # An unparseable timestamp is not a licence to hold forever.
1291
+ return False
1292
+
1274
1293
  def _lease_holder(self, key: str) -> str | None:
1275
- """Who holds this lease right now, in whichever plane arbitrates it."""
1294
+ """Who holds this lease right now, in whichever plane arbitrates it.
1295
+
1296
+ `None` for an expired lease as well as an absent one: the TTL is the
1297
+ contract, and a lease past it is not held by anybody.
1298
+ """
1276
1299
  if self.lease_mode == "git":
1277
1300
  sha, held = self._git_read_lease(key)
1278
- return held.get("run") if sha else None
1301
+ if not sha or not self._lease_alive(held, self.ttl):
1302
+ return None
1303
+ return held.get("run")
1279
1304
  lock = self._local_lock(key)
1280
1305
  if not lock.exists():
1281
1306
  return None
1282
1307
  try:
1283
- return json.loads(lock.read_text()).get("run")
1308
+ held = json.loads(lock.read_text())
1284
1309
  except (json.JSONDecodeError, OSError):
1285
1310
  return None
1311
+ return held.get("run") if self._lease_alive(held, self.ttl) else None
1286
1312
 
1287
1313
  def release(self, key: str) -> bool:
1288
1314
  """Release only what this run holds, and say so plainly when it does not.
@@ -1308,10 +1334,20 @@ class Sync:
1308
1334
  lock = self._local_lock(key)
1309
1335
  if lock.exists():
1310
1336
  try:
1311
- if json.loads(lock.read_text()).get("run") in (self.rid, None):
1312
- lock.unlink(missing_ok=True)
1337
+ held = json.loads(lock.read_text())
1313
1338
  except (json.JSONDecodeError, OSError):
1314
1339
  lock.unlink(missing_ok=True)
1340
+ else:
1341
+ owner = held.get("run")
1342
+ if owner in (self.rid, None):
1343
+ lock.unlink(missing_ok=True)
1344
+ elif not self._lease_alive(held, self.ttl):
1345
+ # Reaped, not stolen — and said out loud, because the operator
1346
+ # asked to release THEIR lease and is getting somebody else's
1347
+ # corpse cleared as well.
1348
+ print(f" reaped {key}: expired lease from {owner}, "
1349
+ f"whose run left it past its TTL")
1350
+ lock.unlink(missing_ok=True)
1315
1351
  if self.adapter.is_lease_authority:
1316
1352
  try:
1317
1353
  self.adapter.log_append(self.log_id("claims"),