Files

572 lines
22 KiB
Markdown

# Effect v4 — Practical Notes for the Subagents Extension
> **Building a new pi extension on this stack?** Start with the migration-oriented
> [`effect-v4-extension-guide.md`](./effect-v4-extension-guide.md) (toolchain setup, the
> `ManagedRuntime` + `runTool` boundary, when *not* to use Effect, per-extension recipes).
> This file is the deeper Effect API reference it points back to.
> **Verified against:** `effect@4.0.0-beta.98` and `@effect/platform-node@4.0.0-beta.98`
> (npm dist-tag `beta`, checked 2026-07-13). Every snippet in this doc was type-checked
> with `tsc --strict` against these packages, and the process-spawning / runtime snippets
> were executed with Node 24. v4 source lives in the **`Effect-TS/effect-smol`** repo
> (not `Effect-TS/effect`); the official migration guide is
> [`effect-smol/MIGRATION.md`](https://github.com/Effect-TS/effect-smol/blob/main/MIGRATION.md)
> plus the `migration/*.md` files next to it (including a machine-readable
> `migration/v3-to-v4.md` rename map).
## Install
```bash
npm install effect@beta @effect/platform-node@beta
```
Big structural facts:
- **One version number for everything.** All ecosystem packages release together at
`4.0.0-beta.N`. `@effect/platform-node` must match `effect` exactly.
- **`@effect/platform` is gone — merged into core `effect`.** `FileSystem`, `Path`,
`PlatformError`, `Terminal`, `Stdio` are now top-level `effect` modules.
`@effect/platform-node` remains as the Node *implementation* package.
- **`effect/unstable/*` namespace.** Modules that may break in minor releases:
`effect/unstable/process` (child processes — the one we need), `http`, `rpc`, `cli`,
`ai`, `workers`, etc. Everything outside `unstable/` follows strict semver.
- **`"type": "module"`**, ESM-first. Works with `moduleResolution: NodeNext` or `Bundler`.
- Runtime keep-alive is built in now: a fiber suspended on `Deferred.await` etc. keeps
the Node process alive without `NodeRuntime.runMain` (v3 needed runMain for that).
## Cheat sheet: v3 → v4 renames you will actually hit
| v3 | v4 |
| --- | --- |
| `Context.Tag` / `Effect.Service` | `Context.Service` (`Effect.Service` is **gone**) |
| `Effect.fork` | `Effect.forkChild` |
| `Effect.forkDaemon` | `Effect.forkDetach` |
| `Effect.async` | `Effect.callback` |
| `Effect.catchAll` | `Effect.catch` |
| `Effect.catchAllCause` / `catchAllDefect` | `Effect.catchCause` / `catchDefect` |
| `Effect.zipRight` / `zipLeft` | `Effect.andThen` / `Effect.tap` |
| `Effect.either` | `Effect.result` |
| `Either` module | `Result` (`Result.succeed` / `Result.fail`) |
| `Layer.scoped` | `Layer.effect` (all layers are scoped-capable now) |
| `Mailbox` | `Queue` (Queue absorbed Mailbox: done/fail signalling built in) |
| `FiberRef` | `Context.Reference` (module `effect/References`) |
| `Scope.extend` | `Scope.provide` |
| `Stream.async*` (all 4 variants) | `Stream.callback` |
| `Stream.fromChunk(s)` | `Stream.fromArray` / `Stream.fromArrays` (Chunk de-emphasized; arrays used) |
| `Schema.TaggedError` | `Schema.TaggedErrorClass` |
| `@effect/platform/Command` | `effect/unstable/process` → `ChildProcess` |
| `@effect/platform/CommandExecutor` | `effect/unstable/process` → `ChildProcessSpawner` |
| `@effect/platform/FileSystem` | `effect/FileSystem` |
| `Runtime.runPromise(runtime)(...)` | gone — use `ManagedRuntime` methods directly |
| `UnknownException` (tryPromise default) | `Cause.UnknownError` |
Removed with no replacement: `Effect.forkAll`, `Effect.forkWithErrorHandler`.
Early v4 betas renamed `Context` → `ServiceMap`; **that was reverted** — beta.98 uses
`Context` again (ignore older blog posts/AI answers mentioning `ServiceMap`).
---
## 1. Basics: `Effect.gen`, running, the Promise boundary
Unchanged in spirit from v3. `Effect<A, E, R>` = success `A`, typed error `E`,
required services `R`.
```ts
import { Effect, Exit } from "effect"
const double = (n: number) => Effect.succeed(n * 2)
const program = Effect.gen(function* () {
const a = yield* Effect.succeed(1)
const b = yield* double(a)
yield* Effect.log(`result: ${b}`)
return b
})
// Entry-point conversion — only at the outermost layer (tool handlers):
const p: Promise<number> = Effect.runPromise(program) // rejects on failure/defect
const pe: Promise<Exit.Exit<number, never>> = Effect.runPromiseExit(program) // never rejects
const s: number = Effect.runSync(program) // throws if async
const fiber = Effect.runFork(program) // fire-and-forget fiber
```
`runPromise` only accepts `Effect<A, E, never>` — all services must be provided first
(or use a `ManagedRuntime`, §8). `runPromiseExit` is the right choice inside tool
handlers when you want to convert failures to structured results instead of throws.
`Effect.fn` gives you named, traced effect functions (nice stack traces):
```ts
const spawnJob = Effect.fn("spawnJob")(function* (name: string) {
yield* Effect.log(`spawning ${name}`)
return name
})
// spawnJob("x") : Effect<string>
```
## 2. Services & Layers
`Context.Tag` and the v3 `Effect.Service` helper class are **both gone**. The single
API is `Context.Service`, in two forms:
```ts
import { Context, Effect, Layer } from "effect"
// Class-style (recommended — class value doubles as the key):
class Clock extends Context.Service<Clock, {
readonly now: Effect.Effect<number>
}>()("app/Clock") {}
// Function-style key:
const Random = Context.Service<{ next: Effect.Effect<number> }>("app/Random")
// Yielding the key retrieves the service (keys are Effects):
const use = Effect.gen(function* () {
const clock = yield* Clock
return yield* clock.now
})
```
Layers work like v3 (`Layer.scoped` merged into `Layer.effect` — every `Layer.effect`
build can use scoped resources):
```ts
const ClockLive = Layer.succeed(Clock, { now: Effect.sync(() => Date.now()) })
const RandomLive = Layer.effect(
Random,
Effect.gen(function* () {
yield* Effect.log("building Random")
return { next: Effect.sync(() => Math.random()) }
})
)
// Dependencies between layers:
class Ids extends Context.Service<Ids, { readonly nextId: Effect.Effect<string> }>()("app/Ids") {}
const IdsLive = Layer.effect(
Ids,
Effect.gen(function* () {
const random = yield* Random
return Ids.of({ nextId: Effect.map(random.next, (n) => `id-${n}`) })
})
)
const AppLayer = Layer.mergeAll(ClockLive, IdsLive.pipe(Layer.provide(RandomLive)))
const runnable = use.pipe(Effect.provide(AppLayer)) // R = never
// One-off service injection without a layer:
const withTestClock = use.pipe(Effect.provideService(Clock, { now: Effect.succeed(0) }))
```
Also useful:
- `Context.Reference("key", { defaultValue: () => ... })` — a service **with a default**
(this replaces `FiberRef`); no layer needed, override with `Effect.provideService`.
- **Layer memoization changed:** layers are memoized *globally across separate
`Effect.provide` calls* by default in v4 (per-runtime MemoMap), so providing the same
layer to two effects builds it once.
## 3. Error handling
`Data.TaggedError` survives unchanged; `Schema.TaggedError` is now
`Schema.TaggedErrorClass`. Errors are yieldable directly.
```ts
import { Data, Effect, Schema } from "effect"
class SpawnError extends Data.TaggedError("SpawnError")<{
readonly command: string
readonly cause: unknown
}> {}
class TimeoutError extends Data.TaggedError("TimeoutError")<{ readonly ms: number }> {}
// Schema-validated variant:
class NotFound extends Schema.TaggedErrorClass<NotFound>()("NotFound", {
id: Schema.Number
}) {}
const risky: Effect.Effect<string, SpawnError | TimeoutError> = Effect.gen(function* () {
if (somethingBad) return yield* new SpawnError({ command: "codex", cause: "enoent" })
return "ok"
})
// catchTag narrows the error union:
const handled: Effect.Effect<string, TimeoutError> = risky.pipe(
Effect.catchTag("SpawnError", (e) => Effect.succeed(`spawn failed: ${e.command}`))
)
const handledAll: Effect.Effect<string> = risky.pipe(
Effect.catchTags({
SpawnError: (e) => Effect.succeed(`spawn: ${e.command}`),
TimeoutError: (e) => Effect.succeed(`timeout ${e.ms}ms`)
})
)
```
Renames: `catchAll` → `Effect.catch`, `catchAllCause` → `catchCause`,
`catchAllDefect` → `catchDefect`, `tapErrorCause` → `tapCause`, `Effect.either` →
`Effect.result` (returns `Result`, the renamed `Either`). `Effect.match`,
`Effect.exit`, `Effect.orDie`, `Effect.mapError`, `Effect.tapError` all still exist.
The `Cause` data structure was **flattened** (no nested `Sequential`/`Parallel` trees;
a cause is now a flat list of failures — simpler to render in job status output).
## 4. Promise / async / callback interop
```ts
import { Cause, Data, Effect } from "effect"
// Promise that "can't" reject — rejection becomes a defect:
const a = Effect.promise(() => Promise.resolve(42))
// Promise that may reject — default error type is Cause.UnknownError (v3: UnknownException):
const b = Effect.tryPromise((signal) => fetch("https://x", { signal }))
// Typed error:
class HttpError extends Data.TaggedError("HttpError")<{ cause: unknown }> {}
const c = Effect.tryPromise({
try: (signal) => fetch("https://x", { signal }),
catch: (cause) => new HttpError({ cause })
})
```
Both forms receive an `AbortSignal` that fires on fiber interruption — pass it to SDKs
(claude SDK, fetch, etc.) so interrupting a subagent fiber cancels the underlying call.
Callback APIs — `Effect.async` is now **`Effect.callback`**:
```ts
// signature: Effect.callback<A, E, R>((resume, signal) => void | cleanupEffect)
const waitForExit = (child: import("node:child_process").ChildProcess) =>
Effect.callback<number>((resume) => {
child.once("exit", (code) => resume(Effect.succeed(code ?? -1)))
})
// cleanup on interruption via the AbortSignal:
const sleepy = Effect.callback<number>((resume, signal) => {
const t = setTimeout(() => resume(Effect.succeed(1)), 1000)
signal.addEventListener("abort", () => clearTimeout(t))
})
```
## 5. Scopes & resource management
Same shape as v3. Relevant rename: `Scope.extend` → `Scope.provide`.
```ts
import { Effect, Exit, Scope } from "effect"
const managedProc = Effect.acquireRelease(
acquireEffect, // acquire (uninterruptible)
(proc, exit) => Effect.sync(() => proc.kill()) // release, gets the Exit
)
// Scoped region: release runs when the region ends
const useIt = Effect.scoped(
Effect.gen(function* () {
const proc = yield* managedProc
return proc.pid
})
)
// addFinalizer:
Effect.scoped(Effect.gen(function* () {
yield* Effect.addFinalizer((exit) => Effect.log(`closing, ok=${Exit.isSuccess(exit)}`))
}))
```
**Key pattern for background subagents** — a manually-controlled scope so the process
outlives the spawning tool call, and killing the job = closing the scope:
```ts
const startJob = Effect.gen(function* () {
const scope = yield* Scope.make()
const handle = yield* Scope.provide(managedProc, scope) // resource lives in `scope`
// store `scope` in your job registry; later, from any fiber:
// yield* Scope.close(scope, Exit.void) // runs finalizers -> kills process
return { handle, scope }
})
```
`Effect.acquireUseRelease(acquire, use, release)` for one-shot bracketing.
## 6. Child processes & FileSystem (the important part)
The v3 `@effect/platform` `Command`/`CommandExecutor` modules were redesigned into
**`effect/unstable/process`** with `ChildProcess` (command builder) and
`ChildProcessSpawner` (the service). The Node implementation comes from
`@effect/platform-node`.
Import gotcha: `import { ChildProcessSpawner } from "effect/unstable/process"` gives you
the **module namespace**, not the service class. Import the class from the submodule:
```ts
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { NodeServices, NodeFileSystem } from "@effect/platform-node"
```
`NodeServices.layer` provides everything at once:
`ChildProcessSpawner | Crypto | FileSystem | Path | Stdio | Terminal`.
### Building commands
```ts
// Template literal form:
const cmd1 = ChildProcess.make`echo hello`
// Options + template:
const cmd2 = ChildProcess.make({ cwd: "/tmp" })`ls -la`
// Array form (best for dynamic args — no shell parsing):
const cmd = ChildProcess.make("codex", ["exec", "--json", prompt], {
cwd: workDir,
env: { CODEX_API_KEY: key }, // merged over process.env when extendEnv: true
extendEnv: true,
stdin: "pipe", // "pipe" | "inherit" | "ignore" | a Stream<Uint8Array>
stdout: "pipe", // "pipe" | "inherit" | "ignore" | a Sink
stderr: "pipe"
})
// pipelines: cmdA.pipe(ChildProcess.pipeTo(cmdB, { from: "stderr" }))
// modifiers: ChildProcess.setCwd, ChildProcess.setEnv, ChildProcess.prefix
```
Note `detached` defaults to **true on non-Windows** — the child gets its own process
group (good: killing it kills the group).
### Running: two styles
**Style A — via the spawner service (simple, non-interactive):**
```ts
const simple = Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const text = yield* spawner.string(cmd) // full stdout as string
const lines = yield* spawner.lines(cmd) // string[]
const code = yield* spawner.exitCode(cmd) // ExitCode (branded number)
const lineStream = spawner.streamLines(cmd, { includeStderr: true }) // Stream<string>
})
```
**Style B — the Command IS an Effect.** Yielding a command spawns it and returns a
`ChildProcessHandle`, with the process lifetime tied to the ambient `Scope`
(`Command extends Effect<ChildProcessHandle, PlatformError, ChildProcessSpawner | Scope>`):
```ts
import { Effect, Stream } from "effect"
const streaming = Effect.scoped(
Effect.gen(function* () {
const handle = yield* cmd // <-- spawns; killed when scope closes
handle.pid // ProcessId (branded number)
// stdout is Stream<Uint8Array, PlatformError>; decode + split lines:
yield* handle.stdout.pipe(
Stream.decodeText(),
Stream.splitLines,
Stream.runForEach((line) => Effect.log(`out: ${line}`))
)
// stdin is a Sink<void, Uint8Array>; write by running a stream into it:
yield* Stream.fromArray([new TextEncoder().encode("hello\n")]).pipe(
Stream.run(handle.stdin)
)
// handle.stderr, handle.all (stdout+stderr interleaved) also available
const code = yield* handle.exitCode // waits for exit
const running = yield* handle.isRunning
yield* handle.kill({ killSignal: "SIGTERM", forceKillAfter: "5 seconds" })
return code
})
)
```
Verified at runtime: interrupting a fiber that spawned a process inside its scope
(e.g. `Fiber.interrupt` on a fiber running `Effect.scoped(...)` around `sleep 30`)
kills the child process. This is the backbone of subagent cancellation.
There's also `handle.unref` for letting the parent exit independently, and
`additionalFds` for extra file descriptor channels (fd3+).
### FileSystem / Path (now core)
```ts
import { Effect, FileSystem, Path } from "effect"
const files = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const content = yield* fs.readFileString("/tmp/in.txt")
yield* fs.writeFileString(path.join("/tmp", "out.txt"), content)
})
// provide NodeFileSystem.layer, or NodeServices.layer for everything
```
## 7. Concurrency toolbox for a job manager
All verified compiling + running:
```ts
import {
Cause, Deferred, Effect, Fiber, FiberMap, PubSub, Queue, Ref, Stream, Exit
} from "effect"
const forking = Effect.gen(function* () {
const f1 = yield* Effect.forkChild(work) // v3 fork — dies with parent
const f2 = yield* Effect.forkScoped(work) // tied to ambient Scope
const f3 = yield* Effect.forkDetach(work) // v3 forkDaemon — outlives parent ✅ for background jobs
// all fork variants accept { startImmediately?: boolean, uninterruptible?: boolean | "inherit" }
const r: number = yield* Fiber.join(f1) // propagates failure
const exit: Exit.Exit<number> = yield* Fiber.await(f3) // failure as data
yield* Fiber.interrupt(f2)
// fiber.pollUnsafe() -> Exit | undefined for sync status checks
})
```
**Deferred** — one-shot completion signal (job finished):
```ts
const done = yield* Deferred.make<Result, JobError>()
yield* Effect.forkDetach(job.pipe(Effect.exit, Effect.flatMap((e) => Deferred.done(done, e))))
const result = yield* Deferred.await(done) // keeps Node process alive in v4, no runMain needed
```
**Ref** — shared job-table state: `Ref.make({})`, `Ref.update`, `Ref.get`, `Ref.set`.
(`SynchronizedRef` for effectful updates.)
**Queue** — absorbed v3's `Mailbox`: it can be ended/failed, and converts to a Stream.
To use `Queue.end`, the error type must include `Cause.Done`:
```ts
const q = yield* Queue.make<string, Cause.Done>() // capacity/strategy via options
yield* Queue.offer(q, "line")
yield* Queue.offerAll(q, ["a", "b"])
yield* Queue.end(q) // signal "no more items"
const asStream = Stream.fromQueue(q) // ends when queue ends
// Queue.take / takeAll / takeN / poll for direct consumption
```
**PubSub** — broadcast (e.g. multiple watchers of one job's output):
```ts
const ps = yield* PubSub.unbounded<string>()
const sub = yield* PubSub.subscribe(ps) // scoped acquisition
yield* PubSub.publish(ps, "event")
const msg = yield* PubSub.take(sub) // function call, not sub.take
// Stream.fromPubSub(ps) for stream consumption
```
**FiberMap / FiberSet / FiberHandle** — keyed fiber registries, ideal for a subagent
manager (auto-interrupts everything when its scope closes; adding under an existing key
interrupts the old fiber):
```ts
const jobsDemo = Effect.scoped(Effect.gen(function* () {
const jobs = yield* FiberMap.make<string>() // FiberMap<string> (keys)
yield* FiberMap.run(jobs, "job-1", work) // fork into the map
const fiber = yield* FiberMap.get(jobs, "job-1")
yield* FiberMap.remove(jobs, "job-1") // interrupts it
}))
```
**Stream** essentials for process output: `Stream.decodeText()`, `Stream.splitLines`,
`Stream.runForEach`, `Stream.runCollect`, `Stream.run(sink)`, `Stream.fromQueue`,
`Stream.fromPubSub`, `Stream.toAsyncIterable`, `Stream.callback` (v3 `Stream.async`),
`Stream.fromArray` (v3 `fromChunk` — v4 uses plain arrays, Chunk is de-emphasized).
Also `Stream.mkString` to collect into one string.
Combinators: `Effect.all([...], { concurrency: n })`, `Effect.race`,
`Effect.timeout("5 seconds")` (fails with `Cause.TimeoutError`, tag `"TimeoutError"`),
`Semaphore.make(n)` (moved out of `Effect.makeSemaphore`), `Latch.make()`.
## 8. ManagedRuntime — the entry-point pattern for the extension
v3's `Runtime.runPromise(runtime)(effect)` API is gone; `effect/Runtime` now only holds
`runMain` plumbing. **`ManagedRuntime` is the way** to share layers across async entry
points:
```ts
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import { NodeServices } from "@effect/platform-node"
class JobStore extends Context.Service<JobStore, {
readonly list: Effect.Effect<Array<string>>
}>()("app/JobStore") {}
const JobStoreLive = Layer.sync(JobStore, () => ({ list: Effect.succeed([]) }))
const AppLayer = Layer.mergeAll(JobStoreLive, NodeServices.layer)
// Build ONCE at extension activation. Layers are built lazily on first run.
const runtime = ManagedRuntime.make(AppLayer)
// pi tool handler — the only place we touch Promises:
export async function handleToolCall() {
return await runtime.runPromise(
Effect.gen(function* () {
const store = yield* JobStore
return yield* store.list
})
)
}
// fire-and-forget background work from a sync/async context:
const fiber = runtime.runFork(backgroundEffect)
// extension deactivation — closes the runtime scope, runs ALL finalizers
// (i.e. kills any still-scoped child processes):
export async function shutdown() {
await runtime.dispose()
}
```
`ManagedRuntime` also exposes `runSync`, `runSyncExit`, `runPromiseExit`, `runCallback`,
a `memoMap` (share layer memoization between multiple runtimes), and `.scope`.
Prefer `runPromiseExit` in tool handlers if you want to map typed failures to
tool-result errors instead of catching thrown `Cause` wrappers.
---
## Architecture sketch for subagents
```
ManagedRuntime.make(Layer.mergeAll(NodeServices.layer, SubagentManagerLive))
│ built once at extension init; dispose() on shutdown
▼
SubagentManager service (Context.Service):
- Ref<HashMap<JobId, JobEntry>> job table
- start(cmd): Scope.make() → Scope.provide(ChildProcess handle, scope)
→ Effect.forkDetach(pump stdout → Queue<string, Cause.Done>)
→ Deferred<ExitCode> completed on exit
- status(id): Deferred.isDone / handle.isRunning / Ref lookup
- output(id): drain Queue (takeAll) or Stream.fromQueue for tailing
- kill(id): Scope.close(scope, Exit.void) → finalizer kills the process
Tool handlers: async fns calling runtime.runPromise(Effect.gen(...))
```
## Surprises & gotchas (learned the hard way)
1. **`effect/unstable/process` exports module namespaces.** The
`ChildProcessSpawner` class must be imported from
`"effect/unstable/process/ChildProcessSpawner"` (or use
`ChildProcessSpawner.ChildProcessSpawner` off the namespace).
2. **`Effect.fork` does not exist** — code (or an LLM) writing v3-style `Effect.fork`
fails to compile. Use `forkChild` / `forkScoped` / `forkDetach` / `forkIn`.
3. **`Queue.end` needs `Cause.Done` in the queue's error type** —
`Queue.make<A>()` defaults to `E = never` and won't accept `end`.
4. **Early-beta content mentioning `ServiceMap` is stale** — it was renamed back to
`Context` during the beta. Similarly, some AI-generated content mentions APIs that
never shipped.
5. **`Either` is `Result`**, `Effect.either` is `Effect.result`.
6. **`Data.TaggedError` errors are yieldable** — `yield* new SpawnError({...})` fails
the effect directly, no `Effect.fail` wrapper needed (works in v3 too, but idiomatic
in v4 docs).
7. **Chunk → Array**: stream element groups are plain arrays (`Stream.fromArray`,
`runCollect` returns `Array<A>`), not `Chunk`.
8. `tryPromise` default error is `Cause.UnknownError` (was `UnknownException`).
9. `unstable/*` modules can break between v4 minors — pin exact versions
(`4.0.0-beta.98`) and keep `effect` and `@effect/platform-node` in lockstep.
10. Scratch workspace with all verified test files: `/tmp/effect-v4-scratch`
(`test1-basics.ts` … `test8-runtime.ts`, `smoke.mts` executed successfully).