Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
The diff you're trying to view is too large. We only load the first 3000 changed files.
7 changes: 7 additions & 0 deletions .changeset/effect-native-stage0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@agent-bundle/runtime": patch
---

Pin Effect 4 (`effect@4.0.0-rc.112`) for Wave 3.5 internals and add the
runtime-only `src/effect/boundary.ts` Promise edge. Public authoring stays
Promise + zod; Effect is not part of the published API.
24 changes: 24 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"files.exclude": {
"repos/**": true
},
"files.watcherExclude": {
"repos/**": true
},
"javascript.preferences.autoImportFileExcludePatterns": [
"**/repos/**"
],
"js/ts.experimental.useTsgo": true,
"js/ts.tsdk.additionalLocations": [
"./node_modules/typescript/lib"
],
"js/ts.tsdk.path": "./node_modules/typescript/lib",
"js/ts.tsdk.promptToUseWorkspaceVersion": true,
"search.exclude": {
"repos": true,
"repos/**": true
},
"typescript.preferences.autoImportFileExcludePatterns": [
"**/repos/**"
]
}
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,13 @@
- Validate examples at a 1440×900 desktop viewport; mobile support is not required.
- Never accept or capture a Workbench route while its loading state is still visible.
- Browser acceptance must cover populated state plus the documented stale-diagnostic and repair flow.

## Vendored repos

- `repos/` is **read-only reference material**. Do not edit, format, or import from `repos/**`.
- Application code imports the published npm package (`effect`), never a path under `repos/`.
- Before writing Effect code, read `repos/effect/LLMS.md` and the linked
`agent-patterns/effect-{stream,scope,concurrency,errors}.md`.
- Editor search, file watching, and auto-import exclude `repos/**`
(`.vscode/settings.json`). Subtree updates ride the same named chore as
the Effect RC re-pin — see `docs/effect-conventions.md`.
52 changes: 52 additions & 0 deletions agent-patterns/effect-concurrency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Effect concurrency patterns

Source: `repos/effect/packages/effect/src/Fiber.ts`, `Semaphore.ts`,
`Latch.ts`, `Queue.ts`, `PubSub.ts`, `Deferred.ts`, and `Effect.ts`
(`forkChild`, `forEach`, `all`, `raceFirst`). Refresh when the subtree moves.

Waves 4–6 grow the biggest concurrency surfaces (hook thin-clients under
host deadlines, MCP progress projector, notices ledger, warm-runtime
lifecycle). Build those Effect-native from day one behind Promise edges.

## Fibers

- `yield* Effect.forkChild(effect)` — structured child of the current fiber.
- `Fiber.await` / `Fiber.join` / `Fiber.interrupt` — observe or cancel.
- Prefer exported Fiber functions over `interruptUnsafe` / `pollUnsafe`.
- Forked work that owns a resource must be forked *into a scope*
(`forkScoped` / `Layer.scoped`) so interruption closes the resource.

Host `AbortSignal` still exists at the edges (`dispatch()` / `stream()`).
Do not thread extra internal signals once the program is an Effect; interrupt
the fiber.

## Bounded work

| Primitive | Use |
| --- | --- |
| `Semaphore.make(n)` + `withPermits(k)(effect)` | Cap concurrent rebuilds, hook clients, sqlite writers. |
| `Latch.make(open?)` | Gate a coalesced rebuild (Stage 3). `makeUnsafe` only when you already hold a sync context. |
| `Queue.bounded(n)` / `Queue.unbounded()` | Single-consumer work queue. Downstream pull applies backpressure. |
| `PubSub.bounded(n)` / `unbounded` | Fan-out. Stage 3 SSE hub: one publisher, many subscribers, identical wire contract (sequence numbers, replay-gap frames). |
| `Deferred.make<A, E>()` | One-shot wait (handshake, first event, shutdown). |

`Effect.forEach(items, fn, { concurrency })` beats a hand-rolled worker pool.
`Effect.all` for a fixed tuple of effects.

## Racing and cancellation

- `Effect.raceFirst(a, b)` — first to complete wins; the loser is interrupted.
- `interruptWhenAborted(effect, signal)` — host deadline into a race with
`Effect.interrupt`.
- Do not `Promise.race` around Effects. Race inside Effect, `runPromise` once.

## What to avoid

