agent-lyceum 0.19.0 → 0.21.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 +30 -2
- package/dist/monitor-lifecycle.d.ts +19 -0
- package/dist/monitor-lifecycle.js +42 -0
- package/dist/parent-watch.d.ts +17 -0
- package/dist/parent-watch.js +37 -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
|
@@ -22,6 +22,8 @@ import { prepareTask, readTaskFile, taskMessageBody } from "./task.js";
|
|
|
22
22
|
import { callAgent, callProject, callsDir, resolveCallAgent } from "./call.js";
|
|
23
23
|
import { formatEnforcement, validateProject } from "./validate.js";
|
|
24
24
|
import { must } from "./assert.js";
|
|
25
|
+
import { runMonitor } from "./monitor-lifecycle.js";
|
|
26
|
+
import { LAUNCH_PPID, startedByWrapper, watchParent } from "./parent-watch.js";
|
|
25
27
|
import { onlyAgent, soloProject } from "./solo.js";
|
|
26
28
|
import { DEMO_PROJECT, DEMO_TASK, defaultDemoDir, detectRuntime, prepareDemo } from "./demo.js";
|
|
27
29
|
const program = new Command();
|
|
@@ -145,6 +147,8 @@ function takeLock(pr, runId, opts = {}) {
|
|
|
145
147
|
/**
|
|
146
148
|
* Ctrl-C (or SIGTERM) stops the run cleanly: the first signal cancels it (agents are killed, unread mail is kept,
|
|
147
149
|
* the lock is released, exit code 130); a second one quits at once.
|
|
150
|
+
* Started through a launcher that does not relay SIGHUP (npx, npm exec, tsx), the launcher exiting counts as that
|
|
151
|
+
* first signal: otherwise closing the terminal would leave this process running on its own (spec 21).
|
|
148
152
|
*/
|
|
149
153
|
function cancelOnSignals() {
|
|
150
154
|
const ac = new AbortController();
|
|
@@ -157,13 +161,29 @@ function cancelOnSignals() {
|
|
|
157
161
|
else
|
|
158
162
|
process.exit(130);
|
|
159
163
|
};
|
|
164
|
+
const onParentGone = () => {
|
|
165
|
+
if (seen > 0)
|
|
166
|
+
return; // already stopping
|
|
167
|
+
seen = 1;
|
|
168
|
+
// stderr may be a dead terminal or pipe: console.error would throw EPIPE/EIO there
|
|
169
|
+
try {
|
|
170
|
+
fs.writeSync(2, "\nLauncher exited (parent process gone): stopping the run (running agents are being stopped; unread mail is kept).\n");
|
|
171
|
+
}
|
|
172
|
+
catch { }
|
|
173
|
+
ac.abort();
|
|
174
|
+
};
|
|
160
175
|
const onInt = handler("Interrupted");
|
|
161
176
|
const onTerm = handler("Terminated");
|
|
177
|
+
const onHup = handler("Hung up");
|
|
162
178
|
process.on("SIGINT", onInt);
|
|
163
179
|
process.on("SIGTERM", onTerm);
|
|
180
|
+
process.on("SIGHUP", onHup); // the terminal went away
|
|
181
|
+
const stopWatch = startedByWrapper() ? watchParent(onParentGone, { startPpid: LAUNCH_PPID }) : () => { };
|
|
164
182
|
return { signal: ac.signal, dispose: () => {
|
|
183
|
+
stopWatch();
|
|
165
184
|
process.off("SIGINT", onInt);
|
|
166
185
|
process.off("SIGTERM", onTerm);
|
|
186
|
+
process.off("SIGHUP", onHup);
|
|
167
187
|
},
|
|
168
188
|
};
|
|
169
189
|
}
|
|
@@ -774,8 +794,16 @@ program
|
|
|
774
794
|
}
|
|
775
795
|
process.stdout.write(`\x1b[2J\x1b[H${text}\n\nRefreshing every ${sec}s — ${new Date().toLocaleTimeString()} — Ctrl-C to quit\n`);
|
|
776
796
|
};
|
|
777
|
-
|
|
778
|
-
|
|
797
|
+
runMonitor({
|
|
798
|
+
draw,
|
|
799
|
+
intervalMs: sec * 1000,
|
|
800
|
+
stdout: process.stdout,
|
|
801
|
+
stdin: process.stdin,
|
|
802
|
+
ppid: () => process.ppid,
|
|
803
|
+
exit: (code) => process.exit(code),
|
|
804
|
+
onSignal: (sig, handler) => process.on(sig, handler),
|
|
805
|
+
fail: (message) => fail(message),
|
|
806
|
+
});
|
|
779
807
|
await new Promise(() => { });
|
|
780
808
|
}
|
|
781
809
|
catch (e) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { EventEmitter } from "node:events";
|
|
2
|
+
export interface MonitorDeps {
|
|
3
|
+
draw: () => void;
|
|
4
|
+
intervalMs: number;
|
|
5
|
+
stdout: EventEmitter & {
|
|
6
|
+
destroyed?: boolean;
|
|
7
|
+
writable?: boolean;
|
|
8
|
+
};
|
|
9
|
+
stdin: EventEmitter & {
|
|
10
|
+
isTTY?: boolean;
|
|
11
|
+
resume: () => unknown;
|
|
12
|
+
};
|
|
13
|
+
ppid: () => number;
|
|
14
|
+
exit: (code: number) => void;
|
|
15
|
+
onSignal: (sig: NodeJS.Signals, handler: () => void) => void;
|
|
16
|
+
fail: (message: string) => void;
|
|
17
|
+
}
|
|
18
|
+
/** Run `draw` every interval and end the process when the monitor is closed: signal, dead stdout, TTY stdin close, or lost parent. */
|
|
19
|
+
export declare function runMonitor(d: MonitorDeps): void;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const SIGNAL_CODES = [["SIGINT", 130], ["SIGTERM", 143], ["SIGHUP", 129]];
|
|
2
|
+
/** Run `draw` every interval and end the process when the monitor is closed: signal, dead stdout, TTY stdin close, or lost parent. */
|
|
3
|
+
export function runMonitor(d) {
|
|
4
|
+
const startPpid = d.ppid();
|
|
5
|
+
let timer;
|
|
6
|
+
let done = false;
|
|
7
|
+
const stop = (code) => {
|
|
8
|
+
if (done)
|
|
9
|
+
return;
|
|
10
|
+
done = true;
|
|
11
|
+
if (timer)
|
|
12
|
+
clearInterval(timer);
|
|
13
|
+
d.exit(code);
|
|
14
|
+
};
|
|
15
|
+
for (const [sig, code] of SIGNAL_CODES)
|
|
16
|
+
d.onSignal(sig, () => stop(code));
|
|
17
|
+
d.stdout.on("error", (e) => {
|
|
18
|
+
if (e.code === "EPIPE" || e.code === "EIO")
|
|
19
|
+
return stop(0);
|
|
20
|
+
done = true;
|
|
21
|
+
if (timer)
|
|
22
|
+
clearInterval(timer);
|
|
23
|
+
d.fail(e.message);
|
|
24
|
+
});
|
|
25
|
+
if (d.stdin.isTTY) {
|
|
26
|
+
for (const ev of ["end", "close", "error"])
|
|
27
|
+
d.stdin.on(ev, () => stop(0));
|
|
28
|
+
d.stdin.resume(); // a TTY only reports EOF while it is being read
|
|
29
|
+
}
|
|
30
|
+
const tick = () => {
|
|
31
|
+
if (done)
|
|
32
|
+
return;
|
|
33
|
+
if (d.ppid() !== startPpid)
|
|
34
|
+
return stop(129);
|
|
35
|
+
if (d.stdout.destroyed || d.stdout.writable === false)
|
|
36
|
+
return stop(0);
|
|
37
|
+
d.draw();
|
|
38
|
+
};
|
|
39
|
+
tick();
|
|
40
|
+
if (!done)
|
|
41
|
+
timer = setInterval(tick, d.intervalMs);
|
|
42
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** The parent pid when the process started (read at module load, before config loading and preflight). */
|
|
2
|
+
export declare const LAUNCH_PPID: number;
|
|
3
|
+
/**
|
|
4
|
+
* Started by a launcher that does not relay SIGHUP: npm/npx/pnpm/yarn (they set `npm_execpath`), or the tsx CLI
|
|
5
|
+
* (its child runs with `--require …/tsx/dist/preflight.cjs`). A directly run binary is never watched, so runs that are
|
|
6
|
+
* detached on purpose (`&`, `disown`, `setsid`, supervisors) keep running when their parent exits.
|
|
7
|
+
*/
|
|
8
|
+
export declare function startedByWrapper(env?: NodeJS.ProcessEnv, execArgv?: readonly string[]): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Call `onGone` once when the parent pid differs from `startPpid` (the launcher exited and the process was adopted):
|
|
11
|
+
* checked at once, then every `intervalMs` on an unref'd timer. Returns a function that stops the watch.
|
|
12
|
+
*/
|
|
13
|
+
export declare function watchParent(onGone: () => void, opts: {
|
|
14
|
+
startPpid: number;
|
|
15
|
+
ppid?: () => number;
|
|
16
|
+
intervalMs?: number;
|
|
17
|
+
}): () => void;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** The parent pid when the process started (read at module load, before config loading and preflight). */
|
|
2
|
+
export const LAUNCH_PPID = process.ppid;
|
|
3
|
+
/**
|
|
4
|
+
* Started by a launcher that does not relay SIGHUP: npm/npx/pnpm/yarn (they set `npm_execpath`), or the tsx CLI
|
|
5
|
+
* (its child runs with `--require …/tsx/dist/preflight.cjs`). A directly run binary is never watched, so runs that are
|
|
6
|
+
* detached on purpose (`&`, `disown`, `setsid`, supervisors) keep running when their parent exits.
|
|
7
|
+
*/
|
|
8
|
+
export function startedByWrapper(env = process.env, execArgv = process.execArgv) {
|
|
9
|
+
if (env.npm_execpath)
|
|
10
|
+
return true;
|
|
11
|
+
return execArgv.some((a) => /[\\/]tsx[\\/]dist[\\/]preflight\.cjs$/.test(a));
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Call `onGone` once when the parent pid differs from `startPpid` (the launcher exited and the process was adopted):
|
|
15
|
+
* checked at once, then every `intervalMs` on an unref'd timer. Returns a function that stops the watch.
|
|
16
|
+
*/
|
|
17
|
+
export function watchParent(onGone, opts) {
|
|
18
|
+
const ppid = opts.ppid ?? (() => process.ppid);
|
|
19
|
+
let timer;
|
|
20
|
+
const stop = () => {
|
|
21
|
+
if (timer)
|
|
22
|
+
clearInterval(timer);
|
|
23
|
+
timer = undefined;
|
|
24
|
+
};
|
|
25
|
+
if (ppid() !== opts.startPpid) {
|
|
26
|
+
onGone();
|
|
27
|
+
return stop;
|
|
28
|
+
}
|
|
29
|
+
timer = setInterval(() => {
|
|
30
|
+
if (ppid() === opts.startPpid)
|
|
31
|
+
return;
|
|
32
|
+
stop();
|
|
33
|
+
onGone();
|
|
34
|
+
}, opts.intervalMs ?? 1000);
|
|
35
|
+
timer.unref();
|
|
36
|
+
return stop;
|
|
37
|
+
}
|
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(() => {
|