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

  1. 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.
  2. 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).
  3. Deduplicate resume requests at the webhook/queue level (idempotency key on `runId`).
  4. 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

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


AI-assisted analysis of n8n-io/n8n@5ac6606e81 (2026-08-12). Data as JSON: /api/errors/340d94cd320aa5e8. Report an issue: GitHub.