@sagentlab/navarch-runtime 0.1.96 → 0.1.98

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.
@@ -9,6 +9,7 @@ exports.extractUsageFromCodexEvents = extractUsageFromCodexEvents;
9
9
  exports.extractFinalMessageFromCodexEvents = extractFinalMessageFromCodexEvents;
10
10
  exports.leadingVerificationVerdict = leadingVerificationVerdict;
11
11
  exports.leadWithReviewFixMarker = leadWithReviewFixMarker;
12
+ exports.leadWithReviewAttestation = leadWithReviewAttestation;
12
13
  exports.mapExitCondition = mapExitCondition;
13
14
  /**
14
15
  * Leading text of every report for a turn the coding agent CLI refused because
@@ -242,6 +243,61 @@ function leadWithReviewFixMarker(report) {
242
243
  .trim();
243
244
  return rest ? `${line}\n\n${rest}` : line;
244
245
  }
246
+ const REVIEW_ATTESTATION = /^(?:\[(Approve|Code Review: Blocking|Code Review: Comment)\]\s+)?NAVARCH_REVIEW_ATTESTATION:\s*(approve|request_changes|comment)\s+([0-9a-f]{40}|[0-9a-f]{64})(?:\s|$)/i;
247
+ const REVIEW_PREFIXES = {
248
+ approve: "[approve]",
249
+ request_changes: "[code review: blocking]",
250
+ comment: "[code review: comment]",
251
+ };
252
+ function parseReviewAttestation(line) {
253
+ const match = REVIEW_ATTESTATION.exec(line.trim());
254
+ if (!match)
255
+ return null;
256
+ const verdict = (match[2] ?? "").toLowerCase();
257
+ if (match[1] && `[${match[1]}]`.toLowerCase() !== REVIEW_PREFIXES[verdict])
258
+ return null;
259
+ return { verdict, headSha: (match[3] ?? "").toLowerCase() };
260
+ }
261
+ /**
262
+ * Moves a review's `NAVARCH_REVIEW_ATTESTATION` line to the top of the report.
263
+ * The control plane accepts it only as the first line
264
+ * (lib/navarch/review-attestation.ts). Claude often writes a sentence of
265
+ * preamble before it, and every such review was rejected with
266
+ * `review_attestation_required` and needed a correction turn. Kept in step
267
+ * with leadWithPlatformReviewAttestation there; this package cannot import
268
+ * it. Two lines that disagree are left for the control plane to reject.
269
+ */
270
+ function leadWithReviewAttestation(report) {
271
+ if (parseReviewAttestation(report))
272
+ return report;
273
+ const lines = report.split("\n");
274
+ const claims = lines
275
+ .map((line, index) => ({ index, line: line.trim(), parsed: parseReviewAttestation(line) }))
276
+ .filter((entry) => entry.parsed !== null);
277
+ const first = claims[0];
278
+ if (!first)
279
+ return report;
280
+ const agree = claims.every((entry) => entry.parsed.verdict === first.parsed.verdict && entry.parsed.headSha === first.parsed.headSha);
281
+ if (!agree)
282
+ return report;
283
+ const claimLines = new Set(claims.map((entry) => entry.index));
284
+ const rest = lines
285
+ .filter((_, index) => !claimLines.has(index))
286
+ .join("\n")
287
+ .replace(/\n{3,}/g, "\n\n")
288
+ .trim();
289
+ return rest ? `${first.line}\n\n${rest}` : first.line;
290
+ }
291
+ /**
292
+ * True when the report opens with `Build outcome: FAILED|BLOCKED`. Also
293
+ * accepts `Task outcome:` and Markdown emphasis, which is how agents write
294
+ * "report a failed completion". Kept in step with declaresBuildFailure in
295
+ * lib/navarch/build-outcome.ts.
296
+ */
297
+ function declaresBuildFailure(report) {
298
+ const firstLine = report.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "";
299
+ return /^(?:Build|Task) outcome:[*_`~]*\s*[*_`~]*(?:FAILED|BLOCKED)(?![a-z0-9_])/i.test(firstLine.replace(/^(?:#{1,6}\s+|>\s*|[*_`~]+\s*)+/, ""));
300
+ }
245
301
  function summarize(text, maxLen = 500) {
246
302
  const trimmed = text.trim();
247
303
  if (!trimmed)
@@ -341,7 +397,8 @@ function mapExitCondition(result) {
341
397
  // falls back to raw stdout when neither is available (see
342
398
  // parseClaudeJsonResult's doc comment).
343
399
  const reportText = result.reportText ?? parsedJson?.result ?? result.stdout;
344
- const reportSummary = summarize(leadWithReviewFixMarker(reportText)) || "Adapter completed with no report text.";
400
+ const reportSummary = summarize(leadWithReviewAttestation(leadWithReviewFixMarker(reportText))) ||
401
+ "Adapter completed with no report text.";
345
402
  // A verify agent's FAIL/BLOCKED verdict is a successful agent run whose
346
403
  // conclusion is that the delivery did not pass. The completion API only
347
404
  // accepts those verdicts with a failed outcome, so map them to a failed
@@ -362,7 +419,7 @@ function mapExitCondition(result) {
362
419
  // Build agents also use an explicit outcome marker. A zero process exit
363
420
  // means the conversation finished, not that the requested build succeeded.
364
421
  if (/^blocked(?:\s|:|$)/i.test(reportText.trim()) ||
365
- /^Build outcome:\s*(?:FAILED|BLOCKED)\b/i.test(reportText.trim())) {
422
+ declaresBuildFailure(reportText)) {
366
423
  return {
367
424
  leaseOutcome: "failed",
368
425
  exitStatus: "failed",
@@ -0,0 +1,265 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.listProcesses = listProcesses;
7
+ exports.parsePsOutput = parsePsOutput;
8
+ exports.parseLsofCwds = parseLsofCwds;
9
+ exports.sessionProcessIds = sessionProcessIds;
10
+ exports.sessionIdsInUse = sessionIdsInUse;
11
+ exports.terminateProcesses = terminateProcesses;
12
+ exports.reapSessionProcesses = reapSessionProcesses;
13
+ exports.reapOrphanedSessionProcesses = reapOrphanedSessionProcesses;
14
+ const node_fs_1 = require("node:fs");
15
+ const node_os_1 = __importDefault(require("node:os"));
16
+ const node_path_1 = __importDefault(require("node:path"));
17
+ const logger_cjs_1 = require("./logger.cjs");
18
+ const sandbox_cjs_1 = require("./sandbox.cjs");
19
+ const log = (0, logger_cjs_1.createLogger)("session-processes");
20
+ const DEFAULT_GRACE_MS = 3_000;
21
+ const POLL_MS = 200;
22
+ /** Every process this user owns, with its working directory where readable. */
23
+ async function listProcesses(deps = {}) {
24
+ const platform = deps.platform ?? process.platform;
25
+ if (platform === "win32")
26
+ return [];
27
+ const runner = deps.runner ?? sandbox_cjs_1.nodeCommandRunner;
28
+ const listing = await runner.run("ps", ["axww", "-o", "pid=,ppid=,tty=,command="], { timeoutMs: 10_000 });
29
+ if (listing.code !== 0)
30
+ return [];
31
+ const processes = parsePsOutput(listing.stdout);
32
+ const cwds = platform === "linux"
33
+ ? await linuxCwds(processes, deps.readProcCwd ?? readProcCwd)
34
+ : await lsofCwds(runner, deps.uid ?? node_os_1.default.userInfo().uid);
35
+ for (const entry of processes)
36
+ entry.cwd = cwds.get(entry.pid);
37
+ return processes;
38
+ }
39
+ function parsePsOutput(stdout) {
40
+ const processes = [];
41
+ for (const line of stdout.split("\n")) {
42
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
43
+ if (!match)
44
+ continue;
45
+ processes.push({ pid: Number(match[1]), ppid: Number(match[2]), tty: match[3], command: match[4] ?? "" });
46
+ }
47
+ return processes;
48
+ }
49
+ /** `lsof -F pn` output: a `p<pid>` line opens each process, `n<path>` is its cwd. */
50
+ function parseLsofCwds(stdout) {
51
+ const cwds = new Map();
52
+ let pid = null;
53
+ for (const line of stdout.split("\n")) {
54
+ if (line.startsWith("p"))
55
+ pid = Number(line.slice(1));
56
+ else if (line.startsWith("n") && pid !== null && Number.isFinite(pid))
57
+ cwds.set(pid, line.slice(1));
58
+ }
59
+ return cwds;
60
+ }
61
+ async function lsofCwds(runner, uid) {
62
+ // lsof exits 1 when some processes could not be read; the rest is still valid.
63
+ const result = await runner
64
+ .run("lsof", ["-nP", "-a", "-u", String(uid), "-d", "cwd", "-F", "pn"], { timeoutMs: 15_000 })
65
+ .catch(() => null);
66
+ return result ? parseLsofCwds(result.stdout) : new Map();
67
+ }
68
+ async function linuxCwds(processes, read) {
69
+ const cwds = new Map();
70
+ await Promise.all(processes.map(async ({ pid }) => {
71
+ const cwd = await read(pid);
72
+ if (cwd)
73
+ cwds.set(pid, cwd);
74
+ }));
75
+ return cwds;
76
+ }
77
+ async function readProcCwd(pid) {
78
+ return node_fs_1.promises.readlink(`/proc/${pid}/cwd`).then((cwd) => cwd.replace(/ \(deleted\)$/, ""), () => undefined);
79
+ }
80
+ /** A process on a terminal is a person's, e.g. a shell left in a deleted session directory. */
81
+ function isInteractive(entry) {
82
+ return entry.tty !== undefined && entry.tty !== "??" && entry.tty !== "?" && entry.tty !== "-";
83
+ }
84
+ function within(candidate, root) {
85
+ return candidate === root || candidate.startsWith(`${root}${node_path_1.default.sep}`);
86
+ }
87
+ function namesPathIn(command, root) {
88
+ let index = command.indexOf(root);
89
+ while (index !== -1) {
90
+ const before = index === 0 ? " " : command[index - 1];
91
+ const after = command[index + root.length];
92
+ if ((before === " " || before === "=" || before === "'" || before === '"') &&
93
+ (after === undefined || after === node_path_1.default.sep || after === " " || after === "'" || after === '"')) {
94
+ return true;
95
+ }
96
+ index = command.indexOf(root, index + 1);
97
+ }
98
+ return false;
99
+ }
100
+ /** The runtime and every ancestor up to init: never a session's process. */
101
+ function protectedPids(processes, selfPid) {
102
+ const parentOf = new Map(processes.map((entry) => [entry.pid, entry.ppid]));
103
+ const protectedSet = new Set([selfPid, 0, 1]);
104
+ let current = parentOf.get(selfPid);
105
+ while (current !== undefined && !protectedSet.has(current)) {
106
+ protectedSet.add(current);
107
+ current = parentOf.get(current);
108
+ }
109
+ return protectedSet;
110
+ }
111
+ /**
112
+ * Pids tied to any of `roots` (the same directory under its spellings, e.g.
113
+ * /var and /private/var), with their descendants. The runtime and its
114
+ * ancestors are never included.
115
+ */
116
+ function sessionProcessIds(processes, roots, selfPid, options = {}) {
117
+ const guarded = protectedPids(processes, selfPid);
118
+ if (options.skipInteractive) {
119
+ for (const entry of processes)
120
+ if (isInteractive(entry))
121
+ guarded.add(entry.pid);
122
+ }
123
+ const childrenOf = new Map();
124
+ for (const entry of processes) {
125
+ const siblings = childrenOf.get(entry.ppid) ?? [];
126
+ siblings.push(entry.pid);
127
+ childrenOf.set(entry.ppid, siblings);
128
+ }
129
+ const selected = new Set();
130
+ const visit = (pid) => {
131
+ if (guarded.has(pid) || selected.has(pid))
132
+ return;
133
+ selected.add(pid);
134
+ for (const child of childrenOf.get(pid) ?? [])
135
+ visit(child);
136
+ };
137
+ for (const entry of processes) {
138
+ const tied = roots.some((root) => namesPathIn(entry.command, root) || (entry.cwd !== undefined && within(entry.cwd, root)));
139
+ if (tied)
140
+ visit(entry.pid);
141
+ }
142
+ return [...selected].sort((a, b) => a - b);
143
+ }
144
+ /**
145
+ * Session ids named by processes under `sessionsRoots`, keyed to the
146
+ * spellings of that session's root that matched.
147
+ */
148
+ function sessionIdsInUse(processes, sessionsRoots) {
149
+ const found = new Map();
150
+ for (const sessionsRoot of sessionsRoots) {
151
+ const prefix = `${sessionsRoot}${node_path_1.default.sep}`;
152
+ for (const entry of processes) {
153
+ for (const text of [entry.command, entry.cwd ?? ""]) {
154
+ let index = text.indexOf(prefix);
155
+ while (index !== -1) {
156
+ const rest = text.slice(index + prefix.length);
157
+ const sessionId = /^[A-Za-z0-9._-]+/.exec(rest)?.[0];
158
+ if (sessionId && sessionId !== "." && sessionId !== "..") {
159
+ const roots = found.get(sessionId) ?? new Set();
160
+ roots.add(node_path_1.default.join(sessionsRoot, sessionId));
161
+ found.set(sessionId, roots);
162
+ }
163
+ index = text.indexOf(prefix, index + 1);
164
+ }
165
+ }
166
+ }
167
+ }
168
+ return found;
169
+ }
170
+ /** SIGTERM, a grace period, then SIGKILL for anything still running. */
171
+ async function terminateProcesses(pids, deps = {}) {
172
+ if (pids.length === 0)
173
+ return [];
174
+ const kill = deps.kill ?? ((pid, signal) => process.kill(pid, signal));
175
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
176
+ const alive = (pid) => {
177
+ try {
178
+ kill(pid, 0);
179
+ return true;
180
+ }
181
+ catch {
182
+ return false;
183
+ }
184
+ };
185
+ const signalled = pids.filter((pid) => {
186
+ try {
187
+ kill(pid, "SIGTERM");
188
+ return true;
189
+ }
190
+ catch {
191
+ return false;
192
+ }
193
+ });
194
+ const graceMs = deps.graceMs ?? DEFAULT_GRACE_MS;
195
+ let remaining = signalled;
196
+ for (let waited = 0; remaining.length > 0 && waited < graceMs; waited += POLL_MS) {
197
+ await sleep(POLL_MS);
198
+ remaining = remaining.filter(alive);
199
+ }
200
+ for (const pid of remaining) {
201
+ try {
202
+ kill(pid, "SIGKILL");
203
+ }
204
+ catch { /* Exited between the check and the kill. */ }
205
+ }
206
+ return signalled;
207
+ }
208
+ async function spellings(root) {
209
+ const resolved = node_path_1.default.resolve(root);
210
+ const real = await node_fs_1.promises.realpath(resolved).catch(() => resolved);
211
+ return real === resolved ? [resolved] : [resolved, real];
212
+ }
213
+ /**
214
+ * Stops every process still tied to a finished session's directory. Never
215
+ * throws: a failed listing must not block worktree cleanup.
216
+ */
217
+ async function reapSessionProcesses(sessionRoot, deps = {}) {
218
+ try {
219
+ const processes = await listProcesses(deps);
220
+ const pids = sessionProcessIds(processes, await spellings(sessionRoot), deps.selfPid ?? process.pid);
221
+ const stopped = await terminateProcesses(pids, deps);
222
+ if (stopped.length > 0) {
223
+ log.info(`stopped ${stopped.length} process(es) left running by ${node_path_1.default.basename(sessionRoot)}: ${stopped.join(" ")}`);
224
+ }
225
+ return stopped;
226
+ }
227
+ catch (error) {
228
+ log.warn(`could not stop processes left by ${node_path_1.default.basename(sessionRoot)}: ${String(error)}`);
229
+ return [];
230
+ }
231
+ }
232
+ /**
233
+ * Stops processes of sessions that no longer exist: not active in this
234
+ * runtime and with no directory on disk. Those are left over from a runtime
235
+ * that crashed, or from an older runtime that never stopped them. A session
236
+ * directory that still exists belongs to the janitor's age rules, which may
237
+ * be protecting another runtime's live session. Processes on a terminal are
238
+ * left alone here: by then they are more likely a person's than an agent's.
239
+ */
240
+ async function reapOrphanedSessionProcesses(sessionsRoot, isSessionActive, deps = {}) {
241
+ try {
242
+ const processes = await listProcesses(deps);
243
+ const inUse = sessionIdsInUse(processes, await spellings(sessionsRoot));
244
+ const orphanRoots = [];
245
+ for (const [sessionId, roots] of inUse) {
246
+ if (isSessionActive(sessionId))
247
+ continue;
248
+ const exists = await node_fs_1.promises.stat(node_path_1.default.join(sessionsRoot, sessionId)).then(() => true, () => false);
249
+ if (!exists)
250
+ orphanRoots.push(...roots);
251
+ }
252
+ if (orphanRoots.length === 0)
253
+ return [];
254
+ const pids = sessionProcessIds(processes, orphanRoots, deps.selfPid ?? process.pid, { skipInteractive: true });
255
+ const stopped = await terminateProcesses(pids, deps);
256
+ if (stopped.length > 0) {
257
+ log.info(`stopped ${stopped.length} process(es) left by deleted sessions: ${stopped.join(" ")}`);
258
+ }
259
+ return stopped;
260
+ }
261
+ catch (error) {
262
+ log.warn(`orphaned session process sweep failed: ${String(error)}`);
263
+ return [];
264
+ }
265
+ }
package/dist/session.cjs CHANGED
@@ -21,6 +21,7 @@ const mcp_config_cjs_1 = require("./mcp-config.cjs");
21
21
  const logger_cjs_1 = require("./logger.cjs");
22
22
  const git_worktree_cjs_1 = require("./git-worktree.cjs");
23
23
  const worktree_janitor_cjs_1 = require("./worktree-janitor.cjs");
24
+ const session_processes_cjs_1 = require("./session-processes.cjs");
24
25
  const github_pr_cjs_1 = require("./github-pr.cjs");
25
26
  const delivery_cjs_1 = require("./delivery.cjs");
26
27
  const worktree_guard_cjs_1 = require("./worktree-guard.cjs");
@@ -904,6 +905,9 @@ async function runClaimedSession(deps, claimed, sessionId, lifecycle) {
904
905
  catch (stopErr) {
905
906
  log.warn(`sandbox stop failed during teardown: ${String(stopErr)}`);
906
907
  }
908
+ // Dev servers the agent started detached outlive its process group. Stop
909
+ // them before the worktree goes, or they run on against a deleted repo.
910
+ await (0, session_processes_cjs_1.reapSessionProcesses)(workDir);
907
911
  if (retainWorktree) {
908
912
  await gitWorktree.retainForRecovery().catch((error) => log.error(`Recovery worktree retained at ${gitWorktree.worktreePath}, but retention setup or credential cleanup failed: ${String(error)}`));
909
913
  }
@@ -10,9 +10,14 @@ const node_path_1 = __importDefault(require("node:path"));
10
10
  const node_fs_1 = require("node:fs");
11
11
  const logger_cjs_1 = require("./logger.cjs");
12
12
  const sandbox_cjs_1 = require("./sandbox.cjs");
13
+ const session_processes_cjs_1 = require("./session-processes.cjs");
13
14
  const log = (0, logger_cjs_1.createLogger)("worktree-janitor");
14
15
  exports.SESSION_ACTIVITY_FILENAME = ".navarch-active";
15
16
  exports.SESSION_RECOVERY_FILENAME = ".navarch-recovery-required";
17
+ const defaultProcessReaper = {
18
+ reapSession: (sessionRoot) => (0, session_processes_cjs_1.reapSessionProcesses)(sessionRoot),
19
+ reapOrphans: (sessionsRoot, isSessionActive) => (0, session_processes_cjs_1.reapOrphanedSessionProcesses)(sessionsRoot, isSessionActive),
20
+ };
16
21
  /**
17
22
  * Refreshes the durable activity marker used by janitors in other runtime
18
23
  * processes that happen to share a workspace root.
@@ -33,12 +38,14 @@ class WorktreeJanitor {
33
38
  options;
34
39
  runner;
35
40
  now;
41
+ processReaper;
36
42
  timer = null;
37
43
  sweepInFlight = null;
38
44
  constructor(options) {
39
45
  this.options = options;
40
46
  this.runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
41
47
  this.now = options.now ?? Date.now;
48
+ this.processReaper = options.processReaper ?? defaultProcessReaper;
42
49
  }
43
50
  start() {
44
51
  if (this.timer)
@@ -118,6 +125,7 @@ class WorktreeJanitor {
118
125
  summary.skippedActive += 1;
119
126
  continue;
120
127
  }
128
+ await this.processReaper.reapSession(sessionRoot);
121
129
  await this.removeSessionWorktree(sessionRoot);
122
130
  summary.removed += 1;
123
131
  }
@@ -126,6 +134,9 @@ class WorktreeJanitor {
126
134
  log.warn(`could not clean abandoned session ${sessionId}: ${String(error)}`);
127
135
  }
128
136
  }
137
+ // Processes can outlive their session directory: a runtime that crashed
138
+ // before stopping them, or a runtime older than 0.1.98.
139
+ await this.processReaper.reapOrphans(sessionsRoot, (sessionId) => this.options.isSessionActive?.(sessionId) ?? false);
129
140
  return summary;
130
141
  }
131
142
  async lastActivityAt(sessionRoot) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.96",
3
+ "version": "0.1.98",
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",