@sagentlab/navarch-runtime 0.1.50 → 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. |
@@ -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
@@ -210,6 +210,7 @@ async function startCommand(flags) {
210
210
  const config = { ...baseConfig, agentType, model: modelFromFlags(flags, agentType, identity) };
211
211
  const api = new api_cjs_1.NavarchApiClient({ baseUrl: identity.api_base, token: identity.token });
212
212
  const capacity = new capacity_cjs_1.CapacityTracker(config.maxSessions);
213
+ const runtimeShutdown = new AbortController();
213
214
  const activeSessionIds = new Set();
214
215
  const worktreeJanitor = new worktree_janitor_cjs_1.WorktreeJanitor({
215
216
  workspaceRoot: config.workspaceRoot,
@@ -219,7 +220,7 @@ async function startCommand(flags) {
219
220
  });
220
221
  const claimLoop = new claim_loop_cjs_1.ClaimLoop(api, config, capacity, (claimed, sessionId) => {
221
222
  activeSessionIds.add(sessionId);
222
- return (0, session_cjs_1.runSession)({ api, config }, claimed, sessionId)
223
+ return (0, session_cjs_1.runSession)({ api, config, shutdownSignal: runtimeShutdown.signal }, claimed, sessionId)
223
224
  .finally(() => activeSessionIds.delete(sessionId));
224
225
  });
225
226
  const bootId = (0, node_crypto_1.randomUUID)();
@@ -243,8 +244,11 @@ async function startCommand(flags) {
243
244
  : `machine credentials for ${identity.name} were rejected by ${identity.api_base}; it can no longer claim tasks.`);
244
245
  log.error(`Re-enroll this machine with a fresh "Connect an agent" token, then run ` +
245
246
  `\`${superviseInvocation(config.configDir)}\` again.`);
246
- // Active sessions keep running to completion; exiting mid-task would
247
- // 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();
248
252
  void capacity.waitForIdle().then(() => process.exit(CREDENTIALS_REJECTED_EXIT_CODE));
249
253
  });
250
254
  updateCoordinatorRef.current = new update_coordinator_cjs_1.RuntimeUpdateCoordinator({
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
@@ -211,6 +211,14 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
211
211
  let activeAbortController = null;
212
212
  let leaseLost = false;
213
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
+ };
214
222
  let sessionClosing = false;
215
223
  let retainWorktree = false;
216
224
  let heartbeatInFlight = null;
@@ -392,6 +400,9 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
392
400
  activeAbortController?.abort();
393
401
  });
394
402
  const heartbeatTimer = setInterval(() => void pollLease(), config.leaseHeartbeatIntervalMs);
403
+ deps.shutdownSignal?.addEventListener("abort", abortForRuntimeShutdown);
404
+ if (deps.shutdownSignal?.aborted)
405
+ abortForRuntimeShutdown();
395
406
  lifecycle.enteredMainSessionTry = true;
396
407
  try {
397
408
  await gitWorktree.prepare();
@@ -427,7 +438,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
427
438
  // notification and start the first turn with the corrected prompt.
428
439
  pendingGuidance = [];
429
440
  activeAbortController = new AbortController();
430
- if (leaseLost)
441
+ if (leaseLost || deps.shutdownSignal?.aborted)
431
442
  activeAbortController.abort();
432
443
  const runPrompt = nextPrompt ??
433
444
  (deliveredGuidance.length > (bundle.guidance?.length ?? 0)
@@ -459,7 +470,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
459
470
  cwd: gitWorktree.worktreePath,
460
471
  dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
461
472
  signal: activeAbortController.signal,
462
- }, task.idle_timeout_ms ?? config.sessionIdleTimeoutMs ?? 45 * 60_000);
473
+ }, task.idle_timeout_ms ?? config.sessionIdleTimeoutMs ?? 45 * 60_000, runtime === "opencode" ? config.opencodeStartupTimeoutMs : 0);
463
474
  activeAbortController = null;
464
475
  if (turnResult.timedOut || leaseLost)
465
476
  await checkpointForRecovery(turnResult);
@@ -711,6 +722,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
711
722
  .catch((completeErr) => log.warn(`complete() after crash also failed: ${String(completeErr)}`));
712
723
  }
713
724
  finally {
725
+ deps.shutdownSignal?.removeEventListener("abort", abortForRuntimeShutdown);
714
726
  sessionClosing = true;
715
727
  clearInterval(heartbeatTimer);
716
728
  heartbeatGuard.stop();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.50",
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",