Skip to content

How an Attempt runs

The executor contract, the durable layer, the live channel for interventions, transcripts, the three harnesses.

An Attempt goes through six layers. All of them run inside the orchestrator process except the children. The table lists them from the engine down.

Layer Code Does
Run fiber run-driver.ts:288 Starts or reattaches the Attempt, gives the executor a transcript sink, records the outcome.
StepExecutor scheduler.ts:163 Builds a CheckoutRequest from the Run and the settings, then calls prepare and launch in one scope.
Executor executor.ts:172 Checkout, credentials, guard, salvage, harvest on one Worker kind.
Durable session durable-session.ts:73 Shared by every Worker kind: runs the parent task, stops it, routes interventions, implements isLive.
AttemptRuntime attempt-runtime.ts:70, implemented in runtime.ts:36 The Pi parent as a pi-durable task, its subagent tool, the envelope relay.
Child backends children.ts Claude Code, Codex, or Pi, on the Worker the executor chose.

The Effect workflow and cluster modules of ADR 0003 no longer exist. A migration dropped their tables (20261011153856_drop_workflow_tables), and the Run lifecycle is the fiber described in Run lifecycle.

sequenceDiagram
  participant F as Run fiber
  participant S as StepExecutor
  participant X as Executor
  participant P as Pi parent
  participant C as Child
  F->>F: beginAttempt
  F->>S: execute
  S->>X: prepare
  Note over X: credentials,<br/>worktree
  S->>X: launch
  X->>P: execute
  P->>C: subagent
  C-->>P: session events
  P-->>F: envelopes
  C-->>P: result
  P-->>X: report
  Note over X: harvest,<br/>recordResult
  Note over X: release the<br/>checkout
  X-->>F: StepResult
  F->>F: finishAttempt

The fiber begins the Attempt and gives StepExecutor a sink for envelopes. StepExecutor calls the executor to prepare the checkout with new credentials, then to launch the Attempt, which runs the Pi parent task through AttemptRuntime.execute. The parent starts children with its subagent tool. Every envelope from the parent and its children goes through the runaway guard to the fiber’s sink, which appends it to the transcript. When the parent reports, the executor harvests the checkout, stores the result with the task, and releases the checkout before launch returns, so the worktree is removed or its salvage is recorded before the fiber calls finishAttempt.

Executor (executor.ts:172) is the interface that ADR 0006 defines:

