diff --git a/docs/architecture/KEY_FILES.md b/docs/architecture/KEY_FILES.md index 39cf3c496..339d97129 100644 --- a/docs/architecture/KEY_FILES.md +++ b/docs/architecture/KEY_FILES.md @@ -18,7 +18,7 @@ per-release `**vX.Y.Z:**` narration — CI enforces this - `src/core/search/mode.ts` extension — `graph_signals: boolean` knob in `ModeBundle` (defaults: `conservative=false`, `balanced=true`, `tokenmax=true`). `KNOBS_HASH_VERSION` appends a `gs=` parts entry per the cache-key contamination convention so a graph-on cache write can't be served to a graph-off lookup. `SearchKeyOverrides` + `SearchPerCallOpts` + `loadOverridesFromConfig` + `SEARCH_MODE_CONFIG_KEYS` + `resolveSearchMode` + `attributeKnob` all carry the field. Opt-out: `gbrain config set search.graph_signals false`. Mid-deploy `query_cache` rows from before the upgrade hash differently — natural row segregation, clears within `cache.ttl_seconds` (3600s default). - `src/core/context-engine.ts` + `src/openclaw-context-engine.ts` — the deterministic context engine OpenClaw loads on every turn (`assemble()` injects the Live Context block, zero-LLM). `createGBrainContextEngine({workspaceDir, resolveEntities?})` accepts an OPTIONAL host-injected resolver (`ENGINE_API_VERSION` 0.2.0, additive — older hosts work unchanged; the plugin entry maps `ctx.resolveEntities`/`ctx.brainQuery` onto it). `assemble()` runs the Retrieval Reflex after the Live Context block: extracts the current turn's user text, builds prior-context text (every message EXCEPT the current turn — suppression must not see the triggering mention), passes the rolling window (`getWindowTurns`, last 12 user/assistant turns; the reflex slices to its configured `retrieval_reflex_window_turns`), and appends the pointer block. `warmReflex()` fires at construction. - `src/core/context/` — Retrieval Reflex (Layer 1, issue #1981). `entity-salience.ts`: pure, zero-LLM, precision-biased `extractCandidates(text)` (capitalized runs + `@handles`, STOPWORDS + soft COMMON_WORDS + sentence-start guard, deterministic, capped) + `extractCandidatesFromWindow(turns)` (#2095: merges per-turn extraction across the last N turns by normalizeAlias form with occurrence/newest-turn/user-mention metadata; salience-ordered — recency > frequency > user-role — so the cap drops stale assistant chatter first). `retrieval-reflex.ts`: `resolveEntitiesToPointers(engine, sourceId, candidates, opts)` — alias arm (`resolveAliases`, caught per-arm for pre-v110 brains) + exact title/slug-suffix arm (the recall fix: real slugs are namespaced `people/x` but `slugify` drops the prefix); pointers carry `source_id`/`arm`/`confidence`/`matchedNorm` (#2095 — `ARM_CONFIDENCE` alias 0.9 / title 0.8 / slug-suffix 0.6 lives next to the arm definitions; arm-2 provenance classified in JS since the combined OR can't report which predicate matched); opts: `sourceIds?` federated scope (alias arm loops per source, arm 2 uses `source_id = ANY`), `suppression?` ('slug-and-title' legacy default; 'slug-only' REQUIRED under windowing — the title rule would suppress every entity merely mentioned in a prior window turn), `logChannel?: 'reflex'` (server-side ambient-channel event logging through the volunteer-events sink); synopsis runs through `stripTakesFence`/`stripFactsFence` (the same privacy boundary `get_page` applies) so private facts never reach the prompt; capped at `MAX_POINTERS`. `reflex.ts`: the orchestrator + engine-aware resolver ladder (host `resolveEntities` → PGLite serve IPC → Postgres cached process-singleton → disabled), zero-candidate fast path, fail-open + timeout, heartbeat write for the doctor check, `reflexEnabled(cfg)` (file/env gate, default ON; DB-plane does NOT gate — `assemble()` is sync); windowed extraction when `windowTurns` present and `retrieval_reflex_window_turns` (default 4; 1 = exact legacy behavior) > 1 — switches suppression to slug-only and the direct-Postgres rung logs `channel: 'reflex'` events. `resolve-ipc.ts`: local unix-socket resolve protocol (client + server) so PGLite resolves through the single connection `gbrain serve` holds (a second opener would hit the exclusive lock; a subprocess would force-steal it past the 5-min staleness window and crash). Wired into `src/mcp/server.ts` (serve binds `/.gbrain-resolve.sock` on PGLite, cleaned up on shutdown). Doctor surface: `retrieval_reflex_health` in `src/commands/doctor.ts` (reads the heartbeat for truthful runtime status; categorized in `doctor-categories.ts`). Config: `retrieval_reflex` + `retrieval_reflex_max_pointers` + `retrieval_reflex_window_turns` in `src/core/config.ts` (env `GBRAIN_RETRIEVAL_REFLEX_WINDOW_TURNS`). `volunteer.ts` (#2095): `parseWindow` (lenient `user:`/`assistant:` prefixes, unprefixed → one user turn), `volunteerContext` (extract → resolve → +0.05 multi-turn/newest-turn boost → `min_confidence` 0.7 gate → cap 3/5; deterministic rationale strings, never raw conversation text; slug-only suppression), `volunteerUsageStats` (per-arm/channel precision from the `pages.last_retrieved_at > volunteered_at` join — APPROXIMATE: the 5-min last-retrieved throttle causes false negatives, unrelated reads false positives). `volunteer-events.ts` (#2095): `insertVolunteerEvents` (ONE multi-row parameterized INSERT), `logVolunteerEventsFireAndForget` + bounded drain registered as the `volunteer-events` background-work sink (order 4), `purgeStaleVolunteerEvents` (90-day GC, called from the dream cycle's purge phase). Policy layer ships as the `retrieval-reflex` recipe (`recipes/retrieval-reflex/`). Pinned by `test/context/entity-salience.test.ts`, `test/retrieval-reflex.test.ts`, `test/context/resolve-ipc.test.ts`, `test/doctor-retrieval-reflex.test.ts`, `test/volunteer-context.test.ts`, `test/e2e/volunteer-context-postgres.test.ts`. -- `src/core/cli-force-exit.ts` — the CLI exit contract (#2084). `shouldForceExitAfterMain(argv)`: daemon gate — only `serve` returns from main() while the event loop carries the server; every other long-runner (`jobs work`, `autopilot`, `watch`) BLOCKS in its awaited handler, so the deliberate exit on main()-resolve is correct for them. `flushStdoutThenExit(code, deps?)`: drains stdout AND stderr (`writableLength === 0`, 'drain'-event + 25ms poll loop — 'drain' only fires after backpressure) under a 2s unref'd guard for blocked pipes, then exits. The cli.ts entrypoint calls it when main() resolves — the fix for lingering embedding/PgBouncer sockets riding the 10s backstop on every `gbrain query`; the backstop timers remain as the never-hit safety net for a HUNG teardown. Stdout flush is load-bearing: incident #1959 was a force-exit truncating piped output. Pinned by `test/cli-flush-exit.test.ts`, `test/cli-should-force-exit.test.ts`, `test/cli-pipe-truncation.test.ts` (256KB real-pipe byte-exact), `test/cli-drain-then-disconnect.test.ts`, `test/e2e/pgbouncer-teardown.test.ts` (CI txn-mode pooler, the #1972/#2015/#2084 class). +- `src/core/cli-force-exit.ts` — the CLI exit contract (#2084). `setCliExitCode(n)`/`getCliExitCode()`: gbrain's OWN exit verdict — every gbrain exit-code assignment routes through the setter (mirrored to process.exitCode), and the exit paths read the getter, NEVER ambient `process.exitCode`: PGLite's Emscripten runtime asynchronously writes the WASM backend's proc_exit status into process.exitCode (initdb at create, postmaster at close), so trusting it made a clean `gbrain apply-migrations` exit 99. `shouldForceExitAfterMain(argv)`: daemon gate — only `serve` returns from main() while the event loop carries the server; every other long-runner (`jobs work`, `autopilot`, `watch`) BLOCKS in its awaited handler, so the deliberate exit on main()-resolve is correct for them. `flushStdoutThenExit(code, deps?)`: drains stdout AND stderr (`writableLength === 0`, 'drain'-event + 25ms poll loop — 'drain' only fires after backpressure) under a 2s unref'd guard for blocked pipes, then exits. The cli.ts entrypoint calls it when main() resolves — the fix for lingering embedding/PgBouncer sockets riding the 10s backstop on every `gbrain query`; the backstop timers remain as the never-hit safety net for a HUNG teardown. Stdout flush is load-bearing: incident #1959 was a force-exit truncating piped output. Pinned by `test/cli-flush-exit.test.ts`, `test/cli-should-force-exit.test.ts`, `test/cli-pipe-truncation.test.ts` (256KB real-pipe byte-exact), `test/cli-drain-then-disconnect.test.ts`, `test/e2e/pgbouncer-teardown.test.ts` (CI txn-mode pooler, the #1972/#2015/#2084 class). - `src/commands/watch.ts` — `gbrain watch` (#2095): the push transport. Reads turns from stdin as they arrive (`user:`/`assistant:` prefixes; unprefixed = user turn), keeps a rolling in-process window (`--window-turns`, default 4), calls `volunteerContext` per turn, streams pointers to stdout (`--json` for JSONL with turn attribution), logs `channel: 'watch'` events with a per-session id. Session dedupe feeds already-pushed slugs back as priorContext so the core's slug-only suppression dedupes. Blocks in the stdin iteration (interactive alive until Ctrl-C/Ctrl-D; piped ends at EOF) — deliberately NOT in DAEMON_COMMANDS; SIGINT closes the stream so teardown flows through drainThenDisconnect. Per-turn resolution failures are fail-open. Registered in CLI_ONLY + CLI_ONLY_SELF_HELP + THIN_CLIENT_REFUSED_COMMANDS (thin clients use the `volunteer_context` MCP op). Pinned by `test/watch-command.test.ts`. - `src/commands/integrations.ts` — recipe install. The resolver-row install fence is keyed by `manifest.recipe` (`gbrain::resolver-rows`), so a second `copy-into-host-repo` recipe no longer writes a block mislabeled with the first recipe's name. Pinned by `test/integrations-install.test.ts`. - `src/core/audit/audit-writer.ts` — shared JSONL audit primitive consolidating the hand-rolled audit modules. Exports `createAuditWriter({kind, recordSchema})` returning `{log, readRecent}` plus shared helpers `computeIsoWeekFilename(kind, now?)` and `resolveAuditDir()` (honors `GBRAIN_AUDIT_DIR`). ISO-week file rotation; best-effort writes (stderr warn on failure, never throws); read-path scans current-week + previous-week files for boundary spans. Refactored onto it for parity: `src/core/rerank-audit.ts`, `src/core/audit-slug-fallback.ts`, `src/core/minions/handlers/shell-audit.ts`, `src/core/minions/handlers/supervisor-audit.ts`, `src/core/facts/phantom-audit.ts` (each module's public API preserved bit-for-bit). The `graph-signals-failures` audit (`logGraphSignalsFailure`) uses the same primitive. One hand-rolled audit remains at `src/core/skillpack/audit.ts`. Pinned by `test/audit/audit-writer.test.ts`. diff --git a/src/cli.ts b/src/cli.ts index 63c8d6fec..a3927fd12 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,7 +26,7 @@ import type { BrainEngine } from './core/engine.ts'; import { operations, OperationError } from './core/operations.ts'; import type { Operation, OperationContext } from './core/operations.ts'; import { drainAllBackgroundWorkForCliExit } from './core/background-work.ts'; -import { shouldForceExitAfterMain, flushStdoutThenExit } from './core/cli-force-exit.ts'; +import { shouldForceExitAfterMain, flushStdoutThenExit, setCliExitCode, getCliExitCode } from './core/cli-force-exit.ts'; import { serializeMarkdown } from './core/markdown.ts'; import { parseGlobalFlags, setCliOptions, getCliOptions } from './core/cli-options.ts'; import type { CliOptions } from './core/cli-options.ts'; @@ -376,7 +376,7 @@ async function main() { } else { console.error(e instanceof Error ? e.message : String(e)); } - process.exitCode = 1; + setCliExitCode(1); } finally { // 1s per-sink drain timeout: read paths with no pending work pay the // ~0ms fast path; capture/import that DO enqueue pay up to 1s (+ facts @@ -419,7 +419,7 @@ export async function drainThenDisconnect( ); // Honor an exit code an errored op already set — a bare process.exit(0) // here would mask a failed op as success if the drain/disconnect hangs. - process.exit(process.exitCode ?? 0); + process.exit(getCliExitCode()); }, DISCONNECT_HARD_DEADLINE_MS); // unref so the timer itself doesn't keep the event loop alive — only // the actual pending work (PGLite WASM handle) does. @@ -1169,12 +1169,12 @@ async function handleCliOnly(command: string, args: string[]) { // v0.43 (#2084 inner-exit sweep): exitCode + return instead of a // mid-handler process.exit — flows through the entrypoint flush-exit // so buffered stdout is never truncated. - process.exitCode = runFriction(args); + setCliExitCode(runFriction(args)); return; } if (command === 'claw-test') { const { runClawTest } = await import('./commands/claw-test.ts'); - process.exitCode = await runClawTest(args); + setCliExitCode(await runClawTest(args)); return; } if (command === 'report') { @@ -1276,7 +1276,7 @@ async function handleCliOnly(command: string, args: string[]) { execSync(`bash "${scriptPath}"`, { stdio: 'inherit', env: { ...process.env } }); } catch (e: any) { // Non-zero exit = some tests failed (exit code = failure count) - process.exitCode = e.status ?? 1; + setCliExitCode(e.status ?? 1); } return; } @@ -1324,7 +1324,7 @@ async function handleCliOnly(command: string, args: string[]) { // The handler self-configures the AI gateway from loadConfig() + process.env. if (command === 'eval' && args[0] === 'cross-modal') { const { runEvalCrossModal } = await import('./commands/eval-cross-modal.ts'); - process.exitCode = await runEvalCrossModal(args.slice(1)); + setCliExitCode(await runEvalCrossModal(args.slice(1))); return; } @@ -1336,7 +1336,7 @@ async function handleCliOnly(command: string, args: string[]) { // engine-required path below. if (command === 'eval' && args[0] === 'takes-quality' && args[1] === 'replay') { const { runReplayNoBrain } = await import('./commands/eval-takes-quality.ts'); - process.exitCode = await runReplayNoBrain(args.slice(2)); + setCliExitCode(await runReplayNoBrain(args.slice(2))); return; } @@ -1369,7 +1369,7 @@ async function handleCliOnly(command: string, args: string[]) { // gate runs on machines with no `~/.gbrain/config.json`. if (command === 'eval' && args[0] === 'conversation-parser') { const { runEvalConversationParser } = await import('./commands/eval-conversation-parser.ts'); - process.exitCode = await runEvalConversationParser(args.slice(1)); + setCliExitCode(await runEvalConversationParser(args.slice(1))); return; } @@ -1398,7 +1398,7 @@ async function handleCliOnly(command: string, args: string[]) { const cfgPre = loadConfig(); if (isThinClient(cfgPre)) { const { runEvalWhoknows } = await import('./commands/eval-whoknows.ts'); - process.exitCode = await runEvalWhoknows(null, args.slice(1)); + setCliExitCode(await runEvalWhoknows(null, args.slice(1))); return; } } @@ -1413,7 +1413,7 @@ async function handleCliOnly(command: string, args: string[]) { if (cfgPre && isThinClient(cfgPre)) { const { runStatus } = await import('./commands/status.ts'); const result = await runStatus(null, args); - process.exitCode = result.exitCode; + setCliExitCode(result.exitCode); return; } } @@ -1547,7 +1547,7 @@ async function handleCliOnly(command: string, args: string[]) { // so wrappers (sync, CI scripts, `&& gbrain doctor`) propagate. const importResult = await runImport(engine, args); if (importResult.errors > 0) { - process.exitCode = 1; + setCliExitCode(1); } break; } @@ -1732,7 +1732,7 @@ async function handleCliOnly(command: string, args: string[]) { // v0.43 (#2084 inner-exit sweep): a mid-switch process.exit skipped // the finally's drain + disconnect entirely. exitCode + break flows // through both, then the entrypoint flush-exit ends the process. - process.exitCode = result.exitCode; + setCliExitCode(result.exitCode); break; } // v0.38 — Capture: single human-facing entrypoint for ingestion. @@ -2317,7 +2317,7 @@ if (import.meta.main) { // are excluded by shouldForceExitAfterMain and keep the event loop. main().then( () => { - if (shouldForceExitAfterMain()) void flushStdoutThenExit(Number(process.exitCode ?? 0)); + if (shouldForceExitAfterMain()) void flushStdoutThenExit(getCliExitCode()); }, (e) => { console.error(e instanceof Error ? (e.message || String(e)) : String(e)); diff --git a/src/commands/autopilot.ts b/src/commands/autopilot.ts index d5831df74..8d5a549c3 100644 --- a/src/commands/autopilot.ts +++ b/src/commands/autopilot.ts @@ -37,6 +37,7 @@ import { logSelfUpgrade } from '../core/audit/self-upgrade-audit.ts'; import { detectInstallMethod } from './upgrade.ts'; import { evaluateQuietHours } from '../core/minions/quiet-hours.ts'; import { inspectLock } from '../core/db-lock.ts'; +import { setCliExitCode } from '../core/cli-force-exit.ts'; /** * v0.37.7.0 #1162 — classify autopilot reconnect-loop errors. @@ -542,7 +543,7 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) { `Exiting so launchd ThrottleInterval can apply backoff.`, ); stopping = true; - process.exitCode = 1; + setCliExitCode(1); break; } if (autopilotReconnectFails >= AUTOPILOT_MAX_RECONNECT_FAILS) { @@ -551,7 +552,7 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) { `Last error: ${(e as Error).message ?? 'unknown'}. Exiting.`, ); stopping = true; - process.exitCode = 1; + setCliExitCode(1); break; } } diff --git a/src/commands/brainstorm.ts b/src/commands/brainstorm.ts index 253bd4ad9..9d3ef8295 100644 --- a/src/commands/brainstorm.ts +++ b/src/commands/brainstorm.ts @@ -27,6 +27,7 @@ import { serializeMarkdown } from '../core/markdown.ts'; import { importFromContent } from '../core/import-file.ts'; import { writePageThrough, type WriteThroughResult } from '../core/write-through.ts'; import { randomBytes } from 'crypto'; +import { setCliExitCode } from '../core/cli-force-exit.ts'; export interface BrainstormCliArgs { question?: string; @@ -322,7 +323,7 @@ async function runBrainstormCli( const msg = formatSaveOutcome(outcome, { profileLabel: profile.label, slug }); if (msg.stdout) console.log(msg.stdout); for (const line of msg.stderr) console.error(line); - if (msg.exitCode) process.exitCode = msg.exitCode; + if (msg.exitCode) setCliExitCode(msg.exitCode); } } diff --git a/src/commands/frontmatter.ts b/src/commands/frontmatter.ts index 77a8fba4c..93ddfb810 100644 --- a/src/commands/frontmatter.ts +++ b/src/commands/frontmatter.ts @@ -30,6 +30,7 @@ import { type AuditFix, } from '../core/brain-writer.ts'; import { isSyncable, pruneDir, slugifyPath } from '../core/sync.ts'; +import { setCliExitCode } from '../core/cli-force-exit.ts'; export async function runFrontmatter(args: string[]): Promise { const sub = args[0]; @@ -63,7 +64,7 @@ export async function runFrontmatter(args: string[]): Promise { } console.error(`Unknown frontmatter subcommand: ${sub}\n`); printHelp(); - process.exitCode = 1; + setCliExitCode(1); } async function connectEngineForAudit(): Promise { @@ -164,14 +165,14 @@ async function runValidate(rest: string[]): Promise { } if (!target) { console.error('error: gbrain frontmatter validate requires a argument'); - process.exitCode = 1; + setCliExitCode(1); return; } const resolved = resolve(target); if (!existsSync(resolved)) { console.error(`error: path not found: ${target}`); - process.exitCode = 1; + setCliExitCode(1); return; } @@ -242,7 +243,7 @@ async function runValidate(rest: string[]): Promise { } } - process.exitCode = totalErrors > 0 && !flags.fix ? 1 : 0; + setCliExitCode(totalErrors > 0 && !flags.fix ? 1 : 0); } /** @@ -378,7 +379,7 @@ async function runGenerate(args: string[]): Promise { if (!targetPath) { console.error('error: gbrain frontmatter generate requires a argument'); console.error('usage: gbrain frontmatter generate [--fix] [--dry-run] [--json]'); - process.exitCode = 1; + setCliExitCode(1); return; } diff --git a/src/commands/reindex.ts b/src/commands/reindex.ts index 6e5245ac4..8108acbc3 100644 --- a/src/commands/reindex.ts +++ b/src/commands/reindex.ts @@ -29,6 +29,7 @@ import { createProgress } from '../core/progress.ts'; import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts'; import { existsSync } from 'fs'; import { resolve } from 'path'; +import { setCliExitCode } from '../core/cli-force-exit.ts'; // v0.41.15.0 (T10, D9): per-batch parallel workers. import { runSlidingPool } from '../core/worker-pool.ts'; import { resolveWorkersWithClamp } from '../core/sync-concurrency.ts'; @@ -150,7 +151,7 @@ export async function runReindex(engine: BrainEngine, args: string[]): Promise describe('v0.42.20.0 — background-work registry drains every sink before disconnect', () => { // Supersedes the v0.41.8.0 #1247/#1269/#1290 per-call last-retrieved drain: - // last-retrieved is now one of four registry sinks; cli.ts drains the whole - // registry (drainAllBackgroundWorkForCliExit) before disconnect on BOTH the - // op-dispatch path AND the CLI_ONLY path (the latter closes #1762 for capture). - test('cli.ts imports + uses drainAllBackgroundWorkForCliExit', () => { + // last-retrieved is one of the registry sinks. Since the v0.43 #2084 drain + // hoist, cli.ts routes EVERY owner-disconnect through drainThenDisconnect + // (one helper, all 8 sites) instead of two inline drain+disconnect pairs. + test('cli.ts imports the registry drain + routes owner-disconnects through drainThenDisconnect', () => { const src = readFileSync('src/cli.ts', 'utf8'); expect(src).toMatch(/import\s+\{\s*drainAllBackgroundWorkForCliExit\s*\}\s*from\s+['"]\.\/core\/background-work\.ts['"]/); - // Two call sites: op-dispatch finally + handleCliOnly finally. - const calls = src.match(/await\s+drainAllBackgroundWorkForCliExit\s*\(/g) ?? []; - expect(calls.length).toBeGreaterThanOrEqual(2); + // The single registry-drain call site lives inside the shared helper... + expect(src).toMatch(/export async function drainThenDisconnect/); + expect(src).toMatch(/await\s+drainAllBackgroundWorkForCliExit\s*\(/); + // ...and the helper covers every owner-disconnect (op-dispatch, CLI_ONLY + // fall-through, search dashboard, doctor x3, ze-switch, dream, + // read-only-timeout path). + const calls = src.match(/await\s+drainThenDisconnect\s*\(/g) ?? []; + expect(calls.length).toBeGreaterThanOrEqual(8); }); test('last-retrieved.ts still exports the bounded drain + registers a drainer', () => { @@ -155,11 +160,13 @@ describe('v0.42.20.0 — background-work registry drains every sink before disco .toMatch(/name:\s*'eval-capture'/); }); - test('cli.ts behavioral positioning: registry drain appears BEFORE engine.disconnect (op-dispatch)', () => { + test('cli.ts behavioral positioning: drainThenDisconnect drains the registry BEFORE engine.disconnect', () => { const src = readFileSync('src/cli.ts', 'utf8'); - const localPath = src.match(/\/\/ Local engine path \(unchanged behavior[\s\S]+?^\}/m); - expect(localPath).not.toBeNull(); - const block = localPath![0]; + // The ordering invariant lives ONCE, inside the shared helper (v0.43 + // #2084 drain hoist): registry drain, THEN best-effort disconnect. + const helper = src.match(/export async function drainThenDisconnect[\s\S]+?^\}/m); + expect(helper).not.toBeNull(); + const block = helper![0]; const drainCallRe = /await\s+drainAllBackgroundWorkForCliExit\s*\(/; const disconnectCallRe = /await\s+engine\.disconnect\s*\(/; expect(block).toMatch(drainCallRe); @@ -167,6 +174,10 @@ describe('v0.42.20.0 — background-work registry drains every sink before disco const drainIdx = block.indexOf(block.match(drainCallRe)![0]); const disconnectIdx = block.indexOf(block.match(disconnectCallRe)![0]); expect(drainIdx).toBeLessThan(disconnectIdx); + // And the op-dispatch finally routes through the helper. + const localPath = src.match(/\/\/ Local engine path \(unchanged behavior[\s\S]+?\n \}\n\}/m); + expect(localPath).not.toBeNull(); + expect(localPath![0]).toMatch(/await\s+drainThenDisconnect\s*\(/); }); test('background-work.ts: Map registry, ordered drain, awaited abort, test seam', () => {