zen-gitsync 2.17.31 → 2.17.33
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 +14 -6
- package/package.json +10 -1
- package/scripts/release.js +66 -29
- package/src/ui/public/assets/{AgentEngineSelector-bQmJlH1b.js → AgentEngineSelector-CGrUh2kD.js} +1 -1
- package/src/ui/public/assets/{AgentView-DpNj6VFj.js → AgentView-BRC3kn-3.js} +1 -1
- package/src/ui/public/assets/{AppVersionBadge-2etZBF2E.js → AppVersionBadge-BR_ZX6Oq.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-BvbMlZW6.js → BranchSelector-C77-aizC.js} +1 -1
- package/src/ui/public/assets/{CommitForm-C-cL8_vz.css → CommitForm-BbgIoF4h.css} +1 -1
- package/src/ui/public/assets/CommitForm-C6qTIW0T.js +6 -0
- package/src/ui/public/assets/{CommonDialog-UrQUmb6G.js → CommonDialog-kDck8ITZ.js} +1 -1
- package/src/ui/public/assets/{EditorView-DIu2cIai.js → EditorView-DNZXnJnc.js} +1 -1
- package/src/ui/public/assets/{FileDiffViewer-BlDGBxKd.js → FileDiffViewer-BxFkqvIA.js} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-MPq4Fh6G.js → FlowExecutionViewer-DDkb05TW.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-ByBJ4B3G.js → FlowOrchestrationWorkspace-BiHGQE0o.js} +1 -1
- package/src/ui/public/assets/{IconButton-BvbRjQ_D.js → IconButton-BflzboUD.js} +1 -1
- package/src/ui/public/assets/{LogList-Xs52JPC1.js → LogList-DHfr-rPz.js} +1 -1
- package/src/ui/public/assets/{MindmapView-Br7Fj7Ga.js → MindmapView-7u02tj5n.js} +1 -1
- package/src/ui/public/assets/{MonitorView-DSV5_Q-6.js → MonitorView-cdUkh4VE.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-BCS2YQdX.js → ProjectStartupButton-B9KAavAO.js} +1 -1
- package/src/ui/public/assets/{RecentDirectoriesChat-Dv47YPKL.js → RecentDirectoriesChat-DQ9phSSJ.js} +1 -1
- package/src/ui/public/assets/{RemoteManagerDialog-CIHTYzr7.js → RemoteManagerDialog-CgnM1ndZ.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-DCYTEmuq.js → RemoteRepoCard-BDAvJcFk.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-BANSmwrh.js → SourceMapView-BEAC-dHC.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-b50XyCO5.js → SvgIcon-B-2iTLW-.js} +1 -1
- package/src/ui/public/assets/{TemplateManager-D5-gC33Z.js → TemplateManager-CYy3G7rH.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-BFmTQYZP.js → UserInputNode-DIX4shBD.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-BAko4SjJ.js +20 -0
- package/src/ui/public/assets/WorkbenchView-DWQChnqF.css +1 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-MIPci_U9.js → _plugin-vue_export-helper-D8rwCQpe.js} +6 -6
- package/src/ui/public/assets/{agentConversations-D8mdZaNY.js → agentConversations-C0WoSH0y.js} +1 -1
- package/src/ui/public/assets/{configStore-BMixvVAA.js → configStore-BFGSfIBp.js} +1 -1
- package/src/ui/public/assets/{gitStore-DriJnutC.js → gitStore-BcrFZogC.js} +1 -1
- package/src/ui/public/assets/index-BCTkDpQv.css +1 -0
- package/src/ui/public/assets/{index-CEEQaAF4.js → index-aNSr4k87.js} +10 -10
- package/src/ui/public/assets/{previewSandbox-D86LH88s.js → previewSandbox-C_ZzCEpS.js} +1 -1
- package/src/ui/public/assets/{toolsStore-B1Lx5gWu.js → toolsStore-B3LQrSEp.js} +1 -1
- package/src/ui/public/index.html +12 -12
- package/src/ui/server/routes/workbench/index.js +182 -18
- package/src/ui/server/routes/workbench/jobActivity.js +18 -6
- package/src/ui/server/routes/workbench/liveJobs.js +7 -1
- package/src/ui/server/routes/workbench/llmClient.js +37 -6
- package/src/ui/server/routes/workbench/progressReport.js +141 -50
- package/src/ui/server/routes/workbench/progressReport.test.js +101 -4
- package/src/ui/server/routes/workbench/projectRegistry.js +58 -2
- package/src/ui/server/routes/workbench/projectRegistry.test.js +51 -0
- package/src/ui/server/routes/workbench/stallWatchdog.js +330 -0
- package/src/ui/server/routes/workbench/stallWatchdog.test.js +356 -0
- package/src/ui/server/utils/directoryGitState.js +41 -1
- package/src/ui/server/utils/directoryGitState.test.js +91 -0
- package/src/utils/desktopNotify.js +199 -41
- package/src/utils/desktopNotify.test.js +84 -29
- package/src/ui/public/assets/CommitForm-Bp41Q0Oo.js +0 -6
- package/src/ui/public/assets/WorkbenchView-Dc7bMMfe.css +0 -1
- package/src/ui/public/assets/WorkbenchView-DsPII7uQ.js +0 -20
- package/src/ui/public/assets/index-BrIl1iaG.css +0 -1
|
@@ -47,6 +47,17 @@
|
|
|
47
47
|
// 界面不画那条进度条:宁可什么都不画,也不画一条我们自己编出来的进度。
|
|
48
48
|
// · 解析fail-safe:认不出来的标记行**留在正文里**(不吞内容),只把真认下来的那两行
|
|
49
49
|
// 从正文里摘掉。老模型 / 不听话的模型不过是少一条进度条,报告本身照常。
|
|
50
|
+
//
|
|
51
|
+
// 2026-09-30 晚修的「有时候生成失败」(用户报的,历史里三条连着空报告):
|
|
52
|
+
// · 失败形态:errorCode=LLM_FAILED、errorDetail **空**、text 空 —— 界面上只有一句
|
|
53
|
+
// "生成失败,只记录了任务事实",盘上查不出任何原因。
|
|
54
|
+
// · 根因:`max_tokens` 是**思考 + 正文共用**的,而 900 的预算被推理模型的思考写满了。
|
|
55
|
+
// 用本机默认模型(deepseek-v4.1-flash)复现:同样一份 prompt,一轮思考 2708 字符、
|
|
56
|
+
// content 0 字节,HTTP 却是 200 —— 前端看到的"有时候",就是模型这次想得多不多。
|
|
57
|
+
// · 三处改动:预算 900 → 3000(正文只要 200~400,其余是思考的余量);
|
|
58
|
+
// 被截断且正文为空时**加预算重试一次**(第二种预算见 MAX_REPORT_TOKENS_RETRY);
|
|
59
|
+
// 两轮都空记成 `LLM_EMPTY`,并把 finish_reason / token 用量写进 errorDetail,
|
|
60
|
+
// 下次不用再翻网关日志。
|
|
50
61
|
|
|
51
62
|
import { callLlmStream } from './llmClient.js';
|
|
52
63
|
import { createThinkFilter } from '../../../../cli/ai/streamFilter.js';
|
|
@@ -68,8 +79,23 @@ export const MAX_FACT_TASKS = 8;
|
|
|
68
79
|
const MAX_TITLE_CHARS = 140;
|
|
69
80
|
const MAX_REPORT_CHARS = 2000;
|
|
70
81
|
const MAX_ERROR_CHARS = 300;
|
|
71
|
-
/**
|
|
72
|
-
|
|
82
|
+
/**
|
|
83
|
+
* 生成预算。**注意这是"思考 + 正文"共用的**:推理模型(本机默认的
|
|
84
|
+
* deepseek-v4.1-flash 就是)在写正文之前会先想一遍,思考的 token 也从这里扣。
|
|
85
|
+
*
|
|
86
|
+
* 2026-09-30 从 900 提到 3000:900 的时候观察到的失败形态是**思考把预算写满、
|
|
87
|
+
* 正文一个字都没有**,HTTP 200、`finish_reason='length'`、content 空 —— 界面上
|
|
88
|
+
* 就是"生成失败,只记录了任务事实"(那三条失败报告全是这个,见下面的重试)。
|
|
89
|
+
* 正文本身只要 200~400(220 字的汇报),剩下的全是留给思考的余量。
|
|
90
|
+
*/
|
|
91
|
+
export const MAX_REPORT_TOKENS = 3000;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 还是被截断时的重试预算。只在"第一次被截断且正文为空"时用一次 ——
|
|
95
|
+
* 思考的长度跟任务数量、日志长度一起涨,3000 也不是保险箱(实测 4 个任务时
|
|
96
|
+
* 思考已到 2708 字符),而重试的代价只是一次已经失败掉的调用。
|
|
97
|
+
*/
|
|
98
|
+
export const MAX_REPORT_TOKENS_RETRY = 8000;
|
|
73
99
|
/** 生成超时。它跑在服务端后台,卡住不返回会一直占着"正在生成"这个位子 */
|
|
74
100
|
const REPORT_TIMEOUT_MS = 90000;
|
|
75
101
|
|
|
@@ -104,6 +130,12 @@ export function buildRunningFacts({ jobs = [], tasks = [], now = Date.now() } =
|
|
|
104
130
|
const started = Date.parse(job.startedAt || '');
|
|
105
131
|
out.push({
|
|
106
132
|
taskId: job.taskId || null,
|
|
133
|
+
/**
|
|
134
|
+
* 这条事实对应哪次执行。报告本身用不到(一张卡片一条事实),
|
|
135
|
+
* 但静默看门狗要拿着判定结果**回到那条 job 上改状态** ——
|
|
136
|
+
* 同一个任务可能有好几条 job(重跑 / 并行子任务),按 taskId 改会改错人。
|
|
137
|
+
*/
|
|
138
|
+
jobId: job.id || null,
|
|
107
139
|
taskTitle: String((task && task.title) || '').slice(0, MAX_TITLE_CHARS),
|
|
108
140
|
projectName: projectName(projectPath),
|
|
109
141
|
startedAt: job.startedAt || null,
|
|
@@ -358,6 +390,30 @@ Then write **one English progress report**:
|
|
|
358
390
|
5. Output only the report itself`;
|
|
359
391
|
}
|
|
360
392
|
|
|
393
|
+
/**
|
|
394
|
+
* 一次调用的元信息摘要 → 写进 errorDetail(界面上悬停可见的那个 tooltip)。
|
|
395
|
+
*
|
|
396
|
+
* 为什么值得单独写一行:界面上"生成失败"三个字背后是几种完全不同的处理办法,
|
|
397
|
+
* 而它们在盘上的记录里**长得一模一样**。2026-09-30 那三条失败报告就是
|
|
398
|
+
* `errorCode=LLM_FAILED` + `errorDetail=''` + `text=''`,光看 orchestrator-reports.json
|
|
399
|
+
* 什么都查不出来(得去翻网关日志才知道是被 max_tokens 截断)。
|
|
400
|
+
* 摘要里放 finish_reason 与 token 用量,下次一眼能分清"被预算截断"和"网关没回话"。
|
|
401
|
+
*
|
|
402
|
+
* 认不出来的字段一律不写(老网关不给 usage),整串截到 MAX_ERROR_CHARS。
|
|
403
|
+
*/
|
|
404
|
+
export function describeCallResult(res, maxTokens) {
|
|
405
|
+
const r = res && typeof res === 'object' ? res : {};
|
|
406
|
+
const parts = [`max_tokens=${Number(maxTokens) || 0}`];
|
|
407
|
+
parts.push(`finish_reason=${r.finishReason || 'unknown'}`);
|
|
408
|
+
const u = r.usage && typeof r.usage === 'object' ? r.usage : null;
|
|
409
|
+
if (u && u.completion_tokens != null) parts.push(`completion_tokens=${u.completion_tokens}`);
|
|
410
|
+
// reasoning_tokens 是"预算里有多少花在思考上"的唯一直接证据
|
|
411
|
+
const detail = u && u.completion_tokens_details;
|
|
412
|
+
if (detail && detail.reasoning_tokens != null) parts.push(`reasoning_tokens=${detail.reasoning_tokens}`);
|
|
413
|
+
if (r.aborted) parts.push('aborted');
|
|
414
|
+
return parts.join(' ').slice(0, MAX_ERROR_CHARS);
|
|
415
|
+
}
|
|
416
|
+
|
|
361
417
|
/**
|
|
362
418
|
* 生成一份进度报告。**不落盘**(落盘由 orchestratorStore.appendReport 负责),
|
|
363
419
|
* 返回的就是要存的那个记录。
|
|
@@ -365,6 +421,7 @@ Then write **one English progress report**:
|
|
|
365
421
|
* 三种"没有正文"的情况都记成一条**带事实的报告**而不是抛错,理由是一样:
|
|
366
422
|
* 面板上"有 2 个任务在跑,但没配模型"远比一个空白面板有用,
|
|
367
423
|
* 而且失败会留在历史里 —— 用户能看出"是模型挂了"而不是"这个功能没做"。
|
|
424
|
+
* (第四种,"模型答了但没写正文",记成 LLM_EMPTY —— 成因与处理办法都不同。)
|
|
368
425
|
*
|
|
369
426
|
* @param {object} input
|
|
370
427
|
* @param {object[]} input.facts buildRunningFacts 的输出
|
|
@@ -403,58 +460,92 @@ export async function generateProgressReport({
|
|
|
403
460
|
|
|
404
461
|
const zh = !String(locale || '').startsWith('en');
|
|
405
462
|
const prompt = buildReportPrompt(facts, locale);
|
|
406
|
-
const controller = new AbortController();
|
|
407
|
-
const timer = setTimeout(() => controller.abort(), REPORT_TIMEOUT_MS);
|
|
408
|
-
|
|
409
|
-
// 部分模型把推理也塞进 content(与 recentDirectoriesAiSummary 同一处理):
|
|
410
|
-
// 汇报里混进一段思考过程,用户会以为那是结论。
|
|
411
|
-
const filter = createThinkFilter();
|
|
412
|
-
let text = '';
|
|
413
|
-
const forward = (segments) => {
|
|
414
|
-
for (const seg of segments) if (seg.content) text += seg.content;
|
|
415
|
-
};
|
|
416
463
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
forward(
|
|
429
|
-
|
|
430
|
-
} catch (err) {
|
|
431
|
-
return {
|
|
432
|
-
...base,
|
|
433
|
-
errorCode: 'LLM_FAILED',
|
|
434
|
-
errorDetail: String((err && err.message) || err).slice(0, MAX_ERROR_CHARS),
|
|
464
|
+
/**
|
|
465
|
+
* 跑一次模型。每次调用**自带** AbortController 与过滤器:重试必须是干净的一次,
|
|
466
|
+
* 上一轮的超时定时器和半截 think 状态都不能带过来。
|
|
467
|
+
* 部分模型把推理也塞进 content(与 recentDirectoriesAiSummary 同一处理):
|
|
468
|
+
* 汇报里混进一段思考过程,用户会以为那是结论。
|
|
469
|
+
*/
|
|
470
|
+
async function attempt(maxTokens) {
|
|
471
|
+
const controller = new AbortController();
|
|
472
|
+
const timer = setTimeout(() => controller.abort(), REPORT_TIMEOUT_MS);
|
|
473
|
+
const filter = createThinkFilter();
|
|
474
|
+
let raw = '';
|
|
475
|
+
const forward = (segments) => {
|
|
476
|
+
for (const seg of segments) if (seg.content) raw += seg.content;
|
|
435
477
|
};
|
|
436
|
-
|
|
437
|
-
|
|
478
|
+
try {
|
|
479
|
+
const res = await callStream(
|
|
480
|
+
model,
|
|
481
|
+
prompt,
|
|
482
|
+
(delta) => { if (delta.content) forward(filter.feed(delta.content)); },
|
|
483
|
+
{
|
|
484
|
+
maxTokens,
|
|
485
|
+
systemPrompt: zh ? SYSTEM_PROMPT_ZH : SYSTEM_PROMPT_EN,
|
|
486
|
+
signal: controller.signal,
|
|
487
|
+
}
|
|
488
|
+
);
|
|
489
|
+
forward(filter.flush());
|
|
490
|
+
return {
|
|
491
|
+
raw,
|
|
492
|
+
aborted: !!(res && res.aborted),
|
|
493
|
+
// 老签名(单测注入的假流)没有这两个字段 —— 拿不到就当"不知道",
|
|
494
|
+
// 后果是退化成"不重试",不是重试到天荒地老
|
|
495
|
+
finishReason: String((res && res.finishReason) || ''),
|
|
496
|
+
meta: describeCallResult(res, maxTokens),
|
|
497
|
+
};
|
|
498
|
+
} finally {
|
|
499
|
+
clearTimeout(timer);
|
|
500
|
+
}
|
|
438
501
|
}
|
|
439
502
|
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
503
|
+
// 两轮:常规预算 → (只有**被截断且正文为空**才)加预算重试一次。
|
|
504
|
+
// 中途任何一次拿到正文就返回;一次都没拿到正文时,第二轮的元信息更值得留档
|
|
505
|
+
// (它才带着"两次都空"这个事实)。
|
|
506
|
+
let meta = '';
|
|
507
|
+
for (let round = 0; round < 2; round++) {
|
|
508
|
+
const budget = round === 0 ? MAX_REPORT_TOKENS : MAX_REPORT_TOKENS_RETRY;
|
|
509
|
+
let res;
|
|
510
|
+
try {
|
|
511
|
+
res = await attempt(budget);
|
|
512
|
+
} catch (err) {
|
|
513
|
+
return {
|
|
514
|
+
...base,
|
|
515
|
+
errorCode: 'LLM_FAILED',
|
|
516
|
+
errorDetail: String((err && err.message) || err).slice(0, MAX_ERROR_CHARS),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
// 超时不重试:90 秒没回来,再来一次大概率还是 90 秒
|
|
520
|
+
if (res.aborted) return { ...base, errorCode: 'LLM_TIMEOUT', errorDetail: res.meta };
|
|
521
|
+
meta = res.meta;
|
|
522
|
+
|
|
523
|
+
// 先摘掉头部那两行(PROGRESS / TASKS),剩下的才是给用户看的正文。
|
|
524
|
+
// 顺序不能反:先截断再解析,免得 2000 字的正文把标记行挤出去(标记总在最前面,
|
|
525
|
+
// 截断其实伤不到它,但"先解析"这件事本身更经得起以后改格式)
|
|
526
|
+
const parsed = parseProgressHeader(res.raw.trim().slice(0, MAX_REPORT_CHARS));
|
|
527
|
+
if (parsed.text) {
|
|
528
|
+
return {
|
|
529
|
+
...base,
|
|
530
|
+
text: parsed.text,
|
|
531
|
+
percent: parsed.percent,
|
|
532
|
+
// 每个任务各自的百分比挂在事实快照上:报告卡片里那一行行事实本来就在,
|
|
533
|
+
// 挂上去才不会"整体 62%,但看不出是哪两个任务拖的"
|
|
534
|
+
tasks: facts.map((f, i) => ({
|
|
535
|
+
...f,
|
|
536
|
+
percent: i < parsed.taskPercents.length ? parsed.taskPercents[i] : null,
|
|
537
|
+
})),
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
// 空正文但不是被预算截断(模型自己 stop 了、或只回了那两行标记):
|
|
541
|
+
// 加预算重试解决不了,直接按空正文记
|
|
542
|
+
if (res.finishReason !== 'length') break;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// 空正文按失败记:界面上一张"什么都没有"的报告卡片和"模型没返回内容"是两回事。
|
|
546
|
+
// 单独一个码是因为它的成因与前两个不同(模型答了,但没写正文),
|
|
547
|
+
// 处理办法也不同 —— LLM_EMPTY 要调的是生成预算,不是网络。
|
|
548
|
+
return { ...base, errorCode: 'LLM_EMPTY', errorDetail: meta };
|
|
458
549
|
}
|
|
459
550
|
|
|
460
551
|
export const __testables = {
|
|
@@ -28,11 +28,14 @@ import { readFile } from 'node:fs/promises';
|
|
|
28
28
|
import {
|
|
29
29
|
buildRunningFacts,
|
|
30
30
|
buildReportPrompt,
|
|
31
|
+
describeCallResult,
|
|
31
32
|
formatDuration,
|
|
32
33
|
generateProgressReport,
|
|
33
34
|
normalizePercent,
|
|
34
35
|
parseProgressHeader,
|
|
35
36
|
MAX_FACT_TASKS,
|
|
37
|
+
MAX_REPORT_TOKENS,
|
|
38
|
+
MAX_REPORT_TOKENS_RETRY,
|
|
36
39
|
SYSTEM_PROMPT_ZH,
|
|
37
40
|
SYSTEM_PROMPT_EN,
|
|
38
41
|
} from './progressReport.js';
|
|
@@ -237,11 +240,11 @@ test('英文模板同样给思考 / 工具分布 / 静默', () => {
|
|
|
237
240
|
// ── generateProgressReport ──────────────────────────────────────────────
|
|
238
241
|
|
|
239
242
|
/** 假流式调用:按 delta 序列回调,再给出终态 */
|
|
240
|
-
function fakeStream(deltas, { aborted = false, error = null } = {}) {
|
|
243
|
+
function fakeStream(deltas, { aborted = false, error = null, finishReason = '', usage = null } = {}) {
|
|
241
244
|
return async (_model, _prompt, onDelta) => {
|
|
242
245
|
if (error) throw error;
|
|
243
246
|
for (const d of deltas) onDelta(d);
|
|
244
|
-
return { content: deltas.map(d => d.content || '').join(''), aborted };
|
|
247
|
+
return { content: deltas.map(d => d.content || '').join(''), aborted, finishReason, usage };
|
|
245
248
|
};
|
|
246
249
|
}
|
|
247
250
|
|
|
@@ -300,7 +303,7 @@ test('thinking 段被滤掉,不混进正文(否则用户会以为那是结
|
|
|
300
303
|
assert.equal(report.text, '一切正常。');
|
|
301
304
|
});
|
|
302
305
|
|
|
303
|
-
test('模型报错 → LLM_FAILED
|
|
306
|
+
test('模型报错 → LLM_FAILED 并留住原因;空正文记成 LLM_EMPTY', async () => {
|
|
304
307
|
const facts = buildRunningFacts({ jobs: [runningJob()], tasks: TASKS, now: NOW });
|
|
305
308
|
const failed = await generateProgressReport({
|
|
306
309
|
facts,
|
|
@@ -310,13 +313,107 @@ test('模型报错 → LLM_FAILED 并留住原因;空正文也按失败记', a
|
|
|
310
313
|
assert.equal(failed.errorCode, 'LLM_FAILED');
|
|
311
314
|
assert.equal(failed.errorDetail, 'HTTP 401');
|
|
312
315
|
|
|
316
|
+
// 空正文与"报错"分开记:成因不同(这个是模型答了但没写正文),处理办法也不同
|
|
313
317
|
const empty = await generateProgressReport({
|
|
314
318
|
facts,
|
|
315
319
|
model: MODEL,
|
|
316
320
|
callStream: fakeStream([{ content: ' ' }]),
|
|
317
321
|
});
|
|
318
|
-
assert.equal(empty.errorCode, '
|
|
322
|
+
assert.equal(empty.errorCode, 'LLM_EMPTY');
|
|
319
323
|
assert.equal(empty.text, '');
|
|
324
|
+
// detail 里必须留下可以查的东西 —— 老实现这里是空串,界面上只有一句"生成失败",
|
|
325
|
+
// 盘上也查不出原因(2026-09-30 那三条失败报告就是这么埋掉的)
|
|
326
|
+
assert.match(empty.errorDetail, /finish_reason=/);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ── 空正文:预算被思考吃光(2026-09-30 用户报的"有时候生成失败")──────────
|
|
330
|
+
//
|
|
331
|
+
// 复现过的形态:推理模型的 `max_tokens` 是**思考 + 正文共用**的,思考写满预算时
|
|
332
|
+
// 正文一个字都没写出来,HTTP 还是 200。900 的老预算下实测思考 2708 字符 / content 0。
|
|
333
|
+
// 这三条守的是"预算够用 + 截断了会自己重试一次 + 重试也没用时不假装成功"。
|
|
334
|
+
|
|
335
|
+
test('生成预算够装下思考与正文(900 那个老值会被推理模型吃光)', () => {
|
|
336
|
+
assert.ok(MAX_REPORT_TOKENS >= 2000, `MAX_REPORT_TOKENS=${MAX_REPORT_TOKENS}`);
|
|
337
|
+
assert.ok(MAX_REPORT_TOKENS_RETRY > MAX_REPORT_TOKENS, '重试预算必须比常规预算大');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test('第一次被截断(finish_reason=length)且正文为空 → 加预算重试一次并拿到正文', async () => {
|
|
341
|
+
const facts = buildRunningFacts({ jobs: [runningJob()], tasks: TASKS, now: NOW });
|
|
342
|
+
const budgets = [];
|
|
343
|
+
const report = await generateProgressReport({
|
|
344
|
+
facts,
|
|
345
|
+
model: MODEL,
|
|
346
|
+
callStream: async (_m, _p, onDelta, opts) => {
|
|
347
|
+
budgets.push(opts.maxTokens);
|
|
348
|
+
if (budgets.length === 1) return { content: '', aborted: false, finishReason: 'length' };
|
|
349
|
+
onDelta({ content: 'PROGRESS: 40\nTASKS: 40\n第二次拿到了汇报正文。' });
|
|
350
|
+
return { content: '...', aborted: false, finishReason: 'stop' };
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
assert.equal(report.errorCode, '');
|
|
354
|
+
assert.equal(report.text, '第二次拿到了汇报正文。');
|
|
355
|
+
assert.equal(report.percent, 40);
|
|
356
|
+
assert.equal(budgets.length, 2, '必须恰好重试一次');
|
|
357
|
+
assert.ok(budgets[1] > budgets[0], `预算没加大:${budgets.join(' -> ')}`);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test('空正文但没被截断(模型自己 stop 了)→ 不重试,加预算也没用', async () => {
|
|
361
|
+
const facts = buildRunningFacts({ jobs: [runningJob()], tasks: TASKS, now: NOW });
|
|
362
|
+
let calls = 0;
|
|
363
|
+
const report = await generateProgressReport({
|
|
364
|
+
facts,
|
|
365
|
+
model: MODEL,
|
|
366
|
+
callStream: async () => { calls++; return { content: '', aborted: false, finishReason: 'stop' }; },
|
|
367
|
+
});
|
|
368
|
+
assert.equal(calls, 1);
|
|
369
|
+
assert.equal(report.errorCode, 'LLM_EMPTY');
|
|
370
|
+
assert.match(report.errorDetail, /finish_reason=stop/);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test('重试也没拿到正文时,errorDetail 里留下 finish_reason 与思考用量', async () => {
|
|
374
|
+
const facts = buildRunningFacts({ jobs: [runningJob()], tasks: TASKS, now: NOW });
|
|
375
|
+
let calls = 0;
|
|
376
|
+
const report = await generateProgressReport({
|
|
377
|
+
facts,
|
|
378
|
+
model: MODEL,
|
|
379
|
+
callStream: async () => {
|
|
380
|
+
calls++;
|
|
381
|
+
return {
|
|
382
|
+
content: '',
|
|
383
|
+
aborted: false,
|
|
384
|
+
finishReason: 'length',
|
|
385
|
+
usage: { completion_tokens: 8000, completion_tokens_details: { reasoning_tokens: 7980 } },
|
|
386
|
+
};
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
assert.equal(calls, 2, '被截断时两轮都该跑');
|
|
390
|
+
assert.equal(report.errorCode, 'LLM_EMPTY');
|
|
391
|
+
// 这一串就是"下次不用再翻网关日志"的全部意义
|
|
392
|
+
assert.match(report.errorDetail, /finish_reason=length/);
|
|
393
|
+
assert.match(report.errorDetail, /reasoning_tokens=7980/);
|
|
394
|
+
assert.match(report.errorDetail, /max_tokens=8000/);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('超时不重试(90 秒没回来,再来一次大概率还是 90 秒)', async () => {
|
|
398
|
+
const facts = buildRunningFacts({ jobs: [runningJob()], tasks: TASKS, now: NOW });
|
|
399
|
+
let calls = 0;
|
|
400
|
+
const report = await generateProgressReport({
|
|
401
|
+
facts,
|
|
402
|
+
model: MODEL,
|
|
403
|
+
callStream: async () => { calls++; return { content: '', aborted: true, finishReason: 'length' }; },
|
|
404
|
+
});
|
|
405
|
+
assert.equal(calls, 1);
|
|
406
|
+
assert.equal(report.errorCode, 'LLM_TIMEOUT');
|
|
407
|
+
assert.match(report.errorDetail, /aborted/);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('describeCallResult 认不出的字段不写,整串有上限', () => {
|
|
411
|
+
assert.equal(describeCallResult(null, 900), 'max_tokens=900 finish_reason=unknown');
|
|
412
|
+
assert.equal(
|
|
413
|
+
describeCallResult({ finishReason: 'stop' }, 3000),
|
|
414
|
+
'max_tokens=3000 finish_reason=stop'
|
|
415
|
+
);
|
|
416
|
+
assert.ok(describeCallResult({ finishReason: 'x'.repeat(600) }, 1).length <= 300);
|
|
320
417
|
});
|
|
321
418
|
|
|
322
419
|
test('流被中断(超时)→ LLM_TIMEOUT,而不是当成一句空汇报', async () => {
|
|
@@ -124,22 +124,53 @@ export function latestJob(jobsForTask) {
|
|
|
124
124
|
return best;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* 用户手动标的「已完成」(`task.manualDoneAt`,由 POST /tasks/:id/done 写)现在还成不成立。
|
|
129
|
+
*
|
|
130
|
+
* 为什么要这么个字段:列本来纯靠执行事实推导,但有**两件事只有人知道** ——
|
|
131
|
+
* 一条待在「待处理」里的任务其实早在别处干完了;一条「进行中」的任务模型已经不说话了,
|
|
132
|
+
* 用户比静默看门狗更早判断它干完了。这两件事推导不出来,只能由人落一个标记。
|
|
133
|
+
*
|
|
134
|
+
* 为什么写成"时间比较"而不是"起新一轮时把标记清掉":
|
|
135
|
+
* 起 job 的入口不止一处(看板「执行」、编辑器「执行任务」、续聊、主 Agent 自动派发),
|
|
136
|
+
* 每处都去清一遍,漏掉任何一处就会留下一张"明明刚跑完却还挂在已完成"的卡片 —— 而且不报错。
|
|
137
|
+
* 时间比较把判据收在一个纯函数里:**标记之后又跑过一轮,这次标记就作废**
|
|
138
|
+
* (那次执行才是最新的事实,用户标完成时看到的不是它)。
|
|
139
|
+
*
|
|
140
|
+
* jobsForTask 里没有 startedAt 的老记录不算数:宁可让标记继续成立,
|
|
141
|
+
* 也不能凭空认为"标记过期了"而把用户手动收掉的任务又弹回待处理。
|
|
142
|
+
*/
|
|
143
|
+
export function manualDoneHolds(task, jobsForTask = []) {
|
|
144
|
+
const at = task && typeof task.manualDoneAt === 'string' ? task.manualDoneAt : '';
|
|
145
|
+
if (!at) return false;
|
|
146
|
+
return !(Array.isArray(jobsForTask) ? jobsForTask : [])
|
|
147
|
+
.some(j => j && typeof j.startedAt === 'string' && j.startedAt > at);
|
|
148
|
+
}
|
|
149
|
+
|
|
127
150
|
/**
|
|
128
151
|
* 把一个任务推导到看板的四列之一。纯函数,单测覆盖。
|
|
129
152
|
*
|
|
130
153
|
* 口径(按优先级,逐条短路):
|
|
131
154
|
* 1. 有 job 处于 running/pending → `doing`
|
|
132
|
-
* 2.
|
|
133
|
-
* 3.
|
|
155
|
+
* 2. 有成立的手动「已完成」标记 → `done`
|
|
156
|
+
* 3. 从没执行过 → `todo`
|
|
157
|
+
* 4. 最近一条 job 是 done → `done`;其余终态(error/cancelled)→ `todo`
|
|
134
158
|
*
|
|
135
159
|
* error/cancelled 不再单列:列表示任务是否仍待处理,错误是这一轮执行的结果,
|
|
136
160
|
* 具体错误继续通过卡片标记和任务详情展示。
|
|
161
|
+
*
|
|
162
|
+
* 「在跑」为什么排在手写标记前面:手动完成一条**正在跑**的任务时,路由会先把这一轮停掉
|
|
163
|
+
* 再落标记(见 /tasks/:id/done)。停不掉的那种(任务跑在另一个 g ui 实例里)路由会直接
|
|
164
|
+
* 拒绝,所以正常情况下走不到这条 —— 真走到了,也宁可继续显示「进行中」:
|
|
165
|
+
* 卡片说"已完成"而进程还在写代码,是比列不准更难发现的一种撒谎。
|
|
137
166
|
*/
|
|
138
167
|
export function deriveTaskColumn(task, jobsForTask = []) {
|
|
139
168
|
const jobs = Array.isArray(jobsForTask) ? jobsForTask : [];
|
|
140
169
|
|
|
141
170
|
if (jobs.some(j => j && (j.status === 'running' || j.status === 'pending'))) return 'doing';
|
|
142
171
|
|
|
172
|
+
if (manualDoneHolds(task, jobs)) return 'done';
|
|
173
|
+
|
|
143
174
|
if (jobs.length === 0) return 'todo';
|
|
144
175
|
const last = latestJob(jobs);
|
|
145
176
|
return last && last.status === 'done' ? 'done' : 'todo';
|
|
@@ -364,6 +395,31 @@ export function decorateTaskForBoard(task, jobsForTask = [], { now = Date.now()
|
|
|
364
395
|
lastDurationMs: jobDurationMs(last),
|
|
365
396
|
/** 那条 job 的启动时刻,配合 lastJobEndedAt 给悬停提示里的「几点到几点」 */
|
|
366
397
|
lastJobStartedAt: last ? (last.startedAt || null) : null,
|
|
398
|
+
/**
|
|
399
|
+
* 「这条不是跑完的,是**判**完的」——静默看门狗给的 `{at, reason, silentMs}`,没判过是 null。
|
|
400
|
+
*
|
|
401
|
+
* 为什么要专门给卡片一个字段:自动收尾和"进程自己正常退出"在看板上长得一模一样
|
|
402
|
+
* (都是「已完成」+ 一段用时),但它俩的含义差很远 —— 前者是模型读了思考与最后那段话
|
|
403
|
+
* 后认为活已经干完,用户多半想扫一眼依据再决定要不要信。没有这个字段,卡片只会
|
|
404
|
+
* 不声不响地换个列,模型那句依据就永远躺在 jobs.json 里没人看得到。
|
|
405
|
+
*/
|
|
406
|
+
autoCompleted: (last && last.autoCompleted && typeof last.autoCompleted === 'object')
|
|
407
|
+
? {
|
|
408
|
+
at: last.autoCompleted.at || null,
|
|
409
|
+
reason: typeof last.autoCompleted.reason === 'string' ? last.autoCompleted.reason : '',
|
|
410
|
+
silentMs: Number.isFinite(last.autoCompleted.silentMs) ? last.autoCompleted.silentMs : null,
|
|
411
|
+
}
|
|
412
|
+
: null,
|
|
413
|
+
/**
|
|
414
|
+
* 用户手动标的「已完成」时刻(`POST /tasks/:id/done`),没标过 / 已经被新一轮执行作废 → null。
|
|
415
|
+
*
|
|
416
|
+
* 给的是**成立与否**(manualDoneHolds)而不是原始字段:卡片拿它做两件事 ——
|
|
417
|
+
* ① 决定右下角那颗按钮是「完成」还是「撤销」(撤销只该出现在"手动标的"那种卡上,
|
|
418
|
+
* 撤销一个自己跑完的任务什么都不会发生);② 当完成时刻显示。
|
|
419
|
+
* ② 是必须的:手动收掉的任务没有"跑完的时刻",最近一条 job 可能是三天前那次报错,
|
|
420
|
+
* 照旧取 lastJobEndedAt 的话,用户刚点完完成,卡片上写的是「3 天前」。
|
|
421
|
+
*/
|
|
422
|
+
manualDoneAt: manualDoneHolds(task, jobs) ? task.manualDoneAt : null,
|
|
367
423
|
createdAt: task.createdAt || null,
|
|
368
424
|
updatedAt: task.updatedAt || null,
|
|
369
425
|
};
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
projectName,
|
|
24
24
|
resolveTaskRepoPath,
|
|
25
25
|
deriveTaskColumn,
|
|
26
|
+
manualDoneHolds,
|
|
26
27
|
latestJob,
|
|
27
28
|
summarizeProjectTasks,
|
|
28
29
|
buildProjectEntries,
|
|
@@ -113,6 +114,56 @@ test('latestJob 按 startedAt 取最新的一条', () => {
|
|
|
113
114
|
assert.equal(latestJob([]), null);
|
|
114
115
|
});
|
|
115
116
|
|
|
117
|
+
// ── 手动「已完成」标记(看板卡片右下角的「完成」) ──────────────────
|
|
118
|
+
|
|
119
|
+
test('deriveTaskColumn: 手动标的已完成能盖住"从没跑过"与"上一轮报错"', () => {
|
|
120
|
+
const marked = { manualDoneAt: '2026-01-02T00:00:00Z' };
|
|
121
|
+
assert.equal(deriveTaskColumn(marked, []), 'done');
|
|
122
|
+
// 上一轮报错/被取消的任务,本来退回待处理 —— 手动标完就留在已完成
|
|
123
|
+
const err = [job('j1', 't1', 'error', '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z')];
|
|
124
|
+
assert.equal(deriveTaskColumn({ manualDoneAt: '2026-01-02T00:00:00Z' }, err), 'done');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('deriveTaskColumn: 标记之后又起过一轮,这次标记作废', () => {
|
|
128
|
+
// 用户标完成时看到的是"上一轮报错",之后又跑了一轮 —— 那次执行才是最新的事实。
|
|
129
|
+
// 用时间比较而不是"起 job 时清标记":起 job 的入口有四个,漏一处就会留下
|
|
130
|
+
// 一张"刚跑完却还挂在已完成"的卡片,而且不报错。
|
|
131
|
+
const jobs = [
|
|
132
|
+
job('j1', 't1', 'running', '2026-01-03T00:00:00Z'),
|
|
133
|
+
];
|
|
134
|
+
assert.equal(manualDoneHolds({ manualDoneAt: '2026-01-02T00:00:00Z' }, jobs), false);
|
|
135
|
+
// 新一轮跑挂了 → 回到待处理,不是已完成(那条错误正等着人看)
|
|
136
|
+
const later = [job('j2', 't1', 'error', '2026-01-03T00:00:00Z', '2026-01-03T00:01:00Z')];
|
|
137
|
+
assert.equal(deriveTaskColumn({ manualDoneAt: '2026-01-02T00:00:00Z' }, later), 'todo');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('deriveTaskColumn: 在跑永远压过手动标记(卡片不能说"已完成"而进程还在写代码)', () => {
|
|
141
|
+
const jobs = [job('j1', 't1', 'running', '2026-01-01T00:00:00Z')];
|
|
142
|
+
// 正常路径下走不到:路由会先把这一轮停掉再落标记,停不掉(别的实例在跑)直接拒绝。
|
|
143
|
+
assert.equal(deriveTaskColumn({ manualDoneAt: '2026-01-02T00:00:00Z' }, jobs), 'doing');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('manualDoneHolds: 缺 startedAt 的老记录不算"标记过期"', () => {
|
|
147
|
+
// 宁可让标记继续成立,也不能凭空认为它过期、把用户手动收掉的任务弹回待处理
|
|
148
|
+
const legacy = [{ id: 'j1', taskId: 't1', status: 'error', startedAt: '' }];
|
|
149
|
+
assert.equal(manualDoneHolds({ manualDoneAt: '2026-01-02T00:00:00Z' }, legacy), true);
|
|
150
|
+
// 从没标过 / 字段不是字符串 → 一律不成立
|
|
151
|
+
assert.equal(manualDoneHolds({}, []), false);
|
|
152
|
+
assert.equal(manualDoneHolds({ manualDoneAt: 12345 }, []), false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('decorateTaskForBoard: manualDoneAt 给的是"成立与否",不是原始字段', () => {
|
|
156
|
+
const now = Date.parse('2026-01-05T00:00:00Z');
|
|
157
|
+
const task = { id: 't1', manualDoneAt: '2026-01-02T00:00:00Z' };
|
|
158
|
+
// 标记之后没跑过 → 卡片拿到它,据此显示「撤销」并把完成时间显示成标记时刻
|
|
159
|
+
assert.equal(decorateTaskForBoard(task, [], { now }).manualDoneAt, '2026-01-02T00:00:00Z');
|
|
160
|
+
// 标记之后又跑了一轮 → 给 null:卡片上那颗按钮该变回「完成」,不能还挂着「撤销」
|
|
161
|
+
const later = [job('j2', 't1', 'done', '2026-01-03T00:00:00Z', '2026-01-03T00:01:00Z')];
|
|
162
|
+
assert.equal(decorateTaskForBoard(task, later, { now }).manualDoneAt, null);
|
|
163
|
+
// 没标过就是 null(不是 undefined —— 前端按 truthy 判断)
|
|
164
|
+
assert.equal(decorateTaskForBoard({ id: 't2' }, [], { now }).manualDoneAt, null);
|
|
165
|
+
});
|
|
166
|
+
|
|
116
167
|
// ── 项目合并去重 ────────────────────────────────────────────────────
|
|
117
168
|
|
|
118
169
|
test('buildProjectEntries: 最近目录与任务路径取并集去重,大小写与斜杠都归一', () => {
|