ruvnet/ruflo · error · Error

No steal to contest - issue was not recently stolen

Error message

No steal to contest - issue was not recently stolen

What it means

Plain Error thrown by contestSteal when claim.contestInfo is undefined. contestInfo is only populated by the steal() operation; if it is absent, the issue was never stolen (it may have been handed off normally, or is still owned by its original claimant), so there is no contest window to open.

Source

Thrown at v3/@claude-flow/claims/src/application/work-stealing-service.ts:367

  }

  // ===========================================================================
  // Contest Steal
  // ===========================================================================

  /**
   * 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

View on GitHub (pinned to 6b01dc5a68)

Solutions

  1. Inspect the claim (findByIssueId/getStealable) and confirm contestInfo is present before contesting.
  2. If the transfer was a handoff, use the handoff accept/reject APIs instead of the contest API.
  3. Drop stale or misrouted notifications rather than contesting a non-stolen claim.
  4. Disambiguate IssueStolen events from HandoffRequested events in your event handler.

Example fix

// before
await workStealing.contestSteal(issueId, originalClaimant, reason); // not stolen -> 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) {
  throw new Error('Cannot contest: issue was not stolen');
}
await workStealing.contestSteal(issueId, originalClaimant, reason);

Type guard

function hasOpenContest(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 && /not recently stolen/.test(e.message)) {
    // route to handoff accept/reject instead, or drop
    return;
  }
  throw e;
}

Prevention

When it happens

Trigger: Calling contestSteal after a normal handoff rather than a steal; on an issue that was never stolen; after contestInfo was cleared following resolution; confusing a HandoffRequested event with an IssueStolen event.

Common situations: Event routing bug delivering handoff notifications to the steal-contest handler; replay of an old notification; caller assumes any ownership change is a steal.

Related errors


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