@ssheleg/agent-sync 1.20.1 → 1.21.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,59 @@
1
+ ## v1.21.0 — the SessionEnd budget no host gave, and the watchdog that held the pipe
2
+
3
+ Codex 0.157 prints `clamping SessionEnd hook timeout to 3s in …/agent-sync/…/hooks.json`
4
+ at every session start. The hook declared 20 s; Codex clamps a SessionEnd handler to 3 s,
5
+ and Claude Code sizes its own SessionEnd wait from the largest handler timeout. So the
6
+ number was never honoured, and the loop behind it did not fit the budget it really had.
7
+
8
+ - **`hooks.json` declares `timeout: 3`** for SessionEnd, and `check_hooks_manifest` refuses
9
+ anything larger, with a self-test plant of the exact 20 that shipped 0.1.0 → 1.20.2.
10
+ - **`release --held`** gives back everything this run holds in ONE process. `session-end.sh`
11
+ used to run `whoami` plus one `release` per key, each with its own 10 s limit, so the host
12
+ killed it part-way and the tail of the list stayed out until its TTL. It now runs
13
+ `release --held` under a 2 s limit. The re-read of `held()` after each release is kept,
14
+ because a run's last task key takes its resource claims with it.
15
+ - **`run_limited`'s fallback watchdog held the caller's pipe.** Stock macOS has neither
16
+ `timeout` nor `gtimeout`; the bash fallback was `( sleep N; kill ) &` with the caller's
17
+ stdout inherited, and `kill "$watchdog"` ended the subshell but not its `sleep`. The orphan
18
+ kept the pipe open, so `$(run_limited 10 …)` and `… | sed` waited the full limit after the
19
+ command had finished. That covered session-end, and SessionStart's `status` output too.
20
+ Measured 2026-09-27: session-end took 10.4 s to release three leases. It is a race, so a
21
+ trap-based stop still lost it now and then. The watchdog now polls `kill -0` in 0.1 s steps
22
+ with its stdio on `/dev/null`.
23
+
24
+ Seven cases in `test/hooks_session_test.py` (13 total). The watchdog cases force the
25
+ fallback with a PATH that carries no timeout binary, so a Linux runner exercises it too.
26
+ They repeat the shape forty times, because a single draw of a race proves nothing.
27
+
28
+ ## v1.20.2 — the override that could not reach the plane the state was on
29
+
30
+ **161 expired lease refs on one remote, from two runs that ended five days earlier, and
31
+ no command in this tool could clear any of them.** Measured on the operator's machine
32
+ 2026-09-14: `residue` listed every one, `reap` left them alone (correctly — a ref in a
33
+ dead run's name is `foreign`), and `reap --i-own-this`, the one path a person has for
34
+ exactly this, answered *there is no lock by that name in this checkout*. True, and
35
+ useless: in git mode the authority is `refs/agent-sync/leases/*` on the REMOTE, and the
36
+ override walked the local lock directory only.
37
+
38
+ This is AS-01b returning on the other plane. That row closed *"expired locks accumulate
39
+ with no path out for anybody"* for the filesystem; the same sentence was true of the git
40
+ plane the whole time, and the tool that reports it could not act on it.
41
+
42
+ - `_reap_by_operator_decision` now reads **both planes** and collects entries per key as a
43
+ LIST — a key can be a lock file here AND a ref there, and clearing one while calling the
44
+ key done is how the ref survived every sweep that ran. A git entry is deleted through
45
+ `git_reap`'s existing `--force-with-lease` compare-and-swap and proved gone by a second
46
+ `ls-remote`, never by the push's exit code.
47
+ - **Every refusal the override had, it keeps**, now per plane: a LIVE lease is refused and
48
+ named, a key nobody holds is reported rather than guessed at, the destroyed payload is
49
+ printed with its plane so the decision stays auditable, and the journal line carries it.
50
+ - **A remote that cannot be read says so** instead of answering "no such lock" — that
51
+ answer reads as *there is nothing to clear* over state that may well be there.
52
+
53
+ Three cases in `test/claim_cell_test.py` (27 total): the override clears a dead run's ref
54
+ and proves it gone, a live ref is still refused, and an unreachable remote does not read
55
+ as a missing key.
56
+
1
57
  ## v1.20.1 — the filter that was never read, and the check that would have said so
2
58
 
3
59
  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.21.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"
@@ -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.21.0",
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",
@@ -24,9 +24,25 @@ run_limited() {
24
24
  return $?
25
25
  fi
26
26
 
27
+ # The watchdog POLLS in 0.1 s steps with its stdio on /dev/null, and nothing here
28
+ # depends on a signal reaching it in time. It used to be `( sleep N; kill ) &` with
29
+ # the caller's stdout inherited: `kill "$watchdog"` ended the subshell and not its
30
+ # `sleep`, and the orphan held the pipe, so `$(run_limited 10 …)` or `… | sed` waited
31
+ # the full limit after the command had finished. A trap-based stop still lost the
32
+ # race now and then (the TERM landing inside the fork), so the stop is now "the
33
+ # command is gone", seen within one step. session-end.sh spent 10.4 s releasing
34
+ # three leases the old way, against a SessionEnd budget of 3 s.
27
35
  "$@" &
28
36
  local pid=$!
29
- ( sleep "$secs"; kill -TERM "$pid" 2>/dev/null ) &
37
+ (
38
+ n=$((secs * 10))
39
+ while [ "$n" -gt 0 ]; do
40
+ kill -0 "$pid" 2>/dev/null || exit 0
41
+ sleep 0.1
42
+ n=$((n - 1))
43
+ done
44
+ kill -TERM "$pid" 2>/dev/null
45
+ ) </dev/null >/dev/null 2>&1 &
30
46
  local watchdog=$!
31
47
  wait "$pid" 2>/dev/null
32
48
  local rc=$?
@@ -59,7 +59,7 @@
59
59
  "type": "command",
60
60
  "shell": "bash",
61
61
  "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/session-end.sh\"",
62
- "timeout": 20
62
+ "timeout": 3
63
63
  }
64
64
  ]
