Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"displayName": "Agent Sync",
"source": "./plugins/agent-sync",
"description": "Coordination layer for multi-agent repositories. Gives task-pipeline runs leases with a TTL, race-free ID reservation, a cross-repo signal feed, an append-only run journal and a machine-generated read-only board. A lease is decided by a primitive that genuinely has compare-and-swap — an atomic file create on one machine, a pushed git ref across machines — never by the knowledge base, which loses concurrent writes; the store carries the record and the ID allocation, which is positional over the merged log. The knowledge backend is a pluggable adapter (Outline, filesystem) declaring its own capabilities and degrading out loud. Ships Claude Code hooks that deny edits to guarded registry files without a live lease.",
"version": "1.20.1",
"version": "1.20.2",
"author": {
"name": "ssheleg",
"url": "https://x.com/sshlg93"
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
## v1.20.2 — the override that could not reach the plane the state was on

**161 expired lease refs on one remote, from two runs that ended five days earlier, and
no command in this tool could clear any of them.** Measured on the operator's machine
2026-09-14: `residue` listed every one, `reap` left them alone (correctly — a ref in a
dead run's name is `foreign`), and `reap --i-own-this`, the one path a person has for
exactly this, answered *there is no lock by that name in this checkout*. True, and
useless: in git mode the authority is `refs/agent-sync/leases/*` on the REMOTE, and the
override walked the local lock directory only.

This is AS-01b returning on the other plane. That row closed *"expired locks accumulate
with no path out for anybody"* for the filesystem; the same sentence was true of the git
plane the whole time, and the tool that reports it could not act on it.

- `_reap_by_operator_decision` now reads **both planes** and collects entries per key as a
LIST — a key can be a lock file here AND a ref there, and clearing one while calling the
key done is how the ref survived every sweep that ran. A git entry is deleted through
`git_reap`'s existing `--force-with-lease` compare-and-swap and proved gone by a second
`ls-remote`, never by the push's exit code.
- **Every refusal the override had, it keeps**, now per plane: a LIVE lease is refused and
named, a key nobody holds is reported rather than guessed at, the destroyed payload is
printed with its plane so the decision stays auditable, and the journal line carries it.
- **A remote that cannot be read says so** instead of answering "no such lock" — that
answer reads as *there is nothing to clear* over state that may well be there.

Three cases in `test/claim_cell_test.py` (27 total): the override clears a dead run's ref
and proves it gone, a live ref is still refused, and an unreachable remote does not read
as a missing key.

## v1.20.1 — the filter that was never read, and the check that would have said so

Claude Code 2.1.270 prints `agent-sync: hooks.json: unknown key "if" in
Expand Down
2 changes: 1 addition & 1 deletion SKILL-CARD.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
| Field | Value |
|---|---|
| Pack and skill | `agent-sync` |
| Version | `1.20.1` |
| Version | `1.20.2` |
| License | MIT |
| Source | https://github.com/ssheleg/agent-sync |

Expand Down
1 change: 1 addition & 0 deletions docs/evidence/backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ anything that can make the tool report something untrue outranks both.

| ID | Priority | What | Why it is here | Source |
|---|---|---|---|---|
| **AS-11** | unverified | **A run that dies leaves its git lease refs forever, and nothing notices.** `session-end.sh` releases the leases a run HOLDS; a run that is killed, crashes, or whose session ends without the hook releases nothing, and in git mode the ref outlives the checkout. v1.20.2 gives a person a way to clear them (`reap --i-own-this`, both planes); nothing clears them by itself, so the next 161 accumulate exactly as these did. | Measured 2026-09-14 on the operator's machine: 161 refs from `r-2d51807cd` (146) and `r-883021cf7` (15), every one expired more than four days against a 2700-second TTL. Closing it means a sweep with an owner — a session-start reap of one's OWN prior refs, or a TTL the remote itself enforces — which is a decision, not a patch. | 2026-09-14, FIX-HK-10 |
| **AS-10** | unverified | **This repo's own gate is LOOSER than the gate CI applies to the same field.** `test/validate.py` refuses a description over **1024** (`check_skill`, the spec cap); CI runs the family's pinned `audit_skill.py --house`, which refuses one over **970** (`DESC_HEADROOM`, exit 1). So a description between 971 and 1024 passes `npm test` and fails CI — measured on this run at 1017 chars: `PASS: agent-sync v1.19.0 — all checks green` locally, `1 GAP, 13 PASS` from the auditor pinned at `991cbb4`. The same shape as AS-06, which was the other half of the same budget (3.9 vs 4 chars/token for the BODY) and was closed by adopting the auditor's number. Fix: adopt 970 here too, with a plant that watches it fire — or state in the check's own message that it is not the binding limit. | This run, 2026-08-31. The cost was one rewrite of a description that had already been measured against two models; the cost of not fixing it is a red CI on someone else's release, after the local gate said green. | this run, ASY-10 |
| **AS-08** | unverified | **A register whose ids are not four digits leaks forever.** `_leaks` (`agent_sync.py:3169`) tests `f"{reg}-{value:04d}" not in text`, and `reserve`'s own print (`:3570`) and the baseline (`:2719`, `:3857`) hardcode the same width. Against a three-digit register the id handed out is `CO-0051` while the register's own convention — and every other row in it — is `CO-051`. | Reproduced 2026-08-26 in `fabric`: `CO-051` and `CO-052` written and committed, `_leaks` returned `[('CO', 51, …), ('CO', 52, …)]` against a file containing both. So the board reports a permanent phantom leak, and `release-id` — the documented remedy — would lose an id that is not lost. The fix is to match the register's own width, or `\b{reg}-0*{value}\b`, rather than a constant. | this run, 2026-08-26; filed in `fabric` as CO-053 |
| **AS-07** | unverified | **A lease in a SECOND repository is never renewed.** `hooks/_lib.sh:36` resolves the project as `${CLAUDE_PROJECT_DIR:-$PWD}`, and in Claude Code that variable is the session's root. An agent whose session is rooted in project A but which works in project B renews A's lease and lets B's expire under it. | Observed on this run: the lease taken here expired 14 minutes before the work ended, while the session was rooted in `fabric`. `residue` reported it correctly, which is the only reason it was noticed — the commit into guarded files had already gone through (see AS-09). | this run, 2026-08-25 |
Expand Down
10 changes: 10 additions & 0 deletions docs/evidence/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ A row whose method is "read the code" is a row nobody can re-run; those say so.
(`python3 test/validate.py --self-test`).


## v1.20.2 — the override reaches the plane the state is on

**Release candidate v1.20.2.** This section was written before the tag.

| REQ | What must hold | Verified by | Last run |
|---|---|---|---|
| REQ-31 | `reap --i-own-this` can clear an expired lease ref in a dead run's name | `test/claim_cell_test.py` `the_override_reaches_the_git_plane`: a ref pushed by `r-dead` is cleared by name, the remote is re-read to prove it went, and the payload is printed | 2026-09-14 |
| REQ-32 | The override's floors survive on the git plane | same suite: a LIVE ref is refused and left on the remote; an unreachable remote reports *could not be read* rather than *no such lock* | 2026-09-14 |
| Gate | The whole suite on this tree | `npm test` → `PASS: agent-sync v1.20.2 — all checks green`, `SELF-TEST PASS`, claim cell 27 cases, SessionStart identity, installer | 2026-09-14 |

## v1.20.1 — the filter that was never read

**Release candidate v1.20.1.** This section was written before the tag.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ssheleg/agent-sync",
"version": "1.20.1",
"version": "1.20.2",
"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.",
"bin": {
"agent-sync": "bin/agent-sync.js"
Expand Down
2 changes: 1 addition & 1 deletion plugins/agent-sync/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "agent-sync",
"displayName": "Agent Sync",
"version": "1.20.1",
"version": "1.20.2",
"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.",
"author": {
"name": "ssheleg",
Expand Down
2 changes: 1 addition & 1 deletion plugins/agent-sync/skills/agent-sync/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ description: "Use when several coding agents work one repository at the same tim
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."
license: MIT
metadata:
version: "1.20.1"
version: "1.20.2"
author: ssheleg
---

Expand Down
132 changes: 95 additions & 37 deletions plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from pathlib import Path
from typing import Any

VERSION = "1.20.1"
VERSION = "1.20.2"

CONFIG_PATH = Path(".claude/agent-sync.json")
ENV_FILE = Path(".env.agent-sync")
Expand Down Expand Up @@ -4327,6 +4327,18 @@ def _reap_by_operator_decision(s: "Sync", keys: list[str]) -> int:
* **it prints the payload it destroyed** — run, timestamp, machine — so the decision is
auditable afterwards by somebody who was not there, and journals it where a record
plane is configured.

**Both planes, because AS-01b came back on the other one.** Until v1.20.2 this walked
the lock DIRECTORY only. In git mode the authority is `refs/agent-sync/leases/*` on the
remote, a ref outlives the checkout that wrote it, and `reap` clears a git ref only when
the classifier calls it `reapable` — this run's own. So an expired ref in a DEAD run's
name had no path out from anywhere: `residue` listed it, `reap` left it alone, and
`--i-own-this` answered *there is no lock by that name in this checkout*, which is true
and useless. Measured on this machine 2026-09-14: **161 refs from two runs that ended on
2026-09-09 and 2026-09-10**, every one expired more than four days against a 2700-second
TTL, with no command able to reach them. That is the identical shape AS-01b closed for
the local plane — "expired locks accumulate with no path out for anybody" — and the
remedy is the identical one, applied where the state actually lives.
"""
if not keys:
print("reap --i-own-this needs the keys, one or more, by name.\n"
Expand All @@ -4336,48 +4348,94 @@ def _reap_by_operator_decision(s: "Sync", keys: list[str]) -> int:
file=sys.stderr)
return 2

by_key = {}
# A key can exist on BOTH planes — a lock file here and a ref on the remote — and the
# two are separate pieces of state with separate deletes. Collected as a LIST per key
# rather than a dict, because clearing one and calling the key done is how the git ref
# survived every sweep that ever ran.
by_key: dict[str, list[dict[str, Any]]] = {}

def offer(entry: dict[str, Any]) -> None:
by_key.setdefault(entry["key"], []).append(entry)
stem = s._local_lock(entry["key"]).stem
if stem != entry["key"]:
by_key.setdefault(stem, []).append(entry)

for e in s.residue():
by_key.setdefault(e["key"], e)
by_key.setdefault(s._local_lock(e["key"]).stem, e)
e.setdefault("plane", "fs")
offer(e)
git_unreadable = None
if s.lease_mode == "git":
refs, git_unreadable = s.git_residue()
for e in refs:
offer(e)
if git_unreadable is not None:
print(f" ⚠ the git plane could not be read ({git_unreadable}) — anything on it "
"is neither cleared nor\n reported clean. Enumerate by hand: git "
f"ls-remote {s.cfg.get('leaseRemote') or 'origin'} "
"'refs/agent-sync/leases/*'", file=sys.stderr)

rc = 0
for k in keys:
e = by_key.get(k) or by_key.get(s._local_lock(k).stem)
if e is None:
print(f" · {k} — there is no lock by that name in this checkout", file=sys.stderr)
rc = 1
continue
if e["state"] == LIVE:
print(f" ✗ {e['key']} is LIVE under {e.get('run') or 'a run'}"
f"{' on ' + e['host'] if e.get('host') else ''} — not cleared. An override "
"is for residue;\n a live lease belongs to a run that may still be "
"working. Ask the holder, or wait for the TTL.", file=sys.stderr)
rc = 1
continue
had = (f"run {e.get('run') or 'unknown'}"
f"{' · host ' + e['host'] if e.get('host') else ''}"
f"{' · ' + e['ts'] if e.get('ts') else ''}"
f" · {spent(e)}")
try:
e["path"].unlink()
except OSError as exc:
print(f" ✗ {e['key']} could not be removed ({exc})", file=sys.stderr)
rc = 1
continue
# Proved gone by looking again, the same rule the ordinary reap follows.
if any(x["key"] == e["key"] for x in s.residue()):
print(f" ✗ {e['key']} is STILL PRESENT after the delete — the teardown was not "
"verified, whatever the call returned", file=sys.stderr)
entries = by_key.get(k) or by_key.get(s._local_lock(k).stem) or []
# Deduplicate: the stem alias can offer the same object twice.
seen_ids, unique = set(), []
for e in entries:
if id(e) in seen_ids:
continue
seen_ids.add(id(e))
unique.append(e)
if not unique:
where = "this checkout" if s.lease_mode != "git" else (
"this checkout or on the remote" if git_unreadable is None
else "this checkout (the remote could not be read)")
print(f" · {k} — there is no lock by that name in {where}", file=sys.stderr)
rc = 1
continue
print(f" cleared {e['key']} by operator decision — it held {had}")
print(f" the classifier called it `{e['state']}`, and that has not changed: this "
"was a person's\n call, not a proof of ownership.")
try:
s.journal(f"reap --i-own-this {e['key']} — was {had}, classified {e['state']}")
except Exception: # noqa: BLE001 - the record plane is optional
pass
for e in unique:
plane = e.get("plane", "fs")
if e["state"] == LIVE:
print(f" ✗ {e['key']} [{plane}] is LIVE under {e.get('run') or 'a run'}"
f"{' on ' + e['host'] if e.get('host') else ''} — not cleared. An override "
"is for residue;\n a live lease belongs to a run that may still be "
"working. Ask the holder, or wait for the TTL.", file=sys.stderr)
rc = 1
continue
had = (f"run {e.get('run') or 'unknown'}"
f"{' · host ' + e['host'] if e.get('host') else ''}"
f"{' · ' + e['ts'] if e.get('ts') else ''}"
f" · {spent(e)}")
if plane == "git":
# The same compare-and-swap the ordinary reap uses, and the same proof:
# a second read of the remote, never the push's exit code.
done = s.git_reap([e])[0]
if not done.get("gone"):
print(f" ✗ {e['key']} is STILL on the remote after the delete "
f"({done.get('why_gone') or 'no reason given'}) — either somebody "
"won it between the read\n and the delete, or the remote "
"refused. Nothing was reported as cleared.", file=sys.stderr)
rc = 1
continue
else:
try:
e["path"].unlink()
except OSError as exc:
print(f" ✗ {e['key']} could not be removed ({exc})", file=sys.stderr)
rc = 1
continue
# Proved gone by looking again, the same rule the ordinary reap follows.
if any(x["key"] == e["key"] for x in s.residue()):
print(f" ✗ {e['key']} is STILL PRESENT after the delete — the teardown was not "
"verified, whatever the call returned", file=sys.stderr)
rc = 1
continue
print(f" cleared {e['key']} [{plane}] by operator decision — it held {had}")
print(f" the classifier called it `{e['state']}`, and that has not changed: this "
"was a person's\n call, not a proof of ownership.")
try:
s.journal(f"reap --i-own-this {e['key']} [{plane}] — was {had}, "
f"classified {e['state']}")
except Exception: # noqa: BLE001 - the record plane is optional
pass
return rc


Expand Down
Loading
Loading