diff --git a/src/daemon.js b/src/daemon.js new file mode 100644 index 0000000..1711ee2 --- /dev/null +++ b/src/daemon.js @@ -0,0 +1,591 @@ +// Daemon scheduler, worker runner, and local HTTP API. +const fs = require('node:fs'); +const path = require('node:path'); +const http = require('node:http'); +const { spawn, spawnSync } = require('node:child_process'); + +const store = require('./store'); +const { taskPrompt, parseLoopResult } = require('./prompts'); + +const pollMs = 1500; +const maxResultText = 20000; +const runningActivity = new Map(); +const activeWorkers = new Map(); + +let daemonInfo; +let server; +let ticker; +let stopping = false; + +function taskTime(task, field) { + const value = Date.parse(task[field]); + return Number.isFinite(value) ? value : 0; +} + +function taskPriority(task) { + const value = Number(task.priority); + return Number.isFinite(value) ? value : 5; +} + +function sortPending(tasks) { + return tasks.sort((left, right) => ( + taskPriority(left) - taskPriority(right) + || taskTime(left, 'createdAt') - taskTime(right, 'createdAt') + )); +} + +function trimResult(text) { + const value = String(text || '').trim(); + return value.length > maxResultText ? value.slice(-maxResultText) : value; +} + +function eventText(line) { + try { + const event = JSON.parse(line); + const candidates = [ + event.text, + event.message, + event.content, + event.item && event.item.text, + event.item && event.item.content, + ]; + + for (const candidate of candidates) { + if (typeof candidate === 'string') { + return candidate; + } + } + } catch { + } + + return line; +} + +function recordActivity(id, line) { + const text = eventText(line).trim(); + + if (!text) { + return ''; + } + + runningActivity.set(id, { + ts: new Date().toISOString(), + text: text.slice(0, 500), + }); + return text; +} + +function streamLines(stream, onLine) { + let buffered = ''; + stream.setEncoding('utf8'); + + stream.on('data', (chunk) => { + buffered += chunk; + const lines = buffered.split('\n'); + buffered = lines.pop(); + + for (const line of lines) { + onLine(line.replace(/\r$/, '')); + } + }); + + stream.on('end', () => { + if (buffered) { + onLine(buffered.replace(/\r$/, '')); + } + }); +} + +function resolveCodex() { + if (process.platform !== 'win32') { + return 'codex'; + } + + const directories = (process.env.PATH || '').split(path.delimiter).filter(Boolean); + const extensions = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';'); + + for (const directory of directories) { + for (const extension of extensions) { + const candidate = path.join(directory, `codex${extension}`); + + if (fs.existsSync(candidate)) { + return candidate; + } + } + } + + return 'codex'; +} + +function spawnCodex(args, options) { + const executable = resolveCodex(); + + if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(executable)) { + return spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', executable, ...args], options); + } + + return spawn(executable, args, options); +} + +function terminateWorker(child) { + if (!child.pid) { + return; + } + + if (process.platform === 'win32') { + try { + const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true, + }); + killer.on('error', () => {}); + } catch { + } + return; + } + + try { + child.kill('SIGKILL'); + } catch { + } +} + +function stopWorker(child) { + if (!child.pid) { + return; + } + + if (process.platform === 'win32') { + try { + spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { + stdio: 'ignore', + windowsHide: true, + }); + } catch { + } + return; + } + + try { + child.kill('SIGTERM'); + } catch { + } +} + +function readWorkerOutput(outputPath, fallback) { + try { + const output = fs.readFileSync(outputPath, 'utf8'); + return output.trim() ? output : fallback; + } catch { + return fallback; + } finally { + try { + fs.unlinkSync(outputPath); + } catch { + } + } +} + +function fallbackSummary(text, status, timedOut) { + if (timedOut) { + return `Worker timed out after ${store.config.taskTimeoutMin} minutes.`; + } + + const lines = String(text || '').trim().split(/\r?\n/).filter(Boolean); + const lastLine = lines[lines.length - 1]; + + if (lastLine) { + return lastLine.slice(0, 500); + } + + return status === 'done' ? 'Worker completed.' : 'Worker failed.'; +} + +function completeTask(task, details) { + const finishedAt = new Date().toISOString(); + const startedAt = taskTime(task, 'startedAt'); + const parsed = parseLoopResult(details.resultText); + const workerExitedNonzero = !details.forceFailed && !details.timedOut + && (details.exitCode !== 0 || details.signal); + const invalidLoopResult = !details.forceFailed && !details.timedOut + && !workerExitedNonzero && !parsed; + const status = details.forceFailed || details.timedOut || workerExitedNonzero || invalidLoopResult + ? 'failed' + : parsed.status; + const result = { + id: task.id, + status, + summary: invalidLoopResult + ? 'worker exited without a valid LOOP_RESULT' + : parsed && parsed.summary + ? parsed.summary + : fallbackSummary(details.resultText, status, details.timedOut), + resultText: trimResult(details.resultText), + exitCode: details.exitCode, + ...(workerExitedNonzero ? { reason: 'worker_exited_nonzero' } : {}), + durationMs: startedAt ? Math.max(0, Date.now() - startedAt) : 0, + finishedAt, + }; + const completedTask = { + ...task, + status, + finishedAt, + }; + + try { + store.writeResult(result); + store.writeTask(completedTask, 'running'); + store.moveTask(task.id, 'running', 'done'); + if (workerExitedNonzero) { + store.appendEvent('fail', { id: task.id, reason: 'worker_exited_nonzero', code: details.exitCode }); + } else if (invalidLoopResult) { + store.appendEvent('fail', { id: task.id, reason: 'invalid_loop_result' }); + } else { + store.appendEvent('done', { id: task.id, status }); + } + } catch (error) { + console.error(`Failed to finish ${task.id}: ${error.message}`); + } finally { + runningActivity.delete(task.id); + } +} + +function spawnWorker(task) { + const model = task.model || store.config.model || 'gpt-5.6-terra'; + const cwd = task.cwd ? path.resolve(task.cwd) : path.join(store.paths.root, 'workspace'); + const outputPath = path.join(store.paths.results, `.${task.id}.${Date.now()}.last-message.tmp`); + const args = [ + 'exec', + '--json', + '--dangerously-bypass-approvals-and-sandbox', + '--skip-git-repo-check', + '--output-last-message', outputPath, + '--model', model, + '-', + ]; + let child; + + try { + fs.mkdirSync(cwd, { recursive: true }); + child = spawnCodex(args, { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + } catch (error) { + completeTask(task, { + exitCode: null, + forceFailed: true, + resultText: `Worker failed to start: ${error.message}`, + timedOut: false, + }); + return; + } + + let lastTextLine = ''; + let timedOut = false; + let settled = false; + let timeout; + activeWorkers.set(task.id, { child, timeout: null }); + const captureLine = (line) => { + const text = recordActivity(task.id, line); + + if (text) { + lastTextLine = text; + } + }; + const finish = (exitCode, signal, forceFailed = false) => { + if (settled || stopping) { + return; + } + + settled = true; + clearTimeout(timeout); + activeWorkers.delete(task.id); + completeTask(task, { + exitCode, + signal, + forceFailed, + resultText: readWorkerOutput(outputPath, lastTextLine), + timedOut, + }); + }; + + streamLines(child.stdout, captureLine); + streamLines(child.stderr, captureLine); + child.once('error', (error) => { + lastTextLine = `Worker error: ${error.message}`; + finish(null, null, true); + }); + child.once('close', (code, signal) => finish(code, signal)); + child.stdin.on('error', (error) => { + lastTextLine = `Worker input error: ${error.message}`; + }); + + const timeoutMinutes = Math.max(1, Number(store.config.taskTimeoutMin) || 45); + timeout = setTimeout(() => { + timedOut = true; + lastTextLine = `Worker timed out after ${timeoutMinutes} minutes.`; + terminateWorker(child); + }, timeoutMinutes * 60 * 1000); + activeWorkers.get(task.id).timeout = timeout; + + try { + child.stdin.end(taskPrompt(task)); + } catch (error) { + lastTextLine = `Worker input error: ${error.message}`; + finish(null, null, true); + } +} + +function startTask(task) { + const runningTask = { + ...task, + startedAt: new Date().toISOString(), + }; + + try { + store.moveTask(task.id, 'pending', 'running'); + } catch (error) { + console.error(`Failed to start ${task.id}: ${error.message}`); + return; + } + + try { + store.writeTask(runningTask, 'running'); + store.appendEvent('start', { id: task.id }); + runningActivity.set(task.id, { + ts: runningTask.startedAt, + text: 'Worker starting.', + }); + spawnWorker(runningTask); + } catch (error) { + completeTask(runningTask, { + exitCode: null, + forceFailed: true, + resultText: `Worker failed to start: ${error.message}`, + timedOut: false, + }); + } +} + +function fillSlots() { + const maxConcurrent = Math.max(1, Number(store.config.maxConcurrent) || 2); + const runningCount = store.listTasks('running').length; + const slots = Math.max(0, maxConcurrent - runningCount); + + if (!slots) { + return; + } + + const pending = sortPending(store.listTasks('pending')); + + for (const task of pending.slice(0, slots)) { + startTask(task); + } +} + +function send(res, statusCode, body, contentType) { + res.writeHead(statusCode, { 'content-type': contentType }); + res.end(body); +} + +function sendJson(res, statusCode, value) { + send(res, statusCode, JSON.stringify(value), 'application/json; charset=utf-8'); +} + +function daemonState() { + const running = store.listTasks('running').map((task) => ({ + ...task, + lastActivity: runningActivity.get(task.id) || null, + })); + const pending = sortPending(store.listTasks('pending')); + const recent = store.listTasks('done') + .sort((left, right) => taskTime(right, 'finishedAt') - taskTime(left, 'finishedAt')) + .slice(0, 20); + + return { + daemon: { + alive: true, + port: daemonInfo.port, + startedAt: daemonInfo.startedAt, + }, + running, + pending, + recent, + }; +} + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let size = 0; + let body = ''; + + req.setEncoding('utf8'); + req.on('data', (chunk) => { + size += Buffer.byteLength(chunk); + + if (size > 1000000) { + reject(new Error('Request body is too large.')); + req.destroy(); + return; + } + + body += chunk; + }); + req.on('end', () => { + if (!body) { + resolve({}); + return; + } + + try { + resolve(JSON.parse(body)); + } catch { + reject(new Error('Request body must be valid JSON.')); + } + }); + req.on('error', reject); + }); +} + +function serveDashboard(res) { + const dashboardPath = path.join(store.paths.root, 'dashboard', 'index.html'); + + if (fs.existsSync(dashboardPath)) { + send(res, 200, fs.readFileSync(dashboardPath), 'text/html; charset=utf-8'); + return; + } + + send(res, 200, '

