Skip to content

refactor(core): make v2 session inputs event sourced#30785

Open
kitlangton wants to merge 11 commits into
devfrom
refactor/core-v2-event-sourced-input
Open

refactor(core): make v2 session inputs event sourced#30785
kitlangton wants to merge 11 commits into
devfrom
refactor/core-v2-event-sourced-input

Conversation

@kitlangton
Copy link
Copy Markdown
Contributor

@kitlangton kitlangton commented Jun 4, 2026

Why This Exists

V2 already separated prompt recording from model execution, but an accepted prompt lived only in session_input until it became visible. That meant pending work survived a local restart but could not be reconstructed from synchronized Session history.

This PR makes prompt admission and promotion explicit durable facts.

Vocabulary

Term Meaning
Admission OpenCode accepted one prompt and immutable delivery mode.
Pending input An admitted prompt that is not model-visible yet.
Promotion One pending prompt became visible transcript history at a safe runner boundary.
evt_* Identity of one immutable event fact.
msg_* Identity of one projected transcript resource.
Advisory wake Process-local hint that durable work may exist. It is not canonical state.

Native V2 Prompt Lifecycle

flowchart LR
  submit[Submit prompt] --> admitted[PromptAdmitted evt_*]
  admitted --> inbox[session_input msg_* pending]
  inbox --> boundary[Safe runner boundary]
  boundary --> promoted[PromptPromoted evt_*]
  promoted --> transcript[session_message msg_* visible]
  transcript --> context[Model context]
Loading

Admission and promotion are separate facts because accepted intent and model-visible history are different concepts.

sequenceDiagram
  participant Client
  participant Core
  participant Log as Session event log
  participant Inbox as session_input
  participant Transcript as session_message
  participant Runner

  Client->>Core: prompt(id: msg_user_7, delivery: steer)
  Core->>Log: append PromptAdmitted(evt_101, msg_user_7)
  Log->>Inbox: insert msg_user_7 pending
  Core-->>Client: admission receipt
  Runner->>Log: append PromptPromoted(evt_102, msg_user_7, prompt)
  Log->>Inbox: set promoted_seq
  Log->>Transcript: insert visible msg_user_7
Loading

PromptPromoted carries the prompt payload and original creation time. A live renderer can show the newly visible user message directly from the event without refetching transcript history.

Event And Schema Inventory

New Durable Events

Event Version Payload Projection
session.next.prompt.admitted 1 timestamp, sessionID, messageID, full prompt, immutable delivery Inserts pending session_input with admitted_seq
session.next.prompt.promoted 1 promotion timestamp, sessionID, messageID, full prompt, original timeCreated Sets promoted_seq and inserts visible user session_message

New Schema Definitions

  • SessionMessageID.ID: branded msg_* transcript-resource identity with sortable internal generation.
  • SessionInput.LifecycleConflict: tagged error used when admission, promotion, or another message-producing event conflicts with an existing msg_* lifecycle.

Changed Event Payload Schemas

  • AgentSwitched, ModelSwitched, Prompted, Synthetic, ShellStarted, and CompactionStarted now carry explicit messageID: msg_*.
  • StepStarted, every text and reasoning event, every tool event, and step settlement now carry explicit assistantMessageID: msg_*.
  • Existing tool and step settlement ownership changes from event identity to projected assistant-message identity.

Changed Core, Storage, And API Schemas

  • EventV2.ID now enforces evt_*; SessionMessage.ID now uses the distinct msg_* schema.
  • SessionInput.Admitted replaces inbox-local seq with event-derived admittedSeq and retains optional promotedSeq.
  • session_input makes id its primary key, replaces autoincrement seq with admitted_seq, and adds admission/promotion sequence uniqueness.
  • Event aggregate sequence and projected Session-message sequence indexes become unique.
  • SessionV2.prompt(...), HTTP, OpenAPI, and generated SDK success schemas now return SessionInput.Admitted instead of SessionMessage.User.

Identity Model

Events and transcript resources have separate identities. They are never converted by prefix swapping.

