/** * subagent.ts — Subagent class: identity, lifecycle status, and per-subagent behavior. * * Status/stats are delegated to the SubagentState value object; each run's abort * lever and listener handles to a SubagentRun; workspace prepare/dispose to * WorkspaceBracket. * Behavior (abort, steer buffering) lives here rather than on SubagentManager. */ import { debugLog } from "#src/debug"; import type { Model } from "@earendil-works/pi-ai"; import type { AgentDefinition } from "#src/agents/definition"; import type { ChildShapingSettings } from "#src/agents/launch-preparer"; import type { AgentSessionEvent, ToolDefinition, } from "@earendil-works/pi-coding-agent"; import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session"; import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot"; import { subscribeSubagentObserver } from "#src/lifecycle/record-observer"; import { type CarrierClaim, type RunKind, type RunOutcome, type RunOutcomeInit, SubagentRun, } from "#src/lifecycle/subagent-run"; import type { SubagentSession, TurnLoopResult, } from "#src/lifecycle/subagent-session"; import { SubagentState, type SubagentStatus, } from "#src/lifecycle/subagent-state"; import type { TurnBudget } from "#src/lifecycle/turn-limits"; import type { LifetimeUsage } from "#src/lifecycle/usage"; import type { WorkspaceProvider } from "#src/lifecycle/workspace"; import { WorkspaceBracket } from "#src/lifecycle/workspace-bracket"; import type { ExactModelIdentity } from "#src/models/identity"; import type { ModelSelection } from "#src/models/selection"; import { defined } from "#src/optional"; import type { ThinkingDecision } from "#src/thinking/resolve"; import type { RunConfig } from "#src/runtime"; import type { TurnPolicy } from "#src/lifecycle/turn-limits"; import type { CompactionInfo, ParentSessionInfo, SessionMessage, SubagentType, ThinkingLevel, } from "#src/types"; /** Per-subagent lifecycle observer — created by SubagentManager for each spawn. */ export interface SubagentLifecycleObserver { /** * Fires once a run is under way: the initial run after it transitions to * running, a resume after the record is rewound, so a subscriber reading it * sees the run that just started rather than the outcome of the one it * replaced. */ onRunStarted?(agent: Subagent, kind: RunKind): void; /** Fires once the session is created — the subagent's subagentSession is now available. */ onSessionCreated?(agent: Subagent): void; /** Fires once when a run reaches a terminal state: it completed, failed, or was stopped. */ onRunFinished?(agent: Subagent, kind: RunKind): void; /** Fires when the running agent sends its parent a mid-run message. */ onUpdateSent?(agent: Subagent, message: string): void; /** * Fires when a teardown after the agent's result was delivered reported where * its work went. Not fired for a failed run or resume: those reach a terminal * notification of their own, which carries the notice. */ onWorkspaceNotice?(agent: Subagent, notice: string): void; /** Fires on compaction events during the run. */ onCompacted?(agent: Subagent, info: CompactionInfo): void; } export type { SubagentStatus } from "#src/lifecycle/subagent-state"; /** * Why a resume of an agent would be refused. * * One vocabulary for a fact three record-level conditions used to answer * separately: the resume door decided it from `isSessionReady()`, * `sessionReleased`, and `workspaceDisposed`, while the result carriers never * consulted any of them and advertised the resume regardless. * * `still-running` and `resume-queued` are the transient members: each is a * refusal of *now* rather than of ever, clearing when the run in question * settles, and the carriers word them accordingly. `resume-queued` names a * resume waiting for a concurrency slot, which has not started running. */ export type ResumeRefusal = | "still-running" | "resume-queued" | "no-session" | "session-released" | "workspace-disposed"; /** * The result of a steer attempt. `Subagent.steer` owns the non-running * rejection rule and reports it here, so coordinators switch on the outcome * instead of pre-checking status (tell by id, with outcomes). */ export type SteerOutcome = | { kind: "delivered" } | { kind: "buffered" } | { kind: "rejected"; status: SubagentStatus }; /** * The execution machinery a Subagent needs to run. A single mandatory * collaborator: production (SubagentManager.spawn) always supplies it, so run() * needs no "not configured" guards. The genuinely-optional behavior knobs stay * optional; the four inputs run() cannot proceed without are required. */ export interface SubagentExecution { /** Assembly factory that produces a born-complete SubagentSession. */ createSubagentSession: ( params: CreateSubagentSessionParams, ) => Promise; /** Immutable spawn-time parent snapshot handed to the session factory. */ snapshot: ParentSnapshot; /** Initial prompt for the turn loop. */ prompt: string; /** Parent working directory handed to a workspace provider's prepare(). */ baseCwd: string; observer?: SubagentLifecycleObserver; getRunConfig?: () => RunConfig; /** Resolves the registered workspace provider (if any) at run-start. */ getWorkspaceProvider?: () => WorkspaceProvider | undefined; /** Exact model choice captured by preparation; materialized at child creation. */ modelIdentity?: ExactModelIdentity; /** The definition selected by preparation; execution must not reload it. */ definition?: AgentDefinition; /** * The child-shaping settings captured with the launch, when the door * prepared one. Preferred over the live run config and the live * prompt-inheritance resolver at execution. */ childShaping?: ChildShapingSettings; /** * The run's turn ceiling, resolved once at the door. * * Captured with the launch rather than read at run time, so a later change * to the operator's default budget cannot alter accepted work. */ turnPolicy: TurnPolicy; thinkingLevel?: ThinkingLevel; /** * How the resolved model was chosen, and why — the decision behind a launch. */ modelSelection?: Pick; /** * The reasoning decision behind the level the child runs at: whether it * applied, where it came from, whether it was clamped, and why. */ thinkingDecision?: Pick< ThinkingDecision, "applicable" | "source" | "clamped" | "reason" >; parentSession?: ParentSessionInfo; signal?: AbortSignal; /** * The tools the child was given, resolved when the request was made. * * The manager resolves the effective selection once, so the child runs with * exactly the tools the runtime classified it by. Absent, the session * factory resolves the definition's selection itself. */ toolNames?: ReadonlyArray; } /** * Runs a run's thunk, now or once admitted; a concurrency limiter's `schedule` * is one. The returned promise settles with the thunk. */ export type RunScheduler = (thunk: () => Promise) => Promise; /** What one resume of a subagent is started with. */ export type ResumeRunOptions = { /** * Whether the resume runs in the background. The front door that starts it * decides, so a resume need not run in the mode the agent was spawned in. */ background: boolean; /** Cancels the resumed run through its own lever, ending it like `abort()` does. */ signal?: AbortSignal; /** * The caller will deliver this outcome to the parent, so nothing announces * it: the resume's run is born claimed. Omitted, the resumed outcome is * announced like any other completion. */ claimOutcome?: boolean; }; export type SubagentInit = { // Identity id: string; type: SubagentType; description: string; /** The mode SubagentManager resolved for this spawn: the initial run's, and whether it queues. */ isBackground: boolean; /** * The generation of the session that asked for this child. * * Recorded rather than derived, because the session that accepted the * request is the only thing that may continue it: a later session holding * this id must not resume it, and the manager needs to know which of its * records to cancel when that session ends. */ ownerGeneration: number; /** Execution machinery — always supplied; construct-complete, no test fallbacks. */ execution: SubagentExecution; /** Lifecycle status and metrics. Defaults to a fresh queued state. */ state?: SubagentState; /** Seeds for the initial run's outcome; production never passes them. */ outcome?: RunOutcomeInit; }; export class Subagent { // Identity — set once at construction readonly id: string; readonly type: SubagentType; readonly description: string; /** * The generation of the accepting session, kept as a plain number: the * manager compares it against its own counter and nothing else reads it. */ readonly ownerGeneration: number; // Lifecycle status and metrics — owned by a private value object; getters and // mutation methods below delegate to it one line. private readonly state: SubagentState; get status(): SubagentStatus { return this.currentRun.status; } get result(): string | undefined { return this.currentRun.result; } get error(): string | undefined { return this.currentRun.error; } get stoppedWhileQueued(): boolean { return this.currentRun.stoppedWhileQueued; } get startedAt(): number { return this.currentRun.startedAt; } get completedAt(): number | undefined { return this.currentRun.completedAt; } get consumedAt(): number | undefined { return this.currentRun.consumedAt; } get consumed(): boolean { return this.currentRun.consumed; } get claimed(): boolean { return this.currentRun.claimed; } get pendingQuestion(): string | undefined { return this.currentRun.pendingQuestion; } get runUpdates(): ReadonlyArray { return this.currentRun.runUpdates; } /** * What the workspace reported at a teardown with no result text to fold it * into — the provider's own wording for where the child's work ended up. * * Undefined for a run that completed normally: there the addendum rides the * result, and duplicating it here would have every carrier report it twice. */ get workspaceNotice(): string | undefined { return this.currentRun.workspaceNotice; } get turnBudget(): TurnBudget | undefined { return this.currentRun.turnBudget; } get toolUses(): number { return this.state.toolUses; } get lifetimeUsage(): Readonly { return this.state.lifetimeUsage; } get compactionCount(): number { return this.state.compactionCount; } get activeTools(): ReadonlyMap { return this.state.activeTools; } get responseText(): string { return this.state.responseText; } get lastProgressAt(): number { return this.state.lastProgressAt; } isActive(): boolean { return this.currentRun.isActive(); } isTerminalError(): boolean { return this.currentRun.isTerminalError(); } isRunning(): boolean { return this.currentRun.isRunning(); } canBeSteered(): boolean { return this.currentRun.canBeSteered(); } /** * The run in flight, or the last one to end. The initial run is born with the * agent, so a queued agent already has the run it is waiting to start. */ private currentRun: SubagentRun; /** The run in flight, or the last one to end, as an outcome carrier reads it. */ get latestRun(): RunOutcome { return this.currentRun; } /** * Whether this agent's current run is in the background: the run in flight, * or the last one to end. The initial run takes the spawn's mode. * * Mode is a fact about a run, not the agent, so a resume can change it. The * record answers rather than a consumer re-deriving it from a per-call * display snapshot only the tool door ever built (#724). */ get isBackground(): boolean { return this.currentRun.inBackground; } private _promise?: Promise; /** A queued run's early-settlement lever, paired with the exact run it owns. */ private queuedCancellation: | { readonly run: SubagentRun; readonly resolve: () => void; readonly detach: () => void; } | undefined; /** Handle on the agent's current run — the initial run, or the live resume that replaced it. */ get promise(): Promise | undefined { return this._promise; } private readonly execution: SubagentExecution; private readonly workspaceBracket: WorkspaceBracket; // A backing field, not an optional property: releaseSession() clears it, and // "cleared" is a value this class holds between calls. private subagentSession: SubagentSession | undefined; /** * The single writer of {@link subagentSession}: the run that created one * attaches it, releaseSession() passes `undefined`. * * Public because it is also how a passive fixture — a record that must read * as session-ready without ever running — takes the same path production * takes, rather than reaching into a private field. */ attachSession(session: SubagentSession | undefined): void { this.subagentSession = session; } // Retained after releaseSession() disposes the heavy session, so outputFile // (transcript pointer) survives and the resume path can tell "released" from // "never had a session." private _releasedOutputFile: string | undefined; private _releasedModel: Model | undefined; private _releasedThinkingLevel: ThinkingLevel | undefined; private _sessionReleased = false; /** True once releaseSession() has freed a live session (distinct from never having had one). */ get sessionReleased(): boolean { return this._sessionReleased; } /** * True once this agent's provider-supplied workspace has been torn down. * False for an agent that never had one, so it names the resume the session * would re-enter a removed directory for — not merely a session with a * workspace provider registered. */ get workspaceDisposed(): boolean { return this.workspaceBracket.wasDisposed(); } // Steer buffer — messages queued before the session is ready private _pendingSteers: string[] = []; /** Number of steer messages waiting to be delivered. */ get pendingSteerCount(): number { return this._pendingSteers.length; } /** * Path to the agent's session JSONL file, or undefined if not yet available. * Falls back to the path captured at releaseSession() once the live session is gone. */ get outputFile(): string | undefined { return this.subagentSession?.outputFile ?? this._releasedOutputFile; } /** * The model this agent runs: the live session's (so a mid-run switch shows), * then the one captured at releaseSession(). Before session creation the * accepted launch owns only an identity, not a live SDK model object. */ get model(): Model | undefined { return this.subagentSession?.model ?? this._releasedModel; } /** Durable model identity, available before the SDK session materializes it. */ get modelIdentity(): ExactModelIdentity | undefined { const model = this.model; return model ? { provider: model.provider, id: model.id } : this.execution.modelIdentity; } /** The thinking level this agent runs at, resolved in the same order as `model`. */ get thinkingLevel(): ThinkingLevel | undefined { return ( this.subagentSession?.thinkingLevel ?? this._releasedThinkingLevel ?? this.execution.thinkingLevel ); } /** How the resolved model was chosen, and why, for the launch this run was born from. */ get modelSelection(): Pick | undefined { return this.execution.modelSelection; } /** The reasoning decision behind the level the child runs at, for this launch. */ get thinkingDecision(): ThinkingDecision | undefined { return this.execution.thinkingDecision; } /** The tool call ID that spawned this background agent, if any. */ get toolCallId(): string | undefined { return this.execution.parentSession?.toolCallId; } /** Returns true when a SubagentSession is available (session is ready). */ isSessionReady(): boolean { return this.subagentSession != null; } /** * Why a resume of this agent would be refused, or undefined when one would be * accepted. * * The conditions are checked in the order the resume door checks them, so a * record whose session was released *and* whose workspace is gone reports the * session — the door's message for it names the retention window, which is * the fact that explains both. A live run outranks all of them: nothing about * a settled record is decided yet. So does a resume still waiting for its * slot, which would otherwise read resumable a second time. * * A getter rather than a predicate method because the result carriers read it * as a field: `OutcomeAddenda` and `AgentReport` both declare it, and a live * record satisfies them structurally only if it is a property. */ get resumeRefusal(): ResumeRefusal | undefined { // Before the session check: a run transitions to running before it creates // its session, and "still running" describes that record better than "no // session" does. A queued agent is not running and keeps the no-session // answer, which is the truth about it. if (this.isRunning()) return "still-running"; // A resume waiting for its slot holds the live session it will continue, so it // is checked before the session; a queued initial run has none to hold. if (this.status === "queued" && this.currentRun.kind === "resume") return "resume-queued"; if (!this.isSessionReady()) return this._sessionReleased ? "session-released" : "no-session"; if (this.workspaceDisposed) return "workspace-disposed"; return undefined; } /** * Steer a running agent, owning the non-running rejection rule. * Returns a `rejected` outcome (with the observed status) when the agent is * not running, a `buffered` outcome when the session is not yet ready, or a * `delivered` outcome once the message reaches the session. */ async steer(message: string): Promise { if (!this.canBeSteered()) { return { kind: "rejected", status: this.status }; } if (!this.subagentSession) { this.queueSteer(message); return { kind: "buffered" }; } await this.subagentSession.steer(message); return { kind: "delivered" }; } /** Return the session conversation as formatted text, or undefined if no session. */ getConversation(): string | undefined { return this.subagentSession?.getConversation(); } /** Return the session context window utilization (0-100), or null if unavailable. */ getContextPercent(): number | null { return this.subagentSession?.getContextPercent() ?? null; } /** * Subscribe to session events for live updates (e.g., conversation viewer). * Returns an unsubscribe function, or undefined if no session is available. */ subscribeToUpdates( fn: (event: AgentSessionEvent) => void, ): (() => void) | undefined { return this.subagentSession?.subscribe(fn); } /** The session's message history, or an empty array if no session. */ get messages(): ReadonlyArray { return this.subagentSession?.messages ?? []; } /** The session's message history typed for Pi's session-rendering machinery, or empty if no session. */ get agentMessages(): ReadonlyArray { return this.subagentSession?.agentMessages ?? []; } /** Resolve a registered tool definition by name, or undefined if no session. */ getToolDefinition(name: string): ToolDefinition | undefined { return this.subagentSession?.getToolDefinition(name); } constructor(init: SubagentInit) { // Identity this.id = init.id; this.type = init.type; this.description = init.description; this.ownerGeneration = init.ownerGeneration; this.currentRun = new SubagentRun( "initial", init.isBackground, init.outcome, ); // Lifecycle status and metrics — fresh queued state unless one is supplied this.state = init.state ?? new SubagentState(); // Execution machinery — a single mandatory collaborator this.execution = init.execution; // Per-run lifecycle collaborators this.workspaceBracket = new WorkspaceBracket( this.execution.getWorkspaceProvider ?? (() => undefined), ); } /** * Execute the full agent lifecycle: workspace preparation, session creation * via the factory, observer wiring, the turn loop, workspace disposal, and * status transitions. * * Execution is supplied at construction (mandatory), so run() needs no * "not configured" guards. The returned promise always resolves (errors are * captured internally). */ private sessionDisposalRequested = false; async run(): Promise { const run = this.currentRun; this.startRun(run, this.execution.signal); this.execution.observer?.onRunStarted?.(this, run.kind); // Cancelled before it started: end here, before the workspace and the child // session cost anything. if (run.aborted) { this.endBeforeStart(run); return; } // Guard the await so the no-provider path stays synchronous, preserving // the original run() timing: the factory is called in the same turn as // spawn() when no workspace provider is registered. let cwd: string | undefined; if (this.workspaceBracket.hasProvider()) { try { cwd = await this.workspaceBracket.prepare({ agentId: this.id, agentType: this.type, baseCwd: this.execution.baseCwd, }); } catch (err) { // Nothing was prepared and no question asked yet, so failing the run // disposes nothing and drops nothing. this.fail(run, err); return; } } // Shutdown may finish while a provider is still preparing. The arriving // workspace still belongs to this record, but must never reach a child. if (this.sessionDisposalRequested) { this.disposeHeldWorkspace(); // The run is stopped, not left reading "running" on a disposed record. this.endBeforeStart(run); return; } // A captured decision wins over the live read: a queued child keeps the // update policy in force when it was asked for (story 56). Only internal // callers that never went through preparation fall back to the live config. const runConfig = this.execution.childShaping ? { midRunUpdates: this.execution.childShaping.midRunUpdates } : this.execution.getRunConfig?.(); let session: SubagentSession; try { session = await this.execution.createSubagentSession( defined({ snapshot: this.execution.snapshot, type: this.type, askParent: (question: string) => { this.currentRun.setPendingQuestion(question); }, cwd, parentSession: this.execution.parentSession, modelIdentity: this.execution.modelIdentity, thinkingLevel: this.execution.thinkingLevel, // A run whose config suppresses announcements has nothing to announce // through, so the child gets no channel at all rather than a dead one. notifyParent: this.canSendUpdates(runConfig) ? (message: string) => { this.announceUpdate(message); } : undefined, turnPolicy: this.execution.turnPolicy, definition: this.execution.definition, childShaping: this.execution.childShaping, toolNames: this.execution.toolNames, }), ); if (this.sessionDisposalRequested) { await disposeQuietly(session, "late child session dispose"); this.endBeforeStart(run); return; } this.attachSession(session); } catch (err) { // The factory disposed its own session on a post-creation failure. this.fail(run, err); return; } this.flushPendingSteers(); this.execution.observer?.onSessionCreated?.(this); await this.runOnSession(run, session, this.execution.prompt); } /** * The body every run shares once its session exists: subscribe the record * observer, drive the session's turn loop under the run's lever, then end * the run through its terminal transition. Always resolves. * * Nothing here awaits before the turn loop starts, so a resume reaches its * loop in the same turn as resume(). */ private async runOnSession( run: SubagentRun, session: SubagentSession, prompt: string, ): Promise { run.attachObserver( subscribeSubagentObserver(session, this.state, { onCompact: (info) => this.execution.observer?.onCompacted?.(this, info), }), ); try { this.complete( run, await session.runTurnLoop(prompt, { signal: run.signal, onTurnBudget: (budget) => { run.setTurnBudget(budget); }, }), ); } catch (err) { this.fail(run, err); } } /** * Whether this run gets the mid-run update channel. * * The operator's setting is the whole gate: where an update lands is decided * per message by announceUpdate(), not per child at session creation, so no * child has to be refused the tool for a condition that can change mid-run. * Defaults to on when no run config is supplied, matching the setting. */ private canSendUpdates(runConfig: RunConfig | undefined): boolean { return runConfig?.midRunUpdates ?? true; } /** * Record an update the child sent, then offer it to the announcement channel. * * Every update joins the run's ledger, whoever ends up delivering it: this * side cannot know whether an announcement will reach the parent in time, or * at all, so it records unconditionally and lets the channel that delivers * mark what it took. What the ledger still owes is what an outcome carrier * renders alongside the result. * * The observer is told either way: an update is a fact about the run, like * the terminal transitions, so the lifecycle event fires regardless of which * carrier delivers it. */ private announceUpdate(message: string): void { // The session outlives this run, so an update joins whichever run is under // way when the child sends it. this.currentRun.recordUpdate(message); this.execution.observer?.onUpdateSent?.(this, message); } /** * Start execution without going through the concurrency limiter. * * Production admission always goes through `scheduleVia`, so this is the * direct-entry seam tests use to drive a record without a limiter; it is not * a bypass any caller can reach. * * Stores the run promise so it is awaitable via the `promise` getter. */ start(): void { this._promise = this.guardedRun(); } /** * Schedule execution through an external concurrency scheduler (the limiter). * Captures the scheduler's promise eagerly, so a still-queued agent is * awaitable via the `promise` getter from spawn — not only once its slot opens. * The guard in guardedRun() makes an abort-while-queued run a no-op when the * slot finally frees. */ scheduleVia(schedule: RunScheduler): void { const run = this.currentRun; this._promise = this.scheduleQueued( run, schedule, () => this.guardedRun(), this.execution.signal, ); } /** * Keep the scheduler's slot ownership separate from a queued run's promise: * stopping one run settles its own waiter immediately, while the scheduler * task remains queued and cannot release another run's capacity. */ private scheduleQueued( run: SubagentRun, schedule: RunScheduler, task: () => Promise, signal?: AbortSignal, ): Promise { const cancelled = Promise.withResolvers(); const stop = (): void => { // A stale signal from an older run must not stop or settle a newer one. if (this.currentRun !== run || run.status !== "queued") return; this.stopQueued(); }; const detach = (): void => { signal?.removeEventListener("abort", stop); }; this.queuedCancellation = { run, resolve: cancelled.resolve, detach }; // An already-fired signal is not this path's business: the run's own start // reads it and hands the turn loop a fired lever, which the existing // settled-before-it-started behaviour depends on. if (!signal?.aborted) signal?.addEventListener("abort", stop, { once: true }); const scheduled = schedule(() => { this.clearQueuedCancellation(run); return task(); }); // Ending this queued run does not settle the scheduler's task or release // another child's slot. Its stopped thunk is skipped when admitted. return Promise.race([scheduled, cancelled.promise]).finally(() => { this.clearQueuedCancellation(run); }); } /** Clear only the cancellation handle belonging to this exact queued run. */ private clearQueuedCancellation(run: SubagentRun): void { const pending = this.queuedCancellation; if (!pending || pending.run !== run) return; this.queuedCancellation = undefined; pending.detach(); } /** Settle only the cancellation waiter belonging to this exact queued run. */ private resolveQueuedCancellation(run: SubagentRun): void { const pending = this.queuedCancellation; if (!pending || pending.run !== run) return; this.queuedCancellation = undefined; pending.detach(); pending.resolve(); } /** * Run unless the agent left the active set before its slot opened * (e.g. abort-while-queued): a non-queued, non-running status resolves * immediately without running. */ private guardedRun(): Promise { if (!this.isActive()) return Promise.resolve(); return this.run(); } /** * Wait until this agent's current run settles. * Resolves immediately when the agent is no longer active or has no run * handle. A queued agent is awaitable because scheduleVia() captures the * limiter promise at spawn, so the wait spans both the queue slot and the * run that follows it. * * When `signal` fires the wait ends early and the agent keeps running: this * is a query, so interrupting it must not cancel the work. Cancelling the * work on a parent interrupt is InterruptHandler's separate decision. * * Returns the run the wait began on, whatever happened to it: still active * when the wait was interrupted (or there was nothing to wait on), settled * otherwise. A resume can start between that run settling and the waiter * continuing (a consumer resuming from the completion event does); the * returned run is then `replaced`, and still reports what it ended with. */ async waitUntilSettled(signal: AbortSignal): Promise { const run = this.currentRun; const handle = this._promise; if (handle && run.isActive()) await settleOrAbort(handle, signal); return run; } /** * Resume an existing session with a new prompt, managing the observer * subscription lifecycle internally (same wiring as run()). * * Requires an existing SubagentSession (set when the original run created it). * The returned promise always resolves (errors are captured internally) and is * published as the `promise` getter, so waiters track the resume rather than * the settled handle of the original run. * * A resume is a run of its own, like the initial run: it is born with its own * lever, so a record aborted on its original run does not resume under a spent * one. A caller's `signal` is wired to abort() rather than forwarded to the * turn loop, so both levers stop the same run and both leave the record * reading `stopped`. * * The resume's run is born at once, queued; `schedule` decides when it starts. * The default starts it now. A concurrency limiter may hold it until a slot * frees, and a run stopped while it waits never starts. */ resume( prompt: string, options: ResumeRunOptions, schedule: RunScheduler = (thunk) => thunk(), ): Promise { const subagentSession = this.subagentSession; if (!subagentSession) { // Rejection, not a throw: this method is not async, and a synchronous // throw would escape a caller's `.rejects` assertion. return Promise.reject( new Error("Subagent not configured for resume — missing session"), ); } const run = this.beginResume(options); // Checked on the run, not the record: a run stopped while it waited leaves // the record resumable, and a later resume may already have replaced it. this._promise = this.scheduleQueued( run, schedule, () => run.isActive() ? this.startResumedRun(run, subagentSession, prompt, options.signal) : Promise.resolve(), options.signal, ); return this._promise; } /** * A resume's birth: it replaces the current run with a new queued run of its * own, claimed when the caller asks, so a carrier's claim is in place before * the run can start. */ private beginResume(options: ResumeRunOptions): SubagentRun { // The resume is a new run, so nothing the previous run held carries over: // its handles are released, and it is marked replaced for any carrier that // still holds it. this.currentRun.release(); this.currentRun.markReplaced(); const run = new SubagentRun("resume", options.background); if (options.claimOutcome) run.claim(); this.currentRun = run; return run; } /** * A resume's start, then the shared run body. Always resolves. * * Synchronous up to the turn loop, so the record reads running, and the * started notification has fired, by the time this returns its promise. */ private async startResumedRun( run: SubagentRun, session: SubagentSession, prompt: string, signal: AbortSignal | undefined, ): Promise { this.startRun(run, signal); // Cancelled before it started: the turn loop takes no turn on a fired lever, // so the resume ends like one aborted mid-run. if (run.aborted) this.abort(); this.execution.observer?.onRunStarted?.(this, run.kind); await this.runOnSession(run, session, prompt); } /** * Start a run: it transitions to running and wires the caller's signal to * abort(). A run's start counts as progress, so a run that has not yet * emitted a session event reads as just started rather than stalled. */ private startRun( run: SubagentRun, callerSignal: AbortSignal | undefined, ): void { const startedAt = Date.now(); run.start(startedAt, callerSignal, () => this.abort()); this.state.markProgress(startedAt); } /** * Transition to completed state. * Always sets result and completedAt (??=). Only changes status if not stopped. */ markCompleted(result: string, completedAt?: number): void { this.currentRun.markCompleted(result, completedAt); } /** Transition to stopped state. Always valid — no guard. */ markStopped(completedAt?: number): void { this.currentRun.markStopped(completedAt); } /** Record the parent collected the current run's outcome. Idempotent. */ markConsumed(at?: number): void { this.currentRun.markConsumed(at); } /** The announcement channel delivered this update; no outcome carrier repeats it. */ markUpdateAnnounced(message: string): void { this.currentRun.markUpdateAnnounced(message); } /** * A carrier has committed to delivering the current run's outcome; nothing * else announces it. The returned handle lets a carrier that abandons its * commitment (a waiter whose parent turn was interrupted) drop only its own. */ claim(): CarrierClaim { return this.currentRun.claim(); } /** * Stop a run still waiting for its slot, then notify like every other * terminal transition. No listener release: a run wires nothing until it * starts. The run leaves the active set here, so the thunk the limiter runs * when the slot finally frees no-ops on its guard — one notification. */ stopQueued(): void { const run = this.currentRun; run.stopQueued(); this.resolveQueuedCancellation(run); // The queued run is the agent's initial run or a resume waiting for its slot, // and it ends on that run's own path. this.execution.observer?.onRunFinished?.(this, run.kind); } /** * Abort a running agent: pull the current run's lever and transition to * stopped. Each run and resume is born with its own lever, so it always * reaches whichever run is in flight. * Returns false if the agent is not running. * A still-queued agent is stopped via stopQueued(); its scheduled thunk * then no-ops on the queued-status guard. */ abort(): boolean { if (!this.isRunning()) return false; this.currentRun.abort(); this.currentRun.markStopped(); return true; } /** * Buffer a steer message for delivery once the session is ready. * Called internally from steer() before the session is ready. */ private queueSteer(message: string): void { this._pendingSteers.push(message); } /** * Flush all buffered steer messages to the session and clear the buffer. * Called once the session is available (inside run()). */ private flushPendingSteers(): void { for (const msg of this._pendingSteers) { this.subagentSession?.steer(msg).catch(() => {}); } this._pendingSteers = []; } /** Complete a run: release its handles, dispose or hold the workspace, status transition, notify observer. */ private complete(run: SubagentRun, result: TurnLoopResult): void { run.release(); // The harness ending the run at its turn limit is the one way a run that // returned is not complete. const exhausted = result.turnBudget.phase === "exhausted"; const finalStatus: SubagentStatus = exhausted ? "aborted" : "completed"; // A completed child that declared a question is inviting a resume, so its // workspace stays live for the resume to re-enter, including a run that // wrapped up at its turn limit: the resume gets a fresh budget to act on the // answer. Every other outcome ends the run for good and tears it down here. // The question was recorded by ask_parent during the run, so it is already // on the record here. const holdForResume = finalStatus === "completed" && run.pendingQuestion !== undefined; const finalResult = holdForResume ? result.responseText : result.responseText + this.workspaceBracket.dispose({ status: finalStatus, description: this.description, }); // The run this completion ends, which a resume may already have replaced. if (exhausted) run.markAborted(finalResult); else run.markCompleted(finalResult); this.execution.observer?.onRunFinished?.(this, run.kind); } /** * Dispose the wrapped session, firing the `disposed` lifecycle event. * Resolves once the child's extensions have shut down; a failing teardown is * swallowed so the caller's remaining cleanup still runs. */ async disposeSession(): Promise { this.sessionDisposalRequested = true; this.disposeHeldWorkspace(); await disposeQuietly(this.subagentSession, "child session dispose"); } /** * Release the heavy session while keeping the record: capture the transcript * pointer, dispose the session (firing `disposed`), clear it, and mark released. * A no-op once the session is gone — the retention sweep may call it repeatedly. * * The record's own state is updated before the teardown is awaited, so a sweep * tick arriving mid-teardown sees a released record rather than starting a * second one. */ async releaseSession(): Promise { const session = this.subagentSession; if (!session) return; this.disposeHeldWorkspace(); this._releasedOutputFile = session.outputFile; this._releasedModel = session.model; this._releasedThinkingLevel = session.thinkingLevel; this.attachSession(undefined); this._sessionReleased = true; await disposeQuietly(session, "child session release"); } /** * End a run whose caller cancelled it before it started: stopped, with no * workspace prepared and no session created, then notify like every other * terminal transition. */ private endBeforeStart(run: SubagentRun): void { this.abort(); run.release(); this.execution.observer?.onRunFinished?.(this, run.kind); } /** Fail a run: mark error, release its handles, best-effort workspace dispose, notify observer. */ private fail(run: SubagentRun, err: unknown): void { run.markError(err); // Drop a question the child recorded before the run failed: every carrier // renders a pending question as "answer by resuming me", which is not the // right next action after a failure, and the failure text already tells the // parent to look. An aborted run keeps its question: it reached a terminal // transition with an outcome to report. run.setPendingQuestion(undefined); run.release(); this.disposeWorkspaceQuietly(run, "error"); this.execution.observer?.onRunFinished?.(this, run.kind); } /** * Tear down a workspace still held once the agent's run is over — the child * asked a question nobody answered, and its session is now going away. * * A no-op while the agent is active: an in-flight run's own terminal * transition owns disposal, and pulling the directory out from under a live * child is not this path's business. */ private disposeHeldWorkspace(): void { if (this.isActive()) return; // Announce what *this* disposal produced, not what the record holds: both // release and teardown reach here, and the second finds nothing to dispose. const notice = this.disposeWorkspaceQuietly(this.currentRun, this.status); if (notice) this.execution.observer?.onWorkspaceNotice?.(this, notice); } /** * Dispose the workspace without letting a provider failure escape, recording * what it reported and handing that back. * * These are the paths with no result text left to fold the addendum into, so * it is kept on `run`, the run whose end produced it, for the carriers to * report instead. The value is * returned as well as stored, so a caller can tell an addendum this call * produced from one an earlier disposal already recorded. */ private disposeWorkspaceQuietly( run: SubagentRun, status: SubagentStatus, ): string { try { const notice = this.workspaceBracket.dispose({ status, description: this.description, }); if (notice) run.setWorkspaceNotice(notice); return notice; } catch (err) { debugLog(`workspace dispose (${status})`, err); return ""; } } } /** * Tear a child session down without letting its failure escape. * Both teardown paths are cleanup: a child that will not shut down cleanly must * not stop the caller from finishing the rest of its own cleanup. */ async function disposeQuietly( session: SubagentSession | undefined, context: string, ): Promise { try { await session?.dispose(); } catch (err) { debugLog(context, err); } } /** * Settle with `run`, or early when `signal` fires — whichever comes first. * The inner controller is the listener-cleanup channel: it detaches the abort * listener whichever branch wins, so repeated waits within one parent turn do * not accumulate listeners on that turn's signal. */ function settleOrAbort(run: Promise, signal: AbortSignal): Promise { if (signal.aborted) return Promise.resolve(); const detach = new AbortController(); const interrupted = new Promise((resolve) => { signal.addEventListener( "abort", () => { resolve(); }, { once: true, signal: detach.signal }, ); }); return Promise.race([run, interrupted]).finally(() => { detach.abort(); }); }