{"record":{"id":"340d94cd320aa5e8","repo":"n8n-io/n8n","slug":"run-runid-is-not-suspended-cannot-resume","errorCode":null,"errorMessage":"Run ${runId} is not suspended. Cannot resume.","messagePattern":"Run (.+?) is not suspended\\. Cannot resume\\.","errorType":"exception","errorClass":"StaleResumeError","httpStatus":null,"severity":"warning","filePath":"packages/@n8n/agents/src/runtime/state/run-state.ts","lineNumber":93,"sourceCode":"\t/**\n\t * Durable-log RFC (resilience phase): load a checkpoint after a crash,\n\t * where status is 'running' (mid-step death) rather than 'suspended'.\n\t * Callers pair this with the interrupted-run sweeper: in-flight tool calls\n\t * are resolved as `tool-interrupted` facts, never re-executed.\n\t */\n\tasync loadForCrashResume(runId: string): Promise<SerializableAgentState | undefined> {\n\t\treturn await this.store.load(runId);\n\t}\n\n\t/**\n\t * Load a suspended run state for resumption. This is read-only so callers can\n\t * validate the resume request before claiming the checkpoint.\n\t */\n\tasync resume(runId: string): Promise<SerializableAgentState | undefined> {\n\t\tconst state = await this.store.load(runId);\n\t\tif (!state) return undefined;\n\t\tif (state.status !== 'suspended') {\n\t\t\tthrow new StaleResumeError(`Run ${runId} is not suspended. Cannot resume.`);\n\t\t}\n\t\treturn state;\n\t}\n\n\tasync claimResume(runId: string, state: SerializableAgentState): Promise<boolean> {\n\t\tif (state.status !== 'suspended') {\n\t\t\tthrow new StaleResumeError(`Run ${runId} is not suspended. Cannot resume.`);\n\t\t}\n\n\t\tif (this.store.claimForResume) {\n\t\t\treturn await this.store.claimForResume(runId, state);\n\t\t}\n\n\t\tawait this.store.save(runId, { ...state, status: 'running' });\n\t\treturn true;\n\t}\n\n\t/** Delete a finished run from storage. Called when a resumed run completes without re-suspending. */","sourceCodeStart":75,"sourceCodeEnd":111,"githubUrl":"https://github.com/n8n-io/n8n/blob/5ac6606e81f67bb9534255570cd4e86fd8101eee/packages/@n8n/agents/src/runtime/state/run-state.ts#L75-L111","documentation":"`RunStateManager.resume()` is a read-only load used to validate a resume request before claiming the checkpoint. It loads the stored `SerializableAgentState` and throws `StaleResumeError` if `status !== 'suspended'` — meaning the run is either still running, already completed, already cancelled, or was never suspended. This is a warning-level error (`level: 'warning'`) indicating a benign resume race: another consumer already claimed, finished, or re-suspended the run.","triggerScenarios":"A human-in-the-loop approval webhook fires twice (double-click, retry). Two n8n main instances race to resume the same run. A resume request arrives after the run already completed or timed out. A resume request arrives for a run that was never suspended (e.g. a synchronous run).","commonSituations":"Duplicate webhook delivery for tool-approval. Multi-main deployment where two mains process the same resume event. Frontend retry on timeout when the first request actually succeeded. A stale UI tab sending an old resume action.","solutions":["Catch `StaleResumeError` at the resume endpoint/handler — it is `level: 'warning'`, so treat it as a no-op success (idempotent resume) rather than a hard failure.","If multi-main racing is frequent, ensure `claimForResume` on the store is atomic (the default `MemoryCheckpointStore` uses reference equality; a DB-backed store should use a conditional UPDATE).","Deduplicate resume requests at the webhook/queue level (idempotency key on `runId`).","Log the current `status` from the loaded state to understand why the run is not suspended (completed? running? cancelled?)."],"exampleFix":"// before:\nconst state = await runState.resume(runId);\n// StaleResumeError propagates uncaught on duplicate webhook\n\n// after:\ntry {\n  const state = await runState.resume(runId);\n  // proceed with claim and execution\n} catch (e) {\n  if (e instanceof StaleResumeError) {\n    // benign: already resumed/completed/cancelled — return success (idempotent)\n    return { status: 'already-resumed' };\n  }\n  throw e;\n}","handlingStrategy":"try-catch","validationCode":"// Check run status before calling resume\nasync function safeResume(runId: string) {\n  const state = await runState.loadForCrashResume(runId);\n  if (!state) return undefined;\n  if (state.status !== 'suspended') {\n    return undefined; // not in a resumable state\n  }\n  return runState.resume(runId);\n}","typeGuard":"function isSuspended(state: SerializableAgentState | undefined): state is SerializableAgentState & { status: 'suspended' } {\n  return state !== undefined && state.status === 'suspended';\n}","tryCatchPattern":"import { StaleResumeError } from './run-state';\n\ntry {\n  const state = await runState.resume(runId);\n  // ... proceed with claim and execution\n} catch (e) {\n  if (e instanceof StaleResumeError) {\n    // Benign: run was already resumed, completed, or cancelled\n    return { status: 'already-resumed', ok: true };\n  }\n  throw e;\n}","preventionTips":["Always catch StaleResumeError at resume endpoints — it is warning-level and benign.","Deduplicate resume requests with an idempotency key on runId.","In multi-main deployments, ensure the CheckpointStore's claimForResume is atomic.","Log the stale state's status to diagnose why the run is not suspended."],"tags":["run-state","resume","race-condition","hitl","human-in-the-loop"],"backgroundTag":null,"analyzedSha":"5ac6606e81f67bb9534255570cd4e86fd8101eee","analyzedAt":"2026-08-12T05:26:35.080Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}