Skip to content

fix(core): coalesce concurrent Config.initialize() calls - #11037

Merged
yiliang114 merged 2 commits into
mainfrom
fix/main-ci-11002
Sep 5, 2026
Merged

fix(core): coalesce concurrent Config.initialize() calls#11037
yiliang114 merged 2 commits into
mainfrom
fix/main-ci-11002

Conversation

@yiliang114

Copy link
Copy Markdown
Collaborator

What this PR does

Config.initialize() set its initialized flag synchronously and only then awaited the actual initialization work, so a second caller arriving while the first call was still in flight got an immediate Config was already initialized error instead of the initialization result. Callers that swallow that error (the OpenTUI submit path and the slash-command loader both do) then proceeded on a config whose chat had not started yet.

This change makes a caller arriving mid-flight join the first flight: it awaits the in-flight promise and returns when that settles. A call after the first one has settled still throws exactly as before, so the double-init contract is unchanged once initialization is over.

Why it's needed

The E2E Interactive - OpenTUI renderer (bun) leg reddened on run 33834473606 (issue #11002) with two named test failures in interactive/mid-turn-submit-interactive.test.ts ("exits on a bare quit token…" and "holds a slash command back…"), each failing all three attempts with Held response never reached the screen, so the turn is not mid-stream.

The job log shows why the held response never arrived: every affected session rendered the submitted prompt followed by a rejection —

> Start the review.✖︎ Chat not initialized

The OpenTUI input prompt mounts on first paint, before configuration initialization has run anywhere (initializeApp does not call it). Mounting the prompt starts command-registry loading, which is what kicks off the first config.initialize() flight. A prompt submitted while that flight is still running calls config.initialize() again on its way to the model; today that call throws, the catch {} swallows it, and the turn proceeds into a client whose chat does not exist yet — getChat() then throws Chat not initialized, the turn surfaces the error and the submitted prompt is lost.

Whether the race is lost depends on whether initialization takes longer than the gap between the prompt appearing and the submission landing, which is why it is intermittent: the same commit passed the scheduled run 33836390526 minutes later, and the leg has been green on every completed main run since. Slow, loaded runners widen the window — in the failing run several unrelated interactive suites were stalled at high poll counts at the same time. The identical failure mode (same assertion, same ✖︎ Chat not initialized artifact) also appears in run 33829764813 tracked by #10990.

The ink renderer closed its version of this race in #11000 by keeping the input closed until initialization completes; the OpenTUI path has no such gate, and its own code already documents the same in-flight hazard for the command registry (commands-dispatch.ts startup-window self-heal: "the second initialize() call throws 'already initialized', the catch proceeds"). Joining the flight removes the hazard at the source for every caller instead of healing each consumer separately.

Reviewer Test Plan

How to verify

  1. Run the config suite: cd packages/core && npx vitest run src/config/config.test.ts. The two new cases pin the behaviour: a second caller issued while the first flight is held on a gate settles only when the gate releases and initializeInternal runs exactly once; when the first flight fails, the concurrent caller receives the same error. After the first flight settles, a further call still rejects with Config was already initialized (the pre-existing case still passes unchanged).
  2. Mutation check: reverting the config.ts hunk alone makes makes a concurrent caller join the in-flight initialization fail with Config was already initialized — the exact error the OpenTUI submit path swallowed in the CI failure.
  3. The end-to-end confirmation is the leg itself: with the gate joined, a prompt submitted during startup initialization waits for it and then runs normally instead of dying on Chat not initialized. That window is load-dependent, so it can only be observed over subsequent main runs rather than forced on demand.

Evidence (Before & After)

Before (run 33834473606, job log PTY dumps, one per failing attempt):

> Start the review.✖︎ Chat not initialized
...
Test Files  1 failed | 8 passed | 1 skipped (10)
     Tests  2 failed | 16 passed | 2 skipped (20)

After: a concurrent initialize() caller resolves together with the first flight (new unit coverage); the settled-case throw is preserved.

Tested on

OS Status
🍏 macOS ✅ tested (unit)
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Risk & Scope

  • Main risk or tradeoff: a concurrent second caller now waits for the first flight instead of failing fast. Every existing caller either calls once on its path (ink AppContainer, headless Session, ACP session creation, llm.tsx, the /mcp reconnect throwaway config) or deliberately swallows the error and proceeds (OpenTUI submit and command loading) — the latter two strictly benefit from proceeding on a settled config. Options passed by a concurrent caller are ignored; the first flight's options win, same as today when the second call's options were discarded along with the throw.
  • Not validated / out of scope: the leg's load-dependent stalls in general (the 30 s wait budgets on a slow runner) and any separate failure class that produces red runs without a named test. This PR fixes the named failures of Main CI failed: E2E Tests on 56f75adf2992 #11002.
  • Breaking changes / migration notes: none; no public signature changed.

Linked Issues

Fixes #11002

Related: #11000 closed the ink-side variant of this startup race; this is the OpenTUI-side variant it explicitly left open. #10990 shows the same failure mode one run earlier.

中文说明

这个 PR 做了什么

Config.initialize() 会同步置位 initialized 标志,随后才去 await 真正的初始化工作;因此在第一次调用仍在进行时到达的第二个调用,会立刻得到 Config was already initialized 错误,而不是初始化的结果。吞掉该错误的调用方(OpenTUI 的提交路径与斜杠命令加载器都这么做)会继续在一个 chat 尚未启动的 config 上往前走。

本改动让在飞行途中到达的调用加入第一次飞行:await 进行中的 promise,待其落定后返回。第一次调用落定之后再调用,仍然像从前一样抛错——初始化完成后的双重初始化契约不变。

为什么需要它

运行 33834473606(issue #11002)中,E2E Interactive - OpenTUI renderer (bun) 检查项以两个具名测试失败变红(interactive/mid-turn-submit-interactive.test.ts 的 "exits on a bare quit token…" 与 "holds a slash command back…"),各重试三次全部失败,断言为 Held response never reached the screen, so the turn is not mid-stream

job 日志给出了 held 响应为何从未到达的原因:每个受影响会话都渲染出了提交的 prompt,紧跟着是一条拒绝——

> Start the review.✖︎ Chat not initialized

OpenTUI 的输入框在首次绘制时就挂载,而此时没有任何地方运行过配置初始化(initializeApp 不会调用它)。输入框的挂载会启动命令注册表加载,正是它发起了第一次 config.initialize() 飞行。在这趟飞行仍在进行时提交的 prompt,会在去往模型的路上再次调用 config.initialize();如今这次调用抛错、被 catch {} 吞掉,turn 继续进入一个 chat 尚不存在的 client——getChat() 随后抛出 Chat not initialized,turn 把错误浮出,提交的 prompt 丢失。

是否输掉这个竞态,取决于初始化是否比「输入框出现到提交到达」的间隔更久,这正是它间歇性的原因:同一提交几分钟后的定时运行 33836390526 通过,此后 main 上每一次完成的运行该检查项都是绿的。慢速、高负载的 runner 会拉大窗口——失败的这次运行里,多个互不相关的交互式套件同时卡在高轮询计数上。完全相同的失败形态(同样的断言、同样的 ✖︎ Chat not initialized 痕迹)也出现在 #10990 追踪的运行 33829764813 中。

ink 渲染器已在 #11000 中通过「初始化完成前保持输入关闭」关掉了它那一侧的同款竞态;OpenTUI 路径没有这样的门,而且它自己的代码已经记录了命令注册表上的同款在飞行途中隐患(commands-dispatch.ts 的 startup-window self-heal 注释:"the second initialize() call throws 'already initialized', the catch proceeds")。加入飞行在源头为所有调用方消除该隐患,而不是逐个修补每个消费者。

审阅者测试计划

如何验证

  1. 运行 config 套件:cd packages/core && npx vitest run src/config/config.test.ts。两个新用例钉住该行为:在第一次飞行被门闩挂起时发出的第二个调用,只有在门闩释放后才落定,且 initializeInternal 恰好运行一次;若第一次飞行失败,并发调用方收到同一个错误。第一次飞行落定之后,再次调用仍以 Config was already initialized 拒绝(既有用例原样通过)。
  2. 变异检查:仅还原 config.ts 的改动块,makes a concurrent caller join the in-flight initialization 即以 Config was already initialized 失败——正是 CI 失败里被 OpenTUI 提交路径吞掉的那个错误。
  3. 端到端的确认是该检查项本身:加入门闩后,启动初始化期间提交的 prompt 会等待初始化完成再正常运行,而不是死于 Chat not initialized。该窗口依赖负载,只能在后续 main 运行中观察,无法按需强制触发。

证据(改动前与改动后)

改动前(运行 33834473606,job 日志 PTY 转储,每次失败尝试各一条):

> Start the review.✖︎ Chat not initialized
...
Test Files  1 failed | 8 passed | 1 skipped (10)
     Tests  2 failed | 16 passed | 2 skipped (20)

改动后:并发的 initialize() 调用与第一次飞行一同落定(新增单元覆盖);落定后再调用的抛错行为保留。

测试环境

OS Status
🍏 macOS ✅ 已测试(单元)
🪟 Windows ⚠️ 未测试
🐧 Linux ⚠️ 未测试

风险与范围

  • 主要风险或取舍:并发的第二个调用现在会等待第一次飞行,而不是快速失败。现有调用方要么在其路径上只调用一次(ink AppContainer、headless Session、ACP 会话创建、llm.tsx/mcp reconnect 的一次性 config),要么刻意吞错继续(OpenTUI 提交与命令加载)——后两者在一个已落定的 config 上继续,严格受益。并发调用方传入的 options 被忽略,以第一次飞行的 options 为准;与现状相同(此前第二次调用的 options 连同抛错一起被丢弃)。
  • 未验证 / 范围之外:该检查项整体的负载敏感停顿(慢速 runner 上的 30 秒等待预算),以及任何产生「无测试名红灯」的独立失败类别。本 PR 修复的是 Main CI failed: E2E Tests on 56f75adf2992 #11002 的具名失败。
  • 破坏性变更 / 迁移说明:无;公开签名未变。

关联 Issue

Fixes #11002

相关:#11000 关掉了这一启动竞态在 ink 侧的变体;本 PR 是它明确留下的 OpenTUI 侧变体。#10990 展示了早一次运行的相同失败形态。

initialize() set its initialized flag synchronously and only then
awaited the work, so a caller arriving while the first call was still
in flight got an immediate "Config was already initialized" error.
Callers that swallow that error proceed on a config whose chat has not
started yet: the OpenTUI submit path then lost the first prompt to
"Chat not initialized", reddening the E2E Interactive OpenTUI leg on
runs like 33834473606 (#11002) whenever startup initialization
outlasted the gap between the prompt appearing and the submission
landing. The OpenTUI command-registry loader hits the same hazard and
carries a bounded self-heal for it.

Join the in-flight call instead: await the first flight's promise and
return when it settles, propagating its error if it failed. A call
after the first one settled still throws as before, so the double-init
contract is unchanged once initialization is over.
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

⚠️ Deferred approval withheld — 1 PR CI workflow run(s) on 87d4074 did not finish green; see the updated table in the Stage 2 comment. Re-run @qwen-code /triage after fixes. finalize run

⚠️ 延迟审批已搁置 —— 87d4074 有 1 个 PR CI workflow 未以绿色完成,详见 Stage 2 评论中已更新的表格。修复后可重新运行 @qwen-code /triage查看 finalize 运行

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — every required heading is there, including the Chinese section.

Problem: observed, not theoretical. #11002 is the autofix record of a real red main run (33834473606, E2E Interactive - OpenTUI renderer (bun)), two named tests failing all three attempts, and the PTY dump shows the submitted prompt followed by ✖︎ Chat not initialized. I traced the mechanism independently in the tree rather than taking the description's word for it, and it holds end to end: the input prompt's mount effect calls the command-registry loader, which calls config.initialize() and sets the initialized flag synchronously before awaiting any of the real work; a prompt submitted while that flight is still running calls initialize() again on its way to the model, gets the throw, swallows it in a bare catch {}, and then asks for a client whose chat was never started. So the flag was acting as a "someone started" marker while being read as a "someone finished" marker. That is a real bug with a real user-visible cost — the first prompt is lost.

Direction: aligned. Losing the user's first prompt to a startup race is squarely a CLI-core problem, and the ink renderer already closed its variant of the same race in #11000, so leaving the OpenTUI path open is an inconsistency rather than a new direction. Reference CHANGELOG has several analogous entries for this class of fix (a startup race silently unregistering a plugin marketplace, a queued-prompt Esc race letting the next turn finish early), so nothing here is off-mission for an agent CLI.

Size: core path touched (packages/core/src/config/config.ts, matching both packages/core/src/** and packages/*/src/config/**). Production logic: 12 lines (11 added, 1 deleted). Tests: 55 lines. Generated/schema: 0. Title type is fix(core), not refactor, so the large-scope core hard block does not apply, and at 12 production lines neither the 500-line maintainer-awareness escalation nor the 1000-line advisory is anywhere in reach. The author also has admin on this repo.

Approach: the scope feels right, and I'd have landed in the same place. Two things I checked specifically because this is core. First, it adds no new state — initializationPromise and initializationSettled already exist and are already read by the shutdown path, so this reuses the flight-tracking that was already there instead of bolting on a parallel mechanism. Second, coalescing a second caller onto the in-flight promise is already the established shape in this same class (activateProvisionalWorkspace does exactly this), so the change reads as consistent rather than invented.

The narrower alternative — gate the OpenTUI input until initialization completes, the way #11000 did for ink — would heal the submit path and leave the command loader's identical hazard standing, along with the 15-second polling self-heal that exists purely to paper over it. Fixing the invariant is both smaller and broader. One follow-up worth a thought, not a blocker: that self-heal's own comment now documents a hazard this PR removes at the source, so retiring it (and its poll budget) is probably worth a separate PR. Leaving it untouched here is the right call for keeping this diff minimal.

Risk: no elevated risk signals — the changed files match none of the revert-correlated paths. Because this is core, I did the consumer work rather than eyeballing it: there are exactly eight Config.initialize() call sites in non-test source, and I checked each against the new semantics. Four pass no options at all (ink's app container, the stream-json entry, and the two OpenTUI paths that benefit). The four that do pass options can't hit the joining branch with a different option bag — the three ACP sites and the /mcp reconnect site each initialize a freshly constructed Config, and the non-interactive Session reaches initialize() once per process through a first-message handler whose SDK and direct branches are mutually exclusive. So the "second caller's options are silently ignored" tradeoff the description names is real in principle but not reachable in today's tree; I've left a note about it in the code review since it becomes a trap for the next caller who does race.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 所有必需小标题都在,包括中文说明部分。

问题: 是已观测到的问题,不是理论性加固。#11002 是一次真实 main 红run(33834473606E2E Interactive - OpenTUI renderer (bun))的 autofix 记录:两个具名测试在三次尝试中全部失败,PTY dump 里能看到提交的 prompt 后面紧跟着 ✖︎ Chat not initialized。我没有只采信 PR 描述,而是在代码树里独立把这条链路走了一遍,结论成立:输入框挂载时的 effect 会调用命令注册表加载器,后者调用 config.initialize(),并在真正开始等待任何初始化工作之前就同步把 initialized 标志置位;在这一次 flight 仍在进行中时提交 prompt,会在通往模型的路上再次调用 initialize(),拿到抛错,被一个空的 catch {} 吞掉,然后去取一个 chat 从未启动的 client。也就是说,这个标志实际含义是"有人开始了",却被当成"有人完成了"来读。这是真实 bug,也有真实的用户代价——第一条 prompt 会丢失。

方向: 对齐。让用户的第一条 prompt 因启动竞态而丢失,属于 CLI 核心问题;ink 渲染器已经在 #11000 关掉了同一竞态的它那一份,所以 OpenTUI 这条路仍然敞开是不一致,而不是新方向。参考 CHANGELOG 里有多条同类修复记录(启动竞态导致插件市场被静默注销、排队 prompt 下按 Esc 让下一轮提前结束),因此这个改动对 agent CLI 来说完全不偏离主线。

规模: 触及核心路径(packages/core/src/config/config.ts,同时匹配 packages/core/src/**packages/*/src/config/**)。生产逻辑 12 行(新增 11、删除 1);测试 55 行;生成/schema 0 行。标题类型是 fix(core) 而非 refactor,因此大范围核心重构硬阻断不适用;12 行生产代码也远未触及 500 行的维护者关注阈值和 1000 行的大 PR 建议。作者在本仓库也具备 admin 权限。

方案: 范围合理,我自己也会落到同一个做法上。因为这是核心模块,我专门确认了两点。第一,它没有引入新状态——initializationPromiseinitializationSettled 本来就已存在,并且已经被 shutdown 路径读取,所以这是复用已有的 flight 跟踪机制,而不是另起一套并行机制。第二,把后到的调用者合并到进行中的 promise 上,本来就是同一个类里已有的写法(activateProvisionalWorkspace 就是这么做的),因此这个改动读起来是一致延续,而非凭空发明。

更窄的替代方案——像 #11000 对 ink 做的那样,让 OpenTUI 输入在初始化完成前保持关闭——只能治好提交路径,命令加载器那份完全相同的隐患仍在,连带那个纯粹为掩盖它而存在的 15 秒轮询自愈逻辑也仍在。修不变量本身既更小也更广。有一个值得考虑的后续(不是阻断项):那个自愈逻辑自己的注释现在描述的正是本 PR 从源头移除的隐患,所以退役它(连同它的轮询预算)大概值得单开一个 PR。本次不动它是保持 diff 最小化的正确选择。

风险: 无升级风险信号——改动文件未命中任何与 revert 相关的路径。因为这是核心改动,我没有靠目测,而是把下游消费者查清了:非测试源码中恰好有八处 Config.initialize() 调用点,我逐一对照了新语义。其中四处根本不传 options(ink 的 app container、stream-json 入口,以及两处受益的 OpenTUI 路径)。传 options 的四处不可能带着不同的 option 进入合并分支——三个 ACP 调用点和 /mcp reconnect 各自初始化的是新构造的 Config;非交互式 Session 每个进程只会经由首条消息处理器到达 initialize() 一次,而其 SDK 分支与 direct 分支互斥。因此描述里提到的"第二个调用者的 options 被静默忽略"这一取舍,在原理上真实存在,但在当前代码树中不可达;我在代码审查里就此留了一条备注,因为对下一个真的会并发调用的调用者来说它会变成陷阱。

进入代码审查 🔍

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 87d40742bed300ff12550c2089b15346673d4877 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Code review

No blocking findings. The change is correct, and I want to be specific about why rather than just say it looks fine.

My independent proposal before reading the diff was: keep the in-flight promise on the instance and hand it to concurrent callers, which is what this does. It also does it without inventing state — initializationPromise and initializationSettled were already declared and already read by shutdownResources / scheduleResourceShutdownAfterInitialization, so the fix reads the flight-tracking that existed instead of adding a second mechanism beside it. Coalescing a second caller onto an in-flight promise is also already this class's own idiom (activateProvisionalWorkspace returns this.provisionalWorkspaceActivation when one exists), so the shape is consistent with the file it lands in.

The invariant repair is the right one. initialized was being set synchronously at the top of the method and then read by callers as though it meant "initialization finished", which is why two consumers that swallow the throw ended up running against a config whose chat had not started. Splitting "a flight exists" from "the flight settled" is what the two pre-existing fields were already for.

I checked the two things that would have made this a blocker and neither holds:

  • The joining branch could await undefined. initialized is set before initializationPromise is assigned, so a caller slipping into that gap would await undefined, resolve immediately, and proceed on an uninitialized config — failing open rather than closed. It isn't reachable: the only code between the two assignments is the synchronous prologue of initializeOnce, which runs two boolean guards and two throw guards and then hits await SessionWriterLease.acquire(...) without calling back into initialize(). Control cannot return to any other caller until after the assignment. I'd not add a guard for it — AGENTS.md is explicit about not handling impossible scenarios, and a defensive branch here would obscure the invariant rather than protect it.
  • A joiner could hang where it used to fail fast. Real, and the Risk section already names it. Worth adding one detail: it isn't a new hang. The caller that started the flight is already awaiting the same promise unboundedly, so a wedged initialization wedges the app today with or without this change — the difference is that a second caller now waits on the same wedge instead of getting a loud error. Given both swallow-the-throw consumers turned that loud error into Chat not initialized anyway, the trade is favourable.

One non-blocking note. The joining branch returns before the checks underneath it, so a concurrent caller's options are dropped entirely — not just its sendSdkMcpMessage, but also options?.signal?.throwIfAborted(), and the shutdownRequested guard. I traced whether that bites today and it doesn't: of the eight Config.initialize() call sites in non-test source, the four that pass options each do so on a freshly constructed Config (three ACP sites plus the /mcp reconnect throwaway) or behind a one-shot path (Session.handleFirstMessage, whose SDK and direct branches are mutually exclusive and each return). So no caller can currently reach the joining branch holding an option bag that matters.

The reason I'd still raise it is that the docstring doesn't say so. It documents that a mid-flight caller joins, but a future caller who races with a different options bag gets the first flight's options silently and a resolved promise that reads as "my options were applied" — which is a worse failure than the old throw, because the throw at least told them. One clause on the existing @param options line ("ignored when joining an in-flight call") would close that off for the cost of a line. Take it or leave it; it doesn't block.

Tests are load-bearing, not decorative. I traced both new cases against the pre-fix code rather than trusting the description's mutation claim. Reverting the config.ts hunk makes the first fail at Promise.all with Config was already initialized (the joiner rejects, so the whole thing rejects) and the second fail its identity assertion (toBe) because the joiner would get a fresh error instead of the first flight's. Both genuinely pin the change. The first case also re-asserts the settled-case throw in the same breath, which is the useful part — it pins the boundary rather than only the new behaviour, so a future refactor can't quietly make initialize() idempotent forever. The as unknown as { initializeInternal: () => Promise<void> } spy cast matches roughly seventeen existing uses in the same file, so it follows convention instead of introducing a new test idiom.

The added four-line comment in config.ts is the kind that should exist: it names the two consumers that swallow the throw and the issue number, which is the why that isn't recoverable from the code.

sequenceDiagram
    participant P1 as OpenTUI input prompt (input-prompt.tsx)
    participant P2 as loadInteractiveCommands (slash-dispatch.ts)
    participant P3 as Config.initialize (config.ts)
    participant P4 as livePromptEvents (live-session.ts)
    participant P5 as GeminiClient (client.ts)
    P1->>P2: mount effect loads the command registry
    P2->>P3: initialize, this is the first flight
    Note over P3: the initialized flag is set synchronously while the flight is still running
    P1->>P4: user submits a prompt during that window
    P4->>P3: initialize, second call
    Note over P3: before this PR the second call threw and the bare catch swallowed it
    P3-->>P4: now it awaits the in-flight promise and returns when that settles
    P4->>P5: getGeminiClient
    P5-->>P4: the chat exists, so the turn runs instead of dying on Chat not initialized
Loading

Testing

This is an unattended CI run, so per the gate rules I executed nothing from this PR — no build, no test run, no checkout. The evidence below is this commit's own CI, read through the API. Say plainly what that means: the two new tests have not reported a result yet. The unit suite is still in flight, so nothing here confirms they pass, and I'm not going to imply otherwise.

Nine checks are green and none are red. The three that matter most for this diff are all still running: Test (ubuntu-latest, Node 22.x) (the suite that contains the new config cases), Lint & Static (ubuntu-latest, Node 22.x) (typecheck — relevant because the joining branch reads two private fields, though both pre-exist so I expect no issue), and Integration Tests (no-AK, No Sandbox). Test (macos-latest, Node 22.x), Test (windows-latest, Node 22.x) and Integration Tests (CLI, No Sandbox) are skipped on this PR, so the only platform CI covers here is Linux — which lines up with the Tested-on table marking macOS as the author's own unit run and the other two untested. review-pr and triage in the list below are bot orchestration jobs, not PR CI.

Not verified, and why:

  • The new unit tests pass — not verified: Test (ubuntu-latest, Node 22.x) is still in progress at the time of writing. The finalize job rewrites the table below once CI settles.
  • The mutation claim (reverting the config.ts hunk makes the new test fail) — not verified by execution. I traced it by hand against the pre-fix control flow and it holds, but that is my reading, not a run. This is the author's claim, independently reasoned about here, not independently reproduced.
  • The intermittent E2E leg actually stops reddening — not verified, and not verifiable from a diff or from a green suite. The PR says this itself: the window depends on whether initialization outlasts the gap between the prompt appearing and the submission landing, so it can only be observed across subsequent main runs. Nothing in this review settles it.

Sandboxed verification would settle the two gaps that matter, and the author has write access so both lanes are open:

  • @qwen-code /verify — that the two new cases are load-bearing against the base build rather than passing identically with and without the hunk, and that initializeInternal really is invoked exactly once under a concurrent second caller. The mutation result is currently the author's assertion plus my hand-trace; an A/B against base turns it into evidence.
  • @qwen-code /tmux — the OpenTUI submit-during-startup window itself. Honest caveat before anyone spends a run on it: the race is load-dependent, so tmux is unlikely to force the original failure on demand. Its realistic value here is confirming the first-prompt path through the OpenTUI renderer still works normally with the joining branch in place — a regression check, not a reproduction.

Final CI results for 87d4074 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Test (ubuntu-latest, Node 22.x) 🚫 cancelled
web-shell E2E Smoke (ubuntu-latest, Node 22.x) 🚫 cancelled
Classify PR ✅ success
Dependency CVE audit ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Integration Tests (no-AK, No Sandbox) ✅ success
Lint & Static (ubuntu-latest, Node 22.x) ✅ success
Secret scan (TruffleHog) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

中文说明

代码审查

无阻断性问题。 这个改动是正确的,我想说清楚为什么正确,而不是只说一句"看起来没问题"。

在读 diff 之前我自己的方案是:把进行中的 promise 保存在实例上,交给并发调用者——这正是本 PR 的做法。而且它没有凭空造状态:initializationPromiseinitializationSettled 本来就已声明,并且已经被 shutdownResources / scheduleResourceShutdownAfterInitialization 读取,所以这是复用已有的 flight 跟踪机制,而不是在它旁边再加一套。把后到的调用者合并到进行中的 promise 上,也正是这个类自己的既有写法(activateProvisionalWorkspace 在已有 activation 时就直接返回它),因此这个改动的形态与它所在文件是一致的。

修复的不变量选得对。initialized 原本在方法开头就被同步置位,却被调用者当成"初始化已完成"来读——这就是为什么两个吞掉抛错的消费者会跑在一个 chat 尚未启动的 config 上。把"存在一次 flight"与"这次 flight 已结束"区分开,本来就是那两个已有字段的用途。

我专门核查了两件可能构成阻断的事,结论都不成立:

  • 合并分支可能 await undefined initialized 的置位早于 initializationPromise 的赋值,因此若有人挤进这个间隙,就会 await 到 undefined、立即 resolve,然后在未初始化的 config 上继续——是 fail-open 而非 fail-closed。但它不可达:两处赋值之间唯一执行的代码是 initializeOnce 的同步前奏,它只做两个布尔判断和两个 throw 判断,随后就进入 await SessionWriterLease.acquire(...),期间不会回调 initialize()。在赋值完成之前,控制权不可能交回任何其他调用者。我不建议为此加保护——AGENTS.md 明确要求不为不可能的场景做处理,这里加一条防御分支只会掩盖不变量,而不是保护它。
  • 合并者可能从"快速失败"变成"挂住"。 这是真实的,Risk 部分已经点明。补充一个细节:这并不是新增的挂起。发起这次 flight 的调用者本来就在无上限地 await 同一个 promise,所以初始化一旦卡死,无论有没有这个改动整个应用都会卡住——区别只在于第二个调用者现在会等同一个卡死,而不是拿到一个响亮的报错。而考虑到两个吞错的消费者本来就会把那个响亮报错变成 Chat not initialized,这个取舍是划算的。

一条非阻断备注。 合并分支在底下那些检查之前就 return 了,所以并发调用者的 options 会被完全丢弃——不只是 sendSdkMcpMessage,还包括 options?.signal?.throwIfAborted()shutdownRequested 这道保护。我查了这在今天是否会造成问题,结论是不会:非测试源码中八处 Config.initialize() 调用点里,传 options 的四处要么作用在新构造的 Config 上(三个 ACP 调用点,加上 /mcp reconnect 的一次性 config),要么位于一次性路径上(Session.handleFirstMessage,其 SDK 与 direct 分支互斥且各自 return)。所以目前没有任何调用者能带着真正重要的 option 进入合并分支。

我之所以仍然提出来,是因为 docstring 没有说明这一点。它写了进行中的调用者会合并,但未来某个带着不同 options 并发调用的调用者,会静默拿到第一次 flight 的 options,同时拿到一个读起来像"我的 options 生效了"的 resolved promise——这比原来的抛错更糟,因为抛错至少告知了对方。在已有的 @param options 那行补一个短句("合并到进行中的调用时会被忽略")就能以一行代价堵住它。采纳与否都可以,不构成阻断。

测试是承重的,不是装饰。 我没有采信描述里的 mutation 说法,而是把两个新用例对着修复前的代码走了一遍。回退 config.ts 那个 hunk 后:第一个用例会在 Promise.all 处以 Config was already initialized 失败(合并者 reject,整体随之 reject);第二个用例会在同一性断言(toBe)上失败,因为合并者会拿到一个新的 error 而不是第一次 flight 的那个。两者确实钉住了这个改动。第一个用例还顺手重新断言了"已结束情况下仍然抛错",这部分很有价值——它钉住的是边界而不只是新行为,因此未来的重构无法悄悄把 initialize() 变成永久幂等。as unknown as { initializeInternal: () => Promise<void> } 这个 spy 转型与同一文件中约十七处既有写法一致,是沿用惯例而非引入新的测试套路。

config.ts 中新增的四行注释属于应当存在的那一类:它点明了两个吞掉抛错的消费者和 issue 编号,而这正是无法从代码本身还原的 why

(时序图见上,图中英文标签保持原样:它展示的是 prompt 挂载 → 命令加载器发起第一次 flight → 用户在该窗口内提交 → 第二次调用合并到同一 flight → chat 已存在因此本轮正常执行。)

测试

这是一次无人值守的 CI 运行,因此按门禁规则我没有执行本 PR 的任何内容——不构建、不跑测试、不 checkout。下面的证据来自该 commit 自身的 CI,通过 API 读取。把话说清楚:两个新测试目前还没有出结果。 单元测试仍在进行中,所以此处没有任何东西能确认它们通过,我也不会暗示相反。

九项检查为绿,没有红的。对这个 diff 最关键的三项都还在跑:Test (ubuntu-latest, Node 22.x)(包含新 config 用例的那个套件)、Lint & Static (ubuntu-latest, Node 22.x)(typecheck——因为合并分支读取了两个私有字段,不过两者都是既有的,所以我预计没有问题)、以及 Integration Tests (no-AK, No Sandbox)Test (macos-latest, Node 22.x)Test (windows-latest, Node 22.x)Integration Tests (CLI, No Sandbox) 在本 PR 上被跳过,因此 CI 在此覆盖的唯一平台是 Linux——这与 Tested on 表格一致:macOS 是作者自己的单元测试运行,另外两个未测试。下表中的 review-prtriage 是机器人编排任务,不是 PR CI。

未验证项及原因:

  • 新单元测试是否通过 —— 未验证:撰写时 Test (ubuntu-latest, Node 22.x) 仍在进行中。CI 落定后 finalize 任务会就地重写下表。
  • mutation 说法(回退 config.ts hunk 会让新测试失败)—— 未经执行验证。我对着修复前的控制流手工推演过,结论成立,但那是我的阅读,不是一次运行。这是作者的主张,此处经过独立推理,但未被独立复现。
  • 间歇性的 E2E leg 是否真的不再变红 —— 未验证,且无法从 diff 或绿色套件中验证。PR 自己也这么说:该窗口取决于初始化是否比"prompt 出现"到"提交落地"之间的间隔更久,因此只能在后续的 main run 中观察。本次审查没有任何东西能确定这一点。

沙箱化验证可以解决其中两个真正重要的缺口,且作者具备写权限,两条通道都开放:

  • @qwen-code /verify —— 用以确认两个新用例相对 base 构建是承重的,而不是加不加这个 hunk 都同样通过;并确认在并发第二个调用者下 initializeInternal 确实只被调用一次。mutation 结果目前只是作者的主张加上我的手工推演;对 base 做一次 A/B 才能把它变成证据。
  • @qwen-code /tmux —— 用以观察 OpenTUI 启动期提交这个窗口本身。先说清楚一句实话,免得有人白花一次运行:该竞态依赖负载,tmux 不太可能按需逼出原始故障。它在此处的现实价值是确认加入合并分支后,OpenTUI 渲染器的首条 prompt 路径仍然正常——是回归检查,不是复现。

(CI 结果表见上方机器可读区域。)

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 87d40742bed300ff12550c2089b15346673d4877 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — the mechanism is verified and the tests genuinely pin it; the one nit is a missing docstring clause, and the thing I can't confirm is field efficacy, which nobody can confirm from a diff.

Stepping back: this is a small, honest fix to a real invariant bug, and it landed in the right place. My independent proposal before reading the diff was the same idea — hold the in-flight promise and hand it to concurrent callers — and the PR's version is better than mine would have been, because it reuses two fields that already existed and were already read by the shutdown path rather than adding a third piece of state beside them. That's the difference between a fix and a fix that leaves the next person two mechanisms to reconcile.

The thing I'm most satisfied with is that I didn't have to take the framing on faith. The description makes a fairly elaborate causal claim, and elaborate causal claims are exactly what a reviewer nods along to. So I walked it: the prompt's mount effect really does call the command loader, the loader really does call initialize() inside a bare catch {}, initialized really is set synchronously before any of the work is awaited, the submit path really does call initialize() again and swallow the throw, and getGeminiClient() really does end at throw new Error('Chat not initialized') — the exact string in the CI PTY dump. Every link holds. The bug is that one flag was doing two jobs, and this splits them.

Six months from now I'd thank the author rather than curse them. The docstring is updated in the same hunk instead of being left to drift, the inline comment names the two consumers and the issue number so the why survives, and the settled-case throw is preserved and re-asserted inside the new test — so nobody can quietly relax initialize() into permanent idempotence without a red test telling them. That last point matters more than it looks: the easy version of this fix would have deleted the throw.

Two honest reservations, neither blocking. First, the joining branch steps over everything below it — the caller's options, its abort signal, the shutdown guard. I traced all eight call sites and none can reach that branch holding an option bag that matters today, so it isn't a defect; it's a trap with no sign on it, and one clause on @param options is the sign. Second, and more fundamental: I have not seen this fix the bug. The unit suite was still running when I wrote the review above, so I can't even confirm the two new tests pass, let alone that the OpenTUI leg stops reddening. The PR is candid that the window is load-dependent and can only be observed across later main runs, which is the right thing to say, but it does mean the headline claim rests on reasoning and a green-when-it-lands suite rather than on an observation. That's why the review names /verify and /tmux instead of waving at them.

On the pattern question, since it's the honest one to ask: this author has 41 open PRs, several from the same day, and this branch is the CI autofix lane for its linked issue. I checked whether that was wearing down my judgement in either direction. It shouldn't count against the PR — the author has admin on this repo, so this is a maintainer's own working queue rather than the drive-by volume the gate exists to be skeptical of, and the linked issue carries the repo's own status/ready-for-agent label. It also shouldn't count for it. I want to be explicit that I used the admin status for exactly two things: the maintainer exemption from the large-core-refactor block, which was moot because this is a fix, and noting that the semantic contract of a core method is his to own. Everything else above came from reading the code — I did not let "the author is a maintainer" stand in for verifying the eight consumers, and a maintainer's PR gets the same "prove the problem exists" treatment as anyone else's.

Scope discipline is worth a word too, because it's the part most PRs get wrong. This fixes the invariant and stops. It does not remove the 15-second polling self-heal in the command dispatcher that exists purely to paper over this hazard, even though doing so would have been defensible and would have made the diff look more thorough — that belongs in a follow-up, and leaving it out is what keeps this reviewable and revertable as one idea.

Verdict: approve, deferred. CI is still in flight on this commit (the unit suite, lint/typecheck, and the integration leg), so I'm not posting an approval in this run — approving now would attest to a result that doesn't exist yet. Approval is deferred until CI lands green on 87d40742bed300ff12550c2089b15346673d4877; the finalize job posts the commit-pinned approval once every check completes, and withholds it if anything lands red or the head moves. The fork-refactor guardrail doesn't apply (same-repo branch, fix type) and Stage 0 raised no escalation — 12 production lines against a 500-line threshold.

中文说明

Confidence: 4/5 —— 机制已核实,测试确实承重;唯一的瑕疵是 docstring 少了一个短句,而我无法确认的是实际效果——这一点任何人都无法从 diff 中确认。

退一步看:这是对一个真实不变量 bug 的小而诚实的修复,而且落在了正确的位置。在读 diff 之前我自己的方案是同一个想法——保存进行中的 promise 并交给并发调用者——而 PR 的版本比我本来会写的更好,因为它复用了两个本来就已存在、并且已被 shutdown 路径读取的字段,而不是在旁边再加第三份状态。这就是"一个修复"与"一个让后来者要去调和两套机制的修复"之间的区别。

我最满意的一点是,我不必采信它的叙述。PR 描述提出了一个相当精细的因果链,而精细的因果链恰恰是审阅者最容易点头放过的东西。所以我把它走了一遍:prompt 的挂载 effect 确实调用了命令加载器;加载器确实在一个空的 catch {} 里调用 initialize()initialized 确实在任何实际工作被 await 之前就同步置位;提交路径确实再次调用 initialize() 并吞掉抛错;而 getGeminiClient() 确实终止于 throw new Error('Chat not initialized')——正是 CI PTY dump 里的那个字符串。每一环都成立。这个 bug 的本质是一个标志在干两份活,而本 PR 把它们分开了。

六个月后我会感谢作者而不是骂他。docstring 在同一个 hunk 里就更新了,没有被留下慢慢失真;内联注释点明了两个消费者和 issue 编号,因此 why 能存活下来;而已结束情况下的抛错被保留,并在新测试内部重新断言——所以没人能悄悄把 initialize() 放松成永久幂等而不被一个红色测试告知。最后这一点比看上去更重要:这个修复的偷懒版本本来会是把那个抛错删掉。

两点诚实的保留意见,都不构成阻断。第一,合并分支跨过了它底下的所有内容——调用者的 options、它的 abort signal、shutdown 保护。我追查了全部八处调用点,今天没有任何一处能带着真正重要的 option 进入该分支,所以它不是缺陷;它是一个没有立牌子的陷阱,而 @param options 上的一个短句就是那个牌子。第二,也更根本:我并没有看到这个修复真的修好了 bug。 我写上面那份审查时单元测试还在跑,所以我甚至无法确认两个新测试通过,更不用说 OpenTUI leg 是否不再变红。PR 坦率地说明该窗口依赖负载、只能在后续的 main run 中观察,这是应该说的话,但这确实意味着那个标题级主张依靠的是推理加上一个"落地时会是绿的"套件,而不是一次观测。这就是审查里点名 /verify/tmux 而不是含糊带过的原因。

关于"模式"这个问题,因为诚实地问就该诚实回答:这位作者有 41 个开启的 PR,其中数个来自同一天,而本分支是其关联 issue 的 CI autofix 通道。我检查了这是否在两个方向上磨损了我的判断。它不应当成为对本 PR 的扣分项——作者在本仓库具备 admin 权限,所以这是维护者自己的工作队列,而不是门禁之所以要保持怀疑的那种顺手刷量的模式;关联 issue 也带着本仓库自己的 status/ready-for-agent 标签。它同样不应当成为加分项。我想说清楚:我只在两件事上用到了 admin 身份——大范围核心重构阻断的维护者豁免(而这一条在此无关紧要,因为这是 fix),以及指出一个核心方法的语义契约本就归他所有。上面其余一切都来自读代码——我没有让"作者是维护者"代替对八个消费者的核实,维护者的 PR 也和任何人的一样,接受"证明问题确实存在"的同等对待。

范围克制也值得一句,因为那是多数 PR 做错的部分。这个 PR 修好不变量就停了。它没有移除命令分发器里那个纯粹为掩盖此隐患而存在的 15 秒轮询自愈逻辑——尽管那么做也说得过去,而且会让 diff 显得更彻底。那属于后续 PR;把它排除在外,正是让本次改动可以作为"一个想法"被审查、被回滚的原因。

结论:批准,但延后。 该 commit 上 CI 仍在进行(单元测试套件、lint/typecheck、集成 leg),因此本次运行我不发布批准——现在批准等于为一个尚不存在的结果背书。批准延后至 CI 在 87d40742bed300ff12550c2089b15346673d4877 上全绿;finalize 任务会在每项检查完成后发布绑定该 commit 的批准,若有任一检查变红或 head 移动则不予发布。fork-refactor 保护规则不适用(同仓库分支,fix 类型),Stage 0 也未提出升级——12 行生产代码对 500 行阈值。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 87d40742bed300ff12550c2089b15346673d4877 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed. Suggestions are inline.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Test Plan (not a blocker): 16 passed — this review observed 23339, 1945, 28502, 298, 1815, 504, 5982, 94 passed.

中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Test Plan(非阻断):16 passed — this review observed 23339, 1945, 28502, 298, 1815, 504, 5982, 94 passed

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +3008 to +3011
if (!this.initializationSettled) {
await this.initializationPromise;
return;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-1: The new join branch discards the joining caller's options without notice — the joiner resolves successfully as if they had been applied, and the refreshed docstring does not say that the first caller's options win. If a caller invokes initialize({ sendSdkMcpMessage }) (the shape of the ACP session path) or initialize({ skipMcpDiscovery: true }) while an options-less first flight is in progress on the same Config, it joins, resolves, and proceeds believing its options were honored — SDK MCP messages go nowhere, or discovery runs anyway. A joiner holding an already-aborted signal is never checked (throwIfAborted runs only on the leader path) and blocks on the foreign flight. This is latent today: every in-tree concurrent joiner (live-session.ts:563, slash-dispatch.ts:59, AppContainer.tsx:1070) passes no options — the hazard begins with the first options-bearing joiner. Document first-caller-wins in the docstring, and fail loud on the join path: honor options?.signal?.throwIfAborted() before the await at minimum, or reject a joiner that passes options it cannot honor.

Witness:

PROBE F1: joiner state while first flight still in flight: pending undefined
PROBE F1: joiner final state: resolved

With the implied one-line fix the same probe inverts: promise rejected "DOMException{ stack: 'AbortError: Th…' }" instead of resolving.

Suggested change
if (!this.initializationSettled) {
await this.initializationPromise;
return;
}
if (!this.initializationSettled) {
options?.signal?.throwIfAborted();
await this.initializationPromise;
return;
}

The known concurrent joiners all call with no arguments (slash-dispatch.ts:59, live-session.ts:563, AppContainer.tsx:1070), so aborting or rejecting only options-bearing joiners cannot break the swallow-callers this fix exists for. Please add a test beside the two added cases: with the first flight held on the gate, config.initialize({ skipMcpDiscovery: true }) must reject rather than resolving with the option dropped — removing the guard must make it go red.

中文说明

新的 join 分支会悄无声息地丢弃加入方传入的 options——加入方会成功 resolve,仿佛这些选项已被应用,而更新后的文档注释也没有说明以第一个调用方的选项为准。若某个调用方在同一个 Config 上、第一个无选项的初始化仍在进行时调用 initialize({ sendSdkMcpMessage })(ACP 会话路径的形态)或 initialize({ skipMcpDiscovery: true }),它会加入、resolve 并继续执行,以为自己的选项已生效——SDK MCP 消息将无处可达,或者发现流程照样执行。携带已中止 signal 的加入方不会被检查(throwIfAborted 只在主导路径上运行),会阻塞在别人的初始化上。目前是潜伏的:树内所有并发加入方(live-session.ts:563slash-dispatch.ts:59AppContainer.tsx:1070)都不传选项——隐患从第一个携带选项的加入方开始出现。建议在文档注释中写明「以第一个调用方为准」,并让 join 路径显式失败:至少在 await 之前执行 options?.signal?.throwIfAborted(),或者直接拒绝携带选项的加入方。

已知并发加入方都不带参数调用(slash-dispatch.ts:59live-session.ts:563AppContainer.tsx:1070),因此只对携带选项的加入方中止或拒绝,不会破坏本修复所服务的吞错调用方。请补充一个测试:在第一次初始化被门闩挂起时,config.initialize({ skipMcpDiscovery: true }) 必须 reject,而不是带着被丢弃的选项 resolve——移除该守卫后测试应变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +3008 to +3011
if (!this.initializationSettled) {
await this.initializationPromise;
return;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-2: The join semantics falsify the rationale comment on ensureCommandsLoaded in packages/cli/src/ui/opentui/commands-dispatch.ts (~340-350), which still claims the second initialize() call throws 'already initialized' and the catch proceeds — the mechanism this PR removes. A maintainer debugging OpenTUI startup ordering reads that comment and reasons from a mechanism that no longer exists — e.g. removing the join branch believing the bounded retry already covers the race, or building a second workaround for a race that is gone. The bounded retry itself is not dead — a failed leader still rejects joiners into the same catch — which is exactly why the comment should be rewritten to say concurrent calls now join the in-flight run, and what the retry is still for.

Witness:

witness: not run — nothing runnable discriminates a comment/code contradiction;
settled by quoting commands-dispatch.ts:344-346 against the join branch the diff
adds (config.ts:3008-3011), the closest capability (probe) having no observable
to measure
中文说明

join 语义使得 packages/cli/src/ui/opentui/commands-dispatch.ts(约 340-350 行)上 ensureCommandsLoaded 的注释失效:它仍然声称第二次 initialize() 调用会抛出 'already initialized'、catch 继续执行——而这正是本 PR 移除的机制。维护者调试 OpenTUI 启动顺序时读到该注释,会基于一个已不存在的机制推理——例如以为有界重试已覆盖该竞态而移除 join 分支,或为一个已消失的竞态再建一套补丁。有界重试本身并未失效——失败的主导初始化仍会把加入方以同样的错误拒入同一个 catch——正因如此应重写注释:说明并发调用现在会加入进行中的初始化,并说明保留该重试的原因。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +5178 to +5180
expect(firstError).toBeInstanceOf(Error);
expect(secondError).toBe(firstError);
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-3: The documented 'throws if called again after the first call settled' contract is only pinned after a successful settle — nothing calls initialize() after a failed-and-settled first flight. A one-token mutation of the new condition — if (!this.initializationSettled)if (!this.initializationSucceeded) — survives the added tests: in-flight joining and post-success behaviour are identical under it, but a caller arriving after a failed init awaits the stored rejection and receives the stale startup error instead of 'Config was already initialized', changing what retry/diagnostic logic observes with no test going red.

Witness:

mutant (initializationSettled → initializationSucceeded), scratch tree:
  PR's own new tests: Tests 3 passed | 618 skipped
  PROBE F3: post-failure settle contract (call after failed settle)
   × expected [Function] to throw error including 'Config was already initialized'
     but got 'startup discovery exploded'
intact PR: the same probe is green
Suggested change
expect(firstError).toBeInstanceOf(Error);
expect(secondError).toBe(firstError);
});
expect(firstError).toBeInstanceOf(Error);
expect(secondError).toBe(firstError);
await expect(config.initialize()).rejects.toThrow(
'Config was already initialized',
);
});

Settle is recorded on failure too — finally { this.initializationSettled = true; } in initialize() (packages/core/src/config/config.ts:3023-3025); the fix must not move that flag to the success-only path. The added assertion is its own witness: it goes red under the initializationSettledinitializationSucceeded mutation of the new condition.

中文说明

文档承诺的「第一次调用落定后再次调用会抛错」只在成功落定后被钉住——没有任何测试在第一次初始化失败并落定之后再调用 initialize()。对新条件做单 token 变异——if (!this.initializationSettled)if (!this.initializationSucceeded)——新增测试依然全绿:在该变异下,飞行途中加入与成功落定后的行为完全一致,但在失败初始化之后到达的调用方会 await 已存储的 rejection,收到陈旧的启动错误而不是 'Config was already initialized',重试/诊断逻辑观察到的行为随之改变,而没有任何测试变红。

落定在失败时同样会被记录——initialize() 中的 finally { this.initializationSettled = true; }packages/core/src/config/config.ts:3023-3025);修复不得把该标志移到仅成功路径。新增断言本身就是它的见证:在 initializationSettledinitializationSucceeded 变异下它会变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Comment on lines +3008 to +3011
if (!this.initializationSettled) {
await this.initializationPromise;
return;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] R1-4: The new join path emits no debug breadcrumb — nothing logs that a second caller joined an in-flight initialization, and both in-tree joiners swallow the outcome in bare catch {} blocks. When oncall is paged on "first prompt stalled for tens of seconds at startup", the debug log cannot distinguish "initialization was slow" from "a second caller joined the flight and swallowed its outcome" — precisely the invisible state-machine interaction class behind #11002. Config already uses debugLogger for comparable transitions (Config initialization started in initializeInternal), so one breadcrumb before the await closes the gap.

Witness:

PROBE F4: all debug/info/warn calls observed during the join: []
PROBE F4: join-related breadcrumbs: []
Suggested change
if (!this.initializationSettled) {
await this.initializationPromise;
return;
}
if (!this.initializationSettled) {
this.debugLogger.debug(
'Config.initialize() called while initialization is in flight; joining the existing run',
);
await this.initializationPromise;
return;
}
中文说明

新的 join 路径没有输出任何调试面包屑——没有任何记录表明第二个调用方加入了正在进行的初始化,而树内两个加入方都用裸 catch {} 吞掉结果。当值班同学被「启动后第一个提示卡住几十秒」呼叫时,调试日志无法区分「初始化慢」与「第二个调用方加入了该初始化并吞掉了结果」——这正是 #11002 背后那类不可见的状态机交互。Config 已对同类转换使用 debugLoggerinitializeInternal 中的 Config initialization started),在 await 之前加一条面包屑即可补上该缺口。

— qwen3.8-max via Qwen Code /review (v0.23.0)

@wenshao

wenshao commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Local verification — real OpenTUI sessions, A/B on stock bundles

I built both sides locally and drove the actual CLI. Headline: the failure this PR fixes is reproducible on demand, and the fix removes it. That closes the one gap the PR and the review both flagged as unverifiable ("that window is load-dependent, so it can only be observed over subsequent main runs rather than forced on demand").

Tree. PR head 87d40742be merged with origin/main dfadc11604 (clean merge, 9267f261f1). Two arms built from that tree, differing only by the initialize() hunk — confirmed at the bundled-chunk byte level, not just at the source level.

Runtime. macOS 26.6.2 (Darwin 25.6.0), Node 24.18.1, bun 1.3.14, bun dist/cli.js with QWEN_TUI_RENDERER=opentui + QWEN_TUI_RENDERER_STRICT=1 — the same launcher the E2E Interactive - OpenTUI renderer (bun) leg uses.

How the race was made deterministic — without touching product code. QWEN_CODE_LEGACY_MCP_BLOCKING=1 (a product env var, config.ts:3474) moves MCP discovery inline, so createToolRegistry is awaited inside initializeInternal. A user-scope mcpServers entry then points at a stdio server that waits 6 s before answering the MCP initialize handshake. config.initialize() is genuinely blocked for ~6 s by work the product really does — which is what a slow, loaded runner produces. Both arms get byte-identical env, the same QWEN_HOME, and the repo's own unmodified test files.


1. The CI failure reproduces on demand, and the fix removes it

Repo's own leg, integration-tests/interactive/mid-turn-submit-interactive.test.ts, --retry=0:

Arm Slow-startup probe Result ✖︎ Chat not initialized
BEFORE (main) off 4 passed 0
BEFORE (main) on 4 failed 8
AFTER (#11037) on 4 passed 0

The BEFORE failures are byte-identical to the CI artifact in run 33834473606: assertion Held response never reached the screen, so the turn is not mid-stream, screen text > Start the review.✖︎ Chat not initialized. With the probe off, BEFORE passes — which is exactly the intermittency the PR describes.

Full interactive leg (CI's exclusion list, --retry=0):

Run Arm Probe Test Files Tests Chat not initialized
1 BEFORE on 4 failed / 6 passed / 1 skipped 8 failed 12
2 AFTER on 1 failed / 9 passed / 1 skipped 2 failed 0
3 AFTER off 1 failed / 9 passed / 1 skipped 2 failed 0

The 2 residual failures in runs 2 and 3 are context-compress-interactive.test.ts (chat_compression telemetry event was not found). They fail identically with the probe off and on the BEFORE arm with the probe off, so they are local-environment, not this PR.

Worth noting the blast radius is wider than the two tests #11002 names: on BEFORE, file-system-interactive and protocol-tags-interactive also went red — the same three files that carry the Chat not initialized artifact in the failing CI job log.

before/after


2. Mutation matrix — executed, with a counterfactual arm

Six mutants of the new branch, each run against (a) the shipped suite and (b) the same suite with the two new it() blocks deleted:

Mutant Shipped suite Suite without the two new tests
M1 — revert the hunk (= main) ❌ both new tests ✅ 616 passed — invisible
M2 — drop the !initializationSettled guard ❌ new #1 + the pre-existing double-init test ❌ pre-existing test only
M3 — join, but drop the await ❌ new #2 only invisible
M4 — invert the guard ❌ both new + pre-existing ❌ pre-existing only
M5 — drop the early return ❌ new #1 only invisible
M6 — swallow the failed flight (.catch(() => {})) ❌ new #2 only invisible

6/6 killed by the shipped suite; 4/6 were invisible to the pre-PR suite. The PR's own mutation claim (M1) is confirmed by execution, not by hand-trace. Unmutated: 618 passed.


3. One test-quality gap: the ordering property is never asserted

M3 keeps the joining branch but drops the await:

if (this.initialized) {
  if (!this.initializationSettled) {
    return;              // joins the flight, does not wait for it
  }
  throw Error('Config was already initialized');
}

makes a concurrent caller join the in-flight initialization passes on M3 — the test releases the gate before awaiting, and never asserts that the second call was still pending beforehand. Only shares a failed in-flight initialization with concurrent callers catches it, and it catches it for a different reason (error identity).

That matters because M3 fully reintroduces #11002. I built an M3 bundle and drove a real session: same scenario, same env — ✖︎ Chat not initialized, prompt lost.

mutant

Suggestion (non-blocking, one line): in the first test, assert the joiner has not settled before release() — e.g. push to an array in .then() on both promises and assert the array is empty before releasing the gate. As written, the case that names the behaviour does not pin it.


4. The join is unbounded and silent (observation, non-blocking)

The review's point that this is not a new hang is right — the first caller already awaits the same promise. But the user-visible difference is real. Same scenario with the startup dependency held 45 s, screens captured 12 s after the prompt was submitted:

  • BEFORE: fails fast — the prompt is echoed with ✖︎ Chat not initialized.
  • AFTER: the transcript is completely blank. The submitted prompt is not echoed, there is no spinner, and no error. The keystroke was accepted and nothing on screen acknowledges it until initialization finishes.

wedge

The ink fix (#11000) closes the input until initialization completes, so a user can see they cannot submit yet; the OpenTUI path accepts the keystroke and shows nothing. Worth a follow-up (echo the pending prompt, or bound the join the way commands-dispatch.ts already bounds its own startup wait at STARTUP_REGISTRY_WAIT_MS = 15_000). Not a reason to hold this PR — before the fix that same prompt was lost outright.

Two small things while in here:

  • I'd take the reviewer's docstring nit — one clause on @param options saying it is ignored when joining an in-flight call.
  • commands-dispatch.ts:344 still says "the second initialize() call throws 'already initialized', the catch proceeds". This PR makes that untrue, and it is the comment the PR description cites as corroboration. Worth updating in the same change.

5. The red Test (ubuntu-latest, Node 22.x) is not this PR

Attempt 1 of run 33903459396 failed on exactly two tests, neither touching config.ts:

  1. src/acp-integration/acpAgent.test.ts > QwenAgent runtime-root pinning choke point. Pre-existing on main: origin/main's acpAgent.ts names runWithAcpRuntimeOutputDir at both 4595 and 9200, and the test allows only the first. I reproduce it locally on a tree where acpAgent.ts and acpAgent.test.ts are byte-identical to origin/main.
  2. packages/web-shell client/components/MessageList.dom.test.tsx > drops the anchor instead of re-expanding…. Passes locally (163/163) on a file byte-identical to origin/main — a flake.

In that same job, packages/core src/config/config.test.ts (618 tests) passed — so the two new cases did report green in CI, which the review could not confirm at the time it was written.


6. Regression sweeps (merged tree)

Scope Result
packages/core src/config 8 files / 812 passed
packages/cli src/ui/opentui 67 files / 1132 passed
packages/cli src/acp-integration + src/nonInteractive + src/commands/mcp 2718 tests, 3 failed — the acpAgent structural test above, plus 2 SystemController > get_usage_info timeouts that also fail in isolation on files byte-identical to origin/main

Verdict

Mechanism confirmed by execution rather than by reading; the fix works end-to-end in a real OpenTUI session; no regression is attributable to it, and the red CI job is inherited from main. No blocking findings. One test-quality suggestion (§3, the ordering assertion — worth taking, because the mutant it misses is the one that silently restores the bug) and one UX follow-up (§4), both non-blocking.

中文说明

本地验证 —— 真实 OpenTUI 会话,两臂均为原样打包产物

我在本地把两侧都构建出来并驱动真实 CLI 跑通。结论先说:本 PR 修复的失败可以按需复现,加上修复后消失。 这正好补上了 PR 与评审都标记为「无法验证」的那一处("该窗口依赖负载,只能在后续 main 运行中观察,无法按需强制触发")。

代码树:PR head 87d40742beorigin/main dfadc11604 合并(干净合并,9267f261f1)。两臂自同一棵树构建,initialize() 那个 hunk —— 已在打包 chunk 的字节层面核对,而非只比对源码。

运行时:macOS 26.6.2(Darwin 25.6.0)、Node 24.18.1、bun 1.3.14,bun dist/cli.js + QWEN_TUI_RENDERER=opentui + QWEN_TUI_RENDERER_STRICT=1 —— 与 E2E Interactive - OpenTUI renderer (bun) 检查项同一套启动方式。

如何在不改动产品代码的前提下把竞态变成确定性事件QWEN_CODE_LEGACY_MCP_BLOCKING=1(产品自带环境变量,config.ts:3474)会把 MCP 发现改为内联,于是 createToolRegistryinitializeInternal 内被 await。再在用户级 settings 里声明一个 stdio MCP server,它等待 6 秒才回应 MCP initialize 握手。这样 config.initialize() 就被产品真实执行的工作阻塞约 6 秒 —— 正是慢速、高负载 runner 会造成的情形。两臂环境变量逐字节相同、QWEN_HOME 相同、测试文件均为仓库原文件。


1. CI 失败可按需复现,修复后消失

仓库自带用例 integration-tests/interactive/mid-turn-submit-interactive.test.ts--retry=0

慢启动探针 结果 ✖︎ Chat not initialized
BEFORE(main) 4 通过 0
BEFORE(main) 4 失败 8
AFTER(#11037 4 通过 0

BEFORE 的失败与运行 33834473606 的 CI 痕迹逐字一致:断言 Held response never reached the screen, so the turn is not mid-stream,屏幕文本 > Start the review.✖︎ Chat not initialized。探针关闭时 BEFORE 通过 —— 这正是 PR 描述的间歇性。

完整 interactive 检查项(沿用 CI 的排除列表,--retry=0):

运行 探针 Test Files Tests Chat not initialized
1 BEFORE 4 失败 / 6 通过 / 1 跳过 8 失败 12
2 AFTER 1 失败 / 9 通过 / 1 跳过 2 失败 0
3 AFTER 1 失败 / 9 通过 / 1 跳过 2 失败 0

运行 2、3 里残留的 2 个失败是 context-compress-interactive.test.tschat_compression telemetry event was not found)。它在探针关闭时同样失败,在 BEFORE 臂关闭探针时也同样失败,属本机环境问题,与本 PR 无关。

另外,影响面比 #11002 点名的两个用例更广:BEFORE 臂上 file-system-interactiveprotocol-tags-interactive 同样变红 —— 与失败 CI job 日志中携带 Chat not initialized 痕迹的正是同样三个文件。


2. 变异矩阵 —— 实跑,并带反事实对照臂

对新增分支做 6 个变异体,各自跑 (a) PR 提交的套件、(b) 删掉两个新 it() 后的同一套件:

变异体 PR 套件 删掉两个新用例后的套件
M1 —— 还原 hunk(即 main) ❌ 两个新用例 ✅ 616 通过 —— 看不见
M2 —— 去掉 !initializationSettled 守卫 ❌ 新用例 #1 + 既有双重初始化用例 ❌ 仅既有用例
M3 —— 加入飞行但去掉 await ❌ 仅新用例 #2 看不见
M4 —— 守卫取反 ❌ 两个新用例 + 既有用例 ❌ 仅既有用例
M5 —— 去掉提前 return ❌ 仅新用例 #1 看不见
M6 —— 吞掉失败的飞行(.catch(() => {}) ❌ 仅新用例 #2 看不见

PR 套件 6/6 全杀;其中 4/6 对 PR 之前的套件完全不可见。PR 自己的变异断言(M1)由实跑证实,而非手工推演。未变异时 618 通过。


3. 一处测试质量缺口:顺序性质从未被断言

M3 保留加入飞行的分支,但去掉 await

if (this.initialized) {
  if (!this.initializationSettled) {
    return;              // 加入飞行,但不等待
  }
  throw Error('Config was already initialized');
}

makes a concurrent caller join the in-flight initialization 在 M3 下通过 —— 该用例在 await 之前就释放了门闩,也从未断言第二个调用在此之前仍处于未落定状态。只有 shares a failed in-flight initialization with concurrent callers 能杀掉它,而且是因为另一个理由(错误对象同一性)。

这一点重要,因为 M3 会完整重现 #11002。我构建了 M3 的打包产物并驱动真实会话:同样场景、同样环境 —— ✖︎ Chat not initialized,提交的 prompt 丢失。

建议(非阻塞,一行即可):在第一个用例里断言 joiner 在 release() 之前尚未落定 —— 例如给两个 promise 各挂一个 .then() 往数组里 push,然后在释放门闩前断言数组为空。按现在的写法,那个为该行为命名的用例并没有钉住它。


4. 这次加入飞行是无界且无声的(观察项,非阻塞)

评审说「这不是的挂起」是对的 —— 第一个调用方本来就在无界等待同一个 promise。但用户可见的差别是真实存在的。同样场景,把启动依赖挂住 45 秒,在提交 prompt 后 12 秒截图:

  • BEFORE:快速失败 —— prompt 被回显,并跟一条 ✖︎ Chat not initialized
  • AFTER:整个对话区一片空白。提交的 prompt 没有回显,没有转圈,也没有错误。按键被接收了,但在初始化完成前屏幕上没有任何东西确认这一点。

ink 侧的修复(#11000)是在初始化完成前保持输入关闭,用户能看出此刻不能提交;OpenTUI 路径则接收按键却什么也不显示。值得后续跟进(回显待处理的 prompt,或者像 commands-dispatch.ts 已经用 STARTUP_REGISTRY_WAIT_MS = 15_000 给自己的启动等待设上限那样,给这次加入飞行设上限)。这不构成拦下本 PR 的理由 —— 修复之前,同一个 prompt 是直接丢掉的。

顺带两点:

  • 我赞成评审提的 docstring 小意见 —— 在 @param options 上加一句,说明加入在飞行中的调用时该参数会被忽略。
  • commands-dispatch.ts:344 目前仍写着「第二次 initialize() 调用抛出 'already initialized',catch 继续往下走」。本 PR 让这句话不再成立,而它恰恰是 PR 描述引用作为佐证的那条注释。建议在同一个改动里一并更新。

5. 变红的 Test (ubuntu-latest, Node 22.x) 不是本 PR 造成的

运行 33903459396 的第 1 次尝试只失败了两个用例,都与 config.ts 无关:

  1. src/acp-integration/acpAgent.test.ts > QwenAgent runtime-root pinning choke pointmain 上既有origin/mainacpAgent.ts45959200 两处都写了 runWithAcpRuntimeOutputDir,而该用例只允许第一处。我在 acpAgent.tsacpAgent.test.tsorigin/main 逐字节相同的树上本地复现了它。
  2. packages/web-shell client/components/MessageList.dom.test.tsx > drops the anchor instead of re-expanding…。在与 origin/main 逐字节相同的文件上本地通过(163/163),属抖动。

同一个 job 里,packages/core src/config/config.test.ts(618 个用例)通过 —— 也就是说两个新用例在 CI 里确实报了绿,这一点在评审撰写时还无法确认。


6. 回归扫描(合并树)

范围 结果
packages/core src/config 8 个文件 / 812 通过
packages/cli src/ui/opentui 67 个文件 / 1132 通过
packages/cli src/acp-integration + src/nonInteractive + src/commands/mcp 2718 个用例,3 个失败 —— 上面那个 acpAgent 结构性用例,外加 2 个 SystemController > get_usage_info 超时;后者在与 origin/main 逐字节相同的文件上单独跑同样失败

结论

机理由实跑而非阅读确认;修复在真实 OpenTUI 会话中端到端有效;没有可归因于它的回归,变红的 CI job 是从 main 继承来的。无阻塞性发现。 一条测试质量建议(§3 的顺序断言 —— 值得采纳,因为它漏掉的那个变异体恰恰会悄悄把缺陷放回去)和一条 UX 后续项(§4),均非阻塞。

@yiliang114
yiliang114 enabled auto-merge September 5, 2026 03:20
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with @qwen-code /review. See workflow logs.

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed at head e33d3c3e (the merge-from-main delta touches nothing this PR owns). No historical blocking issues exist on this PR — triage found no blockers at the feature commit, the four round-1 inline items are explicitly Suggestions, and the maintainer's local A/B on real OpenTUI sessions confirmed the #11002 failure is reproducible before the fix and removed by it. My independent Critical-only pass finds no blocking defect.

The join semantics, verified at this head's code (packages/core/src/config/config.ts:2999-3026):

  • Ordering is race-free: this.initialized = true, the initializeOnce(options) call, and this.initializationPromise = initialization all execute in one synchronous turn with no await between them, so no interleaved caller can observe initialized === true with the promise still undefined — every joiner awaits a promise that is guaranteed to exist.
  • A failed first flight is shared, not hidden: initializationSettled flips in a finally, the joiner's await surfaces the same rejection (pinned by the new test asserting secondError === firstError), and only after settle does the classic "Config was already initialized" throw return — the pre-settle contract is unchanged for post-settle callers.
  • The join branch returns without touching initializationSucceeded, so success accounting stays owned by the first flight.

Tests: both new cases pin the actual contract — the gate/release test proves initializeInternal runs exactly once while a concurrent caller joins mid-flight, and the failure-sharing test proves the rejection identity is shared.

CI at this head: no failing or cancelled checks at review time; Test (ubuntu-latest, Node 22.x) is still pending, which does not gate this review per policy.

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No blocking findings. Approval blockers: none.

What I checked:

  • Pre-existing fields: initializationPromise, initializationSettled, initializationSucceeded all in the base; the PR adds only the 8-line join guard.
  • Synchrony: initialized=true and initializationPromise=init are assigned synchronously before any await, so no window where the concurrent caller sees a null promise.
  • Failure propagation: same rejection object reaches all joiners (expect(secondError).toBe(firstError) confirms identity). ✓
  • Post-settlement: finally fires on both success and failure, so further calls always fall to throw 'Config was already initialized'. ✓
  • Current callers: slash-dispatch.ts:59, loadInteractiveCommands, AppContainer.tsx all pass no options — signal-ignored concern is latent.
  • Tests: gate mock discriminates correctly; PR mutation witness is valid.
  • Sibling: activateProvisionalWorkspace uses same coalesce pattern. ✓

Confirmed suggestions (all non-blocking, from ci-bot R1-1 through R1-4):

R1-1: Join branch discards the joining caller's options silently. Add options?.signal?.throwIfAborted() before the await and document first-caller-wins.

R1-2: commands-dispatch.ts:~344 still reads "the second initialize() call throws 'already initialized'" — mechanism this PR removes. Worth updating.

R1-3 (mutation witness): !this.initializationSettled!this.initializationSucceeded survives the added tests. After a failed settle initializationSucceeded stays false, so the mutant re-enters the join branch and returns the stale startup error instead of 'Config was already initialized'. Add one line after the failure test: await expect(config.initialize()).rejects.toThrow('Config was already initialized').

R1-4: No debug breadcrumb on the join path. debugLogger.debug(...) before the await closes the gap that made #11002 hard to diagnose.

Not covered: no working tree; rung 3 not applicable.

Reviewed with AI assistance.

// throw (the OpenTUI submit path, slash-command loading) proceeded on
// a config whose chat had not started yet, and the first prompt died
// with "Chat not initialized" (#11002).
if (!this.initializationSettled) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Confirmed — Suggestion] R1-1 + R1-4: The join branch silently drops the joining caller's options. throwIfAborted is only reached on the leader path; a joiner with an already-aborted signal hangs on the foreign flight. Latent today (all concurrent callers: slash-dispatch.ts:59, loadInteractiveCommands, pass no options), but the first options-bearing joiner silently misbehaves. Suggested one-liner fix: options?.signal?.throwIfAborted() before the await. Also: no debug breadcrumb is logged when a caller joins — one debugLogger.debug(...) before the await closes the observability gap that made #11002 hard to diagnose.

// throw (the OpenTUI submit path, slash-command loading) proceeded on
// a config whose chat had not started yet, and the first prompt died
// with "Chat not initialized" (#11002).
if (!this.initializationSettled) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Confirmed — Suggestion] R1-2: commands-dispatch.ts:~344 still describes the old throw-then-catch-proceeds mechanism removed by this PR. Worth updating to say concurrent calls now join the in-flight run.

second.catch((error: unknown) => error),
]);
expect(firstError).toBeInstanceOf(Error);
expect(secondError).toBe(firstError);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Confirmed — Suggestion] R1-3: The mutation !this.initializationSettled!this.initializationSucceeded survives the added tests (ci-bot mutation witness). After a failed settle, initializationSucceeded stays false, so the mutant re-enters the join branch and callers get the stale startup error instead of 'Config was already initialized'. Fix: add at the end of the failure test:

await expect(config.initialize()).rejects.toThrow('Config was already initialized');

@yiliang114
yiliang114 added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 941e6f4 Sep 5, 2026
99 of 101 checks passed
@qwen-code-review-bot

Copy link
Copy Markdown
Collaborator

Review verified. Final comment below.


Post-merge review — no blockers found.

The join branch in initialize() is race-free as written: initialized = true is set synchronously before the first await, and initializationSettled flips in finally, so the join window closes exactly when the first flight settles, on success or failure. On the failure path joiners get the same rejection as the first caller, and the two new tests pin this down (single initializeInternal call, shared error identity, post-settle call still throws).

I also checked every Config.initialize() call site against the changed semantics: the two callers that swallow the old throw (live-session.ts, slash-dispatch.ts) call without options, so joining can't silently drop anything, and the option-passing sites (ACP sessions, non-interactive, mcp reconnect) either own their Config or only touch settled ones, where the throw behavior is unchanged. CI was green on Lint & Static, no-AK integration, and Desktop Shell at merge.

One non-blocking note for the future: a joining caller that passes different options would have them silently ignored while the first flight is in progress. No caller can hit that today, so nothing to change — just worth keeping in mind if an option-bearing caller ever ends up in this race.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Resolved the four round-1 review suggestions (R1-1 through R1-4) deferred on this PR in follow-up #11075:

  • R1-1 — a joining caller's options are ignored (first caller wins); an already-aborted signal now fails fast on the join path.
  • R1-2 — the commands-dispatch.ts comment is rewritten for the join semantics.
  • R1-3 — the post-failure-settle contract is pinned with a regression assertion.
  • R1-4 — a debug breadcrumb is logged on the join path.
中文

本条 PR 上搁置的四条 round-1 评审建议(R1-1 ~ R1-4)已在 follow-up #11075 解决:

  • R1-1 —— 加入方 options 被忽略(以第一方为准);已中止的信号现在会在 join 路径快速失败。
  • R1-2 —— commands-dispatch.ts 注释改写为 join 语义。
  • R1-3 —— 以回归断言钉住「失败落定后再调用」的契约。
  • R1-4 —— join 路径新增 debug 面包屑。

pull Bot pushed a commit to edisplay/qwen-code that referenced this pull request Sep 5, 2026
* fix(core): harden Config.initialize() join path

Follow-ups to the non-blocking review suggestions left on QwenLM#11037:

- a joining caller's options are ignored (first caller wins), so honor an
  already-aborted signal on the join path instead of blocking on the
  foreign flight
- log a debug breadcrumb when a caller joins an in-flight initialization
- pin the post-failure-settle contract: a call after a failed first flight
  still throws 'Config was already initialized'
- update the OpenTUI commands-dispatch comment to describe the join
  semantics instead of the removed throw-then-catch mechanism

* test(core): pin fast rejection for the aborted joining caller

The test asserted the abort reason but not the ordering: `release()` ran
before the assertion on `joining`, so a `throwIfAborted()` moved below
`await this.initializationPromise` would still reject with the identical
reason and keep the test green — the exact "aborted joiner blocks on the
foreign flight" behaviour this branch removes.

Assert the rejection while the gate is still held. The guard sits ahead of
any `await` in `initialize()`, so the joining promise settles on the first
microtask without the first flight finishing; with the guard moved after
the await, the assertion never settles and the test times out instead.

---------

Co-authored-by: yiliang114 <jinjing.zzj@gmail.com>
wenshao pushed a commit to wenshao/qwen-code that referenced this pull request Sep 5, 2026
…utant fails (QwenLM#11113)

The 'makes a concurrent caller join the in-flight initialization' test released
the gate before asserting anything and never checked that the joining caller was
still pending mid-flight. A join branch that drops the await (returning
immediately) therefore passed this test while silently reproducing QwenLM#11002: the
joiner proceeds before initialization completes and dies on 'Chat not
initialized'. It was killed only incidentally by the failure-sharing test, for an
unrelated reason.

Assert nothing has settled while the first flight is still gated, so the named
test actually pins the ordering property it describes. Verified: full config
suite 621 passed; removing the await now fails this test with ['second'] vs [].

Follow-up to QwenLM#11037 (review finding 3, not covered by QwenLM#11075).
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.23.1.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Main CI failed: E2E Tests on 56f75adf2992

6 participants