flowchart TD
  fact[evt_101 StepStarted] -->|carries| assistant[msg_assistant_9]
  text[evt_102 TextStarted] -->|assistantMessageID| assistant
  delta[evt_103 TextDelta] -->|assistantMessageID| assistant
  tool[evt_104 ToolCalled] -->|assistantMessageID| assistant
  settle[evt_105 StepEnded] -->|assistantMessageID| assistant
Loading

Every message-producing event carries an explicit msg_* resource ID:

Event family Resource field
PromptAdmitted, PromptPromoted messageID
Prompted compatibility event messageID
StepStarted assistantMessageID
Text, reasoning, tool, and step settlement events assistantMessageID
Agent switch, model switch, synthetic, shell start, compaction start messageID

PromptAdmitted carries messageID, the full prompt, immutable delivery, and the admission timestamp. PromptPromoted carries messageID, the full prompt, a promotion timestamp, and the original timeCreated; it deliberately does not repeat delivery.

Example assistant turn:

StepStarted evt_a -> assistantMessageID msg_assistant_9
TextDelta   evt_b -> assistantMessageID msg_assistant_9
ToolCalled  evt_c -> assistantMessageID msg_assistant_9
StepEnded   evt_d -> assistantMessageID msg_assistant_9

This makes assistant-turn ownership explicit across turns, delayed fragments, and provider-local call-ID reuse. ShellEnded still resolves its projected shell by callID, CompactionDelta and CompactionEnded resolve the current compaction, and Retried is durable but not projected. msg_* values are opaque sortable handles for identity and index locality; transcript chronology always follows the durable aggregate seq, never message-ID ordering. Event IDs enforce the evt_* prefix and projected Session-message IDs enforce msg_*. Admitted prompt message IDs are reserved against compatibility prompts and other transcript-row creator events, and distinct creator events cannot reuse one projected message ID.

Exact Retry Semantics

first submit:  msg_user_7 + prompt A + steer -> admit once
exact retry:   msg_user_7 + prompt A + steer -> return stored receipt
conflict:      msg_user_7 + prompt B + steer -> reject
conflict:      msg_user_7 + prompt A + queue -> reject

When a client omits the ID, Core creates a new sortable msg_*. Browser-safe SDK-side msg_* generation remains follow-up work tracked locally as a5ei0w.

Queue And Steer Semantics

flowchart TD
  safe[Safe provider-turn boundary] --> cutoff[Capture aggregate sequence cutoff]
  cutoff --> active{Active activity?}
  active -->|yes| steers[Promote eligible steers through cutoff]
  active -->|no, steers exist| steers
  active -->|no steers| queue[Promote one oldest queue item]
  queue --> queuedSteers[Promote eligible steers through same cutoff]
Loading
State Input Behavior
Active activity steer Joins at the next safe provider-turn boundary.
Active activity queue Waits for a future activity.
Idle Session with steers steer Steers promote before queued work.
Idle Session with queue only queue Exactly one oldest queued input opens the next activity.

A steer admitted after boundary capture waits for the next boundary instead of joining a turn that already opened.

Durable Post-Commit Delivery

Transactional guards and projectors run before the durable commit and may reject publication. After commit, sync handlers and listeners are observers: non-interruption defects are logged and isolated so they cannot retroactively fail the committed publisher or block later observers. Live-only event publication retains its existing fail-fast listener behavior, and interruption still propagates.

Replay

Core replay reconstructs durable state and never directly invokes Session execution, a provider, or tool side effects. By default it emits no live notifications. replay(..., { publish: true }) additionally notifies listeners and subscribers after accepting a new durable event, but does not invoke sync handlers; arbitrary listeners may perform their own effects. Exact stale replay is a no-op only when event ID, versioned type, and encoded payload match. Divergent stale replay, sequence gaps, and event-ID reuse at another aggregate position fail. Strict replay-owner claims fence conflicting owners even when they resend an exact historical event.

flowchart LR
  history[Durable Session events] --> replay[Replay into fresh target]
  replay --> pending[Rebuild pending session_input]
  replay --> visible[Rebuild promoted session_message]
  replay -. never .-> provider[Provider execution]
Loading

The fresh-target regression uses a physically separate SQLite database:

  1. Replay Session creation plus prompt admission.
  2. Assert the inbox row is pending and model context is empty.
  3. Replay promotion.
  4. Assert the same msg_* is visible in context.

Retained V1 Shadow Bridge

