t3-code-android-nightly/docs/orchestration-v2/feature-lifecycles.md
Theo Browne f32c23cf1a
fix(server): Stop also stops delegated tasks and pull request watches (#16002)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:51:15 -07:00

17 KiB

Feature Lifecycles

This document describes how core user-facing features should work in V2. These flows describe target behavior, not current implementation constraints.

Creating Threads

Creating an app thread is separate from starting provider work.

thread.create
  -> AppThread created
  -> no provider session required yet
  -> no provider thread required yet unless eager provider creation is enabled

Provider thread creation may be lazy:

first run starts
  -> ensure ProviderSession
  -> ensure ProviderThread
  -> send provider turn

If the provider supports empty thread creation, V2 may create a provider thread early. If not, the provider thread is created at first run start.

Starting A Run

When the user sends a message:

thread.message.send
  -> ConversationMessage(role=user)
  -> Run(status=queued, ordinal=N)
  -> root ExecutionNode(kind=root_turn, countsForRun=true)
  -> enqueue provider command

When provider execution begins:

provider turn accepted/started
  -> ProviderTurn allocated or correlated
  -> root node status=running
  -> run status=running

When the root provider turn completes:

root provider turn/completed
  -> finalize assistant streams
  -> finalize plans/todos/questions
  -> root node terminal
  -> capture checkpoint if applicable
  -> run terminal

Only the root node can complete the run.

Steering And Queueing Messages

V2 should support three classes of follow-up input:

steer active run in place
steer active run by restart
queue next run

Steering is an app-level intent to change the active run's instructions. Provider-native steering is only one implementation strategy.

If the provider supports active-run steering, the app sends a steer command to the provider and keeps the same run active.

If the provider does not support active-run steering, the app interrupts the active provider turn, finalizes the interrupted attempt, and starts a replacement provider turn for the same app-level steering intent. This may be represented as either:

  • the same RunId with a new root attempt node, if the UI should treat steering as revising the active run, or
  • a new RunId linked by supersedesRunId, if preserving the interrupted attempt as a counted turn is important.

The preferred default is the same RunId with a new root attempt node until the first attempt has produced a user-meaningful terminal assistant response or checkpoint.

Queueing creates a new run after the active run.

Recommended command shape:

type MessageDispatchMode =
  | { type: "steer_active"; targetRunId: RunId }
  | { type: "restart_active"; targetRunId: RunId }
  | { type: "queue_after_active" }
  | { type: "start_immediately" };

Policy:

  • If the provider supports active-run steering, send steer to the provider and attach the message to the active run.
  • If not, interrupt the active provider turn and restart the active run with the steering message included.
  • If the user explicitly requests a new turn, queue even if steering is available.
  • If no run is active, start immediately.

The model supports this because Run and ProviderTurn are separate. A single app run may have multiple provider-turn attempts when the first attempt was interrupted for steering.

type RunAttempt = {
  id: RunAttemptId;
  runId: RunId;
  attemptOrdinal: number;
  rootNodeId: NodeId;
  providerThreadId: ProviderThreadId;
  providerTurnId: ProviderTurnId | null;
  reason: "initial" | "steering_restart" | "retry" | "provider_recovery";
  status: "running" | "completed" | "interrupted" | "failed" | "cancelled";
};

Only the selected/final attempt completes the run and creates the run checkpoint. Earlier interrupted steering attempts remain in the execution graph for audit/debugging.

Follow-Up Turns

A normal follow-up after a completed run creates a new run.

previous Run completed
user sends follow-up
  -> new Run ordinal=N+1
  -> new root node
  -> same AppThread
  -> same ProviderThread if resumable

If the previous provider thread cannot be resumed due to runtime error, missing native state, or incompatible provider settings, the provider thread is marked unavailable and the app creates a replacement provider thread from reconstructed context.

The replacement path is an error recovery flow, not a provider capability branch.

Changing Providers Between Runs

Changing providers is a first-class context handoff. It does not fork the app thread and it does not rewrite prior provider history.

user selects a different provider for next run
  -> create Run with target provider
  -> resolve or create target ProviderThread
  -> create ContextHandoff if target provider thread lacks current app context
  -> inject handoff summary into provider context
  -> send user message as the new root turn

Example:

runs 1-5: Codex provider thread C1
switch to Claude
  -> summarize runs 1-5 for Claude
  -> create Claude provider thread L1
runs 6-8: Claude provider thread L1
switch back to Codex
  -> resume Codex provider thread C1 if possible
  -> summarize runs 6-8 for Codex
  -> send run 9 to C1 with the delta handoff

The default policy when returning to a provider is to resume the previous provider thread and bridge only the off-provider delta. This preserves native continuity for that provider's own earlier work while informing it about what happened elsewhere.

Fallback policy: create a fresh provider thread with a full app-thread summary when:

  • the previous provider thread cannot be resumed,
  • the provider's context is likely too stale or polluted by repeated handoffs,
  • model/runtime settings changed incompatibly,
  • the user explicitly requests a clean provider context.

The fallback should be explicit in the graph:

new ProviderThread
  forked/reconstructed from AppThread summary
  ContextHandoff(strategy=full_thread_summary)

Provider switching should never concatenate hidden summaries into arbitrary prompts without recording the handoff. The handoff text is part of the run input contract and must be auditable.

Forking Threads

Forking creates a new app thread from a source point. It should not require provider selection and it should not eagerly create provider runtime state.

thread.fork
  -> create target AppThread
  -> record AppThread lineage
  -> create ContextTransfer(type=fork, status=pending)
  -> no ProviderSession required
  -> no ProviderThread required
  -> no portable context handoff required

The first run on the fork resolves the pending transfer:

first message on fork selects provider P
  -> if source provider is P and native fork is available:
       resolve by provider-native fork
     else:
       materialize portable context
  -> create/resume target ProviderThread
  -> send resolved context + user message

The preferred first implementation should fork only from stable source points: completed runs, checkpoints, or idle provider threads with known coverage. Forking from an active run should use the latest completed checkpoint or be rejected until active-run semantics are deliberately designed.

Merge-Back From Forks

Merge-back brings fork exploration into a source thread without copying the entire fork transcript into the main provider context.

source thread forked at point S
fork thread explores through point F
user sends next source-thread message with merge-back intent
  -> create ContextTransfer(type=merge_back, basePoint=S, sourcePoint=F)
  -> build delta context lazily
  -> inject delta with the next source-thread user message

The delta should describe what changed since the fork point: decisions, files changed, commands/tests run, conclusions, and unresolved issues. It should not include provider protocol noise or full transcript content unless needed.

Interruption

Interruption targets a run or a node.

interrupt.run(RunId)
  -> resolve root node
  -> resolve provider turn/thread refs
  -> provider interrupt command if supported
  -> mark interrupt requested

Terminal state arrives from provider lifecycle:

provider interrupt request returns
  -> command acknowledged only
provider emits root turn/completed status=interrupted
  -> root node interrupted
  -> run interrupted
  -> checkpoint policy runs

The app should not mark the run terminal solely because the interrupt request returned.

If the provider does not support interruption:

  • best case: stop provider session and mark run interrupted or cancelled by policy.
  • worst case: mark interrupt unsupported and leave run active until provider exits.

The Stop button sends run.interrupt with holdQueue. A Stop also holds the queue, ends the thread's pull request watches, and drops wakes its delegated tasks still owe, so nothing automatic restarts the thread. It then stops every delegated task under the thread: a delegated-tasks.stop effect sends the internal thread.stop command to each child thread, depth first. This runs after commit, not inside the parent's command, because a command holds only its own thread's lock. task_cancel sends the same thread.stop to its child.

Resumption

Resumption has three related meanings:

  1. Resume an app thread in the UI from persisted projection.
  2. Resume a provider-native conversation/session.
  3. Recover or recreate a live provider runtime process while preserving app and provider-thread continuity.

V2 always supports app-thread resumption from stored events/projections. Provider-thread resumption is a required provider adapter primitive. Every provider harness must expose a cursor/session/thread handle that can be used to resume prior provider state.

Provider runtime recovery is also a core primitive, not a test-only hook. The same recovery path is used when:

  • the user restarts the app/server and all provider processes are gone,
  • an idle-session reaper intentionally releases provider processes,
  • a provider process crashes or loses network and policy allows retry.
open app thread
  -> load AppThread, Runs, Messages, ExecutionGraph summary
  -> if provider work must continue, resume the relevant ProviderThread

Provider resume flow:

resolve activeProviderThreadId
resolve nativeThreadRef / nativeConversationHeadRef resume cursor
start ProviderSession
provider resume
bind new ProviderSessionId to ProviderThread

Provider runtime recovery flow:

provider runtime unavailable
  -> mark ProviderSession stopped/error with reason
  -> remove live runtime handle from ProviderSessionManager
  -> keep ProviderThread and nativeThreadRef durable
  -> on next work, open a new provider runtime
  -> resume the active ProviderThread before starting/retrying work

Idle cleanup and crash recovery must use production lifecycle APIs. Tests may recreate the outermost layer/runtime or drive the same reaper/recovery service with deterministic clock and replayed provider transport, but they must not use test-only session restart methods.

If provider resume fails:

  • preserve historical graph.
  • mark provider thread not loaded or error.
  • create replacement provider thread from reconstructed app context when the user continues.
  • mark pending live requests as not resumable.

Provider resume failure is a runtime/recovery condition. It should not be modeled as "provider does not support resume."

Forking

Forking creates a new first-class AppThread from a stable source.

Supported fork sources:

type ForkSource =
  | { type: "run"; threadId: ThreadId; runId: RunId }
  | { type: "node"; nodeId: NodeId }
  | {
      type: "provider_thread";
      providerThreadId: ProviderThreadId;
      providerTurnId?: ProviderTurnId;
    };

Stable fork boundaries:

  • completed run
  • interrupted or failed run with a provider snapshot/checkpoint
  • completed subagent node with a provider thread
  • provider thread snapshot returned by the provider
  • checkpoint boundary

Unstable boundaries:

  • streaming message delta
  • active tool call
  • pending approval
  • active subagent turn, unless live fork support is explicitly implemented

Forking from a subagent:

subagent node selected
  -> resolve ProviderThread owned by node
  -> provider fork/resume if supported
  -> create new AppThread
  -> attach forked ProviderThread
  -> copy/link checkpoint baseline

If the provider cannot fork native threads, V2 may synthesize a fork by creating a new app thread with projected messages/context, but that should be marked as lower fidelity by capability.

Checkpointing

Checkpoints attach to checkpoint scopes. Root runs and child execution nodes can both be checkpointed.

Pre-run baseline:

before root execution starts
  -> ensure root checkpoint for current app run ordinal - 1

Post-root-run checkpoint:

root node terminal
  -> drain/finalize streams
  -> capture root checkpoint for app run ordinal
  -> compute diff from prior root checkpoint
  -> mark run terminal with checkpoint

Child/subagent checkpoint:

child checkpointable node starts
  -> create child CheckpointScope under parent scope
child node terminal
  -> drain/finalize child streams/items
  -> capture child checkpoint for that scope
  -> compute diff from parent/previous child checkpoint
  -> mark child node checkpointed

Interrupted/failed runs:

  • If filesystem state is meaningful and safe to capture, capture with status reflecting terminal state.
  • If not, store a missing/error checkpoint summary.
  • Child/subagent checkpoints are allowed, but they do not advance the parent app run count.
  • Root run checkpointing should include or reference child checkpoint summaries so the run has a complete audit trail.

Rollback:

rollback.toRun(threadId, targetRunOrdinal)
  -> restore filesystem checkpoint targetRunOrdinal
  -> compute provider turns to roll back if provider supports rollback
  -> call provider rollback
  -> reconcile returned provider snapshot if available
  -> mark later runs rolled_back
  -> delete or mark stale later checkpoint refs

Codex thread/rollback returns an authoritative provider thread snapshot. Other providers may require a synthetic provider thread restart from context.

Nested rollback:

rollback.node(nodeId, checkpointId)
  -> restore child checkpoint if scope cwd/ref is available
  -> roll back provider thread for that child scope if supported
  -> mark descendant child checkpoints stale/rolled_back
  -> preserve parent run checkpoint history unless parent filesystem state changed

If a child checkpoint mutates the same workspace as the parent, restoring it may dirty the parent run's workspace state. The UI/API should make that explicit.

Approvals

Approvals are runtime requests scoped to execution nodes.

provider requestApproval
  -> RuntimeRequest(kind=...)
  -> approval ExecutionNode
  -> thread pending request projection
  -> optional thread status waiting

User response:

approval.respond(RuntimeRequestId)
  -> resolve live provider callback
  -> send provider response
  -> RuntimeRequest resolved
  -> approval node completed/cancelled

Rules:

  • Approval ids are app-owned.
  • Provider request ids are refs.
  • Pending approvals from subagents are still actionable if the provider exposes live response capability.
  • Approval response should fail with not_resumable if provider callback state is gone.

Ask Questions / User Input

Ask-questions is a user-input request, not a plan by itself.

provider requests user input
  -> RuntimeRequest(kind=user_input)
  -> ExecutionNode(kind=user_input_request)
  -> Question artifact
  -> run status=waiting

On answer:

user-input.respond(RuntimeRequestId, answers)
  -> provider callback
  -> request resolved
  -> node completed
  -> run resumes/runs

If the provider does not support structured questions, the adapter may project a plain assistant message and no respondable request.

Plans And Todo Lists

V2 separates three concepts:

  • Proposed plan: a durable plan artifact the user can accept/implement.
  • Todo list: live execution progress for the current run.
  • Ask questions: structured user-input request.

Codex examples:

turn/plan/updated
  -> PlanArtifact(kind=todo_list)
  -> activity/projection update

item/plan/delta or plan item completed
  -> PlanArtifact(kind=proposed_plan)

item/tool/requestUserInput
  -> PlanArtifact(kind=questions)
  -> RuntimeRequest(kind=user_input)

Rules:

  • Root run todo lists update the main plan/progress UI.
  • Child/subagent todo lists are nested under their execution node.
  • Child plans do not replace root plans unless promoted/forked.
  • Proposed plan implementation creates a new run or a new thread depending on user action.

Activities

Activities are a projection of execution graph events.

Examples:

tool.started
approval.requested
approval.resolved
plan.updated
checkpoint.captured
subagent.started
subagent.completed
runtime.warning

Activities should keep nodeId and runId so the UI can group them correctly.

Completion Barriers

Before a run is marked terminal, V2 should pass a finalization barrier:

root provider turn terminal
  -> flush assistant text buffers
  -> close active assistant messages
  -> finalize plan/todo artifacts
  -> close open non-live child nodes if provider marked them done
  -> capture checkpoint
  -> publish run terminal

This prevents early checkpoint capture and incomplete plan state.