diff --git a/src/constants/terminationTimeouts.ts b/src/constants/terminationTimeouts.ts index 4cabd8b575..d8d35dc7b9 100644 --- a/src/constants/terminationTimeouts.ts +++ b/src/constants/terminationTimeouts.ts @@ -8,3 +8,10 @@ export const WORKTREE_DELETE_GIT_TIMEOUT_MS = 60 * 1000; * slow initial clone. */ export const BACKUP_GIT_TIMEOUT_MS = 5 * 60 * 1000; + +/** + * Bounds the app Effect runtime's scope close, the last step of + * `ServiceContainer.dispose()`. Must stay well inside the 5 s quit budgets that + * `desktop/main.ts` and `cli/server.ts` race the whole dispose against. + */ +export const APP_RUNTIME_DISPOSE_TIMEOUT_MS = 2 * 1000; diff --git a/src/node/bench/headlessEnvironment.ts b/src/node/bench/headlessEnvironment.ts index c389b75e26..3b412dfc46 100644 --- a/src/node/bench/headlessEnvironment.ts +++ b/src/node/bench/headlessEnvironment.ts @@ -115,6 +115,9 @@ export async function createHeadlessEnvironment( services.windowService.setMainWindow(mockWindow); const dispose = async () => { + // Release the container (background processes, bridges, the Effect runtime + // scope) before deleting the directory it writes into. + await services.dispose(); sentEvents.length = 0; await disposeRootDir(); }; diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index ac966a5b53..68877d8659 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -64,8 +64,8 @@ import type { OrpcEffectServices } from "@/node/orpc/effectContext"; /** * `WithEffectContext` adds the `"effect/context"` key carrying the Effect - * services available to Effect-native handlers (built via - * `buildOrpcEffectContext` in the service container). + * services available to Effect-native handlers (the app runtime's built + * service context, `ServiceContainer.runtime.context`). */ export interface ORPCContext extends WithEffectContext { config: Config; diff --git a/src/node/orpc/effectContext.ts b/src/node/orpc/effectContext.ts index 7fdcfd0745..0d2c15f6ee 100644 --- a/src/node/orpc/effectContext.ts +++ b/src/node/orpc/effectContext.ts @@ -3,30 +3,37 @@ * with `@orpc/experimental-effect`. * * `ORPCContext` carries a pre-built `Context.Context` under the well-known - * `"effect/context"` key (see `WithEffectContext`). `handlerGen` provides it to - * every effect a handler yields, so Effect-native handlers resolve services by - * yielding tags instead of reaching through the oRPC context object. During - * the incremental migration both styles coexist: + * `"effect/context"` key (see `WithEffectContext`). In production that context + * is the app runtime's built service context (`ServiceContainer.runtime`, see + * `di/appRuntime.ts`), so every service the Layer graph provides is yieldable + * by tag. `handlerGen` provides it to every effect a handler yields, so + * Effect-native handlers resolve services by yielding tags instead of reaching + * through the oRPC context object. During the incremental migration both + * styles coexist: * * - Effect-native handlers: `const meta = yield* MemoryMeta;` * - Transitional handlers: `context.memoryMetaService.effects.…` (same * instances, no Effect context required). * - * Grow `OrpcEffectServices` as more services gain Effect surfaces. + * Tags live in `src/node/services/di/tags.ts`; this module re-exports the ones + * oRPC handlers use. */ import { Context } from "effect"; import type { MemoryMetaService } from "@/node/services/memoryMeta"; +import { MemoryMeta, type AppTags } from "@/node/services/di/tags"; -/** Effect service tag for the memory metadata sidecar service. */ -export class MemoryMeta extends Context.Service()( - "xum/MemoryMeta" -) {} +export { MemoryMeta }; /** Union of all services available to Effect-native oRPC handlers. */ -export type OrpcEffectServices = MemoryMeta; +export type OrpcEffectServices = AppTags; +/** + * Test helper: build a context holding only the memory metadata service, for + * handler tests that construct a partial `ORPCContext` by hand + * (`effectBridge.test.ts`). Production contexts come from the app runtime. + */ export function buildOrpcEffectContext(services: { memoryMetaService: MemoryMetaService; -}): Context.Context { +}): Context.Context { return Context.make(MemoryMeta, services.memoryMetaService); } diff --git a/src/node/services/coreServices.ts b/src/node/services/coreServices.ts index 1d851f8533..11f08a3eb6 100644 --- a/src/node/services/coreServices.ts +++ b/src/node/services/coreServices.ts @@ -63,6 +63,11 @@ export interface CoreServicesOptions { mcpConfig?: Config; mcpServerManagerOptions?: MCPServerManagerOptions; workspaceMcpOverridesService?: WorkspaceMcpOverridesService; + /** + * Layer-provided instance (desktop `ServiceContainer` builds it from its + * Effect graph, see `di/layers/core.ts`); default-constructed when absent. + */ + memoryMetaService?: MemoryMetaService; /** Optional cross-cutting services (desktop creates before core services). */ policyService?: PolicyService; telemetryService?: TelemetryService; @@ -205,7 +210,7 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { // Agent memory (memory experiment): scope roots derive from Config (xum home // + session dirs); experiment gating happens per stream in AIService. // Host-local sidecar for user-owned memory metadata (pins + usage stats). - const memoryMetaService = new MemoryMetaService(config.rootDir); + const memoryMetaService = opts.memoryMetaService ?? new MemoryMetaService(config.rootDir); const memoryService = new MemoryService(config, memoryMetaService); turnRequestBuilderBindings.memoryService = memoryService; diff --git a/src/node/services/di/appRuntime.test.ts b/src/node/services/di/appRuntime.test.ts new file mode 100644 index 0000000000..5d27867a39 --- /dev/null +++ b/src/node/services/di/appRuntime.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, spyOn } from "bun:test"; +import { Context, Effect, Layer } from "effect"; +import { log } from "@/node/services/log"; +import { disposeAppRuntime, makeAppRuntime } from "./appRuntime"; + +class ProbeA extends Context.Service()("test/ProbeA") {} +class ProbeB extends Context.Service()("test/ProbeB") {} + +describe("makeAppRuntime", () => { + it("builds a synchronous layer graph eagerly and caches the context", () => { + const app = makeAppRuntime(Layer.succeed(ProbeA)({ name: "a" })); + + expect(app.managed.cachedContext).toBeDefined(); + expect(app.get(ProbeA).name).toBe("a"); + expect(Context.get(app.context, ProbeA)).toBe(app.get(ProbeA)); + }); + + it("throws synchronously when a layer body suspends", () => { + const asyncLayer = Layer.effect( + ProbeA, + Effect.promise(() => Promise.resolve({ name: "late" })) + ); + + expect(() => makeAppRuntime(asyncLayer)).toThrow(); + }); + + it("propagates a throwing layer body as a synchronous throw", () => { + const throwingLayer = Layer.sync(ProbeA, () => { + throw new Error("constructor boom"); + }); + + expect(() => makeAppRuntime(throwingLayer)).toThrow("constructor boom"); + }); + + it("starts fibers synchronously after the eager build", () => { + const app = makeAppRuntime(Layer.succeed(ProbeA)({ name: "a" })); + let ran = false; + + app.managed.runFork( + Effect.sync(() => { + ran = true; + }) + ); + + // runFork on a built runtime is Effect.runForkWith(cachedContext): the body + // executes before runFork returns, up to its first async boundary. + expect(ran).toBe(true); + }); +}); + +describe("disposeAppRuntime", () => { + it("runs layer finalizers in reverse acquisition order", async () => { + const order: string[] = []; + const release = (name: string) => + Effect.sync(() => { + order.push(name); + }); + const a = Layer.effect( + ProbeA, + Effect.acquireRelease(Effect.succeed({ name: "a" }), () => release("a")) + ); + const b = Layer.effect( + ProbeB, + Effect.acquireRelease(Effect.succeed({ name: "b" }), () => release("b")) + ); + // B is provided with A, so A is acquired first and must be released last. + const app = makeAppRuntime(b.pipe(Layer.provideMerge(a))); + + await disposeAppRuntime(app.managed); + + expect(order).toEqual(["b", "a"]); + }); + + it("is idempotent", async () => { + let released = 0; + const app = makeAppRuntime( + Layer.effect( + ProbeA, + Effect.acquireRelease(Effect.succeed({ name: "a" }), () => + Effect.sync(() => { + released += 1; + }) + ) + ) + ); + + await disposeAppRuntime(app.managed); + await disposeAppRuntime(app.managed); + + expect(released).toBe(1); + expect(app.managed.cachedContext).toBeUndefined(); + }); + + it("returns at the timeout when a finalizer hangs, warning instead of rejecting", async () => { + const warnSpy = spyOn(log, "warn").mockImplementation(() => undefined); + try { + const app = makeAppRuntime( + Layer.effect( + ProbeA, + Effect.acquireRelease(Effect.succeed({ name: "a" }), () => Effect.never) + ) + ); + + const startedAt = Date.now(); + await disposeAppRuntime(app.managed, 50); + + expect(Date.now() - startedAt).toBeLessThan(2_000); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(String(warnSpy.mock.calls[0]?.[0])).toContain("timed out"); + } finally { + warnSpy.mockRestore(); + } + }); +}); diff --git a/src/node/services/di/appRuntime.ts b/src/node/services/di/appRuntime.ts new file mode 100644 index 0000000000..77d15344a0 --- /dev/null +++ b/src/node/services/di/appRuntime.ts @@ -0,0 +1,117 @@ +/** + * App-lifetime Effect runtime (Effect migration Phase 11). + * + * A `ManagedRuntime` built from the process's Layer graph (`./layers/app.ts` + * for `ServiceContainer` roots). It owns the app-lifetime `Scope`, and its + * built `Context` is what oRPC Effect-native handlers receive as + * `"effect/context"`. + * + * DI contract (Phase 11 compatibility rules, not permanent architecture law): + * + * - **Synchronous layer bodies.** Every Layer in the graph must build without + * suspending (`Layer.succeed`/`Layer.sync`/`Layer.effect` over sync effects; + * `acquireRelease` with a sync acquire is fine). `makeAppRuntime` builds the + * graph eagerly with `runSync` and asserts that it completed, so a layer that + * suspends fails right here — at construction, exactly where a throwing + * service constructor fails today, and therefore inside every entry point's + * existing startup catch path (`desktop/main.ts` dialog, `cli/server.ts`/ACP + * log-and-exit). Asynchronous acquisition belongs in `initialize()` or a + * later explicit async factory root, never silently inside a layer. Eager + * building also keeps fibers started through the runtime synchronous up to + * their first async boundary (`cachedContext` is set, so `runX` is + * `Effect.run…With(context)`), which the deterministic-winner funnels in the + * codebase rely on. + * - **Only the composition root holds the runtime.** Services must not be + * handed the `ManagedRuntime`: after `dispose()` every `runX` on it dies with + * "ManagedRuntime disposed", and a late worker callback would defect. + * - **No layer finalizers yet.** Teardown order stays explicit in + * `ServiceContainer.dispose()`; layer bodies register no finalizers, so + * `disposeAppRuntime` (the last dispose step) reorders nothing. It is wired + * now so that later phases (the streamManager engine core) have a fixed, + * bounded slot for scope-owned resources. + */ +import assert from "@/common/utils/assert"; +import { Context, Duration, Effect, Fiber, ManagedRuntime } from "effect"; +import type { Layer } from "effect"; +import { APP_RUNTIME_DISPOSE_TIMEOUT_MS } from "@/constants/terminationTimeouts"; +import { log } from "@/node/services/log"; + +export interface AppRuntime { + /** The runtime that owns the layer scope. Composition roots only (see contract). */ + readonly managed: ManagedRuntime.ManagedRuntime; + /** The built service context; also the oRPC `"effect/context"`. */ + readonly context: Context.Context; + /** Resolve a service instance from the built context. */ + readonly get: (tag: Context.Key) => S; +} + +/** + * Build the runtime for `layer` eagerly and synchronously. Throws (constructor + * semantics) if the graph cannot be built without suspending or a layer body + * throws. + */ +export function makeAppRuntime(layer: Layer.Layer): AppRuntime { + const startedAt = performance.now(); + const managed = ManagedRuntime.make(layer); + const context = managed.runSync(Effect.context()); + assert( + managed.cachedContext !== undefined, + "AppRuntime layer graph must build synchronously (see DI contract in di/appRuntime.ts)" + ); + log.debug("[startup] AppRuntime built", { ms: Math.round(performance.now() - startedAt) }); + return { + managed, + context, + get: (tag) => Context.get(context, tag), + }; +} + +/** + * Close the runtime's scope (interrupting fibers started through it and running + * layer finalizers in reverse order), bounded by `timeoutMs`. Never rejects and + * is idempotent: a second call finds the scope already closed and returns. + * + * The close runs in a detached fiber and the caller waits on its join, so a + * hung finalizer cannot pin shutdown past the bound — the wait is interrupted, + * the finalizer keeps running best-effort, and a warning is logged. + */ +export function disposeAppRuntime( + runtime: ManagedRuntime.ManagedRuntime, + timeoutMs: number = APP_RUNTIME_DISPOSE_TIMEOUT_MS +): Promise { + return Effect.runPromise(disposeAppRuntimeEffect(runtime, timeoutMs)); +} + +function disposeAppRuntimeEffect( + runtime: ManagedRuntime.ManagedRuntime, + timeoutMs: number +): Effect.Effect { + const startedAt = performance.now(); + // Uninterruptible teardown shell; only the bounded wait inside is interruptible + // so the timeout can win the race (house shape from #4038). + return Effect.uninterruptible( + Effect.gen(function* () { + const closing = yield* Effect.forkDetach(runtime.disposeEffect); + yield* Effect.interruptible( + Fiber.join(closing).pipe(Effect.timeout(Duration.millis(timeoutMs))) + ).pipe( + Effect.catchTag("TimeoutError", () => + Effect.sync(() => { + log.warn("[shutdown] AppRuntime dispose timed out; finalizers continue best-effort", { + timeoutMs, + }); + }) + ) + ); + log.debug("[shutdown] AppRuntime disposed", { + ms: Math.round(performance.now() - startedAt), + }); + }).pipe( + Effect.catchDefect((defect) => + Effect.sync(() => { + log.warn("[shutdown] AppRuntime dispose failed", { error: defect }); + }) + ) + ) + ); +} diff --git a/src/node/services/di/layers/app.ts b/src/node/services/di/layers/app.ts new file mode 100644 index 0000000000..0e24acae66 --- /dev/null +++ b/src/node/services/di/layers/app.ts @@ -0,0 +1,18 @@ +import { Layer } from "effect"; +import type { ConfigStores } from "@/node/config"; +import type { AppTags } from "@/node/services/di/tags"; +import { MemoryMetaLive } from "./core"; +import { StoresLive } from "./stores"; + +/** + * Full Layer graph for a `ServiceContainer` process (desktop, `xum server`, + * ACP, tests/ipc, headless bench). + * + * Composition direction: `consumer.pipe(Layer.provideMerge(provider))` — the + * right-hand operand satisfies the left-hand operand's requirements and both + * stay exposed in the final context. `Layer.mergeAll` is only for true + * siblings; it does not satisfy one sibling's requirements from another. + */ +export function AppLive(stores: ConfigStores): Layer.Layer { + return MemoryMetaLive.pipe(Layer.provideMerge(StoresLive(stores))); +} diff --git a/src/node/services/di/layers/core.ts b/src/node/services/di/layers/core.ts new file mode 100644 index 0000000000..d0be98c8ff --- /dev/null +++ b/src/node/services/di/layers/core.ts @@ -0,0 +1,15 @@ +import { Effect, Layer } from "effect"; +import { ConfigTag, MemoryMeta } from "@/node/services/di/tags"; +import { MemoryMetaService } from "@/node/services/memoryMeta"; + +/** + * Layers for the core service graph shared by the desktop/server app and the + * headless CLI roots. Bodies are thin adapters around the existing constructors + * and must stay synchronous (see the DI contract in `../appRuntime.ts`). + */ + +/** Memory metadata sidecar; scope root derives from the xum home (`config.rootDir`). */ +export const MemoryMetaLive: Layer.Layer = Layer.effect( + MemoryMeta, + Effect.map(ConfigTag, (config) => new MemoryMetaService(config.rootDir)) +); diff --git a/src/node/services/di/layers/stores.ts b/src/node/services/di/layers/stores.ts new file mode 100644 index 0000000000..2bdefff9da --- /dev/null +++ b/src/node/services/di/layers/stores.ts @@ -0,0 +1,26 @@ +import { Layer } from "effect"; +import type { ConfigStores } from "@/node/config"; +import { + ConfigTag, + FileLeaseManagerTag, + ProvidersConfigStoreTag, + SecretsStoreTag, + SessionLocatorTag, + type StoreTags, +} from "@/node/services/di/tags"; + +/** + * Exposes an already-constructed `ConfigStores` bundle as Layer outputs. The + * stores are true siblings (no inter-dependencies), so `mergeAll` is correct + * here; anything that depends on a store must be composed with + * `Layer.provideMerge` instead. + */ +export function StoresLive(stores: ConfigStores): Layer.Layer { + return Layer.mergeAll( + Layer.succeed(ConfigTag)(stores.config), + Layer.succeed(SessionLocatorTag)(stores.sessionLocator), + Layer.succeed(ProvidersConfigStoreTag)(stores.providersConfigStore), + Layer.succeed(SecretsStoreTag)(stores.secretsStore), + Layer.succeed(FileLeaseManagerTag)(stores.fileLeaseManager) + ); +} diff --git a/src/node/services/di/tags.ts b/src/node/services/di/tags.ts new file mode 100644 index 0000000000..be2aead7b6 --- /dev/null +++ b/src/node/services/di/tags.ts @@ -0,0 +1,54 @@ +/** + * Effect service tags for the app dependency graph (Effect migration Phase 11). + * + * One tag per service class provided by the Layer graph in `./layers/*`. The + * service classes are imported as types only, so this module has no runtime + * dependency on them and can be imported from anywhere (layers, oRPC handlers, + * tests) without creating import cycles. + * + * Naming: the class name minus a trailing `Service` (`MemoryMeta` for + * `MemoryMetaService`); classes without that suffix, or whose bare name would + * collide with the exported class, take a `Tag` suffix (`ConfigTag`). Ids are + * `"xum/"`. + */ +import { Context } from "effect"; +import type { + Config, + FileLeaseManager, + ProvidersConfigStore, + SecretsStore, + WorkspaceSessionLocator, +} from "@/node/config"; +import type { MemoryMetaService } from "@/node/services/memoryMeta"; + +export class ConfigTag extends Context.Service()("xum/Config") {} +export class SessionLocatorTag extends Context.Service< + SessionLocatorTag, + WorkspaceSessionLocator +>()("xum/SessionLocator") {} +export class ProvidersConfigStoreTag extends Context.Service< + ProvidersConfigStoreTag, + ProvidersConfigStore +>()("xum/ProvidersConfigStore") {} +export class SecretsStoreTag extends Context.Service()( + "xum/SecretsStore" +) {} +export class FileLeaseManagerTag extends Context.Service()( + "xum/FileLeaseManager" +) {} + +/** Host-local sidecar for user-owned memory metadata (pins + usage stats). */ +export class MemoryMeta extends Context.Service()( + "xum/MemoryMeta" +) {} + +/** The process's config stores (`ConfigStores`), one tag per store. */ +export type StoreTags = + | ConfigTag + | SessionLocatorTag + | ProvidersConfigStoreTag + | SecretsStoreTag + | FileLeaseManagerTag; + +/** Every service the desktop/server app graph (`AppLive`) provides. */ +export type AppTags = StoreTags | MemoryMeta; diff --git a/src/node/services/serviceContainer.test.ts b/src/node/services/serviceContainer.test.ts index 9c3812580e..3b7a3f7fc5 100644 --- a/src/node/services/serviceContainer.test.ts +++ b/src/node/services/serviceContainer.test.ts @@ -2,8 +2,11 @@ import * as path from "path"; import * as fs from "fs"; import * as os from "os"; import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import { Context, Layer } from "effect"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import { createConfigStores, type Config, type ConfigStores } from "@/node/config"; +import * as appLayers from "@/node/services/di/layers/app"; +import { MemoryMeta } from "@/node/services/di/tags"; import { ServiceContainer } from "./serviceContainer"; describe("ServiceContainer", () => { @@ -108,4 +111,51 @@ describe("ServiceContainer", () => { expect(closeAllSpy).toHaveBeenCalledTimes(1); }); + + it("serves the layer-built MemoryMetaService through both the field and the Effect context", () => { + services = new ServiceContainer(stores); + + const effectContext = services.toORPCContext()["effect/context"]; + + // One instance: constructor-wired consumers (memoryService, refineService) + // and Effect-native oRPC handlers (`yield* MemoryMeta`) must share state. + expect(Context.get(effectContext, MemoryMeta)).toBe(services.memoryMetaService); + expect(services.runtime.get(MemoryMeta)).toBe(services.memoryMetaService); + }); + + it("closes the Effect runtime as the last dispose step", async () => { + services = new ServiceContainer(stores); + const container = services; + let runtimeAliveAtLastExplicitStep: boolean | undefined; + // timelineService.flush() is the final explicit teardown step; the runtime + // must still be alive when it runs and gone once dispose() resolves. + const flushSpy = spyOn(services.timelineService, "flush").mockImplementation(() => { + runtimeAliveAtLastExplicitStep = container.runtime.managed.cachedContext !== undefined; + return Promise.resolve(undefined); + }); + + await services.dispose(); + + expect(flushSpy).toHaveBeenCalledTimes(1); + expect(runtimeAliveAtLastExplicitStep).toBe(true); + // ManagedRuntime clears its cached context when its scope closes. + expect(services.runtime.managed.cachedContext).toBeUndefined(); + // The afterEach dispose()+shutdown() pair then exercises the latched path. + }); + + it("surfaces a throwing layer as a synchronous constructor throw", () => { + const realAppLive = appLayers.AppLive; + const appLiveSpy = spyOn(appLayers, "AppLive").mockImplementation((appStores) => + Layer.sync(MemoryMeta, () => { + throw new Error("layer boom"); + }).pipe(Layer.provideMerge(realAppLive(appStores))) + ); + try { + // Same shape as a throwing service constructor, so the entry points' + // existing startup catch paths (dialog / log-and-exit) apply unchanged. + expect(() => new ServiceContainer(stores)).toThrow("layer boom"); + } finally { + appLiveSpy.mockRestore(); + } + }); }); diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index cb0f16b075..cbe9bdbf5d 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -81,14 +81,22 @@ import { DesktopBridgeServer } from "@/node/services/desktop/DesktopBridgeServer import { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager"; import { DesktopTokenManager } from "@/node/services/desktop/DesktopTokenManager"; import type { ORPCContext } from "@/node/orpc/context"; -import { buildOrpcEffectContext } from "@/node/orpc/effectContext"; +import { disposeAppRuntime, makeAppRuntime, type AppRuntime } from "@/node/services/di/appRuntime"; +import { AppLive } from "@/node/services/di/layers/app"; +import { MemoryMeta, type AppTags } from "@/node/services/di/tags"; /** * ServiceContainer - Central dependency container for all backend services. * * This class instantiates and wires together all services needed by the ORPC router. * Services are accessed via the ORPC context object. + * + * Services provided by the Effect Layer graph (`di/layers/app.ts`) are built + * first by `runtime` and handed to the constructor-wired remainder; the + * migration moves services into the graph incrementally (see the DI contract in + * `di/appRuntime.ts`). */ export class ServiceContainer { + public readonly runtime: AppRuntime; public readonly workflowRuntimeFactory = new QuickJSRuntimeFactory(); public readonly config: Config; public readonly sessionLocator: WorkspaceSessionLocator; @@ -157,8 +165,13 @@ export class ServiceContainer { public readonly idleDispatcher: IdleDispatcher; public readonly heartbeatService: HeartbeatService; public readonly agentStatusService: AgentStatusService; + private runtimeDisposed = false; constructor(stores: ConfigStores) { + // Built eagerly and synchronously (a layer body that throws fails the + // constructor, like any service constructor) before the constructor-wired + // services so layer-provided instances can be passed into them. + this.runtime = makeAppRuntime(AppLive(stores)); const config = stores.config; this.config = config; this.sessionLocator = stores.sessionLocator; @@ -193,6 +206,7 @@ export class ServiceContainer { ...stores, extensionMetadataPath: path.join(config.rootDir, "extensionMetadata.json"), workspaceMcpOverridesService: this.workspaceMcpOverridesService, + memoryMetaService: this.runtime.get(MemoryMeta), policyService: this.policyService, telemetryService: this.telemetryService, analyticsService: this.analyticsService, @@ -648,9 +662,9 @@ export class ServiceContainer { */ toORPCContext(): Omit { return { - // Pre-built Effect service context consumed by Effect-native oRPC - // handlers (see src/node/orpc/effectContext.ts). - "effect/context": buildOrpcEffectContext({ memoryMetaService: this.memoryMetaService }), + // The runtime's built service context, consumed by Effect-native oRPC + // handlers (`yield* MemoryMeta`; see src/node/orpc/effectContext.ts). + "effect/context": this.runtime.context, workflowRuntimeFactory: this.workflowRuntimeFactory, config: this.config, sessionLocator: this.sessionLocator, @@ -776,5 +790,14 @@ export class ServiceContainer { this.providerService.dispose(); await this.backgroundProcessManager.terminateAll(); await this.timelineService.flush(); + // Last: close the Effect runtime's scope. No layer owns finalizers yet, so + // this only releases the runtime; the position (after every explicit + // teardown step) is fixed now for later scope-owned occupants. Latched so + // the concurrent before-quit paths and dispose-then-shutdown callers cannot + // race a second close into the bounded wait. + if (!this.runtimeDisposed) { + this.runtimeDisposed = true; + await disposeAppRuntime(this.runtime.managed); + } } }