diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 6f8f3c05..a204a12d 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -60,6 +60,13 @@ import { probeCommand, formatHealthReport, probeOpencodeTui, type HealthResult } import { fleetHealthReport, FLEET_GUARD_REL } from "./fleet_health"; import { isFleetClient, getFleetRole, goStandalone, readFleetConfig, migrateLegacyFallback } from "./fleet_fallback"; import { resolveHubTarget, restartHub } from "./hub_ops"; +import { + goStandaloneSpawn, + journalSelfHeal, + localDbPath, + OfferGate, + ReofferBackoff, +} from "./standalone_spawn"; import { registerAmicodeTerminal } from "./terminal"; import { amicodeServiceDisposal, startAmicodeService } from "./amicode_service_wiring"; import { registerOpencodeUpdater } from "./opencode_updater_wiring"; @@ -103,6 +110,22 @@ let distillerSetup: DistillerSetup | undefined; let devicePollTimer: ReturnType | undefined; /** Fleet client tunnel poll — when the machine is a fleet client (guard `exit 1`), we don't spawn. */ let fleetClientPoll: ReturnType | undefined; +/** Restarts the attach poll after a failed Go-Standalone spawn (#781 AC1) — + * set by the client-mode boot; undefined when this window never polled. */ +let fleetPollResume: (() => void) | undefined; +/** True while a Go-Standalone spawn attempt is in flight — the attach loop + * holds its re-offers so no banner fires mid-spawn (#781 AC4). */ +let standaloneAttemptActive = false; + +/** Stop the fleet client attach poll (idempotent). The poll stops ONLY via the + * FleetPollGuard path (#781: after the local server is healthy) or disposal — + * never before a spawn attempt. */ +function stopFleetClientPoll(): void { + if (fleetClientPoll) { + clearInterval(fleetClientPoll); + fleetClientPoll = undefined; + } +} const DEVICE_POLL_MS = 2500; // mirror the RunsManager cadence @@ -610,7 +633,25 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Poll the tunnel — when the canonical server is reachable the forward answers 200. let fleetReady = false; let fleetChecks = 0; - let fleetNotified = false; + // #781 AC2: the one-shot `fleetNotified` flag is gone. Re-offers now follow + // a per-window exponential backoff (base 30s, ceiling 5 min) that resets on + // any successful attach, and an OfferGate keeps exactly one banner alive so + // offers can never stack into double banners (AC4). + const reoffer = new ReofferBackoff(); + const offerGate = new OfferGate(); + const offerStandalone = (reason: string) => { + if (standaloneAttemptActive) return; // a spawn attempt is in flight — hold offers + if (!reoffer.due() || !offerGate.tryBegin()) return; + reoffer.recordOffer(); + opencodeChannel.appendLine(`[fleet] ${reason} (checks=${fleetChecks}) — offering standalone`); + void vscode.window + .showWarningMessage(`Amicode: fleet tunnel down — canonical unreachable. Go standalone?`, `Go Standalone`, `Show log`) + .then((pick) => { + offerGate.end(); + if (pick === `Go Standalone`) void vscode.commands.executeCommand(`amicode.fleet.goStandalone`); + else if (pick === `Show log`) opencodeChannel.show(); + }); + }; const checkFleet = async () => { fleetChecks++; try { @@ -621,7 +662,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const up = r.ok || (r.status >= 200 && r.status < 400); if (up && !fleetReady) { fleetReady = true; - fleetNotified = false; + reoffer.reset(); // a successful attach restarts the re-offer schedule (AC2) opencodeReadyUrl = new URL(`http://127.0.0.1:${fleetPort}`); statusBar?.setServerReady(true); sseClient?.connect(opencodeReadyUrl); @@ -641,15 +682,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { opencodeChannel.appendLine(`[fleet] waiting for tunnel 127.0.0.1:${fleetPort} — canonical unreachable, will retry`); } // After ~10s (5 checks) still down → offer standalone visibly, not just a log - if (!up && !fleetReady && !fleetNotified && fleetChecks >= 5) { - fleetNotified = true; - opencodeChannel.appendLine(`[fleet] tunnel still down after ${fleetChecks} checks — offering standalone`); - void vscode.window - .showWarningMessage(`Amicode: fleet tunnel down — canonical unreachable. Go standalone?`, `Go Standalone`, `Show log`) - .then((pick) => { - if (pick === `Go Standalone`) void vscode.commands.executeCommand(`amicode.fleet.goStandalone`); - else if (pick === `Show log`) opencodeChannel.show(); - }); + if (!up && !fleetReady && fleetChecks >= 5) { + offerStandalone(`tunnel still down after ${fleetChecks} checks — offering standalone`); } } catch { if (fleetReady) { @@ -660,20 +694,17 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } else if (fleetChecks === 1) { opencodeChannel.appendLine(`[fleet] waiting for tunnel 127.0.0.1:${fleetPort} — will retry`); } - if (!fleetReady && !fleetNotified && fleetChecks >= 5) { - fleetNotified = true; - opencodeChannel.appendLine(`[fleet] tunnel still down after ${fleetChecks} checks — offering standalone`); - void vscode.window - .showWarningMessage(`Amicode: fleet tunnel down — canonical unreachable. Go standalone?`, `Go Standalone`, `Show log`) - .then((pick) => { - if (pick === `Go Standalone`) void vscode.commands.executeCommand(`amicode.fleet.goStandalone`); - else if (pick === `Show log`) opencodeChannel.show(); - }); + if (!fleetReady && fleetChecks >= 5) { + offerStandalone(`tunnel still down after ${fleetChecks} checks — offering standalone`); } } }; - fleetClientPoll = setInterval(() => void checkFleet(), 2000); - ctx.subscriptions.push({ dispose: () => { if (fleetClientPoll) clearInterval(fleetClientPoll); fleetClientPoll = undefined; } }); + const startFleetPoll = () => { + if (!fleetClientPoll) fleetClientPoll = setInterval(() => void checkFleet(), 2000); + }; + fleetPollResume = startFleetPoll; // a failed Go-Standalone spawn restores the poll (#781 AC1) + startFleetPoll(); + ctx.subscriptions.push({ dispose: () => stopFleetClientPoll() }); void checkFleet(); // Fallback status bar already handles the fallback-active case; in pure // client mode we surface tunnel health via the fleet health warning above. @@ -1372,65 +1403,110 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } refreshFleetStatus(); opencodeChannel.appendLine(`[fleet] Go Standalone — restarting server locally (was binary=${prevBinary || "(vendored)"} port=${prevPort})`); + // #781: the fleet attach poll is NOT stopped before the spawn. It stops + // exactly once the local server reports healthy, and every failure path + // restores it (FleetPollGuard inside goStandaloneSpawn) — a failed spawn + // can never leave the window dead (AC1), and re-offers resume under the + // attach loop's per-window backoff (AC2). + standaloneAttemptActive = true; // hold the attach loop's re-offers during the attempt (AC4) + const runSqlite3 = (cmd: string[]): Promise<{ code: number; stdout: string; stderr: string }> => + new Promise((resolve) => { + const { execFile } = require("node:child_process") as typeof import("node:child_process"); + execFile(cmd[0]!, cmd.slice(1), { timeout: 10_000, encoding: "utf8" }, (error, stdout, stderr) => { + resolve({ + code: error ? (typeof error.code === "number" ? error.code : 1) : 0, + stdout: String(stdout ?? ""), + stderr: String(stderr ?? ""), + }); + }); + }); try { await serverManager?.stop(); statusBar?.setServerReady(false); opencodeReadyUrl = undefined; - // Stop the fleet client poll if running - if (fleetClientPoll) { clearInterval(fleetClientPoll); fleetClientPoll = undefined; } - // Spawn a fresh local server (selected harness, fresh resolution — the - // standalone path deliberately re-resolves, ephemeral port) - const standaloneCfg = vscode.workspace.getConfiguration("amicode"); - const standaloneLaunch = resolveSelectedLaunch({ - harnessId: standaloneCfg.get("harness", "opencode"), - opencodeBinary: "", // standalone drops the override by design - telaioBinary: standaloneCfg.get("telaioBinary", ""), - telaioAppDir: standaloneCfg.get("telaioAppDir", ""), - extensionPath: ctx.extensionPath, - }); - harnessEnvCurrent = standaloneLaunch.descriptor.spawnEnvAdditions({ - telaioAppDir: standaloneCfg.get("telaioAppDir", ""), - }); - harnessConsumesOpencodeConfig = standaloneLaunch.descriptor.consumesOpencodeConfig; - const amicoRunBinDir2 = resolveAmicoRunBinDir(ctx.extensionPath); - const freshManager = new ServerManager({ - binary: standaloneLaunch.binary, - cwd: opencodeProject.projectDir, - port: undefined, // ephemeral — standalone - env: spawnEnv({ - amicoRunBinDir: amicoRunBinDir2, - serverPassword, - configContent: buildOpencodeConfigContent( - opencodeProject.agentsPath, - opencodeProject.templatePath, - runsRoot, - undefined, - undefined, - opencodeProject.skillPaths, - opencodeProject.skillsStageDir, - opencodeProject.vaultDir, - opencodeProject.mounts, - validatedModelPin(vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin()), - telemetryOpen(), - ), - }), - channel: opencodeChannel, - }); - serverManager = freshManager; - ctx.subscriptions.push({ dispose: () => void freshManager.stop() }); - freshManager.onReady((url) => { - opencodeReadyUrl = url; - statusBar?.setServerReady(true); - sseClient?.connect(url); - if (vscode.workspace.getConfiguration("amicode").get("chat.autoOpen", true)) { - ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir); - } + const result = await goStandaloneSpawn({ + poll: { stopPoll: () => stopFleetClientPoll(), resumePoll: () => fleetPollResume?.() }, + startServer: async () => { + // Spawn a fresh local server (selected harness, fresh resolution — the + // standalone path deliberately re-resolves, ephemeral port) + const standaloneCfg = vscode.workspace.getConfiguration("amicode"); + const standaloneLaunch = resolveSelectedLaunch({ + harnessId: standaloneCfg.get("harness", "opencode"), + opencodeBinary: "", // standalone drops the override by design + telaioBinary: standaloneCfg.get("telaioBinary", ""), + telaioAppDir: standaloneCfg.get("telaioAppDir", ""), + extensionPath: ctx.extensionPath, + }); + harnessEnvCurrent = standaloneLaunch.descriptor.spawnEnvAdditions({ + telaioAppDir: standaloneCfg.get("telaioAppDir", ""), + }); + harnessConsumesOpencodeConfig = standaloneLaunch.descriptor.consumesOpencodeConfig; + const amicoRunBinDir2 = resolveAmicoRunBinDir(ctx.extensionPath); + const freshManager = new ServerManager({ + binary: standaloneLaunch.binary, + cwd: opencodeProject.projectDir, + port: undefined, // ephemeral — standalone + env: spawnEnv({ + amicoRunBinDir: amicoRunBinDir2, + serverPassword, + configContent: buildOpencodeConfigContent( + opencodeProject.agentsPath, + opencodeProject.templatePath, + runsRoot, + undefined, + undefined, + opencodeProject.skillPaths, + opencodeProject.skillsStageDir, + opencodeProject.vaultDir, + opencodeProject.mounts, + validatedModelPin(vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || resolveModelPin()), + telemetryOpen(), + ), + }), + channel: opencodeChannel, + }); + serverManager = freshManager; + ctx.subscriptions.push({ dispose: () => void freshManager.stop() }); + freshManager.onReady((url) => { + opencodeReadyUrl = url; + statusBar?.setServerReady(true); + sseClient?.connect(url); + if (vscode.workspace.getConfiguration("amicode").get("chat.autoOpen", true)) { + ChatPanel.openOrReveal(ctx, url, serverAuthToken(serverPassword), opencodeProject.projectDir); + } + }); + await freshManager.start(); + }, + notice: (n) => Promise.resolve(vscode.window.showErrorMessage(n.title, ...n.actions)), + confirmHeal: async () => + (await vscode.window.showWarningMessage( + "Run the local-DB journal self-heal? This rewrites local session data (a backup of the DB is kept first).", + { modal: true }, + "Self-heal", + "Cancel", + )) === "Self-heal", + heal: () => + journalSelfHeal({ + dbPath: localDbPath(), + run: runSqlite3, + fs: { existsSync: (p) => fs.existsSync(p), copyFileSync: (src, dest) => fs.copyFileSync(src, dest) }, + }), + onHealResult: (r) => { + const detail = r.steps.map((s) => `${s.step}: ${s.detail}`).join(" | "); + opencodeChannel.appendLine(`[fleet] journal self-heal ok=${r.ok} — ${detail}`); + if (r.ok) { + void vscode.window.showInformationMessage("Amicode: journal self-heal applied — try Go Standalone again."); + } else { + void vscode.window.showErrorMessage(`Amicode: journal self-heal failed — ${detail}`); + } + }, + log: (line) => opencodeChannel.appendLine(line), }); - await freshManager.start(); - void vscode.window.showInformationMessage("Amicode: Standalone mode — running locally. Your local sessions are now visible."); - } catch (e) { - void vscode.window.showErrorMessage(`Amicode: go standalone failed — ${(e as Error).message}`); - opencodeChannel.appendLine(`[fleet] go standalone failed: ${(e as Error).message}`); + if (result.ok) { + void vscode.window.showInformationMessage("Amicode: Standalone mode — running locally. Your local sessions are now visible."); + } + } finally { + standaloneAttemptActive = false; } }; @@ -1958,10 +2034,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } export function deactivate(): void { - if (fleetClientPoll) { - clearInterval(fleetClientPoll); - fleetClientPoll = undefined; - } + stopFleetClientPoll(); if (devicePollTimer) { clearInterval(devicePollTimer); devicePollTimer = undefined; diff --git a/packages/extension/src/server_manager.ts b/packages/extension/src/server_manager.ts index c9d5afcc..61f3a87b 100644 --- a/packages/extension/src/server_manager.ts +++ b/packages/extension/src/server_manager.ts @@ -30,8 +30,14 @@ export interface ServerOptions { channel: vscode.OutputChannel; /** Fixed port to serve on. 0 (default) picks a free ephemeral port each start. */ port?: number; + /** Health-probe budget in ms. Default 30_000; tests inject a short one. */ + healthTimeoutMs?: number; } +/** Tail length of the child's captured output attached to a failed start + * (#781) — long enough to carry a crash cause, short enough for a toast. */ +const SPAWN_OUTPUT_TAIL = 4000; + export class ServerManager { private child?: cp.ChildProcessByStdio; private _port?: number; @@ -77,8 +83,22 @@ export class ServerManager { }); this.child = child; - child.stdout.on("data", (b: Buffer) => this.opts.channel.append(`[opencode] ${b.toString()}`)); - child.stderr.on("data", (b: Buffer) => this.opts.channel.append(`[opencode!] ${b.toString()}`)); + // Capture the child's output while spawning (#781): on a failed start the + // thrown error carries a tail of it, so the caller can match a known crash + // signature (e.g. the local-DB `duplicate column name` migration failure) + // instead of the generic timeout message hiding the cause. + let output = ""; + const capture = (b: Buffer) => { + output = (output + b.toString()).slice(-SPAWN_OUTPUT_TAIL); + }; + child.stdout.on("data", (b: Buffer) => { + capture(b); + this.opts.channel.append(`[opencode] ${b.toString()}`); + }); + child.stderr.on("data", (b: Buffer) => { + capture(b); + this.opts.channel.append(`[opencode!] ${b.toString()}`); + }); child.on("exit", (code, signal) => { this.opts.channel.appendLine(`[server] opencode exited code=${code} signal=${signal}`); this._ready = false; @@ -90,11 +110,32 @@ export class ServerManager { // a healthy boot would read as a 30s timeout. Derived from the same env // the child gets, so probe and server can never disagree. const password = this.opts.env.OPENCODE_SERVER_PASSWORD; - const ready = await waitForHealth(`http://127.0.0.1:${port}/`, 30_000, password ? serverAuthHeader(password) : undefined); + const healthTimeoutMs = this.opts.healthTimeoutMs ?? 30_000; + // Fail fast on a crashed child (#781): a binary that dies during startup + // must surface immediately with its captured output — not burn the full + // health budget and throw a generic timeout. `close` (not `exit`) fires + // after the stdio streams drain, so the captured tail is complete. + const childClosed = new Promise((resolve) => child.once("close", () => resolve(true))); + const healthSettled = waitForHealth(`http://127.0.0.1:${port}/`, healthTimeoutMs, password ? serverAuthHeader(password) : undefined).then(() => false); + const crashed = await Promise.race([healthSettled, childClosed]); + if (crashed) { + this.opts.channel.appendLine(`[server] opencode exited before becoming healthy — aborting start`); + this.stop(); + const err = new Error( + "opencode exited during startup before becoming healthy — check the 'Amicode — opencode' output channel", + ) as Error & { spawnOutput?: string }; + err.spawnOutput = output; + throw err; + } + const ready = await healthSettled.then(() => true); if (!ready) { - this.opts.channel.appendLine(`[server] opencode did not become healthy within 30s`); + this.opts.channel.appendLine(`[server] opencode did not become healthy within ${Math.round(healthTimeoutMs / 1000)}s`); this.stop(); - throw new Error("opencode failed to start within 30s — check the 'Amicode — opencode' output channel"); + const err = new Error( + `opencode failed to start within ${Math.round(healthTimeoutMs / 1000)}s — check the 'Amicode — opencode' output channel`, + ) as Error & { spawnOutput?: string }; + err.spawnOutput = output; + throw err; } this._ready = true; const url = new URL(`http://127.0.0.1:${port}`); diff --git a/packages/extension/src/standalone_spawn.ts b/packages/extension/src/standalone_spawn.ts new file mode 100644 index 00000000..47fb781c --- /dev/null +++ b/packages/extension/src/standalone_spawn.ts @@ -0,0 +1,323 @@ +// standalone_spawn.ts — Go-Standalone spawn hardening (#781). +// +// The 2026-09-03 incident: the Go-Standalone flow cleared the fleet attach +// poll BEFORE spawning the local server; when the vendored binary crashed on +// a drifted local DB (`duplicate column name: directories`), the window was +// left dead — no poll, no server, no banner — until a reload. The failure +// surfaced as a generic "failed to start within 30s" that hid the cause. +// +// This module is the pure, unit-testable core (the extension.ts wiring stays +// thin): crash-signature detection, the failure notice, the per-window +// re-offer backoff, the poll-lifetime guard, and the journal self-heal +// (the Aug-30 erlich medicine: backup kept, missing journal row inserted). +// All process execution and fs is injected, per the hub_ops/fleet_health +// pattern. + +import path from "node:path"; +import { opencodeDataDir } from "./opencode_xdg"; + +// ============================================================================ +// AC3 — the known local-DB migration crash signature +// ============================================================================ + +/** The observed crash mode: a pre-guard vendored binary replays an already- + * applied schema migration because the local DB's journal lacks its row, and + * SQLite fails with `duplicate column name: ` (#776 comment). Matched + * from the spawn error output — never from timing or retry counts. */ +export function isDbMigrationCrash(message: string): boolean { + return /duplicate column name/i.test(message); +} + +/** The drifted-migration journal row the known medicine inserts. `INSERT OR + * IGNORE` keeps the heal idempotent — re-running on a healthy DB is a no-op. */ +export const KNOWN_DRIFTED_MIGRATION_IDS = ["20260828201050_normal_stryfe"] as const; + +export type SpawnFailureNotice = { + /** What the user is told (error-message body). */ + title: string; + /** Button labels, in order. */ + actions: string[]; + /** True when the known DB-migration crash matched — the caller offers the + * self-heal behind a CONFIRMATION (it rewrites local session data; never + * auto-run). */ + dbCrash: boolean; + /** The action label that triggers the self-heal flow (present only when dbCrash). */ + healAction?: string; +}; + +export const SELF_HEAL_ACTION = "Self-heal local DB…"; +export const SHOW_LOG_ACTION = "Show log"; + +/** Build the failure notice. DB-crash failures surface the ACTUAL cause with + * the remediation offered inline; everything else keeps the existing + * generic error path. */ +export function spawnFailureNotice(message: string, spawnOutput?: string): SpawnFailureNotice { + const combined = `${message}\n${spawnOutput ?? ""}`; + if (isDbMigrationCrash(combined)) { + return { + dbCrash: true, + healAction: SELF_HEAL_ACTION, + title: + "Amicode: go standalone failed — the local session DB failed a schema migration " + + "(“duplicate column name”). This is a known journal drift; the journal self-heal can repair it. " + + "A backup of the DB is kept before anything is written.", + actions: [SELF_HEAL_ACTION, SHOW_LOG_ACTION], + }; + } + return { + dbCrash: false, + title: `Amicode: go standalone failed — ${message}`, + actions: [SHOW_LOG_ACTION], + }; +} + +/** The client's local session DB (the same file the vendored server opens). */ +export function localDbPath(): string { + return path.join(opencodeDataDir(), "opencode.db"); +} + +// ============================================================================ +// AC2 — per-window re-offer backoff (replaces the one-shot `fleetNotified`) +// ============================================================================ + +/** Delay before the FIRST re-offer after one has fired. The initial offer + * itself is still gated by the attach loop's warm-up (5 checks ≈ 10s). */ +export const REOFFER_BASE_MS = 30_000; +/** Dev's call (#781 leaves the ceiling open): 5 minutes — often enough to + * stay discoverable while the hub is down, quiet enough not to nag. The old + * "offer once per window lifetime" behavior is gone: the backoff resets on + * any successful attach. */ +export const REOFFER_CEILING_MS = 300_000; + +export class ReofferBackoff { + private lastOffer: number | undefined; + private offers = 0; + + constructor( + private readonly now: () => number = Date.now, + private readonly baseMs: number = REOFFER_BASE_MS, + private readonly ceilingMs: number = REOFFER_CEILING_MS, + ) {} + + /** True when an offer may fire now: never offered, or the current backoff + * delay has elapsed since the last one. */ + due(): boolean { + if (this.lastOffer === undefined) return true; + return this.now() - this.lastOffer >= this.delayMs(); + } + + recordOffer(): void { + this.lastOffer = this.now(); + this.offers++; + } + + /** Any successful attach resets the schedule (AC2). */ + reset(): void { + this.lastOffer = undefined; + this.offers = 0; + } + + private delayMs(): number { + // After offer N (N≥1), the next is due after base × 2^(N-1), capped. + return Math.min(this.baseMs * 2 ** (this.offers - 1), this.ceilingMs); + } +} + +/** One offer outstanding at a time — `showWarningMessage` is non-modal and + * stacking banners is exactly the "double banner" #781 bans (AC4). */ +export class OfferGate { + private pending = false; + tryBegin(): boolean { + if (this.pending) return false; + this.pending = true; + return true; + } + end(): void { + this.pending = false; + } +} + +// ============================================================================ +// AC1 + AC4 — the attach poll outlives the spawn attempt +// ============================================================================ + +/** Poll-lifetime guard for the Go-Standalone spawn. The poll is NEVER stopped + * before the spawn; it stops only once the local server is healthy (exactly + * once), and every failure path resumes it. */ +export class FleetPollGuard { + private stopped = false; + + constructor(private readonly hooks: { stopPoll: () => void; resumePoll: () => void }) {} + + /** The local server reported healthy — the attach poll has no job left. + * Idempotent: a second healthy report must not double-stop (AC4). */ + onLocalHealthy(): void { + if (this.stopped) return; + this.stopped = true; + this.hooks.stopPoll(); + } + + /** The spawn failed — the window must keep an active attach poll (AC1). + * Always resumes, even after a prior successful stop (the user may be + * re-attaching after standalone mode). */ + onSpawnFailure(): void { + this.stopped = false; + this.hooks.resumePoll(); + } +} + +// ============================================================================ +// AC3 — the journal self-heal (the Aug-30 erlich medicine) +// ============================================================================ + +export type HealStep = { step: "backup" | "verify" | "insert"; ok: boolean; detail: string }; +export type HealRunner = (cmd: string[]) => Promise<{ code: number; stdout: string; stderr: string }>; + +export type HealDeps = { + /** The client's local session DB. */ + dbPath: string; + /** Process runner (sqlite3). Injected — the decision logic stays testable. */ + run: HealRunner; + /** fs surface for the backup. Injected. */ + fs: { existsSync(p: string): boolean; copyFileSync(src: string, dest: string): void }; + /** Journal rows to repair. Defaults to the known drifted migration. */ + migrationIds?: readonly string[]; + now?: () => number; +}; + +export type HealResult = { ok: boolean; steps: HealStep[] }; + +const BACKUP_KEEP = "A backup was kept alongside the DB (.bak-)."; + +/** The self-heal: back up the DB, verify the drift is what it looks like + * (the migration's work IS in the schema — only the journal row is missing), + * then insert the missing journal row(s). Honest at every step; refuses to + * fake a journal row the schema doesn't corroborate. The CALLER owns the + * user confirmation — this function is never invoked without one. */ +export async function journalSelfHeal(deps: HealDeps): Promise { + const steps: HealStep[] = []; + const now = deps.now ?? Date.now; + const ids = deps.migrationIds ?? KNOWN_DRIFTED_MIGRATION_IDS; + + // 1. Backup — never rewrite session data without a copy kept first. + if (!deps.fs.existsSync(deps.dbPath)) { + steps.push({ step: "backup", ok: false, detail: `local DB not found at ${deps.dbPath} — nothing was changed` }); + return { ok: false, steps }; + } + const stamp = new Date(now()).toISOString().replace(/[:.]/g, "-"); + const backupPath = `${deps.dbPath}.bak-${stamp}`; + try { + deps.fs.copyFileSync(deps.dbPath, backupPath); + steps.push({ step: "backup", ok: true, detail: `copied to ${backupPath}` }); + } catch (e) { + steps.push({ step: "backup", ok: false, detail: `backup failed: ${e instanceof Error ? e.message : String(e)} — nothing was changed` }); + return { ok: false, steps }; + } + + // 2. Verify — the journal table exists, and the schema actually shows the + // migration's work applied (a `directories` column exists somewhere). + // If the schema does NOT show it, inserting the row would mask a real + // half-applied migration — refuse. + const verifyCmd = [ + "sqlite3", + deps.dbPath, + "SELECT (SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='migration'), " + + "(SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND sql LIKE '%directories%');", + ]; + const verify = await deps.run(verifyCmd); + if (verify.code !== 0) { + steps.push({ step: "verify", ok: false, detail: `sqlite3 failed: ${verify.stderr.trim() || `exit ${verify.code}`}. ${BACKUP_KEEP}` }); + return { ok: false, steps }; + } + const [journalTables, appliedEvidence] = verify.stdout.trim().split("|").map((n) => Number.parseInt(n, 10)); + if (journalTables !== 1) { + steps.push({ step: "verify", ok: false, detail: `local journal table 'migration' not found in ${deps.dbPath} — unexpected DB shape; nothing was changed. ${BACKUP_KEEP}` }); + return { ok: false, steps }; + } + if (!appliedEvidence) { + steps.push({ step: "verify", ok: false, detail: "the schema does not show the migration's work applied — refusing to mark it applied (the DB may be half-migrated). Nothing was changed. " + BACKUP_KEEP }); + return { ok: false, steps }; + } + steps.push({ step: "verify", ok: true, detail: "journal table present; schema shows the migration applied — drift confirmed as a missing journal row" }); + + // 3. Insert the missing journal row(s). INSERT OR IGNORE → idempotent. + for (const id of ids) { + const insert = await deps.run([ + "sqlite3", + deps.dbPath, + `INSERT OR IGNORE INTO migration (id, time_completed) VALUES ('${id}', ${now()});`, + ]); + steps.push({ + step: "insert", + ok: insert.code === 0, + detail: + insert.code === 0 + ? `journal row '${id}' present` + : `insert failed: ${insert.stderr.trim() || `exit ${insert.code}`}. ${BACKUP_KEEP}`, + }); + if (insert.code !== 0) return { ok: false, steps }; + } + return { ok: true, steps }; +} + +// ============================================================================ +// The orchestration the extension.ts wiring delegates to — poll lifetime, +// failure notice, and the confirmation-gated self-heal in one testable unit. +// ============================================================================ + +export type StandaloneSpawnDeps = { + /** Attach-poll hooks (FleetPollGuard): stop only after health; restore on failure. */ + poll: { stopPoll: () => void; resumePoll: () => void }; + /** Builds + starts the standalone ServerManager. Resolves = local server healthy. */ + startServer: () => Promise; + /** Shows the failure prompt; returns the chosen action label (undefined = dismissed). */ + notice: (notice: SpawnFailureNotice) => Promise; + /** The self-heal's confirmation gate — the heal NEVER runs without it. */ + confirmHeal: (notice: SpawnFailureNotice) => Promise; + /** The journal self-heal itself (caller binds real fs/sqlite3 deps). */ + heal: () => Promise; + /** Surfacing for the heal outcome (toast + output channel on the caller side). */ + onHealResult: (result: HealResult) => void; + log?: (line: string) => void; +}; + +export type StandaloneSpawnResult = { + ok: boolean; + notice?: SpawnFailureNotice; + healConfirmed?: boolean; + heal?: HealResult; +}; + +/** Run the Go-Standalone spawn with #781's hardening: the attach poll is NOT + * stopped before the spawn (it stops exactly once the local server is + * healthy, AC1+AC4); a failed spawn always restores the poll (AC1) and + * surfaces the actual cause — the known DB-migration crash gets the journal + * self-heal offered inline, strictly behind a user confirmation (AC3). */ +export async function goStandaloneSpawn(deps: StandaloneSpawnDeps): Promise { + const guard = new FleetPollGuard(deps.poll); + try { + await deps.startServer(); + guard.onLocalHealthy(); + return { ok: true }; + } catch (e) { + guard.onSpawnFailure(); + const err = e as Error & { spawnOutput?: string }; + const notice = spawnFailureNotice(err.message ?? String(e), err.spawnOutput); + deps.log?.( + `[fleet] go standalone failed: ${notice.dbCrash ? "local-DB migration crash (duplicate column name)" : (err.message ?? String(e))}`, + ); + const pick = await deps.notice(notice); + if (notice.dbCrash && notice.healAction !== undefined && pick === notice.healAction) { + const confirmed = await deps.confirmHeal(notice); + if (!confirmed) { + deps.log?.("[fleet] journal self-heal declined — nothing was changed"); + return { ok: false, notice, healConfirmed: false }; + } + deps.log?.("[fleet] journal self-heal confirmed — running (backup kept first)"); + const heal = await deps.heal(); + deps.onHealResult(heal); + return { ok: false, notice, healConfirmed: true, heal }; + } + return { ok: false, notice }; + } +} diff --git a/packages/extension/test/server_manager_spawn_output.test.ts b/packages/extension/test/server_manager_spawn_output.test.ts new file mode 100644 index 00000000..de767813 --- /dev/null +++ b/packages/extension/test/server_manager_spawn_output.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { mkdtempSync, writeFileSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ServerManager } from "../src/server_manager"; + +// ============================================================================ +// #781 AC3: the spawn error path must carry the ACTUAL cause, not just the +// generic "failed to start within 30s". ServerManager captures the child's +// output while spawning and attaches a tail to the failure error so the +// caller can match the known crash signature (e.g. a drifted local-DB +// migration). Success behavior is untouched (AC5 — existing tests cover it). +// ============================================================================ + +function captureChannel() { + const lines: string[] = []; + return { + lines, + channel: { appendLine: (l: string) => lines.push(l), append: (l: string) => lines.push(l) } as never, + }; +} + +describe("ServerManager — spawn-output capture on failure (#781)", () => { + it("fails fast — well inside the health budget — when the child crashes on startup, carrying the crash cause", async () => { + const dir = mkdtempSync(join(tmpdir(), "sm-781-fast-")); + const bin = join(dir, "opencode"); + writeFileSync( + bin, + `#!/bin/sh\necho "opencode! Error: duplicate column name: directories" 1>&2\nexit 1\n`, + ); + chmodSync(bin, 0o755); + const { channel } = captureChannel(); + const manager = new ServerManager({ + binary: bin, + cwd: dir, + healthTimeoutMs: 30_000, + env: {}, + channel, + }); + const t0 = Date.now(); + const err = await manager.start().then( + () => { throw new Error("expected start() to reject"); }, + (e: Error & { spawnOutput?: string }) => e, + ); + // A crashed binary must not burn the full health budget before the + // failure surfaces (AC1 — the window recovers promptly). + expect(Date.now() - t0).toBeLessThan(20_000); + expect(err.message).not.toMatch(/failed to start within/); + expect(err.spawnOutput).toContain("duplicate column name: directories"); + }); + + it("attaches the child's output tail to the thrown error when the server never becomes healthy", async () => { + const dir = mkdtempSync(join(tmpdir(), "sm-781-")); + // A fake `opencode` that crashes like the .18 vendored binary did on a + // drifted local DB: prints the migration failure to stderr and dies. + const bin = join(dir, "opencode"); + writeFileSync( + bin, + `#!/bin/sh\necho "opencode! Error: duplicate column name: directories" 1>&2\nexit 1\n`, + ); + chmodSync(bin, 0o755); + const { channel } = captureChannel(); + const manager = new ServerManager({ + binary: bin, + cwd: dir, + healthTimeoutMs: 5000, + env: {}, + channel, + }); + await expect(manager.start()).rejects.toMatchObject({ + spawnOutput: expect.stringContaining("duplicate column name: directories"), + }); + }); + + it("success path is unchanged — no error, URL returned (AC5)", async () => { + const dir = mkdtempSync(join(tmpdir(), "sm-781-ok-")); + const serverMjs = join(dir, "fake_serve.mjs"); + writeFileSync( + serverMjs, + `import http from "node:http";\nconst port = Number(process.argv[process.argv.indexOf("--port") + 1]);\nhttp.createServer((req, res) => res.end("ok")).listen(port, "127.0.0.1", () => console.log("fake opencode serving"));\n`, + ); + const bin = join(dir, "opencode"); + writeFileSync(bin, `#!/bin/sh\nexec "${process.execPath}" "${serverMjs}" "$@"\n`); + chmodSync(bin, 0o755); + const { channel } = captureChannel(); + const manager = new ServerManager({ binary: bin, cwd: dir, healthTimeoutMs: 5000, env: {}, channel }); + const url = await manager.start(); + expect(url.port).toBeTruthy(); + await manager.stop(); + }); +}); diff --git a/packages/extension/test/standalone_spawn.test.ts b/packages/extension/test/standalone_spawn.test.ts new file mode 100644 index 00000000..ede9d725 --- /dev/null +++ b/packages/extension/test/standalone_spawn.test.ts @@ -0,0 +1,410 @@ +import { describe, it, expect } from "vitest"; +import { + isDbMigrationCrash, + spawnFailureNotice, + ReofferBackoff, + OfferGate, + FleetPollGuard, + journalSelfHeal, + goStandaloneSpawn, + type StandaloneSpawnDeps, + KNOWN_DRIFTED_MIGRATION_IDS, + localDbPath, + REOFFER_BASE_MS, + REOFFER_CEILING_MS, +} from "../src/standalone_spawn"; + +// ============================================================================ +// #781 — Go-Standalone spawn hardening: never park the window on a failed +// spawn. The 2026-09-03 incident: the vendored binary crashed on a drifted +// local DB (`duplicate column name: directories`), the attach poll had +// already been cleared, and the window was dead until reload — the failure +// surfaced as a generic "failed to start within 30s". +// ============================================================================ + +describe("isDbMigrationCrash (#781 AC3)", () => { + it("matches the known local-DB migration crash signature", () => { + expect(isDbMigrationCrash("sqlite: duplicate column name: directories")).toBe(true); + expect(isDbMigrationCrash("opencode exited code=1\ncolumns... Duplicate Column Name: directories")).toBe(true); + }); + + it("does not match unrelated spawn failures", () => { + expect(isDbMigrationCrash("opencode failed to start within 30s")).toBe(false); + expect(isDbMigrationCrash("spawn ENOENT")).toBe(false); + expect(isDbMigrationCrash("")).toBe(false); + }); +}); + +describe("spawnFailureNotice (#781 AC3)", () => { + it("surfaces the actual cause and the self-heal remediation for the DB crash", () => { + const notice = spawnFailureNotice( + "opencode failed to start within 30s — check the output channel", + "[opencode!] Error: duplicate column name: directories", + ); + expect(notice.dbCrash).toBe(true); + expect(notice.title).toMatch(/duplicate column name/i); + expect(notice.title).toMatch(/schema migration/i); + expect(notice.actions).toContain("Self-heal local DB…"); + }); + + it("keeps the generic message for non-matching failures", () => { + const notice = spawnFailureNotice("opencode failed to start within 30s"); + expect(notice.dbCrash).toBe(false); + expect(notice.title).toContain("go standalone failed"); + expect(notice.actions).not.toContain("Self-heal local DB…"); + }); + + it("detects the signature from spawn output alone", () => { + const notice = spawnFailureNotice("opencode failed to start within 30s", "duplicate column name: directories"); + expect(notice.dbCrash).toBe(true); + }); +}); + +describe("ReofferBackoff (#781 AC2 — re-offer with backoff, reset on attach)", () => { + it("is due immediately before any offer", () => { + const bo = new ReofferBackoff(() => 1000); + expect(bo.due()).toBe(true); + }); + + it("is not due right after an offer, but becomes due after the base delay", () => { + let t = 1000; + const bo = new ReofferBackoff(() => t); + bo.recordOffer(); + t += REOFFER_BASE_MS - 1; + expect(bo.due()).toBe(false); + t += 1; + expect(bo.due()).toBe(true); + }); + + it("backs off exponentially between offers", () => { + let t = 0; + const bo = new ReofferBackoff(() => t); + bo.recordOffer(); // offer 1 + t += REOFFER_BASE_MS - 1; + expect(bo.due()).toBe(false); + bo.recordOffer(); // offer 2 — delay now 2× + t += 2 * REOFFER_BASE_MS - 1; + expect(bo.due()).toBe(false); + bo.recordOffer(); // offer 3 — delay now 4× + t += 4 * REOFFER_BASE_MS - 1; + expect(bo.due()).toBe(false); + t += 1; + expect(bo.due()).toBe(true); + }); + + it("caps the backoff at the ceiling (5 min)", () => { + let t = 0; + const bo = new ReofferBackoff(() => t); + for (let i = 0; i < 20; i++) bo.recordOffer(); + t += REOFFER_CEILING_MS - 1; + expect(bo.due()).toBe(false); + t += 1; + expect(bo.due()).toBe(true); + }); + + it("resets on a successful attach — due again immediately, schedule restarts at base", () => { + let t = 0; + const bo = new ReofferBackoff(() => t); + bo.recordOffer(); + bo.recordOffer(); + bo.recordOffer(); + bo.reset(); + expect(bo.due()).toBe(true); + bo.recordOffer(); + t += REOFFER_BASE_MS - 1; + expect(bo.due()).toBe(false); + t += 1; + expect(bo.due()).toBe(true); + }); +}); + +describe("OfferGate (#781 AC4 — no double banners)", () => { + it("admits one offer and blocks the rest until it ends", () => { + const gate = new OfferGate(); + expect(gate.tryBegin()).toBe(true); + expect(gate.tryBegin()).toBe(false); + gate.end(); + expect(gate.tryBegin()).toBe(true); + }); +}); + +describe("FleetPollGuard (#781 AC1 + AC4 — poll lifetime vs spawn)", () => { + it("stops the poll exactly once when the local server is healthy", () => { + let stops = 0; + const guard = new FleetPollGuard({ stopPoll: () => stops++, resumePoll: () => {} }); + guard.onLocalHealthy(); + guard.onLocalHealthy(); + expect(stops).toBe(1); + }); + + it("never stops the poll before the spawn — stop only happens on healthy", () => { + let stops = 0; + let resumes = 0; + const guard = new FleetPollGuard({ stopPoll: () => stops++, resumePoll: () => resumes++ }); + guard.onSpawnFailure(); + expect(stops).toBe(0); + expect(resumes).toBe(1); + }); + + it("resumes polling after a failure that follows a prior successful stop", () => { + let stops = 0; + let resumes = 0; + const guard = new FleetPollGuard({ stopPoll: () => stops++, resumePoll: () => resumes++ }); + guard.onLocalHealthy(); + expect(stops).toBe(1); + guard.onSpawnFailure(); + expect(resumes).toBe(1); + guard.onLocalHealthy(); + expect(stops).toBe(2); + }); + + it("failure after failure keeps requesting a poll (idempotent resume)", () => { + let resumes = 0; + const guard = new FleetPollGuard({ stopPoll: () => {}, resumePoll: () => resumes++ }); + guard.onSpawnFailure(); + guard.onSpawnFailure(); + expect(resumes).toBe(2); + }); +}); + +describe("localDbPath (#781 AC3)", () => { + it("points at the opencode data dir's session DB", () => { + expect(localDbPath()).toMatch(/opencode[\\/]opencode\.db$/); + }); +}); + +describe("goStandaloneSpawn (#781 wiring — poll lifetime, failure notice, confirmed heal)", () => { + const baseDeps = (over: Partial = {}): StandaloneSpawnDeps => ({ + poll: { stopPoll: () => {}, resumePoll: () => {} }, + startServer: async () => {}, + notice: async () => undefined, + confirmHeal: async () => false, + heal: async () => ({ ok: true, steps: [] }), + onHealResult: () => {}, + ...over, + }); + const dbCrashError = () => { + const e = new Error("opencode failed to start within 30s — check the output channel") as Error & { spawnOutput?: string }; + e.spawnOutput = "opencode! Error: duplicate column name: directories"; + return e; + }; + + it("AC1/AC4: success stops the poll exactly once and never resumes it", async () => { + let stops = 0; + let resumes = 0; + const result = await goStandaloneSpawn( + baseDeps({ poll: { stopPoll: () => stops++, resumePoll: () => resumes++ } }), + ); + expect(result.ok).toBe(true); + expect(stops).toBe(1); + expect(resumes).toBe(0); + }); + + it("AC1: the poll is still running while the spawn is in flight — stop happens only after health", async () => { + let stops = 0; + let stopDuringSpawn: number | undefined; + const result = await goStandaloneSpawn( + baseDeps({ + poll: { + stopPoll: () => stops++, + resumePoll: () => {}, + }, + startServer: async () => { + stopDuringSpawn = stops; // the old bug cleared the poll BEFORE this line + }, + }), + ); + expect(result.ok).toBe(true); + expect(stopDuringSpawn).toBe(0); // nothing was stopped while spawning + expect(stops).toBe(1); // and exactly once, after health + }); + + it("AC1/AC3: failure resumes the poll and surfaces the DB-crash cause, not the generic timeout", async () => { + let stops = 0; + let resumes = 0; + let shown: string | undefined; + const result = await goStandaloneSpawn( + baseDeps({ + poll: { stopPoll: () => stops++, resumePoll: () => resumes++ }, + startServer: async () => { + throw dbCrashError(); + }, + notice: async (n) => { + shown = n.title; + return undefined; + }, + }), + ); + expect(result.ok).toBe(false); + expect(resumes).toBe(1); + expect(stops).toBe(0); + expect(shown).toMatch(/duplicate column name/i); + expect(result.notice?.dbCrash).toBe(true); + }); + + it("AC3: picking the heal action and confirming runs the self-heal and reports the result", async () => { + const healed: boolean[] = []; + const result = await goStandaloneSpawn( + baseDeps({ + startServer: async () => { + throw dbCrashError(); + }, + notice: async () => "Self-heal local DB…", + confirmHeal: async () => true, + heal: async () => { + healed.push(true); + return { ok: true, steps: [{ step: "insert", ok: true, detail: "journal row present" }] }; + }, + onHealResult: (r) => healed.push(r.ok), + }), + ); + expect(result.ok).toBe(false); + expect(result.heal?.ok).toBe(true); + expect(healed).toEqual([true, true]); + }); + + it("CONSTRAINT: the self-heal NEVER runs without user confirmation", async () => { + let healRan = false; + const result = await goStandaloneSpawn( + baseDeps({ + startServer: async () => { + throw dbCrashError(); + }, + notice: async () => "Self-heal local DB…", + confirmHeal: async () => false, + heal: async () => { + healRan = true; + return { ok: true, steps: [] }; + }, + }), + ); + expect(result.ok).toBe(false); + expect(result.heal).toBeUndefined(); + expect(healRan).toBe(false); + }); + + it("CONSTRAINT: dismissing the failure notice never runs the self-heal", async () => { + let healRan = false; + let confirmAsked = false; + await goStandaloneSpawn( + baseDeps({ + startServer: async () => { + throw dbCrashError(); + }, + notice: async () => undefined, + confirmHeal: async () => { + confirmAsked = true; + return true; + }, + heal: async () => { + healRan = true; + return { ok: true, steps: [] }; + }, + }), + ); + expect(confirmAsked).toBe(false); + expect(healRan).toBe(false); + }); + + it("AC3: a generic failure keeps the existing error surface — no heal flow", async () => { + let confirmAsked = false; + const result = await goStandaloneSpawn( + baseDeps({ + startServer: async () => { + throw new Error("spawn ENOENT"); + }, + notice: async () => "Show log", + confirmHeal: async () => { + confirmAsked = true; + return true; + }, + heal: async () => ({ ok: true, steps: [] }), + }), + ); + expect(result.ok).toBe(false); + expect(result.notice?.dbCrash).toBe(false); + expect(result.notice?.title).toContain("go standalone failed — spawn ENOENT"); + expect(confirmAsked).toBe(false); + }); +}); + +describe("journalSelfHeal (#781 AC3 — the Aug-30 medicine, confirmed + backed up)", () => { + const fsDeps = () => ({ existsSync: () => true, copyFileSync: () => {} }); + const okRun = (expectedCmd: (cmd: string[]) => boolean) => async (cmd: string[]) => { + if (!expectedCmd(cmd)) return { code: 1, stdout: "", stderr: "unexpected command" }; + return { code: 0, stdout: "1|1", stderr: "" }; + }; + + it("backs up the DB, verifies the drift, and inserts the missing journal row", async () => { + const copies: Array<[string, string]> = []; + const cmds: string[][] = []; + const result = await journalSelfHeal({ + dbPath: "/data/opencode.db", + run: async (cmd) => { + cmds.push(cmd); + return { code: 0, stdout: "1|1", stderr: "" }; + }, + fs: { existsSync: () => true, copyFileSync: (src, dest) => copies.push([src, dest]) }, + now: () => 1700000000000, + }); + expect(result.ok).toBe(true); + expect(copies).toHaveLength(1); + expect(copies[0]![0]).toBe("/data/opencode.db"); + expect(copies[0]![1]).toMatch(/^\/data\/opencode\.db\.bak-/); + const insert = cmds.find((c) => c.some((a) => a.includes("INSERT OR IGNORE"))); + expect(insert).toBeDefined(); + expect(insert!.join(" ")).toContain(KNOWN_DRIFTED_MIGRATION_IDS[0]!); + expect(insert!.join(" ")).toContain("migration"); + }); + + it("refuses to fake the journal row when the schema does not show the migration applied", async () => { + const result = await journalSelfHeal({ + dbPath: "/data/opencode.db", + run: async () => ({ code: 0, stdout: "1|0", stderr: "" }), + fs: fsDeps(), + }); + expect(result.ok).toBe(false); + expect(result.steps.some((s) => !s.ok && /refus/i.test(s.detail))).toBe(true); + }); + + it("reports honestly when the journal table is missing", async () => { + const result = await journalSelfHeal({ + dbPath: "/data/opencode.db", + run: async () => ({ code: 0, stdout: "0|1", stderr: "" }), + fs: fsDeps(), + }); + expect(result.ok).toBe(false); + expect(result.steps.some((s) => !s.ok && /journal table/i.test(s.detail))).toBe(true); + }); + + it("fails when the local DB does not exist — nothing is written", async () => { + const result = await journalSelfHeal({ + dbPath: "/data/opencode.db", + run: async () => ({ code: 0, stdout: "1|1", stderr: "" }), + fs: { existsSync: () => false, copyFileSync: () => {} }, + }); + expect(result.ok).toBe(false); + expect(result.steps[0]!.ok).toBe(false); + expect(result.steps[0]!.detail).toMatch(/not found/); + }); + + it("propagates sqlite3 failures honestly", async () => { + const result = await journalSelfHeal({ + dbPath: "/data/opencode.db", + run: async () => ({ code: 127, stdout: "", stderr: "sqlite3: command not found" }), + fs: fsDeps(), + }); + expect(result.ok).toBe(false); + expect(result.steps.some((s) => !s.ok)).toBe(true); + }); + + it("continues past journal ids already present (INSERT OR IGNORE is idempotent)", async () => { + const result = await journalSelfHeal({ + dbPath: "/data/opencode.db", + run: async () => ({ code: 0, stdout: "1|1", stderr: "" }), + fs: fsDeps(), + migrationIds: ["20260828201050_normal_stryfe"], + }); + expect(result.ok).toBe(true); + }); +});