@brutalsystems/muster 0.7.11 → 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 +16 -4
- package/dist/identity/processes.js +60 -12
- package/dist/registry.js +6 -2
- package/dist/run.js +16 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -289,10 +289,22 @@ reader who does not know why they are there:
|
|
|
289
289
|
session that ended while Muster was not running is reported as ended rather
|
|
290
290
|
than as running.
|
|
291
291
|
|
|
292
|
-
Process-tree membership
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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.
|
|
296
308
|
|
|
297
309
|
## Configuration and permissions
|
|
298
310
|
|
|
@@ -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/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
|
|
@@ -80,7 +80,11 @@ export class Registry {
|
|
|
80
80
|
if (["starting", "running"].includes(entry.status) &&
|
|
81
81
|
entry.root &&
|
|
82
82
|
!(await isSame(entry.root))) {
|
|
83
|
-
|
|
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))))
|
|
84
88
|
entry.status = "exited";
|
|
85
89
|
}
|
|
86
90
|
}
|
package/dist/run.js
CHANGED
|
@@ -8,7 +8,7 @@ import { runSchema, assertAllowedWorkspace, launchArgs, launchEnv, paramsFingerp
|
|
|
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";
|
|
@@ -210,7 +210,14 @@ export class Muster {
|
|
|
210
210
|
await driver.stop(launched.hostRef);
|
|
211
211
|
throw new Error("process exited during launch");
|
|
212
212
|
}
|
|
213
|
-
|
|
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
|
+
};
|
|
214
221
|
await this.registry.update(entry.launchId, entry);
|
|
215
222
|
let diagnostic = req.runtime === "claude"
|
|
216
223
|
? "no Claude session registry found (check terminal for workspace trust or login prompts)"
|
|
@@ -223,7 +230,7 @@ export class Muster {
|
|
|
223
230
|
if (deadline - Date.now() < 25)
|
|
224
231
|
break;
|
|
225
232
|
if (await isSame(root)) {
|
|
226
|
-
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);
|
|
227
234
|
const refs = [...(entry.descendants ?? [])];
|
|
228
235
|
for (const ref of tracked)
|
|
229
236
|
if (!refs.some((r) => r.pid === ref.pid && r.start === ref.start))
|
|
@@ -410,9 +417,11 @@ export class Muster {
|
|
|
410
417
|
child.kill();
|
|
411
418
|
throw new Error("task owner exited during launch");
|
|
412
419
|
}
|
|
420
|
+
const group = await ownedGroup(child.pid);
|
|
413
421
|
try {
|
|
414
422
|
await this.registry.update(entry.launchId, {
|
|
415
423
|
root,
|
|
424
|
+
...(group ? { group } : {}),
|
|
416
425
|
outputPath,
|
|
417
426
|
exitPath,
|
|
418
427
|
status: "running",
|
|
@@ -428,7 +437,7 @@ export class Muster {
|
|
|
428
437
|
return handle;
|
|
429
438
|
}
|
|
430
439
|
catch (e) {
|
|
431
|
-
await stopTree(root);
|
|
440
|
+
await stopTree(root, group);
|
|
432
441
|
await rm(specPath, { force: true });
|
|
433
442
|
throw e;
|
|
434
443
|
}
|
|
@@ -458,7 +467,7 @@ export class Muster {
|
|
|
458
467
|
return;
|
|
459
468
|
if (!(await isSame(entry.root))) {
|
|
460
469
|
for (const ref of entry.descendants ?? [])
|
|
461
|
-
await stopTree(ref);
|
|
470
|
+
await stopTree(ref, entry.group);
|
|
462
471
|
return;
|
|
463
472
|
}
|
|
464
473
|
// Validate the persisted process identity before ever using a window reference.
|
|
@@ -468,9 +477,9 @@ export class Muster {
|
|
|
468
477
|
(await driver.list()).some((s) => s.hostRef === entry.hostRef && s.pid === entry.root.pid))
|
|
469
478
|
await driver.stop(entry.hostRef);
|
|
470
479
|
else
|
|
471
|
-
await stopTree(entry.root);
|
|
480
|
+
await stopTree(entry.root, entry.group);
|
|
472
481
|
for (const ref of entry.descendants ?? [])
|
|
473
|
-
await stopTree(ref);
|
|
482
|
+
await stopTree(ref, entry.group);
|
|
474
483
|
}
|
|
475
484
|
async find(id) {
|
|
476
485
|
const all = await this.registry.all();
|