Skip to content

fix(desktop): serve the last connection snapshot while a refresh is in flight (#4611) - #4616

Merged
me2seeks merged 1 commit into
apache:mainfrom
me2seeks:fix/4611-composer-connection-refresh
Sep 3, 2026
Merged

fix(desktop): serve the last connection snapshot while a refresh is in flight (#4611)#4616
me2seeks merged 1 commit into
apache:mainfrom
me2seeks:fix/4611-composer-connection-refresh

Conversation

@me2seeks

@me2seeks me2seeks commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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: useShellConnections emptied its exposed snapshot on every refresh — refreshConnections() wrote {status:'refreshing'} (no snapshot), and the getter served EMPTY_SNAPSHOT until the IPC read landed. app-shell.tsx passes noModelConnection={connections.length === 0}, so every connection_list_changed event, mount, and session switch produced a "no model" window that blocked send. Worse, a failed refresh stayed refreshing forever — the banner never cleared.

Changes, all inside use-shell-connections.ts:

  1. Stale-while-revalidate: a refresh keeps serving the last ready snapshot until the new read lands; only unrequested (nothing ever read) still reads empty.
  2. Fix the dead seedSnapshot (found in adversarial review): the mount layout effect always wrote the in-flight entry before the shell's passive seed effect ran, so the has(key) guard made every seed a no-op and the initialOnboardingSnapshot fallback in app-shell.tsx unreachable. 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.
  3. Gate the carry by Host identity (PR review): the default target uses one constant key while the Host behind it can change (profile switch), so its refreshing write now happens inside runOnDefaultRuntimeHost with 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_CONNECTION path 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/desktop full 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 ends ready + 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).
  • Renderer architecture ratchet passes against the merge base (check-renderer-architecture.mjs --base …).
  • tsc -p apps/desktop/tsconfig.main.json clean; biome check clean 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

  • Generative tooling made a substantive contribution

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

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above

@github-actions github-actions Bot added the effort/M Under 500 readable lines label Sep 3, 2026
@me2seeks
me2seeks force-pushed the fix/4611-composer-connection-refresh branch 2 times, most recently from 9c87d06 to cc7f787 Compare September 3, 2026 05:58

@hqhq1025 hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread apps/desktop/src/renderer/use-shell-connections.ts Outdated
…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)
@me2seeks
me2seeks force-pushed the fix/4611-composer-connection-refresh branch from cc7f787 to 1eb42bc Compare September 3, 2026 06:56

@M4n5ter M4n5ter left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@me2seeks
me2seeks merged commit ea2457a into apache:main Sep 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/M Under 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(desktop): composer flashes "还没有可用的模型连接,无法发送" and blocks send during every connection snapshot refresh

3 participants