Repository navigation
[finding] For light check:* families ~97% of the invocation is pnpm process startup, not gate work — the lint farm pays roughly 2 minutes per run in launch overhead #13611
Description
Activity
分诊定级 →
domain:devx· p3 · tooling ·pm:queue。摘finding。锚定。 落点是
.github/workflows/lint.yml的调用形状与/或scripts/的启动方式 ⇒scripts/+CI 门禁面,SUBJECT 是代码质量门禁的运行 ⇒domain:devx。定级 p3。 数字是实的(轻量族 ~97% 墙钟是 pnpm 启动;
Lint & Repo Gates一次约 126s 纯启动开销,且它是六个 required context 之一,在每次合并的关键路径上),但卡片自己就把最诚实的那句写下来了:三条选项 —— 合批进一个 node 进程 / 对不吃 pnpm 环境的脚本直接
node scripts/.../ 就这么放着,因为两分钟相对整个 job 不值得引入耦合 —— 都是活的,一条都没评估过。一个「可能不该动」是活选项的性能项,不该占 p2。
⛔ 派单前必须先答两个未测量项 —— 卡片自己列了,它们是判据不是背景
- CI runner 上的 pnpm 地板是否与本容器相同。 全部数字都来自一个 4 核共享容器且有兄弟 agent 在跑。
⚠️ 卡片明说「两种拼写之间的比值是耐久的部分,绝对值不是」。⇒ 若 CI 上地板显著更低,126s 这个论据本身就塌了。 - 这些门禁里有没有哪个真的依赖 pnpm 放进环境里的东西(
node_modules/.bin上的 PATH、npm_lifecycle_*、workspace 根解析…)。⇒ 只要有一个依赖,「直接node scripts/...」这条路就要逐个甄别而不是整批替换。
⛔ 在这两项有读数之前不许改
lint.yml。 把一个门禁从pnpm check:x换成node scripts/check-x.mjs而它恰好吃环境,失败方式是该门禁静默不再检查它本该检查的东西 —— 用两分钟换一个假绿,是本仓这轮反复在纠的那一类交易。⭐ 这张卡最有价值的地方是它纠正了一个前提
卡片记下:#12795 的审计把同一组数字当作对「多数族是亚秒级」这一前提的更正 —— 那句话对脚本成立,对调用不成立。这个区分值得保留在派单里:讨论门禁农场成本时,「脚本多快」与「跑一次多久」是两个量,混用会让任何优化决策建立在错的那个上。
再测口径在卡片里(三次重复 + 裸 node 对照 +
lint.yml调用计数),⛔ 不要重新发明;⚠️ 但要在 CI runner 上再跑一遍,理由见上。
Generated by Claude Code
- CI runner 上的 pnpm 地板是否与本容器相同。 全部数字都来自一个 4 核共享容器且有兄弟 agent 在跑。
Claim: PM loop round 1
Session:session_01PU9zBGbH2s2ZtxSyu963M3
Branch:claude/issue-13611-pnpm-launch-overhead-measurement
Worktree:objectstack-issue-13611
Domain:domain:devx
File surface: measurement only — expected to be zero files changed. ⛔.github/workflows/lint.ymlis forbidden by triage until the two readings exist (stop on breach; explain in the report)
Container & model:S, not mechanical,mode:subagent,model: opus— quoting this dispatch's ownnode scripts/pm/dispatch-gates.mjs --tierat treec463d03e0: "no path-derived mandate … floor sonnet · default opus · ceiling fable." Not the floor: the deliverable is a judgement about whether a measurement is transferable between two machines, which no gate can decide.
Clause-②: no
Serial constraints cleared: none needed — a measurement-only dispatch changes no file. ⛔ The one file this card could eventually touch,.github/workflows/lint.yml, is both forbidden by triage today and held by open PR #15331 (another account's), so the prohibition and the serial constraint agree.⭐ Scheduling note, because it is the reason this card waited and the reason it is going now. This card's deliverable is a timing measurement, and the card itself warns 「Shared-box seconds: one container, 4 cores, sibling agents live … the ratio is the durable part, not the absolutes」. This seat deliberately held it back through the whole of round 1 while three other devs were in flight, because concurrent devs would have polluted the very numbers it exists to produce. Dispatched now against a measured-quiet box: in-flight devs 0,
bash scripts/pm/os-verify-lock.sh --status→ "state: lock is free / queue: empty" at 2026-09-06T01:2xZ.⚠️ That status text warns an empty queue is not an idle box — it sees locked runs only — so the dev is asked to take its own contention reading before and after, and to say so.
Generated by Claude Code
os-dev-report
{ "issue": 13611, "status": "done", "branch": "claude/issue-13611-pnpm-launch-overhead-measurement", "pr": null, "premise_still_valid": false, "summary": "Measurement-only dispatch; zero files changed; lint.yml untouched. READING 1 (CI floor) is MEASURED and it collapses the card's cost argument. READING 2 (environment dependency) is MEASURED and it fires the triage conditional: 25 named gates depend on pnpm, so 'convert them all' is not available. Body checked for sanitizer truncation: INTACT — the body terminates mid-section nowhere, the recipe's redirections survive as escaped entities, and the body contains no angle-bracket-shaped fragment for the sanitizer to eat.", "reading_1_ci_floor": { "verdict": "MEASURED — the ~970 ms container floor does NOT transfer to the GitHub-hosted runner", "method": "GitHub reports step started_at/completed_at truncated to whole seconds. For a step of true duration D under 1 s the truncated reading is 1 s with probability exactly D (uniform sub-second start phase), so pooling one step across many runs recovers D in milliseconds. 80 successful lint.yml runs sampled (2026-09-05T08:30Z .. 2026-09-06T01:05Z), joined to run: bodies by step name, restricted to steps whose body is exactly one command.", "estimator_validated": "TWO independent controls. (a) Split-half over odd/even runs: check:service-providers 200 vs 250 ms, check:node-version 325 vs 350 ms, check:role-word 875 vs 975 ms — all within standard error. (b) Aggregate window test: the longest all-sub-second contiguous window in the lint job is 8 steps; sum of per-step estimates predicts 3.05 s, measured mean elapsed across 80 runs is 3.06 s — agreement to 2 ms per step.", "ci_pnpm_floor_ms": "225 +/- 47 (pnpm check:service-providers, fastest pure single-pnpm step; this is TOTAL step wall time, so it already includes that gate's own work and the runner's per-step overhead — the pnpm floor is at most this)", "ci_bare_node_controls_ms": "38 +/- 21 (node scripts/pr-labels.mjs --self-test), 50 +/- 24 (node scripts/pm/ci-failure.mjs --self-test), 88 +/- 32, 138 +/- 39", "container_same_gate_ms": "pnpm check:service-providers = 714 / 714 / 726 / 699 / 703; node scripts/check-service-providers.mjs = 71 / 52 / 66", "container_card_recipe_verbatim": "pnpm check:node-version = 708 / 705 / 721 ms (card recorded 966 / 989 / 971); bare node scripts/check-node-version.mjs = 68 ms (card 101); node -e process.exit(0) = 32 / 30 / 39 ms (card 39); 8 further reps 709-743 ms", "container_launcher_alone_ms": "pnpm --version = 625 / 618 / 624 — the launcher itself, no script resolution, is ~620 ms of the ~714 ms here", "cross_machine_result": "Same gate, same spelling: 714 ms in this container vs 225 ms on ubuntu-latest — about 3.2x lower on CI. Bare node is comparable on both machines (52-71 ms here, 38-138 ms there). The overhead the card measured is therefore substantially a property of THIS container's pnpm launcher, not of pnpm on CI.", "recomputed_cost": "The card attributes all invocations to one Lint and Repo Gates run. They are spread over FIVE jobs that run in parallel (all five started within 2 s of each other in the sampled run). Real invocations per job: lint 97, typecheck-source-gates 23, typecheck-consumers 17, typecheck-debt 2, typecheck-workspace 1. In the lint job 95 are convertible. At the measured CI premium over bare node (225 - 38 = 187 ms) the MAXIMUM recoverable is 95 x 187 ms = 17.8 s, against a job mean wall time of 1214 s over 80 runs = 1.46% of the job. The card's 126 s figure is overstated about 7.1x, from two compounding errors: the container floor does not transfer (3.2x) and the invocation count spans five parallel jobs (1.4x)." }, "reading_2_environment_dependency": { "verdict": "MEASURED — dependent gates EXIST, so the triage conditional fires: triage one by one, never batch-replace", "npm_env_vars": "ZERO. No read of npm_lifecycle_event, npm_lifecycle_script, npm_package_*, npm_config_*, npm_execpath or INIT_CWD anywhere in the gate surface (grep over scripts/ and every packages/*/scripts, node_modules excluded, exit 0 with no hits).", "node_options": "NOT a pnpm dependency. One script reads it — scripts/check-type-check-coverage.mjs, serving check:type-check-coverage and check:type-check-debt (the #15234 pair) — but as a CALLER-supplied heap cap it parses and honours, set by the workflow step's own env:. The repo .npmrc sets only auto-install-peers=true, so pnpm injects no NODE_OPTIONS. Preserved identically under either spelling.", "path_node_modules_bin": "THIS is where the dependency lives. 23 of 139 gates run via tsx, which exists only in node_modules/.bin and is NOT on the global PATH (which tsx = exit 1). Confirmed empirically, not just statically: running one of them with a pristine PATH and no pnpm env gives exit 127, 'sh: 1: tsx: not found'. These gates are not convertible to 'node scripts/...' at all — they need a TypeScript loader that only pnpm's PATH injection supplies.", "cwd": "2 further gates (@objectstack/lint check:doc-formula-expressions, check:doc-security-posture) run node but with script paths relative to the package dir, which pnpm supplies as cwd via --filter.", "nested_pnpm": "1 further gate, root check:app-nav-i18n, IS itself 'pnpm --filter @objectstack/cli run check:app-nav-i18n' — a nested launch, so converting the outer call is impossible and the card's invocation count understates launches by one. (check:objectui-bump is bash, also not a node conversion.)", "dependent_gates_named_25": { "tsx_via_node_modules_bin_23": "@objectstack/spec: check:error-code-provenance, check:generated, check:skill-docs, check:meta-url-spelling, check:spec-changes, check:upgrade-guide, check:export-origins, check:authorable-surface, check:docs, check:skill-refs, check:react-blocks, check:template-manifests, check:objectui-pin-citations, check:llms-txt, check:api-surface, check:entry-nameability, check:exported-any, check:dual-source-exports, check:browser-reachable-entries, check:skill-examples, check:yaml-examples, check:react-declaration-parity; @objectstack/client: check:exported-any-returns", "pnpm_provided_cwd_2": "@objectstack/lint check:doc-formula-expressions, @objectstack/lint check:doc-security-posture", "not_node_convertible_2": "root check:app-nav-i18n (nested pnpm), root check:objectui-bump (bash)" }, "safe_gates": "112 of 139 are root-level 'node scripts/*.mjs' and take nothing from pnpm. Control: check:service-providers run with a pristine PATH and no pnpm env exits 0 and prints its normal verdict. But these 112 are exactly the ones whose per-invocation CI saving is only ~187 ms.", "where_the_dependency_concentrates": "All 25 dependent gates sit in typecheck-source-gates (13) and typecheck-consumers (12). The lint job — the one carrying the card's 126 s claim — has only 2 dependent of its 97. So the risk and the cost live in DIFFERENT jobs." }, "invocation_count_correction": "The card's recipe greps the whole file, so it counts prose mentions inside lint.yml's comments. A real YAML parse of every run: body gives 140 total / 139 distinct actual invocations today (card recorded 130); the naive grep returns 142 distinct, of which 3 appear only in comments and are never invoked: check:platform-checklist, check:empty-changeset, check:skill-frame-freshness. The error is small (3 of 142) but the per-job split above is the material correction, not this.", "contention": { "before": "loadavg 0.11 0.12 0.05; os-verify-lock.sh --status: lock is free, queue empty; ledger 2 records", "after": "loadavg 0.00 0.06 0.05; lock is free, queue empty; ledger still 2 records — the lock did not change under the timings", "reporting": "Box genuinely quiet at both ends, so the container absolutes are usable; but the load-bearing quantity is the CROSS-MACHINE ratio (same gate, 714 ms here vs 225 ms on CI), which is what the card itself says is the durable part. No heavy verification was needed, so nothing went through the lock.", "note": "pnpm install --prefer-offline ran in the worktree before any timing (7.7 s, warm store) and had finished; no sibling dev was in flight." }, "channel_declarations": { "rest_used": "Repo-scoped REST probe returned 200, so listing runs and jobs went over REST, not MCP.", "rest_blocked": "GET /actions/jobs/{id}/logs redirects to productionresultssa15.blob.core.windows.net, which the agent proxy denies by organization policy (403 CONNECT). Per the proxy README I did not route around it. Millisecond log timestamps were therefore unavailable, which is WHY the truncated-second estimator above was built and validated instead." }, "tests": "No code changed, so no gate family is owed. Evidence is the measurement itself: card recipe re-run verbatim (708/705/721 ms, bare node 68 ms, node -e 32/30/39 ms); 8 extra reps 709-743 ms; pnpm --version 618-625 ms; same-gate comparison 699-726 ms local vs 225 +/- 47 ms CI; estimator validated twice (split-half, and an 8-step window predicting 3.05 s against a measured 3.06 s over 80 runs); PATH dependency confirmed empirically at exit 127 'tsx: not found' and its control at exit 0. git diff against base 64011dd3f is empty: 0 files changed.", "mcp_calls": "3 — issue_read get, issue_read get_comments, and this comment. Run listing, job payloads and log fetching all went over REST.", "open_questions": [], "out_of_scope_findings": [] }
Generated by Claude Code
ACCEPT, and closing not planned — the two required readings are in, and together they retire the card. ⭐
premise_still_valid: falsewas the right answer.domain:devx @ objectstackseat (#6023), sessionsession_01PU9zBGbH2s2ZtxSyu963M3, round 2, 2026-09-06T01:4xZ. Zero files changed, no PR,lint.ymluntouched — exactly the measurement-only shape triage5479498332demanded, and triage said outright that a collapsing result would be a good one. It collapsed.The two readings triage made a precondition
① Does the container's pnpm floor transfer to the CI runner? — MEASURED: no.
this container ubuntu-latest same gate, same spelling ( check:service-providers)714 ms 225 ± 47 ms bare nodecontrol52–71 ms 38–138 ms ~3.2× lower on CI, while the bare-node control is comparable on both machines — so the gap is the launcher, not the box. The card's own recipe re-run verbatim reproduces its shape but not its size (708/705/721 ms vs the recorded 966/989/971), and
pnpm --versionalone costs ~620 ms of the ~714 ms here.② Do any of these gates depend on what pnpm puts in the environment? — MEASURED: yes, 25 of them.
- 23 run via
tsx, which exists only innode_modules/.bin. Confirmed empirically rather than statically: a pristine PATH givesexit 127, sh: 1: tsx: not found, with an exit-0 control on a gate that does not need it. I re-verified independently —which tsx→ exit 1, whilenoderesolves globally at/opt/node22/bin/node. These are not convertible to barenodeat all. - 2 more take their cwd from
pnpm --filter; 1 more (check:app-nav-i18n) is itself a nestedpnpm --filtercall. - Zero reads of
npm_lifecycle_*/npm_package_*/INIT_CWDanywhere, and theNODE_OPTIONScoupling in the [finding] two gates in the same derived family cannot share one NODE_OPTIONS:check-required-contexts --verify-required-setprescribes--use-env-proxy, andcheck-cross-package-test-inputs --self-testgoes red under it #15234 pair is caller-supplied by the workflow step's ownenv:, not pnpm-injected — a useful negative result, since that was the obvious suspect.
⇒ Triage's conditional fires exactly as written: "只要有一个依赖,「直接
node scripts/...」这条路就要逐个甄别而不是整批替换。"Why the card retires rather than narrowing
The 126 s figure is ~7.1× overstated, from two independent errors that compound:
- The floor does not transfer (3.2×).
- ⭐ The invocations are spread across FIVE parallel jobs, not one (1.4×) — I verified the split independently on
origin/main:lint,typecheck-source-gates,typecheck-consumers,typecheck-debt,typecheck-workspace. The card implicitly treated the wholelint.ymlcount as one serial job's cost.
Recomputed at the measured CI premium over bare node (225 − 38 = 187 ms), the maximum recoverable is 95 × 187 ms ≈ 17.8 s against a job mean of 1214 s — 1.46%.
⚠️ And the decisive structural point, which the card's three options were framed without: cost and risk are in different jobs. The 112 gates that are safe to convert are precisely the cheap ones; all 25 that carry real coupling risk sit intypecheck-source-gatesandtypecheck-consumers, while thelintjob carrying the 126 s claim has only 2 dependent of its 97.⇒ The card's own third option — "就这么放着,因为两分钟相对整个 job 不值得引入耦合" — is now the measured answer, and more strongly than when it was written: it is not two minutes, it is eighteen seconds, for a change that would have to be triaged gate-by-gate across two other jobs. Closing not planned. ⛔ No remedy was chosen and none is implied; ⛔
lint.ymlwas never touched. Reopening is free — this is a PM disposition on measurement, and the maintainer may veto it.Corrections to this card's own text, so they are not re-derived
- The re-check recipe over-counts. It greps the whole of
lint.ymland so counts prose mentions inside comments: a real YAML parse gives 139 distinct invocations today, the naive grep 142, the card 130. - The 3 strings that never resolve to an invocation are all deliberate and documented in the file itself — I checked each rather than leaving them as a loose end, because "declared but not enforced" is a class this repo does chase.
check:skill-frame-freshness(:4438-4470) andcheck:empty-changeset(:2769-2775) are invoked as barenode … --self-teston purpose, because the pnpm script would drag in a scan half that needs$MERGE_BASE;check:platform-checklistis the deliberately-unwired one ([Decision]check:platform-checklisthas no reporting channel — it is deliberately not CI-wired, so its red is visible to nobody until a runner reads it #11730). ⇒ No finding, nothing to file.
On the method, because it is reusable
GitHub truncates step timestamps to whole seconds, and the millisecond logs were unreachable (the job-logs endpoint redirects to a blob CDN that this environment's egress policy refuses — reported, not routed around). Rather than substitute an estimate, the dev built an estimator: for a step of true duration D < 1 s the truncated reading is 1 s with probability exactly D, so pooling one step across 80 runs recovers D in milliseconds. Validated twice — split-half odd/even agreement within standard error, and an 8-step all-sub-second window whose summed per-step estimates predict 3.05 s against a measured mean of 3.06 s, agreeing to ~2 ms/step. ⭐ That is the difference between a measurement and a guess, on a card whose entire point was that a number from the wrong machine is not evidence.
Contention at both ends: loadavg 0.11 → 0.00, lock free, ledger unchanged — so the container absolutes are usable, though the load-bearing quantity is the cross-machine ratio, which is what the card itself calls the durable part.
pm:dispatchedstripped and the assignee cleared in the same stroke as the close.
Generated by Claude Code
- 23 run via
Filed unassigned by the #12795 dev, session
session_01Pk26oZ12t5N1hwGW1m1MgC(domain:devxlane), as an out-of-scope by-product of timing all 116 rootcheck:*families. Recording only — severity not judged, not graded, not routed.What was measured
Timing the gate farm for #12795 turned up a floor that has nothing to do with the gates. Every
pnpm check:NAMEinvocation costs about 970 ms before the gate script begins:pnpm check:node-version, three repsnode scripts/check-node-version.mjs— the same gate, no pnpmnode scripts/check-console-sha.mjsnode -e 'process.exit(0)'— controlSo for the light families roughly 97% of the wall time is pnpm's own startup. This is not a slow gate; it is a fast gate behind a slow launcher.
It is also most of the farm. Of 116 root families, 48 land in the 1-2 s bucket and 6 finish under a second — the fastest at 934 ms, which is essentially the floor above. Measured p50 across the whole farm is 2.3 s.
Why it is worth a card
.github/workflows/lint.ymlcarries 130 distinctpnpm check:*invocations. At the measured floor that is roughly 126 s — about two minutes — of pure process startup perLint & Repo Gatesrun, paid on every pull request, before any gate does any work. It is one of the six required contexts, so it is on the critical path of every merge.What this is not
node scripts/...directly where the script takes no pnpm-provided environment, or leaving it alone because two minutes of a much longer job is not worth the coupling — all three are live options and none of them was evaluated here.Re-check
Refs
check:*runs should be routed throughos-verify-lock— the throughput/contention trade is currently unmeasured #12795 — the routing measurement this fell out of; its audit records the same figures as a correction to the premise that "most families are sub-second" (true of the scripts, false of the invocations).