65
65
  }
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env bash
2
2
  # Release every lease this run holds. An abandoned lease looks like active work
3
3
  # until its TTL expires.
4
+ #
5
+ # ONE process under a 2 s limit, inside the 3 s hooks.json declares. Codex clamps a
6
+ # SessionEnd handler to 3 s and Claude Code sizes its SessionEnd wait from the largest
7
+ # handler timeout, so 3 s is what either host actually gives. This used to be `whoami`
8
+ # plus one `release` per key, each with its own 10 s limit — a loop the host killed
9
+ # part-way, leaving the tail of the list out until its TTL.
4
10
  set -uo pipefail
5
11
  . "${CLAUDE_PLUGIN_ROOT}/hooks/_lib.sh"
6
12
  S="$AGENT_SYNC_PY"
7
13
  agent_sync_configured || exit 0
8
- held=$(run_limited 10 python3 "$S" whoami 2>/dev/null | sed -n 's/^holds: //p')
9
- [ -z "$held" ] || [ "$held" = "nothing" ] && exit 0
10
- IFS=', ' read -r -a keys <<<"$held"
11
- for k in "${keys[@]}"; do
12
- [ -n "$k" ] && run_limited 10 python3 "$S" release "$k" >/dev/null 2>&1 || true
13
- done
14
+ run_limited 2 python3 "$S" release --held >/dev/null 2>&1 || true
14
15
  exit 0
@@ -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.21.0"
8
8
  author: ssheleg
9
9
  ---
10
10
 
@@ -163,6 +163,7 @@ npx sshlg-skills install
163
163
  | `acquire <KEY>` | Take the lease on a task id. Prints `won` or `lost <holder>` |
164
164
  | `renew <KEY>` | Extend the lease. The `PostToolUse` hook does this for you |
165
165
  | `release <KEY>` | Give the lease back. Always do this, including on failure |
166
+ | `release --held` | Give back everything this run holds, in one process — what SessionEnd runs |
166
167
  | `reserve <REG> [--key K] [--offline]` | Reserve the next id in a register (`DEC`, `OQ`, `DEP`, …); prints it. `--key` makes a retry idempotent (one key, one number); `--offline` issues a namespaced `REG-o-…` id with no global authority |
167
168
  | `map-offline <REG> <ID> <N>` | Bind an offline id to a properly reserved number — append-only, never rebound |
168
169
  | `release-id <REG> <ID>` | Return an id you did not end up writing to git |
@@ -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.21.0"
37
37
 
38
38
  CONFIG_PATH = Path(".claude/agent-sync.json")
39
39
  ENV_FILE = Path(".env.agent-sync")
@@ -4128,6 +4128,12 @@ def cmd_renew(args: argparse.Namespace) -> int:
4128
4128
 
4129
4129
 
4130
4130
  def cmd_release(args: argparse.Namespace) -> int:
4131
+ if getattr(args, "held", False):
4132
+ if args.key:
4133
+ raise Fail("release takes a key OR --held, not both")
4134
+ return _release_held()
4135
+ if not args.key:
4136
+ raise Fail("release needs a key, or --held for everything this run holds")
4131
4137
  # Exit non-zero when nothing was released. A caller that scripts `release` in a
4132
4138
  # cleanup path has no other way to learn the lease is still out there.
4133
4139
  if not Sync().release(args.key):
@@ -4137,6 +4143,33 @@ def cmd_release(args: argparse.Namespace) -> int:
4137
4143
  return 0
4138
4144
 
4139
4145
 
4146
+ def _release_held() -> int:
4147
+ """Everything this run holds, in ONE process — the SessionEnd path.
4148
+
4149
+ The hook used to spend `whoami` plus one `release` process per key, each paying the
4150
+ interpreter start and the config read again; under the 3 s both hosts give a SessionEnd
4151
+ handler, the tail of the list stayed out until its TTL. `held()` is re-read after every
4152
+ release because releasing a run's last task key releases its resource claims with it.
4153
+ """
4154
+ s = Sync()
4155
+ released, refused = [], []
4156
+ for _ in range(len(s.held()) + 1):
4157
+ pending = [k for k in s.held() if k not in refused]
4158
+ if not pending:
4159
+ break
4160
+ key = pending[0]
4161
+ (released if s.release(key) else refused).append(key)
4162
+ if not released and not refused:
4163
+ print("released nothing — this run holds nothing")
4164
+ return 0
4165
+ if released:
4166
+ print(f"released {', '.join(released)}")
4167
+ if refused:
4168
+ print(f"NOT released: {', '.join(refused)}", file=sys.stderr)
4169
+ return 1
4170
+ return 0
4171
+
4172
+
4140
4173
  def cmd_reserve(args: argparse.Namespace) -> int:
4141
4174
  s = Sync()
4142
4175
  if getattr(args, "offline", False):
@@ -4327,6 +4360,18 @@ def _reap_by_operator_decision(s: "Sync", keys: list[str]) -> int:
4327
4360
  * **it prints the payload it destroyed** — run, timestamp, machine — so the decision is
4328
4361
  auditable afterwards by somebody who was not there, and journals it where a record
4329
4362
  plane is configured.
