@sagentlab/navarch-runtime 0.1.49 → 0.1.51

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 CHANGED
@@ -360,6 +360,7 @@ unchanged across the deployment.
360
360
  | `NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS` | `300000` | Per-lease heartbeat interval; must stay well under the 60-minute lease TTL (schema-design.md §4). |
361
361
  | `NAVARCH_SESSION_TIMEOUT_MS` | `0` (disabled) | Optional absolute runtime budget across agent turns in a claimed session. |
362
362
  | `NAVARCH_SESSION_IDLE_TIMEOUT_MS` | `2700000` (45 min) | Stop after a continuously observed window with no stdout/stderr or process-tree CPU activity. `0` disables idle detection. |
363
+ | `NAVARCH_OPENCODE_STARTUP_TIMEOUT_MS` | `120000` (2 min) | Stop OpenCode when it produces no stdout/stderr during startup. This catches provider auth/quota failures that leave its CLI process alive. `0` disables the startup deadline. |
363
364
  | `NAVARCH_WORKTREE_CLEANUP_INTERVAL_MS` | `3600000` (1 hour) | How often the runtime scans for session worktrees abandoned by a hard exit or host restart. |
364
365
  | `NAVARCH_WORKTREE_STALE_AFTER_MS` | `86400000` (24 hours) | Minimum inactivity age before an abandoned session worktree is removed. Active sessions are always protected. |
365
366
  | `NAVARCH_GIT_AUTHOR_NAME` / `NAVARCH_GIT_AUTHOR_EMAIL` | `sagentlab` / `z@sagentlab.com` | Git identity forced into session commits so host-level personal config is not inherited; override both for a project-authorized bot. |
@@ -837,3 +838,13 @@ Each invocation rebuilds from Git blobs at the requested commit, avoiding stale
837
838
  persistent indexes. Bounds are 2,000 files, 10 MB total source, 500 KB per file,
838
839
  100 returned nodes and 300 edges. A later persistent index can optimize large
839
840
  repositories without changing the query format.
