paperclipai/paperclip · warning · QueuedCommentMutationError

queued_comment_already_dispatching

queued_comment_already_dispatching

Error message

The queued message is already being dispatched

What it means

Inside withLockedQueue, the wake-row lookup classified the queued comment as already_dispatching: a dispatch of this comment is in flight. The mutation (edit/reorder/discard) is refused with QueuedCommentMutationError code queued_comment_already_dispatching to prevent changing a message while it is being sent.

Solutions

  1. Wait for dispatch to complete (poll the comment status) and then operate on the resulting state (e.g., follow-up message instead of edit)
  2. Treat code queued_comment_already_dispatching in the UI as 'message is being sent' and disable the action
  3. Retry the mutation after the dispatch finishes only if the comment returns to a mutable state
  4. Use a follow-up queued comment instead of mutating one already in flight

Example fix

// before
await editQueuedComment(id, rev, text); // throws while dispatching
// after
try { await editQueuedComment(id, rev, text); }
catch (e) { if (e.code === "queued_comment_already_dispatching") await queueFollowUp(text); else throw e; }
Defensive patterns

Strategy: try-catch

Validate before calling

const snap = await getQueuedCommentSnapshot(commentId);
if (snap.status === "dispatching") { console.log("dispatch in flight — mutate later"); return; }

Type guard

function isMutable(snap: { status: string }): boolean { return snap.status === "pending"; }

Try / catch

try { await editQueuedComment(...); }
catch (e) {
  if (e instanceof QueuedCommentMutationError && e.code === "queued_comment_already_dispatching") {
    await pollUntilDispatched(commentId);
    await queueFollowUp(editedText); // post-dispatch alternative
  } else throw e;
}

Prevention

When it happens

Trigger: A mutation path (withLockedQueue) observes decideQueuedCommentWakeLookup return kind "already_dispatching" — i.e., the wake row/queue run for the comment is mid-dispatch when the lock is acquired.

Common situations: User tries to edit or discard a queued message at the exact moment the heartbeat wakes and starts dispatching it; a slow agent turn keeps the comment in dispatching state while the user retries the UI action.

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/d142f8a562ad8865. Report an issue: GitHub.

Appendix: source

Thrown at server/src/modules/wake-queue/adapters/queued-comment-postgres.ts:245

          )
          .for("update")
          .limit(1)
          .then((rows) => rows[0] ?? null);

        const wakePayload = parseObject(wakeRow?.payload);
        const lookup = decideQueuedCommentWakeLookup({
          wakePresent: wakeRow !== null,
          wakeIssueIdMatches: readNonEmptyString(wakePayload.issueId) === input.issue.id,
          hasQueuedCommentIds: queuedCommentIdsFromWakePayload(wakeRow?.payload ?? null).length > 0,
          wakeStatus: wakeRow?.status ?? null,
          wakeHasRunId: Boolean(wakeRow?.runId),
        });

        if (lookup.kind === "not_pending") {
          throw new QueuedCommentMutationError("queued_comment_not_pending", "The queued message is no longer pending");
        }
        if (lookup.kind === "already_dispatching") {
          throw new QueuedCommentMutationError("queued_comment_already_dispatching", "The queued message is already being dispatched");
        }

        // Unreachable: `decideQueuedCommentWakeLookup` only returns "deferred" or "check_queue_run" when the wake row is present.
        if (!wakeRow) throw new Error("wake-queue: queued-comment lookup resolved without a wake row");

        let state: "deferred" | "queued";
        let queueRunRow: RunRow | null = null;

        if (lookup.kind === "deferred") {
          state = "deferred";
        } else {
          // check_queue_run: `wakeHasRunId` was true for this branch to have been reached.
          queueRunRow = await tx
            .select()
            .from(heartbeatRuns)
            .where(
              and(
                eq(heartbeatRuns.id, wakeRow.runId!),

View on GitHub (pinned to 3f1d897a7c)