zen-gitsync 2.17.32 → 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 +8 -0
- package/package.json +8 -1
- package/src/ui/public/assets/{AgentEngineSelector-CPS9WawU.js → AgentEngineSelector-CGrUh2kD.js} +1 -1
- package/src/ui/public/assets/{AgentView-CHj8Esf5.js → AgentView-BRC3kn-3.js} +1 -1
- package/src/ui/public/assets/{AppVersionBadge-CbYQ0KFm.js → AppVersionBadge-BR_ZX6Oq.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-i_DZoGr4.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-Nwwi4-_9.js → CommonDialog-kDck8ITZ.js} +1 -1
- package/src/ui/public/assets/{EditorView-Cj7r5oIY.js → EditorView-DNZXnJnc.js} +1 -1
- package/src/ui/public/assets/{FileDiffViewer-2vvvRZ_R.js → FileDiffViewer-BxFkqvIA.js} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-STZphigj.js → FlowExecutionViewer-DDkb05TW.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-BdW1MijZ.js → FlowOrchestrationWorkspace-BiHGQE0o.js} +1 -1
- package/src/ui/public/assets/{IconButton-BuiJBsJ3.js → IconButton-BflzboUD.js} +1 -1
- package/src/ui/public/assets/{LogList-C14b7EUJ.js → LogList-DHfr-rPz.js} +1 -1
- package/src/ui/public/assets/{MindmapView-dcm1kcrO.js → MindmapView-7u02tj5n.js} +1 -1
- package/src/ui/public/assets/{MonitorView-7SOXc-I2.js → MonitorView-cdUkh4VE.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-BM2RrBbL.js → ProjectStartupButton-B9KAavAO.js} +1 -1
- package/src/ui/public/assets/{RecentDirectoriesChat-CXxGKkqI.js → RecentDirectoriesChat-DQ9phSSJ.js} +1 -1
- package/src/ui/public/assets/{RemoteManagerDialog-BTtVeges.js → RemoteManagerDialog-CgnM1ndZ.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-B0n8k5Go.js → RemoteRepoCard-BDAvJcFk.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-CT2C3tAc.js → SourceMapView-BEAC-dHC.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-U-AI95UV.js → SvgIcon-B-2iTLW-.js} +1 -1
- package/src/ui/public/assets/{TemplateManager-BIMeoTp0.js → TemplateManager-CYy3G7rH.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-D_V8dhh9.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-DkeTtFKF.js → _plugin-vue_export-helper-D8rwCQpe.js} +6 -6
- package/src/ui/public/assets/{agentConversations-B2aTliJe.js → agentConversations-C0WoSH0y.js} +1 -1
- package/src/ui/public/assets/{configStore-Y1biBqmb.js → configStore-BFGSfIBp.js} +1 -1
- package/src/ui/public/assets/{gitStore-CiBz68_Z.js → gitStore-BcrFZogC.js} +1 -1
- package/src/ui/public/assets/index-BCTkDpQv.css +1 -0
- package/src/ui/public/assets/{index-BoZNUwmd.js → index-aNSr4k87.js} +10 -10
- package/src/ui/public/assets/{previewSandbox-D7HcNin7.js → previewSandbox-C_ZzCEpS.js} +1 -1
- package/src/ui/public/assets/{toolsStore-Dhfr-VDL.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 +20 -4
- package/src/ui/server/routes/workbench/progressReport.js +6 -0
- 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/ui/public/assets/CommitForm-DrAhqXPt.js +0 -6
- package/src/ui/public/assets/WorkbenchView-BeC5sg_V.css +0 -1
- package/src/ui/public/assets/WorkbenchView-C74ciC03.js +0 -20
- package/src/ui/public/assets/index-BrIl1iaG.css +0 -1
|
@@ -0,0 +1,356 @@
|
|
|
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
|
+
// 静默看门狗的断言重心是**它不乱动**:这个模块是唯一一个会自动改任务状态的地方,
|
|
16
|
+
// 而它唯一的输入是另一个模型的自由文本。所以每条测试问的都不是"判得准不准"
|
|
17
|
+
// (那取决于模型),而是"判不准的时候会不会出事":
|
|
18
|
+
// · 静默不够久 / 时间戳推不出来 → 一次都不许问(问了就是白烧额度)
|
|
19
|
+
// · 模型答得含糊、答错格式、调用失败 → 一个字段都不许动
|
|
20
|
+
// · 判定期间任务又活了 → 不许拿上一次的结论去改现在的状态
|
|
21
|
+
// · 判成完成 → **不许编造 exitCode**(进程没退就没有退出码)
|
|
22
|
+
|
|
23
|
+
import { test } from 'node:test';
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
MAX_STALL_CHECKS,
|
|
28
|
+
SILENT_DONE_CHECK_MS,
|
|
29
|
+
applyStallVerdict,
|
|
30
|
+
buildStallFact,
|
|
31
|
+
buildStallPrompt,
|
|
32
|
+
judgeStalledJob,
|
|
33
|
+
parseStallVerdict,
|
|
34
|
+
pickStalledJobs,
|
|
35
|
+
shouldCheckStall,
|
|
36
|
+
stallCheckState,
|
|
37
|
+
} from './stallWatchdog.js';
|
|
38
|
+
|
|
39
|
+
const NOW = Date.parse('2026-10-01T02:00:00.000Z');
|
|
40
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
41
|
+
|
|
42
|
+
/** 一条"跑了 1 小时、静默 12 分钟"的 job(默认就在该判的区间里) */
|
|
43
|
+
function stalledJob(over = {}) {
|
|
44
|
+
return {
|
|
45
|
+
id: 'j1',
|
|
46
|
+
taskId: 't1',
|
|
47
|
+
status: 'running',
|
|
48
|
+
pid: 45796,
|
|
49
|
+
startedAt: iso(NOW - 60 * 60 * 1000),
|
|
50
|
+
lastActivityAt: iso(NOW - 12 * 60 * 1000),
|
|
51
|
+
output: '',
|
|
52
|
+
thinking: '',
|
|
53
|
+
toolCalls: [],
|
|
54
|
+
...over,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const TASK = { id: 't1', title: '这块模型感觉都比较老了', projectPath: 'C:\\ws\\flowdash-ai-radar-demo' };
|
|
59
|
+
const MODEL = { baseURL: 'http://x', model: 'm', apiKey: 'k' };
|
|
60
|
+
|
|
61
|
+
// ── shouldCheckStall / pickStalledJobs ──────────────────────────────────
|
|
62
|
+
|
|
63
|
+
test('静默不到 10 分钟不判(9 分 59 秒也不行)', () => {
|
|
64
|
+
const justUnder = stalledJob({ lastActivityAt: iso(NOW - SILENT_DONE_CHECK_MS + 1000) });
|
|
65
|
+
assert.equal(shouldCheckStall(justUnder, NOW), false);
|
|
66
|
+
const justOver = stalledJob({ lastActivityAt: iso(NOW - SILENT_DONE_CHECK_MS) });
|
|
67
|
+
assert.equal(shouldCheckStall(justOver, NOW), true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('只有 running 才判:pending 是排队、终态是已经有结论了', () => {
|
|
71
|
+
for (const status of ['pending', 'done', 'error', 'cancelled']) {
|
|
72
|
+
assert.equal(shouldCheckStall(stalledJob({ status }), NOW), false, status);
|
|
73
|
+
}
|
|
74
|
+
assert.equal(shouldCheckStall(stalledJob({ status: 'running' }), NOW), true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('推不出静默时长就不判(老记录没有 lastActivityAt,宁可什么都不做)', () => {
|
|
78
|
+
assert.equal(shouldCheckStall(stalledJob({ lastActivityAt: null }), NOW), false);
|
|
79
|
+
assert.equal(shouldCheckStall(stalledJob({ lastActivityAt: '不是时间' }), NOW), false);
|
|
80
|
+
assert.equal(shouldCheckStall(null, NOW), false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('同一段静默里只问一次:判过之后要再等一个 10 分钟', () => {
|
|
84
|
+
const asked = stalledJob({ stallCheck: { at: iso(NOW - 60 * 1000), checks: 1 } });
|
|
85
|
+
assert.equal(shouldCheckStall(asked, NOW), false);
|
|
86
|
+
const old = stalledJob({ stallCheck: { at: iso(NOW - SILENT_DONE_CHECK_MS), checks: 1 } });
|
|
87
|
+
assert.equal(shouldCheckStall(old, NOW), true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test(`答了 ${MAX_STALL_CHECKS} 次"还没完"之后就别再问了(卡死 8 小时不该烧 48 次额度)`, () => {
|
|
91
|
+
const many = stalledJob({ stallCheck: { at: iso(NOW - 60 * 60 * 1000), checks: MAX_STALL_CHECKS } });
|
|
92
|
+
assert.equal(shouldCheckStall(many, NOW), false);
|
|
93
|
+
const oneLess = stalledJob({ stallCheck: { at: iso(NOW - 60 * 60 * 1000), checks: MAX_STALL_CHECKS - 1 } });
|
|
94
|
+
assert.equal(shouldCheckStall(oneLess, NOW), true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('stallCheck 脏值按"从没判过"处理,而不是把整条 job 判死', () => {
|
|
98
|
+
assert.deepEqual(stallCheckState(null), { checks: 0, at: null });
|
|
99
|
+
assert.deepEqual(stallCheckState({ stallCheck: { checks: -2, at: 42 } }), { checks: 0, at: null });
|
|
100
|
+
assert.deepEqual(stallCheckState({ stallCheck: { checks: '3', at: iso(NOW) } }), { checks: 3, at: iso(NOW) });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('pickStalledJobs 只挑该判的(其余原样留在池子里)', () => {
|
|
104
|
+
const jobs = [
|
|
105
|
+
stalledJob({ id: 'a' }),
|
|
106
|
+
stalledJob({ id: 'b', lastActivityAt: iso(NOW - 60 * 1000) }),
|
|
107
|
+
stalledJob({ id: 'c', status: 'done' }),
|
|
108
|
+
];
|
|
109
|
+
assert.deepEqual(pickStalledJobs(jobs, NOW).map(j => j.id), ['a']);
|
|
110
|
+
assert.deepEqual(pickStalledJobs(null, NOW), []);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── parseStallVerdict ───────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
test('只有明确的 true 才算完成', () => {
|
|
116
|
+
assert.equal(parseStallVerdict({ done: true }).done, true);
|
|
117
|
+
assert.equal(parseStallVerdict({ done: 'true' }).done, true);
|
|
118
|
+
assert.equal(parseStallVerdict({ done: 'YES' }).done, true);
|
|
119
|
+
assert.equal(parseStallVerdict({ done: '是' }).done, true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('含糊 / 脏值一律不算完成(每一处宽松都等于允许误标一条还在跑的任务)', () => {
|
|
123
|
+
for (const done of [false, null, undefined, 0, 1, '1', 'maybe', 'no', {}, []]) {
|
|
124
|
+
assert.equal(parseStallVerdict({ done }).done, false, JSON.stringify(done));
|
|
125
|
+
}
|
|
126
|
+
assert.equal(parseStallVerdict({}).done, false, '缺字段');
|
|
127
|
+
assert.equal(parseStallVerdict(null).done, false);
|
|
128
|
+
assert.equal(parseStallVerdict('').done, false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('模型忽略 json_object 直接吐文本时也能认;认不下来标 parsed=false', () => {
|
|
132
|
+
const ok = parseStallVerdict('{"done": true, "reason": "它已经写完结论并在问要不要 push"}');
|
|
133
|
+
assert.equal(ok.done, true);
|
|
134
|
+
assert.equal(ok.parsed, true);
|
|
135
|
+
assert.ok(ok.reason.includes('要不要 push'));
|
|
136
|
+
|
|
137
|
+
// 截断 / 半截 JSON / 散文 —— 全部当"没答",调用侧据此什么都不做
|
|
138
|
+
assert.equal(parseStallVerdict('{"done": true, "reason":').parsed, false);
|
|
139
|
+
assert.equal(parseStallVerdict('我觉得它已经完成了。').parsed, false);
|
|
140
|
+
assert.equal(parseStallVerdict([{ done: true }]).parsed, false, '数组不是我们要的对象');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('reason 折成一行并截断(它要进悬停提示和日志)', () => {
|
|
144
|
+
const v = parseStallVerdict({ done: true, reason: ' 第一行\n 第二行 ' });
|
|
145
|
+
assert.equal(v.reason, '第一行 第二行');
|
|
146
|
+
assert.equal(parseStallVerdict({ done: true, reason: 'x'.repeat(500) }).reason.length, 160);
|
|
147
|
+
assert.equal(parseStallVerdict({ done: true }).reason, '');
|
|
148
|
+
assert.equal(parseStallVerdict({ done: true, reason: null }).reason, '');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ── buildStallFact / buildStallPrompt ───────────────────────────────────
|
|
152
|
+
|
|
153
|
+
test('buildStallFact 里的摘录比卡片那条长(收尾的总结与反问常常隔着几百字)', () => {
|
|
154
|
+
const long = `${'前'.repeat(400)}总结:三处都改完了。要 push 吗?`;
|
|
155
|
+
const fact = buildStallFact(stalledJob({ output: '\n'.repeat(3) + long, thinking: 'Let me write the code.' }), TASK, NOW);
|
|
156
|
+
assert.equal(fact.jobId, 'j1', '带着 jobId —— 判定结果要回到这条 job 上改状态');
|
|
157
|
+
assert.equal(fact.silentMs, 12 * 60 * 1000);
|
|
158
|
+
assert.ok(fact.lastLine.endsWith('要 push 吗?'));
|
|
159
|
+
assert.ok(fact.lastLine.length > 100, '长于卡片上的 MAX_REPLY_CHARS(100)');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('prompt 里给全了判据:静默时长 / 思考 / 最后那段话 / 工具分布,且标明是不可信数据', () => {
|
|
163
|
+
const job = stalledJob({
|
|
164
|
+
thinking: '用户抱怨模型太老,我先去改筛选口径',
|
|
165
|
+
output: '三处都改完了,要 push 吗?',
|
|
166
|
+
toolCalls: [{ name: 'Edit' }, { name: 'Bash' }, { name: 'Bash' }],
|
|
167
|
+
});
|
|
168
|
+
const prompt = buildStallPrompt(buildStallFact(job, TASK, NOW), 'zh-CN');
|
|
169
|
+
assert.ok(prompt.includes('这块模型感觉都比较老了'));
|
|
170
|
+
assert.ok(prompt.includes('12 分钟'), '静默时长本身要写进去');
|
|
171
|
+
assert.ok(prompt.includes('用户抱怨模型太老'));
|
|
172
|
+
assert.ok(prompt.includes('要 push 吗?'));
|
|
173
|
+
assert.ok(prompt.includes('Bash×2 · Edit'));
|
|
174
|
+
assert.ok(prompt.includes('不可信数据'));
|
|
175
|
+
// 这条是"不许猜"的硬要求 —— 少了它,模型倾向于顺着"跑这么久了应该完了吧"编一个 true
|
|
176
|
+
assert.ok(prompt.includes('判据不足时必须说没完成'));
|
|
177
|
+
assert.ok(prompt.includes('"done"'));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('英文界面走英文模板', () => {
|
|
181
|
+
const job = stalledJob({ output: 'All three spots are fixed. Should I push?' });
|
|
182
|
+
const prompt = buildStallPrompt(buildStallFact(job, TASK, NOW), 'en-US');
|
|
183
|
+
assert.ok(prompt.includes('untrusted data'));
|
|
184
|
+
assert.ok(prompt.includes('12 minute(s)'));
|
|
185
|
+
assert.ok(prompt.includes('Should I push?'));
|
|
186
|
+
assert.ok(!prompt.includes('判据不足'));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('静默时长为空时 prompt 不写假的 0 分钟', () => {
|
|
190
|
+
const fact = { ...buildStallFact(stalledJob(), TASK, NOW), silentMs: null };
|
|
191
|
+
const prompt = buildStallPrompt(fact, 'zh');
|
|
192
|
+
assert.ok(prompt.includes('一段时间'));
|
|
193
|
+
assert.ok(!prompt.includes('0 分钟'));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ── judgeStalledJob(注入 callJson,不打真网络) ─────────────────────────
|
|
197
|
+
|
|
198
|
+
test('没配模型就不问,直接 NO_MODEL(与进度报告同一取舍)', async () => {
|
|
199
|
+
let called = 0;
|
|
200
|
+
const v = await judgeStalledJob({
|
|
201
|
+
fact: buildStallFact(stalledJob(), TASK, NOW),
|
|
202
|
+
model: null,
|
|
203
|
+
callJson: async () => { called++; return { done: true }; },
|
|
204
|
+
});
|
|
205
|
+
assert.equal(called, 0);
|
|
206
|
+
assert.equal(v.errorCode, 'NO_MODEL');
|
|
207
|
+
assert.equal(v.done, false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('模型调用失败 / 返回认不下来 → 不抛错,done=false 带 errorCode', async () => {
|
|
211
|
+
const fact = buildStallFact(stalledJob(), TASK, NOW);
|
|
212
|
+
const boom = await judgeStalledJob({
|
|
213
|
+
fact, model: MODEL,
|
|
214
|
+
callJson: async () => { throw new Error('HTTP 502'); },
|
|
215
|
+
});
|
|
216
|
+
assert.equal(boom.done, false);
|
|
217
|
+
assert.equal(boom.errorCode, 'LLM_FAILED');
|
|
218
|
+
assert.ok(boom.errorDetail.includes('502'));
|
|
219
|
+
|
|
220
|
+
const garbage = await judgeStalledJob({ fact, model: MODEL, callJson: async () => '我觉得它做完了' });
|
|
221
|
+
assert.equal(garbage.done, false);
|
|
222
|
+
assert.equal(garbage.errorCode, 'LLM_UNPARSABLE');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('正常返回时把 done / reason 透传,并带上预算与 system prompt', async () => {
|
|
226
|
+
let seen = null;
|
|
227
|
+
const v = await judgeStalledJob({
|
|
228
|
+
fact: buildStallFact(stalledJob(), TASK, NOW),
|
|
229
|
+
model: MODEL,
|
|
230
|
+
callJson: async (_m, prompt, opts) => {
|
|
231
|
+
seen = { prompt, opts };
|
|
232
|
+
return { done: true, reason: '它已经写完结论并在问要不要 push' };
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
assert.equal(v.done, true);
|
|
236
|
+
assert.equal(v.errorCode, '');
|
|
237
|
+
assert.equal(v.reason, '它已经写完结论并在问要不要 push');
|
|
238
|
+
assert.ok(seen.opts.maxTokens > 0, '推理模型的预算必须显式给,不能交给网关默认值');
|
|
239
|
+
assert.ok(seen.opts.systemPrompt.includes('不可信数据'));
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ── applyStallVerdict ───────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
test('判没完成:只记账,状态一个字段都不动', () => {
|
|
245
|
+
const job = stalledJob();
|
|
246
|
+
const before = JSON.stringify({ ...job, stallCheck: undefined });
|
|
247
|
+
const changed = applyStallVerdict(job, { at: iso(NOW), verdict: { done: false, reason: '还在读文件' } });
|
|
248
|
+
assert.equal(changed, false);
|
|
249
|
+
assert.equal(JSON.stringify({ ...job, stallCheck: undefined }), before, '除了记账不该有别的副作用');
|
|
250
|
+
assert.equal(job.status, 'running');
|
|
251
|
+
assert.equal(job.stallCheck.checks, 1);
|
|
252
|
+
assert.equal(job.stallCheck.done, false);
|
|
253
|
+
assert.equal(job.autoCompleted, undefined);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('判完成:落成终态 + 写下依据,卡片据此从「进行中」挪到「已完成」', () => {
|
|
257
|
+
const job = stalledJob({ output: '改完了,要 push 吗?' });
|
|
258
|
+
const changed = applyStallVerdict(job, {
|
|
259
|
+
at: iso(NOW),
|
|
260
|
+
verdict: { done: true, reason: '已给出结论并反问用户' },
|
|
261
|
+
});
|
|
262
|
+
assert.equal(changed, true);
|
|
263
|
+
assert.equal(job.status, 'done');
|
|
264
|
+
assert.equal(job.endedAt, iso(NOW));
|
|
265
|
+
assert.deepEqual(job.autoCompleted, {
|
|
266
|
+
at: iso(NOW),
|
|
267
|
+
reason: '已给出结论并反问用户',
|
|
268
|
+
silentMs: 12 * 60 * 1000,
|
|
269
|
+
});
|
|
270
|
+
// 进程没退就没有退出码 —— 编一个 0 等于把"进程正常结束"写进档案,
|
|
271
|
+
// 而这恰恰是这次判定**没有**依据的那件事
|
|
272
|
+
assert.equal(job.exitCode, undefined);
|
|
273
|
+
assert.equal(job.pid, 45796, 'PID 留着:真要 kill 还认得出是哪个进程');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('判定期间任务又动了 → 记下这次判定但**不改状态**', () => {
|
|
277
|
+
const job = stalledJob();
|
|
278
|
+
// 模型往返的这几十秒里它又吐了字
|
|
279
|
+
job.lastActivityAt = iso(NOW - 1000);
|
|
280
|
+
const changed = applyStallVerdict(job, { at: iso(NOW), verdict: { done: true, reason: '看着像完了' } });
|
|
281
|
+
assert.equal(changed, false);
|
|
282
|
+
assert.equal(job.status, 'running');
|
|
283
|
+
assert.equal(job.endedAt, undefined);
|
|
284
|
+
assert.equal(job.stallCheck.checks, 1, '账还是要记(否则同一个窗口会被反复问)');
|
|
285
|
+
assert.equal(job.stallCheck.done, true);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('判定期间 job 已经被别的路径收掉(进程自己退了 / 用户点了停止)→ 让那个结果说了算', () => {
|
|
289
|
+
for (const status of ['done', 'error', 'cancelled']) {
|
|
290
|
+
const job = stalledJob({ status, endedAt: iso(NOW - 5000) });
|
|
291
|
+
assert.equal(applyStallVerdict(job, { at: iso(NOW), verdict: { done: true } }), false, status);
|
|
292
|
+
assert.equal(job.status, status);
|
|
293
|
+
assert.equal(job.endedAt, iso(NOW - 5000), '不覆盖别人写的结束时刻');
|
|
294
|
+
assert.equal(job.autoCompleted, undefined);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('checks 逐次累加,at 缺省时用当前时刻兜底(不能留一个 undefined 进记录)', () => {
|
|
299
|
+
const job = stalledJob({ stallCheck: { at: iso(NOW - 20 * 60 * 1000), checks: 2 } });
|
|
300
|
+
applyStallVerdict(job, { verdict: { done: false }, now: NOW });
|
|
301
|
+
assert.equal(job.stallCheck.checks, 3);
|
|
302
|
+
assert.equal(job.stallCheck.at, iso(NOW));
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('autoCompleted 里记的是**当时**的静默时长(不是 now - startedAt 那种别的口径)', () => {
|
|
306
|
+
const job = stalledJob({ lastActivityAt: iso(NOW - 54 * 60 * 1000) });
|
|
307
|
+
applyStallVerdict(job, { at: iso(NOW), verdict: { done: true } });
|
|
308
|
+
assert.equal(job.autoCompleted.silentMs, 54 * 60 * 1000);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// ── 整条链路 ────────────────────────────────────────────────────────────
|
|
312
|
+
// 上面每条都只覆盖一段。这一段把四步串起来跑一遍(准入 → 抽事实 → 判定 → 落终态),
|
|
313
|
+
// 因为"每段都对、拼起来不通"正是这类功能的典型坏法:buildStallFact 给的字段
|
|
314
|
+
// judgeStalledJob 读不到、或者 applyStallVerdict 认的 done 不是 judge 返回的那个形状。
|
|
315
|
+
|
|
316
|
+
test('链路:静默 12 分钟 → 判定 → 落成终态(看板据此把卡片挪进「已完成」)', async () => {
|
|
317
|
+
const job = stalledJob({
|
|
318
|
+
thinking: '三处口径都改完了,要不要顺手把索引也更新了?',
|
|
319
|
+
output: '改完了:\n1. 筛选口径\n2. 发布时间\n3. 排序\n\n要 push 吗?',
|
|
320
|
+
toolCalls: [{ name: 'Edit' }, { name: 'Edit' }, { name: 'Bash' }],
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
assert.equal(shouldCheckStall(job, NOW), true, '第一步:准入');
|
|
324
|
+
const fact = buildStallFact(job, TASK, NOW);
|
|
325
|
+
assert.equal(fact.jobId, job.id, '事实要能指回这条 job');
|
|
326
|
+
|
|
327
|
+
const verdict = await judgeStalledJob({
|
|
328
|
+
fact,
|
|
329
|
+
model: MODEL,
|
|
330
|
+
// 假模型:按真实返回的形状给(callLlmJson 返回的是 parse 好的对象)
|
|
331
|
+
callJson: async (_m, prompt) => {
|
|
332
|
+
assert.ok(prompt.includes('要 push 吗?'), '收尾那句必须在 prompt 里,它是判据');
|
|
333
|
+
return { done: true, reason: '已给出三条改动的总结并反问是否 push' };
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
assert.equal(verdict.done, true);
|
|
337
|
+
|
|
338
|
+
assert.equal(applyStallVerdict(job, { at: iso(NOW), verdict }), true, '第四步:落终态');
|
|
339
|
+
assert.equal(job.status, 'done');
|
|
340
|
+
assert.equal(job.endedAt, iso(NOW));
|
|
341
|
+
assert.equal(job.autoCompleted.reason, '已给出三条改动的总结并反问是否 push');
|
|
342
|
+
assert.equal(job.stallCheck.checks, 1);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test('链路:判定说"还没完"时一个字段都不动(等下一个窗口再问)', async () => {
|
|
346
|
+
const job = stalledJob({ thinking: '正在读第 12 个文件', output: '', toolCalls: [{ name: 'Read' }] });
|
|
347
|
+
const fact = buildStallFact(job, TASK, NOW);
|
|
348
|
+
const verdict = await judgeStalledJob({
|
|
349
|
+
fact, model: MODEL,
|
|
350
|
+
callJson: async () => ({ done: false, reason: '还在读文件,没有收尾迹象' }),
|
|
351
|
+
});
|
|
352
|
+
assert.equal(applyStallVerdict(job, { at: iso(NOW), verdict }), false);
|
|
353
|
+
assert.equal(job.status, 'running');
|
|
354
|
+
assert.equal(job.autoCompleted, undefined);
|
|
355
|
+
assert.equal(job.stallCheck.done, false);
|
|
356
|
+
});
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
// 因此这里做四件事:
|
|
19
19
|
// 1. 每个目录只跑**一次** git(status 成功即说明是仓库,失败即非仓库),
|
|
20
20
|
// 并用 --branch 顺带拿到分支与领先/落后,避免额外 3 次 spawn;
|
|
21
|
+
// 唯一的例外是"没配远程仓库"这一条(见 hasRemote),它只在 status 没能
|
|
22
|
+
// 给出上游时才补一次 `git remote`,不在常见路径上;
|
|
21
23
|
// 2. 并发上限 + 单目录超时,别让某个大仓库(或网络盘)拖死整个请求;
|
|
22
24
|
// 3. 结果短 TTL 缓存,弹窗反复打开不必重复扫盘;
|
|
23
25
|
// 4. 领先/落后只读**本地 remote-tracking 引用,不联网 fetch** —— 探测十几个目录
|
|
@@ -55,6 +57,9 @@ function emptyState(extra = {}) {
|
|
|
55
57
|
changed: 0, staged: 0, unstaged: 0, untracked: 0,
|
|
56
58
|
// 分支与上游跟踪
|
|
57
59
|
branch: null, upstream: null, hasUpstream: false, detached: false, ahead: 0, behind: 0,
|
|
60
|
+
// hasRemote: true=配了 remote / false=一个都没配 / null=没探到(超时等)。
|
|
61
|
+
// 列表卡片据此挂「未配远程」徽标 —— null 不挂,不把未知谎报成"没配"。
|
|
62
|
+
hasRemote: false,
|
|
58
63
|
...extra,
|
|
59
64
|
};
|
|
60
65
|
}
|
|
@@ -209,11 +214,17 @@ export async function probeDirectoryGitState(dirPath, { timeoutMs = DEFAULT_PROB
|
|
|
209
214
|
},
|
|
210
215
|
}
|
|
211
216
|
);
|
|
217
|
+
const tracking = parseBranchTracking(stdout);
|
|
218
|
+
// 「有没有配远程仓库」:有上游(`origin/main` 这种)就说明 remote 一定在,
|
|
219
|
+
// 不必再起进程;没有上游时才补跑一次 `git remote` —— 列表里十个仓库
|
|
220
|
+
// 九个都有上游,这一条因此不在常见路径上。
|
|
221
|
+
const hasRemote = tracking.hasUpstream ? true : await probeHasRemote(dirPath, timeoutMs);
|
|
212
222
|
return {
|
|
213
223
|
exists: true,
|
|
214
224
|
isGitRepo: true,
|
|
215
225
|
...parsePorcelainStatus(stdout),
|
|
216
|
-
...
|
|
226
|
+
...tracking,
|
|
227
|
+
hasRemote,
|
|
217
228
|
};
|
|
218
229
|
} catch (error) {
|
|
219
230
|
const text = `${error?.stderr || ''}\n${error?.message || ''}`;
|
|
@@ -226,6 +237,35 @@ export async function probeDirectoryGitState(dirPath, { timeoutMs = DEFAULT_PROB
|
|
|
226
237
|
}
|
|
227
238
|
}
|
|
228
239
|
|
|
240
|
+
/**
|
|
241
|
+
* 这个仓库配了 remote 吗?
|
|
242
|
+
*
|
|
243
|
+
* 与 probeDirectoryGitState 同口径:一次 spawn、超时即放弃、**不联网**
|
|
244
|
+
* (`git remote` 只读 .git/config,不请求远端)。
|
|
245
|
+
*
|
|
246
|
+
* 三态:true=至少有一个 / false=一个都没配 / null=没探到(超时、git 不存在等)。
|
|
247
|
+
* null 不能塌成 false —— 列表卡片要靠它决定挂不挂「未配远程」徽标,
|
|
248
|
+
* 把"没探到"说成"没配"就是谎报。
|
|
249
|
+
*
|
|
250
|
+
* 已知是仓库时调用;非仓库也会直接失败并返回 null,不会给出错误的 false。
|
|
251
|
+
*/
|
|
252
|
+
export async function probeHasRemote(dirPath, { timeoutMs = DEFAULT_PROBE_TIMEOUT_MS } = {}) {
|
|
253
|
+
try {
|
|
254
|
+
const { stdout } = await execFileAsync(
|
|
255
|
+
'git',
|
|
256
|
+
['-C', dirPath, 'remote'],
|
|
257
|
+
{ timeout: timeoutMs, maxBuffer: MAX_BUFFER, windowsHide: true }
|
|
258
|
+
);
|
|
259
|
+
// 有输出就是有 remote(每个 remote 一行);空输出 = 一个都没配
|
|
260
|
+
return String(stdout || '').trim().length > 0;
|
|
261
|
+
} catch (error) {
|
|
262
|
+
const text = `${error?.stderr || ''}\n${error?.message || ''}`;
|
|
263
|
+
// 不是仓库是明确答案(不是"没探到"),返回 false 让调用方不必再判 isGitRepo
|
|
264
|
+
if (/not a git repository|not a git repo/i.test(text)) return false;
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
229
269
|
/** 以固定并发跑完所有任务,保持入参顺序输出 */
|
|
230
270
|
async function runWithConcurrency(items, limit, worker) {
|
|
231
271
|
const results = new Array(items.length);
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
probeDirectoryGitStates,
|
|
29
29
|
probeDirectoryOrigin,
|
|
30
30
|
probeDirectoryOrigins,
|
|
31
|
+
probeHasRemote,
|
|
31
32
|
clearGitStateCache,
|
|
32
33
|
} from './directoryGitState.js';
|
|
33
34
|
|
|
@@ -264,6 +265,96 @@ test('probeDirectoryGitState: 真实远端 → 读出上游分支与领先/落
|
|
|
264
265
|
}
|
|
265
266
|
});
|
|
266
267
|
|
|
268
|
+
// ── 「有没有配远程仓库」(列表卡片的「未配远程」徽标靠它) ────────────────────
|
|
269
|
+
// 三条口径必须分清:配了 / 一个都没配 / 没探到。塌成两态就会把"没探到"
|
|
270
|
+
// 谎报成"没配远程",用户白去配一个其实已经配好的仓库。
|
|
271
|
+
test('probeHasRemote: 配了 remote 为 true,一个都没配为 false,非仓库为 false', async () => {
|
|
272
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-remote-'));
|
|
273
|
+
const withRemote = path.join(root, 'with');
|
|
274
|
+
const noRemote = path.join(root, 'without');
|
|
275
|
+
const plain = path.join(root, 'plain');
|
|
276
|
+
try {
|
|
277
|
+
await fs.mkdir(withRemote);
|
|
278
|
+
await fs.mkdir(noRemote);
|
|
279
|
+
await fs.mkdir(plain);
|
|
280
|
+
await git(['init', '-q'], withRemote);
|
|
281
|
+
await git(['init', '-q'], noRemote);
|
|
282
|
+
await git(['remote', 'add', 'origin', 'git@github.com:o/r.git'], withRemote);
|
|
283
|
+
|
|
284
|
+
assert.equal(await probeHasRemote(withRemote), true);
|
|
285
|
+
assert.equal(await probeHasRemote(noRemote), false);
|
|
286
|
+
// 不是仓库是明确答案(不是 null):调用方已经知道 isGitRepo,这里不用再判一次
|
|
287
|
+
assert.equal(await probeHasRemote(plain), false);
|
|
288
|
+
// 目录压根不存在 → git 报的是 "cannot change to ...",不是"不是仓库",
|
|
289
|
+
// 属于没探到,必须留 null —— 塌成 false 就成了"这个路径没配远程"
|
|
290
|
+
assert.equal(await probeHasRemote(path.join(root, 'ghost')), null);
|
|
291
|
+
} finally {
|
|
292
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('probeDirectoryGitState: hasRemote 跟着实际情况走(没配 / 配了但没上游 / 有上游)', async () => {
|
|
297
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-hremote-'));
|
|
298
|
+
try {
|
|
299
|
+
// 1) 仓库一个 remote 都没配 → false(卡片要挂「未配远程」)
|
|
300
|
+
const bare = path.join(root, 'bare');
|
|
301
|
+
await fs.mkdir(bare);
|
|
302
|
+
await git(['init', '-q', '-b', 'main'], bare);
|
|
303
|
+
assert.equal((await probeDirectoryGitState(bare)).hasRemote, false);
|
|
304
|
+
|
|
305
|
+
// 2) 配了 origin 但当前分支没设上游 → 仍然是 true,
|
|
306
|
+
// 这正是"有上游就不必多跑一次 git remote"那条捷径要覆盖的分支
|
|
307
|
+
const noUpstream = path.join(root, 'no-upstream');
|
|
308
|
+
await fs.mkdir(noUpstream);
|
|
309
|
+
await git(['init', '-q', '-b', 'main'], noUpstream);
|
|
310
|
+
await git(['remote', 'add', 'origin', 'git@github.com:o/r.git'], noUpstream);
|
|
311
|
+
const withRemoteNoUpstream = await probeDirectoryGitState(noUpstream);
|
|
312
|
+
assert.equal(withRemoteNoUpstream.hasUpstream, false);
|
|
313
|
+
assert.equal(withRemoteNoUpstream.hasRemote, true, '配了 remote 就不能报 false');
|
|
314
|
+
|
|
315
|
+
// 3) 有上游(克隆出来的) → hasRemote 直接由 hasUpstream 推出 true
|
|
316
|
+
const originDir = path.join(root, 'origin.git');
|
|
317
|
+
const seed = path.join(root, 'seed');
|
|
318
|
+
const work = path.join(root, 'work');
|
|
319
|
+
await git(['init', '-q', '--bare', '-b', 'main', originDir], root);
|
|
320
|
+
await git(['init', '-q', '-b', 'main', seed], root);
|
|
321
|
+
await fs.writeFile(path.join(seed, 'README.md'), 'seed\n');
|
|
322
|
+
await git(['add', '.'], seed);
|
|
323
|
+
await gitCommit(seed, 'init');
|
|
324
|
+
await git(['remote', 'add', 'origin', asRemote(originDir)], seed);
|
|
325
|
+
await git(['push', '-q', '-u', 'origin', 'main'], seed);
|
|
326
|
+
await git(['clone', '-q', asRemote(originDir), work], root);
|
|
327
|
+
const cloned = await probeDirectoryGitState(work);
|
|
328
|
+
assert.equal(cloned.hasUpstream, true);
|
|
329
|
+
assert.equal(cloned.hasRemote, true);
|
|
330
|
+
|
|
331
|
+
// 4) 非仓库 / 不存在的目录:hasRemote 落回 false,不显示任何 Git 标记
|
|
332
|
+
const plain = path.join(root, 'plain');
|
|
333
|
+
await fs.mkdir(plain);
|
|
334
|
+
assert.equal((await probeDirectoryGitState(plain)).hasRemote, false);
|
|
335
|
+
assert.equal((await probeDirectoryGitState(path.join(root, 'ghost'))).exists, false);
|
|
336
|
+
} finally {
|
|
337
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('probeDirectoryGitStates: 批量结果里的 hasRemote 也对(卡片徽标靠它)', async () => {
|
|
342
|
+
clearGitStateCache();
|
|
343
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-batch-remote-'));
|
|
344
|
+
try {
|
|
345
|
+
const repo = path.join(root, 'repo');
|
|
346
|
+
const plain = path.join(root, 'plain');
|
|
347
|
+
await fs.mkdir(repo);
|
|
348
|
+
await fs.mkdir(plain);
|
|
349
|
+
await git(['init', '-q'], repo);
|
|
350
|
+
const results = await probeDirectoryGitStates([repo, plain], { useCache: false });
|
|
351
|
+
assert.equal(results[repo].hasRemote, false);
|
|
352
|
+
assert.equal(results[plain].isGitRepo, false);
|
|
353
|
+
} finally {
|
|
354
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
267
358
|
// ── origin 远程地址探测(远程仓库列表的「已克隆」徽标) ──────────────────
|
|
268
359
|
test('probeDirectoryOrigin: 有 origin 读到地址,没有 / 非仓库 / 目录不存在都是 null', async () => {
|
|
269
360
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'zen-origin-'));
|