{"record":{"id":"cd40d5aa998d0296","repo":"coleam00/Archon","slug":"resumable-run-snapshot-changed-during-reset-expec","errorCode":null,"errorMessage":"Resumable run snapshot changed during reset (expected ${String(resumable.length)}, cancelled ${String(result.rowCount)})","messagePattern":"Resumable run snapshot changed during reset \\(expected (.+?), cancelled (.+?)\\)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/core/src/db/workflows.ts","lineNumber":618,"sourceCode":"        `SELECT * FROM remote_agent_workflow_runs\n         WHERE conversation_id = $1 OR parent_conversation_id = $2\n         ORDER BY started_at DESC${rowLockClause()}`,\n        [conversationId, conversationId]\n      );\n      const resumable = snapshot.rows.filter(\n        run => run.status === 'paused' || run.status === 'failed'\n      );\n      if (resumable.length === 0) return [];\n\n      const result = await query(\n        `UPDATE remote_agent_workflow_runs\n         SET status = 'cancelled', completed_at = ${dialect.now()}\n         WHERE (conversation_id = $1 OR parent_conversation_id = $2)\n           AND status IN ('paused', 'failed')`,\n        [conversationId, conversationId]\n      );\n      if (result.rowCount !== resumable.length) {\n        throw new Error(\n          `Resumable run snapshot changed during reset (expected ${String(resumable.length)}, cancelled ${String(result.rowCount)})`\n        );\n      }\n      for (const run of resumable) {\n        await insertWorkflowEvent(query, {\n          workflow_run_id: run.id,\n          event_type: 'workflow_cancelled',\n        });\n      }\n      return resumable.map(run => normalizeWorkflowRun(run));\n    });\n  } catch (error) {\n    const err = error as Error;\n    getLog().error({ err, conversationId }, 'db.workflow_run_cancel_resumable_for_conv_failed');\n    throw new Error(`Failed to cancel resumable runs for conversation: ${err.message}`);\n  }\n}\n","sourceCodeStart":600,"sourceCodeEnd":636,"githubUrl":"https://github.com/coleam00/Archon/blob/0773b9745896ef0612e709c80845a0f7db315b19/packages/core/src/db/workflows.ts#L600-L636","documentation":"cancelResumableRunsForConversation first SELECTs the paused/failed runs it intends to cancel, then cancels them with an UPDATE and verifies the affected row count matches the snapshot. This error is thrown when rowCount differs, meaning another writer inserted, deleted, or transitioned a resumable run between the read and the write. It is a deliberate optimistic-concurrency guard, not a driver failure.","triggerScenarios":"Calling cancelResumableRunsForConversation while a concurrent actor (another process, a resume call, a manual DB edit) pauses/fails/cancels/deletes a run in the same conversation between the SELECT and the UPDATE, so the UPDATE affects fewer (or more) rows than were snapshotted.","commonSituations":"Two chat handlers racing on the same conversation reset; a user approving/resuming a paused run at the same moment an operator cancels it; a background sweeper flipping run status concurrently.","solutions":["Retry the whole cancelResumableRunsForConversation call: the next execution takes a fresh snapshot and usually succeeds.","Identify the concurrent writer (resume path, admin action) and serialize the reset against it.","If it recurs, wrap the SELECT+UPDATE in a single transaction with row locks (SELECT ... FOR UPDATE) so the snapshot cannot drift.","Inspect workflow_events for the run that changed to confirm who mutated it."],"exampleFix":"// before\ntry {\n  await cancelResumableRunsForConversation(conversationId);\n} catch (err) {\n  if (String(err).includes('snapshot changed')) throw err;\n}\n// after\nfor (let attempt = 0; attempt < 3; attempt++) {\n  try {\n    await cancelResumableRunsForConversation(conversationId);\n    break;\n  } catch (err) {\n    if (!String(err).includes('snapshot changed') || attempt === 2) throw err;\n  }\n}","handlingStrategy":"retry","validationCode":"// Pre-check: snapshot the resumable runs and refuse if state is visibly churning\nconst before = await pool.query(\n  `SELECT id FROM workflow_runs WHERE (conversation_id = $1 OR parent_conversation_id = $1) AND status IN ('paused','failed')`,\n  [conversationId]\n);\nif (before.rows.length === 0) return; // nothing to cancel, skip the racy path","typeGuard":"function isSnapshotMismatch(err: unknown): boolean {\n  return err instanceof Error && err.message.includes('Resumable run snapshot changed during reset');\n}","tryCatchPattern":"for (let attempt = 0; attempt < 3; attempt++) {\n  try {\n    await cancelResumableRunsForConversation(conversationId);\n    break;\n  } catch (err) {\n    if (!isSnapshotMismatch(err) || attempt === 2) throw err;\n    await sleep(50 * (attempt + 1)); // back off so the concurrent writer settles\n  }\n}","preventionTips":["Serialize conversation resets against resume/approval paths with an application-level mutex.","Retry the whole function rather than partially interpreting the mismatch.","Investigate recurring occurrences: they indicate two actors owning the same conversation.","Consider adding SELECT ... FOR UPDATE in a transaction if the race is frequent."],"tags":["database","concurrency","race-condition","workflow-run"],"backgroundTag":"race-condition-snapshot-mismatch","analyzedSha":"0773b9745896ef0612e709c80845a0f7db315b19","analyzedAt":"2026-09-01T02:28:07.064Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-08T05:18:18.240Z"}