agent-lyceum 0.19.0 → 0.20.0
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/cli.js +3 -0
- package/dist/process-runner.d.ts +10 -0
- package/dist/process-runner.js +122 -12
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -159,11 +159,14 @@ function cancelOnSignals() {
|
|
|
159
159
|
};
|
|
160
160
|
const onInt = handler("Interrupted");
|
|
161
161
|
const onTerm = handler("Terminated");
|
|
162
|
+
const onHup = handler("Hung up");
|
|
162
163
|
process.on("SIGINT", onInt);
|
|
163
164
|
process.on("SIGTERM", onTerm);
|
|
165
|
+
process.on("SIGHUP", onHup); // the terminal went away
|
|
164
166
|
return { signal: ac.signal, dispose: () => {
|
|
165
167
|
process.off("SIGINT", onInt);
|
|
166
168
|
process.off("SIGTERM", onTerm);
|
|
169
|
+
process.off("SIGHUP", onHup);
|
|
167
170
|
},
|
|
168
171
|
};
|
|
169
172
|
}
|
package/dist/process-runner.d.ts
CHANGED
|
@@ -13,6 +13,16 @@ export interface RunnerOptions {
|
|
|
13
13
|
/** How long to wait for output pipes to close after the process is gone or killed, before giving up on them. Default 1000. */
|
|
14
14
|
settleWaitMs?: number;
|
|
15
15
|
}
|
|
16
|
+
/** Pids below `root` (children, grandchildren, ...) by parent links; catches descendants that left the process group (setsid, daemons). */
|
|
17
|
+
export declare function descendantsOf(root: number): number[];
|
|
18
|
+
/** Sampling interval of the descendant timer (default 2000 ms); for tests. Takes effect for the next timer. */
|
|
19
|
+
export declare function setSamplerIntervalMs(ms: number): void;
|
|
20
|
+
/** Whether the descendant timer is running (for tests). */
|
|
21
|
+
export declare function samplerActive(): boolean;
|
|
22
|
+
/** How many runtimes are registered as running (for tests). */
|
|
23
|
+
export declare function runningCount(): number;
|
|
24
|
+
/** Synchronously SIGKILL every runtime still running, with the descendants seen when it was started or last checked. Used when agent-lyceum itself is going away. */
|
|
25
|
+
export declare function killAllRunning(): void;
|
|
16
26
|
/**
|
|
17
27
|
* Run an invocation as a child process in its own process group, with a timeout, cancellation, bounded memory
|
|
18
28
|
* and a bounded finish: whatever the runtime and its descendants do with the output pipes, this settles once,
|
package/dist/process-runner.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
/** Most output kept in memory per stream; the rest only goes to the log files. */
|
|
@@ -31,6 +31,121 @@ function endStream(s) {
|
|
|
31
31
|
});
|
|
32
32
|
});
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Runtimes that are running right now, with the descendants seen so far, so an exiting agent-lyceum can take them with it.
|
|
36
|
+
* A descendant that left the process group (setsid, daemon) is only found by parent links while its parent is alive, so a
|
|
37
|
+
* shared timer samples the process table while any runtime is registered. Remaining gap: a child that escapes and
|
|
38
|
+
* outlives its parent within one sampling interval is never seen.
|
|
39
|
+
*/
|
|
40
|
+
const live = new Map();
|
|
41
|
+
let exitHook = false;
|
|
42
|
+
let sampler;
|
|
43
|
+
let samplerMs = 2000;
|
|
44
|
+
/** Parent -> children from one `ps` run, or undefined when it fails or hangs (SIGKILL on timeout: a ps that ignores SIGTERM must not block, even in the exit hook). */
|
|
45
|
+
function processTable() {
|
|
46
|
+
if (process.platform === "win32")
|
|
47
|
+
return undefined;
|
|
48
|
+
const ps = spawnSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8", timeout: 2000, killSignal: "SIGKILL" });
|
|
49
|
+
if (ps.status !== 0 || !ps.stdout)
|
|
50
|
+
return undefined;
|
|
51
|
+
const kids = new Map();
|
|
52
|
+
for (const line of ps.stdout.split("\n")) {
|
|
53
|
+
const [pid, ppid] = line.trim().split(/\s+/).map(Number);
|
|
54
|
+
if (!pid || !ppid)
|
|
55
|
+
continue;
|
|
56
|
+
kids.set(ppid, [...(kids.get(ppid) ?? []), pid]);
|
|
57
|
+
}
|
|
58
|
+
return kids;
|
|
59
|
+
}
|
|
60
|
+
function below(kids, root) {
|
|
61
|
+
const out = [];
|
|
62
|
+
if (!kids)
|
|
63
|
+
return out;
|
|
64
|
+
for (let todo = [root]; todo.length;) {
|
|
65
|
+
const next = [];
|
|
66
|
+
for (const p of todo) {
|
|
67
|
+
for (const k of kids.get(p) ?? []) {
|
|
68
|
+
if (out.includes(k))
|
|
69
|
+
continue;
|
|
70
|
+
out.push(k);
|
|
71
|
+
next.push(k);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
todo = next;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** Pids below `root` (children, grandchildren, ...) by parent links; catches descendants that left the process group (setsid, daemons). */
|
|
79
|
+
export function descendantsOf(root) {
|
|
80
|
+
return below(processTable(), root);
|
|
81
|
+
}
|
|
82
|
+
/** Record the current descendants of every live runtime (one `ps` for all). */
|
|
83
|
+
function sample() {
|
|
84
|
+
if (!live.size)
|
|
85
|
+
return;
|
|
86
|
+
const kids = processTable();
|
|
87
|
+
for (const [pid, seen] of live)
|
|
88
|
+
for (const d of below(kids, pid))
|
|
89
|
+
seen.add(d);
|
|
90
|
+
}
|
|
91
|
+
function register(pid) {
|
|
92
|
+
live.set(pid, new Set());
|
|
93
|
+
if (!exitHook) {
|
|
94
|
+
exitHook = true;
|
|
95
|
+
process.on("exit", killAllRunning); // process.exit() and crashes must not leave detached runtimes behind
|
|
96
|
+
}
|
|
97
|
+
if (!sampler) {
|
|
98
|
+
sampler = setInterval(sample, samplerMs);
|
|
99
|
+
sampler.unref();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function unregister(pid) {
|
|
103
|
+
live.delete(pid);
|
|
104
|
+
if (!live.size && sampler) {
|
|
105
|
+
clearInterval(sampler);
|
|
106
|
+
sampler = undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Sampling interval of the descendant timer (default 2000 ms); for tests. Takes effect for the next timer. */
|
|
110
|
+
export function setSamplerIntervalMs(ms) {
|
|
111
|
+
samplerMs = ms;
|
|
112
|
+
}
|
|
113
|
+
/** Whether the descendant timer is running (for tests). */
|
|
114
|
+
export function samplerActive() {
|
|
115
|
+
return sampler !== undefined;
|
|
116
|
+
}
|
|
117
|
+
function signalPid(pid, sig, group) {
|
|
118
|
+
try {
|
|
119
|
+
process.kill(group && process.platform !== "win32" ? -pid : pid, sig);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* already gone */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Signal the runtime's group, the descendants recorded earlier that are still in the process table, and its current descendants. */
|
|
126
|
+
function killTree(pid, sig) {
|
|
127
|
+
const kids = processTable();
|
|
128
|
+
const seen = live.get(pid) ?? new Set();
|
|
129
|
+
const now = below(kids, pid);
|
|
130
|
+
const present = kids ? new Set([...kids.values()].flat()) : undefined; // without a table, trust the record
|
|
131
|
+
for (const d of now)
|
|
132
|
+
seen.add(d);
|
|
133
|
+
signalPid(pid, sig, true);
|
|
134
|
+
for (const d of seen)
|
|
135
|
+
if (!present || present.has(d))
|
|
136
|
+
signalPid(d, sig, false);
|
|
137
|
+
}
|
|
138
|
+
/** How many runtimes are registered as running (for tests). */
|
|
139
|
+
export function runningCount() {
|
|
140
|
+
return live.size;
|
|
141
|
+
}
|
|
142
|
+
/** Synchronously SIGKILL every runtime still running, with the descendants seen when it was started or last checked. Used when agent-lyceum itself is going away. */
|
|
143
|
+
export function killAllRunning() {
|
|
144
|
+
for (const pid of [...live.keys()]) {
|
|
145
|
+
killTree(pid, "SIGKILL");
|
|
146
|
+
unregister(pid);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
34
149
|
/**
|
|
35
150
|
* Run an invocation as a child process in its own process group, with a timeout, cancellation, bounded memory
|
|
36
151
|
* and a bounded finish: whatever the runtime and its descendants do with the output pipes, this settles once,
|
|
@@ -49,6 +164,8 @@ export function runInvocation(inv, opts) {
|
|
|
49
164
|
outLog.on("error", () => { });
|
|
50
165
|
errLog.on("error", () => { });
|
|
51
166
|
const child = spawn(inv.cmd, inv.args, { cwd: inv.cwd, env: inv.env, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
|
|
167
|
+
if (child.pid !== undefined)
|
|
168
|
+
register(child.pid);
|
|
52
169
|
const stdoutTail = new Tail();
|
|
53
170
|
const stderrTail = new Tail();
|
|
54
171
|
const parser = inv.stream?.();
|
|
@@ -58,17 +175,8 @@ export function runInvocation(inv, opts) {
|
|
|
58
175
|
let settled = false;
|
|
59
176
|
const timers = [];
|
|
60
177
|
const killGroup = (sig) => {
|
|
61
|
-
if (child.pid
|
|
62
|
-
|
|
63
|
-
try {
|
|
64
|
-
if (process.platform === "win32")
|
|
65
|
-
child.kill(sig);
|
|
66
|
-
else
|
|
67
|
-
process.kill(-child.pid, sig);
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
/* nothing left to signal */
|
|
71
|
-
}
|
|
178
|
+
if (child.pid !== undefined)
|
|
179
|
+
killTree(child.pid, sig);
|
|
72
180
|
};
|
|
73
181
|
const finish = (spawnError) => {
|
|
74
182
|
if (settled)
|
|
@@ -78,6 +186,8 @@ export function runInvocation(inv, opts) {
|
|
|
78
186
|
clearTimeout(t);
|
|
79
187
|
opts.signal?.removeEventListener("abort", onAbort);
|
|
80
188
|
killGroup("SIGKILL"); // whatever of the tree is still around
|
|
189
|
+
if (child.pid !== undefined)
|
|
190
|
+
unregister(child.pid);
|
|
81
191
|
child.stdout?.destroy();
|
|
82
192
|
child.stderr?.destroy();
|
|
83
193
|
void Promise.all([endStream(outLog), endStream(errLog)]).then(() => {
|