AgentLoop daemon is running.

', 'text/html; charset=utf-8'); +} + +async function dispatch(req, res) { + let body; + + try { + body = await readJsonBody(req); + } catch (error) { + sendJson(res, 400, { error: error.message }); + return; + } + + if (!body || typeof body.prompt !== 'string' || !body.prompt.trim()) { + sendJson(res, 400, { error: 'prompt is required.' }); + return; + } + + if (body.engine && body.engine !== 'codex') { + sendJson(res, 400, { error: 'Only codex is supported.' }); + return; + } + + const task = store.enqueueTask({ + prompt: body.prompt, + engine: 'codex', + model: body.model, + cwd: body.cwd, + title: body.title, + priority: body.priority, + source: 'api', + }); + + sendJson(res, 201, { id: task.id }); +} + +async function handleRequest(req, res) { + const requestPath = (req.url || '/').split('?')[0]; + + if (req.method === 'GET' && (requestPath === '/' || requestPath === '/index.html')) { + serveDashboard(res); + return; + } + + if (req.method === 'GET' && requestPath === '/api/state') { + sendJson(res, 200, daemonState()); + return; + } + + if (req.method === 'POST' && requestPath === '/api/dispatch') { + await dispatch(req, res); + return; + } + + sendJson(res, 404, { error: 'Not found.' }); +} + +function tick() { + try { + store.writeHeartbeat(daemonInfo); + fillSlots(); + } catch (error) { + console.error(`Daemon tick failed: ${error.message}`); + } +} + +function stop() { + stopping = true; + const workers = [...activeWorkers.values()]; + + for (const worker of workers) { + clearTimeout(worker.timeout); + } + + for (const worker of workers) { + stopWorker(worker.child); + } + + activeWorkers.clear(); + clearInterval(ticker); + + if (server) { + server.close(); + } +} + +function start() { + store.ensureDirs(); + + daemonInfo = { + pid: process.pid, + port: store.config.dashboardPort, + startedAt: new Date().toISOString(), + }; + + if (!store.acquireHeartbeat(daemonInfo)) { + console.log('AgentLoop daemon is already running.'); + return; + } + + server = http.createServer((req, res) => { + handleRequest(req, res).catch((error) => { + sendJson(res, 500, { error: error.message }); + }); + }); + server.on('error', (error) => { + console.error(`HTTP server failed: ${error.message}`); + process.exitCode = 1; + stop(); + }); + server.listen(daemonInfo.port, '127.0.0.1', () => { + if (stopping) { + return; + } + + console.log(`Dashboard: http://127.0.0.1:${daemonInfo.port}`); + ticker = setInterval(tick, pollMs); + tick(); + }); + + process.once('SIGINT', stop); + process.once('SIGTERM', stop); +} + +if (require.main === module) { + start(); +} + +module.exports = { + start, + fillSlots, +}; diff --git a/src/prompts.js b/src/prompts.js new file mode 100644 index 0000000..161fcc2 --- /dev/null +++ b/src/prompts.js @@ -0,0 +1,94 @@ +// Worker prompt protocol and result parsing. +// blocked/question support arrives with the loop slice. +const allowedStatuses = new Set(['done', 'failed']); + +const PROTOCOL = [ + 'You are an autonomous coding agent.', + 'You will receive one task.', + 'Complete the task fully.', + 'Your final message must end with exactly one line in this form: LOOP_RESULT {"status":"done|failed","summary":"..."}', +].join('\n'); + +function taskPrompt(task) { + return `${PROTOCOL}\n\n${task.prompt || ''}`; +} + +function matchObject(text, start) { + const open = text.indexOf('{', start); + + if (open === -1) { + return null; + } + + let depth = 0; + let quoted = false; + let escaped = false; + + for (let index = open; index < text.length; index += 1) { + const character = text[index]; + + if (quoted) { + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === '"') { + quoted = false; + } + continue; + } + + if (character === '"') { + quoted = true; + } else if (character === '{') { + depth += 1; + } else if (character === '}') { + depth -= 1; + if (depth === 0) { + return text.slice(open, index + 1); + } + } + } + + return null; +} + +function parseLoopResult(text) { + const lines = String(text || '').split(/\r?\n/); + + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index].trim(); + + if (!line.startsWith('LOOP_RESULT')) { + continue; + } + + const json = matchObject(line, 'LOOP_RESULT'.length); + + if (!json) { + continue; + } + + try { + const result = JSON.parse(json); + + if (!result || !allowedStatuses.has(result.status)) { + continue; + } + + return { + status: result.status, + summary: typeof result.summary === 'string' ? result.summary : '', + }; + } catch { + } + } + + return null; +} + +module.exports = { + PROTOCOL, + taskPrompt, + parseLoopResult, +}; diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..22b7bc7 --- /dev/null +++ b/src/store.js @@ -0,0 +1,295 @@ +// Filesystem-backed task state and daemon configuration. +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +const root = path.resolve(__dirname, '..'); +const paths = { + root, + config: path.join(root, 'config.json'), + state: path.join(root, 'state'), + tasks: path.join(root, 'state', 'tasks'), + pending: path.join(root, 'state', 'tasks', 'pending'), + running: path.join(root, 'state', 'tasks', 'running'), + done: path.join(root, 'state', 'tasks', 'done'), + results: path.join(root, 'state', 'results'), + events: path.join(root, 'state', 'events.ndjson'), + daemon: path.join(root, 'state', 'daemon.json'), +}; + +const defaults = { + dashboardPort: 5757, + maxConcurrent: 2, + taskTimeoutMin: 45, + defaultEngine: 'codex', +}; + +function loadConfig() { + let loaded = {}; + + try { + loaded = JSON.parse(fs.readFileSync(paths.config, 'utf8')); + } catch { + loaded = {}; + } + + const config = { + dashboardPort: loaded.dashboardPort ?? defaults.dashboardPort, + maxConcurrent: loaded.maxConcurrent ?? defaults.maxConcurrent, + taskTimeoutMin: loaded.taskTimeoutMin ?? defaults.taskTimeoutMin, + defaultEngine: loaded.defaultEngine ?? defaults.defaultEngine, + }; + + if (loaded.model) { + config.model = loaded.model; + } + + return config; +} + +const config = loadConfig(); + +function ensureDirs() { + for (const directory of [paths.pending, paths.running, paths.done, paths.results]) { + fs.mkdirSync(directory, { recursive: true }); + } +} + +function stageDir(stage) { + if (!['pending', 'running', 'done'].includes(stage)) { + throw new Error(`Unknown task stage: ${stage}`); + } + + return paths[stage]; +} + +function taskPath(id, stage) { + return path.join(stageDir(stage), `${id}.json`); +} + +function writeJsonAtomic(filePath, value) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + + const tempPath = path.join( + path.dirname(filePath), + `.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`, + ); + + fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' }); + + try { + fs.renameSync(tempPath, filePath); + } catch (error) { + if (error.code !== 'EXDEV') { + try { + fs.unlinkSync(tempPath); + } catch { + } + throw error; + } + + fs.copyFileSync(tempPath, filePath); + fs.unlinkSync(tempPath); + } +} + +function moveFile(source, destination) { + try { + fs.renameSync(source, destination); + } catch (error) { + if (error.code !== 'EXDEV') { + throw error; + } + + fs.copyFileSync(source, destination); + fs.unlinkSync(source); + } +} + +function enqueueTask(partial = {}) { + const input = partial && typeof partial === 'object' ? partial : {}; + const task = { + ...input, + id: `t-${crypto.randomBytes(4).toString('hex')}`, + type: 'task', + title: input.title || 'untitled', + priority: input.priority ?? 5, + createdAt: new Date().toISOString(), + source: input.source || 'api', + }; + + writeTask(task, 'pending'); + return task; +} + +function moveTask(id, fromStage, toStage) { + ensureDirs(); + moveFile(taskPath(id, fromStage), taskPath(id, toStage)); +} + +function readTask(id, stage) { + return JSON.parse(fs.readFileSync(taskPath(id, stage), 'utf8')); +} + +function writeTask(task, stage) { + if (!task || !task.id) { + throw new Error('Task id is required.'); + } + + ensureDirs(); + writeJsonAtomic(taskPath(task.id, stage), task); +} + +function listTasks(stage) { + ensureDirs(); + + return fs.readdirSync(stageDir(stage), { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => JSON.parse(fs.readFileSync(path.join(stageDir(stage), entry.name), 'utf8'))); +} + +function writeResult(result) { + if (!result || !result.id) { + throw new Error('Result id is required.'); + } + + ensureDirs(); + writeJsonAtomic(path.join(paths.results, `${result.id}.json`), result); +} + +function appendEvent(type, dataObj = {}) { + ensureDirs(); + const event = { + ...(dataObj && typeof dataObj === 'object' ? dataObj : {}), + type, + ts: new Date().toISOString(), + }; + + fs.appendFileSync(paths.events, `${JSON.stringify(event)}\n`, 'utf8'); + return event; +} + +function heartbeatValue(heartbeat) { + return { + ...heartbeat, + ts: new Date().toISOString(), + }; +} + +function writeExclusiveHeartbeat(value) { + let fd; + + try { + fd = fs.openSync(paths.daemon, 'wx'); + fs.writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); + } finally { + if (fd !== undefined) { + fs.closeSync(fd); + } + } +} + +function acquireHeartbeat(heartbeat) { + ensureDirs(); + const value = heartbeatValue(heartbeat); + + try { + writeExclusiveHeartbeat(value); + return true; + } catch (error) { + if (error.code !== 'EEXIST') { + throw error; + } + } + + const existing = readHeartbeat(); + + if (isAlive(existing)) { + return false; + } + + if (!existing) { + let age; + + try { + age = Date.now() - fs.statSync(paths.daemon).mtimeMs; + } catch { + return false; + } + + if (!Number.isFinite(age) || age < 15000) { + return false; + } + } + + try { + fs.unlinkSync(paths.daemon); + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + } + + try { + writeExclusiveHeartbeat(value); + return true; + } catch (error) { + if (error.code === 'EEXIST') { + return false; + } + throw error; + } +} + +function writeHeartbeat(heartbeat) { + const value = heartbeatValue(heartbeat); + + ensureDirs(); + writeJsonAtomic(paths.daemon, value); + return value; +} + +function readHeartbeat() { + try { + return JSON.parse(fs.readFileSync(paths.daemon, 'utf8')); + } catch { + return null; + } +} + +function isAlive(heartbeat = readHeartbeat()) { + const timestamp = heartbeat && Date.parse(heartbeat.ts); + const pid = heartbeat && Number(heartbeat.pid); + + if (!Number.isFinite(timestamp) || !Number.isInteger(pid) || pid <= 0) { + return false; + } + + if (Date.now() - timestamp >= 15000) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error.code === 'EPERM'; + } +} + +module.exports = { + config, + paths, + ensureDirs, + enqueueTask, + moveTask, + readTask, + writeTask, + listTasks, + writeResult, + appendEvent, + acquireHeartbeat, + writeHeartbeat, + readHeartbeat, + isAlive, +};