Skip to content

test(devx): give the ref'd-timer instrument a home every package can reach, and gate the raw probe - #10905

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-10783-refd-timer-probe-and-gate
Aug 22, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-10783-refd-timer-probe-and-gate

Conversation

@claude

@claude claude Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Closes #10783
Closes #10785

The class — a process-global getActiveResourcesInfo() timer count scored with toBe across an await — has been found five times over four cards. Twice the first signal was a shard-only intermittent red whose message names a timer count rather than the change that caused it: once in CI (expected 2 to be 4), once in the merge queue, where it stalls every lane at once. This PR converts the fifth site, closes the class with a gate, and converts the one leg whose earlier repair could still hide a real leak.

The home decision, and the cost accepted

This is the load-bearing part, so it leads. @objectstack/service-automation could not reach packages/core/src/refd-timer-probe.testkit.ts at all — @objectstack/core exports exactly . and ./logger. The three candidate homes were measured, not argued:

home verdict
test-only relative import into packages/core/src, declared in CROSS_PACKAGE_TEST_INPUTS rejected — measured TS6059
subpath on @objectstack/core's exports rejected on principle
private, test-only workspace package, bare specifier chosen

Why the relative import lost. I built it first: the declaration and the turbo input glob both landed, check:cross-package-test-inputs went from OK: 13 package(s) to OK: 14 package(s), and both suites were green. Then tsc -p packages/services/service-automation/tsconfig.json:

src/engine.test.ts(19,51): error TS6059: File 'packages/core/src/refd-timer-probe.testkit.ts'
  is not under 'rootDir' 'packages/services/service-automation/src'.

That package's tests are in its tsc program (its DEBT entry says so and --listFiles confirms it), so the count goes 3 → 4. check-type-check-coverage.mjs counts TS6059 for a package's own config deliberately — the asymmetry is documented at TSC_ROOTDIR_DIAGNOSTIC — and its own self-test pins “a count that grew by one is red too — no tolerance band”, with the raise marked ⛔ MAINTAINER-ONLY. So this route's only exit is a maintainer-only ratchet weakening. It is also not a quirk of one manifest: most packages here set rootDir: src, so it would have been a trap for the next adopter too.

Why the exports subpath lost. It would put a test helper on a runtime package's public npm surface, under semver, for every consumer of the kernel — capability expansion with no external pull, against the startup scope-discipline principle. It would also need a tsup entry, contradicting the instrument's own documented property that tsup never bundles it into dist.

⚠️ #10785 predicted the api-surface ratchet "would rightly notice" this. I checked, and that prediction is wrong — api-surface/ is @objectstack/spec-scoped (packages/spec/api-surface/*.json, gated by pnpm --filter @objectstack/spec run check:api-surface), and @objectstack/core has no entry there. That makes the option worse, not better: the cost would have been paid silently. Recorded here so the next reader does not inherit the wrong reassurance.

What was chosen, and what it costs. packages/qa/refd-timer-testkit — private: true, no build, exports straight at src, consumed as a devDependency. Measured: a bare specifier resolved through node_modules is exempt from rootDir (verified on a scratch fixture with --noEmit and with emit), so it is reachable from any consumer whatever its own build config says. service-automation's debt is back to exactly 3, its frozen value.

The cost accepted is a new workspace package — a manifest, a tsconfig, and one more row in the type-check-coverage census (64/77 → 65/78, arriving covered, debt unchanged at 436). The instrument also moves out of packages/core, so four core suites change one import line each.

The one thing this had to not break is turbo's Layer B, because a bare specifier is deliberately invisible to check:cross-package-test-inputs. Measured rather than assumed, on turbo run test --dry=json: @objectstack/refd-timer-testkit#build is a real graph node with inputs: ["package.json","src/index.ts","tsconfig.json"], and editing the instrument moves @objectstack/service-automation#test's hash ee8253e9cb374faa → 77680cb1d4bdd2d4. No hand-written input glob needed; the dependency edge carries it.

⛔ Not considered: forking a second copy into service-automation. Both cards turn it down, and the gate below would red on it anyway.

