scheduler-gateway/src/nodes/executors.js
Liuxinyu176 bbb364cd69 feat: DSH 调度网关(互联网关)v6.0.0 — 四模型合并版 R1+R2+R3
- 零第三方依赖,Node >=20 原生 ESM
- 团队式编排引擎:拆解/路由/执行/审查/合并全真实 LLM
- 阶段心跳、单一权威清单守卫、all-keys-failed 如实上报
- H4 会话视图/amend/watchdog 有界重试/产物区 artifacts.json
- H5 零依赖三栏控制台
2026-10-09 23:20:27 +08:00

264 lines
11 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.

/**
* 节点执行器(H4/H5)。
* - nativeExecute:原生内置 worker(确定性,不冒充 LLM),独立工作目录
* - makeAdapterExecute:兼容转化层,把外部执行器包成统一协议
* kind=cmd 本地白名单命令
* kind=http 任意 OpenAI 兼容端点(真实大模型 worker)
* kind=codex codex CLI 子进程(真实,需本机装 codex;Windows 已兼容 .cmd shim)
* kind=cli 任意本地 agent CLI(--cli 指定,{prompt} 占位符)
* 安全:危险关键词拦截、独立 workspace、spawn 参数数组(无 shell 注入)。
*/
import { spawn } from "node:child_process";
import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from "node:fs";
import { resolve, join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { isDangerousTask } from "../security.js";
import { chatComplete, llmConfig } from "../llm.js";
import { WorkspaceManager } from "../worktree.js";
// 产物回传上限:过小会把长文(如白皮书章节)截断导致内容永久丢失(历史教训:
// task-mt9focod-zj852h 第二部分在 slice(0,2000) 处被切掉尾部)。可用 GW_ARTIFACT_MAX 覆盖。
const ARTIFACT_MAX = Number(process.env.GW_ARTIFACT_MAX) || 20_000;
// 可选:设置 GW_WORKTREE=1 即每任务独立 git worktree(失败自动回退普通目录并记录原因)
const wtEnabled = process.env.GW_WORKTREE === "1";
const wm = wtEnabled
? new WorkspaceManager({
repoRoot: process.env.GW_REPO_ROOT || resolve(process.cwd(), "workspace-repo"),
workspaceRoot: process.env.GW_WORKSPACE_ROOT || resolve(process.cwd(), "workspace"),
})
: null;
let seq = 0;
function taskWorkspace(tag, taskId) {
const dir = resolve(tmpdir(), "gw-live-nodes", `${tag}-${process.pid}`, `${taskId}-${++seq}`);
mkdirSync(dir, { recursive: true });
return dir;
}
function assertSafe(task) {
const d = isDangerousTask(task);
if (d.dangerous) throw new Error(`blocked: dangerous pattern "${d.keyword}"`);
// 压测故障注入标记
if (String(task.prompt || "").includes("__FAIL__")) throw new Error("injected-failure");
}
/** native 直连节点执行器。 */
export async function nativeExecute(task) {
assertSafe(task);
let ws, wtInfo = null;
if (wm) {
wtInfo = wm.acquire(task.id); // {path,mode:'worktree'|'dir',fallbackReason?}
ws = wtInfo.path;
} else {
ws = taskWorkspace("native", task.id);
}
try {
const text = String(task.prompt || task.title || "");
const words = (text.match(/[\w一-龿]+/g) || []).length;
const artifact = `# ${task.title}\n\n${text}\n\n---\nnative worker @ ${new Date().toISOString()}\n词数 ${words}\n`;
writeFileSync(join(ws, "result.md"), artifact);
await new Promise((r) => setTimeout(r, 3 + Math.random() * 10));
return {
output: artifact.slice(0, ARTIFACT_MAX),
evidence: {
workspace: ws,
artifact: "result.md",
words,
isolation: wtInfo ? wtInfo : "temp-dir",
},
executor: "native-builtin",
};
} finally {
// git worktree 保留供审计/diff 取证;临时目录直接清理
if (!wtInfo) {
try {
rmSync(ws, { recursive: true, force: true });
} catch {}
}
}
}
/** Windows:npm 的 .cmd 是跳板,解析出真实 exe/js 直接调用(绕开 cmd.exe 兼容坑)。 */
async function _resolveShimTarget(cmdPath) {
try {
const txt = readFileSync(cmdPath, "utf8");
// shim 里可能先出现 "%dp0%\node.exe"(探测分支),优先取 node_modules 下的真实入口
const re = /"%dp0%\\([^"]+?)"/g;
let m, hit = null, last = null;
while ((m = re.exec(txt))) {
last = m[1];
if (/node_modules/i.test(m[1])) { hit = m[1]; break; }
}
const rel = hit || last;
if (!rel) return null;
const target = join(dirname(cmdPath), rel);
return existsSync(target) ? target : null;
} catch {
return null;
}
}
/** 解析可执行文件的真实路径(Windows npm 全局是 .cmd shim,裸 spawn 会失败)。 */
const _binCache = new Map();
export async function resolveBin(bin) {
if (_binCache.has(bin)) return _binCache.get(bin);
const isWin = process.platform === "win32";
let abs = bin;
try {
const { out } = await runCmd(isWin ? "where" : "which", [bin], null, 5_000);
const hits = out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
// where 会把无扩展名的 bash shim 排在前面,cmd.exe 无法执行它——优先可执行扩展名
let hit = isWin ? hits.find((h) => /\.(cmd|exe|bat)$/i.test(h)) || hits[0] : hits[0];
if (hit && isWin && /\.cmd$/i.test(hit)) {
const real = await _resolveShimTarget(hit);
if (real) hit = real; // claude→claude.exe;codex/gemini→node 脚本
}
if (hit) abs = hit;
} catch {}
_binCache.set(bin, abs);
return abs;
}
/**
* 运行外部 agent CLI(binAbs 来自 resolveBin:真实 exe 或 node 脚本)。
* js 脚本用当前 node 直启;exe 直接 spawn——全程不经 cmd.exe(其控制台句柄会挂起部分 CLI)。
*/
export function runCli(binAbs, argv, input, timeoutMs = 180_000, extraEnv = null) {
const isScript = /\.(js|cjs|mjs)$/i.test(binAbs);
const cmd = isScript ? process.execPath : binAbs;
const finalArgs = isScript ? [binAbs, ...argv] : argv;
return new Promise((resolveP, rejectP) => {
const child = spawn(cmd, finalArgs, {
shell: false,
// 自定义模型接入点:extraEnv 覆盖同名变量(如 ANTHROPIC_BASE_URL/ANTHROPIC_MODEL/OPENAI_API_KEY)
env: extraEnv && Object.keys(extraEnv).length ? { ...process.env, ...extraEnv } : undefined,
});
let out = "", err = "";
const t = setTimeout(() => { child.kill(9); rejectP(new Error("cli-timeout")); }, timeoutMs);
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (err += d));
child.on("error", (e) => { clearTimeout(t); rejectP(e); });
child.on("close", (code) => {
clearTimeout(t);
code === 0 ? resolveP({ out, err }) : rejectP(new Error("exit " + code + ": " + err.slice(0, 300)));
});
// 所有平台都要写完立即关闭 stdin:部分 CLI(如 claude -p)会等待 EOF 才开始处理
if (input != null) { try { child.stdin.write(input); } catch {} }
try { child.stdin.end(); } catch {}
});
}
function runCmd(command, args, input, timeoutMs = 30_000) {
return new Promise((resolveP, rejectP) => {
const child = spawn(command, args, { shell: false });
let out = "",
err = "";
const t = setTimeout(() => {
child.kill(9);
rejectP(new Error("cmd-timeout"));
}, timeoutMs);
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (err += d));
child.on("error", (e) => {
clearTimeout(t);
rejectP(e);
});
child.on("close", (code) => {
clearTimeout(t);
code === 0 ? resolveP({ out, err }) : rejectP(new Error(`exit ${code}: ${err.slice(0, 200)}`));
});
if (input != null) {
child.stdin.write(input);
child.stdin.end();
}
});
}
/** 兼容转化层执行器工厂。 */
export function makeAdapterExecute(kind, opts = {}) {
return async function adapterExecute(task) {
assertSafe(task);
if (kind === "fast") {
// 转化层包装的"外部快速执行单元"(压测用;证明协议转换而非吞吐瓶颈)
await new Promise((r) => setTimeout(r, 2 + Math.random() * 8));
return {
output: `[adapter-fast] processed: ${String(task.prompt || "").slice(0, 60)}`,
evidence: { adapter: "fast" },
executor: "adapter-fast",
};
}
if (kind === "cmd") {
const ws = taskWorkspace("adapter-cmd", task.id);
writeFileSync(join(ws, "input.txt"), String(task.prompt || ""));
const isWin = process.platform === "win32";
const { out } = await runCmd(
isWin ? "cmd" : "sh",
isWin
? ["/c", "echo", `[adapter-cmd] ${task.id}`]
: ["-c", `echo '[adapter-cmd] ${task.id}'`],
null,
10_000,
);
return { output: out.trim(), evidence: { adapter: "cmd", workspace: ws }, executor: "adapter-cmd" };
}
if (kind === "http") {
const cfg = llmConfig();
const r = await chatComplete([
{ role: "system", content: "你是子任务执行者,用 2-3 句中文给出结果。" },
{ role: "user", content: String(task.prompt || task.title) },
]);
if (!r.ok) throw new Error(`upstream ${r.status}/${r.httpStatus}`);
return {
output: r.text.slice(0, ARTIFACT_MAX),
evidence: { adapter: "http", model: cfg.model, latencyMs: r.latencyMs, usage: r.usage },
executor: "adapter-http-llm",
};
}
if (kind === "codex") {
const bin = await resolveBin(opts.codexBin || "codex");
const t0 = Date.now();
// prompt 作为参数传入(跨平台免 stdin;Windows 的 .cmd 经 cmd.exe 包装也能收到)
const { out } = await runCli(
bin,
["exec", "--skip-git-repo-check", String(task.prompt || task.title)],
null,
Number(process.env.GW_CLI_TIMEOUT) || 120_000,
opts.cliEnv,
);
return { output: out.slice(0, ARTIFACT_MAX), evidence: { adapter: "codex-cli", bin, latencyMs: Date.now() - t0 }, executor: "adapter-codex" };
}
if (kind === "cli") {
// 通用 CLI 适配器:任意「提示词进、文本出」的本地 CLI 都能包成节点
// opts.cli 可执行名;opts.cliArgs 支持 {prompt} 占位符(如 "-p {prompt}"),缺省把 prompt 作为最后一个参数
const rawBin = opts.cli || process.env.GW_CLI_BIN;
if (!rawBin) throw new Error("adapter-cli: 缺少 --cli <可执行名>");
const bin = await resolveBin(rawBin);
const prompt = String(task.prompt || task.title || "");
const tmpl = String(opts.cliArgs ?? "").trim();
let argv;
if (tmpl.includes("{prompt}")) {
argv = tmpl.split("{prompt}").length === 2
? [tmpl.split("{prompt}")[0].trim(), prompt, tmpl.split("{prompt}")[1].trim()].filter(Boolean)
: tmpl.split(/\s+/);
} else {
argv = [...(tmpl ? tmpl.split(/\s+/) : []), prompt];
}
const t0 = Date.now();
const { out, err } = await runCli(bin, argv, prompt, Number(process.env.GW_CLI_TIMEOUT) || 180_000, opts.cliEnv);
return {
output: (out || err).slice(0, ARTIFACT_MAX),
evidence: { adapter: "cli", bin, args: argv.map((a) => (String(a).length > 60 ? "[prompt]" : a)), latencyMs: Date.now() - t0 },
executor: "cli:" + bin.split(/[\\/]/).pop(),
};
}
throw new Error(`unknown adapter kind: ${kind}`);
};
}