@ssheleg/agent-sync 1.13.0 → 1.14.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 +41 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/plugins/agent-sync/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-sync/commands/agent-sync.md +5 -2
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +4 -1
- package/plugins/agent-sync/skills/agent-sync/references/lease-protocol.md +66 -0
- package/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +356 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,44 @@
|
|
|
1
|
+
## v1.14.0 — expiry ended a lease and left the file, and every reader folded that away
|
|
2
|
+
|
|
3
|
+
**`status` reported `leases held: none` over three expired locks in the directory it had
|
|
4
|
+
just read.** Not a bug in one function: *every* reader of lease state folded the TTL into
|
|
5
|
+
the read. `held()` keeps only what is this run's **and** alive; `_lease_holder()` returns
|
|
6
|
+
`None` for an expired lock; `all_holdings()` drops it. All three are correct for
|
|
7
|
+
exclusion — an expired lease is not held — and all three therefore give one answer for
|
|
8
|
+
*expired* and for *absent*. There was no fourth reader, so nothing could tell the two
|
|
9
|
+
apart, and `finish` printed `✓ no lease left held` beside a two-day-old corpse.
|
|
10
|
+
|
|
11
|
+
Measured across the family when the conformance audit went looking: **17 expired lock
|
|
12
|
+
files across 9 checkouts, the oldest 3 days 11 hours.**
|
|
13
|
+
|
|
14
|
+
### The mechanism
|
|
15
|
+
|
|
16
|
+
`classify_lock()` — pure, all arguments in — reads every `.agent-sync/leases/*.lock` as
|
|
17
|
+
`live` · `reapable` · `foreign` · `ambiguous`. `status` and `finish` report residue
|
|
18
|
+
instead of silence. Two new verbs: `residue` (report only, safe in any checkout) and
|
|
19
|
+
`reap`, which clears **only** provably-own spent state and refuses foreign or ambiguous
|
|
20
|
+
state out loud, with exit 1, even when it is named on the command line.
|
|
21
|
+
|
|
22
|
+
`reapable` requires four things together, and anything short of all four is reported and
|
|
23
|
+
never deleted: the lease is spent with a parseable clock; it records a run; that run is
|
|
24
|
+
this one; and the run id means something. That last clause carries the rule — a shell
|
|
25
|
+
with no session id is served one **shared** identity, so a matching run id under it
|
|
26
|
+
proves nothing and does not license a delete. In doubt: `ambiguous`.
|
|
27
|
+
|
|
28
|
+
**Teardown is verified by re-reading the directory**, not by trusting the delete's return
|
|
29
|
+
value. Driven by hand: with the lease directory made read-only, `reap` exits 1 with
|
|
30
|
+
*"MINE is STILL PRESENT after the delete … the teardown was not verified, whatever the
|
|
31
|
+
call returned"* — and the lock is still there, which is the point.
|
|
32
|
+
|
|
33
|
+
### Standing instruction 9
|
|
34
|
+
|
|
35
|
+
*A predicate cannot report the condition it folds into its answer.* That is the class,
|
|
36
|
+
and it is now in `docs/evidence/retro.md` rather than in this entry alone.
|
|
37
|
+
|
|
38
|
+
Self-test 38 → 43 fixtures, every one detected. Latency measured, not assumed: `status`
|
|
39
|
+
0.39 s against 0.31 s before; `guard` unchanged at 0.15 s — residue is not on the guard
|
|
40
|
+
path.
|
|
41
|
+
|
|
1
42
|
## v1.13.0 — the file carried two notions of *held* and they disagreed where it mattered
|
|
2
43
|
|
|
3
44
|
**A lease from a run that died could not be cleared by any command.** Measured in the field:
|
package/README.md
CHANGED
|
@@ -248,6 +248,8 @@ python3 "$SKILL_DIR/scripts/agent_sync.py" <command>
|
|
|
248
248
|
| `guard <path>` | May this run write that path? Exit 0 = yes, 2 = no |
|
|
249
249
|
| `board` | Regenerate the read-only board and the mirror from git |
|
|
250
250
|
| `whoami` | Print this run's id and its held leases |
|
|
251
|
+
| `residue` | Expired locks still on disk, classified — this run's spent ones, and the foreign or ambiguously owned ones it reports and leaves alone |
|
|
252
|
+
| `reap [KEY…]` | Clear only the locks this run can prove it owns and has spent, then re-read the directory to confirm the teardown |
|
|
251
253
|
| `merge` | Land this branch on the integration branch: conflicts checked first, merge log written, lease released |
|
|
252
254
|
| `merges` | What landed while you were on your branch |
|
|
253
255
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssheleg/agent-sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.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.14.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",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Coordinate concurrent agents — initialise the shared knowledge store, check status, take a task, reserve an id, or regenerate the board.
|
|
3
|
-
argument-hint: "[init|status|check|acquire <KEY>|release <KEY>|reserve <REG>|board|merge|finish]"
|
|
3
|
+
argument-hint: "[init|status|check|acquire <KEY>|release <KEY>|reserve <REG>|residue|board|merge|finish]"
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Invoke the `agent-sync` skill.
|
|
@@ -15,6 +15,9 @@ instance URL) or local files. Never guess that answer.
|
|
|
15
15
|
If the project is already initialised, report status and name exactly one next
|
|
16
16
|
action.
|
|
17
17
|
|
|
18
|
+
With `residue`, report the expired locks still on disk — what this run can prove it owns and
|
|
19
|
+
has spent, and the foreign or ambiguously owned ones it reports and leaves alone.
|
|
20
|
+
|
|
18
21
|
With `finish`, run the end-of-work check instead: every repository clean, pushed and pointed at,
|
|
19
|
-
|
|
22
|
+
no lease left held, and what the run leaves behind named rather than assumed. In a project of git submodules that is the one failure nobody sees — the
|
|
20
23
|
submodule is pushed and the parent still points at the commit before the work.
|
|
@@ -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.14.0"
|
|
8
8
|
author: ssheleg
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -187,6 +187,8 @@ npx sshlg-skills install
|
|
|
187
187
|
| `guard <path>` | Answer whether this run may write that path. Exit 0 = yes, 2 = no |
|
|
188
188
|
| `board` | Regenerate the shared board and this repo's page. `--mirror` also renders the configured git docs into the plane |
|
|
189
189
|
| `whoami` | Print this run's id and its held leases |
|
|
190
|
+
| `residue` | Expired locks still on disk: this run's spent ones, and the foreign or ambiguous ones it reports and never touches |
|
|
191
|
+
| `reap [KEY…]` | Clear only what this run **proves** it owns and has spent, then re-read the directory to confirm it went |
|
|
190
192
|
| `setup` | Write the generated snapshot of how **this** project is wired, for agents to read |
|
|
191
193
|
| `adopt` | Inspect an existing project and **propose** a config — writes nothing |
|
|
192
194
|
| `merge` | Land this branch: local target fast-forwarded, conflicts checked **before** anything is touched, merge log written, the `--key` lease released. `--summary`, `--dry-run`, `--push` |
|
|
@@ -360,6 +362,7 @@ drift, not a formatting problem.
|
|
|
360
362
|
|
|
361
363
|
- Append, read back, then act. Never rewrite a coordination document.
|
|
362
364
|
- `release` what you `acquire`, on every path including failure.
|
|
365
|
+
- A run reports what it leaves behind: expiry ends a lease, not the file — `residue`.
|
|
363
366
|
- Credentials never reach `argv`, a log line, or the repository.
|
|
364
367
|
- Degrade out loud. `ungated` is an acceptable state; a false claim of enforcement is not.
|
|
365
368
|
- Two agents in one checkout are two identities, or the lease is decoration.
|
|
@@ -9,6 +9,7 @@ when two agents disagree about who holds something.
|
|
|
9
9
|
- [Acquiring — the third design, and the first that is true](#acquiring--the-third-design-and-the-first-that-is-true)
|
|
10
10
|
- [Expiry and stealing](#expiry-and-stealing)
|
|
11
11
|
- [Releasing](#releasing)
|
|
12
|
+
- [Residue — what expiry leaves behind](#residue--what-expiry-leaves-behind)
|
|
12
13
|
- [Id reservation](#id-reservation)
|
|
13
14
|
- [The lease is not the claim](#the-lease-is-not-the-claim)
|
|
14
15
|
|
|
@@ -143,6 +144,71 @@ and when.
|
|
|
143
144
|
from active work until its TTL runs out, and during that window the task looks
|
|
144
145
|
taken. Report the failure and release; do not hold the lease "in case".
|
|
145
146
|
|
|
147
|
+
## Residue — what expiry leaves behind
|
|
148
|
+
|
|
149
|
+
**Expiry ends a lease. It does not remove a file.** Every reader of lease state in this
|
|
150
|
+
tool folds the TTL into the read — `held()`, `_lease_holder()` and `all_holdings()` each
|
|
151
|
+
answer *none* for an expired lock and *none* for a lock that is not there. That is exactly
|
|
152
|
+
right for exclusion, and it means an expired lock is not un-owned but **invisible**.
|
|
153
|
+
|
|
154
|
+
Measured across the nine repositories of one skill family on 2026-08-19: **seventeen lock
|
|
155
|
+
files, all seventeen expired**, the oldest by three days. `status` printed `leases held:
|
|
156
|
+
none` / `other runs: none holding anything` in a checkout holding three of them, and
|
|
157
|
+
`finish` printed `✓ no lease left held` beside a two-day-expired one. Nothing was wrong
|
|
158
|
+
with any of those answers. Nothing enumerated.
|
|
159
|
+
|
|
160
|
+
So `status` and `finish` report residue, `residue` prints it in full, and `reap` is the
|
|
161
|
+
only thing that removes any of it. The split between the two kinds is the mechanism:
|
|
162
|
+
|
|
163
|
+
| Verdict | What it means | What happens to it |
|
|
164
|
+
|---|---|---|
|
|
165
|
+
| `live` | still inside its TTL | held, not residue — untouched |
|
|
166
|
+
| `reapable` | this run PROVABLY owns it and has spent it | `reap` clears it |
|
|
167
|
+
| `foreign` | it demonstrably belongs to another run or another machine | reported, left alone |
|
|
168
|
+
| `ambiguous` | ownership or expiry cannot be established at all | reported, left alone |
|
|
169
|
+
|
|
170
|
+
**What makes ownership provable** — all four, or the verdict is not `reapable`:
|
|
171
|
+
|
|
172
|
+
1. the lock is past its TTL (a live lease is held, not residue);
|
|
173
|
+
2. it records a `run`, and that run is this one;
|
|
174
|
+
3. **this run's identity is not the shared fallback.** `run_id()` keys its marker by
|
|
175
|
+
session; a shell with no session id is served one shared entry, and that identity is
|
|
176
|
+
shared with any other session in the same checkout. Under it a matching run id proves
|
|
177
|
+
nothing — so it does not license a delete, and every such lock is reported as
|
|
178
|
+
`ambiguous` instead;
|
|
179
|
+
4. the lock's `repo` is this checkout and its `host` — written by the git mode, absent in
|
|
180
|
+
`local`, where the lease is machine-local by construction — is this machine.
|
|
181
|
+
|
|
182
|
+
In doubt the answer is `ambiguous`, never `reapable`. A classifier that resolves doubt by
|
|
183
|
+
deleting is worse than none, because it deletes under a claim of authority.
|
|
184
|
+
|
|
185
|
+
**Teardown is verified by re-reading the state, never by the delete's return value.**
|
|
186
|
+
`unlink` returns nothing and raises nothing on a filesystem where the entry survives the
|
|
187
|
+
call — a read-only mount, an NFS write that never lands, another process recreating the
|
|
188
|
+
name. `reap` therefore reads the lease directory again and compares `(run, ts)`: a lock
|
|
189
|
+
still there under the same identity is a **failure**, however cleanly the delete went, and
|
|
190
|
+
nothing is reported as cleared. A key that came back as another run's live lease *was*
|
|
191
|
+
torn down, and calling that a failure would teach an operator to ignore the one message
|
|
192
|
+
that matters.
|
|
193
|
+
|
|
194
|
+
`reap` never touches `foreign` or `ambiguous` state, including when it is named on the
|
|
195
|
+
command line: naming one is refused out loud with a non-zero exit, because an operator
|
|
196
|
+
reads a silent zero as done.
|
|
197
|
+
|
|
198
|
+
### Why `release <KEY>` may reap a foreign corpse and `reap` may not
|
|
199
|
+
|
|
200
|
+
They look contradictory and they are not. `release` clears an expired lock in another
|
|
201
|
+
run's name — that is the remedy for the #4 incident, a lease measured at **604×** its
|
|
202
|
+
2700-second TTL that no command could clear, and it is tested
|
|
203
|
+
(`test/claim_cell_test.py`). What makes it safe is the thing `reap` does not have: **a
|
|
204
|
+
person named that one key.** The reap is announced in the output, because the operator
|
|
205
|
+
asked to release *their* lease and is getting somebody else's corpse cleared as well.
|
|
206
|
+
|
|
207
|
+
`reap` is a sweep nobody named key by key. A sweep that deletes what it cannot prove it
|
|
208
|
+
owns is how one run destroys another's only record of work in progress — an agent that
|
|
209
|
+
stopped renewing may still be running. So the sweep refuses, and the two verbs keep two
|
|
210
|
+
contracts on purpose. Do not "align" them by widening `reap`.
|
|
211
|
+
|
|
146
212
|
## Id reservation
|
|
147
213
|
|
|
148
214
|
Reading a "next free id" line from a file is not reserving it. Allocation is
|
|
@@ -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.14.0"
|
|
37
37
|
|
|
38
38
|
CONFIG_PATH = Path(".claude/agent-sync.json")
|
|
39
39
|
ENV_FILE = Path(".env.agent-sync")
|
|
@@ -113,12 +113,24 @@ def now_iso() -> str:
|
|
|
113
113
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
114
114
|
|
|
115
115
|
|
|
116
|
-
def
|
|
116
|
+
def parse_iso_or_none(ts: str) -> float | None:
|
|
117
|
+
"""The instant this string names, or None when it names none.
|
|
118
|
+
|
|
119
|
+
`parse_iso` folds an unreadable timestamp into 0.0, and every expiry test then reads
|
|
120
|
+
that as "expired in 1970". For exclusion that is the right answer — a lock whose clock
|
|
121
|
+
cannot be read must not go on holding a key. For residue it is the wrong one: *spent*
|
|
122
|
+
and *unreadable* are different verdicts, and only the first may be cleared.
|
|
123
|
+
"""
|
|
117
124
|
try:
|
|
118
125
|
return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(
|
|
119
126
|
tzinfo=timezone.utc).timestamp()
|
|
120
|
-
except ValueError:
|
|
121
|
-
return
|
|
127
|
+
except (TypeError, ValueError):
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parse_iso(ts: str) -> float:
|
|
132
|
+
at = parse_iso_or_none(ts)
|
|
133
|
+
return 0.0 if at is None else at
|
|
122
134
|
|
|
123
135
|
|
|
124
136
|
def git(*args: str, cwd: Path | None = None) -> str:
|
|
@@ -827,6 +839,129 @@ def resolve_holder(events: list[dict[str, str]], key: str, at: float) -> str | N
|
|
|
827
839
|
return str(h["run"]) if h else None
|
|
828
840
|
|
|
829
841
|
|
|
842
|
+
# What a lock file turns out to be once the TTL has been applied to it. `live` is not
|
|
843
|
+
# residue; the other three are, and they are not interchangeable — the first may be
|
|
844
|
+
# cleared, the other two may only be reported.
|
|
845
|
+
LIVE = "live"
|
|
846
|
+
REAPABLE = "reapable"
|
|
847
|
+
FOREIGN = "foreign"
|
|
848
|
+
AMBIGUOUS = "ambiguous"
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def classify_lock(key: str, raw: str, *, rid: str, identity_is_strong: bool,
|
|
852
|
+
repo: str, host: str, default_ttl: int, at: float) -> dict[str, Any]:
|
|
853
|
+
"""One lock file, read as a live lease or as one of three kinds of residue.
|
|
854
|
+
|
|
855
|
+
Pure — everything it needs is an argument — so each verdict below is a fixture rather
|
|
856
|
+
than a scenario somebody has to reproduce with two sessions and a clock.
|
|
857
|
+
|
|
858
|
+
**Why this function has to exist.** Every reader of lease state in this tool folds the
|
|
859
|
+
TTL into the read: `held()`, `_lease_holder()` and `all_holdings()` each answer *none*
|
|
860
|
+
for an expired lock and *none* for a lock that is not there. That is correct for
|
|
861
|
+
exclusion and it is why seventeen expired locks across nine repositories of one family
|
|
862
|
+
were reported by nothing — `status` printed `leases held: none` and `finish` printed
|
|
863
|
+
"no lease left held" standing on top of them. Expiry ends a lease. It does not remove
|
|
864
|
+
a file, and nothing here could see the difference.
|
|
865
|
+
|
|
866
|
+
**The split is the load-bearing part.** `reapable` means state this run can PROVE it
|
|
867
|
+
owns and has spent, and the proof is deliberately narrow:
|
|
868
|
+
|
|
869
|
+
- the lock is past its TTL — a live lease is held, not residue;
|
|
870
|
+
- it records a `run`, and that run is this one;
|
|
871
|
+
- **this run's identity is not the shared fallback.** `run_id()` keys its marker by
|
|
872
|
+
session, and a shell with no session id is served one shared entry whose own
|
|
873
|
+
docstring says the identity "is shared with any other session in this checkout".
|
|
874
|
+
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 the git lease mode, absent
|
|
876
|
+
in local mode, where the lease is machine-local by construction — is this machine.
|
|
877
|
+
|
|
878
|
+
Everything else is reported and left alone: `foreign` where it demonstrably belongs to
|
|
879
|
+
somebody else, `ambiguous` where the question cannot be answered at all. In doubt the
|
|
880
|
+
answer is `ambiguous`, never `reapable`.
|
|
881
|
+
"""
|
|
882
|
+
out: dict[str, Any] = {"key": key, "state": AMBIGUOUS, "run": None, "repo": None,
|
|
883
|
+
"host": None, "ts": "", "expired_for": None, "why": ""}
|
|
884
|
+
try:
|
|
885
|
+
held = json.loads(raw)
|
|
886
|
+
except (json.JSONDecodeError, ValueError):
|
|
887
|
+
out["why"] = "the lock is not readable JSON, so nothing in it identifies an owner"
|
|
888
|
+
return out
|
|
889
|
+
if not isinstance(held, dict):
|
|
890
|
+
out["why"] = "the lock is not an object, so nothing in it identifies an owner"
|
|
891
|
+
return out
|
|
892
|
+
|
|
893
|
+
out["run"] = held.get("run") or None
|
|
894
|
+
out["repo"] = held.get("repo") or None
|
|
895
|
+
out["host"] = held.get("host") or None
|
|
896
|
+
out["ts"] = str(held.get("ts") or "")
|
|
897
|
+
|
|
898
|
+
taken = parse_iso_or_none(out["ts"])
|
|
899
|
+
if taken is None:
|
|
900
|
+
out["why"] = (f"its timestamp ({out['ts'] or 'absent'}) is not one, so whether this "
|
|
901
|
+
"lock is spent cannot be established")
|
|
902
|
+
return out
|
|
903
|
+
try:
|
|
904
|
+
ttl = int(held.get("ttl", default_ttl))
|
|
905
|
+
except (TypeError, ValueError):
|
|
906
|
+
out["why"] = (f"its ttl ({held.get('ttl')!r}) is not a number, so whether this lock "
|
|
907
|
+
"is spent cannot be established")
|
|
908
|
+
return out
|
|
909
|
+
|
|
910
|
+
expires = taken + ttl
|
|
911
|
+
if at <= expires:
|
|
912
|
+
out["state"] = LIVE
|
|
913
|
+
out["why"] = (f"held by {out['run'] or 'an unnamed run'} for another "
|
|
914
|
+
f"{int(expires - at)}s")
|
|
915
|
+
return out
|
|
916
|
+
out["expired_for"] = int(at - expires)
|
|
917
|
+
|
|
918
|
+
if not out["run"]:
|
|
919
|
+
out["why"] = "it records no run, so there is nobody it can be proved to belong to"
|
|
920
|
+
return out
|
|
921
|
+
if out["host"] and out["host"] != host:
|
|
922
|
+
out["state"] = FOREIGN
|
|
923
|
+
out["why"] = f"it was taken on {out['host']}, which is not this machine"
|
|
924
|
+
return out
|
|
925
|
+
if out["run"] != rid:
|
|
926
|
+
out["state"] = FOREIGN
|
|
927
|
+
out["why"] = f"it belongs to run {out['run']}, not to this one"
|
|
928
|
+
return out
|
|
929
|
+
if out["repo"] and out["repo"] != repo:
|
|
930
|
+
out["why"] = (f"its run id matches, but it names repository {out['repo']} while this "
|
|
931
|
+
f"checkout is {repo}")
|
|
932
|
+
return out
|
|
933
|
+
if not identity_is_strong:
|
|
934
|
+
out["why"] = ("its run id matches, but this run's identity is the shared fallback — "
|
|
935
|
+
"any other session in this checkout answers to the same id, so the "
|
|
936
|
+
"match proves nothing")
|
|
937
|
+
return out
|
|
938
|
+
out["state"] = REAPABLE
|
|
939
|
+
out["why"] = "this run took it and let it expire"
|
|
940
|
+
return out
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def since(seconds: int) -> str:
|
|
944
|
+
"""A duration an operator can act on."""
|
|
945
|
+
if seconds < 90:
|
|
946
|
+
return f"{seconds}s"
|
|
947
|
+
if seconds < 5400:
|
|
948
|
+
return f"{seconds // 60}m"
|
|
949
|
+
if seconds < 172800:
|
|
950
|
+
return f"{seconds // 3600}h"
|
|
951
|
+
return f"{seconds // 86400}d {(seconds % 86400) // 3600}h"
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def spent(entry: dict[str, Any]) -> str:
|
|
955
|
+
"""How long this lock has been residue — said once, so no surface can phrase it
|
|
956
|
+
differently. A lock whose clock cannot be read is not "expired an unknown time ago";
|
|
957
|
+
it is a lock nobody can say is spent, which is why it is never reaped."""
|
|
958
|
+
if entry["state"] == LIVE:
|
|
959
|
+
return "live"
|
|
960
|
+
if entry.get("expired_for") is None:
|
|
961
|
+
return "expiry could not be established"
|
|
962
|
+
return f"expired {since(entry['expired_for'])} ago"
|
|
963
|
+
|
|
964
|
+
|
|
830
965
|
def resolve_reservations(events: list[dict[str, str]], reg: str) -> tuple[int, list[int], list[tuple[str, int]]]:
|
|
831
966
|
"""Positional allocation over the log. Returns (base, free_list, assignments)."""
|
|
832
967
|
base = None
|
|
@@ -882,6 +1017,7 @@ class Sync:
|
|
|
882
1017
|
self.adapter = make_adapter(self.cfg, self.root)
|
|
883
1018
|
self.rid = run_id(self.root)
|
|
884
1019
|
self.ttl = int(self.cfg.get("leaseTtlSeconds") or DEFAULT_TTL)
|
|
1020
|
+
self._identity: tuple[str, str] | None = None
|
|
885
1021
|
|
|
886
1022
|
@property
|
|
887
1023
|
def gated(self) -> bool:
|
|
@@ -1356,6 +1492,107 @@ class Sync:
|
|
|
1356
1492
|
print(f"note: released locally, not published ({exc})", file=sys.stderr)
|
|
1357
1493
|
return True
|
|
1358
1494
|
|
|
1495
|
+
@property
|
|
1496
|
+
def identity(self) -> tuple[str, str]:
|
|
1497
|
+
"""(key, how) for this run's identity — resolved once, because it walks `ps`."""
|
|
1498
|
+
if self._identity is None:
|
|
1499
|
+
self._identity = _session_key()
|
|
1500
|
+
return self._identity
|
|
1501
|
+
|
|
1502
|
+
@property
|
|
1503
|
+
def identity_is_strong(self) -> bool:
|
|
1504
|
+
"""Whether a matching run id is proof of anything.
|
|
1505
|
+
|
|
1506
|
+
False means `_session_key()` established nothing and `run_id()` served the shared
|
|
1507
|
+
entry — the identity every other session in this checkout is also given. Enough to
|
|
1508
|
+
coordinate with, and deliberately not enough to delete on.
|
|
1509
|
+
"""
|
|
1510
|
+
return bool(self.identity[0])
|
|
1511
|
+
|
|
1512
|
+
def residue(self) -> list[dict[str, Any]]:
|
|
1513
|
+
"""Every lock file in this checkout, classified — the live ones included.
|
|
1514
|
+
|
|
1515
|
+
The enumerating read the tool never had. `held()` below globs the same directory
|
|
1516
|
+
and keeps only what is BOTH this run's and alive, which makes it a liveness
|
|
1517
|
+
predicate: it cannot tell an empty directory from one full of corpses, and no
|
|
1518
|
+
other reader here can either.
|
|
1519
|
+
"""
|
|
1520
|
+
d = self.root / STATE_DIR / "leases"
|
|
1521
|
+
now, host, repo = time.time(), platform.node(), repo_name()
|
|
1522
|
+
out: list[dict[str, Any]] = []
|
|
1523
|
+
for p in sorted(d.glob("*.lock") if d.exists() else []):
|
|
1524
|
+
try:
|
|
1525
|
+
raw = p.read_text()
|
|
1526
|
+
except OSError as exc:
|
|
1527
|
+
entry: dict[str, Any] = {
|
|
1528
|
+
"key": p.stem, "state": AMBIGUOUS, "run": None, "repo": None,
|
|
1529
|
+
"host": None, "ts": "", "expired_for": None,
|
|
1530
|
+
"why": f"the lock cannot be read ({exc})"}
|
|
1531
|
+
else:
|
|
1532
|
+
entry = classify_lock(p.stem, raw, rid=self.rid,
|
|
1533
|
+
identity_is_strong=self.identity_is_strong,
|
|
1534
|
+
repo=repo, host=host, default_ttl=self.ttl, at=now)
|
|
1535
|
+
entry["path"] = p
|
|
1536
|
+
out.append(entry)
|
|
1537
|
+
return out
|
|
1538
|
+
|
|
1539
|
+
def stale(self) -> list[dict[str, Any]]:
|
|
1540
|
+
"""The residue only — every lock whose lease has already ended."""
|
|
1541
|
+
return [e for e in self.residue() if e["state"] != LIVE]
|
|
1542
|
+
|
|
1543
|
+
def reap(self, keys: list[str] | None = None) -> dict[str, list[dict[str, Any]]]:
|
|
1544
|
+
"""Clear only `reapable` residue, and prove it went by LOOKING AGAIN.
|
|
1545
|
+
|
|
1546
|
+
The second observation is the whole point. `unlink` returns nothing and raises
|
|
1547
|
+
nothing on a filesystem where the entry survives the call — a read-only mount, an
|
|
1548
|
+
NFS write that never lands, a directory whose write bit was dropped between two
|
|
1549
|
+
commands, another process recreating the name — so a teardown that reports success
|
|
1550
|
+
out of its own return value is reporting the wish rather than the state. What comes
|
|
1551
|
+
back here is the difference between two reads of the directory.
|
|
1552
|
+
|
|
1553
|
+
Identity decides the second read, not absence: a key that came back as another
|
|
1554
|
+
run's live lease WAS torn down, and calling that a failure would teach an operator
|
|
1555
|
+
to ignore the one message that matters.
|
|
1556
|
+
"""
|
|
1557
|
+
before = self.residue()
|
|
1558
|
+
named = None
|
|
1559
|
+
if keys:
|
|
1560
|
+
named = set()
|
|
1561
|
+
for k in keys:
|
|
1562
|
+
named.add(k)
|
|
1563
|
+
named.add(self._local_lock(k).stem)
|
|
1564
|
+
wanted = [e for e in before
|
|
1565
|
+
if e["state"] == REAPABLE and (named is None or e["key"] in named)]
|
|
1566
|
+
refused = [e for e in before
|
|
1567
|
+
if e["state"] != REAPABLE and named is not None and e["key"] in named]
|
|
1568
|
+
if named is not None:
|
|
1569
|
+
known = {e["key"] for e in before}
|
|
1570
|
+
for k in sorted(named - known):
|
|
1571
|
+
if self._local_lock(k).stem in known:
|
|
1572
|
+
continue
|
|
1573
|
+
refused.append({"key": k, "state": "absent", "run": None, "ts": "",
|
|
1574
|
+
"expired_for": None,
|
|
1575
|
+
"why": "there is no lock by that name in this checkout"})
|
|
1576
|
+
|
|
1577
|
+
for e in wanted:
|
|
1578
|
+
try:
|
|
1579
|
+
e["path"].unlink()
|
|
1580
|
+
except FileNotFoundError:
|
|
1581
|
+
pass
|
|
1582
|
+
except OSError as exc:
|
|
1583
|
+
e["error"] = str(exc)
|
|
1584
|
+
|
|
1585
|
+
after = {e["key"]: e for e in self.residue()}
|
|
1586
|
+
reaped, remaining = [], []
|
|
1587
|
+
for e in wanted:
|
|
1588
|
+
still = after.get(e["key"])
|
|
1589
|
+
if still is not None and still["run"] == e["run"] and still["ts"] == e["ts"]:
|
|
1590
|
+
remaining.append(e)
|
|
1591
|
+
else:
|
|
1592
|
+
reaped.append(e)
|
|
1593
|
+
return {"reaped": reaped, "remaining": remaining, "refused": refused,
|
|
1594
|
+
"left": [e for e in before if e["state"] in (FOREIGN, AMBIGUOUS)]}
|
|
1595
|
+
|
|
1359
1596
|
def held(self) -> list[str]:
|
|
1360
1597
|
d = self.root / STATE_DIR / "leases"
|
|
1361
1598
|
mine = []
|
|
@@ -2193,6 +2430,8 @@ class Sync:
|
|
|
2193
2430
|
"| What was actually built, with its commit | as-built log | permanent, append-only |",
|
|
2194
2431
|
"| Cross-repo dependency state | signal log | permanent, append-only |",
|
|
2195
2432
|
"| Who holds a task right now | claims log | expires by TTL |",
|
|
2433
|
+
"| A lock left by a run that stopped | the lease directory | until it is "
|
|
2434
|
+
"reported and reaped |",
|
|
2196
2435
|
"| Per-run narrative | that run's journal | permanent |",
|
|
2197
2436
|
"| The board and these pages | generated | replaced on every regeneration |",
|
|
2198
2437
|
"",
|
|
@@ -2224,6 +2463,10 @@ class Sync:
|
|
|
2224
2463
|
"merge --key → land the branch: conflicts checked first, the merge recorded,",
|
|
2225
2464
|
" that lease released. Without a branch, `release ID` by hand",
|
|
2226
2465
|
" — on every path, including failure",
|
|
2466
|
+
"residue → what this run leaves on disk. Expiry ends a lease and leaves",
|
|
2467
|
+
" the file, so `status` and `finish` enumerate them; `reap`",
|
|
2468
|
+
" clears only what THIS run can prove it owns and has spent,",
|
|
2469
|
+
" and reports foreign or ambiguously owned locks untouched",
|
|
2227
2470
|
"```",
|
|
2228
2471
|
"",
|
|
2229
2472
|
f"This project's integration branch is `{self.integration_branch}`.",
|
|
@@ -2509,6 +2752,29 @@ def cmd_status(_args: argparse.Namespace) -> int:
|
|
|
2509
2752
|
return 1
|
|
2510
2753
|
print(f" leases held : {', '.join(held) if held else 'none'}")
|
|
2511
2754
|
|
|
2755
|
+
# A run produces more than a diff, and what it leaves behind has to be reported by the
|
|
2756
|
+
# command every session runs. Until this line existed, `status` printed `leases held:
|
|
2757
|
+
# none` beside three expired locks in the directory it had just read — every reader of
|
|
2758
|
+
# lease state here applies the TTL, so "expired" and "absent" were one answer.
|
|
2759
|
+
stale = s.stale()
|
|
2760
|
+
reapable = [e for e in stale if e["state"] == REAPABLE]
|
|
2761
|
+
left_alone = [e for e in stale if e["state"] != REAPABLE]
|
|
2762
|
+
if not stale:
|
|
2763
|
+
print(" expired locks : none")
|
|
2764
|
+
else:
|
|
2765
|
+
print(f" expired locks : {len(stale)} — {len(reapable)} this run's to clear, "
|
|
2766
|
+
f"{len(left_alone)} left alone")
|
|
2767
|
+
print("\n Expired leases still on disk. Nobody holds these: the TTL ended the "
|
|
2768
|
+
"lease\n and left the file.")
|
|
2769
|
+
for e in stale[:6]:
|
|
2770
|
+
print(f" · {e['key']} [{e['state']}] {e['why']} ({spent(e)})")
|
|
2771
|
+
if len(stale) > 6:
|
|
2772
|
+
print(f" · … and {len(stale) - 6} more — agent_sync.py residue")
|
|
2773
|
+
if reapable:
|
|
2774
|
+
print(" Clear what this run owns: agent_sync.py reap")
|
|
2775
|
+
if left_alone:
|
|
2776
|
+
print(" The rest are foreign or ambiguously owned — reported, not touched.")
|
|
2777
|
+
|
|
2512
2778
|
# Who else is in here, and what landed while this run was away. Without this a
|
|
2513
2779
|
# lease only tells an agent it is blocked, never who by or on what.
|
|
2514
2780
|
plane_broken = False
|
|
@@ -2674,6 +2940,64 @@ def cmd_release_id(args: argparse.Namespace) -> int:
|
|
|
2674
2940
|
return 0
|
|
2675
2941
|
|
|
2676
2942
|
|
|
2943
|
+
def cmd_residue(_args: argparse.Namespace) -> int:
|
|
2944
|
+
"""What this run leaves behind, classified — and never quietly cleared.
|
|
2945
|
+
|
|
2946
|
+
Reporting and clearing are two commands on purpose. This one is safe to run anywhere,
|
|
2947
|
+
including in somebody else's checkout, because it cannot remove anything.
|
|
2948
|
+
"""
|
|
2949
|
+
s = Sync()
|
|
2950
|
+
entries = s.residue()
|
|
2951
|
+
print(f"run {s.rid} · identity from {s.identity[1]}")
|
|
2952
|
+
print(f"lease mode {s.lease_mode} · ttl {s.ttl}s · {len(entries)} lock file(s) in "
|
|
2953
|
+
f"{STATE_DIR}/leases\n")
|
|
2954
|
+
if not entries:
|
|
2955
|
+
print(" nothing on disk — no lease has been taken in this checkout, or every one "
|
|
2956
|
+
"was released")
|
|
2957
|
+
return 0
|
|
2958
|
+
for e in entries:
|
|
2959
|
+
print(f" {e['key']}")
|
|
2960
|
+
print(f" state : {e['state']}"
|
|
2961
|
+
+ ("" if e["state"] == LIVE else f" ({spent(e)})"))
|
|
2962
|
+
print(f" run : {e['run'] or '—'} · repo {e['repo'] or '—'}"
|
|
2963
|
+
f"{' · host ' + e['host'] if e['host'] else ''}")
|
|
2964
|
+
print(f" why : {e['why']}")
|
|
2965
|
+
reapable = [e for e in entries if e["state"] == REAPABLE]
|
|
2966
|
+
other = [e for e in entries if e["state"] in (FOREIGN, AMBIGUOUS)]
|
|
2967
|
+
print()
|
|
2968
|
+
if reapable:
|
|
2969
|
+
print(f" {len(reapable)} reapable — this run's own, spent: agent_sync.py reap")
|
|
2970
|
+
if other:
|
|
2971
|
+
print(f" {len(other)} foreign or ambiguous — reported, never cleared from here. "
|
|
2972
|
+
"An expired\n lock in another run's name is that run's to explain, and a "
|
|
2973
|
+
"lock whose owner\n cannot be established is nobody's to delete.")
|
|
2974
|
+
if not reapable and not other:
|
|
2975
|
+
print(" no residue — every lock on disk is a live lease")
|
|
2976
|
+
return 0
|
|
2977
|
+
|
|
2978
|
+
|
|
2979
|
+
def cmd_reap(args: argparse.Namespace) -> int:
|
|
2980
|
+
"""Clear this run's spent locks, and verify the teardown by reading the state again."""
|
|
2981
|
+
s = Sync()
|
|
2982
|
+
result = s.reap(args.keys or None)
|
|
2983
|
+
for e in result["reaped"]:
|
|
2984
|
+
print(f" reaped {e['key']} — {e['why']}, confirmed gone by re-reading "
|
|
2985
|
+
f"{STATE_DIR}/leases")
|
|
2986
|
+
for e in result["remaining"]:
|
|
2987
|
+
detail = f" ({e['error']})" if e.get("error") else ""
|
|
2988
|
+
print(f" ✗ {e['key']} is STILL PRESENT after the delete{detail} — the teardown was "
|
|
2989
|
+
"not\n verified, whatever the call returned. Nothing was reported as "
|
|
2990
|
+
"cleared.", file=sys.stderr)
|
|
2991
|
+
for e in result["refused"]:
|
|
2992
|
+
print(f" · {e['key']} [{e['state']}] left alone — {e['why']}")
|
|
2993
|
+
if not args.keys:
|
|
2994
|
+
for e in result["left"]:
|
|
2995
|
+
print(f" · {e['key']} [{e['state']}] left alone — {e['why']}")
|
|
2996
|
+
if not result["reaped"] and not result["remaining"] and not result["refused"]:
|
|
2997
|
+
print(" nothing this run can prove it owns and has spent — nothing reaped")
|
|
2998
|
+
return 1 if result["remaining"] or result["refused"] else 0
|
|
2999
|
+
|
|
3000
|
+
|
|
2677
3001
|
def cmd_journal(args: argparse.Namespace) -> int:
|
|
2678
3002
|
return 0 if Sync().journal(" ".join(args.text)) else 1
|
|
2679
3003
|
|
|
@@ -3173,6 +3497,22 @@ def cmd_finish(args: argparse.Namespace) -> int:
|
|
|
3173
3497
|
else:
|
|
3174
3498
|
ok.append("no lease left held")
|
|
3175
3499
|
|
|
3500
|
+
# And the other half of that question, which for six versions nothing asked: what is
|
|
3501
|
+
# left on disk. `held()` answers `none` for a directory full of expired locks, so
|
|
3502
|
+
# "no lease left held" was printed beside a two-day-expired one. Proof of Done
|
|
3503
|
+
# records what remains — it does not license deleting all of it, so only what this
|
|
3504
|
+
# run can prove it owns is a problem to fix here.
|
|
3505
|
+
stale = s.stale()
|
|
3506
|
+
reapable = [e for e in stale if e["state"] == REAPABLE]
|
|
3507
|
+
left_alone = [e for e in stale if e["state"] != REAPABLE]
|
|
3508
|
+
if not stale:
|
|
3509
|
+
ok.append("no expired lock left behind")
|
|
3510
|
+
if reapable:
|
|
3511
|
+
problems.append(
|
|
3512
|
+
f"{len(reapable)} expired lock(s) this run owns are still on disk ("
|
|
3513
|
+
+ ", ".join(e["key"] for e in reapable)
|
|
3514
|
+
+ ") — clear them with `agent_sync.py reap`")
|
|
3515
|
+
|
|
3176
3516
|
# 3. the declared gates, on request. They are the project's own commands and can be slow, so
|
|
3177
3517
|
# running them is opt-in — but a `finish` that never ran them is a claim, not a check.
|
|
3178
3518
|
if args.gates:
|
|
@@ -3193,6 +3533,11 @@ def cmd_finish(args: argparse.Namespace) -> int:
|
|
|
3193
3533
|
print(f" \u2713 {line}")
|
|
3194
3534
|
for line in problems:
|
|
3195
3535
|
print(f" \u2717 {line}")
|
|
3536
|
+
if left_alone:
|
|
3537
|
+
print("\n Left alone — residue this run cannot prove it owns:")
|
|
3538
|
+
for e in left_alone:
|
|
3539
|
+
print(f" · {e['key']} [{e['state']}] {e['why']}")
|
|
3540
|
+
print(" Reported, not touched: another run's expired lock is that run's to explain.")
|
|
3196
3541
|
print()
|
|
3197
3542
|
if problems:
|
|
3198
3543
|
print(f"{len(problems)} problem(s) — this work is not finished. The usual one is a "
|
|
@@ -3746,6 +4091,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
3746
4091
|
sg.add_argument("state")
|
|
3747
4092
|
sg.set_defaults(fn=cmd_signal)
|
|
3748
4093
|
|
|
4094
|
+
sub.add_parser("residue", help="expired locks left on disk, classified by who can "
|
|
4095
|
+
"prove they own them").set_defaults(fn=cmd_residue)
|
|
4096
|
+
rp = sub.add_parser("reap", help="clear expired locks this run provably owns; foreign "
|
|
4097
|
+
"and ambiguous ones are reported, never touched")
|
|
4098
|
+
rp.add_argument("keys", nargs="*", help="which to clear (default: every reapable one)")
|
|
4099
|
+
rp.set_defaults(fn=cmd_reap)
|
|
4100
|
+
|
|
3749
4101
|
g = sub.add_parser("guard", help="may this run write that path? exit 2 = no")
|
|
3750
4102
|
g.add_argument("path")
|
|
3751
4103
|
g.set_defaults(fn=cmd_guard)
|