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:
authored and GitHub committed 2026-10-15 00:18:23 -07:00
1 parent c68a483cd9
commit 8d9d2f9ddf
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"
);