Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/watch-circuit-breaker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@bradygaster/squad-cli": minor
---

feat(watch): circuit breaker integration for rate limit protection (#515)

Adds GitHub API rate limit protection to Ralph's watch command via
the Predictive Circuit Breaker from PR #518.

**What changed (additive patch — existing flow untouched):**
- `gh-cli.ts`: Added `ghRateLimitCheck()` and `isRateLimitError()` helpers
- `watch.ts`: Added `CircuitBreakerState` type + persistence helpers
- `watch.ts`: Added `executeRound()` wrapper that gates the existing
`runCheck()` call through pre-flight quota checks
- `watch.ts`: Added `roundInProgress` flag to prevent overlapping rounds

**Circuit breaker state machine:**
- CLOSED → OPEN: When traffic light is red or predictor says exhaustion imminent
- OPEN → HALF-OPEN: After cooldown expires (exponential: 2m → 4m → 8m → ... → 30m cap)
- HALF-OPEN → CLOSED: After 2 consecutive successful rounds
- HALF-OPEN → OPEN: On rate limit error during probe

State persists to `.squad/ralph-circuit-breaker.json` across restarts.

**Tests:** 16 new tests covering state transitions, race condition guard,
predictive integration, and `isRateLimitError` detection.
171 changes: 148 additions & 23 deletions packages/squad-cli/src/cli/commands/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ import {
} from '@bradygaster/squad-sdk/ralph/triage';
import { RalphMonitor } from '@bradygaster/squad-sdk/ralph';
import { EventBus } from '@bradygaster/squad-sdk/runtime/event-bus';
import { ghAvailable, ghAuthenticated, ghIssueList, ghIssueEdit, ghPrList, type GhIssue, type GhPullRequest } from '../core/gh-cli.js';
import { ghAvailable, ghAuthenticated, ghIssueList, ghIssueEdit, ghPrList, ghRateLimitCheck, isRateLimitError, type GhIssue, type GhPullRequest } from '../core/gh-cli.js';
import type { MachineCapabilities } from '@bradygaster/squad-sdk/ralph/capabilities';
import {
PredictiveCircuitBreaker,
getTrafficLight,
} from '@bradygaster/squad-sdk/ralph/rate-limiting';

export interface BoardState {
untriaged: number;
Expand Down Expand Up @@ -243,6 +247,47 @@ async function runCheck(
}
}

// ── Circuit Breaker State (#515) ─────────────────────────────────
// Persisted to .squad/ralph-circuit-breaker.json across restarts.

interface CircuitBreakerState {
status: 'closed' | 'open' | 'half-open';
openedAt: string | null;
cooldownMinutes: number;
consecutiveFailures: number;
consecutiveSuccesses: number;
lastRateLimitRemaining: number | null;
lastRateLimitTotal: number | null;
}

function defaultCBState(): CircuitBreakerState {
return {
status: 'closed',
openedAt: null,
cooldownMinutes: 2,
consecutiveFailures: 0,
consecutiveSuccesses: 0,
lastRateLimitRemaining: null,
lastRateLimitTotal: null,
};
}

function loadCBState(squadDir: string): CircuitBreakerState {
const filePath = path.join(squadDir, 'ralph-circuit-breaker.json');
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch {
return defaultCBState();
}
}

function saveCBState(squadDir: string, state: CircuitBreakerState): void {
fs.writeFileSync(
path.join(squadDir, 'ralph-circuit-breaker.json'),
JSON.stringify(state, null, 2),
);
}

/**
* Run watch command — Ralph's local polling process
*/
Expand Down Expand Up @@ -314,35 +359,114 @@ export async function runWatch(dest: string, intervalMinutes: number): Promise<v
console.log(`\n${BOLD}🔄 Ralph — Watch Mode${RESET}`);
console.log(`${DIM}Polling every ${intervalMinutes} minute(s) for squad work. Ctrl+C to stop.${RESET}\n`);

// Initialize circuit breaker (#515)
const circuitBreaker = new PredictiveCircuitBreaker();
let cbState = loadCBState(squadDirInfo.path);
let round = 0;
let roundInProgress = false;

/**
* Gate a round through the circuit breaker, then delegate to the
* existing runCheck + reportBoard flow. This wrapper is the ONLY
* new control flow — everything inside is unchanged.
*/
async function executeRound(): Promise<void> {
const ts = new Date().toLocaleTimeString();

// Check if circuit is open and cooldown hasn't elapsed
if (cbState.status === 'open') {
const elapsed = Date.now() - new Date(cbState.openedAt!).getTime();
if (elapsed < cbState.cooldownMinutes * 60_000) {
const left = Math.ceil((cbState.cooldownMinutes * 60_000 - elapsed) / 1000);
console.log(`${YELLOW}⏸${RESET} [${ts}] Circuit open — cooling down (${left}s left)`);
return;
}
cbState.status = 'half-open';
console.log(`${DIM}[${ts}] Circuit half-open — probing...${RESET}`);
saveCBState(squadDirInfo.path, cbState);
}

// Pre-flight: sample rate limit headers
try {
const rl = await ghRateLimitCheck();
cbState.lastRateLimitRemaining = rl.remaining;
cbState.lastRateLimitTotal = rl.limit;
circuitBreaker.addSample(rl.remaining, rl.limit);

const light = getTrafficLight(rl.remaining, rl.limit);
if (light === 'red' || circuitBreaker.shouldOpen()) {
cbState.status = 'open';
cbState.openedAt = new Date().toISOString();
cbState.consecutiveFailures++;
cbState.consecutiveSuccesses = 0;
cbState.cooldownMinutes = Math.min(cbState.cooldownMinutes * 2, 30);
saveCBState(squadDirInfo.path, cbState);
console.log(`${RED}🛑${RESET} [${ts}] Circuit opened — quota ${light === 'red' ? 'critical' : 'predicted low'} (${rl.remaining}/${rl.limit})`);
return;
}
if (light === 'amber') {
console.log(`${YELLOW}⚠️${RESET} [${ts}] Quota amber (${rl.remaining}/${rl.limit}) — proceeding cautiously`);
}
} catch {
// Rate limit check failed — proceed anyway, runCheck has its own catch
}

// ── Delegate to existing check cycle (untouched) ────────────
round++;
const roundState = await runCheck(rules, modules, roster, hasCopilot, autoAssign, capabilities);
await eventBus.emit({
type: 'agent:milestone',
sessionId: monitorSessionId,
agentName: 'Ralph',
payload: { milestone: `Completed watch round ${round}`, task: 'watch cycle' },
timestamp: new Date(),
});
await monitor.healthCheck();
reportBoard(roundState, round);

// Post-round: update circuit breaker on success
if (cbState.status === 'half-open') {
cbState.consecutiveSuccesses++;
if (cbState.consecutiveSuccesses >= 2) {
cbState.status = 'closed';
cbState.cooldownMinutes = 2;
cbState.consecutiveFailures = 0;
console.log(`${GREEN}✓${RESET} [${new Date().toLocaleTimeString()}] Circuit closed — quota recovered`);
}
} else {
cbState.consecutiveSuccesses = 0;
cbState.consecutiveFailures = 0;
}
saveCBState(squadDirInfo.path, cbState);
}

// Run immediately, then on interval
round++;
const state = await runCheck(rules, modules, roster, hasCopilot, autoAssign, capabilities);
await eventBus.emit({
type: 'agent:milestone',
sessionId: monitorSessionId,
agentName: 'Ralph',
payload: { milestone: `Completed watch round ${round}`, task: 'watch cycle' },
timestamp: new Date(),
});
await monitor.healthCheck();
reportBoard(state, round);
await executeRound();

return new Promise<void>((resolve) => {
const intervalId = setInterval(
async () => {
round++;
const roundState = await runCheck(rules, modules, roster, hasCopilot, autoAssign, capabilities);
await eventBus.emit({
type: 'agent:milestone',
sessionId: monitorSessionId,
agentName: 'Ralph',
payload: { milestone: `Completed watch round ${round}`, task: 'watch cycle' },
timestamp: new Date(),
});
await monitor.healthCheck();
reportBoard(roundState, round);
// Prevent overlapping rounds when a previous one is still running
if (roundInProgress) return;
roundInProgress = true;
try {
await executeRound();
} catch (e) {
const err = e as Error;
if (isRateLimitError(err)) {
cbState.status = 'open';
cbState.openedAt = new Date().toISOString();
cbState.consecutiveFailures++;
cbState.consecutiveSuccesses = 0;
cbState.cooldownMinutes = Math.min(cbState.cooldownMinutes * 2, 30);
saveCBState(squadDirInfo.path, cbState);
console.log(`${RED}🛑${RESET} Rate limited — circuit opened, cooldown ${cbState.cooldownMinutes}m`);
} else {
console.error(`${RED}✗${RESET} Round error: ${err.message}`);
}
} finally {
roundInProgress = false;
}
},
intervalMinutes * 60 * 1000
);
Expand All @@ -363,6 +487,7 @@ export async function runWatch(dest: string, intervalMinutes: number): Promise<v
timestamp: new Date(),
});
await monitor.stop();
saveCBState(squadDirInfo.path, cbState);
console.log(`\n${DIM}🔄 Ralph — Watch stopped${RESET}`);
resolve();
};
Expand Down
34 changes: 34 additions & 0 deletions packages/squad-cli/src/cli/core/gh-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,37 @@ export async function ghIssueEdit(issueNumber: number, options: GhEditOptions):

