diff --git a/packages/agent-bundle/src/effect/lift.ts b/packages/agent-bundle/src/effect/lift.ts
index 5fec1275d..f1a437b97 100644
--- a/packages/agent-bundle/src/effect/lift.ts
+++ b/packages/agent-bundle/src/effect/lift.ts
@@ -1,15 +1,38 @@
import { Effect } from 'effect';
/**
- * Lifts for the dev seam's existing Promise/sync helpers. Both keep the
- * thrown/rejected value untouched in the error channel — the dev seam's
+ * Lifts for the dev seam's existing Promise/sync helpers (Stage 3). Both keep
+ * the thrown/rejected value untouched in the error channel — the dev seam's
* typed contracts are plain `Error` subclasses that must cross
* `src/effect/boundary.ts` identity-preserved, and several call sites
* re-raise non-Error values (for example an `AbortSignal.reason`) verbatim.
+ *
+ * A bare `Effect.tryPromise(fn)` would wrap every rejection in
+ * `Cause.UnknownError`; these helpers are the only sanctioned way to lift a
+ * leaf helper (`docs/effect-conventions.md` § Stage 3 "Hurt / gotchas").
*/
-export const liftPromise = (evaluate: () => PromiseLike): Effect.Effect =>
- Effect.tryPromise({ catch: (error) => error, try: evaluate });
+/**
+ * The raw value a lifted helper threw or rejected with. It is `unknown` by
+ * contract — the lift is an identity, not a normalizer — so callers narrow it
+ * where they know the helper's failure contract (`instanceof`, `isErrno`,
+ * `Effect.mapError` into a typed dev error) instead of assuming a shape. The
+ * boundary maps whatever reaches it: typed dev errors and `Error`s rethrow
+ * as-is, other values are wrapped, interruption becomes `AbortError`.
+ */
+export type LiftedRejection = unknown;
+
+/**
+ * Lift a Promise-returning leaf helper. `evaluate` receives Effect's
+ * interruption `AbortSignal` (aborted when the fiber is interrupted), so a
+ * cancellable API can be passed the signal directly; thunks that ignore it
+ * keep working unchanged.
+ */
+export const liftPromise = (
+ evaluate: (signal: AbortSignal) => PromiseLike,
+): Effect.Effect =>
+ Effect.tryPromise({ catch: (error): LiftedRejection => error, try: evaluate });
-export const liftTry = (evaluate: () => A): Effect.Effect =>
- Effect.try({ catch: (error) => error, try: evaluate });
+/** Lift a synchronous leaf helper; a throw becomes a typed failure carrying the thrown value. */
+export const liftTry = (evaluate: () => A): Effect.Effect =>
+ Effect.try({ catch: (error): LiftedRejection => error, try: evaluate });
diff --git a/packages/agent-bundle/tests/effect-boundary.test.ts b/packages/agent-bundle/tests/effect-boundary.test.ts
index dcb3e26eb..40a7427fc 100644
--- a/packages/agent-bundle/tests/effect-boundary.test.ts
+++ b/packages/agent-bundle/tests/effect-boundary.test.ts
@@ -18,6 +18,7 @@ import {
runSync,
toDevError,
} from '../src/effect/boundary.ts';
+import { liftPromise, liftTry } from '../src/effect/lift.ts';
import * as rootApi from '../src/index.ts';
describe('effect boundary (agent-bundle dev seam)', () => {
@@ -90,3 +91,41 @@ describe('effect boundary (agent-bundle dev seam)', () => {
await expect(runPromise(abortToInterrupt(controller.signal))).rejects.toSatisfy(isAbortError);
});
});
+
+describe('effect lifts (src/effect/lift.ts)', () => {
+ it('keeps the rejected or thrown value identity-preserved on the fail channel', async () => {
+ const typed = new EpochStoreError('EPOCH_NOT_FOUND', 'Epoch "e1" does not exist.');
+ const reason = { code: 'ECUSTOM', message: 'not an Error' };
+ const rejected = await runPromiseExit(liftPromise(() => Promise.reject(typed)));
+ expect(Exit.isFailure(rejected) && Cause.squash(rejected.cause)).toBe(typed);
+ const rawReason = await runPromiseExit(liftPromise(() => Promise.reject(reason)));
+ expect(Exit.isFailure(rawReason) && Cause.squash(rawReason.cause)).toBe(reason);
+ const thrown = await runPromiseExit(liftTry((): never => { throw typed; }));
+ expect(Exit.isFailure(thrown) && Cause.squash(thrown.cause)).toBe(typed);
+ expect(runSync(liftTry(() => 7))).toBe(7);
+ // The Promise edge rethrows typed errors as-is and wraps non-Error values,
+ // exactly per the boundary's mapping table — the lift itself never
+ // normalizes, so a caller that must re-raise a raw reason reads the Exit.
+ await expect(runPromise(liftPromise(() => Promise.reject(typed)))).rejects.toBe(typed);
+ await expect(runPromise(liftPromise(() => Promise.reject(reason)))).rejects.toEqual(new Error(String(reason)));
+ });
+
+ it('hands the lifted helper an AbortSignal that aborts when the fiber is interrupted', async () => {
+ const host = new AbortController();
+ let observed: AbortSignal | undefined;
+ const pending = runPromise(
+ liftPromise((signal) => {
+ observed = signal;
+ return new Promise((_, reject) => {
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
+ });
+ }),
+ { signal: host.signal },
+ );
+ await Promise.resolve();
+ expect(observed?.aborted).toBe(false);
+ host.abort();
+ await expect(pending).rejects.toSatisfy(isAbortError);
+ expect(observed?.aborted).toBe(true);
+ });
+});
diff --git a/packages/rsc-runtime/src/reconciler.ts b/packages/rsc-runtime/src/reconciler.ts
index 9b9a7636b..5f6b4e61b 100644
--- a/packages/rsc-runtime/src/reconciler.ts
+++ b/packages/rsc-runtime/src/reconciler.ts
@@ -285,18 +285,23 @@ type SettledBoundary = {
readonly ok: boolean;
};
+/**
+ * Waits for the first pending boundary to settle either way. The rejection
+ * reason is data, not a failure to normalize: React rejects a boundary with a
+ * `{ message, digest }` object that `renderErrorFrom` reads, so mapping it
+ * through `toRuntimeError` would erase the digest. Both settlements are folded
+ * inside the promise, which therefore cannot reject and never puts an
+ * `unknown` on the Effect error channel.
+ */
const waitSettledBoundary = (
pending: readonly PendingBoundary[],
-): Effect.Effect =>
+): Effect.Effect =>
Effect.raceAll(
pending.map((boundary) =>
- Effect.tryPromise({
- catch: (error) => error,
- try: () => Promise.resolve(boundary.thenable),
- }).pipe(
- Effect.map(() => ({ boundary, ok: true as const })),
- Effect.catch((error) => Effect.succeed({ boundary, error, ok: false as const })),
- ),
+ Effect.promise((): Promise => Promise.resolve(boundary.thenable).then(
+ () => ({ boundary, ok: true as const }),
+ (error: unknown) => ({ boundary, error, ok: false as const }),
+ )),
),
);