first commit

This commit is contained in:
魏天炜
2026-11-12 15:29:12 +08:00
commit 5dabea54ab
93 changed files with 12170 additions and 0 deletions

80
demo/cloud/loop-worker.js Normal file
View File

@@ -0,0 +1,80 @@
// 云端 Agent Loop Worker:无状态、用完即退。
// 每次被调度器拉起时:读 Session 日志 → 判断下一步 → 调模型或把工具调用发给设备 → 结果写回 Session。
// 进程内存里没有任何必须保存的东西:随时 kill -9,下一个 Worker 读日志就能接上。
const { tracer, pendingWork, rebuildMessages } = require('../shared');
const SESSION = process.env.SESSION_ID;
const CLOUD = process.env.CLOUD_URL;
const GATEWAY = process.env.GATEWAY_URL; // 私有网关,Anthropic Messages 协议
const MODEL = process.env.GATEWAY_MODEL;
const trace = tracer('cloud-worker');
const SYSTEM = [
'你是运行在云端的编码 Agent。用户的代码仓库在用户自己的电脑上,你只能通过工具在那台电脑上读写文件、执行命令。',
'完整测试 `npm test` 含约 12 秒的集成测试,必须用 start_background 在后台运行,启动后直接结束本轮回复、简短告诉用户已启动即可,不要等待;结果会以“[后台任务完成]”消息送回给你。',
'单元测试可以用 run_command 执行 `node --test test/token.test.js`。',
'修改要最小。不要提交 git。回复用中文,简短。',
].join('\n');
const TOOLS = [
{ name: 'read_file', description: '读取用户电脑上仓库里的文件(带行号)', input_schema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } },
{ name: 'edit_file', description: '把文件中唯一出现的 old_text 替换为 new_text', input_schema: { type: 'object', properties: { path: { type: 'string' }, old_text: { type: 'string' }, new_text: { type: 'string' } }, required: ['path', 'old_text', 'new_text'] } },
{ name: 'write_file', description: '创建或覆盖文件', input_schema: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] } },
{ name: 'run_command', description: '在仓库根目录同步执行一条 shell 命令(15 秒超时),返回退出码和输出', input_schema: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] } },
{ name: 'start_background', description: '在仓库根目录后台启动一条命令,立即返回 job_id;完成后结果会作为新消息送回', input_schema: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] } },
];
const get = async (p) => (await fetch(CLOUD + p)).json();
const post = async (p, b) => (await fetch(CLOUD + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) })).json();
const append = (ev) => post(`/sessions/${SESSION}/events`, { ...ev, by: `worker:${process.pid}` });
async function callModel(events) {
const messages = rebuildMessages(events);
const reqBody = { model: MODEL, max_tokens: 2000, system: SYSTEM, tools: TOOLS, messages };
const started = Date.now();
trace('model_request', { session: SESSION, url: GATEWAY + '/v1/messages', model: MODEL, message_count: messages.length, request_bytes: JSON.stringify(reqBody).length, request: reqBody });
const r = await fetch(GATEWAY + '/v1/messages', {
method: 'POST',
headers: { 'content-type': 'application/json', 'anthropic-version': '2023-06-01', Authorization: `Bearer ${process.env.GATEWAY_KEY}` },
body: JSON.stringify(reqBody),
});
const text = await r.text();
if (!r.ok) throw new Error(`gateway ${r.status}: ${text.slice(0, 300)}`);
const d = JSON.parse(text);
trace('model_response', { session: SESSION, ms: Date.now() - started, status: r.status, stop_reason: d.stop_reason, usage: d.usage, response_id: d.id, content: d.content });
return d;
}
async function main() {
trace('worker_boot', { session: SESSION, holds_repo: false, cwd: process.cwd() });
for (let step = 0; step < 12; step++) {
const events = await get(`/sessions/${SESSION}/events`);
const work = pendingWork(events);
trace('worker_decide', { session: SESSION, event_count: events.length, decision: work.kind, open_calls: work.calls?.map((c) => c.id) });
if (work.kind === 'idle') break;
if (work.kind === 'dispatch') {
const { device } = await get(`/sessions/${SESSION}/device`);
for (const call of work.calls) {
const started = Date.now();
const r = await post('/dispatch', { device, session: SESSION, call_id: call.id, tool: call.name, input: call.input });
if (r.offline) {
await append({ type: 'waiting_device', device, call_id: call.id, tool: call.name });
trace('worker_park', { session: SESSION, reason: 'device offline', call_id: call.id });
return; // 不空等:直接退出,设备回来时调度器会再拉起一个 Worker
}
trace('tool_roundtrip', { session: SESSION, call_id: call.id, tool: call.name, ms: Date.now() - started, ran_on: r.host, ran_pid: r.pid });
await append({ type: 'tool_result', call_id: call.id, tool: call.name, output: r.output, ran_on: r.host, ran_pid: r.pid, repo_after: r.repo_after });
}
continue;
}
const d = await callModel(events);
await append({ type: 'model_response', content: d.content, stop_reason: d.stop_reason, usage: d.usage, model: MODEL });
const textOut = d.content.filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim();
if (d.stop_reason !== 'tool_use' && textOut) await append({ type: 'reply_to_user', text: textOut });
}
trace('worker_done', { session: SESSION });
}
main().catch((e) => { trace('worker_error', { session: SESSION, error: String(e) }); process.exit(1); });

