mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
fix(recovery): preserve deliberate stops and dependency waits
Preserve deliberate assignee blocks and operator stops during dependency and run-release recovery. Attribute native waits to their bound owner. Ignore unrelated edits and shared-workspace exports from another issue. Continue after genuine dependency changes while preserving independently authorized user input. Keep the original contributor's implementation and review history. Add native ownership, unrelated-edit, later readiness, and shared-workspace regressions. The shared-workspace loop was reproduced before the fix. Co-Authored-By: Devin Foley <devin@paperclip.ing> Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
11 files changed
+412
-7
No files matched your search
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isOperatorStoppedRunRow } from "./postgres.js";
|
||||
|
||||
describe("isOperatorStoppedRunRow", () => {
|
||||
it.each([
|
||||
[{ status: "cancelled", errorCode: "cancelled", resultJson: { cancelledByActorType: "user" } }, true],
|
||||
[{ status: "cancelled", errorCode: "cancelled", resultJson: { cancelledByActorType: "board" } }, true],
|
||||
[{ status: "cancelled", errorCode: "agent_paused", resultJson: null }, true],
|
||||
[{ status: "cancelled", errorCode: "operator_interrupted", resultJson: {} }, true],
|
||||
[{ status: "cancelled", errorCode: "cancelled", resultJson: { stopReason: "cancelled" } }, false],
|
||||
[{ status: "cancelled", errorCode: "issue_terminal_status", resultJson: null }, false],
|
||||
[{ status: "cancelled", errorCode: "cancelled", resultJson: { cancellation: {
|
||||
source: "operator", expected: true, initiator: { type: "user" }, reason: "Stop", recordedAt: "2026-10-15T06:00:00Z",
|
||||
} } }, true],
|
||||
[{ status: "cancelled", errorCode: "operator_interrupted", resultJson: { cancelledByActorType: "user", cancellation: {
|
||||
source: "queued_message", expected: true, initiator: { type: "user" }, reason: "New message", recordedAt: "2026-10-15T06:00:00Z",
|
||||
} } }, false],
|
||||
[{ status: "failed", errorCode: "agent_paused", resultJson: { cancelledByActorType: "user" } }, false],
|
||||
])("classifies %j as %s", (row, expected) => {
|
||||
expect(isOperatorStoppedRunRow(row as never)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -98,9 +98,23 @@ function toRunSnapshot(row: HeartbeatRunRow): RunSnapshot {
|
||||
responsibleUserId: row.responsibleUserId,
|
||||
contextSnapshot: parseObject(row.contextSnapshot),
|
||||
configurationIncompletePayload: Object.keys(configurationIncompletePayload).length > 0 ? configurationIncompletePayload : null,
|
||||
operatorStopped: isOperatorStoppedRunRow(row),
|
||||
};
|
||||
}
|
||||
|
||||
const OPERATOR_STOP_ERROR_CODES = new Set(["operator_interrupted", "agent_paused"]);
|
||||
|
||||
/** Mirrors recovery's `isOperatorCancelledRun`, plus agent pause, for the release-time recovery tail. */
|
||||
export function isOperatorStoppedRunRow(row: Pick<HeartbeatRunRow, "status" | "errorCode" | "resultJson">): boolean {
|
||||
if (row.status !== "cancelled") return false;
|
||||
const cancellation = readRunCancellation(row.resultJson);
|
||||
if (cancellation?.source === "queued_message") return false;
|
||||
if (cancellation?.source === "operator" && cancellation.expected) return true;
|
||||
if (row.errorCode && OPERATOR_STOP_ERROR_CODES.has(row.errorCode)) return true;
|
||||
const actorType = parseObject(row.resultJson).cancelledByActorType;
|
||||
return actorType === "user" || actorType === "board";
|
||||
}
|
||||
|
||||
function toIssueSnapshot(row: IssueRow): IssueSnapshot {
|
||||
return {
|
||||
conversationAgentId: row.conversationAgentId,
|
||||
|
||||
@@ -23,6 +23,12 @@ export type RunSnapshot = {
|
||||
configurationIncompletePayload: Record<string, unknown> | null;
|
||||
/** The host schedules failed conversation turns with its durable retry budget. */
|
||||
conversationContinuation?: boolean;
|
||||
/**
|
||||
* True when a person deliberately stopped this run: the board cancel route
|
||||
* (`resultJson.cancelledByActorType` of `user`/`board`), an operator interrupt,
|
||||
* or an agent pause. Release must not immediately re-queue such a run.
|
||||
*/
|
||||
operatorStopped?: boolean;
|
||||
};
|
||||
|
||||
export type IssueSnapshot = {
|
||||
|
||||
@@ -698,6 +698,37 @@ describe("releaseIssueExecution", () => {
|
||||
expect(queueCall.contextSnapshot).toBe(resolveCall.contextSnapshot);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["board cancel", { errorCode: "cancelled", operatorStopped: true }],
|
||||
["agent pause", { errorCode: "agent_paused", operatorStopped: true }],
|
||||
] as const)("does not re-queue or escalate a run a person stopped (%s)", async (_label, stop) => {
|
||||
// Regression: a board cancel of a queued wake re-created it as
|
||||
// issue_continuation_needed / issue_assignment_recovery within a second.
|
||||
for (const status of ["in_progress", "todo"]) {
|
||||
const transaction = createFakeTransaction();
|
||||
const recovery = createFakeRecovery();
|
||||
const release = createReleaseIssueExecution({
|
||||
issueLock: createFakeIssueLock(createFakeHost(), transaction, { ...ISSUE, status }, { ...RUN, status: "cancelled", ...stop }),
|
||||
recovery,
|
||||
});
|
||||
const result = await release({ companyId: RUN.companyId, runId: RUN.id, now: new Date() });
|
||||
expect(result.outcome.kind).toBe("released");
|
||||
expect(transaction.queueImmediateRecoveryRun).not.toHaveBeenCalled();
|
||||
expect(recovery.escalateStrandedAssignedIssue).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("still recovers a control-plane cancelled run that no person stopped", async () => {
|
||||
const transaction = createFakeTransaction();
|
||||
const release = createReleaseIssueExecution({
|
||||
issueLock: createFakeIssueLock(createFakeHost(), transaction, ISSUE, { ...RUN, status: "cancelled", errorCode: "cancelled", operatorStopped: false }),
|
||||
recovery: createFakeRecovery(),
|
||||
});
|
||||
const result = await release({ companyId: RUN.companyId, runId: RUN.id, now: new Date() });
|
||||
expect(result.outcome.kind).toBe("queued_recovery");
|
||||
expect(transaction.queueImmediateRecoveryRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws WakeQueueApplicationError with code responsible_user_unresolved for a recovery run, without queuing it", async () => {
|
||||
const transaction = createFakeTransaction();
|
||||
const host = createFakeHost({ resolveResponsibleUserId: vi.fn(async () => null) });
|
||||
|
||||
@@ -493,7 +493,13 @@ async function runReleaseRecoveryTail(
|
||||
input: ReleaseIssueExecutionInput,
|
||||
postCommitEffects: PostCommitEffect[],
|
||||
): Promise<ReleaseTransactionResult> {
|
||||
const suppressImmediateRecovery = input.suppressImmediateRecovery === true || Boolean(
|
||||
// A run a person deliberately stopped (board cancel, operator interrupt, agent
|
||||
// pause) is not stranded work. Re-queuing it here re-created the cancelled wake
|
||||
// within a second of the cancel (issue_continuation_needed / assignment_recovery),
|
||||
// and for a paused agent escalated the issue to `blocked`. The periodic sweep
|
||||
// already stands down for operator cancels; this keeps release consistent.
|
||||
const operatorStopped = run.status === "cancelled" && run.operatorStopped === true;
|
||||
const suppressImmediateRecovery = input.suppressImmediateRecovery === true || operatorStopped || Boolean(
|
||||
issue.conversationAgentId && issue.conversationUserId &&
|
||||
issue.conversationState === "waiting" && issue.status === "in_review"
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user