@sagentlab/navarch-runtime 0.1.46 → 0.1.47

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
@@ -204,7 +204,62 @@ restart, not a new dispatch:
204
204
 
205
205
  The daemon logs `restarting the agent turn in the same worktree` when it
206
206
  delivers guidance. Lowering the lease-heartbeat interval makes guidance arrive
207
- sooner, but keep it comfortably below the 15-minute lease TTL.
207
+ sooner, but keep it comfortably below the 60-minute lease TTL.
208
+
209
+ Temporary DNS, connection, timeout, rate-limit, and server failures during a
210
+ lease heartbeat are retried at the configured heartbeat interval while the
211
+ last server-confirmed lease remains valid. A separate expiry timer aborts the
212
+ agent at that deadline even if a renewal request is still pending. Successful
213
+ renewals move the deadline; transport failures never extend it. Terminal lease
214
+ rejections still abort immediately. This tolerance applies to lease heartbeats;
215
+ completion and transcript upload failures retain their existing behavior.
216
+
217
+ ## Long-running jobs and recovery
218
+
219
+ There is no default total-duration cutoff. Successful lease heartbeats renew
220
+ ownership independently of agent progress, so a healthy job can run for hours.
221
+ The default watchdog instead requires 45 minutes of observed inactivity before
222
+ stopping an agent. Stdout/stderr, changing CPU time, and changes in the child
223
+ process tree restart that window. Silent CPU-intensive builds and tests count
224
+ as active. On Unix hosts, agents use a separate process group so cancellation also stops
225
+ their tools; Docker sessions stop their dedicated container.
226
+
227
+ The watchdog samples `ps` on the host or `docker top` for a container every
228
+ 30 seconds. CPU activity is a liveness heuristic, not proof of useful progress:
229
+ a busy loop or repeated output can keep the job alive. Quiet external waits
230
+ may need a larger idle window or idle detection disabled. If process telemetry
231
+ is unavailable, silence alone does not trigger an idle kill; an explicitly
232
+ configured absolute limit and lease expiry still apply.
233
+
234
+ Operators can set the environment defaults above. Existing launch commands
235
+ that explicitly set `NAVARCH_SESSION_TIMEOUT_MS` (including older generated
236
+ Docker setup commands) retain that cap; remove it or set it to `0` to adopt
237
+ unlimited total runtime. Task creators/editors can set
238
+ `max_runtime_ms` and `idle_timeout_ms` through task creation, PATCH, or the task
239
+ MCP tools. A nonnegative integer overrides the machine default, `0` disables
240
+ that limit, and `null` inherits it. Values are read at the next claim and apply
241
+ to machine-runtime sessions; the separate managed hosted executor retains its
242
+ own budgets. An absolute limit covers all guidance/remediation turns within the
243
+ claim, rather than resetting with each turn. These are runtime limits, not
244
+ provider spending caps.
245
+
246
+ After a watchdog stop or lease loss during a turn, before worktree cleanup, the runtime attempts a private
247
+ local recovery checkpoint at
248
+ `<workspaceRoot>/checkpoints/<project>/<task>/<session>/`. It contains a Git
249
+ bundle of committed history, a binary patch for dirty tracked files, untracked
250
+ non-ignored files, and recovery instructions. The bundle excludes repository
251
+ configuration and broker credentials. Checkpoints are not pushed or uploaded;
252
+ timeout reports and runtime logs name their location. They survive session cleanup and need
253
+ manual recovery and removal on that machine. Ignored files, agent conversation
254
+ state, and checkpoints across machine/disk loss are not provided.
255
+
256
+ If checkpoint creation fails, the original working copy is retained instead of
257
+ being force-deleted. The runtime removes session metadata and broker credentials,
258
+ detaches the working copy without discarding dirty files, and writes a recovery
259
+ marker. The janitor skips marked directories; a retry refuses to delete a marked
260
+ working copy if detachment failed. Recover/remove it manually. Container teardown
261
+ has a bounded timeout, and cancellation also stops the local Docker client, so an
262
+ unresponsive daemon cannot keep the session waiting indefinitely.
208
263
 
209
264
  ## Automatic and manual runtime upgrades
210
265
 
@@ -302,8 +357,9 @@ unchanged across the deployment.
302
357
  | `NAVARCH_OWNER_ZONE` | `sagentlab` | `sagentlab` or `customer-<slug>-premises` (project-plan.md §3.11). |
303
358
  | `NAVARCH_POLL_INTERVAL_MS` | `30000` | Claim-loop poll interval. |
304
359
  | `NAVARCH_HEARTBEAT_INTERVAL_MS` | `60000` | Machine-level heartbeat interval. |
305
- | `NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS` | `300000` | Per-lease heartbeat interval; must stay well under the 15-minute lease TTL (schema-design.md §4). |
306
- | `NAVARCH_SESSION_TIMEOUT_MS` | `2700000` (45 min) | Hard kill timeout for a single session. |
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
+ | `NAVARCH_SESSION_TIMEOUT_MS` | `0` (disabled) | Optional absolute runtime budget across agent turns in a claimed session. |
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. |
307
363
  | `NAVARCH_WORKTREE_CLEANUP_INTERVAL_MS` | `3600000` (1 hour) | How often the runtime scans for session worktrees abandoned by a hard exit or host restart. |
308
364
  | `NAVARCH_WORKTREE_STALE_AFTER_MS` | `86400000` (24 hours) | Minimum inactivity age before an abandoned session worktree is removed. Active sessions are always protected. |
