paperclipai/paperclip · error · Error

Review decision was not accepted

Error message

Review decision was not accepted (${response.status}): ${(await response.text()).slice(0, 2_000)}

What it means

In #resolveReview, after validating the accept/reject decision locally, the runner posts the decision to the issue interaction resolution route (`/api/issues/:id/interactions/:interactionId/accept|reject`) using a locally minted agent JWT. If the HTTP response is not ok, the error is thrown with the upstream status and up to 2000 chars of the response body, so the agent sees exactly why the control plane refused the decision.

Solutions

  1. Check the status and body text embedded in the error message; fix the root cause on the control-plane side (auth, permissions, or interaction state) and retry the tool call.
  2. Verify PAPERCLIP_API_URL (or binding.apiUrl) points at the reachable API base with no trailing slash issues and correct scheme.
  3. Confirm the run is still the issue's executionRunId and the review interaction is still pending; if the board already decided, re-fetch assignment state instead of retrying blind.
  4. For 401s, ensure the local agent JWT secret/config matches the API server so createLocalAgentJwt tokens validate.
  5. Retry only after fixing the cause; the operation is POSTed to a resolution route and may not be idempotent across differing decisions.

Example fix

// before: blind retry loop on failure
while (!ok) { await resolveReview({ decision: 'accept' }); }

// after: inspect status and stop on permanent failures
if (err.message.includes('(401)') || err.message.includes('(403)')) {
  throw err; // fix auth/authorization first, do not retry
}
Defensive patterns

Strategy: try-catch

Validate before calling

const apiUrl = binding.apiUrl ?? process.env.PAPERCLIP_API_URL;
if (!apiUrl || !/^https?:\/\//.test(apiUrl)) throw new Error('PAPERCLIP_API_URL not configured');
if (!review || review.interaction.status !== 'pending') throw new Error('review not pending for this run');

Try / catch

try {
  await resolveReview({ decision });
} catch (err) {
  const m = /\((\d{3})\)/.exec(err.message);
  if (m && ['401','403','404','409'].includes(m[1])) {
    // permanent: refresh assignment/auth, do not blind-retry
    await refreshReviewState();
  }
  throw err;
}

Prevention

When it happens

Trigger: Calling the resolve_review runner tool when the API returns a non-2xx: e.g. 401 because the locally minted createLocalAgentJwt is rejected (PAPERCLIP_API_URL mismatch or secret skew), 403 because the run is no longer authorized for the interaction, 404 because the interaction id does not exist on that issue, 409 from a concurrent decision, or a 30x redirect (redirect: 'error').

Common situations: PAPERCLIP_API_URL set to the wrong host/port or missing trailing protocol, the review interaction being resolved by the board between the status check and the POST, stale run admission after a run restart, or the API being behind a proxy that returns HTML error pages.

Understand the failure class

Background: "API error: {status}" and "HTTP 401/403/404/429/5xx" errors: non-2xx HTTP responses explained — this error's family across 27 libraries.

Related errors


AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18). Data as JSON: /api/errors/8bb73e7c9bb25343. Report an issue: GitHub.

Appendix: source

Thrown at server/src/services/native-runtime/paperclip-runner-tool-authority.ts:646

    });
    if (!review) throw forbidden("Review is no longer assigned to this run.");
    const expectedStatus = input.decision === "accept" ? "accepted" : "rejected";
    if (review.interaction.status !== "pending") {
      if (review.interaction.status !== expectedStatus) throw badRequest("This review already has a different decision.");
      return { interactionId: review.interaction.id, status: expectedStatus, deduplicated: true };
    }
    const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL;
    const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId,
      context.actor.adapterType, this.binding.runId, context.run.responsibleUserId);
    if (!apiUrl || !token) throw new Error("Review tool authentication is unavailable");
    // Use the existing resolution route so authorization, activity, dependency
    // wakes and request-changes continuation have one implementation.
    const response = await fetch(`${apiUrl.replace(/\/$/, "")}/api/issues/${this.binding.issueId}/interactions/${review.interaction.id}/${input.decision}`, {
      method: "POST", redirect: "error", signal: AbortSignal.timeout(30_000),
      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "X-Paperclip-Run-Id": this.binding.runId },
      body: JSON.stringify(input.decision === "reject" ? { reason } : {}),
    });
    if (!response.ok) throw new Error(`Review decision was not accepted (${response.status}): ${(await response.text()).slice(0, 2_000)}`);
    return { interactionId: review.interaction.id, status: expectedStatus };
  }

  async #reportProgress(input: Record<string, unknown>): Promise<unknown> {
    const body = typeof input.body === "string" ? input.body.trim() : "";
    const idempotencyKey = typeof input.idempotencyKey === "string" ? input.idempotencyKey.trim() : "";
    if (!body || !idempotencyKey) throw new Error("paperclip_runner_tool_input_invalid");
    let publication: Awaited<ReturnType<typeof persistActivity>>["publication"] | null = null;
    const result = await this.#withMutationReceipt(
      "report_progress",
      idempotencyKey,
      input,
      async (tx, context) => {
        const comment = await issueService(tx).addComment(
          this.binding.issueId,
          body,
          { agentId: this.binding.agentId, runId: this.binding.runId },
          { authorizationReason: "paperclip_runner_protocol" },

View on GitHub (pinned to 3f1d897a7c)