dsh-supercrew 0.1.4 → 0.1.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/bin/supercrew.mjs +8 -0
- package/client.js +87 -0
- package/crew-watch.mjs +134 -24
- package/crew.mjs +33 -9
- package/lib/router.mjs +5 -1
- package/package.json +15 -3
- package/panel.mjs +104 -6
- package/selftest.mjs +20 -3
- package/worker-brief.md +2 -2
package/bin/supercrew.mjs
CHANGED
|
@@ -92,3 +92,11 @@ const child = spawn(process.execPath, args, {
|
|
|
92
92
|
env,
|
|
93
93
|
});
|
|
94
94
|
child.on('exit', (code) => process.exit(code ?? 0));
|
|
95
|
+
// ★ spawn 失败是异步的:不监听的话,node 不在该在的位置时只打印一个未捕获
|
|
96
|
+
// 异常栈,用户看到的要么是「什么都没发生」、要么是一坨内部路径。
|
|
97
|
+
child.on('error', (e) => {
|
|
98
|
+
console.error(`起不来子命令 ${cmd}:${e.message}`);
|
|
99
|
+
console.error(` 用的 node: ${NODE}`);
|
|
100
|
+
console.error(` 要跑的脚本: ${path.join(ROOT, file)}`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
});
|
package/client.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-supercrew 的界面部分:会话头部一枚状态胶囊。
|
|
3
|
+
*
|
|
4
|
+
* 值来自看板的 /api/heartbeat + /api/tickets(每 3 秒拉一次):
|
|
5
|
+
* 🟢 值守中 · 412 轮 它盯着,手上没活
|
|
6
|
+
* 🟡 正在干 2 张 手上正干着活
|
|
7
|
+
* 🔴 值守掉线 心跳断了(或看板没起来)
|
|
8
|
+
* 点一下打开看板。
|
|
9
|
+
*/
|
|
10
|
+
window.__ModuleLoader__.load({
|
|
11
|
+
id: 'dsh-supercrew',
|
|
12
|
+
factory(require) {
|
|
13
|
+
const React = require('react');
|
|
14
|
+
const h = React.createElement;
|
|
15
|
+
|
|
16
|
+
const PANEL = 'http://127.0.0.1:3090';
|
|
17
|
+
const POLL_MS = 3000;
|
|
18
|
+
|
|
19
|
+
function Capsule() {
|
|
20
|
+
const [hb, setHb] = React.useState(null);
|
|
21
|
+
const [busy, setBusy] = React.useState(0);
|
|
22
|
+
|
|
23
|
+
React.useEffect(() => {
|
|
24
|
+
let alive = true;
|
|
25
|
+
const pull = async () => {
|
|
26
|
+
try {
|
|
27
|
+
const hbRes = await fetch(`${PANEL}/api/heartbeat`, { cache: 'no-store' });
|
|
28
|
+
const j = await hbRes.json();
|
|
29
|
+
let n = 0;
|
|
30
|
+
try {
|
|
31
|
+
const tRes = await fetch(`${PANEL}/api/tickets`, { cache: 'no-store' });
|
|
32
|
+
const list = await tRes.json();
|
|
33
|
+
if (Array.isArray(list)) n = list.filter((t) => t && t.state === 'working').length;
|
|
34
|
+
} catch { /* 工单拉不到不算错,胶囊照样显示值守状态 */ }
|
|
35
|
+
if (alive) { setHb(j); setBusy(n); }
|
|
36
|
+
} catch {
|
|
37
|
+
if (alive) setHb({ alive: false, offline: true });
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
pull();
|
|
41
|
+
const timer = setInterval(pull, POLL_MS);
|
|
42
|
+
return () => { alive = false; clearInterval(timer); };
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const offline = !hb || hb.alive === false;
|
|
46
|
+
const working = !offline && busy > 0;
|
|
47
|
+
const color = offline ? '#e5484d' : (working ? '#f5a623' : '#2ecc71');
|
|
48
|
+
const text = offline
|
|
49
|
+
? '值守掉线'
|
|
50
|
+
: (working ? `正在干 ${busy} 张`
|
|
51
|
+
: `值守中${hb.rounds ? ` · ${hb.rounds} 轮` : ''}`);
|
|
52
|
+
|
|
53
|
+
return h('button', {
|
|
54
|
+
type: 'button',
|
|
55
|
+
title: offline
|
|
56
|
+
? `连不上看板(${PANEL})—— 点开看看`
|
|
57
|
+
: `团队系统:${hb.note || '值守中'}\n第 ${hb.rounds ?? 0} 轮 · ${hb.ageSec ?? '?'} 秒前扫过 · pid ${hb.pid ?? '?'}\n点开看板`,
|
|
58
|
+
onClick: () => window.open(`${PANEL}/`, '_blank'),
|
|
59
|
+
style: {
|
|
60
|
+
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
61
|
+
height: 26, padding: '0 10px', borderRadius: 999,
|
|
62
|
+
border: '1px solid rgba(127,127,127,0.28)',
|
|
63
|
+
background: 'rgba(127,127,127,0.10)',
|
|
64
|
+
color: 'inherit', fontSize: 12, lineHeight: 1,
|
|
65
|
+
cursor: 'pointer', whiteSpace: 'nowrap', maxWidth: 220,
|
|
66
|
+
overflow: 'hidden', textOverflow: 'ellipsis',
|
|
67
|
+
},
|
|
68
|
+
}, h('span', {
|
|
69
|
+
style: {
|
|
70
|
+
width: 7, height: 7, borderRadius: '50%',
|
|
71
|
+
background: color, flex: '0 0 auto',
|
|
72
|
+
},
|
|
73
|
+
}), text);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
inject: ['slots'],
|
|
78
|
+
apply(ctx) {
|
|
79
|
+
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
|
|
80
|
+
name: 'conversation.session.header.utilities',
|
|
81
|
+
id: 'supercrew-status',
|
|
82
|
+
order: 15,
|
|
83
|
+
}, Capsule));
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
});
|
package/crew-watch.mjs
CHANGED
|
@@ -114,6 +114,7 @@ function bumpHeartbeat(patch = {}) {
|
|
|
114
114
|
}
|
|
115
115
|
if (patch.recent) h.recent = patch.recent.slice(0, 12);
|
|
116
116
|
if (patch.note) h.note = patch.note;
|
|
117
|
+
if (patch.qq !== undefined) h.qq = patch.qq; // 耳朵还挂着吗(看板与状态胶囊上显示)
|
|
117
118
|
try { saveJson(HEARTBEAT, h); } catch { /* 心跳写不进去不该影响值守 */ }
|
|
118
119
|
return h;
|
|
119
120
|
}
|
|
@@ -125,6 +126,29 @@ function markSeen(keys) {
|
|
|
125
126
|
saveJson(SEEN, [...s].slice(-3000));
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
/**
|
|
130
|
+
* 耳朵还在不在?
|
|
131
|
+
*
|
|
132
|
+
* 我们的 QQ 入口整个架在 surfcore 桥上:消息从桥写的 social-v2.json 里读,
|
|
133
|
+
* 回话走桥的控制台端口。桥要是没跑,我们就会一直对着一份冻结的文件「认真听」,
|
|
134
|
+
* 看起来像在干活,其实什么也收不到 —— 所以每一轮都先探一下端口,探不到就
|
|
135
|
+
* 明说,别再假装在听。
|
|
136
|
+
*
|
|
137
|
+
* 注意:桥对未授权的请求会回 401,那也算活着 —— 只要连得上就说明进程在。
|
|
138
|
+
*/
|
|
139
|
+
export async function bridgeAlive(timeoutMs = 3000) {
|
|
140
|
+
const ctl = new AbortController();
|
|
141
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
142
|
+
try {
|
|
143
|
+
const r = await fetch(`${API}/api/status`, { signal: ctl.signal });
|
|
144
|
+
return r.status > 0; // 200/401/403 都说明端口有人听
|
|
145
|
+
} catch {
|
|
146
|
+
return false; // 连接被拒 / 超时 → 桥没跑
|
|
147
|
+
} finally {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
128
152
|
export function scan({ onlyNew = true } = {}) {
|
|
129
153
|
const social = readSocial();
|
|
130
154
|
const seen = seenSet();
|
|
@@ -161,8 +185,14 @@ export async function intake({ dryRun = false, concurrency = 4 } = {}) {
|
|
|
161
185
|
try {
|
|
162
186
|
// 门卫:@ 我一下不等于给我派了活。判不出就放过(宁可漏,不可瞎干)。
|
|
163
187
|
const j = await judge(body);
|
|
188
|
+
// ★ 「判不出来」和「判成闲聊」必须分开:前者不记 seen(下一轮还能重判)。
|
|
189
|
+
// 混在一起的话,网关卡一下 / 超时一次,这批需求就被永久吞掉了。
|
|
190
|
+
if (j.ok === false) {
|
|
191
|
+
failed.push({ key: m.key, msgId: String(m.id ?? ''), text: body.slice(0, 40), why: j.reason });
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
164
194
|
if (!j.isTask) {
|
|
165
|
-
skipped.push({ key: m.key, from: m.from, text: body, why: j.reason });
|
|
195
|
+
skipped.push({ key: m.key, msgId: String(m.id ?? ''), from: m.from, text: body, why: j.reason });
|
|
166
196
|
continue;
|
|
167
197
|
}
|
|
168
198
|
if (dryRun) { made.push({ dryRun: true, ...j, chatKey: m.key, msgId: m.id }); continue; }
|
|
@@ -188,7 +218,15 @@ export async function intake({ dryRun = false, concurrency = 4 } = {}) {
|
|
|
188
218
|
});
|
|
189
219
|
await Promise.all(lanes);
|
|
190
220
|
|
|
191
|
-
if (!dryRun)
|
|
221
|
+
if (!dryRun) {
|
|
222
|
+
// 只标记「真正处理过的」:开单成功、或门卫确认是闲聊。
|
|
223
|
+
// 判不出来 / 开单出错的留着 —— 下一轮还能重判,不然需求就永久丢了。
|
|
224
|
+
const handled = new Set([
|
|
225
|
+
...made.map((t) => `${t.chatKey}|${t.msgId}`),
|
|
226
|
+
...skipped.map((s) => `${s.key}|${s.msgId}`),
|
|
227
|
+
]);
|
|
228
|
+
markSeen(found.filter((m) => handled.has(`${m.key}|${m.id ?? ''}`)).map(seenKey));
|
|
229
|
+
}
|
|
192
230
|
|
|
193
231
|
// 记心跳:这一轮扫到几条、开了几单、拦下几条、判过的都长什么样
|
|
194
232
|
if (!dryRun) {
|
|
@@ -221,8 +259,24 @@ function sanitize(s) {
|
|
|
221
259
|
|
|
222
260
|
/** 把结果发回它来的会话;失败会把原因记进工单,方便事后补发 */
|
|
223
261
|
export async function reply(ticket) {
|
|
262
|
+
// ★ 失败要留痕:没有来源会话 / 没有 agentToken / 令牌文件读不到,是最常见的三个
|
|
263
|
+
// 失败点。工单 done 之后不会再跑,这类失败要是直接 throw,看板上什么都不显示,
|
|
264
|
+
// 结果就永远没发出去而没人知道。所以一律写 replyError 再返回。
|
|
265
|
+
const fail = (msg) => {
|
|
266
|
+
try {
|
|
267
|
+
const fresh = loadAllTickets().find((t) => t.id === ticket.id);
|
|
268
|
+
if (fresh) {
|
|
269
|
+
fresh.replyAt = new Date().toISOString();
|
|
270
|
+
fresh.replyStatus = -1;
|
|
271
|
+
fresh.replyError = msg;
|
|
272
|
+
saveTicket(fresh);
|
|
273
|
+
}
|
|
274
|
+
} catch { /* 记账失败就算了,别因为记账影响主流程 */ }
|
|
275
|
+
return { status: -1, body: msg };
|
|
276
|
+
};
|
|
277
|
+
|
|
224
278
|
const key = ticket.chatKey;
|
|
225
|
-
if (!key)
|
|
279
|
+
if (!key) return fail('这张工单没有来源会话');
|
|
226
280
|
|
|
227
281
|
// 两个令牌都要带:
|
|
228
282
|
// ① x-console-token —— 桥的全局关卡(所有 /api/* 都查它)
|
|
@@ -232,8 +286,10 @@ export async function reply(ticket) {
|
|
|
232
286
|
const social = readSocial();
|
|
233
287
|
const conv = (social.conversations || {})[key] || {};
|
|
234
288
|
const agentToken = conv.agentToken || '';
|
|
235
|
-
if (!agentToken)
|
|
236
|
-
|
|
289
|
+
if (!agentToken) return fail(`会话 ${key} 没有 agentToken,发不出去`);
|
|
290
|
+
let consoleToken = '';
|
|
291
|
+
try { consoleToken = fs.readFileSync(TOKENFILE, 'utf8').trim(); }
|
|
292
|
+
catch (e) { return fail(`读不到控制台令牌(${TOKENFILE}):${e.message}`); }
|
|
237
293
|
|
|
238
294
|
const isGroup = key.startsWith('group:');
|
|
239
295
|
const id = key.split(':')[1];
|
|
@@ -304,37 +360,91 @@ async function main() {
|
|
|
304
360
|
} else if (cmd === 'loop') {
|
|
305
361
|
console.log('守着…… Ctrl+C 退出');
|
|
306
362
|
bumpHeartbeat({ note: '值守起来了' }); // 先写一条,让看板立刻看到它还活着
|
|
363
|
+
// ★ 独立的心跳:不管下面那圈卡在哪个 worker 上,心跳照跳。
|
|
364
|
+
// 以前没有它的时候,一旦接管了一张要跑十分钟的单,心跳就跟着停,
|
|
365
|
+
// 看板(和会话头部那枚胶囊)会显示「值守掉线」—— 明明它在干活。
|
|
366
|
+
const beat = setInterval(() => { try { bumpHeartbeat({}); } catch { /* ignore */ } }, 8000);
|
|
367
|
+
beat.unref?.();
|
|
368
|
+
let qqDownRounds = 0; // 桥断了几轮(断着的时候不重复刷屏)
|
|
369
|
+
const running = new Set(); // 正在后台跑的单,别重复派
|
|
307
370
|
for (;;) {
|
|
308
371
|
try {
|
|
309
372
|
// 先接管「开了但没人派」的单 —— 从看板输入框、CLI 丢进来的活都落在 queued,
|
|
310
373
|
// 以前必须有人去点卡片上的按钮才会跑,所以看起来像「它一点不干」。
|
|
311
374
|
// CREW_AUTO_RUN=0 可以关掉这个行为(只想手动派的时候)。
|
|
375
|
+
//
|
|
376
|
+
// ★ 不 await:worker 一张单可能跑十分钟,等它就把整圈堵死了(新单收不到、
|
|
377
|
+
// 心跳也停)。这里只把「谁在跑」记进 running,跑完由回调收尾。
|
|
312
378
|
if (process.env.CREW_AUTO_RUN !== '0') {
|
|
313
|
-
|
|
379
|
+
// 一次能同时开几个 worker。每张单是一个独立的 headless 进程(独立上下文、
|
|
380
|
+
// 独立工作目录),并行才叫团队;但每个进程都吃内存,所以要有个上限。
|
|
381
|
+
const MAX_WORKERS = Math.max(1, Number(process.env.CREW_MAX_WORKERS || 3));
|
|
382
|
+
const room = MAX_WORKERS - running.size;
|
|
383
|
+
const queued = room > 0
|
|
384
|
+
? loadAllTickets().filter((t) => t.state === 'queued' && !running.has(t.id)).slice(0, room)
|
|
385
|
+
: [];
|
|
314
386
|
for (const q of queued) {
|
|
387
|
+
running.add(q.id);
|
|
315
388
|
console.log(`⏳ 接管待办 ${q.id} ${q.title}`);
|
|
316
389
|
bumpHeartbeat({ note: `接管待办 ${q.id}:${q.title}` });
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
390
|
+
(async () => {
|
|
391
|
+
try {
|
|
392
|
+
const done = await runTicket(q.id);
|
|
393
|
+
if (!done) { console.log(' (被别的进程抢走了)'); return; }
|
|
394
|
+
console.log(` → ${done.state} ${(done.result || done.error || '').slice(0, 120)}`);
|
|
395
|
+
if (done.chatKey) {
|
|
396
|
+
const rr = await reply(done);
|
|
397
|
+
console.log(` 结果回传:${rr.status}`);
|
|
398
|
+
}
|
|
399
|
+
bumpHeartbeat({ note: `${q.id} ${done.state}` });
|
|
400
|
+
} catch (e) {
|
|
401
|
+
console.log(` ✗ ${q.id} 出错:${e.message}`);
|
|
402
|
+
bumpHeartbeat({ note: `${q.id} 出错:${e.message}` });
|
|
403
|
+
} finally {
|
|
404
|
+
running.delete(q.id);
|
|
405
|
+
}
|
|
406
|
+
})();
|
|
325
407
|
}
|
|
326
408
|
}
|
|
327
409
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
410
|
+
// 耳朵还挂着吗?桥没跑就别对着那份冻结的文件继续听 —— 但只跳 QQ 这一段,
|
|
411
|
+
// 上面的「接管待办」和下面的循环照旧,工作区的活不受影响。
|
|
412
|
+
const up = await bridgeAlive();
|
|
413
|
+
if (!up) {
|
|
414
|
+
qqDownRounds += 1;
|
|
415
|
+
bumpHeartbeat({
|
|
416
|
+
qq: false,
|
|
417
|
+
note: qqDownRounds === 1
|
|
418
|
+
? 'QQ 入口断了(surfcore 桥没在跑)· 只盯工作区'
|
|
419
|
+
: `QQ 入口断着(已 ${qqDownRounds} 轮)· 只盯工作区`,
|
|
420
|
+
});
|
|
421
|
+
} else {
|
|
422
|
+
qqDownRounds = 0;
|
|
423
|
+
bumpHeartbeat({ qq: true });
|
|
424
|
+
const r = await intake();
|
|
425
|
+
if (r.made.length || r.failed.length) showJudged(r);
|
|
426
|
+
for (const t of r.made) {
|
|
427
|
+
if (running.has(t.id)) continue;
|
|
428
|
+
// ★ 这里也不能 await:一张 QQ 单跑十分钟,等的就是这个「耳朵」被堵死的样子。
|
|
429
|
+
// 丢进后台、记进 running,跑完(含回传)由回调收尾。
|
|
430
|
+
running.add(t.id);
|
|
431
|
+
console.log(`▶ ${t.id} ${t.title}`);
|
|
432
|
+
bumpHeartbeat({ note: `正在干 ${t.id}:${t.title}` });
|
|
433
|
+
(async () => {
|
|
434
|
+
try {
|
|
435
|
+
const done = await runTicket(t.id);
|
|
436
|
+
if (!done) { console.log(' (被别的进程抢走了)'); return; }
|
|
437
|
+
const rr = await reply(done);
|
|
438
|
+
console.log(` 结果回传:${rr.status}${rr.status >= 300 ? ` —— ${String(rr.body).slice(0, 120)}` : ''}`);
|
|
439
|
+
bumpHeartbeat({ note: `${t.id} 干完,结果回传 ${rr.status}` });
|
|
440
|
+
} catch (e) {
|
|
441
|
+
console.log(` ✗ ${t.id} 出错:${e.message}`);
|
|
442
|
+
bumpHeartbeat({ note: `${t.id} 出错:${e.message}` });
|
|
443
|
+
} finally {
|
|
444
|
+
running.delete(t.id);
|
|
445
|
+
}
|
|
446
|
+
})();
|
|
447
|
+
}
|
|
338
448
|
}
|
|
339
449
|
} catch (e) {
|
|
340
450
|
console.error('这一轮出错:', e.message);
|
package/crew.mjs
CHANGED
|
@@ -356,7 +356,12 @@ function runWorkerOnce(ticket, onSpawn, liveFile, onWarn) {
|
|
|
356
356
|
err += `\n[crew] worker 超过 ${Math.round(TIMEOUT / 1000)} 秒仍未结束,已强杀整棵进程树`;
|
|
357
357
|
feed(`\n[crew] 超过 ${Math.round(TIMEOUT / 1000)} 秒未结束,超时强杀\n`);
|
|
358
358
|
try {
|
|
359
|
-
spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true });
|
|
359
|
+
const tk = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true });
|
|
360
|
+
// spawn 的失败是异步 'error' 事件,外面这层 try/catch 抓不到 —— 没人监听就是
|
|
361
|
+
// 未捕获异常,会把值守整个带崩(而此刻可能还有别的 worker 在跑),
|
|
362
|
+
// 本该兜底的 child.kill 也永远走不到。
|
|
363
|
+
tk.on('error', () => { try { child.kill('SIGKILL'); } catch { /* 尽力而为 */ } });
|
|
364
|
+
tk.on('close', (c) => { if (c !== 0) { try { child.kill('SIGKILL'); } catch { /* 尽力而为 */ } } });
|
|
360
365
|
} catch {
|
|
361
366
|
try { child.kill('SIGKILL'); } catch { /* 尽力而为 */ }
|
|
362
367
|
}
|
|
@@ -369,9 +374,16 @@ function runWorkerOnce(ticket, onSpawn, liveFile, onWarn) {
|
|
|
369
374
|
child.stdout.on('data', (d) => { checkMojibake(d); if (out.length < MAX_OUT) out += d; feed(d); });
|
|
370
375
|
child.stderr.on('data', (d) => { checkMojibake(d); if (err.length < MAX_OUT) err += d; feedErr(d); });
|
|
371
376
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
377
|
+
// ★ exit 兜底必须在收尾时摘掉。它是每派一张单加一个监听器,而 process 默认上限
|
|
378
|
+
// 10 个 —— 值守跑十几张单就开始刷 MaxListenersExceededWarning;更糟的是每个
|
|
379
|
+
// 监听器都攥着 child(连带它的 stdout/stderr 缓冲,各上限 1MB),已完成的单
|
|
380
|
+
// 输出永远不回收,值守内存只涨不落。
|
|
381
|
+
const onExit = () => { if (!killed && child.pid) { killed = true; try { child.kill(); } catch { /* 尽力而为 */ } } };
|
|
382
|
+
process.once('exit', onExit);
|
|
383
|
+
const unhook = () => { try { process.removeListener('exit', onExit); } catch { /* 尽力而为 */ } };
|
|
384
|
+
|
|
385
|
+
child.on('error', (e) => { clearTimeout(timer); unhook(); resolve({ rc: -1, out: cap(out), err: cap(err + '\n' + e.message), seconds: 0, pid: child.pid }); });
|
|
386
|
+
child.on('close', (rc) => { clearTimeout(timer); unhook(); resolve({ rc, out: cap(out), err: cap(err), seconds: (Date.now() - started) / 1000, pid: child.pid }); });
|
|
375
387
|
});
|
|
376
388
|
}
|
|
377
389
|
|
|
@@ -527,15 +539,27 @@ async function main() {
|
|
|
527
539
|
console.log(' 下一步: node crew.mjs run');
|
|
528
540
|
} else if (cmd === 'run') {
|
|
529
541
|
const all = !!opts.all;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
542
|
+
// ★ 抢不到锁时不能直接 continue:pending() 仍然会把那张单排在第一,于是
|
|
543
|
+
// do/while 原地空转,CPU 打满却什么也不干(review.md #6)。记下来跳过。
|
|
544
|
+
const skipped = new Set();
|
|
545
|
+
for (;;) {
|
|
546
|
+
const q = pending().filter((t) => !skipped.has(t.id));
|
|
547
|
+
if (!q.length) {
|
|
548
|
+
console.log(skipped.size ? '剩下的都被别的进程处理着。' : '没有排队的工单。');
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
533
551
|
const t = q[0];
|
|
534
552
|
console.log(`▶ 派工 ${t.id}:${t.title}`);
|
|
535
553
|
const done = await runTicket(t.id);
|
|
536
|
-
if (!done) {
|
|
554
|
+
if (!done) {
|
|
555
|
+
skipped.add(t.id);
|
|
556
|
+
console.log(' (被别的进程抢走了,跳过这张)');
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
537
559
|
console.log(` ${done.state === 'done' ? '✔ 完成' : '✘ 失败'} ${done.state === 'done' ? done.result.slice(0, 200) : done.error}`);
|
|
538
|
-
|
|
560
|
+
skipped.clear(); // 干完一张,之前卡住的单可能已经松手了,重新扫一遍
|
|
561
|
+
if (!all) break;
|
|
562
|
+
}
|
|
539
563
|
} else if (cmd === 'requeue') {
|
|
540
564
|
const staleMin = Number(typeof opts.stale === 'string' ? opts.stale : STALE_MIN);
|
|
541
565
|
const moved = requeueStale({ staleMin, id: typeof opts.id === 'string' ? opts.id : null });
|
package/lib/router.mjs
CHANGED
|
@@ -74,6 +74,7 @@ export async function judge(text, { context = '' } = {}) {
|
|
|
74
74
|
const r = await askOnce(model, user);
|
|
75
75
|
if (r.ok) {
|
|
76
76
|
return {
|
|
77
|
+
ok: true,
|
|
77
78
|
isTask: !!r.o.isTask,
|
|
78
79
|
title: (r.o.title || '').slice(0, 40),
|
|
79
80
|
body: r.o.body || text,
|
|
@@ -86,7 +87,10 @@ export async function judge(text, { context = '' } = {}) {
|
|
|
86
87
|
tried.push(`${model}: ${e.message}`);
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
|
-
|
|
90
|
+
// ★ 这里必须和「真的是闲聊」分开:调用方(crew-watch)只对 ok:true 且 isTask:false
|
|
91
|
+
// 的消息记 seen。判不出来还记 seen 的话,网关卡一下、超时一次,那批需求就被
|
|
92
|
+
// 永久吞掉了 —— 下次再扫到也不认。
|
|
93
|
+
return { ok: false, isTask: null, reason: '所有模型都没判出来(保守放过,不记 seen)', tried };
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
// 直接命令行跑:node router.mjs "帮我写个 python 脚本算质数"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-supercrew",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "给 DeepSeek Harness 装一个团队:收需求 → 门卫判断 → 开工单 → 派独立 worker 干活 → 结果回报。模型可配(默认用你当前在用的,免费池当备选),不依赖 tmux。",
|
|
@@ -22,12 +22,23 @@
|
|
|
22
22
|
"dsh": ">=0.2.2-rc.1"
|
|
23
23
|
},
|
|
24
24
|
"main": "lib/index.js",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./lib/index.js",
|
|
27
|
+
"./client": "./client.js"
|
|
28
|
+
},
|
|
25
29
|
"bin": {
|
|
26
30
|
"supercrew": "./bin/supercrew.mjs"
|
|
27
31
|
},
|
|
28
32
|
"dsh": {
|
|
29
33
|
"bundle": {
|
|
30
34
|
"patch": "./cordis.patch.yml"
|
|
35
|
+
},
|
|
36
|
+
"client": {
|
|
37
|
+
"platform": "web",
|
|
38
|
+
"immediately": true,
|
|
39
|
+
"inject": [
|
|
40
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
41
|
+
]
|
|
31
42
|
}
|
|
32
43
|
},
|
|
33
44
|
"files": [
|
|
@@ -37,6 +48,7 @@
|
|
|
37
48
|
"panel.mjs",
|
|
38
49
|
"crew.mjs",
|
|
39
50
|
"crew-watch.mjs",
|
|
51
|
+
"client.js",
|
|
40
52
|
"worker-models.yml",
|
|
41
53
|
"cordis.patch.yml",
|
|
42
54
|
"crew_guard.ps1",
|
|
@@ -54,7 +66,7 @@
|
|
|
54
66
|
"panel": "node panel.mjs",
|
|
55
67
|
"watch": "node crew-watch.mjs loop",
|
|
56
68
|
"selftest": "node selftest.mjs",
|
|
57
|
-
"check": "node --check crew.mjs && node --check crew-watch.mjs && node --check panel.mjs && node --check lib/index.js && node --check lib/router.mjs && node --check bin/supercrew.mjs",
|
|
69
|
+
"check": "node --check crew.mjs && node --check crew-watch.mjs && node --check panel.mjs && node --check client.js && node --check lib/index.js && node --check lib/router.mjs && node --check bin/supercrew.mjs",
|
|
58
70
|
"selftest:quick": "node selftest.mjs --quick",
|
|
59
71
|
"work": "node crew.mjs work"
|
|
60
72
|
},
|
|
@@ -66,4 +78,4 @@
|
|
|
66
78
|
"bugs": {
|
|
67
79
|
"url": "https://github.com/wesqaz111/dsh-supercrew/issues"
|
|
68
80
|
}
|
|
69
|
-
}
|
|
81
|
+
}
|
package/panel.mjs
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
import http from 'node:http';
|
|
20
20
|
import fs from 'node:fs';
|
|
21
21
|
import path from 'node:path';
|
|
22
|
-
import { loadAllTickets, addTicket, runTicket } from './crew.mjs';
|
|
22
|
+
import { loadAllTickets, addTicket, saveTicket, runTicket } from './crew.mjs';
|
|
23
|
+
import { judge } from './lib/router.mjs';
|
|
23
24
|
|
|
24
25
|
function argPort() {
|
|
25
26
|
const argv = process.argv.slice(2);
|
|
@@ -44,9 +45,14 @@ function tail(file, maxBytes = 24 * 1024) {
|
|
|
44
45
|
const from = Math.max(0, st.size - maxBytes);
|
|
45
46
|
const fd = fs.openSync(file, 'r');
|
|
46
47
|
try {
|
|
48
|
+
// readSync 只保证「最多读这么多」,短读时缓冲区尾部是未写入的 \0 —— 直接
|
|
49
|
+
// 全域 toString 会把一串 NUL 塞进浏览器。用返回值 n 截断。
|
|
47
50
|
const buf = Buffer.alloc(Math.min(maxBytes, st.size - from));
|
|
48
|
-
fs.readSync(fd, buf, 0, buf.length, from);
|
|
49
|
-
|
|
51
|
+
const n = fs.readSync(fd, buf, 0, buf.length, from);
|
|
52
|
+
let s = buf.subarray(0, n).toString('utf8');
|
|
53
|
+
// from > 0 时起点很可能劈开一个汉字,开头会出现 或半行 —— 丢掉第一行残片
|
|
54
|
+
if (from > 0) s = s.replace(/^[^\n]*\n/, '');
|
|
55
|
+
return s;
|
|
50
56
|
} finally { fs.closeSync(fd); }
|
|
51
57
|
} catch { return ''; }
|
|
52
58
|
}
|
|
@@ -355,12 +361,33 @@ if (_sp.get('open')) openDrawer(_sp.get('open'));
|
|
|
355
361
|
const server = http.createServer(async (req, res) => {
|
|
356
362
|
const url = new URL(req.url, 'http://x');
|
|
357
363
|
const json = (o, code = 200) => {
|
|
358
|
-
res.writeHead(code, {
|
|
364
|
+
res.writeHead(code, {
|
|
365
|
+
'content-type': 'application/json; charset=utf-8',
|
|
366
|
+
// DSH 页面跑在别的端口上,靠这行才读得到心跳(状态胶囊用)
|
|
367
|
+
'access-control-allow-origin': '*',
|
|
368
|
+
});
|
|
359
369
|
res.end(JSON.stringify(o));
|
|
360
370
|
};
|
|
361
371
|
try {
|
|
372
|
+
// ★ 防跨站派工:本机任意网页都能 POST 到这里,而 /api/add 会立刻派一个
|
|
373
|
+
// --permission-mode danger-full-access 的 worker —— 等于「打开一个网页就能让
|
|
374
|
+
// 这台机器执行任意命令」。浏览器发起的请求一定带 Origin,本机脚本不会带。
|
|
375
|
+
// 所以:写操作(POST)带 Origin 一律拒;再要求 content-type 是 application/json
|
|
376
|
+
// (simple request 只能发 text/plain,加这一条把不触发预检的那条路也堵掉)。
|
|
377
|
+
// 读操作(GET)不拦 —— 会话头部那枚胶囊就是从 DSH 页面跨端口来读心跳的。
|
|
378
|
+
if (req.method === 'POST') {
|
|
379
|
+
if (req.headers.origin) {
|
|
380
|
+
return json({ error: '拒绝来自浏览器的写调用(防跨站派工);请用本机脚本或 crew.mjs' }, 403);
|
|
381
|
+
}
|
|
382
|
+
if (!String(req.headers['content-type'] || '').includes('application/json')) {
|
|
383
|
+
return json({ error: 'content-type 必须是 application/json' }, 415);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
362
386
|
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/index.html')) {
|
|
363
|
-
res.writeHead(200, {
|
|
387
|
+
res.writeHead(200, {
|
|
388
|
+
'content-type': 'text/html; charset=utf-8',
|
|
389
|
+
'access-control-allow-origin': '*',
|
|
390
|
+
});
|
|
364
391
|
return res.end(HTML);
|
|
365
392
|
}
|
|
366
393
|
|
|
@@ -388,7 +415,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
388
415
|
t.liveTail = lastLine(path.join(RUNS, `${t.id}.live.log`)) || '';
|
|
389
416
|
}
|
|
390
417
|
}
|
|
391
|
-
|
|
418
|
+
// ★ 只回列表要用的字段,result/error 截到 2000 字。整份可能是 1MB × 单数,
|
|
419
|
+
// 而前端只显示 600 字 —— 每 5 秒轮询传几 MB JSON,浏览器和 node 都会卡。
|
|
420
|
+
// 要看全文有 /api/live/:id。
|
|
421
|
+
return json(list.map((t) => ({
|
|
422
|
+
id: t.id, title: t.title, state: t.state, source: t.source,
|
|
423
|
+
createdAt: t.createdAt, startedAt: t.startedAt, finishedAt: t.finishedAt,
|
|
424
|
+
pid: t.pid, attempts: t.attempts, warn: t.warn, replyError: t.replyError,
|
|
425
|
+
liveTail: t.liveTail,
|
|
426
|
+
result: t.result ? String(t.result).slice(0, 2000) : null,
|
|
427
|
+
error: t.error ? String(t.error).slice(0, 2000) : null,
|
|
428
|
+
})));
|
|
392
429
|
}
|
|
393
430
|
|
|
394
431
|
if (req.method === 'GET' && url.pathname.startsWith('/api/live/')) {
|
|
@@ -437,6 +474,55 @@ const server = http.createServer(async (req, res) => {
|
|
|
437
474
|
return json(t);
|
|
438
475
|
}
|
|
439
476
|
|
|
477
|
+
// POST /api/ask —— 让「别的程序」把「有人叫我了」递进来。
|
|
478
|
+
//
|
|
479
|
+
// 为什么需要它:QQ 那边的入口原本架在 surfcore 桥上(读它写的 social-v2.json)。
|
|
480
|
+
// 桥停了以后,我们这边就收不到任何东西了。现在换一条不依赖桥的路:
|
|
481
|
+
// 谁听见了消息,就把它整句 POST 到这里,门卫在这里判 —— 是活就开单派出去,
|
|
482
|
+
// 是闲聊就只回一句「没开单」,不占工单列表。
|
|
483
|
+
if (req.method === 'POST' && url.pathname === '/api/ask') {
|
|
484
|
+
let raw = '';
|
|
485
|
+
for await (const c of req) raw += c;
|
|
486
|
+
let b = {};
|
|
487
|
+
try { b = JSON.parse(raw || '{}'); } catch { return json({ error: '请求体不是合法 JSON' }, 400); }
|
|
488
|
+
const text = String(b.text || '').trim();
|
|
489
|
+
if (!text) return json({ error: 'text 不能为空' }, 400);
|
|
490
|
+
const chatKey = b.chatKey ? String(b.chatKey) : '';
|
|
491
|
+
const from = b.from ? String(b.from) : '';
|
|
492
|
+
|
|
493
|
+
let j;
|
|
494
|
+
try {
|
|
495
|
+
j = await judge(text);
|
|
496
|
+
} catch (e) {
|
|
497
|
+
// 门卫挂了不该当成「那就干吧」—— 宁可漏,也不瞎干
|
|
498
|
+
return json({ isTask: null, error: `门卫没判出来:${e.message}` });
|
|
499
|
+
}
|
|
500
|
+
if (!j.isTask) {
|
|
501
|
+
return json({ isTask: false, why: j.reason || '', text: text.slice(0, 80) });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const t = addTicket({
|
|
505
|
+
body: j.body || text,
|
|
506
|
+
title: j.title || text.slice(0, 40),
|
|
507
|
+
cwd: b.cwd || process.env.CREW_WORKDIR || process.cwd(),
|
|
508
|
+
source: chatKey ? `qq:${chatKey}` : (b.source ? String(b.source) : 'ask'),
|
|
509
|
+
requester: from,
|
|
510
|
+
chatKey, // 回话要用(reply 靠它找原会话)
|
|
511
|
+
msgId: b.msgId ? String(b.msgId) : '',
|
|
512
|
+
});
|
|
513
|
+
t.judged = { reason: j.reason, by: j.by, original: text };
|
|
514
|
+
saveTicket(t);
|
|
515
|
+
|
|
516
|
+
if (b.autoRun !== false && !running.has(t.id)) {
|
|
517
|
+
running.add(t.id);
|
|
518
|
+
runTicket(t.id)
|
|
519
|
+
.then((done) => { if (done) console.log(`[ask] ${t.id} → ${done.state}`); })
|
|
520
|
+
.catch((e) => console.error(`[ask] ${t.id} 派工出错:${e.message}`))
|
|
521
|
+
.finally(() => running.delete(t.id));
|
|
522
|
+
}
|
|
523
|
+
return json({ isTask: true, id: t.id, title: t.title, why: j.reason || '' });
|
|
524
|
+
}
|
|
525
|
+
|
|
440
526
|
if (req.method === 'POST' && url.pathname.startsWith('/api/run/')) {
|
|
441
527
|
const id = decodeURIComponent(url.pathname.slice('/api/run/'.length));
|
|
442
528
|
if (!/^[A-Za-z0-9_-]+$/.test(id)) return json({ error: 'id 不合法' }, 400);
|
|
@@ -456,6 +542,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
456
542
|
}
|
|
457
543
|
});
|
|
458
544
|
|
|
545
|
+
// ★ 端口被占时 listen 的失败是异步事件 —— 没人监听就是未捕获异常,栈里只有
|
|
546
|
+
// EADDRINUSE,看不出是「谁」占了。
|
|
547
|
+
server.on('error', (e) => {
|
|
548
|
+
if (e && e.code === 'EADDRINUSE') {
|
|
549
|
+
console.error(`端口 ${PORT} 已被占用:先跑 netstat -ano | findstr :${PORT} 看是谁,`
|
|
550
|
+
+ `或者换一个 --port=<其他端口>。`);
|
|
551
|
+
} else {
|
|
552
|
+
console.error('看板起不来:', e);
|
|
553
|
+
}
|
|
554
|
+
process.exit(1);
|
|
555
|
+
});
|
|
556
|
+
|
|
459
557
|
server.listen(PORT, '127.0.0.1', () => {
|
|
460
558
|
console.log(`团队看板:http://127.0.0.1:${PORT}/`);
|
|
461
559
|
console.log(`数据目录:${HOME}`);
|
package/selftest.mjs
CHANGED
|
@@ -37,11 +37,28 @@ function check(name, ok, detail) {
|
|
|
37
37
|
function run(cmd, args, opts = {}) {
|
|
38
38
|
return new Promise((resolve) => {
|
|
39
39
|
const p = spawn(cmd, args, { windowsHide: true, ...opts });
|
|
40
|
-
let out = '', err = '';
|
|
40
|
+
let out = '', err = '', done = false;
|
|
41
|
+
// ★ 不设编码时 chunk 边界会把中文劈成乱码 —— 下面「worker 读中文」那项就会
|
|
42
|
+
// 误报失败(自检说系统坏了,其实是自检自己坏了)。
|
|
43
|
+
p.stdout.setEncoding('utf8');
|
|
44
|
+
p.stderr.setEncoding('utf8');
|
|
45
|
+
// ★ 没有超时会永久挂住:worker 起浏览器不退时实测过,自检就卡在那儿不说话。
|
|
46
|
+
const limit = opts.timeoutMs || 120000;
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
if (done) return;
|
|
49
|
+
done = true;
|
|
50
|
+
try {
|
|
51
|
+
spawn('taskkill', ['/PID', String(p.pid), '/T', '/F'], { windowsHide: true })
|
|
52
|
+
.on('error', () => { /* 杀不掉就算了,至少不再等它 */ });
|
|
53
|
+
} catch { /* 尽力而为 */ }
|
|
54
|
+
resolve({ rc: -2, out, err: err + `\n(超过 ${limit}ms 未结束,已强杀)` });
|
|
55
|
+
}, limit);
|
|
56
|
+
if (timer.unref) timer.unref();
|
|
57
|
+
const finish = (r) => { if (done) return; done = true; clearTimeout(timer); resolve(r); };
|
|
41
58
|
p.stdout.on('data', (d) => { out += d; });
|
|
42
59
|
p.stderr.on('data', (d) => { err += d; });
|
|
43
|
-
p.on('error', (e) =>
|
|
44
|
-
p.on('close', (rc) =>
|
|
60
|
+
p.on('error', (e) => finish({ rc: -1, out, err: String(e.message) }));
|
|
61
|
+
p.on('close', (rc) => finish({ rc, out, err }));
|
|
45
62
|
});
|
|
46
63
|
}
|
|
47
64
|
|
package/worker-brief.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
你手上有 **read / glob / grep 这些文件工具**,它们会自动处理编码。**读文件就用它们。**
|
|
8
8
|
|
|
9
9
|
不要用 shell 去读文件 —— 尤其不要用 PowerShell 的 `Get-Content`:这台机器上它默认按
|
|
10
|
-
GBK 解码,读 UTF-8
|
|
10
|
+
GBK 解码,读 UTF-8 的中文文件会变乱码(看起来像一堆方块、问号、`é–` 这类怪符号),**而且行数也会算错**
|
|
11
11
|
(GBK 解码器会把换行字节当成多字节字符的一部分吃掉,几行会并成一行)。
|
|
12
12
|
|
|
13
13
|
只有 read 工具做不了的事(数行数、批量统计),才用 **python**:
|
|
@@ -23,7 +23,7 @@ Python 在 `D:\Python312\python.exe`(**不在 PATH,要写全路径**)。
|
|
|
23
23
|
|
|
24
24
|
动手之前,先确认你能正确读到中文:用 **read 工具**读一个含中文的文件,看一眼中文是不是正常的。
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
若显示成一堆怪符号(方块、问号、`é–` 之类),说明你走错路了 —— 换回 read 工具,或者用 python 指定 `encoding='utf-8'`。
|
|
27
27
|
|
|
28
28
|
这 30 秒能省掉十分钟的绕圈。
|
|
29
29
|
|