diff --git a/.changeset/effect-dev-seam-boundary.md b/.changeset/effect-dev-seam-boundary.md
new file mode 100644
index 000000000..a79090c0a
--- /dev/null
+++ b/.changeset/effect-dev-seam-boundary.md
@@ -0,0 +1,5 @@
+---
+"agent-bundle": patch
+---
+
+Add the Stage-3 Effect boundary module for the dev seam (`src/effect/boundary.ts`, exact `effect@4.0.0-rc.112` pin). Internal only: no public API or artifact change.
diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md
index c32440ed9..472f7f83f 100644
--- a/docs/effect-conventions.md
+++ b/docs/effect-conventions.md
@@ -22,7 +22,7 @@ Read `repos/effect/LLMS.md` before writing Effect code. Refresh
Each Effect-consuming package has exactly one `src/effect/boundary.ts`:
- [`packages/rsc-runtime/src/effect/boundary.ts`](../packages/rsc-runtime/src/effect/boundary.ts) — runtime + state kernel internals.
-- `packages/agent-bundle/src/effect/boundary.ts` — added when the dev seam (Stage 3) starts using Effect.
+- [`packages/agent-bundle/src/effect/boundary.ts`](../packages/agent-bundle/src/effect/boundary.ts) — the dev seam (Stage 3). Maps interruption to `AbortError` and rethrows the dev seam's typed contracts (`CodedError` subclasses, `DiagnosticError`) unchanged.
The boundary owns:
diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json
index d9e11f3d3..40337bce3 100644
--- a/packages/agent-bundle/package.json
+++ b/packages/agent-bundle/package.json
@@ -84,6 +84,7 @@
"ajv-formats": "3.0.1",
"chokidar": "5.0.0",
"commander": "15.0.0",
+ "effect": "4.0.0-rc.112",
"es-module-lexer": "2.3.2",
"fast-glob": "3.3.3",
"ignore": "7.0.6",
diff --git a/packages/agent-bundle/src/effect/boundary.ts b/packages/agent-bundle/src/effect/boundary.ts
new file mode 100644
index 000000000..4fef0c922
--- /dev/null
+++ b/packages/agent-bundle/src/effect/boundary.ts
@@ -0,0 +1,164 @@
+import {
+ Cause,
+ Effect,
+ Exit,
+ type Layer,
+ ManagedRuntime,
+ type Scope,
+} from 'effect';
+
+import { DiagnosticError } from '../core/diagnostics.ts';
+import { CodedError } from '../core/errors.ts';
+
+/**
+ * The sole Effect → Promise / sync edge for the `agent-bundle` package
+ * (the dev seam, Wave 3.5 stage 3).
+ *
+ * Internals write Effect programs. This module is the only place in this
+ * package that may call `Effect.runPromise` / `Effect.runSync` (and
+ * siblings). Public authoring stays Promise + zod: nothing here is
+ * re-exported from any package entry. See `docs/effect-conventions.md`.
+ *
+ * Error mapping mirrors `packages/rsc-runtime/src/effect/boundary.ts`: the
+ * dev seam's typed contracts (`CodedError` subclasses such as
+ * `EpochStoreError`, `DevLockError`, `ProjectEventHubError`, and the AB
+ * diagnostic carrier `DiagnosticError`) ride the fail channel as ordinary
+ * `Error` instances and rethrow unchanged, so callers of `runPromise` see
+ * exactly the same types they see today.
+ */
+
+export interface RunPromiseOptions {
+ readonly signal?: AbortSignal;
+}
+
+const interruptAs = (): Effect.Effect =>
+ Effect.interrupt as Effect.Effect;
+
+export const abortError = (cause?: unknown): DOMException => {
+ const error = new DOMException('The operation was aborted', 'AbortError');
+ if (cause !== undefined) {
+ error.cause = cause;
+ }
+ return error;
+};
+
+export const isAbortError = (error: unknown): error is DOMException =>
+ (error instanceof DOMException && error.name === 'AbortError')
+ || (error instanceof Error && error.name === 'AbortError');
+
+/**
+ * True for the dev seam's typed Error contracts: `CodedError` subclasses
+ * (`DevLockError`, `ProjectEventHubError`, ...), any coded-shape error that
+ * predates `CodedError` (`EpochStoreError` carries a bare `code` string),
+ * and the AB diagnostic carrier `DiagnosticError`. Plain `Error` values also
+ * rethrow as-is; this predicate only exists so intent is greppable at the
+ * boundary.
+ */
+export const isTypedDevError = (error: unknown): error is Error =>
+ error instanceof CodedError
+ || error instanceof DiagnosticError
+ || (error instanceof Error && typeof (error as { readonly code?: unknown }).code === 'string');
+
+export const toDevError = (value: unknown): Error => {
+ if (isAbortError(value) || isTypedDevError(value)) return value;
+ if (value instanceof Error) return value;
+ return new Error(typeof value === 'string' ? value : String(value));
+};
+
+export const mapCause = (cause: Cause.Cause): Error => {
+ if (Cause.hasInterruptsOnly(cause)) return abortError(cause);
+ return toDevError(Cause.squash(cause));
+};
+
+const throwExitFailure = (exit: Exit.Exit): A => {
+ if (Exit.isSuccess(exit)) return exit.value;
+ throw mapCause(exit.cause);
+};
+
+const runOptions = (options: RunPromiseOptions | undefined): Effect.RunOptions | undefined =>
+ options?.signal === undefined ? undefined : { signal: options.signal };
+
+/**
+ * Promise edge. Interruption (including `options.signal`) becomes
+ * `DOMException` `AbortError`. Typed dev-seam failures rethrow as-is.
+ */
+export const runPromise = async (
+ effect: Effect.Effect,
+ options?: RunPromiseOptions,
+): Promise => throwExitFailure(await Effect.runPromiseExit(effect, runOptions(options)));
+
+/** Promise edge that preserves the Effect `Exit` for callers that branch on cause. */
+export const runPromiseExit = async (
+ effect: Effect.Effect,
+ options?: RunPromiseOptions,
+): Promise> => Effect.runPromiseExit(effect, runOptions(options));
+
+/**
+ * Sync edge for effects that cannot suspend. Do not use for I/O, streams,
+ * or anything that waits on a fiber.
+ */
+export const runSync = (effect: Effect.Effect): A =>
+ throwExitFailure(Effect.runSyncExit(effect));
+
+export const runSyncExit = (effect: Effect.Effect): Exit.Exit =>
+ Effect.runSyncExit(effect);
+
+/**
+ * Internal long-lived Effect runtime. Its Layer owns one Scope, which stays
+ * live across Promise API calls and is finalized exactly once by close().
+ */
+export interface ScopedEffectRuntime {
+ close(): Promise;
+ run(effect: Effect.Effect, options?: RunPromiseOptions): Promise;
+}
+
+export const makeScopedEffectRuntime = (
+ layer: Layer.Layer,
+): ScopedEffectRuntime => {
+ const runtime = ManagedRuntime.make(layer);
+ let closing: Promise | undefined;
+ return Object.freeze({
+ close(): Promise {
+ closing ??= runtime.dispose();
+ return closing;
+ },
+ async run(
+ effect: Effect.Effect,
+ options?: RunPromiseOptions,
+ ): Promise {
+ return throwExitFailure(await runtime.runPromiseExit(effect, runOptions(options)));
+ },
+ });
+};
+
+/**
+ * AbortSignal → Effect interruption, for programs that still run inside
+ * Effect and receive a host signal. The Promise edge also accepts `signal`
+ * directly via {@link runPromise}.
+ */
+export const interruptWhenAborted = (
+ effect: Effect.Effect,
+ signal: AbortSignal,
+): Effect.Effect => {
+ if (signal.aborted) return interruptAs();
+ return Effect.raceFirst(
+ effect,
+ Effect.callback((resume) => {
+ const onAbort = () => {
+ resume(Effect.interrupt);
+ };
+ signal.addEventListener('abort', onAbort, { once: true });
+ return Effect.sync(() => {
+ signal.removeEventListener('abort', onAbort);
+ });
+ }),
+ );
+};
+
+/**
+ * Effect interruption → AbortSignal, for Promise/fetch APIs that take a
+ * signal. Requires `Scope`; close the scope to abort. This is the stage-2
+ * lesson's bridge: interrupt a source through its signal instead of calling
+ * `cancel()` on a web stream a consumer may have locked.
+ */
+export const scopedAbortSignal: Effect.Effect = Effect.abortSignal;
diff --git a/packages/agent-bundle/tests/effect-boundary.test.ts b/packages/agent-bundle/tests/effect-boundary.test.ts
new file mode 100644
index 000000000..feead003f
--- /dev/null
+++ b/packages/agent-bundle/tests/effect-boundary.test.ts
@@ -0,0 +1,79 @@
+import { Cause, Effect, Exit } from 'effect';
+import { describe, expect, it } from '@rstest/core';
+
+import { DiagnosticError } from '../src/core/diagnostics.ts';
+import * as devApi from '../src/dev/index.ts';
+import { EpochStoreError } from '../src/dev/epoch-store.ts';
+import { DevLockError } from '../src/dev/dev-lock.ts';
+import { ProjectEventHubError } from '../src/dev/events.ts';
+import {
+ abortError,
+ interruptWhenAborted,
+ isAbortError,
+ isTypedDevError,
+ mapCause,
+ runPromise,
+ runPromiseExit,
+ runSync,
+ toDevError,
+} from '../src/effect/boundary.ts';
+import * as rootApi from '../src/index.ts';
+
+describe('effect boundary (agent-bundle dev seam)', () => {
+ it('is not part of any public export', () => {
+ expect('runPromise' in rootApi).toBe(false);
+ expect('runSync' in rootApi).toBe(false);
+ expect('runPromise' in devApi).toBe(false);
+ expect('interruptWhenAborted' in devApi).toBe(false);
+ });
+
+ it('resolves a successful effect', async () => {
+ await expect(runPromise(Effect.succeed(41))).resolves.toBe(41);
+ expect(runSync(Effect.succeed('ok'))).toBe('ok');
+ });
+
+ it('rethrows the dev seam typed errors from the fail channel unchanged', async () => {
+ const store = new EpochStoreError('EPOCH_NOT_FOUND', 'Epoch "e1" does not exist.');
+ const lock = new DevLockError('DEV_LOCK_HELD', 'Another agent-bundle dev process owns this project.');
+ const hub = new ProjectEventHubError('PROJECT_EVENT_CURSOR_AHEAD', 'cursor is ahead');
+ const diagnostics = new DiagnosticError([
+ { code: 'AB7200', message: 'rebuild failed', severity: 'error' },
+ ]);
+ for (const error of [store, lock, hub, diagnostics]) {
+ expect(isTypedDevError(error)).toBe(true);
+ await expect(runPromise(Effect.fail(error))).rejects.toBe(error);
+ }
+ });
+
+ it('maps interruption to DOMException AbortError', async () => {
+ const mapped = mapCause(Cause.interrupt(1));
+ expect(isAbortError(mapped)).toBe(true);
+ expect(mapped).toBeInstanceOf(DOMException);
+
+ const controller = new AbortController();
+ controller.abort();
+ await expect(runPromise(Effect.never, { signal: controller.signal })).rejects.toSatisfy(isAbortError);
+ });
+
+ it('interrupts an in-flight effect when the host signal aborts', async () => {
+ const controller = new AbortController();
+ const pending = runPromise(interruptWhenAborted(Effect.never, controller.signal));
+ await Promise.resolve();
+ controller.abort();
+ await expect(pending).rejects.toSatisfy(isAbortError);
+ });
+
+ it('preserves Exit on runPromiseExit', async () => {
+ const success = await runPromiseExit(Effect.succeed(7));
+ expect(Exit.isSuccess(success)).toBe(true);
+ if (Exit.isSuccess(success)) expect(success.value).toBe(7);
+
+ const failure = await runPromiseExit(Effect.fail('nope'));
+ expect(Exit.isFailure(failure)).toBe(true);
+ });
+
+ it('wraps non-Error fail values', () => {
+ expect(toDevError('plain')).toEqual(new Error('plain'));
+ expect(abortError().name).toBe('AbortError');
+ });
+});
diff --git a/packages/rsc-runtime/tests/effect-boundary-lint.test.ts b/packages/rsc-runtime/tests/effect-boundary-lint.test.ts
index eb5b43e0a..b35e87619 100644
--- a/packages/rsc-runtime/tests/effect-boundary-lint.test.ts
+++ b/packages/rsc-runtime/tests/effect-boundary-lint.test.ts
@@ -19,8 +19,21 @@ const apply = (filename: string, visit: (listeners: ReturnType {
it('recognizes only src/effect/boundary.ts as the legal runner home', () => {
expect(isEffectBoundaryFile('/fast/projects/agent-bundle/packages/rsc-runtime/src/effect/boundary.ts')).toBe(true);
+ expect(isEffectBoundaryFile('/fast/projects/agent-bundle/packages/agent-bundle/src/effect/boundary.ts')).toBe(true);
expect(isEffectBoundaryFile('C:\\repo\\packages\\rsc-runtime\\src\\effect\\boundary.ts')).toBe(true);
expect(isEffectBoundaryFile('/fast/projects/agent-bundle/packages/rsc-runtime/src/dispatcher.ts')).toBe(false);
+ expect(isEffectBoundaryFile('/fast/projects/agent-bundle/packages/agent-bundle/src/dev/epoch-store.ts')).toBe(false);
+ });
+
+ it('rejects ad-hoc runners in agent-bundle dev seam files', () => {
+ const reports = apply('packages/agent-bundle/src/dev/coordinator.ts', (listeners) => {
+ listeners.MemberExpression?.({
+ computed: false,
+ object: { name: 'Effect', type: 'Identifier' },
+ property: { name: 'runFork', type: 'Identifier' },
+ });
+ });
+ expect(reports).toEqual([{ data: { name: 'Effect.runFork' }, messageId: 'forbiddenCall' }]);
});
it('rejects Effect.runPromise and Effect.runSync outside the boundary', () => {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1e00aaa3d..b59653a38 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -226,6 +226,9 @@ importers:
commander:
specifier: 15.0.0
version: 15.0.0
+ effect:
+ specifier: 4.0.0-rc.112
+ version: 4.0.0-rc.112
es-module-lexer:
specifier: 2.3.2
version: 2.3.2