{"record":{"id":"0508b5ed13f1e9d1","repo":"paperclipai/paperclip","slug":"conversation-turn-cancelled","errorCode":"conversation_turn_cancelled","errorMessage":"This conversation turn was cancelled","messagePattern":"This conversation turn was cancelled","errorType":"http","errorClass":null,"httpStatus":403,"severity":"error","filePath":"server/src/middleware/auth.ts","lineNumber":390,"sourceCode":"        });\n        next(\n          unprocessable(\"X-Paperclip-Run-Id does not match signed agent JWT run_id\", {\n            code: \"agent_jwt_run_id_mismatch\",\n            claimRunId: claims.run_id,\n            headerRunId: normalizedRunIdHeader,\n          }),\n        );\n        return;\n      }\n\n      const [identityRun] = await db.select({ activeIdentityContextId: heartbeatRuns.activeIdentityContextId,\n        responsibleUserId: heartbeatRuns.responsibleUserId, status: heartbeatRuns.status,\n        contextSnapshot: heartbeatRuns.contextSnapshot }).from(heartbeatRuns).where(and(\n          eq(heartbeatRuns.id, claims.run_id), eq(heartbeatRuns.companyId, claims.company_id), eq(heartbeatRuns.agentId, claims.sub),\n        ));\n      if (identityRun?.status === \"cancelled\" && identityRun.contextSnapshot?.conversationMode === true\n        && ![\"GET\", \"HEAD\", \"OPTIONS\"].includes(req.method)) {\n        _res.status(403).json({ error: \"This conversation turn was cancelled\", code: \"conversation_turn_cancelled\" });\n        return;\n      }\n      if (identityRun?.activeIdentityContextId && identityRun.status === \"running\") {\n        const captured = await captureRunIdentity(db, { companyId: claims.company_id, agentId: claims.sub, runId: claims.run_id });\n        identityRun.activeIdentityContextId = captured.context?.id ?? null;\n        identityRun.responsibleUserId = captured.context?.responsibleUserId ?? null;\n      }\n      const onBehalfOfUserId = identityRun?.activeIdentityContextId\n        ? identityRun.responsibleUserId\n        : claims.responsible_user_id !== undefined\n        ? normalizeOptionalString(claims.responsible_user_id)\n        : await resolveLegacyRunResponsibleUserId(db, {\n            companyId: claims.company_id,\n            agentId: claims.sub,\n            runId: claims.run_id,\n          });\n      const onBehalfOfMemberships = await loadResponsibleUserMemberships(db, {\n        companyId: claims.company_id,","sourceCodeStart":372,"sourceCodeEnd":408,"githubUrl":"https://github.com/paperclipai/paperclip/blob/3f1d897a7c018d76563a21c6e39c3c9b03933622/server/src/middleware/auth.ts#L372-L408","documentation":"actorMiddleware checks, for agent API-key requests carrying a run_id claim, whether the referenced heartbeat run has been cancelled while in conversation mode. If a non-GET/HEAD/OPTIONS request arrives for a cancelled conversation turn, the middleware immediately answers 403 with code conversation_turn_cancelled and never calls the downstream handler — the run's user cancelled the conversation and the server refuses further turns.","triggerScenarios":"An agent (or its CLI/adapter loop) continues POSTing messages in conversationMode for a heartbeat run whose status was set to \"cancelled\"; the identity run lookup (by id, company, and agent) returns status cancelled with contextSnapshot.conversationMode true.","commonSituations":"User cancels a conversation from the board while the agent is mid-turn; a retrying HTTP client resends a request after cancellation; a long-polling agent adapter not honoring cancel events and sending the next message.","solutions":["Check the run status before sending the next conversation turn and stop the loop when it is cancelled","Handle the 403 code conversation_turn_cancelled in the agent client as a terminal signal, not a retryable error","In the UI/CLI, surface the cancellation to the agent run loop so it aborts pending work","If the run should not have been cancelled, start a new run rather than reusing the cancelled run_id"],"exampleFix":"// before\nawait fetch(url, { method: \"POST\", body });\n// after\nif (res.status === 403 && body.code === \"conversation_turn_cancelled\") { stopRunLoop(); return; }","handlingStrategy":"try-catch","validationCode":"// client-side: check run status before sending a conversation turn\nconst run = await api.get(`/api/companies/${companyId}/heartbeat-runs/${runId}`);\nif (run.status === \"cancelled\") { stopConversationLoop(); return; }","typeGuard":"function isCancellationRejection(res: { status: number; body?: { code?: string } }): boolean {\n  return res.status === 403 && res.body?.code === \"conversation_turn_cancelled\";\n}","tryCatchPattern":"const res = await sendTurn(...);\nif (res.status === 403 && res.body?.code === \"conversation_turn_cancelled\") {\n  abortConversation(runId); // terminal: do not retry\n  return;\n}","preventionTips":["Poll run status before each conversation turn and stop on \"cancelled\"","Treat 403 conversation_turn_cancelled as terminal in agent clients, never retryable","Honor server-sent cancellation events in adapters promptly","Start a new run instead of reusing a cancelled run_id"],"tags":["auth","middleware","cancellation","conversation"],"backgroundTag":"invalid-state-transition","analyzedSha":"3f1d897a7c018d76563a21c6e39c3c9b03933622","analyzedAt":"2026-09-18T08:03:59.046Z","contentChangedAt":"2026-09-18T08:03:59.046Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}