841
+
842
+ ### Pinning a connection model
843
+
844
+ `connect --agent codex --model gpt-6-astra` saves the model alongside the local
845
+ machine identity. `start` and `supervise` reuse that model, including after worker
846
+ restarts. Their optional `--model` flag overrides the saved choice. The pin wins
847
+ over the project's session model for the selected adapter only; switching agent
848
+ types does not reuse another adapter's pin. Reconnect without `--model` to return
849
+ to project-controlled model selection. Older identities without a model keep
850
+ using the control-plane execution policy.
@@ -16,6 +16,7 @@ class ActivityWatchdog {
16
16
  stopped = false;
17
17
  polling = false;
18
18
  pollTimer;
19
+ startupTimer;
19
20
  hardTimer;
20
21
  constructor(options) {
21
22
  this.options = options;
@@ -24,16 +25,23 @@ class ActivityWatchdog {
24
25
  if (options.idleTimeoutMs > 0) {
25
26
  this.pollTimer = setInterval(() => void this.poll(), Math.min(30_000, options.idleTimeoutMs / 4));
26
27
  }
28
+ if ((options.startupTimeoutMs ?? 0) > 0) {
29
+ this.startupTimer = setTimeout(() => this.expire("startup"), options.startupTimeoutMs);
30
+ }
27
31
  if (options.timeoutMs > 0)
28
32
  this.armHardLimit();
29
33
  }
30
34
  activity() {
31
- if (!this.stopped)
35
+ if (!this.stopped) {
32
36
  this.lastActivity = this.now();
37
+ clearTimeout(this.startupTimer);
38
+ this.startupTimer = undefined;
39
+ }
33
40
  }
34
41
  stop() {
35
42
  this.stopped = true;
36
43
  clearInterval(this.pollTimer);
44
+ clearTimeout(this.startupTimer);
37
45
  clearTimeout(this.hardTimer);
38
46
  }
39
47
  armHardLimit() {
@@ -61,8 +69,11 @@ class ActivityWatchdog {
61
69
  this.observed = false;
62
70
  return;
63
71
  }
72
+ // Process-tree activity keeps the ordinary idle window alive, but only
73
+ // actual adapter output proves that a CLI got through startup. Some
74
+ // providers leave a quiet process behind after an auth/quota failure.
64
75
  if (!this.observed || signature !== this.signature)
65
- this.activity();
76
+ this.lastActivity = this.now();
66
77
  this.signature = signature;
67
78
  this.observed = true;
68
79
  if (this.now() - this.lastActivity >= this.options.idleTimeoutMs)
@@ -79,15 +90,15 @@ class ActivityWatchdog {
79
90
  }
80
91
  exports.ActivityWatchdog = ActivityWatchdog;
81
92
  /** No command arguments or environment values are collected from other jobs. */
82
- async function processActivity(pid, containerName) {
93
+ async function processActivity(pid, containerName, ignoreRootCpu = false) {
83
94
  if (!containerName && pid === undefined)
84
95
  return null;
85
96
  const output = await new Promise((resolve) => {
86
97
  (0, node_child_process_1.execFile)(containerName ? "docker" : "ps", containerName ? ["top", containerName, "-eo", "pid=,ppid=,time="] : ["-e", "-o", "pid=,ppid=,time="], { timeout: 5_000, maxBuffer: 4 * 1024 * 1024 }, (error, stdout) => resolve(error ? null : stdout));
87
98
  });
88
- return output === null ? null : processTreeSignature(output, containerName ? undefined : pid);
99
+ return output === null ? null : processTreeSignature(output, containerName ? undefined : pid, ignoreRootCpu);
89
100
  }
90
- function processTreeSignature(output, rootPid) {
101
+ function processTreeSignature(output, rootPid, ignoreRootCpu = false) {
91
102
  const rows = output.split("\n").flatMap((line) => {
92
103
  const match = /^\s*(\d+)\s+(\d+)\s+([\d:.\-]+)\s*$/.exec(line);
93
104
  return match ? [{ pid: Number(match[1]), parent: Number(match[2]), cpu: match[3] }] : [];
@@ -106,5 +117,5 @@ function processTreeSignature(output, rootPid) {
106
117
  }
107
118
  }
108
119
  return rows.filter((row) => included.has(row.pid)).sort((a, b) => a.pid - b.pid)
109
- .map((row) => `${row.pid}:${row.cpu}`).join(",");
120
+ .map((row) => `${row.pid}:${ignoreRootCpu && row.pid === rootPid ? "root" : row.cpu}`).join(",");
110
121
  }
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.runWithActivityWatchdog = runWithActivityWatchdog;
4
4
  const activity_watchdog_cjs_1 = require("./activity-watchdog.cjs");
5
5
  /** Shared policy for every host/Docker/ACP adapter; adapters still own cancellation. */
6
- async function runWithActivityWatchdog(adapter, options, idleTimeoutMs) {
6
+ async function runWithActivityWatchdog(adapter, options, idleTimeoutMs, startupTimeoutMs = 0) {
7
7
  const controller = new AbortController();
8
8
  const onAbort = () => controller.abort();
9
9
  options.signal?.addEventListener("abort", onAbort, { once: true });
@@ -13,8 +13,11 @@ async function runWithActivityWatchdog(adapter, options, idleTimeoutMs) {
13
13
  let timeoutReason;
14
14
  const watchdog = new activity_watchdog_cjs_1.ActivityWatchdog({
15
15
  idleTimeoutMs,
16
+ startupTimeoutMs,
16
17
  timeoutMs: options.timeoutMs,
17
- probe: () => (0, activity_watchdog_cjs_1.processActivity)(pid, options.dockerExec?.containerName),
18
+ // OpenCode keeps doing root-process housekeeping after provider failures.
19
+ // Descendant work and stdout/stderr still count, but that churn does not.
20
+ probe: () => (0, activity_watchdog_cjs_1.processActivity)(pid, options.dockerExec?.containerName, adapter.agentType === "opencode" && !options.dockerExec),
18
21
  onTimeout: (reason) => {
19
22
  timeoutReason = reason;
20
23
  controller.abort();
package/dist/cli.cjs CHANGED
@@ -76,6 +76,14 @@ function agentFromFlag(flags) {
76
76
  }
77
77
  return value;
78
78
  }
79
+ /** Saved pins only apply to the adapter they were selected for. */
80
+ function modelFromFlags(flags, agentType, identity) {
81
+ const model = flags.model ?? (identity?.agent_type === agentType ? identity.model : undefined);
82
+ if (model !== undefined && (!model.trim() || model === "true" || /\s/.test(model))) {
83
+ throw new Error("--model requires a non-empty model reference without whitespace.");
84
+ }
85
+ return model;
86
+ }
79
87
  function parseArgs(argv) {
80
88
  const [command, ...rest] = argv;
81
89
  const flags = {};
@@ -118,6 +126,7 @@ async function registerCommand(flags) {
118
126
  .filter(Boolean);
119
127
  const ownerZone = flags["owner-zone"] ?? config.ownerZone;
120
128
  const agentType = agentFromFlag(flags) ?? config.agentType;
129
+ modelFromFlags(flags, agentType); // Validate before consuming an enrollment token.
121
130
  const client = new api_cjs_1.NavarchApiClient({ baseUrl: apiBase });
122
131
  const result = await client.registerMachine({
123
132
  enrollment_token: enrollmentToken,
@@ -132,6 +141,7 @@ async function registerCommand(flags) {
132
141
  name,
133
142
  api_base: apiBase,
134
143
  agent_type: agentType,
144
+ model: modelFromFlags(flags, agentType),
135
145
  });
136
146
  // Printed exactly once. Never logged or echoed again after this point.
137
147
  console.log("Machine registered.");
@@ -166,6 +176,7 @@ async function connectCommand(flags) {
166
176
  .map((s) => s.trim())
167
177
  .filter(Boolean);
168
178
  const agentType = agentFromFlag(flags) ?? config.agentType;
179
+ modelFromFlags(flags, agentType); // Validate before consuming an enrollment token.
169
180
  const client = new api_cjs_1.NavarchApiClient({ baseUrl: apiBase });
170
181
  const result = await client.connectMachine({
171
182
  enrollment_token: enrollmentToken,
@@ -180,6 +191,7 @@ async function connectCommand(flags) {
180
191
  name,
181
192
  api_base: apiBase,
182
193
  agent_type: agentType,
194
+ model: modelFromFlags(flags, agentType),
183
195
  });
184
196
  // Printed exactly once. Never logged or echoed again after this point.
185
197
  console.log("Machine connected.");
@@ -195,9 +207,10 @@ async function startCommand(flags) {
195
207
  // time, and finally the backwards-compatible Claude Code default.
196
208
  const agentType = agentFromFlag(flags) ??
197
209
  (process.env.NAVARCH_AGENT ? baseConfig.agentType : identity.agent_type ?? baseConfig.agentType);
198
- const config = { ...baseConfig, agentType };
210
+ const config = { ...baseConfig, agentType, model: modelFromFlags(flags, agentType, identity) };
199
211
  const api = new api_cjs_1.NavarchApiClient({ baseUrl: identity.api_base, token: identity.token });
200
212
  const capacity = new capacity_cjs_1.CapacityTracker(config.maxSessions);
213
+ const runtimeShutdown = new AbortController();
201
214
  const activeSessionIds = new Set();
202
215
  const worktreeJanitor = new worktree_janitor_cjs_1.WorktreeJanitor({
203
216
  workspaceRoot: config.workspaceRoot,
@@ -207,7 +220,7 @@ async function startCommand(flags) {
207
220
  });
208
221
  const claimLoop = new claim_loop_cjs_1.ClaimLoop(api, config, capacity, (claimed, sessionId) => {
209
222
  activeSessionIds.add(sessionId);
210
- return (0, session_cjs_1.runSession)({ api, config }, claimed, sessionId)
223
+ return (0, session_cjs_1.runSession)({ api, config, shutdownSignal: runtimeShutdown.signal }, claimed, sessionId)
211
224
  .finally(() => activeSessionIds.delete(sessionId));
212
225
  });
213
226
  const bootId = (0, node_crypto_1.randomUUID)();
@@ -231,8 +244,11 @@ async function startCommand(flags) {
231
244
  : `machine credentials for ${identity.name} were rejected by ${identity.api_base}; it can no longer claim tasks.`);
232
245
  log.error(`Re-enroll this machine with a fresh "Connect an agent" token, then run ` +
233
246
  `\`${superviseInvocation(config.configDir)}\` again.`);
234
- // Active sessions keep running to completion; exiting mid-task would
235
- // strand a worktree the control plane can no longer hear about either.
247
+ // Removal/revocation is terminal: stop detached agent process groups so
248
+ // a provider-side hang cannot keep consuming local resources forever.
249
+ // runSession checkpoints or retains the worktree before releasing its
250
+ // capacity slot.
251
+ runtimeShutdown.abort();
236
252
  void capacity.waitForIdle().then(() => process.exit(CREDENTIALS_REJECTED_EXIT_CODE));
237
253
  });
238
254
  updateCoordinatorRef.current = new update_coordinator_cjs_1.RuntimeUpdateCoordinator({
@@ -277,6 +293,9 @@ async function superviseCommand(flags) {
277
293
  const agentType = agentFromFlag(flags) ??
278
294
  (process.env.NAVARCH_AGENT ? config.agentType : identity.agent_type ?? config.agentType);
279
295
  const workerArgs = ["--agent", agentType];
296
+ const model = modelFromFlags(flags, agentType, identity);
297
+ if (model)
298
+ workerArgs.push("--model", model);
280
299
  // Pin the identity for this supervisor's lifetime. Without this snapshot, a
281
300
  // replacement worker rereads machine.json after an automatic update and can
282
301
  // silently become a different machine if another terminal reused the same
@@ -332,11 +351,11 @@ function helpText() {
332
351
 
333
352
  Usage:
334
353
  navarch-runtime register --token <enrollment-token> --name <machine-name> \\
335
- [--config-dir <path>] [--agent claude-code|codex|gemini|opencode|acp] [--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]
354
+ [--config-dir <path>] [--agent claude-code|codex|gemini|opencode|acp] [--model <model>] [--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]
336
355
  navarch-runtime connect --token <enrollment-token> --name <machine-name> \\
337
- [--config-dir <path>] [--agent claude-code|codex|gemini|opencode|acp] [--project <project-id>] [--capabilities a,b] [--max-sessions N] [--api-base url]
338
- navarch-runtime start [--config-dir <path>] [--agent claude-code|codex|gemini|opencode|acp]
339
- navarch-runtime supervise [--config-dir <path>] [--agent claude-code|codex|gemini|opencode|acp]
356
+ [--config-dir <path>] [--agent claude-code|codex|gemini|opencode|acp] [--model <model>] [--project <project-id>] [--capabilities a,b] [--max-sessions N] [--api-base url]
357
+ navarch-runtime start [--config-dir <path>] [--agent claude-code|codex|gemini|opencode|acp] [--model <model>]
358
+ navarch-runtime supervise [--config-dir <path>] [--agent claude-code|codex|gemini|opencode|acp] [--model <model>]
340
359
  navarch-runtime doctor [--config-dir <path>]
341
360
  navarch-runtime code-graph --query <symbol-or-file> [--mode search|callers|callees|impact] [--commit HEAD] [--depth 1..5] [--repo <path>]
342
361
 
package/dist/config.cjs CHANGED
@@ -108,6 +108,7 @@ function loadRuntimeConfig(env = process.env) {
108
108
  leaseHeartbeatIntervalMs,
109
109
  sessionTimeoutMs: envRuntimeLimit(env, "NAVARCH_SESSION_TIMEOUT_MS", 0),
110
110
  sessionIdleTimeoutMs: envRuntimeLimit(env, "NAVARCH_SESSION_IDLE_TIMEOUT_MS", 45 * 60 * 1000),
111
+ opencodeStartupTimeoutMs: envRuntimeLimit(env, "NAVARCH_OPENCODE_STARTUP_TIMEOUT_MS", 2 * 60 * 1000),
111
112
  worktreeCleanupIntervalMs: envInt(env, "NAVARCH_WORKTREE_CLEANUP_INTERVAL_MS", 60 * 60 * 1000),
112
113
  worktreeStaleAfterMs: envInt(env, "NAVARCH_WORKTREE_STALE_AFTER_MS", 24 * 60 * 60 * 1000),
113
114
  agentType,
@@ -233,9 +233,11 @@ function mapExitCondition(result) {
233
233
  return {
234
234
  leaseOutcome: "failed",
235
235
  exitStatus: "killed",
236
- reportSummary: (result.timeoutReason === "idle"
237
- ? "Session stopped: no output or process-tree CPU activity within the configured idle window."
238
- : "Session killed: exceeded the configured max session duration.") +
236
+ reportSummary: (result.timeoutReason === "startup"
237
+ ? "Session stopped: the agent produced no output within the configured startup window."
238
+ : result.timeoutReason === "idle"
239
+ ? "Session stopped: no output or process-tree CPU activity within the configured idle window."
240
+ : "Session killed: exceeded the configured max session duration.") +
239
241
  (result.checkpointPath ? ` Local recovery checkpoint: ${result.checkpointPath}` : "") +
240
242
  (result.retainedWorktreePath ? ` Recovery worktree retained: ${result.retainedWorktreePath}` : ""),
241
243
  evidenceUrls,
package/dist/session.cjs CHANGED
@@ -38,6 +38,21 @@ const COMPLETION_REMEDIATION_RETRIES = 2;
38
38
  */
39
39
  const DELIVERY_CORRECTION_RETRIES = 1;
40
40
  const log = (0, logger_cjs_1.createLogger)("session");
41
+ function resolveSessionExecution(config, claimed) {
42
+ const runtime = claimed.runtime ?? config.agentType;
43
+ const execution = claimed.context_bundle.execution ?? {
44
+ profile: claimed.task.execution_profile ?? "standard",
45
+ model: runtime === "codex" ? "gpt-6-astra"
46
+ : runtime === "gemini" ? "auto"
47
+ : runtime === "opencode" || runtime === "acp" ? "default"
48
+ : "claude-opus-5",
49
+ reasoning_effort: "medium",
50
+ };
51
+ return {
52
+ ...execution,
53
+ ...(runtime === config.agentType && config.model ? { model: config.model } : {}),
54
+ };
55
+ }
41
56
  /**
42
57
  * Runs one claimed task end to end (implementation-plan.md WP-07):
43
58
  * 1. write the prompt file
@@ -70,19 +85,9 @@ async function runSession(deps, claimed, sessionId) {
70
85
  // explicit pre-start signal. Otherwise it waits for expiry and bypasses
71
86
  // the repeat/backoff policy entirely.
72
87
  const { api, config } = deps;
73
- const { lease_id: leaseId, task, context_bundle: bundle } = claimed;
88
+ const { lease_id: leaseId, task } = claimed;
74
89
  const runtime = claimed.runtime ?? config.agentType;
75
- const execution = bundle.execution ?? {
76
- profile: task.execution_profile ?? "standard",
77
- model: runtime === "codex"
78
- ? "gpt-6-astra"
79
- : runtime === "gemini"
80
- ? "auto"
81
- : runtime === "opencode" || runtime === "acp"
82
- ? "default"
83
- : "claude-opus-5",
84
- reasoning_effort: "medium",
85
- };
90
+ const execution = resolveSessionExecution(config, claimed);
86
91
  const crashDetail = describeCompletionError(err).slice(0, 1800);
87
92
  const failureSummary = `Session crashed: ${crashDetail}`;
88
93
  log.error(`session ${leaseId} threw before adapter start: ${crashDetail}`);
@@ -110,17 +115,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
110
115
  const { api, config } = deps;
111
116
  const { lease_id: leaseId, task, context_bundle: bundle } = claimed;
112
117
  const runtime = claimed.runtime ?? config.agentType;
113
- const execution = bundle.execution ?? {
114
- profile: task.execution_profile ?? "standard",
115
- model: runtime === "codex"
116
- ? "gpt-6-astra"
117
- : runtime === "gemini"
118
- ? "auto"
119
- : runtime === "opencode" || runtime === "acp"
120
- ? "default"
121
- : "claude-opus-5",
122
- reasoning_effort: "medium",
123
- };
118
+ const execution = resolveSessionExecution(config, claimed);
124
119
  // Isolation posture reported on every completion so `sessions` records what
125
120
  // a run actually executed under (#660). Derived from config alone, so the
126
121
  // pre-start failure paths below can stamp it too. Host mode records
@@ -216,6 +211,14 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
216
211
  let activeAbortController = null;
217
212
  let leaseLost = false;
218
213
  let leaseGone = false;
214
+ const abortForRuntimeShutdown = () => {
215
+ // Machine removal/revocation also makes lease completion impossible.
216
+ // Treat it like a terminal lease loss so the worktree is checkpointed
217
+ // and no remediation turn or authenticated API call is attempted.
218
+ leaseLost = true;
219
+ leaseGone = true;
220
+ activeAbortController?.abort();
221
+ };
219
222
  let sessionClosing = false;
220
223
  let retainWorktree = false;
221
224
  let heartbeatInFlight = null;
@@ -397,6 +400,9 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
397
400
  activeAbortController?.abort();
398
401
  });
399
402
  const heartbeatTimer = setInterval(() => void pollLease(), config.leaseHeartbeatIntervalMs);
403
+ deps.shutdownSignal?.addEventListener("abort", abortForRuntimeShutdown);
404
+ if (deps.shutdownSignal?.aborted)
405
+ abortForRuntimeShutdown();
400
406
  lifecycle.enteredMainSessionTry = true;
401
407
  try {
402
408
  await gitWorktree.prepare();
@@ -432,7 +438,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
432
438
  // notification and start the first turn with the corrected prompt.
433
439
  pendingGuidance = [];
434
440
  activeAbortController = new AbortController();
435
- if (leaseLost)
441
+ if (leaseLost || deps.shutdownSignal?.aborted)
436
442
  activeAbortController.abort();
437
443
  const runPrompt = nextPrompt ??
438
444
  (deliveredGuidance.length > (bundle.guidance?.length ?? 0)
@@ -464,7 +470,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
464
470
  cwd: gitWorktree.worktreePath,
465
471
  dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
466
472
  signal: activeAbortController.signal,
467
- }, task.idle_timeout_ms ?? config.sessionIdleTimeoutMs ?? 45 * 60_000);
473
+ }, task.idle_timeout_ms ?? config.sessionIdleTimeoutMs ?? 45 * 60_000, runtime === "opencode" ? config.opencodeStartupTimeoutMs : 0);
468
474
  activeAbortController = null;
469
475
  if (turnResult.timedOut || leaseLost)
470
476
  await checkpointForRecovery(turnResult);
@@ -716,6 +722,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
716
722
  .catch((completeErr) => log.warn(`complete() after crash also failed: ${String(completeErr)}`));
717
723
  }
718
724
  finally {
725
+ deps.shutdownSignal?.removeEventListener("abort", abortForRuntimeShutdown);
719
726
  sessionClosing = true;
720
727
  clearInterval(heartbeatTimer);
721
728
  heartbeatGuard.stop();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.49",
3
+ "version": "0.1.51",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through local coding agents.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",