From 394486c6a5d121e7e0eeb12f3eff3abccf626b6c Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 07:12:10 +0000 Subject: [PATCH 1/5] refactor(effect): make dev seam and eval framework error classes yieldable via Data.Error bases Add src/effect/errors.ts with YieldableFrameworkError / YieldableCodedError (rc.112 Data.Error twins of Error / CodedError, same constructors, plain Error toJSON / util.inspect / non-enumerable cause restored) and swap the extends clause on the internal dev seam and eval classes. CodedError, DiagnosticError, DevLockError, McpAppBridgeCloseError, every class exported from a package entry, and emitted artifacts stay plain (measured: 66 host-test artifacts byte-identical in size; Effect-free entries unchanged). Record the maintainer decision in docs/effect-conventions.md and agent-patterns/effect-errors.md; pin serialization and the artifact surface with tests. --- .../data-error-yieldable-framework-errors.md | 5 + agent-patterns/effect-errors.md | 52 +++++- docs/effect-conventions.md | 92 ++++++++-- packages/agent-bundle/src/dev/agent-api.ts | 5 +- .../artifacts/artifact-inspection-service.ts | 4 +- packages/agent-bundle/src/dev/coordinator.ts | 3 +- packages/agent-bundle/src/dev/epoch-store.ts | 9 +- .../agent-bundle/src/dev/eval/eval-service.ts | 3 +- packages/agent-bundle/src/dev/events.ts | 4 +- .../agent-bundle/src/dev/foreground-server.ts | 7 +- .../agent-bundle/src/dev/host-mcp-routes.ts | 3 +- .../src/dev/inspector-launcher.ts | 4 +- .../src/dev/logs/dev-log-service.ts | 3 +- .../dev/mcp-app-runtime-preview-service.ts | 3 +- .../src/dev/mcp-session/mcp-session-types.ts | 10 +- .../dev/playground/hook-playground-routes.ts | 4 +- .../playground/lifecycle-replay-service.ts | 3 +- .../src/dev/playground/mcp-probe-service.ts | 7 +- .../src/dev/playground/playground-store.ts | 7 +- .../playground/script-playground-service.ts | 7 +- .../src/dev/runtime-generation-store.ts | 5 +- .../src/dev/runtime-mcp-registry.ts | 5 +- .../src/dev/runtime-provider-loader.ts | 3 +- .../agent-bundle/src/dev/runtime-provider.ts | 5 +- .../src/dev/skill-document-service.ts | 5 +- .../agent-bundle/src/dev/workbench-server.ts | 7 +- packages/agent-bundle/src/effect/errors.ts | 100 +++++++++++ .../agent-bundle/src/eval/codex-errors.ts | 4 +- packages/agent-bundle/src/eval/run-store.ts | 5 +- .../host-contracts/native-codex-contract.ts | 3 +- .../agent-bundle/src/services/hook-service.ts | 5 +- .../agent-bundle/tests/effect-errors.test.ts | 116 +++++++++++++ .../emitted-artifact-effect-surface.test.ts | 160 ++++++++++++++++++ rstest.integration-tests.ts | 1 + 34 files changed, 587 insertions(+), 72 deletions(-) create mode 100644 .changeset/data-error-yieldable-framework-errors.md create mode 100644 packages/agent-bundle/src/effect/errors.ts create mode 100644 packages/agent-bundle/tests/effect-errors.test.ts create mode 100644 packages/agent-bundle/tests/emitted-artifact-effect-surface.test.ts diff --git a/.changeset/data-error-yieldable-framework-errors.md b/.changeset/data-error-yieldable-framework-errors.md new file mode 100644 index 000000000..f1772a70d --- /dev/null +++ b/.changeset/data-error-yieldable-framework-errors.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Make the dev seam's and eval service's internal framework error classes (`McpSessionError`, `EpochStoreError`, `DevCoordinatorCloseError`, `ProjectEventHubError`, and the rest of the dev-seam close / request errors) yieldable inside `Effect.gen` through the new `Data.Error`-based bases in `src/effect/errors.ts`. Messages, codes, `instanceof`, JSON output, and stack traces are unchanged; `CodedError`, `DiagnosticError`, every class exported from a package entry, and the emitted hook, MCP, and CLI artifacts keep their plain `Error` bases. (#PR) diff --git a/agent-patterns/effect-errors.md b/agent-patterns/effect-errors.md index 13ad7a30b..f246aa189 100644 --- a/agent-patterns/effect-errors.md +++ b/agent-patterns/effect-errors.md @@ -21,11 +21,19 @@ 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 McpSessionError('MCP_SESSION_CLOSED', message)`. + `Effect.fail(new McpSessionError(...))` 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`. @@ -33,6 +41,44 @@ 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 McpSessionError extends YieldableCodedError { + constructor(code: McpSessionErrorCode, message: string) { + super('McpSessionError', 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 is exported from a +package entry (Effect must not reach user-facing `.d.ts`), 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/emitted-artifact-effect-surface.test.ts` and `tests/cli.test.ts` +fail if one is crossed. + ## Boundary mapping `packages/rsc-runtime/src/effect/boundary.ts`: diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index 3efc4801e..2e1322ca5 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -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: @@ -62,6 +62,64 @@ 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 — 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` (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). +- **Public classes.** Anything exported from a package 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`): a + `Data.Error` base would put `Cause.YieldableError` in the user-facing + `.d.ts`, and Effect never appears in user-facing types. The `@agent-bundle/runtime` + `plugin` entry also has no `effect` import today. +- **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` | @@ -632,18 +690,26 @@ 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` 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 exported from a package entry (the `Agent*` + classes included: `Cause.YieldableError` would enter user-facing `.d.ts`), + 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 diff --git a/packages/agent-bundle/src/dev/agent-api.ts b/packages/agent-bundle/src/dev/agent-api.ts index 1d4907a83..29eec752c 100644 --- a/packages/agent-bundle/src/dev/agent-api.ts +++ b/packages/agent-bundle/src/dev/agent-api.ts @@ -22,6 +22,7 @@ import type { EvalService } from './eval/eval-service.ts'; import { runtimeAppFiniteOrdinaryJsonByteLength } from './runtime-app-message-limits.ts'; import type { ProjectStatus } from './types.ts'; import { deepFreeze } from '../core/freeze.ts'; +import { YieldableFrameworkError } from '../effect/errors.ts'; export const agentApiToolNames = Object.freeze([ @@ -105,7 +106,7 @@ export interface AgentApiOptions { export type AgentApiCloseFailure = Readonly<{ readonly error: unknown; readonly resource: 'eval' | 'handler' }>; -export class AgentApiCloseError extends Error { +export class AgentApiCloseError extends YieldableFrameworkError { readonly failures: readonly AgentApiCloseFailure[]; constructor(failures: readonly AgentApiCloseFailure[]) { @@ -232,7 +233,7 @@ const maximumAgentApiJsonDepth = 32; const maximumAgentApiJsonNodes = 4_096; const maximumAgentApiToolResultBytes = 1_024 * 1_024; -class AgentApiRequestError extends Error { +class AgentApiRequestError extends YieldableFrameworkError { readonly code: 'AGENT_API_REQUEST_INVALID' | 'AGENT_API_REQUEST_TOO_LARGE'; readonly status: 400 | 413; diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts b/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts index 676ca53df..c52108a08 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts @@ -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'; @@ -29,6 +28,7 @@ import type { ArtifactInspectionTarget, ArtifactInspectionTreeNode, } from '../types.ts'; +import { YieldableCodedError } from '../../effect/errors.ts'; export type ArtifactInspectionServiceErrorCode = | 'ARTIFACT_INSPECTION_INVALID' @@ -58,7 +58,7 @@ const snapshotDiagnostic = (diagnostic: Diagnostic): Diagnostic => Object.freeze ...(diagnostic.target === undefined ? {} : { target: diagnostic.target }), }); -export class ArtifactInspectionServiceError extends CodedError { +export class ArtifactInspectionServiceError extends YieldableCodedError { readonly diagnostics: readonly Diagnostic[]; constructor(code: ArtifactInspectionServiceErrorCode, message: string, diagnostics: readonly Diagnostic[]) { diff --git a/packages/agent-bundle/src/dev/coordinator.ts b/packages/agent-bundle/src/dev/coordinator.ts index 5f11282a5..ab5996d2d 100644 --- a/packages/agent-bundle/src/dev/coordinator.ts +++ b/packages/agent-bundle/src/dev/coordinator.ts @@ -26,6 +26,7 @@ import { type SourceStatus, type SucceededBuildAttempt, } from './types.ts'; +import { YieldableFrameworkError } from '../effect/errors.ts'; export interface DevLockHandle { close(): Promise; @@ -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[]) { diff --git a/packages/agent-bundle/src/dev/epoch-store.ts b/packages/agent-bundle/src/dev/epoch-store.ts index 29b811541..377dd62cd 100644 --- a/packages/agent-bundle/src/dev/epoch-store.ts +++ b/packages/agent-bundle/src/dev/epoch-store.ts @@ -12,6 +12,7 @@ import { runPromise, runSync } from '../effect/boundary.ts'; import { liftPromise, liftTry } from '../effect/lift.ts'; import { freezeArtifactEpoch, type ArtifactEpoch } from './types.ts'; import { deepFreeze } from '../core/freeze.ts'; +import { YieldableFrameworkError } from '../effect/errors.ts'; export interface EpochStoreOptions { @@ -69,7 +70,7 @@ export interface EpochCleanupFailure { readonly resource: EpochCleanupResource; } -export class EpochCleanupError extends Error { +export class EpochCleanupError extends YieldableFrameworkError { readonly failures: readonly EpochCleanupFailure[]; constructor(failures: readonly EpochCleanupFailure[]) { @@ -80,7 +81,7 @@ export class EpochCleanupError extends Error { } } -export class EpochPostCommitCleanupError extends Error { +export class EpochPostCommitCleanupError extends YieldableFrameworkError { readonly committedEpoch: ArtifactEpoch; constructor(committedEpoch: ArtifactEpoch, cleanupError: unknown) { @@ -91,7 +92,7 @@ export class EpochPostCommitCleanupError extends Error { } } -export class EpochPostCommitDurabilityError extends Error { +export class EpochPostCommitDurabilityError extends YieldableFrameworkError { readonly committedEpoch: ArtifactEpoch; constructor(committedEpoch: ArtifactEpoch, durabilityError: unknown) { @@ -104,7 +105,7 @@ export class EpochPostCommitDurabilityError extends Error { } } -export class EpochStoreError extends Error { +export class EpochStoreError extends YieldableFrameworkError { readonly code: EpochStoreErrorCode; constructor(code: EpochStoreErrorCode, message: string) { diff --git a/packages/agent-bundle/src/dev/eval/eval-service.ts b/packages/agent-bundle/src/dev/eval/eval-service.ts index 01d45c913..d18b205a4 100644 --- a/packages/agent-bundle/src/dev/eval/eval-service.ts +++ b/packages/agent-bundle/src/dev/eval/eval-service.ts @@ -37,6 +37,7 @@ import type { DevLogKindFor, DevLogSink } from '../logs/dev-log-service.ts'; import { isInsideOrEqual, toPosixRelative } from '../../core/paths.ts'; import { isErrno } from '../../core/errors.ts'; import { deepFreeze } from '../../core/freeze.ts'; +import { YieldableFrameworkError } from '../../effect/errors.ts'; export type EvalServiceErrorCode = @@ -199,7 +200,7 @@ const serviceError = (code: EvalServiceErrorCode, message: string): EvalServiceE new EvalServiceError(code, message); /** Explicit evidence that Eval shutdown observed more distinct background failures than it retained. */ -export class EvalServiceBackgroundFailureOverflowError extends Error { +export class EvalServiceBackgroundFailureOverflowError extends YieldableFrameworkError { readonly droppedCount: number; constructor(droppedCount: number) { diff --git a/packages/agent-bundle/src/dev/events.ts b/packages/agent-bundle/src/dev/events.ts index 1d4344685..3710fcb08 100644 --- a/packages/agent-bundle/src/dev/events.ts +++ b/packages/agent-bundle/src/dev/events.ts @@ -1,4 +1,3 @@ -import { CodedError } from '../core/errors.ts'; import { freezeJsonValue, freezeProjectEvent, @@ -8,6 +7,7 @@ import { type ProjectEventType, type ProjectReplayGap, } from './types.ts'; +import { YieldableCodedError } from '../effect/errors.ts'; type EpochScopedProjectEventType = 'artifact.available' | 'dev.contract.status' | 'dev.host.sync'; @@ -55,7 +55,7 @@ export type ProjectEventHubErrorCode = | 'PROJECT_EVENT_PAYLOAD_INVALID' | 'PROJECT_EVENT_TYPE_INVALID'; -export class ProjectEventHubError extends CodedError { +export class ProjectEventHubError extends YieldableCodedError { constructor(code: ProjectEventHubErrorCode, message: string) { super('ProjectEventHubError', code, message); } diff --git a/packages/agent-bundle/src/dev/foreground-server.ts b/packages/agent-bundle/src/dev/foreground-server.ts index 3d48e2e76..aa59de1af 100644 --- a/packages/agent-bundle/src/dev/foreground-server.ts +++ b/packages/agent-bundle/src/dev/foreground-server.ts @@ -39,6 +39,7 @@ import { singleHeader, type RequestDiagnostic, } from './http.ts'; +import { YieldableFrameworkError } from '../effect/errors.ts'; const instanceIdLengthLimit = 128; const loopbackHosts = new Set(['127.0.0.1', '::1']); @@ -52,7 +53,7 @@ interface QueuedSseFrame { export type ForegroundServerErrorCode = 'AB8000'; /** Configuration errors that prevent a foreground server from starting. */ -export class ForegroundServerError extends Error { +export class ForegroundServerError extends YieldableFrameworkError { readonly code: ForegroundServerErrorCode; constructor(code: ForegroundServerErrorCode, message: string) { @@ -73,7 +74,7 @@ export interface ForegroundServerStartFailure { } /** Reports all releases that failed after every foreground resource was asked to close. */ -export class ForegroundServerCloseError extends Error { +export class ForegroundServerCloseError extends YieldableFrameworkError { readonly failures: readonly ForegroundServerCloseFailure[]; constructor(failures: readonly ForegroundServerCloseFailure[]) { @@ -84,7 +85,7 @@ export class ForegroundServerCloseError extends Error { } /** Preserves a failed startup and every release failure needed to unwind it. */ -export class ForegroundServerStartError extends Error { +export class ForegroundServerStartError extends YieldableFrameworkError { readonly failures: readonly ForegroundServerStartFailure[]; constructor(failures: readonly ForegroundServerStartFailure[]) { diff --git a/packages/agent-bundle/src/dev/host-mcp-routes.ts b/packages/agent-bundle/src/dev/host-mcp-routes.ts index 964dc3090..3e6bd513c 100644 --- a/packages/agent-bundle/src/dev/host-mcp-routes.ts +++ b/packages/agent-bundle/src/dev/host-mcp-routes.ts @@ -15,13 +15,14 @@ import { type McpSession, type McpSessionService, } from './mcp-session/mcp-session-service.ts'; +import { YieldableFrameworkError } from '../effect/errors.ts'; const hostMcpPathPrefix = '/mcp/host/'; const internalErrorCode = -32_603; export const hostMcpEpochDriftCode = 'AB8024'; -export class HostMcpEpochDriftError extends Error { +export class HostMcpEpochDriftError extends YieldableFrameworkError { readonly code = hostMcpEpochDriftCode; readonly epochId: string; diff --git a/packages/agent-bundle/src/dev/inspector-launcher.ts b/packages/agent-bundle/src/dev/inspector-launcher.ts index fff166e47..39d7cf107 100644 --- a/packages/agent-bundle/src/dev/inspector-launcher.ts +++ b/packages/agent-bundle/src/dev/inspector-launcher.ts @@ -2,8 +2,8 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { resolve } from 'node:path'; import { sleep as delay } from '../core/async.ts'; -import { CodedError } from '../core/errors.ts'; import { taskkill, terminateProcessTree } from '../services/process-tree.ts'; +import { YieldableCodedError } from '../effect/errors.ts'; const inspectorPackage = '@modelcontextprotocol/inspector'; const startupBudgetMs = 30_000; @@ -54,7 +54,7 @@ export interface InspectorLauncher { } /** Coded refusals a caller can act on without reading inspector internals. */ -export class InspectorLauncherError extends CodedError { +export class InspectorLauncherError extends YieldableCodedError { constructor(code: InspectorLauncherErrorCode, message: string) { super('InspectorLauncherError', code, message); } diff --git a/packages/agent-bundle/src/dev/logs/dev-log-service.ts b/packages/agent-bundle/src/dev/logs/dev-log-service.ts index f7137d6db..2e0aad755 100644 --- a/packages/agent-bundle/src/dev/logs/dev-log-service.ts +++ b/packages/agent-bundle/src/dev/logs/dev-log-service.ts @@ -13,6 +13,7 @@ import { type DevLogLevel, type DevLogProducer, } from './dev-log-kinds.ts'; +import { YieldableFrameworkError } from '../../effect/errors.ts'; export { devLogKinds, devLogLevels, devLogProducers } from './dev-log-kinds.ts'; export type { DevLogKindFor, DevLogKindMap, DevLogLevel, DevLogProducer } from './dev-log-kinds.ts'; @@ -88,7 +89,7 @@ export interface DevLogServiceOptions { export type DevLogServiceErrorCode = 'DEV_LOG_CURSOR_AHEAD' | 'DEV_LOG_CURSOR_INVALID' | 'DEV_LOG_SERVICE_CLOSED'; -export class DevLogServiceError extends Error { +export class DevLogServiceError extends YieldableFrameworkError { readonly code: DevLogServiceErrorCode; constructor(code: DevLogServiceErrorCode, message: string) { diff --git a/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts b/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts index 3b382841c..db1fac087 100644 --- a/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts +++ b/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts @@ -39,6 +39,7 @@ import { import type { RuntimeClientSurfaceContentPolicy } from './runtime-client-surface-proxy.ts'; import type { DevRuntimeClientSurfaceProxyBinding, DevRuntimeMcpRegistryMessage, DevRuntimeMcpSessionView, DevRuntimeSession } from './runtime-provider.ts'; import type { DevRuntimeMcpAppRunBinding, DevRuntimeMcpConnectionState, RuntimeVector } from './runtime-protocol.ts'; +import { YieldableFrameworkError } from '../effect/errors.ts'; export type McpAppBindingOperation = | Readonly<{ readonly kind: 'tools/list' }> @@ -129,7 +130,7 @@ export interface McpAppRuntimeOperationOptions { } /** Closed, phase-safe diagnostics intended for the authenticated runtime App route. */ -export class McpAppRuntimePreviewError extends Error { +export class McpAppRuntimePreviewError extends YieldableFrameworkError { readonly code: 'AB8023' | 'AB8201' | 'AB8203' | 'AB8204'; readonly status: 400 | 404 | 409 | 502; diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts index ee1cdab9c..c1cbe03bc 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts @@ -19,8 +19,8 @@ import type { McpSessionReplayOverflow, } from './mcp-session-protocol.ts'; import type { McpSessionTraceSink } from './mcp-session-trace.ts'; -import { CodedError } from '../../core/errors.ts'; import { deepFreeze } from '../../core/freeze.ts'; +import { YieldableCodedError, YieldableFrameworkError } from '../../effect/errors.ts'; export interface McpRequestOptions { @@ -159,11 +159,11 @@ export type McpSessionErrorCode = * Expected session-lifecycle failures on the Effect error channel: the * session or its service is closed, a protocol call ran before `initialize`, * or a request was admitted with an invalid or already-active `requestId`. - * These ride the fail channel as a `CodedError` (never `Effect.die`) and + * These ride the fail channel as a coded error (never `Effect.die`) and * rethrow unchanged at `src/effect/boundary.ts`, so Promise callers keep the * exact messages they saw before the class existed. */ -export class McpSessionError extends CodedError { +export class McpSessionError extends YieldableCodedError { constructor(code: McpSessionErrorCode, message: string) { super('McpSessionError', code, message); } @@ -202,7 +202,7 @@ export class McpSessionError extends CodedError { * retention, which cannot observe this process's epoch leases). Tool calls * fail closed with this error instead of hanging against a vanished artifact. */ -export class McpSessionStaleEpochError extends Error { +export class McpSessionStaleEpochError extends YieldableFrameworkError { readonly epochId: string; constructor(epochId: string, options?: Readonly<{ readonly cause?: unknown }>) { @@ -216,7 +216,7 @@ export class McpSessionStaleEpochError extends Error { } /** Reports every session-service lifecycle failure after all tracked work settles. */ -export class McpSessionServiceCloseError extends Error { +export class McpSessionServiceCloseError extends YieldableFrameworkError { readonly failures: readonly McpSessionServiceCloseFailure[]; constructor(failures: readonly McpSessionServiceCloseFailure[]) { diff --git a/packages/agent-bundle/src/dev/playground/hook-playground-routes.ts b/packages/agent-bundle/src/dev/playground/hook-playground-routes.ts index 317af170b..6d5b4ac80 100644 --- a/packages/agent-bundle/src/dev/playground/hook-playground-routes.ts +++ b/packages/agent-bundle/src/dev/playground/hook-playground-routes.ts @@ -1,6 +1,5 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; -import { CodedError } from '../../core/errors.ts'; import { isRecord } from '../../core/strict-json.ts'; import { isHookSimulationCancellation } from '../../services/hook-service.ts'; import { @@ -24,6 +23,7 @@ import type { HookPlaygroundSimulation, HookPlaygroundSimulationOptions, } from './hook-playground-service.ts'; +import { YieldableCodedError } from '../../effect/errors.ts'; type Route = Readonly<{ readonly kind: 'hooks' | 'simulations' | 'replays' }>; @@ -37,7 +37,7 @@ export interface HookPlaygroundCloseFailure { } /** Reports every in-flight operation that failed to settle once shutdown cancelled it. */ -export class HookPlaygroundCloseError extends CodedError<'AB8034'> { +export class HookPlaygroundCloseError extends YieldableCodedError<'AB8034'> { readonly failures: readonly HookPlaygroundCloseFailure[]; constructor(failures: readonly HookPlaygroundCloseFailure[]) { diff --git a/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts b/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts index 95a8050e5..c3eec6f5d 100644 --- a/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts +++ b/packages/agent-bundle/src/dev/playground/lifecycle-replay-service.ts @@ -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'; @@ -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; diff --git a/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts b/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts index 437b11cd2..ae48d15e4 100644 --- a/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts +++ b/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts @@ -36,6 +36,7 @@ import { } from '../mcp-session/mcp-session-launch.ts'; import type { McpSessionInspectorConfig } from '../mcp-session/mcp-session-protocol.ts'; import type { RemoteTransportOptions, StdioOptions } from '../mcp-session/mcp-session-types.ts'; +import { YieldableFrameworkError } from '../../effect/errors.ts'; export const mcpProbeTimeoutMs = 10_000; export const mcpProbeToolLimit = 200; @@ -131,14 +132,14 @@ export interface McpProbeServiceOptions { readonly timers?: McpProbeTimers; } -export class McpProbeTargetNotFoundError extends Error { +export class McpProbeTargetNotFoundError extends YieldableFrameworkError { constructor(message: string) { super(message); this.name = 'McpProbeTargetNotFoundError'; } } -class McpProbeTimeoutError extends Error { +class McpProbeTimeoutError extends YieldableFrameworkError { readonly kind: McpProbeFailureKind; constructor(kind: McpProbeFailureKind) { @@ -148,7 +149,7 @@ class McpProbeTimeoutError extends Error { } } -class McpProbeProtocolError extends Error { +class McpProbeProtocolError extends YieldableFrameworkError { readonly kind: McpProbeFailureKind; constructor(kind: McpProbeFailureKind, message: string, options?: ErrorOptions) { diff --git a/packages/agent-bundle/src/dev/playground/playground-store.ts b/packages/agent-bundle/src/dev/playground/playground-store.ts index 5883663be..bc32ed1dc 100644 --- a/packages/agent-bundle/src/dev/playground/playground-store.ts +++ b/packages/agent-bundle/src/dev/playground/playground-store.ts @@ -10,6 +10,7 @@ import { isInsideOrEqual } from '../../core/paths.ts'; import { hasExactOwnKeys, isRecord, parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts'; import type { DevLogSink } from '../logs/dev-log-service.ts'; import { deepFreeze } from '../../core/freeze.ts'; +import { YieldableFrameworkError } from '../../effect/errors.ts'; export type PlaygroundJsonPrimitive = boolean | null | number | string; @@ -170,7 +171,7 @@ export type PlaygroundServiceErrorCode = | 'PLAYGROUND_STORE_CORRUPT' | 'PLAYGROUND_VALUE_INVALID'; -export class PlaygroundServiceError extends Error { +export class PlaygroundServiceError extends YieldableFrameworkError { readonly code: PlaygroundServiceErrorCode; constructor(code: PlaygroundServiceErrorCode, message: string) { @@ -180,7 +181,7 @@ export class PlaygroundServiceError extends Error { } } -export class PlaygroundSessionCloseError extends Error { +export class PlaygroundSessionCloseError extends YieldableFrameworkError { readonly failures: readonly PlaygroundCleanupFailure[]; readonly sessionId: string; @@ -197,7 +198,7 @@ export interface PlaygroundServiceCloseFailure { readonly sessionId: string; } -export class PlaygroundServiceCloseError extends Error { +export class PlaygroundServiceCloseError extends YieldableFrameworkError { readonly failures: readonly PlaygroundServiceCloseFailure[]; constructor(failures: readonly PlaygroundServiceCloseFailure[]) { diff --git a/packages/agent-bundle/src/dev/playground/script-playground-service.ts b/packages/agent-bundle/src/dev/playground/script-playground-service.ts index 179444997..9fe94cfa8 100644 --- a/packages/agent-bundle/src/dev/playground/script-playground-service.ts +++ b/packages/agent-bundle/src/dev/playground/script-playground-service.ts @@ -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, @@ -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; @@ -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 { +export class ScriptPlaygroundFailure extends YieldableCodedError { readonly cleanupFailures: readonly ScriptPlaygroundCleanupFailure[]; readonly stderr: string; readonly stdout: string; @@ -83,7 +84,7 @@ export class ScriptPlaygroundFailure extends CodedError; @@ -16,7 +17,7 @@ const importProviderModule: DevRuntimeModuleImporter = async (path) => { return jiti.import(path); }; -export class DevRuntimeProviderLoadError extends Error { +export class DevRuntimeProviderLoadError extends YieldableFrameworkError { readonly code = 'AB8200' as const; constructor(message: string, options?: ErrorOptions) { diff --git a/packages/agent-bundle/src/dev/runtime-provider.ts b/packages/agent-bundle/src/dev/runtime-provider.ts index c082204ce..ba859c7cf 100644 --- a/packages/agent-bundle/src/dev/runtime-provider.ts +++ b/packages/agent-bundle/src/dev/runtime-provider.ts @@ -20,6 +20,7 @@ import type { DevRuntimeStatus, DevRuntimeSurface, } from './runtime-protocol.ts'; +import { YieldableFrameworkError } from '../effect/errors.ts'; /** Trusted-process-only compiler endpoint; never serialize it into runtime JSON. */ export interface DevRuntimeClientSurfaceEndpoint { @@ -182,7 +183,7 @@ export interface DevRuntimeProvider { export type CreateDevRuntimeProvider = () => DevRuntimeProvider | Promise; -export class DevRuntimeUnavailableError extends Error { +export class DevRuntimeUnavailableError extends YieldableFrameworkError { readonly code = 'AB8201' as const; constructor(message = 'Development runtime is not available.') { @@ -191,7 +192,7 @@ export class DevRuntimeUnavailableError extends Error { } } -export class DevRuntimeGenerationConflictError extends Error { +export class DevRuntimeGenerationConflictError extends YieldableFrameworkError { readonly actualGenerationId?: string; readonly code = 'AB8204' as const; readonly expectedGenerationId: string; diff --git a/packages/agent-bundle/src/dev/skill-document-service.ts b/packages/agent-bundle/src/dev/skill-document-service.ts index e6376ffcd..3db9e93c1 100644 --- a/packages/agent-bundle/src/dev/skill-document-service.ts +++ b/packages/agent-bundle/src/dev/skill-document-service.ts @@ -5,12 +5,13 @@ import { projectMeta } from '../build/meta.ts'; import { parseSkill, type SkillDocument, type SkillResource } from '../config/skill.ts'; import { freezeDiagnostics } from '../core/diagnostics.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; -import { CodedError, isErrno } from '../core/errors.ts'; +import { isErrno } from '../core/errors.ts'; import type { NormalizedPlugin, NormalizedSkill, SourceProvenance } from '../core/types.ts'; import { EpochStore } from './epoch-store.ts'; import { ProjectService } from './project-service.ts'; import { isInsideOrEqual } from '../core/paths.ts'; import { deepFreeze } from '../core/freeze.ts'; +import { YieldableCodedError } from '../effect/errors.ts'; export type SkillDocumentErrorCode = @@ -20,7 +21,7 @@ export type SkillDocumentErrorCode = | 'SKILL_TARGET_UNAVAILABLE'; /** Stable, route-safe failures from the explicit source/epoch Skill bases. */ -export class SkillDocumentError extends CodedError { +export class SkillDocumentError extends YieldableCodedError { constructor(code: SkillDocumentErrorCode, message: string) { super('SkillDocumentError', code, message); } diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index 7e2c5ca48..f28a8815e 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -69,6 +69,7 @@ import { SkillDocumentService } from './skill-document-service.ts'; import { createWorkbenchAssetSource } from './workbench-assets.ts'; import type { Invalidation, ProjectStatus } from './types.ts'; import { deepFreeze } from '../core/freeze.ts'; +import { YieldableFrameworkError } from '../effect/errors.ts'; export interface DevServerSession { @@ -90,7 +91,7 @@ export interface DevServerLifecycleCloseFailure { } /** Reports session and coordinator cleanup failures without hiding either resource. */ -export class DevServerLifecycleCloseError extends Error { +export class DevServerLifecycleCloseError extends YieldableFrameworkError { readonly failures: readonly DevServerLifecycleCloseFailure[]; constructor(failures: readonly DevServerLifecycleCloseFailure[]) { @@ -155,7 +156,7 @@ export interface DevServerStartFailure { } /** Preserves a failed post-listener startup and every release failure needed to unwind it. */ -export class DevServerStartError extends Error { +export class DevServerStartError extends YieldableFrameworkError { readonly failures: readonly DevServerStartFailure[]; constructor(failures: readonly DevServerStartFailure[]) { @@ -170,7 +171,7 @@ interface McpAppLifecycleCloseFailure { readonly resource: 'previews' | 'runtime-previews' | 'sandbox'; } -class McpAppLifecycleCloseError extends Error { +class McpAppLifecycleCloseError extends YieldableFrameworkError { readonly failures: readonly McpAppLifecycleCloseFailure[]; constructor(failures: readonly McpAppLifecycleCloseFailure[]) { diff --git a/packages/agent-bundle/src/effect/errors.ts b/packages/agent-bundle/src/effect/errors.ts new file mode 100644 index 000000000..ef9e291fd --- /dev/null +++ b/packages/agent-bundle/src/effect/errors.ts @@ -0,0 +1,100 @@ +import { Data } from 'effect'; + +/** + * Yieldable bases for framework-process error classes (Wave 3.5, maintainer + * decision 2026-09-03: adopt `Data.Error`; `Schema.TaggedError` stays + * deferred). A class that extends one of these can be raised inside + * `Effect.gen` as `return yield* new X(...)` without `Effect.fail`, and still + * behaves as the plain `Error` subclass it replaces: + * + * - `instanceof Error` / `instanceof X`, `.name`, `.message`, `.code`, + * `.cause`, and `.stack` are unchanged (rc.112 `Data.Error` is + * `class extends globalThis.Error`). + * - `JSON.stringify(error)`, `stableJson(error)`, and `{ ...error }` stay + * byte-identical to the plain-`Error` output. rc.112 `Data.Error#toJSON` + * would spread the constructor fields (`message`, `cause`) into the JSON + * and `stableJson` would then sort the keys; the base shadows `toJSON` so + * both serializers take their plain-object path ("own enumerable fields, + * insertion order"). + * - `util.inspect` / `console.error(error)` print the stack trace. rc.112 + * installs `[nodejs.util.inspect.custom]` on the yieldable prototype + * (returning `toJSON()`), which would replace the stack with a field dump + * in CLI output; the base shadows it so Node's default `Error` formatting + * applies again. + * - `cause` is installed exactly like `new Error(message, { cause })`: only + * when `options` carries the key, and never enumerable. rc.112 passes a + * falsy `cause` through `Object.assign`-style property assignment, which + * would make it an enumerable own field. + * + * Scope: internal classes of the framework process (the dev seam, the eval + * service). This module imports `effect`, so anything reachable from an + * Effect-free entry (`agent-bundle/config`, `agent-bundle/meta`, + * `agent-bundle/rstest`, `agent-bundle/test/browser`, the CLI's `--help` / + * `--version` path, the host MCP proxy, emitted hook / MCP / bin runtime) + * keeps `CodedError` / `Error`; `tests/cli.test.ts` and + * `tests/emitted-artifact-effect-surface.test.ts` pin that. Public classes + * (exported from a package entry) also stay plain so Effect types never + * appear in user-facing declarations. Carve-out list: + * `docs/effect-conventions.md` § Yieldable framework errors. + */ + +interface YieldableFields { + readonly message?: string; +} + +const nodeInspectSymbol = Symbol.for('nodejs.util.inspect.custom'); + +const installCause = (target: object, options: ErrorOptions | undefined): void => { + if (options === undefined || !('cause' in options)) return; + Object.defineProperty(target, 'cause', { + configurable: true, + enumerable: false, + value: options.cause, + writable: true, + }); +}; + +/** + * `Error`'s yieldable twin: same `(message?, options?)` constructor, same + * observable shape (see the module doc), plus `yield*` inside `Effect.gen`. + * Subclasses set `this.name` exactly as they did on `Error`. + */ +export class YieldableFrameworkError extends Data.Error { + constructor(message?: string, options?: ErrorOptions) { + super(message === undefined ? {} : { message }); + installCause(this, options); + } +} + +// Shadow Effect's prototype `toJSON` (spreads the constructor fields) and +// `[nodejs.util.inspect.custom]` (returns `toJSON()`) with non-functions. +// `JSON.stringify`, `stableJson`, Effect's `Inspectable.toJSON`, and +// `util.inspect` all check `typeof === 'function'` first, so every one of +// them falls back to exactly what it does for a plain `Error` subclass: own +// enumerable fields in insertion order, and the default `name: message` + +// stack rendering. Defined on the prototype (not as class members) because +// `Cause.YieldableError` types both as methods. +for (const key of ['toJSON', nodeInspectSymbol] as const) { + Object.defineProperty(YieldableFrameworkError.prototype, key, { + configurable: true, + enumerable: false, + value: undefined, + writable: true, + }); +} + +/** + * `CodedError`'s yieldable twin: identical `(name, code, message, options?)` + * constructor and `code` field, for coded framework-process errors raised in + * Effect programs. The boundary's `isTypedDevError` already matches it + * through its string `code` (no `instanceof CodedError` needed). + */ +export class YieldableCodedError extends YieldableFrameworkError { + readonly code: TCode; + + constructor(name: string, code: TCode, message: string, options?: ErrorOptions) { + super(message, options); + this.name = name; + this.code = code; + } +} diff --git a/packages/agent-bundle/src/eval/codex-errors.ts b/packages/agent-bundle/src/eval/codex-errors.ts index bb0932072..82cb19d73 100644 --- a/packages/agent-bundle/src/eval/codex-errors.ts +++ b/packages/agent-bundle/src/eval/codex-errors.ts @@ -1,6 +1,6 @@ -import { CodedError } from '../core/errors.ts'; import type { EvalHarnessFailure, EvalHarnessFailureCode, EvalHarnessFailureStage } from './types.ts'; import { deepFreeze } from '../core/freeze.ts'; +import { YieldableCodedError } from '../effect/errors.ts'; export type CodexEvalHarnessErrorCode = @@ -15,7 +15,7 @@ export type CodexEvalHarnessErrorCode = | 'CODEX_TRIAL_CANCELLED'; /** A defect in Agent Bundle or the installed Codex CLI, never evidence about the plugin. */ -export class CodexEvalHarnessError extends CodedError { +export class CodexEvalHarnessError extends YieldableCodedError { constructor(code: CodexEvalHarnessErrorCode, message: string) { super('CodexEvalHarnessError', code, message); } diff --git a/packages/agent-bundle/src/eval/run-store.ts b/packages/agent-bundle/src/eval/run-store.ts index 89ba2495d..4b21e331e 100644 --- a/packages/agent-bundle/src/eval/run-store.ts +++ b/packages/agent-bundle/src/eval/run-store.ts @@ -18,6 +18,7 @@ import type { EvalPluginFailure, EvalTrialEvidence, } from './types.ts'; +import { YieldableFrameworkError } from '../effect/errors.ts'; export interface EvalArtifactBinding { readonly manifestPath: string; @@ -131,7 +132,7 @@ export interface EvalRunEvent { } /** The full JSONL event line exists, but fsync or descriptor close could not confirm its durability. */ -export class EvalRunEventDurabilityError extends Error { +export class EvalRunEventDurabilityError extends YieldableFrameworkError { readonly event: EvalRunEvent; readonly failures: readonly unknown[]; @@ -144,7 +145,7 @@ export class EvalRunEventDurabilityError extends Error { } /** A failed append may have left bytes that could not be durably rolled back to the prior journal boundary. */ -export class EvalRunEventWriteUncertainError extends Error { +export class EvalRunEventWriteUncertainError extends YieldableFrameworkError { readonly event: EvalRunEvent; readonly failures: readonly unknown[]; diff --git a/packages/agent-bundle/src/host-contracts/native-codex-contract.ts b/packages/agent-bundle/src/host-contracts/native-codex-contract.ts index 47bf84a18..ff556f2de 100644 --- a/packages/agent-bundle/src/host-contracts/native-codex-contract.ts +++ b/packages/agent-bundle/src/host-contracts/native-codex-contract.ts @@ -16,6 +16,7 @@ import { type DigestSnapshot, } from './native-host-spine.ts'; import { runBoundedChildProcess } from './process.ts'; +import { YieldableFrameworkError } from '../effect/errors.ts'; const codexExecutable = 'codex'; const minimumCodexVersion = '0.147.0'; @@ -104,7 +105,7 @@ export interface CodexNativeSmokeResult { type CodexStateSite = 'auth' | 'config' | 'plugins'; type CodexStateSnapshot = DigestSnapshot; -class SmokeStepError extends Error { +class SmokeStepError extends YieldableFrameworkError { readonly code?: string; readonly failure?: 'output-limit' | 'timeout'; readonly output?: string; diff --git a/packages/agent-bundle/src/services/hook-service.ts b/packages/agent-bundle/src/services/hook-service.ts index 6623b39c0..b22396c7c 100644 --- a/packages/agent-bundle/src/services/hook-service.ts +++ b/packages/agent-bundle/src/services/hook-service.ts @@ -13,6 +13,7 @@ import { validateArtifact } from '../build/validate-artifact.ts'; import { parseArtifactHookIndex } from '../build/hook-index.ts'; import { taskkill, terminateProcessTree, type ProcessTreeTaskkill } from './process-tree.ts'; import { deepFreeze } from '../core/freeze.ts'; +import { YieldableFrameworkError } from '../effect/errors.ts'; const defaultTimeoutMs = 5_000; @@ -60,7 +61,7 @@ const cancellations = new WeakSet(); * clone that could not be removed stays a real failure even when it reports the * same surface. */ -class HookSimulationAbortError extends Error { +class HookSimulationAbortError extends YieldableFrameworkError { readonly code = 'hook.simulation.aborted'; constructor() { @@ -74,7 +75,7 @@ class HookSimulationAbortError extends Error { export const isHookSimulationCancellation = (error: unknown): boolean => typeof error === 'object' && error !== null && cancellations.has(error); -class HookSimulationTerminationError extends Error { +class HookSimulationTerminationError extends YieldableFrameworkError { readonly code = 'hook.simulation.termination.unsettled'; constructor(reason: Error) { diff --git a/packages/agent-bundle/tests/effect-errors.test.ts b/packages/agent-bundle/tests/effect-errors.test.ts new file mode 100644 index 000000000..3cca6bc4f --- /dev/null +++ b/packages/agent-bundle/tests/effect-errors.test.ts @@ -0,0 +1,116 @@ +import { inspect } from 'node:util'; + +import { Cause, Effect, Exit } from 'effect'; +import { describe, expect, it } from '@rstest/core'; + +import { stableJson } from '../src/core/digest.ts'; +import { CodedError } from '../src/core/errors.ts'; +import { DevCoordinatorCloseError } from '../src/dev/coordinator.ts'; +import { EpochStoreError } from '../src/dev/epoch-store.ts'; +import { McpSessionError } from '../src/dev/mcp-session/mcp-session-types.ts'; +import { isTypedDevError, runPromise, runPromiseExit } from '../src/effect/boundary.ts'; +import { YieldableCodedError, YieldableFrameworkError } from '../src/effect/errors.ts'; + +/** The plain-`Error` twin the yieldable bases replace; the serialization pins compare against it. */ +class PlainCoded extends CodedError<'AB0001'> { + readonly detail: string; + + constructor(message: string, detail: string, options?: ErrorOptions) { + super('Probe', 'AB0001', message, options); + this.detail = detail; + } +} + +class YieldableCoded extends YieldableCodedError<'AB0001'> { + readonly detail: string; + + constructor(message: string, detail: string, options?: ErrorOptions) { + super('Probe', 'AB0001', message, options); + this.detail = detail; + } +} + +describe('yieldable framework error bases (src/effect/errors.ts)', () => { + it('fails an Effect.gen program by yield* with the same instance Effect.fail would carry', async () => { + const error = new McpSessionError('MCP_SESSION_CLOSED', 'Session "s1" is closed.'); + const program = Effect.gen(function* () { + return yield* error; + }); + const exit = await runPromiseExit(program); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBe(error); + } + await expect(runPromise(program)).rejects.toBe(error); + await expect(runPromise(Effect.fail(error))).rejects.toBe(error); + expect(isTypedDevError(error)).toBe(true); + const uncoded = new DevCoordinatorCloseError([]); + await expect(runPromise(Effect.gen(function* () { + return yield* uncoded; + }))).rejects.toBe(uncoded); + }); + + it('types the yielded error into the fail channel', () => { + const program: Effect.Effect = Effect.gen(function* () { + return yield* new EpochStoreError('EPOCH_NOT_FOUND', 'No active artifact epoch is available.'); + }); + expect(program).toBeDefined(); + }); + + it('keeps the plain Error shape: instanceof, name, code, message, cause, stack', () => { + const cause = new Error('inner'); + const error = new YieldableCoded('outer', 'd', { cause }); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(YieldableCodedError); + expect(error).toBeInstanceOf(YieldableFrameworkError); + expect(error.name).toBe('Probe'); + expect(error.code).toBe('AB0001'); + expect(error.message).toBe('outer'); + expect(error.cause).toBe(cause); + expect(Object.getOwnPropertyDescriptor(error, 'cause')?.enumerable).toBe(false); + expect(Object.getOwnPropertyDescriptor(error, 'message')?.enumerable).toBe(false); + expect(error.stack?.split('\n')[0]).toBe('Probe: outer'); + expect(String(error)).toBe('Probe: outer'); + expect('cause' in new YieldableCoded('no cause', 'd')).toBe(false); + }); + + it('installs a falsy cause exactly like new Error(message, { cause })', () => { + for (const cause of [null, 0, '', false, undefined]) { + const plain = new PlainCoded('m', 'd', { cause }); + const yieldable = new YieldableCoded('m', 'd', { cause }); + expect('cause' in yieldable).toBe('cause' in plain); + expect(yieldable.cause).toBe(plain.cause); + expect(Object.keys(yieldable)).toEqual(Object.keys(plain)); + } + }); + + it('serializes byte-identically to the plain Error twin (JSON.stringify, stableJson, spread)', () => { + const plain = new PlainCoded('outer', 'd', { cause: new Error('inner') }); + const yieldable = new YieldableCoded('outer', 'd', { cause: new Error('inner') }); + expect(Object.keys(yieldable)).toEqual(Object.keys(plain)); + expect(Object.keys(yieldable)).toEqual(['code', 'name', 'detail']); + expect(JSON.stringify(yieldable)).toBe(JSON.stringify(plain)); + expect(JSON.stringify(yieldable)).toBe('{"code":"AB0001","name":"Probe","detail":"d"}'); + expect(stableJson(yieldable)).toBe(stableJson(plain)); + expect(stableJson({ error: yieldable })).toBe(stableJson({ error: plain })); + expect({ ...yieldable }).toEqual({ ...plain }); + expect(structuredClone(yieldable)).toEqual(structuredClone(plain)); + }); + + it('prints the stack trace under util.inspect instead of a field dump', () => { + const yieldable = new YieldableCoded('outer', 'd'); + const rendered = inspect(yieldable); + expect(rendered.startsWith('Probe: outer\n at ')).toBe(true); + expect(rendered).toContain("code: 'AB0001'"); + expect(rendered.startsWith('{')).toBe(false); + }); + + it('never leaks into a package entry', async () => { + const rootApi = await import('../src/index.ts'); + const api = await import('../src/api.ts'); + expect('YieldableCodedError' in rootApi).toBe(false); + expect('YieldableFrameworkError' in rootApi).toBe(false); + expect('YieldableCodedError' in api).toBe(false); + expect('YieldableFrameworkError' in api).toBe(false); + }); +}); diff --git a/packages/agent-bundle/tests/emitted-artifact-effect-surface.test.ts b/packages/agent-bundle/tests/emitted-artifact-effect-surface.test.ts new file mode 100644 index 000000000..762e56f20 --- /dev/null +++ b/packages/agent-bundle/tests/emitted-artifact-effect-surface.test.ts @@ -0,0 +1,160 @@ +import { cp, mkdtemp, readdir, readFile, rm, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from '@rstest/core'; + +import { build } from '../src/api.ts'; + +/** + * Emitted artifacts never bundle the yieldable framework error bases + * (`src/effect/errors.ts`, the `Data.Error` twins of `Error` / `CodedError`). + * + * `examples/host-test` emits every artifact class the framework produces: + * per-event hook wrappers, the hooks Flight worker, CLI bins plus their + * Flight worker, the framework MCP lifecycle shell plus its Flight worker, a + * hand-rolled stdio MCP server, and the `install.mjs` script. The classes + * that already carry the Effect runtime (hook wrappers, bins, framework MCP + * shell) do so through `src/effect/boundary.ts`, whose coded base is the + * plain `CodedError`; the classes without Effect (raw stdio server, + * `install.mjs`) must stay without it. A byte-size gate cannot see a 12 kB + * delta inside a 2.4 MB unminified wrapper, so this test pins the invariant + * itself: no emitted file contains the yieldable base, Effect-free classes + * stay Effect-free, and the wrapper runtime's coded base stays plain. + */ + +/** `Symbol.for` key Effect's `Data.Error` registers; present iff Effect's core is bundled. */ +const effectCoreMarker = 'effect/Data/Error/plainArgs'; +const yieldableBasePattern = /Yieldable(?:Coded|Framework)Error/u; +const plainCodedBase = 'class CodedError extends Error'; + +type ArtifactClass = + | 'cli-bin' + | 'cli-bin-flight-worker' + | 'hook-flight-worker' + | 'hook-wrapper' + | 'install-script' + | 'mcp-flight-worker' + | 'mcp-framework-shell' + | 'mcp-raw-stdio-server'; + +const classify = (relativePath: string): ArtifactClass | undefined => { + const [, kind, file] = relativePath.split('/'); + if (kind === 'install.mjs' && file === undefined) return 'install-script'; + if (file === undefined || !file.endsWith('.mjs')) return undefined; + switch (kind) { + case 'hooks': + return file === 'hooks-flight.mjs' ? 'hook-flight-worker' : 'hook-wrapper'; + case 'bin': + return file.endsWith('-flight.mjs') ? 'cli-bin-flight-worker' : 'cli-bin'; + case 'mcp': + if (file.endsWith('-flight.mjs')) return 'mcp-flight-worker'; + return file.startsWith('mcp-host-test-raw-') ? 'mcp-raw-stdio-server' : 'mcp-framework-shell'; + default: + return undefined; + } +}; + +/** Classes whose emitted code carries no Effect runtime at all. */ +const effectFreeClasses: ReadonlySet = new Set(['install-script', 'mcp-raw-stdio-server']); +/** + * Classes whose bundled runtime always includes `src/effect/boundary.ts` and + * therefore the plain `CodedError` (the framework MCP shell only does so on + * hosts with an event runtime, so it is checked by the yieldable-base scan + * alone). + */ +const boundaryClasses: ReadonlySet = new Set(['hook-wrapper']); +const everyClass: readonly ArtifactClass[] = [ + 'cli-bin', + 'cli-bin-flight-worker', + 'hook-flight-worker', + 'hook-wrapper', + 'install-script', + 'mcp-flight-worker', + 'mcp-framework-shell', + 'mcp-raw-stdio-server', +]; + +interface EmittedFile { + readonly artifactClass: ArtifactClass; + readonly bytes: number; + readonly content: string; + readonly relativePath: string; +} + +const listFiles = async (root: string): Promise => { + const entries = await readdir(root, { recursive: true, withFileTypes: true }); + return entries + .filter((entry) => entry.isFile()) + .map((entry) => relative(root, join(entry.parentPath, entry.name))) + .sort(); +}; + +let projectRoot: string | undefined; +let emitted: readonly EmittedFile[] = []; + +beforeAll(async () => { + const exampleRoot = join(process.cwd(), 'examples', 'host-test'); + projectRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-emitted-artifact-effect-surface-')); + // Only what the build reads: the example's tsconfig extends the workspace + // root and its tests / scripts are not artifact inputs. + const inputs = new Set(['', 'agent-bundle.config.ts', 'package.json', 'src']); + await cp(exampleRoot, projectRoot, { + filter: (source) => inputs.has(relative(exampleRoot, source).split('/')[0] ?? ''), + recursive: true, + }); + await symlink(join(exampleRoot, 'node_modules'), join(projectRoot, 'node_modules'), 'dir'); + const artifactRoot = join(projectRoot, 'artifact'); + const result = await build({ output: artifactRoot, root: projectRoot }); + expect(result.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); + const files: EmittedFile[] = []; + for (const relativePath of await listFiles(artifactRoot)) { + const artifactClass = classify(relativePath); + if (artifactClass === undefined) continue; + const content = await readFile(join(artifactRoot, relativePath), 'utf8'); + files.push({ artifactClass, bytes: Buffer.byteLength(content), content, relativePath }); + } + emitted = files; +}, 240_000); + +afterAll(async () => { + if (projectRoot !== undefined) await rm(projectRoot, { force: true, recursive: true }); +}); + +describe('emitted artifacts and the yieldable framework error bases', () => { + it('emits every artifact class the invariant covers', () => { + const present = new Set(emitted.map((file) => file.artifactClass)); + expect([...present].sort()).toEqual(everyClass); + }); + + it('never bundles src/effect/errors.ts into an emitted artifact', () => { + const offenders = emitted + .filter((file) => yieldableBasePattern.test(file.content)) + .map((file) => file.relativePath); + expect(offenders).toEqual([]); + }); + + it('keeps the Effect-free artifact classes free of the Effect runtime', () => { + const offenders = emitted + .filter((file) => effectFreeClasses.has(file.artifactClass) && file.content.includes(effectCoreMarker)) + .map((file) => file.relativePath); + expect(offenders).toEqual([]); + }); + + it('keeps the wrapper runtime on the plain CodedError base', () => { + const missing = emitted + .filter((file) => boundaryClasses.has(file.artifactClass) && !file.content.includes(plainCodedBase)) + .map((file) => file.relativePath); + expect(missing).toEqual([]); + }); + + it('reports one size per artifact class for the PR record', () => { + const byClass = new Map(); + for (const file of emitted) { + byClass.set(file.artifactClass, Math.max(byClass.get(file.artifactClass) ?? 0, file.bytes)); + } + for (const artifactClass of everyClass) { + expect(byClass.get(artifactClass)).toBeGreaterThan(0); + } + }); +}); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 75e01b526..ca13cb557 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -28,6 +28,7 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/dev-live-host.test.ts', 'packages/agent-bundle/tests/dev-package-build.test.ts', 'packages/agent-bundle/tests/dev-workbench.test.ts', + 'packages/agent-bundle/tests/emitted-artifact-effect-surface.test.ts', 'packages/agent-bundle/tests/eval-claude-harness.test.ts', 'packages/agent-bundle/tests/eval-cli.test.ts', 'packages/agent-bundle/tests/eval-fixtures.test.ts', From 0facd40c0b7de4bf0d6b9f64b4695d3b8091457b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 07:12:59 +0000 Subject: [PATCH 2/5] chore: reference #543 in the changeset --- .changeset/data-error-yieldable-framework-errors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/data-error-yieldable-framework-errors.md b/.changeset/data-error-yieldable-framework-errors.md index f1772a70d..8952248e9 100644 --- a/.changeset/data-error-yieldable-framework-errors.md +++ b/.changeset/data-error-yieldable-framework-errors.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Make the dev seam's and eval service's internal framework error classes (`McpSessionError`, `EpochStoreError`, `DevCoordinatorCloseError`, `ProjectEventHubError`, and the rest of the dev-seam close / request errors) yieldable inside `Effect.gen` through the new `Data.Error`-based bases in `src/effect/errors.ts`. Messages, codes, `instanceof`, JSON output, and stack traces are unchanged; `CodedError`, `DiagnosticError`, every class exported from a package entry, and the emitted hook, MCP, and CLI artifacts keep their plain `Error` bases. (#PR) +Make the dev seam's and eval service's internal framework error classes (`McpSessionError`, `EpochStoreError`, `DevCoordinatorCloseError`, `ProjectEventHubError`, and the rest of the dev-seam close / request errors) yieldable inside `Effect.gen` through the new `Data.Error`-based bases in `src/effect/errors.ts`. Messages, codes, `instanceof`, JSON output, and stack traces are unchanged; `CodedError`, `DiagnosticError`, every class exported from a package entry, and the emitted hook, MCP, and CLI artifacts keep their plain `Error` bases. (#543) From 43ec5afc3fab81040f6a2caf5521556d9c8a5997 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 07:30:28 +0000 Subject: [PATCH 3/5] test(effect): use a real McpSessionErrorCode in the yieldability proof --- agent-patterns/effect-errors.md | 2 +- packages/agent-bundle/tests/effect-errors.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/agent-patterns/effect-errors.md b/agent-patterns/effect-errors.md index f246aa189..6cf938012 100644 --- a/agent-patterns/effect-errors.md +++ b/agent-patterns/effect-errors.md @@ -26,7 +26,7 @@ onto those classes at the boundary — it does not replace them. - Typed fail, framework-process class (dev seam / eval service, extends `YieldableFrameworkError` or `YieldableCodedError` from `packages/agent-bundle/src/effect/errors.ts`): - `return yield* new McpSessionError('MCP_SESSION_CLOSED', message)`. + `return yield* new McpSessionError('session-closed', message)`. `Effect.fail(new McpSessionError(...))` is equally valid; do not churn call sites for style. - Defect (bug): `Effect.die(defect)` — not for expected fail-closed states. diff --git a/packages/agent-bundle/tests/effect-errors.test.ts b/packages/agent-bundle/tests/effect-errors.test.ts index 3cca6bc4f..3ef91060b 100644 --- a/packages/agent-bundle/tests/effect-errors.test.ts +++ b/packages/agent-bundle/tests/effect-errors.test.ts @@ -32,7 +32,7 @@ class YieldableCoded extends YieldableCodedError<'AB0001'> { describe('yieldable framework error bases (src/effect/errors.ts)', () => { it('fails an Effect.gen program by yield* with the same instance Effect.fail would carry', async () => { - const error = new McpSessionError('MCP_SESSION_CLOSED', 'Session "s1" is closed.'); + const error = new McpSessionError('session-closed', 'Session "s1" is closed.'); const program = Effect.gen(function* () { return yield* error; }); From 7bf524559c0e48e12ceebfb0b87d6d5f09267860 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 08:09:19 +0000 Subject: [PATCH 4/5] refactor(errors): keep public declaration graphs free of effect; pin per export A consumer's tsc follows every .d.ts a package export reaches, so a class extending the yieldable base in a reachable declaration made effect a type dependency (public-api root-declaration consumer failed on Node 24). Move the 31 dev-seam classes on the root / api / eval declaration graphs back to plain Error / CodedError; keep the 14 classes no export reaches on YieldableFrameworkError / YieldableCodedError. Add a public-api test that walks each export's emitted declaration graph and fails on any effect import, convert the coordinator's close failure to yield*, and record the declaration-graph carve-out in docs/effect-conventions.md, agent-patterns/effect-errors.md, and the changeset. --- .../data-error-yieldable-framework-errors.md | 2 +- agent-patterns/effect-errors.md | 33 ++++++----- docs/effect-conventions.md | 52 +++++++++++++---- packages/agent-bundle/src/dev/agent-api.ts | 5 +- packages/agent-bundle/src/dev/coordinator.ts | 2 +- packages/agent-bundle/src/dev/epoch-store.ts | 9 ++- .../agent-bundle/src/dev/eval/eval-service.ts | 3 +- packages/agent-bundle/src/dev/events.ts | 4 +- .../agent-bundle/src/dev/foreground-server.ts | 7 +-- .../agent-bundle/src/dev/host-mcp-routes.ts | 3 +- .../src/dev/inspector-launcher.ts | 4 +- .../src/dev/logs/dev-log-service.ts | 3 +- .../dev/mcp-app-runtime-preview-service.ts | 3 +- .../src/dev/mcp-session/mcp-session-types.ts | 10 ++-- .../dev/playground/hook-playground-routes.ts | 4 +- .../src/dev/playground/mcp-probe-service.ts | 7 +-- .../src/dev/playground/playground-store.ts | 7 +-- .../agent-bundle/src/dev/runtime-provider.ts | 5 +- .../src/dev/skill-document-service.ts | 5 +- .../agent-bundle/src/dev/workbench-server.ts | 7 +-- packages/agent-bundle/src/effect/errors.ts | 9 ++- packages/agent-bundle/src/eval/run-store.ts | 5 +- .../agent-bundle/tests/effect-errors.test.ts | 10 ++-- .../agent-bundle/tests/public-api.test.ts | 57 ++++++++++++++++++- 24 files changed, 169 insertions(+), 87 deletions(-) diff --git a/.changeset/data-error-yieldable-framework-errors.md b/.changeset/data-error-yieldable-framework-errors.md index 8952248e9..4d28b1258 100644 --- a/.changeset/data-error-yieldable-framework-errors.md +++ b/.changeset/data-error-yieldable-framework-errors.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Make the dev seam's and eval service's internal framework error classes (`McpSessionError`, `EpochStoreError`, `DevCoordinatorCloseError`, `ProjectEventHubError`, and the rest of the dev-seam close / request errors) yieldable inside `Effect.gen` through the new `Data.Error`-based bases in `src/effect/errors.ts`. Messages, codes, `instanceof`, JSON output, and stack traces are unchanged; `CodedError`, `DiagnosticError`, every class exported from a package entry, and the emitted hook, MCP, and CLI artifacts keep their plain `Error` bases. (#543) +Raise the dev seam's and eval service's internal framework errors that no public declaration reaches (`DevCoordinatorCloseError`, `RuntimeMcpRegistryError`, `RuntimeGenerationStoreError`, `DevRuntimeProviderLoadError`, `ScriptPlaygroundFailure`, `LifecycleReplayRequestError`, `ArtifactInspectionServiceError`, `HookSimulationAbortError`, `CodexEvalHarnessError`, `SmokeStepError`, and their close / abort siblings) through the new `Data.Error`-based bases in `src/effect/errors.ts`, so `agent-bundle dev` and `agent-bundle eval` programs can `yield*` them inside `Effect.gen`. Messages, codes, `instanceof`, JSON output, and stack traces are unchanged; `CodedError`, `DiagnosticError`, every class on a public entry's declaration graph, and the emitted hook, MCP, and CLI artifacts keep their plain `Error` bases, and no `effect` type reaches a public `.d.ts`. (#543) diff --git a/agent-patterns/effect-errors.md b/agent-patterns/effect-errors.md index 6cf938012..a0f318996 100644 --- a/agent-patterns/effect-errors.md +++ b/agent-patterns/effect-errors.md @@ -26,9 +26,9 @@ onto those classes at the boundary — it does not replace them. - Typed fail, framework-process class (dev seam / eval service, extends `YieldableFrameworkError` or `YieldableCodedError` from `packages/agent-bundle/src/effect/errors.ts`): - `return yield* new McpSessionError('session-closed', message)`. - `Effect.fail(new McpSessionError(...))` is equally valid; do not churn - call sites for style. + `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. None of our classes are tagged (`Data.Error`, not `Data.TaggedError` or @@ -46,9 +46,9 @@ explicitly lifts the Schema deferral. ```ts import { YieldableCodedError, YieldableFrameworkError } from '../effect/errors.ts'; -export class McpSessionError extends YieldableCodedError { - constructor(code: McpSessionErrorCode, message: string) { - super('McpSessionError', code, message); +export class ScriptPlaygroundFailure extends YieldableCodedError { + constructor(code: ScriptPlaygroundFailureCode, message: string) { + super('ScriptPlaygroundFailure', code, message); } } @@ -70,14 +70,19 @@ the plain-`Error` observable shape — `JSON.stringify`, `stableJson`, 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 is exported from a -package entry (Effect must not reach user-facing `.d.ts`), 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/emitted-artifact-effect-surface.test.ts` and `tests/cli.test.ts` -fail if one is crossed. +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 diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index 2e1322ca5..3d9ab927a 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -65,7 +65,14 @@ 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 — extend the yieldable bases 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` (the @@ -100,14 +107,35 @@ moves into one of these positions moves back: 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). -- **Public classes.** Anything exported from a package 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`): a - `Data.Error` base would put `Cause.YieldableError` in the user-facing - `.d.ts`, and Effect never appears in user-facing types. The `@agent-bundle/runtime` +- **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 @@ -703,8 +731,12 @@ resolved the current repo practice stands, and new code follows it. (`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 exported from a package entry (the `Agent*` - classes included: `Cause.YieldableError` would enter user-facing `.d.ts`), + 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 diff --git a/packages/agent-bundle/src/dev/agent-api.ts b/packages/agent-bundle/src/dev/agent-api.ts index 29eec752c..1d4907a83 100644 --- a/packages/agent-bundle/src/dev/agent-api.ts +++ b/packages/agent-bundle/src/dev/agent-api.ts @@ -22,7 +22,6 @@ import type { EvalService } from './eval/eval-service.ts'; import { runtimeAppFiniteOrdinaryJsonByteLength } from './runtime-app-message-limits.ts'; import type { ProjectStatus } from './types.ts'; import { deepFreeze } from '../core/freeze.ts'; -import { YieldableFrameworkError } from '../effect/errors.ts'; export const agentApiToolNames = Object.freeze([ @@ -106,7 +105,7 @@ export interface AgentApiOptions { export type AgentApiCloseFailure = Readonly<{ readonly error: unknown; readonly resource: 'eval' | 'handler' }>; -export class AgentApiCloseError extends YieldableFrameworkError { +export class AgentApiCloseError extends Error { readonly failures: readonly AgentApiCloseFailure[]; constructor(failures: readonly AgentApiCloseFailure[]) { @@ -233,7 +232,7 @@ const maximumAgentApiJsonDepth = 32; const maximumAgentApiJsonNodes = 4_096; const maximumAgentApiToolResultBytes = 1_024 * 1_024; -class AgentApiRequestError extends YieldableFrameworkError { +class AgentApiRequestError extends Error { readonly code: 'AGENT_API_REQUEST_INVALID' | 'AGENT_API_REQUEST_TOO_LARGE'; readonly status: 400 | 413; diff --git a/packages/agent-bundle/src/dev/coordinator.ts b/packages/agent-bundle/src/dev/coordinator.ts index ab5996d2d..6c3d0f123 100644 --- a/packages/agent-bundle/src/dev/coordinator.ts +++ b/packages/agent-bundle/src/dev/coordinator.ts @@ -619,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); })); } } diff --git a/packages/agent-bundle/src/dev/epoch-store.ts b/packages/agent-bundle/src/dev/epoch-store.ts index 377dd62cd..29b811541 100644 --- a/packages/agent-bundle/src/dev/epoch-store.ts +++ b/packages/agent-bundle/src/dev/epoch-store.ts @@ -12,7 +12,6 @@ import { runPromise, runSync } from '../effect/boundary.ts'; import { liftPromise, liftTry } from '../effect/lift.ts'; import { freezeArtifactEpoch, type ArtifactEpoch } from './types.ts'; import { deepFreeze } from '../core/freeze.ts'; -import { YieldableFrameworkError } from '../effect/errors.ts'; export interface EpochStoreOptions { @@ -70,7 +69,7 @@ export interface EpochCleanupFailure { readonly resource: EpochCleanupResource; } -export class EpochCleanupError extends YieldableFrameworkError { +export class EpochCleanupError extends Error { readonly failures: readonly EpochCleanupFailure[]; constructor(failures: readonly EpochCleanupFailure[]) { @@ -81,7 +80,7 @@ export class EpochCleanupError extends YieldableFrameworkError { } } -export class EpochPostCommitCleanupError extends YieldableFrameworkError { +export class EpochPostCommitCleanupError extends Error { readonly committedEpoch: ArtifactEpoch; constructor(committedEpoch: ArtifactEpoch, cleanupError: unknown) { @@ -92,7 +91,7 @@ export class EpochPostCommitCleanupError extends YieldableFrameworkError { } } -export class EpochPostCommitDurabilityError extends YieldableFrameworkError { +export class EpochPostCommitDurabilityError extends Error { readonly committedEpoch: ArtifactEpoch; constructor(committedEpoch: ArtifactEpoch, durabilityError: unknown) { @@ -105,7 +104,7 @@ export class EpochPostCommitDurabilityError extends YieldableFrameworkError { } } -export class EpochStoreError extends YieldableFrameworkError { +export class EpochStoreError extends Error { readonly code: EpochStoreErrorCode; constructor(code: EpochStoreErrorCode, message: string) { diff --git a/packages/agent-bundle/src/dev/eval/eval-service.ts b/packages/agent-bundle/src/dev/eval/eval-service.ts index d18b205a4..01d45c913 100644 --- a/packages/agent-bundle/src/dev/eval/eval-service.ts +++ b/packages/agent-bundle/src/dev/eval/eval-service.ts @@ -37,7 +37,6 @@ import type { DevLogKindFor, DevLogSink } from '../logs/dev-log-service.ts'; import { isInsideOrEqual, toPosixRelative } from '../../core/paths.ts'; import { isErrno } from '../../core/errors.ts'; import { deepFreeze } from '../../core/freeze.ts'; -import { YieldableFrameworkError } from '../../effect/errors.ts'; export type EvalServiceErrorCode = @@ -200,7 +199,7 @@ const serviceError = (code: EvalServiceErrorCode, message: string): EvalServiceE new EvalServiceError(code, message); /** Explicit evidence that Eval shutdown observed more distinct background failures than it retained. */ -export class EvalServiceBackgroundFailureOverflowError extends YieldableFrameworkError { +export class EvalServiceBackgroundFailureOverflowError extends Error { readonly droppedCount: number; constructor(droppedCount: number) { diff --git a/packages/agent-bundle/src/dev/events.ts b/packages/agent-bundle/src/dev/events.ts index 3710fcb08..1d4344685 100644 --- a/packages/agent-bundle/src/dev/events.ts +++ b/packages/agent-bundle/src/dev/events.ts @@ -1,3 +1,4 @@ +import { CodedError } from '../core/errors.ts'; import { freezeJsonValue, freezeProjectEvent, @@ -7,7 +8,6 @@ import { type ProjectEventType, type ProjectReplayGap, } from './types.ts'; -import { YieldableCodedError } from '../effect/errors.ts'; type EpochScopedProjectEventType = 'artifact.available' | 'dev.contract.status' | 'dev.host.sync'; @@ -55,7 +55,7 @@ export type ProjectEventHubErrorCode = | 'PROJECT_EVENT_PAYLOAD_INVALID' | 'PROJECT_EVENT_TYPE_INVALID'; -export class ProjectEventHubError extends YieldableCodedError { +export class ProjectEventHubError extends CodedError { constructor(code: ProjectEventHubErrorCode, message: string) { super('ProjectEventHubError', code, message); } diff --git a/packages/agent-bundle/src/dev/foreground-server.ts b/packages/agent-bundle/src/dev/foreground-server.ts index aa59de1af..3d48e2e76 100644 --- a/packages/agent-bundle/src/dev/foreground-server.ts +++ b/packages/agent-bundle/src/dev/foreground-server.ts @@ -39,7 +39,6 @@ import { singleHeader, type RequestDiagnostic, } from './http.ts'; -import { YieldableFrameworkError } from '../effect/errors.ts'; const instanceIdLengthLimit = 128; const loopbackHosts = new Set(['127.0.0.1', '::1']); @@ -53,7 +52,7 @@ interface QueuedSseFrame { export type ForegroundServerErrorCode = 'AB8000'; /** Configuration errors that prevent a foreground server from starting. */ -export class ForegroundServerError extends YieldableFrameworkError { +export class ForegroundServerError extends Error { readonly code: ForegroundServerErrorCode; constructor(code: ForegroundServerErrorCode, message: string) { @@ -74,7 +73,7 @@ export interface ForegroundServerStartFailure { } /** Reports all releases that failed after every foreground resource was asked to close. */ -export class ForegroundServerCloseError extends YieldableFrameworkError { +export class ForegroundServerCloseError extends Error { readonly failures: readonly ForegroundServerCloseFailure[]; constructor(failures: readonly ForegroundServerCloseFailure[]) { @@ -85,7 +84,7 @@ export class ForegroundServerCloseError extends YieldableFrameworkError { } /** Preserves a failed startup and every release failure needed to unwind it. */ -export class ForegroundServerStartError extends YieldableFrameworkError { +export class ForegroundServerStartError extends Error { readonly failures: readonly ForegroundServerStartFailure[]; constructor(failures: readonly ForegroundServerStartFailure[]) { diff --git a/packages/agent-bundle/src/dev/host-mcp-routes.ts b/packages/agent-bundle/src/dev/host-mcp-routes.ts index 3e6bd513c..964dc3090 100644 --- a/packages/agent-bundle/src/dev/host-mcp-routes.ts +++ b/packages/agent-bundle/src/dev/host-mcp-routes.ts @@ -15,14 +15,13 @@ import { type McpSession, type McpSessionService, } from './mcp-session/mcp-session-service.ts'; -import { YieldableFrameworkError } from '../effect/errors.ts'; const hostMcpPathPrefix = '/mcp/host/'; const internalErrorCode = -32_603; export const hostMcpEpochDriftCode = 'AB8024'; -export class HostMcpEpochDriftError extends YieldableFrameworkError { +export class HostMcpEpochDriftError extends Error { readonly code = hostMcpEpochDriftCode; readonly epochId: string; diff --git a/packages/agent-bundle/src/dev/inspector-launcher.ts b/packages/agent-bundle/src/dev/inspector-launcher.ts index 39d7cf107..fff166e47 100644 --- a/packages/agent-bundle/src/dev/inspector-launcher.ts +++ b/packages/agent-bundle/src/dev/inspector-launcher.ts @@ -2,8 +2,8 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { resolve } from 'node:path'; import { sleep as delay } from '../core/async.ts'; +import { CodedError } from '../core/errors.ts'; import { taskkill, terminateProcessTree } from '../services/process-tree.ts'; -import { YieldableCodedError } from '../effect/errors.ts'; const inspectorPackage = '@modelcontextprotocol/inspector'; const startupBudgetMs = 30_000; @@ -54,7 +54,7 @@ export interface InspectorLauncher { } /** Coded refusals a caller can act on without reading inspector internals. */ -export class InspectorLauncherError extends YieldableCodedError { +export class InspectorLauncherError extends CodedError { constructor(code: InspectorLauncherErrorCode, message: string) { super('InspectorLauncherError', code, message); } diff --git a/packages/agent-bundle/src/dev/logs/dev-log-service.ts b/packages/agent-bundle/src/dev/logs/dev-log-service.ts index 2e0aad755..f7137d6db 100644 --- a/packages/agent-bundle/src/dev/logs/dev-log-service.ts +++ b/packages/agent-bundle/src/dev/logs/dev-log-service.ts @@ -13,7 +13,6 @@ import { type DevLogLevel, type DevLogProducer, } from './dev-log-kinds.ts'; -import { YieldableFrameworkError } from '../../effect/errors.ts'; export { devLogKinds, devLogLevels, devLogProducers } from './dev-log-kinds.ts'; export type { DevLogKindFor, DevLogKindMap, DevLogLevel, DevLogProducer } from './dev-log-kinds.ts'; @@ -89,7 +88,7 @@ export interface DevLogServiceOptions { export type DevLogServiceErrorCode = 'DEV_LOG_CURSOR_AHEAD' | 'DEV_LOG_CURSOR_INVALID' | 'DEV_LOG_SERVICE_CLOSED'; -export class DevLogServiceError extends YieldableFrameworkError { +export class DevLogServiceError extends Error { readonly code: DevLogServiceErrorCode; constructor(code: DevLogServiceErrorCode, message: string) { diff --git a/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts b/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts index db1fac087..3b382841c 100644 --- a/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts +++ b/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts @@ -39,7 +39,6 @@ import { import type { RuntimeClientSurfaceContentPolicy } from './runtime-client-surface-proxy.ts'; import type { DevRuntimeClientSurfaceProxyBinding, DevRuntimeMcpRegistryMessage, DevRuntimeMcpSessionView, DevRuntimeSession } from './runtime-provider.ts'; import type { DevRuntimeMcpAppRunBinding, DevRuntimeMcpConnectionState, RuntimeVector } from './runtime-protocol.ts'; -import { YieldableFrameworkError } from '../effect/errors.ts'; export type McpAppBindingOperation = | Readonly<{ readonly kind: 'tools/list' }> @@ -130,7 +129,7 @@ export interface McpAppRuntimeOperationOptions { } /** Closed, phase-safe diagnostics intended for the authenticated runtime App route. */ -export class McpAppRuntimePreviewError extends YieldableFrameworkError { +export class McpAppRuntimePreviewError extends Error { readonly code: 'AB8023' | 'AB8201' | 'AB8203' | 'AB8204'; readonly status: 400 | 404 | 409 | 502; diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts index c1cbe03bc..ee1cdab9c 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-types.ts @@ -19,8 +19,8 @@ import type { McpSessionReplayOverflow, } from './mcp-session-protocol.ts'; import type { McpSessionTraceSink } from './mcp-session-trace.ts'; +import { CodedError } from '../../core/errors.ts'; import { deepFreeze } from '../../core/freeze.ts'; -import { YieldableCodedError, YieldableFrameworkError } from '../../effect/errors.ts'; export interface McpRequestOptions { @@ -159,11 +159,11 @@ export type McpSessionErrorCode = * Expected session-lifecycle failures on the Effect error channel: the * session or its service is closed, a protocol call ran before `initialize`, * or a request was admitted with an invalid or already-active `requestId`. - * These ride the fail channel as a coded error (never `Effect.die`) and + * These ride the fail channel as a `CodedError` (never `Effect.die`) and * rethrow unchanged at `src/effect/boundary.ts`, so Promise callers keep the * exact messages they saw before the class existed. */ -export class McpSessionError extends YieldableCodedError { +export class McpSessionError extends CodedError { constructor(code: McpSessionErrorCode, message: string) { super('McpSessionError', code, message); } @@ -202,7 +202,7 @@ export class McpSessionError extends YieldableCodedError { * retention, which cannot observe this process's epoch leases). Tool calls * fail closed with this error instead of hanging against a vanished artifact. */ -export class McpSessionStaleEpochError extends YieldableFrameworkError { +export class McpSessionStaleEpochError extends Error { readonly epochId: string; constructor(epochId: string, options?: Readonly<{ readonly cause?: unknown }>) { @@ -216,7 +216,7 @@ export class McpSessionStaleEpochError extends YieldableFrameworkError { } /** Reports every session-service lifecycle failure after all tracked work settles. */ -export class McpSessionServiceCloseError extends YieldableFrameworkError { +export class McpSessionServiceCloseError extends Error { readonly failures: readonly McpSessionServiceCloseFailure[]; constructor(failures: readonly McpSessionServiceCloseFailure[]) { diff --git a/packages/agent-bundle/src/dev/playground/hook-playground-routes.ts b/packages/agent-bundle/src/dev/playground/hook-playground-routes.ts index 6d5b4ac80..317af170b 100644 --- a/packages/agent-bundle/src/dev/playground/hook-playground-routes.ts +++ b/packages/agent-bundle/src/dev/playground/hook-playground-routes.ts @@ -1,5 +1,6 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; +import { CodedError } from '../../core/errors.ts'; import { isRecord } from '../../core/strict-json.ts'; import { isHookSimulationCancellation } from '../../services/hook-service.ts'; import { @@ -23,7 +24,6 @@ import type { HookPlaygroundSimulation, HookPlaygroundSimulationOptions, } from './hook-playground-service.ts'; -import { YieldableCodedError } from '../../effect/errors.ts'; type Route = Readonly<{ readonly kind: 'hooks' | 'simulations' | 'replays' }>; @@ -37,7 +37,7 @@ export interface HookPlaygroundCloseFailure { } /** Reports every in-flight operation that failed to settle once shutdown cancelled it. */ -export class HookPlaygroundCloseError extends YieldableCodedError<'AB8034'> { +export class HookPlaygroundCloseError extends CodedError<'AB8034'> { readonly failures: readonly HookPlaygroundCloseFailure[]; constructor(failures: readonly HookPlaygroundCloseFailure[]) { diff --git a/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts b/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts index ae48d15e4..437b11cd2 100644 --- a/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts +++ b/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts @@ -36,7 +36,6 @@ import { } from '../mcp-session/mcp-session-launch.ts'; import type { McpSessionInspectorConfig } from '../mcp-session/mcp-session-protocol.ts'; import type { RemoteTransportOptions, StdioOptions } from '../mcp-session/mcp-session-types.ts'; -import { YieldableFrameworkError } from '../../effect/errors.ts'; export const mcpProbeTimeoutMs = 10_000; export const mcpProbeToolLimit = 200; @@ -132,14 +131,14 @@ export interface McpProbeServiceOptions { readonly timers?: McpProbeTimers; } -export class McpProbeTargetNotFoundError extends YieldableFrameworkError { +export class McpProbeTargetNotFoundError extends Error { constructor(message: string) { super(message); this.name = 'McpProbeTargetNotFoundError'; } } -class McpProbeTimeoutError extends YieldableFrameworkError { +class McpProbeTimeoutError extends Error { readonly kind: McpProbeFailureKind; constructor(kind: McpProbeFailureKind) { @@ -149,7 +148,7 @@ class McpProbeTimeoutError extends YieldableFrameworkError { } } -class McpProbeProtocolError extends YieldableFrameworkError { +class McpProbeProtocolError extends Error { readonly kind: McpProbeFailureKind; constructor(kind: McpProbeFailureKind, message: string, options?: ErrorOptions) { diff --git a/packages/agent-bundle/src/dev/playground/playground-store.ts b/packages/agent-bundle/src/dev/playground/playground-store.ts index bc32ed1dc..5883663be 100644 --- a/packages/agent-bundle/src/dev/playground/playground-store.ts +++ b/packages/agent-bundle/src/dev/playground/playground-store.ts @@ -10,7 +10,6 @@ import { isInsideOrEqual } from '../../core/paths.ts'; import { hasExactOwnKeys, isRecord, parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts'; import type { DevLogSink } from '../logs/dev-log-service.ts'; import { deepFreeze } from '../../core/freeze.ts'; -import { YieldableFrameworkError } from '../../effect/errors.ts'; export type PlaygroundJsonPrimitive = boolean | null | number | string; @@ -171,7 +170,7 @@ export type PlaygroundServiceErrorCode = | 'PLAYGROUND_STORE_CORRUPT' | 'PLAYGROUND_VALUE_INVALID'; -export class PlaygroundServiceError extends YieldableFrameworkError { +export class PlaygroundServiceError extends Error { readonly code: PlaygroundServiceErrorCode; constructor(code: PlaygroundServiceErrorCode, message: string) { @@ -181,7 +180,7 @@ export class PlaygroundServiceError extends YieldableFrameworkError { } } -export class PlaygroundSessionCloseError extends YieldableFrameworkError { +export class PlaygroundSessionCloseError extends Error { readonly failures: readonly PlaygroundCleanupFailure[]; readonly sessionId: string; @@ -198,7 +197,7 @@ export interface PlaygroundServiceCloseFailure { readonly sessionId: string; } -export class PlaygroundServiceCloseError extends YieldableFrameworkError { +export class PlaygroundServiceCloseError extends Error { readonly failures: readonly PlaygroundServiceCloseFailure[]; constructor(failures: readonly PlaygroundServiceCloseFailure[]) { diff --git a/packages/agent-bundle/src/dev/runtime-provider.ts b/packages/agent-bundle/src/dev/runtime-provider.ts index ba859c7cf..c082204ce 100644 --- a/packages/agent-bundle/src/dev/runtime-provider.ts +++ b/packages/agent-bundle/src/dev/runtime-provider.ts @@ -20,7 +20,6 @@ import type { DevRuntimeStatus, DevRuntimeSurface, } from './runtime-protocol.ts'; -import { YieldableFrameworkError } from '../effect/errors.ts'; /** Trusted-process-only compiler endpoint; never serialize it into runtime JSON. */ export interface DevRuntimeClientSurfaceEndpoint { @@ -183,7 +182,7 @@ export interface DevRuntimeProvider { export type CreateDevRuntimeProvider = () => DevRuntimeProvider | Promise; -export class DevRuntimeUnavailableError extends YieldableFrameworkError { +export class DevRuntimeUnavailableError extends Error { readonly code = 'AB8201' as const; constructor(message = 'Development runtime is not available.') { @@ -192,7 +191,7 @@ export class DevRuntimeUnavailableError extends YieldableFrameworkError { } } -export class DevRuntimeGenerationConflictError extends YieldableFrameworkError { +export class DevRuntimeGenerationConflictError extends Error { readonly actualGenerationId?: string; readonly code = 'AB8204' as const; readonly expectedGenerationId: string; diff --git a/packages/agent-bundle/src/dev/skill-document-service.ts b/packages/agent-bundle/src/dev/skill-document-service.ts index 3db9e93c1..e6376ffcd 100644 --- a/packages/agent-bundle/src/dev/skill-document-service.ts +++ b/packages/agent-bundle/src/dev/skill-document-service.ts @@ -5,13 +5,12 @@ import { projectMeta } from '../build/meta.ts'; import { parseSkill, type SkillDocument, type SkillResource } from '../config/skill.ts'; import { freezeDiagnostics } from '../core/diagnostics.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; -import { isErrno } from '../core/errors.ts'; +import { CodedError, isErrno } from '../core/errors.ts'; import type { NormalizedPlugin, NormalizedSkill, SourceProvenance } from '../core/types.ts'; import { EpochStore } from './epoch-store.ts'; import { ProjectService } from './project-service.ts'; import { isInsideOrEqual } from '../core/paths.ts'; import { deepFreeze } from '../core/freeze.ts'; -import { YieldableCodedError } from '../effect/errors.ts'; export type SkillDocumentErrorCode = @@ -21,7 +20,7 @@ export type SkillDocumentErrorCode = | 'SKILL_TARGET_UNAVAILABLE'; /** Stable, route-safe failures from the explicit source/epoch Skill bases. */ -export class SkillDocumentError extends YieldableCodedError { +export class SkillDocumentError extends CodedError { constructor(code: SkillDocumentErrorCode, message: string) { super('SkillDocumentError', code, message); } diff --git a/packages/agent-bundle/src/dev/workbench-server.ts b/packages/agent-bundle/src/dev/workbench-server.ts index f28a8815e..7e2c5ca48 100644 --- a/packages/agent-bundle/src/dev/workbench-server.ts +++ b/packages/agent-bundle/src/dev/workbench-server.ts @@ -69,7 +69,6 @@ import { SkillDocumentService } from './skill-document-service.ts'; import { createWorkbenchAssetSource } from './workbench-assets.ts'; import type { Invalidation, ProjectStatus } from './types.ts'; import { deepFreeze } from '../core/freeze.ts'; -import { YieldableFrameworkError } from '../effect/errors.ts'; export interface DevServerSession { @@ -91,7 +90,7 @@ export interface DevServerLifecycleCloseFailure { } /** Reports session and coordinator cleanup failures without hiding either resource. */ -export class DevServerLifecycleCloseError extends YieldableFrameworkError { +export class DevServerLifecycleCloseError extends Error { readonly failures: readonly DevServerLifecycleCloseFailure[]; constructor(failures: readonly DevServerLifecycleCloseFailure[]) { @@ -156,7 +155,7 @@ export interface DevServerStartFailure { } /** Preserves a failed post-listener startup and every release failure needed to unwind it. */ -export class DevServerStartError extends YieldableFrameworkError { +export class DevServerStartError extends Error { readonly failures: readonly DevServerStartFailure[]; constructor(failures: readonly DevServerStartFailure[]) { @@ -171,7 +170,7 @@ interface McpAppLifecycleCloseFailure { readonly resource: 'previews' | 'runtime-previews' | 'sandbox'; } -class McpAppLifecycleCloseError extends YieldableFrameworkError { +class McpAppLifecycleCloseError extends Error { readonly failures: readonly McpAppLifecycleCloseFailure[]; constructor(failures: readonly McpAppLifecycleCloseFailure[]) { diff --git a/packages/agent-bundle/src/effect/errors.ts b/packages/agent-bundle/src/effect/errors.ts index ef9e291fd..dcbd917c8 100644 --- a/packages/agent-bundle/src/effect/errors.ts +++ b/packages/agent-bundle/src/effect/errors.ts @@ -32,9 +32,12 @@ import { Data } from 'effect'; * `agent-bundle/rstest`, `agent-bundle/test/browser`, the CLI's `--help` / * `--version` path, the host MCP proxy, emitted hook / MCP / bin runtime) * keeps `CodedError` / `Error`; `tests/cli.test.ts` and - * `tests/emitted-artifact-effect-surface.test.ts` pin that. Public classes - * (exported from a package entry) also stay plain so Effect types never - * appear in user-facing declarations. Carve-out list: + * `tests/emitted-artifact-effect-surface.test.ts` pin that. Any class whose + * declaration file a `package.json` export's `types` reaches (exported or + * merely imported by the public `.d.ts` graph, e.g. `McpSessionError`) also + * stays plain: a consumer's `tsc` would otherwise need `effect` for + * `Cause.YieldableError`; `tests/public-api.test.ts` walks every export's + * declaration graph and pins it. Carve-out list: * `docs/effect-conventions.md` § Yieldable framework errors. */ diff --git a/packages/agent-bundle/src/eval/run-store.ts b/packages/agent-bundle/src/eval/run-store.ts index 4b21e331e..89ba2495d 100644 --- a/packages/agent-bundle/src/eval/run-store.ts +++ b/packages/agent-bundle/src/eval/run-store.ts @@ -18,7 +18,6 @@ import type { EvalPluginFailure, EvalTrialEvidence, } from './types.ts'; -import { YieldableFrameworkError } from '../effect/errors.ts'; export interface EvalArtifactBinding { readonly manifestPath: string; @@ -132,7 +131,7 @@ export interface EvalRunEvent { } /** The full JSONL event line exists, but fsync or descriptor close could not confirm its durability. */ -export class EvalRunEventDurabilityError extends YieldableFrameworkError { +export class EvalRunEventDurabilityError extends Error { readonly event: EvalRunEvent; readonly failures: readonly unknown[]; @@ -145,7 +144,7 @@ export class EvalRunEventDurabilityError extends YieldableFrameworkError { } /** A failed append may have left bytes that could not be durably rolled back to the prior journal boundary. */ -export class EvalRunEventWriteUncertainError extends YieldableFrameworkError { +export class EvalRunEventWriteUncertainError extends Error { readonly event: EvalRunEvent; readonly failures: readonly unknown[]; diff --git a/packages/agent-bundle/tests/effect-errors.test.ts b/packages/agent-bundle/tests/effect-errors.test.ts index 3ef91060b..93c50584c 100644 --- a/packages/agent-bundle/tests/effect-errors.test.ts +++ b/packages/agent-bundle/tests/effect-errors.test.ts @@ -6,8 +6,8 @@ import { describe, expect, it } from '@rstest/core'; import { stableJson } from '../src/core/digest.ts'; import { CodedError } from '../src/core/errors.ts'; import { DevCoordinatorCloseError } from '../src/dev/coordinator.ts'; -import { EpochStoreError } from '../src/dev/epoch-store.ts'; -import { McpSessionError } from '../src/dev/mcp-session/mcp-session-types.ts'; +import { RuntimeMcpRegistryError } from '../src/dev/runtime-mcp-registry.ts'; +import { ScriptPlaygroundFailure } from '../src/dev/playground/script-playground-service.ts'; import { isTypedDevError, runPromise, runPromiseExit } from '../src/effect/boundary.ts'; import { YieldableCodedError, YieldableFrameworkError } from '../src/effect/errors.ts'; @@ -32,7 +32,7 @@ class YieldableCoded extends YieldableCodedError<'AB0001'> { describe('yieldable framework error bases (src/effect/errors.ts)', () => { it('fails an Effect.gen program by yield* with the same instance Effect.fail would carry', async () => { - const error = new McpSessionError('session-closed', 'Session "s1" is closed.'); + const error = new ScriptPlaygroundFailure('spawn-failed', 'Script failed.', { stderr: '', stdout: '' }); const program = Effect.gen(function* () { return yield* error; }); @@ -51,8 +51,8 @@ describe('yieldable framework error bases (src/effect/errors.ts)', () => { }); it('types the yielded error into the fail channel', () => { - const program: Effect.Effect = Effect.gen(function* () { - return yield* new EpochStoreError('EPOCH_NOT_FOUND', 'No active artifact epoch is available.'); + const program: Effect.Effect = Effect.gen(function* () { + return yield* new RuntimeMcpRegistryError('RUNTIME_MCP_REGISTRY_CLOSED', 'Runtime MCP registry is closed.'); }); expect(program).toBeDefined(); }); diff --git a/packages/agent-bundle/tests/public-api.test.ts b/packages/agent-bundle/tests/public-api.test.ts index 25bd4370a..1f48c4833 100644 --- a/packages/agent-bundle/tests/public-api.test.ts +++ b/packages/agent-bundle/tests/public-api.test.ts @@ -3,7 +3,7 @@ import { execFile as executeFile } from 'node:child_process'; import { access, mkdtemp, mkdir, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join, relative } from 'node:path'; import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; @@ -169,6 +169,61 @@ it('publishes directly executable built entrypoints with declarations', async () expect(stdout).toBe(`${manifest.version}\n`); }, 15_000); +/** + * Declaration files each public export reaches, by breadth-first walk over + * the relative `import`/`export ... from` specifiers of the emitted `.d.ts` + * graph (per-file declarations, `dts.bundle: false`). + */ +const reachableDeclarations = async (entry: string): Promise> => { + const specifierPattern = /(?:from\s+|import\s*\(\s*)["']([^"']+)["']/gu; + const resolveRelative = async (from: string, specifier: string): Promise => { + if (!specifier.startsWith('.')) return undefined; + const base = join(dirname(from), specifier).replace(/\.ts$/u, ''); + for (const candidate of [`${base}.d.ts`, `${base}/index.d.ts`]) { + try { + await access(candidate); + return candidate; + } catch { + // try the next candidate + } + } + return undefined; + }; + const seen = new Map(); + const queue = [entry]; + while (queue.length > 0) { + const file = queue.shift()!; + if (seen.has(file)) continue; + const source = await readFile(file, 'utf8'); + seen.set(file, source); + for (const match of source.matchAll(specifierPattern)) { + const next = await resolveRelative(file, match[1]!); + if (next !== undefined && !seen.has(next)) queue.push(next); + } + } + return seen; +}; + +it('keeps every public declaration graph free of effect', async () => { + // Effect is an implementation detail (docs/effect-conventions.md § Boundary + // modules): a consumer's `tsc` resolves every declaration the exports + // reach, so one `import ... from 'effect'` in a reachable `.d.ts` (for + // example a class extending `src/effect/errors.ts`) makes `effect` a type + // dependency of the package. Public entry classes therefore stay on plain + // `Error` / `CodedError`, and this pins it for each export. + await buildPackage(); + const manifest = await readPackageManifest(); + const effectImport = /from\s+["']effect(?:\/|["'])/u; + const offenders: string[] = []; + for (const [name, entrypoint] of Object.entries(manifest.exports)) { + const reachable = await reachableDeclarations(join(packageRoot, entrypoint.types)); + for (const [file, source] of reachable) { + if (effectImport.test(source)) offenders.push(`${name} -> ${relative(packageRoot, file)}`); + } + } + expect(offenders).toEqual([]); +}, 30_000); + it('writes the package version as the producer of a built CLI manifest', async () => { await buildPackage(); From d84e4755259b48850a3a0a6d94e31512b97bf097 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 08:09:53 +0000 Subject: [PATCH 5/5] chore(changeset): describe observable package behavior --- .changeset/data-error-yieldable-framework-errors.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/data-error-yieldable-framework-errors.md b/.changeset/data-error-yieldable-framework-errors.md index 4d28b1258..8a892c62c 100644 --- a/.changeset/data-error-yieldable-framework-errors.md +++ b/.changeset/data-error-yieldable-framework-errors.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Raise the dev seam's and eval service's internal framework errors that no public declaration reaches (`DevCoordinatorCloseError`, `RuntimeMcpRegistryError`, `RuntimeGenerationStoreError`, `DevRuntimeProviderLoadError`, `ScriptPlaygroundFailure`, `LifecycleReplayRequestError`, `ArtifactInspectionServiceError`, `HookSimulationAbortError`, `CodexEvalHarnessError`, `SmokeStepError`, and their close / abort siblings) through the new `Data.Error`-based bases in `src/effect/errors.ts`, so `agent-bundle dev` and `agent-bundle eval` programs can `yield*` them inside `Effect.gen`. Messages, codes, `instanceof`, JSON output, and stack traces are unchanged; `CodedError`, `DiagnosticError`, every class on a public entry's declaration graph, and the emitted hook, MCP, and CLI artifacts keep their plain `Error` bases, and no `effect` type reaches a public `.d.ts`. (#543) +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)