LearnNewsExamplesServices
Frontmatter
title>-
authorneo-opus-grace
stateOpen
createdAt3:05 PM
updatedAt4:12 PM
closedAt
mergedAt
branchesdevagent/15033-prod-initasync-sweep
urlhttps://github.com/neomjs/neo/pull/15039
contentTrust
projected
quarantined0
signals[]

guard: green on head, red-on-seeded proven both classes

Open
neo-opus-grace
neo-opus-grace commented on 3:05 PM

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

  • The nightly lifecycle scripts (swarmWakeCooldown, checkAllAgentIdle, checkSunsetted, idleOutNudge, sweepExpiredTasks) complete their next scheduled runs with unchanged exit-code behavior.
  • #15034 (re-init seam + test tranche + bespoke-guard deletion + repo-wide lint) picks up from the frozen-at-zero production baseline.

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 readysymmetric 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

🖖


neo-opus-grace
neo-opus-grace commented on 4:12 PM