@brutalsystems/muster 3.0.2 → 3.1.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/README.md +4 -0
- package/dist/cli-options.js +60 -0
- package/dist/duration.js +17 -0
- package/dist/format.js +3 -0
- package/dist/help.js +25 -0
- package/dist/identity/processes.js +6 -3
- package/dist/muster.js +94 -47
- package/dist/on-exit.js +76 -0
- package/dist/registry.js +81 -1
- package/dist/run.js +381 -64
- package/dist/task-worker.js +7 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -70,6 +70,10 @@ muster run claude --prompt 'Review this project' --cwd .
|
|
|
70
70
|
muster run codex --kind task --prompt 'Summarise the failing tests' --read-only
|
|
71
71
|
muster output <id>
|
|
72
72
|
|
|
73
|
+
# Knowing it has finished: block on it, or have muster run a command then.
|
|
74
|
+
muster wait <id> --timeout 30m
|
|
75
|
+
muster run codex --kind task --prompt 'Review' --on-exit 'notify "$MUSTER_STATE"'
|
|
76
|
+
|
|
73
77
|
# What is running, and stopping it.
|
|
74
78
|
muster list --format human
|
|
75
79
|
muster stop <id>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every flag any command takes, in one table so `asksForHelp` can tell which
|
|
3
|
+
* flags consume the next word: `--prompt --help` is a prompt, not a request
|
|
4
|
+
* for help.
|
|
5
|
+
*/
|
|
6
|
+
export const CLI_OPTIONS = {
|
|
7
|
+
prompt: { type: "string" },
|
|
8
|
+
cwd: { type: "string" },
|
|
9
|
+
kind: { type: "string" },
|
|
10
|
+
host: { type: "string" },
|
|
11
|
+
open: { type: "boolean" },
|
|
12
|
+
terminal: { type: "string" },
|
|
13
|
+
format: { type: "string" },
|
|
14
|
+
permissions: { type: "string" },
|
|
15
|
+
sandbox: { type: "string" },
|
|
16
|
+
level: { type: "string" },
|
|
17
|
+
"read-only": { type: "boolean" },
|
|
18
|
+
"write-here": { type: "boolean" },
|
|
19
|
+
unrestricted: { type: "boolean" },
|
|
20
|
+
mcp: { type: "string", multiple: true },
|
|
21
|
+
options: { type: "string", multiple: true },
|
|
22
|
+
"no-mcp": { type: "boolean" },
|
|
23
|
+
plugin: { type: "string", multiple: true },
|
|
24
|
+
model: { type: "string" },
|
|
25
|
+
"idle-timeout": { type: "string" },
|
|
26
|
+
ttl: { type: "string" },
|
|
27
|
+
title: { type: "string" },
|
|
28
|
+
socket: { type: "string" },
|
|
29
|
+
home: { type: "string" },
|
|
30
|
+
"stop-now": { type: "boolean" },
|
|
31
|
+
"no-plugin": { type: "boolean" },
|
|
32
|
+
"request-key": { type: "string" },
|
|
33
|
+
project: { type: "string" },
|
|
34
|
+
identity: { type: "string" },
|
|
35
|
+
agent: { type: "string" },
|
|
36
|
+
"token-env": { type: "string" },
|
|
37
|
+
interactive: { type: "boolean" },
|
|
38
|
+
"on-exit": { type: "string" },
|
|
39
|
+
timeout: { type: "string" },
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Whether a command's arguments ask for its help: `--help` or `-h` before any
|
|
43
|
+
* `--`, and not the value of a flag that takes one.
|
|
44
|
+
*/
|
|
45
|
+
export function asksForHelp(args) {
|
|
46
|
+
for (let i = 0; i < args.length; i++) {
|
|
47
|
+
const arg = args[i];
|
|
48
|
+
if (arg === "--")
|
|
49
|
+
return false;
|
|
50
|
+
if (arg === "--help" || arg === "-h")
|
|
51
|
+
return true;
|
|
52
|
+
const name = arg.startsWith("--") ? arg.slice(2) : undefined;
|
|
53
|
+
const option = name && !name.includes("=")
|
|
54
|
+
? CLI_OPTIONS[name]
|
|
55
|
+
: undefined;
|
|
56
|
+
if (option?.type === "string")
|
|
57
|
+
i++;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
package/dist/duration.js
CHANGED
|
@@ -19,3 +19,20 @@ export function parseDuration(value, label) {
|
|
|
19
19
|
throw new Error(`${label} must be a duration like 90s, 30m, 4h, or off (got ${JSON.stringify(value)})`);
|
|
20
20
|
return seconds;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* `wait --timeout`: seconds as a bare number, which is what `timeout(1)` and
|
|
24
|
+
* shell callers write, or a duration. A lifecycle limit refuses the bare
|
|
25
|
+
* number because a wrong unit reaps a session; here the unit is the obvious
|
|
26
|
+
* one. `off` is refused: no timeout is spelled by leaving the flag out.
|
|
27
|
+
*/
|
|
28
|
+
export function waitSeconds(value) {
|
|
29
|
+
const raw = value.trim();
|
|
30
|
+
const seconds = /^\d+$/.test(raw)
|
|
31
|
+
? Number(raw)
|
|
32
|
+
: /^\d+[smh]$/i.test(raw)
|
|
33
|
+
? parseDuration(raw, "--timeout")
|
|
34
|
+
: 0;
|
|
35
|
+
if (seconds <= 0)
|
|
36
|
+
throw new Error(`--timeout must be seconds or a duration like 90s, 30m, 4h (got ${JSON.stringify(value)})`);
|
|
37
|
+
return seconds;
|
|
38
|
+
}
|
package/dist/format.js
CHANGED
|
@@ -38,6 +38,9 @@ function recordHuman(record, color) {
|
|
|
38
38
|
const id = record.id ?? record.thread_id ?? record.session_id;
|
|
39
39
|
if (record.stopped === true)
|
|
40
40
|
return `${paint("Stopped", 90)} ${clean(id)}.`;
|
|
41
|
+
// `stop` on a launch that had already ended changes nothing and says so.
|
|
42
|
+
if (record.stopped === false)
|
|
43
|
+
return `Not stopped: ${clean(id)} had already ${clean(record.state)}.`;
|
|
41
44
|
// `muster title` answers with the id and the title alone, no kind.
|
|
42
45
|
if (record.kind === undefined && record.title !== undefined)
|
|
43
46
|
return `Titled ${clean(id)}: ${clean(record.title)}.`;
|
package/dist/help.js
CHANGED
|
@@ -97,6 +97,10 @@ export const COMMANDS = [
|
|
|
97
97
|
"--request-key KEY",
|
|
98
98
|
"name the request; sending it again returns the same launch",
|
|
99
99
|
],
|
|
100
|
+
[
|
|
101
|
+
"--on-exit COMMAND",
|
|
102
|
+
"run COMMAND with /bin/sh once the agent exits, however it ends; muster runs it, outside any sandbox, with MUSTER_ID, MUSTER_RUNTIME, MUSTER_KIND, MUSTER_STATE, MUSTER_EXIT_CODE, MUSTER_SIGNAL, MUSTER_OUTPUT, MUSTER_ERROR and MUSTER_RECORD set",
|
|
103
|
+
],
|
|
100
104
|
["--format FORMAT", "json (default) or human"],
|
|
101
105
|
["-- RUNTIME_OPTIONS", "everything after -- is passed to the runtime"],
|
|
102
106
|
],
|
|
@@ -105,11 +109,32 @@ export const COMMANDS = [
|
|
|
105
109
|
"A sealed launch does not read your profile's or the project's settings, so it also loads no rules or CLAUDE.md.",
|
|
106
110
|
"Use one of the three flags, --level, or --permissions/--sandbox; naming two is an error rather than a precedence rule.",
|
|
107
111
|
"A pty launch keeps this process running and Ctrl-C stops it; a tmux launch survives this process exiting.",
|
|
112
|
+
"--on-exit: MUSTER_EXIT_CODE and MUSTER_SIGNAL are empty for a session, whose terminal keeps no exit status, and MUSTER_OUTPUT (the captured output's path) is empty for one too. The command's own output goes to ~/.muster/on-exit/<launch id>.log. A launch that fails at readiness runs it too, with MUSTER_STATE=failed and MUSTER_ERROR. wait and list show it as on_exit: {claimed_at, result}. It is not run if the machine restarts first.",
|
|
108
113
|
],
|
|
109
114
|
examples: [
|
|
110
115
|
"muster run claude --prompt 'Review this project' --cwd .",
|
|
111
116
|
"muster run claude --read-only --prompt 'Audit this' --cwd .",
|
|
112
117
|
"muster run codex --identity work --write-here --prompt 'Ship it'",
|
|
118
|
+
"muster run codex --kind task --prompt 'Review' --on-exit 'notify.sh'",
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: "wait",
|
|
123
|
+
summary: "block until a session or task has ended",
|
|
124
|
+
synopsis: "muster wait <id> [--timeout SECONDS] [--format json|human]",
|
|
125
|
+
options: [
|
|
126
|
+
[
|
|
127
|
+
"--timeout SECONDS",
|
|
128
|
+
"give up after this long: seconds, or 90s, 30m, 4h",
|
|
129
|
+
],
|
|
130
|
+
["--format FORMAT", "json (default) or human"],
|
|
131
|
+
],
|
|
132
|
+
notes: [
|
|
133
|
+
"Prints the final record, with state, exit_code and signal (null for a session, whose terminal keeps no exit status). Exits 0 when it has ended, 2 for an id that names nothing, and 124 on timeout, printing the record as it stands.",
|
|
134
|
+
],
|
|
135
|
+
examples: [
|
|
136
|
+
"muster wait 6fae755b-8f5d-4425-8ed7-69e84bfac8c5",
|
|
137
|
+
"muster wait simple-cms-46 --timeout 30m",
|
|
113
138
|
],
|
|
114
139
|
},
|
|
115
140
|
{
|
|
@@ -96,10 +96,13 @@ export async function descendants(root, deadline = Date.now() + 1000, group) {
|
|
|
96
96
|
/** Whether anything still runs in a group we started. */
|
|
97
97
|
export async function groupAlive(group, deadline = Date.now() + 1000) {
|
|
98
98
|
try {
|
|
99
|
-
const rows = (await command("ps", ["-axo", "pid=,pgid="], Math.max(1, Math.min(1000, deadline - Date.now()))))
|
|
99
|
+
const rows = (await command("ps", ["-axo", "pid=,pgid=,stat="], Math.max(1, Math.min(1000, deadline - Date.now()))))
|
|
100
100
|
.split("\n")
|
|
101
|
-
.map((l) => l.trim().split(/\s+/)
|
|
102
|
-
|
|
101
|
+
.map((l) => l.trim().split(/\s+/));
|
|
102
|
+
// A zombie is not alive, as `processRef` already holds: a group left with
|
|
103
|
+
// only an unreaped zombie in it has ended, and counting it kept `wait`
|
|
104
|
+
// waiting for as long as its parent chose not to reap.
|
|
105
|
+
return rows.some(([pid, pgid, stat]) => Number(pid) > 1 && Number(pgid) === group && !stat?.startsWith("Z"));
|
|
103
106
|
}
|
|
104
107
|
catch {
|
|
105
108
|
return false;
|
package/dist/muster.js
CHANGED
|
@@ -5,7 +5,8 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
7
7
|
import { z } from "zod";
|
|
8
|
-
import { Muster } from "./run.js";
|
|
8
|
+
import { Muster, UnknownRun, durableId, failedLaunchId } from "./run.js";
|
|
9
|
+
import { waitSeconds } from "./duration.js";
|
|
9
10
|
import { delay } from "./identity/processes.js";
|
|
10
11
|
import { formatHuman, humanColorEnabled, statusText } from "./format.js";
|
|
11
12
|
import { checkPlugins, npmRegistry } from "./doctor.js";
|
|
@@ -17,6 +18,7 @@ import { toolGate } from "./mcp.js";
|
|
|
17
18
|
import { ALLOW_LAUNCH, MCP_SUBCOMMANDS, mcpCommand, notEnabled, selfInvocation, } from "./mcp-install.js";
|
|
18
19
|
import { normalizeOpenFlag, usesTerminalFlag } from "./guard.js";
|
|
19
20
|
import { commandHelp, topLevelHelp } from "./help.js";
|
|
21
|
+
import { CLI_OPTIONS, asksForHelp } from "./cli-options.js";
|
|
20
22
|
import { promptForMissing, terminalReader } from "./prompt.js";
|
|
21
23
|
const listSchema = z
|
|
22
24
|
.object({ kind: z.enum(["session", "task"]).optional() })
|
|
@@ -67,9 +69,10 @@ async function main() {
|
|
|
67
69
|
}
|
|
68
70
|
// Recognised before parsing, not during it: the parser is strict and has no
|
|
69
71
|
// `help` option, so `muster setup-identity --help` used to be answered with
|
|
70
|
-
// "Unknown option '--help'".
|
|
71
|
-
//
|
|
72
|
-
|
|
72
|
+
// "Unknown option '--help'". Anywhere among the command's own flags, so
|
|
73
|
+
// `muster run codex --help` is help too; see `asksForHelp` for the `--help`
|
|
74
|
+
// that is some flag's value, which still reaches the command.
|
|
75
|
+
if (commandHelp(argv[0] ?? "") && asksForHelp(argv.slice(1))) {
|
|
73
76
|
process.stdout.write(commandHelp(argv[0]));
|
|
74
77
|
return;
|
|
75
78
|
}
|
|
@@ -201,41 +204,13 @@ async function main() {
|
|
|
201
204
|
args: normalizeOpenFlag(argv),
|
|
202
205
|
allowPositionals: true,
|
|
203
206
|
strict: true,
|
|
204
|
-
options:
|
|
205
|
-
prompt: { type: "string" },
|
|
206
|
-
cwd: { type: "string" },
|
|
207
|
-
kind: { type: "string" },
|
|
208
|
-
host: { type: "string" },
|
|
209
|
-
open: { type: "boolean" },
|
|
210
|
-
terminal: { type: "string" },
|
|
211
|
-
format: { type: "string" },
|
|
212
|
-
permissions: { type: "string" },
|
|
213
|
-
sandbox: { type: "string" },
|
|
214
|
-
level: { type: "string" },
|
|
215
|
-
"read-only": { type: "boolean" },
|
|
216
|
-
"write-here": { type: "boolean" },
|
|
217
|
-
unrestricted: { type: "boolean" },
|
|
218
|
-
mcp: { type: "string", multiple: true },
|
|
219
|
-
options: { type: "string", multiple: true },
|
|
220
|
-
"no-mcp": { type: "boolean" },
|
|
221
|
-
plugin: { type: "string", multiple: true },
|
|
222
|
-
model: { type: "string" },
|
|
223
|
-
"idle-timeout": { type: "string" },
|
|
224
|
-
ttl: { type: "string" },
|
|
225
|
-
title: { type: "string" },
|
|
226
|
-
socket: { type: "string" },
|
|
227
|
-
home: { type: "string" },
|
|
228
|
-
"stop-now": { type: "boolean" },
|
|
229
|
-
"no-plugin": { type: "boolean" },
|
|
230
|
-
"request-key": { type: "string" },
|
|
231
|
-
project: { type: "string" },
|
|
232
|
-
identity: { type: "string" },
|
|
233
|
-
agent: { type: "string" },
|
|
234
|
-
"token-env": { type: "string" },
|
|
235
|
-
interactive: { type: "boolean" },
|
|
236
|
-
},
|
|
207
|
+
options: CLI_OPTIONS,
|
|
237
208
|
});
|
|
238
|
-
const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, "request-key": requestKey, "idle-timeout": idleTimeout, socket, home: homeFlag, "stop-now": stopNow, "read-only": readOnly, "write-here": writeHere, unrestricted, ...values } = parsed;
|
|
209
|
+
const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, "request-key": requestKey, "idle-timeout": idleTimeout, socket, home: homeFlag, "stop-now": stopNow, "read-only": readOnly, "write-here": writeHere, unrestricted, "on-exit": onExit, timeout, ...values } = parsed;
|
|
210
|
+
if (onExit !== undefined && command !== "run")
|
|
211
|
+
throw new Error("--on-exit applies only to run");
|
|
212
|
+
if (timeout !== undefined && command !== "wait")
|
|
213
|
+
throw new Error("--timeout applies only to wait");
|
|
239
214
|
// The plain spellings of the three sealed levels. Each is a level, so the
|
|
240
215
|
// existing exclusivity with --permissions/--sandbox applies unchanged once
|
|
241
216
|
// it is folded into `values.level`.
|
|
@@ -267,7 +242,9 @@ async function main() {
|
|
|
267
242
|
["--home", homeFlag],
|
|
268
243
|
["--stop-now", stopNow],
|
|
269
244
|
])
|
|
270
|
-
if (value !== undefined &&
|
|
245
|
+
if (value !== undefined &&
|
|
246
|
+
command !== "reap-check" &&
|
|
247
|
+
!(name === "--home" && command === "on-exit-watch"))
|
|
271
248
|
throw new Error(`${name} applies only to reap-check`);
|
|
272
249
|
if (requestKey !== undefined && command !== "run")
|
|
273
250
|
throw new Error("--request-key applies only to run");
|
|
@@ -297,13 +274,41 @@ async function main() {
|
|
|
297
274
|
process.stderr.write("[muster] --terminal is deprecated and will be removed in v2; pass the app to --open instead, or set terminal in config.toml\n");
|
|
298
275
|
const runtime = positionals[0];
|
|
299
276
|
const args = positionals.slice(1);
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
277
|
+
let record;
|
|
278
|
+
try {
|
|
279
|
+
record = await muster.run({
|
|
280
|
+
runtime,
|
|
281
|
+
...values,
|
|
282
|
+
...(requestKey === undefined ? {} : { requestKey }),
|
|
283
|
+
...(idleTimeout === undefined ? {} : { idleTimeout }),
|
|
284
|
+
args,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
// A launch that failed still ended, and the caller is waiting on its
|
|
289
|
+
// command to say so: armed on the failed entry, the watcher finds it
|
|
290
|
+
// already over and runs it with MUSTER_STATE=failed and MUSTER_ERROR.
|
|
291
|
+
// The launch's own error is still what this command fails with.
|
|
292
|
+
const launchId = failedLaunchId(e);
|
|
293
|
+
if (onExit !== undefined && launchId)
|
|
294
|
+
await muster
|
|
295
|
+
.armOnExit(launchId, onExit, process.cwd())
|
|
296
|
+
.catch((armError) => process.stderr.write(`[muster] --on-exit not run for the failed launch: ${armError.message}\n`));
|
|
297
|
+
throw e;
|
|
298
|
+
}
|
|
299
|
+
// Armed after the launch and before the record is printed, so a caller
|
|
300
|
+
// that reads the record knows the watcher exists. A launch that is up
|
|
301
|
+
// but could not be armed still prints its record — the session is real
|
|
302
|
+
// and needs stopping — and then fails, because the caller asked for a
|
|
303
|
+
// signal it will not get.
|
|
304
|
+
if (onExit !== undefined)
|
|
305
|
+
try {
|
|
306
|
+
await muster.armOnExit(durableId(record), onExit, process.cwd());
|
|
307
|
+
}
|
|
308
|
+
catch (e) {
|
|
309
|
+
print(record);
|
|
310
|
+
throw e;
|
|
311
|
+
}
|
|
307
312
|
print(record);
|
|
308
313
|
if (record.kind === "session" && record.host === "pty") {
|
|
309
314
|
process.stderr.write("[muster] pty: not watchable or attachable; this process owns the session. Ctrl-C stops it.\n");
|
|
@@ -409,6 +414,41 @@ async function main() {
|
|
|
409
414
|
const { id } = idSchema.parse({ id: positionals[0] });
|
|
410
415
|
print(await muster.title(id, positionals[1]));
|
|
411
416
|
}
|
|
417
|
+
else if (command === "wait") {
|
|
418
|
+
if (positionals.length !== 1 || Object.keys(values).length)
|
|
419
|
+
throw new Error("wait requires exactly one id");
|
|
420
|
+
const { id } = idSchema.parse({ id: positionals[0] });
|
|
421
|
+
try {
|
|
422
|
+
const { done, record } = await muster.wait(id, {
|
|
423
|
+
...(timeout === undefined
|
|
424
|
+
? {}
|
|
425
|
+
: { timeoutMs: waitSeconds(timeout) * 1000 }),
|
|
426
|
+
});
|
|
427
|
+
print(record);
|
|
428
|
+
// 124 is timeout(1)'s status, which a shell caller already tests for.
|
|
429
|
+
if (!done)
|
|
430
|
+
process.exitCode = 124;
|
|
431
|
+
}
|
|
432
|
+
catch (e) {
|
|
433
|
+
if (!(e instanceof UnknownRun))
|
|
434
|
+
throw e;
|
|
435
|
+
process.stderr.write(`[muster] ${e.message}\n`);
|
|
436
|
+
process.exitCode = 2;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
else if (command === "on-exit-watch") {
|
|
440
|
+
// Hidden, like reap-check: started detached by `run --on-exit`, never
|
|
441
|
+
// typed. Its own exit status is unread; the command's goes to the log.
|
|
442
|
+
if (positionals.length !== 1 || !homeFlag)
|
|
443
|
+
throw new Error("on-exit-watch requires --home and one launch id");
|
|
444
|
+
const watcher = await Muster.create({ home: homeFlag });
|
|
445
|
+
try {
|
|
446
|
+
await watcher.runOnExit(positionals[0]);
|
|
447
|
+
}
|
|
448
|
+
finally {
|
|
449
|
+
await watcher.close();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
412
452
|
else if (command === "reap-check") {
|
|
413
453
|
// Hidden: armed by muster itself as a tmux job, never typed by a person.
|
|
414
454
|
// NOTE: `Muster.create()` above already ran against the REAL home before
|
|
@@ -435,6 +475,13 @@ async function main() {
|
|
|
435
475
|
}
|
|
436
476
|
}
|
|
437
477
|
main().catch((e) => {
|
|
438
|
-
process.
|
|
478
|
+
const command = process.argv[2] ?? "";
|
|
479
|
+
// parseArgs's own text explains how to pass a positional that starts with a
|
|
480
|
+
// dash, which is never what a mistyped flag meant; the flag list is.
|
|
481
|
+
const hint = e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" &&
|
|
482
|
+
commandHelp(command)
|
|
483
|
+
? `\n[muster] see \`muster ${command} --help\` for its options`
|
|
484
|
+
: "";
|
|
485
|
+
process.stderr.write(`[muster] ${e.message}${hint}\n`);
|
|
439
486
|
process.exitCode = 1;
|
|
440
487
|
});
|
package/dist/on-exit.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running an `--on-exit` command: what it is told, and where its output goes.
|
|
3
|
+
* Kept out of run.ts so the environment contract can be tested without a
|
|
4
|
+
* launch, since that contract is what every caller's script depends on.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdir, open, stat } from "node:fs/promises";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
/**
|
|
10
|
+
* The variables the command receives. Strings only, and an empty string where
|
|
11
|
+
* the fact was not observed — a session's exit status, a session's output —
|
|
12
|
+
* because a shell cannot tell an unset variable from a mistyped one.
|
|
13
|
+
*/
|
|
14
|
+
export function onExitEnv(entry, record) {
|
|
15
|
+
return {
|
|
16
|
+
MUSTER_ID: String(record.id),
|
|
17
|
+
MUSTER_RUNTIME: entry.runtime,
|
|
18
|
+
MUSTER_KIND: entry.kind,
|
|
19
|
+
MUSTER_STATE: String(record.state),
|
|
20
|
+
MUSTER_EXIT_CODE: record.exit_code === null || record.exit_code === undefined
|
|
21
|
+
? ""
|
|
22
|
+
: String(record.exit_code),
|
|
23
|
+
MUSTER_SIGNAL: record.signal ? String(record.signal) : "",
|
|
24
|
+
// A path rather than the text: a task's output can be megabytes, and the
|
|
25
|
+
// environment has a size limit that would fail the command outright.
|
|
26
|
+
MUSTER_OUTPUT: entry.kind === "task" && entry.outputPath ? entry.outputPath : "",
|
|
27
|
+
// Why a launch failed before it was ever up (`MUSTER_STATE=failed`).
|
|
28
|
+
MUSTER_ERROR: record.error ? String(record.error) : "",
|
|
29
|
+
MUSTER_RECORD: JSON.stringify(record),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Where the command will run and where its output will go, settled before the
|
|
34
|
+
* command is claimed, because a claim is spent whether or not it then starts.
|
|
35
|
+
* Neither failure is allowed to cost the signal: a directory removed since the
|
|
36
|
+
* launch falls back to `fallbackCwd`, and a log that cannot be opened leaves
|
|
37
|
+
* the output discarded. Each fallback is reported, for the launch log.
|
|
38
|
+
*/
|
|
39
|
+
export async function prepareOnExit(opts) {
|
|
40
|
+
const warnings = [];
|
|
41
|
+
let cwd = opts.cwd;
|
|
42
|
+
try {
|
|
43
|
+
if (!(await stat(cwd)).isDirectory())
|
|
44
|
+
throw new Error("not a directory");
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
warnings.push(`cwd ${cwd} unusable (${e.message}); ran in ${opts.fallbackCwd}`);
|
|
48
|
+
cwd = opts.fallbackCwd;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
await mkdir(dirname(opts.logPath), { recursive: true, mode: 0o700 });
|
|
52
|
+
return { cwd, log: await open(opts.logPath, "a", 0o600), warnings };
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
warnings.push(`log ${opts.logPath} unusable (${e.message}); output discarded`);
|
|
56
|
+
return { cwd, warnings };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Runs `command` through `/bin/sh -c`, resolving to how it ended, or to why it
|
|
61
|
+
* never started. `onSpawn` is called once the shell is running, so whatever it
|
|
62
|
+
* does cannot keep the command from starting.
|
|
63
|
+
*/
|
|
64
|
+
export async function runOnExitCommand(opts) {
|
|
65
|
+
const out = opts.log?.fd ?? "ignore";
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
const child = spawn("/bin/sh", ["-c", opts.command], {
|
|
68
|
+
cwd: opts.cwd,
|
|
69
|
+
env: { ...opts.env, ...onExitEnv(opts.entry, opts.record) },
|
|
70
|
+
stdio: ["ignore", out, out],
|
|
71
|
+
});
|
|
72
|
+
child.once("spawn", () => opts.onSpawn?.());
|
|
73
|
+
child.once("error", (e) => resolve({ exit_code: null, signal: null, error: e.message }));
|
|
74
|
+
child.once("exit", (code, signal) => resolve({ exit_code: code, signal: signal ?? null }));
|
|
75
|
+
});
|
|
76
|
+
}
|
package/dist/registry.js
CHANGED
|
@@ -5,6 +5,19 @@ import { execFile } from "node:child_process";
|
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { processRef, isSame, delay, groupAlive, } from "./identity/processes.js";
|
|
7
7
|
import { LOCAL, requesterKey } from "./requester.js";
|
|
8
|
+
/**
|
|
9
|
+
* Whether a vanished launch was ended by its stop: a stop was asked, and no
|
|
10
|
+
* task exit file was written before it. The file is the worker's, published
|
|
11
|
+
* by rename, so its mtime is when the exit was recorded.
|
|
12
|
+
*/
|
|
13
|
+
async function stoppedBeforeExit(entry) {
|
|
14
|
+
if (!entry.stopRequestedAt)
|
|
15
|
+
return false;
|
|
16
|
+
if (entry.kind !== "task" || !entry.exitPath)
|
|
17
|
+
return true;
|
|
18
|
+
const written = await stat(entry.exitPath).then((s) => s.mtimeMs, () => undefined);
|
|
19
|
+
return written === undefined || written >= Date.parse(entry.stopRequestedAt);
|
|
20
|
+
}
|
|
8
21
|
/**
|
|
9
22
|
* A request key arrived again with different parameters. Distinct from a launch
|
|
10
23
|
* failure: nothing was attempted, and the caller reused a key it should have
|
|
@@ -114,7 +127,9 @@ export class Registry {
|
|
|
114
127
|
// concluding the session ended: it is the signal that survives both.
|
|
115
128
|
if (!(await Promise.all((entry.descendants ?? []).map(isSame))).some(Boolean) &&
|
|
116
129
|
!(entry.group && (await groupAlive(entry.group))))
|
|
117
|
-
entry.status =
|
|
130
|
+
entry.status = (await stoppedBeforeExit(entry))
|
|
131
|
+
? "stopped"
|
|
132
|
+
: "exited";
|
|
118
133
|
}
|
|
119
134
|
}
|
|
120
135
|
const result = await fn(entries);
|
|
@@ -217,6 +232,71 @@ export class Registry {
|
|
|
217
232
|
return { entry: { ...entry }, deduped: false };
|
|
218
233
|
});
|
|
219
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* One entry, read without the lock. Safe because every write lands by rename,
|
|
237
|
+
* so a reader sees the old file or the new one and never half of either. For
|
|
238
|
+
* polling only: it does not run the liveness sweep, so a status read here may
|
|
239
|
+
* be stale until the next transaction.
|
|
240
|
+
*/
|
|
241
|
+
async peek(launchId) {
|
|
242
|
+
try {
|
|
243
|
+
const entries = JSON.parse(await readFile(join(this.home, "registry.json"), "utf8"));
|
|
244
|
+
return entries.find((e) => e.launchId === launchId);
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
if (e.code === "ENOENT")
|
|
248
|
+
return undefined;
|
|
249
|
+
throw e;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* True at most once per launch. The check and the write share a transaction,
|
|
254
|
+
* so two watchers racing over the same exit cannot both run the command.
|
|
255
|
+
* At most, not exactly: a watcher that dies after claiming runs nothing.
|
|
256
|
+
*/
|
|
257
|
+
async claimOnExit(launchId) {
|
|
258
|
+
return this.transaction(async (entries) => {
|
|
259
|
+
const entry = entries.find((e) => e.launchId === launchId);
|
|
260
|
+
if (!entry?.onExit || entry.onExitFiredAt)
|
|
261
|
+
return undefined;
|
|
262
|
+
entry.onExitFiredAt = new Date().toISOString();
|
|
263
|
+
return { ...entry };
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Records that `stop` was asked, unless the launch has already ended, in
|
|
268
|
+
* which case nothing changes and undefined is returned: stopping a finished
|
|
269
|
+
* launch must not rewrite how it finished. `ended` is asked inside the
|
|
270
|
+
* transaction, after its sweep, so a process that vanished is already
|
|
271
|
+
* `exited` by the time it looks.
|
|
272
|
+
*/
|
|
273
|
+
async requestStop(launchId, ended) {
|
|
274
|
+
return this.transaction(async (entries) => {
|
|
275
|
+
const entry = entries.find((e) => e.launchId === launchId);
|
|
276
|
+
if (!entry)
|
|
277
|
+
throw new Error(`Unknown launch ${launchId}`);
|
|
278
|
+
if (await ended(entry))
|
|
279
|
+
return undefined;
|
|
280
|
+
entry.stopRequestedAt ??= new Date().toISOString();
|
|
281
|
+
return { ...entry };
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Records the `--on-exit` command, refusing one already recorded. The check
|
|
286
|
+
* and the write share a transaction: two callers handed the same launch (a
|
|
287
|
+
* request key sent twice) would otherwise both see none and both succeed,
|
|
288
|
+
* with the second command silently replacing the first.
|
|
289
|
+
*/
|
|
290
|
+
async armOnExit(launchId, onExit) {
|
|
291
|
+
return this.transaction(async (entries) => {
|
|
292
|
+
const entry = entries.find((e) => e.launchId === launchId);
|
|
293
|
+
if (!entry)
|
|
294
|
+
throw new Error(`Unknown launch ${launchId}`);
|
|
295
|
+
if (entry.onExit)
|
|
296
|
+
throw new Error(`launch ${entry.id} already has an --on-exit command; it is claimed at most once, so a second is refused rather than dropped`);
|
|
297
|
+
entry.onExit = onExit;
|
|
298
|
+
});
|
|
299
|
+
}
|
|
220
300
|
async update(id, patch) {
|
|
221
301
|
return this.transaction(async (entries) => {
|
|
222
302
|
const byLaunch = entries.find((e) => e.launchId === id);
|
package/dist/run.js
CHANGED
|
@@ -17,7 +17,7 @@ import { hosts, selectHost } from "./hosts/index.js";
|
|
|
17
17
|
import { AGENTS } from "./agents.js";
|
|
18
18
|
import { assertOptionsSupported, screenWorkspace } from "./options.js";
|
|
19
19
|
import { workspaceTrustKey } from "./identity/workspace-key.js";
|
|
20
|
-
import { processRef, isSame, stopTree, delay, descendants, ownedGroup, } from "./identity/processes.js";
|
|
20
|
+
import { processRef, isSame, stopTree, delay, descendants, ownedGroup, groupAlive, } from "./identity/processes.js";
|
|
21
21
|
import { resolveCodex } from "./identity/codex.js";
|
|
22
22
|
import { claudeConfigDir, claudeRegistryDiagnostic, claudeWorkspaceTrusted, resolveClaude, } from "./identity/claude.js";
|
|
23
23
|
import { openCodeEndpointOwnedBy, resolveOpenCode, } from "./identity/opencode.js";
|
|
@@ -33,6 +33,7 @@ import { assignNames, resolvePeer, runtimeName, suffixOf, } from "./naming.js";
|
|
|
33
33
|
import { selectMcp, prepareMcp, mcpEnvironment, } from "./mcp.js";
|
|
34
34
|
import { pollInterval } from "./reap.js";
|
|
35
35
|
import { selectPlugins } from "./plugins.js";
|
|
36
|
+
import { prepareOnExit, runOnExitCommand } from "./on-exit.js";
|
|
36
37
|
function taskHandle(entry, pid) {
|
|
37
38
|
return {
|
|
38
39
|
kind: "task",
|
|
@@ -66,6 +67,72 @@ function taskHandle(entry, pid) {
|
|
|
66
67
|
* as "there is no such variable" via `?? entry.identityPath` and an absent
|
|
67
68
|
* value is what that idiom expects.
|
|
68
69
|
*/
|
|
70
|
+
/**
|
|
71
|
+
* A finished record always answers the three questions a waiter has, even
|
|
72
|
+
* where the answer is "not observed": a session's agent exits inside its
|
|
73
|
+
* terminal host, which keeps no exit status, so only a task carries real ones.
|
|
74
|
+
*/
|
|
75
|
+
/**
|
|
76
|
+
* Whether the `--on-exit` command was claimed and what became of it, on every
|
|
77
|
+
* record of a launch that has one: a claim with no result is a watcher that
|
|
78
|
+
* died mid-command, and the only place that shows is here.
|
|
79
|
+
*/
|
|
80
|
+
function onExitView(e) {
|
|
81
|
+
if (!e.onExit)
|
|
82
|
+
return {};
|
|
83
|
+
return {
|
|
84
|
+
on_exit: {
|
|
85
|
+
claimed_at: e.onExitFiredAt ?? null,
|
|
86
|
+
result: e.onExitResult ?? null,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function finalRecord(record) {
|
|
91
|
+
return {
|
|
92
|
+
...record,
|
|
93
|
+
exit_code: record.exit_code ?? null,
|
|
94
|
+
signal: record.signal ?? null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* A task's exit file, and when it was written: undefined until it holds a
|
|
99
|
+
* record. Only a record that parses counts — a file that exists but cannot be
|
|
100
|
+
* read as one is never the end, whatever wrote it. The time is the file's,
|
|
101
|
+
* which the worker publishes by rename, so it is when the exit was recorded.
|
|
102
|
+
*/
|
|
103
|
+
async function readExit(path) {
|
|
104
|
+
if (!path)
|
|
105
|
+
return undefined;
|
|
106
|
+
try {
|
|
107
|
+
const [text, info] = await Promise.all([
|
|
108
|
+
readFile(path, "utf8"),
|
|
109
|
+
stat(path),
|
|
110
|
+
]);
|
|
111
|
+
const exit = JSON.parse(text);
|
|
112
|
+
if (exit && typeof exit === "object" && "code" in exit)
|
|
113
|
+
return { ...exit, at: info.mtimeMs };
|
|
114
|
+
}
|
|
115
|
+
catch { }
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
/** Whether a stop, not the agent, ended a launch with this exit record. */
|
|
119
|
+
function endedByStop(entry, exit) {
|
|
120
|
+
if (!entry.stopRequestedAt)
|
|
121
|
+
return false;
|
|
122
|
+
return !exit || exit.at >= Date.parse(entry.stopRequestedAt);
|
|
123
|
+
}
|
|
124
|
+
const FAILED_LAUNCH = Symbol("muster.failedLaunch");
|
|
125
|
+
/**
|
|
126
|
+
* The launch a failed `run` had reserved, so the CLI can still fire its
|
|
127
|
+
* `--on-exit` for it. Undefined for a refusal made before anything was
|
|
128
|
+
* reserved — a bad option, or a request key already in use — which is not a
|
|
129
|
+
* launch that ended and must not be reported as one.
|
|
130
|
+
*/
|
|
131
|
+
export function failedLaunchId(e) {
|
|
132
|
+
return e && typeof e === "object"
|
|
133
|
+
? e[FAILED_LAUNCH]
|
|
134
|
+
: undefined;
|
|
135
|
+
}
|
|
69
136
|
function configHomeVar(runtime) {
|
|
70
137
|
return AGENTS[runtime].configHomeVar ?? undefined;
|
|
71
138
|
}
|
|
@@ -73,6 +140,28 @@ function configHomeVar(runtime) {
|
|
|
73
140
|
function provisionalTitle(req) {
|
|
74
141
|
return sanitiseTitle(`${basename(req.cwd)} · ${req.runtime}`);
|
|
75
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* `wait` was given an id that names no launch. A class of its own because the
|
|
145
|
+
* CLI answers it with its own exit status: a caller polling for completion must
|
|
146
|
+
* be able to tell "never existed" from "still running", or a typo waits forever.
|
|
147
|
+
*/
|
|
148
|
+
export class UnknownRun extends Error {
|
|
149
|
+
constructor(message) {
|
|
150
|
+
super(message);
|
|
151
|
+
this.name = "UnknownRun";
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The id that names a launch for as long as the registry holds it. Not a
|
|
156
|
+
* name or `canonical_id`: those resolve only among running sessions, so a
|
|
157
|
+
* session that exits between readiness and `--on-exit` being armed would no
|
|
158
|
+
* longer be found, and would lose its command.
|
|
159
|
+
*/
|
|
160
|
+
export function durableId(record) {
|
|
161
|
+
if (record.kind === "task")
|
|
162
|
+
return record.id;
|
|
163
|
+
return record.runtime === "codex" ? record.thread_id : record.session_id;
|
|
164
|
+
}
|
|
76
165
|
export class Muster {
|
|
77
166
|
config;
|
|
78
167
|
home;
|
|
@@ -911,6 +1000,8 @@ export class Muster {
|
|
|
911
1000
|
});
|
|
912
1001
|
}
|
|
913
1002
|
catch (e) {
|
|
1003
|
+
if (e && typeof e === "object")
|
|
1004
|
+
e[FAILED_LAUNCH] = entry.launchId;
|
|
914
1005
|
await this.stopEntry(entry).catch(() => { });
|
|
915
1006
|
const message = e.message;
|
|
916
1007
|
await this.registry.update(entry.launchId, {
|
|
@@ -1105,10 +1196,35 @@ export class Muster {
|
|
|
1105
1196
|
async stop(id, requester = LOCAL) {
|
|
1106
1197
|
const entry = await this.find(id);
|
|
1107
1198
|
assertMayAct(entry, requester);
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1199
|
+
// Recorded before the kill: any transaction that sees the processes gone
|
|
1200
|
+
// before the write below lands would otherwise sweep the entry to
|
|
1201
|
+
// `exited`, and a waiter would report a stop as the agent ending itself.
|
|
1202
|
+
// A launch that has already ended is left as it ended: relabelling an
|
|
1203
|
+
// exited task `stopped` would tell its waiter the wrong thing.
|
|
1204
|
+
const requested = await this.registry.requestStop(entry.launchId, (e) => this.ended(e));
|
|
1205
|
+
if (!requested) {
|
|
1206
|
+
// As `wait` and `list` report it, not the raw status: a task's exit
|
|
1207
|
+
// file can land while its worker is still tearing down, and then the
|
|
1208
|
+
// registry still says running though the task has exited.
|
|
1209
|
+
const final = (await this.registry.peek(entry.launchId)) ?? entry;
|
|
1210
|
+
return {
|
|
1211
|
+
stopped: false,
|
|
1212
|
+
id: entry.id,
|
|
1213
|
+
state: (await this.view(final)).state,
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
await this.stopEntry(requested);
|
|
1217
|
+
// The agent may have exited on its own between the check and the kill;
|
|
1218
|
+
// an exit recorded before the stop was asked is still an exit.
|
|
1219
|
+
const exit = requested.kind === "task"
|
|
1220
|
+
? await readExit(requested.exitPath)
|
|
1221
|
+
: undefined;
|
|
1222
|
+
const status = endedByStop(requested, exit) ? "stopped" : "exited";
|
|
1223
|
+
await this.registry.update(entry.launchId, { status });
|
|
1224
|
+
await this.log.write({ event: status, launch_id: entry.launchId });
|
|
1225
|
+
return status === "stopped"
|
|
1226
|
+
? { stopped: true, id: entry.id }
|
|
1227
|
+
: { stopped: false, id: entry.id, state: status };
|
|
1112
1228
|
}
|
|
1113
1229
|
/**
|
|
1114
1230
|
* Where to look for the agent's own session registry. Two different uses of an
|
|
@@ -1200,68 +1316,269 @@ export class Muster {
|
|
|
1200
1316
|
for (const original of entries) {
|
|
1201
1317
|
if (kind && kind !== original.kind)
|
|
1202
1318
|
continue;
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1319
|
+
result.push(await this.view(original));
|
|
1320
|
+
}
|
|
1321
|
+
return result;
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* One entry as `list` reports it. Shared with `wait`, so the record a waiter
|
|
1325
|
+
* is handed is the record a listing would have shown for the same launch.
|
|
1326
|
+
*/
|
|
1327
|
+
async view(original) {
|
|
1328
|
+
const e = await this.refreshed(original);
|
|
1329
|
+
if (e.mcpConfigPath && !["starting", "running"].includes(e.status))
|
|
1330
|
+
await rm(e.mcpConfigPath, { force: true });
|
|
1331
|
+
if (e.identityPath && !["starting", "running"].includes(e.status))
|
|
1332
|
+
await rm(e.identityPath, { recursive: true, force: true });
|
|
1333
|
+
if (e.kind === "session")
|
|
1334
|
+
return {
|
|
1335
|
+
...e.peer,
|
|
1336
|
+
kind: "session",
|
|
1337
|
+
...(e.permissions
|
|
1338
|
+
? { permissions: e.permissions, sandbox: e.sandbox }
|
|
1339
|
+
: {}),
|
|
1340
|
+
// From the entry, not the peer: a listing must report the same fact a
|
|
1341
|
+
// launch result does, and an entry predating the field has none.
|
|
1342
|
+
...(e.enforcement ? { enforcement: e.enforcement } : {}),
|
|
1343
|
+
...(e.identity ? { identity: e.identity } : {}),
|
|
1344
|
+
// Absent on an entry written before the field, which is a different
|
|
1345
|
+
// fact from an explicit null: that one means Muster resolved nothing
|
|
1346
|
+
// and the child chose. Both are more useful than a guess.
|
|
1347
|
+
...(e.model !== undefined
|
|
1348
|
+
? { model: e.model, model_source: e.modelSource ?? null }
|
|
1349
|
+
: {}),
|
|
1350
|
+
...(e.idleTimeout ? { idle_timeout: e.idleTimeout } : {}),
|
|
1351
|
+
...(e.ttl ? { ttl: e.ttl } : {}),
|
|
1352
|
+
id: e.id,
|
|
1353
|
+
runtime: e.peer?.runtime ?? e.runtime,
|
|
1354
|
+
state: e.status === "running" ? (e.peer?.state ?? "idle") : e.status,
|
|
1355
|
+
host: e.host,
|
|
1356
|
+
...(e.error ? { error: e.error } : {}),
|
|
1357
|
+
...onExitView(e),
|
|
1358
|
+
};
|
|
1359
|
+
else {
|
|
1360
|
+
const exit = await readExit(e.exitPath);
|
|
1361
|
+
return {
|
|
1362
|
+
kind: "task",
|
|
1363
|
+
...(e.mcp ? { mcp: e.mcp } : {}),
|
|
1364
|
+
...(e.mcp_warnings ? { mcp_warnings: e.mcp_warnings } : {}),
|
|
1365
|
+
...(e.permissions
|
|
1366
|
+
? { permissions: e.permissions, sandbox: e.sandbox }
|
|
1367
|
+
: {}),
|
|
1368
|
+
...(e.enforcement ? { enforcement: e.enforcement } : {}),
|
|
1369
|
+
...(e.identity ? { identity: e.identity } : {}),
|
|
1370
|
+
// Absent on an entry written before the field, which is a different
|
|
1371
|
+
// fact from an explicit null: that one means Muster resolved nothing
|
|
1372
|
+
// and the child chose. Both are more useful than a guess.
|
|
1373
|
+
...(e.model !== undefined
|
|
1374
|
+
? { model: e.model, model_source: e.modelSource ?? null }
|
|
1375
|
+
: {}),
|
|
1376
|
+
id: e.id,
|
|
1377
|
+
runtime: e.runtime,
|
|
1378
|
+
cwd: e.cwd,
|
|
1379
|
+
// A stop wins over an exit file written after it: the worker writes
|
|
1380
|
+
// that file on the SIGTERM a stop sends, so its presence says the task
|
|
1381
|
+
// ended, not why. One written before the stop was asked is an exit.
|
|
1382
|
+
state: (exit || !["starting", "running"].includes(e.status)) &&
|
|
1383
|
+
endedByStop(e, exit)
|
|
1384
|
+
? "stopped"
|
|
1385
|
+
: exit
|
|
1386
|
+
? "exited"
|
|
1387
|
+
: e.status,
|
|
1388
|
+
...(e.root ? { pid: e.root.pid } : {}),
|
|
1389
|
+
...(exit ? { exit_code: exit.code, signal: exit.signal } : {}),
|
|
1390
|
+
...(e.error ? { error: e.error } : {}),
|
|
1391
|
+
...onExitView(e),
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* The entry an id names, by launch id first. A task's id IS its launch id; a
|
|
1397
|
+
* session's durable id replaces it at readiness, and the `--on-exit` watcher
|
|
1398
|
+
* is armed with the launch id, which is the one handle that never changes.
|
|
1399
|
+
*/
|
|
1400
|
+
async locate(id) {
|
|
1401
|
+
const byLaunch = (await this.registry.all()).find((e) => e.launchId === id);
|
|
1402
|
+
if (byLaunch)
|
|
1403
|
+
return byLaunch;
|
|
1404
|
+
try {
|
|
1405
|
+
return await this.find(id);
|
|
1406
|
+
}
|
|
1407
|
+
catch (e) {
|
|
1408
|
+
const message = e.message;
|
|
1409
|
+
// The resolver's "unknown: <every live name>" reads as a list of
|
|
1410
|
+
// matches; the ambiguous case does want its candidates, so it keeps them.
|
|
1411
|
+
throw new UnknownRun(message.startsWith("unknown")
|
|
1412
|
+
? `no session or task matches ${JSON.stringify(id)}`
|
|
1413
|
+
: message);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Whether the launch has ended, from cheap evidence about this one entry.
|
|
1418
|
+
* True is provisional for a process that vanished: the caller confirms it
|
|
1419
|
+
* through a transaction, whose sweep is what records `exited`.
|
|
1420
|
+
*/
|
|
1421
|
+
async ended(entry) {
|
|
1422
|
+
if (!["starting", "running"].includes(entry.status))
|
|
1423
|
+
return true;
|
|
1424
|
+
// A task's worker writes this the moment the agent exits, before it tears
|
|
1425
|
+
// down its group: the earliest signal there is, and the only exact one.
|
|
1426
|
+
if (entry.kind === "task" && (await readExit(entry.exitPath)))
|
|
1427
|
+
return true;
|
|
1428
|
+
if (!entry.root)
|
|
1429
|
+
return !(await isSame(entry.owner));
|
|
1430
|
+
if (await isSame(entry.root))
|
|
1431
|
+
return false;
|
|
1432
|
+
if ((await Promise.all((entry.descendants ?? []).map(isSame))).some(Boolean))
|
|
1433
|
+
return false;
|
|
1434
|
+
return !(entry.group && (await groupAlive(entry.group)));
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Blocks until the launch `id` names is no longer running, or `timeoutMs`
|
|
1438
|
+
* passes. An id that names nothing throws `UnknownRun` at once rather than
|
|
1439
|
+
* waiting for something that will never appear.
|
|
1440
|
+
*
|
|
1441
|
+
* Polls one entry, lock-free, backing off to two seconds: a watcher may wait
|
|
1442
|
+
* days on a long session, and a transaction per poll would take the registry
|
|
1443
|
+
* lock and run `ps` for every live entry, every time.
|
|
1444
|
+
*/
|
|
1445
|
+
async wait(id, opts = {}) {
|
|
1446
|
+
const { launchId } = await this.locate(id);
|
|
1447
|
+
const deadline = opts.timeoutMs === undefined ? Infinity : Date.now() + opts.timeoutMs;
|
|
1448
|
+
let interval = opts.pollMs ?? 250;
|
|
1449
|
+
while (true) {
|
|
1450
|
+
const peeked = await this.registry.peek(launchId);
|
|
1451
|
+
if (!peeked)
|
|
1452
|
+
throw new UnknownRun(`Unknown launch ${id}`);
|
|
1453
|
+
if (await this.ended(peeked)) {
|
|
1454
|
+
// The transaction's sweep settles what the cheap check guessed.
|
|
1455
|
+
const settled = (await this.registry.all()).find((e) => e.launchId === launchId);
|
|
1456
|
+
if (await this.ended(settled))
|
|
1457
|
+
return { done: true, record: finalRecord(await this.view(settled)) };
|
|
1458
|
+
}
|
|
1459
|
+
const left = deadline - Date.now();
|
|
1460
|
+
if (left <= 0)
|
|
1461
|
+
return { done: false, record: await this.view(peeked) };
|
|
1462
|
+
await delay(Math.min(interval, left));
|
|
1463
|
+
interval = Math.min(interval * 1.5, 2000);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Records `command` against the launch and starts the detached watcher that
|
|
1468
|
+
* runs it once the agent exits. The watcher is muster, outside any sandbox
|
|
1469
|
+
* the agent runs in, so a sealed agent that can write nothing still produces
|
|
1470
|
+
* the signal — and it fires on a crash or a kill, which a report the agent
|
|
1471
|
+
* makes about itself never would.
|
|
1472
|
+
*
|
|
1473
|
+
* CLI only, deliberately absent from the MCP `run` schema: the command runs
|
|
1474
|
+
* unsandboxed as the operator, which no MCP caller is granted.
|
|
1475
|
+
*/
|
|
1476
|
+
async armOnExit(id, command, cwd) {
|
|
1477
|
+
if (!command.trim())
|
|
1478
|
+
throw new Error("--on-exit needs a command");
|
|
1479
|
+
const entry = await this.locate(id);
|
|
1480
|
+
await this.registry.armOnExit(entry.launchId, { command, cwd });
|
|
1481
|
+
const watcher = spawn(process.execPath, [
|
|
1482
|
+
// <root>/dist from src under vitest and from dist in the package
|
|
1483
|
+
// alike. Same idiom as the reap-check job.
|
|
1484
|
+
fileURLToPath(new URL("../dist/muster.js", import.meta.url)),
|
|
1485
|
+
"on-exit-watch",
|
|
1486
|
+
"--home",
|
|
1487
|
+
this.home,
|
|
1488
|
+
entry.launchId,
|
|
1489
|
+
],
|
|
1490
|
+
// In muster's home, not the caller's directory: the command's directory
|
|
1491
|
+
// is recorded on the entry, and a watcher started inside one that is
|
|
1492
|
+
// later removed cannot even start Node there.
|
|
1493
|
+
{ detached: true, stdio: "ignore", env: this.sourceEnv, cwd: this.home });
|
|
1494
|
+
await new Promise((resolve, reject) => {
|
|
1495
|
+
watcher.once("spawn", resolve);
|
|
1496
|
+
watcher.once("error", reject);
|
|
1497
|
+
});
|
|
1498
|
+
watcher.unref();
|
|
1499
|
+
await this.log.write({
|
|
1500
|
+
event: "on_exit_armed",
|
|
1501
|
+
launch_id: entry.launchId,
|
|
1502
|
+
watcher_pid: watcher.pid,
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
/**
|
|
1506
|
+
* The watcher's whole job: wait for the launch to end, claim the command so
|
|
1507
|
+
* at most one watcher runs it, and run it. Its output goes to
|
|
1508
|
+
* `<home>/on-exit/<launch id>.log`, its status to the registry entry (shown
|
|
1509
|
+
* as `on_exit` by `wait` and `list`) and then to the launch log.
|
|
1510
|
+
*
|
|
1511
|
+
* Everything that can fail is settled before the claim — where it runs and
|
|
1512
|
+
* where its output goes — because a claim is spent whether or not the
|
|
1513
|
+
* command then starts. After the claim the command is spawned before any
|
|
1514
|
+
* launch-log write, so a log that cannot be written cannot stop it, and its
|
|
1515
|
+
* result lands in the registry first: a claim with no result there is a
|
|
1516
|
+
* watcher that died mid-command, which `on_exit` shows as `result: null`.
|
|
1517
|
+
*/
|
|
1518
|
+
async runOnExit(launchId) {
|
|
1519
|
+
const { record } = await this.wait(launchId);
|
|
1520
|
+
// The hook is told how the launch ended, not about itself.
|
|
1521
|
+
const { on_exit: _, ...hookRecord } = record;
|
|
1522
|
+
const armed = (await this.registry.peek(launchId))?.onExit;
|
|
1523
|
+
if (!armed)
|
|
1524
|
+
return null;
|
|
1525
|
+
const prepared = await prepareOnExit({
|
|
1526
|
+
cwd: armed.cwd,
|
|
1527
|
+
fallbackCwd: this.sourceEnv.HOME ?? homedir(),
|
|
1528
|
+
logPath: join(this.home, "on-exit", `${launchId}.log`),
|
|
1529
|
+
});
|
|
1530
|
+
try {
|
|
1531
|
+
const entry = await this.registry.claimOnExit(launchId);
|
|
1532
|
+
if (!entry)
|
|
1533
|
+
return null;
|
|
1534
|
+
let result;
|
|
1535
|
+
try {
|
|
1536
|
+
const outcome = await runOnExitCommand({
|
|
1537
|
+
command: entry.onExit.command,
|
|
1538
|
+
cwd: prepared.cwd,
|
|
1539
|
+
env: this.sourceEnv,
|
|
1540
|
+
entry,
|
|
1541
|
+
record: hookRecord,
|
|
1542
|
+
log: prepared.log,
|
|
1543
|
+
onSpawn: () => void this.log
|
|
1544
|
+
.write({
|
|
1545
|
+
event: "on_exit_claimed",
|
|
1546
|
+
launch_id: launchId,
|
|
1547
|
+
state: record.state,
|
|
1548
|
+
...(prepared.warnings.length
|
|
1549
|
+
? { warnings: prepared.warnings }
|
|
1550
|
+
: {}),
|
|
1551
|
+
})
|
|
1552
|
+
.catch(() => { }),
|
|
1261
1553
|
});
|
|
1554
|
+
result = { finished_at: new Date().toISOString(), ...outcome };
|
|
1555
|
+
}
|
|
1556
|
+
catch (e) {
|
|
1557
|
+
result = {
|
|
1558
|
+
finished_at: new Date().toISOString(),
|
|
1559
|
+
exit_code: null,
|
|
1560
|
+
signal: null,
|
|
1561
|
+
error: e.message,
|
|
1562
|
+
};
|
|
1262
1563
|
}
|
|
1564
|
+
if (prepared.warnings.length)
|
|
1565
|
+
result.warnings = prepared.warnings;
|
|
1566
|
+
await this.registry.update(launchId, { onExitResult: result });
|
|
1567
|
+
await this.log
|
|
1568
|
+
.write({
|
|
1569
|
+
event: "on_exit",
|
|
1570
|
+
launch_id: launchId,
|
|
1571
|
+
state: record.state,
|
|
1572
|
+
exit_code: result.exit_code,
|
|
1573
|
+
...(result.signal ? { signal: result.signal } : {}),
|
|
1574
|
+
...(result.error ? { error: result.error } : {}),
|
|
1575
|
+
})
|
|
1576
|
+
.catch(() => { });
|
|
1577
|
+
return result.exit_code;
|
|
1578
|
+
}
|
|
1579
|
+
finally {
|
|
1580
|
+
await prepared.log?.close();
|
|
1263
1581
|
}
|
|
1264
|
-
return result;
|
|
1265
1582
|
}
|
|
1266
1583
|
async output(id, requester = LOCAL) {
|
|
1267
1584
|
const entry = await this.find(id);
|
package/dist/task-worker.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// A per-task owner captures output and the exit result; no pools, retries or daemon.
|
|
2
|
-
import { readFile, writeFile, rm, open } from "node:fs/promises";
|
|
2
|
+
import { readFile, writeFile, rm, open, rename } from "node:fs/promises";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { delay, finishOwnedGroup, processRef, stopTree, } from "./identity/processes.js";
|
|
5
5
|
import { LaunchLog } from "./log.js";
|
|
@@ -45,9 +45,14 @@ async function finish(code, signal, error) {
|
|
|
45
45
|
}
|
|
46
46
|
finally {
|
|
47
47
|
try {
|
|
48
|
-
|
|
48
|
+
// Published by rename: `wait` treats this file as the task's end, and a
|
|
49
|
+
// reader that found it created but not yet written would report a
|
|
50
|
+
// finished task with no exit status.
|
|
51
|
+
const tmp = `${spec.exitPath}.tmp`;
|
|
52
|
+
await writeFile(tmp, JSON.stringify({ code, signal, error }), {
|
|
49
53
|
mode: 0o600,
|
|
50
54
|
});
|
|
55
|
+
await rename(tmp, spec.exitPath);
|
|
51
56
|
}
|
|
52
57
|
finally {
|
|
53
58
|
await finishOwnedGroup(code ?? 1);
|