@sagentlab/navarch-runtime 0.1.98 → 0.1.99
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/session-processes.cjs +51 -24
- package/dist/worktree-janitor.cjs +41 -6
- package/package.json +1 -1
|
@@ -11,6 +11,7 @@ exports.sessionIdsInUse = sessionIdsInUse;
|
|
|
11
11
|
exports.terminateProcesses = terminateProcesses;
|
|
12
12
|
exports.reapSessionProcesses = reapSessionProcesses;
|
|
13
13
|
exports.reapOrphanedSessionProcesses = reapOrphanedSessionProcesses;
|
|
14
|
+
exports.sessionDirectoryMissing = sessionDirectoryMissing;
|
|
14
15
|
const node_fs_1 = require("node:fs");
|
|
15
16
|
const node_os_1 = __importDefault(require("node:os"));
|
|
16
17
|
const node_path_1 = __importDefault(require("node:path"));
|
|
@@ -205,6 +206,28 @@ async function terminateProcesses(pids, deps = {}) {
|
|
|
205
206
|
}
|
|
206
207
|
return signalled;
|
|
207
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Stops the selected processes, then lists again to verify none remain. A
|
|
211
|
+
* second round catches anything spawned during the grace period; whatever
|
|
212
|
+
* survives that is logged, since it needs a person.
|
|
213
|
+
*/
|
|
214
|
+
async function stopUntilGone(label, select, deps) {
|
|
215
|
+
const stopped = new Set();
|
|
216
|
+
let processes = await listProcesses(deps);
|
|
217
|
+
let pids = await select(processes);
|
|
218
|
+
for (let round = 0; round < 2 && pids.length > 0; round += 1) {
|
|
219
|
+
for (const pid of await terminateProcesses(pids, deps))
|
|
220
|
+
stopped.add(pid);
|
|
221
|
+
processes = await listProcesses(deps);
|
|
222
|
+
pids = await select(processes);
|
|
223
|
+
}
|
|
224
|
+
if (pids.length > 0) {
|
|
225
|
+
const survivors = processes.filter((entry) => pids.includes(entry.pid))
|
|
226
|
+
.map((entry) => `${entry.pid} ${entry.command.slice(0, 120)}`);
|
|
227
|
+
log.warn(`processes left by ${label} survived SIGKILL: ${survivors.join("; ")}`);
|
|
228
|
+
}
|
|
229
|
+
return [...stopped].sort((a, b) => a - b);
|
|
230
|
+
}
|
|
208
231
|
async function spellings(root) {
|
|
209
232
|
const resolved = node_path_1.default.resolve(root);
|
|
210
233
|
const real = await node_fs_1.promises.realpath(resolved).catch(() => resolved);
|
|
@@ -216,9 +239,8 @@ async function spellings(root) {
|
|
|
216
239
|
*/
|
|
217
240
|
async function reapSessionProcesses(sessionRoot, deps = {}) {
|
|
218
241
|
try {
|
|
219
|
-
const
|
|
220
|
-
const
|
|
221
|
-
const stopped = await terminateProcesses(pids, deps);
|
|
242
|
+
const roots = await spellings(sessionRoot);
|
|
243
|
+
const stopped = await stopUntilGone(node_path_1.default.basename(sessionRoot), async (processes) => sessionProcessIds(processes, roots, deps.selfPid ?? process.pid), deps);
|
|
222
244
|
if (stopped.length > 0) {
|
|
223
245
|
log.info(`stopped ${stopped.length} process(es) left running by ${node_path_1.default.basename(sessionRoot)}: ${stopped.join(" ")}`);
|
|
224
246
|
}
|
|
@@ -230,31 +252,33 @@ async function reapSessionProcesses(sessionRoot, deps = {}) {
|
|
|
230
252
|
}
|
|
231
253
|
}
|
|
232
254
|
/**
|
|
233
|
-
* Stops processes of sessions that
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
255
|
+
* Stops processes of sessions that have ended: not active in this runtime,
|
|
256
|
+
* and `hasEnded` agrees. By default that means the directory is gone; the
|
|
257
|
+
* janitor also counts a directory whose runtime crashed (its activity marker
|
|
258
|
+
* names a runtime pid that no longer exists), because a crash skips the
|
|
259
|
+
* session's own cleanup and leaves the directory behind. A directory that
|
|
260
|
+
* may still belong to another live runtime stays under the janitor's age
|
|
261
|
+
* rules. Processes on a terminal are left alone here: by then they are more
|
|
262
|
+
* likely a person's than an agent's.
|
|
239
263
|
*/
|
|
240
264
|
async function reapOrphanedSessionProcesses(sessionsRoot, isSessionActive, deps = {}) {
|
|
265
|
+
const hasEnded = deps.hasEnded ?? sessionDirectoryMissing;
|
|
241
266
|
try {
|
|
242
|
-
const
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
return
|
|
254
|
-
|
|
255
|
-
const stopped = await terminateProcesses(pids, deps);
|
|
267
|
+
const sessionsRoots = await spellings(sessionsRoot);
|
|
268
|
+
const stopped = await stopUntilGone("ended sessions", async (processes) => {
|
|
269
|
+
const orphanRoots = [];
|
|
270
|
+
for (const [sessionId, roots] of sessionIdsInUse(processes, sessionsRoots)) {
|
|
271
|
+
if (isSessionActive(sessionId))
|
|
272
|
+
continue;
|
|
273
|
+
if (await hasEnded(sessionId, node_path_1.default.join(sessionsRoot, sessionId)))
|
|
274
|
+
orphanRoots.push(...roots);
|
|
275
|
+
}
|
|
276
|
+
if (orphanRoots.length === 0)
|
|
277
|
+
return [];
|
|
278
|
+
return sessionProcessIds(processes, orphanRoots, deps.selfPid ?? process.pid, { skipInteractive: true });
|
|
279
|
+
}, deps);
|
|
256
280
|
if (stopped.length > 0) {
|
|
257
|
-
log.info(`stopped ${stopped.length} process(es) left by
|
|
281
|
+
log.info(`stopped ${stopped.length} process(es) left by ended sessions: ${stopped.join(" ")}`);
|
|
258
282
|
}
|
|
259
283
|
return stopped;
|
|
260
284
|
}
|
|
@@ -263,3 +287,6 @@ async function reapOrphanedSessionProcesses(sessionsRoot, isSessionActive, deps
|
|
|
263
287
|
return [];
|
|
264
288
|
}
|
|
265
289
|
}
|
|
290
|
+
async function sessionDirectoryMissing(_sessionId, sessionRoot) {
|
|
291
|
+
return node_fs_1.promises.stat(sessionRoot).then(() => false, () => true);
|
|
292
|
+
}
|
|
@@ -5,6 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.WorktreeJanitor = exports.SESSION_RECOVERY_FILENAME = exports.SESSION_ACTIVITY_FILENAME = void 0;
|
|
7
7
|
exports.markSessionWorkspaceActive = markSessionWorkspaceActive;
|
|
8
|
+
exports.sessionOwnerPid = sessionOwnerPid;
|
|
9
|
+
exports.sessionHasEnded = sessionHasEnded;
|
|
8
10
|
exports.hasRecoveryMarker = hasRecoveryMarker;
|
|
9
11
|
const node_path_1 = __importDefault(require("node:path"));
|
|
10
12
|
const node_fs_1 = require("node:fs");
|
|
@@ -16,15 +18,48 @@ exports.SESSION_ACTIVITY_FILENAME = ".navarch-active";
|
|
|
16
18
|
exports.SESSION_RECOVERY_FILENAME = ".navarch-recovery-required";
|
|
17
19
|
const defaultProcessReaper = {
|
|
18
20
|
reapSession: (sessionRoot) => (0, session_processes_cjs_1.reapSessionProcesses)(sessionRoot),
|
|
19
|
-
reapOrphans: (sessionsRoot, isSessionActive) => (0, session_processes_cjs_1.reapOrphanedSessionProcesses)(sessionsRoot, isSessionActive),
|
|
21
|
+
reapOrphans: (sessionsRoot, isSessionActive) => (0, session_processes_cjs_1.reapOrphanedSessionProcesses)(sessionsRoot, isSessionActive, { hasEnded: sessionHasEnded }),
|
|
20
22
|
};
|
|
21
23
|
/**
|
|
22
24
|
* Refreshes the durable activity marker used by janitors in other runtime
|
|
23
|
-
* processes that happen to share a workspace root.
|
|
25
|
+
* processes that happen to share a workspace root. It names the owning
|
|
26
|
+
* runtime pid so a restarted runtime can tell its crashed predecessor's
|
|
27
|
+
* sessions from another live runtime's.
|
|
24
28
|
*/
|
|
25
|
-
async function markSessionWorkspaceActive(sessionRoot) {
|
|
29
|
+
async function markSessionWorkspaceActive(sessionRoot, ownerPid = process.pid) {
|
|
26
30
|
await node_fs_1.promises.mkdir(sessionRoot, { recursive: true });
|
|
27
|
-
await node_fs_1.promises.writeFile(node_path_1.default.join(sessionRoot, exports.SESSION_ACTIVITY_FILENAME), `${new Date().toISOString()}\n`, "utf8");
|
|
31
|
+
await node_fs_1.promises.writeFile(node_path_1.default.join(sessionRoot, exports.SESSION_ACTIVITY_FILENAME), `${new Date().toISOString()}\npid=${ownerPid}\n`, "utf8");
|
|
32
|
+
}
|
|
33
|
+
/** The runtime pid an activity marker names, or null for older markers. */
|
|
34
|
+
async function sessionOwnerPid(sessionRoot) {
|
|
35
|
+
const marker = await node_fs_1.promises.readFile(node_path_1.default.join(sessionRoot, exports.SESSION_ACTIVITY_FILENAME), "utf8").catch(() => "");
|
|
36
|
+
const match = /^pid=(\d+)$/m.exec(marker);
|
|
37
|
+
return match ? Number(match[1]) : null;
|
|
38
|
+
}
|
|
39
|
+
function isPidAlive(pid) {
|
|
40
|
+
try {
|
|
41
|
+
process.kill(pid, 0);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
// EPERM: alive, but another user's.
|
|
46
|
+
return error instanceof Error && "code" in error && error.code === "EPERM";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A session the janitor's caller has already found inactive has ended when
|
|
51
|
+
* its directory is gone, or when its marker names this runtime (so it
|
|
52
|
+
* finished here) or a runtime that no longer exists (it crashed, which skips
|
|
53
|
+
* the session's own process cleanup). A live other owner, or a marker from
|
|
54
|
+
* before owners were recorded, leaves it to the age rules.
|
|
55
|
+
*/
|
|
56
|
+
async function sessionHasEnded(sessionId, sessionRoot, alive = isPidAlive) {
|
|
57
|
+
if (await (0, session_processes_cjs_1.sessionDirectoryMissing)(sessionId, sessionRoot))
|
|
58
|
+
return true;
|
|
59
|
+
const owner = await sessionOwnerPid(sessionRoot);
|
|
60
|
+
if (owner === null)
|
|
61
|
+
return false;
|
|
62
|
+
return owner === process.pid || !alive(owner);
|
|
28
63
|
}
|
|
29
64
|
/**
|
|
30
65
|
* Periodically removes session worktrees left behind by SIGKILL, a host
|
|
@@ -134,8 +169,8 @@ class WorktreeJanitor {
|
|
|
134
169
|
log.warn(`could not clean abandoned session ${sessionId}: ${String(error)}`);
|
|
135
170
|
}
|
|
136
171
|
}
|
|
137
|
-
// Processes can outlive their session
|
|
138
|
-
//
|
|
172
|
+
// Processes can outlive their session: a runtime that crashed before
|
|
173
|
+
// stopping them, or a runtime older than 0.1.98.
|
|
139
174
|
await this.processReaper.reapOrphans(sessionsRoot, (sessionId) => this.options.isSessionActive?.(sessionId) ?? false);
|
|
140
175
|
return summary;
|
|
141
176
|
}
|
package/package.json
CHANGED