await execFileAsync('gh', args);
}

// ── Rate limit helpers (#515) ──────────────────────────────────

export interface GhRateLimit {
remaining: number;
limit: number;
resetAt: string;
}

/**
* Check current GitHub API rate limit via `gh api rate_limit`.
*/
export async function ghRateLimitCheck(): Promise<GhRateLimit> {
const { stdout } = await execFileAsync('gh', [
'api', 'rate_limit', '--jq', '.resources.core | {remaining, limit, reset}',
]);
const data = JSON.parse(stdout);
return {
remaining: data.remaining,
limit: data.limit,
resetAt: new Date(data.reset * 1000).toISOString(),
};
}

/**
* Detect if an error is a GitHub 429 rate limit error.
*/
export function isRateLimitError(err: unknown): boolean {
if (err instanceof Error) {
const msg = err.message.toLowerCase();
return msg.includes('rate limit') || msg.includes('secondary rate') || msg.includes('403');
}
return false;
}
2 changes: 1 addition & 1 deletion packages/squad-cli/src/cli/core/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ export function ensureGitattributes(dest: string): string[] {
try {
fs.writeFileSync(filePath, content + suffix + added.join('\n') + '\n');
} catch (err: unknown) {
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EPERM') {
if (err instanceof Error && 'code' in err && ['EPERM', 'EACCES'].includes((err as NodeJS.ErrnoException).code ?? '')) {
warn('Could not update .gitattributes (read-only). Add merge=union entries manually.');
return [];
}
Expand Down
Loading
Loading