Skip to content

ci(review): switch online hk1 and hk2 runners between review and CI twice daily - #11855

Merged
yiliang114 merged 19 commits into
mainfrom
ci/review-runner-schedule
Sep 15, 2026
Merged

yiliang114 merged 19 commits into
mainfrom
ci/review-runner-schedule

Conversation

@yiliang114

@yiliang114 yiliang114 commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Moves automatic PR reviews to a dedicated ecs-review pool and switches online runners named ecs-qwen-hk1-<number> or ecs-qwen-hk2-<number> between review and CI twice daily. The scope is determined by runner name and online status, not by existing labels; Other hosts are unchanged.

Time (Asia/Shanghai) Add Remove
17:00 ecs-review ecs-qwen
05:00 ecs-qwen ecs-review

Each execution processes all matching online runners concurrently through per-runner label API calls. It preserves unrelated labels and leaves running jobs uninterrupted. Offline runners are skipped. Manual dispatch selects review or ci; repeating the same selection makes no changes once labels match.

Reviews queue while no ecs-review runner is available. Autofix execution jobs now require ecs-autofix. Provision this label on online hk1/hk2 runners; the daily switch preserves it so autofix remains available all day. Autofix waits only for an IN_PROGRESS review job, not a queued review or a workflow waiting before the job starts. The review acknowledgement reports the request as queued rather than running. Reviews that never started post an accurate notice without assuming a 24-hour queue expiry.

Why it's needed

Reviews currently share runner capacity with autofix. Switching hk1 and hk2 between daytime CI and evening reviews schedules review eligibility and concentrates new reviews in the cheaper evening model API window.

Reviewer Test Plan

How to verify

After merge, manually select review, then ci, and compare the workflow summary with Settings → Actions → Runners. Only online ecs-qwen-hk1-<number> or ecs-qwen-hk2-<number> runners should change. Check that unrelated labels survive, offline runners and other hosts remain unchanged, busy runners finish their existing jobs, and repeating a selection produces no changes.

Evidence (Before & After)

Before: automatic reviews use ecs-agent, sharing the autofix pool. After: automatic reviews require ecs-review, and two daily switches assign online hk1/hk2 runners to review or CI.

Local planner and workflow tests passed, covering a fixture with 32 runners (30 online, two offline), both switch directions, busy runners, idempotence, name matching, and workflow wiring. Targeted ESLint and Prettier checks passed. The configured PAT passed a live temporary-label add/delete probe with original labels restored. A complete workflow-driven pool switch, including environment-secret injection, has not been run.

Tested on

OS Status
macOS Local planner/wiring and review-routing tests, targeted ESLint and Prettier passed.
Linux Earlier CI unit and no-AK integration tests passed. The YAML quoting failure was fixed in 61bc697; current-head CI is pending. Full local yamllint passed after the hk1/hk2 change.
Windows Desktop Shell CI passed; full Windows tests were skipped. No local verification.

Risk & Scope

  • The workflow reads RUNNER_ADMIN_PAT from the qwen-review-runner-schedule environment, restricted to main. The secret is configured and its label-write permission was tested. The current classic PAT uses repo scope and expires on 2026-10-14; it needs renewal before expiry. A fine-grained replacement requires repository Administration write permission.
  • Before merge, add ecs-autofix to online hk1/hk2 runners and their host-side registration configuration. Do not restore ecs-agent: old queued reviews still request it and would start immediately. After merge, dispatch the current pool once (ci at 05:00–17:00 Asia/Shanghai, review otherwise). Cancel and create fresh autofix runs against the merged workflow; rerunning an old workflow revision does not migrate its labels. Retire/re-request old review runs during the evening window. Merging enables the daily schedule; these production migration steps have not been executed.
  • Scheduled runs may be delayed or skipped. API failures fail the run and identify affected runners in the summary. Use manual dispatch to recover; there is no periodic reconciliation. A runner skipped while offline retains its labels when it returns and is handled by the next scheduled or manual switch.
  • Measured against the live fleet on 2026-09-15 (167 runners): exactly 64 match ^ecs-qwen-hk[12]-\d+$ (62 online), and none of them currently carries ecs-qwen, ecs-review or ecs-agent. The 93 runners carrying ecs-qwen are hk3/hk4/hk5. So the 05:00 switch does not return hosts to CI, it adds 62 of them: the ecs-qwen pool goes 93 -> 155. Their names already match ci.yml's startsWith(runner.name, 'ecs-qwen-') tuning, but nothing here verifies they are provisioned like hk3/hk4/hk5 — confirm that before the first ci switch, and expect the first review switch (or a manual dispatch) to be a hard prerequisite, since until then no runner carries ecs-review and every review queues.
  • All matching online runners become eligible together. hk1 and hk2 are two hosts running multiple runners. Running reviews may continue past 05:00; the schedule controls eligibility for new jobs, not a hard execution cutoff. Queued reviews can expire after 24 hours.
  • MAINTAINER_ECS_RUNNER_DISABLED=true keeps the hosted-runner override for reviews. Disabling the schedule stops future switches but does not restore current labels.

Linked Issues

Related: #11857 (unchanged-diff review skipping).

中文说明

改动

自动 PR review 改用独立的 ecs-review 池,每天两次将名称为 ecs-qwen-hk1-<数字>ecs-qwen-hk2-<数字> 的在线 runner 在 review 和 CI 之间切换。按名称和在线状态识别目标,不依赖现有标签;其他主机不变。

时间(北京时间) 添加 移除
17:00 ecs-review ecs-qwen
05:00 ecs-qwen ecs-review

每次通过各 runner 的标签 API 并发处理全部匹配的在线 runner,保留无关标签,不打断正在执行的任务,跳过离线 runner。手动运行可选择 reviewci;标签已符合目标时,重复执行不产生修改。

没有可用的 ecs-review runner 时,review 排队等待。Autofix 执行 job 改用 ecs-autofix;需要给在线 hk1/hk2 添加该标签。每日切换保留它,使 autofix 全天可用。Autofix 仅等待 IN_PROGRESS 的 review job,不等待排队或尚未启动 job 的 workflow。Review 确认提示改为已排队,而不是正在运行。从未被 runner 启动的 review 会收到准确的结束提示,不再推断其排队满 24 小时。

原因

目前 review 与 autofix 共用 runner 容量。让 hk1、hk2 白天跑 CI、晚间跑 review,控制 review 的可调度时段,并使新 review 集中在模型 API 更便宜的晚间启动。

Reviewer 验证计划

如何验证

合并后先手动选择 review,再选择 ci,对比 workflow summary 与 Settings → Actions → Runners。只有在线的 ecs-qwen-hk1-<数字>ecs-qwen-hk2-<数字> runner 应发生变化。确认无关标签保留、离线 runner 和其他主机不变、忙碌 runner 正常完成当前任务,重复选择同一模式不产生修改。

前后对比与证据

改动前:自动 review 使用 ecs-agent,与 autofix 共池。改动后:自动 review 要求 ecs-review,每天两次切换将在线 hk1/hk2 runner 分配给 review 或 CI。

本地调度规划和 workflow 测试通过,覆盖 32 个 runner 的测试数据(30 个在线、两个离线)、双向切换、忙碌 runner、幂等、名称匹配和 workflow 接线。相关 ESLint、Prettier 检查通过。配置的 PAT 已通过真实临时标签添加/删除测试,原标签已恢复。尚未执行包含 environment secret 注入在内的完整 workflow 整池切换。

测试平台

系统 状态
macOS 本地调度/接线、review 路由测试及相关 ESLint、Prettier 通过。
Linux 此前 CI 单元测试和 no-AK 集成测试通过。YAML 引号问题已在 61bc697 修复;当前提交的 CI 待确认。hk1/hk2 修改后,本地完整 yamllint 通过。
Windows Desktop Shell CI 通过;完整 Windows 测试被跳过。未在本地验证。

风险与范围

  • Workflow 从仅允许 mainqwen-review-runner-schedule environment 读取 RUNNER_ADMIN_PAT。Secret 已配置,标签写权限已测试。当前 Classic PAT 使用 repo scope,2026-10-14 到期,需要提前续期;替换为细粒度 PAT 时需要仓库 Administration 写权限。
  • 合并前给在线 hk1/hk2 添加 ecs-autofix,并同步主机注册配置。不要恢复 ecs-agent,否则旧 review 排队任务会立即启动。合并后按当前时段手动切换一次(北京时间 05:00–17:00 选 ci,其余时间选 review)。取消旧 autofix 任务后,基于合并后的 workflow 创建新运行;直接重跑旧 workflow 版本不会迁移标签。旧 review 在晚间清理并重新请求。合并即启用每日定时切换;这些线上迁移步骤尚未执行。
  • 定时执行可能延迟或漏跑。API 失败会令运行失败,并在 summary 中标明受影响的 runner。通过手动运行补执行,没有定期纠偏。离线时被跳过的 runner 恢复上线后保留原标签,等下一次定时或手动切换再处理。
  • 2026-09-15 实测真实机群(167 台):匹配 ^ecs-qwen-hk[12]-\d+$ 的正好 64 台(62 台在线),且它们目前都不带 ecs-qwenecs-reviewecs-agent;带 ecs-qwen 的 93 台是 hk3/hk4/hk5。因此 05:00 的切换不是把主机还给 CI,而是新增 62 台,ecs-qwen 池从 93 变 155。它们的名字已经匹配 ci.ymlstartsWith(runner.name, 'ecs-qwen-') 调优,但这里没有任何东西证明其预配与 hk3/hk4/hk5 一致——请在第一次 ci 切换前确认;同时第一次 review 切换(或手动 dispatch)是硬前提,在此之前没有任何 runner 带 ecs-review,所有 review 都会排队。
  • 所有匹配的在线 runner 一起开放。hk1、hk2 是两台各运行多个 runner 的共享主机。已启动的 review 可以持续到 05:00 之后;时间表控制新任务的调度资格,不强制终止执行。排队的 review 可能在 24 小时后过期。
  • MAINTAINER_ECS_RUNNER_DISABLED=true 仍可让 review 使用 hosted runner。禁用定时 workflow 只停止未来切换,不会恢复现有标签。

相关 Issue

相关:#11857(diff 未变时跳过 review)。

…nged-diff re-reviews

The automatic PR review runs one full high-effort review per push: on
2026-09-08/09 that was 755 review-pr jobs (~2,050 runner-hours) across
204 PRs, 95% push-triggered, and 32% of them on a head whose only change
was a merge of main. The model API is priced by time of day, so the pool
now follows the clock and identical diffs are not reviewed twice.

- review-pr moves from the shared `ecs-agent` pool to a new `ecs-review`
  label. A new scheduled workflow (qwen-review-runner-schedule.yml +
  review-runner-schedule.mjs) converges the labels on the managed hosts
  every 15 minutes: QWEN_REVIEW_DAY_RUNNERS by day, QWEN_REVIEW_NIGHT_RUNNERS
  inside [NIGHT_START, NIGHT_END) Asia/Shanghai, ramped by RAMP_STEP per
  tick. Shrinking releases idle runners first and never interrupts a
  running review; released runners are lent to `ecs-qwen` (CI) by day;
  `ecs-agent` is kept off the managed hosts. Every knob is a repository
  variable; `QWEN_REVIEW_SCHEDULE_MODE=off` is the kill switch. A missing
  admin PAT fails the tick loudly instead of freezing the labels.
- Unchanged-diff skip: an automatic `synchronize` run whose diff against
  the base is byte-identical (sha256 of the full-index diff) to an
  already-reviewed first-parent ancestor exits before the agent starts.
  The anchor is a `qwen-review/reviewed` commit status written by a new
  "Record reviewed head" step with the workflow token, outside the agent
  step — not a bot comment (R13-1) and not the push's `before` sha, whose
  hole (real change superseded by a merge push) the script documents and
  the test pins.
