@ssheleg/agent-sync 1.14.0 → 1.16.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 +98 -0
- package/package.json +1 -1
- package/plugins/agent-sync/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +91 -137
- package/plugins/agent-sync/skills/agent-sync/references/lease-protocol.md +37 -0
- package/plugins/agent-sync/skills/agent-sync/references/two-sources.md +10 -0
- package/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +477 -22
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,101 @@
|
|
|
1
|
+
## Unreleased — both `AS-01` halves exercised outside their fixtures
|
|
2
|
+
|
|
3
|
+
The two rows sat at priority `unverified`: shipped, and confirmed by nothing but their own
|
|
4
|
+
fixtures. Both are now exercised against real state, and both hold. **Nothing in the tool
|
|
5
|
+
changed** — this records that the mechanism was watched working on something other than its
|
|
6
|
+
own test data, which is what `unverified` meant.
|
|
7
|
+
|
|
8
|
+
**The git plane, on a real remote.** A git-mode checkout was built against a local bare
|
|
9
|
+
remote, a lease taken at `ttl 2`, and its local note **deleted** — exactly the state a ref
|
|
10
|
+
won on another machine leaves behind, and the state the row said `residue` could not see.
|
|
11
|
+
`residue` printed *nothing in the lock directory* and then enumerated the git plane:
|
|
12
|
+
`refs/agent-sync/leases/DEMO-KEY @ 495370743e`, run `r-rverifya`, **expired 18s ago**,
|
|
13
|
+
`foreign`, closing with *1 ref(s) on the remote, 0 this run can prove it owns and has spent*.
|
|
14
|
+
|
|
15
|
+
**Reap, in both directions.** Reaping as a **different** run left the ref standing and said so
|
|
16
|
+
by name. Reaping as the run that took it removed it and reported *confirmed gone by re-reading
|
|
17
|
+
the remote* — the proof coming from `ls-remote`, not from a push's exit code.
|
|
18
|
+
|
|
19
|
+
**And on live residue.** `~/DATA/0xDEV` carries a genuine foreign expired lock —
|
|
20
|
+
`BLOG-SITEMAP`, run `r-blog-1429e`, **expired 4d 22h ago**. `reap` named it, said *left alone
|
|
21
|
+
— it belongs to run r-blog-1429e, not to this one*, and **deleted nothing**: both lock files
|
|
22
|
+
byte-identical afterwards. State a run cannot prove is its own is reported and untouched.
|
|
23
|
+
|
|
24
|
+
## v1.16.0 — the release that closes one version string over two trees
|
|
25
|
+
|
|
26
|
+
**281 lines of shipped behaviour had been sitting behind the tag.** `AS-01a` (the
|
|
27
|
+
git plane is swept, not only disclosed) and `AS-01b` (an ambiguous lock is
|
|
28
|
+
clearable by a person, per key, attributably) landed on `main`, were pinned by the
|
|
29
|
+
umbrella, and were never tagged. So three channels served `1.15.0` and two
|
|
30
|
+
different trees:
|
|
31
|
+
|
|
32
|
+
| channel | source | `agent_sync.py` |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| npm `@ssheleg/agent-sync@1.15.0` | the tag | 4344 lines |
|
|
35
|
+
| the plugin marketplace | the branch tip | 4575 lines |
|
|
36
|
+
| the skills CLI | the branch tip | 4575 lines |
|
|
37
|
+
|
|
38
|
+
All three reported `1.15.0`. `check_pins.py` was green throughout — correctly,
|
|
39
|
+
because it compares the version STRING, and the string did match. A version that
|
|
40
|
+
identifies two artefacts cannot be reasoned about, and the family's own invariant
|
|
41
|
+
("the pin is the promise") was satisfied to the letter while being false in
|
|
42
|
+
substance.
|
|
43
|
+
|
|
44
|
+
Found on 2026-08-23 by fetching the npm tarball and counting lines in all three
|
|
45
|
+
channels rather than trusting any of them. This release makes the number true.
|
|
46
|
+
|
|
47
|
+
Also in this range: the release workflow refuses a tag whose commit no clone can
|
|
48
|
+
reach, and the evidence ledger records both `AS-01` halves exercised on a real
|
|
49
|
+
remote rather than in a fixture.
|
|
50
|
+
|
|
51
|
+
## v1.15.0 — a claim tag outlived its lease, and no command reached it
|
|
52
|
+
|
|
53
|
+
**GitHub issue #5, filed 2026-08-17, reproduced verbatim at v1.14.0.** A board row shipped
|
|
54
|
+
in a published release reading `(claimed: r-6e62c4dab)` while the lease plane said
|
|
55
|
+
`leases held: none`. `release <key>` printed `released`, exited **0** and changed nothing;
|
|
56
|
+
`residue` said *nothing on disk*; `reconcile` found *no mechanical divergence*. Three
|
|
57
|
+
commands, none of which could reach the tag, because `write_claim(key, None)` restores the
|
|
58
|
+
cell for the key it holds and it held none.
|
|
59
|
+
|
|
60
|
+
This is #4 from the other side. #4 was a lease that outlived its run; this is a tag that
|
|
61
|
+
outlived its lease — and the second is worse, because an invisible lease invites a check
|
|
62
|
+
while a confident wrong answer does not. Closed the same way: **one notion of held,
|
|
63
|
+
consulted by both planes.** `_lease_holder` stays the single reader, memoised per command
|
|
64
|
+
so a report over a tagged board costs one `ls-remote` per *tagged row* in git mode rather
|
|
65
|
+
than one per board row. `orphan_claims()` classifies a tag `orphan` — the TTL has ended its
|
|
66
|
+
lease — or `disputed`, live under another run, which is reported and never touched.
|
|
67
|
+
`release` clears an orphan and names the run it belonged to. `status`, `residue` and
|
|
68
|
+
`reconcile` each report one, so the disagreement is visible to a gate rather than to a
|
|
69
|
+
diff.
|
|
70
|
+
|
|
71
|
+
Four more, each with its plant watched refusing:
|
|
72
|
+
|
|
73
|
+
- **`residue` read as a complete answer in git mode and could not be.** It walks
|
|
74
|
+
`.agent-sync/leases/*.lock`, which only the local plane writes, so a ref won on another
|
|
75
|
+
machine was invisible. It now prints `⚠ INCOMPLETE IN THIS MODE` with the `ls-remote`
|
|
76
|
+
command it does not run. The sweep itself is still open, and the row says so — a check
|
|
77
|
+
that cannot look must not read as one that looked.
|
|
78
|
+
- **A `local` lock recorded no host**, so residue could not tell a lock written here from
|
|
79
|
+
one written on another machine; 25 of 25 locks on this machine carried none. Both modes
|
|
80
|
+
write it now, with a fixture separating two machines.
|
|
81
|
+
- **The ledger described an artifact nobody shipped**: its newest section was headed
|
|
82
|
+
*(in tree, unreleased)* and quoted `PASS: agent-sync v1.13.0` while v1.14.0 was tagged,
|
|
83
|
+
in `package.json` and on npm. Guarded three ways.
|
|
84
|
+
- **Two divisors for one token budget.** The family's auditor measured the pack's body at
|
|
85
|
+
~5084 tokens against a 5000 limit; this repo's own gate divided by 4 and passed at 4957.
|
|
86
|
+
One divisor now (3.9, the auditor's), and the body is under it at ~4683 after a split.
|
|
87
|
+
|
|
88
|
+
Also corrected: the board's *"14 of the 17 live in repositories this row must not touch"*
|
|
89
|
+
recomputes to **16 outside this checkout, 13 outside it and the umbrella**, with the `find`
|
|
90
|
+
that produces each.
|
|
91
|
+
|
|
92
|
+
**Found while doing it:** a plant had stopped planting. `the script path is prose only`
|
|
93
|
+
substituted a paragraph that had since been reflowed, so it changed nothing — and a no-op
|
|
94
|
+
plant still reported `detected` for a check that never ran. Re-anchored on the values the
|
|
95
|
+
check actually reads.
|
|
96
|
+
|
|
97
|
+
Self-test fixtures 43 → **51**; the claim-cell suite 9 → **16 cases**.
|
|
98
|
+
|
|
1
99
|
## v1.14.0 — expiry ended a lease and left the file, and every reader folded that away
|
|
2
100
|
|
|
3
101
|
**`status` reported `leases held: none` over three expired locks in the directory it had
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssheleg/agent-sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.16.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"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sync",
|
|
3
3
|
"displayName": "Agent Sync",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.16.0",
|
|
5
5
|
"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.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ssheleg",
|
|
@@ -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.16.0"
|
|
8
8
|
author: ssheleg
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -24,7 +24,6 @@ keep that true while several agents write at once.
|
|
|
24
24
|
**1. The knowledge base never decides a lease.** It cannot: twelve concurrent appends to
|
|
25
25
|
one Outline document returned twelve successes and left **three** lines. Exclusion comes
|
|
26
26
|
from something with real compare-and-swap. The plane carries the record and nothing else.
|
|
27
|
-
Full measurements in `references/lease-protocol.md`.
|
|
28
27
|
|
|
29
28
|
**2. Know which lease you have, and say so.** `leaseBackend: "local"` is an atomic file
|
|
30
29
|
create — exclusive between processes on one filesystem, **advisory across machines**.
|
|
@@ -38,14 +37,12 @@ round-trip. **Read `references/roadmap.md`** before configuring `claimTags` or c
|
|
|
38
37
|
task; closing is a statement about the work and stays yours.
|
|
39
38
|
|
|
40
39
|
**Work on a branch; the integration branch is somebody else's stable base.** `acquire`
|
|
41
|
-
writes the claim through **only** there
|
|
42
|
-
coordination plane, where `status` shows it to every agent
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
lease released. `merges` tells the next agent what landed while it was away. **Read
|
|
48
|
-
`references/branching.md`** before merging.
|
|
40
|
+
writes the claim through **only** there — on any other branch the holder stays in the
|
|
41
|
+
coordination plane, where `status` shows it to every agent. Committed to a branch, a claim
|
|
42
|
+
is invisible until the merge and turns the shared roadmap into a file two branches both
|
|
43
|
+
edit. Land work with `merge`: conflicts computed **before** anything is touched, the merge
|
|
44
|
+
recorded in `docs/MERGES.md`, the `--key` lease released; `merges` says what landed while
|
|
45
|
+
you were away. **Read `references/branching.md`** before merging.
|
|
49
46
|
|
|
50
47
|
**3. Hooks exist only in Claude Code.** Elsewhere nothing blocks a guarded edit: run
|
|
51
48
|
`guard` yourself and record the run as `ungated`. Do not describe a project as protected
|
|
@@ -55,8 +52,7 @@ when it is not.
|
|
|
55
52
|
what you wrote — Outline turns a `- ` bullet into `* `. Emit `- `, accept `-`/`*`/`+`,
|
|
56
53
|
count anything entry-shaped that fails, and **fail loudly** past 2% unparseable. Reporting
|
|
57
54
|
`lost` when the truth is *unreadable* names a holder who does not exist. Watch for a
|
|
58
|
-
silent pre-filter: a `continue` before the regex hides bad lines from the counter
|
|
59
|
-
expose them.
|
|
55
|
+
silent pre-filter: a `continue` before the regex hides bad lines from the counter.
|
|
60
56
|
|
|
61
57
|
## Bringing this into ANY project — the whole chain
|
|
62
58
|
|
|
@@ -70,19 +66,15 @@ setup → generate the snapshot that describes this project's wiring
|
|
|
70
66
|
check → validate the whole thing; non-zero if it is not healthy
|
|
71
67
|
```
|
|
72
68
|
|
|
73
|
-
**`check` is what makes the skill self-sufficient.** It refuses to call a setup healthy on
|
|
74
|
-
|
|
69
|
+
**`check` is what makes the skill self-sufficient.** It refuses to call a setup healthy on a
|
|
70
|
+
rule that protects nothing (a register, guard glob, claim pattern, gate or mirror source
|
|
75
71
|
pointing at what is not there), on missing credentials, on an env file **tracked by git** —
|
|
76
|
-
the one unrecoverable mistake here — on a stale
|
|
77
|
-
|
|
78
|
-
|
|
72
|
+
the one unrecoverable mistake here — on a stale snapshot, or on a register with no baseline.
|
|
73
|
+
It names each one; every one failed for real during this tool's own adoption. Run it after
|
|
74
|
+
adopting, after changing the config, and in CI.
|
|
79
75
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
**`scaffold` never overwrites.** It seeds a decision register with an allocation line and
|
|
83
|
-
an `AGENTS.md` that points at the snapshot, and leaves every existing file untouched — a
|
|
84
|
-
tool that rewrites a project's own conventions on adoption is worse than one that does
|
|
85
|
-
nothing.
|
|
76
|
+
**`scaffold` never overwrites.** It seeds a decision register and an `AGENTS.md` pointing at
|
|
77
|
+
the snapshot, and leaves every existing file untouched.
|
|
86
78
|
|
|
87
79
|
## Existing project: start with `adopt`
|
|
88
80
|
|
|
@@ -95,15 +87,14 @@ python3 "$SKILL_DIR/scripts/agent_sync.py" adopt
|
|
|
95
87
|
```
|
|
96
88
|
|
|
97
89
|
Confirm the registers and guarded files with the operator first: a register pointed at the
|
|
98
|
-
wrong file makes every later check confidently wrong, and a guarded list that misses a
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
`init`.
|
|
90
|
+
wrong file makes every later check confidently wrong, and a guarded list that misses a shared
|
|
91
|
+
file leaves the one place collisions happen unprotected. In a submodule it declares no
|
|
92
|
+
registers — decisions belong to the parent. Then take the chain above from `init`.
|
|
102
93
|
|
|
103
94
|
## First command in a project: `init`
|
|
104
95
|
|
|
105
|
-
**Never run anything else against an uninitialised project.** `init` is where the
|
|
106
|
-
|
|
96
|
+
**Never run anything else against an uninitialised project.** `init` is where the storage
|
|
97
|
+
question gets asked and answered, once, and written down.
|
|
107
98
|
|
|
108
99
|
**Ask the operator these two things in chat — do not guess, do not pick a default:**
|
|
109
100
|
|
|
@@ -127,15 +118,11 @@ python3 "$SKILL_DIR/scripts/agent_sync.py" init --backend outline --url https://
|
|
|
127
118
|
python3 "$SKILL_DIR/scripts/agent_sync.py" init --backend fs
|
|
128
119
|
```
|
|
129
120
|
|
|
130
|
-
`init` writes `.claude/agent-sync.json` (shape, committed)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
without `--force`.
|
|
136
|
-
|
|
137
|
-
Relay those closing instructions to the operator verbatim. Getting the token into
|
|
138
|
-
the file is their step, and the design depends on it staying theirs.
|
|
121
|
+
`init` writes `.claude/agent-sync.json` (shape, committed) and `.env.agent-sync` with an
|
|
122
|
+
**empty** token line (identity, mode 600), gitignores both, and prints the one step that is
|
|
123
|
+
the operator's — creating the token and pasting it into that line. Never overwrites either
|
|
124
|
+
file without `--force`. **Relay those closing instructions verbatim**; the design depends on
|
|
125
|
+
the token staying theirs.
|
|
139
126
|
|
|
140
127
|
## Then, before every session
|
|
141
128
|
|
|
@@ -144,25 +131,23 @@ python3 "$SKILL_DIR/scripts/agent_sync.py" status
|
|
|
144
131
|
```
|
|
145
132
|
|
|
146
133
|
Idempotent. Inspects, repairs, reports, names exactly ONE next action — and carries
|
|
147
|
-
`check`'s verdict, so the command every session runs and the one that validates the
|
|
148
|
-
|
|
134
|
+
`check`'s verdict, so the command every session runs and the one that validates the setup
|
|
135
|
+
cannot give two answers about one project.
|
|
149
136
|
|
|
150
137
|
**Read the two awareness sections it prints — they are the point, not decoration.**
|
|
151
138
|
|
|
152
|
-
- **Other runs working this project right now.** Who holds what, this minute. Do not
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
This list is watermarked per run, so it stays quiet until something actually
|
|
158
|
-
changes; when it speaks, it matters.
|
|
139
|
+
- **Other runs working this project right now.** Who holds what, this minute. Do not take
|
|
140
|
+
those on, and do not "just look at" the files they cover.
|
|
141
|
+
- **New since you last looked.** Cross-repo dependency moves that landed while you were
|
|
142
|
+
away — watermarked per run, so it stays quiet until something changes. A dependency that
|
|
143
|
+
moved may unblock what you planned, or invalidate it.
|
|
159
144
|
|
|
160
145
|
An agent that skips this block will re-derive work someone else is doing and act on a
|
|
161
146
|
dependency state that changed an hour ago.
|
|
162
147
|
|
|
163
|
-
What else `status` decides: no credentials → degraded mode, reported, and it continues
|
|
164
|
-
|
|
165
|
-
|
|
148
|
+
What else `status` decides: no credentials → degraded mode, reported, and it continues;
|
|
149
|
+
`task-pipeline` absent → it prints the install line and stops. Do not improvise a substitute
|
|
150
|
+
flow — without those stages there is nothing to bind to.
|
|
166
151
|
|
|
167
152
|
```bash
|
|
168
153
|
npx sshlg-skills install
|
|
@@ -187,37 +172,32 @@ npx sshlg-skills install
|
|
|
187
172
|
| `guard <path>` | Answer whether this run may write that path. Exit 0 = yes, 2 = no |
|
|
188
173
|
| `board` | Regenerate the shared board and this repo's page. `--mirror` also renders the configured git docs into the plane |
|
|
189
174
|
| `whoami` | Print this run's id and its held leases |
|
|
190
|
-
| `residue` |
|
|
175
|
+
| `residue` | What a run left behind in BOTH planes: expired locks on disk, and claim tags with no live lease behind them |
|
|
191
176
|
| `reap [KEY…]` | Clear only what this run **proves** it owns and has spent, then re-read the directory to confirm it went |
|
|
192
177
|
| `setup` | Write the generated snapshot of how **this** project is wired, for agents to read |
|
|
193
178
|
| `adopt` | Inspect an existing project and **propose** a config — writes nothing |
|
|
194
|
-
| `merge` | Land this branch:
|
|
179
|
+
| `merge` | Land this branch: target fast-forwarded, conflicts checked **before** anything is touched, merge log written, the `--key` lease released. `--summary`, `--dry-run`, `--push` |
|
|
195
180
|
| `merges` | What landed while you were on your branch. `--all` includes the compacted tail |
|
|
196
181
|
| `check` | Validate the whole setup end to end. Non-zero when it is not healthy |
|
|
197
|
-
| `scaffold [--full]` | Create only what is missing, never a line over
|
|
198
|
-
| `finish [--gates]` | Is the **work** finished — every repository clean, pushed and pointed at,
|
|
182
|
+
| `scaffold [--full]` | Create only what is missing, never a line over what exists. `--full` also seeds the question register, index, dependency board, data model and docs gate |
|
|
183
|
+
| `finish [--gates]` | Is the **work** finished — every repository clean, pushed and pointed at, nothing left held. `check` answers whether the project is wired right; this, whether you are done |
|
|
199
184
|
|
|
200
|
-
`$SKILL_DIR` is this skill's own directory: `${CLAUDE_PLUGIN_ROOT}/skills/agent-sync`
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
listed.
|
|
185
|
+
`$SKILL_DIR` is this skill's own directory: `${CLAUDE_PLUGIN_ROOT}/skills/agent-sync` under
|
|
186
|
+
the Claude Code plugin, `~/.agents/skills/agent-sync` elsewhere. Resolve it once per session
|
|
187
|
+
and reuse it — do not guess. Every command reads `.claude/agent-sync.json` from the project
|
|
188
|
+
root and needs no arguments beyond those listed.
|
|
205
189
|
|
|
206
190
|
## One identity per session, and how it is decided
|
|
207
191
|
|
|
208
|
-
A lease is only a lease if two agents get two identities
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
guarded, and `release` takes a lease the caller never had.
|
|
192
|
+
A lease is only a lease if two agents get two identities, and both ends have bitten:
|
|
193
|
+
`CLAUDE_SESSION_ID` alone gave **one session two identities** — it acquired as one and was denied by
|
|
194
|
+
its own guard as the other — and one id per checkout gave **two sessions one identity**, silently,
|
|
195
|
+
so `release` took a lease the caller never had.
|
|
213
196
|
|
|
214
197
|
The order is: `AGENT_SYNC_RUN_ID` · `CLAUDE_SESSION_ID` · **the session that started this shell** ·
|
|
215
|
-
shared.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
`SessionStart` stamps `.agent-sync/sessions/<CLI pid>` with the session it knows, and a later
|
|
219
|
-
command finds itself by walking its own process ancestry to a stamped pid. Why that and not
|
|
220
|
-
command-line parsing: `references/earned-rules.md`.
|
|
198
|
+
shared. The third exists because a plain shell command has no session id and a hook does, so
|
|
199
|
+
`SessionStart` stamps `.agent-sync/sessions/<CLI pid>` and a later command walks its own process
|
|
200
|
+
ancestry to it. Why that and not command-line parsing: `references/earned-rules.md`.
|
|
221
201
|
|
|
222
202
|
When none of the four can be established the run says so — *"this identity is shared with any other
|
|
223
203
|
session in this checkout"* — rather than presenting a shared entry as separation.
|
|
@@ -235,6 +215,11 @@ TTL expires, and the next agent cannot tell "in progress" from "crashed an hour
|
|
|
235
215
|
claim is the tag in git, written through by `acquire` and cleared by `release`. One fact,
|
|
236
216
|
one home — do not invent a third place that records ownership.
|
|
237
217
|
|
|
218
|
+
**One notion of held, consulted by both planes.** The TTL ends the lease, so a tag naming a
|
|
219
|
+
run whose lease has ended is residue: reported by `status`, `residue` and `reconcile`, and
|
|
220
|
+
cleared by `release <KEY>`, which says whose it was. A tag whose lease is still live is
|
|
221
|
+
never touched by another run.
|
|
222
|
+
|
|
238
223
|
**Read `references/lease-protocol.md`** before changing acquisition, expiry, stealing or
|
|
239
224
|
id allocation.
|
|
240
225
|
|
|
@@ -246,65 +231,59 @@ The config lists registry files several agents write. Before editing one:
|
|
|
246
231
|
python3 "$SKILL_DIR/scripts/agent_sync.py" guard docs/DECISIONS.md
|
|
247
232
|
```
|
|
248
233
|
|
|
249
|
-
**Exit 2 is about *this run*: it holds no lease** — not that somebody else holds that
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
234
|
+
**Exit 2 is about *this run*: it holds no lease** — not that somebody else holds that file.
|
|
235
|
+
One lease covers every guarded file; hold one or write none. A denial names the other run
|
|
236
|
+
**and its key**, because "r-x holds a lease" beside a path gets repeated as "r-x holds this
|
|
237
|
+
file". Do not edit anyway, and do not "just fix one line" — a clobbered decision looks exactly
|
|
238
|
+
like a decision.
|
|
254
239
|
|
|
255
240
|
Claude Code's `PreToolUse` hook runs this for you. Elsewhere nothing does.
|
|
256
241
|
|
|
257
242
|
## Reserving an id
|
|
258
243
|
|
|
259
|
-
Reading a "Next free ID" line is not reserving it — two agents read the same number and
|
|
260
|
-
|
|
244
|
+
Reading a "Next free ID" line is not reserving it — two agents read the same number and both
|
|
245
|
+
use it.
|
|
261
246
|
|
|
262
247
|
```bash
|
|
263
248
|
python3 "$SKILL_DIR/scripts/agent_sync.py" reserve DEC # → DEC-0216
|
|
264
249
|
```
|
|
265
250
|
|
|
266
|
-
Allocation is positional over the **merged** log — every shard, never just this run's —
|
|
267
|
-
|
|
268
|
-
|
|
251
|
+
Allocation is positional over the **merged** log — every shard, never just this run's — so
|
|
252
|
+
every agent computes the same answer. Reserved and not written to git? `release-id` it, or the
|
|
253
|
+
number is a hole the board reports as a leak.
|
|
269
254
|
|
|
270
255
|
## Nothing in a log is ever edited or deleted
|
|
271
256
|
|
|
272
|
-
Logs are **replayed in order**, so an edit silently rewrites a conclusion other agents
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
overwritten. Lifetimes: `references/two-sources.md`.
|
|
257
|
+
Logs are **replayed in order**, so an edit silently rewrites a conclusion other agents already
|
|
258
|
+
acted on. Correct by **appending**: release a lease, `release-id` an unused id, supersede a
|
|
259
|
+
wrong as-built entry with a later one. Generated pages are the only exception. Lifetimes and
|
|
260
|
+
the generated-object contract: `references/two-sources.md`.
|
|
277
261
|
|
|
278
262
|
## Two documentation sources, and the duty to reconcile them
|
|
279
263
|
|
|
280
|
-
Git docs answer **how it should be**; the as-built record answers **how it actually
|
|
281
|
-
|
|
282
|
-
**the gap between them is the finding**, not a defect.
|
|
264
|
+
Git docs answer **how it should be**; the as-built record answers **how it actually is**.
|
|
265
|
+
Neither outranks the other, and **the gap between them is the finding**, not a defect.
|
|
283
266
|
|
|
284
267
|
The duty runs at both ends of a task: `reconcile` and resolve every divergence before
|
|
285
|
-
starting, then `record` and `reconcile` again after finishing. Building on an
|
|
286
|
-
|
|
268
|
+
starting, then `record` and `reconcile` again after finishing. Building on an unresolved
|
|
269
|
+
divergence is writing code against a system that does not exist.
|
|
287
270
|
|
|
288
|
-
**The trap: `reconcile` is mechanical and refuses to judge** whether the built thing
|
|
289
|
-
|
|
290
|
-
|
|
271
|
+
**The trap: `reconcile` is mechanical and refuses to judge** whether the built thing matches
|
|
272
|
+
the document — it compares ids, commits, presence and claim tags. That reading is yours, and
|
|
273
|
+
treating its green as agreement is how a divergence survives both ends.
|
|
291
274
|
|
|
292
|
-
Every project also carries a **generated snapshot** of its own wiring (`setup`) — commit
|
|
293
|
-
|
|
294
|
-
inferring it.
|
|
275
|
+
Every project also carries a **generated snapshot** of its own wiring (`setup`) — commit it
|
|
276
|
+
and link it from the agent instructions, so agents read the pipeline instead of inferring it.
|
|
295
277
|
|
|
296
|
-
**Read `references/two-sources.md`** before the first reconcile, and whenever deciding
|
|
297
|
-
|
|
298
|
-
decides, why the check is a ratchet, where a document belongs, and why nothing is
|
|
299
|
-
deleted.
|
|
278
|
+
**Read `references/two-sources.md`** before the first reconcile, and whenever deciding which
|
|
279
|
+
side a document belongs on.
|
|
300
280
|
|
|
301
281
|
## Binding to task-pipeline
|
|
302
282
|
|
|
303
|
-
This skill supplies stages; the names are `task-pipeline`'s own.
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
reaches git; **9** the main write point; **10** `merge` or `release` every lease.
|
|
283
|
+
This skill supplies stages; the names are `task-pipeline`'s own. Five of the eleven stages
|
|
284
|
+
carry an ordering rule: **0** `acquire` before the brief is committed; **1** `reconcile` before
|
|
285
|
+
writing code; **3** `reserve` every id before it reaches git; **9** the main write point;
|
|
286
|
+
**10** `merge` or `release` every lease.
|
|
308
287
|
|
|
309
288
|
**Read `references/pipeline-binding.md`** when wiring `pipeline.json` — it holds the
|
|
310
289
|
per-stage reasoning, the `skills[]` entries, what must be guarded, and the gate
|
|
@@ -326,41 +305,17 @@ the collection id to paste beside it. Found here, or in a superproject, or where
|
|
|
326
305
|
set -a && . ./.env.agent-sync && set +a
|
|
327
306
|
```
|
|
328
307
|
|
|
329
|
-
Never write a host name or token into the config, a test, an example or a commit
|
|
330
|
-
handle
|
|
331
|
-
tell them to put it in that file instead.
|
|
308
|
+
Never write a host name or token into the config, a test, an example or a commit; never
|
|
309
|
+
handle, echo or `argv`-pass a token value. Offered one in chat, point at that file instead.
|
|
332
310
|
|
|
333
311
|
**A submodule's config declares only its own registers.** Cross-repository facts belong to
|
|
334
312
|
the parent; a service repo listing the parent's decision register is a config defect.
|
|
335
313
|
|
|
336
|
-
## Backends
|
|
337
|
-
|
|
338
|
-
| Backend | Read when |
|
|
339
|
-
|---|---|
|
|
340
|
-
| `outline` | `references/backend-outline.md` — before any Outline call |
|
|
341
|
-
| `fs` | `references/backend-fs.md` — local files, no shared awareness |
|
|
342
|
-
|
|
343
|
-
**Read `references/adapter-contract.md` before adding a backend** — six primitives, the
|
|
344
|
-
capability flags, and a degradation path that must be honest.
|
|
345
|
-
|
|
346
|
-
## Generated objects
|
|
347
|
-
|
|
348
|
-
The board and the mirror are machine-written. Their first line is
|
|
349
|
-
|
|
350
|
-
```
|
|
351
|
-
<!-- agent-sync:generated source=<repo>@<sha> at=<iso8601> — edit in git, not here -->
|
|
352
|
-
```
|
|
353
|
-
|
|
354
|
-
A write to an object missing that marker is refused, not forced. If a human took over a
|
|
355
|
-
generated page, report it and stop.
|
|
356
|
-
|
|
357
|
-
The mirror is a **rendering** of git, stamped with the source commit. It has no
|
|
358
|
-
authority. When its stamp and `HEAD` disagree, the board gate fails — that is
|
|
359
|
-
drift, not a formatting problem.
|
|
360
|
-
|
|
361
314
|
## Non-negotiables
|
|
362
315
|
|
|
363
316
|
- Append, read back, then act. Never rewrite a coordination document.
|
|
317
|
+
- A generated object without its `agent-sync:generated` marker is **refused**, not
|
|
318
|
+
overwritten — a human took it over. Contract: `references/two-sources.md`.
|
|
364
319
|
- `release` what you `acquire`, on every path including failure.
|
|
365
320
|
- A run reports what it leaves behind: expiry ends a lease, not the file — `residue`.
|
|
366
321
|
- Credentials never reach `argv`, a log line, or the repository.
|
|
@@ -375,16 +330,15 @@ Each file is loaded on its own trigger, not by default.
|
|
|
375
330
|
|
|
376
331
|
| File | Read it when |
|
|
377
332
|
|---|---|
|
|
378
|
-
| `references/adapter-contract.md` | adding or auditing a knowledge backend |
|
|
333
|
+
| `references/adapter-contract.md` | adding or auditing a knowledge backend — six primitives, the capability flags, an honest degradation path |
|
|
379
334
|
| `references/lease-protocol.md` | changing acquisition, expiry, stealing or id allocation |
|
|
380
335
|
| `references/backend-outline.md` | making any Outline API call, or debugging one |
|
|
381
336
|
| `references/backend-fs.md` | running without a cloud backend, or explaining degraded mode |
|
|
382
337
|
| `references/pipeline-binding.md` | wiring `pipeline.json`, or adding a stage hook |
|
|
383
338
|
| `references/hooks.md` | installing, debugging or removing the Claude Code hooks |
|
|
384
|
-
| `references/two-sources.md` | before the first reconcile,
|
|
339
|
+
| `references/two-sources.md` | before the first reconcile, deciding where a document belongs, or writing a generated object |
|
|
385
340
|
| `references/roadmap.md` | configuring `claimTags`, taking or closing a task, or re-planning a board |
|
|
386
341
|
| `references/branching.md` | starting work that will produce commits, merging a branch, or asking what landed while you were away |
|
|
387
342
|
| `references/earned-rules.md` | asking why identity resolves the way it does, or why `finish` exists |
|
|
388
343
|
|
|
389
|
-
|
|
390
|
-
`https://raw.githubusercontent.com/ssheleg/agent-sync/main/plugins/agent-sync/skills/agent-sync/references/<file>`.
|
|
344
|
+
Missing `references/`? Fetch from `https://raw.githubusercontent.com/ssheleg/agent-sync/main/plugins/agent-sync/skills/agent-sync/references/<file>`.
|
|
@@ -112,6 +112,21 @@ renewed in between.
|
|
|
112
112
|
|
|
113
113
|
The tool reports which guarantee is in force; it never implies the stronger one.
|
|
114
114
|
|
|
115
|
+
**Residue in this mode is on the remote, and the enumerating read goes there.** The local
|
|
116
|
+
directory holds a *note* — `_note_local` writes one only for the run that won the key on
|
|
117
|
+
this machine — so a ref won elsewhere leaves nothing to walk, and for a while `residue`
|
|
118
|
+
could print `nothing on disk` over an expired lease sitting on the remote. That is the one
|
|
119
|
+
shape a residue report must never take. `residue` and `reap` now sweep
|
|
120
|
+
`refs/agent-sync/leases/*` on the configured remote and classify each ref with **the same
|
|
121
|
+
classifier the local plane uses**, because whose lease this is must have one definition.
|
|
122
|
+
`reap` deletes with the same `--force-with-lease=<ref>:<sha>` compare-and-swap `release`
|
|
123
|
+
uses, and proves the ref went by re-reading `ls-remote` — a push's exit code is the wish,
|
|
124
|
+
the second read is the state.
|
|
125
|
+
|
|
126
|
+
**A remote that cannot be reached is `COULD NOT LOOK`, never an empty sweep.** The two read
|
|
127
|
+
identically to an operator and mean opposite things, so they are printed differently and
|
|
128
|
+
`reap` exits non-zero on the first.
|
|
129
|
+
|
|
115
130
|
## Expiry and stealing
|
|
116
131
|
|
|
117
132
|
A lock is expired when `now > ts + ttl` for the timestamp inside it.
|
|
@@ -182,6 +197,28 @@ only thing that removes any of it. The split between the two kinds is the mechan
|
|
|
182
197
|
In doubt the answer is `ambiguous`, never `reapable`. A classifier that resolves doubt by
|
|
183
198
|
deleting is worse than none, because it deletes under a claim of authority.
|
|
184
199
|
|
|
200
|
+
**And that has a consequence the classifier cannot fix: nobody could ever clear an
|
|
201
|
+
`ambiguous` lock.** A shell with no session id answers to the shared identity, so a
|
|
202
|
+
matching run id proves nothing, so the lock is `ambiguous`, so no run reaps it — for ever.
|
|
203
|
+
28 of them had accumulated on this machine by 2026-08-20, the oldest overdue by 21 days
|
|
204
|
+
against a 2700-second TTL, every one of them correctly refused.
|
|
205
|
+
|
|
206
|
+
`reap --i-own-this <key>` is the way out, and it is a **person's** decision rather than a
|
|
207
|
+
run's proof. M-50 forbids a RUN from deleting what it cannot establish; it does not forbid
|
|
208
|
+
an operator from deciding. Three things keep it a decision instead of a sweep:
|
|
209
|
+
|
|
210
|
+
* **named keys, and it refuses to run without them** — a blanket override is the sweep the
|
|
211
|
+
classifier exists to refuse, wearing a flag. Use `--` before a key that starts with a
|
|
212
|
+
dash: the tool slugifies guarded-file paths into keys like
|
|
213
|
+
`-claude-plugin-marketplace-json`, and a key the tool writes must be a key the tool can
|
|
214
|
+
address;
|
|
215
|
+
* **it refuses a LIVE lease** — residue is what it clears, and a live lease belongs to a run
|
|
216
|
+
that may still be working;
|
|
217
|
+
* **it prints the payload it destroyed** — run, timestamp, machine, how long expired — and
|
|
218
|
+
journals the decision where a record plane is configured. The classifier's verdict is
|
|
219
|
+
printed unchanged beside it, because the override does not make the lock provably yours;
|
|
220
|
+
it makes the deletion attributable.
|
|
221
|
+
|
|
185
222
|
**Teardown is verified by re-reading the state, never by the delete's return value.**
|
|
186
223
|
`unlink` returns nothing and raises nothing on a filesystem where the entry survives the
|
|
187
224
|
call — a read-only mount, an NFS write that never lands, another process recreating the
|
|
@@ -130,6 +130,16 @@ Generated pages are the exception, and a narrow one: they are rewritten wholesal
|
|
|
130
130
|
page whose first line has lost its `agent-sync:generated` marker is **refused** rather
|
|
131
131
|
than overwritten, because a human took it over.
|
|
132
132
|
|
|
133
|
+
**The marker itself.** The board and the mirror are machine-written, and their first line is
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
<!-- agent-sync:generated source=<repo>@<sha> at=<iso8601> — edit in git, not here -->
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
A write to an object missing it is refused, not forced; report the takeover and stop. The
|
|
140
|
+
mirror is a **rendering** of git stamped with the source commit, and has no authority — when
|
|
141
|
+
its stamp and `HEAD` disagree the board gate fails, and that is drift, not formatting.
|
|
142
|
+
|
|
133
143
|
**Growth.** These logs are small — one line per event — so rotation is not urgent. When a
|
|
134
144
|
log does need trimming, archive the whole document and start a fresh one with a `base`
|
|
135
145
|
line carrying the current allocation state. Never delete lines from a live log to shrink
|
|
@@ -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.16.0"
|
|
37
37
|
|
|
38
38
|
CONFIG_PATH = Path(".claude/agent-sync.json")
|
|
39
39
|
ENV_FILE = Path(".env.agent-sync")
|
|
@@ -847,6 +847,34 @@ REAPABLE = "reapable"
|
|
|
847
847
|
FOREIGN = "foreign"
|
|
848
848
|
AMBIGUOUS = "ambiguous"
|
|
849
849
|
|
|
850
|
+
# The same three-way split for the OTHER plane: a claim tag written into a registry file.
|
|
851
|
+
# `held` there means the lease the tag names is still inside its TTL; `orphan` means it is
|
|
852
|
+
# not, and `disputed` means the lease is live under a different run than the tag names.
|
|
853
|
+
TAG_HELD = "held"
|
|
854
|
+
ORPHAN = "orphan"
|
|
855
|
+
DISPUTED = "disputed"
|
|
856
|
+
|
|
857
|
+
DEFAULT_CLAIM_TEMPLATE = "{prev} (claimed: {holder})"
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def claim_marker_re(template: str) -> "re.Pattern[str] | None":
|
|
861
|
+
"""A regex for this template's claim marker, capturing the run it names.
|
|
862
|
+
|
|
863
|
+
`write_claim` builds the marker by emptying `{prev}` and substituting the run id. Read
|
|
864
|
+
back, the run id is the one part not known in advance: it is the capture and everything
|
|
865
|
+
around it is literal, so one function defines the marker in both directions instead of
|
|
866
|
+
two spellings that can disagree.
|
|
867
|
+
|
|
868
|
+
`None` for a template with no `{holder}`. Such a marker names no owner, and a tag whose
|
|
869
|
+
owner cannot be read must never be cleared on somebody's behalf — the same rule
|
|
870
|
+
`classify_lock` applies to a lock that records no run.
|
|
871
|
+
"""
|
|
872
|
+
marker = template.replace("{prev}", "").strip()
|
|
873
|
+
if "{holder}" not in marker:
|
|
874
|
+
return None
|
|
875
|
+
head, _, tail = marker.partition("{holder}")
|
|
876
|
+
return re.compile(re.escape(head) + r"(?P<holder>[^|\s]+)" + re.escape(tail))
|
|
877
|
+
|
|
850
878
|
|
|
851
879
|
def classify_lock(key: str, raw: str, *, rid: str, identity_is_strong: bool,
|
|
852
880
|
repo: str, host: str, default_ttl: int, at: float) -> dict[str, Any]:
|
|
@@ -872,8 +900,8 @@ def classify_lock(key: str, raw: str, *, rid: str, identity_is_strong: bool,
|
|
|
872
900
|
session, and a shell with no session id is served one shared entry whose own
|
|
873
901
|
docstring says the identity "is shared with any other session in this checkout".
|
|
874
902
|
Under that key a matching run id proves nothing, so it does not license a delete.
|
|
875
|
-
- its `repo` is this checkout, and its `host` — written by
|
|
876
|
-
|
|
903
|
+
- its `repo` is this checkout, and its `host` — written by both lease modes since
|
|
904
|
+
AS-03, absent only in locks taken before it — is this machine.
|
|
877
905
|
|
|
878
906
|
Everything else is reported and left alone: `foreign` where it demonstrably belongs to
|
|
879
907
|
somebody else, `ambiguous` where the question cannot be answered at all. In doubt the
|
|
@@ -1018,6 +1046,7 @@ class Sync:
|
|
|
1018
1046
|
self.rid = run_id(self.root)
|
|
1019
1047
|
self.ttl = int(self.cfg.get("leaseTtlSeconds") or DEFAULT_TTL)
|
|
1020
1048
|
self._identity: tuple[str, str] | None = None
|
|
1049
|
+
self._holders: dict[str, str | None] = {}
|
|
1021
1050
|
|
|
1022
1051
|
@property
|
|
1023
1052
|
def gated(self) -> bool:
|
|
@@ -1284,8 +1313,14 @@ class Sync:
|
|
|
1284
1313
|
return won, holder
|
|
1285
1314
|
|
|
1286
1315
|
lock = self._local_lock(key)
|
|
1316
|
+
# `host` is written in BOTH lease modes (AS-03). It used to be the git mode's alone,
|
|
1317
|
+
# on the reasoning that a `local` lease is machine-local by construction — but the
|
|
1318
|
+
# *file* is not: a checkout on a synced or shared directory is read by two machines,
|
|
1319
|
+
# and `classify_lock` consumes `host` to decide `foreign`. Without it the classifier
|
|
1320
|
+
# had one fewer way to refuse, on 25 of the 25 locks this family had on disk
|
|
1321
|
+
# (2026-08-20). Absent stays legal: locks written before this line exist.
|
|
1287
1322
|
payload = json.dumps({"run": self.rid, "ts": now_iso(), "ttl": self.ttl,
|
|
1288
|
-
"repo": repo_name()})
|
|
1323
|
+
"repo": repo_name(), "host": platform.node()})
|
|
1289
1324
|
|
|
1290
1325
|
if lock.exists():
|
|
1291
1326
|
try:
|
|
@@ -1509,6 +1544,102 @@ class Sync:
|
|
|
1509
1544
|
"""
|
|
1510
1545
|
return bool(self.identity[0])
|
|
1511
1546
|
|
|
1547
|
+
# -- the git plane's own enumerating read (AS-01a) -------------------------
|
|
1548
|
+
|
|
1549
|
+
def _git_lease_refs(self) -> tuple[list[tuple[str, str]], str | None]:
|
|
1550
|
+
"""Every lease ref on the remote as (sha, key), or the reason it could not look.
|
|
1551
|
+
|
|
1552
|
+
The authority in git mode is `refs/agent-sync/leases/*` on the remote, and a ref
|
|
1553
|
+
won on another machine leaves NO local note — `_note_local` only fires for the run
|
|
1554
|
+
that won it here. So the local directory walk cannot see it, and for a while
|
|
1555
|
+
`residue` could print `nothing on disk` over an expired lease sitting on the
|
|
1556
|
+
remote. That is the one shape a residue report must never take.
|
|
1557
|
+
|
|
1558
|
+
The failure is returned rather than raised, because the caller has to be able to
|
|
1559
|
+
say `could not look` instead of `nothing there`.
|
|
1560
|
+
"""
|
|
1561
|
+
remote = self._git_remote()
|
|
1562
|
+
r = subprocess.run(["git", "ls-remote", remote, "refs/agent-sync/leases/*"],
|
|
1563
|
+
capture_output=True, text=True)
|
|
1564
|
+
if r.returncode != 0:
|
|
1565
|
+
why = (r.stderr or r.stdout or "").strip().splitlines()
|
|
1566
|
+
return [], (why[-1] if why else f"`git ls-remote {remote}` failed")
|
|
1567
|
+
out = []
|
|
1568
|
+
for line in r.stdout.strip().splitlines():
|
|
1569
|
+
parts = line.split()
|
|
1570
|
+
if len(parts) != 2 or not parts[1].startswith("refs/agent-sync/leases/"):
|
|
1571
|
+
continue
|
|
1572
|
+
out.append((parts[0], parts[1].rsplit("/", 1)[-1]))
|
|
1573
|
+
return out, None
|
|
1574
|
+
|
|
1575
|
+
def _git_lease_payload(self, sha: str) -> str:
|
|
1576
|
+
"""The lease object's body — the same JSON the local note carries.
|
|
1577
|
+
|
|
1578
|
+
One shape for both planes, so `classify_lock` is the only classifier in this file.
|
|
1579
|
+
A second one would be a second definition of *whose lease this is*, which is the
|
|
1580
|
+
question the whole tool exists to answer once.
|
|
1581
|
+
"""
|
|
1582
|
+
body = git("log", "-1", "--format=%B", sha)
|
|
1583
|
+
if body:
|
|
1584
|
+
return body.strip()
|
|
1585
|
+
# The object may not be local yet: the ref lives on the remote.
|
|
1586
|
+
subprocess.run(["git", "fetch", "-q", self._git_remote(), sha],
|
|
1587
|
+
capture_output=True, text=True)
|
|
1588
|
+
return (git("log", "-1", "--format=%B", sha) or "").strip()
|
|
1589
|
+
|
|
1590
|
+
def git_residue(self) -> tuple[list[dict[str, Any]], str | None]:
|
|
1591
|
+
"""The git plane, classified by the same rules as the local one."""
|
|
1592
|
+
refs, why = self._git_lease_refs()
|
|
1593
|
+
if why is not None:
|
|
1594
|
+
return [], why
|
|
1595
|
+
now, host, repo = time.time(), platform.node(), repo_name()
|
|
1596
|
+
out: list[dict[str, Any]] = []
|
|
1597
|
+
for sha, key in sorted(refs, key=lambda x: x[1]):
|
|
1598
|
+
raw = self._git_lease_payload(sha)
|
|
1599
|
+
if raw:
|
|
1600
|
+
entry = classify_lock(key, raw, rid=self.rid,
|
|
1601
|
+
identity_is_strong=self.identity_is_strong,
|
|
1602
|
+
repo=repo, host=host, default_ttl=self.ttl, at=now)
|
|
1603
|
+
else:
|
|
1604
|
+
entry = {"key": key, "state": AMBIGUOUS, "run": None, "repo": None,
|
|
1605
|
+
"host": None, "ts": "", "expired_for": None,
|
|
1606
|
+
"why": "the lease object carries no readable payload"}
|
|
1607
|
+
entry["plane"] = "git"
|
|
1608
|
+
entry["ref"] = self._ref(key)
|
|
1609
|
+
entry["sha"] = sha
|
|
1610
|
+
entry["path"] = None
|
|
1611
|
+
out.append(entry)
|
|
1612
|
+
return out, None
|
|
1613
|
+
|
|
1614
|
+
def git_reap(self, entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
1615
|
+
"""Delete a reapable lease ref, and prove it went by looking again.
|
|
1616
|
+
|
|
1617
|
+
Same compare-and-swap `_git_release` uses: `--force-with-lease=<ref>:<sha>` refuses
|
|
1618
|
+
if somebody won the key between the read and the delete, which is the whole reason
|
|
1619
|
+
the sha travels with the entry. And the verdict comes from a second `ls-remote`,
|
|
1620
|
+
not from the push's exit code — a teardown that reports success out of its own
|
|
1621
|
+
return value is reporting the wish rather than the state.
|
|
1622
|
+
"""
|
|
1623
|
+
done: list[dict[str, Any]] = []
|
|
1624
|
+
for e in entries:
|
|
1625
|
+
r = subprocess.run(["git", "push", self._git_remote(),
|
|
1626
|
+
f"--force-with-lease={e['ref']}:{e['sha']}",
|
|
1627
|
+
f":{e['ref']}"], capture_output=True, text=True)
|
|
1628
|
+
after, why = self._git_lease_refs()
|
|
1629
|
+
still = {k for _s, k in after}
|
|
1630
|
+
if why is not None:
|
|
1631
|
+
e["gone"] = None
|
|
1632
|
+
e["why_gone"] = f"could not re-read the remote to prove it went ({why})"
|
|
1633
|
+
elif e["key"] in still:
|
|
1634
|
+
e["gone"] = False
|
|
1635
|
+
e["why_gone"] = ((r.stderr or "").strip().splitlines() or
|
|
1636
|
+
["the ref is still on the remote"])[-1]
|
|
1637
|
+
else:
|
|
1638
|
+
e["gone"] = True
|
|
1639
|
+
e["why_gone"] = ""
|
|
1640
|
+
done.append(e)
|
|
1641
|
+
return done
|
|
1642
|
+
|
|
1512
1643
|
def residue(self) -> list[dict[str, Any]]:
|
|
1513
1644
|
"""Every lock file in this checkout, classified — the live ones included.
|
|
1514
1645
|
|
|
@@ -1593,6 +1724,87 @@ class Sync:
|
|
|
1593
1724
|
return {"reaped": reaped, "remaining": remaining, "refused": refused,
|
|
1594
1725
|
"left": [e for e in before if e["state"] in (FOREIGN, AMBIGUOUS)]}
|
|
1595
1726
|
|
|
1727
|
+
def claim_tags_on_disk(self) -> list[dict[str, Any]]:
|
|
1728
|
+
"""Every claim tag written into a registry file, with the run each one names.
|
|
1729
|
+
|
|
1730
|
+
The enumerating read for the OTHER plane, and it did not exist. `held()`,
|
|
1731
|
+
`_lease_holder()` and `residue()` all answer from the lease; nothing answered from
|
|
1732
|
+
the board — so a tag whose lease had ended was reported by no command at all.
|
|
1733
|
+
`release` printed success and changed nothing (`write_claim` had no saved cell to
|
|
1734
|
+
undo), `residue` said "nothing on disk", `status` said "leases held: none", and
|
|
1735
|
+
`reconcile` never mentioned it. Filed as ssheleg/agent-sync#5 and reproduced
|
|
1736
|
+
verbatim at 1.14.0.
|
|
1737
|
+
"""
|
|
1738
|
+
out: list[dict[str, Any]] = []
|
|
1739
|
+
for pattern, spec in (self.cfg.get("claimTags") or {}).items():
|
|
1740
|
+
if spec.get("mode") != "cell":
|
|
1741
|
+
continue
|
|
1742
|
+
rx = claim_marker_re(spec.get("held") or DEFAULT_CLAIM_TEMPLATE)
|
|
1743
|
+
if rx is None:
|
|
1744
|
+
continue
|
|
1745
|
+
idx = int(spec.get("cell", -1))
|
|
1746
|
+
for path in sorted(glob_files(self.root, pattern)):
|
|
1747
|
+
if not path.is_file():
|
|
1748
|
+
continue
|
|
1749
|
+
try:
|
|
1750
|
+
lines = path.read_text().splitlines()
|
|
1751
|
+
except OSError:
|
|
1752
|
+
continue
|
|
1753
|
+
rel = str(path.relative_to(self.root))
|
|
1754
|
+
for n, line in enumerate(lines, 1):
|
|
1755
|
+
cells = self._row_cells(line)
|
|
1756
|
+
if not cells or not -len(cells) <= idx < len(cells):
|
|
1757
|
+
continue
|
|
1758
|
+
m = rx.search(cells[idx])
|
|
1759
|
+
if not m:
|
|
1760
|
+
continue
|
|
1761
|
+
out.append({"key": cells[0].strip(), "holder": m.group("holder"),
|
|
1762
|
+
"file": rel, "line": n, "cell": cells[idx].strip()})
|
|
1763
|
+
return out
|
|
1764
|
+
|
|
1765
|
+
def _holder_of(self, key: str) -> str | None:
|
|
1766
|
+
"""`_lease_holder`, memoised for the length of one command.
|
|
1767
|
+
|
|
1768
|
+
One notion of held, and only one reader of it. Memoised because in git mode that
|
|
1769
|
+
reader is an `ls-remote`: a report over a board with several tags would otherwise
|
|
1770
|
+
pay a network round-trip per row. The bound is the number of TAGGED rows, not the
|
|
1771
|
+
size of the board — an untagged row is never looked up.
|
|
1772
|
+
"""
|
|
1773
|
+
if key not in self._holders:
|
|
1774
|
+
self._holders[key] = self._lease_holder(key)
|
|
1775
|
+
return self._holders[key]
|
|
1776
|
+
|
|
1777
|
+
def orphan_claims(self) -> list[dict[str, Any]]:
|
|
1778
|
+
"""Claim tags with no live lease behind them — one notion of held, both planes.
|
|
1779
|
+
|
|
1780
|
+
The TTL is the contract. A tag is a claim about a lease, so once `_lease_holder` —
|
|
1781
|
+
the single reader every other command already trusts — answers `None`, the tag is
|
|
1782
|
+
residue in the registry exactly as an expired lock is residue on disk, and is
|
|
1783
|
+
reported the same way.
|
|
1784
|
+
|
|
1785
|
+
A tag naming a run that still holds the lease is not residue and never appears
|
|
1786
|
+
here. A tag naming one run while the live lease belongs to another is `disputed`:
|
|
1787
|
+
reported, never cleared, because clearing it would edit a registry under a run
|
|
1788
|
+
that is still working.
|
|
1789
|
+
"""
|
|
1790
|
+
out: list[dict[str, Any]] = []
|
|
1791
|
+
for e in self.claim_tags_on_disk():
|
|
1792
|
+
holder = self._holder_of(e["key"])
|
|
1793
|
+
if holder == e["holder"]:
|
|
1794
|
+
continue
|
|
1795
|
+
e = dict(e)
|
|
1796
|
+
if holder is None:
|
|
1797
|
+
e["state"] = ORPHAN
|
|
1798
|
+
e["why"] = (f"the tag names run {e['holder']}, and the {self.lease_mode} "
|
|
1799
|
+
f"lease plane holds no live lease for `{e['key']}` — the TTL "
|
|
1800
|
+
"has already ended it")
|
|
1801
|
+
else:
|
|
1802
|
+
e["state"] = DISPUTED
|
|
1803
|
+
e["why"] = (f"the tag names run {e['holder']} while the live lease for "
|
|
1804
|
+
f"`{e['key']}` is held by {holder}")
|
|
1805
|
+
out.append(e)
|
|
1806
|
+
return out
|
|
1807
|
+
|
|
1596
1808
|
def held(self) -> list[str]:
|
|
1597
1809
|
d = self.root / STATE_DIR / "leases"
|
|
1598
1810
|
mine = []
|
|
@@ -2015,7 +2227,40 @@ class Sync:
|
|
|
2015
2227
|
state.setdefault(key, {})[str(rel)] = current
|
|
2016
2228
|
else:
|
|
2017
2229
|
if saved is None:
|
|
2018
|
-
|
|
2230
|
+
# NOT "nothing to undo" — that reading is ssheleg/agent-sync#5. The
|
|
2231
|
+
# state file is this run's memory of what it overwrote, and a tag
|
|
2232
|
+
# outlives it routinely: written by a run that died, by a session on
|
|
2233
|
+
# another machine, or with `.agent-sync/` wiped between the acquire and
|
|
2234
|
+
# the release. `release` then printed success, exited 0, and left
|
|
2235
|
+
# `(claimed: r-…)` on the board with nothing behind it — a claim no
|
|
2236
|
+
# command could reach.
|
|
2237
|
+
#
|
|
2238
|
+
# The TTL decides, exactly as it decides for a lock file: the lease
|
|
2239
|
+
# plane is asked who holds the key, and only a tag with no live lease
|
|
2240
|
+
# behind it is cleared. A live lease is somebody working, and its tag
|
|
2241
|
+
# is left alone whoever runs this.
|
|
2242
|
+
template = spec.get("held") or DEFAULT_CLAIM_TEMPLATE
|
|
2243
|
+
rx = claim_marker_re(template)
|
|
2244
|
+
m = rx.search(current) if rx else None
|
|
2245
|
+
if m is None:
|
|
2246
|
+
continue # no tag here either, so nothing to undo
|
|
2247
|
+
whose = m.group("holder")
|
|
2248
|
+
live = self._holder_of(key)
|
|
2249
|
+
if live is not None and live != self.rid:
|
|
2250
|
+
notes.append(
|
|
2251
|
+
f"{rel}: `{key}` carries a claim tag naming {whose}, and the "
|
|
2252
|
+
f"lease is live under {live} — left alone")
|
|
2253
|
+
continue
|
|
2254
|
+
stripped = (current[:m.start()] + current[m.end():]).strip()
|
|
2255
|
+
cells[idx] = f" {stripped} " if stripped else " "
|
|
2256
|
+
notes.append(
|
|
2257
|
+
f"{rel}: cleared an orphaned claim tag on `{key}` — it named run "
|
|
2258
|
+
f"{whose}, and no live lease stands behind it (ssheleg/agent-sync#5)")
|
|
2259
|
+
lines[i] = row_prefix + "|" + "|".join(cells) + row_suffix
|
|
2260
|
+
tmp = path.with_suffix(path.suffix + ".agent-sync.tmp")
|
|
2261
|
+
tmp.write_text("".join(lines))
|
|
2262
|
+
tmp.replace(path)
|
|
2263
|
+
continue
|
|
2019
2264
|
# Restoring VERBATIM loses any edit made while the claim was held, and in
|
|
2020
2265
|
# this family the claim cell IS the status cell — so `close then release`
|
|
2021
2266
|
# silently reopened a row closed with evidence minutes earlier (B-35,
|
|
@@ -2057,14 +2302,18 @@ class Sync:
|
|
|
2057
2302
|
that rewrites a shared registry file on its own is the exact mechanism that
|
|
2058
2303
|
clobbers another agent's work, and it would do it from a hook, unattended.
|
|
2059
2304
|
So this reports, and the agent writes.
|
|
2305
|
+
|
|
2306
|
+
Both directions, since AS-04. It used to return on `if not held` — so divergence
|
|
2307
|
+
was reported only to a run holding a lease, and the one shape that needs reporting
|
|
2308
|
+
most, a tag with NO lease behind it, was structurally invisible: nobody holds it,
|
|
2309
|
+
so nobody could be told (ssheleg/agent-sync#5). The lease side is still keyed by
|
|
2310
|
+
what this run holds; the registry side is now swept whether it holds anything or not.
|
|
2060
2311
|
"""
|
|
2061
2312
|
out: list[str] = []
|
|
2062
2313
|
tags = self.cfg.get("claimTags") or {}
|
|
2063
2314
|
if not tags:
|
|
2064
2315
|
return out
|
|
2065
2316
|
held = set(self.held())
|
|
2066
|
-
if not held:
|
|
2067
|
-
return out
|
|
2068
2317
|
for pattern, spec in tags.items():
|
|
2069
2318
|
for path in sorted(self.root.glob(pattern)):
|
|
2070
2319
|
if not path.is_file():
|
|
@@ -2092,6 +2341,15 @@ class Sync:
|
|
|
2092
2341
|
out.append(f"{rel}: cannot verify the claim tag for `{key}` — "
|
|
2093
2342
|
f"`{spec['open']}` appears in the file but not on that "
|
|
2094
2343
|
"id's line. Fix claimTags, or write the tag by hand")
|
|
2344
|
+
|
|
2345
|
+
# The other direction: a tag whose lease the TTL has already ended, or one naming a
|
|
2346
|
+
# run that is not the run holding the key. Reported to every session, not only to a
|
|
2347
|
+
# session that happens to hold a lease.
|
|
2348
|
+
for e in self.orphan_claims():
|
|
2349
|
+
remedy = ("`release` it" if e["state"] == ORPHAN
|
|
2350
|
+
else "ask the holder, and do not edit the row")
|
|
2351
|
+
out.append(f"{e['file']}:{e['line']}: `{e['key']}` [{e['state']}] — "
|
|
2352
|
+
f"{e['why']}. NEXT: {remedy}")
|
|
2095
2353
|
return out
|
|
2096
2354
|
|
|
2097
2355
|
# -- as-built record and reconciliation ---------------------------------
|
|
@@ -2231,6 +2489,15 @@ class Sync:
|
|
|
2231
2489
|
"no id registers declared here, so register checks are not evaluated in "
|
|
2232
2490
|
"this repository — run reconcile in the umbrella for those")
|
|
2233
2491
|
|
|
2492
|
+
# 4. A claim tag on the board whose lease the TTL has already ended. Mechanical,
|
|
2493
|
+
# two-plane divergence — the definition of what this command reports — and it
|
|
2494
|
+
# was the one kind `reconcile` never mentioned (ssheleg/agent-sync#5).
|
|
2495
|
+
for e in self.orphan_claims():
|
|
2496
|
+
findings.append({
|
|
2497
|
+
"kind": f"claim tag with no live lease ({e['state']})",
|
|
2498
|
+
"detail": f"{e['file']}:{e['line']} `{e['key']}` names {e['holder']}",
|
|
2499
|
+
"means": e["why"]})
|
|
2500
|
+
|
|
2234
2501
|
self.backlog = notes_backlog
|
|
2235
2502
|
return findings
|
|
2236
2503
|
|
|
@@ -2775,6 +3042,26 @@ def cmd_status(_args: argparse.Namespace) -> int:
|
|
|
2775
3042
|
if left_alone:
|
|
2776
3043
|
print(" The rest are foreign or ambiguously owned — reported, not touched.")
|
|
2777
3044
|
|
|
3045
|
+
# The registry plane's residue, on the same footing as the lease plane's. A claim tag
|
|
3046
|
+
# is a claim about a lease, and until this line the TTL could end the lease while the
|
|
3047
|
+
# tag stayed on the board reading live — with `leases held: none` printed above it.
|
|
3048
|
+
try:
|
|
3049
|
+
orphans = s.orphan_claims()
|
|
3050
|
+
except Fail:
|
|
3051
|
+
orphans = []
|
|
3052
|
+
if not (s.cfg.get("claimTags") or {}):
|
|
3053
|
+
pass
|
|
3054
|
+
elif not orphans:
|
|
3055
|
+
print(" orphan claims : none")
|
|
3056
|
+
else:
|
|
3057
|
+
print(f" orphan claims : {len(orphans)} claim tag(s) with no live lease behind "
|
|
3058
|
+
"them")
|
|
3059
|
+
for e in orphans[:6]:
|
|
3060
|
+
print(f" · {e['file']}:{e['line']} {e['key']} [{e['state']}] {e['why']}")
|
|
3061
|
+
if len(orphans) > 6:
|
|
3062
|
+
print(f" · … and {len(orphans) - 6} more")
|
|
3063
|
+
print(" Clear one: agent_sync.py release <KEY>")
|
|
3064
|
+
|
|
2778
3065
|
# Who else is in here, and what landed while this run was away. Without this a
|
|
2779
3066
|
# lease only tells an agent it is blocked, never who by or on what.
|
|
2780
3067
|
plane_broken = False
|
|
@@ -2820,7 +3107,7 @@ def cmd_status(_args: argparse.Namespace) -> int:
|
|
|
2820
3107
|
|
|
2821
3108
|
claim_issues = s.claim_divergence()
|
|
2822
3109
|
if claim_issues:
|
|
2823
|
-
print("\n Claim tags
|
|
3110
|
+
print("\n Claim tags out of step with the lease plane:")
|
|
2824
3111
|
for c in claim_issues:
|
|
2825
3112
|
print(f" ! {c}")
|
|
2826
3113
|
|
|
@@ -2952,9 +3239,8 @@ def cmd_residue(_args: argparse.Namespace) -> int:
|
|
|
2952
3239
|
print(f"lease mode {s.lease_mode} · ttl {s.ttl}s · {len(entries)} lock file(s) in "
|
|
2953
3240
|
f"{STATE_DIR}/leases\n")
|
|
2954
3241
|
if not entries:
|
|
2955
|
-
print(" nothing
|
|
2956
|
-
"was released")
|
|
2957
|
-
return 0
|
|
3242
|
+
print(" nothing in the lock directory — no lease has been taken in this checkout, "
|
|
3243
|
+
"or every\n one was released")
|
|
2958
3244
|
for e in entries:
|
|
2959
3245
|
print(f" {e['key']}")
|
|
2960
3246
|
print(f" state : {e['state']}"
|
|
@@ -2964,21 +3250,79 @@ def cmd_residue(_args: argparse.Namespace) -> int:
|
|
|
2964
3250
|
print(f" why : {e['why']}")
|
|
2965
3251
|
reapable = [e for e in entries if e["state"] == REAPABLE]
|
|
2966
3252
|
other = [e for e in entries if e["state"] in (FOREIGN, AMBIGUOUS)]
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
3253
|
+
if entries:
|
|
3254
|
+
print()
|
|
3255
|
+
if reapable:
|
|
3256
|
+
print(f" {len(reapable)} reapable — this run's own, spent: agent_sync.py reap")
|
|
3257
|
+
if other:
|
|
3258
|
+
print(f" {len(other)} foreign or ambiguous — reported, never cleared from here. "
|
|
3259
|
+
"An expired\n lock in another run's name is that run's to explain, and a "
|
|
3260
|
+
"lock whose owner\n cannot be established is nobody's to delete.")
|
|
3261
|
+
if not reapable and not other:
|
|
3262
|
+
print(" no residue — every lock on disk is a live lease")
|
|
3263
|
+
|
|
3264
|
+
# The second plane. A claim tag is residue too, and reporting only the lock directory
|
|
3265
|
+
# was the whole of ssheleg/agent-sync#5: `residue` printed "nothing on disk" over a
|
|
3266
|
+
# board row reading `(claimed: r-…)` that no command could reach.
|
|
3267
|
+
if s.cfg.get("claimTags"):
|
|
3268
|
+
orphans = s.orphan_claims()
|
|
3269
|
+
tags = len(s.claim_tags_on_disk())
|
|
3270
|
+
print(f"\n claim tags: {tags} on disk, {len(orphans)} with no live lease behind them")
|
|
3271
|
+
for e in orphans:
|
|
3272
|
+
print(f" · {e['file']}:{e['line']} {e['key']} [{e['state']}]")
|
|
3273
|
+
print(f" why : {e['why']}")
|
|
3274
|
+
if e["state"] == ORPHAN:
|
|
3275
|
+
print(f" clear : agent_sync.py release {e['key']}")
|
|
3276
|
+
else:
|
|
3277
|
+
print(" leave : the lease is live under another run — ask the holder")
|
|
3278
|
+
if tags and not orphans:
|
|
3279
|
+
print(" every tag names the run that still holds its key")
|
|
3280
|
+
else:
|
|
3281
|
+
print("\n claim tags: not configured here, so none are swept "
|
|
3282
|
+
"(`claimTags` in the config)")
|
|
3283
|
+
|
|
3284
|
+
# AS-01a. The sweep, and the disclosure is now about what it COULD NOT reach rather
|
|
3285
|
+
# than about a plane nobody read. A check that cannot look must not read as one that
|
|
3286
|
+
# looked — and a check that CAN look must not keep printing that it cannot.
|
|
3287
|
+
if s.lease_mode == "git":
|
|
3288
|
+
remote = s.cfg.get("leaseRemote") or "origin"
|
|
3289
|
+
refs, why = s.git_residue()
|
|
3290
|
+
print(f"\n git plane · {remote} · refs/agent-sync/leases/*")
|
|
3291
|
+
if why is not None:
|
|
3292
|
+
print(f" ⚠ COULD NOT LOOK — {why}")
|
|
3293
|
+
print(" So this is not an empty sweep: the refs may be there and unread. "
|
|
3294
|
+
"Fix the remote, or")
|
|
3295
|
+
print(f" enumerate by hand: git ls-remote {remote} "
|
|
3296
|
+
"'refs/agent-sync/leases/*'")
|
|
3297
|
+
elif not refs:
|
|
3298
|
+
print(" no lease refs on the remote — swept and empty, not unread")
|
|
3299
|
+
else:
|
|
3300
|
+
local_keys = {e["key"] for e in entries}
|
|
3301
|
+
reapable = [e for e in refs if e["state"] == REAPABLE]
|
|
3302
|
+
for e in refs:
|
|
3303
|
+
also = " · also noted locally" if e["key"] in local_keys else ""
|
|
3304
|
+
print(f" · {e['key']} [{e['state']}]{also}")
|
|
3305
|
+
print(f" ref : {e['ref']} @ {e['sha'][:10]}")
|
|
3306
|
+
if e.get("run"):
|
|
3307
|
+
print(f" run : {e['run']}"
|
|
3308
|
+
+ (f" · host {e['host']}" if e.get("host") else ""))
|
|
3309
|
+
# `spent()` says this once for both planes: a second phrasing here is a
|
|
3310
|
+
# second definition of what residue means.
|
|
3311
|
+
print(f" state : {spent(e)}")
|
|
3312
|
+
if e.get("why"):
|
|
3313
|
+
print(f" why : {e['why']}")
|
|
3314
|
+
if e["state"] == REAPABLE:
|
|
3315
|
+
print(f" clear : agent_sync.py reap {e['key']}")
|
|
3316
|
+
print(f" {len(refs)} ref(s) on the remote, {len(reapable)} this run can prove "
|
|
3317
|
+
"it owns and has spent")
|
|
2976
3318
|
return 0
|
|
2977
3319
|
|
|
2978
3320
|
|
|
2979
3321
|
def cmd_reap(args: argparse.Namespace) -> int:
|
|
2980
3322
|
"""Clear this run's spent locks, and verify the teardown by reading the state again."""
|
|
2981
3323
|
s = Sync()
|
|
3324
|
+
if getattr(args, "own", False):
|
|
3325
|
+
return _reap_by_operator_decision(s, args.keys)
|
|
2982
3326
|
result = s.reap(args.keys or None)
|
|
2983
3327
|
for e in result["reaped"]:
|
|
2984
3328
|
print(f" reaped {e['key']} — {e['why']}, confirmed gone by re-reading "
|
|
@@ -2993,9 +3337,116 @@ def cmd_reap(args: argparse.Namespace) -> int:
|
|
|
2993
3337
|
if not args.keys:
|
|
2994
3338
|
for e in result["left"]:
|
|
2995
3339
|
print(f" · {e['key']} [{e['state']}] left alone — {e['why']}")
|
|
2996
|
-
|
|
3340
|
+
# AS-01a. The git plane is a second place residue lives, and until this it was a
|
|
3341
|
+
# second place nothing could clear: `reap` walked the lock directory only, so an
|
|
3342
|
+
# expired ref this run had won stayed on the remote with no command able to reach it.
|
|
3343
|
+
git_bad = 0
|
|
3344
|
+
if s.lease_mode == "git":
|
|
3345
|
+
refs, why = s.git_residue()
|
|
3346
|
+
remote = s.cfg.get("leaseRemote") or "origin"
|
|
3347
|
+
if why is not None:
|
|
3348
|
+
print(f" ⚠ the git plane could not be read ({why}) — its refs are "
|
|
3349
|
+
"neither reaped nor reported clean.", file=sys.stderr)
|
|
3350
|
+
print(f" Enumerate by hand: git ls-remote {remote} "
|
|
3351
|
+
"'refs/agent-sync/leases/*'", file=sys.stderr)
|
|
3352
|
+
git_bad = 1
|
|
3353
|
+
else:
|
|
3354
|
+
named = set(args.keys or [])
|
|
3355
|
+
want = [e for e in refs if e["state"] == REAPABLE and (not named or e["key"] in named)]
|
|
3356
|
+
for e in s.git_reap(want):
|
|
3357
|
+
if e["gone"]:
|
|
3358
|
+
print(f" reaped {e['key']} on {remote} — {e['why']}, confirmed gone by "
|
|
3359
|
+
"re-reading the remote")
|
|
3360
|
+
else:
|
|
3361
|
+
print(f" ✗ {e['key']} is STILL on {remote} after the delete "
|
|
3362
|
+
f"({e['why_gone']}) — the compare-and-swap refused, which means "
|
|
3363
|
+
"somebody won it between the read and the delete",
|
|
3364
|
+
file=sys.stderr)
|
|
3365
|
+
git_bad = 1
|
|
3366
|
+
for e in refs:
|
|
3367
|
+
if e["state"] != REAPABLE and (not named or e["key"] in named):
|
|
3368
|
+
print(f" · {e['key']} [{e['state']}] on {remote} left alone — {e['why']}")
|
|
3369
|
+
if not refs:
|
|
3370
|
+
print(f" no lease refs on {remote} — swept and empty, not unread")
|
|
3371
|
+
if not result["reaped"] and not result["remaining"] and not result["refused"] \
|
|
3372
|
+
and s.lease_mode != "git":
|
|
2997
3373
|
print(" nothing this run can prove it owns and has spent — nothing reaped")
|
|
2998
|
-
return 1 if result["remaining"] or result["refused"] else 0
|
|
3374
|
+
return 1 if (result["remaining"] or result["refused"] or git_bad) else 0
|
|
3375
|
+
|
|
3376
|
+
|
|
3377
|
+
def _reap_by_operator_decision(s: "Sync", keys: list[str]) -> int:
|
|
3378
|
+
"""`--i-own-this` — the one path that clears state no RUN can prove is its own.
|
|
3379
|
+
|
|
3380
|
+
AS-01b. The classifier is right to refuse: a run whose identity is the shared fallback
|
|
3381
|
+
cannot prove a matching run id means anything, so every such lock is `ambiguous` and
|
|
3382
|
+
nothing reaps it. The consequence is that expired locks accumulate with no path out for
|
|
3383
|
+
anybody — 28 of them on this machine by 2026-08-20, the oldest overdue by 21 days
|
|
3384
|
+
against a 2700-second TTL.
|
|
3385
|
+
|
|
3386
|
+
M-50 forbids a RUN from deleting what it cannot prove. A person deciding, per key, is
|
|
3387
|
+
not a run guessing — so the decision is available, and three things make it a decision
|
|
3388
|
+
rather than a sweep:
|
|
3389
|
+
|
|
3390
|
+
* **it takes named keys and refuses to run without them.** A blanket override is the
|
|
3391
|
+
thing the classifier exists to prevent, wearing a flag;
|
|
3392
|
+
* **it refuses a LIVE lease.** Residue is what this clears; a live lease belongs to a
|
|
3393
|
+
run that may still be working, and taking it by hand is the collision the tool exists
|
|
3394
|
+
to prevent;
|
|
3395
|
+
* **it prints the payload it destroyed** — run, timestamp, machine — so the decision is
|
|
3396
|
+
auditable afterwards by somebody who was not there, and journals it where a record
|
|
3397
|
+
plane is configured.
|
|
3398
|
+
"""
|
|
3399
|
+
if not keys:
|
|
3400
|
+
print("reap --i-own-this needs the keys, one or more, by name.\n"
|
|
3401
|
+
" A blanket override is the sweep the classifier exists to refuse, wearing a "
|
|
3402
|
+
"flag.\n"
|
|
3403
|
+
" `agent_sync.py residue` lists what is there and why each one is unclearable.",
|
|
3404
|
+
file=sys.stderr)
|
|
3405
|
+
return 2
|
|
3406
|
+
|
|
3407
|
+
by_key = {}
|
|
3408
|
+
for e in s.residue():
|
|
3409
|
+
by_key.setdefault(e["key"], e)
|
|
3410
|
+
by_key.setdefault(s._local_lock(e["key"]).stem, e)
|
|
3411
|
+
|
|
3412
|
+
rc = 0
|
|
3413
|
+
for k in keys:
|
|
3414
|
+
e = by_key.get(k) or by_key.get(s._local_lock(k).stem)
|
|
3415
|
+
if e is None:
|
|
3416
|
+
print(f" · {k} — there is no lock by that name in this checkout", file=sys.stderr)
|
|
3417
|
+
rc = 1
|
|
3418
|
+
continue
|
|
3419
|
+
if e["state"] == LIVE:
|
|
3420
|
+
print(f" ✗ {e['key']} is LIVE under {e.get('run') or 'a run'}"
|
|
3421
|
+
f"{' on ' + e['host'] if e.get('host') else ''} — not cleared. An override "
|
|
3422
|
+
"is for residue;\n a live lease belongs to a run that may still be "
|
|
3423
|
+
"working. Ask the holder, or wait for the TTL.", file=sys.stderr)
|
|
3424
|
+
rc = 1
|
|
3425
|
+
continue
|
|
3426
|
+
had = (f"run {e.get('run') or 'unknown'}"
|
|
3427
|
+
f"{' · host ' + e['host'] if e.get('host') else ''}"
|
|
3428
|
+
f"{' · ' + e['ts'] if e.get('ts') else ''}"
|
|
3429
|
+
f" · {spent(e)}")
|
|
3430
|
+
try:
|
|
3431
|
+
e["path"].unlink()
|
|
3432
|
+
except OSError as exc:
|
|
3433
|
+
print(f" ✗ {e['key']} could not be removed ({exc})", file=sys.stderr)
|
|
3434
|
+
rc = 1
|
|
3435
|
+
continue
|
|
3436
|
+
# Proved gone by looking again, the same rule the ordinary reap follows.
|
|
3437
|
+
if any(x["key"] == e["key"] for x in s.residue()):
|
|
3438
|
+
print(f" ✗ {e['key']} is STILL PRESENT after the delete — the teardown was not "
|
|
3439
|
+
"verified, whatever the call returned", file=sys.stderr)
|
|
3440
|
+
rc = 1
|
|
3441
|
+
continue
|
|
3442
|
+
print(f" cleared {e['key']} by operator decision — it held {had}")
|
|
3443
|
+
print(f" the classifier called it `{e['state']}`, and that has not changed: this "
|
|
3444
|
+
"was a person's\n call, not a proof of ownership.")
|
|
3445
|
+
try:
|
|
3446
|
+
s.journal(f"reap --i-own-this {e['key']} — was {had}, classified {e['state']}")
|
|
3447
|
+
except Exception: # noqa: BLE001 - the record plane is optional
|
|
3448
|
+
pass
|
|
3449
|
+
return rc
|
|
2999
3450
|
|
|
3000
3451
|
|
|
3001
3452
|
def cmd_journal(args: argparse.Namespace) -> int:
|
|
@@ -4096,6 +4547,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
4096
4547
|
rp = sub.add_parser("reap", help="clear expired locks this run provably owns; foreign "
|
|
4097
4548
|
"and ambiguous ones are reported, never touched")
|
|
4098
4549
|
rp.add_argument("keys", nargs="*", help="which to clear (default: every reapable one)")
|
|
4550
|
+
rp.add_argument("--i-own-this", action="store_true", dest="own",
|
|
4551
|
+
help="clear the NAMED expired locks even though this run cannot prove "
|
|
4552
|
+
"they are its own — an operator's decision, per key, recorded. "
|
|
4553
|
+
"Refuses a live lease and refuses to run with no key.")
|
|
4099
4554
|
rp.set_defaults(fn=cmd_reap)
|
|
4100
4555
|
|
|
4101
4556
|
g = sub.add_parser("guard", help="may this run write that path? exit 2 = no")
|