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..a041b520e8a5 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,30 @@ 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().catch(() => undefined) + if (cfg?.data?.session?.auto_resume) { + const active = await Effect.runPromise(ActiveManifest.read()).catch(() => []) + if (active.length > 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`) + await Effect.runPromise(ActiveManifest.clear()).catch(() => {}) + return { + id: candidate.id, + title: existing.data.title, + directory: existing.data.directory, + } + } + } + } + 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 new file mode 100644 index 000000000000..feb09700d7d4 --- /dev/null +++ b/packages/opencode/src/session/active-manifest.ts @@ -0,0 +1,91 @@ +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" + +export type ActiveSession = { + id: string + timestamp: number +} + +type Manifest = { sessions: ActiveSession[] } + +let manifestDir = Global.Path.data + +export function setManifestDir(dir: string) { + manifestDir = dir +} + +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()) + 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 { + 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) { + 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) + 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* () { + const manifest = yield* Effect.promise(() => readRaw()) + return manifest.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: (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))), +} + +// 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/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 }) 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..8f7c2a1aeeca --- /dev/null +++ b/packages/opencode/test/session/active-manifest.test.ts @@ -0,0 +1,156 @@ +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", + timestamp: Date.now(), +} + +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") + }) +}) + +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", + timestamp: Date.now(), + }), + ) + const sessions = await Effect.runPromise(ActiveManifest.read()) + expect(sessions).toHaveLength(2) + }) +}) + +test("writeActiveSession updates existing session", async () => { + await withTmpDir(async (dir) => { + setManifestDir(dir) + await Effect.runPromise(ActiveManifest.write(sampleEntry)) + await Effect.runPromise(ActiveManifest.write({ ...sampleEntry, timestamp: 999 })) + const sessions = await Effect.runPromise(ActiveManifest.read()) + expect(sessions).toHaveLength(1) + expect(sessions[0].timestamp).toBe(999) + }) +}) + +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) + }) +}) + +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) + await Effect.runPromise(ActiveManifest.write(sampleEntry)) + await Effect.runPromise(ActiveManifest.clear()) + const sessions = await Effect.runPromise(ActiveManifest.read()) + expect(sessions).toHaveLength(0) + }) +}) + +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) + }) +}) + +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) + }) +}) + +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) + }) +}) + +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") + }) +}) 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) + }) +})