diff --git a/.changeset/ci-merge-queue-throughput.md b/.changeset/ci-merge-queue-throughput.md
new file mode 100644
index 0000000000..ca42c03bfc
--- /dev/null
+++ b/.changeset/ci-merge-queue-throughput.md
@@ -0,0 +1,4 @@
+---
+---
+
+CI-only (#4859): Test Core / Dogfood 各 2→3 分片 + merge-queue 失败自动分诊评论 workflow。仅 `.github/workflows/**`,不发布任何包。
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b6c1b40532..63080805ae 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -75,11 +75,13 @@ jobs:
- '.github/workflows/ci.yml'
test:
- # Sharded 2-way BY PACKAGE: a core-touching PR ran the affected suite
- # ~11½ min on one 4-vCPU runner — the longest pole in the whole workflow.
- # scripts/partition-test-shards.mjs splits the package list into two
- # deterministic, test-file-count-balanced halves (573/572 at the time of
- # writing) and each shard runs its half through turbo. NOT the dogfood
+ # Sharded 3-way BY PACKAGE: a core-touching PR ran the affected suite
+ # ~11½ min on one 4-vCPU runner — the longest pole in the whole workflow —
+ # and at 2 shards the slower shard still ran ~10 min, keeping merge-queue
+ # builds at ~11 min end-to-end (#4859).
+ # scripts/partition-test-shards.mjs splits the package list into three
+ # deterministic, test-file-count-balanced thirds and each shard runs its
+ # slice through turbo. NOT the dogfood
# job's vitest --shard passthrough, deliberately: that works for dogfood
# because it is ONE package with ~60 files, but applied workspace-wide,
# vitest 4 hard-fails every package with fewer test files than the shard
@@ -90,13 +92,13 @@ jobs:
# Branch protection requires the bare "Test Core" context, which a matrix
# can never publish again — the test-gate job below carries that name
# (the #3622 lesson; see dogfood-gate).
- name: Test Core (${{ matrix.shard }}/2)
+ name: Test Core (${{ matrix.shard }}/3)
needs: filter
if: needs.filter.outputs.core == 'true'
runs-on: ubuntu-latest
# Backstop only — the stall guard on the test steps is the primary
# detector for a #4250-style hang and fires well before this. 30 min is
- # ~4× a normal sharded run (~6-7 min), with margin for a cold Turbo cache;
+ # ~5× a normal sharded run (~4-6 min), with margin for a cold Turbo cache;
# the old 45 left a hung job "running" for half an hour past any plausible
# healthy finish.
timeout-minutes: 30
@@ -105,7 +107,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- shard: [1, 2]
+ shard: [1, 2, 3]
steps:
- name: Checkout repository
@@ -190,7 +192,7 @@ jobs:
pnpm exec turbo ls --output=json > "$RUNNER_TEMP/turbo-ls.json"
fi
node scripts/partition-test-shards.mjs "$RUNNER_TEMP/turbo-ls.json" \
- --shard ${{ matrix.shard }}/2 --exclude @objectstack/dogfood \
+ --shard ${{ matrix.shard }}/3 --exclude @objectstack/dogfood \
> "$RUNNER_TEMP/shard-packages.txt"
echo "Packages on this shard:"
cat "$RUNNER_TEMP/shard-packages.txt"
@@ -499,18 +501,19 @@ jobs:
test
dogfood:
- # Sharded 2-way: the suite is ~60 independent test files, each booting its
- # own in-process app, and a single 4-vCPU runner needed ~7½ minutes for the
- # lot — the longest pole in the whole workflow. vitest partitions the file
- # list deterministically across shards; both shards must pass. If branch
- # protection lists "Dogfood Regression Gate" as a required check, it must be
- # updated to the two sharded check names.
- name: Dogfood Regression Gate (${{ matrix.shard }}/2)
+ # Sharded 3-way: the suite is ~60 independent test files, each booting its
+ # own in-process app; a single 4-vCPU runner needed ~7½ minutes for the
+ # lot, and at 2 shards each half still ran ~7 min — the longest pole left
+ # once Test Core went 3-way (#4859). vitest partitions the file list
+ # deterministically across shards; all shards must pass. Branch protection
+ # requires only the bare "Dogfood Regression Gate" context, carried by the
+ # dogfood-gate job below — the shard count can change without touching it.
+ name: Dogfood Regression Gate (${{ matrix.shard }}/3)
needs: filter
if: needs.filter.outputs.core == 'true'
runs-on: ubuntu-latest
# Backstop only — the stall guard on the test step is the primary detector
- # for a #4250-style hang (see Test Core). 30 min is ~4× a shard (~7 min;
+ # for a #4250-style hang (see Test Core). 30 min is ~6× a shard (~5 min;
# the verify-CLI pass that used to ride shard 1 is its own parallel job
# now — dogfood-verify below).
timeout-minutes: 30
@@ -519,7 +522,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- shard: [1, 2]
+ shard: [1, 2, 3]
steps:
- name: Checkout repository
@@ -552,7 +555,10 @@ jobs:
# Shard-scoped key: the turbo test hash differs per shard (pass-through
# args are part of the task hash). Restore-only on PRs — see the Restore
# Turbo cache comment in the test job; the save step at the end of this
- # job seeds from main only.
+ # job seeds from main only. The job-level catch-all (same as Test Core's)
+ # is what keeps a NEW shard number warm before main has ever saved it:
+ # turbo's cache is content-addressed per task, so another shard's entries
+ # replay the shared build closure even when the test slice differs.
- name: Restore Turbo cache
uses: actions/cache/restore@v6
with:
@@ -561,6 +567,7 @@ jobs:
restore-keys: |
${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-${{ github.ref_name }}-
${{ runner.os }}-turbo-${{ github.job }}-${{ matrix.shard }}-
+ ${{ runner.os }}-turbo-${{ github.job }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -585,7 +592,7 @@ jobs:
mkdir -p "$RUNNER_TEMP/stall-reports"
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/dogfood.log" --stall-minutes 10 \
--report-dir "$RUNNER_TEMP/stall-reports" -- \
- pnpm turbo run test --filter=@objectstack/dogfood -- --shard=${{ matrix.shard }}/2
+ pnpm turbo run test --filter=@objectstack/dogfood -- --shard=${{ matrix.shard }}/3
# Dogfood boots real apps in-process, so a native/OOM abort is likelier
# here than in the unit suites — and a shard that dies silently looks like
diff --git a/.github/workflows/merge-queue-triage.yml b/.github/workflows/merge-queue-triage.yml
new file mode 100644
index 0000000000..1981f46dae
--- /dev/null
+++ b/.github/workflows/merge-queue-triage.yml
@@ -0,0 +1,163 @@
+name: Merge Queue Triage
+
+# Why this exists (#4859): red queue builds were being blind-requeued. On
+# 2026-08-03 one PR failed the queue four times and landed unchanged on the
+# fifth attempt (09:19 → 10:22), and every failure evicted and rebuilt every
+# entry queued behind it — the queue's perceived slowness that morning was
+# mostly this amplification, not build duration.
+#
+# A queue failure is a different animal from a PR failure: the PR's own CI ran
+# affected-only, while the queue runs the FULL suite on the speculative merge
+# result. The failing test is therefore often in a package the PR never
+# touched — a flake, or a semantic conflict with another queued PR — and
+# neither of those is fixed by re-queueing; re-queueing just burns another
+# ~10-minute build for every entry behind it.
+#
+# So: every red merge_group CI run gets a triage comment on its PR — the
+# failed jobs/steps, the failing test lines pulled from the logs (best
+# effort), how many times THIS PR has already failed in the queue in the last
+# 24 h, and the queue-wide failure count. The checklist tells the author
+# (human or agent) to diagnose before re-queueing. The comment is the
+# machine-readable signal the PM dispatch loop can key on, and the cross-PR
+# flake evidence lives in these comments: the same test name appearing in two
+# unrelated PRs' triage comments is a confirmed flake.
+#
+# Fires on conclusion == failure ONLY. 'cancelled' is the queue evicting an
+# entry because something AHEAD of it failed (or a manual cancel) — it says
+# nothing about this PR, so it gets no comment (the same run-lifecycle
+# reasoning as dogfood-gate's cancelled handling in ci.yml, from the other
+# side).
+#
+# workflow_run executes in the DEFAULT branch's context: this file must be on
+# main before it fires, it never checks out or runs PR code, and it holds the
+# minimum permissions (actions: read for logs, pull-requests: write for the
+# comment).
+
+on:
+ workflow_run:
+ workflows: [CI]
+ types: [completed]
+
+permissions: {}
+
+jobs:
+ triage:
+ name: Comment queue-failure triage on the PR
+ if: >-
+ github.event.workflow_run.event == 'merge_group' &&
+ github.event.workflow_run.conclusion == 'failure'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ actions: read
+ pull-requests: write
+ steps:
+ - name: Post the triage comment
+ uses: actions/github-script@v9
+ with:
+ script: |
+ const run = context.payload.workflow_run;
+ const { owner, repo } = context.repo;
+
+ // Queue branches are named gh-readonly-queue//pr--.
+ const m = /^gh-readonly-queue\/.+\/pr-(\d+)-[0-9a-f]{40}$/.exec(run.head_branch ?? '');
+ if (!m) {
+ core.info(`head_branch '${run.head_branch}' is not a merge-queue branch — nothing to do.`);
+ return;
+ }
+ const prNumber = Number(m[1]);
+ const marker = ``;
+
+ // Idempotency: workflow_run deliveries can repeat; one comment per run.
+ const existing = await github.rest.issues.listComments({
+ owner, repo, issue_number: prNumber, per_page: 100,
+ });
+ if (existing.data.some((c) => (c.body ?? '').includes(marker))) {
+ core.info('triage comment for this run already exists — skipping.');
+ return;
+ }
+
+ // Failed jobs and their failed steps.
+ const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
+ owner, repo, run_id: run.id, per_page: 100,
+ });
+ const failedJobs = jobs.filter((j) => j.conclusion === 'failure');
+
+ // Best-effort log harvest: the lines a human would grep for first.
+ // Aggregate gate jobs (Test Core / Dogfood Regression Gate) fail
+ // with no information of their own, so prefer real jobs when both
+ // are present. A 4xx on the logs endpoint degrades to names only.
+ const FAIL_LINE = /(?:^|[\s|])(?:✗|×|✕|FAIL\s|AssertionError|STALL)/;
+ const informative = failedJobs.filter((j) => (j.steps ?? []).some(
+ (s) => s.conclusion === 'failure' && !/^Verify .* results$/.test(s.name)));
+ const details = [];
+ for (const job of (informative.length ? informative : failedJobs).slice(0, 4)) {
+ const steps = (job.steps ?? [])
+ .filter((s) => s.conclusion === 'failure')
+ .map((s) => s.name);
+ let lines = [];
+ try {
+ const res = await github.request(
+ 'GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs',
+ { owner, repo, job_id: job.id });
+ const text = typeof res.data === 'string'
+ ? res.data
+ : Buffer.from(res.data).toString('utf8');
+ lines = text.split('\n')
+ .map((l) => l.replace(/^[^ ]*Z /, ''))
+ .filter((l) => FAIL_LINE.test(l))
+ .map((l) => l.trim().slice(0, 200))
+ .slice(0, 12);
+ } catch (e) {
+ core.info(`logs unavailable for job ${job.id}: ${e.message}`);
+ }
+ details.push({ name: job.name, url: job.html_url, steps, lines });
+ }
+
+ // History: this PR's earlier queue failures + queue-wide count, 24 h.
+ const since = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
+ const recent = await github.paginate(github.rest.actions.listWorkflowRunsForRepo, {
+ owner, repo, event: 'merge_group', created: `>=${since}`, per_page: 100,
+ });
+ const ciRuns = recent.filter((r) => r.workflow_id === run.workflow_id && r.id !== run.id);
+ const priorFailuresThisPr = ciRuns.filter((r) =>
+ r.conclusion === 'failure' && (r.head_branch ?? '').includes(`/pr-${prNumber}-`)).length;
+ const queueFailures24h = ciRuns.filter((r) => r.conclusion === 'failure').length;
+
+ const jobSections = details.map((d) => {
+ const head = `- **[${d.name}](${d.url})** — 失败步骤: ${d.steps.join('、') || '(无步骤级结论)'}`;
+ return d.lines.length
+ ? `${head}\n\n \`\`\`\n ${d.lines.join('\n ')}\n \`\`\``
+ : `${head}(日志不可读,点进 job 看)`;
+ }).join('\n');
+
+ const flakeHint = priorFailuresThisPr > 0
+ ? `⚠️ **本 PR 过去 24h 已在队列失败 ${priorFailuresThisPr} 次(不含本次)。** 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。`
+ : '本 PR 过去 24h 无队列失败记录(首次)。';
+
+ const body = [
+ '### ⛔ merge queue 构建失败 — 先分诊,再决定要不要重排',
+ '',
+ `队列构建 [${run.id}](${run.html_url}) 红了。队列跑的是**全量**套件(PR 侧 CI 只跑 affected 子集),`,
+ '所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。',
+ '',
+ '**失败的 job(日志抽取,best effort):**',
+ '',
+ jobSections || '- (没拿到 job 级信息,点上面的 run 链接看)',
+ '',
+ '**历史信号:**',
+ `- ${flakeHint}`,
+ `- 过去 24h 队列共有 ${queueFailures24h} 个失败构建(不含本次)。`,
+ '',
+ '**分诊清单:**',
+ '1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。',
+ '2. 失败测试与本 PR 无关 → 在其他 PR 的同类评论里搜同名测试;出现过 ⇒ flaky 实锤,开 issue 修/隔离那条测试。修好前重排只会再烧一轮全队列。',
+ '3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。',
+ '',
+ marker,
+ '---',
+ '_Generated by [Claude Code](https://claude.ai/code) · merge-queue-triage workflow (#4859)_',
+ ].join('\n');
+
+ await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
+ core.info(`triage comment posted on #${prNumber}.`);