Part V · The work lifecycleThreads and work items
Chapter 25source checked

Threads, provider tasks, plans, skills, and subagents

A T3 thread is the durable work record. Provider task and subagent activity is normalized into activities and roster projections; high-frequency progress and usage streams are bounded, while liveness and plan progress stay explicitly ephemeral.

What this chapter resolves
  • Separate the durable thread work record from provider task and subagent observations.
  • Trace task lifecycle normalization into activities, bounded latest-state streams, the Agents projection, and the quiet work log.
  • Identify which liveness and plan-progress indicators disappear on restart or settlement.
  • Explain lifecycle, pin, and snooze as thread overlays rather than scheduler state.

“Task” is overloaded in agent products. Here, a thread is the durable work item: it has a project, title, model selection, runtime and interaction modes, conversation, activities, checkpoints, session summary, and lifecycle fields. A provider task or subagent is observed runtime activity inside that thread, not a second durable cross-provider work-item aggregate.

This lets a provider expose a fleet, workflow, child agent, background shell, or nothing at all without forcing every provider into a false scheduler schema. T3 can retain useful normalized evidence while preserving provider task identity and provenance in an activity payload.

The durable record is the thread

The thread read model holds message/activity history, latest turn, checkpoints, proposed plans, session summary, and lifecycle overlays. It is the address used for routing, recovery, and cross-surface navigation. A task id is not promoted to that role: it travels inside normalized task.started, task.progress, task.updated, and task.completed activities.

Thread lifecycle is durable. Archive, settled/active override, and delete have their own state. Snooze is explicitly an overlay on an active thread: it suppresses the inbox until snoozedUntil passes or activity wakes it. Pinning is another overlay: active pinned threads render in a pinned block, while settled and snoozed threads remain in their own shelves. A fractional pin order key lets a client move one pin without rewriting its neighbors.

These overlays are separate fields, but their commands deliberately coordinate visibility state. Settling also unpins and unsnoozes; pinning unsets settlement and unsnoozes. Snooze rejects pending approval/input and queued-start windows, yet it can hide a thread whose provider session is already running. That coupling organizes the inbox; it still does not schedule or cancel native provider work.

Native activity becomes product evidence; high-frequency streams are bounded

