mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43:16 +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
@@ -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