136
demo/cloud/server.js Normal file
View File

@@ -0,0 +1,136 @@
// 云端控制面:Session 存储 + 设备通道 + 调度器。
// 它不调模型、不碰代码,只做三件事:
// 1. 把事件追加进 Session(写到 cloud/sessions/<id>.jsonl,相当于数据库)
// 2. 让本地守护进程长轮询领取工具调用、回传结果
// 3. 看到 Session 有活要干、又没有 Worker 在跑时,拉起一个新的 Worker 进程
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { tracer, pendingWork } = require('../shared');
const PORT = Number(process.env.PORT || 7700);
const DATA = process.env.CLOUD_DATA;
const trace = tracer('cloud-server');
fs.mkdirSync(path.join(DATA, 'sessions'), { recursive: true });
// ---------- Session 存储 ----------
const sessFile = (id) => path.join(DATA, 'sessions', `${id}.jsonl`);
function readEvents(id) {
if (!fs.existsSync(sessFile(id))) return [];
return fs.readFileSync(sessFile(id), 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
}
function appendEvent(id, ev) {
const events = readEvents(id);
const rec = { seq: events.length + 1, at: Date.now(), ...ev };
fs.appendFileSync(sessFile(id), JSON.stringify(rec) + '\n');
trace('session_append', { session: id, seq: rec.seq, type: rec.type, bytes: JSON.stringify(rec).length, by: ev.by });
setTimeout(() => schedule(id), 0);
return rec;
}
// ---------- 设备通道 ----------
const devices = {}; // id -> { waiting: res|null, queue: [], pending: Map(reqId -> res), lastSeen }
const dev = (id) => (devices[id] ||= { waiting: null, queue: [], pending: new Map(), lastSeen: 0, online: false });
function isOnline(id) {
const d = devices[id];
return !!(d && d.online && (d.waiting || Date.now() - d.lastSeen < 20000));
}
function flush(d) {
if (d.waiting && d.queue.length) {
const res = d.waiting; d.waiting = null; d.lastSeen = Date.now();
res.end(JSON.stringify(d.queue.shift()));
}
}
// ---------- 调度器 ----------
const running = {}; // session -> pid
const sessionDevice = {}; // session -> device
function schedule(id) {
if (running[id]) return;
const work = pendingWork(readEvents(id));
if (work.kind === 'idle') return;
if (work.kind === 'dispatch' && !isOnline(sessionDevice[id])) return; // 设备不在线:不拉起 Worker,等它回来
const child = spawn(process.execPath, [path.join(__dirname, 'loop-worker.js')], {
cwd: path.join(DATA, 'worker-scratch'),
env: { ...process.env, SESSION_ID: id, CLOUD_URL: `http://127.0.0.1:${PORT}`, HOST_LABEL: 'cloud-worker' },
stdio: ['ignore', 'inherit', 'inherit'],
});
running[id] = child.pid;
trace('worker_spawn', { session: id, worker_pid: child.pid, reason: work.kind });
child.on('exit', (code, signal) => {
trace('worker_exit', { session: id, worker_pid: child.pid, code, signal });
delete running[id];
setTimeout(() => schedule(id), signal ? 800 : 0); // 被杀掉的话稍等一下再看,模拟“故障检测”
});
}
fs.mkdirSync(path.join(DATA, 'worker-scratch'), { recursive: true });
// ---------- HTTP ----------
const body = (req) => new Promise((ok) => { let s = ''; req.on('data', (c) => (s += c)); req.on('end', () => ok(s ? JSON.parse(s) : {})); });
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x');
const p = url.pathname.split('/').filter(Boolean);
const handled = (req.method === 'POST' && p[0] === 'dispatch') || (req.method === 'GET' && p[0] === 'devices' && p[2] === 'poll');
try {
// 用户 / 外部事件 / Worker 往 Session 里追加事件
if (req.method === 'POST' && p[0] === 'sessions' && p[2] === 'events') {
const ev = await body(req);
if (ev.device) sessionDevice[p[1]] = ev.device;
return json(res, 200, appendEvent(p[1], ev));
}
if (req.method === 'GET' && p[0] === 'sessions' && p[2] === 'events') return json(res, 200, readEvents(p[1]));
if (req.method === 'GET' && p[0] === 'sessions' && p[2] === 'device') return json(res, 200, { device: sessionDevice[p[1]], online: isOnline(sessionDevice[p[1]]) });
// Worker 把一次工具调用交给设备,同步等结果;设备不在线立刻返回 offline
if (req.method === 'POST' && p[0] === 'dispatch') {
const cmd = await body(req);
const d = dev(cmd.device);
if (!isOnline(cmd.device)) {
trace('dispatch_offline', { device: cmd.device, call_id: cmd.call_id, tool: cmd.tool });
return json(res, 200, { offline: true });
}
cmd.req_id = `${cmd.call_id}`;
d.pending.set(cmd.req_id, res);
d.queue.push(cmd);
trace('dispatch', { device: cmd.device, call_id: cmd.call_id, tool: cmd.tool, input: cmd.input });
flush(d);
return void 0;
}
// 守护进程长轮询领取命令(设备在 NAT 后面,只能它主动连云端)
if (req.method === 'GET' && p[0] === 'devices' && p[2] === 'poll') {
const d = dev(p[1]);
const wasOnline = isOnline(p[1]);
d.online = true; d.lastSeen = Date.now(); d.waiting = res;
if (!wasOnline) {
trace('device_online', { device: p[1] });
for (const s of Object.keys(sessionDevice)) if (sessionDevice[s] === p[1]) setTimeout(() => schedule(s), 50);
}
req.on('close', () => { if (d.waiting === res) { d.waiting = null; d.lastSeen = Date.now(); } });
flush(d);
setTimeout(() => { if (d.waiting === res) { d.waiting = null; d.lastSeen = Date.now(); res.end('null'); } }, 15000);
return;
}
if (req.method === 'POST' && p[0] === 'devices' && p[2] === 'result') {
const r = await body(req);
const d = dev(p[1]); d.lastSeen = Date.now();
const waiter = d.pending.get(r.req_id);
d.pending.delete(r.req_id);
if (waiter) waiter.end(JSON.stringify(r));
return json(res, 200, { ok: true });
}
if (req.method === 'POST' && p[0] === 'devices' && p[2] === 'bye') {
const d = dev(p[1]); d.online = false;
trace('device_offline', { device: p[1] });
return json(res, 200, { ok: true });
}
if (req.method === 'GET' && p[0] === 'status') return json(res, 200, { running, devices: Object.fromEntries(Object.keys(devices).map((k) => [k, isOnline(k)])) });
if (!res.headersSent && !res.writableEnded && !handled) json(res, 404, { error: 'not found' });
} catch (e) {
console.error('[cloud-server]', e);
if (!res.headersSent) json(res, 500, { error: String(e) });
}
}).listen(PORT, () => trace('server_start', { port: PORT, data_dir: DATA }));