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
- Retry the launch — contention over a 10s-stale lock resolves itself within seconds
- Stagger daemon worker schedules (cron jitter) so budget checks do not all fire in the same instant
- If it persists, verify no process holds the lock (`lsof ~/.claude-flow/ai-budget.lock`), then delete it once the holder is gone
- 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
- Stagger daemon worker schedules across worktrees so budget checks don't align
- Remember the lock is user-global: N daemons contend for ONE file
- Keep launches of `claude --print` bundled rather than fanned out simultaneously
- A stale lock self-heals after 10s — prefer waiting over manual deletion
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
- Timeouts: ETIMEDOUT, deadlines, and hung requests — what actually expires when a request times out.
Related errors
- timed out acquiring flywheel attempts lock
- timed out acquiring flywheel transaction lock
- timed out acquiring repo-supervisor lock
- timed out acquiring workspace-lease lock
- Another Ruflo/MetaHarness installer still owns the…
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)