From d2e94959e01b871fad3db64ab8ef70cea635b6f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 23 Apr 2026 22:14:01 -0400 Subject: [PATCH] fix: make run log creation atomic --- src/main/database-base.ts | 12 ++- src/main/database/runs.ts | 26 +++-- tests/unit/run-log-atomicity.test.ts | 155 +++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 7 deletions(-) create mode 100644 tests/unit/run-log-atomicity.test.ts diff --git a/src/main/database-base.ts b/src/main/database-base.ts index 17d0b8d3..c313b204 100644 --- a/src/main/database-base.ts +++ b/src/main/database-base.ts @@ -1,4 +1,4 @@ -import { mkdirSync } from "node:fs"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { DatabaseSync, type SQLInputValue } from "node:sqlite"; import { buildThreadMethods } from "./database/threads"; @@ -38,6 +38,15 @@ function nowIso() { return new Date().toISOString(); } +function assertWritableDirectory(dir: string, label: string): void { + const probePath = join(dir, `.agentcompany-${label}-write-probe-${process.pid}-${Date.now()}`); + try { + writeFileSync(probePath, "", { encoding: "utf8", flag: "wx" }); + } finally { + rmSync(probePath, { force: true }); + } +} + export interface AppDatabaseCoreModules { threads: ReturnType; company: ReturnType; @@ -101,6 +110,7 @@ export class AppDatabaseBase { this.appVersion = options.appVersion ?? "unknown"; mkdirSync(rootDir, { recursive: true }); mkdirSync(this.logsDir, { recursive: true }); + assertWritableDirectory(this.logsDir, "logs"); mkdirSync(this.artifactsDir, { recursive: true }); mkdirSync(this.backupsDir, { recursive: true }); this.vault = new SecretVault(rootDir, { isPackaged: options.isPackaged }); diff --git a/src/main/database/runs.ts b/src/main/database/runs.ts index 53d53e4b..ca64889a 100644 --- a/src/main/database/runs.ts +++ b/src/main/database/runs.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { writeFileSync } from "node:fs"; +import { rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { DatabaseSync, SQLInputValue } from "node:sqlite"; import type { ConnectorId, RunRecord, RunUsageStatus } from "@shared/types"; @@ -24,6 +24,14 @@ export function buildRunMethods( logsDir: string; }, ) { + function createEmptyRunLog(logPath: string): void { + writeFileSync(logPath, "", { encoding: "utf8", flag: "wx" }); + } + + function cleanupEmptyRunLog(logPath: string): void { + rmSync(logPath, { force: true }); + } + function insertRunRow(input: { id: string; companyId: string; @@ -85,10 +93,15 @@ export function buildRunMethods( const id = input.id ?? randomUUID(); const ts = nowIso(); const logPath = join(deps.logsDir, `${id}.log`); - transaction(() => { - insertRunRow({ ...input, id, logPath, ts }); - }); - writeFileSync(logPath, "", "utf8"); + createEmptyRunLog(logPath); + try { + transaction(() => { + insertRunRow({ ...input, id, logPath, ts }); + }); + } catch (error) { + cleanupEmptyRunLog(logPath); + throw error; + } return { id, logPath }; }, @@ -103,6 +116,7 @@ export function buildRunMethods( const id = input.id ?? randomUUID(); const ts = nowIso(); const logPath = join(deps.logsDir, `${id}.log`); + createEmptyRunLog(logPath); try { transaction(() => { insertRunRow({ ...input, id, logPath, ts }); @@ -112,12 +126,12 @@ export function buildRunMethods( } }); } catch (error) { + cleanupEmptyRunLog(logPath); if (error instanceof TaskCheckoutConflict) { return null; } throw error; } - writeFileSync(logPath, "", "utf8"); return { id, logPath }; }, diff --git a/tests/unit/run-log-atomicity.test.ts b/tests/unit/run-log-atomicity.test.ts new file mode 100644 index 00000000..485f2a67 --- /dev/null +++ b/tests/unit/run-log-atomicity.test.ts @@ -0,0 +1,155 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + safeStorage: { + isEncryptionAvailable: () => false, + getSelectedStorageBackend: () => "basic_text", + encryptString: (value: string) => Buffer.from(value, "utf8"), + decryptString: (value: Buffer) => value.toString("utf8"), + }, +})); + +import { AppDatabase } from "../../src/main/database"; + +type RawDb = { + db: { + prepare: (sql: string) => { + get: (...args: unknown[]) => unknown; + }; + }; +}; + +function createCompany(db: AppDatabase): string { + return db.core.company.saveCompany({ + name: "Run Log Atomicity Co", + description: "Run log creation tests", + status: "active", + }); +} + +function createConnector(db: AppDatabase): void { + db.core.connectors.saveConnector({ + id: "openai-codex", + model: "gpt-5", + envBindingText: "", + notes: "Test connector", + }); +} + +function createWorkspace(db: AppDatabase, companyId: string): string { + return db.core.workspaces.saveWorkspace({ + companyId, + name: "Workspace", + localPath: "/tmp/run-log-atomicity", + repoUrl: "", + repoRef: "", + isPrimary: true, + }); +} + +function createAgent(db: AppDatabase, companyId: string, workspaceId: string): string { + return db.core.agents.saveAgent({ + companyId, + name: "Agent", + role: "IC", + title: "Agent", + status: "active", + reportsTo: null, + connectorId: "openai-codex", + workspaceId, + model: null, + capabilities: "Executes tasks", + budgetMonthlyUsd: 100, + }); +} + +function createTask(db: AppDatabase, companyId: string, agentId: string | null, workspaceId: string): string { + return db.core.tasks.saveTask({ + companyId, + title: "Task", + description: "A task", + assigneeAgentId: agentId, + workspaceId, + priority: "medium", + status: "todo", + }); +} + +describe("run log creation atomicity", () => { + let tempDir: string; + let db: AppDatabase; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "agentcompany-run-log-")); + db = new AppDatabase(tempDir); + db.init(); + }); + + afterEach(async () => { + db.close(); + await rm(tempDir, { recursive: true, force: true }); + }); + + it("createRun rejects before inserting the run row when the log path is unavailable", () => { + const companyId = createCompany(db); + createConnector(db); + const workspaceId = createWorkspace(db, companyId); + const agentId = createAgent(db, companyId, workspaceId); + const taskId = createTask(db, companyId, null, workspaceId); + const blockedRunId = randomUUID(); + mkdirSync(join(db.logsDir, `${blockedRunId}.log`)); + + expect(() => + db.core.runs.createRun({ + id: blockedRunId, + companyId, + taskId, + agentId, + workspaceId, + connectorId: "openai-codex", + }), + ).toThrow(); + + const storedRun = (db as unknown as RawDb).db + .prepare("SELECT id FROM runs WHERE id = ?") + .get(blockedRunId); + expect(storedRun).toBeUndefined(); + const task = db.core.entity.getTask(taskId); + expect(task.activeRunId).toBeNull(); + expect(task.status).toBe("todo"); + }); + + it("createTaskRun removes the empty log when checkout loses the race", () => { + const companyId = createCompany(db); + createConnector(db); + const workspaceId = createWorkspace(db, companyId); + const agentId = createAgent(db, companyId, workspaceId); + const taskId = createTask(db, companyId, null, workspaceId); + const firstRun = db.core.runs.createTaskRun({ + companyId, + taskId, + agentId, + workspaceId, + connectorId: "openai-codex", + }); + expect(firstRun).not.toBeNull(); + + const losingRunId = randomUUID(); + const secondRun = db.core.runs.createTaskRun({ + id: losingRunId, + companyId, + taskId, + agentId, + workspaceId, + connectorId: "openai-codex", + }); + + expect(secondRun).toBeNull(); + expect(existsSync(join(db.logsDir, `${losingRunId}.log`))).toBe(false); + }); +});