@gavinc-cn/touchstone-dsh 0.1.0 → 0.1.1
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 +1 -0
- package/README.zh.md +1 -0
- package/agents.py +0 -0
- package/board.py +348 -26
- package/chat.py +0 -0
- package/db.py +1 -1
- package/dsh-plugin/install.sh +0 -0
- package/dsh-plugin/lib/agent-driver.js +190 -42
- package/dsh-plugin/lib/index.js +181 -22
- package/dshdriver.py +0 -0
- package/dshevents.py +35 -3
- package/feishu.py +29 -8
- package/package.json +8 -3
- package/runner.py +11 -0
- package/server.py +46 -4
- package/sessparse.py +95 -1
- package/touchstone.py +2 -1
- package/webui/dist-plugin/assets/index-DtwKoi1T.js +473 -0
- package/webui/dist-plugin/index.html +1 -1
- package/worktree.py +100 -0
- package/webui/dist-plugin/assets/index-eHpxBfP6.js +0 -473
|
@@ -57,6 +57,21 @@ const QUESTION_CALL_MAX = 64;
|
|
|
57
57
|
/** SSE keepalive 注释帧间隔(毫秒):防中间层按空闲超时切断长连 */
|
|
58
58
|
const KEEPALIVE_MS = 15000;
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* 归档集兜底扫描间隔(毫秒;纯内存读,不产生任何 HTTP/磁盘 IO)。
|
|
62
|
+
*
|
|
63
|
+
* 2026-10-07 实测定因:宿主 `WorkspaceRegistry.setState()` 的顺序是
|
|
64
|
+
* `await global.set(state)` → `this.state = state`,而 `domain/changed` 正是在
|
|
65
|
+
* `global.set` 内部**同步**发出的 ⇒ 监听器执行时 registry 的
|
|
66
|
+
* `archivedSessionIds` 读到的仍是**旧快照**(官方 registry 的内存缓存尚未刷新)。
|
|
67
|
+
* 旧实现据此比对 key 判定「没变化」而早退,一次帧都不推——dsh 侧归档只能等
|
|
68
|
+
* 15s 的 `_keepalive` 兜底(真机四组实测:归档 RPC 13~19ms 返回,帧延迟
|
|
69
|
+
* 1.1/6.1/6.1/13.8s)。事件路径已改为**直接取事件载荷**里的权威新值
|
|
70
|
+
* (见 `_onDomainChanged`),本定时器只作二道兜底:事件再丢/形态再变时,
|
|
71
|
+
* 变化也能在 3s 内推出去(原兜底是 15s)。
|
|
72
|
+
*/
|
|
73
|
+
const ARCHIVE_SWEEP_MS = 3000;
|
|
74
|
+
|
|
60
75
|
/** 请求体上限(字节):提示词可能很长,给足余量 */
|
|
61
76
|
const MAX_BODY = 8 * 1024 * 1024;
|
|
62
77
|
|
|
@@ -188,13 +203,17 @@ export class AgentDriver {
|
|
|
188
203
|
this.fiber = null;
|
|
189
204
|
this._routes = [];
|
|
190
205
|
this._timer = null;
|
|
206
|
+
/** 归档集快速兜底扫描定时器(见 ARCHIVE_SWEEP_MS) */
|
|
207
|
+
this._sweepTimer = null;
|
|
191
208
|
this._userMessageFactory = null;
|
|
192
209
|
/** 平台侧审批 id 序号(`ap-<n>`;ApprovalRequestEvent 无 id,见 _onApprovalRequest) */
|
|
193
210
|
this._approvalSeq = 0;
|
|
194
211
|
/**
|
|
195
|
-
* 插件**认领中**的提问:callId → {sid, mark, resolve, reject, finish, ...}
|
|
196
|
-
* (2026-10-04 修 #791
|
|
197
|
-
*
|
|
212
|
+
* 插件**认领中**的提问:callId → {sid, mark, resolve, reject, finish, lane, ...}
|
|
213
|
+
* (2026-10-04 修 #791 认领;2026-10-06 修 #837 起改为**双通道**:平台经
|
|
214
|
+
* `/answer` 兑现,dsh GUI 的原生作答同样兑现——先到者胜,见 `_holdQuestion`)。
|
|
215
|
+
* 只装平台自持会话的提问;兑现或中止即从表里摘除;外部会话的提问一律让位
|
|
216
|
+
* 原生作答者,不入本表。
|
|
198
217
|
*/
|
|
199
218
|
this.questions = new Map();
|
|
200
219
|
/**
|
|
@@ -290,22 +309,25 @@ export class AgentDriver {
|
|
|
290
309
|
// 事件挂在注入作用域上(契约自检的桩 ctx 只有 agentCtx 有 on;真机两者同一总线)
|
|
291
310
|
const bus = (wsCtx && typeof wsCtx.on === 'function') ? wsCtx : this.ctx;
|
|
292
311
|
if (bus && typeof bus.on === 'function') {
|
|
293
|
-
bus.on('domain/changed', (change) =>
|
|
294
|
-
if (!change || change.domain !== 'workspace') return;
|
|
295
|
-
this._publishArchiveState(false);
|
|
296
|
-
});
|
|
312
|
+
bus.on('domain/changed', (change) => this._onDomainChanged(change));
|
|
297
313
|
}
|
|
298
314
|
this._publishArchiveState(true);
|
|
299
315
|
this.logger.info('touchstone: workspaceRegistry 可用(归档集事件 + /archived//archive)');
|
|
300
316
|
});
|
|
301
317
|
this._timer = setInterval(() => this._keepalive(), KEEPALIVE_MS);
|
|
302
318
|
if (this._timer.unref) this._timer.unref();
|
|
319
|
+
// 归档集快速兜底(2026-10-07):事件为主路径已毫秒级,这里只是二道防线
|
|
320
|
+
this._sweepTimer = setInterval(() => this._publishArchiveState(false),
|
|
321
|
+
ARCHIVE_SWEEP_MS);
|
|
322
|
+
if (this._sweepTimer.unref) this._sweepTimer.unref();
|
|
303
323
|
}
|
|
304
324
|
|
|
305
325
|
/** 停用:注销路由、断开 SSE、释放常驻会话与观察表。 */
|
|
306
326
|
async dispose() {
|
|
307
327
|
if (this._timer) clearInterval(this._timer);
|
|
308
328
|
this._timer = null;
|
|
329
|
+
if (this._sweepTimer) clearInterval(this._sweepTimer);
|
|
330
|
+
this._sweepTimer = null;
|
|
309
331
|
for (const disposeRoute of this._routes) {
|
|
310
332
|
try {
|
|
311
333
|
disposeRoute();
|
|
@@ -328,11 +350,13 @@ export class AgentDriver {
|
|
|
328
350
|
await this._disposeHandle(entry);
|
|
329
351
|
}
|
|
330
352
|
// 认领中的提问:插件要走了,兑现不了——reject 收口(宿主 `ask()` 见信号中止/
|
|
331
|
-
// 异常会归一,tool result
|
|
353
|
+
// 异常会归一,tool result 记中断),并清挂起标记、收起 dsh GUI 的提问框,
|
|
354
|
+
// 避免平台侧与浏览器侧各残留一个 pending
|
|
332
355
|
for (const [callId, pending] of [...this.questions]) {
|
|
333
356
|
this.questions.delete(callId);
|
|
334
357
|
try {
|
|
335
358
|
pending.finish();
|
|
359
|
+
pending.cancelLane();
|
|
336
360
|
pending.reject(new Error('touchstone driver disposed before the question settled'));
|
|
337
361
|
} catch {
|
|
338
362
|
/* 已收口/已兑现:忽略 */
|
|
@@ -357,6 +381,8 @@ export class AgentDriver {
|
|
|
357
381
|
usage: entry.usage || null, // 上下文用量(assistant/message 累积)
|
|
358
382
|
permission: this._permissionState(entry), // 会话级权限({mode, preset};进程内回读)
|
|
359
383
|
started_at: entry.createdAt, // 平台侧「运行中会话精确探测」的排序依据
|
|
384
|
+
// 会话头行 origin(子代理='subagent',主会话=空串):平台据此不建卡/不占位
|
|
385
|
+
origin: this._sessionOrigin(entry.agent && entry.agent.session),
|
|
360
386
|
}));
|
|
361
387
|
const external = [];
|
|
362
388
|
for (const [sid, info] of this.observed) {
|
|
@@ -368,6 +394,7 @@ export class AgentDriver {
|
|
|
368
394
|
cwd: info.cwd,
|
|
369
395
|
status: live ? live.status : 'unknown',
|
|
370
396
|
owned: false,
|
|
397
|
+
origin: info.origin || '', // 子代理会话标记(见 _sessionOrigin)
|
|
371
398
|
interaction: info.interaction || null,
|
|
372
399
|
last_turn_reason: info.lastTurnReason || null,
|
|
373
400
|
});
|
|
@@ -486,17 +513,31 @@ export class AgentDriver {
|
|
|
486
513
|
entry.publish({ sid, ...trimEvent(relay) });
|
|
487
514
|
}
|
|
488
515
|
|
|
516
|
+
/** 会话头行的 origin(dsh 子代理会话为 `'subagent'`,主会话/未上报为空串)。
|
|
517
|
+
*
|
|
518
|
+
* 为什么平台需要它(2026-10-07):子代理会话与主会话同 bucket 同格式,平台侧
|
|
519
|
+
* 原本把每个会话都建成看板卡并在其运行时落 `ext:` 占用行——子代理是主会话的
|
|
520
|
+
* 实现细节,于是「一次派 8 个子代理」=多 8 张卡 + 项目被占满不补位(实障:
|
|
521
|
+
* 卡 870 排队 5 小时)。平台据此把子代理排除在建卡与占用之外,判定源就是这里。
|
|
522
|
+
* 空串=未知(绝不能推断成「非子代理」)。
|
|
523
|
+
*/
|
|
524
|
+
_sessionOrigin(session) {
|
|
525
|
+
return String((session && session.header && session.header.origin) || '');
|
|
526
|
+
}
|
|
527
|
+
|
|
489
528
|
/** `session/created`:登记全局可见会话(外部直跑会话的发现入口)。 */
|
|
490
529
|
_observe(session) {
|
|
491
530
|
const sid = String(session.id);
|
|
492
531
|
const cwd = (session.header && session.header.cwd) || '';
|
|
493
532
|
this.observed.set(sid, {
|
|
494
533
|
cwd,
|
|
534
|
+
origin: this._sessionOrigin(session),
|
|
495
535
|
lastTurnReason: null,
|
|
496
536
|
interaction: null,
|
|
497
537
|
updatedAt: Date.now(),
|
|
498
538
|
});
|
|
499
|
-
this._publishState('session/created', {
|
|
539
|
+
this._publishState('session/created', {
|
|
540
|
+
sid, data: { cwd, owned: this.sessions.has(sid), origin: this._sessionOrigin(session) } });
|
|
500
541
|
}
|
|
501
542
|
|
|
502
543
|
/**
|
|
@@ -547,7 +588,7 @@ export class AgentDriver {
|
|
|
547
588
|
}
|
|
548
589
|
|
|
549
590
|
/**
|
|
550
|
-
* `user-questions/request`(waterfall,**prepend 注册**):提问的观测 +
|
|
591
|
+
* `user-questions/request`(waterfall,**prepend 注册**):提问的观测 + 双通道作答。
|
|
551
592
|
*
|
|
552
593
|
* 为什么必须 prepend(2026-10-04 隔离实例探针实测,修 #791):
|
|
553
594
|
* dsh Web 的客户端问答桥(`@deepseek-ai/dsh-api-remotes` 把该 waterfall 转发给
|
|
@@ -557,13 +598,17 @@ export class AgentDriver {
|
|
|
557
598
|
* 由下面的分支决定,故 Web GUI 对**外部会话**的行为一个字节都没变。
|
|
558
599
|
*
|
|
559
600
|
* 两条分支:
|
|
560
|
-
* 1) 平台自持会话(`/session`
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
601
|
+
* 1) 平台自持会话(`/session` 建的池内会话,看板卡与任务轮都走它):**双通道**——
|
|
602
|
+
* 插件认领(平台 `/answer` 兑现,Touchstone 会话窗渲染选择框可作答)**并且**
|
|
603
|
+
* 把请求交回下游原生链路(dsh Web GUI 同样弹提问框、同样可作答),两侧先答者胜
|
|
604
|
+
* (竞速口径见 `_holdQuestion`)。
|
|
605
|
+
* · 为什么必须认领:dsh 阻塞式 `ask_user_question`(tool-ask-user 默认 legacy
|
|
606
|
+
* 模式)**不带 `request.wait.callId`**,而宿主
|
|
607
|
+
* `userQuestions.answer(agent, callId, …)` 只受理「continued」(前台等待超时
|
|
608
|
+
* 后)的提问——**在途提问只有 waterfall 链内返回答案这一条路**。只让位不认领,
|
|
609
|
+
* 平台的 answerable/作答通道全程空转(#791:Touchstone 会话窗不出现选择框)。
|
|
610
|
+
* · 为什么要让位(2026-10-06 修 #837):纯认领时该 waterfall 到插件为止,dsh Web
|
|
611
|
+
* GUI 收不到提问 —— 提问框只在 Touchstone 侧出现。预期是**两侧都显示**。
|
|
567
612
|
* 2) 其它会话(用户自己直跑/外部会话):只旁听(发状态帧,平台据此把卡置阻塞、
|
|
568
613
|
* 把 ext 行按挂起处置),`next()` 让原生作答者(dsh Web GUI)照旧作答。
|
|
569
614
|
*/
|
|
@@ -573,10 +618,14 @@ export class AgentDriver {
|
|
|
573
618
|
const waitCallId = String((request && request.wait && request.wait.callId) || '');
|
|
574
619
|
const entry = sid ? this.sessions.get(sid) : undefined;
|
|
575
620
|
if (entry !== undefined) {
|
|
576
|
-
//
|
|
621
|
+
// 平台自持会话:双通道。标识优先取宿主给的 wait.callId(timed 模式),
|
|
577
622
|
// 否则取 `ask_user_question` 的真实 tool callId(legacy 模式),再否则自造
|
|
578
623
|
const callId = waitCallId || this._takeQuestionCallId(sid) || this._nextQuestionId();
|
|
579
|
-
|
|
624
|
+
// 先留原宿主取消信号(轮次中止/停卡):打开原生通道会替换请求对象上的
|
|
625
|
+
// `signal`(见 `_openNativeLane`),平台侧的收口仍要认**原信号**
|
|
626
|
+
const hostSignal = request && request.signal;
|
|
627
|
+
const lane = this._openNativeLane(request, next);
|
|
628
|
+
return this._holdQuestion(entry, callId, request, hostSignal, lane);
|
|
580
629
|
}
|
|
581
630
|
if (!sid) return next();
|
|
582
631
|
this._publishQuestionMark(sid, waitCallId, request);
|
|
@@ -584,52 +633,123 @@ export class AgentDriver {
|
|
|
584
633
|
}
|
|
585
634
|
|
|
586
635
|
/**
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
* `ask_user_question
|
|
636
|
+
* 打开「原生作答通道」:把提问交回 waterfall 下游(dsh Web 客户端问答桥 → 浏览器
|
|
637
|
+
* 提问框),返回 `{promise, cancel}`。dsh GUI 的作答经该 promise 回到宿主
|
|
638
|
+
* `ask_user_question`,与平台 `/answer` 是同一条 waterfall 语义。
|
|
639
|
+
*
|
|
640
|
+
* 为什么转发前要替换 `request.signal`(修 #837 的关键;依据是读
|
|
641
|
+
* `@deepseek-ai/dsh-api-gateway` 两个半壳代码,不是猜测):
|
|
642
|
+
* · Host 半(`lib/index.js` `startRemoteEvent`):请求对象上的 `signal` 被登记为该
|
|
643
|
+
* pending 事件的**宿主取消信号**,一旦 abort 就 `finishRemoteEvent` 并向浏览器补发
|
|
644
|
+
* `{type:'cancel', eventId}`;
|
|
645
|
+
* · Client 半(`lib/client.js` 的 `frame.type === 'cancel'`):收到 cancel 即 abort 该
|
|
646
|
+
* 次投递的 signal ⇒ 客户端问答桥的 `claimSignal` 中止 ⇒ 提问框自行收起、
|
|
647
|
+
* 定时等待(timed 模式)一并释放。
|
|
648
|
+
* 平台先在 Touchstone 侧作答时,这是插件**唯一**能让 dsh GUI 那张框消失的手段
|
|
649
|
+
* (否则框会留在输入区,用户再答也无人接收)。
|
|
650
|
+
* `request` 是宿主 `ask()` 里 `{...request, agent}` 造出来的**浅拷贝**,只被本链下游
|
|
651
|
+
* 看到;宿主 `ask()` 自身的取消判断读原对象,故替换不影响它。替换后仍把原信号接进来
|
|
652
|
+
* (停卡/轮次中止时两端一起收口,行为与替换前一致)。
|
|
653
|
+
*
|
|
654
|
+
* 下游失败(无 Web 客户端问答桥的 profile 如 headless、客户端内部异常)只当该通道
|
|
655
|
+
* 不存在:这里先吃掉 rejection(平台通道仍等 `/answer`),调用方只竞速**成功**值。
|
|
656
|
+
*
|
|
657
|
+
* @returns {{promise: Promise<any>, cancel: (reason?: Error) => void}}
|
|
658
|
+
*/
|
|
659
|
+
_openNativeLane(request, next) {
|
|
660
|
+
const upstream = request && request.signal;
|
|
661
|
+
const controller = new AbortController();
|
|
662
|
+
if (upstream) {
|
|
663
|
+
if (upstream.aborted) controller.abort(upstream.reason);
|
|
664
|
+
else if (typeof upstream.addEventListener === 'function') {
|
|
665
|
+
upstream.addEventListener('abort', () => controller.abort(upstream.reason), { once: true });
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
request.signal = controller.signal; // 网关据此登记取消/收框
|
|
670
|
+
} catch {
|
|
671
|
+
/* 请求对象被冻结(异常形态):退化为「平台先答时 GUI 的框不自收」,不影响作答 */
|
|
672
|
+
}
|
|
673
|
+
let promise;
|
|
674
|
+
try {
|
|
675
|
+
promise = Promise.resolve(next());
|
|
676
|
+
} catch (err) {
|
|
677
|
+
promise = Promise.reject(err);
|
|
678
|
+
}
|
|
679
|
+
promise.catch(() => {}); // 见 docstring:下游失败由平台通道兜底
|
|
680
|
+
return { promise, cancel: (reason) => { try { controller.abort(reason); } catch { /* 已中止 */ } } };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* 认领一个平台自持会话的提问:登记进 `this.questions`、发挂起状态帧,返回的 Promise
|
|
685
|
+
* 由**两个作答通道先到者**兑现——① 平台 `/answer`;② 原生链路(dsh GUI 提问框,
|
|
686
|
+
* `_openNativeLane`)。返回值直接交回宿主 `ask_user_question`。
|
|
687
|
+
*
|
|
688
|
+
* 竞速口径(修 #837,两侧都显示、都可答):
|
|
689
|
+
* · 原生链路**成功**即胜出:清平台挂起标记(Touchstone 会话窗的框随之收起),
|
|
690
|
+
* 平台侧迟到的 `/answer` 落到 `svc.answer` 的 continued 通道(accepted=false,
|
|
691
|
+
* 平台按 40405 收口,不空转)。
|
|
692
|
+
* · 原生链路**失败**不算数(GUI 点「取消」、客户端异常、无客户端的 profile…):
|
|
693
|
+
* 平台通道继续等 `/answer`。真正的失败只由宿主取消信号(停卡/cancel)给出。
|
|
694
|
+
* · 平台先答:`_settleQuestion` 主动 abort 原生通道的取消信号 → dsh GUI 的提问框
|
|
695
|
+
* 自行收起(见 `_openNativeLane`),不留「答了也没人收」的死框。
|
|
590
696
|
*
|
|
591
697
|
* 中止(用户停卡/cancel):宿主 abort 信号触发 → reject 普通 Error,宿主 `ask()`
|
|
592
698
|
* 见「信号已中止」会归一为 ASK_ABORTED(tool result 记中断,与旧行为一致)。
|
|
593
699
|
*/
|
|
594
|
-
_holdQuestion(entry, callId, request) {
|
|
700
|
+
_holdQuestion(entry, callId, request, hostSignal, lane) {
|
|
595
701
|
const sid = entry.sessionId;
|
|
596
702
|
return new Promise((resolve, reject) => {
|
|
597
|
-
const pending = { sid, callId, mark: null, done: false, finish: null, resolve, reject
|
|
703
|
+
const pending = { sid, callId, mark: null, done: false, finish: null, resolve, reject,
|
|
704
|
+
lane: lane || null,
|
|
705
|
+
cancelLane: () => { if (lane) lane.cancel(new Error('the platform answered the question first')); } };
|
|
598
706
|
this.questions.set(callId, pending);
|
|
599
707
|
/** 收口(幂等):摘表 + 解绑中止监听 + 清挂起标记 */
|
|
600
708
|
pending.finish = () => {
|
|
601
709
|
if (pending.done) return;
|
|
602
710
|
pending.done = true;
|
|
603
711
|
if (this.questions.get(callId) === pending) this.questions.delete(callId);
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
signal.removeEventListener('abort', pending.onAbort);
|
|
712
|
+
if (pending.onAbort && hostSignal
|
|
713
|
+
&& typeof hostSignal.removeEventListener === 'function') {
|
|
714
|
+
hostSignal.removeEventListener('abort', pending.onAbort);
|
|
608
715
|
}
|
|
609
716
|
this._clearQuestionMark(sid);
|
|
610
717
|
};
|
|
611
|
-
|
|
612
|
-
if (
|
|
613
|
-
|
|
718
|
+
// 原生通道(dsh GUI):成功即胜出并清平台挂起标记;失败一律忽略(平台通道仍在)
|
|
719
|
+
if (lane) {
|
|
720
|
+
lane.promise.then((value) => {
|
|
721
|
+
if (pending.done) return;
|
|
722
|
+
pending.finish();
|
|
723
|
+
resolve(value);
|
|
724
|
+
}, () => { /* 见 docstring:旁路故障不判死提问 */ });
|
|
725
|
+
}
|
|
726
|
+
if (hostSignal) {
|
|
727
|
+
if (hostSignal.aborted) {
|
|
614
728
|
pending.finish();
|
|
729
|
+
pending.cancelLane();
|
|
615
730
|
reject(new Error('ask_user_question aborted before the platform answered'));
|
|
616
731
|
return;
|
|
617
732
|
}
|
|
618
733
|
pending.onAbort = () => {
|
|
619
734
|
pending.finish();
|
|
735
|
+
pending.cancelLane();
|
|
620
736
|
reject(new Error('ask_user_question aborted before the platform answered'));
|
|
621
737
|
};
|
|
622
|
-
|
|
738
|
+
hostSignal.addEventListener('abort', pending.onAbort);
|
|
623
739
|
}
|
|
624
740
|
pending.mark = this._publishQuestionMark(sid, callId, request);
|
|
625
741
|
});
|
|
626
742
|
}
|
|
627
743
|
|
|
628
|
-
/**
|
|
744
|
+
/**
|
|
745
|
+
* 兑现认领中的提问(平台 `/answer` 送达):true=命中并已交付宿主。
|
|
746
|
+
* 平台先答时顺带 abort 原生通道的取消信号——dsh GUI 的提问框随之收起(修 #837)。
|
|
747
|
+
*/
|
|
629
748
|
_settleQuestion(callId, answer) {
|
|
630
749
|
const pending = this.questions.get(callId);
|
|
631
750
|
if (pending === undefined) return false;
|
|
632
751
|
pending.finish();
|
|
752
|
+
pending.cancelLane();
|
|
633
753
|
pending.resolve(answer);
|
|
634
754
|
return true;
|
|
635
755
|
}
|
|
@@ -758,19 +878,44 @@ export class AgentDriver {
|
|
|
758
878
|
}
|
|
759
879
|
}
|
|
760
880
|
|
|
881
|
+
/**
|
|
882
|
+
* `domain/changed` → 归档集推帧(事件为主路径)。
|
|
883
|
+
*
|
|
884
|
+
* **为什么取事件载荷而不是回读 registry(2026-10-07 真机实测定因)**:官方
|
|
885
|
+
* `WorkspaceRegistry.setState()` 的写法是 `await this.global.set(state)` **之后**
|
|
886
|
+
* 才更新自己的内存缓存 `this.state`,而 `domain/changed` 正是在 `global.set`
|
|
887
|
+
* 内部**同步**发出的——监听器执行时 `registry.archivedSessionIds` 读到的还是
|
|
888
|
+
* **旧快照**,旧实现据此算出「key 没变」直接早退、一帧都不推;真机表现为
|
|
889
|
+
* 「dsh GUI 归档后要等 15s keepalive 才把卡片搬进『已完成』」(四组实测:
|
|
890
|
+
* RPC 13~19ms,帧延迟 1.1/6.1/6.1/13.8s,全部落在 keepalive 节拍上)。
|
|
891
|
+
* 事件载荷 `change.value` 就是刚落盘的整表状态(与 registry 随后缓存的是同一个
|
|
892
|
+
* 对象引用),取 `archivedSessionIds` 即权威新值;非全局态写入(`table` 非空,
|
|
893
|
+
* 如 workspaces 表记录)不携带该字段 → 回退读 registry(那类写入本就不改归档集)。
|
|
894
|
+
* @param {object} change - `{domain, table, key, operation, value}`
|
|
895
|
+
*/
|
|
896
|
+
_onDomainChanged(change) {
|
|
897
|
+
if (!change || change.domain !== 'workspace') return;
|
|
898
|
+
const state = change.table ? null : change.value;
|
|
899
|
+
const ids = (state && Array.isArray(state.archivedSessionIds))
|
|
900
|
+
? state.archivedSessionIds : undefined;
|
|
901
|
+
this._publishArchiveState(false, ids);
|
|
902
|
+
}
|
|
903
|
+
|
|
761
904
|
/**
|
|
762
905
|
* 归档集变化 → 推一帧 `driver/archived` **整表快照**(进 stateRing + 广播)。
|
|
763
906
|
* 整表而非增量:平台卡片绑定的 sid 可能不在会话池/观察表里(历史会话),
|
|
764
907
|
* 整表帧覆盖任意 sid,且平台侧一次覆盖、无需逐会话记账。
|
|
765
908
|
* @param {boolean} force - true=无条件发(启动基线);false=与上次发布值比对
|
|
909
|
+
* @param {string[]|undefined} ids - 权威归档集(`domain/changed` 事件载荷优先);
|
|
910
|
+
* 缺省=回读 registry(keepalive / 快速兜底扫描路径)
|
|
766
911
|
*/
|
|
767
|
-
_publishArchiveState(force) {
|
|
768
|
-
const
|
|
769
|
-
if (
|
|
770
|
-
const key =
|
|
912
|
+
_publishArchiveState(force, ids) {
|
|
913
|
+
const list = Array.isArray(ids) ? ids.map(String) : this._archivedIds();
|
|
914
|
+
if (list === null) return; // 服务缺失:不发帧(平台侧维持「未知」)
|
|
915
|
+
const key = list.join('\n');
|
|
771
916
|
if (!force && key === this._archivedKey) return;
|
|
772
917
|
this._archivedKey = key;
|
|
773
|
-
this._publishState('driver/archived', { data: { archived:
|
|
918
|
+
this._publishState('driver/archived', { data: { archived: list } });
|
|
774
919
|
}
|
|
775
920
|
|
|
776
921
|
/** `GET /archived`:归档集整表(平台 EventHub 在(重)连对齐时取一次)。 */
|
|
@@ -923,6 +1068,8 @@ export class AgentDriver {
|
|
|
923
1068
|
interaction: entry.interaction,
|
|
924
1069
|
cwd: entry.cwd,
|
|
925
1070
|
task: entry.task,
|
|
1071
|
+
// 会话头行 origin(子代理='subagent',主会话=空串)——与 /live 同口径
|
|
1072
|
+
origin: this._sessionOrigin(entry.agent && entry.agent.session),
|
|
926
1073
|
});
|
|
927
1074
|
}
|
|
928
1075
|
|
|
@@ -1122,15 +1269,16 @@ export class AgentDriver {
|
|
|
1122
1269
|
}
|
|
1123
1270
|
|
|
1124
1271
|
/**
|
|
1125
|
-
* 回答挂起的提问(2026-10-04 修 #791
|
|
1272
|
+
* 回答挂起的提问(2026-10-04 修 #791 起分两路;2026-10-06 修 #837 起 dsh GUI 也可答):
|
|
1126
1273
|
*
|
|
1127
1274
|
* ① 插件**认领中**的提问(平台自持会话的在途提问,`this.questions` 命中):
|
|
1128
1275
|
* 兑现认领 Promise——返回值由 waterfall 直接交回宿主 `ask_user_question`。
|
|
1129
|
-
*
|
|
1276
|
+
* 这是平台答**在途**提问的通道(见 `_onQuestionRequest` 注释)。
|
|
1130
1277
|
* ② 认领表未命中:退回宿主 `ctx.userQuestions.answer(agent, callId, …)`——
|
|
1131
1278
|
* 只对「continued」(前台等待超时后)的提问有效,覆盖 timed 模式超时后
|
|
1132
|
-
* 平台迟到作答的场景;accepted=false
|
|
1133
|
-
*
|
|
1279
|
+
* 平台迟到作答的场景;accepted=false 表示问题已不存在/已应答(含**用户在
|
|
1280
|
+
* dsh GUI 先答**的情形:#837 起两侧同题,先答者胜),平台侧按
|
|
1281
|
+
* 40405「问题已不存在」放弃重试,不空转。
|
|
1134
1282
|
*/
|
|
1135
1283
|
async _answer(res, req) {
|
|
1136
1284
|
const body = await this._body(req);
|
package/dsh-plugin/lib/index.js
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* 两条与正常 dispose 共用同一个幂等 `teardown()`, 因此正常路径行为不变。
|
|
23
23
|
*/
|
|
24
24
|
import { spawn } from 'node:child_process';
|
|
25
|
-
import { existsSync } from 'node:fs';
|
|
25
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
26
26
|
import http from 'node:http';
|
|
27
27
|
import { dirname, join } from 'node:path';
|
|
28
28
|
import { fileURLToPath } from 'node:url';
|
|
@@ -48,11 +48,146 @@ const UNMOUNT_WATCH_MS = 2000;
|
|
|
48
48
|
/** 进程级存活壳注册表键(同一 dsh 进程内只允许一份有效壳) */
|
|
49
49
|
const LIVE_KEY = Symbol.for('touchstone.live');
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* 后端 Python 依赖预检(2026-10-07 增):
|
|
53
|
+
* 为什么——插件模式下 server.py 由本壳直接 spawn, 用户若只 `dsh plugin add` 而没跑
|
|
54
|
+
* `pip install -r requirements.txt`, Python 侧在**导入期**就退出(server.py 的
|
|
55
|
+
* `import sessparse` → sessparse.py 模块级 `import zstandard`; `import feishu`
|
|
56
|
+
* → feishu.py 模块级 `import requests`)。此时面板只会看到 503 JSON、日志只有一行
|
|
57
|
+
* traceback, 新用户无从下手 —— 而 npm 安装的预期是「装完就能用」。
|
|
58
|
+
* 做法——spawn 前用**同一个解释器**探一次: 硬依赖用真 import 验(将来新增模块级硬依赖
|
|
59
|
+
* 要同步这个列表), requirements.txt 里其余发行名用 importlib.metadata 验, 只作告警。
|
|
60
|
+
* 铁律——预检自身任何异常/超时一律**放行**, 绝不因为预检挡住启动。
|
|
61
|
+
*/
|
|
62
|
+
const FATAL_PY_MODULES = ['zstandard', 'requests'];
|
|
63
|
+
const PREFLIGHT_TIMEOUT_MS = 10000;
|
|
64
|
+
|
|
51
65
|
/** 子进程句柄与解析出的后端端口(每 profile 一个插件实例, 模块级单例即可) */
|
|
52
66
|
let child = null;
|
|
53
67
|
let backendPort = 0;
|
|
54
68
|
/** agent 驱动实例(模块级单例: 与 child 同生命周期, 热重载时整体替换) */
|
|
55
69
|
let driver = null;
|
|
70
|
+
/** 依赖缺失时的提示页 HTML: 非空 ⇒ proxy 用它代替 503 JSON(每次 apply 重算) */
|
|
71
|
+
let backendHint = '';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* HTML 转义: 提示页里要嵌路径与命令原文(含用户配置的 pythonPath)
|
|
75
|
+
*/
|
|
76
|
+
function escapeHtml(text) {
|
|
77
|
+
return String(text).replace(/[&<>"]/g,
|
|
78
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 读 requirements.txt 的发行名清单(注释/空行/选项行跳过, 剥掉版本与 extras 约束)。
|
|
83
|
+
* 读不到就返回空数组 —— 预检降级为「只验硬依赖」, 不影响启动。
|
|
84
|
+
*/
|
|
85
|
+
function readRequirementNames(repoDir) {
|
|
86
|
+
try {
|
|
87
|
+
return readFileSync(join(repoDir, 'requirements.txt'), 'utf8')
|
|
88
|
+
.split('\n')
|
|
89
|
+
.map((line) => line.split('#')[0].trim())
|
|
90
|
+
.filter((line) => line && !line.startsWith('-'))
|
|
91
|
+
.map((line) => line.split(/[<>=!~;[\s]/, 1)[0].trim())
|
|
92
|
+
.filter(Boolean);
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 用 pythonPath 探一次运行依赖, 返回 { fatal, advisory, skipped }:
|
|
100
|
+
* fatal —— 缺失的硬依赖(会导致 server.py 导入期退出)
|
|
101
|
+
* advisory —— requirements.txt 里缺失的可选依赖(只影响对应功能)
|
|
102
|
+
* skipped —— 预检本身没跑成(解释器缺失/超时/输出不可解析)⇒ 调用方照常启动
|
|
103
|
+
*/
|
|
104
|
+
function preflightDeps(pythonPath, repoDir, logger) {
|
|
105
|
+
const advisory = readRequirementNames(repoDir)
|
|
106
|
+
.filter((name) => !FATAL_PY_MODULES.includes(name.toLowerCase()));
|
|
107
|
+
const script = [
|
|
108
|
+
'import importlib, importlib.metadata as md, json, sys',
|
|
109
|
+
'fatal, rest = sys.argv[1].split(","), sys.argv[2].split(",")',
|
|
110
|
+
'out = {"fatal": [], "advisory": []}',
|
|
111
|
+
'for mod in fatal:',
|
|
112
|
+
' try: importlib.import_module(mod)',
|
|
113
|
+
' except Exception: out["fatal"].append(mod)',
|
|
114
|
+
'for dist in rest:',
|
|
115
|
+
' if not dist: continue',
|
|
116
|
+
' try: md.version(dist)',
|
|
117
|
+
' except Exception: out["advisory"].append(dist)',
|
|
118
|
+
'print(json.dumps(out))',
|
|
119
|
+
].join('\n');
|
|
120
|
+
const argv = ['-c', script, FATAL_PY_MODULES.join(','), advisory.join(',')];
|
|
121
|
+
return new Promise((resolve) => {
|
|
122
|
+
let settled = false;
|
|
123
|
+
const settle = (value) => {
|
|
124
|
+
if (settled) return;
|
|
125
|
+
settled = true;
|
|
126
|
+
resolve(value);
|
|
127
|
+
};
|
|
128
|
+
const skip = (why) => {
|
|
129
|
+
if (settled) return; // error 与 close 可能都到, 只记一次
|
|
130
|
+
logger.warn(`touchstone: 依赖预检未完成(${why}), 照常启动后端`);
|
|
131
|
+
settle({ fatal: [], advisory: [], skipped: true });
|
|
132
|
+
};
|
|
133
|
+
const proc = spawn(pythonPath, argv, { cwd: repoDir, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
134
|
+
let stdout = '';
|
|
135
|
+
const timer = setTimeout(() => {
|
|
136
|
+
skip(`超过 ${PREFLIGHT_TIMEOUT_MS}ms`);
|
|
137
|
+
try {
|
|
138
|
+
proc.kill('SIGKILL');
|
|
139
|
+
} catch { /* 已退出:忽略 */ }
|
|
140
|
+
}, PREFLIGHT_TIMEOUT_MS);
|
|
141
|
+
if (timer.unref) timer.unref();
|
|
142
|
+
proc.stdout.on('data', (buf) => { stdout += String(buf); });
|
|
143
|
+
proc.stderr.on('data', (buf) => logger.warn(`touchstone[py-preflight]: ${String(buf).trim()}`));
|
|
144
|
+
proc.on('error', (err) => {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
skip(err.message);
|
|
147
|
+
});
|
|
148
|
+
// 必须用 close 而不是 exit: exit 可能在 stdout 最后一个数据块**之前**触发,
|
|
149
|
+
// 那会把正常输出读成空串而静默放行(缺依赖的机器上照样拉起注定退出的后端)。
|
|
150
|
+
// close = 子进程已退出且 stdio 全部关闭, 此时 stdout 一定收全。
|
|
151
|
+
proc.on('close', () => {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
const text = stdout.trim().split('\n').pop() || '';
|
|
154
|
+
if (!text) {
|
|
155
|
+
// 解释器存在但什么都没输出(如非 Python 的解释器、启动即崩)⇒ 放行
|
|
156
|
+
skip('无输出');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const parsed = JSON.parse(text);
|
|
161
|
+
settle({
|
|
162
|
+
fatal: parsed.fatal || [], advisory: parsed.advisory || [], skipped: false,
|
|
163
|
+
});
|
|
164
|
+
} catch {
|
|
165
|
+
skip('输出不可解析');
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 依赖缺失时的面板提示页(代替 503 JSON): 缺什么 + 一条可直接复制的修复命令
|
|
173
|
+
* + 修完怎么让插件重来(停用再启用即会重跑 apply, 按 spec §8.8 不必重启 dsh web)。
|
|
174
|
+
*/
|
|
175
|
+
function renderDepHint(pythonPath, repoDir, missing) {
|
|
176
|
+
const cmd = `${pythonPath} -m pip install -r ${join(repoDir, 'requirements.txt')}`;
|
|
177
|
+
return '<!doctype html><html lang="zh"><head><meta charset="utf-8">'
|
|
178
|
+
+ '<title>Touchstone 后端未启动</title></head>'
|
|
179
|
+
+ '<body style="margin:0;padding:2rem;font:14px/1.75 ui-monospace,Menlo,Consolas,monospace;'
|
|
180
|
+
+ 'background:#14161a;color:#e6e6e6">'
|
|
181
|
+
+ '<h2 style="color:#ffb454;margin:0 0 .9rem">Touchstone 后端未启动:Python 运行依赖缺失</h2>'
|
|
182
|
+
+ `<p>缺少:<b style="color:#ff6b6b">${escapeHtml(missing.join(', '))}</b></p>`
|
|
183
|
+
+ '<p>在本机执行一次(用下面这个解释器,也就是插件启动后端用的那个):</p>'
|
|
184
|
+
+ '<pre style="background:#0d0f12;border:1px solid #2a2f36;border-radius:6px;'
|
|
185
|
+
+ `padding:.8rem 1rem;overflow:auto">${escapeHtml(cmd)}</pre>`
|
|
186
|
+
+ '<p>装完把本插件<b>停用再启用</b>(Plugins 面板里那一行的开关)即会重试,不必重启 dsh web。</p>'
|
|
187
|
+
+ '<p style="color:#8b949e">若这个解释器不对,可在 profile patch 的 touchstone 条目里配置 '
|
|
188
|
+
+ '<code>pythonPath</code> 指向含这些依赖的解释器。</p>'
|
|
189
|
+
+ '</body></html>';
|
|
190
|
+
}
|
|
56
191
|
|
|
57
192
|
/**
|
|
58
193
|
* 反代 /touchstone/* 到本机 server.py 子进程: 剥掉 /touchstone 前缀后原样转发
|
|
@@ -60,6 +195,12 @@ let driver = null;
|
|
|
60
195
|
*/
|
|
61
196
|
function proxy(req, res) {
|
|
62
197
|
if (!backendPort) {
|
|
198
|
+
// 依赖缺失导致后端没起来时给可操作的提示页, 其余未就绪窗口仍回 503 JSON
|
|
199
|
+
if (backendHint) {
|
|
200
|
+
res.writeHead(503, { 'content-type': 'text/html; charset=utf-8' });
|
|
201
|
+
res.end(backendHint);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
63
204
|
res.writeHead(503, { 'content-type': 'application/json; charset=utf-8' });
|
|
64
205
|
res.end('{"error":"touchstone 后端尚未就绪, 稍后重试"}');
|
|
65
206
|
return;
|
|
@@ -188,28 +329,46 @@ export async function apply(ctx, config = {}) {
|
|
|
188
329
|
extraEnv.TS_AGENT_DRIVER_URL = driverUrl;
|
|
189
330
|
extraEnv.TS_AGENT_DRIVER_TOKEN = myDriver.token;
|
|
190
331
|
}
|
|
191
|
-
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
332
|
+
|
|
333
|
+
// 依赖预检(2026-10-07): 缺硬依赖时**不 spawn** —— spawn 了也必然在导入期退出,
|
|
334
|
+
// 日志里只留一行 traceback。改为把「缺什么 + 一条可复制的修复命令」同时送进
|
|
335
|
+
// 日志与面板(backendHint 由 proxy 渲染), 让 npm 安装真正做到「装完就知道缺什么」。
|
|
336
|
+
const pythonPath = config.pythonPath || 'python3';
|
|
337
|
+
const deps = await preflightDeps(pythonPath, repoDir, logger);
|
|
338
|
+
let myChild = null;
|
|
339
|
+
if (deps.fatal.length) {
|
|
340
|
+
backendHint = renderDepHint(pythonPath, repoDir, deps.fatal);
|
|
341
|
+
logger.warn(`touchstone: 后端未启动 —— Python 运行依赖缺失: ${deps.fatal.join(', ')}`);
|
|
342
|
+
logger.warn(`touchstone: 修复: ${pythonPath} -m pip install -r ${join(repoDir, 'requirements.txt')}`);
|
|
343
|
+
logger.warn('touchstone: 装完把本插件停用再启用即会重试(不必重启 dsh web)');
|
|
344
|
+
} else {
|
|
345
|
+
backendHint = '';
|
|
346
|
+
if (deps.advisory.length) {
|
|
347
|
+
logger.warn(`touchstone: 可选依赖未安装(只影响对应功能, 不影响启动): ${deps.advisory.join(', ')}`);
|
|
208
348
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
349
|
+
logger.info(`touchstone: 启动后端 ${pythonPath} ${args.join(' ')}`);
|
|
350
|
+
// stdio[0] 必须是 pipe 且**永不写入**:这是父死感知的 A 通道——dsh 进程无论
|
|
351
|
+
// 优雅退出还是被 kill -9,OS 都会关闭该管道写端,Python 侧 os.read(0) 收到
|
|
352
|
+
// EOF 即自主退出(实测父 kill -9 后 1.94s 退出)。改成 'ignore' 会让该通道失效。
|
|
353
|
+
myChild = spawn(pythonPath, args, {
|
|
354
|
+
cwd: repoDir,
|
|
355
|
+
env: { ...process.env, ...extraEnv },
|
|
356
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
357
|
+
});
|
|
358
|
+
child = myChild;
|
|
359
|
+
myChild.stdout.on('data', (buf) => {
|
|
360
|
+
for (const line of String(buf).split('\n')) {
|
|
361
|
+
const m = line.match(/^TOUCHSTONE_LISTEN (\d+)/);
|
|
362
|
+
if (m) {
|
|
363
|
+
backendPort = Number(m[1]);
|
|
364
|
+
logger.info(`touchstone: 后端就绪 127.0.0.1:${backendPort}`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
myChild.stderr.on('data', (buf) => logger.warn(`touchstone[py]: ${String(buf).trim()}`));
|
|
369
|
+
myChild.on('exit', (code, sig) => logger.warn(`touchstone: 后端退出 code=${code} sig=${sig}`));
|
|
370
|
+
myChild.on('error', (err) => logger.warn(`touchstone: 后端拉起失败: ${err.message}`));
|
|
371
|
+
}
|
|
213
372
|
|
|
214
373
|
let disposeRoute = null;
|
|
215
374
|
if (!webServer) {
|
package/dshdriver.py
CHANGED
|
File without changes
|