- Hidden shared `let` / `Map` mutation for in-flight work. Use `Ref`,
`Queue`, or `PubSub`.
- Starting a fiber and dropping the handle (leaks). Hold it in a `Scope` or
`Fiber` you join/interrupt.
- Using `PubSub` when consumers should compete (that's a `Queue`).
- Using `Queue` when every subscriber needs every event (that's `PubSub`).
- Coalescing rebuilds with debounce timers (`setTimeout`) — `Latch` +
fiber, not host timers (`globalTimersInEffect`).
58 changes: 58 additions & 0 deletions agent-patterns/effect-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Effect typed-error patterns

Source: `repos/effect/packages/effect/src/Cause.ts`, `Exit.ts`,
`Effect.ts` (`fail`, `catch`, `catchTag`, `die`), and
`repos/effect/LLMS.md` § Error handling. Refresh when the subtree moves.

This repo already has fail-closed typed errors. Effect's error channel maps
onto those classes at the boundary — it does not replace them.

## Existing contracts (keep)

| Class | Codes (do not widen) |
| --- | --- |
| `AgentRequestError` | `invalid-invocation`, `outside-invocation`, `request-closed`, `store-version-conflict` |
| `AgentContractError` | document / event / elapsed bounds, `handoff-required` |
| `AgentStateError` | `aborted`, `corrupt`, `idempotency-conflict`, `invalid-*`, `lifetime-mismatch`, `migration-*`, `reducer-failure`, `revision-*`, `store-closed`, `unavailable` |

`Observed<T>` unavailable reasons (`not-provided`, `unsupported-surface`,
`host-omitted`, `unauthenticated`) stay a data union, not an Effect error.

## Inside Effect

- Succeed: `Effect.succeed(value)`, `Effect.sync(() => value)`.
- Typed fail: `Effect.fail(new AgentRequestError('request-closed', message))`.
- 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`.
- Inspect after run: `Exit.isSuccess` / `isFailure`, then `Cause.squash`,
`Cause.hasInterruptsOnly`, `Cause.hasFails`, `Cause.hasDies`.

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

## Boundary mapping

`packages/rsc-runtime/src/effect/boundary.ts`:

1. `Cause.hasInterruptsOnly` → `DOMException` `AbortError` (host cancellation).
2. `AgentRequestError` / `AgentContractError` via `instanceof`.
3. `AgentStateError` via `error.name` (no `./state/contract` import on the
root graph).
4. Other `Error` rethrown; other values wrapped.

Callers of `runPromise` see the same types they see today.

## What to avoid

- `try` / `catch` around `Effect.gen` construction. It catches nothing
useful; handle errors in the channel.
- Putting `unknown` or global `Error` in the fail channel
(`unknownInEffectCatch`, `globalErrorInEffectFailure`).
- Swallowing interruption as a typed success. Cancellation is `AbortError`.
- `Effect.runPromise` in a test to "see the error" when `runPromiseExit` +
`Cause` is the assertion you want — still only through the boundary.
- New public error codes without updating the authoring docs and the mapping
table in `docs/effect-conventions.md`.
63 changes: 63 additions & 0 deletions agent-patterns/effect-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Effect Scope patterns

Source: `repos/effect/packages/effect/src/Scope.ts`,
`repos/effect/packages/effect/src/Effect.ts` (`acquireRelease`, `scoped`,
`addFinalizer`, `abortSignal`). Refresh when the subtree moves.

Stage 1 uses `Scope` for state-driver transaction/connection lifecycles.
Stage 3 uses it for `EpochStore` staging/leases/recovery and MCP session
teardown. The public `defineState` / `state.dispatch` APIs stay Promise.

## Resource lifecycle

```ts
const connection = Effect.acquireRelease(
Effect.sync(() => open()),
(handle, exit) => Effect.sync(() => handle.close(exit)),
);
```

- `acquireRelease(acquire, release)` — release receives the exit so you can
distinguish success / fail / interrupt.
- `acquireDisposable` — when the resource already implements
`Symbol.dispose` / `Symbol.asyncDispose`.
- `addFinalizer` — extra cleanup on the current scope.
- `Effect.scoped(effect)` — provide a fresh scope and close it when `effect`
finishes (including interruption).
- `Effect.scopedWith((scope) => ...)` — when you must hold the `Scope` value.

Finalizers run in reverse acquire order. Interruption still runs them.

## Layers

- `Layer.effect` for a service with no finalizer.
- `Layer.scoped` when constructing the service needs `Scope` (open a handle,
register a lease, start a subscriber).
- `Layer.effectDiscard` for a background fiber you do not expose as a
service (pair with `Effect.forkScoped` / `Effect.forkChild` + scope).

Do not return a live `Scope` across the Promise boundary. Close it inside
the Effect and let `runPromise` observe the result.

## AbortSignal

- Host → Effect: `runPromise(program, { signal })` or
`interruptWhenAborted(program, signal)`.
- Effect → host: `yield* scopedAbortSignal` (`Effect.abortSignal`). The
signal aborts when the owning scope closes. Do not keep it longer than
that scope.

Do not allocate a raw `AbortController` inside `Effect.gen` when
`Effect.abortSignal` is the owner (language-service `abortControllerInEffect`).

## What to avoid

- `try` / `finally` around Effect construction. Construction is lazy;
finalizers belong on the scope.
- Opening sqlite / file / network handles in `Effect.sync` without
`acquireRelease`.
- Sharing one connection across requests without a scope (or a documented
process-lifetime Layer).
- Importing `./state/contract` from the root runtime boundary to type
`AgentStateError` — duck-type by `error.name` so the kernel stays off the
package-root graph.
59 changes: 59 additions & 0 deletions agent-patterns/effect-stream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Effect Stream patterns

Source: `repos/effect/packages/effect/src/Stream.ts` (vendored v4, package
`4.0.0-rc.112`). Refresh when the subtree moves. Read
`repos/effect/LLMS.md` § Working with Streams first.

Stage 2 replaces the #145 pull-gated Flight `TransformStream` with Effect
`Stream`. Native pull backpressure is the point — do not re-implement a
gated reader.

## Constructors

| Need | Use | Notes |
| --- | --- | --- |
| Iterable / array | `Stream.fromIterable`, `Stream.fromArray`, `Stream.make` | Eager chunks from in-memory data. |
| One effect | `Stream.fromEffect` | Emits the success value once. |
| Poll / schedule | `Stream.fromEffectSchedule`, `Stream.tick` | Repeating effects. |
| Paginated API | `Stream.paginate` | Token → page → next token. |
| `AsyncIterable` | `Stream.fromAsyncIterable(iter, onError)` | Maps iterator throw to `E`. |
| Web `ReadableStream` | `Stream.fromReadableStream({ evaluate, onError })` | Flight bytes in. |
| Queue / PubSub | `Stream.fromQueue`, `Stream.fromPubSub`, `Stream.fromSubscription` | Fan-in. |
| Callback / DOM | `Stream.callback`, `Stream.fromEventListener` | Resume-at-most-once. |
| Empty / never | `Stream.empty`, `Stream.never` | |
| Fail | `Stream.fail`, `Stream.failCause`, `Stream.die` | Typed vs defect. |

Avoid inventing a custom pull loop. If the source is already a
`ReadableStream` or async iterable, use the constructor above.

## Combinators (stage 2+)

- Transform: `map`, `mapEffect` (effectful, concurrency option), `flatMap`,
`switchMap` (cancel previous), `filter`, `tap`.
- Merge: `merge`, `mergeAll`, `concat`.
- Time: `timeout`, `schedule`, `repeat`.
- Resource: `Stream.scoped` — acquire inside the stream, release when it ends
or is interrupted.
- Consume: `Stream.runCollect`, `Stream.runFold`, `runForEach` / `runForEachArray`.
- Edge out: `Stream.toReadableStream` / `toReadableStreamEffect` when a host
still wants a web stream. That helper itself calls `Effect.runFork` — only
legal via the package boundary if we wrap it; prefer staying on `Stream`
until the Promise edge.

## Backpressure

Streams are pull-based. Downstream `run*` pulls chunks; producers that honor
the pull (readable streams, queues) automatically apply backpressure. Do not
add a second gate (`TransformStream` + manual pause) around an Effect stream.

## What to avoid

- `for await` over a stream you already have as `Stream` — use `mapEffect` /
`runForEach`.
- Encoding/decoding JSON by hand when `Stream.pipeThroughChannel` +
`effect/unstable/encoding` (Ndjson / SchemaBinary) would do. Unstable
encoding is **not** adopted in Stage 0; list it in
`docs/effect-conventions.md` before first use.
- Constructing `new ReadableStream` to paper over missing backpressure.
- Calling `Effect.runPromise` on each chunk. Consume with `Stream.run*`
inside Effect, one `runPromise` at the boundary.
20 changes: 20 additions & 0 deletions docs/effect-cold-start-baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"effect": "4.0.0-rc.112",
"hookPath": "claude/hooks/<generated-session-start>.mjs",
"kind": "generated-stdio-hook-cold-start",
"maxMs": 43.06,
"measuredAt": "2026-09-01T11:12:47.007Z",
"medianMs": 39.74,
"minMs": 36.41,
"node": "v22.23.1",
"notes": "Budget gate for Wave 3.5 stage 2. Re-run with pnpm bench:hook-cold-start -- --check.",
"samplesMs": [
40.96,
36.41,
36.56,
41.76,
39.35,
39.74,
43.06
]
}
Loading
Loading