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

  1. Check claim.contestInfo.resolution before calling; if set, treat as already-decided.
  2. De-duplicate contest requests with an idempotency key tied to the steal event id.
  3. Disable the contest action in the UI once a resolution is observed.
  4. 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

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


AI-assisted analysis of ruvnet/ruflo@6b01dc5a68 (2026-08-12). Data as JSON: /api/errors/cd8bea9fe819c0d7. Report an issue: GitHub.