zen-gitsync 2.13.6 → 2.13.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/package.json +1 -1
- package/src/ui/public/assets/{EditorView-BLK-Sohi.js → EditorView-CbfYBgbJ.js} +0 -0
- package/src/ui/public/assets/{SourceMapView-CMJnpleg.js → SourceMapView-CyGL7mqz.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-B6BA_Qbo.css +1 -0
- package/src/ui/public/assets/WorkbenchView-Bu9HDEKs.js +6 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-Dw3U5p9d.js → _plugin-vue_export-helper-CSyhX3vt.js} +2 -2
- package/src/ui/public/assets/{index-DyR_trSU.js → index-BTlhS8hO.js} +4 -4
- package/src/ui/public/assets/{index-CV4VteDa.css → index-p1e3YDoA.css} +1 -1
- package/src/ui/public/index.html +3 -3
- package/src/ui/server/routes/workbench.js +299 -160
- package/src/ui/public/assets/WorkbenchView-CPcGSyVM.css +0 -1
- package/src/ui/public/assets/WorkbenchView-CTB3QNSR.js +0 -6
package/src/ui/public/index.html
CHANGED
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
11
11
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
12
12
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
13
|
-
<script type="module" crossorigin src="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-BTlhS8hO.js"></script>
|
|
14
14
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CMxvf4Kt.js">
|
|
15
15
|
<link rel="modulepreload" crossorigin href="/assets/vendor-C30huq-U.js">
|
|
16
|
-
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-
|
|
16
|
+
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-CSyhX3vt.js">
|
|
17
17
|
<link rel="stylesheet" crossorigin href="/assets/vendor-Bq2rS2vY.css">
|
|
18
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
18
|
+
<link rel="stylesheet" crossorigin href="/assets/index-p1e3YDoA.css">
|
|
19
19
|
</head>
|
|
20
20
|
<body>
|
|
21
21
|
<div id="app"></div>
|
|
@@ -942,187 +942,209 @@ async function runTaskQueue(task, repoPath, branch) {
|
|
|
942
942
|
const priorOutputs = []
|
|
943
943
|
for (const sub of task.subtasks) {
|
|
944
944
|
if (sub.status === 'done') continue;
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
945
|
+
await runSingleSubtask(task, sub, repoPath, branch, priorOutputs)
|
|
946
|
+
}
|
|
947
|
+
// 写回 tasks.json
|
|
948
|
+
await persistTaskAfterRun(task)
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* 执行单个子任务。被 runTaskQueue(整批)和"单 sub 执行"endpoint 共用。
|
|
953
|
+
*
|
|
954
|
+
* @param {object} task 主任务对象
|
|
955
|
+
* @param {object} sub 要跑的子任务
|
|
956
|
+
* @param {string} repoPath 仓库路径
|
|
957
|
+
* @param {string} branch 分支名(可空)
|
|
958
|
+
* @param {Array<{title:string,output:string}>} priorOutputs
|
|
959
|
+
* 前序 done 子任务的输出摘要(in-place 追加)。用于把同一任务下
|
|
960
|
+
* 前面已完成的 sub 产物拼到当前 sub 的 prompt 头部,让 Claude
|
|
961
|
+
* 知道上下文。单独跑一个 sub 时,这个数组里只会有"前面 done 的 sub"。
|
|
962
|
+
*/
|
|
963
|
+
async function runSingleSubtask(task, sub, repoPath, branch, priorOutputs) {
|
|
964
|
+
const MAX_PREV_OUTPUT_CHARS = 2000
|
|
965
|
+
const promptTemplate = sub.promptOverride || (task.promptId
|
|
966
|
+
? (await readJson(PROMPTS_FILE, { prompts: [] })).prompts.find(p => p.id === task.promptId)?.content
|
|
967
|
+
: null) || '';
|
|
968
|
+
const ctx = {
|
|
969
|
+
task: { title: task.title, desc: task.desc || '' },
|
|
970
|
+
sub: { title: sub.title, desc: sub.desc || '' },
|
|
971
|
+
repo: { path: repoPath || '' },
|
|
972
|
+
branch: branch || ''
|
|
973
|
+
};
|
|
974
|
+
const interpolated = interpolate(promptTemplate, ctx);
|
|
975
|
+
const parts = [interpolated, sub.title, sub.desc].filter(s => s && s.trim());
|
|
976
|
+
let prompt = parts.join('\n\n');
|
|
977
|
+
|
|
978
|
+
// ── 前序上下文:把前几个 done 子任务的输出摘要拼到 prompt 头部 ──
|
|
979
|
+
if (priorOutputs && priorOutputs.length > 0) {
|
|
980
|
+
const prevBlock = priorOutputs.map((p, i) => {
|
|
981
|
+
const text = (p.output || '').slice(0, MAX_PREV_OUTPUT_CHARS)
|
|
982
|
+
const truncated = (p.output || '').length > MAX_PREV_OUTPUT_CHARS ? '\n…(前文已截断)' : ''
|
|
983
|
+
return `### [${i + 1}] ${p.title}\n${text}${truncated}`
|
|
984
|
+
}).join('\n\n')
|
|
985
|
+
prompt = `以下是同一任务下已经完成的前序子任务输出(仅作上下文参考,请基于这些结论继续当前子任务,无需重复执行它们):
|
|
966
986
|
|
|
967
987
|
${prevBlock}
|
|
968
988
|
|
|
969
989
|
---
|
|
970
990
|
|
|
971
991
|
${prompt}`
|
|
972
|
-
|
|
992
|
+
}
|
|
973
993
|
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
}
|
|
994
|
+
// ── 附件:合并 sub.attachments + task.attachments 后拼到 prompt 末尾 ──
|
|
995
|
+
// claude -p 字符串模式会扫描 prompt 中出现的本地文件路径并自动
|
|
996
|
+
// 识别为附件(图片 / PDF / 文本均可)。
|
|
997
|
+
// 主任务附件对所有 sub 都可见;子任务自己的附件只对该 sub 可见。
|
|
998
|
+
const allAttachments = [
|
|
999
|
+
...(Array.isArray(task.attachments) ? task.attachments : []),
|
|
1000
|
+
...(Array.isArray(sub.attachments) ? sub.attachments : [])
|
|
1001
|
+
];
|
|
1002
|
+
if (allAttachments.length > 0) {
|
|
1003
|
+
const lines = allAttachments
|
|
1004
|
+
.filter(a => a && a.absolutePath)
|
|
1005
|
+
.map((a, i) => ` ${i + 1}. [${a.mimeType || 'application/octet-stream'}] ${a.absolutePath}`);
|
|
1006
|
+
if (lines.length > 0) {
|
|
1007
|
+
prompt += `\n\n---\n本任务包含 ${lines.length} 个附件(请按文件路径读取,不要让用户重新提供):\n${lines.join('\n')}\n---`;
|
|
989
1008
|
}
|
|
1009
|
+
}
|
|
990
1010
|
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1011
|
+
const jobId = genId();
|
|
1012
|
+
const job = {
|
|
1013
|
+
id: jobId,
|
|
1014
|
+
taskId: task.id,
|
|
1015
|
+
subId: sub.id,
|
|
1016
|
+
title: `${task.title} / ${sub.title}`,
|
|
1017
|
+
status: 'pending',
|
|
1018
|
+
prompt
|
|
1019
|
+
};
|
|
1020
|
+
jobs.set(jobId, job);
|
|
1021
|
+
sub.status = 'running';
|
|
1022
|
+
publish('sub:update', { taskId: task.id, sub });
|
|
1023
|
+
publish('job:update', job);
|
|
1024
|
+
|
|
1025
|
+
try {
|
|
1026
|
+
const { pid, child } = await launchClaudeInNewWindow(repoPath || process.cwd(), prompt);
|
|
1027
|
+
job.pid = pid;
|
|
1028
|
+
// 保存 child 引用,供 cancel 接口调用 kill
|
|
1029
|
+
job.child = child;
|
|
1030
|
+
job.startedAt = nowIso();
|
|
1031
|
+
job.status = 'running';
|
|
1003
1032
|
publish('job:update', job);
|
|
1004
1033
|
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
const
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1034
|
+
// 流式 NDJSON 解析:把 stdout 当作 stream-json 协议处理
|
|
1035
|
+
// assistant.text → job.output (用户主要关心的内容)
|
|
1036
|
+
// assistant.thinking → job.thinking (折叠展示,让用户知道 Claude 在想)
|
|
1037
|
+
// 其他事件(init / tool_use / result 等)忽略,避免噪声
|
|
1038
|
+
// 解析失败的行原样进 output,便于排查协议异常。
|
|
1039
|
+
// thinking 几乎不做服务端截断:Claude reasoning 一般在 KB~几十 MB 之间,
|
|
1040
|
+
// 100MB 兜底只是为了防止内存爆炸。流式 publish 时改推增量 delta(仅新拼接的
|
|
1041
|
+
// 那部分),终态或重连时才随 job:update 全量同步,避免每帧重复广播整个累积文本。
|
|
1042
|
+
const MAX_OUTPUT = 100 * 1024 * 1024;
|
|
1043
|
+
const MAX_THINKING = 100 * 1024 * 1024;
|
|
1044
|
+
job.output = '';
|
|
1045
|
+
job.thinking = '';
|
|
1046
|
+
const lineBuf = { stdout: '', stderr: '' };
|
|
1047
|
+
|
|
1048
|
+
const parseLines = (channel, buf) => {
|
|
1049
|
+
const chunk = buf.toString('utf8');
|
|
1050
|
+
lineBuf[channel] += chunk;
|
|
1051
|
+
const lines = lineBuf[channel].split('\n');
|
|
1052
|
+
lineBuf[channel] = lines.pop() ?? ''; // 最后一段可能不完整,留给下次
|
|
1053
|
+
let pendingThinkingDelta = '';
|
|
1054
|
+
for (const line of lines) {
|
|
1055
|
+
const trimmed = line.trim();
|
|
1056
|
+
if (!trimmed) continue;
|
|
1057
|
+
if (channel === 'stderr' || !trimmed.startsWith('{')) {
|
|
1058
|
+
// 非 stream-json 行:原样塞进 output(兼容老版本 claude / 错误信息)
|
|
1059
|
+
const prevLen = job.output.length;
|
|
1060
|
+
job.output = (job.output + trimmed + '\n').slice(-MAX_OUTPUT);
|
|
1061
|
+
// output 也用 delta 推送,前端按"以 length 为锚追加"语义合并
|
|
1062
|
+
const delta = job.output.slice(prevLen);
|
|
1063
|
+
if (delta) publish('job:output-delta', { id: job.id, delta });
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
let evt;
|
|
1067
|
+
try { evt = JSON.parse(trimmed) } catch { continue }
|
|
1068
|
+
if (evt.type !== 'assistant') continue;
|
|
1069
|
+
const blocks = evt.message?.content;
|
|
1070
|
+
if (!Array.isArray(blocks)) continue;
|
|
1071
|
+
for (const b of blocks) {
|
|
1072
|
+
if (b.type === 'text' && typeof b.text === 'string') {
|
|
1039
1073
|
const prevLen = job.output.length;
|
|
1040
|
-
job.output = (job.output +
|
|
1041
|
-
// output 也用 delta 推送,前端按"以 length 为锚追加"语义合并
|
|
1074
|
+
job.output = (job.output + b.text).slice(-MAX_OUTPUT);
|
|
1042
1075
|
const delta = job.output.slice(prevLen);
|
|
1043
1076
|
if (delta) publish('job:output-delta', { id: job.id, delta });
|
|
1044
|
-
|
|
1077
|
+
} else if (b.type === 'thinking' && typeof b.thinking === 'string') {
|
|
1078
|
+
const prevLen = job.thinking.length;
|
|
1079
|
+
job.thinking = (job.thinking + b.thinking).slice(-MAX_THINKING);
|
|
1080
|
+
const delta = job.thinking.slice(prevLen);
|
|
1081
|
+
if (delta) pendingThinkingDelta += delta;
|
|
1045
1082
|
}
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
// 一批 NDJSON 处理完后统一发一次 thinking delta,避免高频小块 socket 占用
|
|
1086
|
+
if (pendingThinkingDelta) {
|
|
1087
|
+
publish('job:thinking-delta', { id: job.id, delta: pendingThinkingDelta });
|
|
1088
|
+
}
|
|
1089
|
+
};
|
|
1090
|
+
if (child.stdout) child.stdout.on('data', (buf) => parseLines('stdout', buf));
|
|
1091
|
+
if (child.stderr) child.stderr.on('data', (buf) => parseLines('stderr', buf));
|
|
1092
|
+
|
|
1093
|
+
// 等待进程退出(detached 不阻塞主进程,用 polling /proc 兜底)
|
|
1094
|
+
await waitProcessExit(pid);
|
|
1095
|
+
const wasCancelled = cancelledJobs.has(jobId)
|
|
1096
|
+
if (wasCancelled) cancelledJobs.delete(jobId)
|
|
1097
|
+
// 进程退出时 stdout 可能残留最后一段未换行的 NDJSON,flush 一次
|
|
1098
|
+
// flush 内部也按 delta 推送,保持与流式阶段一致
|
|
1099
|
+
if (lineBuf.stdout.trim()) {
|
|
1100
|
+
const outPrev = job.output.length;
|
|
1101
|
+
const thinkPrev = job.thinking.length;
|
|
1102
|
+
try {
|
|
1103
|
+
const evt = JSON.parse(lineBuf.stdout.trim())
|
|
1104
|
+
if (evt.type === 'assistant' && Array.isArray(evt.message?.content)) {
|
|
1105
|
+
for (const b of evt.message.content) {
|
|
1052
1106
|
if (b.type === 'text' && typeof b.text === 'string') {
|
|
1053
|
-
|
|
1054
|
-
job.output = (job.output + b.text).slice(-MAX_OUTPUT);
|
|
1055
|
-
const delta = job.output.slice(prevLen);
|
|
1056
|
-
if (delta) publish('job:output-delta', { id: job.id, delta });
|
|
1107
|
+
job.output = (job.output + b.text).slice(-MAX_OUTPUT)
|
|
1057
1108
|
} else if (b.type === 'thinking' && typeof b.thinking === 'string') {
|
|
1058
|
-
|
|
1059
|
-
job.thinking = (job.thinking + b.thinking).slice(-MAX_THINKING);
|
|
1060
|
-
const delta = job.thinking.slice(prevLen);
|
|
1061
|
-
if (delta) pendingThinkingDelta += delta;
|
|
1109
|
+
job.thinking = (job.thinking + b.thinking).slice(-MAX_THINKING)
|
|
1062
1110
|
}
|
|
1063
1111
|
}
|
|
1064
1112
|
}
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
};
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
const evt = JSON.parse(lineBuf.stdout.trim())
|
|
1084
|
-
if (evt.type === 'assistant' && Array.isArray(evt.message?.content)) {
|
|
1085
|
-
for (const b of evt.message.content) {
|
|
1086
|
-
if (b.type === 'text' && typeof b.text === 'string') {
|
|
1087
|
-
job.output = (job.output + b.text).slice(-MAX_OUTPUT)
|
|
1088
|
-
} else if (b.type === 'thinking' && typeof b.thinking === 'string') {
|
|
1089
|
-
job.thinking = (job.thinking + b.thinking).slice(-MAX_THINKING)
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
} catch { /* 不是 JSON,忽略 */ }
|
|
1094
|
-
const outDelta = job.output.slice(outPrev);
|
|
1095
|
-
if (outDelta) publish('job:output-delta', { id: job.id, delta: outDelta });
|
|
1096
|
-
const thinkDelta = job.thinking.slice(thinkPrev);
|
|
1097
|
-
if (thinkDelta) publish('job:thinking-delta', { id: job.id, delta: thinkDelta });
|
|
1098
|
-
}
|
|
1099
|
-
job.endedAt = nowIso();
|
|
1100
|
-
if (wasCancelled) {
|
|
1101
|
-
job.exitCode = 130; // 128 + SIGINT(2),约定俗成的"用户取消"退出码
|
|
1102
|
-
job.status = 'cancelled';
|
|
1103
|
-
job.error = '用户已停止执行';
|
|
1104
|
-
// sub 不改状态——cancelled 是 job 维度,同 task 后续 sub 仍可继续执行
|
|
1105
|
-
} else {
|
|
1106
|
-
job.exitCode = 0;
|
|
1107
|
-
job.status = 'done';
|
|
1108
|
-
sub.status = 'done';
|
|
1109
|
-
// 把这个 sub 的输出累积到前序上下文,喂给下一个 sub
|
|
1110
|
-
priorOutputs.push({ title: sub.title, output: job.output || '' })
|
|
1111
|
-
}
|
|
1112
|
-
} catch (err) {
|
|
1113
|
-
job.error = err && err.message ? err.message : String(err);
|
|
1114
|
-
job.status = 'error';
|
|
1115
|
-
sub.status = 'error';
|
|
1116
|
-
} finally {
|
|
1117
|
-
// 移除 child 引用——避免后续被 SSE 序列化到前端
|
|
1118
|
-
delete job.child
|
|
1119
|
-
publish('job:update', job);
|
|
1120
|
-
publish('sub:update', { taskId: task.id, sub });
|
|
1121
|
-
// 终态:fire-and-forget 同步落盘,确保 done/cancelled/error 都立即归档
|
|
1122
|
-
flushJobsSaveNow().catch(err => console.warn('[workbench] jobs save failed:', err.message))
|
|
1113
|
+
} catch { /* 不是 JSON,忽略 */ }
|
|
1114
|
+
const outDelta = job.output.slice(outPrev);
|
|
1115
|
+
if (outDelta) publish('job:output-delta', { id: job.id, delta: outDelta });
|
|
1116
|
+
const thinkDelta = job.thinking.slice(thinkPrev);
|
|
1117
|
+
if (thinkDelta) publish('job:thinking-delta', { id: job.id, delta: thinkDelta });
|
|
1118
|
+
}
|
|
1119
|
+
job.endedAt = nowIso();
|
|
1120
|
+
if (wasCancelled) {
|
|
1121
|
+
job.exitCode = 130; // 128 + SIGINT(2),约定俗成的"用户取消"退出码
|
|
1122
|
+
job.status = 'cancelled';
|
|
1123
|
+
job.error = '用户已停止执行';
|
|
1124
|
+
// sub 不改状态——cancelled 是 job 维度,同 task 后续 sub 仍可继续执行
|
|
1125
|
+
} else {
|
|
1126
|
+
job.exitCode = 0;
|
|
1127
|
+
job.status = 'done';
|
|
1128
|
+
sub.status = 'done';
|
|
1129
|
+
// 把这个 sub 的输出累积到前序上下文,喂给下一个 sub
|
|
1130
|
+
if (priorOutputs) priorOutputs.push({ title: sub.title, output: job.output || '' })
|
|
1123
1131
|
}
|
|
1132
|
+
} catch (err) {
|
|
1133
|
+
job.error = err && err.message ? err.message : String(err);
|
|
1134
|
+
job.status = 'error';
|
|
1135
|
+
sub.status = 'error';
|
|
1136
|
+
} finally {
|
|
1137
|
+
// 移除 child 引用——避免后续被 SSE 序列化到前端
|
|
1138
|
+
delete job.child
|
|
1139
|
+
publish('job:update', job);
|
|
1140
|
+
publish('sub:update', { taskId: task.id, sub });
|
|
1141
|
+
// 终态:fire-and-forget 同步落盘,确保 done/cancelled/error 都立即归档
|
|
1142
|
+
flushJobsSaveNow().catch(err => console.warn('[workbench] jobs save failed:', err.message))
|
|
1124
1143
|
}
|
|
1125
|
-
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** 把 task.subtasks 写回 tasks.json,并广播 task:update。runTaskQueue 和"单 sub 执行"共用。 */
|
|
1147
|
+
async function persistTaskAfterRun(task) {
|
|
1126
1148
|
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
1127
1149
|
const t = data.tasks.find(x => x.id === task.id);
|
|
1128
1150
|
if (t) {
|
|
@@ -1133,6 +1155,31 @@ ${prompt}`
|
|
|
1133
1155
|
}
|
|
1134
1156
|
}
|
|
1135
1157
|
|
|
1158
|
+
/**
|
|
1159
|
+
* 构建单 sub 执行时的 priorOutputs:把同一 task 下"排在当前 sub 之前"且已 done 的
|
|
1160
|
+
* 子任务输出摘要收集起来。这样单独跑一个 sub 时,它也能拿到前序上下文。
|
|
1161
|
+
*/
|
|
1162
|
+
async function collectPriorOutputs(task, targetSub) {
|
|
1163
|
+
const MAX_PREV_OUTPUT_CHARS = 2000
|
|
1164
|
+
const prior = []
|
|
1165
|
+
const targetIdx = task.subtasks.findIndex(s => s.id === targetSub.id)
|
|
1166
|
+
if (targetIdx < 0) return prior
|
|
1167
|
+
for (let i = 0; i < targetIdx; i++) {
|
|
1168
|
+
const s = task.subtasks[i]
|
|
1169
|
+
if (s.status !== 'done') continue
|
|
1170
|
+
// 从 jobs 列表里找最近一个属于这个 sub 且 status=done 的 job,
|
|
1171
|
+
// 取其 output 作为"前序上下文摘要"
|
|
1172
|
+
const job = snapshotJobs()
|
|
1173
|
+
.filter(j => j.subId === s.id && j.status === 'done')
|
|
1174
|
+
.sort((a, b) => (b.endedAt || '').localeCompare(a.endedAt || ''))[0]
|
|
1175
|
+
if (!job) continue
|
|
1176
|
+
const text = (job.output || '').slice(0, MAX_PREV_OUTPUT_CHARS)
|
|
1177
|
+
const truncated = (job.output || '').length > MAX_PREV_OUTPUT_CHARS ? '\n…(前文已截断)' : ''
|
|
1178
|
+
prior.push({ title: s.title, output: text + truncated })
|
|
1179
|
+
}
|
|
1180
|
+
return prior
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1136
1183
|
function waitProcessExit(pid) {
|
|
1137
1184
|
return new Promise(resolve => {
|
|
1138
1185
|
let exited = false;
|
|
@@ -1694,8 +1741,11 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
1694
1741
|
|
|
1695
1742
|
app.post('/api/workbench/tasks', async (req, res) => {
|
|
1696
1743
|
try {
|
|
1697
|
-
const { id, title, desc, promptId, subtasks } = req.body || {};
|
|
1744
|
+
const { id, title, desc, promptId, subtasks, type: rawType, simpleOverride } = req.body || {};
|
|
1698
1745
|
if (!title) return res.status(400).json({ success: false, error: 'title 必填' });
|
|
1746
|
+
// type 归一化:仅接受 'simple' | 'complex',缺省/未知一律按 complex
|
|
1747
|
+
const taskType = rawType === 'simple' ? 'simple' : 'complex';
|
|
1748
|
+
const safeOverride = typeof simpleOverride === 'string' ? simpleOverride.slice(0, 8000) : '';
|
|
1699
1749
|
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
1700
1750
|
const tasks = data.tasks || [];
|
|
1701
1751
|
const now = nowIso();
|
|
@@ -1709,6 +1759,8 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
1709
1759
|
title,
|
|
1710
1760
|
desc: desc || '',
|
|
1711
1761
|
promptId: promptId || null,
|
|
1762
|
+
type: taskType,
|
|
1763
|
+
simpleOverride: taskType === 'simple' ? safeOverride : '',
|
|
1712
1764
|
subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
|
|
1713
1765
|
id: s.id || genId(),
|
|
1714
1766
|
title: s.title || '',
|
|
@@ -1737,6 +1789,8 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
1737
1789
|
title,
|
|
1738
1790
|
desc: desc || '',
|
|
1739
1791
|
promptId: promptId || null,
|
|
1792
|
+
type: taskType,
|
|
1793
|
+
simpleOverride: taskType === 'simple' ? safeOverride : '',
|
|
1740
1794
|
projectPath: currentProjectPath || '',
|
|
1741
1795
|
subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
|
|
1742
1796
|
id: s.id || genId(),
|
|
@@ -1789,6 +1843,91 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
1789
1843
|
}
|
|
1790
1844
|
});
|
|
1791
1845
|
|
|
1846
|
+
// ── 执行简单任务(无子任务直接跑) ──────────────────────────────────
|
|
1847
|
+
// POST /api/workbench/tasks/:id/run-simple
|
|
1848
|
+
// 行为:
|
|
1849
|
+
// - 适用于 type==='simple' 的任务,无需拆分子任务
|
|
1850
|
+
// - 在内存里合成一个虚拟 sub{title=task.title, desc=task.desc,
|
|
1851
|
+
// promptOverride=task.simpleOverride, status='todo'},
|
|
1852
|
+
// 复用 runSingleSubtask(同一套 prompt 拼装、附件、job 跟踪、取消、落盘)
|
|
1853
|
+
// - 不会修改 task.subtasks 持久化结构
|
|
1854
|
+
app.post('/api/workbench/tasks/:id/run-simple', async (req, res) => {
|
|
1855
|
+
try {
|
|
1856
|
+
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
1857
|
+
const task = (data.tasks || []).find(t => t.id === req.params.id);
|
|
1858
|
+
if (!task) return res.status(404).json({ success: false, error: '任务不存在' });
|
|
1859
|
+
if (task.type !== 'simple') {
|
|
1860
|
+
return res.status(400).json({ success: false, error: '该任务不是简单任务,请使用普通执行接口' });
|
|
1861
|
+
}
|
|
1862
|
+
const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
1863
|
+
// 虚拟 sub:不写回 tasks.json,只用一次
|
|
1864
|
+
const virtualSub = {
|
|
1865
|
+
id: `${task.id}__simple`,
|
|
1866
|
+
title: task.title,
|
|
1867
|
+
desc: task.desc || '',
|
|
1868
|
+
status: 'todo',
|
|
1869
|
+
promptOverride: task.simpleOverride || '',
|
|
1870
|
+
attachments: Array.isArray(task.attachments) ? task.attachments : []
|
|
1871
|
+
};
|
|
1872
|
+
// 简单任务本身没有 subtasks 列表,直接调 runSingleSubtask(不再走 runTaskQueue 循环)
|
|
1873
|
+
res.json({ success: true, message: '已开始执行简单任务' });
|
|
1874
|
+
runSingleSubtask(task, virtualSub, repoPath, '', []).catch(err => {
|
|
1875
|
+
publish('task:error', { taskId: task.id, error: err.message });
|
|
1876
|
+
});
|
|
1877
|
+
} catch (err) {
|
|
1878
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1879
|
+
}
|
|
1880
|
+
});
|
|
1881
|
+
|
|
1882
|
+
// ── 执行单个子任务 ────────────────────────────────────────────────────
|
|
1883
|
+
// POST /api/workbench/subtasks/:id/run
|
|
1884
|
+
// 行为:
|
|
1885
|
+
// - 仅执行指定 id 的这一个 sub,不再遍历 task 下其他 sub
|
|
1886
|
+
// - 拼 prompt 时会把"前面已 done 的 sub"输出摘要作为前序上下文塞进去,
|
|
1887
|
+
// 跟整批执行的语义保持一致
|
|
1888
|
+
// - 整批"执行任务"队列和单 sub 执行共用 runSingleSubtask,所以日志/状态
|
|
1889
|
+
// /取消/落盘逻辑完全一致
|
|
1890
|
+
// 限制:
|
|
1891
|
+
// - 该 sub 处于 running/pending 时拒绝(并发跑同一个 sub 会出现状态混乱)
|
|
1892
|
+
app.post('/api/workbench/subtasks/:id/run', async (req, res) => {
|
|
1893
|
+
try {
|
|
1894
|
+
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
1895
|
+
const subId = req.params.id;
|
|
1896
|
+
let foundTask = null;
|
|
1897
|
+
let foundSub = null;
|
|
1898
|
+
for (const t of (data.tasks || [])) {
|
|
1899
|
+
if (!Array.isArray(t.subtasks)) continue;
|
|
1900
|
+
const s = t.subtasks.find(x => x.id === subId);
|
|
1901
|
+
if (s) { foundTask = t; foundSub = s; break; }
|
|
1902
|
+
}
|
|
1903
|
+
if (!foundTask || !foundSub) {
|
|
1904
|
+
return res.status(404).json({ success: false, error: '子任务不存在' });
|
|
1905
|
+
}
|
|
1906
|
+
if (foundSub.status === 'running') {
|
|
1907
|
+
return res.status(400).json({ success: false, error: '该子任务正在执行中' });
|
|
1908
|
+
}
|
|
1909
|
+
// 兜底:即便磁盘状态是 todo,如果磁盘里还没归档的 job 还在跑(进程被孤儿),也拦一下
|
|
1910
|
+
const liveJob = snapshotJobs().find(j => j.subId === subId && (j.status === 'running' || j.status === 'pending'));
|
|
1911
|
+
if (liveJob) {
|
|
1912
|
+
return res.status(400).json({ success: false, error: '该子任务已有正在执行的 job' });
|
|
1913
|
+
}
|
|
1914
|
+
const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
1915
|
+
// 异步执行,立即返回
|
|
1916
|
+
res.json({ success: true, message: `已开始执行子任务:${foundSub.title || subId}` });
|
|
1917
|
+
(async () => {
|
|
1918
|
+
try {
|
|
1919
|
+
const priorOutputs = await collectPriorOutputs(foundTask, foundSub);
|
|
1920
|
+
await runSingleSubtask(foundTask, foundSub, repoPath, '', priorOutputs);
|
|
1921
|
+
await persistTaskAfterRun(foundTask);
|
|
1922
|
+
} catch (err) {
|
|
1923
|
+
publish('task:error', { taskId: foundTask.id, error: err.message });
|
|
1924
|
+
}
|
|
1925
|
+
})();
|
|
1926
|
+
} catch (err) {
|
|
1927
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1928
|
+
}
|
|
1929
|
+
});
|
|
1930
|
+
|
|
1792
1931
|
// ── 进程状态查询(兜底,SSE 断了也能拉) ────────────────────────────
|
|
1793
1932
|
app.get('/api/workbench/jobs', (_req, res) => {
|
|
1794
1933
|
res.json({ success: true, jobs: snapshotJobs() });
|