Skip to content

fix(service-package): refuse get() / list() over a seam that never answered - #11064

Merged
os-warren merged 3 commits into
mainfrom
claude/issue-10965-service-package-null-seam
Aug 22, 2026
Merged

fix(service-package): refuse get() / list() over a seam that never answered#11064
os-warren merged 3 commits into
mainfrom
claude/issue-10965-service-package-null-seam

Conversation

@os-warren

@os-warrenos-warren commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Fixes#10965

service-package answered "this package is not installed" / "no packages are installed" over a storage seam it never queried. Its local normalizeRows maps a non-answering seam onto zero rows, and both read paths hand that back as a product answer a caller acts on.

Third instance of one settled class — #10677 / PR #10788 (os migrate duplicates), #10789 / PR #10964 (backfillSeedTenancy): a seam that cannot ANSWER is absent, not empty. The reasoning is reused; the code is a local copy (below).


Step 1 — the measurement the card said it had not taken

The card established the conflation by reading, and named the boot path as unverified:

Whether the DevPlugin zero-install stack (a real InMemoryDriver, plugin-dev/src/dev-plugin.ts:473-482) reaches get()/list() at boot is unverified — I only established the conflation by reading, not by booting.

It was booted before anything was changed: a LiteKernel with the real ObjectQLPlugin and a real InMemoryDriver({ persistence: false }), with PackageServicePlugin.start() driven against that engine, execute instrumented to record every statement and its return. Measured on 2866d5f97:

typeof objectql.execute -> 'function' (the shape test passes)
objectql.registry.installPackage -> 'function' (so the hydration loop RUNS)
objectql.registry.getPackage -> 'function'
start() issued three statements, each answered null:
CREATE TABLE IF NOT EXISTS sys_packages … -> null
CREATE INDEX IF NOT EXISTS idx_packages_latest … -> null
SELECT * FROM sys_packages WHERE (id, created_at) IN ( … ) -> null <-- list()
list() -> [] ⇒ "no packages are installed"
get() -> null ⇒ "this package is not installed"

Answer: yes, the boot path reaches it.start()'s own sys_packages rehydration calls list(), the seam returns no result set, and the loop iterated zero times — silently, because its only log sits behind if (hydrated > 0).

Does anything WRITE on that reading? No.

Every consumer of the two methods was enumerated:

consumerreadseffect of the false answer
service-package/src/index.tsstart() boot rehydrationlist()hydration skip; the loop's only write (registry.installPackage) is per-row, so zero rows ⇒ zero writes
rest/src/package-routes.ts:644GET /api/v1/packageslist()read-only merge — database packages silently missing from the listing
rest/src/package-routes.ts:685GET /api/v1/packages/:idget()read-only — falls through to the registry, then 404

The three protocol paths that do write (metadata-protocol/src/protocol.tsinstallPackage :19496, updatePackage :19542, deletePackage :16473) call publish / deleteunconditionally — none is gated on this read, and publish is an ON CONFLICT … DO UPDATE upsert regardless.

So: no re-install, no destructive write. The blast radius is a silent boot hydration skip plus two false product answers on the HTTP read doors. That is above cosmetic — these are product values, and the skip was inaudible — and below the data-loss case the card flagged as the reason to triage. Reporting it plainly rather than leaving it implied: the severity comes down. The fix is still right; a method that reports an absence it never measured is wrong regardless.

The fix

Both reads establish that the seam answered before reading emptiness as a fact:

if(!isResultSet(result))throwpackageSeamUnreadableError();
  • isResultSet — the shapes normalizeRows accepts, asked as a yes/no. An empty result set in any spelling is still true.
  • The refusal — ADR-0112 envelope, codeandstatus: SERVICE_UNAVAILABLE / 503, the standard catalog's own pairing and the spelling metadata-protocol's metadataStoreUnavailableError already uses for exactly this condition (store unreachable ⇒ existence unknown). No new code is registered and nothing in packages/spec is touched. The message interpolates nothing — no driver text, no statement, no table name — and says the one thing a caller can act on: the answer is UNKNOWN, not "no".
  • The catchesget()/list() each swallow into null/[], so a bare throw would have been swallowed by the very catch the refusal exists to escape. Only the branded seam refusal is re-thrown. Deliberately not the existing declaresHttpAnswer: widening these two catches to re-throw every declared envelope would change how the service answers driver faults it has always swallowed — a behaviour change this card did not measure and does not need.
  • Boot hydration — the skip is now logged at warn, naming what is unknown. Boot still continues: an unreadable seam must not brick the environment.

The dialect shapes, enumerated FROM THE CODE

The card describes this as "the same three-dialect flattener metadata-protocol exports". Read against the source, that is not what this file has. Its local normalizeRows (:177-181) has exactly two accepting branches:

#shapedialectlocal normalizeRows
1bare row arraybetter-sqlite3 through knex, TursoArray.isArray(result)result
2{ rows, rowCount, … }pgArray.isArray(result.rows)result.rows
3[rows, fields] tuplemysql2not unwrapped — no Array.isArray(result[0]) branch

metadata-protocol's copy has all three. The doc comment's three bullets name only two distinct shapes (bullets 2 and 3 are both { rows }). The missing tuple unwrap is a separate defect of a different class (a seam that answered being misread, not one that could not answer), so it is filed as #11062 rather than fixed as a rider here. What this PR does pin is that a tuple-shaped result is still treated as an answer, so the new guard cannot misfire on a dialect it does not fully flatten — no false 503.

The safety property holds against the local flattener: every shape isResultSet rejects is one normalizeRows already maps to [], so no row can be lost. The only change is "refused as unreadable" replacing "reported as zero rows".

Why the predicate is a local copy

metadata-protocol deliberately does not publish its isResultSet from the package index, the CLI keeps a third for its own probes — and @objectstack/metadata-protocol is not a dependency of this package at all. Unifying the three is its own decision, not a rider on this fix; raised as an open question for the maintainer instead.

What is pinned — both directions

packages/services/service-package/src/null-seam.test.ts (28 cases) and packages/runtime/src/package-service.null-seam.test.ts (4 cases, on a real booted kernel + engine + plugin — see the census note below for why its driver is a local double):

  1. Non-answering seam is refused — all six spellings (null first: the measured InMemoryDriver return; plus undefined, an echoed statement, {}, { rows: 'not-an-array' }, a number) × get()/list(), asserting codeandstatusand the message. Never a bare toThrow(): an unfixed path throwing a plain Error would satisfy that and pin nothing.
  2. ⚠️ Still-empty, load-bearing — a real node:sqlite database running the real statements from index.ts: with SELECT COUNT(*) = 0 proven against the db itself, get() still returns null and list() still returns [], and no warn is emitted. An implementation that treated every empty result as a broken seam would score green on the refusal cases alone and break every legitimately-empty deployment. Beside it: a populated database still returns its rows, and an absent package beside a present one is still null.
  3. The flattener still flattens what it implements — bare array and { rows }, populated and empty, driven end-to-end through get()/list(); plus the tuple-shaped non-misfire above.
  4. Boot hydration — over a non-answering seam boot does not brick and now says the durable packages could not be read; over an answering seam with zero rows it hydrates nothing and says nothing; over an answering seam with a durable row (published, then a second plugin instance booted over the same database — a restart) it still hydrates it.

Ablation — signature predicted in writing FIRST

Prediction, written before mutating: delete the two if (!isResultSet(result)) throw packageSeamUnreadableError(); lines, leaving everything else. Direction: turns red.

predictedobserved
service-package suite15 red — the 13 refusal cases (6 shapes × get/list, + pinned version) with expected a refusal, but the call returned, the log-assertion case, and the boot "SAYS it could not read" case; describes 2 and 3 stay green15 failed | 48 passed (63) — exactly those 15, by name
runtime boot suite2 of 4 red — "boot … says the durable packages could not be read" and "get() and list() REFUSE"; the two cases asserting the composition stay green2 failed | 2 passed (4) — exactly those 2

