← back to Wallco Ai

scripts/drunk_animals_daemon.js

91 lines

#!/usr/bin/env node
/**
 * Drunk-animals daemon — runs as a pm2-managed long-lived process and fires
 * scripts/drunk_animals_tick.js exactly every TICK_MS milliseconds. Replaces
 * the launchd plist which got deferred from 4-min to 20-min cadence by
 * macOS power-coalescing on Apple Silicon (idle system + StartInterval is
 * "best effort", not guaranteed).
 *
 * Stops itself past STOP_HOUR PT (default 6 AM) — does not exit, but
 * suppresses tick firings. Resumes the next evening if the daemon is still
 * up (the tick script also has its own outside-window guard, redundant).
 */

const { spawn, execSync } = require('child_process');
const path = require('path');

const TICK_MS    = parseInt(process.env.TICK_MS || '90000', 10);   // 90s default (max-it mode)
const STOP_HOUR  = parseInt(process.env.STOP_HOUR || '6', 10);
const ROOT       = path.join(__dirname, '..');
const TICK_SCRIPT = path.join(__dirname, 'drunk_animals_tick.js');

// Preflight: spoon_all_designs_id_seq.last_value MUST be ≥ max(id), else
// every INSERT ... RETURNING id fails with a duplicate-key error and the
// generator silently drops designs (the 2026-05-13 05:25-05:50 PT incident
// where 6 generations went orphan). Self-heal by setval'ing past max(id).
function preflight() {
  try {
    const raw = execSync(
      `psql dw_unified -At -F'|' -c "SELECT last_value, (SELECT max(id) FROM spoon_all_designs) FROM spoon_all_designs_id_seq;"`,
      { encoding: 'utf8' }
    ).trim();
    const [seqStr, maxStr] = raw.split('|');
    const seq = parseInt(seqStr, 10);
    const max = parseInt(maxStr, 10);
    if (!isFinite(seq) || !isFinite(max)) return;
    if (seq < max) {
      console.log(`[${new Date().toISOString()}] PREFLIGHT FIX: id_seq.last_value=${seq} < max(id)=${max} — self-healing`);
      execSync(`psql dw_unified -At -c "SELECT setval('spoon_all_designs_id_seq', ${max + 1}, false);"`);
    }
  } catch (e) {
    console.error(`[${new Date().toISOString()}] preflight err: ${e.message}`);
  }
}

// Overnight gate removed 2026-05-13 — drunk-animals now fires 24/7 to
// match stoned-animals. Kept the STOP_HOUR / nowPT scaffolding inert
// so re-enabling is one diff if Steve wants the gate back.
function inWindow() { return true; }

let running = false;

function fire() {
  if (running) {
    console.log(`[${new Date().toISOString()}] previous tick still running, skipping`);
    return;
  }
  if (!inWindow()) {
    const h = nowPT().getHours();
    if (h % 4 === 0) console.log(`[${new Date().toISOString()}] outside window (PT hour ${h}), idle`);
    return;
  }
  running = true;
  preflight();
  const t0 = Date.now();
  const child = spawn('node', [TICK_SCRIPT], { stdio: 'inherit', cwd: ROOT });
  // Hard timeout — last night a Replicate call hung for 16 min, blocking
  // 3+ subsequent ticks. Cap at 5 min so the next 4-min tick can fire on
  // time even if Replicate is sick.
  const killTimer = setTimeout(() => {
    if (!child.killed) {
      console.error(`[${new Date().toISOString()}] tick exceeded 5min — sending SIGKILL`);
      try { child.kill('SIGKILL'); } catch {}
    }
  }, 5 * 60_000);
  child.on('exit', code => {
    clearTimeout(killTimer);
    running = false;
    console.log(`[${new Date().toISOString()}] tick done in ${((Date.now()-t0)/1000).toFixed(1)}s (exit ${code})`);
  });
  child.on('error', err => {
    clearTimeout(killTimer);
    running = false;
    console.error(`[${new Date().toISOString()}] tick spawn error:`, err.message);
  });
}

console.log(`[${new Date().toISOString()}] drunk-animals daemon up · TICK_MS=${TICK_MS} STOP_HOUR=${STOP_HOUR}PT`);
console.log(`[${new Date().toISOString()}] first tick in ${TICK_MS/1000}s`);
setInterval(fire, TICK_MS);
// Don't fire on boot — give pm2 a moment, then ride the setInterval cadence