@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 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 is derived from the parent chain, which has a known
293
- limit: a child reparented after its own parent exits leaves the chain and cannot
294
- be recovered from it. Tracking that needs a membership signal the chain does not
295
- carry.
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 derived from the parent chain and inherits its one real limit:
20
- * a reparented child is unreachable from the root. See issue #24.
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
- export async function descendants(root, deadline = Date.now() + 1000) {
45
- const rows = (await command("ps", ["-axo", "pid=,ppid="], Math.max(1, Math.min(1000, deadline - Date.now()))))
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
- async function liveTree(root) {
60
- return (await Promise.all((await descendants(root)).map(processRef))).filter((r) => !!r);
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 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).
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)) ? remember(await liveTree(ref.pid)) : [];
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
- if (!(await Promise.all((entry.descendants ?? []).map(isSame))).some(Boolean))
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
- entry = { ...entry, root, host: driver.id, hostRef: launched.hostRef };
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brutalsystems/muster",
3
- "version": "0.7.11",
3
+ "version": "0.7.12",
4
4
  "description": "Launch instructed, reachable local agent sessions",
5
5
  "type": "module",
6
6
  "engines": {