From 4c0ab681902a21d79ae8105f59a66600f9b32af4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Tue, 26 May 2026 09:08:33 +0200 Subject: [PATCH 1/4] fix(sandbox): non-destructive stop + reap; auto-start on reattach (openlock-27e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: `openlock stop ` and the 30-minute idle-reaper both routed through `deleteSandbox`, which tears down the container, the workspace volume, and the session-scoped handshake secret in one call. Help text claimed "preserves state" and "no removal" — both lies. A workspace that survived a `Ctrl-C` would silently disappear after half an hour of being idle. Fix: - container.ts: add buildSandboxStopArgv / buildSandboxStartArgv and stopSandbox / startSandbox wrappers around the new openshell sandbox stop / start verbs (vessux/OpenShell#3). - session-ops.ts: stopSession and reapIdleStaleSessions now call stopSandbox instead of deleteSandbox. cleanSession keeps the destructive deleteSandbox call (that is the explicit-teardown path). - session.ts: when reattaching to a session whose container is in "exited" state, call startSandbox before waitForSandboxReady. The supervisor lives inside the container, so without an explicit start the existing wait path would time out. The existing _descriptions.ts already advertises "preserves state" and "no removal" — those statements are now finally true. Other: - package.json: scope `bun test` to `./src/ ./tests/` so the test runner does not pick up the openshell-fork checkout's z3-sys build artifacts (a TypeScript test file shipped inside the vendored z3 source). The wired-up TS layer talks to the fork's new Stop/Start RPCs via the openshell CLI. Dev-mode builds (this repo plus a sibling openshell-fork checkout) pick up the binaries from openshell-fork/target/debug; production installs will need a fork release tag bump once the upstream fork PR merges. --- package.json | 2 +- src/sandbox/container.test.ts | 14 +++++++++++++ src/sandbox/container.ts | 37 +++++++++++++++++++++++++++++++++++ src/sandbox/session-ops.ts | 5 +++-- src/sandbox/session.ts | 4 ++++ 5 files changed, 59 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 4d440da..41277cb 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "scripts": { "start": "bun run src/cli.ts", - "test": "bun test", + "test": "bun test ./src/ ./tests/", "typecheck": "tsc --noEmit", "lint": "biome check .", "lint:fix": "biome check --write .", diff --git a/src/sandbox/container.test.ts b/src/sandbox/container.test.ts index d1b7bb0..290a51a 100644 --- a/src/sandbox/container.test.ts +++ b/src/sandbox/container.test.ts @@ -9,6 +9,8 @@ import { buildSandboxExecRootArgv, buildSandboxGetArgv, buildSandboxListNamesArgv, + buildSandboxStartArgv, + buildSandboxStopArgv, buildSandboxUploadArgv, wrapCmdWithEnv, } from "./container"; @@ -220,6 +222,18 @@ describe("buildSandboxDeleteArgv", () => { }); }); +describe("buildSandboxStopArgv", () => { + it("emits `openshell sandbox stop `", () => { + expect(buildSandboxStopArgv(["cli"], "sess")).toEqual(["cli", "sandbox", "stop", "sess"]); + }); +}); + +describe("buildSandboxStartArgv", () => { + it("emits `openshell sandbox start `", () => { + expect(buildSandboxStartArgv(["cli"], "sess")).toEqual(["cli", "sandbox", "start", "sess"]); + }); +}); + describe("buildSandboxUploadArgv", () => { it("emits `openshell sandbox upload `", () => { expect(buildSandboxUploadArgv(["cli"], "sess", "/host/file", "/sbx/dir")).toEqual([ diff --git a/src/sandbox/container.ts b/src/sandbox/container.ts index f82d6e8..a8551c4 100644 --- a/src/sandbox/container.ts +++ b/src/sandbox/container.ts @@ -238,6 +238,14 @@ export function buildSandboxDeleteArgv(cliPrefix: readonly string[], name: strin return [...cliPrefix, "sandbox", "delete", name]; } +export function buildSandboxStopArgv(cliPrefix: readonly string[], name: string): string[] { + return [...cliPrefix, "sandbox", "stop", name]; +} + +export function buildSandboxStartArgv(cliPrefix: readonly string[], name: string): string[] { + return [...cliPrefix, "sandbox", "start", name]; +} + export function buildSandboxUploadArgv( cliPrefix: readonly string[], name: string, @@ -294,6 +302,35 @@ export async function deleteSandbox(name: string): Promise { await proc.exited; } +// Halt the container without removing it. Workspace volume + cred secret +// survive; reconnect via startSandbox. Used by `openlock stop` and +// reapIdleStaleSessions to avoid destroying user state. +export async function stopSandbox(name: string): Promise { + const cli = await getCliInvocation(); + const argv = buildSandboxStopArgv(cli.argv, name); + const proc = Bun.spawn(argv, { cwd: cli.cwd, stdout: "ignore", stderr: "pipe" }); + const stderr = await new Response(proc.stderr).text(); + const code = await proc.exited; + if (code !== 0) { + throw new Error(`openshell sandbox stop failed (exit ${code}): ${stderr.trim()}`); + } +} + +// Start a previously-stopped container. Idempotent on already-running +// containers. Throws when the backend resource has been pruned (the +// underlying CLI emits the "backend resource missing" warning and exits +// non-zero only on hard errors). +export async function startSandbox(name: string): Promise { + const cli = await getCliInvocation(); + const argv = buildSandboxStartArgv(cli.argv, name); + const proc = Bun.spawn(argv, { cwd: cli.cwd, stdout: "ignore", stderr: "pipe" }); + const stderr = await new Response(proc.stderr).text(); + const code = await proc.exited; + if (code !== 0) { + throw new Error(`openshell sandbox start failed (exit ${code}): ${stderr.trim()}`); + } +} + export async function uploadToSandbox( name: string, localPath: string, diff --git a/src/sandbox/session-ops.ts b/src/sandbox/session-ops.ts index 5fcbb37..3a8abaf 100644 --- a/src/sandbox/session-ops.ts +++ b/src/sandbox/session-ops.ts @@ -6,6 +6,7 @@ import { deleteSandbox, downloadFromSandbox, getSandboxState, + stopSandbox, } from "./container"; import { getCliInvocation } from "./fork-binaries"; import { pruneSandboxRefs } from "./git-sync"; @@ -55,7 +56,7 @@ export async function reapIdleStaleSessions(): Promise<{ const start = Date.now(); await Promise.all( targets.map((r) => - deleteSandbox(`${SANDBOX_PREFIX}${r.meta.name}`).catch((e: unknown) => + stopSandbox(`${SANDBOX_PREFIX}${r.meta.name}`).catch((e: unknown) => console.error(`stop ${r.meta.name}: ${(e as Error).message}`), ), ), @@ -66,7 +67,7 @@ export async function reapIdleStaleSessions(): Promise<{ export async function stopSession(name: string): Promise { const m = await loadSessionByName(name); if (!m) throw new Error(`no such session: ${name}`); - await deleteSandbox(`${SANDBOX_PREFIX}${m.name}`); + await stopSandbox(`${SANDBOX_PREFIX}${m.name}`); console.log(`stopped ${name}`); } diff --git a/src/sandbox/session.ts b/src/sandbox/session.ts index dafd21f..e49050d 100644 --- a/src/sandbox/session.ts +++ b/src/sandbox/session.ts @@ -24,6 +24,7 @@ import { getSandboxState, listSandboxes, openshellSandboxCreateAsync, + startSandbox, waitForSandboxReady, } from "./container"; import { containerfileKeyForCaps, DEFAULT_CONTAINERFILES } from "./default-containerfiles"; @@ -515,6 +516,9 @@ async function reattachSession( } await startGateway(); await ensureProvider(providerId); + if (state === "exited") { + await startSandbox(containerName); + } await waitForSandboxReady(m.name); for (const mount of mounts) { if (mount.type !== "copy-refresh") continue; From b0acd616db71d8b594b5cc27bf9eca8eeca2a372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Tue, 26 May 2026 11:54:57 +0200 Subject: [PATCH 2/4] chore(deps): bump OPENSHELL_FORK_TAG to v0.5.0 Fork v0.5.0 ships the `openshell sandbox stop` / `openshell sandbox start` verbs that this branch's new `stopSandbox`/`startSandbox` wrappers call. Production installs need the binary tarballs that v0.5.0 publishes; dev-mode picks up the local `openshell-fork/target/debug` checkout regardless and ran fine without this bump. See vessux/OpenShell#3 + release notes for v0.5.0. --- src/sandbox/fork-binaries.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sandbox/fork-binaries.ts b/src/sandbox/fork-binaries.ts index 97ef9f0..e25878a 100644 --- a/src/sandbox/fork-binaries.ts +++ b/src/sandbox/fork-binaries.ts @@ -7,7 +7,7 @@ import { forkDir } from "../paths"; // release ships, alongside any matching changes in openlock that depend // on fork-side behavior. const OPENSHELL_FORK_REPO = "vessux/OpenShell"; -export const OPENSHELL_FORK_TAG = "v0.4.0"; +export const OPENSHELL_FORK_TAG = "v0.5.0"; type ForkBinary = "openshell-gateway" | "openshell-sandbox" | "openshell"; From 0c052e03f09632c928f21b538357d41d5583eff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Tue, 26 May 2026 13:30:27 +0200 Subject: [PATCH 3/4] fix(sandbox): name openshell sandboxes without podman prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `openshell sandbox ` operates on the gateway-registered sandbox name (the one passed to `--name` at create), not the underlying podman container name `openshell-sandbox-`. openlock was prefixing every name with `SANDBOX_PREFIX` before invoking the binary, so the gateway always answered NotFound: $ openshell sandbox get openshell-sandbox-foo → NotFound $ openshell sandbox get foo → Ready `deleteSandbox` masked this by silently swallowing stderr/exit code (`{stdout:"ignore", stderr:"ignore"}`), so `openlock clean` reported success while leaving the podman container orphaned — discovered when trying to smoke-test PR-#40's auto-start path which depends on `getSandboxState` returning a non-`missing` value. Drop the prefix from every callsite that talks to the openshell binary (session.ts / session-ops.ts / cli/exec / cli/shell), drop the unused `SANDBOX_PREFIX` constant + file (integration tests hardcode the container name where they hit podman directly), and surface non-NotFound delete failures instead of swallowing them. Also fixes `getSandboxState`: - `openshell sandbox get` has never accepted `-o json` (only `--policy-only` on top of `[name]`), so the JSON.parse path always exit-coded to `"missing"`. Drop the flag, parse the human "Phase: X" line instead. - Add ANSI-stripping + a `parseSandboxGetPhase` unit test. Without this, PR-#40's `if (state === "exited") await startSandbox(...)` in `reattachSession` is dead code: `getSandboxState` returns `"missing"` first and we exit before reaching the start. --- src/cli/exec.ts | 4 +--- src/cli/shell.ts | 4 +--- src/sandbox/constants.ts | 1 - src/sandbox/container.test.ts | 38 +++++++++++++++++++++++++---------- src/sandbox/container.ts | 38 +++++++++++++++++++++++------------ src/sandbox/session-ops.ts | 9 ++++----- src/sandbox/session.ts | 14 +++++++------ 7 files changed, 66 insertions(+), 42 deletions(-) delete mode 100644 src/sandbox/constants.ts diff --git a/src/cli/exec.ts b/src/cli/exec.ts index 8c39383..b61543c 100644 --- a/src/cli/exec.ts +++ b/src/cli/exec.ts @@ -1,6 +1,5 @@ import type { ParseArgsOptionsConfig } from "node:util"; import { parseArgs } from "node:util"; -import { SANDBOX_PREFIX } from "../sandbox/constants"; import { getSandboxState, execCmd as runExec } from "../sandbox/container"; import { printCmdHelp } from "./_help"; import { resolveSessionName } from "./_resolve"; @@ -28,8 +27,7 @@ export async function execCmd(args: string[]): Promise { } const name = await resolveSessionName(positionals[0], "exec into"); if (!name) return 1; - const containerName = `${SANDBOX_PREFIX}${name}`; - const state = await getSandboxState(containerName); + const state = await getSandboxState(name); if (state === "missing") { console.error(`session ${name} has no container`); return 1; diff --git a/src/cli/shell.ts b/src/cli/shell.ts index 9b46a6a..7890768 100644 --- a/src/cli/shell.ts +++ b/src/cli/shell.ts @@ -1,6 +1,5 @@ import type { ParseArgsOptionsConfig } from "node:util"; import { parseArgs } from "node:util"; -import { SANDBOX_PREFIX } from "../sandbox/constants"; import { execBash, getSandboxState } from "../sandbox/container"; import { printCmdHelp } from "./_help"; import { resolveSessionName } from "./_resolve"; @@ -21,8 +20,7 @@ export async function shellCmd(args: string[]): Promise { } const name = await resolveSessionName(positionals[0], "shell into"); if (!name) return 1; - const containerName = `${SANDBOX_PREFIX}${name}`; - const state = await getSandboxState(containerName); + const state = await getSandboxState(name); if (state === "missing") { console.error(`session ${name} has no container`); return 1; diff --git a/src/sandbox/constants.ts b/src/sandbox/constants.ts deleted file mode 100644 index 854011c..0000000 --- a/src/sandbox/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const SANDBOX_PREFIX = "openshell-sandbox-"; diff --git a/src/sandbox/container.test.ts b/src/sandbox/container.test.ts index 290a51a..f132ec8 100644 --- a/src/sandbox/container.test.ts +++ b/src/sandbox/container.test.ts @@ -12,6 +12,7 @@ import { buildSandboxStartArgv, buildSandboxStopArgv, buildSandboxUploadArgv, + parseSandboxGetPhase, wrapCmdWithEnv, } from "./container"; @@ -190,15 +191,8 @@ describe("buildHarnessExecArgv (harness binary selection)", () => { }); describe("buildSandboxGetArgv", () => { - it("emits `openshell sandbox get -o json`", () => { - expect(buildSandboxGetArgv(["cli"], "sess")).toEqual([ - "cli", - "sandbox", - "get", - "sess", - "-o", - "json", - ]); + it("emits `openshell sandbox get `", () => { + expect(buildSandboxGetArgv(["cli"], "sess")).toEqual(["cli", "sandbox", "get", "sess"]); }); it("supports a multi-element cli prefix", () => { @@ -210,12 +204,34 @@ describe("buildSandboxGetArgv", () => { "sandbox", "get", "sess", - "-o", - "json", ]); }); }); +describe("parseSandboxGetPhase", () => { + // openshell sandbox get prints colorized human output, e.g. + // \x1b[1m\x1b[36mSandbox:\x1b[39m\x1b[0m + // \x1b[2mPhase:\x1b[0m Ready + // The parser must strip ANSI escapes and locate the `Phase: ` line. + it("returns 'running' for Ready/Running phase", () => { + expect(parseSandboxGetPhase(" Phase: Ready\n Revision: 1")).toBe("running"); + expect(parseSandboxGetPhase(" Phase: Running")).toBe("running"); + }); + it("returns 'exited' for Failed/Exited/Stopped phase", () => { + expect(parseSandboxGetPhase(" Phase: Failed")).toBe("exited"); + expect(parseSandboxGetPhase(" Phase: Exited")).toBe("exited"); + expect(parseSandboxGetPhase(" Phase: Stopped")).toBe("exited"); + }); + it("returns 'other' when phase is unknown or missing", () => { + expect(parseSandboxGetPhase(" Phase: Provisioning")).toBe("other"); + expect(parseSandboxGetPhase("no phase here")).toBe("other"); + }); + it("strips ANSI color codes around the Phase label and value", () => { + const ansi = "\x1b[1m\x1b[36mSandbox:\x1b[39m\x1b[0m\n\n \x1b[2mPhase:\x1b[0m Ready\n"; + expect(parseSandboxGetPhase(ansi)).toBe("running"); + }); +}); + describe("buildSandboxDeleteArgv", () => { it("emits `openshell sandbox delete `", () => { expect(buildSandboxDeleteArgv(["cli"], "sess")).toEqual(["cli", "sandbox", "delete", "sess"]); diff --git a/src/sandbox/container.ts b/src/sandbox/container.ts index a8551c4..93975b8 100644 --- a/src/sandbox/container.ts +++ b/src/sandbox/container.ts @@ -230,8 +230,11 @@ export async function waitForSandboxReady(name: string, timeoutMs = 60_000): Pro // up on `sandbox delete`. // ============================================================================ +// `openshell sandbox get` accepts only [name] + --policy-only — there is no +// -o/--output flag. We parse the human-formatted output for the `Phase:` line +// (the only field getSandboxState consumes). export function buildSandboxGetArgv(cliPrefix: readonly string[], name: string): string[] { - return [...cliPrefix, "sandbox", "get", name, "-o", "json"]; + return [...cliPrefix, "sandbox", "get", name]; } export function buildSandboxDeleteArgv(cliPrefix: readonly string[], name: string): string[] { @@ -283,23 +286,32 @@ export async function getSandboxState(name: string): Promise { const out = await new Response(proc.stdout).text(); const code = await proc.exited; if (code !== 0) return "missing"; - try { - const json = JSON.parse(out); - // `openshell sandbox get` returns { status: { phase: "Ready"|"Failed"|... }, ... } - const phase = json?.status?.phase ?? json?.phase; - if (phase === "Ready" || phase === "Running") return "running"; - if (phase === "Failed" || phase === "Exited") return "exited"; - return "other"; - } catch { - return "other"; - } + return parseSandboxGetPhase(out); +} + +// Extracts the Phase field from `openshell sandbox get` human output, which +// is colorized + indented like ` Phase: Ready`. Strips ANSI before matching. +export function parseSandboxGetPhase(stdout: string): ContainerState { + // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ESC (0x1b) is the point — stripping ANSI CSI sequences. + const stripped = stdout.replace(/\u001b\[[0-9;]*m/g, ""); + const m = stripped.match(/^\s*Phase:\s*(\S+)/m); + const phase = m?.[1]; + if (phase === "Ready" || phase === "Running") return "running"; + if (phase === "Failed" || phase === "Exited" || phase === "Stopped") return "exited"; + return "other"; } export async function deleteSandbox(name: string): Promise { const cli = await getCliInvocation(); const argv = buildSandboxDeleteArgv(cli.argv, name); - const proc = Bun.spawn(argv, { cwd: cli.cwd, stdout: "ignore", stderr: "ignore" }); - await proc.exited; + const proc = Bun.spawn(argv, { cwd: cli.cwd, stdout: "ignore", stderr: "pipe" }); + const stderr = await new Response(proc.stderr).text(); + const code = await proc.exited; + // NotFound is fine — clean is idempotent; surface other failures so we + // don't leave orphaned podman containers while pretending success. + if (code !== 0 && !/sandbox not found|NotFound/.test(stderr)) { + throw new Error(`openshell sandbox delete failed (exit ${code}): ${stderr.trim()}`); + } } // Halt the container without removing it. Workspace volume + cred secret diff --git a/src/sandbox/session-ops.ts b/src/sandbox/session-ops.ts index 3a8abaf..181026a 100644 --- a/src/sandbox/session-ops.ts +++ b/src/sandbox/session-ops.ts @@ -1,6 +1,5 @@ import { rmSync } from "node:fs"; import { resolve } from "node:path"; -import { SANDBOX_PREFIX } from "./constants"; import { buildOpenshellExecArgv, deleteSandbox, @@ -22,7 +21,7 @@ export async function loadSessionByName(name: string): Promise { - const containerState = await getSandboxState(`${SANDBOX_PREFIX}${m.name}`); + const containerState = await getSandboxState(m.name); return { ...m, containerState, @@ -56,7 +55,7 @@ export async function reapIdleStaleSessions(): Promise<{ const start = Date.now(); await Promise.all( targets.map((r) => - stopSandbox(`${SANDBOX_PREFIX}${r.meta.name}`).catch((e: unknown) => + stopSandbox(r.meta.name).catch((e: unknown) => console.error(`stop ${r.meta.name}: ${(e as Error).message}`), ), ), @@ -67,7 +66,7 @@ export async function reapIdleStaleSessions(): Promise<{ export async function stopSession(name: string): Promise { const m = await loadSessionByName(name); if (!m) throw new Error(`no such session: ${name}`); - await stopSandbox(`${SANDBOX_PREFIX}${m.name}`); + await stopSandbox(m.name); console.log(`stopped ${name}`); } @@ -79,7 +78,7 @@ export interface CleanOpts { export async function cleanSession(name: string, opts: CleanOpts = {}): Promise { const m = await loadSessionByName(name); if (!m) throw new Error(`no such session: ${name}`); - const containerName = `${SANDBOX_PREFIX}${m.name}`; + const containerName = m.name; if (opts.copyDir) { const dest = resolve(opts.copyDir); diff --git a/src/sandbox/session.ts b/src/sandbox/session.ts index e49050d..a650037 100644 --- a/src/sandbox/session.ts +++ b/src/sandbox/session.ts @@ -14,7 +14,6 @@ import type { ProviderId } from "../providers/types"; import { type Runtime, resolveRuntime } from "../runtime"; import { readToken } from "../tokens"; import { validateBranchFlagAgainstWorkdir } from "./branch-validation"; -import { SANDBOX_PREFIX } from "./constants"; import { buildOpenshellExecArgv, buildSandboxEnv, @@ -149,7 +148,10 @@ async function createSession( const id = newSessionId(); const name = friendlyNameFromId(basename(projectPath), id); - const containerName = `${SANDBOX_PREFIX}${name}`; + // openshell registers the sandbox under its CLI --name; the podman container + // happens to be named `openshell-sandbox-` but openshell verbs + // (get/exec/stop/start/delete) take the gateway name (unprefixed). + const containerName = name; const tmp = mkdtempSync(join(tmpdir(), "openlock-")); try { @@ -497,7 +499,7 @@ async function reattachSession( mounts: readonly Mount[], providerId: ProviderId, ): Promise { - const containerName = `${SANDBOX_PREFIX}${m.name}`; + const containerName = m.name; const state = await getSandboxState(containerName); if (state === "missing") { console.error( @@ -674,9 +676,9 @@ export async function runSandbox(opts: SandboxOpts): Promise { harness, }; const exitCode = await attachHarnessAndSync(containerName, sessionName, launch, resolved.mounts); - // listSandboxes returns all states; gateway should only stay up for OTHER - // currently-running sandboxes, so filter via getSandboxState. - const others = (await listSandboxes(SANDBOX_PREFIX)).filter((n) => n !== containerName); + // listSandboxes returns all gateway-tracked sandboxes; gateway should only + // stay up for OTHER currently-running sandboxes, so filter via getSandboxState. + const others = (await listSandboxes()).filter((n) => n !== containerName); const otherStates = await Promise.all(others.map((n) => getSandboxState(n))); const stillRunning = others.filter((_, i) => otherStates[i] === "running"); handleGatewayShutdown(stillRunning.length); From 415aba4458b08caa6797f4a9ced2c7371dd1f371 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kova=C4=BE?= Date: Tue, 26 May 2026 15:38:33 +0200 Subject: [PATCH 4/4] fix(sandbox): map openshell Phase=Error to "exited" (no Stopped variant) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fork v0.5.0 has no SandboxPhase::Stopped — an explicit `openshell sandbox stop` transitions phase Ready -> Error because the watch loop treats any ContainerExited as a terminal failure. Before this, parseSandboxGetPhase mapped Error to "other"; reattachSession then printed "Attaching to running" but did not invoke startSandbox, and waitForSandboxReady hung probing a stopped container. Mapping Error to "exited" lets reattach trigger startSandbox; genuine provisioning failures still surface when startSandbox/waitForSandboxReady fail. Tracked as bd openlock-z9i (fork-side fix: add Stopped variant or intentional-stop flag). --- src/sandbox/container.test.ts | 4 +++- src/sandbox/container.ts | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/sandbox/container.test.ts b/src/sandbox/container.test.ts index f132ec8..3f2c78a 100644 --- a/src/sandbox/container.test.ts +++ b/src/sandbox/container.test.ts @@ -217,10 +217,12 @@ describe("parseSandboxGetPhase", () => { expect(parseSandboxGetPhase(" Phase: Ready\n Revision: 1")).toBe("running"); expect(parseSandboxGetPhase(" Phase: Running")).toBe("running"); }); - it("returns 'exited' for Failed/Exited/Stopped phase", () => { + it("returns 'exited' for Failed/Exited/Stopped/Error phase", () => { expect(parseSandboxGetPhase(" Phase: Failed")).toBe("exited"); expect(parseSandboxGetPhase(" Phase: Exited")).toBe("exited"); expect(parseSandboxGetPhase(" Phase: Stopped")).toBe("exited"); + // Fork v0.5.0 has no Stopped variant — explicit stop transitions Ready → Error. + expect(parseSandboxGetPhase(" Phase: Error")).toBe("exited"); }); it("returns 'other' when phase is unknown or missing", () => { expect(parseSandboxGetPhase(" Phase: Provisioning")).toBe("other"); diff --git a/src/sandbox/container.ts b/src/sandbox/container.ts index 93975b8..0a05eed 100644 --- a/src/sandbox/container.ts +++ b/src/sandbox/container.ts @@ -297,7 +297,13 @@ export function parseSandboxGetPhase(stdout: string): ContainerState { const m = stripped.match(/^\s*Phase:\s*(\S+)/m); const phase = m?.[1]; if (phase === "Ready" || phase === "Running") return "running"; - if (phase === "Failed" || phase === "Exited" || phase === "Stopped") return "exited"; + // Fork v0.5.0 has no Stopped variant: explicit `openshell sandbox stop` + // transitions phase Ready → Error (watch loop treats container exit as + // terminal failure). Mapping Error → "exited" lets reattach trigger + // startSandbox; genuine provisioning failures surface when startSandbox + // or waitForSandboxReady fail. + if (phase === "Failed" || phase === "Exited" || phase === "Stopped" || phase === "Error") + return "exited"; return "other"; }