Skip to content

feat(server): durable log reads, changes feed, and watermarked snapshots - #34962

Merged
kitlangton merged 8 commits into
v2from
event-api-contracts
Jul 2, 2026
Merged

kitlangton merged 8 commits into
v2from
event-api-contracts

Conversation

@kitlangton

@kitlangton kitlangton commented Jul 2, 2026 •

Copy link
Copy Markdown
Contributor

Reshapes the V2 event surface into three explicit contracts, replacing the "one stream, three jobs" shape. The old durable-read endpoints are deleted outright: session.history and session.events are subsumed by the log read, and V2 is experimental with one known external consumer (being migrated next).

RFC: anomalyco/experiments — agents/docs/rfc-opencode-event-api-and-storage-seam.md (§3 is normative for the contracts here).

Why

Two production consumers were audited against the current API:

  • The downstream opencode-agent hand-rolled changes+read+checkpoint on top of the global stream (global subscribe → discard payload → history re-fetch), paying for overflow-induced fleet replays.
  • The V2 TUI has demonstrable races: bootstrap snapshot stomps, a clear-then-fetch window in message.refresh that drops concurrent events, and overflow disconnects that leave open transcripts silently stale. The web app built hundreds of lines of bespoke fetch-vs-event reconciliation.

All of these reduce to the same two gaps: durable data riding a lossy channel, and snapshots that share no coordinate system with the stream.

The three contracts

1. The Log — GET /api/session/:id/log?after&follow

Durable, ordered, gap-free read of public session events after an exclusive after cursor — the only event API allowed to promise reliability.

  • follow=false replays to the end of the log and completes (replaces session.history); follow=true transitions to live (replaces session.events). Both old endpoints are deleted in this PR.
  • Both modes emit a log.caught_up marker at the replay boundary carrying the aggregate watermark, so even a bare read yields an attach point. The marker reports the true log position even when trailing events were filtered.
  • Core EventV2.log subscribes the aggregate wake before the historical read, so events committed during replay either appear in the replay or arrive after the marker (RFC §6 Q2). Reads skip event types missing from the durable manifest instead of failing, preserving history's filtered-gap tolerance; raw tail seqs keep cursors advancing across the gaps.
  • Pull-based end to end (sliding(1) doorbell + re-read from cursor): no queue to overflow (RFC §6 Q4).

2. The Changes feed — GET /api/event/changes

Payload-free hint channel: { type: "log.hint", aggregateID, seq } | { type: "log.sweep_required" }.

  • Coalesces to the latest seq per aggregate under backpressure — a slow consumer sees fewer, fresher doorbells, never a failed stream. The SubscriberOverflowError failure mode does not exist on this channel by design.
  • Coalescing is bounded by key cardinality (default 4096, LayerOptions.changesKeyCapacity); exceeding it abandons the buffer and emits sweep_required — the honest escape hatch (Kubernetes 410 Gone lesson).
  • sweep_required is also emitted first on every (re)subscribe, making the "hints missed while disconnected were never buffered" contract self-enforcing.
  • No consumer may derive correctness from receiving a hint. Correctness always comes from log reads plus the consumer's own checkpoint.

3. Watermarked snapshots

  • session.list and session.active responses gain watermarks: Record<Session.ID, seq>; session.messages gains a response-level watermark.
  • Kubernetes list-watch resourceVersion protocol: UIs do fetch → log(after: watermark, follow: true) with no race window, and guard snapshot application with watermark >= appliedSeq.
  • Consistency direction is load-bearing: an overstated watermark lets an attached tail skip events missing from the snapshot (a gap), while an understated one only redelivers already-reflected full-value events (safe). So session.list pairs rows and seqs in one transaction (exact), session.messages reads the watermark before the snapshot (understate-safe), and session.active is documented advisory since activity is process state, not log-derived.

Branded coordinates

Event.Seq (log position) and Event.Version (envelope schema version) are branded ints across the durable envelope, markers, hints, watermarks, and after cursors. after only accepts values that came from the log/watermarks — and seq can't be confused with the message-row seq space, nor transposed with version in envelope literals. Authoring stays plain (durable: { version: 1 }); the brand applies at the envelope boundary. Core service inputs accept plain number; discipline is enforced at the protocol layer, and the effect client inherits the brands.

Deleted

  • GET /api/session/:id/history and GET /api/session/:id/event, their handlers, SessionV2.history/SessionV2.events, EventV2.durable, and EventV2.readAggregate. All their test coverage migrated to log-based equivalents (filtered gaps, exclusive cursors, replay/live handoff, wake coalescing).
  • event.subscribe survives unchanged as the volatile live channel, now documented as such.

Ephemeral inventory (RFC §6 Q5/Q6)

Already resolved in the codebase: all *.delta events, ExecutionSettled, and server.connected are live-only by definition (session-event.ts). No durable consumer replays deltas.

Out of scope (deliberate)

  • The EventStore storage seam / Postgres driver (RFC §4) — separate PR after this surface settles.
  • TUI/app/agent consumer migrations to the new read patterns (the TUI got a one-line envelope fix for session.active; the downstream agent migrates to changes + log in a follow-up).

Notes

  • First commit fixes the promise.test.ts API-group pin that was already failing on v2 — separable.
  • Known pre-existing failure left alone: schema/test/event-manifest.test.ts public-surface count drift (63 → 66 on base).
  • Known pre-existing flake left alone: 6 filesystem/watcher timeouts under full-suite load (identical on base; pass in isolation).
  • Generated client/sdk surfaces regenerated via bun run generate; the generator stopped unwrapping .data for session.active now that the response carries watermarks. The legacy packages/sdk OpenAPI artifact regenerates through V1 tooling and was not touched, matching upstream practice.

Verification

  • Workspace typecheck: 29/29 packages.
  • Contract tests: core/test/event.test.ts (caught-up boundary both modes, empty-log marker, coalescing keeps max seq per key, sweep on retention exhaustion, sweep-first on subscribe, sequences, replay/live handoff and wake coalescing through log), core/test/session-log.test.ts (public filtering vs aggregate watermark, live follow, NotFound, undecodable gaps, migrated empty sessions, list watermark transactional pairing).
  • Full core suite matches base exactly. Client 19/19, TUI data tests 18/18, sdk-next 5/5, protocol 2/2.

@kitlangton
kitlangton force-pushed the event-api-contracts branch from 74b401f to d7d0b6b Compare July 2, 2026 19:07
@kitlangton
kitlangton merged commit bc2e270 into v2 Jul 2, 2026
4 of 6 checks passed
@kitlangton
kitlangton deleted the event-api-contracts branch July 2, 2026 20:42
jinhuang712 pushed a commit to jinhuang712/opencode that referenced this pull request Sep 26, 2026
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