fix(thread): make wait-state side effects atomic

This commit is contained in:
ZacharyZhang-NY committed 2026-04-23 19:08:06 -04:00
1 parent f8a90f4402
commit a1dd361f51
4 files changed
+356 -83

No files matched your search

@@ -6,6 +6,7 @@ import { AppDatabase } from "../../src/main/database";
import { createThreadIntegration } from "../../src/main/thread-integration";
import { createCheckpointEngine } from "../../src/main/checkpoint-engine";
import { dispatchAgentMessage, HUMAN_SENDER_ID } from "../../src/main/messaging/dispatch";
import { isAppError } from "../../src/main/errors";
const tempDirs: string[] = [];
@@ -89,6 +90,14 @@ function createScaffold(db: AppDatabase) {
return { companyId, workspaceId, ceoId, engineerId, taskId };
}
function expectThreadIntegrationFailure(error: unknown, code: string) {
expect(isAppError(error)).toBe(true);
if (isAppError(error)) {
expect(error.kind).toBe("upstream");
expect(error.code).toBe(code);
}
}
describe("thread wait + heartbeat integration", () => {
it("pauses the assignee heartbeat on successful run completion until a human reply resolves the wait state", async () => {
const profileDir = await createTempDir("agentcompany-thread-wait-");
@@ -276,4 +285,160 @@ describe("thread wait + heartbeat integration", () => {
db.close();
});
it("rolls back approval-created thread writes when wait-state creation fails", async () => {
const profileDir = await createTempDir("agentcompany-approval-create-fail-");
const db = new AppDatabase(profileDir);
db.init();
const { companyId, engineerId, taskId } = createScaffold(db);
const threadIntegration = createThreadIntegration({
db,
logger: createNoopLogger(),
});
const approvalId = db.core.approvals.requestApproval({
companyId,
relatedTaskId: taskId,
requestedByAgentId: engineerId,
type: "draft_review",
payloadSummary: "Review the draft",
impactSummary: "Board approval is required before this work can continue.",
payloadJson: "{}",
});
vi.spyOn(db.core.threads, "setThreadWaitState").mockImplementation(() => {
throw new Error("wait-state write failed");
});
let thrown: unknown;
try {
threadIntegration.onApprovalCreated(approvalId, companyId, taskId, "draft_review", "Draft review needed");
} catch (error) {
thrown = error;
}
expectThreadIntegrationFailure(thrown, "thread.integration.onApprovalCreated.failed");
const taskThreads = db.core.threads.listThreads({
companyId,
type: "task",
relatedEntityType: "task",
relatedEntityId: taskId,
limit: 1,
});
expect(taskThreads).toHaveLength(0);
db.close();
});
it("rolls back approval resolution when decision event recording fails", async () => {
const profileDir = await createTempDir("agentcompany-approval-resolve-fail-");
const db = new AppDatabase(profileDir);
db.init();
const { companyId, engineerId, taskId } = createScaffold(db);
const threadIntegration = createThreadIntegration({
db,
logger: createNoopLogger(),
});
threadIntegration.onTaskCreated(taskId, companyId);
const approvalId = db.core.approvals.requestApproval({
companyId,
relatedTaskId: taskId,
requestedByAgentId: engineerId,
type: "draft_review",
payloadSummary: "Review the draft",
impactSummary: "Board approval is required before this work can continue.",
payloadJson: "{}",
});
threadIntegration.onApprovalCreated(approvalId, companyId, taskId, "draft_review", "Draft review needed");
const waitStateId = db.core.threads.listThreadWaitStates(companyId)
.find((entry) => entry.relatedApprovalId === approvalId)?.id;
expect(waitStateId).toBeTruthy();
const addThreadEvent = db.core.threads.addThreadEvent.bind(db.core.threads);
vi.spyOn(db.core.threads, "addThreadEvent").mockImplementation((input) => {
if (input.eventType === "approval_decision") {
throw new Error("decision event write failed");
}
return addThreadEvent(input);
});
let thrown: unknown;
try {
threadIntegration.onApprovalResolved(approvalId, companyId, "approved");
} catch (error) {
thrown = error;
}
expectThreadIntegrationFailure(thrown, "thread.integration.onApprovalResolved.failed");
const unresolved = db.core.threads.listThreadWaitStates(companyId)
.filter((entry) => entry.relatedApprovalId === approvalId);
expect(unresolved.map((entry) => entry.id)).toEqual([waitStateId]);
db.close();
});
it("rolls back human-message wait-state resolution when one resolution fails", async () => {
const profileDir = await createTempDir("agentcompany-human-message-fail-");
const db = new AppDatabase(profileDir);
db.init();
const { companyId, taskId } = createScaffold(db);
const threadIntegration = createThreadIntegration({
db,
logger: createNoopLogger(),
});
threadIntegration.onTaskCreated(taskId, companyId);
const thread = db.core.threads.listThreads({
companyId,
type: "task",
relatedEntityType: "task",
relatedEntityId: taskId,
limit: 1,
})[0];
if (!thread) {
throw new Error("Expected task thread for human-message failure testing.");
}
const first = db.core.threads.setThreadWaitState({
threadId: thread.id,
companyId,
waitingFor: "user_reply",
relatedApprovalId: null,
reason: "First reply needed",
});
const second = db.core.threads.setThreadWaitState({
threadId: thread.id,
companyId,
waitingFor: "user_review",
relatedApprovalId: null,
reason: "Second review needed",
});
const resolveThreadWaitState = db.core.threads.resolveThreadWaitState.bind(db.core.threads);
let resolutionCount = 0;
vi.spyOn(db.core.threads, "resolveThreadWaitState").mockImplementation((waitStateId, currentCompanyId) => {
resolutionCount += 1;
if (resolutionCount === 2) {
throw new Error("wait-state resolve failed");
}
resolveThreadWaitState(waitStateId, currentCompanyId);
});
let thrown: unknown;
try {
threadIntegration.onHumanMessage(companyId, null, thread.id, null);
} catch (error) {
thrown = error;
}
expectThreadIntegrationFailure(thrown, "thread.integration.onHumanMessage.failed");
const unresolvedIds = db.core.threads.listThreadWaitStates(companyId)
.filter((entry) => entry.threadId === thread.id)
.map((entry) => entry.id)
.sort();
expect(unresolvedIds).toEqual([first.id, second.id].sort());
db.close();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { join } from "node:path";
import { tmpdir } from "node:os";
import { mkdtemp, rm } from "node:fs/promises";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
beforeAll(() => {
process.env.AGENTCOMPANY_ALLOW_INSECURE_VAULT = "true";
});
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";
describe("AppDatabase transactions", () => {
let tempDir: string;
let db: AppDatabase;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-db-tx-"));
db = new AppDatabase(tempDir);
db.init();
});
afterEach(async () => {
db.close();
await rm(tempDir, { recursive: true, force: true });
});
it("rolls back nested transactions to their savepoint", () => {
const outerCompanyId = db.withTransaction(() => {
const companyId = db.core.company.saveCompany({
name: "Nested Transaction Co",
description: "Outer transaction should survive inner rollback.",
status: "active",
});
expect(() => {
db.withTransaction(() => {
db.core.company.saveCompany({
name: "Nested Transaction Rolled Back",
description: "Inner transaction should roll back.",
status: "active",
});
throw new Error("inner write failed");
});
}).toThrow("inner write failed");
return companyId;
});
const companies = db.queryAll<{ id: string; name: string }>("select id, name from companies order by created_at asc");
expect(companies.map((company) => company.id)).toContain(outerCompanyId);
expect(companies.some((company) => company.name === "Nested Transaction Rolled Back")).toBe(false);
});
});