diff --git a/.agents/notes/implemented/process/2026-08-19-fork-ci-parallel-lanes.i18n.yaml b/.agents/notes/implemented/process/2026-08-19-fork-ci-parallel-lanes.i18n.yaml index f179630c9c..a74b98ad70 100644 --- a/.agents/notes/implemented/process/2026-08-19-fork-ci-parallel-lanes.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-19-fork-ci-parallel-lanes.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-19-fork-ci-parallel-lanes.md -2026-08-19-fork-ci-parallel-lanes.md: 86a90db76a95e0e881581402dfd2ddafc6726dbb -2026-08-19-fork-ci-parallel-lanes.zh.md: 7e034e9f334f07071db81d686e12ab93dfff5d02 +2026-08-19-fork-ci-parallel-lanes.md: 842436f3fabde9114168d97209da8a3d67f65149 +2026-08-19-fork-ci-parallel-lanes.zh.md: 8a262a94dfef0a232109d482868b61056a076957 diff --git a/.agents/notes/implemented/process/2026-08-19-fork-ci-parallel-lanes.md b/.agents/notes/implemented/process/2026-08-19-fork-ci-parallel-lanes.md index 86a90db76a..842436f3fa 100644 --- a/.agents/notes/implemented/process/2026-08-19-fork-ci-parallel-lanes.md +++ b/.agents/notes/implemented/process/2026-08-19-fork-ci-parallel-lanes.md @@ -13,7 +13,7 @@ English | [中文](2026-08-19-fork-ci-parallel-lanes.zh.md) Fork CI is now four parallel, keyless jobs behind one stable verdict, all on GitHub-hosted runners: - `static` (one host build feeding lint and typecheck host+client, the doc-sync aggregate, the shared static gates — constraints, package invariants, Cordis config, runtime closure, optional-dependency imports, issue policy — the module graph check, and the desktop runtime closure), `unit` (the complete vitest inventory, including apps/desktop and apps/web specs, pinned to two forked workers so the timing-sensitive terminal/subprocess suites keep headroom on the 4-vCPU runner), `web` (built frontend + `DSH_SNAPSHOT=replay` browser replay), `coverage` (`check:ci:coverage`, per-file 100% on `packages/*/*/src`). -- `all-checks-passed` aggregates the three blocking lanes with `if: always()` so a failed dependency can never skip the required check into a green; branch protection requires only `Fork CI / all checks passed`. +- `all-checks-passed` aggregates the four blocking lanes with `if: always()` so a failed dependency can never skip the required check into a green; branch protection requires only `Fork CI / all checks passed`. - Pull requests trigger the workflow; `cancel-in-progress` exempts only push (`${{ github.event_name != 'push' }}`), because a master push is the post-merge signal and the cache producer, while superseded pull-request and dispatch runs are disposable. - Caches flow master → every lane: the `unit` lane alone saves the pnpm store on master pushes (five parallel saves of one key would race and waste compression), and the `web` lane alone saves the Playwright browser cache; every lane restores both families on every event. - `coverage` runs with `DSH_COVERAGE_MAX_WORKERS=3` (two instrumented workers plus one exempt heavy-suite worker) and `DSH_GATE_CONCURRENCY=2`, so the two coverage gates overlap at 2 + 1 = 3 forks instead of serializing — sized for the 4-vCPU hosted runner. The first Ubuntu run of this lane reproduced a process-exit race: the scenario host crashed on a partial tree.json read and never published its ready file. The host fixture now retries the read+parse, the scenario reads `DSH_COVERAGE_TEST_TIMEOUT_MS` (set to 60000 on this lane) to widen its ready wait, and a ready-timeout failure surfaces the host's exit and stderr. @@ -22,9 +22,9 @@ Fork CI is now four parallel, keyless jobs behind one stable verdict, all on Git - The `static` lane sets `DSH_ARCHIVE_BASE_REF` to the PR base only on the pull-request-gated `doc-sync` step: an empty string would be read as a literal ref instead of the script's HEAD default. - `knip` and `duplication` stay out of the lane until the fork's pre-existing debt is fixed: knip fails on an unused desktop file and dependencies plus 108 unlisted test imports, and jscpd on 14 plugin-installer clones. `check:ci:static` embeds knip, so the lane runs its green subset as explicit steps instead of the aggregate; fixing the debt means re-adopting `check:ci:static` plus one duplication step. -The `web` lane is diagnostic at first: it is deliberately absent from `all-checks-passed.needs`, and its job name carries a `(diagnostic)` suffix so its non-blocking status stays visible in the pull-request check list. Local runs cannot exercise the assembled app's confined bash tool (the host sandbox denies `posix_openpt` and nested `sandbox-exec`, cascading failures through the aria goldens), so only an Ubuntu run can prove the fork's web goldens current. `web` stays diagnostic until an Ubuntu run proves them current; promoting it is a one-line change to `needs`, and golden drift is first refreshed on CI with `DSH_SNAPSHOT=refresh`. +The `web` lane started diagnostic (absent from `all-checks-passed.needs`, `(diagnostic)` name suffix) because local runs cannot exercise the assembled app's confined bash tool (the host sandbox denies `posix_openpt` and nested `sandbox-exec`, cascading failures through the aria goldens), so only an Ubuntu run could prove the fork's web goldens current. Its first Ubuntu run failed on exactly one stale golden — the retry row now reports attempt 1 of the fork's five transient retries — which was refreshed, and the next run went green; `web` then joined `all-checks-passed.needs` under its plain name. -The upstream workflows stay verbatim (`disabled_manually` in settings) rather than carrying fork guard patches: `scripts/ci-workflow.spec.ts` pins their exact `if` strings, and settings-level disabling keeps them conflict-free across upstream syncs. The fork-owned executed gate `scripts/fork-ci-workflow.spec.ts` pins the new contract: triggers, keyless-ness, hosted runners, cache direction, aggregator membership (including the diagnostic-web exclusion), and the continued absence of snapshot replay and real-API e2e. +The upstream workflows stay verbatim (`disabled_manually` in settings) rather than carrying fork guard patches: `scripts/ci-workflow.spec.ts` pins their exact `if` strings, and settings-level disabling keeps them conflict-free across upstream syncs. The fork-owned executed gate `scripts/fork-ci-workflow.spec.ts` pins the new contract: triggers, keyless-ness, hosted runners, cache direction, aggregator membership, and the continued absence of snapshot replay and real-API e2e. ## Alternatives considered @@ -32,7 +32,7 @@ The upstream workflows stay verbatim (`disabled_manually` in settings) rather th **Snapshot replays re-enabled now** — re-run `test:snapshot` on every pull request. Rejected: the goldens were dropped in dd602d3668 while they drifted, and re-owning them needs a fork-side refresh verified on CI; that stays a separate step before the lane returns. -**Making `web` blocking immediately** — rejected: a red lane from unverified goldens would block every merge during the refresh window; the diagnostic start is one line to promote once green. +**Making `web` blocking immediately** — rejected at first: a red lane from unverified goldens would block every merge during the refresh window, so the lane started diagnostic and promoted once its Ubuntu run went green. ## Consequences diff --git a/.agents/notes/implemented/process/2026-08-19-fork-ci-parallel-lanes.zh.md b/.agents/notes/implemented/process/2026-08-19-fork-ci-parallel-lanes.zh.md index 7e034e9f33..8a262a94df 100644 --- a/.agents/notes/implemented/process/2026-08-19-fork-ci-parallel-lanes.zh.md +++ b/.agents/notes/implemented/process/2026-08-19-fork-ci-parallel-lanes.zh.md @@ -13,7 +13,7 @@ Status: implemented Fork CI 现在是四个并行、无密钥的 job,汇聚到一个稳定判定,全部跑在 GitHub 托管 runner 上: - `static`(一次 host 构建喂给 lint 与 host+client typecheck、doc-sync 聚合、共享静态门禁——constraints、package invariants、Cordis config、runtime closure、optional-dependency imports、issue policy——模块图检查与桌面运行时闭包)、`unit`(完整 vitest 清单,含 apps/desktop 与 apps/web 的 spec,固定为两个 fork worker,让时序敏感的终端/子进程套件在 4-vCPU runner 上保有裕量)、`web`(构建产物 + `DSH_SNAPSHOT=replay` 浏览器回放)、`coverage`(`check:ci:coverage`,`packages/*/*/src` 逐文件 100%)。 -- `all-checks-passed` 以 `if: always()` 聚合三个阻塞车道,失败依赖永远不可能把必查项跳过成绿色;branch protection 只需勾选 `Fork CI / all checks passed`。 +- `all-checks-passed` 以 `if: always()` 聚合四个阻塞车道,失败依赖永远不可能把必查项跳过成绿色;branch protection 只需勾选 `Fork CI / all checks passed`。 - pull request 触发工作流;`cancel-in-progress` 只豁免 push(`${{ github.event_name != 'push' }}`),因为 master push 既是合并后信号也是缓存生产者,而被取代的 PR 与手动运行可丢弃。 - 缓存由 master 流向所有车道:只有 `unit` 车道在 master push 保存 pnpm store(五个并行写同一 key 会竞争并浪费压缩),只有 `web` 车道保存 Playwright 浏览器缓存;每个车道在每个事件上都恢复这两个缓存族。 - `coverage` 以 `DSH_COVERAGE_MAX_WORKERS=3`(两个插桩 worker 加一个豁免重套件 worker)与 `DSH_GATE_CONCURRENCY=2` 运行,两个 coverage gate 以 2 + 1 = 3 个 fork 重叠而不是串行——按 4-vCPU 托管 runner 定尺寸。本车道的首次 Ubuntu 运行复现了 process-exit 竞态:场景宿主进程因读到写了一半的 tree.json 而崩溃,永远没有发布 ready 文件。宿主夹具现在对读取+解析做重试,场景读取 `DSH_COVERAGE_TEST_TIMEOUT_MS`(本车道设为 60000)放宽 ready 等待,且 ready 超时失败会带出宿主的退出码与 stderr。 @@ -22,9 +22,9 @@ Fork CI 现在是四个并行、无密钥的 job,汇聚到一个稳定判定 - `static` 车道只在 PR 专属的 `doc-sync` 步骤上把 `DSH_ARCHIVE_BASE_REF` 设为 PR base:空字符串会被脚本当作字面 ref 而非 HEAD 默认值。 - `knip` 与 `duplication` 在 fork 既有债务修复前不进车道:knip 因一个未使用的桌面文件与依赖、108 条未列明的测试导入而失败,jscpd 因 14 处 plugin-installer clone 而失败。`check:ci:static` 内嵌 knip,因此车道以显式步骤运行其绿色子集而非聚合;债务修复后应重新采用 `check:ci:static` 加一个 duplication 步骤。 -`web` 车道起初是诊断性的:刻意不进入 `all-checks-passed.needs`,且 job 名带 `(diagnostic)` 后缀,让非阻塞状态在 pull request 检查列表中保持可见。本地无法执行组装应用的受限 bash 工具(宿主沙箱拒绝 `posix_openpt` 与嵌套 `sandbox-exec`,失败沿 aria golden 级联),只有 Ubuntu 上的运行才能证明 fork 的 web golden 是否最新。`web` 保持诊断性,直到 Ubuntu 运行证明 golden 当前为止;将其提升只需改动 `needs` 一行,golden 漂移则先在 CI 上用 `DSH_SNAPSHOT=refresh` 刷新。 +`web` 车道起初是诊断性的(不进入 `all-checks-passed.needs`、job 名带 `(diagnostic)` 后缀),因为本地无法执行组装应用的受限 bash 工具(宿主沙箱拒绝 `posix_openpt` 与嵌套 `sandbox-exec`,失败沿 aria golden 级联),只有 Ubuntu 上的运行才能证明 fork 的 web golden 是否最新。其首次 Ubuntu 运行恰好挂在一个过期 golden 上——重试行现在报告 fork 五次瞬态重试的第 1 次——刷新后下一轮变绿;`web` 随后以普通名称加入 `all-checks-passed.needs`。 -上游工作流保持原样(设置里 `disabled_manually`),而不是打 fork 守卫补丁:`scripts/ci-workflow.spec.ts` 用精确字符串钉死了它们的 `if`,设置级禁用让上游同步零冲突。fork 专属的执行门禁 `scripts/fork-ci-workflow.spec.ts` 钉住新契约:触发条件、无密钥、托管 runner、缓存方向、聚合器成员(含诊断性 web 排除),以及快照回放与真实 API e2e 的持续缺席。 +上游工作流保持原样(设置里 `disabled_manually`),而不是打 fork 守卫补丁:`scripts/ci-workflow.spec.ts` 用精确字符串钉死了它们的 `if`,设置级禁用让上游同步零冲突。fork 专属的执行门禁 `scripts/fork-ci-workflow.spec.ts` 钉住新契约:触发条件、无密钥、托管 runner、缓存方向、聚合器成员,以及快照回放与真实 API e2e 的持续缺席。 ## Alternatives considered @@ -32,7 +32,7 @@ Fork CI 现在是四个并行、无密钥的 job,汇聚到一个稳定判定 **现在就恢复快照回放**——每个 PR 重跑 `test:snapshot`。否决:golden 在 dd602d3668 中因漂移被移除,重新认领需要先在 CI 上验证 fork 侧刷新;这仍是车道回归前的独立一步。 -**立即把 `web` 设为阻塞**——否决:未经验证的 golden 造成的红灯会在刷新窗口期内阻塞所有合并;诊断性起步只需在变绿后改一行即可晋升。 +**立即把 `web` 设为阻塞**——起初否决:未经验证的 golden 造成的红灯会在刷新窗口期内阻塞所有合并,因此车道先以诊断性起步,待 Ubuntu 运行变绿后再晋升。 ## Consequences diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml index 4902203713..9e3a882b06 100644 --- a/.github/workflows/fork-ci.yml +++ b/.github/workflows/fork-ci.yml @@ -17,10 +17,9 @@ name: Fork CI # - unit: the complete keyless vitest unit inventory (thread-safe and # process-bound projects; includes apps/desktop and apps/web specs) # - web: built web frontend + keyless browser replay -# (DSH_SNAPSHOT=replay over apps/web/tests). DIAGNOSTIC lane for -# now: local runs cannot exercise the confined bash tool, so the -# fork verifies this lane green on Ubuntu before it joins -# all-checks-passed.needs (then promote it there). +# (DSH_SNAPSHOT=replay over apps/web/tests). Promoted into the +# verdict after its first Ubuntu run proved the fork's goldens +# current (one stale retry golden, refreshed in df281d24e2). # - coverage: per-file 100% coverage gate on packages/*/*/src # - all-checks-passed: single stable required check for branch protection # @@ -218,11 +217,7 @@ jobs: run: pnpm exec vitest run --maxWorkers=2 web: - # Diagnostic lane (see the header job map): the name suffix keeps its - # non-blocking status visible in the pull-request check list. Absent from - # all-checks-passed.needs until its first Ubuntu run proves the fork's web - # goldens current. A red lane here never blocks a pull request. - name: web browser replay (diagnostic) + name: web browser replay runs-on: ubuntu-latest timeout-minutes: 90 steps: @@ -356,16 +351,14 @@ jobs: # Single stable required check for branch protection: require "Fork CI / # all checks passed" instead of enumerating jobs whose names evolve as lanes - # change. Every blocking job above must stay listed in `needs`; `web` is - # deliberately absent while it is the diagnostic lane above (rejoin it once - # the fork's web replay is proven green on Ubuntu). + # change. Every blocking job above must stay listed in `needs`. # `if: always()` is load-bearing: without it a failed dependency would SKIP # this job, and GitHub counts a skipped required check as passing — so this # job always runs on pull requests and fails on any non-success result, # including 'cancelled' and 'skipped'. all-checks-passed: name: all checks passed - needs: [static, unit, coverage] + needs: [static, unit, web, coverage] if: always() && github.event_name == 'pull_request' runs-on: ubuntu-latest steps: diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 467da8088a..368ed6053c 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -6,10 +6,16 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx' import type { TrajectoryTurnModel } from '../src/client/layout.ts' -afterEach(() => { +afterEach(async () => { cleanup() vi.restoreAllMocks() Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo') + // The virtualizer's scrollend fallback debounces notify by + // isScrollingResetDelay on the jsdom window; a scroll in the last test can + // leave that timer pending past the environment teardown, where its + // callback reads a destroyed `window` (ReferenceError) and fails the whole + // run. Let it fire while jsdom is still alive. + await new Promise(resolve => setTimeout(resolve, 200)) }) const TURNS: readonly TrajectoryTurnModel[] = [{ diff --git a/scripts/fork-ci-workflow.spec.ts b/scripts/fork-ci-workflow.spec.ts index 5194aef531..465f4c87d5 100644 --- a/scripts/fork-ci-workflow.spec.ts +++ b/scripts/fork-ci-workflow.spec.ts @@ -11,10 +11,10 @@ import { describe, expect, it } from 'vitest' // one stable verdict, and single-producer caches restored by every lane. const root = resolve(import.meta.dirname, '..') const workflowPath = '.github/workflows/fork-ci.yml' -// The lanes behind the required verdict. `web` is the diagnostic lane for -// now and must stay OUT of this list until its Ubuntu run proves the fork's -// web goldens current; promote it here together with all-checks-passed.needs. -const BLOCKING_JOBS = ['static', 'unit', 'coverage'] as const +// The lanes behind the required verdict. `web` joined after its Ubuntu run +// proved the fork's goldens current; a lane only leaves this list together +// with all-checks-passed.needs. +const BLOCKING_JOBS = ['static', 'unit', 'web', 'coverage'] as const const CACHED_JOBS = ['static', 'unit', 'web', 'coverage'] as const describe('Fork CI workflow', () => { @@ -129,8 +129,6 @@ describe('Fork CI workflow', () => { for (const jobName of BLOCKING_JOBS) { expect(aggregate.needs, `${jobName} must gate the verdict`).toContain(jobName) } - // The diagnostic web lane must not gate merges while unverified. - expect(aggregate.needs).not.toContain('web') if (!Array.isArray(aggregate.steps)) throw new TypeError('Aggregate must define steps') const failStep = aggregate.steps.filter(isRecord).find(step => step.name === 'Fail if any needed job did not succeed') expect(failStep).toMatchObject({ @@ -253,9 +251,9 @@ describe('Fork CI workflow', () => { }) expect(stepRuns(coverage)).toContain('pnpm run check:ci:coverage') - // The non-blocking status must stay visible in the pull-request check - // list; a rename would make a red diagnostic lane look like a blocking one. - expect(web).toMatchObject({ name: 'web browser replay (diagnostic)' }) + // A renamed or resuffixed check changes the check name branch protection + // sees; the lane is blocking now, so keep the plain name. + expect(web).toMatchObject({ name: 'web browser replay' }) }) it('keeps snapshot replay and real-API e2e out until they are re-owned for the fork', () => {