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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2793-system-label-crew-runs-as-crew-name-cell → main 5 commits fork
6 files
changed +73
−10
src/crew/claude.ts +7
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | * settings files of the operator's, a spend budget, and a deadline. |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | import { watchCrewRoot } from "./roots.ts"; | |
| 15 | 16 | import { childEnv, pump } from "../apps/process.ts"; |
| 16 | 17 | import { spellings, stripForgeDirs } from "../runs/paths.ts"; |
| 17 | 18 | import type { SandboxPolicy } from "./sandbox.ts"; |
| @@ -27,6 +28,8 @@ export interface ClaudeInput { | ||
| 27 | 28 | readonly resume?: string | undefined; |
| 28 | 29 | readonly env: Record<string, string>; |
| 29 | 30 | readonly sandbox: SandboxPolicy; |
| 31 | /** What `/system` calls this run's process tree (`crew:<name>`); unlabelled when absent. */ | |
| 32 | readonly label?: string | undefined; | |
| 30 | 33 | readonly timeoutMs: number; |
| 31 | 34 | readonly log: (line: string) => void; |
| 32 | 35 | readonly signal: AbortSignal; |
| @@ -187,6 +190,10 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> { | ||
| 187 | 190 | // to one pid never touches a grandchild holding stdout open. |
| 188 | 191 | detached: true, |
| 189 | 192 | }); |
| 193 | if (input.label !== undefined) { | |
| 194 | const unwatch = watchCrewRoot(input.label, proc.pid); | |
| 195 | void proc.exited.finally(unwatch); | |
| 196 | } | |
| 190 | 197 | // What the agent hears: the prompt first, then whatever a person says |
| 191 | 198 | // while it works. Each is a user turn; the session ends when the forge |
| 192 | 199 | // 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( | ||
| 1159 | 1159 | resume, |
| 1160 | 1160 | env, |
| 1161 | 1161 | sandbox: shellPolicy(), |
| 1162 | label: `crew:${agent.user.username}`, | |
| 1162 | 1163 | timeoutMs: Math.max(deadline - Date.now(), 0), |
| 1163 | 1164 | log: write, |
| 1164 | 1165 | signal, |
src/system/live.ts +2 −1
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | import type { App } from "../app.ts"; |
| 9 | 9 | import { cellRoots } from "../apps/contain.ts"; |
| 10 | import { crewRoots } from "../crew/roots.ts"; | |
| 10 | 11 | import { HOST, readRaw, type Paths, type Raw } from "./proc.ts"; |
| 11 | 12 | import { buildSystem, forAgent, type Roots, type SystemView } from "./view.ts"; |
| 12 | 13 | |
| @@ -30,7 +31,7 @@ export type Live = { | ||
| 30 | 31 | |
| 31 | 32 | export function createLive(options: { paths?: Paths; roots?: () => Roots; self?: number; pushMs?: number } = {}): Live { |
| 32 | 33 | const paths = options.paths ?? HOST; |
| 33 | const roots = options.roots ?? cellRoots; | |
| 34 | const roots = options.roots ?? (() => [...cellRoots(), ...crewRoots()]); | |
| 34 | 35 | const self = options.self ?? process.pid; |
| 35 | 36 | let prev: Raw | undefined; |
| 36 | 37 | let last: Raw | undefined; |
src/system/view.ts +16 −9
| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The machine as one plain object (`/system`, the `system` MCP tool and |
| 3 | 3 | * `/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. | |
| 7 | 7 | * Command lines are never kept — only `*.test.ts` arguments. |
| 8 | 8 | */ |
| 9 | 9 | import type { Proc, Raw } from "./proc.ts"; |
| @@ -11,7 +11,7 @@ import { TICKS_PER_SECOND } from "./proc.ts"; | ||
| 11 | 11 | |
| 12 | 12 | export type Group = { |
| 13 | 13 | label: string; |
| 14 | kind: "check" | "app" | "forge" | "host"; | |
| 14 | kind: "check" | "app" | "crew" | "forge" | "host"; | |
| 15 | 15 | procs: number; |
| 16 | 16 | cpuCores: number | null; |
| 17 | 17 | rssBytes: number; |
| @@ -53,7 +53,15 @@ const TOP = 8; | ||
| 53 | 53 | export type Roots = readonly { label: string; root: number; cap?: { bytes: number; by: "kernel" | "watch" } }[]; |
| 54 | 54 | |
| 55 | 55 | 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"; | |
| 57 | 65 | |
| 58 | 66 | /** Which group each process is in: the nearest cell root above it, the forge, or the host. */ |
| 59 | 67 | 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 | ||
| 184 | 192 | */ |
| 185 | 193 | export function forAgent(view: SystemView, visible: (label: string) => boolean): SystemView { |
| 186 | 194 | const hide = new Map<string, string>(); |
| 187 | const n = { check: 0, app: 0 }; | |
| 195 | const n = { check: 0, app: 0, crew: 0 }; | |
| 188 | 196 | const name = (label: string): string => { |
| 189 | 197 | 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"; | |
| 191 | 199 | return hide.get(label) ?? hide.set(label, `${kind}:private-${++n[kind]}`).get(label)!; |
| 192 | 200 | }; |
| 193 | 201 | return { |
| 194 | 202 | ...view, |
| 195 | 203 | 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. | |
| 198 | 205 | g.kind === "forge" || g.kind === "host" |
| 199 | 206 | ? { ...g, top: [], tests: [] } |
| 200 | 207 | : 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 | }); |
new above
offline