Co-authored-by: maria-rcks <maria@kuuro.net> Co-authored-by: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> Co-authored-by: Vitalii Yehorov <vitalyiegorov@gmail.com> Co-authored-by: Jake Leventhal <jakeleventhal@me.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Alex Southwell <saphid@gmail.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Nicholas Wasmiller <derped@mineperial.com> Co-authored-by: PB <poilmb@gmail.com> Co-authored-by: Exotic <118054752+extoci@users.noreply.github.com> Co-authored-by: Yash Singh <saiansh2525@gmail.com> Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Theo Browne <me@t3.gg> Co-authored-by: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Co-authored-by: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Co-authored-by: scratchyone <scratchywon@gmail.com> Co-authored-by: Dominic Roy <dominic@sdko.org> Co-authored-by: chukfinley <chuk@chuk.dev> Co-authored-by: Primož Ajdišek <bigpod@bigpod.si> Co-authored-by: benthecarman <benthecarman@live.com> Co-authored-by: NaveDanan <nave0712@gmail.com> Co-authored-by: aaditagrawal <103925638+aaditagrawal@users.noreply.github.com> Co-authored-by: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Co-authored-by: Nick Anisimov <n.anisimov.23@gmail.com> Co-authored-by: MacKinley Smith <smithmackinley@gmail.com> Co-authored-by: Yordis Prieto <yordis.prieto@gmail.com> Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: AKolenda <akole779@mtroyal.ca> Co-authored-by: Guillermo Casanova <75276669+Gigioxx@users.noreply.github.com> Co-authored-by: Otavio Salvador <otavio@ossystems.com.br> Co-authored-by: Shirish Pothi <183252392+shirishpothi@users.noreply.github.com> Co-authored-by: Ishaan Kothari <ishaanko.mail@gmail.com> Co-authored-by: Bob Fowler <bob@rjf.ca> Co-authored-by: Anton Bezdenezhnykh <gamer392@yandex.ru> Co-authored-by: ValeraZSD <48602572+ValeraZSD@users.noreply.github.com> Co-authored-by: Ephraim <ephraim39hr14m@gmail.com> Co-authored-by: Ryan Ilano <ryanilano@users.noreply.github.com> Co-authored-by: Alex <me@pixp.cc> Co-authored-by: maco <gosarmarcel7@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Tristan Knight <admin@snappeh.com> Co-authored-by: PR Batch Tester <agent@local.test> Co-authored-by: oliver <97427849+flamboh@users.noreply.github.com> Co-authored-by: kamkm <99585688+Kamkmgamer@users.noreply.github.com> Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
23 KiB
Core Graph And Data Model
Overview
V2 models orchestration as a graph with a small number of durable entity types:
Project
AppThread
Run
ExecutionNode tree
ProviderThread handles
Context transfers and handoffs
Checkpoints
The central separation is:
AppThread: the user-visible conversation in T3 Code.Run: a counted user-visible turn on an app thread.ExecutionNode: a unit of provider/runtime work inside a run.ProviderThread: a provider-native conversation handle.ProviderSession: a live or resumable provider process/runtime.ContextTransfer: a provider-neutral relationship/source-point record used by forks, provider switches, merge-back, and subagents.ContextHandoff: a materialized portable context artifact used when native transfer is unavailable or insufficient.
Provider-specific lifecycle is preserved in provider refs and diagnostic raw-provider logs. App behavior is driven by app-owned ids, V2-native orchestration events, and graph relationships.
V2 events are durable because they are the app state transition log. Raw provider frames are not durable app state. They may be attached by optional correlation ids such as rawEventId, but the canonical durable row is the normalized V2 event.
Every committed V2 event has a store-assigned monotonically increasing sequence. Projection snapshots and websocket streams use that sequence as the cursor boundary:
read snapshot at sequence N
-> stream committed V2 events where sequence > N
The sequence belongs to the stored event envelope, not the provider event and not the domain payload. Provider-native ordering is preserved separately through provider refs and diagnostic logs. Clients should treat the snapshot's snapshotSequence as the authoritative cursor and apply only subsequent events with a greater sequence.
Entity Summary
AppThread
id: ThreadId
projectId: ProjectId
title
providerBinding
activeProviderThreadId?
lineage?
status projection
Run
id: RunId
threadId: ThreadId
ordinal: number
status
rootNodeId
attempts[]
providerThreadId
contextHandoffId?
userMessageId
checkpoint?
ExecutionNode
id: NodeId
threadId: ThreadId
runId: RunId | null
parentNodeId: NodeId | null
rootNodeId: NodeId
kind
status
providerThreadId?
providerTurnId?
itemId?
countsForRun
checkpointScopeId?
CheckpointScope
id: CheckpointScopeId
threadId
runId?
nodeId
parentScopeId?
kind
advancesAppRunCount
ProviderSession
id: ProviderSessionId
provider
status
cwd
capabilities
ProviderThread
id: ProviderThreadId
providerSessionId?
appThreadId?
nativeThreadRef?
nativeConversationHeadRef?
coveredRunRange?
contextHandoffIds[]
forkSource?
ContextTransfer
id: ContextTransferId
type
sourceThreadId
targetThreadId
sourcePoint
basePoint?
sourceProvider?
targetProvider?
status
resolution?
ContextHandoff
id: ContextHandoffId
transferId
kind
payload
status
ProviderTurn
id: ProviderTurnId
providerThreadId
nativeTurnRef?
nodeId
runAttemptId?
status
RuntimeRequest
id: RuntimeRequestId
nodeId
providerRequestRef?
kind
status
AppThread
An AppThread is the stable user-facing conversation. It owns messages, runs, checkpoints, and UI state. It does not have to map one-to-one with a provider-native thread forever.
type AppThread = {
id: ThreadId;
projectId: ProjectId;
title: string;
defaultProvider: ProviderKind;
modelSelection: ModelSelection;
runtimeMode: RuntimeMode;
interactionMode: InteractionMode;
branch: string | null;
worktreePath: string | null;
activeProviderThreadId: ProviderThreadId | null;
lineage: AppThreadLineage | null;
createdAt: string;
updatedAt: string;
archivedAt: string | null;
deletedAt: string | null;
};
activeProviderThreadId points to the provider-native conversation currently backing the app thread. Forking, provider migration, or recovery may create new provider threads while preserving the same app thread, depending on the operation.
defaultProvider is only the currently selected default for future runs. Historical runs retain their own provider and provider thread bindings.
lineage is intentionally lightweight browsing metadata. Operational source points, lazy native fork resolution, portable context handoffs, merge-back deltas, and subagent result transfers live in ContextTransfer rows because an app thread can participate in more than one transfer over time.
type AppThreadLineage = {
parentThreadId: ThreadId | null;
relationshipToParent: "fork" | "subagent" | null;
rootThreadId: ThreadId;
};
Run
A Run is the counted user-visible turn. This replaces using provider turn ids as the app-level lifecycle boundary.
type Run = {
id: RunId;
threadId: ThreadId;
ordinal: number;
provider: ProviderKind;
providerThreadId: ProviderThreadId | null;
userMessageId: MessageId;
rootNodeId: NodeId | null;
activeAttemptId: RunAttemptId | null;
status:
| "queued"
| "starting"
| "running"
| "waiting"
| "completed"
| "interrupted"
| "failed"
| "cancelled"
| "rolled_back";
requestedAt: string;
startedAt: string | null;
completedAt: string | null;
checkpointId: CheckpointId | null;
contextHandoffId: ContextHandoffId | null;
sourcePlanRef?: {
threadId: ThreadId;
planId: ProposedPlanId;
};
};
Only a run with countsForConversation = true contributes to the user-visible turn count and checkpoint count.
providerThreadId is the provider-native conversation used for this run. This makes mixed-provider app threads explicit: run 1 may be Codex, run 2 may be Claude, and run 3 may return to the original Codex provider thread.
contextHandoffId points to a materialized handoff artifact consumed by this run. A run may also be associated with a broader ContextTransfer through the transfer's target/source fields. The handoff is the payload; the transfer is the durable relationship and policy record.
RunAttempt
A RunAttempt represents one provider execution attempt for an app run. Most runs have exactly one attempt. Steering, retries, provider recovery, or provider-switch recovery may create more than one attempt.
type RunAttempt = {
id: RunAttemptId;
runId: RunId;
attemptOrdinal: number;
rootNodeId: NodeId;
provider: ProviderKind;
providerThreadId: ProviderThreadId;
providerTurnId: ProviderTurnId | null;
reason: "initial" | "steering_restart" | "retry" | "provider_recovery";
status:
"pending" | "running" | "completed" | "interrupted" | "failed" | "cancelled" | "superseded";
startedAt: string | null;
completedAt: string | null;
};
Run attempts let app-level steering work even for providers that cannot steer an active native turn. The app can interrupt the active provider turn and create a replacement attempt under the same RunId.
Only one attempt is the final selected attempt for run completion and checkpointing. Superseded/interrupted attempts remain in the execution graph for audit/debugging.
ExecutionNode
An ExecutionNode is the generic unit of runtime work. It is the bridge between provider events and app behavior.
type ExecutionNode = {
id: NodeId;
threadId: ThreadId;
runId: RunId | null;
parentNodeId: NodeId | null;
rootNodeId: NodeId;
kind:
| "root_turn"
| "assistant_message"
| "reasoning"
| "plan"
| "todo_list"
| "tool_call"
| "approval_request"
| "user_input_request"
| "subagent"
| "hook"
| "system";
status:
| "pending"
| "running"
| "waiting"
| "completed"
| "interrupted"
| "failed"
| "cancelled"
| "rolled_back";
countsForRun: boolean;
providerThreadId: ProviderThreadId | null;
providerTurnId: ProviderTurnId | null;
nativeItemRef: ProviderRef | null;
runtimeRequestId: RuntimeRequestId | null;
checkpointScopeId: CheckpointScopeId | null;
startedAt: string | null;
completedAt: string | null;
};
The root node of a run is the only node allowed to complete the run. Subagent nodes, tool nodes, approval nodes, and plan nodes may complete independently.
CheckpointScope
A CheckpointScope describes a unit of filesystem state that can be checkpointed. Root runs and child execution nodes can both have checkpoint scopes.
type CheckpointScope = {
id: CheckpointScopeId;
threadId: ThreadId;
runId: RunId | null;
nodeId: NodeId;
parentScopeId: CheckpointScopeId | null;
providerThreadId: ProviderThreadId | null;
kind: "root_run" | "subagent" | "tool" | "provider_thread" | "manual";
ordinalWithinParent: number;
advancesAppRunCount: boolean;
cwd: string;
createdAt: string;
};
Root run scopes have advancesAppRunCount = true. Child scopes, such as subagents, have advancesAppRunCount = false and are nested under the parent run's scope.
ProviderSession
A ProviderSession is a live provider runtime process/session, such as a Codex app-server process or a Claude SDK session.
type ProviderSession = {
id: ProviderSessionId;
provider: ProviderKind;
status: "starting" | "ready" | "running" | "waiting" | "stopped" | "error";
cwd: string;
model: string | null;
capabilities: ProviderCapabilities;
createdAt: string;
updatedAt: string;
lastError: string | null;
};
A provider session may host one or more provider threads if the provider supports it. If a provider only supports one active conversation per process, V2 still models that as one provider thread attached to the session.
The session entity is durable metadata for a live-or-recoverable runtime, not the runtime handle itself. The in-memory process/client handle may disappear because the server restarted, an idle reaper released it, or the provider process crashed. In all cases, the app keeps the ProviderSession, ProviderThread, and native resume refs in durable state, then recreates the live runtime through the normal provider session manager path.
Provider runtime ids and ordinals must not depend on adapter process memory. If a value is persisted or must survive recovery, it is allocated by the orchestrator/correlation/id services or derived from durable provider refs.
ProviderThread
A ProviderThread is a provider-native conversation handle. It is addressable even when it is nested under a subagent execution node.
type ProviderThread = {
id: ProviderThreadId;
provider: ProviderKind;
providerSessionId: ProviderSessionId | null;
appThreadId: ThreadId | null;
ownerNodeId: NodeId | null;
nativeThreadRef: string | null;
nativeConversationHeadRef: ProviderRef | null;
status: "not_loaded" | "idle" | "active" | "archived" | "closed" | "error";
firstRunOrdinal: number | null;
lastRunOrdinal: number | null;
handoffIds: ContextHandoffId[];
forkedFrom: ProviderThreadForkSource | null;
createdAt: string;
updatedAt: string;
};
appThreadId is set when the provider thread backs a first-class app thread. ownerNodeId is set when the provider thread is nested under an execution node, such as a subagent. A provider thread can later be forked or promoted into a first-class app thread.
A provider thread may have gaps in its native run coverage. Example: runs 1-5 use Codex provider thread A, runs 6-8 use Claude provider thread B, and run 9 returns to Codex thread A with a handoff summary covering runs 6-8. In that case, Codex thread A remains the same provider thread, but it has an explicit ContextHandoff before run 9.
ContextTransfer
A ContextTransfer records a source/target relationship and how context should move between them. It is the shared primitive for user forks, provider switching, merge-back, and subagents.
type ContextTransfer = {
id: ContextTransferId;
type: "fork" | "provider_handoff" | "merge_back" | "subagent_spawn" | "subagent_result";
sourceThreadId: ThreadId;
targetThreadId: ThreadId;
sourcePoint: ContextSourcePoint;
basePoint: ContextSourcePoint | null;
sourceProvider: ProviderKind | null;
targetProvider: ProviderKind | null;
status:
"pending" | "resolved_native" | "resolved_portable" | "failed" | "consumed" | "superseded";
resolution: ContextTransferResolution | null;
createdBy: "user" | "agent" | "system";
createdAt: string;
consumedAt: string | null;
};
type ContextSourcePoint = {
threadId: ThreadId;
runId: RunId | null;
checkpointId: CheckpointId | null;
turnItemId: TurnItemId | null;
providerThreadRef: NativeThreadRef | null;
providerTurnRef: NativeTurnRef | null;
};
type ContextTransferResolution =
| { strategy: "native_fork"; providerThreadRef: NativeThreadRef }
| { strategy: "portable_context"; contextHandoffId: ContextHandoffId }
| { strategy: "delta_context"; contextHandoffId: ContextHandoffId }
| { strategy: "checkpoint_context"; contextHandoffId: ContextHandoffId };
Creating a transfer should be cheap. Expensive context handoffs are materialized lazily when the target run starts and the selected provider is known.
ContextHandoff
A ContextHandoff is a first-class artifact created when portable provider context must be bridged. It is most common when changing providers between runs, but it also applies when provider resume fails and a replacement provider thread must be seeded from app history.
In the broader transfer model, ContextTransfer records source, target, and lifecycle. ContextHandoff is the materialized payload consumed by a run when native transfer cannot satisfy the relationship.
type ContextHandoff = {
id: ContextHandoffId;
transferId: ContextTransferId;
threadId: ThreadId;
targetRunId: RunId;
fromProviderThreadIds: ProviderThreadId[];
toProviderThreadId: ProviderThreadId;
coveredRunOrdinals: {
from: number;
to: number;
};
strategy:
| "delta_since_target_last_seen"
| "full_thread_summary"
| "checkpoint_summary"
| "manual_context";
status: "pending" | "ready" | "failed" | "superseded";
summaryMessageId: MessageId | null;
summaryText: string;
createdByProvider: ProviderKind | null;
createdAt: string;
updatedAt: string;
};
The handoff is not just prompt text. It is part of the graph and can be inspected, regenerated, superseded, or audited.
The preferred return-to-provider strategy is delta_since_target_last_seen: resume the previous provider thread and summarize only the runs that happened while that provider was inactive. Use full_thread_summary when resume fails, provider settings are incompatible, or the accumulated handoffs would create poor context quality.
ProviderTurn
A ProviderTurn is the normalized handle for a provider-native turn.
type ProviderTurn = {
id: ProviderTurnId;
providerThreadId: ProviderThreadId;
nodeId: NodeId;
runAttemptId: RunAttemptId | null;
nativeTurnRef: string | null;
ordinal: number;
status: "pending" | "running" | "completed" | "interrupted" | "failed" | "cancelled";
startedAt: string | null;
completedAt: string | null;
};
Codex has strong native turn ids. Weaker providers may only have ordinals. Both map into ProviderTurnId.
RuntimeRequest
Requests represent provider-originated callbacks that require app/user response.
type RuntimeRequest = {
id: RuntimeRequestId;
nodeId: NodeId;
providerTurnId: ProviderTurnId | null;
nativeRequestRef: string | null;
kind:
"command" | "file-read" | "file-change" | "dynamic_tool_call" | "user_input" | "auth_refresh";
status: "pending" | "resolved" | "expired" | "cancelled";
responseCapability:
| { type: "live"; providerSessionId: ProviderSessionId }
| { type: "not_resumable"; reason: string };
createdAt: string;
resolvedAt: string | null;
};
The permission kinds are the same canonical domain values used by V1 ProviderRequestKind.
Adapters map provider-native callback names such as Codex item/commandExecution/requestApproval
and Claude tool permission names into these app-level values.
Requests may remain visible after restart, but they are only respondable if their responseCapability is live.
Messages
Messages are part of the conversation projection, not the raw provider graph. They link back to runs and nodes when possible.
type ConversationMessage = {
id: MessageId;
threadId: ThreadId;
runId: RunId | null;
nodeId: NodeId | null;
role: "user" | "assistant" | "system";
text: string;
attachments: Attachment[];
streaming: boolean;
createdAt: string;
updatedAt: string;
};
Provider message chunks are collected through items/content events and projected into messages. The projection may hide child/subagent messages by default while preserving them in the graph.
Plans, Questions, And Todo Lists
V2 treats these as structured turn items or execution nodes.
type PlanArtifact = {
id: PlanId;
threadId: ThreadId;
runId: RunId | null;
nodeId: NodeId;
kind: "proposed_plan" | "todo_list" | "questions";
status: "draft" | "active" | "completed" | "superseded";
markdown?: string;
steps?: Array<{ id: string; text: string; status: "pending" | "running" | "completed" }>;
questions?: UserInputQuestion[];
};
Codex turn/plan/updated maps to a todo_list artifact. Plan-mode final plan items map to proposed_plan. User-input question requests map to questions plus a RuntimeRequest.
Checkpoint
Checkpoints attach to checkpoint scopes. A root run checkpoint is the user-visible conversation checkpoint. A child checkpoint records nested filesystem state for a subagent, tool, or provider thread without advancing the parent run count.
type Checkpoint = {
id: CheckpointId;
threadId: ThreadId;
scopeId: CheckpointScopeId;
runId: RunId | null;
nodeId: NodeId;
parentCheckpointId: CheckpointId | null;
ordinalWithinScope: number;
appRunOrdinal: number | null;
ref: string;
status: "ready" | "missing" | "error" | "stale";
files: CheckpointFileSummary[];
capturedAt: string;
};
A child/subagent provider turn can create a child checkpoint. It does not create an app-run checkpoint unless it is running as a first-class app run in a forked/promoted thread.
Raw Provider Diagnostics
Raw provider diagnostics are append-only evidence for debugging, support, and replay fixture generation. They should be written to bounded rotating log files, not treated as canonical durable SQLite state.
type RawProviderEvent = {
id: RawEventId;
provider: ProviderKind;
providerSessionId: ProviderSessionId;
sequence: number;
direction: "incoming" | "outgoing";
messageKind: "request" | "response" | "notification" | "error";
method: string | null;
jsonRpcId: string | number | null;
payload: unknown;
observedAt: string;
};
No domain behavior should depend on parsing historic UI events if raw provider diagnostics are available. Production behavior should depend on normalized orchestration events/entities and provider refs. Raw provider frames are evidence and replay input, not the source of truth for normal app state.
Normalized V2 entities should preserve enough correlation metadata to explain and route behavior:
- provider kind.
- provider session/thread/turn refs.
- provider item/request refs when available.
- native id strength.
- diagnostic log location or raw frame reference when useful.
The replay framework can use raw provider transcripts as input without requiring production SQLite to store every raw frame.
Projections
V2 should expose separate projections:
- Thread shell: fast sidebar list.
- Thread detail: messages, runs, activities, checkpoints, plans.
- Execution graph: debug/developer view of nodes and provider refs.
- Provider sessions: live runtime/process state.
- Pending requests: actionable approvals/user input.
The UI can remain simple while the graph remains precise.
Projection streaming should use the existing app snapshot-plus-cursor contract. A thread-detail subscription should return a projection snapshot at sequence N, then stream only events after N. The frontend should not receive events already reflected in the snapshot. Reconnects may reset from a fresh snapshot instead of replaying local client state; the fresh snapshot and its snapshotSequence become the new cursor boundary.
Turn Item Projection
The frontend should not reconstruct display order by merging messages, plans, checkpoints, approvals, and activities itself. V2 exposes an ordered turnItems projection for thread detail rendering.
turnItems are projection records, not the canonical source of truth. The canonical graph remains normalized in runs, attempts, nodes, requests, messages, plans, and checkpoints. Provider-native item refs live directly on nodes and turn items where correlation is needed.
Forked threads also expose a derived visibleTurnItems projection. turnItems remains the canonical
items emitted on that app thread. visibleTurnItems is the user-visible ordered read model: it may
reference inherited source items through the fork source point, include synthetic lifecycle markers
such as "forked from conversation", and then continue with local target-thread items. This keeps
storage normalized while giving thread detail views and tests one backend-owned surface to assert.
Known common tools should have structured item variants so the frontend can render stable custom components without parsing provider text:
type TurnItem =
| UserMessage
| AssistantMessage
| Reasoning
| Plan
| FileChange
| CommandExecution
| FileSearch
| WebSearch
| ApprovalRequest
| Checkpoint
| Compaction
| Handoff
| Fork
| DynamicTool;
Examples:
type FileChange = {
type: "file_change";
fileName: string;
additions?: number;
deletions?: number;
diffStr?: string;
oldStr?: string;
newStr?: string;
};
type CommandExecution = {
type: "command_execution";
input: string;
status: TurnItemStatus;
output?: string;
exitCode?: number;
};
type DynamicTool = {
type: "dynamic_tool";
toolName: string | null;
input: unknown;
output?: unknown;
};
Compaction, handoff, and fork are orchestration lifecycle items, not dynamic tools:
compaction: one item can transition fromrunningwith title like "Compacting context..." tocompletedwith title like "Compacted context".handoff: records the context bridge from one or more source provider threads/providers to a target provider thread/provider.fork: records that the user or system created a new app thread from a run, node, or provider thread.
The UI can render known variants with deterministic components and render dynamic_tool as expandable JSON input/output. Each turn item keeps refs back to runId, nodeId, providerTurnId, and nativeItemRef so debug views can jump from the display stream back into the graph and provider logs.