From c6494b1d294e9460662e995c159904d8a8d42282 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 2 Sep 2026 05:17:08 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20Effect=20Phase=2011=20PR=203=20?= =?UTF-8?q?=E2=80=94=20coarse=20CoreProjectionLive=20+=20createCoreService?= =?UTF-8?q?s=20facade=20+=20CLI=20runtime=20disposal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli/run.ts | 10 +- src/cli/workflow.test.ts | 34 +++++ src/cli/workflow.ts | 12 +- src/node/services/coreServices.ts | 14 +- src/node/services/coreServicesRoot.test.ts | 170 +++++++++++++++++++++ src/node/services/coreServicesRoot.ts | 37 +++++ src/node/services/di/layers/app.ts | 14 +- src/node/services/di/layers/core.ts | 151 +++++++++++++++++- src/node/services/di/layers/desktop.ts | 84 ++++++++++ src/node/services/di/layers/stores.ts | 27 +++- src/node/services/di/tags.ts | 129 +++++++++++++++- src/node/services/serviceContainer.test.ts | 67 +++++++- src/node/services/serviceContainer.ts | 85 +++++------ 13 files changed, 777 insertions(+), 57 deletions(-) create mode 100644 src/node/services/coreServicesRoot.test.ts create mode 100644 src/node/services/coreServicesRoot.ts create mode 100644 src/node/services/di/layers/desktop.ts diff --git a/src/cli/run.ts b/src/cli/run.ts index 973ddc48fb3..b5624fc04bd 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -23,7 +23,8 @@ import { CodexOauthService } from "../node/services/codexOauthService"; import { CoderOauthService } from "../node/services/coderOauthService"; import { PolicyService } from "../node/services/policyService"; import { ProviderService } from "../node/services/providerService"; -import { createCoreServices } from "../node/services/coreServices"; +import { createCoreServices } from "../node/services/coreServicesRoot"; +import { closeScopeBounded, disposeAppRuntime } from "../node/services/di/appRuntime"; import { isCaughtUpMessage, isReasoningDelta, @@ -658,6 +659,8 @@ async function main(): Promise { idleDispatcher, streamManager, turnRequestBuilderBindings, + runtime: coreRuntime, + appFiberScope, } = createCoreServices({ ...runStores, policyService, @@ -1571,6 +1574,9 @@ async function main(): Promise { name: "backgroundProcessManager.beginShutdown", run: () => backgroundProcessManager.beginShutdown(), }, + // Interrupt + await the runtime's supervised fibers while their + // dependencies are still alive (same slot as ServiceContainer.dispose). + { name: "appFiberScope.close", run: () => closeScopeBounded(appFiberScope) }, { name: "session.dispose", run: () => session.dispose() }, { name: "mcpServerManager.dispose", run: () => mcpServerManager.dispose() }, { name: "codexOauthService.dispose", run: () => codexOauthService.dispose() }, @@ -1585,6 +1591,8 @@ async function main(): Promise { run: () => backgroundProcessManager.terminateAll(), }, ]), + // Last: release the Effect runtime that owns the core graph. + { name: "appRuntime.dispose", run: () => disposeAppRuntime(coreRuntime.managed) }, ], (stepName, error) => { const message = getErrorMessage(error); diff --git a/src/cli/workflow.test.ts b/src/cli/workflow.test.ts index 0bb57b175ea..536b225fdb8 100644 --- a/src/cli/workflow.test.ts +++ b/src/cli/workflow.test.ts @@ -78,6 +78,40 @@ describe("xum workflow CLI helpers", () => { expect(result.exitCode).toBe(0); }); + // The CLI root owns an Effect runtime (createCoreServices). Its cleanup must + // close the supervised fiber scope before the session-level disposers and + // release the runtime after the background processes are terminated, mirroring + // ServiceContainer.dispose(); the debug shutdown lines pin that order. + test("CLI run closes the AppFiberScope first and disposes the AppRuntime last", async () => { + using tmp = new DisposableTempDir("workflow-cli-runtime"); + const repo = path.join(tmp.path, "repo"); + const muxRoot = path.join(tmp.path, "mux-root"); + await fs.mkdir(path.join(repo, "workflows"), { recursive: true }); + await fs.mkdir(muxRoot, { recursive: true }); + await fs.writeFile( + path.join(repo, "workflows", "echo.js"), + `export default function workflow() { return { reportMarkdown: "ok" }; } +`, + "utf-8" + ); + await trustProject(muxRoot, repo); + + const result = + await Bun.$`${BUN_EXECUTABLE} ${INDEX_ENTRY} wf run ./workflows/echo.js --dir ${repo}` + .env({ ...process.env, MUX_ROOT: muxRoot, XUM_LOG_LEVEL: "debug", NO_COLOR: "1" }) + .nothrow() + .quiet(); + + expect(result.exitCode).toBe(0); + const output = result.stdout.toString() + result.stderr.toString(); + const scopeClosedAt = output.indexOf("[shutdown] AppFiberScope closed"); + const terminateAllAt = output.indexOf("BackgroundProcessManager.terminateAll() called"); + const runtimeDisposedAt = output.indexOf("[shutdown] AppRuntime disposed"); + expect(scopeClosedAt).toBeGreaterThan(output.indexOf("[startup] AppRuntime built")); + expect(terminateAllAt).toBeGreaterThan(scopeClosedAt); + expect(runtimeDisposedAt).toBeGreaterThan(terminateAllAt); + }); + // Regression: headless `xum workflow` must initialize PolicyService and thread // it through the core service graph like the desktop wiring. Without it, a // stored credential for a provider that MUX_POLICY_FILE / Xum Governor now diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts index 5ee7a771610..3e2c13634a6 100644 --- a/src/cli/workflow.ts +++ b/src/cli/workflow.ts @@ -30,7 +30,8 @@ import { CodexOauthService } from "@/node/services/codexOauthService"; import { CoderOauthService } from "@/node/services/coderOauthService"; import { PolicyService } from "@/node/services/policyService"; import { ProviderService } from "@/node/services/providerService"; -import { createCoreServices } from "@/node/services/coreServices"; +import { createCoreServices } from "@/node/services/coreServicesRoot"; +import { closeScopeBounded, disposeAppRuntime } from "@/node/services/di/appRuntime"; import { log, type LogLevel } from "@/node/services/log"; import { DisposableTempDir } from "@/node/services/tempDir"; import { QuickJSRuntimeFactory } from "@/node/services/ptc/quickjsRuntime"; @@ -269,6 +270,11 @@ async function disposeWorkflowResources(input: { // Suppress monitor:stopped before session.dispose() triggers cleanup() so persisted // armed-monitor registry records survive shutdown (post-restart "monitor lost" wakes). input.services?.backgroundProcessManager.beginShutdown(); + // Interrupt + await the runtime's supervised fibers while their dependencies + // are still alive (same slot as ServiceContainer.dispose); never rejects. + if (input.services) { + await closeScopeBounded(input.services.appFiberScope); + } try { input.session?.dispose(); } catch (error) { @@ -316,6 +322,10 @@ async function disposeWorkflowResources(input: { error: getErrorMessage(error), }); } + // Last: release the Effect runtime that owns the core graph; never rejects. + if (input.services) { + await disposeAppRuntime(input.services.runtime.managed); + } input.tempDir[Symbol.dispose](); } diff --git a/src/node/services/coreServices.ts b/src/node/services/coreServices.ts index 11f08a3eb63..73f251c1390 100644 --- a/src/node/services/coreServices.ts +++ b/src/node/services/coreServices.ts @@ -1,5 +1,13 @@ /** - * Core service graph shared by `xum run` (CLI) and `ServiceContainer` (desktop). + * Core service graph shared by `xum run`/`xum workflow` (CLI) and + * `ServiceContainer` (desktop). + * + * `buildCoreGraph` is the imperative construction body. Both roots reach it + * through the Effect Layer graph — `CoreProjectionLive` in `di/layers/core.ts` + * wraps it as a single coarse layer (Effect migration Phase 11) — so the roots + * are `createCoreServices` (`./coreServicesRoot.ts`, CLI) and `AppLive` + * (`di/layers/app.ts`, desktop). Construction order and wiring here are the + * behavioral contract the per-service layers of the next phase must replay. */ import * as os from "os"; @@ -105,7 +113,7 @@ export interface CoreServices { turnRequestBuilderBindings: TurnRequestBuilderBindings; } -export function createCoreServices(opts: CoreServicesOptions): CoreServices { +export function buildCoreGraph(opts: CoreServicesOptions): CoreServices { const { config, extensionMetadataPath } = opts; const sessionLocator = opts.sessionLocator ?? new WorkspaceSessionLocator(config.rootDir); @@ -357,7 +365,7 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { workspaceService.setAgentTaskIntegration(taskService); // Goal continuation bridge lives at the core scope so every codepath that - // uses createCoreServices (xum run, xum server via ServiceContainer, tests) + // uses the core graph (xum run, xum server via ServiceContainer, tests) // gets a working dispatcher. Without this, requestContinuationAfterStreamEnd // is a no-op and the auto-continuation loop never fires. The dispatcher is // also exposed so ServiceContainer can share it with HeartbeatService. diff --git a/src/node/services/coreServicesRoot.test.ts b/src/node/services/coreServicesRoot.test.ts new file mode 100644 index 00000000000..c469209e166 --- /dev/null +++ b/src/node/services/coreServicesRoot.test.ts @@ -0,0 +1,170 @@ +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import type { Context } from "effect"; +import { createConfigStores, type ConfigStores } from "@/node/config"; +import * as coreServices from "@/node/services/coreServices"; +import type { CoreServices } from "@/node/services/coreServices"; +import { AppFiberScopeTag } from "@/node/services/di/appFiberScope"; +import { closeScopeBounded, disposeAppRuntime } from "@/node/services/di/appRuntime"; +import { EffectRunnerTag } from "@/node/services/di/effectRunner"; +import { CoreOptionsTag } from "@/node/services/di/layers/core"; +import { + AI, + BackgroundProcessManagerTag, + ConfigTag, + ExtensionMetadata, + FileLeaseManagerTag, + History, + IdleDispatcherTag, + InitStateManagerTag, + MCPConfig, + MCPServerManagerTag, + Memory, + MemoryConsolidation, + MemoryMeta, + Provider, + ProvidersConfigStoreTag, + SecretsStoreTag, + SessionLocatorTag, + SessionUsage, + StreamManagerTag, + Task, + TurnRequestBuilderBindingsTag, + Workspace, + WorkspaceGoal, + WorkspaceTurnManagerTag, + type CoreRootTags, + type CoreTags, +} from "@/node/services/di/tags"; +import { createCoreServices, type CoreServicesRoot } from "./coreServicesRoot"; + +/** + * Independent field → tag listing (the production mapping lives in + * di/layers/core.ts); `Record` keeps it exhaustive. + */ +const CORE_FIELD_TAGS: Record> = { + historyService: History, + initStateManager: InitStateManagerTag, + providerService: Provider, + backgroundProcessManager: BackgroundProcessManagerTag, + sessionUsageService: SessionUsage, + workspaceGoalService: WorkspaceGoal, + idleDispatcher: IdleDispatcherTag, + aiService: AI, + streamManager: StreamManagerTag, + mcpConfigService: MCPConfig, + mcpServerManager: MCPServerManagerTag, + extensionMetadata: ExtensionMetadata, + workspaceService: Workspace, + taskService: Task, + workspaceTurnManager: WorkspaceTurnManagerTag, + memoryService: Memory, + memoryMetaService: MemoryMeta, + memoryConsolidationService: MemoryConsolidation, + turnRequestBuilderBindings: TurnRequestBuilderBindingsTag, +}; + +describe("createCoreServices", () => { + let tempDir: string; + let stores: ConfigStores; + let root: CoreServicesRoot | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-core-root-test-")); + stores = createConfigStores(tempDir); + }); + + afterEach(async () => { + if (root) { + // The CLI cleanup order: supervised scope first, runtime last. + await closeScopeBounded(root.appFiberScope); + await disposeAppRuntime(root.runtime.managed); + root = undefined; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("serves every CoreServices field through its tag (one instance each)", () => { + root = createCoreServices({ + ...stores, + extensionMetadataPath: path.join(tempDir, "extensionMetadata.json"), + }); + + for (const [field, tag] of Object.entries(CORE_FIELD_TAGS) as Array< + [keyof CoreServices, Context.Key] + >) { + expect(root.runtime.get(tag)).toBe(root[field]); + } + expect(root.appFiberScope).toBe(root.runtime.get(AppFiberScopeTag)); + expect(root.appFiberScope.state._tag).not.toBe("Closed"); + expect(root.runtime.get(EffectRunnerTag)).toBeDefined(); + }); + + it("uses the caller's stores and carries the remaining options under CoreOptionsTag", () => { + const extensionMetadataPath = path.join(tempDir, "extensionMetadata.json"); + root = createCoreServices({ ...stores, extensionMetadataPath, mcpConfig: stores.config }); + + expect(root.runtime.get(ConfigTag)).toBe(stores.config); + expect(root.runtime.get(SessionLocatorTag)).toBe(stores.sessionLocator); + expect(root.runtime.get(ProvidersConfigStoreTag)).toBe(stores.providersConfigStore); + expect(root.runtime.get(SecretsStoreTag)).toBe(stores.secretsStore); + expect(root.runtime.get(FileLeaseManagerTag)).toBe(stores.fileLeaseManager); + // Options minus stores, exactly as passed (no cross-cutting services in a CLI root). + expect(root.runtime.get(CoreOptionsTag)).toEqual({ + extensionMetadataPath, + mcpConfig: stores.config, + }); + }); + + it("defaults omitted stores to the config root, like the graph body did", () => { + root = createCoreServices({ + config: stores.config, + extensionMetadataPath: path.join(tempDir, "extensionMetadata.json"), + }); + + expect(root.runtime.get(ConfigTag)).toBe(stores.config); + expect(root.runtime.get(SessionLocatorTag).rootDir).toBe(stores.config.rootDir); + expect(root.runtime.get(ProvidersConfigStoreTag).rootDir).toBe(stores.config.rootDir); + expect(root.runtime.get(SecretsStoreTag).rootDir).toBe(stores.config.rootDir); + expect(root.runtime.get(FileLeaseManagerTag).rootDir).toBe(stores.config.rootDir); + expect(root.runtime.get(SessionLocatorTag)).not.toBe(stores.sessionLocator); + }); + + it("releases the runtime through the CLI cleanup steps, idempotently", async () => { + root = createCoreServices({ + ...stores, + extensionMetadataPath: path.join(tempDir, "extensionMetadata.json"), + }); + const { appFiberScope, runtime } = root; + + await closeScopeBounded(appFiberScope); + expect(appFiberScope.state._tag).toBe("Closed"); + // Dependencies are still alive between the two steps (the explicit CLI + // disposers run here). + expect(runtime.managed.cachedContext).toBeDefined(); + + await disposeAppRuntime(runtime.managed); + expect(runtime.managed.cachedContext).toBeUndefined(); + // The afterEach pair then exercises the idempotent second close/dispose. + }); + + it("surfaces a throwing graph body as a synchronous throw", () => { + const buildSpy = spyOn(coreServices, "buildCoreGraph").mockImplementation(() => { + throw new Error("core boom"); + }); + try { + // Same shape as a throwing service constructor, so the CLI roots' existing + // startup error paths apply unchanged. + expect(() => + createCoreServices({ + ...stores, + extensionMetadataPath: path.join(tempDir, "extensionMetadata.json"), + }) + ).toThrow("core boom"); + } finally { + buildSpy.mockRestore(); + } + }); +}); diff --git a/src/node/services/coreServicesRoot.ts b/src/node/services/coreServicesRoot.ts new file mode 100644 index 00000000000..cb5929217f6 --- /dev/null +++ b/src/node/services/coreServicesRoot.ts @@ -0,0 +1,37 @@ +/** + * Composition root for the headless CLI processes (`xum run`, `xum workflow`). + * + * Builds the core service graph through the same Layer definitions the desktop + * `ServiceContainer` uses (`di/layers/core.ts`) and hands back the plain + * `CoreServices` object the CLI code consumes, plus the runtime handles the + * CLI's cleanup list must release: `closeScopeBounded(appFiberScope)` before + * the session is disposed and `disposeAppRuntime(runtime.managed)` as the very + * last step (see the dispose order in `ServiceContainer` and the DI contract in + * `di/appRuntime.ts`). + */ +import type { Scope } from "effect"; +import type { CoreServices, CoreServicesOptions } from "@/node/services/coreServices"; +import { AppFiberScopeTag } from "@/node/services/di/appFiberScope"; +import { makeAppRuntime, type AppRuntime } from "@/node/services/di/appRuntime"; +import { CoreRootLive, coreServicesFromContext } from "@/node/services/di/layers/core"; +import type { CoreRootTags } from "@/node/services/di/tags"; + +export interface CoreServicesRoot extends CoreServices { + /** The runtime that owns the graph; composition roots only (DI contract). */ + readonly runtime: AppRuntime; + /** Supervised fiber scope (`di/appFiberScope.ts`); closed first during cleanup. */ + readonly appFiberScope: Scope.Closeable; +} + +/** + * Synchronous, like every service constructor: a layer body that throws (or + * suspends) fails here, inside the caller's existing startup error path. + */ +export function createCoreServices(opts: CoreServicesOptions): CoreServicesRoot { + const runtime = makeAppRuntime(CoreRootLive(opts)); + return { + ...coreServicesFromContext(runtime.context), + runtime, + appFiberScope: runtime.get(AppFiberScopeTag), + }; +} diff --git a/src/node/services/di/layers/app.ts b/src/node/services/di/layers/app.ts index 853b515fab5..a7a9916d4d6 100644 --- a/src/node/services/di/layers/app.ts +++ b/src/node/services/di/layers/app.ts @@ -3,7 +3,8 @@ import type { ConfigStores } from "@/node/config"; import { AppFiberScopeLive } from "@/node/services/di/appFiberScope"; import { EffectRunnerLive } from "@/node/services/di/effectRunner"; import type { AppTags } from "@/node/services/di/tags"; -import { MemoryMetaLive } from "./core"; +import { CoreProjectionLive, MemoryMetaLive } from "./core"; +import { CoreOptionsFromDesktopLive, CrossCuttingLive } from "./desktop"; import { StoresLive } from "./stores"; /** @@ -17,11 +18,18 @@ import { StoresLive } from "./stores"; * * The runtime seams sit at the base, above the stores: `EffectRunnerLive` * captures its building context, so placing it there keeps that context to the - * stores plus references (`Clock`, …). + * stores plus references (`Clock`, …). Above them the graph replays the + * constructor's former order: memory metadata, the cross-cutting services, the + * core options derived from them, then the core graph. */ export function AppLive(stores: ConfigStores): Layer.Layer { const runtimeSeams = AppFiberScopeLive.pipe( Layer.provideMerge(EffectRunnerLive.pipe(Layer.provideMerge(StoresLive(stores)))) ); - return MemoryMetaLive.pipe(Layer.provideMerge(runtimeSeams)); + return CoreProjectionLive.pipe( + Layer.provideMerge(CoreOptionsFromDesktopLive), + Layer.provideMerge(CrossCuttingLive), + Layer.provideMerge(MemoryMetaLive), + Layer.provideMerge(runtimeSeams) + ); } diff --git a/src/node/services/di/layers/core.ts b/src/node/services/di/layers/core.ts index d0be98c8ffd..288dbdaa4ef 100644 --- a/src/node/services/di/layers/core.ts +++ b/src/node/services/di/layers/core.ts @@ -1,6 +1,43 @@ -import { Effect, Layer } from "effect"; -import { ConfigTag, MemoryMeta } from "@/node/services/di/tags"; +import { Context, Effect, Layer } from "effect"; +import type { ConfigStores } from "@/node/config"; +import { + buildCoreGraph, + type CoreServices, + type CoreServicesOptions, +} from "@/node/services/coreServices"; +import { AppFiberScopeLive } from "@/node/services/di/appFiberScope"; +import { EffectRunnerLive } from "@/node/services/di/effectRunner"; +import { + AI, + BackgroundProcessManagerTag, + ConfigTag, + ExtensionMetadata, + FileLeaseManagerTag, + History, + IdleDispatcherTag, + InitStateManagerTag, + MCPConfig, + MCPServerManagerTag, + Memory, + MemoryConsolidation, + MemoryMeta, + Provider, + ProvidersConfigStoreTag, + SecretsStoreTag, + SessionLocatorTag, + SessionUsage, + StreamManagerTag, + Task, + TurnRequestBuilderBindingsTag, + Workspace, + WorkspaceGoal, + WorkspaceTurnManagerTag, + type CoreRootTags, + type CoreTags, + type StoreTags, +} from "@/node/services/di/tags"; import { MemoryMetaService } from "@/node/services/memoryMeta"; +import { StoresFromCoreOptionsLive } from "./stores"; /** * Layers for the core service graph shared by the desktop/server app and the @@ -13,3 +50,113 @@ export const MemoryMetaLive: Layer.Layer = Layer.e MemoryMeta, Effect.map(ConfigTag, (config) => new MemoryMetaService(config.rootDir)) ); + +/** + * The core graph's inputs other than the stores: today's `CoreServicesOptions` + * minus `ConfigStores`. The optional cross-cutting services stay optional here + * (present in the desktop graph, absent in CLI roots), so core constructors + * see exactly the arguments they saw before. + */ +export type CoreOptions = Omit; + +export class CoreOptionsTag extends Context.Service()( + "xum/CoreOptions" +) {} + +/** + * Coarse projection of the whole core graph: runs today's imperative + * construction body (`buildCoreGraph`, unchanged) once and exposes every + * `CoreServices` field under its tag. Zero behavior change by construction — + * the peel into staged per-service layers is the next phase's work, gated on + * this layer's typecheck/startup budgets. + */ +export const CoreProjectionLive: Layer.Layer = + Layer.effectContext( + Effect.gen(function* () { + const opts = yield* CoreOptionsTag; + const core = buildCoreGraph({ + ...opts, + config: yield* ConfigTag, + sessionLocator: yield* SessionLocatorTag, + providersConfigStore: yield* ProvidersConfigStoreTag, + secretsStore: yield* SecretsStoreTag, + fileLeaseManager: yield* FileLeaseManagerTag, + }); + return coreContextFromServices(core); + }) + ); + +/** `CoreServices` → tagged context; inverse of `coreServicesFromContext`. */ +export function coreContextFromServices(core: CoreServices): Context.Context { + return Context.empty().pipe( + Context.add(History, core.historyService), + Context.add(InitStateManagerTag, core.initStateManager), + Context.add(Provider, core.providerService), + Context.add(BackgroundProcessManagerTag, core.backgroundProcessManager), + Context.add(SessionUsage, core.sessionUsageService), + Context.add(WorkspaceGoal, core.workspaceGoalService), + Context.add(IdleDispatcherTag, core.idleDispatcher), + Context.add(AI, core.aiService), + Context.add(StreamManagerTag, core.streamManager), + Context.add(MCPConfig, core.mcpConfigService), + Context.add(MCPServerManagerTag, core.mcpServerManager), + Context.add(ExtensionMetadata, core.extensionMetadata), + Context.add(Workspace, core.workspaceService), + Context.add(Task, core.taskService), + Context.add(WorkspaceTurnManagerTag, core.workspaceTurnManager), + Context.add(Memory, core.memoryService), + Context.add(MemoryMeta, core.memoryMetaService), + Context.add(MemoryConsolidation, core.memoryConsolidationService), + Context.add(TurnRequestBuilderBindingsTag, core.turnRequestBuilderBindings) + ); +} + +/** Tagged context → the plain `CoreServices` object the roots hand out. */ +export function coreServicesFromContext(context: Context.Context): CoreServices { + return { + historyService: Context.get(context, History), + initStateManager: Context.get(context, InitStateManagerTag), + providerService: Context.get(context, Provider), + backgroundProcessManager: Context.get(context, BackgroundProcessManagerTag), + sessionUsageService: Context.get(context, SessionUsage), + workspaceGoalService: Context.get(context, WorkspaceGoal), + idleDispatcher: Context.get(context, IdleDispatcherTag), + aiService: Context.get(context, AI), + streamManager: Context.get(context, StreamManagerTag), + mcpConfigService: Context.get(context, MCPConfig), + mcpServerManager: Context.get(context, MCPServerManagerTag), + extensionMetadata: Context.get(context, ExtensionMetadata), + workspaceService: Context.get(context, Workspace), + taskService: Context.get(context, Task), + workspaceTurnManager: Context.get(context, WorkspaceTurnManagerTag), + memoryService: Context.get(context, Memory), + memoryMetaService: Context.get(context, MemoryMeta), + memoryConsolidationService: Context.get(context, MemoryConsolidation), + turnRequestBuilderBindings: Context.get(context, TurnRequestBuilderBindingsTag), + }; +} + +/** + * Full Layer graph for a headless CLI root (`xum run`, `xum workflow`): the + * core projection over the caller's options, with the runtime seams at the + * base exactly as in `AppLive` (`./app.ts`). Composition direction is + * `consumer.pipe(Layer.provideMerge(provider))`; every tag stays exposed. + */ +export function CoreRootLive(opts: CoreServicesOptions): Layer.Layer { + // The stores travel under their own tags (StoresFromCoreOptionsLive). + const { + config, + sessionLocator, + providersConfigStore, + secretsStore, + fileLeaseManager, + ...coreOptions + } = opts; + const runtimeSeams = AppFiberScopeLive.pipe( + Layer.provideMerge(EffectRunnerLive.pipe(Layer.provideMerge(StoresFromCoreOptionsLive(opts)))) + ); + return CoreProjectionLive.pipe( + Layer.provideMerge(Layer.succeed(CoreOptionsTag)(coreOptions)), + Layer.provideMerge(runtimeSeams) + ); +} diff --git a/src/node/services/di/layers/desktop.ts b/src/node/services/di/layers/desktop.ts new file mode 100644 index 00000000000..062aeca645e --- /dev/null +++ b/src/node/services/di/layers/desktop.ts @@ -0,0 +1,84 @@ +import * as path from "path"; +import { Context, Effect, Layer } from "effect"; +import { AnalyticsService } from "@/node/services/analytics/analyticsService"; +import { DevToolsService } from "@/node/services/devToolsService"; +import { + Analytics, + ConfigTag, + DevTools, + Experiments, + MemoryMeta, + Policy, + SessionTiming, + Telemetry, + WorkspaceMcpOverrides, + type CrossCuttingTags, +} from "@/node/services/di/tags"; +import { ExperimentsService } from "@/node/services/experimentsService"; +import { PolicyService } from "@/node/services/policyService"; +import { SessionTimingService } from "@/node/services/sessionTimingService"; +import { TelemetryService } from "@/node/services/telemetryService"; +import { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; +import { CoreOptionsTag } from "./core"; + +/** + * Desktop/server-only layers (`ServiceContainer` roots). Only the services the + * core graph's options derive from live here so far; the remaining desktop + * constructions stay in the `ServiceContainer` constructor until they get + * their own group layers. + */ + +/** + * Cross-cutting services, built in the order the `ServiceContainer` + * constructor used before they moved here. Their constructors only capture + * arguments; `initialize()` stays with `ServiceContainer.initialize()`. + */ +export const CrossCuttingLive: Layer.Layer = + Layer.effectContext( + Effect.map(ConfigTag, (config) => { + const policyService = new PolicyService(config); + const telemetryService = new TelemetryService(config.rootDir); + const experimentsService = new ExperimentsService({ + telemetryService, + xumHome: config.rootDir, + }); + const sessionTimingService = new SessionTimingService(config, telemetryService); + const analyticsService = new AnalyticsService(config); + const devToolsService = new DevToolsService(config); + // Desktop passes WorkspaceMcpOverridesService explicitly so AIService uses + // the persistent config rather than creating a default with an ephemeral one. + const workspaceMcpOverridesService = new WorkspaceMcpOverridesService(config); + return Context.empty().pipe( + Context.add(Policy, policyService), + Context.add(Telemetry, telemetryService), + Context.add(Experiments, experimentsService), + Context.add(SessionTiming, sessionTimingService), + Context.add(Analytics, analyticsService), + Context.add(DevTools, devToolsService), + Context.add(WorkspaceMcpOverrides, workspaceMcpOverridesService) + ); + }) + ); + +/** The desktop's core graph options: every optional cross-cutting service present. */ +export const CoreOptionsFromDesktopLive: Layer.Layer< + CoreOptionsTag, + never, + ConfigTag | MemoryMeta | CrossCuttingTags +> = Layer.effect( + CoreOptionsTag, + Effect.gen(function* () { + const config = yield* ConfigTag; + return { + extensionMetadataPath: path.join(config.rootDir, "extensionMetadata.json"), + workspaceMcpOverridesService: yield* WorkspaceMcpOverrides, + memoryMetaService: yield* MemoryMeta, + policyService: yield* Policy, + telemetryService: yield* Telemetry, + analyticsService: yield* Analytics, + experimentsService: yield* Experiments, + sessionTimingService: yield* SessionTiming, + devToolsService: yield* DevTools, + }; + }) +); diff --git a/src/node/services/di/layers/stores.ts b/src/node/services/di/layers/stores.ts index 2bdefff9dac..a42756e4123 100644 --- a/src/node/services/di/layers/stores.ts +++ b/src/node/services/di/layers/stores.ts @@ -1,5 +1,12 @@ import { Layer } from "effect"; -import type { ConfigStores } from "@/node/config"; +import { + FileLeaseManager, + ProvidersConfigStore, + SecretsStore, + WorkspaceSessionLocator, + type ConfigStores, +} from "@/node/config"; +import type { CoreServicesOptions } from "@/node/services/coreServices"; import { ConfigTag, FileLeaseManagerTag, @@ -24,3 +31,21 @@ export function StoresLive(stores: ConfigStores): Layer.Layer { Layer.succeed(FileLeaseManagerTag)(stores.fileLeaseManager) ); } + +/** + * Stores for a headless CLI root (`createCoreServices`): the caller's stores + * when given, otherwise the same per-store defaults rooted at + * `config.rootDir` that the core graph body applied before the stores became + * layer inputs. Store constructors only compute paths, so building them ahead + * of the graph changes nothing observable. + */ +export function StoresFromCoreOptionsLive(opts: CoreServicesOptions): Layer.Layer { + const { config } = opts; + return StoresLive({ + config, + sessionLocator: opts.sessionLocator ?? new WorkspaceSessionLocator(config.rootDir), + providersConfigStore: opts.providersConfigStore ?? new ProvidersConfigStore(config.rootDir), + secretsStore: opts.secretsStore ?? new SecretsStore(config.rootDir), + fileLeaseManager: opts.fileLeaseManager ?? new FileLeaseManager(config.rootDir), + }); +} diff --git a/src/node/services/di/tags.ts b/src/node/services/di/tags.ts index b92b8b2a258..35423398d34 100644 --- a/src/node/services/di/tags.ts +++ b/src/node/services/di/tags.ts @@ -19,9 +19,35 @@ import type { SecretsStore, WorkspaceSessionLocator, } from "@/node/config"; +import type { AIService } from "@/node/services/aiService"; +import type { AnalyticsService } from "@/node/services/analytics/analyticsService"; +import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; +import type { DevToolsService } from "@/node/services/devToolsService"; +import type { ExperimentsService } from "@/node/services/experimentsService"; +import type { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; +import type { HistoryService } from "@/node/services/historyService"; +import type { IdleDispatcher } from "@/node/services/idleDispatcher"; +import type { InitStateManager } from "@/node/services/initStateManager"; +import type { MCPConfigService } from "@/node/services/mcpConfigService"; +import type { MCPServerManager } from "@/node/services/mcpServerManager"; +import type { MemoryConsolidationService } from "@/node/services/memoryConsolidationService"; import type { MemoryMetaService } from "@/node/services/memoryMeta"; +import type { MemoryService } from "@/node/services/memoryService"; +import type { PolicyService } from "@/node/services/policyService"; +import type { ProviderService } from "@/node/services/providerService"; +import type { SessionTimingService } from "@/node/services/sessionTimingService"; +import type { SessionUsageService } from "@/node/services/sessionUsageService"; +import type { StreamManager } from "@/node/services/streamManager"; +import type { TaskService } from "@/node/services/taskService"; +import type { TelemetryService } from "@/node/services/telemetryService"; +import type { TurnRequestBuilderBindings } from "@/node/services/turnRequestBuilder"; +import type { WorkspaceGoalService } from "@/node/services/workspaceGoalService"; +import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; +import type { WorkspaceService } from "@/node/services/workspaceService"; +import type { WorkspaceTurnManager } from "@/node/services/workspaceTurnManager"; import type { AppFiberScopeTag } from "./appFiberScope"; import type { EffectRunnerTag } from "./effectRunner"; +import type { CoreOptionsTag } from "./layers/core"; export class ConfigTag extends Context.Service()("xum/Config") {} export class SessionLocatorTag extends Context.Service< @@ -44,6 +70,72 @@ export class MemoryMeta extends Context.Service() "xum/MemoryMeta" ) {} +// Core service graph (`CoreServices` in coreServices.ts), shared by the desktop +// app and the headless CLI roots. One tag per `CoreServices` field. +export class History extends Context.Service()("xum/History") {} +export class InitStateManagerTag extends Context.Service()( + "xum/InitStateManager" +) {} +export class Provider extends Context.Service()("xum/Provider") {} +export class BackgroundProcessManagerTag extends Context.Service< + BackgroundProcessManagerTag, + BackgroundProcessManager +>()("xum/BackgroundProcessManager") {} +export class SessionUsage extends Context.Service()( + "xum/SessionUsage" +) {} +export class WorkspaceGoal extends Context.Service()( + "xum/WorkspaceGoal" +) {} +export class IdleDispatcherTag extends Context.Service()( + "xum/IdleDispatcher" +) {} +export class AI extends Context.Service()("xum/AI") {} +export class StreamManagerTag extends Context.Service()( + "xum/StreamManager" +) {} +export class MCPConfig extends Context.Service()("xum/MCPConfig") {} +export class MCPServerManagerTag extends Context.Service()( + "xum/MCPServerManager" +) {} +export class ExtensionMetadata extends Context.Service< + ExtensionMetadata, + ExtensionMetadataService +>()("xum/ExtensionMetadata") {} +export class Workspace extends Context.Service()("xum/Workspace") {} +export class Task extends Context.Service()("xum/Task") {} +export class WorkspaceTurnManagerTag extends Context.Service< + WorkspaceTurnManagerTag, + WorkspaceTurnManager +>()("xum/WorkspaceTurnManager") {} +export class Memory extends Context.Service()("xum/Memory") {} +export class MemoryConsolidation extends Context.Service< + MemoryConsolidation, + MemoryConsolidationService +>()("xum/MemoryConsolidation") {} +/** Late-bound collaborators of the turn request builder (a mutable record, filled by wiring). */ +export class TurnRequestBuilderBindingsTag extends Context.Service< + TurnRequestBuilderBindingsTag, + TurnRequestBuilderBindings +>()("xum/TurnRequestBuilderBindings") {} + +// Desktop cross-cutting services that the core graph's options derive from +// (`CrossCuttingLive` in ./layers/desktop.ts). +export class Policy extends Context.Service()("xum/Policy") {} +export class Telemetry extends Context.Service()("xum/Telemetry") {} +export class Experiments extends Context.Service()( + "xum/Experiments" +) {} +export class SessionTiming extends Context.Service()( + "xum/SessionTiming" +) {} +export class Analytics extends Context.Service()("xum/Analytics") {} +export class DevTools extends Context.Service()("xum/DevTools") {} +export class WorkspaceMcpOverrides extends Context.Service< + WorkspaceMcpOverrides, + WorkspaceMcpOverridesService +>()("xum/WorkspaceMcpOverrides") {} + /** The process's config stores (`ConfigStores`), one tag per store. */ export type StoreTags = | ConfigTag @@ -58,5 +150,40 @@ export type StoreTags = */ export type RuntimeSeamTags = EffectRunnerTag | AppFiberScopeTag; +/** Every `CoreServices` field, as provided by `CoreProjectionLive` (./layers/core.ts). */ +export type CoreTags = + | History + | InitStateManagerTag + | Provider + | BackgroundProcessManagerTag + | SessionUsage + | WorkspaceGoal + | IdleDispatcherTag + | AI + | StreamManagerTag + | MCPConfig + | MCPServerManagerTag + | ExtensionMetadata + | Workspace + | Task + | WorkspaceTurnManagerTag + | Memory + | MemoryMeta + | MemoryConsolidation + | TurnRequestBuilderBindingsTag; + +/** Everything a headless CLI root (`createCoreServices`) provides. */ +export type CoreRootTags = StoreTags | RuntimeSeamTags | CoreOptionsTag | CoreTags; + +/** The desktop cross-cutting services provided by `CrossCuttingLive`. */ +export type CrossCuttingTags = + | Policy + | Telemetry + | Experiments + | SessionTiming + | Analytics + | DevTools + | WorkspaceMcpOverrides; + /** Every service the desktop/server app graph (`AppLive`) provides. */ -export type AppTags = StoreTags | RuntimeSeamTags | MemoryMeta; +export type AppTags = CoreRootTags | CrossCuttingTags; diff --git a/src/node/services/serviceContainer.test.ts b/src/node/services/serviceContainer.test.ts index a7fe39f2498..76c65340b51 100644 --- a/src/node/services/serviceContainer.test.ts +++ b/src/node/services/serviceContainer.test.ts @@ -9,7 +9,32 @@ import { createConfigStores, type Config, type ConfigStores } from "@/node/confi import { AppFiberScopeTag } from "@/node/services/di/appFiberScope"; import { EffectRunnerTag } from "@/node/services/di/effectRunner"; import * as appLayers from "@/node/services/di/layers/app"; -import { MemoryMeta } from "@/node/services/di/tags"; +import { CoreOptionsTag } from "@/node/services/di/layers/core"; +import { + AI, + Analytics, + DevTools, + Experiments, + IdleDispatcherTag, + InitStateManagerTag, + MCPConfig, + MCPServerManagerTag, + Memory, + MemoryConsolidation, + MemoryMeta, + Policy, + Provider, + SessionTiming, + SessionUsage, + StreamManagerTag, + Task, + Telemetry, + Workspace, + WorkspaceGoal, + WorkspaceMcpOverrides, + WorkspaceTurnManagerTag, + type AppTags, +} from "@/node/services/di/tags"; import { ServiceContainer } from "./serviceContainer"; describe("ServiceContainer", () => { @@ -258,6 +283,46 @@ describe("ServiceContainer", () => { services.idleCompactionService.stop(); }); + it("serves the layer-built core and cross-cutting services through the fields and the Effect context", () => { + services = new ServiceContainer(stores); + const effectContext = services.toORPCContext()["effect/context"]; + + const fieldTags: Array<[keyof ServiceContainer, Context.Key]> = [ + ["aiService", AI], + ["streamManager", StreamManagerTag], + ["initStateManager", InitStateManagerTag], + ["workspaceService", Workspace], + ["taskService", Task], + ["workspaceTurnManager", WorkspaceTurnManagerTag], + ["providerService", Provider], + ["mcpConfigService", MCPConfig], + ["mcpServerManager", MCPServerManagerTag], + ["sessionUsageService", SessionUsage], + ["workspaceGoalService", WorkspaceGoal], + ["memoryService", Memory], + ["memoryMetaService", MemoryMeta], + ["memoryConsolidationService", MemoryConsolidation], + ["idleDispatcher", IdleDispatcherTag], + ["policyService", Policy], + ["telemetryService", Telemetry], + ["experimentsService", Experiments], + ["sessionTimingService", SessionTiming], + ["analyticsService", Analytics], + ["devToolsService", DevTools], + ["workspaceMcpOverridesService", WorkspaceMcpOverrides], + ]; + for (const [field, tag] of fieldTags) { + expect(Context.get(effectContext, tag)).toBe(services[field]); + } + // The core graph's options are derived from the layer-built cross-cutting + // instances, so core constructors received the same objects the fields expose. + const coreOptions = services.runtime.get(CoreOptionsTag); + expect(coreOptions.policyService).toBe(services.policyService); + expect(coreOptions.experimentsService).toBe(services.experimentsService); + expect(coreOptions.workspaceMcpOverridesService).toBe(services.workspaceMcpOverridesService); + expect(coreOptions.memoryMetaService).toBe(services.memoryMetaService); + }); + it("surfaces a throwing layer as a synchronous constructor throw", () => { const realAppLive = appLayers.AppLive; const appLiveSpy = spyOn(appLayers, "AppLive").mockImplementation((appStores) => diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 867e82e8fcf..b2f512ab897 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -4,7 +4,7 @@ import { DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR } from "@/common/config/worktreeArchi import { log } from "@/node/services/log"; import type { Config, ConfigStores, WorkspaceSessionLocator } from "@/node/config"; import type { FileLeaseManager, ProvidersConfigStore, SecretsStore } from "@/node/config"; -import { createCoreServices, type CoreServices } from "@/node/services/coreServices"; +import type { CoreServices } from "@/node/services/coreServices"; import { PTYService } from "@/node/services/ptyService"; import type { TerminalWindowManager } from "@/desktop/terminalWindowManager"; import { ProjectService } from "@/node/services/projectService"; @@ -24,7 +24,7 @@ import { InstructionsService } from "@/node/services/instructionsService"; import { ServerService } from "@/node/services/serverService"; import { MenuEventService } from "@/node/services/menuEventService"; import { VoiceService } from "@/node/services/voiceService"; -import { TelemetryService } from "@/node/services/telemetryService"; +import type { TelemetryService } from "@/node/services/telemetryService"; import type { ErrorEvent, ReasoningDeltaEvent, @@ -41,15 +41,15 @@ import { AgentBrowserSessionDiscoveryService } from "@/node/services/browser/Age import { BrowserBridgeTokenManager } from "@/node/services/browser/BrowserBridgeTokenManager"; import { BrowserControlService } from "@/node/services/browser/BrowserControlService"; import { BrowserSessionStateHub } from "@/node/services/browser/BrowserSessionStateHub"; -import { DevToolsService } from "@/node/services/devToolsService"; -import { SessionTimingService } from "@/node/services/sessionTimingService"; +import type { DevToolsService } from "@/node/services/devToolsService"; +import type { SessionTimingService } from "@/node/services/sessionTimingService"; import { TimelineService } from "@/node/services/timelineService"; -import { +import type { AnalyticsService, - type IngestWorkspaceMeta, + IngestWorkspaceMeta, } from "@/node/services/analytics/analyticsService"; -import { ExperimentsService } from "@/node/services/experimentsService"; -import { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; +import type { ExperimentsService } from "@/node/services/experimentsService"; +import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; import { AgentPluginInstallService } from "@/node/services/agentPlugins/installService"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { McpOauthService } from "@/node/services/mcpOauthService"; @@ -75,7 +75,7 @@ import { createRuntimeForWorkspace, resolveWorkspaceExecutionPath, } from "@/node/runtime/runtimeHelpers"; -import { PolicyService } from "@/node/services/policyService"; +import type { PolicyService } from "@/node/services/policyService"; import { ServerAuthService } from "@/node/services/serverAuthService"; import { DesktopBridgeServer } from "@/node/services/desktop/DesktopBridgeServer"; import { DesktopSessionManager } from "@/node/services/desktop/DesktopSessionManager"; @@ -91,17 +91,28 @@ import { } from "@/node/services/di/appRuntime"; import { EffectRunnerTag } from "@/node/services/di/effectRunner"; import { AppLive } from "@/node/services/di/layers/app"; -import { MemoryMeta, type AppTags } from "@/node/services/di/tags"; +import { coreServicesFromContext } from "@/node/services/di/layers/core"; +import { + Analytics, + DevTools, + Experiments, + Policy, + SessionTiming, + Telemetry, + WorkspaceMcpOverrides, + 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`). + * Services provided by the Effect Layer graph (`di/layers/app.ts`: the stores, + * the runtime seams, the cross-cutting services and the whole core graph) are + * built first by `runtime` and handed to the constructor-wired desktop + * remainder; the migration moves services into the graph incrementally (see + * the DI contract in `di/appRuntime.ts`). */ export class ServiceContainer { public readonly runtime: AppRuntime; @@ -116,7 +127,8 @@ export class ServiceContainer { public readonly providersConfigStore: ProvidersConfigStore; public readonly secretsStore: SecretsStore; public readonly fileLeaseManager: FileLeaseManager; - // Core services — instantiated by createCoreServices (shared with `xum run` CLI) + // Core services — built by the shared core graph layer (`di/layers/core.ts`; + // the same definitions back the `xum run`/`xum workflow` roots) private readonly historyService: CoreServices["historyService"]; public readonly aiService: CoreServices["aiService"]; public readonly streamManager: CoreServices["streamManager"]; @@ -204,41 +216,26 @@ export class ServiceContainer { this.secretsStore = stores.secretsStore; this.fileLeaseManager = stores.fileLeaseManager; - // Cross-cutting services: created first so they can be passed to core - // services via constructor params (no setter injection needed). - this.policyService = new PolicyService(config); - this.telemetryService = new TelemetryService(config.rootDir); - this.experimentsService = new ExperimentsService({ - telemetryService: this.telemetryService, - xumHome: config.rootDir, - }); + // Cross-cutting services: layer-built (`CrossCuttingLive`) ahead of the + // core graph, whose options derive from them (`CoreOptionsFromDesktopLive`). + this.policyService = this.runtime.get(Policy); + this.telemetryService = this.runtime.get(Telemetry); + this.experimentsService = this.runtime.get(Experiments); this.backupService = new BackupService(config, { gitRepo: createBackupGitRepo({ cacheRoot: path.join(config.rootDir, "backup-cache"), }), payload: createBackupPayloadStore({ config }), }); - this.sessionTimingService = new SessionTimingService(config, this.telemetryService); - this.analyticsService = new AnalyticsService(config); - this.devToolsService = new DevToolsService(config); + this.sessionTimingService = this.runtime.get(SessionTiming); + this.analyticsService = this.runtime.get(Analytics); + this.devToolsService = this.runtime.get(DevTools); this.browserBridgeTokenManager = new BrowserBridgeTokenManager(); + this.workspaceMcpOverridesService = this.runtime.get(WorkspaceMcpOverrides); - // Desktop passes WorkspaceMcpOverridesService explicitly so AIService uses - // the persistent config rather than creating a default with an ephemeral one. - this.workspaceMcpOverridesService = new WorkspaceMcpOverridesService(config); - - const core = createCoreServices({ - ...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, - experimentsService: this.experimentsService, - sessionTimingService: this.sessionTimingService, - devToolsService: this.devToolsService, - }); + // The core graph (shared with the `xum run`/`xum workflow` roots) is built by + // `CoreProjectionLive`; read it back as the plain object the wiring below uses. + const core = coreServicesFromContext(this.runtime.context); // Spread core services into class fields this.historyService = core.historyService; @@ -328,7 +325,7 @@ export class ServiceContainer { this.workspaceService.setIdleCompactionOutcomeListener((workspaceId, outcome) => this.idleCompactionService.recordOutcome(workspaceId, outcome) ); - // IdleDispatcher + goal continuation bridge are owned by createCoreServices + // IdleDispatcher + goal continuation bridge are owned by the core graph // so the wiring works for `xum run` too. Share the same dispatcher with // HeartbeatService — its priority ordering ensures an active goal // suppresses background heartbeats. @@ -421,7 +418,7 @@ export class ServiceContainer { // Wire terminal service to workspace service for cleanup on removal this.workspaceService.setTerminalService(this.terminalService); this.workspaceService.setDesktopSessionManager(this.desktopSessionManager); - // Plugin-override pruning is wired inside createCoreServices (shared with + // Plugin-override pruning is wired inside the core graph (shared with // headless CLI registration), using this.workspaceMcpOverridesService. // Editor service for opening workspaces in code editors this.editorService = new EditorService(config, this.workspaceService);