paperclipai/paperclip · error
conversation_turn_cancelled
conversation_turn_cancelled
Error message
This conversation turn was cancelled
What it means
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.
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
Example fix
// before
await fetch(url, { method: "POST", body });
// after
if (res.status === 403 && body.code === "conversation_turn_cancelled") { stopRunLoop(); return; } Defensive patterns
Strategy: try-catch
Validate before calling
// client-side: check run status before sending a conversation turn
const run = await api.get(`/api/companies/${companyId}/heartbeat-runs/${runId}`);
if (run.status === "cancelled") { stopConversationLoop(); return; } Type guard
function isCancellationRejection(res: { status: number; body?: { code?: string } }): boolean {
return res.status === 403 && res.body?.code === "conversation_turn_cancelled";
} Try / catch
const res = await sendTurn(...);
if (res.status === 403 && res.body?.code === "conversation_turn_cancelled") {
abortConversation(runId); // terminal: do not retry
return;
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Understand the failure class
Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.
Related errors
- invalid_cloud_control_assertion
- invalid_cloud_runtime_identity
- Cloud control assertion has already been used
- cloud_control_wrong_endpoint
- Cloud runtime identity assertion is expired or has an…
AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18).
Data as JSON: /api/errors/0508b5ed13f1e9d1.
Report an issue: GitHub.
Appendix: source
Thrown at server/src/middleware/auth.ts:390
});
next(
unprocessable("X-Paperclip-Run-Id does not match signed agent JWT run_id", {
code: "agent_jwt_run_id_mismatch",
claimRunId: claims.run_id,
headerRunId: normalizedRunIdHeader,
}),
);
return;
}
const [identityRun] = await db.select({ activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
responsibleUserId: heartbeatRuns.responsibleUserId, status: heartbeatRuns.status,
contextSnapshot: heartbeatRuns.contextSnapshot }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, claims.run_id), eq(heartbeatRuns.companyId, claims.company_id), eq(heartbeatRuns.agentId, claims.sub),
));
if (identityRun?.status === "cancelled" && identityRun.contextSnapshot?.conversationMode === true
&& !["GET", "HEAD", "OPTIONS"].includes(req.method)) {
_res.status(403).json({ error: "This conversation turn was cancelled", code: "conversation_turn_cancelled" });
return;
}
if (identityRun?.activeIdentityContextId && identityRun.status === "running") {
const captured = await captureRunIdentity(db, { companyId: claims.company_id, agentId: claims.sub, runId: claims.run_id });
identityRun.activeIdentityContextId = captured.context?.id ?? null;
identityRun.responsibleUserId = captured.context?.responsibleUserId ?? null;
}
const onBehalfOfUserId = identityRun?.activeIdentityContextId
? identityRun.responsibleUserId
: claims.responsible_user_id !== undefined
? normalizeOptionalString(claims.responsible_user_id)
: await resolveLegacyRunResponsibleUserId(db, {
companyId: claims.company_id,
agentId: claims.sub,
runId: claims.run_id,
});
const onBehalfOfMemberships = await loadResponsibleUserMemberships(db, {
companyId: claims.company_id,View on GitHub (pinned to 3f1d897a7c)