Conversation
There was a problem hiding this comment.
Important
The mechanism is sound end-to-end — but the release gate this PR exists to enable rests on a desktop "lockstep" claim that the daemon-discovery code refutes. Worth fixing now, while the reasoning is fresh, because the next person counts release windows from that line.
Reviewed changes
- Version-agnostic handshake.
parseWireMessagenow accepts a below-floor envelope when its payload kind isping/pongand the payload validates, so a peer under the floor learns the range it must update into instead of dying in the 5 s timeout. - Typed incompatibility. New
WireIncompatibleErrorcarriesremedy: 'update-host' | 'update-app';ConnectionControllerbail()s out of the retry loop on that type rather than burning its budget. - Three update-required surfaces. Mobile's connection state, plus workbench's shared state for desktop and webview via the new
useWorkbenchRuntimeErrorhook. - Recorded floor-bump policy. A two-mobile-release advisory window in
docs/RELEASE.md,docs/ARCHITECTURE.md, and rootAGENTS.md.
I traced the things most likely to be quietly wrong here and they hold up, so I'll record them so nobody re-does the work:
- No below-floor frame escapes onto a send path.
hub.ts:125-127consumes the inboundpingand replies with a freshlycreateWireMessaged pong, returning beforeinbound.emit. The ws/socket.io servers and the tunnel onlyinbound.emitthe parsed frame, and there is no cross-peer relay inTunnelTransportServer. So theas ValidatedWireMessagecast at the new branch — which does mint an object violatingWireMessageSchema's.min(MIN_COMPATIBLE_WIRE_VERSION)— stays inert. - The typed error survives every boundary.
foxts'bail()wraps inAsyncRetryAbortErrorand the handler rethrows.causeverbatim, skippingonFailedAttempt;unwrapConnectionErroronly unwrapsExplicitRetryError. One bundle per app, soinstanceofholds across the package edge. - No stale-error false positive.
startRecoverypublisheserror: undefinedbefore flipping status toconnecting, so workbench computingmessageunconditionally can't leak update copy into a healthy reconnect. - The mobile prop plumbing works despite
host-connection.tsnot being in the diff —HostConnectionis an intersection withHostClientState, andhost-connection-scope.tsx:40spreads...state.
ℹ️ Invariant 1's "every wire change" is left unadjudicated for receiver-only changes
Holding WIRE_PROTOCOL_VERSION at 82 is defensible on the stated reasoning — no frame shape, field, or meaning changed, and no capability gate anywhere interrogates the version to decide whether a handshake will be answered. I checked; there's no probe that silently misbehaves.
The part worth a sentence somewhere is that two builds both stamping 82 now behave observably differently: a pre-PR v82 daemon leaves a below-floor client in the 5 s timeout, a post-PR v82 daemon hands it a named advisory, and nothing on the wire distinguishes them. That's not a correctness bug — the pre-PR behavior is exactly the status quo — but during a mixed-fleet floor bump it's the difference between "this daemon is broken" and "this daemon is old", and an operator can't tell which they have.
Invariant 1 says the stamp moves on every wire change. This PR is the first case where "wire change" plausibly means "receiver acceptance policy" rather than "frame shape", and the answer chosen here becomes precedent by default. Recording the call — either way — in Invariant 1 itself would keep the next person from re-deriving it. This is a judgment call for you, not something I'd assert an answer to.
ℹ️ Nitpicks
VERSION_AGNOSTIC_KINDSis a hand-writtenSet(['ping', 'pong'])rather than being derived from the keep-alive schema. Renaming either kind would silently disable the entire advisory with no type error — and the failure mode is precisely the timeout this PR removes, so it'd read as a regression in the feature rather than a rename fallout. Low likelihood (renaming those kinds is itself a breaking wire change), but the blast radius is the whole feature.docs/ARCHITECTURE.md:317still reads that an unchecked object cannot reach a send path, while the sibling comment inmessage.tsgained the below-floor caveat. The claim is still true for the reasons traced above; it's just now true for a narrower reason than the sentence implies.
Claude Opus | 𝕏
1b28063 to
af62ae1
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
The cross-layer transport, client, desktop, and mobile changes warrant final human review.
Pull request overview
This PR makes wire-version skew explicit during handshake instead of allowing incompatible peers to time out.
Changes:
- Accepts validated below-floor
ping/pongframes across transports. - Adds typed incompatibility errors and stops retries.
- Adds desktop/mobile update guidance, tests, localization, and documentation.
File summaries
| File | Description |
|---|---|
packages/presentation/i18n/src/locales/zh-cn.ts |
Adds Chinese skew messages. |
packages/presentation/i18n/src/locales/en.ts |
Adds English skew messages. |
packages/foundation/transport/tests/integration/ws-server.test.ts |
Adds WebSocket handshake coverage. |
packages/foundation/transport/tests/integration/socket-io.test.ts |
Adds Socket.IO handshake coverage. |
packages/foundation/transport/src/__tests__/tunnel.test.ts |
Adds tunnel handshake coverage. |
packages/foundation/schema/tests/contract/wire/envelope.test.ts |
Tests handshake compatibility rules. |
packages/foundation/schema/src/wire/message.ts |
Allows validated below-floor handshake frames. |
packages/client/workbench/src/runtime/provider.tsx |
Exposes connection errors. |
packages/client/workbench/src/app/connection-state.tsx |
Displays skew guidance. |
packages/client/core/tests/integration/wire-skew.test.ts |
Adds end-to-end skew coverage. |
packages/client/core/src/wire-incompatible-error.ts |
Adds the typed incompatibility error. |
packages/client/core/src/index.ts |
Exports the new error. |
packages/client/core/src/connection-controller.ts |
Stops retries for wire skew. |
packages/client/core/src/client.ts |
Detects and preserves typed errors. |
packages/client/core/src/__tests__/connection.test.ts |
Tests incompatibility directions. |
packages/client/core/src/__tests__/connection-controller.test.ts |
Tests retry behavior. |
docs/RELEASE.md |
Documents the compatibility-floor policy. |
docs/ARCHITECTURE.md |
Documents version-agnostic handshakes. |
apps/mobile/src/runtime/use-host-client.ts |
Propagates skew state. |
apps/mobile/src/components/host/host-connection-state.tsx |
Renders update-required states. |
apps/mobile/src/components/host/host-client-gate.tsx |
Passes remedy state to the UI. |
AGENTS.md |
Updates the wire-version invariant. |
Review details
- Files reviewed: 22/22 changed files
- Comments generated: 0
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
af62ae1 to
2e2ec5f
Compare
|
Review round (2026-09-11). Both pullfrog findings were real and are fixed; the branch is rebased onto the updated
Left for the owner, flagged rather than invented: what measurably bounds the desktop window (auto-update adoption has no dwell or adoption trigger the way "two store releases" does), and the N=2 mobile count itself, which is still a proposal. |
There was a problem hiding this comment.
🟡 Changes recommended
The English and Chinese update messages direct Android users to the App Store instead of providing platform-appropriate guidance.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 22/22 changed files
- Comments generated: 2
- Review effort level: Lite
There was a problem hiding this comment.
ℹ️ No critical issues — minor suggestions inline.
Both findings from the previous review are genuinely fixed, not relocated. One consequence of the mobile fix is worth a look before merge.
Reviewed changes
- Corrected the desktop exemption in
docs/RELEASE.mdanddocs/ARCHITECTURE.md. The new "Desktop is not exempt, only unbounded" bullet names the actual non-bundled paths —resolveDaemonUrl(), theruntime.jsonadvertisement, the Developer-tab override, the supervisor stand-down — and folds the webview into the same rule. RootAGENTS.mdInvariant 1 was correctly left alone: it already defers todocs/RELEASE.md, so the three-site partial-fix trap I warned about is avoided rather than half-hit. - Dropped the Retry button on the mobile
update-appscreen and suppressed the rawfailurefootnote under either named skew, so the friendly copy isn't shadowed by triage voice.
ℹ️ Nitpicks
packages/client/workbench/src/app/connection-state.tsx:46still renders Retry unconditionally, including onupdate-app. I scoped this out last round on the grounds that the workbench surfaces don't share mobile's flip-flop tension, and that reasoning still holds — flagging only because the two platforms now diverge visibly on the same state. No change needed if the divergence is intentional.
Claude Opus | 𝕏
…a skew is named instead of timing out
…sking to start the daemon
…empting desktop from the advisory
2e2ec5f to
b992236
Compare
|
Your organization has disabled Claude subscription access for Claude Code. Ask your Claude organization's admin to re-enable it in the Claude Console, or set an Add repo secret → · Model settings → · Setup docs → · Ask in Discord →
|
|
@xiaoland following up on your review. The completed-reply → “Output for this turn is unavailable” after refocus is real, but it is not caused by this PR’s wire-advisory commits. Conversation reads revalidate on focus. Settled output is reconstructed from attributed native Claude history; when that attribution fails or returns no usable partition, the projection installs The first failing projection is on stacked ancestor PR #510 ( Cause
Fixes (on
Missing direct native history now throws instead of returning an empty conversation. Duplicate stream-error banners after an already-reported provider-result error are suppressed; independent and subsequent-turn stream errors remain visible. Fork integrations that needed the same config root were restacked on their own branches:
Evidence
Limits — please do not treat these as covered
Please retry the updated |
There was a problem hiding this comment.
🔵 Needs a closer look
Two moderate, one-vote localization findings require platform-neutral or platform-specific store wording for Android users.
Review details
Suppressed comments (2)
packages/presentation/i18n/src/locales/en.ts:1434
- This copy is shared by the Expo mobile client, which also ships on Android, so “App Store” is not the correct place for every user. Use platform-neutral wording (or select the store per platform) so Android users are not sent to a nonexistent store.
'This version of the app is too old to talk to that host. Update it from the App Store and try again.',
packages/presentation/i18n/src/locales/zh-cn.ts:1389
- This copy is shared by the Expo mobile client, which also ships on Android, so “App Store” is not the correct place for every user. Use platform-neutral wording (or select the store per platform) so Android users are not sent to a nonexistent store.
updateAppBody: '此版本的应用过旧,无法连接到该 host。请从 App Store 更新后重试。',
- Files reviewed: 22/22 changed files
- Comments generated: 0 new
- Review effort level: Lite
|
Your organization has disabled Claude subscription access for Claude Code. Ask your Claude organization's admin to re-enable it in the Claude Console, or set an Add repo secret → · Model settings → · Setup docs → · Ask in Discord →
|








Summary
Phase 6 of CODE-627 — Conversation turn graph & immutable attachment store. Linear: https://linear.app/arcbox/issue/CODE-641/feattransport-tunnel-minimum-version-advisory-for-below-floor-clients
Stack: #525 ← this PR (
ruocheng/code-641, baseruocheng/code-640) ← top of the stack. Merge bottom-up; this PR's diff is only its own commits.The precondition for the floor-bump release. The client half of the advisory already existed: since wire v64 (2026-07-31) the
pongcarriesversion+minCompatibleandLinkCodeClientnames both skews from it. What never worked was the transport boundary — every server transport (socket.io, ws, tunnel peer) refused a below-floor frame silently, so an old client'spingwas never answered and its handshake died in the 5 s timeout ("daemon unavailable or wire protocol mismatch"), and a newer client dropped an older host'spongthe same way, which made the existing "update the host" branch unreachable. One rule fixes both directions on every carrier:parseWireMessageaccepts a below-floor frame only if its payload validates aspingorpong; everything else below the floor is still refused. The Hub's existing pong path then answers an old client, and the reply travels back on the same connection — so the tunnel needs no wire-version surface of its own and no relay change (the relay forwardsWireMessageframes opaquely; that assumption cannot be verified from this repo). The client turns the skew into a typedWireIncompatibleError { remedy: 'update-host' | 'update-app', peerVersion, peerMinCompatible }, the connection controller stops retrying on it (a skew cannot heal by retrying), mobile renders an update-required screen instead of "Host unavailable", and the workbench's connection state names the side that must update instead of asking to start a daemon that is running. The deprecation-window policy is recorded: the floor may move only after the update screen has shipped in at least two mobile store releases.Commits
Verification
Every commit passed
pnpm check:ciandpnpm testat its own tip; the tip (1b280635) is atpnpm check:ci0 errors,pnpm test3482 passed / 1 skipped. Tests: the schema contract (a below-floorping/pongis accepted, a bare{kind:'pong'}claim and every other kind below the floor are refused; the new acceptance test fails on the previous parser), a raw socket.io client and a raw ws client stamping v−1 sendingpingthrough aHuband receivingpong {version, minCompatible}, a below-floor peer ping delivered through the tunnel peer transport, an older host's below-floorpongreachingLinkCodeClientthrough the realWsTransportasupdate-host, both remedies from the handshake, and the controller stopping after one attempt on the typed error (and again after a deliberate retry) while still exhausting its budget on an ordinary failure. Adversarial review (isolated read-only worktree): ACCEPT-WITH-FINDINGS, no P1/P2 — it could not make a below-floor peer do anything beyond having its handshake answered; its findings (a wrong docs anchor, the workbench copy, comment and API tidy-ups, the mobile redial flicker, the ws-carrier test gap) are folded into these commits. Observed running: against the development daemon (tsx watch, hot-reloaded), a raw wire-v70 client'spinggotpong {version: 82, minCompatible: 76}back within milliseconds; the webview, pointed at a stand-in daemon answering pings with an out-of-range pong, rendered "This LinkCode build is too old to talk to the daemon at … Update the app." and, with the range inverted, "The daemon at … is too old for this LinkCode build. Update the daemon." Not rendered on a device: the mobile update screen against a skewed daemon (the hook logic is unit-covered; the SwiftUI view is a copy branch).Checklist
pnpm check:ciandpnpm testboth pass (no Rust changes)docs/ARCHITECTURE.md, rootAGENTS.mdInvariant 1,docs/RELEASE.md"Moving the compatibility floor")