Files
agent-loop/demo/laptop/daemon.js
2026-11-12 15:29:12 +08:00

111 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 本地守护进程:跑在用户电脑上,持有仓库、.env、工具链。
// 它不调模型、不存会话,只做一件事:领取工具调用 → 在仓库里执行 → 回传结果。
// 为了演示,每次执行后顺手拍一下 git 快照(status / diff),写进 trace。
const fs = require('node:fs');
const path = require('node:path');
const { spawn, execFileSync } = require('node:child_process');
const { tracer } = require('../shared');
const CLOUD = process.env.CLOUD_URL;
const DEVICE = process.env.DEVICE_ID;
const REPO = process.env.REPO_DIR;
const trace = tracer('laptop-daemon');
let jobSeq = Number(process.env.JOB_SEQ_START || 1);
const done = new Map(); // call_id -> 已执行结果(同一个调用被重发时不重复执行)
const git = (...a) => { try { return execFileSync('git', a, { cwd: REPO, encoding: 'utf8' }); } catch (e) { return e.stdout || ''; } };
function repoSnapshot() {
return { status: git('status', '--short').trim(), diff: git('diff').trim(), head: git('rev-parse', '--short', 'HEAD').trim() };
}
function sh(command, timeoutMs) {
return new Promise((ok) => {
const child = spawn('/bin/sh', ['-c', command], { cwd: REPO, env: process.env });
let out = '';
child.stdout.on('data', (c) => (out += c));
child.stderr.on('data', (c) => (out += c));
const timer = timeoutMs ? setTimeout(() => child.kill('SIGKILL'), timeoutMs) : null;
child.on('exit', (code) => { if (timer) clearTimeout(timer); ok({ code, out, pid: child.pid }); });
});
}
async function runTool(cmd) {
const { tool, input } = cmd;
const file = input.path ? path.join(REPO, input.path) : null;
if (tool === 'read_file') {
const lines = fs.readFileSync(file, 'utf8').split('\n');
return { output: lines.map((l, i) => `${String(i + 1).padStart(3)}| ${l}`).join('\n') };
}
if (tool === 'edit_file') {
const src = fs.readFileSync(file, 'utf8');
const n = src.split(input.old_text).length - 1;
if (n !== 1) return { output: `错误:old_text 出现了 ${n} 次,需要恰好 1 次` };
fs.writeFileSync(file, src.replace(input.old_text, input.new_text));
return { output: `已修改 ${input.path}` };
}
if (tool === 'write_file') {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, input.content);
return { output: `已写入 ${input.path}(${input.content.length} 字节)` };
}
if (tool === 'run_command') {
const r = await sh(input.command, 15000);
trace('process_exit', { command: input.command, child_pid: r.pid, exit_code: r.code });
return { output: `退出码 ${r.code}\n${r.out.slice(-3000)}`, child_pid: r.pid };
}
if (tool === 'start_background') {
const jobId = `job-${jobSeq++}`;
const started = Date.now();
const child = spawn('/bin/sh', ['-c', input.command], { cwd: REPO, env: process.env });
let out = '';
child.stdout.on('data', (c) => (out += c));
child.stderr.on('data', (c) => (out += c));
trace('job_start', { job_id: jobId, command: input.command, child_pid: child.pid, session: cmd.session });
child.on('exit', async (code) => {
trace('job_exit', { job_id: jobId, child_pid: child.pid, exit_code: code, ms: Date.now() - started });
const tail = out.trim().split('\n').slice(-12).join('\n');
await postRetry(`/sessions/${cmd.session}/events`, { type: 'job_finished', job_id: jobId, command: input.command, exit_code: code, tail, ran_on: DEVICE, ran_pid: child.pid, by: `daemon:${process.pid}` });
});
return { output: `已在后台启动 ${jobId}(pid ${child.pid})`, child_pid: child.pid };
}
return { output: `未知工具 ${tool}` };
}
async function postRetry(p, b) {
for (let i = 0; i < 30; i++) {
try { return await (await fetch(CLOUD + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) })).json(); }
catch { await new Promise((r) => setTimeout(r, 1000)); }
}
}
async function handle(cmd) {
const started = Date.now();
let result = done.get(cmd.call_id);
const replay = !!result;
if (!result) {
try { result = await runTool(cmd); } catch (e) { result = { output: `错误:${e.message}` }; }
done.set(cmd.call_id, result);
}
const repo_after = repoSnapshot();
trace('tool_exec', { call_id: cmd.call_id, tool: cmd.tool, input: cmd.input, cwd: REPO, ms: Date.now() - started, replay, output: result.output, child_pid: result.child_pid, repo_after });
await postRetry(`/devices/${DEVICE}/result`, { req_id: cmd.req_id, output: result.output, host: DEVICE, pid: process.pid, repo_after: { status: repo_after.status } });
}
let stopping = false;
async function loop() {
trace('daemon_start', { device: DEVICE, repo: REPO, repo_state: repoSnapshot() });
while (!stopping) {
let cmd = null;
try { cmd = await (await fetch(`${CLOUD}/devices/${DEVICE}/poll`)).json(); }
catch { await new Promise((r) => setTimeout(r, 1000)); continue; }
if (cmd) handle(cmd);
}
}
process.on('SIGTERM', async () => {
stopping = true;
trace('daemon_stop', { device: DEVICE, reason: 'laptop lid closed (SIGTERM)' });
try { await fetch(`${CLOUD}/devices/${DEVICE}/bye`, { method: 'POST' }); } catch {}
process.exit(0);
});
loop();