Skip to content

fix(clients): older clients skip event types they don't know instead of breaking - #13941

Merged
juliusmarminge merged 2 commits into
t3code/codex-turn-mappingfrom
v2/clients-skip-unknown-events
Sep 27, 2026
Merged

juliusmarminge merged 2 commits into
t3code/codex-turn-mappingfrom
v2/clients-skip-unknown-events

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Sep 27, 2026 •

Copy link
Copy Markdown
Member

Every V2 PR that adds a domain event type (for example run.background-work-cancelled in #13895) breaks older web, desktop, and mobile clients. OrchestrationV2ThreadStreamItem is a strict union, so an unknown event.type fails the whole chunk decode.

What happens today. The Effect RPC client decodes each stream chunk with Effect.orDie, so a decode failure is a defect that ends the subscribeThread stream. In client-runtime, subscribeDynamic reports it through onDefect ("Could not synchronize the thread.") and does not retry. The thread stays on its cached data until the next session or foreground resubscribe. That resubscribe starts from the same afterSequence, so the server replays the same event and it fails the same way. On an older mobile build the thread never goes live again.

What changed

  • contracts: OrchestrationV2ThreadStreamItem gets a decode-only fallback arm, placed after the known event arm. It accepts { kind: "event", sequence, event: { type } } only when type is not a known domain event type, and decodes it to { kind: "unknown-event", sequence, eventType }. A known type whose payload does not decode still fails, so real contract bugs are still caught. Encoding this arm is forbidden, and the server never builds it.
  • client-runtime: the thread reducer treats unknown-event like an event for the sequence cursor. It skips the item with a debug log and still moves lastSequence past it, so resubscribe and catch-up math stays correct, even when the unknown event is the last one in a batch.

Scope decisions

  • The server stays strict. OrchestrationV2DomainEvent, OrchestrationV2DomainEventJson and OrchestrationV2StoredEvent are unchanged. The event store and provider ingestor still reject unknown types.
  • Shell stream: left strict. Its kind values have been stable since June: project.updated and project.removed already exist, and the only later addition was the synchronized marker, which is negotiated through shellResumeCompletionMarker. Project domain events do not reach the shell wire; the server maps them to project.updated / project.removed. refactor(server): commit project commands without the V1 engine #13896 adds project event types, but it touches only apps/server and the shell item kinds stay the same. The archived shell stream has the same shape and the same reasoning applies.
  • Snapshots: thread and shell snapshots carry projections, not event arrays, so they have no open-ended event union to cover.
  • Mobile: it has no separate decoding path. apps/mobile uses createOrchestrationEnvironmentAtoms and makeEnvironmentThreadState from client-runtime over the same WsRpcGroup client, so this fix covers web, desktop, and mobile.

Clients only benefit from this once they ship with it. Builds already in users' hands still break on new event types.

Verification

  • packages/contracts: vp test run src/orchestrationV2.test.ts (28 passed). The new test decodes, through the RPC JSON codec, a known event, then a run.background-work-cancelled event, then another known event. It checks that the middle item becomes unknown-event with its sequence, and that a known type with a broken payload still throws.
  • packages/client-runtime: vp test run src/state/threads-sync.test.ts src/state/threads-atoms.test.ts src/state/shell-sync.test.ts (63 passed). The new test feeds message.updated, then an unknown event, then thread.metadata-updated, then a trailing unknown event, then synchronized. It checks that both known events applied, the thread went live with no error, and a replacement session resubscribes with afterSequence equal to the trailing unknown event's sequence.
  • I reverted the source changes and kept the new tests. The contract test failed with a SchemaError on the unknown type, and the reducer test timed out because the stream never went live.
  • apps/server: vp test run src/orchestration-v2/ThreadTransportPerformance.test.ts src/orchestration-v2/ThreadLiveEventCoalescer.test.ts (13 passed).
  • tsc --noEmit for contracts, client-runtime, web, mobile, and server: no errors.
  • vpr knip:check: clean. vp lint and vp fmt on the touched files: clean.
  • Not run: repo-wide tests or typecheck, and no manual check in a real client (no dev servers).

Model: Claude Opus 5.5 (Claude Code)

🤖 Generated with Claude Code


Devin Review

…of breaking

A thread stream event with a type the client build does not know used to
fail the chunk decode. The RPC client turns that into a defect, which ends
the thread subscription, and resubscribing replays the same event.

The thread stream item union now has a decode-only fallback arm. It takes an
`event` item whose `type` is not a known domain event type and decodes it as
`{ kind: "unknown-event", sequence, eventType }`. A known type with a payload
that does not decode still fails. The client-runtime reducer skips these
items with a debug log and still advances its resume cursor past them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Sep 27, 2026
Effect.annotateLogs({
environmentId,
threadId,
eventType: item.eventType,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eventType is an unchecked string from the wire, so logging it can expose arbitrary payload text or produce unbounded annotations. Could you log a safe diagnostic such as its length instead?

Suggested change
eventType: item.eventType,
eventTypeLength: item.eventType.length,

Posted via Macroscope — Effect Service Conventions

@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

ℹ️ No successful main baseline artifact is available yet. This run establishes the initial measurement.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire — 4.9 KiB — 6.8 KiB ✅
Codex Thread snapshot wire — 3.7 KiB — 4.9 KiB ✅
Codex Live turn WebSocket wire — 1.2 KiB — 2.0 KiB ✅
Codex Live turn WebSocket decoded — 20.4 KiB — 29.3 KiB ✅
Codex Live turn messages — 2 — 8 ✅
Claude Total thread wire — 4.9 KiB — 6.8 KiB ✅
Claude Thread snapshot wire — 3.7 KiB — 4.9 KiB ✅
Claude Live turn WebSocket wire — 1.2 KiB — 2.0 KiB ✅
Claude Live turn WebSocket decoded — 20.8 KiB — 29.3 KiB ✅
Claude Live turn messages — 2 — 8 ✅

Baseline: unavailable · PR result: c926cbb · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 106.1 KiB
  • Claude decoded thread snapshot: 106.4 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@macroscopeapp

macroscopeapp Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — The PR is a focused compatibility fix that safely skips unknown events and preserves resume sequencing, with targeted tests. Human review is warranted because the new debug logging records unchecked event-type text from the wire, which may expose sensitive content despite truncation.

You can add or adjust custom eligibility rules. Learn more.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
environmentId,
threadId,
// Bounded: the type comes from a newer server and is not validated here.
eventType: item.eventType.slice(0, 64),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Truncating eventType bounds its size but still puts unchecked wire text (potentially payload or secrets) into logs. Could you record only a safe diagnostic instead?

Suggested change
eventType: item.eventType.slice(0, 64),
eventTypeLength: item.eventType.length,

Posted via Macroscope — Effect Service Conventions

@juliusmarminge
juliusmarminge merged commit b222464 into t3code/codex-turn-mapping Sep 27, 2026
24 checks passed
@juliusmarminge
juliusmarminge deleted the v2/clients-skip-unknown-events branch September 27, 2026 21:46
SoulSniper-V2 pushed a commit to SoulSniper-V2/t3code that referenced this pull request Sep 27, 2026
…of breaking (pingdotgg#13941)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M 30-99 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant