@ssheleg/agent-sync 1.20.1 → 1.20.2

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,32 @@
1
+ ## v1.20.2 — the override that could not reach the plane the state was on
2
+
3
+ **161 expired lease refs on one remote, from two runs that ended five days earlier, and
4
+ no command in this tool could clear any of them.** Measured on the operator's machine
5
+ 2026-09-14: `residue` listed every one, `reap` left them alone (correctly — a ref in a
6
+ dead run's name is `foreign`), and `reap --i-own-this`, the one path a person has for
7
+ exactly this, answered *there is no lock by that name in this checkout*. True, and
8
+ useless: in git mode the authority is `refs/agent-sync/leases/*` on the REMOTE, and the
9
+ override walked the local lock directory only.
10
+
11
+ This is AS-01b returning on the other plane. That row closed *"expired locks accumulate
12
+ with no path out for anybody"* for the filesystem; the same sentence was true of the git
13
+ plane the whole time, and the tool that reports it could not act on it.
14
+
15
+ - `_reap_by_operator_decision` now reads **both planes** and collects entries per key as a
16
+ LIST — a key can be a lock file here AND a ref there, and clearing one while calling the
17
+ key done is how the ref survived every sweep that ran. A git entry is deleted through
18
+ `git_reap`'s existing `--force-with-lease` compare-and-swap and proved gone by a second
19
+ `ls-remote`, never by the push's exit code.
20
+ - **Every refusal the override had, it keeps**, now per plane: a LIVE lease is refused and
21
+ named, a key nobody holds is reported rather than guessed at, the destroyed payload is
22
+ printed with its plane so the decision stays auditable, and the journal line carries it.
23
+ - **A remote that cannot be read says so** instead of answering "no such lock" — that
24
+ answer reads as *there is nothing to clear* over state that may well be there.
25
+
26
+ Three cases in `test/claim_cell_test.py` (27 total): the override clears a dead run's ref
27
+ and proves it gone, a live ref is still refused, and an unreachable remote does not read
28
+ as a missing key.
29
+
1
30
  ## v1.20.1 — the filter that was never read, and the check that would have said so
2
31
 
3
32
  Claude Code 2.1.270 prints `agent-sync: hooks.json: unknown key "if" in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ssheleg/agent-sync",
3
- "version": "1.20.1",
3
+ "version": "1.20.2",
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"
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "agent-sync",
4
4
  "displayName": "Agent Sync",
5
- "version": "1.20.1",
5
+ "version": "1.20.2",
6
6
  "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.",
7
7
  "author": {
8
8
  "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.20.1"
7
+ version: "1.20.2"
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.20.1"
36
+ VERSION = "1.20.2"
37
37
 
38
38
  CONFIG_PATH = Path(".claude/agent-sync.json")
39
39
  ENV_FILE = Path(".env.agent-sync")
@@ -4327,6 +4327,18 @@ def _reap_by_operator_decision(s: "Sync", keys: list[str]) -> int:
4327
4327
  * **it prints the payload it destroyed** — run, timestamp, machine — so the decision is
4328
4328
  auditable afterwards by somebody who was not there, and journals it where a record
4329
4329
  plane is configured.
4330
+
4331
+ **Both planes, because AS-01b came back on the other one.** Until v1.20.2 this walked
4332
+ the lock DIRECTORY only. In git mode the authority is `refs/agent-sync/leases/*` on the
4333
+ remote, a ref outlives the checkout that wrote it, and `reap` clears a git ref only when
4334
+ the classifier calls it `reapable` — this run's own. So an expired ref in a DEAD run's
4335
+ name had no path out from anywhere: `residue` listed it, `reap` left it alone, and
4336
+ `--i-own-this` answered *there is no lock by that name in this checkout*, which is true
4337
+ and useless. Measured on this machine 2026-09-14: **161 refs from two runs that ended on
4338
+ 2026-09-09 and 2026-09-10**, every one expired more than four days against a 2700-second
4339
+ TTL, with no command able to reach them. That is the identical shape AS-01b closed for
4340
+ the local plane — "expired locks accumulate with no path out for anybody" — and the
4341
+ remedy is the identical one, applied where the state actually lives.
4330
4342
  """
4331
4343
  if not keys:
4332
4344
  print("reap --i-own-this needs the keys, one or more, by name.\n"
@@ -4336,48 +4348,94 @@ def _reap_by_operator_decision(s: "Sync", keys: list[str]) -> int:
4336
4348
  file=sys.stderr)
4337
4349
  return 2
4338
4350
 
4339
- by_key = {}
4351
+ # A key can exist on BOTH planes — a lock file here and a ref on the remote — and the
4352
+ # two are separate pieces of state with separate deletes. Collected as a LIST per key
4353
+ # rather than a dict, because clearing one and calling the key done is how the git ref
4354
+ # survived every sweep that ever ran.
4355
+ by_key: dict[str, list[dict[str, Any]]] = {}
4356
+
4357
+ def offer(entry: dict[str, Any]) -> None:
4358
+ by_key.setdefault(entry["key"], []).append(entry)
4359
+ stem = s._local_lock(entry["key"]).stem
4360
+ if stem != entry["key"]:
4361
+ by_key.setdefault(stem, []).append(entry)
4362
+
4340
4363
  for e in s.residue():
4341
- by_key.setdefault(e["key"], e)
4342
- by_key.setdefault(s._local_lock(e["key"]).stem, e)
4364
+ e.setdefault("plane", "fs")
4365
+ offer(e)
4366
+ git_unreadable = None
4367
+ if s.lease_mode == "git":
4368
+ refs, git_unreadable = s.git_residue()
4369
+ for e in refs:
4370
+ offer(e)
4371
+ if git_unreadable is not None:
4372
+ print(f" ⚠ the git plane could not be read ({git_unreadable}) — anything on it "
4373
+ "is neither cleared nor\n reported clean. Enumerate by hand: git "
4374
+ f"ls-remote {s.cfg.get('leaseRemote') or 'origin'} "
4375
+ "'refs/agent-sync/leases/*'", file=sys.stderr)
4343
4376
 
4344
4377
  rc = 0
4345
4378
  for k in keys:
4346
- e = by_key.get(k) or by_key.get(s._local_lock(k).stem)
4347
- if e is None:
4348
- print(f" · {k} — there is no lock by that name in this checkout", file=sys.stderr)
4349
- rc = 1
4350
- continue
4351
- if e["state"] == LIVE:
4352
- print(f" ✗ {e['key']} is LIVE under {e.get('run') or 'a run'}"
4353
- f"{' on ' + e['host'] if e.get('host') else ''} — not cleared. An override "
4354
- "is for residue;\n a live lease belongs to a run that may still be "
4355
- "working. Ask the holder, or wait for the TTL.", file=sys.stderr)
4356
- rc = 1
4357
- continue
4358
- had = (f"run {e.get('run') or 'unknown'}"
4359
- f"{' · host ' + e['host'] if e.get('host') else ''}"
4360
- f"{' · ' + e['ts'] if e.get('ts') else ''}"
4361
- f" · {spent(e)}")
4362
- try:
4363
- e["path"].unlink()
4364
- except OSError as exc:
4365
- print(f" ✗ {e['key']} could not be removed ({exc})", file=sys.stderr)
4366
- rc = 1
4367
- continue
4368
- # Proved gone by looking again, the same rule the ordinary reap follows.
4369
- if any(x["key"] == e["key"] for x in s.residue()):
4370
- print(f" ✗ {e['key']} is STILL PRESENT after the delete — the teardown was not "
4371
- "verified, whatever the call returned", file=sys.stderr)
4379
+ entries = by_key.get(k) or by_key.get(s._local_lock(k).stem) or []
4380
+ # Deduplicate: the stem alias can offer the same object twice.
4381
+ seen_ids, unique = set(), []
4382
+ for e in entries:
4383
+ if id(e) in seen_ids:
4384
+ continue
4385
+ seen_ids.add(id(e))
4386
+ unique.append(e)
4387
+ if not unique:
4388
+ where = "this checkout" if s.lease_mode != "git" else (
4389
+ "this checkout or on the remote" if git_unreadable is None
4390
+ else "this checkout (the remote could not be read)")
4391
+ print(f" · {k} — there is no lock by that name in {where}", file=sys.stderr)
4372
4392
  rc = 1
4373
4393
  continue
4374
- print(f" cleared {e['key']} by operator decision — it held {had}")
4375
- print(f" the classifier called it `{e['state']}`, and that has not changed: this "
4376
- "was a person's\n call, not a proof of ownership.")
4377
- try:
4378
- s.journal(f"reap --i-own-this {e['key']} — was {had}, classified {e['state']}")
4379
- except Exception: # noqa: BLE001 - the record plane is optional
4380
- pass
4394
+ for e in unique:
4395
+ plane = e.get("plane", "fs")
4396
+ if e["state"] == LIVE:
4397
+ print(f" ✗ {e['key']} [{plane}] is LIVE under {e.get('run') or 'a run'}"
4398
+ f"{' on ' + e['host'] if e.get('host') else ''} — not cleared. An override "
4399
+ "is for residue;\n a live lease belongs to a run that may still be "
4400
+ "working. Ask the holder, or wait for the TTL.", file=sys.stderr)
4401
+ rc = 1
4402
+ continue
4403
+ had = (f"run {e.get('run') or 'unknown'}"
4404
+ f"{' · host ' + e['host'] if e.get('host') else ''}"
4405
+ f"{' · ' + e['ts'] if e.get('ts') else ''}"
4406
+ f" · {spent(e)}")
4407
+ if plane == "git":
4408
+ # The same compare-and-swap the ordinary reap uses, and the same proof:
4409
+ # a second read of the remote, never the push's exit code.
4410
+ done = s.git_reap([e])[0]
4411
+ if not done.get("gone"):
4412
+ print(f" ✗ {e['key']} is STILL on the remote after the delete "
4413
+ f"({done.get('why_gone') or 'no reason given'}) — either somebody "
4414
+ "won it between the read\n and the delete, or the remote "
4415
+ "refused. Nothing was reported as cleared.", file=sys.stderr)
4416
+ rc = 1
4417
+ continue
4418
+ else:
4419
+ try:
4420
+ e["path"].unlink()
4421
+ except OSError as exc:
4422
+ print(f" ✗ {e['key']} could not be removed ({exc})", file=sys.stderr)
4423
+ rc = 1
4424
+ continue
4425
+ # Proved gone by looking again, the same rule the ordinary reap follows.
4426
+ if any(x["key"] == e["key"] for x in s.residue()):
4427
+ print(f" ✗ {e['key']} is STILL PRESENT after the delete — the teardown was not "
4428
+ "verified, whatever the call returned", file=sys.stderr)
4429
+ rc = 1
4430
+ continue
4431
+ print(f" cleared {e['key']} [{plane}] by operator decision — it held {had}")
4432
+ print(f" the classifier called it `{e['state']}`, and that has not changed: this "
4433
+ "was a person's\n call, not a proof of ownership.")
4434
+ try:
4435
+ s.journal(f"reap --i-own-this {e['key']} [{plane}] — was {had}, "
4436
+ f"classified {e['state']}")
4437
+ except Exception: # noqa: BLE001 - the record plane is optional
4438
+ pass
4381
4439
  return rc
4382
4440
 
4383
4441