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

  1. Check the run status before sending the next conversation turn and stop the loop when it is cancelled
  2. Handle the 403 code conversation_turn_cancelled in the agent client as a terminal signal, not a retryable error
  3. In the UI/CLI, surface the cancellation to the agent run loop so it aborts pending work
  4. 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

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


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)