1. The fifth site (#10783)

packages/services/service-automation/src/engine.test.ts, at :2012–:2025 on the base commit — re-derived, matching the card. A local refdTimers() sampled either side of await engine.execute('guarded_flow') and scored with toBe(before). It was green only because registerInstantScript resolves on a microtask — a property of code the test does not own. Now:

const guards = await recordGuards(GUARD_MS, async () => {
    result = await engine.execute('guarded_flow');
});
expect(guards).toHaveLength(3);              // one guard per guarded node
expect(stillPinningTheLoop(guards)).toBe(0); // and none outlived its race

2. The gate (#10785 part 1)

scripts/check-refd-timer-probe.mjs + pnpm check:refd-timer-probe, wired into lint.yml.

It bans the raw probe outside one approved module — the cards' cheap, robust formulation. It does not attempt "is there an await between these two readings": that is an AST question, and a text scan answering it approximately fails silently on spellings it does not know, which is the failure mode AGENTS.md already records for source-scanning gates. A gate whose default is a silent pass is the failure mode, not a weaker fix.

Two design points worth review:

  • Matched by IDENTIFIER, not by process. receiver. A member access is one route; const { getActiveResourcesInfo } = process, a named import from node:process and process['…'] are others, and matching the receiver leaves a silent hole per route. The one evading spelling — a name assembled at runtime — is stated in the header rather than left to be discovered.
  • Comments are masked (js-comment-mask.mjs). The instrument's docblock, four core suites' fake-timer comments and the gate's own header all have to name the banned probe to explain the rule. A gate that forces authors to reword prose to dodge a scanner teaches them the scanner is noise.

It also cannot report a vacuous green: "nobody reads the probe" is equally true of a tree where the instrument was deleted or gutted, so the sweep asserts the approved module is present and still reads it.

Registration. A check:* key in root package.json, the dominant convention (70+ keys), so the remedy the gate prints is a command that exists. The #9465 fence scopes that file's @changesets/cli range and its version script — the parenthetical in the issue body is scoping, not illustrative — not the file. #10785's body says otherwise; that is the over-broad reading. This is not my claim alone: the Step-collector gate comment landed on main today says the same thing in as many words, while choosing the other spelling on its own merits.

3. The toBeLessThanOrEqual leg (#10785 part 2)

packages/core/src/timeout-guard.test.ts. #10661's repair was deliberate and documented, so it is superseded with an explanation, not silently overwritten — the docblock now carries both the original reasoning and why the instrument replaces it. All three legs move to recordGuards/stillPinningTheLoop, and the assertion is an exact toBe(0) again. The third leg's guard is supposed to fire; what it pins is now stated rather than implied.

The file's other probe use — arms a REF'D timer — is a genuinely synchronous window, so it keeps the raw reading, now via the instrument's refdTimeouts(), with a comment saying exactly which property makes it sound.

Ablations — predictions written down before each run

Every mutation was proven on disk by marker counts (an editing tool's exit code is not evidence), and every restore leg proved git diff --quiet.

A — the converted pin reds on a real leak. Delete clearTimeout(this.timer) from TimeoutGuard.reclaim(). Predicted: leg 1 red with expected 1 to be +0, plus two pre-existing sibling pins. Observed, exactly:

× leaves no ref'd timer behind on any of the three outcomes
    AssertionError: expected 1 to be +0
× arms a REF'D timer, ...            AssertionError: expected 6 to be 5
× clears the timer, ...              AssertionError: expected 1 to be +0
Tests  3 failed | 8 passed (11)

B — the fifth site reds on a real leak. Delete clearTimeout(guard) from executeWithTimeout's finally. Predicted expected 3 to be +0. Observed verbatim: AssertionError: expected 3 to be +0.

C — the gate's self-test cannot pass without the rule. Make probeSitesIn() return []. Predicted exit 1. Observed exit 1, 9 cases failing. ⚠️ The mechanism differed from my prediction: I expected the red cases to fail as "expected red, got green", but gutting the detector trips the vacuous-green limb first, so most fail as "red as expected, but the message does not name …". Direction right, mechanism not as written — recorded rather than smoothed over.

D — the card's part-2 arithmetic, reproduced. Same leaking reclaim(), plus two foreign timers armed to expire inside the window (modelling shard load deterministically instead of waiting for a flaky shard). Scratch harness, deleted; git diff --quiet clean after.

ABLATION-D  before=3 after=2 delta=-1 guardsArmed=1 stillPinningTheLoop=1

subject +1, two foreign −2, reading = before − 1 ⇒ toBeLessThanOrEqual passed on a real leak, while the instrument reported 1 on the identical tree. That is #10785 part 2, measured.

The gate's real-tree positive control. Before the conversions, on the tree as it stood, the gate named all three live sites and exited 1 — timeout-guard.test.ts:18, timeout-guard.test.ts:162, engine.test.ts:2013. Turning those green is what this PR does.

Verification

Gate union re-run after the final commit, at 46fa7102a1, all green — including every family node scripts/pm/dispatch-gates.mjs derived from the real diff:

check:refd-timer-probe · check:cross-package-test-inputs (13 packages, unchanged) · check:entry-guard · check:parse-guard · check:nul-bytes · check:ratchet-remedy-authority (117 swept; this gate classifies excluded — it hands out no ratchet-expanding remedy, so it needs no control entry) · check:type-check-coverage (65/78, debt 436 unchanged) · check:test-source-alias · check:type-source-resolution · check:slot-lookup · check:query-options-erasure · check:engine-double-contract · check:where-matcher · check:kernel-hook-pairs · check:required-contexts · check:shard-attestation · check:workflow-status-functions · check:node-version · check:override-consistency · check-step-collectors · check-ci-filter-parity · check-changeset-fixed · check-plugin-teardown-shape.

Tests at the same commit: @objectstack/core 37 files / 887 tests passed, @objectstack/service-automation 84 files / 998 tests passed, @objectstack/refd-timer-testkit typecheck clean.

Declared narrowing: check:type-check-debt --re-measure was not run locally — it refuses without the full ledgered dependency closure built (35 packages). Instead I measured the one package this PR could have moved, directly: tsc -p packages/services/service-automation/tsconfig.json reports exactly 3 errors, its frozen value, with the TS6059 gone. CI runs the farm version.

No changeset: test files, a private (unpublished) package, a gate script and its registration. No published package's contents change — the two devDependencies lines are not installed by consumers. skip-changeset applied.

Concurrency check: two siblings were in flight on scripts/**. Both landed while this branch was open (c9636de1fd, 409077e93c, touching check-type-check-coverage.mjs and check-dispatcher-error-vocabulary.mjs) and were merged in cleanly; this diff touches neither file. Worth flagging as a near miss: git reset --soft origin/main re-anchors to wherever origin/main points now, not to the commit you merged, so both of those landed commits briefly appeared inside this branch as deletions — silently, exit 0. Caught by reading git status before pushing; the final diff is the 14 files listed above and nothing else.


Generated by Claude Code

…reach, and gate the raw probe

The class — "a process-global `getActiveResourcesInfo()` timer count scored
across an `await`" — has been found five times over four cards, twice as a
shard-only intermittent red (once in CI, once in the merge queue, where it
stalls every lane at once). This converts the fifth site, closes the class with
a gate, and converts the one leg whose repair could still hide a real leak.

The load-bearing decision is where the instrument lives, because
`service-automation` could not reach `packages/core/src/refd-timer-probe.testkit.ts`
at all. It moves to a private, test-only workspace package
`@objectstack/refd-timer-testkit`, reached by a bare specifier. The two
alternatives were measured, not argued: a relative import into another package's
`src` lands TS6059 in the consumer's own frozen type-check debt (3 -> 4, a
shrink-only ledger with no tolerance band), and publishing a subpath on
`@objectstack/core` would put a test helper on a runtime package's public npm
surface under semver.

Closes #10783
Closes #10785
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/qa/refd-timer-testkit/package.json, packages/qa/refd-timer-testkit/tsconfig.json) — pages documenting those are invisible to this run
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 58563be069a70d09c4d9cc5f522bb27c5f8a78b2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2c73bd691ecfe40874768437c04fea7aec1464a8 — the merge of head 46fa7102a1ebee531938685eaed7c03516b943aa into base 58563be069a70d09c4d9cc5f522bb27c5f8a78b2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2c73bd691ecfe40874768437c04fea7aec1464a8 && git checkout 2c73bd691ecfe40874768437c04fea7aec1464a8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 58563be069a70d09c4d9cc5f522bb27c5f8a78b2 46fa7102a1ebee531938685eaed7c03516b943aa && git checkout -B drift-repro 58563be069a70d09c4d9cc5f522bb27c5f8a78b2 && git merge --no-ff 46fa7102a1ebee531938685eaed7c03516b943aa

node scripts/docs-audit/affected-docs.mjs --json 58563be069a70d09c4d9cc5f522bb27c5f8a78b2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 21, 2026
@github-actions github-actions Bot added ci/cd dependencies Pull requests that update a dependency file tests tooling labels Aug 21, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 22, 2026 03:55
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32550304635 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 4.93s
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️ 本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 71 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit bcc5a27 Aug 22, 2026
36 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-10783-refd-timer-probe-and-gate branch August 22, 2026 04:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate tests tooling

Projects

None yet

2 participants