← back to Animals

agents/animal-yolo/server.js

222 lines

// Animal YOLO Loop — autonomous overnight feature builder.
//
// Picks one task at a time from agents/animal-yolo/task-pool.json, hands it
// to Claude CLI (`claude --print -p "<task>"`), captures the diff, runs a
// quick syntax/sanity check, commits if clean, sleeps, repeats.
//
// Failure modes are by design BENIGN: Claude either edits no files (skip),
// edits files that fail node -c (revert), or edits files that pass — commit.
// The agent NEVER pushes to remote; commits live on the local main branch
// for Steve to review in the morning.
//
// Express HTTP control surface on :9726:
//   GET  /healthz
//   GET  /             — pretty status dashboard
//   GET  /api/status   — JSON
//   POST /api/skip     — abort current task, advance pointer
//   POST /api/pause / /api/resume

import 'dotenv/config';
import express from 'express';
import fs from 'node:fs';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..', '..');
const POOL_PATH = path.join(__dirname, 'task-pool.json');
const STATE_PATH = path.join(__dirname, 'state.json');
const LOG_PATH = path.join(ROOT, 'logs', 'animal-yolo.log');

let paused = false;
let currentTask = null;
let lastResult = null;
let history = [];

function log(...args) {
  const line = `[${new Date().toISOString()}] ${args.join(' ')}`;
  console.log(line);
  fs.appendFileSync(LOG_PATH, line + '\n');
}

function readState() {
  try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); }
  catch { return { cursor: 0, runs: 0, started_at: new Date().toISOString() }; }
}
function writeState(s) { fs.writeFileSync(STATE_PATH, JSON.stringify(s, null, 2)); }

function readPool() {
  return JSON.parse(fs.readFileSync(POOL_PATH, 'utf8'));
}

const app = express();
app.use(express.json({ limit: '128kb' }));

app.get('/healthz', (_req, res) => res.json({ ok: true, paused, currentTask: currentTask?.id || null }));

app.get('/api/status', (_req, res) => {
  const state = readState();
  const pool = readPool();
  res.json({
    paused,
    current: currentTask,
    last: lastResult,
    history: history.slice(-25),
    pool_size: pool.length,
    cursor: state.cursor,
    runs_completed: state.runs,
  });
});

app.post('/api/pause',  (_req, res) => { paused = true;  res.json({ paused }); });
app.post('/api/resume', (_req, res) => { paused = false; res.json({ paused }); });

app.get('/', (_req, res) => {
  const state = readState();
  const pool = readPool();
  res.send(`<!doctype html><html><head><meta charset=utf-8><title>Animal YOLO</title>
<style>body{font:14px -apple-system,sans-serif;background:#0f1117;color:#e4e4e7;padding:20px;max-width:1100px;margin:0 auto}h1{color:#c97a3e}h1 small{font-size:.5em;color:#888;font-weight:400}h2{margin-top:1.5em;color:#0f4d3a}table{width:100%;border-collapse:collapse;background:#1a1d27;border:1px solid #2a2d3a;border-radius:8px;overflow:hidden;margin:10px 0}th,td{padding:8px 12px;text-align:left;border-bottom:1px solid #2a2d3a;font-size:13px}th{background:#22252f;color:#888;text-transform:uppercase;font-size:11px}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}.dot.green{background:#4ade80}.ok{color:#4ade80}.fail{color:#f87171}.skip{color:#888}</style>
</head><body>
<h1>🦴 Animal YOLO <small>${paused?'paused':'<span class="dot green"></span>running'} · cursor ${state.cursor}/${pool.length} · ${state.runs} runs</small></h1>
<form onsubmit="event.preventDefault();fetch(this.action,{method:'POST'}).then(_=>location.reload())" action="${paused?'/api/resume':'/api/pause'}"><button>${paused?'Resume':'Pause'}</button></form>
<h2>Current</h2>
<pre>${currentTask ? JSON.stringify(currentTask, null, 2).replace(/</g,'&lt;') : 'idle'}</pre>
<h2>Recent runs</h2>
<table><tr><th>at</th><th>id</th><th>result</th><th>files changed</th><th>commit</th></tr>
${history.slice(-25).reverse().map(h => `<tr><td>${h.at}</td><td>${h.id}</td><td class="${h.outcome}">${h.outcome}</td><td>${h.files_changed||0}</td><td>${(h.commit_sha||'').slice(0,7)}</td></tr>`).join('')}
</table>
<h2>Task pool (${pool.length})</h2>
<table><tr><th>idx</th><th>id</th><th>title</th></tr>
${pool.map((t, i) => `<tr><td>${i === state.cursor ? '→ ' : ''}${i}</td><td>${t.id}</td><td>${t.title}</td></tr>`).join('')}
</table>
</body></html>`);
});

const PORT = parseInt(process.env.PORT || '9726', 10);
app.listen(PORT, () => log(`animal-yolo listening on :${PORT}`));

// ─── main loop ───────────────────────────────────────────────────────────
const SLEEP_MS = parseInt(process.env.YOLO_SLEEP_MS || String(40 * 60 * 1000), 10);   // 40 min between cycles
const TASK_TIMEOUT_MS = parseInt(process.env.YOLO_TASK_TIMEOUT_MS || String(20 * 60 * 1000), 10);  // 20 min per task

