paperclipai/paperclip · warning
Conversation session changed; this older turn cannot resume
Error message
Conversation session changed; this older turn cannot resume
What it means
prepareConversationTurn guards against stale turns: each conversation issue carries a conversationSessionGeneration that increments whenever the session is reset. If the caller passes a context whose conversationSessionGeneration is a number that no longer matches the issue's current generation, the turn is from an older session and is refused — resuming it would continue the wrong agent session state.
Solutions
- Discard the stale turn and submit a new message so it carries the current session generation.
- Refresh the issue/conversation state (re-fetch) and retry with the updated conversationSessionGeneration.
- Avoid resetting the session while turns are queued; wait for pending turns to drain first.
- If this happens on retries, drop idempotency replays of old turn payloads after any reset.
Example fix
// before
await prepareConversationTurn(issue.id, { conversationSessionGeneration: savedGeneration, ... });
// after
const issue = await getIssue(issue.id);
if (issue.conversationSessionGeneration !== savedGeneration) {
savedGeneration = issue.conversationSessionGeneration; // refresh to current session
}
await prepareConversationTurn(issue.id, { conversationSessionGeneration: savedGeneration, ... }); Defensive patterns
Strategy: type-guard
Validate before calling
const current = (await getIssue(issueId)).conversationSessionGeneration;
if (typeof pendingTurn.conversationSessionGeneration === "number" &&
pendingTurn.conversationSessionGeneration !== current) {
pendingTurn = null; // stale turn; resubmit instead
} Type guard
function canResume(turn, issue) {
return typeof turn.conversationSessionGeneration !== "number" ||
turn.conversationSessionGeneration === issue.conversationSessionGeneration;
} Try / catch
try { await prepareConversationTurn(issueId, ctx); }
catch (e) {
if (e.message.includes("older turn cannot resume")) {
await refreshConversation(issueId); // discard stale turn, start fresh
return;
}
throw e;
} Prevention
- Re-fetch issue state before submitting queued turns
- Do not reset the conversation while turns are pending
- Drop idempotent replays of pre-reset turn payloads
- Refresh UI state after any session reset in another tab
When it happens
Trigger: Enqueueing or preparing a conversation turn after a reset occurred (e.g. a reset command comment bumped the generation) while the caller still holds the pre-reset generation; a UI or automation retrying an old turn payload after the session was regenerated.
Common situations: A user hits 'reset conversation' while an agent response is still pending; a queued/retried turn from before a crash-recovery reset; multiple board tabs where one reset the session and another submits a follow-up 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
- ACPX runtime omitted backendSessionId
- Codex thread response omitted thread.id
- --company-name cannot be empty.
- conversation_turn_cancelled
- Discord form correction is not current
AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18).
Data as JSON: /api/errors/de3a6173ab2d2cd2.
Report an issue: GitHub.
Appendix: source
Thrown at server/src/services/agent-conversations.ts:150
.select()
.from(issueComments)
.where(
and(
eq(issueComments.id, commentId),
eq(issueComments.issueId, issueId),
eq(issueComments.companyId, run.companyId),
),
)
: [];
const reset = Boolean(
comment && comment.authorUserId && isConversationReset(comment.body),
);
let generation = issue.conversationSessionGeneration;
if (
typeof context.conversationSessionGeneration === "number" &&
context.conversationSessionGeneration !== generation
) {
throw new Error(
"Conversation session changed; this older turn cannot resume",
);
}
// The boundary lives on the command comment. A crash/retry reuses it instead of resetting twice.
if (reset && comment && comment.conversationSessionGeneration == null) {
generation += 1;
await tx
.update(issues)
.set({
conversationSessionGeneration: generation,
conversationBoundaryCommentId: comment.id,
updatedAt: new Date(),
})
.where(eq(issues.id, issue.id));
await tx
.update(issueComments)
.set({ conversationSessionGeneration: generation })
.where(eq(issueComments.id, comment.id));View on GitHub (pinned to 3f1d897a7c)