Skip to content

Keep older apps updating when a newer node sends unknown events - #768

Merged
sak0a merged 2 commits into
mainfrom
fix/forward-compatible-wire-unions
Oct 23, 2026
Merged

sak0a merged 2 commits into
mainfrom
fix/forward-compatible-wire-unions

Conversation

@sak0a

@sak0a sak0a commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

What Changed

  • New contract helpers in baseSchemas.ts: ForwardCompatibleUnion, UnknownUnionMember and isUnknownUnionMember.
    • If a member's tag is unknown, it decodes to an UnknownUnionMember placeholder (tag, value).
    • If a tag is known but its payload is broken, decoding still fails.
    • A placeholder cannot be encoded.
    • The placeholder can keep fields that every member has. An unknown orchestration event keeps sequence, aggregateKind and aggregateId. An unknown shell item keeps its optional sequence. Resume cursors can then move past it instead of replaying it.
    • The known members stay one nested union that dispatches on the tag, so a known member still decodes once on Effect's single-candidate fast path.
  • Only what a node sends a client is forward-compatible:
    • thread and thread-window stream items, and their events (OrchestrationWireEvent)
    • shell stream items (OrchestrationShellStreamWireItem)
    • replayEvents and replayEventsPage results
    • server config stream events and terminal events
  • These stay strict: the event store, the decider, client commands, and the server-built OrchestrationEvent, OrchestrationShellStreamItem, ServerConfigStreamEvent and TerminalEvent.
  • How the client runtime skips placeholders:
    • Thread and thread-window subscriptions move resumeFromSequence past an unknown event without applying it.
    • An unknown shell event's sequence goes to a new skipShellEvent supervisor step. The step is queued in the shell batcher in order with the events around it. Web and mobile both wire it.
    • Unknown config and terminal events are dropped before they reach the stores.
    • The shell snapshot assembler passes placeholders through.
  • Every skip goes through one helper, skipNewerNodeContent. It sets a per-environment environmentNodeNewerAtom and logs one warning per environment. No UI reads the flag yet (see Follow-ups).
  • The ryco remote CLI skips placeholders too.
  • New tests:
    • A contract test covers every forward-compatible union.
    • A guard test requires every stream RPC to be listed on purpose, either as forward-compatible or as strict.
    • Server tests assert that the server never sends a member its own contract does not know.

Why

Clients decoded every server-to-client stream with closed unions. When a newer node sent an event type this build did not know (thread.trashed, worktree.relocated and the like), decoding failed with a SchemaError. wsTransport does not retry a SchemaError, so the subscription ended. The thread, shell, config or terminal view then stopped updating until the app was reloaded. Older App Store, desktop and CLI builds that connect to updated nodes hit this.

This follows T3 Code ca3f9738c3 (pingdotgg/t3code#15951), with two differences:

  • The placeholder keeps the event's sequence, so Ryco's resume cursors can move past it.
  • The known members stay a nested union. In a micro-benchmark over 5,000 thread stream items, T3's flat layout cost about 35% on small events. The nested layout cost about 8% on small events and was within noise on realistic thread.message-sent events.

Testing

After rebasing onto main and applying the review fixes:

  • bun run check: format and lint pass with 0 errors, and typecheck:fast passes in 12 of 12 packages.
  • bun run --cwd packages/contracts test -- src/forwardCompatibleWire.test.ts src/baseSchemas.test.ts: 40 passed. I also removed one stream from the strict list once to check that the new guard fails.
  • bun run --cwd packages/client-runtime test -- src/connection/connection.test.ts src/connection/supervision.test.ts src/rpc/shellSnapshotAssembly.test.ts src/rpc/wsTransport.test.ts: 134 passed.
  • RYCO_TEST_MAX_WORKERS=4 bun run check:changed: exit 0. Format and lint passed, the TS7 + Effect typecheck passed in 12 of 12 packages, the related tests passed in 10 of 10 packages, and every read-from-disk suite passed.

Before the review fixes, on the previous main:

  • bun run --cwd packages/contracts test: 582 passed.
  • bun run --cwd packages/client-runtime test: 3349 passed.
  • bun run --cwd apps/mobile test: 2089 passed.
  • bun run --cwd apps/server test -- src/server.test.ts: 141 passed.
  • bun run --cwd apps/server test -- src/test/WsTestClient.test.ts src/hubConnector/HostedRelaySessionIntegration.test.ts src/ws/context src/cliRemote: 126 passed.

Follow-ups

  • "Update this app" hint. Show an "update this app to see everything" hint for each environment, driven by environmentNodeNewerAtom, in the web desktop tier and in mobile. Where it goes is still open: the ChatView version-mismatch banner, Connections settings, or Diagnostics. ChatView is also being changed by a parallel batch.
  • Streams that are still strict. These streams still use closed unions: lifecycle, auth access, VCS status, git action progress, device events, local task changes, node update, worktree setup, and agent-control proposals. The new guard test lists them. Each one can move to ForwardCompatibleUnion the same way.
  • Closed literals inside known members. Closed literal fields inside known members still fail a known event when a newer node adds a value. Examples are provider state, session status and activity tone. Field-level helpers like T3's ForwardCompatibleOptional and ForwardCompatibleArray would be the next step.
  • Effects of skipped events. A skipped event moves the resume cursor. Any effect it has on fields this build knows stays missing until a fresh snapshot, which happens on a new connection or a reload. The alternative is to drop the resume baseline after a skip, so that the next resubscribe takes a full snapshot.
  • CLI notice. The ryco remote CLI skips unknown members silently. It could print a one-line notice that the remote is newer.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L labels Oct 22, 2026
sak0a and others added 2 commits October 23, 2026 17:25
A client decoded every server-to-client stream with closed unions. When a
newer node sent an event type, stream item kind or update kind this build
did not know (thread.trashed, worktree.relocated and the like), decoding
failed with a SchemaError. wsTransport does not retry a SchemaError, so the
subscription ended and the thread, shell, config or terminal view stopped
updating until the app reloaded. Older App Store, desktop and CLI builds
connected to updated nodes hit this.

Contracts gain ForwardCompatibleUnion and UnknownUnionMember (after T3
Code ca3f9738c3, pingdotgg/t3code#15951). A member whose tag is unknown
decodes to an UnknownUnionMember placeholder; a known tag whose payload is
broken still fails; a placeholder cannot be encoded. Unlike T3, the
placeholder can keep fields every member has: an unknown orchestration
event keeps its sequence, aggregateKind and aggregateId, and an unknown
shell item its optional sequence, so resume cursors move past it instead
of replaying it. The known members stay one nested union that dispatches
on the tag, so a known member decodes once on Effect's single-candidate
fast path (a flat union cost about 35% on small events; nested is within
a few percent of the strict union).

Only what a node sends a client is forward-compatible: thread and thread
window stream items with their events (OrchestrationWireEvent), shell
stream items (OrchestrationShellStreamWireItem), replay results, server
config stream events and terminal events. The event store, the decider,
client commands and the server-built OrchestrationEvent,
OrchestrationShellStreamItem, ServerConfigStreamEvent and TerminalEvent
stay strict.

The client runtime skips placeholders: thread subscriptions advance
resumeFromSequence past unknown events without applying them, the shell
connection hands an unknown shell event's sequence to a new
skipShellEvent supervisor step (queued in order with the events around
it), the shell snapshot assembler passes placeholders through, and config
and terminal events are dropped before they reach the stores. Each skip
records a per-environment environmentNodeNewerAtom flag (one warning per
environment) for an "update this app" hint. The ryco remote CLI skips
them too. Server integration tests assert the server never sends a member
its own contract does not know.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
- Say plainly that no UI reads environmentNodeNewerAtom yet. The "update
  this app" hint that will read it is a follow-up.
- Route every client skip through one helper, skipNewerNodeContent, so the
  four copies of "is it unknown, then record it" are gone.
  recordNewerNodeContent is now private.
- Add a guard test: every stream RPC in the hosted group must be listed on
  purpose, either as forward-compatible or as strict. A new stream can no
  longer slip in with a closed union unnoticed.
- Note on the shell wire item that `sequence` always means a shell event
  sequence. A new non-event kind must name its own field, or older clients
  would move their cursor to it.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
@sak0a
sak0a force-pushed the fix/forward-compatible-wire-unions branch from 5ef4885 to ab72005 Compare October 23, 2026 15:25
@sak0a
sak0a merged commit db1ad6a into main Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 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