#242

Sync server truth (3/3): the client applies only what the server says

Opencalledforthwants to merge intofrom

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.send answer no longer writes the turn: turn.started settles 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.send and the composer setters load a session the rows hold, with an open's checks in its order and the caller's context.
  • turn.interrupt on 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 with SessionUnknownError instead of creating, and the server answers session_unrecoverable (retry policy never).

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.ts and store.ts: what each overlay entry shows and when it goes, and the store returning the composed view (getServerState is the raw state the web cache keeps).
  • packages/environment-client/src/websocket.ts: every command now writes only the overlay, and openSessionAt with its overtaking rules.
  • apps/server/src/thread-service.ts: loadSession, stampIdentity, the session_created stamp, and the child load: 'require' route.
  • apps/server/src/db/event-batcher.ts and db/write-errors.ts: which failures count as refusals, and how a refused batch is isolated.
  • packages/agent-runtime: the load: '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 applySessionCreated and applySessionTitle reducers remain exported for desktop's Convex adapter only.

🤖 Generated with Claude Code

Activity

10
cloudflare-workers-and-pages[bot]7d ago

🚀 Deploying Preview to Cloudflare 🚀

Preview Deployments by commit

StatusDeployment URLCommitUpdated (UTC)See this deployment's details
  • Build: Failed ❌

View logs ↗
feeaf362026-10-23T11:19:43.187ZView logs ↗
  • Build: Failed ❌

View logs ↗
854a5b82026-10-23T11:05:35.122ZView logs ↗
calledforth7d ago