#242
Sync server truth (3/3): the client applies only what the server says
Part 3 of 3 of the sync "server truth" phase (F3). Merge it together with parts 1 and 2, and ship them with one server release. All three share protocol v17, and the web app deploys from main, so merging this alone would break the web app against an older server.
What changes for the user
- A chat that was open across a server restart works straight away. Before, the first message after a restart was refused ("Thread not found.") until the chat was reopened. Now the server loads the chat for any command that needs it. The browser also reopens the chat when it notices the server restarted, so the agent starts before the user types.
- A chat cut off while its agent was starting opens again. Before, a crash at that moment left a listed chat that no device could ever open. Now the next open starts the agent's session. If the crash also cut off the first message, that turn shows as interrupted and the user sends it again.
- A sub-agent chat whose history the agent has lost says so. It shows "This chat can't be reopened" and offers deletion instead of a "Try again" that would always fail. Nothing new is created in its place.
- Opening a chat finishes when the chat is ready. Right after a reload, the user's own open of a chat could finish before the chat had loaded, and a message sent then reached the screen late. Now the open finishes only when the chat is loaded and live.
- What you click shows at once, and nothing the server didn't say sticks. Renames, settling, acknowledging, deletes, answers to questions and composer picks still show immediately. They are now kept beside the server's state, not written into it. The screen falls back to what the server said if a command fails, and lets go once the server confirms.
- A write that can never succeed no longer blocks every write behind it. If the server ever produces an event its own database refuses, it is dropped and logged as an error, and later writes go through.
How
The design is in docs/sync-foundations.md (§3.2, §3.4, §4.7.3–§4.7.5, §4.7.10).
Mutation answers are acknowledgements.
- The client keeps the user's unconfirmed actions in an overlay (
state.pending: creates, renames, answers, settles, acks, removals, preferences; a send's entry is the thread's outbox). The store composes it over server state for readers, and returns the same object while nothing is pending. - An entry goes when server state shows what it asked for (pruned after every update) or its command fails. The newest request owns its entry. A settle also waits for its own answer, so an earlier click's echo cannot move the row.
- Read answers (lists, open, history) stay applied as facts. Composer preferences, from events and reads alike, are ordered by the server's revision.
- The
turn.sendanswer no longer writes the turn:turn.startedsettles the echo by the turn's command id, and so does a snapshot or history page after a gap.confirmTurnStart,applyTurnStarted, the settle tracker and the composer tickets are gone.
S17: one loader.
session.open,turn.sendand the composer setters load a session the rows hold, with an open's checks in its order and the caller's context.turn.interrupton a thread no process runs answers "not in progress".- A record dropped while its rows survive ends its turn through the terminal path.
- The handshake answer carries a per-process
instanceId; a client reconnecting to a new process reopens its active chat.
S18: one stamping rule.
- The loader stamps the identity the runtime reports when it created a session or reports a different id, before any prompt. A respawn inside a prompt reports through
session_created, which stamps before that prompt is sent. - Sub-agent sessions are loaded with
load: 'require'. The ACP and Claude runtimes then fail withSessionUnknownErrorinstead of creating, and the server answerssession_unrecoverable(retry policynever).
S19: the newest open wins.
- An open resolves only when its chat is hydrated and subscribed; a thread that fails to load rejects it.
- Overtaken by a newer open of the same session, it follows that open's outcome. Overtaken by another session or a selection, it rejects with the client-only
superseded, which callers ignore. A close rejects it with the transport's error.
Refusals cannot block the queue.
- Projection and repository invariants throw
ProjectionRefusal. With schema errors and SQLite constraint failures, they count as refusals; disk, lock and unknown errors stay retryable. - The batcher writes a refused batch one event at a time, drops only the refused events (logged at error level), and lets later writes through.
- Titles are now decided against the committed row, not memory, so a rename whose write landed late cannot let an agent's title through.
Convergence harness
- Flipped to guards:
- S17: right after a restart, the send goes out before any reopen and is served.
- S18, with a first-message variant.
- S19: the overtaken open waits after its own answer and resolves with the chat ready; a turn sent then arrives live.
- New: W7's root half joins the crash windows (18 tests); killed just after
session.created, the session opens and gets its identity. - Removed: the seeded runs' S17 reopen workaround and S18 orphan bookkeeping. The fingerprint list is empty, so the runs must converge exactly.
- Still known divergences, for later phases: S4, S5, S6(a), S6(b), S9 and S10, plus the nightly S7, S14 and S15.
Runs
- Convergence fast set, once: 63 passed, 7 nightly-only skipped, 1 todo.
- Server: 925 of 927 in the full run. lifecycle and workspace-icons failed only under full-suite load and pass alone.
- Environment-client 275, app-core 484, web 355 (Node 24).
- Desktop: typecheck clean, 666 tests.
- Agent runtime session tests 150, protocol 269.
- Typecheck across all packages, and lint on the changed code.
What reviewers should look at
packages/environment-client/src/overlay.tsandstore.ts: what each overlay entry shows and when it goes, and the store returning the composed view (getServerStateis the raw state the web cache keeps).packages/environment-client/src/websocket.ts: every command now writes only the overlay, andopenSessionAtwith its overtaking rules.apps/server/src/thread-service.ts:loadSession,stampIdentity, thesession_createdstamp, and the childload: 'require'route.apps/server/src/db/event-batcher.tsanddb/write-errors.ts: which failures count as refusals, and how a refused batch is isolated.packages/agent-runtime: theload: 'require'policy in the ACP and Claude runtimes.
Known limits
- Two-step create. A crash between a session's announcement and its first turn's write still loses that first message (it is a durability gap, not a sync one). It needs its own follow-up.
- "Can't be reopened" is learned per process. That a sub-agent's session can never be opened is learned when its load fails and kept in memory. After a restart, the first open tries once more before answering it.
- Desktop keeps its own legacy rules. The
applySessionCreatedandapplySessionTitlereducers remain exported for desktop's Convex adapter only.
🤖 Generated with Claude Code
🚀 Deploying Preview to Cloudflare 🚀
Preview Deployments by commit
View logs ↗
View logs ↗