Public: anyone can read this; the crew can change it.

/system: label crew runs as crew:<name> cells so the process table can group them #2796

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2793-system-label-crew-runs-as-crew-name-cell → main 5 commits fork

  • Merged. scout/2793-system-label-crew-runs-as-crew-name-cell was merged into main as 8e07c8d.

Review

Sign in to review this change.
6 files changed +73 −10
src/crew/claude.ts +7
@@ -12,6 +12,7 @@
1212 * settings files of the operator's, a spend budget, and a deadline.
1313 */
1414
15 import { watchCrewRoot } from "./roots.ts";
1516 import { childEnv, pump } from "../apps/process.ts";
1617 import { spellings, stripForgeDirs } from "../runs/paths.ts";
1718 import type { SandboxPolicy } from "./sandbox.ts";
@@ -27,6 +28,8 @@ export interface ClaudeInput {
2728 readonly resume?: string | undefined;
2829 readonly env: Record<string, string>;
2930 readonly sandbox: SandboxPolicy;
31 /** What `/system` calls this run's process tree (`crew:<name>`); unlabelled when absent. */
32 readonly label?: string | undefined;
3033 readonly timeoutMs: number;
3134 readonly log: (line: string) => void;
3235 readonly signal: AbortSignal;
@@ -187,6 +190,10 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> {
187190 // to one pid never touches a grandchild holding stdout open.
188191 detached: true,
189192 });
193 if (input.label !== undefined) {
194 const unwatch = watchCrewRoot(input.label, proc.pid);
195 void proc.exited.finally(unwatch);
196 }
190197 // What the agent hears: the prompt first, then whatever a person says
191198 // while it works. Each is a user turn; the session ends when the forge
192199 // closes its stdin after a turn nobody followed.
src/crew/roots.ts added +19
@@ -0,0 +1,19 @@
1 /**
2 * The crew runs in flight, as labelled roots for `/system`: Claude Code is
3 * the root of its tree (it makes its own bubblewrap), so the process table
4 * groups what it spawned under `crew:<name>`. There is no cap: a root is
5 * dropped when its process exits, so the map is as large as the runs in flight,
6 * which the runner already bounds.
7 */
8 const live = new Map<symbol, { label: string; root: number }>();
9
10 /** Name `pid` as the root of `label` until the returned function is called. */
11 export function watchCrewRoot(label: string, pid: number): () => void {
12 const key = Symbol(label);
13 live.set(key, { label, root: pid });
14 return () => void live.delete(key);
15 }
16
17 export function crewRoots(): { label: string; root: number }[] {
18 return [...live.values()];
19 }
src/crew/runner.ts +1
@@ -1159,6 +1159,7 @@ async function work(
11591159 resume,
11601160 env,
11611161 sandbox: shellPolicy(),
1162 label: `crew:${agent.user.username}`,
11621163 timeoutMs: Math.max(deadline - Date.now(), 0),
11631164 log: write,
11641165 signal,
src/system/live.ts +2 −1
@@ -7,6 +7,7 @@
77 */
88 import type { App } from "../app.ts";
99 import { cellRoots } from "../apps/contain.ts";
10 import { crewRoots } from "../crew/roots.ts";
1011 import { HOST, readRaw, type Paths, type Raw } from "./proc.ts";
1112 import { buildSystem, forAgent, type Roots, type SystemView } from "./view.ts";
1213
@@ -30,7 +31,7 @@ export type Live = {
3031
3132 export function createLive(options: { paths?: Paths; roots?: () => Roots; self?: number; pushMs?: number } = {}): Live {
3233 const paths = options.paths ?? HOST;
33 const roots = options.roots ?? cellRoots;
34 const roots = options.roots ?? (() => [...cellRoots(), ...crewRoots()]);
3435 const self = options.self ?? process.pid;
3536 let prev: Raw | undefined;
3637 let last: Raw | undefined;
src/system/view.ts +16 −9
@@ -1,9 +1,9 @@
11 /**
22 * The machine as one plain object (`/system`, the `system` MCP tool and
33 * `/api/v1/telemetry/system`): two raw readings in, rates and groups out.
4 * Every process belongs to the cell that launched it (`check:<run>` or
5 * `app:<slug>/<env>`), else to the forge itself, else to the host. The same
6 * test file under two checks at once is duplicate work, and is named.
4 * Every process belongs to the cell that launched it (`check:<run>`,
5 * `app:<slug>/<env>` or `crew:<name>`), else to the forge itself, else to the
6 * host. The same test file under two checks at once is duplicate work, and is named.
77 * Command lines are never kept — only `*.test.ts` arguments.
88 */
99 import type { Proc, Raw } from "./proc.ts";
@@ -11,7 +11,7 @@ import { TICKS_PER_SECOND } from "./proc.ts";
1111
1212 export type Group = {
1313 label: string;
14 kind: "check" | "app" | "forge" | "host";
14 kind: "check" | "app" | "crew" | "forge" | "host";
1515 procs: number;
1616 cpuCores: number | null;
1717 rssBytes: number;
@@ -53,7 +53,15 @@ const TOP = 8;
5353 export type Roots = readonly { label: string; root: number; cap?: { bytes: number; by: "kernel" | "watch" } }[];
5454
5555 const kindOf = (label: string): Group["kind"] =>
56 label.startsWith("check:") ? "check" : label.startsWith("app:") ? "app" : label === "forge" ? "forge" : "host";
56 label.startsWith("check:")
57 ? "check"
58 : label.startsWith("app:")
59 ? "app"
60 : label.startsWith("crew:")
61 ? "crew"
62 : label === "forge"
63 ? "forge"
64 : "host";
5765
5866 /** Which group each process is in: the nearest cell root above it, the forge, or the host. */
5967 function groupOf(procs: readonly Proc[], roots: Roots, self: number): Map<number, string> {
@@ -184,17 +192,16 @@ export function buildSystem(prev: Raw | undefined, next: Raw, roots: Roots, self
184192 */
185193 export function forAgent(view: SystemView, visible: (label: string) => boolean): SystemView {
186194 const hide = new Map<string, string>();
187 const n = { check: 0, app: 0 };
195 const n = { check: 0, app: 0, crew: 0 };
188196 const name = (label: string): string => {
189197 if (visible(label) || kindOf(label) === "forge" || kindOf(label) === "host") return label;
190 const kind = kindOf(label) as "check" | "app";
198 const kind = kindOf(label) as "check" | "app" | "crew";
191199 return hide.get(label) ?? hide.set(label, `${kind}:private-${++n[kind]}`).get(label)!;
192200 };
193201 return {
194202 ...view,
195203 groups: view.groups.map((g) =>
196 // The forge and the host hold whatever no labelled cell claims (a crew run in a private clone,
197 // an unwatched sandbox): their counts stay, their processes and test files never leave.
204 // The forge and the host hold whatever no labelled cell claims (an unwatched sandbox): their counts stay, their processes and test files never leave.
198205 g.kind === "forge" || g.kind === "host"
199206 ? { ...g, top: [], tests: [] }
200207 : visible(g.label)
test/system-crew.test.ts added +28
@@ -0,0 +1,28 @@
1 import { expect, test } from "bun:test";
2 import { crewRoots, watchCrewRoot } from "../src/crew/roots.ts";
3 import type { Proc, Raw } from "../src/system/proc.ts";
4 import { buildSystem, forAgent } from "../src/system/view.ts";
5
6 const proc = (pid: number, ppid: number): Proc => ({ pid, ppid, comm: "bun", ticks: 0, rss: 1 << 20, age: 5, tests: [] });
7 const raw: Raw = {
8 at: 1_000, cores: [], temps: [], load: null, memory: null, disks: [], net: [], io: [],
9 procs: [proc(1, 0), proc(300, 1), proc(301, 300), proc(302, 301)],
10 };
11
12 test("a crew run's tree is a crew:<name> group, hidden by name from agents", () => {
13 const view = buildSystem(undefined, raw, [{ label: "crew:scout", root: 300 }], 1);
14 const g = view.groups.find((x) => x.label === "crew:scout")!;
15 expect(g.kind).toBe("crew");
16 expect(g.procs).toBe(3);
17 expect(view.groups.find((x) => x.label === "forge")!.procs).toBe(1);
18 const seen = forAgent(view, () => false).groups.find((x) => x.kind === "crew")!;
19 expect(seen.label).toBe("crew:private-1");
20 expect(seen.top).toEqual([]);
21 });
22
23 test("a watched crew root lasts until it is released", () => {
24 const stop = watchCrewRoot("crew:scout", 42);
25 expect(crewRoots()).toContainEqual({ label: "crew:scout", root: 42 });
26 stop();
27 expect(crewRoots()).toEqual([]);
28 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs