@brutalsystems/muster 3.3.2 → 3.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/help.js CHANGED
@@ -138,7 +138,7 @@ export const COMMANDS = [
138
138
  ["--format FORMAT", "json (default) or human"],
139
139
  ],
140
140
  notes: [
141
- "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.",
141
+ "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, 124 on timeout, printing the record as it stands, and 75 when the registry is locked (as every command does): retry.",
142
142
  ],
143
143
  examples: [
144
144
  "muster wait 6fae755b-8f5d-4425-8ed7-69e84bfac8c5",
@@ -210,14 +210,19 @@ export class TmuxHost {
210
210
  * Run `argv` in the background after `seconds`, scheduled by the tmux server
211
211
  * itself. This is what lets a lifecycle outlive every muster process: nothing
212
212
  * of ours is running between the call and the job.
213
+ *
214
+ * The job's output goes to `log`, and it always exits 0. tmux shows a
215
+ * `run-shell` job's non-zero exit as "'<cmd>' returned 1" in whichever pane
216
+ * is active on the server, which is someone else's session, and it never
217
+ * shows the job's stderr, so the error itself was lost (#65).
213
218
  */
214
- async schedule(seconds, argv) {
219
+ async schedule(seconds, argv, log) {
215
220
  await this.call([
216
221
  "run-shell",
217
222
  "-b",
218
223
  "-d",
219
224
  String(Math.max(1, Math.round(seconds))),
220
- tmuxCommand(argv),
225
+ `${tmuxCommand(argv)} >>${tmuxCommand([log])} 2>&1 || true`,
221
226
  ]);
222
227
  }
223
228
  async launch(opts) {
package/dist/muster.js CHANGED
@@ -6,6 +6,7 @@ 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
8
  import { Muster, UnknownRun, durableId, failedLaunchId } from "./run.js";
9
+ import { RegistryLocked } from "./registry.js";
9
10
  import { waitSeconds } from "./duration.js";
10
11
  import { delay } from "./identity/processes.js";
11
12
  import { formatHuman, humanColorEnabled, statusText } from "./format.js";
@@ -111,6 +112,30 @@ async function main() {
111
112
  const allowLaunch = argv[0] === "mcp" && argv[1] === ALLOW_LAUNCH;
112
113
  if (argv[0] === "mcp" && argv.length > (allowLaunch ? 2 : 1))
113
114
  throw new Error(`mcp takes ${ALLOW_LAUNCH}, or one of ${MCP_SUBCOMMANDS.join(", ")}`);
115
+ // Before Muster.create(), which reads the real home's config: a broken
116
+ // config.toml failed every poll outside `reapCheck`, whose own catch is what
117
+ // re-arms, so the session's idle timeout was silently dropped (#65).
118
+ if (argv[0] === "reap-check") {
119
+ const { values, positionals } = parseArgs({
120
+ args: argv.slice(1),
121
+ allowPositionals: true,
122
+ strict: true,
123
+ options: CLI_OPTIONS,
124
+ });
125
+ // Hidden: armed by muster itself as a tmux job, never typed by a person.
126
+ if (positionals.length !== 1 || !values.socket)
127
+ throw new Error("reap-check requires --socket and one launch id");
128
+ const { reapCheck } = await import("./reap.js");
129
+ await reapCheck({
130
+ home: values.home ?? musterHome(),
131
+ socket: values.socket,
132
+ ...(values["stop-now"] ? { stopNow: true } : {}),
133
+ launchId: positionals[0],
134
+ // The exact command that was armed, so a re-arm reproduces itself.
135
+ argv: [...process.argv],
136
+ });
137
+ return;
138
+ }
114
139
  const muster = await Muster.create();
115
140
  let shutdown;
116
141
  const close = () => (shutdown ??= (async () => {
@@ -452,24 +477,6 @@ async function main() {
452
477
  await watcher.close();
453
478
  }
454
479
  }
455
- else if (command === "reap-check") {
456
- // Hidden: armed by muster itself as a tmux job, never typed by a person.
457
- // NOTE: `Muster.create()` above already ran against the REAL home before
458
- // this dispatch. Harmless (nothing is owned, so `close()` stops nothing)
459
- // but it means a broken ~/.muster/config.toml would fail every poll.
460
- // Left as-is: moving that create changes every command's startup.
461
- if (positionals.length !== 1 || !socket)
462
- throw new Error("reap-check requires --socket and one launch id");
463
- const { reapCheck } = await import("./reap.js");
464
- await reapCheck({
465
- home: homeFlag ?? musterHome(),
466
- socket,
467
- ...(stopNow ? { stopNow: true } : {}),
468
- launchId: positionals[0],
469
- // The exact command that was armed, so a re-arm reproduces itself.
470
- argv: [...process.argv],
471
- });
472
- }
473
480
  else
474
481
  throw new Error(topLevelHelp());
475
482
  }
@@ -485,6 +492,11 @@ main().catch((e) => {
485
492
  commandHelp(command)
486
493
  ? `\n[muster] see \`muster ${command} --help\` for its options`
487
494
  : "";
488
- process.stderr.write(`[muster] ${e.message}${hint}\n`);
489
- process.exitCode = 1;
495
+ // reap-check's stderr is the reap log, read later by someone asking which
496
+ // poll failed and when.
497
+ const context = command === "reap-check"
498
+ ? `${new Date().toISOString()} reap-check ${process.argv.slice(3).find((a) => /^[0-9a-f-]{36}$/i.test(a)) ?? "?"}: `
499
+ : "";
500
+ process.stderr.write(`[muster] ${context}${e.message}${hint}\n`);
501
+ process.exitCode = e instanceof RegistryLocked ? RegistryLocked.exitCode : 1;
490
502
  });
package/dist/reap.js CHANGED
@@ -1,4 +1,13 @@
1
+ import { join } from "node:path";
1
2
  import { Registry } from "./registry.js";
3
+ /**
4
+ * Where the reaper's tmux jobs write their output, under muster's home. Not
5
+ * launches.jsonl: that is one JSON event per line, and raw shell output would
6
+ * break every reader of it.
7
+ */
8
+ export function reapLog(home) {
9
+ return join(home, "reap.log");
10
+ }
2
11
  // The floor is low so a short limit is honoured rather than rounded up: a 45s
3
12
  // ttl behind a 30s floor fires at up to 75s. A session with a limit that short
4
13
  // does not live long enough for the extra polls to matter.
@@ -57,11 +66,11 @@ export function decide(entry, state) {
57
66
  export async function reapCheck(opts) {
58
67
  const rearm = async (seconds) => {
59
68
  const schedule = opts.schedule ??
60
- (async (s, argv) => {
69
+ (async (s, argv, log) => {
61
70
  const { TmuxHost } = await import("./hosts/tmux.js");
62
- await new TmuxHost(opts.socket).schedule(s, argv);
71
+ await new TmuxHost(opts.socket).schedule(s, argv, log);
63
72
  });
64
- await schedule(seconds, opts.argv);
73
+ await schedule(seconds, opts.argv, reapLog(opts.home));
65
74
  return { action: "rearm", seconds };
66
75
  };
67
76
  try {
@@ -92,7 +101,24 @@ export async function reapCheck(opts) {
92
101
  ((e, args) => {
93
102
  // Detached and unref'd so it survives the tmux server exiting as the
94
103
  // session it is stopping goes away.
95
- void import("node:child_process").then(({ spawn }) => spawn(e, args, { detached: true, stdio: "ignore" }).unref());
104
+ // Its output goes to the reap log too: with stdio ignored, a stop
105
+ // that failed left no trace anywhere.
106
+ void Promise.all([
107
+ import("node:child_process"),
108
+ import("node:fs"),
109
+ ]).then(([{ spawn }, { openSync, closeSync }]) => {
110
+ let out = "ignore";
111
+ try {
112
+ out = openSync(reapLog(opts.home), "a", 0o600);
113
+ }
114
+ catch { }
115
+ spawn(e, args, {
116
+ detached: true,
117
+ stdio: ["ignore", out, out],
118
+ }).unref();
119
+ if (out !== "ignore")
120
+ closeSync(out);
121
+ });
96
122
  });
97
123
  spawnFn(exec, [...opts.argv.slice(1), "--stop-now"]);
98
124
  }
package/dist/registry.js CHANGED
@@ -38,12 +38,25 @@ export class IdempotencyKeyReuse extends Error {
38
38
  }
39
39
  }
40
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.
41
+ * A lock directory with no owner file is presumed live for this long. A
42
+ * transaction holds the lock for milliseconds and the holder writes its owner
43
+ * file immediately after taking it, so a lock still unowned this long was left
44
+ * by a holder killed in between, or by a version that wrote none (#64). Three
45
+ * default lock waits: one waiter may still time out on it, the next breaks it.
45
46
  */
46
- const UNOWNED_LOCK_MS = 60_000;
47
+ const UNOWNED_LOCK_MS = 15_000;
48
+ /**
49
+ * The registry is busy, not broken and not empty: the CLI exits 75
50
+ * (EX_TEMPFAIL, "try again later") for it, so a script polling `list` or
51
+ * `wait` can tell it from a run that is gone (#64) and retry.
52
+ */
53
+ export class RegistryLocked extends Error {
54
+ static exitCode = 75;
55
+ constructor(message) {
56
+ super(message);
57
+ this.name = "RegistryLocked";
58
+ }
59
+ }
47
60
  let self;
48
61
  /** Statuses whose process is over: such an entry no longer holds its id. */
49
62
  const FINISHED = ["exited", "failed", "stopped"];
@@ -84,9 +97,14 @@ export class Registry {
84
97
  continue;
85
98
  if (Date.now() > deadline) {
86
99
  const owner = await lockOwner(lock);
87
- throw new Error(owner
88
- ? `Registry locked, held by pid ${owner.pid}; verify no Muster operation is running before removing registry.lock`
89
- : "Registry locked; verify no Muster operation is running before removing registry.lock");
100
+ const age = await ageMs(lock);
101
+ const held = [
102
+ owner ? `held by pid ${owner.pid}` : "with no owner recorded",
103
+ age === undefined ? undefined : `for ${Math.round(age / 1000)}s`,
104
+ ]
105
+ .filter(Boolean)
106
+ .join(" ");
107
+ throw new RegistryLocked(`Registry locked, ${held}; retry shortly. If it persists, verify no Muster operation is running before removing ${lock}`);
90
108
  }
91
109
  await delay(20);
92
110
  }
package/dist/run.js CHANGED
@@ -11,7 +11,7 @@ import { assertEnforcementMeetsMinimum, assertLevelWithinCeiling, assertMayAct,
11
11
  import { IdentityError, readIdentity, } from "./identity-store.js";
12
12
  import { resolveCredential } from "./identity-token.js";
13
13
  import { copyIdentity, identityEnv } from "./identity-copy.js";
14
- import { currentEntry, Registry } from "./registry.js";
14
+ import { currentEntry, Registry, RegistryLocked, } from "./registry.js";
15
15
  import { LaunchLog } from "./log.js";
16
16
  import { hosts, selectHost } from "./hosts/index.js";
17
17
  import { AGENTS } from "./agents.js";
@@ -31,7 +31,7 @@ import { allocateLoopbackPort, OpenCodeHttp } from "./opencode-http.js";
31
31
  import { assertOpenCodeVersion, openCodeMcpNames, openCodeSessionLaunch, openCodeTaskLaunch, } from "./opencode-policy.js";
32
32
  import { assignNames, resolvePeer, runtimeName, suffixOf, } from "./naming.js";
33
33
  import { selectMcp, prepareMcp, mcpEnvironment, } from "./mcp.js";
34
- import { pollInterval } from "./reap.js";
34
+ import { pollInterval, reapLog } from "./reap.js";
35
35
  import { selectPlugins } from "./plugins.js";
36
36
  import { prepareOnExit, runOnExitCommand } from "./on-exit.js";
37
37
  function taskHandle(entry, pid) {
@@ -714,7 +714,7 @@ export class Muster {
714
714
  "--socket",
715
715
  driver.socketName?.() ?? "muster",
716
716
  entry.launchId,
717
- ])
717
+ ], reapLog(this.home))
718
718
  .then(() => true, () => false);
719
719
  // A lifecycle that could not be armed must not fail the launch — the
720
720
  // session is up and usable, and the orphan risk is what we had before.
@@ -1470,6 +1470,10 @@ export class Muster {
1470
1470
  return await this.find(id);
1471
1471
  }
1472
1472
  catch (e) {
1473
+ // Busy is not gone: `wait` exits 2 for an UnknownRun, and a poller that
1474
+ // read a lock as "no such run" is how #64 ended a wait loop early.
1475
+ if (e instanceof RegistryLocked)
1476
+ throw e;
1473
1477
  const message = e.message;
1474
1478
  // The resolver's "unknown: <every live name>" reads as a list of
1475
1479
  // matches; the ambiguous case does want its candidates, so it keeps them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "3.3.2",
3
+ "version": "3.3.4",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {