@sagentlab/navarch-runtime 0.1.97 → 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.
@@ -0,0 +1,292 @@
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
+ exports.sessionDirectoryMissing = sessionDirectoryMissing;
15
+ const node_fs_1 = require("node:fs");
16
+ const node_os_1 = __importDefault(require("node:os"));
17
+ const node_path_1 = __importDefault(require("node:path"));
18
+ const logger_cjs_1 = require("./logger.cjs");
19
+ const sandbox_cjs_1 = require("./sandbox.cjs");
20
+ const log = (0, logger_cjs_1.createLogger)("session-processes");
21
+ const DEFAULT_GRACE_MS = 3_000;
22
+ const POLL_MS = 200;
23
+ /** Every process this user owns, with its working directory where readable. */
24
+ async function listProcesses(deps = {}) {
25
+ const platform = deps.platform ?? process.platform;
26
+ if (platform === "win32")
27
+ return [];
28
+ const runner = deps.runner ?? sandbox_cjs_1.nodeCommandRunner;
29
+ const listing = await runner.run("ps", ["axww", "-o", "pid=,ppid=,tty=,command="], { timeoutMs: 10_000 });
30
+ if (listing.code !== 0)
31
+ return [];
32
+ const processes = parsePsOutput(listing.stdout);
33
+ const cwds = platform === "linux"
34
+ ? await linuxCwds(processes, deps.readProcCwd ?? readProcCwd)
35
+ : await lsofCwds(runner, deps.uid ?? node_os_1.default.userInfo().uid);
36
+ for (const entry of processes)
37
+ entry.cwd = cwds.get(entry.pid);
38
+ return processes;
39
+ }
40
+ function parsePsOutput(stdout) {
41
+ const processes = [];
42
+ for (const line of stdout.split("\n")) {
43
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line);
44
+ if (!match)
45
+ continue;
46
+ processes.push({ pid: Number(match[1]), ppid: Number(match[2]), tty: match[3], command: match[4] ?? "" });
47
+ }
48
+ return processes;
49
+ }
50
+ /** `lsof -F pn` output: a `p<pid>` line opens each process, `n<path>` is its cwd. */
51
+ function parseLsofCwds(stdout) {
52
+ const cwds = new Map();
53
+ let pid = null;
54
+ for (const line of stdout.split("\n")) {
55
+ if (line.startsWith("p"))
56
+ pid = Number(line.slice(1));
57
+ else if (line.startsWith("n") && pid !== null && Number.isFinite(pid))
58
+ cwds.set(pid, line.slice(1));
59
+ }
60
+ return cwds;
61
+ }
62
+ async function lsofCwds(runner, uid) {
63
+ // lsof exits 1 when some processes could not be read; the rest is still valid.
64
+ const result = await runner
65
+ .run("lsof", ["-nP", "-a", "-u", String(uid), "-d", "cwd", "-F", "pn"], { timeoutMs: 15_000 })
66
+ .catch(() => null);
67
+ return result ? parseLsofCwds(result.stdout) : new Map();
68
+ }
69
+ async function linuxCwds(processes, read) {
70
+ const cwds = new Map();
71
+ await Promise.all(processes.map(async ({ pid }) => {
72
+ const cwd = await read(pid);
73
+ if (cwd)
74
+ cwds.set(pid, cwd);
75
+ }));
76
+ return cwds;
77
+ }
78
+ async function readProcCwd(pid) {
79
+ return node_fs_1.promises.readlink(`/proc/${pid}/cwd`).then((cwd) => cwd.replace(/ \(deleted\)$/, ""), () => undefined);
80
+ }
81
+ /** A process on a terminal is a person's, e.g. a shell left in a deleted session directory. */
82
+ function isInteractive(entry) {
83
+ return entry.tty !== undefined && entry.tty !== "??" && entry.tty !== "?" && entry.tty !== "-";
84
+ }
85
+ function within(candidate, root) {
86
+ return candidate === root || candidate.startsWith(`${root}${node_path_1.default.sep}`);
87
+ }
88
+ function namesPathIn(command, root) {
89
+ let index = command.indexOf(root);
90
+ while (index !== -1) {
91
+ const before = index === 0 ? " " : command[index - 1];
92
+ const after = command[index + root.length];
93
+ if ((before === " " || before === "=" || before === "'" || before === '"') &&
94
+ (after === undefined || after === node_path_1.default.sep || after === " " || after === "'" || after === '"')) {
95
+ return true;
96
+ }
97
+ index = command.indexOf(root, index + 1);
98
+ }
99
+ return false;
100
+ }
101
+ /** The runtime and every ancestor up to init: never a session's process. */
102
+ function protectedPids(processes, selfPid) {
103
+ const parentOf = new Map(processes.map((entry) => [entry.pid, entry.ppid]));
104
+ const protectedSet = new Set([selfPid, 0, 1]);
105
+ let current = parentOf.get(selfPid);
106
+ while (current !== undefined && !protectedSet.has(current)) {
107
+ protectedSet.add(current);
108
+ current = parentOf.get(current);
109
+ }
110
+ return protectedSet;
111
+ }
112
+ /**
113
+ * Pids tied to any of `roots` (the same directory under its spellings, e.g.
114
+ * /var and /private/var), with their descendants. The runtime and its
115
+ * ancestors are never included.
116
+ */
117
+ function sessionProcessIds(processes, roots, selfPid, options = {}) {
118
+ const guarded = protectedPids(processes, selfPid);
119
+ if (options.skipInteractive) {
120
+ for (const entry of processes)
121
+ if (isInteractive(entry))
122
+ guarded.add(entry.pid);
123
+ }
124
+ const childrenOf = new Map();
125
+ for (const entry of processes) {
126
+ const siblings = childrenOf.get(entry.ppid) ?? [];
127
+ siblings.push(entry.pid);
128
+ childrenOf.set(entry.ppid, siblings);
129
+ }
130
+ const selected = new Set();
131
+ const visit = (pid) => {
132
+ if (guarded.has(pid) || selected.has(pid))
133
+ return;
134
+ selected.add(pid);
135
+ for (const child of childrenOf.get(pid) ?? [])
136
+ visit(child);
137
+ };
138
+ for (const entry of processes) {
139
+ const tied = roots.some((root) => namesPathIn(entry.command, root) || (entry.cwd !== undefined && within(entry.cwd, root)));
140
+ if (tied)
141
+ visit(entry.pid);
142
+ }
143
+ return [...selected].sort((a, b) => a - b);
144
+ }
145
+ /**
146
+ * Session ids named by processes under `sessionsRoots`, keyed to the
147
+ * spellings of that session's root that matched.
148
+ */
149
+ function sessionIdsInUse(processes, sessionsRoots) {
150
+ const found = new Map();
151
+ for (const sessionsRoot of sessionsRoots) {
152
+ const prefix = `${sessionsRoot}${node_path_1.default.sep}`;
153
+ for (const entry of processes) {
154
+ for (const text of [entry.command, entry.cwd ?? ""]) {
155
+ let index = text.indexOf(prefix);
156
+ while (index !== -1) {
157
+ const rest = text.slice(index + prefix.length);
158
+ const sessionId = /^[A-Za-z0-9._-]+/.exec(rest)?.[0];
159
+ if (sessionId && sessionId !== "." && sessionId !== "..") {
160
+ const roots = found.get(sessionId) ?? new Set();
161
+ roots.add(node_path_1.default.join(sessionsRoot, sessionId));
162
+ found.set(sessionId, roots);
163
+ }
164
+ index = text.indexOf(prefix, index + 1);
165
+ }
166
+ }
167
+ }
168
+ }
169
+ return found;
170
+ }
171
+ /** SIGTERM, a grace period, then SIGKILL for anything still running. */
172
+ async function terminateProcesses(pids, deps = {}) {
173
+ if (pids.length === 0)
174
+ return [];
175
+ const kill = deps.kill ?? ((pid, signal) => process.kill(pid, signal));
176
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
177
+ const alive = (pid) => {
178
+ try {
179
+ kill(pid, 0);
180
+ return true;
181
+ }
182
+ catch {
183
+ return false;
184
+ }
185
+ };
186
+ const signalled = pids.filter((pid) => {
187
+ try {
188
+ kill(pid, "SIGTERM");
189
+ return true;
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ });
195
+ const graceMs = deps.graceMs ?? DEFAULT_GRACE_MS;
196
+ let remaining = signalled;
197
+ for (let waited = 0; remaining.length > 0 && waited < graceMs; waited += POLL_MS) {
198
+ await sleep(POLL_MS);
199
+ remaining = remaining.filter(alive);
200
+ }
201
+ for (const pid of remaining) {
202
+ try {
203
+ kill(pid, "SIGKILL");
204
+ }
205
+ catch { /* Exited between the check and the kill. */ }
206
+ }
207
+ return signalled;
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
+ }
231
+ async function spellings(root) {
232
+ const resolved = node_path_1.default.resolve(root);
233
+ const real = await node_fs_1.promises.realpath(resolved).catch(() => resolved);
234
+ return real === resolved ? [resolved] : [resolved, real];
235
+ }
236
+ /**
237
+ * Stops every process still tied to a finished session's directory. Never
238
+ * throws: a failed listing must not block worktree cleanup.
239
+ */
240
+ async function reapSessionProcesses(sessionRoot, deps = {}) {
241
+ try {
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);
244
+ if (stopped.length > 0) {
245
+ log.info(`stopped ${stopped.length} process(es) left running by ${node_path_1.default.basename(sessionRoot)}: ${stopped.join(" ")}`);
246
+ }
247
+ return stopped;
248
+ }
249
+ catch (error) {
250
+ log.warn(`could not stop processes left by ${node_path_1.default.basename(sessionRoot)}: ${String(error)}`);
251
+ return [];
252
+ }
253
+ }
254
+ /**
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.
263
+ */
264
+ async function reapOrphanedSessionProcesses(sessionsRoot, isSessionActive, deps = {}) {
265
+ const hasEnded = deps.hasEnded ?? sessionDirectoryMissing;
266
+ try {
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);
280
+ if (stopped.length > 0) {
281
+ log.info(`stopped ${stopped.length} process(es) left by ended sessions: ${stopped.join(" ")}`);
282
+ }
283
+ return stopped;
284
+ }
285
+ catch (error) {
286
+ log.warn(`orphaned session process sweep failed: ${String(error)}`);
287
+ return [];
288
+ }
289
+ }
290
+ async function sessionDirectoryMissing(_sessionId, sessionRoot) {
291
+ return node_fs_1.promises.stat(sessionRoot).then(() => false, () => true);
292
+ }
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
  }
