@brutalsystems/muster 0.7.10 → 0.7.12
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 +66 -4
- package/dist/config.js +3 -0
- package/dist/format.js +1 -1
- package/dist/guard.js +40 -1
- package/dist/identity/processes.js +60 -12
- package/dist/muster.js +9 -0
- package/dist/registry.js +23 -4
- package/dist/run.js +40 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -208,11 +208,61 @@ What a repeat returns depends on what the first attempt did:
|
|
|
208
208
|
| Reachable session or running task | That session or task, unchanged |
|
|
209
209
|
| Still launching | Refused — ask for its status instead |
|
|
210
210
|
| Failed, exited or stopped | Refused, naming the recorded outcome |
|
|
211
|
+
| Outcome unknown | Refused, and told to inspect first (below) |
|
|
211
212
|
|
|
212
213
|
A repeat never consumes launch capacity, including when the concurrency cap is
|
|
213
214
|
full of the very launch being asked about. Omitting the key leaves behaviour
|
|
214
215
|
exactly as it was: every request is its own launch.
|
|
215
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
|
+
|
|
216
266
|
## Process ownership
|
|
217
267
|
|
|
218
268
|
Muster identifies a launched process by its **pid paired with its start time**,
|
|
@@ -239,10 +289,22 @@ reader who does not know why they are there:
|
|
|
239
289
|
session that ended while Muster was not running is reported as ended rather
|
|
240
290
|
than as running.
|
|
241
291
|
|
|
242
|
-
Process-tree membership
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
292
|
+
Process-tree membership uses two signals, because neither is sufficient alone.
|
|
293
|
+
The parent chain is exact while it holds, and stops holding the moment an
|
|
294
|
+
intermediate process exits — its children reparent to launchd and are no longer
|
|
295
|
+
reachable from the root, though they are still doing the session's work. The
|
|
296
|
+
process group survives that, since reparenting changes no process's group.
|
|
297
|
+
|
|
298
|
+
Muster records the group the launched root leads and takes the union. A stop
|
|
299
|
+
reaches reparented workers, and a session whose root has died but whose group is
|
|
300
|
+
still busy is reported as running rather than ended.
|
|
301
|
+
|
|
302
|
+
Only a group the root *leads* is used. A process that merely belongs to someone
|
|
303
|
+
else's group shares it with whatever else is there — a shell, a tmux pane, the
|
|
304
|
+
terminal — and treating that as membership would reach far outside the session.
|
|
305
|
+
Both terminal hosts start their root in a new session, so in practice it does
|
|
306
|
+
lead its own group. A process that calls `setsid()` for itself still escapes;
|
|
307
|
+
nothing on macOS prevents that.
|
|
246
308
|
|
|
247
309
|
## Configuration and permissions
|
|
248
310
|
|
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,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import {
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
4
5
|
import { mcpName, claudeMcpConfig } from "./mcp.js";
|
|
5
6
|
import { pluginName } from "./plugins.js";
|
|
6
7
|
export const runSchema = z
|
|
@@ -23,6 +24,10 @@ export const runSchema = z
|
|
|
23
24
|
mcp: z.array(mcpName).optional(),
|
|
24
25
|
plugin: z.array(pluginName).optional(),
|
|
25
26
|
model: z.string().min(1).optional(),
|
|
27
|
+
project: z
|
|
28
|
+
.string()
|
|
29
|
+
.regex(/^[a-zA-Z0-9_-]+$/)
|
|
30
|
+
.optional(),
|
|
26
31
|
requestKey: z.string().min(1).max(200).optional(),
|
|
27
32
|
args: z.array(z.string()).default([]),
|
|
28
33
|
})
|
|
@@ -42,6 +47,40 @@ export function resolvePermissions(req, config) {
|
|
|
42
47
|
throw new Error("--permissions bypass requires --sandbox full-access");
|
|
43
48
|
return { permissions, sandbox };
|
|
44
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
|
+
}
|
|
45
84
|
/**
|
|
46
85
|
* What a request key is bound to, so the same key arriving with different
|
|
47
86
|
* parameters can be refused rather than silently answered with the wrong
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
* on every transaction, so a process that died while Muster was not running
|
|
17
17
|
* is reported as dead rather than trusted from what was recorded.
|
|
18
18
|
*
|
|
19
|
-
* Membership is
|
|
20
|
-
* a reparented child
|
|
19
|
+
* Membership is the union of the parent chain and the process group the root
|
|
20
|
+
* leads, because a reparented child leaves the chain but keeps its group.
|
|
21
21
|
*/
|
|
22
22
|
import { execFile } from "node:child_process";
|
|
23
23
|
import { promisify } from "node:util";
|
|
@@ -41,8 +41,41 @@ export async function processRef(pid) {
|
|
|
41
41
|
export async function isSame(ref) {
|
|
42
42
|
return (await processRef(ref.pid))?.start === ref.start;
|
|
43
43
|
}
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
/**
|
|
45
|
+
* The process group a pid leads, or undefined if it leads none.
|
|
46
|
+
*
|
|
47
|
+
* Only a leader's group is safe to treat as membership: the group then exists
|
|
48
|
+
* because this process was started, so everything in it descends from that
|
|
49
|
+
* start. A process that merely belongs to someone else's group shares it with
|
|
50
|
+
* whatever else is there — a shell, a tmux pane, the terminal — and sweeping
|
|
51
|
+
* that would reach far outside the session.
|
|
52
|
+
*
|
|
53
|
+
* Both terminal hosts start their root in a new session, so in practice the
|
|
54
|
+
* launched root does lead its own group.
|
|
55
|
+
*/
|
|
56
|
+
export async function ownedGroup(pid) {
|
|
57
|
+
try {
|
|
58
|
+
const pgid = Number((await command("ps", ["-p", String(pid), "-o", "pgid="])).trim());
|
|
59
|
+
return pgid === pid ? pgid : undefined;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Membership by two signals, because neither is sufficient alone.
|
|
67
|
+
*
|
|
68
|
+
* The parent chain is exact while it holds, and it stops holding the moment an
|
|
69
|
+
* intermediate process exits: the children reparent to launchd and are no
|
|
70
|
+
* longer reachable from the root, though they are still running the session's
|
|
71
|
+
* work. The process group survives that — reparenting changes no pid's group —
|
|
72
|
+
* but is only meaningful for a group the root leads, and is escapable by a
|
|
73
|
+
* process that calls setsid() for itself.
|
|
74
|
+
*
|
|
75
|
+
* So: union them. What the chain loses to reparenting, the group usually keeps.
|
|
76
|
+
*/
|
|
77
|
+
export async function descendants(root, deadline = Date.now() + 1000, group) {
|
|
78
|
+
const rows = (await command("ps", ["-axo", "pid=,ppid=,pgid="], Math.max(1, Math.min(1000, deadline - Date.now()))))
|
|
46
79
|
.split("\n")
|
|
47
80
|
.map((l) => l.trim().split(/\s+/).map(Number));
|
|
48
81
|
const found = new Set([root]);
|
|
@@ -54,10 +87,26 @@ export async function descendants(root, deadline = Date.now() + 1000) {
|
|
|
54
87
|
changed = true;
|
|
55
88
|
}
|
|
56
89
|
}
|
|
90
|
+
if (group)
|
|
91
|
+
for (const [pid, , pgid] of rows)
|
|
92
|
+
if (pid && pid > 1 && pgid === group)
|
|
93
|
+
found.add(pid);
|
|
57
94
|
return [...found];
|
|
58
95
|
}
|
|
59
|
-
|
|
60
|
-
|
|
96
|
+
/** Whether anything still runs in a group we started. */
|
|
97
|
+
export async function groupAlive(group, deadline = Date.now() + 1000) {
|
|
98
|
+
try {
|
|
99
|
+
const rows = (await command("ps", ["-axo", "pid=,pgid="], Math.max(1, Math.min(1000, deadline - Date.now()))))
|
|
100
|
+
.split("\n")
|
|
101
|
+
.map((l) => l.trim().split(/\s+/).map(Number));
|
|
102
|
+
return rows.some(([pid, pgid]) => pid && pid > 1 && pgid === group);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function liveTree(root, group) {
|
|
109
|
+
return (await Promise.all((await descendants(root, Date.now() + 1000, group)).map(processRef))).filter((r) => !!r);
|
|
61
110
|
}
|
|
62
111
|
function signal(ref, name) {
|
|
63
112
|
try {
|
|
@@ -74,11 +123,10 @@ function signal(ref, name) {
|
|
|
74
123
|
* every signal, because a pid that exits between discovery and the signal may
|
|
75
124
|
* already belong to something else.
|
|
76
125
|
*
|
|
77
|
-
* Reparented descendants are
|
|
78
|
-
*
|
|
79
|
-
* That needs a membership signal the chain does not provide (see issue #24).
|
|
126
|
+
* Reparented descendants are reachable only through the process group, so pass
|
|
127
|
+
* one when the caller has recorded it; the parent chain alone cannot see them.
|
|
80
128
|
*/
|
|
81
|
-
export async function stopTree(ref) {
|
|
129
|
+
export async function stopTree(ref, group) {
|
|
82
130
|
if (!(await isSame(ref)))
|
|
83
131
|
return;
|
|
84
132
|
const known = new Map();
|
|
@@ -94,12 +142,12 @@ export async function stopTree(ref) {
|
|
|
94
142
|
return added;
|
|
95
143
|
};
|
|
96
144
|
const deepestFirst = () => [...known.values()].reverse();
|
|
97
|
-
remember(await liveTree(ref.pid));
|
|
145
|
+
remember(await liveTree(ref.pid, group));
|
|
98
146
|
for (const r of deepestFirst())
|
|
99
147
|
if (await isSame(r))
|
|
100
148
|
signal(r, "SIGTERM");
|
|
101
149
|
await delay(150);
|
|
102
|
-
const late = (await isSame(ref)) ?
|
|
150
|
+
const late = remember((await isSame(ref)) || group ? await liveTree(ref.pid, group) : []);
|
|
103
151
|
if (late.length) {
|
|
104
152
|
for (const r of late.reverse())
|
|
105
153
|
if (await isSame(r))
|
package/dist/muster.js
CHANGED
|
@@ -37,6 +37,9 @@ 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.
|
|
40
43
|
--request-key KEY names the launch request itself. Sending the same key again
|
|
41
44
|
returns the launch it already produced rather than starting a second one, and
|
|
42
45
|
sending it with different parameters is refused rather than answered.
|
|
@@ -129,6 +132,11 @@ async function main() {
|
|
|
129
132
|
items: { type: "string", pattern: "^[a-zA-Z0-9_-]+$" },
|
|
130
133
|
description: "Configured MCP server names. Omit for session defaults; [] disables all. Tasks have no defaults.",
|
|
131
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
|
+
},
|
|
132
140
|
requestKey: {
|
|
133
141
|
type: "string",
|
|
134
142
|
minLength: 1,
|
|
@@ -227,6 +235,7 @@ async function main() {
|
|
|
227
235
|
model: { type: "string" },
|
|
228
236
|
"no-plugin": { type: "boolean" },
|
|
229
237
|
"request-key": { type: "string" },
|
|
238
|
+
project: { type: "string" },
|
|
230
239
|
},
|
|
231
240
|
});
|
|
232
241
|
const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, "request-key": requestKey, ...values } = parsed;
|
package/dist/registry.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
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
|
-
import { processRef, isSame, delay, } from "./identity/processes.js";
|
|
4
|
+
import { processRef, isSame, delay, groupAlive, } from "./identity/processes.js";
|
|
5
5
|
/**
|
|
6
6
|
* A request key arrived again with different parameters. Distinct from a launch
|
|
7
7
|
* failure: nothing was attempted, and the caller reused a key it should have
|
|
@@ -59,13 +59,32 @@ export class Registry {
|
|
|
59
59
|
if (entry.status === "starting" &&
|
|
60
60
|
!(await isSame(entry.owner)) &&
|
|
61
61
|
!entry.root) {
|
|
62
|
-
|
|
63
|
-
|
|
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
|
+
}
|
|
64
79
|
}
|
|
65
80
|
if (["starting", "running"].includes(entry.status) &&
|
|
66
81
|
entry.root &&
|
|
67
82
|
!(await isSame(entry.root))) {
|
|
68
|
-
|
|
83
|
+
// The recorded descendants are only those seen during launch, and a
|
|
84
|
+
// reparented one is not among them at all. Ask the group before
|
|
85
|
+
// concluding the session ended: it is the signal that survives both.
|
|
86
|
+
if (!(await Promise.all((entry.descendants ?? []).map(isSame))).some(Boolean) &&
|
|
87
|
+
!(entry.group && (await groupAlive(entry.group))))
|
|
69
88
|
entry.status = "exited";
|
|
70
89
|
}
|
|
71
90
|
}
|
package/dist/run.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
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, paramsFingerprint, 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";
|
|
11
|
-
import { processRef, isSame, stopTree, delay, descendants, } from "./identity/processes.js";
|
|
11
|
+
import { processRef, isSame, stopTree, delay, descendants, ownedGroup, } from "./identity/processes.js";
|
|
12
12
|
import { resolveCodex } from "./identity/codex.js";
|
|
13
13
|
import { resolveClaude } from "./identity/claude.js";
|
|
14
14
|
import { openCodeEndpointOwnedBy, resolveOpenCode, } from "./identity/opencode.js";
|
|
@@ -69,9 +69,25 @@ export class Muster {
|
|
|
69
69
|
return operation.finally(() => this.active.delete(operation));
|
|
70
70
|
}
|
|
71
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");
|
|
72
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
|
+
}
|
|
73
88
|
if (!(await stat(req.cwd)).isDirectory())
|
|
74
89
|
throw new Error("cwd is not a directory");
|
|
90
|
+
await assertAllowedWorkspace(req.cwd, this.config);
|
|
75
91
|
if (req.kind === "task" && req.host)
|
|
76
92
|
throw new Error("host applies only to session launches");
|
|
77
93
|
if (req.terminal && !req.open)
|
|
@@ -177,6 +193,9 @@ export class Muster {
|
|
|
177
193
|
}
|
|
178
194
|
if (this.closing)
|
|
179
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 });
|
|
180
199
|
if (req.kind === "task")
|
|
181
200
|
return await this.startTask(entry, argv, runtimeEnv);
|
|
182
201
|
const launched = await driver.launch({
|
|
@@ -191,7 +210,14 @@ export class Muster {
|
|
|
191
210
|
await driver.stop(launched.hostRef);
|
|
192
211
|
throw new Error("process exited during launch");
|
|
193
212
|
}
|
|
194
|
-
|
|
213
|
+
const group = await ownedGroup(launched.pid);
|
|
214
|
+
entry = {
|
|
215
|
+
...entry,
|
|
216
|
+
root,
|
|
217
|
+
...(group ? { group } : {}),
|
|
218
|
+
host: driver.id,
|
|
219
|
+
hostRef: launched.hostRef,
|
|
220
|
+
};
|
|
195
221
|
await this.registry.update(entry.launchId, entry);
|
|
196
222
|
let diagnostic = req.runtime === "claude"
|
|
197
223
|
? "no Claude session registry found (check terminal for workspace trust or login prompts)"
|
|
@@ -204,7 +230,7 @@ export class Muster {
|
|
|
204
230
|
if (deadline - Date.now() < 25)
|
|
205
231
|
break;
|
|
206
232
|
if (await isSame(root)) {
|
|
207
|
-
const tracked = (await Promise.all((await descendants(root.pid)).map(processRef))).filter((p) => !!p);
|
|
233
|
+
const tracked = (await Promise.all((await descendants(root.pid, Date.now() + 1000, entry.group)).map(processRef))).filter((p) => !!p);
|
|
208
234
|
const refs = [...(entry.descendants ?? [])];
|
|
209
235
|
for (const ref of tracked)
|
|
210
236
|
if (!refs.some((r) => r.pid === ref.pid && r.start === ref.start))
|
|
@@ -347,6 +373,9 @@ export class Muster {
|
|
|
347
373
|
const key = entry.requestKey;
|
|
348
374
|
if (entry.status === "starting")
|
|
349
375
|
throw new Error(`Request key ${key} is already launching (${entry.launchId}); ask for its status rather than launching again`);
|
|
376
|
+
if (entry.status === "unknown")
|
|
377
|
+
throw new Error(`Request key ${key} has an unknown outcome (${entry.launchId}): ${entry.error}. ` +
|
|
378
|
+
`Inspect before launching again — this is not a failure, and retrying it may leave two sessions`);
|
|
350
379
|
if (entry.status !== "running")
|
|
351
380
|
throw new Error(`Request key ${key} already resolved as ${entry.status}` +
|
|
352
381
|
(entry.error ? `: ${entry.error}` : "") +
|
|
@@ -388,9 +417,11 @@ export class Muster {
|
|
|
388
417
|
child.kill();
|
|
389
418
|
throw new Error("task owner exited during launch");
|
|
390
419
|
}
|
|
420
|
+
const group = await ownedGroup(child.pid);
|
|
391
421
|
try {
|
|
392
422
|
await this.registry.update(entry.launchId, {
|
|
393
423
|
root,
|
|
424
|
+
...(group ? { group } : {}),
|
|
394
425
|
outputPath,
|
|
395
426
|
exitPath,
|
|
396
427
|
status: "running",
|
|
@@ -406,7 +437,7 @@ export class Muster {
|
|
|
406
437
|
return handle;
|
|
407
438
|
}
|
|
408
439
|
catch (e) {
|
|
409
|
-
await stopTree(root);
|
|
440
|
+
await stopTree(root, group);
|
|
410
441
|
await rm(specPath, { force: true });
|
|
411
442
|
throw e;
|
|
412
443
|
}
|
|
@@ -436,7 +467,7 @@ export class Muster {
|
|
|
436
467
|
return;
|
|
437
468
|
if (!(await isSame(entry.root))) {
|
|
438
469
|
for (const ref of entry.descendants ?? [])
|
|
439
|
-
await stopTree(ref);
|
|
470
|
+
await stopTree(ref, entry.group);
|
|
440
471
|
return;
|
|
441
472
|
}
|
|
442
473
|
// Validate the persisted process identity before ever using a window reference.
|
|
@@ -446,9 +477,9 @@ export class Muster {
|
|
|
446
477
|
(await driver.list()).some((s) => s.hostRef === entry.hostRef && s.pid === entry.root.pid))
|
|
447
478
|
await driver.stop(entry.hostRef);
|
|
448
479
|
else
|
|
449
|
-
await stopTree(entry.root);
|
|
480
|
+
await stopTree(entry.root, entry.group);
|
|
450
481
|
for (const ref of entry.descendants ?? [])
|
|
451
|
-
await stopTree(ref);
|
|
482
|
+
await stopTree(ref, entry.group);
|
|
452
483
|
}
|
|
453
484
|
async find(id) {
|
|
454
485
|
const all = await this.registry.all();
|