From 119c846876db2bd684316a0ffe37430c8116a797 Mon Sep 17 00:00:00 2001 From: Jorge Vidaurre Date: Wed, 18 Mar 2026 07:41:04 -0300 Subject: [PATCH] feat: consolidate daemon/autonomous into `squads run` (#587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three execution systems → one command, two modes: - Delete daemon.ts (488 lines, dead code — never registered in CLI) - Delete autonomous.ts (796 lines, cron scheduler) - Merge cron routines + outcome tracking + PR reactions + daemon lifecycle into run-modes.ts runAutopilot() - Extract routine parsing + cooldowns to cron.ts - Add --stop/--status/--pause/--resume flags to `squads run` - Deprecate `squads autonomous` with migration notice Security fixes: - Remove hardcoded telemetry endpoint URL from telemetry.ts - Add scrub-secrets.sh for git history cleanup (BFG) Testing: - Add Docker E2E test (scripts/e2e-docker.sh) + CI job - Add test/commands/run-daemon.test.ts (9 tests) - 86 files, 1717 tests — all passing Net: -953 lines (deleted ~1740, added ~790) Co-Authored-By: Claude --- .github/workflows/ci.yml | 19 + package.json | 1 + scripts/e2e-docker.sh | 92 ++++ scripts/scrub-secrets.sh | 97 ++++ src/cli.ts | 57 ++- src/commands/autonomous.ts | 796 ------------------------------- src/commands/daemon.ts | 488 ------------------- src/commands/run.ts | 46 +- src/lib/cron.ts | 131 ++++- src/lib/run-modes.ts | 566 +++++++++++++++++++++- src/lib/run-types.ts | 4 + src/lib/telemetry.ts | 9 +- test/commands/autonomous.test.ts | 193 -------- test/commands/daemon.test.ts | 247 ---------- test/commands/run-daemon.test.ts | 161 +++++++ test/e2e/Dockerfile.first-run | 8 +- 16 files changed, 1163 insertions(+), 1752 deletions(-) create mode 100755 scripts/e2e-docker.sh create mode 100755 scripts/scrub-secrets.sh delete mode 100644 src/commands/autonomous.ts delete mode 100644 src/commands/daemon.ts delete mode 100644 test/commands/autonomous.test.ts delete mode 100644 test/commands/daemon.test.ts create mode 100644 test/commands/run-daemon.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 224d548..5029824 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -180,3 +180,22 @@ jobs: - name: Run npm-install smoke test run: bash scripts/e2e-smoke.sh + + docker-e2e: + name: Docker E2E (clean env gate) + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run Docker E2E test + run: bash scripts/e2e-docker.sh diff --git a/package.json b/package.json index dddb599..5242b6a 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "test:docker": "bash scripts/e2e-docker.sh", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/scripts/e2e-docker.sh b/scripts/e2e-docker.sh new file mode 100755 index 0000000..65b51e1 --- /dev/null +++ b/scripts/e2e-docker.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# E2E Docker test: builds a clean Ubuntu container, installs squads-cli from +# tarball (like a real user), and runs smoke tests inside it. +# +# Catches issues that local tests miss: +# - Missing files in npm package +# - Broken bin entry / shebang +# - Node version incompatibilities +# - Permission issues (non-root user) +# - Missing runtime dependencies +# +# Usage: bash scripts/e2e-docker.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DOCKERFILE="$REPO_ROOT/test/e2e/Dockerfile.first-run" +IMAGE_NAME="squads-cli-e2e" + +echo "▶ Building package..." +cd "$REPO_ROOT" +npm run build + +echo "▶ Packing tarball..." +TARBALL=$(npm pack --quiet) +TARBALL_PATH="$REPO_ROOT/$TARBALL" + +cleanup() { + echo "▶ Cleaning up..." + rm -f "$TARBALL_PATH" + docker rmi "$IMAGE_NAME" 2>/dev/null || true +} +trap cleanup EXIT + +echo "▶ Building Docker image (clean Ubuntu + squads-cli from tarball)..." +docker build \ + -f "$DOCKERFILE" \ + --build-arg "TARBALL=$TARBALL" \ + -t "$IMAGE_NAME" \ + "$REPO_ROOT" + +echo "▶ Running tests inside container..." +docker run --rm "$IMAGE_NAME" bash -c ' +set -euo pipefail + +step() { echo ""; echo "=== STEP: $1 ==="; } + +step "squads --version" +squads --version + +step "squads --help" +squads --help | head -20 + +step "squads init --yes --force" +mkdir -p /tmp/test-project && cd /tmp/test-project +git init -q && git commit --allow-empty -q -m "init" +squads init --yes --force + +step "squads status" +squads status + +step "squads run --status (daemon status)" +squads run --status + +step "squads run --dry-run --once (autopilot preview)" +squads run --dry-run --once || true + +step "squads run company --dry-run (single squad preview)" +squads run company --dry-run || true + +step "squads run --pause \"e2e test\"" +squads run --pause "e2e test" + +step "squads run --status (should show paused)" +squads run --status + +step "squads run --resume" +squads run --resume + +step "squads run --status (should show not running)" +squads run --status + +step "squads autonomous status (deprecated alias)" +squads autonomous status || true + +step "squads doctor" +squads doctor || true + +echo "" +echo "✅ All Docker E2E tests passed" +' diff --git a/scripts/scrub-secrets.sh b/scripts/scrub-secrets.sh new file mode 100755 index 0000000..cf311b0 --- /dev/null +++ b/scripts/scrub-secrets.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────── +# Git history secret scrub for squads-cli (PUBLIC REPO) +# +# Removes leaked secrets from git history using git-filter-repo. +# This rewrites history — all collaborators must re-clone after. +# +# Prerequisites: +# brew install git-filter-repo (or pip install git-filter-repo) +# +# What gets scrubbed: +# 1. Telemetry API key (base64-encoded) +# 2. Telemetry endpoint URL (base64-encoded) +# 3. Local DB credentials (two connection strings) +# +# Usage: +# 1. Review this script +# 2. Run: bash scripts/scrub-secrets.sh +# 3. Verify: git log -p --all -S '' should return nothing +# 4. Force push: git push --force --all && git push --force --tags +# ───────────────────────────────────────────────────────────────────── + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +# Preflight +if ! command -v git-filter-repo &>/dev/null; then + echo "ERROR: git-filter-repo not found" + echo "Install: brew install git-filter-repo" + exit 1 +fi + +# Safety: must be on a clean working tree +if [ -n "$(git status --porcelain)" ]; then + echo "ERROR: Working tree not clean. Commit or stash changes first." + exit 1 +fi + +echo "=== Strings to scrub from git history ===" +echo "" +echo "1. Telemetry API key (base64): c3FfdGVsX3YxXzdmOGE5YjJjM2Q0ZTVmNmE=" +echo "2. Telemetry endpoint (base64): aHR0cHM6Ly9zcXVhZHMtdGVsZW1ldHJ5LTk3ODg3MTgxNzYxMC51cy1jZW50cmFsMS5ydW4uYXBwL3Bpbmc=" +echo "3. DB credential 1: postgresql://user:password@localhost:5432/squads" +echo "4. DB credential 2: postgresql://squads:squads_local_dev@localhost:5433/squads" +echo "" +echo "This will REWRITE git history. All collaborators must re-clone." +echo "" +read -p "Continue? (yes/no): " CONFIRM +if [ "$CONFIRM" != "yes" ]; then + echo "Aborted." + exit 0 +fi + +# Create the replacements file +REPLACEMENTS=$(mktemp) +cat > "$REPLACEMENTS" <<'REPLACE' +c3FfdGVsX3YxXzdmOGE5YjJjM2Q0ZTVmNmE===>REDACTED_TELEMETRY_KEY +aHR0cHM6Ly9zcXVhZHMtdGVsZW1ldHJ5LTk3ODg3MTgxNzYxMC51cy1jZW50cmFsMS5ydW4uYXBwL3Bpbmc===>REDACTED_TELEMETRY_ENDPOINT +postgresql://user:password@localhost:5432/squads==>REDACTED_DB_URL +postgresql://squads:squads_local_dev@localhost:5433/squads==>REDACTED_DB_URL +REPLACE + +echo "▶ Running git-filter-repo with blob replacements..." +git filter-repo --replace-text "$REPLACEMENTS" --force + +rm -f "$REPLACEMENTS" + +echo "" +echo "=== Verification ===" +echo "" + +# Verify scrub worked +FOUND=0 +for pattern in "c3FfdGVsX3YxXzdm" "aHR0cHM6Ly9zcXVhZHMtdGVsZW1ldHJ5" "user:password@localhost" "squads_local_dev"; do + HITS=$(git log --all -p | grep -c "$pattern" 2>/dev/null || true) + if [ "$HITS" -gt 0 ]; then + echo "FAIL: '$pattern' still found $HITS time(s)" + FOUND=1 + else + echo "OK: '$pattern' scrubbed" + fi +done + +echo "" +if [ "$FOUND" -eq 0 ]; then + echo "All secrets scrubbed from git history." + echo "" + echo "Next steps:" + echo " 1. git push --force --all" + echo " 2. git push --force --tags" + echo " 3. Tell collaborators to re-clone (git pull won't work after rewrite)" + echo " 4. Rotate the telemetry API key server-side (if not already done)" +else + echo "WARNING: Some secrets still found. Manual investigation needed." +fi diff --git a/src/cli.ts b/src/cli.ts index 5f7a310..9d3ce23 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -53,7 +53,7 @@ import { applyStackConfig } from './lib/stack-config.js'; // Register-pattern commands (must define subcommand structure before parseAsync) import { registerOrchestrateCommand } from './commands/orchestrate.js'; import { registerTriggerCommand } from './commands/trigger.js'; -import { registerAutonomousCommand } from './commands/autonomous.js'; +// autonomous.ts removed — daemon lifecycle consolidated into run-modes.ts import { registerApprovalCommand } from './commands/approval.js'; import { registerDeployCommand } from './commands/deploy.js'; import { registerEvalCommand } from './commands/eval.js'; @@ -303,6 +303,10 @@ program .option('--once', 'Autopilot: run one cycle then exit') .option('--phased', 'Autopilot: use dependency-based phase ordering (from SQUAD.md depends_on)') .option('--no-eval', 'Skip post-run COO evaluation') + .option('--stop', 'Stop running daemon') + .option('--status', 'Show daemon status, running agents, next routines') + .option('--pause [reason]', 'Pause daemon without stopping') + .option('--resume', 'Resume daemon after pause') .addHelpText('after', ` Examples: $ squads run engineering Run squad conversation (lead → scan → work → review) @@ -316,9 +320,13 @@ Examples: $ squads run engineering -w Run in background but tail logs $ squads run research --provider=google Use Gemini CLI instead of Claude $ squads run engineering/issue-solver --cloud Dispatch to cloud worker - $ squads run Autopilot mode (watch → decide → dispatch → learn) - $ squads run --once --dry-run Preview one autopilot cycle - $ squads run -i 15 --budget 50 Autopilot: 15min cycles, $50/day cap + $ squads run Daemon mode (cron routines + scoring + dispatch) + $ squads run --once --dry-run Preview one cycle then exit + $ squads run -i 15 --budget 50 Custom: 15min cycles, $50/day cap + $ squads run --status Show daemon status and next routines + $ squads run --stop Stop running daemon + $ squads run --pause "quota" Pause daemon without stopping + $ squads run --resume Resume after pause `) .action(async (target, options) => { const { runCommand } = await import('./commands/run.js'); @@ -711,7 +719,7 @@ program program .command('autopilot') .alias('daemon') - .description('[deprecated] Use "squads run" instead — autopilot mode when no target given') + .description('[deprecated] Use "squads run" instead — unified daemon mode') .option('-i, --interval ', 'Minutes between cycles', '30') .option('-p, --parallel ', 'Max parallel agent runs', '2') .option('-b, --budget ', 'Max daily spend in dollars (0 = unlimited/subscription)', '0') @@ -898,8 +906,41 @@ registerTriggerCommand(program); // Approval command group - human-in-the-loop for agents registerApprovalCommand(program); -// Autonomous command group - scheduled routines -registerAutonomousCommand(program); +// Autonomous command — deprecated, now `squads run --status/--stop/--pause/--resume` +program + .command('autonomous') + .alias('auto') + .description('[deprecated] Daemon lifecycle moved to squads run flags') + .argument('[action]', 'start|stop|status|pause|resume') + .argument('[reason]', 'Pause reason (optional)') + .action(async (action?: string, reason?: string) => { + const colors = termColors; + const mapping: Record = { + start: 'squads run', + stop: 'squads run --stop', + status: 'squads run --status', + pause: 'squads run --pause', + resume: 'squads run --resume', + }; + const newCmd = mapping[action || 'status'] || 'squads run --status'; + writeLine(` ${colors.yellow}Note: "squads autonomous ${action || ''}" is now "${newCmd}"${termReset}`); + writeLine(); + + const { runCommand } = await import('./commands/run.js'); + switch (action) { + case 'start': + return runCommand(null, {}); + case 'stop': + return runCommand(null, { stop: true }); + case 'pause': + return runCommand(null, { pause: reason || 'Manual pause' }); + case 'resume': + return runCommand(null, { resume: true }); + case 'status': + default: + return runCommand(null, { status: true }); + } + }); // ─── System ────────────────────────────────────────────────────────────────── @@ -1076,7 +1117,7 @@ program program.command('stack', { hidden: true }).description('[removed]').action(removedCommand('stack', 'Infrastructure is managed via the cloud. Use: squads login')); program.command('cron', { hidden: true }).description('[removed]').action(removedCommand('cron', 'Use platform scheduler: squads trigger list')); -program.command('tonight', { hidden: true }).description('[removed]').action(removedCommand('tonight', 'Use platform scheduler for overnight runs: squads autonomous start')); +program.command('tonight', { hidden: true }).description('[removed]').action(removedCommand('tonight', 'Use: squads run (daemon mode, no arguments)')); program.command('live', { hidden: true }).description('[removed]').action(removedCommand('live', 'Use: squads dash')); program.command('top', { hidden: true }).description('[removed]').action(removedCommand('top', 'Use: squads sessions')); program.command('watch', { hidden: true }).description('[removed]').action(removedCommand('watch', 'Use: watch -n 2 squads status')); diff --git a/src/commands/autonomous.ts b/src/commands/autonomous.ts deleted file mode 100644 index 39d77ac..0000000 --- a/src/commands/autonomous.ts +++ /dev/null @@ -1,796 +0,0 @@ -/** - * squads autonomous - Local scheduling daemon for autonomous agent execution - * - * Commands: - * squads autonomous start Start the daemon (detached background process) - * squads autonomous stop Stop the daemon - * squads autonomous status Show daemon status, running agents, next runs - * - * The daemon reads SQUAD.md routines, evaluates cron schedules, and spawns - * agents via `squads run --background`. No database. No Redis. Just a process. - * - * Architecture: Layer 2 in docs/ARCHITECTURE.md - */ - -import { Command } from "commander"; -import chalk from "chalk"; -import { writeLine } from "../lib/terminal.js"; -import { - existsSync, - readFileSync, - writeFileSync, - unlinkSync, - readdirSync, - mkdirSync, - appendFileSync, - openSync, -} from "fs"; -import { join } from "path"; -import { homedir } from "os"; -import { spawn, execSync } from "child_process"; -import { findSquadsDir, listSquads, Routine } from "../lib/squad-parser.js"; -import { - cronMatches, - getNextCronRun, - parseCooldown, -} from "../lib/cron.js"; - -// Daemon state directory — persistent across runs -const DAEMON_DIR = join(homedir(), ".squads"); -const PID_FILE = join(DAEMON_DIR, "autonomous.pid"); -const DAEMON_LOG = join(DAEMON_DIR, "autonomous.log"); -const PAUSE_FILE = join(DAEMON_DIR, "autonomous.paused"); -const COOLDOWN_FILE = join(DAEMON_DIR, "autonomous.cooldowns.json"); - -// Configuration from env vars (all optional) -const MAX_CONCURRENT = parseInt(process.env.SQUADS_MAX_CONCURRENT || "5"); -const AGENT_TIMEOUT_MIN = parseInt(process.env.SQUADS_AGENT_TIMEOUT || "30"); -const EVAL_INTERVAL_SEC = parseInt(process.env.SQUADS_EVAL_INTERVAL || "60"); - -interface RoutineWithSquad extends Routine { - squad: string; -} - -// ============================================================================= -// Cron Evaluator - now imported from lib/cron.ts -// ============================================================================= -// Functions: cronMatches, getNextCronRun, parseCooldown are now in lib/cron.ts - -// ============================================================================= -// Routine Collection (from SQUAD.md files) -// ============================================================================= - -/** - * Parse routines from SQUAD.md YAML blocks - */ -function parseRoutinesFromFile(filePath: string): Routine[] { - if (!existsSync(filePath)) return []; - - const content = readFileSync(filePath, "utf-8"); - const routines: Routine[] = []; - - const routinesMatch = content.match( - /##+ \w*\s*Routines[\s\S]*?```yaml\s*\n([\s\S]*?)```/i - ); - if (!routinesMatch) return []; - - let yamlContent = routinesMatch[1]; - yamlContent = yamlContent.replace(/^\s*routines:\s*\n?/, ""); - yamlContent = "\n" + yamlContent.trim(); - - const routineBlocks = yamlContent.split(/\n\s*- name:\s*/); - - for (const block of routineBlocks) { - if (!block.trim()) continue; - - const lines = block.split("\n"); - const name = lines[0].trim(); - if (!name) continue; - - const scheduleMatch = block.match(/schedule:\s*["']?([^"'\n#]+)/); - const agentsMatch = block.match(/agents:\s*\[(.*?)\]/); - const modelMatch = block.match(/model:\s*(\w+)/); - const enabledMatch = block.match(/enabled:\s*(true|false)/); - const priorityMatch = block.match(/priority:\s*(\d+)/); - const cooldownMatch = block.match( - /cooldown:\s*["']?([^"'\n]+)["']?/ - ); - - if (scheduleMatch && agentsMatch) { - const agents = agentsMatch[1] - .split(",") - .map((a) => a.trim().replace(/["']/g, "")) - .filter(Boolean); - - routines.push({ - name, - schedule: scheduleMatch[1].trim().replace(/["']/g, ""), - agents, - model: modelMatch - ? (modelMatch[1] as "opus" | "sonnet" | "haiku") - : undefined, - enabled: enabledMatch ? enabledMatch[1] === "true" : true, - priority: priorityMatch ? parseInt(priorityMatch[1]) : undefined, - cooldown: cooldownMatch ? cooldownMatch[1].trim() : undefined, - }); - } - } - - return routines; -} - -function collectRoutines(): RoutineWithSquad[] { - const squadsDir = findSquadsDir(); - if (!squadsDir) return []; - - const routines: RoutineWithSquad[] = []; - const squadNames = listSquads(squadsDir); - - for (const name of squadNames) { - const squadFile = join(squadsDir, name, "SQUAD.md"); - const squadRoutines = parseRoutinesFromFile(squadFile); - - for (const routine of squadRoutines) { - routines.push({ ...routine, squad: name }); - } - } - - return routines; -} - -// ============================================================================= -// PID File Management -// ============================================================================= - -/** - * Find the .agents/logs directory (relative to project root) - */ -function getLogsDir(): string | null { - const squadsDir = findSquadsDir(); - if (!squadsDir) return null; - // squadsDir is .agents/squads, logs are at .agents/logs - return join(squadsDir, "..", "logs"); -} - -/** - * Count currently running agents by checking PID files - */ -function getRunningAgents(): { - squad: string; - agent: string; - pid: number; - startedAt: number; - logFile: string; -}[] { - const logsDir = getLogsDir(); - if (!logsDir || !existsSync(logsDir)) return []; - - const running: { - squad: string; - agent: string; - pid: number; - startedAt: number; - logFile: string; - }[] = []; - - let squadDirs: string[]; - try { - squadDirs = readdirSync(logsDir); - } catch { - return []; - } - - for (const squadDir of squadDirs) { - const squadPath = join(logsDir, squadDir); - let files: string[]; - try { - files = readdirSync(squadPath); - } catch { - continue; - } - - for (const file of files) { - if (!file.endsWith(".pid")) continue; - - const pidPath = join(squadPath, file); - try { - const pid = parseInt(readFileSync(pidPath, "utf-8").trim()); - if (isNaN(pid)) continue; - - // Check if process is alive - try { - process.kill(pid, 0); - } catch { - // Process dead — clean up orphan PID file - try { - unlinkSync(pidPath); - } catch { - /* ignore */ - } - continue; - } - - // Extract agent name and timestamp from filename: agent-timestamp.pid - const match = file.match(/^(.+)-(\d+)\.pid$/); - if (!match) continue; - - const agentName = match[1]; - const timestamp = parseInt(match[2]); - - running.push({ - squad: squadDir, - agent: agentName, - pid, - startedAt: timestamp, - logFile: pidPath.replace(".pid", ".log"), - }); - } catch { - continue; - } - } - } - - return running; -} - -/** - * Kill an agent by PID and clean up its PID file - */ -function killAgent(pid: number, pidFile: string, signal: NodeJS.Signals = "SIGTERM"): boolean { - try { - process.kill(pid, signal); - // Give it a moment, then check if it's dead - if (signal === "SIGTERM") { - setTimeout(() => { - try { - process.kill(pid, 0); // Still alive? - process.kill(pid, "SIGKILL"); // Force kill - } catch { - /* already dead */ - } - }, 5000); - } - try { - unlinkSync(pidFile); - } catch { - /* ignore */ - } - return true; - } catch { - return false; - } -} - -// ============================================================================= -// Cooldown Parsing - now imported from lib/cron.ts -// ============================================================================= -// Function: parseCooldown is now in lib/cron.ts - -// ============================================================================= -// Daemon Core -// ============================================================================= - -/** - * Log a message to the daemon log file with timestamp - */ -function daemonLog(msg: string): void { - const ts = new Date().toISOString(); - const line = `[${ts}] ${msg}\n`; - try { - appendFileSync(DAEMON_LOG, line); - } catch { - // Can't log — ignore - } -} - -// ============================================================================= -// Pause / Resume — quota awareness -// ============================================================================= - -/** - * Check if the daemon is paused (e.g., quota exhausted). - * The daemon stays running but stops spawning new agents. - */ -function isPaused(): { paused: boolean; reason?: string; since?: string } { - if (!existsSync(PAUSE_FILE)) return { paused: false }; - try { - const data = JSON.parse(readFileSync(PAUSE_FILE, "utf-8")); - return { paused: true, reason: data.reason, since: data.since }; - } catch { - return { paused: true, reason: "unknown" }; - } -} - -/** - * Pause the daemon. It stays running but won't spawn new agents. - * Use for quota limits, maintenance, or manual override. - */ -function pauseDaemon(reason: string): void { - if (!existsSync(DAEMON_DIR)) { - mkdirSync(DAEMON_DIR, { recursive: true }); - } - writeFileSync(PAUSE_FILE, JSON.stringify({ - reason, - since: new Date().toISOString(), - })); - daemonLog(`PAUSED: ${reason}`); -} - -/** - * Resume the daemon after a pause. - */ -function resumeDaemon(): void { - try { - unlinkSync(PAUSE_FILE); - } catch { - /* not paused */ - } - daemonLog("RESUMED"); -} - -// ============================================================================= -// Persistent Cooldown State — survives daemon restarts -// ============================================================================= - -function loadCooldowns(): Map { - const map = new Map(); - if (!existsSync(COOLDOWN_FILE)) return map; - try { - const data = JSON.parse(readFileSync(COOLDOWN_FILE, "utf-8")); - for (const [key, ts] of Object.entries(data)) { - if (typeof ts === "number") map.set(key, ts); - } - } catch { - /* corrupt file — start fresh */ - } - return map; -} - -function saveCooldowns(map: Map): void { - try { - const obj: Record = {}; - for (const [key, ts] of map) { - obj[key] = ts; - } - writeFileSync(COOLDOWN_FILE, JSON.stringify(obj)); - } catch { - /* best effort */ - } -} - -/** - * The main daemon loop. Runs as a long-lived process. - * This is the COO's operating system — always on, managing the workforce. - */ -async function daemonLoop(): Promise { - daemonLog("Daemon started"); - - // Load persistent cooldown state (survives restarts) - const lastSpawned = loadCooldowns(); - - // Track consecutive spawn failures for auto-pause (quota detection) - let consecutiveFailures = 0; - const AUTO_PAUSE_THRESHOLD = 5; - - const tick = async () => { - try { - // Check if paused (quota, maintenance, manual) - const pauseStatus = isPaused(); - if (pauseStatus.paused) { - // Still enforce timeouts on running agents even when paused - const running = getRunningAgents(); - for (const agent of running) { - const runtimeMin = (Date.now() - agent.startedAt) / 60000; - if (runtimeMin > AGENT_TIMEOUT_MIN) { - daemonLog( - `TIMEOUT: ${agent.squad}/${agent.agent} (PID ${agent.pid}, ${Math.round(runtimeMin)}min)` - ); - const pidFile = agent.logFile.replace(".log", ".pid"); - killAgent(agent.pid, pidFile); - } - } - return; // Don't spawn new agents while paused - } - - const now = new Date(); - now.setSeconds(0, 0); // Round to minute - - // 1. Collect enabled routines - const routines = collectRoutines().filter((r) => r.enabled !== false); - - // 2. Check running agents - const running = getRunningAgents(); - - // 3. Timeout enforcement - for (const agent of running) { - const runtimeMin = (Date.now() - agent.startedAt) / 60000; - if (runtimeMin > AGENT_TIMEOUT_MIN) { - daemonLog( - `TIMEOUT: ${agent.squad}/${agent.agent} (PID ${agent.pid}, ${Math.round(runtimeMin)}min)` - ); - const pidFile = agent.logFile.replace(".log", ".pid"); - killAgent(agent.pid, pidFile); - } - } - - // 4. Evaluate cron schedules - for (const routine of routines) { - if (!cronMatches(routine.schedule, now)) continue; - - for (const agentName of routine.agents) { - const key = `${routine.squad}/${agentName}`; - - // Cooldown check (persistent across restarts) - if (routine.cooldown) { - const last = lastSpawned.get(key); - const cooldownMs = parseCooldown(routine.cooldown); - if (last && Date.now() - last < cooldownMs) { - continue; - } - } - - // Already running check - const alreadyRunning = running.some( - (r) => r.squad === routine.squad && r.agent === agentName - ); - if (alreadyRunning) continue; - - // Concurrency check - const currentRunning = getRunningAgents().length; - if (currentRunning >= MAX_CONCURRENT) { - daemonLog( - `SKIP: ${key} — concurrency limit (${currentRunning}/${MAX_CONCURRENT})` - ); - continue; - } - - // Spawn the agent - daemonLog(`SPAWN: ${key} (routine: ${routine.name})`); - try { - const modelFlag = routine.model ? `--model ${routine.model}` : ""; - execSync( - `squads run ${routine.squad}/${agentName} --background ${modelFlag} --trigger scheduled`, - { - cwd: process.cwd(), - stdio: "ignore", - timeout: 10000, // 10s to spawn - env: { - ...process.env, - CLAUDECODE: "", // Allow nested claude sessions - }, - } - ); - lastSpawned.set(key, Date.now()); - saveCooldowns(lastSpawned); // Persist to disk - consecutiveFailures = 0; // Reset failure counter - daemonLog(`SPAWNED: ${key}`); - } catch (err) { - consecutiveFailures++; - daemonLog(`ERROR: Failed to spawn ${key} (${consecutiveFailures}/${AUTO_PAUSE_THRESHOLD}): ${err}`); - - // Auto-pause after repeated failures (likely quota exhausted) - if (consecutiveFailures >= AUTO_PAUSE_THRESHOLD) { - pauseDaemon(`Auto-paused: ${consecutiveFailures} consecutive spawn failures (likely quota exhausted)`); - daemonLog(`AUTO-PAUSED: ${consecutiveFailures} consecutive failures. Run 'squads autonomous resume' when quota resets.`); - } - } - } - } - } catch (err) { - daemonLog(`TICK ERROR: ${err}`); - } - }; - - // Run immediately, then on interval - await tick(); - setInterval(tick, EVAL_INTERVAL_SEC * 1000); - - // Clean shutdown - const cleanup = (signal: string) => { - daemonLog(`Received ${signal}, shutting down`); - saveCooldowns(lastSpawned); // Persist cooldowns before exit - try { - unlinkSync(PID_FILE); - } catch { - /* ignore */ - } - process.exit(0); - }; - - process.on("SIGTERM", () => cleanup("SIGTERM")); - process.on("SIGINT", () => cleanup("SIGINT")); -} - -// ============================================================================= -// Daemon Lifecycle (check/start/stop) -// ============================================================================= - -function isRunning(): { running: boolean; pid?: number } { - if (!existsSync(PID_FILE)) return { running: false }; - - const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim()); - if (isNaN(pid)) return { running: false }; - - try { - process.kill(pid, 0); - return { running: true, pid }; - } catch { - // Stale PID file - try { - unlinkSync(PID_FILE); - } catch { - /* ignore */ - } - return { running: false }; - } -} - -async function startScheduler(): Promise { - const status = isRunning(); - if (status.running) { - writeLine( - chalk.yellow(`Daemon already running (PID ${status.pid})`) - ); - writeLine(chalk.gray(` Log: ${DAEMON_LOG}`)); - return; - } - - // Ensure daemon directory exists - if (!existsSync(DAEMON_DIR)) { - mkdirSync(DAEMON_DIR, { recursive: true }); - } - - const routines = collectRoutines().filter((r) => r.enabled !== false); - if (routines.length === 0) { - writeLine(chalk.yellow("No enabled routines found.")); - writeLine( - chalk.gray("Add routines to SQUAD.md files under ### Routines section.") - ); - return; - } - - // Check if we're being invoked as the daemon itself (via env var) - if (process.env.SQUADS_DAEMON === "1") { - // We ARE the daemon — run the loop - writeFileSync(PID_FILE, process.pid.toString()); - await daemonLoop(); - // daemonLoop never returns (infinite setInterval) - // Keep the event loop alive - await new Promise(() => {}); - return; - } - - // Spawn a detached daemon process - // Use SQUADS_DAEMON env var instead of --daemon CLI flag to avoid - // Commander.js rejecting the unknown option and silently exiting - - // Redirect child stdout/stderr to daemon log for diagnosability - if (!existsSync(DAEMON_LOG)) { - writeFileSync(DAEMON_LOG, ""); - } - const logFd = openSync(DAEMON_LOG, "a"); - - const child = spawn( - process.execPath, // node - [process.argv[1], "autonomous", "start"], - { - cwd: process.cwd(), - detached: true, - stdio: ["ignore", logFd, logFd], - env: { ...process.env, SQUADS_DAEMON: "1" }, - } - ); - child.unref(); - - // Wait for PID file to appear (2s for slower systems) - await new Promise((resolve) => setTimeout(resolve, 2000)); - - const check = isRunning(); - if (check.running) { - writeLine(chalk.green(`\n Daemon started (PID ${check.pid})`)); - } else { - writeLine(chalk.red("\n Daemon failed to start. Check log:")); - writeLine(chalk.gray(` $ tail -20 ${DAEMON_LOG}`)); - } - - writeLine(chalk.gray(` Log: ${DAEMON_LOG}`)); - writeLine(chalk.gray(` Config: SQUAD.md routines\n`)); - - // Show what's scheduled - writeLine(chalk.cyan(" Routines")); - const bySquad = new Map(); - for (const r of routines) { - if (!bySquad.has(r.squad)) bySquad.set(r.squad, []); - bySquad.get(r.squad)!.push(r); - } - - for (const [squad, squadRoutines] of bySquad) { - for (const r of squadRoutines) { - const next = getNextCronRun(r.schedule); - const timeStr = next.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); - writeLine( - ` ${chalk.green("●")} ${chalk.cyan(squad)}/${r.name} ${chalk.gray(r.schedule)} ${chalk.gray(`→ ${timeStr}`)}` - ); - } - } - - writeLine( - chalk.gray(`\n ${routines.length} routines, max ${MAX_CONCURRENT} concurrent`) - ); - writeLine(chalk.gray(" Stop: squads autonomous stop")); - writeLine(chalk.gray(` Monitor: tail -f ${DAEMON_LOG}\n`)); -} - -function stopScheduler(): void { - const status = isRunning(); - - if (!status.running) { - writeLine(chalk.gray("Daemon not running")); - return; - } - - try { - process.kill(status.pid!, "SIGTERM"); - try { - unlinkSync(PID_FILE); - } catch { - /* ignore */ - } - writeLine(chalk.green(`Daemon stopped (PID ${status.pid})`)); - } catch (error) { - console.error(chalk.red(`Failed to stop daemon: ${error}`)); - } -} - -async function showStatus(): Promise { - const daemon = isRunning(); - const routines = collectRoutines(); - const enabled = routines.filter((r) => r.enabled !== false); - const running = getRunningAgents(); - - writeLine(chalk.bold("\n Autonomous Scheduler\n")); - - // Daemon status - const pauseStatus = isPaused(); - if (daemon.running) { - if (pauseStatus.paused) { - writeLine( - ` ${chalk.yellow("●")} Daemon paused ${chalk.gray(`(PID ${daemon.pid})`)}` - ); - writeLine(` ${chalk.yellow(pauseStatus.reason || "No reason given")} ${chalk.gray(`since ${pauseStatus.since || "unknown"}`)}`); - } else { - writeLine( - ` ${chalk.green("●")} Daemon running ${chalk.gray(`(PID ${daemon.pid})`)}` - ); - } - } else { - writeLine(` ${chalk.red("●")} Daemon not running`); - } - writeLine(); - - // Running agents - if (running.length > 0) { - writeLine(chalk.cyan(" Running Agents")); - for (const agent of running) { - const runtimeMin = Math.round((Date.now() - agent.startedAt) / 60000); - const timeoutWarning = - runtimeMin > AGENT_TIMEOUT_MIN * 0.8 ? chalk.yellow(" ⚠") : ""; - writeLine( - ` ${chalk.green("●")} ${chalk.cyan(agent.squad)}/${agent.agent} ${chalk.gray(`${runtimeMin}min`)}${timeoutWarning} ${chalk.gray(`PID ${agent.pid}`)}` - ); - } - writeLine(); - } - - // Routine summary - writeLine(chalk.cyan(" Routines")); - writeLine( - ` ${enabled.length} enabled / ${routines.length} total, ${running.length}/${MAX_CONCURRENT} running` - ); - writeLine(); - - // Next 10 upcoming runs - if (enabled.length > 0) { - writeLine(chalk.cyan(" Next Runs")); - - const now = new Date(); - const nextRuns: { - squad: string; - routine: string; - agent: string; - nextRun: Date; - }[] = []; - - for (const r of enabled) { - const next = getNextCronRun(r.schedule, now); - for (const agent of r.agents) { - nextRuns.push({ - squad: r.squad, - routine: r.name, - agent, - nextRun: next, - }); - } - } - - nextRuns - .sort((a, b) => a.nextRun.getTime() - b.nextRun.getTime()) - .slice(0, 10) - .forEach((run) => { - const timeStr = run.nextRun.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); - const dateStr = - run.nextRun.toDateString() === now.toDateString() - ? "today" - : run.nextRun.toLocaleDateString([], { - month: "short", - day: "numeric", - }); - writeLine( - ` ${chalk.gray(timeStr)} ${chalk.gray(dateStr)} ${chalk.cyan(run.squad)}/${run.agent}` - ); - }); - } - - writeLine(); - writeLine(chalk.gray(" Commands:")); - writeLine(chalk.gray(" $ squads autonomous start Start daemon")); - writeLine(chalk.gray(" $ squads autonomous stop Stop daemon")); - writeLine(chalk.gray(" $ squads autonomous pause Pause (quota/manual)")); - writeLine(chalk.gray(" $ squads autonomous resume Resume after pause")); - writeLine(chalk.gray(` $ tail -f ${DAEMON_LOG}`)); - writeLine(); -} - -// ============================================================================= -// Command Registration -// ============================================================================= - -export function registerAutonomousCommand(program: Command): void { - const autonomous = program - .command("autonomous") - .alias("auto") - .description("Local scheduling daemon for autonomous agent execution") - .action(() => { autonomous.outputHelp(); }); - - autonomous - .command("start") - .description("Start the scheduling daemon") - .action(async () => { - await startScheduler(); - }); - - autonomous - .command("stop") - .description("Stop the scheduling daemon") - .action(() => { - stopScheduler(); - }); - - autonomous - .command("status") - .description("Show daemon status, running agents, and next runs") - .action(async () => { - await showStatus(); - }); - - autonomous - .command("pause") - .description("Pause the daemon (e.g. quota exhausted)") - .argument("[reason]", "Reason for pausing", "Manual pause") - .action((reason: string) => { - pauseDaemon(reason); - }); - - autonomous - .command("resume") - .description("Resume a paused daemon") - .action(() => { - resumeDaemon(); - }); -} diff --git a/src/commands/daemon.ts b/src/commands/daemon.ts deleted file mode 100644 index 98cd96d..0000000 --- a/src/commands/daemon.ts +++ /dev/null @@ -1,488 +0,0 @@ -/** - * squads daemon — persistent intelligence loop. - * - * Watches the org, decides what to run, dispatches agents, - * monitors results, and reacts (merge, retry, escalate). - * - * This is the product: incremental smartness, not 200 agents. - */ - -import { spawn } from 'child_process'; -import { getBotGhEnv } from '../lib/github.js'; -import { - recordArtifacts, - gradeExecution, - pollOutcomes, - computeAllScorecards, -} from '../lib/outcomes.js'; -import { pushCognitionSignal } from '../lib/api-client.js'; -import { - colors, - bold, - RESET, - icons, - writeLine, -} from '../lib/terminal.js'; -import { - MIN_PHANTOM_DURATION_MS, - loadLoopState, - saveLoopState, - getSquadRepos, - scoreSquads, - checkNewPRs, - getPRsWithReviewFeedback, - buildReviewTask, - pushMemorySignals, - slackNotify, -} from '../lib/squad-loop.js'; - -// Bot environment for gh CLI commands (populated on first cycle) -let botGhEnv: Record = {}; - -// ── Types ──────────────────────────────────────────────────────────── - -interface DaemonOptions { - interval: number; // minutes between cycles - maxParallel: number; - dryRun: boolean; - verbose: boolean; - once: boolean; // run one cycle and exit - budget: number; // max $/day -} - -interface RunningJob { - squad: string; - agent: string; - pid: number; - startedAt: number; - process: ReturnType; -} - -interface CycleResult { - dispatched: string[]; - completed: string[]; - failed: string[]; - skipped: string[]; - costEstimate: number; -} - -// ── Dispatch: Run agents ───────────────────────────────────────────── - -function dispatchAgent( - squad: string, - agent: string, - task?: string, -): RunningJob { - const args = ['run', squad, '-a', agent]; - if (task) args.push('--task', task); - - const proc = spawn('squads', args, { - stdio: ['ignore', 'pipe', 'pipe'], - detached: false, - }); - - return { - squad, - agent, - pid: proc.pid || 0, - startedAt: Date.now(), - process: proc, - }; -} - -/** Dispatch a full squad conversation (squads run ) instead of a single agent. */ -function dispatchConversation(squad: string): RunningJob { - const proc = spawn('squads', ['run', squad], { - stdio: ['ignore', 'pipe', 'pipe'], - detached: false, - }); - - return { - squad, - agent: 'conversation', - pid: proc.pid || 0, - startedAt: Date.now(), - process: proc, - }; -} - -function waitForJob(job: RunningJob, timeoutMs: number = 20 * 60 * 1000): Promise<'completed' | 'failed' | 'timeout'> { - return new Promise((resolve) => { - let settled = false; - - const timer = setTimeout(() => { - if (!settled) { - settled = true; - try { job.process.kill('SIGTERM'); } catch { /* ignore */ } - resolve('timeout'); - } - }, timeoutMs); - - job.process.on('close', (code) => { - if (!settled) { - settled = true; - clearTimeout(timer); - resolve(code === 0 ? 'completed' : 'failed'); - } - }); - - job.process.on('error', () => { - if (!settled) { - settled = true; - clearTimeout(timer); - resolve('failed'); - } - }); - }); -} - -// ── Main cycle ─────────────────────────────────────────────────────── - -async function runCycle(options: DaemonOptions): Promise { - // Refresh bot token for gh CLI calls - botGhEnv = await getBotGhEnv(); - - const state = loadLoopState(); - const today = new Date().toISOString().slice(0, 10); - const result: CycleResult = { - dispatched: [], - completed: [], - failed: [], - skipped: [], - costEstimate: 0, - }; - - // Reset daily cost counter - if (state.dailyCostDate !== today) { - state.dailyCost = 0; - state.dailyCostDate = today; - } - - // Check budget (0 = unlimited, subscription mode) - if (options.budget > 0 && state.dailyCost >= options.budget) { - writeLine(` ${icons.warning} ${colors.yellow}Daily budget reached ($${state.dailyCost.toFixed(2)}/$${options.budget})${RESET}`); - saveLoopState(state); - return result; - } - - // Poll outcomes for unsettled records - const pollResult = pollOutcomes(botGhEnv); - if (pollResult.polled > 0) { - writeLine(` ${colors.dim}Polled ${pollResult.polled} artifact(s), ${pollResult.settled} newly settled${RESET}`); - } - - // Recompute scorecards - computeAllScorecards('7d'); - - // Gather intelligence - writeLine(` ${colors.dim}Scanning org state...${RESET}`); - const squadRepos = getSquadRepos(); - const signals = scoreSquads(state, squadRepos, botGhEnv); - - if (signals.length === 0) { - writeLine(` ${colors.dim}No squads need attention${RESET}`); - saveLoopState(state); - return result; - } - - if (options.verbose) { - writeLine(` ${colors.dim}Scored ${signals.length} squads${RESET}`); - } - - // Show what we found - writeLine(` ${bold}Signals:${RESET}`); - for (const sig of signals.slice(0, 8)) { - const scoreColor = sig.score >= 60 ? colors.red : sig.score >= 30 ? colors.yellow : colors.dim; - writeLine(` ${scoreColor}[${sig.score}]${RESET} ${colors.cyan}${sig.squad}${RESET} ${colors.dim}${sig.reason}${RESET}`); - } - writeLine(); - - // Pick top N to dispatch - const toDispatch = signals - .filter(s => s.score > 0) - .slice(0, options.maxParallel); - - if (toDispatch.length === 0) { - writeLine(` ${colors.dim}All signals below threshold${RESET}`); - saveLoopState(state); - return result; - } - - if (options.dryRun) { - writeLine(` ${colors.yellow}[DRY RUN] Would dispatch:${RESET}`); - for (const sig of toDispatch) { - const label = sig.agent ?? 'conversation'; - writeLine(` ${colors.cyan}${sig.squad}/${label}${RESET} — ${sig.reason}`); - } - saveLoopState(state); - return result; - } - - // Dispatch agents - const jobs: RunningJob[] = []; - for (const sig of toDispatch) { - let job: RunningJob; - - if (sig.agent === undefined) { - // Conversation mode: `squads run ` — coordinates all agents - writeLine(` ${icons.running} Dispatching ${colors.cyan}${sig.squad}/conversation${RESET} (${sig.issues.length} issues)`); - job = dispatchConversation(sig.squad); - } else { - // Single-agent mode: target a specific agent (usually issue-solver) - const topIssue = sig.issues[0]; - const task = topIssue - ? `Fix issue #${topIssue.number}: ${topIssue.title}` - : undefined; - writeLine(` ${icons.running} Dispatching ${colors.cyan}${sig.squad}/${sig.agent}${RESET}${task ? ` → #${topIssue?.number}` : ''}`); - job = dispatchAgent(sig.squad, sig.agent, task); - } - - jobs.push(job); - result.dispatched.push(`${sig.squad}/${job.agent}`); - } - - writeLine(` ${colors.dim}${jobs.length} agents running. Waiting...${RESET}`); - writeLine(); - - // Wait for all jobs (parallel) - const outcomes = await Promise.all( - jobs.map(async (job) => { - const outcome = await waitForJob(job); - const durationMs = Date.now() - job.startedAt; - const durationMin = Math.floor(durationMs / 60000); - const key = `${job.squad}:${job.agent}`; - - // Update state - state.recentRuns.push({ - squad: job.squad, - agent: job.agent, - at: new Date().toISOString(), - result: outcome, - durationMs, - }); - - // Minimum duration threshold: runs completing in <30s did no real work. - // Count them as "skipped" (phantom completion) to avoid masking health issues. - const effectiveOutcome = - outcome === 'completed' && durationMs < MIN_PHANTOM_DURATION_MS - ? 'skipped' - : outcome; - - // Track failures - if (effectiveOutcome === 'failed' || effectiveOutcome === 'timeout') { - state.failCounts[key] = (state.failCounts[key] || 0) + 1; - result.failed.push(`${job.squad}/${job.agent}`); - writeLine(` ${icons.error} ${colors.red}${job.squad}/${job.agent}${RESET} ${effectiveOutcome} (${durationMin}m)`); - } else if (effectiveOutcome === 'skipped') { - // Phantom completion: don't reset fail counts, don't record as success - result.skipped.push(`${job.squad}/${job.agent}`); - writeLine(` ${icons.warning} ${colors.yellow}${job.squad}/${job.agent}${RESET} skipped (instant exit: ${durationMs}ms — no work done)`); - } else { - state.failCounts[key] = 0; // Reset on success - result.completed.push(`${job.squad}/${job.agent}`); - writeLine(` ${icons.success} ${colors.green}${job.squad}/${job.agent}${RESET} completed (${durationMin}m)`); - } - - // Estimate cost (~$0.50 per agent run average, but zero for phantom runs) - const estimatedCost = effectiveOutcome === 'skipped' ? 0 : 0.50; - state.dailyCost += estimatedCost; - result.costEstimate += estimatedCost; - - // Record artifacts for outcome tracking (only real completions) - let qualityGrade: string | undefined; - if (effectiveOutcome === 'completed') { - const repo = squadRepos[job.squad]; - if (repo) { - const record = recordArtifacts({ - executionId: `daemon_${job.squad}_${job.agent}_${job.startedAt}`, - squad: job.squad, - agent: job.agent, - completedAt: new Date().toISOString(), - costUsd: estimatedCost, - repo, - }, botGhEnv); - - // Grade the execution quality - if (record) { - const { grade, reason } = gradeExecution(record); - qualityGrade = grade; - - // Push quality signal to cognition engine - pushCognitionSignal({ - source: 'execution', - signal_type: 'execution_quality', - value: { A: 4, B: 3, C: 2, D: 1, F: 0 }[grade] ?? 0, - unit: 'quality_score', - data: { grade, reason, cost_usd: estimatedCost }, - entity_type: 'agent', - entity_id: `${job.squad}/${job.agent}`, - confidence: 0.9, - }); - - if (options.verbose) { - const gradeColor = grade <= 'B' ? colors.green : grade >= 'D' ? colors.red : colors.yellow; - writeLine(` ${gradeColor}Grade: ${grade}${RESET} ${colors.dim}${reason}${RESET}`); - } - } - } - } - - // Push execution signal to cognition engine (fire-and-forget) - pushCognitionSignal({ - source: 'execution', - signal_type: effectiveOutcome === 'completed' ? 'agent_completed' : 'agent_failed', - value: effectiveOutcome === 'completed' ? 1 : 0, - unit: 'completion', - data: { outcome: effectiveOutcome, durationMs, cost_usd: estimatedCost, quality_grade: qualityGrade }, - entity_type: 'agent', - entity_id: `${job.squad}/${job.agent}`, - confidence: 0.95, - }); - - return { job, outcome: effectiveOutcome, durationMs }; - }), - ); - - // Push changed memory files to cognition engine (fire-and-forget) - const dispatchedSquads = [...new Set(toDispatch.map(s => s.squad))]; - await pushMemorySignals(dispatchedSquads, state, options.verbose); - - // Trim recent runs to last 50 - state.recentRuns = state.recentRuns.slice(-50); - state.lastCycle = new Date().toISOString(); - saveLoopState(state); - - // React: check for new PRs - writeLine(); - for (const { job, outcome } of outcomes) { - if (outcome !== 'completed') continue; - const repo = squadRepos[job.squad]; - if (!repo) continue; - const newPRs = checkNewPRs(repo, 30, botGhEnv); - if (newPRs.length > 0) { - writeLine(` ${icons.success} ${colors.cyan}${job.squad}${RESET} created ${newPRs.length} PR(s):`); - for (const pr of newPRs) { - writeLine(` ${colors.dim}#${pr.number} ${pr.title}${RESET}`); - } - } - } - - // React: check for review feedback on bot PRs (Gemini, humans, etc.) - if (!options.dryRun) { - const reviewJobs: RunningJob[] = []; - - for (const repo of Object.values(squadRepos)) { - const prsWithFeedback = getPRsWithReviewFeedback(repo, botGhEnv); - for (const pr of prsWithFeedback) { - // Find which squad owns this repo - const squad = Object.entries(squadRepos).find(([, r]) => r === repo)?.[0]; - if (!squad) continue; - - // Check budget (0 = unlimited) - if (options.budget > 0 && state.dailyCost >= options.budget) break; - - const task = buildReviewTask(pr); - writeLine(` ${icons.running} Addressing ${pr.comments.length} review comment(s) on ${colors.cyan}${squad}${RESET} PR #${pr.number}`); - - const job = dispatchAgent(squad, 'issue-solver', task); - reviewJobs.push(job); - result.dispatched.push(`${squad}/issue-solver (review #${pr.number})`); - } - } - - // Wait for review-fix jobs - if (reviewJobs.length > 0) { - writeLine(` ${colors.dim}${reviewJobs.length} review-fix agent(s) running...${RESET}`); - for (const job of reviewJobs) { - const outcome = await waitForJob(job); - const durationMs = Date.now() - job.startedAt; - const durationMin = Math.floor(durationMs / 60000); - - if (outcome === 'completed') { - result.completed.push(`${job.squad}/review-fix`); - writeLine(` ${icons.success} ${colors.green}${job.squad}/review-fix${RESET} completed (${durationMin}m)`); - } else { - result.failed.push(`${job.squad}/review-fix`); - writeLine(` ${icons.error} ${colors.red}${job.squad}/review-fix${RESET} ${outcome} (${durationMin}m)`); - } - - state.dailyCost += 0.50; - result.costEstimate += 0.50; - } - } - } - - saveLoopState(state); - - // Slack notifications: only on failures and escalations (not routine completions) - if (result.failed.length > 0) { - const summary = [ - `*Daemon cycle — failures detected*`, - `Failed: ${result.failed.join(', ')}`, - result.completed.length > 0 ? `Completed: ${result.completed.join(', ')}` : '', - `Est. cost: $${result.costEstimate.toFixed(2)} (daily: $${state.dailyCost.toFixed(2)}${options.budget > 0 ? '/$' + options.budget : ''})`, - ].filter(Boolean).join('\n'); - slackNotify(summary); - } - - // Escalate persistent failures - for (const [key, count] of Object.entries(state.failCounts)) { - if (count >= 3) { - slackNotify(`🚨 *Escalation*: ${key} has failed ${count} times consecutively. Needs human attention.`); - } - } - - return result; -} - -// ── Command ────────────────────────────────────────────────────────── - -export async function daemonCommand(options: { - interval?: string; - parallel?: string; - dryRun?: boolean; - verbose?: boolean; - once?: boolean; - budget?: string; -}): Promise { - const config: DaemonOptions = { - interval: parseInt(options.interval || '30', 10), - maxParallel: parseInt(options.parallel || '2', 10), - dryRun: options.dryRun || false, - verbose: options.verbose || false, - once: options.once || false, - budget: parseFloat(options.budget || '0'), - }; - - writeLine(); - writeLine(` ${bold}squads daemon${RESET}`); - const budgetLabel = config.budget > 0 ? `Budget: $${config.budget}/day` : 'Subscription (no budget limit)'; - writeLine(` ${colors.dim}Interval: ${config.interval}m | Parallel: ${config.maxParallel} | ${budgetLabel}${config.dryRun ? ' | DRY RUN' : ''}${RESET}`); - writeLine(); - - // First cycle - const result = await runCycle(config); - - if (config.once) { - writeLine(); - writeLine(` ${colors.dim}Single cycle complete. Dispatched: ${result.dispatched.length}, Completed: ${result.completed.length}, Failed: ${result.failed.length}${RESET}`); - writeLine(); - return; - } - - // Continuous loop - writeLine(); - writeLine(` ${colors.dim}Next cycle in ${config.interval} minutes. Ctrl+C to stop.${RESET}`); - - const loop = async () => { - while (true) { - await new Promise(resolve => setTimeout(resolve, config.interval * 60 * 1000)); - writeLine(); - writeLine(` ${colors.dim}─── Cycle ${new Date().toISOString()} ───${RESET}`); - await runCycle(config); - writeLine(` ${colors.dim}Next cycle in ${config.interval} minutes.${RESET}`); - } - }; - - await loop(); -} diff --git a/src/commands/run.ts b/src/commands/run.ts index f4d40bd..ede7ac6 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -26,12 +26,51 @@ import { runCloudDispatch } from '../lib/cloud-dispatch.js'; import { runConversation, saveTranscript, type ConversationOptions } from '../lib/workflow.js'; import { reportExecutionStart, reportConversationResult, pushCognitionSignal } from '../lib/api-client.js'; import { runAgent } from '../lib/agent-runner.js'; -import { runPostEvaluation, runAutopilot, runLeadMode } from '../lib/run-modes.js'; +import { + runPostEvaluation, + runAutopilot, + runLeadMode, + isDaemonRunning, + stopDaemon, + showDaemonStatus, + pauseDaemon, + resumeDaemon, + startDaemon, +} from '../lib/run-modes.js'; export async function runCommand( target: string | null, options: RunOptions ): Promise { + // ── Daemon lifecycle commands (handle before anything else) ── + if (options.stop) { + const stopped = stopDaemon(); + if (stopped) { + writeLine(` ${colors.green}Daemon stopped${RESET}`); + } else { + writeLine(` ${colors.dim}Daemon not running${RESET}`); + } + return; + } + + if (options.status) { + await showDaemonStatus(); + return; + } + + if (options.pause) { + const reason = typeof options.pause === 'string' ? options.pause : 'Manual pause'; + pauseDaemon(reason); + writeLine(` ${colors.yellow}Daemon paused: ${reason}${RESET}`); + return; + } + + if (options.resume) { + resumeDaemon(); + writeLine(` ${colors.green}Daemon resumed${RESET}`); + return; + } + const squadsDir = findSquadsDir(); if (!squadsDir) { @@ -48,6 +87,11 @@ export async function runCommand( // MODE 1: Autopilot — no target means run all squads continuously if (!target) { + // If SQUADS_DAEMON env is set, start as detached daemon + if (process.env.SQUADS_DAEMON === '1') { + await startDaemon(); + return; + } await runAutopilot(squadsDir, options); return; } diff --git a/src/lib/cron.ts b/src/lib/cron.ts index d767806..24e99cc 100644 --- a/src/lib/cron.ts +++ b/src/lib/cron.ts @@ -1,8 +1,135 @@ /** - * Zero-dependency cron evaluator utilities - * Extracted from autonomous.ts for reusability and testing + * Zero-dependency cron evaluator utilities + routine collection from SQUAD.md files. + * Cron logic extracted from autonomous.ts; routine parsing consolidated here. */ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { findSquadsDir, listSquads, type Routine } from './squad-parser.js'; + +// Persistent cooldown state file +const COOLDOWN_FILE = join(homedir(), '.squads', 'autonomous.cooldowns.json'); + +// ── Routine with squad name ────────────────────────────────────────── + +export interface RoutineWithSquad extends Routine { + squad: string; +} + +// ── Routine parsing from SQUAD.md files ────────────────────────────── + +/** + * Parse routines from a SQUAD.md file's YAML block. + */ +export function parseRoutinesFromFile(filePath: string): Routine[] { + if (!existsSync(filePath)) return []; + + const content = readFileSync(filePath, 'utf-8'); + const routines: Routine[] = []; + + const routinesMatch = content.match( + /##+ \w*\s*Routines[\s\S]*?```yaml\s*\n([\s\S]*?)```/i + ); + if (!routinesMatch) return []; + + let yamlContent = routinesMatch[1]; + yamlContent = yamlContent.replace(/^\s*routines:\s*\n?/, ''); + yamlContent = '\n' + yamlContent.trim(); + + const routineBlocks = yamlContent.split(/\n\s*- name:\s*/); + + for (const block of routineBlocks) { + if (!block.trim()) continue; + + const lines = block.split('\n'); + const name = lines[0].trim(); + if (!name) continue; + + const scheduleMatch = block.match(/schedule:\s*["']?([^"'\n#]+)/); + const agentsMatch = block.match(/agents:\s*\[(.*?)\]/); + const modelMatch = block.match(/model:\s*(\w+)/); + const enabledMatch = block.match(/enabled:\s*(true|false)/); + const priorityMatch = block.match(/priority:\s*(\d+)/); + const cooldownMatch = block.match( + /cooldown:\s*["']?([^"'\n]+)["']?/ + ); + + if (scheduleMatch && agentsMatch) { + const agents = agentsMatch[1] + .split(',') + .map((a) => a.trim().replace(/["']/g, '')) + .filter(Boolean); + + routines.push({ + name, + schedule: scheduleMatch[1].trim().replace(/["']/g, ''), + agents, + model: modelMatch + ? (modelMatch[1] as 'opus' | 'sonnet' | 'haiku') + : undefined, + enabled: enabledMatch ? enabledMatch[1] === 'true' : true, + priority: priorityMatch ? parseInt(priorityMatch[1]) : undefined, + cooldown: cooldownMatch ? cooldownMatch[1].trim() : undefined, + }); + } + } + + return routines; +} + +/** + * Collect all routines from all squads. + */ +export function collectRoutines(): RoutineWithSquad[] { + const squadsDir = findSquadsDir(); + if (!squadsDir) return []; + + const routines: RoutineWithSquad[] = []; + const squadNames = listSquads(squadsDir); + + for (const name of squadNames) { + const squadFile = join(squadsDir, name, 'SQUAD.md'); + const squadRoutines = parseRoutinesFromFile(squadFile); + + for (const routine of squadRoutines) { + routines.push({ ...routine, squad: name }); + } + } + + return routines; +} + +// ── Persistent cooldowns ───────────────────────────────────────────── + +export function loadCooldowns(): Map { + const map = new Map(); + if (!existsSync(COOLDOWN_FILE)) return map; + try { + const data = JSON.parse(readFileSync(COOLDOWN_FILE, 'utf-8')); + for (const [key, ts] of Object.entries(data)) { + if (typeof ts === 'number') map.set(key, ts); + } + } catch { + /* corrupt file — start fresh */ + } + return map; +} + +export function saveCooldowns(map: Map): void { + try { + const obj: Record = {}; + for (const [key, ts] of map) { + obj[key] = ts; + } + writeFileSync(COOLDOWN_FILE, JSON.stringify(obj)); + } catch { + /* best effort */ + } +} + +// ── Cron evaluation ────────────────────────────────────────────────── + /** * Check if a cron expression matches a given date * @param cron - Cron expression (minute hour day month weekday) diff --git a/src/lib/run-modes.ts b/src/lib/run-modes.ts index 3e9a1ad..f49867c 100644 --- a/src/lib/run-modes.ts +++ b/src/lib/run-modes.ts @@ -1,11 +1,22 @@ /** - * Squad execution modes: autopilot, squad loop, lead mode, and post-evaluation. - * Extracted from commands/run.ts to reduce its size. + * Squad execution modes: autopilot (daemon), squad loop, lead mode, and post-evaluation. + * The autopilot mode is the unified daemon: cron routines + intelligence scoring + outcome tracking. + * Consolidated from commands/daemon.ts, commands/autonomous.ts, and the original runAutopilot(). */ -import { spawn } from 'child_process'; +import { spawn, execSync } from 'child_process'; import { join } from 'path'; -import { existsSync, readFileSync } from 'fs'; +import { + existsSync, + readFileSync, + writeFileSync, + unlinkSync, + readdirSync, + mkdirSync, + appendFileSync, + openSync, +} from 'fs'; +import { homedir } from 'os'; import { type RunOptions, DEFAULT_TIMEOUT_MINUTES, @@ -29,17 +40,27 @@ import { } from './squad-parser.js'; import { type LoopState, + MIN_PHANTOM_DURATION_MS, loadLoopState, saveLoopState, getSquadRepos, scoreSquads, checkCooldown, classifyRunOutcome, + checkNewPRs, + getPRsWithReviewFeedback, + buildReviewTask, pushMemorySignals, slackNotify, computePhases, scoreSquadsForPhase, } from './squad-loop.js'; +import { + recordArtifacts, + gradeExecution, + pollOutcomes, + computeAllScorecards, +} from './outcomes.js'; import { loadCognitionState, saveCognitionState, @@ -71,8 +92,456 @@ import { } from './llm-clis.js'; import { getBridgeUrl } from './env-config.js'; import { classifyAgent } from './conversation.js'; +import { + cronMatches, + getNextCronRun, + parseCooldown, + collectRoutines, + loadCooldowns, + saveCooldowns, +} from './cron.js'; import ora from 'ora'; +// ── Daemon state directory (from autonomous.ts) ───────────────────── + +const DAEMON_DIR = join(homedir(), '.squads'); +const PID_FILE = join(DAEMON_DIR, 'autonomous.pid'); +const DAEMON_LOG = join(DAEMON_DIR, 'autonomous.log'); +const PAUSE_FILE = join(DAEMON_DIR, 'autonomous.paused'); + +// Configuration from env vars +const MAX_CONCURRENT = parseInt(process.env.SQUADS_MAX_CONCURRENT || '5'); +const AGENT_TIMEOUT_MIN = parseInt(process.env.SQUADS_AGENT_TIMEOUT || '30'); +const EVAL_INTERVAL_SEC = parseInt(process.env.SQUADS_EVAL_INTERVAL || '60'); +const AUTO_PAUSE_THRESHOLD = 5; + +// ── Daemon lifecycle (from autonomous.ts) ──────────────────────────── + +function daemonLog(msg: string): void { + const ts = new Date().toISOString(); + try { + appendFileSync(DAEMON_LOG, `[${ts}] ${msg}\n`); + } catch { + /* Can't log — ignore */ + } +} + +/** + * Check if the daemon is paused. + */ +export function isDaemonPaused(): { paused: boolean; reason?: string; since?: string } { + if (!existsSync(PAUSE_FILE)) return { paused: false }; + try { + const data = JSON.parse(readFileSync(PAUSE_FILE, 'utf-8')); + return { paused: true, reason: data.reason, since: data.since }; + } catch { + return { paused: true, reason: 'unknown' }; + } +} + +/** + * Pause the daemon. It stays running but won't spawn new agents. + */ +export function pauseDaemon(reason: string): void { + if (!existsSync(DAEMON_DIR)) { + mkdirSync(DAEMON_DIR, { recursive: true }); + } + writeFileSync(PAUSE_FILE, JSON.stringify({ + reason, + since: new Date().toISOString(), + })); + daemonLog(`PAUSED: ${reason}`); +} + +/** + * Resume the daemon after a pause. + */ +export function resumeDaemon(): void { + try { + unlinkSync(PAUSE_FILE); + } catch { + /* not paused */ + } + daemonLog('RESUMED'); +} + +/** + * Check if the daemon process is running. + */ +export function isDaemonRunning(): { running: boolean; pid?: number } { + if (!existsSync(PID_FILE)) return { running: false }; + + const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim()); + if (isNaN(pid)) return { running: false }; + + try { + process.kill(pid, 0); + return { running: true, pid }; + } catch { + // Stale PID file + try { unlinkSync(PID_FILE); } catch { /* ignore */ } + return { running: false }; + } +} + +/** + * Stop the running daemon. + */ +export function stopDaemon(): boolean { + const status = isDaemonRunning(); + if (!status.running || !status.pid) return false; + + try { + process.kill(status.pid, 'SIGTERM'); + try { unlinkSync(PID_FILE); } catch { /* ignore */ } + return true; + } catch { + return false; + } +} + +/** + * Find the .agents/logs directory for running agent tracking. + */ +function getLogsDir(): string | null { + const squadsDir = findSquadsDir(); + if (!squadsDir) return null; + return join(squadsDir, '..', 'logs'); +} + +/** + * Count currently running agents by checking PID files. + */ +export function getRunningAgents(): { + squad: string; + agent: string; + pid: number; + startedAt: number; + logFile: string; +}[] { + const logsDir = getLogsDir(); + if (!logsDir || !existsSync(logsDir)) return []; + + const running: { + squad: string; + agent: string; + pid: number; + startedAt: number; + logFile: string; + }[] = []; + + let squadDirs: string[]; + try { + squadDirs = readdirSync(logsDir); + } catch { + return []; + } + + for (const squadDir of squadDirs) { + const squadPath = join(logsDir, squadDir); + let files: string[]; + try { + files = readdirSync(squadPath); + } catch { + continue; + } + + for (const file of files) { + if (!file.endsWith('.pid')) continue; + + const pidPath = join(squadPath, file); + try { + const pid = parseInt(readFileSync(pidPath, 'utf-8').trim()); + if (isNaN(pid)) continue; + + // Check if process is alive + try { + process.kill(pid, 0); + } catch { + // Process dead — clean up orphan PID file + try { unlinkSync(pidPath); } catch { /* ignore */ } + continue; + } + + const match = file.match(/^(.+)-(\d+)\.pid$/); + if (!match) continue; + + running.push({ + squad: squadDir, + agent: match[1], + pid, + startedAt: parseInt(match[2]), + logFile: pidPath.replace('.pid', '.log'), + }); + } catch { + continue; + } + } + } + + return running; +} + +/** + * Show daemon status: running state, agents, next routines. + */ +export async function showDaemonStatus(): Promise { + const daemon = isDaemonRunning(); + const routines = collectRoutines(); + const enabled = routines.filter(r => r.enabled !== false); + const running = getRunningAgents(); + + writeLine(`\n ${bold}Daemon Status${RESET}\n`); + + // Daemon status + const pauseStatus = isDaemonPaused(); + if (daemon.running) { + if (pauseStatus.paused) { + writeLine(` ${colors.yellow}●${RESET} Daemon paused ${colors.dim}(PID ${daemon.pid})${RESET}`); + writeLine(` ${colors.yellow}${pauseStatus.reason || 'No reason given'}${RESET} ${colors.dim}since ${pauseStatus.since || 'unknown'}${RESET}`); + } else { + writeLine(` ${colors.green}●${RESET} Daemon running ${colors.dim}(PID ${daemon.pid})${RESET}`); + } + } else { + writeLine(` ${colors.red}●${RESET} Daemon not running`); + } + writeLine(); + + // Running agents + if (running.length > 0) { + writeLine(` ${colors.cyan}Running Agents${RESET}`); + for (const agent of running) { + const runtimeMin = Math.round((Date.now() - agent.startedAt) / 60000); + const timeoutWarning = runtimeMin > AGENT_TIMEOUT_MIN * 0.8 ? ` ${colors.yellow}!${RESET}` : ''; + writeLine(` ${colors.green}●${RESET} ${colors.cyan}${agent.squad}${RESET}/${agent.agent} ${colors.dim}${runtimeMin}min${RESET}${timeoutWarning} ${colors.dim}PID ${agent.pid}${RESET}`); + } + writeLine(); + } + + // Routine summary + writeLine(` ${colors.cyan}Routines${RESET}`); + writeLine(` ${enabled.length} enabled / ${routines.length} total, ${running.length}/${MAX_CONCURRENT} running`); + writeLine(); + + // Next upcoming runs + if (enabled.length > 0) { + writeLine(` ${colors.cyan}Next Runs${RESET}`); + + const now = new Date(); + const nextRuns: { squad: string; routine: string; agent: string; nextRun: Date }[] = []; + + for (const r of enabled) { + const next = getNextCronRun(r.schedule, now); + for (const agent of r.agents) { + nextRuns.push({ squad: r.squad, routine: r.name, agent, nextRun: next }); + } + } + + nextRuns + .sort((a, b) => a.nextRun.getTime() - b.nextRun.getTime()) + .slice(0, 10) + .forEach(run => { + const timeStr = run.nextRun.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const dateStr = run.nextRun.toDateString() === now.toDateString() + ? 'today' + : run.nextRun.toLocaleDateString([], { month: 'short', day: 'numeric' }); + writeLine(` ${colors.dim}${timeStr} ${dateStr}${RESET} ${colors.cyan}${run.squad}${RESET}/${run.agent}`); + }); + } + + writeLine(); + writeLine(` ${colors.dim}Commands:${RESET}`); + writeLine(` ${colors.dim}$ squads run Start daemon${RESET}`); + writeLine(` ${colors.dim}$ squads run --stop Stop daemon${RESET}`); + writeLine(` ${colors.dim}$ squads run --pause Pause (quota/manual)${RESET}`); + writeLine(` ${colors.dim}$ squads run --resume Resume after pause${RESET}`); + writeLine(` ${colors.dim}$ tail -f ${DAEMON_LOG}${RESET}`); + writeLine(); +} + +/** + * Start the daemon as a detached background process. + */ +export async function startDaemon(): Promise { + const status = isDaemonRunning(); + if (status.running) { + writeLine(` ${colors.yellow}Daemon already running (PID ${status.pid})${RESET}`); + writeLine(` ${colors.dim}Log: ${DAEMON_LOG}${RESET}`); + return; + } + + if (!existsSync(DAEMON_DIR)) { + mkdirSync(DAEMON_DIR, { recursive: true }); + } + + const routines = collectRoutines().filter(r => r.enabled !== false); + if (routines.length === 0) { + writeLine(` ${colors.yellow}No enabled routines found.${RESET}`); + writeLine(` ${colors.dim}Add routines to SQUAD.md files under ### Routines section.${RESET}`); + // Continue anyway — daemon also does scoring-based dispatch + } + + // If we're being invoked as the daemon itself (via env var) + if (process.env.SQUADS_DAEMON === '1') { + writeFileSync(PID_FILE, process.pid.toString()); + await daemonLoop(); + await new Promise(() => {}); // Keep alive + return; + } + + // Spawn a detached daemon process + if (!existsSync(DAEMON_LOG)) { + writeFileSync(DAEMON_LOG, ''); + } + const logFd = openSync(DAEMON_LOG, 'a'); + + const child = spawn( + process.execPath, + [process.argv[1], 'run'], + { + cwd: process.cwd(), + detached: true, + stdio: ['ignore', logFd, logFd], + env: { ...process.env, SQUADS_DAEMON: '1' }, + } + ); + child.unref(); + + // Wait for PID file to appear + await new Promise(resolve => setTimeout(resolve, 2000)); + + const check = isDaemonRunning(); + if (check.running) { + writeLine(`\n ${colors.green}Daemon started (PID ${check.pid})${RESET}`); + } else { + writeLine(`\n ${colors.red}Daemon failed to start. Check log:${RESET}`); + writeLine(` ${colors.dim}$ tail -20 ${DAEMON_LOG}${RESET}`); + } + + writeLine(` ${colors.dim}Log: ${DAEMON_LOG}${RESET}`); + + // Show scheduled routines + if (routines.length > 0) { + writeLine(`\n ${colors.cyan}Routines${RESET}`); + for (const r of routines) { + const next = getNextCronRun(r.schedule); + const timeStr = next.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + writeLine(` ${colors.green}●${RESET} ${colors.cyan}${r.squad}${RESET}/${r.name} ${colors.dim}${r.schedule} → ${timeStr}${RESET}`); + } + writeLine(`\n ${colors.dim}${routines.length} routines, max ${MAX_CONCURRENT} concurrent${RESET}`); + } + + writeLine(` ${colors.dim}Stop: squads run --stop${RESET}`); + writeLine(` ${colors.dim}Monitor: tail -f ${DAEMON_LOG}${RESET}\n`); +} + +/** + * The daemon loop: evaluates cron routines + enforces timeouts. + * Runs as a long-lived detached process. + */ +async function daemonLoop(): Promise { + daemonLog('Daemon started'); + + const lastSpawned = loadCooldowns(); + let consecutiveFailures = 0; + + const tick = async () => { + try { + const pauseStatus = isDaemonPaused(); + const running = getRunningAgents(); + + // Enforce timeouts on running agents (even when paused) + for (const agent of running) { + const runtimeMin = (Date.now() - agent.startedAt) / 60000; + if (runtimeMin > AGENT_TIMEOUT_MIN) { + daemonLog(`TIMEOUT: ${agent.squad}/${agent.agent} (PID ${agent.pid}, ${Math.round(runtimeMin)}min)`); + const pidFile = agent.logFile.replace('.log', '.pid'); + try { + process.kill(agent.pid, 'SIGTERM'); + try { unlinkSync(pidFile); } catch { /* ignore */ } + } catch { /* already dead */ } + } + } + + if (pauseStatus.paused) return; // Don't spawn while paused + + const now = new Date(); + now.setSeconds(0, 0); + + const routines = collectRoutines().filter(r => r.enabled !== false); + + for (const routine of routines) { + if (!cronMatches(routine.schedule, now)) continue; + + for (const agentName of routine.agents) { + const key = `${routine.squad}/${agentName}`; + + // Cooldown check + if (routine.cooldown) { + const last = lastSpawned.get(key); + const cooldownMs = parseCooldown(routine.cooldown); + if (last && Date.now() - last < cooldownMs) continue; + } + + // Already running check + const alreadyRunning = running.some( + r => r.squad === routine.squad && r.agent === agentName + ); + if (alreadyRunning) continue; + + // Concurrency check + if (getRunningAgents().length >= MAX_CONCURRENT) { + daemonLog(`SKIP: ${key} — concurrency limit (${MAX_CONCURRENT})`); + continue; + } + + // Spawn + daemonLog(`SPAWN: ${key} (routine: ${routine.name})`); + try { + const modelFlag = routine.model ? `--model ${routine.model}` : ''; + execSync( + `squads run ${routine.squad}/${agentName} --background ${modelFlag} --trigger scheduled`, + { + cwd: process.cwd(), + stdio: 'ignore', + timeout: 10000, + env: { ...process.env, CLAUDECODE: '' }, + } + ); + lastSpawned.set(key, Date.now()); + saveCooldowns(lastSpawned); + consecutiveFailures = 0; + daemonLog(`SPAWNED: ${key}`); + } catch (err) { + consecutiveFailures++; + daemonLog(`ERROR: Failed to spawn ${key} (${consecutiveFailures}/${AUTO_PAUSE_THRESHOLD}): ${err}`); + + if (consecutiveFailures >= AUTO_PAUSE_THRESHOLD) { + pauseDaemon(`Auto-paused: ${consecutiveFailures} consecutive spawn failures (likely quota exhausted)`); + daemonLog(`AUTO-PAUSED: ${consecutiveFailures} consecutive failures. Run 'squads run --resume' when quota resets.`); + } + } + } + } + } catch (err) { + daemonLog(`TICK ERROR: ${err}`); + } + }; + + await tick(); + setInterval(tick, EVAL_INTERVAL_SEC * 1000); + + const cleanup = (signal: string) => { + daemonLog(`Received ${signal}, shutting down`); + saveCooldowns(lastSpawned); + try { unlinkSync(PID_FILE); } catch { /* ignore */ } + process.exit(0); + }; + + process.on('SIGTERM', () => cleanup('SIGTERM')); + process.on('SIGINT', () => cleanup('SIGINT')); +} + // ── Post-run evaluation ───────────────────────────────────────────── // After any squad run, dispatch the COO (company-lead) to evaluate outputs. // This is the feedback loop that makes the system learn. @@ -389,6 +858,93 @@ export async function runAutopilot( const cycleCost = dispatchedSquadNames.length * 1.0; state.dailyCost += cycleCost; + // ── Outcome tracking (from daemon.ts) ── + // Poll outcomes for unsettled records, recompute scorecards + const pollResult = pollOutcomes(ghEnv); + if (pollResult.polled > 0 && options.verbose) { + writeLine(` ${colors.dim}Polled ${pollResult.polled} artifact(s), ${pollResult.settled} newly settled${RESET}`); + } + computeAllScorecards('7d'); + + // Grade completed squad loops and push quality signals + for (const name of completed) { + const repo = squadRepos[name]; + if (!repo) continue; + + const record = recordArtifacts({ + executionId: `autopilot_${name}_${cycleStart}`, + squad: name, + agent: 'squad-loop', + completedAt: new Date().toISOString(), + costUsd: 1.0, + repo, + }, ghEnv); + + if (record) { + const { grade, reason } = gradeExecution(record); + pushCognitionSignal({ + source: 'execution', + signal_type: 'execution_quality', + value: { A: 4, B: 3, C: 2, D: 1, F: 0 }[grade] ?? 0, + unit: 'quality_score', + data: { grade, reason, cost_usd: 1.0 }, + entity_type: 'squad', + entity_id: name, + confidence: 0.9, + }); + + if (options.verbose) { + const gradeColor = grade <= 'B' ? colors.green : grade >= 'D' ? colors.red : colors.yellow; + writeLine(` ${gradeColor}${name} Grade: ${grade}${RESET} ${colors.dim}${reason}${RESET}`); + } + } + + // Check for new PRs from this squad + const newPRs = checkNewPRs(repo, 30, ghEnv); + if (newPRs.length > 0) { + writeLine(` ${icons.success} ${colors.cyan}${name}${RESET} created ${newPRs.length} PR(s):`); + for (const pr of newPRs) { + writeLine(` ${colors.dim}#${pr.number} ${pr.title}${RESET}`); + } + } + } + + // ── PR review reaction (from daemon.ts) ── + // Dispatch agents to address review feedback on bot PRs + if (!options.dryRun) { + for (const repo of Object.values(squadRepos)) { + if (budget > 0 && state.dailyCost >= budget) break; + + const prsWithFeedback = getPRsWithReviewFeedback(repo, ghEnv); + for (const pr of prsWithFeedback) { + const squad = Object.entries(squadRepos).find(([, r]) => r === repo)?.[0]; + if (!squad) continue; + if (budget > 0 && state.dailyCost >= budget) break; + + const task = buildReviewTask(pr); + writeLine(` ${icons.running} Addressing ${pr.comments.length} review comment(s) on ${colors.cyan}${squad}${RESET} PR #${pr.number}`); + + try { + const squadsDir2 = findSquadsDir(); + if (squadsDir2) { + const solverPath = join(squadsDir2, squad, 'issue-solver.md'); + if (existsSync(solverPath)) { + await runAgent('issue-solver', solverPath, squad, { + ...options, + task, + background: false, + eval: false, + }); + } + } + state.dailyCost += 0.50; + } catch (err) { + writeLine(` ${icons.error} ${colors.red}Review-fix failed for ${squad} PR #${pr.number}${RESET}`); + } + } + } + } + // Push memory signals for dispatched squads await pushMemorySignals(dispatchedSquadNames, state, !!options.verbose); @@ -415,13 +971,11 @@ export async function runAutopilot( } // ── Post-run COO evaluation ── - // Evaluate outputs from all dispatched squads (skips if company was the only one) if (dispatchedSquadNames.length > 0) { await runPostEvaluation(dispatchedSquadNames, options); } // ── Cognition: learn from this cycle ── - // Ingest memory → synthesize signals → evaluate decisions → reflect writeLine(` ${colors.dim}Cognition cycle...${RESET}`); const cognitionResult = await runCognitionCycle(dispatchedSquadNames, !!options.verbose); if (cognitionResult.signalsIngested > 0 || cognitionResult.beliefsUpdated > 0 || cognitionResult.reflected) { diff --git a/src/lib/run-types.ts b/src/lib/run-types.ts index c57c47d..f50958f 100644 --- a/src/lib/run-types.ts +++ b/src/lib/run-types.ts @@ -39,6 +39,10 @@ export interface RunOptions { once?: boolean; // Autopilot: run one cycle then exit phased?: boolean; // Autopilot: use dependency-based phase ordering eval?: boolean; // Post-run COO evaluation (default: true, --no-eval to skip) + stop?: boolean; // Daemon: stop running daemon + status?: boolean; // Daemon: show daemon status + pause?: boolean | string; // Daemon: pause (optional reason) + resume?: boolean; // Daemon: resume after pause } /** diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index da21ebb..93d4e94 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -20,12 +20,9 @@ const TELEMETRY_DIR = join(homedir(), '.squads-cli'); const CONFIG_PATH = join(TELEMETRY_DIR, 'telemetry.json'); const EVENTS_PATH = join(TELEMETRY_DIR, 'events.json'); -// Telemetry endpoint - locked to Agents Squads infrastructure -// Users can opt-out but cannot redirect telemetry -const TELEMETRY_ENDPOINT = process.env.SQUADS_TELEMETRY_ENDPOINT || Buffer.from( - 'aHR0cHM6Ly9zcXVhZHMtdGVsZW1ldHJ5LTk3ODg3MTgxNzYxMC51cy1jZW50cmFsMS5ydW4uYXBwL3Bpbmc=', - 'base64' -).toString(); +// Telemetry endpoint — must be set via environment variable +// No hardcoded URLs in public repos (see: engineering#51) +const TELEMETRY_ENDPOINT = process.env.SQUADS_TELEMETRY_ENDPOINT || ''; // API key for endpoint validation — must be set via environment variable // NEVER hardcode API keys in source (see: engineering#51) diff --git a/test/commands/autonomous.test.ts b/test/commands/autonomous.test.ts deleted file mode 100644 index 4a6ce4f..0000000 --- a/test/commands/autonomous.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { Command } from 'commander'; - -// Mock chalk with fluent proxy (autonomous.ts uses chalk directly) -vi.mock('chalk', () => { - const identity = (s: string) => s; - const chalk = new Proxy(identity, { - get: () => new Proxy(identity, { get: () => identity }), - }); - (chalk as Record).bold = identity; - (chalk as Record).green = identity; - (chalk as Record).red = identity; - (chalk as Record).yellow = identity; - (chalk as Record).cyan = identity; - (chalk as Record).gray = identity; - (chalk as Record).dim = identity; - (chalk as Record).white = identity; - return { default: chalk }; -}); - -vi.mock('../../src/lib/terminal.js', () => ({ - writeLine: vi.fn(), -})); - -vi.mock('../../src/lib/squad-parser.js', () => ({ - findSquadsDir: vi.fn(), - listSquads: vi.fn(() => []), - Routine: {}, -})); - -vi.mock('../../src/lib/cron.js', () => ({ - cronMatches: vi.fn(() => false), - getNextCronRun: vi.fn(() => null), - parseCooldown: vi.fn(() => 0), -})); - -// Mock fs module — default: no PID file, no pause file, no running agents -vi.mock('fs', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - existsSync: vi.fn(() => false), - readFileSync: vi.fn(() => ''), - writeFileSync: vi.fn(), - unlinkSync: vi.fn(), - readdirSync: vi.fn(() => []), - mkdirSync: vi.fn(), - appendFileSync: vi.fn(), - openSync: vi.fn(() => 3), - }; -}); - -vi.mock('child_process', () => ({ - spawn: vi.fn(() => ({ - unref: vi.fn(), - pid: 12345, - })), - execSync: vi.fn(), -})); - -import { registerAutonomousCommand } from '../../src/commands/autonomous.js'; -import { writeLine } from '../../src/lib/terminal.js'; -import * as fs from 'fs'; - -const mockWriteLine = vi.mocked(writeLine); -const mockExistsSync = vi.mocked(fs.existsSync); -const mockReadFileSync = vi.mocked(fs.readFileSync); -const mockWriteFileSync = vi.mocked(fs.writeFileSync); -const mockUnlinkSync = vi.mocked(fs.unlinkSync); - -describe('registerAutonomousCommand', () => { - let program: Command; - - beforeEach(() => { - vi.clearAllMocks(); - program = new Command(); - program.exitOverride(); - registerAutonomousCommand(program); - }); - - it('registers the autonomous command', () => { - const cmd = program.commands.find(c => c.name() === 'autonomous'); - expect(cmd).toBeDefined(); - expect(cmd?.description()).toContain('daemon'); - }); - - it('registers all expected subcommands', () => { - const cmd = program.commands.find(c => c.name() === 'autonomous')!; - const names = cmd.commands.map(c => c.name()); - expect(names).toContain('start'); - expect(names).toContain('stop'); - expect(names).toContain('status'); - expect(names).toContain('pause'); - expect(names).toContain('resume'); - }); - - describe('autonomous stop', () => { - it('shows not-running message when no PID file exists', async () => { - mockExistsSync.mockReturnValue(false); - - await program.parseAsync(['node', 'squads', 'autonomous', 'stop']); - - expect(mockWriteLine).toHaveBeenCalledWith( - expect.stringContaining('not running') - ); - }); - - it('shows not-running when PID file has no valid PID', async () => { - mockExistsSync.mockImplementation((p: fs.PathLike) => - String(p).endsWith('autonomous.pid') - ); - mockReadFileSync.mockReturnValue('invalid\n' as unknown as Buffer); - - await program.parseAsync(['node', 'squads', 'autonomous', 'stop']); - - expect(mockWriteLine).toHaveBeenCalledWith( - expect.stringContaining('not running') - ); - }); - }); - - describe('autonomous status', () => { - it('shows daemon not running when no PID file', async () => { - mockExistsSync.mockReturnValue(false); - - await program.parseAsync(['node', 'squads', 'autonomous', 'status']); - - const output = mockWriteLine.mock.calls.flat().join(' '); - expect(output).toContain('not running'); - }); - - it('shows daemon not running when PID file is stale', async () => { - // PID file exists but process.kill throws (stale PID) - mockExistsSync.mockImplementation((p: fs.PathLike) => - String(p).endsWith('autonomous.pid') - ); - mockReadFileSync.mockReturnValue('99999\n' as unknown as Buffer); - - const origKill = process.kill.bind(process); - const mockKill = vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { - if (signal === 0) throw new Error('ESRCH'); - return origKill(pid, signal as NodeJS.Signals); - }); - - await program.parseAsync(['node', 'squads', 'autonomous', 'status']); - - const output = mockWriteLine.mock.calls.flat().join(' '); - expect(output).toContain('not running'); - - mockKill.mockRestore(); - }); - }); - - describe('autonomous pause', () => { - it('writes pause file with reason', async () => { - await program.parseAsync(['node', 'squads', 'autonomous', 'pause', 'quota exceeded']); - - expect(mockWriteFileSync).toHaveBeenCalledWith( - expect.stringContaining('autonomous.paused'), - expect.stringContaining('quota exceeded') - ); - }); - - it('uses default reason when none provided', async () => { - await program.parseAsync(['node', 'squads', 'autonomous', 'pause']); - - expect(mockWriteFileSync).toHaveBeenCalledWith( - expect.stringContaining('autonomous.paused'), - expect.stringContaining('Manual pause') - ); - }); - }); - - describe('autonomous resume', () => { - it('removes pause file', async () => { - await program.parseAsync(['node', 'squads', 'autonomous', 'resume']); - - expect(mockUnlinkSync).toHaveBeenCalledWith( - expect.stringContaining('autonomous.paused') - ); - }); - - it('does not throw if pause file does not exist', async () => { - mockUnlinkSync.mockImplementation(() => { - throw new Error('ENOENT'); - }); - - await expect( - program.parseAsync(['node', 'squads', 'autonomous', 'resume']) - ).resolves.toBeDefined(); - }); - }); -}); diff --git a/test/commands/daemon.test.ts b/test/commands/daemon.test.ts deleted file mode 100644 index e03a462..0000000 --- a/test/commands/daemon.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; - -// Heavy mocks must be hoisted before imports -vi.mock('child_process', () => ({ - execSync: vi.fn(), - spawn: vi.fn(() => ({ - stdout: { on: vi.fn() }, - stderr: { on: vi.fn() }, - on: vi.fn(), - pid: 12345, - })), -})); - -vi.mock('crypto', () => ({ - createHash: vi.fn(() => ({ - update: vi.fn().mockReturnThis(), - digest: vi.fn(() => 'abc123abc123abc1'), - })), -})); - -vi.mock('fs', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - existsSync: vi.fn(() => false), - readFileSync: vi.fn(() => '{}'), - writeFileSync: vi.fn(), - mkdirSync: vi.fn(), - readdirSync: vi.fn(() => []), - statSync: vi.fn(() => ({ mtimeMs: Date.now() })), - }; -}); - -vi.mock('../../src/lib/terminal.js', () => ({ - writeLine: vi.fn(), - colors: { - dim: '', - red: '', - green: '', - yellow: '', - purple: '', - cyan: '', - white: '', - blue: '', - magenta: '', - }, - bold: '', - RESET: '', - icons: { - running: '→', - success: '✓', - error: '✗', - warning: '!', - progress: '›', - empty: '○', - paused: '⏸', - }, - gradient: vi.fn((s: string) => s), - padEnd: vi.fn((s: string, n: number) => s.padEnd(n)), -})); - -vi.mock('../../src/lib/squad-parser.js', () => ({ - findSquadsDir: vi.fn(() => null), - listSquads: vi.fn(() => []), -})); - -vi.mock('../../src/lib/memory.js', () => ({ - findMemoryDir: vi.fn(() => null), -})); - -vi.mock('../../src/lib/github.js', () => ({ - getBotGhEnv: vi.fn(async () => ({})), -})); - -vi.mock('../../src/lib/outcomes.js', () => ({ - recordArtifacts: vi.fn(), - pollOutcomes: vi.fn(() => ({ polled: 0, settled: 0 })), - computeAllScorecards: vi.fn(), - getOutcomeScoreModifier: vi.fn(() => 0), -})); - -vi.mock('../../src/lib/api-client.js', () => ({ - pushCognitionSignal: vi.fn(async () => null), - ingestMemorySignal: vi.fn(async () => null), -})); - -import { daemonCommand } from '../../src/commands/daemon.js'; -import { existsSync, readFileSync, writeFileSync } from 'fs'; -import { getBotGhEnv } from '../../src/lib/github.js'; -import { pollOutcomes, computeAllScorecards } from '../../src/lib/outcomes.js'; -import { findSquadsDir } from '../../src/lib/squad-parser.js'; -import { writeLine } from '../../src/lib/terminal.js'; - -const mockExistsSync = vi.mocked(existsSync); -const mockReadFileSync = vi.mocked(readFileSync); -const mockWriteFileSync = vi.mocked(writeFileSync); -const mockGetBotGhEnv = vi.mocked(getBotGhEnv); -const mockPollOutcomes = vi.mocked(pollOutcomes); -const mockComputeAllScorecards = vi.mocked(computeAllScorecards); -const mockFindSquadsDir = vi.mocked(findSquadsDir); -const mockWriteLine = vi.mocked(writeLine); - -describe('daemonCommand', () => { - beforeEach(() => { - vi.clearAllMocks(); - // Default: no state file, no squads dir - mockExistsSync.mockReturnValue(false); - mockReadFileSync.mockReturnValue('{}' as unknown as ReturnType); - mockFindSquadsDir.mockReturnValue(null); - mockGetBotGhEnv.mockResolvedValue({}); - mockPollOutcomes.mockReturnValue({ polled: 0, settled: 0 }); - mockComputeAllScorecards.mockReturnValue(undefined); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('runs one cycle and exits when --once is set', async () => { - await expect(daemonCommand({ once: true })).resolves.toBeUndefined(); - expect(mockGetBotGhEnv).toHaveBeenCalledTimes(1); - }); - - it('calls pollOutcomes once per cycle', async () => { - await expect(daemonCommand({ once: true })).resolves.toBeUndefined(); - expect(mockPollOutcomes).toHaveBeenCalledTimes(1); - }); - - it('computes scorecards with 7d period on each cycle', async () => { - await expect(daemonCommand({ once: true })).resolves.toBeUndefined(); - expect(mockComputeAllScorecards).toHaveBeenCalledWith('7d'); - }); - - it('exits cleanly when no squads are found', async () => { - mockFindSquadsDir.mockReturnValue(null); - await expect(daemonCommand({ once: true })).resolves.toBeUndefined(); - }); - - it('resolves with verbose option enabled', async () => { - await expect(daemonCommand({ once: true, verbose: true })).resolves.toBeUndefined(); - }); - - it('resolves with dry-run option enabled', async () => { - await expect(daemonCommand({ dryRun: true, once: true })).resolves.toBeUndefined(); - }); - - it('uses default interval and parallel values', async () => { - await expect(daemonCommand({ once: true })).resolves.toBeUndefined(); - // Should not throw from parsing defaults - }); - - it('writes status lines on start', async () => { - await expect(daemonCommand({ once: true })).resolves.toBeUndefined(); - expect(mockWriteLine).toHaveBeenCalled(); - }); - - it('saves state after each cycle', async () => { - await expect(daemonCommand({ once: true })).resolves.toBeUndefined(); - expect(mockWriteFileSync).toHaveBeenCalled(); - }); - - it('checks existsSync for state file on cycle start', async () => { - await expect(daemonCommand({ once: true })).resolves.toBeUndefined(); - expect(mockExistsSync).toHaveBeenCalled(); - }); - - it('enforces budget ceiling when daily cost equals budget', async () => { - const today = new Date().toISOString().slice(0, 10); - mockExistsSync.mockImplementation((p) => String(p).endsWith('state.json')); - mockReadFileSync.mockReturnValue( - JSON.stringify({ - lastCycle: '', - dailyCost: 10, - dailyCostDate: today, - recentRuns: [], - failCounts: {}, - memoryHashes: {}, - }) as unknown as ReturnType, - ); - // Budget of $5 with $10 already spent — should halt without dispatching - await expect(daemonCommand({ once: true, budget: '5' })).resolves.toBeUndefined(); - }); - - it('enforces budget ceiling when daily cost exceeds budget', async () => { - const today = new Date().toISOString().slice(0, 10); - mockExistsSync.mockImplementation((p) => String(p).endsWith('state.json')); - mockReadFileSync.mockReturnValue( - JSON.stringify({ - lastCycle: '', - dailyCost: 100, - dailyCostDate: today, - recentRuns: [], - failCounts: {}, - memoryHashes: {}, - }) as unknown as ReturnType, - ); - await expect(daemonCommand({ once: true, budget: '50' })).resolves.toBeUndefined(); - }); - - it('resets daily cost counter when date changes', async () => { - mockExistsSync.mockImplementation((p) => String(p).endsWith('state.json')); - mockReadFileSync.mockReturnValue( - JSON.stringify({ - lastCycle: '', - dailyCost: 99, - dailyCostDate: '2020-01-01', // old date — triggers reset - recentRuns: [], - failCounts: {}, - memoryHashes: {}, - }) as unknown as ReturnType, - ); - await expect(daemonCommand({ once: true }).then(() => { - // Daily cost should have been reset — state file was saved - expect(mockWriteFileSync).toHaveBeenCalled(); - })).resolves.toBeUndefined(); - }); - - it('does not enforce budget when budget is 0 (subscription mode)', async () => { - const today = new Date().toISOString().slice(0, 10); - mockExistsSync.mockImplementation((p) => String(p).endsWith('state.json')); - mockReadFileSync.mockReturnValue( - JSON.stringify({ - lastCycle: '', - dailyCost: 999, - dailyCostDate: today, - recentRuns: [], - failCounts: {}, - memoryHashes: {}, - }) as unknown as ReturnType, - ); - // budget=0 (default) = unlimited — should proceed past budget check - await expect(daemonCommand({ once: true, budget: '0' })).resolves.toBeUndefined(); - }); - - it('handles missing state file by using default state', async () => { - mockExistsSync.mockReturnValue(false); // state file does not exist - await expect(daemonCommand({ once: true })).resolves.toBeUndefined(); - // writeFileSync called because saveState creates it - expect(mockWriteFileSync).toHaveBeenCalled(); - }); - - it('handles corrupt state file by falling back to default state', async () => { - mockExistsSync.mockImplementation((p) => String(p).endsWith('state.json')); - mockReadFileSync.mockReturnValue('INVALID JSON !!!' as unknown as ReturnType); - await expect(daemonCommand({ once: true })).resolves.toBeUndefined(); - }); -}); diff --git a/test/commands/run-daemon.test.ts b/test/commands/run-daemon.test.ts new file mode 100644 index 0000000..4f02e45 --- /dev/null +++ b/test/commands/run-daemon.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock fs module — default: no PID file, no pause file +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(() => false), + readFileSync: vi.fn(() => ''), + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), + readdirSync: vi.fn(() => []), + mkdirSync: vi.fn(), + appendFileSync: vi.fn(), + openSync: vi.fn(() => 3), + }; +}); + +vi.mock('../../src/lib/terminal.js', () => ({ + writeLine: vi.fn(), + colors: { + dim: '', red: '', green: '', yellow: '', purple: '', + cyan: '', white: '', blue: '', magenta: '', + }, + bold: '', + RESET: '', + icons: { + running: '→', success: '✓', error: '✗', warning: '!', + progress: '›', empty: '○', paused: '⏸', + }, + gradient: vi.fn((s: string) => s), + padEnd: vi.fn((s: string, n: number) => s.padEnd(n)), +})); + +vi.mock('../../src/lib/squad-parser.js', () => ({ + findSquadsDir: vi.fn(() => null), + listSquads: vi.fn(() => []), +})); + +vi.mock('../../src/lib/cron.js', () => ({ + cronMatches: vi.fn(() => false), + getNextCronRun: vi.fn(() => new Date()), + parseCooldown: vi.fn(() => 0), + collectRoutines: vi.fn(() => []), + loadCooldowns: vi.fn(() => new Map()), + saveCooldowns: vi.fn(), +})); + +vi.mock('child_process', () => ({ + spawn: vi.fn(() => ({ + unref: vi.fn(), + pid: 12345, + })), + execSync: vi.fn(), +})); + +import * as fs from 'fs'; +import { writeLine } from '../../src/lib/terminal.js'; +import { + isDaemonRunning, + isDaemonPaused, + pauseDaemon, + resumeDaemon, + stopDaemon, + showDaemonStatus, +} from '../../src/lib/run-modes.js'; + +const mockExistsSync = vi.mocked(fs.existsSync); +const mockReadFileSync = vi.mocked(fs.readFileSync); +const mockWriteFileSync = vi.mocked(fs.writeFileSync); +const mockUnlinkSync = vi.mocked(fs.unlinkSync); +const mockWriteLine = vi.mocked(writeLine); + +describe('daemon lifecycle (run-modes)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockExistsSync.mockReturnValue(false); + }); + + describe('isDaemonRunning', () => { + it('returns false when no PID file exists', () => { + const result = isDaemonRunning(); + expect(result.running).toBe(false); + }); + + it('returns false when PID file has invalid content', () => { + mockExistsSync.mockImplementation((p: fs.PathLike) => + String(p).endsWith('autonomous.pid') + ); + mockReadFileSync.mockReturnValue('invalid\n' as unknown as Buffer); + + const result = isDaemonRunning(); + expect(result.running).toBe(false); + }); + }); + + describe('isDaemonPaused', () => { + it('returns false when no pause file exists', () => { + const result = isDaemonPaused(); + expect(result.paused).toBe(false); + }); + + it('returns true with reason when pause file exists', () => { + mockExistsSync.mockImplementation((p: fs.PathLike) => + String(p).endsWith('autonomous.paused') + ); + mockReadFileSync.mockReturnValue( + JSON.stringify({ reason: 'quota exceeded', since: '2026-03-17T00:00:00Z' }) as unknown as Buffer + ); + + const result = isDaemonPaused(); + expect(result.paused).toBe(true); + expect(result.reason).toBe('quota exceeded'); + }); + }); + + describe('pauseDaemon', () => { + it('writes pause file with reason', () => { + pauseDaemon('quota exceeded'); + + expect(mockWriteFileSync).toHaveBeenCalledWith( + expect.stringContaining('autonomous.paused'), + expect.stringContaining('quota exceeded') + ); + }); + }); + + describe('resumeDaemon', () => { + it('removes pause file', () => { + resumeDaemon(); + + expect(mockUnlinkSync).toHaveBeenCalledWith( + expect.stringContaining('autonomous.paused') + ); + }); + + it('does not throw if pause file does not exist', () => { + mockUnlinkSync.mockImplementation(() => { + throw new Error('ENOENT'); + }); + + expect(() => resumeDaemon()).not.toThrow(); + }); + }); + + describe('stopDaemon', () => { + it('returns false when daemon not running', () => { + const result = stopDaemon(); + expect(result).toBe(false); + }); + }); + + describe('showDaemonStatus', () => { + it('shows not running when no PID file', async () => { + await showDaemonStatus(); + + const output = mockWriteLine.mock.calls.flat().join(' '); + expect(output).toContain('not running'); + }); + }); +}); diff --git a/test/e2e/Dockerfile.first-run b/test/e2e/Dockerfile.first-run index 64ac365..0777b99 100644 --- a/test/e2e/Dockerfile.first-run +++ b/test/e2e/Dockerfile.first-run @@ -23,10 +23,8 @@ RUN git config --global user.name "Test User" && \ git config --global init.defaultBranch main # Install our branch from local tarball (instead of npm registry) -COPY --chown=dev:dev squads-cli-0.2.1.tgz /home/dev/squads-cli-0.2.1.tgz -RUN npm install -g /home/dev/squads-cli-0.2.1.tgz - -# Install Claude Code -RUN npm install -g @anthropic-ai/claude-code@latest +ARG TARBALL=squads-cli-0.2.1.tgz +COPY --chown=dev:dev ${TARBALL} /home/dev/squads-cli.tgz +RUN npm install -g /home/dev/squads-cli.tgz CMD ["bash"]