From f999bce82ef788aecf575d5a6f5dcb6000ac3836 Mon Sep 17 00:00:00 2001 From: Sergey S Date: Mon, 14 Sep 2026 03:25:03 +0200 Subject: [PATCH] =?UTF-8?q?v1.20.2=20=E2=80=94=20the=20override=20that=20c?= =?UTF-8?q?ould=20not=20reach=20the=20plane=20the=20state=20was=20on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. - the override 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, per plane: a LIVE lease refused and named, an unknown key reported rather than guessed at, the destroyed payload printed with its plane, the journal line carrying it - an unreachable remote says so instead of answering "no such lock", which reads as "there is nothing to clear" over state that may well be there - AS-11 filed: nothing clears a dead run's refs by itself, so the next 161 accumulate exactly as these did — a sweep with an owner is a decision Gate: npm test EXIT=0 — validate PASS v1.20.2, SELF-TEST PASS, claim cell 27 cases (3 new), SessionStart identity 6, installer 11. Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/marketplace.json | 2 +- CHANGELOG.md | 29 ++++ SKILL-CARD.md | 2 +- docs/evidence/backlog.md | 1 + docs/evidence/verification.md | 10 ++ package.json | 2 +- plugins/agent-sync/.claude-plugin/plugin.json | 2 +- plugins/agent-sync/skills/agent-sync/SKILL.md | 2 +- .../skills/agent-sync/scripts/agent_sync.py | 132 +++++++++++++----- test/claim_cell_test.py | 55 ++++++++ 10 files changed, 195 insertions(+), 42 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e61eb0a..200af6c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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" diff --git a/CHANGELOG.md b/CHANGELOG.md index 23c2afe..169f58c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/SKILL-CARD.md b/SKILL-CARD.md index 9884655..8989753 100644 --- a/SKILL-CARD.md +++ b/SKILL-CARD.md @@ -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 | diff --git a/docs/evidence/backlog.md b/docs/evidence/backlog.md index 0e7fa27..3829c8a 100644 --- a/docs/evidence/backlog.md +++ b/docs/evidence/backlog.md @@ -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 | diff --git a/docs/evidence/verification.md b/docs/evidence/verification.md index 3d68141..91c11ce 100644 --- a/docs/evidence/verification.md +++ b/docs/evidence/verification.md @@ -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. diff --git a/package.json b/package.json index 30ae2be..3033ad4 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/plugins/agent-sync/.claude-plugin/plugin.json b/plugins/agent-sync/.claude-plugin/plugin.json index 0b86a15..a976c89 100644 --- a/plugins/agent-sync/.claude-plugin/plugin.json +++ b/plugins/agent-sync/.claude-plugin/plugin.json @@ -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", diff --git a/plugins/agent-sync/skills/agent-sync/SKILL.md b/plugins/agent-sync/skills/agent-sync/SKILL.md index 54074be..c05ab8c 100644 --- a/plugins/agent-sync/skills/agent-sync/SKILL.md +++ b/plugins/agent-sync/skills/agent-sync/SKILL.md @@ -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 --- diff --git a/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py b/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py index 207aa6a..474b93f 100644 --- a/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +++ b/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py @@ -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") @@ -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" @@ -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 diff --git a/test/claim_cell_test.py b/test/claim_cell_test.py index af04684..a494fcc 100644 --- a/test/claim_cell_test.py +++ b/test/claim_cell_test.py @@ -504,6 +504,56 @@ def two_machines_are_separated_in_local_mode(): assert "boxB" in there["why"], f"does not name the machine: {there['why']}" +def the_override_reaches_the_git_plane(): + """AS-01b came back on the other plane, and this is the case that says so. + + A ref in a DEAD run's name is `foreign`, so `reap` leaves it alone — correctly. Until + v1.20.2 `--i-own-this` walked the lock DIRECTORY only, answered *there is no lock by + that name in this checkout*, and there was no command anywhere that could clear it. + Measured on the operator's machine 2026-09-14: 161 refs from two runs that ended five + days earlier, unreachable by every verb the tool has. + """ + d, _bare = git_project(["| B-01 | a thing | open |\n"], run="r-dead", keys=("B-31",)) + before = remote_lease_refs(d) + assert any(r.endswith("/B-31") for r in before), f"fixture did not push the ref: {before}" + r = cli(d, "reap", "--i-own-this", "B-31") + out = r.stdout + r.stderr + assert "there is no lock by that name" not in out, \ + f"the override still reads only the local plane: {out}" + assert "cleared B-31 [git]" in out, f"the git ref was not cleared by name: {out}" + after = remote_lease_refs(d) + assert not any(x.endswith("/B-31") for x in after), \ + f"the ref is still on the remote after the override said it cleared it: {after}" + assert "r-dead" in out, "the payload it destroyed is not printed — the decision is unauditable" + + +def the_override_still_refuses_a_live_git_lease(): + """The floor the override must not lower: residue is what a person may clear by hand; + a live lease belongs to a run that may still be working.""" + import datetime + now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + # The fixture back-dates by ttl+600 so its default state is EXPIRED; a live lease + # needs the stamp said out loud, or the case tests the wrong thing. + d, _bare = git_project(["| B-01 | a thing | open |\n"], run="r-busy", ttl=9000, + ts=now, keys=("B-32",)) + r = cli(d, "reap", "--i-own-this", "B-32") + out = r.stdout + r.stderr + assert "is LIVE" in out, f"a live git lease was not refused: {out}" + assert any(x.endswith("/B-32") for x in remote_lease_refs(d)), \ + "a live lease was taken by hand — the collision this tool exists to prevent" + + +def an_unreachable_remote_does_not_read_as_a_missing_key(): + """A remote nobody could read must not make the override answer `no such lock` — that + reads as *there is nothing to clear* over state that may well be there.""" + d, bare = git_project(["| B-01 | a thing | open |\n"], keys=("B-33",)) + subprocess.run(["rm", "-rf", bare], check=True) + r = cli(d, "reap", "--i-own-this", "B-33") + out = r.stdout + r.stderr + assert "could not be read" in out, f"an unreachable remote said nothing: {out}" + assert "the remote could not be read" in out or "could not be read" in out + + CASES = [ ("a cited id is still taggable (B-42)", a_cited_id_is_still_taggable), ("releasing keeps a close written while held (B-35)", releasing_keeps_a_close_written_while_held), @@ -536,6 +586,11 @@ def two_machines_are_separated_in_local_mode(): residue_says_it_could_not_look_when_the_remote_is_gone), ("a local lock records its host (AS-03)", a_local_lock_records_the_machine_that_wrote_it), ("two machines are separated in local mode (AS-03)", two_machines_are_separated_in_local_mode), + ("the override reaches the git plane (AS-01b, second plane)", + the_override_reaches_the_git_plane), + ("the override still refuses a live git lease", the_override_still_refuses_a_live_git_lease), + ("an unreachable remote does not read as a missing key", + an_unreachable_remote_does_not_read_as_a_missing_key), ] for n, f in CASES: case(n, f)