Adapters translate native task/subagent updates into canonical task events. Runtime ingestion turns those into thread activities. High-frequency progress uses a stable per-thread/per-task id, so a meaningful tick replaces latest state instead of creating an unbounded timeline. Typed usage has a second stable id, and agent-owned tool heartbeats have another stable latest-state shape. By contrast, task.started, task.updated, and task.completed keep the provider event id. They are retained activities, but this code does not collapse all lifecycle evidence into one bounded row. A usage-only update cannot erase the latest status line, and a status update cannot erase the last known task usage.

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:569–646 ↗verbatim · typescript · 11d184eb
      const linkage = taskLinkageActivityFields(event.payload as Record<string, unknown>);
      // Usage and activity are independent latest-state streams. Keeping them
      // under separate stable ids prevents a command/reasoning update from
      // replacing the last known token count (and prevents a usage-only tick
      // from blanking the last meaningful activity).
      const identityLinkage = { ...linkage };
      delete identityLinkage.typedUsage;
      delete identityLinkage.status;
      delete identityLinkage.error;
      const title =
        event.payload.description.trim().length > 0
          ? { title: truncateDetail(event.payload.description, 120) }
          : {};
      const hasProgressState =
        event.payload.typedUsage === undefined ||
        event.payload.summary !== undefined ||
        event.payload.lastToolName !== undefined ||
        event.payload.status !== undefined ||
        event.payload.error !== undefined;
      return [
        ...(hasProgressState
          ? [
              {
                // Stable per-task id: activity is "latest state", not
                // history, so each meaningful tick replaces the last. This
                // bounds a large fleet to one activity row per task.
                id: EventId.make(`task-progress:${event.threadId}:${event.payload.taskId}`),
                createdAt: event.createdAt,
                tone: "info" as const,
                kind: "task.progress" as const,
                summary:
                  event.payload.description.trim().length > 0
                    ? truncateDetail(event.payload.description, 120)
                    : "Reasoning update",
                payload: {
                  taskId: event.payload.taskId,
                  ...title,
                  detail: truncateDetail(event.payload.summary ?? event.payload.description),
                  ...(event.payload.summary
                    ? { summary: truncateDetail(event.payload.summary) }
                    : {}),
                  ...(event.payload.lastToolName
                    ? { lastToolName: event.payload.lastToolName }
                    : {}),
                  ...(event.payload.status ? { status: event.payload.status } : {}),
                  ...(event.payload.error ? { error: event.payload.error } : {}),
                  ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}),
                  ...identityLinkage,
                },
                turnId: toTurnId(event.turnId) ?? null,
                ...maybeSequence,
              },
            ]
          : []),
        ...(event.payload.typedUsage !== undefined
          ? [
              {
                id: EventId.make(`task-usage:${event.threadId}:${event.payload.taskId}`),
                createdAt: event.createdAt,
                tone: "info" as const,
                kind: "task.progress" as const,
                summary: "Task usage updated",
                payload: {
                  taskId: event.payload.taskId,
                  ...title,
                  ...identityLinkage,
                  usageSnapshot: true,
                  typedUsage: event.payload.typedUsage,
                },
                turnId: toTurnId(event.turnId) ?? null,
                ...maybeSequence,
              },
            ]
          : []),
      ];
    }
 
    case "task.updated": {
Read this as: Progress and typed usage are persisted under separate stable task-scoped activity ids, so either stream may update without erasing the other.

The payload can retain task type, agent linkage, title, role, model, effort, tool-use id, workflow/phase metadata, output file, status, error, and typed usage. The server stamps agentKind, so clients do not reverse-engineer an agent from a provider-specific task-type vocabulary. Legacy rows without that server stamp fall back to background classification and therefore do not enter the current Agents roster merely because their task type looks agent-like.

packages/contracts/src/providerRuntime.ts:471–543 ↗verbatim · typescript · 9c779466
/**
 * Typed per-task usage rollup. Field names match the orchestration-v2 subagent
 * usage vocabulary (#4779) so the eventual migration is a rename, not a remap.
 * Claude reports per-activation deltas; Codex reports cumulative totals — the
 * merge strategy is provider-specific and lives in client-runtime.
 */
export const RuntimeTaskUsage = Schema.Struct({
  totalTokens: NonNegativeInt,
  inputTokens: Schema.optional(NonNegativeInt),
  cachedInputTokens: Schema.optional(NonNegativeInt),
  outputTokens: Schema.optional(NonNegativeInt),
  reasoningOutputTokens: Schema.optional(NonNegativeInt),
  toolUses: Schema.optional(NonNegativeInt),
  durationMs: Schema.optional(NonNegativeInt),
});
export type RuntimeTaskUsage = typeof RuntimeTaskUsage.Type;
 
export const TaskWorkflowPhase = Schema.Struct({
  index: NonNegativeInt,
  title: TrimmedNonEmptyStringSchema,
});
export type TaskWorkflowPhase = typeof TaskWorkflowPhase.Type;
 
export const TaskRunHandles = Schema.Struct({
  runId: Schema.optional(TrimmedNonEmptyStringSchema),
  scriptPath: Schema.optional(TrimmedNonEmptyStringSchema),
  transcriptDir: Schema.optional(TrimmedNonEmptyStringSchema),
  /** Only http/https URLs may be stored here — sanitized at the adapter. */
  sessionUrl: Schema.optional(TrimmedNonEmptyStringSchema),
});
export type TaskRunHandles = typeof TaskRunHandles.Type;
 
/**
 * Watch-loop task types: Monitor-tool tasks plus background shells (a shell
 * that outlives its turn is in practice a watch loop). Canonical single copy —
 * the server liveness registry, ingestion's agentKind stamp, and the client
 * fold's legacy fallback all classify with these sets.
 */
export const MONITOR_TASK_TYPES: ReadonlySet<string> = new Set([
  "monitor",
  "monitor_mcp",
  "local_bash",
  "shell",
]);
/** Task types that are neither agents nor watch loops (plan-mode bookkeeping). */
export const INERT_TASK_TYPES: ReadonlySet<string> = new Set(["plan", "dream"]);
 
/**
 * Agent-vs-background classification, stamped by ingestion as `agentKind` so
 * persisted rows are self-describing. A deliberate denylist: the SDK's
 * agent-flavored type names drift (subagent, local_agent, local_workflow, …)
 * and an allowlist silently dropped real subagents when "local_agent"
 * appeared. A task launched from inside a subagent (agentId set) is
 * agent-internal background work UNLESS it is itself agent-flavored — a
 * nested agent can outlive its parent and stays in the roster.
 */
export function classifyTaskAgentKind(input: {
  readonly taskType?: string | undefined;
  readonly agentId?: string | undefined;
}): "agent" | "background" {
  const { taskType, agentId } = input;
  const nonAgentType =
    taskType !== undefined && (MONITOR_TASK_TYPES.has(taskType) || INERT_TASK_TYPES.has(taskType));
  if (agentId !== undefined && agentId.trim().length > 0) {
    return taskType === undefined || nonAgentType ? "background" : "agent";
  }
  return nonAgentType ? "background" : "agent";
}
 
/**
 * Optional agent-identity linkage carried on every task lifecycle payload.
 * Repeated on progress and terminal rows (not just start) so client folds can
 * reconstruct an agent even when its start row aged out of activity retention.
Read this as: The shared contract defines typed per-task usage and a denylist-based agent/background classifier; provider type-name drift is expected.

Codex and Claude have concrete task normalization paths in the pinned source. Codex turns collaboration-agent notifications into task lifecycle/progress events. Claude maps SDK task, workflow, and member progress messages into task events, including typed usage when present. This audit does not establish equivalent task-rollup coverage for Cursor, Grok, or OpenCode; absence here is an evidence boundary, not a claim they can never support it.

Figure 25.1 · Provider tasks become thread evidence, then surface-specific viewsprogress and usage are bounded latest-state streams; lifecycle events retain provider ids
Provider tasks through durable and ephemeral projectionsDiagram loading

Zoom with the controls, +/, or Ctrl/ + trackpad scroll. Enable Pan to drag, use two-finger scrolling, or use the arrow keys. 0 fits the diagram; Esc leaves Pan or expanded view.

Provider tasks through durable and ephemeral projections
Text equivalent

Native Codex or Claude task signals pass through provider adapters into canonical task events. Runtime ingestion creates thread activities. Progress, usage, and agent tool heartbeat streams use stable latest-state identifiers; lifecycle start, update, and completion retain provider event identifiers. The web Agents panel and web/mobile work logs derive different views from those activities. A separate branch updates in-memory liveness and plan progress, which disappear on restart or settlement according to service rules.

Figure 25.1. Codex and Claude normalize native task observations into the shared runtime vocabulary. Runtime ingestion stamps task linkage and writes thread activities; progress, typed usage, and agent tool heartbeats use stable latest-state ids, while start/update/completion keep provider event ids. Clients fold retained activity into a web Agents roster and surface-specific quiet views. In parallel, the server updates in-memory background liveness and plan progress. Those ephemeral services are read for current status but are not a second durable task projection.

The Agents view is a projection, not a second log

The client folds retained task.* and agent-owned tool.progress activities into a source-neutral subagent model. It accepts pending, running, waiting, idle, completed, failed, cancelled, and interrupted; idle is explicitly resumable rather than automatically “working.” The web Agents panel displays stable spawn order, current activity, model/effort, token count, tool-use count, and elapsed time. Its elapsed clock is presentation state: settled rows freeze at completion.

The fold is tolerant of missing starts, late completions, reactivation, and session death. Task usage is parsed field by field and max-merged so duplicate or late cumulative frames cannot shrink a known count or erase a known breakdown.

packages/client-runtime/src/state/subagentRuntime.ts:149–225 ↗verbatim · typescript · 0c82d5cb
function asUsage(value: unknown): SubagentUsage | undefined {
  if (typeof value !== "object" || value === null) {
    return undefined;
  }
  const record = value as Record<string, unknown>;
  const totalTokens = asCount(record.totalTokens);
  if (totalTokens === undefined) {
    return undefined;
  }
  const usage: {
    totalTokens: number;
    inputTokens?: number;
    cachedInputTokens?: number;
    outputTokens?: number;
    reasoningOutputTokens?: number;
    toolUses?: number;
    durationMs?: number;
  } = { totalTokens };
  const inputTokens = asCount(record.inputTokens);
  if (inputTokens !== undefined) usage.inputTokens = inputTokens;
  const cachedInputTokens = asCount(record.cachedInputTokens);
  if (cachedInputTokens !== undefined) usage.cachedInputTokens = cachedInputTokens;
  const outputTokens = asCount(record.outputTokens);
  if (outputTokens !== undefined) usage.outputTokens = outputTokens;
  const reasoningOutputTokens = asCount(record.reasoningOutputTokens);
  if (reasoningOutputTokens !== undefined) usage.reasoningOutputTokens = reasoningOutputTokens;
  const toolUses = asCount(record.toolUses);
  if (toolUses !== undefined) usage.toolUses = toolUses;
  const durationMs = asCount(record.durationMs);
  if (durationMs !== undefined) usage.durationMs = durationMs;
  return usage;
}
 
/**
 * Provider-specific usage merge (#4779 semantics, verbatim):
 * - max-merge (Codex-style cumulative frames): field-wise maximum, idempotent
 *   under duplicate or late frames. Cumulative totals never shrink.
 * - accumulate (Claude-style activation deltas): not needed at this layer —
 *   Claude's task_progress usage is itself cumulative per task, so the fold
 *   also max-merges. The distinction matters when v2 sums activations.
 * Field-wise: a terminal payload carrying only totalTokens must not wipe a
 * known breakdown.
 */
function mergeUsageMax(
  current: SubagentUsage | null,
  incoming: SubagentUsage | undefined,
): SubagentUsage | null {
  if (!incoming) {
    return current;
  }
  if (!current) {
    return incoming;
  }
  const pick = (a: number | undefined, b: number | undefined): number | undefined =>
    a === undefined ? b : b === undefined ? a : Math.max(a, b);
  const merged: {
    totalTokens: number;
    inputTokens?: number;
    cachedInputTokens?: number;
    outputTokens?: number;
    reasoningOutputTokens?: number;
    toolUses?: number;
    durationMs?: number;
  } = { totalTokens: Math.max(current.totalTokens, incoming.totalTokens) };
  const inputTokens = pick(current.inputTokens, incoming.inputTokens);
  if (inputTokens !== undefined) merged.inputTokens = inputTokens;
  const cachedInputTokens = pick(current.cachedInputTokens, incoming.cachedInputTokens);
  if (cachedInputTokens !== undefined) merged.cachedInputTokens = cachedInputTokens;
  const outputTokens = pick(current.outputTokens, incoming.outputTokens);
  if (outputTokens !== undefined) merged.outputTokens = outputTokens;
  const reasoningOutputTokens = pick(current.reasoningOutputTokens, incoming.reasoningOutputTokens);
  if (reasoningOutputTokens !== undefined) merged.reasoningOutputTokens = reasoningOutputTokens;
  const toolUses = pick(current.toolUses, incoming.toolUses);
  if (toolUses !== undefined) merged.toolUses = toolUses;
  const durationMs = pick(current.durationMs, incoming.durationMs);
  if (durationMs !== undefined) merged.durationMs = durationMs;
  return merged;
Read this as: The client accepts partial typed usage and field-wise max-merges retained values. This is an idempotent presentation fold, not cost accounting.

For Codex and Claude task data, the normalizer can roll up per-task totalTokens, input/cache/output/reasoning fields, toolUses, and durationMs when typed usage supplies them. These are task-local, provider-observed rollups. They are not invoice records. Inference from the separate ingestion paths: they do not feed either Chapter 20 lane; neither the live thread-context meter nor the transcript-scan historical usage total consumes this task rollup.

Live liveness and plan progress are intentionally not durable

ThreadBackgroundLivenessService is an in-memory registry for sidebar status labels. Agent work becomes working; monitor work becomes monitoring only when no agent work is live. Inert plan-mode bookkeeping is excluded. The registry is empty after restart until fresh task events arrive, and a dead session clears it.

Plan progress follows the same pattern. It stores a current in-progress or next pending step with completed/total counts in memory, clears when every step is complete, and clears when the turn settles or the session dies. It is a live annotation for working indicators, not a durable plan-execution ledger.

A durable thread can therefore remain visible after its liveness pill and plan-progress label disappear. That is not loss of the thread record; it avoids showing stale runtime work as live.

Quiet views select from the same evidence

Web chat keeps the parent narrative quiet: agent lifecycle rows collapse into a spawn-batch call to action that opens Agents. Direct agents group by spawn turn; workflow members group under their coordinator. The detailed per-agent state lives in the derived Agents roster rather than one parent-log row per task. Mobile has no matching Agents roster on this path, so it retains compact terminal/task signals per task identity in the work log. Both are presentations of thread activity, not separate stores.

Skills are provider inventory, not orchestrated tasks

Each provider-instance snapshot carries default-empty slashCommands and skills arrays. Their contents remain driver-specific: Codex asks app-server for its native skill inventory and adds T3’s feedback command; Claude combines discovered skills with commands reported during SDK initialization; OpenCode flattens its inventory; the inspected Cursor and Grok snapshots expose models and probe state without skills or slash commands.

The composer then normalizes presentation, not execution, with surface-specific menu policy. On web, enabled skills join / only when showSkillsInSlashMenu is enabled, and a visible skill name suppresses the same-named provider slash command. Mobile independently includes enabled skills alongside matching provider commands and does not apply that web collision helper. Both $ paths search enabled skills; choosing one inserts $skill provider-facing prompt syntax. That insertion does not create a durable task, plan step, dependency, or scheduler assignment by itself.

Interactive work-log projector

One thread, three surface projections

Switch views to see what a surface may derive from durable thread activity without inventing a scheduler record.

Web · chat work log. Illustrative projection, not live data: a quiet parent narrative collapses agent lifecycle into a spawn-batch call to action.

Web · chat work logthread: durable

Illustrative projection, not live data: a quiet parent narrative collapses agent lifecycle into a spawn-batch call to action.

  1. Parent
    thread.message-sentdurable thread narrative
  2. Spawn batch
    direct agentsgrouped by spawn turn
  3. Spawn batch
    workflow membersgrouped under the coordinator; CTA opens Agents
Durable threadmessages · activities · lifecycle overlays
Derived rostertask/subagent fold when evidence exists
Ephemeral live stateliveness clears on restart/session death; plan progress clears at settlement/session death
Static projection rules
  • Web chat groups agent lifecycle into spawn-batch CTAs that open Agents: direct agents by spawn turn, workflow members by coordinator.
  • Web Agents renders a derived roster, including task-local typed usage when supplied.
  • Mobile retains compact terminal/task signals because this path has no equivalent Agents roster.
  • Pin, snooze, and lifecycle organize the thread; they do not schedule provider tasks.

What the model does and does not promise

State or view Durable? Source of truth Appropriate claim
Thread, messages, activities, checkpoints, lifecycle overlays yes orchestration read model/projections durable project work record
Provider task/subagent identity and status activity-backed, provider-derived normalized runtime events and client fold observed state, subject to provider delivery and retention
Web Agents roster and rollups derived client fold or future v2 projection current presentation of retained evidence
Background liveness / plan progress no in-memory server service current server observation only
Snooze / pin / settled placement yes thread lifecycle fields inbox/display organization, not execution scheduling
Composer skills / commands no task state implied provider inventory and composer UI selectable invocation affordance

A durable scheduler could add assignments, dependencies, retry rules, and reconciliation receipts. That is a future design, not an inference that the current activity fold already provides those guarantees.

T3
Source-locked editionRead against fa219001d · 23 Aug 2026
Book search

Find a concept, module, or source path

Type two or more characters.