@brutalsystems/muster 0.7.8 → 0.7.10
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 +64 -0
- package/dist/guard.js +33 -0
- package/dist/identity/processes.js +68 -13
- package/dist/muster.js +20 -3
- package/dist/registry.js +39 -2
- package/dist/run.js +58 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -180,6 +180,70 @@ 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
|
+
|
|
212
|
+
A repeat never consumes launch capacity, including when the concurrency cap is
|
|
213
|
+
full of the very launch being asked about. Omitting the key leaves behaviour
|
|
214
|
+
exactly as it was: every request is its own launch.
|
|
215
|
+
|
|
216
|
+
## Process ownership
|
|
217
|
+
|
|
218
|
+
Muster identifies a launched process by its **pid paired with its start time**,
|
|
219
|
+
and revalidates that pair whenever the registry is touched — including after
|
|
220
|
+
Muster itself restarts.
|
|
221
|
+
|
|
222
|
+
This is not a compromise chosen for convenience. On macOS it is the strongest
|
|
223
|
+
check the public platform offers:
|
|
224
|
+
|
|
225
|
+
- There is no cgroup. Process groups and sessions are the nearest approximation
|
|
226
|
+
and are escapable by design, so they bound a tree rather than owning it.
|
|
227
|
+
- The kernel's unique process identifier is not exposed in the public SDK.
|
|
228
|
+
- The audit-token route requires a message from the target process, which a
|
|
229
|
+
supervisor observing arbitrary children never has.
|
|
230
|
+
|
|
231
|
+
Two consequences worth stating, because both look like redundant work to a
|
|
232
|
+
reader who does not know why they are there:
|
|
233
|
+
|
|
234
|
+
- **Identity is revalidated at the moment of use, not at discovery.** Every
|
|
235
|
+
signal, every status answer and every registry pass re-checks the pair. A pid
|
|
236
|
+
discovered a moment ago may already belong to something else.
|
|
237
|
+
- **Revalidation after a Muster restart is deliberate.** Comparable supervisors
|
|
238
|
+
trust what they recorded before they died. Muster does not, which is why a
|
|
239
|
+
session that ended while Muster was not running is reported as ended rather
|
|
240
|
+
than as running.
|
|
241
|
+
|
|
242
|
+
Process-tree membership is derived from the parent chain, which has a known
|
|
243
|
+
limit: a child reparented after its own parent exits leaves the chain and cannot
|
|
244
|
+
be recovered from it. Tracking that needs a membership signal the chain does not
|
|
245
|
+
carry.
|
|
246
|
+
|
|
183
247
|
## Configuration and permissions
|
|
184
248
|
|
|
185
249
|
Muster reads `~/.muster/config.toml` once at startup and **never writes it**.
|
package/dist/guard.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { resolve } from "node:path";
|
|
3
4
|
import { mcpName, claudeMcpConfig } from "./mcp.js";
|
|
4
5
|
import { pluginName } from "./plugins.js";
|
|
@@ -22,6 +23,7 @@ export const runSchema = z
|
|
|
22
23
|
mcp: z.array(mcpName).optional(),
|
|
23
24
|
plugin: z.array(pluginName).optional(),
|
|
24
25
|
model: z.string().min(1).optional(),
|
|
26
|
+
requestKey: z.string().min(1).max(200).optional(),
|
|
25
27
|
args: z.array(z.string()).default([]),
|
|
26
28
|
})
|
|
27
29
|
.strict();
|
|
@@ -40,6 +42,37 @@ export function resolvePermissions(req, config) {
|
|
|
40
42
|
throw new Error("--permissions bypass requires --sandbox full-access");
|
|
41
43
|
return { permissions, sandbox };
|
|
42
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* What a request key is bound to, so the same key arriving with different
|
|
47
|
+
* parameters can be refused rather than silently answered with the wrong
|
|
48
|
+
* session.
|
|
49
|
+
*
|
|
50
|
+
* Computed here, after validation and after defaults are resolved, rather than
|
|
51
|
+
* accepted from the caller: a sender and a receiver canonicalize a path
|
|
52
|
+
* differently (symlinks, /private, a different home directory), and a
|
|
53
|
+
* fingerprint computed before validation would disagree with itself across
|
|
54
|
+
* machines.
|
|
55
|
+
*
|
|
56
|
+
* Covers what changes *what gets launched*. Excludes anything cosmetic or
|
|
57
|
+
* derived at launch — timestamps, the key itself, pids, ports, tmux targets —
|
|
58
|
+
* because changing those should not make this a different request.
|
|
59
|
+
*/
|
|
60
|
+
export function paramsFingerprint(req, permissions, mcp, plugins) {
|
|
61
|
+
return createHash("sha256")
|
|
62
|
+
.update(JSON.stringify([
|
|
63
|
+
req.runtime,
|
|
64
|
+
req.kind,
|
|
65
|
+
req.cwd,
|
|
66
|
+
createHash("sha256").update(req.prompt).digest("hex"),
|
|
67
|
+
permissions.permissions,
|
|
68
|
+
permissions.sandbox,
|
|
69
|
+
[...mcp].sort(),
|
|
70
|
+
[...plugins].sort(),
|
|
71
|
+
req.model ?? null,
|
|
72
|
+
req.args,
|
|
73
|
+
]))
|
|
74
|
+
.digest("hex");
|
|
75
|
+
}
|
|
43
76
|
export function runtimeArgs(req) {
|
|
44
77
|
const result = [];
|
|
45
78
|
const allowed = req.runtime === "claude"
|
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process identity on macOS.
|
|
3
|
+
*
|
|
4
|
+
* A launched process is identified by `(pid, start time)`. That pair is the
|
|
5
|
+
* ceiling of what this platform offers a supervisor, not a shortcut taken to
|
|
6
|
+
* avoid something better: there is no cgroup to own a tree, the kernel's unique
|
|
7
|
+
* process identifier is absent from the public SDK, and `audit_token` requires
|
|
8
|
+
* a message from the target that arbitrary children never send.
|
|
9
|
+
*
|
|
10
|
+
* Two rules follow, and both look like wasted work if the reason is not stated:
|
|
11
|
+
*
|
|
12
|
+
* 1. Revalidate at the moment of use, never at discovery. The gap between
|
|
13
|
+
* learning a pid and acting on it is where reuse happens, so `isSame()` is
|
|
14
|
+
* called immediately before each signal rather than once per operation.
|
|
15
|
+
* 2. Revalidation survives our own restart. The registry re-checks generations
|
|
16
|
+
* on every transaction, so a process that died while Muster was not running
|
|
17
|
+
* is reported as dead rather than trusted from what was recorded.
|
|
18
|
+
*
|
|
19
|
+
* Membership is derived from the parent chain and inherits its one real limit:
|
|
20
|
+
* a reparented child is unreachable from the root. See issue #24.
|
|
21
|
+
*/
|
|
1
22
|
import { execFile } from "node:child_process";
|
|
2
23
|
import { promisify } from "node:util";
|
|
3
24
|
const exec = promisify(execFile);
|
|
@@ -35,25 +56,59 @@ export async function descendants(root, deadline = Date.now() + 1000) {
|
|
|
35
56
|
}
|
|
36
57
|
return [...found];
|
|
37
58
|
}
|
|
59
|
+
async function liveTree(root) {
|
|
60
|
+
return (await Promise.all((await descendants(root)).map(processRef))).filter((r) => !!r);
|
|
61
|
+
}
|
|
62
|
+
function signal(ref, name) {
|
|
63
|
+
try {
|
|
64
|
+
process.kill(ref.pid, name);
|
|
65
|
+
}
|
|
66
|
+
catch { }
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A tree enumerated once, before the wait between SIGTERM and SIGKILL, cannot
|
|
70
|
+
* contain anything the tree forked during that wait — and a process asked to
|
|
71
|
+
* terminate is exactly the kind that spawns a last child. So the membership is
|
|
72
|
+
* re-read after the wait and unioned with what was already known, and only then
|
|
73
|
+
* is the kill pass run. Each entry is still revalidated immediately before
|
|
74
|
+
* every signal, because a pid that exits between discovery and the signal may
|
|
75
|
+
* already belong to something else.
|
|
76
|
+
*
|
|
77
|
+
* Reparented descendants are a separate gap: once an intermediate process dies,
|
|
78
|
+
* its children leave the parent chain entirely and no re-read will find them.
|
|
79
|
+
* That needs a membership signal the chain does not provide (see issue #24).
|
|
80
|
+
*/
|
|
38
81
|
export async function stopTree(ref) {
|
|
39
82
|
if (!(await isSame(ref)))
|
|
40
83
|
return;
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
84
|
+
const known = new Map();
|
|
85
|
+
const remember = (refs) => {
|
|
86
|
+
const added = [];
|
|
87
|
+
for (const r of refs) {
|
|
88
|
+
const key = `${r.pid}:${r.start}`;
|
|
89
|
+
if (!known.has(key)) {
|
|
90
|
+
known.set(key, r);
|
|
91
|
+
added.push(r);
|
|
46
92
|
}
|
|
47
|
-
catch { }
|
|
48
93
|
}
|
|
94
|
+
return added;
|
|
95
|
+
};
|
|
96
|
+
const deepestFirst = () => [...known.values()].reverse();
|
|
97
|
+
remember(await liveTree(ref.pid));
|
|
98
|
+
for (const r of deepestFirst())
|
|
99
|
+
if (await isSame(r))
|
|
100
|
+
signal(r, "SIGTERM");
|
|
49
101
|
await delay(150);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
102
|
+
const late = (await isSame(ref)) ? remember(await liveTree(ref.pid)) : [];
|
|
103
|
+
if (late.length) {
|
|
104
|
+
for (const r of late.reverse())
|
|
105
|
+
if (await isSame(r))
|
|
106
|
+
signal(r, "SIGTERM");
|
|
107
|
+
await delay(50);
|
|
108
|
+
}
|
|
109
|
+
for (const r of deepestFirst())
|
|
110
|
+
if (await isSame(r))
|
|
111
|
+
signal(r, "SIGKILL");
|
|
57
112
|
}
|
|
58
113
|
// Called only by a terminal/task supervisor that is the leader of its own group.
|
|
59
114
|
// Keeping the supervisor alive until this point prevents process-group ID reuse.
|
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,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
|
+
--request-key KEY names the launch request itself. Sending the same key again
|
|
41
|
+
returns the launch it already produced rather than starting a second one, and
|
|
42
|
+
sending it with different parameters is refused rather than answered.
|
|
40
43
|
run, list and stop default to JSON. output prints raw task text.
|
|
41
44
|
|
|
42
45
|
doctor checks path-configured plugins against the packages they came from: a
|
|
@@ -126,6 +129,12 @@ async function main() {
|
|
|
126
129
|
items: { type: "string", pattern: "^[a-zA-Z0-9_-]+$" },
|
|
127
130
|
description: "Configured MCP server names. Omit for session defaults; [] disables all. Tasks have no defaults.",
|
|
128
131
|
},
|
|
132
|
+
requestKey: {
|
|
133
|
+
type: "string",
|
|
134
|
+
minLength: 1,
|
|
135
|
+
maxLength: 200,
|
|
136
|
+
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.",
|
|
137
|
+
},
|
|
129
138
|
args: { type: "array", items: { type: "string" } },
|
|
130
139
|
},
|
|
131
140
|
required: ["runtime", "prompt"],
|
|
@@ -217,9 +226,12 @@ async function main() {
|
|
|
217
226
|
plugin: { type: "string", multiple: true },
|
|
218
227
|
model: { type: "string" },
|
|
219
228
|
"no-plugin": { type: "boolean" },
|
|
229
|
+
"request-key": { type: "string" },
|
|
220
230
|
},
|
|
221
231
|
});
|
|
222
|
-
const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, ...values } = parsed;
|
|
232
|
+
const { format = "json", "no-mcp": noMcp, "no-plugin": noPlugin, "request-key": requestKey, ...values } = parsed;
|
|
233
|
+
if (requestKey !== undefined && command !== "run")
|
|
234
|
+
throw new Error("--request-key applies only to run");
|
|
223
235
|
if (noMcp !== undefined && command !== "run")
|
|
224
236
|
throw new Error("--no-mcp applies only to run");
|
|
225
237
|
if (noMcp && values.mcp)
|
|
@@ -242,7 +254,12 @@ async function main() {
|
|
|
242
254
|
if (command === "run") {
|
|
243
255
|
const runtime = positionals[0];
|
|
244
256
|
const args = positionals.slice(1);
|
|
245
|
-
const record = await muster.run({
|
|
257
|
+
const record = await muster.run({
|
|
258
|
+
runtime,
|
|
259
|
+
...values,
|
|
260
|
+
...(requestKey === undefined ? {} : { requestKey }),
|
|
261
|
+
args,
|
|
262
|
+
});
|
|
246
263
|
print(record);
|
|
247
264
|
if (record.kind === "session" && record.host === "pty") {
|
|
248
265
|
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) {
|
|
@@ -65,11 +84,28 @@ export class Registry {
|
|
|
65
84
|
async all() {
|
|
66
85
|
return this.transaction(async (entries) => structuredClone(entries));
|
|
67
86
|
}
|
|
68
|
-
|
|
87
|
+
/**
|
|
88
|
+
* The lookup runs inside the same transaction as the write, so the mkdir lock
|
|
89
|
+
* that already serializes reservations serializes deduplication too.
|
|
90
|
+
*
|
|
91
|
+
* Order is load-bearing: a match returns before the cap is consulted, because
|
|
92
|
+
* the entry it returns is already counted against the cap. Checking capacity
|
|
93
|
+
* first would reject a retry of a launch that succeeded, which reads as a
|
|
94
|
+
* failure and invites a third attempt.
|
|
95
|
+
*/
|
|
96
|
+
async reserve(req, cap, idem) {
|
|
69
97
|
const owner = await processRef(process.pid);
|
|
70
98
|
if (!owner)
|
|
71
99
|
throw new Error("Cannot identify Muster owner process");
|
|
72
100
|
return this.transaction(async (entries) => {
|
|
101
|
+
if (idem) {
|
|
102
|
+
const prior = entries.find((e) => e.requestKey === idem.requestKey);
|
|
103
|
+
if (prior) {
|
|
104
|
+
if (prior.paramsFingerprint !== idem.paramsFingerprint)
|
|
105
|
+
throw new IdempotencyKeyReuse(idem.requestKey, prior.paramsFingerprint ?? "none", idem.paramsFingerprint);
|
|
106
|
+
return { entry: structuredClone(prior), deduped: true };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
73
109
|
if (entries.filter((e) => e.status === "starting" || e.status === "running")
|
|
74
110
|
.length >= cap)
|
|
75
111
|
throw new Error(`Concurrency cap reached (${cap})`);
|
|
@@ -78,12 +114,13 @@ export class Registry {
|
|
|
78
114
|
...req,
|
|
79
115
|
id,
|
|
80
116
|
launchId: id,
|
|
117
|
+
...(idem ?? {}),
|
|
81
118
|
status: "starting",
|
|
82
119
|
createdAt: new Date().toISOString(),
|
|
83
120
|
owner,
|
|
84
121
|
};
|
|
85
122
|
entries.push(entry);
|
|
86
|
-
return { ...entry };
|
|
123
|
+
return { entry: { ...entry }, deduped: false };
|
|
87
124
|
});
|
|
88
125
|
}
|
|
89
126
|
async update(id, patch) {
|
package/dist/run.js
CHANGED
|
@@ -4,7 +4,7 @@ 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, 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;
|
|
@@ -86,7 +100,22 @@ export class Muster {
|
|
|
86
100
|
await assertClaudePolicy(this.env);
|
|
87
101
|
if (req.runtime === "opencode")
|
|
88
102
|
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
|
|
103
|
+
const { entry: reservation, deduped } = await this.registry.reserve({ runtime: req.runtime, kind: req.kind, cwd: req.cwd, ...permissions }, this.config.max_concurrent, req.requestKey
|
|
104
|
+
? {
|
|
105
|
+
requestKey: req.requestKey,
|
|
106
|
+
paramsFingerprint: paramsFingerprint(req, permissions, selectedMcp.map((s) => s.name), selectedPlugins.map((p) => p.name)),
|
|
107
|
+
}
|
|
108
|
+
: undefined);
|
|
109
|
+
if (deduped) {
|
|
110
|
+
await this.log.write({
|
|
111
|
+
event: "deduped",
|
|
112
|
+
launch_id: reservation.launchId,
|
|
113
|
+
request_key: req.requestKey,
|
|
114
|
+
status: reservation.status,
|
|
115
|
+
requester,
|
|
116
|
+
});
|
|
117
|
+
return this.recordedOutcome(reservation);
|
|
118
|
+
}
|
|
90
119
|
this.owned.add(reservation.launchId);
|
|
91
120
|
let entry = reservation;
|
|
92
121
|
const startedAt = Date.now();
|
|
@@ -305,6 +334,32 @@ export class Muster {
|
|
|
305
334
|
await rpc?.close();
|
|
306
335
|
}
|
|
307
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* The answer to a request we have already seen. Never a new launch: a repeat
|
|
339
|
+
* of a request that succeeded must return what it produced, and a repeat of
|
|
340
|
+
* one that did not must report that rather than quietly trying again.
|
|
341
|
+
*
|
|
342
|
+
* A launch still in flight is refused rather than answered, because there is
|
|
343
|
+
* nothing yet to return and inventing a pending result would let a caller
|
|
344
|
+
* treat it as a session.
|
|
345
|
+
*/
|
|
346
|
+
recordedOutcome(entry) {
|
|
347
|
+
const key = entry.requestKey;
|
|
348
|
+
if (entry.status === "starting")
|
|
349
|
+
throw new Error(`Request key ${key} is already launching (${entry.launchId}); ask for its status rather than launching again`);
|
|
350
|
+
if (entry.status !== "running")
|
|
351
|
+
throw new Error(`Request key ${key} already resolved as ${entry.status}` +
|
|
352
|
+
(entry.error ? `: ${entry.error}` : "") +
|
|
353
|
+
`; use a different key to launch again`);
|
|
354
|
+
if (entry.kind === "task") {
|
|
355
|
+
if (!entry.root)
|
|
356
|
+
throw new Error(`Request key ${key} has no recorded task process`);
|
|
357
|
+
return taskHandle(entry, entry.root.pid);
|
|
358
|
+
}
|
|
359
|
+
if (!entry.peer)
|
|
360
|
+
throw new Error(`Request key ${key} has no recorded session`);
|
|
361
|
+
return entry.peer;
|
|
362
|
+
}
|
|
308
363
|
async startTask(entry, argv, env) {
|
|
309
364
|
const dir = join(this.home, "tasks", entry.id);
|
|
310
365
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
@@ -342,18 +397,7 @@ export class Muster {
|
|
|
342
397
|
});
|
|
343
398
|
await writeFile(startPath, "start", { mode: 0o600 });
|
|
344
399
|
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
|
-
};
|
|
400
|
+
const handle = taskHandle(entry, root.pid);
|
|
357
401
|
await this.log.write({
|
|
358
402
|
event: "started",
|
|
359
403
|
launch_id: entry.launchId,
|