Skip to content
5 changes: 5 additions & 0 deletions .changeset/effect-filesystem-phase2-dev-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Keep `agent-bundle dev` — the Workbench dev server, its host installs, MCP sessions and probes, hook / host-discovery / native / script playgrounds, skill documents, evals, and asset serving — behaving exactly as before while every service's file reads, temporary directories, and removals run on one platform runtime that `startDevServer` creates and the session's `close` releases after the last service has closed. Diagnostics, routes, and responses are unchanged. Two lifetimes are now explicit: an MCP session's plugin-data directory lives exactly as long as the session (removed when the session closes, or when an open fails before the session exists), and a script playground workspace that cannot be removed is still reported in the run result's `cleanupFailures` instead of replacing the script's outcome. `agent-bundle --version` / `--help` keep loading no Effect module. (#551)
63 changes: 56 additions & 7 deletions docs/effect-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,9 +411,23 @@ the first-party CLI's user-facing text — see
`NodeTerminal` + `NodeStdio`, built on the first command write, see
[Terminal and Stdio](#terminal-and-stdio-user-facing-cli-text)) and widens
to `platformLayer` there when CLI code adopts the filesystem services; the
dev server (phase 2) gets one `makeScopedEffectRuntime(platformLayer)` in
`startDevServer`, disposed from the session's `close`. Never provide a
platform layer deep inside library code.
dev server has one `makeScopedEffectRuntime(platformLayer)`, created
inside `startDevServer` (never at module top level — `effect` is a CLI
cold-start cost) and disposed from the returned session's `close` after
every service has closed (`createDevPlatformRuntime` in
`src/dev/platform-run.ts`). Every dev service takes the runtime as an
optional `platformRuntime` constructor option typed as `DevPlatformRuntime`
(`src/dev/platform-runtime.ts`), a deliberately Effect-free handle with only
`close()`: service options sit on the package's public declaration graph,
which `public-api.test.ts` keeps free of `effect` imports. The service's
implementation resolves the handle to its `PlatformRun` edge with
`platformRunOf(options.platformRuntime)` (`runWithPlatform`'s signature
over the long-lived runtime, `PlatformError` unwrapped the same way);
absent a handle, `platformRunOf` returns `runWithPlatform`, so the services
stay usable on their own. Both modules live under `dev/`, not in
`platform.ts` — that module is bundled into the emitted installer, which
stays byte-identical. Never provide a platform layer deep inside library
code.
- Errors: `PlatformError` flows through the Effect error channel and is
mapped once, at the boundary, onto the existing contract. Where a
user-facing AB#### diagnostic already exists for the failure, map to it
Expand Down Expand Up @@ -450,6 +464,22 @@ the first-party CLI's user-facing text — see
synchronous `existsSync` probe beside `createRequire`'s synchronous
resolution.
- Synchronous SQLite setup (`rsc-runtime/src/state/sqlite.ts`).
- Dev-server durable protocols and identity checks: `dev/epoch-store.ts`,
`dev/dev-lock.ts`, `dev/runtime-generation-store.ts`'s publish path
(`wx` manifest, non-recursive `mkdir`, `rename`, `lstat`-verified assets;
only its manifest / asset reads and recursive `mkdir`s are programs),
`dev/playground/native-playground-service.ts`'s catalog publication
(`link` / `open` / `rename` / `lstat`, rollback quarantine),
`dev/host-install-manager.ts`'s `lstat` rows, `mkdtemp` + `cp`
(`verbatimSymlinks`, `errorOnExist`) staging and atomic `rename` swap,
`dev/eval/eval-service.ts`'s `O_NOFOLLOW` evidence reader, the
`lstat` / `realpath` containment in `dev/skill-document-service.ts` and
`dev/project-service.ts`, `dev/package-build-service.ts`'s `rmdir`
pruning (no `FileSystem` equivalent with its "not empty" contract), and
`dev/playground/mcp-probe-service.ts`'s retrying teardown `rm`
(`maxRetries` / `retryDelay` have no `FileSystem.remove` option), and
`dev/playground/lifecycle-replay-service.ts`'s synchronous `existsSync`
probe.
- `dev/watcher.ts`: chokidar stays. `FileSystem.watch` is a thin `fs.watch`
with create/update/remove only — no `ignored` callbacks, readiness, or the
other event kinds — and the watcher's `dev:ino` signatures need `stat`
Expand Down Expand Up @@ -511,9 +541,28 @@ carve-outs above. The sibling `routes/graph.ts` reads stay raw:
only its two async reads would add an Effect runtime per compile for
nothing. Emitted artifacts, hook wrappers, compiler hot paths, and the
modules `cli.ts` loads eagerly never import this module (`cli.test.ts`
fails if `--version` / `--help` resolve an `effect` module); the dev server
picks it up in phase 2's second PR through
`makeScopedEffectRuntime(platformLayer)`.
fails if `--version` / `--help` resolve an `effect` module). The dev server
(phase 2, second PR, 2026-09-03) runs on one
`makeScopedEffectRuntime(platformLayer)` created in `startDevServer`
(`dev/platform-run.ts`) and handed to every service as the Effect-free
`platformRuntime` handle (`dev/platform-runtime.ts`), resolved to its edge
with `platformRunOf`: the ordinary reads, temp directories, and removals of
`dev/project-service.ts`, `package-build-service.ts`,
`host-install-manager.ts`, `skill-document-service.ts`,
`workbench-assets.ts`, `runtime-provider-loader.ts`,
`playground/{hook,host-discovery,mcp-probe,native,script}-playground-service.ts`,
and `eval/eval-service.ts`. `runtime-generation-store.ts` reads through
`FileSystem` too but on `runWithPlatform`: providers construct it through
the public `createRuntimeGenerationStore` factory, whose effect-free options
contract (`runtime-store-contracts.ts`, exported from `agent-bundle/api`)
has no session runtime to hand it. Two directories outlive their call and are
therefore not `withTempDirectory` brackets: the MCP session's plugin-data
directory is acquired into its own session-lifetime `Scope` whose only
finalizer removes it — the session closes that scope from `close()`, and
until the session exists the open scope's release closes it instead — and
the script playground's workspace lease keeps `close` as a separate step so
a removal failure is reported in the result's `cleanupFailures`, not in
place of the script's outcome.

