mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
fix: preserve workspace finalization during watchdog recovery (#16247)
Keep task completion separate from runtime and workspace settlement. Permit verified board file-only repair of successful legacy retained-source runs without authorizing replay. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
9cc979116e
commit
5ea3f905cf
13 files changed
+209
-9
No files matched your search
@@ -65,13 +65,15 @@ export async function validateExecutionReconciliation(input: {
|
||||
review.currentParticipant.agentId === run?.agentId;
|
||||
const retainedWorkspace = hasRequiredWorkspaceRecovery(run?.resultJson);
|
||||
const repairOnly = input.workspaceRepairOnly === true && retainedWorkspace;
|
||||
const terminalRepairSource = ["failed", "interrupted", "timed_out", "cancelled"].includes(run?.status ?? "") ||
|
||||
(repairOnly && run?.runtimeMode === "legacy" && run.status === "succeeded");
|
||||
if (
|
||||
(input.workspaceRepairOnly && !retainedWorkspace) ||
|
||||
!run ||
|
||||
!task ||
|
||||
(!repairOnly && (task.assigneeAgentId !== agentId || (run.agentId !== agentId && !isCurrentReviewer))) ||
|
||||
(run.nativeIssueId ?? run.contextSnapshot?.issueId) !== issueId ||
|
||||
!["failed", "interrupted", "timed_out", "cancelled"].includes(run.status)
|
||||
!terminalRepairSource
|
||||
) {
|
||||
throw conflict(
|
||||
"The recovery source or task owner changed. Inspect the current execution before continuing.",
|
||||
|
||||
@@ -80,6 +80,39 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
|
||||
await expect(validateExecutionReconciliation({ db, ...f })).resolves.toMatchObject({ id: f.sourceRunId });
|
||||
});
|
||||
|
||||
it("repairs retained files from a succeeded run without reopening or replaying its completed work", async () => {
|
||||
const f = await seed();
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded", errorCode: null }).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, f.issueId));
|
||||
const repaired = await validateExecutionReconciliation({ db, ...f, workspaceRepairOnly: true });
|
||||
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.actionId));
|
||||
await markExecutionReconciliation(db, action, f.decision, "board", undefined, { workspaceRepairOnly: true });
|
||||
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull();
|
||||
expect(repaired.status).toBe("succeeded");
|
||||
const [recorded] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.actionId));
|
||||
expect(recorded.evidence.continuationDelivery).toBe("not_requested");
|
||||
const [task] = await db.select().from(issues).where(eq(issues.id, f.issueId));
|
||||
expect(task).toMatchObject({ status: "todo", assigneeAgentId: f.agentId, executionRunId: null });
|
||||
});
|
||||
|
||||
it.each(["running", "queued", "succeeded"])("does not authorize a normal continuation of a %s retained run", async status => {
|
||||
const f = await seed();
|
||||
await db.update(heartbeatRuns).set({ status }).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
await expect(validateExecutionReconciliation({ db, ...f })).rejects.toThrow("source or task owner changed");
|
||||
});
|
||||
|
||||
it.each(["running", "queued"])("does not accept file repair while the source run is %s", async status => {
|
||||
const f = await seed();
|
||||
await db.update(heartbeatRuns).set({ status }).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
await expect(validateExecutionReconciliation({ db, ...f, workspaceRepairOnly: true })).rejects.toThrow("source or task owner changed");
|
||||
});
|
||||
|
||||
it("does not replace native finalization with legacy success repair", async () => {
|
||||
const f = await seed();
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded", runtimeMode: "native" }).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
await expect(validateExecutionReconciliation({ db, ...f, workspaceRepairOnly: true })).rejects.toThrow("source or task owner changed");
|
||||
});
|
||||
|
||||
it.each(["pending_stop", "wrong_receipt", "missing_source", "unrecorded_source", "empty_sources"])("rejects repair with %s even when releasedAt is populated", async scenario => {
|
||||
const f = await seed();
|
||||
if (scenario === "pending_stop") {
|
||||
|
||||
Reference in new issue
Block a user