@brutalsystems/muster 3.0.1 → 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 +103 -47
- package/dist/on-exit.js +76 -0
- package/dist/registry.js +204 -6
- 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() })
|
|
@@ -51,6 +53,15 @@ function identitiesHuman(view, color) {
|
|
|
51
53
|
return lines.join("\n") + "\n";
|
|
52
54
|
}
|
|
53
55
|
async function main() {
|
|
56
|
+
// A reader that leaves early (`muster list | head -1`) makes the rest of a
|
|
57
|
+
// large write fail with EPIPE. Unhandled, that error exits Node on the spot —
|
|
58
|
+
// inside whatever registry transaction `close()` is running, which strands
|
|
59
|
+
// the lock. Nobody is left to read the output, so dropping it is correct.
|
|
60
|
+
for (const stream of [process.stdout, process.stderr])
|
|
61
|
+
stream.on("error", (e) => {
|
|
62
|
+
if (e.code !== "EPIPE")
|
|
63
|
+
throw e;
|
|
64
|
+
});
|
|
54
65
|
const argv = process.argv.slice(2);
|
|
55
66
|
if (argv[0] === "--help" || argv[0] === "-h") {
|
|
56
67
|
process.stdout.write(topLevelHelp());
|
|
@@ -58,9 +69,10 @@ async function main() {
|
|
|
58
69
|
}
|
|
59
70
|
// Recognised before parsing, not during it: the parser is strict and has no
|
|
60
71
|
// `help` option, so `muster setup-identity --help` used to be answered with
|
|
61
|
-
// "Unknown option '--help'".
|
|
62
|
-
//
|
|
63
|
-
|
|
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))) {
|
|
64
76
|
process.stdout.write(commandHelp(argv[0]));
|
|
65
77
|
return;
|
|
66
78
|
}
|
|
@@ -192,41 +204,13 @@ async function main() {
|
|
|
192
204
|
args: normalizeOpenFlag(argv),
|
|
193
205
|
allowPositionals: true,
|
|
194
206
|
strict: true,
|
|
195
|
-
options:
|
|
196
|
-
prompt: { type: "string" },
|
|
197
|
-
cwd: { type: "string" },
|
|
198
|
-
kind: { type: "string" },
|
|
199
|
-
host: { type: "string" },
|
|
200
|
-
open: { type: "boolean" },
|
|
201
|
-
terminal: { type: "string" },
|
|
202
|
-
format: { type: "string" },
|
|
203
|
-
permissions: { type: "string" },
|
|
204
|
-
sandbox: { type: "string" },
|
|
205
|
-
level: { type: "string" },
|
|
206
|
-
"read-only": { type: "boolean" },
|
|
207
|
-
"write-here": { type: "boolean" },
|
|
208
|
-
unrestricted: { type: "boolean" },
|
|
209
|
-
mcp: { type: "string", multiple: true },
|
|
210
|
-
options: { type: "string", multiple: true },
|
|
211
|
-
"no-mcp": { type: "boolean" },
|
|
212
|
-
plugin: { type: "string", multiple: true },
|
|
213
|
-
model: { type: "string" },
|
|
214
|
-
"idle-timeout": { type: "string" },
|
|
215
|
-
ttl: { type: "string" },
|
|
216
|
-
title: { type: "string" },
|
|
217
|
-
socket: { type: "string" },
|
|
218
|
-
home: { type: "string" },
|
|
219
|
-
"stop-now": { type: "boolean" },
|
|
220
|
-
"no-plugin": { type: "boolean" },
|
|
221
|
-
"request-key": { type: "string" },
|
|
222
|
-
project: { type: "string" },
|
|
223
|
-
identity: { type: "string" },
|
|
224
|
-
agent: { type: "string" },
|
|
225
|
-
"token-env": { type: "string" },
|
|
226
|
-
interactive: { type: "boolean" },
|
|
227
|
-
},
|
|
207
|
+
options: CLI_OPTIONS,
|
|
228
208
|
});
|
|
229
|
-
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");
|
|
230
214
|
// The plain spellings of the three sealed levels. Each is a level, so the
|
|
231
215
|
// existing exclusivity with --permissions/--sandbox applies unchanged once
|
|
232
216
|
// it is folded into `values.level`.
|
|
@@ -258,7 +242,9 @@ async function main() {
|
|
|
258
242
|
["--home", homeFlag],
|
|
259
243
|
["--stop-now", stopNow],
|
|
260
244
|
])
|
|
261
|
-
if (value !== undefined &&
|
|
245
|
+
if (value !== undefined &&
|
|
246
|
+
command !== "reap-check" &&
|
|
247
|
+
!(name === "--home" && command === "on-exit-watch"))
|
|
262
248
|
throw new Error(`${name} applies only to reap-check`);
|
|
263
249
|
if (requestKey !== undefined && command !== "run")
|
|
264
250
|
throw new Error("--request-key applies only to run");
|
|
@@ -288,13 +274,41 @@ async function main() {
|
|
|
288
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");
|
|
289
275
|
const runtime = positionals[0];
|
|
290
276
|
const args = positionals.slice(1);
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
+
}
|
|
298
312
|
print(record);
|
|
299
313
|
if (record.kind === "session" && record.host === "pty") {
|
|
300
314
|
process.stderr.write("[muster] pty: not watchable or attachable; this process owns the session. Ctrl-C stops it.\n");
|
|
@@ -400,6 +414,41 @@ async function main() {
|
|
|
400
414
|
const { id } = idSchema.parse({ id: positionals[0] });
|
|
401
415
|
print(await muster.title(id, positionals[1]));
|
|
402
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
|
+
}
|
|
403
452
|
else if (command === "reap-check") {
|
|
404
453
|
// Hidden: armed by muster itself as a tmux job, never typed by a person.
|
|
405
454
|
// NOTE: `Muster.create()` above already ran against the REAL home before
|
|
@@ -426,6 +475,13 @@ async function main() {
|
|
|
426
475
|
}
|
|
427
476
|
}
|
|
428
477
|
main().catch((e) => {
|
|
429
|
-
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`);
|
|
430
486
|
process.exitCode = 1;
|
|
431
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
|
@@ -1,8 +1,23 @@
|
|
|
1
|
-
import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, writeFile, rename, rm, stat } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
4
6
|
import { processRef, isSame, delay, groupAlive, } from "./identity/processes.js";
|
|
5
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
|
+
}
|
|
6
21
|
/**
|
|
7
22
|
* A request key arrived again with different parameters. Distinct from a launch
|
|
8
23
|
* failure: nothing was attempted, and the caller reused a key it should have
|
|
@@ -22,15 +37,26 @@ export class IdempotencyKeyReuse extends Error {
|
|
|
22
37
|
this.name = "IdempotencyKeyReuse";
|
|
23
38
|
}
|
|
24
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* A lock directory with no owner file is presumed live for this long. Long
|
|
42
|
+
* enough that no transaction comes near it, and the holder writes its owner
|
|
43
|
+
* file immediately after taking the lock, so in practice only a lock left by a
|
|
44
|
+
* version that wrote none (an empty directory) ever reaches it.
|
|
45
|
+
*/
|
|
46
|
+
const UNOWNED_LOCK_MS = 60_000;
|
|
47
|
+
let self;
|
|
25
48
|
export class Registry {
|
|
26
49
|
home;
|
|
27
|
-
|
|
50
|
+
lockWait;
|
|
51
|
+
/** `lockWait` is how long to wait for a live holder; tests shorten it. */
|
|
52
|
+
constructor(home, lockWait = 5000) {
|
|
28
53
|
this.home = home;
|
|
54
|
+
this.lockWait = lockWait;
|
|
29
55
|
}
|
|
30
56
|
async transaction(fn) {
|
|
31
57
|
await mkdir(this.home, { recursive: true, mode: 0o700 });
|
|
32
58
|
const lock = join(this.home, "registry.lock");
|
|
33
|
-
const deadline = Date.now() +
|
|
59
|
+
const deadline = Date.now() + this.lockWait;
|
|
34
60
|
while (true) {
|
|
35
61
|
try {
|
|
36
62
|
await mkdir(lock);
|
|
@@ -39,12 +65,27 @@ export class Registry {
|
|
|
39
65
|
catch (e) {
|
|
40
66
|
if (e.code !== "EEXIST")
|
|
41
67
|
throw e;
|
|
42
|
-
if (
|
|
43
|
-
|
|
68
|
+
if (await this.breakStale(lock))
|
|
69
|
+
continue;
|
|
70
|
+
if (Date.now() > deadline) {
|
|
71
|
+
const owner = await lockOwner(lock);
|
|
72
|
+
throw new Error(owner
|
|
73
|
+
? `Registry locked, held by pid ${owner.pid}; verify no Muster operation is running before removing registry.lock`
|
|
74
|
+
: "Registry locked; verify no Muster operation is running before removing registry.lock");
|
|
75
|
+
}
|
|
44
76
|
await delay(20);
|
|
45
77
|
}
|
|
46
78
|
}
|
|
47
79
|
try {
|
|
80
|
+
// The lock is released only by this process's `finally`, which does not
|
|
81
|
+
// run if the process dies first: an unhandled EPIPE, or a SIGKILL from a
|
|
82
|
+
// client stopping its MCP server. The owner file is what lets the next
|
|
83
|
+
// process see that and take the lock over instead of waiting forever.
|
|
84
|
+
// Cached only once known: a `ps` that failed once must not leave every
|
|
85
|
+
// later transaction of a long-lived server unowned.
|
|
86
|
+
const ref = (self ??= await lockRef(process.pid));
|
|
87
|
+
if (ref)
|
|
88
|
+
await writeFile(join(lock, "owner.json"), JSON.stringify(ref));
|
|
48
89
|
let entries = [];
|
|
49
90
|
try {
|
|
50
91
|
const value = JSON.parse(await readFile(join(this.home, "registry.json"), "utf8"));
|
|
@@ -86,7 +127,9 @@ export class Registry {
|
|
|
86
127
|
// concluding the session ended: it is the signal that survives both.
|
|
87
128
|
if (!(await Promise.all((entry.descendants ?? []).map(isSame))).some(Boolean) &&
|
|
88
129
|
!(entry.group && (await groupAlive(entry.group))))
|
|
89
|
-
entry.status =
|
|
130
|
+
entry.status = (await stoppedBeforeExit(entry))
|
|
131
|
+
? "stopped"
|
|
132
|
+
: "exited";
|
|
90
133
|
}
|
|
91
134
|
}
|
|
92
135
|
const result = await fn(entries);
|
|
@@ -101,6 +144,42 @@ export class Registry {
|
|
|
101
144
|
await rm(lock, { recursive: true });
|
|
102
145
|
}
|
|
103
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Removes the lock if its holder is provably gone: its recorded process is
|
|
149
|
+
* not running, or it recorded none and is older than any transaction.
|
|
150
|
+
*
|
|
151
|
+
* Breaking is serialized by a second directory and the verdict re-checked
|
|
152
|
+
* inside it. Without that, two waiters could both judge the same dead lock,
|
|
153
|
+
* one break it and take a new lock, and the other then remove that live one.
|
|
154
|
+
*/
|
|
155
|
+
async breakStale(lock) {
|
|
156
|
+
const breaker = `${lock}.break`;
|
|
157
|
+
try {
|
|
158
|
+
await mkdir(breaker);
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
if (e.code !== "EEXIST")
|
|
162
|
+
throw e;
|
|
163
|
+
// A breaker holds this for milliseconds; one this old died holding it.
|
|
164
|
+
const age = await ageMs(breaker);
|
|
165
|
+
if (age !== undefined && age > UNOWNED_LOCK_MS)
|
|
166
|
+
await rm(breaker, { recursive: true, force: true });
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const owner = await lockOwner(lock);
|
|
171
|
+
const stale = owner
|
|
172
|
+
? await holderGone(owner)
|
|
173
|
+
: ((await ageMs(lock)) ?? 0) > UNOWNED_LOCK_MS;
|
|
174
|
+
if (!stale)
|
|
175
|
+
return false;
|
|
176
|
+
await rm(lock, { recursive: true, force: true });
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
await rm(breaker, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
104
183
|
async all() {
|
|
105
184
|
return this.transaction(async (entries) => structuredClone(entries));
|
|
106
185
|
}
|
|
@@ -153,6 +232,71 @@ export class Registry {
|
|
|
153
232
|
return { entry: { ...entry }, deduped: false };
|
|
154
233
|
});
|
|
155
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
|
+
}
|
|
156
300
|
async update(id, patch) {
|
|
157
301
|
return this.transaction(async (entries) => {
|
|
158
302
|
const byLaunch = entries.find((e) => e.launchId === id);
|
|
@@ -172,3 +316,57 @@ export class Registry {
|
|
|
172
316
|
});
|
|
173
317
|
}
|
|
174
318
|
}
|
|
319
|
+
async function lockOwner(lock) {
|
|
320
|
+
try {
|
|
321
|
+
const value = JSON.parse(await readFile(join(lock, "owner.json"), "utf8"));
|
|
322
|
+
return typeof value.pid === "number" && typeof value.start === "string"
|
|
323
|
+
? { pid: value.pid, start: value.start }
|
|
324
|
+
: undefined;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The holder's identity as written into the lock. `ps` prints the start time in
|
|
332
|
+
* the caller's timezone and language, and the holder and a waiter need not
|
|
333
|
+
* share either (an MCP client's environment, a terminal's). Fixing both here
|
|
334
|
+
* makes the two readings comparable; `processRef`, whose strings are already
|
|
335
|
+
* stored in registry.json, is left as it is.
|
|
336
|
+
*/
|
|
337
|
+
async function lockRef(pid) {
|
|
338
|
+
try {
|
|
339
|
+
const { stdout } = await promisify(execFile)("ps", ["-p", String(pid), "-o", "lstart=,stat="], { timeout: 1000, env: { ...process.env, TZ: "UTC", LC_ALL: "C" } });
|
|
340
|
+
const out = stdout.trim();
|
|
341
|
+
if (!out || /\bZ\S*$/.test(out))
|
|
342
|
+
return;
|
|
343
|
+
return { pid, start: out.slice(0, 24) };
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Gone only on positive evidence. `processRef` answers undefined for any `ps`
|
|
351
|
+
* failure, a timeout under load included, and breaking a live lock on that
|
|
352
|
+
* would let two transactions interleave their writes.
|
|
353
|
+
*/
|
|
354
|
+
async function holderGone(owner) {
|
|
355
|
+
try {
|
|
356
|
+
process.kill(owner.pid, 0);
|
|
357
|
+
}
|
|
358
|
+
catch (e) {
|
|
359
|
+
return e.code === "ESRCH";
|
|
360
|
+
}
|
|
361
|
+
// The pid is in use; it is the holder unless it started at another time.
|
|
362
|
+
const now = await lockRef(owner.pid);
|
|
363
|
+
return now !== undefined && now.start !== owner.start;
|
|
364
|
+
}
|
|
365
|
+
async function ageMs(path) {
|
|
366
|
+
try {
|
|
367
|
+
return Date.now() - (await stat(path)).mtimeMs;
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return undefined;
|
|
371
|
+
}
|
|
372
|
+
}
|
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);
|