ruvnet/ruflo · error · Error

timed out acquiring ai-budget lock

Error message

timed out acquiring ai-budget lock

What it means

GlobalAiBudget serializes ledger mutations with an O_EXCL lock (`~/.claude-flow/ai-budget.lock`); a lock older than 10s (LOCK_STALE_MS) is considered abandoned and taken over. Because the budget is user-global, every daemon on the machine contends for this single lock — when a fresh lock persists past the acquire deadline (25ms poll loop), this timeout is thrown.

Solutions

  1. Retry the launch — contention over a 10s-stale lock resolves itself within seconds
  2. Stagger daemon worker schedules (cron jitter) so budget checks do not all fire in the same instant
  3. If it persists, verify no process holds the lock (`lsof ~/.claude-flow/ai-budget.lock`), then delete it once the holder is gone
  4. Reduce the number of concurrently scheduled AI workers per user

Example fix

// before: single attempt during a burst of daemon launches
const permit = await budget.acquire(request);

// after: short backoff retry, since the lock is machine-global and short-lived
for (let i = 0; i < 5; i++) {
  try { permit = await budget.acquire(request); break; }
  catch (e) {
    if (e?.message !== 'timed out acquiring ai-budget lock') throw e;
    await new Promise((r) => setTimeout(r, 2000 * (i + 1)));
  }
}
Defensive patterns

Strategy: retry

Validate before calling

import { existsSync, lstatSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';

const LOCK_STALE_MS = 10_000;

function aiBudgetLockStatus(): 'free' | 'stale' | 'held' {
  const lock = join(homedir(), '.claude-flow', 'ai-budget.lock');
  if (!existsSync(lock)) return 'free';
  return Date.now() - lstatSync(lock).mtimeMs > LOCK_STALE_MS ? 'stale' : 'held';
}

Try / catch

for (let attempt = 1; attempt <= 5; attempt++) {
  try {
    permit = await budget.acquire(request);
    break;
  } catch (e) {
    if (e?.message !== 'timed out acquiring ai-budget lock') throw e;
    // lock is machine-global but short-lived; back off and retry
    await new Promise((r) => setTimeout(r, attempt * 2_000));
  }
}

Prevention

When it happens

Trigger: Many ruflo daemons/worktrees requesting AI launches at nearly the same moment (the lock is user-wide, not per-workspace), or a process that crashed immediately after creating the lock, leaving it younger than the 10s staleness window.

Common situations: Several worktree daemons whose worker schedules align; a burst of parallel `claude --print` launches; a machine recovering from a crash with leftover locks from dead processes.

Understand the failure class

Related errors


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

Appendix: source

Thrown at v3/@claude-flow/cli/src/services/global-ai-budget.ts:396

      try {
        const fd = fs.openSync(this.lockFile, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
        fs.writeSync(fd, String(process.pid));
        fs.closeSync(fd);
        return () => {
          try { fs.unlinkSync(this.lockFile); } catch { /* already gone */ }
        };
      } catch (e) {
        if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
        // Stale lock from a crashed process — take over.
        try {
          const st = fs.lstatSync(this.lockFile);
          if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
            fs.unlinkSync(this.lockFile);
            continue;
          }
        } catch { /* raced — retry */ }
        if (Date.now() > deadline) {
          throw new Error('timed out acquiring ai-budget lock');
        }
        await delay(25);
      }
    }
  }

  /** Read + prune the ledger. Caller must hold the lock for read-modify-write. */
  private readLedger(now: number): Ledger {
    assertNotSymlink(this.ledgerFile);
    let ledger: Ledger = { version: 1, launches: [], active: [] };
    if (fs.existsSync(this.ledgerFile)) {
      try {
        const raw = JSON.parse(fs.readFileSync(this.ledgerFile, 'utf-8'));
        if (raw && typeof raw === 'object') {
          ledger = {
            version: 1,
            launches: Array.isArray(raw.launches) ? raw.launches.filter((l: LaunchRecord) => typeof l?.at === 'number') : [],
            active: Array.isArray(raw.active) ? raw.active.filter((a: ActiveRecord) => typeof a?.at === 'number') : [],

View on GitHub (pinned to fa13ee4ad6)