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

An agent run's log ends with one timing line on every path: clone, brief, harness, wrap-up #5663

Merged opened this

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

Merged

scout/forge:scout/3779-an-agent-run-s-log-ends-with-one-timing → main 2 commits fork

  • Merged. scout/3779-an-agent-run-s-log-ends-with-one-timing was merged into main as 69db4eb.

Review

Sign in to review this change.
4 files changed +158 −1
docs/CREW.md +4
@@ -34,6 +34,10 @@ Nesting a sandbox is not possible there, so `test/crew-sandbox.test.ts` checks
3434 the settings that enforce it (`allowLocalBinding` on, `allowAllUnixSockets` off,
3535 the `denyRead` list) and not the boundary itself.
3636
37 ## Where a run's time went
38
39 Every run that started ends its log with one `timing: run ended …s after the run started — clone … · brief … · harness …` line (plus `wrap-up` when the reserve was used), whatever way it ended; `src/apps/timing.ts` draws it.
40
3741 ## The sentinel (#1131)
3842
3943 The forge hunts its own faults. `src/crew/sentinel.ts` runs on the autonomy
src/crew/runner.ts +48
@@ -29,6 +29,7 @@ import {
2929 } from "./early.ts";
3030 import { forkRepo, sealedForkFor } from "../actions/repos.ts";
3131 import { announceRun } from "../actions/runs.ts";
32 import { Stopwatch } from "../apps/timing.ts";
3233 import { previewEnv } from "../apps/data.ts";
3334 import { appsRuntime } from "../apps/runtime.ts";
3435 import { ownApp, ownAppUrls } from "./app_hosts.ts";
@@ -172,6 +173,8 @@ export interface CrewOptions {
172173 readonly sandboxNote?: string | undefined;
173174 /** Milliseconds between the crew's ticks; tests pass a small one. @default 5000 */
174175 readonly tickMs?: number | undefined;
176 /** The clock a run's timing line reads; tests pass a fake. @default Date.now */
177 readonly now?: (() => number) | undefined;
175178 }
176179
177180 export { crewRoot, workdirFor } from "./workdir.ts";
@@ -286,6 +289,13 @@ export function settleReview(
286289 : { state: "ok", summary: `reviewed: ${verdict.state.replace("_", " ")}` };
287290 }
288291
292 /** How far a run got, for the timing line an early return would cut short. */
293 interface Ran {
294 claimed: boolean;
295 inHarness: boolean;
296 inWrapUp: boolean;
297 }
298
289299 /**
290300 * Claim and execute a queued agent run. Never throws. A run the provider
291301 * turned away ends cancelled with a `paused:` summary and returns when its
@@ -295,6 +305,31 @@ export async function executeAgentRun(
295305 app: App,
296306 run: Run,
297307 options: CrewOptions,
308 ): Promise<Pause | undefined> {
309 const watch = new Stopwatch(options.now ?? Date.now);
310 const began = { claimed: false, inHarness: false, inWrapUp: false };
311 try {
312 return await runClaimed(app, run, options, watch, began);
313 } finally {
314 // The log's last line, on every path that started: where the time went.
315 // A step an early return cut short still owns the time it held.
316 if (began.inHarness) watch.lap("harness");
317 if (began.inWrapUp) watch.lap("wrap-up");
318 if (began.claimed)
319 appendRunLog(
320 app.config.dataDir,
321 run.id,
322 watch.line("run ended", "the run started"),
323 );
324 }
325 }
326
327 async function runClaimed(
328 app: App,
329 run: Run,
330 options: CrewOptions,
331 watch: Stopwatch,
332 began: Ran,
298333 ): Promise<Pause | undefined> {
299334 // A follow-up queued behind a run that has since answered everything:
300335 // nothing new to act on, so nothing starts (`guard.ts`).
@@ -311,6 +346,7 @@ export async function executeAgentRun(
311346 }
312347 const claimed = claimRun(app.db, run.id);
313348 if (!claimed) return undefined;
349 began.claimed = true;
314350 const lease = holdLease(app.db, run.id);
315351 // A resolve run keeps the head it started from, to tell a push from none.
316352 const startedAt =
@@ -349,6 +385,8 @@ export async function executeAgentRun(
349385 write,
350386 controller.signal,
351387 meter,
388 watch,
389 began,
352390 );
353391 } catch (error) {
354392 log.error("agent run threw", { run: run.id, error: String(error) });
@@ -744,6 +782,8 @@ async function work(
744782 write: (line: string) => void,
745783 signal: AbortSignal,
746784 meter: Meter,
785 watch: Stopwatch,
786 ran: Ran,
747787 ): Promise<Outcome> {
748788 const began = new Date().toISOString();
749789 const repo = getRepoById(app.db, run.repoId);
@@ -901,6 +941,7 @@ async function work(
901941 signal,
902942 run.attempt > 0,
903943 );
944 watch.lap("clone");
904945 if (!prepared.ok) return { state: "failed", summary: prepared.error };
905946
906947 // What the agent is told.
@@ -1256,6 +1297,8 @@ async function work(
12561297 write(
12571298 `sandbox: off here${options.sandboxNote === undefined ? "" : ` — ${options.sandboxNote}`}; the shell runs unrestricted`,
12581299 );
1300 watch.lap("brief");
1301 ran.inHarness = true;
12591302 const working = budget - reserve;
12601303 if (!reviewing)
12611304 early = watchForPull({
@@ -1439,6 +1482,8 @@ async function work(
14391482 limitLifts = resumeAt(result.result, new Date());
14401483 }
14411484
1485 watch.lap("harness");
1486 ran.inHarness = false;
14421487 // The reserve: the same session gets one more turn, to land what exists
14431488 // rather than to keep working. Its ending is the run's ending, with the
14441489 // whole spend; the pull says where the work stopped.
@@ -1450,6 +1495,7 @@ async function work(
14501495 Date.now() < deadline
14511496 ) {
14521497 stoppedAt = result.costUsd;
1498 ran.inWrapUp = true;
14531499 write(`claude: wrapping up with the reserve, $${reserve.toFixed(2)}`);
14541500 const wrapped = await retrying(
14551501 await invoke(wrapUpPrompt(), reserve, result.sessionId),
@@ -1474,6 +1520,8 @@ async function work(
14741520 turns: result.turns + wrapped.turns,
14751521 };
14761522 noteRunProgress(app.db, run.id, { costUsd: result.costUsd });
1523 watch.lap("wrap-up");
1524 ran.inWrapUp = false;
14771525 }
14781526 if (signal.aborted)
14791527 return {
test/crew-run-timing.test.ts added +100
@@ -0,0 +1,100 @@
1 /**
2 * An agent run's log ends with one `timing:` line: where the run's time went,
3 * lap by lap, on the path that started it — a finished run and a failed
4 * clone alike. A clock that ticks a second per read makes the laps exact.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdirSync, rmSync } from "node:fs";
9 import { join } from "node:path";
10 import { assignIssue, openIssue } from "../src/actions/issues.ts";
11 import { getAgent } from "../src/core/agents.ts";
12 import { workdirFor } from "../src/crew/runner.ts";
13 import { readRunLog } from "../src/runs/log.ts";
14 import {
15 agentRunSettled,
16 startStandInCrew,
17 stopStandInCrew,
18 type StandInCrew,
19 } from "./helpers/crew-standin.ts";
20
21 describe("an agent run's timing line", () => {
22 let standIn: StandInCrew;
23 let ticks = 0;
24
25 beforeAll(async () => {
26 standIn = await startStandInCrew({ now: () => 1_000 * ticks++ });
27 }, 30_000);
28
29 afterAll(() => stopStandInCrew(standIn));
30
31 async function runOn(
32 title: string,
33 prepare?: (n: number) => void,
34 body = "Do it.",
35 paused = false,
36 ) {
37 const { app, alice, bot, repo } = standIn;
38 const issue = openIssue(app, alice, repo, { title, body });
39 if (!issue.ok) throw new Error(String(issue.error));
40 prepare?.(issue.value.number);
41 expect(assignIssue(app, alice, repo, issue.value, bot).ok).toBe(true);
42 const run = await agentRunSettled(
43 app,
44 repo,
45 (r) =>
46 r.issueId === issue.value.id &&
47 (!paused || r.summary.startsWith("paused:")),
48 60_000,
49 );
50 const lines = readRunLog(standIn.dataDir, run.id).lines;
51 return { run, lines, timing: lines.filter((l) => l.startsWith("timing:")) };
52 }
53
54 test("a finished run ends with the laps in order", async () => {
55 const { run, lines, timing } = await runOn("Water the ferns");
56 expect(run.state).toBe("ok");
57 expect(timing).toHaveLength(1);
58 expect(lines.at(-1)).toBe(timing[0]!);
59 expect(timing[0]!).toMatch(
60 /^timing: run ended \d+\.\ds after the run started — clone \d+\.\ds · brief \d+\.\ds · harness \d+\.\ds$/,
61 );
62 }, 30_000);
63
64 test("a run whose clone cannot be prepared still ends with the line", async () => {
65 const { run, lines, timing } = await runOn("Prune the roses", (number) => {
66 const agent = getAgent(standIn.app.db, standIn.bot.id);
67 if (!agent) throw new Error("no agent");
68 // A `.git` that is no repository: the fetch of a kept clone fails.
69 mkdirSync(
70 join(
71 workdirFor(standIn.app.config.dataDir, agent, standIn.repo, number),
72 ".git",
73 ),
74 { recursive: true },
75 );
76 });
77 expect(run.state).toBe("failed");
78 expect(timing).toHaveLength(1);
79 expect(lines.at(-1)).toBe(timing[0]!);
80 expect(timing[0]!).toMatch(/after the run started — clone \d+\.\ds$/);
81 }, 30_000);
82
83 test("a run paused by an outage keeps its harness lap, one line last", async () => {
84 const { run, lines, timing } = await runOn("Network out", undefined, "NET-DOWN", true);
85 expect(run.state).toBe("cancelled");
86 expect(timing).toHaveLength(1);
87 expect(lines.at(-1)).toBe(timing[0]!);
88 expect(timing[0]!).toMatch(/clone \d+\.\ds · brief \d+\.\ds · harness \d+\.\ds$/);
89 rmSync(join(standIn.dataDir, "down"), { force: true });
90 }, 90_000);
91
92 test("a wrap-up outage keeps the harness and wrap-up laps, one line last", async () => {
93 const { run, lines, timing } = await runOn("Wrap down", undefined, "WRAP-DOWN", true);
94 expect(run.state).toBe("cancelled");
95 expect(timing).toHaveLength(1);
96 expect(lines.at(-1)).toBe(timing[0]!);
97 expect(timing[0]!).toMatch(/harness \d+\.\ds · wrap-up \d+\.\ds$/);
98 rmSync(join(standIn.dataDir, "wrapdown"), { force: true });
99 }, 90_000);
100 });
test/helpers/crew-standin.ts +6 −1
@@ -43,7 +43,11 @@ export interface StandInCrew {
4343
4444 /** A forge with checks and a crew whose Claude Code is the stand-in. */
4545 export async function startStandInCrew(
46 options: { readonly reviewBudgetMs?: number; readonly tickMs?: number } = {},
46 options: {
47 readonly reviewBudgetMs?: number;
48 readonly tickMs?: number;
49 readonly now?: () => number;
50 } = {},
4751 ): Promise<StandInCrew> {
4852 const dataDir = await mkdtemp(join(tmpdir(), "forge-crew-"));
4953 const app = createApp({ dataDir, port: 0 });
@@ -179,6 +183,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Added CREW
179183 networkRetryScale: 0,
180184 reviewBudgetMs: options.reviewBudgetMs ?? 4_000,
181185 ...(options.tickMs === undefined ? {} : { tickMs: options.tickMs }),
186 ...(options.now === undefined ? {} : { now: options.now }),
182187 });
183188 const a = await createUser(app.db, {
184189 origin: "seed",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs