ruvnet/ruflo · error · Error

timed out acquiring flywheel attempts lock

Error message

timed out acquiring flywheel attempts lock

What it means

The flywheel harness serializes generation-attempt accounting with the same O_EXCL + stale-mtime lock protocol used by the transaction service (`withAttemptsLock` in harness-flywheel-generations.ts). If the attempts lock exists, is younger than ATTEMPTS_LOCK_STALE_MS, and the caller's deadline expires while polling every 5ms, this error surfaces — protecting the attempt counters from concurrent mutation.

Solutions

  1. Serialize flywheel generation commands per checkout and retry after the current run finishes
  2. Check for and terminate stray claude-flow processes, then let the stale window elapse or delete the attempts lock file in the flywheel state directory
  3. Give parallel jobs separate workspaces instead of a shared checkout

Example fix

# before: two jobs race the same checkout in CI
- run: npx ruflo flywheel promote & npx ruflo flywheel eval &

# after: serialized
- run: npx ruflo flywheel promote && npx ruflo flywheel eval
Defensive patterns

Strategy: retry

Validate before calling

// Best-effort: detect contention before invoking harness generation commands.
import { existsSync, lstatSync, readdirSync } from 'node:fs';
import { join } from 'node:path';

function flywheelLocksPresent(root: string): string[] {
  const dir = join(root, '.claude-flow');
  return existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.lock')) : [];
}

Try / catch

let done = false;
for (let attempt = 0; attempt < 3 && !done; attempt++) {
  try {
    await runFlywheelGeneration(root);
    done = true;
  } catch (e) {
    if (e?.message !== 'timed out acquiring flywheel attempts lock') throw e;
    await new Promise((r) => setTimeout(r, 15_000));
  }
}
if (!done) throw new Error('flywheel attempts lock remained contended after retries');

Prevention

When it happens

Trigger: Two harness/flywheel commands mutating generation attempts concurrently in one checkout (e.g. a promotion run racing a status or eval command), or a stale-but-not-yet-old lock left by a crashed run.

Common situations: Parallel CI jobs or scripts invoking flywheel generation commands on the same workspace; a previously killed run leaving a fresh lock behind; an interactive session plus an automated timer both triggering the harness.

Understand the failure class

Related errors


AI-assisted analysis of ruvnet/ruflo@fa13ee4ad6 (2026-08-18). Data as JSON: /api/errors/a8902e3f031ccb8a. Report an issue: GitHub.

Appendix: source

Thrown at v3/@claude-flow/cli/src/services/harness-flywheel-generations.ts:130

    try {
      const fd = fs.openSync(lock, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
      fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }), 'utf-8');
      fs.closeSync(fd);
      try {
        return fn();
      } finally {
        try { fs.unlinkSync(lock); } catch { /* lock already gone */ }
      }
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
      try {
        const stat = fs.lstatSync(lock);
        if (Date.now() - stat.mtimeMs > ATTEMPTS_LOCK_STALE_MS) {
          fs.unlinkSync(lock);
          continue;
        }
      } catch { /* raced with owner */ }
      if (Date.now() >= deadline) throw new Error('timed out acquiring flywheel attempts lock');
      await delay(5);
    }
  }
}

/** The current operating champion (last promotion's config), or defaults. */
export function currentChampion(root: string): { config: Record<string, number>; hash: string | null; generation: number } {
  const p = loadPromotions(root);
  if (!p.length) return { config: { ...DEFAULT_CONFIG }, hash: null, generation: 0 };
  const last = p[p.length - 1];
  return { config: (last.candidateManifest.policy.value ?? { ...DEFAULT_CONFIG }) as Record<string, number>, hash: last.candidateManifestHash, generation: p.length };
}

export interface ServedState { championHash: string | null; config: Record<string, number> | null; servedAt: number | null; fromGeneration: number | null; }
export function servedChampion(root: string): ServedState {
  return readJson<ServedState>(path.join(dir(root), SERVED_FILE)) ?? { championHash: null, config: null, servedAt: null, fromGeneration: null };
}

View on GitHub (pinned to fa13ee4ad6)