@brutalsystems/muster 3.0.2 → 3.1.1
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/claude.js +61 -18
- package/dist/identity/processes.js +6 -3
- package/dist/identity-copy.js +30 -3
- package/dist/muster.js +94 -47
- package/dist/on-exit.js +98 -0
- package/dist/registry.js +81 -1
- package/dist/run.js +414 -68
- package/dist/task-worker.js +7 -2
- package/package.json +1 -1
package/dist/registry.js
CHANGED
|
@@ -5,6 +5,19 @@ import { execFile } from "node:child_process";
|
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { processRef, isSame, delay, groupAlive, } from "./identity/processes.js";
|
|
7
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
|
+
}
|
|
8
21
|
/**
|
|
9
22
|
* A request key arrived again with different parameters. Distinct from a launch
|
|
10
23
|
* failure: nothing was attempted, and the caller reused a key it should have
|
|
@@ -114,7 +127,9 @@ export class Registry {
|
|
|
114
127
|
// concluding the session ended: it is the signal that survives both.
|
|
115
128
|
if (!(await Promise.all((entry.descendants ?? []).map(isSame))).some(Boolean) &&
|
|
116
129
|
!(entry.group && (await groupAlive(entry.group))))
|
|
117
|
-
entry.status =
|
|
130
|
+
entry.status = (await stoppedBeforeExit(entry))
|
|
131
|
+
? "stopped"
|
|
132
|
+
: "exited";
|
|
118
133
|
}
|
|
119
134
|
}
|
|
120
135
|
const result = await fn(entries);
|
|
@@ -217,6 +232,71 @@ export class Registry {
|
|
|
217
232
|
return { entry: { ...entry }, deduped: false };
|
|
218
233
|
});
|
|
219
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
|
+
}
|
|
220
300
|
async update(id, patch) {
|
|
221
301
|
return this.transaction(async (entries) => {
|
|
222
302
|
const byLaunch = entries.find((e) => e.launchId === id);
|
package/dist/run.js
CHANGED
|
@@ -17,9 +17,9 @@ 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
|
-
import {
|
|
22
|
+
import { claudeTrustFile, claudeRegistryDiagnostic, claudeWorkspaceTrusted, resolveClaude, } from "./identity/claude.js";
|
|
23
23
|
import { openCodeEndpointOwnedBy, resolveOpenCode, } from "./identity/opencode.js";
|
|
24
24
|
import { codexReachable, threadRejection } from "./reach/codex.js";
|
|
25
25
|
import { claudeReachable } from "./reach/claude.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,86 @@ 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
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Where an agent keeps its trust records when no configuration home is named,
|
|
138
|
+
* from the HOME it is spawned with. Codex only: its default `config.toml` is
|
|
139
|
+
* written by Codex just when you change a setting, and its trust entry is
|
|
140
|
+
* appended without rewriting the file. Claude's default is `~/.claude.json`,
|
|
141
|
+
* which every running session rewrites continuously, and a read-modify-write
|
|
142
|
+
* there could undo theirs — so a Claude launch with no CLAUDE_CONFIG_DIR still
|
|
143
|
+
* writes nothing, as before.
|
|
144
|
+
*/
|
|
145
|
+
function defaultConfigHome(runtime, env) {
|
|
146
|
+
return runtime === "codex"
|
|
147
|
+
? join(env.HOME ?? homedir(), ".codex")
|
|
148
|
+
: undefined;
|
|
149
|
+
}
|
|
69
150
|
function configHomeVar(runtime) {
|
|
70
151
|
return AGENTS[runtime].configHomeVar ?? undefined;
|
|
71
152
|
}
|
|
@@ -73,6 +154,28 @@ function configHomeVar(runtime) {
|
|
|
73
154
|
function provisionalTitle(req) {
|
|
74
155
|
return sanitiseTitle(`${basename(req.cwd)} · ${req.runtime}`);
|
|
75
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* `wait` was given an id that names no launch. A class of its own because the
|
|
159
|
+
* CLI answers it with its own exit status: a caller polling for completion must
|
|
160
|
+
* be able to tell "never existed" from "still running", or a typo waits forever.
|
|
161
|
+
*/
|
|
162
|
+
export class UnknownRun extends Error {
|
|
163
|
+
constructor(message) {
|
|
164
|
+
super(message);
|
|
165
|
+
this.name = "UnknownRun";
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The id that names a launch for as long as the registry holds it. Not a
|
|
170
|
+
* name or `canonical_id`: those resolve only among running sessions, so a
|
|
171
|
+
* session that exits between readiness and `--on-exit` being armed would no
|
|
172
|
+
* longer be found, and would lose its command.
|
|
173
|
+
*/
|
|
174
|
+
export function durableId(record) {
|
|
175
|
+
if (record.kind === "task")
|
|
176
|
+
return record.id;
|
|
177
|
+
return record.runtime === "codex" ? record.thread_id : record.session_id;
|
|
178
|
+
}
|
|
76
179
|
export class Muster {
|
|
77
180
|
config;
|
|
78
181
|
home;
|
|
@@ -461,8 +564,17 @@ export class Muster {
|
|
|
461
564
|
// Written before the agent starts and into the SAME dir the child will open.
|
|
462
565
|
// An identity launch has already had its copy trusted above, so this is a
|
|
463
566
|
// no-op there rather than a second write.
|
|
464
|
-
|
|
465
|
-
|
|
567
|
+
//
|
|
568
|
+
// With no configuration home named, the agent reads its default one, and
|
|
569
|
+
// that is where the record has to go. Skipping it — as this did — made the
|
|
570
|
+
// option a silent no-op for every Codex launch without CODEX_HOME: the
|
|
571
|
+
// launch then sat on Codex's trust dialog and timed out with "no
|
|
572
|
+
// descendant runtime identity found".
|
|
573
|
+
if (req.options.includes("auto-approve-path")) {
|
|
574
|
+
const trustHome = configHome ?? defaultConfigHome(req.runtime, runtimeEnv);
|
|
575
|
+
if (trustHome)
|
|
576
|
+
await AGENTS[req.runtime].trustWorkspace?.(trustHome, await workspaceTrustKey(req.cwd));
|
|
577
|
+
}
|
|
466
578
|
const resolveEnv = this.resolutionEnv(req.runtime, configHome);
|
|
467
579
|
let bridgePath;
|
|
468
580
|
if (req.runtime === "claude" && prepared.servers.length) {
|
|
@@ -785,7 +897,7 @@ export class Muster {
|
|
|
785
897
|
// runs headless and never sees the dialog, so refusing an untrusted
|
|
786
898
|
// directory up front would break task launches that work today.
|
|
787
899
|
if (req.runtime === "claude" && diagnostic === neverRegistered)
|
|
788
|
-
diagnostic = claudeRegistryDiagnostic(await claudeWorkspaceTrusted(resolveEnv, req.cwd), req.cwd,
|
|
900
|
+
diagnostic = claudeRegistryDiagnostic(await claudeWorkspaceTrusted(resolveEnv, req.cwd), req.cwd, claudeTrustFile(resolveEnv), {
|
|
789
901
|
bypassWarningPending: req.kind === "session" &&
|
|
790
902
|
permissions.permissions === "bypass" &&
|
|
791
903
|
!req.options.includes("accept-bypass-warning"),
|
|
@@ -911,6 +1023,8 @@ export class Muster {
|
|
|
911
1023
|
});
|
|
912
1024
|
}
|
|
913
1025
|
catch (e) {
|
|
1026
|
+
if (e && typeof e === "object")
|
|
1027
|
+
e[FAILED_LAUNCH] = entry.launchId;
|
|
914
1028
|
await this.stopEntry(entry).catch(() => { });
|
|
915
1029
|
const message = e.message;
|
|
916
1030
|
await this.registry.update(entry.launchId, {
|
|
@@ -922,6 +1036,12 @@ export class Muster {
|
|
|
922
1036
|
.catch(() => { });
|
|
923
1037
|
throw e;
|
|
924
1038
|
}
|
|
1039
|
+
finally {
|
|
1040
|
+
// The readiness connection is a `codex app-server` child of THIS
|
|
1041
|
+
// process. Left open, its pipes keep the event loop alive, and the CLI
|
|
1042
|
+
// that launched a Codex session never exits after printing the record.
|
|
1043
|
+
await rpc?.close();
|
|
1044
|
+
}
|
|
925
1045
|
}
|
|
926
1046
|
/**
|
|
927
1047
|
* The answer to a request we have already seen. Never a new launch: a repeat
|
|
@@ -1105,10 +1225,35 @@ export class Muster {
|
|
|
1105
1225
|
async stop(id, requester = LOCAL) {
|
|
1106
1226
|
const entry = await this.find(id);
|
|
1107
1227
|
assertMayAct(entry, requester);
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1228
|
+
// Recorded before the kill: any transaction that sees the processes gone
|
|
1229
|
+
// before the write below lands would otherwise sweep the entry to
|
|
1230
|
+
// `exited`, and a waiter would report a stop as the agent ending itself.
|
|
1231
|
+
// A launch that has already ended is left as it ended: relabelling an
|
|
1232
|
+
// exited task `stopped` would tell its waiter the wrong thing.
|
|
1233
|
+
const requested = await this.registry.requestStop(entry.launchId, (e) => this.ended(e));
|
|
1234
|
+
if (!requested) {
|
|
1235
|
+
// As `wait` and `list` report it, not the raw status: a task's exit
|
|
1236
|
+
// file can land while its worker is still tearing down, and then the
|
|
1237
|
+
// registry still says running though the task has exited.
|
|
1238
|
+
const final = (await this.registry.peek(entry.launchId)) ?? entry;
|
|
1239
|
+
return {
|
|
1240
|
+
stopped: false,
|
|
1241
|
+
id: entry.id,
|
|
1242
|
+
state: (await this.view(final)).state,
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
await this.stopEntry(requested);
|
|
1246
|
+
// The agent may have exited on its own between the check and the kill;
|
|
1247
|
+
// an exit recorded before the stop was asked is still an exit.
|
|
1248
|
+
const exit = requested.kind === "task"
|
|
1249
|
+
? await readExit(requested.exitPath)
|
|
1250
|
+
: undefined;
|
|
1251
|
+
const status = endedByStop(requested, exit) ? "stopped" : "exited";
|
|
1252
|
+
await this.registry.update(entry.launchId, { status });
|
|
1253
|
+
await this.log.write({ event: status, launch_id: entry.launchId });
|
|
1254
|
+
return status === "stopped"
|
|
1255
|
+
? { stopped: true, id: entry.id }
|
|
1256
|
+
: { stopped: false, id: entry.id, state: status };
|
|
1112
1257
|
}
|
|
1113
1258
|
/**
|
|
1114
1259
|
* Where to look for the agent's own session registry. Two different uses of an
|
|
@@ -1200,68 +1345,269 @@ export class Muster {
|
|
|
1200
1345
|
for (const original of entries) {
|
|
1201
1346
|
if (kind && kind !== original.kind)
|
|
1202
1347
|
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
|
-
|
|
1348
|
+
result.push(await this.view(original));
|
|
1349
|
+
}
|
|
1350
|
+
return result;
|
|
1351
|
+
}
|
|
1352
|
+
/**
|
|
1353
|
+
* One entry as `list` reports it. Shared with `wait`, so the record a waiter
|
|
1354
|
+
* is handed is the record a listing would have shown for the same launch.
|
|
1355
|
+
*/
|
|
1356
|
+
async view(original) {
|
|
1357
|
+
const e = await this.refreshed(original);
|
|
1358
|
+
if (e.mcpConfigPath && !["starting", "running"].includes(e.status))
|
|
1359
|
+
await rm(e.mcpConfigPath, { force: true });
|
|
1360
|
+
if (e.identityPath && !["starting", "running"].includes(e.status))
|
|
1361
|
+
await rm(e.identityPath, { recursive: true, force: true });
|
|
1362
|
+
if (e.kind === "session")
|
|
1363
|
+
return {
|
|
1364
|
+
...e.peer,
|
|
1365
|
+
kind: "session",
|
|
1366
|
+
...(e.permissions
|
|
1367
|
+
? { permissions: e.permissions, sandbox: e.sandbox }
|
|
1368
|
+
: {}),
|
|
1369
|
+
// From the entry, not the peer: a listing must report the same fact a
|
|
1370
|
+
// launch result does, and an entry predating the field has none.
|
|
1371
|
+
...(e.enforcement ? { enforcement: e.enforcement } : {}),
|
|
1372
|
+
...(e.identity ? { identity: e.identity } : {}),
|
|
1373
|
+
// Absent on an entry written before the field, which is a different
|
|
1374
|
+
// fact from an explicit null: that one means Muster resolved nothing
|
|
1375
|
+
// and the child chose. Both are more useful than a guess.
|
|
1376
|
+
...(e.model !== undefined
|
|
1377
|
+
? { model: e.model, model_source: e.modelSource ?? null }
|
|
1378
|
+
: {}),
|
|
1379
|
+
...(e.idleTimeout ? { idle_timeout: e.idleTimeout } : {}),
|
|
1380
|
+
...(e.ttl ? { ttl: e.ttl } : {}),
|
|
1381
|
+
id: e.id,
|
|
1382
|
+
runtime: e.peer?.runtime ?? e.runtime,
|
|
1383
|
+
state: e.status === "running" ? (e.peer?.state ?? "idle") : e.status,
|
|
1384
|
+
host: e.host,
|
|
1385
|
+
...(e.error ? { error: e.error } : {}),
|
|
1386
|
+
...onExitView(e),
|
|
1387
|
+
};
|
|
1388
|
+
else {
|
|
1389
|
+
const exit = await readExit(e.exitPath);
|
|
1390
|
+
return {
|
|
1391
|
+
kind: "task",
|
|
1392
|
+
...(e.mcp ? { mcp: e.mcp } : {}),
|
|
1393
|
+
...(e.mcp_warnings ? { mcp_warnings: e.mcp_warnings } : {}),
|
|
1394
|
+
...(e.permissions
|
|
1395
|
+
? { permissions: e.permissions, sandbox: e.sandbox }
|
|
1396
|
+
: {}),
|
|
1397
|
+
...(e.enforcement ? { enforcement: e.enforcement } : {}),
|
|
1398
|
+
...(e.identity ? { identity: e.identity } : {}),
|
|
1399
|
+
// Absent on an entry written before the field, which is a different
|
|
1400
|
+
// fact from an explicit null: that one means Muster resolved nothing
|
|
1401
|
+
// and the child chose. Both are more useful than a guess.
|
|
1402
|
+
...(e.model !== undefined
|
|
1403
|
+
? { model: e.model, model_source: e.modelSource ?? null }
|
|
1404
|
+
: {}),
|
|
1405
|
+
id: e.id,
|
|
1406
|
+
runtime: e.runtime,
|
|
1407
|
+
cwd: e.cwd,
|
|
1408
|
+
// A stop wins over an exit file written after it: the worker writes
|
|
1409
|
+
// that file on the SIGTERM a stop sends, so its presence says the task
|
|
1410
|
+
// ended, not why. One written before the stop was asked is an exit.
|
|
1411
|
+
state: (exit || !["starting", "running"].includes(e.status)) &&
|
|
1412
|
+
endedByStop(e, exit)
|
|
1413
|
+
? "stopped"
|
|
1414
|
+
: exit
|
|
1415
|
+
? "exited"
|
|
1416
|
+
: e.status,
|
|
1417
|
+
...(e.root ? { pid: e.root.pid } : {}),
|
|
1418
|
+
...(exit ? { exit_code: exit.code, signal: exit.signal } : {}),
|
|
1419
|
+
...(e.error ? { error: e.error } : {}),
|
|
1420
|
+
...onExitView(e),
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* The entry an id names, by launch id first. A task's id IS its launch id; a
|
|
1426
|
+
* session's durable id replaces it at readiness, and the `--on-exit` watcher
|
|
1427
|
+
* is armed with the launch id, which is the one handle that never changes.
|
|
1428
|
+
*/
|
|
1429
|
+
async locate(id) {
|
|
1430
|
+
const byLaunch = (await this.registry.all()).find((e) => e.launchId === id);
|
|
1431
|
+
if (byLaunch)
|
|
1432
|
+
return byLaunch;
|
|
1433
|
+
try {
|
|
1434
|
+
return await this.find(id);
|
|
1435
|
+
}
|
|
1436
|
+
catch (e) {
|
|
1437
|
+
const message = e.message;
|
|
1438
|
+
// The resolver's "unknown: <every live name>" reads as a list of
|
|
1439
|
+
// matches; the ambiguous case does want its candidates, so it keeps them.
|
|
1440
|
+
throw new UnknownRun(message.startsWith("unknown")
|
|
1441
|
+
? `no session or task matches ${JSON.stringify(id)}`
|
|
1442
|
+
: message);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Whether the launch has ended, from cheap evidence about this one entry.
|
|
1447
|
+
* True is provisional for a process that vanished: the caller confirms it
|
|
1448
|
+
* through a transaction, whose sweep is what records `exited`.
|
|
1449
|
+
*/
|
|
1450
|
+
async ended(entry) {
|
|
1451
|
+
if (!["starting", "running"].includes(entry.status))
|
|
1452
|
+
return true;
|
|
1453
|
+
// A task's worker writes this the moment the agent exits, before it tears
|
|
1454
|
+
// down its group: the earliest signal there is, and the only exact one.
|
|
1455
|
+
if (entry.kind === "task" && (await readExit(entry.exitPath)))
|
|
1456
|
+
return true;
|
|
1457
|
+
if (!entry.root)
|
|
1458
|
+
return !(await isSame(entry.owner));
|
|
1459
|
+
if (await isSame(entry.root))
|
|
1460
|
+
return false;
|
|
1461
|
+
if ((await Promise.all((entry.descendants ?? []).map(isSame))).some(Boolean))
|
|
1462
|
+
return false;
|
|
1463
|
+
return !(entry.group && (await groupAlive(entry.group)));
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Blocks until the launch `id` names is no longer running, or `timeoutMs`
|
|
1467
|
+
* passes. An id that names nothing throws `UnknownRun` at once rather than
|
|
1468
|
+
* waiting for something that will never appear.
|
|
1469
|
+
*
|
|
1470
|
+
* Polls one entry, lock-free, backing off to two seconds: a watcher may wait
|
|
1471
|
+
* days on a long session, and a transaction per poll would take the registry
|
|
1472
|
+
* lock and run `ps` for every live entry, every time.
|
|
1473
|
+
*/
|
|
1474
|
+
async wait(id, opts = {}) {
|
|
1475
|
+
const { launchId } = await this.locate(id);
|
|
1476
|
+
const deadline = opts.timeoutMs === undefined ? Infinity : Date.now() + opts.timeoutMs;
|
|
1477
|
+
let interval = opts.pollMs ?? 250;
|
|
1478
|
+
while (true) {
|
|
1479
|
+
const peeked = await this.registry.peek(launchId);
|
|
1480
|
+
if (!peeked)
|
|
1481
|
+
throw new UnknownRun(`Unknown launch ${id}`);
|
|
1482
|
+
if (await this.ended(peeked)) {
|
|
1483
|
+
// The transaction's sweep settles what the cheap check guessed.
|
|
1484
|
+
const settled = (await this.registry.all()).find((e) => e.launchId === launchId);
|
|
1485
|
+
if (await this.ended(settled))
|
|
1486
|
+
return { done: true, record: finalRecord(await this.view(settled)) };
|
|
1487
|
+
}
|
|
1488
|
+
const left = deadline - Date.now();
|
|
1489
|
+
if (left <= 0)
|
|
1490
|
+
return { done: false, record: await this.view(peeked) };
|
|
1491
|
+
await delay(Math.min(interval, left));
|
|
1492
|
+
interval = Math.min(interval * 1.5, 2000);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Records `command` against the launch and starts the detached watcher that
|
|
1497
|
+
* runs it once the agent exits. The watcher is muster, outside any sandbox
|
|
1498
|
+
* the agent runs in, so a sealed agent that can write nothing still produces
|
|
1499
|
+
* the signal — and it fires on a crash or a kill, which a report the agent
|
|
1500
|
+
* makes about itself never would.
|
|
1501
|
+
*
|
|
1502
|
+
* CLI only, deliberately absent from the MCP `run` schema: the command runs
|
|
1503
|
+
* unsandboxed as the operator, which no MCP caller is granted.
|
|
1504
|
+
*/
|
|
1505
|
+
async armOnExit(id, command, cwd) {
|
|
1506
|
+
if (!command.trim())
|
|
1507
|
+
throw new Error("--on-exit needs a command");
|
|
1508
|
+
const entry = await this.locate(id);
|
|
1509
|
+
await this.registry.armOnExit(entry.launchId, { command, cwd });
|
|
1510
|
+
const watcher = spawn(process.execPath, [
|
|
1511
|
+
// <root>/dist from src under vitest and from dist in the package
|
|
1512
|
+
// alike. Same idiom as the reap-check job.
|
|
1513
|
+
fileURLToPath(new URL("../dist/muster.js", import.meta.url)),
|
|
1514
|
+
"on-exit-watch",
|
|
1515
|
+
"--home",
|
|
1516
|
+
this.home,
|
|
1517
|
+
entry.launchId,
|
|
1518
|
+
],
|
|
1519
|
+
// In muster's home, not the caller's directory: the command's directory
|
|
1520
|
+
// is recorded on the entry, and a watcher started inside one that is
|
|
1521
|
+
// later removed cannot even start Node there.
|
|
1522
|
+
{ detached: true, stdio: "ignore", env: this.sourceEnv, cwd: this.home });
|
|
1523
|
+
await new Promise((resolve, reject) => {
|
|
1524
|
+
watcher.once("spawn", resolve);
|
|
1525
|
+
watcher.once("error", reject);
|
|
1526
|
+
});
|
|
1527
|
+
watcher.unref();
|
|
1528
|
+
await this.log.write({
|
|
1529
|
+
event: "on_exit_armed",
|
|
1530
|
+
launch_id: entry.launchId,
|
|
1531
|
+
watcher_pid: watcher.pid,
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* The watcher's whole job: wait for the launch to end, claim the command so
|
|
1536
|
+
* at most one watcher runs it, and run it. Its output goes to
|
|
1537
|
+
* `<home>/on-exit/<launch id>.log`, its status to the registry entry (shown
|
|
1538
|
+
* as `on_exit` by `wait` and `list`) and then to the launch log.
|
|
1539
|
+
*
|
|
1540
|
+
* Everything that can fail is settled before the claim — where it runs and
|
|
1541
|
+
* where its output goes — because a claim is spent whether or not the
|
|
1542
|
+
* command then starts. After the claim the command is spawned before any
|
|
1543
|
+
* launch-log write, so a log that cannot be written cannot stop it, and its
|
|
1544
|
+
* result lands in the registry first: a claim with no result there is a
|
|
1545
|
+
* watcher that died mid-command, which `on_exit` shows as `result: null`.
|
|
1546
|
+
*/
|
|
1547
|
+
async runOnExit(launchId) {
|
|
1548
|
+
const { record } = await this.wait(launchId);
|
|
1549
|
+
// The hook is told how the launch ended, not about itself.
|
|
1550
|
+
const { on_exit: _, ...hookRecord } = record;
|
|
1551
|
+
const armed = (await this.registry.peek(launchId))?.onExit;
|
|
1552
|
+
if (!armed)
|
|
1553
|
+
return null;
|
|
1554
|
+
const prepared = await prepareOnExit({
|
|
1555
|
+
cwd: armed.cwd,
|
|
1556
|
+
fallbackCwd: this.sourceEnv.HOME ?? homedir(),
|
|
1557
|
+
logPath: join(this.home, "on-exit", `${launchId}.log`),
|
|
1558
|
+
});
|
|
1559
|
+
try {
|
|
1560
|
+
const entry = await this.registry.claimOnExit(launchId);
|
|
1561
|
+
if (!entry)
|
|
1562
|
+
return null;
|
|
1563
|
+
let result;
|
|
1564
|
+
try {
|
|
1565
|
+
const outcome = await runOnExitCommand({
|
|
1566
|
+
command: entry.onExit.command,
|
|
1567
|
+
cwd: prepared.cwd,
|
|
1568
|
+
env: this.sourceEnv,
|
|
1569
|
+
entry,
|
|
1570
|
+
record: hookRecord,
|
|
1571
|
+
log: prepared.log,
|
|
1572
|
+
onSpawn: () => void this.log
|
|
1573
|
+
.write({
|
|
1574
|
+
event: "on_exit_claimed",
|
|
1575
|
+
launch_id: launchId,
|
|
1576
|
+
state: record.state,
|
|
1577
|
+
...(prepared.warnings.length
|
|
1578
|
+
? { warnings: prepared.warnings }
|
|
1579
|
+
: {}),
|
|
1580
|
+
})
|
|
1581
|
+
.catch(() => { }),
|
|
1261
1582
|
});
|
|
1583
|
+
result = { finished_at: new Date().toISOString(), ...outcome };
|
|
1584
|
+
}
|
|
1585
|
+
catch (e) {
|
|
1586
|
+
result = {
|
|
1587
|
+
finished_at: new Date().toISOString(),
|
|
1588
|
+
exit_code: null,
|
|
1589
|
+
signal: null,
|
|
1590
|
+
error: e.message,
|
|
1591
|
+
};
|
|
1262
1592
|
}
|
|
1593
|
+
if (prepared.warnings.length)
|
|
1594
|
+
result.warnings = prepared.warnings;
|
|
1595
|
+
await this.registry.update(launchId, { onExitResult: result });
|
|
1596
|
+
await this.log
|
|
1597
|
+
.write({
|
|
1598
|
+
event: "on_exit",
|
|
1599
|
+
launch_id: launchId,
|
|
1600
|
+
state: record.state,
|
|
1601
|
+
exit_code: result.exit_code,
|
|
1602
|
+
...(result.signal ? { signal: result.signal } : {}),
|
|
1603
|
+
...(result.error ? { error: result.error } : {}),
|
|
1604
|
+
})
|
|
1605
|
+
.catch(() => { });
|
|
1606
|
+
return result.exit_code;
|
|
1607
|
+
}
|
|
1608
|
+
finally {
|
|
1609
|
+
await prepared.log?.close();
|
|
1263
1610
|
}
|
|
1264
|
-
return result;
|
|
1265
1611
|
}
|
|
1266
1612
|
async output(id, requester = LOCAL) {
|
|
1267
1613
|
const entry = await this.find(id);
|