@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.
- package/dist/exit-conditions.cjs +59 -2
- package/dist/session-processes.cjs +265 -0
- package/dist/session.cjs +4 -0
- package/dist/worktree-janitor.cjs +11 -0
- package/package.json +1 -1
package/dist/exit-conditions.cjs
CHANGED
|
@@ -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)) ||
|
|
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
|
-
|
|
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