Ordinary sessions still execute through V1. Prompt, synthetic, shell, assistant-output, retry, and compaction mirroring remains behind experimentalEventSystem. Agent-switch and model-switch events are currently published unconditionally during V1 prompt construction, so the bridge is not wholly behind the flag.

flowchart LR
  v1[V1 runtime activity] --> canonical[V1 session / message / part rows]
  v1 --> bridge[Temporary V2 mirror]
  bridge --> v2[V2 Session events]
  v2 --> debug[Internal V2 transcript viewer]
Loading

The bridge performs V1-specific translation for prompts, files, references, synthetic text, shells, assistant steps, text, reasoning, tools, retries, and compaction. Keeping the whole bridge avoids malformed shadow transcripts with assistant output but no owning prompt.

Prompted remains an already-visible compatibility event. Projecting it atomically creates the visible user message and a promoted session_input record so exact retries can reconcile it; it does not emit the native admitted/promoted lifecycle pair.

Compaction-summary assistants are represented by CompactionStarted and CompactionEnded. They intentionally do not also mirror normal assistant step/text/reasoning events.

Debug V2 TUI

For native V2 lifecycle prompts, the internal debug viewer renders promoted history only. It also continues rendering already-visible Prompted compatibility events and live assistant events:

  • Admission does not render a user message.
  • Promotion renders the visible user row directly from PromptPromoted.
  • Recent duplicate mirrored events are deduplicated by event ID using a bounded 1,000-event cache.
  • Snapshot hydration preserves live events received while the request is in flight without replacing authoritative snapshot order or untouched fields.
  • Ordinary V1 TUI behavior is unchanged.

Disposable Beta Reset

This is an unreleased V2 beta cutover. The migration clears incompatible synchronized beta state instead of adding compatibility machinery.

flowchart TD
  migration[Apply beta reset] --> clear[Clear event history and V2 projections]
  clear --> unlink[Clear Session workspace links]
  unlink --> drop[Delete disposable workspace rows]
  drop --> preserve[Preserve canonical V1 rows]
Loading
Cleared Preserved
event session
event_sequence message
session_input part
session_message
workspace
session.workspace_id links

The migration also replaces the autoincrement inbox sequence with admitted_seq, makes session_input.id the primary key, and installs unique constraints for event aggregate sequence, projected-message Session sequence, inbox admission sequence, and inbox promotion sequence.

The SQL migration deletes local workspace rows and Session workspace links only. This PR does not delete adapter-managed external workspace resources; rollout must discard those resources before startup so adapters do not rediscover stale remote history.

Surface Area

  • sessions.prompt(...) returns the full SessionInput.Admitted shape: admittedSeq, id, sessionID, full prompt, immutable delivery, timeCreated, and optional promotedSeq.
  • sessions.messages(...) returns promoted transcript rows only.
  • Pending inbox rows remain internal.
  • No public pending-input listing endpoint is introduced.
  • Event-ID prefix constraints and the new messageID / assistantMessageID payload fields flow through OpenAPI and the generated SDK event schemas.
  • No public HTTP/SDK replay endpoint is introduced; replay remains an embedded Core surface.
  • No workspace epochs, upcasters, or mixed-version synchronization protocol are introduced.

Validation

  • Final follow-up commit a8e885050 is pushed and CI is rerunning against the complete PR.
  • Final local focused Core lifecycle, replay, runner, and migration suites: 152 pass.
  • Final local focused OpenCode debug-TUI, HTTP, and message-updater suites: 24 pass, 4 skip.
  • Final Core, Server, and SDK typechecks: passed.
  • Final Prettier and whitespace checks: passed.
  • Migration drift and isolated real-TTY smoke were earlier results and were not revalidated after the local follow-ups.

packages/opencode typecheck is not usable from the auxiliary worktree because plugin types resolve from both the main checkout and the temporary worktree, producing duplicate protected SDK client identities in src/plugin/index.ts. CI provides the clean-environment check.

This supersedes #30759.

@kitlangton kitlangton force-pushed the refactor/core-v2-event-sourced-input branch from 2030cf1 to e35abee Compare June 4, 2026 20:25
@kitlangton kitlangton marked this pull request as ready for review June 4, 2026 21:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant