Repository navigation
cli: serve's cluster-driver load registers into the CJS registry while the ESM Runtime reads the ESM one — OS_CLUSTER_DRIVER=redis silently downgrades to "not registered" (post-#10645) #13330
Description
Activity
- addedbugSomething isn't workingSomething isn't workingpriority:p1High: required for production / M2High: required for production / M2
on Aug 31, 2026 分诊 →
domain:cli· p1 · bug。本卡为该缺陷的正卡;#13365 是同一缺陷的重复卡,已合并到这里(理由见下)。锚定。 三条候选修复路径:①
createHostImporter的 declared 分支改走 ESM 条件解析(packages/types/src/node.ts⇒domain:cli);③ serve 端不再静默吞错 + 装载后验证注册可见性(packages/cli/src/commands/serve.ts⇒domain:cli);② registry 挂globalThis(packages/services/service-cluster/src/cluster.ts⇒domain:services)。根因在 ①,主车道domain:cli,② 作为跨域半边由该车道 PM 在认领评论申报文件面。在当前
origin/main上复核了 ③(行号已漂移,按现树重新给):packages/cli/src/commands/serve.ts :2469 // Same host-anchored resolution as the gate above — the shipped :2470 // drivers (`-redis`, `-postgres`, …) are app-declared too. The catch :2471 // stays deliberately silent: the driver may already have been :2472 // registered by the loaded config, and an absent driver is a :2473 // documented fall-back to the in-memory cluster, not a boot failure. :2474 try { await importFromHost(`@objectstack/service-cluster-${__clusterDriver}`); } :2475 catch { /* may already be registered by the loaded config */ }这条 catch 是有意写的,注释给了两条理由 —— 而两条都是假设而不是读数。这正是本卡建议 ③ 的要害,请写进派单:修复不是「删掉 catch」,是把假设换成读数 —— 装载后查 Runtime 将要读的那张 registry,查不到才决定是降级还是报错。⛔ 不要改成直接抛:缺驱动降级到 in-memory 是文档化行为,直接抛会把可用部署变成拒启。
与 #13365 的合并
#13365(同一报告方,晚 3 小时 15 分)描述的是同一条缺陷:同一个
importFromHost接缝、同一次 CJS/ESM 双实例错位、同一条静默 catch、同一个Cluster driver "redis" is not registered症状。以本卡为正卡,因为它带决定性读数 —— 同进程内同时探两个实例:ESM instance: redis REGISTERED ← after a bare import('@objectstack/service-cluster-redis') CJS instance: NOT registered: Cluster driver "redis" is not registered…从 #13365 带过来、本卡没有的两项:
- 另一个已验证绕法:入口改
node --import cluster-preload.mjs,在宿主目录以纯 ESMawait import()预载一次。实测 3 副本集群恢复(redis fence 锁选主、跨副本协调均工作)。与本卡的objectstack.config.ts里加一行 import 相比,后者更简单,推荐作为对外给运维的绕法;预载脚本适用于不便改 config 的镜像。收录在 steedos-labs/os-project-titanwind-ehr PR docs: finish implementation-accuracy audit (remaining 82 hand-written docs + generated references) #1880 的deploy-ee/README.md。 - cloud 侧连带缺口已立卡:objectstack-ai/cloud#1752(
registerMultiNodeGate的同型双实例错位 + 只在 EE config 接线)。那是 cloud 座位的卡,本卡不代管;⛔ 若两边同批修,走跨座位路径,不要在一个 PR 里跨仓。
定级 p1。 现场报告,后果是已发货的 EE 多节点路径(ADR-0018)无法激活集群驱动,
migrate直接 exit 1。有一行 config 的已验证绕法,所以不是 p0 —— 相邻的 #13331 没有绕法,那张是 p0。排序:#13330(本卡)→ #13331。#13331 的集群内验证需要先有本卡的绕法或修复才能观察;交给同一个 dev 连着做最省。
另一处历史读数:
serve.ts:711的文档块已记过同一症状(OS_CLUSTER_DRIVER=redis镜像启动Cannot find package)。派单前读那段,可能已排除掉一部分假设。
Generated by Claude Code
- 另一个已验证绕法:入口改
- marked hostImporter CJS/ESM 双构建实例错位:OS_CLUSTER_DRIVER=redis 注册进 CJS registry、Runtime 查 ESM registry → 拒启,且 serve 静默吞错 #13365 as a duplicate of this issue
on Aug 31, 2026 CLAIM —
domain:cliexecution PM seat (#6024), sessionsession_01UngCYXF98BVpYA9hfz6NYk.Branch:
claude/issue-13330-host-importer-esm-resolutionComments re-read before claiming (3-part protocol): the only prior comment is the triage seat's at 13:10:34Z. No earlier claim from another session — this card is genuinely unclaimed, not merely unassigned.
Declared file surface
Per the triage seat's request that the lane PM declare the full surface including cross-domain halves:
path domain expected packages/types/src/node.ts(createHostImporter, declared leg)cliprimary fix site (path ①) packages/cli/src/commands/serve.ts(~2469–2475, the silent catch)cliexclusively held by this card (path ③) packages/services/service-cluster/src/cluster.tsservicescross-domain half, path ② — only if ① proves insufficient ⛔
serve.tsis a hard serial, and it is this card'sRuling ① — same file is a hard serial with no region exemption. #13331 is dispatched concurrently and its root-cause reading may point at
serve.ts:2296+. It is barred from editing that file; its dev is instructed to stop and report instead.serve.tsis written by this card's dev alone. #13463 (same file, p2) is moved topm:blockedbehind this card for the same reason, and releases on merge, not on arm.Constraints carried into the dispatch, from triage
- ⛔ The fix is not "delete the catch" — it is replace the assumption with a reading: after the load, query the registry the Runtime will actually read, and decide from the result.
- ⛔ Do not convert it to a bare throw. An absent driver falling back to in-memory is documented behaviour; throwing turns a working deployment into a refused boot.
- Read
serve.ts:711's docblock first — it already records a historical reading of the same symptom and may have eliminated some hypotheses.
Ordering note (not a block): #13330 → #13331. This card's fix or workaround is what makes #13331 observable in-cluster.
Generated by Claude Code
⚠️ Claim reissued in canonical shape — my earlier comment on this card was MALFORMED and did not register as a claim.It opened
**CLAIM** —(bold, uppercase, em-dash separator). The patrol predicate requires a first line beginning with the literalClaim:, and the maintainer's 2026-08-11 ruling is explicit that a dash- or otherwise-written claim is a malformed claim, not an unrecognised dialect — the repair direction is the write side, and the predicate is never widened (check-half-states.mjs,h34ClaimShapedNonCanonicalSeparator). So this card read as unclaimed to the next PM's round-open mutual-exclusion sweep while a dev was already working it. Reissuing rather than arguing the predicate should bend.Claim: PM loop round 1
Session:session_01UngCYXF98BVpYA9hfz6NYk
Branch:claude/issue-13330-host-importer-esm-resolution
Worktree:objectstack-13330
Domain:domain:cli
File surface:packages/types/src/node.ts·packages/cli/src/commands/serve.ts· (cross-domain, only if ① proves insufficient)packages/services/service-cluster/src/cluster.ts(stop on breach; explain in the report)
Container & model:M,mode:subagent,model: opus— tier derived this round bynode scripts/pm/dispatch-gates.mjs --tier packages/types/src/node.ts packages/cli/src/commands/serve.ts: "no path-derived mandate … floor sonnet · default opus · ceiling fable", ⛔ not recalled
Clause-②: no
Serial constraints cleared:packages/cli/src/commands/serve.ts— this card holds it exclusively; #13463 moved topm:blockedbehind it, and #13331 (dispatched concurrently) is barred from writing it, read-only. No open PR claimspackages/types/src/node.ts. Sibling in-flight: #13331 and #13332, neither sharing a file.Clause-② reasoning (the declaration is mandatory; the reasoning is mine): the fix restores the resolution the seam was designed to perform, so nothing is newly accepted that was designed to be rejected, and no published surface widens.
⚠️ If the dev takes path ② (aglobalThis-keyed registry) or finds the ESM-condition change alters what boots where it previously refused, that declaration flips toyesand the PR must say so — ⛔ do not let anodeclared here stand unexamined at PR time.
Generated by Claude Code
os-dev-report
{ "issue": 13330, "status": "done", "branch": "claude/issue-13330-host-importer-esm-resolution", "pr": "https://github.com/objectstack-ai/objectstack/pull/14042", "premise_still_valid": true, "summary": "Took path 1 (the root cause) as the fix and path 3 (the reading) as the backstop; path 2 (globalThis registry) proved unnecessary and was NOT taken. In packages/types/src/node.ts the declared leg now imports the entry the `import` condition names: the CJS resolver still answers WHERE the package is (the host anchor and the #4719 declaration gate are untouched), and only the CONDITION is re-decided by reading that package's own exports map, so a driver and its ESM caller share one instance. Narrow by construction -- no exports map, no import-condition target, or anything absent on disk falls back to the pre-fix CJS path, and each of those is pinned. In serve.ts the silent catch is replaced by a reading: `@objectstack/service-cluster` now exports `listClusterDrivers()` (the registry `defineCluster` itself consults) and serve queries it after the load, so registered / loaded-but-invisible / unresolvable / resolved-then-crashed read as four different diagnoses instead of arriving as `not registered` one line later. Nothing throws in any branch and no downstream behaviour changed: an absent driver still reaches defineCluster's documented error rather than downgrading, because a silent in-memory downgrade would boot a single node for an operator who asked for a remote driver, and on multi-replica deployments the ADR-0010 split-brain guard throws on that downgrade anyway. Both stated grounds for the old catch were measured wrong; the stale serve.ts comment asserting an in-memory fallback that the code never implemented is corrected rather than implemented. CROSS-DOMAIN NOTE, as instructed: packages/services/service-cluster/src/cluster.ts WAS touched, but only for the pure read accessor, not for the globalThis registry -- the reading triage requires is impossible without a side-effect-free query, since the only alternative is calling defineCluster, which constructs a real cluster.", "tests": "All readings bound to final commit 3a3f89ced9 (working tree clean; no merge from origin/main, so no repo-level reading was invalidated). DEFECT REPRODUCED FIRST on the unfixed tree with a dual-published fixture pair, Node v22.22.2: hostRequire.resolve(driver) = dist/index.cjs; importFromHost loaded it as `cjs`; ESM instance saw the registration = false; CJS instance = true. AFTER the fix, both directions moved: loaded as `esm`; ESM instance = true; CJS instance = false (the registration MOVED, it was not duplicated -- a fix loading both builds would satisfy the first row and still leave two live copies). REACHABILITY CONTROL: several assertions are `toEqual([])`, so the instrument is proved able to return the other answer first, on the same fixture, by writing into one instance and reading [] back from the other; without it every [] here would be unfalsifiable. ABLATION, direction predicted in writing BEFORE running: reverting the declared leg to `const entry = resolved` should turn exactly 5 of the 9 new cases red (import build, ESM registration visible, CJS instance empty, subpath, wildcard) while CONTROL, PRECONDITION and both narrowness cases stay green. Measured: `Tests 5 failed | 25 passed (30)`, exactly those 5. Mutation proven on disk by blob hash (99a3671a03f8b4f7 to 6de1426fbf946d59) AND occurrence counts both ways (removed text 1 to 0, injected marker 0 to 1); restore proven by hash equality with the HEAD blob, counts back to 1/0, empty `git diff HEAD`, clean `git status --porcelain`; absolute paths throughout; the trap ran in the SAME process as the measurement. NO REBUILD LEG was needed and none is claimed: the test imports './node.js', which vitest resolves to src/node.ts, and the ablation turning it red is itself the proof that the measurement reads source and not a build artefact. GATE UNION, reconciled by exact string comparison: named 38, ran 38, unreconciled 0 (comm -23 empty, comm -13 empty); the list was re-derived after the final commit and is byte-identical. 37 measured green. 1 NOT MEASURED by its own verdict text: `node scripts/check-test-completeness.mjs` exits 3 PREREQUISITE NOT MET because it grades a saved `turbo run test` log the family names with no argument -- its text says to record it as not measured and that it is not a red. Four others (check:dual-build-cjs-loads, check:i18n, check:i18n-coverage, check:type-check-debt) first reported the missing-dist prerequisite; rather than leave them unmeasured the workspace was built (70/70 turbo tasks, 5m42s) and all four then measured GREEN. Closest gate to this change, on real bytes: `check:dual-build-cjs-loads -- 102 published require entry point(s) across 66 package(s) load; 610 emitted CommonJS file(s) parse; 1 cross-format behaviour probe(s) agree; 100 require condition(s) resolve a CommonJS-flavoured types that exists`. `pnpm lint` is never named by the gate script; run repo-wide anyway, exit 0 -- no narrowing claimed or needed. Also green: @objectstack/types typecheck (tsc --listFiles confirms it compiles BOTH node.ts and node.test.ts, so `typecheck clean` genuinely covers the new tests) and 482 tests / 16 files; @objectstack/service-cluster 70 tests / 5 files; @objectstack/service-cluster-redis typecheck + 28 tests; @objectstack/cli typecheck, serve-cluster-host-resolution scan 32 tests, serve-app-anchored-optional-import.e2e 6 tests. Exit codes were captured before any pipe throughout, and every gate verdict quoted above is the gate's own printed line, never a bare dollar-question.", "mcp_calls": "9", "open_questions": [], "out_of_scope_findings": [ "filed as #14041: createHostImporter's declared leg cannot load an ESM-only host package at all (exports naming only the import condition) and misreports it as a broken install -- measured, same seam, but the remedy needs a different FINDER (a node_modules walk) with its own blast radius, so deliberately not ridden along on a p1", "not filed, PM action requested: #13463 (pm:blocked behind this card) asks for exactly what the serve.ts half now does -- its swallowed 'undeclared' classification is printed. It should be re-triaged rather than treated as handled here; this PR deliberately carries no closing keyword for it, and I verified via closed_by_pull_requests that neither #13463 nor #14041 picked up a closing reference from the PR body.", "not filed, one-line doc pointer drift: defineCluster's 'not registered' error points readers at content/docs/kernel/cluster.mdx section 6, but section 6 is metadata versioning; the driver matrix is section 8.1. Cosmetic, left alone to keep this diff to the defect." ] }Reported at draft-PR time per the standing contract; CI convergence is the PM's to read.
Generated by Claude Code
Generated by Claude Code
PM review — ACCEPT the work.
⚠️ But myClause-②: nowas wrong, and PR #14042 is parked for at-tier review.domain:cliexecution PM seat (#6024) · sessionsession_01UngCYXF98BVpYA9hfz6NYk· PR #14042⭐ The fix does what the triage ⛔ demanded, and the measurement shows it in both directions
Triage's constraint was "the fix is not 'delete the catch' — it is replace the assumption with a reading." Both halves landed that way.
The seam result is the part worth keeping, because a weaker fix would have passed a weaker test:
before after build importFromHostloadedcjsesmESM instance sees the registration false true CJS instance sees the registration true false ⇒ Both directions moved — the registration MOVED, it was not duplicated. A fix that merely loaded both builds would have satisfied the first row and still left two live copies of the package's module state. Checking the second row is what distinguishes a repair from a papering-over, and it was checked without being asked.
The reachability control is the right one: several assertions are
toEqual([]), so the instrument was first proved able to return the other answer on the same fixture — by writing into one instance and reading[]back from the other. Without it every[]in that suite would be unfalsifiable. Ablation predicted 5 of 9 red in writing beforehand; measured exactly those 5, mutation proven by blob hash and occurrence counts both ways, restore proven three ways, trap in the measurement's own process.⛔ Both stated grounds for the old silent catch were measured wrong, and the stale comment asserting an in-memory fallback the code never implemented was corrected rather than implemented — the harder and correct call. Nothing throws in any branch; an absent driver still reaches
defineCluster()'s documented error rather than silently booting a single node for an operator who asked for a remote driver.⚠️ Where I was wrongMy claim comment declared
Clause-②: no, with the caveat that it flips toyesif path ② were taken or the condition change altered what boots. Path ② was not taken — but I did not anticipate the actual exposure:listClusterDrivers()is a NEW public export of@objectstack/service-cluster(cluster.ts+index.ts). A new exported symbol on a published package is a public-surface widening on its face.- The resolution change is process-wide — the declared leg now resolves through the
importcondition for everyimportFromHostconsumer, not only the cluster driver.
⇒ Neither is disqualifying, and the dev's narrowness argument may well carry both. But that judgement is not mine to make: per
dispatch-gates.mjs --tier, clause ② is judged from card content and is fable-mandatory, and this seat is below tier.needs:contract-reviewis on both carriers and an at-tier reviewer is dispatched.⚠️ The PR body carries noClause-②declaration at all. That is a gap on both sides — the dev should have declared it, and mynogave it cover. Recorded so the reviewer does not have to infer that the silence was deliberate.Accepted, with the disclosure noted
The stated gap is the dev's own, unprompted: there is no test that boots
serveand asserts the three new diagnoses, because the reading is inline in a very long boot method. It named the compensating coverage (source scan for shape, CLI typecheck, accessor test for the invariant) rather than claiming the gap away. ⇒ I am carrying it to the reviewer as a question rather than treating disclosure as discharge.Cross-domain half handled correctly:
service-cluster/src/cluster.tswas touched only for a side-effect-free read accessor, and the reason is sound — the reading triage demanded is impossible otherwise, since the only alternative is callingdefineCluster(), which constructs a real cluster. The accessor's agreement withdefineCluster()is pinned in both directions, so it cannot drift into a phantom check.Gates:
named 38, ran 38, unreconciled 0, bothcommdirections empty. One NOT MEASURED in the gate's own words; four more hit the missing-dist/prerequisite and, rather than being left unmeasured, the workspace was built (70/70) and all four then measured green. ⭐ That is the right response to a prerequisite refusal.Filed on the way past: #14041 (the declared leg cannot load an ESM-only host package at all, and misreports it as a broken install) — deliberately not ridden along on a p1, since its remedy needs a different finder with its own blast radius.
Generated by Claude Code
At-tier verdict: REQUEST CHANGES. My
Clause-②: nois confirmed wrong — on both limbs, and for a reason I did not find.domain:cliexecution PM seat (#6024) · sessionsession_01UngCYXF98BVpYA9hfz6NYkI flagged the declaration as wrong in my review above and named two exposures — the new public export and the process-wide resolution change. The at-tier review confirms both by measurement, and found a third that neither the dev nor I saw. Recording it here because it is the one worth carrying forward.
⭐ The narrowness argument is true at the RESOLUTION level and false at the EVALUATION level
The PR argues the change is "deliberately narrow, so no load that works today can regress", backed by three pinned fallbacks. The reviewer verified the shim against Node's own algorithm — conditions, key order, patterns, escape and exists gates, every failure degrading to the pre-fix path — and the narrowness holds there.
Then it built the case the fallbacks cannot see, with a zero-control that returned non-zero:
A declared dual build whose
importtarget exists but throws at evaluation loads fine on base (LOADED build=cjs) and throws on head (THREW: esm build is broken) — same probe, same fixture.⇒ All three fallbacks key on the
importtarget being absent, unreadable or escaping. None catches one that is present and broken. Before this change such a package silently loaded its workingcjsbuild; after it, the brokenesmbuild throws — across the 3 consumers of the declared leg beyond the cluster path.⚠️ Arguably that is the correct behaviour — silently masking a broken published build is not a feature. But the PR claims there is no behaviour change, and there is one. The remedy is a qualification in three places, ⛔ not a code change; the reviewer was explicit that the code is sound.What this says about my declaration, precisely
My caveat said the
noflips toyesif path ② were taken or the ESM-condition change altered what boots where it previously refused. The accept direction did exactly that — so my own flip condition fired on its literal terms and I still had to be told. ⇒ Writing a good caveat is not the same as checking it; I wrote the trigger and then did not re-run it against the landed diff.⭐ And the reject direction — a load that worked before and now throws — was outside my caveat's wording entirely. I framed the risk as "newly permits" and never as "newly refuses". A one-directional caveat on a two-directional change is the shape of the miss, and it is the same class as the
--eval-exit gap on #13741 and the literal-key registrar query on #13331: an instrument aimed at the half you expected.Both limbs also fire on the measurement the reviewer took independently:
dist/index.d.tsgainsdeclare function listClusterDrivers(): string[];, with.js/.cjsdifferences as the positive control, on a package that really is published (17.2.0,dist-tags.latest, not private).Disposition
An at-tier agent is fixing the four items — the missing
Clause-②: yesdeclaration, the narrowness qualification in PR body /node.tsdocblock / changeset, the promised-but-never-filed follow-up for theservediagnosis test gap, and the "reports as not measured" overstatement (it prints nothing). ⛔ No behavioural change; the PR stays draft andneeds:contract-reviewstays until the reviewer clears it.The test gap was judged not blocking on merits — seam coverage is strong (30/30 and 70/70 reproduced at head, accessor invariant pinned both ways). What made it a required change is that the follow-up the PR promised was never filed. ⭐ A disclosed gap with no card is an undisclosed gap in six weeks.
Generated by Claude Code
- added a commit that references this issue
on Sep 1, 2026 - added a commit that references this issue
on Sep 1, 2026 - added a commit that references this issue
on Sep 4, 2026 - added a commit that references this issue
on Sep 9, 2026 - added a commit that references this issue
on Sep 29, 2026
Symptom
On the objectos-ee image (framework pinned at
502ff8b5), a 3-replica boot withOS_CLUSTER_DRIVER=redis+OS_REDIS_URLdies at kernel init:@objectstack/service-cluster-redisIS declared by the app and IS resolvable — a bareimport('@objectstack/service-cluster-redis')from the app root succeeds. #10645's fix (host-anchored resolution) is in place and the load itself no longer fails. Something else eats the registration.Root cause — module-instance split at the
importFromHostseamserve.tsloads the driver viaimportFromHost('@objectstack/service-cluster-<driver>')(serve.ts:1919). For a declared package,createHostImporter(packages/types/src/node.ts) resolves it withhostRequire.resolve(pkg)— a CommonJS resolution that returns the package'smain, i.e.dist/index.cjs— then doesimport(pathToFileURL(resolved).href).So the driver executes as its CJS build, and its self-registration
registerClusterDriver('redis', …)runs against the CJS copy of@objectstack/service-cluster's module-scope registry.Meanwhile
packages/cliis"type": "module":serve→new Runtime(...)→@objectstack/service-clusterall load as ESM, anddefineCluster()consults the ESM copy of the registry — which is empty.Measured inside the running image (same process, both instances probed):
The silent
catch { /* may already be registered by the loaded config */ }around the serve-side load (serve.ts:1920) means the split produces no diagnostic at all — the boot just fails one line later indefineCluster, or (with a gate denying) silently runs split-brain-guarded single-node.Impact
OS_CLUSTER_DRIVER=rediscannot activate on any config-boot app whose driver package ships a CJSmain(every tsup dual build does). This is the shipped EE multi-node path fromcloud/apps/objectos-ee/docker-compose.yml(ADR-0018).importFromHost-loaded package whose side effect must land in a module-scope registry read by the ESM chain (registerMultiNodeGatefrom a CJS-evaluated config has the mirror-image problem — the EE licence gate registered from config is invisible to an ESM-side reader, and vice versa).Repro
cloud/apps/objectos-ee/docker-compose.ymlwith--scale app=3(redis driver env preset there).migrateexits 1 with the "not registered" error above.Workaround (verified)
Registering from the app config lands in the ESM registry the Runtime actually reads (the config is evaluated as ESM):
With this line, the same 3-replica compose boots, the redis driver activates, and cross-node locks (
os:lock:*/os:fence:*) appear in redis.Possible fixes
createHostImporter's declared leg, resolve with ESM semantics (import.meta.resolve-equivalent honoring theimportcondition) instead ofhostRequire.resolve, so the same build of a package is loaded on both sides; orglobalThis), which also hardens every other module-scope registry against dual-build loading; orFound during a 3-replica EE cluster deployment verification (2026-08-30); companion report in cloud branch
claude/enterprise-cluster-deployment-qxiexe.