mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
fix(server): fence native retry cancellation through final commit
Recheck retry eligibility under the coordinator lock before creating a cancellation intent. Preserve later run and coordinator outcomes with a failed-only acknowledged-result CAS, while retaining same-intent recovery and NOWAIT conflict handling.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
(cherry picked from commit 29230e2000)
This commit is contained in:
1 parent
45b977bca0
commit
e4bf459e31
5 files changed
+186
-34
No files matched your search
@@ -4925,7 +4925,10 @@ function cancellationDb(options?: {
|
||||
runId: string;
|
||||
assessmentId: string | null;
|
||||
decisionId?: string | null;
|
||||
phase?: string;
|
||||
failureCode?: string | null;
|
||||
} | null;
|
||||
status?: string;
|
||||
failResultJsonUpdateAt?: number;
|
||||
ownershipHeld?: boolean;
|
||||
resultJson?: Record<string, unknown>;
|
||||
@@ -4936,6 +4939,7 @@ function cancellationDb(options?: {
|
||||
companyId: execution.binding.companyId,
|
||||
nativeIssueId: execution.binding.issueId,
|
||||
runtimeMode: "native",
|
||||
status: options?.status ?? "running",
|
||||
...(options?.ownershipHeld
|
||||
? {
|
||||
status: "running",
|
||||
@@ -4995,6 +4999,7 @@ function cancellationDb(options?: {
|
||||
set: (values: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
updates.push({ table, values });
|
||||
if (table === nativeRunFinalizations && coordinator) Object.assign(coordinator, values);
|
||||
const updatesResultJson = "resultJson" in values;
|
||||
if (updatesResultJson) resultJsonUpdateCount += 1;
|
||||
const shouldFail =
|
||||
@@ -5516,6 +5521,43 @@ describe("native session cancellation", () => {
|
||||
expect(state.publishActivity).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(["terminal_failure", "observed", "committed"])("rejects retry phase %s that changed after caller reservation, before any native effect", async phase => {
|
||||
const id = "11111111-1111-4111-8111-111111111111";
|
||||
const persistence = cancellationDb({ status: "failed",
|
||||
coordinator: { runId: execution.binding.runId, assessmentId: null, phase, failureCode: "board_recovery" },
|
||||
resultJson: { startupCancellation: { cancellationRequestId: id, retryCancellation: true } } });
|
||||
await expect(cancelNativeSession(execution.binding.runId, "operator Stop", { db: persistence.db,
|
||||
scope: "run", cancellationRequestId: id })).rejects.toMatchObject({ status: 409, message: "Native retry is no longer cancellable" });
|
||||
expect(persistence.updates).toEqual([]);
|
||||
expect(state.cancel).not.toHaveBeenCalled();
|
||||
expect(state.persistActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not mistake a recovered running attempt for the failed retry that reserved Stop", async () => {
|
||||
const id = "11111111-1111-4111-8111-111111111111";
|
||||
const persistence = cancellationDb({ status: "running",
|
||||
coordinator: { runId: execution.binding.runId, assessmentId: null, phase: "observed" },
|
||||
resultJson: { startupCancellation: { cancellationRequestId: id, retryCancellation: true } } });
|
||||
await expect(cancelNativeSession(execution.binding.runId, "operator Stop", { db: persistence.db,
|
||||
scope: "run", cancellationRequestId: id })).rejects.toMatchObject({ status: 409 });
|
||||
expect(persistence.updates).toEqual([]);
|
||||
expect(state.cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fences a failed retry at dispatch and preserves the same audited intent on replay", async () => {
|
||||
const id = "11111111-1111-4111-8111-111111111111";
|
||||
const persistence = cancellationDb({ status: "failed",
|
||||
coordinator: { runId: execution.binding.runId, assessmentId: null, phase: "retryable_failure" },
|
||||
resultJson: { startupCancellation: { cancellationRequestId: id, retryCancellation: true } } });
|
||||
const first = await cancelNativeSession(execution.binding.runId, "operator Stop", { db: persistence.db, scope: "run", cancellationRequestId: id });
|
||||
expect(persistence.updates).toContainEqual(expect.objectContaining({ table: nativeRunFinalizations,
|
||||
values: expect.objectContaining({ phase: "terminal_failure", failureCode: "native_retry_cancelled", nextAttemptAt: null }) }));
|
||||
const writes = persistence.getResultJsonUpdateCount();
|
||||
await expect(cancelNativeSession(execution.binding.runId, "retry Stop", { db: persistence.db,
|
||||
scope: "run", cancellationRequestId: id })).resolves.toMatchObject({ auditId: first.auditId });
|
||||
expect(persistence.getResultJsonUpdateCount()).toBe(writes);
|
||||
});
|
||||
|
||||
it("uses the reserved caller UUID and permits same-intent and default retries", async () => {
|
||||
const id = "11111111-1111-4111-8111-111111111111";
|
||||
const persistence = cancellationDb({ resultJson: { startupCancellation: { cancellationRequestId: id } } });
|
||||
|
||||
Reference in new issue
Block a user