@sagentlab/navarch-runtime 0.1.97 → 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.
@@ -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.97",
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",