Public: anyone can read this; the crew can change it.
The crew starts every run through one agent-runner seam, with today's CLI as its first runner #12132
mason/forge:mason/12122-the-crew-starts-every-run-through-one-ag → main 2 commits fork
11 files
changed +194
−110
src/crew/agent_runner.ts added +138
| @@ -0,0 +1,138 @@ | ||
| 1 | /** | |
| 2 | * The agent-runner seam (#12122): every crew run starts here. A runner takes | |
| 3 | * a brief (prompt, system prompt, model, budget) and a sandbox, streams the | |
| 4 | * run's steps (`log`, `onSaid`), reports spend (`onUsage`, `onMeasure`), | |
| 5 | * pauses (`onCleared`, a rate-limited outcome) and its end (`AgentOutcome`), | |
| 6 | * as plain data in and out. `crew/claude.ts` is the first runner; callers | |
| 7 | * import this module, never a runner. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { Measure } from "../core/measure.ts"; | |
| 11 | import type { LimitWindow } from "../core/standing.ts"; | |
| 12 | import type { RunLimits } from "./run-cell.ts"; | |
| 13 | import type { SandboxPolicy } from "./sandbox.ts"; | |
| 14 | import { detectClaude, runClaude } from "./claude.ts"; | |
| 15 | ||
| 16 | export interface AgentRun { | |
| 17 | readonly binary: string; | |
| 18 | readonly cwd: string; | |
| 19 | readonly prompt: string; | |
| 20 | readonly appendSystemPrompt: string; | |
| 21 | readonly model?: string | undefined; | |
| 22 | readonly budgetUsd: number; | |
| 23 | /** A session id to continue. */ | |
| 24 | readonly resume?: string | undefined; | |
| 25 | readonly env: Record<string, string>; | |
| 26 | readonly sandbox: SandboxPolicy; | |
| 27 | /** The run's machine's memory and CPU ceilings (`crew/run-cell.ts`); none is today's cell. */ | |
| 28 | readonly limits?: RunLimits | undefined; | |
| 29 | /** What `/system` calls this run's process tree (`crew:<name>`); unlabelled when absent. */ | |
| 30 | readonly label?: string | undefined; | |
| 31 | readonly timeoutMs: number; | |
| 32 | readonly log: (line: string) => void; | |
| 33 | readonly signal: AbortSignal; | |
| 34 | /** Images that go with the prompt, once: base64 data and its media type (#8820). */ | |
| 35 | readonly images?: readonly { readonly mediaType: string; readonly data: string }[] | undefined; | |
| 36 | /** | |
| 37 | * Hands the caller a way to say something to the agent while it runs. | |
| 38 | * The words arrive as its next user turn, after the step it is on; a | |
| 39 | * send after the session ended returns false. | |
| 40 | */ | |
| 41 | readonly onSteer?: ((send: (text: string) => boolean) => void) | undefined; | |
| 42 | /** | |
| 43 | * The session's spend so far, each time it reports some: tokens as each | |
| 44 | * assistant message's `usage` arrives, the cost once the result names it | |
| 45 | * (Claude Code reports `total_cost_usd` only at the end). Running totals | |
| 46 | * for this invocation, never deltas. | |
| 47 | */ | |
| 48 | readonly onUsage?: ((spent: Spent) => void) | undefined; | |
| 49 | /** The provider's measurement of a window, each time the session reports one (#8960). */ | |
| 50 | readonly onMeasure?: ((measure: Measure) => void) | undefined; | |
| 51 | /** The provider said this window is under its thresholds: a stored reading of it is stale. */ | |
| 52 | readonly onCleared?: ((window: LimitWindow) => void) | undefined; | |
| 53 | /** | |
| 54 | * Talks to the forge through MCP alone (#7651): no file or shell tools, no | |
| 55 | * clone, the one server named in `config` (`--mcp-config` JSON) allowed. | |
| 56 | */ | |
| 57 | readonly mcp?: { readonly config: string; readonly allow: readonly string[] } | undefined; | |
| 58 | /** The session id, once the init record names it (a restart resumes it). */ | |
| 59 | readonly onSession?: ((sessionId: string) => void) | undefined; | |
| 60 | /** What the agent said or called, as each assistant message arrives. */ | |
| 61 | readonly onSaid?: ((said: { readonly kind: "text" | "tool" | "thinking"; readonly text: string }) => void) | undefined; | |
| 62 | /** | |
| 63 | * How long a session may stay silent after a result while its input is | |
| 64 | * still open (a steer was pending) before it is closed and settled as | |
| 65 | * done. A steer folded into the turn it interrupted leaves a result owed | |
| 66 | * that never comes. Default the runner's own default (Claude: five minutes). | |
| 67 | */ | |
| 68 | readonly idleMs?: number | undefined; | |
| 69 | /** | |
| 70 | * How long the process has to exit once its input is closed after the | |
| 71 | * final result. Past it the process group is stopped and the run settles | |
| 72 | * on the result it already gave. Default is the runner's own (Claude: five seconds). | |
| 73 | */ | |
| 74 | readonly settleMs?: number | undefined; | |
| 75 | } | |
| 76 | ||
| 77 | ||
| 78 | export interface Spent { | |
| 79 | readonly tokensIn: number; | |
| 80 | readonly tokensOut: number; | |
| 81 | readonly costUsd: number; | |
| 82 | } | |
| 83 | ||
| 84 | export interface AgentOutcome { | |
| 85 | /** Whether the run ended with a result rather than an error. */ | |
| 86 | readonly ok: boolean; | |
| 87 | readonly sessionId: string; | |
| 88 | /** The agent's final message. */ | |
| 89 | readonly result: string; | |
| 90 | readonly costUsd: number; | |
| 91 | readonly turns: number; | |
| 92 | /** | |
| 93 | * Tokens the model read (fresh input, cache writes and cache reads) and | |
| 94 | * wrote, from the result's `usage`; 0 when the run reported none. | |
| 95 | */ | |
| 96 | readonly tokensIn: number; | |
| 97 | readonly tokensOut: number; | |
| 98 | /** What the last assistant message read: the session's context at its end; 0 if none. */ | |
| 99 | readonly contextTokens: number; | |
| 100 | /** The model's context window in tokens, from the result's `modelUsage`; 0 when it named none. */ | |
| 101 | readonly contextWindow: number; | |
| 102 | /** `completed`, `max_budget_reached`, `timeout`, `cancelled`, `error`, `exit <n>`. */ | |
| 103 | readonly reason: string; | |
| 104 | } | |
| 105 | ||
| 106 | ||
| 107 | /** The run spent its budget and the runner stopped it. */ | |
| 108 | export const BUDGET_REACHED = "max_budget_reached"; | |
| 109 | ||
| 110 | export type AgentDetection = | |
| 111 | | { ok: true; version: string } | |
| 112 | | { ok: false; error: string }; | |
| 113 | ||
| 114 | /** What a runner implements. `run` never throws; every ending is an outcome. */ | |
| 115 | export interface AgentRunner { | |
| 116 | run(input: AgentRun): Promise<AgentOutcome>; | |
| 117 | /** Whether `binary` is an agent this runner can drive. */ | |
| 118 | detect(binary: string): Promise<AgentDetection>; | |
| 119 | } | |
| 120 | ||
| 121 | // Wrapped, not bound, so the two modules may load in either order. | |
| 122 | const claude: AgentRunner = { | |
| 123 | run: (input) => runClaude(input), | |
| 124 | detect: (binary) => detectClaude(binary), | |
| 125 | }; | |
| 126 | ||
| 127 | /** The runner every run uses today. */ | |
| 128 | export function currentRunner(): AgentRunner { | |
| 129 | return claude; | |
| 130 | } | |
| 131 | ||
| 132 | export function runAgent(input: AgentRun): Promise<AgentOutcome> { | |
| 133 | return currentRunner().run(input); | |
| 134 | } | |
| 135 | ||
| 136 | export function detectAgent(binary: string): Promise<AgentDetection> { | |
| 137 | return currentRunner().detect(binary); | |
| 138 | } |
src/crew/claude.ts +15 −93
| @@ -12,11 +12,10 @@ | ||
| 12 | 12 | * settings files of the operator's, a spend budget, and a deadline. |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | import { clearedOf, measureOf, type Measure } from "../core/measure.ts"; | |
| 16 | import type { LimitWindow } from "../core/standing.ts"; | |
| 15 | import { clearedOf, measureOf } from "../core/measure.ts"; | |
| 17 | 16 | import { backgroundArgv, crewCpus } from "../apps/background.ts"; |
| 18 | 17 | import { cgroupHost } from "../apps/cgroup.ts"; |
| 19 | import { openRunCell, type RunLimits } from "./run-cell.ts"; | |
| 18 | import { openRunCell } from "./run-cell.ts"; | |
| 20 | 19 | import { watchLabelled } from "../apps/contain.ts"; |
| 21 | 20 | import { childEnv, pump } from "../apps/process.ts"; |
| 22 | 21 | import { endedLine, liveTree } from "../runs/ended.ts"; |
| @@ -24,102 +23,25 @@ import { CANCEL_GRACE_MS } from "../runs/grace.ts"; | ||
| 24 | 23 | import { spellings, stripForgeDirs } from "../runs/paths.ts"; |
| 25 | 24 | import type { SandboxPolicy } from "./sandbox.ts"; |
| 26 | 25 | |
| 27 | export interface ClaudeInput { | |
| 28 | readonly binary: string; | |
| 29 | readonly cwd: string; | |
| 30 | readonly prompt: string; | |
| 31 | readonly appendSystemPrompt: string; | |
| 32 | readonly model?: string | undefined; | |
| 33 | readonly budgetUsd: number; | |
| 34 | /** A session id to continue. */ | |
| 35 | readonly resume?: string | undefined; | |
| 36 | readonly env: Record<string, string>; | |
| 37 | readonly sandbox: SandboxPolicy; | |
| 38 | /** The run's machine's memory and CPU ceilings (`crew/run-cell.ts`); none is today's cell. */ | |
| 39 | readonly limits?: RunLimits | undefined; | |
| 40 | /** What `/system` calls this run's process tree (`crew:<name>`); unlabelled when absent. */ | |
| 41 | readonly label?: string | undefined; | |
| 42 | readonly timeoutMs: number; | |
| 43 | readonly log: (line: string) => void; | |
| 44 | readonly signal: AbortSignal; | |
| 45 | /** Images that go with the prompt, once: base64 data and its media type (#8820). */ | |
| 46 | readonly images?: readonly { readonly mediaType: string; readonly data: string }[] | undefined; | |
| 47 | /** | |
| 48 | * Hands the caller a way to say something to the agent while it runs. | |
| 49 | * The words arrive as its next user turn, after the step it is on; a | |
| 50 | * send after the session ended returns false. | |
| 51 | */ | |
| 52 | readonly onSteer?: ((send: (text: string) => boolean) => void) | undefined; | |
| 53 | /** | |
| 54 | * The session's spend so far, each time it reports some: tokens as each | |
| 55 | * assistant message's `usage` arrives, the cost once the result names it | |
| 56 | * (Claude Code reports `total_cost_usd` only at the end). Running totals | |
| 57 | * for this invocation, never deltas. | |
| 58 | */ | |
| 59 | readonly onUsage?: ((spent: Spent) => void) | undefined; | |
| 60 | /** The provider's measurement of a window, each time the session reports one (#8960). */ | |
| 61 | readonly onMeasure?: ((measure: Measure) => void) | undefined; | |
| 62 | /** The provider said this window is under its thresholds: a stored reading of it is stale. */ | |
| 63 | readonly onCleared?: ((window: LimitWindow) => void) | undefined; | |
| 64 | /** | |
| 65 | * Talks to the forge through MCP alone (#7651): no file or shell tools, no | |
| 66 | * clone, the one server named in `config` (`--mcp-config` JSON) allowed. | |
| 67 | */ | |
| 68 | readonly mcp?: { readonly config: string; readonly allow: readonly string[] } | undefined; | |
| 69 | /** The session id, once the init record names it (a restart resumes it). */ | |
| 70 | readonly onSession?: ((sessionId: string) => void) | undefined; | |
| 71 | /** What the agent said or called, as each assistant message arrives. */ | |
| 72 | readonly onSaid?: ((said: { readonly kind: "text" | "tool" | "thinking"; readonly text: string }) => void) | undefined; | |
| 73 | /** | |
| 74 | * How long a session may stay silent after a result while its input is | |
| 75 | * still open (a steer was pending) before it is closed and settled as | |
| 76 | * done. A steer folded into the turn it interrupted leaves a result owed | |
| 77 | * that never comes. Default `IDLE_AFTER_RESULT_MS`. | |
| 78 | */ | |
| 79 | readonly idleMs?: number | undefined; | |
| 80 | /** | |
| 81 | * How long the process has to exit once its input is closed after the | |
| 82 | * final result. Past it the process group is stopped and the run settles | |
| 83 | * on the result it already gave. Default `SETTLE_MS`. | |
| 84 | */ | |
| 85 | readonly settleMs?: number | undefined; | |
| 86 | } | |
| 26 | import type { | |
| 27 | AgentOutcome, | |
| 28 | AgentRun, | |
| 29 | BUDGET_REACHED as SeamBudgetReached, | |
| 30 | Spent, | |
| 31 | } from "./agent_runner.ts"; | |
| 32 | ||
| 33 | /** Claude's names for the seam's types. */ | |
| 34 | export type ClaudeInput = AgentRun; | |
| 35 | export type ClaudeOutcome = AgentOutcome; | |
| 36 | export type { Spent }; | |
| 87 | 37 | |
| 88 | 38 | /** The idle guard's default: five minutes of silence after a result. */ |
| 89 | 39 | export const IDLE_AFTER_RESULT_MS = 5 * 60_000; |
| 90 | 40 | /** The grace a finished session has to exit on its own. */ |
| 91 | 41 | export const SETTLE_MS = 5_000; |
| 92 | 42 | |
| 93 | export interface Spent { | |
| 94 | readonly tokensIn: number; | |
| 95 | readonly tokensOut: number; | |
| 96 | readonly costUsd: number; | |
| 97 | } | |
| 98 | ||
| 99 | export interface ClaudeOutcome { | |
| 100 | /** Whether the run ended with a result rather than an error. */ | |
| 101 | readonly ok: boolean; | |
| 102 | readonly sessionId: string; | |
| 103 | /** The agent's final message. */ | |
| 104 | readonly result: string; | |
| 105 | readonly costUsd: number; | |
| 106 | readonly turns: number; | |
| 107 | /** | |
| 108 | * Tokens the model read (fresh input, cache writes and cache reads) and | |
| 109 | * wrote, from the result's `usage`; 0 when the run reported none. | |
| 110 | */ | |
| 111 | readonly tokensIn: number; | |
| 112 | readonly tokensOut: number; | |
| 113 | /** What the last assistant message read: the session's context at its end; 0 when none reported. */ | |
| 114 | readonly contextTokens: number; | |
| 115 | /** The model's context window in tokens, from the result's `modelUsage`; 0 when it named none. */ | |
| 116 | readonly contextWindow: number; | |
| 117 | /** `completed`, `max_budget_reached`, `timeout`, `cancelled`, `error`, `exit <n>`. */ | |
| 118 | readonly reason: string; | |
| 119 | } | |
| 120 | ||
| 121 | /** The run spent its budget and Claude Code stopped it. */ | |
| 122 | export const BUDGET_REACHED = "max_budget_reached"; | |
| 43 | /** The seam's value, spelled here so this module needs nothing from it at run time. */ | |
| 44 | export const BUDGET_REACHED: typeof SeamBudgetReached = "max_budget_reached"; | |
| 123 | 45 | |
| 124 | 46 | /** |
| 125 | 47 | * The tools an agent has. Enough to read, change, run and push code; no |
src/crew/conversation.ts +4 −4
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | * A person's message becomes one agent turn (#7651): one `runClaude`, resumed | |
| 2 | * A person's message becomes one agent turn (#7651): one `runAgent`, resumed | |
| 3 | 3 | * from the conversation's stored session, with no clone and no shell — only |
| 4 | 4 | * this forge's `/mcp`, reached with a `via` token of that person, so the |
| 5 | 5 | * agent can do what they can and no more (`core/via_token.ts`, the fence in |
| @@ -37,7 +37,7 @@ import { id } from "../kernel/id.ts"; | ||
| 37 | 37 | import { running } from "../kernel/version.ts"; |
| 38 | 38 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 39 | 39 | import { appendRunLog } from "../runs/log.ts"; |
| 40 | import { runClaude, type ClaudeOutcome } from "./claude.ts"; | |
| 40 | import { runAgent, type AgentOutcome } from "./agent_runner.ts"; | |
| 41 | 41 | import { CONVERSATION_BRIEF } from "./conversation_brief.ts"; |
| 42 | 42 | import { isRateLimited, takeLimit, whenText } from "./limits.ts"; |
| 43 | 43 | import { credentialEnv, listenForSteer, steerRun } from "./runner.ts"; |
| @@ -511,7 +511,7 @@ function begin( | ||
| 511 | 511 | // nothing to enforce and an unenforced policy cannot fail the turn. |
| 512 | 512 | const sandbox = sandboxPolicy({ dataDir: app.config.dataDir, agentName: "conversation", urls: [options.localUrl], enforced: false, runId }); |
| 513 | 513 | const turn = (async (): Promise<void> => { |
| 514 | let outcome: ClaudeOutcome | undefined; | |
| 514 | let outcome: AgentOutcome | undefined; | |
| 515 | 515 | let unlisten = (): void => {}; |
| 516 | 516 | try { |
| 517 | 517 | const cwd = turnDir(app.config.dataDir); |
| @@ -523,7 +523,7 @@ function begin( | ||
| 523 | 523 | for (let session = resumeSession; ; session = undefined) { |
| 524 | 524 | let named = false; |
| 525 | 525 | if (session !== undefined) appendRunLog(app.config.dataDir, runId, `resuming session ${session}`); |
| 526 | outcome = await runClaude({ | |
| 526 | outcome = await runAgent({ | |
| 527 | 527 | binary: options.binary, |
| 528 | 528 | cwd, |
| 529 | 529 | // Attachments go with the message itself, never with a resume. |
src/crew/dispatch.ts +2 −2
| @@ -85,7 +85,7 @@ import { isContributionFork } from "../core/contribute.ts"; | ||
| 85 | 85 | import { guestSql, lacks } from "../core/roles.ts"; |
| 86 | 86 | import { createLog } from "../kernel/log.ts"; |
| 87 | 87 | import { issueOfBranch, type RunReason } from "./brief.ts"; |
| 88 | import { detectClaude } from "./claude.ts"; | |
| 88 | import { detectAgent } from "./agent_runner.ts"; | |
| 89 | 89 | import { followDivergence, settleAll, settleOnPush } from "./converge.ts"; |
| 90 | 90 | import { breakerTripped } from "./breaker.ts"; |
| 91 | 91 | import { REPEAT_NOTE, repeatTripped } from "./repeat.ts"; |
| @@ -209,7 +209,7 @@ export async function startCrew( | ||
| 209 | 209 | app: App, |
| 210 | 210 | options: CrewOptions, |
| 211 | 211 | ): Promise<CrewHandle> { |
| 212 | const detected = await detectClaude(options.binary); | |
| 212 | const detected = await detectAgent(options.binary); | |
| 213 | 213 | if (!detected.ok) |
| 214 | 214 | log.warn("claude not usable; agent runs will fail", { |
| 215 | 215 | error: detected.error, |
src/crew/leftovers.ts +1 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * Claude Code's sandbox bridges that outlived their session (#3708). Each |
| 3 | 3 | * session starts two `socat UNIX-LISTEN:<its temp dir>/…,fork TCP:…` |
| 4 | * relays in its own process group; `runClaude` kills that group once the | |
| 4 | * relays in its own process group; the runner kills that group once the | |
| 5 | 5 | * session ends (`reapGroup`), and this sweep is the net under it — for a |
| 6 | 6 | * session a forge before that fix, or a forge that died, left behind. |
| 7 | 7 | * |
src/crew/limits.ts +2 −2
| @@ -9,7 +9,7 @@ import type { Database } from "bun:sqlite"; | ||
| 9 | 9 | import { noteLimit, type LimitWindow } from "../core/standing.ts"; |
| 10 | 10 | import { PLATFORM, restSubscription } from "../core/subscriptions.ts"; |
| 11 | 11 | import { isNetworkError } from "./network.ts"; |
| 12 | import type { ClaudeOutcome } from "./claude.ts"; | |
| 12 | import type { AgentOutcome } from "./agent_runner.ts"; | |
| 13 | 13 | |
| 14 | 14 | /** How many times one task may be paused before the forge gives up on it. */ |
| 15 | 15 | export const MAX_PAUSES = 6; |
| @@ -24,7 +24,7 @@ const SLACK_MS = 60_000; | ||
| 24 | 24 | * failure. Claude Code ends such a run with `api_error`; the message names |
| 25 | 25 | * the limit, and is checked too in case the ending is named differently. |
| 26 | 26 | */ |
| 27 | export function isRateLimited(outcome: ClaudeOutcome): boolean { | |
| 27 | export function isRateLimited(outcome: AgentOutcome): boolean { | |
| 28 | 28 | if (outcome.ok || isNetworkError(outcome)) return false; |
| 29 | 29 | return ( |
| 30 | 30 | outcome.reason === "api_error" || |
src/crew/network.ts +2 −2
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * later goes on. The runner retries before it judges the run. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import type { ClaudeOutcome } from "./claude.ts"; | |
| 8 | import type { AgentOutcome } from "./agent_runner.ts"; | |
| 9 | 9 | |
| 10 | 10 | /** The one wording of a dead network; the interrupted-summary reader builds on it. */ |
| 11 | 11 | export const NETWORK = |
| @@ -18,7 +18,7 @@ export const NETWORK_RETRY_SECONDS: readonly number[] = [5, 20, 60]; | ||
| 18 | 18 | export const NETWORK_PAUSE_MS = 5 * 60_000; |
| 19 | 19 | |
| 20 | 20 | /** Whether the run ended on the network rather than on the work. */ |
| 21 | export function isNetworkError(outcome: ClaudeOutcome): boolean { | |
| 21 | export function isNetworkError(outcome: AgentOutcome): boolean { | |
| 22 | 22 | if (outcome.ok) return false; |
| 23 | 23 | return NETWORK.test(outcome.result) || NETWORK.test(outcome.reason); |
| 24 | 24 | } |
src/crew/one_shot.ts +2 −2
| @@ -12,7 +12,7 @@ import { dailyLeft } from "../core/account_ceiling.ts"; | ||
| 12 | 12 | import { PLATFORM, pickSubscription } from "../core/subscriptions.ts"; |
| 13 | 13 | import { id } from "../kernel/id.ts"; |
| 14 | 14 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 15 | import { runClaude } from "./claude.ts"; | |
| 15 | import { runAgent } from "./agent_runner.ts"; | |
| 16 | 16 | import { conversationOptions, sessionsDir, turnDir } from "./conversation.ts"; |
| 17 | 17 | import { isRateLimited } from "./limits.ts"; |
| 18 | 18 | import { credentialEnv } from "./runner.ts"; |
| @@ -42,7 +42,7 @@ export async function oneShot(app: App, userId: string, system: string, prompt: | ||
| 42 | 42 | .run(runId, userId, subscriptionId, budget, now.toISOString()); |
| 43 | 43 | let costUsd = budget; |
| 44 | 44 | try { |
| 45 | const outcome = await runClaude({ | |
| 45 | const outcome = await runAgent({ | |
| 46 | 46 | binary: options.binary, |
| 47 | 47 | cwd: turnDir(app.config.dataDir), |
| 48 | 48 | prompt, |
src/crew/run-cell.ts +1 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | * A crew run's cell (#9469): the cgroup `runClaude` puts the agent in, held | |
| 2 | * A crew run's cell (#9469): the cgroup the runner puts the agent in, held | |
| 3 | 3 | * to its machine's ceilings (`crew/machines.ts`). A machine can only narrow |
| 4 | 4 | * it: memory is capped where today it is not, and cores are the lower of the |
| 5 | 5 | * machine's and the forge's own `FORGE_CREW_CPUS`. A machine that declares |
src/crew/runner.ts +3 −3
| @@ -140,7 +140,7 @@ import { | ||
| 140 | 140 | NETWORK_RETRY_SECONDS, |
| 141 | 141 | pause, |
| 142 | 142 | } from "./network.ts"; |
| 143 | import { BUDGET_REACHED, runClaude, type ClaudeOutcome } from "./claude.ts"; | |
| 143 | import { BUDGET_REACHED, runAgent, type AgentOutcome } from "./agent_runner.ts"; | |
| 144 | 144 | import { |
| 145 | 145 | isRateLimited, |
| 146 | 146 | limitOf, |
| @@ -1539,9 +1539,9 @@ async function work( | ||
| 1539 | 1539 | prompt: string, |
| 1540 | 1540 | budgetUsd: number, |
| 1541 | 1541 | resume: string | undefined, |
| 1542 | ): Promise<ClaudeOutcome> => { | |
| 1542 | ): Promise<AgentOutcome> => { | |
| 1543 | 1543 | const spent = redactResult( |
| 1544 | await runClaude({ | |
| 1544 | await runAgent({ | |
| 1545 | 1545 | binary: options.binary, |
| 1546 | 1546 | cwd: dir, |
| 1547 | 1547 | prompt, |
test/agent-runner-seam.test.ts added +24
| @@ -0,0 +1,24 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { Glob } from "bun"; | |
| 3 | ||
| 4 | // Every crew run starts through src/crew/agent_runner.ts (#12122); a runner | |
| 5 | // implementation is imported by the seam alone. | |
| 6 | const IMPLEMENTATIONS = new Set(["src/crew/claude.ts", "src/crew/agent_runner.ts"]); | |
| 7 | const FROM = /(?:from|import)\s*\(?\s*["']([^"']*\/claude(?:\.ts)?)["']/g; | |
| 8 | ||
| 9 | test("no module outside the runner implementations imports claude.ts", async () => { | |
| 10 | const offenders: string[] = []; | |
| 11 | for await (const file of new Glob("src/**/*.ts").scan(".")) { | |
| 12 | if (IMPLEMENTATIONS.has(file)) continue; | |
| 13 | const text = await Bun.file(file).text(); | |
| 14 | for (const m of text.matchAll(FROM)) offenders.push(`${file} -> ${m[1]}`); | |
| 15 | } | |
| 16 | expect(offenders).toEqual([]); | |
| 17 | }); | |
| 18 | ||
| 19 | test("the four crew callers go through the seam", async () => { | |
| 20 | for (const name of ["runner", "dispatch", "one_shot", "conversation"]) { | |
| 21 | const text = await Bun.file(`src/crew/${name}.ts`).text(); | |
| 22 | expect(text).toContain('from "./agent_runner.ts"'); | |
| 23 | } | |
| 24 | }); |
new above
offline