@@ -5,21 +5,61 @@ 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");
11
13
  const logger_cjs_1 = require("./logger.cjs");
12
14
  const sandbox_cjs_1 = require("./sandbox.cjs");
15
+ const session_processes_cjs_1 = require("./session-processes.cjs");
13
16
  const log = (0, logger_cjs_1.createLogger)("worktree-janitor");
14
17
  exports.SESSION_ACTIVITY_FILENAME = ".navarch-active";
15
18
  exports.SESSION_RECOVERY_FILENAME = ".navarch-recovery-required";
19
+ const defaultProcessReaper = {
20
+ reapSession: (sessionRoot) => (0, session_processes_cjs_1.reapSessionProcesses)(sessionRoot),
21
+ reapOrphans: (sessionsRoot, isSessionActive) => (0, session_processes_cjs_1.reapOrphanedSessionProcesses)(sessionsRoot, isSessionActive, { hasEnded: sessionHasEnded }),
22
+ };
16
23
  /**
17
24
  * Refreshes the durable activity marker used by janitors in other runtime
18
- * 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.
19
28
  */
20
- async function markSessionWorkspaceActive(sessionRoot) {
29
+ async function markSessionWorkspaceActive(sessionRoot, ownerPid = process.pid) {
21
30
  await node_fs_1.promises.mkdir(sessionRoot, { recursive: true });
22
- 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);
23
63
  }
