Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
feat(harness): cancellable + streaming Vercel shell
Vercel exec now runs detached and kills the remote process on interrupt/timeout;
adds optional ISandboxExe.stream (Vercel via Command.logs) bridged to ToolShell.stream.
  • Loading branch information
sanchitrk committed Jun 25, 2026
commit 58825a9f3a546243d95773f1735fb91444bb8d65
91 changes: 71 additions & 20 deletions packages/harness/src/sandbox/providers/vercel.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { type CommandFinished, Sandbox as RemoteSandbox } from "@vercel/sandbox";
import { Context, Effect, Layer, Schema } from "effect";
import { type Command, Sandbox as RemoteSandbox } from "@vercel/sandbox";
import { Context, Effect, Layer, Schema, Stream } from "effect";
import { Buffer } from "node:buffer";
import type { Stats } from "node:fs";
import { SandboxFileSystem } from "../filesystem/filesystem";
import { RemoteFileSystem } from "../filesystem/remote";
import { type ExecResult, type ISandboxExe, Shell, ShellError } from "../shell";
import { type ExecChunk, type ExecResult, type ISandboxExe, Shell, ShellError } from "../shell";

const utf8 = new TextEncoder();

export class VercelError extends Schema.TaggedErrorClass<VercelError>()("VercelError", {
cause: Schema.optional(Schema.Defect()),
Expand Down Expand Up @@ -159,26 +161,75 @@ const providerFrom = (sandbox: RemoteSandbox): RemoteFilesystemProvider => {
return filesystem;
};

// Arbitrary command strings run through `sh -c` to match the single-string
// contract the rest of the harness expects. Commands run **detached** so the
// returned `Command` exposes `kill`/`logs`/`wait`: a `cwd`/`env` is threaded in,
// and the per-command `execTimeout` is a server-side SIGKILL deadline.
const spawn = (
sandbox: RemoteSandbox,
options: Options,
command: string,
env?: Record<string, string>,
): Promise<Command> =>
sandbox.runCommand({
cmd: "sh",
args: ["-c", command],
cwd: options.cwd,
env,
detached: true,
...(options.execTimeout === undefined ? {} : { timeoutMs: options.execTimeout }),
});

// Acquire a detached command and register its kill as a scope finalizer, so an
// interrupt / timeout from the consumer actually terminates the remote process
// (best-effort SIGKILL) instead of leaving it running server-side.
const acquireCommand = (sandbox: RemoteSandbox, options: Options, command: string, env?: Record<string, string>) =>
Effect.acquireRelease(
Effect.tryPromise({
try: () => spawn(sandbox, options, command, env),
catch: (cause) => new ShellError({ command, cause }),
}),
(cmd) => Effect.promise(() => cmd.kill("SIGKILL").catch(() => {})),
);

// Vercel reports stdout and stderr separately with a distinct exit code, so the
// shell surfaces both streams faithfully. Arbitrary command strings run through
// `sh -c` to match the single-string contract the rest of the harness expects.
// shell surfaces both streams faithfully.
const exec =
(sandbox: RemoteSandbox, options: Options): ISandboxExe["exec"] =>
(command, opts) =>
Effect.tryPromise({
try: async (): Promise<ExecResult> => {
const result: CommandFinished = await sandbox.runCommand({
cmd: "sh",
args: ["-c", command],
cwd: options.cwd,
env: opts?.env,
...(options.execTimeout === undefined ? {} : { timeoutMs: options.execTimeout }),
});
const [stdout, stderr] = await Promise.all([result.stdout(), result.stderr()]);
return { stdout, stderr, exitCode: result.exitCode };
},
catch: (cause) => new ShellError({ command, cause }),
});
acquireCommand(sandbox, options, command, opts?.env).pipe(
Effect.flatMap((cmd) =>
Effect.tryPromise({
try: async (): Promise<ExecResult> => {
const result = await cmd.wait();
const [stdout, stderr] = await Promise.all([result.stdout(), result.stderr()]);
return { stdout, stderr, exitCode: result.exitCode };
},
catch: (cause) => new ShellError({ command, cause }),
}),
),
Effect.scoped,
);

// Streaming output via `Command.logs` (an async generator of stdout/stderr
// entries), followed by the exit code from `wait`. The kill finalizer fires when
// the consuming scope closes (interrupt / timeout).
const stream =
(sandbox: RemoteSandbox, options: Options): NonNullable<ISandboxExe["stream"]> =>
(command, opts) =>
Stream.unwrap(
acquireCommand(sandbox, options, command, opts?.env).pipe(
Effect.map((cmd) => {
const logs = Stream.fromAsyncIterable(cmd.logs(), (cause) => new ShellError({ command, cause })).pipe(
Stream.map((log): ExecChunk => ({ _tag: log.stream, bytes: utf8.encode(log.data) })),
);
const exit = Stream.fromEffect(
Effect.tryPromise({ try: () => cmd.wait(), catch: (cause) => new ShellError({ command, cause }) }),
).pipe(Stream.map((finished): ExecChunk => ({ _tag: "exit", exitCode: finished.exitCode })));
return Stream.concat(logs, exit);
}),
),
);

const filesystemLayer = (options: Options) =>
Layer.effect(
Expand All @@ -189,7 +240,7 @@ const filesystemLayer = (options: Options) =>
const shellLayer = (options: Options) =>
Layer.effect(
Shell,
Effect.map(Remote, ({ sandbox }) => Shell.of({ exec: exec(sandbox, options) })),
Effect.map(Remote, ({ sandbox }) => Shell.of({ exec: exec(sandbox, options), stream: stream(sandbox, options) })),
);

/**
Expand Down
21 changes: 20 additions & 1 deletion packages/harness/src/sandbox/shell.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Context, type Effect, Schema } from "effect";
import { Context, type Effect, Schema, type Stream } from "effect";

/**
* The pluggable execution contract. Local sandboxes usually get a Shell through
Expand All @@ -18,6 +18,16 @@ export interface ExecResult {
readonly exitCode: number;
}

/**
* A streamed chunk of command output, terminated by a single `exit` carrying the
* exit code. (A backend-level mirror of the tool layer's event; kept here so
* `sandbox/` does not depend on `tools/`.)
*/
export type ExecChunk =
| { readonly _tag: "stdout"; readonly bytes: Uint8Array }
| { readonly _tag: "stderr"; readonly bytes: Uint8Array }
| { readonly _tag: "exit"; readonly exitCode: number };

/**
* The command-execution capability a sandbox exposes. For an in-process
* backend this is just-bash; for a remote backend it is the sandbox's own
Expand All @@ -28,6 +38,15 @@ export interface ISandboxExe {
command: string,
options?: { env?: Record<string, string> },
) => Effect.Effect<ExecResult, ShellError>;
/**
* Optional streaming output: stdout/stderr chunks then a terminal `exit`.
* Backends that can stream (e.g. Vercel) implement it; `ToolShell.fromSandboxShell`
* bridges it to `ToolShell.stream` so the bash tool streams over them too.
*/
readonly stream?: (
command: string,
options?: { env?: Record<string, string> },
) => Stream.Stream<ExecChunk, ShellError>;
}

/** Execution service — the live {@link ISandboxExe} for the active sandbox. */
Expand Down
31 changes: 25 additions & 6 deletions packages/harness/src/tools/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,13 @@ export class ToolShell extends Context.Service<ToolShell, IToolShell>()("@codewo
* Bridge the existing `sandbox/Shell` into a `ToolShell`. Used for just-bash
* (in-process) and remote providers.
*
* Caveats of the bridge: `sandbox/Shell.exec` is buffered (no `stream`) and
* takes only `{ env }`, so `cwd` is ignored — the backing sandbox owns its
* working directory. The `timeout` is enforced here with `Effect.timeoutOption`:
* interruption always returns control, but the underlying command is only truly
* killed mid-flight on signal-aware backends (handled in the provider layer).
* Caveats of the bridge: `sandbox/Shell` takes only `{ env }`, so `cwd` is
* ignored — the backing sandbox owns its working directory. The buffered `exec`
* timeout is enforced here with `Effect.timeoutOption` (interruption always
* returns control; the underlying command is only truly killed mid-flight on
* signal-aware backends, handled in the provider layer). When the backend exposes
* `stream` (e.g. Vercel `Command.logs`), it is bridged to {@link IToolShell.stream}
* so the bash tool streams over it too.
*/
export const fromSandboxShell: Layer.Layer<ToolShell, never, Shell> = Layer.effect(
ToolShell,
Expand All @@ -119,7 +121,24 @@ export const fromSandboxShell: Layer.Layer<ToolShell, never, Shell> = Layer.effe
);
};

return ToolShell.of({ exec });
// Bridge the backend's optional streaming (e.g. Vercel `Command.logs`) into
// `ToolShell.stream`: stdout/stderr chunks fold into `Output`, `exit` into
// `Exit`. The deadline for the streaming path is the consumer's (the bash tool).
const sandboxStream = shell.stream;
const stream: IToolShell["stream"] = sandboxStream
? (command, options) =>
sandboxStream(command, options?.env ? { env: options.env } : undefined).pipe(
Stream.map(
(chunk): ToolShellEvent =>
chunk._tag === "exit"
? { _tag: "Exit", exitCode: chunk.exitCode }
: { _tag: "Output", bytes: chunk.bytes },
),
Stream.mapError((cause) => new ToolShellError({ command, cause })),
)
: undefined;

return ToolShell.of(stream ? { exec, stream } : { exec });
}),
);

Expand Down
Loading