Skip to content

Instantly share code, notes, and snippets.

@ruvnet
Created July 14, 2026 04:46
Show Gist options
  • Select an option

  • Save ruvnet/f4cda824aaf58e1f2dae72368e692220 to your computer and use it in GitHub Desktop.

Select an option

Save ruvnet/f4cda824aaf58e1f2dae72368e692220 to your computer and use it in GitHub Desktop.
The worktree-daemon flywheel: how ruflo accidentally invented a Claude-quota DoS against itself (#2661)

The worktree-daemon flywheel: how ruflo accidentally invented a Claude-quota DoS against itself

Issue: ruvnet/ruflo#2661 (P0) Fix: ruvnet/ruflo#2662, shipped starting in @claude-flow/cli@3.27.0

The bug, in one sentence

Every Git worktree of a ruflo project started its own autonomous daemon, each daemon independently scheduled three Claude-powered background workers, and nothing coordinated across worktrees — so the number of autonomous Claude launches per hour scaled linearly with worktree count, with no ceiling.

How it happened

None of the individual pieces were new bugs. Each had already been fixed in isolation:

  • #1117 — orphaned Claude children after worker timeout
  • #1914 — one workspace's daemon no longer kills another's
  • #2407 — concurrent duplicate daemon starts inside one workspace
  • #2484 — atomic same-workspace deduplication

Every one of those fixes was worktree-scoped — correct in isolation, but built on an identity model where the dedup key was the worktree's filesystem path, never the repository. A recent submodule split encouraged people to use Git worktrees for milestone isolation, multiplying the worktree count per project. That was the trigger, not the defect — it just exposed a lifecycle/scheduling flaw that had been latent all along.

The cardinality math

Each daemon's default schedule:

Worker Interval
audit every 10 min
optimize every 15 min
testgaps every 20 min
Claude launches per hour per daemon = 60/10 + 60/15 + 60/20 = 6 + 4 + 3 = 13
Fleet launches per hour = 13 × active worktrees
Active worktrees Launches/hour Launches/12h TTL
1 13 156
4 52 624
8 104 1,248
12 156 1,872

And the trigger for all of it was just... claude being present on PATH. HeadlessWorkerExecutor auto-enabled itself on a successful claude --version probe — no separate consent gate, no budget, no cross-daemon awareness. A developer running a handful of parallel worktrees for legitimate isolation could burn through their interactive Claude quota without ever knowingly opting into background model usage.

The fix

Shipped in two phases on the same PR.

Phase 1 — containment

1. AI workers are opt-in now. DaemonConfig.aiWorkersEnabled defaults to false. Without explicit consent, the daemon never even probes claude --version — every worker runs its $0 local path. A default install now produces zero autonomous Claude launches, regardless of worktree count. Consent, in precedence order:

daemon start --headless          # explicit flag
daemon.aiWorkers.enabled: true   # project config
RUFLO_DAEMON_AI_WORKERS=1        # env var

Consent is deliberately not restored from a stale daemon-state.json, and the worker-execution path re-checks the gate independently (defense in depth, not just a startup check).

2. --headless became a real gate. It existed before this fix — it was forwarded to the forked child process and then silently ignored. Now it actually threads into DaemonConfig.aiWorkersEnabled on both the foreground and background paths.

3. A user-global launch budget, enforced before any process spawns:

const permit = await globalAiBudget.reserve({ repositoryId, worktreeRoot, workerType, model, estimatedTokens });
if (!permit.allowed) { emitSkippedReceipt(permit.reason); return; }

Defaults (exactly what the issue asked for):

maxConcurrentGlobal: 1
maxLaunchesPerHour: 2
maxLaunchesPerDay: 12
pauseOnQuotaErrorMinutes: 60

The ledger lives under ~/.claude-flow/, owner-only permissions, symlinks rejected, atomic reservation with stale-lock takeover. A 429 / rate-limit / usage-exhaustion failure response opens a global circuit breaker — every daemon on the machine stops launching for the cooldown window, not just the one that hit the limit. Denials return an error result immediately (local fallback) instead of queueing, so there's no retry storm hammering the budget. The whole thing fails closed: if the ledger itself errors, nothing launches.

4. ruflo daemon stop --all. Stops every ruflo daemon across every worktree on the machine — identified only by self-identifying argv patterns, so interactive Claude sessions are never candidates for termination. SIGTERM, 2-second grace period, SIGKILL fallback, POSIX process-group signaling so orphaned children actually die too.

5. Visibility. daemon start now warns when it detects a multi-daemon fleet. daemon status --all gained a budget panel (launches this hour/24h, active children, circuit-breaker state) and per-workspace launch attribution, so if the shared budget is getting consumed, you can see by which worktree.

Phase 2 — repository identity and cross-worktree dedup

6. Real repository identity, separated from worktree identity:

interface GitWorkspaceIdentity {
  worktreeRoot: string;   // git rev-parse --show-toplevel — per worktree
  commonGitDir: string;   // git rev-parse --git-common-dir — shared by ALL worktrees
  repositoryId: string;   // sha256(canonical commonGitDir) — stable repo key
  head: string;
}

Two worktrees of the same repository now resolve to the same repositoryId. This is the piece the original worktree-scoped fixes structurally couldn't provide, because they never distinguished "this worktree" from "this repository."

7. Cross-worktree job dedup. Before any launch:

jobKey = sha256(repositoryId, HEAD, workerType, configHash)

If the same job already succeeded within its freshness window, skip. Ten worktrees checked out at the same commit now run each analysis exactly once, not ten times. This sits underneath the budget as an optimization — the budget reservation remains the hard invariant even if two daemons race the same key and both attempt a launch.

8. Worktree-removal self-shutdown. The daemon's lifecycle monitor previously only ran when a TTL or idle timeout was configured — meaning it could be skipped entirely. It now always runs: a daemon whose workspace directory has been deleted (worktree removed) shuts itself down within one 60-second check instead of continuing to schedule jobs against a tree that no longer exists.

Validation

41 new tests across daemon-ai-workers-optin-2661, global-ai-budget, git-workspace-identity, and ai-job-dedup suites. 227 daemon/headless/services tests green. Runtime-smoked on a host with claude actually on PATH — the exact precondition that triggers the fanout in the first place.

What's still open

This fixes the invariants that make the P0 a P0 — zero autonomous launches by default, a hard global concurrency/rate ceiling, cross-worktree dedup, and a quota circuit breaker that never touches interactive sessions. It does not yet implement the full architecture the issue originally sketched:

  • No single elected repository-level supervisor with worktree leases (15-minute heartbeat expiry) — coordination currently happens via the shared budget ledger + job dedup, not one process owning the schedule.
  • No structured per-launch token telemetry — receipts log operational metadata (timestamp, launch/denial/pause reason) but not input/output token counts, since claude --print isn't invoked in a mode that reliably exposes usage.
  • No dedicated daemon budget show/pause/resume subcommands — budget state is visible via daemon status --all but not independently controllable yet.
  • No one-time upgrade migration warning for pre-existing multi-daemon fleets.

Full detail in the issue thread and PR #2662.

Where the code lives

  • v3/@claude-flow/cli/src/services/git-workspace-identity.ts — repository identity
  • v3/@claude-flow/cli/src/services/ai-job-dedup.ts — cross-worktree job dedup
  • v3/@claude-flow/cli/src/services/global-ai-budget.ts — the global launch budget + circuit breaker
  • v3/@claude-flow/cli/src/services/headless-worker-executor.ts — where the budget reservation gates the actual spawn('claude', ...) call
  • v3/@claude-flow/cli/src/commands/daemon.tsstop --all, status --all budget panel, --headless gate
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment