@ssheleg/agent-sync 1.20.2 → 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 +27 -0
- package/package.json +1 -1
- package/plugins/agent-sync/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-sync/hooks/_lib.sh +17 -1
- package/plugins/agent-sync/hooks/hooks.json +1 -1
- package/plugins/agent-sync/hooks/session-end.sh +7 -6
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +2 -1
- package/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +42 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,30 @@
|
|
|
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
|
+
|
|
1
28
|
## v1.20.2 — the override that could not reach the plane the state was on
|
|
2
29
|
|
|
3
30
|
**161 expired lease refs on one remote, from two runs that ended five days earlier, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssheleg/agent-sync",
|
|
3
|
-
"version": "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.
|
|
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
|
-
(
|
|
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=$?
|
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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):
|
|
@@ -5493,10 +5526,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
5493
5526
|
help="also render the configured git documents into the plane")
|
|
5494
5527
|
bd.set_defaults(fn=cmd_board)
|
|
5495
5528
|
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
|
|
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)
|
|
5500
5537
|
|
|
5501
5538
|
r = sub.add_parser("renew")
|
|
5502
5539
|
r.add_argument("key", nargs="?")
|