Skip to content

fix(core): harden Config.initialize() join path - #11075

Merged
yiliang114 merged 3 commits into
mainfrom
fix/config-init-join-followups
Sep 5, 2026
Merged

fix(core): harden Config.initialize() join path#11075
yiliang114 merged 3 commits into
mainfrom
fix/config-init-join-followups

Conversation

@yiliang114

@yiliang114 yiliang114 commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Follow-up to #11037 that resolves the four non-blocking review suggestions left on the merged join-path change. Config.initialize()'s in-flight join branch now fails fast when a joining caller's signal is already aborted (instead of blocking on the foreign flight) and logs a debug breadcrumb. The docstring documents that a joining caller's options are ignored — the first caller's options win. A regression assertion pins the post-failure-settle contract, and the OpenTUI commands-dispatch comment is updated to describe the join semantics instead of the removed throw-then-catch mechanism.

Why it's needed

The four suggestions were latent gaps the original PR merged without resolving: an aborted-signal joiner would hang on the first flight, the join path had no observability (the same gap that made #11002 hard to diagnose), the post-failure-settle contract was unpinned (a one-token mutation survives the added tests), and a stale comment still described a mechanism the PR removed.

Reviewer Test Plan

How to verify

Run the config suite: cd packages/core && npx vitest run src/config/config.test.ts. The new case proves a joining caller with an already-aborted signal rejects with the abort reason before the first flight settles — the assertion runs while the gate is still held, so it also pins the ordering; the updated failure case proves a call after a failed first flight still throws Config was already initialized.

The first revision (689bcc2) was verified locally: all 621 config tests and 58 commands-dispatch tests passed, typecheck, lint and prettier clean. The follow-up commit 82d82c7 reorders two assertions and was not re-run locally — CI on this head is the check.

Evidence (Before & After)

N/A (non-user-visible behavior hardening plus a comment; the unit tests are the evidence).

Tested on

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

Risk & Scope

  • Main risk or tradeoff: an options-bearing caller that joins an in-flight initialization still has its options ignored (first caller wins), but an already-aborted signal now fails fast instead of blocking. No in-tree caller passes options on a concurrent join, so no behavior changes today.
  • Not validated / out of scope: nothing beyond the four suggestions.
  • Breaking changes / migration notes: none.

Linked Issues

Follow-up to #11037

中文说明

这个 PR 做了什么

#11037 的后续,解决合并后的 join 路径改动上遗留的四条非阻塞评审建议。Config.initialize() 的飞行途中 join 分支现在会在加入方信号已中止时快速失败(而不是阻塞在别人的初始化上),并输出一条 debug 面包屑。文档注释写明加入方传入的 options 会被忽略——以第一个调用方的 options 为准。新增一条回归断言钉住「失败落定后再调用」的契约,并把 OpenTUI 的 commands-dispatch 注释更新为 join 语义,替换掉已移除的「抛错后 catch 继续」机制。

为什么需要它

这四条建议都是原 PR 合并时未解决的潜在缺口:已中止信号的加入方会挂在第一次初始化上;join 路径没有任何可观测性(正是 #11002 当初难以定位的原因);「失败落定后再调用」的契约没有被钉住(单 token 变异能逃过现有测试);以及一条过时注释仍在描述本 PR 已移除的机制。

审阅者测试计划

如何验证

运行 config 套件:cd packages/core && npx vitest run src/config/config.test.ts。新用例证明携带已中止信号的加入方会在第一次初始化落定之前以中止原因 reject——断言在门控仍被持有时执行,因此顺序属性也被钉住;更新后的失败用例证明第一次初始化失败后再次调用仍抛出 Config was already initialized

第一版(689bcc2)经过本地验证:621 个 config 测试与 58 个 commands-dispatch 测试全部通过,typecheck、lint、prettier 均干净。后续提交 82d82c7 只调整了两条断言的顺序,未在本地重跑——由当前 head 的 CI 负责验证。

证据(改动前与改动后)

N/A(非用户可见的行为加固 + 注释;单元测试即证据)。

测试环境

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

风险与范围

  • 主要风险或取舍:携带 options 的加入方在飞行途中 join 时其 options 仍会被忽略(以第一个调用方为准),但已中止的信号现在会快速失败而非阻塞。树内没有任何调用方在并发 join 时传 options,因此今天没有行为变化。
  • 未验证 / 范围之外:仅限这四条建议。
  • 破坏性变更 / 迁移说明:无。

关联 Issue

#11037 的后续

Follow-ups to the non-blocking review suggestions left on #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
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

⚠️ 延迟审批已搁置 —— 689bcc2 有 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 filled in, Evidence (Before & After) correctly says N/A for a non-user-visible change, and the bilingual mirror is appreciated.

Problem: observed and documented, not theoretical hardening. All four items are unresolved review threads on #11037 (merged earlier today), and each was independently confirmed by a human reviewer — including a concrete mutation witness for R1-3 (!initializationSettled!initializationSucceeded survives the tests that PR added) and a suggested one-liner for R1-1/R1-4. I read the threads directly: R1-1, R1-2, R1-3 and R1-4 are all still unresolved on a merged PR. Review debt on landed code is about as strong as "the problem exists" gets.

Direction: aligned. This closes gaps on a change that already merged, on the same startup path behind #11002 ("Chat not initialized"). No new surface, no new abstraction, no product-direction question to litigate. CHANGELOG: no direct reference, and none needed — this is internal startup-path hardening, not a user-facing capability.

Size: core paths are touched (packages/core/src/config/**, plus a cross-package comment edit under packages/cli), so the breakdown: 25 production lines (config.ts 9, commands-dispatch.ts 16 — the latter comment-only), 35 test lines, 0 generated/schema. Title is fix, so the Tier 1 large-refactor hard block does not apply, and 25 lines sits far below every escalation threshold. The author also holds admin permission on this repo.

Approach: scope feels right — it maps 1:1 onto the four threads and carries nothing else. I went looking for a smaller version and there isn't one worth having: the throwIfAborted() guard and the debug breadcrumb are two statements in the same branch, the docstring and the OpenTUI comment are the documentation halves of R1-1 and R1-2, and the two test assertions are what stop the whole thing from silently regressing. No drive-by refactors, no formatting churn, no unrelated edits.

Risk: no elevated risk signals — none of the changed files match the revert-correlated path list, so ordinary review depth.

One scope boundary worth naming up front, as an observation rather than an objection: throwIfAborted() only catches an already-aborted signal. A joiner whose signal aborts mid-flight still blocks on the foreign initialization until it settles. That is inherent to the join design #11037 merged — honoring a late abort would mean racing the foreign promise against the signal, which is a different change — and the one-liner here is precisely what was asked for on the thread. Flagging it so it does not get silently lost if an options-bearing joiner ever appears.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ —— 所有必填小节都写了,Evidence (Before & After) 对非用户可见改动正确填了 N/A,中英双语也很贴心。

问题:是已观测且有据可查的,不是理论性加固。这四项都是 #11037(今天早些时候已合并)上未解决的评审线程,而且每一条都经人工评审者独立确认过——包括 R1-3 的具体变异见证(!initializationSettled!initializationSucceeded 能逃过该 PR 新增的测试),以及 R1-1/R1-4 的建议一行修复。我直接读了这些线程:R1-1、R1-2、R1-3、R1-4 在一个已合并的 PR 上全部仍处于未解决状态。已落地代码上的评审欠债,是「问题确实存在」最有力的一种证据。

方向:对齐。这是在补齐一个已经合并的改动的缺口,且正是 #11002("Chat not initialized")背后的同一条启动路径。没有新增接口面、没有新抽象,也不存在需要争论的产品方向问题。CHANGELOG:没有直接对应条目,也不需要——这是内部启动路径加固,不是面向用户的能力。

规模:触及核心路径(packages/core/src/config/**,外加 packages/cli 下一处跨包注释修改),因此给出拆分:生产代码 25 行config.ts 9 行、commands-dispatch.ts 16 行,后者纯注释)、测试 35 行生成/schema 0 行。标题是 fix,所以 Tier 1 大型重构硬性拦截不适用,25 行也远低于所有升级阈值。作者在本仓库拥有 admin 权限。

方案:范围合理——与四条线程一一对应,没有夹带别的东西。我尝试找一个更小的版本,但没有值得采用的:throwIfAborted() 守卫和 debug 面包屑是同一分支里的两条语句,文档注释和 OpenTUI 注释分别是 R1-1 与 R1-2 的文档部分,两条测试断言则是防止整体悄悄回退的关键。没有顺手重构、没有格式化噪音、没有无关改动。

风险:无升级风险信号——改动文件都不匹配「回退相关」路径清单,按常规深度评审即可。

有一点范围边界想先说明,属于观察而非反对:throwIfAborted() 只能拦住已经中止的信号。如果加入方的信号是在飞行途中才中止,它仍会阻塞在别人的初始化上直到落定。这是 #11037 合并的 join 设计本身的固有特性——要支持迟到的中止,就得拿外部 promise 与 signal 竞速,那是另一个改动——而这里的一行修复正是线程上所要求的。提出来是为了避免将来真出现「带 options 的加入方」时这一点被悄悄遗忘。

进入代码审查 🔍

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

Reviewed at 689bcc2b9a144d31cea838682de3664a0c7a5ec4 · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

I wrote down my own answer to the four threads before reading the diff, and the diff is what I would have written — same guard, same placement, same two test assertions. So this is a confirmation pass rather than a hunt for an alternative.

What I actually checked, in the order it mattered:

The debug breadcrumb cannot crash the path it illuminates. This was the one real hazard in the change: debugLogger is logged from inside the join branch, which by definition runs before initialization completes. It's a field assigned in the constructor (createDebugLogger(), config.ts:2508), not something initializeInternal stands up, so it's live from the moment the object exists. Safe.

The guard mirrors the leader path instead of inventing semantics. initialize() already calls options?.signal?.throwIfAborted() before flipping initialized. Adding the same two words to the join branch makes both entry points agree about what an aborted signal means. That's the right shape for a fix like this.

Every downstream consumer, named. The callers that can actually reach the join branch today are all options-less — slash-dispatch.ts:59 (inside loadInteractiveCommands), live-session.ts:563, AppContainer.tsx:1108, llm.tsx:1350 — and for each of them options?.signal?.throwIfAborted() short-circuits on undefined, i.e. a literal no-op. The options-bearing callers (commands/mcp/reconnect.ts:114, acpAgent.ts:2756, :3929, :13968, nonInteractive/session.ts:175) each own a freshly constructed Config, so they take the leader path and never touch the new branch. The behaviour change is real but latent — which is what the PR description says, and I can confirm it rather than take it on faith.

The R1-3 assertion genuinely kills the mutant. After a failed first flight the finally sets initializationSettled = true while initializationSucceeded stays false. Real code: a later initialize() throws Config was already initialized. Mutant (!initializationSucceeded): re-enters the join branch and awaits the stored rejection, handing the caller the stale startup error instead. The added rejects.toThrow(...) is exactly the assertion that separates those two, and it sits at the end of the existing failure test where it belongs.

The new test is load-bearing, and correctly built. initializeInternal is the real private method (config.ts:3111, reached from initializeOnce at :3077) and the established spy target across this file's initialization tests — including the neighbouring join test whose gate pattern this one copies. Remove the throwIfAborted() line and joining awaits the released gate and resolves, so rejects.toBe(abortReason) fails — the test pins the change rather than decorating it.

I also looked for the classic failure shape in that structure: joining is a rejected promise that isn't awaited until after an intervening await expect(first).resolves.toBeUndefined(), which can surface as an unhandled rejection. It doesn't here — release() resolves the gate synchronously before that await, so the handler attaches inside the same microtask drain, and Node only processes pending rejections once microtasks are exhausted. Non-issue; mentioning it only because I checked.

The OpenTUI comment is comment-only (8 added / 8 removed) and now tells the truth. ensureCommandsLoadedloadCommandsloadInteractiveCommands, whose try { await config?.initialize() } catch { /* proceed with partial commands */ } is the "partial-commands catch" the new text names. Under join semantics a successful first flight means the joiner awaits the whole flight, so the skill manager exists by the time the loaders run; only a failed first flight propagates into that catch. That's precisely the correction R1-2 asked for, and it keeps the nuance that the bounded retry is still not dead code.

Conventions are clean: ESM, no any (the as unknown as { initializeInternal: ... } cast matches the file's existing style), collocated test, kebab-case filenames, and the only new comments are the two carrying a genuinely non-obvious why.

No critical blockers and no AGENTS.md violations. Nothing I'd ask to change.

Test evidence

Unattended CI run — I did not build or execute anything from this PR. The signal below is the PR's own CI, read from the API for the reviewed commit. Zero checks are red. The unit suite, lint, and integration jobs were still in flight when I fetched this once (no polling); the Qwen Triage Finalize job rewrites the table region in place when they settle.

Final CI results for 689bcc2 (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
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
OpenTUI no-flicker gate ✅ success
TUI parity snapshots (ink vs opentui) ✅ success

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

Two of the green checks that matter here are the OpenTUI ones — OpenTUI no-flicker gate and TUI parity snapshots (ink vs opentui) both passed, which is the relevant signal for a PR that edits commands-dispatch.ts, even though that edit is comment-only. The macos-latest and windows-latest unit jobs are skipped by this repo's PR workflow, so CI covers the unit suite on Linux only.

Not verified: the author's "621 config tests and 58 commands-dispatch tests pass" is their own local macOS run, not something I re-ran — the Linux unit job above is what will independently confirm it, and it had not landed at fetch time.

Sandboxed verification would settle the remaining gap: @qwen-code /verify — the central claim is behavioural (an already-aborted joiner fails fast instead of hanging on the foreign flight), and while the new test does pin it, an A/B against the base build would prove the test is load-bearing rather than passing coincidentally, independent of the author's single-platform local run. The author holds admin permission, so this is an ordinary run rather than a sponsored one.

中文说明

代码审查

在读 diff 之前,我先针对那四条线程写下了自己的答案,结果 diff 就是我会写的样子——同样的守卫、同样的位置、同样的两条测试断言。所以这一轮是确认,而不是去找替代方案。

我真正核查过的点,按重要性排列:

debug 面包屑不会在它要照亮的那条路径上崩掉。 这是整个改动里唯一真实的隐患:这条日志写在 join 分支内部,而该分支按定义运行在初始化完成之前debugLogger 是构造函数里赋值的字段(createDebugLogger()config.ts:2508),不是 initializeInternal 才建立的东西,所以对象一存在它就可用。安全。

守卫是复刻主调用方路径,而不是自创语义。 initialize() 在翻转 initialized 之前已经调用了 options?.signal?.throwIfAborted()。把同样两个词加到 join 分支,让两个入口对「信号已中止」的含义达成一致。这类修复就应该是这个形状。

逐个点名下游消费方。 今天真正能走到 join 分支的调用方全都不带 options——slash-dispatch.ts:59(在 loadInteractiveCommands 内)、live-session.ts:563AppContainer.tsx:1108llm.tsx:1350——对它们而言 options?.signal?.throwIfAborted()undefined 上短路,也就是纯粹的空操作。带 options 的调用方(commands/mcp/reconnect.ts:114acpAgent.ts:2756:3929:13968nonInteractive/session.ts:175)各自持有一个全新构造的 Config,因此走的是主调用方路径,根本碰不到新分支。行为变化是真实存在的,但处于潜伏状态——这正是 PR 描述所说的,而我是核实过的,不是照单全收。

R1-3 那条断言确实能杀死变异体。 第一次初始化失败后,finally 会把 initializationSettled 置为 true,而 initializationSucceeded 仍是 false。真实代码:后续 initialize() 抛出 Config was already initialized。变异体(!initializationSucceeded):重新进入 join 分支并 await 那个已存储的 rejection,把过期的启动错误交给调用方。新增的 rejects.toThrow(...) 恰好就是区分这两者的断言,而且放在既有失败测试的末尾,位置正确。

新测试是承重的,构造也正确。 initializeInternal 是真实的私有方法(config.ts:3111,由 :3077initializeOnce 调用),也是本文件各初始化测试通用的 spy 目标——包括相邻那个 join 测试,新测试正是复用了它的 gate 模式。把 throwIfAborted() 那行去掉,joining 就会 await 已释放的 gate 并 resolve,于是 rejects.toBe(abortReason) 失败——这条测试钉住了改动,而不是装饰它。

我还找了这种结构常见的翻车形态:joining 是一个已被拒绝的 promise,却要等到中间那句 await expect(first).resolves.toBeUndefined() 之后才被 await,这可能表现为 unhandled rejection。这里不会——release() 在那个 await 之前就同步解决了 gate,所以 handler 在同一次 microtask 排空过程中就挂上了,而 Node 只在 microtask 耗尽之后才处理待决的 rejection。不是问题;提一下只是因为我查过。

OpenTUI 那处是纯注释改动(增 8 / 删 8),而且现在说的是真话。 ensureCommandsLoadedloadCommandsloadInteractiveCommands,后者的 try { await config?.initialize() } catch { /* proceed with partial commands */ } 就是新注释里点名的「partial-commands catch」。在 join 语义下,第一次初始化成功意味着加入方会 await 完整个飞行过程,因此 loader 运行时 skill manager 已经存在;只有第一次初始化失败才会把 rejection 传进那个 catch。这正是 R1-2 要求的修正,而且保留了「bounded retry 仍不是死代码」这个细微之处。

约定方面干净:ESM、无 anyas unknown as { initializeInternal: ... } 的写法与该文件既有风格一致)、测试与源码同目录、文件名 kebab-case,新增注释只有两处、且都承载着并不显而易见的 why

没有关键阻塞项,也没有违反 AGENTS.md 的地方。我没有要求修改的内容。

测试证据

无人值守的 CI 运行——我没有构建或执行本 PR 的任何代码。下面的信号来自 PR 自身的 CI,通过 API 针对被审查的 commit 读取。没有任何检查是红的。 抓取时(只抓一次,不轮询)单元测试、lint 与集成测试仍在运行中;Qwen Triage Finalize 任务会在它们落定后就地重写上面的表格区域。

这里真正相关的绿色检查中有两个是 OpenTUI 相关的——OpenTUI no-flicker gateTUI parity snapshots (ink vs opentui) 均通过。对一个改动了 commands-dispatch.ts 的 PR 来说这是相关信号,尽管那处改动只是注释。macos-latestwindows-latest 的单元测试任务被本仓库的 PR workflow 跳过,所以 CI 只在 Linux 上覆盖单元测试套件。

未验证:作者所称的「621 个 config 测试与 58 个 commands-dispatch 测试通过」是其本人在 macOS 上的本地运行结果,我没有重跑——上面那个 Linux 单元测试任务才是独立确认它的依据,而抓取时它尚未落定。

沙箱验证可以补齐剩余缺口:@qwen-code /verify —— 核心主张是行为性的(已中止的加入方快速失败,而不是挂在别人的初始化上)。虽然新测试确实钉住了它,但与 base 构建做 A/B 能证明这条测试是承重的、而非碰巧通过,从而不依赖作者单平台的本地运行。作者拥有 admin 权限,因此这是常规运行而非受助运行。

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

Reviewed at 689bcc2b9a144d31cea838682de3664a0c7a5ec4 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — every stage came back clean, and the one hazard in the change (logging from a branch that runs before initialization finishes) turned out to be constructor-safe.

Stepping back: this is what closing review debt properly looks like. Four threads were left unresolved on #11037 when it merged; a human reviewer had already confirmed each one and even handed over a mutation witness and a suggested one-liner. This PR takes exactly that scope, adds three production statements and two documentation corrections, and pins all of it with assertions that fail if you undo the fix. I wrote my own answer to those four threads before opening the diff and arrived at the same change, which is the strongest signal I have that there was no simpler path being missed.

The part I'd want a maintainer to notice is why the small bits earn their place. The docstring line saying a joiner's options are ignored converts an implicit contract into a written one — that's the thing R1-1 was really about, since the silent-drop is only dangerous while it's undocumented. And the OpenTUI comment was describing a throw-then-catch mechanism that no longer exists; a maintainer debugging startup ordering would have reasoned from a fiction. Neither is cosmetic.

What gives me pause is small and I don't think it belongs in this PR. throwIfAborted() catches an already-aborted signal only — a joiner whose signal aborts mid-flight still blocks on the foreign initialization until it settles. That's inherent to the join design that already merged, honoring it would mean racing the foreign promise against the signal, and no in-tree caller passes options on a concurrent join today, so nothing can hit it. Worth an issue if an options-bearing joiner ever appears; not worth widening this diff.

On the "am I being worn down" question: single commit, single PR, follow-up on the author's own merged change, no volume pattern. And I verified the problem exists by reading the four threads rather than accepting the PR's framing — all four are still unresolved on a merged PR.

Verdict: approve, deferred. The unit suite, lint, and integration jobs were still running on this commit when I reviewed, and approving now would attest to a result that doesn't exist yet. Approval is deferred until CI lands green on 689bcc2b9a144d31cea838682de3664a0c7a5ec4; the finalize job posts the commit-pinned approval then, and withholds it if anything lands red or the head moves. Nothing is red as of this writing, and the two OpenTUI gates that matter for the commands-dispatch.ts edit already passed.

中文说明

Confidence: 5/5 —— 各阶段都干净,而改动里唯一的隐患(在初始化完成之前运行的分支里打日志)经核查是构造函数级别安全的。

退一步看:这才是正确清偿评审欠债的样子。#11037 合并时留下四条未解决线程;人工评审者已逐条确认,甚至给出了变异见证和建议的一行修复。本 PR 精确地取了这个范围,新增三条生产语句和两处文档修正,并用「撤销修复即失败」的断言把全部改动钉住。我在打开 diff 之前先自己给出了那四条线程的答案,得到的正是同一个改动——这是我能拿到的最强信号,说明没有被忽略的更简路径。

我希望 maintainer 注意的一点是:那些小改动凭什么占位置。文档注释里「加入方的 options 会被忽略」这句话,把一个隐式契约变成了写下来的契约——这才是 R1-1 真正的要害,因为静默丢弃只有在没被写进文档时才是危险的。而 OpenTUI 那处注释描述的是一个已不存在的「抛错后 catch 继续」机制;排查启动顺序的 maintainer 会基于一个虚构的机制去推理。两者都不是表面功夫。

让我略有保留的地方很小,而且我认为不属于本 PR。throwIfAborted() 只能拦住已经中止的信号——如果加入方的信号在飞行途中才中止,它仍会阻塞在别人的初始化上直到落定。这是已合并的 join 设计的固有特性,要支持它就得拿外部 promise 与 signal 竞速;而今天没有任何在树调用方在并发 join 时传 options,所以碰不到。将来真出现带 options 的加入方,值得开一个 issue;但不值得为此扩大本 diff。

关于「我是不是被磨软了」这个问题:单个 commit、单个 PR、作者对自己已合并改动的后续,不存在批量投稿的模式。而我是通过实际阅读那四条线程来确认问题存在的,不是接受 PR 的叙述——四条在一个已合并的 PR 上仍未解决。

结论:批准,但延后。 我审查时该 commit 上的单元测试、lint 与集成测试仍在运行,此刻批准等于为一个尚不存在的结果背书。批准延后至 CI 在 689bcc2b9a144d31cea838682de3664a0c7a5ec4 上全绿;届时由 finalize 任务发出与该 commit 绑定的批准,若有检查变红或 head 移动则不予批准。截至撰写时没有任何红灯,而与 commands-dispatch.ts 改动相关的两个 OpenTUI 门禁已通过。

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

Reviewed at 689bcc2b9a144d31cea838682de3664a0c7a5ec4 · 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) never ran at this commit and its suite did not run locally.

Not reviewed: reverse audit — stopped before round 4 by the review time budget.

中文说明

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

未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) never ran at this commit and its suite did not run locally.

未审查:反向审计——评审时间预算不足,未能开始第 4 轮。

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

Comment on lines +5183 to +5184
const joining = config.initialize({ signal: controller.signal });
release();

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 'rejects a joining caller whose signal is already aborted' test pins that the joining caller rejects with the abort reason, but not that it rejects fast — release() runs before any assertion on joining, so the fail-before-joining ordering is never checked. If options?.signal?.throwIfAborted() is later moved below await this.initializationPromise, the joiner blocks on the full foreign flight before rejecting with the identical abort reason — reintroducing exactly the "aborted-signal joiner hangs on the first flight" behaviour this PR removes — yet this test still passes green, because release() has already let the first flight settle.

Witness:

Probe matrix at PR HEAD (scratch tree):
  original guard position + shipped test               → PASS
  mutant (guard moved below the await) + shipped test  → PASS  ← mutant survives
  mutant + rejection asserted before release()         → FAIL (Test timed out in 60000ms)
  original guard + rejection asserted before release() → PASS

Assert the rejection while the gate is still held — move await expect(joining).rejects.toBe(abortReason); above release();:

const joining = config.initialize({ signal: controller.signal });
await expect(joining).rejects.toBe(abortReason);
release();
await expect(first).resolves.toBeUndefined();

release() must stay in the test so the existing await expect(first).resolves.toBeUndefined(); still holds — dropping it instead of reordering the assertion would hang the test. If you apply this reorder, please prove it kills the mutant: move (or delete) the throwIfAborted() guard below the await and confirm this test then goes red — with the gate still held, the pre-release() rejection assertion never settles and the test times out.

中文说明

新增的 'rejects a joining caller whose signal is already aborted' 测试只钉住了「加入方会以中止原因 reject」,没有钉住「它 reject 得快」——release() 先于对 joining 的任何断言执行,因此「先失败、后加入」的顺序属性从未被检查。如果日后有人把 options?.signal?.throwIfAborted() 移到 await this.initializationPromise 之后,加入方会先阻塞在别人的完整初始化上、再以同一个 abortReason reject——恰好重新引入本 PR 要消除的「已中止信号的加入方挂在第一次初始化上」行为——而这条测试仍然通过,因为在断言 rejection 之前 release() 已让第一次初始化落定。

见证(PR HEAD 上的探针矩阵,独立 scratch 树):原守卫位置 + 现有测试 → 通过;变异体(守卫移到 await 之后)+ 现有测试 → 通过(变异体存活);变异体 + 在 release() 之前断言 rejection → 失败(60 秒超时);原守卫 + 在 release() 之前断言 rejection → 通过。

修复:在门控仍然持有时断言 rejection——把 await expect(joining).rejects.toBe(abortReason); 移到 release(); 之前:

const joining = config.initialize({ signal: controller.signal });
await expect(joining).rejects.toBe(abortReason);
release();
await expect(first).resolves.toBeUndefined();

前提约束:release() 必须保留,现有的 await expect(first).resolves.toBeUndefined(); 依赖它——只删 release() 而不是调整断言顺序会让测试挂起。修复见证:应用该重排后,请证明它能杀死变异体——把 throwIfAborted() 守卫移到 await 之后(或删除),确认该测试变红:门控仍被持有时,release() 之前的 rejection 断言永远不会落定,测试超时。

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Applied in 82d82c7 — the reorder is exactly as suggested:

const joining = config.initialize({ signal: controller.signal });
await expect(joining).rejects.toBe(abortReason);
release();
await expect(first).resolves.toBeUndefined();

release() stays, so await expect(first).resolves.toBeUndefined() still holds.

On the requested mutation witness: I did not run the mutant probe, so I am not claiming a reproduced red. The argument the reorder rests on is structural — options?.signal?.throwIfAborted() sits ahead of every await in initialize() (config.ts:3013, before await this.initializationPromise), so the joining promise settles on the first microtask and the pre-release() assertion resolves without the first flight finishing. Move the guard below that await and the assertion has nothing to settle it while the gate is held, which is the 60s timeout your matrix recorded. CI on this head is the check that the shipped ordering is green.

The comment above the assertions now says why the order matters, so the next reader does not "tidy" it back.

中文说明

已在 82d82c7 中按建议重排,release() 保留,原有的 await expect(first).resolves.toBeUndefined() 仍然成立。

关于要求的变异见证:我没有跑变异体探针,因此不声称复现了红。重排所依赖的是结构性理由——options?.signal?.throwIfAborted() 位于 initialize() 中所有 await 之前(config.ts:3013,在 await this.initializationPromise 之前),所以加入方的 promise 在第一个 microtask 就落定,release() 之前的断言无需等第一次初始化结束即可完成。把守卫移到该 await 之后,门控仍被持有时该断言就没有任何东西能让它落定,正是你矩阵里记录的 60 秒超时。当前 head 的 CI 负责验证重排后的测试是绿的。

断言上方的注释已写明顺序为什么重要,避免后来者把它「整理」回去。

yiliang114 and others added 2 commits September 5, 2026 19:15
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.

@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.

Scope: packages/core/src/config/config.ts (initialize join path, lines 3013–3022), packages/core/src/config/config.test.ts (two test cases), packages/cli/src/ui/opentui/commands-dispatch.ts (comment-only).

What I checked:

  • Config.initialize() join path (config.ts:3011–3023): throwIfAborted() is correctly placed before the first await in the join branch — the fast-fail fires synchronously on the joining caller's promise, before any yield to the microtask queue, so the "already-aborted signal blocks on the foreign flight" regression is structurally impossible with the guard in this position.
  • ConfigInitializeOptions interface (config.ts:1563–1578): signal is typed AbortSignal | undefined; throwIfAborted() is a standard AbortSignal method. The ?. chain is correct for the optional case.
  • Post-failure path: after a failed first flight, initializationSettled is true (set in the finally block at line 3036), so a subsequent caller reaches throw Error('Config was already initialized') instead of re-joining the stale rejected promise. The new assertion at config.test.ts:5212–5216 pins this.
  • New test 'rejects a joining caller whose signal is already aborted' (config.test.ts:5160–5189): at the current HEAD (f7e45dc), await expect(joining).rejects.toBe(abortReason) appears before release(). This ordering property is load-bearing — it proves the guard fires before await initializationPromise rather than after — and the comment above the assertions (config.test.ts:5183–5185) documents why the order must not be changed.
  • OpenTUI comment (commands-dispatch.ts:342–351): comment-only; accurately describes the new join semantics (no longer references the removed throw-then-catch mechanism).
  • Cross-check vs. prior review (qwen-code-ci-bot, round 1 on 689bcc2): R1-1 (test assertion ordering) is confirmed fixed at the current head. The author applied the exact reorder suggested.

Not covered: no local test run available; the PR description notes commit 82d82c7 was not re-run locally. The structural argument for assertion ordering correctness is sound (guard is before the first await), and the fix is a reorder of two lines with no logic change. CI on this head is the check the author identified.

Reviewed with AI assistance.

@qwen-code-dev-bot qwen-code-dev-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.

Approved at head f7e45dc5.

History. The single open thread (Suggestion R1-1, about the test not pinning how fast the joiner rejects) is addressed on this head: 82d82c7 is an ancestor of f7e45dc5 and the rejection is now asserted while the gate is still held. I reproduced the reviewer's mutant rather than taking that on faith — on a scratch tree I moved options?.signal?.throwIfAborted() below await this.initializationPromise and ran the test: it fails with Test timed out in 8000ms. With the guard where it is now, the same test passes, as does the new later call must throw assertion added to shares a failed in-flight initialization with concurrent callers. So the test pins the ordering property, not just the rejection value.

Independent check. The only behavior change is that a joiner whose signal is already aborted rejects with its own reason instead of waiting out somebody else's flight — which is the point, since a joining caller's options cannot be honored anyway, and the docblock now says so plainly ("the first caller's options win"). Non-aborted joiners, the settled path, and the initialized / shutdownRequested / derived-config throws are untouched; the commands-dispatch.ts hunk is comment-only and its rewritten wording matches the join semantics that landed in #11037. No new Critical found.

CI facts. Lint & Static, Test (ubuntu-latest) and Integration Tests (no-AK) were still queued on this head when I submitted, with the five reported checks green and nothing red — stating it rather than implying a full-green lane.

@yiliang114
yiliang114 enabled auto-merge September 5, 2026 11:24

@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 f7e45dc5 (the merge-from-main delta touches nothing this PR owns). No historical blocking issues exist on this PR — the single round-1 Suggestion (the aborted-joiner test not pinning rejection speed) was resolved in 82d82c7 with the assertion moved before the gate release — and my independent Critical-only pass finds none.

The change, verified at this head:

  • config.ts join branch: options?.signal?.throwIfAborted() now runs before the await this.initializationPromise, so a joining caller holding an already-aborted signal fails fast with its own abort reason instead of blocking on a foreign flight whose options it can never have honored; the first-flight path keeps its own pre-set guard, so non-joining callers are unaffected. The added debugLogger.debug breadcrumb is constructor-safe (the logger exists before initialization completes), and the docstring now states the joiner-options contract ("the first caller's options win") that the #11037 round had asked to make explicit.
  • config.test.ts: the new aborted-joiner test asserts the rejection while the gate is still held — pinning fail-before-joining placement, not just the rejection value (the post-await-guard mutant that motivated the reorder cannot pass it); and the failed-flight test now additionally asserts that a call after a failed-and-settled first flight throws "Config was already initialized" rather than re-joining the stale rejection — closing the last unpinned half of the settle contract from the previous PR's round.
  • commands-dispatch.ts: comment-only — the startup-window self-heal rationale is updated to the join semantics that actually shipped (only a failed first flight can still land the loader in the partial-commands catch); no code change.

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

@yiliang114
yiliang114 added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 82612e3 Sep 5, 2026
77 of 79 checks passed
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.

5 participants