From 99c8e1b1755b95f5e79ccbf813d11bd02f7e513f Mon Sep 17 00:00:00 2001 From: AndyS77 Date: Wed, 19 Aug 2026 17:32:55 +0200 Subject: [PATCH 1/8] docs: add crash recovery plan --- plans/PLAN-crash-recovery.md | 82 ++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 plans/PLAN-crash-recovery.md diff --git a/plans/PLAN-crash-recovery.md b/plans/PLAN-crash-recovery.md new file mode 100644 index 000000000000..dad8b63cb473 --- /dev/null +++ b/plans/PLAN-crash-recovery.md @@ -0,0 +1,82 @@ +# Plan: Crash Recovery — Auto-Restart + Session Resume + +## Problem + +When opencode crashes (SIGKILL, panic, OOM, power loss), all in-memory session +status is lost. Sessions that were "busy" at crash time are left in an +indeterminate state. There is no mechanism to detect the crash on next startup +and resume the previously active sessions. + +## Design + +### 1. Active Sessions Manifest (`packages/opencode/src/session/active-manifest.ts`) + +A JSON file stored at `Global.Path.data/active-sessions.json` that tracks which +sessions are currently busy. + +**Schema:** +```json +{ + "sessions": [ + { + "id": "session-uuid", + "model": { "id": "model-id", "providerID": "provider-id", "variant": "variant-name" }, + "agent": "build", + "timestamp": 1234567890 + } + ] +} +``` + +**API (pure functions using Bun.file):** +- `writeActiveSession(entry)` — add/update a session in the manifest +- `removeActiveSession(sessionID)` — remove a session from the manifest +- `readActiveSessions()` — read the manifest (returns session list) +- `clearActiveSessions()` — delete the manifest file (clean shutdown sentinel) +- `hasCrashed()` — returns true if manifest file exists (crash detected) + +### 2. Config Option (`packages/core/src/v1/config/config.ts`) + +Add `session` field: +```ts +session: Schema.optional(Schema.Struct({ + auto_resume: Schema.optional(Schema.Boolean).annotate({ + description: "Automatically resume sessions that were active when opencode crashed (default: false)" + }) +})) +``` + +### 3. SessionStatus Hook (`packages/opencode/src/session/status.ts`) + +When `set` is called: +- `type: "busy"` → write session to manifest +- `type: "idle"` → remove session from manifest + +### 4. Crash Detection on Startup (`packages/opencode/src/cli/cmd/run/runtime.ts`) + +Before session resolution: +- If `hasCrashed()` AND `config.session?.auto_resume` → read manifest, resume sessions +- If no crash or auto_resume disabled → proceed normally +- After resuming, clear the manifest + +### 5. Graceful Shutdown (`packages/opencode/src/index.ts`) + +On SIGINT/SIGTERM and in the `finally` block before `process.exit()`: +- Call `clearActiveSessions()` to mark a clean shutdown + +## Acceptance Criteria + +1. Given a session is busy, when opencode crashes, then the manifest persists with the session ID +2. Given opencode starts and manifest exists AND auto_resume is on, then sessions are resumed +3. Given opencode starts and manifest is absent, then no auto-resume +4. Given opencode exits gracefully, then manifest is deleted (clean sentinel) +5. Given auto_resume is false/unset, then no auto-resume even after crash +6. Given a session in manifest no longer exists in DB, then it is skipped + +## Test Strategy + +- Unit tests for manifest read/write/clear operations (temp dirs) +- Test that busy→manifest write happens +- Test that idle→manifest remove happens +- Test crash detection (manifest exists = crash) +- Test clean shutdown clears manifest From 67cf47ad1d46181603eb505b830dc18dee21350d Mon Sep 17 00:00:00 2001 From: AndyS77 Date: Wed, 19 Aug 2026 17:34:23 +0200 Subject: [PATCH 2/8] test(session): active manifest crash recovery (RED) --- .../test/session/active-manifest.test.ts | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 packages/opencode/test/session/active-manifest.test.ts diff --git a/packages/opencode/test/session/active-manifest.test.ts b/packages/opencode/test/session/active-manifest.test.ts new file mode 100644 index 000000000000..5b279af4c22f --- /dev/null +++ b/packages/opencode/test/session/active-manifest.test.ts @@ -0,0 +1,103 @@ +import { expect } from "bun:test" +import { Effect, Layer } from "effect" +import { testEffect } from "../lib/effect" +import { provideTmpdirInstance } from "../fixture/fixture" +import { ActiveManifest } from "@/session/active-manifest" + +const it = testEffect(Layer.empty) + +const sampleEntry = { + id: "session-001", + model: { id: "claude-sonnet", providerID: "anthropic" }, + agent: "build", + timestamp: Date.now(), +} + +it.live("writeActiveSession creates manifest with session", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* ActiveManifest.write(sampleEntry) + const sessions = yield* ActiveManifest.read() + expect(sessions).toHaveLength(1) + expect(sessions[0].id).toBe("session-001") + }), + ), +) + +it.live("writeActiveSession adds to existing manifest", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* ActiveManifest.write(sampleEntry) + yield* ActiveManifest.write({ + id: "session-002", + model: { id: "gpt-4", providerID: "openai" }, + agent: "general", + timestamp: Date.now(), + }) + const sessions = yield* ActiveManifest.read() + expect(sessions).toHaveLength(2) + }), + ), +) + +it.live("writeActiveSession updates existing session", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* ActiveManifest.write(sampleEntry) + yield* ActiveManifest.write({ ...sampleEntry, agent: "plan" }) + const sessions = yield* ActiveManifest.read() + expect(sessions).toHaveLength(1) + expect(sessions[0].agent).toBe("plan") + }), + ), +) + +it.live("removeActiveSession removes from manifest", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* ActiveManifest.write(sampleEntry) + yield* ActiveManifest.remove("session-001") + const sessions = yield* ActiveManifest.read() + expect(sessions).toHaveLength(0) + }), + ), +) + +it.live("clearActiveSessions deletes the manifest file", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* ActiveManifest.write(sampleEntry) + yield* ActiveManifest.clear() + const sessions = yield* ActiveManifest.read() + expect(sessions).toHaveLength(0) + }), + ), +) + +it.live("hasCrashed returns false when no manifest exists", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const crashed = yield* ActiveManifest.hasCrashed() + expect(crashed).toBe(false) + }), + ), +) + +it.live("hasCrashed returns true when manifest exists", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* ActiveManifest.write(sampleEntry) + const crashed = yield* ActiveManifest.hasCrashed() + expect(crashed).toBe(true) + }), + ), +) + +it.live("read returns empty array when manifest does not exist", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const sessions = yield* ActiveManifest.read() + expect(sessions).toHaveLength(0) + }), + ), +) From 97eca0eda1fe1d849775405631b22e45916a39ff Mon Sep 17 00:00:00 2001 From: AndyS77 Date: Wed, 19 Aug 2026 17:48:39 +0200 Subject: [PATCH 3/8] feat(session): active manifest for crash recovery (GREEN) --- .../opencode/src/session/active-manifest.ts | 73 ++++++++ .../test/session/active-manifest.test.ts | 171 +++++++++--------- 2 files changed, 160 insertions(+), 84 deletions(-) create mode 100644 packages/opencode/src/session/active-manifest.ts diff --git a/packages/opencode/src/session/active-manifest.ts b/packages/opencode/src/session/active-manifest.ts new file mode 100644 index 000000000000..e22fe6c2e531 --- /dev/null +++ b/packages/opencode/src/session/active-manifest.ts @@ -0,0 +1,73 @@ +import path from "path" +import { Global } from "@opencode-ai/core/global" +import { Effect } from "effect" + +export type ActiveSession = { + id: string + model: { id: string; providerID: string; variant?: string } + agent: string | undefined + timestamp: number +} + +type Manifest = { sessions: ActiveSession[] } + +let manifestDir = Global.Path.data + +export function setManifestDir(dir: string) { + manifestDir = dir +} + +function manifestPath() { + return path.join(manifestDir, "active-sessions.json") +} + +async function readRaw(): Promise { + const file = Bun.file(manifestPath()) + const exists = await file.exists() + if (!exists) return { sessions: [] } + return file.json().catch(() => ({ sessions: [] })) +} + +async function writeRaw(manifest: Manifest): Promise { + await Bun.write(manifestPath(), JSON.stringify(manifest)) +} + +const write = Effect.fn("ActiveManifest.write")(function* (entry: ActiveSession) { + yield* Effect.promise(async () => { + const manifest = await readRaw() + const without = manifest.sessions.filter((s) => s.id !== entry.id) + without.push(entry) + await writeRaw({ sessions: without }) + }) +}) + +const remove = Effect.fn("ActiveManifest.remove")(function* (sessionID: string) { + yield* Effect.promise(async () => { + const manifest = await readRaw() + const without = manifest.sessions.filter((s) => s.id !== sessionID) + await writeRaw({ sessions: without }) + }) +}) + +const read = Effect.fn("ActiveManifest.read")(function* () { + return yield* Effect.promise(() => readRaw().then((m) => m.sessions)) +}) + +const clear = Effect.fn("ActiveManifest.clear")(function* () { + yield* Effect.promise(async () => { + const file = Bun.file(manifestPath()) + if (await file.exists()) await file.unlink() + }) +}) + +const hasCrashed = Effect.fn("ActiveManifest.hasCrashed")(function* () { + return yield* Effect.promise(() => Bun.file(manifestPath()).exists()) +}) + +export const ActiveManifest = { + write, + remove, + read, + clear, + hasCrashed, +} diff --git a/packages/opencode/test/session/active-manifest.test.ts b/packages/opencode/test/session/active-manifest.test.ts index 5b279af4c22f..68d20c55b436 100644 --- a/packages/opencode/test/session/active-manifest.test.ts +++ b/packages/opencode/test/session/active-manifest.test.ts @@ -1,10 +1,9 @@ -import { expect } from "bun:test" -import { Effect, Layer } from "effect" -import { testEffect } from "../lib/effect" -import { provideTmpdirInstance } from "../fixture/fixture" -import { ActiveManifest } from "@/session/active-manifest" - -const it = testEffect(Layer.empty) +import { expect, test } from "bun:test" +import { Effect } from "effect" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { ActiveManifest, setManifestDir } from "@/session/active-manifest" const sampleEntry = { id: "session-001", @@ -13,91 +12,95 @@ const sampleEntry = { timestamp: Date.now(), } -it.live("writeActiveSession creates manifest with session", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* ActiveManifest.write(sampleEntry) - const sessions = yield* ActiveManifest.read() - expect(sessions).toHaveLength(1) - expect(sessions[0].id).toBe("session-001") - }), - ), -) +async function withTmpDir(fn: (dir: string) => Promise) { + const dir = path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)) + await fs.mkdir(dir, { recursive: true }) + try { + await fn(dir) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } +} + +test("writeActiveSession creates manifest with session", async () => { + await withTmpDir(async (dir) => { + setManifestDir(dir) + await Effect.runPromise(ActiveManifest.write(sampleEntry)) + const sessions = await Effect.runPromise(ActiveManifest.read()) + expect(sessions).toHaveLength(1) + expect(sessions[0].id).toBe("session-001") + }) +}) -it.live("writeActiveSession adds to existing manifest", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* ActiveManifest.write(sampleEntry) - yield* ActiveManifest.write({ +test("writeActiveSession adds to existing manifest", async () => { + await withTmpDir(async (dir) => { + setManifestDir(dir) + await Effect.runPromise(ActiveManifest.write(sampleEntry)) + await Effect.runPromise( + ActiveManifest.write({ id: "session-002", model: { id: "gpt-4", providerID: "openai" }, agent: "general", timestamp: Date.now(), - }) - const sessions = yield* ActiveManifest.read() - expect(sessions).toHaveLength(2) - }), - ), -) + }), + ) + const sessions = await Effect.runPromise(ActiveManifest.read()) + expect(sessions).toHaveLength(2) + }) +}) -it.live("writeActiveSession updates existing session", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* ActiveManifest.write(sampleEntry) - yield* ActiveManifest.write({ ...sampleEntry, agent: "plan" }) - const sessions = yield* ActiveManifest.read() - expect(sessions).toHaveLength(1) - expect(sessions[0].agent).toBe("plan") - }), - ), -) +test("writeActiveSession updates existing session", async () => { + await withTmpDir(async (dir) => { + setManifestDir(dir) + await Effect.runPromise(ActiveManifest.write(sampleEntry)) + await Effect.runPromise(ActiveManifest.write({ ...sampleEntry, agent: "plan" })) + const sessions = await Effect.runPromise(ActiveManifest.read()) + expect(sessions).toHaveLength(1) + expect(sessions[0].agent).toBe("plan") + }) +}) -it.live("removeActiveSession removes from manifest", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* ActiveManifest.write(sampleEntry) - yield* ActiveManifest.remove("session-001") - const sessions = yield* ActiveManifest.read() - expect(sessions).toHaveLength(0) - }), - ), -) +test("removeActiveSession removes from manifest", async () => { + await withTmpDir(async (dir) => { + setManifestDir(dir) + await Effect.runPromise(ActiveManifest.write(sampleEntry)) + await Effect.runPromise(ActiveManifest.remove("session-001")) + const sessions = await Effect.runPromise(ActiveManifest.read()) + expect(sessions).toHaveLength(0) + }) +}) -it.live("clearActiveSessions deletes the manifest file", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* ActiveManifest.write(sampleEntry) - yield* ActiveManifest.clear() - const sessions = yield* ActiveManifest.read() - expect(sessions).toHaveLength(0) - }), - ), -) +test("clearActiveSessions deletes the manifest file", async () => { + await withTmpDir(async (dir) => { + setManifestDir(dir) + await Effect.runPromise(ActiveManifest.write(sampleEntry)) + await Effect.runPromise(ActiveManifest.clear()) + const sessions = await Effect.runPromise(ActiveManifest.read()) + expect(sessions).toHaveLength(0) + }) +}) -it.live("hasCrashed returns false when no manifest exists", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const crashed = yield* ActiveManifest.hasCrashed() - expect(crashed).toBe(false) - }), - ), -) +test("hasCrashed returns false when no manifest exists", async () => { + await withTmpDir(async (dir) => { + setManifestDir(dir) + const crashed = await Effect.runPromise(ActiveManifest.hasCrashed()) + expect(crashed).toBe(false) + }) +}) -it.live("hasCrashed returns true when manifest exists", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - yield* ActiveManifest.write(sampleEntry) - const crashed = yield* ActiveManifest.hasCrashed() - expect(crashed).toBe(true) - }), - ), -) +test("hasCrashed returns true when manifest exists", async () => { + await withTmpDir(async (dir) => { + setManifestDir(dir) + await Effect.runPromise(ActiveManifest.write(sampleEntry)) + const crashed = await Effect.runPromise(ActiveManifest.hasCrashed()) + expect(crashed).toBe(true) + }) +}) -it.live("read returns empty array when manifest does not exist", () => - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const sessions = yield* ActiveManifest.read() - expect(sessions).toHaveLength(0) - }), - ), -) +test("read returns empty array when manifest does not exist", async () => { + await withTmpDir(async (dir) => { + setManifestDir(dir) + const sessions = await Effect.runPromise(ActiveManifest.read()) + expect(sessions).toHaveLength(0) + }) +}) From 965f614a566526424f8e3ea5256877abb07bbef6 Mon Sep 17 00:00:00 2001 From: AndyS77 Date: Wed, 19 Aug 2026 18:07:24 +0200 Subject: [PATCH 4/8] feat(session): wire crash recovery into status, config, startup, shutdown --- packages/core/src/v1/config/config.ts | 8 +++++++ packages/opencode/src/cli/cmd/run.ts | 21 +++++++++++++++++++ packages/opencode/src/index.ts | 5 +++++ .../opencode/src/session/active-manifest.ts | 2 -- packages/opencode/src/session/status.ts | 3 +++ 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/core/src/v1/config/config.ts b/packages/core/src/v1/config/config.ts index 7ebb4b69b023..03ea98af299c 100644 --- a/packages/core/src/v1/config/config.ts +++ b/packages/core/src/v1/config/config.ts @@ -187,6 +187,14 @@ export const Info = Schema.Struct({ }), }), ), + session: Schema.optional( + Schema.Struct({ + auto_resume: Schema.optional(Schema.Boolean).annotate({ + description: + "Automatically resume sessions that were active when opencode crashed (default: false)", + }), + }), + ).annotate({ description: "Session crash recovery configuration" }), }).annotate({ identifier: "Config" }) export type Info = DeepMutable> diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 3927f615a080..9b40e041b5a9 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -18,6 +18,7 @@ import path from "path" import { pathToFileURL } from "url" import { open } from "node:fs/promises" import { Effect } from "effect" +import { ActiveManifest } from "@/session/active-manifest" import { UI } from "../ui" import { effectCmd } from "../effect-cmd" import { EOL } from "os" @@ -491,6 +492,26 @@ export const RunCommand = effectCmd({ const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined + if (!args.continue && !args.session) { + const crashed = await Effect.runPromise(ActiveManifest.hasCrashed()).catch(() => false) + if (crashed) { + const cfg = await sdk.config.get() + if (cfg.data?.session?.auto_resume) { + const active = await Effect.runPromise(ActiveManifest.read()).catch(() => []) + if (active.length > 0) { + UI.println(UI.Style.TEXT_WARNING_BOLD + "!" + UI.Style.TEXT_NORMAL + ` crash detected — resuming ${active.length} active session(s)`) + await Effect.runPromise(ActiveManifest.clear()).catch(() => {}) + return { + id: active[0].id, + title: undefined, + directory: undefined, + } + } + } + await Effect.runPromise(ActiveManifest.clear()).catch(() => {}) + } + } + if (base && args.fork) { const forked = await sdk.session.fork({ sessionID: base.id, diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 13540a73a36f..8e421c1708bb 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -29,6 +29,8 @@ import { DbCommand } from "./cli/cmd/db" import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" +import { ActiveManifest } from "./session/active-manifest" +import { Effect } from "effect" const args = hideBin(process.argv) @@ -134,6 +136,9 @@ try { } process.exitCode = 1 } finally { + // Clean shutdown: clear the active-sessions manifest so the next startup + // knows the previous process exited cleanly (not a crash). + await Effect.runPromise(ActiveManifest.clear()).catch(() => {}) // Some subprocesses don't react properly to SIGTERM and similar signals. // Most notably, some docker-container-based MCP servers don't handle such signals unless // run using `docker run --init`. diff --git a/packages/opencode/src/session/active-manifest.ts b/packages/opencode/src/session/active-manifest.ts index e22fe6c2e531..0c699c241fb5 100644 --- a/packages/opencode/src/session/active-manifest.ts +++ b/packages/opencode/src/session/active-manifest.ts @@ -4,8 +4,6 @@ import { Effect } from "effect" export type ActiveSession = { id: string - model: { id: string; providerID: string; variant?: string } - agent: string | undefined timestamp: number } diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 11140acfeef5..96a956ad21d3 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -4,6 +4,7 @@ import { SessionID } from "./schema" import { Effect, Layer, Context } from "effect" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event" +import { ActiveManifest } from "./active-manifest" export const Info = SessionStatusEvent.Info export type Info = SessionStatusEvent.Info @@ -42,9 +43,11 @@ const layer = Layer.effect( if (status.type === "idle") { yield* events.publish(Event.Idle, { sessionID }) data.delete(sessionID) + yield* ActiveManifest.remove(sessionID) return } data.set(sessionID, status) + yield* ActiveManifest.write({ id: sessionID, timestamp: Date.now() }) }) return Service.of({ get, list, set }) From eaf09239ad6708bd3a7b947e6b332a2d26d24614 Mon Sep 17 00:00:00 2001 From: AndyS77 Date: Wed, 19 Aug 2026 18:13:45 +0200 Subject: [PATCH 5/8] chore: remove plan file --- plans/PLAN-crash-recovery.md | 82 ------------------------------------ 1 file changed, 82 deletions(-) delete mode 100644 plans/PLAN-crash-recovery.md diff --git a/plans/PLAN-crash-recovery.md b/plans/PLAN-crash-recovery.md deleted file mode 100644 index dad8b63cb473..000000000000 --- a/plans/PLAN-crash-recovery.md +++ /dev/null @@ -1,82 +0,0 @@ -# Plan: Crash Recovery — Auto-Restart + Session Resume - -## Problem - -When opencode crashes (SIGKILL, panic, OOM, power loss), all in-memory session -status is lost. Sessions that were "busy" at crash time are left in an -indeterminate state. There is no mechanism to detect the crash on next startup -and resume the previously active sessions. - -## Design - -### 1. Active Sessions Manifest (`packages/opencode/src/session/active-manifest.ts`) - -A JSON file stored at `Global.Path.data/active-sessions.json` that tracks which -sessions are currently busy. - -**Schema:** -```json -{ - "sessions": [ - { - "id": "session-uuid", - "model": { "id": "model-id", "providerID": "provider-id", "variant": "variant-name" }, - "agent": "build", - "timestamp": 1234567890 - } - ] -} -``` - -**API (pure functions using Bun.file):** -- `writeActiveSession(entry)` — add/update a session in the manifest -- `removeActiveSession(sessionID)` — remove a session from the manifest -- `readActiveSessions()` — read the manifest (returns session list) -- `clearActiveSessions()` — delete the manifest file (clean shutdown sentinel) -- `hasCrashed()` — returns true if manifest file exists (crash detected) - -### 2. Config Option (`packages/core/src/v1/config/config.ts`) - -Add `session` field: -```ts -session: Schema.optional(Schema.Struct({ - auto_resume: Schema.optional(Schema.Boolean).annotate({ - description: "Automatically resume sessions that were active when opencode crashed (default: false)" - }) -})) -``` - -### 3. SessionStatus Hook (`packages/opencode/src/session/status.ts`) - -When `set` is called: -- `type: "busy"` → write session to manifest -- `type: "idle"` → remove session from manifest - -### 4. Crash Detection on Startup (`packages/opencode/src/cli/cmd/run/runtime.ts`) - -Before session resolution: -- If `hasCrashed()` AND `config.session?.auto_resume` → read manifest, resume sessions -- If no crash or auto_resume disabled → proceed normally -- After resuming, clear the manifest - -### 5. Graceful Shutdown (`packages/opencode/src/index.ts`) - -On SIGINT/SIGTERM and in the `finally` block before `process.exit()`: -- Call `clearActiveSessions()` to mark a clean shutdown - -## Acceptance Criteria - -1. Given a session is busy, when opencode crashes, then the manifest persists with the session ID -2. Given opencode starts and manifest exists AND auto_resume is on, then sessions are resumed -3. Given opencode starts and manifest is absent, then no auto-resume -4. Given opencode exits gracefully, then manifest is deleted (clean sentinel) -5. Given auto_resume is false/unset, then no auto-resume even after crash -6. Given a session in manifest no longer exists in DB, then it is skipped - -## Test Strategy - -- Unit tests for manifest read/write/clear operations (temp dirs) -- Test that busy→manifest write happens -- Test that idle→manifest remove happens -- Test crash detection (manifest exists = crash) -- Test clean shutdown clears manifest From 908ab4109d2487ae7a8cd71da4dd1c494c73ddc8 Mon Sep 17 00:00:00 2001 From: AndyS77 Date: Wed, 19 Aug 2026 18:36:34 +0200 Subject: [PATCH 6/8] =?UTF-8?q?fix(session):=20address=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20error-safe=20manifest,=20atomic=20writes,=20sess?= =?UTF-8?q?ion=20validation,=20config=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/src/cli/cmd/run.ts | 20 +++++++++------ .../opencode/src/session/active-manifest.ts | 25 +++++++++++++------ .../test/session/active-manifest.test.ts | 18 ++++++++----- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 9b40e041b5a9..4dd2bac06180 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -495,16 +495,20 @@ export const RunCommand = effectCmd({ if (!args.continue && !args.session) { const crashed = await Effect.runPromise(ActiveManifest.hasCrashed()).catch(() => false) if (crashed) { - const cfg = await sdk.config.get() - if (cfg.data?.session?.auto_resume) { + const cfg = await sdk.config.get().catch(() => undefined) + if (cfg?.data?.session?.auto_resume) { const active = await Effect.runPromise(ActiveManifest.read()).catch(() => []) if (active.length > 0) { - UI.println(UI.Style.TEXT_WARNING_BOLD + "!" + UI.Style.TEXT_NORMAL + ` crash detected — resuming ${active.length} active session(s)`) - await Effect.runPromise(ActiveManifest.clear()).catch(() => {}) - return { - id: active[0].id, - title: undefined, - directory: undefined, + const candidate = active[0] + const existing = await sdk.session.get({ sessionID: candidate.id }).catch(() => undefined) + if (existing?.data) { + UI.println(UI.Style.TEXT_WARNING_BOLD + "!" + UI.Style.TEXT_NORMAL + ` crash detected — resuming last active session`) + await Effect.runPromise(ActiveManifest.clear()).catch(() => {}) + return { + id: candidate.id, + title: existing.data.title, + directory: existing.data.directory, + } } } } diff --git a/packages/opencode/src/session/active-manifest.ts b/packages/opencode/src/session/active-manifest.ts index 0c699c241fb5..8b91426cd685 100644 --- a/packages/opencode/src/session/active-manifest.ts +++ b/packages/opencode/src/session/active-manifest.ts @@ -1,4 +1,5 @@ -import path from "path" +import path from "node:path" +import fs from "node:fs/promises" import { Global } from "@opencode-ai/core/global" import { Effect } from "effect" @@ -27,7 +28,9 @@ async function readRaw(): Promise { } async function writeRaw(manifest: Manifest): Promise { - await Bun.write(manifestPath(), JSON.stringify(manifest)) + const tmp = manifestPath() + ".tmp" + await Bun.write(tmp, JSON.stringify(manifest)) + await fs.rename(tmp, manifestPath()) } const write = Effect.fn("ActiveManifest.write")(function* (entry: ActiveSession) { @@ -43,12 +46,18 @@ const remove = Effect.fn("ActiveManifest.remove")(function* (sessionID: string) yield* Effect.promise(async () => { const manifest = await readRaw() const without = manifest.sessions.filter((s) => s.id !== sessionID) + if (without.length === 0) { + const file = Bun.file(manifestPath()) + if (await file.exists()) await file.unlink() + return + } await writeRaw({ sessions: without }) }) }) const read = Effect.fn("ActiveManifest.read")(function* () { - return yield* Effect.promise(() => readRaw().then((m) => m.sessions)) + const manifest = yield* Effect.promise(() => readRaw()) + return manifest.sessions }) const clear = Effect.fn("ActiveManifest.clear")(function* () { @@ -63,9 +72,9 @@ const hasCrashed = Effect.fn("ActiveManifest.hasCrashed")(function* () { }) export const ActiveManifest = { - write, - remove, - read, - clear, - hasCrashed, + write: (entry: ActiveSession) => write(entry).pipe(Effect.catch(() => Effect.void)), + remove: (sessionID: string) => remove(sessionID).pipe(Effect.catch(() => Effect.void)), + read: () => read().pipe(Effect.catch(() => Effect.succeed([] as ActiveSession[]))), + clear: () => clear().pipe(Effect.catch(() => Effect.void)), + hasCrashed: () => hasCrashed().pipe(Effect.catch(() => Effect.succeed(false))), } diff --git a/packages/opencode/test/session/active-manifest.test.ts b/packages/opencode/test/session/active-manifest.test.ts index 68d20c55b436..06343afaf02d 100644 --- a/packages/opencode/test/session/active-manifest.test.ts +++ b/packages/opencode/test/session/active-manifest.test.ts @@ -7,8 +7,6 @@ import { ActiveManifest, setManifestDir } from "@/session/active-manifest" const sampleEntry = { id: "session-001", - model: { id: "claude-sonnet", providerID: "anthropic" }, - agent: "build", timestamp: Date.now(), } @@ -39,8 +37,6 @@ test("writeActiveSession adds to existing manifest", async () => { await Effect.runPromise( ActiveManifest.write({ id: "session-002", - model: { id: "gpt-4", providerID: "openai" }, - agent: "general", timestamp: Date.now(), }), ) @@ -53,10 +49,10 @@ test("writeActiveSession updates existing session", async () => { await withTmpDir(async (dir) => { setManifestDir(dir) await Effect.runPromise(ActiveManifest.write(sampleEntry)) - await Effect.runPromise(ActiveManifest.write({ ...sampleEntry, agent: "plan" })) + await Effect.runPromise(ActiveManifest.write({ ...sampleEntry, timestamp: 999 })) const sessions = await Effect.runPromise(ActiveManifest.read()) expect(sessions).toHaveLength(1) - expect(sessions[0].agent).toBe("plan") + expect(sessions[0].timestamp).toBe(999) }) }) @@ -70,6 +66,16 @@ test("removeActiveSession removes from manifest", async () => { }) }) +test("removeActiveSession deletes manifest file when last session removed", async () => { + await withTmpDir(async (dir) => { + setManifestDir(dir) + await Effect.runPromise(ActiveManifest.write(sampleEntry)) + await Effect.runPromise(ActiveManifest.remove("session-001")) + const crashed = await Effect.runPromise(ActiveManifest.hasCrashed()) + expect(crashed).toBe(false) + }) +}) + test("clearActiveSessions deletes the manifest file", async () => { await withTmpDir(async (dir) => { setManifestDir(dir) From f8009f643b0f81b6fb7367e12c6155e0fddd508a Mon Sep 17 00:00:00 2001 From: AndyS77 Date: Wed, 19 Aug 2026 18:46:13 +0200 Subject: [PATCH 7/8] test(session): e2e tests for crash recovery flow --- .../test/session/crash-recovery-e2e.test.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 packages/opencode/test/session/crash-recovery-e2e.test.ts diff --git a/packages/opencode/test/session/crash-recovery-e2e.test.ts b/packages/opencode/test/session/crash-recovery-e2e.test.ts new file mode 100644 index 000000000000..64ee7277f5ba --- /dev/null +++ b/packages/opencode/test/session/crash-recovery-e2e.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from "bun:test" +import { Effect } from "effect" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { ActiveManifest, setManifestDir } from "@/session/active-manifest" + +async function withTmpDir(fn: (dir: string) => Promise) { + const dir = path.join(os.tmpdir(), "opencode-e2e-" + Math.random().toString(36).slice(2)) + await fs.mkdir(dir, { recursive: true }) + try { + await fn(dir) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } +} + +test("E2E: crash leaves manifest, clean shutdown clears it", async () => { + await withTmpDir(async (dir) => { + setManifestDir(dir) + + // 1. Simulate sessions going busy + await Effect.runPromise(ActiveManifest.write({ id: "s1", timestamp: Date.now() })) + await Effect.runPromise(ActiveManifest.write({ id: "s2", timestamp: Date.now() })) + + // 2. Crash: manifest should persist + const afterCrash = await Effect.runPromise(ActiveManifest.read()) + expect(afterCrash).toHaveLength(2) + const crashed = await Effect.runPromise(ActiveManifest.hasCrashed()) + expect(crashed).toBe(true) + + // 3. Simulate one session going idle + await Effect.runPromise(ActiveManifest.remove("s1")) + const afterIdle = await Effect.runPromise(ActiveManifest.read()) + expect(afterIdle).toHaveLength(1) + expect(afterIdle[0].id).toBe("s2") + + // 4. Simulate last session going idle + await Effect.runPromise(ActiveManifest.remove("s2")) + const afterLastIdle = await Effect.runPromise(ActiveManifest.read()) + expect(afterLastIdle).toHaveLength(0) + + // 5. After all sessions idle, manifest file should be deleted + const fileExists = await Effect.runPromise(ActiveManifest.hasCrashed()) + expect(fileExists).toBe(false) + }) +}) + +test("E2E: crash with multiple sessions, then clean shutdown clears all", async () => { + await withTmpDir(async (dir) => { + setManifestDir(dir) + + // 1. Multiple sessions active + await Effect.runPromise(ActiveManifest.write({ id: "s1", timestamp: Date.now() })) + await Effect.runPromise(ActiveManifest.write({ id: "s2", timestamp: Date.now() })) + await Effect.runPromise(ActiveManifest.write({ id: "s3", timestamp: Date.now() })) + + // 2. Crash — manifest persists with all sessions + expect(await Effect.runPromise(ActiveManifest.hasCrashed())).toBe(true) + const sessions = await Effect.runPromise(ActiveManifest.read()) + expect(sessions).toHaveLength(3) + + // 3. Clean shutdown — manifest cleared + await Effect.runPromise(ActiveManifest.clear()) + expect(await Effect.runPromise(ActiveManifest.hasCrashed())).toBe(false) + expect(await Effect.runPromise(ActiveManifest.read())).toHaveLength(0) + }) +}) + +test("E2E: manifest survives corrupt JSON (graceful degradation)", async () => { + await withTmpDir(async (dir) => { + setManifestDir(dir) + + // Write corrupt JSON to manifest file + const manifestPath = path.join(dir, "active-sessions.json") + await fs.writeFile(manifestPath, "{ broken json") + + // Read should return empty array, not throw + const sessions = await Effect.runPromise(ActiveManifest.read()) + expect(sessions).toHaveLength(0) + + // hasCrashed should still return true (file exists) + expect(await Effect.runPromise(ActiveManifest.hasCrashed())).toBe(true) + + // After clear, file is gone + await Effect.runPromise(ActiveManifest.clear()) + expect(await Effect.runPromise(ActiveManifest.hasCrashed())).toBe(false) + }) +}) + +test("E2E: write-update cycle preserves only latest entry per session", async () => { + await withTmpDir(async (dir) => { + setManifestDir(dir) + + // Write session with timestamp T1 + await Effect.runPromise(ActiveManifest.write({ id: "s1", timestamp: 1000 })) + + // Update same session with timestamp T2 + await Effect.runPromise(ActiveManifest.write({ id: "s1", timestamp: 2000 })) + + // Should have only one entry with latest timestamp + const sessions = await Effect.runPromise(ActiveManifest.read()) + expect(sessions).toHaveLength(1) + expect(sessions[0].timestamp).toBe(2000) + + // Write a second session + await Effect.runPromise(ActiveManifest.write({ id: "s2", timestamp: 3000 })) + const sessions2 = await Effect.runPromise(ActiveManifest.read()) + expect(sessions2).toHaveLength(2) + }) +}) From b2e7016d04b0cfd7979ebf8e7bfa13e1c3719866 Mon Sep 17 00:00:00 2001 From: AndyS77 Date: Wed, 19 Aug 2026 19:29:44 +0200 Subject: [PATCH 8/8] =?UTF-8?q?fix(session):=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20shape=20validation,=20newest-session=20selection,=20process.?= =?UTF-8?q?on(exit)=20cleanup,=20additional=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/src/cli/cmd/run.ts | 2 +- .../opencode/src/session/active-manifest.ts | 19 ++++++-- .../test/session/active-manifest.test.ts | 44 +++++++++++++++++++ 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 4dd2bac06180..a041b520e8a5 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -499,7 +499,7 @@ export const RunCommand = effectCmd({ if (cfg?.data?.session?.auto_resume) { const active = await Effect.runPromise(ActiveManifest.read()).catch(() => []) if (active.length > 0) { - const candidate = active[0] + const candidate = active[active.length - 1] const existing = await sdk.session.get({ sessionID: candidate.id }).catch(() => undefined) if (existing?.data) { UI.println(UI.Style.TEXT_WARNING_BOLD + "!" + UI.Style.TEXT_NORMAL + ` crash detected — resuming last active session`) diff --git a/packages/opencode/src/session/active-manifest.ts b/packages/opencode/src/session/active-manifest.ts index 8b91426cd685..feb09700d7d4 100644 --- a/packages/opencode/src/session/active-manifest.ts +++ b/packages/opencode/src/session/active-manifest.ts @@ -1,5 +1,6 @@ import path from "node:path" import fs from "node:fs/promises" +import fsSync from "node:fs" import { Global } from "@opencode-ai/core/global" import { Effect } from "effect" @@ -16,15 +17,17 @@ export function setManifestDir(dir: string) { manifestDir = dir } -function manifestPath() { +export function manifestPath() { return path.join(manifestDir, "active-sessions.json") } +// Best-effort: manifest errors must never crash the session lifecycle async function readRaw(): Promise { const file = Bun.file(manifestPath()) - const exists = await file.exists() - if (!exists) return { sessions: [] } - return file.json().catch(() => ({ sessions: [] })) + if (!(await file.exists())) return { sessions: [] } + const parsed = await file.json().catch(() => null) + if (!parsed || !Array.isArray(parsed.sessions)) return { sessions: [] } + return { sessions: parsed.sessions } } async function writeRaw(manifest: Manifest): Promise { @@ -78,3 +81,11 @@ export const ActiveManifest = { clear: () => clear().pipe(Effect.catch(() => Effect.void)), hasCrashed: () => hasCrashed().pipe(Effect.catch(() => Effect.succeed(false))), } + +// Synchronous cleanup on process.exit() — runs for clean exits but not for +// SIGKILL/crashes, which is exactly the desired crash-detection behavior. +process.on("exit", () => { + try { + fsSync.unlinkSync(manifestPath()) + } catch {} +}) diff --git a/packages/opencode/test/session/active-manifest.test.ts b/packages/opencode/test/session/active-manifest.test.ts index 06343afaf02d..8f7c2a1aeeca 100644 --- a/packages/opencode/test/session/active-manifest.test.ts +++ b/packages/opencode/test/session/active-manifest.test.ts @@ -110,3 +110,47 @@ test("read returns empty array when manifest does not exist", async () => { expect(sessions).toHaveLength(0) }) }) + +test("read returns empty array for valid-but-wrong-shape JSON", async () => { + await withTmpDir(async (dir) => { + setManifestDir(dir) + const manifestPath = path.join(dir, "active-sessions.json") + await fs.writeFile(manifestPath, JSON.stringify({})) + const sessions = await Effect.runPromise(ActiveManifest.read()) + expect(sessions).toHaveLength(0) + }) +}) + +test("read returns empty array for valid array JSON", async () => { + await withTmpDir(async (dir) => { + setManifestDir(dir) + const manifestPath = path.join(dir, "active-sessions.json") + await fs.writeFile(manifestPath, JSON.stringify([])) + const sessions = await Effect.runPromise(ActiveManifest.read()) + expect(sessions).toHaveLength(0) + }) +}) + +test("write after corrupt-shape manifest recovers gracefully", async () => { + await withTmpDir(async (dir) => { + setManifestDir(dir) + const manifestPath = path.join(dir, "active-sessions.json") + await fs.writeFile(manifestPath, JSON.stringify({ sessions: "not-an-array" })) + await Effect.runPromise(ActiveManifest.write(sampleEntry)) + const sessions = await Effect.runPromise(ActiveManifest.read()) + expect(sessions).toHaveLength(1) + expect(sessions[0].id).toBe("session-001") + }) +}) + +test("multi-session manifest preserves write order", async () => { + await withTmpDir(async (dir) => { + setManifestDir(dir) + await Effect.runPromise(ActiveManifest.write({ id: "s1", timestamp: 1000 })) + await Effect.runPromise(ActiveManifest.write({ id: "s2", timestamp: 2000 })) + const sessions = await Effect.runPromise(ActiveManifest.read()) + expect(sessions).toHaveLength(2) + expect(sessions[0].id).toBe("s1") + expect(sessions[sessions.length - 1].id).toBe("s2") + }) +})