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
5 changes: 5 additions & 0 deletions .changeset/data-error-yieldable-framework-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Keep `agent-bundle dev` and `agent-bundle eval` error output unchanged while their internal framework errors become yieldable Effect errors: every error message, `code`, `name`, `instanceof` check, JSON / `stableJson` serialization, and CLI stack trace is byte-for-byte what it was; emitted hook wrappers, `bin/*.mjs`, MCP shells, and `install.mjs` do not change in size or content; and no `effect` type enters any public `.d.ts`, so consumers still compile against `agent-bundle`, `agent-bundle/api`, `agent-bundle/eval`, and the other entries without an `effect` dependency. (#543)
57 changes: 54 additions & 3 deletions agent-patterns/effect-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,69 @@ onto those classes at the boundary — it does not replace them.
## Inside Effect

- Succeed: `Effect.succeed(value)`, `Effect.sync(() => value)`.
- Typed fail: `Effect.fail(new AgentRequestError('request-closed', message))`.
- Typed fail, public or Effect-free class:
`Effect.fail(new AgentRequestError('request-closed', message))`.
- Typed fail, framework-process class (dev seam / eval service, extends
`YieldableFrameworkError` or `YieldableCodedError` from
`packages/agent-bundle/src/effect/errors.ts`):
`return yield* new RuntimeMcpRegistryError('RUNTIME_MCP_REGISTRY_CLOSED', message)`.
`Effect.fail(new RuntimeMcpRegistryError(...))` is equally valid; do not
churn call sites for style.
- Defect (bug): `Effect.die(defect)` — not for expected fail-closed states.
- Recover: `Effect.catch`, `Effect.catchTag` when the error is tagged.
Our `Agent*` classes are **not** Schema tagged errors. Catch them with
`Effect.catch((error) => ...)` and `instanceof` / `error.name`.
None of our classes are tagged (`Data.Error`, not `Data.TaggedError` or
`Schema.TaggedError`). Catch them with `Effect.catch((error) => ...)` and
`instanceof` / `error.name` / `error.code`.
- Inspect after run: `Exit.isSuccess` / `isFailure`, then `Cause.squash`,
`Cause.hasInterruptsOnly`, `Cause.hasFails`, `Cause.hasDies`.

The Wave 3.5 brief defers Effect Schema. Do **not** convert `Agent*Error` to
`Schema.TaggedError` to unlock `catchTag`. Revisit only if a later wave
explicitly lifts the Schema deferral.

## Declaring a framework-process error (decided 2026-09-03)

```ts
import { YieldableCodedError, YieldableFrameworkError } from '../effect/errors.ts';

export class ScriptPlaygroundFailure extends YieldableCodedError<ScriptPlaygroundFailureCode> {
constructor(code: ScriptPlaygroundFailureCode, message: string) {
super('ScriptPlaygroundFailure', code, message);
}
}

export class DevCoordinatorCloseError extends YieldableFrameworkError {
readonly failures: readonly DevCoordinatorCloseFailure[];
constructor(failures: readonly DevCoordinatorCloseFailure[]) {
super('DevCoordinator could not close every resource.');
this.name = 'DevCoordinatorCloseError';
this.failures = failures;
}
}
```

The bases keep the `Error` / `CodedError` constructor shapes, so migrating
an existing class is the `extends` clause plus the import. They also keep
the plain-`Error` observable shape — `JSON.stringify`, `stableJson`,
`{ ...error }`, `util.inspect`, non-enumerable `cause` — which rc.112
`Data.Error` alone would change (its prototype `toJSON` spreads the
constructor fields; its `[nodejs.util.inspect.custom]` prints that instead
of the stack). Never extend `Data.Error` directly.

Stay on plain `Error` / `CodedError` when the class's declaration file is
reachable from any `package.json` export's `types` — exported or not; a
consumer's `tsc` follows the whole `.d.ts` graph, so `McpSessionError`
(reached from `.` / `./api` through the dev types) stays plain even though
it is never exported — when it is reachable from an Effect-free entry
(`agent-bundle/config`, `meta`, `rstest`, `test/browser`, the CLI `--help`
path, the host MCP proxy), or when it ships inside an emitted artifact.
`docs/effect-conventions.md` § "Yieldable framework errors" lists the
current carve-outs; `tests/public-api.test.ts` ("keeps every public
declaration graph free of effect"), `tests/emitted-artifact-effect-surface.test.ts`,
and `tests/cli.test.ts` fail if one is crossed. Before migrating a class,
run `pnpm build` and that `public-api` test; if it reports the class's
`.d.ts`, the class is on a public graph and keeps its plain base.

## Boundary mapping

`packages/rsc-runtime/src/effect/boundary.ts`:
Expand Down
124 changes: 111 additions & 13 deletions docs/effect-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,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`](../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.
- [`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` / `YieldableCodedError` subclasses, `DiagnosticError`) unchanged.
- [`packages/create-agent-bundle/src/effect/boundary.ts`](../packages/create-agent-bundle/src/effect/boundary.ts) — the scaffolder (FileSystem phase 1). Rethrows `UsageError` / `Error` unchanged; unwraps `PlatformError` to its Node cause.

The boundary owns:
Expand Down Expand Up @@ -62,6 +62,92 @@ zod stays at every schema boundary (MCP SDK interop; recorded G-decisions).
— do not introduce `Schema.TaggedError` on the public or MCP-facing contracts.
Internals keep the existing classes.

### Yieldable framework errors (`Data.Error`, decided 2026-09-03)

Framework-process error classes — the ones raised inside Effect programs in
the dev seam and the eval service whose declarations no package export
reaches (today: `DevCoordinatorCloseError`, `RuntimeMcpRegistryError` /
`RuntimeMcpRegistryCloseError`, `RuntimeGenerationStoreError` /
`RuntimeGenerationStoreCloseError`, `DevRuntimeProviderLoadError`,
`ScriptPlaygroundFailure` / `ScriptPlaygroundAbortError`,
`LifecycleReplayRequestError`, `ArtifactInspectionServiceError`,
`HookSimulationAbortError` / `HookSimulationTerminationError`,
`CodexEvalHarnessError`, `SmokeStepError`) — extend the yieldable bases in
[`packages/agent-bundle/src/effect/errors.ts`](../packages/agent-bundle/src/effect/errors.ts):
`YieldableFrameworkError` (the `Data.Error` twin of `Error`, same
`(message?, options?)` constructor) and `YieldableCodedError<TCode>` (the
twin of `CodedError`, same `(name, code, message, options?)` constructor and
`code` field). A program raises one with `return yield* new X(...)`;
`Effect.fail(new X(...))` still works and existing call sites were not
churned. Untagged on purpose: `Schema.TaggedError` stays deferred, `catchTag`
is not the goal, and recovery keeps using `Effect.catch` + `instanceof` /
`error.code`.

What does not change: `instanceof Error` / `instanceof X`, `.name`,
`.message`, `.code`, `.cause`, `.stack`, the boundary's identity-preserving
rethrow (`isTypedDevError` matches the string `code`), `JSON.stringify`,
`stableJson`, `{ ...error }`, and `util.inspect`. rc.112 `Data.Error` would
otherwise change the last four — its prototype `toJSON` spreads the
constructor fields (`message`, `cause`) into the JSON and its
`[nodejs.util.inspect.custom]` prints that instead of the stack — so the
base shadows both with non-functions and installs `cause` non-enumerable;
`tests/effect-errors.test.ts` pins byte-identical output against the plain
twin. Migration is mechanical per file: swap the `extends` clause, import
the base; constructors and call sites stay.

Carve-outs — these stay on plain `Error` / `CodedError`, and a class that
moves into one of these positions moves back:

- **Effect-free entry graphs.** `src/effect/errors.ts` imports `effect`.
`CodedError` (`core/errors.ts`) and `DiagnosticError`
(`core/diagnostics.ts`) sit on `agent-bundle/config` (`CapabilityStateError`),
`agent-bundle/meta` and `agent-bundle/rstest` (`MetaUnavailableError`), the
host MCP proxy (`DevLockError`), and the CLI's `--help` / `--version` path
(`cli.test.ts` fails the trivial invocations that resolve an `effect`
module, #530). Measured: the base swap would put a static `effect` import
on all five entries. `McpAppBridgeCloseError` stays plain for the same
reason (`agent-bundle/rstest` and `agent-bundle/test/browser` reach it).
- **The public declaration graph.** Any class whose declaration file a
`package.json` export's `types` reaches — not only classes that are
themselves exported. A consumer's `tsc` resolves every `.d.ts` the entry
imports, so `class X extends YieldableCodedError` in a reachable file
makes `effect` a type dependency of the package (`public-api.test.ts`'s
root-declaration consumer failed exactly this way with `McpSessionError`
migrated: `dev/mcp-session/mcp-session-types.d.ts` is reached from `.` and
`./api` through the dev types). That keeps plain: everything exported from
an entry (`Agent*` and `McpProjectionError` in `@agent-bundle/runtime`,
`CliUsageError` / `CliInputError`, `EventRuntimeTransportError`,
`Eval*Error` on `agent-bundle/eval`, `EvalServiceError`, `AgentTestError`,
`BrowserAppTestError`, `UsageError` in `create-agent-bundle`) and the
dev-seam classes the root / `api` / `eval` declaration graphs reach
(`McpSessionError` and its stale-epoch / close siblings,
`EpochStoreError` and the epoch cleanup / durability errors,
`ProjectEventHubError`, `HostMcpEpochDriftError`, `AgentApiCloseError`,
`DevLogServiceError`, `DevServerStartError` /
`DevServerLifecycleCloseError`, `ForegroundServer*Error`,
`DevRuntimeUnavailableError` / `DevRuntimeGenerationConflictError`,
`PlaygroundService*Error` / `PlaygroundSessionCloseError`,
`HookPlaygroundCloseError`, `McpProbeTargetNotFoundError`,
`McpAppRuntimePreviewError`, `SkillDocumentError`,
`InspectorLauncherError`, `EvalRunEvent*Error`, and
`EvalServiceBackgroundFailureOverflowError`). The `@agent-bundle/runtime`
`plugin` entry also has no `effect` import today.
`public-api.test.ts` "keeps every public declaration graph free of effect"
walks each export's emitted `.d.ts` graph and fails on any `effect`
import; a class is eligible for the yieldable base only while that test
stays green with it migrated.
- **Emitted artifacts.** Hook wrappers, CLI bins, and the framework MCP
shell bundle `src/effect/boundary.ts` (plain `CodedError`); the raw stdio
MCP server and `install.mjs` carry no Effect at all. Measured on the
`examples/host-test` build: all 66 emitted files byte-identical in size
before and after the migration.
`tests/emitted-artifact-effect-surface.test.ts` (integration lane, reads
the built `dist`) pins that no emitted file contains the yieldable base,
the Effect-free classes stay Effect-free, and hook wrappers keep the plain
`CodedError`.
- **Workbench** client errors (`AB82xx`): browser code; `effect` is allowed
only in the atom modules.

| Effect channel | Runtime contract |
| --- | --- |
| Success `A` | Promise resolves `A` |
Expand Down Expand Up @@ -632,18 +718,30 @@ resolved the current repo practice stands, and new code follows it.
budget for hooks that would newly import `effect/Predicate`. Either way
the emitted-string copies stay: generated host-side JS has no `effect`
import by design.
- **`Data.Error` for internal class errors.** Every typed error is a plain
`Error` subclass (82 `export class … extends CodedError | Error`), so they
enter the channel through `Effect.fail(...)`; `Effect.gen` cannot
`yield*` them. rc.112 `Data.Error<Fields>` is a yieldable, untagged error
(`Cause.YieldableError`), which would let programs `yield* new X(...)`
without `Effect.fail` and without the `Schema.TaggedError` that the
[Error mapping](#error-mapping) section keeps off public and MCP
contracts. It changes the constructor to a single fields object and adds a
`Data` import (measured `Data.TaggedError` cost for hooks: +12 kB, see
[Effect platform services](#effect-platform-services-effectplatform-node)),
so it would apply to internals only — the `Agent*` classes and the
boundary's identity-preserving rethrow table stay as they are either way.
- **`Data.Error` for internal class errors — decided 2026-09-03: adopt,
internals only.** The rule and its carve-outs live in
[Yieldable framework errors](#yieldable-framework-errors-dataerror-decided-2026-09-03).
Summary: framework-process error classes in Effect-native modules extend
`YieldableFrameworkError` / `YieldableCodedError`
(`packages/agent-bundle/src/effect/errors.ts`, thin subclasses of rc.112
`Data.Error` that keep the `Error` / `CodedError` constructor shapes and
restore plain-`Error` `toJSON` / `util.inspect`), so programs write
`return yield* new X(...)`. `Schema.TaggedError` stays deferred. Plain
`Error` / `CodedError` remain for the bases on Effect-free entry graphs
(`CodedError`, `DiagnosticError`, `DevLockError`, `McpAppBridgeCloseError`
— the swap was measured to add a static `effect` import to
`agent-bundle/config`, `meta`, `rstest`, the CLI `--help` path, and the host
MCP proxy), every class on a public declaration graph — exported from a
package entry (the `Agent*` classes included) or merely reached by one
through the emitted `.d.ts` files (`McpSessionError`, `EpochStoreError`,
`ProjectEventHubError`, …), because `Cause.YieldableError` would make
`effect` a type dependency for consumers; `public-api.test.ts` walks every
export's declaration graph and pins it —
emitted artifacts (66 `examples/host-test` files byte-identical in size
before/after; the +12 kB figure was `effect/PlatformError`'s
`Schema.TaggedError`, not `Data.Error`, which lives in the Effect core the
wrappers already bundle), and Workbench client errors. The boundary's
identity-preserving rethrow table is unchanged.

## Parked toolchain follow-ups

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
type ValidatedArtifactSnapshot,
} from '../../build/validate-artifact.ts';
import type { Diagnostic } from '../../core/diagnostics.ts';
import { CodedError } from '../../core/errors.ts';
import type { ProjectContext } from '../../core/project-context.ts';
import { EpochReference, EpochStore } from '../epoch-store.ts';
import { artifactScriptCatalog } from './artifact-script-catalog.ts';
Expand All @@ -29,6 +28,7 @@ import type {
ArtifactInspectionTarget,
ArtifactInspectionTreeNode,
} from '../types.ts';
import { YieldableCodedError } from '../../effect/errors.ts';

export type ArtifactInspectionServiceErrorCode =
| 'ARTIFACT_INSPECTION_INVALID'
Expand Down Expand Up @@ -58,7 +58,7 @@ const snapshotDiagnostic = (diagnostic: Diagnostic): Diagnostic => Object.freeze
...(diagnostic.target === undefined ? {} : { target: diagnostic.target }),
});

export class ArtifactInspectionServiceError extends CodedError<ArtifactInspectionServiceErrorCode> {
export class ArtifactInspectionServiceError extends YieldableCodedError<ArtifactInspectionServiceErrorCode> {
readonly diagnostics: readonly Diagnostic[];

constructor(code: ArtifactInspectionServiceErrorCode, message: string, diagnostics: readonly Diagnostic[]) {
Expand Down
5 changes: 3 additions & 2 deletions packages/agent-bundle/src/dev/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
type SourceStatus,
type SucceededBuildAttempt,
} from './types.ts';
import { YieldableFrameworkError } from '../effect/errors.ts';

export interface DevLockHandle {
close(): Promise<void>;
Expand Down Expand Up @@ -57,7 +58,7 @@ export interface DevCoordinatorCloseFailure {
}

/** Reports every resource that could not be released during coordinator shutdown. */
export class DevCoordinatorCloseError extends Error {
export class DevCoordinatorCloseError extends YieldableFrameworkError {
readonly failures: readonly DevCoordinatorCloseFailure[];

constructor(failures: readonly DevCoordinatorCloseFailure[]) {
Expand Down Expand Up @@ -618,7 +619,7 @@ export class DevCoordinator {
...(hasBuildInFlight ? closeFailure(buildExit, 'build') : []),
...releases.flatMap((exit, index) => closeFailure(exit, resources[index]!.resource)),
];
if (failures.length > 0) return yield* Effect.fail(new DevCoordinatorCloseError(failures));
if (failures.length > 0) return yield* new DevCoordinatorCloseError(failures);
}));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type {
LifecycleRenderChildResponse,
LifecycleRenderChildResult,
} from './lifecycle-render-protocol.ts';
import { YieldableFrameworkError } from '../../effect/errors.ts';

const concreteHosts = new Set(['claude', 'codex', 'cursor']);
const projectionDiagnosticCode = 'lifecycle.projection.unsupported';
Expand Down Expand Up @@ -144,7 +145,7 @@ export interface LifecycleReplayServiceOptions {
readonly render?: typeof renderRouteEvents;
}

export class LifecycleReplayRequestError extends Error {
export class LifecycleReplayRequestError extends YieldableFrameworkError {
readonly code: 'AB8211' | 'AB8213';
readonly status: 400 | 409;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';

import { createDefaultRegistry, type TargetRegistry } from '../../adapters/registry.ts';
import { validateArtifactWithSnapshot } from '../../build/validate-artifact.ts';
import { CodedError, isErrno } from '../../core/errors.ts';
import { isErrno } from '../../core/errors.ts';
import { isInside } from '../../core/paths.ts';
import {
taskkill,
Expand All @@ -15,6 +15,7 @@ import {
} from '../../services/process-tree.ts';
import { artifactScriptCatalog } from '../artifacts/artifact-script-catalog.ts';
import { EpochStore, type EpochReference } from '../epoch-store.ts';
import { YieldableCodedError, YieldableFrameworkError } from '../../effect/errors.ts';

const defaultOutputLimit = 64 * 1024;
const defaultTimeoutMs = 5_000;
Expand Down Expand Up @@ -65,7 +66,7 @@ export type ScriptPlaygroundFailureCode =
| 'timeout';

/** Stable script infrastructure failure with bounded captured output safe for durable evidence. */
export class ScriptPlaygroundFailure extends CodedError<ScriptPlaygroundFailureCode> {
export class ScriptPlaygroundFailure extends YieldableCodedError<ScriptPlaygroundFailureCode> {
readonly cleanupFailures: readonly ScriptPlaygroundCleanupFailure[];
readonly stderr: string;
readonly stdout: string;
Expand All @@ -83,7 +84,7 @@ export class ScriptPlaygroundFailure extends CodedError<ScriptPlaygroundFailureC
}
}

export class ScriptPlaygroundAbortError extends Error {
export class ScriptPlaygroundAbortError extends YieldableFrameworkError {
readonly cleanupFailures: readonly ScriptPlaygroundCleanupFailure[];

constructor(cleanupFailures: readonly ScriptPlaygroundCleanupFailure[] = []) {
Expand Down
5 changes: 3 additions & 2 deletions packages/agent-bundle/src/dev/runtime-generation-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { isAbsolute, join, relative, resolve, sep } from 'node:path';

import { digest, stableJson } from '../core/digest.ts';
import { freezeJsonValue, type JsonObject, type JsonValue } from './types.ts';
import { YieldableFrameworkError } from '../effect/errors.ts';

const manifestFileName = 'generation.manifest.json';
const defaultRetainInactive = 5;
Expand Down Expand Up @@ -101,7 +102,7 @@ export interface RuntimeGenerationCloseFailure {
readonly path: string;
}

export class RuntimeGenerationStoreCloseError extends Error {
export class RuntimeGenerationStoreCloseError extends YieldableFrameworkError {
readonly failures: readonly RuntimeGenerationCloseFailure[];

constructor(failures: readonly RuntimeGenerationCloseFailure[]) {
Expand All @@ -118,7 +119,7 @@ export type RuntimeGenerationStoreErrorCode =
| 'RUNTIME_GENERATION_NOT_FOUND'
| 'RUNTIME_GENERATION_SUPERSEDED';

export class RuntimeGenerationStoreError extends Error {
export class RuntimeGenerationStoreError extends YieldableFrameworkError {
readonly code: RuntimeGenerationStoreErrorCode;

constructor(code: RuntimeGenerationStoreErrorCode, message: string) {
Expand Down
Loading
Loading