diff --git a/src/main/db/connection.ts b/src/main/db/connection.ts index 062569987..aa72c7929 100644 --- a/src/main/db/connection.ts +++ b/src/main/db/connection.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import Database from "better-sqlite3"; import { drizzle } from "drizzle-orm/better-sqlite3"; import * as schema from "../db.schema"; +import { resetMainCreatedThreads } from "./mainCreatedThreads"; import { assertRequiredDatabaseSchema, repairSafeSchemaDrift, @@ -316,4 +317,5 @@ export function closeDatabase() { } _sqlite = undefined; _db = undefined; + resetMainCreatedThreads(); } diff --git a/src/main/db/mainCreatedThreads.ts b/src/main/db/mainCreatedThreads.ts new file mode 100644 index 000000000..670fc489d --- /dev/null +++ b/src/main/db/mainCreatedThreads.ts @@ -0,0 +1,36 @@ +/** + * Thread rows main inserted itself — remote `start` commands, schedules, + * orchestrator launches — that the renderer's store has not mirrored yet. + * + * The renderer persists its whole store through `dbSyncAll`, which deletes every + * thread row missing from that snapshot. A main-created row is missing from it + * until the forwarded `start` command reaches the renderer, and the delete + * cascades into `thread_runtime_items`: the launch turn's `user_message` (already + * persisted from the supervisor's emit) disappears, and later events are dropped + * until the renderer re-creates the row. Ids tracked here are exempt from that + * delete; the first renderer snapshot carrying an id hands ownership back to the + * renderer, so its own deletions keep working. + */ +const unmirroredThreadIds = new Set(); + +export function noteMainCreatedThread(threadId: string): void { + unmirroredThreadIds.add(threadId); +} + +export function forgetMainCreatedThread(threadId: string): void { + unmirroredThreadIds.delete(threadId); +} + +export function isMainCreatedThreadUnmirrored(threadId: string): boolean { + return unmirroredThreadIds.has(threadId); +} + +/** A renderer snapshot arrived: every thread it carries is renderer-owned now. */ +export function acknowledgeMirroredThreadIds(threadIds: Iterable): void { + for (const threadId of threadIds) unmirroredThreadIds.delete(threadId); +} + +/** Tied to the open database — a new database starts with no pending rows. */ +export function resetMainCreatedThreads(): void { + unmirroredThreadIds.clear(); +} diff --git a/src/main/db/projectsThreads.ts b/src/main/db/projectsThreads.ts index a70e8647a..6d7e84342 100644 --- a/src/main/db/projectsThreads.ts +++ b/src/main/db/projectsThreads.ts @@ -2,6 +2,7 @@ import { asc, eq, notInArray } from "drizzle-orm"; import type { Project, Thread } from "@/shared/contracts"; import * as schema from "../db.schema"; import { getDb } from "./connection"; +import { forgetMainCreatedThread, noteMainCreatedThread } from "./mainCreatedThreads"; import { notifyProjectThreadDataChanged } from "./projectThreadChanges"; import { projectMutableRow, rowToProject, rowToThread } from "./rowMappers"; @@ -84,6 +85,15 @@ export function dbUpdateProject(project: Project): void { export function dbUpsertThread(thread: Thread, sortOrder: number): void { const db = getDb(); + // A row main inserts on its own is invisible to the renderer's store until the + // forwarded command reaches it, so shield it from `dbSyncAll`'s delete pass + // (see mainCreatedThreads). + const isNewRow = + db + .select({ id: schema.threads.id }) + .from(schema.threads) + .where(eq(schema.threads.id, thread.id)) + .get() === undefined; db.insert(schema.threads) .values({ id: thread.id, @@ -147,6 +157,7 @@ export function dbUpsertThread(thread: Thread, sortOrder: number): void { }, }) .run(); + if (isNewRow) noteMainCreatedThread(thread.id); notifyProjectThreadDataChanged(); } @@ -182,6 +193,7 @@ export function dbMarkLiveThreadsInactive(): void { export function dbDeleteThread(threadId: string): void { const db = getDb(); db.delete(schema.threads).where(eq(schema.threads.id, threadId)).run(); + forgetMainCreatedThread(threadId); notifyProjectThreadDataChanged(); } diff --git a/src/main/db/sync.test.ts b/src/main/db/sync.test.ts new file mode 100644 index 000000000..4bf636528 --- /dev/null +++ b/src/main/db/sync.test.ts @@ -0,0 +1,110 @@ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import Database from "better-sqlite3"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { Project, Thread } from "@/shared/contracts"; +import { closeDatabase, initDatabase } from "./connection"; +import { dbGetThread, dbUpsertProject, dbUpsertThread } from "./projectsThreads"; +import { dbApplyThreadRuntimeEvents, dbGetThreadRuntimeItems } from "./runtimeItems"; +import { dbSyncAll } from "./sync"; + +const serverNativeBinding = join(process.cwd(), "dist", "server-native", "better_sqlite3.node"); +let nativeBindingEnv: string | undefined; +let sqliteAvailable = true; +try { + new Database(":memory:").close(); +} catch { + if (existsSync(serverNativeBinding)) { + nativeBindingEnv = serverNativeBinding; + } else { + sqliteAvailable = false; + } +} + +const project: Project = { + id: "project-1", + name: "Test project", + location: { kind: "posix", path: "/tmp/project" }, + createdAt: "2026-01-01T00:00:00.000Z", +}; + +function remoteStartedThread(): Thread { + return { + id: "thread-remote", + projectId: project.id, + title: "Started from a remote client", + agentKind: "claude", + config: { model: "claude-opus-5" }, + status: "launching", + attention: "none", + canResumeWithConfig: false, + archived: false, + done: false, + starred: false, + presentationMode: "gui", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function persistLaunchUserMessage(threadId: string): void { + dbApplyThreadRuntimeEvents(threadId, [ + { type: "turn.started", threadId, turnId: "turn-1" }, + { + type: "item.started", + threadId, + itemId: "user-1", + itemType: "user_message", + payload: { content: [{ kind: "text", text: "fix the sidebar" }] }, + }, + { type: "item.completed", threadId, itemId: "user-1" }, + ]); +} + +describe.skipIf(!sqliteAvailable)("dbSyncAll thread ownership", () => { + let dir: string; + + beforeEach(() => { + if (nativeBindingEnv) { + process.env.PORACODE_BETTER_SQLITE3_NATIVE_BINDING = nativeBindingEnv; + } + dir = mkdtempSync(join(tmpdir(), "poracode-sync-db-test-")); + initDatabase(join(dir, "state.sqlite")); + dbUpsertProject(project, 0); + }); + + afterEach(() => { + closeDatabase(); + rmSync(dir, { recursive: true, force: true }); + delete process.env.PORACODE_BETTER_SQLITE3_NATIVE_BINDING; + }); + + it("keeps a main-created thread (and its launch transcript) that the renderer has not mirrored yet", () => { + dbUpsertThread(remoteStartedThread(), 0); + persistLaunchUserMessage("thread-remote"); + + // Renderer flushes its store before the forwarded `start` command lands. + dbSyncAll([project], [], JSON.stringify({ kind: "home" })); + + expect(dbGetThread("thread-remote")).not.toBeNull(); + expect(dbGetThreadRuntimeItems("thread-remote").map((item) => item.type)).toEqual([ + "user_message", + ]); + }); + + it("still deletes a thread the renderer dropped after it had mirrored it", () => { + dbUpsertThread(remoteStartedThread(), 0); + persistLaunchUserMessage("thread-remote"); + + // Renderer applied the command: its snapshot now carries the thread. + dbSyncAll([project], [remoteStartedThread()], JSON.stringify({ kind: "home" })); + expect(dbGetThreadRuntimeItems("thread-remote")).toHaveLength(1); + + // The user deletes it in the renderer. + dbSyncAll([project], [], JSON.stringify({ kind: "home" })); + + expect(dbGetThread("thread-remote")).toBeNull(); + expect(dbGetThreadRuntimeItems("thread-remote")).toEqual([]); + }); +}); diff --git a/src/main/db/sync.ts b/src/main/db/sync.ts index a2564345b..f90949a67 100644 --- a/src/main/db/sync.ts +++ b/src/main/db/sync.ts @@ -7,6 +7,7 @@ import { } from "@/shared/contracts"; import type { DbPersistExperimentStatePayload } from "@/shared/ipc"; import { getSqlite } from "./connection"; +import { acknowledgeMirroredThreadIds, isMainCreatedThreadUnmirrored } from "./mainCreatedThreads"; import { notifyProjectThreadDataChanged } from "./projectThreadChanges"; import { projectMutableRow } from "./rowMappers"; @@ -44,13 +45,20 @@ export function dbSyncAll(projectsData: Project[], threadsData: Thread[], viewJs const upsertThread = prepareThreadSyncStatement(sqlite); for (const tid of existingThreadIds) { - if (!incomingThreadIds.has(tid)) { - deleteThread.run(tid); - } + if (incomingThreadIds.has(tid)) continue; + // A thread main just created (remote `start`, schedule, orchestrator) is + // absent from this snapshot only because the renderer has not applied the + // forwarded command yet. Deleting it would cascade away the launch turn's + // runtime items — most visibly the initial `user_message`. + if (isMainCreatedThreadUnmirrored(tid)) continue; + deleteThread.run(tid); } for (let i = 0; i < threadsData.length; i++) { runThreadSync(upsertThread, threadsData[i]!, i); } + // Anything in this snapshot is renderer-owned from here on, so a later + // snapshot that drops it is a real deletion. + acknowledgeMirroredThreadIds(incomingThreadIds); sqlite .prepare(