zen-gitsync 2.14.2 → 2.14.3
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/package.json +1 -1
- package/src/ui/public/assets/{AppVersionBadge-M9ydXpd9.js → AppVersionBadge-z2kqhmtD.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-scIk6OeM.js → BranchSelector-CSjsCeeJ.js} +1 -1
- package/src/ui/public/assets/{CommandConsole-BikQuarw.js → CommandConsole-96MUoG1F.js} +2 -2
- package/src/ui/public/assets/{CommitForm-DuvNxUdP.js → CommitForm-BBKQ0GqO.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-G17HYMln.js → CommonDialog-CgQovpCJ.js} +1 -1
- package/src/ui/public/assets/{EditorView-rGLnccH3.js → EditorView-aoA30emq.js} +0 -0
- package/src/ui/public/assets/{FlowExecutionViewer-B4BADP8e.js → FlowExecutionViewer-BvXlaQai.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-C3l3Jco7.js → FlowOrchestrationWorkspace-CgWUF1Jc.js} +1 -1
- package/src/ui/public/assets/{LogList-7agk5Obq.js → LogList-uF4tir23.js} +1 -1
- package/src/ui/public/assets/{MindmapView-mOFG9TmR.js → MindmapView-B8wr0EFL.js} +1 -1
- package/src/ui/public/assets/{MonitorView-Dlpgy8Bb.js → MonitorView-DaJ0h8Im.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-CgS14qyg.js → ProjectStartupButton-CcH1NL2W.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-CFKl6CB9.js → RemoteRepoCard-BDbJ76uf.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-Dsc_a3t7.js → SourceMapView-2HjAlHnp.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-Cw2mL_zx.js → SvgIcon-CK6VGHb5.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-yBT2005h.js → UserInputNode-Cx8r5geA.js} +1 -1
- package/src/ui/public/assets/{WorkbenchView-CiG-qMl7.js → WorkbenchView-CG54iND-.js} +4 -4
- package/src/ui/public/assets/WorkbenchView-CvHPPs8Q.css +1 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-BMXWW0fG.js → _plugin-vue_export-helper-CJMndG_W.js} +2 -2
- package/src/ui/public/assets/{configStore-Cq6Qwn35.js → configStore-DDnE5eie.js} +1 -1
- package/src/ui/public/assets/index-B7FGNx_l.js +23 -0
- package/src/ui/public/assets/{index-gbRjS0sL.css → index-f6UhuE5j.css} +1 -1
- package/src/ui/public/index.html +6 -6
- package/src/ui/server/index.js +632 -625
- package/src/ui/server/middleware/errorHandler.js +84 -0
- package/src/ui/server/middleware/errorHandler.test.js +114 -0
- package/src/ui/server/routes/exec.js +0 -21
- package/src/ui/server/routes/gitOps.js +62 -0
- package/src/ui/server/routes/monitor.js +42 -26
- package/src/ui/server/routes/terminal.js +318 -320
- package/src/ui/server/routes/workbench/attachmentUtils.js +76 -0
- package/src/ui/server/routes/workbench/index.js +1680 -0
- package/src/ui/server/routes/workbench/instructionStore.js +175 -0
- package/src/ui/server/routes/workbench/jobStore.js +225 -0
- package/src/ui/server/routes/workbench/jsonParse.js +219 -0
- package/src/ui/server/routes/workbench/llmClient.js +214 -0
- package/src/ui/server/routes/workbench/projectScan.js +191 -0
- package/src/ui/server/routes/workbench/sessionStore.js +126 -0
- package/src/ui/server/routes/workbench/shared.js +115 -0
- package/src/ui/server/routes/workbench/taskRunner.js +493 -0
- package/src/ui/server/routes/workbench.js +16 -3523
- package/src/ui/public/assets/WorkbenchView-OmJ1eM5g.css +0 -1
- package/src/ui/public/assets/index-CPqKvZBK.js +0 -23
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// 任务执行引擎:spawn claude CLI 跑子任务,流式收集输出,更新 job 状态。
|
|
16
|
+
// 拆分自原 routes/workbench.js 1108-1564 行。
|
|
17
|
+
//
|
|
18
|
+
// 核心 API:
|
|
19
|
+
// - launchClaudeInNewWindow(cwd, prompt, resumeSessionId) spawn claude,返回 {pid, child}
|
|
20
|
+
// - runTaskQueue(task, repoPath, branch, opts) 顺序执行 task.subtasks
|
|
21
|
+
// - runSingleSubtask(task, sub, repoPath, branch, priorOutputs, opts) 跑单个 sub
|
|
22
|
+
// - syncSubToCancelled(job) cancel 路径专用
|
|
23
|
+
// - persistTaskAfterRun(task) 把 sub.status 落回 tasks.json
|
|
24
|
+
// - collectPriorOutputs(task, targetSub) 收集前序 done sub 的输出
|
|
25
|
+
// - waitProcessExit(pid) polling 等进程退出
|
|
26
|
+
|
|
27
|
+
import fs from 'fs';
|
|
28
|
+
import path from 'path';
|
|
29
|
+
import { spawn, execFileSync } from 'child_process';
|
|
30
|
+
import {
|
|
31
|
+
logger,
|
|
32
|
+
PROMPTS_FILE,
|
|
33
|
+
TASKS_FILE,
|
|
34
|
+
readJson,
|
|
35
|
+
writeJson,
|
|
36
|
+
nowIso,
|
|
37
|
+
genId,
|
|
38
|
+
interpolate,
|
|
39
|
+
} from './shared.js';
|
|
40
|
+
import {
|
|
41
|
+
jobs,
|
|
42
|
+
cancelledJobs,
|
|
43
|
+
publish,
|
|
44
|
+
snapshotJobs,
|
|
45
|
+
flushJobsSaveNow,
|
|
46
|
+
} from './jobStore.js';
|
|
47
|
+
|
|
48
|
+
// 用 detached 进程跑 claude;进程退出时回填状态。
|
|
49
|
+
// 返回 { pid, child }:调用方可以监听 child.stdout/stderr 实时收集输出。
|
|
50
|
+
// 不再走 cmd /k 弹窗——claude -p 是非交互模式,输出通过 stdout pipe 实时回传
|
|
51
|
+
// 到前端面板展示。
|
|
52
|
+
export function launchClaudeInNewWindow(cwd, promptText, resumeSessionId) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const args = [];
|
|
55
|
+
// 续接历史会话:--resume 必须放在 -p 之前,确保 claude CLI 先识别 resume 上下文
|
|
56
|
+
if (resumeSessionId) {
|
|
57
|
+
args.push('--resume', String(resumeSessionId));
|
|
58
|
+
}
|
|
59
|
+
args.push(
|
|
60
|
+
// -p '-' 让 claude CLI 从 stdin 读取 prompt —— 避免 Windows 命令行 32K 长度上限
|
|
61
|
+
// (spawn argv 会拼成 cmdline 给 CreateProcess,长 prompt 直接 ENAMETOOLONG)
|
|
62
|
+
'-p', '-',
|
|
63
|
+
'--output-format', 'stream-json',
|
|
64
|
+
'--verbose',
|
|
65
|
+
'--permission-mode', 'bypassPermissions',
|
|
66
|
+
'--dangerously-skip-permissions'
|
|
67
|
+
);
|
|
68
|
+
let child;
|
|
69
|
+
let spawnedExe = 'claude';
|
|
70
|
+
if (process.platform === 'win32') {
|
|
71
|
+
// 直接 spawn claude.exe(npm 全局 @anthropic-ai/claude-code 里的真实二进制),
|
|
72
|
+
// 避开两件事:
|
|
73
|
+
// 1. Node 23 在 Windows 上拒绝 spawn .cmd/.bat(EINVAL)
|
|
74
|
+
// 2. shell:true 会把 argv 拼成命令行交给 cmd 解释,prompt 里的 \n 被切成多段
|
|
75
|
+
// 用 `where claude` 找到 claude.cmd,再从 cmd 内容推断对应 .exe 路径。
|
|
76
|
+
let claudeExe = 'claude.exe';
|
|
77
|
+
try {
|
|
78
|
+
const cmdShim = execFileSync('where', ['claude'], { encoding: 'utf8' })
|
|
79
|
+
.split(/\r?\n/).map(s => s.trim()).find(s => /\.cmd$/i.test(s));
|
|
80
|
+
if (cmdShim) {
|
|
81
|
+
const txt = fs.readFileSync(cmdShim, 'utf8');
|
|
82
|
+
if (/%dp0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude\.exe/i.test(txt)) {
|
|
83
|
+
claudeExe = path.join(path.dirname(cmdShim), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} catch { /* fallback */ }
|
|
87
|
+
spawnedExe = claudeExe;
|
|
88
|
+
child = spawn(claudeExe, args, {
|
|
89
|
+
cwd,
|
|
90
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
91
|
+
windowsHide: false,
|
|
92
|
+
env: { ...process.env, LANG: 'zh_CN.UTF-8' }
|
|
93
|
+
});
|
|
94
|
+
} else {
|
|
95
|
+
// macOS / Linux:直接 spawn claude(Node spawn 不走 shell,
|
|
96
|
+
// prompt 中的引号 / 反斜杠无需手动 escape)
|
|
97
|
+
child = spawn('claude', args, {
|
|
98
|
+
cwd,
|
|
99
|
+
detached: true,
|
|
100
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
101
|
+
env: { ...process.env, LANG: 'zh_CN.UTF-8' }
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
child.on('error', reject);
|
|
105
|
+
child.on('spawn', () => {
|
|
106
|
+
// unref 让 claude 独立于父进程事件循环;返回 child 引用让调用方继续读 stdout。
|
|
107
|
+
child.unref();
|
|
108
|
+
// 长 prompt 通过 stdin 喂入(避开 Windows CreateProcess 32K 命令行上限)。
|
|
109
|
+
// 必须在 spawn 事件回调里 write,而不是 resolve 前同步 write——因为 spawn
|
|
110
|
+
// 返回时 child.stdin 句柄可能尚未绑定到真正的 pipe fd。
|
|
111
|
+
// write 完成后必须 end(),否则 claude CLI 会一直阻塞在读 stdin 上 → hang。
|
|
112
|
+
try {
|
|
113
|
+
child.stdin.write(promptText, () => {
|
|
114
|
+
try { child.stdin.end(); } catch { /* 子进程已关闭,忽略 */ }
|
|
115
|
+
});
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// stdin 写入失败不要让 spawn 整体 reject —— 让 child 自然以错误状态收尾,
|
|
118
|
+
// 后面的 stdout/stderr 监听会捕获到 LLM 端反馈。
|
|
119
|
+
logger.warn('[workbench] stdin write failed:', err && err.message || err);
|
|
120
|
+
}
|
|
121
|
+
resolve({ pid: child.pid, child });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 顺序执行一个任务下所有子任务;上一个结束再启动下一个。
|
|
128
|
+
* 入参 fromIndex 指定从哪个 sub 开始(0-based,默认 0),用于"从此处开始"入口;
|
|
129
|
+
* fromIndex>0 时,把 [0, fromIndex) 区间内已 done 的 sub 输出预填到 priorOutputs,
|
|
130
|
+
* 这样后续 sub 仍能拿到前序上下文(跟单 sub 执行的语义保持一致)。
|
|
131
|
+
*
|
|
132
|
+
* 连续模式(默认):任意 sub 终态非 done(cancelled / error)→ 整批停,后续 sub 保持 todo。
|
|
133
|
+
* AI 拆出来的 sub 一般前后强依赖(前一步产出是后一步输入),出错就停下来让用户决策。
|
|
134
|
+
* 关闭后回退旧行为:单个 sub 失败不影响后续 sub 继续跑。
|
|
135
|
+
*/
|
|
136
|
+
export async function runTaskQueue(task, repoPath, branch, opts) {
|
|
137
|
+
// 前序上下文:跑完一个 sub 后把它"完成态"输出存到这里,下一个 sub 启动时
|
|
138
|
+
// 拼到 prompt 头部,让 Claude 知道前面做了什么、产出了什么。
|
|
139
|
+
// 现在 LLM 都是百万 token 上下文窗口,完整透传 raw output,不做截断——
|
|
140
|
+
// 关键产物(生成的代码块、JSON、结论)在中间被砍掉反而会让后续 sub 失去依据。
|
|
141
|
+
const requested = Number(opts && opts.fromIndex);
|
|
142
|
+
const fromIndex = Number.isInteger(requested) && requested >= 0 && requested < task.subtasks.length
|
|
143
|
+
? requested
|
|
144
|
+
: 0;
|
|
145
|
+
// 从 fromIndex 开始时,把前面已 done 的 sub 输出预填进 priorOutputs,
|
|
146
|
+
// 否则"从中间开始"会丢失前序上下文。
|
|
147
|
+
const priorOutputs = fromIndex > 0
|
|
148
|
+
? await collectPriorOutputsUpTo(task, fromIndex)
|
|
149
|
+
: [];
|
|
150
|
+
const sequential = task.sequential !== false;
|
|
151
|
+
for (let i = fromIndex; i < task.subtasks.length; i++) {
|
|
152
|
+
const sub = task.subtasks[i];
|
|
153
|
+
if (sub.status === 'done') continue;
|
|
154
|
+
const outcome = await runSingleSubtask(task, sub, repoPath, branch, priorOutputs);
|
|
155
|
+
// 逐 sub 落盘:之前只在队列跑完才 persistTaskAfterRun,中途崩溃会丢已完成
|
|
156
|
+
// sub 的状态。runSingleSubtask 已经把 sub.status 改完,这里补一次落盘。
|
|
157
|
+
await persistTaskAfterRun(task);
|
|
158
|
+
if (sequential && outcome !== 'done') {
|
|
159
|
+
// cancelled / error → 后续 sub 全部保持 todo,不再继续
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 执行单个子任务。被 runTaskQueue(整批)和"单 sub 执行"endpoint 共用。
|
|
167
|
+
*
|
|
168
|
+
* @param {object} task 主任务对象
|
|
169
|
+
* @param {object} sub 要跑的子任务
|
|
170
|
+
* @param {string} repoPath 仓库路径
|
|
171
|
+
* @param {string} branch 分支名(可空)
|
|
172
|
+
* @param {Array<{title:string,output:string}>} priorOutputs
|
|
173
|
+
* 前序 done 子任务的输出摘要(in-place 追加)。用于把同一任务下
|
|
174
|
+
* 前面已完成的 sub 产物拼到当前 sub 的 prompt 头部,让 Claude
|
|
175
|
+
* 知道上下文。单独跑一个 sub 时,这个数组里只会有"前面 done 的 sub"。
|
|
176
|
+
* @param {object} [options]
|
|
177
|
+
* @param {string|null} [options.resumeSessionId]
|
|
178
|
+
* 续接历史 claude 会话:传入上一轮通过 stream-json 的 system.init
|
|
179
|
+
* 事件捕获到的 session_id,本轮以 --resume <id> 启动,claude 会带着
|
|
180
|
+
* 上下文继续对话。前端"简单任务执行完成后继续聊"走的就是这条路径。
|
|
181
|
+
* @returns {Promise<'done'|'cancelled'|'error'>} sub 的终态
|
|
182
|
+
*/
|
|
183
|
+
export async function runSingleSubtask(task, sub, repoPath, branch, priorOutputs, options) {
|
|
184
|
+
const opts = options || {};
|
|
185
|
+
const resumeSessionId = opts.resumeSessionId || null;
|
|
186
|
+
const promptTemplate = sub.promptOverride || (task.promptId
|
|
187
|
+
? (await readJson(PROMPTS_FILE, { prompts: [] })).prompts.find(p => p.id === task.promptId)?.content
|
|
188
|
+
: null) || '';
|
|
189
|
+
const ctx = {
|
|
190
|
+
task: { title: task.title, desc: task.desc || '' },
|
|
191
|
+
sub: { title: sub.title, desc: sub.desc || '' },
|
|
192
|
+
repo: { path: repoPath || '' },
|
|
193
|
+
branch: branch || ''
|
|
194
|
+
};
|
|
195
|
+
const interpolated = interpolate(promptTemplate, ctx);
|
|
196
|
+
const parts = [interpolated, sub.title, sub.desc].filter(s => s && s.trim());
|
|
197
|
+
let prompt = parts.join('\n\n');
|
|
198
|
+
|
|
199
|
+
// ── 前序上下文:把前几个 done 子任务的输出完整拼到 prompt 头部 ──
|
|
200
|
+
if (priorOutputs && priorOutputs.length > 0) {
|
|
201
|
+
const prevBlock = priorOutputs.map((p, i) => {
|
|
202
|
+
return `### [${i + 1}] ${p.title}\n${p.output || ''}`;
|
|
203
|
+
}).join('\n\n');
|
|
204
|
+
prompt = `以下是同一任务下已经完成的前序子任务输出(仅作上下文参考,请基于这些结论继续当前子任务,无需重复执行它们):
|
|
205
|
+
|
|
206
|
+
${prevBlock}
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
${prompt}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── 附件:合并 sub.attachments + task.attachments 后拼到 prompt 末尾 ──
|
|
214
|
+
// claude -p 字符串模式会扫描 prompt 中出现的本地文件路径并自动
|
|
215
|
+
// 识别为附件(图片 / PDF / 文本均可)。
|
|
216
|
+
// 主任务附件对所有 sub 都可见;子任务自己的附件只对该 sub 可见。
|
|
217
|
+
// 注意:run-simple 路径下 virtualSub.attachments 就是 task.attachments 的同一引用,
|
|
218
|
+
// 不去重会把同一张图在 prompt 里列两遍。按 absolutePath 去重。
|
|
219
|
+
const taskAtts = Array.isArray(task.attachments) ? task.attachments : [];
|
|
220
|
+
const subAtts = Array.isArray(sub.attachments) ? sub.attachments : [];
|
|
221
|
+
const seen = new Set();
|
|
222
|
+
const allAttachments = [];
|
|
223
|
+
for (const a of [...subAtts, ...taskAtts]) {
|
|
224
|
+
if (!a || !a.absolutePath) continue;
|
|
225
|
+
if (seen.has(a.absolutePath)) continue;
|
|
226
|
+
seen.add(a.absolutePath);
|
|
227
|
+
allAttachments.push(a);
|
|
228
|
+
}
|
|
229
|
+
if (allAttachments.length > 0) {
|
|
230
|
+
const lines = allAttachments
|
|
231
|
+
.filter(a => a && a.absolutePath)
|
|
232
|
+
.map((a, i) => ` ${i + 1}. [${a.mimeType || 'application/octet-stream'}] ${a.absolutePath}`);
|
|
233
|
+
if (lines.length > 0) {
|
|
234
|
+
prompt += `\n\n---\n本任务包含 ${lines.length} 个附件(请按文件路径读取,不要让用户重新提供):\n${lines.join('\n')}\n---`;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const jobId = genId();
|
|
239
|
+
const job = {
|
|
240
|
+
id: jobId,
|
|
241
|
+
taskId: task.id,
|
|
242
|
+
subId: sub.id,
|
|
243
|
+
title: `${task.title} / ${sub.title}`,
|
|
244
|
+
status: 'pending',
|
|
245
|
+
prompt,
|
|
246
|
+
// 续接历史会话时先填上,首条 system.init 事件回来后再用真值覆盖(通常等同)
|
|
247
|
+
claudeSessionId: resumeSessionId
|
|
248
|
+
};
|
|
249
|
+
jobs.set(jobId, job);
|
|
250
|
+
sub.status = 'running';
|
|
251
|
+
publish('sub:update', { taskId: task.id, sub });
|
|
252
|
+
publish('job:update', job);
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const { pid, child } = await launchClaudeInNewWindow(repoPath || process.cwd(), prompt, resumeSessionId);
|
|
256
|
+
job.pid = pid;
|
|
257
|
+
// 保存 child 引用,供 cancel 接口调用 kill
|
|
258
|
+
job.child = child;
|
|
259
|
+
job.startedAt = nowIso();
|
|
260
|
+
job.status = 'running';
|
|
261
|
+
publish('job:update', job);
|
|
262
|
+
|
|
263
|
+
// 流式 NDJSON 解析:把 stdout 当作 stream-json 协议处理
|
|
264
|
+
// assistant.text → job.output (用户主要关心的内容)
|
|
265
|
+
// assistant.thinking → job.thinking (折叠展示,让用户知道 Claude 在想)
|
|
266
|
+
// 其他事件(init / tool_use / result 等)忽略,避免噪声
|
|
267
|
+
const MAX_OUTPUT = 100 * 1024 * 1024;
|
|
268
|
+
const MAX_THINKING = 100 * 1024 * 1024;
|
|
269
|
+
job.output = '';
|
|
270
|
+
job.thinking = '';
|
|
271
|
+
const lineBuf = { stdout: '', stderr: '' };
|
|
272
|
+
|
|
273
|
+
const parseLines = (channel, buf) => {
|
|
274
|
+
const chunk = buf.toString('utf8');
|
|
275
|
+
lineBuf[channel] += chunk;
|
|
276
|
+
const lines = lineBuf[channel].split('\n');
|
|
277
|
+
lineBuf[channel] = lines.pop() ?? ''; // 最后一段可能不完整,留给下次
|
|
278
|
+
let pendingThinkingDelta = '';
|
|
279
|
+
for (const line of lines) {
|
|
280
|
+
const trimmed = line.trim();
|
|
281
|
+
if (!trimmed) continue;
|
|
282
|
+
if (channel === 'stderr' || !trimmed.startsWith('{')) {
|
|
283
|
+
// 非 stream-json 行:原样塞进 output(兼容老版本 claude / 错误信息)
|
|
284
|
+
const prevLen = job.output.length;
|
|
285
|
+
job.output = (job.output + trimmed + '\n').slice(-MAX_OUTPUT);
|
|
286
|
+
const delta = job.output.slice(prevLen);
|
|
287
|
+
if (delta) publish('job:output-delta', { id: job.id, delta });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
let evt;
|
|
291
|
+
try { evt = JSON.parse(trimmed); } catch { continue; }
|
|
292
|
+
// 捕获 system.init 里的 session_id,供后续"续接对话"用(--resume <id>)
|
|
293
|
+
if (evt.type === 'system' && evt.subtype === 'init' && typeof evt.session_id === 'string') {
|
|
294
|
+
if (!job.claudeSessionId || job.claudeSessionId !== evt.session_id) {
|
|
295
|
+
job.claudeSessionId = evt.session_id;
|
|
296
|
+
publish('job:update', job);
|
|
297
|
+
}
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (evt.type !== 'assistant') continue;
|
|
301
|
+
const blocks = evt.message?.content;
|
|
302
|
+
if (!Array.isArray(blocks)) continue;
|
|
303
|
+
for (const b of blocks) {
|
|
304
|
+
if (b.type === 'text' && typeof b.text === 'string') {
|
|
305
|
+
const prevLen = job.output.length;
|
|
306
|
+
job.output = (job.output + b.text).slice(-MAX_OUTPUT);
|
|
307
|
+
const delta = job.output.slice(prevLen);
|
|
308
|
+
if (delta) publish('job:output-delta', { id: job.id, delta });
|
|
309
|
+
} else if (b.type === 'thinking' && typeof b.thinking === 'string') {
|
|
310
|
+
const prevLen = job.thinking.length;
|
|
311
|
+
job.thinking = (job.thinking + b.thinking).slice(-MAX_THINKING);
|
|
312
|
+
const delta = job.thinking.slice(prevLen);
|
|
313
|
+
if (delta) pendingThinkingDelta += delta;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// 一批 NDJSON 处理完后统一发一次 thinking delta,避免高频小块 socket 占用
|
|
318
|
+
if (pendingThinkingDelta) {
|
|
319
|
+
publish('job:thinking-delta', { id: job.id, delta: pendingThinkingDelta });
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
if (child.stdout) child.stdout.on('data', (buf) => parseLines('stdout', buf));
|
|
323
|
+
if (child.stderr) child.stderr.on('data', (buf) => parseLines('stderr', buf));
|
|
324
|
+
|
|
325
|
+
// 等待进程退出(detached 不阻塞主进程,用 polling /proc 兜底)
|
|
326
|
+
await waitProcessExit(pid);
|
|
327
|
+
const wasCancelled = cancelledJobs.has(jobId);
|
|
328
|
+
if (wasCancelled) cancelledJobs.delete(jobId);
|
|
329
|
+
// 进程退出时 stdout 可能残留最后一段未换行的 NDJSON,flush 一次
|
|
330
|
+
if (lineBuf.stdout.trim()) {
|
|
331
|
+
const outPrev = job.output.length;
|
|
332
|
+
const thinkPrev = job.thinking.length;
|
|
333
|
+
try {
|
|
334
|
+
const evt = JSON.parse(lineBuf.stdout.trim());
|
|
335
|
+
if (evt.type === 'assistant' && Array.isArray(evt.message?.content)) {
|
|
336
|
+
for (const b of evt.message.content) {
|
|
337
|
+
if (b.type === 'text' && typeof b.text === 'string') {
|
|
338
|
+
job.output = (job.output + b.text).slice(-MAX_OUTPUT);
|
|
339
|
+
} else if (b.type === 'thinking' && typeof b.thinking === 'string') {
|
|
340
|
+
job.thinking = (job.thinking + b.thinking).slice(-MAX_THINKING);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
} catch { /* 不是 JSON,忽略 */ }
|
|
345
|
+
const outDelta = job.output.slice(outPrev);
|
|
346
|
+
if (outDelta) publish('job:output-delta', { id: job.id, delta: outDelta });
|
|
347
|
+
const thinkDelta = job.thinking.slice(thinkPrev);
|
|
348
|
+
if (thinkDelta) publish('job:thinking-delta', { id: job.id, delta: thinkDelta });
|
|
349
|
+
}
|
|
350
|
+
job.endedAt = nowIso();
|
|
351
|
+
if (wasCancelled) {
|
|
352
|
+
job.exitCode = 130; // 128 + SIGINT(2),约定俗成的"用户取消"退出码
|
|
353
|
+
job.status = 'cancelled';
|
|
354
|
+
job.error = '用户已停止执行';
|
|
355
|
+
// 同步把闭包里的 sub 也置 cancelled(cancel 接口的 syncSubToCancelled 改了磁盘 task
|
|
356
|
+
// 引用,但 runSingleSubtask 形参里的 sub 是另一份内存引用)。否则 finally publish sub:update
|
|
357
|
+
// 会用 status='running' 覆盖掉前端已渲染的 cancelled 状态,导致 UI 反复跳回 running。
|
|
358
|
+
sub.status = 'cancelled';
|
|
359
|
+
if (!sub.error) sub.error = '用户已停止执行';
|
|
360
|
+
} else {
|
|
361
|
+
job.exitCode = 0;
|
|
362
|
+
job.status = 'done';
|
|
363
|
+
sub.status = 'done';
|
|
364
|
+
// 把这个 sub 的输出累积到前序上下文,喂给下一个 sub
|
|
365
|
+
if (priorOutputs) priorOutputs.push({ title: sub.title, output: job.output || '' });
|
|
366
|
+
}
|
|
367
|
+
} catch (err) {
|
|
368
|
+
const errMsg = err && err.message ? err.message : String(err);
|
|
369
|
+
job.error = errMsg;
|
|
370
|
+
job.status = 'error';
|
|
371
|
+
sub.status = 'error';
|
|
372
|
+
sub.error = errMsg;
|
|
373
|
+
sub.errorAt = nowIso();
|
|
374
|
+
} finally {
|
|
375
|
+
// 移除 child 引用——避免后续被 SSE 序列化到前端
|
|
376
|
+
delete job.child;
|
|
377
|
+
publish('job:update', job);
|
|
378
|
+
publish('sub:update', { taskId: task.id, sub });
|
|
379
|
+
// 终态:await 同步落盘,确保 done/cancelled/error 全部立即归档。
|
|
380
|
+
try {
|
|
381
|
+
await flushJobsSaveNow();
|
|
382
|
+
} catch (err) {
|
|
383
|
+
logger.warn('[workbench] flushJobsSaveNow failed (job id=' + job.id + ', status=' + job.status + '):', err && err.message || err);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// 把 sub 的终态返回给 runTaskQueue,用于「连续模式」判断要不要 break 整批队列
|
|
387
|
+
return job.status; // 'done' | 'cancelled' | 'error'
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* 把被取消的 sub 同步置 'cancelled' 并落盘。
|
|
392
|
+
* cancelJob 路径专用:前端 taskIsRunning/sub.is-running 都看 sub.status,不改就会出现
|
|
393
|
+
* "主任务黄点 + sub running 动效 + 右侧执行完成"三处不一致。
|
|
394
|
+
*
|
|
395
|
+
* 简单任务的虚拟 subId 不在 tasks.json 里(task.subtasks 是 complex 才有),所以这里
|
|
396
|
+
* 找不到 sub 时静默返回;简单任务的 running 状态由 job 数组单独维护(见 taskIsRunning)。
|
|
397
|
+
*
|
|
398
|
+
* @returns {{ taskId: string, sub: object } | null} 找到并更新时返回新 sub,否则 null
|
|
399
|
+
*/
|
|
400
|
+
export async function syncSubToCancelled(job) {
|
|
401
|
+
if (!job || !job.taskId || !job.subId) return null;
|
|
402
|
+
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
403
|
+
const task = (data.tasks || []).find(x => x.id === job.taskId);
|
|
404
|
+
if (!task || !Array.isArray(task.subtasks)) return null;
|
|
405
|
+
const sub = task.subtasks.find(s => s && s.id === job.subId);
|
|
406
|
+
if (!sub) return null;
|
|
407
|
+
if (sub.status === 'cancelled') return { taskId: task.id, sub }; // 已置过,幂等返回
|
|
408
|
+
sub.status = 'cancelled';
|
|
409
|
+
sub.error = '用户已停止执行';
|
|
410
|
+
sub.errorAt = nowIso();
|
|
411
|
+
task.updatedAt = nowIso();
|
|
412
|
+
await writeJson(TASKS_FILE, data);
|
|
413
|
+
publish('sub:update', { taskId: task.id, sub });
|
|
414
|
+
publish('task:update', task);
|
|
415
|
+
return { taskId: task.id, sub };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** 把 task.subtasks 写回 tasks.json,并广播 task:update。runTaskQueue 和"单 sub 执行"共用。 */
|
|
419
|
+
export async function persistTaskAfterRun(task) {
|
|
420
|
+
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
421
|
+
const t = data.tasks.find(x => x.id === task.id);
|
|
422
|
+
if (t) {
|
|
423
|
+
// 仅同步 status 之外的 error/errorAt 字段,避免覆盖用户编辑过的 title/desc 等。
|
|
424
|
+
const newMap = new Map(task.subtasks.map(s => [s.id, s]));
|
|
425
|
+
t.subtasks = (t.subtasks || []).map(old => {
|
|
426
|
+
const fresh = newMap.get(old.id);
|
|
427
|
+
if (!fresh) return old;
|
|
428
|
+
return {
|
|
429
|
+
...old,
|
|
430
|
+
status: fresh.status ?? old.status,
|
|
431
|
+
error: fresh.error ?? old.error,
|
|
432
|
+
errorAt: fresh.errorAt ?? old.errorAt,
|
|
433
|
+
};
|
|
434
|
+
});
|
|
435
|
+
t.updatedAt = nowIso();
|
|
436
|
+
await writeJson(TASKS_FILE, data);
|
|
437
|
+
publish('task:update', t);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* 构建单 sub 执行时的 priorOutputs:把同一 task 下"排在当前 sub 之前"且已 done 的
|
|
443
|
+
* 子任务输出摘要收集起来。这样单独跑一个 sub 时,它也能拿到前序上下文。
|
|
444
|
+
*/
|
|
445
|
+
export async function collectPriorOutputs(task, targetSub) {
|
|
446
|
+
const targetIdx = task.subtasks.findIndex(s => s.id === targetSub.id);
|
|
447
|
+
if (targetIdx < 0) return [];
|
|
448
|
+
return collectPriorOutputsUpTo(task, targetIdx);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* 收集 [0, endIdx) 区间内已 done 的 sub 输出摘要,作为队列内 sub 的前序上下文。
|
|
453
|
+
* runTaskQueue 在 fromIndex>0 时调这个,让"从此处开始"也能拼上前序 done sub 的结论。
|
|
454
|
+
*/
|
|
455
|
+
export async function collectPriorOutputsUpTo(task, endIdx) {
|
|
456
|
+
const prior = [];
|
|
457
|
+
for (let i = 0; i < endIdx; i++) {
|
|
458
|
+
const s = task.subtasks[i];
|
|
459
|
+
if (s.status !== 'done') continue;
|
|
460
|
+
// 从 jobs 列表里找最近一个属于这个 sub 且 status=done 的 job,
|
|
461
|
+
// 取其 output 作为"前序上下文"。完整透传,不做字符截断。
|
|
462
|
+
const job = snapshotJobs()
|
|
463
|
+
.filter(j => j.subId === s.id && j.status === 'done')
|
|
464
|
+
.sort((a, b) => (b.endedAt || '').localeCompare(a.endedAt || ''))[0];
|
|
465
|
+
if (!job) continue;
|
|
466
|
+
prior.push({ title: s.title, output: job.output || '' });
|
|
467
|
+
}
|
|
468
|
+
return prior;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// polling 等进程退出:信号 0 探测存活;30 分钟超时兜底
|
|
472
|
+
export function waitProcessExit(pid) {
|
|
473
|
+
return new Promise(resolve => {
|
|
474
|
+
let exited = false;
|
|
475
|
+
const tryCheck = () => {
|
|
476
|
+
if (exited) return;
|
|
477
|
+
try {
|
|
478
|
+
process.kill(pid, 0); // 信号 0 = 探测存活
|
|
479
|
+
} catch (err) {
|
|
480
|
+
// 只在进程真的消失(ESRCH / EPERM)时才 resolve;
|
|
481
|
+
if (err && (err.code === 'ESRCH' || err.code === 'EPERM')) {
|
|
482
|
+
exited = true;
|
|
483
|
+
resolve();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
setTimeout(tryCheck, 1500);
|
|
488
|
+
};
|
|
489
|
+
tryCheck();
|
|
490
|
+
// 兜底:30 分钟超时自动结束
|
|
491
|
+
setTimeout(() => { if (!exited) { exited = true; resolve(); } }, 30 * 60 * 1000);
|
|
492
|
+
});
|
|
493
|
+
}
|