@sagentlab/navarch-runtime 0.1.6 → 0.1.8
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/README.md +72 -24
- package/bin/worktree-guard-hook.cjs +5 -6
- package/dist/adapters/claude.cjs +13 -39
- package/dist/adapters/codex.cjs +5 -4
- package/dist/capacity.cjs +12 -0
- package/dist/claim-loop.cjs +22 -0
- package/dist/cli.cjs +46 -7
- package/dist/config.cjs +3 -0
- package/dist/git-worktree.cjs +30 -1
- package/dist/heartbeat-loop.cjs +29 -3
- package/dist/session.cjs +2 -0
- package/dist/supervisor.cjs +149 -0
- package/dist/update-coordinator.cjs +53 -0
- package/dist/update-installer.cjs +164 -0
- package/dist/version.cjs +9 -0
- package/dist/worktree-guard.cjs +6 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ git clone <this repo> && cd sagentlab/runtime
|
|
|
24
24
|
./install.sh # checks node, npm install, npm run build
|
|
25
25
|
export NAVARCH_API_BASE=https://navarch.example.com
|
|
26
26
|
node bin/navarch.cjs register --token <enrollment-token> --name my-machine-1
|
|
27
|
-
node bin/navarch.cjs
|
|
27
|
+
node bin/navarch.cjs supervise
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
Or, once dependencies are installed:
|
|
@@ -32,7 +32,7 @@ Or, once dependencies are installed:
|
|
|
32
32
|
```sh
|
|
33
33
|
npm run build
|
|
34
34
|
NAVARCH_API_BASE=https://navarch.example.com npm run register -- --token <enrollment-token> --name my-machine-1
|
|
35
|
-
npm
|
|
35
|
+
npm run supervise
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
`register` is the one command that prints the machine's auth token — exactly
|
|
@@ -61,7 +61,7 @@ project only (it will never be dispatched work from any other project).
|
|
|
61
61
|
```sh
|
|
62
62
|
npx @sagentlab/navarch-runtime connect --token flmt_<...> --project <project-id> \
|
|
63
63
|
--name my-agent-1 --agent codex --api-base https://navarch.example.com
|
|
64
|
-
|
|
64
|
+
npx @sagentlab/navarch-runtime supervise
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
(`@sagentlab/navarch-runtime` is published to npm, so `npx
|
|
@@ -76,6 +76,7 @@ The from-source flow — `git clone` + `./install.sh` + `node bin/navarch.cjs
|
|
|
76
76
|
| `register --token <t> --name <n> [--agent claude-code\|codex] […]` | Registers this machine, saves its local agent choice, and prints the token once. |
|
|
77
77
|
| `connect --token <t> --name <n> [--agent claude-code\|codex] [--project <id>] […]` | Connects this machine to one project, saves its local agent choice, and prints the token once. |
|
|
78
78
|
| `start [--agent claude-code\|codex]` | Runs the daemon. A start-time agent choice overrides the saved choice. |
|
|
79
|
+
| `supervise [--agent claude-code\|codex]` | Runs the daemon under the update supervisor, enabling drain-safe automatic updates and rollback. |
|
|
79
80
|
| `doctor` | Prints resolved config + Docker/registration status; no side effects. |
|
|
80
81
|
|
|
81
82
|
## Active-task guidance
|
|
@@ -102,13 +103,49 @@ The daemon logs `restarting the agent turn in the same worktree` when it
|
|
|
102
103
|
delivers guidance. Lowering the lease-heartbeat interval makes guidance arrive
|
|
103
104
|
sooner, but keep it comfortably below the 15-minute lease TTL.
|
|
104
105
|
|
|
105
|
-
##
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
106
|
+
## Automatic and manual runtime upgrades
|
|
107
|
+
|
|
108
|
+
Run long-lived agents with `navarch-runtime supervise`. The worker advertises
|
|
109
|
+
its package version and updater protocol on each machine heartbeat. When the
|
|
110
|
+
control plane assigns an eligible release, it:
|
|
111
|
+
|
|
112
|
+
1. Downloads the exact `@sagentlab/navarch-runtime@<version>` tarball with npm
|
|
113
|
+
lifecycle scripts disabled and verifies the control-plane-recorded SHA-512
|
|
114
|
+
integrity.
|
|
115
|
+
2. Stages it under `$NAVARCH_CONFIG_DIR/versions/<version>` while existing
|
|
116
|
+
sessions continue.
|
|
117
|
+
3. Stops claiming, reports zero available capacity, and waits for both an
|
|
118
|
+
in-flight claim and every active session to finish.
|
|
119
|
+
4. Atomically writes `pending-update.json` and exits with the supervisor-only
|
|
120
|
+
handoff code. The supervisor starts the staged binary with the same config
|
|
121
|
+
directory and machine identity.
|
|
122
|
+
5. Commits the activation after the replacement completes its first successful
|
|
123
|
+
heartbeat, persisting `active-runtime.json` so machine restarts keep using
|
|
124
|
+
the verified release. A crash or two-minute health timeout before then
|
|
125
|
+
restores the previous binary.
|
|
126
|
+
|
|
127
|
+
The control plane never supplies a package name, URL, path, or shell command:
|
|
128
|
+
only an exact semver and npm SHA-512 integrity for the fixed package name are
|
|
129
|
+
accepted. Rollouts are assigned by the machine's server-managed `stable` or
|
|
130
|
+
`canary` channel and deterministic rollout percentage. Set
|
|
131
|
+
`NAVARCH_AUTO_UPDATE=off` to keep telemetry but disable automatic activation.
|
|
132
|
+
|
|
133
|
+
Plain `navarch-runtime start` continues to work and reports available releases,
|
|
134
|
+
but deliberately does not activate them because no parent process would exist
|
|
135
|
+
to perform a safe handoff or rollback.
|
|
136
|
+
|
|
137
|
+
After publishing a runtime, an operator records the immutable npm metadata in
|
|
138
|
+
`runtime_releases`. Obtain the integrity with
|
|
139
|
+
`npm view @sagentlab/navarch-runtime@<version> dist.integrity`, then insert the
|
|
140
|
+
exact version, returned integrity, release channel, and desired rollout
|
|
141
|
+
percentage. Start with `channel='canary'` or a small `rollout_percent`; raising
|
|
142
|
+
the percentage keeps existing machines in the same deterministic cohort.
|
|
143
|
+
|
|
144
|
+
### Manual fallback
|
|
145
|
+
|
|
146
|
+
`SIGINT` or `SIGTERM` now stops new claims and drains active sessions before
|
|
147
|
+
exiting. A second signal forces an immediate exit and can interrupt work, so use
|
|
148
|
+
it only when abandoning the active leases is intentional.
|
|
112
149
|
|
|
113
150
|
Use this sequence for an upgrade:
|
|
114
151
|
|
|
@@ -158,8 +195,10 @@ unchanged across the deployment.
|
|
|
158
195
|
| `NAVARCH_SANDBOX_MODE` | `host` | `host` uses the resources already available to the agent process. Set `docker` explicitly for container isolation. |
|
|
159
196
|
| `NAVARCH_DOCKER_IMAGE` | `node:20-slim` | Image used for the per-session container. |
|
|
160
197
|
| `NAVARCH_AGENT` | saved choice, then `claude-code` | Local choice of agent CLI: `claude-code` or `codex`. Overrides the choice saved by `connect`/`register`; `start --agent` has highest priority. |
|
|
198
|
+
| `NAVARCH_UPDATE_CHANNEL` | `stable` | Release channel advertised by the worker (`stable` or `canary`); the server-managed machine channel remains authoritative. |
|
|
199
|
+
| `NAVARCH_AUTO_UPDATE` | on under `supervise` | Set `off`, `false`, or `0` to report releases without staging or activating them. Automatic activation is always off under plain `start`. |
|
|
161
200
|
| `NAVARCH_CLAUDE_BIN` | `claude` | Path/name of the Claude Code CLI binary. |
|
|
162
|
-
| `NAVARCH_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code).
|
|
201
|
+
| `NAVARCH_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). `--allowedTools`/`--disallowedTools` layer rules onto auto mode; an explicit `--permission-mode`, `--permission-prompt-tool`, or bypass flag replaces the unattended default `--permission-mode auto`. |
|
|
163
202
|
| `NAVARCH_CODEX_BIN` | `codex` | Path/name of the Codex CLI binary. |
|
|
164
203
|
| `NAVARCH_CODEX_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the generated MCP `-c` overrides and `--json` (Codex). |
|
|
165
204
|
| `NAVARCH_MCP_CONFIG_PATH` | — | Path to the platform MCP config passed as `--mcp-config`. |
|
|
@@ -172,13 +211,16 @@ A machine typically runs several sessions concurrently (`NAVARCH_MAX_SESSIONS`),
|
|
|
172
211
|
each in its own git worktree. The runtime enforces the same boundary for both
|
|
173
212
|
supported coding agents, using each CLI's native enforcement point:
|
|
174
213
|
|
|
175
|
-
- **Claude Code:**
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
`
|
|
214
|
+
- **Claude Code:** the runtime uses `--permission-mode auto`, which sends
|
|
215
|
+
approval decisions through Claude Code's background safety classifier. It
|
|
216
|
+
does not blanket-preapprove the lease-scoped Navarch MCP tools. A generated
|
|
217
|
+
settings file (`src/worktree-guard.cts`, passed as `--settings`) also installs
|
|
218
|
+
`bin/worktree-guard-hook.cjs` as a fail-closed `PreToolUse` boundary hook.
|
|
179
219
|
- **Codex:** the runtime passes a one-off native permission profile with
|
|
180
|
-
|
|
181
|
-
|
|
220
|
+
`approval_policy="on-request"` and `approvals_reviewer="auto_review"`.
|
|
221
|
+
Codex's OS sandbox grants read/write access only to the allowed roots and
|
|
222
|
+
denies the surrounding multi-session workspace, while eligible escalations
|
|
223
|
+
are decided by the automatic reviewer rather than waiting for human input.
|
|
182
224
|
`--ignore-user-config` and an untrusted project-config override prevent a
|
|
183
225
|
user or checked-in legacy `sandbox_mode` from silently disabling the
|
|
184
226
|
generated profile; Codex authentication still comes from `CODEX_HOME`, and
|
|
@@ -207,12 +249,14 @@ An operator-supplied `--settings` in `NAVARCH_CLAUDE_EXTRA_ARGS`, or an
|
|
|
207
249
|
explicit Codex permission/sandbox option in `NAVARCH_CODEX_EXTRA_ARGS`, takes
|
|
208
250
|
precedence over the generated policy.
|
|
209
251
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
252
|
+
Claude auto mode requires Claude Code 2.1.83 or later and an eligible account,
|
|
253
|
+
model, and first-party Anthropic API provider. If those requirements are not
|
|
254
|
+
met, Claude Code rejects auto mode instead of silently bypassing checks.
|
|
255
|
+
|
|
256
|
+
The auto-review launch paths were verified live on 2026-07-20 with Claude Code
|
|
257
|
+
2.1.214 (`--permission-mode auto`) and Codex CLI 0.144.1 (the generated
|
|
258
|
+
permission profile plus `approvals_reviewer="auto_review"`). Both completed an
|
|
259
|
+
unattended smoke task successfully.
|
|
216
260
|
|
|
217
261
|
## Choosing an agent (Claude Code vs. Codex)
|
|
218
262
|
|
|
@@ -263,7 +307,7 @@ envelope as a compatibility fallback.
|
|
|
263
307
|
cli.cts
|
|
264
308
|
├─ register → api.registerMachine() → machine-store.cts (writes machine.json once)
|
|
265
309
|
├─ connect → api.connectMachine() → machine-store.cts (writes machine.json once)
|
|
266
|
-
|
|
310
|
+
├─ start
|
|
267
311
|
├─ MachineHeartbeatLoop (heartbeat-loop.cts) → api.machineHeartbeat() [every NAVARCH_HEARTBEAT_INTERVAL_MS]
|
|
268
312
|
└─ ClaimLoop (claim-loop.cts) → api.claim() [every NAVARCH_POLL_INTERVAL_MS, gated by CapacityTracker]
|
|
269
313
|
└─ runSession (session.cts), one per claimed lease, run concurrently up to NAVARCH_MAX_SESSIONS:
|
|
@@ -282,6 +326,10 @@ cli.cts
|
|
|
282
326
|
6. api.completeLease(), reporting agent_type: config.agentType
|
|
283
327
|
7. sandbox.wipe() when present; remove the session workspace
|
|
284
328
|
unconditionally (finally block)
|
|
329
|
+
└─ supervise → supervisor.cts starts the worker with IPC
|
|
330
|
+
└─ update directive → update-installer.cts stages exact npm release
|
|
331
|
+
└─ drain + exit 75 → supervisor activates candidate, waits for
|
|
332
|
+
healthy heartbeat, or rolls back
|
|
285
333
|
```
|
|
286
334
|
|
|
287
335
|
`adapter.cts` (top-level) is now a backward-compat re-export of
|
|
@@ -8,12 +8,11 @@
|
|
|
8
8
|
* Multiple sessions run concurrently on one machine (claim-loop.cts), each
|
|
9
9
|
* confined by convention to its own git worktree
|
|
10
10
|
* (`<workspaceRoot>/sessions/<sessionId>/repo` — git-worktree.cts). The
|
|
11
|
-
* adapter launches Claude
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* unrelated to its task.
|
|
11
|
+
* adapter launches Claude in auto permission mode (adapters/claude.cts).
|
|
12
|
+
* Claude Code runs PreToolUse hooks in every permission mode, so this script
|
|
13
|
+
* is the enforcement point that keeps an agent from reading or writing
|
|
14
|
+
* another session's worktree, the operator's home directory, or anything
|
|
15
|
+
* else unrelated to its task.
|
|
17
16
|
*
|
|
18
17
|
* Protocol (Claude Code hooks): the hook receives {tool_name, tool_input,
|
|
19
18
|
* cwd, ...} as JSON on stdin. Exit 0 allows the tool call; exit 2 blocks it
|
package/dist/adapters/claude.cjs
CHANGED
|
@@ -4,17 +4,6 @@ exports.claudeCodeAdapter = void 0;
|
|
|
4
4
|
exports.runClaudeCodeAdapter = runClaudeCodeAdapter;
|
|
5
5
|
const node_child_process_1 = require("node:child_process");
|
|
6
6
|
const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
|
|
7
|
-
const NAVARCH_MCP_ALLOWED_TOOLS = [
|
|
8
|
-
"mcp__navarch__task_comment",
|
|
9
|
-
"mcp__navarch__record_decision",
|
|
10
|
-
"mcp__navarch__request_approval",
|
|
11
|
-
"mcp__navarch__register_resource",
|
|
12
|
-
"mcp__navarch__create_task",
|
|
13
|
-
"mcp__navarch__list_triage_tasks",
|
|
14
|
-
"mcp__navarch__groom_triage_task",
|
|
15
|
-
"mcp__navarch__defer_triage_task",
|
|
16
|
-
"mcp__navarch__file_pr_review",
|
|
17
|
-
];
|
|
18
7
|
/**
|
|
19
8
|
* Headless Claude Code adapter (project-plan.md §3.9 / implementation-plan.md
|
|
20
9
|
* WP-07): `claude -p "<context bundle>" --mcp-config platform-mcp.json
|
|
@@ -37,28 +26,18 @@ const NAVARCH_MCP_ALLOWED_TOOLS = [
|
|
|
37
26
|
*/
|
|
38
27
|
async function runClaudeCodeAdapter(options) {
|
|
39
28
|
const args = ["-p", options.prompt];
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"--permission-
|
|
47
|
-
"--dangerously-skip-permissions",
|
|
48
|
-
].includes(arg));
|
|
49
|
-
// Navarch sessions are unattended: a permission prompt can never be
|
|
50
|
-
// answered and leaves Claude unable to edit the task's isolated worktree.
|
|
51
|
-
// The runtime owns that worktree (and, in Docker mode, the surrounding
|
|
52
|
-
// container), so make the non-interactive session capable of completing
|
|
53
|
-
// coding tasks by default. Operators can replace this with a narrower
|
|
54
|
-
// Claude permission policy through NAVARCH_CLAUDE_EXTRA_ARGS.
|
|
55
|
-
if (!hasExplicitPermissionPolicy) {
|
|
56
|
-
args.push("--dangerously-skip-permissions");
|
|
29
|
+
const hasExplicitPermissionMode = options.extraArgs.some((arg) => ["--permission-mode", "--permission-prompt-tool", "--dangerously-skip-permissions"].some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
|
|
30
|
+
// Navarch sessions are unattended, so route permission decisions through
|
|
31
|
+
// Claude Code's native auto-mode classifier instead of prompting a human or
|
|
32
|
+
// bypassing checks. Operators can replace this with a different permission
|
|
33
|
+
// policy through NAVARCH_CLAUDE_EXTRA_ARGS.
|
|
34
|
+
if (!hasExplicitPermissionMode) {
|
|
35
|
+
args.push("--permission-mode", "auto");
|
|
57
36
|
}
|
|
58
37
|
// The worktree-guard settings file (worktree-guard.cts) installs the
|
|
59
|
-
// PreToolUse boundary hook. Hooks run
|
|
60
|
-
//
|
|
61
|
-
//
|
|
38
|
+
// PreToolUse boundary hook. Hooks run in auto mode, so this remains a
|
|
39
|
+
// fail-closed boundary around each worktree on a machine running several
|
|
40
|
+
// agents. An operator-supplied --settings (via
|
|
62
41
|
// NAVARCH_CLAUDE_EXTRA_ARGS) wins — two --settings flags on one invocation
|
|
63
42
|
// would be ambiguous, and session.cts logs the guard as skipped.
|
|
64
43
|
if (options.settingsPath && !options.extraArgs.includes("--settings")) {
|
|
@@ -66,14 +45,9 @@ async function runClaudeCodeAdapter(options) {
|
|
|
66
45
|
}
|
|
67
46
|
if (options.mcpConfigPath) {
|
|
68
47
|
args.push("--mcp-config", options.mcpConfigPath);
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// that still inspect allowedTools while permission bypass is active.
|
|
73
|
-
if (!options.extraArgs.includes("--allowedTools") &&
|
|
74
|
-
!options.extraArgs.includes("--allowed-tools")) {
|
|
75
|
-
args.push("--allowedTools", NAVARCH_MCP_ALLOWED_TOOLS.join(","));
|
|
76
|
-
}
|
|
48
|
+
// Do not blanket-preapprove Navarch MCP tools: auto mode should review
|
|
49
|
+
// their side effects too. Operators can still add an explicit
|
|
50
|
+
// --allowedTools policy through NAVARCH_CLAUDE_EXTRA_ARGS.
|
|
77
51
|
}
|
|
78
52
|
// Only append the default when the caller hasn't already asked for a
|
|
79
53
|
// specific --output-format (extraArgs wins so an operator can opt back
|
package/dist/adapters/codex.cjs
CHANGED
|
@@ -32,10 +32,11 @@ const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
|
|
|
32
32
|
* variables so machine/lease credentials never appear in argv.
|
|
33
33
|
* - Host-mode sessions use a generated native Codex permission profile that
|
|
34
34
|
* denies reads and writes outside the session worktree, shared Git dir,
|
|
35
|
-
* temp dirs, and operator-approved extra roots.
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
35
|
+
* temp dirs, and operator-approved extra roots. Interactive approval
|
|
36
|
+
* requests use `approval_policy="on-request"` with the native automatic
|
|
37
|
+
* reviewer, so unattended sessions retain safety review without waiting for
|
|
38
|
+
* human input. Explicit permission-policy arguments in
|
|
39
|
+
* NAVARCH_CODEX_EXTRA_ARGS replace that generated profile.
|
|
39
40
|
* - `NAVARCH_CODEX_EXTRA_ARGS` (`extraArgs`) wins over the default `--json`
|
|
40
41
|
* exactly like the Claude adapter's `--output-format` opt-out, so an
|
|
41
42
|
* operator can fall back to plain-text output (or add the real
|
package/dist/capacity.cjs
CHANGED
|
@@ -14,6 +14,7 @@ function computeAvailableCapacity(maxSessions, activeSessions) {
|
|
|
14
14
|
class CapacityTracker {
|
|
15
15
|
maxSessions;
|
|
16
16
|
active = new Set();
|
|
17
|
+
idleWaiters = new Set();
|
|
17
18
|
constructor(maxSessions) {
|
|
18
19
|
this.maxSessions = maxSessions;
|
|
19
20
|
}
|
|
@@ -36,6 +37,17 @@ class CapacityTracker {
|
|
|
36
37
|
}
|
|
37
38
|
release(sessionId) {
|
|
38
39
|
this.active.delete(sessionId);
|
|
40
|
+
if (this.active.size === 0) {
|
|
41
|
+
for (const resolve of this.idleWaiters)
|
|
42
|
+
resolve();
|
|
43
|
+
this.idleWaiters.clear();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Resolves once every acquired lease has finished and released its slot. */
|
|
47
|
+
waitForIdle() {
|
|
48
|
+
if (this.active.size === 0)
|
|
49
|
+
return Promise.resolve();
|
|
50
|
+
return new Promise((resolve) => this.idleWaiters.add(resolve));
|
|
39
51
|
}
|
|
40
52
|
}
|
|
41
53
|
exports.CapacityTracker = CapacityTracker;
|
package/dist/claim-loop.cjs
CHANGED
|
@@ -20,6 +20,7 @@ class ClaimLoop {
|
|
|
20
20
|
timer = null;
|
|
21
21
|
stopped = false;
|
|
22
22
|
claimInFlight = false;
|
|
23
|
+
quiescenceWaiters = new Set();
|
|
23
24
|
constructor(api, config, capacity, runSession) {
|
|
24
25
|
this.api = api;
|
|
25
26
|
this.config = config;
|
|
@@ -29,6 +30,7 @@ class ClaimLoop {
|
|
|
29
30
|
start() {
|
|
30
31
|
if (this.timer)
|
|
31
32
|
return;
|
|
33
|
+
this.stopped = false;
|
|
32
34
|
this.timer = setInterval(() => void this.tick(), this.config.pollIntervalMs);
|
|
33
35
|
}
|
|
34
36
|
stop() {
|
|
@@ -36,6 +38,18 @@ class ClaimLoop {
|
|
|
36
38
|
if (this.timer)
|
|
37
39
|
clearInterval(this.timer);
|
|
38
40
|
this.timer = null;
|
|
41
|
+
this.resolveQuiescenceWaiters();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Stops new polls and waits for a claim request already on the wire to
|
|
45
|
+
* settle. If that request returns a lease, the session is still run; callers
|
|
46
|
+
* must then wait for CapacityTracker.waitForIdle() before restarting.
|
|
47
|
+
*/
|
|
48
|
+
async drain() {
|
|
49
|
+
this.stop();
|
|
50
|
+
if (!this.claimInFlight)
|
|
51
|
+
return;
|
|
52
|
+
await new Promise((resolve) => this.quiescenceWaiters.add(resolve));
|
|
39
53
|
}
|
|
40
54
|
async tick() {
|
|
41
55
|
if (this.stopped || this.claimInFlight || !this.capacity.hasCapacity())
|
|
@@ -75,7 +89,15 @@ class ClaimLoop {
|
|
|
75
89
|
}
|
|
76
90
|
finally {
|
|
77
91
|
this.claimInFlight = false;
|
|
92
|
+
this.resolveQuiescenceWaiters();
|
|
78
93
|
}
|
|
79
94
|
}
|
|
95
|
+
resolveQuiescenceWaiters() {
|
|
96
|
+
if (this.claimInFlight)
|
|
97
|
+
return;
|
|
98
|
+
for (const resolve of this.quiescenceWaiters)
|
|
99
|
+
resolve();
|
|
100
|
+
this.quiescenceWaiters.clear();
|
|
101
|
+
}
|
|
80
102
|
}
|
|
81
103
|
exports.ClaimLoop = ClaimLoop;
|
package/dist/cli.cjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.main = main;
|
|
4
4
|
const config_cjs_1 = require("./config.cjs");
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
6
|
const machine_store_cjs_1 = require("./machine-store.cjs");
|
|
6
7
|
const api_cjs_1 = require("./api.cjs");
|
|
7
8
|
const capacity_cjs_1 = require("./capacity.cjs");
|
|
@@ -10,6 +11,8 @@ const claim_loop_cjs_1 = require("./claim-loop.cjs");
|
|
|
10
11
|
const session_cjs_1 = require("./session.cjs");
|
|
11
12
|
const sandbox_cjs_1 = require("./sandbox.cjs");
|
|
12
13
|
const logger_cjs_1 = require("./logger.cjs");
|
|
14
|
+
const update_coordinator_cjs_1 = require("./update-coordinator.cjs");
|
|
15
|
+
const supervisor_cjs_1 = require("./supervisor.cjs");
|
|
13
16
|
const log = (0, logger_cjs_1.createLogger)("cli");
|
|
14
17
|
const PACKAGE_NAME = "@sagentlab/navarch-runtime";
|
|
15
18
|
/**
|
|
@@ -99,7 +102,7 @@ async function registerCommand(flags) {
|
|
|
99
102
|
console.log("Machine registered.");
|
|
100
103
|
console.log(` machine_id: ${result.machine_id}`);
|
|
101
104
|
console.log(` token: ${result.token}`);
|
|
102
|
-
console.log(`\nStored in ${config.configDir}/machine.json (mode 0600). Run \`${invocation("
|
|
105
|
+
console.log(`\nStored in ${config.configDir}/machine.json (mode 0600). Run \`${invocation("supervise")}\` to begin serving tasks.`);
|
|
103
106
|
}
|
|
104
107
|
/**
|
|
105
108
|
* `navarch-runtime connect` — "Connect an agent to a project"
|
|
@@ -147,7 +150,7 @@ async function connectCommand(flags) {
|
|
|
147
150
|
console.log("Machine connected.");
|
|
148
151
|
console.log(` machine_id: ${result.machine_id}`);
|
|
149
152
|
console.log(` token: ${result.token}`);
|
|
150
|
-
console.log(`\nStored in ${config.configDir}/machine.json (mode 0600). Run \`${invocation("
|
|
153
|
+
console.log(`\nStored in ${config.configDir}/machine.json (mode 0600). Run \`${invocation("supervise")}\` to begin serving tasks.`);
|
|
151
154
|
}
|
|
152
155
|
async function startCommand(flags) {
|
|
153
156
|
const baseConfig = (0, config_cjs_1.loadRuntimeConfig)();
|
|
@@ -160,20 +163,52 @@ async function startCommand(flags) {
|
|
|
160
163
|
const config = { ...baseConfig, agentType };
|
|
161
164
|
const api = new api_cjs_1.NavarchApiClient({ baseUrl: identity.api_base, token: identity.token });
|
|
162
165
|
const capacity = new capacity_cjs_1.CapacityTracker(config.maxSessions);
|
|
163
|
-
const heartbeat = new heartbeat_loop_cjs_1.MachineHeartbeatLoop(api, identity.machine_id, config, capacity);
|
|
164
166
|
const claimLoop = new claim_loop_cjs_1.ClaimLoop(api, config, capacity, (claimed, sessionId) => (0, session_cjs_1.runSession)({ api, config }, claimed, sessionId));
|
|
167
|
+
const bootId = (0, node_crypto_1.randomUUID)();
|
|
168
|
+
const updateCoordinatorRef = {};
|
|
169
|
+
let readySent = false;
|
|
170
|
+
const heartbeat = new heartbeat_loop_cjs_1.MachineHeartbeatLoop(api, identity.machine_id, config, capacity, bootId, (result) => updateCoordinatorRef.current?.consider(result.update), () => {
|
|
171
|
+
if (!readySent && process.send) {
|
|
172
|
+
readySent = true;
|
|
173
|
+
process.send({ type: "navarch-ready", boot_id: bootId });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
updateCoordinatorRef.current = new update_coordinator_cjs_1.RuntimeUpdateCoordinator({
|
|
177
|
+
config,
|
|
178
|
+
claimLoop,
|
|
179
|
+
capacity,
|
|
180
|
+
heartbeat,
|
|
181
|
+
});
|
|
165
182
|
heartbeat.start();
|
|
166
183
|
claimLoop.start();
|
|
167
184
|
log.info(`navarch-runtime started: machine=${identity.name} agent=${config.agentType} max_sessions=${config.maxSessions} api_base=${identity.api_base}`);
|
|
185
|
+
let shuttingDown = false;
|
|
168
186
|
const shutdown = () => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
187
|
+
if (shuttingDown) {
|
|
188
|
+
log.warn("second shutdown signal received; forcing exit with active work");
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
shuttingDown = true;
|
|
192
|
+
void (async () => {
|
|
193
|
+
log.info("shutting down: draining active sessions...");
|
|
194
|
+
heartbeat.setUpdateState("draining");
|
|
195
|
+
await claimLoop.drain();
|
|
196
|
+
await capacity.waitForIdle();
|
|
197
|
+
heartbeat.stop();
|
|
198
|
+
log.info("shutdown drain complete");
|
|
199
|
+
process.exit(0);
|
|
200
|
+
})();
|
|
173
201
|
};
|
|
174
202
|
process.on("SIGINT", shutdown);
|
|
175
203
|
process.on("SIGTERM", shutdown);
|
|
176
204
|
}
|
|
205
|
+
async function superviseCommand(flags) {
|
|
206
|
+
const config = (0, config_cjs_1.loadRuntimeConfig)();
|
|
207
|
+
const agentType = agentFromFlag(flags);
|
|
208
|
+
const workerArgs = agentType ? ["--agent", agentType] : [];
|
|
209
|
+
const exitCode = await (0, supervisor_cjs_1.superviseRuntime)(config.configDir, process.argv[1] ?? "", workerArgs);
|
|
210
|
+
process.exitCode = exitCode;
|
|
211
|
+
}
|
|
177
212
|
async function doctorCommand(flags) {
|
|
178
213
|
const config = (0, config_cjs_1.loadRuntimeConfig)();
|
|
179
214
|
const dockerOk = await (0, sandbox_cjs_1.isDockerAvailable)();
|
|
@@ -206,6 +241,7 @@ Usage:
|
|
|
206
241
|
navarch-runtime connect --token <enrollment-token> --name <machine-name> \\
|
|
207
242
|
[--agent claude-code|codex] [--project <project-id>] [--capabilities a,b] [--max-sessions N] [--api-base url]
|
|
208
243
|
navarch-runtime start [--agent claude-code|codex]
|
|
244
|
+
navarch-runtime supervise [--agent claude-code|codex]
|
|
209
245
|
navarch-runtime doctor
|
|
210
246
|
|
|
211
247
|
Configuration is via NAVARCH_* environment variables; see runtime/README.md.
|
|
@@ -224,6 +260,9 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
224
260
|
case "start":
|
|
225
261
|
await startCommand(flags);
|
|
226
262
|
break;
|
|
263
|
+
case "supervise":
|
|
264
|
+
await superviseCommand(flags);
|
|
265
|
+
break;
|
|
227
266
|
case "doctor":
|
|
228
267
|
await doctorCommand(flags);
|
|
229
268
|
break;
|
package/dist/config.cjs
CHANGED
|
@@ -67,5 +67,8 @@ function loadRuntimeConfig(env = process.env) {
|
|
|
67
67
|
.split(node_path_1.default.delimiter)
|
|
68
68
|
.map((s) => s.trim())
|
|
69
69
|
.filter(Boolean),
|
|
70
|
+
updateChannel: env.NAVARCH_UPDATE_CHANNEL === "canary" ? "canary" : "stable",
|
|
71
|
+
autoUpdate: env.NAVARCH_SUPERVISED === "1" &&
|
|
72
|
+
!["off", "false", "0"].includes(env.NAVARCH_AUTO_UPDATE ?? ""),
|
|
70
73
|
};
|
|
71
74
|
}
|
package/dist/git-worktree.cjs
CHANGED
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.GitWorktree = void 0;
|
|
7
|
+
exports.branchSlug = branchSlug;
|
|
7
8
|
const node_path_1 = __importDefault(require("node:path"));
|
|
8
9
|
const node_fs_1 = require("node:fs");
|
|
9
10
|
const sandbox_cjs_1 = require("./sandbox.cjs");
|
|
@@ -28,7 +29,7 @@ class GitWorktree {
|
|
|
28
29
|
this.sessionRoot = node_path_1.default.join(options.workspaceRoot, "sessions", sessionKey);
|
|
29
30
|
this.worktreePath = node_path_1.default.join(this.sessionRoot, "repo");
|
|
30
31
|
this.repositoryPath = node_path_1.default.join(options.workspaceRoot, "repositories", `${projectKey}.git`);
|
|
31
|
-
this.branch = `navarch/${
|
|
32
|
+
this.branch = `navarch/${branchSlug(options)}-${sessionKey.toLowerCase().slice(0, 8)}`;
|
|
32
33
|
this.runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
|
|
33
34
|
this.cloneUrl = options.cloneUrl;
|
|
34
35
|
this.githubToken = options.githubToken;
|
|
@@ -87,6 +88,8 @@ class GitWorktree {
|
|
|
87
88
|
async runGit(args, authenticated) {
|
|
88
89
|
const credentialArgs = authenticated && this.githubToken
|
|
89
90
|
? [
|
|
91
|
+
"-c",
|
|
92
|
+
"credential.helper=",
|
|
90
93
|
"-c",
|
|
91
94
|
'credential.helper=!f() { echo username=x-access-token; echo "password=$GITHUB_TOKEN"; }; f',
|
|
92
95
|
]
|
|
@@ -130,6 +133,32 @@ async function pathExists(value) {
|
|
|
130
133
|
return false;
|
|
131
134
|
}
|
|
132
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Kebab-case slug for the session branch, derived from the task summary so
|
|
138
|
+
* branch names read like `navarch/fix-login-redirect-a1b2c3d4` instead of a
|
|
139
|
+
* UUID mash. Falls back to the task type, then a task-id prefix, when the
|
|
140
|
+
* summary yields nothing. Output contains only [a-z0-9-] with no leading or
|
|
141
|
+
* trailing dash, so it is always a valid git ref component (no `..`, no
|
|
142
|
+
* trailing `.lock`).
|
|
143
|
+
*/
|
|
144
|
+
function branchSlug(options) {
|
|
145
|
+
for (const candidate of [options.taskSummary, options.taskType]) {
|
|
146
|
+
const slug = (candidate ?? "")
|
|
147
|
+
.toLowerCase()
|
|
148
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
149
|
+
.replace(/^-+/, "")
|
|
150
|
+
.slice(0, 40)
|
|
151
|
+
.replace(/-+$/, "");
|
|
152
|
+
if (slug)
|
|
153
|
+
return slug;
|
|
154
|
+
}
|
|
155
|
+
const idSlug = safePathSegment(options.taskId)
|
|
156
|
+
.toLowerCase()
|
|
157
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
158
|
+
.slice(0, 8)
|
|
159
|
+
.replace(/^-+|-+$/g, "");
|
|
160
|
+
return idSlug || "task";
|
|
161
|
+
}
|
|
133
162
|
function safePathSegment(value) {
|
|
134
163
|
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "-").replace(/^-+|-+$/g, "");
|
|
135
164
|
if (!safe)
|
package/dist/heartbeat-loop.cjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.MachineHeartbeatLoop = void 0;
|
|
4
|
+
const version_cjs_1 = require("./version.cjs");
|
|
4
5
|
const logger_cjs_1 = require("./logger.cjs");
|
|
5
6
|
const log = (0, logger_cjs_1.createLogger)("heartbeat");
|
|
6
7
|
/**
|
|
@@ -14,12 +15,21 @@ class MachineHeartbeatLoop {
|
|
|
14
15
|
machineId;
|
|
15
16
|
config;
|
|
16
17
|
capacity;
|
|
18
|
+
bootId;
|
|
19
|
+
onResult;
|
|
20
|
+
onHealthy;
|
|
17
21
|
timer = null;
|
|
18
|
-
|
|
22
|
+
updateState = "idle";
|
|
23
|
+
lastUpdateError;
|
|
24
|
+
draining = false;
|
|
25
|
+
constructor(api, machineId, config, capacity, bootId, onResult, onHealthy) {
|
|
19
26
|
this.api = api;
|
|
20
27
|
this.machineId = machineId;
|
|
21
28
|
this.config = config;
|
|
22
29
|
this.capacity = capacity;
|
|
30
|
+
this.bootId = bootId;
|
|
31
|
+
this.onResult = onResult;
|
|
32
|
+
this.onHealthy = onHealthy;
|
|
23
33
|
}
|
|
24
34
|
start() {
|
|
25
35
|
if (this.timer)
|
|
@@ -32,12 +42,28 @@ class MachineHeartbeatLoop {
|
|
|
32
42
|
clearInterval(this.timer);
|
|
33
43
|
this.timer = null;
|
|
34
44
|
}
|
|
45
|
+
setUpdateState(state, error) {
|
|
46
|
+
this.updateState = state;
|
|
47
|
+
this.lastUpdateError = error;
|
|
48
|
+
this.draining = state === "draining";
|
|
49
|
+
void this.tick();
|
|
50
|
+
}
|
|
35
51
|
async tick() {
|
|
36
52
|
try {
|
|
37
|
-
await this.api.machineHeartbeat(this.machineId, {
|
|
38
|
-
available_capacity: this.capacity.available(),
|
|
53
|
+
const result = await this.api.machineHeartbeat(this.machineId, {
|
|
54
|
+
available_capacity: this.draining ? 0 : this.capacity.available(),
|
|
39
55
|
capabilities: this.config.capabilities,
|
|
56
|
+
runtime: {
|
|
57
|
+
version: version_cjs_1.RUNTIME_VERSION,
|
|
58
|
+
updater_protocol: version_cjs_1.UPDATER_PROTOCOL_VERSION,
|
|
59
|
+
channel: this.config.updateChannel,
|
|
60
|
+
state: this.updateState,
|
|
61
|
+
boot_id: this.bootId,
|
|
62
|
+
...(this.lastUpdateError ? { last_update_error: this.lastUpdateError } : {}),
|
|
63
|
+
},
|
|
40
64
|
});
|
|
65
|
+
this.onHealthy?.();
|
|
66
|
+
this.onResult?.(result);
|
|
41
67
|
}
|
|
42
68
|
catch (err) {
|
|
43
69
|
log.warn(`machine heartbeat failed: ${String(err)}`);
|
package/dist/session.cjs
CHANGED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.superviseRuntime = superviseRuntime;
|
|
4
|
+
const node_child_process_1 = require("node:child_process");
|
|
5
|
+
const logger_cjs_1 = require("./logger.cjs");
|
|
6
|
+
const update_installer_cjs_1 = require("./update-installer.cjs");
|
|
7
|
+
const log = (0, logger_cjs_1.createLogger)("supervisor");
|
|
8
|
+
const UPDATE_RESTART_EXIT_CODE = 75;
|
|
9
|
+
function runWorker(binPath, args, healthTimeoutMs, setCurrentChild, onHealthy) {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
const child = (0, node_child_process_1.spawn)(process.execPath, [binPath, "start", ...args], {
|
|
12
|
+
env: { ...process.env, NAVARCH_SUPERVISED: "1" },
|
|
13
|
+
stdio: ["inherit", "inherit", "inherit", "ipc"],
|
|
14
|
+
});
|
|
15
|
+
setCurrentChild(child);
|
|
16
|
+
let healthy = false;
|
|
17
|
+
let healthSignalReceived = false;
|
|
18
|
+
let healthTimedOut = false;
|
|
19
|
+
let healthCommit = Promise.resolve();
|
|
20
|
+
let healthTimer = null;
|
|
21
|
+
let forceKillTimer = null;
|
|
22
|
+
let settled = false;
|
|
23
|
+
const finish = (code) => {
|
|
24
|
+
if (settled)
|
|
25
|
+
return;
|
|
26
|
+
settled = true;
|
|
27
|
+
if (healthTimer)
|
|
28
|
+
clearTimeout(healthTimer);
|
|
29
|
+
if (forceKillTimer)
|
|
30
|
+
clearTimeout(forceKillTimer);
|
|
31
|
+
setCurrentChild(null);
|
|
32
|
+
void healthCommit.finally(() => resolve({ code, healthy }));
|
|
33
|
+
};
|
|
34
|
+
if (healthTimeoutMs !== null) {
|
|
35
|
+
healthTimer = setTimeout(() => {
|
|
36
|
+
if (!healthy) {
|
|
37
|
+
healthTimedOut = true;
|
|
38
|
+
log.error("updated runtime missed its startup health deadline; rolling back");
|
|
39
|
+
child.kill("SIGTERM");
|
|
40
|
+
forceKillTimer = setTimeout(() => child.kill("SIGKILL"), 10_000);
|
|
41
|
+
}
|
|
42
|
+
}, healthTimeoutMs);
|
|
43
|
+
}
|
|
44
|
+
child.on("message", (message) => {
|
|
45
|
+
if (!healthSignalReceived &&
|
|
46
|
+
!healthTimedOut &&
|
|
47
|
+
typeof message === "object" &&
|
|
48
|
+
message !== null &&
|
|
49
|
+
message.type === "navarch-ready") {
|
|
50
|
+
healthSignalReceived = true;
|
|
51
|
+
healthCommit = onHealthy()
|
|
52
|
+
.then(() => {
|
|
53
|
+
healthy = true;
|
|
54
|
+
if (healthTimer)
|
|
55
|
+
clearTimeout(healthTimer);
|
|
56
|
+
if (forceKillTimer)
|
|
57
|
+
clearTimeout(forceKillTimer);
|
|
58
|
+
})
|
|
59
|
+
.catch((error) => {
|
|
60
|
+
log.error(`could not commit runtime activation: ${String(error)}`);
|
|
61
|
+
child.kill("SIGTERM");
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
child.once("error", (error) => {
|
|
66
|
+
log.error(`could not start runtime worker: ${String(error)}`);
|
|
67
|
+
finish(1);
|
|
68
|
+
});
|
|
69
|
+
child.once("exit", (code) => finish(code ?? 1));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** Runs the worker and owns update activation, health checking, and rollback. */
|
|
73
|
+
async function superviseRuntime(configDir, initialBinPath, workerArgs = [], healthTimeoutMs = 120_000) {
|
|
74
|
+
let currentBin = initialBinPath;
|
|
75
|
+
let rollbackBin = null;
|
|
76
|
+
let candidatePending = null;
|
|
77
|
+
let awaitingCandidateHealth = false;
|
|
78
|
+
let currentChild = null;
|
|
79
|
+
let stopping = false;
|
|
80
|
+
const forwardSignal = (signal) => {
|
|
81
|
+
stopping = true;
|
|
82
|
+
currentChild?.kill(signal);
|
|
83
|
+
};
|
|
84
|
+
const onSigint = () => forwardSignal("SIGINT");
|
|
85
|
+
const onSigterm = () => forwardSignal("SIGTERM");
|
|
86
|
+
process.on("SIGINT", onSigint);
|
|
87
|
+
process.on("SIGTERM", onSigterm);
|
|
88
|
+
try {
|
|
89
|
+
const active = await (0, update_installer_cjs_1.readActiveRuntime)(configDir);
|
|
90
|
+
if (active) {
|
|
91
|
+
try {
|
|
92
|
+
const verified = await (0, update_installer_cjs_1.verifyManagedRuntime)(configDir, active);
|
|
93
|
+
currentBin = verified.bin_path;
|
|
94
|
+
log.info(`starting active managed runtime ${active.version}`);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
log.warn(`ignoring invalid active runtime pointer: ${String(error)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
while (!stopping) {
|
|
101
|
+
const candidateThisRun = awaitingCandidateHealth;
|
|
102
|
+
const result = await runWorker(currentBin, workerArgs, candidateThisRun ? healthTimeoutMs : null, (child) => {
|
|
103
|
+
currentChild = child;
|
|
104
|
+
}, async () => {
|
|
105
|
+
if (candidateThisRun) {
|
|
106
|
+
if (!candidatePending)
|
|
107
|
+
throw new Error("candidate has no pending release metadata");
|
|
108
|
+
await (0, update_installer_cjs_1.writeActiveRuntime)(configDir, candidatePending);
|
|
109
|
+
await (0, update_installer_cjs_1.clearPendingUpdate)(configDir);
|
|
110
|
+
awaitingCandidateHealth = false;
|
|
111
|
+
rollbackBin = null;
|
|
112
|
+
candidatePending = null;
|
|
113
|
+
log.info("updated runtime reported healthy; activation committed");
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
if (stopping)
|
|
117
|
+
return result.code;
|
|
118
|
+
if (candidateThisRun && !result.healthy) {
|
|
119
|
+
if (!rollbackBin)
|
|
120
|
+
return 1;
|
|
121
|
+
log.warn("updated runtime failed before becoming healthy; restoring previous version");
|
|
122
|
+
currentBin = rollbackBin;
|
|
123
|
+
rollbackBin = null;
|
|
124
|
+
awaitingCandidateHealth = false;
|
|
125
|
+
candidatePending = null;
|
|
126
|
+
await (0, update_installer_cjs_1.clearPendingUpdate)(configDir);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (result.code !== UPDATE_RESTART_EXIT_CODE)
|
|
130
|
+
return result.code;
|
|
131
|
+
const pending = await (0, update_installer_cjs_1.readPendingUpdate)(configDir);
|
|
132
|
+
if (!pending) {
|
|
133
|
+
log.error("worker requested an update restart without a pending update");
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
const verified = await (0, update_installer_cjs_1.verifyManagedRuntime)(configDir, pending);
|
|
137
|
+
rollbackBin = currentBin;
|
|
138
|
+
currentBin = verified.bin_path;
|
|
139
|
+
candidatePending = pending;
|
|
140
|
+
awaitingCandidateHealth = true;
|
|
141
|
+
log.info(`activating staged runtime ${pending.version}`);
|
|
142
|
+
}
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
process.off("SIGINT", onSigint);
|
|
147
|
+
process.off("SIGTERM", onSigterm);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RuntimeUpdateCoordinator = void 0;
|
|
4
|
+
const logger_cjs_1 = require("./logger.cjs");
|
|
5
|
+
const update_installer_cjs_1 = require("./update-installer.cjs");
|
|
6
|
+
const version_cjs_1 = require("./version.cjs");
|
|
7
|
+
const log = (0, logger_cjs_1.createLogger)("update");
|
|
8
|
+
class RuntimeUpdateCoordinator {
|
|
9
|
+
options;
|
|
10
|
+
targetInProgress = null;
|
|
11
|
+
warnedUnsupervised = false;
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.options = options;
|
|
14
|
+
}
|
|
15
|
+
consider(directive) {
|
|
16
|
+
if (!directive || directive.target_version === version_cjs_1.RUNTIME_VERSION)
|
|
17
|
+
return;
|
|
18
|
+
if (!this.options.config.autoUpdate) {
|
|
19
|
+
if (!this.warnedUnsupervised) {
|
|
20
|
+
log.warn(`runtime ${directive.target_version} is available; run under \`navarch-runtime supervise\` to activate updates automatically`);
|
|
21
|
+
this.warnedUnsupervised = true;
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (this.targetInProgress)
|
|
26
|
+
return;
|
|
27
|
+
this.targetInProgress = directive.target_version;
|
|
28
|
+
void this.apply(directive);
|
|
29
|
+
}
|
|
30
|
+
async apply(directive) {
|
|
31
|
+
const { config, heartbeat, claimLoop, capacity } = this.options;
|
|
32
|
+
try {
|
|
33
|
+
heartbeat.setUpdateState("staging");
|
|
34
|
+
log.info(`staging runtime ${directive.target_version}`);
|
|
35
|
+
const staged = await (this.options.stage ?? update_installer_cjs_1.stageRuntimeUpdate)(config.configDir, directive);
|
|
36
|
+
heartbeat.setUpdateState("draining");
|
|
37
|
+
log.info("runtime update staged; draining active sessions before restart");
|
|
38
|
+
await claimLoop.drain();
|
|
39
|
+
await capacity.waitForIdle();
|
|
40
|
+
await (0, update_installer_cjs_1.writePendingUpdate)(config.configDir, staged, directive.rollout_id);
|
|
41
|
+
log.info(`runtime ${directive.target_version} ready; handing off to supervisor`);
|
|
42
|
+
(this.options.requestRestart ?? ((code) => process.exit(code)))(75);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
46
|
+
log.error(`runtime update failed: ${message}`);
|
|
47
|
+
heartbeat.setUpdateState("failed", message.slice(0, 500));
|
|
48
|
+
claimLoop.start();
|
|
49
|
+
this.targetInProgress = null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
exports.RuntimeUpdateCoordinator = RuntimeUpdateCoordinator;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.validateUpdateDirective = validateUpdateDirective;
|
|
7
|
+
exports.stageRuntimeUpdate = stageRuntimeUpdate;
|
|
8
|
+
exports.writePendingUpdate = writePendingUpdate;
|
|
9
|
+
exports.readPendingUpdate = readPendingUpdate;
|
|
10
|
+
exports.clearPendingUpdate = clearPendingUpdate;
|
|
11
|
+
exports.writeActiveRuntime = writeActiveRuntime;
|
|
12
|
+
exports.readActiveRuntime = readActiveRuntime;
|
|
13
|
+
exports.assertSafePendingUpdate = assertSafePendingUpdate;
|
|
14
|
+
exports.verifyManagedRuntime = verifyManagedRuntime;
|
|
15
|
+
const node_child_process_1 = require("node:child_process");
|
|
16
|
+
const node_crypto_1 = require("node:crypto");
|
|
17
|
+
const node_fs_1 = require("node:fs");
|
|
18
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
19
|
+
const node_util_1 = require("node:util");
|
|
20
|
+
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
21
|
+
const PACKAGE_NAME = "@sagentlab/navarch-runtime";
|
|
22
|
+
const EXACT_SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/;
|
|
23
|
+
const SHA512_INTEGRITY = /^sha512-[A-Za-z0-9+/]+={0,2}$/;
|
|
24
|
+
const defaultRunner = async (file, args) => {
|
|
25
|
+
const result = await execFileAsync(file, args, { maxBuffer: 10 * 1024 * 1024 });
|
|
26
|
+
return { stdout: result.stdout, stderr: result.stderr };
|
|
27
|
+
};
|
|
28
|
+
function validateUpdateDirective(directive) {
|
|
29
|
+
if (!EXACT_SEMVER.test(directive.target_version)) {
|
|
30
|
+
throw new Error(`Invalid runtime target version: ${directive.target_version}`);
|
|
31
|
+
}
|
|
32
|
+
if (!SHA512_INTEGRITY.test(directive.integrity)) {
|
|
33
|
+
throw new Error("Runtime update is missing a valid SHA-512 integrity value.");
|
|
34
|
+
}
|
|
35
|
+
if (!directive.rollout_id || directive.rollout_id.length > 128) {
|
|
36
|
+
throw new Error("Runtime update has an invalid rollout id.");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function verifyInstalledPackage(packageDir, version, integrity) {
|
|
40
|
+
const manifest = JSON.parse(await node_fs_1.promises.readFile(node_path_1.default.join(packageDir, "package.json"), "utf8"));
|
|
41
|
+
if (manifest.name !== PACKAGE_NAME || manifest.version !== version) {
|
|
42
|
+
throw new Error("Staged runtime package identity did not match the requested release.");
|
|
43
|
+
}
|
|
44
|
+
const recordedIntegrity = (await node_fs_1.promises.readFile(node_path_1.default.join(packageDir, ".navarch-integrity"), "utf8")).trim();
|
|
45
|
+
if (recordedIntegrity !== integrity) {
|
|
46
|
+
throw new Error("Staged runtime integrity did not match the release directive.");
|
|
47
|
+
}
|
|
48
|
+
const binPath = node_path_1.default.join(packageDir, "bin", "navarch.cjs");
|
|
49
|
+
await node_fs_1.promises.access(binPath);
|
|
50
|
+
return { version, integrity, bin_path: binPath };
|
|
51
|
+
}
|
|
52
|
+
/** Downloads and verifies an exact immutable npm release without running package scripts. */
|
|
53
|
+
async function stageRuntimeUpdate(configDir, directive, runner = defaultRunner) {
|
|
54
|
+
validateUpdateDirective(directive);
|
|
55
|
+
const versionsDir = node_path_1.default.join(configDir, "versions");
|
|
56
|
+
const targetDir = node_path_1.default.join(versionsDir, directive.target_version);
|
|
57
|
+
await node_fs_1.promises.mkdir(versionsDir, { recursive: true, mode: 0o700 });
|
|
58
|
+
try {
|
|
59
|
+
const staged = await verifyInstalledPackage(targetDir, directive.target_version, directive.integrity);
|
|
60
|
+
await runner(process.execPath, [staged.bin_path, "--help"]);
|
|
61
|
+
return staged;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error.code !== "ENOENT")
|
|
65
|
+
throw error;
|
|
66
|
+
await node_fs_1.promises.rm(targetDir, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
const stagingDir = node_path_1.default.join(versionsDir, `.staging-${(0, node_crypto_1.randomUUID)()}`);
|
|
69
|
+
const installDir = node_path_1.default.join(stagingDir, "install");
|
|
70
|
+
await node_fs_1.promises.mkdir(installDir, { recursive: true, mode: 0o700 });
|
|
71
|
+
try {
|
|
72
|
+
const packed = await runner("npm", [
|
|
73
|
+
"pack",
|
|
74
|
+
`${PACKAGE_NAME}@${directive.target_version}`,
|
|
75
|
+
"--json",
|
|
76
|
+
"--ignore-scripts",
|
|
77
|
+
"--pack-destination",
|
|
78
|
+
stagingDir,
|
|
79
|
+
]);
|
|
80
|
+
const packResult = JSON.parse(packed.stdout);
|
|
81
|
+
const artifact = packResult[0];
|
|
82
|
+
if (!artifact ||
|
|
83
|
+
typeof artifact.filename !== "string" ||
|
|
84
|
+
artifact.integrity !== directive.integrity) {
|
|
85
|
+
throw new Error("Downloaded runtime tarball failed its release integrity check.");
|
|
86
|
+
}
|
|
87
|
+
const tarballPath = node_path_1.default.join(stagingDir, node_path_1.default.basename(artifact.filename));
|
|
88
|
+
await runner("npm", [
|
|
89
|
+
"install",
|
|
90
|
+
"--prefix",
|
|
91
|
+
installDir,
|
|
92
|
+
"--ignore-scripts",
|
|
93
|
+
"--omit=dev",
|
|
94
|
+
"--no-audit",
|
|
95
|
+
"--no-fund",
|
|
96
|
+
"--package-lock=false",
|
|
97
|
+
tarballPath,
|
|
98
|
+
]);
|
|
99
|
+
const installedPackage = node_path_1.default.join(installDir, "node_modules", "@sagentlab", "navarch-runtime");
|
|
100
|
+
await node_fs_1.promises.writeFile(node_path_1.default.join(installedPackage, ".navarch-integrity"), `${directive.integrity}\n`, { mode: 0o600 });
|
|
101
|
+
await node_fs_1.promises.rename(installedPackage, targetDir);
|
|
102
|
+
const staged = await verifyInstalledPackage(targetDir, directive.target_version, directive.integrity);
|
|
103
|
+
await runner(process.execPath, [staged.bin_path, "--help"]);
|
|
104
|
+
return staged;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await node_fs_1.promises.rm(stagingDir, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function pendingPath(configDir) {
|
|
111
|
+
return node_path_1.default.join(configDir, "pending-update.json");
|
|
112
|
+
}
|
|
113
|
+
function activePath(configDir) {
|
|
114
|
+
return node_path_1.default.join(configDir, "active-runtime.json");
|
|
115
|
+
}
|
|
116
|
+
async function writePendingUpdate(configDir, staged, rolloutId) {
|
|
117
|
+
const pending = { ...staged, rollout_id: rolloutId };
|
|
118
|
+
const tempPath = `${pendingPath(configDir)}.${(0, node_crypto_1.randomUUID)()}.tmp`;
|
|
119
|
+
await node_fs_1.promises.writeFile(tempPath, `${JSON.stringify(pending, null, 2)}\n`, { mode: 0o600 });
|
|
120
|
+
await node_fs_1.promises.rename(tempPath, pendingPath(configDir));
|
|
121
|
+
}
|
|
122
|
+
async function readPendingUpdate(configDir) {
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(await node_fs_1.promises.readFile(pendingPath(configDir), "utf8"));
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (error.code === "ENOENT")
|
|
128
|
+
return null;
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function clearPendingUpdate(configDir) {
|
|
133
|
+
await node_fs_1.promises.rm(pendingPath(configDir), { force: true });
|
|
134
|
+
}
|
|
135
|
+
async function writeActiveRuntime(configDir, pending) {
|
|
136
|
+
const active = { ...pending, activated_at: new Date().toISOString() };
|
|
137
|
+
const tempPath = `${activePath(configDir)}.${(0, node_crypto_1.randomUUID)()}.tmp`;
|
|
138
|
+
await node_fs_1.promises.writeFile(tempPath, `${JSON.stringify(active, null, 2)}\n`, { mode: 0o600 });
|
|
139
|
+
await node_fs_1.promises.rename(tempPath, activePath(configDir));
|
|
140
|
+
}
|
|
141
|
+
async function readActiveRuntime(configDir) {
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(await node_fs_1.promises.readFile(activePath(configDir), "utf8"));
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (error.code === "ENOENT")
|
|
147
|
+
return null;
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function assertSafePendingUpdate(configDir, pending) {
|
|
152
|
+
if (!EXACT_SEMVER.test(pending.version))
|
|
153
|
+
throw new Error("Pending update has an invalid version.");
|
|
154
|
+
const expectedBin = node_path_1.default.resolve(configDir, "versions", pending.version, "bin", "navarch.cjs");
|
|
155
|
+
if (node_path_1.default.resolve(pending.bin_path) !== expectedBin) {
|
|
156
|
+
throw new Error("Pending update executable is outside the managed versions directory.");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Re-verifies a persisted managed pointer before the supervisor executes it. */
|
|
160
|
+
async function verifyManagedRuntime(configDir, runtime) {
|
|
161
|
+
assertSafePendingUpdate(configDir, runtime);
|
|
162
|
+
const packageDir = node_path_1.default.resolve(configDir, "versions", runtime.version);
|
|
163
|
+
return verifyInstalledPackage(packageDir, runtime.version, runtime.integrity);
|
|
164
|
+
}
|
package/dist/version.cjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.UPDATER_PROTOCOL_VERSION = exports.RUNTIME_VERSION = void 0;
|
|
4
|
+
// package.json is always included in an npm package, even though the runtime's
|
|
5
|
+
// explicit `files` allowlist only names dist/bin/README.
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
7
|
+
const packageJson = require("../package.json");
|
|
8
|
+
exports.RUNTIME_VERSION = typeof packageJson.version === "string" ? packageJson.version : "0.0.0-unknown";
|
|
9
|
+
exports.UPDATER_PROTOCOL_VERSION = 1;
|
package/dist/worktree-guard.cjs
CHANGED
|
@@ -88,8 +88,12 @@ function codexWorktreeGuardArgs(options) {
|
|
|
88
88
|
}
|
|
89
89
|
return [
|
|
90
90
|
"--ignore-user-config",
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
// Keep approvals interactive at the policy layer, but route them to
|
|
92
|
+
// Codex's automatic reviewer because `codex exec` has no human available.
|
|
93
|
+
"-c",
|
|
94
|
+
'approval_policy="on-request"',
|
|
95
|
+
"-c",
|
|
96
|
+
'approvals_reviewer="auto_review"',
|
|
93
97
|
"-c",
|
|
94
98
|
`projects.${tomlString(node_path_1.default.resolve(options.worktreePath))}.trust_level="untrusted"`,
|
|
95
99
|
"-c",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sagentlab/navarch-runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Navarch machine-side session manager: registers a machine, claims tasks from the control-plane dispatcher, runs them via the Claude Code or Codex adapter, and reports results back.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"test:watch": "vitest",
|
|
39
39
|
"prepublishOnly": "npm run build && npm test",
|
|
40
40
|
"start": "node bin/navarch.cjs start",
|
|
41
|
+
"supervise": "node bin/navarch.cjs supervise",
|
|
41
42
|
"register": "node bin/navarch.cjs register",
|
|
42
43
|
"connect": "node bin/navarch.cjs connect",
|
|
43
44
|
"doctor": "node bin/navarch.cjs doctor"
|