Re-run after the census migration below, against the local double instead of the real driver: the same 2 of 4, by name. The double reproduces the failure the pin exists to catch.

Restored and proved byte-identical:

git hash-object … before ablation: 1314b679cff9af4fcb705cc88812b449c500994c
git hash-object … after restore: 1314b679cff9af4fcb705cc88812b449c500994c BYTE-IDENTICAL

…and the restore leg was then re-run to a real verdict rather than trusted on the hash: RESTORE-LEG service-package=0 runtime=0, 63/63 and 4/4.

src vs dist — argued from the files, and held falsifiable

Both suites read src, and this was not merely argued:

  • packages/services/service-package/src/null-seam.test.ts imports ./index.js — a relative import to its own sibling, which vitest resolves to src/index.ts. It never consults the package's exports.
  • packages/runtime/src/package-service.null-seam.test.ts imports the bare specifier, and packages/runtime/vitest.config.ts:110-112 declares an explicit alias to ../services/service-package/src/index.ts.
  • The falsifiable half: at the time both suites first ran green, packages/services/service-package/dist/ was present but stale — built at 13:33, before the edits — and grep -c PACKAGE_SEAM_UNREADABLE_MESSAGE dist/index.js returned 0 while src/index.ts returned 2. A suite reading dist would have imported undefined for that symbol and failed every message assertion; isResultSet would not have existed at all. Both suites passed against that stale dist on disk. dist was rebuilt afterwards, for the typechecks.

No ablation-dist-preflight run is claimed, and none applies: neither leg resolves the subject through exportsdist/, which is the condition that check exists for.

check:driver-memory-census — the runtime pin was MIGRATED off the frozen driver, not ledgered

