blob: 20d90e87666c5478416f3d5d0d755b2a4e90c32f [file]
/**
* SessionManager — the public Runtime API.
*
* Ties together:
* SessionStore (storage) — JSONL persistence
* AgentBackend (AiSdkBackend etc) — SDK adapter
* PermissionEngine — policy + parking
*
* `SessionStore` comes from `@maka/storage`; its public interface owns
* persistence and same-session serialization semantics.
*/
import { createHash } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
import type {
SessionEvent,
CompleteEvent,
TextDeltaEvent,
ErrorEvent,
AbortEvent,
PermissionDecisionAckEvent,
PermissionRequestEvent,
QueueEnqueueOutcome,
ShellRunUpdate,
} from '@maka/core/events';
import { messageContentsEqual } from '@maka/core/events';
import type {
SessionHeader,
SessionBlockedReason,
SessionStatus,
SessionSummary,
StoredMessage,
SubagentSessionParent,
TurnRecord,
UserMessage,
PermissionDecisionMessage,
SystemNoteMessage,
BackendKind,
} from '@maka/core/session';
import type {
AgentSpec,
ChildAgentTurnInput,
CreateSessionInput,
BranchFromTurnInput,
RegenerateTurnInput,
ReviseBeforeTurnInput,
UserMessageInput,
SessionListFilter,
} from '@maka/core/runtime-inputs';
import type { PermissionResponse } from '@maka/core/permission';
import type { UserQuestionResponse } from '@maka/core/user-question';
import type { PermissionMode } from '@maka/core/permission';
import type { CollaborationMode } from '@maka/core/collaboration';
import type { OrchestrationMode } from '@maka/core/orchestration';
import type {
ApprovePlanProposalInput,
PlanMutationResult,
PlanSessionState,
PlanStore,
} from '@maka/core/plan';
import {
DEFAULT_SESSION_NAME,
DEEP_RESEARCH_SESSION_LABEL,
SUBAGENT_SESSION_RUNTIME_SCHEMA_VERSION,
SUBAGENT_SESSION_SPAWN_SCHEMA_VERSION,
childSessionsForParent,
decodeAgentGraphIntentClaim,
failureClassFromCompleteStopReason,
isDeepResearchSession,
isPermissionModeWithinCeiling,
isSessionInlineRun,
subagentSessionRuntimeSummary,
} from '@maka/core';
import type {
AgentGraphIntentClaim,
AgentGraphIntentClaimStore,
AgentGraphOperatorProvisionRequest,
AgentGraphOperatorProvisionResult,
AgentGraphProvisionedEdge,
AgentGraphScheduleUpdateSource,
AgentRunEvent,
AgentRunHeader,
AgentRunStore,
ArtifactRecord,
RootExecutionDescriptor,
RuntimeEvent,
RuntimeEventStore,
ToolBoundaryProtocol,
SubagentWorkspaceBinding,
SubagentWorktreeExecutor,
} from '@maka/core';
import { AGENT_GRAPH_OPERATOR_PROVISION_SCHEMA_VERSION } from '@maka/core';
import { type RuntimeEventTerminalFact } from './runtime-event-read-model.js';
import {
RuntimeReadModel,
RuntimeReadModelError,
type RuntimeReadModelSessionView,
} from './runtime-read-model.js';
import { inspectAgentRunReadModel, type AgentRunInspectModel } from './agent-run-inspect.js';
import { firstRuntimeRepairRunId, RuntimeLedgerRepair } from './runtime-ledger-repair.js';
import {
buildRecoveredTerminalRuntimeEvent,
classifyTerminalRuntimeLedger,
commitTerminalRunWithRuntimeFact,
effectiveRunHeaderFromTerminalFact,
terminalRunStatusFromRuntimeEvent,
} from './terminal-run-commit.js';
import type { AgentBackend, BackendStopMode } from '@maka/core/backend-types';
import type { AgentTeamExecutionContext, MakaTool } from './tool-runtime.js';
import type { RunTraceRecorder } from './run-trace.js';
import type {
ProviderRequestAttemptRecord,
ProviderRequestCaptureLedgerRecord,
} from './provider-request-telemetry.js';
import type { ShellRunProcessManager } from './shell-run-manager.js';
import type { ActiveFullCompactBlock } from './active-full-compact.js';
import type { SemanticCompactBlock } from './semantic-compact.js';
import type { HistoryCompactCheckpoint } from './history-compact-checkpoint.js';
import type { AgentRunLineage, RuntimeContinuationFailpoint } from './agent-run.js';
import { classifyAgentRunRecovery, type AgentRunRecoveryDecision } from './agent-run-recovery.js';
import type { InvocationResult, InvocationSource } from './invocation-context.js';
import {
isRuntimeHostedRootAuthority,
RuntimeMessageAuthorityInvariantError,
type RuntimeHostedRootExecutionInput,
type RuntimeMessageAuthority,
type RuntimeMessageRunIdentity,
} from './message-authority.js';
import {
RuntimeInteractionInvariantError,
type CanonicalPermissionOutcomeReader,
type RuntimeInteractionAuthority,
} from './interaction-authority.js';
import {
RuntimeKernel,
type BackendActivationBoundary,
type RuntimeExecutionClaim,
type RuntimeKernelLike,
type TurnStartOptions,
} from './runtime-kernel.js';
import { fallbackSessionTitle, sessionTitleSource } from './session-title.js';
import type { HistoryCompactCleanupRequest } from './runtime-kernel.js';
import { fingerprintAgentGraphRunnableIntent } from './stream-graph-admission.js';
import type { AgentGraphRunnableIntent } from './stream-graph-readiness.js';
import {
buildStatusPatch,
buildTurnStateMessage,
turnHasRetainedOutput as messagesHaveRetainedOutput,
} from './session-projection-helpers.js';
import {
assertAgentDefinitionRunnable,
buildToolsForAgentDefinition,
getBuiltinAgentDefinition,
listBuiltinAgentDefinitions,
requireBuiltinAgentDefinitionByProfile,
AGENT_WORKSPACE_WORKTREE,
type AgentProfile,
type AgentDefinition,
type AgentDefinitionListItem,
} from './agent-catalog.js';
import { buildRuntimeEventModelReplayPlan } from './model-history.js';
import { requireResolvedAgentDefinition } from './expert-catalog.js';
import { stableHash } from './request-shape.js';
import type { SubagentExecutionRef } from './subagent-execution.js';
import {
buildResumePlanFromRuntimeEvents,
RuntimeContinuationPlanner,
type RuntimeContinuation,
type RuntimeContinuationPlannerInput,
type RuntimeContinuationSafetyObservation,
type SafeBoundaryContinuationPlan,
} from './runtime-resume.js';
export interface StopSessionInput {
source?: 'stop_button' | 'benchmark_deadline' | 'graph_supervisor';
mode?: BackendStopMode;
}
export interface CompactSessionInput {
turnId?: string;
}
export type PlanSafeBoundaryContinuationInput = Omit<RuntimeContinuationPlannerInput, 'sessionId'>;
export interface PlanAuthoritativeSafeBoundaryContinuationInput {
sourceRunId: string;
expectedRuntimeEventHighWater?: number;
}
export interface SpawnChildAgentInput {
parentRunId: string;
turnId?: string;
spec: AgentSpec;
prompt: string;
abortSignal?: AbortSignal;
onReady?: (input: { turnId: string; agentId: string; agentName: string }) => void | Promise<void>;
/** Presentation-only observer for projecting child activity into a parent surface. */
onEvent?: (event: SessionEvent) => void;
}
export interface SpawnChildSessionInput {
spawnedBy: SubagentSessionParent['spawnedBy'];
agentProfile: AgentProfile;
prompt: string;
name?: string;
turnId?: string;
runId?: string;
swarm?: SubagentSessionParent['swarm'];
abortSignal?: AbortSignal;
onReady?: (input: {
childSessionId: string;
turnId: string;
runId: string;
agentId: string;
agentName: string;
}) => void | Promise<void>;
/** Presentation-only observer for projecting child activity into a parent surface. */
onEvent?: (event: SessionEvent) => void;
}
export interface SpawnChildSessionResult extends SpawnChildAgentResult {
childSessionId: string;
runId: string;
profile: string;
}
export interface ProvisionAgentGraphOperatorInput {
graphId: string;
workId: string;
agentId: string;
operatorId: string;
source: AgentGraphScheduleUpdateSource;
edges: AgentGraphProvisionedEdge[];
expectedScheduleRevision: number;
}
export interface ProvisionAgentGraphOperatorResult extends AgentGraphOperatorProvisionResult {
header: SessionHeader;
}
export interface RunClaimedAgentGraphIntentInput {
/**
* Embedded graph claim authority and stream-graph admission dependency.
* Hosted execution treats this as a non-authoritative caller reference and
* re-reads the claim through its trusted composition capability.
*/
claimStore: AgentGraphIntentClaimStore;
/** Complete control-plane input used to verify the durable claim fingerprint. */
intent: AgentGraphRunnableIntent;
graphId: string;
intentId: string;
prompt: string;
/**
* Optional control-plane gate evaluated after Session serialization and
* immediately before a new Runtime turn is admitted. Existing durable runs
* bypass the gate and remain recoverable.
*/
admitExecution?: () => Promise<'executing' | 'cancelled'>;
abortSignal?: AbortSignal;
onReady?: (input: {
claimId: string;
graphId: string;
intentId: string;
operatorId: string;
childSessionId: string;
turnId: string;
runId: string;
agentId: string;
agentName: string;
}) => void | Promise<void>;
/** Presentation-only observer for the newly started runtime stream. */
onEvent?: (event: SessionEvent) => void;
}
export interface ClaimedAgentGraphIntentResult extends SpawnChildSessionResult {
claimId: string;
graphId: string;
intentId: string;
operatorId: string;
}
type ResolvedClaimedAgentGraphIntentInput = Omit<
RunClaimedAgentGraphIntentInput,
'claimStore' | 'graphId' | 'intentId'
> & {
claim: AgentGraphIntentClaim;
hostedGraphExecution?: RuntimeHostedAgentGraphExecutionCapability;
};
export interface PrepareChildAgentResumeResult {
sourceRunId: string;
execution: SubagentExecutionRef;
agentId: string;
agentName: string;
profile: string;
}
export interface ResumeChildAgentInput {
parentRunId: string;
sourceRunId: string;
turnId?: string;
prompt: string;
abortSignal?: AbortSignal;
onReady?: (input: {
childSessionId?: string;
turnId: string;
runId?: string;
agentId: string;
agentName: string;
}) => void | Promise<void>;
/** Presentation-only observer for projecting child activity into a parent surface. */
onEvent?: (event: SessionEvent) => void;
}
export interface SpawnChildAgentResult {
childSessionId?: string;
agentId: string;
agentName: string;
turnId: string;
runId?: string;
status: 'completed' | 'failed' | 'cancelled' | 'running' | 'waiting_for_user';
permissionMode: PermissionMode;
summary: string;
artifactIds: string[];
startedAt: number;
completedAt: number;
durationMs: number;
eventCount: number;
failureClass?: string;
resumedFromRunId?: string;
retriedFromRunId?: string;
}
export interface RetryChildAgentInput {
parentRunId: string;
sourceRunId: string;
execution?: SubagentExecutionRef;
abortSignal?: AbortSignal;
onReady?: (input: {
childSessionId?: string;
turnId: string;
runId?: string;
agentId: string;
agentName: string;
}) => void | Promise<void>;
/** Presentation-only observer for projecting child activity into a parent surface. */
onEvent?: (event: SessionEvent) => void;
}
const CHILD_AGENT_SUMMARY_MAX_CHARS = 4_000;
const MAX_RUNTIME_LEDGER_REPAIR_ATTEMPTS = 8;
export interface AgentListItem {
runId: string;
turnId: string;
parentRunId: string;
agentId?: string;
agentName?: string;
status: AgentRunHeader['status'];
permissionMode: AgentRunHeader['permissionMode'];
createdAt: number;
updatedAt: number;
completedAt?: number;
durationMs?: number;
failureClass?: string;
}
export interface SubagentExecutionListItem {
execution: SubagentExecutionRef;
agentId?: string;
agentName?: string;
profile?: string;
turnId?: string;
status: AgentRunHeader['status'];
permissionMode: PermissionMode;
createdAt: number;
updatedAt: number;
completedAt?: number;
durationMs?: number;
failureClass?: string;
}
export interface AgentListResult {
definitions: AgentDefinitionListItem[];
/** Canonical mixed projection for new child Sessions and legacy same-session child AgentRuns. */
executions: SubagentExecutionListItem[];
/** Legacy projection retained while callers migrate to executions. */
runs: AgentListItem[];
}
export interface AgentOutputInput {
execution?: SubagentExecutionRef;
runId?: string;
turnId?: string;
maxEvents?: number;
}
export interface AgentOutputResult {
execution: SubagentExecutionRef;
header: AgentRunHeader;
events: AgentRunEvent[];
runtimeEvents: RuntimeEvent[];
sourceHealth: AgentRunInspectModel['sourceHealth'];
diagnostics: AgentRunInspectModel['diagnostics'];
artifacts: ArtifactRecord[];
truncated: {
events: boolean;
runtimeEvents: boolean;
diagnostics: boolean;
};
}
// ============================================================================
// SessionStore contract (matches the storage package surface)
// ============================================================================
// StoredMessage rows remain a projection/cache surface for existing public
// shapes. RuntimeEventStore is the semantic conversation ledger.
export interface SessionStore {
create(input: CreateSessionInput): Promise<SessionHeader>;
createSubagent(input: CreateSessionInput): Promise<{ header: SessionHeader; created: boolean }>;
createAgentGraphOperator?(
input: CreateSessionInput,
request: AgentGraphOperatorProvisionRequest,
expectedRevision: number,
): Promise<ProvisionAgentGraphOperatorResult>;
list(filter?: SessionListFilter): Promise<SessionSummary[]>;
readHeader(sessionId: string): Promise<SessionHeader>;
readMessages(sessionId: string): Promise<StoredMessage[]>;
listTurns(sessionId: string): Promise<TurnRecord[]>;
appendMessage(sessionId: string, m: StoredMessage): Promise<void>;
appendMessages(sessionId: string, ms: StoredMessage[]): Promise<void>;
updateHeader(sessionId: string, patch: Partial<SessionHeader>): Promise<SessionHeader>;
markSessionReadThrough(sessionId: string, readThroughTs: number): Promise<SessionHeader>;
archive(sessionId: string): Promise<void>;
unarchive(sessionId: string): Promise<void>;
setFlagged(sessionId: string, isFlagged: boolean): Promise<void>;
rename(sessionId: string, name: string): Promise<void>;
setGeneratedTitleIfAbsent?(sessionId: string, title: string): Promise<SessionHeader | null>;
remove(sessionId: string): Promise<void>;
}
export interface StrictRecoverySessionStore extends SessionStore {
listForRecovery(): Promise<SessionHeader[]>;
readMessagesForRecovery(sessionId: string): Promise<StoredMessage[]>;
}
export interface StrictRecoveryAgentRunStore extends AgentRunStore {
listSessionRunsForRecovery(sessionId: string): Promise<AgentRunHeader[]>;
readEventsForRecovery(sessionId: string, runId: string): Promise<AgentRunEvent[]>;
}
export interface StrictRecoveryStores {
sessionStore: StrictRecoverySessionStore;
agentRunStore: StrictRecoveryAgentRunStore;
}
// ============================================================================
// BackendRegistry — factory dispatch by BackendKind
// ============================================================================
export interface BackendFactoryContext {
sessionId: string;
workspaceRoot: string;
header: SessionHeader;
store: SessionStore;
/** Process-local cancellation for the execution that owns this activation. */
abortSignal?: AbortSignal;
appendMessage?: (message: StoredMessage) => Promise<void>;
/**
* Child-agent instruction channel. Legacy child runs and linked child
* sessions populate this; an ordinary main-session activation leaves it
* undefined. A
* main-session factory that needs a system prompt must source it from
* its own closure (the desktop path and the headless benchmark path
* both do this) — do NOT route a main-session prompt through this
* field, it is semantically the child instruction, not the session
* system prompt.
*/
systemPrompt?: string;
/**
* Optional hard tool ceiling for this backend activation. When present, a
* host may remove tools for stricter local policy, but must never append,
* substitute, or otherwise expose a tool outside this exact set.
*/
tools?: readonly MakaTool[];
/** Trusted child expert-team identity. Main-session factories leave this undefined. */
agentTeam?: AgentTeamExecutionContext;
recordRunTrace?: RunTraceRecorder;
/** Durable AgentRun metadata row written after the private capture artifact. */
recordProviderRequestCapture?: (capture: ProviderRequestCaptureLedgerRecord) => Promise<void>;
/** Best-effort AgentRun row for one physical provider call. */
recordProviderRequestAttempt?: (attempt: ProviderRequestAttemptRecord) => void;
loadHistoryCompactCheckpoint?: () => Promise<HistoryCompactCheckpoint | undefined>;
recordHistoryCompactCheckpoint?: (
checkpoint: HistoryCompactCheckpoint,
turnId: string,
) => Promise<void>;
/**
* Durable read of the given turn's persisted RuntimeEvents from the
* authoritative run ledger. The Runtime reloads this projection between
* provider requests; mid-turn compaction also derives its coverage pool from
* the same durable facts.
*/
loadTurnRuntimeEvents?: (turnId: string) => Promise<RuntimeEvent[]>;
/** Whether this activation may fold its run ledger into session-scoped history. */
allowMidTurnHistoryCompaction?: boolean;
recordActiveFullCompactBlock?: (block: ActiveFullCompactBlock) => void;
recordSemanticCompactBlock?: (block: SemanticCompactBlock) => void;
shellRunContextSummary?: () => Promise<string | undefined>;
}
export type BackendFactory = (ctx: BackendFactoryContext) => AgentBackend | Promise<AgentBackend>;
export class BackendRegistry {
private readonly factories = new Map<BackendKind, BackendFactory>();
register(kind: BackendKind, factory: BackendFactory): void {
this.factories.set(kind, factory);
}
async build(kind: BackendKind, ctx: BackendFactoryContext): Promise<AgentBackend> {
const f = this.factories.get(kind);
if (!f) throw new Error(`No backend factory registered for kind="${kind}"`);
return await f(ctx);
}
has(kind: BackendKind): boolean {
return this.factories.has(kind);
}
}
// ============================================================================
// SessionManager
// ============================================================================
export interface RuntimeHostedAgentGraphExecutionCapability {
readAgentGraphIntentClaim(
graphId: string,
intentId: string,
): Promise<AgentGraphIntentClaim | undefined>;
readRootTurnAdmissionIdentity(
sessionId: string,
turnId: string,
): Promise<{ runId: string; userMessageId: string | null } | undefined>;
}
interface SessionManagerBaseDeps {
store: SessionStore;
planStore?: PlanStore;
runStore?: AgentRunStore;
runtimeEventStore?: RuntimeEventStore;
/** Host capability; RuntimeKernel gates it by the selected backend. */
toolBoundaryProtocol?: ToolBoundaryProtocol;
backends: BackendRegistry;
newId: () => string;
now: () => number;
childTools?: readonly MakaTool[];
/** Host-owned filesystem isolation for worktree-backed child Sessions. */
worktreeChildExecutor?: SubagentWorktreeExecutor;
listArtifactsForTurn?: (sessionId: string, turnId: string) => Promise<ArtifactRecord[]>;
runtimeSource?: InvocationSource;
runtimeInvocationObserver?: (result: InvocationResult) => void | Promise<void>;
runtimeKernel?: RuntimeKernelLike;
/** Optional host-owned parent run authority for runtimes that execute the parent externally. */
isParentRunActive?: (sessionId: string, runId: string, turnId: string) => boolean;
shellRuns?: ShellRunProcessManager;
cleanupHistoryCompactArtifacts?: (input: HistoryCompactCleanupRequest) => Promise<void>;
inspectContinuationSafety?: (sessionId: string) => Promise<RuntimeContinuationSafetyObservation>;
continuationFailpoint?: (point: RuntimeContinuationFailpoint) => Promise<void>;
runBackendActivation?: BackendActivationBoundary;
safeBoundaryResumeEnabled?: boolean;
/** Hosted composition capability. Omit for the production embedded queue. */
messageAuthority?: RuntimeMessageAuthority;
/** Trusted Host-owned graph readers. Hosted graph execution fails closed without them. */
hostedAgentGraphExecution?: RuntimeHostedAgentGraphExecutionCapability;
onContinuationLifecycleEvent?: (event: RuntimeContinuationLifecycleEvent) => void | Promise<void>;
generateSessionTitle?: (input: {
sessionId: string;
header: SessionHeader;
sourceText: string;
}) => Promise<string | undefined>;
onSessionTitleChanged?: (sessionId: string) => void;
}
type SessionManagerInteractionDeps =
| {
/** Hosted composition capabilities. Omit both for embedded interaction ownership. */
interactionAuthority: RuntimeInteractionAuthority;
canonicalPermissionOutcomes: CanonicalPermissionOutcomeReader;
}
| {
interactionAuthority?: undefined;
canonicalPermissionOutcomes?: undefined;
};
export type SessionManagerDeps = SessionManagerBaseDeps & SessionManagerInteractionDeps;
export type RuntimeContinuationLifecycleEvent =
| {
type: 'plan_approved';
sessionId: string;
sourceRunId: string;
targetRunId: string;
}
| {
type: 'plan_parked';
sessionId: string;
sourceRunId: string;
rejectionReasons: readonly string[];
}
| {
type: 'execution_started' | 'execution_completed';
sessionId: string;
sourceRunId: string;
targetRunId: string;
}
| {
type: 'execution_failed';
sessionId: string;
sourceRunId: string;
targetRunId: string;
errorClass: string;
};
export class SessionManager {
private readonly runtimeKernel: RuntimeKernelLike;
private readonly runtimeLedgerRepair?: RuntimeLedgerRepair;
private readonly activeHostedLinkedChildSessions = new Set<string>();
private readonly childSessionSpawns = new Map<
string,
{ requestFingerprint: string; promise: Promise<SpawnChildSessionResult> }
>();
private readonly claimedAgentGraphIntentRuns = new Map<
string,
{ requestFingerprint: string; promise: Promise<ClaimedAgentGraphIntentResult> }
>();
private readonly claimedAgentGraphSessionTails = new Map<string, Promise<void>>();
constructor(private readonly deps: SessionManagerDeps) {
if (deps.runStore && !deps.runtimeEventStore) {
throw new Error('RuntimeEventStore is required when AgentRunStore is configured');
}
if (deps.runStore && deps.runtimeEventStore) {
this.runtimeLedgerRepair = new RuntimeLedgerRepair({
runStore: deps.runStore,
runtimeEventStore: deps.runtimeEventStore,
readMessages: (sessionId) => deps.store.readMessages(sessionId),
appendTurnState: (sessionId, turnId, status, lineage, options) =>
this.appendTurnState(sessionId, turnId, status, lineage, options),
newId: deps.newId,
now: deps.now,
});
}
this.runtimeKernel =
deps.runtimeKernel ??
new RuntimeKernel({
...deps,
repairRunRuntimeLedger: (sessionId, runId) =>
this.repairMissingTerminalFactOnce(sessionId, runId),
});
}
// --------------------------------------------------------------------------
// Session lifecycle
// --------------------------------------------------------------------------
async createSession(input: CreateSessionInput): Promise<SessionSummary> {
const header = await this.deps.store.create(input);
return headerToSummary(header);
}
async listSessions(filter?: SessionListFilter): Promise<SessionSummary[]> {
return this.deps.store.list(filter);
}
async listChildSessions(parentSessionId: string): Promise<SessionSummary[]> {
const sessions = await this.deps.store.list({ subagentParentSessionId: parentSessionId });
return childSessionsForParent(sessions, parentSessionId);
}
private async provisionChildWorkspace(
parent: SessionHeader,
definition: AgentDefinition,
requestFingerprint: string,
): Promise<SubagentWorkspaceBinding | undefined> {
if (definition.contract.workspace !== AGENT_WORKSPACE_WORKTREE) return undefined;
const executor = this.deps.worktreeChildExecutor;
if (!executor) {
throw new Error(
`Agent "${definition.id}" is unavailable: "worktree" workspace isolation requires a worktree child executor.`,
);
}
const fingerprint = requestFingerprint.startsWith('sha256:')
? requestFingerprint.slice('sha256:'.length)
: requestFingerprint;
if (!/^[a-f0-9]{64}$/.test(fingerprint)) {
throw new Error('Child workspace request fingerprint must be SHA-256');
}
return executor.provision({
leaseId: `subagent_worktree_${fingerprint.slice(0, 32)}`,
sourceSessionId: parent.id,
sourceCwd: parent.cwd,
...(parent.projectId !== undefined ? { sourceProjectId: parent.projectId } : {}),
});
}
private async ensureChildWorkspace(header: SessionHeader): Promise<void> {
const binding = header.subagentWorkspace;
if (!binding) return;
const executor = this.deps.worktreeChildExecutor;
if (!executor) {
throw new Error(
`Child Session ${header.id} requires a worktree child executor for ${binding.worktreePath}`,
);
}
if (header.cwd !== binding.worktreePath) {
throw new Error(`Child Session ${header.id} workspace binding disagrees with its cwd`);
}
await executor.ensure(binding);
}
/** Invalidate backend snapshots now, or immediately after active turns settle. */
async refreshIdleBackends(): Promise<void> {
await this.runtimeKernel.invalidateCachedBackends();
}
async getMessages(sessionId: string): Promise<StoredMessage[]> {
return (await this.getSessionView(sessionId)).messages;
}
async listTurns(sessionId: string): Promise<TurnRecord[]> {
return (await this.getSessionView(sessionId)).turns;
}
async listShellRunUpdates(sessionId: string): Promise<ShellRunUpdate[]> {
const shellRuns = this.deps.shellRuns;
if (!shellRuns) return [];
const ownUpdates = await shellRuns.listSessionUpdates(sessionId);
const ownToolCalls = new Set(ownUpdates.map((update) => update.sourceToolCallId));
let messages: StoredMessage[];
try {
messages = await this.getMessages(sessionId);
} catch (error) {
if (!(error instanceof RuntimeReadModelError)) throw error;
// ShellRun hydration is a best-effort UI projection. A legacy RuntimeEvent
// incompatibility must not turn its retry loop into a permanent IPC error.
try {
messages = await this.deps.store.readMessages(sessionId);
} catch {
return ownUpdates;
}
}
const bashToolCalls = new Set(
messages.flatMap((message) =>
message.type === 'tool_call' && message.toolName === 'Bash' ? [message.id] : [],
),
);
const inherited = new Map<
string,
{
ref: string;
turnId: string;
toolUseId: string;
result: ShellRunUpdate['result'];
}
>();
for (const message of messages) {
if (
message.type === 'tool_result' &&
bashToolCalls.has(message.toolUseId) &&
!ownToolCalls.has(message.toolUseId) &&
message.content.kind === 'shell_run' &&
message.content.status === 'running'
) {
const { operation: _operation, ...result } = message.content;
inherited.set(message.toolUseId, {
ref: message.content.ref,
turnId: message.turnId,
toolUseId: message.toolUseId,
result,
});
}
}
if (inherited.size === 0) return ownUpdates;
const inheritedFrom = await this.deps.store.readHeader(sessionId);
const parentSessionId = inheritedFrom.revisionParentSessionId ?? inheritedFrom.parentSessionId;
if (!parentSessionId) return ownUpdates;
const inheritedUpdates = await Promise.all(
[...inherited.values()].map(async (candidate) => {
const owner = await this.resolveShellRunOwner(parentSessionId, candidate.ref);
return {
sessionId,
ownership: owner
? {
kind: 'source_owned',
sourceSessionId: parentSessionId,
ownerSessionId: owner.sessionId,
}
: { kind: 'source_unavailable', sourceSessionId: parentSessionId },
sourceTurnId: candidate.turnId,
sourceToolCallId: candidate.toolUseId,
result: owner?.result ?? candidate.result,
} satisfies ShellRunUpdate;
}),
);
return [...ownUpdates, ...inheritedUpdates];
}
async recoverInterruptedSessions(): Promise<string[]> {
return this.recoverInterruptedSessionsWithPolicy({ kind: 'best_effort' });
}
async recoverInterruptedSessionsStrict(stores: StrictRecoveryStores): Promise<string[]> {
if (stores.sessionStore !== this.deps.store || stores.agentRunStore !== this.deps.runStore) {
throw new Error('Strict recovery stores must match the SessionManager composition');
}
return this.recoverInterruptedSessionsWithPolicy({ kind: 'strict', stores });
}
private async recoverInterruptedSessionsWithPolicy(policy: RecoveryPolicy): Promise<string[]> {
const interrupted = (await listSessionsForRecovery(this.deps.store, policy)).filter(
(session) => session.status !== 'archived',
);
const recovered = new Set<string>();
for (const session of interrupted) {
if (this.runtimeKernel.hasActiveRuns(session.id)) continue;
if (this.deps.planStore) {
const planRecovery = await recoverOr(
policy,
() => this.deps.planStore!.interruptActiveExecution(session.id, 'runtime_recovery'),
null,
);
if (planRecovery) recovered.add(session.id);
}
if (this.deps.shellRuns) {
const recoveredShellRuns = await recoverOr(
policy,
() => this.deps.shellRuns!.recoverOrphanedSession(session.id),
0,
);
if (recoveredShellRuns > 0) recovered.add(session.id);
}
let messages: StoredMessage[] = [];
let messagesReadable = true;
try {
messages =
policy.kind === 'strict'
? await policy.stores.sessionStore.readMessagesForRecovery(session.id)
: await this.deps.store.readMessages(session.id);
} catch (error) {
if (policy.kind === 'strict') throw error;
messagesReadable = false;
}
if (session.revisionState === 'preparing' && messagesReadable) {
if (hasRevisionUserMessage(messages)) {
await recoverOr(policy, () => this.commitRevisionVersion(session.id), undefined);
} else {
await recoverOr(policy, () => this.remove(session.id), undefined);
recovered.add(session.id);
continue;
}
}
if (this.deps.runStore) {
const runRecovery = await recoverOr(
policy,
() => this.recoverAgentRunsFromLedger(session.id, policy),
undefined,
);
if (runRecovery?.hasLedger) {
if (runRecovery.recovered) {
await recoverOr(policy, () => this.updateStatus(session.id, 'active'), undefined);
recovered.add(session.id);
} else if (
!messagesReadable &&
(session.status === 'running' || session.status === 'waiting_for_user')
) {
await recoverOr(policy, () => this.updateStatus(session.id, 'active'), undefined);
recovered.add(session.id);
}
continue;
}
}
if (!messagesReadable) {
if (session.status === 'running' || session.status === 'waiting_for_user') {
// Recovery may run in BACKGROUND startup (#456): re-check for a
// run the user started while this session's recovery was in
// flight, so we never stomp a live run's status.
if (this.runtimeKernel.hasActiveRuns(session.id)) continue;
await recoverOr(policy, () => this.updateStatus(session.id, 'active'), undefined);
recovered.add(session.id);
}
continue;
}
const recoveries = interruptedTurnRecoveries(messages);
if (recoveries.length === 0) continue;
for (const recovery of recoveries) {
await recoverOr(
policy,
() =>
this.appendTurnState(session.id, recovery.turnId, 'failed', recovery.lineage, {
errorClass: recovery.errorClass,
}),
undefined,
);
}
if (session.status === 'running' || session.status === 'waiting_for_user') {
// Same double-check as above: a message sent mid-recovery owns
// the session status now (its own transitions will settle it).
if (this.runtimeKernel.hasActiveRuns(session.id)) {
recovered.add(session.id);
continue;
}
await recoverOr(policy, () => this.updateStatus(session.id, 'active'), undefined);
}
recovered.add(session.id);
}
return [...recovered];
}
async updateSession(sessionId: string, patch: Partial<SessionHeader>): Promise<SessionSummary> {
const backendConfigChanged = changesBackendConfig(patch);
if (backendConfigChanged && this.runtimeKernel.hasActiveRuns(sessionId)) {
throw new Error('Cannot change backend configuration while a turn is running');
}
const { name, titleIsManual: _titleIsManual, ...rest } = patch;
if (name !== undefined) await this.deps.store.rename(sessionId, name);
const next =
Object.keys(rest).length > 0
? await this.deps.store.updateHeader(sessionId, rest)
: await this.deps.store.readHeader(sessionId);
this.runtimeKernel.updateCachedHeader(sessionId, next);
if (backendConfigChanged) {
// AgentBackend instances snapshot backend/model config at construction
// time. If a stale session is rebound to a real default connection, the
// next turn must build a fresh backend instead of reusing FakeBackend or
// an AiSdkBackend pointed at a deleted connection.
await this.runtimeKernel.disposeBackend(sessionId);
}
return headerToSummary(next);
}
async archive(sessionId: string): Promise<void> {
const shellRunClose = await this.deps.shellRuns?.terminateSession(sessionId);
try {
await this.deps.store.archive(sessionId);
} catch (error) {
if (shellRunClose) this.deps.shellRuns?.rollbackSessionClose(shellRunClose);
throw error;
}
if (shellRunClose) await this.deps.shellRuns?.commitSessionClose(shellRunClose);
await this.runtimeKernel.disposeBackend(sessionId);
}
async unarchive(sessionId: string): Promise<void> {
await this.deps.store.unarchive(sessionId);
this.deps.shellRuns?.resumeSession(sessionId);
}
async setSessionStatus(
sessionId: string,
status: SessionStatus,
blockedReason?: SessionBlockedReason,
): Promise<SessionSummary> {
const next = await this.deps.store.updateHeader(
sessionId,
buildStatusPatch(status, this.deps.now(), blockedReason),
);
this.runtimeKernel.updateCachedHeader(sessionId, next);
return headerToSummary(next);
}
async commitRevisionVersion(sessionId: string): Promise<SessionSummary> {
const current = await this.deps.store.readHeader(sessionId);
if (current.revisionState !== 'preparing') return headerToSummary(current);
const next = await this.deps.store.updateHeader(sessionId, { revisionState: 'committed' });
this.runtimeKernel.updateCachedHeader(sessionId, next);
return headerToSummary(next);
}
async setFlagged(sessionId: string, isFlagged: boolean): Promise<void> {
await this.deps.store.setFlagged(sessionId, isFlagged);
const header = await this.deps.store.readHeader(sessionId).catch(() => undefined);
if (header) this.runtimeKernel.updateCachedHeader(sessionId, header);
}
async markSessionRead(sessionId: string, readThroughTs: number | undefined): Promise<void> {
if (readThroughTs === undefined || !Number.isFinite(readThroughTs)) return;
const next = await this.deps.store.markSessionReadThrough(sessionId, readThroughTs);
this.runtimeKernel.updateCachedHeader(sessionId, next);
}
async renameSession(sessionId: string, name: string): Promise<void> {
await this.deps.store.rename(sessionId, name);
const header = await this.deps.store.readHeader(sessionId).catch(() => undefined);
if (header) this.runtimeKernel.updateCachedHeader(sessionId, header);
}
async setPermissionMode(sessionId: string, mode: PermissionMode): Promise<SessionSummary> {
const previous = await this.deps.store.readHeader(sessionId);
if (
previous.subagentRuntime &&
!isPermissionModeWithinCeiling(mode, previous.subagentRuntime.permissionCeiling)
) {
throw new Error(
`Child session permission mode "${mode}" exceeds its "${previous.subagentRuntime.permissionCeiling}" ceiling`,
);
}
const leavingDeepResearch = isDeepResearchSession(previous.labels) && mode !== 'explore';
if (previous.permissionMode === mode && !leavingDeepResearch) return headerToSummary(previous);
if (this.runtimeKernel.hasActiveRuns(sessionId)) {
throw new Error('当前对话正在运行,等结束后再切换权限模式。');
}
if (previous.status === 'waiting_for_user') {
throw new Error('当前有工具调用正在等待确认,处理后再切换权限模式。');
}
const next = await this.deps.store.updateHeader(sessionId, {
permissionMode: mode,
labels: leavingDeepResearch
? previous.labels.filter((label) => label !== DEEP_RESEARCH_SESSION_LABEL)
: previous.labels,
});
await this.deps.store.appendMessage(sessionId, {
type: 'system_note',
id: this.deps.newId(),
ts: this.deps.now(),
kind: 'mode_change',
data: { from: previous.permissionMode, to: mode },
} satisfies SystemNoteMessage);
this.runtimeKernel.updateCachedHeader(sessionId, next);
// AiSdkBackend snapshots the header at construction time. Rebuild the
// backend before the next turn so PermissionEngine receives the new mode.
await this.runtimeKernel.disposeBackend(sessionId);
return headerToSummary(next);
}
async getPlanState(sessionId: string): Promise<PlanSessionState> {
return this.requirePlanStore().readState(sessionId);
}
async setCollaborationMode(sessionId: string, mode: CollaborationMode): Promise<SessionSummary> {
const previous = await this.deps.store.readHeader(sessionId);
const from = previous.collaborationMode ?? 'agent';
if (from === mode) return headerToSummary(previous);
if (this.runtimeKernel.hasActiveRuns(sessionId)) {
throw new Error('当前对话正在运行,等结束后再切换协作模式。');
}
if (previous.status === 'waiting_for_user') {
throw new Error('当前有工具调用正在等待确认,处理后再切换协作模式。');
}
const planState = await this.requirePlanStore().readState(sessionId);
if (mode === 'plan' && planState.activeExecutionId) {
throw new Error('当前计划仍在执行,结束或中断后才能切换到 Plan Mode。');
}
const latestProposal = planState.proposals.find(
(proposal) => proposal.proposalId === planState.latestProposalId,
);
if (mode === 'agent' && latestProposal?.status === 'pending_approval') {
throw new Error('当前方案正在等待审批,请明确放弃方案后再退出 Plan Mode。');
}
const next = await this.deps.store.updateHeader(sessionId, {
collaborationMode: mode,
});
await this.deps.store.appendMessage(sessionId, {
type: 'system_note',
id: this.deps.newId(),
ts: this.deps.now(),
kind: 'mode_change',
data: { dimension: 'collaboration', from, to: mode },
} satisfies SystemNoteMessage);
this.runtimeKernel.updateCachedHeader(sessionId, next);
await this.runtimeKernel.disposeBackend(sessionId);
return headerToSummary(next);
}
async setOrchestrationMode(sessionId: string, mode: OrchestrationMode): Promise<SessionSummary> {
const previous = await this.deps.store.readHeader(sessionId);
const from = previous.orchestrationMode ?? 'default';
if (from === mode) return headerToSummary(previous);
if (this.runtimeKernel.hasActiveRuns(sessionId)) {
throw new Error('Cannot change orchestration mode while a turn is running.');
}
if (previous.status === 'waiting_for_user') {
throw new Error('Cannot change orchestration mode while a tool call awaits confirmation.');
}
const next = await this.deps.store.updateHeader(sessionId, { orchestrationMode: mode });
await this.deps.store.appendMessage(sessionId, {
type: 'system_note',
id: this.deps.newId(),
ts: this.deps.now(),
kind: 'mode_change',
data: { dimension: 'orchestration', from, to: mode },
} satisfies SystemNoteMessage);
this.runtimeKernel.updateCachedHeader(sessionId, next);
return headerToSummary(next);
}
async requestPlanRevision(sessionId: string, proposalId: string): Promise<PlanMutationResult> {
const result = await this.requirePlanStore().requestRevision({ sessionId, proposalId });
const header = await this.deps.store.readHeader(sessionId);
if ((header.collaborationMode ?? 'agent') !== 'plan') {
const next = await this.deps.store.updateHeader(sessionId, { collaborationMode: 'plan' });
this.runtimeKernel.updateCachedHeader(sessionId, next);
}
await this.runtimeKernel.disposeBackend(sessionId);
return result;
}
async abandonPlanProposal(sessionId: string, proposalId: string): Promise<PlanMutationResult> {
const header = await this.deps.store.readHeader(sessionId);
if (this.runtimeKernel.hasActiveRuns(sessionId)) {
throw new Error('当前对话仍在运行,无法放弃计划。');
}
if (header.status === 'waiting_for_user') {
throw new Error('当前有工具调用正在等待确认,无法放弃计划。');
}
const result = await this.requirePlanStore().abandonProposal({
sessionId,
proposalId,
reason: 'User exited Plan Mode before approval.',
});
const from = header.collaborationMode ?? 'agent';
const next = await this.deps.store.updateHeader(sessionId, { collaborationMode: 'agent' });
if (from !== 'agent') {
await this.deps.store.appendMessage(sessionId, {
type: 'system_note',
id: this.deps.newId(),
ts: this.deps.now(),
kind: 'mode_change',
data: { dimension: 'collaboration', from, to: 'agent' },
} satisfies SystemNoteMessage);
}
this.runtimeKernel.updateCachedHeader(sessionId, next);
await this.runtimeKernel.disposeBackend(sessionId);
return result;
}
async approvePlan(input: ApprovePlanProposalInput): Promise<PlanMutationResult> {
const header = await this.deps.store.readHeader(input.sessionId);
if (this.runtimeKernel.hasActiveRuns(input.sessionId)) {
throw new Error('当前对话仍在运行,无法批准计划。');
}
if (header.status === 'waiting_for_user') {
throw new Error('当前有工具调用正在等待确认,无法批准计划。');
}
const result = await this.requirePlanStore().approveProposal(input);
const next = await this.deps.store.updateHeader(input.sessionId, {
collaborationMode: 'agent',
});
this.runtimeKernel.updateCachedHeader(input.sessionId, next);
await this.runtimeKernel.disposeBackend(input.sessionId);
return result;
}
async resumePlanExecution(sessionId: string, executionId: string): Promise<PlanMutationResult> {
const result = await this.requirePlanStore().resumeExecution(sessionId, executionId);
const next = await this.deps.store.updateHeader(sessionId, { collaborationMode: 'agent' });
this.runtimeKernel.updateCachedHeader(sessionId, next);
await this.runtimeKernel.disposeBackend(sessionId);
return result;
}
async cancelPlanExecution(sessionId: string, executionId: string): Promise<PlanMutationResult> {
const planStore = this.requirePlanStore();
const state = await planStore.readState(sessionId);
const execution = state.executions.find((item) => item.executionId === executionId);
if (execution?.status !== 'interrupted') {
throw new Error('只有已中断的计划可以从这里放弃。');
}
const result = await planStore.cancelExecution({
sessionId,
executionId,
reason: 'User abandoned the interrupted plan.',
});
await this.runtimeKernel.disposeBackend(sessionId);
return result;
}
async interruptActivePlanExecution(
sessionId: string,
reason: string,
): Promise<PlanMutationResult | null> {
const result = await this.requirePlanStore().interruptActiveExecution(sessionId, reason);
if (result) await this.runtimeKernel.disposeBackend(sessionId);
return result;
}
async remove(sessionId: string): Promise<void> {
const shellRunClose = await this.deps.shellRuns?.terminateSession(sessionId);
try {
await this.runtimeKernel.disposeBackend(sessionId);
await this.deps.store.remove(sessionId);
} catch (error) {
if (shellRunClose) this.deps.shellRuns?.rollbackSessionClose(shellRunClose);
throw error;
}
if (shellRunClose) await this.deps.shellRuns?.commitSessionClose(shellRunClose);
}
// --------------------------------------------------------------------------
// Send / stream — Phase 1 vertical heart
// --------------------------------------------------------------------------
/**
* Send a user message and stream back normalized events. The caller
* (desktop main) is expected to forward the events to the renderer over
* the IPC bridge.
*
* Runtime v2 bridge: SessionManager remains the public facade; RuntimeKernel
* owns AgentRun/AiSdkFlow/RuntimeRunner orchestration and ledger recording.
*/
async *sendMessage(
sessionId: string,
input: UserMessageInput,
options: TurnStartOptions = {},
): AsyncIterable<SessionEvent> {
const sourceText = sessionTitleSource(input);
const onRunStarted = this.deps.generateSessionTitle
? async (runId: string, header: SessionHeader) => {
await options.onRunStarted?.(runId, header);
if (
!header.connectionLocked &&
!header.titleIsManual &&
header.name === DEFAULT_SESSION_NAME &&
sourceText
) {
void this.generateTitleInBackground(sessionId, header, sourceText);
}
}
: options.onRunStarted;
yield* this.runtimeKernel.startTurn(sessionId, input, { ...options, onRunStarted });
}
private async generateTitleInBackground(
sessionId: string,
header: SessionHeader,
sourceText: string,
): Promise<void> {
let generated: string | undefined;
try {
generated = await this.deps.generateSessionTitle?.({ sessionId, header, sourceText });
} catch {}
try {
const title = generated ?? fallbackSessionTitle(sourceText);
if (!title) return;
const next = await this.deps.store.setGeneratedTitleIfAbsent?.(sessionId, title);
if (!next) return;
this.runtimeKernel.updateCachedHeader(sessionId, next);
this.deps.onSessionTitleChanged?.(sessionId);
} catch {}
}
async planSafeBoundaryContinuation(
sessionId: string,
input: PlanSafeBoundaryContinuationInput,
): Promise<SafeBoundaryContinuationPlan> {
const planner = new RuntimeContinuationPlanner({
readSourceRun: async (targetSessionId, runId) => {
if (!this.deps.runStore) throw new Error('AgentRunStore is not configured');
return this.deps.runStore.readRun(targetSessionId, runId);
},
readRuntimeEvents: async (targetSessionId, runId) => {
if (!this.deps.runtimeEventStore) throw new Error('RuntimeEventStore is not configured');
return this.deps.runtimeEventStore.readRuntimeEvents(targetSessionId, runId);
},
findExistingContinuation: async (
targetSessionId,
sourceRunId,
sourceRuntimeEventHighWater,
) => {
if (!this.deps.runStore) throw new Error('AgentRunStore is not configured');
return (await this.deps.runStore.listSessionRuns(targetSessionId)).find(
(run) =>
run.continuationSource?.sourceRunId === sourceRunId &&
run.continuationSource.sourceRuntimeEventHighWater === sourceRuntimeEventHighWater,
);
},
newId: this.deps.newId,
});
const plan = await planner.plan({ sessionId, ...input });
this.recordContinuationPlan(sessionId, input.sourceRunId, plan);
return plan;
}
async planAuthoritativeSafeBoundaryContinuation(
sessionId: string,
input: PlanAuthoritativeSafeBoundaryContinuationInput,
): Promise<SafeBoundaryContinuationPlan> {
if (this.deps.safeBoundaryResumeEnabled !== true) {
const plan = resumeFeatureDisabledPlan();
this.recordContinuationPlan(sessionId, input.sourceRunId, plan);
return plan;
}
if (!this.deps.runStore || !this.deps.inspectContinuationSafety) {
const plan: SafeBoundaryContinuationPlan = {
disposition: 'park',
rejectionReasons: ['safety_observation_unavailable'],
diagnostics: [
{
code: 'safety_observation_unavailable',
message: 'authoritative continuation safety inspection is not configured',
},
],
};
this.recordContinuationPlan(sessionId, input.sourceRunId, plan);
return plan;
}
const sourceRun = await this.deps.runStore
.readRun(sessionId, input.sourceRunId)
.catch(() => undefined);
if (!sourceRun) {
const plan: SafeBoundaryContinuationPlan = {
disposition: 'park',
rejectionReasons: ['source_run_unreadable'],
diagnostics: [
{ code: 'source_run_unreadable', message: 'source AgentRun could not be read' },
],
};
this.recordContinuationPlan(sessionId, input.sourceRunId, plan);
return plan;
}
if (!sourceRun.workspaceIdentity) {
const plan: SafeBoundaryContinuationPlan = {
disposition: 'park',
rejectionReasons: ['workspace_identity_missing'],
diagnostics: [
{
code: 'workspace_identity_missing',
message: 'source AgentRun has no authoritative workspace identity',
},
],
};
this.recordContinuationPlan(sessionId, input.sourceRunId, plan);
return plan;
}
const [header, observation] = await Promise.all([
this.deps.store.readHeader(sessionId),
this.deps.inspectContinuationSafety(sessionId),
]);
return this.planSafeBoundaryContinuation(sessionId, {
sourceRunId: input.sourceRunId,
currentCwd: header.cwd,
sourceWorkspaceIdentity: sourceRun.workspaceIdentity,
currentWorkspaceIdentity: observation.workspaceIdentity,
backgroundOperationsSettled: observation.backgroundOperationsSettled,
availableToolNames: observation.availableToolNames,
...(input.expectedRuntimeEventHighWater !== undefined
? { expectedRuntimeEventHighWater: input.expectedRuntimeEventHighWater }
: {}),
...(observation.workspaceCheckpoint
? { workspaceCheckpoint: observation.workspaceCheckpoint }
: {}),
});
}
async planLatestAuthoritativeSafeBoundaryContinuation(
sessionId: string,
): Promise<SafeBoundaryContinuationPlan> {
if (this.deps.safeBoundaryResumeEnabled !== true) {
const plan = resumeFeatureDisabledPlan();
this.recordContinuationPlan(sessionId, '', plan);
return plan;
}
if (!this.deps.runStore) {
const plan: SafeBoundaryContinuationPlan = {
disposition: 'park',
rejectionReasons: ['resume_candidate_missing'],
diagnostics: [
{
code: 'resume_candidate_missing',
message: 'no AgentRun store is configured for resume discovery',
},
],
};
this.recordContinuationPlan(sessionId, '', plan);
return plan;
}
const candidate = (await this.deps.runStore.listSessionRuns(sessionId))
.filter(
(run) => (run.status === 'failed' || run.status === 'cancelled') && isSessionInlineRun(run),
)
.sort(
(left, right) => right.createdAt - left.createdAt || right.runId.localeCompare(left.runId),
)[0];
if (!candidate) {
const plan: SafeBoundaryContinuationPlan = {
disposition: 'park',
rejectionReasons: ['resume_candidate_missing'],
diagnostics: [
{
code: 'resume_candidate_missing',
message: 'no failed or cancelled top-level continuation candidate exists',
},
],
};
this.recordContinuationPlan(sessionId, '', plan);
return plan;
}
return this.planAuthoritativeSafeBoundaryContinuation(sessionId, {
sourceRunId: candidate.runId,
});
}
async *resumeSafeBoundaryContinuation(
continuation: RuntimeContinuation,
): AsyncIterable<SessionEvent> {
const resume = this.runtimeKernel.resumeContinuation;
if (!resume) throw new Error('RuntimeKernel does not support safe-boundary continuation');
this.recordContinuationLifecycleEvent({
type: 'execution_started',
sessionId: continuation.sessionId,
sourceRunId: continuation.sourceRunId,
targetRunId: continuation.runId,
});
try {
yield* resume.call(this.runtimeKernel, continuation);
this.recordContinuationLifecycleEvent({
type: 'execution_completed',
sessionId: continuation.sessionId,
sourceRunId: continuation.sourceRunId,
targetRunId: continuation.runId,
});
} catch (error) {
this.recordContinuationLifecycleEvent({
type: 'execution_failed',
sessionId: continuation.sessionId,
sourceRunId: continuation.sourceRunId,
targetRunId: continuation.runId,
errorClass: continuationExecutionErrorClass(error),
});
throw error;
}
}
private recordContinuationPlan(
sessionId: string,
sourceRunId: string,
plan: SafeBoundaryContinuationPlan,
): void {
if (plan.disposition === 'continue' && plan.continuation) {
this.recordContinuationLifecycleEvent({
type: 'plan_approved',
sessionId,
sourceRunId,
targetRunId: plan.continuation.runId,
});
return;
}
this.recordContinuationLifecycleEvent({
type: 'plan_parked',
sessionId,
sourceRunId,
rejectionReasons: plan.rejectionReasons,
});
}
private recordContinuationLifecycleEvent(event: RuntimeContinuationLifecycleEvent): void {
try {
const result = this.deps.onContinuationLifecycleEvent?.(event);
if (result) void Promise.resolve(result).catch(() => {});
} catch {
// Operational telemetry must never alter resume correctness.
}
}
async *compactSession(
sessionId: string,
input: CompactSessionInput = {},
): AsyncIterable<SessionEvent> {
yield* this.runtimeKernel.compactSession(sessionId, input);
}
async *startChildTurn(
sessionId: string,
input: ChildAgentTurnInput,
): AsyncIterable<SessionEvent> {
const execution = this.runtimeKernel.claimExecution(sessionId);
try {
await this.ensureChildWorkspace(await this.deps.store.readHeader(sessionId));
yield* this.runtimeKernel.startChildTurn(sessionId, input, execution);
} finally {
execution.release();
}
}
async spawnChildAgent(
sessionId: string,
input: SpawnChildAgentInput,
): Promise<SpawnChildAgentResult> {
const execution = this.runtimeKernel.claimExecution(sessionId);
try {
const definition = requireResolvedAgentDefinition(input.spec.id);
return await this.runChildAgent(sessionId, definition, input, execution);
} finally {
execution.release();
}
}
/**
* Create and run a durable linked child Session without changing the
* parent-facing agent_spawn compatibility path.
*
* Cross-session provenance lives on the child header. The first AgentRun
* intentionally carries no parentRunId, so it is an ordinary session-inline
* run and every later child turn can reuse only the child's own history.
*/
async spawnChildSession(
parentSessionId: string,
input: SpawnChildSessionInput,
): Promise<SpawnChildSessionResult> {
const spawnKey = childSessionSpawnKey(parentSessionId, input);
const requestFingerprint = childSessionRequestFingerprint(parentSessionId, input);
const inFlight = this.childSessionSpawns.get(spawnKey);
if (inFlight) {
if (inFlight.requestFingerprint !== requestFingerprint) {
throw new Error('Child-session spawn identity was reused for different work');
}
return await inFlight.promise;
}
const runtimeOwner = {
execution: this.runtimeKernel.claimExecution(parentSessionId),
};
const promise = this.spawnChildSessionOnce(
parentSessionId,
input,
requestFingerprint,
runtimeOwner,
).finally(() => runtimeOwner.execution.release());
this.childSessionSpawns.set(spawnKey, { requestFingerprint, promise });
try {
return await promise;
} finally {
if (this.childSessionSpawns.get(spawnKey)?.promise === promise) {
this.childSessionSpawns.delete(spawnKey);
}
}
}
/**
* Atomically materialize one catalog agent as a durable child Session and a
* monotonic graph-topology operator.
*
* This is metadata admission only. The reserved first turn/run is executed
* later through the ordinary claimed graph-intent path.
*/
async provisionAgentGraphOperator(
input: ProvisionAgentGraphOperatorInput,
): Promise<ProvisionAgentGraphOperatorResult> {
const create = this.deps.store.createAgentGraphOperator;
if (!create || !this.deps.runStore || !this.deps.runtimeEventStore) {
throw new Error(
'Graph operator provisioning requires SQLite Session metadata, AgentRunStore, and RuntimeEventStore',
);
}
if (input.source.sessionId.length === 0) {
throw new Error('Graph operator provision requires a supervisor Session');
}
const [parentHeader, sourceRun] = await Promise.all([
this.deps.store.readHeader(input.source.sessionId),
this.deps.runStore.readRun(input.source.sessionId, input.source.runId),
]);
if (
sourceRun.sessionId !== input.source.sessionId ||
sourceRun.runId !== input.source.runId ||
sourceRun.turnId !== input.source.turnId
) {
throw new Error('Graph schedule source does not match its durable supervisor run');
}
const definition = requireResolvedAgentDefinition(input.agentId);
assertAgentDefinitionRunnable({
parentPermissionMode: parentHeader.permissionMode,
definition,
tools: this.deps.childTools ?? [],
worktreeChildExecutorAvailable: this.deps.worktreeChildExecutor !== undefined,
});
const childPermissionMode =
parentHeader.permissionMode === 'bypass' ? 'bypass' : definition.permissionMode;
const initialTurnId = this.deps.newId();
const initialRunId = this.deps.newId();
const identityHash = stableHash({
schemaVersion: AGENT_GRAPH_OPERATOR_PROVISION_SCHEMA_VERSION,
graphId: input.graphId,
workId: input.workId,
}).slice('sha256:'.length, 'sha256:'.length + 32);
const provisionFingerprint = stableHash({
schemaVersion: AGENT_GRAPH_OPERATOR_PROVISION_SCHEMA_VERSION,
graphId: input.graphId,
workId: input.workId,
agentId: input.agentId,
operatorId: input.operatorId,
source: input.source,
edges: input.edges,
definition: {
definitionVersion: definition.definitionVersion,
agentId: definition.id,
profile: definition.profile,
workspace: definition.contract.workspace,
permissionMode: childPermissionMode,
toolNames: [...definition.tools],
categoryPolicy: { ...definition.categoryPolicy },
systemPrompt: definition.systemPrompt,
},
parentPermissionCeiling: parentHeader.permissionMode,
});
const workspace = await this.provisionChildWorkspace(
parentHeader,
definition,
provisionFingerprint,
);
const request: AgentGraphOperatorProvisionRequest = {
schemaVersion: AGENT_GRAPH_OPERATOR_PROVISION_SCHEMA_VERSION,
provisionId: `graph_provision_${identityHash}`,
provisionFingerprint,
graphId: input.graphId,
workId: input.workId,
agentId: input.agentId,
operatorId: input.operatorId,
initialTurnId,
initialRunId,
edges: input.edges.map((edge) => ({ ...edge })),
};
const result = await create.call(
this.deps.store,
{
cwd: workspace?.worktreePath ?? parentHeader.cwd,
...(parentHeader.projectId !== undefined ? { projectId: parentHeader.projectId } : {}),
name: definition.name,
backend: parentHeader.backend,
llmConnectionSlug: parentHeader.llmConnectionSlug,
model: parentHeader.model,
...(parentHeader.thinkingLevel !== undefined
? { thinkingLevel: parentHeader.thinkingLevel }
: {}),
permissionMode: childPermissionMode,
collaborationMode: 'agent',
orchestrationMode: 'default',
subagentParent: {
kind: 'subagent',
parentSessionId: input.source.sessionId,
spawnedBy: {
parentRunId: input.source.runId,
parentTurnId: input.source.turnId,
toolCallId: input.source.toolCallId,
},
graph: {
graphId: input.graphId,
workId: input.workId,
operatorId: input.operatorId,
},
lifecycle: 'foreground',
},
subagentRuntime: {
schemaVersion: SUBAGENT_SESSION_RUNTIME_SCHEMA_VERSION,
definitionVersion: definition.definitionVersion,
agentId: definition.id,
agentName: definition.name,
profile: definition.profile,
systemPrompt: definition.systemPrompt,
toolNames: [...definition.tools],
categoryPolicy: { ...definition.categoryPolicy },
permissionCeiling: parentHeader.permissionMode,
},
subagentSpawn: {
schemaVersion: SUBAGENT_SESSION_SPAWN_SCHEMA_VERSION,
requestFingerprint: provisionFingerprint.slice('sha256:'.length),
initialTurnId,
initialRunId,
},
...(workspace ? { subagentWorkspace: workspace } : {}),
},
request,
input.expectedScheduleRevision,
);
const relation = result.header.subagentParent?.graph;
if (
relation?.graphId !== input.graphId ||
relation.workId !== input.workId ||
relation.operatorId !== result.provision.operatorId ||
result.header.id !== result.provision.targetSessionId ||
!sameSubagentWorkspace(result.header.subagentWorkspace, workspace)
) {
throw new Error('Stored graph operator provision returned mismatched Session metadata');
}
return result;
}
/**
* Execute one durably claimed graph intent through the existing
* session-inline child runtime primitive.
*
* The graph claim is admission authority only. Once its exact run identity
* exists, the AgentRun/RuntimeEvent ledgers are execution authority and a
* retry observes or recovers that run instead of invoking the backend again.
*/
async runClaimedAgentGraphIntent(
input: RunClaimedAgentGraphIntentInput,
): Promise<ClaimedAgentGraphIntentResult> {
const hosted = isRuntimeHostedRootAuthority(this.deps.messageAuthority);
const hostedGraphExecution = hosted ? this.deps.hostedAgentGraphExecution : undefined;
if (hosted && !hostedGraphExecution) {
throw new RuntimeMessageAuthorityInvariantError(
'Hosted claimed graph execution requires its trusted graph execution capability',
);
}
const storedClaim = await (hostedGraphExecution ?? input.claimStore).readAgentGraphIntentClaim(
input.graphId,
input.intentId,
);
if (!storedClaim) {
throw new Error(`Graph intent ${input.graphId}/${input.intentId} has not been claimed`);
}
const claim = decodeAgentGraphIntentClaim(storedClaim);
if (claim.graphId !== input.graphId || claim.intentId !== input.intentId) {
throw new Error('Graph intent claim store returned a mismatched identity');
}
assertAgentGraphIntentExecutionMatchesClaim(claim, input.intent, input.prompt);
const resolved: ResolvedClaimedAgentGraphIntentInput = {
claim,
intent: input.intent,
prompt: input.prompt,
...(input.admitExecution ? { admitExecution: input.admitExecution } : {}),
...(hostedGraphExecution ? { hostedGraphExecution } : {}),
...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
...(input.onReady ? { onReady: input.onReady } : {}),
...(input.onEvent ? { onEvent: input.onEvent } : {}),
};
const requestFingerprint = claimedAgentGraphIntentRequestFingerprint(resolved);
const inFlight = this.claimedAgentGraphIntentRuns.get(claim.claimId);
if (inFlight) {
if (inFlight.requestFingerprint !== requestFingerprint) {
throw new Error('Graph intent claim identity was reused for different execution input');
}
return await inFlight.promise;
}
const runtimeExecution = this.runtimeKernel.claimExecution(claim.targetSessionId);
const promise = this.enqueueClaimedAgentGraphIntent(resolved, runtimeExecution).finally(() =>
runtimeExecution.release(),
);
this.claimedAgentGraphIntentRuns.set(claim.claimId, {
requestFingerprint,
promise,
});
try {
return await promise;
} finally {
if (this.claimedAgentGraphIntentRuns.get(claim.claimId)?.promise === promise) {
this.claimedAgentGraphIntentRuns.delete(claim.claimId);
}
}
}
private enqueueClaimedAgentGraphIntent(
input: ResolvedClaimedAgentGraphIntentInput,
runtimeExecution: RuntimeExecutionClaim,
): Promise<ClaimedAgentGraphIntentResult> {
const sessionId = input.claim.targetSessionId;
const previous = this.claimedAgentGraphSessionTails.get(sessionId) ?? Promise.resolve();
let enteredQueue = false;
const queuedExecution = previous
.catch(() => {
// A failed predecessor releases the Session slot for the next claim.
})
.then(() => {
if (input.abortSignal?.aborted) {
throw new Error('Claimed graph execution was cancelled before runtime admission');
}
if (runtimeExecution.stopSignal.aborted) {
throw runtimeExecution.stopSignal.reason;
}
enteredQueue = true;
return this.runClaimedAgentGraphIntentOnce(input, runtimeExecution);
});
const tail = queuedExecution.then(
() => {},
() => {},
);
this.claimedAgentGraphSessionTails.set(sessionId, tail);
void tail.then(() => {
if (this.claimedAgentGraphSessionTails.get(sessionId) === tail) {
this.claimedAgentGraphSessionTails.delete(sessionId);
}
});
let rejectStopped!: (reason?: unknown) => void;
const stopped = new Promise<never>((_resolve, reject) => {
rejectStopped = reject;
});
const onRuntimeStop = (): void => {
if (!enteredQueue) rejectStopped(runtimeExecution.stopSignal.reason);
};
runtimeExecution.stopSignal.addEventListener('abort', onRuntimeStop, { once: true });
if (runtimeExecution.stopSignal.aborted) onRuntimeStop();
return Promise.race([queuedExecution, stopped]).finally(() => {
runtimeExecution.stopSignal.removeEventListener('abort', onRuntimeStop);
});
}
private async runClaimedAgentGraphIntentOnce(
input: ResolvedClaimedAgentGraphIntentInput,
runtimeExecution: RuntimeExecutionClaim,
): Promise<ClaimedAgentGraphIntentResult> {
if (!this.deps.runStore || !this.deps.runtimeEventStore) {
throw new Error('Claimed graph execution requires AgentRunStore and RuntimeEventStore');
}
if (input.abortSignal?.aborted) {
throw new Error('Claimed graph execution was cancelled before runtime admission');
}
const { claim } = input;
const child = await this.deps.store.readHeader(claim.targetSessionId);
await this.ensureChildWorkspace(child);
const snapshot = child.subagentRuntime;
if (
child.id !== claim.targetSessionId ||
child.subagentParent?.kind !== 'subagent' ||
!snapshot
) {
throw new Error('Claimed graph execution target must be a linked child session');
}
const rootExecution: RootExecutionDescriptor = {
kind: 'claimed_agent_graph_intent',
claim,
agentId: snapshot.agentId,
agentName: snapshot.agentName,
};
const readyInfo = {
claimId: claim.claimId,
graphId: claim.graphId,
intentId: claim.intentId,
operatorId: claim.targetOperatorId,
childSessionId: child.id,
turnId: claim.targetTurnId,
runId: claim.targetRunId,
agentId: snapshot.agentId,
agentName: snapshot.agentName,
};
let readyNotification: Promise<void> | undefined;
const notifyReady = (): Promise<void> => {
readyNotification ??= Promise.resolve()
.then(() => input.onReady?.(readyInfo))
.catch(() => {
// A presentation observer must not change graph execution.
});
return readyNotification;
};
let run = await this.deps.runStore.readRun(child.id, claim.targetRunId).catch((error) => {
if (isNotFoundError(error)) return undefined;
throw error;
});
if (run) {
this.assertClaimedAgentGraphRun(child, snapshot, claim, run);
if (input.hostedGraphExecution) {
const admission = await this.requireClaimedGraphAdmissionIdentity(
claim,
input.hostedGraphExecution,
);
await this.consumeLinkedRootExecution({
sessionId: child.id,
turnId: claim.targetTurnId,
runId: claim.targetRunId,
userMessageId: admission.userMessageId,
execution: rootExecution,
content: { text: input.prompt },
start: () => {
throw new RuntimeMessageAuthorityInvariantError(
'Hosted retry attempted to start an existing claimed graph Run',
);
},
});
run = await this.deps.runStore.readRun(child.id, claim.targetRunId);
this.assertClaimedAgentGraphRun(child, snapshot, claim, run);
await this.assertClaimedAgentGraphPrompt(
child.id,
claim.targetTurnId,
input.prompt,
admission.userMessageId,
);
await notifyReady();
return claimedAgentGraphIntentResult(
claim,
await this.projectExistingChildSpawn(child, run),
);
}
await this.assertClaimedAgentGraphPrompt(child.id, claim.targetTurnId, input.prompt);
await notifyReady();
while (
!isTerminalRunStatus(run.status) &&
this.runtimeKernel.hasActiveRun?.(child.id, run.runId, run.turnId)
) {
await delay(25, undefined, input.abortSignal ? { signal: input.abortSignal } : undefined);
run = await this.deps.runStore.readRun(child.id, claim.targetRunId);
}
if (!isTerminalRunStatus(run.status)) {
await this.recoverAgentRunsFromLedger(child.id);
run = await this.deps.runStore.readRun(child.id, claim.targetRunId);
}
this.assertClaimedAgentGraphRun(child, snapshot, claim, run);
await this.assertClaimedAgentGraphPrompt(child.id, claim.targetTurnId, input.prompt);
return claimedAgentGraphIntentResult(claim, await this.projectExistingChildSpawn(child, run));
}
const [runs, messages] = await Promise.all([
this.deps.runStore.listSessionRuns(child.id),
this.deps.store.readMessages(child.id),
]);
const turnOwner = runs.find((candidate) => candidate.turnId === claim.targetTurnId);
if (turnOwner) {
throw new Error(
`Claimed graph turn ${claim.targetTurnId} is already owned by run ${turnOwner.runId}`,
);
}
if (messages.some((message) => 'turnId' in message && message.turnId === claim.targetTurnId)) {
throw new Error(`Claimed graph turn ${claim.targetTurnId} already has durable messages`);
}
if (child.isArchived || child.status === 'archived' || child.status === 'aborted') {
throw new Error('Claimed graph execution target child session is terminated');
}
if (input.abortSignal?.aborted) {
throw new Error('Claimed graph execution was cancelled before runtime admission');
}
const admitExecution = input.admitExecution;
const startedAt = this.deps.now();
const summary = new ChildAgentSummaryAccumulator();
const identity = {
sessionId: child.id,
turnId: claim.targetTurnId,
runId: claim.targetRunId,
};
let aborted = false;
let stopPromise: Promise<void> | undefined;
const userMessageId = await this.claimedGraphUserMessageId(claim, input.hostedGraphExecution);
const execution = this.consumeLinkedRootExecution({
...identity,
userMessageId,
execution: rootExecution,
...(admitExecution ? { admitExecution } : {}),
content: { text: input.prompt },
start: ({ runId, userMessageId, onRunStarted }) =>
this.sendMessage(
child.id,
{
turnId: claim.targetTurnId,
text: input.prompt,
agentId: snapshot.agentId,
agentName: snapshot.agentName,
},
{
runId,
...(userMessageId ? { userMessageId } : {}),
durability: 'required',
...(admitExecution && !input.hostedGraphExecution
? {
admitTurn: async () =>
(await admitExecution()) === 'executing' ? 'admitted' : 'cancelled',
}
: {}),
onRunStarted,
execution: runtimeExecution,
},
),
onReady: notifyReady,
onEvent: (event) => {
summary.add(event);
try {
input.onEvent?.(event);
} catch {
// A presentation observer must not change graph execution.
}
},
});
const onAbort = () => {
aborted = true;
stopPromise ??= this.stopLinkedRoot(identity, { source: 'stop_button' });
};
if (input.abortSignal) {
input.abortSignal.addEventListener('abort', onAbort, { once: true });
if (input.abortSignal.aborted) onAbort();
}
try {
await execution;
} finally {
input.abortSignal?.removeEventListener('abort', onAbort);
if (aborted) await stopPromise;
}
const completedAt = this.deps.now();
const completedRun = await this.deps.runStore.readRun(child.id, claim.targetRunId);
this.assertClaimedAgentGraphRun(child, snapshot, claim, completedRun);
const failureClass = completedRun.failureClass ?? summary.failureClass;
const artifacts = this.deps.listArtifactsForTurn
? await this.deps.listArtifactsForTurn(child.id, claim.targetTurnId)
: [];
return {
claimId: claim.claimId,
graphId: claim.graphId,
intentId: claim.intentId,
operatorId: claim.targetOperatorId,
childSessionId: child.id,
agentId: snapshot.agentId,
agentName: snapshot.agentName,
profile: snapshot.profile,
turnId: claim.targetTurnId,
runId: claim.targetRunId,
status: agentRunStatusForSpawnResult(completedRun.status),
permissionMode: child.permissionMode,
summary: summary.text(),
artifactIds: artifacts.map((artifact) => artifact.id),
startedAt,
completedAt,
durationMs: Math.max(0, completedAt - startedAt),
eventCount: summary.eventCount,
...(failureClass ? { failureClass } : {}),
};
}
private async claimedGraphUserMessageId(
claim: AgentGraphIntentClaim,
hostedGraphExecution: RuntimeHostedAgentGraphExecutionCapability | undefined,
): Promise<string> {
if (!hostedGraphExecution) return this.deps.newId();
const admission = await hostedGraphExecution.readRootTurnAdmissionIdentity(
claim.targetSessionId,
claim.targetTurnId,
);
if (!admission) return this.deps.newId();
if (admission.runId !== claim.targetRunId) {
throw new RuntimeMessageAuthorityInvariantError(
'Claimed graph RootTurn admission has a mismatched run identity',
);
}
if (admission.userMessageId === null) {
throw new RuntimeMessageAuthorityInvariantError(
'Claimed graph RootTurn admission is missing its user message identity',
);
}
return admission.userMessageId;
}
private async requireClaimedGraphAdmissionIdentity(
claim: AgentGraphIntentClaim,
hostedGraphExecution: RuntimeHostedAgentGraphExecutionCapability,
): Promise<{ runId: string; userMessageId: string }> {
const admission = await hostedGraphExecution.readRootTurnAdmissionIdentity(
claim.targetSessionId,
claim.targetTurnId,
);
if (!admission) {
throw new RuntimeMessageAuthorityInvariantError(
'Existing claimed graph Run is missing its durable RootTurn admission',
);
}
if (admission.runId !== claim.targetRunId) {
throw new RuntimeMessageAuthorityInvariantError(
'Claimed graph RootTurn admission has a mismatched run identity',
);
}
if (admission.userMessageId === null) {
throw new RuntimeMessageAuthorityInvariantError(
'Claimed graph RootTurn admission is missing its user message identity',
);
}
return { runId: admission.runId, userMessageId: admission.userMessageId };
}
private assertClaimedAgentGraphRun(
child: SessionHeader,
snapshot: NonNullable<SessionHeader['subagentRuntime']>,
claim: AgentGraphIntentClaim,
run: AgentRunHeader,
): void {
if (
run.sessionId !== child.id ||
run.runId !== claim.targetRunId ||
run.turnId !== claim.targetTurnId ||
!isSessionInlineRun(run) ||
run.agentId !== snapshot.agentId ||
(run.agentName !== undefined && run.agentName !== snapshot.agentName)
) {
throw new Error('Existing AgentRun does not match the claimed graph activation identity');
}
}
private async assertClaimedAgentGraphPrompt(
sessionId: string,
turnId: string,
prompt: string,
expectedUserMessageId?: string,
): Promise<void> {
const messages = await this.deps.store.readMessages(sessionId);
const userMessages = messages.filter(
(message): message is UserMessage => message.type === 'user' && message.turnId === turnId,
);
if (expectedUserMessageId !== undefined) {
if (
userMessages.length !== 1 ||
userMessages[0]?.id !== expectedUserMessageId ||
!messageContentsEqual(userMessages[0], { text: prompt })
) {
throw new RuntimeMessageAuthorityInvariantError(
'Existing claimed graph Run does not match its durable UserMessage',
);
}
return;
}
if (userMessages.length > 1 || (userMessages[0] && userMessages[0].text !== prompt)) {
throw new Error('Graph intent claim identity was reused for different execution input');
}
}
private async spawnChildSessionOnce(
parentSessionId: string,
input: SpawnChildSessionInput,
requestFingerprint: string,
runtimeOwner: { execution: RuntimeExecutionClaim },
): Promise<SpawnChildSessionResult> {
if (input.abortSignal?.aborted) {
throw new Error('Child session spawn was cancelled before creation');
}
if (!this.deps.runStore || !this.deps.runtimeEventStore) {
throw new Error('Child session creation requires AgentRunStore and RuntimeEventStore');
}
const [parentHeader, parentRun] = await Promise.all([
this.deps.store.readHeader(parentSessionId),
this.deps.runStore.readRun(parentSessionId, input.spawnedBy.parentRunId),
]);
this.assertActiveParentRun(parentSessionId, parentRun, input.spawnedBy.parentTurnId);
const definition = requireBuiltinAgentDefinitionByProfile(input.agentProfile);
const availableChildTools = this.deps.childTools ?? [];
assertAgentDefinitionRunnable({
parentPermissionMode: parentHeader.permissionMode,
definition,
tools: availableChildTools,
worktreeChildExecutorAvailable: this.deps.worktreeChildExecutor !== undefined,
});
const proposedTurnId = input.turnId ?? this.deps.newId();
const proposedRunId = input.runId ?? this.deps.newId();
const workspace = await this.provisionChildWorkspace(
parentHeader,
definition,
requestFingerprint,
);
const creation = await this.deps.store.createSubagent({
cwd: workspace?.worktreePath ?? parentHeader.cwd,
...(parentHeader.projectId !== undefined ? { projectId: parentHeader.projectId } : {}),
name: input.name ?? definition.name,
backend: parentHeader.backend,
llmConnectionSlug: parentHeader.llmConnectionSlug,
model: parentHeader.model,
...(parentHeader.thinkingLevel !== undefined
? { thinkingLevel: parentHeader.thinkingLevel }
: {}),
permissionMode: definition.permissionMode,
collaborationMode: 'agent',
orchestrationMode: 'default',
subagentParent: {
kind: 'subagent',
parentSessionId,
spawnedBy: input.spawnedBy,
...(input.swarm ? { swarm: input.swarm } : {}),
lifecycle: 'foreground',
},
subagentRuntime: {
schemaVersion: SUBAGENT_SESSION_RUNTIME_SCHEMA_VERSION,
definitionVersion: definition.definitionVersion,
agentId: definition.id,
agentName: definition.name,
profile: definition.profile,
systemPrompt: definition.systemPrompt,
toolNames: [...definition.tools],
categoryPolicy: { ...definition.categoryPolicy },
permissionCeiling: parentHeader.permissionMode,
},
subagentSpawn: {
schemaVersion: SUBAGENT_SESSION_SPAWN_SCHEMA_VERSION,
requestFingerprint,
initialTurnId: proposedTurnId,
initialRunId: proposedRunId,
},
...(workspace ? { subagentWorkspace: workspace } : {}),
});
const child = creation.header;
const snapshot = child.subagentRuntime;
const spawn = child.subagentSpawn;
if (
!snapshot ||
!spawn ||
!child.subagentParent ||
!sameSubagentWorkspace(child.subagentWorkspace, workspace)
) {
throw new Error('Stored child session is missing its durable runtime or spawn identity');
}
try {
runtimeOwner.execution = this.transferRuntimeExecution(runtimeOwner.execution, child.id);
} catch (error) {
if (creation.created) await this.updateStatus(child.id, 'aborted').catch(() => {});
throw error;
}
const releaseHostedExecution = this.acquireHostedLinkedChildExecution(child.id);
try {
const turnId = spawn.initialTurnId;
const runId = spawn.initialRunId;
const readyInfo = {
childSessionId: child.id,
turnId,
runId,
agentId: snapshot.agentId,
agentName: snapshot.agentName,
};
let readyNotification: Promise<void> | undefined;
const notifyReady = (): Promise<void> => {
readyNotification ??= Promise.resolve().then(() => input.onReady?.(readyInfo));
return readyNotification;
};
// Close the create/start race: if the parent settled (or cancellation
// arrived) while metadata was being written, retain an inspectable aborted
// child but do not admit new foreground work.
try {
this.assertActiveParentRun(parentSessionId, parentRun, input.spawnedBy.parentTurnId);
if (input.abortSignal?.aborted) {
throw new Error('Child session spawn was cancelled before its first run');
}
} catch (error) {
if (creation.created) await this.updateStatus(child.id, 'aborted').catch(() => {});
throw error;
}
if (!creation.created) {
const existing = await this.resolveExistingChildSpawn(child, input, notifyReady);
if (existing) return existing;
}
// A committed metadata row without its initial AgentRun is a recoverable
// crash boundary. Revalidate admission after the lookup: the parent or
// caller may have settled while durable state was being inspected.
try {
const latestParentRun = await this.deps.runStore.readRun(
parentSessionId,
input.spawnedBy.parentRunId,
);
this.assertActiveParentRun(parentSessionId, latestParentRun, input.spawnedBy.parentTurnId);
if (input.abortSignal?.aborted) {
throw new Error('Child session spawn was cancelled before its first run');
}
} catch (error) {
await this.updateStatus(child.id, 'aborted').catch(() => {});
throw error;
}
const startedAt = this.deps.now();
const summary = new ChildAgentSummaryAccumulator();
let aborted = false;
let stopPromise: Promise<void> | undefined;
const identity = { sessionId: child.id, turnId, runId };
const execution = this.consumeLinkedRootExecution(
{
...identity,
userMessageId: this.deps.newId(),
execution: {
kind: 'linked_child_initial',
agentId: snapshot.agentId,
agentName: snapshot.agentName,
},
content: { text: input.prompt },
start: ({ runId: admittedRunId, userMessageId, onRunStarted }) =>
this.sendMessage(
child.id,
{
turnId,
text: input.prompt,
agentId: snapshot.agentId,
agentName: snapshot.agentName,
},
{
runId: admittedRunId,
...(userMessageId ? { userMessageId } : {}),
durability: 'required',
onRunStarted,
execution: runtimeOwner.execution,
},
),
onReady: notifyReady,
onEvent: (event) => {
summary.add(event);
try {
input.onEvent?.(event);
} catch {
// A presentation observer must not change the child run outcome.
}
},
},
true,
);
const onAbort = () => {
aborted = true;
stopPromise ??= this.stopLinkedRoot(identity, { source: 'stop_button' });
};
if (input.abortSignal) {
input.abortSignal.addEventListener('abort', onAbort, { once: true });
if (input.abortSignal.aborted) onAbort();
}
try {
await execution;
} finally {
input.abortSignal?.removeEventListener('abort', onAbort);
if (aborted) await stopPromise;
}
const completedAt = this.deps.now();
const run = await this.findRunByTurnId(child.id, turnId);
const failureClass = run?.failureClass ?? summary.failureClass;
const artifacts = this.deps.listArtifactsForTurn
? await this.deps.listArtifactsForTurn(child.id, turnId)
: [];
return {
childSessionId: child.id,
agentId: snapshot.agentId,
agentName: snapshot.agentName,
profile: snapshot.profile,
turnId,
runId,
status: run ? agentRunStatusForSpawnResult(run.status) : summary.status(aborted),
permissionMode: child.permissionMode,
summary: summary.text(),
artifactIds: artifacts.map((artifact) => artifact.id),
startedAt,
completedAt,
durationMs: Math.max(0, completedAt - startedAt),
eventCount: summary.eventCount,
...(failureClass ? { failureClass } : {}),
};
} finally {
releaseHostedExecution();
}
}
private async resolveExistingChildSpawn(
child: SessionHeader,
input: SpawnChildSessionInput,
notifyReady: () => Promise<void>,
): Promise<SpawnChildSessionResult | undefined> {
if (!this.deps.runStore || !this.deps.runtimeEventStore) return undefined;
const snapshot = child.subagentRuntime;
const spawn = child.subagentSpawn;
if (!snapshot || !spawn) {
throw new Error('Stored child session is missing its durable runtime or spawn identity');
}
let run = await this.deps.runStore.readRun(child.id, spawn.initialRunId).catch((error) => {
if (isNotFoundError(error)) return undefined;
throw error;
});
if (!run) return undefined;
await notifyReady();
while (
!isTerminalRunStatus(run.status) &&
this.runtimeKernel.hasActiveRun?.(child.id, run.runId, run.turnId)
) {
await delay(25, undefined, input.abortSignal ? { signal: input.abortSignal } : undefined);
run = await this.deps.runStore.readRun(child.id, spawn.initialRunId);
}
if (!isTerminalRunStatus(run.status)) {
await this.recoverAgentRunsFromLedger(child.id);
run = await this.deps.runStore.readRun(child.id, spawn.initialRunId);
}
return await this.projectExistingChildSpawn(child, run);
}
private async projectExistingChildSpawn(
child: SessionHeader,
run: AgentRunHeader,
): Promise<SpawnChildSessionResult> {
if (!this.deps.runtimeEventStore) {
throw new Error('Child session projection requires RuntimeEventStore');
}
const snapshot = child.subagentRuntime;
if (!snapshot) throw new Error('Stored child session is missing its durable runtime snapshot');
const [messages, runtimeEvents, artifacts] = await Promise.all([
this.deps.store.readMessages(child.id),
this.deps.runtimeEventStore.readRuntimeEvents(child.id, run.runId),
this.deps.listArtifactsForTurn
? this.deps.listArtifactsForTurn(child.id, run.turnId)
: Promise.resolve([]),
]);
const storedSummary =
messages
.filter(
(message): message is Extract<StoredMessage, { type: 'assistant' }> =>
message.type === 'assistant' && message.turnId === run.turnId,
)
.at(-1)?.text ?? '';
const runtimeText = runtimeEvents.filter(
(
event,
): event is RuntimeEvent & {
content: Extract<NonNullable<RuntimeEvent['content']>, { kind: 'text' }>;
} => event.role === 'model' && event.content?.kind === 'text',
);
const durableRuntimeSummary = runtimeText.filter((event) => !event.partial).at(-1)
?.content.text;
const partialRuntimeSummary = runtimeText
.filter((event) => event.partial)
.map((event) => event.content.text)
.join('');
const completedAt = run.completedAt ?? run.updatedAt;
return {
childSessionId: child.id,
agentId: snapshot.agentId,
agentName: snapshot.agentName,
profile: snapshot.profile,
turnId: run.turnId,
runId: run.runId,
status: agentRunStatusForSpawnResult(run.status),
permissionMode: child.permissionMode,
summary: trimSummary(durableRuntimeSummary ?? (storedSummary || partialRuntimeSummary)),
artifactIds: artifacts.map((artifact) => artifact.id),
startedAt: run.createdAt,
completedAt,
durationMs: Math.max(0, completedAt - run.createdAt),
eventCount: runtimeEvents.length,
...(run.failureClass ? { failureClass: run.failureClass } : {}),
};
}
async prepareChildAgentResume(
sessionId: string,
sourceRunId: string,
): Promise<PrepareChildAgentResumeResult> {
if (!this.deps.runStore || !this.deps.runtimeEventStore) {
throw new Error('Child AgentRun resume requires AgentRunStore and RuntimeEventStore');
}
const execution = await this.resolveChildAgentExecution(sessionId, sourceRunId);
return await this.prepareChildAgentResumeForExecution(sessionId, execution, sourceRunId);
}
private async prepareChildAgentResumeForExecution(
sessionId: string,
execution: SubagentExecutionRef,
sourceRunId: string,
): Promise<PrepareChildAgentResumeResult> {
if (!this.deps.runStore || !this.deps.runtimeEventStore) {
throw new Error('Child AgentRun resume requires AgentRunStore and RuntimeEventStore');
}
if (execution.kind === 'child_session') {
return await this.prepareLinkedChildSessionResume(sessionId, execution, sourceRunId);
}
const runs = await this.deps.runStore.listSessionRuns(sessionId);
const runsById = new Map(runs.map((run) => [run.runId, run]));
const source = runsById.get(sourceRunId);
if (!source || !source.parentRunId || isSessionInlineRun(source)) {
throw new Error(`Child AgentRun resume source ${sourceRunId} was not found`);
}
const definition = source.agentId ? getBuiltinAgentDefinition(source.agentId) : undefined;
if (!definition) {
throw new Error(`AgentRun ${sourceRunId} is not a resumable built-in child agent`);
}
const sessionHeader = await this.deps.store.readHeader(sessionId);
await this.ensureChildWorkspace(sessionHeader);
assertAgentDefinitionRunnable({
parentPermissionMode: sessionHeader.permissionMode,
definition,
tools: this.deps.childTools ?? [],
worktreeChildExecutorAvailable: this.deps.worktreeChildExecutor !== undefined,
});
const visited = new Set<string>();
let cursor: AgentRunHeader | undefined = source;
while (cursor) {
if (visited.has(cursor.runId)) {
throw new Error(`Child AgentRun resume lineage contains a cycle at ${cursor.runId}`);
}
visited.add(cursor.runId);
if (!cursor.parentRunId || isSessionInlineRun(cursor) || cursor.agentId !== definition.id) {
throw new Error(`Child AgentRun resume profile changed at ${cursor.runId}`);
}
if (
cursor.backendKind !== sessionHeader.backend ||
cursor.llmConnectionSlug !== sessionHeader.llmConnectionSlug ||
cursor.modelId !== sessionHeader.model ||
cursor.cwd !== sessionHeader.cwd ||
cursor.permissionMode !== definition.permissionMode
) {
throw new Error(`Child AgentRun resume environment changed for ${cursor.runId}`);
}
const events = await this.deps.runtimeEventStore
.readRuntimeEvents(sessionId, cursor.runId)
.catch(() => []);
const replay = buildRuntimeEventModelReplayPlan(events);
const unsafe = replay.diagnostics.find((diagnostic) =>
isUnsafeChildResumeDiagnostic(diagnostic.code),
);
if (unsafe) {
throw new Error(`Child AgentRun resume history is unsafe: ${unsafe.code}`);
}
const first = replay.items[0];
if (!first || first.kind !== 'text' || first.role !== 'user') {
throw new Error(
`Child AgentRun resume source ${cursor.runId} has no user-anchored history`,
);
}
const terminal = classifyTerminalRuntimeLedger(cursor, events);
if (terminal.kind !== 'fact') {
throw new Error(`Child AgentRun resume source ${cursor.runId} is not durably terminal`);
}
const effective = effectiveRunHeaderFromTerminalFact(cursor, terminal.fact);
if (!['completed', 'failed', 'cancelled'].includes(effective.status)) {
throw new Error(`Child AgentRun resume source ${cursor.runId} is not in a resumable state`);
}
const previousRunId = cursor.resumedFromRunId ?? cursor.retriedFromRunId;
if (!previousRunId) break;
cursor = runsById.get(previousRunId);
if (!cursor) {
throw new Error(`Child AgentRun resume source ${previousRunId} was not found`);
}
}
this.assertChildRunHasNoSuccessor(runs, sourceRunId);
return {
sourceRunId,
execution,
agentId: definition.id,
agentName: definition.name,
profile: definition.profile,
};
}
private async resolveChildAgentExecution(
parentSessionId: string,
sourceRunId: string,
): Promise<SubagentExecutionRef> {
if (!this.deps.runStore) {
throw new Error('Child AgentRun lookup requires AgentRunStore');
}
const legacy = await this.deps.runStore.readRun(parentSessionId, sourceRunId).catch((error) => {
if (isNotFoundError(error)) return undefined;
throw error;
});
if (legacy?.parentRunId && !isSessionInlineRun(legacy)) {
return {
kind: 'legacy_child_run',
sessionId: parentSessionId,
runId: sourceRunId,
};
}
const children = await this.listChildSessions(parentSessionId);
const matches = (
await Promise.all(
children.map(async (child) => {
const run = await this.deps.runStore!.readRun(child.id, sourceRunId).catch((error) => {
if (isNotFoundError(error)) return undefined;
throw error;
});
return run && isSessionInlineRun(run) ? child.id : undefined;
}),
)
).filter((childSessionId): childSessionId is string => childSessionId !== undefined);
if (matches.length !== 1) {
throw new Error(`Child AgentRun resume source ${sourceRunId} was not found`);
}
return {
kind: 'child_session',
sessionId: matches[0]!,
currentRunId: sourceRunId,
};
}
private async prepareLinkedChildSessionResume(
parentSessionId: string,
execution: Extract<SubagentExecutionRef, { kind: 'child_session' }>,
sourceRunId: string,
): Promise<PrepareChildAgentResumeResult> {
if (!this.deps.runStore || !this.deps.runtimeEventStore) {
throw new Error('Child Session resume requires AgentRunStore and RuntimeEventStore');
}
const child = await this.deps.store.readHeader(execution.sessionId);
const snapshot = child.subagentRuntime;
if (
child.subagentParent?.kind !== 'subagent' ||
child.subagentParent.parentSessionId !== parentSessionId ||
!snapshot
) {
throw new Error(`Child AgentRun resume source ${sourceRunId} was not found`);
}
if (!isPermissionModeWithinCeiling(child.permissionMode, snapshot.permissionCeiling)) {
throw new Error('Child Session permission mode exceeds its durable runtime ceiling');
}
const runnableTools = buildToolsForAgentDefinition(this.deps.childTools ?? [], {
id: snapshot.agentId,
permissionMode: child.permissionMode,
tools: snapshot.toolNames,
categoryPolicy: snapshot.categoryPolicy,
});
if (runnableTools.length !== snapshot.toolNames.length) {
throw new Error('Child Session durable runtime tool snapshot is unavailable');
}
const runs = await this.deps.runStore.listSessionRuns(child.id);
const runsById = new Map(runs.map((run) => [run.runId, run]));
const source = runsById.get(sourceRunId);
if (!source || !isSessionInlineRun(source)) {
throw new Error(`Child AgentRun resume source ${sourceRunId} was not found`);
}
const visited = new Set<string>();
const replaySegments: RuntimeEvent[][] = [];
let cursor: AgentRunHeader | undefined = source;
while (cursor) {
if (visited.has(cursor.runId)) {
throw new Error(`Child AgentRun resume lineage contains a cycle at ${cursor.runId}`);
}
visited.add(cursor.runId);
if (!isSessionInlineRun(cursor) || cursor.agentId !== snapshot.agentId) {
throw new Error(`Child AgentRun resume profile changed at ${cursor.runId}`);
}
if (
cursor.backendKind !== child.backend ||
cursor.llmConnectionSlug !== child.llmConnectionSlug ||
cursor.modelId !== child.model ||
cursor.cwd !== child.cwd ||
cursor.permissionMode !== child.permissionMode
) {
throw new Error(`Child AgentRun resume environment changed for ${cursor.runId}`);
}
const events = await this.deps.runtimeEventStore
.readRuntimeEvents(child.id, cursor.runId)
.catch(() => []);
const replay = buildRuntimeEventModelReplayPlan(events);
const unsafe = replay.diagnostics.find((diagnostic) =>
isUnsafeChildResumeDiagnostic(diagnostic.code),
);
if (unsafe) {
throw new Error(`Child AgentRun resume history is unsafe: ${unsafe.code}`);
}
replaySegments.unshift(events);
const terminal = classifyTerminalRuntimeLedger(cursor, events);
if (terminal.kind !== 'fact') {
throw new Error(`Child AgentRun resume source ${cursor.runId} is not durably terminal`);
}
const effective = effectiveRunHeaderFromTerminalFact(cursor, terminal.fact);
if (!['completed', 'failed', 'cancelled'].includes(effective.status)) {
throw new Error(`Child AgentRun resume source ${cursor.runId} is not in a resumable state`);
}
const previousRunId = cursor.resumedFromRunId ?? cursor.retriedFromRunId;
if (!previousRunId) break;
cursor = runsById.get(previousRunId);
if (!cursor) {
throw new Error(`Child AgentRun resume source ${previousRunId} was not found`);
}
}
const replay = buildRuntimeEventModelReplayPlan(replaySegments.flat());
const first = replay.items[0];
if (!first || first.kind !== 'text' || first.role !== 'user') {
throw new Error(`Child AgentRun resume source ${sourceRunId} has no user-anchored history`);
}
this.assertChildRunHasNoSuccessor(runs, sourceRunId);
return {
sourceRunId,
execution,
agentId: snapshot.agentId,
agentName: snapshot.agentName,
profile: snapshot.profile,
};
}
async resumeChildAgent(
sessionId: string,
input: ResumeChildAgentInput,
): Promise<SpawnChildAgentResult> {
let runtimeExecution = this.runtimeKernel.claimExecution(sessionId);
try {
throwIfChildExecutionAborted(
input.abortSignal,
'Child agent resume was cancelled before start',
);
const execution = await this.resolveChildAgentExecution(sessionId, input.sourceRunId);
if (execution.kind === 'child_session') {
runtimeExecution = this.transferRuntimeExecution(runtimeExecution, execution.sessionId);
const releaseHostedExecution = this.acquireHostedLinkedChildExecution(execution.sessionId);
try {
const prepared = await this.prepareChildAgentResumeForExecution(
sessionId,
execution,
input.sourceRunId,
);
return await this.resumeLinkedChildSession(
sessionId,
execution,
prepared,
input,
runtimeExecution,
true,
);
} finally {
releaseHostedExecution();
}
}
const prepared = await this.prepareChildAgentResumeForExecution(
sessionId,
execution,
input.sourceRunId,
);
const definition = getBuiltinAgentDefinition(prepared.agentId)!;
return await this.runChildAgent(
sessionId,
definition,
input,
runtimeExecution,
input.sourceRunId,
);
} finally {
runtimeExecution.release();
}
}
private transferRuntimeExecution(
source: RuntimeExecutionClaim,
targetSessionId: string,
): RuntimeExecutionClaim {
if (source.sessionId === targetSessionId) return source;
let target: RuntimeExecutionClaim;
try {
target = this.runtimeKernel.claimExecution(targetSessionId);
} catch (error) {
source.release();
throw error;
}
return this.handoffRuntimeExecution(source, target);
}
private handoffRuntimeExecution(
source: RuntimeExecutionClaim,
target: RuntimeExecutionClaim,
): RuntimeExecutionClaim {
if (source.sessionId === target.sessionId) {
target.release();
return source;
}
const stopped = source.isStopRequested();
source.release();
if (stopped) {
target.release();
throw new Error(
`Session ${source.sessionId} stopped before child execution ownership transferred`,
);
}
return target;
}
private async resumeLinkedChildSession(
parentSessionId: string,
execution: Extract<SubagentExecutionRef, { kind: 'child_session' }>,
prepared: PrepareChildAgentResumeResult,
input: ResumeChildAgentInput,
runtimeExecution: RuntimeExecutionClaim,
hostedGateAlreadyHeld: boolean,
): Promise<SpawnChildAgentResult> {
throwIfChildExecutionAborted(
input.abortSignal,
'Child agent resume was cancelled before start',
);
if (!this.deps.runStore) {
throw new Error('Child Session resume requires AgentRunStore');
}
const [parentRun, child] = await Promise.all([
this.deps.runStore.readRun(parentSessionId, input.parentRunId),
this.deps.store.readHeader(execution.sessionId),
]);
this.assertActiveParentRun(parentSessionId, parentRun, parentRun.turnId);
if (
child.subagentParent?.kind !== 'subagent' ||
child.subagentParent.parentSessionId !== parentSessionId ||
child.subagentRuntime?.agentId !== prepared.agentId
) {
throw new Error(`Child AgentRun resume source ${input.sourceRunId} was not found`);
}
const turnId = input.turnId ?? this.deps.newId();
const runId = this.deps.newId();
const startedAt = this.deps.now();
const summary = new ChildAgentSummaryAccumulator();
let aborted = input.abortSignal?.aborted === true;
let stopPromise: Promise<void> | undefined;
const identity = { sessionId: child.id, turnId, runId };
throwIfChildExecutionAborted(
input.abortSignal,
'Child agent resume was cancelled before start',
);
const executionPromise = this.consumeLinkedRootExecution(
{
...identity,
userMessageId: this.deps.newId(),
execution: {
kind: 'linked_child_resume',
agentId: prepared.agentId,
agentName: prepared.agentName,
sourceRunId: input.sourceRunId,
},
content: { text: input.prompt },
start: ({ runId: admittedRunId, userMessageId, onRunStarted }) =>
this.sendMessage(
child.id,
{
turnId,
text: input.prompt,
resumedFromRunId: input.sourceRunId,
agentId: prepared.agentId,
agentName: prepared.agentName,
},
{
runId: admittedRunId,
...(userMessageId ? { userMessageId } : {}),
durability: 'required',
onRunStarted,
execution: runtimeExecution,
},
),
onReady: () =>
input.onReady?.({
childSessionId: child.id,
turnId,
runId,
agentId: prepared.agentId,
agentName: prepared.agentName,
}),
onEvent: (event) => {
summary.add(event);
try {
input.onEvent?.(event);
} catch {
// A presentation observer must not change the child run outcome.
}
},
},
hostedGateAlreadyHeld,
);
const onAbort = () => {
aborted = true;
stopPromise ??= this.stopLinkedRoot(identity, { source: 'stop_button' });
};
if (input.abortSignal) {
input.abortSignal.addEventListener('abort', onAbort, { once: true });
if (input.abortSignal.aborted) onAbort();
}
try {
await executionPromise;
} finally {
input.abortSignal?.removeEventListener('abort', onAbort);
if (aborted) await stopPromise;
}
const completedAt = this.deps.now();
const run = await this.findRunByTurnId(child.id, turnId);
const failureClass = run?.failureClass ?? summary.failureClass;
const artifacts = this.deps.listArtifactsForTurn
? await this.deps.listArtifactsForTurn(child.id, turnId)
: [];
return {
childSessionId: child.id,
agentId: prepared.agentId,
agentName: prepared.agentName,
turnId,
runId,
resumedFromRunId: input.sourceRunId,
status: run ? agentRunStatusForSpawnResult(run.status) : summary.status(aborted),
permissionMode: child.permissionMode,
summary: summary.text(),
artifactIds: artifacts.map((artifact) => artifact.id),
startedAt,
completedAt,
durationMs: Math.max(0, completedAt - startedAt),
eventCount: summary.eventCount,
...(failureClass ? { failureClass } : {}),
};
}
private async runChildAgent(
sessionId: string,
definition: AgentDefinition,
input: SpawnChildAgentInput | ResumeChildAgentInput,
execution: RuntimeExecutionClaim,
resumedFromRunId?: string,
): Promise<SpawnChildAgentResult> {
throwIfChildExecutionAborted(
input.abortSignal,
'Child agent execution was cancelled before start',
);
const turnId = input.turnId ?? this.deps.newId();
const startedAt = this.deps.now();
const summary = new ChildAgentSummaryAccumulator();
let aborted = input.abortSignal?.aborted === true;
await input.onReady?.({ turnId, agentId: definition.id, agentName: definition.name });
const iterator = this.runtimeKernel
.startChildTurn(
sessionId,
{
turnId,
parentRunId: input.parentRunId,
spec: {
id: definition.id,
name: definition.name,
systemPrompt: definition.systemPrompt,
},
prompt: input.prompt,
...(resumedFromRunId ? { resumedFromRunId } : {}),
},
execution,
)
[Symbol.asyncIterator]();
const onAbort = () => {
aborted = true;
void iterator.return?.();
};
if (input.abortSignal && !input.abortSignal.aborted) {
input.abortSignal.addEventListener('abort', onAbort, { once: true });
}
try {
while (!aborted) {
const next = await iterator.next();
if (next.done) break;
summary.add(next.value);
try {
input.onEvent?.(next.value);
} catch {
// A presentation observer must not change the child run outcome.
}
}
} finally {
input.abortSignal?.removeEventListener('abort', onAbort);
if (aborted) await iterator.return?.();
}
const completedAt = this.deps.now();
const run = await this.findRunByTurnId(sessionId, turnId);
const failureClass = run?.failureClass ?? summary.failureClass;
const artifacts = this.deps.listArtifactsForTurn
? await this.deps.listArtifactsForTurn(sessionId, turnId)
: [];
return {
agentId: definition.id,
agentName: definition.name,
turnId,
...(run?.runId ? { runId: run.runId } : {}),
status: run ? agentRunStatusForSpawnResult(run.status) : summary.status(aborted),
permissionMode: definition.permissionMode,
summary: summary.text(),
artifactIds: artifacts.map((artifact) => artifact.id),
startedAt,
completedAt,
durationMs: Math.max(0, completedAt - startedAt),
eventCount: summary.eventCount,
...(failureClass ? { failureClass } : {}),
...(resumedFromRunId ? { resumedFromRunId } : {}),
};
}
async retryChildAgent(
sessionId: string,
input: RetryChildAgentInput,
): Promise<SpawnChildAgentResult> {
let runtimeExecution = this.runtimeKernel.claimExecution(sessionId);
let hintedTargetExecution: RuntimeExecutionClaim | undefined;
try {
if (input.execution?.kind === 'child_session') {
hintedTargetExecution = this.runtimeKernel.claimExecution(input.execution.sessionId);
}
throwIfChildExecutionAborted(
input.abortSignal,
'Child agent retry was cancelled before start',
);
if (!this.deps.runStore || !this.deps.runtimeEventStore) {
throw new Error('Child agent retry requires AgentRunStore and RuntimeEventStore');
}
const execution = await this.resolveChildAgentExecution(sessionId, input.sourceRunId);
this.assertChildRetryExecutionIdentity(input, execution);
if (execution.kind === 'child_session') {
if (hintedTargetExecution) {
runtimeExecution = this.handoffRuntimeExecution(runtimeExecution, hintedTargetExecution);
hintedTargetExecution = undefined;
} else {
runtimeExecution = this.transferRuntimeExecution(runtimeExecution, execution.sessionId);
}
}
const releaseHostedExecution =
execution.kind === 'child_session'
? this.acquireHostedLinkedChildExecution(execution.sessionId)
: () => {};
try {
return await this.retryChildAgentWithExecution(
sessionId,
input,
execution,
runtimeExecution,
);
} finally {
releaseHostedExecution();
}
} finally {
hintedTargetExecution?.release();
runtimeExecution.release();
}
}
private assertChildRetryExecutionIdentity(
input: RetryChildAgentInput,
execution: SubagentExecutionRef,
): void {
if (
input.execution &&
(input.execution.kind !== execution.kind ||
input.execution.sessionId !== execution.sessionId ||
(input.execution.kind === 'legacy_child_run' &&
(execution.kind !== 'legacy_child_run' || input.execution.runId !== execution.runId)) ||
(input.execution.kind === 'child_session' &&
input.execution.currentRunId !== undefined &&
input.execution.currentRunId !== input.sourceRunId))
) {
throw new Error('Child agent retry execution identity changed');
}
}
private async retryChildAgentWithExecution(
sessionId: string,
input: RetryChildAgentInput,
execution: SubagentExecutionRef,
runtimeExecution: RuntimeExecutionClaim,
): Promise<SpawnChildAgentResult> {
throwIfChildExecutionAborted(input.abortSignal, 'Child agent retry was cancelled before start');
if (!this.deps.runStore || !this.deps.runtimeEventStore) {
throw new Error('Child agent retry requires AgentRunStore and RuntimeEventStore');
}
const targetSessionId = execution.sessionId;
const runs = await this.deps.runStore.listSessionRuns(targetSessionId);
const rawSourceRun = runs.find((run) => run.runId === input.sourceRunId);
if (!rawSourceRun) throw new Error('Child agent retry source run was not found');
const sourceRun = await this.effectiveRunHeaderFromRuntimeLedger(rawSourceRun);
let definition: {
id: string;
name: string;
systemPrompt: string;
permissionMode: PermissionMode;
};
if (execution.kind === 'child_session') {
const [parentRun, child] = await Promise.all([
this.deps.runStore.readRun(sessionId, input.parentRunId),
this.deps.store.readHeader(targetSessionId),
]);
this.assertActiveParentRun(sessionId, parentRun, parentRun.turnId);
const snapshot = child.subagentRuntime;
if (
child.subagentParent?.kind !== 'subagent' ||
child.subagentParent.parentSessionId !== sessionId ||
!snapshot ||
!isSessionInlineRun(sourceRun) ||
sourceRun.agentId !== snapshot.agentId
) {
throw new Error('Child agent retry source does not belong to the parent Session');
}
definition = {
id: snapshot.agentId,
name: snapshot.agentName,
systemPrompt: snapshot.systemPrompt,
permissionMode: child.permissionMode,
};
} else {
if (!sourceRun.parentRunId || sourceRun.parentRunId !== input.parentRunId) {
throw new Error('Child agent retry source does not belong to the active parent run');
}
if (!sourceRun.agentId) throw new Error('Child agent retry source is missing its agent id');
definition = requireResolvedAgentDefinition(sourceRun.agentId);
}
if (sourceRun.status !== 'failed' || sourceRun.failureClass !== 'RateLimit') {
throw new Error('Child agent retry source must be a provider rate-limit failure');
}
this.assertChildRunHasNoSuccessor(runs, sourceRun.runId);
const replaySegments: RuntimeEvent[][] = [];
let chainRun: AgentRunHeader | undefined = rawSourceRun;
const visited = new Set<string>();
while (chainRun) {
if (visited.has(chainRun.runId))
throw new Error('Child agent retry lineage contains a cycle');
visited.add(chainRun.runId);
const events = await this.deps.runtimeEventStore.readRuntimeEvents(
targetSessionId,
chainRun.runId,
);
const plan = buildResumePlanFromRuntimeEvents(events);
if (plan.disposition !== 'safe_replay') {
throw new Error(`Child agent retry source is not safely replayable: ${chainRun.runId}`);
}
replaySegments.unshift(plan.replayRuntimeEvents);
const previousRunId: string | undefined =
chainRun.retriedFromRunId ?? chainRun.resumedFromRunId;
if (!previousRunId) break;
chainRun = runs.find((run) => run.runId === previousRunId);
if (!chainRun) throw new Error('Child agent retry lineage source is missing');
}
const sourceEvents = await this.deps.runtimeEventStore.readRuntimeEvents(
targetSessionId,
sourceRun.runId,
);
const sourcePlan = buildResumePlanFromRuntimeEvents(sourceEvents);
if (sourcePlan.disposition !== 'safe_replay') {
throw new Error('Child agent retry source is not safely replayable');
}
const sourceInvocationId = sourceRun.invocationId ?? sourceEvents[0]?.invocationId;
if (!sourceInvocationId) throw new Error('Child agent retry source has no invocation id');
const retryReplay = buildRuntimeEventModelReplayPlan(replaySegments.flat());
const retryAnchor = retryReplay.items[0];
if (!retryAnchor || retryAnchor.kind !== 'text' || retryAnchor.role !== 'user') {
throw new Error('Child agent retry source has no user-anchored history');
}
const turnId = this.deps.newId();
const runId = this.deps.newId();
const invocationId = this.deps.newId();
const continuation: RuntimeContinuation = {
sessionId: targetSessionId,
invocationId,
runId,
turnId,
sourceInvocationId,
sourceRunId: sourceRun.runId,
sourceTurnId: sourceRun.turnId,
sourceRuntimeEventHighWater: sourceEvents.length,
sourceRuntimeContext: sourcePlan.replayRuntimeEvents,
runtimeContext: replaySegments.flat(),
safetySnapshot: {
workspaceIdentity: sourceRun.workspaceIdentity ?? sourceRun.cwd,
backgroundOperationsSettled: true,
availableToolNames: [],
},
};
const startedAt = this.deps.now();
const summary = new ChildAgentSummaryAccumulator();
let aborted = input.abortSignal?.aborted === true;
const startChildRetry = this.runtimeKernel.startChildRetry;
if (!startChildRetry) throw new Error('RuntimeKernel does not support child agent retry');
const linkedIdentity = { sessionId: targetSessionId, turnId, runId };
const emitReady = () =>
input.onReady?.({
...(execution.kind === 'child_session'
? { childSessionId: execution.sessionId, runId }
: {}),
turnId,
agentId: definition.id,
agentName: definition.name,
});
const consumeEvent = (event: SessionEvent) => {
summary.add(event);
try {
input.onEvent?.(event);
} catch {
// A presentation observer must not change the child run outcome.
}
};
throwIfChildExecutionAborted(input.abortSignal, 'Child agent retry was cancelled before start');
const executionPromise =
execution.kind === 'child_session'
? this.consumeLinkedRootExecution(
{
...linkedIdentity,
userMessageId: null,
execution: {
kind: 'linked_child_provider_retry',
agentId: definition.id,
agentName: definition.name,
sourceRunId: sourceRun.runId,
},
content: {
text: retryAnchor.content,
...(retryAnchor.attachments ? { attachments: retryAnchor.attachments } : {}),
},
start: ({ onRunStarted }) =>
startChildRetry.call(
this.runtimeKernel,
targetSessionId,
{
parentRunId: input.parentRunId,
spec: {
id: definition.id,
name: definition.name,
systemPrompt: definition.systemPrompt,
},
continuation,
linkedSession: true,
onRunStarted,
},
runtimeExecution,
),
onReady: emitReady,
onEvent: consumeEvent,
},
true,
)
: this.consumeRuntimeEvents(
startChildRetry.call(
this.runtimeKernel,
targetSessionId,
{
parentRunId: input.parentRunId,
spec: {
id: definition.id,
name: definition.name,
systemPrompt: definition.systemPrompt,
},
continuation,
},
runtimeExecution,
),
emitReady,
consumeEvent,
);
let stopPromise: Promise<void> | undefined;
const onAbort = () => {
aborted = true;
if (execution.kind === 'child_session') {
stopPromise ??= this.stopLinkedRoot(linkedIdentity, { source: 'stop_button' });
} else {
stopPromise ??= this.runtimeKernel.stopSession(targetSessionId, {
source: 'stop_button',
});
}
};
if (input.abortSignal) {
input.abortSignal.addEventListener('abort', onAbort, { once: true });
if (input.abortSignal.aborted) onAbort();
}
try {
await executionPromise;
} finally {
input.abortSignal?.removeEventListener('abort', onAbort);
if (aborted) await stopPromise;
}
const completedAt = this.deps.now();
const run = await this.findRunByTurnId(targetSessionId, turnId);
const failureClass = run?.failureClass ?? summary.failureClass;
const artifacts = this.deps.listArtifactsForTurn
? await this.deps.listArtifactsForTurn(targetSessionId, turnId)
: [];
return {
...(execution.kind === 'child_session' ? { childSessionId: execution.sessionId } : {}),
agentId: definition.id,
agentName: definition.name,
turnId,
...(run?.runId ? { runId: run.runId } : { runId }),
retriedFromRunId: sourceRun.runId,
status: run ? agentRunStatusForSpawnResult(run.status) : summary.status(aborted),
permissionMode: definition.permissionMode,
summary: summary.text(),
artifactIds: artifacts.map((artifact) => artifact.id),
startedAt,
completedAt,
durationMs: Math.max(0, completedAt - startedAt),
eventCount: summary.eventCount,
...(failureClass ? { failureClass } : {}),
};
}
private assertChildRunHasNoSuccessor(runs: readonly AgentRunHeader[], sourceRunId: string): void {
if (
runs.some(
(run) => run.resumedFromRunId === sourceRunId || run.retriedFromRunId === sourceRunId,
)
) {
throw new Error(`Child AgentRun ${sourceRunId} already has a successor`);
}
}
async listChildAgents(sessionId: string): Promise<AgentListResult> {
const header = await this.deps.store.readHeader(sessionId);
const definitions = listBuiltinAgentDefinitions({
parentPermissionMode: header.permissionMode,
tools: this.deps.childTools ?? [],
worktreeChildExecutorAvailable: this.deps.worktreeChildExecutor !== undefined,
});
if (!this.deps.runStore) return { definitions, executions: [], runs: [] };
const runs = await this.deps.runStore.listSessionRuns(sessionId);
const childRuns = await Promise.all(
runs
.filter(
(run): run is AgentRunHeader & { parentRunId: string } =>
!!run.parentRunId && !isSessionInlineRun(run),
)
.map(
async (run): Promise<AgentRunHeader & { parentRunId: string }> => ({
...(await this.effectiveRunHeaderFromRuntimeLedger(run)),
parentRunId: run.parentRunId,
}),
),
);
const legacyRuns = childRuns.map((run) => ({
runId: run.runId,
turnId: run.turnId,
parentRunId: run.parentRunId,
...(run.agentId ? { agentId: run.agentId } : {}),
...(run.agentName ? { agentName: run.agentName } : {}),
status: run.status,
permissionMode: run.permissionMode,
createdAt: run.createdAt,
updatedAt: run.updatedAt,
...(run.completedAt !== undefined ? { completedAt: run.completedAt } : {}),
...(run.completedAt !== undefined
? { durationMs: Math.max(0, run.completedAt - run.createdAt) }
: {}),
...(run.failureClass ? { failureClass: run.failureClass } : {}),
}));
const childSessionHeaders = await Promise.all(
(await this.listChildSessions(sessionId)).map((child) =>
this.deps.store.readHeader(child.id),
),
);
const childSessionExecutions = await Promise.all(
childSessionHeaders.map(async (child): Promise<SubagentExecutionListItem> => {
const childRuns = await this.deps.runStore!.listSessionRuns(child.id);
const latest = childRuns
.slice()
.sort(
(left, right) =>
right.createdAt - left.createdAt ||
right.updatedAt - left.updatedAt ||
right.runId.localeCompare(left.runId),
)[0];
const run = latest ? await this.effectiveRunHeaderFromRuntimeLedger(latest) : undefined;
const currentRunId = run?.runId ?? child.subagentSpawn?.initialRunId;
return {
execution: {
kind: 'child_session',
sessionId: child.id,
...(currentRunId ? { currentRunId } : {}),
},
...(child.subagentRuntime?.agentId ? { agentId: child.subagentRuntime.agentId } : {}),
...(child.subagentRuntime?.agentName
? { agentName: child.subagentRuntime.agentName }
: {}),
...(child.subagentRuntime?.profile ? { profile: child.subagentRuntime.profile } : {}),
...(run?.turnId ? { turnId: run.turnId } : {}),
status: run?.status ?? (child.status === 'aborted' ? 'cancelled' : 'created'),
permissionMode: run?.permissionMode ?? child.permissionMode,
createdAt: run?.createdAt ?? child.createdAt,
updatedAt: run?.updatedAt ?? child.lastUsedAt,
...(run?.completedAt !== undefined ? { completedAt: run.completedAt } : {}),
...(run?.completedAt !== undefined
? { durationMs: Math.max(0, run.completedAt - run.createdAt) }
: {}),
...(run?.failureClass ? { failureClass: run.failureClass } : {}),
};
}),
);
return {
definitions,
executions: [
...childSessionExecutions,
...childRuns.map(
(run): SubagentExecutionListItem => ({
execution: {
kind: 'legacy_child_run',
sessionId,
runId: run.runId,
},
...(run.agentId ? { agentId: run.agentId } : {}),
...(run.agentName ? { agentName: run.agentName } : {}),
turnId: run.turnId,
status: run.status,
permissionMode: run.permissionMode,
createdAt: run.createdAt,
updatedAt: run.updatedAt,
...(run.completedAt !== undefined ? { completedAt: run.completedAt } : {}),
...(run.completedAt !== undefined
? { durationMs: Math.max(0, run.completedAt - run.createdAt) }
: {}),
...(run.failureClass ? { failureClass: run.failureClass } : {}),
}),
),
],
runs: legacyRuns,
};
}
async readChildAgentOutput(
sessionId: string,
input: AgentOutputInput,
): Promise<AgentOutputResult> {
if (!this.deps.runStore || !this.deps.runtimeEventStore) {
throw new Error('agent_output requires AgentRunStore and RuntimeEventStore');
}
const located = await this.findChildRunForOutput(sessionId, input);
const { header } = located;
const inspected = await inspectAgentRunReadModel(
this.deps.runStore,
this.deps.runtimeEventStore,
{
sessionId: header.sessionId,
runId: header.runId,
header,
},
);
const artifacts = this.deps.listArtifactsForTurn
? await this.deps.listArtifactsForTurn(header.sessionId, header.turnId)
: [];
const maxEvents = normalizeAgentOutputMaxEvents(input.maxEvents);
return {
execution: located.execution,
header: inspected.header,
events: tail(inspected.events, maxEvents),
runtimeEvents: tail(inspected.runtimeEvents, maxEvents),
sourceHealth: inspected.sourceHealth,
diagnostics: tail(inspected.diagnostics, maxEvents),
artifacts,
truncated: {
events: inspected.events.length > maxEvents,
runtimeEvents: inspected.runtimeEvents.length > maxEvents,
diagnostics: inspected.diagnostics.length > maxEvents,
},
};
}
async stopSession(sessionId: string, input: StopSessionInput = {}): Promise<void> {
const hostedAuthority = isRuntimeHostedRootAuthority(this.deps.messageAuthority)
? this.deps.messageAuthority
: undefined;
const ownStop = hostedAuthority
? hostedAuthority.stopSession(sessionId, input)
: this.runtimeKernel.stopSession(sessionId, input);
let childStops: PromiseSettledResult<void>[] = [];
let childLookupError: unknown;
try {
const children = await this.listChildSessions(sessionId);
childStops = await Promise.allSettled(
children
.filter((child) => child.subagentParent?.lifecycle === 'foreground')
.map((child) =>
hostedAuthority
? hostedAuthority.stopSession(child.id, input)
: this.runtimeKernel.stopSession(child.id, input),
),
);
} catch (error) {
childLookupError = error;
}
await ownStop;
const childStopError = childStops.find(
(result): result is PromiseRejectedResult => result.status === 'rejected',
)?.reason;
if (childLookupError !== undefined) throw childLookupError;
if (childStopError !== undefined) throw childStopError;
}
async deliverHostedRootStop(sessionId: string, input: StopSessionInput = {}): Promise<void> {
const ownStop = this.runtimeKernel.stopSession(sessionId, input);
const authority = isRuntimeHostedRootAuthority(this.deps.messageAuthority)
? this.deps.messageAuthority
: undefined;
let childStops: PromiseSettledResult<void>[] = [];
let childLookupError: unknown;
try {
const children = await this.listChildSessions(sessionId);
childStops = await Promise.allSettled(
children
.filter((child) => child.subagentParent?.lifecycle === 'foreground')
.map((child) =>
authority
? authority.stopSession(child.id, input)
: this.runtimeKernel.stopSession(child.id, input),
),
);
} catch (error) {
childLookupError = error;
}
await ownStop;
const childStopError = childStops.find(
(result): result is PromiseRejectedResult => result.status === 'rejected',
)?.reason;
if (childLookupError !== undefined) throw childLookupError;
if (childStopError !== undefined) throw childStopError;
}
async closePendingHostedLinkedChildAdmission(input: {
sessionId: string;
turnId: string;
runId: string;
admittedAt: number;
execution: Exclude<RootExecutionDescriptor, { kind: 'external_message' }>;
}): Promise<void> {
if (!this.deps.runStore || !this.deps.runtimeEventStore) {
throw new Error('Linked child admission recovery requires execution stores');
}
const session = await this.deps.store.readHeader(input.sessionId);
if (
session.subagentParent?.kind !== 'subagent' ||
session.subagentRuntime?.agentId !== input.execution.agentId ||
session.subagentRuntime.agentName !== input.execution.agentName
) {
throw new Error(
`Admitted Turn ${input.turnId} does not match its linked child Session identity`,
);
}
let workspaceIdentity: string | undefined;
if (
input.execution.kind === 'linked_child_resume' ||
input.execution.kind === 'linked_child_provider_retry'
) {
const sourceRun = await this.deps.runStore.readRun(
input.sessionId,
input.execution.sourceRunId,
);
if (
sourceRun.agentId !== input.execution.agentId ||
sourceRun.agentName !== input.execution.agentName
) {
throw new Error(`Admitted Turn ${input.turnId} source changed its trusted agent identity`);
}
workspaceIdentity = sourceRun.workspaceIdentity;
}
const run: AgentRunHeader = {
runId: input.runId,
invocationId: input.runId,
sessionId: input.sessionId,
turnId: input.turnId,
status: 'created',
backendKind: session.backend,
llmConnectionSlug: session.llmConnectionSlug,
modelId: session.model,
cwd: session.cwd,
...(workspaceIdentity !== undefined ? { workspaceIdentity } : {}),
permissionMode: session.permissionMode,
collaborationMode: session.collaborationMode ?? 'agent',
createdAt: input.admittedAt,
updatedAt: input.admittedAt,
...(input.execution.kind === 'linked_child_resume'
? { resumedFromRunId: input.execution.sourceRunId }
: {}),
...(input.execution.kind === 'linked_child_provider_retry'
? { retriedFromRunId: input.execution.sourceRunId }
: {}),
agentId: input.execution.agentId,
agentName: input.execution.agentName,
};
await this.deps.runStore.createRun(run, { durable: true });
const ts = this.deps.now();
const recoveryReason = 'child_internal_admission_without_run';
const terminalEvent = buildRecoveredTerminalRuntimeEvent({
id: this.deps.newId(),
run,
status: 'failed',
ts,
failureClass: 'app_restarted',
recoveryReason,
diagnostic: {
executionKind: input.execution.kind,
...(input.execution.kind === 'linked_child_resume' ||
input.execution.kind === 'linked_child_provider_retry'
? { sourceRunId: input.execution.sourceRunId }
: {}),
},
message: 'app_restarted',
});
await commitTerminalRunWithRuntimeFact({
runStore: this.deps.runStore,
runtimeEventStore: this.deps.runtimeEventStore,
newId: this.deps.newId,
sessionId: input.sessionId,
runId: input.runId,
turnId: input.turnId,
status: 'failed',
ts,
terminalEvent,
failureClass: 'app_restarted',
runEventData: {
recovered: true,
recoveryReason,
executionKind: input.execution.kind,
},
existingEvents: [],
});
}
private async consumeLinkedRootExecution(
input: RuntimeHostedRootExecutionInput,
hostedGateAlreadyHeld = false,
): Promise<void> {
const authority = isRuntimeHostedRootAuthority(this.deps.messageAuthority)
? this.deps.messageAuthority
: undefined;
if (authority) {
const release = hostedGateAlreadyHeld
? undefined
: this.acquireHostedLinkedChildExecution(input.sessionId);
try {
await authority.executeRoot(input);
return;
} finally {
release?.();
}
}
await this.consumeRuntimeEvents(
input.start({
runId: input.runId,
userMessageId: input.userMessageId,
onRunStarted: () => input.onReady?.(),
}),
undefined,
input.onEvent,
);
}
private acquireHostedLinkedChildExecution(sessionId: string): () => void {
if (!isRuntimeHostedRootAuthority(this.deps.messageAuthority)) return () => {};
if (this.activeHostedLinkedChildSessions.has(sessionId)) {
throw new Error(`Child Session ${sessionId} already has an active execution`);
}
this.activeHostedLinkedChildSessions.add(sessionId);
return () => {
this.activeHostedLinkedChildSessions.delete(sessionId);
};
}
private async consumeRuntimeEvents(
events: AsyncIterable<SessionEvent>,
onReady?: () => void | Promise<void>,
onEvent?: (event: SessionEvent) => void,
): Promise<void> {
await onReady?.();
for await (const event of events) onEvent?.(event);
}
private stopLinkedRoot(
identity: RuntimeMessageRunIdentity,
input: StopSessionInput,
): Promise<void> {
const authority = isRuntimeHostedRootAuthority(this.deps.messageAuthority)
? this.deps.messageAuthority
: undefined;
return authority
? authority.stopRoot(identity, input)
: this.runtimeKernel.stopSession(identity.sessionId, input);
}
/** Queue a user message for mid-turn injection at the next step boundary. */
steer(sessionId: string, text: string): QueueEnqueueOutcome {
return this.runtimeKernel.steer(sessionId, text);
}
/** Queue a user message to open the turn after the current one finishes. */
queueMessage(sessionId: string, text: string): QueueEnqueueOutcome {
return this.runtimeKernel.queueMessage(sessionId, text);
}
/** Drain the followup queue into one `\n\n`-joined prompt, or null if empty. */
drainFollowup(sessionId: string): string | null {
return this.runtimeKernel.drainFollowup(sessionId);
}
/** Take back every queued message (both queues) as one `\n\n`-joined string. */
retractQueue(sessionId: string): string {
return this.runtimeKernel.retractQueue(sessionId);
}
async *regenerateTurn(
sessionId: string,
input: RegenerateTurnInput,
): AsyncIterable<SessionEvent> {
const execution = this.runtimeKernel.claimExecution(sessionId);
try {
// retry semantics merged into regenerate (#546): regenerate now accepts
// failed/aborted turns too, not just completed — one action re-runs the
// turn regardless of how the previous attempt ended.
const source = await this.requireTurnForAction(
sessionId,
input.sourceTurnId,
['failed', 'aborted', 'completed'],
'regenerate',
);
const user = await this.requireUserMessageForTurn(sessionId, source.turnId);
yield* this.sendMessage(
sessionId,
{
turnId: input.turnId ?? this.deps.newId(),
text: user.text,
...(user.displayText !== undefined ? { displayText: user.displayText } : {}),
...(user.attachments ? { attachments: user.attachments } : {}),
...(user.quotes ? { quotes: user.quotes } : {}),
parentTurnId: source.turnId,
regeneratedFromTurnId: source.turnId,
},
{ execution },
);
} finally {
execution.release();
}
}
async branchFromTurn(sessionId: string, input: BranchFromTurnInput): Promise<SessionSummary> {
const sourceView = await this.getSessionView(sessionId);
// Inclusive: keep everything up to and including the chosen turn. A found
// turn always has at least its own messages, so an empty copy means the
// turn does not exist.
const copied = copyMessagesThroughTurnBoundary(sourceView.messages, input.sourceTurnId);
if (copied.length === 0)
throw new Error(`Cannot branch from unknown turn ${input.sourceTurnId}`);
return this.createBranchSession(sessionId, sourceView, copied, input);
}
async branchBeforeTurn(sessionId: string, input: BranchFromTurnInput): Promise<SessionSummary> {
const sourceView = await this.getSessionView(sessionId);
// Exclusive dual of branchFromTurn: keep everything strictly before the
// chosen turn, dropping it and every later turn. An empty copy is valid
// here (the turn is the first one) — it branches to a fresh, empty context.
const copied = copyMessagesBeforeTurn(sourceView.messages, input.sourceTurnId);
if (copied === null) throw new Error(`Cannot branch before unknown turn ${input.sourceTurnId}`);
return this.createBranchSession(sessionId, sourceView, copied, input);
}
/**
* Create a non-destructive edit-and-resend version. Unlike branchBeforeTurn,
* this is not a new sidebar conversation: revision lineage lets hosts fold
* every version into one conversation slot while keeping old transcripts.
*/
async reviseBeforeTurn(sessionId: string, input: ReviseBeforeTurnInput): Promise<SessionSummary> {
const sourceView = await this.getSessionView(sessionId);
const copied = copyMessagesBeforeTurn(sourceView.messages, input.sourceTurnId);
if (copied === null) throw new Error(`Cannot revise before unknown turn ${input.sourceTurnId}`);
return this.createRevisionSession(sessionId, sourceView, copied, input);
}
private async createRevisionSession(
sessionId: string,
sourceView: RuntimeReadModelSessionView,
copied: StoredMessage[],
input: ReviseBeforeTurnInput,
): Promise<SessionSummary> {
const header = await this.deps.store.readHeader(sessionId);
const revisionRootSessionId = header.revisionRootSessionId ?? sessionId;
const family = (await this.deps.store.list()).filter(
(candidate) =>
candidate.id === revisionRootSessionId ||
candidate.revisionRootSessionId === revisionRootSessionId,
);
const revisionIndex =
Math.max(1, ...family.map((candidate) => candidate.revisionIndex ?? 1)) + 1;
const next = await this.deps.store.create({
cwd: header.cwd,
...(header.projectId !== undefined ? { projectId: header.projectId } : {}),
backend: header.backend,
llmConnectionSlug: header.llmConnectionSlug,
model: header.model,
thinkingLevel: header.thinkingLevel,
permissionMode: header.permissionMode,
collaborationMode: header.collaborationMode,
orchestrationMode: header.orchestrationMode ?? 'default',
name: header.name,
labels: header.labels,
// A revision of a real branch remains in that branch's conversation
// slot; revision lineage itself must not create a branch banner.
parentSessionId: header.parentSessionId,
branchOfTurnId: header.branchOfTurnId,
revisionRootSessionId,
revisionParentSessionId: sessionId,
revisionOfTurnId: input.sourceTurnId,
revisionIndex,
revisionState: 'preparing',
status: 'active',
});
await this.cloneConversationRuntimeLedger(next.id, sourceView, copied);
if (copied.length > 0) await this.deps.store.appendMessages(next.id, copied);
await this.deps.store.appendMessage(next.id, {
type: 'system_note',
id: this.deps.newId(),
ts: this.deps.now(),
kind: 'session_start',
data: {
revisionRootSessionId,
revisionParentSessionId: sessionId,
revisionOfTurnId: input.sourceTurnId,
revisionIndex,
revisionState: 'preparing',
},
});
await this.deps.store.updateHeader(next.id, {
isFlagged: header.isFlagged,
titleIsManual: header.titleIsManual,
});
return headerToSummary(await this.deps.store.readHeader(next.id));
}
private async createBranchSession(
sessionId: string,
sourceView: RuntimeReadModelSessionView,
copied: StoredMessage[],
input: BranchFromTurnInput,
): Promise<SessionSummary> {
const header = await this.deps.store.readHeader(sessionId);
const next = await this.deps.store.create({
cwd: header.cwd,
...(header.projectId !== undefined ? { projectId: header.projectId } : {}),
backend: header.backend,
llmConnectionSlug: header.llmConnectionSlug,
model: header.model,
thinkingLevel: header.thinkingLevel,
permissionMode: header.permissionMode,
collaborationMode: header.collaborationMode,
orchestrationMode: header.orchestrationMode ?? 'default',
name: input.name ?? `${header.name} · 分支`,
labels: header.labels,
parentSessionId: sessionId,
branchOfTurnId: input.sourceTurnId,
status: 'active',
});
await this.cloneConversationRuntimeLedger(next.id, sourceView, copied);
if (copied.length > 0) await this.deps.store.appendMessages(next.id, copied);
await this.deps.store.appendMessage(next.id, {
type: 'system_note',
id: this.deps.newId(),
ts: this.deps.now(),
kind: 'session_start',
data: { parentSessionId: sessionId, branchOfTurnId: input.sourceTurnId },
});
return headerToSummary(await this.deps.store.readHeader(next.id));
}
async respondToPermission(sessionId: string, response: PermissionResponse): Promise<void> {
if (this.deps.interactionAuthority) {
throw new RuntimeInteractionInvariantError(
'Hosted permission answers must use the captured continuation',
);
}
await this.runtimeKernel.respondToPermission(sessionId, response);
}
async respondToUserQuestion(sessionId: string, response: UserQuestionResponse): Promise<void> {
if (this.deps.interactionAuthority) {
throw new RuntimeInteractionInvariantError(
'Hosted question answers must use the captured continuation',
);
}
await this.runtimeKernel.respondToUserQuestion?.(sessionId, response);
}
// --------------------------------------------------------------------------
// Internal helpers
// --------------------------------------------------------------------------
private async findRunByTurnId(
sessionId: string,
turnId: string,
): Promise<AgentRunHeader | undefined> {
if (!this.deps.runStore) return undefined;
const runs = await this.deps.runStore.listSessionRuns(sessionId).catch(() => []);
const run = runs.find((candidate) => candidate.turnId === turnId);
return run ? this.effectiveRunHeaderFromRuntimeLedger(run) : undefined;
}
private assertActiveParentRun(
parentSessionId: string,
parentRun: AgentRunHeader,
parentTurnId: string,
): void {
if (
parentRun.sessionId !== parentSessionId ||
parentRun.turnId !== parentTurnId ||
!(
this.runtimeKernel.hasActiveRun?.(parentSessionId, parentRun.runId, parentTurnId) ||
this.deps.isParentRunActive?.(parentSessionId, parentRun.runId, parentTurnId)
)
) {
throw new Error('Child session parent run is not active');
}
}
private async resolveShellRunOwner(
firstParentSessionId: string,
ref: string,
): Promise<{ sessionId: string; result: ShellRunUpdate['result'] } | undefined> {
const shellRuns = this.deps.shellRuns;
if (!shellRuns) return undefined;
let ownerSessionId: string | undefined = firstParentSessionId;
const visited = new Set<string>();
while (ownerSessionId && !visited.has(ownerSessionId)) {
visited.add(ownerSessionId);
try {
return {
sessionId: ownerSessionId,
result: await shellRuns.inspectResource(ownerSessionId, ref),
};
} catch (error) {
if (!isNotFoundError(error)) throw error;
try {
const ownerHeader = await this.deps.store.readHeader(ownerSessionId);
ownerSessionId = ownerHeader.revisionParentSessionId ?? ownerHeader.parentSessionId;
} catch (headerError) {
if (isNotFoundError(headerError)) return undefined;
throw headerError;
}
}
}
return undefined;
}
private async findChildRunForOutput(
sessionId: string,
input: AgentOutputInput,
): Promise<{ header: AgentRunHeader; execution: SubagentExecutionRef }> {
if (Number(!!input.execution) + Number(!!input.runId) + Number(!!input.turnId) !== 1) {
throw new Error('agent_output requires exactly one execution, runId, or turnId locator');
}
if (input.execution?.kind === 'child_session') {
const execution = input.execution;
const child = await this.deps.store.readHeader(execution.sessionId).catch((error) => {
if (isNotFoundError(error)) return undefined;
throw error;
});
if (
!child ||
child.subagentParent?.kind !== 'subagent' ||
child.subagentParent.parentSessionId !== sessionId
) {
throw new Error('agent_output could not find the requested child session');
}
const runs = await this.deps.runStore?.listSessionRuns(child.id);
const selected = execution.currentRunId
? runs?.find((run) => run.runId === execution.currentRunId)
: runs
?.slice()
.sort(
(left, right) =>
right.createdAt - left.createdAt ||
right.updatedAt - left.updatedAt ||
right.runId.localeCompare(left.runId),
)[0];
if (!selected || !isSessionInlineRun(selected)) {
throw new Error('agent_output could not find the requested child session run');
}
const header = await this.effectiveRunHeaderFromRuntimeLedger(selected);
return {
header,
execution: {
kind: 'child_session',
sessionId: child.id,
currentRunId: header.runId,
},
};
}
const legacyExecution =
input.execution?.kind === 'legacy_child_run' ? input.execution : undefined;
if (legacyExecution && legacyExecution.sessionId !== sessionId) {
throw new Error('agent_output could not find the requested legacy child run');
}
const runs = await this.deps.runStore?.listSessionRuns(sessionId);
const header = runs?.find((run) =>
legacyExecution
? run.runId === legacyExecution.runId
: input.runId
? run.runId === input.runId
: input.turnId
? run.turnId === input.turnId
: false,
);
if (!header) throw new Error('agent_output could not find the requested child agent run');
if (!header.parentRunId || isSessionInlineRun(header)) {
throw new Error('agent_output only reads child agent runs');
}
return {
header: await this.effectiveRunHeaderFromRuntimeLedger(header),
execution: {
kind: 'legacy_child_run',
sessionId,
runId: header.runId,
},
};
}
private async effectiveRunHeaderFromRuntimeLedger(run: AgentRunHeader): Promise<AgentRunHeader> {
if (!this.deps.runtimeEventStore) return run;
const runtimeEvents = await this.deps.runtimeEventStore
.readRuntimeEvents(run.sessionId, run.runId)
.catch(() => undefined);
if (!runtimeEvents) return run;
const ledger = classifyTerminalRuntimeLedger(run, runtimeEvents);
return ledger.kind === 'fact' ? effectiveRunHeaderFromTerminalFact(run, ledger.fact) : run;
}
private async updateStatus(
sessionId: string,
status: SessionStatus,
blockedReason?: SessionBlockedReason,
ts = this.deps.now(),
): Promise<void> {
await this.updateHeader(sessionId, buildStatusPatch(status, ts, blockedReason));
}
private async updateHeader(
sessionId: string,
patch: Partial<SessionHeader>,
): Promise<SessionHeader> {
const next = await this.deps.store.updateHeader(sessionId, patch);
this.runtimeKernel.updateCachedHeader(sessionId, next);
return next;
}
private requirePlanStore(): PlanStore {
if (!this.deps.planStore) throw new Error('Plan Mode is unavailable on this surface');
return this.deps.planStore;
}
private async appendTurnState(
sessionId: string,
turnId: string,
status: TurnRecord['status'],
lineage: AgentRunLineage = {},
options: { ts?: number; errorClass?: string; abortSource?: string } = {},
): Promise<void> {
const ts = options.ts ?? this.deps.now();
await this.deps.store.appendMessage(
sessionId,
buildTurnStateMessage({
id: this.deps.newId(),
turnId,
ts,
status,
lineage,
...(options.abortSource ? { abortSource: options.abortSource } : {}),
...(options.errorClass !== undefined ? { errorClass: options.errorClass } : {}),
partialOutputRetained: await this.turnHasRetainedOutput(sessionId, turnId),
}),
);
}
private async turnHasRetainedOutput(sessionId: string, turnId: string): Promise<boolean> {
const messages = await this.deps.store.readMessages(sessionId).catch(() => []);
return messagesHaveRetainedOutput(messages, turnId);
}
private async requireTurnForAction(
sessionId: string,
turnId: string,
allowed: readonly TurnRecord['status'][],
action: string,
): Promise<TurnRecord> {
const turn = (await this.getSessionView(sessionId)).turns.find(
(candidate) => candidate.turnId === turnId,
);
if (!turn) throw new Error(`Cannot ${action}: unknown turn ${turnId}`);
if (!allowed.includes(turn.status)) {
throw new Error(`Cannot ${action}: turn ${turnId} is ${turn.status}`);
}
return turn;
}
private async requireUserMessageForTurn(sessionId: string, turnId: string): Promise<UserMessage> {
const user = (await this.getSessionView(sessionId)).messages.find(
(message): message is UserMessage => message.type === 'user' && message.turnId === turnId,
);
if (!user) throw new Error(`Turn ${turnId} has no user message`);
return user;
}
private async getSessionView(sessionId: string): Promise<RuntimeReadModelSessionView> {
const repaired = new Set<string>();
for (let attempt = 0; attempt < MAX_RUNTIME_LEDGER_REPAIR_ATTEMPTS; attempt += 1) {
try {
const view = await this.readModel().getSessionView(sessionId);
const runId = firstRuntimeRepairRunId(view.diagnostics, repaired);
if (!runId) return view;
if (!(await this.repairMissingTerminalFactOnce(sessionId, runId))) return view;
repaired.add(runId);
} catch (error) {
if (!(error instanceof RuntimeReadModelError)) throw error;
const runId = firstRuntimeRepairRunId(error.diagnostics, repaired);
if (!runId) throw error;
if (!(await this.repairMissingTerminalFactOnce(sessionId, runId))) throw error;
repaired.add(runId);
}
}
return this.readModel().getSessionView(sessionId);
}
private readModel(): RuntimeReadModel {
if (!this.deps.runStore || !this.deps.runtimeEventStore) {
throw new Error('RuntimeReadModel requires AgentRunStore and RuntimeEventStore');
}
return new RuntimeReadModel({
runStore: this.deps.runStore,
runtimeEventStore: this.deps.runtimeEventStore,
projectionCache: this.deps.store,
...(this.deps.canonicalPermissionOutcomes
? { canonicalPermissionOutcomes: this.deps.canonicalPermissionOutcomes }
: {}),
});
}
private async repairMissingTerminalFactOnce(sessionId: string, runId: string): Promise<boolean> {
return (
(await this.runtimeLedgerRepair?.repairMissingTerminalFactOnce(sessionId, runId)) ?? false
);
}
private async cloneConversationRuntimeLedger(
childSessionId: string,
sourceView: RuntimeReadModelSessionView,
copiedMessages: readonly StoredMessage[],
): Promise<void> {
if (!this.deps.runStore || !this.deps.runtimeEventStore) return;
const copiedTurnIds = new Set<string>();
for (const message of copiedMessages) {
if ('turnId' in message && typeof message.turnId === 'string')
copiedTurnIds.add(message.turnId);
}
if (copiedTurnIds.size === 0) return;
const copiedPermissionDecisions = new Map(
copiedMessages.flatMap((message) =>
message.type === 'permission_decision' ? [[message.id, message] as const] : [],
),
);
for (const sourceRun of sourceView.runs) {
if (!copiedTurnIds.has(sourceRun.turnId)) continue;
const sourceEvents = sourceView.events.filter(
(event) => event.runId === sourceRun.runId && copiedTurnIds.has(event.turnId),
);
if (sourceEvents.length === 0) continue;
const runId = this.deps.newId();
const invocationId = this.deps.newId();
const clonedRun = cloneRunHeaderForConversationCopy(
sourceRun,
childSessionId,
runId,
invocationId,
);
await this.deps.runStore.createRun(clonedRun);
const sourceTerminalLedger = classifyTerminalRuntimeLedger(sourceRun, sourceEvents);
const clonedEventBySourceId = new Map<string, RuntimeEvent>();
for (const event of sourceEvents) {
const clonedEvent = cloneRuntimeEventForConversationCopy(
event,
{
sessionId: childSessionId,
runId,
eventId: this.deps.newId(),
invocationId,
},
copiedPermissionDecisions,
);
await this.deps.runtimeEventStore.appendRuntimeEvent(childSessionId, runId, clonedEvent);
clonedEventBySourceId.set(event.id, clonedEvent);
}
if (sourceTerminalLedger.kind === 'fact' && isTerminalRunStatus(sourceRun.status)) {
const terminalEvent = clonedEventBySourceId.get(sourceTerminalLedger.fact.terminalEvent.id);
if (!terminalEvent) continue;
await commitTerminalRunWithRuntimeFact({
runStore: this.deps.runStore,
runtimeEventStore: this.deps.runtimeEventStore,
newId: this.deps.newId,
sessionId: childSessionId,
runId,
turnId: sourceRun.turnId,
status: sourceTerminalLedger.fact.runStatus,
ts: terminalEvent.ts,
terminalEvent,
...(sourceTerminalLedger.fact.failureClass
? { failureClass: sourceTerminalLedger.fact.failureClass }
: {}),
...(sourceRun.failureMessage ? { failureMessage: sourceRun.failureMessage } : {}),
...(sourceTerminalLedger.fact.abortSource
? { abortSource: sourceTerminalLedger.fact.abortSource }
: {}),
runEventData: {
recovered: true,
recoveryReason: 'conversation_runtime_ledger_clone',
sourceSessionId: sourceRun.sessionId,
sourceRunId: sourceRun.runId,
},
});
}
}
}
private async recoverAgentRunsFromLedger(
sessionId: string,
policy: RecoveryPolicy = { kind: 'best_effort' },
): Promise<{ hasLedger: boolean; recovered: boolean }> {
if (!this.deps.runStore || !this.deps.runtimeEventStore)
return { hasLedger: false, recovered: false };
const runs =
policy.kind === 'strict'
? await policy.stores.agentRunStore.listSessionRunsForRecovery(sessionId)
: await this.deps.runStore.listSessionRuns(sessionId);
if (runs.length === 0) return { hasLedger: false, recovered: false };
let recovered = false;
for (const run of runs) {
if (policy.kind === 'strict') {
await policy.stores.agentRunStore.readEventsForRecovery(sessionId, run.runId);
}
const inspected = await inspectAgentRunReadModel(
this.deps.runStore,
this.deps.runtimeEventStore,
{ sessionId, runId: run.runId, header: run },
);
if (inspected.sourceHealth.runtimeLedger === 'read_failed') {
if (policy.kind === 'strict') {
throw new Error(`RuntimeEvent ledger is unreadable for run ${run.runId}`);
}
continue;
}
if (
policy.kind === 'strict' &&
inspected.diagnostics.some(
(diagnostic) =>
diagnostic.code === 'operational_ledger_read_failed' ||
diagnostic.code === 'operational_event_corrupt',
)
) {
throw new Error(`AgentRun event ledger is unreadable for run ${run.runId}`);
}
const terminalLedger = classifyTerminalRuntimeLedger(run, inspected.runtimeEvents);
if (terminalLedger.kind === 'ambiguous') {
if (policy.kind === 'strict') {
throw new Error(`RuntimeEvent ledger has ambiguous terminal facts for run ${run.runId}`);
}
continue;
}
if (isTerminalRunStatus(run.status) && !inspected.terminalRuntimeFact) {
const repaired = await this.repairMissingTerminalFactOnce(sessionId, run.runId);
if (repaired) {
recovered = true;
} else if (policy.kind === 'strict') {
throw new Error(`Unable to repair the terminal RuntimeEvent fact for run ${run.runId}`);
}
continue;
}
const runtimeDecision = this.classifyRuntimeEventRecovery(inspected);
const decision = runtimeDecision ?? classifyAgentRunRecovery(run, inspected.events);
if (!decision) continue;
if (await this.applyAgentRunRecovery(sessionId, decision, inspected, policy)) {
recovered = true;
}
}
return { hasLedger: true, recovered };
}
private classifyRuntimeEventRecovery(
inspected: AgentRunInspectModel,
): AgentRunRecoveryDecision | undefined {
if (isTerminalRunStatus(inspected.header.status) || !inspected.terminalRuntimeFact)
return undefined;
return runtimeTerminalFactToRecoveryDecision(inspected.header, inspected.terminalRuntimeFact);
}
private async applyAgentRunRecovery(
sessionId: string,
decision: AgentRunRecoveryDecision,
inspected: AgentRunInspectModel,
policy: RecoveryPolicy = { kind: 'best_effort' },
): Promise<boolean> {
if (!this.deps.runStore || !this.deps.runtimeEventStore) return false;
const ts = this.deps.now();
const terminalLedger = classifyTerminalRuntimeLedger(inspected.header, inspected.runtimeEvents);
const existingTerminal =
inspected.terminalRuntimeFact?.terminalEvent ??
(terminalLedger.kind === 'incomplete_single_terminal'
? terminalLedger.terminalEvent
: undefined);
const status = existingTerminal
? (terminalRunStatusFromRuntimeEvent(existingTerminal) ?? decision.status)
: decision.status;
const failureClass =
status === 'failed' ? (decision.failureClass ?? 'app_restarted') : undefined;
const abortSource = status === 'cancelled' ? (decision.abortSource ?? 'unknown') : undefined;
const terminalEvent =
existingTerminal ??
buildRecoveredTerminalRuntimeEvent({
id: this.deps.newId(),
run: inspected.header,
status,
ts,
recoveryReason: diagnosticRecoveryReason(decision.diagnostic),
...(inspected.runtimeEvents[0]?.invocationId
? { invocationId: inspected.runtimeEvents[0].invocationId }
: {}),
...(failureClass ? { failureClass, message: failureClass } : {}),
...(abortSource ? { abortSource } : {}),
...(decision.diagnostic ? { diagnostic: decision.diagnostic } : {}),
});
try {
await commitTerminalRunWithRuntimeFact({
runStore: this.deps.runStore,
runtimeEventStore: this.deps.runtimeEventStore,
newId: this.deps.newId,
sessionId,
runId: decision.runId,
turnId: decision.turnId,
status,
ts,
terminalEvent,
...(failureClass ? { failureClass } : {}),
...(abortSource ? { abortSource } : {}),
runEventData: { recovered: true, ...decision.diagnostic },
existingEvents: inspected.events,
});
} catch (error) {
if (policy.kind === 'strict') throw error;
return false;
}
await recoverOr(
policy,
() =>
this.appendTerminalTurnStateIfNeeded(
sessionId,
inspected.header,
decision,
terminalTurnStatus(status),
{
ts,
...(failureClass ? { errorClass: failureClass } : {}),
...(abortSource ? { abortSource } : {}),
},
policy,
),
undefined,
);
return true;
}
private async appendTerminalTurnStateIfNeeded(
sessionId: string,
run: AgentRunHeader,
decision: AgentRunRecoveryDecision,
status: TurnRecord['status'],
options: { ts: number; errorClass?: string; abortSource?: string },
policy: RecoveryPolicy = { kind: 'best_effort' },
): Promise<void> {
if (!isSessionInlineRun(run)) return;
const messages = await recoverOr(
policy,
() => this.deps.store.readMessages(sessionId),
[] as StoredMessage[],
);
const latest = latestTurnState(messages, decision.turnId);
if (latest && isTerminalTurnStatus(latest.status) && latest.status === status) return;
await this.appendTurnState(sessionId, decision.turnId, status, decision.lineage, options);
}
}
function resumeFeatureDisabledPlan(): SafeBoundaryContinuationPlan {
return {
disposition: 'park',
rejectionReasons: ['resume_feature_disabled'],
diagnostics: [
{
code: 'resume_feature_disabled',
message: 'safe-boundary resume is disabled by the host feature flag',
},
],
};
}
function continuationExecutionErrorClass(error: unknown): string {
if (
error instanceof Error &&
'code' in error &&
typeof (error as Error & { code?: unknown }).code === 'string'
) {
return (error as Error & { code: string }).code;
}
return error instanceof Error ? error.name : 'unknown';
}
type RecoveryPolicy = { kind: 'best_effort' } | { kind: 'strict'; stores: StrictRecoveryStores };
function listSessionsForRecovery(
store: SessionStore,
policy: RecoveryPolicy,
): Promise<Array<SessionHeader | SessionSummary>> {
return policy.kind === 'strict' ? policy.stores.sessionStore.listForRecovery() : store.list();
}
async function recoverOr<T>(
policy: RecoveryPolicy,
operation: () => Promise<T>,
fallback: T,
): Promise<T> {
try {
return await operation();
} catch (error) {
if (policy.kind === 'strict') throw error;
return fallback;
}
}
// ============================================================================
// Helpers
// ============================================================================
export function headerToSummary(h: SessionHeader): SessionSummary {
const summary: SessionSummary = {
id: h.id,
cwd: h.cwd,
...(h.projectId !== undefined ? { projectId: h.projectId } : {}),
name: h.name === 'New Session' ? DEFAULT_SESSION_NAME : h.name,
isFlagged: h.isFlagged,
isArchived: h.isArchived,
labels: h.labels,
hasUnread: h.hasUnread,
status: h.status,
...(h.blockedReason ? { blockedReason: h.blockedReason } : {}),
...(h.statusUpdatedAt !== undefined ? { statusUpdatedAt: h.statusUpdatedAt } : {}),
...(h.parentSessionId ? { parentSessionId: h.parentSessionId } : {}),
...(h.branchOfTurnId ? { branchOfTurnId: h.branchOfTurnId } : {}),
...(h.subagentParent ? { subagentParent: h.subagentParent } : {}),
...(h.subagentRuntime
? { subagentRuntime: subagentSessionRuntimeSummary(h.subagentRuntime) }
: {}),
...(h.subagentWorkspace ? { subagentWorkspace: h.subagentWorkspace } : {}),
...(h.revisionRootSessionId ? { revisionRootSessionId: h.revisionRootSessionId } : {}),
...(h.revisionParentSessionId ? { revisionParentSessionId: h.revisionParentSessionId } : {}),
...(h.revisionOfTurnId ? { revisionOfTurnId: h.revisionOfTurnId } : {}),
...(h.revisionIndex !== undefined ? { revisionIndex: h.revisionIndex } : {}),
...(h.revisionState ? { revisionState: h.revisionState } : {}),
backend: h.backend,
llmConnectionSlug: h.llmConnectionSlug,
connectionLocked: h.connectionLocked,
model: h.model,
permissionMode: h.permissionMode ?? 'ask',
collaborationMode: h.collaborationMode ?? 'agent',
orchestrationMode: h.orchestrationMode ?? 'default',
};
if (h.thinkingLevel !== undefined) summary.thinkingLevel = h.thinkingLevel;
if (h.lastMessageAt !== undefined) {
summary.lastMessageAt = h.lastMessageAt;
}
return summary;
}
function isNotFoundError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
}
function sameSubagentWorkspace(
left: SubagentWorkspaceBinding | undefined,
right: SubagentWorkspaceBinding | undefined,
): boolean {
if (!left || !right) return left === right;
return (
left.schemaVersion === right.schemaVersion &&
left.kind === right.kind &&
left.leaseId === right.leaseId &&
left.gitCommonDir === right.gitCommonDir &&
left.worktreePath === right.worktreePath &&
left.branch === right.branch &&
left.baseCommit === right.baseCommit
);
}
function childSessionSpawnKey(
parentSessionId: string,
input: Pick<SpawnChildSessionInput, 'spawnedBy' | 'swarm'>,
): string {
return JSON.stringify([
1,
parentSessionId,
input.spawnedBy.parentRunId,
input.spawnedBy.toolCallId,
input.swarm?.swarmId ?? null,
input.swarm?.itemId ?? null,
]);
}
function childSessionRequestFingerprint(
parentSessionId: string,
input: Pick<SpawnChildSessionInput, 'spawnedBy' | 'agentProfile' | 'prompt' | 'swarm'>,
): string {
return createHash('sha256')
.update(
JSON.stringify([
1,
parentSessionId,
input.spawnedBy.parentRunId,
input.spawnedBy.parentTurnId,
input.spawnedBy.toolCallId,
input.agentProfile,
input.prompt,
input.swarm?.swarmId ?? null,
input.swarm?.itemId ?? null,
]),
)
.digest('hex');
}
function claimedAgentGraphIntentRequestFingerprint(
input: Pick<ResolvedClaimedAgentGraphIntentInput, 'claim' | 'prompt'>,
): string {
return createHash('sha256')
.update(JSON.stringify([1, input.claim, input.prompt]))
.digest('hex');
}
function assertAgentGraphIntentExecutionMatchesClaim(
claim: AgentGraphIntentClaim,
intent: AgentGraphRunnableIntent,
prompt: string,
): void {
if (
intent.graphId !== claim.graphId ||
intent.intentId !== claim.intentId ||
intent.readinessContextFingerprint !== claim.readinessContextFingerprint ||
intent.operatorId !== claim.targetOperatorId ||
intent.targetSessionId !== claim.targetSessionId ||
fingerprintAgentGraphRunnableIntent({
intent,
executionInput: { prompt },
}) !== claim.intentFingerprint
) {
throw new Error('Claimed graph intent execution does not match its durable claim');
}
}
function claimedAgentGraphIntentResult(
claim: AgentGraphIntentClaim,
result: SpawnChildSessionResult,
): ClaimedAgentGraphIntentResult {
return {
claimId: claim.claimId,
graphId: claim.graphId,
intentId: claim.intentId,
operatorId: claim.targetOperatorId,
...result,
};
}
export function changesBackendConfig(patch: Partial<SessionHeader>): boolean {
return (
'backend' in patch ||
'llmConnectionSlug' in patch ||
'model' in patch ||
'thinkingLevel' in patch ||
'cwd' in patch ||
'collaborationMode' in patch ||
// AiSdkBackend snapshots the header at construction and ToolRuntime
// reads `header.permissionMode` at every decision, so a mode change
// that does not rebuild the backend is persisted but NOT enforced —
// the live session keeps deciding with the old mode.
//
// `setPermissionMode` disposes the backend for exactly this reason.
// `updateSession` did not, so every other path that lowers a mode was
// advisory: notably the bot-incoming guard re-pinning a conversation
// to `explore`, which left an already-built `execute`/`bypass` backend
// serving the remote sender.
'permissionMode' in patch
);
}
function agentRunStatusForSpawnResult(
status: AgentRunHeader['status'],
): SpawnChildAgentResult['status'] {
if (status === 'waiting_for_user') return 'waiting_for_user';
if (status === 'cancelled') return 'cancelled';
if (status === 'failed') return 'failed';
if (status === 'running' || status === 'created') return 'running';
return 'completed';
}
function isUnsafeChildResumeDiagnostic(code: string): boolean {
return (
code === 'unmatched_tool_call' ||
code === 'unmatched_tool_result' ||
code === 'tool_id_mismatch' ||
code === 'unsupported_role' ||
code === 'unsupported_content'
);
}
function trimSummary(text: string): string {
const trimmed = text.trim();
return trimmed.length <= CHILD_AGENT_SUMMARY_MAX_CHARS
? trimmed
: `${trimmed.slice(0, CHILD_AGENT_SUMMARY_MAX_CHARS - 1)}…`;
}
class ChildAgentSummaryAccumulator {
eventCount = 0;
failureClass: string | undefined;
private terminalStatus: SpawnChildAgentResult['status'] | undefined;
private lastTextComplete = '';
private textDeltaTail = '';
private textDeltaTruncated = false;
private lastError = '';
add(event: SessionEvent): void {
this.eventCount += 1;
switch (event.type) {
case 'text_complete':
this.lastTextComplete = trimSummary(event.text);
break;
case 'text_delta':
this.appendTextDelta(event.text);
break;
case 'error':
this.terminalStatus = 'failed';
this.lastError = trimSummary(event.message);
break;
case 'abort':
this.terminalStatus = 'cancelled';
break;
case 'complete':
this.failureClass = failureClassFromCompleteStopReason(event.stopReason);
if (this.failureClass) this.terminalStatus = 'failed';
else if (event.stopReason === 'user_stop') this.terminalStatus = 'cancelled';
else this.terminalStatus = 'completed';
break;
}
}
status(aborted: boolean): SpawnChildAgentResult['status'] {
if (aborted) return 'cancelled';
return this.terminalStatus ?? 'running';
}
text(): string {
if (this.lastTextComplete.trim()) return this.lastTextComplete;
if (this.textDeltaTail.trim()) {
return this.textDeltaTruncated
? `…${this.textDeltaTail.slice(1)}`
: this.textDeltaTail.trim();
}
return this.lastError;
}
private appendTextDelta(text: string): void {
this.textDeltaTail += text;
if (this.textDeltaTail.length <= CHILD_AGENT_SUMMARY_MAX_CHARS) return;
this.textDeltaTruncated = true;
this.textDeltaTail = this.textDeltaTail.slice(-CHILD_AGENT_SUMMARY_MAX_CHARS);
}
}
interface InterruptedTurnRecovery {
turnId: string;
errorClass: string;
lineage: Partial<
Pick<
UserMessageInput,
| 'parentTurnId'
| 'retriedFromTurnId'
| 'regeneratedFromTurnId'
| 'branchOfTurnId'
| 'parentSessionId'
>
>;
}
function hasRevisionUserMessage(messages: readonly StoredMessage[]): boolean {
let boundary = -1;
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index]!;
if (
message.type === 'system_note' &&
message.kind === 'session_start' &&
message.data &&
typeof message.data === 'object' &&
'revisionRootSessionId' in message.data
) {
boundary = index;
}
}
return boundary >= 0 && messages.slice(boundary + 1).some((message) => message.type === 'user');
}
function interruptedTurnRecoveries(messages: readonly StoredMessage[]): InterruptedTurnRecovery[] {
const byTurn = new Map<
string,
{
hasAssistant: boolean;
states: Array<Extract<StoredMessage, { type: 'turn_state' }>>;
}
>();
for (const message of messages) {
const turnId = (message as { turnId?: string }).turnId;
if (!turnId) continue;
const bucket = byTurn.get(turnId) ?? { hasAssistant: false, states: [] };
if (message.type === 'assistant') bucket.hasAssistant = true;
if (message.type === 'turn_state') bucket.states.push(message);
byTurn.set(turnId, bucket);
}
const recoveries: InterruptedTurnRecovery[] = [];
for (const [turnId, bucket] of byTurn) {
const latest = bucket.states.at(-1);
if (!latest) continue;
if (latest.status === 'running') {
recoveries.push({
turnId,
errorClass: 'app_restarted',
lineage: turnStateLineage(latest),
});
continue;
}
const failed = [...bucket.states].reverse().find((state) => state.status === 'failed');
if (latest.status === 'completed' && !bucket.hasAssistant && failed) {
recoveries.push({
turnId,
errorClass: failed.errorClass ?? 'unknown',
lineage: turnStateLineage(failed),
});
}
}
return recoveries;
}
function turnStateLineage(
state: Extract<StoredMessage, { type: 'turn_state' }>,
): Partial<
Pick<
UserMessageInput,
| 'parentTurnId'
| 'retriedFromTurnId'
| 'regeneratedFromTurnId'
| 'branchOfTurnId'
| 'parentSessionId'
>
> {
return {
...(state.parentTurnId ? { parentTurnId: state.parentTurnId } : {}),
...(state.retriedFromTurnId ? { retriedFromTurnId: state.retriedFromTurnId } : {}),
...(state.regeneratedFromTurnId ? { regeneratedFromTurnId: state.regeneratedFromTurnId } : {}),
...(state.branchOfTurnId ? { branchOfTurnId: state.branchOfTurnId } : {}),
...(state.parentSessionId ? { parentSessionId: state.parentSessionId } : {}),
};
}
function cloneRuntimeEventForConversationCopy(
event: RuntimeEvent,
ids: { sessionId: string; runId: string; eventId: string; invocationId: string },
copiedPermissionDecisions: ReadonlyMap<
string,
Extract<StoredMessage, { type: 'permission_decision' }>
>,
): RuntimeEvent {
const cloned: RuntimeEvent = {
...event,
id: ids.eventId,
invocationId: ids.invocationId,
sessionId: ids.sessionId,
runId: ids.runId,
};
const accepted = event.actions?.permissionAnswerAccepted;
const decision = accepted ? copiedPermissionDecisions.get(accepted.requestId) : undefined;
if (!decision || !cloned.actions) return cloned;
const { permissionAnswerAccepted: _accepted, ...actions } = cloned.actions;
cloned.actions = {
...actions,
permissionDecision: {
requestId: decision.id,
toolName: decision.toolName,
decision: decision.decision,
...(decision.rememberForTurn !== undefined
? { rememberForTurn: decision.rememberForTurn }
: {}),
...(decision.reviewer !== undefined ? { reviewer: decision.reviewer } : {}),
...(decision.rationale !== undefined ? { rationale: decision.rationale } : {}),
...(decision.riskLevel !== undefined ? { riskLevel: decision.riskLevel } : {}),
},
};
cloned.ts = decision.ts;
return cloned;
}
function cloneRunHeaderForConversationCopy(
sourceRun: AgentRunHeader,
childSessionId: string,
runId: string,
invocationId: string,
): AgentRunHeader {
const cloned = { ...sourceRun, invocationId, sessionId: childSessionId, runId };
if (isTerminalRunStatus(sourceRun.status)) {
cloned.status = 'running';
delete cloned.completedAt;
delete cloned.failureClass;
delete cloned.failureMessage;
delete cloned.abortSource;
}
return cloned;
}
function copyMessagesThroughTurnBoundary(
messages: readonly StoredMessage[],
turnId: string,
): StoredMessage[] {
let lastIndex = -1;
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index]!;
if ((message as { turnId?: string }).turnId === turnId) {
lastIndex = index;
}
}
if (lastIndex < 0) return [];
// Branch v1 copies conversation context only. Turn metadata is intentionally
// not copied into the child session; lineage lives on the child session
// header (`parentSessionId` + `branchOfTurnId`) and future turns.
return messages.slice(0, lastIndex + 1).filter((message) => message.type !== 'turn_state');
}
// Exclusive dual of copyMessagesThroughTurnBoundary: every message belonging to
// a turn strictly before the chosen one, dropping it and every later turn.
// Returns null when the turn is absent (so the caller can reject an unknown
// turn), and an empty array when the turn is the first one (a valid branch into
// empty context). Membership, not array position, decides what to keep: the read
// model does not guarantee a turn's messages are contiguous or that a user
// prompt precedes its turn_state in array order, so a positional slice could
// drop an earlier turn's prompt. turn_state is dropped for the same reason as in
// the inclusive copy — lineage lives on the child header, not copied metadata.
function copyMessagesBeforeTurn(
messages: readonly StoredMessage[],
turnId: string,
): StoredMessage[] | null {
const turnOrder: string[] = [];
const seen = new Set<string>();
for (const message of messages) {
const messageTurnId = (message as { turnId?: string }).turnId;
if (messageTurnId && !seen.has(messageTurnId)) {
seen.add(messageTurnId);
turnOrder.push(messageTurnId);
}
}
const cut = turnOrder.indexOf(turnId);
if (cut < 0) return null;
const keep = new Set(turnOrder.slice(0, cut));
return messages.filter((message) => {
if (message.type === 'turn_state') return false;
const messageTurnId = (message as { turnId?: string }).turnId;
return messageTurnId !== undefined && keep.has(messageTurnId);
});
}
function isTerminalRunStatus(status: AgentRunHeader['status']): boolean {
return status === 'completed' || status === 'failed' || status === 'cancelled';
}
function isTerminalTurnStatus(status: TurnRecord['status']): boolean {
return status === 'completed' || status === 'failed' || status === 'aborted';
}
function terminalTurnStatus(status: AgentRunRecoveryDecision['status']): TurnRecord['status'] {
if (status === 'cancelled') return 'aborted';
return status;
}
function diagnosticRecoveryReason(diagnostic: Record<string, unknown> | undefined): string {
const recoveryReason = diagnostic?.recoveryReason;
return typeof recoveryReason === 'string' && recoveryReason.length > 0
? recoveryReason
: 'agent_run_recovery';
}
function latestTurnState(
messages: readonly StoredMessage[],
turnId: string,
): Extract<StoredMessage, { type: 'turn_state' }> | undefined {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message?.type === 'turn_state' && message.turnId === turnId) return message;
}
return undefined;
}
function runtimeTerminalFactToRecoveryDecision(
header: AgentRunHeader,
fact: RuntimeEventTerminalFact,
): AgentRunRecoveryDecision {
return {
runId: fact.runId,
turnId: fact.turnId,
status: fact.runStatus,
...(fact.failureClass ? { failureClass: fact.failureClass } : {}),
...(fact.abortSource ? { abortSource: fact.abortSource } : {}),
diagnostic: {
recoveryReason: 'runtime_event_terminal_fact',
runtimeEventId: fact.terminalEvent.id,
runtimeEventStatus: fact.terminalEvent.status,
},
lineage: headerLineage(header),
};
}
function headerLineage(header: AgentRunHeader): AgentRunRecoveryDecision['lineage'] {
return {
...(header.parentRunId ? { parentRunId: header.parentRunId } : {}),
...(header.parentTurnId ? { parentTurnId: header.parentTurnId } : {}),
...(header.retriedFromTurnId ? { retriedFromTurnId: header.retriedFromTurnId } : {}),
...(header.regeneratedFromTurnId
? { regeneratedFromTurnId: header.regeneratedFromTurnId }
: {}),
...(header.branchOfTurnId ? { branchOfTurnId: header.branchOfTurnId } : {}),
...(header.parentSessionId ? { parentSessionId: header.parentSessionId } : {}),
};
}
function normalizeAgentOutputMaxEvents(value: number | undefined): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return 20;
return Math.min(100, Math.max(1, Math.floor(value)));
}
function tail<T>(items: readonly T[], max: number): T[] {
if (items.length <= max) return [...items];
return items.slice(items.length - max);
}
function throwIfChildExecutionAborted(signal: AbortSignal | undefined, message: string): void {
if (!signal?.aborted) return;
throw Object.assign(new Error(message), { name: 'AbortError' });
}
// Re-export the suppressed-unused types so this file is the canonical home
// for them. (Avoids TS "imported but unused" warnings.)
export type {
TextDeltaEvent,
CompleteEvent,
ErrorEvent,
AbortEvent,
PermissionRequestEvent,
PermissionDecisionAckEvent,
PermissionDecisionMessage,
};