4363
+
4364
+ **Both planes, because AS-01b came back on the other one.** Until v1.20.2 this walked
4365
+ the lock DIRECTORY only. In git mode the authority is `refs/agent-sync/leases/*` on the
4366
+ remote, a ref outlives the checkout that wrote it, and `reap` clears a git ref only when
4367
+ the classifier calls it `reapable` — this run's own. So an expired ref in a DEAD run's
4368
+ name had no path out from anywhere: `residue` listed it, `reap` left it alone, and
4369
+ `--i-own-this` answered *there is no lock by that name in this checkout*, which is true
4370
+ and useless. Measured on this machine 2026-09-14: **161 refs from two runs that ended on
4371
+ 2026-09-09 and 2026-09-10**, every one expired more than four days against a 2700-second
4372
+ TTL, with no command able to reach them. That is the identical shape AS-01b closed for
4373
+ the local plane — "expired locks accumulate with no path out for anybody" — and the
4374
+ remedy is the identical one, applied where the state actually lives.
4330
4375
  """
4331
4376
  if not keys:
4332
4377
  print("reap --i-own-this needs the keys, one or more, by name.\n"
@@ -4336,48 +4381,94 @@ def _reap_by_operator_decision(s: "Sync", keys: list[str]) -> int:
4336
4381
  file=sys.stderr)
4337
4382
  return 2
4338
4383
 
4339
- by_key = {}
4384
+ # A key can exist on BOTH planes — a lock file here and a ref on the remote — and the
4385
+ # two are separate pieces of state with separate deletes. Collected as a LIST per key
4386
+ # rather than a dict, because clearing one and calling the key done is how the git ref
4387
+ # survived every sweep that ever ran.
4388
+ by_key: dict[str, list[dict[str, Any]]] = {}
4389
+
4390
+ def offer(entry: dict[str, Any]) -> None:
4391
+ by_key.setdefault(entry["key"], []).append(entry)
4392
+ stem = s._local_lock(entry["key"]).stem
4393
+ if stem != entry["key"]:
4394
+ by_key.setdefault(stem, []).append(entry)
4395
+
4340
4396
  for e in s.residue():
4341
- by_key.setdefault(e["key"], e)
4342
- by_key.setdefault(s._local_lock(e["key"]).stem, e)
4397
+ e.setdefault("plane", "fs")
4398
+ offer(e)
4399
+ git_unreadable = None
4400
+ if s.lease_mode == "git":
4401
+ refs, git_unreadable = s.git_residue()
4402
+ for e in refs:
4403
+ offer(e)
4404
+ if git_unreadable is not None:
4405
+ print(f" ⚠ the git plane could not be read ({git_unreadable}) — anything on it "
4406
+ "is neither cleared nor\n reported clean. Enumerate by hand: git "
4407
+ f"ls-remote {s.cfg.get('leaseRemote') or 'origin'} "
4408
+ "'refs/agent-sync/leases/*'", file=sys.stderr)
4343
4409
 
4344
4410
  rc = 0
4345
4411
  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)
4412
+ entries = by_key.get(k) or by_key.get(s._local_lock(k).stem) or []
4413
+ # Deduplicate: the stem alias can offer the same object twice.
4414
+ seen_ids, unique = set(), []
4415
+ for e in entries:
4416
+ if id(e) in seen_ids:
4417
+ continue
4418
+ seen_ids.add(id(e))
4419
+ unique.append(e)
4420
+ if not unique:
4421
+ where = "this checkout" if s.lease_mode != "git" else (
4422
+ "this checkout or on the remote" if git_unreadable is None
4423
+ else "this checkout (the remote could not be read)")
4424
+ print(f" · {k} — there is no lock by that name in {where}", file=sys.stderr)
4372
4425
  rc = 1
4373
4426
  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
4427
+ for e in unique:
4428
+ plane = e.get("plane", "fs")
4429
+ if e["state"] == LIVE:
4430
+ print(f" ✗ {e['key']} [{plane}] is LIVE under {e.get('run') or 'a run'}"
4431
+ f"{' on ' + e['host'] if e.get('host') else ''} — not cleared. An override "
4432
+ "is for residue;\n a live lease belongs to a run that may still be "
4433
+ "working. Ask the holder, or wait for the TTL.", file=sys.stderr)
4434
+ rc = 1
4435
+ continue
4436
+ had = (f"run {e.get('run') or 'unknown'}"
4437
+ f"{' · host ' + e['host'] if e.get('host') else ''}"
4438
+ f"{' · ' + e['ts'] if e.get('ts') else ''}"
4439
+ f" · {spent(e)}")
4440
+ if plane == "git":
4441
+ # The same compare-and-swap the ordinary reap uses, and the same proof:
4442
+ # a second read of the remote, never the push's exit code.
4443
+ done = s.git_reap([e])[0]
4444
+ if not done.get("gone"):
4445
+ print(f" ✗ {e['key']} is STILL on the remote after the delete "
4446
+ f"({done.get('why_gone') or 'no reason given'}) — either somebody "
4447
+ "won it between the read\n and the delete, or the remote "
4448
+ "refused. Nothing was reported as cleared.", file=sys.stderr)
4449
+ rc = 1
4450
+ continue
4451
+ else:
4452
+ try:
4453
+ e["path"].unlink()
4454
+ except OSError as exc:
4455
+ print(f" ✗ {e['key']} could not be removed ({exc})", file=sys.stderr)
4456
+ rc = 1
4457
+ continue
4458
+ # Proved gone by looking again, the same rule the ordinary reap follows.
4459
+ if any(x["key"] == e["key"] for x in s.residue()):
4460
+ print(f" ✗ {e['key']} is STILL PRESENT after the delete — the teardown was not "
4461
+ "verified, whatever the call returned", file=sys.stderr)
4462
+ rc = 1
4463
+ continue
4464
+ print(f" cleared {e['key']} [{plane}] by operator decision — it held {had}")
4465
+ print(f" the classifier called it `{e['state']}`, and that has not changed: this "
4466
+ "was a person's\n call, not a proof of ownership.")
4467
+ try:
4468
+ s.journal(f"reap --i-own-this {e['key']} [{plane}] — was {had}, "
4469
+ f"classified {e['state']}")
4470
+ except Exception: # noqa: BLE001 - the record plane is optional
4471
+ pass
4381
4472
  return rc
4382
4473
 
4383
4474
 
@@ -5435,10 +5526,14 @@ def build_parser() -> argparse.ArgumentParser:
5435
5526
  help="also render the configured git documents into the plane")
5436
5527
  bd.set_defaults(fn=cmd_board)
5437
5528
 
5438
- for name, fn, arg in (("acquire", cmd_acquire, "key"), ("release", cmd_release, "key")):
5439
- q = sub.add_parser(name)
5440
- q.add_argument(arg)
5441
- q.set_defaults(fn=fn)
5529
+ q = sub.add_parser("acquire")
5530
+ q.add_argument("key")
5531
+ q.set_defaults(fn=cmd_acquire)
5532
+ q = sub.add_parser("release", help="release a lease, or --held for all this run holds")
5533
+ q.add_argument("key", nargs="?")
5534
+ q.add_argument("--held", action="store_true",
5535
+ help="release every lease this run holds, in one process (SessionEnd)")
5536
+ q.set_defaults(fn=cmd_release)
5442
5537
 
5443
5538
  r = sub.add_parser("renew")
5444
5539
  r.add_argument("key", nargs="?")