Resolves #15033
Sub 1 of #15031: every external initAsync() call in the production trees migrates to the core.Base contract surface (ready()), per-site — with the rejection-path classification the parent's V-B-A required, and the contract frozen at zero by a call-anchored guard spec. No core.Base change, no bespoke-guard deletion (that is #15034, blocked on this landing), no test/ churn.
The sweep (22 files): 38 await-anchored sites from the intake inventory PLUS two the tightened call-anchored scan caught that an await-anchored grep misses — a thunk (start: () => RecorderService.initAsync() in the memory-core Server's startup-dependency table; its sibling entry one line above already used ready()) and the DreamService _initPromise reach-in the parent ticket cited (in ai/daemons/, which my intake grep scoped past — the guard spec now scans the full ai/ tree precisely so scope errors of that kind cannot recur).
Two live production bugs fixed by the migration itself (found at source, not hypothesized):
SystemLifecycleService.initAsync() double-initialized StorageRouter on every boot — its if (!StorageRouter._initPromise) gate checked a field StorageRouter never assigns, so the external call always re-ran initAsync() and created a second CollectionProxy per boot. The whole init-vs-ready dance collapses to the four await X.ready() calls that were ALREADY there beneath it.
AgentOrchestrator created every MCP client twice: createAgent() (Neo.create → construct auto-fires init) followed by await agent.initAsync() re-ran the full connection loop. Now await agent.ready().
- (Plus dead code removed: DreamService's reach-in guarded on a field that is never set — the branch never executed.)
Rejection-path classification (the error-visibility AC): verified first that ai/ has no global unhandledRejection handler, so a construct-fired init rejection crashes Node loudly at import time — visibility is process-level and survives the migration for every crash-loud service. Per target:
| Service |
initAsync failure mode |
Disposition |
GraphService (memory-core; incl. Memory_GraphService, IngestionService's injected graphService) |
internal catch → degraded, init resolves |
safe as-is (the precedent) |
ChromaLifecycleService / InferenceLifecycleService |
log-only bodies — cannot reject |
safe as-is |
SystemLifecycleService |
composes children via ready(); rejects if a crash-loud child rejects |
crash-loud at import (unhandled rejection) — visibility preserved |
StorageRouter / ChromaManager / SessionService / ConnectionService |
can reject (proxy/connect/spawn) |
crash-loud at import — identical today and after; scripts' exit-code contracts intact |
Agent |
MCP client boot + cognitive runtime — the ONE consumer whose try/catch did real structured handling (AgentOrchestrator) |
catch-and-degrade adopted (GraphService precedent): failures land in a new Agent#initError, ready() resolves, the orchestrator re-throws agent.initError after ready() — its catch block keeps identical behavior with no double-run |
The guard (test/playwright/unit/ai/InitAsyncContractGuard.spec.mjs): the GuideToolParity repo-invariant pattern — runs in the existing unit job, zero CI wiring. Call-anchored (X.initAsync() non-super, thunks included), _initPromise reach-ins (non-this), comment-prose skipped, src/core/Base.mjs exempt (the contract's home holds the framework fire + the warning example). Owner-internal this._initPromise (GraphService) stays legal until #15034 deletes the bespoke guards; extending SCAN_ROOTS to test/ is that ticket's one-line change.
Evidence: L2 (behavior + guard):
InitAsyncContractGuard.spec.mjs → 2 passed
seeded probe (external call + reach-in) → 2 failed, exact file:line reported → removed → 2 passed
<h1 class="neo-h1" data-record-id="2">contract greps at head</h1>
external non-super initAsync() calls in src/+ai/ → 0 (Base.mjs framework fire + comments only)
._initPromise (non-this) in src/+ai/ → 0
<h1 class="neo-h1" data-record-id="3">structural edits: node --check clean (Agent, AgentOrchestrator, SystemLifecycleService,</h1>
<h1 class="neo-h1" data-record-id="4">DreamService, memory-core Server, IngestionService)</h1>
Behavior gate: the directly-affected spec set (lifecycle + maintenance scripts, orchestrator services/scheduling, memory-core services) runs locally at head — result appended to this body when it completes; the hosted full unit suite on this PR is the authoritative regression gate either way (the migrated call sites are exercised by their existing suites, unchanged).
Deltas from ticket
- +2 sites over the intake inventory (the thunk + the daemons-tree reach-in) — both inside the ticket's scope definition; the inventory's grep shape was the gap, and the guard is call-anchored because of it.
Agent gains initError (catch-and-degrade) — this is the ticket's own "explicit error surface" disposition for the one real structured-error consumer, not scope growth.
- The guard lands as a unit spec (repo-invariant pattern) rather than a separate CI workflow — the AC asks that CI fails on violations; the unit job IS that gate, with zero new pipeline surface.
Test Evidence
At head bb7025f5c:
npm run test-unit -- test/playwright/unit/ai/InitAsyncContractGuard.spec.mjs \
test/playwright/unit/ai/scripts/lifecycle/ test/playwright/unit/ai/scripts/maintenance/ \
test/playwright/unit/ai/daemons/orchestrator/ test/playwright/unit/ai/services/memory-core/ --workers=1
(result appended on completion; hosted unit suite = the authoritative gate)
Post-Merge Validation
Deltas
See "Deltas from ticket" above.
Process note: authored during the operator-granted temporary Fable 5 window.
Authored by Grace (Claude Fable 5, Claude Code). Session ef6b9a4a-54ec-4afb-8438-f89a3ee46ad2
CI red investigated to root cause — one failure class, 19 instances, all test doubles pinned to the retired contract
Operator flagged the red unit job with the right prior: "maybe some tests no longer fit / got stale, but for core changes we must investigate." Investigated — the answer is the first hypothesis, with a traced reason, and zero production-code defects:
All 19 failures are injected test doubles/spies exposing initAsync() but not ready() — fakes written against the contract this PR retires, driven through the exact seams the sweep migrated:
| Spec |
Double |
Fix |
AgentOrchestrator.spec (6) |
createFakeAgent() — agent.ready is not a function at the migrated execute() call |
double now carries initError as a FIELD + async ready() {} — the crashed-outcome spec pins the identical failure flow through the orchestrator's new if (agent.initError) throw |
businessMetricsProbeCore.spec (9) |
makeGraph() stub |
initAsync(){} → ready(){} |
graphLifecycleReport.spec + migrationCensusReport.spec (2) |
fake graphService + an assertion literally named "inits the graph" |
stub + call-ledger updated: ['ready', 'graph-ready', …] — the assertion now names both readiness gates |
memory-core Server.spec (1) |
stubbed Memory_RecorderService.initAsync |
now stubs ready — symmetric with its own siblings: the same spec already stubbed ready for Inference + Session; RecorderService was the odd one out precisely because the production thunk was the odd one out |
IngestionService.spec (1) |
createGraphStub() |
initAsync(){} → ready(){} |
(The 5 "flaky" entries in the run are pre-existing retry-pass classes — fleetGrid/graphBackup/FileSystemIngestor — untouched by this diff.)
Why local missed it, honestly: my pre-push affected-set targeted the script/service subdirectories; AgentOrchestrator.spec.mjs lives at the test/playwright/unit/ai/ ROOT and the double-shape class only manifests where fakes cross the migrated seams. And this failure class was literally pre-announced — the parent-ticket V-B-A note "test doubles must expose ready() after the swap" (the agentFactory seam) is exactly what fired. Named, then under-swept. The sub-2 lint extension to test/ will make double-contract drift mechanically visible.
Boundary check: these are double-SHAPE updates on specs consuming the migrated production files — in this PR's scope. The specs' own external initAsync() calls on real singletons (e.g. checkSunsetted.spec's seeding calls) stay untouched and green — that's #15034's tranche, and CI confirms they pass under the bespoke guards exactly as the phasing design predicted.
Local at the new head c4f31421b: the exact CI-failure spec set + the contract guard, one clean serial run — 101 passed, all 19 formerly-failing specs green (the single local red is Server.spec #14388 SQLite-identity, which expects a file-backed graph DB and gets :memory: from my gitignored local config — the known config-divergence class; CI passed that spec on the previous run and it is untouched by this diff). CI re-verifies the full suite at this head.
Process note: authored during the operator-granted temporary Fable 5 window.
Authored by Grace (Claude Fable 5, Claude Code). Session ef6b9a4a-54ec-4afb-8438-f89a3ee46ad2
🖖
Resolves #15033
Sub 1 of #15031: every external
initAsync()call in the production trees migrates to thecore.Basecontract surface (ready()), per-site — with the rejection-path classification the parent's V-B-A required, and the contract frozen at zero by a call-anchored guard spec. Nocore.Basechange, no bespoke-guard deletion (that is #15034, blocked on this landing), notest/churn.The sweep (22 files): 38 await-anchored sites from the intake inventory PLUS two the tightened call-anchored scan caught that an await-anchored grep misses — a thunk (
start: () => RecorderService.initAsync()in the memory-core Server's startup-dependency table; its sibling entry one line above already usedready()) and theDreamService_initPromisereach-in the parent ticket cited (inai/daemons/, which my intake grep scoped past — the guard spec now scans the fullai/tree precisely so scope errors of that kind cannot recur).Two live production bugs fixed by the migration itself (found at source, not hypothesized):
SystemLifecycleService.initAsync()double-initialized StorageRouter on every boot — itsif (!StorageRouter._initPromise)gate checked a field StorageRouter never assigns, so the external call always re-raninitAsync()and created a secondCollectionProxyper boot. The whole init-vs-ready dance collapses to the fourawait X.ready()calls that were ALREADY there beneath it.AgentOrchestratorcreated every MCP client twice:createAgent()(Neo.create→ construct auto-fires init) followed byawait agent.initAsync()re-ran the full connection loop. Nowawait agent.ready().Rejection-path classification (the error-visibility AC): verified first that
ai/has no globalunhandledRejectionhandler, so a construct-fired init rejection crashes Node loudly at import time — visibility is process-level and survives the migration for every crash-loud service. Per target:GraphService(memory-core; incl.Memory_GraphService, IngestionService's injectedgraphService)ChromaLifecycleService/InferenceLifecycleServiceSystemLifecycleServiceready(); rejects if a crash-loud child rejectsStorageRouter/ChromaManager/SessionService/ConnectionServiceAgentAgentOrchestrator)Agent#initError,ready()resolves, the orchestrator re-throwsagent.initErrorafterready()— its catch block keeps identical behavior with no double-runThe guard (
test/playwright/unit/ai/InitAsyncContractGuard.spec.mjs): theGuideToolParityrepo-invariant pattern — runs in the existing unit job, zero CI wiring. Call-anchored (X.initAsync()non-super, thunks included),_initPromisereach-ins (non-this), comment-prose skipped,src/core/Base.mjsexempt (the contract's home holds the framework fire + the warning example). Owner-internalthis._initPromise(GraphService) stays legal until #15034 deletes the bespoke guards; extendingSCAN_ROOTStotest/is that ticket's one-line change.Evidence: L2 (behavior + guard):
Behavior gate: the directly-affected spec set (lifecycle + maintenance scripts, orchestrator services/scheduling, memory-core services) runs locally at head — result appended to this body when it completes; the hosted full unit suite on this PR is the authoritative regression gate either way (the migrated call sites are exercised by their existing suites, unchanged).
Deltas from ticket
AgentgainsinitError(catch-and-degrade) — this is the ticket's own "explicit error surface" disposition for the one real structured-error consumer, not scope growth.Test Evidence
At head
bb7025f5c:Post-Merge Validation
swarmWakeCooldown,checkAllAgentIdle,checkSunsetted,idleOutNudge,sweepExpiredTasks) complete their next scheduled runs with unchanged exit-code behavior.Deltas
See "Deltas from ticket" above.
Process note: authored during the operator-granted temporary Fable 5 window.
Authored by Grace (Claude Fable 5, Claude Code). Session ef6b9a4a-54ec-4afb-8438-f89a3ee46ad2
CI red investigated to root cause — one failure class, 19 instances, all test doubles pinned to the retired contract
Operator flagged the red unit job with the right prior: "maybe some tests no longer fit / got stale, but for core changes we must investigate." Investigated — the answer is the first hypothesis, with a traced reason, and zero production-code defects:
All 19 failures are injected test doubles/spies exposing
initAsync()but notready()— fakes written against the contract this PR retires, driven through the exact seams the sweep migrated:AgentOrchestrator.spec(6)createFakeAgent()—agent.ready is not a functionat the migratedexecute()callinitErroras a FIELD +async ready() {}— the crashed-outcome spec pins the identical failure flow through the orchestrator's newif (agent.initError) throwbusinessMetricsProbeCore.spec(9)makeGraph()stubinitAsync(){}→ready(){}graphLifecycleReport.spec+migrationCensusReport.spec(2)graphService+ an assertion literally named "inits the graph"['ready', 'graph-ready', …]— the assertion now names both readiness gatesmemory-core Server.spec(1)Memory_RecorderService.initAsyncready— symmetric with its own siblings: the same spec already stubbedreadyfor Inference + Session; RecorderService was the odd one out precisely because the production thunk was the odd one outIngestionService.spec(1)createGraphStub()initAsync(){}→ready(){}(The 5 "flaky" entries in the run are pre-existing retry-pass classes — fleetGrid/graphBackup/FileSystemIngestor — untouched by this diff.)
Why local missed it, honestly: my pre-push affected-set targeted the script/service subdirectories;
AgentOrchestrator.spec.mjslives at thetest/playwright/unit/ai/ROOT and the double-shape class only manifests where fakes cross the migrated seams. And this failure class was literally pre-announced — the parent-ticket V-B-A note "test doubles must exposeready()after the swap" (theagentFactoryseam) is exactly what fired. Named, then under-swept. The sub-2 lint extension totest/will make double-contract drift mechanically visible.Boundary check: these are double-SHAPE updates on specs consuming the migrated production files — in this PR's scope. The specs' own external
initAsync()calls on real singletons (e.g.checkSunsetted.spec's seeding calls) stay untouched and green — that's #15034's tranche, and CI confirms they pass under the bespoke guards exactly as the phasing design predicted.Local at the new head
c4f31421b: the exact CI-failure spec set + the contract guard, one clean serial run — 101 passed, all 19 formerly-failing specs green (the single local red isServer.spec#14388 SQLite-identity, which expects a file-backed graph DB and gets:memory:from my gitignored local config — the known config-divergence class; CI passed that spec on the previous run and it is untouched by this diff). CI re-verifies the full suite at this head.Process note: authored during the operator-granted temporary Fable 5 window.
Authored by Grace (Claude Fable 5, Claude Code). Session ef6b9a4a-54ec-4afb-8438-f89a3ee46ad2
🖖