@brutalsystems/muster 0.7.9 → 0.7.11
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 +83 -0
- package/dist/config.js +3 -0
- package/dist/format.js +1 -1
- package/dist/guard.js +73 -1
- package/dist/muster.js +29 -3
- package/dist/registry.js +56 -4
- package/dist/run.js +81 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -180,6 +180,89 @@ OpenCode sessions otherwise use the same tmux/pty, viewer, list, stop, and human
|
|
|
180
180
|
output behavior described above; OpenCode tasks use the same detached output
|
|
181
181
|
and exit-status lifecycle as other tasks.
|
|
182
182
|
|
|
183
|
+
## Repeating a launch request
|
|
184
|
+
|
|
185
|
+
`--request-key KEY` (MCP: `requestKey`) names the launch *request*, not the
|
|
186
|
+
session it produces. Sending the same key again returns the launch it already
|
|
187
|
+
produced instead of starting a second agent:
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
muster run codex --prompt "Review the auth flow" --request-key review-auth-7f3a
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
A key is bound to the parameters it was first seen with — runtime, kind,
|
|
194
|
+
resolved working directory, the instruction, the effective permissions, the
|
|
195
|
+
selected MCP servers and plugins, and the model. Sending the same key with any
|
|
196
|
+
of those changed is **refused**, rather than answered with a session that does
|
|
197
|
+
something other than what was asked. Launching something genuinely different
|
|
198
|
+
means using a different key.
|
|
199
|
+
|
|
200
|
+
The fingerprint is computed here, after validation, rather than accepted from
|
|
201
|
+
the caller: two machines canonicalize a path differently, and a fingerprint
|
|
202
|
+
computed before validation would disagree with itself.
|
|
203
|
+
|
|
204
|
+
What a repeat returns depends on what the first attempt did:
|
|
205
|
+
|
|
206
|
+
| First attempt | A repeat gets |
|
|
207
|
+
| --- | --- |
|
|
208
|
+
| Reachable session or running task | That session or task, unchanged |
|
|
209
|
+
| Still launching | Refused — ask for its status instead |
|
|
210
|
+
| Failed, exited or stopped | Refused, naming the recorded outcome |
|
|
211
|
+
| Outcome unknown | Refused, and told to inspect first (below) |
|
|
212
|
+
|
|
213
|
+
A repeat never consumes launch capacity, including when the concurrency cap is
|
|
214
|
+
full of the very launch being asked about. Omitting the key leaves behaviour
|
|
215
|
+
exactly as it was: every request is its own launch.
|
|
216
|
+
|
|
217
|
+
## When an outcome is unknown
|
|
218
|
+
|
|
219
|
+
If Muster dies between starting a process and recording it, the entry says
|
|
220
|
+
`unknown` rather than `failed`, and `muster list` reports it that way.
|
|
221
|
+
|
|
222
|
+
The distinction is not cosmetic. `failed` means nothing is running and retrying
|
|
223
|
+
is safe; `unknown` means a session may be running that Muster never got to
|
|
224
|
+
record, and relaunching would be how a second agent appears in the same
|
|
225
|
+
directory. A repeat under the same request key is refused for exactly that
|
|
226
|
+
reason.
|
|
227
|
+
|
|
228
|
+
Only the half of the window that provably never reached a spawn is still called
|
|
229
|
+
`failed` — an owner that died before Muster marked itself about to start a
|
|
230
|
+
process. Resolving an `unknown` entry is manual today: look for the session,
|
|
231
|
+
stop it if it is there, and launch again under a new key.
|
|
232
|
+
|
|
233
|
+
## Where a launch may run
|
|
234
|
+
|
|
235
|
+
By default any directory the account can read is a legal target, which is the
|
|
236
|
+
behaviour Muster has always had. Two config settings narrow it:
|
|
237
|
+
|
|
238
|
+
```toml
|
|
239
|
+
allowed_roots = ["/Users/you/Source"]
|
|
240
|
+
|
|
241
|
+
[projects]
|
|
242
|
+
muster = "/Users/you/Source/brutalsystems/muster"
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
With either configured, a launch outside all of them is refused. A configured
|
|
246
|
+
project is a grant in its own right — naming a directory there permits it
|
|
247
|
+
without also permitting its parent.
|
|
248
|
+
|
|
249
|
+
```sh
|
|
250
|
+
muster run codex --project muster --prompt "Review the auth flow"
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
`--project NAME` (MCP: `project`) is used instead of `--cwd`; naming both is an
|
|
254
|
+
error rather than a precedence rule. A project name is also portable in a way an
|
|
255
|
+
absolute path is not, since the same name can point somewhere different on
|
|
256
|
+
another machine.
|
|
257
|
+
|
|
258
|
+
The check resolves symlinks before comparing, so a link inside an allowed root
|
|
259
|
+
that points elsewhere does not escape it, and compares path-relatively, so
|
|
260
|
+
`/work/project-other` is not treated as part of `/work/project`.
|
|
261
|
+
|
|
262
|
+
This constrains **where a session starts, not what it can then reach**. It is
|
|
263
|
+
not a sandbox: a launched agent still has whatever access the OS account has.
|
|
264
|
+
Permission modes and the sandbox settings below are the controls for that.
|
|
265
|
+
|
|
183
266
|
## Process ownership
|
|
184
267
|
|
|
185
268
|
Muster identifies a launched process by its **pid paired with its start time**,
|
package/dist/config.js
CHANGED
|
@@ -11,6 +11,9 @@ export const configSchema = z
|
|
|
11
11
|
host: z.enum(["auto", "tmux", "pty", "macos-terminal"]).default("auto"),
|
|
12
12
|
launch_timeout_sec: z.number().finite().positive().max(600).default(30),
|
|
13
13
|
max_concurrent: z.number().int().positive().max(1000).default(4),
|
|
14
|
+
allowed_roots: z.array(z.string().min(1)).default([]),
|
|
15
|
+
projects: z.record(z.string().regex(/^[a-zA-Z0-9_-]+$/), z.string().min(1))
|
|
16
|
+
.default({}),
|
|
14
17
|
permissions: z.enum(["auto", "deny", "bypass"]).default("deny"),
|
|
15
18
|
sandbox: z
|
|
16
19
|
.enum([
|
package/dist/format.js
CHANGED
|
@@ -19,7 +19,7 @@ function recordHuman(record, color) {
|
|
|
19
19
|
const paint = (value, code) => color ? `\u001b[${code}m${value}\u001b[0m` : value;
|
|
20
20
|
const field = (label, value) => ` ${paint(label + ":", 90)} ${value}`;
|
|
21
21
|
const badExit = typeof record.exit_code === "number" && record.exit_code !== 0;
|
|
22
|
-
const stateColor = record.state === "failed" || badExit
|
|
22
|
+
const stateColor = record.state === "failed" || record.state === "unknown" || badExit
|
|
23
23
|
? 31
|
|
24
24
|
: ["idle", "running"].includes(record.state ?? "")
|
|
25
25
|
? 32
|
package/dist/guard.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
5
|
import { mcpName, claudeMcpConfig } from "./mcp.js";
|
|
4
6
|
import { pluginName } from "./plugins.js";
|
|
5
7
|
export const runSchema = z
|
|
@@ -22,6 +24,11 @@ export const runSchema = z
|
|
|
22
24
|
mcp: z.array(mcpName).optional(),
|
|
23
25
|
plugin: z.array(pluginName).optional(),
|
|
24
26
|
model: z.string().min(1).optional(),
|
|
27
|
+
project: z
|
|
28
|
+
.string()
|
|
29
|
+
.regex(/^[a-zA-Z0-9_-]+$/)
|
|
30
|
+
.optional(),
|
|
31
|
+
requestKey: z.string().min(1).max(200).optional(),
|
|
25
32
|
args: z.array(z.string()).default([]),
|
|
26
33
|
})
|
|
27
34
|
.strict();
|
|
@@ -40,6 +47,71 @@ export function resolvePermissions(req, config) {
|
|
|
40
47
|
throw new Error("--permissions bypass requires --sandbox full-access");
|
|
41
48
|
return { permissions, sandbox };
|
|
42
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Where a launch is allowed to run.
|
|
52
|
+
*
|
|
53
|
+
* Two properties do the work, and a prefix comparison has neither:
|
|
54
|
+
*
|
|
55
|
+
* - **Symlinks are resolved first.** A link inside an allowed root pointing
|
|
56
|
+
* anywhere else would otherwise be a way out of the root, and the path as
|
|
57
|
+
* written says nothing about where it lands.
|
|
58
|
+
* - **Containment is path-relative.** `/work/project-other` is not inside
|
|
59
|
+
* `/work/project`, though it shares every character of it.
|
|
60
|
+
*
|
|
61
|
+
* Configured project paths are roots in their own right: naming a directory as
|
|
62
|
+
* a project is the grant.
|
|
63
|
+
*
|
|
64
|
+
* With nothing configured this permits anything the account can reach, which is
|
|
65
|
+
* the behaviour Muster has always had. That is a choice, and the README says so
|
|
66
|
+
* — it is not a safe default inherited by accident.
|
|
67
|
+
*/
|
|
68
|
+
export async function assertAllowedWorkspace(cwd, config) {
|
|
69
|
+
const roots = [...config.allowed_roots, ...Object.values(config.projects)];
|
|
70
|
+
if (!roots.length)
|
|
71
|
+
return;
|
|
72
|
+
const real = await realpath(cwd);
|
|
73
|
+
for (const root of roots) {
|
|
74
|
+
const resolved = await realpath(resolve(root)).catch(() => null);
|
|
75
|
+
if (!resolved)
|
|
76
|
+
continue;
|
|
77
|
+
const rel = relative(resolved, real);
|
|
78
|
+
if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)))
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`cwd is outside every configured allowed root: ${cwd}. ` +
|
|
82
|
+
`Add a root or a project to config.toml, or launch somewhere already permitted.`);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* What a request key is bound to, so the same key arriving with different
|
|
86
|
+
* parameters can be refused rather than silently answered with the wrong
|
|
87
|
+
* session.
|
|
88
|
+
*
|
|
89
|
+
* Computed here, after validation and after defaults are resolved, rather than
|
|
90
|
+
* accepted from the caller: a sender and a receiver canonicalize a path
|
|
91
|
+
* differently (symlinks, /private, a different home directory), and a
|
|
92
|
+
* fingerprint computed before validation would disagree with itself across
|
|
93
|
+
* machines.
|
|
94
|
+
*
|
|
95
|
+
* Covers what changes *what gets launched*. Excludes anything cosmetic or
|
|
96
|
+
* derived at launch — timestamps, the key itself, pids, ports, tmux targets —
|
|
97
|
+
* because changing those should not make this a different request.
|
|
98
|
+
*/
|
|
99
|
+
export function paramsFingerprint(req, permissions, mcp, plugins) {
|
|
100
|
+
return createHash("sha256")
|
|
101
|
+
.update(JSON.stringify([
|
|
102
|
+
req.runtime,
|
|
103
|
+
req.kind,
|
|
104
|
+
req.cwd,
|
|
105
|
+
createHash("sha256").update(req.prompt).digest("hex"),
|
|
106
|
+
permissions.permissions,
|
|
107
|
+
permissions.sandbox,
|
|
108
|
+
[...mcp].sort(),
|
|
109
|
+
[...plugins].sort(),
|
|
110
|
+
req.model ?? null,
|
|
111
|
+
req.args,
|
|
112
|
+
]))
|
|
113
|
+
.digest("hex");
|
|
114
|
+
}
|
|
43
115
|
export function runtimeArgs(req) {
|
|
44
116
|
const result = [];
|
|
45
117
|
const allowed = req.runtime === "claude"
|
package/dist/muster.js
CHANGED
|
@@ -17,7 +17,7 @@ const listSchema = z
|
|
|
17
17
|
const idSchema = z.object({ id: z.string().min(1) }).strict();
|
|
18
18
|
const { version } = createRequire(import.meta.url)("../package.json");
|
|
19
19
|
const help = `Usage:
|
|
20
|
-
muster run <codex|claude|opencode> --prompt TEXT [--cwd DIR] [--kind session|task] [--host ID] [--open] [--terminal APP] [-- RUNTIME_OPTIONS]
|
|
20
|
+
muster run <codex|claude|opencode> --prompt TEXT [--cwd DIR] [--kind session|task] [--host ID] [--open] [--terminal APP] [--request-key KEY] [-- RUNTIME_OPTIONS]
|
|
21
21
|
muster list [--kind session|task] [--format json|human]
|
|
22
22
|
muster stop <id> [--format json|human]
|
|
23
23
|
muster output <id>
|
|
@@ -37,6 +37,12 @@ which also admits any plugin the target repository ships.
|
|
|
37
37
|
--model PROVIDER/MODEL selects the OpenCode model, overriding the configured
|
|
38
38
|
[opencode] model. Providers defined under [opencode.provider] are supplied to
|
|
39
39
|
the child, so launches do not depend on the user's own OpenCode config.
|
|
40
|
+
--project NAME launches in a directory named under [projects] in config.toml,
|
|
41
|
+
instead of --cwd; naming both is an error. When allowed_roots or [projects] are
|
|
42
|
+
configured, a launch outside all of them is refused.
|
|
43
|
+
--request-key KEY names the launch request itself. Sending the same key again
|
|
44
|
+
returns the launch it already produced rather than starting a second one, and
|
|
45
|
+
sending it with different parameters is refused rather than answered.
|
|
40
46
|
run, list and stop default to JSON. output prints raw task text.
|
|
41
47
|
|
|
42
48
|
doctor checks path-configured plugins against the packages they came from: a
|
|
@@ -126,6 +132,17 @@ async function main() {
|
|
|
126
132
|
items: { type: "string", pattern: "^[a-zA-Z0-9_-]+$" },
|
|
127
133
|
description: "Configured MCP server names. Omit for session defaults; [] disables all. Tasks have no defaults.",
|
|
128
134
|
},
|
|
135
|
+
project: {
|
|
136
|
+
type: "string",
|
|
137
|
+
pattern: "^[a-zA-Z0-9_-]+$",
|
|
138
|
+
description: "A project named in config.toml, used instead of cwd. Naming both is an error.",
|
|
139
|
+
},
|
|
140
|
+
requestKey: {
|
|
141
|
+
type: "string",
|
|
142
|
+
minLength: 1,
|
|
143
|
+
maxLength: 200,
|
|
144
|
+
description: "Caller-owned identity for this launch. Repeating it returns the launch it already produced instead of starting another; repeating it with different parameters is refused.",
|
|
145
|
+
},
|
|
129
146
|
args: { type: "array", items: { type: "string" } },
|
|
130
147
|
},
|
|
131
148
|
required: ["runtime", "prompt"],
|
|
@@ -217,9 +234,13 @@ async function main() {
|
|
|
217
234
|
plugin: { type: "string", multiple: true },
|
|
218
235
|
model: { type: "string" },
|
|
219
236
|
"no-plugin": { type: "boolean" },
|
|
237
|
+
"request-key": { type: "string" },
|
|
238
|
+
project: { type: "string" },
|
|
220
239
|
},
|
|
221
240
|
});
|
|
222
|
-
const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, ...values } = parsed;
|
|
241
|
+
const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, "request-key": requestKey, ...values } = parsed;
|
|
242
|
+
if (requestKey !== undefined && command !== "run")
|
|
243
|
+
throw new Error("--request-key applies only to run");
|
|
223
244
|
if (noMcp !== undefined && command !== "run")
|
|
224
245
|
throw new Error("--no-mcp applies only to run");
|
|
225
246
|
if (noMcp && values.mcp)
|
|
@@ -242,7 +263,12 @@ async function main() {
|
|
|
242
263
|
if (command === "run") {
|
|
243
264
|
const runtime = positionals[0];
|
|
244
265
|
const args = positionals.slice(1);
|
|
245
|
-
const record = await muster.run({
|
|
266
|
+
const record = await muster.run({
|
|
267
|
+
runtime,
|
|
268
|
+
...values,
|
|
269
|
+
...(requestKey === undefined ? {} : { requestKey }),
|
|
270
|
+
args,
|
|
271
|
+
});
|
|
246
272
|
print(record);
|
|
247
273
|
if (record.kind === "session" && record.host === "pty") {
|
|
248
274
|
process.stderr.write("[muster] pty: not watchable or attachable; this process owns the session. Ctrl-C stops it.\n");
|
package/dist/registry.js
CHANGED
|
@@ -2,6 +2,25 @@ import { mkdir, readFile, writeFile, rename, rm } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import { processRef, isSame, delay, } from "./identity/processes.js";
|
|
5
|
+
/**
|
|
6
|
+
* A request key arrived again with different parameters. Distinct from a launch
|
|
7
|
+
* failure: nothing was attempted, and the caller reused a key it should have
|
|
8
|
+
* rotated.
|
|
9
|
+
*/
|
|
10
|
+
export class IdempotencyKeyReuse extends Error {
|
|
11
|
+
requestKey;
|
|
12
|
+
recorded;
|
|
13
|
+
offered;
|
|
14
|
+
constructor(requestKey, recorded, offered) {
|
|
15
|
+
super(`Request key ${requestKey} was recorded with different parameters ` +
|
|
16
|
+
`(recorded ${recorded.slice(0, 12)}, offered ${offered.slice(0, 12)}). ` +
|
|
17
|
+
`Use a new key to launch something different.`);
|
|
18
|
+
this.requestKey = requestKey;
|
|
19
|
+
this.recorded = recorded;
|
|
20
|
+
this.offered = offered;
|
|
21
|
+
this.name = "IdempotencyKeyReuse";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
5
24
|
export class Registry {
|
|
6
25
|
home;
|
|
7
26
|
constructor(home) {
|
|
@@ -40,8 +59,23 @@ export class Registry {
|
|
|
40
59
|
if (entry.status === "starting" &&
|
|
41
60
|
!(await isSame(entry.owner)) &&
|
|
42
61
|
!entry.root) {
|
|
43
|
-
|
|
44
|
-
|
|
62
|
+
// The owner died between reserving and recording a process. Whether
|
|
63
|
+
// one exists depends on how far it got, and the entry cannot say —
|
|
64
|
+
// so only the half that provably never reached a spawn is called
|
|
65
|
+
// failed. The other half is unknown, which is not the same claim:
|
|
66
|
+
// failed reads as safe to retry, and retrying a launch that did
|
|
67
|
+
// happen is how a second agent appears in the same directory.
|
|
68
|
+
if (entry.spawning) {
|
|
69
|
+
entry.status = "unknown";
|
|
70
|
+
entry.unknownSince ??= new Date().toISOString();
|
|
71
|
+
entry.error =
|
|
72
|
+
"launch owner exited after starting a process but before recording it; " +
|
|
73
|
+
"a session may be running untracked";
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
entry.status = "failed";
|
|
77
|
+
entry.error = "launch owner exited before recording process";
|
|
78
|
+
}
|
|
45
79
|
}
|
|
46
80
|
if (["starting", "running"].includes(entry.status) &&
|
|
47
81
|
entry.root &&
|
|
@@ -65,11 +99,28 @@ export class Registry {
|
|
|
65
99
|
async all() {
|
|
66
100
|
return this.transaction(async (entries) => structuredClone(entries));
|
|
67
101
|
}
|
|
68
|
-
|
|
102
|
+
/**
|
|
103
|
+
* The lookup runs inside the same transaction as the write, so the mkdir lock
|
|
104
|
+
* that already serializes reservations serializes deduplication too.
|
|
105
|
+
*
|
|
106
|
+
* Order is load-bearing: a match returns before the cap is consulted, because
|
|
107
|
+
* the entry it returns is already counted against the cap. Checking capacity
|
|
108
|
+
* first would reject a retry of a launch that succeeded, which reads as a
|
|
109
|
+
* failure and invites a third attempt.
|
|
110
|
+
*/
|
|
111
|
+
async reserve(req, cap, idem) {
|
|
69
112
|
const owner = await processRef(process.pid);
|
|
70
113
|
if (!owner)
|
|
71
114
|
throw new Error("Cannot identify Muster owner process");
|
|
72
115
|
return this.transaction(async (entries) => {
|
|
116
|
+
if (idem) {
|
|
117
|
+
const prior = entries.find((e) => e.requestKey === idem.requestKey);
|
|
118
|
+
if (prior) {
|
|
119
|
+
if (prior.paramsFingerprint !== idem.paramsFingerprint)
|
|
120
|
+
throw new IdempotencyKeyReuse(idem.requestKey, prior.paramsFingerprint ?? "none", idem.paramsFingerprint);
|
|
121
|
+
return { entry: structuredClone(prior), deduped: true };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
73
124
|
if (entries.filter((e) => e.status === "starting" || e.status === "running")
|
|
74
125
|
.length >= cap)
|
|
75
126
|
throw new Error(`Concurrency cap reached (${cap})`);
|
|
@@ -78,12 +129,13 @@ export class Registry {
|
|
|
78
129
|
...req,
|
|
79
130
|
id,
|
|
80
131
|
launchId: id,
|
|
132
|
+
...(idem ?? {}),
|
|
81
133
|
status: "starting",
|
|
82
134
|
createdAt: new Date().toISOString(),
|
|
83
135
|
owner,
|
|
84
136
|
};
|
|
85
137
|
entries.push(entry);
|
|
86
|
-
return { ...entry };
|
|
138
|
+
return { entry: { ...entry }, deduped: false };
|
|
87
139
|
});
|
|
88
140
|
}
|
|
89
141
|
async update(id, patch) {
|
package/dist/run.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { stat, readFile, writeFile, mkdir, rm } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { loadConfig } from "./config.js";
|
|
7
|
-
import { runSchema, launchArgs, launchEnv, resolvePermissions, runtimeArgs, } from "./guard.js";
|
|
7
|
+
import { runSchema, assertAllowedWorkspace, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, runtimeArgs, } from "./guard.js";
|
|
8
8
|
import { Registry } from "./registry.js";
|
|
9
9
|
import { LaunchLog } from "./log.js";
|
|
10
10
|
import { hosts, selectHost } from "./hosts/index.js";
|
|
@@ -23,6 +23,20 @@ import { assertOpenCodeVersion, openCodeMcpNames, openCodeSessionLaunch, openCod
|
|
|
23
23
|
import { assignNames, resolvePeer, runtimeName, } from "./naming.js";
|
|
24
24
|
import { selectMcp, prepareMcp, mcpEnvironment } from "./mcp.js";
|
|
25
25
|
import { selectPlugins } from "./plugins.js";
|
|
26
|
+
function taskHandle(entry, pid) {
|
|
27
|
+
return {
|
|
28
|
+
kind: "task",
|
|
29
|
+
permissions: entry.permissions,
|
|
30
|
+
sandbox: entry.sandbox,
|
|
31
|
+
mcp: entry.mcp ?? [],
|
|
32
|
+
...(entry.mcp_warnings ? { mcp_warnings: entry.mcp_warnings } : {}),
|
|
33
|
+
id: entry.id,
|
|
34
|
+
runtime: entry.runtime,
|
|
35
|
+
state: "running",
|
|
36
|
+
cwd: entry.cwd,
|
|
37
|
+
pid,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
26
40
|
export class Muster {
|
|
27
41
|
config;
|
|
28
42
|
home;
|
|
@@ -55,9 +69,25 @@ export class Muster {
|
|
|
55
69
|
return operation.finally(() => this.active.delete(operation));
|
|
56
70
|
}
|
|
57
71
|
async launch(input, requester) {
|
|
72
|
+
// Read before parsing, because cwd defaults during it and a default is then
|
|
73
|
+
// indistinguishable from a directory the caller asked for. Naming both a
|
|
74
|
+
// project and a directory is a contradiction worth reporting rather than
|
|
75
|
+
// resolving by precedence.
|
|
76
|
+
if (typeof input === "object" &&
|
|
77
|
+
input !== null &&
|
|
78
|
+
"project" in input &&
|
|
79
|
+
input.cwd !== undefined)
|
|
80
|
+
throw new Error("use project or cwd, not both");
|
|
58
81
|
const req = runSchema.parse(input);
|
|
82
|
+
if (req.project) {
|
|
83
|
+
const configured = this.config.projects[req.project];
|
|
84
|
+
if (!configured)
|
|
85
|
+
throw new Error(`Unknown project ${req.project}. Configured: ${Object.keys(this.config.projects).join(", ") || "none"}`);
|
|
86
|
+
req.cwd = resolve(configured);
|
|
87
|
+
}
|
|
59
88
|
if (!(await stat(req.cwd)).isDirectory())
|
|
60
89
|
throw new Error("cwd is not a directory");
|
|
90
|
+
await assertAllowedWorkspace(req.cwd, this.config);
|
|
61
91
|
if (req.kind === "task" && req.host)
|
|
62
92
|
throw new Error("host applies only to session launches");
|
|
63
93
|
if (req.terminal && !req.open)
|
|
@@ -86,7 +116,22 @@ export class Muster {
|
|
|
86
116
|
await assertClaudePolicy(this.env);
|
|
87
117
|
if (req.runtime === "opencode")
|
|
88
118
|
await assertOpenCodeVersion(this.env, "1.18.31", Date.now() + 4000);
|
|
89
|
-
const reservation = await this.registry.reserve({ runtime: req.runtime, kind: req.kind, cwd: req.cwd, ...permissions }, this.config.max_concurrent
|
|
119
|
+
const { entry: reservation, deduped } = await this.registry.reserve({ runtime: req.runtime, kind: req.kind, cwd: req.cwd, ...permissions }, this.config.max_concurrent, req.requestKey
|
|
120
|
+
? {
|
|
121
|
+
requestKey: req.requestKey,
|
|
122
|
+
paramsFingerprint: paramsFingerprint(req, permissions, selectedMcp.map((s) => s.name), selectedPlugins.map((p) => p.name)),
|
|
123
|
+
}
|
|
124
|
+
: undefined);
|
|
125
|
+
if (deduped) {
|
|
126
|
+
await this.log.write({
|
|
127
|
+
event: "deduped",
|
|
128
|
+
launch_id: reservation.launchId,
|
|
129
|
+
request_key: req.requestKey,
|
|
130
|
+
status: reservation.status,
|
|
131
|
+
requester,
|
|
132
|
+
});
|
|
133
|
+
return this.recordedOutcome(reservation);
|
|
134
|
+
}
|
|
90
135
|
this.owned.add(reservation.launchId);
|
|
91
136
|
let entry = reservation;
|
|
92
137
|
const startedAt = Date.now();
|
|
@@ -148,6 +193,9 @@ export class Muster {
|
|
|
148
193
|
}
|
|
149
194
|
if (this.closing)
|
|
150
195
|
throw new Error("Launch cancelled: Muster is closing");
|
|
196
|
+
// From here a process may exist without the registry knowing it. See the
|
|
197
|
+
// recovery branch in Registry.transaction.
|
|
198
|
+
entry = await this.registry.update(entry.launchId, { spawning: true });
|
|
151
199
|
if (req.kind === "task")
|
|
152
200
|
return await this.startTask(entry, argv, runtimeEnv);
|
|
153
201
|
const launched = await driver.launch({
|
|
@@ -305,6 +353,35 @@ export class Muster {
|
|
|
305
353
|
await rpc?.close();
|
|
306
354
|
}
|
|
307
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* The answer to a request we have already seen. Never a new launch: a repeat
|
|
358
|
+
* of a request that succeeded must return what it produced, and a repeat of
|
|
359
|
+
* one that did not must report that rather than quietly trying again.
|
|
360
|
+
*
|
|
361
|
+
* A launch still in flight is refused rather than answered, because there is
|
|
362
|
+
* nothing yet to return and inventing a pending result would let a caller
|
|
363
|
+
* treat it as a session.
|
|
364
|
+
*/
|
|
365
|
+
recordedOutcome(entry) {
|
|
366
|
+
const key = entry.requestKey;
|
|
367
|
+
if (entry.status === "starting")
|
|
368
|
+
throw new Error(`Request key ${key} is already launching (${entry.launchId}); ask for its status rather than launching again`);
|
|
369
|
+
if (entry.status === "unknown")
|
|
370
|
+
throw new Error(`Request key ${key} has an unknown outcome (${entry.launchId}): ${entry.error}. ` +
|
|
371
|
+
`Inspect before launching again — this is not a failure, and retrying it may leave two sessions`);
|
|
372
|
+
if (entry.status !== "running")
|
|
373
|
+
throw new Error(`Request key ${key} already resolved as ${entry.status}` +
|
|
374
|
+
(entry.error ? `: ${entry.error}` : "") +
|
|
375
|
+
`; use a different key to launch again`);
|
|
376
|
+
if (entry.kind === "task") {
|
|
377
|
+
if (!entry.root)
|
|
378
|
+
throw new Error(`Request key ${key} has no recorded task process`);
|
|
379
|
+
return taskHandle(entry, entry.root.pid);
|
|
380
|
+
}
|
|
381
|
+
if (!entry.peer)
|
|
382
|
+
throw new Error(`Request key ${key} has no recorded session`);
|
|
383
|
+
return entry.peer;
|
|
384
|
+
}
|
|
308
385
|
async startTask(entry, argv, env) {
|
|
309
386
|
const dir = join(this.home, "tasks", entry.id);
|
|
310
387
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
@@ -342,18 +419,7 @@ export class Muster {
|
|
|
342
419
|
});
|
|
343
420
|
await writeFile(startPath, "start", { mode: 0o600 });
|
|
344
421
|
child.unref();
|
|
345
|
-
const handle =
|
|
346
|
-
kind: "task",
|
|
347
|
-
permissions: entry.permissions,
|
|
348
|
-
sandbox: entry.sandbox,
|
|
349
|
-
mcp: entry.mcp ?? [],
|
|
350
|
-
...(entry.mcp_warnings ? { mcp_warnings: entry.mcp_warnings } : {}),
|
|
351
|
-
id: entry.id,
|
|
352
|
-
runtime: entry.runtime,
|
|
353
|
-
state: "running",
|
|
354
|
-
cwd: entry.cwd,
|
|
355
|
-
pid: root.pid,
|
|
356
|
-
};
|
|
422
|
+
const handle = taskHandle(entry, root.pid);
|
|
357
423
|
await this.log.write({
|
|
358
424
|
event: "started",
|
|
359
425
|
launch_id: entry.launchId,
|