From 274d0d013c2d6850d2e25e088788be4445a8177d Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Thu, 27 Aug 2026 16:17:32 +0000 Subject: [PATCH] fix(cli): swallow synchronous goal-persistence throws at the startup gate (#10311) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit waitForGoalRuntime called config.getGoalRuntimeReady() outside its try/catch when the startup gate gained a timeout (#10128). With chat recording disabled (--no-chat-recording, the integration-test default, or the equivalent setting) that call THROWS GoalPersistenceUnavailableError synchronously instead of rejecting, so the throw escaped into the AppContainer init effect; the non-fatal unhandledRejection handler swallowed it and setConfigInitialized(true) never ran. The TUI rendered the composer but never enabled input — submitted prompts sat queued under a permanent "Initializing..." spinner and every interactive E2E test that submits a prompt polled out, red-lining the post-merge E2E lanes (run 33084403356; tracker #10272). Call getGoalRuntimeReady() inside the try again so the synchronous throw is treated exactly like the rejected promise, and pin both halves with unit tests: the persistence-unavailable throw settles the gate with and without the timeout, and any other synchronous throw still escapes. --- .../cli/src/ui/utils/goal-runtime.test.ts | 30 +++++++++++++++++++ packages/cli/src/ui/utils/goal-runtime.ts | 8 +++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/utils/goal-runtime.test.ts b/packages/cli/src/ui/utils/goal-runtime.test.ts index affa44ea4ee..8f2b1bc5fcc 100644 --- a/packages/cli/src/ui/utils/goal-runtime.test.ts +++ b/packages/cli/src/ui/utils/goal-runtime.test.ts @@ -28,6 +28,36 @@ describe('waitForGoalRuntime', () => { expect(getGoalRuntimeReady).toHaveBeenCalledTimes(1); }); + it('treats a synchronous persistence-unavailable throw as settled', async () => { + // Config.getGoalRuntimeReady() THROWS synchronously (rather than + // rejecting) when chat recording is disabled, e.g. --no-chat-recording. + // The startup gate must swallow that throw exactly like the rejected + // promise above; an escaped rejection kills the AppContainer init + // effect and freezes the TUI on the init banner forever (#10311). + const getGoalRuntimeReady = vi.fn((): Promise => { + throw new GoalPersistenceUnavailableError(); + }); + + await expect(waitForGoalRuntime({ getGoalRuntimeReady })).resolves.toBe( + true, + ); + await expect( + waitForGoalRuntime({ getGoalRuntimeReady }, { timeoutMs: 100 }), + ).resolves.toBe(true); + expect(getGoalRuntimeReady).toHaveBeenCalledTimes(2); + }); + + it('does not hide a synchronous throw of any other error', async () => { + const failure = new Error('unsupported Goal lifecycle record'); + const getGoalRuntimeReady = vi.fn((): Promise => { + throw failure; + }); + + await expect(waitForGoalRuntime({ getGoalRuntimeReady })).rejects.toBe( + failure, + ); + }); + it('does not hide malformed or unsupported persisted Goal state', async () => { const failure = new Error('unsupported Goal lifecycle record'); const getGoalRuntimeReady = vi.fn().mockRejectedValue(failure); diff --git a/packages/cli/src/ui/utils/goal-runtime.ts b/packages/cli/src/ui/utils/goal-runtime.ts index dc8064e1665..3c8810b725b 100644 --- a/packages/cli/src/ui/utils/goal-runtime.ts +++ b/packages/cli/src/ui/utils/goal-runtime.ts @@ -54,10 +54,14 @@ export async function waitForGoalRuntime( config: Pick, options: { timeoutMs?: number } = {}, ): Promise { - const ready = config.getGoalRuntimeReady(); const awaitReady = async (): Promise => { try { - await ready; + // The call must stay inside the try: with chat recording disabled + // (--no-chat-recording, settings) getGoalRuntimeReady() THROWS + // synchronously instead of rejecting, and an escaped rejection kills + // the AppContainer init effect, freezing the TUI on the init banner + // forever. + await config.getGoalRuntimeReady(); } catch (error) { if (!(error instanceof GoalPersistenceUnavailableError)) throw error; }