diff --git a/packages/coding-agent/src/session/history-storage.ts b/packages/coding-agent/src/session/history-storage.ts index 161edadca..037270906 100644 --- a/packages/coding-agent/src/session/history-storage.ts +++ b/packages/coding-agent/src/session/history-storage.ts @@ -19,12 +19,48 @@ type HistoryRow = { const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)"; +class AsyncDrain { + #queue?: T[]; + #promise = Promise.resolve(); + + constructor(readonly delayMs: number = 0) {} + + push(value: T, hnd: (values: T[]) => Promise | void): Promise { + let queue = this.#queue; + if (!queue) { + queue = []; + this.#queue = []; + this.#promise = new Promise((resolve, reject) => { + const exec = () => { + try { + if (this.#queue === queue) { + this.#queue = undefined; + } + resolve(hnd(queue!)); + } catch (error) { + reject(error); + } + }; + + if (this.delayMs > 0) { + setTimeout(exec, this.delayMs); + } else { + queueMicrotask(exec); + } + }); + } + queue.push(value); + return this.#promise; + } +} + export class HistoryStorage { #db: Database; static #instance?: HistoryStorage; + #drain = new AsyncDrain>(100); // Prepared statements - #insertStmt: Statement; + #insertRowStmt: Statement; #recentStmt: Statement; #searchStmt: Statement; #lastPromptStmt: Statement; @@ -71,7 +107,6 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN } } - this.#insertStmt = this.#db.prepare("INSERT INTO history (prompt, cwd) VALUES (?, ?)"); this.#recentStmt = this.#db.prepare( "SELECT id, prompt, created_at, cwd FROM history ORDER BY created_at DESC, id DESC LIMIT ?", ); @@ -80,6 +115,8 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN ); this.#lastPromptStmt = this.#db.prepare("SELECT prompt FROM history ORDER BY id DESC LIMIT 1"); + this.#insertRowStmt = this.#db.prepare("INSERT INTO history (prompt, cwd) VALUES (?, ?)"); + const last = this.#lastPromptStmt.get() as { prompt?: string } | undefined; this.#lastPromptCache = last?.prompt ?? null; } @@ -91,19 +128,21 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN return HistoryStorage.#instance; } - add(prompt: string, cwd?: string): void { - const trimmed = prompt.trim(); - if (!trimmed) return; - if (this.#lastPromptCache === trimmed) return; - - this.#lastPromptCache = trimmed; - - setImmediate(() => { - try { - this.#insertStmt.run(trimmed, cwd ?? null); - } catch (error) { - logger.error("HistoryStorage add failed", { error: String(error) }); + #insertBatch(rows: Array>): void { + this.#db.transaction((rows: Array>) => { + for (const row of rows) { + this.#insertRowStmt.run(row.prompt, row.cwd ?? null); } + })(rows); + } + + add(prompt: string, cwd?: string): Promise { + const trimmed = prompt.trim(); + if (!trimmed) return Promise.resolve(); + if (this.#lastPromptCache === trimmed) return Promise.resolve(); + this.#lastPromptCache = trimmed; + return this.#drain.push({ prompt: trimmed, cwd: cwd ?? undefined }, rows => { + this.#insertBatch(rows); }); } diff --git a/packages/coding-agent/test/history-storage-sqlite-compat.test.ts b/packages/coding-agent/test/history-storage-sqlite-compat.test.ts index 3e2f7555f..076dbc24c 100644 --- a/packages/coding-agent/test/history-storage-sqlite-compat.test.ts +++ b/packages/coding-agent/test/history-storage-sqlite-compat.test.ts @@ -46,9 +46,7 @@ it("migrates legacy history schema away from unixepoch defaults", async () => { legacyDb.close(); const storage = HistoryStorage.open(dbPath); - storage.add("new prompt", "/tmp/new"); - // add() defers the INSERT via setImmediate; drain the macrotask queue - await new Promise(resolve => setImmediate(resolve)); + await storage.add("new prompt", "/tmp/new"); const db = new Database(dbPath, { readonly: true }); try { diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 522e4a6b7..a0b6b4f14 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,4 +1,4 @@ -import { getProjectDir } from "@oh-my-pi/pi-utils"; +import { getProjectDir, logger } from "@oh-my-pi/pi-utils"; import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete"; import { BracketedPasteHandler } from "../bracketed-paste"; import { getKeybindings, type KeybindingsManager } from "../keybindings"; @@ -304,7 +304,7 @@ interface HistoryEntry { } interface HistoryStorage { - add(prompt: string, cwd?: string): void; + add(prompt: string, cwd?: string): Promise; getRecent(limit: number): HistoryEntry[]; } @@ -478,7 +478,12 @@ export class Editor implements Component, Focusable { this.#history.pop(); } - this.#historyStorage?.add(trimmed, getProjectDir()); + const stor = this.#historyStorage; + if (stor) { + stor.add(trimmed, getProjectDir()).catch(error => { + logger.error("HistoryStorage add failed", { error: String(error) }); + }); + } } #isEditorEmpty(): boolean {