### Terminal and Stdio: user-facing CLI text

Expand Down Expand Up @@ -688,7 +737,7 @@ wire contracts](#effect-schema-wire-contracts-schema-projections).
| Module | Adopted in | Re-verify |
| --- | --- | --- |
| `effect/unstable/reactivity` (+ `@effect/atom-react` bindings) | Workbench Agent Document panel (#105 phase 1) and route editor (#105 phase 2) | re-pin bumps @effect/atom-react in lockstep; re-run disposal regression + bundle measurement; stream-backed derived atoms stay banned until the rc.112 disposal fix ships |
| `@effect/platform-node` (`NodeServices.layer`, `create-agent-bundle`) and `@effect/platform-node-shared` (`agent-bundle`'s `platformLayer`); `FileSystem` / `Path` services live in `effect` | **adopted** (2026-09-03) for ordinary I/O — `create-agent-bundle` scaffolder and the `agent-bundle` temp directories in `api.ts` / the Codex validator (phase 1); host-contracts validators, `services/*`, `eval/*`, and the post-build readers (phase 2, ordinary-I/O modules, 2026-09-03); see [Effect platform services](#effect-platform-services-effectplatform-node) for the keep-raw list and the consumer-footprint reason for the split | re-pin bumps both in lockstep with `effect`; re-check whether `@effect/platform-node` still forces a `redis` peer (if it stops, `agent-bundle` can move to `NodeServices.layer`); re-check whether `lstat` / `O_NOFOLLOW` / directory fsync landed (would shrink the keep-raw list) and the `runMain` 130/143 exit contract |
| `@effect/platform-node` (`NodeServices.layer`, `create-agent-bundle`) and `@effect/platform-node-shared` (`agent-bundle`'s `platformLayer`); `FileSystem` / `Path` services live in `effect` | **adopted** (2026-09-03) for ordinary I/O — `create-agent-bundle` scaffolder and the `agent-bundle` temp directories in `api.ts` / the Codex validator (phase 1); host-contracts validators, `services/*`, `eval/*`, and the post-build readers (phase 2, ordinary-I/O modules, 2026-09-03); the dev server's services on one session-scoped runtime created in `startDevServer` (phase 2, dev server, 2026-09-03); see [Effect platform services](#effect-platform-services-effectplatform-node) for the keep-raw list and the consumer-footprint reason for the split | re-pin bumps both in lockstep with `effect`; re-check whether `@effect/platform-node` still forces a `redis` peer (if it stops, `agent-bundle` can move to `NodeServices.layer`); re-check whether `lstat` / `O_NOFOLLOW` / directory fsync landed (would shrink the keep-raw list) and the `runMain` 130/143 exit contract |
| `@effect/platform-node-shared` (`NodeTerminal` / `NodeStdio`) + `effect/Terminal`, `effect/Stdio` | first-party CLI command output, diagnostics, and machine output (`src/cli.ts`, `src/effect/terminal.ts`, `src/effect/cli-runtime.ts`), loaded lazily on the first command write (2026-09-03); Commander's help/version/argv-error text and the scaffolder's `--help` / flag-error text stay on synchronous process writes for the cold-start budget | re-pin re-checks `Terminal.display` stays stdout-only, `readLine` EOF → `QuitError`, the `Stdio` sink contract, and re-measures `agent-bundle --version` startup against the recorded ≈60 ms (`cli.test.ts` fails the build if the trivial invocations resolve an `effect` module) |
| `Schema` / `SchemaAST` / `SchemaParser` projections (`toType` / `toEncoded`) for wire contracts | **declined** (2026-09-01) | revisit at Effect GA or on the first encoded/decoded-divergent wire contract; re-pin re-checks the projections API and the `onExcessProperty` parse-option default |

Expand Down
15 changes: 13 additions & 2 deletions packages/agent-bundle/src/dev/eval/eval-service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { createHash } from 'node:crypto';
import { constants, type Stats } from 'node:fs';
import { lstat, open, realpath, rm } from 'node:fs/promises';
import { lstat, open, realpath } from 'node:fs/promises';
import { basename, dirname, join, relative, resolve } from 'node:path';
import { Readable } from 'node:stream';

import { Effect, FileSystem } from 'effect';

import { createDefaultRegistry, type TargetRegistry } from '../../adapters/registry.ts';
import { type PlatformRun } from '../../effect/platform.ts';
import { platformRunOf } from '../platform-run.ts';
import type { DevPlatformRuntime } from '../platform-runtime.ts';
import { loadConfig } from '../../config/load.ts';
import type { Diagnostic } from '../../core/diagnostics.ts';
import { digest } from '../../core/digest.ts';
Expand Down Expand Up @@ -152,6 +157,8 @@ export interface EvalServiceOptions {
readonly now?: () => Date;
readonly projectRoot: string;
readonly registry?: TargetRegistry;
/** The dev server's session runtime; absent, each program runs on its own `platformLayer`. */
readonly platformRuntime?: DevPlatformRuntime;
Comment on lines +160 to +161

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the session runtime out of public service options

EvalServiceOptions is exported through agent-bundle/api, but this new structurally typed option cannot be supplied correctly by package consumers: platformRunOf accepts only object identities registered by the internal createDevPlatformRuntime factory and throws TypeError for every other conforming { close(): Promise<void> } value, while neither the factory nor runtime type is exported from a package entry. Thus consumer code can type-check and then fail in the EvalService constructor. Keep this plumbing in an internal options type, or expose and document a supported runtime API.

AGENTS.md reference: AGENTS.md:L71-L77

Useful? React with 👍 / 👎.

readonly targets?: readonly string[];
}

Expand Down Expand Up @@ -447,6 +454,7 @@ export class EvalService {
readonly #now: () => Date;
readonly #projectRoot: string;
readonly #registry: TargetRegistry;
readonly #run: PlatformRun;
readonly #targets: readonly string[] | undefined;
readonly #eventSubscriptions = new Map<string, Set<PendingEvalEventSubscription>>();
readonly #activeRuns = new Map<string, ActiveEvalRun>();
Expand All @@ -461,6 +469,7 @@ export class EvalService {
this.#now = options.now ?? (() => new Date());
this.#projectRoot = resolve(options.projectRoot);
this.#registry = options.registry ?? createDefaultRegistry();
this.#run = platformRunOf(options.platformRuntime);
this.#targets = options.targets;
}

Expand Down Expand Up @@ -660,7 +669,7 @@ export class EvalService {
const missing = missingArtifactTargets(planned, artifact);
if (missing.length > 0) {
// Nothing owns this directory yet, so the abandoned artifact copy is removed.
await rm(directory, { force: true, recursive: true });
await this.#run(Effect.flatMap(FileSystem.FileSystem, (fs) => fs.remove(directory, { force: true, recursive: true })));
throw serviceError(
'EVAL_TARGET_MISSING',
`The evaluated artifact has no target for ${JSON.stringify(missing)}. Build the pinned host targets before evaluating them.`,
Expand Down Expand Up @@ -1057,6 +1066,8 @@ export class EvalService {
): Promise<EvalArtifactReader> {
const artifactRoot = join(directory, 'artifacts');
const target = join(directory, ...segments);
// Stays on `node:fs` (keep-raw list): an `O_NOFOLLOW` descriptor whose
// `dev`/`ino`/`nlink` identity is checked against the surrounding `lstat`s.
await assertNoSymlinkedArtifactPath(this.#projectRoot, target);
const before = await lstat(target);
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || before.size > maximumArtifactBytes) {
Expand Down
33 changes: 25 additions & 8 deletions packages/agent-bundle/src/dev/host-install-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,20 @@ import {
lstat,
mkdir,
mkdtemp,
readFile,
readdir,
rename,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { basename, join, relative, resolve } from 'node:path';

import { Effect, FileSystem } from 'effect';

import { stableJson } from '../core/digest.ts';
import { isPlatformErrno, readFileString, type PlatformRun } from '../effect/platform.ts';
import { platformRunOf } from './platform-run.ts';
import type { DevPlatformRuntime } from './platform-runtime.ts';
import type { Diagnostic } from '../core/diagnostics.ts';
import {
installBundle as defaultInstallBundle,
Expand Down Expand Up @@ -44,6 +47,8 @@ export interface DevHostInstallManagerOptions {
readonly hosts: readonly InstallHost[];
readonly installBundle?: (options: InstallBundleOptions) => Promise<InstallResult>;
readonly projectRoot: string;
/** The dev server's session runtime; absent, each program runs on its own `platformLayer`. */
readonly platformRuntime?: DevPlatformRuntime;
}

interface InstalledDevHost {
Expand Down Expand Up @@ -80,13 +85,14 @@ const rewriteMcpDocument = async (
bundleRoot: string,
host: InstallHost,
projectRoot: string,
run: PlatformRun,
): Promise<void> => {
const path = join(bundleRoot, mcpDocumentPath(host));
let document: unknown;
try {
document = JSON.parse(await readFile(path, 'utf8')) as unknown;
document = JSON.parse(await run(readFileString(path))) as unknown;
} catch (error) {
if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return;
if (isPlatformErrno(error, 'ENOENT')) return;
throw error;
}
if (!isRecord(document) || !isRecord(document.mcpServers)) {
Expand All @@ -103,7 +109,7 @@ const rewriteMcpDocument = async (
},
] as const),
));
await writeFile(path, `${stableJson({ ...document, mcpServers })}\n`, 'utf8');
await run(Effect.flatMap(FileSystem.FileSystem, (fs) => fs.writeFileString(path, `${stableJson({ ...document, mcpServers })}\n`)));
};

const marker = (
Expand All @@ -117,18 +123,27 @@ const marker = (
schemaVersion: 1,
});

/**
* Stays on `node:fs` for the staging copy: `cp` with `verbatimSymlinks` and
* `errorOnExist` has no `FileSystem.copy` equivalent, and the parent's
* ownership transfers to the returned `cleanup`, so it is not a bracket.
*/
const prepareDevBundle = async (
source: string,
host: InstallHost,
epochId: string,
projectRoot: string,
run: PlatformRun,
): Promise<Readonly<{ readonly cleanup: () => Promise<void>; readonly root: string }>> => {
const parent = await mkdtemp(join(tmpdir(), `agent-bundle-dev-${host}-`));
const root = join(parent, 'bundle');
try {
await cp(source, root, { errorOnExist: true, force: false, recursive: true, verbatimSymlinks: true });
await rewriteMcpDocument(root, host, projectRoot);
await writeFile(join(root, DEV_INSTALL_MARKER), `${stableJson(marker(epochId, host, projectRoot))}\n`, 'utf8');
await rewriteMcpDocument(root, host, projectRoot, run);
await run(Effect.flatMap(FileSystem.FileSystem, (fs) => fs.writeFileString(
join(root, DEV_INSTALL_MARKER),
`${stableJson(marker(epochId, host, projectRoot))}\n`,
)));
return Object.freeze({
cleanup: () => rm(parent, { force: true, recursive: true }),
root,
Expand Down Expand Up @@ -296,6 +311,7 @@ export class DevHostInstallManager {
readonly #installBundle: (options: InstallBundleOptions) => Promise<InstallResult>;
readonly #installed = new Map<InstallHost, InstalledDevHost>();
readonly #projectRoot: string;
readonly #run: PlatformRun;
#closed = false;
#pending: Promise<void> = Promise.resolve();
#subscription: ProjectEventSubscription | undefined;
Expand All @@ -309,6 +325,7 @@ export class DevHostInstallManager {
this.#hosts = Object.freeze([...new Set(options.hosts)]);
this.#installBundle = options.installBundle ?? defaultInstallBundle;
this.#projectRoot = resolve(options.projectRoot);
this.#run = platformRunOf(options.platformRuntime);
}

start(): void {
Expand Down Expand Up @@ -384,7 +401,7 @@ export class DevHostInstallManager {
}

async #syncHost(epochRoot: string, epochId: string, host: InstallHost): Promise<void> {
const prepared = await prepareDevBundle(join(epochRoot, host), host, epochId, this.#projectRoot);
const prepared = await prepareDevBundle(join(epochRoot, host), host, epochId, this.#projectRoot, this.#run);
try {
let installed = this.#installed.get(host);
if (installed === undefined) {
Expand Down
Loading
Loading