Skip to content

Commit d1cf9b0

Browse files
sanchitrkclaude
andauthored
fix(cli): stop a run's created sandbox before the harness closes (#75)
* fix(cli): stop a run's created sandbox before the harness closes The run's scope wrapped the provided harness layer, so its finalizers, including stopping the sandbox the run created, ran after the database had closed. Scope inside the provide instead, and log to stderr so stdout carries only the response. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(harness): report missing Daytona paths as ENOENT Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: load credentials from the root .env.local Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(harness): make the runner cycle interrupt deterministic Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: update the changelog Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e288185 commit d1cf9b0

13 files changed

Lines changed: 189 additions & 17 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,16 @@ This file is the canonical source for unreleased changes and published release n
1414
### Fixed
1515

1616
- `@codeworksh/plugin`: Fixed tools whose parameters are `Schema.Struct({})` being rejected by OpenAI and Anthropic. Their JSON Schema is now `{ "type": "object", "properties": {} }` instead of `{ "not": { "type": "null" } }`.
17+
- `@codeworksh/cli`: Fixed `codework run` exiting 1 with `statement has been finalized` after printing the response when it created its own sandbox (`--sandbox-driver memory`, `daytona` or `vercel`). The sandbox was stopped after the database had closed, so the stop failed and a Daytona or Vercel sandbox was left running. It is now stopped before the database closes, and the run exits 0.
18+
- `@codeworksh/cli`: `codework run` now writes logs to stderr, so stdout carries only the response.
19+
- `@codeworksh/harness`: Fixed Daytona sandboxes logging a warning, with the full HTTP error, for every instruction file (`AGENTS.md`, `CLAUDE.md`, ...) a directory does not have. A missing path on Daytona now reads as not found, as on the other sandboxes, which also gives the `read` tool its usual not-found error there.
20+
1721
### Internal
1822

1923
- The `codework tui` E2E artifact now records the CLI version as `<version>`, so it no longer goes stale on every version bump.
24+
- Tests load provider and sandbox credentials from the root `.env.local` instead of one file per package.
25+
- Added a live E2E test that runs `codework run` on a memory, Daytona and Vercel sandbox and checks the sandbox is stopped on exit.
26+
- The live runner-cycle test now interrupts a reply long enough that it cannot finish first, so the interrupt no longer races the model.
2027

2128
## [@codeworksh/cli@0.2.0] - 2026-10-12
2229

‎packages/aikit/test/utils/env.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
33
import { join } from "node:path";
44
import { fileURLToPath } from "node:url";
55

6-
const envPath = fileURLToPath(new URL("../../.env.local", import.meta.url));
6+
const envPath = fileURLToPath(new URL("../../../../.env.local", import.meta.url));
77

88
if (existsSync(envPath)) {
99
process.loadEnvFile(envPath);

‎packages/codework/src/cli/cmd/handlers/run.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { EventList, type EventSchema, Harness, Sandbox, Session } from "@codeworksh/harness/effect";
2-
import { Effect, Exit, Fiber, Option, Queue, Ref, Schema, Stream } from "effect";
2+
import { Effect, Exit, Fiber, Logger, Option, Queue, Ref, Schema, Stream } from "effect";
33
import { Runtime } from "../../../framework/runtime.ts";
44
import { Client } from "../../../server/client.ts";
55
import { InvalidInputError, renderError } from "../../error.ts";
@@ -210,12 +210,19 @@ export default Runtime.handler(
210210
yield* finish(renderState, columns);
211211
});
212212