Operation Does
kind The Worker kind that the executor handles. StepExecutor refuses a configured Worker of another kind.
preflight(worker, brains) Verifies Git and each rostered brain where the Worker runs children. Doesn’t install anything.
prepare(request) Needs a Scope. Checks the Attempt’s brain again, gets repository access, creates or reattaches the checkout, and returns a PreparedAttempt with workspace and launch(io).
cancel(attemptId) Stops preparation, or cancels the live parent task, waits a grace period, then interrupts.
intervene(attemptId, intervention) Delivers an operator action to the live session.
isLive, outcome Tell a restarted engine whether to reattach an open Attempt or finish it with a stored result.
recover(attempt, record) Salvages or removes a checkout that an earlier process left.
packages/executor/src/executor.ts
/** Prepares isolated checkouts, runs Attempts on a Worker, and releases their resources (ADR 0006). */
export class Executor extends Context.Service<
Executor,
{
/** Reconciles an old checkout before new Attempts start; records its salvage before releasing any work. */
readonly recover: <E>(
attempt: AttemptRecovery,
record: (salvage: AttemptSalvage) => Effect.Effect<void, E>,
) => Effect.Effect<void, E | ExecutorError>;
readonly kind: WorkerKind;
/**
* Whether the Attempt's parent task survived an orchestrator restart and can be reattached:
* it still runs, or it reported success before its checkout was harvested.
*/
readonly isLive: (attemptId: AttemptId) => Effect.Effect<boolean, AttemptFailure>;
/**
* The result an Attempt's finished parent task left in durable storage before an
* orchestrator restart: the harvested result, or the parent's failure. None when
* nothing was stored, or when `isLive` says the Attempt is reattached instead.
*/
readonly outcome: (
attemptId: AttemptId,
) => Effect.Effect<Option.Option<Result.Result<StepResult, AttemptFailure>>, AttemptFailure>;
/** Verifies Git and each rostered brain where the Worker runs children; never installs tools or logs in. */
readonly preflight: (
worker: WorkerName,
brains: ReadonlyArray<BrainSpec>,
) => Effect.Effect<void, ExecutorError>;
/**
* Verifies the assigned brain before acquiring the Attempt in the caller's scope. Clone, fetch, and lock waits
* remain interruptible; partial preparation releases everything already acquired
* before reporting failure. Each request identifies a fresh Attempt, or a live one
* to reattach when `reattach` is set.
*/
readonly prepare: (
request: CheckoutRequest,
) => Effect.Effect<PreparedAttempt, ExecutorError, Scope.Scope>;
/**
* Stops preparation and releases partial resources, or cancels the live
* parent task with a grace period before forced termination. A canceled prepared Attempt
* cannot launch. Repeated cancellation after release is a no-op.
*/
readonly cancel: (attemptId: AttemptId) => Effect.Effect<void, ExecutorError>;
/** Delivers an attributed operator action to the active Attempt's live session. */
readonly intervene: (
attemptId: AttemptId,
intervention: AttemptChannel.Intervention,
) => Effect.Effect<void, AttemptFailure>;

CheckoutRequest (executor.ts:93) has the Attempt id, Worker, repository, revision, Event payload, task text, Agent, parent model and effort, maxChildren, the two optional ceilings, the salvage ref, the salvage to resume from, and reattach. The revision is HEAD for issue and schedule Runs (trigger-submission.ts:17) and the head commit for pull request Runs (loop-evaluation.ts:78).

provisionWorktree (provision.ts:233) is the same for every Worker kind. Under a per-repository lock it clones into a staging directory (git clone --bare --filter=blob:none) and renames it to the cache, or updates the cache’s remote URL. Then it fetches the revision, records FETCH_HEAD as the base commit, fetches the previous Attempt’s salvage when the request resumes one, and runs git worktree add --detach. The worktree is <worker directory>/attempts/<attempt id> and the cache is <worker directory>/repos/<repository>.git, where both names replace every character other than letters, digits, _, ., and - with _ (provision.ts:21).

launch (prepared-attempt.ts) wraps the sink with the runaway guard (prepared-attempt.ts:114) and races the parent task against the guard’s wall-clock trip. The guard (guard.ts:139) arms only the ceilings that usine.yaml sets. The token meter sums the highest SessionTokensUpdated count per session (guard.ts:32). The wall clock pauses while any child is held (guard.ts:64). At 80% of either ceiling (guard.ts:8) the guard sends the parent one Steer from operator engine (prepared-attempt.ts:119). When a ceiling is reached, the guard stops the Attempt and fails it with RunawayFailure.

When the parent reports success, the executor harvests the checkout with harvestFacts (harvest.ts:135): the commits from the base commit to HEAD, then the pull requests whose head is the final commit, from gh api repos/<repo>/commits/<head>/pulls. Pull request discovery has a ten-second timeout and never fails the Attempt. StepResult also allows a Comment fact (result.ts:20), but no code produces one.

If the Attempt ends any other way, salvageWorktree (salvage.ts:56) runs before the worktree is removed. Uncommitted changes become a wip(usine): salvage attempt <id> commit, and git push --force pushes HEAD to usine/salvage/<run>/<number>. If the push fails, the executor keeps the worktree on the Worker and records its path as Retained. The next Attempt of the Run checks out the newest pushed salvage (ADR 0013, amendment of 2026-09-18).

AttemptRuntime (attempt-runtime.ts:70) runs the Pi parent as a pi-durable task called usine.attempt (task.ts:46). The task is stored in .usine/durable.sqlite (storage-path.ts:5), which @earendil-works/pi-durable 1.1.0 opens. Building the layer (live.ts:74) resumes every unfinished task (runtime.ts:50).

Operation Does
execute(request, io) Creates the task, or finds it after a restart, forwards its activity to io, and waits for the parent’s report.
isLive, finished Whether the task is still open, and what a finished task left: ParentReported, ResultRecorded, or ParentFailed.
recordResult Stores the harvested StepResult with the task.
request The stored request of a task, read for a reattachment.
intervene Applies an operator action to the parent or a child.
cancel Aborts children, then the parent conversation.

The stored request contains the RunSpec, the Worker, and the worktree path (attempt-runtime.ts:14). The task doesn’t store the child host or the environment; every execute call passes them again, because the environment contains the Attempt’s credential configuration (ADR 0011).

The parent’s first turn is the prompt from assemblePrompt (prompt.ts:58): the repository, the salvage it resumes from, the Event payload as JSON, the Agent persona, the task text, completion rules, and GitHub rules (review with COMMENT only, and end every comment, issue, pull request, and review with the signature line from githubSignature, which ends in usine agent: <name>, prompt.ts:28). The parent’s only tool is subagent (subagent.ts:31). Each call starts one child of the Agent’s harness in the checkout. The tool is declared replay: "unsafe" (subagent.ts:271): after a restart, the interrupted call ends with the output committed so far, and the parent decides whether to delegate again. A final answer that starts with [usine:blocked] fails the Attempt with AgentFailure (task.ts:58). usine enforces max_children with the children count in the Attempt record; one more spawn fails the Attempt with ChildSessionBudgetFailure.

The parent’s billing pool depends on its provider: a model from openai-codex uses the ChatGPT plan and every other model uses API keys (authentication.ts:30, ADR 0021).

  1. RunDriver.openAttempt (run-driver.ts:234) calls isLive. The durable session returns true when the task is live, or when it finished with ParentReported and its checkout isn’t harvested yet (durable-session.ts:396).

  2. beginAttempt returns ReattachAttempt, with the same Attempt id and number, and no retry used (lifecycle.ts:132).

  3. prepare with reattach reads the stored RunSpec, issues new credentials, and reuses the existing worktree.

  4. execute finds the task in storage and continues its relay from the stored sequence.

An Attempt whose task no longer exists fails with an InfraFailure, and the next Attempt starts from the newest pushed salvage. If a launch is interrupted and no one canceled it, the process is shutting down, so the task stays live and the worktree stays on disk (durable-session.ts:193).

channel.ts defines the envelopes, at protocol version 7. Outbound payloads are Started, SessionEvent, Final, Failed, Interrupted, InterventionApplied, and InterventionRejected. Inbound payloads are Steer, Interrupt, OperatorMessage, Release, and Cancel, each with a target: the parent or a child session id. admit (channel.ts:221) enforces the order: one Started or Failed first, contiguous sequences, nothing after the terminal envelope.

packages/core/src/attempt/channel.ts
/** Nonterminal operator controls; cancellation is an Attempt operation. */
export const OperatorAction = Schema.Union([Steer, Interrupt, OperatorMessage, Release]);
export type OperatorAction = typeof OperatorAction.Type;
export const InboundPayload = Schema.Union([OperatorAction, Cancel]);
export type InboundPayload = typeof InboundPayload.Type;
// ...
export const OutboundPayload = Schema.Union([
Started,
SessionEvent,
Final,
Failed,
Interrupted,
InterventionApplied,
InterventionRejected,
]);
export type OutboundPayload = typeof OutboundPayload.Type;

relayAttempt (relay.ts:171) sends Started, then the parent’s committed entries and the children’s session events in the order they happened, then one terminal envelope. The next sequence numbers are in the durable Attempt record, so a relay that restarts continues the same sequence.

The sink that the Run fiber gives to the executor calls RunStore.appendTranscript (store.ts:546). Each envelope becomes one row of usine_attempt_transcripts. A unique index on Attempt, direction, and channel sequence keeps the first copy when a reattached relay sends an envelope again. A Started envelope writes the parent model the SDK chose to the Attempt row, and an InterventionApplied envelope sets operator_intervened. Envelopes of protocol versions 3 and 4 (transcript.ts:21) and 5 and 6 (transcript.ts:43) still decode.

flowchart TB
  child["Child: Claude Code, Codex, Pi"]
  parent["Pi parent task, pi-durable"]
  guard["Runaway guard sink<br/>executor/src/guard.ts"]
  store[("RunStore.appendTranscript<br/>usine_attempt_transcripts")]
  reader["RunReader.transcript<br/>GET /api/runs/:runId"]
  drawer["Run drawer<br/>apps/web/src/run-drawer.tsx"]
  child -- "normalized AgentSessionEvent" --> parent
  parent -- "relayAttempt: envelopes in order" --> guard
  guard -- "meters tokens, tracks Holds" --> store
  store --> reader
  reader -- "newest 500 rows per Attempt,<br/>then the tail" --> drawer

The operator’s first read returns the newest 500 rows of each Attempt (projection.ts:37, projection.ts:192), later reads return the rows after a sequence, at most 500 rows across all Attempts (projection.ts:39), and the drawer loads earlier pages of one Attempt on request. The drawer polls every second while the Run can be canceled (run-drawer.tsx:91).

Operator input never becomes an Event (ADR 0018). It takes this path:

sequenceDiagram
  participant O as Operator
  participant E as RunEngine
  participant D as Durable session
  participant R as AttemptRuntime
  Note over O: POST intervene,<br/>attributed
  O->>E: intervene
  Note over E: Running, latest<br/>open Attempt?
  E->>D: intervene
  Note over D: live session<br/>in this process?
  D->>R: intervene
  Note over R: record inbound,<br/>apply to target
  R-->>E: Applied or<br/>Rejected
  1. POST /api/runs/:runId/intervene with the Attempt id, the target, and the action.

  2. Operator.intervene (operator.ts:471) attributes it to operator from usine.yaml, or to local operator.

  3. RunEngine.intervene (engine.ts:120) refuses unless the Run is Running and the Attempt is its latest open one.

  4. The executor’s durable session (durable-session.ts:312) refuses with AgentFailure when the Attempt has no live session in this process (for example, before a reattachment).

  5. AttemptRuntime.intervene (runtime.ts:305) records the inbound envelope, applies the action, and records InterventionApplied or InterventionRejected. Both go to the transcript through the same sink as the other envelopes.

On the parent, Interrupt aborts the current turn and marks the parent paused in its HoldDoc. An OperatorMessage to a paused parent resumes it with the message, and one to a busy parent is sent as a steer (runtime.ts:225). Release resumes a paused parent. On a child, an OperatorMessage places a Hold and Release ends it (runtime.ts:205). The subagent call waits for the Hold to end before it returns the held child’s result to the parent (subagent.ts:223).

Harness Driven by Where it runs Preflight Pool
Claude Code @anthropic-ai/claude-agent-sdk 0.3.261, executable claude, permissionMode: "bypassPermissions", user settings only (claude.ts:183) Locally, or one SSH command with a pid file through spawnClaudeCodeProcess (claude.ts:160) claude --version; claude auth status --json must report a claude.ai first-party login (preflight.ts:74) Claude plan
Codex codex app-server JSON-RPC over stdio, approvalPolicy: "never", sandbox danger-full-access (codex.ts:132) Locally, or one SSH command with a pid file (app-server.ts:121) codex --version must be 0.144.0 or 0.153.4; account check through the app-server (preflight.ts:20, preflight.ts:51) ChatGPT plan
Pi @earendil-works/pi-coding-agent 1.1.0 in the orchestrator process Local Workers only; it refuses a remote host (pi.ts:69) Starts a session to check the model and its API-key authentication (preflight.ts:86) API keys

childDispatcherLayer picks the backend from the Agent’s harness (children.ts:31). Every child gets only the Attempt’s environment, with no variables from the orchestrator’s own environment (runtime.ts:38). On the local Worker that is HOME, LANG, LC_ALL, PATH, SHELL, TMPDIR, USER, the credential variables, and TURBO_CACHE_DIR (local.ts:72, local.ts:280). On a remote Worker, the variables are added to the machine’s own environment. Before a remote checkout is kept or removed, CHILD_KILL_SCRIPT stops every process listed in a <worktree>.child-*.pid file (child-host.ts:106), because the exit of an SSH client doesn’t mean that its remote command stopped.

Pi children on a local Worker run their tools in the orchestrator process. Their grep and find tools inherit the orchestrator’s environment (#181).