feat(deploy): 画布 Agent - 独立 yingce-agent 容器并修复运行中断

生产会话不再因 stdout 管道提前关闭被误判失败。设置 YINGCE_AGENT_URL 后只走独立容器,失败不回退到内嵌进程。
This commit is contained in:
ddcat666
2026-09-29 21:41:36 +08:00
parent 3fb363aad2
commit cb146d27a1
25 changed files with 821 additions and 101 deletions

19
yingce-agent/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
# syntax=docker/dockerfile:1.7
FROM node:22.19-alpine
RUN apk add --no-cache ca-certificates wget \
&& addgroup -S app \
&& adduser -S app -G app
WORKDIR /app
COPY backend/agent-runtime/pi/package.json backend/agent-runtime/pi/package-lock.json /app/agent-runtime/
RUN npm ci --omit=dev --ignore-scripts --prefix /app/agent-runtime
COPY backend/agent-runtime/pi/agent-runtime.mjs /app/agent-runtime/agent-runtime.mjs
COPY yingce-agent/server.mjs /app/server.mjs
ENV NODE_ENV=production
USER app
EXPOSE 8081
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -qO- http://127.0.0.1:8081/healthz >/dev/null || exit 1
CMD ["node", "server.mjs"]

7
yingce-agent/README.md Normal file
View File

@@ -0,0 +1,7 @@
# yingce-agent
独立的 Agent Node 运行时。它不连接数据库,也不执行计费、审批或画布写入。
后端在 `YINGCE_AGENT_URL` 非空时把一轮会话交给本服务,并在自己的网络地址上提供一次性 bridge token。Node 只通过该 bridge 把模型、工具和事件交回 Go。远程调用失败时不会回退到后端内嵌进程,避免同一步执行两次。
本地开发不设置 `YINGCE_AGENT_URL`,后端仍在本进程启动 Node。生产 Compose 必须同时提供镜像、`YINGCE_AGENT_TOKEN` 和 `YINGCE_AGENT_BRIDGE_HOST`。

167
yingce-agent/server.mjs Normal file
View File

@@ -0,0 +1,167 @@
import { spawn } from "node:child_process";
import { timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { dirname } from "node:path";
const token = process.env.YINGCE_AGENT_TOKEN || "";
const maxSessions = positiveInt(process.env.MAX_CONCURRENT_SESSIONS, 8, 1000);
const memoryMB = positiveInt(process.env.NODE_MAX_OLD_SPACE_SIZE, 512, 8192);
const runtimePath = process.env.YINGCE_AGENT_RUNTIME || "/app/agent-runtime/agent-runtime.mjs";
const port = Number(process.env.PORT || 8081);
const bodyLimit = 32 * 1024 * 1024;
if (token.length < 32) {
console.error("YINGCE_AGENT_TOKEN must contain at least 32 characters");
process.exit(1);
}
let active = 0;
const waiters = [];
function positiveInt(raw, fallback, max) {
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 1) return fallback;
return Math.min(parsed, max);
}
function authorized(request) {
const actual = Buffer.from(request.headers.authorization || "");
const expected = Buffer.from(`Bearer ${token}`);
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
function acquire(request) {
if (active < maxSessions) {
active += 1;
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const waiter = { resolve, reject };
waiters.push(waiter);
const abort = () => {
const index = waiters.indexOf(waiter);
if (index >= 0) waiters.splice(index, 1);
reject(new Error("Agent request cancelled while queued"));
};
if (request.aborted || request.destroyed) abort();
else request.once("aborted", abort);
});
}
function release() {
const waiter = waiters.shift();
if (waiter) {
waiter.resolve();
return;
}
active = Math.max(0, active - 1);
}
function readBody(request) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
request.on("data", (chunk) => {
size += chunk.length;
if (size > bodyLimit) {
reject(new Error("request body too large"));
request.destroy();
return;
}
chunks.push(chunk);
});
request.on("end", () => resolve(Buffer.concat(chunks)));
request.on("error", reject);
});
}
function writeRuntimeError(response, message) {
if (!response.headersSent) {
response.writeHead(200, { "content-type": "application/x-ndjson" });
}
response.end(`${JSON.stringify({ event: "runtime_error", message })}\n`);
}
const server = createServer(async (request, response) => {
if (request.method === "GET" && request.url === "/healthz") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true, active, limit: maxSessions }));
return;
}
if (request.method !== "POST" || request.url !== "/v1/runs") {
response.writeHead(404, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "not found" }));
return;
}
if (!authorized(request)) {
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "unauthorized" }));
return;
}
let body;
try {
body = await readBody(request);
JSON.parse(body.toString("utf8"));
} catch (error) {
response.writeHead(400, { "content-type": "application/json" });
response.end(JSON.stringify({ error: error instanceof Error ? error.message : "invalid request" }));
return;
}
try {
await acquire(request);
} catch (error) {
response.writeHead(499, { "content-type": "application/json" });
response.end(JSON.stringify({ error: error instanceof Error ? error.message : "cancelled" }));
return;
}
const child = spawn(process.execPath, [`--max-old-space-size=${memoryMB}`, runtimePath], {
cwd: dirname(runtimePath),
env: {
PATH: process.env.PATH || "",
NODE_ENV: "production",
PI_OFFLINE: "1",
},
stdio: ["pipe", "pipe", "pipe"],
});
let released = false;
const finish = () => {
if (released) return;
released = true;
release();
};
let sawRuntimeError = false;
const stop = () => {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
};
request.on("aborted", stop);
response.writeHead(200, { "content-type": "application/x-ndjson" });
child.stdout.on("data", (chunk) => {
const text = chunk.toString("utf8");
if (text.includes('"runtime_error"') || text.includes('"bridge_error"')) sawRuntimeError = true;
response.write(chunk);
});
child.stderr.on("data", (chunk) => process.stderr.write(chunk));
child.on("error", (error) => {
writeRuntimeError(response, error.message);
finish();
});
child.on("close", (code) => {
if (!response.writableEnded) {
if (code && !sawRuntimeError) {
response.write(`${JSON.stringify({ event: "runtime_error", message: `Agent runtime exited with code ${code}` })}\n`);
}
response.end();
}
finish();
});
child.stdin.end(body);
});
server.requestTimeout = 0;
server.headersTimeout = 0;
server.timeout = 0;
server.listen(port, "0.0.0.0", () => {
const address = server.address();
console.log(`YINGCE_AGENT_READY ${address.port}`);
});