Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -658,6 +659,8 @@ async function main(): Promise<number> {
idleDispatcher,
streamManager,
turnRequestBuilderBindings,
runtime: coreRuntime,
appFiberScope,
} = createCoreServices({
...runStores,
policyService,
Expand Down Expand Up @@ -1571,6 +1574,9 @@ async function main(): Promise<number> {
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() },
Expand All @@ -1585,6 +1591,8 @@ async function main(): Promise<number> {
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);
Expand Down
34 changes: 34 additions & 0 deletions src/cli/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/cli/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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]();
}

Expand Down
14 changes: 11 additions & 3 deletions src/node/services/coreServices.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
170 changes: 170 additions & 0 deletions src/node/services/coreServicesRoot.test.ts
Original file line number Diff line number Diff line change
@@ -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<keyof CoreServices, …>` keeps it exhaustive.
*/
const CORE_FIELD_TAGS: Record<keyof CoreServices, Context.Key<CoreTags, unknown>> = {
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<CoreRootTags, unknown>]
>) {
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();
}
});
});
37 changes: 37 additions & 0 deletions src/node/services/coreServicesRoot.ts
Original file line number Diff line number Diff line change
@@ -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<CoreRootTags>;
/** 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),
};
}
14 changes: 11 additions & 3 deletions src/node/services/di/layers/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -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<AppTags> {
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)
);
}
Loading
Loading