24
64
  /**
25
65
  * Periodically removes session worktrees left behind by SIGKILL, a host
@@ -33,12 +73,14 @@ class WorktreeJanitor {
33
73
  options;
34
74
  runner;
35
75
  now;
76
+ processReaper;
36
77
  timer = null;
37
78
  sweepInFlight = null;
38
79
  constructor(options) {
39
80
  this.options = options;
40
81
  this.runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
41
82
  this.now = options.now ?? Date.now;
83
+ this.processReaper = options.processReaper ?? defaultProcessReaper;
42
84
  }
43
85
  start() {
44
86
  if (this.timer)
@@ -118,6 +160,7 @@ class WorktreeJanitor {
118
160
  summary.skippedActive += 1;
119
161
  continue;
120
162
  }
163
+ await this.processReaper.reapSession(sessionRoot);
121
164
  await this.removeSessionWorktree(sessionRoot);
122
165
  summary.removed += 1;
123
166
  }
@@ -126,6 +169,9 @@ class WorktreeJanitor {
126
169
  log.warn(`could not clean abandoned session ${sessionId}: ${String(error)}`);
127
170
  }
128
171
  }
172
+ // Processes can outlive their session: a runtime that crashed before
173
+ // stopping them, or a runtime older than 0.1.98.
174
+ await this.processReaper.reapOrphans(sessionsRoot, (sessionId) => this.options.isSessionActive?.(sessionId) ?? false);
129
175
  return summary;
130
176
  }
131
177
  async lastActivityAt(sessionRoot) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.97",
3
+ "version": "0.1.99",
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",