ruvnet/ruflo · warning · Error
Contest has already been resolved
Error message
Contest has already been resolved
What it means
Plain Error thrown by contestSteal when claim.contestInfo.resolution is already set. Once resolveContest has decided a winner, the contest is closed and cannot be contested again. This is an idempotency guard against duplicate or racing contest attempts.
Source
Thrown at v3/@claude-flow/claims/src/application/work-stealing-service.ts:371
// ===========================================================================
/**
* Contest a steal (original owner wants the work back)
*/
async contestSteal(issueId: IssueId, originalClaimant: Claimant, reason: string): Promise<void> {
const claim = await this.repository.findByIssueId(issueId);
if (!claim) {
throw new Error(`Claim not found for issue: ${issueId}`);
}
// Check if there's a valid contest window
if (!claim.contestInfo) {
throw new Error('No steal to contest - issue was not recently stolen');
}
if (claim.contestInfo.resolution) {
throw new Error('Contest has already been resolved');
}
const nowMs = this.nowMs();
const windowEndsAtMs = new Date(claim.contestInfo.windowEndsAt).getTime();
if (nowMs > windowEndsAtMs) {
throw new Error('Contest window has expired');
}
// Verify the contester was the original owner
if (claim.contestInfo.contestedBy.id !== originalClaimant.id) {
throw new Error('Only the original claimant can contest the steal');
}
// Update contest info with reason
claim.contestInfo.reason = reason;
claim.contestInfo.contestedAt = new Date(nowMs);
await this.repository.update(claim);View on GitHub (pinned to 6b01dc5a68)
Solutions
- Check claim.contestInfo.resolution before calling; if set, treat as already-decided.
- De-duplicate contest requests with an idempotency key tied to the steal event id.
- Disable the contest action in the UI once a resolution is observed.
- Consume the StealContestResolved event to invalidate pending contest attempts.
Example fix
// before
await workStealing.contestSteal(issueId, originalClaimant, reason); // already resolved -> throws
// after
const claim = await repo.findByIssueId(issueId);
if (claim?.contestInfo && !claim.contestInfo.resolution) {
await workStealing.contestSteal(issueId, originalClaimant, reason);
} Defensive patterns
Strategy: validation
Validate before calling
const claim = await repository.findByIssueId(issueId);
if (claim?.contestInfo?.resolution) {
// already decided; nothing to do
return;
}
await workStealing.contestSteal(issueId, originalClaimant, reason); Type guard
function contestIsOpen(claim: IssueClaimWithStealing | null): claim is IssueClaimWithStealing {
return !!claim && !!claim.contestInfo && !claim.contestInfo.resolution;
} Try / catch
try {
await workStealing.contestSteal(issueId, originalClaimant, reason);
} catch (e) {
if (e instanceof Error && /already been resolved/.test(e.message)) {
return; // idempotent: already decided
}
throw e;
} Prevention
- Track resolved contest ids and skip duplicate requests.
- Consume StealContestResolved events to invalidate pending contest UI/actions.
- Add an idempotency key tied to the steal event id on contest requests.
When it happens
Trigger: Calling contestSteal after resolveContest already ran; a retry loop re-firing the contest after resolution; two contest paths racing where one resolved first.
Common situations: At-least-once delivery redelivers the StealStolen event after a coordinator already resolved; UI 'contest' button enabled after resolution; missing idempotency key on the contest request.
Related errors
- No steal to contest - issue was not recently stolen
- Contest window has expired
- Only the original claimant can contest the steal
- Claim not found for issue: ${issueId}
- Claim is still in grace period
AI-assisted analysis of ruvnet/ruflo@6b01dc5a68 (2026-08-12).
Data as JSON: /api/errors/cd8bea9fe819c0d7.
Report an issue: GitHub.