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)