@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.
@@ -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 processes = await listProcesses(deps);
220
- const pids = sessionProcessIds(processes, await spellings(sessionRoot), deps.selfPid ?? process.pid);
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 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.
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 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);
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 deleted sessions: ${stopped.join(" ")}`);
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 directory: a runtime that crashed
138
- // before stopping them, or a runtime older than 0.1.98.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.98",
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",