@aron98/load-lock 1.2.0 → 1.3.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.
@@ -95,15 +95,62 @@ uninstall — see issue #19); not live-verified inside the real CLIs.
95
95
  [Sandboxes](#sandboxes)).
96
96
  - Also honored by the shared client: `LOAD_LOCK_LABEL_PREFIX` (prefix for
97
97
  holder labels; see Sandboxes).
98
- - Files: `config.json` (settings + history), `journal.json` (held permits),
99
- `daemon.json` (pid), `spawn.lock` (bootstrap race guard).
98
+ - Files: `config.json` (settings + history), `journal.json` (held permits +
99
+ queued requests), `daemon.json` (pid), `spawn.lock` (bootstrap race guard).
100
100
 
101
101
  ```sh
102
- load-lock service status # hello + pid
103
- load-lock service start # ensure running (idempotent)
104
- load-lock service stop # SIGTERM; next tool call respawns
102
+ load-lock service status # hello + pid + daemon version
103
+ load-lock service start # ensure running (idempotent); refusals name the conflicting pid
104
+ load-lock service stop # SIGTERM → bounded wait → SIGKILL; verifies the socket is free
105
105
  ```
106
106
 
107
+ `service stop` is honest, not fire-and-forget: it signals the daemon
108
+ (`--timeout <ms>` bounds the wait, default 5s), escalates to SIGKILL if the
109
+ socket still answers, and only reports success once the endpoint is
110
+ verifiably free (stale `daemon.json`/socket files are cleaned up either way).
111
+ `service start` refuses loudly when something else owns the endpoint —
112
+ naming the conflicting pid — instead of a bare "did not come up within 10s".
113
+
114
+ ## Update lifecycle
115
+
116
+ The daemon is long-lived, so an update (`npm i -g @aron98/load-lock@latest`,
117
+ or opencode refreshing its plugin copy) leaves the running daemon on stale
118
+ code. The **version handshake** closes that gap (v1.3.0+):
119
+
120
+ - Every client envelope carries `clientVersion`; `hello`/`status` replies
121
+ carry `daemonVersion` (the package version of the code the daemon runs).
122
+ - When a **newer** client contacts an older daemon, the daemon finishes
123
+ answering, then **self-restarts**: it persists state + journal, drains all
124
+ connections, respawns itself with the same argv (`process.execPath` +
125
+ original arguments, so it runs whatever is now on disk at the same path),
126
+ and exits. The respawn inherits a `LOAD_LOCK_RESTARTED_FOR` marker so
127
+ repeated contact with the same version cannot cause a restart loop — one
128
+ respawn attempt per version.
129
+ - Clients that were connected see a disconnect and simply reconnect to the
130
+ fresh daemon.
131
+ - **What survives a restart:** held permits are journaled and reconciled
132
+ conservatively (tracked jobs re-checked for liveness, same permit tokens
133
+ valid; dead jobs released); queued *and offered* requests are journaled and
134
+ re-queued FIFO with the same request ids (idempotent re-acquire keeps
135
+ working). Offered entries restart as queued and are re-offered with fresh
136
+ tokens — pre-restart offer tokens are invalid, exactly as for any restart.
137
+ - Skew is visible before it heals: `load-lock status`, `service status`,
138
+ `doctor`, and the dashboards show "daemon vX, installed vY" and note the
139
+ pending self-restart; `doctor` actively waits a few seconds to confirm the
140
+ daemon came back on the new version.
141
+
142
+ Limits, stated honestly: the respawn re-runs the **same argv** — it picks up
143
+ an updated copy only if the daemon's launching copy was updated *in place*
144
+ (the normal `npm i -g` case). A daemon spawned from a different copy (e.g.
145
+ opencode's private cache copy) keeps running that copy's code after a
146
+ restart; see the multi-copy notes in
147
+ [COMPATIBILITY.md](COMPATIBILITY.md#multiple-copies-of-load-lock-opencode).
148
+ Pre-1.3.0 daemons do not answer the handshake and never self-restart —
149
+ `service stop` followed by any load-lock command moves them onto current
150
+ code. The restart behavior itself is live-verified on Linux by the test
151
+ suite (`src/test/update.test.ts`); the in-place-update path beyond
152
+ same-argv respawn is inspected, not live-tested.
153
+
107
154
  ## Configuration
108
155
 
109
156
  All configuration is agent-editable through tools (no file editing for
@@ -607,56 +654,122 @@ Caveats observed (verified):
607
654
  inside the sandbox and split the budget. The tests in
608
655
  `src/test/sandbox.test.ts` pin the loud failure instead.
609
656
 
610
- ### omac — contract-inspected
611
-
612
- **omac is not installed on the verification machine**, so nothing in this
613
- subsection was executed here; it is assembled from omac's documented
614
- behavior (the facts below are from omac's docs, not invented) and the
615
- bubblewrap recipe above, which omac wraps on Linux. Treat it as a
616
- ready-to-run procedure with expected outcomes, and record what you find.
617
-
618
- omac sandboxes agents with bubblewrap on Linux (seatbelt on macOS, WSL2
619
- elsewhere) and **strips the harness environment** before starting the
620
- agent, allowing through only variables listed in the sandbox profile's
621
- `environment.allow_vars` — `~/.config/omac/sandbox-profiles/default.json`
622
- by default. That file is the whole recipe:
623
-
624
- 1. Make sure the load-lock state dir is visible inside omac's bubblewrap
625
- sandbox (the `LOAD_LOCK_SOCKET` path must resolve inside it). How
626
- omac's sandbox mounts map to bwrap `--bind`s is an omac sandbox-config
627
- question — omac's own facade socket under `~/.local/state/omac/run/`
628
- is the project's precedent for a sandbox process connecting to a
629
- host-owned socket through its profile.
630
- 2. Add the load-lock variables to the profile's `environment.allow_vars`:
631
-
632
- ```json
633
- {
634
- "environment": {
635
- "allow_vars": [
636
- "LOAD_LOCK_SOCKET",
637
- "LOAD_LOCK_STATE_DIR",
638
- "LOAD_LOCK_NO_SPAWN",
639
- "LOAD_LOCK_LABEL_PREFIX"
640
- ]
641
- }
642
- }
643
- ```
657
+ ### omac — live-verified
658
+
659
+ Verified 2026-10-20 against **omac 0.11.0** (extracted package binary, no
660
+ root needed) on Linux, driving the load-lock CLI from the v1.2.0 worktree
661
+ build end-to-end from inside `omac sandbox run`. omac wraps bubblewrap +
662
+ Landlock on Linux (seatbelt on macOS with the same env-stripping) and
663
+ **strips the harness environment**, allowing through only variables
664
+ listed in the user-global sandbox profile —
665
+ `~/.config/omac/sandbox-profiles/default.json`. The profile is the whole
666
+ recipe, and `load-lock install --harness omac` writes it for you
667
+ (merge-only; idempotent; `load-lock uninstall --harness omac` removes
668
+ exactly the entries it added):
669
+
670
+ ```json
671
+ {
672
+ "filesystem": {
673
+ "allow_unix_dir": ["<load-lock state dir>"],
674
+ "read": ["<installed package tree>"]
675
+ },
676
+ "environment": { "allow_vars": ["LOAD_LOCK_*"] }
677
+ }
678
+ ```
679
+
680
+ - `filesystem.allow_unix_dir` += the state dir: AF_UNIX connect to the
681
+ host daemon socket plus read/write on the state dir — this is the
682
+ sandbox→host bridge (omac has no separate "bind" step; the grant is the
683
+ bridge).
684
+ - `environment.allow_vars` += `"LOAD_LOCK_*"` — one trailing-`*` prefix
685
+ entry (verified: omac 0.11.0 accepts prefix patterns) covering all four
686
+ client vars (`LOAD_LOCK_SOCKET`, `LOAD_LOCK_STATE_DIR`,
687
+ `LOAD_LOCK_NO_SPAWN`, `LOAD_LOCK_LABEL_PREFIX`).
688
+ - `filesystem.read` += the installed package tree — **only when
689
+ load-lock lives outside an already-granted workdir** (see gotcha 1).
690
+
691
+ The exact procedure that was verified (run it with a throwaway `HOME`
692
+ unless you want the grants in your real profile):
693
+
694
+ ```sh
695
+ # 0. scratch dirs + a current daemon built from the v1.2.0+ tree.
696
+ # GOTCHA 2: the machine's real daemon was still v1.1.0 — a daemon
697
+ # started before v1.2.0 silently strips the unknown labelPrefix field
698
+ # (zod), so prefixed labels disappear without any error. Upgrade and
699
+ # restart: load-lock service stop && load-lock service start. This
700
+ # verification deliberately
701
+ # used its own daemon on a temp state dir.
702
+ LOAD_LOCK_STATE_DIR=/tmp/ll-32-test node dist/cli/main.js service start
703
+
704
+ # 1. install the grants (merge-only; prints exactly what it merged)
705
+ HOME=/tmp/ll-32-home LOAD_LOCK_STATE_DIR=/tmp/ll-32-test \
706
+ node dist/cli/main.js install --harness omac
707
+
708
+ # 2. omac's strict parser accepts the profile (unknown fields are errors)
709
+ HOME=/tmp/ll-32-home omac sandbox run -- true # exit 0, no ERROR lines
710
+
711
+ # 3. sandboxed client -> host daemon round-trip.
712
+ # NOTE: standalone `omac sandbox run` starts at cwd "/" and grants NO
713
+ # workdir by default — `--workdir-access read` binds the invocation
714
+ # dir read-only, modeling what a real omac agent session gets for its
715
+ # project workdir (which is why a repo checkout needs no read grant).
716
+ # The env values are set inside the sandboxed command with `env`
717
+ # (`-- env VAR=... node ...`); with the LOAD_LOCK_* allow_vars entry
718
+ # in place they could equally be inherited from the outer shell.
719
+ HOME=/tmp/ll-32-home omac sandbox run --workdir-access read -- \
720
+ env LOAD_LOCK_SOCKET=/tmp/ll-32-test/daemon.sock LOAD_LOCK_NO_SPAWN=1 \
721
+ LOAD_LOCK_LABEL_PREFIX=omac \
722
+ node dist/cli/main.js acquire --op-key issue-32-live \
723
+ --reason "issue-32 live verification build" --label "issue-32 build" --json
724
+ # -> permit prm_…; host status shows:
725
+ # held: omac:issue-32 build — issue-32 live verification build [claimed, awaiting start]
726
+
727
+ # 4. host-side release (or any later step from inside the sandbox):
728
+ node dist/cli/main.js release --permit prm_… # -> releasedNow: true
729
+ ```
644
730
 
645
- Set the values themselves in your harness env (or the profile's env
646
- mechanism): `LOAD_LOCK_SOCKET` to the host daemon socket as seen inside
647
- the sandbox, `LOAD_LOCK_NO_SPAWN=1`, and — recommended —
648
- `LOAD_LOCK_LABEL_PREFIX=omac` so the dashboard shows holders as
649
- `omac:<label>`.
650
- 3. Watch out: **omac never rewrites a profile it has created**, so if your
651
- `default.json` already exists, edit it by hand (adding the entries
652
- above); and **unknown profile fields are errors**, so don't invent
653
- other keys — `allow_vars` entries are plain variable names.
654
-
655
- Expected outcome (unverified here): with those four variables allowed and
656
- the state dir mounted, an agent inside omac behaves exactly like the
657
- verified bubblewrap case above — host daemon answers, labels carry the
658
- `omac:` prefix, and long holds need `mark_started` + heartbeats (lease
659
- mode) because of the PID namespace.
731
+ Also verified live:
732
+
733
+ - **`LOAD_LOCK_NO_SPAWN=1` error contract**: with the client pointed at a
734
+ missing socket inside the sandbox, the CLI exits **70** with the loud
735
+ "not reachable and auto-spawn is disabled … bridge its socket into this
736
+ sandbox" error instead of silently spawning a sandbox-local daemon.
737
+ - **Lease-mode lifecycle across the PID namespace** (same as the
738
+ bubblewrap case): `acquire` → `mark-started` (held, lease 900s) →
739
+ `release` (`releasedNow: true`), all issued from inside the sandbox;
740
+ long holds need lease mode because the host daemon pid is invisible to
741
+ the sandbox.
742
+ - **Install/uninstall round-trips against the real omac**: a populated
743
+ user profile (workdir/network/environment entries) survives install
744
+ byte-faithfully with our grants appended, omac's strict parser accepts
745
+ the merged file, and uninstall leaves the user entries intact; a
746
+ from-scratch profile is removed entirely on uninstall (with the omac
747
+ config tree pruned) and omac keeps running on its defaults.
748
+
749
+ The two gotchas that shaped the installer:
750
+
751
+ 1. **A global install needs a read grant for the package tree, not just
752
+ the bin dir.** omac's builtin grants cover `~/.bun/bin`, but the
753
+ launcher resolves the dist tree under
754
+ `~/.bun/install/global/node_modules/@aron98/load-lock/dist/…` —
755
+ ungranted, the CLI dies with `Cannot find module …` (exit 1) before
756
+ ever reaching the socket. Proven live both ways: the sandboxed CLI
757
+ from a global-style tree failed with exactly that error before the
758
+ grant, and completed the full acquire→mark-started→release round-trip
759
+ after `filesystem.read` covered the tree. A repo checkout needs no
760
+ read grant (the agent session's workdir already covers it) and the
761
+ installer says so honestly in its output.
762
+ 2. **Label prefixes need a current daemon on the host.** A daemon
763
+ started before v1.2.0 strips the unknown `labelPrefix` field silently
764
+ (zod); the dashboard shows the bare label with no error anywhere.
765
+ `load-lock service stop && load-lock service start` after upgrading.
766
+
767
+ What was NOT verified: omac's macOS seatbelt path (same profile schema,
768
+ per omac's docs, but the verification machine is Linux) and omac's agent
769
+ orchestration commands (`omac start`/`continue`/…) driving a real agent —
770
+ the round-trip was verified with the documented `omac sandbox run`
771
+ primitive those commands build on. The installer's contract tests cover
772
+ the merge/uninstall edge cases (`src/test/install.test.ts`).
660
773
 
661
774
  ### Docker — contract-inspected
662
775
 
@@ -730,8 +843,10 @@ path-literal strict, or re-generate the profile. Expected outcome
730
843
  `LOAD_LOCK_LABEL_PREFIX=…`) passed through to the agent, a seatbelt-sandboxed
731
844
  client talks to the host daemon exactly as in the verified bubblewrap
732
845
  case. omac's macOS sandboxing uses seatbelt under the hood with the same
733
- `environment.allow_vars` env-stripping as on Linux, so the omac recipe
734
- above (profile `allow_vars` + bridged socket path) applies unchanged.
846
+ `environment.allow_vars` env-stripping as on Linux, so the omac installer
847
+ (`load-lock install --harness omac`, live-verified on Linux against omac
848
+ 0.11.0 — see above) applies unchanged; only the socket path and the
849
+ seatbelt file rules differ.
735
850
 
736
851
  ### WSL2 — honest state
737
852
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aron98/load-lock",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Machine-wide counting semaphore coordinating resource-intensive work across parallel coding agents (OpenCode, Codex CLI, Claude Code, ...)",
5
5
  "type": "module",
6
6
  "license": "MIT",