You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

356 lines
13 KiB

'use strict';
// DSH memory - handoff.js
// 计划落盘 .dsh-memory/ -> spawn `dsh --profile headless "…"` -> 输出采集 -> runs/<runId>.json 持久真源。
// 串行队列:同时最多 1 个 dsh handoff。扩展通过 GET /handoff/:runId 轮询。
const fs = require('fs');
const path = require('path');
const stateDirMod = require('./state-dir');
const { spawn, spawnSync } = require('child_process');
const { ToolError } = require('./guard');
const STDOUT_MAX = 256 * 1024; // stdout 内存上限,超限落盘
const STDERR_MAX = 256 * 1024;
// ---------------------------------------------------------------------------
// node / dsh 可执行定位(复用 DeepSeek Harness 桌面壳的策略)
// ---------------------------------------------------------------------------
function resolveNodeExec(config) {
// 1) 系统 node(与 dsh 原生模块 ABI 一致,桌面壳同款逻辑)
try {
const p = spawnSync('node', ['--version'], { encoding: 'utf8', windowsHide: true, timeout: 8000 });
if (p.status === 0) return { cmd: 'node', env: { ...process.env }, source: 'system node' };
} catch {}
// 2) 回退:Electron as node(开发环境 dsh-runtime 同级)
const electronCandidates = [
path.join('D:', path.sep, 'DeepSeek Harness', 'harness-desktop', 'node_modules', 'electron', 'dist', 'electron.exe'),
'C:\\Program Files\\Microsoft\\VS Code\\Code.exe',
];
for (const c of electronCandidates) {
if (fs.existsSync(c)) {
return { cmd: c, env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, source: 'electron-as-node' };
}
}
return null;
}
function resolveDshBin(config) {
const candidates = [];
if (config.dshBin) candidates.push(String(config.dshBin));
candidates.push(
path.join('D:', path.sep, 'DeepSeek Harness', 'harness-desktop', 'dsh-runtime', 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js')
);
for (const c of candidates) {
try {
if (fs.existsSync(c)) return c;
} catch {}
}
return null;
}
function dshProbe(config) {
const bin = resolveDshBin(config);
const node = resolveNodeExec(config);
return { binFound: Boolean(bin && node), bin, node };
}
// ---------------------------------------------------------------------------
// 状态落盘
// ---------------------------------------------------------------------------
function slugify(text, fallback) {
const s = String(text || '')
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40);
return s || fallback;
}
function tsCompact(d) {
return d.toISOString().replace(/[:.]/g, '-').slice(0, 19);
}
function stateDir(workspace) {
return stateDirMod.stateDirAbs(workspace);
}
function writeStatus(workspace, run) {
const lines = [
'# DSH memory agent-status',
'',
`- status: ${run.status}`,
`- runId: ${run.runId}`,
`- planFile: ${run.planFileRel}`,
`- workspace: ${run.workspace}`,
`- startedAt: ${run.startedAt}`,
run.finishedAt ? `- finishedAt: ${run.finishedAt}` : null,
run.exitCode !== undefined && run.exitCode !== null ? `- exitCode: ${run.exitCode}` : null,
run.durationMs !== undefined ? `- durationMs: ${run.durationMs}ms` : null,
run.note ? `- note: ${run.note}` : null,
'',
].filter((x) => x !== null);
fs.writeFileSync(path.join(stateDir(workspace), 'agent-status.md'), lines.join('\n'), 'utf8');
}
function appendLog(workspace, obj) {
try {
fs.appendFileSync(path.join(stateDir(workspace), 'execution-log.jsonl'), JSON.stringify(obj) + '\n', 'utf8');
} catch {}
}
// ---------------------------------------------------------------------------
// Handoff 管理器(单例,由 server.js 持有)
// ---------------------------------------------------------------------------
class HandoffManager {
constructor(config) {
this.config = config;
this.runs = new Map(); // runId -> run(内存真源;完成后持久化到 runs/<runId>.json)
this.current = null; // 串行:同时最多 1 个,忙时 E_BUSY
this.persistIndex = this.config.allowedRoots.map((r) => stateDirMod.runsDir(r));
}
/** 启动一次 handoff(同步返回 runId,异步执行)。 */
start({ workspace, planText, taskNote, timeoutMs }) {
if (this.current) {
throw new ToolError('E_BUSY', '已有 handoff 正在执行(串行队列),请稍后再试');
}
const t = Math.min(
Math.max(30000, Number(timeoutMs) || this.config.handoffTimeoutMs),
this.config.handoffTimeoutMaxMs
);
const probe = dshProbe(this.config);
if (!probe.binFound) {
throw new ToolError(
'E_INTERNAL',
`dsh 运行时未找到(bin/node)。probe=${JSON.stringify(probe)}。请确认 DeepSeek Harness 桌面版已安装,或在 config.json 配置 dshBin。`
);
}
const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const sd = stateDir(workspace);
stateDirMod.ensureStateDir(workspace);
fs.mkdirSync(path.join(sd, 'plans'), { recursive: true });
fs.mkdirSync(path.join(sd, 'outputs'), { recursive: true });
fs.mkdirSync(path.join(sd, 'runs'), { recursive: true });
const stamp = tsCompact(new Date());
const planName = `${stamp}-${slugify(taskNote, 'plan')}.md`;
const planAbs = path.join(sd, 'plans', planName);
fs.writeFileSync(planAbs, planText, 'utf8');
// current-plan.md 为最近一次计划的指针副本
fs.writeFileSync(path.join(sd, 'current-plan.md'), planText, 'utf8');
const run = {
runId,
workspace,
planFileRel: `.dsh-memory/plans/${planName}`,
planAbs,
status: 'running',
startedAt: new Date().toISOString(),
timeoutMs: t,
exitCode: null,
stdout: '',
stdoutTruncated: false,
stdoutFile: null,
stderrFile: null,
stderrLines: 0,
durationMs: null,
note: taskNote || '',
child: null,
};
this.runs.set(runId, run);
this.current = run;
this.persist(run);
writeStatus(workspace, run);
appendLog(workspace, { ts: run.startedAt, event: 'start', runId, planFile: run.planFileRel, timeoutMs: t });
// 任务文本:让 dsh 读取计划文件并执行(CWD=workspace 即工作区根)
const taskText =
`读取计划文件 ${run.planFileRel.replace(/\\/g, '/')} 并完整执行其中的步骤。` +
`当前目录就是工作区根目录。` +
(taskNote ? `任务备注:${taskNote}。` : '') +
`完成后输出:变更文件清单与每处变更的简述。`;
const { bin, node: nodeExec } = probe;
let child;
try {
// dsh 沙箱要求 temp 在 workspace 之外;本机 TMP 可能被项目指到 workspace 内
// (如 HiSpark 的 D:\HiSpark_Studio\temp),故给 dsh 子进程单独覆盖到系统临时目录,
// 避免 "Windows ACL temp root must be outside the workspace"
const env = { ...nodeExec.env, TMP: require('os').tmpdir(), TEMP: require('os').tmpdir() };
child = spawn(nodeExec.cmd, [bin, '--profile', 'headless', taskText], {
cwd: workspace,
env,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
} catch (err) {
run.status = 'failed';
run.note = `spawn 失败:${err.message}`;
run.finishedAt = new Date().toISOString();
this.finalize(run);
throw new ToolError('E_INTERNAL', `dsh 启动失败:${err.message}`);
}
run.child = child;
let stdoutBuf = Buffer.alloc(0);
let stderrBuf = Buffer.alloc(0);
child.stdout.on('data', (c) => {
if (stdoutBuf.length < STDOUT_MAX) {
stdoutBuf = Buffer.concat([stdoutBuf, c]);
if (stdoutBuf.length > STDOUT_MAX) {
stdoutBuf = stdoutBuf.slice(0, STDOUT_MAX);
run.stdoutTruncated = true;
}
} else {
run.stdoutTruncated = true;
}
// 实时尾部:仅供扩展面板显示进度,不回传给模型(防误读半截输出)
run.stdoutLive = stdoutBuf.toString('utf8').slice(-1500);
});
child.stderr.on('data', (c) => {
if (stderrBuf.length < STDERR_MAX) {
stderrBuf = Buffer.concat([stderrBuf, c]);
if (stderrBuf.length > STDERR_MAX) stderrBuf = stderrBuf.slice(0, STDERR_MAX);
}
});
run.timer = setTimeout(() => {
if (run.status === 'running') {
run.note = `超时(${t}ms),已终止进程`;
this.kill(run, 'timeout');
}
}, t);
child.on('error', (err) => {
run.note = `进程错误:${err.message}`;
if (run.status === 'running') this.finish(run, -1, stdoutBuf, stderrBuf);
});
child.on('close', (code) => {
// 超时/取消路径已预置状态,finish 内会保留
this.finish(run, code, stdoutBuf, stderrBuf);
});
return { runId, planFile: run.planFileRel, status: run.status, timeoutMs: t };
}
kill(run, statusAfter) {
try {
spawnSync('taskkill', ['/PID', String(run.child.pid), '/T', '/F'], { windowsHide: true });
} catch {}
run.status = statusAfter; // 'timeout' | 'cancelled'
}
finish(run, code, stdoutBuf, stderrBuf) {
if (run._finished) return;
run._finished = true;
clearTimeout(run.timer);
run.exitCode = code;
run.status = run.status === 'timeout' || run.status === 'cancelled' ? run.status : code === 0 ? 'done' : 'failed';
run.finishedAt = new Date().toISOString();
run.durationMs = Date.parse(run.finishedAt) - Date.parse(run.startedAt);
run.stdout = stdoutBuf.toString('utf8');
const outsDir = path.join(stateDir(run.workspace), 'outputs');
try {
const stamp = tsCompact(new Date());
if (run.stdoutTruncated || run.stdout.length > 64 * 1024) {
const f = path.join(outsDir, `${stamp}-stdout.txt`);
fs.writeFileSync(f, stdoutBuf, 'utf8');
run.stdoutFile = `.dsh-memory/outputs/${stamp}-stdout.txt`;
}
if (stderrBuf.length > 0) {
const f = path.join(outsDir, `${stamp}-stderr.txt`);
fs.writeFileSync(f, stderrBuf, 'utf8');
run.stderrFile = `.dsh-memory/outputs/${stamp}-stderr.txt`;
run.stderrLines = stderrBuf.toString('utf8').split('\n').filter(Boolean).length;
}
} catch {}
this.finalize(run);
}
finalize(run) {
run.child = null;
this.current = null;
this.persist(run);
writeStatus(run.workspace, run);
appendLog(run.workspace, {
ts: run.finishedAt || new Date().toISOString(),
event: 'end',
runId: run.runId,
status: run.status,
exitCode: run.exitCode,
durationMs: run.durationMs,
stdoutBytes: run.stdout ? Buffer.byteLength(run.stdout, 'utf8') : 0,
note: run.note || undefined,
});
}
persist(run) {
try {
const data = { ...run, child: undefined, timer: undefined };
delete data.child;
delete data.timer;
delete data.stdoutLive; // 瞬态进度,不持久化
fs.writeFileSync(path.join(stateDir(run.workspace), 'runs', `${run.runId}.json`), JSON.stringify(data, null, 2), 'utf8');
} catch {}
}
/** 查询:内存优先,桥接重启后回退读各 workspace 的 runs/<runId>.json。 */
get(runId) {
const mem = this.runs.get(runId);
if (mem) return this.serialize(mem);
for (const dir of this.persistIndex) {
const f = path.join(dir, `${runId}.json`);
try {
if (fs.existsSync(f)) return JSON.parse(fs.readFileSync(f, 'utf8'));
} catch {}
}
return null;
}
serialize(run) {
const out = {
runId: run.runId,
status: run.status,
workspace: run.workspace,
planFile: run.planFileRel,
startedAt: run.startedAt,
finishedAt: run.finishedAt || null,
durationMs: run.durationMs ?? null,
exitCode: run.exitCode ?? null,
stdout: run.stdout || '',
stdoutTruncated: Boolean(run.stdoutTruncated),
stdoutFile: run.stdoutFile || null,
stderrFile: run.stderrFile || null,
stderrLines: run.stderrLines || 0,
note: run.note || '',
timeoutMs: run.timeoutMs ?? null,
};
if (run.status === 'running') {
out.elapsedMs = Date.now() - Date.parse(run.startedAt);
out.liveTail = run.stdoutLive || ''; // 面板进度用;模型侧仍只拿终态
out.stdout = ''; // 运行中不回传半截输出,避免模型误读
}
return out;
}
cancel(runId) {
const run = this.runs.get(runId);
if (!run) {
const persisted = this.get(runId);
if (persisted && persisted.status !== 'running') return { ok: true, status: persisted.status, note: '已结束,无需取消' };
throw new ToolError('E_ARGS_INVALID', `未找到运行中的 handoff:${runId}`);
}
if (run.status !== 'running') return { ok: true, status: run.status };
run.status = 'cancelled';
run.note = '用户取消';
this.kill(run, 'cancelled');
run.finishedAt = new Date().toISOString();
run.durationMs = Date.parse(run.finishedAt) - Date.parse(run.startedAt);
this.finalize(run);
return { ok: true, status: 'cancelled' };
}
}
module.exports = { HandoffManager, dshProbe, resolveNodeExec, resolveDshBin };