213+
// Scope inside the provided layer, so the run's finalizers (stopping a created sandbox) close
214+
// while the harness and its database are still open.
213215
const execute = Effect.gen(function* () {
214-
if (Option.isSome(server)) return yield* remote.pipe(Effect.provide(Client.layer(server.value)));
215-
return yield* program.pipe(Effect.provide(Harness.layer(harnessOptions(shared))));
216+
if (Option.isSome(server))
217+
return yield* remote.pipe(Effect.scoped, Effect.provide(Client.layer(server.value)));
218+
// Stdout carries only the response; harness logs go with the rest of the diagnostics.
219+
return yield* program.pipe(
220+
Effect.scoped,
221+
Effect.provide(Harness.layer(harnessOptions(shared))),
222+
Effect.provideService(Logger.LogToStderr, true),
223+
);
216224
});
217225
return yield* execute.pipe(
218-
Effect.scoped,
219226
Effect.catch((error) =>
220227
writeError(renderError(error)).pipe(
221228
Effect.andThen(
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"status": 0,
3+
"bash": [
4+
"completed"
5+
],
6+
"sandboxes": [
7+
{
8+
"driver": "daytona",
9+
"kind": "remote",
10+
"ownership": "managed",
11+
"status": "offline",
12+
"provider_status": "stopped",
13+
"last_error": null
14+
}
15+
]
16+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"status": 0,
3+
"bash": [
4+
"completed"
5+
],
6+
"sandboxes": [
7+
{
8+
"driver": "memory",
9+
"kind": "virtual",
10+
"ownership": "managed",
11+
"status": "offline",
12+
"provider_status": "retained",
13+
"last_error": null
14+
}
15+
]
16+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"status": 0,
3+
"bash": [
4+
"completed"
5+
],
6+
"sandboxes": [
7+
{
8+
"driver": "vercel",
9+
"kind": "remote",
10+
"ownership": "managed",
11+
"status": "offline",
12+
"provider_status": "stopped",
13+
"last_error": null
14+
}
15+
]
16+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/* @effect-diagnostics nodeBuiltinImport:off -- this suite spawns the CLI as a child process. */
2+
import { spawnSync } from "node:child_process";
3+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { DatabaseSync } from "node:sqlite";
7+
import { fileURLToPath } from "node:url";
8+
import { describe, expect, it } from "vite-plus/test";
9+
import "./utils/env.ts";
10+
11+
/**
12+
* A `codework run` that creates its own sandbox stops it on the way out, while the session
13+
* database is still open, and exits 0. The sandbox's final state is read back from that database;
14+
* for a remote driver that includes what the provider reported. A clean stderr also means probing
15+
* for instruction files the sandbox does not have stays silent.
16+
*/
17+
18+
const cli = fileURLToPath(new URL("../src/index.ts", import.meta.url));
19+
const models = fileURLToPath(new URL("../../../models.gen.json", import.meta.url));
20+
21+
const MODEL = { provider: "openrouter", id: "deepseek/deepseek-v4.1-flash", env: "OPENROUTER_API_KEY" } as const;
22+
23+
const DRIVERS = [
24+
{ driver: "memory", env: [] },
25+
{ driver: "daytona", env: ["DAYTONA_API_KEY"] },
26+
{ driver: "vercel", env: ["VERCEL_OIDC_TOKEN"] },
27+
] as const;
28+
29+
const PROMPT = "Run this bash command: echo hi. Reply with its output and nothing else.";
30+
31+
describe.each(DRIVERS)("created $driver sandbox through the CLI", (live) => {
32+
it.skipIf([MODEL.env, ...live.env].some((name) => !process.env[name]?.trim()))(
33+
"stops the sandbox it created and exits 0",
34+
async () => {
35+
const root = mkdtempSync(join(tmpdir(), "codework-cli-sandbox-"));
36+
try {
37+
const project = join(root, "project");
38+
mkdirSync(project);
39+
const home = join(root, "home");
40+
const database = join(root, "session.sqlite");
41+
const result = spawnSync(
42+
process.execPath,
43+
[
44+
"--conditions=development",
45+
cli,
46+
"--home",
47+
home,
48+
"--database",
49+
database,
50+
"run",
51+
"--sandbox-driver",
52+
live.driver,
53+
"--provider",
54+
MODEL.provider,
55+
"--model",
56+
MODEL.id,
57+
PROMPT,
58+
],
59+
{
60+
encoding: "utf8",
61+
cwd: project,
62+
env: { ...process.env, HOME: home, CODEWORK_MODELS_FILE: models },
63+
timeout: 240_000,
64+
},
65+
);
66+
expect(result.status, result.stderr).toBe(0);
67+
expect(result.stdout + result.stderr).not.toMatch(/ERROR|WARN/);
68+
69+
const db = new DatabaseSync(database);
70+
try {
71+
const sandboxes = db
72+
.prepare("SELECT driver, kind, ownership, status, provider_status, last_error FROM sandbox_instance")
73+
.all()
74+
.map((row) => ({ ...row }));
75+
const bash = db
76+
.prepare("SELECT status FROM session_entry_part WHERE type = 'toolCall' AND tool_name = 'bash'")
77+
.all()
78+
.map((row) => String(row["status"]));
79+
expect(bash).toContain("completed");
80+
expect(result.stdout).toContain("hi");
81+
82+
await expect(
83+
`${JSON.stringify({ status: result.status, bash: [...new Set(bash)], sandboxes }, null, "\t")}\n`,
84+
).toMatchFileSnapshot(`./__artifacts__/cli.sandbox.live.${live.driver}.json`);
85+
} finally {
86+
db.close();
87+
}
88+
} finally {
89+
rmSync(root, { recursive: true, force: true });
90+
}
91+
},
92+
300_000,
93+
);
94+
});

‎packages/codework/test/utils/env.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
44
import { join } from "node:path";
55
import { fileURLToPath } from "node:url";
66

7-
const envPath = fileURLToPath(new URL("../../.env.local", import.meta.url));
7+
const envPath = fileURLToPath(new URL("../../../../.env.local", import.meta.url));
88

99
if (existsSync(envPath)) {
1010
process.loadEnvFile(envPath);

‎packages/harness/src/sandboxes/daytona/provider.ts‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -166,17 +166,30 @@ type RemoteFilesystemProvider = Pick<
166166
| "scanLines"
167167
>;
168168

169+
const enoent = (operation: string, path: string) =>
170+
Object.assign(new Error(`ENOENT: no such file or directory, ${operation} '${path}'`), { code: "ENOENT" });
171+
172+
/** The toolbox rejects a missing path with its own 404; the filesystem contract expects `ENOENT`. */
173+
const missing =
174+
(operation: string, path: string) =>
175+
async (cause: unknown): Promise<never> => {
176+
const { DaytonaFileNotFoundError } = await import("@daytona/sdk");
177+
throw cause instanceof DaytonaFileNotFoundError ? Object.assign(enoent(operation, path), { cause }) : cause;
178+
};
179+
169180
const providerFrom = (sandbox: RemoteSandbox, options: Options) => {
170181
const run = async (argv: ReadonlyArray<string>) => {
171182
const result = await sandbox.process.executeCommand(quoteArgv(argv), options.cwd, undefined, options.execTimeout);
172183
return { exitCode: result.exitCode, stdout: result.result ?? "" };
173184
};
174185
const filesystem: RemoteFilesystemProvider = {
175-
readFile: async (path: string) => (await sandbox.fs.downloadFile(path)).toString("utf8"),
176-
readFileBuffer: async (path: string) => new Uint8Array(await sandbox.fs.downloadFile(path)),
186+
readFile: async (path: string) =>
187+
(await sandbox.fs.downloadFile(path).catch(missing("open", path))).toString("utf8"),
188+
readFileBuffer: async (path: string) =>
189+
new Uint8Array(await sandbox.fs.downloadFile(path).catch(missing("open", path))),
177190
writeFile: (path: string, content: string | Uint8Array) =>
178191
sandbox.fs.uploadFile(typeof content === "string" ? Buffer.from(content, "utf8") : Buffer.from(content), path),
179-
stat: async (path: string) => statsFrom(await sandbox.fs.getFileDetails(path)),
192+
stat: async (path: string) => statsFrom(await sandbox.fs.getFileDetails(path).catch(missing("stat", path))),
180193
// The toolbox file-details endpoint follows symlinks. Detect the entry
181194
// with the sandbox shell first so lstat never reports target metadata as
182195
// if it described the link itself.
@@ -186,11 +199,13 @@ const providerFrom = (sandbox: RemoteSandbox, options: Options) => {
186199
if (result.exitCode === 0) {
187200
return { isFile: false, isDirectory: false, isSymbolicLink: true };
188201
}
189-
if (result.exitCode === 1) return statsFrom(await sandbox.fs.getFileDetails(path));
202+
if (result.exitCode === 1)
203+
return statsFrom(await sandbox.fs.getFileDetails(path).catch(missing("lstat", path)));
190204
assertCommandSucceeded(command, result);
191205
throw new Error(`unreachable lstat result for ${path}`);
192206
},
193-
readdir: async (path: string) => (await sandbox.fs.listFiles(path)).map((entry) => entry.name),
207+
readdir: async (path: string) =>
208+
(await sandbox.fs.listFiles(path).catch(missing("scandir", path))).map((entry) => entry.name),
194209
// Only a genuine 404 means "absent". Auth, rate-limit, and transport
195210
// failures propagate: a caller that deletes records on absence must not
196211
// be told a path is gone because the API was briefly unreachable.
@@ -229,8 +244,7 @@ const providerFrom = (sandbox: RemoteSandbox, options: Options) => {
229244
const result = await sandbox.process.executeCommand(command, options.cwd, undefined, options.execTimeout);
230245
if (result.exitCode === 0) return (result.result ?? "").trimEnd();
231246
}
232-
// same shape `fs.stat` rejects with, so `isNotFoundError` recognises it
233-
throw Object.assign(new Error(`ENOENT: no such file or directory, realpath '${path}'`), { code: "ENOENT" });
247+
throw enoent("realpath", path);
234248
},
235249
scanLines: SandboxFileSystem.Scan.viaShell(run),
236250
readBytes: SandboxFileSystem.readBytesViaShell(run),

‎packages/harness/test/fixtures/runner.cycle.spec.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,10 @@ export const runnerCycleSpec = (resourceId: () => Promise<string>) =>
244244
});
245245
yield* Effect.addFinalizer(() => removeDeltaListener);
246246

247+
// Long enough that it cannot finish streaming before the interrupt lands; a short reply
248+
// can complete first and end the turn instead of failing it.
247249
const storyPrompt =
248-
"Using every established detail about Velora, write a vivid story of exactly 100 words. Do not preface or explain it.";
250+
"Using every established detail about Velora, write a vivid story of at least 3,000 words. Do not preface or explain it.";
249251
yield* control.prompt({
250252
sessionId: session.id,
251253
prompt: { parts: [{ type: "text", text: storyPrompt }] },

0 commit comments

Comments
 (0)