@sagentlab/navarch-runtime 0.1.45 → 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 +62 -6
- package/dist/activity-watchdog.cjs +110 -0
- package/dist/adapter-watchdog.cjs +40 -0
- package/dist/adapters/acp.cjs +16 -7
- package/dist/adapters/claude.cjs +23 -13
- package/dist/adapters/codex.cjs +18 -8
- package/dist/adapters/gemini.cjs +19 -6
- package/dist/adapters/index.cjs +1 -1
- package/dist/adapters/opencode.cjs +19 -6
- package/dist/adapters/pi.cjs +16 -5
- package/dist/config.cjs +10 -2
- package/dist/exit-conditions.cjs +5 -1
- package/dist/git-worktree.cjs +50 -0
- package/dist/kill-agent.cjs +14 -0
- package/dist/lease-heartbeat.cjs +45 -0
- package/dist/sandbox.cjs +19 -7
- package/dist/session.cjs +88 -25
- package/dist/worktree-janitor.cjs +21 -2
- package/package.json +1 -1
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
|
|
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
|
|
306
|
-
| `NAVARCH_SESSION_TIMEOUT_MS` | `
|
|
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. |
|
|
@@ -490,7 +546,7 @@ set the standard `reasoning_effort` configuration option when it is available.
|
|
|
490
546
|
Machine-wide extra arguments still configure other CLI behavior; dispatched
|
|
491
547
|
model policy wins.
|
|
492
548
|
|
|
493
|
-
Codex defaults to `gpt-6-astra`; Claude Code defaults to `claude-
|
|
549
|
+
Codex defaults to `gpt-6-astra`; Claude Code defaults to `claude-opus-5`.
|
|
494
550
|
Explicit project and `steering/navarch.yaml` model choices still win. A task's
|
|
495
551
|
explicit execution profile wins over the repository's per-task-type profile.
|
|
496
552
|
Otherwise, claim-time classification uses the task summary and metadata:
|
|
@@ -505,7 +561,7 @@ The classifier is deterministic and conservative; it never chooses `critical`
|
|
|
505
561
|
or `deep` automatically. Set an explicit profile to override its assessment.
|
|
506
562
|
Existing explicit task profiles remain explicit.
|
|
507
563
|
|
|
508
|
-
Fable sessions pass `--fallback-model claude-opus-5` for Claude Code's native
|
|
564
|
+
Explicitly selected Fable sessions pass `--fallback-model claude-opus-5` for Claude Code's native
|
|
509
565
|
[availability fallback](https://code.claude.com/docs/en/model-config#fallback-model-chains).
|
|
510
566
|
Because native fallback excludes usage limits, a structured 429 usage-limit
|
|
511
567
|
rejection gets one additional Opus attempt within the original timeout. It
|
|
@@ -516,7 +572,7 @@ that retry. If Opus is also limited, the usual machine cooldown applies.
|
|
|
516
572
|
The runtime records the fallback in the transcript and reports Opus for the
|
|
517
573
|
retry and subsequent turns. Native multi-model turns still report the requested
|
|
518
574
|
primary model; their aggregate provider-reported usage includes both models.
|
|
519
|
-
|
|
575
|
+
Selecting Fable requires a Claude Code version supporting Fable 5.1 (v2.1.255 or later).
|
|
520
576
|
|
|
521
577
|
Astra token cost reporting uses a standard short-context API-equivalent
|
|
522
578
|
estimate from [OpenAI pricing](https://developers.openai.com/api/docs/pricing),
|
|
@@ -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
|
+
}
|
package/dist/adapters/acp.cjs
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/adapters/claude.cjs
CHANGED
|
@@ -4,8 +4,9 @@ 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
|
-
const DEFAULT_CLAUDE_MODEL = "claude-
|
|
9
|
+
const DEFAULT_CLAUDE_MODEL = "claude-opus-5";
|
|
9
10
|
const FALLBACK_CLAUDE_MODEL = "claude-opus-5";
|
|
10
11
|
function hasFlag(args, flag) {
|
|
11
12
|
return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
|
|
@@ -35,13 +36,13 @@ async function runClaudeCodeAdapter(options) {
|
|
|
35
36
|
return { exitCode: null, timedOut: false, killedByLeaseLoss: true, stdout: "", stderr: "" };
|
|
36
37
|
}
|
|
37
38
|
const model = options.model ?? DEFAULT_CLAUDE_MODEL;
|
|
38
|
-
const automaticFallback = [
|
|
39
|
+
const automaticFallback = ["claude-fable-5-1", "fable"].includes(model) &&
|
|
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
|
-
|
|
188
|
+
options.onProcessStarted?.(child.pid);
|
|
189
|
+
const timer = options.timeoutMs > 0 ? setTimeout(() => {
|
|
187
190
|
timedOut = true;
|
|
188
|
-
|
|
189
|
-
}, options.timeoutMs);
|
|
191
|
+
(0, kill_agent_cjs_1.killAgent)(child);
|
|
192
|
+
}, options.timeoutMs) : undefined;
|
|
190
193
|
const onAbort = () => {
|
|
191
194
|
killedByLeaseLoss = true;
|
|
192
|
-
|
|
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
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
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,
|
package/dist/adapters/codex.cjs
CHANGED
|
@@ -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
|
-
|
|
180
|
+
options.onProcessStarted?.(child.pid);
|
|
181
|
+
const timer = options.timeoutMs > 0 ? setTimeout(() => {
|
|
179
182
|
timedOut = true;
|
|
180
|
-
|
|
181
|
-
}, options.timeoutMs);
|
|
183
|
+
(0, kill_agent_cjs_1.killAgent)(child);
|
|
184
|
+
}, options.timeoutMs) : undefined;
|
|
182
185
|
const onAbort = () => {
|
|
183
186
|
killedByLeaseLoss = true;
|
|
184
|
-
|
|
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
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
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 {
|
package/dist/adapters/gemini.cjs
CHANGED
|
@@ -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
|
-
|
|
161
|
+
options.onProcessStarted?.(child.pid);
|
|
162
|
+
const timer = options.timeoutMs > 0 ? setTimeout(() => {
|
|
160
163
|
timedOut = true;
|
|
161
|
-
|
|
162
|
-
}, options.timeoutMs);
|
|
164
|
+
(0, kill_agent_cjs_1.killAgent)(child);
|
|
165
|
+
}, options.timeoutMs) : undefined;
|
|
163
166
|
const onAbort = () => {
|
|
164
167
|
killedByLeaseLoss = true;
|
|
165
|
-
|
|
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,
|
|
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,
|
package/dist/adapters/index.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
200
|
+
options.onProcessStarted?.(child.pid);
|
|
201
|
+
const timer = options.timeoutMs > 0 ? setTimeout(() => {
|
|
199
202
|
timedOut = true;
|
|
200
|
-
|
|
201
|
-
}, options.timeoutMs);
|
|
203
|
+
(0, kill_agent_cjs_1.killAgent)(child);
|
|
204
|
+
}, options.timeoutMs) : undefined;
|
|
202
205
|
const onAbort = () => {
|
|
203
206
|
killedByLeaseLoss = true;
|
|
204
|
-
|
|
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,
|
|
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,
|
package/dist/adapters/pi.cjs
CHANGED
|
@@ -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
|
-
|
|
271
|
+
options.onProcessStarted?.(child.pid);
|
|
272
|
+
const timer = options.timeoutMs > 0 ? setTimeout(() => {
|
|
270
273
|
timedOut = true;
|
|
271
|
-
|
|
272
|
-
}, options.timeoutMs);
|
|
274
|
+
(0, kill_agent_cjs_1.killAgent)(child);
|
|
275
|
+
}, options.timeoutMs) : undefined;
|
|
273
276
|
const onAbort = () => {
|
|
274
277
|
killedByLeaseLoss = true;
|
|
275
|
-
|
|
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 +
|
|
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:
|
|
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,
|
package/dist/exit-conditions.cjs
CHANGED
|
@@ -233,7 +233,11 @@ function mapExitCondition(result) {
|
|
|
233
233
|
return {
|
|
234
234
|
leaseOutcome: "failed",
|
|
235
235
|
exitStatus: "killed",
|
|
236
|
-
reportSummary:
|
|
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
|
}
|
package/dist/git-worktree.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
|
@@ -69,7 +72,7 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
69
72
|
? "auto"
|
|
70
73
|
: runtime === "opencode" || runtime === "acp"
|
|
71
74
|
? "default"
|
|
72
|
-
: "claude-
|
|
75
|
+
: "claude-opus-5",
|
|
73
76
|
reasoning_effort: "medium",
|
|
74
77
|
};
|
|
75
78
|
const crashDetail = describeCompletionError(err).slice(0, 1800);
|
|
@@ -107,7 +110,7 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
|
|
|
107
110
|
? "auto"
|
|
108
111
|
: runtime === "opencode" || runtime === "acp"
|
|
109
112
|
? "default"
|
|
110
|
-
: "claude-
|
|
113
|
+
: "claude-opus-5",
|
|
111
114
|
reasoning_effort: "medium",
|
|
112
115
|
};
|
|
113
116
|
// Isolation posture reported on every completion so `sessions` records what
|
|
@@ -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
|
-
|
|
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
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
618
|
-
|
|
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