Skip to content

fix(rest): pay ONE kernel-waiter window per request — optional KernelResolver.resolveEnvironment - #11085

Merged
os-elon merged 2 commits into
mainfrom
claude/issue-10988-kernel-resolver-resolve-environment
Aug 22, 2026
Merged

fix(rest): pay ONE kernel-waiter window per request — optional KernelResolver.resolveEnvironment#11085
os-elon merged 2 commits into
mainfrom
claude/issue-10988-kernel-resolver-resolve-environment

Conversation

@os-elon

@os-elonos-elon commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Fixes#10988

Adopts the maintainer's Option A ruling (2026-08-22, 「同意所有」item 6 = A; recorded verbatim as 「10988 A」): add the optional environment-only capability to the ADR-0006 KernelResolver contract, have rest-api-plugin's wrapper prefer it, and leave resolveProtocol as the single kernel-acquisition point — fail-closed preserved, waiterTimeoutMs untouched.

Two spellings in this body were rewritten after the first save: GitHub's body sanitizer eats short angle-bracket fragments, including inside backticks, so Promise + angle-bracketed void and a <path> placeholder both vanished on the way in. They are spelled without angle brackets below.

The defect, restated from the mechanism

RestApiPlugin wraps the host's kernel-resolver so RestServer can ask "which environment is this request in?". It asked resolveKernel — a kernel-acquisition api — and kept only context.environmentId. A real host resolver writes the id and then awaits that environment's kernel, so the wrapper paid a full waiter window and discarded what it bought; resolveProtocol then acquired the kernel again. Free on a warm environment (a cache hit — which is why it stayed invisible for so long), a second serial wait on a cold or wedged one.

What changed

filechange
packages/runtime/src/http-dispatcher.tsKernelResolver gains an optional resolveEnvironment?(context, defaultKernel), returning void or a promise of void — resolve the environment onto context, acquire no kernel. Optional, so no flag day.
packages/rest/src/rest-api-plugin.tsthe wrapper prefers resolveEnvironment when the host implements it, else calls resolveKernel exactly as before.
packages/rest/src/rest-server.tsdoc only: RestRequestEnvResolver states the cost contract; resolveProtocol is named as THE single acquisition point and its fail-closed obligation is written down. No behaviour change.
packages/rest/src/rest-resolve-environment-single-window.test.tsnew — the acquisition-count pins, driven through the real plugin.
packages/runtime/src/http-dispatcher.kernel-resolver.test.tsnew pins: the dispatcher keeps acquiring through resolveKernel even when the host implements both.
.changeset/kernel-resolver-resolve-environment.md@objectstack/runtime minor (new optional member on an exported contract), @objectstack/rest patch.

No resolveKernel fallback when resolveEnvironment leaves the id unset. An unset id is the seam's FINAL answer for an unscoped / control-plane request (RestRequestEnvResolver's existing contract), and "retry with the expensive method" would re-buy exactly the window this prefers away, on the requests that need no environment at all. Pinned.

What the tests pin — and why "it answered" would have been vacuous

