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
7 changes: 7 additions & 0 deletions src/constants/terminationTimeouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
3 changes: 3 additions & 0 deletions src/node/bench/headlessEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
Expand Down
4 changes: 2 additions & 2 deletions src/node/orpc/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OrpcEffectServices> {
config: Config;
Expand Down
29 changes: 18 additions & 11 deletions src/node/orpc/effectContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MemoryMeta, MemoryMetaService>()(
"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<OrpcEffectServices> {
}): Context.Context<MemoryMeta> {
return Context.make(MemoryMeta, services.memoryMetaService);
}
7 changes: 6 additions & 1 deletion src/node/services/coreServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down
114 changes: 114 additions & 0 deletions src/node/services/di/appRuntime.test.ts
Original file line number Diff line number Diff line change
@@ -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<ProbeA, { readonly name: string }>()("test/ProbeA") {}
class ProbeB extends Context.Service<ProbeB, { readonly name: string }>()("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();
}
});
});
117 changes: 117 additions & 0 deletions src/node/services/di/appRuntime.ts
Original file line number Diff line number Diff line change
@@ -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<R> {
/** The runtime that owns the layer scope. Composition roots only (see contract). */
readonly managed: ManagedRuntime.ManagedRuntime<R, never>;
/** The built service context; also the oRPC `"effect/context"`. */
readonly context: Context.Context<R>;
/** Resolve a service instance from the built context. */
readonly get: <I extends R, S>(tag: Context.Key<I, S>) => 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<R>(layer: Layer.Layer<R, never, never>): AppRuntime<R> {
const startedAt = performance.now();
const managed = ManagedRuntime.make(layer);
const context = managed.runSync(Effect.context<R>());
Comment thread
ThomasK33 marked this conversation as resolved.
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<never, never>,
timeoutMs: number = APP_RUNTIME_DISPOSE_TIMEOUT_MS
): Promise<void> {
return Effect.runPromise(disposeAppRuntimeEffect(runtime, timeoutMs));
}

function disposeAppRuntimeEffect(
runtime: ManagedRuntime.ManagedRuntime<never, never>,
timeoutMs: number
): Effect.Effect<void> {
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 });
})
)
)
);
}
18 changes: 18 additions & 0 deletions src/node/services/di/layers/app.ts
Original file line number Diff line number Diff line change
@@ -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<AppTags> {
return MemoryMetaLive.pipe(Layer.provideMerge(StoresLive(stores)));
}
15 changes: 15 additions & 0 deletions src/node/services/di/layers/core.ts
Original file line number Diff line number Diff line change
@@ -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<MemoryMeta, never, ConfigTag> = Layer.effect(
MemoryMeta,
Effect.map(ConfigTag, (config) => new MemoryMetaService(config.rootDir))
);
26 changes: 26 additions & 0 deletions src/node/services/di/layers/stores.ts
Original file line number Diff line number Diff line change
@@ -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<StoreTags> {
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)
);
}
Loading
Loading