n8n-io/n8n · warning · StaleResumeError
Run ${runId} is not suspended. Cannot resume.
Error message
Run ${runId} is not suspended. Cannot resume. What it means
`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.
Source
Thrown at packages/@n8n/agents/src/runtime/state/run-state.ts:93
/**
* Durable-log RFC (resilience phase): load a checkpoint after a crash,
* where status is 'running' (mid-step death) rather than 'suspended'.
* Callers pair this with the interrupted-run sweeper: in-flight tool calls
* are resolved as `tool-interrupted` facts, never re-executed.
*/
async loadForCrashResume(runId: string): Promise<SerializableAgentState | undefined> {
return await this.store.load(runId);
}
/**
* Load a suspended run state for resumption. This is read-only so callers can
* validate the resume request before claiming the checkpoint.
*/
async resume(runId: string): Promise<SerializableAgentState | undefined> {
const state = await this.store.load(runId);
if (!state) return undefined;
if (state.status !== 'suspended') {
throw new StaleResumeError(`Run ${runId} is not suspended. Cannot resume.`);
}
return state;
}
async claimResume(runId: string, state: SerializableAgentState): Promise<boolean> {
if (state.status !== 'suspended') {
throw new StaleResumeError(`Run ${runId} is not suspended. Cannot resume.`);
}
if (this.store.claimForResume) {
return await this.store.claimForResume(runId, state);
}
await this.store.save(runId, { ...state, status: 'running' });
return true;
}
/** Delete a finished run from storage. Called when a resumed run completes without re-suspending. */View on GitHub (pinned to 5ac6606e81)
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?).
Example fix
// before:
const state = await runState.resume(runId);
// StaleResumeError propagates uncaught on duplicate webhook
// after:
try {
const state = await runState.resume(runId);
// proceed with claim and execution
} catch (e) {
if (e instanceof StaleResumeError) {
// benign: already resumed/completed/cancelled — return success (idempotent)
return { status: 'already-resumed' };
}
throw e;
} Defensive patterns
Strategy: try-catch
Validate before calling
// Check run status before calling resume
async function safeResume(runId: string) {
const state = await runState.loadForCrashResume(runId);
if (!state) return undefined;
if (state.status !== 'suspended') {
return undefined; // not in a resumable state
}
return runState.resume(runId);
} Type guard
function isSuspended(state: SerializableAgentState | undefined): state is SerializableAgentState & { status: 'suspended' } {
return state !== undefined && state.status === 'suspended';
} Try / catch
import { StaleResumeError } from './run-state';
try {
const state = await runState.resume(runId);
// ... proceed with claim and execution
} catch (e) {
if (e instanceof StaleResumeError) {
// Benign: run was already resumed, completed, or cancelled
return { status: 'already-resumed', ok: true };
}
throw e;
} Prevention
- 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.
When it happens
Trigger: 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).
Common situations: 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.
Related errors
- Delegated child checkpoint metadata is missing or invalid
- Delegated child checkpoint does not match the selected sub-a
- No pending tool call found for toolCallId: ${resumedId}
- Tool "${this.name}" has .suspend() but missing .resume()
- Tool "${this.name}" has .resume() but missing .suspend()
AI-assisted analysis of n8n-io/n8n@5ac6606e81 (2026-08-12).
Data as JSON: /api/errors/340d94cd320aa5e8.
Report an issue: GitHub.