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

Merged opened this

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

Merged

scout/forge:scout/2824-a-crew-run-s-process-tree-is-watched-as → main 1 commit fork

  • Merged. scout/2824-a-crew-run-s-process-tree-is-watched-as was merged into main as b846011.

Review

Sign in to review this change.
5 files changed +46 −31
src/crew/claude.ts +2 −2
@@ -12,7 +12,7 @@
1212 * settings files of the operator's, a spend budget, and a deadline.
1313 */
1414
15 import { watchCrewRoot } from "./roots.ts";
15 import { watchLabelled } from "../apps/contain.ts";
1616 import { childEnv, pump } from "../apps/process.ts";
1717 import { spellings, stripForgeDirs } from "../runs/paths.ts";
1818 import type { SandboxPolicy } from "./sandbox.ts";
@@ -191,7 +191,7 @@ export async function runClaude(input: ClaudeInput): Promise<ClaudeOutcome> {
191191 detached: true,
192192 });
193193 if (input.label !== undefined) {
194 const unwatch = watchCrewRoot(input.label, proc.pid);
194 const unwatch = watchLabelled(proc.pid, input.label);
195195 void proc.exited.finally(unwatch);
196196 }
197197 // 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 @@
77 */
88 import type { App } from "../app.ts";
99 import { cellRoots } from "../apps/contain.ts";
10 import { crewRoots } from "../crew/roots.ts";
1110 import { HOST, readRaw, type Paths, type Raw } from "./proc.ts";
1211 import { buildSystem, forAgent, type Roots, type SystemView } from "./view.ts";
1312
@@ -31,7 +30,7 @@ export type Live = {
3130
3231 export function createLive(options: { paths?: Paths; roots?: () => Roots; self?: number; pushMs?: number } = {}): Live {
3332 const paths = options.paths ?? HOST;
34 const roots = options.roots ?? (() => [...cellRoots(), ...crewRoots()]);
33 const roots = options.roots ?? (() => cellRoots());
3534 const self = options.self ?? process.pid;
3635 let prev: Raw | undefined;
3736 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 @@
11 import { expect, test } from "bun:test";
2 import { crewRoots, watchCrewRoot } from "../src/crew/roots.ts";
32 import type { Proc, Raw } from "../src/system/proc.ts";
43 import { buildSystem, forAgent } from "../src/system/view.ts";
54
@@ -19,10 +18,3 @@ test("a crew run's tree is a crew:<name> group, hidden by name from agents", ()
1918 expect(seen.label).toBe("crew:private-1");
2019 expect(seen.top).toEqual([]);
2120 });
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