mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
Make task recovery durable and preserve the latest user request across native and legacy continuations. Keep routine recovery quiet and prevent replay when action outcomes are uncertain. Co-Authored-By: Paperclip <noreply@paperclip.ing>
706 lines
31 KiB
TypeScript
706 lines
31 KiB
TypeScript
import { activityService } from "../activity.js";
|
|
import { buildPaperclipWakePayload, heartbeatService } from "../heartbeat.js";
|
|
import { legacyExecutionNeedsReconciliation, terminalizeLegacyExecution } from "../legacy-execution-recovery.js";
|
|
import { deliverExecutionStatuses } from "../execution-status-delivery.js";
|
|
import { publishLiveEvent } from "../live-events.js";
|
|
import {
|
|
settleUnrecoverableExecutions,
|
|
validateExecutionReconciliation,
|
|
markExecutionReconciliation,
|
|
deliverReconciledExecutions,
|
|
} from "../execution-recovery-resolution.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { tmpdir } from "node:os";
|
|
import { and, eq, inArray } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import {
|
|
agents,
|
|
companies,
|
|
createDb,
|
|
heartbeatRunEvents,
|
|
heartbeatRuns,
|
|
issueRecoveryActions,
|
|
issues,
|
|
nativeRunFinalizations,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "../../__tests__/helpers/embedded-postgres.js";
|
|
import { reconcileSafeNativeReplacements } from "./native-safe-replacement.js";
|
|
import { reconcileAbandonedExecutionControl } from "../execution-control-reconciliation.js";
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
(support.supported ? describe : describe.skip)(
|
|
"durable replacement and control recovery",
|
|
() => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
beforeAll(async () => {
|
|
database = await startEmbeddedPostgresTestDatabase(
|
|
"paperclip-safe-replacement-",
|
|
);
|
|
db = createDb(database.connectionString);
|
|
}, 30_000);
|
|
afterAll(async () => {
|
|
await database?.cleanup();
|
|
});
|
|
async function seed(attempt = 1) {
|
|
const companyId = randomUUID(),
|
|
agentId = randomUUID(),
|
|
issueId = randomUUID(),
|
|
runId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Recovery",
|
|
issuePrefix: `R${companyId.slice(0, 6)}`,
|
|
});
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: "Executor",
|
|
role: "engineer",
|
|
adapterType: "paperclip_runner",
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
title: "Read fixture",
|
|
status: "in_progress",
|
|
assigneeAgentId: agentId,
|
|
});
|
|
await db.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId,
|
|
agentId,
|
|
nativeIssueId: issueId,
|
|
runtimeMode: "native",
|
|
status: "failed",
|
|
contextSnapshot: { issueId },
|
|
runnerProfileJson: {
|
|
recoveryEventInventoryVersion: 1,
|
|
nativeExecutionInput: {
|
|
provider: { kind: "codex" },
|
|
workspace: { cwd: tmpdir() },
|
|
},
|
|
},
|
|
});
|
|
await db.insert(nativeRunFinalizations).values({
|
|
runId,
|
|
companyId,
|
|
issueId,
|
|
phase: "terminal_failure",
|
|
attempt,
|
|
failureCode: "native_provider_terminal_failed",
|
|
failureDetail: { originalFailureCode: "fixture_checkpoint_unusable" },
|
|
});
|
|
return { companyId, agentId, issueId, runId };
|
|
}
|
|
it("automatically closes an exhausted incident once, preserves ownership, and records no replay", async () => {
|
|
const source = await seed(3);
|
|
await reconcileSafeNativeReplacements(db);
|
|
await Promise.all([settleUnrecoverableExecutions(db), settleUnrecoverableExecutions(db)]);
|
|
await settleUnrecoverableExecutions(db);
|
|
const [task] = await db.select().from(issues).where(eq(issues.id, source.issueId));
|
|
expect(task).toMatchObject({ status: "blocked", assigneeAgentId: source.agentId, executionRunId: null, checkoutRunId: null });
|
|
const actions = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, source.issueId));
|
|
expect(actions).toHaveLength(1);
|
|
expect(actions[0]).toMatchObject({ status: "resolved", outcome: "blocked", evidence: {
|
|
automaticRecovery: { policy: "preserve_without_replay_v1", actionOutcome: "unknown", replay: "blocked", runId: source.runId },
|
|
} });
|
|
const logs = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, source.runId));
|
|
expect(logs.filter(log => log.payload?.automaticRecovery === "preserve_without_replay_v1")).toHaveLength(1);
|
|
const history = await activityService(db).runsForIssue(source.companyId, source.issueId);
|
|
expect(history.find(run => run.runId === source.runId)).toMatchObject({ execution: { phase: "recovery_needed", label: "Stopped" } });
|
|
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, source.companyId))).toHaveLength(1);
|
|
});
|
|
it("does not let a full batch awaiting replacement starve an eligible disposition", async () => {
|
|
const sources: Awaited<ReturnType<typeof seed>>[] = [];
|
|
for (let index = 0; index < 26; index += 1) {
|
|
const source = await seed();
|
|
sources.push(source);
|
|
await db.insert(issueRecoveryActions).values({ companyId: source.companyId, sourceIssueId: source.issueId,
|
|
kind: "active_run_watchdog", ownerType: "board", returnOwnerAgentId: source.agentId,
|
|
cause: "native_provider_terminal_failed", fingerprint: source.runId, evidence: { runId: source.runId }, nextAction: "Checking recovery" });
|
|
if (index === 25) {
|
|
await db.update(nativeRunFinalizations).set({ failureDetail: { replacementDenied: "uncertain_external_action" } }).where(eq(nativeRunFinalizations.runId, source.runId));
|
|
await settleUnrecoverableExecutions(db);
|
|
const [task] = await db.select().from(issues).where(eq(issues.id, source.issueId));
|
|
expect(task.status).toBe("blocked");
|
|
}
|
|
}
|
|
await db.update(issueRecoveryActions).set({ status: "resolved" }).where(inArray(issueRecoveryActions.sourceIssueId, sources.map(source => source.issueId)));
|
|
await db.update(nativeRunFinalizations).set({ failureDetail: { replacementDenied: "fixture_closed" } }).where(inArray(nativeRunFinalizations.runId, sources.map(source => source.runId)));
|
|
});
|
|
it("rolls back a crashed automatic disposition and completes it on the next sweep", async () => {
|
|
const source = await seed(3);
|
|
await reconcileSafeNativeReplacements(db);
|
|
await expect(settleUnrecoverableExecutions(db, new Date(), { failpoint: () => { throw new Error("crash before commit"); } })).rejects.toThrow("crash before commit");
|
|
const [before] = await db.select().from(issues).where(eq(issues.id, source.issueId));
|
|
expect(before.status).toBe("in_progress");
|
|
const [pending] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, source.issueId));
|
|
expect(pending.status).toBe("active");
|
|
await settleUnrecoverableExecutions(db);
|
|
const [after] = await db.select().from(issues).where(eq(issues.id, source.issueId));
|
|
expect(after.status).toBe("blocked");
|
|
const logs = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, source.runId));
|
|
expect(logs.filter(log => log.payload?.automaticRecovery === "preserve_without_replay_v1")).toHaveLength(1);
|
|
});
|
|
it("does not let the automatic fallback preempt a safe replacement", async () => {
|
|
const source = await seed();
|
|
await db.insert(issueRecoveryActions).values({ companyId: source.companyId, sourceIssueId: source.issueId,
|
|
kind: "active_run_watchdog", ownerType: "board", returnOwnerAgentId: source.agentId,
|
|
cause: "native_provider_terminal_failed", fingerprint: source.runId, evidence: { runId: source.runId }, nextAction: "Checking recovery" });
|
|
await settleUnrecoverableExecutions(db);
|
|
const [task] = await db.select().from(issues).where(eq(issues.id, source.issueId));
|
|
expect(task.status).toBe("in_progress");
|
|
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, source.issueId));
|
|
expect(action.status).toBe("active");
|
|
});
|
|
it.each(["closed", "reassigned", "new_execution"])("closes stale recovery after %s without changing task state or granting replay", async change => {
|
|
const source = await seed(3);
|
|
await reconcileSafeNativeReplacements(db);
|
|
const nextRun = randomUUID();
|
|
if (change === "new_execution") await db.insert(heartbeatRuns).values({ id: nextRun, companyId: source.companyId, agentId: source.agentId, status: "running" });
|
|
const patch = change === "closed" ? { status: "done" } : change === "reassigned" ? { assigneeAgentId: null } : { executionRunId: nextRun };
|
|
await db.update(issues).set(patch).where(eq(issues.id, source.issueId));
|
|
await settleUnrecoverableExecutions(db);
|
|
const [task] = await db.select().from(issues).where(eq(issues.id, source.issueId));
|
|
expect(task).toMatchObject(patch);
|
|
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, source.issueId));
|
|
expect(action).toMatchObject({ status: "resolved", outcome: "cancelled", evidence: { automaticRecovery: { replay: "blocked" } } });
|
|
});
|
|
it("cancels a durable native retry even when its previous provider is already failed", async () => {
|
|
const source = await seed();
|
|
await db.update(nativeRunFinalizations).set({ phase: "retryable_failure", nextAttemptAt: new Date(Date.now() + 30_000) }).where(eq(nativeRunFinalizations.runId, source.runId));
|
|
await db.update(issues).set({ executionRunId: source.runId }).where(eq(issues.id, source.issueId));
|
|
const heartbeat = heartbeatService(db);
|
|
expect(await heartbeat.cancelRun(source.runId)).toMatchObject({ status: "cancelled" });
|
|
expect(await heartbeat.cancelRun(source.runId)).toMatchObject({ status: "cancelled" });
|
|
const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, source.runId));
|
|
expect(coordinator).toMatchObject({ phase: "terminal_failure", nextAttemptAt: null, leaseOwner: null });
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.retryOfRunId, source.runId))).toHaveLength(0);
|
|
});
|
|
it("atomically terminalizes unsupported legacy recovery and preserves its owner", async () => {
|
|
const source = await seed();
|
|
await db.update(heartbeatRuns).set({ runtimeMode: "legacy", status: "running" }).where(eq(heartbeatRuns.id, source.runId));
|
|
await db.update(issues).set({ executionRunId: source.runId, checkoutRunId: source.runId }).where(eq(issues.id, source.issueId));
|
|
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, source.runId));
|
|
const results = await Promise.all([1, 2].map(() => terminalizeLegacyExecution({ db, run, status: "failed", fromStatuses: ["running"], patch: { errorCode: "provider_quota", finishedAt: new Date() } })));
|
|
expect(results.filter(Boolean)).toHaveLength(1);
|
|
const [task] = await db.select().from(issues).where(eq(issues.id, source.issueId));
|
|
expect(task).toMatchObject({ assigneeAgentId: source.agentId, executionRunId: null, checkoutRunId: null });
|
|
const actions = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, source.issueId));
|
|
expect(actions).toHaveLength(1);
|
|
expect(actions[0]).toMatchObject({ ownerType: "board", cause: "legacy_execution_requires_reconciliation" });
|
|
await db.delete(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, source.runId));
|
|
await settleUnrecoverableExecutions(db);
|
|
const [settled] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, actions[0]!.id));
|
|
expect(settled).toMatchObject({ status: "resolved", outcome: "blocked", evidence: { automaticRecovery: { replay: "blocked" } } });
|
|
|
|
expect(legacyExecutionNeedsReconciliation({ ...run, status: "failed", resultJson: { errorFamily: "provider_quota" } })).toBe(true);
|
|
expect(legacyExecutionNeedsReconciliation({ ...run, status: "failed", resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } })).toBe(false);
|
|
expect(legacyExecutionNeedsReconciliation({ ...run, status: "failed", scheduledRetryAttempt: 2, resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } })).toBe(true);
|
|
});
|
|
it("surfaces a failed current reviewer without transferring the original assignment", async () => {
|
|
const source = await seed();
|
|
const reviewerId = randomUUID();
|
|
await db.insert(agents).values({ id: reviewerId, companyId: source.companyId, name: "Reviewer", role: "engineer", adapterType: "process" });
|
|
await db.update(issues).set({ status: "in_review", executionState: {
|
|
status: "pending", currentStageId: randomUUID(), currentStageIndex: 0, currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: reviewerId, userId: null },
|
|
returnAssignee: { type: "agent", agentId: source.agentId, userId: null },
|
|
completedStageIds: [], lastDecisionId: null, lastDecisionOutcome: null,
|
|
} }).where(eq(issues.id, source.issueId));
|
|
const [run] = await db.update(heartbeatRuns).set({ agentId: reviewerId, runtimeMode: "legacy", status: "running" }).where(eq(heartbeatRuns.id, source.runId)).returning();
|
|
await terminalizeLegacyExecution({ db, run, status: "failed", patch: { finishedAt: new Date() } });
|
|
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, source.issueId));
|
|
expect(action).toMatchObject({ ownerType: "board", returnOwnerAgentId: source.agentId, evidence: { reviewParticipantAgentId: reviewerId } });
|
|
const input = { db, companyId: source.companyId, issueId: source.issueId, agentId: source.agentId, sourceRunId: source.runId,
|
|
decision: { runId: source.runId, providerStopped: true as const, actionOutcome: "not_performed" as const, outcomeEvidence: "Inspected reviewer process and verified no actions occurred." } };
|
|
await expect(validateExecutionReconciliation(input)).resolves.toMatchObject({ id: source.runId });
|
|
await db.update(issues).set({ executionState: null }).where(eq(issues.id, source.issueId));
|
|
await expect(validateExecutionReconciliation(input)).rejects.toThrow("source or task owner changed");
|
|
});
|
|
it("does not turn an already reconciled continuation into a recovery assignment", async () => {
|
|
const source = await seed();
|
|
const [action] = await db.insert(issueRecoveryActions).values({ companyId: source.companyId, sourceIssueId: source.issueId, kind: "active_run_watchdog", status: "resolved", ownerType: "board", returnOwnerAgentId: source.agentId, cause: "native_event_replay_conflict", fingerprint: source.runId, nextAction: "Old recovery instruction", evidence: { executionReconciliation: { runId: source.runId } } }).returning();
|
|
const wake = await buildPaperclipWakePayload({ db, companyId: source.companyId, contextSnapshot: { issueId: source.issueId, recoveryActionId: action.id, wakeReason: "issue_recovery_action_restored" } });
|
|
expect(wake?.recovery).toBeNull();
|
|
expect(wake?.reason).toBe("issue_recovery_action_restored");
|
|
});
|
|
it("keeps an unclassified transport terminal operator-owned", async () => {
|
|
const source = await seed();
|
|
await db
|
|
.update(nativeRunFinalizations)
|
|
.set({
|
|
failureDetail: {
|
|
originalFailureCode: "notification_transport_failed",
|
|
},
|
|
})
|
|
.where(eq(nativeRunFinalizations.runId, source.runId));
|
|
await reconcileSafeNativeReplacements(db);
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.retryOfRunId, source.runId)),
|
|
).toHaveLength(0);
|
|
const [coordinator] = await db
|
|
.select()
|
|
.from(nativeRunFinalizations)
|
|
.where(eq(nativeRunFinalizations.runId, source.runId));
|
|
expect(coordinator?.failureDetail?.replacementDenied).toBe(
|
|
"provider_failure_meaning_unverified",
|
|
);
|
|
});
|
|
it("persists exactly one linked successor under competing sweepers and restarts", async () => {
|
|
const source = await seed(2);
|
|
const now = new Date();
|
|
await Promise.all([
|
|
reconcileSafeNativeReplacements(db, now),
|
|
reconcileSafeNativeReplacements(db, now),
|
|
]);
|
|
await reconcileSafeNativeReplacements(db, now);
|
|
const children = await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.retryOfRunId, source.runId));
|
|
expect(children).toHaveLength(1);
|
|
expect(children[0]).toMatchObject({
|
|
agentId: source.agentId,
|
|
status: "scheduled_retry",
|
|
scheduledRetryAttempt: 2,
|
|
contextSnapshot: {
|
|
forceFreshSession: true,
|
|
recoveryIncidentRootRunId: source.runId,
|
|
},
|
|
});
|
|
expect(children[0]!.scheduledRetryAt!.getTime()).toBe(
|
|
now.getTime() + 30_000,
|
|
);
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(nativeRunFinalizations)
|
|
.where(eq(nativeRunFinalizations.runId, source.runId))
|
|
)[0]?.failureDetail?.successorRunId,
|
|
).toBe(children[0]!.id);
|
|
});
|
|
it("retries status publication after a crash without dispatching provider work", async () => {
|
|
await deliverExecutionStatuses(db);
|
|
const source = await seed();
|
|
const deliveryId = randomUUID();
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ executionStatusDeliveryId: deliveryId,
|
|
error: "credential-in-provider-error", errorCode: "credential-in-provider-code",
|
|
triggerDetail: "credential-in-trigger-detail",
|
|
resultJson: { summary: "credential-in-provider-summary", toolResult: "credential-in-tool-result" },
|
|
})
|
|
.where(eq(heartbeatRuns.id, source.runId));
|
|
await deliverExecutionStatuses(db, {
|
|
publish: () => {
|
|
throw new Error("publication unavailable");
|
|
},
|
|
});
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, source.runId))
|
|
)[0]?.executionStatusDeliveryId,
|
|
).toBe(deliveryId);
|
|
const observed: unknown[] = [];
|
|
const publish: typeof publishLiveEvent = (event) => {
|
|
observed.push(event);
|
|
return publishLiveEvent(event);
|
|
};
|
|
await expect(
|
|
deliverExecutionStatuses(db, {
|
|
publish,
|
|
failpoint: () => {
|
|
throw new Error("crash after publication");
|
|
},
|
|
}),
|
|
).rejects.toThrow("crash after publication");
|
|
await deliverExecutionStatuses(db, { publish });
|
|
expect(JSON.stringify(observed)).not.toContain("credential-in-");
|
|
expect(Object.keys((observed[0] as { payload: Record<string, unknown> }).payload).sort()).toEqual(
|
|
["runId", "agentId", "status", "startedAt", "finishedAt", "deliveryId"].sort(),
|
|
);
|
|
expect(observed).toEqual([
|
|
expect.objectContaining({
|
|
companyId: source.companyId,
|
|
payload: expect.objectContaining({
|
|
runId: source.runId,
|
|
deliveryId,
|
|
status: "failed",
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
companyId: source.companyId,
|
|
payload: expect.objectContaining({
|
|
runId: source.runId,
|
|
deliveryId,
|
|
status: "failed",
|
|
}),
|
|
}),
|
|
]);
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, source.runId))
|
|
)[0]?.executionStatusDeliveryId,
|
|
).toBeNull();
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.retryOfRunId, source.runId)),
|
|
).toHaveLength(0);
|
|
});
|
|
it("never resets an exhausted incident by assigning another run id", async () => {
|
|
const source = await seed(3);
|
|
await reconcileSafeNativeReplacements(db);
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.retryOfRunId, source.runId)),
|
|
).toHaveLength(0);
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(issueRecoveryActions)
|
|
.where(eq(issueRecoveryActions.sourceIssueId, source.issueId))
|
|
)[0],
|
|
).toMatchObject({
|
|
ownerType: "board",
|
|
cause: "execution_recovery_budget_exhausted",
|
|
});
|
|
});
|
|
it.each(["successor_inserted", "lineage_committed"] as const)(
|
|
"recovers a crash at %s without duplicate successors",
|
|
async (phase) => {
|
|
const source = await seed();
|
|
await expect(
|
|
reconcileSafeNativeReplacements(db, new Date(), {
|
|
failpoint: (point) => {
|
|
if (point === phase)
|
|
throw new Error("simulated coordinator crash");
|
|
},
|
|
}),
|
|
).rejects.toThrow("simulated coordinator crash");
|
|
await reconcileSafeNativeReplacements(db);
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.retryOfRunId, source.runId)),
|
|
).toHaveLength(1);
|
|
},
|
|
);
|
|
it.each([false, true])(
|
|
"does not replay a provider-native command with unknown effects (PRP envelope: %s)",
|
|
async (wrapped) => {
|
|
const source = await seed();
|
|
await db.insert(heartbeatRunEvents).values({
|
|
companyId: source.companyId,
|
|
runId: source.runId,
|
|
agentId: source.agentId,
|
|
seq: 1,
|
|
eventType: "tool.execution.started",
|
|
stream: "system",
|
|
payload: wrapped
|
|
? {
|
|
prpEvent: {
|
|
payload: {
|
|
transport: "process",
|
|
executionId: "shell-write-1",
|
|
name: "send_email",
|
|
},
|
|
},
|
|
}
|
|
: {
|
|
transport: "process",
|
|
executionId: "shell-write-1",
|
|
name: "send_email",
|
|
},
|
|
});
|
|
await reconcileSafeNativeReplacements(db);
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.retryOfRunId, source.runId)),
|
|
).toHaveLength(0);
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(issueRecoveryActions)
|
|
.where(eq(issueRecoveryActions.sourceIssueId, source.issueId))
|
|
)[0]?.nextAction,
|
|
).toContain("shell-write-1");
|
|
},
|
|
);
|
|
it("surfaces a reviewer's abandoned control transition for the operator", async () => {
|
|
const source = await seed();
|
|
const reviewerId = randomUUID();
|
|
await db.insert(agents).values({ id: reviewerId, companyId: source.companyId, name: "Reviewer", role: "engineer", adapterType: "process" });
|
|
await db.update(issues).set({ status: "in_review", executionRunId: source.runId, executionState: {
|
|
status: "pending", currentStageId: randomUUID(), currentStageIndex: 0, currentStageType: "review",
|
|
currentParticipant: { type: "agent", agentId: reviewerId, userId: null },
|
|
returnAssignee: { type: "agent", agentId: source.agentId, userId: null },
|
|
completedStageIds: [], lastDecisionId: null, lastDecisionOutcome: null,
|
|
} }).where(eq(issues.id, source.issueId));
|
|
await db.update(heartbeatRuns).set({ agentId: reviewerId, status: "running", executionControlDeadlineAt: new Date(Date.now() - 1_000) }).where(eq(heartbeatRuns.id, source.runId));
|
|
await reconcileAbandonedExecutionControl(db);
|
|
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, source.issueId))).toMatchObject([
|
|
{ ownerType: "board", returnOwnerAgentId: source.agentId, evidence: { reviewParticipantAgentId: reviewerId } },
|
|
]);
|
|
expect((await db.select().from(issues).where(eq(issues.id, source.issueId)))[0]).toMatchObject({ assigneeAgentId: source.agentId, executionRunId: null });
|
|
});
|
|
it("fences a stranded finalization and surfaces one operator action after its deadline", async () => {
|
|
const source = await seed();
|
|
const deadline = new Date("2026-09-08T10:00:00Z");
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ status: "running" })
|
|
.where(eq(heartbeatRuns.id, source.runId));
|
|
await db
|
|
.update(nativeRunFinalizations)
|
|
.set({
|
|
phase: "observed",
|
|
leaseOwner: "dead-controller",
|
|
controlDeadlineAt: deadline,
|
|
})
|
|
.where(eq(nativeRunFinalizations.runId, source.runId));
|
|
await reconcileAbandonedExecutionControl(
|
|
db,
|
|
new Date(deadline.getTime() + 15_000),
|
|
);
|
|
await reconcileAbandonedExecutionControl(
|
|
db,
|
|
new Date(deadline.getTime() + 30_000),
|
|
);
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, source.runId))
|
|
)[0],
|
|
).toMatchObject({
|
|
status: "failed",
|
|
errorCode: "execution_finalization_deadline_exceeded",
|
|
});
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(nativeRunFinalizations)
|
|
.where(eq(nativeRunFinalizations.runId, source.runId))
|
|
)[0],
|
|
).toMatchObject({
|
|
leaseOwner: null,
|
|
controlDeadlineAt: null,
|
|
phase: "terminal_failure",
|
|
});
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(issueRecoveryActions)
|
|
.where(
|
|
and(
|
|
eq(issueRecoveryActions.sourceIssueId, source.issueId),
|
|
eq(
|
|
issueRecoveryActions.cause,
|
|
"execution_finalization_deadline_exceeded",
|
|
),
|
|
),
|
|
),
|
|
).toHaveLength(1);
|
|
});
|
|
it("bounds legacy finalization without adding a deadline to healthy provider execution", async () => {
|
|
const source = await seed();
|
|
await db
|
|
.delete(nativeRunFinalizations)
|
|
.where(eq(nativeRunFinalizations.runId, source.runId));
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({
|
|
runtimeMode: "legacy",
|
|
status: "running",
|
|
executionControlDeadlineAt: null,
|
|
})
|
|
.where(eq(heartbeatRuns.id, source.runId));
|
|
await reconcileAbandonedExecutionControl(db);
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, source.runId))
|
|
)[0]?.status,
|
|
).toBe("running");
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ executionControlDeadlineAt: new Date(Date.now() - 1000) })
|
|
.where(eq(heartbeatRuns.id, source.runId));
|
|
await reconcileAbandonedExecutionControl(db);
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, source.runId))
|
|
)[0],
|
|
).toMatchObject({ status: "failed", executionControlDeadlineAt: null });
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(issueRecoveryActions)
|
|
.where(eq(issueRecoveryActions.sourceIssueId, source.issueId))
|
|
)[0]?.ownerType,
|
|
).toBe("board");
|
|
});
|
|
it("does not release another run's checkout or replace a reassigned task", async () => {
|
|
const source = await seed();
|
|
const otherRunId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: otherRunId,
|
|
companyId: source.companyId,
|
|
agentId: source.agentId,
|
|
status: "running",
|
|
});
|
|
await db
|
|
.update(issues)
|
|
.set({ checkoutRunId: otherRunId })
|
|
.where(eq(issues.id, source.issueId));
|
|
await reconcileSafeNativeReplacements(db);
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.retryOfRunId, source.runId)),
|
|
).toHaveLength(0);
|
|
await db
|
|
.update(issues)
|
|
.set({ assigneeAgentId: null })
|
|
.where(eq(issues.id, source.issueId));
|
|
await reconcileSafeNativeReplacements(db);
|
|
expect(
|
|
await db
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.retryOfRunId, source.runId)),
|
|
).toHaveLength(0);
|
|
});
|
|
it("requires explicit reconciled outcomes and rejects a still-running provider", async () => {
|
|
const source = await seed();
|
|
const input = {
|
|
db,
|
|
companyId: source.companyId,
|
|
issueId: source.issueId,
|
|
agentId: source.agentId,
|
|
sourceRunId: source.runId,
|
|
decision: undefined,
|
|
};
|
|
await expect(validateExecutionReconciliation(input)).rejects.toThrow(
|
|
"Reconcile the recorded execution",
|
|
);
|
|
const decision = {
|
|
runId: source.runId,
|
|
providerStopped: true as const,
|
|
actionOutcome: "not_performed" as const,
|
|
outcomeEvidence:
|
|
"Verified that the fixture emitted an event only; no external action happened.",
|
|
};
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ processPid: process.pid })
|
|
.where(eq(heartbeatRuns.id, source.runId));
|
|
await expect(
|
|
validateExecutionReconciliation({ ...input, decision }),
|
|
).rejects.toThrow("still running");
|
|
await db
|
|
.update(heartbeatRuns)
|
|
.set({ processPid: null })
|
|
.where(eq(heartbeatRuns.id, source.runId));
|
|
await expect(
|
|
validateExecutionReconciliation({ ...input, decision }),
|
|
).resolves.toMatchObject({ id: source.runId });
|
|
await expect(
|
|
validateExecutionReconciliation({
|
|
...input,
|
|
companyId: randomUUID(),
|
|
decision,
|
|
}),
|
|
).rejects.toThrow("source or task owner changed");
|
|
});
|
|
it("retains a reconciliation delivery across dispatch failure and invalidates stale ownership", async () => {
|
|
const source = await seed();
|
|
const [action] = await db
|
|
.insert(issueRecoveryActions)
|
|
.values({
|
|
companyId: source.companyId,
|
|
sourceIssueId: source.issueId,
|
|
kind: "active_run_watchdog",
|
|
status: "resolved",
|
|
ownerType: "board",
|
|
returnOwnerAgentId: source.agentId,
|
|
cause: "uncertain_provider_action",
|
|
fingerprint: source.runId,
|
|
evidence: { runId: source.runId },
|
|
nextAction: "Reconcile fixture action",
|
|
})
|
|
.returning();
|
|
const decision = {
|
|
runId: source.runId,
|
|
providerStopped: true as const,
|
|
actionOutcome: "not_performed" as const,
|
|
outcomeEvidence:
|
|
"The fixture was inspected and no command was executed.",
|
|
};
|
|
await markExecutionReconciliation(db, action!, decision, "operator");
|
|
await deliverReconciledExecutions(db, async () => {
|
|
throw new Error("dispatch unavailable");
|
|
});
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(issueRecoveryActions)
|
|
.where(eq(issueRecoveryActions.id, action!.id))
|
|
)[0]?.evidence.continuationDelivery,
|
|
).toBe("pending");
|
|
await db
|
|
.update(issues)
|
|
.set({ assigneeAgentId: null })
|
|
.where(eq(issues.id, source.issueId));
|
|
let woke = false;
|
|
await deliverReconciledExecutions(db, async () => {
|
|
woke = true;
|
|
return null;
|
|
});
|
|
expect(woke).toBe(false);
|
|
expect(
|
|
(
|
|
await db
|
|
.select()
|
|
.from(issueRecoveryActions)
|
|
.where(eq(issueRecoveryActions.id, action!.id))
|
|
)[0]?.evidence.continuationDelivery,
|
|
).toBe("invalidated");
|
|
});
|
|
},
|
|
);
|