Public: anyone can read this; the crew can change it.
A crew run's process tree is watched as crew:<name> while it runs #2837
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2824-a-crew-run-s-process-tree-is-watched-as → main 1 commit fork
5 files
changed +46
−31
src/crew/claude.ts +2 −2
| @@ -12,7 +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 | import { watchLabelled } from "../apps/contain.ts"; | |
| 16 | 16 | import { childEnv, pump } from "../apps/process.ts"; |
| 17 | 17 | import { spellings, stripForgeDirs } from "../runs/paths.ts"; |
| 18 | 18 | import type { SandboxPolicy } from "./sandbox.ts"; |
| @@ -191,7 +191,7 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> { | ||
| 191 | 191 | detached: true, |
| 192 | 192 | }); |
| 193 | 193 | if (input.label !== undefined) { |
| 194 | const unwatch = watchCrewRoot(input.label, proc.pid); | |
| 194 | const unwatch = watchLabelled(proc.pid, input.label); | |
| 195 | 195 | void proc.exited.finally(unwatch); |
| 196 | 196 | } |
| 197 | 197 | // What the agent hears: the prompt first, then whatever a person says |
src/crew/roots.ts deleted −19
| @@ -1,19 +0,0 @@ | ||
| 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/system/live.ts +1 −2
| @@ -7,7 +7,6 @@ | ||
| 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"; | |
| 11 | 10 | import { HOST, readRaw, type Paths, type Raw } from "./proc.ts"; |
| 12 | 11 | import { buildSystem, forAgent, type Roots, type SystemView } from "./view.ts"; |
| 13 | 12 | |
| @@ -31,7 +30,7 @@ export type Live = { | ||
| 31 | 30 | |
| 32 | 31 | export function createLive(options: { paths?: Paths; roots?: () => Roots; self?: number; pushMs?: number } = {}): Live { |
| 33 | 32 | const paths = options.paths ?? HOST; |
| 34 | const roots = options.roots ?? (() => [...cellRoots(), ...crewRoots()]); | |
| 33 | const roots = options.roots ?? (() => cellRoots()); | |
| 35 | 34 | const self = options.self ?? process.pid; |
| 36 | 35 | let prev: Raw | undefined; |
| 37 | 36 | let last: Raw | undefined; |
test/crew-watch.test.ts added +43
| @@ -0,0 +1,43 @@ | ||
| 1 | /** A crew run's process tree shows as crew:<name> while it runs, and only then. */ | |
| 2 | import { expect, test } from "bun:test"; | |
| 3 | import { chmodSync, writeFileSync } from "node:fs"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { WATCH, cellCounters } from "../src/apps/contain.ts"; | |
| 8 | import { runClaude } from "../src/crew/claude.ts"; | |
| 9 | import { sandboxPolicy } from "../src/crew/sandbox.ts"; | |
| 10 | ||
| 11 | test("a run is listed as crew:<name> in cellCounters while it runs, not after", async () => { | |
| 12 | const dir = await mkdtemp(join(tmpdir(), "forge-watch-")); | |
| 13 | const fake = join(dir, "fake-claude"); | |
| 14 | writeFileSync( | |
| 15 | fake, | |
| 16 | `#!/bin/sh | |
| 17 | IFS= read -r first | |
| 18 | echo '{"type":"system","subtype":"init","session_id":"s1","model":"fake"}' | |
| 19 | sleep 1.5 | |
| 20 | echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total_cost_usd":0.01,"num_turns":1}' | |
| 21 | `, | |
| 22 | ); | |
| 23 | chmodSync(fake, 0o755); | |
| 24 | const listed = () => cellCounters().some((c) => c.label === "crew:bot"); | |
| 25 | const run = runClaude({ | |
| 26 | binary: fake, | |
| 27 | cwd: dir, | |
| 28 | prompt: "go", | |
| 29 | appendSystemPrompt: "", | |
| 30 | budgetUsd: 1, | |
| 31 | env: {}, | |
| 32 | sandbox: sandboxPolicy({ dataDir: dir, agentName: "bot", urls: [] }), | |
| 33 | timeoutMs: 10_000, | |
| 34 | log: () => {}, | |
| 35 | signal: new AbortController().signal, | |
| 36 | label: "crew:bot", | |
| 37 | }); | |
| 38 | await Bun.sleep(WATCH.tickMs + 300); | |
| 39 | expect(listed()).toBe(true); | |
| 40 | await run; | |
| 41 | expect(listed()).toBe(false); | |
| 42 | await rm(dir, { recursive: true, force: true }); | |
| 43 | }, 20_000); |
test/system-crew.test.ts −8
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { crewRoots, watchCrewRoot } from "../src/crew/roots.ts"; | |
| 3 | 2 | import type { Proc, Raw } from "../src/system/proc.ts"; |
| 4 | 3 | import { buildSystem, forAgent } from "../src/system/view.ts"; |
| 5 | 4 | |
| @@ -19,10 +18,3 @@ test("a crew run's tree is a crew:<name> group, hidden by name from agents", () | ||
| 19 | 18 | expect(seen.label).toBe("crew:private-1"); |
| 20 | 19 | expect(seen.top).toEqual([]); |
| 21 | 20 | }); |
| 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