The card already measured the naive fix (catch the resolver's throw, keep the id it wrote) at 2.38x/2.02x with two acquisitions: the window is spent inside the resolver call before any id is returned. So "the request succeeded" / "an env id came back" passes on today's code, on the naive fix, and on this one. Every assertion counts the mechanismgetOrCreate calls per request:

  1. One acquisition/api/v1/discovery and /api/v1/data/:object each make exactly one getOrCreate when the resolver implements resolveEnvironment (expect(km.acquisitions).toEqual([ENV])).
  2. Two without it, exercised not assumed — the same two routes still make two, in the production shape: window Add metamodel interfaces for ObjectQL/ObjectUI contract #1 is the resolver's own kernel await, whose rejection resolveRequestEnvironmentId swallows; the legacy hostname chain then supplies the same id and resolveProtocol opens window ✨ Set up Copilot instructions #2. The harness wires that legacy chain deliberately — without it the back-compat path degrades to the control-plane protocol instead, a different and worse shape.
  3. Fail-closed survives — the surviving getOrCreate still rejects, the caller still gets 503 / SERVICE_UNAVAILABLE (the pair carried by cloud's kernel_warming), and the host protocol answers neither route. Asserted, not argued.

Plus: the warm path drops the discarded acquisition too (/discovery resolves the environment twice — protocol, then the mcp probe — so 4 acquisitions become 2), the resolved environment is unchanged (the document is served from the environment kernel, never the control plane), and the dispatcher is pinned NOT to take the cheap door, since it serves the request from the kernel it resolves.

Reverse verification

Each pin was broken deliberately, with the direction predicted before the run. All four legs ran from a committed state and were restored with git checkout HEAD -- PATH (never git stash); git status --porcelain was empty after each restore, and the suites are green again on c567b8bd9d.

legmutationpredictedobserved
Awrapper always calls resolveKernel (the fix removed)RED on the one-window pinsRED — 4 failed / 4 passed. expected [ 'env_probe', 'env_probe' ] to deeply equal [ 'env_probe' ]. The fail-closed pin stayed GREEN, correctly: two windows still 503.
BresolveProtocol swallows the acquisition rejection (fail OPEN)RED on the fail-closed pinRED — 5 failed / 3 passed. Fail-closed leg: expected undefined to be 503 — the route was served instead of refused.
Cthe optional member called without its capability guardRED on the back-compat pinsRED — 3 failed / 5 passed. expected [ 'env_probe' ] to deeply equal [ 'env_probe', 'env_probe' ] — note the direction: fewer acquisitions, which reads as an improvement while actually meaning the host resolver was never consulted at all (a TypeError swallowed into the legacy chain).
Ddispatcher prefers resolveEnvironmentRED on the dispatcher pinRED — 1 failed / 5 passed. expected "vi.fn()" to be called 1 times, but got 0 times, i.e. context.kernel left on defaultKernel.

No ablation was involved: both test files import their subject through in-package relative specifiers, so vitest reads src, not dist.

The four sibling call sites, re-checked in the same pass

resolveEndpointMatchAuthority (~934), resolveMetadataService (~971), resolveI18nService (~1141), probeMcpServeable (~3107) — no change needed, and the reason is structural. Each already routes through the shared resolveRequestEnvironmentId entry point and then acquires one kernel for the service slot it actually needs (metadata, i18n, mcp). None of them buys a kernel it discards; they inherited the waste from the entry point's cost, so they are fixed by this change without an edit. That is measured rather than asserted: the warm /discovery pin shows 4 acquisitions collapsing to 2, and the two that remain are resolveProtocol's and probeMcpServeable's own — i.e. the probe shed its discarded one.

The framework's two other resolveKernel consumers were re-checked for the same shape and genuinely need the kernel, so both keep calling resolveKernel: HttpDispatcher.resolveProjectKernelObjectQL (resolves objectql off the returned kernel) and plugin-hono-server's resolveRequestContext (serves identity from the per-environment kernel's services). Leg D pins the dispatcher against the "prefer the cheap method everywhere" follow-up edit.

Clause-② path limb — measured, and the naive spelling misleads

The requested command reports a spec path, and it is a false positive:

$ git diff --name-only origin/main | grep '^packages/spec/src/'
packages/spec/src/contracts/data-driver.ts

Two-dot git diff origin/main compares origin/main's current tip tree with mine, so every sibling PR that landed after this branch was cut is attributed to it — origin/main moved 1c3a46f87603bdd148ee during this task, and that file is another PR's. Measured against the merge base, the intersection is empty:

$ git merge-base origin/main HEAD
1c3a46f876822d488d3af43189dbc33273b28015
$ git diff --name-only origin/main...HEAD | grep '^packages/spec/src/'
(none)
$ git diff --name-only origin/main...HEAD
.changeset/kernel-resolver-resolve-environment.md
packages/rest/src/rest-api-plugin.ts
packages/rest/src/rest-resolve-environment-single-window.test.ts
packages/rest/src/rest-server.ts
packages/runtime/src/http-dispatcher.kernel-resolver.test.ts
packages/runtime/src/http-dispatcher.ts

scripts/pm/dispatch-gates.mjs derives the identical 6 paths ("three-dot semantics, never 'origin/main..HEAD'"). Clause-② path limb: NO — this PR touches nothing under packages/spec/src/**.

Governed surfaces — not touched, and none needed

docs/adr/** is untouched, and no ADR edit is owed: ADR-0006's prose never names the resolver interface at all (grep -in "resolver" docs/adr/0006*.md → 0 matches across all three files; grep -c kernel → 0/1/0). The contract lives in the code, and the new member is documented there. No content/docs/releases/**, skills/**, .claude/**, AGENTS.md or CLAUDE.md edit. Nothing outside the declared file surface was repaired.

Verification — all on c567b8bd9d, the head of this PR

Heavy runs went through scripts/pm/os-verify-lock.sh; every exit code was captured before any pipe.

  • pnpm --filter @objectstack/rest testEXIT=0, Test Files 135 passed (135), Tests 2187 passed (2187)
  • pnpm --filter @objectstack/runtime testEXIT=0, Test Files 181 passed (181), Tests 2694 passed (2694)
  • pnpm --filter @objectstack/rest --filter @objectstack/runtime typecheckEXIT=0 (both echoed tsc --noEmit, so this is not a zero-match silent pass)
  • The 16 families node scripts/pm/dispatch-gates.mjs named (no path arguments — it derives the change set itself), plus check:nul-bytes and the 5 convention-triggered families for a diff that adds test files. 22 gates, all EXIT=0, re-run in full on this head. Verdict lines, quoted:
    • check-nul-bytes: OK (scanned 6402 text file(s) ... no raw ASCII control bytes).
    • check-engine-double-contract: OK — 377 pinned, 133 in the DEBT ledger, 2 exempt.
    • ✓ where-matcher conformance holds: 277 matcher(s) discovered ... 0 silently-wrong and 0 unjudged
    • check-test-source-alias OK — 72 packages with tests scanned
    • ✓ check:authz-resolver: single shared authorization resolver intact; both entry points delegate.
    • ✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).
    • ✓ This diff introduces no 'major' bump.
    • check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 254.2s, 1907 raw tsc error(s) total, none above its recorded number.

One ratchet fired and was fixed at the source, never in the ledger. The first run of check:type-check-debt --re-measure reported @objectstack/rest: TEST_DEBT records 155 raw tsc error(s), tsc --noEmit now reports 158 (+3) — the new test file's 3 errors (one TS2835 extensionless relative import under nodenext, two TS2550 for Array.prototype.at, which is outside this program's lib target). Both were fixed in the test file; re-measured at exactly 155, with 0 from this file. No threshold moved, no baseline raised, no test skipped or quarantined.

Left to the accepting seat

The cloud-side implementation card — implement resolveEnvironment in cloud's kernel-resolver plus the .objectstack-sha bump, reopening objectstack-ai/cloud#1548 — is filed by the accepting seat on landing, per the ruling. objectstack-ai/cloud is outside this session's repo scope and no attempt was made to file there. Until cloud implements it, existing resolvers keep working and keep paying twice on cold builds, by design.

CI is not awaited — the standing ruling is that report delivery happens at draft-PR time. Every conclusion above is a local measurement on c567b8bd9d; the CI gate farm (all 136 families, including the ~120 that no path derivation can narrow) is still in flight at the time of writing.


Generated by Claude Code

os-elonand others added 2 commits August 22, 2026 15:30
…solveEnvironment
A REST request on a multi-tenant host paid the host's kernel-waiter window
twice. `RestApiPlugin`'s wrapper asked `resolveKernel` — a kernel-acquisition
api — for an environment id and discarded the kernel it bought; `resolveProtocol`
then acquired it again. Free on a warm environment, a second serial wait on a
cold or wedged one: measured 42s to a 503 on REST-owned routes against 21s on
dispatcher-owned ones (`waiterTimeoutMs: 20s`).
Adds the optional `KernelResolver.resolveEnvironment?(context, defaultKernel)`
to the ADR-0006 contract — resolve the environment, acquire no kernel — and has
the REST wrapper prefer it, leaving `resolveProtocol` as the single acquisition
point. `?.`-optional, so every existing resolver keeps working unchanged.
Fail-closed preserved and pinned: the surviving `getOrCreate` still rejects and
the caller still gets the declared 503. `waiterTimeoutMs` untouched.
Fixes#10988
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r
`check:type-check-debt --re-measure` caught the new test file adding 3 raw
errors to the `@objectstack/rest` TEST_DEBT entry (155 -> 158): one TS2835
(extensionless relative import under nodenext) and two TS2550 (`Array.at` is
not in this program's lib target). Fixed at the source — the ledger is a
shrink-only ratchet and is untouched. Re-measured: 155, zero from this file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/rest, @objectstack/runtime, touching 7 documentable anchor(s).

12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/connect-mcp.mdx(via /api/v1/discovery (route))
  • content/docs/api/client-sdk.mdx(via data.create (sdk), data.find (sdk), /api/v1/discovery (route))
  • content/docs/api/data-flow.mdx(via data.create (sdk))
  • content/docs/api/environment-routing.mdx(via KernelResolver (symbol), data.find (sdk))
  • content/docs/api/error-catalog.mdx(via data.create (sdk))
  • content/docs/api/index.mdx(via /api/v1/discovery (route))
  • content/docs/deployment/troubleshooting.mdx(via data.find (sdk))
  • content/docs/kernel/runtime-services/data-service.mdx(via data.create (sdk), data.find (sdk))
  • content/docs/kernel/runtime-services/storage-service.mdx(via /api/v1/discovery (route))
  • content/docs/permissions/authentication.mdx(via createRestApiPlugin (symbol), data.find (sdk))
  • content/docs/plugins/packages.mdx(via createRestApiPlugin (symbol))
  • content/docs/protocol/kernel/http-protocol.mdx(via /api/v1/discovery (route))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/implementation-status.mdx(via RestServer (symbol), /api/v1/discovery (route))
  • content/docs/releases/v12.mdx(via RestServer (symbol))
  • content/docs/releases/v16.mdx(via RestServer (symbol), data.create (sdk))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: /api/v1/data/:object (route, 29 pages)
  • 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 — 28 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 ab47f6974a275586355f06abd83a0975b8637d12packageMentionDocs.

Which tree this was computed on

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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ab47f6974a275586355f06abd83a0975b8637d12 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 22, 2026
@os-elon
os-elon marked this pull request as ready for review August 22, 2026 16:53
@os-elon
os-elon added this pull request to the merge queueAug 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

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

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

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

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

    ✗ Build failed in 6.00s
    

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

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

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

历史信号:

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

分诊清单:

  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 5b39785Aug 22, 2026
35 checks passed
@os-elon
os-elon deleted the claude/issue-10988-kernel-resolver-resolve-environment branch August 22, 2026 18:15
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

1 participant

@os-elon