fix: prevent duplicate AI design stream reconnects

This commit is contained in:
2026-08-19 00:31:04 +08:00
parent f8d82e6c19
commit 3a6d3888e8
3 changed files with 101 additions and 1 deletions

View File

@@ -0,0 +1,86 @@
# Task: Fix AI design rapid session switching freeze
## Identity
- Task ID: 20260819-makelore-session-freeze-7c4e
- Mode: Feature
- Branch: codex/20260818-prompt-museum-client-4f7a-prompt-museum-client
- Worktree: D:\mk-4f7a
- Base commit: f8d82e6c19536bbd9bd7d70a72efbf7ec163aeb4
- Owner: codex
- Status: Ready for integration
## Scope
- Diagnose and fix the Makelore AI Design client freeze reported when a user
rapidly switches Conversations after login.
- Keep the change limited to the Renderer task-stream lifecycle and its unit
regression coverage; do not change the Works API or persistent Agent Session
contract.
## Intent And Constraints
- Preserve the existing Workspace + Conversation identity and generation
guards, including stale-stream isolation and REST reconciliation after the
Canvas effect is cleaned up.
- Do not issue a second event-stream open for the same active Workspace +
Conversation solely because reconciliation was requested while its stream
is `connecting`. An explicit Conversation selection may invalidate the old
identity and start the newly selected Conversation's stream. Only a known
`degraded` stream may be forced closed and reopened by reconciliation.
- Use a regression test that models an unresolved `openImageWorkspaceTaskEvents`
Promise, because the underlying connection attempt is not cancellable once
started.
## Outcome
- Confirmed the freeze mechanism: Canvas effect cleanup marks task-stream
reconciliation while the stream is still `connecting`; the next connect
call treated every non-`connected` state as broken, closed the logical
stream, and started another request. The old Promise could not be cancelled
and only closed its EventSource after eventually resolving, so rapid
switching accumulated concurrent connection attempts.
- Changed `connectTaskStream` to force a close/reconnect only for `degraded`
streams. An in-flight `connecting` stream is reused while Conversation and
task REST reconciliation still runs.
- Added a store regression test proving reconciliation does not issue a second
stream-open request while the first one is unresolved.
- Audited the adjacent Main SSE relay close timing; the existing `finally`
path closes a subscription after a response is already closed, and no
reliable red test proved an additional leak in this task, so no Main/API
change was made.
## Verification
- New regression before the fix: failed as expected (`openImageWorkspaceTaskEvents`
was called twice instead of once).
- `node_modules\\.bin\\vitest.cmd run tests/unit/image-workspace-store.test.ts
--reporter=dot` — passed, 1 file / 31 tests.
- `node_modules\\.bin\\vitest.cmd run tests/unit/image-canvas-page.test.tsx
tests/unit/works-square-design-workspace.test.ts --reporter=dot` — passed,
2 files / 78 tests. Existing React `act(...)` warnings were emitted by an
unrelated auth test.
- `node_modules\\.bin\\eslint.cmd src/stores/image-workspace.ts
tests/unit/image-workspace-store.test.ts` — passed.
- `node_modules\\.bin\\tsc.cmd --noEmit` — passed.
- `git diff --check` — passed.
- `check_doc_drift.py --task-id 20260819-makelore-session-freeze-7c4e` — passed.
- Independent Sol read-only final review — PASS. The review noted the
untested extreme A→B→A case where a deliberately stalled old open request
may later be replaced; that is retained as a separate follow-up rather than
part of the same-identity reconciliation race.
## Follow-ups
- Run a real logged-in Electron smoke test that alternates two or more
Conversations while the event-stream handshake is deliberately slow; local
unit tests prove the duplicate-open race but do not validate a deployed Works
gateway or production network behavior.
- If production telemetry later shows a WebSocket handshake that never
resolves, track a separate cancellation/timeout task instead of coupling it
to this Renderer race fix.
## Promotion Candidates
- None. The fix restores the existing client stream-lifecycle invariant and
does not introduce a new architectural decision or canonical contract.

View File

@@ -1026,7 +1026,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
const conversationId = get().activeConversationId;
const shouldReconcile = reconcileTaskStateOnNextConnect;
reconcileTaskStateOnNextConnect = false;
if (shouldReconcile && get().taskStreamState !== 'connected') {
if (shouldReconcile && get().taskStreamState === 'degraded') {
closeTaskEventSource();
}
if (workspaceId && conversationId) startTaskStream(workspaceId, conversationId);

View File

@@ -385,6 +385,20 @@ describe('AI design task event store', () => {
.toEqual(['继续优化海报', '已完成最新方向整理。']);
});
it('keeps the in-flight stream connection when reconciliation is requested', async () => {
const pendingSource = deferred<EventSource>();
openImageWorkspaceTaskEventsMock.mockReturnValue(pendingSource.promise);
await useImageWorkspaceStore.getState().load();
expect(useImageWorkspaceStore.getState().taskStreamState).toBe('connecting');
expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce();
useImageWorkspaceStore.getState().markTaskStreamNeedsReconciliation();
useImageWorkspaceStore.getState().connectTaskStream();
expect(openImageWorkspaceTaskEventsMock).toHaveBeenCalledOnce();
});
it('recovers a committed generation task when the Agent Run fails afterward', async () => {
const source = new MockEventSource();
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);