From 036840a1f97e0e7e906f94505a41d1051cb45ad6 Mon Sep 17 00:00:00 2001 From: aaron Date: Sat, 8 Aug 2026 15:31:19 -0400 Subject: [PATCH] feat(extension): channel-flip guard for the chat session DB opencode resolves its chat DB by build channel (dev -> opencode-dev.db, unbranded/local -> opencode-local.db, ...). A vendored-binary refresh can therefore boot a HEALTHY server on a FRESH database: panels show an empty history while the real one sits untouched on disk (fleet incident 2026-08-08, diagnosed by hand over hours). Two cheap, never-blocking additions to ServerManager: - log the spawned binary's sha256 at start (provenance for post-mortems, complements #292) - warnIfServingFreshDb: after health, if /session serves < 10 sessions while a sibling opencode-*.db on disk exceeds 32 MB, append a WARNING to the output channel and toast with an 'Open Output' action False-positive analysis: fresh installs have no large sibling DB (silent); a fork serving its own large DB serves a high session count (silent); the client-tunnel path serves the canonical count through the tunnel (silent). Any probe failure is silent by construction. --- packages/extension/src/server_manager.ts | 51 ++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/packages/extension/src/server_manager.ts b/packages/extension/src/server_manager.ts index 3cd6760a..67c18759 100644 --- a/packages/extension/src/server_manager.ts +++ b/packages/extension/src/server_manager.ts @@ -1,6 +1,10 @@ import * as vscode from "vscode"; import * as cp from "node:child_process"; +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; import * as net from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; import type { Readable } from "node:stream"; import { serverAuthHeader } from "./server_auth"; @@ -58,6 +62,10 @@ export class ServerManager { const port = this.opts.port ?? (await pickFreePort()); this._port = port; this.opts.channel.appendLine(`[server] spawning opencode serve --port=${port} (cwd=${this.opts.cwd})`); + void hashFile(this.opts.binary).then( + (sum) => this.opts.channel.appendLine(`[server] binary sha256=${sum} (${this.opts.binary})`), + () => {}, + ); const child = cp.spawn(this.opts.binary, ["serve", "--port", String(port)], { cwd: this.opts.cwd, @@ -88,10 +96,48 @@ export class ServerManager { this._ready = true; const url = new URL(`http://127.0.0.1:${port}`); this.opts.channel.appendLine(`[server] ready at ${url}`); + void this.warnIfServingFreshDb(port, password).catch(() => {}); this._onReady.fire(url); return url; } + /** Channel-flip guard (fleet incident 2026-08-08): opencode picks its chat + * DB by build channel (dev → opencode-dev.db, unbranded/local → + * opencode-local.db, …), so a binary swap can boot a HEALTHY server on a + * FRESH database — panels then show an empty history while the real one + * sits untouched on disk. Detect that exact shape: a nearly-empty served + * session list while a large sibling DB exists. Fire-and-forget by design; + * never blocks readiness, and silent on any probe failure. */ + private async warnIfServingFreshDb(port: number, password?: string): Promise { + const r = await fetchWithTimeout( + `http://127.0.0.1:${port}/session?limit=1000`, + 5_000, + password ? serverAuthHeader(password) : undefined, + ); + if (!r.ok) return; + const served = await r.json(); + if (!Array.isArray(served) || served.length >= 10) return; + + const dir = path.join(os.homedir(), ".local", "share", "opencode"); + let biggest: { file: string; bytes: number } | undefined; + for (const f of fs.readdirSync(dir)) { + if (!/^opencode(-[a-z]+)?\.db$/.test(f)) continue; + const bytes = fs.statSync(path.join(dir, f)).size; + if (!biggest || bytes > biggest.bytes) biggest = { file: f, bytes }; + } + if (!biggest || biggest.bytes < 32 * 1024 * 1024) return; + + const mb = Math.round(biggest.bytes / 1024 / 1024); + this.opts.channel.appendLine( + `[server] WARNING: serving only ${served.length} session(s) but ${biggest.file} on disk is ${mb} MB — wrong channel/DB?`, + ); + const pick = await vscode.window.showWarningMessage( + `Amicode's opencode server is serving only ${served.length} session(s), yet ${biggest.file} on disk holds a real history (${mb} MB). The server likely resolved the wrong database after a binary update (build-channel flip).`, + "Open Output", + ); + if (pick === "Open Output") this.opts.channel.show(); + } + /** Resolves once the child has actually exited (bounded by the SIGKILL fallback) — * callers restarting onto a fixed port must await this or the new spawn can race * the old process for the socket. */ @@ -167,6 +213,11 @@ async function fetchWithTimeout(url: string, ms: number, authorization?: string) } } +async function hashFile(file: string): Promise { + const buf = await fs.promises.readFile(file); + return crypto.createHash("sha256").update(buf).digest("hex"); +} + function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); }