@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 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+/).map(Number));
102
- return rows.some(([pid, pgid]) => pid && pid > 1 && pgid === group);
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'". Only in second position, so a `--help` that is
62
- // some flag's value still reaches the command it was written for.
63
- if ((argv[1] === "--help" || argv[1] === "-h") && commandHelp(argv[0])) {
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 && command !== "reap-check")
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
- const record = await muster.run({
292
- runtime,
293
- ...values,
294
- ...(requestKey === undefined ? {} : { requestKey }),
295
- ...(idleTimeout === undefined ? {} : { idleTimeout }),
296
- args,
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.stderr.write(`[muster] ${e.message}\n`);
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
  });
@@ -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
- constructor(home) {
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() + 5000;
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 (Date.now() > deadline)
43
- throw new Error("Registry locked; verify no Muster operation is running before removing registry.lock");
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 = "exited";
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
- await this.stopEntry(entry);
1109
- await this.registry.update(entry.launchId, { status: "stopped" });
1110
- await this.log.write({ event: "stopped", launch_id: entry.launchId });
1111
- return { stopped: true, id: entry.id };
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
- const e = await this.refreshed(original);
1204
- if (e.mcpConfigPath && !["starting", "running"].includes(e.status))
1205
- await rm(e.mcpConfigPath, { force: true });
1206
- if (e.identityPath && !["starting", "running"].includes(e.status))
1207
- await rm(e.identityPath, { recursive: true, force: true });
1208
- if (e.kind === "session")
1209
- result.push({
1210
- ...e.peer,
1211
- kind: "session",
1212
- ...(e.permissions
1213
- ? { permissions: e.permissions, sandbox: e.sandbox }
1214
- : {}),
1215
- // From the entry, not the peer: a listing must report the same fact a
1216
- // launch result does, and an entry predating the field has none.
1217
- ...(e.enforcement ? { enforcement: e.enforcement } : {}),
1218
- ...(e.identity ? { identity: e.identity } : {}),
1219
- // Absent on an entry written before the field, which is a different
1220
- // fact from an explicit null: that one means Muster resolved nothing
1221
- // and the child chose. Both are more useful than a guess.
1222
- ...(e.model !== undefined
1223
- ? { model: e.model, model_source: e.modelSource ?? null }
1224
- : {}),
1225
- ...(e.idleTimeout ? { idle_timeout: e.idleTimeout } : {}),
1226
- ...(e.ttl ? { ttl: e.ttl } : {}),
1227
- id: e.id,
1228
- runtime: e.peer?.runtime ?? e.runtime,
1229
- state: e.status === "running" ? (e.peer?.state ?? "idle") : e.status,
1230
- host: e.host,
1231
- ...(e.error ? { error: e.error } : {}),
1232
- });
1233
- else {
1234
- let exit;
1235
- try {
1236
- exit = JSON.parse(await readFile(e.exitPath, "utf8"));
1237
- }
1238
- catch { }
1239
- result.push({
1240
- kind: "task",
1241
- ...(e.mcp ? { mcp: e.mcp } : {}),
1242
- ...(e.mcp_warnings ? { mcp_warnings: e.mcp_warnings } : {}),
1243
- ...(e.permissions
1244
- ? { permissions: e.permissions, sandbox: e.sandbox }
1245
- : {}),
1246
- ...(e.enforcement ? { enforcement: e.enforcement } : {}),
1247
- ...(e.identity ? { identity: e.identity } : {}),
1248
- // Absent on an entry written before the field, which is a different
1249
- // fact from an explicit null: that one means Muster resolved nothing
1250
- // and the child chose. Both are more useful than a guess.
1251
- ...(e.model !== undefined
1252
- ? { model: e.model, model_source: e.modelSource ?? null }
1253
- : {}),
1254
- id: e.id,
1255
- runtime: e.runtime,
1256
- cwd: e.cwd,
1257
- state: exit ? "exited" : e.status,
1258
- ...(e.root ? { pid: e.root.pid } : {}),
1259
- ...(exit ? { exit_code: exit.code, signal: exit.signal } : {}),
1260
- ...(e.error ? { error: e.error } : {}),
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);
@@ -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
- await writeFile(spec.exitPath, JSON.stringify({ code, signal, error }), {
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "3.0.1",
3
+ "version": "3.1.0",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {