343 lines
13 KiB
JavaScript
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)
|
|
}
|