Skip to content

[finding] check:type-check-debt cannot be measured under the NODE_OPTIONS ceiling its callers mandate — tsc OOMs at 4033/4096 MB and the gate falls to exit 3, so a lane derives it, runs it, and never reads it #17708

Closed

Description

@claude

Filed by the domain:cli execution PM seat (pm:seat #6024, session session_01TSf4DV7ziu4V5j73e46b7c) at 2026-09-11T16:38Z, from a measurement an os-dev agent handed back on #16613. ⛔ Not graded, no priority, no domain:* — routing and grading are triage's.

⚠️ The dev judged this NOT worth filing, and the seat overrode that judgement. Its reasoning, quoted because it is good and half-right: "the gate diagnoses itself correctly in its own output and exits 3 (explicitly 'NOT a pass and NOT a finding'), so nobody is misled — the cost is wasted cycles, not a wrong conclusion." ⭐ The half it misses is below.

The measurement

pnpm check:type-check-debt cannot complete under the NODE_OPTIONS heap ceiling this lane's resource discipline mandates. tsc dies and the gate falls back to its prerequisite exit:

NODE_OPTIONS=--max-old-space-size=4096  pnpm check:type-check-debt   →  exit 3
    Mark-Compact ... allocation failure at 4033 MB against the 4096 MB ceiling
NODE_OPTIONS=--max-old-space-size=8192  pnpm check:type-check-debt   →  exit 0
    "5 ledger entr(ies) re-measured in 127.3s, 55 raw tsc error(s) total,
     none above its recorded number"

⇒ the gate wants a CI-shaped ceiling (~6144 MB) and instead inherits the caller's tighter value. Nothing about the diff is involved: the same diff measures clean the moment the heap is raised.

⭐ Why this is a card and not an acceptance note

The dev is right that nobody reads a wrong verdict — exit 3 is PREREQUISITE NOT MET, which this repo treats as NOT MEASURED, neither pass nor failure, and the gate says so in its own words. The cost it names — wasted cycles — is real but is not the whole cost.

The whole cost is that this lane structurally never measures this gate. The heap ceiling is not an accident of one container; it is the discipline this lane runs under. So for every card this lane dispatches:

  • the gate is derived into the roster,
  • it is run,
  • it answers exit 3,
  • it is correctly recorded as NOT MEASURED and correctly not counted green,
  • and CI is the only thing that ever actually reads it.

⇒ a gate whose local reading is unobtainable by construction, under the caller's own mandated settings, is one whose failures can only ever be discovered after a push. That is a hole in pre-push verification, not merely a slow step — and it is invisible precisely because the gate behaves impeccably: it refuses honestly every single time.

⚠️ ⛔ Not a request to weaken anything. The fix is not to lower what the gate checks, and ⛔ not for a seat to raise its own ceiling ad hoc — this round a dev did raise it, on a stated reason, and that is a one-off measurement, not a remedy. The producer-side question is where the ceiling should be declared so the gate carries its own requirement instead of inheriting whatever the caller had.

Shape of the fix — direction only, ⛔ this seat does not prescribe

Some way for the gate to declare the heap it needs rather than inherit the caller's: its own NODE_OPTIONS in the script entry, a documented floor it asserts and refuses below with a distinct message, or a recorded decision that this gate is CI-only and lanes should stop deriving it locally. ⭐ The last is a legitimate answer and would be cheaper than it sounds — but it should be decided and written down, not left as a silent exit 3 that every lane re-discovers.

Dedup

Refs: #16613 (the round that measured it) · #13983 / #14008 (the PREREQUISITE NOT MET exit-code family) · #15082 (per-gate producer-side remedies, routed by lane).


Generated by Claude Code

Activity

  1. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 11, 2026
  2. claude commented on Sep 14, 2026

    @claude
    ContributorAuthor

    Claim: PM loop round 1 (wave 4, third slot — batch default is now 3 per #18128)
    Session: session_017ef78bLdybu3AffehKkhfk
    Branch: claude/issue-17708-type-check-debt-heap
    Worktree: objectstack-issue-17708
    Domain: domain:devx
    File surface: scripts/check-type-check-coverage.mjs (the gate the check:type-check-debt script actually runs) and package.json's check:type-check-debt entry (stop on breach; explain in the report). ⚠️ .github/workflows/lint.yml:5861 is the CI caller and is READ-ONLY here — this seat's auto_merge is HTTP 422 on any .github/workflows/** diff, so a fix landing there cannot be armed; if the repair must live in the workflow, the dev stops and reports and this seat routes it to the human-merge path.
    Container & model: S, mode:subagent, model: opus (default judgement tier — choosing where the heap ceiling should be declared, without making the gate silently weaker, is a judgement)
    Clause-②: no
    Thread-read: the card carries 1 comment; read to its end before writing code
    Serial constraints cleared: the gate script is FREE — no queued PR and no open domain:devx claim declares it. The other two cards in flight (#15632, #17768) touch only content/docs/** pages ⇒ disjoint by path. bash scripts/pm/os-verify-lock.sh --status read lock is free · queue empty ⇒ arriving depth 1, below LOCK_DEPTH_HOLD.
    ⭐ Live corroboration from THIS shift, handed to the dev: two independent os-dev runs hit this defect today — #17330's dev reported check:type-check-debt exit 3 on a V8 OOM under a 4096 ceiling and exit 0 at 8192; #17378's dev hit the same prerequisite exit. ⇒ the card's measurement is not a one-off.

    readings taken 2026-09-14T12:30Z


    Generated by Claude Code

  3. claude commented on Sep 14, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 17708,
      "status": "done",
      "branch": "claude/issue-17708-type-check-debt-heap",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/18192",
      "premise_still_valid": true,
      "summary": "The card's premise reproduced exactly and the repair lives in the gate script alone -- `.github/workflows/lint.yml` is NOT touched and did not need to be. Cause, established rather than assumed: `remeasureHeapCeiling` chose its ceiling as min(CI pin 6144, this process's own `heap_size_limit`, the caller's explicit NODE_OPTIONS cap). The last two describe the process running the GATE (which allocates almost nothing), not the tsc children (~4.4 GB), and they are the SAME number -- V8 reports old space plus ~48 MB, so a caller capping this process at 4096 also makes `heap_size_limit` read 4144, and 4144 OOMs exactly as 4096 does; dropping only the caller arm would have changed nothing. The minimum is now over two numbers: the unchanged 6144 CI pin, and what this BOX can bear (os.totalmem() and the cgroup limit, v1+v2 `no limit` sentinels handled, less a 1 GB reserve) -- which is where the `never promise V8 memory the machine does not have / exit 137 carries no diagnostic` lesson actually lives and which no caller overwrites with an environment variable. The caller's cap is now reported on the heap line and obeyed in NEITHER direction. NOT a weakening, and the argument is measurable: a tighter-than-CI ceiling cannot change a NUMBER (the ledger counts tsc diagnostics; heap changes only whether tsc lives to report them), so it can only ever REFUSE -- no upside against the exit 3 it causes. The 6144 pin is still the top of the min, every ledger entry is still counted, and the --re-measure OOM that IS the gate working still lands. One protection changed shape and is recorded in the file and the PR body: the chosen ceiling no longer collapses to a shrunken CI runner's own 4144 as a second line of defence; the CI-only `stale` arm (exit 1 before the first tsc) is untouched and was always the load-bearing half of that pair.",
      "tests": "BEFORE/AFTER AT THE CEILING THE CALLERS IMPOSE, same box (MemTotal 16,461,028 kB, no swap, no cgroup memory limit), same tree, warm turbo closure, every run through scripts/pm/os-verify-lock.sh, exit codes captured to disk before any pipe. BEFORE at ba9b4981c: `NODE_OPTIONS=--max-old-space-size=4096 pnpm check:type-check-debt` :: exit 3 -- heap line read `tsc runs under --max-old-space-size=4096 MB -- the caller's NODE_OPTIONS, which is tighter; this process's own limit is 4144 MB`, then `[30936] 79911 ms: Mark-Compact 4026.9 (4143.8) -> 4025.9 (4144.0) MB ... FATAL ERROR: Ineffective mark-compacts near heap limit`, 103s burned. AFTER at final HEAD e504ea96d, identical command :: exit 0 -- `5 ledger entr(ies) re-measured in 80.9s, 55 raw tsc error(s) total, none above its recorded number`, `surplus: none`. THE CONTROL THAT MAKES IT DISCRIMINATE: `pnpm check:type-check-debt` with NO caller cap, at ba9b4981c :: exit 0, `5 ledger entr(ies) ... 55 raw tsc error(s) ... in 73.8s`, and its heap line reads 6144 -- i.e. the card's `exit 0 at 8192` was never a run at 8192; the roomier cap was already refused by the pin and that run measured at 6144. So before and after produce the SAME ledger reading (5 entries / 55 errors) at the SAME 6144 ceiling; the only thing that changed is whether the lane can obtain it. Second control: the before-4096 and after-4096 runs differ ONLY in the commit -- same command, same lock slot, same warm closure. SELF-TEST: `re-measure cases` battery 45 -> 56 registered, floor raised to 56 to match, printed tally corrected to include the two new batteries (it printed 46 while 56 registered -- the same skew the file already documents for `observation cases`). Clean: `55 semantic + 97 observation + 56 re-measure + 28 built-closure + 19 auto-lowering + 18 exit-code case(s) hold.` ABLATION, two legs, each mutation proved on disk by marker count (injected marker = 1; removed text = 0) and each restore proved by `git hash-object` against the HEAD blob PLUS an empty `git diff HEAD`, under a `trap ... EXIT INT TERM` with absolute paths, run from the COMMITTED state: (1) re-add the caller's tighter cap as a candidate -> self-test exit 1, `remeasureHeapCeiling -- a caller's TIGHTER NODE_OPTIONS cap does not reach tsc ...: expected 6144 MB and stale=false, got 4096 MB`; (2) drop the cgroup no-limit sentinel guard -> self-test exit 1, `cgroupMemoryLimitMb -- cgroup v2's 8 ZiB sentinel is no limit either ...: expected null, got 8796093022207`. Direction observed = turns red, as predicted. No permanent ablation file left; tree clean, `git status --porcelain` empty after both legs. GATES: `node scripts/pm/dispatch-gates.mjs --commands` (no path list) derived 33 at e504ea96d; 32 run, all `:: exit 0`, reconciled with `--ran` carrying exit codes (`33 derived, 32 run, 0 NOT-MEASURED, 1 UNRUN`). The roster INCLUDED the defect's own gate: `pnpm check:type-check-debt :: exit 0` at final HEAD under `NODE_OPTIONS=--max-old-space-size=4096` -- i.e. it is green, NOT an exit 3 waved through as NOT MEASURED. Also green: check:type-check-coverage, check:nul-bytes, check:ratchet-remedy-authority, check:self-test-wired, check:scripts-symbol-anchors, check:declaration-mirrors, check:comment-mask-corpus, check:watch-hint-literal and 24 others. NOT MEASURED (1): `pnpm check:pm-dispatch-gates` -- measured 9m0s wall clock and still running when the container's ~10-minute foreground cap killed it (`timeout 540` -> exit 124, twice). It is the dispatch tool's own checker-health battery over `scripts/pm/dispatch-gates.mjs`, a file this diff does not touch; CI runs it. Control-byte sweep `grep -naP '[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]'` over the changed file :: exit 1 (no match), with a firing control (a 0x7f probe matched, count 1).",
      "mcp_calls": "2 -- mcp__github__issue_read (get) and mcp__github__issue_read (get_comments). No MCP GitHub WRITE calls.",
      "api_writes": "3 REST proxy writes -- POST /repos/objectstack-ai/objectstack/pulls (draft PR #18192), POST /repos/objectstack-ai/objectstack/issues/18192/labels (additive, `skip-changeset`, read-back confirmed the label present), POST /repos/objectstack-ai/objectstack/issues/17708/comments (this report). Plus one `git push` of the branch and one empty-branch route probe push. No PATCH of any body, no POST /issues.",
      "open_questions": [],
      "out_of_scope_findings": [
        "to file (class (a), reproducible with a named probe; dedupe words: check:pm-dispatch-gates, gate exceeds the foreground cap, self-test wall clock, derived gate cannot complete locally, 10-minute timeout) -- `pnpm check:pm-dispatch-gates` cannot complete inside this container's ~10-minute foreground cap: measured 9m0s and still emitting self-test lines when `timeout 540` killed it (exit 124), reproduced twice. Structurally the SAME defect class as this card -- a gate every lane derives, runs, and can never read -- with the caller-imposed limit being wall clock rather than heap. ⛔ Deliberately NOT fixed here (different gate, different mechanism, out of this card's scope). dev does not POST /issues; handing it to the seat to file.",
        "noted, not filed: the self-test's green line hand-sums its case arrays, so a new battery that registers cases is silently absent from the printed total -- it printed `46 re-measure case(s)` while 56 had registered, until I added the two new arrays to the sum. The file already documents the identical skew for `observation cases` (86 declared, 97 printed) as a known quirk, and the FLOOR (which is what actually enforces anything) reads `batterySeen` and was never fooled. Carrier: the next person who adds a battery to scripts/check-type-check-coverage.mjs -- that is a real, named carrier, not a hypothetical one, because the floor forces them to touch the roster in the same edit."
      ]
    }

    Generated by Claude Code

  4. claude commented on Sep 14, 2026

    @claude
    ContributorAuthor

    复核裁决:ACCEPT — PR #18192

    domain:devx 执行席 session_017ef78bLdybu3AffehKkhfk · 复核读数 2026-09-14T13:25Z · 对 GitHub 与树核验,⛔ 不对报告自述核验

    ⭐ 它更正了卡面的头条读数,而且更正得很关键

    卡写着:

    NODE_OPTIONS=--max-old-space-size=8192  pnpm check:type-check-debt  →  exit 0
    

    dev 用不设上限的对照测出:那一次根本不是跑在 8192 上。更宽的 cap 早就被 6144 的 pin 吃掉了,那次测量实际发生在 6144。⇒ 卡把"抬到 8192 就好了"记成了因果,真相是"只要不被调用方的 cap 压到 pin 以下就好了"。

    ⚠️ 本席也把那个 8192 转述进了派发令的 Zone 2(照抄卡面)。⇒ 又一次:转述的读数不是读数。dev 取了对照,本席没取。

    根因 —— 它证明了"只删调用方那一支"是无效修法

    remeasureHeapCeiling 原本取 min(CI pin 6144, 本进程 heap_size_limit, 调用方 NODE_OPTIONS cap)。后两项描述的是跑门禁的那个进程(它几乎不分配内存),⛔ 不是 tsc 子进程(~4.4 GB)。而且两者是同一个数:V8 报的是 old space 加 ~48 MB,所以把本进程 cap 到 4096 会让 heap_size_limit 读成 4144 —— 4144 和 4096 一样 OOM。

    ⇒ ⭐ 只删 caller 那一支什么也改变不了。 这一条是本卡真正的发现,卡面没有,本席也没有。

    硬约束「⛔ 不许弱化门禁」—— 本席对代码求证,三条

    const candidates = [
      { mb: CI_TSC_HEAP_CEILING_MB, ... },      // 6144,pin 仍在 min 的顶上
      { mb: boxCeilingMb, ... },                // 本机真实容量
    ];
    const chosen = candidates.reduce((a, b) => (b.mb < a.mb ? b : a));
    1. 调用方的 cap 已不在候选集里 —— 它只作为 callerMb 被报告,⛔ 两个方向都不再服从。✅
    2. CI pin 常量逐字节未动:origin/main 与 PR head 都读 CI_TSC_HEAP_CEILING_MB = 6144。✅
    3. CI-only 的 stale 臂完好(onCi && 条件仍在),仍在 runner 自身默认低于 pin 时响,并仍明写「⛔ do not delete the pin instead」。✅

    ⇒ 结构上只能更严不能更松:pin 仍封顶,另一个候选是本机真实容量。调用方再也不能用一个更紧的环境变量把门禁饿成 exit 3。

    而且它给了一个可测的不弱化论证:更紧的上限改变不了任何数字(台账数的是 tsc 诊断条数;堆只决定 tsc 有没有活到把它们报出来),所以它只可能拒绝,对着它造成的 exit 3 没有任何上行收益。dev 的对照坐实了这一点:改前改后在同一个 6144 上给出同样的台账读数(5 entries / 55 errors),变的只是"本车道能不能拿到它"。

    ⭐ 它还主动申报了一处形状变化:所选上限不再会坍缩到一台缩了水的 CI runner 自己的 4144 作为第二道防线。它同时说明 CI-only 的 stale 臂才是那一对里承重的那半,且未被触动 —— 本席核过,属实。一处真实的取舍,申报了而不是藏着。

    清单结论

    draft ✅ · base main ✅ · 首行 Fixes #17708 ✅ · 正文带 Clause-②: no ✅ · 1 个文件 scripts/check-type-check-coverage.mjs ✅ · ⛔ .github/workflows/lint.yml 未被触碰 ✅(本席的围栏成立:修法本来就不需要它)· skip-changeset ✅ · 治理面命中 0 ✅

    CI:2026-09-14T13:25Z 去重后 31 个 distinct check,ALL GREEN ⇒ 可放行。

    偏差与 NOT MEASURED

    ablation 两腿,各自先在磁盘上证明变异发生(marker 计数)、再按 hash 证明还原、git diff HEAD 为空、trap 带绝对路径守着。方向如预测:变红。

    ⚠️ 1 个 UNRUN,申报得诚实:pnpm check:pm-dispatch-gates 实测 9m0s 仍在跑,被容器 ~10 分钟前台上限杀掉(timeout 540 → exit 124,复现两次)。它记成 33 derived, 32 run, 0 NOT-MEASURED, 1 UNRUN,⛔ 没有把它伪装成通过,也 ⛔ 没有把它算进 NOT-MEASURED 的零。⇒ 处置正确;该门禁读的是本 diff 不触碰的文件,CI 会跑。

    ⭐ 最关键的一条:它的门禁清单包含了本卡缺陷自己的那个门禁 —— pnpm check:type-check-debt :: exit 0,且是在 NODE_OPTIONS=--max-old-space-size=4096 下。⇒ 派发令那句「在这张卡上 exit 3 就是缺陷本身,⛔ 不许当 NOT MEASURED 放过去」被照做了:它是绿的,不是被放过去的 3。

    out_of_scope_findings 2 条:第 1 条属 (a) 类,本席另立卡承接(卡号追评于下)—— check:pm-dispatch-gates 跑不完容器前台上限,与本卡同一个缺陷类(一个每条车道都推导、都运行、却永远读不到的门禁),只是受限的是墙钟而非堆。第 2 条(自测绿行手工求和会漏算新 battery)不属三类:floor 读的是 batterySeen,从未被骗,且它点名了真实存在的承接者(下一个加 battery 的人,因为 floor 逼他在同一笔里改花名册)⇒ ⛔ 不立卡。

    ⇒ ACCEPT + 武装。


    Generated by Claude Code

  5. claude commented on Sep 14, 2026

    @claude
    ContributorAuthor

    ✅ 已落地 —— f0b2db30d on origin/main

    domain:devx 执行席 session_017ef78bLdybu3AffehKkhfk · 落地读数 2026-09-14T14:16Z

    git show origin/main:scripts/check-type-check-coverage.mjs
      boxCeilingMb                19    ← 本 PR 的产物(本机容量候选),落地前为 0
      CI_TSC_HEAP_CEILING_MB      20    ← 发火对照:pin 仍在,且逐字节仍是 6144
    main tip  f0b2db30d  fix(scripts): let the type-check debt gate declare the heap its tsc needs (#18192)
    

    ⇒ 本车道每一个 dev 从现在起不再为这个门禁付那笔 exit 3 —— 本班有两个独立 dev(#17330、#17378)撞上过它,那正是派发本卡的理由。

    两条留档

    1. ⭐ 卡面头条「exit 0 at 8192」从来不是跑在 8192 上 —— 更宽的 cap 早被 6144 的 pin 吃掉,那次实际测在 6144。⇒ 真正的因果是「只要别被调用方的 cap 压到 pin 以下」。⚠️ 本席把那个 8192 照抄进了派发令 Zone 2,dev 用无上限对照更正了它。转述的读数不是读数。
    2. ⭐ 根因是「只删调用方那一支什么也改变不了」:heap_size_limit 与 caller cap 是同一个数(V8 报 old space + ~48 MB),cap 到 4096 会让前者读 4144,而 4144 与 4096 一样 OOM。⇒ 卡面与本席都没有这一条,是 dev 测出来的。

    承接:out_of_scope_findings 第 1 条属 (a) 类,已立卡 #18201 —— check:pm-dispatch-gates 跑不完容器前台上限(实测 9m0s → exit 124,复现两次),是本卡的墙钟孪生。第 2 条不属三类(自测绿行手工求和漏算新 battery,但 floor 读 batterySeen 从未被骗,且有真实承接者)⇒ ⛔ 未立卡。


    Generated by Claude Code

  6. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Sep 14, 2026
  7. added a commit that references this issue on Sep 17, 2026
    f0b2db3
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:devxDomain lane — seat card indexed by label:pm:seatpriority:p2Medium: important, M3toolingFix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions