paperclipai/paperclip · error · PhotonAnswerValidationError
Photon poll already claimed
Error message
Photon poll already claimed
What it means
Inside publishPhotonPrompt, before creating a native poll, the code performs a compare-and-swap on adapter.state: if the current record is already phase "creating" and retryUnknown is falsy, another concurrent attempt has claimed the send, so a PhotonAnswerValidationError("Photon poll already claimed") is thrown. This is a concurrency guard ensuring only one poll-creation attempt is in flight per stateKey.
Solutions
- Serialize publishing per publicationId (queue or lock) so only one attempt runs at a time.
- After reconciling the unknown-outcome prior attempt, reset state to prepared and retry with retryUnknown: true.
- Check for duplicate triggers (double delivery of the same interaction) and deduplicate upstream.
- If a stale creating record is the cause, manually update state to {schema:1, phase:"prepared"} before retrying.
Example fix
// before — concurrent duplicate call publishPhotonPrompt(input); publishPhotonPrompt(input); // second throws // after const once = memoizePerPublication(publishPhotonPrompt); await Promise.all([once(input), once(input)]); // single create
Defensive patterns
Strategy: try-catch
Validate before calling
const claimed = (await adapter.state.read<{phase:string}>(`poll:${publicationId}:${qIndex}`))?.phase === "creating";
if (claimed) await reconcileBeforePublish(publicationId); Type guard
function isUnclaimed(current: {phase: string} | undefined): boolean {
return current?.phase !== "creating";
} Try / catch
try {
await publishPhotonPrompt(input);
} catch (e) {
if (e instanceof PhotonAnswerValidationError && e.message === "Photon poll already claimed") {
// another attempt owns the send; back off and poll state until created/prepared
await waitForPollSettled(`poll:${input.publicationId}:${qIndex}`);
} else throw e;
} Prevention
- Deduplicate interaction deliveries before invoking publishPhotonPrompt.
- Use a per-publication mutex/queue around publishing.
- Add idempotency keys upstream so duplicate triggers collapse to one call.
- Clean up creating state left by crashed processes during startup recovery.
When it happens
Trigger: Two concurrent invocations of publishPhotonPrompt for the same publicationId/questionIndex both reach the adapter.state.update(stateKey) CAS; the loser sees current.phase === "creating" and throws. Also thrown when a stale creating record from a crashed prior attempt is hit during the CAS despite the earlier read check passing.
Common situations: Duplicate webhook/redelivery triggering two publishPhotonPrompt calls in parallel; retried request racing the original still in flight; a crashed process leaving phase creating and a later attempt bypassing the read check via race timing.
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
- delivery_unknown
- capability_live_attempt_active_turn
- Capability live session already has an active turn
- ACPX provider ownership admission is closed
- ACPX runtime host already has an active turn
AI-assisted analysis of paperclipai/paperclip@3f1d897a7c (2026-09-18).
Data as JSON: /api/errors/4d78e3b4559898f8.
Report an issue: GitHub.
Appendix: source
Thrown at server/src/services/photon/interactions.ts:291
type PollSend = {
schema: 1;
phase: "prepared" | "creating" | "created";
receipt?: {
pollMessageGuid: string;
options: Array<{ optionIdentifier: string; text: string }>;
};
};
let saved = await adapter.state.read<PollSend>(stateKey);
if (saved?.phase === "creating" && !input.retryUnknown)
throw new PhotonError(
"delivery_unknown",
"Photon poll creation is unknown; reconcile this publication before retrying",
);
if (!saved || saved.phase !== "created") {
await assertCurrent();
await adapter.state.update<PollSend>(stateKey, (current) => {
if (current?.phase === "creating" && !input.retryUnknown)
throw new PhotonAnswerValidationError("Photon poll already claimed");
return { schema: 1, phase: "creating" };
});
const poll = await adapter.client.polls
.create(
adapter.decodeThreadId(input.threadId).chatGuid,
title,
choices.map((choice) => choice.label),
{
clientMessageId: createHash("sha256")
.update(`${adapter.state.scope.endpointId}:${stateKey}`)
.digest("hex"),
},
)
.catch(async (error) => {
const failure = photonFailure(error, true);
if (failure.code !== "delivery_unknown")
await adapter.state.update<PollSend>(stateKey, () => ({
schema: 1,View on GitHub (pinned to 3f1d897a7c)