From 3a6d3888e81ff1b99b4997ae19f4bb0f1bad1880 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Wed, 19 Aug 2026 00:31:04 +0800 Subject: [PATCH] fix: prevent duplicate AI design stream reconnects --- .../20260819-makelore-session-freeze-7c4e.md | 86 +++++++++++++++++++ src/stores/image-workspace.ts | 2 +- tests/unit/image-workspace-store.test.ts | 14 +++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 .project-docs/30-worklog/tasks/20260819-makelore-session-freeze-7c4e.md diff --git a/.project-docs/30-worklog/tasks/20260819-makelore-session-freeze-7c4e.md b/.project-docs/30-worklog/tasks/20260819-makelore-session-freeze-7c4e.md new file mode 100644 index 0000000..9e117ca --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260819-makelore-session-freeze-7c4e.md @@ -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. diff --git a/src/stores/image-workspace.ts b/src/stores/image-workspace.ts index 2cfdf94..e3acae7 100644 --- a/src/stores/image-workspace.ts +++ b/src/stores/image-workspace.ts @@ -1026,7 +1026,7 @@ export const useImageWorkspaceStore = create((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); diff --git a/tests/unit/image-workspace-store.test.ts b/tests/unit/image-workspace-store.test.ts index 107cd9e..123495a 100644 --- a/tests/unit/image-workspace-store.test.ts +++ b/tests/unit/image-workspace-store.test.ts @@ -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(); + 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);