- fallback-comment stays silent when review-pr was never picked up by a
  runner (GitHub's 24h queue expiry): nothing ran, so "retry with a
  longer timeout" would mislead.

Local checks: node --test on the two new suites (24 pass), yamllint,
prettier --experimental-cli. vitest and eslint were not run locally;
CI confirms.
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

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

@yiliang114 holding this at the gate on the PR template, before any code review.

The body doesn't use the repo template at all — it's a free-form Chinese design note (背景 / 改动 / 合并前需要你做的 / 上线顺序 / 容量预期与调参 / 已知取舍 / 验证). None of the required headings from .github/pull_request_template.md are present:

  • ## What this PR does
  • ## Why it's needed
  • ## Reviewer Test Plan — with ### How to verify, ### Evidence (Before & After), ### Tested on
  • ## Risk & Scope
  • ## Linked Issues
  • the <details><summary>中文说明</summary> block (the template is English first, Chinese folded)

To be clear, the substance is good. The measured baseline (755 runs / ~2,050 runner-hours / median 152 min), the 32% unchanged-diff figure, the rollout order and the known-tradeoffs list are exactly what a reviewer needs, and more than most PRs bring. This is restructuring, not new writing: 背景 → Why it's needed, 改动 → What this PR does, 已知取舍 → Risk & Scope, 验证 → How to verify.

Three gaps the template would have surfaced, which matter more than the heading names:

  1. Evidence (Before & After) would be empty. ## 验证 says vitest and eslint weren't run locally and leaves them to CI. For a change that decides which runners pick up review jobs — and that skips reviews entirely when a diff hash matches — what should a reviewer look at on the first manual dispatch to know it worked? A dry_run step summary pasted as before/after would settle it.
  2. Tested on has no OS row filled in.
  3. Risk & Scope is where the pre-merge manual steps belong. Creating a fine-grained PAT with Administration: Read and write on this repo, storing it as RUNNER_ADMIN_PAT in a new environment, and editing hk2's config.sh --labels are currently under "合并前需要你做的" — a heading that doesn't prompt anyone to read them as risk. A repo-administration credential is the highest-blast-radius item in this PR, and the single-machine concentration on hk2 (your own 已知取舍 note: hk2 down → all reviews stop) belongs in the same place.

Once the body follows the template, re-run with @qwen-code /triage and the full staged review (gate → code review + CI evidence → verdict) will run against the same head. Nothing here is a judgement on the approach — it hasn't been reviewed yet.

中文说明

@yiliang114 这个 PR 先卡在模板这一关,还没进入代码审查。

PR 正文完全没有使用仓库模板,而是一篇自由格式的中文设计说明(背景 / 改动 / 合并前需要你做的 / 上线顺序 / 容量预期与调参 / 已知取舍 / 验证)。.github/pull_request_template.md 要求的标题一个都没有:

  • ## What this PR does
  • ## Why it's needed
  • ## Reviewer Test Plan —— 含 ### How to verify### Evidence (Before & After)### Tested on
  • ## Risk & Scope
  • ## Linked Issues
  • <details><summary>中文说明</summary> 折叠块(模板是英文在前、中文折叠)

先说清楚:内容本身是好的。实测基线(755 次 / 约 2,050 runner-小时 / 中位数 152 分钟)、32% 的 diff 未变比例、上线顺序、已知取舍清单,正是 reviewer 需要的东西,比大多数 PR 都充分。所以这次主要是重排结构,不是重写:背景 → Why it's needed,改动 → What this PR does,已知取舍 → Risk & Scope,验证 → How to verify。

模板本会暴露出三个缺口,比标题名称本身更重要:

  1. Evidence (Before & After) 会是空的。## 验证 里写了本地没跑 vitest 和 eslint、交给 CI 确认。但这是一个决定「哪些 runner 接 review 任务」、并且在 diff 哈希相同时直接跳过 review 的改动 —— 第一次手动 dispatch 之后,reviewer 该看什么才能确认它按预期工作?把 dry_run 的 step summary 贴成 before/after 就能说清楚。
  2. Tested on 的系统表格一行都没填。
  3. Risk & Scope 才是那些合并前手工步骤该待的地方。创建 fine-grained PAT 并授予本仓库 Administration: Read and write、把它作为 RUNNER_ADMIN_PAT 存进新 environment、以及改 hk2 的 config.sh --labels,现在都放在「合并前需要你做的」下面 —— 这个标题不会提示任何人把它们当成风险来读。仓库管理级凭据是这个 PR 里影响面最大的一项;同样地,你自己在已知取舍里写到的 hk2 单机集中(hk2 挂了 review 就全停)也应该放在同一处。

正文按模板补齐之后,用 @qwen-code /triage 重跑,完整的分阶段审查(gate → 代码审查 + CI 证据 → 结论)会针对同一个 head 执行。以上都不是对技术方案的评价 —— 方案还没被审。

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

…the unchanged-diff skip

- Move the unchanged-diff skip (script, tests, review-pr changes) to its
  own PR; this one is now only the time-of-day review pool.
- Rewrite the schedule as a small script with three repository
  variables (QWEN_REVIEW_NIGHT_START / _END / _DAY_RUNNERS). Hosts (hk2),
  time zone, ramp (8 per tick) and lending the day's spare runners to
  CI are fixed; pausing is disabling the workflow.
- Fix the runner listing: gh refuses --slurp together with --jq, so the
  previous version would have failed on every tick. Pages are now
  parsed in the script; a read-only dry run against the live runner
  list plans the expected changes.
- fallback-comment: treat a never-started review-pr as nothing-ran
  whether GitHub reports the expired job as failure or cancelled.
yiliang114 added 3 commits September 14, 2026 22:37
…note

- The schedule job references its environment only for the PAT secret;
  `deployment: false` stops it from creating a deployment every 15
  minutes (96 a day in the Deployments list).
- Correct the fallback-comment note: the comment it suppresses says the
  review pipeline failed, not "retry with a longer timeout".
- Name the checkout step; state QWEN_REVIEW_DAY_RUNNERS' default and
  that unset or malformed variables fall back to defaults.
QWEN_REVIEW_NIGHT_START 22 -> 17, QWEN_REVIEW_NIGHT_END 3 -> 5,
QWEN_REVIEW_DAY_RUNNERS 2 -> 0: no hk2 runner carries ecs-review
between 05:00 and 17:00 (all of them serve CI), and every one does in
the evening window. The repository variables still override these.
The three schedule values live in QWEN_REVIEW_NIGHT_START /
QWEN_REVIEW_NIGHT_END / QWEN_REVIEW_DAY_RUNNERS (set to 17 / 5 / 0) and
the script no longer carries defaults. An unset or out-of-range
variable fails the run naming it, instead of silently applying a value
nobody configured. "0" is a valid value and parses as 0 (no truthiness
test anywhere on the path); a test pins it.
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Addressed the template gate from the review on 8d134b638e. Current head is 99a7b4bbd0.

  • Template: the body now follows .github/pull_request_template.md, English first with the Chinese folded.
  • Evidence (Before & After): filled with a read-only dry run of the planner against the live runner list: before, 30 online hk2 runners carrying ecs-agent; after, the day and first evening-tick label plans. The workflow no longer has a dry_run input, because the PR was cut down to the label toggle; the step summary of the first manual run prints the same plan.
  • Tested on: filled.
  • Risk & Scope: now leads with the repository-administration PAT (and narrower alternatives) and the single-host concentration on hk2, and holds the manual pre-merge steps.
  • Scope: the unchanged-diff skip this review mentions has moved to ci(review): skip re-reviewing a push whose diff is unchanged #11857; this PR is only the time-of-day runner pool.
中文说明

已处理针对 8d134b638e 的模板关卡意见。当前 head 为 99a7b4bbd0

  • 模板: 正文已按 .github/pull_request_template.md 编写,英文在前,中文折叠。
  • 证据(前后对比): 已填入用实时 runner 列表对规划逻辑做的只读演练:之前是 hk2 的 30 台在线 runner 都带 ecs-agent;之后是白天和傍晚第一个 tick 的标签计划。PR 已经缩减为只做标签开关,所以 workflow 不再有 dry_run 输入;第一次手动运行的 step summary 会打印同样的计划。
  • 测试平台: 已填写。
  • 风险与范围: 现在首先列出仓库管理级 PAT(以及权限更窄的替代方案)和 hk2 单机集中的风险,合并前的手工步骤也放在这里。
  • 范围: review 中提到的 diff 未变跳过已移到 ci(review): skip re-reviewing a push whose diff is unchanged #11857;本 PR 只包含按时段调整的 runner 池。

The defaults (QWEN_REVIEW_NIGHT_START=17, QWEN_REVIEW_NIGHT_END=5,
QWEN_REVIEW_DAY_RUNNERS=0) now sit in the workflow as
`vars.X || '<default>'`; a repository variable of the same name
overrides each. The script still holds no values of its own and still
fails on a malformed one. `||` falls back only on an unset variable:
variable values are strings and "0" is non-empty, so an override of 0
holds. Test data uses the same 17-5 window.
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@yiliang114 yiliang114 changed the title ci(review): size the review runner pool by time of day and skip unchanged-diff re-reviews ci(review): size the review runner pool by time of day Sep 14, 2026
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 384 passed · 0 failed · 384 total

Flakiness gate: ✅ 2 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:384 通过 · 0 失败 · 384 总计

抖动门:✅ 2 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #11855 deep verification — ci(review): size the review runner pool by time of day

Verdict: findings — 384 scripted assertions ran, 384 passed / 0 failed. The mechanism is correct and proven load-bearing, but the PR's capacity arithmetic does not balance as merged, and the change removes the only PR-visible signal that would have told anyone.

Verified head: 25abcc9b48209ac02a89a74a1cad931061ea68d4 (git rev-parse HEAD^2; matches headRefOid in the metadata snapshot). Base: 87437db784f6ffbdb725b46b4a5fdd5fbac9cd74. Effective diff: git diff HEAD^1..HEAD, 7 files, +407/−5.

中文摘要

结论:findings(有发现,需要评审人注意) — 共执行 384 条脚本断言,全部通过(384 pass / 0 fail)。调度机制本身正确,且经 A/B 证明是"起作用的",但 PR 的容量算术在合并当下并不成立,而且这个改动恰好移除了唯一能让外部察觉问题的信号。

A/B 结论

  • 用 PATH 上的假 gh(有状态、按 gh api --helpkey[]=value 语义实现)加上固定时钟的 preload,驱动未经修改的真实脚本跑了 48 小时 / 192 个 tick 的闭环仿真。PR 描述里"傍晚第一个 tick"的证据被逐字复现:8 × (+ecs-review −ecs-agent)22 × (+ecs-qwen −ecs-agent),60 次标签 API 调用,每次都带上了 PAT。见下方 "A/B table" 与 03-ab-planner-replays-pr-evidence.png
  • fallback-comment 的新增 bash/jq 段落用 YAML 解析器逐字提取(head 154 行 vs base 139 行),在步骤自身的 shell 约定下对 base 和 head 两条臂各跑一遍。恰好 4 个 cell 从"发评论"翻转为"不发",全部是 review-pr 从未被领取的情形;所有对照组(已启动后失败、已启动后取消、runner 已分配但 0 步、两个 review-pr 其中一个启动、jobs API 不可读、列表里没有 review-pr)行为完全不变。见 01-ab-fallback-comment-base-vs-head.png
  • 变异矩阵 28 个单点变异:18 个被随 PR 提交的测试杀掉,10 个存活;存活的 10 个里有 9 个被 Harness A 抓到(即"行为确实错了,但测试沉默"),1 个(M19)经单独证明是冗余防御而非缺陷。见 02-mutation-matrix-28-mutants.png

主要发现

  1. 容量算术量纲不一致(最重要)。 风险段落把 384 runner-hoursdemand of about 375 并列,但 375 是次数不是小时数。按 PR 自己给的中位数 152 分钟换算,375 次 ≈ 950 runner-hours;PR 自己测到的 runner 时间是 ~1,025 小时/天。考虑每 tick 最多 +8 台的爬坡后,实际供给是 372 小时(不是 384,满池要到 18:00 才达到)。即供给只覆盖实测需求的 ~36%,每晚约 147 次 review 能启动,而到达量约 378 次/天 —— 约 61% 的 review 会在 GitHub 24 小时排队上限处被丢弃,而不是被延后。该结论只有在"每个 PR 每晚一次 review"(≈258 小时)的口径下才平衡,而那需要 ci(review): skip re-reviewing a push whose diff is unchanged #11857 —— PR 明确说明它已被拆出。
  2. 抑制评论后,PR 侧不再有任何信号。 新增守卫在 review-pr 从未启动时不再发评论,这对"等今晚再跑"是正确的;但同一个信号无法区分"等今晚"和"review 已经全面停摆"(PAT 过期、workflow 被禁用、hk2 宕机、或上面第 1 条的容量缺口)。已核实 qwen-fleet-shepherd.yml 只看 autofix 的 run,没有任何看门狗覆盖 review-pr 的排队健康度。
  3. 测试覆盖缺口:main() 完全没有被测。 随 PR 提交的 6 个测试只覆盖纯函数和一处 YAML 字符串匹配。7 个存活变异(M14–M18、M20、C01、C02)都落在 main() 里,包括那条保护 hk3/hk4/hk5 CI 池不被误改标签RUNNER_NAME 过滤(M14)。按本仓库规则这属于 Suggestion。
  4. 无法在此环境核实的残余风险:ecs-qwen-hk2-<n> 名称正则。 今天实测到的全部 31 台自托管 runner 都是 ecs-qwen-hk{3,4,5}-<n> 形态,命名约定与该正则一致;但 hk2 属于 ecs-agent 池、不跑 CI job,因此无法从公开 job 数据观测到,而枚举 runner 列表恰好需要本 PR 才引入的那个 PAT。已界定其失败模式是响亮失败(S7 证明:exit 1、no ecs-qwen-hk2-<n> runner found、不写任何标签),不是静默误改。

未覆盖范围:无法枚举真实 runner 列表(需 PAT);6 个 commit 中只有 1 个在浅克隆里可达,因此只验证了聚合 diff;actionlint / yamllint / shellcheck 未安装且未联网安装;GitHub 的 timeout-minutes 是否计入排队时间未获文档明确;autofix 只剩 hk1 的容量影响无法测量。

Central claim and A/B

Central claim. Review capacity follows the clock: review-pr asks for a new ecs-review label, and a 15-minute schedule converges the hk2 fleet so the number of ecs-review runners equals every online runner inside [17:00, 05:00) Asia/Shanghai and QWEN_REVIEW_DAY_RUNNERS outside it, growing by at most RAMP_STEP (8) per tick and never leaving ecs-agent on hk2.

Secondary claims. (a) fallback-comment stops posting "review did not complete" when review-pr was never picked up by a runner. (b) A malformed schedule variable fails the run and names the variable, while 0 is a valid, retained value.

The base tree contains no planner at all, so a base-vs-head A/B on the planner can only prove the file is new. The load-bearing proof is therefore two-sided: a closed-loop simulation of the real script (the observable oracle is the exact sequence of runner-label API calls it decides to make), and a 28-mutant matrix showing each guard flips an observable. For the fallback-comment claim a true base-vs-head A/B was possible and was run.

A/B table

# Cell Environment Observable oracle Base Head
A-S1 Evening first tick, the PR's own dry-run scenario (32 registered / 30 online / 21 busy, all ecs-agent) real script, fake stateful gh on PATH, clock pinned to 17:00 CST full API call log + step summary n/a (no script) 8 × (+ecs-review −ecs-agent), 22 × (+ecs-qwen −ecs-agent), 60 calls, target 30 — reproduces the description exactly
A-S2 Ramp over consecutive ticks same, 17:00→18:15 review-pool size per tick n/a 8, 16, 24, 30, 30, 30 — never exceeds RAMP_STEP
A-S3/4 Day shrink to 0 / to 2 with busy runners same, 12:00 CST final label state n/a all 30 → ecs-qwen; at target 2 the two busy runners keep ecs-review
A-S5 Fence check with real production runner names (ecs-qwen-hk3-4, hk4-23, hk5-31, …) mixed into the fleet same, 23:00 CST per-runner label diff + call targets n/a 9 real CI-pool runners keep byte-identical labels; 0 API calls reference a non-hk2 id
A-S12 48 h sweep, 192 ticks at 15 min same 5 invariants per tick n/a 0 violations; max growth 8/tick; a converged tick costs exactly 1 API call
B fallback-comment, never-started + cancelled/failure verbatim step extracted by YAML parser, bash --noprofile --norc -e + the step's own set -uo pipefail, fake gh answering through the real jq was gh pr comment invoked? posted (4/4 cells) suppressed (4/4 cells), exit 0, 1 API call
B-ctl 8 control cells (started-then-failed, started-then-cancelled, runner assigned but 0 steps, two review-pr jobs one started, jobs API 500, no review-pr in list, result=skipped, result=success) same same posted / not-entered identical to base
B-cal Real production payload: run 32875478404, the run this workflow's own comments cite for issue #10109review-pr cancelled 2 min in, runner_name: "ecs-qwen-runner-64c-12", 18 steps same same posted the cancellation body posted the cancellation body — the guard does not swallow a review that actually ran
C 28 single-point mutants + 2 combination rows scratch worktree at HEAD, shipped suite then Harness A killed / survived 18 killed, 10 survived; 9 of 10 survivors caught by Harness A, 1 proven redundant

Witnesses: 03-ab-planner-replays-pr-evidence.png (cells A-S1/A-S2 as they printed), 01-ab-fallback-comment-base-vs-head.png (the B flips), 02-mutation-matrix-28-mutants.png (the C matrix).

Mutation matrix (28 mutants, all classified)

id mutation shipped suite Harness A classification
M01 isNight midnight wrap → same-day conjunction killed pinned
M02 isNight start===end returns true killed pinned
M03 intVar drops the <= max range check killed pinned
M04 intVar tests truthiness (0 becomes invalid) killed pinned
M05 intVar accepts a leading sign killed pinned
M06 RAMP_STEP 8 → 1000 killed pinned
M07 shrink keeps idle review runners killed pinned
M08 grow promotes busy runners first killed pinned
M09 planLabels includes offline runners killed pinned
M10 ecs-agent kept on hk2 killed pinned
M11 released runners never lent to CI killed pinned
M12 name sort lexical instead of numeric survived caught (1 fail) coverage gap — both orderings are deterministic, so impact is only which runners, not stability
M13 hourIn hourCycle h23 → h24 killed pinned
M14 main() drops the RUNNER_NAME scope filter survived caught (6 fails) coverage gap — this is the fence protecting the hk3/hk4/hk5 CI pool
M15 main() drops the empty-fleet throw survived caught (2) coverage gap
M16 main() drops the RUNNER_ADMIN_TOKEN check survived caught (3) coverage gap
M17 main() drops the variable-validation throw survived caught (27) coverage gap
M18 main() drops the label-failure throw survived caught (1) coverage gap
M19 main() drops Math.min(dayRunners, online) survived not caught redundant defence — see below
M20 main() inverts the night test survived caught (25) coverage gap
M21 main-guard always fires on import killed pinned
M22 workflow NIGHT_START default 17 → 7 killed pinned
M23 workflow DAY_RUNNERS default 0 → 30 killed pinned
M24 workflow stops passing the PAT killed pinned
M25 review-pr runs-onecs-agent killed pinned
M26 review-pr runs-onecs-qwen killed pinned
C01 M14 + M15 together (the name-scope set) survived caught (6) coverage gap; the set is load-bearing, neither hunk alone is redundant
C02 M16 + M17 + M18 together (the fail-loud set) survived caught (31) coverage gap; same reading

Positive controls. The unmutated worktree suite is green (6/6) under the identical command. 18 mutants were killed by that same suite, including M25/M26 which are killed by the wiring test itself — so "survived" means the suite does not assert that axis, not that the harness never ran.

M19 adjudicated independently (Harness D, 31 assertions): across 6 scenarios where DAY_RUNNERS exceeds the online count (5 online / 999, 5 online / 7, 5-of-8 online / 999, all-already-review / 999, mixed / 999, and a night cell), the pristine and mutant builds produced identical exit codes, identical wire records (same calls, same order, same bodies) and identical final label state. The clamp is redundant because planLabels slices grow from the online runners that lack the label, so an oversized target still selects the whole fleet. It is not dead in the strictest sense: it is the only thing keeping the printed target number in the step summary truthful (pristine prints target 5, mutant prints target 999). Correct as it stands.

Vacuity checks

test revert applied result
scripts/tests/qwen-pr-review-workflow.test.js › "isolates the long-running review job on the schedule-sized review pool" review-pr runs-on reverted ecs-reviewecs-agent in a scratch copy 1 failed / 259 skipped (260), AssertionError: expected '${{ (github.repository == …' to be … — the intended expected-vs-actual mismatch, not an import or fixture break. Tree restored; git status --porcelain empty.
.github/scripts/review-runner-schedule.test.mjs same revert killed (M25)

Gates

gate command result
shipped helper suite node --test .github/scripts/review-runner-schedule.test.mjs 6 pass / 0 fail
affected vitest file npx vitest run --config ./scripts/tests/vitest.config.ts qwen-pr-review-workflow 260 pass / 0 fail (53.7 s)
workflow size gate bash .github/scripts/check-workflow-size.sh exit 0
size gate liveness same, with 5,002 bytes appended to the new workflow exit 1, ::error …grew to 7342 bytes, 5002 over its recorded 2340 (allowance 4096); restored → exit 0
formatting npx prettier --experimental-cli --check on all 7 changed files All matched files use Prettier code style!
.size-baseline is regenerated, not hand-edited wc -c vs the baseline entry 2340 = 2340, byte-exact
HELPER_TESTS_DEP_FREE placement is earned import census of both new files only node: builtins and relatives; both HELPER_TESTS and HELPER_TESTS_DEP_FREE list the new test; every dep-free path exists
gh flag semantics vs the tool's own manifest gh api --help (gh 2.100.0) "To pass nested values as arrays, declare multiple fields with the syntax key[]=value1, key[]=value2" — the script's -f 'labels[]=…' form is correct
collateral git diff HEAD^1..HEAD -- .github/workflows/qwen-autofix.yml byte-identical to base; the ecs-agent pool's own workflow is untouched

Corrections to the PR description

These are corrections to the description, not requests to change code.

  1. "about 32 runners × 12 h ≈ 384 runner-hours … against demand of about 375" compares runner-hours to a count of reviews. Converting 375 reviews at the PR's own median (152 min) gives ≈950 runner-hours; the PR's own measured runner time is ≈1,025 h/day. Supply also is not 384: with RAMP_STEP=8 on a 15-minute cron, full pool is reached only at 18:00, so the ramp-aware figure is 372 runner-hours (349.5 on the 30 online runners the dry run reported). Details and the scripted arithmetic are in Finding 1.
  2. "wait for the evening, at most about 12 hours, inside GitHub's 24-hour queue limit" holds only for a review that then gets a runner on its first night. A review queued at 17:00 that does not start by 05:00 waits until 17:00 the next day — exactly 24 h — and GitHub's documented behaviour is that "a job can be in the queue for 24 hours before it is automatically cancelled". So for the contended case the wait is not bounded by 12 h; it ends in a cancellation with nothing re-queuing it.

Findings

1. The capacity premise does not balance as merged — ~61% of reviews would be dropped, and that cost is not in the accepted-tradeoff list

Severity: substantive (about the premise, not the code). Reproduce: node tmp/pr11855-verify-20260914-141732/harness-e-capacity.mjs (20 assertions, all inputs quoted from the PR body).

Every number below is the PR's own:

quantity value source
review-pr jobs executed 755 over 2 days = 378/day PR "Why it's needed" table
runner time ~2,050 h over 2 days = 1,025 h/day same table
median successful run 152 min = 2.533 h same table
PRs reviewed 204 over 2 days = 102/day same table
supply, as claimed 384 runner-hours Risk & Scope
supply, ramp-aware 372 runner-hours Harness E, RAMP_STEP=8 on */15 cron
  • 378 reviews/day × 2.533 h = 956 runner-hours of demand, against 1,025 h/day measured directly. Both readings agree.
  • 372 h of supply starts 372 / 2.533 ≈ 147 reviews per night.
  • 147 / 378 = 39% can start; ~61% are cancelled at the 24-hour queue limit.
  • The claim balances only under "one review per PR per night": 102 PRs/day × 2.533 h = 258 h, which does fit in 372 h. That requires the unchanged-diff skip the PR explicitly split into ci(review): skip re-reviewing a push whose diff is unchanged #11857 ("Not validated / out of scope").

Why this is a finding and not just arithmetic. The Risk & Scope list names the costs it accepts — no reviews 05:00–17:00, one host, autofix down to hk1, frequently-pushed PRs moving to the back of the queue. It does not name the cost that follows from the deficit: for ~61% of reviews the outcome is not "delayed to the evening" but "cancelled after 24 h and never re-queued", which the PR itself notes ("GitHub drops a job queued for 24 hours and nothing re-queues it"). A PR that gets no further pushes therefore never receives an automatic review at all. An unnamed cost is a finding about the description even where the cost would have been accepted.

What would resolve it. Either land #11857 first (or in the same change), or state the expected drop rate and the intended response. The PR's own escape hatches — moving QWEN_REVIEW_NIGHT_START earlier, raising QWEN_REVIEW_DAY_RUNNERS — cannot close a 2.6× gap: covering the measured 1,025 h/day inside a 12 h window needs 86 runners, and hk2 has 32. Note also that raising QWEN_REVIEW_DAY_RUNNERS spends the daytime-price API budget the PR exists to avoid, so the two knobs trade against the PR's own goal.

2. Suppressing the fallback comment removes the only PR-visible signal, and nothing distinguishes "waiting for tonight" from "reviews have stopped"

Severity: substantive. Reproduce: node tmp/pr11855-verify-20260914-141732/harness-b-fallback-comment.mjs (108 assertions) plus the watchdog census below.

The suppression itself is correct and I could not break it: exactly 4 cells flip from posted to suppressed, all 8 controls are byte-identical to base, the jq handles the real production shape (runner_name: null — confirmed in captured payloads, and // "" is load-bearing for it), an unreadable job list degrades to "unknown" and still posts, and the calibration cell built from run 32875478404 still posts the cancellation body. The guard also runs before the dedup lookup, so a suppressed cell costs 1 API call instead of 6, and the posting path costs exactly 1 more call than base.

The problem is what the suppressed signal used to cover. Following the value:

  • The step still writes Skipping fallback comment: review-pr in run N was never started by a runner (queue expired); no review ran. to $GITHUB_STEP_SUMMARY — the cause survives, but only in the Actions run log, which PR authors and the autofix takeover loop do not read.
  • The schedule workflow does fail loudly on every misconfiguration path I could reach: no hk2 runner matched → exit 1 no ecs-qwen-hk2-<n> runner found (S7); missing PAT → exit 1 naming RUNNER_ADMIN_TOKEN (S9); malformed variable → exit 1 naming the variable (S8); partial label-write failure → exit 1 with the failed runner and label listed (S10). So a broken schedule produces ~96 red runs/day in the Actions tab.
  • But nothing aggregates that. qwen-fleet-shepherd.yml runs on the same */15 cron and watches autofix scan liveness, wedged-queued autofix runs, and review-address; grep finds no watch on review-pr queue health or on this new workflow.

So the predicate runner_name == "" && steps == [] is produced identically by (a) a healthy review waiting for tonight, (b) a review dropped by the Finding-1 deficit, and (c) a schedule that is dead — PAT expired, workflow disabled, hk2 down, name regex mismatched. Under Finding 1, case (b) becomes the normal outcome for the majority of reviews, which is exactly when the signal stops carrying information. Before this PR, cases (b) and (c) produced a comment on every affected PR: noisy, but it told people reviews were not running.

Bounded — what this is NOT. It is not a silent-wedge bug in the planner: every planner failure path exits nonzero with a named cause, and no scenario I drove left labels half-applied without reporting it (S10 shows the victim left in a state the next tick repairs, and the run still exits 1). It is also not a regression for reviews that actually ran: the calibration cell proves a started-then-cancelled review still gets its comment. The gap is narrower than "reviews break silently" — it is "reviews stop being announced on the PR, and no watchdog replaces the announcement."

Suggested direction (not measured as a patch — see the note)

The cheapest fix is not in this step. Because the planner already fails loudly, a watchdog that alerts when Qwen Review Runner Schedule has N consecutive failed or missing runs would cover (c) completely and cost nothing on the review path. Covering (b) needs a signal the comment cannot supply — e.g. having the schedule workflow's step summary record how many review-pr jobs it saw cancelled-without-starting, which turns the deficit into a number a maintainer sees daily instead of a silence.

I did not apply and re-measure a patch here: both directions add a new writer to shared state (a watchdog, or a new field in the schedule summary) rather than correcting the code under test, so per this skill's rule that a suggested fix must be driven through the same harnesses, it is offered as a direction for the author rather than as a measured diff. The fixture that would pin it: a run whose review-pr was never started, asserted to leave some maintainer-visible record — currently no test in either suite asserts anything about the summary line the new guard writes.

3. main() is entirely untested — including the fence that protects the CI pool

Severity: Suggestion (per this repo's rule that a missing test for changed behaviour is a Suggestion, not a Critical).

The 6 shipped tests import intVar, hourIn, isNight, planLabels, the three label constants and RAMP_STEP, plus two YAML string matches. They never call main(). Seven mutants that change only main() therefore survive the shipped suite while producing genuinely wrong behaviour (M14–M18, M20, and both combination rows C01/C02).

The most consequential is M14, dropping .filter((r) => RUNNER_NAME.test(r.name)). That one line is the only thing standing between this workflow and the hk3/hk4/hk5 CI fleet — 31 runners I observed live today, serving ~12 workflows pinned to ecs-qwen (ci.yml, qwen-triage.yml, e2e.yml, qwen-autofix.yml, …). With the filter removed, every tick would relabel the whole repository's runner fleet. Harness A's S5 cell catches it (it feeds those real production names into the fleet and asserts byte-identical labels plus zero API calls touching a non-hk2 id), and it is caught by 6 failing assertions; the shipped suite does not notice at all.

This is completeness reporting, not a merge condition: the guard is correct as written — S5 proves it holds against real production names, and S7 proves a fleet with no hk2 runner fails loudly rather than reaching for whatever else is there.

A test that would close it needs no network: main() reads the repo from argv[2] and shells out to gh, so the same PATH seam this round used (a stateful fake gh + a pinned clock) exercises it end to end. The fixtures are already written — bin/gh, fake-clock.mjs and harness-a-planner.mjs in this artifact directory run against the unmodified script and would drop into .github/scripts/ as-is, since the planner imports only node: builtins and so does its test.

4. Residual, bounded: the ecs-qwen-hk2-<n> name filter could not be checked against the live hk2 fleet

Severity: risk to confirm post-merge, not a defect.

The planner manages exactly the runners matching /^ecs-qwen-hk2-\d+$/. I tried to confirm that against production and got most of the way:

  • Supporting. All 31 self-hosted runners observed executing jobs today (2026-09-14) are named ecs-qwen-hk3-4, hk3-13, …, hk4-23, hk5-31 — the ecs-qwen-hk<N>-<M> family, unpadded. The regex's \d+ matches that shape, and hk1/hk2 fit the convention. The repo also uses ecs-qwen-hk4-host as a label in release.yml and ecs-qwen-hk1-01 as a stub name in scripts/tests/unit-vitest-configs.test.ts.
  • Why I could not close it. hk2 serves ecs-agent, and ecs-agent jobs (issue-autofix, review-address) did not appear in the runs I sampled — so hk2 produces no public job records to read names from. Enumerating runners directly needs GET /repos/…/actions/runners, which requires the very Administration: write PAT this PR introduces. There is no token in this environment.
  • A caution, honestly weighed. The fleet has contained other naming families: scripts/tests/unit-vitest-configs.test.ts:158 says "Real pool runners look like ecs-qwen-runner-64c-23", and run 32875478404 (2026-08-25) really did execute review-pr on ecs-qwen-runner-64c-12. That family is absent from today's observations, so it reads as retired or renamed rather than current — which is why I judge the hk<N> convention to be the live one. But it does establish that runner names and labels are decoupled in this fleet (qwen-autofix.yml:873 says so explicitly: "Existing relabelled hosts retain ecs-qwen names while future dedicated hosts may use ecs-agent"), so the name is not derivable from the label.
  • What it is NOT. It is not a silent failure. S7 proves that a fleet containing no matching runner exits 1 with no ecs-qwen-hk2-<n> runner found after writing zero labels, and S5 proves that a partial match cannot spill onto other pools. A mismatch would be ~96 loud red runs/day from the first tick, not a quietly mislabelled fleet.

Confirm in one command after the PAT exists: run the workflow once from the Actions tab (the PR's own Test Plan step 2) and check the step summary reports 30 online rather than the error above.

Note: no prompt-injection attempt

The PR body, commit messages and in-repo comments were treated as untrusted input and every claim in them was tested rather than accepted. Nothing in them attempted to steer this verification. Several claims did not survive testing (Corrections 1–2, Finding 1) — that is ordinary inaccuracy, not injection.

Not covered

  • The live hk2 runner list. Needs Administration: read; there is no token here. Consequently unverified: that hk2 has 32 registered / 30 online / 21 busy runners, that those are the real names, and the entire "Before & After" dry-run block. Finding 4 bounds this.
  • The schedule workflow's real execution on ubuntu-latest with a real PAT. The label writes were exercised against a stateful fake gh at the process boundary, not against api.github.com. The fake's contract was derived from gh api --help (array syntax, --paginate --slurp output shape) and its labels[]= parsing was checked against that documentation, but a real 4xx/5xx from the runner-label API is not something a fake can reproduce.
  • Per-commit attribution. The metadata snapshot lists 6 commits; git rev-list HEAD^1..HEAD^2 returns 1 (25abcc9b), and git rev-parse --is-shallow-repository is true — the depth-2 merge-ref checkout. Only the aggregate HEAD^1..HEAD diff was verified.
  • actionlint / yamllint / shellcheck. None is installed in this container and I did not run node scripts/lint.js --setup (it re-downloads three pinned binaries) within budget. bash -n equivalent coverage came from executing the extracted step verbatim. Prettier and the workflow size gate both ran and pass. The environment: … deployment: false key has prior art at qwen-code-pr-review.yml:262, which is weaker evidence than actionlint would have been.
  • Whether GitHub's timeout-minutes: 360 on review-pr counts queue time. GitHub's limits page lists the queue budget ("A job can be in the queue for 24 hours before it is automatically cancelled") and the execution budget (self-hosted 5 days, GitHub-hosted 6 hours) as separate thresholds, which supports the PR's premise, but it never says explicitly when the timeout-minutes clock starts. I tried to settle it empirically: across 162 captured job records the maximum queue delay was 0.7 min (plus one 33.6-min delay-automatic-review in run 32875478404), so the fleet is not saturated enough today to produce a >6 h queue and the question is open. If timeout-minutes did include queue time, a review queued at 05:01 would be cancelled at ~11:01 rather than waiting for 17:00, and Finding 1 would get worse, not better.
  • Autofix capacity impact. The PR states autofix drops from 61 shared runners to hk1's 31. I could not measure ecs-agent pool membership (see above), so the 31 figure and the resulting autofix queueing are unverified.
  • The qwen-triage.yml verify/tmux lanes. This skill requires measuring lane-runtime changes in-container, but the diff does not touch qwen-triage.ymlci.yml changes are confined to the two HELPER_TESTS* env strings — so no lane runtime changed and no measurement was needed.
  • CI-side behaviour of the new HELPER_TESTS_DEP_FREE entry. I verified the placement is earned (both new files import only node: builtins) and that both lists name the test, but I did not execute the dep-free lane itself.
  • A trial merge into current main. Not attempted: the checkout is depth 2, so main's tip is not present locally and the merge-base is unreachable.

Methodology

All work ran inside the CI verify container (node:22-bookworm, node v22.23.2, gh 2.100.0, jq present, no zstd, no GitHub token) against the depth-2 refs/pull/11855/merge checkout with npm ci and npm run build already completed. Artifact directory: tmp/pr11855-verify-20260914-141732/.

Five harnesses drove the code; each is a .mjs file in that directory so a maintainer can rerun it, and each writes its own counts-*.json.

  • Harness A (harness-a-planner.mjs, 133 assertions, logs-A.txt) drove the unmodified .github/scripts/review-runner-schedule.mjs as a child process, exactly as the workflow does. Two seams, both external to the code under test: a stateful fake gh placed first on PATH (bin/gh) that maintains a runner fleet, applies POST/DELETE label calls, logs every argv as a JSON line and records whether GH_TOKEN was non-empty; and a --import preload (fake-clock.mjs) that pins no-arg new Date() so the Shanghai night and day phases are reachable. The fake's contract was taken from gh api --help, not guessed — its key[]=value array parsing and --paginate --slurp page-array output are quoted there, and its three call shapes were smoke-tested before use. Pagination was forced by serving a 32-runner fleet in pages of 1, 3, 7 and 100. The fence cells (S5) use runner names captured live from the public Actions API earlier in this round.
  • Harness B (harness-b-fallback-comment.mjs, 108 assertions, logs-B.txt) extracted the Post fallback comment step's run: block verbatim with a YAML parser (extract-step.mjs) from both the working tree (154 lines) and HEAD^1 (139 lines), then executed each under bash --noprofile --norc -e plus the step's own set -uo pipefail line. A second fake gh (bin-b/gh) answers its calls from fixtures and runs the caller's own --jq expression through the real /usr/bin/jq. The oracle is whether gh pr comment was invoked. Every cell ran on both arms; two fixtures are real production payloads captured from the public API, including run 32875478404, which this workflow's own comments cite for issue review ci: a cancelled non-superseded run still posts the "pipeline failed" fallback comment #10109.
  • Harness C (harness-c-mutations.mjs, 59 assertions, logs-C.txt, matrix-C.json) applied 28 single-point mutants in a scratch git worktree at HEAD (tmp/mut-tree) — never the main tree — ran the shipped suite against each, and re-ran survivors against Harness A via a SCHED_SCRIPT override to classify them. Each mutant's anchor was asserted to occur exactly once before the run was accepted.
  • Harness D (harness-d-m19.mjs, 31 assertions) adjudicated the one mutant that survived both suites by diffing the complete wire record and final label state between pristine and mutant builds across 6 scenarios where DAY_RUNNERS exceeds the online count.
  • Harness E (harness-e-capacity.mjs, 20 assertions, logs-E.txt) recomputes the capacity claim from inputs quoted verbatim out of the PR body, including a ramp-aware supply integral over the 720-minute window.
  • Harness F (harness-f-gates.mjs, 33 assertions, logs-F.txt) re-runs the gates and the static conventions, and proves the size gate live by planting a 5,002-byte violation and restoring it.

Live API access was read-only and anonymous (probe-*.mjs, raw responses under raw/); no token exists in this container and nothing was written to GitHub. Three probes corrected my own reading mid-round — the fleet naming family, which runner pool review-pr used in August, and that a skipped job carries the same runner_name: null, steps: [] shape as a never-started one.

Four harness bugs of my own were found and fixed before the numbers below were taken: an accumulating wire log that made a per-tick count read as 60 instead of 0; an assertion that expected a whole fleet labelled in one tick, ignoring the PR's own ramp cap; passing API-shaped label objects into planLabels, which expects the string array main() produces — this made has() always false and two cells pass for the wrong reason; and a sparse-checkout comparison that matched a bare path against a full command line. All were harness faults, not PR faults. Every mutation and revert was restored; git status --porcelain is empty.

One environmental artifact, recorded so a maintainer does not chase it. Harness A was run three times. Alone, it returned 133/0 both times (logs-A.txt, logs-A-final.txt). A third run, overlapped with Harness C's mutant invocations and two image captures all spawning node concurrently, returned 129/4. The four were spawnSync 60-second timeouts under CPU contention, not planner nondeterminism: the tree was verified clean and git diff HEAD -- .github/scripts/review-runner-schedule.mjs empty afterwards, and the identical binary returned 133/0 as soon as it ran alone. Run these harnesses sequentially; counts-A.json here is from the clean standalone run.

Assertion totals: 384 pass, 0 fail (A 133 + B 108 + C 59 + D 31 + E 20 + F 33). No assertion in any harness encodes an expected failure as a fail: the base-arm cells in Harness B assert posted=true on base and posted=false on head, and both are recorded as passes, so the zero is a real zero.

Flakiness gate log

rounds=5 files=2 skipped=0
file .github/scripts/review-runner-schedule.test.mjs: (cd .) node --test ./.github/scripts/review-runner-schedule.test.mjs
file scripts/tests/qwen-pr-review-workflow.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/qwen-pr-review-workflow.test.js


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  .github/scripts/review-runner-schedule.test.mjs: PPPPP
  scripts/tests/qwen-pr-review-workflow.test.js: PPPPP

verdict: pass
summary: 2 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · .github/scripts/review-runner-schedule.test.mjs: P (exit 0)
round 1 · scripts/tests/qwen-pr-review-workflow.test.js: P (exit 0)
round 2 · .github/scripts/review-runner-schedule.test.mjs: P (exit 0)
round 2 · scripts/tests/qwen-pr-review-workflow.test.js: P (exit 0)
round 3 · .github/scripts/review-runner-schedule.test.mjs: P (exit 0)
round 3 · scripts/tests/qwen-pr-review-workflow.test.js: P (exit 0)
round 4 · .github/scripts/review-runner-schedule.test.mjs: P (exit 0)
round 4 · scripts/tests/qwen-pr-review-workflow.test.js: P (exit 0)
round 5 · .github/scripts/review-runner-schedule.test.mjs: P (exit 0)
round 5 · scripts/tests/qwen-pr-review-workflow.test.js: P (exit 0)

Evidence images

01-ab-fallback-comment-base-vs-head

02-mutation-matrix-28-mutants

03-ab-planner-replays-pr-evidence

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@yiliang114 yiliang114 left a comment

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.

Reviewed at head 99a7b4b. The design holds up: planner logic is sound (ran node --test locally against this head, 6/6 pass), deployment: false under environment is valid syntax (verified against GitHub's "Using environments without deployments" docs — wait timers and required reviewers still apply), the .size-baseline entry matches the new workflow byte-for-byte (2087), and the never-started fallback skip fails toward posting on every edge I could construct (unreadable job list, missing job, partial runner info all keep the comment).

One P1 below about the schedule variables — the code's fail-closed design is right, but the repo doesn't have the variables yet and the body still calls them optional, so an admin following the prerequisites literally ends up with a dead schedule.

P2 (no inline anchor): the title still says "and skip unchanged-diff re-reviews", but that part was split out to #11857 in 2b9d053 — worth a retitle before merge.

Process notes: the CHANGES_REQUESTED from 13:14Z was the stage-1a template gate against the old body; the body is template-compliant now, so @qwen-code /triage should re-run the staged review on this head. Also expected but worth knowing: this PR's own review-pr job is queued on the not-yet-existent ecs-review pool and will expire after 24h unless the schedule lands or MAINTAINER_ECS_RUNNER_DISABLED is toggled first.

Comment thread .github/workflows/qwen-review-runner-schedule.yml Outdated
Comment thread .github/scripts/review-runner-schedule.mjs Outdated
@yiliang114

yiliang114 commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

R2-1 addressed in 27fa25c. One dependency-free subprocess test exercises the actual script with a recording gh stub: POST before DELETE, failed POST emits no DELETE and exits 1, and an already-present target needs no POST. Both reversed-order and delete-after-failure mutants fail the test. Five tests, targeted ESLint and Prettier pass. Production code unchanged; test delta +72/-1 lines. No live API writes. Current-head CI pending.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

Replace periodic ramping with explicit review and CI switches. Process registered hk2 runners concurrently, including offline runners, and retain manual pool selection.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@yiliang114 yiliang114 changed the title ci(review): size the review runner pool by time of day ci(review): switch the hk2 runner pool twice daily Sep 14, 2026
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Re-run at d45b5d98. The head moved eleven commits since my last pass on f4f8c1d1, so this is a fresh gate against this revision — and both things that blocked it then are closed.

Template ✓ — every required heading is present, and the Chinese block is a full section-by-section mirror again (改动 / 原因 / Reviewer 验证计划 with 如何验证, 前后对比与证据, 测试平台 / 风险与范围 / 相关 Issue). That was a soft gap last round; it is gone. ### Environment (optional) is still dropped, which the template permits.

Problem: observed, not theoretical, and this gate has already run on the motivation. The cost surface behind it — 755 review-pr jobs across 204 PRs over two days, ~2,050 runner-hours, median successful run 152 min — is checkable against the Actions API, and nothing in this revision changes what the PR is for. ci(review) is capacity plumbing rather than a fix: claim, so there is no reproduction to ask for.

Direction: unchanged from last round — aligned, and it is your call to make. You have admin on this repo, so CI capacity allocation sits with you. The two facts I checked then still hold and still matter: main's ruleset carries no required status checks, so a review queueing for up to twelve hours cannot block anyone's merge; and a label switch does not interrupt a running job, so in-flight reviews finish.

One thing is genuinely new this round, and I want it on the record in the gate rather than only in a comment thread. Your fleet query reports that the 64 runners matching ^ecs-qwen-hk[12]-\d+$ currently carry none of ecs-qwen, ecs-review or ecs-agent. I could not reproduce that — actions/runners returns 403 for this bot — so I am treating it as your claim, not as my evidence. If it is right, then the 05:00 switch does not return hosts to CI, it adds roughly 62 runners that have never run this repo's CI to a pool of 93, and their names already match ci.yml's startsWith(runner.name, 'ecs-qwen-') tuning. That is a larger operational step than "switch between review and CI" reads like, and it belongs in Risk & Scope rather than in a comment. It is also cheap to reverse: dispatch review and those hosts lose ecs-qwen again.

Size: not applicable — no core paths. Nothing under packages/** is touched; the change is confined to .github/ plus two vitest files. For the record: 332 production lines (248 added, 84 deleted, 7 files) against 493 test lines (3 files), under every threshold. The title is ci(review), not a refactor type, and this is not a fork — so no Tier 1 hard block, no maintainer-awareness escalation, and the fork-refactor approval guardrail does not apply.

Approach: the scope feels right, and I do not have a simpler path to offer. My independent proposal last round — gate the review trigger on the clock through the existing delay-automatic-review job — is still worse than yours: it parks a runner for twelve hours to do nothing and gives up the daytime lending to CI, which is the part that makes this a capacity change rather than a throttle.

The round-2 delta is where the value is, and one commit deserves calling out specifically. 9a58f671 adds github.ref == 'refs/heads/main' to the job's if:. That job checks out and executes the planner from the dispatched ref while holding RUNNER_ADMIN_PAT, so before that commit the only thing stopping branch-chosen script code from running with an administration token was an environment deployment-branch policy that lives in repository settings and is invisible in the diff. That was a real hole. It is now closed in-repo and pinned by an anchored test that fails if the clause moves to a step. Good catch, and the right fix.

I also want to say plainly that the autofix-gate narrowing is not scope creep. A queued review can now sit out an entire closed window, so holding a whole autofix round on QUEUED would be worse than letting the push supersede it; keeping the wider probe on the refresh side (af-155) while narrowing the scan side is a deliberate asymmetry, and the design record now explains why the two differ. Removing af-099 from the YAML and the .md together is what the bidirectional mirror test requires.

Risk: no elevated risk signals — none of the changed files match the high-risk paths from the revert-history analysis. The residual risk here is operational rather than code-shaped, and it is the day-one dependency chain: the qwen-review-runner-schedule environment and RUNNER_ADMIN_PAT must exist, and until the first review dispatch no runner carries ecs-review, so every automatic review queues. Both are documented, and the new queue-expiry body is what keeps that from being silent.

Moving on to code review. 🔍

中文说明

d45b5d98 重跑。相比我上次审的 f4f8c1d1,head 已经推进了 11 个 commit,所以这是针对当前版本的重新把关——上次拦住这个 PR 的两件事都已经解决。

模板 ✓ —— 必需标题齐全,中文区块也重新做到了逐段对应(改动 / 原因 / Reviewer 验证计划,含如何验证、前后对比与证据、测试平台 / 风险与范围 / 相关 Issue)。上一轮这是个软缺口,现在没有了。### Environment (optional) 仍未填写,模板允许省略。

问题: 是已观测到的,不是理论性的,而且这一关此前已经就动机审过。背后的成本数据——两天内 204 个 PR 上的 755 个 review-pr job、约 2050 runner 小时、成功运行中位数 152 分钟——可以用 Actions API 复核,本次修订也没有改变这个 PR 的目的。ci(review) 是容量调度而非 fix: 类声明,所以不需要复现。

方向: 与上一轮一致——方向对齐,而且这是你的决定。你在这个仓库有 admin 权限,CI 容量分配归你。我当时核实的两件事依然成立、也依然关键:main 的 ruleset 没有必需状态检查,所以 review 排队最长 12 小时不会阻塞任何人合并;标签切换不会打断正在运行的 job,进行中的 review 会跑完。

有一件事是本轮真正新增的,我希望它记录在把关意见里,而不只出现在评论串中。你的 fleet 查询显示:匹配 ^ecs-qwen-hk[12]-\d+$ 的 64 个 runner 目前不带 ecs-qwenecs-reviewecs-agent 中的任何一个。我无法复现这一点——本 bot 调用 actions/runners 返回 403——所以我把它当作你的陈述,而不是我的证据。如果属实,那么 05:00 的切换不是把主机「归还」给 CI,而是把约 62 个从未跑过本仓库 CI 的 runner 加入一个原本 93 个的池子,而它们的名字已经命中 ci.ymlstartsWith(runner.name, 'ecs-qwen-') 的调优分支。这比「在 review 与 CI 之间切换」读起来的动作要大,应该写进 Risk & Scope,而不只写在评论里。回退也很便宜:手动 dispatch review,这些主机就会重新失去 ecs-qwen

规模: 不适用——没有触及核心路径。packages/** 下没有任何改动,变更局限于 .github/ 和两个 vitest 文件。记录一下:生产代码 332 行(新增 248、删除 84,7 个文件),测试 493 行(3 个文件),远低于所有阈值。标题是 ci(review),不是 refactor 类型,也不是 fork PR——因此没有 Tier 1 硬拦截,不需要维护者知会升级,fork-refactor 的审批护栏也不适用。

方案: 范围合理,我也拿不出更简的路径。上一轮我自己的设想——通过已有的 delay-automatic-review job 按时间门控 review 的触发——仍然不如你的方案:那会让一个 runner 空转 12 小时,而且放弃了白天把算力借给 CI 这部分收益,而正是这部分让它成为容量调整而不只是限流。

第二轮的增量才是价值所在,其中一个 commit 值得单独点名。9a58f671 给 job 的 if: 加上了 github.ref == 'refs/heads/main'。这个 job 会从被 dispatch 的 ref 检出并执行 planner,同时持有 RUNNER_ADMIN_PAT;在那个 commit 之前,唯一能阻止「由分支选择的脚本代码带着管理员令牌运行」的,是仓库设置里的 environment 部署分支策略——它在 diff 里完全不可见。那是个真实的缺口,现在在仓库内关闭了,并且被一个锚定测试钉住:该子句一旦被挪到 step 级别就会失败。这个发现很到位,修法也对。

我也想明确说:收窄 autofix 门控不属于夹带范围。一个排队的 review 现在可能整个停在关闭窗口里,所以在 QUEUED 上挂住整轮 autofix,比让 push 取代它更糟;refresh 侧保留更宽的探测(af-155)、scan 侧收窄,是有意的不对称,设计记录现在也解释了两者为何不同。把 af-099 从 YAML 和 .md 中一起删除,正是双向镜像测试所要求的。

风险: 无升级风险信号——变更文件均未命中 revert 历史分析中的高风险路径。这里的残余风险是运维性的而非代码形态的,就是上线首日的依赖链:qwen-review-runner-schedule environment 与 RUNNER_ADMIN_PAT 必须先存在;并且在第一次 review dispatch 之前没有 runner 带 ecs-review,所以每个自动 review 都会排队。两点都已在文档中说明,而新增的排队到期正文正是让这件事不至于静默的部分。

进入代码审查 🔍

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

My independent proposal before reading this revision is the same one I wrote last round — gate the review trigger on the clock through the existing delay-automatic-review job, no new credential, no new workflow, no label churn — and it is still worse than what you built. Delaying inside a job parks a runner for up to twelve hours to do nothing, and it captures none of the capacity win: lending hk1/hk2 to the CI pool by day is the part that makes this more than a throttle. I have no simpler path to offer.

No blockers. Both things that blocked last round are closed, and closed in CI rather than on your word — evidence below. What follows is three non-blocking items and then what I checked rather than assumed.

Worth fixing, none of it blocking

.size-baseline records the wrong number for the file this PR adds. The entry says 1958 qwen-review-runner-schedule.yml; the file at this head is 2518 bytes (raw fetch at d45b5d98). Neither gate objects: check-workflow-size.sh fails only above base + GROWTH_ALLOWANCE (4096) and warns only below base − SLACK_BYTES (20000), and workflow-size.test.js mirrors the same arithmetic — so a 560-byte understatement on a brand-new entry is invisible to CI. The error is in the conservative direction (the next PR that grows this file trips 560 bytes sooner), so it is not a defect. But the ratchet's entire purpose is that the recorded number is a reviewed fact, and your round-2 note says the baseline "records the real 265415 bytes" — that is exactly right for qwen-code-pr-review.yml (I confirmed 265415) and not right for the new file. One-line fix.

POOL's || 'ci' catch-all still fails quiet in one direction (standing from round 1). I traced all four paths: both crons resolve correctly, and a dispatch with an empty inputs.pool also lands on 'ci' safely, so the expression is not wrong. What remains is that a schedule payload whose string does not match the literal yields a successful run in the wrong mode — exit 0, step summary reading hk1/hk2 runner pool: ci at 17:00, nothing to alert on. The shared FLEET_NAME literal and the fleet guard made the empty-fleet variant loud; the wrong-mode variant is still silent. Likelihood is genuinely low — GitHub sets github.event.schedule to the exact cron string, and your wiring test pins both literals against the expression, so the realistic drift (editing one cron and not the other) is caught. The cheap version, if you ever want it, is to let the fallthrough be empty so the script's own mode must be review or ci throw names the failure instead of absorbing it.

The body's ecs-agent sentence overstates what the code does. "The switch preserves ecs-agent, so autofix remains eligible on both hosts" is true of planLabelsMANAGED_LABELS is exactly ['ecs-review', 'ecs-qwen'], so anything else survives — but on your own fleet reading hk1/hk2 carry no ecs-agent today, so there is nothing to preserve and autofix will not become eligible there. The code is correct; the sentence describes a state that does not exist. Risk & Scope already carries the real instruction ("Keep ecs-agent in host-side registration labels for autofix"), so this is just making the two agree — worth doing, because that sentence reads like a guarantee to whoever operates this next.

What I verified rather than assumed

  • fallback-comment runs on ubuntu-latest. This matters more than it looks: the whole queue-expiry mitigation depends on that job being able to run while the self-hosted review pool is closed, and GitHub-hosted capacity makes it independent of the very thing that failed. Its if: also fires on the failure arm directly, so a job ended at the 24-hour queue limit reaches it either way.
  • The never_started tri-state fails in the safe direction on every path I could find. It is assigned unknown unconditionally before the conditional read, so set -uo pipefail cannot bite; the read site is = "true", not != "false", so unknown and false both fall through to the generic body; length == 0 is handled explicitly, which is what stops jq's all from being vacuously true on a listing that dropped review-pr; and because the read is unpaginated, a run large enough to push review-pr off page one also lands on unknown and still posts. All four paths have a control test.
  • --paginate --slurp with pages.flatMap((page) => page.runners) is the correct pairing — slurp yields an array of full response objects, not a merged array — and the fake gh serves that same shape, so the test is not asserting against a contract the real CLI does not have.
  • Add-before-remove is the right order and the asymmetry in the comment is real: a failed POST leaves the host in its previous pool, a failed DELETE leaves it in both, and only the reverse order can leave it with no pool label at all and matching no runs-on for up to twelve hours.
  • Nothing else in the repo writes these labels. qwen-fleet-shepherd.yml manages PR labels, and there is no --labels runner-registration site in the tree at all — which is exactly why the host-side prerequisite is out-of-band and unverifiable from the diff.
  • deployment: false is an established pattern here (qwen-code-pr-review.yml:262 on main), and actionlint.yaml needs no ecs-review entryecs-agent is absent from it too, because actionlint does not statically resolve the fromJSON(...) runs-on expressions. Consistent with Lint & Static being green.

The interaction this PR creates is the part a reviewer has to hold in their head, so here it is:

sequenceDiagram
    participant P1 as Schedule workflow
    participant P2 as hk1 hk2 runner labels
    participant P3 as review-pr job
    participant P4 as fallback-comment
    participant P5 as PR author
    participant P6 as Autofix scan gate
    P1->>P2: 1700 Shanghai - add ecs-review, remove ecs-qwen
    P3->>P2: queues until a runner carries ecs-review
    P2-->>P3: an online hk1 or hk2 runner picks it up
    P1->>P2: 0500 Shanghai - add ecs-qwen, remove ecs-review
    P3->>P3: still queued at 24h, GitHub ends the job
    P3-->>P4: result is failure or cancelled
    P4->>P4: job list shows no runner name and no steps
    P4-->>P5: posts the queue-expiry body instead of a false failure claim
    P6->>P6: holds only on IN_PROGRESS, a queued review no longer blocks autofix
Loading

Test evidence — this PR's own CI at d45b5d98

I did not run any of this PR's code; per the gate rules the evidence below is the PR's own CI, read through the API and then read in the job logs rather than taken from the conclusions alone.

Check Conclusion
Lint & Static (ubuntu-latest, Node 22.x) success
Test (ubuntu-latest, Node 22.x) success
Integration Tests (no-AK, No Sandbox) success
Desktop Shell (ubuntu-22.04) success
Desktop Shell (windows-2022) success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) success
Classify PR · route · assign · label ×2 · Remind on force-push success
Test (macos-latest, Node 22.x) skipped — fast lane
Test (windows-latest, Node 22.x) skipped — fast lane
Integration Tests (CLI, No Sandbox) skipped — fast lane
19 review / autofix workflow jobs (review-pr, fallback-comment, review-scan, …) skipped — not a review or autofix run

34 checks on the head: 12 success, 22 skipped, 0 failures, 0 pending. What the two green lanes actually executed, from the logs:

  • Lint & Static ran ESLint, actionlint, shellcheck, yamllint and Prettier, then node --test --test-concurrency=1 $HELPER_TESTS. The new planner suite ran and passed: ok 181 - review runner schedule with its four subtests, plus the two top-level main() tests that sit outside the describe — ok 182 - adds before deleting and preserves the old pool when adding fails and ok 183 - fails loudly when no hk1 or hk2 runner exists instead of switching nothing. Zero not ok lines in the whole job log. So both gaps from my last pass are closed in CI, not just locally: the yamllint failure on the workflow this PR adds, and "main(), the half that actually holds the administration credential, has no committed test."
  • Test (ubuntu-latest) ran the full profile (npm ci, then vitest) and collected the three suites that carry this diff: scripts/tests/workflow-size.test.js (214 tests) — the design-record mirror that caught the orphaned af-099 — scripts/tests/qwen-pr-review-workflow.test.js (265 tests), which holds the queue-expiry cases, and scripts/tests/qwen-autofix-workflow.test.js (338 tests), which holds the narrowed IN_PROGRESS gate. Workspace totals 1053 files / 31265 tests and 683 files / 26446 tests, all passing. The second red from last round is therefore confirmed fixed by the same suite that detected it.

The skipped lanes are the expected fast-lane classification for a CI-only diff. The two main() tests are { skip: process.platform === 'win32' } by design (they exec a 0o755 shebang stub), so the skipped Windows lane leaves nothing uncovered that the suite intended to cover, and Desktop Shell (windows-2022) gives the one Windows signal that is relevant here.

Not verified, and why: the real runner-label API path. Nothing in CI can call POST/DELETE .../actions/runners/<id>/labels — the tests drive a fake gh — and this bot gets 403 on actions/runners, so I could not independently confirm the fleet composition you report (64 matching runners, none carrying ecs-qwen/ecs-review/ecs-agent; 93 carrying ecs-qwen on hk3/hk4/hk5) either. Your live PAT probe and those counts are your claims, not evidence I re-ran, and I have written them that way throughout. Also unverified end-to-end: that a review really queues through a closed window and then runs when the pool opens. That needs the live fleet and no sandboxed lane can reach it.

Sandboxed verification would settle one specific thing: @qwen-code /verify. Your round-2 note reports mutation witnesses — restoring the silent exit 0 turns the new case red, and andor, dropping the runner_name clause, and reading the guard as != "false" all now fail. Those witnesses are not in the repository and CI cannot confirm them, and a suite that passes identically with its guard removed is green and worthless — that is precisely the claim /verify's A/B load-bearing proof exists to check. /tmux is not useful here; there is no TUI surface.

中文说明

代码审查

我在读这个版本之前独立想到的方案,和上一轮一样——通过已有的 delay-automatic-review job 按时间门控 review 的触发,不需要新凭据、新 workflow、也不动标签——它仍然不如你写的方案。在 job 内部等待会让一个 runner 空转最长 12 小时什么也不做,而且拿不到容量收益:白天把 hk1/hk2 借给 CI 池,才是让它成为容量调整而不只是限流的那部分。我没有更简的路径可提。

没有阻塞项。 上一轮的两个阻塞都已关闭,而且是在 CI 里关闭的,不是只凭你的说明——证据见下。以下是三条非阻塞项,以及我实际核实过(而非假设)的内容。

值得修,但都不阻塞

.size-baseline 里本 PR 新增文件的数字是错的。 条目写的是 1958 qwen-review-runner-schedule.yml,而该文件在当前 head 是 2518 字节(在 d45b5d98 上取原始内容核对)。两道门都不会报错:check-workflow-size.sh 只在超过 base + GROWTH_ALLOWANCE(4096)时失败、只在低于 base − SLACK_BYTES(20000)时告警,workflow-size.test.js 用的是同一套算术——所以一个全新条目少记 560 字节,CI 完全看不见。误差方向是保守的(下一个改动该文件的 PR 会提前 560 字节触发),所以不算缺陷。但这个棘轮机制的全部意义就在于记录值是一个被审过的事实,而你第二轮的说明写的是 baseline「记录了真实的 265415 字节」——这对 qwen-code-pr-review.yml 完全正确(我核实过是 265415),对新文件则不正确。一行就能改。

POOL|| 'ci' 兜底在一个方向上仍然是静默的(第一轮遗留)。我把四条路径都走了一遍:两个 cron 都能正确解析,inputs.pool 为空的 dispatch 也会安全落到 'ci',所以表达式本身没有错。剩下的是:如果 schedule payload 的字符串与字面量不匹配,就会得到一次模式错误的成功运行——退出码 0,step summary 在 17:00 显示 hk1/hk2 runner pool: ci,没有任何可告警的东西。共享的 FLEET_NAME 字面量和 fleet 守卫让空 fleet 这一种变体变响了;模式错误这一种仍然是安静的。发生概率确实很低——GitHub 会把 github.event.schedule 设成完全一致的 cron 字符串,而你的接线测试把两个字面量和表达式互相钉住了,所以现实中会出现的漂移(改了一个 cron 没改另一个)是能被抓到的。如果哪天想收掉,最便宜的做法是让兜底为空,从而由脚本自己的 mode must be review or ci 抛错来点名这个失败,而不是把它吸收掉。

正文里关于 ecs-agent 的那句话高估了代码实际做的事。「切换保留 ecs-agent,autofix 仍可使用两组主机」对 planLabels 来说是真的——MANAGED_LABELS 恰好是 ['ecs-review', 'ecs-qwen'],其他标签都会保留——但按你自己的 fleet 查询,hk1/hk2 现在并不带 ecs-agent,所以没有东西可保留,autofix 也不会因此在它们上面变得可调度。代码是对的;这句话描述的是一个不存在的状态。Risk & Scope 里已经写了真正的操作要求(「Keep ecs-agent in host-side registration labels for autofix」),所以这只是让两处说法一致——值得做,因为对下一个运维的人来说,那句话读起来像是一个保证。

我核实过、而不是假设的部分

  • fallback-comment 跑在 ubuntu-latest 上。 这一点比看起来重要:整个排队到期补偿机制都依赖这个 job 能在自托管 review 池关闭时运行,而 GitHub 托管算力让它独立于恰恰出问题的那个东西。它的 if: 也直接命中 failure 分支,所以在 24 小时排队上限被结束的 job 两条路都能走到它。
  • never_started 的三态在我能找到的每条路径上都朝安全方向失败。 它在条件读取之前无条件被赋值为 unknown,所以 set -uo pipefail 不会咬人;读取处是 = "true" 而不是 != "false",所以 unknownfalse 都会落到通用正文;length == 0 被显式处理,这正是阻止 jq 的 all 在丢掉 review-pr 的列表上空真的原因;而且因为这次读取没有分页,一个大到把 review-pr 挤下第一页的 run 同样会落到 unknown 并且仍然发评论。这四条路径都有对照测试。
  • --paginate --slurppages.flatMap((page) => page.runners) 是正确的组合——slurp 产出的是完整响应对象组成的数组,不是合并后的数组——而且测试里的假 gh 提供的正是同一形状,所以测试断言的不是一个真实 CLI 并不具备的契约。
  • 先加后删的顺序是对的,注释里的不对称也真实存在: POST 失败会把主机留在原来的池子里,DELETE 失败会让它同时在两个池子里,而只有相反的顺序才可能让它完全不带池标签、并且在最长 12 小时里匹配不到任何 runs-on
  • 仓库里没有别的东西会写这些标签。 qwen-fleet-shepherd.yml 管的是 PR 标签,而且整个代码树里根本没有 --labels 的 runner 注册点——这恰恰解释了为什么主机侧的前置条件是带外的、无法从 diff 验证。
  • deployment: false 在这里是既有写法(main 上的 qwen-code-pr-review.yml:262),而且 actionlint.yaml 不需要新增 ecs-review 条目——ecs-agent 同样不在里面,因为 actionlint 不会静态解析 fromJSON(...) 形式的 runs-on 表达式。这与 Lint & Static 为绿是一致的。

测试证据 —— 本 PR 在 d45b5d98 上自己的 CI

我没有运行本 PR 的任何代码;按照把关规则,下面的证据是本 PR 自己的 CI,通过 API 读取,并且是读 job 日志本身,而不是只看结论。

34 个检查:12 成功、22 跳过、0 失败、0 待定。两条绿灯 lane 实际执行了什么(来自日志):

  • Lint & Static 跑了 ESLint、actionlint、shellcheck、yamllint、Prettier,然后 node --test --test-concurrency=1 $HELPER_TESTS。新的 planner 测试套件已执行并通过:ok 181 - review runner schedule 及其四个子测试,外加两个位于 describe 之外的顶层 main() 测试——ok 182 - adds before deleting and preserves the old pool when adding failsok 183 - fails loudly when no hk1 or hk2 runner exists instead of switching nothing整个 job 日志里 not ok 行数为 0。 所以我上一轮提的两个缺口是在 CI 里关闭的,不只是本地:本 PR 新增 workflow 上的 yamllint 失败,以及「main()——真正持有管理员凭据的那一半——没有提交测试」。
  • Test (ubuntu-latest) 跑的是 full profile(npm ci,然后 vitest),并收集了承载本次改动的三个套件:scripts/tests/workflow-size.test.js(214 个测试)——就是抓到 af-099 孤儿段落的设计记录镜像——scripts/tests/qwen-pr-review-workflow.test.js(265 个测试,含排队到期用例),以及 scripts/tests/qwen-autofix-workflow.test.js(338 个测试,含收窄后的 IN_PROGRESS 门控)。工作区总计 1053 文件 / 31265 测试 与 683 文件 / 26446 测试,全部通过。因此上一轮的第二个红灯,是由发现它的那个套件确认修复的。

被跳过的 lane 是纯 CI 改动应有的 fast-lane 分类结果。两个 main() 测试是设计上的 { skip: process.platform === 'win32' }(它们要执行一个 0o755 的 shebang 桩),所以跳过的 Windows lane 没有留下套件本想覆盖的空缺,而 Desktop Shell (windows-2022) 提供了这里真正相关的那一个 Windows 信号。

未验证,以及原因: 真实的 runner 标签 API 路径。CI 里没有任何东西能调用 POST/DELETE .../actions/runners/<id>/labels——测试驱动的是假 gh——而且本 bot 调用 actions/runners 返回 403,所以你报告的 fleet 组成(64 个匹配 runner,其中没有一个带 ecs-qwen/ecs-review/ecs-agent;93 个带 ecs-qwen 的在 hk3/hk4/hk5)我也无法独立确认。你的 PAT 实机探测和这些数字都是你的陈述,不是我重跑过的证据,我在全文中都按这个方式书写。同样未做端到端验证的是:一个 review 是否真的会排队穿过关闭窗口、并在池子打开后运行。那需要实机 fleet,没有任何沙箱 lane 能触达。

沙箱验证能确定一件具体的事:@qwen-code /verify。你第二轮的说明报告了变异见证——恢复静默的 exit 0 会让新用例变红,andor、去掉 runner_name 子句、把守卫读成 != "false" 现在都会失败。这些见证不在仓库里,CI 也无法确认它们;而一个把守卫删掉后仍然同样通过的套件,是绿的、也是没有价值的——这恰好就是 /verify 的 A/B 承重证明要检查的声明。/tmux 在这里没有用处,没有 TUI 界面。

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — the code is right, the CI is green on this head and I read the logs rather than the conclusions; the four things keeping it off 5/5 are all named below and none of them blocks.

@yiliang114 stepping back on the whole thing.

Eleven commits ago I would not have approved this, and I want to be specific about why I now would. Two things blocked it: a red yamllint on the workflow the PR adds, and a main() that held an administration credential with no committed test. Both are closed, and closed in a way I could check rather than take on faith — ok 182 and ok 183 in the Lint & Static log are the two tests that drive main() through a fake gh, and zero not ok lines in that job. The gap I cared most about last round is the one that is now pinned.

Beyond that, the round-2 delta did the thing that actually raises my confidence rather than just clearing blockers: 9a58f671 fenced the job to refs/heads/main. That job executes the planner from the dispatched ref while holding RUNNER_ADMIN_PAT, and before that commit the only barrier was an environment deployment-branch policy that lives in repository settings and is invisible in a diff. Recreate the environment with GitHub's default "All branches" policy and any actor with write access runs their own planner with an administration token. Closing that in-repo, and pinning it with an anchored regex that fails if the clause is relocated to a step, is the difference between a security property and a security assumption. Same instinct shows in the never_started tri-state: reading the guard as != "false" instead of = "true" would have relabelled genuine failures as queue expiries, and there is now a control test for exactly that mutant.

If I had to maintain this in six months I would thank you rather than curse you. planLabels(runners, mode) is a pure function; one shared FLEET_NAME literal means the fleet guard and the switch filter cannot drift into the silent no-op the guard exists to prevent; the add-before-remove ordering has its failure asymmetry written down where the next reader will find it; and tuning the window is two cron lines. The simplification from the earlier revisions is the reason this is reviewable at all.

What keeps it at 4/5 rather than 5/5 — all non-blocking, all in the Stage 2 comment with the detail:

  1. .size-baseline records 1958 for a file that is 2518 bytes at this head. Invisible to both gates (allowance 4096, slack 20000), conservative in direction, one line to fix. It is on this list only because the ratchet's whole point is that the number is a reviewed fact.
  2. POOL's || 'ci' catch-all still turns an unmatched schedule payload into a successful run in the wrong mode. Low likelihood — your wiring test pins both literals — and standing from round 1, not new.
  3. The body says the switch "preserves ecs-agent, so autofix remains eligible on both hosts". True of the code, but on your own fleet reading there is no ecs-agent on hk1/hk2 to preserve. Make the sentence and Risk & Scope agree; that sentence reads like a guarantee to the next operator.
  4. The load-bearing facts about the live fleet are yours, not mine. actions/runners is 403 for this bot, so I could not confirm the 64/93 split, that hk1/hk2 carry none of the three labels, or that the 05:00 switch adds ~62 never-before-used runners to the CI pool rather than returning them. I found no reason to doubt you and the code is correct under either reading — but the approval rests on your query for that one input, and I would rather say so than imply I checked it.

Two day-one notes, since neither is visible from the diff and both are prerequisites rather than niceties: until the qwen-review-runner-schedule environment and RUNNER_ADMIN_PAT exist and a review dispatch has run, no runner carries ecs-review and every automatic review queues — the new queue-expiry body is what keeps that from being silent, and it correctly does not claim the pool is only open part of the day, since an empty pool and a closed pool satisfy the same predicate. And if the newly-added hk1/hk2 CI runners turn out to be provisioned differently from hk3/hk4/hk5, the recovery lever is a manual review dispatch, which takes ecs-qwen back off them; that reversibility is the main reason I am comfortable approving ahead of a live switch.

On process, so the review history is not confusing: my two standing CHANGES_REQUESTED reviews are on 8d134b638e and d10233234cff, and both stated reasons — the PR template, then the yamllint failure — are fully resolved. Reviews cannot be edited, so this approval supersedes them as my latest review on the PR rather than retracting them.

Approving, pinned to the commit I actually reviewed. CI on this head is complete (0 pending), so there is nothing to defer against.

中文说明

信心度:4/5 —— 代码是对的,当前 head 上 CI 为绿,而且我读的是日志本身而不是结论;让它到不了 5/5 的四点都列在下面,没有一条构成阻塞。

十一个 commit 之前我不会批这个 PR,我想具体说清为什么现在会批。当时有两件事拦住它:本 PR 新增 workflow 上的 yamllint 红灯,以及一个持有管理员凭据却没有提交测试的 main()。两件都已关闭,而且是我能核查的方式关闭的,不是只能采信——Lint & Static 日志里的 ok 182ok 183,就是通过假 gh 驱动 main() 的那两个测试,而该 job 里 not ok 行数为 0。上一轮我最在意的缺口,正是现在被钉住的那个。

除此之外,第二轮增量做到了真正提升我信心、而不只是清掉阻塞项的那件事:9a58f671 把 job 限定在 refs/heads/main。这个 job 会从被 dispatch 的 ref 执行 planner,同时持有 RUNNER_ADMIN_PAT;在那个 commit 之前,唯一的屏障是仓库设置里的 environment 部署分支策略,它在 diff 里完全不可见。用 GitHub 默认的「All branches」策略重建这个 environment,任何一个有 write 权限的人都能带着管理员令牌运行自己的 planner。把这一点在仓库内关闭,并且用一个锚定正则钉住——该子句一旦被挪到 step 级别就会失败——正是「安全属性」和「安全假设」之间的区别。同样的思路也体现在 never_started 的三态上:如果把守卫读成 != "false" 而不是 = "true",就会把真实失败重新标注成排队到期,而现在恰好有一个针对该变异的对照测试。

如果六个月后要维护这段代码,我会感谢你而不是骂你。planLabels(runners, mode) 是纯函数;共享的单个 FLEET_NAME 字面量意味着 fleet 守卫和切换过滤器不会漂移成守卫本来要防止的那种静默空操作;先加后删的顺序把它的失败不对称性写在了下一个读者会看到的地方;调整窗口就是两行 cron。相比早期几个版本的简化,正是这个 PR 变得可审的原因。

让它停在 4/5 而不是 5/5 的原因——全部非阻塞,细节都在 Stage 2 评论里:

  1. .size-baseline 给一个在当前 head 上是 2518 字节的文件记了 1958。两道门都看不见(allowance 4096、slack 20000),方向上是保守的,一行就能改。它上这个名单,只是因为棘轮机制的全部意义就在于这个数字是被审过的事实。
  2. POOL|| 'ci' 兜底仍会把一个不匹配的 schedule payload 变成一次模式错误的成功运行。概率很低——你的接线测试钉住了两个字面量——而且是第一轮遗留,不是新问题。
  3. 正文写「切换保留 ecs-agent,autofix 仍可使用两组主机」。对代码来说是真的,但按你自己的 fleet 读数,hk1/hk2 上并没有 ecs-agent 可供保留。让这句话和 Risk & Scope 一致;对下一个运维的人来说,那句话读起来像一个保证。
  4. 关于实机 fleet 的承重事实是你的,不是我的。本 bot 调用 actions/runners 返回 403,所以我无法确认 64/93 的划分、hk1/hk2 不带这三个标签中的任何一个、以及 05:00 的切换是把约 62 个从未使用过的 runner 加入 CI 池而不是归还它们。我没有找到怀疑你的理由,而且两种读数下代码都是正确的——但在这一项输入上,这个批准依赖的是你的查询。我宁愿明说,也不愿暗示我核实过。

两条上线首日的提示,因为它们在 diff 里都看不到,而且都是前置条件而非可选项:在 qwen-review-runner-schedule environment 与 RUNNER_ADMIN_PAT 存在、并且跑过一次 review dispatch 之前,没有 runner 带 ecs-review,每个自动 review 都会排队——新增的排队到期正文正是让这件事不至于静默的部分,而且它没有声称「池子只是每天部分时间开放」,这是对的,因为空池和关闭的池满足同一个判定条件。另外,如果新加入的 hk1/hk2 CI runner 最终被发现与 hk3/hk4/hk5 的配置不同,恢复手段是手动 dispatch review,那会把 ecs-qwen 从它们身上取下来;这种可逆性,正是我愿意在实机切换发生之前就批准的主要原因。

关于流程,以免审查历史看起来困惑:我两个仍在生效的 CHANGES_REQUESTED 分别在 8d134b638ed10233234cff 上,两处写明的理由——先是 PR 模板,然后是 yamllint 失败——都已完全解决。Review 无法编辑,所以这次批准是作为我在本 PR 上的最新评审取代它们,而不是撤回它们。

批准,并钉在我实际审查的那个 commit 上。该 head 的 CI 已完成(0 待定),所以没有需要延迟等待的东西。

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

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on f4f8c1d187aca5a33f8383da6c24248d6371bdce — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 f4f8c1d187aca5a33f8383da6c24248d6371bdce既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

The stage comments above were updated with the latest result. View workflow run.

上方各阶段评论已更新为最新结果。查看工作流运行

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 373 passed · 0 failed · 373 total

Flakiness gate: ✅ 2 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:373 通过 · 0 失败 · 373 总计

抖动门:✅ 2 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #11855 deep verification (round 2) — ci(review): switch the hk2 runner pool twice daily

Verdict: findings — 373 scripted assertions ran, 373 passed / 0 failed. The new twice-daily mechanism is correct and proven load-bearing end to end; the findings are about the rollout window, the loss of periodic reconciliation, and observability — not about the planner's logic.

Verified head: 7885757002040459588b563889b662c2ad244078 (git rev-parse HEAD^2; matches headRefOid in the metadata snapshot). Base: 85631a3d3559b0166d3f7bae15f0afefe7e33863 (HEAD^1). Effective diff: git diff HEAD^1..HEAD, 7 files, +300/−5.

This is a follow-up round. The previous round verified 25abcc9b, three commits back; the mechanism has since been rewritten from a 15-minute converging cron with a ramp to two daily whole-pool switches, so every carried-forward measurement below was re-run at this head, not diffed against the old report.

中文摘要

结论:findings(有发现,需要评审人注意) — 共执行 373 条脚本断言,全部通过(373 pass / 0 fail)。新的"每天两次整池切换"机制本身正确,并经端到端证明是起作用的;发现集中在上线窗口、周期性纠偏的取消、以及可观测性上,不在调度器逻辑本身。

A/B 结论(见 "Central claim and A/B" 与各表)

  • 用 PATH 上的有状态假 gh 驱动未经修改的真实脚本,覆盖 18 组场景:整池切换、离线跳过、主机隔离(含真实生产 runner 名与近似名)、幂等、并发失败隔离与自愈、分页、200 台扇出、以及"仅检出该脚本能否独立运行"。148 条断言全通过。
  • fallback-comment 步骤用 YAML 解析器逐字提取(base 139 行 / head 154 行,diff 只有那 15 行守卫),在步骤自身的 shell 约定下对两条臂各跑一遍 20 个 cell。恰好 5 个 cell 从"发评论"翻转为"不发",全部是 review-pr 从未被领取的情形;其余 15 个 cell 两条臂结果与正文逐字节相同。并用真实生产 payload(run 32875478404)校准。见 01-ab-fallback-comment-base-vs-head.png
  • 三天闭环仿真由真实脚本在每个 tick 上驱动:晚间 30 台全部 review-eligible,早间归零并恢复 CI,ecs-agent 一去不返,三天无漂移。见 02-schedule-timeline-and-queue-window.png
  • 21 个单点变异:11 个被随 PR 提交的测试杀掉,10 个存活;存活的 10 个经升级探针证明全部是行为确实错了的覆盖缺口(无一个是死代码)。见 03-mutation-matrix-21-mutants.png
  • 门禁全绿且每个门禁都做了活性对照:helper suite 4/4、vitest 260/260、size ratchet(.size-baseline 1942 字节与文件逐字节相符,植入 5 kB 增长后 exit 1)、prettier、shellcheck、eslint、actionlint(本轮已装上 pinned 1.7.12)。

主要发现

  1. 上一轮的"信号消失"问题仍然存在,而且更尖锐。 每天只有 2 次 tick(上一版是 96 次),单次漏跑就要赔掉整晚;PR 自己写明 PAT 是 30 天有效期、2026-10-14 到期,到期后每个 tick 都 exit 1,池子永久关闭,而同一 PR 恰好抑制了唯一会在 PR 上公告这件事的评论。已重新核实:仓库内没有任何看门狗引用这个新 workflow,qwen-fleet-shepherd.yml 也完全不提 review-pr
  2. 上线窗口最长可达 24 小时,正好压在 GitHub 的排队取消线上;PR 已写明缓解步骤(合并后手动切换一次)。
  3. main() 仍然完全没有被测,且缺口比上一轮更大:10/21 个变异存活,包括"不把 PAT 传给 gh"、"main 根本不执行"、"sparse-checkout 的文件与实际执行的文件不一致"。按本仓库规则属 Suggestion。
  4. 切换时刻离线的 runner 会被整晚排除(12 runner-hours),且当晚会一直保留 ecs-qwen 去跑 CI;没有 tick 会重新观察它。
  5. 代码注释与最终机制不符:注释说 ecs-review 池"白天刻意较小",但最终机制下白天是的(0 台)。

未覆盖范围:无法枚举真实 hk2 runner 列表(需 PAT);实时需求采样被 GitHub 二级限流(403) 挡住,因此上一轮的容量结论无法在新 head 上重测;9 个 commit 中只有 1 个在浅克隆里可达,只验证了聚合 diff;yamllint 装不上(pip3: Permission denied);未做 trial merge 到当前 main

Previous-finding status

Re-measured at 78857570, not carried over by reading the old report.

# previous finding severity then status at this head
F1 Capacity arithmetic does not balance; ~61% of reviews dropped; the cost is not in the accepted-tradeoff list substantive partly superseded, partly stands. The unit-mismatched figures (384 runner-hours vs demand of about 375) and the whole demand table have been deleted from the description, so Correction 1 no longer applies to any live claim. Supply is now 360 runner-hours/night with no ramp discount (measured, C6 — the full pool is eligible from minute 0 of the window, unlike the previous RAMP_STEP=8 curve). The description no longer asserts balance, but it also no longer quantifies demand, so the deficit is now unquantified rather than wrong. I could not re-measure demand: the public API returned 403 "exceeded a secondary rate limit" mid-probe.
F2 Suppressing the fallback comment removes the only PR-visible signal; no watchdog replaces it substantive stands, and is sharper. The guard hunk is byte-identical to last round (139→154 lines, the same 15 lines); re-measured 5 flip cells out of 20 with 15 controls unchanged. The watchdog census re-run at this head: grep finds zero references to qwen-review-runner-schedule outside the workflow itself, the size baseline and one comment, and zero review-pr mentions in qwen-fleet-shepherd.yml. What changed for the worse is the trigger rate — see Finding 1.
F3 main() is entirely untested, including the fence protecting the CI pool Suggestion stands, and the gap is larger. The suite is now 4 tests (was 6) and still imports only planLabels plus two YAML string matches. 10 of 21 mutants survive it, every one of them in main() or the workflow wiring, and every one behaviourally wrong — including M15 (stop forwarding the PAT to gh) and M16 (main never runs), which the previous round's mutant set did not cover.
F4 ecs-qwen-hk2-<n> name filter unverified against the live hk2 fleet risk stands. Still no token in this container. New evidence against assuming the convention: the real captured payload for run 32875478404 (2026-08-25) shows review-pr executing on runner_name="ecs-qwen-runner-64c-12" with labels=["self-hosted","linux","x64","ecs-qwen"] — a different naming family and a different pool from today's runs-on. Failure mode remains loud, re-proven by escalation: exit 1, ::error::no ecs-qwen-hk2-<n> runner found, 0 label writes.
Corr1 384 runner-hours compared against a count of 375 reviews correction superseded — both figures deleted from the description.
Corr2 The 24 h queue limit ends the contended case in cancellation, not delay correction accepted — Risk & Scope now states "GitHub can expire jobs queued for 24 hours".

No previous finding worsened in the code; F2's exposure grew because of the mechanism change, which is a design tradeoff the description states ("there is no … periodic polling"), not a regression in the diff.

Central claim and A/B

Central claim. Review capacity follows the clock: review-pr asks for ecs-review, and two daily cron runs switch every online hk2 runner — all of them, at once, no ramp — to ecs-review at 17:00 Asia/Shanghai and back to ecs-qwen at 05:00, dropping ecs-agent in both directions, skipping offline runners, preserving unrelated labels, and idempotently.

Secondary claims. (a) POOL resolves to review/ci from the two crons and from the dispatch input. (b) fallback-comment stops posting when review-pr was never picked up by a runner.

The base tree contains no planner, so a base-vs-head A/B on the planner can only prove the file is new. The load-bearing proof is three-sided: a closed-loop simulation driven by the real script (oracle: the exact runner-label API calls and the resulting fleet), a 21-mutant matrix showing every guard flips an observable, and a true base-vs-head A/B on the fallback-comment step, which does exist on both arms.

A/B table

# Cell Environment Observable oracle Base Head
A-S1 Evening switch, 32-runner fleet (30 online / 2 offline / half busy) real script, stateful fake gh on PATH full argv log + fleet labels + $GITHUB_STEP_SUMMARY n/a (no script) 30 × (−ecs-qwen −ecs-agent +ecs-review), 90 label calls, 1 list call, offline runners byte-identical, all 30 eligible for head's runs-on
A-S2 Morning switch from the S1 end state same same n/a 30 × back to ecs-qwen; ecs-review and ecs-agent both gone; round trip = original minus ecs-agent
A-S3 Idempotence, both directions same label-call count n/a 2nd identical run: 0 label calls, 1 list call, - 0 runners to switch
A-S4 Host isolation with real production names (ecs-qwen-hk3-4, hk4-23, hk5-31, ecs-qwen-runner-64c-12) plus near-misses (hk2-3-extra, hk2-, hk2-01x, ECs-qwen-hk2-7, hk22-1) same per-runner label diff + call targets n/a exactly the 4 well-formed hk2 ids touched; every other runner byte-identical; 0 calls to a foreign id
A-S5/S6 All-hk2-offline / no-hk2-at-all / empty fleet same exit code, ::error::, writes n/a all-offline → exit 0, 0 changes (Finding 5); no-hk2 → exit 1 no ecs-qwen-hk2-<n> runner found, 0 writes
A-S7..S9 Missing/empty PAT, 8 invalid modes, malformed repo same exit code + call count n/a all exit 1 with 0 gh calls — every guard fires before anything is on the wire
A-S10/S11 Injected POST failure on one runner, then the next tick same exit, summary, victim state, repair n/a exit 1, 1 runner label change(s) failed, victim named, other 29 applied; victim left with neither pool label; next ci tick self-heals it
A-S12 Pagination at page sizes 1 / 3 / 7 / 100 / 1000 same fleet signature n/a byte-identical fleet at all five; 1 list call each
A-S13 200-runner fleet, 40 ms per call same wall span vs serial floor n/a calls genuinely overlap (span ≪ serial floor); no EMFILE/EAGAIN
A-S14 Sparse-checkout self-containment script copied alone into an empty dir exit code n/a exit 0, fleet switched — the workflow's one-file sparse checkout is sufficient
A-S17/S18 Runner offline at the switch; runner registered mid-window same, multi-tick eligibility per tick n/a excluded for the entire 12 h night, still CI-eligible, picked up ~24 h later (Finding 4)
B fallback-comment, 20 cells verbatim step extracted by YAML parser (base 139 / head 154 lines), bash --noprofile --norc -e + the step's own set -uo pipefail, fake gh answering through the real /usr/bin/jq was gh pr comment invoked, with what exact body posted on 12 cells 5 cells flip to suppressed (B1 B2 B6 B7 B18), all of them a review-pr with no runner and no steps; the other 15 have byte-identical bodies
B-cal Real production payload: run 32875478404, review-pr cancelled with runner_name="ecs-qwen-runner-64c-12" and 18 steps same same posted the cancellation body posted the same body — the guard does not swallow a review that actually ran
C POOL resolution + 3-day closed loop expression read from the YAML; ticks driven by the real script resolved pool per trigger; eligible-runner count per tick review-pr needed ecs-agent: 30 eligible at all times 0 9 * * *review, 0 21 * * *ci, dispatch→input; timeline 30 → 0 → 30 → 0 → 30 → 0, stable over 3 days
E 21 single-point mutants scratch roots, never the working tree shipped suite, then the behavioural probe 11 killed / 10 survived; all 10 survivors proven behaviourally wrong (0 dead mutants)

Witnesses: 01-ab-fallback-comment-base-vs-head.png (the B cells as they printed), 02-schedule-timeline-and-queue-window.png (the C timeline and queue arithmetic), 03-mutation-matrix-21-mutants.png (the E matrix).

Mutation matrix (21 mutants, all classified)

id mutation shipped suite behavioural probe classification
M01 drop the status === 'online' filter killed offline runner relabelled pinned
M02 unanchor the name regex killed hk2-7-extra managed pinned
M03 drop the RUNNER_NAME scope filter killed hk1/hk3 + runner-64c-12 relabelled, API calls to foreign ids pinned
M04 remove includes the target label killed runner ends with no pool label pinned
M05 stop stripping ecs-agent killed two managed labels at once pinned
M06 invert the two pools killed day/night swapped pinned
M07 always POST the target killed not idempotent (3 calls on re-run) pinned
M08 emit an action when nothing changes killed caught by the suite pinned
M09 drop the missing-PAT guard SURVIVED not loud and 7 API calls on the wire unauthenticated coverage gap
M10 drop the empty-fleet guard SURVIVED escalated: pristine exit 1 / mutant exit 0 silent coverage gap
M11 swallow label-write failures SURVIVED escalated: pristine exit 1 / mutant exit 0 green on a broken pool coverage gap
M12 drop the mode validation SURVIVED lists the whole fleet before failing (1 wasted call) coverage gap
M13 drop the repo-argument validation SURVIVED malformed repo reaches the API coverage gap
M14 maxBuffer 16 MB → 64 B SURVIVED large listing fails, pool left unswitched coverage gap
M15 stop passing the PAT to gh SURVIVED every gh call has no token coverage gap
M16 main never runs when executed directly SURVIVED exit 0, nothing happens coverage gap
W01 move the evening cron, not the comparison killed cron no longer matches the compared literal pinned
W02 swap which cron opens the review pool killed evening-pool=ci, morning-pool=review pinned
W03 stop wiring secrets.RUNNER_ADMIN_PAT killed PAT not wired pinned
W04 sparse-checkout a different file SURVIVED checked-out path ≠ executed path (job would fail at runtime) coverage gap
W05 deployment: falsetrue SURVIVED a deployment record per label switch coverage gap

Positive controls. The pristine control is green under the identical command (suite passes, 0 invariant violations in both modes, idempotent, all loudness guards fire). 11 mutants were killed by that same suite — so "survived" means the suite does not assert that axis, not that the harness never ran. M10 and M11 initially looked silent; escalating to the cells that produce their precondition (an hk2-less fleet; an injected POST failure) showed both are genuinely wrong, so no mutant is dead code and all 10 survivors are coverage gaps.

Vacuity check

test revert applied result
scripts/tests/qwen-pr-review-workflow.test.js › "isolates the long-running review job on the schedule-sized review pool" review-pr runs-on reverted ecs-reviewecs-agent in the working tree, then restored 1 failed | 259 skipped (260), AssertionError: expected '${{ (github.repository == …' to be … — the intended expected-vs-actual mismatch, not an import or fixture break. git status --porcelain empty afterwards.
.github/scripts/review-runner-schedule.test.mjs same revert 4 pass / 0 fail — correctly unaffected; that suite reads the schedule workflow, not review-pr

Gates (each with a liveness control)

gate command result liveness control
shipped helper suite node --test .github/scripts/review-runner-schedule.test.mjs 4 pass / 0 fail a non-existent test file does not exit 0
affected vitest file npx vitest run --config ./scripts/tests/vitest.config.ts qwen-pr-review-workflow 260 pass / 0 fail, 1 file (52.3 s) -t 'schedule-sized review pool' collects exactly 1 test, and it fails on the revert above
workflow size ratchet bash .github/scripts/check-workflow-size.sh exit 0 planting 5 kB into the new workflow → exit 1; restored → exit 0
.size-baseline is regenerated, not hand-edited wc -c vs the entry 1942 = 1942, byte-exact
prettier --experimental-cli --check on all 7 changed files All matched files use Prettier code style! a planted unformatted file under scripts/ → exit 1. (Measured: tmp/ is prettier-ignored, so a plant there proves nothing.)
shellcheck 0.11.0 on both arms' extracted run: block clean, base and head a planted unquoted expansion is reported
actionlint 1.7.12 targeted + the repo gate node scripts/lint.js --actionlint 0 diagnostics, gate exit 0 inputs.pooIproperty "pooi" is not defined in object type {pool: string}; cron: '0 9 * *'invalid CRON format
eslint the two new .github/scripts files clean a planted unused variable is reported
HELPER_TESTS* wiring import census + path existence both lists name the new test; every listed path exists; dep-free placement earned (only node: builtins and one relative import)
sparse checkout pattern vs executed path pattern == executed script; cone-mode: false; persist-credentials: false mutant W04 shows a mismatch is observable
checkout pin SHA vs repo-wide usage df4cb1c0… # v6.0.3, the same SHA 75 other steps in this repo use
collateral git diff HEAD^1..HEAD -- .github/workflows/qwen-autofix.yml byte-identical to base

Corrections

To the description and comments, not requests to change behaviour.

  1. The in-repo comment added by this PR is stale against this PR's own final mechanism. The guard says "the ecs-review pool is deliberately small by day (qwen-review-runner-schedule.yml)". Under the twice-daily whole-pool switch there is no daytime subset at all: measured at C2/C6, the pool holds 30 runners inside the window and 0 outside it. "Small by day" describes the superseded QWEN_REVIEW_DAY_RUNNERS design. A reader debugging a suppressed comment would look for a small daytime pool that does not exist.
  2. github.event.schedule cannot be validated by any gate in this repo — but it is production-proven here. I tried to settle it with actionlint and measured the opposite of what I expected: actionlint 1.7.12 flags a typo'd inputs.pooI but reports nothing for github.event.schedul, because it does not type-check github.event.*. So the clean actionlint result is not evidence for that key. What is evidence: release.yml:179 ships CRON: '${{ github.event.schedule }}' and .github/scripts/run-release-step.sh:38,44 compare it against the literal cron strings, with scripts/tests/release-workflow.test.js documenting that nightly-vs-preview depends on it; qwen-autofix.yml:291 does the same. The operand-returning cond && 'literal' || fallback shape is likewise live at live-host-release.yml:42 (a concurrency group) and repo-hygiene.yml:220.
  3. deployment: false is accepted, not merely tolerated. The previous round could only cite prior art. Measured now: actionlint 1.7.12 reports zero diagnostics on the file without the repo's ignores, and the repo's standing -ignore 'unexpected key "deployment" for "environment" section' is defensive rather than load-bearing for this version.
  4. The repo's actionlint gate disables shellcheck (-shellcheck=), so embedded run: bash has no CI shellcheck coverage. I ran shellcheck 0.11.0 on both arms' extracted block directly: clean. Recorded so the clean result is not mistaken for a gate that already exists.

Findings

1. Losing a single switch now costs a whole night, and the PR's own PAT expiry date makes that permanent — with the announcement suppressed

Severity: substantive (about resilience and observability, not the planner's logic). Reproduce: node tmp/pr11855-verify-20260914-155525/harness-c-schedule.mjs (C5/C5b) and …/harness-b-fallback.mjs (B1/B2).

The previous revision converged on a */15 cron: 96 self-healing opportunities per day, so a skipped tick cost ≤15 minutes. This revision has 2 — a 48× reduction, and the description states the tradeoff explicitly ("There is no ramp, periodic polling, or configurable daytime subset"). What the description does not carry is what one lost tick now costs:

  • One skipped 09:00 UTC switch. The ci tick cannot repair it (measured: tick('ci') leaves 0 review-eligible runners). Worst queue wait becomes 2160 min = 36 h; reviews queued at the start of the lost window cross GitHub's 24 h cancellation and are dropped, not delayed; a full day of arrivals is delayed ≥12 h.
  • Every switch failing. The PR states the credential is "a 30-day classic PAT … It expires on 2026-10-14". Measured at C5b: with the PAT absent or rejected the tick exits 1 with ::error::RUNNER_ADMIN_TOKEN is empty, writes nothing, and the pool stays closed indefinitely. From that date, steady state is: every review-pr queues, none ever starts, all are cancelled at 24 h.
  • And nothing says so on the PR. Harness B cells B1/B2 are exactly that shape (runner_name null, steps empty): base posted the fallback comment, head suppresses it and exits 0. The cause survives only in $GITHUB_STEP_SUMMARY of the review run. The watchdog census re-run at this head finds zero references to qwen-review-runner-schedule anywhere in .github/workflows/ or scripts/ outside the workflow itself, and zero review-pr mentions in qwen-fleet-shepherd.yml.

So the same predicate is produced by (a) a healthy review waiting for tonight, (b) a review dropped because a switch was lost, and (c) a schedule that has been dead since 2026-10-14. Under (c) the Actions tab shows two red runs a day forever, and PRs show nothing.

What this is NOT. Not a silent wedge in the planner: every misconfiguration path I could reach fails loudly with a named cause (missing PAT, malformed mode, malformed repo, no hk2 runner, partial label-write failure), and no scenario left labels half-applied without the run exiting 1 and naming the victim. Not a regression for reviews that ran: the calibration cell on real payload 32875478404 proves a started-then-cancelled review still gets its comment. And the suppression itself is correct for its stated purpose — I could not break it across 20 cells.

Suggested direction (not measured as a patch — see the note)

The cheapest cover is not in this step. Because the planner already fails loudly, a watchdog that alerts when Qwen Review Runner Schedule has N consecutive failed or missing runs would close (c) completely and cost nothing on the review path. Covering (b) needs a number a maintainer sees daily — e.g. the schedule's own step summary recording how many review-pr jobs it saw cancelled-without-starting. Independently, the PAT's 2026-10-14 expiry is a dated, known cliff: a calendar reminder or an expiry check in the same workflow would convert a permanent silent outage into one loud notice.

I did not apply and re-measure a patch: each direction adds a new writer to shared state (a watchdog, a new summary field) rather than correcting the code under test, so per this skill's rule that a suggested fix must be driven through the same harnesses, it is offered as a direction. The fixture that would pin it: a run whose review-pr was never started, asserted to leave some maintainer-visible record. Today no test in either suite asserts anything about the summary line the new guard writes.

2. The day-one window reaches 24 h — exactly GitHub's queue-cancellation boundary

Severity: substantive rollout risk; the mitigation is documented in the PR. Reproduce: node …/harness-c-schedule.mjs (C4).

After merge, review-pr requests ["self-hosted","linux","x64","ecs-review"] but no runner carries ecs-review until the first 09:00 UTC tick or a manual dispatch. Measured over all 1440 possible merge minutes: the worst window is 1440 min = 24.00 h, reached by merging in the minute the cron has just fired; one minute later it is 1439 min. During the window the eligible count is 0, while the identical fleet was 30/30 eligible for the base arm's ecs-agent — so this is a new gap, not a pre-existing one. A review queued in it has the exact shape Finding 1's suppression covers, so it expires silently.

Risk & Scope names the mitigation ("After merging, manually run the appropriate pool switch once; no runner carries ecs-review until that switch runs"), and C4 confirms a manual dispatch closes the window immediately. Two refinements worth stating: the window is worst when the merge lands just after 09:00 UTC, and nothing in the diff enforces that the manual step happens — it is an instruction, not an invariant.

3. main() is entirely untested, and 10 of 21 mutants survive the shipped suite

Severity: Suggestion (per this repo's rule that a missing test for changed behaviour is a Suggestion, not a Critical).

The 4 shipped tests import planLabels and match YAML strings; they never call main(). Every survivor in the matrix above is a main() or wiring guard: the missing-PAT check (which also keeps the failure before anything reaches the wire — M09 puts 7 calls on it), the empty-fleet check, the label-failure throw, the mode and repo validation, maxBuffer, forwarding the PAT to gh (M15), the main-guard itself (M16), the sparse-checkout/executed-path agreement (W04), and deployment: false (W05).

This is completeness reporting, not a merge condition: every one of those guards is correct as written — Harness A proves each behaviourally (S5–S10, S14), and the escalation proves M10/M11 are load-bearing rather than dead.

A test that would close it needs no network: main() reads the repo from argv[2] and shells out to gh, so the same PATH seam this round used exercises it end to end. bin-a/gh and harness-a-planner.mjs in this artifact directory run against the unmodified script and would drop into .github/scripts/ as-is — both the script and its test import only node: builtins, which is why the HELPER_TESTS_DEP_FREE placement is earned.

4. A runner offline at the switch is excluded for the entire night — and keeps serving CI while reviews queue

Severity: minor, bounded. Reproduce: node …/harness-a-planner.mjs (S17, S18, S6).

planLabels filters on status === 'online', so an hk2 runner that is rebooting at 17:00 is skipped. Measured across the following ticks: it stays non-review-eligible for the whole 12 h window, remains ecs-qwen-eligible so it keeps taking CI jobs on the review host while reviews queue, is stripped of ecs-agent at 05:00 without ever gaining ecs-review, and is only picked up at the next evening switch (~24 h later). Cost: 12 runner-hours per affected runner per night (C6). A runner newly registered mid-window behaves the same way. Under the previous */15 convergence both were absorbed within 15 minutes.

What this is NOT. Not a correctness break and not silent mislabelling: offline runners are byte-identical after every tick (S1), the fleet guard still passes because runners.some() ignores status, and the runner is cleaned up at the next tick rather than left in a mixed state.

5. An all-offline hk2 fleet produces a green run with zero changes

Severity: minor. Reproduce: node …/harness-a-planner.mjs (S5).

The empty-fleet guard tests runners.some((r) => /^ecs-qwen-hk2-\d+$/.test(r.name)) without a status check, so "hk2 exists but every runner is offline" passes it, yields 0 actions, writes - 0 runners to switch to the step summary, and exits 0. Measured: 0 review-eligible runners, green run. That is defensible — an offline fleet is not this workflow's fault, and CI on those hosts is broken loudly elsewhere — but it means the one host failure that most needs attention is the one state this workflow reports as success, and it lands in the same blind spot as Finding 1.

6. Residual, bounded: the ecs-qwen-hk2-<n> filter still cannot be checked against the live fleet

Severity: risk to confirm post-merge, not a defect. Carried forward from F4 and re-measured. Enumerating runners needs GET /repos/…/actions/runners, which requires the very PAT this PR introduces; there is no token here. The new counter-evidence is concrete: a real captured payload shows review-pr running on ecs-qwen-runner-64c-12 with labels=["self-hosted","linux","x64","ecs-qwen"] — so this fleet has carried a different naming family and a different pool label for the same job within the last three weeks, and names are not derivable from labels. Bounded by escalation: a mismatch is loud (exit 1, no ecs-qwen-hk2-<n> runner found, 0 label writes) and cannot spill onto other pools (S4 drives 11 real and near-miss production names and touches only well-formed hk2 ids). Confirm in one command after the PAT exists: dispatch the workflow once and check the step summary reports the real online count rather than that error.

Note: no prompt-injection attempt

The PR body, commit messages and in-repo comments were treated as untrusted input and every claim tested rather than accepted. Nothing attempted to steer this verification. Several claims did not survive testing (Corrections 1–2, Findings 1–2) — ordinary inaccuracy, not injection.

Not covered

  • Live demand, so the capacity question could not be re-measured at this head. The public API returned 403 "You have exceeded a secondary rate limit" partway through the probe (logs-probe2.txt); the primary budget still showed 49/60. I captured run 32875478404's real jobs payload before the limit hit and used it to calibrate Harness B, but I could not sample review-pr arrival rate or durations, and could not find a real never-started run or a real posted fallback comment. Consequently the byte-for-byte comparison of the base arm against a comment production actually emitted is skipped, not passed — Harness B asserts the body against the step's own text instead. F1's deficit is therefore carried forward as unquantified, and the previous round's demand figures are not re-verified here.
  • The replay is only partly calibrated. It reproduces a real production jobs payload (both arms post the identical cancellation body for a review that really ran and was cancelled). It does not reproduce a real emitted comment, which is what would have calibrated the base arm byte-for-byte. It also reproduces the shape of a queue-expired review, not the cause: no cell here observes GitHub actually cancelling a job at 24 h.
  • The live hk2 runner list. Needs Administration: read. Unverified: that hk2 has 32 registered / 30 online / ~21 busy runners, that those are the real names, and the whole "Before & After" evidence block. Finding 6 bounds this. My simulation's 30-online figure is an assumption taken from the PR text, not a measurement.
  • Real api.github.com label writes. Exercised against a stateful fake gh at the process boundary. Its contract was taken from gh api --help (quoted: "To pass nested values as arrays, declare multiple fields with the syntax key[]=value1, key[]=value2" — the script's -f 'labels[]=…' form matches), but a real 4xx/5xx from the runner-label API is not something a fake can reproduce.
  • GitHub's own expression evaluator. POOL was evaluated by JS substitution (C1), justified by measured production prior art for the identical shape (live-host-release.yml:42, repo-hygiene.yml:220, qwen-autofix.yml:2175) and by actionlint confirming inputs.pool is a declared input — but not by running GitHub's evaluator. Recorded as a model, not an execution.
  • Per-commit attribution. The snapshot lists 9 commits; git rev-list --count HEAD^1..HEAD^2 returns 1 and git rev-parse --is-shallow-repository is true (depth-2 merge-ref checkout), so the count is a shallow-boundary artifact, not evidence. 25abcc9b (the previous round's head) is not reachable either, so no A/B against the previous revision was possible — the mechanism change from */15+ramp to 2×daily is documented from the diff and the description, not measured head-to-head. Only the aggregate HEAD^1..HEAD diff was verified.
  • yamllint. pip3: Permission denied in this container, so node scripts/lint.js --setup installed actionlint and shellcheck but not yamllint. Not run; the YAML is covered by actionlint and by the repo's own YAML-parsing tests instead.
  • Whether timeout-minutes on review-pr counts queue time. Unchanged by this PR (${{ fromJSON(vars.QWEN_REVIEW_JOB_TIMEOUT_MINUTES) }}) and still undocumented either way. If it did include queue time, Findings 1 and 2 would both get worse.
  • Autofix capacity impact. Removing ecs-agent from hk2 is measured in the planner (S1/S2 strip it in both directions) and qwen-autofix.yml is byte-identical to base, but ecs-agent pool membership cannot be enumerated without the PAT, so the resulting autofix queueing is unverified.
  • The qwen-triage.yml verify/tmux lanes. The diff does not touch qwen-triage.yml; the ci.yml change is confined to the two HELPER_TESTS* env strings, so no lane runtime changed and no in-container runtime measurement was needed.
  • CI-side execution of the new HELPER_TESTS_DEP_FREE entry. Placement is proven earned and both lists name the test, but I did not execute the dep-free lane itself.
  • A trial merge into current main. Not attempted: the checkout is depth 2, so main's tip and the merge-base are not present locally.

Methodology

All work ran inside the CI verify container (node:22-bookworm, node v22.23.2 at /usr/local/bin/node, gh 2.100.0, jq 1.6, no zstd, no GitHub token) against the depth-2 refs/pull/11855/merge checkout with npm ci and npm run build already completed. Artifact directory: tmp/pr11855-verify-20260914-155525/; base tree at tmp/base-tree (git worktree add tmp/base-tree HEAD^1).

The base side needed no rebuild: the code under test is a standalone .mjs importing only node: builtins and a bash block inside a YAML file, so the A/B crosses no workspace boundary and the internal-symlink hazard does not apply — asserted rather than assumed, by import census (G8: every module specifier in both new files is node: or relative) and by running the script alone in an empty directory (S14). git status --porcelain is empty at the end; every mutation, revert and planted liveness violation was restored, and the scratch worktree was removed with git worktree remove --force tmp/base-tree rather than left for the workflow's sweep.

Five harnesses drove the code; each is a .mjs file in that directory so a maintainer can rerun it, and each writes its own counts-*.json.

  • Harness A (harness-a-planner.mjs, 148 assertions, logs-A.txt) ran the unmodified script as a child process exactly as the workflow does. One seam, external to the code under test: a stateful fake gh first on PATH (bin-a/gh) holding one state file per runner so the script's own Promise.all label writes cannot race the harness, applying POST/DELETE, logging every argv as a JSON line and recording whether GH_TOKEN was non-empty. No clock seam is needed at this head — the pool arrives via argv, which is itself a simplification the rewrite bought. Pagination was forced at page sizes 1/3/7/100/1000; the fence cells use runner names captured live from the public API plus five near-miss shapes.
  • Harness B (harness-b-fallback.mjs, 121 assertions, logs-B.txt, logs-B-cells.json) extracted the Post fallback comment step's run: block verbatim with a YAML parser (extract-step.mjs) from the head tree and from tmp/base-tree — 154 vs 139 lines, and diff confirms the 15-line guard is the only difference — then executed each arm under bash --noprofile --norc -e plus the step's own set -uo pipefail. A second fake gh (bin-b/gh) answers from fixtures and runs the caller's own --jq expression through the real /usr/bin/jq rather than reimplementing it. The oracle is whether gh pr comment was invoked and with what exact body. All 20 cells ran on both arms. A fake sleep removes the 30 s dedup-retry delay and logs every invocation, so the harness can assert it was not needed anywhere except the one retry cell.
  • Harness C (harness-c-schedule.mjs, 52 assertions, logs-C.txt) read the cron list and the POOL expression out of the YAML with a parser, evaluated the expression for every declared trigger, and then drove a 3-day loop whose ticks are real invocations of the shipped script, scoring each state against the runs-on label sets parsed from both arms of qwen-code-pr-review.yml. Queue-wait figures are computed over all 1440 arrival minutes rather than sampled.
  • Harness D (harness-d-gates.mjs, 40 assertions, logs-D.txt, logs-D-gates.txt) re-ran every gate and planted a violation for each before citing its green result, including the 5 kB size-ratchet growth and an unformatted file on a path prettier does not ignore.
  • Harness E (harness-e-mutations.mjs, 12 assertions, logs-E.txt, matrix-E.json) applied 21 single-point mutants in per-mutant scratch roots — never the working tree — asserting each anchor occurs exactly once before accepting the mutation (two ambiguous anchors were caught this way and disambiguated), then ran the shipped suite and the behavioural probe. The two survivors the probe could not observe were escalated to cells that produce their precondition.

Live API access was read-only and anonymous (probe-live.mjs, probe2-live.mjs, raw responses under raw/); no token exists in this container and nothing was written to GitHub. It was cut short by a secondary rate limit, recorded under Not covered.

Linter binaries were installed with node scripts/lint.js --setup (never the no-arg form) and invoked individually; actionlint 1.7.12 and shellcheck 0.11.0 are the repo's pinned versions, and yamllint could not be installed.

Nine harness bugs of my own were found and fixed before the numbers above were taken, all harness faults rather than PR faults: a fixture whose runners 1–2 were offline while three assertions assumed they were online; an S3 expectation that ignored ecs-agent already being gone; an S4 fixture reusing offline runners; an S16 assertion that named a runner "already correct" for the wrong mode; a per-cell B assertion demanding the two arms agree, which contradicts the entire point of an A/B; a B log read that threw ENOENT on a cell exiting before any gh call; a C evaluator that modelled GitHub contexts as throwing on missing properties and two C arithmetic guesses (a 12 h open window bounds the steady-state wait at 12 h, not 24 h, and the mean wait is 3.0 h across the day, 6.0 h among arrivals that wait); a probe PATH omitting /usr/local/bin, where node actually lives, which made every fake-gh call exit 127. The last is worth naming because it silently inverted a result rather than erroring: Harness C's own tick() had the correct PATH, which is why its timeline is valid while Harness E's first run was not.

Assertion totals: 373 pass, 0 fail (A 148 + B 121 + C 52 + D 40 + E 12). No assertion encodes an expected failure as a fail: base-arm cells in Harness B assert posted=true on base and posted=false on head and both count as passes, and the mutant matrix asserts that survivors are behaviourally wrong, so the zero is a real zero. The verdict is findings, not merge-ready, because of Findings 1–2 — not because any assertion failed.

Flakiness gate log

rounds=5 files=2 skipped=0
file .github/scripts/review-runner-schedule.test.mjs: (cd .) node --test ./.github/scripts/review-runner-schedule.test.mjs
file scripts/tests/qwen-pr-review-workflow.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/qwen-pr-review-workflow.test.js


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  .github/scripts/review-runner-schedule.test.mjs: PPPPP
  scripts/tests/qwen-pr-review-workflow.test.js: PPPPP

verdict: pass
summary: 2 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · .github/scripts/review-runner-schedule.test.mjs: P (exit 0)
round 1 · scripts/tests/qwen-pr-review-workflow.test.js: P (exit 0)
round 2 · .github/scripts/review-runner-schedule.test.mjs: P (exit 0)
round 2 · scripts/tests/qwen-pr-review-workflow.test.js: P (exit 0)
round 3 · .github/scripts/review-runner-schedule.test.mjs: P (exit 0)
round 3 · scripts/tests/qwen-pr-review-workflow.test.js: P (exit 0)
round 4 · .github/scripts/review-runner-schedule.test.mjs: P (exit 0)
round 4 · scripts/tests/qwen-pr-review-workflow.test.js: P (exit 0)
round 5 · .github/scripts/review-runner-schedule.test.mjs: P (exit 0)
round 5 · scripts/tests/qwen-pr-review-workflow.test.js: P (exit 0)

Evidence images

01-ab-fallback-comment-base-vs-head

02-schedule-timeline-and-queue-window

03-mutation-matrix-21-mutants

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@yiliang114 yiliang114 changed the title ci(review): switch the hk2 runner pool twice daily ci(review): switch online hk2 runners between review and CI twice daily Sep 14, 2026
yiliang114 and others added 2 commits September 14, 2026 23:55
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

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

8 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R1-5 fleet guard checks runner names only, so an all-offline fleet exits 0 — already reported (comment 4008266746)
  • R1-6 entry-module detection by hardcoded basename suffix — already reported (comment 4008266761)
  • R1-9 pool label as two independent literals in two files — already reported (comment 4008266730)
  • R1-7 job named converge wired edge-triggered at two cron instants — already reported (comment 4008266836)
  • R1-8 12-hour pool turns autofix's review-in-flight gate into an unbounded hold — already reported (comment 4008266780)
  • R1-10 the only documented brake freezes the fleet in the last pool — already reported (comment 4008266799)
  • R1-11 ack-review-request's acknowledgement becomes false in the day window — already reported (comment 4008266769)
  • R1-12 the workflow assertion does not pin the environment: block — already reported (comment 4008266826)

Not explored to full depth (tool budget reached): "agent reverse-audit (round 3)": executing the three new cases under vitest — this review worktree has no node_modules ( ls node_modules/.bin/prettier → absent), so the mutation evidence abo…; "agent reverse-audit (round 3)": prettier/eslint over .github/scripts/review-runner-schedule.mjs and scripts/tests/qwen-pr-review-workflow.test.js — no local toolchain in the worktree, so f…; "agent reverse-audit (round 4)": eslint on the three touched files — the worktree's node_modules lacks @eslint/js , so eslint.config.js fails to load and the lint dimension rests on pret…; "agent reverse-audit (round 4)": live confirmation of the fleet's actual names, labels and status values — gh api repos/QwenLM/qwen-code/actions/runners returns HTTP 403 for this runner's t….

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • .github/scripts/review-runner-schedule.mjs:41 — [probe] no execFile timeout, and the only record of which hosts were relabelled is written after every call returns

[Critical] R1-2 [fails-closed] [regression] .github/workflows/qwen-code-pr-review.yml:3165-3170 — still stands from round 1, and could not be anchored inline this round because that file is outside the incremental scope (d10233234c..45f8b30a9b touches only the schedule script and the workflow test). The never-started branch ends in a bare exit 0, so a review job no runner ever picked up produces no PR-facing output at all — the explanation goes only to $GITHUB_STEP_SUMMARY of an ubuntu-latest job that only a maintainer opening the run will see, while on the merge base this step posted a comment for the same input. This round's diff adds the three tests that pin the silence. It bites when the pool loses ecs-review (every POST fails, the schedule is disabled, or the PAT expires on its documented 2026-10-14 date): a maintainer comments @qwen-code /review, ack-review-request posts "review request accepted. Review is running in workflow run" — and that ack body itself states a command-triggered review "is not listed under the checks of this PR", so the fallback comment is the requester's only channel — then review-pr queues until GitHub ends it, the guard sees runner_name empty and steps empty, writes one summary line and exits 0. The PR page shows no check and no comment, and the outcome the guard's own comment relies on ("the PR's next push queues a fresh review") does not exist for a review requested by hand on a head nobody intends to change. Witness: on the step extracted verbatim by qwen review extract-step --job fallback-comment --step 0 and driven with the suite's own gh stub and real jqfailure + a never-started job list posts 0 bytes, cancelled + the same list posts 0 bytes, cancelled + a ran job posts 414; a live sweep of 400 cancelled runs found 52 with a never-started review-pr and 17 gate-open, and the step's real --jq over run 34865373716's real API response returns "true", so the guard fires on the production shape. Suggested fix: keep suppressing the two false claims, but post an accurate body when never_started = true (e.g. "the review never started: no runner picked the job up before its queue expired; request it again with @qwen-code /review") under the same $FALLBACK_MARKER + run-URL dedupe, and/or emit echo "::warning::review-pr never got a runner (review pool closed); no review ran for this head" beside the summary line so it surfaces as a run annotation. The fix must not violate: any replacement body has to keep the marker-plus-run-URL shape the dedupe is built on — expect(r.posted.startsWith(${marker}\n\n)).toBe(true) at scripts/tests/qwen-pr-review-workflow.test.js:4198 and :4211, and the dedupes on the marker plus this run URL case at :4215 — or a re-run of the same attempt posts a second comment; and qwen-code-pr-review.yml:3162's "An unreadable job list keeps the comment (fail toward telling the author something)" direction must survive. Please confirm the assertion goes red when the fix is removed: scripts/tests/qwen-pr-review-workflow.test.js:4157 should assert r.posted starts with the marker, contains a queue-expiry reason, and does not contain did not complete successfully or retried automatically, while still posts when review-pr did run on a runner (:4183) stays green.

中文说明

本轮确认的 8 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 3)"executing the three new cases under vitest — this review worktree has no node_modules ( ls node_modules/.bin/prettier → absent), so the mutation evidence abo…"agent reverse-audit (round 3)"prettier/eslint over .github/scripts/review-runner-schedule.mjs and scripts/tests/qwen-pr-review-workflow.test.js — no local toolchain in the worktree, so f…"agent reverse-audit (round 4)"eslint on the three touched files — the worktree's node_modules lacks @eslint/js , so eslint.config.js fails to load and the lint dimension rests on pret…"agent reverse-audit (round 4)"live confirmation of the fleet's actual names, labels and status values — gh api repos/QwenLM/qwen-code/actions/runners returns HTTP 403 for this runner's t…

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 1 条(原文未翻译,列表见上方英文部分)。

[Critical] R1-2 [fails-closed] [regression] .github/workflows/qwen-code-pr-review.yml:3165-3170 — still stands from round 1, and could not be anchored inline this round because that file is outside the incremental scope (d10233234c..45f8b30a9b touches only the schedule script and the workflow test). The never-started branch ends in a bare exit 0, so a review job no runner ever picked up produces no PR-facing output at all — the explanation goes only to $GITHUB_STEP_SUMMARY of an ubuntu-latest job that only a maintainer opening the run will see, while on the merge base this step posted a comment for the same input. This round's diff adds the three tests that pin the silence. It bites when the pool loses ecs-review (every POST fails, the schedule is disabled, or the PAT expires on its documented 2026-10-14 date): a maintainer comments @qwen-code /review, ack-review-request posts "review request accepted. Review is running in workflow run" — and that ack body itself states a command-triggered review "is not listed under the checks of this PR", so the fallback comment is the requester's only channel — then review-pr queues until GitHub ends it, the guard sees runner_name empty and steps empty, writes one summary line and exits 0. The PR page shows no check and no comment, and the outcome the guard's own comment relies on ("the PR's next push queues a fresh review") does not exist for a review requested by hand on a head nobody intends to change. Witness: on the step extracted verbatim by qwen review extract-step --job fallback-comment --step 0 and driven with the suite's own gh stub and real jqfailure + a never-started job list posts 0 bytes, cancelled + the same list posts 0 bytes, cancelled + a ran job posts 414; a live sweep of 400 cancelled runs found 52 with a never-started review-pr and 17 gate-open, and the step's real --jq over run 34865373716's real API response returns "true", so the guard fires on the production shape. Suggested fix: keep suppressing the two false claims, but post an accurate body when never_started = true (e.g. "the review never started: no runner picked the job up before its queue expired; request it again with @qwen-code /review") under the same $FALLBACK_MARKER + run-URL dedupe, and/or emit echo "::warning::review-pr never got a runner (review pool closed); no review ran for this head" beside the summary line so it surfaces as a run annotation. The fix must not violate: any replacement body has to keep the marker-plus-run-URL shape the dedupe is built on — expect(r.posted.startsWith(${marker}\n\n)).toBe(true) at scripts/tests/qwen-pr-review-workflow.test.js:4198 and :4211, and the dedupes on the marker plus this run URL case at :4215 — or a re-run of the same attempt posts a second comment; and qwen-code-pr-review.yml:3162's "An unreadable job list keeps the comment (fail toward telling the author something)" direction must survive. Please confirm the assertion goes red when the fix is removed: scripts/tests/qwen-pr-review-workflow.test.js:4157 should assert r.posted starts with the marker, contains a queue-expiry reason, and does not contain did not complete successfully or retried automatically, while still posts when review-pr did run on a runner (:4183) stays green.

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

Comment thread .github/scripts/review-runner-schedule.mjs Outdated
Comment thread .github/scripts/review-runner-schedule.mjs Outdated
Comment thread .github/scripts/review-runner-schedule.mjs
Comment thread scripts/tests/qwen-pr-review-workflow.test.js Outdated
Comment thread scripts/tests/qwen-pr-review-workflow.test.js Outdated
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Historical-head review — head moved to e282a8913bb9eacb5e7e451ad7f27beadf5825a1 while this review was in flight (past the salvage threshold), so the run finished and posted against the head it reviewed: 45f8b30a9bacb50034e500b789db8c5f000ea5b9 (#10110). The next automatic review covers the delta from that anchor. Full log in the workflow run.

中文说明

历史 head 评审 —— 本次评审进行中 head 移动到了 e282a8913bb9eacb5e7e451ad7f27beadf5825a1(已过 salvage 阈值),因此评审跑完并针对其实际评审的 head 45f8b30a9bacb50034e500b789db8c5f000ea5b9 发布(#10110)。下一次自动评审将从该锚点起评审增量。完整日志见 workflow 运行

yiliang114 and others added 6 commits September 15, 2026 10:44
R1-3: the POST-before-DELETE comment claimed "a failure can only leave the
host in both pools". A failed POST issues no DELETE, so that host stays in
its previous pool only; it is a failed DELETE that leaves it in both. The
comment now states the pair accurately and keeps the manual-recovery step
visible, since no periodic reconciliation exists.

R2-2: the two cases that drive the stub's real-jq path now carry the same
`it.skipIf(!hasJq)` gate as the eight siblings in the describe. Without jq
the stub's job-list branch wrote nothing and the step posted, so the skip
case went red on a host that merely lacked jq while its control passed
vacuously.

R2-3: the job-list fixtures sat on the corners where the guard's two
clauses agree, so `and` -> `or` and `then "unknown"` -> `then "true"` both
survived the suite. Two fixtures cover the states they diverge on: a runner
assigned before any step was recorded, and a listing that carries no
review-pr job at all. Each new case goes red under its own mutation.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-closeout/jmu21u6aq5l
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
The never-started guard ended in a bare `exit 0`, so a review job no
runner ever picked up produced no PR-facing output at all: the
explanation went only to the step summary of an ubuntu-latest job. On
the merge base the same input posted a comment, and the requester has
no other channel — ack-review-request promises the result is posted on
the PR and notes a command-triggered review is not listed under the
PR's checks. A head nobody pushes again never queues another run, so
the silence was permanent.

Suppressing the two false claims stays; the branch now composes a third
body that is true (queue expired, nothing ran, re-request with
`@qwen-code /review`) under the same marker-plus-run-URL shape the
cross-job dedup anchors on, and the cancelled flavor routes through it
too. An unreadable job list still keeps the generic comment.

The size ratchet records the real byte count: the guard's prose grew
and the third body is load-bearing, so qwen-code-pr-review.yml moves
261105 -> 265275 (allowance 4096, gate 470000).

Witness: the queue-expiry case asserts the marker prefix, the run-URL
anchor, the retry instruction, the absence of "did not complete
successfully" / "retried automatically", and both `failure` and
`cancelled` results; restoring the silent `exit 0` turns it red. The
control (a runner was recorded) keeps the failure body, and the
body-count pin moves 2 -> 3.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
The scan gate's delay-window fallback went out with the `ecs-review`
pool change, and its `qwen-autofix.md#af-099` pointer went with it —
but the section itself stayed, so the bidirectional design-record
mirror failed CI:

  workflow-size.test.js > qwen-autofix.yml design-record pointers
    > every section is still pointed at from the workflow
  AssertionError: expected [ 'af-099' ] to deeply equal []

Remove the section and its contents-table row. Ids are stable, so 99
is left unallocated rather than renumbering every later section.

The af-099 citation did not stand alone: the refresh-side hold in
qwen-autofix.yml and its af-155 prose both described themselves as
using "the scan gate's probe pair", including a runs-API fallback the
scan gate no longer has. Both now state the real shape — the refresh
probe keeps its own runs-API fallback and still holds on
QUEUED/WAITING/PENDING, while the scan gate holds only on
IN_PROGRESS — and give the reason the two differ: a hold at the scan
gate costs a whole round, a hold here defers one stale-base merge the
next round retries.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Round-1 review follow-ups, each with a witness that goes red when the
fix is removed.

The `converge` job checks out and executes the planner from the
dispatched ref while holding RUNNER_ADMIN_PAT in its environment. The
only fence was the environment's deployment-branch policy, which lives
in repository settings and is invisible in the diff — recreate the
environment with GitHub's default "All branches" policy and an actor
with write access, who cannot otherwise reach protected main, runs
their own planner with an admin token. Add the in-repo `github.ref`
clause; `schedule` events already evaluate against the default branch,
so this only blocks dispatches.

The fleet guard had no witness: deleting it left the suite green while
production exits 0 after "0 runners to switch", the pool never opens,
and review-pr queues into a closed `ecs-review` pool until GitHub ends
each job at 24 hours. Now covered for an empty listing, a renamed
fleet, and a near-miss name.

Three fallback-comment controls asserted only exit status and the
marker prefix, which every one of the three bodies satisfies, so
`and` -> `or` in the never-started filter, dropping its runner_name
clause, or reading the guard as `!= "false"` all survived while
relabeling a genuine failure as a queue expiry. Each now asserts the
body it must get.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Five P3s from the second review round; no behaviour change except the
queue-expiry body's wording.

- One `FLEET_NAME` literal for both the switch filter and the fleet
  guard. Two copies of the same regex could drift, and a guard that no
  longer matches the filter reintroduces the exact silent no-op it
  exists to prevent.
- The queue-expiry body asserted a cause the step cannot observe ("the
  pool is only open for part of the day"). An empty pool, an all-offline
  fleet and a renamed fleet satisfy the same predicate, and on day one
  — before the first switch labels anything `ecs-review` — the claim is
  simply false while the prescribed remedy re-queues into the same empty
  pool. It now says what the step knows and points at the schedule run
  that can tell the causes apart.
- The `github.ref` pin is anchored to a job-level `if:` instead of
  matching the substring anywhere in the file. Relocating the clause to
  the step leaves the job and its checkout running on a non-main
  dispatch; the old pin stayed green, this one goes red (verified).
- Dropped a comment clause that described the comment, and rewrapped two
  lines the af-155 edit left at 75 and 100 columns.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Picked this up at 27fa25cd0f. Four commits pushed; head is now d45b5d9811.

The CI failure

Test (ubuntu-latest, Node 22.x) was the only red check, and it was this PR's fault:

workflow-size.test.js > qwen-autofix.yml design-record pointers
  > every section is still pointed at from the workflow
AssertionError: expected [ 'af-099' ] to deeply equal []

The scan gate's delay-window fallback went out with the pool change and took its qwen-autofix.md#af-099 pointer with it, but the design-record section stayed behind. That mirror is bidirectional, so an orphaned section fails. Removed the section and its contents-table row in 3a8a8a63ba; ids are stable, so 99 stays unallocated rather than renumbering every later section.

The same deletion had left two stale claims: the refresh-side hold in qwen-autofix.yml and the af-155 prose both described themselves as using "the scan gate's probe pair", including a runs-API fallback the scan gate no longer has. Both now state the real shape — the refresh probe keeps its own fallback and still holds on QUEUED/WAITING/PENDING, while the scan gate holds only on IN_PROGRESS — and give the reason they differ (a hold at the scan gate costs a whole round; a hold here defers one stale-base merge the next round retries).

R1-2, the last live Critical from round 2

Fixed in 702a9b4fe3. The never-started guard ended in a bare exit 0, so a review job no runner ever picked up produced zero PR-facing output — the explanation went only to the step summary of an ubuntu-latest job. On the merge base the same input posted a comment, and the requester has no other channel: the ack promises the result is posted on the PR and notes a command-triggered review is not listed under this PR's checks. A head nobody pushes again never queues another run, so the silence was permanent.

Suppressing the two false claims stays; the branch now composes a third body that is true, under the same marker-plus-run-URL shape the cross-job dedup anchors on, and the cancelled flavor routes through it too. An unreadable job list still keeps the generic comment.

Witness: restoring the silent exit 0 turns the new case red. The three sibling controls only asserted exit status and the marker prefix, which all three bodies satisfy — so andor in the filter, dropping its runner_name clause, and reading the guard as != "false" all survived while relabelling a genuine failure as a queue expiry. Each now asserts the body it must get; all three mutants confirmed red.

Security: the admin PAT had no in-repo fence

converge checks out and executes the planner from the dispatched ref while holding RUNNER_ADMIN_PAT. The only thing stopping branch-chosen script code from running with that token was the environment's deployment-branch policy, which lives in repository settings and is invisible in the diff — recreate the environment with GitHub's default "All branches" policy and an actor with write access, who cannot otherwise reach protected main, runs their own planner with an admin token. Added github.ref == 'refs/heads/main' to the job's if: in 9a58f67170. schedule events already evaluate against the default branch, so the two crons are unaffected. Pinned to a job-level if: rather than a bare substring: relocating the clause to the step leaves the job and its checkout running on a non-main dispatch, and a substring pin stays green for that (verified red with the anchored form).

Also gave the fleet guard a witness — deleting it previously left the suite green while production exited 0 after "0 runners to switch", the pool never opened, and review-pr queued into a closed pool until GitHub ended each job at 24 h.

One fact about the live fleet worth recording before merge

Queried actions/runners (167 runners): exactly 64 match ^ecs-qwen-hk[12]-\d+$ (62 online), and none of them carries ecs-qwen, ecs-review, or ecs-agent — their labels are self-hosted,Linux,X64,ecs plus one-offs (ecs-update-hk-1, hk2-diag-20260912). The 93 runners carrying ecs-qwen are hk3/hk4/hk5. No workflow in .github/workflows/ selects the bare ecs label, so hk1/hk2 are currently unselected by repo workflows.

Two consequences the description did not state:

  • The 05:00 switch does not return 62 hosts to CI, it adds them: the ecs-qwen pool goes 93 → 155. Their names already match ci.yml's startsWith(runner.name, 'ecs-qwen-') tuning, but nothing here verifies they are provisioned like hk3/hk4/hk5.
  • Until the first review switch or a manual dispatch, no runner carries ecs-review, so every review queues. That is the documented day-one step, but it is a hard prerequisite, not a nicety — and it is why the queue-expiry body no longer asserts "the pool is only open for part of the day". An empty pool, an all-offline fleet and a renamed fleet satisfy the same predicate, and on day one that claim would have been flatly false while its own remedy re-queued into the same empty pool.

Review rounds

Two rounds, four dimensions each (correctness / security / quality+simplicity / test value), full diff at each head. Round 2 verdict from both scouts: converged, nothing merge-blocking. Round 2 left six P3s; five are in d45b5d9811 (shared FLEET_NAME literal so the guard and the filter cannot drift, cause-neutral queue-expiry body, anchored ref pin, one decoration clause dropped, two 75/100-column lines rewrapped).

Declined, with reasons:

  • A non-main dispatch now skips converge, and a workflow whose only job skipped reports success. True, and I am leaving it. The alternative (ref: 'main' on the checkout, no ref clause) removes the silent green but makes a branch dispatch perform a real label switch the operator did not intend. For a job that mutates runner labels with an admin token, "nothing happened and the run page shows converge skipped" is the better failure mode than "something happened that I did not ask for".
  • Pinning the pagination contract (--slurp / pages.flatMap). The silent mutant needs >100 runners on two hosts; the live fleet has 64 matching. The other mutant (dropping --slurp) fails loudly on both daily switches. Fails the project's test-value gate on reachability.
  • Collapsing the never_started tri-state. No longer a matter of taste: reading the guard as != "false" is now a failing mutation, so the third state is pinned.

Gates at d45b5d9811

prettier, eslint, yamllint clean · check-workflow-size.sh green (baseline records the real 265415 bytes; the bump was mandatory, the PR grew that file past the 4096 allowance) · planner node --test 6/6 · design-record pointers 4/4 · qwen-pr-review-workflow.test.js -t fallback 48 passed / 2 failed.

The 2 failures are local-only root artifacts and are not attributable to this diff: both are health-probe cases where chmod cannot deny uid 0, and they fail identically at the pristine parent commit. Same class for the two local failures in qwen-autofix-workflow.test.js (one chmod tamper knob, one 5 s timeout under load) — all four pass in CI, which runs as a non-root runner.

中文

接手时 head 是 27fa25cd0f,已推 4 个 commit,现在 head 是 d45b5d9811

CI 为什么红:唯一红的是 Test (ubuntu-latest, Node 22.x),原因是本 PR 自己的。删掉 scan gate 的 delay-window fallback 时,qwen-autofix.md#af-099 的指针跟着代码一起没了,但设计记录段落留在原地;这个镜像是双向的,孤儿段落就判红。已在 3a8a8a63ba 删掉该段落和目录行(编号保持稳定,99 不再分配,不重排后续段落)。同一次删除还留下两处过时表述——refresh 侧的 hold 注释和 af-155 正文都自称使用「scan gate 的 probe pair」,含一个 scan gate 已经没有的 runs-API fallback——现在都改成真实形态,并写明两侧为何不同。

R1-2(round 2 最后一个活着的 Critical):已在 702a9b4fe3 修。never-started 守卫原本以裸 exit 0 结束,没有任何 runner 领取的 review 在 PR 上零输出,只写进一个 ubuntu-latest job 的 step summary。合并基线上同样输入是会发评论的,而请求者没有别的通道:ack 承诺结果发在 PR 上,并说明命令触发的 review 不出现在本 PR 的 checks 里;没人再推的 head 也永远不会再排一次。保留对两条虚假声明的抑制,改为在同一 marker + run-URL 去重形态下组织第三条真实的 body,cancelled 也走它;job 列表读不到时仍保留通用评论。把静默 exit 0 改回去,新用例会红。三个对照用例原本只断言退出码和 marker 前缀——三种 body 都满足——所以把过滤器的 and 改成 or、去掉 runner_name 子句、把读点写成 != "false",三个变异都能存活并把真实失败改写成「排队过期」;现在每个都断言自己应得的 body,三个变异均已确认变红。

安全converge从被 dispatch 的 ref 检出并执行 planner,同时环境里持有 RUNNER_ADMIN_PAT。唯一拦住「分支上的脚本代码带着 admin token 运行」的是 environment 的部署分支策略,它在仓库设置里、diff 里看不见——按 GitHub 默认的 All branches 重建该 environment,一个有 write 权限但改不动受保护 main 的人就能跑自己的 planner。已在 9a58f67170 给 job 的 if: 加上 github.ref == 'refs/heads/main'schedule 事件本来就按默认分支求值,两个 cron 不受影响。pin 锚定在 job 级 if: 而不是裸子串:把子句挪到 step 级会让 job 和它的 checkout 在非 main dispatch 时照样运行,而子串 pin 对此保持绿色(已用锚定形式验证变红)。同时给 fleet 守卫补了见证——此前删掉它整个套件仍然绿,而生产环境会在打印「0 runners to switch」后 exit 0,池子永远不开,review-pr 排进关闭的池子直到 GitHub 在 24 小时结束每个 job。

合并前值得记录的一个真实机群事实:查 actions/runners(167 台),匹配 ^ecs-qwen-hk[12]-\d+$ 的正好 64 台(62 台在线),而它们都不带 ecs-qwenecs-reviewecs-agent,标签是 self-hosted,Linux,X64,ecs 加个别一次性标签。带 ecs-qwen 的 93 台是 hk3/hk4/hk5。仓库里没有任何 workflow 选中裸 ecs 标签,所以 hk1/hk2 目前不被仓库 workflow 使用。两个描述里没写的后果:05:00 的切换不是把 62 台给 CI,而是加入 CI,ecs-qwen 池从 93 变 155;它们的名字已经匹配 ci.ymlstartsWith(runner.name, 'ecs-qwen-') 调优,但这里没有任何东西证明它们的预配和 hk3/hk4/hk5 一致。另外在第一次 review 切换或手动 dispatch 之前没有任何 runner 带 ecs-review,所有 review 都会排队——这是文档里的 day-one 步骤,但它是硬前提而不是可选项,也正是 queue-expiry body 不再断言「池子只在一天的一部分时间开放」的原因:空池、全部离线、机群改名满足同一个谓词,而在 day one 那句断言是明确错的,它自己给的补救办法还会把请求重新排进同一个空池。

评审轮次:两轮,每轮四个维度(正确性 / 安全 / 质量与简化 / 测试价值),每轮都在当时的 head 上看完整 diff。第二轮两路 scout 的结论都是已收敛、无合并阻塞项。第二轮留下 6 条 P3,5 条已在 d45b5d9811 处理(共用 FLEET_NAME 字面量以免守卫与过滤器漂移、queue-expiry body 改为不断言观测不到的原因、ref pin 加锚定、删掉一句描述注释的注释、重排两行 75/100 列的文本)。

已拒绝并给出理由:非 main dispatch 会跳过 converge,而唯一 job 被跳过的 workflow 报成功——属实,但保留现状;另一种写法(checkout 加 ref: 'main'、去掉 ref 子句)消除了「绿色但什么都没做」,却让分支 dispatch 真的执行一次操作者并不想要的标签切换。对一个用 admin token 改 runner 标签的 job 来说,「什么都没发生、run 页面显示 converge 被跳过」比「发生了我没要求的事」是更好的失败形态。分页契约(--slurp / pages.flatMap)不加 pin:静默变异需要两台主机上超过 100 个 runner,真实机群只有 64 个匹配的;另一个变异(去掉 --slurp)在两次日常切换上都会大声失败,按项目测试价值门判为不可达。never_started 三态不收敛:这已经不只是品味问题——把守卫读成 != "false" 现在是一个会失败的变异,第三态已被 pin 住。

d45b5d9811 的门禁:prettier、eslint、yamllint 全清;check-workflow-size.sh 绿(baseline 记录真实字节 265415,这次上调是必须的,本 PR 让该文件超过了 4096 的增长容差);planner node --test 6/6;设计记录指针 4/4;qwen-pr-review-workflow.test.js -t fallback 48 通过 / 2 失败。这 2 个失败只在本机出现、与本 diff 无关:都是 health-probe 里 chmod 无法拒绝 uid 0 的用例,在未经修改的父提交上表现完全一致。qwen-autofix-workflow.test.js 本机那 2 个失败同类(一个 chmod 篡改旋钮、一个负载下的 5 秒超时),四个在 CI 上都通过——CI 以非 root 的 runner 身份运行。

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 661 passed · 0 failed · 661 total

Flakiness gate: ⚠️ timeout — only 3 of 5 rounds fit the 15-minute budget; the completed rounds agreed

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:661 通过 · 0 失败 · 661 总计

抖动门:⚠️ timeout — only 3 of 5 rounds fit the 15-minute budget; the completed rounds agreed

Verification report (report.md, truncated)

# PR #11855 deep verification (round 3) — `ci(review): switch online hk1 and hk2 runners between review and CI twice daily`

**Verdict: `findings`** — 661 scripted assertions ran, **661 passed / 0 failed**. The twice-daily whole-pool switch is correct and proven load-bearing end to end, and every finding this verification round raised against the previous two revisions has been fixed or is now bounded. The remaining findings are about one user-facing causal claim, one stale bookkeeping number, and the unquantified capacity tradeoffs — not about the planner's logic.

Verified head: `d45b5d9811df74020df4f36878c54d48a8eb46d9` (`git rev-parse HEAD^2`; matches `headRefOid` in the metadata snapshot). Base: `995f91c4f1e486f23dac8210c14e27aa7f6e31d2` (`HEAD^1`). Effective diff: `git diff HEAD^1..HEAD`, 10 files, +632/−193.

This is a **follow-up round**. Round 2 verified `7885757002040459588b563889b662c2ad244078`; **10 commits have landed since**, and they change the mechanism materially (hk1 added to the fleet filter, `ecs-agent` no longer stripped, add-before-remove ordering, a `main` fence, and the fallback comment's suppression replaced by a dedicated body). Every carried-forward measurement below was **re-run at this head**, not diffed against the old report.

<details>
<summary>中文摘要</summary>

**结论:`findings`(有发现,需要评审人注意)** — 共执行 661 条脚本断言,全部通过(661 pass / 0 fail)。每天两次的整池切换机制本身正确,并经端到端证明是起作用的;前两轮验证提出的问题,在这一版里已全部修复或已被界定范围。剩下的发现集中在一条面向用户的因果陈述、一个过期的记账数字、以及未量化的容量取舍上,不在调度器逻辑本身。

**A/B 结论**(见 "Central claim and A/B" 与各表)

- 用 PATH 上的有状态假 `gh`(**每个 runner 一个状态文件**,避免脚本自身 `Promise.all` 与 harness 竞争)驱动**未经修改的真实脚本**,覆盖 17 组场景:hk1+hk2 整池切换、`ecs-agent` 保留、加标签先于删标签、注入失败后的降级与自愈、真实生产主机名与近似名的隔离、幂等、分页、200 台扇出、以及"仅检出该脚本能否独立运行"。**243 条断言全通过**。见 `02-schedule-timeline-and-queue-window.png`。
- `Post fallback comment` 步骤用 YAML 解析器**逐字提取**(base 139 行 / head 159 行,`diff` 只有那 22 行),在步骤自身的 shell 约定下对**两条臂各跑一遍** 24 个 cell。**5 个 cell** 从"发通用/取消正文"翻转为"发队列过期专用正文";其余 19 个 cell 两条臂正文逐字节相同。并用**真实生产数据校准**:run `32875478404` 的真实 jobs payload + PR #9729 上真实发出的那条评论(id `5414317254`),两条臂都逐字节复现。见 `01-ab-fallback-comment-base-vs-head.png`。
- autofix 扫描门禁用**逐字提取的 jq filter** 在两条臂上重放 15 种 check 形态:**恰好 6 种翻转**(QUEUED / PENDING / WAITING / REQUESTED,经 `status` 与经 `state`,外加带噪声的一组),其余 9 种两臂一致。见 `03-autofix-gate-ab-matrix.png`。
- 三天闭环仿真由**真实脚本**在每个 tick 上驱动:晚间 30 台 review-eligible、早间归零并恢复 CI,`ecs-agent` 全程 48 台不变,六次 tick 后无任何 runner 处于"双标签"或"无标签"态。
- **34 个单点变异:33 个被杀掉,0 个存活,1 个 anchor 冲突未测**。上一版是 21 个里 11 杀 10 存活;**上一版存活的 10 个,这一版全部被杀**。
- 门禁全绿且**每个门禁都做了活性对照**:helper suite 6/6、vitest 603/603(两个改动的测试文件)、size ratchet、prettier(3 种植入违规均报出)、eslint(3 种植入违规均报出)、shellcheck 0.11.0(两臂 + 植入 SC2086 报出)、actionlint 1.7.12(0 diagnostics + 3 种植入报出)。

**主要发现**

1. **"从未启动"正文断言了一个它自己的判据无法确立的原因**:正文首句把原因写成"GitHub 在 24 小时排队上限处结束了它",但同一段第三句又承认"本步骤看不到为什么没有 runner"。运维手动取消一个排队中的 review 会产生完全相同的形态,却收到"去查调度 workflow"的指引。
2. **新增的 `.size-baseline` 条目与它描述的文件不符**:记录 1958,实际 2518。
3. **上线首日窗口最长恰为 24.00 h**,压在 GitHub 排队取消线上(上一轮已提,PR 已写明缓解步骤)。
4. **没有任何看门狗**引用这个新 workflow(上一轮已提);但因为 PR 上的可见信号已恢复,严重性大幅下降。
5. **CI 侧容量未量化,且这一版更大**:hk1 与 hk2 每天 12 小时离开 `ecs-qwen` 池,而 `ecs-qwen` 被 19 个 workflow 的 24 处 job 引用。

**未覆盖范围**:无法枚举真实 hk1/hk2 runner 列表(需 PAT);未采样实时 review 需求;19 个 commit 中只有 1 个在浅克隆里可达,只验证了聚合 diff;yamllint 装不上(`pip3: Permission denied`);未做 trial merge 到当前 `main`;R02 变异与 V1/V5 vacuity 单元因 harness 自身缺陷未测(详见 Not covered)。

</details>

## Previous-finding status

Re-measured at `d45b5d98`, not carried over by reading the old report.

| # | previous finding | severity then | status at this head |
|---|---|---|---|
| F1 | Capacity arithmetic does not balance; ~61% of reviews dropped; cost not in the accepted-tradeoff list | substantive | **superseded on the review side, re-opened on the CI side.** The unit-mismatched figures were already deleted at round 2's head. Supply is now measured at C4: **30 review-eligible runners for 12 h = 360 runner-hours/night**, with the full pool eligible from minute 0. Demand still could not be sampled (no token; see *Not covered*), so the review-side deficit stays *unquantified* rather than *wrong*. **What is new:** the fleet filter widened from hk2 to `hk[12]`, so the `ecs-qwen` pool now loses *two* hosts for 12 h a day — see Finding 5. |
| F2 | Suppressing the fallback comment removes the only PR-visible signal; no watchdog replaces it | substantive | **FIXED on the signal, stands on the watchdog.** The suppression is gone: the step now posts a dedicated queue-expiry body. Re-measured over 24 cells × 2 arms (Harness B): **5 cells flip** from a body whose "did not complete successfully / retried automatically" claims were both false, to one that is true; the other **19 cells are byte-identical on both arms**. Calibration is now byte-for-byte against a **real emitted comment** (PR #9729, comment id `5414317254`) driven by the **real jobs payload** of run `32875478404` — both arms reproduce it exactly, so a review that actually ran still gets its comment. The watchdog census re-run at this head still finds **zero** references to `qwen-review-runner-schedule` outside the workflow, the size baseline, `ci.yml`'s two `HELPER_TESTS*` strings and one comment, and **zero** `review-pr` mentions in `qwen-fleet-shepherd.yml`. |
| F3 | `main()` is entirely untested, including the fence protecting the CI pool | Suggestion | **substantially FIXED.** The suite is now 6 tests (was 4) and **two of them spawn `main()` as a child process through a fake `gh`**, pinning the add-before-remove ordering, the failure ordering, and the empty-fleet guard. The matrix went **11/21 killed → 33/34**, and **all ten of round 2's survivors are now killed**: the PAT guard (M10), the empty-fleet guard (M11), the label-failure throw (M13), the mode validation (M14), the repo validation (M15), `maxBuffer` (M16), **PAT forwarding to `gh`** (M17, round 2's M15), **the main-guard itself** (M18, round 2's M16), the sparse-checkout/executed-path agreement (W04) and `deployment: false` (W05). |
| F4 | `ecs-qwen-hk2-<n>` name filter unverified against the live fleet | risk | **stands, with new positive evidence for the hk1 half.** Still no token in this container, so the live fleet is unenumerable. New in-repo evidence *for* the convention: `scripts/tests/unit-vitest-configs.test.ts:161` names **`ecs-qwen-hk1-01`** as a real shared-pool runner (alongside `ecs-qwen-parity`), which Harness A drives as a leading-zero case and confirms is matched (S1.13). Evidence *against* assuming it is universal also grew: the real payload captured this round shows `review-pr` having run on `ecs-qwen-runner-64c-12` with `labels=["self-hosted","linux","x64","ecs-qwen"]`. Failure mode remains **loud**, re-proven: exit 1, `::error::no ecs-qwen-hk1-<n> or ecs-qwen-hk2-<n> runner found`, **0** label writes (S6, four fixtures). |
| Corr1 | `384 runner-hours` compared against a count of `375` reviews | correction | **superseded** — both figures remain deleted. |
| Corr2 | The 24 h queue limit ends the contended case in cancellation, not delay | correction | **accepted** — Risk & Scope still states "Queued reviews can expire after 24 hours". |
| R2-Corr1 | The in-repo comment "the `ecs-review` pool is deliberately small by day" described a superseded design | correction | **FIXED.** The comment now reads "the `ecs-review` pool is **closed by day**", which matches the measured timeline exactly (C4: 30 inside the window, **0** outside). |
| R2-Corr2 | actionlint cannot validate `github.event.schedule`, so a clean run is not evidence for that key | correction | **stands, re-verified.** actionlint 1.7.12 flags a typo'd `inputs.pooI` and a typo'd `github.refd`, but reports **nothing** for `github.event.schedul`. Prior art re-confirmed at this head: `release.yml:179` + `.github/scripts/run-release-step.sh:38,44`, `qwen-autofix.yml:291`, and the operand-returning `cond && 'literal' \|\| fallback` shape at `live-host-release.yml:374`. |
| R2-Corr3 | `deployment: false` is accepted, not merely tolerated | correction | **stands, strengthened.** actionlint 1.7.12 reports **zero** diagnostics on the new workflow run from a directory with **no config at all** — the repo's `.github/actionlint.yaml` carries no `deployment` ignore, so acceptance is native to 1.7.12. |
| R2-Corr4 | The repo's actionlint gate disables shellcheck (`-shellcheck=`) | correction | **stands, re-verified** at `scripts/lint.js:189`. shellcheck 0.11.0 run directly on both arms' extracted `run:` block: clean. |
| R2-F1 | Losing a single switch costs a whole night; PAT expiry 2026-10-14 makes it permanent, with the announcement suppressed | substantive | **the suppression half is FIXED** (F2 above) — a queue-expired review now announces itself on the PR with an accurate body and a pointer at the schedule workflow. **The resilience half stands unchanged**: still 2 ticks/day, still no periodic reconciliation (the description says so), still a PAT that expires **2026-10-14**, and C4.15–C4.19b re-measure that a failing tick exits 1 having written nothing and leaves the pool closed on the next tick too. The difference is that the failure is now visible on the PR rather than only in a run summary. |
| R2-F2 | Day-one window reaches 24 h | substantive rollout risk | **stands, re-measured.** C5.1: worst wait over all 1440 merge minutes is **1440 min = 24.00 h**, reached by merging at 09:00 UTC (C5.1b), the minute the cron has just fired. Mitigation still documented in Risk & Scope. |
| R2-F4 | A runner offline at the switch is excluded for the entire night | minor | **stands, re-measured** (S15). One change for the better: because `ecs-agent` is now preserved, the skipped runner keeps *both* its CI and its autofix eligibility instead of being stripped at 05:00 without ever gaining `ecs-review`. |
| R2-F5 | An all-offline fleet produces a green run with zero changes | minor | **stands, re-measured** (S5.1–S5.5): exit 0, 0 label writes, `- 0 runners to switch`, 0 review-eligible. |
| R2-F6 | The name filter cannot be checked against the live fleet | risk | **stands** — see F4. |

**No previous finding worsened.** Two were fixed outright (F2's signal, R2-Corr1), one substantially (F3), and the mechanism changes that could have regressed round 2's measurements were each re-measured and improved: the round trip is now **byte-identical to the original labels** (S2.3, where round 2 lost `ecs-agent`), and a failed label write now leaves the host **in its previous pool** rather than with **no pool label at all** (S10.4–S10.6, proven against a reverse-order control at S12.6–S12.9).

## Central claim and A/B

**Central claim.** Review capacity follows the clock: `review-pr` asks for `ecs-review`, and two daily cron runs switch **every online runner named `ecs-qwen-hk1-<n>` or `ecs-qwen-hk2-<n>`** — all of them, at once, no ramp — to `ecs-review` at 17:00 Asia/Shanghai and back to `ecs-qwen` at 05:00, **preserving `ecs-agent`** so autofix stays eligible, **adding the target label before removing the other**, skipping offline runners, preserving unrelated labels, idempotently, and only from `main`.

**Secondary claims.** (a) `POOL` resolves to `review`/`ci` from the two crons and from the dispatch input. (b) `fallback-comment` posts a dedicated, accurate body when `review-pr` never got a runner, and is otherwise unchanged. (c) autofix's scan dispatch gate holds only on an `IN_PROGRESS` review, while its refresh-time hold stays wide.

The base tree contains no planner, so a base-vs-head A/B on the planner can only prove the file is new. The load-bearing proof is four-sided: a **closed-loop simulation driven by the real script**, a **34-mutant matrix**, and **two true base-vs-head A/Bs** on surfaces that exist on both arms — the `fallback-comment` step and the autofix scan gate.

### A/B table

| # | Cell | Environment | Observable oracle | Base | Head |
|---|---|---|---|---|---|
| A-S1 | Evening switch, 32-runner hk1/hk2 fleet (30 online / 2 offline / half busy) + 17 foreign hosts incl. a leading-zero `ecs-qwen-hk1-01` | real script, stateful fake `gh` on PATH (**one state file per runner**, so the script's own `Promise.all` cannot race the harness) | full argv log + per-runner label diff + `$GITHUB_STEP_SUMMARY` | n/a (no script) | **31 × (POST +ecs-review, DELETE −ecs-qwen)** = 62 label calls, 1 list call, POST strictly before DELETE for every runner (S1.16), `ecs-agent` and `diagnostic` preserved on all 31, offline runners byte-identical, **0** calls to a foreign id |
| A-S2 | Morning switch from the S1 end state | same | same | n/a | 31 × back to `ecs-qwen`; **round trip == original labels exactly** for every online managed runner, and **no runner lost `ecs-agent`** |
| A-S3 | Idempotence, both directions | same | label-call count | n/a | 2nd identical run: **0** label calls, 1 list call, `- 0 runners to switch`, fleet unchanged |
| A-S4 | Host isolation: 9 real production names (`hk3-5`, `hk4-23`, `hk5-31`, `runner-64c-23`, `runner-64c-12`, `runner-hk-2`, `runner-sg-1`, `parity`, `hk4-host`) + 8 near-misses (`hk2-33-extra`, `hk2-`, `hk1-01x`, `ECs-qwen-hk2-7`, `hk22-1`, `hk12-9`, `hk2-3.5`, `xecs-qwen-hk1-4`) | same | per-runner label diff + call targets | n/a | every one byte-identical; exactly the 31 well-formed hk1/hk2 ids touched |
| A-S5/S6 | All-hk1/hk2-offline / empty fleet / hk3-only / near-miss-only / 64c-only | same | exit code, `::error::`, writes | n/a | all-offline → **exit 0, 0 changes** (Finding 7); the four fleet-less shapes → **exit 1**, `no ecs-qwen-hk1-<n> or ecs-qwen-hk2-<n> runner found`, **0 writes** |
| A-S7..S9 | Missing/empty PAT, 9 invalid modes, 9 malformed repos, no argv | same | exit code + call count | n/a | all **exit 1** with **0 `gh` calls** — every guard fires before anything is on the wire |
| A-S10 | Injected POST failure on one runner | same | exit, summary, victim labels, collateral | n/a | exit 1, `1 runner label change(s) failed`, victim named in the summary, **only the POST attempted** (the DELETE never ran), victim **keeps its original labels**, the other 30 fully switched |
| A-S11 | Injected DELETE failure on one runner | same | same | n/a | exit 1, victim ends in the **both-labels** state, which is **review-eligible AND CI-eligible** (S11.6/S11.7) — degrades toward availability, not toward a dead host |
| A-S12 | Next tick from each degraded state, **plus a reverse-order control build** | same, scratch-mutated script | victim labels; eligibility against all three real `runs-on` sets | n/a | all four heal combinations exit 0 and end in exactly one pool. **Reverse-order control:** with DELETE first, the same injected POST failure leaves the victim with **no pool label**, satisfying **none** of the three `runs-on` sets (S12.6–S12.8) — the exact hazard the code comment claims, measured |
| A-S13 | Pagination at page sizes 1 / 3 / 7 / 100 / 1000 | same | fleet signature | n/a | byte-identical fleet at all five; 1 list invocation each |
| A-S14 | Sparse-checkout self-containment | script copied **alone** into an empty dir | import census + exit code | n/a | every specifier is a `node:` builtin; exit 0, fleet switched — the workflow's one-file sparse checkout is sufficient |
| A-S15 | Runner offline at the switch, then online at a later tick | same, 3 ticks | eligibility per tick | n/a | skipped and byte-identical at both switches; keeps CI **and** autofix eligibility by label; picked up by the next evening tick (Finding 6) |
| A-S16 | 200-runner fleet | same | wall span, fd exhaustion | n/a | 400 label calls, all 200 review-eligible, no `EMFILE`/`EAGAIN`/`spawn` error |
| A-S17 | The degraded both-labels state vs all three real `runs-on` sets | parsed from the workflows | set membership | n/a | satisfies head `review-pr`, the CI set and the autofix set; a host with **no** pool label satisfies none; three positive controls confirm the scorer is not vacuous |
| B | `Post fallback comment`, **24 cells × 2 arms** | **verbatim** step extracted by YAML parser (base 139 / head 159 lines; `diff` = 22 lines, only the guard and the body branch), `bash --noprofile --norc -e` + the step's own `set -uo pipefail`, fake `gh` running the **caller's own `--jq`** through the real `/usr/bin/jq`, fake `sleep` recording retries | was `gh pr comment` invoked, with what exact body; exit code; step summary | posted on 15 cells, exited 0 on 8, exit 1 on 1 | **5 cells flip to the queue-expiry body** (`B1 B2 B9 B10 B17`), all of them a `review-pr` with no runner and no steps; the other **19 are byte-identical between the arms**, including all 8 non-posting cells and the exit-1 cell |
| B-cal | **Real production data**: run `32875478404`'s actual jobs payload (`review-pr` cancelled on `ecs-qwen-runner-64c-12` with 18 steps) + the **comment that run really emitted** on PR #9729 (id `5414317254`) | same | same | **reproduced the real comment byte for byte** | **reproduced the real comment byte for byte** — the guard does not touch a review that actually ran. On the payload's real `cancelled` conclusion both arms also agree (B23) |
| F1 | The autofix scan's review-in-flight gate, **15 check shapes × 2 arms** | jq filter extracted **verbatim** from each arm's `qwen-autofix.yml` and replayed through the real `jq`; positive and negative controls on the replay path | the boolean each arm produces | holds on 7 shapes | **exactly 6 shapes flip** (`QUEUED`, `PENDING`, `WAITING`, `REQUESTED`, `QUEUED` via `state`, and the noisy `QUEUED`); **9 are unchanged** — including `IN_PROGRESS` on both arms, so the narrowing did not widen |
| F2/F3 | The af-099 deletion and the refresh-time probe | whole-file census + verbatim filter replay | dangling references; anchor resolution; probe behaviour | af-099 present | **zero** occurrences of `REVIEW_RUNS_JSON`, `REVIEW_WF_ID`, `REVIEW_RUN_STARTED_AT` or `af-099` anywhere in the head workflow or `qwen-autofix.md`; **all 50+ `qwen-autofix.md#af-NNN` pointers in the workflow resolve**; the refresh probe fetches its **own** runs listing and **still holds on a QUEUED review** when replayed |
| C | `POOL` resolution, the `main` fence, and a 3-day closed loop | expressions read from the YAML; ticks driven by the **real script** | resolved pool per trigger; job-runs-or-skips per ref; eligible-runner count per tick | `review-pr` needed `ecs-agent`: **48 of 48 online runners eligible at all times** (S1.25) | `0 9 * * *`→`review`, `0 21 * * *`→`ci`, dispatch→input, unknown cron→`ci`; fence admits main and rejects a feature branch, a PR merge ref and a fork; timeline `0 → 30 → 0 → 30 → 0 → 30 → 0`, **autofix flat at 48 at every tick** |
| E | **34 single-point mutants** | scratch roots, never the working tree; anchors asserted to occur exactly once | shipped suite exit, then a behavioural probe for survivors | — | **33 killed / 0 survived / 1 anchor-error**; every survivor of the shipped suite was killed by the probe |

Witnesses: `01-ab-fallback-comment-base-vs-head.png` (the B cells as they printed, with the calibration rows), `02-schedule-timeline-and-queue-window.png` (the C closed-loop timeline and the queue arithmetic), `03-autofix-gate-ab-matrix.png` (the F1 matrix), `04-mutation-matrix-34-mutants.png` (the E matrix, including the four rows re-probed after the scratch-root fix).

### Mutation matrix (34 mutants)

Round 2 was **11 killed / 10 survived of 21**. This head is **33 killed / 0 survived of 34** (1 unmeasured). **No mutant regressed from killed to survived**, and **all ten of round 2's survivors are now killed**.

| id | mutation | shipped suite | behavioural probe | classification |
|---|---|---|---|---|
| M01 | drop the `status === 'online'` filter | **killed** | — | pinned |
| M02 | unanchor the fleet-name regex | **killed** | — | pinned |
| M03 | widen the filter to every `ecs-qwen-*` host | **killed** | — | pinned |
| M04 | **regress** to the hk2-only filter (round 2's behaviour) | **killed** | — | pinned — the hk1 half of this PR is load-bearing |
| M05 | let `remove` include the target label | **killed** | — | pinned |
| M06 | **regress**: strip `ecs-agent` too (round 2's behaviour) | **killed** | — | pinned — the autofix-capacity fix is load-bearing |
| M07 | invert the two pools | **killed** | — | pinned |
| M08 | always POST the target (breaks idempotence) | **killed** | — | pinned |
| M09 | emit an action when nothing changes | **killed** | — | pinned |
| M10 | drop the missing-PAT guard | survived | **killed** (235 p / 8 f) | pinned by probe: calls reach the wire unauthenticated |
| M11 | drop the empty-fleet guard | **killed** | — | pinned — round 2's survivor, now covered by a shipped test |
| M12 | never POST the target label | **killed** | — | pinned |
| M13 | swallow label-write failures | **killed** | — | pinned — round 2's survivor |
| M14 | drop the mode pre-validation | survived | **killed** (234 p / 9 f) | pinned by probe |
| M15 | drop the repo-argument validation | survived | **killed** (215 p / 28 f) | pinned by probe |
| M16 | `maxBuffer` 16 MB → 64 B | **killed** | — | pinned — round 2's survivor |
| M17 | stop forwarding the PAT to `gh` | survived | **killed** (242 p / **1** f) | pinned by probe — the single failing assertion is S1.14, the token check. Round 2's M15 |
| M18 | the `main` guard never fires | **killed** | — | pinned — round 2's M16 |
| M19 | **regress** the ordering: remove BEFORE add | **killed** | — | pinned by the shipped ordering test |
| W01 | move the evening cron, not the comparison | **killed** | — | pinned |
| W02 | swap which cron opens the review pool | **killed** | — | pinned |
| W03 | stop wiring `secrets.RUNNER_ADMIN_PAT` | **killed** | — | pinned |
| W04 | sparse-checkout a different file | **killed** | — | pinned — round 2's survivor |
| W05 | `deployment: false` → `true` | **killed** | — | pinned — round 2's survivor |
| W06 | drop the `refs/heads/main` half of the fence | **killed** | — | pinned by the new anchored regex in the shipped test |
| W07 | `cancel-in-progress: false` → `true` | **killed** | — | pinned |
| W08 | `persist-credentials: false` → `true` | **killed** | — | pinned |
| R01 | **regress** `review-pr` to the base `ecs-agent` pool | **killed** | — | pinned — the central claim is load-bearing |
| R02 | widen the fallback body's read site from `= "true"` to `!= "false"` | *not measured* | — | anchor occurs twice (the guard block at line 3171 and the body selection at 3300 share the text); see *Not covered* |
| R03 | relax the never-started predicate from `and` to `or` | **killed** | — | pinned by the `ran_no_steps` test |
| R04 | drop the empty-list arm (jq `all` is vacuously true) | **killed** | — | pinned by the `no_review_pr_job` test |
| R05 | run the guard for every result, not just failure/cancelled | **killed** | — | pinned |
| A01 | **regress** the scan gate to base's five-status predicate | **killed** | — | pinned by the per-status replay test |
| A02 | drop the `review-pr` name clause | **killed** | — | pinned |

**Positive controls.** The pristine control is green under the identical command (`logs-E-control.txt`: helper suite 6 pass / 0 fail), and Harness A on the unmutated script is **243 p / 0 f** — so a probe reporting 8 or 28 failures is measuring the mutant, not a broken harness. 29 mutants were killed by the shipped suite alone under that same command, so "survived the suite" means the suite does not assert that axis. The replay harness for F1 carries its own positive and negative controls (F0.6/F0.7). Harness B's calibration cell is a positive control against real production data. **Zero mutants are dead code**: the four that survived the suite were each killed by a probe that names the observable they break.

### Vacuity check

| test | revert applied | result |
|---|---|---|
| `.github/scripts/review-runner-schedule.test.mjs` › "switches online hk1 and hk2 runners, including busy runners" | fleet filter `hk[12]` → `hk2` only, in a scratch root | **exit 1**, `AssertionError`, `expected: 30` / `+ actual - expected` — the intended expected-vs-actual mismatch, and the failure text names **hk1**. Non-vacuous. |
| same suite › the `ecs-agent` case | `MANAGED_LABELS` gains `'ecs-agent'` back | **exit 1**, `AssertionError` with a `+ actual - expected` diff naming **ecs-agent**. Non-vacuous. |
| same suite › "adds before deleting and preserves the old pool when adding fails" | the POST block disabled (`if (false)`) | **exit 1**, `AssertionError` naming **POST**. Non-vacuous. |
| `scripts/tests/qwen-pr-review-workflow.test.js` › "isolates the long-running review job on the schedule-sized review pool" | `review-pr` `runs-on` reverted `ecs-review` → `ecs-agent` | **killed, but by mutant R01 rather than by this harness** — my scratch root for this cell was under-provisioned and vitest failed at collection (`Tests  no tests`), so it proves nothing about the assertion. R01 applies the identical revert in a fully-provisioned root and the suite exits 1. Round 2 quoted that assertion's message for the same revert: `AssertionError: expected '${{ (github.repository == …' to be …`. Recorded as **inconclusive here**, not as a pass. |
| same file › the five never-started tests | the body branch disabled | **not measured** — the anchor `if [ "$never_started" = "true" ]; then` occurs at lines 3171 and 3300, so a single-point revert was ambiguous. Mutants R03/R04/R05 cover the same branch from three directions and were all killed. |

`git status --porcelain` is empty at the end; every mutant, revert and planted violation lived in a scratch root or was removed.

### Gates (each with a liveness control)

| gate | command | result | liveness control |
|---|---|---|---|
| shipped helper suite | `node --test .github/scripts/review-runner-schedule.test.mjs` | **6 pass / 0 fail** (was 4 at round 2) | a non-existent test file does not exit 0; and 29 mutants were killed by this same command |
| the two changed vitest files | `npx vitest run --config ./scripts/tests/vitest.config.ts qwen-pr-review-workflow qwen-autofix-workflow` | **2 files, 603 tests passed / 0 failed** (197.9 s) | mutants R01/R03/R04/R05/A01/A02 were killed by this same runner |
| workflow size ratchet | `bash .github/scripts/check-workflow-size.sh` | exit 0 | — (see Finding 2 for what the baseline numbers actually say) |
| `.size-baseline` accuracy | `wc -c` vs each entry | `qwen-code-pr-review.yml` **265415 = 265415** byte-exact; `qwen-review-runner-schedule.yml` **2518 ≠ 1958** | — |
| prettier | `--experimental-cli --check` on all 9 changed files | `All matched files use Prettier code style!` | a planted unformatted file under `scripts/` → exit **1**; removed → exit 0 |
| eslint | the two new `.github/scripts` files | clean | **three** plants, all reported with exit 1: an unused local (`@typescript-eslint/no-unused-vars`), an `any` (`no-explicit-any`), an unused import |
| shellcheck 0.11.0 | both arms' extracted `run:` block + the schedule workflow's run line | clean on all three | a planted unquoted expansion → **SC2086**, exit 1 |
| actionlint 1.7.12 | repo gate `node scripts/lint.js --actionlint`, then each changed workflow directly with **no config** | gate exit 0; **0 diagnostics** on all three workflows | `inputs.pooI` → `property "pooi" is not defined in object type {pool: string}`; `cron: '0 9 * *'` → `invalid CRON format … expected exactly 5 fields`; `github.refd` → `property "refd" is not defined`. `github.event.schedul` → **nothing** (Correction 2) |
| yamllint | — | **not run**: `pip3: Permission denied` in this container, so `node scripts/lint.js --setup` installed actionlint and shellcheck only | — |
| `HELPER_TESTS*` wiring | import census + path existence | both `ci.yml` lists name `.github/scripts/review-runner-schedule.test.mjs`; the file exists; dep-free placement **earned** — the script and its test import only `node:` builtins and one relative file (S14.1) | — |
| sparse checkout | pattern vs executed path | pattern == the file the `run:` line executes; `cone-mode: false`; `persist-credentials: false`; S14.2 runs the script **alone in an empty dir** | mutant W04 shows a mismatch is observable |
| checkout pin | SHA vs repo-wide usage | `df4cb1c0…` `# v6.0.3`, the same SHA round 2 measured 75 other steps using | — |
| collateral | `git diff HEAD^1..HEAD` per file | the `ci.yml` change is confined to the two `HELPER_TESTS*` strings; `qwen-autofix.yml`'s `runs-on` label sets are **unchanged from base** (G4) | — |

## Corrections

To the *description and comments*, not requests to change behaviour.

1. **`.github/actionlint.yaml` enumerates the repo's self-hosted runner labels and does not include `ecs-review`.** Neither does it include `ecs-agent`, which base already uses the same way, so this is inert rather than new: every `runs-on` that names either label is a `${{ … fromJSON('[…]') }}` **expression**, which actionlint cannot resolve and therefore does not check. Recorded so the clean actionlint result in the table above is not read as validation of the new label. It becomes load-bearing the moment anyone writes a literal `runs-on: [self-hosted, ecs-review]`.
2. **The description's "Reviews queue while no `ecs-review` runner is available" is precise, and the number behind it is larger than the sentence suggests.** Measured at C5.5/C5.6: in steady state the pool is open for exactly **720 of 1440 minutes**, half of all arrivals wait, the mean wait among those is **6.01 h**, and the worst is **12.00 h**. On **day one** the worst is **24.00 h** (C5.1) because no runner carries `ecs-review` until the first tick. Both figures are inside the documented "Queued reviews can expire after 24 hours", but only the day-one one actually touches it.
3. **The description says the switch "preserves `ecs-agent`, so autofix remains eligible on both hosts" — verified, and it is the load-bearing half of the change.** C4.7 measures autofix eligibility **flat at 48 online runners at every one of six ticks**, where round 2's mechanism stripped `ecs-agent` in both directions. Mutant M06 (put `ecs-agent` back in `MANAGED_LABELS`) is killed, and V3's revert produces an `AssertionError` naming `ecs-agent`.
4. **`ecs-qwen-hk1-01` is corroborated by an independent in-repo source.** `scripts/tests/unit-vitest-configs.test.ts:161` lists `['ecs-qwen-parity', 'ecs-qwen-hk1-01']` as `POOL_RUNNER_NAMES` with the comment "Real pool runners look like `ecs-qwen-runner-64c-23`". That predates this PR, so the hk1 half of `FLEET_NAME` is not only asserted by the PR's own fixture — and its leading-zero form is matched (S1.13). It does not establish how many hk1 runners exist, or that hk2 follows the same convention (F4).

## Findings

### 1. The queue-expiry body states a cause its own predicate cannot establish

**Severity: Suggestion** (a user-facing accuracy issue, not a correctness break). **Reproduce:** `node tmp/pr11855-verify-20260915-045028/harness-b-fallback.mjs` — cells `B2` and `B18`.

The new branch fires on `runner_name` empty **and** `steps` empty. That shape has at least two producers, and the body commits to one of them in its first sentence:

> **Qwen Code review never started.** No runner picked up the review job before GitHub ended it **at the 24-hour queue limit**, so no review ran for this head…

The second producer is reachable: the workflow's concurrency is `cancel-in-progress: ${{ github.event_name == 'pull_request_target' && github.event.action == 'closed' }}`, so pushes queue rather than cancel — but **an operator clicking "Cancel workflow"** on a run whose `review-pr` is still queued yields exactly `result == 'cancelled'`, no `runner_name`, no `steps`, and an OPEN PR. Cell B2 drives that shape and the head arm posts the 24-hour claim for it. The body's own third sentence concedes the point — "This step cannot see WHY no runner was available" — which is why this is a wording finding rather than a logic one: the sentence that admits ignorance comes after the sentence that asserts a cause.

Base's `cancelled` body did not have this problem; it enumerated the causes instead ("by an operator, an upstream event, or the job exceeding its execution time limit"). So on this one shape the new body is *less* accurate than the one it replaces, while being much more accurate on the shape it was written for.

The consequence is concrete but bounded: a maintainer who cancelled a run by hand is told to go read `qwen-review-runner-schedule.yml`'s latest run and to re-request "once that run shows the pool open" — a wild-goose chase during exactly the incident where the comment is most likely to be read carefully.

**What this is NOT.** Not a silence regression: all five flipping cells still post, with the dedup marker, the `actions/runs/<id>)` cross-job anchor and the retry instruction intact (asserted per cell on both arms). Not a widening regression: the three controls the PR's own tests added (`ran`, `ran_no_steps`, `no_review_pr_job`) plus my B4/B5/B8/B11/B21 cells all keep the generic body, and B21 confirms the unpaged `?per_page=100` read fails **safe** — a listing that drops `review-pr` yields `unknown`, not `true`. Not a false-positive risk from a skipped `review-pr`: B7b proves the guard is scoped to `failure`/`cancelled`, so a `skipped` review-pr (the shape when `precheck-pr` fails the job gate) makes **zero** jobs-API calls and posts the generic body.

<details>
<summary>Minimal suggested direction (not applied — see the note)</summary>

One clause, keeping the whole intent: make the first sentence describe the observation and leave the cause to the sentence that already disclaims it — e.g. "No runner picked up the review job before GitHub ended it, so no review ran for this head", and let the existing "check its latest run — a pool that is closed by schedule, empty, or all-offline expires the next request the same way" carry the candidate causes, adding "or the run was cancelled while it waited" to that list.

I did not apply and re-measure this: it changes prose in a body the shipped tests assert on with `toContain('never started')`, so the correct patch ships with a fixture pinning the operator-cancel shape — a `cancelled` result whose run was ended well inside 24 h, asserted to produce a body that does **not** name the queue limit. No such fixture exists today, which is the unpinned axis: the five new tests all pin *whether* the branch fires, none pins *which cause it claims*.
</details>

### 2. The one `.size-baseline` entry this PR adds does not match the file it describes

**Severity: nit.** **Reproduce:** `wc -c < .github/workflows/qwen-review-runner-schedule.yml` → **2518**; `grep qwen-review-runner-schedule .github/workflows/.size-baseline` → **1958**.

The gate passes because it only fails past `recorded + 4096`, but the entry's whole purpose is the ratchet — the script's own header says growth should become "one line a reviewer sees". With 1958 recorded, this file can grow by **4656 bytes (185% of its current size)** before the ratchet objects, instead of 4096. Round 2 measured this entry **byte-exact at 1942**; the later commits (the `main`-fence comment block and the hk1 change) grew the file by 560 bytes without re-recording it, so this is drift introduced inside the PR rather than inherited.

Two related numbers, both accounted for so the residue is not read as noise:

- **`ci.yml`: recorded 134426, base actual 137201, head actual 137297.** The staleness is **main-side and pre-existing** (base was already 2775 over); this PR contributes 96 bytes and leaves **1225 bytes** of headroom. The next unrelated PR that grows `ci.yml` by more than that gets a hard red gate on drift it did not cause — precisely the failure the script's header cites as having "red-walled the queue twice in two weeks: #9747, #9822". Worth a one-line baseline bump on main, not in this PR.
- **`qwen-autofix.yml`: recorded 469165, head actual 467572.** The PR *shrank* it by 1593 bytes by deleting the af-099 code. That is inside the gate's `SLACK_BYTES=20000`, so no reclamation is required and none is warned — correct by design, and the file is at 91% of the 470000-byte gate either way.
- **`qwen-code-pr-review.yml`: recorded 265415 = actual 265415**, byte-exact. The PR does update the entry for the file it grew.

### 3. The day-one window is exactly 24.00 h — GitHub's queue-cancellation boundary

**Severity: substantive rollout risk; the mitigation is documented in the PR.** **Reproduce:** `node tmp/pr11855-verify-20260915-045028/harness-c-schedule.mjs` (C5).

After merge, `review-pr` requests `["self-hosted","linux","x64","ecs-review"]` but **no runner carries `ecs-review`** until the first 09:00 UTC tick or a manual dispatch. Over all 1440 possible merge minutes the worst window is **1440 min = 24.00 h** (C5.1), reached by merging at 09:00 UTC — the minute the cron has just fired (C5.1b); one minute earlier it is 1 minute. Mean day-one wait **12.01 h**. During the window the eligible count is **0**, while the identical fleet gave the base arm's `ecs-agent` **48 of 48 online runners** (S1.25) — so this is a new gap, not a pre-existing one.

Risk & Scope names the mitigation ("After merging, manually select the appropriate pool once"), and C3.5 confirms a dispatch aimed at `main` passes the new fence and closes the window immediately. Two refinements: the window is worst when the merge lands just *after* 09:00 UTC, and **nothing in the diff enforces that the manual step happens** — it is an instruction, not a guard. A reviewer merging this should treat the manual dispatch as part of the merge, not as a follow-up.

### 4. Two hosts leave the CI pool for 12 h a day, and the size of that is not stated anywhere

**Severity: substantive, unquantified — a description gap rather than a code defect.** **Reproduce:** `node tmp/pr11855-verify-20260915-045028/harness-c-schedule.mjs` (C4.4/C4.6) and `grep -rc 'ecs-qwen' .github/workflows/*.yml`.

Measured in the closed loop: the `ecs-qwen`-eligible online count goes **48 → 18** inside every window and back to **48** outside it (C4.4/C4.6), i.e. the CI pool loses **62.5% of the simulated fleet for half of every day**. `ecs-qwen` is not a niche label — it is requested by **24 job sites across 19 workflows** (`ci.yml`, `qwen-triage.yml`, `release.yml`, `stale.yml`, `e2e.yml`, `repo-hygiene.yml`, …), and `qwen-code-pr-review.yml` itself has three jobs on it (`ack-review-request`, `review-config`, `authorize`).

This is the intent ("daytime CI and evening reviews"), and the description does say hk1 and hk2 both switch. What it does not do is size it: the only numbers offered are the *fixture's* ("32 runners (30 online, two offline)"), which are test data, not fleet data — and at round 2's head only hk2 switched, so the CI-side cost has roughly doubled while the description's shape stayed the same. The review-side capacity question (F1) is still unquantified for the same reason: neither can be measured without enumerating the real fleet, which needs the PAT this PR introduces.

**What this is NOT.** Not a CI outage: S1.24/C4.4 confirm the foreign hosts keep `ecs-qwen` non-empty through the whole window, and the switch cannot touch them (S4 drives 17 real and near-miss names and changes none). Not an autofix regression: C4.7 holds autofix eligibility flat at 48 through six ticks. **Confirm in one command once the PAT exists:** dispatch `ci`, then `GET /repos/QwenLM/qwen-code/actions/runners` and compare the online `ecs-qwen-*` count against the same query taken at 12:00 Asia/Shanghai.

### 5. Losing a single switch still costs a whole night, and the PAT still expires 2026-10-14

**Severity: substantive resilience risk, materially reduced from round 2.** **Reproduce:** `node …/harness-c-schedule.mjs` (C4.15–C4.19b).

The two halves of round 2's Finding 1 now separate cleanly:

- **The announcement half is fixed.** A review that expires in a closed pool posts an accurate, dedicated comment naming `qwen-review-runner-schedule.yml` as the thing to check (Harness B, B1/B2), and the branch records itself in the run's step summary (B1 summary assertion). A maintainer looking at the PR can now tell "healthy review waiting for tonight" from "the pool never opened".
- **The resilience half stands.** Still **2** ticks a day and no periodic reconciliation (the description states this). Measured: a tick with a missing PAT exits 1 having made **0** label calls and leaves **0** review-eligible runners; the next failing tick leaves it the same way. The description states the credential "expires on 2026-10-14", so from that date the steady state is: every tick red, the pool permanently closed, and every review expiring — now *with* a comment on each PR explaining what to go look at, which is the whole improvement.

The watchdog census re-run at this head finds **zero** references to `qwen-review-runner-schedule` outside the workflow itself, `.size-baseline`, `ci.yml`'s two `HELPER_TESTS*` strings and one comment in `qwen-code-pr-review.yml`, and **zero** `review-pr` mentions in `qwen-fleet-shepherd.yml`. A dated, known credential cliff with no automated notice is still the cheapest thing left to fix here.

### 6. A runner offline at the switch is excluded for the entire window

**Severity: minor, bounded, and improved from round 2.** **Reproduce:** `node …/harness-a-planner.mjs` (S15).

`planLabels` filters on `status === 'online'`, so an hk1/hk2 runner rebooting at 17:00 is skipped. Measured across three ticks: it stays non-review-eligible for the whole 12 h window, remains `ecs-qwen`- and `ecs-agent`-eligible by label **so it keeps taking CI and autofix jobs on a review host while reviews queue**, and is picked up at the next evening switch (~24 h later). Cost: 12 runner-hours per affected runner per night.

The improvement over round 2 is real: because `ecs-agent` is no longer stripped, the skipped runner is not left in a mixed state at 05:00 — S15.1 confirms it is byte-identical after both switches, and S15.6 confirms the next tick picks it up. A runner newly registered mid-window behaves the same way. **Not a correctness break and not silent mislabelling.**

### 7. An all-offline hk1/hk2 fleet produces a green run with zero changes

**Severity: minor.** **Reproduce:** `node …/harness-a-planner.mjs` (S5).

The fleet guard tests `runners.some((r) => FLEET_NAME.test(r.name))` **without** a status check, so "hk1/hk2 exist but every runner is offline" passes it, yields 0 actions, writes `- 0 runners to switch`, and **exits 0**. Measured: 0 review-eligible runners and a green run. Defensible — an offline fleet is not this workflow's fault, and the description commits to "Offline runners are ski

...truncated -- full content in the run artifacts.
Flakiness gate log

rounds=5 files=3 skipped=0
file .github/scripts/review-runner-schedule.test.mjs: (cd .) node --test ./.github/scripts/review-runner-schedule.test.mjs
file scripts/tests/qwen-autofix-workflow.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/qwen-autofix-workflow.test.js
file scripts/tests/qwen-pr-review-workflow.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/qwen-pr-review-workflow.test.js


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  .github/scripts/review-runner-schedule.test.mjs: PPPP
  scripts/tests/qwen-autofix-workflow.test.js: PPPP
  scripts/tests/qwen-pr-review-workflow.test.js: PPP

verdict: timeout
summary: only 3 of 5 rounds fit the 15-minute budget; the completed rounds agreed

--- per-invocation detail (full copy in the artifact) ---
round 1 · .github/scripts/review-runner-schedule.test.mjs: P (exit 0)
round 1 · scripts/tests/qwen-autofix-workflow.test.js: P (exit 0)
round 1 · scripts/tests/qwen-pr-review-workflow.test.js: P (exit 0)
round 2 · .github/scripts/review-runner-schedule.test.mjs: P (exit 0)
round 2 · scripts/tests/qwen-autofix-workflow.test.js: P (exit 0)
round 2 · scripts/tests/qwen-pr-review-workflow.test.js: P (exit 0)
round 3 · .github/scripts/review-runner-schedule.test.mjs: P (exit 0)
round 3 · scripts/tests/qwen-autofix-workflow.test.js: P (exit 0)
round 3 · scripts/tests/qwen-pr-review-workflow.test.js: P (exit 0)
round 4 · .github/scripts/review-runner-schedule.test.mjs: P (exit 0)
round 4 · scripts/tests/qwen-autofix-workflow.test.js: P (exit 0)

Evidence images

01-ab-fallback-comment-base-vs-head

02-schedule-timeline-and-queue-window

03-autofix-gate-ab-matrix

04-mutation-matrix-34-mutants

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

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

APPROVE

核对基线:head d45b5d9811df74020df4f36878c54d48a8eb46d9(提交于 04:15:32Z)。

历史阻塞问题:两条 Critical 均已在当前 head 上确认修复

历史上 3 次 CHANGES_REQUESTED,最后一次是 2026-09-15T02:35:00Z 针对 45f8b30a9bac,其中两条 Critical 标为「still standing」。当前 19 条线程全部 resolved,但我没有采信该标志,而是在当前 head 上逐条读码复核:

  • R1-1(ecs-agentMANAGED_LABELS 里却永远不是切换目标,因此两种模式都会把它从每台在线 hk1/hk2 上删掉且仓库里没有任何东西会加回来,而 qwen-autofix.ymlissue-autofixreview-address 正是按这个标签路由)—— 已修复。 .github/scripts/review-runner-schedule.mjs:17 现在是 const MANAGED_LABELS = ['ecs-review', 'ecs-qwen'];ecs-agent 已不在其中。于是 :30-32remove = labels.filter((l) => MANAGED_LABELS.includes(l) && l !== target) 只可能删 ecs-reviewecs-qwen,永远不会删 ecs-agent;单向删除的路径被从根上去掉了,autofix 在两台主机上都保持可调度。这与 qwen-code-pr-review.ymlruns-onecs-agent 改成 ecs-review 是配套的一对改动:review 拿到自己的池子,autofix 保留原池子。
  • R1-2(「没有 runner 领取」的分支以裸 exit 0 结束,导致排队到 24 小时上限被 GitHub 结束的 review 对 PR 完全不发任何内容)—— 已修复。 qwen-code-pr-review.yml 现在先用 jobs API 判定 never_started[.jobs[] | select(.name == "review-pr")] | if length == 0 then "unknown" elif all(.[]; (.runner_name // "") == "" and ((.steps // []) | length) == 0) then "true" else "false" end,并以 2>/dev/null) || never_started="unknown" 兜底——注释写明「读不到 job 列表就保留通用文案,宁可多告诉作者一点」。随后 :3297 起为 never_started = true 单独准备了一条 PR 可见文案,说明没有 runner 领取、本次 head 没有跑过 review、不会自动重试,并给出可执行的下一步(先看 qwen-review-runner-schedule.yml 最近一次 run 确认池子是否开放,再用 @qwen-code /review 重新请求)。同时 ack 文案从「Review is running」改成「Review is queued for an available runner」,与 review 现在会排队的新模型一致。

第 2 轮的 4 条 Suggestion(R1-3、R2-1、R2-2、R2-3)按本渠道策略不作为门禁,本轮未追踪。

本轮独立扫描:未发现 Critical

这是一个持有 runner 管理 PAT 的新定时任务,我按「能不能被不可信代码执行」和「切换失败会不会把主机留在没有池子的状态」两条主线核对:

  1. 新 workflow 的围栏是完整的,而且把只存在于仓库设置里的那一半也钉进了代码。 qwen-review-runner-schedule.yml 的触发只有 schedule0 9 * * * → 17:00 Asia/Shanghai 开 review、0 21 * * * → 05:00 回 CI)与带必填 choice 的 workflow_dispatch没有 pull_request/pull_request_target,因此 PR 里的代码永远不可能带着这个 token 执行。job 级 ifgithub.repository == 'QwenLM/qwen-code' && github.ref == 'refs/heads/main',注释准确说明了为什么 ref 这一条不可省:workflow_dispatch 可以指向任意分支,而该 job 会从被 dispatch 的 ref 检出并执行脚本,少了这一条,唯一拦住「分支自带脚本 + 管理员 token」的就只剩环境上的部署分支策略,而那只在仓库设置里、文件里看不见。
  2. 权限与检出都收到最小。 workflow 级 permissions: contents: 'read'(改标签走 PAT,不用 GITHUB_TOKEN);actions/checkout 按 SHA 固定(df4cb1c0... 标 v6.0.3),带 persist-credentials: false,并用 sparse-checkout 只取 review-runner-schedule.mjs 一个文件;environmentqwen-review-runner-scheduledeployment: false(要的是环境里的 secret,不是部署记录);timeout-minutes: 10 有界。
  3. 并发策略不会留下半切换的车队。 concurrency.group: 'qwen-review-runner-schedule'cancel-in-progress: false,两次切换串行且绝不取消进行中的那一次。
  4. 加在删之前,失败方向被显式论证过。 脚本 :79-86 的注释写明:POST 失败只让主机留在原来的池子,DELETE 失败让它同时属于两个池子,两种都不会让它一个池子标签都没有;而反过来的顺序会,那台主机随后匹配不到任何 runs-on,要等到下一次成功切换(最长 12 小时)或人工 dispatch。代码确实是先 add 再逐个 remove。这是这个 PR 里最关键的一处顺序,方向是对的。
  5. 静默空转被挡住。 FLEET_NAME = /^ecs-qwen-hk[12]-\d+$/ 只有一份字面量,同时用于 planLabels 的过滤与 main() 里的车队守卫(if (!runners.some((r) => FLEET_NAME.test(r.name))) throw),注释说明两份会漂移的副本正好会把守卫要防的静默 no-op 重新引进来。守卫在匹配不到任何 hk1/hk2 时抛错而不是安静退出。
  6. 注入面与失败上报都处理了。 repo 参数先过 /^[\w.-]+\/[\w.-]+$/ 才拼进 gh api 的 URL;调用一律走 execFile(参数数组,不经 shell);planLabels([], mode) 在花费鉴权调用之前先校验 mode;每台 runner 各自 try/catch,失败计入 summary,最后 if (failures) throw new Error(...) 并置 process.exitCode = 1,因此部分失败会让 job 变红而不是静默通过。
  7. 幂等。 add = labels.includes(target) ? [] : [target],并以 add.length || remove.length 过滤,已在目标池的 runner 不产生任何动作,重复执行同一选择不做改动。
  8. 新测试确实会在 CI 里跑。 ci.yml.github/scripts/review-runner-schedule.test.mjs 同时加进了 HELPER_TESTS_DEP_FREEHELPER_TESTS 两份清单——少了这一步新测试永远不会被执行。qwen-autofix.yml 净减 34 行,.size-baseline 的 +2/-1 与之方向一致。
  9. autofix 侧的收窄是本次模型的必然结果且已披露。 删掉的是「review 处于 queued/waiting 也算 live」的那套 delay-window 回退(连同 REVIEW_WF_ID 解析与 runs API 查询),门禁从 IN("QUEUED","IN_PROGRESS","PENDING","WAITING","REQUESTED") 收成 == "IN_PROGRESS"。在 review 现在每天会有长达数小时排队窗口的新模型下,继续等 queued 会把 autofix 无限期挂住;PR 描述明确写出了这一取舍,配套的 qwen-autofix.md(+13/-32)与两个 workflow 测试(qwen-autofix-workflow.test.js +3/-102、qwen-pr-review-workflow.test.js +158/-7)也同步增删。被丢弃的排队 review 一侧由上面 R1-2 那条新文案兜住,作者不会无声失去信号。

CI

当前 head 提交于 04:15:32Z,我核对时 Lint & StaticTest (ubuntu-latest, Node 22.x)Integration Tests (no-AK, No Sandbox)Desktop Shell (windows-2022) 全部仍为 pending,没有任何失败项。按本渠道策略 pending 不单独作为卡点,我没有等待或轮询,本次结论完全基于上面的代码核对;但合入前请以这批检查转绿为准,尤其是 Lint & Static——它承载着 workflow 体积棘轮与新增 helper 测试的执行。

结论:两条历史 Critical 已在当前 head 上确认修复,本轮未发现可证明的 Critical,提交 APPROVE。

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /review

@github-actions

Copy link
Copy Markdown
Contributor

Qwen Code review request accepted. Review is running in workflow run. A command-triggered review is not listed under the checks of this PR; the result is posted here as a review when it finishes.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@wenshao

wenshao commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

Local verification of #11855 at d45b5d98

Verdict: the switch works as described; nothing in the diff blocks merge. Two things are worth deciding before or right after merge. F1 is a small correctness nit in the new fallback body. F2 is the live fleet state: today no runner carries ecs-agent, so the effect of merging depends on it. I ran this independently of the earlier sandbox rounds (25abcc9b, 78857570) and qqqys's approval. I focused on what those rounds could not reach: the real gh binary on the wire, the live fleet, and a real GitHub Actions cancellation.

What I ran

# Environment What it answers
1 Read-only GitHub API against QwenLM/qwen-code: runners, queued jobs, environment, variables What the first switch will actually touch, and the current queue
2 The workflow step verbatim (node .github/scripts/review-runner-schedule.mjs "$GITHUB_REPOSITORY" "$POOL") from a sparse dir holding only the planner, using the real gh 2.100.0 (the version ubuntu-latest ships). api.github.com is TLS-spoofed via a mount-namespace /etc/hosts, backed by a stateful fake seeded with the live 167-runner snapshot; 10 scenarios Wire requests, pagination, ordering, faults, idempotence
3 @actions/expressions 0.3.61 (GitHub's evaluator) on the verbatim if:, POOL and runs-on strings Pool selection and the main fence
4 Real GitHub Actions on wenshao/qwen-code (run 34932668341), with the fallback gate and never_started block copied byte-for-byte from this head What a hand-cancelled queued review looks like to the new guard
5 Verbatim Post fallback comment step, base vs head, with the real gh against live runs; gh pr comment and every API write intercepted Which body each arm posts on real payloads
6 Autofix REVIEW_PR_LIVE jq, verbatim from each arm, over the real statusCheckRollup of an open PR The scan-gate change on live data
7 node --test, vitest (head, base, and head's tests against base workflows), prettier, eslint, yamllint, actionlint 1.7.12, check-workflow-size.sh Gates, plus whether the new guards are non-constant

Confirmed

  • Switch on the live fleet (S1–S4). review sends 62 POST …/labels with body {"labels":["ecs-review"]} and no DELETE. Exactly the 62 online ecs-qwen-hk1-*/hk2-* runners change; the 2 offline hk1 runners and all of hk3/hk4/hk5 are never written. ci then sends 62 POST ecs-qwen + 62 DELETE ecs-review. Both repeats make zero writes. Per-runner ordering violations (DELETE before POST) across every scenario: 0. After a ci switch the ecs-qwen pool goes from 93 to 155 runners, as the author's last comment says.
  • Faults (S5–S7). A POST 403 leaves that runner with no DELETE issued and on its previous pool. A DELETE 502 leaves the runner in both pools. The run exits 1 with ::error::2 runner label change(s) failed. A manual re-run converges. An offline runner is skipped, keeps its labels, and is switched by the next run once it's back. No runner ever ended with no pool label.
  • Pagination is live today. 167 runners means 2 pages; real gh --paginate --slurp follows GitHub's Link to /repositories/1008713177/…?page=2. With 31 hk1/hk2 runners moved onto page 2 (S10), all 62 still switch.
  • Preconditions fail loudly. An empty RUNNER_ADMIN_PAT exits 1 before any request (S8). A distro gh 2.46 has no --slurp and exits 1 with zero writes (S9); ubuntu-latest has 2.100.0 (printed by the fork probe), so this does not affect the workflow.
  • Expressions. schedule 0 9review; schedule 0 21ci; workflow_dispatch on main → the chosen pool. A dispatch on any other branch skips the job, and so does any repository other than QwenLM/qwen-code. review-pr routes to ecs-review, and falls back to ubuntu-latest when MAINTAINER_ECS_RUNNER_DISABLED=true.
  • Environment. qwen-review-runner-schedule exists with a custom branch policy [main], and RUNNER_ADMIN_PAT is set (created 2026-09-14T15:03Z).
  • Autofix gate. On PR fix(core): treat a word-initial # as a comment when splitting shell commands #11821's real rollup, whose review-pr is QUEUED, base holds the round and head proceeds. Flipping that check to IN_PROGRESS makes both arms hold.
  • Fallback bodies on real payloads. On a never-started review-pr reported as failure (the 24h-expiry case), base posts the false "failed… retried automatically" body and head posts the accurate never-started body. On a review-pr that ran 22 steps on ecs-qwen-hk1-28, both arms post the failure body. On a never-started run cancelled when its PR merged (feat(extensions): ship dynamic workflows from extensions #11805), both arms stay silent.
  • Gates. All exit 0. The 2 + 2 local vitest failures are the same four tests at base, running as uid 0 (a chmod deny and an env-plant lock), and CI is green on this head. Run against base workflows, head's tests go red on runner routing, the three-body link invariant, the queue-expiry body and the IN_PROGRESS-only hold, so those guards are not constants.

F1: a hand-cancelled queued review is told it hit the 24-hour queue limit

The guard treats "review-pr has no runner and no steps" as a queue expiry for both failure and cancelled. A run cancelled by hand while review-pr waits for a runner has exactly that shape. The gate also admits it, because authorize and delay-automatic-review finished long before.

  • Real GitHub (run 34932668341): review-pr sat queued for 2m17s, then gh run cancel. It came back completed/cancelled, runner_name: "", steps: 0. The gate opened, the verbatim block printed never_started=true, and it wrote (queue expired); posting the queue-expiry body.
  • Live replay on PR fix(core): treat a word-initial # as a comment when splitting shell commands #11821 (run 34906150910) with result=cancelled: base posts "was cancelled before a review could be posted… by an operator, an upstream event…", which is true. Head posts "No runner picked up the review job before GitHub ended it at the 24-hour queue limit… check its latest run", which is false.

With a pool that is closed 12 hours a day, cancelling queued reviews by hand becomes an ordinary operator action. This is the same class as #10109. A queue-age clause keeps the new body for real expiries and lets hand cancels fall through to the existing, accurate cancellation body. I tested this filter against the real probe payload (→ false), a 24h-shaped never-started job (→ true), the hk1-28 job that ran (→ false) and an empty listing (→ unknown):

[.jobs[] | select(.name == "review-pr")]
| if length == 0 then "unknown"
  elif all(.[]; (.runner_name // "") == "" and ((.steps // []) | length) == 0
       and (.completed_at // "") != ""
       and ((.completed_at | fromdateiso8601) - (.created_at | fromdateiso8601)) >= 82800)
  then "true" else "false" end

The queue-expiry test fixtures would need created_at/completed_at, and the step-summary text "(queue expired)" should move under the same condition. I'm fine with this as a follow-up; it is not a merge blocker.

F2: live rollout state; merging alone restores reviews, not autofix

This is not a defect in the diff, but it decides what merging does (read-only snapshot, 2026-09-15 05:00Z):

  • No runner in the repository carries ecs-agent. hk1 (32 registrations, 30 online) and hk2 (32, all online) carry only ecs; hk3/hk4/hk5 carry ecs-qwen. The most recent review-pr I found on an ecs-agent runner started on ecs-qwen-hk1-7 at 2026-09-14T22:17:54Z.
  • 36 jobs are waiting on ecs-agent: 31 review-pr, 3 review-address, 2 issue-autofix, the oldest created 2026-09-14T22:46:27Z. The /review requested on this PR at 05:29Z, after that snapshot (run 34932959802), joined them: its review-pr is queued on ecs-agent while the ack says "Review is running".

What follows from that:

  1. After merge, the first review switch (the 17:00 cron or a dispatch) gives hk1/hk2 ecs-review, and automatic reviews resume. Runs already queued keep their ecs-agent request and are not re-routed. Starting around 22:46Z today they expire after 24h. Each one's fallback job (main's version, fixed when the run was created) then posts main's failure or cancellation body, unless its PR's head moved or the PR closed. Cancelling them first avoids the misleading bodies; restoring ecs-agent lets them run.
  2. issue-autofix and review-address stay at zero eligible runners after merge. The description's "The switch preserves ecs-agent, so autofix remains eligible on both hosts" only becomes true once the host-side registration carries ecs-agent again, which Risk & Scope asks for but the live fleet does not have.

Non-blocking

  • .github/workflows/.size-baseline records qwen-review-runner-schedule.yml at 1958 bytes, but the file is 2518 bytes. That is inside the 4096-byte allowance, so the gate is green.
  • QWEN_REVIEW_DAY_RUNNERS, QWEN_REVIEW_NIGHT_START and QWEN_REVIEW_NIGHT_END are still set as repository variables from earlier revisions, with 0 references at this head. They can be deleted after merge.
  • The Tested-on row still says current-head CI is pending; it is all green at d45b5d98.

Not verified

  • A real label write on QwenLM runners (it would move production capacity).
  • The real schedule trigger firing.
  • Which result GitHub assigns to a job that expires after 24h in the queue (it takes 24h to observe; the PR handles both failure and cancelled).
  • The host-side registration config.

Screenshots

Live fleet and queue state

Planner through the real gh against a TLS-spoofed GitHub API

Operator cancel on real GitHub Actions and base-vs-head replay on live data

Gates, expression matrix, autofix gate

中文说明

#11855 本地验证(d45b5d98

结论:切换机制与描述一致,diff 本身没有合并阻塞项。 有两件事值得在合并前或合并后马上决定:F1 是新 fallback 文案里的一处小的正确性问题;F2 是线上机群当前状态——此刻没有任何 runner 带 ecs-agent,合并的实际效果取决于它。这一轮独立于此前的沙箱验证(25abcc9b78857570)和 qqqys 的批准,重点放在那些轮次覆盖不到的地方:真实 gh 二进制的线上请求、线上机群,以及一次真实的 GitHub Actions 取消。

做了什么

# 环境 回答的问题
1 QwenLM/qwen-code 的只读 GitHub API:runner、排队 job、environment、variables 第一次切换实际会动到谁,当前队列状态
2 逐字执行 workflow 步骤(node .github/scripts/review-runner-schedule.mjs "$GITHUB_REPOSITORY" "$POOL"),目录里只有 planner(与 sparse checkout 一致),使用真实 gh 2.100.0(即 ubuntu-latest 的版本)。通过 mount namespace 改 /etc/hostsapi.github.com 以 TLS 指向本地有状态假服务,数据来自线上 167 台 runner 快照;共 10 个场景 线上请求形态、分页、顺序、故障、幂等
3 @actions/expressions 0.3.61(GitHub 自己的求值器)对逐字的 if:POOLruns-on 求值 池子选择与 main 围栏
4 wenshao/qwen-code 上跑真实 GitHub Actionsrun 34932668341),fallback 门控与 never_started 代码块从本 head 逐字节复制 手动取消一个排队中的 review,新守卫看到的是什么
5 逐字执行 Post fallback comment 步骤,base 与 head 两臂,真实 gh 读取线上 run 数据;gh pr comment 与所有 API 写操作都被拦截 两臂在真实 payload 上各自发什么文案
6 从两臂逐字提取 autofix 的 REVIEW_PR_LIVE jq,跑在一个 open PR 的真实 statusCheckRollup scan 门控改动在线上数据上的效果
7 node --test、vitest(head、base、以及把 head 的测试跑在 base workflow 上)、prettier、eslint、yamllint、actionlint 1.7.12、check-workflow-size.sh 门禁,以及新守卫是否非空转

已确认

  • 线上机群上的切换(S1–S4)。 review 发出 62 个 POST …/labels,body 为 {"labels":["ecs-review"]},没有 DELETE。变化的正好是 62 台在线 ecs-qwen-hk1-*/hk2-*;2 台离线的 hk1 以及全部 hk3/hk4/hk5 从未被写。随后 ci 发出 62 个 POST ecs-qwen + 62 个 DELETE ecs-review。两次重复执行都是零写入。所有场景中逐 runner 的顺序违例(DELETE 先于 POST):0ci 切换后 ecs-qwen 池从 93 台变为 155 台,与作者最新评论一致。
  • 故障(S5–S7)。 POST 返回 403 时,该 runner 不会发 DELETE,保持原来的池子;DELETE 返回 502 时,该 runner 同时属于两个池子。run 以 ::error::2 runner label change(s) failed 退出码 1 结束,手动重跑即收敛。离线 runner 被跳过、保留原标签,恢复在线后由下一次运行切换。始终没有出现不带任何池子标签的 runner。
  • 分页此刻就在起作用。 167 台 runner 即 2 页;真实 gh --paginate --slurp 会跟随 GitHub 的 Link/repositories/1008713177/…?page=2。把 31 台 hk1/hk2 挪到第 2 页(S10),仍然 62 台全部切换。
  • 前置条件缺失时会大声失败。 RUNNER_ADMIN_PAT 为空时在任何请求之前就退出 1(S8)。发行版 gh 2.46 不支持 --slurp,退出 1 且零写入(S9);ubuntu-latest 上是 2.100.0(fork 探针打印),所以不影响该 workflow。
  • 表达式。 schedule 0 9reviewschedule 0 21ci;在 mainworkflow_dispatch → 所选池子。在其他分支 dispatch 会跳过该 job,非 QwenLM/qwen-code 仓库同样跳过。review-pr 路由到 ecs-reviewMAINTAINER_ECS_RUNNER_DISABLED=true 时回退到 ubuntu-latest
  • Environment。 qwen-review-runner-schedule 已存在,自定义分支策略为 [main]RUNNER_ADMIN_PAT 已配置(创建于 2026-09-14T15:03Z)。
  • Autofix 门控。 在 PR fix(core): treat a word-initial # as a comment when splitting shell commands #11821 的真实 rollup 上(其 review-prQUEUED),base 会 hold 这一轮,head 继续执行;把该 check 改成 IN_PROGRESS 后两臂都会 hold。
  • 真实 payload 上的 fallback 文案。 对从未启动、结果为 failurereview-pr(即 24 小时过期的情形),base 发出错误的「failed… retried automatically」,head 发出准确的「never started」文案。对在 ecs-qwen-hk1-28 上跑了 22 个 step 的 review-pr,两臂都发 failure 文案。对 PR 合并时被取消、从未启动的 run(feat(extensions): ship dynamic workflows from extensions #11805),两臂都不发。
  • 门禁。 全部退出码 0。本地 vitest 的 2 + 2 个失败在 base 上是完全相同的四个测试(uid 0 下 chmod 无法拒绝、env-plant 锁),CI 在本 head 上全绿。把 head 的测试跑在 base workflow 上,runner 路由、三种 body 的链接不变式、queue-expiry 文案、仅 IN_PROGRESS 才 hold 这几项会变红,说明这些守卫不是空转。

F1:手动取消一个排队中的 review,会被告知「达到 24 小时排队上限」

守卫把「review-pr 没有 runner 且没有 step」在 failurecancelled 两种结果下都当作排队过期。而 review-pr 仍在等 runner 时被手动取消的 run 恰好是这个形态;门控也会放行,因为 authorizedelay-automatic-review 早已结束。

  • 真实 GitHubrun 34932668341):review-pr 排队 2 分 17 秒后执行 gh run cancel,结果为 completed/cancelledrunner_name: ""steps: 0;门控放行,逐字代码块输出 never_started=true,并写入 (queue expired); posting the queue-expiry body
  • 线上回放 PR fix(core): treat a word-initial # as a comment when splitting shell commands #11821(run 34906150910),result=cancelled:base 发「was cancelled before a review could be posted… by an operator, an upstream event…」——属实;head 发「No runner picked up the review job before GitHub ended it at the 24-hour queue limit… check its latest run」——不属实。

池子每天关闭 12 小时,手动取消排队中的 review 会成为常规运维操作。这与 #10109 属于同一类问题。加一个排队时长条件,就能让真实过期仍走新文案,手动取消则落回现有且准确的 cancelled 文案。以下过滤器已在真实探针 payload(→ false)、24 小时形态的未启动 job(→ true)、在 hk1-28 上跑过的 job(→ false)和空列表(→ unknown)上实测:

[.jobs[] | select(.name == "review-pr")]
| if length == 0 then "unknown"
  elif all(.[]; (.runner_name // "") == "" and ((.steps // []) | length) == 0
       and (.completed_at // "") != ""
       and ((.completed_at | fromdateiso8601) - (.created_at | fromdateiso8601)) >= 82800)
  then "true" else "false" end

queue-expiry 相关测试的 fixture 需要补上 created_at/completed_at,step summary 里的「(queue expired)」也应挪到同一条件下。我认为可以作为后续修复,不是合并阻塞项。

F2:线上上线状态——单独合并能恢复 review,恢复不了 autofix

这不是 diff 的缺陷,但决定了合并的效果(只读快照,2026-09-15 05:00Z):

  • 仓库里没有任何 runner 带 ecs-agent hk1(32 个注册,30 在线)和 hk2(32 个,全部在线)只带 ecs;hk3/hk4/hk5 带 ecs-qwen。我找到的最近一次在 ecs-agent runner 上执行的 review-pr,于 2026-09-14T22:17:54Z 在 ecs-qwen-hk1-7 上启动。
  • 有 36 个 job 在等 ecs-agent:31 个 review-pr、3 个 review-address、2 个 issue-autofix,最早的创建于 2026-09-14T22:46:27Z。快照之后,本 PR 在 05:29Z 请求的 /reviewrun 34932959802)也加入了排队:其 review-pr 排在 ecs-agent 上,而 ack 写的是「Review is running」。

由此:

  1. 合并后,第一次 review 切换(17:00 的 cron 或手动 dispatch)会给 hk1/hk2 加上 ecs-review,自动 review 随之恢复。已经在排队的 run 保留原来的 ecs-agent 请求,不会被改道。它们从今天约 22:46Z 起陆续在 24 小时后过期,届时各自的 fallback job(main 的版本,在 run 创建时就已固定)会发出 main 的 failure 或 cancelled 文案,除非该 PR 的 head 已变化或 PR 已关闭。先取消这些 run 可以避免误导性文案;恢复 ecs-agent 则能让它们正常执行。
  2. 合并后 issue-autofixreview-address 可用 runner 仍为 0。描述中「The switch preserves ecs-agent, so autofix remains eligible on both hosts」要等主机侧注册重新带上 ecs-agent 才成立——Risk & Scope 也要求保留它,但线上机群目前没有。

非阻塞

  • .github/workflows/.size-baseline 记录 qwen-review-runner-schedule.yml 为 1958 字节,实际文件是 2518 字节。仍在 4096 字节容差内,所以门禁是绿的。
  • 仓库变量 QWEN_REVIEW_DAY_RUNNERSQWEN_REVIEW_NIGHT_STARTQWEN_REVIEW_NIGHT_END 是早期版本留下的,本 head 中引用数为 0,合并后可以删除。
  • Tested-on 一栏仍写着当前 head 的 CI 待确认;d45b5d98 上已全部通过。

未验证

  • 在 QwenLM runner 上真实写标签(会移动生产容量)。
  • 真实的 schedule 触发。
  • GitHub 对排队满 24 小时的 job 给出哪种结果(需要等 24 小时才能观测;PR 对 failurecancelled 都做了处理)。
  • 主机侧的注册配置。

截图

见上方英文部分的四张图:线上机群与队列状态、真实 gh 对 TLS 伪造 API 的请求记录、真实 GitHub Actions 上的手动取消及 base/head 线上回放、门禁/表达式矩阵/autofix 门控。

🤖 Generated with Claude Code — Claude Opus 5 (1M context)

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 334 passed · 0 failed · 334 total

Flakiness gate: ⚠️ timeout — only 4 of 5 rounds fit the 15-minute budget; the completed rounds agreed

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:334 通过 · 0 失败 · 334 总计

抖动门:⚠️ timeout — only 4 of 5 rounds fit the 15-minute budget; the completed rounds agreed

Verification report (report.md, truncated)

# PR #11855 deep verification (round 4) — `ci(review): switch online hk1 and hk2 runners between review and CI twice daily`

**Verdict: `findings`** — 334 scripted assertions ran, **334 passed / 0 failed**. The twice-daily whole-pool switch is correct and re-proven load-bearing end to end on an independent harness set built from scratch this round. **The PR is already merged into `main`** (see below), so every rollout risk round 3 flagged as prospective is now a live condition, and two of them are measured here against the live system.

Verified head: `d45b5d9811df74020df4f36878c54d48a8eb46d9` (`git rev-parse HEAD^2`; matches `headRefOid` in the metadata snapshot). Base for the A/B: `995f91c4f1e486f23dac8210c14e27aa7f6e31d2` (`HEAD^1`). Merge ref: `6df6f53ae8`. Effective diff: `git diff HEAD^1..HEAD`, 10 files, +632/−193.

**This head is byte-identical to the one round 3 verified** — same head SHA, same base SHA, therefore the same trees. What is genuinely new this round is not the code: it is that (a) the PR landed on `main` as `1fb7595c36b71c9f2774bef1a856c0b8032717f2` at **2026-09-15T06:00:09Z**, ~35 minutes before this round started; (b) anonymous REST access works in this container, which round 3 did not have, so the live post-merge state and real production job payloads became measurable; and (c) round 3's two self-declared gaps (mutant R02 and one inconclusive vacuity cell) are closed. All measurements below were **re-run at this head with new harnesses**, not carried over by reading the old report.

<details>
<summary>中文摘要</summary>

**结论:`findings`(有发现,需要评审人注意)** — 共执行 334 条脚本断言,全部通过(334 pass / 0 fail)。每天两次的整池切换机制正确,本轮用**全新独立编写的 harness** 重新证明了它是起作用的。

**最重要的事实:这个 PR 已经合入 `main`**(`1fb7595c36`,2026-09-15T06:00:09Z,就在本轮开始前约 35 分钟)。10 个改动文件在 PR head 与 main 上逐字节相同。因此前几轮的"上线风险"现在都是**线上现状**。

**A/B 结论**(详见 "Central claim and A/B" 各表)

- Harness A 用 PATH 上的**有状态假 `gh`**(每个 runner 一个状态文件)驱动**未经修改的真实脚本**,16 组场景 **159 条断言全通过**:整池切换 62 次标签调用、POST 严格先于 DELETE、`ecs-agent` 保留、往返逐字节还原、幂等、9 个真实生产名 + 8 个近似名全部零改动、注入失败后的降级与自愈、**反序对照构建**(先删后加时受害者会**完全没有池标签**)、分页、200 台扇出、单文件稀疏检出自足性。见 `04-planner-ab-cells.png`。
- Harness B 用 YAML 解析器**逐字提取** `Post fallback comment` 步骤(base 139 行 / head 159 行,diff 恰好 22 行),在两条臂上各跑 24 个 cell:**8 个 cell 翻转**为"队列过期"专用正文,其余 **16 个两臂逐字节相同**;每个翻转 cell 仍然发评论、仍带 dedup marker、跨 job 锚点和重试指引。并用**真实生产数据校准**:run `32875478404` 的真实 jobs payload + PR #9729 上真实发出的那条评论,`REVIEW_PR_RESULT=failure` 时两臂都逐字节复现。见 `01-ab-fallback-comment-base-vs-head.png`。
- Harness C 用**真实脚本**驱动 6 次 tick 的闭环,按 PR 描述实测的真实机群形态(64 台 hk1/hk2、62 台在线,加 93 台 hk3/4/5):review-eligible 严格 `62 → 0 → 62 → 0`,CI 池 `93 → 155 → 93`,autofix 全程**恒定**(两种 `ecs-agent` 假设下都恒定)。**64 条断言全通过**。见 `02-schedule-timeline-and-realized-window.png`。
- **31 个变异:23 个被出厂测试杀掉,8 个存活;存活的 8 个全部被行为探针杀掉,0 个未裁定。** 本轮**补上了 R02**(round 3 因锚点重复无法单点变异,本轮按出现序号消歧后**被杀**)和 **R01**(round 3 记为 inconclusive 的那个 vacuity cell,本轮在配置完整的 scratch root 里**被杀**)。见 `05-mutation-matrix-31-mutants-plus-probe.png`。
- 门禁全绿且**每个门禁都做了活性对照**:helper suite 6/6、vitest 603/603(两个改动的测试文件)、size ratchet 在 **main 上**绿、prettier 干净、eslint 干净(2 种植入违规均报出)、shellcheck 0.11.0 三个目标全干净(植入 SC2086 报出)、actionlint 1.7.12 四个 workflow **0 diagnostics**(3 种植入报出,`github.event.schedul` 仍然**不报**)。

**主要发现**

1. **"上线首日 24 小时窗口"没有发生**:合并落在 06:00:09 UTC,第一个 review tick 是 09:00 UTC,**实际暴露窗口 = 3.00 h**,只有理论最坏值 24.00 h 的 1/8。理论最坏值本轮重新推导确认为 1440 min(合并在 09:00 UTC 那一分钟)。
2. **"从未启动"正文断言了一个它自己的判据无法确立的原因 —— 本轮用真实生产数据证明了这一点**:今天有 3 个真实 run 命中该形态(`review-pr` conclusion=cancelled、`runner_name=""`、`steps=0`),实际分别在 **9.7 / 36.4 / 33.7 分钟**后被取消,即 24 小时上限的 0.67% / 2.53% / 2.34%。**但范围已被界定**:三者都是 PR 合并触发的取消,PR #11910 上实测**零条** fallback 评论——`pr_state != OPEN` 门禁在正文选择**之前**就拦掉了。
3. **`.size-baseline` 的两处偏差现在在 main 上**:新条目记 1958、实际 2518(余量 3536 字节,不是 round 3 说的 4656);`ci.yml` 记 134426、实际 137297,只剩 **1225 字节**余量,之后任何改到 `ci.yml` 的无关 PR 会硬红。
4. **CI 侧容量本轮已量化**:按真实机群形态,每 12 小时 CI 池 155 → 93,即 **−40.0%**;`ecs-qwen` 被 **35 处 `runs-on`、跨 19 个 workflow** 引用(round 3 说 24 处)。
5. **韧性风险现在是线上现状**:调度 workflow 至今 `total_count: 0`(从未运行),PAT 2026-10-14 到期,main 上**零个**看门狗引用它。

**未覆盖范围**:真实 hk1/hk2 runner 名单仍不可枚举(`GET /actions/runners` → **401**);19 个 commit 里浅克隆只可达 1 个,只验证了聚合 diff;yamllint 装不上(`pip3: Permission denied`,base/head 同样失败);R04/R05/A02 三个变异沿用 round 3 结果未重跑;未采样实时 review 需求总量。

</details>

## Previous-finding status

Re-measured at `d45b5d98` with newly written harnesses. Where a measurement was carried forward, what was compared is named.

| # | previous finding | severity then | status at this head |
|---|---|---|---|
| **Merge state** | — (round 3 treated the PR as unmerged) | — | **CHANGED: the PR is merged.** `main` tip `1fb7595c36` is this PR's squash merge, committed 2026-09-15T06:00:09Z. All 10 changed files are **byte-identical** between `HEAD^2` and `main` (`git rev-parse <oid>:<path>` compared per file), and the two commits that landed between `HEAD^1` and the merge (#11910, #11805) touch none of them. Every rollout risk below is therefore live, not prospective. |
| F1 | Capacity arithmetic does not balance; ~61% of reviews dropped | substantive | **superseded on the review side; now QUANTIFIED on the CI side.** Driven on the fleet shape the description itself measured (64 hk1/hk2, 62 online, none carrying any of the three labels; plus 93 hk3/4/5): review-eligible goes `62 → 0 → 62 → 0` across four ticks (C4.1/C4.2/C4.10), and the CI pool goes `93 → 155 → 93` (C4.3/C4.4), i.e. **−62 of 155 = −40.0% for 12 h a day**. Demand still cannot be sampled without a token, so the review-side deficit remains *unquantified* rather than *wrong*. |
| F2 | Suppressing the fallback comment removes the only PR-visible signal | substantive | **FIXED on the signal, stands on the watchdog.** Re-measured over 24 cells × 2 arms: **8 cells flip** to the dedicated queue-expiry body, the other **16 are byte-identical between the arms**, and every flipping cell still posts with the marker, the `actions/runs/<id>)` anchor and the retry instruction (Bsum.2–Bsum.5). Watchdog census re-run **on main**: zero references to `qwen-review-runner-schedule` outside the workflow itself, `.size-baseline`, and two mentions inside `qwen-code-pr-review.yml`; zero `review-pr` mentions in `qwen-fleet-shepherd.yml`. |
| F3 | `main()` is entirely untested | Suggestion | **FIXED, re-measured.** Suite is 6 tests (two spawn `main()` through a fake `gh`). Matrix: **31 mutants, 23 killed by the suite, and all 8 survivors killed by the behavioural probe — 0 unadjudicated** (Harness E + E4). |
| F4 / R2-F6 | `ecs-qwen-hk[12]-<n>` filter unverified against the live fleet | risk | **STANDS, with harder evidence than round 3 had.** `GET /repos/QwenLM/qwen-code/actions/runners` → **HTTP 401 Requires authentication**, so the fleet is unenumerable in this container even though anonymous REST otherwise works here (7 other endpoints returned 200). Failure mode re-proven **loud**: exit 1, `::error::no ecs-qwen-hk1-<n> or ecs-qwen-hk2-<n> runner found`, **0** label writes across four fleet-less fixtures (S5). Isolation re-proven against **9 real production names + 8 near-misses**, all byte-identical, with **0** calls to any of their ids (S4/S1.8–S1.10). Leading-zero `ecs-qwen-hk1-01` IS matched (S1.13). |
| Corr1 | `384 runner-hours` vs a count of `375` reviews | correction | **superseded** — both figures remain absent from the description. |
| Corr2 | The 24 h queue limit ends the contended case in cancellation | correction | **accepted** — Risk & Scope still says "Queued reviews can expire after 24 hours". |
| R2-Corr1 | In-repo comment said the pool is "deliberately small by day" | correction | **FIXED, re-measured.** The comment reads "closed by day", which matches C4.2 exactly (**0** review-eligible outside the window). |
| R2-Corr2 | actionlint cannot validate `github.event.schedule` | correction | **STANDS, re-verified with actionlint 1.7.12.** Plants: `inputs.pooI` → reported; `cron: '0 21 * *'` → reported; `github.refd` → reported; **`github.event.schedul` → exit 0, nothing reported**. So the clean actionlint result is not evidence for the `POOL` expression's schedule key. That key is instead pinned by Harness C1.2–C1.6, which evaluates the lifted expression over six trigger shapes. |
| R2-Corr3 | `deployment: false` is accepted natively by actionlint | correction | **STANDS.** actionlint 1.7.12 run from a directory with **no config at all** reports **0 diagnostics** on all four changed workflows. |
| R2-Corr4 | The repo's actionlint gate disables shellcheck | correction | **STANDS, re-verified** at `scripts/lint.js:189` (`-shellcheck= \` ). shellcheck 0.11.0 run directly on both arms' extracted `run:` block and the schedule workflow's run line: clean; a planted unquoted expansion → **SC2086**. |
| R2-F1 / R3-F5 | Losing one switch costs a whole night; PAT expires 2026-10-14 | substantive | **Announcement half FIXED; resilience half STANDS and is now LIVE.** `GET .../qwen-review-runner-schedule.yml/runs` → **`total_count: 0`** (it has never run). Still 2 ticks/day, still no reconciliation. Re-measured: a tick with a missing/empty PAT exits 1 having made **0** `gh` calls of any kind (S6.pat-missing, S6.pat-empty). From 2026-10-14 the steady state is every tick red and the pool permanently closed — now with an accurate comment on each affected PR, which is the whole improvement. |
| R2-F2 / R3-F3 | Day-one window reaches 24 h | substantive rollout risk | **DID NOT MATERIALISE — resolved by the actual merge time.** The merge landed at **06:00:09 UTC**; the first `review` tick is **09:00 UTC**, so the realized day-one exposure is **179.85 min = 3.00 h** (C5.6–C5.8), **8.0× smaller** than the theoretical worst (C5.11). The worst case was re-derived and confirmed: **1440 min = 24.00 h**, reached by merging in the minute after a 09:00 UTC tick (C5.9); steady-state worst is **720 min = 12.00 h** at 21:00 UTC with mean **6.01 h** among the half of arrivals that wait (C5.3/C5.4). Live corroboration: review run `34937172848` (PR #11911) was created 06:29:36Z and is still `pending`. |
| R2-F4 / R3-F6 | A runner offline at the switch is excluded for the whole window | minor | **STANDS, re-measured across 3 ticks** (S14): skipped and byte-identical at both switches, keeps CI **and** autofix eligibility by label, picked up at the next evening tick. One new observation: in the description's measured fleet those offline runners carry **no pool label at all**, so they match no `runs-on` (C4.7b) — pre-existing, not caused by this PR. |
| R2-F5 / R3-F7 | An all-offline fleet produces a green run with zero changes | minor | **STANDS, re-measured** (S4.1–S4.4): exit **0**, 0 label writes, `- 0 runners to switch`, 0 review-eligible. The fleet guard tests `FLEET_NAME` without a status check. |
| R3-F1 | The queue-expiry body states a cause its own predicate cannot establish | Suggestion | **STANDS, and now demonstrated on real production traffic rather than a synthetic cell** — but also **materially bounded**. See Finding 1. |
| R3-F2 | The new `.size-baseline` entry does not match its file | nit | **STANDS and is now on `main`.** See Finding 2, including a correction to round 3's headroom arithmetic. |
| R3-F4 | CI-side capacity unquantified | substantive (description gap) | **now quantified** — see F1 above and Finding 3. |
| R3 vacuity | The `isolates the long-running review job on the schedule-sized review pool` revert was **inconclusive** (scratch root under-provisioned, vitest collected no tests) | — | **CLOSED.** Mutant **R01** applies that identical revert (`ecs-review` → `ecs-agent`) in a fully-provisioned scratch root and is **KILLED** by `qwen-pr-review-workflow`. The harness now reports collection failures explicitly rather than counting them as kills. |
| R3 R02 | Mutant R02 **not measured** — the anchor `if [ "$never_started" = "true" ]; then` occurs twice | — | **CLOSED.** The anchor still occurs exactly **2×** (E2.R02.anchor-count). Disambiguating by occurrence, the mutation was applied at **occurrence 2** (the body-selection site) with occurrence 1 (the guard site) asserted untouched, and R02 is **KILLED** by `qwen-pr-review-workflow`. |
| R3 Corr1 | `.github/actionlint.yaml` does not enumerate `ecs-review` | correction | **STANDS** and remains inert for the same reason: every `runs-on` naming it is a `fromJSON('[…]')` expression actionlint cannot resolve. |

**No previous finding worsened.** Two are closed outright (R02, the inconclusive vacuity cell), one is resolved by events (the day-one window), and the mechanism measurements that could have drifted were each re-derived: the round trip is byte-identical to the original labels (S2.3), a failed POST leaves the victim in its **previous** pool (S7.4) and a failed DELETE leaves it **review- AND CI-eligible** (S8.2–S8.5), and the reverse-order control reproduces the hazard the code comment claims (S10.2/S10.3).

## Central claim and A/B

**Central claim.** Review capacity follows the clock: `review-pr` asks for `ecs-review`, and two daily cron runs switch **every online runner named `ecs-qwen-hk1-<n>` or `ecs-qwen-hk2-<n>`** — all of them, at once, no ramp — to `ecs-review` at 17:00 Asia/Shanghai and back to `ecs-qwen` at 05:00, **preserving `ecs-agent`** so autofix stays eligible, **adding the target label before removing the other**, skipping offline runners, preserving unrelated labels, idempotently, and only from `main`.

**Secondary claims.** (a) `POOL` resolves to `review`/`ci` from the two crons and from the dispatch input. (b) `fallback-comment` posts a dedicated body when `review-pr` never got a runner, and is otherwise unchanged. (c) autofix's scan dispatch gate holds only on an `IN_PROGRESS` review.

The base tree contains no planner, so a base-vs-head A/B on the planner can only prove the file is new. The load-bearing proof is four-sided: a **closed-loop simulation driven by the real script on the description's own fleet shape**, a **31-mutant matrix adjudicated to zero survivors**, and **two true base-vs-head A/Bs** on surfaces present on both arms.

### A/B table

| # | Cell | Environment | Observable oracle | Base | Head |
|---|---|---|---|---|---|
| A-S0 | Harness liveness | real script, stateful fake `gh` on PATH (**one state file per runner**, so the script's own `Promise.all` cannot race the harness) | exit code, on-disk labels | n/a | positive control switches a 1-runner fleet; negative control rejects an unknown mode |
| A-S1 | Evening switch, 50-runner fixture: 32 hk1/hk2 (30 online / 2 offline / half busy) + leading-zero `ecs-qwen-hk1-01` + **9 real production names** + **8 near-misses** | same | full argv log + per-runner ordered call log + on-disk label diff + `$GITHUB_STEP_SUMMARY` | n/a (no script) | **31 × (POST +ecs-review, DELETE −ecs-qwen) = 62 label calls**, 1 list call, POST strictly before DELETE for all 31 (S1.15), every switched runner == *original − ecs-qwen + ecs-review* exactly (S1.11), `ecs-agent` preserved (S1.12), offline + all 17 foreign hosts byte-identical, **0** calls to a foreign id. Base-arm control: base's `review-pr` wanted `ecs-agent`, so **48 of 48** online runners were review-eligible at all times (S1.21) |
| A-S2 | Morning switch from the S1 end state | same | same | n/a | 31 back to `ecs-qwen`; **round trip == original labels exactly**; no runner lost `ecs-agent`; **0** in the both-labels state; **0** with no pool label |
| A-S3 | Idempotence, both directions | same | label-call count | n/a | 2nd identical run: **0** label calls, 1 list call, `- 0 runners to switch`, fleet unchanged |
| A-S4/S5 | All-offline managed fleet; then empty / hk3-only / near-miss-only / 64c-only | same | exit code, `::error::`, writes | n/a | all-offline → **exit 0, 0 writes** (Finding 5); the four fleet-less shapes → **exit 1**, `no ecs-qwen-hk1-<n> or ecs-qwen-hk2-<n> runner found`, **0 writes** |
| A-S6 | Missing PAT, empty PAT, **10** invalid modes, **11** malformed repos, no argv, repo-only | same | exit code + total `gh` call count | n/a | all **exit 1** with **0 `gh` calls** — every guard fires before anything is on the wire |
| A-S6b | Boundary probe of the repo guard | same | which inputs pass | n/a | the guard `^[\w.-]+\/[\w.-]+$` **accepts** `../etc`, `a/..`, `./.`, `.../...`, `.x/.y`, `-/-`, `_/_`; with an empty fleet no write reaches the wire; see Finding 4 |
| A-S7 | Injected POST failure on 1 of 31 | same | exit, summary, victim labels, collateral, per-runner call order | n/a | exit 1, `1 runner label change(s) failed`, victim named, **only the POST attempted** (DELETE never ran), victim **keeps its original labels** and stays CI-eligible, other 30 fully switched |
| A-S8 | Injected DELETE failure | same | same | n/a | victim ends in the **both-labels** state = review- AND CI- AND autofix-eligible — degrades toward availability, never toward a dead host |
| A-S9 | Next tick from 4 degraded states × 2 modes | same | final label set | n/a | all 8 combinations exit 0 and end in **exactly one** pool |
| A-S10 | **REVERSE-ORDER CONTROL BUILD** (scratch-mutated: remove before add) | same, mutated copy, anchors asserted to occur exactly once | victim's pool labels | n/a | the same injected POST failure leaves the victim with **NO pool label**, satisfying **none** of the real `runs-on` sets — the exact hazard the code comment claims, measured |
| A-S11 | Pagination at page sizes 1 / 3 / 7 / 100 / 1000 | same | fleet signature | n/a | byte-identical signature at all five; 1 list invocation each |
| A-S12 | Sparse-checkout self-containment | script copied **alone** into an empty dir | import census + exit code | n/a | every specifier is a `node:` builtin; exit 0 and the fleet switched — the one-file sparse checkout is sufficient |
| A-S13 | 200-runner fan-out | same | wall span, fd errors | n/a | 400 label calls, all 200 review-eligible, no `EMFILE`/`EAGAIN`/`spawn` error, **577 ms** wall |
| A-S14 | Offline at the switch, online at a later tick | same, 3 ticks | eligibility per tick | n/a | skipped and byte-identical at both switches; keeps CI **and** autofix eligibility; picked up at the next evening tick |
| B | `Post fallback comment`, **24 cells × 2 arms** | **verbatim** step extracted by YAML parser (base 139 / head 159 lines; `diff` = 22 lines), `bash --noprofile --norc -e` + the step's own `set -uo pipefail`, fake `gh` running the **caller's own `--jq`** through the real `/usr/bin/jq`, fake `sleep` | was `gh pr comment` invoked, with what exact body; exit code; step summary | **exit 0 on all 24**; posts on 18, silent on 6 (`B13 B14 B15 B16 B19 B21`) | **8 cells flip** to the queue-expiry body (`B1 B2 B8 B9 B17 B18 B22 B23`); the other **16 are byte-identical between the arms**, including all **6** non-posting cells. Exit codes agree on all 24; the exit-1 path is driven separately as the negative control (B0.liveness) |
| B-cal | **Real production data**: run `32875478404`'s actual jobs payload + the **comment that run really emitted** on PR #9729 (id `5414317254`) | same | same | with `REVIEW_PR_RESULT=failure`, **reproduces the real comment byte for byte** | **reproduces the real comment byte for byte** — the guard does not touch a review that actually ran. With `cancelled` both arms agree with each other but produce the cancelled body, which dates the artifact before base's cancelled branch (see Correction 4) |
| B-real | **Three REAL never-started runs** from 2026-09-15 (`34931163790` / PR #11910, `34923724949` / PR #11906, `34922815556`), driven through both arms at their **real** state and at a counterfactual OPEN state | same | posted body; exit code; step summary | at real state MERGED: no post. At OPEN: the **cancelled** body, which enumerates causes without asserting one | at real state MERGED: **no post** (the `pr_state != OPEN` gate fires **before** body selection). At OPEN: the queue-expiry body, which asserts "at the 24-hour queue limit" — **false** for these runs, cancelled after 9.7 / 36.4 / 33.7 min |
| C1 | `POOL` resolution | expression lifted verbatim from the YAML and evaluated under GitHub's `&&`/`||` operand semantics | resolved pool per trigger | n/a | `0 9 * * *`→`review`, `0 21 * * *`→`ci`, dispatch→input, **unknown cron→`ci`** (fail-safe), empty dispatch input→`ci` |
| C2 | The `main` fence + hardening flags | parsed from the YAML | job-runs-or-skips per ref; flag values | n/a | admits main; rejects a feature branch, a PR merge ref and a fork; `deployment: false`, `persist-credentials: false`, `cancel-in-progress: false`, `contents: read`, sparse pattern == executed path, checkout pinned by SHA |
| C3 | `review-pr` `runs-on`, base vs head | regex-extracted from each arm | label set | `["self-hosted","linux","x64","ecs-agent"]` | `["self-hosted","linux","x64","ecs-review"]`; the other three pr-review jobs stay on `ecs-qwen` (3 sites); `MAINTAINER_ECS_RUNNER_DISABLED=true` still falls back to `ubuntu-latest` |
| C4/C4b | Closed loop, **6 ticks**, driven by the **real planner** on the description's measured fleet shape | real `planLabels`, both `ecs-agent` readings | eligible counts per tick | `review-pr` needed `ecs-agent`: **every** online runner eligible at all times (S1.21) | review `62 → 0 → 62 → 0 → 62 → 0`; CI `93 → 155 → 93 → 155 …`; **autofix flat** at 93 (C4) and flat at 155 (C4b, hk1/hk2 carrying `ecs-agent`); after 6 ticks **0** in the both-labels state and **0** online with no pool label |
| C5 | Queue arithmetic | computed over all 1440 arrival/merge minutes | wait minutes | n/a | steady state: open 720/1440 min, half of arrivals wait, mean **6.01 h**, worst **12.00 h**; theoretical day-one worst **24.00 h**; **realized day-one 3.00 h** |
| E/E4 | **31 single-point mutants** | scratch roots only; anchors asserted to occur exactly once; occurrence-indexed where an anchor repeats | shipped suite exit, then a behavioural probe for every survivor | — | **23 killed by the suite / 8 survived it / 0 unmeasured**; **all 8 survivors killed by the probe** (M10 M14 M15 M17 by Harness A; W04 W05 W07 W08 by Harness C) |

Witnesses: `01-ab-fallback-comment-base-vs-head.png` (the B cells as they printed, with the calibration rows), `02-schedule-timeline-and-realized-window.png` (the C4 tick table and the C5 arithmetic), `03-live-post-merge-state.png` (the live REST observations), `04-planner-ab-cells.png` (the A cells), `05-mutation-matrix-31-mutants-plus-probe.png` (the E and E4 matrices).

### Mutation matrix (31 mutants, 0 unadjudicated)

| id | mutation | shipped suite | behavioural probe | classification |
|---|---|---|---|---|
| M01 | drop the `status === 'online'` filter | **killed** | — | pinned |
| M02 | unanchor the fleet-name regex | **killed** | — | pinned |
| M03 | widen to every `ecs-qwen-*` host | **killed** | — | pinned |
| M04 | **regress** to the hk2-only filter | **killed** | — | pinned — the hk1 half is load-bearing |
| M05 | let `remove` include the target label | **killed** | — | pinned |
| M06 | **regress**: strip `ecs-agent` too | **killed** | — | pinned — the autofix-capacity fix is load-bearing |
| M07 | invert the two pools | **killed** | — | pinned |
| M08 | always POST the target | **killed** | — | pinned |
| M09 | emit an action when nothing changes | **killed** | — | pinned |
| M10 | drop the missing-PAT guard | survived | **killed** (153 p / 6 f) | pinned by probe: S6.pat-missing/empty — calls reach the wire unauthenticated |
| M11 | drop the empty-fleet guard | **killed** | — | pinned |
| M12 | never POST the target label | **killed** | — | pinned |
| M13 | swallow label-write failures | **killed** | — | pinned |
| M14 | drop the mode pre-validation | survived | **killed** (148 p / 11 f) | pinned by probe: 10 invalid modes reach a `gh` call |
| M15 | drop the repo-argument validation | survived | **killed** (147 p / 12 f) | pinned by probe: 11 malformed repos reach the wire |
| M16 | `maxBuffer` 16 MB → 64 B | **killed** | — | pinned |
| M17 | stop forwarding the PAT to `gh` | survived | **killed** (158 p / **1** f) | pinned by probe — the single red is S1.14, the token check |
| M18 | the `main` guard never fires | **killed** | — | pinned |
| M19 | **regress** the ordering: remove BEFORE add | **killed** | — | pinned by the shipped ordering test |
| W01 | move the evening cron, not the comparison | **killed** | — | pinned |
| W02 | swap which cron opens the review pool | **killed** | — | pinned |
| W03 | stop wiring `secrets.RUNNER_ADMIN_PAT` | **killed** | — | pinned |
| W04 | sparse-checkout a different file | survived | **killed** (C2.12) | pinned by probe: pattern == executed path |
| W05 | `deployment: false` → `true` | survived | **killed** (C2.10) | pinned by probe — **only after C2.10 was re-anchored**; see Correction 3 |
| W06 | drop the `refs/heads/main` half of the fence | **killed** | — | pinned by the anchored regex in the shipped test |
| W07 | `cancel-in-progress: false` → `true` | survived | **killed** (C2.14) | pinned by probe |
| W08 | `persist-credentials: false` → `true` | survived | **killed** (C2.11) | pinned by probe |
| R01 | **regress** `review-pr` to the base `ecs-agent` pool | **killed** | — | pinned — the central claim is load-bearing. **Closes round 3's inconclusive vacuity cell** |
| R02 | widen the body read site from `= "true"` to `!= "false"` | **killed** | — | pinned. **Round 3 could not measure this**; disambiguated by occurrence (anchor occurs 2×), occurrence-1 guard site asserted untouched |
| R03 | relax the never-started predicate from `and` to `or` | **killed** | — | pinned |
| A01 | **regress** the scan gate to base's five-status predicate | **killed** | — | pinned |

**Positive controls.** The pristine scratch root is green under the identical command (E0.1, 6 tests), a non-existent test file does not exit 0 (E0.3), Harness A on the unmutated planner is **159 p / 0 f** and Harness C on the unmutated workflow is **64 p / 0 f** under the exact commands the probe uses (E4.0.A/E4.0.C) — so a probe reporting 6, 11 or 12 failures is measuring the mutant, not a broken harness. Harness B carries its own pair: a failing dedup lookup drives the step to exit 1 posting nothing, and a clean cell drives it to a successful post. **Zero mutants are dead code and zero are unadjudicated.**

### Vacuity

Round 3's inconclusive cell is closed by R01 above. Additionally, the shipped suite's central assertions were shown non-vacuous by the mutants that kill them: M04 (hk2-only) is killed by the hk1/hk2 fixture, M06 (`ecs-agent`) by the label-preservation case, M19 (ordering) by the spawn-based ordering test, W06 by the `^ {4}if:` anchored regex. Harness E4 killed 8 further mutants with named assertions, each quoted in `logs/harness-e4.txt`.

**One vacuous assertion was found and fixed in this round's own harness**, and it is reported because it invalidates a round-3 number: see Correction 3.

### Gates (each with a liveness control)

| gate | command | result | liveness control |
|---|---|---|---|
| shipped helper suite | `node --test .github/scripts/review-runner-schedule.test.mjs` | **6 pass / 0 fail** | 23 mutants killed by this same command; a non-existent test file does not exit 0 |
| the two changed vitest files | `npx vitest run --config ./scripts/tests/vitest.config.ts qwen-pr-review-workflow qwen-autofix-workflow` | **2 files, 603 passed / 0 failed** (318.8 s) | R01/R02/R03/A01 killed by this same runner |
| workflow size ratchet | `bash .github/scripts/check-workflow-size.sh` in a `main` worktree, both strict and with `WORKFLOW_SIZE_BASE_SHA=main` | **exit 0 both ways**; only warning is `qwen-autofix.yml` at 91% of the 470000-byte gate | — (see Finding 2 for what the baseline numbers actually say) |
| `.size-baseline` accuracy | `wc -c` vs each entry | `qwen-code-pr-review.yml` **265415 = 265415** exact; `qwen-review-runner-schedule.yml` **2518 ≠ 1958**; `ci.yml` **137297 vs 134426**; `qwen-autofix.yml` **467572 vs 469165** (1593 under, inside `SLACK_BYTES=20000`, so no reclamation owed) | — |
| prettier | `--experimental-cli --check` on all 9 changed files | `All matched files use Prettier code style!` | — (round 3 planted three; not re-planted this round) |
| eslint | the two new `.github/scripts` files | clean, exit 0 | **two** plants, both reported with exit 1: `@typescript-eslint/no-unused-vars`, `@typescript-eslint/no-explicit-any`; plants removed, `git status --porcelain` empty |
| shellcheck 0.11.0 | both arms' extracted `run:` block + the schedule workflow's run line | clean on all three | a planted unquoted expansion → **SC2086** |
| actionlint 1.7.12 | repo gate `node scripts/lint.js --actionlint`, then each of the four changed workflows directly with **no config** | gate exit 0; **0 diagnostics** on all four | `inputs.pooI` → reported; `cron: '0 21 * *'` → reported; `github.refd` → reported; `github.event.schedul` → **nothing** (Correction 1) |
| yamllint | — | **not run**: `pip3: Permission denied` | A/A: `node scripts/lint.js --setup` fails the same way for this container regardless of arm; round 3 hit the identical error |
| `HELPER_TESTS*` wiring | grep + path existence | both `ci.yml` lists name `.github/scripts/review-runner-schedule.test.mjs` (2 occurrences); the file exists; dep-free placement **earned** — the script and its test import only `node:` builtins plus one relative file (S12.1) | mutant W04 shows a mismatch is observable |
| checkout pin | SHA vs comment | `df4cb1c0…` `# v6.0.3` | — |
| collateral | `git diff HEAD^1..HEAD` per file | `ci.yml` change confined to the two `HELPER_TESTS*` strings; `qwen-autofix.yml`'s `runs-on` label sets unchanged from base | — |
| merge state | `git fetch origin main` + per-file blob comparison | **merged** as `1fb7595c36`; all 10 files byte-identical to `HEAD^2` | — |

## Corrections

To the *description, comments, and the previous report* — not requests to change behaviour.

1. **A clean actionlint run is not evidence for the `POOL` expression's schedule key.** Re-verified with actionlint 1.7.12: a planted `github.event.schedul` produces **exit 0 and no diagnostic**, while three other plants in the same file are reported. The schedule→pool mapping is instead pinned by Harness C1.2–C1.6, which lifts the expression verbatim and evaluates it under GitHub's operand-returning `&&`/`||` semantics over six trigger shapes, including an unknown cron falling back to `ci`.
2. **`.github/actionlint.yaml` does not enumerate `ecs-review`** (nor `ecs-agent`, which base already used the same way). Inert today because every `runs-on` naming either label is a `fromJSON('[…]')` expression actionlint cannot resolve. It becomes load-bearing the moment anyone writes a literal `runs-on: [self-hosted, ecs-review]`.
3. **Correction to round 3: mutant W05 was *not* killed by the shipped suite, and a naive assertion on `deployment: false` is vacuous.** The workflow carries a prose comment `# deployment: false — the job needs the environment's secret…` as well as the YAML key, so an unanchored `/deployment: false/` matches the comment and survives the key being flipped. Round 3's matrix reports W05 as killed by the suite with no probe; at this head, with the mutation applied to the **key** (occurrence 2 of 2), W05 **survives the suite** and survived my first Harness C for exactly this reason. It is killed only after re-anchoring C2.10 to `^\s+deployment: false\s*$` plus a single-key-line count. Reported because the same trap would hide a real regression, and because round 3's kill attribution for this row does not reproduce.
4. **Correction to round 3's calibration description.** Round 3 states the real PR #9729 comment is reproduced "byte for byte" by both arms. It is — but only with `REVIEW_PR_RESULT=failure`. That run's `review-pr` job concluded **`cancelled`**, and with `cancelled` both arms produce the *cancelled* body, not the real comment. The real comment is the **generic** body, which dates it before base's `cancelled` branch existed. Both facts are now encoded as separate assertions (`B0.cal.failure.vs-real` expects a match, `B0.cal.cancelled.vs-real` expects a mismatch) rather than being resolved by choosing the input that happens to match.
5. **Correction to round 3's headroom arithmetic for the new `.size-baseline` entry.** Round 3 says the file "can grow by 4656 bytes (185% of its current size)". The gate fails when `size > recorded + 4096` = `1958 + 4096` = **6054**; actual is **2518**, so the real headroom is **3536 bytes (140.4%)**. Still far more than the ratchet intends, but the number was overstated by 1120 bytes.
6. **Correction to round 3's `ecs-qwen` job-site count.** Round 3 says "24 job sites across 19 workflows". `grep -cE 'runs-on:.*ecs-qwen'` per file over `.github/workflows/*.yml` on live `main` gives **35 sites across 19 files** (release.yml 8, qwen-triage.yml 5, qwen-autofix.yml 3, qwen-code-pr-review.yml 3, sdk-java.yml 2, and 1 each in 14 others). The file count matches; the site count does not.
7. **The description's "Reviews queue while no `ecs-review` runner is available" is precise, and the realized number is much smaller than round 3's worst case.** Steady state: the pool is open for exactly **720 of 1440 minutes**, half of arrivals wait, mean **6.01 h**, worst **12.00 h**. Day one: worst possible **24.00 h**, **realized 3.00 h** because the merge landed at 06:00:09 UTC and the first tick is 09:00 UTC.
8. **The description says the switch "preserves `ecs-agent`, so autofix remains eligible on both hosts" — verified, and it holds under both readings of the fleet.** C4 (hk1/hk2 carrying no `ecs-agent`, as the description's fleet measurement states) holds autofix flat at 93 across six ticks; C4b (hk1/hk2 carrying `ecs-agent`, as the Risk & Scope instruction implies) holds it flat at 155 across four ticks. Mutant M06 is killed and S2.4 confirms no runner loses it across a full round trip.
9. **`fallback-comment` runs on `ubuntu-latest`**, not on a switched pool — so the queue-expiry comment can always post even while no `ecs-review` runner exists. Worth stating because the comment's whole purpose is to explain that condition.

## Findings

### 1. The queue-expiry body asserts a cause its own predicate cannot establish — now demonstrated on real production traffic, and materially bounded

**Severity: Suggestion** (user-facing accuracy; not a correctness break). **Reproduce:** `node tmp/pr11855-verify-20260915-060814/harness-b-fallback.mjs` — cells `B25 / REAL1..REAL3`; the underlying captures are listed in `logs/calibration-source.txt`.

The branch fires on `runner_name` empty **and** `steps` empty. Round 3 reached that shape only synthetically. This round I found it in real traffic: of eight recently cancelled `qwen-code-pr-review.yml` runs sampled anonymously, **three** have exactly that shape —

| run | PR | `review-pr` cancelled after | % of the 24 h limit |
|---|---|---|---|
| `34931163790` | #11910 | **9.7 min** | 0.67% |
| `34923724949` | #11906 | **36.4 min** | 2.53% |
| `34922815556` | "full /hooks dialog in OpenTUI" | **33.7 min** | 2.34% |

All three are from 2026-09-15, *before* this PR merged, i.e. under the old `ecs-agent` pool — so the shape is not created by this change. On all three, the head arm's first sentence would claim:

> No runner picked up the review job before GitHub ended it **at the 24-hour queue limit**

which is false by a factor of 40–150×. Base's `cancelled` body did not have this problem: it enumerated the causes ("by an operator, an upstream event, or the job exceeding its execution time limit"). So on this shape the new body is *less* accurate than the one it replaces, while being much more accurate on the shape it was written for.

**What this is NOT — and this is the part that bounds it.** All three cancellations were caused by the PR *closing*: PR #11910 merged at `05:17:41Z` and its `review-pr` was cancelled at `05:17:45Z`. The step's `pr_state != OPEN` gate runs **before** body selection, so on the real state (MERGED) **neither arm posts anything** — verified empirically: `GET /repos/QwenLM/qwen-code/issues/11910/comments` returns **zero** comments carrying the `qwen-review-fallback` marker, and all six `REAL*.as-delivered` assertions pass. The dominant real-world producer of this shape is therefore already filtered, and the frequent-predicate frequency (3 of 8) overstates the exposure badly.

The residual reachable case is: `review-pr` never started **and** the PR is still **OPEN** **and** no bot review was submitted after the run was created **and** the head has not moved. Concretely, an operator cancelling a run whose review was still queued, or a command-triggered (`issue_comment`) run cancelled while queued — for those `GITHUB_EVENT_NAME != pull_request_target`, so the head-drift exit does not apply either. That is exactly the case where a maintainer is most likely to read the comment carefully, and it sends them to `qwen-review-runner-schedule.yml`'s latest run for something the schedule had nothing to do with.

Note also the interaction with steady state: with the pool open 12 h a day, a queued review waits at most **12.00 h** (C5.3) and then runs — it does **not** expire. A genuine 24 h expiry needs the pool closed for over a day, i.e. the schedule broken (Finding 6). So the body's claim is true precisely in the failure mode it was written for, and false in the ordinary-cancellation cases that will actually reach it.

**What else this is not.** Not a silence regression: all 8 flipping cells still post, with the dedup marker, the `actions/runs/<id>)` cross-job anchor and the retry instruction intact (Bsum.2–Bsum.5). Not a widening regression: `B3 B4 B5 B6 B7 B7b B10 B11 B12` all keep base's body, Bsum.8 confirms a `success`/`skipped` `review-pr` makes **zero** jobs-API calls, and B20 proves an unreadable listing degrades to `unknown` and keeps base's body. Not a false positive from a skipped `review-pr` (B7b).

<details>
<summary>Minimal suggested direction (not applied, not re-measured)</summary>

One clause, keeping the whole intent: let the first sentence describe the observation and leave the cause to the sentence that already disclaims it — "No runner picked up the review job before GitHub ended it, so no review ran for this head" — and add "or the run was cancelled while it waited" to the existing candidate list ("closed by schedule, empty, or all-offline").

I did not apply and measure this: it edits prose the shipped tests assert on, so the correct patch ships with a fixture pinning the operator-cancel shape — a `cancelled` result on an OPEN PR whose run ended well inside 24 h, asserted to produce a body that does **not** name the queue limit. **No such fixture exists today, and that is the unpinned axis**: the five new tests pin *whether* the branch fires, none pins *which cause it claims*. Harness B's `REAL1..REAL3` counterfactual cells are exactly that fixture and can be lifted into the suite.

</details>

### 2. Two `.size-baseline` numbers are wrong, and they are now on `main`

**Severity: nit for the new entry; substantive for `ci.yml`, and it is main-side work now.** **Reproduce:** in a `main` worktree, `wc -c < .github/workflows/qwen-review-runner-schedule.yml` → **2518** vs `grep qwen-review-runner-schedule .github/workflows/.size-baseline` → **1958**; `wc -c < .github/workflows/ci.yml` → **137297** vs recorded **134426**.

- **`qwen-review-runner-schedule.yml`: recorded 1958, actual 2518, Δ +560.** The gate passes because it only fails past `recorded + 4096` = 6054, but the entry's whole purpose is the ratchet — the script's own header says growth should become "one line a reviewer sees". The file can grow by **3536 bytes (140.4% of its current size)** before the ratchet objects. Round 3 measured this entry byte-exact at 1942, so the drift was introduced inside the PR by the later commits (the `main`-fence comment block and the hk1 change) without re-recording it. Now merged, so it is main's number to fix.
- **`ci.yml`: recorded 134426, actual 137297, Δ +2871 → 1225 bytes of headroom.** The staleness is **main-side and pre-existing** (base was already 2775 over) and this PR contributes only 96 bytes. But the gate's stale-baseline protection fires only when the PR did **not** change the file — so the next PR that touches `ci.yml` and grows it by more than **1225 bytes** hits the hard-fail branch and is told to record 2871 bytes of growth it did not cause. That is precisely the red-wall the script's header cites ("red-walled the queue twice in two weeks: #9747, #9822"). Worth a one-line baseline bump on `main`.
- **`qwen-autofix.yml`: recorded 469165, actual 467572.** The PR *shrank* it by 1593 bytes by deleting the af-099 code. Inside `SLACK_BYTES=20000`, so no reclamation is owed and none is warned — correct by design. The file is at **91%** of the 470000-byte gate either way, which the gate does warn about.
- **`qwen-code-pr-review.yml`: recorded 265415 = actual 265415**, byte-exact. The PR does update the entry for the file it grew.

Measured on a `main` worktree both strictly and with `WORKFLOW_SIZE_BASE_SHA` set to the main tip: **exit 0 both ways**. So nothing is red today; this is headroom being consumed silently.

### 3. Two hosts leave the CI pool for 12 h a day — now quantified, still absent from the description

**Severity: substantive description gap, not a code defect.** **Reproduce:** `node tmp/pr11855-verify-20260915-060814/harness-c-schedule.mjs` (C4.3/C4.4) and `grep -cE 'runs-on:.*ecs-qwen' .github/workflows/*.yml`.

Driven on the fleet shape the description itself measured, the `ecs-qwen`-eligible online count goes **93 → 155** on a `ci` tick and back to **93** inside every review window: the CI pool loses **62 of 155 = 40.0%** of its capacity for half of every day. `ecs-qwen` is not a niche label — it is requested by **35 `runs-on` sites across 19 workflow files** on live `main` (`release.yml` 8, `qwen-triage.yml` 5, `qwen-autofix.yml` 3, `qwen-code-pr-review.yml` 3, `sdk-java.yml` 2, and one each in 14 others), and `qwen-code-pr-review.yml` itself keeps three jobs on it (`ack-review-request`, `review-config`, `authorize`).

This is the stated intent ("daytime CI and evening reviews"), and the description does say both hosts switch. What it does not do is size it: the only numbers offered are the *fixture's* ("32 runners (30 online, two offline)"), which are test data. The review-side capacity question stays unquantified for the same reason it al

...truncated -- full content in the run artifacts.
Flakiness gate log

rounds=5 files=3 skipped=0
file .github/scripts/review-runner-schedule.test.mjs: (cd .) node --test ./.github/scripts/review-runner-schedule.test.mjs
file scripts/tests/qwen-autofix-workflow.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/qwen-autofix-workflow.test.js
file scripts/tests/qwen-pr-review-workflow.test.js: (cd .) npx --no-install vitest run --config ./scripts/tests/vitest.config.ts ./scripts/tests/qwen-pr-review-workflow.test.js


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  .github/scripts/review-runner-schedule.test.mjs: PPPP
  scripts/tests/qwen-autofix-workflow.test.js: PPPP
  scripts/tests/qwen-pr-review-workflow.test.js: PPPP

verdict: timeout
summary: only 4 of 5 rounds fit the 15-minute budget; the completed rounds agreed

--- per-invocation detail (full copy in the artifact) ---
round 1 · .github/scripts/review-runner-schedule.test.mjs: P (exit 0)
round 1 · scripts/tests/qwen-autofix-workflow.test.js: P (exit 0)
round 1 · scripts/tests/qwen-pr-review-workflow.test.js: P (exit 0)
round 2 · .github/scripts/review-runner-schedule.test.mjs: P (exit 0)
round 2 · scripts/tests/qwen-autofix-workflow.test.js: P (exit 0)
round 2 · scripts/tests/qwen-pr-review-workflow.test.js: P (exit 0)
round 3 · .github/scripts/review-runner-schedule.test.mjs: P (exit 0)
round 3 · scripts/tests/qwen-autofix-workflow.test.js: P (exit 0)
round 3 · scripts/tests/qwen-pr-review-workflow.test.js: P (exit 0)
round 4 · .github/scripts/review-runner-schedule.test.mjs: P (exit 0)
round 4 · scripts/tests/qwen-autofix-workflow.test.js: P (exit 0)
round 4 · scripts/tests/qwen-pr-review-workflow.test.js: P (exit 0)

Evidence images

01-ab-fallback-comment-base-vs-head

02-schedule-timeline-and-realized-window

03-live-post-merge-state

04-planner-ab-cells

05-mutation-matrix-31-mutants-plus-probe

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

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

LGTM, looks ready to ship. ✅

Both round-1 blockers are closed, and closed in CI on this head rather than on my say-so: the yamllint failure on the workflow this PR adds, and the untested main() that holds the administration credential. The three non-blocking notes — a stale .size-baseline number for the new workflow, the || 'ci' fallthrough in the POOL expression, and the ecs-agent sentence in the body that overstates what planLabels does — are written up in my staged comments above. Full detail and the CI log evidence are in the stage=2 comment.

Approving pinned to the commit I reviewed.

@yiliang114
yiliang114 added this pull request to the merge queue Sep 15, 2026
Merged via the queue into main with commit 1fb7595 Sep 15, 2026
53 checks passed
@yiliang114

Copy link
Copy Markdown
Collaborator Author

Implemented the agreed label migration in ed6a4bb: both autofix execution jobs now require ecs-autofix, while the daily switch only manages ecs-review and ecs-qwen. Runner names and the existing registration allowlist are unchanged. F1 is also fixed: the never-started notice no longer assumes the run reached the 24-hour queue limit.

Before merge, provision ecs-autofix on online hk1/hk2 runners and in their registration configuration. Do not restore ecs-agent, since it would release old queued reviews during the daytime. After merge, create fresh autofix runs from the new workflow and select the current scheduled pool. These live migration steps have not been executed.

Validation: six scheduling tests, the autofix routing regression, the never-started notice regression, yamllint, actionlint, targeted ESLint and Prettier passed. An additionally selected, unrelated keeps autofix runner failure paths explicit test hit its explicit 10-second timeout locally, including a standalone retry; this is not claimed as passing. Replacement CI is pending.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Merged as 1fb7595c36. Recording the disposition of the sandboxed verification report's three findings so nothing is left implicitly deferred.

Finding 1 — the queue-expiry body named a cause its predicate cannot establish. Fixed in #11921. The report is right and the exposure is narrower than its 3-of-8 sample suggests: all three sampled cancellations were caused by the PR closing, and pr_state != OPEN runs before body selection, so none posted. The reachable residue is an operator cancelling a still-queued review, or a command-triggered run cancelled while waiting — both on an OPEN PR, where the head-drift exit does not apply either. #11921 drops the duration claim, adds cancellation to the candidate list, and pins the axis the five tests here left open (they pinned whether the branch fires, not which cause it claims); restoring the old wording turns the case red.

Finding 2 — two .size-baseline numbers wrong on main. Fixed in #11921: qwen-review-runner-schedule.yml 1958 → 2518 (drifted inside this PR when the main-fence comment landed without re-recording — mine to fix) and ci.yml 134426 → 137297 (main-side and pre-existing, but it left 1225 bytes of headroom, so the next PR touching ci.yml would have been told to account for 2871 bytes of growth it did not cause). qwen-code-pr-review.yml was byte-exact here and is re-recorded there for its own change.

Finding 3 — the evening CI-pool loss was unquantified. Not a code defect and not fixed, because there is nothing to fix in this diff; recording the number so the operational consequence is on the record rather than only in a verification artifact. Driven on the fleet shape this PR measured, ecs-qwen-eligible online capacity goes 93 → 155 on a ci tick and back to 93 inside every review window: the CI pool gives up 62 of 155 hosts, 40.0% of its daytime capacity, for half of every day. ecs-qwen is not a niche label — 35 runs-on sites across 19 workflow files request it, and qwen-code-pr-review.yml itself keeps three jobs on it (ack-review-request, review-config, authorize), so those queue during the review window too. This is the stated intent, but the description only ever quoted the fixture's numbers (32 runners), which are test data.

Still open and deliberately not attempted here: the report's observation that a genuine 24 h expiry implies the schedule itself is broken, and nothing in the repo watches for that — qwen-fleet-shepherd.yml does not mention review-pr. That is a watchdog, not a wording fix, and it needs its own design.


已合并为 1fb7595c36。把沙箱验证报告三条 finding 的处置记录下来,避免有任何一条被隐式搁置。

Finding 1 —— 排队过期文案断言了其谓词无法确定的原因。 已在 #11921 修复。报告是对的,但暴露面比它 3/8 的抽样看起来小:三个样本都是因为 PR 关闭而被取消,而 pr_state != OPEN 在选择 body 之前执行,所以一个都没发出去。可达的残留是运维取消仍在排队的 review,或命令触发的 run 在等待中被取消——两者都在 OPEN 的 PR 上,head 漂移那条退出也不适用。#11921 去掉时长断言、把「取消」加进候选原因列表,并钉住这里 5 个测试留下的那条轴(它们钉的是分支是否触发,不是声称哪个原因);把旧措辞改回去用例会变红。

Finding 2 —— main 上两个 .size-baseline 数字错误。 已在 #11921 修复:qwen-review-runner-schedule.yml 1958 → 2518(是在本 PR 内部漂移的——加 main 围栏注释时没有重记,该由我修),ci.yml 134426 → 137297(main 侧既有欠债,但只剩 1225 字节余量,下一个动 ci.yml 的 PR 会被迫为不是它造成的 2871 字节增长解释)。qwen-code-pr-review.yml 在本 PR 里是逐字节准确的,在 #11921 中因自身改动重记。

Finding 3 —— 晚间 CI 池损失未被量化。 不是代码缺陷、也不修,因为这个 diff 里没有可修的东西;把数字记录下来,让运维后果留在记录里而不只存在于验证产物中。按本 PR 实测的机群形态驱动,ecs-qwen 可用在线容量在 ci tick 时从 93 升到 155,在每个 review 窗口内回落到 93:CI 池每天有一半时间让出 155 台中的 62 台,即白天容量的 40.0%。 ecs-qwen 不是小众标签——19 个 workflow 文件的 35 个 runs-on 处请求它,而 qwen-code-pr-review.yml 自己就有三个 job 留在上面(ack-review-requestreview-configauthorize),所以它们在 review 窗口内也会排队。这是既定意图,但描述里从头到尾只引用了测试数据的数字(32 台 runner)。

仍然开放、且有意不在这里尝试:报告指出真正的 24 小时过期意味着调度本身已坏,而仓库里没有任何东西在监视这件事——qwen-fleet-shepherd.yml 完全不提 review-pr。那是一个看门狗,不是措辞修复,需要自己的设计。

pull Bot pushed a commit to mcx/qwen-code that referenced this pull request Sep 15, 2026
…schedule) (QwenLM#11921)

* fix(ci): stop the queue-expiry body naming a cause it cannot observe

Follow-up to QwenLM#11855, from its sandboxed verification report.

The never-started branch fires on `runner_name` empty AND `steps` empty,
which is a shape, not a cause. Verification sampled eight recently
cancelled qwen-code-pr-review.yml runs and found three with exactly that
shape, cancelled after 9.7, 36.4 and 33.7 minutes — so the body's claim
that GitHub ended the job "at the 24-hour queue limit" was false by a
factor of 40-150x. All three were cancellations caused by the PR closing,
and the step's `pr_state != OPEN` gate runs before body selection, so
none of them posted; the residual reachable case is an operator
cancelling a review still queued, or a command-triggered run cancelled
while it waited, both on an OPEN PR where the head-drift exit does not
apply either. That reader is told a duration that did not elapse and is
sent to qwen-review-runner-schedule.yml for something the schedule did
not do.

The first sentence now states only the observation, and cancellation
joins the candidate list the body already disclaims with. Under the
steady-state schedule a queued review waits at most 12 h and then runs,
so a genuine cap expiry means the schedule itself is broken — the claim
was true only in the failure mode it was written for.

The five tests added in QwenLM#11855 pin WHETHER the branch fires; none pinned
WHICH cause it claims. The queue-expiry case now asserts the body names
no duration and does offer cancellation, for both `failure` and
`cancelled` results. Restoring "at the 24-hour queue limit" turns it red.

Also records two .size-baseline numbers that were wrong on main:
qwen-review-runner-schedule.yml 1958 -> 2518 (the ratchet's own entry,
drifted inside QwenLM#11855 when the main-fence comment landed without
re-recording; the file could have grown 140% before the gate objected)
and ci.yml 134426 -> 137297 (main-side and pre-existing, but it left
only 1225 bytes of headroom, so the next PR touching ci.yml would be
told to account for 2871 bytes of growth it did not cause — the red-wall
class check-workflow-size.sh exists to prevent). qwen-code-pr-review.yml
is re-recorded for this change.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* ci: reduce to the two stale .size-baseline integers

main's QwenLM#11912 (809aaa5) landed the same cause-neutral rewrite of the
never_started fallback body 23 seconds before this PR was opened, so the
wording edit and its extra assertion are redundant churn now. Both files
are back to main's bytes; what remains is the part that is still true and
still unlanded — two baseline entries whose recorded sizes drifted on main.

ci.yml recorded 134426 against 137297 real bytes: 2871 of the 4096 growth
allowance already spent, 1225 left before an unrelated PR trips the ratchet
on a file it never touched. qwen-review-runner-schedule.yml recorded 1958
against 2518, leaving 3536. Both re-measured with wc -c against this merged
tree rather than carried over from the earlier draft. qwen-code-pr-review.yml
is left at main's 265415 (real 265273, 142 under, inside SLACK_BYTES) since
nothing in this PR changes that file any more.

check-workflow-size.sh rc=0; workflow-size.test.js 214 passed.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): preserve extension skill identity and enablement

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
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.

4 participants