async function loop() {
  while (true) {
    if (paused) { await sleep(15000); continue; }
    try { await runOne(); }
    catch (err) { log('loop iteration error:', err.message); }
    await sleep(SLEEP_MS);
  }
}

async function runOne() {
  const pool = readPool();
  const state = readState();
  if (pool.length === 0) { log('empty pool — sleeping'); return; }
  const task = pool[state.cursor % pool.length];
  currentTask = task;
  log(`▶ ${task.id} — ${task.title}`);

  // Snapshot HEAD before; we'll diff against this after Claude finishes
  const headBefore = git(['rev-parse', 'HEAD']).trim();
  const dirtyBefore = git(['status', '--porcelain']).split('\n').filter(Boolean).length;

  const claude = which('claude');
  if (!claude) {
    log('✗ claude CLI not on PATH; aborting cycle');
    history.push({ at: new Date().toISOString(), id: task.id, outcome: 'skip', error: 'claude not found' });
    advance(); currentTask = null; return;
  }

  // Run Claude in print mode with the prompt; cwd = project root.
  // Claude is allowed to edit + bash but should NOT push to remote.
  const claudeArgs = ['--model', 'opus', '--print', '--permission-mode', 'acceptEdits', '-p', task.prompt];
  const out = await runChild(claude, claudeArgs, ROOT, TASK_TIMEOUT_MS);
  if (out.timed_out) {
    log(`✗ ${task.id} — timed out after ${TASK_TIMEOUT_MS/60000}min`);
    git(['checkout', '--', '.']);                       // discard partial edits
    history.push({ at: new Date().toISOString(), id: task.id, outcome: 'fail', error: 'timeout' });
    advance(); currentTask = null; return;
  }

  // What did Claude touch?
  const dirtyNow = git(['status', '--porcelain']).split('\n').filter(Boolean);
  const newDirty = dirtyNow.length - dirtyBefore;
  log(`  ${newDirty} file(s) changed`);

  if (newDirty <= 0) {
    log(`  no edits; skip`);
    history.push({ at: new Date().toISOString(), id: task.id, outcome: 'skip', files_changed: 0 });
    advance(); currentTask = null; return;
  }

  // Sanity-check: parse all touched .js files
  const touched = dirtyNow
    .map(line => line.slice(3).trim())
    .filter(p => /\.(js|cjs|mjs)$/.test(p));
  let parseFail = false;
  for (const f of touched) {
    const r = spawnSync('node', ['-c', f], { cwd: ROOT });
    if (r.status !== 0) { log(`  parse fail: ${f}`); parseFail = true; break; }
  }
  if (parseFail) {
    log(`  reverting due to parse error`);
    git(['checkout', '--', '.']);
    git(['clean', '-fd']);                                // drop untracked too
    history.push({ at: new Date().toISOString(), id: task.id, outcome: 'fail', error: 'parse_fail', files_changed: newDirty });
    advance(); currentTask = null; return;
  }

  // Stage + commit. Use a -c override so we don't touch global git config.
  git(['add', '-A']);
  const msg = `yolo: ${task.title}\n\nTask: ${task.id}\nPrompt: ${task.prompt.slice(0, 200)}`;
  const commitR = spawnSync('git',
    ['-c', 'user.email=steve@designerwallcoverings.com',
     '-c', 'user.name=animal-yolo',
     'commit', '-m', msg],
    { cwd: ROOT });
  if (commitR.status !== 0) {
    log(`  commit failed: ${commitR.stderr?.toString().slice(0,200)}`);
    history.push({ at: new Date().toISOString(), id: task.id, outcome: 'fail', error: 'commit_fail', files_changed: newDirty });
    advance(); currentTask = null; return;
  }
  const sha = git(['rev-parse', 'HEAD']).trim();
  log(`  ✓ committed ${sha.slice(0,7)}`);

  history.push({ at: new Date().toISOString(), id: task.id, outcome: 'ok', files_changed: newDirty, commit_sha: sha, head_before: headBefore });
  lastResult = history[history.length - 1];
  advance();
  currentTask = null;
}

function advance() {
  const s = readState();
  s.cursor = (s.cursor + 1) % readPool().length;
  s.runs += 1;
  writeState(s);
}

// ─── helpers ──────────────────────────────────────────────────────────────
function git(args) {
  const r = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
  return r.stdout || '';
}
function which(bin) {
  const r = spawnSync('which', [bin], { encoding: 'utf8' });
  return r.status === 0 ? r.stdout.trim() : null;
}
function runChild(cmd, args, cwd, timeoutMs) {
  return new Promise((resolve) => {
    const child = spawn(cmd, args, { cwd, env: process.env });
    let out = '', err = '', timed_out = false;
    const t = setTimeout(() => { timed_out = true; try { child.kill('SIGKILL'); } catch {} }, timeoutMs);
    child.stdout.on('data', d => { out += d.toString(); if (out.length > 60000) out = out.slice(-60000); });
    child.stderr.on('data', d => { err += d.toString(); if (err.length > 30000) err = err.slice(-30000); });
    child.on('close', (code) => { clearTimeout(t); resolve({ code, out, err, timed_out }); });
    child.on('error', () => { clearTimeout(t); resolve({ code: -1, out: '', err: 'spawn-fail', timed_out: false }); });
  });
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));

loop();