@aron98/load-lock 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +115 -2
- package/demo/demo.sh +112 -0
- package/demo/sim-agent.mjs +149 -0
- package/dist/adapters/opencode/plugin.d.ts +18 -0
- package/dist/adapters/opencode/plugin.js +301 -0
- package/dist/cli/hook.d.ts +1 -0
- package/dist/cli/hook.js +141 -0
- package/dist/cli/install.d.ts +14 -0
- package/dist/cli/install.js +377 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +461 -0
- package/dist/core/daemon-main.d.ts +2 -0
- package/dist/core/daemon-main.js +27 -0
- package/dist/core/daemon.d.ts +79 -0
- package/dist/core/daemon.js +526 -0
- package/dist/core/persist.d.ts +11 -0
- package/dist/core/persist.js +66 -0
- package/dist/core/state.d.ts +203 -0
- package/dist/core/state.js +909 -0
- package/dist/core/sysinfo.d.ts +2 -0
- package/dist/core/sysinfo.js +123 -0
- package/dist/mcp/main.d.ts +2 -0
- package/dist/mcp/main.js +4 -0
- package/dist/mcp/server.d.ts +27 -0
- package/dist/mcp/server.js +352 -0
- package/dist/policy/text.d.ts +30 -0
- package/dist/policy/text.js +58 -0
- package/dist/shared/client.d.ts +30 -0
- package/dist/shared/client.js +227 -0
- package/dist/shared/protocol.d.ts +333 -0
- package/dist/shared/protocol.js +22 -0
- package/dist/test/core.test.d.ts +1 -0
- package/dist/test/core.test.js +281 -0
- package/dist/test/daemon.test.d.ts +1 -0
- package/dist/test/daemon.test.js +384 -0
- package/dist/test/hook.test.d.ts +1 -0
- package/dist/test/hook.test.js +126 -0
- package/dist/test/mcp.test.d.ts +1 -0
- package/dist/test/mcp.test.js +139 -0
- package/dist/test/run-helper.test.d.ts +1 -0
- package/dist/test/run-helper.test.js +84 -0
- package/dist/test/testutil.d.ts +27 -0
- package/dist/test/testutil.js +111 -0
- package/docs/ADAPTERS.md +118 -0
- package/docs/AGENT_GUIDE.md +142 -0
- package/docs/ARCHITECTURE.md +195 -0
- package/docs/COMPATIBILITY.md +116 -0
- package/docs/OPERATIONS.md +126 -0
- package/package.json +102 -4
- package/skills/load-lock/SKILL.md +73 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# load-lock agent guide
|
|
2
|
+
|
|
3
|
+
load-lock coordinates heavy work across all coding agents on this machine.
|
|
4
|
+
Every session — any harness, any repo, any worktree — shares one machine-
|
|
5
|
+
wide capacity budget. Before resource-intensive work, acquire a permit. The
|
|
6
|
+
whole thing is cooperative: you decide what is heavy; the budget only works
|
|
7
|
+
if everyone participates.
|
|
8
|
+
|
|
9
|
+
This guide is also available as `load-lock agent-guide` and as a skill in
|
|
10
|
+
supported harnesses.
|
|
11
|
+
|
|
12
|
+
## 1. Deciding heavy vs light
|
|
13
|
+
|
|
14
|
+
Judge by **expected** CPU/memory/IO cost, not by name:
|
|
15
|
+
|
|
16
|
+
- **Heavy** (acquire first): builds, test suites, `cargo build`/`cargo
|
|
17
|
+
test`, `npm test`, container builds, migration runs, anything you expect
|
|
18
|
+
to saturate cores or memory for more than a few seconds.
|
|
19
|
+
- **Light** (no permit): file edits, greps, reads, git status/diff, short
|
|
20
|
+
shell snippets, install/status probes, anything bounded and cheap.
|
|
21
|
+
|
|
22
|
+
If in doubt about the machine's headroom, call `inspect_machine` — it
|
|
23
|
+
reports effective cores and memory *including container/cgroup limits*
|
|
24
|
+
(the machine may have 8 logical cores but a 2-core cgroup quota — never
|
|
25
|
+
assume "N cores means N jobs") plus current load and memory pressure. Use
|
|
26
|
+
`get_config`/`configure` when the budget genuinely needs adjustment.
|
|
27
|
+
|
|
28
|
+
## 2. Core flow
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
acquire(reason, op_key)
|
|
32
|
+
├─ granted → you hold a permit → do the heavy work now
|
|
33
|
+
├─ queued → keep doing LIGHT work; offers reach you at your next
|
|
34
|
+
│ load-lock tool call, or via a wakeup message
|
|
35
|
+
└─ ...
|
|
36
|
+
offer arrives (token, expiry) → claim(token) BEFORE the window closes
|
|
37
|
+
claim → held → start the heavy work
|
|
38
|
+
best: run_with_permit(permit_token, command) # daemon tracks the process
|
|
39
|
+
else: attach_pid(permit_token, pid) # you started it yourself
|
|
40
|
+
else: mark_started(permit_token) # lease-based, weaker
|
|
41
|
+
work finishes → release(permit_token)
|
|
42
|
+
no longer needed while queued → cancel(request_id)
|
|
43
|
+
offer arrived but you won't make it → decline(offer_token)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Rules, in order of importance:
|
|
47
|
+
|
|
48
|
+
1. **Acquire immediately before** the heavy work — not at the start of a
|
|
49
|
+
long task.
|
|
50
|
+
2. **While queued, continue useful light work** — read code, write plans,
|
|
51
|
+
make edits. Do not sleep or spin; offers are delivered to you.
|
|
52
|
+
3. **Claim before starting.** Heavy work starts only after `acquire`
|
|
53
|
+
returned granted (or `claim` succeeded). A notification alone never
|
|
54
|
+
authorizes work.
|
|
55
|
+
4. **Hold until the process truly finishes** — including background
|
|
56
|
+
execution. If the shell returned but the job still runs, you still hold
|
|
57
|
+
the permit (that is the point).
|
|
58
|
+
5. **Release promptly** — success or failure. Release is idempotent; calling
|
|
59
|
+
it twice is safe, calling it after a crash-recovery release is safe.
|
|
60
|
+
6. **Cancel** queued requests you no longer need, so someone else can have
|
|
61
|
+
the capacity.
|
|
62
|
+
|
|
63
|
+
Notes on the details:
|
|
64
|
+
|
|
65
|
+
- `op_key` makes duplicate acquires idempotent: if your pending request for
|
|
66
|
+
the same op already exists, `acquire` returns it instead of a second one.
|
|
67
|
+
Use a stable key per distinct operation (e.g. `build/auth-module`), and
|
|
68
|
+
different keys for different operations.
|
|
69
|
+
- Offers have a **claim window** (default 30s). If it lapses, the request
|
|
70
|
+
becomes **paused** — capacity moves on and your request waits until you
|
|
71
|
+
explicitly `rejoin(request_id)`. If you know you can't start in time,
|
|
72
|
+
`decline` instead of letting it expire.
|
|
73
|
+
|
|
74
|
+
## 3. Running heavy work (lifecycle options)
|
|
75
|
+
|
|
76
|
+
How you start the work determines how reliably the permit cleans up after
|
|
77
|
+
you — pick the best available:
|
|
78
|
+
|
|
79
|
+
1. **`run_with_permit(permit_token, command)`** — the helper spawns your
|
|
80
|
+
command in its own process group, atomically associates it with the
|
|
81
|
+
permit, and auto-releases when the process group *actually finishes*
|
|
82
|
+
(including background children). Check `job_status(job_id)` for
|
|
83
|
+
completion/exit code/tail of output; `job_stop(job_id)` to kill it.
|
|
84
|
+
Do **not** manually release a permit you handed to `run_with_permit`.
|
|
85
|
+
2. **`attach_pid(permit_token, pid)`** — you spawned the process yourself;
|
|
86
|
+
the daemon now tracks that pid (and its process group) and releases when
|
|
87
|
+
it is confirmed dead. Works for `... &` background jobs whose parent
|
|
88
|
+
shell exits.
|
|
89
|
+
3. **`mark_started(permit_token)`** — no observable process (e.g. you call
|
|
90
|
+
an external service). The permit is held under a lease (default 15 min)
|
|
91
|
+
that is kept alive by heartbeats from your harness integration. Honest
|
|
92
|
+
limitation: if your agent process dies silently, the lease eventually
|
|
93
|
+
frees the capacity, but during that window nobody can guarantee the
|
|
94
|
+
unseen work has stopped. Prefer 1 or 2 whenever possible.
|
|
95
|
+
|
|
96
|
+
If you `acquire` and get **granted** but then fail to start the work within
|
|
97
|
+
the start deadline (default 120s), the permit is automatically reclaimed —
|
|
98
|
+
nobody is stranded by a crashed claimant, and neither are you.
|
|
99
|
+
|
|
100
|
+
The equivalent CLI forms exist for shell-driven flows:
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
load-lock acquire --reason "cargo test" --op-key "test/core" # prints JSON
|
|
104
|
+
load-lock run --permit <permit-token> -- cargo test --quiet # tracks + releases
|
|
105
|
+
# or acquire+run in one go:
|
|
106
|
+
load-lock run --reason "nightly build" -- npm run build
|
|
107
|
+
load-lock wait --id <request-id> --timeout 120000 # bounded wait → auto-claim
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## 4. Reading state
|
|
111
|
+
|
|
112
|
+
- `status(pool?)` — snapshot of the whole machine: holders (with phase,
|
|
113
|
+
reason, age), outstanding offers, queue depth, paused requests.
|
|
114
|
+
- `pending()` — *your* requests and offers (with claim tokens).
|
|
115
|
+
- `inspect_machine()` — cores/memory incl. cgroup limits, pressure.
|
|
116
|
+
- `get_config()` / `configure(expected_version, changes, reason)` —
|
|
117
|
+
versioned CAS budget changes; every change is recorded with author and
|
|
118
|
+
rationale. On `version_conflict`, re-read and retry with the fresh
|
|
119
|
+
version.
|
|
120
|
+
- Events have durable ids; after reconnects or restarts, consult
|
|
121
|
+
`events(since)` rather than assuming.
|
|
122
|
+
|
|
123
|
+
## 5. Cleanup and failure etiquette
|
|
124
|
+
|
|
125
|
+
| Situation | Do this |
|
|
126
|
+
| --- | --- |
|
|
127
|
+
| Work finished (success or failure) | `release` |
|
|
128
|
+
| Queued request no longer needed | `cancel` |
|
|
129
|
+
| Claim window will lapse before you can start | `decline` the offer |
|
|
130
|
+
| Offer already expired (`offer_expired`) | `rejoin` when actually ready |
|
|
131
|
+
| Your harness session is about to end with a live tracked job | Nothing — `run_with_permit`/`attach_pid` jobs release themselves when done |
|
|
132
|
+
| You suspect a stuck holder | `status` → `recover list` diagnostics, and discuss with the operator before `force-release` |
|
|
133
|
+
|
|
134
|
+
Never force-release someone else's permit without checking `recover list`
|
|
135
|
+
first — a long hold with a live tracked process is working as intended.
|
|
136
|
+
|
|
137
|
+
## 6. Suggested internal parallelism
|
|
138
|
+
|
|
139
|
+
When you hold the permit, you own a share of the machine — bound your own
|
|
140
|
+
work too: prefer e.g. `cargo test -j 2` or a bounded test-worker count over
|
|
141
|
+
unbounded parallelism, and publish what you did via
|
|
142
|
+
`configure(... guidance ...)` notes if it should become convention.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# load-lock architecture
|
|
2
|
+
|
|
3
|
+
load-lock is a cross-harness coordination plugin for parallel coding agents. It
|
|
4
|
+
provides a machine-wide counting semaphore: before doing resource-intensive
|
|
5
|
+
work (builds, test suites, long compiles), an agent acquires a permit; while
|
|
6
|
+
queued it keeps doing light work; when capacity frees up it receives a FIFO
|
|
7
|
+
offer, claims it, runs the heavy work, and releases. Coordination is
|
|
8
|
+
cooperative and advisory — nothing wraps or rewrites shell commands, and agents
|
|
9
|
+
decide for themselves what counts as "heavy".
|
|
10
|
+
|
|
11
|
+
## Why a small dedicated service (vs extending something existing)
|
|
12
|
+
|
|
13
|
+
We evaluated the existing landscape before building:
|
|
14
|
+
|
|
15
|
+
- **WorkOS Airlock** (the closest name match) is a commercial, cloud-hosted
|
|
16
|
+
*authorization* gateway for agents: it approves or denies tool calls
|
|
17
|
+
(agent identity, intent, egress policies) via WorkOS Pipes. It answers "is
|
|
18
|
+
this agent allowed to do this?", not "does this machine have spare capacity
|
|
19
|
+
right now?". It has no machine-local state, no queue, no offers, and no
|
|
20
|
+
process-lifecycle tracking. Smaller GitHub projects named "airlock"
|
|
21
|
+
(an agent type-checker gate, an MCP egress allow-list) are similarly
|
|
22
|
+
unrelated.
|
|
23
|
+
- **mic-lock** (npm) provides local sticky locks for shared test resources,
|
|
24
|
+
but it is a single lock with no counting semaphore, no FIFO queue, no
|
|
25
|
+
offer/claim protocol, no harness integration, and no automatic cleanup.
|
|
26
|
+
- **flock-wrapper scripts** (advisory lock around a command) cannot work
|
|
27
|
+
across harness tool calls: the acquire step is a short-lived process that
|
|
28
|
+
must exit before the agent does its real work, releasing the lock with it.
|
|
29
|
+
A `flock` held by a process that has already exited protects nothing. (We
|
|
30
|
+
do use an flock-style spawn lock internally — for daemon bootstrapping —
|
|
31
|
+
but never for permits.)
|
|
32
|
+
|
|
33
|
+
No existing project combines a counting semaphore, FIFO offers with claim
|
|
34
|
+
tokens, per-agent identity, automatic permit cleanup tied to real process
|
|
35
|
+
lifetimes, and harness instruction/notification adapters. The coordination
|
|
36
|
+
state is small and its lifetime is the machine, not a repo — so a small
|
|
37
|
+
dedicated daemon plus thin per-harness adapters is the right shape. Extending
|
|
38
|
+
a larger system (e.g. a build tool) would couple the budget to one harness
|
|
39
|
+
and inherit unrelated constraints.
|
|
40
|
+
|
|
41
|
+
## Core design
|
|
42
|
+
|
|
43
|
+
**One daemon, one budget.** A single `load-lock` daemon process per machine
|
|
44
|
+
owns all state. Every client — any harness, any repo, any worktree — talks to
|
|
45
|
+
the same Unix-domain socket, so all sessions share one capacity budget
|
|
46
|
+
regardless of working directory. The default configuration is one pool
|
|
47
|
+
`heavy` with capacity 1; pools, capacity, and timings are changed at runtime
|
|
48
|
+
through `configure` (versioned compare-and-swap, persisted with author +
|
|
49
|
+
reason). The state directory lives outside any checkout
|
|
50
|
+
(`$XDG_STATE_HOME/load-lock`, default `~/.local/state/load-lock`, overridable
|
|
51
|
+
via `LOAD_LOCK_STATE_DIR`).
|
|
52
|
+
|
|
53
|
+
**Single-threaded atomicity.** The daemon is one Node process; every state
|
|
54
|
+
mutation happens on one event loop turn. There is no interleaving between
|
|
55
|
+
"check capacity" and "grant" — acquisition is atomic by construction, and
|
|
56
|
+
there is no window in which a release can be missed between a failed check
|
|
57
|
+
and queue registration. `acquire` either grants or registers in the queue in
|
|
58
|
+
one operation; there is no separate failed-acquire/subscribe sequence that
|
|
59
|
+
could miss a wakeup.
|
|
60
|
+
|
|
61
|
+
**FIFO offers with claim tokens.** When capacity frees, the daemon reserves
|
|
62
|
+
it and creates an offer for the head of the queue: an opaque claim token with
|
|
63
|
+
an expiry (default 30s). The offer is delivered to the agent (see
|
|
64
|
+
notifications below); heavy work starts only after a successful `claim`,
|
|
65
|
+
which atomically converts the offer into a permit. Outstanding offers consume
|
|
66
|
+
capacity, so an unclaimed offer cannot admit a second agent over budget. If
|
|
67
|
+
the offer expires, capacity passes to the next waiter and the expired request
|
|
68
|
+
becomes **paused** — it will not be re-offered until the agent explicitly
|
|
69
|
+
`rejoin`s. This prevents endless recycling of offers to absent agents.
|
|
70
|
+
|
|
71
|
+
**Permit lifecycle (automatic cleanup).** A permit moves through phases:
|
|
72
|
+
|
|
73
|
+
1. **Claimed, awaiting execution** — has a configurable start deadline
|
|
74
|
+
(default 120s). If execution never starts, the permit is reclaimed.
|
|
75
|
+
2. **Running, tracked** — associated with a real OS process via
|
|
76
|
+
`attach_pid`/`run_with_permit` (pid + process-group id + `/proc` start
|
|
77
|
+
time). The permit lives as long as the associated job: parent shells may
|
|
78
|
+
exit, background children keep the group alive, and the daemon
|
|
79
|
+
auto-releases only on confirmed termination. It is never auto-expired
|
|
80
|
+
merely because time passed. PID reuse is defended by comparing
|
|
81
|
+
`/proc/<pid>/stat` start times.
|
|
82
|
+
3. **Running, untracked** — `mark_started` for jobs the daemon cannot
|
|
83
|
+
observe. Held under a lease (default 15 min) kept alive by heartbeats
|
|
84
|
+
(the MCP server and OpenCode plugin heartbeat automatically every 30s).
|
|
85
|
+
This is an explicit tradeoff: a lease cannot simultaneously guarantee
|
|
86
|
+
prompt reclamation and never overlap an unobserved surviving process.
|
|
87
|
+
We reclaim when the owner stops heartbeating, and we document the
|
|
88
|
+
residual risk instead of claiming a guarantee we can't make.
|
|
89
|
+
|
|
90
|
+
Explicit `release` always remains available and is idempotent (a duplicate or
|
|
91
|
+
late release is a no-op, not an error). `admin release` / `recover
|
|
92
|
+
force-release` exists for humans with holder/job diagnostics.
|
|
93
|
+
|
|
94
|
+
**Association is atomic.** `run_with_permit` spawns the job (its own process
|
|
95
|
+
group) and only then associates pid+pgid with the permit in a single daemon
|
|
96
|
+
operation; a reclamation racing the start loses cleanly (stale token /
|
|
97
|
+
wrong-state error), and a failed start kills the process group immediately.
|
|
98
|
+
Background execution is handled honestly: the helper returns after *the whole
|
|
99
|
+
process group* finishes, never merely when a shell returns a task id.
|
|
100
|
+
|
|
101
|
+
**Identity.** Per-agent identity comes from the harness (OpenCode session ID)
|
|
102
|
+
or an explicit `agent_id` argument; every entry also records an agent label,
|
|
103
|
+
cwd, op key (for idempotent duplicate suppression), and reason. Idempotency:
|
|
104
|
+
a duplicate `acquire` from the same agent for the same pending op key returns
|
|
105
|
+
the existing request rather than a second one; multiple distinct ops per
|
|
106
|
+
agent are supported. All tokens (permits, offers) are opaque; releasing or
|
|
107
|
+
claiming with someone else's token fails.
|
|
108
|
+
|
|
109
|
+
**Restart behavior.** Config is persisted (atomic write + rename). Held
|
|
110
|
+
permits are journaled: on daemon restart, tracked permits are reconciled
|
|
111
|
+
against real process liveness (dead ⇒ released, alive ⇒ kept, same tokens
|
|
112
|
+
still valid), claimed permits keep their original start deadline, untracked
|
|
113
|
+
permits get a fresh lease window, and a release arriving late for an entry
|
|
114
|
+
that was reconciled is idempotent. Queues and offers are *not* journaled:
|
|
115
|
+
they are ephemeral by design — queued agents are still running, notice the
|
|
116
|
+
missed offer via their next tool call, and re-acquire; the daemon
|
|
117
|
+
auto-spawns on the next client connection, so an agent never needs to know
|
|
118
|
+
the daemon restarted. This is documented, deliberate behavior, not a bug.
|
|
119
|
+
|
|
120
|
+
## Notification honesty
|
|
121
|
+
|
|
122
|
+
A critical constraint discovered by research: **no harness reliably pushes
|
|
123
|
+
MCP-server-initiated notifications into model context.** MCP transports
|
|
124
|
+
having a notification mechanism says nothing about whether the harness wakes
|
|
125
|
+
an idle agent or injects into context. load-lock therefore uses four
|
|
126
|
+
mechanisms, and labels exactly which apply per harness:
|
|
127
|
+
|
|
128
|
+
1. **Tool-boundary delivery (always available).** Every load-lock tool
|
|
129
|
+
result and every `status`/`pending` call carries the caller's current
|
|
130
|
+
offers (with claim tokens) — an agent performing light work discovers its
|
|
131
|
+
offer at its next tool call, without polling.
|
|
132
|
+
2. **Bounded blocking wait.** `acquire(wait_ms)` blocks briefly (default
|
|
133
|
+
5s) inside the tool call and auto-claims if capacity frees during the
|
|
134
|
+
wait. The CLI `wait` command and MCP `wait` pattern provide longer
|
|
135
|
+
bounded waits. No model-authored busy-polling required.
|
|
136
|
+
3. **Idle wakeup where the harness genuinely supports it.**
|
|
137
|
+
- OpenCode (live-verified): the native plugin watches daemon events and
|
|
138
|
+
calls `ctx.session.synthetic(...)`, delivering the offer (with token)
|
|
139
|
+
to an idle session at its next execution boundary — it never
|
|
140
|
+
interrupts an in-flight shell command.
|
|
141
|
+
- Claude Code (contract-tested): a `Stop` hook checks for cwd-matching
|
|
142
|
+
offers and continues the session with a claim instruction; an async
|
|
143
|
+
`await-offer` watcher with `asyncRewake: true` wakes idle sessions.
|
|
144
|
+
- Codex CLI (contract-tested): `Stop` hook with `decision: "block"`
|
|
145
|
+
continuation. Note: Codex async background hooks do **not** start a new
|
|
146
|
+
turn — documented as a limitation.
|
|
147
|
+
4. **Durable event identities.** All events have monotonic IDs; agents can
|
|
148
|
+
pull owner-filtered events since a cursor and deduplicate after reconnect
|
|
149
|
+
or daemon restart. A notification NEVER grants permission — only
|
|
150
|
+
`claim(offer_token)` does; expired offers are rejected and stale
|
|
151
|
+
notifications authorize nothing.
|
|
152
|
+
|
|
153
|
+
## Instruction delivery (policy teaching)
|
|
154
|
+
|
|
155
|
+
The behavioral policy (acquire before heavy work, keep doing light work
|
|
156
|
+
while queued, claim before starting, hold until the process truly finishes,
|
|
157
|
+
release promptly, cancel unneeded work, use inspect/configure when the
|
|
158
|
+
budget needs adjustment) is delivered per harness using its real mechanism,
|
|
159
|
+
and restored after context compaction:
|
|
160
|
+
|
|
161
|
+
- **OpenCode**: plugin `context` hook injects the brief on every model call
|
|
162
|
+
(compaction-proof, live-verified), plus a registered skill with the full
|
|
163
|
+
guide.
|
|
164
|
+
- **Claude Code**: `SessionStart` hook (matchers include `compact`) +
|
|
165
|
+
`~/.claude/CLAUDE.md` section + skill.
|
|
166
|
+
- **Codex CLI**: `SessionStart` hook (matcher `compact`) + `SubagentStart`
|
|
167
|
+
hook + `~/.codex/AGENTS.md` section (hooks require `/hooks` trust review).
|
|
168
|
+
- Other harnesses: MCP server `instructions` + a global instructions file
|
|
169
|
+
snippet where the harness supports one (see COMPATIBILITY.md).
|
|
170
|
+
|
|
171
|
+
Policy is short (one brief, no repeated reminders), reinforced by tool
|
|
172
|
+
names/descriptions, and never interrupts ordinary work with approval
|
|
173
|
+
prompts.
|
|
174
|
+
|
|
175
|
+
## Failure modes covered
|
|
176
|
+
|
|
177
|
+
| Failure | Behavior |
|
|
178
|
+
| --- | --- |
|
|
179
|
+
| Agent disconnects mid-heavy-work | Permit retained; tracked job keeps capacity until the real process finishes; untracked job's lease survives until heartbeats stop |
|
|
180
|
+
| Agent process crashes | Tracked permit auto-releases on confirmed process death; untracked reclaims after lease expiry |
|
|
181
|
+
| Background job outlives the tool call | pgid tracking retains the permit until the whole group exits |
|
|
182
|
+
| Daemon crash/restart | Auto-respawn on next connect; journal reconciliation of held permits (conservative: dead ⇒ released, alive ⇒ kept); queues/offers ephemeral by design |
|
|
183
|
+
| Stale/forged token | Opaque tokens; wrong-owner claim/release rejected; expired offers rejected with a precise error |
|
|
184
|
+
| Forgotten release | start deadline (claimed), process death (tracked), lease expiry (untracked), plus human `recover force-release` |
|
|
185
|
+
| PID reuse | `/proc` start-time comparison |
|
|
186
|
+
| Config change while offers outstanding | Capacity increase admits queued agents immediately; capacity decrease lets holders finish and delays new admissions; outstanding offers keep their reservations |
|
|
187
|
+
| Duplicate claims/releases | Idempotent or precise `stale_token`/`offer_expired` errors, never double effects |
|
|
188
|
+
|
|
189
|
+
## Extension points
|
|
190
|
+
|
|
191
|
+
The protocol already carries weights (a request may ask for >1 units of
|
|
192
|
+
capacity; admission is FIFO with head-of-line blocking) and multiple pools;
|
|
193
|
+
the machine currently uses one pool by default but accepts arbitrary pool
|
|
194
|
+
names through the same code paths. Adding a second budget (e.g. `network`)
|
|
195
|
+
is configuration, not code.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Harness compatibility matrix
|
|
2
|
+
|
|
3
|
+
load-lock separates **core capability** (daemon + protocol + CLI, fully
|
|
4
|
+
harness-agnostic) from **per-harness integration** (tools, policy
|
|
5
|
+
instruction injection, compaction restore, idle wakeup, lifecycle help).
|
|
6
|
+
Status labels mean exactly this:
|
|
7
|
+
|
|
8
|
+
- **Implemented + live-verified** — deployed on this machine and exercised
|
|
9
|
+
end-to-end against the real harness.
|
|
10
|
+
- **Implemented + contract-tested** — adapter shipped; behavior tested
|
|
11
|
+
against the harness's documented hook/tool JSON contracts
|
|
12
|
+
(`src/test/hook.test.ts`, `src/test/mcp.test.ts`), but the harness itself
|
|
13
|
+
was not installed during verification.
|
|
14
|
+
- **Proposed / fallback** — research-verified harness capabilities only; no
|
|
15
|
+
adapter shipped beyond the generic MCP path.
|
|
16
|
+
|
|
17
|
+
## Core (all harnesses)
|
|
18
|
+
|
|
19
|
+
| Capability | Status |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| Machine-wide pool, atomic acquire, FIFO offers, claim tokens, expiry → paused → rejoin | Implemented + tested (`core.test` 14, `daemon.test` 8) |
|
|
22
|
+
| Idempotent acquire/release, duplicate suppression, weighted head-block | Implemented + tested |
|
|
23
|
+
| Automatic cleanup: start deadline, tracked pgid process death, untracked lease, heartbeats, PID-reuse defense | Implemented + tested with real processes |
|
|
24
|
+
| Daemon restart: journal reconciliation, idempotent late release, ephemeral queues | Implemented + tested |
|
|
25
|
+
| Config via tools, CAS versions, persisted history with author+reason | Implemented + tested |
|
|
26
|
+
| Resource inspection incl. cgroup v2/v1 CPU+memory limits and pressure | Implemented + tested |
|
|
27
|
+
| CLI: acquire/claim/release/run/wait/status/configure/doctor/recover/install | Implemented + tested + demoed |
|
|
28
|
+
|
|
29
|
+
## Per-harness integration
|
|
30
|
+
|
|
31
|
+
| Harness | MCP tools | Policy injection | Compaction restore | Idle wakeup | Async push into context | Cleanup fidelity | Overall |
|
|
32
|
+
| --- | --- | --- | --- | --- | --- | --- | --- |
|
|
33
|
+
| **OpenCode** (v2) | Native plugin, `load_lock_*` (better than MCP) | Context hook every model call | Yes (hook re-fires per model call) | Yes — `session.synthetic` at execution boundary (live-verified; never interrupts in-flight shell) | Yes (live-verified) | Full: `run_with_permit` tracked jobs + session heartbeats | **Implemented + live-verified** |
|
|
34
|
+
| **Claude Code** | MCP `mcp__load-lock__*` (user scope) | `SessionStart` hook plain-stdout context | Yes (SessionStart matcher `compact`) | Yes — `Stop` hook decision-block + async `await-offer` (`asyncRewake`) | Yes, via asyncRewake (contract-tested) | Full via MCP `run_with_permit` + server heartbeats | **Implemented + contract-tested** |
|
|
35
|
+
| **Codex CLI** | MCP `mcp__load-lock__*` (config.toml) | `SessionStart` + `SubagentStart` additionalContext; `~/.codex/AGENTS.md` | Yes (SessionStart matcher `compact`) | Yes — `Stop` hook `decision:"block"` continuation | Partial: async background hooks do NOT start a new turn (documented limitation); stop-hook covers turn boundaries | Full via MCP `run_with_permit` + server heartbeats | **Implemented + contract-tested** (hooks need `/hooks` trust review) |
|
|
36
|
+
| **Gemini CLI** | Yes (`settings.json` mcpServers) | `GEMINI.md` global (config `context.fileName`) | Via global GEMINI.md (always loaded) | No hook mechanism exists | No | Via MCP `run_with_permit` only | Proposed (MCP fallback) |
|
|
37
|
+
| **GitHub Copilot CLI** | Yes (`copilot mcp add`) | AGENTS.md/GEMINI.md discovery + custom instructions | Via global instructions file | Hooks reported in ecosystem content but **not verified in official docs** — treat as unavailable | Unverified | Via MCP `run_with_permit` only | Proposed (MCP fallback) |
|
|
38
|
+
| **Cursor** | Yes (`.cursor/mcp.json` + global) | `.cursor/rules/*.md` Always rules | Rules re-apply (Always) | `stop` hook exists but **beta** with a less-documented JSON contract — not shipped | Beta/unverified | Via MCP `run_with_permit` only | Proposed (MCP fallback; hook tier possible later) |
|
|
39
|
+
| **Cline** | Yes (`cline_mcp_settings.json`) | `.clinerules/` + `~/.cline/rules` | Rules always loaded | No hooks | No | Via MCP `run_with_permit` only | Proposed (MCP fallback) |
|
|
40
|
+
| **Roo Code** | Yes (Roo MCP settings) | `.roo/` rule dirs | Rules always loaded | No hooks | No | Via MCP `run_with_permit` only | Proposed (MCP fallback) |
|
|
41
|
+
| **Goose** | Yes (`~/.config/goose/config.yaml`) | `.goosehints` (global + project) | Hints always loaded | No hooks | No | Via MCP `run_with_permit` only | Proposed (MCP fallback) |
|
|
42
|
+
| **Aider** | **No MCP support** | CONVENTIONS.md / `.aider.conf.yml` | Read each run | N/A | N/A | CLI-only: same-shell `load-lock acquire ... && cmd; load-lock release ...` (manual) | Proposed (manual CLI tier) |
|
|
43
|
+
|
|
44
|
+
## How each integration tier works
|
|
45
|
+
|
|
46
|
+
### Tier 1 — OpenCode (native plugin, live-verified)
|
|
47
|
+
|
|
48
|
+
Global plugin (`load-lock install --harness opencode`) registers:
|
|
49
|
+
- `load_lock_*` tools with session-bound agent identity and cwd,
|
|
50
|
+
- the policy brief injected into system context on **every** model call
|
|
51
|
+
(survives compaction by construction),
|
|
52
|
+
- a `load-lock` skill with the full agent guide,
|
|
53
|
+
- daemon event watcher → `ctx.session.synthetic` offer messages with the
|
|
54
|
+
real claim token, delivered at execution boundaries,
|
|
55
|
+
- per-session heartbeats.
|
|
56
|
+
|
|
57
|
+
Verified live on opencode v2.0.29: plugin load, policy visible to the model,
|
|
58
|
+
tool round-trip (acquire → queued → wakeup → claim → release), execution-
|
|
59
|
+
boundary delivery without interrupting a running `sleep`, skill visible,
|
|
60
|
+
and graceful handling of a deliberately missed offer (paused → cancel).
|
|
61
|
+
|
|
62
|
+
### Tier 2 — Claude Code / Codex CLI (hooks + MCP, contract-tested)
|
|
63
|
+
|
|
64
|
+
`load-lock install --harness claude-code|codex` configures the MCP server
|
|
65
|
+
(user scope) plus hooks:
|
|
66
|
+
|
|
67
|
+
- **SessionStart** → policy brief + machine summary as developer context
|
|
68
|
+
(matchers include `compact` so it re-fires after auto-compaction).
|
|
69
|
+
- **SubagentStart** (Codex) → same for spawned subagents.
|
|
70
|
+
- **Stop** → if the daemon has an offer whose entry cwd matches the session
|
|
71
|
+
cwd, the hook continues the session with a claim instruction (decision
|
|
72
|
+
block). Honors `stop_hook_active` to avoid loops; silent otherwise.
|
|
73
|
+
- **await-offer** (Claude Code, `async: true, asyncRewake: true`) → watches
|
|
74
|
+
daemon events; on a matching offer exits 2 with a message that wakes an
|
|
75
|
+
idle Claude. The token is deliberately NOT in that message (stderr may be
|
|
76
|
+
shown); the agent claims via `pending`/`claim`.
|
|
77
|
+
|
|
78
|
+
Contract tests cover all hook stdin/stdout shapes against the documented
|
|
79
|
+
harness contracts. Live verification pending a machine with the harness
|
|
80
|
+
installed. Codex requires the user to trust the hooks once via `/hooks`
|
|
81
|
+
(the installer prints this).
|
|
82
|
+
|
|
83
|
+
### Tier 3 — MCP-only fallback (Gemini CLI, Copilot CLI, Cursor, Cline, Roo Code, Goose)
|
|
84
|
+
|
|
85
|
+
The MCP server itself carries the policy in its `instructions`, every tool
|
|
86
|
+
result surfaces pending offers (tool-boundary delivery), and
|
|
87
|
+
`inspect_machine`/`get_config`/`configure` work unchanged. Where the harness
|
|
88
|
+
supports global instruction files (GEMINI.md / AGENTS.md / rules / hints),
|
|
89
|
+
`load-lock install --harness generic` prints a snippet to add. Missing:
|
|
90
|
+
idle wakeup and automatic context re-injection — queued agents discover
|
|
91
|
+
offers at their next tool call or via a bounded `wait`. This is the honest
|
|
92
|
+
fallback; we do not pretend these harnesses got push notifications.
|
|
93
|
+
|
|
94
|
+
### Tier 4 — Aider (manual CLI)
|
|
95
|
+
|
|
96
|
+
No MCP/hooks; nothing to install. Agents (or humans) use the CLI directly in
|
|
97
|
+
the shell: `load-lock acquire ... && load-lock run -- <cmd>` handles
|
|
98
|
+
acquire→run→release in one command line.
|
|
99
|
+
|
|
100
|
+
## Session identity per harness
|
|
101
|
+
|
|
102
|
+
| Harness | Identity | Notes |
|
|
103
|
+
| --- | --- | --- |
|
|
104
|
+
| OpenCode | plugin context `sessionID` (also MCP `_meta` when used) | automatic |
|
|
105
|
+
| Claude Code | hooks get `session_id`; MCP tools take explicit `agent_id` | policy instructs agents to pass it |
|
|
106
|
+
| Codex | hooks get `session_id`; MCP tools take explicit `agent_id` | same |
|
|
107
|
+
| Tier 3 | explicit `agent_id` argument | falls back to per-server-process id |
|
|
108
|
+
|
|
109
|
+
## Verified vs proposed — summary counts
|
|
110
|
+
|
|
111
|
+
- Live-verified: OpenCode (full adapter, including wakeup).
|
|
112
|
+
- Contract-tested: Claude Code, Codex CLI hook/MCP contracts.
|
|
113
|
+
- Core daemon/protocol/CLI: 32 automated tests + scripted 3-worktree demo
|
|
114
|
+
(three independent agent processes, real git worktrees, one pool).
|
|
115
|
+
- Proposed (researched capabilities, generic adapter only): Gemini CLI,
|
|
116
|
+
Copilot CLI, Cursor, Cline, Roo Code, Goose, Aider.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Operating load-lock
|
|
2
|
+
|
|
3
|
+
## Install
|
|
4
|
+
|
|
5
|
+
Requirements: Node 20+, Linux (macOS works with the limitations noted below;
|
|
6
|
+
Windows would need a named-pipe adapter — not implemented).
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install -g @aron98/load-lock # or: npm install -g ./aron98-load-lock-<ver>.tgz
|
|
10
|
+
load-lock install --harness opencode
|
|
11
|
+
load-lock install --harness claude-code
|
|
12
|
+
load-lock install --harness codex
|
|
13
|
+
load-lock install --harness generic # prints MCP snippets for other harnesses
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
What each installer does (all merges are non-destructive; uninstall removes
|
|
17
|
+
exactly the entries it added):
|
|
18
|
+
|
|
19
|
+
- **opencode** — writes a plugin stub to
|
|
20
|
+
`~/.config/opencode/plugins/load-lock/` (npm-installed against the global
|
|
21
|
+
package), registers it in `~/.config/opencode/opencode.json`.
|
|
22
|
+
- **claude-code** — adds `mcpServers.load-lock` to `~/.claude.json`; hooks
|
|
23
|
+
(`SessionStart`, `Stop`, async `await-offer`) to `~/.claude/settings.json`;
|
|
24
|
+
a marked section to `~/.claude/CLAUDE.md`; copies the skill to
|
|
25
|
+
`~/.claude/skills/load-lock/`.
|
|
26
|
+
- **codex** — writes `~/.codex/hooks.json` (`SessionStart`, `SubagentStart`,
|
|
27
|
+
`Stop`), appends `[mcp_servers.load-lock]` to `~/.codex/config.toml` if
|
|
28
|
+
absent, adds a marked section to `~/.codex/AGENTS.md`. **Then run `/hooks`
|
|
29
|
+
inside Codex once to trust the hooks.**
|
|
30
|
+
- **generic** — prints MCP stdio config snippets (Gemini CLI, Cursor,
|
|
31
|
+
Cline/Roo, Goose) plus a suggested instructions-file section.
|
|
32
|
+
|
|
33
|
+
`load-lock uninstall --harness <harness>` reverses any of these.
|
|
34
|
+
|
|
35
|
+
## The daemon
|
|
36
|
+
|
|
37
|
+
- State dir: `~/.local/state/load-lock` (override `LOAD_LOCK_STATE_DIR`).
|
|
38
|
+
- Socket: `<state dir>/daemon.sock` (override `LOAD_LOCK_SOCKET`).
|
|
39
|
+
- **Auto-spawns** on first client connection; no service unit needed.
|
|
40
|
+
- Files: `config.json` (settings + history), `journal.json` (held permits),
|
|
41
|
+
`daemon.json` (pid), `spawn.lock` (bootstrap race guard).
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
load-lock service status # hello + pid
|
|
45
|
+
load-lock service start # ensure running (idempotent)
|
|
46
|
+
load-lock service stop # SIGTERM; next tool call respawns
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
All configuration is agent-editable through tools (no file editing for
|
|
52
|
+
ordinary use) — MCP `configure` / plugin `load_lock_configure` /
|
|
53
|
+
`load-lock configure`. Updates are compare-and-swap on a version number
|
|
54
|
+
(`expected_version` prevents silent overwrites), validated, persisted with
|
|
55
|
+
author + reason, and appended to a visible history (last 50 changes).
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
load-lock configure --capacity 2 --reason "host has 16 cores" --author ops
|
|
59
|
+
load-lock configure --offer-timeout-ms 60000 --reason "model turns can be slow" --author agent
|
|
60
|
+
load-lock get-config
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Defaults: one pool `heavy` capacity 1 · offer window 30s · blocking wait 5s ·
|
|
64
|
+
start deadline 120s · untracked lease 15min. Valid ranges:
|
|
65
|
+
offer window 1s–10min, wait 0–10min, start deadline 1s–1h, lease 10s–24h.
|
|
66
|
+
|
|
67
|
+
Semantics of changes:
|
|
68
|
+
|
|
69
|
+
- **Capacity increase** — queued requests are admitted immediately (FIFO
|
|
70
|
+
offers); outstanding offers keep their reservations.
|
|
71
|
+
- **Capacity decrease** — current holders finish untouched; new admissions
|
|
72
|
+
are delayed until usage fits.
|
|
73
|
+
|
|
74
|
+
`inspect_machine` reports logical cores, cgroup v2/v1 CPU quota and memory
|
|
75
|
+
limits (containers respected — we never assume 2 cores means 2 jobs),
|
|
76
|
+
available memory, and current load/memory pressure with an advisory
|
|
77
|
+
suggestion. Agents are expected to consult it before proposing capacity.
|
|
78
|
+
|
|
79
|
+
## Diagnostics & recovery
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
load-lock doctor # state dir, socket, daemon reachability, effective cores, pressure
|
|
83
|
+
load-lock status # human view: usage, holders w/ phase+pid, offers, queue, paused
|
|
84
|
+
load-lock pending # your own offers/queue entries (JSON)
|
|
85
|
+
load-lock events --follow # durable event log (grants, offers, releases, reclaims...)
|
|
86
|
+
load-lock recover list # holder diagnostics: tracked? alive? age, long-hold flags
|
|
87
|
+
load-lock recover force-release --id <request-id> # admin release (prints holder/job info)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Recovery policy summary:
|
|
91
|
+
|
|
92
|
+
- Held permits with a tracked live process are **never** revoked for being
|
|
93
|
+
old — long holds are flagged in diagnostics, nothing more.
|
|
94
|
+
- A tracked permit auto-releases when its process (or process group) is
|
|
95
|
+
confirmed dead — including background children that outlived the shell.
|
|
96
|
+
- A claimed-but-never-started permit is reclaimed after the start deadline.
|
|
97
|
+
- An untracked (`mark_started`) permit lives under a heartbeat lease; if the
|
|
98
|
+
owning agent (MCP server / plugin / CLI heartbeat) disappears, it is
|
|
99
|
+
reclaimed after the lease. Residual risk of overlap with an unobserved
|
|
100
|
+
surviving process is documented — use `attach_pid`/`run_with_permit`
|
|
101
|
+
whenever possible for real guarantees.
|
|
102
|
+
- After a daemon restart, `journal.json` reconciles held permits against
|
|
103
|
+
actual process liveness (dead ⇒ released, alive ⇒ kept with same tokens).
|
|
104
|
+
Queued/offered requests are not journaled — agents re-acquire; this is
|
|
105
|
+
intentional and safe.
|
|
106
|
+
|
|
107
|
+
## Troubleshooting
|
|
108
|
+
|
|
109
|
+
| Symptom | Check / fix |
|
|
110
|
+
| --- | --- |
|
|
111
|
+
| Tools hang | `load-lock doctor`. Daemon down? It auto-spawns; if the socket file is stale it is detected and replaced automatically. |
|
|
112
|
+
| `offer_expired` on claim | Claim window elapsed (default 30s) — the request is paused; `rejoin` it and claim promptly. Consider a longer `--offer-timeout-ms`. |
|
|
113
|
+
| Agent got "undefined" wakeup / no offer | Offers match the cwd recorded at acquire time; verify with `load-lock pending`. Claude Code/Codex wakeups require cwd match between hook session and acquire call. |
|
|
114
|
+
| Capacity seems stuck | `status` + `recover list`: look for untracked leases (`[untracked, leased]`), long holds, or dead holders awaiting sweep (runs every 5s). Use `force-release` with the printed request id. |
|
|
115
|
+
| Codex hooks never fire | Run `/hooks` in Codex and trust the load-lock entries; check `[features] hooks` is not disabled. |
|
|
116
|
+
| Claude Code doesn't wake when idle | The async `await-offer` hook must be in `~/.claude/settings.json` with `async: true, asyncRewake: true` — verify with `load-lock doctor` / inspect the file. |
|
|
117
|
+
| Config change lost | Configuration is CAS-versioned: fetch `get_config`, retry with the fresh `version` on `version_conflict`. |
|
|
118
|
+
| After OS reboot | Nothing to do: state dir persists config; daemon respawns on first use; journal reconciliation releases permits whose processes died. |
|
|
119
|
+
|
|
120
|
+
## macOS / Windows notes
|
|
121
|
+
|
|
122
|
+
- macOS: no cgroup limits; process-liveness checks fall back from
|
|
123
|
+
`/proc/<pid>/stat` start times to `kill(pid, 0)` (weaker PID-reuse
|
|
124
|
+
defense — document and prefer `run_with_permit`). Everything else works.
|
|
125
|
+
- Windows: Unix-domain sockets are not used; a named-pipe transport adapter
|
|
126
|
+
would be required (planned, not implemented).
|