@ssheleg/agent-sync 1.19.3 → 1.20.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 +13 -0
- package/package.json +1 -1
- package/plugins/agent-sync/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-sync/hooks/guard.sh +24 -0
- package/plugins/agent-sync/hooks/hooks.json +1 -1
- package/plugins/agent-sync/skills/agent-sync/SKILL.md +41 -39
- package/plugins/agent-sync/skills/agent-sync/references/adapter-contract.md +32 -0
- package/plugins/agent-sync/skills/agent-sync/references/backend-fs.md +5 -0
- package/plugins/agent-sync/skills/agent-sync/references/lease-protocol.md +104 -14
- package/plugins/agent-sync/skills/agent-sync/scripts/agent_sync.py +656 -103
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
## v1.20.0 — the two-sources duty stops carrying its own elaboration
|
|
2
|
+
|
|
3
|
+
Sherlock external-v3 (14 findings) plus the House audit gap they introduced.
|
|
4
|
+
|
|
5
|
+
- The audit's doctrine grew SKILL.md past the house working limit, and the rule
|
|
6
|
+
for that is a SPLIT, not a trim. The `Two documentation sources` section keeps
|
|
7
|
+
what a reader needs before opening anything — the two questions, the gap IS the
|
|
8
|
+
finding, the duty at both ends, and that `reconcile` is mechanical so its green
|
|
9
|
+
is not agreement — and hands the rest to `references/two-sources.md`, which
|
|
10
|
+
already held every one of those points in full.
|
|
11
|
+
- `references/adapter-contract.md` passed 100 lines and gained a `## Contents`.
|
|
12
|
+
- CI now MEASURES the token budget with a real tokenizer instead of estimating it.
|
|
13
|
+
|
|
1
14
|
## v1.19.3 — the boundary ships with the skill, and two descriptions stop looking like drift
|
|
2
15
|
|
|
3
16
|
Close-out of the 2026-09-06 family audit (wave AUDIT-WAVE-0906). One bullet
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ssheleg/agent-sync",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.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"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "agent-sync",
|
|
4
4
|
"displayName": "Agent Sync",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.20.0",
|
|
6
6
|
"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.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "ssheleg",
|
|
@@ -3,6 +3,30 @@
|
|
|
3
3
|
# Any other non-zero code is NON-blocking in Claude Code, so every internal
|
|
4
4
|
# failure must also exit 2 — a crashing guard that fails open guards nothing.
|
|
5
5
|
set -uo pipefail
|
|
6
|
+
# --------------------------------------------------------------------------
|
|
7
|
+
# THE PROTECTION BOUNDARY IS ADVISORY, AND STATED SO (FIX-SY-07.01).
|
|
8
|
+
#
|
|
9
|
+
# This guard is a best-effort PreToolUse check, not a sandbox. It covers the
|
|
10
|
+
# mutation surfaces a Claude Code matcher can name; everything else is a write
|
|
11
|
+
# vector it cannot see. The capability matrix, per write vector:
|
|
12
|
+
#
|
|
13
|
+
# Write / Edit / MultiEdit / NotebookEdit COVERED (file_path matcher)
|
|
14
|
+
# Bash: git commit (incl. -C, env, compound) COVERED (parsed below)
|
|
15
|
+
# Bash: apply_patch UNSUPPORTED (no file_path parse)
|
|
16
|
+
# Bash: python -c "...write_text(...)" UNSUPPORTED (a runtime, not a path)
|
|
17
|
+
# Bash: sed -i / perl -i UNSUPPORTED (in-place edit, no arg parse)
|
|
18
|
+
# Bash: > redirect / tee UNSUPPORTED (shell effect, not a tool arg)
|
|
19
|
+
# Bash: git -C <dir> commit COVERED (repo resolved from -C)
|
|
20
|
+
# Bash: env VAR=x git commit COVERED (env prefix skipped)
|
|
21
|
+
# Bash: compound (a && git commit) COVERED (each segment parsed)
|
|
22
|
+
#
|
|
23
|
+
# An UNSUPPORTED vector is not silently trusted — it is DECLARED unenforced.
|
|
24
|
+
# A caller that needs a hard guarantee routes file writes through a trusted
|
|
25
|
+
# mutation API, an isolated worktree, or OS controls with resource locks; a
|
|
26
|
+
# regex shell parser is not a universal sandbox and this guard never claims to
|
|
27
|
+
# be one. The late staged-path check on a commit cannot recover a working tree
|
|
28
|
+
# already overwritten by an unsupported vector before staging.
|
|
29
|
+
# --------------------------------------------------------------------------
|
|
6
30
|
. "${CLAUDE_PLUGIN_ROOT}/hooks/_lib.sh"
|
|
7
31
|
S="$AGENT_SYNC_PY"
|
|
8
32
|
agent_sync_configured || exit 0
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "agent-sync — lease
|
|
2
|
+
"description": "agent-sync — ADVISORY lease guard and run lifecycle. The PreToolUse guard covers exactly the mutation surfaces a Claude Code matcher can name: Edit, Write, MultiEdit, NotebookEdit, and a Bash `git commit` (parsed for compound/`-C`/env forms). It is NOT a sandbox: a language-runtime write (Python write_text), a `sed -i`, or a shell redirect is OUTSIDE the boundary and is not enforced — see the capability matrix in guard.sh. Real enforcement needs a trusted mutation API, an isolated worktree, or OS controls with resource locks, not a regex shell parser. Every hook exits 0 immediately when the project has no .claude/agent-sync.json, so installing the plugin globally changes nothing in projects that do not use it.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{
|
|
@@ -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.20.0"
|
|
8
8
|
author: ssheleg
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -45,8 +45,9 @@ recorded in `docs/MERGES.md`, the `--key` lease released; `merges` says what lan
|
|
|
45
45
|
you were away. **Read `references/branching.md`** before merging.
|
|
46
46
|
|
|
47
47
|
**3. Hooks exist only in Claude Code.** Elsewhere nothing blocks a guarded edit: run
|
|
48
|
-
`guard` yourself and record the run as `ungated`.
|
|
49
|
-
|
|
48
|
+
`guard` yourself and record the run as `ungated`. A mode's five capability fields
|
|
49
|
+
live in ONE place — `references/adapter-contract.md` → *The status capability
|
|
50
|
+
contract*, never restated here. Do not describe a project as protected when it is not.
|
|
50
51
|
|
|
51
52
|
**4. Parse liberally, and never call an unreadable log a lost race.** The store rewrites
|
|
52
53
|
what you wrote — Outline turns a `- ` bullet into `* `. Emit `- `, accept `-`/`*`/`+`,
|
|
@@ -93,21 +94,19 @@ registers — decisions belong to the parent. Then take the chain above from `in
|
|
|
93
94
|
|
|
94
95
|
## First command in a project: `init`
|
|
95
96
|
|
|
96
|
-
**Never run anything else against an uninitialised project.** `init`
|
|
97
|
-
|
|
97
|
+
**Never run anything else against an uninitialised project.** `init` asks and answers
|
|
98
|
+
the storage question once, and writes it down.
|
|
98
99
|
|
|
99
100
|
**Ask the operator these two things in chat — do not guess, do not pick a default:**
|
|
100
101
|
|
|
101
102
|
1. **Where should coordination state live?**
|
|
102
103
|
- a knowledge cloud — `outline`, hosted or self-hosted, or `notion` — the shared
|
|
103
|
-
record, awareness and board across machines
|
|
104
|
-
in either can (trap 1);
|
|
104
|
+
record, awareness and board across machines — **neither decides leases** (trap 1);
|
|
105
105
|
- or local files (`fs`) — no credentials, and no visibility to an agent on another
|
|
106
|
-
machine
|
|
106
|
+
machine — no shared awareness, cross-repo signal or board.
|
|
107
107
|
|
|
108
108
|
The lease is decided separately by `leaseBackend` (trap 2), and **`gated` follows that
|
|
109
|
-
choice, never the record plane
|
|
110
|
-
stronger one the record plane suggests.
|
|
109
|
+
choice, never the record plane** — report the guarantee you actually have.
|
|
111
110
|
2. **If cloud: where.** Outline needs its instance URL; Notion needs the id of the page
|
|
112
111
|
the container goes under. Both are configuration, not secrets, so you may write them.
|
|
113
112
|
The **token is not** — you never ask for it in chat, never read it back, and never
|
|
@@ -145,9 +144,10 @@ cannot give two answers about one project.
|
|
|
145
144
|
away — watermarked per run, so it stays quiet until something changes. A dependency that
|
|
146
145
|
moved may unblock what you planned, or invalidate it.
|
|
147
146
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
147
|
+
`status` also decides: no credentials → degraded mode; `task-pipeline` absent → it
|
|
148
|
+
prints the install line and stops. Absence is checked across every host layout or an
|
|
149
|
+
explicit `pipelinePath` — no host's copy masks or is missed. The lease core
|
|
150
|
+
(`acquire`/`renew`/`release`) needs a backend + lease, not the binding.
|
|
151
151
|
|
|
152
152
|
```bash
|
|
153
153
|
npx sshlg-skills install
|
|
@@ -163,7 +163,8 @@ npx sshlg-skills install
|
|
|
163
163
|
| `acquire <KEY>` | Take the lease on a task id. Prints `won` or `lost <holder>` |
|
|
164
164
|
| `renew <KEY>` | Extend the lease. The `PostToolUse` hook does this for you |
|
|
165
165
|
| `release <KEY>` | Give the lease back. Always do this, including on failure |
|
|
166
|
-
| `reserve <REG
|
|
166
|
+
| `reserve <REG> [--key K] [--offline]` | Reserve the next id in a register (`DEC`, `OQ`, `DEP`, …); prints it. `--key` makes a retry idempotent (one key, one number); `--offline` issues a namespaced `REG-o-…` id with no global authority |
|
|
167
|
+
| `map-offline <REG> <ID> <N>` | Bind an offline id to a properly reserved number — append-only, never rebound |
|
|
167
168
|
| `release-id <REG> <ID>` | Return an id you did not end up writing to git |
|
|
168
169
|
| `journal <text>` | Append one line to this run's journal |
|
|
169
170
|
| `record <text>` | Append what you **actually built** — `--decision DEC-…`, `--files a,b` |
|
|
@@ -232,10 +233,19 @@ python3 "$SKILL_DIR/scripts/agent_sync.py" guard docs/DECISIONS.md
|
|
|
232
233
|
```
|
|
233
234
|
|
|
234
235
|
**Exit 2 is about *this run*: it holds no lease** — not that somebody else holds that file.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
236
|
+
A denial names the other run **and its key**, because "r-x holds a lease" beside a path gets
|
|
237
|
+
repeated as "r-x holds this file". Do not edit anyway, and do not "just fix one line" — a
|
|
238
|
+
clobbered decision looks exactly like a decision.
|
|
239
|
+
|
|
240
|
+
**Two write modes, and the tool does not promise the wrong one.** A task lease authorizes
|
|
241
|
+
the TASK, never the file — so a guarded write also takes the FILE's own **resource claim**
|
|
242
|
+
(`res--<repo>--<canonical path>`, SY-04). That is the **short transaction lock**: two agents
|
|
243
|
+
on one shared registry serialize on it, independent files never serialize, honest
|
|
244
|
+
cross-machine only under `leaseBackend: "git"` (advisory otherwise). The other mode is
|
|
245
|
+
**isolated worktree + merge** — each agent a private checkout, a merge policy reconciling
|
|
246
|
+
them — for when writes overlap so heavily a lock would just queue everyone. What the guard
|
|
247
|
+
does NOT promise is enforcement from a single task owner: holding *a* lease was never
|
|
248
|
+
holding *this* file.
|
|
239
249
|
|
|
240
250
|
Claude Code's `PreToolUse` hook runs this for you. Elsewhere nothing does.
|
|
241
251
|
|
|
@@ -262,32 +272,24 @@ the generated-object contract: `references/two-sources.md`.
|
|
|
262
272
|
## Two documentation sources, and the duty to reconcile them
|
|
263
273
|
|
|
264
274
|
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.
|
|
275
|
+
Neither outranks the other, and **the gap between them is the finding**, not a defect. The
|
|
276
|
+
duty runs at both ends of a task: `reconcile` before starting, `record` and `reconcile`
|
|
277
|
+
after finishing — and `reconcile` is mechanical, so treating its green as agreement is how
|
|
278
|
+
a divergence survives both ends.
|
|
266
279
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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.
|
|
274
|
-
|
|
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.
|
|
277
|
-
|
|
278
|
-
**Read `references/two-sources.md`** before the first reconcile, and whenever deciding which
|
|
279
|
-
side a document belongs on.
|
|
280
|
+
**Read `references/two-sources.md`** before the first reconcile: it holds the full duty,
|
|
281
|
+
what `reconcile` refuses to judge, the generated `setup` snapshot, where a document
|
|
282
|
+
belongs, and why nothing is deleted.
|
|
280
283
|
|
|
281
284
|
## Binding to task-pipeline
|
|
282
285
|
|
|
283
|
-
This skill supplies stages; the names are `task-pipeline`'s own.
|
|
284
|
-
carry an ordering rule: **0** `acquire` before the brief
|
|
285
|
-
|
|
286
|
-
**10** `merge
|
|
286
|
+
This skill supplies stages; the names are `task-pipeline`'s own.
|
|
287
|
+
Five of the eleven stages carry an ordering rule: **0** `acquire` before the brief
|
|
288
|
+
commits; **1** `reconcile` before code; **3** `reserve` ids before git; **9** the main
|
|
289
|
+
write; **10** `merge`/`release` every lease.
|
|
287
290
|
|
|
288
|
-
**Read `references/pipeline-binding.md`** when wiring `pipeline.json` —
|
|
289
|
-
|
|
290
|
-
expressions.
|
|
291
|
+
**Read `references/pipeline-binding.md`** when wiring `pipeline.json` — per-stage
|
|
292
|
+
reasoning, `skills[]` entries, what to guard, and the gate expressions.
|
|
291
293
|
|
|
292
294
|
## Configuration
|
|
293
295
|
|
|
@@ -14,6 +14,16 @@ candidate backend can be trusted with leases.
|
|
|
14
14
|
A backend is an adapter that implements six primitives and declares three
|
|
15
15
|
capabilities. Nothing else about it is the coordinator's business.
|
|
16
16
|
|
|
17
|
+
## Contents
|
|
18
|
+
|
|
19
|
+
- [Primitives](#primitives)
|
|
20
|
+
- [Capabilities](#capabilities)
|
|
21
|
+
- [The status capability contract — one source, five fields](#the-status-capability-contract--one-source-five-fields)
|
|
22
|
+
- [Degradation — non-negotiable](#degradation--non-negotiable)
|
|
23
|
+
- [Errors and retries](#errors-and-retries)
|
|
24
|
+
- [Credentials](#credentials)
|
|
25
|
+
- [Adding a backend — checklist](#adding-a-backend--checklist)
|
|
26
|
+
|
|
17
27
|
## Primitives
|
|
18
28
|
|
|
19
29
|
| Primitive | Signature | Semantics |
|
|
@@ -44,6 +54,28 @@ capabilities. Nothing else about it is the coordinator's business.
|
|
|
44
54
|
it. Declaring it true without one is the most damaging lie an adapter can tell, so
|
|
45
55
|
the default is false and the burden of proof is on the adapter.
|
|
46
56
|
|
|
57
|
+
## The status capability contract — one source, five fields
|
|
58
|
+
|
|
59
|
+
The RECORD-plane capabilities above (`atomicAppend`/`totalOrderRead`/…) are not
|
|
60
|
+
the same axis as the STATUS the coordinator reports about a run. The status is
|
|
61
|
+
`Sync.capabilities()` in `scripts/agent_sync.py` (FIX-SY-06.01), and it is the
|
|
62
|
+
ONE source every doc describes a mode from — SKILL.md and `backend-fs.md` point
|
|
63
|
+
here rather than restating it, so a single mode can never be described three
|
|
64
|
+
different ways. The five fields:
|
|
65
|
+
|
|
66
|
+
| field | values | meaning |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `lease_scope` | cross-machine \| machine-local | where exclusion holds |
|
|
69
|
+
| `enforcement_mode` | enforced \| advisory | enforced only for a cross-machine CAS the operator asked for on a reachable backend |
|
|
70
|
+
| `awareness_scope` | shared \| isolated | whether other agents can see this project's state |
|
|
71
|
+
| `identity_strength` | strong \| weak | how strongly a run identity is bound |
|
|
72
|
+
| `backend_health` | up \| failed | an unreachable backend is `failed`, never reported active |
|
|
73
|
+
|
|
74
|
+
**Legacy `gated` is a DERIVED summary** of `enforcement_mode`, kept for old
|
|
75
|
+
callers. **An unavailable observation is `unknown`/`failed`, never asserted**:
|
|
76
|
+
a backend the coordinator cannot reach collapses `enforcement_mode` to advisory
|
|
77
|
+
and `backend_health` to failed rather than showing green.
|
|
78
|
+
|
|
47
79
|
## Degradation — non-negotiable
|
|
48
80
|
|
|
49
81
|
**No adapter is the lease authority.** Exclusion is an atomic local lock; the adapter
|
|
@@ -34,6 +34,11 @@ from `leaseBackend` (`local` = `O_EXCL`, `git` = a pushed ref). What this backen
|
|
|
34
34
|
1. says so at session start, in one plain sentence;
|
|
35
35
|
2. keeps the lease exactly as configured — `leaseBackend` is independent of this choice;
|
|
36
36
|
3. marks every run `ungated` on the board, because nobody else can read the state.
|
|
37
|
+
In the five-field contract (`adapter-contract.md` → *The status capability
|
|
38
|
+
contract*, the one source this file describes the mode from) that is
|
|
39
|
+
`awareness_scope: isolated`, `enforcement_mode: advisory` — and if the local
|
|
40
|
+
state cannot even be read, `backend_health: failed`, reported as `unknown`,
|
|
41
|
+
never as active green.
|
|
37
42
|
|
|
38
43
|
## The lease is not this backend's job
|
|
39
44
|
|
|
@@ -42,7 +42,8 @@ so a parser anchored to the character you wrote rejects every line the server ha
|
|
|
42
42
|
back. Observed live, and it presented as a lost race rather than a parse failure.
|
|
43
43
|
|
|
44
44
|
Required on every line: `op`, `key`, `run`. `op` is one of
|
|
45
|
-
`acquire` · `release` · `renew` · `base` · `reserve` · `release_id` ·
|
|
45
|
+
`acquire` · `release` · `renew` · `base` · `reserve` · `release_id` ·
|
|
46
|
+
`reserve_offline` · `map_offline` · `signal` · `journal`.
|
|
46
47
|
|
|
47
48
|
Unparseable lines are **counted and reported**, never guessed at. Anything
|
|
48
49
|
entry-shaped (`^[-*+] \``) that fails the full pattern counts as unparseable; blank
|
|
@@ -74,6 +75,15 @@ documents, and implemented nowhere except a warning line on the board that retur
|
|
|
74
75
|
5. won write {run, ts, ttl, repo}; publish op=acquire to the plane for visibility
|
|
75
76
|
```
|
|
76
77
|
|
|
78
|
+
**Every local writer shares ONE critical section — and step 3 is it.** The
|
|
79
|
+
`.steal` guard is not the stealer's private door any more: `renew` and the
|
|
80
|
+
local release enter the same section, so a renewal arriving mid-steal DEFERS
|
|
81
|
+
to the next heartbeat instead of rewriting a timestamp the stealer's expiry
|
|
82
|
+
re-read already consumed. Ownership changes bump a `gen` counter a renewal
|
|
83
|
+
preserves — a reader holding a stale generation is holding a stale ownership.
|
|
84
|
+
And a host where the O_EXCL primitive itself fails gets an explicit
|
|
85
|
+
`unsupported` refusal naming the remedy, never an unlocked fallback (SY-03).
|
|
86
|
+
|
|
77
87
|
**Step 3 is one critical section, not two calls.** `unlink` followed by `O_EXCL create`
|
|
78
88
|
leaves a gap, and a second stealer that has already read the lock as expired removes the
|
|
79
89
|
lock the first one just created — both then hold what each believes is exclusive. Twelve
|
|
@@ -127,6 +137,32 @@ the second read is the state.
|
|
|
127
137
|
identically to an operator and mean opposite things, so they are printed differently and
|
|
128
138
|
`reap` exits non-zero on the first.
|
|
129
139
|
|
|
140
|
+
## The lock is published FULL, never created empty (SY-05)
|
|
141
|
+
|
|
142
|
+
A lock created empty by `O_EXCL` and filled by a later write has a window a
|
|
143
|
+
competitor reads as `{}` — not live, therefore stealable — and steals while
|
|
144
|
+
the creator writes on into a now-unlinked inode: two winners. So a lock is
|
|
145
|
+
PUBLISHED already carrying its body — written to a temp inode, fsync'd, then
|
|
146
|
+
`os.link`ed onto the final name (an atomic no-replace create of a FULL inode)
|
|
147
|
+
— and no reader ever observes an empty lock. Any empty or partial lock that
|
|
148
|
+
does appear is a creation IN FLIGHT, not an expired lease: it is left alone
|
|
149
|
+
within a short creation grace and reclaimed only once its own file age proves
|
|
150
|
+
it abandoned — arbitrated by age, never by the heuristic that empty JSON means
|
|
151
|
+
free.
|
|
152
|
+
|
|
153
|
+
## Resource identity — the file's own claim (SY-04)
|
|
154
|
+
|
|
155
|
+
A task lease is ownership of the TASK, never of a file: two runs holding two
|
|
156
|
+
different task ids used to both pass the guard and interleave writes to one
|
|
157
|
+
shared registry. A guarded write now also takes the file's own claim — key
|
|
158
|
+
`res--<repo>--<canonical path>` (realpath on both sides: a `/var` vs
|
|
159
|
+
`/private/var` symlink split makes one file two names, and a guard that sees
|
|
160
|
+
two names guards neither). The claim is auto-taken under the task lease, so a
|
|
161
|
+
single agent feels nothing; two agents on one file serialize on the FILE;
|
|
162
|
+
independent files carry independent keys and never serialize without cause.
|
|
163
|
+
Releasing the run's last task key releases its resource claims with it — a
|
|
164
|
+
file claim only ever rides under a task lease.
|
|
165
|
+
|
|
130
166
|
## Expiry and stealing
|
|
131
167
|
|
|
132
168
|
A lock is expired when `now > ts + ttl` for the timestamp inside it.
|
|
@@ -136,6 +172,25 @@ lock file in `local` mode, and re-pushes the ref with `--force-with-lease` again
|
|
|
136
172
|
exact object it read in `git` mode. The `op=renew` line it also appends to the record plane
|
|
137
173
|
is visibility, not renewal.
|
|
138
174
|
|
|
175
|
+
**And a renewal is FENCED, not unconditional (SY-03.02).** Three refusals stand
|
|
176
|
+
between a heartbeat and the lock: the run id must match; the lease must still be
|
|
177
|
+
LIVE — an expired lease is not renewed, it is acquired again, because a stealer
|
|
178
|
+
may already have read it as up for grabs; and the lock's `gen` must equal the
|
|
179
|
+
generation this session acquired under. The third is what the run id cannot do:
|
|
180
|
+
a replacement session shares the run id and the checkout, so after its steal
|
|
181
|
+
(gen bump) the old session's heartbeat matches on run and would resurrect the
|
|
182
|
+
lease forever — only the in-memory generation, the one thing a zombie does not
|
|
183
|
+
share with its replacement, tells the two apart. Re-taking one's own expired
|
|
184
|
+
lease is therefore a steal with a generation bump, never a refresh.
|
|
185
|
+
|
|
186
|
+
**The heartbeat's throttle is per (run, key), never shared.** It was one file per
|
|
187
|
+
checkout, and one agent touching it every hundred seconds meant every OTHER run's
|
|
188
|
+
heartbeat read "renewed recently" and refreshed nothing — a 45-minute lease expiring
|
|
189
|
+
under live work because a neighbour was busy (SY-02). Each key of each run now ages
|
|
190
|
+
against its own marker; an `acquire` stamps only the key it just took; and an
|
|
191
|
+
**explicit `renew <key>` never hides behind the throttle** — it refreshes for real or
|
|
192
|
+
answers with the precise per-key reason it could not.
|
|
193
|
+
|
|
139
194
|
That distinction is the whole of the bug fixed in 1.5.3: `renew` wrote *only* the record
|
|
140
195
|
line. The lock's `ts` was written once, by `acquire`, so a run holding a lease lost it at
|
|
141
196
|
TTL while still working — its own guard began denying it, and another run acquired the task
|
|
@@ -248,29 +303,64 @@ contracts on purpose. Do not "align" them by widening `reap`.
|
|
|
248
303
|
|
|
249
304
|
## Id reservation
|
|
250
305
|
|
|
251
|
-
Reading a "next free id" line from a file is not reserving it.
|
|
252
|
-
**
|
|
253
|
-
|
|
254
|
-
A register is opened once:
|
|
306
|
+
Reading a "next free id" line from a file is not reserving it. An issued id is
|
|
307
|
+
**immutable**: once `reserve` has printed a number, no replay, late-arriving shard or
|
|
308
|
+
appended base may move it. The line that records it carries the value —
|
|
255
309
|
|
|
256
310
|
```
|
|
257
|
-
- `…` `op=
|
|
311
|
+
- `…` `op=reserve` `key=DEC` `value=0042` `run=r-7f3a91`
|
|
258
312
|
```
|
|
259
313
|
|
|
260
|
-
|
|
261
|
-
|
|
314
|
+
— a **receipt** of an allocation that already happened, never a claim to be computed
|
|
315
|
+
later. A receipt whose value is already live lost its race and gets no assignment; the
|
|
316
|
+
run that wrote it saw the loss on read-back and appended another line. Where the value
|
|
317
|
+
comes from depends on the mode:
|
|
318
|
+
|
|
319
|
+
- **`leaseBackend: "git"`** — the allocator is a compare-and-swap on a remote ref,
|
|
320
|
+
`refs/agent-sync/ids/<REG>`, whose tip commit records the next free number. Winning
|
|
321
|
+
the push IS the allocation: the remote accepts exactly one successor per tip, so two
|
|
322
|
+
concurrent reserves cannot take one number — the loser re-reads the moved tip and
|
|
323
|
+
takes the next, bounded at `RESERVE_RETRIES` attempts before reporting contention.
|
|
324
|
+
This is the same push semantics the lease itself rides on, and it is why positional
|
|
325
|
+
replay could never be safe across machines: a shard another machine has not pushed
|
|
326
|
+
yet is invisible, and two machines replaying different logs were both "correct" about
|
|
327
|
+
histories nobody shared. A released id is recorded for the leak report but never
|
|
328
|
+
reissued automatically here — the counter only moves forward.
|
|
329
|
+
- **Total-order backends** (Outline, Notion) — the value is probed positionally over
|
|
330
|
+
the merged log, then claimed by appending the receipt and confirmed on read-back;
|
|
331
|
+
a lost race retries with the next number, bounded the same way.
|
|
332
|
+
|
|
333
|
+
A receipt also **names its authority**: `backend=` (git or log), `rev=` (the
|
|
334
|
+
counter commit that served it, in git mode) and `rkey=` (the reservation key).
|
|
335
|
+
A retry with the same `--key` is the SAME reservation — answered from the
|
|
336
|
+
merged log, or, when the run died between winning the compare-and-swap and
|
|
337
|
+
writing its receipt, from the counter ref's own chain, which remembers which
|
|
338
|
+
key each number was served to. One key, one number, however many retries.
|
|
339
|
+
|
|
340
|
+
**Offline, there is no global sequence to pretend at.** `reserve --offline`
|
|
341
|
+
issues a namespaced composite — `REG-o-<run>-<seq>` — that cannot collide with
|
|
342
|
+
the numeric sequence, and `map-offline` later binds it to a properly reserved
|
|
343
|
+
number, append-only: the same fact twice is one fact, a different number is
|
|
344
|
+
refused, and an id never issued cannot be mapped at all.
|
|
345
|
+
|
|
346
|
+
Legacy bare `op=reserve` lines (no `value=`) still resolve positionally, replaying in
|
|
347
|
+
order and maintaining a free list:
|
|
348
|
+
|
|
349
|
+
- `op=base key=DEC value=0216` opens a register; a `base` only ever moves allocation
|
|
350
|
+
**forward** — two runs opening a register in the same minute cannot restart each
|
|
351
|
+
other's count.
|
|
262
352
|
- `op=release_id key=DEC value=NNNN` pushes `NNNN` onto the free list.
|
|
263
|
-
- `op=reserve key=DEC` takes the free-list head if it is non-empty; otherwise
|
|
264
|
-
|
|
353
|
+
- bare `op=reserve key=DEC` takes the free-list head if it is non-empty; otherwise
|
|
354
|
+
`base + (count of prior reserves not served from the free list)`.
|
|
265
355
|
|
|
266
356
|
Every reader computes the same assignment for every reserve line, including its own —
|
|
267
357
|
**and "the log" means every shard merged, never the one this run writes.** Reading only
|
|
268
358
|
its own document is how `reserve` handed three runs `DEC-0007` three times (fixed in
|
|
269
359
|
1.5.3): each replayed a log containing only its own lines, each seeded its own `base`
|
|
270
|
-
from the register, and each was correct about a history nobody else shared. The
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
360
|
+
from the register, and each was correct about a history nobody else shared. The
|
|
361
|
+
value-carrying receipt closes the remaining half of that defect: the merged order
|
|
362
|
+
itself could still renumber an already-issued id when a shard arrived late, and now it
|
|
363
|
+
cannot (`test/audit_regressions/fix-sy-01.01.py`, finding SY-01).
|
|
274
364
|
|
|
275
365
|
**An id you reserved and did not write to git must be released** with
|
|
276
366
|
`release_id`. An id that is reserved, unreleased and absent from git after its run
|