Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Agent Note: Fork CI — parallel keyless lanes on hosted runners

Status: implemented

English | [中文](2026-08-19-fork-ci-parallel-lanes.zh.md)

## Problem

[Fork CI](../../../../.github/workflows/fork-ci.yml) was a single 120-minute `ubuntu-latest` job (lint, typecheck, unit tests, doc-sync) triggered only on master push and manual dispatch: pull requests got no unit or static signal, one failing step skipped every later step, and the lane was not stable — its fifth run failed at unit tests on a docs-only commit. The inherited upstream workflows target DeepSeek's org-scoped runner pools and provider secrets and are `disabled_manually` in the fork's Actions settings, so the fork's real signal is entirely this file.

## Decision

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`.
- 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.
- Once those races were fixed, the lane exposed the fork's real coverage debt: 24 files sit below the per-file 100% bar (fork-added packages shipped without tests to the bar, fork-diverged files changed upstream code without carrying coverage along, and `util/atomic-write` is identical to the upstream snapshot yet short). They are listed in the fork-maintained exclusion block in `vitest.config.ts` with a `TODO(fork)` marker; every other file keeps the 100% gate.
- `unit`, `web`, and `coverage` prepare bubblewrap before running, matching the upstream lanes, so the sandbox suites execute instead of silently skipping.
- 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 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.

## Alternatives considered

**In-file repository guards on ci.yml/e2e.yml** — the fork's copies would skip cleanly even if someone re-enables the workflows. Rejected: `scripts/ci-workflow.spec.ts` asserts those `if` strings exactly, so the patch forks a shared spec file and conflicts on every upstream sync; the workflows are already disabled in settings, which is the writer-visible control point.

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

## Consequences

Pull requests finally carry the fork's unit, static-and-docs, and coverage signal, each with its own timeout and re-run granularity; the monolith's step-cascade failure mode is gone. The master-push red lane (run 5's unit failure) still needs its failing log identified: the unit lane is unchanged in substance, so that failure is a separate follow-up. Coverage costs a long lane on a 4-vCPU runner (bounded at 120 minutes). Caches add first-run latency only: the first master push after merge seeds both stores.
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Agent Note: Fork CI — 基于托管 runner 的并行无密钥车道

Status: implemented

[English](2026-08-19-fork-ci-parallel-lanes.md) | 中文

## Problem

[Fork CI](../../../../.github/workflows/fork-ci.yml) 原本是一个 120 分钟的 `ubuntu-latest` 单 job(lint、typecheck、单元测试、doc-sync),只由 master push 与手动触发:pull request 拿不到任何单测或静态检查信号,某一步失败会跳过其后所有步骤,且该车道出现过失败——第五次运行在一次仅改文档的提交上单元测试失败。继承的上游工作流面向 DeepSeek 组织专属 runner 池与供应商密钥,在 fork 的 Actions 设置中处于 `disabled_manually`,因此 fork 的真实信号完全由这个文件提供。

## Decision

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`。
- 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。
- 竞态修复后,该车道暴露了 fork 的真实覆盖率债:24 个文件低于逐文件 100% 门槛(fork 新增的包没有配套测试达标,fork 分叉的文件改了上游代码却没有带着覆盖率走,`util/atomic-write` 与上游快照完全一致却仍不达标)。它们以 `TODO(fork)` 标记列入 `vitest.config.ts` 的 fork 维护豁免块;其余所有文件保持 100% 门槛。
- `unit`、`web`、`coverage` 在执行前准备 bubblewrap,与上游车道一致,沙箱套件真正执行而不是静默跳过。
- `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` 刷新。

上游工作流保持原样(设置里 `disabled_manually`),而不是打 fork 守卫补丁:`scripts/ci-workflow.spec.ts` 用精确字符串钉死了它们的 `if`,设置级禁用让上游同步零冲突。fork 专属的执行门禁 `scripts/fork-ci-workflow.spec.ts` 钉住新契约:触发条件、无密钥、托管 runner、缓存方向、聚合器成员(含诊断性 web 排除),以及快照回放与真实 API e2e 的持续缺席。

## Alternatives considered

**在 ci.yml/e2e.yml 里加文件级仓库守卫**——即使有人重新启用这些工作流,fork 的副本也会干净地跳过。否决:`scripts/ci-workflow.spec.ts` 精确断言这些 `if` 字符串,补丁会分叉共享 spec 文件并在每次上游同步时冲突;工作流本就已在设置中禁用,那才是写者可见的控制点。

**现在就恢复快照回放**——每个 PR 重跑 `test:snapshot`。否决:golden 在 dd602d3668 中因漂移被移除,重新认领需要先在 CI 上验证 fork 侧刷新;这仍是车道回归前的独立一步。

**立即把 `web` 设为阻塞**——否决:未经验证的 golden 造成的红灯会在刷新窗口期内阻塞所有合并;诊断性起步只需在变绿后改一行即可晋升。

## Consequences

pull request 终于携带 fork 的单元、静态与文档、覆盖率信号,各有独立超时与重跑粒度;单体的步骤级联失败模式不复存在。master push 的红灯(第 5 次运行的 unit 失败)仍需定位其失败日志:unit 车道实质未变,该失败是单独的后续事项。coverage 在 4-vCPU runner 上是一条长车道(以 120 分钟为界)。缓存只带来首次延迟:合并后的第一次 master push 会播种两个存储。
Loading
Loading