fix(workflows): reconcile replacement resume cancellation

This commit is contained in:
Justin Blumencranz
2026-08-27 17:41:59 -07:00
parent a752aab35e
commit 161f18d5d8
2 changed files with 138 additions and 34 deletions
@@ -150,6 +150,12 @@ const ACTIVE_RESUME_TARGET = {
resumeExecutionId: 'resume-ex-1',
}
const REPLACEMENT_ACTIVE_RESUME_TARGET = {
...ACTIVE_RESUME_TARGET,
resumeEntryId: 'resume-entry-2',
resumeExecutionId: 'resume-ex-2',
}
describe('cancelWorkflowExecution', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -785,6 +791,85 @@ describe('cancelWorkflowExecution', () => {
expect(mockClearExecutionCancellation).not.toHaveBeenCalled()
})
it('finishes cancellation when a failed active-resume rollback detects a replacement', async () => {
mockStagePausedCancellation
.mockResolvedValueOnce({ kind: 'active_resume', target: ACTIVE_RESUME_TARGET })
.mockResolvedValueOnce({
kind: 'active_resume',
target: REPLACEMENT_ACTIVE_RESUME_TARGET,
})
mockGetActiveResumeCancellationTarget.mockResolvedValueOnce(REPLACEMENT_ACTIVE_RESUME_TARGET)
mockRollbackActiveResumeCancellation.mockResolvedValueOnce(false)
mockMarkExecutionCancelled
.mockResolvedValueOnce({ durablyRecorded: false, reason: 'redis_unavailable' })
.mockResolvedValueOnce({ durablyRecorded: true, reason: 'recorded' })
mockCompletePausedCancellation.mockResolvedValueOnce(true)
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
success: true,
durablyRecorded: true,
pausedCancelled: true,
reason: 'recorded',
})
expect(mockRollbackActiveResumeCancellation).toHaveBeenCalledWith(
'ex-1',
'wf-1',
'resume-entry-1'
)
expect(mockMarkExecutionCancelled).toHaveBeenNthCalledWith(1, 'resume-ex-1', {
executionDeadlineAt: null,
})
expect(mockMarkExecutionCancelled).toHaveBeenNthCalledWith(2, 'resume-ex-2', {
executionDeadlineAt: null,
})
expect(mockCompletePausedCancellation).toHaveBeenCalledWith('ex-1', 'wf-1')
})
it('finishes late pause cancellation when rollback detects a replacement resume', async () => {
mockStagePausedCancellation
.mockResolvedValueOnce({ kind: 'not_paused' })
.mockResolvedValueOnce({ kind: 'active_resume', target: ACTIVE_RESUME_TARGET })
.mockResolvedValueOnce({
kind: 'active_resume',
target: REPLACEMENT_ACTIVE_RESUME_TARGET,
})
mockGetActiveResumeCancellationTarget.mockResolvedValueOnce(REPLACEMENT_ACTIVE_RESUME_TARGET)
mockRollbackActiveResumeCancellation.mockResolvedValueOnce(false)
mockMarkExecutionCancelled
.mockResolvedValueOnce({ durablyRecorded: false, reason: 'redis_unavailable' })
.mockResolvedValueOnce({ durablyRecorded: false, reason: 'redis_unavailable' })
.mockResolvedValueOnce({ durablyRecorded: true, reason: 'recorded' })
mockCompletePausedCancellation.mockResolvedValueOnce(true)
const response = await POST(makeRequest(), makeParams())
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
success: true,
durablyRecorded: true,
pausedCancelled: true,
reason: 'recorded',
})
expect(mockRollbackActiveResumeCancellation).toHaveBeenCalledWith(
'ex-1',
'wf-1',
'resume-entry-1'
)
expect(mockMarkExecutionCancelled).toHaveBeenNthCalledWith(1, 'ex-1', {
executionDeadlineAt: null,
})
expect(mockMarkExecutionCancelled).toHaveBeenNthCalledWith(2, 'resume-ex-1', {
executionDeadlineAt: null,
})
expect(mockMarkExecutionCancelled).toHaveBeenNthCalledWith(3, 'resume-ex-2', {
executionDeadlineAt: null,
})
expect(mockCompletePausedCancellation).toHaveBeenCalledWith('ex-1', 'wf-1')
})
it('returns success when a paused HITL execution is cancelled directly in the database', async () => {
mockStagePausedCancellation.mockResolvedValue({ kind: 'idle' })
mockCompletePausedCancellation.mockResolvedValue(true)
@@ -392,6 +392,49 @@ async function rollbackPausedCancellationAfterAbort(args: {
return true
}
async function rollbackActiveResumeAfterFailedSignal(args: {
executionId: string
workflowId: string
resumeEntryId: string
}): Promise<boolean> {
try {
const rolledBack = await PauseResumeManager.rollbackActiveResumeCancellation(
args.executionId,
args.workflowId,
args.resumeEntryId
)
if (!rolledBack) {
logger.warn('Active resume cancellation could not be rolled back; completing cancellation', {
executionId: args.executionId,
activeResumeEntryId: args.resumeEntryId,
})
}
return rolledBack
} catch (error) {
logger.warn('Failed to roll back active resume cancellation; completing cancellation', {
executionId: args.executionId,
activeResumeEntryId: args.resumeEntryId,
error: toError(error).message,
})
return false
}
}
function activeResumeSignalFailureResult(
executionId: string,
stopSummary: ExecutionStopSummary
): CancelWorkflowExecutionResult {
return {
success: false,
executionId,
redisAvailable: stopSummary.cancellation.reason !== 'redis_unavailable',
durablyRecorded: stopSummary.cancellation.durablyRecorded,
locallyAborted: stopSummary.locallyAborted,
pausedCancelled: false,
reason: 'active_resume_signal_failed',
}
}
function resolveCancellationReason(args: {
activeResumeSignalFailed: boolean
pauseReconciliationFailed: boolean
@@ -676,26 +719,14 @@ export async function cancelWorkflowExecution({
if (!activeResumeSignalAccepted) {
const failedResumeEntryId = activeResumeTarget.resumeEntryId
await PauseResumeManager.rollbackActiveResumeCancellation(
const rolledBack = await rollbackActiveResumeAfterFailedSignal({
executionId,
workflowId,
failedResumeEntryId
).catch((error) => {
logger.warn('Failed to roll back active resume cancellation intent', {
executionId,
activeResumeEntryId: failedResumeEntryId,
error: toError(error).message,
})
resumeEntryId: failedResumeEntryId,
})
await clearStopSignalMarkers(stopSummary)
return {
success: false,
executionId,
redisAvailable: stopSummary.cancellation.reason !== 'redis_unavailable',
durablyRecorded: stopSummary.cancellation.durablyRecorded,
locallyAborted: stopSummary.locallyAborted,
pausedCancelled: false,
reason: 'active_resume_signal_failed',
if (rolledBack) {
await clearStopSignalMarkers(stopSummary)
return activeResumeSignalFailureResult(executionId, stopSummary)
}
}
} else if (!effectivePausedCancellationPath && !isWorkflowGroupExecution) {
@@ -738,26 +769,14 @@ export async function cancelWorkflowExecution({
})
if (!activeResumeSignalAccepted) {
const failedResumeEntryId = activeResumeTarget.resumeEntryId
await PauseResumeManager.rollbackActiveResumeCancellation(
const rolledBack = await rollbackActiveResumeAfterFailedSignal({
executionId,
workflowId,
failedResumeEntryId
).catch((error) => {
logger.warn('Failed to roll back late active resume cancellation intent', {
executionId,
activeResumeEntryId: failedResumeEntryId,
error: toError(error).message,
})
resumeEntryId: failedResumeEntryId,
})
await clearStopSignalMarkers(stopSummary)
return {
success: false,
executionId,
redisAvailable: stopSummary.cancellation.reason !== 'redis_unavailable',
durablyRecorded: stopSummary.cancellation.durablyRecorded,
locallyAborted: stopSummary.locallyAborted,
pausedCancelled: false,
reason: 'active_resume_signal_failed',
if (rolledBack) {
await clearStopSignalMarkers(stopSummary)
return activeResumeSignalFailureResult(executionId, stopSummary)
}
}
} else if (!effectivePausedCancellationPath) {