@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 +1 -1
- package/dist/hosts/tmux.js +7 -2
- package/dist/muster.js +32 -20
- package/dist/reap.js +30 -4
- package/dist/registry.js +26 -8
- package/dist/run.js +7 -3
- package/package.json +1 -1
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,
|
|
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",
|
package/dist/hosts/tmux.js
CHANGED
|
@@ -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
|
-
|
|
489
|
-
|
|
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
|
-
|
|
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.
|
|
42
|
-
*
|
|
43
|
-
* file immediately after taking
|
|
44
|
-
* version that wrote none (
|
|
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 =
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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.
|