ruvnet/ruflo · warning · Error

Contest window has expired

Error message

Contest window has expired

What it means

Plain Error thrown by contestSteal when nowMs() exceeds claim.contestInfo.windowEndsAt. The contest window (config.contestWindowMinutes in DEFAULT_WORK_STEALING_CONFIG) starts at steal time; once it elapses, the steal is final and cannot be contested. This is time-based and not retryable, since the window only moves further into the past.

Source

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

    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);

    // Emit contest event
    await this.emitContestEvent(claim);
  }

  // ===========================================================================

View on GitHub (pinned to 6b01dc5a68)

Solutions

  1. Increase config.contestWindowMinutes to fit your review latency.
  2. Process IssueStolen events with higher priority so contests are filed within the window.
  3. Pre-check the window before calling: compare nowMs() to contestInfo.windowEndsAt and skip if expired.
  4. If expired, accept the steal as final or escalate to a manual override (resolveContest by a coordinator, if still permitted).

Example fix

// before
await workStealing.contestSteal(issueId, originalClaimant, reason); // window passed -> throws

// after (config)
const workStealing = new WorkStealingService(repo, bus, { contestWindowMinutes: 120 });
// after (pre-check)
const claim = await repo.findByIssueId(issueId);
if (claim?.contestInfo && Date.now() <= claim.contestInfo.windowEndsAt.getTime()) {
  await workStealing.contestSteal(issueId, originalClaimant, reason);
}
Defensive patterns

Strategy: validation

Validate before calling

const claim = await repository.findByIssueId(issueId);
const windowEnd = claim?.contestInfo?.windowEndsAt;
if (!windowEnd || Date.now() > windowEnd.getTime()) {
  throw new Error('Contest window expired; steal is final');
}
await workStealing.contestSteal(issueId, originalClaimant, reason);

Try / catch

try {
  await workStealing.contestSteal(issueId, originalClaimant, reason);
} catch (e) {
  if (e instanceof Error && /window has expired/.test(e.message)) {
    // not retryable: escalate or accept the steal as final
    return escalateExpiredContest(issueId);
  }
  throw e;
}

Prevention

When it happens

Trigger: Contesting long after the steal occurred; processing delay (queue backlog, slow human review) longer than the contest window; contestWindowMinutes misconfigured to a value too short for your workflow; clock skew between an HLC and wall clock.

Common situations: Default contest window too short for human-in-the-loop review; notifications queued behind a long backlog; HLC physical clock drifted relative to the wall clock used to compute windowEndsAt.

Related errors


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