MEGAINFRA-WEBAPP/foreman/bin/runner.mjs
DEls 7719e4f36a feat(foreman): scheduler updates for the runner and mod hooks
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-10 23:52:12 +02:00

343 lines
13 KiB
JavaScript

#!/usr/bin/env node
// Foreman's runner: runs scheduled skills with MiniMax Code (mcode) and writes reports.
// No Claude involved. macOS launchd calls `tick` every minute; the pane calls `run <job>`.
//
// node runner.mjs tick run every job that is due now
// node runner.mjs run <jobId> run one job now (manual)
// node runner.mjs install install the launchd scheduler (every minute)
// node runner.mjs uninstall remove it
// node runner.mjs status print scheduler + jobs status
import { spawn, execFileSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { graceMs, lastSlot, parseSchedule } from '../mod/hooks/schedule.mjs'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') // foreman/
const PROJECT = path.resolve(ROOT, '..')
const JOBS = path.join(ROOT, 'jobs.json')
const STATE = path.join(ROOT, 'state')
const RUNS = path.join(ROOT, 'runs')
const LABEL = 'com.megainfra.foreman'
// launchd won't load or log from an external drive here (home is on one), so the
// agent file lives on the internal disk and the scheduler log in /tmp.
const PLIST_DIR = '/Users/Shared/foreman'
const PLIST = path.join(PLIST_DIR, `${LABEL}.plist`)
const OLD_PLIST = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`)
const SCHED_LOG = '/tmp/foreman-scheduler.log'
const DEFAULT_MCODE = path.join(os.homedir(), '.minimax-code', 'bin', 'mcode')
const HISTORY_MAX = 40
const EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max']
const DEFAULT_EFFORT = 'xhigh'
fs.mkdirSync(STATE, { recursive: true })
fs.mkdirSync(RUNS, { recursive: true })
const readJson = (file, fallback) => {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'))
} catch {
return fallback
}
}
const writeJson = (file, value) => {
const tmp = `${file}.${process.pid}.tmp`
fs.writeFileSync(tmp, JSON.stringify(value, null, 2))
fs.renameSync(tmp, file)
}
const loadConfig = () => readJson(JOBS, { paused: false, jobs: [] })
const stateFile = id => path.join(STATE, `${id}.json`)
const loadState = id => readJson(stateFile(id), { id, status: 'idle', history: [] })
const pidAlive = pid => {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
const log = (...a) => console.log(new Date().toISOString(), ...a)
function skillText(job) {
const p = path.isAbsolute(job.skill) ? job.skill : path.join(ROOT, job.skill)
const raw = fs.readFileSync(p, 'utf8')
return raw.replace(/^---\n[\s\S]*?\n---\n/, '').trim()
}
function buildPrompt(job, startedAt) {
return `${skillText(job)}
---
You are Foreman, the site agent for Mega Infrastructure, ${job.oneOff ? `running a one-off task "${job.name}" handed to you by ${job.requestedBy || 'a Claude agent'}` : `running the job "${job.name}" on a schedule`}.
Run time: ${startedAt.toString()}
Project folder: ${PROJECT}
Report contract (required):
- Your FINAL message is saved as this run's report and shown on Foreman's board.
- Its first line must be exactly \`VERDICT: CLEAR\` when nothing needs a person, or \`VERDICT: ATTENTION (<n> items)\` when something does.
- Then markdown sections: "## Summary" (2-4 lines), "## Needs attention" (a table: reference | problem | suggested action; leave out when CLEAR), "## What I checked".
- Be specific: document numbers, suppliers, amounts, dates. No guesses presented as facts.`
}
function describeInput(input) {
if (!input || typeof input !== 'object') return ''
for (const k of ['path', 'file_path', 'command', 'query', 'url', 'pattern']) {
if (typeof input[k] === 'string') return input[k].replace(/\s+/g, ' ').slice(0, 90)
}
return JSON.stringify(input).slice(0, 90)
}
async function runJob(job, trigger, slot) {
const st = loadState(job.id)
if (st.status === 'running' && st.pid && pidAlive(st.pid)) {
log(`skip ${job.id}: already running (pid ${st.pid})`)
return
}
const startedAt = new Date()
const stamp = startedAt.toISOString().replace(/[:T]/g, '-').slice(0, 16)
const runDir = path.join(RUNS, job.id)
fs.mkdirSync(runDir, { recursive: true })
const reportRel = path.join('runs', job.id, `${stamp}.md`)
const run = {
runId: `${job.id}-${stamp}`,
trigger,
slot: slot ? slot.toISOString() : undefined,
startedAt: startedAt.toISOString(),
toolCount: 0,
activity: [],
}
const save = extra => {
const cur = loadState(job.id)
writeJson(stateFile(job.id), { ...cur, ...extra, id: job.id })
}
save({ status: 'running', pid: process.pid, current: run, ...(slot ? { lastSlot: slot.toISOString() } : {}) })
log(`start ${job.id} (${trigger})`)
let prompt
try {
prompt = buildPrompt(job, startedAt)
} catch (err) {
return finish({ status: 'failed', error: `Could not read the skill file: ${err.message}` })
}
const mcode = job.mcode || loadConfig().mcode || DEFAULT_MCODE
const args = [
'exec', '--cwd', PROJECT, '--input', '-', '--output-format', 'stream-json',
'--permission', job.permission === 'full' ? 'full' : 'smart',
'--effort', EFFORTS.includes(job.effort) ? job.effort : EFFORTS.includes(loadConfig().effort) ? loadConfig().effort : DEFAULT_EFFORT,
'--timeout', `${Math.max(1, Math.min(120, Number(job.timeoutMin) || 20))}m`,
]
const child = spawn(mcode, args, { cwd: PROJECT, stdio: ['pipe', 'pipe', 'pipe'] })
child.stdin.end(prompt)
let output = ''
let error = ''
let stderrTail = ''
let tokensIn = 0
let tokensOut = 0
let model = ''
let lastSave = 0
const activity = (text, kind) => {
run.activity.push({ at: new Date().toISOString(), kind, text: text.slice(0, 160) })
run.activity = run.activity.slice(-30)
if (Date.now() - lastSave > 1000) {
lastSave = Date.now()
save({ current: run })
}
}
let buf = ''
child.stdout.on('data', chunk => {
buf += chunk
let nl
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl).trim()
buf = buf.slice(nl + 1)
if (!line) continue
let ev
try {
ev = JSON.parse(line)
} catch {
continue
}
const item = ev.item
if (ev.type === 'item.completed' && item?.type === 'tool_call') {
run.toolCount++
const c = item.toolCall ?? {}
activity(`${c.name ?? 'tool'} ${describeInput(c.input)}`.trim(), c.status === 2 ? 'tool' : 'tool-fail')
} else if (ev.type === 'item.completed' && item?.type === 'agent_message') {
const text = String(item.content ?? '').replace(/\s+/g, ' ').trim()
if (text) activity(text, 'say')
} else if (ev.type === 'turn.completed') {
tokensIn += Number(ev.usage?.inputTokens ?? 0)
tokensOut += Number(ev.usage?.outputTokens ?? 0)
if (ev.model?.modelId) model = ev.model.modelId
} else if (ev.type === 'exec.completed') {
output = String(ev.result?.output ?? '')
if (ev.result?.status !== 'succeeded') error = `MiniMax ended with status ${ev.result?.status}${ev.result?.error ? `: ${JSON.stringify(ev.result.error)}` : ''}`
}
}
})
child.stderr.on('data', chunk => {
stderrTail = (stderrTail + chunk).slice(-1500)
})
const code = await new Promise(resolve => {
child.on('error', err => {
error = `Could not start MiniMax (${mcode}): ${err.message}`
resolve(-1)
})
child.on('close', resolve)
})
if (!output && !error) error = `MiniMax exited (code ${code}) without a report. ${stderrTail.trim()}`.trim()
return finish({ status: error ? 'failed' : undefined, error })
function finish({ status, error: err }) {
const endedAt = new Date()
const verdictLine = /^\s*VERDICT:\s*(CLEAR|ATTENTION)(?:\s*\((\d+)[^)]*\))?/im.exec(output)
const verdict = verdictLine ? verdictLine[1].toUpperCase() : null
const finalStatus = status ?? (verdict === 'CLEAR' ? 'clear' : 'attention')
const attentionCount = verdictLine?.[2] ? Number(verdictLine[2]) : verdict === 'ATTENTION' ? null : 0
const summaryMatch = /##\s*Summary\s*\n([\s\S]*?)(?:\n##|$)/i.exec(output)
const summary = (summaryMatch?.[1] ?? output.split('\n').slice(1, 4).join(' '))
.replace(/\s+/g, ' ')
.trim()
.slice(0, 300)
const header = [
`# ${job.name}`,
``,
`- Run: ${startedAt.toString()} (${trigger})`,
`- Duration: ${Math.round((endedAt - startedAt) / 1000)}s · ${run.toolCount} tool calls · model ${model || 'unknown'} · tokens ${tokensIn}/${tokensOut}`,
`- Status: ${finalStatus}${verdict ? '' : output ? ' (no VERDICT line found, treated as needing attention)' : ''}`,
``,
`---`,
``,
].join('\n')
fs.writeFileSync(path.join(ROOT, reportRel), header + (err ? `**Run failed:** ${err}\n\n` : '') + (output || ''))
const entry = {
runId: run.runId,
trigger,
slot: run.slot,
startedAt: run.startedAt,
endedAt: endedAt.toISOString(),
status: finalStatus,
attentionCount,
summary: err ? err.slice(0, 300) : summary,
report: reportRel,
toolCount: run.toolCount,
tokensIn,
tokensOut,
model,
}
const cur = loadState(job.id)
writeJson(stateFile(job.id), {
...cur,
id: job.id,
status: finalStatus,
pid: undefined,
current: undefined,
lastRun: entry,
history: [entry, ...(cur.history ?? [])].slice(0, HISTORY_MAX),
})
log(`end ${job.id}: ${finalStatus}`)
}
}
async function tick() {
const cfg = loadConfig()
writeJson(path.join(STATE, 'scheduler.json'), { lastTick: new Date().toISOString(), pid: process.pid, paused: !!cfg.paused })
if (cfg.paused) return
const now = new Date()
const due = []
for (const job of cfg.jobs ?? []) {
if (!job.enabled) continue
const s = parseSchedule(job.schedule)
const slot = lastSlot(s, now)
if (!slot || now - slot > graceMs(s)) continue
const st = loadState(job.id)
if (st.lastSlot && new Date(st.lastSlot) >= slot) continue
due.push({ job, slot })
}
// One at a time keeps MiniMax usage predictable.
for (const { job, slot } of due) await runJob(job, 'schedule', slot)
}
function install() {
const node = process.execPath
const runner = fileURLToPath(import.meta.url)
const plist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>${LABEL}</string>
<key>ProgramArguments</key>
<array><string>${node}</string><string>${runner}</string><string>tick</string></array>
<key>StartInterval</key><integer>60</integer>
<key>RunAtLoad</key><true/>
<key>StandardOutPath</key><string>${SCHED_LOG}</string>
<key>StandardErrorPath</key><string>${SCHED_LOG}</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key><string>${path.dirname(node)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin</string>
<key>HOME</key><string>${os.homedir()}</string>
</dict>
</dict>
</plist>
`
const cfg = loadConfig()
writeJson(JOBS, { ...cfg, node })
fs.mkdirSync(PLIST_DIR, { recursive: true })
fs.rmSync(OLD_PLIST, { force: true })
fs.writeFileSync(PLIST, plist)
const uid = process.getuid()
try {
execFileSync('launchctl', ['bootout', `gui/${uid}/${LABEL}`], { stdio: 'ignore' })
} catch {}
execFileSync('launchctl', ['bootstrap', `gui/${uid}`, PLIST])
console.log(`Scheduler installed: ${PLIST} (runs "tick" every minute, log ${SCHED_LOG}).`)
console.log(`To keep it after a restart: sudo cp ${PLIST} /Library/LaunchAgents/`)
}
function uninstall() {
try {
execFileSync('launchctl', ['bootout', `gui/${process.getuid()}/${LABEL}`], { stdio: 'ignore' })
} catch {}
fs.rmSync(PLIST, { force: true })
fs.rmSync(OLD_PLIST, { force: true })
console.log(`Scheduler removed. If you copied it to /Library/LaunchAgents, also run: sudo rm /Library/LaunchAgents/${LABEL}.plist`)
}
function status() {
const cfg = loadConfig()
let loaded = false
try {
execFileSync('launchctl', ['print', `gui/${process.getuid()}/${LABEL}`], { stdio: 'ignore' })
loaded = true
} catch {}
console.log(`Scheduler: ${loaded ? 'installed' : 'NOT installed'}${cfg.paused ? ' (paused)' : ''}`)
console.log('Last tick:', readJson(path.join(STATE, 'scheduler.json'), {}).lastTick ?? 'never')
for (const job of cfg.jobs ?? []) {
const st = loadState(job.id)
console.log(`- ${job.id} [${job.enabled ? 'on' : 'off'}] "${job.schedule}" → ${st.status}${st.lastRun ? ` (last ${st.lastRun.endedAt})` : ''}`)
}
}
const [cmd, arg] = process.argv.slice(2)
if (cmd === 'tick') await tick()
else if (cmd === 'run') {
const job = (loadConfig().jobs ?? []).find(j => j.id === arg)
if (!job) {
console.error(`No job "${arg}" in ${JOBS}`)
process.exit(1)
}
await runJob(job, 'manual')
} else if (cmd === 'install') install()
else if (cmd === 'uninstall') uninstall()
else if (cmd === 'status') status()
else {
console.log('Usage: node runner.mjs tick | run <jobId> | install | uninstall | status')
process.exit(cmd ? 1 : 0)
}