309
365
  | `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. |
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ActivityWatchdog = void 0;
4
+ exports.processActivity = processActivity;
5
+ exports.processTreeSignature = processTreeSignature;
6
+ const node_child_process_1 = require("node:child_process");
7
+ const node_perf_hooks_1 = require("node:perf_hooks");
8
+ /** A renewable inactivity deadline, separate from an optional absolute budget. */
9
+ class ActivityWatchdog {
10
+ options;
11
+ lastActivity;
12
+ startedAt;
13
+ now;
14
+ signature = null;
15
+ observed = false;
16
+ stopped = false;
17
+ polling = false;
18
+ pollTimer;
19
+ hardTimer;
20
+ constructor(options) {
21
+ this.options = options;
22
+ this.now = options.now ?? (() => node_perf_hooks_1.performance.now());
23
+ this.startedAt = this.lastActivity = this.now();
24
+ if (options.idleTimeoutMs > 0) {
25
+ this.pollTimer = setInterval(() => void this.poll(), Math.min(30_000, options.idleTimeoutMs / 4));
26
+ }
27
+ if (options.timeoutMs > 0)
28
+ this.armHardLimit();
29
+ }
30
+ activity() {
31
+ if (!this.stopped)
32
+ this.lastActivity = this.now();
33
+ }
34
+ stop() {
35
+ this.stopped = true;
36
+ clearInterval(this.pollTimer);
37
+ clearTimeout(this.hardTimer);
38
+ }
39
+ armHardLimit() {
40
+ const remaining = this.options.timeoutMs - (this.now() - this.startedAt);
41
+ this.hardTimer = setTimeout(() => {
42
+ if (this.stopped)
43
+ return;
44
+ if (this.now() - this.startedAt >= this.options.timeoutMs)
45
+ this.expire("duration");
46
+ else
47
+ this.armHardLimit();
48
+ }, Math.min(remaining, 2_147_483_647));
49
+ }
50
+ async poll() {
51
+ if (this.stopped || this.polling)
52
+ return;
53
+ this.polling = true;
54
+ try {
55
+ const signature = await this.options.probe().catch(() => null);
56
+ if (this.stopped)
57
+ return;
58
+ // Missing process telemetry is not evidence of a stalled agent. Require
59
+ // a fresh uninterrupted observation window when telemetry recovers.
60
+ if (signature === null) {
61
+ this.observed = false;
62
+ return;
63
+ }
64
+ if (!this.observed || signature !== this.signature)
65
+ this.activity();
66
+ this.signature = signature;
67
+ this.observed = true;
68
+ if (this.now() - this.lastActivity >= this.options.idleTimeoutMs)
69
+ this.expire("idle");
70
+ }
71
+ finally {
72
+ this.polling = false;
73
+ }
74
+ }
75
+ expire(reason) {
76
+ this.stop();
77
+ this.options.onTimeout(reason);
78
+ }
79
+ }
80
+ exports.ActivityWatchdog = ActivityWatchdog;
81
+ /** No command arguments or environment values are collected from other jobs. */
82
+ async function processActivity(pid, containerName) {
83
+ if (!containerName && pid === undefined)
84
+ return null;
85
+ const output = await new Promise((resolve) => {
86
+ (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
+ });
88
+ return output === null ? null : processTreeSignature(output, containerName ? undefined : pid);
89
+ }
90
+ function processTreeSignature(output, rootPid) {
91
+ const rows = output.split("\n").flatMap((line) => {
92
+ const match = /^\s*(\d+)\s+(\d+)\s+([\d:.\-]+)\s*$/.exec(line);
93
+ return match ? [{ pid: Number(match[1]), parent: Number(match[2]), cpu: match[3] }] : [];
94
+ });
95
+ if (!rows.length || (rootPid !== undefined && !rows.some((row) => row.pid === rootPid)))
96
+ return null;
97
+ const included = new Set(rootPid === undefined ? rows.map((row) => row.pid) : [rootPid]);
98
+ let changed = true;
99
+ while (changed) {
100
+ changed = false;
101
+ for (const row of rows) {
102
+ if (included.has(row.parent) && !included.has(row.pid)) {
103
+ included.add(row.pid);
104
+ changed = true;
105
+ }
106
+ }
107
+ }
108
+ return rows.filter((row) => included.has(row.pid)).sort((a, b) => a.pid - b.pid)
109
+ .map((row) => `${row.pid}:${row.cpu}`).join(",");
110
+ }
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runWithActivityWatchdog = runWithActivityWatchdog;
4
+ const activity_watchdog_cjs_1 = require("./activity-watchdog.cjs");
5
+ /** Shared policy for every host/Docker/ACP adapter; adapters still own cancellation. */
6
+ async function runWithActivityWatchdog(adapter, options, idleTimeoutMs) {
7
+ const controller = new AbortController();
8
+ const onAbort = () => controller.abort();
9
+ options.signal?.addEventListener("abort", onAbort, { once: true });
10
+ if (options.signal?.aborted)
11
+ onAbort();
12
+ let pid;
13
+ let timeoutReason;
14
+ const watchdog = new activity_watchdog_cjs_1.ActivityWatchdog({
15
+ idleTimeoutMs,
16
+ timeoutMs: options.timeoutMs,
17
+ probe: () => (0, activity_watchdog_cjs_1.processActivity)(pid, options.dockerExec?.containerName),
18
+ onTimeout: (reason) => {
19
+ timeoutReason = reason;
20
+ controller.abort();
21
+ },
22
+ });
23
+ try {
24
+ const result = await adapter.run({
25
+ ...options,
26
+ // One policy handles both hard and idle limits, including Docker.
27
+ timeoutMs: 0,
28
+ signal: controller.signal,
29
+ onProcessStarted: (value) => { pid = value; options.onProcessStarted?.(value); },
30
+ onActivity: () => { watchdog.activity(); options.onActivity?.(); },
31
+ });
32
+ return timeoutReason && !options.signal?.aborted
33
+ ? { ...result, timedOut: true, timeoutReason, killedByLeaseLoss: false }
34
+ : result;
35
+ }
36
+ finally {
37
+ watchdog.stop();
38
+ options.signal?.removeEventListener("abort", onAbort);
39
+ }
40
+ }
@@ -5,6 +5,7 @@ exports.runAcpAdapter = runAcpAdapter;
5
5
  const node_child_process_1 = require("node:child_process");
6
6
  const node_fs_1 = require("node:fs");
7
7
  const node_stream_1 = require("node:stream");
8
+ const kill_agent_cjs_1 = require("../kill-agent.cjs");
8
9
  /**
9
10
  * How long a cancelled agent may take to observe `session/cancel` and settle
10
11
  * the in-flight `session/prompt` before the subprocess is terminated. The
@@ -30,6 +31,7 @@ async function runAcpAdapter(options) {
30
31
  const acp = await import("@agentclientprotocol/sdk");
31
32
  const mcpServers = await readMcpServers(options.mcpConfigPath, options.env);
32
33
  const child = spawnAcpAgent(options);
34
+ options.onProcessStarted?.(child.pid);
33
35
  const rawStdout = [];
34
36
  const stderr = [];
35
37
  const protocolInput = new node_stream_1.PassThrough();
@@ -47,12 +49,16 @@ async function runAcpAdapter(options) {
47
49
  settlePrompt = resolve;
48
50
  });
49
51
  child.stdout.on("data", (chunk) => {
52
+ options.onActivity?.();
50
53
  rawStdout.push(chunk);
51
54
  protocolInput.write(chunk);
52
55
  });
53
56
  child.stdout.on("end", () => protocolInput.end());
54
57
  child.stderr.setEncoding("utf8");
55
- child.stderr.on("data", (chunk) => stderr.push(chunk));
58
+ child.stderr.on("data", (chunk) => {
59
+ options.onActivity?.();
60
+ stderr.push(chunk);
61
+ });
56
62
  child.on("error", (error) => {
57
63
  stderr.push(`\n${describeError(error)}`);
58
64
  protocolInput.end();
@@ -88,10 +94,10 @@ async function runAcpAdapter(options) {
88
94
  connection.close();
89
95
  })();
90
96
  }, { once: true });
91
- const timer = setTimeout(() => {
97
+ const timer = options.timeoutMs > 0 ? setTimeout(() => {
92
98
  timedOut = true;
93
99
  controller.abort();
94
- }, options.timeoutMs);
100
+ }, options.timeoutMs) : undefined;
95
101
  const onAbort = () => {
96
102
  killedByLeaseLoss = true;
97
103
  controller.abort();
@@ -126,7 +132,7 @@ async function runAcpAdapter(options) {
126
132
  stopReason = response.stopReason;
127
133
  await Promise.race([
128
134
  context.request(acp.methods.agent.session.close, { sessionId }).catch(() => undefined),
129
- new Promise((resolve) => setTimeout(resolve, Math.min(5_000, options.timeoutMs))),
135
+ new Promise((resolve) => setTimeout(resolve, options.timeoutMs > 0 ? Math.min(5_000, options.timeoutMs) : 5_000)),
130
136
  ]);
131
137
  }
132
138
  catch (error) {
@@ -225,6 +231,7 @@ function spawnAcpAgent(options) {
225
231
  const env = { ...process.env, ...options.env };
226
232
  if (!options.dockerExec) {
227
233
  return (0, node_child_process_1.spawn)(options.bin, options.extraArgs, {
234
+ detached: process.platform !== "win32",
228
235
  cwd: options.cwd,
229
236
  env,
230
237
  stdio: ["pipe", "pipe", "pipe"],
@@ -247,16 +254,18 @@ function spawnAcpAgent(options) {
247
254
  }
248
255
  async function terminateChild(child, options, forceContainerKill) {
249
256
  if (child.exitCode === null && child.signalCode === null) {
250
- child.kill("SIGTERM");
257
+ (0, kill_agent_cjs_1.killAgent)(child, "SIGTERM");
251
258
  await Promise.race([
252
259
  new Promise((resolve) => child.once("close", () => resolve())),
253
260
  new Promise((resolve) => setTimeout(resolve, 1_000)),
254
261
  ]);
255
262
  if (child.exitCode === null && child.signalCode === null)
256
- child.kill("SIGKILL");
263
+ (0, kill_agent_cjs_1.killAgent)(child);
257
264
  }
265
+ if (!options.dockerExec && forceContainerKill)
266
+ (0, kill_agent_cjs_1.killAgent)(child);
258
267
  if (options.dockerExec && forceContainerKill) {
259
- await options.dockerExec.runner.run("docker", ["kill", options.dockerExec.containerName]).catch(() => undefined);
268
+ await options.dockerExec.runner.run("docker", ["kill", options.dockerExec.containerName], { timeoutMs: 10_000 }).catch(() => undefined);
260
269
  }
261
270
  }
262
271
  function describeError(error) {
@@ -4,6 +4,7 @@ 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 kill_agent_cjs_1 = require("../kill-agent.cjs");
7
8
  const adapter_capacity_cjs_1 = require("../adapter-capacity.cjs");
8
9
  const DEFAULT_CLAUDE_MODEL = "claude-opus-5";
9
10
  const FALLBACK_CLAUDE_MODEL = "claude-opus-5";
@@ -39,9 +40,9 @@ async function runClaudeCodeAdapter(options) {
39
40
  !hasFlag(options.extraArgs, "--fallback-model");
40
41
  const startedAt = Date.now();
41
42
  const primary = await runClaudeAttempt({ ...options, model }, automaticFallback);
42
- const remainingMs = options.timeoutMs - (Date.now() - startedAt);
43
+ const remainingMs = options.timeoutMs > 0 ? options.timeoutMs - (Date.now() - startedAt) : 0;
43
44
  if (!automaticFallback || primary.timedOut || primary.killedByLeaseLoss ||
44
- options.signal?.aborted || remainingMs <= 0)
45
+ options.signal?.aborted || (options.timeoutMs > 0 && remainingMs <= 0))
45
46
  return primary;
46
47
  const parsed = (0, exit_conditions_cjs_1.parseClaudeJsonResult)(primary.stdout) ?? (0, exit_conditions_cjs_1.parseClaudeJsonResult)(primary.stderr);
47
48
  // Native fallback handles overload/unavailability, but explicitly excludes
@@ -175,6 +176,7 @@ async function runOnHost(options, args) {
175
176
  let timedOut = false;
176
177
  let killedByLeaseLoss = false;
177
178
  const child = (0, node_child_process_1.spawn)(options.bin, args, {
179
+ detached: process.platform !== "win32",
178
180
  cwd: options.cwd,
179
181
  env: { ...process.env, ...options.env },
180
182
  });
@@ -183,19 +185,24 @@ async function runOnHost(options, args) {
183
185
  // supplied as an argument. Leaving it open makes Claude wait and emit a
184
186
  // "no stdin data received" warning that the runtime treats as failure.
185
187
  child.stdin?.end();
186
- const timer = setTimeout(() => {
188
+ options.onProcessStarted?.(child.pid);
189
+ const timer = options.timeoutMs > 0 ? setTimeout(() => {
187
190
  timedOut = true;
188
- child.kill("SIGKILL");
189
- }, options.timeoutMs);
191
+ (0, kill_agent_cjs_1.killAgent)(child);
192
+ }, options.timeoutMs) : undefined;
190
193
  const onAbort = () => {
191
194
  killedByLeaseLoss = true;
192
- child.kill("SIGKILL");
195
+ (0, kill_agent_cjs_1.killAgent)(child);
193
196
  };
194
197
  options.signal?.addEventListener("abort", onAbort, { once: true });
198
+ if (options.signal?.aborted)
199
+ onAbort();
195
200
  child.stdout.on("data", (d) => {
201
+ options.onActivity?.();
196
202
  stdout += d.toString();
197
203
  });
198
204
  child.stderr.on("data", (d) => {
205
+ options.onActivity?.();
199
206
  stderr += d.toString();
200
207
  });
201
208
  child.on("error", (err) => {
@@ -218,16 +225,19 @@ async function runViaDocker(options, args) {
218
225
  let killedByLeaseLoss = false;
219
226
  const onAbort = () => {
220
227
  killedByLeaseLoss = true;
221
- // Best-effort: kill the exec'd process inside the container. The runner
222
- // call below still resolves once `docker exec` itself is torn down by
223
- // its own timeout/kill; a true "kill this docker exec now" requires
224
- // process-group tracking that nodeCommandRunner does not yet expose.
225
- runner.run("docker", ["kill", containerName]).catch(() => undefined);
228
+ // Ask the daemon to stop the container, with its own deadline. The
229
+ // forwarded signal independently cancels the local exec client below,
230
+ // including when the daemon cannot answer this request.
231
+ runner.run("docker", ["kill", containerName], { timeoutMs: 10_000 }).catch(() => undefined);
226
232
  };
227
233
  options.signal?.addEventListener("abort", onAbort, { once: true });
234
+ if (options.signal?.aborted)
235
+ onAbort();
228
236
  try {
229
237
  const result = await runner.run("docker", ["exec", containerName, "sh", "-c", command], {
230
238
  timeoutMs: options.timeoutMs,
239
+ onActivity: options.onActivity,
240
+ signal: options.signal,
231
241
  });
232
242
  return {
233
243
  exitCode: result.code,
@@ -7,6 +7,7 @@ const node_child_process_1 = require("node:child_process");
7
7
  const node_fs_1 = require("node:fs");
8
8
  const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
9
9
  const codex_pricing_cjs_1 = require("../codex-pricing.cjs");
10
+ const kill_agent_cjs_1 = require("../kill-agent.cjs");
10
11
  /**
11
12
  * Headless OpenAI Codex CLI adapter — the Codex sibling of claude.cts's
12
13
  * `runClaudeCodeAdapter`, implementing the same AgentAdapter interface
@@ -168,6 +169,7 @@ async function runOnHost(options, args) {
168
169
  let timedOut = false;
169
170
  let killedByLeaseLoss = false;
170
171
  const child = (0, node_child_process_1.spawn)(options.bin, args, {
172
+ detached: process.platform !== "win32",
171
173
  cwd: options.cwd,
172
174
  env: { ...process.env, ...options.env },
173
175
  });
@@ -175,19 +177,24 @@ async function runOnHost(options, args) {
175
177
  // a pipe by default, so close it immediately or it can wait forever for
176
178
  // input even though the full prompt was supplied as an argument.
177
179
  child.stdin?.end();
178
- const timer = setTimeout(() => {
180
+ options.onProcessStarted?.(child.pid);
181
+ const timer = options.timeoutMs > 0 ? setTimeout(() => {
179
182
  timedOut = true;
180
- child.kill("SIGKILL");
181
- }, options.timeoutMs);
183
+ (0, kill_agent_cjs_1.killAgent)(child);
184
+ }, options.timeoutMs) : undefined;
182
185
  const onAbort = () => {
183
186
  killedByLeaseLoss = true;
184
- child.kill("SIGKILL");
187
+ (0, kill_agent_cjs_1.killAgent)(child);
185
188
  };
186
189
  options.signal?.addEventListener("abort", onAbort, { once: true });
190
+ if (options.signal?.aborted)
191
+ onAbort();
187
192
  child.stdout.on("data", (d) => {
193
+ options.onActivity?.();
188
194
  stdout += d.toString();
189
195
  });
190
196
  child.stderr.on("data", (d) => {
197
+ options.onActivity?.();
191
198
  stderr += d.toString();
192
199
  });
193
200
  child.on("error", (err) => {
@@ -210,12 +217,13 @@ async function runViaDocker(options, args) {
210
217
  let killedByLeaseLoss = false;
211
218
  const onAbort = () => {
212
219
  killedByLeaseLoss = true;
213
- // Best-effort: kill the exec'd process inside the container — see
214
- // claude.cts's identical runViaDocker for the same caveat about
215
- // nodeCommandRunner not exposing process-group tracking.
216
- runner.run("docker", ["kill", containerName]).catch(() => undefined);
220
+ // Container shutdown is bounded independently from cancellation of the
221
+ // local docker exec process, which receives the signal below.
222
+ runner.run("docker", ["kill", containerName], { timeoutMs: 10_000 }).catch(() => undefined);
217
223
  };
218
224
  options.signal?.addEventListener("abort", onAbort, { once: true });
225
+ if (options.signal?.aborted)
226
+ onAbort();
219
227
  try {
220
228
  // Forward values through the docker CLI process environment and put only
221
229
  // variable names in argv. This keeps generated MCP bearer/header values
@@ -223,6 +231,8 @@ async function runViaDocker(options, args) {
223
231
  const forwardedEnv = Object.keys(options.env).flatMap((name) => ["--env", name]);
224
232
  const result = await runner.run("docker", ["exec", ...forwardedEnv, containerName, "sh", "-c", command], {
225
233
  timeoutMs: options.timeoutMs,
234
+ onActivity: options.onActivity,
235
+ signal: options.signal,
226
236
  env: { ...process.env, ...options.env },
227
237
  });
228
238
  return {
@@ -4,6 +4,7 @@ exports.geminiAdapter = void 0;
4
4
  exports.runGeminiAdapter = runGeminiAdapter;
5
5
  const node_child_process_1 = require("node:child_process");
6
6
  const node_fs_1 = require("node:fs");
7
+ const kill_agent_cjs_1 = require("../kill-agent.cjs");
7
8
  /**
8
9
  * Headless Google Gemini CLI adapter. Gemini's `stream-json` output gives the
9
10
  * same normalized boundary as the Claude and Codex adapters: raw process
@@ -152,23 +153,29 @@ async function runOnHost(options, args) {
152
153
  let timedOut = false;
153
154
  let killedByLeaseLoss = false;
154
155
  const child = (0, node_child_process_1.spawn)(options.bin, args, {
156
+ detached: process.platform !== "win32",
155
157
  cwd: options.cwd,
156
158
  env: { ...process.env, ...options.env },
157
159
  });
158
160
  child.stdin?.end();
159
- const timer = setTimeout(() => {
161
+ options.onProcessStarted?.(child.pid);
162
+ const timer = options.timeoutMs > 0 ? setTimeout(() => {
160
163
  timedOut = true;
161
- child.kill("SIGKILL");
162
- }, options.timeoutMs);
164
+ (0, kill_agent_cjs_1.killAgent)(child);
165
+ }, options.timeoutMs) : undefined;
163
166
  const onAbort = () => {
164
167
  killedByLeaseLoss = true;
165
- child.kill("SIGKILL");
168
+ (0, kill_agent_cjs_1.killAgent)(child);
166
169
  };
167
170
  options.signal?.addEventListener("abort", onAbort, { once: true });
171
+ if (options.signal?.aborted)
172
+ onAbort();
168
173
  child.stdout.on("data", (data) => {
174
+ options.onActivity?.();
169
175
  stdout += data.toString();
170
176
  });
171
177
  child.stderr.on("data", (data) => {
178
+ options.onActivity?.();
172
179
  stderr += data.toString();
173
180
  });
174
181
  child.on("error", (err) => {
@@ -193,12 +200,18 @@ async function runViaDocker(options, args) {
193
200
  let killedByLeaseLoss = false;
194
201
  const onAbort = () => {
195
202
  killedByLeaseLoss = true;
196
- runner.run("docker", ["kill", containerName]).catch(() => undefined);
203
+ runner.run("docker", ["kill", containerName], { timeoutMs: 10_000 }).catch(() => undefined);
197
204
  };
198
205
  options.signal?.addEventListener("abort", onAbort, { once: true });
206
+ if (options.signal?.aborted)
207
+ onAbort();
199
208
  try {
200
209
  const forwardedEnv = Object.keys(options.env).flatMap((name) => ["--env", name]);
201
- const result = await runner.run("docker", ["exec", ...forwardedEnv, containerName, "sh", "-c", command], { timeoutMs: options.timeoutMs, env: { ...process.env, ...options.env } });
210
+ const result = await runner.run("docker", ["exec", ...forwardedEnv, containerName, "sh", "-c", command], { timeoutMs: options.timeoutMs,
211
+ onActivity: options.onActivity,
212
+ signal: options.signal,
213
+ env: { ...process.env, ...options.env },
214
+ });
202
215
  return {
203
216
  exitCode: result.code,
204
217
  timedOut: false,
@@ -48,7 +48,7 @@ function selectAdapter(agentType) {
48
48
  // Exhaustiveness guard: config.cts only ever produces the values
49
49
  // above, but fall back to Claude Code rather than throwing if this
50
50
  // widens in the future without every caller being updated.
51
- const _exhaustive = agentType;
51
+ void agentType;
52
52
  return claude_cjs_1.claudeCodeAdapter;
53
53
  }
54
54
  }
@@ -5,6 +5,7 @@ exports.runOpenCodeAdapter = runOpenCodeAdapter;
5
5
  const node_child_process_1 = require("node:child_process");
6
6
  const node_fs_1 = require("node:fs");
7
7
  const config_cjs_1 = require("../config.cjs");
8
+ const kill_agent_cjs_1 = require("../kill-agent.cjs");
8
9
  /**
9
10
  * Headless OpenCode adapter, fixture-pinned to the OpenCode 1.18 JSON/config
10
11
  * contract:
@@ -191,23 +192,29 @@ async function runOnHost(options, args) {
191
192
  let timedOut = false;
192
193
  let killedByLeaseLoss = false;
193
194
  const child = (0, node_child_process_1.spawn)(options.bin, args, {
195
+ detached: process.platform !== "win32",
194
196
  cwd: options.cwd,
195
197
  env: { ...process.env, ...options.env },
196
198
  });
197
199
  child.stdin?.end();
198
- const timer = setTimeout(() => {
200
+ options.onProcessStarted?.(child.pid);
201
+ const timer = options.timeoutMs > 0 ? setTimeout(() => {
199
202
  timedOut = true;
200
- child.kill("SIGKILL");
201
- }, options.timeoutMs);
203
+ (0, kill_agent_cjs_1.killAgent)(child);
204
+ }, options.timeoutMs) : undefined;
202
205
  const onAbort = () => {
203
206
  killedByLeaseLoss = true;
204
- child.kill("SIGKILL");
207
+ (0, kill_agent_cjs_1.killAgent)(child);
205
208
  };
206
209
  options.signal?.addEventListener("abort", onAbort, { once: true });
210
+ if (options.signal?.aborted)
211
+ onAbort();
207
212
  child.stdout.on("data", (data) => {
213
+ options.onActivity?.();
208
214
  stdout += data.toString();
209
215
  });
210
216
  child.stderr.on("data", (data) => {
217
+ options.onActivity?.();
211
218
  stderr += data.toString();
212
219
  });
213
220
  child.on("error", (error) => {
@@ -230,12 +237,18 @@ async function runViaDocker(options, args) {
230
237
  let killedByLeaseLoss = false;
231
238
  const onAbort = () => {
232
239
  killedByLeaseLoss = true;
233
- runner.run("docker", ["kill", containerName]).catch(() => undefined);
240
+ runner.run("docker", ["kill", containerName], { timeoutMs: 10_000 }).catch(() => undefined);
234
241
  };
235
242
  options.signal?.addEventListener("abort", onAbort, { once: true });
243
+ if (options.signal?.aborted)
244
+ onAbort();
236
245
  try {
237
246
  const forwardedEnv = Object.keys(options.env).flatMap((name) => ["--env", name]);
238
- const result = await runner.run("docker", ["exec", ...forwardedEnv, containerName, "sh", "-c", command], { timeoutMs: options.timeoutMs, env: { ...process.env, ...options.env } });
247
+ const result = await runner.run("docker", ["exec", ...forwardedEnv, containerName, "sh", "-c", command], { timeoutMs: options.timeoutMs,
248
+ onActivity: options.onActivity,
249
+ signal: options.signal,
250
+ env: { ...process.env, ...options.env },
251
+ });
239
252
  return {
240
253
  exitCode: result.code,
241
254
  timedOut: false,
@@ -13,6 +13,7 @@ const node_child_process_1 = require("node:child_process");
13
13
  const node_fs_1 = require("node:fs");
14
14
  const node_os_1 = __importDefault(require("node:os"));
15
15
  const node_path_1 = __importDefault(require("node:path"));
16
+ const kill_agent_cjs_1 = require("../kill-agent.cjs");
16
17
  /**
17
18
  * Headless Pi coding-agent adapter (#885) — the process-side twin of the
18
19
  * Cloudflare hosted executor (worker/hosted-pi/lifecycle.ts). It invokes
@@ -262,23 +263,29 @@ async function runOnHost(options, args) {
262
263
  let timedOut = false;
263
264
  let killedByLeaseLoss = false;
264
265
  const child = (0, node_child_process_1.spawn)(options.bin, boundedArgs, {
266
+ detached: process.platform !== "win32",
265
267
  cwd: options.cwd,
266
268
  env: { ...process.env, ...env },
267
269
  });
268
270
  child.stdin?.end();
269
- const timer = setTimeout(() => {
271
+ options.onProcessStarted?.(child.pid);
272
+ const timer = options.timeoutMs > 0 ? setTimeout(() => {
270
273
  timedOut = true;
271
- child.kill("SIGKILL");
272
- }, options.timeoutMs);
274
+ (0, kill_agent_cjs_1.killAgent)(child);
275
+ }, options.timeoutMs) : undefined;
273
276
  const onAbort = () => {
274
277
  killedByLeaseLoss = true;
275
- child.kill("SIGKILL");
278
+ (0, kill_agent_cjs_1.killAgent)(child);
276
279
  };
277
280
  options.signal?.addEventListener("abort", onAbort, { once: true });
281
+ if (options.signal?.aborted)
282
+ onAbort();
278
283
  child.stdout.on("data", (data) => {
284
+ options.onActivity?.();
279
285
  stdout += data.toString();
280
286
  });
281
287
  child.stderr.on("data", (data) => {
288
+ options.onActivity?.();
282
289
  stderr += data.toString();
283
290
  });
284
291
  child.on("error", (err) => {
@@ -328,13 +335,17 @@ async function runViaDocker(options, args) {
328
335
  let killedByLeaseLoss = false;
329
336
  const onAbort = () => {
330
337
  killedByLeaseLoss = true;
331
- runner.run("docker", ["kill", containerName]).catch(() => undefined);
338
+ runner.run("docker", ["kill", containerName], { timeoutMs: 10_000 }).catch(() => undefined);
332
339
  };
333
340
  options.signal?.addEventListener("abort", onAbort, { once: true });
341
+ if (options.signal?.aborted)
342
+ onAbort();
334
343
  try {
335
344
  const forwardedEnv = Object.keys(options.env).flatMap((name) => ["--env", name]);
336
345
  const result = await runner.run("docker", ["exec", ...forwardedEnv, containerName, "sh", "-c", command], {
337
346
  timeoutMs: options.timeoutMs,
347
+ onActivity: options.onActivity,
348
+ signal: options.signal,
338
349
  env: { ...process.env, ...options.env },
339
350
  });
340
351
  return {
package/dist/config.cjs CHANGED
@@ -26,6 +26,13 @@ function envInt(env, name, fallback) {
26
26
  const n = Number(raw);
27
27
  return Number.isFinite(n) && n > 0 ? n : fallback;
28
28
  }
29
+ function envRuntimeLimit(env, name, fallback) {
30
+ const raw = env[name]?.trim();
31
+ if (!raw)
32
+ return fallback;
33
+ const value = Number(raw);
34
+ return Number.isSafeInteger(value) && value >= 0 ? value : fallback;
35
+ }
29
36
  /** Default `$CODEX_HOME`, matching the Codex CLI's own resolution. */
30
37
  function defaultCodexHome(env) {
31
38
  return env.CODEX_HOME ?? node_path_1.default.join(node_os_1.default.homedir(), ".codex");
@@ -96,10 +103,11 @@ function loadRuntimeConfig(env = process.env) {
96
103
  ownerZone: env.NAVARCH_OWNER_ZONE ?? "sagentlab",
97
104
  pollIntervalMs: envInt(env, "NAVARCH_POLL_INTERVAL_MS", 30_000),
98
105
  machineHeartbeatIntervalMs: envInt(env, "NAVARCH_HEARTBEAT_INTERVAL_MS", 60_000),
99
- // leases.expires_at = claimed_at + 15 min (schema-design.md §4) — default renewal
106
+ // leases.expires_at = claimed_at + 60 min (schema-design.md §4) — default renewal
100
107
  // interval must stay comfortably under that TTL.
101
108
  leaseHeartbeatIntervalMs,
102
- sessionTimeoutMs: envInt(env, "NAVARCH_SESSION_TIMEOUT_MS", 45 * 60 * 1000),
109
+ sessionTimeoutMs: envRuntimeLimit(env, "NAVARCH_SESSION_TIMEOUT_MS", 0),
110
+ sessionIdleTimeoutMs: envRuntimeLimit(env, "NAVARCH_SESSION_IDLE_TIMEOUT_MS", 45 * 60 * 1000),
103
111
  worktreeCleanupIntervalMs: envInt(env, "NAVARCH_WORKTREE_CLEANUP_INTERVAL_MS", 60 * 60 * 1000),
104
112
  worktreeStaleAfterMs: envInt(env, "NAVARCH_WORKTREE_STALE_AFTER_MS", 24 * 60 * 60 * 1000),
105
113
  agentType,
@@ -233,7 +233,11 @@ function mapExitCondition(result) {
233
233
  return {
234
234
  leaseOutcome: "failed",
235
235
  exitStatus: "killed",
236
- reportSummary: "Session killed: exceeded the configured max session duration.",
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.") +
239
+ (result.checkpointPath ? ` Local recovery checkpoint: ${result.checkpointPath}` : "") +
240
+ (result.retainedWorktreePath ? ` Recovery worktree retained: ${result.retainedWorktreePath}` : ""),
237
241
  evidenceUrls,
238
242
  };
239
243
  }
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.GitWorktree = void 0;
7
7
  exports.branchSlug = branchSlug;
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
+ const worktree_janitor_cjs_1 = require("./worktree-janitor.cjs");
9
10
  const node_fs_1 = require("node:fs");
10
11
  const sandbox_cjs_1 = require("./sandbox.cjs");
11
12
  const repositoryLocks = new Map();
@@ -21,6 +22,7 @@ class GitWorktree {
21
22
  repositoryPath;
22
23
  branch;
23
24
  runner;
25
+ checkpointRoot;
24
26
  cloneUrl;
25
27
  githubToken;
26
28
  repoLocalGithubToken;
@@ -32,6 +34,7 @@ class GitWorktree {
32
34
  const projectKey = safePathSegment(options.projectId);
33
35
  const sessionKey = safePathSegment(options.sessionId);
34
36
  const taskKey = safePathSegment(options.taskId);
37
+ this.checkpointRoot = node_path_1.default.join(options.workspaceRoot, "checkpoints", projectKey, taskKey, sessionKey);
35
38
  this.sessionRoot = node_path_1.default.join(options.workspaceRoot, "sessions", sessionKey);
36
39
  this.worktreePath = node_path_1.default.join(this.sessionRoot, "repo");
37
40
  this.repositoryPath = options.repoLocalGithubToken
@@ -215,6 +218,9 @@ class GitWorktree {
215
218
  holders.push(currentPath);
216
219
  }
217
220
  for (const holder of holders) {
221
+ if (await (0, worktree_janitor_cjs_1.hasRecoveryMarker)(node_path_1.default.dirname(holder))) {
222
+ throw new Error(`Worktree ${holder} is retained for recovery; restore or remove it before reusing this branch.`);
223
+ }
218
224
  // Double --force covers locked and submodule-bearing worktrees.
219
225
  await this.runner
220
226
  .run("git", ["--git-dir", this.repositoryPath, "worktree", "remove", "--force", "--force", holder])
@@ -380,6 +386,50 @@ class GitWorktree {
380
386
  }
381
387
  return { branch: this.branch, sha: head.sha };
382
388
  }
389
+ /** Save recoverable code, never the repository config or broker credentials. */
390
+ async saveCheckpoint() {
391
+ await node_fs_1.promises.mkdir(this.checkpointRoot, { recursive: true, mode: 0o700 });
392
+ const bundlePath = node_path_1.default.join(this.checkpointRoot, "commits.bundle");
393
+ await this.runGit(["-C", this.worktreePath, "bundle", "create", bundlePath, "HEAD"], false);
394
+ await node_fs_1.promises.chmod(bundlePath, 0o600);
395
+ const patch = await this.runGit(["-C", this.worktreePath, "diff", "--binary", "HEAD", "--"], false);
396
+ await node_fs_1.promises.writeFile(node_path_1.default.join(this.checkpointRoot, "working-tree.patch"), patch.stdout, { mode: 0o600 });
397
+ const untracked = await this.runGit(["-C", this.worktreePath, "ls-files", "--others", "--exclude-standard", "-z"], false);
398
+ for (const name of untracked.stdout.split("\0").filter(Boolean)) {
399
+ const source = node_path_1.default.resolve(this.worktreePath, name);
400
+ if (!source.startsWith(this.worktreePath + node_path_1.default.sep))
401
+ throw new Error("Invalid checkpoint path");
402
+ const target = node_path_1.default.join(this.checkpointRoot, "untracked", name);
403
+ await node_fs_1.promises.mkdir(node_path_1.default.dirname(target), { recursive: true, mode: 0o700 });
404
+ await node_fs_1.promises.cp(source, target, { dereference: false, verbatimSymlinks: true });
405
+ }
406
+ const head = await this.resolveCurrentHead();
407
+ await node_fs_1.promises.writeFile(node_path_1.default.join(this.checkpointRoot, "README.txt"), `Navarch local recovery checkpoint\nBranch: ${head.branch ?? "detached"}\nHEAD: ${head.sha}\n` +
408
+ "Fetch commits.bundle HEAD into a clean checkout and inspect that commit. Apply working-tree.patch with git apply, then copy files from untracked/. " +
409
+ "Ignored files and agent conversation state are not included. Nothing was pushed.\n", { mode: 0o600 });
410
+ return this.checkpointRoot;
411
+ }
412
+ /** Keep the original code when a checkpoint fails, but strip runtime secrets. */
413
+ async retainForRecovery() {
414
+ await node_fs_1.promises.writeFile(node_path_1.default.join(this.sessionRoot, worktree_janitor_cjs_1.SESSION_RECOVERY_FILENAME), "", { mode: 0o600 });
415
+ try {
416
+ const head = await this.resolveCurrentHead();
417
+ // Detach without checking out files: this also works with a conflicted
418
+ // index, preserves dirty work, and releases the delivery branch for retry.
419
+ await this.runGit(["-C", this.worktreePath, "update-ref", "--no-deref", "HEAD", head.sha], false);
420
+ }
421
+ finally {
422
+ const entries = await node_fs_1.promises.readdir(this.sessionRoot);
423
+ const keep = new Set(["repo", "repository.git", worktree_janitor_cjs_1.SESSION_RECOVERY_FILENAME]);
424
+ const cleanup = await Promise.allSettled([
425
+ this.removeRepoLocalGithubToken(),
426
+ ...entries.filter((name) => !keep.has(name)).map((name) => node_fs_1.promises.rm(node_path_1.default.join(this.sessionRoot, name), { recursive: true, force: true })),
427
+ ]);
428
+ const failures = cleanup.filter((result) => result.status === "rejected");
429
+ if (failures.length)
430
+ throw new AggregateError(failures.map((result) => result.reason), "Recovery credential cleanup failed");
431
+ }
432
+ }
383
433
  async cleanup() {
384
434
  await withRepositoryLock(this.repositoryPath, async () => {
385
435
  await this.removeRepoLocalGithubToken();
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.killAgent = killAgent;
4
+ /** Host agents have a dedicated process group so their tools stop with them. */
5
+ function killAgent(child, signal = "SIGKILL") {
6
+ if (process.platform !== "win32" && child.pid) {
7
+ try {
8
+ process.kill(-child.pid, signal);
9
+ return;
10
+ }
11
+ catch { /* Already exited or no group. */ }
12
+ }
13
+ child.kill(signal);
14
+ }
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LeaseHeartbeatGuard = void 0;
4
+ const api_cjs_1 = require("./api.cjs");
5
+ /** Transient failures never extend the last deadline confirmed by the server. */
6
+ class LeaseHeartbeatGuard {
7
+ onExpired;
8
+ deadline = 0;
9
+ timer;
10
+ stopped = false;
11
+ constructor(expiresAt, onExpired) {
12
+ this.onExpired = onExpired;
13
+ this.renew(expiresAt);
14
+ }
15
+ renew(expiresAt) {
16
+ if (this.stopped)
17
+ return;
18
+ if (this.deadline !== 0 && Date.now() >= this.deadline) {
19
+ this.expire();
20
+ return;
21
+ }
22
+ clearTimeout(this.timer);
23
+ this.deadline = Date.parse(expiresAt);
24
+ const remaining = this.deadline - Date.now();
25
+ if (!Number.isFinite(remaining) || remaining <= 0) {
26
+ this.expire();
27
+ return;
28
+ }
29
+ // Independent of polling: a hanging renewal must not outlive the lease.
30
+ this.timer = setTimeout(() => this.expire(), remaining);
31
+ }
32
+ canRetry(error) {
33
+ return !this.stopped && Date.now() < this.deadline && (error instanceof api_cjs_1.NavarchTransportError ||
34
+ (error instanceof api_cjs_1.NavarchApiError && (error.status === 408 || error.status === 429 || error.status >= 500)));
35
+ }
36
+ stop() {
37
+ this.stopped = true;
38
+ clearTimeout(this.timer);
39
+ }
40
+ expire() {
41
+ this.stop();
42
+ this.onExpired();
43
+ }
44
+ }
45
+ exports.LeaseHeartbeatGuard = LeaseHeartbeatGuard;
package/dist/sandbox.cjs CHANGED
@@ -8,6 +8,7 @@ exports.isDockerAvailable = isDockerAvailable;
8
8
  const node_child_process_1 = require("node:child_process");
9
9
  const node_fs_1 = require("node:fs");
10
10
  const node_path_1 = __importDefault(require("node:path"));
11
+ const kill_agent_cjs_1 = require("./kill-agent.cjs");
11
12
  const sandbox_profile_cjs_1 = require("./sandbox-profile.cjs");
12
13
  class SandboxUnavailableError extends Error {
13
14
  }
@@ -15,36 +16,47 @@ exports.SandboxUnavailableError = SandboxUnavailableError;
15
16
  /** Real process-spawning CommandRunner. Tests inject a fake one instead — see sandbox.test.cts. */
16
17
  exports.nodeCommandRunner = {
17
18
  run(cmd, args, opts = {}) {
19
+ if (opts.signal?.aborted)
20
+ return Promise.resolve({ code: -1, stdout: "", stderr: "" });
18
21
  return new Promise((resolve, reject) => {
19
- const child = (0, node_child_process_1.spawn)(cmd, args, { cwd: opts.cwd, env: opts.env });
22
+ const child = (0, node_child_process_1.spawn)(cmd, args, { cwd: opts.cwd, env: opts.env, detached: process.platform !== "win32" });
20
23
  let stdout = "";
21
24
  let stderr = "";
22
25
  let settled = false;
23
26
  const timer = opts.timeoutMs
24
27
  ? setTimeout(() => {
25
- child.kill("SIGKILL");
28
+ (0, kill_agent_cjs_1.killAgent)(child);
26
29
  }, opts.timeoutMs)
27
30
  : null;
31
+ const onAbort = () => (0, kill_agent_cjs_1.killAgent)(child);
32
+ const cleanup = () => {
33
+ if (timer)
34
+ clearTimeout(timer);
35
+ opts.signal?.removeEventListener("abort", onAbort);
36
+ };
37
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
38
+ if (opts.signal?.aborted)
39
+ onAbort();
28
40
  child.stdout?.on("data", (d) => {
41
+ opts.onActivity?.();
29
42
  stdout += d.toString();
30
43
  });
31
44
  child.stderr?.on("data", (d) => {
45
+ opts.onActivity?.();
32
46
  stderr += d.toString();
33
47
  });
34
48
  child.on("error", (err) => {
35
49
  if (settled)
36
50
  return;
37
51
  settled = true;
38
- if (timer)
39
- clearTimeout(timer);
52
+ cleanup();
40
53
  reject(err);
41
54
  });
42
55
  child.on("close", (code) => {
43
56
  if (settled)
44
57
  return;
45
58
  settled = true;
46
- if (timer)
47
- clearTimeout(timer);
59
+ cleanup();
48
60
  resolve({ code: code ?? -1, stdout, stderr });
49
61
  });
50
62
  if (opts.input !== undefined) {
@@ -252,7 +264,7 @@ class DockerSandbox {
252
264
  }
253
265
  /** Stops/removes only the container; the session orchestrator owns worktree cleanup. */
254
266
  async stop() {
255
- await this.runner.run("docker", ["rm", "-f", this.name]).catch(() => undefined);
267
+ await this.runner.run("docker", ["rm", "-f", this.name], { timeoutMs: 10_000 }).catch(() => undefined);
256
268
  }
257
269
  }
258
270
  exports.DockerSandbox = DockerSandbox;
package/dist/session.cjs CHANGED
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.runSession = runSession;
7
7
  exports.toEnvMap = toEnvMap;
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
+ const node_perf_hooks_1 = require("node:perf_hooks");
9
10
  const node_fs_1 = require("node:fs");
10
11
  const api_cjs_1 = require("./api.cjs");
11
12
  const sandbox_cjs_1 = require("./sandbox.cjs");
@@ -21,6 +22,8 @@ const git_worktree_cjs_1 = require("./git-worktree.cjs");
21
22
  const worktree_janitor_cjs_1 = require("./worktree-janitor.cjs");
22
23
  const github_pr_cjs_1 = require("./github-pr.cjs");
23
24
  const worktree_guard_cjs_1 = require("./worktree-guard.cjs");
25
+ const adapter_watchdog_cjs_1 = require("./adapter-watchdog.cjs");
26
+ const lease_heartbeat_cjs_1 = require("./lease-heartbeat.cjs");
24
27
  const adapter_capacity_cjs_1 = require("./adapter-capacity.cjs");
25
28
  /** Filename the generated platform MCP config is written under inside the session metadata directory. */
26
29
  const MCP_CONFIG_FILENAME = "mcp-config.json";
@@ -205,15 +208,42 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
205
208
  let activeAbortController = null;
206
209
  let leaseLost = false;
207
210
  let leaseGone = false;
211
+ let sessionClosing = false;
212
+ let retainWorktree = false;
208
213
  let heartbeatInFlight = null;
214
+ let heartbeatWorkspaceUpdate = null;
209
215
  const sessionOutcome = {};
216
+ const checkpointForRecovery = async (result) => {
217
+ if (result.checkpointPath || result.retainedWorktreePath)
218
+ return;
219
+ try {
220
+ result.checkpointPath = await gitWorktree.saveCheckpoint();
221
+ log.warn(`session ${leaseId} stopped for ${leaseLost ? "lease loss" : result.timeoutReason ?? "duration"}; recovery checkpoint: ${result.checkpointPath}`);
222
+ }
223
+ catch (error) {
224
+ retainWorktree = true;
225
+ result.retainedWorktreePath = gitWorktree.worktreePath;
226
+ log.warn(`checkpoint failed for ${leaseId}: ${String(error)}; retaining ${gitWorktree.worktreePath} for recovery.`);
227
+ }
228
+ };
210
229
  const pollLease = () => {
230
+ if (leaseLost || sessionClosing)
231
+ return Promise.resolve();
211
232
  if (heartbeatInFlight)
212
233
  return heartbeatInFlight;
213
234
  const request = api
214
235
  .heartbeatLease(leaseId, { guidance_after: guidanceCursor })
215
236
  .then(async (heartbeat) => {
216
- await (0, worktree_janitor_cjs_1.markSessionWorkspaceActive)(workDir);
237
+ if (sessionClosing)
238
+ return;
239
+ heartbeatGuard.renew(heartbeat.expires_at);
240
+ if (leaseLost)
241
+ return;
242
+ heartbeatWorkspaceUpdate = (0, worktree_janitor_cjs_1.markSessionWorkspaceActive)(workDir);
243
+ await heartbeatWorkspaceUpdate;
244
+ heartbeatWorkspaceUpdate = null;
245
+ if (sessionClosing)
246
+ return;
217
247
  guidanceCursor = heartbeat.guidance_cursor ?? guidanceCursor;
218
248
  const fresh = (heartbeat.guidance ?? []).filter((entry) => {
219
249
  if (knownGuidanceIds.has(entry.id))
@@ -229,9 +259,16 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
229
259
  }
230
260
  })
231
261
  .catch((err) => {
262
+ if (leaseLost || sessionClosing)
263
+ return;
264
+ if (heartbeatGuard.canRetry(err)) {
265
+ log.warn(`lease heartbeat failed for ${leaseId}: ${String(err)} — retrying on the next heartbeat while the confirmed lease remains valid.`);
266
+ return;
267
+ }
232
268
  log.warn(`lease heartbeat failed for ${leaseId}: ${String(err)} — killing session.`);
233
269
  leaseLost = true;
234
270
  leaseGone = isTerminalLeaseHeartbeatError(err);
271
+ heartbeatGuard.stop();
235
272
  activeAbortController?.abort();
236
273
  })
237
274
  .finally(() => {
@@ -345,6 +382,12 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
345
382
  // session actually produced instead of zeroing it.
346
383
  let lastCompletion = null;
347
384
  let adapterStarted = false;
385
+ const heartbeatGuard = new lease_heartbeat_cjs_1.LeaseHeartbeatGuard(claimed.expires_at, () => {
386
+ log.warn(`confirmed lease expired for ${leaseId} without renewal — killing session.`);
387
+ leaseLost = true;
388
+ leaseGone = true;
389
+ activeAbortController?.abort();
390
+ });
348
391
  const heartbeatTimer = setInterval(() => void pollLease(), config.leaseHeartbeatIntervalMs);
349
392
  lifecycle.enteredMainSessionTry = true;
350
393
  try {
@@ -370,6 +413,8 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
370
413
  const adapter = (0, index_cjs_1.selectAdapter)(runtime);
371
414
  const { bin, extraArgs } = adapterCommand(config, runtime);
372
415
  const attempts = [];
416
+ const agentBudgetStartedAt = node_perf_hooks_1.performance.now();
417
+ const maxRuntimeMs = task.max_runtime_ms ?? config.sessionTimeoutMs;
373
418
  let nextPrompt = null;
374
419
  let completionRemediationRetries = 0;
375
420
  while (true) {
@@ -387,26 +432,33 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
387
432
  nextPrompt = null;
388
433
  await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), runPrompt, "utf8");
389
434
  adapterStarted = true;
390
- const turnResult = await adapter.run({
391
- prompt: runPrompt,
392
- mcpConfigPath,
393
- bin,
394
- extraArgs,
395
- model: execution.model,
396
- reasoningEffort: execution.reasoning_effort,
397
- timeoutMs: config.sessionTimeoutMs,
398
- env: sessionEnv,
399
- settingsPath: claudeSettingsPath,
400
- codexGuardArgs,
401
- geminiSandboxMounts: geminiGuardMounts,
402
- opencodeHostGuard: runtime === "opencode" && config.sandboxMode === "host" && config.worktreeGuard,
403
- // ACP requires an absolute session cwd. Docker mounts the worktree at
404
- // this same absolute path, while the other Docker adapters ignore cwd.
405
- cwd: gitWorktree.worktreePath,
406
- dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
407
- signal: activeAbortController.signal,
408
- });
435
+ const remainingRuntimeMs = maxRuntimeMs > 0
436
+ ? maxRuntimeMs - (node_perf_hooks_1.performance.now() - agentBudgetStartedAt)
437
+ : 0;
438
+ const turnResult = maxRuntimeMs > 0 && remainingRuntimeMs <= 0
439
+ ? { exitCode: null, timedOut: true, timeoutReason: "duration", killedByLeaseLoss: false, stdout: "", stderr: "" }
440
+ : await (0, adapter_watchdog_cjs_1.runWithActivityWatchdog)(adapter, {
441
+ prompt: runPrompt,
442
+ mcpConfigPath,
443
+ bin,
444
+ extraArgs,
445
+ model: execution.model,
446
+ reasoningEffort: execution.reasoning_effort,
447
+ timeoutMs: remainingRuntimeMs,
448
+ env: sessionEnv,
449
+ settingsPath: claudeSettingsPath,
450
+ codexGuardArgs,
451
+ geminiSandboxMounts: geminiGuardMounts,
452
+ opencodeHostGuard: runtime === "opencode" && config.sandboxMode === "host" && config.worktreeGuard,
453
+ // ACP requires an absolute session cwd. Docker mounts the worktree at
454
+ // this same absolute path, while the other Docker adapters ignore cwd.
455
+ cwd: gitWorktree.worktreePath,
456
+ dockerExec: sandbox ? { containerName: sandbox.name, runner: sandbox_cjs_1.nodeCommandRunner } : undefined,
457
+ signal: activeAbortController.signal,
458
+ }, task.idle_timeout_ms ?? config.sessionIdleTimeoutMs ?? 45 * 60_000);
409
459
  activeAbortController = null;
460
+ if (turnResult.timedOut || leaseLost)
461
+ await checkpointForRecovery(turnResult);
410
462
  if (turnResult.model) {
411
463
  execution.model = turnResult.model;
412
464
  executionReport = { ...executionReport, model: turnResult.model };
@@ -424,10 +476,11 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
424
476
  if (!leaseGone)
425
477
  await pollLease();
426
478
  if (leaseGone) {
479
+ await checkpointForRecovery(turnResult);
427
480
  log.warn(`session ${leaseId} stopped without completion because its lease is no longer active.`);
428
481
  return sessionOutcome;
429
482
  }
430
- if (!leaseLost && pendingGuidance.length > 0)
483
+ if (!leaseLost && !turnResult.timedOut && pendingGuidance.length > 0)
431
484
  continue;
432
485
  const result = {
433
486
  ...turnResult,
@@ -602,8 +655,13 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
602
655
  .catch((completeErr) => log.warn(`complete() after crash also failed: ${String(completeErr)}`));
603
656
  }
604
657
  finally {
658
+ sessionClosing = true;
605
659
  clearInterval(heartbeatTimer);
660
+ heartbeatGuard.stop();
606
661
  activeAbortController?.abort();
662
+ // Drain only an already-started filesystem update, never a stalled network
663
+ // request. A later heartbeat response sees sessionClosing and does nothing.
664
+ await Promise.resolve(heartbeatWorkspaceUpdate).catch(() => undefined);
607
665
  secrets = {};
608
666
  // A sandbox teardown failure must not skip worktree cleanup: a leftover
609
667
  // task branch in the shared cache blocks the task's next retry.
@@ -614,8 +672,13 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
614
672
  catch (stopErr) {
615
673
  log.warn(`sandbox stop failed during teardown: ${String(stopErr)}`);
616
674
  }
617
- await gitWorktree.cleanup();
618
- await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
675
+ if (retainWorktree) {
676
+ await gitWorktree.retainForRecovery().catch((error) => log.error(`Recovery worktree retained at ${gitWorktree.worktreePath}, but retention setup or credential cleanup failed: ${String(error)}`));
677
+ }
678
+ else {
679
+ await gitWorktree.cleanup();
680
+ await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
681
+ }
619
682
  }
620
683
  return sessionOutcome;
621
684
  }
@@ -3,14 +3,16 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.WorktreeJanitor = exports.SESSION_ACTIVITY_FILENAME = void 0;
6
+ exports.WorktreeJanitor = exports.SESSION_RECOVERY_FILENAME = exports.SESSION_ACTIVITY_FILENAME = void 0;
7
7
  exports.markSessionWorkspaceActive = markSessionWorkspaceActive;
8
+ exports.hasRecoveryMarker = hasRecoveryMarker;
8
9
  const node_path_1 = __importDefault(require("node:path"));
9
10
  const node_fs_1 = require("node:fs");
10
11
  const logger_cjs_1 = require("./logger.cjs");
11
12
  const sandbox_cjs_1 = require("./sandbox.cjs");
12
13
  const log = (0, logger_cjs_1.createLogger)("worktree-janitor");
13
14
  exports.SESSION_ACTIVITY_FILENAME = ".navarch-active";
15
+ exports.SESSION_RECOVERY_FILENAME = ".navarch-recovery-required";
14
16
  /**
15
17
  * Refreshes the durable activity marker used by janitors in other runtime
16
18
  * processes that happen to share a workspace root.
@@ -63,7 +65,7 @@ class WorktreeJanitor {
63
65
  const summary = await this.sweep();
64
66
  if (summary.removed > 0 || summary.errors > 0) {
65
67
  log.info(`cleanup complete: scanned=${summary.scanned} removed=${summary.removed} ` +
66
- `active=${summary.skippedActive} fresh=${summary.skippedFresh} errors=${summary.errors}`);
68
+ `active=${summary.skippedActive} fresh=${summary.skippedFresh} recovery=${summary.skippedRecovery} errors=${summary.errors}`);
67
69
  }
68
70
  }
69
71
  catch (error) {
@@ -76,6 +78,7 @@ class WorktreeJanitor {
76
78
  removed: 0,
77
79
  skippedActive: 0,
78
80
  skippedFresh: 0,
81
+ skippedRecovery: 0,
79
82
  errors: 0,
80
83
  };
81
84
  const sessionsRoot = node_path_1.default.resolve(this.options.workspaceRoot, "sessions");
@@ -100,6 +103,10 @@ class WorktreeJanitor {
100
103
  continue;
101
104
  }
102
105
  try {
106
+ if (await hasRecoveryMarker(sessionRoot)) {
107
+ summary.skippedRecovery += 1;
108
+ continue;
109
+ }
103
110
  const lastActivity = await this.lastActivityAt(sessionRoot);
104
111
  if (this.now() - lastActivity < this.options.staleAfterMs) {
105
112
  summary.skippedFresh += 1;
@@ -171,3 +178,15 @@ exports.WorktreeJanitor = WorktreeJanitor;
171
178
  function isMissing(error) {
172
179
  return error instanceof Error && "code" in error && error.code === "ENOENT";
173
180
  }
181
+ /** Retained work must not be removed by the janitor or a subsequent retry. */
182
+ async function hasRecoveryMarker(sessionRoot) {
183
+ try {
184
+ await node_fs_1.promises.access(node_path_1.default.join(sessionRoot, exports.SESSION_RECOVERY_FILENAME));
185
+ return true;
186
+ }
187
+ catch (error) {
188
+ if (isMissing(error))
189
+ return false;
190
+ throw error;
191
+ }
192
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.46",
3
+ "version": "0.1.47",
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",