@brutalsystems/muster 3.3.3 → 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.
@@ -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
@@ -112,6 +112,30 @@ async function main() {
112
112
  const allowLaunch = argv[0] === "mcp" && argv[1] === ALLOW_LAUNCH;
113
113
  if (argv[0] === "mcp" && argv.length > (allowLaunch ? 2 : 1))
114
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
+ }
115
139
  const muster = await Muster.create();
116
140
  let shutdown;
117
141
  const close = () => (shutdown ??= (async () => {
@@ -453,24 +477,6 @@ async function main() {
453
477
  await watcher.close();
454
478
  }
455
479
  }
456
- else if (command === "reap-check") {
457
- // Hidden: armed by muster itself as a tmux job, never typed by a person.
458
- // NOTE: `Muster.create()` above already ran against the REAL home before
459
- // this dispatch. Harmless (nothing is owned, so `close()` stops nothing)
460
- // but it means a broken ~/.muster/config.toml would fail every poll.
461
- // Left as-is: moving that create changes every command's startup.
462
- if (positionals.length !== 1 || !socket)
463
- throw new Error("reap-check requires --socket and one launch id");
464
- const { reapCheck } = await import("./reap.js");
465
- await reapCheck({
466
- home: homeFlag ?? musterHome(),
467
- socket,
468
- ...(stopNow ? { stopNow: true } : {}),
469
- launchId: positionals[0],
470
- // The exact command that was armed, so a re-arm reproduces itself.
471
- argv: [...process.argv],
472
- });
473
- }
474
480
  else
475
481
  throw new Error(topLevelHelp());
476
482
  }
@@ -486,6 +492,11 @@ main().catch((e) => {
486
492
  commandHelp(command)
487
493
  ? `\n[muster] see \`muster ${command} --help\` for its options`
488
494
  : "";
489
- process.stderr.write(`[muster] ${e.message}${hint}\n`);
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`);
490
501
  process.exitCode = e instanceof RegistryLocked ? RegistryLocked.exitCode : 1;
491
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/run.js CHANGED
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "3.3.3",
3
+ "version": "3.3.4",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {