@aron98/load-lock 0.0.0-stage → 0.1.1
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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aron98
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,116 @@
|
|
|
1
|
-
#
|
|
1
|
+
# load-lock
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/aron98/load-lock/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
**A machine-wide heavy-work budget for parallel coding agents.**
|
|
6
|
+
|
|
7
|
+
When several agents work side by side on one machine — separate sessions,
|
|
8
|
+
repos, git worktrees, or harnesses (OpenCode, Codex CLI, Claude Code, ...) —
|
|
9
|
+
their builds and test suites stomp on each other, thrash the caches, and
|
|
10
|
+
starve each other's cores. load-lock gives them one shared counting
|
|
11
|
+
semaphore: acquire a permit before heavy work, keep doing light work while
|
|
12
|
+
queued, receive a FIFO offer when capacity frees, claim it, run, release.
|
|
13
|
+
It is cooperative and advisory — no command wrapping, no automatic shell
|
|
14
|
+
classification, no approval interruptions.
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
agent A (OpenCode) agent B (Codex) agent C (Claude Code)
|
|
18
|
+
acquire "cargo test" acquire "npm test" acquire "build docs"
|
|
19
|
+
granted ✓ runs queued → keeps coding queued → keeps coding
|
|
20
|
+
... offer → claim ✓ runs (misses window → paused)
|
|
21
|
+
release ✓ release ✓ rejoin → offer → claim ✓
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- **One budget for the whole machine** — every client of any harness shares
|
|
27
|
+
one capacity pool regardless of repo/worktree/cwd. Default: pool `heavy`,
|
|
28
|
+
capacity 1; fully runtime-configurable by any agent via versioned CAS
|
|
29
|
+
updates (persisted with author + rationale).
|
|
30
|
+
- **Atomic acquire-or-queue** — single daemon event loop; a release can
|
|
31
|
+
never slip between a failed check and queue registration.
|
|
32
|
+
- **FIFO offers with opaque claim tokens** — outstanding offers reserve
|
|
33
|
+
capacity; expired offers pass capacity to the next waiter and pause the
|
|
34
|
+
absent request until it explicitly rejoins (no endless recycling).
|
|
35
|
+
- **Real process-lifecycle cleanup** — permits attach to actual processes
|
|
36
|
+
(pid + process group + `/proc` start time, PID-reuse-safe). Background
|
|
37
|
+
jobs outliving the shell keep their permit until the whole group exits.
|
|
38
|
+
Never auto-expired merely for being old. Untracked work uses an honest,
|
|
39
|
+
documented heartbeat-lease tradeoff. Explicit release is always
|
|
40
|
+
available and idempotent.
|
|
41
|
+
- **Crash-safe** — agent crashes release tracked permits on confirmed
|
|
42
|
+
process death; daemon restarts reconcile a journal against real liveness
|
|
43
|
+
and auto-respawn transparently.
|
|
44
|
+
- **Honest notifications** — tool-boundary offer delivery everywhere; real
|
|
45
|
+
idle wakeup where the harness supports it (live-verified on OpenCode;
|
|
46
|
+
hook-based on Claude Code and Codex CLI); durable event ids for
|
|
47
|
+
reconnects. A notification never authorizes work — only `claim` does.
|
|
48
|
+
- **Policy that actually reaches the model** — installed per harness via
|
|
49
|
+
its real mechanism (plugin context hook, SessionStart hooks incl.
|
|
50
|
+
compact matchers, global instruction files), plus a detailed skill/guide.
|
|
51
|
+
- **Resource-aware** — `inspect_machine` reports cgroup/container CPU and
|
|
52
|
+
memory limits and current pressure; never assumes cores = capacity.
|
|
53
|
+
|
|
54
|
+
## Quickstart
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
npm install -g @aron98/load-lock
|
|
58
|
+
load-lock install --harness opencode # or claude-code | codex | generic
|
|
59
|
+
load-lock doctor
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
From then on, agents see the policy and tools automatically:
|
|
63
|
+
OpenCode plugin (`load_lock_*` tools), Claude Code / Codex MCP tools
|
|
64
|
+
(`mcp__load-lock__*`) plus session hooks. Any other MCP-capable harness can
|
|
65
|
+
use the generic stdio server; plain shells use the CLI:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
load-lock run --reason "nightly build" -- npm run build # acquire→run→release
|
|
69
|
+
load-lock status # machine-wide view
|
|
70
|
+
load-lock configure --capacity 2 --reason "16-core host" --author me
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Layout
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
src/core/ daemon + state machine + persistence + machine inspection
|
|
77
|
+
src/mcp/ MCP stdio server (universal adapter)
|
|
78
|
+
src/adapters/ OpenCode native plugin
|
|
79
|
+
src/cli/ CLI incl. installer, hook handlers, run/wait helpers
|
|
80
|
+
src/test/ 32 tests: state machine, real multi-process daemon tests,
|
|
81
|
+
MCP integration, hook contracts, run helper
|
|
82
|
+
demo/ reproducible 3-worktree demo (three agents, one pool)
|
|
83
|
+
docs/ ARCHITECTURE / COMPATIBILITY / OPERATIONS / AGENT_GUIDE
|
|
84
|
+
skills/ packaged skill
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Documentation
|
|
88
|
+
|
|
89
|
+
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — design, why a dedicated
|
|
90
|
+
service (vs WorkOS Airlock / mic-lock / flock wrappers), atomicity,
|
|
91
|
+
restart semantics, notification honesty, lifecycle tradeoffs.
|
|
92
|
+
- [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md) — per-harness capability
|
|
93
|
+
matrix with implemented / contract-tested / proposed labels.
|
|
94
|
+
- [docs/OPERATIONS.md](docs/OPERATIONS.md) — install, config,
|
|
95
|
+
troubleshooting, recovery, daemon lifecycle.
|
|
96
|
+
- [docs/AGENT_GUIDE.md](docs/AGENT_GUIDE.md) — the playbook for agents.
|
|
97
|
+
- [docs/ADAPTERS.md](docs/ADAPTERS.md) — how to write an adapter for a new
|
|
98
|
+
harness, using the published library surface.
|
|
99
|
+
|
|
100
|
+
## Status
|
|
101
|
+
|
|
102
|
+
- Core daemon/protocol/CLI: implemented, 32 automated tests passing
|
|
103
|
+
(multi-process daemons, real processes, restarts, races, expiry,
|
|
104
|
+
PID-reuse, subprocess survival).
|
|
105
|
+
- OpenCode adapter: implemented and **live-verified** (tools, policy
|
|
106
|
+
injection, compaction-proof context hook, execution-boundary wakeup
|
|
107
|
+
with tokens, skill).
|
|
108
|
+
- Claude Code + Codex CLI adapters: implemented, contract-tested against
|
|
109
|
+
documented hook JSON; not yet live-verified (harnesses not installed on
|
|
110
|
+
the verification machine).
|
|
111
|
+
- Gemini CLI, Copilot CLI, Cursor, Cline, Roo Code, Goose, Aider:
|
|
112
|
+
researched; MCP fallback tier (Aider: manual CLI tier). See
|
|
113
|
+
COMPATIBILITY.md for the matrix.
|
|
114
|
+
|
|
115
|
+
Linux-first; macOS works with documented liveness-check limitations;
|
|
116
|
+
Windows would need a named-pipe transport adapter (planned, not built).
|
package/demo/demo.sh
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# load-lock demo: three agents in three git worktrees compete for one
|
|
3
|
+
# machine-wide heavy-work budget (capacity 1) while a shared daemon arbitrates.
|
|
4
|
+
#
|
|
5
|
+
# Fully scripted, reproducible, no network. Run: bash demo/demo.sh
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
9
|
+
CLI=(node "$ROOT/dist/cli/main.js")
|
|
10
|
+
DEMO="$(mktemp -d /tmp/opencode/ll-demo-XXXXXX)"
|
|
11
|
+
|
|
12
|
+
cleanup() {
|
|
13
|
+
"${CLI[@]}" service stop >/dev/null 2>&1 || true
|
|
14
|
+
rm -rf "$DEMO"
|
|
15
|
+
}
|
|
16
|
+
trap cleanup EXIT
|
|
17
|
+
|
|
18
|
+
say() { printf '\n\033[1m==== %s ====\033[0m\n' "$*"; }
|
|
19
|
+
line() { printf -- '---- %s\n' "$*"; }
|
|
20
|
+
|
|
21
|
+
# Poll `load-lock status` (human output) until it matches $2, else fail.
|
|
22
|
+
wait_for() {
|
|
23
|
+
local desc="$1" pattern="$2" i
|
|
24
|
+
for i in $(seq 1 150); do
|
|
25
|
+
if "${CLI[@]}" status 2>/dev/null | grep -qE "$pattern"; then return 0; fi
|
|
26
|
+
sleep 0.1
|
|
27
|
+
done
|
|
28
|
+
echo "TIMED OUT waiting for: $desc" >&2
|
|
29
|
+
"${CLI[@]}" status || true
|
|
30
|
+
exit 1
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
say "build"
|
|
34
|
+
(cd "$ROOT" && npm run build --silent)
|
|
35
|
+
|
|
36
|
+
say "workspace: git repo + 3 worktrees in $DEMO"
|
|
37
|
+
git init -q "$DEMO/repo"
|
|
38
|
+
git -C "$DEMO/repo" config user.email demo@load-lock
|
|
39
|
+
git -C "$DEMO/repo" config user.name demo
|
|
40
|
+
echo 'fn main() { println!("heavy"); }' > "$DEMO/repo/main.rs"
|
|
41
|
+
git -C "$DEMO/repo" add -A
|
|
42
|
+
git -C "$DEMO/repo" commit -qm "init"
|
|
43
|
+
for w in a b c; do
|
|
44
|
+
git -C "$DEMO/repo" worktree add -q "$DEMO/wt-$w" -b "wt-$w"
|
|
45
|
+
done
|
|
46
|
+
line "3 worktrees, one repo — 3 'agents' will share ONE budget regardless of checkout"
|
|
47
|
+
|
|
48
|
+
# Isolated daemon for the demo (does not touch any real state).
|
|
49
|
+
export LOAD_LOCK_STATE_DIR="$DEMO/state"
|
|
50
|
+
export LOAD_LOCK_SOCKET="$DEMO/state/daemon.sock"
|
|
51
|
+
|
|
52
|
+
say "start the shared daemon"
|
|
53
|
+
"${CLI[@]}" service start
|
|
54
|
+
|
|
55
|
+
say "machine inventory (what agents are told to reason with)"
|
|
56
|
+
"${CLI[@]}" inspect --json | node -e '
|
|
57
|
+
let s=""; process.stdin.on("data", d => (s += d)).on("end", () => {
|
|
58
|
+
const j = JSON.parse(s);
|
|
59
|
+
console.log(`cpu: ${j.cpu.logicalCores} logical / ${j.cpu.effectiveCores} effective (${j.cpu.cgroupVersion})`);
|
|
60
|
+
console.log(`memory: ${(j.memory.totalBytes/2**30).toFixed(1)} GiB total / ${(j.memory.effectiveLimitBytes/2**30).toFixed(1)} GiB effective limit`);
|
|
61
|
+
console.log(`pressure: ${j.pressure.note}`);
|
|
62
|
+
});'
|
|
63
|
+
|
|
64
|
+
say "configure: capacity 1, short claim window (2s) for a snappy demo"
|
|
65
|
+
"${CLI[@]}" configure --capacity 1 --offer-timeout-ms 2000 \
|
|
66
|
+
--reason "demo: single heavy slot; short offer window to show expiry" --author "demo-operator" >/dev/null
|
|
67
|
+
line "any agent may propose changes like this via configure (versioned CAS); the demo pins it"
|
|
68
|
+
|
|
69
|
+
say "launch agent A (gets the only slot) — then B and C, which queue"
|
|
70
|
+
(cd "$DEMO/wt-a" && exec node "$ROOT/demo/sim-agent.mjs" --role A --agent sim-a --label "A build (wt-a)") &
|
|
71
|
+
PID_A=$!
|
|
72
|
+
wait_for "A holds the only slot" '1/1 in use'
|
|
73
|
+
(cd "$DEMO/wt-b" && exec node "$ROOT/demo/sim-agent.mjs" --role B --agent sim-b --label "B test (wt-b)") &
|
|
74
|
+
PID_B=$!
|
|
75
|
+
wait_for "B is queued" '1 queued'
|
|
76
|
+
(cd "$DEMO/wt-c" && exec node "$ROOT/demo/sim-agent.mjs" --role C --agent sim-c --label "C build (wt-c)") &
|
|
77
|
+
PID_C=$!
|
|
78
|
+
wait_for "C is queued too" '2 queued'
|
|
79
|
+
|
|
80
|
+
say "snapshot: one heavy job running, two agents queued (all in different worktrees)"
|
|
81
|
+
"${CLI[@]}" status
|
|
82
|
+
|
|
83
|
+
say "agents run on their own — watching the hand-off from A to B (strict FIFO)"
|
|
84
|
+
wait_for "A released; B now holds the permit" 'held: B test'
|
|
85
|
+
line "A finished its build; the daemon reserved the slot for the FIFO head (B) and made an offer"
|
|
86
|
+
wait_for "B finished; C gets an offer — but C is busy and will miss it" 'held: C build|1 paused'
|
|
87
|
+
line "C deliberately let its offer expire: the request is PAUSED, not recycled"
|
|
88
|
+
wait_for "capacity back to idle (C paused, nobody holds)" '0/1 in use'
|
|
89
|
+
"${CLI[@]}" status
|
|
90
|
+
|
|
91
|
+
say "C rejoins when actually ready, claims, runs"
|
|
92
|
+
wait_for "all three agents finished (capacity idle)" ': 0/1 in use'
|
|
93
|
+
wait "$PID_A" "$PID_B" "$PID_C"
|
|
94
|
+
|
|
95
|
+
say "final state"
|
|
96
|
+
"${CLI[@]}" status
|
|
97
|
+
"${CLI[@]}" get-config | node -e '
|
|
98
|
+
let s=""; process.stdin.on("data", d => (s += d)).on("end", () => {
|
|
99
|
+
const j = JSON.parse(s);
|
|
100
|
+
console.log(`config version ${j.config.version}; capacity(heavy)=${j.config.pools.heavy.capacity}; offer window ${j.config.offerTimeoutMs}ms`);
|
|
101
|
+
console.log("change history:");
|
|
102
|
+
for (const h of j.history) console.log(` v${h.version} by ${h.author}: ${h.reason}`);
|
|
103
|
+
});'
|
|
104
|
+
|
|
105
|
+
say "event log (durable identities — every grant, offer, claim, release)"
|
|
106
|
+
"${CLI[@]}" events --json | node -e '
|
|
107
|
+
let s=""; process.stdin.on("data", d => (s += d)).on("end", () => {
|
|
108
|
+
const j = JSON.parse(s);
|
|
109
|
+
for (const ev of j.events) console.log(` #${ev.id} ${ev.type.padEnd(13)} ${ev.entry?.agentLabel ?? ev.targetAgent ?? ""}`);
|
|
110
|
+
});'
|
|
111
|
+
|
|
112
|
+
printf '\n\033[1mDemo complete:\033[0m capacity 1 was never exceeded; offers went out strictly in FIFO order;\nC missed its claim window, was paused (not endlessly recycled), and rejoined explicitly.\n'
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Simulated coding agent for the load-lock demo.
|
|
4
|
+
*
|
|
5
|
+
* Each instance is a separate OS process playing one agent from one harness,
|
|
6
|
+
* working inside one git worktree. It talks to the shared load-lock daemon
|
|
7
|
+
* exactly the way a scripted agent (e.g. aider, a shell-driven flow) would:
|
|
8
|
+
* via the `load-lock` CLI. Real agents would use MCP tools or the native
|
|
9
|
+
* OpenCode plugin instead — same daemon, same protocol, same FIFO.
|
|
10
|
+
*
|
|
11
|
+
* Usage (normally launched by demo.sh, not directly):
|
|
12
|
+
* cd <worktree> && node sim-agent.mjs --role A --agent sim-a --label "A (wt-a)"
|
|
13
|
+
*
|
|
14
|
+
* Roles:
|
|
15
|
+
* A — gets the permit immediately, runs heavy work, releases.
|
|
16
|
+
* B — queues, does light work meanwhile, claims its FIFO offer, runs heavy work.
|
|
17
|
+
* C — queues, deliberately MISSES its claim window (offer expires → paused),
|
|
18
|
+
* then explicitly rejoins once actually ready, claims, runs heavy work.
|
|
19
|
+
*/
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { parseArgs } from "node:util";
|
|
23
|
+
|
|
24
|
+
const { values } = parseArgs({
|
|
25
|
+
options: {
|
|
26
|
+
role: { type: "string" },
|
|
27
|
+
agent: { type: "string" },
|
|
28
|
+
label: { type: "string" },
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
const role = values.role;
|
|
32
|
+
const agent = values.agent;
|
|
33
|
+
const label = values.label ?? agent;
|
|
34
|
+
if (!role || !agent) {
|
|
35
|
+
console.error("sim-agent: --role and --agent are required");
|
|
36
|
+
process.exit(64);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const CLI = join(import.meta.dirname, "..", "dist", "cli", "main.js");
|
|
40
|
+
|
|
41
|
+
/** Run the load-lock CLI; returns {code, stdout, stderr, json}. */
|
|
42
|
+
function ll(args) {
|
|
43
|
+
const r = spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8", timeout: 120_000 });
|
|
44
|
+
let json = null;
|
|
45
|
+
try {
|
|
46
|
+
json = JSON.parse(r.stdout);
|
|
47
|
+
} catch {
|
|
48
|
+
/* human output */
|
|
49
|
+
}
|
|
50
|
+
return { code: r.status, stdout: r.stdout, stderr: r.stderr, json };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const log = (msg) => console.log(`[${label}] ${msg}`);
|
|
54
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
55
|
+
const fail = (msg) => {
|
|
56
|
+
console.error(`[${label}] FATAL: ${msg}`);
|
|
57
|
+
process.exit(70);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const ACQ = ["acquire", "--agent", agent, "--label", label];
|
|
61
|
+
|
|
62
|
+
/** Poll own pending offers until one appears (or bail after ~30s). */
|
|
63
|
+
async function waitForOffer() {
|
|
64
|
+
for (let i = 0; i < 100; i++) {
|
|
65
|
+
const p = ll(["pending", "--agent", agent]);
|
|
66
|
+
const offer = p.json?.offers?.[0];
|
|
67
|
+
if (offer) return offer;
|
|
68
|
+
await sleep(300);
|
|
69
|
+
}
|
|
70
|
+
fail("no offer arrived within 30s");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------- role A
|
|
74
|
+
async function roleA() {
|
|
75
|
+
log("I need to run a heavy job: cargo build (simulated with `sleep 2`)");
|
|
76
|
+
const acq = ll([...ACQ, "--op-key", "build-wt-a", "--reason", "cargo build (simulated)"]);
|
|
77
|
+
if (acq.code !== 0 || acq.json?.state !== "granted") {
|
|
78
|
+
fail(`expected an immediate grant, got: ${acq.stdout.trim() || acq.stderr.trim()}`);
|
|
79
|
+
}
|
|
80
|
+
const permit = acq.json.entry.permitToken;
|
|
81
|
+
log(`capacity was free: permit granted immediately (${permit.slice(0, 12)}…). Starting heavy work.`);
|
|
82
|
+
const run = ll(["run", "--permit", permit, "--", "sleep", "2"]);
|
|
83
|
+
log(`heavy work finished (exit ${run.code}); the run helper released the permit with the job's outcome.`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------- role B
|
|
87
|
+
async function roleB() {
|
|
88
|
+
log("I need to run a heavy job: cargo test (simulated with `sleep 1.5`)");
|
|
89
|
+
const acq = ll([...ACQ, "--op-key", "build-wt-b", "--reason", "cargo test (simulated)"]);
|
|
90
|
+
if (acq.json?.state !== "queued") fail(`expected to queue, got: ${acq.stdout.trim()}`);
|
|
91
|
+
log(
|
|
92
|
+
`machine is busy: registered in the FIFO queue at position ${acq.json.entry.position} ` +
|
|
93
|
+
`(request ${acq.json.entry.requestId.slice(0, 12)}…). No permit yet.`,
|
|
94
|
+
);
|
|
95
|
+
log("continuing useful LIGHT work meanwhile — no permit needed for these:");
|
|
96
|
+
for (let i = 1; i <= 3; i++) {
|
|
97
|
+
await sleep(400);
|
|
98
|
+
log(` light work ${i}/3: grep TODOs in my worktree`);
|
|
99
|
+
}
|
|
100
|
+
log("blocking on my queued request (bounded wait; a real agent would just check pending at the next tool call)…");
|
|
101
|
+
const w = ll(["wait", "--id", acq.json.entry.requestId, "--agent", agent, "--timeout", "30000"]);
|
|
102
|
+
if (w.code !== 0) fail(`wait failed: ${w.stderr.trim() || w.stdout.trim()}`);
|
|
103
|
+
const permit = w.json.permit_token;
|
|
104
|
+
log(`FIFO offer arrived and was claimed (permit ${permit.slice(0, 12)}…). Starting heavy work.`);
|
|
105
|
+
const run = ll(["run", "--permit", permit, "--", "sleep", "1.5"]);
|
|
106
|
+
log(`heavy work finished (exit ${run.code}); permit released.`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------- role C
|
|
110
|
+
async function roleC() {
|
|
111
|
+
log("I need to run a heavy job too: cargo build (simulated with `sleep 1`)");
|
|
112
|
+
const acq = ll([...ACQ, "--op-key", "build-wt-c", "--reason", "cargo build (simulated)"]);
|
|
113
|
+
if (acq.json?.state !== "queued") fail(`expected to queue, got: ${acq.stdout.trim()}`);
|
|
114
|
+
log(`machine is busy: queued at position ${acq.json.entry.position}. Doing light work while I wait.`);
|
|
115
|
+
const offer = await waitForOffer();
|
|
116
|
+
log(
|
|
117
|
+
`offer arrived (claim window ~2s, expires ${new Date(offer.offerExpiresAt).toISOString()}) ` +
|
|
118
|
+
"— but I'm mid-edit on a file; I will deliberately NOT claim it yet.",
|
|
119
|
+
);
|
|
120
|
+
await sleep(3200); // longer than the 2s claim window
|
|
121
|
+
const p = ll(["pending", "--agent", agent]);
|
|
122
|
+
const paused = p.json?.paused?.[0];
|
|
123
|
+
if (!paused) fail("expected my request to be PAUSED after missing the claim window");
|
|
124
|
+
log(
|
|
125
|
+
"claim window elapsed → my request is PAUSED (the machine did not wait for me; " +
|
|
126
|
+
"capacity moved on, and I am not endlessly recycled).",
|
|
127
|
+
);
|
|
128
|
+
log("I'm ready now — explicitly rejoining the queue.");
|
|
129
|
+
ll(["rejoin", "--id", acq.json.entry.requestId]);
|
|
130
|
+
const offer2 = await waitForOffer();
|
|
131
|
+
log(`re-offered (request retained its FIFO identity). Claiming now, before the window lapses.`);
|
|
132
|
+
const claim = ll(["claim", "--offer", offer2.offerToken]);
|
|
133
|
+
if (claim.code !== 0) fail(`claim failed: ${claim.stderr.trim() || claim.stdout.trim()}`);
|
|
134
|
+
const permit = claim.json.entry.permitToken;
|
|
135
|
+
log(`claimed (${permit.slice(0, 12)}…). Starting heavy work.`);
|
|
136
|
+
const run = ll(["run", "--permit", permit, "--", "sleep", "1"]);
|
|
137
|
+
log(`heavy work finished (exit ${run.code}); permit released.`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// -------------------------------------------------------------------------------
|
|
141
|
+
try {
|
|
142
|
+
if (role === "A") await roleA();
|
|
143
|
+
else if (role === "B") await roleB();
|
|
144
|
+
else if (role === "C") await roleC();
|
|
145
|
+
else fail(`unknown role "${role}"`);
|
|
146
|
+
log("done. ✅");
|
|
147
|
+
} catch (e) {
|
|
148
|
+
fail(e?.stack ?? String(e));
|
|
149
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* load-lock native plugin for OpenCode V2.
|
|
3
|
+
*
|
|
4
|
+
* Installed to ~/.config/opencode/plugins/load-lock/ (see cli/install.ts).
|
|
5
|
+
* Provides:
|
|
6
|
+
* - native tools in the `load_lock` namespace (acquire/claim/release/...)
|
|
7
|
+
* - run_with_permit / job_status / job_stop: full execution lifecycle
|
|
8
|
+
* - automatic policy injection on every model call via the session
|
|
9
|
+
* "context" hook (compaction-proof by construction)
|
|
10
|
+
* - idle wakeup: daemon watch -> ctx.session.synthetic when an offer
|
|
11
|
+
* targets an OpenCode session
|
|
12
|
+
* - automatic per-session heartbeats (untracked leases never lapse while
|
|
13
|
+
* the OpenCode service lives; the model never has to remember)
|
|
14
|
+
* - a registered skill with the detailed agent guide
|
|
15
|
+
*/
|
|
16
|
+
import { Plugin } from "@opencode/plugin";
|
|
17
|
+
declare const _default: Plugin.Plugin;
|
|
18
|
+
export default _default;
|