Skip to content

ci(lint): Lint & Repo Gates becomes an aggregate over eight parallel lanes; PM dispatch-gates runs its fast tier on PR and queue (#22991) - #23014

Merged
os-zhuang merged 6 commits into
mainfrom
claude/issue-22991-lint-lanes
Oct 12, 2026
Merged

os-zhuang merged 6 commits into
mainfrom
claude/issue-22991-lint-lanes

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22991
Clause-②: no

Executes ruling 6120820653 (letter D) on the card: Lint & Repo Gates becomes an aggregator over eight parallel Lint · * lanes, and pnpm check:pm-dispatch-gates runs its fast tier on pull_request / merge_group while schedule and push keep both tiers.

What changed

  • .github/workflows/lint.yml: the 193-step lint job is split. Job id lint and name: Lint & Repo Gates are kept byte-identical (the required context is matched by check-run name). The job now runs one step: a verdict over toJSON(needs), copied from the typecheck aggregate. It carries OS_AGGREGATOR_MEMBERS (equal to needs:), if: always() and timeout-minutes: 10. Every gate step moved verbatim into one of eight lanes: same run:, same if:, same comment block, same relative order. Each lane repeats the six setup steps, the gate-family selector when it carries a scoped step, and the unmeasured-tail reporter pair. Lane walls use the Type Check lanes' scheme: a checkout budget of 20 on the checkout step plus a body budget (2× the measured body maximum, 5-minute grain, floor 10). That gives 30 for seven lanes and 50 for the PM lane, whose body budget still has to cover both tiers on schedule / push.
  • scripts/pm/check-dispatch-gates.mjs: under GITHUB_ACTIONS=true, GITHUB_EVENT_NAME decides the tier. pull_request and merge_group get --self-test --fast. Every other event gets both tiers: schedule, push, workflow_dispatch, and any trigger nobody listed. --slow stays the manual override everywhere, and the chosen tier and its reason are printed before the spawn. Its own self-test covers each event. It also blanks the runner's own GITHUB_EVENT_NAME, which would otherwise leak into the cases on CI.
  • scripts/check-aggregator-roster.mjs: lint / Lint & Repo Gates is registered as a required aggregate, so deleting its roster is a refusal. It is added to both per-aggregator positive controls. The two typecheck refusal fixtures are anchored on typecheck's own roster: the unanchored pattern hit the first OS_AGGREGATOR_MEMBERS in lint.yml, which is now lint's, and that is how the gate first spoke on this diff.
  • scripts/check-required-contexts.mjs: the pin must now run in a job the Lint & Repo Gates context carries. That is lint itself, or a lane it both declares and lists in needs:. The step may not carry an if:, and neither may the lane hosting it. New fixtures cover: declared and needed counts as wired; declared-only does not; needed-only does not; a lane-level if: is seen.

Lanes (predicted)

How the prediction was made: each lane's wall is rebuilt per run from the per-step timestamps of the 70 successful Lint & Repo Gates runs, 2026-10-12T04:40Z to 08:45Z (pull_request and merge_group, jobs API). Each rebuild uses that run's own setup steps plus the lane's steps as they ran there. The measured walls of this PR's own CI run go into the os-dev-report comment on the card. This body is written once, before that run exists.

lane name steps p50 min max min timeout
lint-eslint Lint · ESLint 2 3.3 4.0 30
lint-repo-tooling Lint · repo & tooling gates 60 4.2 4.7 30
lint-docs-code Lint · docs & code guards 55 4.1 4.6 30
lint-ci-engine Lint · CI, release & engine gates 58 3.9 4.4 30
lint-scoped-erasure Lint · scoped · query-options erasure 2 3.5 5.0 30
lint-scoped-slot-corpus Lint · scoped · slot lookup & corpus 3 3.5 4.9 30
lint-scoped-tooling Lint · scoped · tooling self-tests 3 1.1 4.4 30
lint-pm-dispatch-gates Lint · PM dispatch-gates 1 1.1 5.1 (fast tier, estimated) 50

Critical path. Before: p50 17.8, p90 35.9, max 38.5 minutes. After (predicted): p50 4.2, p90 4.7, max 5.1 minutes on pull_request / merge_group, plus the aggregate's own seconds. On schedule / push the PM lane runs both tiers, about 15–16 minutes.

How the cut differs from the PM's H2:

  • The ESLint lane and the three always-on lanes match H2.
  • H2's single scoped lane would be p50 6.4 and max 11.5 minutes in the same rebuild, over the 5-minute budget. So it is split three ways and the PM self-test stays alone.
  • query-options erasure (160 s) and slot-lookup (84 s) run together in 53 of 70 runs. Together they would put one lane over budget at median, so they sit in different lanes.

Mechanism hypotheses, checked

  • H1 holds. The only inter-step reads in the old job were steps.gate-families.outputs.*: nine if: lines and one step id:. No run: in the job builds, writes GITHUB_PATH, or reads RUNNER_TEMP across steps. The one GITHUB_ENV write (STORE_PATH) is a setup step every lane repeats. Of the 196 scripts the job invokes, I scanned every write call site that has no temp-dir use. None writes a repo file that a later step reads; they are ledger --write modes or fixture strings.
  • H2 refined, as described in the cut above.
  • H3 holds, with one deliberate difference. The fast tier is a closed list (pull_request, merge_group) rather than "fast unless schedule or push". The ruling moved the slow tier off those two events only, so workflow_dispatch keeps both tiers. An event nobody listed also lands on the full battery rather than the shorter one.
  • H4: the selector runs once per scoped lane. It cost 0 s at the median and 1 s at p90 over the 70 runs. A separate families job would put a second full-history checkout and a runner pickup on the critical path of every scoped lane, and it would change all nine if: spellings, which check:select-gate-families pins.
  • H5: each lane's timeout is derived from its measured body, as in the table. The aggregate takes 10, which is lower than the old 55, as the ruling allows.
  • H6: two pins spoke, check-aggregator-roster --self-test and check:required-contexts. Both are updated, and neither is loosened (see "What changed"). The other five pins named in H6 stayed green unchanged.

Proof the move is pure

A one-off verifier parsed the old and the new lint.yml (the yaml package; PyYAML agrees on the result). It checked:

  • each of the 184 gate steps appears in exactly one lane, deep-equal to its old parse, in its old relative order;
  • the six setup steps are identical in every lane except the added checkout timeout-minutes: 20;
  • the selector is present, identical and ahead of the first scoped step in exactly the lanes that carry one;
  • the tail pair is last in every lane;
  • every other job, and the workflow's name, on and concurrency, are unchanged.

Result: 0 problems. Comment edits only name the step a moved neighbour referred to ("the step above" became the step's name and its lane). They also note the per-lane order rule, the per-lane selector and the PM tier change where those notes live.

Ablations (one-time, each restored and confirmed byte-identical to HEAD by scripts/ablation-replace.mjs)

  • FAST_TIER_EVENTS emptied: the first run exited 0, because the self-test looped over the constant under test. The fix names the two events literally; rerun red (2 cases).
  • The event branch forced to always-fast: red (5 cases).
  • lint-ci-engine deleted from the aggregate's needs:: check:required-contexts red, naming the unwired pin.
  • The needs: read in jobsCarriedByLint deleted: red on the declared-only fixture.
  • lint dropped from REQUIRED_AGGREGATORS: check-aggregator-roster --self-test red on the lint refusal fixture.

Gates run locally at 79b0fd6d (this head, after merging origin/main)

Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, which found 54 commands; I ran all of them.

  • 52 ran in the foreground, all exit 0. Among them: check-aggregator-roster (and its --self-test), check:required-contexts, check-ci-filter-parity, scripts/ci/scheduled-full-run.mjs --self-test, check-self-test-workflow-commands, check-step-collectors, check-self-test-wired, check:select-gate-families, check:workflow-step-name-quoting, check:workflow-status-functions and check:nul-bytes.
  • pnpm check:pm-dispatch-gates ran detached, exit 0: the fast tier, 1828 cases, 264.9 s on this box.
  • pnpm check:type-check-debt is NOT MEASURED. Its --self-test passed. Its --re-measure half runs tsc over the ledgered package and was cut at 500 s (exit 124). It needs the build that CI's Type Check · debt ledger lane runs first, and this diff touches no TypeScript program.

--ran reconciliation: 54 derived, 53 run, 1 NOT-MEASURED, 0 unrun. actionlint is not installed in this container; two YAML parsers agree.

Acceptance notes

  • Every lane keeps fetch-depth: 0. I did not re-derive which of the ~190 gates read history. A gate that loses its history degrades to "not verified" rather than red, so a shallow checkout per lane (about 20 s each) is left to a later card.
  • A lane carrying a scoped step without its own selector would fail OPEN: the step would run on every PR. The one-off verifier above checks for this, but no checked-in pin does. check:select-gate-families reads lint.yml as text and does not pair each scoped if: with a selector in the same job.
  • The typecheck aggregate's inline verdict script still says, in a JS comment inside its run: text, that the roster gate runs "in the lint job". I left that run: text untouched; the YAML comment beside it is updated.
  • scripts/pm/dispatch-gates.self-test.mjs line 6 says the both-tier run is "the battery CI runs". That is still true on schedule / push, so it is left alone.
  • The PM lane's fast-tier time on a runner is an estimate: 253–281 s on this 4-core container, where CPU-bound gates run about 1.1–1.3× slower than on CI. This PR's run gives the real number. If the PM lane is over 5 minutes at median, the remedy is splitting the battery, not skipping it.
  • Eight lanes plus the aggregate replace one job: about seven more runner pickups and about seven more minutes of setup per run, in exchange for the wall.

维护者速读(草稿)

改了什么:把 Lint & Repo Gates 这个必过检查从"一台机器串行跑 193 步"拆成 8 条并行车道,原名字保留做汇总(合并队列按名字认,所以不受影响)。另外,PM 派发工具的自测在 PR 和合并队列上只跑快速档,完整档改在每小时定时跑和合入 main 后跑。

为什么改:这个检查中位 17.8 分钟、最长 38.5 分钟,一半的合并队列都在等它。按 70 次真实运行重算,拆分后预计中位约 4.2 分钟、最长约 5.1 分钟(PR 和合并队列上)。

风险与代价(含回滚):每次运行多占约 7 台机器、多约 7 分钟的安装时间(机器分钟换等待时间)。PM 工具的慢速档用例不再在 PR 上跑,有问题会在 main 上的定时运行里第一次变红(这是裁决接受的代价)。回滚就是 revert 这个 PR,必过检查名字没变,设置里不用动。

席位意见:

你要做的:看本 PR 自己那次 CI 的各车道耗时(dev 报告评论里有),确认 8 条车道和汇总都是绿的,Lint & Repo Gates 仍出现在检查列表里;然后批准合入。


Generated by Claude Code

…d merge_group, both tiers on every other CI event

The tier now follows GITHUB_EVENT_NAME under GITHUB_ACTIONS: the PR and
merge-queue path gets --self-test --fast; schedule, push to main,
workflow_dispatch and any unlisted event keep both tiers. --slow stays the
manual override everywhere. The self-test drives each event and blanks the
runner's own GITHUB_EVENT_NAME so it cannot leak into the cases.

Claude-Session: https://claude.ai/code/session_01LqNgdPZB2Jnr5HpGbaqLeB
Co-authored-by: Claude <noreply@anthropic.com>
…ng the list reds it

Claude-Session: https://claude.ai/code/session_01LqNgdPZB2Jnr5HpGbaqLeB
Co-authored-by: Claude <noreply@anthropic.com>
…lanes

The single 193-step job is split into eight Lint · * lanes behind an
aggregator that keeps the required context name byte-identical, with the
TypeScript Type Check roster discipline: OS_AGGREGATOR_MEMBERS == needs,
re-checked against toJSON(needs) at run time, if: always().

Every gate step moves verbatim (same run, same if, same comment block,
same relative order). Each lane repeats the six setup steps, the gate-family
selector where it carries a scoped step, and the unmeasured-tail reporter
pair. Lane walls follow the Type Check lanes' checkout-budget-plus-body
scheme.

Claude-Session: https://claude.ai/code/session_01LqNgdPZB2Jnr5HpGbaqLeB
Co-authored-by: Claude <noreply@anthropic.com>
check-aggregator-roster registers lint / Lint & Repo Gates as a required
aggregate, adds it to both positive controls, and anchors the typecheck
refusal fixtures on typecheck's own roster (an unanchored pattern hit the
first declaration in lint.yml, which is now lint's).

check-required-contexts holds the pin to a job the Lint & Repo Gates
context carries: lint itself, or a lane it both declares and needs, with no
if: at step or lane level; fixtures pin declared-only, needed-only and the
lane-level if: read.

Claude-Session: https://claude.ai/code/session_01LqNgdPZB2Jnr5HpGbaqLeB
Co-authored-by: Claude <noreply@anthropic.com>
…he per-lane order rule

Positional references ("the step above") whose referent moved to another
lane now name the step and its lane; the GATE ORDER note, the selector and
the PM dispatch-gates step say what the split changed for them.

Claude-Session: https://claude.ai/code/session_01LqNgdPZB2Jnr5HpGbaqLeB
Co-authored-by: Claude <noreply@anthropic.com>
@os-zhuang
os-zhuang marked this pull request as ready for review October 12, 2026 11:06
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 12, 2026
Merged via the queue into main with commit 5dda600 Oct 12, 2026
45 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-22991-lint-lanes branch October 12, 2026 11:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ci(lint): measure where Lint & Repo Gates' 198 serial steps spend 14–37 minutes, and bring the maintainer a split plan (maintainer-directed)

2 participants