fix(desktop): serve the last connection snapshot while a refresh is in flight (#4611) - #4616
Conversation
9c87d06 to
cc7f787
Compare
hqhq1025
left a comment
There was a problem hiding this comment.
Found one correctness issue in the default-Host transition path. The required test check is also currently failing because the AppShell architecture ratchet reports nonTriviaTokens growing from 15725 to 15784.
Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.
…n flight (apache#4611) Every connection_list_changed event (and mount / session switch) put the projection into 'refreshing', which emptied the exposed snapshot for the whole IPC round-trip. The composer read connections.length === 0 and flashed "还没有可用的模型连接,无法发送" while disabling Send — and a failed refresh left the banner on permanently. On remote Hosts the window is seconds, not milliseconds. Stale-while-revalidate: a refresh keeps serving the last ready snapshot until the new read lands; only 'unrequested' (nothing ever read) still reads empty. This deliberately reverses the previous invalidate-on-refresh contract: the stale window is one IPC round-trip and anything the stale catalog still names is re-validated by the Host on use (NO_REAL_CONNECTION path), while the empty window produced a user-facing dead-end on every refresh. The previous snapshot is also gated by Host identity: the 'default' target uses one constant key while the Host behind it can change (profile switch), so its refreshing write now waits for the resolved identity inside runOnDefaultRuntimeHost and drops a snapshot the previous default Host produced instead of serving it into the new one's read window (review on apache#4616). Session/new-task targets key by hostId already, so their carry is always same-Host. Known residual, deliberately out of scope: cold start with a restored active session (or first switch to a not-yet-read Host) can still flash the banner for one IPC round-trip, because the session projection is host-keyed and never seeded. Closing it needs a composer-side "settled" predicate in app-shell.tsx, which the renderer architecture ratchet forbids growing; candidate follow-up is an extraction that shrinks the shell enough to pay for it. Generated-by: Maka (AI agent)
cc7f787 to
1eb42bc
Compare
M4n5ter
left a comment
There was a problem hiding this comment.
Reviewed exact head 1eb42bcedc746543ec6d3d7b51d01e85b49a3afe.
The original cross-Host stale snapshot defect is closed: the default Host identity is resolved before deciding whether an existing snapshot may be carried. I found one separate P2 refresh-ordering race, detailed inline. It can temporarily clear the connection catalog and block sending, but it neither exposes another Host's catalog nor corrupts durable state, and a subsequent refresh recovers it. I am approving with that follow-up comment.
The exact-head test check is green and the current-main merge result is clean.
Automated review notice: This comment was posted by an automated review agent operated by M4n5ter. It is not an independent human review and does not replace one.
| // provenance (the startup seed) is still carried: it is the boot-time | ||
| // default catalog, and dropping it would reopen the first-paint flash. | ||
| const result = await runOnDefaultRuntimeHost(async (host) => { | ||
| markRefreshing(host.hostId); |
There was a problem hiding this comment.
P2: Fence this state write with the refresh sequence. A default-Host refresh increments the sequence before awaiting getDefaultHost(), but this callback writes refreshing after that await without checking whether the request is still current. I reproduced the following ordering on this exact head: refresh A waits while resolving its Host; refresh B resolves Host B, reads its snapshot, and publishes ready(connection-b2); then A resolves Host A and this call replaces the newer projection with refreshing and no snapshot. A's eventual result is correctly discarded by the later sequence check, so nothing restores the projection and it remains empty until another refresh or reload. Guard markRefreshing with the same sequence (inside the state updater as well), and add a delayed-default-resolution regression.
Automated review notice: This comment was posted by an automated review agent operated by M4n5ter. It is not an independent human review and does not replace one.
Summary
With a healthy model connection configured, the composer intermittently showed 「还没有可用的模型连接,无法发送。前往模型设置」 and disabled Send, then recovered on its own (issue screenshot). On remote Hosts the window is seconds, not milliseconds.
Root cause:
useShellConnectionsemptied its exposed snapshot on every refresh —refreshConnections()wrote{status:'refreshing'}(no snapshot), and the getter servedEMPTY_SNAPSHOTuntil the IPC read landed.app-shell.tsxpassesnoModelConnection={connections.length === 0}, so everyconnection_list_changedevent, mount, and session switch produced a "no model" window that blocked send. Worse, a failed refresh stayedrefreshingforever — the banner never cleared.Changes, all inside
use-shell-connections.ts:unrequested(nothing ever read) still reads empty.seedSnapshot(found in adversarial review): the mount layout effect always wrote the in-flight entry before the shell's passive seed effect ran, so thehas(key)guard made every seed a no-op and theinitialOnboardingSnapshotfallback inapp-shell.tsxunreachable. The guard is relaxed to "no snapshot yet", so the startup snapshot fills the first-paint and failed-first-refresh windows; a successful read still overwrites it.defaulttarget uses one constant key while the Host behind it can change (profile switch), so its refreshing write now happens insiderunOnDefaultRuntimeHostwith the resolved identity in hand, and a snapshot produced by the previous default Host is dropped instead of served into the new one's read window. Session/new-task targets key by hostId already, so their carry is always same-Host.This deliberately reverses the previously test-encoded invalidate-on-refresh contract ("stop exposing previous mutation targets immediately"). Trade-off, stated honestly: a just-deleted connection can remain selectable for one IPC round-trip on the same Host. Every use of a connection is re-validated by the Host (
NO_REAL_CONNECTIONpath with a targeted toast), while the old behavior produced a user-facing dead-end on every refresh and a permanent dead-end after a failed one.Fixes #4611
Verification
apps/desktopfull suite: 1963 tests, 0 fail — including the rewritten stale-while-revalidate contract test, three new hook tests (seed fills an in-flight first refresh; refresh resolving to a genuinely empty catalog endsready+ empty; first load reads empty until ready), and a default-Host-switch regression test that fails on the pre-fix code (verified by reverting the guard).check-renderer-architecture.mjs --base …).tsc -p apps/desktop/tsconfig.main.jsonclean;biome checkclean on changed files.Known residual (follow-up)
Cold start with a restored active session (or first switch to a not-yet-read Host) can still flash the banner for one IPC round-trip: the session projection is host-keyed and never seeded. Closing it needs a composer-side "settled" predicate in
app-shell.tsx, and the renderer architecture ratchet forbids growing that file (attempted; CI rejected the +1 import). The follow-up is an extraction that shrinks the shell enough to pay for the predicate.Review focus
Two rounds of adversarial sub-agent review plus the automated PR review were run against this diff. Found and fixed: the dead-seed root cause, the missing empty-catalog regression test, and the default-Host identity carry-over. One earlier iteration of this PR also tightened the composer banner predicate in
app-shell.tsx; it was reverted because the architecture ratchet forbids the shell growing — see Known residual.AI use
Tool(s) and scope: Maka (AI agent) authored the full change: issue triage, design, implementation, tests, and adversarial review iterations (also AI, with human-supervised fixes).
Checklist
Does this PR entail a change in behavior?