The first version of the runtime pin booted the real InMemoryDriver, which made it a third consumer of a package whose investment is frozen (#5499). check:driver-memory-census refused it — correctly, and it is the one gate the local derivation cannot reach (its declared population is the package specifier @objectstack/driver-memory, not a tracked path, so the script itself lists it under unreachable by construction: it scores the same quiet green for every card in the tree). CI caught what the derived union structurally could not.

Disposition taken: migrate. Adding a ledger entry was refused for the same reason the check:slot-lookup baseline was left alone earlier in this PR. The evidence that the real driver was not load-bearing, established before migrating:

The kernel, the ObjectQL engine and registry, and PackageServicePlugin.start() all stay real; only the seam's non-answer is doubled. ⚠️The bound, stated in the file itself: this pin is not evidence about @objectstack/driver-memory and would not notice if that driver changed. It models the shape — a seam that accepts a statement and returns no result set — which is the only property the guard keys on, since the implementation judges by return value and never by driver identity.

Census verdict, before and after:

before: 13 module binding(s) in 13 file(s) — x LEDGERED: packages/runtime/src/package-service.null-seam.test.ts:42
binds @objectstack/driver-memory (import) and the ledger does not cover it. exit 1
after: 12 module binding(s) in 12 file(s), 2 ruled test consumer(s) exit 0
"check-driver-memory-census: OK — every declaration is ledgered, every ledger entry is
live, and every ruled file states \"#6664 census: 2 ruled consumers\"."

The ledger is byte-unchanged and the ruled set is still 2. Thirteen bindings became twelve — a migration, not bookkeeping.

Zero-hit counter-check — positive control FIRST

Before reading any silence as evidence, the instrument was proved on the corpus. The consumer sweep grep -rn "get('package')|getService('package')|getService<…>('package')" --include=*.ts packages/ apps/ was run and did return the known consumers (rest/src/direct-mount-composition.ts:110, metadata-protocol/src/protocol.ts:16473, :19496, :19542, objectql/src/registry.ts). Only then was the absence of any write gated on get()/list() read as a fact — and each surviving hit was opened and read rather than counted. No grep hit count is reported here as a fact count; the table above comes from reading all four call sites.

Gates

Derived on the final commit 5820d0922, clean tree, node scripts/pm/dispatch-gates.mjs with no path arguments (the script takes the change set from the merge base itself). Class #10309 is live and was live here: the dispatch prompt named no gate family for this card at all. The derivation named 13 path-matched plus 5 convention-triggered; all 18 were run explicitly and each verdict below is the gate's own printed line, with exit codes captured before any pipe.

gateverdict
check:changeset-gate-self-testsexit 0
check:cross-package-test-inputsexit 0
check:objectui-changesetexit 0
check:slot-lookup✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new
check:test-source-aliasexit 0
check:type-source-resolutionexit 0
check-adr-0087-registration.mjsexit 0
check-changeset-no-major.mjsexit 0
check-ci-filter-parity.mjsexit 0
check-cross-package-test-inputs.mjsexit 0
check-empty-changeset.mjsexit 0
check-plugin-teardown-shape.mjsexit 0
docs-audit/check-affected-docs.mjsexit 0
check:query-options-erasure✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new
check:engine-double-contractOK — 377 pinned, 133 in the DEBT ledger, 2 exempt
check:where-matcher✓ where-matcher conformance holds: 276 matcher(s) discovered … none new
check:type-check-coverageOK — 65/78 workspace packages type-checked (plus the root)
check:type-check-debt--re-measure: OK — 33 ledger entr(ies) re-measured in 246.7s, 1908 raw tsc error(s) total, none above its recorded number
check-nul-bytes.mjsOK (scanned 6392 text file(s) … no raw ASCII control bytes)
check-driver-memory-census.mjsOK — every declaration is ledgered, every ledger entry is live, and every ruled file states "#6664 census: 2 ruled consumers"

check:slot-lookupfailed first — an any type parameter on the getService call in the new runtime pin was a NEW service-lookup erasure (#4251, the baseline never grows). Fixed by declaring the slot's contract shape, not by touching the baseline.

check:type-check-debtfirst refused: --re-measure cannot run: 27 workspace dependenc(ies) … have no built type entry point on disk. That refusal is NOT MEASURED, never a pass — the closure was built (turbo run build --filter='./packages/*' --filter='./packages/*/*', 70/70 successful) and the gate re-run to the real verdict above. Its ℹ @objectstack/plugin-auth: TEST_DEBT records 109, tsc now reports 97 (-12) surplus is pre-existing and in a package this PR does not touch; not lowered here.

Tests, same commit, exit codes captured before any pipe:

pnpm --filter @objectstack/service-package exec vitest run --maxWorkers=2 -> SP_TEST=0 Test Files 4 passed (4) · Tests 63 passed (63)
pnpm --filter @objectstack/runtime exec vitest run … null-seam.test.ts -> RT_TEST=0 Test Files 1 passed (1) · Tests 4 passed (4)
pnpm --filter @objectstack/service-package typecheck -> SP_TC=0 (echoed `tsc --noEmit`)
pnpm --filter @objectstack/runtime typecheck -> RT_TC=0 (echoed `tsc --noEmit`)

Declared narrowings

  • No repo-wide pnpm test / pnpm typecheck. Only the two affected packages were run, plus the 18 derived gate families. CI runs the farm regardless.
  • No downstream consumer typecheck sweep beyond runtime. This PR changes no exported type signature — it only adds one export (PACKAGE_SEAM_UNREADABLE_MESSAGE), which cannot break a consumer's types, and the behavioural change (a throw) is invisible to tsc. rest and cli were not typechecked locally.
  • No real driver is asserted anywhere, deliberately — not the mongodb driver, and since the census migration not the in-memory one either: this suite loads neither, and an assertion about a backend it never loads would be a false pin.

Scope

Two findings are filed and stay open — out of scope: #11062 (the mysql2 tuple gap) and #11063 (the door that swallows the new refusal). Neither is addressed here. Nothing under content/docs/releases/** or packages/spec is touched; the refusal reuses an existing standard-catalog code precisely so no spec edit is needed.

Left for review, NOT decided here


Generated by Claude Code

`get()` returned `null` and `list()` returned `[]` over a storage seam that
accepted the query and ran nothing. `InMemoryDriver.execute()` logs
`Raw execution not supported in InMemory driver` and returns `null`; the local
`normalizeRows` maps that to `[]`, which is also what a working driver returns
for a SELECT that matched nothing. Both read paths reported that emptiness as a
product answer callers act on, and `start()`'s own `sys_packages` rehydration
skipped in silence because of it.
Reads now establish the seam ANSWERED before reading emptiness as a fact: a
result that is not a result set is refused with a declared ADR-0112 envelope
(`SERVICE_UNAVAILABLE` / 503), and the skipped boot rehydration is logged at
warn. A seam that answers with genuinely zero rows is unchanged.
Third instance of one class (#10677 / PR #10788, #10789 / PR #10964): a seam
that cannot answer is absent, not empty. The predicate is a local copy —
`metadata-protocol` deliberately does not publish its own, and this package
does not depend on it.
Part of #10965
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
… `any`
`check:slot-lookup` flagged the new null-seam pin: `getService<any>('objectql')`
is a service-lookup erasure, and the baseline never grows (#4251). The slot's
shape is now spelled out — the raw-SQL seam plus the registry half whose
presence is what lets `start()`'s hydration loop run.
Part of #10965
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
@github-actions

github-actionsBot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 1 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 4257e4e4e385bdd098424850dc57337cdfa1de8cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from ebc6dbf992b1f9194045fe9d086c9f8cc6a48211 — the merge of head 5820d092226f5d7dce1818f7c35ef00616fd70b2 into base 4257e4e4e385bdd098424850dc57337cdfa1de8c, 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 ebc6dbf992b1f9194045fe9d086c9f8cc6a48211 && git checkout ebc6dbf992b1f9194045fe9d086c9f8cc6a48211
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4257e4e4e385bdd098424850dc57337cdfa1de8c 5820d092226f5d7dce1818f7c35ef00616fd70b2 && git checkout -B drift-repro 4257e4e4e385bdd098424850dc57337cdfa1de8c && git merge --no-ff 5820d092226f5d7dce1818f7c35ef00616fd70b2
node scripts/docs-audit/affected-docs.mjs --json 4257e4e4e385bdd098424850dc57337cdfa1de8c

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

… package
`check:driver-memory-census` refused this file as a THIRD consumer of a
package whose investment is frozen (#5499), arriving after #5704 migrated the
test backends and #6664 replaced the prose census with a ledger.
Disposition taken: MIGRATE, not ledger. The two ruled-permanent consumers are
kept because nothing can stand in for them — one needs the schemaless arm of a
divergence pin, the other a driver whose `supports = {}` hands autonumber
seeding back to the engine. This file needs neither: it needs a seam whose
`execute()` returns without answering, which is one return value rather than a
capability profile, and `protocol-recorded-by-null.test.ts` already models
exactly that with a local `makeStubDriver` — the convention #5704/#5784
established so a grep for the driver lands on real consumers only.
The kernel, the ObjectQL engine and registry, and PackageServicePlugin.start()
all stay real; only the seam's non-answer is doubled. What the double does NOT
model is stated in the file: it is not evidence about driver-memory, whose
null-return stays pinned on a real boot by the CLI sibling (#10677).
Ledger untouched; ruled set still 2.
Part of #10965
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx
@os-warren
os-warren added this pull request to the merge queueAug 22, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — round 2 rework: ACCEPTED

Reviewed against the diff on head 5820d092, not against the report.

The disposition held: migrate, not ledger

The round-1 failure was check:driver-memory-census — the new runtime pin bound @objectstack/driver-memory, arriving as a third consumer of an investment-frozen driver (#5499 froze it; #5704 and #6664 ruled the two that stay). The gate's own text forbids the tempting fix, in these terms: a new arrival is not a bookkeeping chore, it is the #6664 defect itself; "is this consumer legitimate?" is a maintainer ruling, and the gate is the bookkeeping under it, not a second opinion on it. I sent it back with the disposition migrate to a local double — do not ledger, and required proof rather than assertion.

The counts say which one happened:

BEFORE exit 1 13 module binding(s) in 13 file(s) — 2 ruled test consumer(s)
AFTER exit 0 12 module binding(s) in 12 file(s) — 2 ruled test consumer(s)

13 bindings to 12, the ruled set still 2, the ledger byte-unchanged. A migration leaves exactly that signature; bookkeeping would have left the ruled set at 3 and the ledger modified. This is the discriminator I checked, and it is the reason the fix is accepted rather than merely green.

Grounds established before the migration, not after

Four, in the order that matters:

  1. Both ruled-permanent entries are kept for a reason this pin does not share — their discriminator is a capability profile (one needs the schemaless arm of L2 hook body 写集不透明:把「写了目标对象上不存在的字段」从 accepted gap 变成作者时 lint 告警 #4271; the other a driver whose supports is empty, handing autonumber seeding back to the engine). This pin needs neither.
  2. The repo already models the shape locally: makeStubDriver in packages/objectql/src/protocol-recorded-by-null.test.ts.
  3. engine.execute delegates straight to driver.execute after checking only that the method exists (packages/objectql/src/engine.ts:11687) — which is the very half of the defect that made the conflation invisible in the first place.
  4. The real driver's null-return stays pinned on a real boot by packages/cli/src/commands/migrate/duplicates.null-seam.test.ts (migrate duplicates: no_sql_seam loud-refusal is dead code for the memory driver (false all-clear) #10677), so the migration does not vacate that coverage.

The ablation wrote down its own falsification condition

This is the part that makes the proof worth something. Before running it, the seat stated what would refute it:

FEWER cases reddening would mean the double is weaker and the migration must become a needs_decision.

It then observed the same 2 of 4 runtime cases reddening, by name, identical to the real-driver version. A double that cannot reproduce the failure is not a substitute — it is a weaker test wearing the same name — and this one reproduces it. The bound of what the double does not model is stated in the test file's own header rather than left implicit.

Class #10309 — a second structurally-unreachable gate

The seat contributed the finding that check:driver-memory-census is listed by the derivation itself as unreachable by construction: its population is the package specifier@objectstack/driver-memory, not a tracked path, so a path-derived union can never reach it. That makes two gates in this class, alongside check:optional-error-sink, which computes its own population and scores silent for every card.

Recording the seat's own correction verbatim, because it is the right shape:

My round-1 report quoted that line without drawing the consequence.

Quoting a line and drawing its consequence are different acts, and the gap between them is where this class of miss lives.

CI — verified by job name, and a correction to my own earlier reading

All 34 check runs on 5820d092 are complete: every one success, except Build Docs and Console Pin Gate which are skipped. The gate that was red is green at step granularity: Lint & Repo Gates step 91, driver-memory census gate, conclusion success.

⚠️ An earlier reading of mine was premature and I am correcting it here rather than quietly. I ran a failed-jobs query against run 32583124328 and read back zero failures — but Lint & Repo Gates was still in_progress at that moment. Zero failures on an unfinished run is not green; it is the same not-measured-versus-passed confusion this lane has already paid for elsewhere. The verification that counts is the one above: named job, named step, terminal conclusion.

Auto-merge stays armed; nothing further is required from the seat.


Generated by Claude Code

Merged via the queue into main with commit ab47f69Aug 22, 2026
35 checks passed
@os-warren
os-warren deleted the claude/issue-10965-service-package-null-seam branch August 22, 2026 16:13
@github-actions

Copy link
Copy Markdown
Contributor

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

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

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

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

    ✗ Build failed in 6.01s
    

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

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

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

历史信号:

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

分诊清单:

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

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

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-warren@claude