@gavinc-cn/touchstone-dsh 0.1.1 → 0.3.0
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 +39 -11
- package/README.zh.md +25 -10
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6154 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2053 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1738 -0
- package/backend/lib/feishu_cfg.js +228 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +1674 -0
- package/backend/lib/feishu_provision.js +855 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +405 -0
- package/backend/lib/proc.js +278 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4520 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +237 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1220 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +215 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +297 -0
- package/backend/routes/projects.js +1059 -0
- package/backend/routes/sessions.js +946 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +498 -0
- package/board.py +805 -72
- package/chat.py +590 -17
- package/db.py +356 -140
- package/dsh-plugin/cordis.patch.yml +7 -3
- package/dsh-plugin/install.sh +28 -11
- package/dsh-plugin/lib/agent-driver.js +710 -89
- package/dsh-plugin/lib/client.bundle.js +94 -9
- package/dsh-plugin/lib/index.js +529 -39
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/src/client.js +94 -9
- package/dshdriver.py +74 -5
- package/dshevents.py +65 -3
- package/feishu.py +1197 -34
- package/feishu_conv.py +920 -0
- package/lifecycle.py +116 -13
- package/package.json +6 -1
- package/requirements.txt +2 -1
- package/server.py +218 -26
- package/sessparse.py +150 -44
- package/touchstone.py +6 -3
- package/waitq.py +23 -0
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
- package/webui/dist-plugin/assets/index-UrMyeAXF.js +484 -0
- package/webui/dist-plugin/index.html +2 -2
- package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
- package/webui/dist-plugin/assets/index-DtwKoi1T.js +0 -473
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* 对外契约(全部挂在 webServer 的 `${DRIVER_PREFIX}` 前缀下,仅回环可达 + 令牌校验):
|
|
15
15
|
* GET /touchstone-agent/health 就绪探测(agents 服务是否可用)
|
|
16
16
|
* GET /touchstone-agent/live 当前可见会话表(含外部直跑会话,零 REST 探测)
|
|
17
|
+
* + `complete`:宿主已有会话是否已枚举(A 批)
|
|
17
18
|
* GET /touchstone-agent/status?session_id= 单会话状态 + last_seq(SSE 续传基准)
|
|
18
19
|
* GET /touchstone-agent/events?session_id=&since= SSE 事件流(首发 ring 内 seq>since 的帧)
|
|
19
20
|
* POST /touchstone-agent/session 建会话或恢复会话 {cwd, task, session_id?, model?, provider?}
|
|
@@ -23,6 +24,7 @@
|
|
|
23
24
|
* POST /touchstone-agent/dispose 释放常驻会话(可选连会话存储一起删)
|
|
24
25
|
* GET /touchstone-agent/archived 宿主归档集整表(看板「已完成」与 dsh 会话归档同步)
|
|
25
26
|
* POST /touchstone-agent/archive {session_id, archived} 归档/取消归档(幂等)
|
|
27
|
+
* POST /touchstone-agent/watch {session_id, on?} 看管声明(外部会话注入开关,C 批)
|
|
26
28
|
*
|
|
27
29
|
* 状态帧(scope=state)新增 `driver/archived`:归档集**整表快照**(不是增量),
|
|
28
30
|
* 变化时推一帧(`domain/changed` 事件为主 + keepalive 15s 兜底比对);平台侧
|
|
@@ -57,6 +59,16 @@ const QUESTION_CALL_MAX = 64;
|
|
|
57
59
|
/** SSE keepalive 注释帧间隔(毫秒):防中间层按空闲超时切断长连 */
|
|
58
60
|
const KEEPALIVE_MS = 15000;
|
|
59
61
|
|
|
62
|
+
/**
|
|
63
|
+
* 宿主已有会话枚举的**兜底重试**间隔(毫秒;A 批 2026-10-08)。
|
|
64
|
+
*
|
|
65
|
+
* apply 瞬间宿主可能仍在恢复工作区(`sessions` 服务尚未挂上),首轮枚举会失败;
|
|
66
|
+
* 1s 后再枚举一次即可补上(枚举幂等,重复调用只做覆盖写)。真机实测:热重载后
|
|
67
|
+
* 驱动实例重建 ⇒ `observed` 全空而 `/live` 恒空,平台可见性整体丢失(见
|
|
68
|
+
* bug_report/20261008_1935),本兜底是「首轮没赶上」的第二道网。
|
|
69
|
+
*/
|
|
70
|
+
const ENUMERATE_RETRY_MS = 1000;
|
|
71
|
+
|
|
60
72
|
/**
|
|
61
73
|
* 归档集兜底扫描间隔(毫秒;纯内存读,不产生任何 HTTP/磁盘 IO)。
|
|
62
74
|
*
|
|
@@ -197,6 +209,16 @@ export class AgentDriver {
|
|
|
197
209
|
this.sessions = new Map();
|
|
198
210
|
/** 全局可见会话(含用户自己直跑的):外部会话发现通道,替代逐会话 REST 探测 */
|
|
199
211
|
this.observed = new Map();
|
|
212
|
+
/**
|
|
213
|
+
* 平台**显式声明**看管的会话(C 批,2026-10-10):`POST /watch` 写入,`/health.watched` 报数。
|
|
214
|
+
*
|
|
215
|
+
* 为什么必须由平台显式声明,而不是「宿主里凡非池内会话一律纳管」:宿主进程里
|
|
216
|
+
* 有大量与 Touchstone 无关的会话(用户自己开的、其它插件建的、子代理派生的),
|
|
217
|
+
* 按推断纳管等于给平台开一个「向任意会话注入消息与作答」的口子。只有声明过的
|
|
218
|
+
* sid 才是平台可回落投递(T2)、认领提问(T3)、接管审批(T4)的对象;平台撤销
|
|
219
|
+
* (`on:false`)或宿主会话销毁即收口,看管表不会只涨不消。
|
|
220
|
+
*/
|
|
221
|
+
this.watched = new Set();
|
|
200
222
|
/** agents 服务就绪标记(未就绪时路由返回 503,Python 侧据此降级为 CLI 形态) */
|
|
201
223
|
this.ready = false;
|
|
202
224
|
this.agentCtx = null;
|
|
@@ -216,6 +238,16 @@ export class AgentDriver {
|
|
|
216
238
|
* 原生作答者,不入本表。
|
|
217
239
|
*/
|
|
218
240
|
this.questions = new Map();
|
|
241
|
+
/**
|
|
242
|
+
* 插件**认领中**的审批(C 批 T4,2026-10-10):sid → {id, mark, resolve, reject, finish, lane}
|
|
243
|
+
* ——与 `questions` 平行的一套,承载**看管的外部会话**的审批双通道(`_holdApproval`)。
|
|
244
|
+
*
|
|
245
|
+
* 为什么不并进 `entry.pendingApproval`:池内会话(平台自持)的审批认领走的是池条目上的
|
|
246
|
+
* 既有单槽 `pendingApproval`(`/status` 的 `pending_approval`、`_approval` 的池内分支都
|
|
247
|
+
* 读它),而看管的外部会话**没有池条目**(不接管),故另立一张按 sid 索引的表。
|
|
248
|
+
* 池内路径一字未动,两套并存、互不影响;兑现或收口即从表里摘除。
|
|
249
|
+
*/
|
|
250
|
+
this.approvals = new Map();
|
|
219
251
|
/**
|
|
220
252
|
* `ask_user_question` 的 tool/call 登记:callId → {sid, at}(bounded)。
|
|
221
253
|
* 两个用途:① legacy 模式提问不带 `wait.callId`,用真实 tool callId 作平台侧
|
|
@@ -242,6 +274,15 @@ export class AgentDriver {
|
|
|
242
274
|
* 的最近一次已发布值(换行拼接的 key,便于整表比对)。null=尚未取到。
|
|
243
275
|
*/
|
|
244
276
|
this._archivedKey = null;
|
|
277
|
+
/**
|
|
278
|
+
* 「宿主已有会话已枚举」标志(A 批 2026-10-08):只有在成功读完宿主 `sessions`
|
|
279
|
+
* 服务的 store 之后才为 true,`/live` 据此声明 `complete`——Python 侧只有见到
|
|
280
|
+
* `complete:true` 才敢把**空快照**当成「宿主里确实没有会话」(否则一律按未知
|
|
281
|
+
* 处理:不写列、不推断,见 dshevents._align)。枚举失败时保持 false。
|
|
282
|
+
*/
|
|
283
|
+
this._enumerated = false;
|
|
284
|
+
/** 枚举兜底重试定时器(见 ENUMERATE_RETRY_MS) */
|
|
285
|
+
this._enumerateTimer = null;
|
|
245
286
|
}
|
|
246
287
|
|
|
247
288
|
/**
|
|
@@ -257,7 +298,11 @@ export class AgentDriver {
|
|
|
257
298
|
agentCtx.on('session/created', (session) => this._observe(session));
|
|
258
299
|
agentCtx.on('session/disposed', (session) => {
|
|
259
300
|
const sid = String(session.id);
|
|
301
|
+
// 在途认领(提问/审批)先收口:会话已销毁,waterfall 再也不可能被兑现,
|
|
302
|
+
// 留着就是「宿主 `ask()` 永远悬挂」+ 挂起标记残留(C 批 T4 的 `_dropClaims`)
|
|
303
|
+
this._dropClaims(sid);
|
|
260
304
|
this.observed.delete(sid);
|
|
305
|
+
this.watched.delete(sid); // 宿主会话已销毁:看管声明随之收口(C 批)
|
|
261
306
|
this._publishState('session/disposed', { sid });
|
|
262
307
|
});
|
|
263
308
|
// agent 状态变更(P0 实测跨插件可见):payload 形状是 **单个对象**
|
|
@@ -278,15 +323,25 @@ export class AgentDriver {
|
|
|
278
323
|
agentCtx.on('user-questions/request',
|
|
279
324
|
(request, next) => this._onQuestionRequest(request, next),
|
|
280
325
|
{ prepend: true });
|
|
281
|
-
// 审批同为 waterfall
|
|
282
|
-
//
|
|
283
|
-
|
|
326
|
+
// 审批同为 waterfall,**同样必须 prepend**(与提问同源的理由见 `_onQuestionRequest`:
|
|
327
|
+
// dsh Web 客户端问答桥先注册且认领后不再 next(),append 注册的 listener 在真机上
|
|
328
|
+
// 永远收不到)。prepend 只抢「先看」位置,**是否让位由分支决定**——池内未接管、
|
|
329
|
+
// 看管外无活 agent、(未看管的)外部会话三类一律 `next()` 让位,dsh GUI 的审批弹窗
|
|
330
|
+
// 照常;只有「池内已接管」与「看管外 + 宿主有活 agent」才认领(双通道:原生框照旧弹)
|
|
331
|
+
agentCtx.on('approval/request', (request, next) => this._onApprovalRequest(request, next),
|
|
332
|
+
{ prepend: true });
|
|
284
333
|
// 宿主 inbox(P6 #21):排队消息的增/领/弃三个事件都可见 ⇒ 平台会话窗能
|
|
285
334
|
// 展示「宿主排队行」(kimi 侧等价物是 /prompts 的 queued 段)。
|
|
286
335
|
agentCtx.on('agent/inbox/inserted', (payload) => this._onInbox('inserted', payload));
|
|
287
336
|
agentCtx.on('agent/inbox/claimed', (payload) => this._onInbox('claimed', payload));
|
|
288
337
|
agentCtx.on('agent/inbox/discarded', (payload) => this._onInbox('discarded', payload));
|
|
289
338
|
this.ready = true;
|
|
339
|
+
// 已有会话枚举(A 批 2026-10-08):`session/created` 对**已有**会话不会再发,
|
|
340
|
+
// 插件重新 apply(热重载)后必须主动补齐,否则 `/live` 恒空、平台可见性丢失。
|
|
341
|
+
this._enumerateHostSessions();
|
|
342
|
+
this._enumerateTimer = setTimeout(() => this._enumerateHostSessions(),
|
|
343
|
+
ENUMERATE_RETRY_MS);
|
|
344
|
+
if (this._enumerateTimer.unref) this._enumerateTimer.unref();
|
|
290
345
|
this.logger.info(`touchstone: agent 驱动就绪(会话池 + SSE 事件流,前缀 ${DRIVER_PREFIX})`);
|
|
291
346
|
});
|
|
292
347
|
// 业务服务延迟注入(2026-10-03 P3):缺失不阻塞插件加载,调用时按需 get;
|
|
@@ -328,6 +383,9 @@ export class AgentDriver {
|
|
|
328
383
|
this._timer = null;
|
|
329
384
|
if (this._sweepTimer) clearInterval(this._sweepTimer);
|
|
330
385
|
this._sweepTimer = null;
|
|
386
|
+
// 枚举兜底定时器:停用后不能再写 `observed`(否则清表之后又被补回,成了幽灵会话)
|
|
387
|
+
if (this._enumerateTimer) clearTimeout(this._enumerateTimer);
|
|
388
|
+
this._enumerateTimer = null;
|
|
331
389
|
for (const disposeRoute of this._routes) {
|
|
332
390
|
try {
|
|
333
391
|
disposeRoute();
|
|
@@ -363,7 +421,18 @@ export class AgentDriver {
|
|
|
363
421
|
}
|
|
364
422
|
}
|
|
365
423
|
this.questionCalls.clear();
|
|
424
|
+
// 在途**审批**认领(C 批 T4):同属「插件要走了、兑现不了」,按看管 sid 逐个走
|
|
425
|
+
// `_dropClaims` 统一收口(提问/审批 + 挂起标记一把清);上面那个全量循环已兜住
|
|
426
|
+
// 提问,故这里实际补的是 `approvals` 与仍残留的挂起标记。`watched` 已并入 sid 集合,
|
|
427
|
+
// 是因为「撤销看管后仍有在途审批」的窗口里 approvals 有值而 watched 已空。
|
|
428
|
+
// 调用点落在 `dispose()`(本驱动**没有** `stop()`:真实失活口只有这里的 dispose,
|
|
429
|
+
// 与 T1 的 `watched.clear()` 同处,brief 里写的 `stop()` 系笔误)
|
|
430
|
+
for (const sid of new Set([...this.watched, ...this.approvals.keys()])) {
|
|
431
|
+
this._dropClaims(sid);
|
|
432
|
+
}
|
|
366
433
|
this.observed.clear();
|
|
434
|
+
this.watched.clear(); // 看管声明同属易失的进程内状态,停用时一并收口
|
|
435
|
+
this._enumerated = false; // 观察表已清空:快照不再完整,重启后须重新枚举
|
|
367
436
|
this.ready = false;
|
|
368
437
|
}
|
|
369
438
|
|
|
@@ -402,6 +471,79 @@ export class AgentDriver {
|
|
|
402
471
|
return [...owned, ...external];
|
|
403
472
|
}
|
|
404
473
|
|
|
474
|
+
/**
|
|
475
|
+
* 该会话是否被平台声明为「看管」(C 批,2026-10-10)。
|
|
476
|
+
*
|
|
477
|
+
* 外部会话(用户在 dsh GUI 直跑/接管的)的投递回落、提问认领、审批接管三条通道
|
|
478
|
+
* 全部以本判据为闸:**只有平台显式 `POST /watch` 过的 sid 才为 true**。绝不按
|
|
479
|
+
* 「不在池内」「能枚举到」之类推断——宿主里与 Touchstone 无关的会话多得是,
|
|
480
|
+
* 推断就等于允许平台向任意会话注入消息与答案(见 `this.watched` 的说明)。
|
|
481
|
+
*
|
|
482
|
+
* @param {string} sid - dsh 会话 id(`session-<uuid>`)
|
|
483
|
+
* @returns {boolean} 看管中即 true;空值/未声明/已撤销/已销毁一律 false
|
|
484
|
+
*/
|
|
485
|
+
_isWatched(sid) {
|
|
486
|
+
return this.watched.has(String(sid || ''));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* 宿主**活 agent**读口(C 批,2026-10-10):`agents.get(sid)` 给出该会话在宿主进程内
|
|
491
|
+
* 的 agent 句柄;没有(会话已结束,或本进程从没起过它)返回 undefined。
|
|
492
|
+
*
|
|
493
|
+
* 与 `this.sessions` 的分界(T2 回落投递的依据):`sessions` 是**平台持有**的池
|
|
494
|
+
* (handle 由本插件建/恢复并持有),本读口只问「宿主里它还活着吗」——句柄归谁与
|
|
495
|
+
* 所有权无关(用户直跑会话的 agent 归 GUI 作用域,见 C 方案 §二 T2)。于是
|
|
496
|
+
* 「`_isWatched` 且 `_liveAgent` 有值」即可直接 followup/steer 投递,无需接管会话。
|
|
497
|
+
* 守卫 `typeof … 'function'`:极简桩 ctx 可能没有 agents 服务(与 `_fork` 同款防御)。
|
|
498
|
+
*
|
|
499
|
+
* @param {string} sid - dsh 会话 id
|
|
500
|
+
* @returns {object|undefined} 宿主活 agent(带 followup/steer/status);无则 undefined
|
|
501
|
+
*/
|
|
502
|
+
_liveAgent(sid) {
|
|
503
|
+
const agents = this.agentCtx && this.agentCtx.agents;
|
|
504
|
+
if (!agents || typeof agents.get !== 'function') return undefined;
|
|
505
|
+
return agents.get(String(sid || ''));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* 外部会话投递的**唯一闸门**(C 批 T2 重构,2026-10-10):`/prompt`、`/steer`、`/status`
|
|
510
|
+
* 三个端点的「池外回落」判定全部收在这里;未看管一律回到池内/404 老路径(见设计 §3.1
|
|
511
|
+
* 与 §3.2)。三档语义如下,调用方按返回值分流:
|
|
512
|
+
*
|
|
513
|
+
* ① 池内(`this.sessions.has(sid)`)**或**未看管:返回 `undefined` 且**不写任何响应**
|
|
514
|
+
* —— 调用方交回既有路径:`/prompt`·`/steer` 继续走 `_lookup`(唯一 404 出口,文案
|
|
515
|
+
* `会话不在驱动池中: <sid>` 一字不变),`/status` 走自己的池内 200 / 池外 404;
|
|
516
|
+
* ② 已看管但 `_liveAgent(sid)` 不存在:**自己写 404** `会话已结束(宿主无活动 agent): <sid>`
|
|
517
|
+
* 并返回 `null` —— 「声明过看管、会话却已结束」的独立分档,平台据此报因,不再误报
|
|
518
|
+
* 「不在驱动池中」;
|
|
519
|
+
* ③ 已看管且宿主有活 agent:返回该 live agent(`/prompt` 用它 `followup`、`/steer` 用它
|
|
520
|
+
* `steer`、`/status` 取它的 `status`),**不接管**会话。
|
|
521
|
+
*
|
|
522
|
+
* `res` 传 `null` = 干跑:只判定、不写响应(`/status` 用)。`/status` 对「watched 但无活
|
|
523
|
+
* agent」保留**既有** 404 原文案 `会话不在驱动池中: <sid>`(控制者裁定维持「三端点文案
|
|
524
|
+
* 不完全统一」,见进度记录「T2 顾虑不修」),故它把 ②③ 一并当「非外部」处理、合流到
|
|
525
|
+
* 自己那个 404 出口;判定逻辑仍只有这一份实现,不会再长出第三个副本。
|
|
526
|
+
*
|
|
527
|
+
* 为什么必须收敛成一份实现:T3(`/answer`)与 T4(`/approval`)还要在同一闸门上加分支,
|
|
528
|
+
* 四处副本改一漏一将是**静默**的(评审 Important 1)。
|
|
529
|
+
*
|
|
530
|
+
* @param {object|null} res - HTTP 响应对象;`null` = 干跑不写响应
|
|
531
|
+
* @param {string} sid - dsh 会话 id(调用方已 `String(...)` 归一)
|
|
532
|
+
* @returns {object|undefined|null} live agent;`undefined`=池内或未看管;`null`=已看管但无活 agent
|
|
533
|
+
*/
|
|
534
|
+
_externalTarget(res, sid) {
|
|
535
|
+
// ① 池内/未看管:不自作主张,交回调用方的既有路径(看管声明是唯一闸门)
|
|
536
|
+
if (this.sessions.has(sid) || !this._isWatched(sid)) return undefined;
|
|
537
|
+
// ② 已看管但宿主已无活 agent:会话结束/被销毁,单独一档文案
|
|
538
|
+
const live = this._liveAgent(sid);
|
|
539
|
+
if (!live) {
|
|
540
|
+
if (res) this._json(res, 404, { error: `会话已结束(宿主无活动 agent): ${sid}` });
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
// ③ 已看管且宿主有活 agent:直投目标
|
|
544
|
+
return live;
|
|
545
|
+
}
|
|
546
|
+
|
|
405
547
|
// ---------- 会话事件 ----------
|
|
406
548
|
|
|
407
549
|
/**
|
|
@@ -459,22 +601,34 @@ export class AgentDriver {
|
|
|
459
601
|
this._publishState('transcript', { sid, data: { event_seq: event.seq,
|
|
460
602
|
kind: event.type } });
|
|
461
603
|
}
|
|
462
|
-
// 审批是会话事件(log-only audit
|
|
604
|
+
// 审批是会话事件(log-only audit),与提问共同构成「等人工输入」实况。
|
|
605
|
+
// C 批 T4:看管外部会话的**认领中**审批以平台标记为准,审计帧不得覆盖/收口它——
|
|
606
|
+
// 审计 mark 既没有 `ap-<n>` 也没有 `answerable`(只是宿主自己落的审计记录),盖上
|
|
607
|
+
// 去平台侧的作答按钮就消失了。真机主顺序是审计帧在前(读宿主
|
|
608
|
+
// `dsh-user-approval/lib/index.js` 的 `request()`:先 append `approval/asked`、再跑
|
|
609
|
+
// `approval/request` waterfall),故这两个分支只在「观察者提交后回调、帧落到认领之后」
|
|
610
|
+
// 时才命中;池内/未看管会话的 `approvals` 恒空,行为一个字节不变。
|
|
463
611
|
if (event.type === 'approval/asked') {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
612
|
+
// 认领中(`approvals` 命中)只跳过「写标记 + 发状态帧」这一段,函数尾部的
|
|
613
|
+
// `observed.updatedAt` 与会话流转发照旧执行(不能用 return 提前退出)
|
|
614
|
+
if (!this.approvals.has(sid)) {
|
|
615
|
+
const mark = { kind: 'approval', id: event.data && event.data.id,
|
|
616
|
+
tool: (event.data && event.data.toolName) || '',
|
|
617
|
+
reason: (event.data && event.data.reason) || '' };
|
|
618
|
+
if (entry) entry.interaction = mark;
|
|
619
|
+
const seen = this.observed.get(sid);
|
|
620
|
+
if (seen) seen.interaction = mark;
|
|
621
|
+
this._publishState('driver/interaction', { sid, data: { state: 'asked', interaction: mark } });
|
|
622
|
+
}
|
|
471
623
|
}
|
|
472
624
|
if (event.type === 'approval/decided') {
|
|
473
|
-
if (
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
625
|
+
if (!this.approvals.has(sid)) {
|
|
626
|
+
if (entry) entry.interaction = null;
|
|
627
|
+
const seen = this.observed.get(sid);
|
|
628
|
+
if (seen) seen.interaction = null;
|
|
629
|
+
this._publishState('driver/interaction', {
|
|
630
|
+
sid, data: { state: 'decided', outcome: (event.data && event.data.outcome) || '' } });
|
|
631
|
+
}
|
|
478
632
|
}
|
|
479
633
|
// 提问的工具调用/结果(2026-10-04 修 #791):
|
|
480
634
|
// · tool/call:`ask_user_question` 的真实 callId 只在这里出现——legacy 模式下
|
|
@@ -540,6 +694,54 @@ export class AgentDriver {
|
|
|
540
694
|
sid, data: { cwd, owned: this.sessions.has(sid), origin: this._sessionOrigin(session) } });
|
|
541
695
|
}
|
|
542
696
|
|
|
697
|
+
/**
|
|
698
|
+
* 枚举**宿主已有会话**,补进观察表(A 批 2026-10-08)。
|
|
699
|
+
*
|
|
700
|
+
* 为什么必须有它:`observed` 的写入路径只有 `session/created` 事件(L257 订阅)、
|
|
701
|
+
* 平台自建会话(`_session`)、fork 接管(`_fork`),而宿主对**已有**会话不会再发
|
|
702
|
+
* `session/created`——插件重新 apply(`dispose()` + 新实例,热重载/面板行开关往返)
|
|
703
|
+
* 后两表全空,`/live` 于是恒空(真机实测 23 条 → 0 条,3 分钟不自愈),Python 侧
|
|
704
|
+
* `dshevents` 对齐拿到空表 ⇒ 对所有外部会话失去可见性(运行中的看板卡被搬去
|
|
705
|
+
* 待审核,见 bug_report/20261008_1935)。
|
|
706
|
+
*
|
|
707
|
+
* 做法:读宿主 `sessions` 服务(`SessionStore.store`,`Map(sid → entry)`,
|
|
708
|
+
* `entry.session` = 活会话)逐个走既有的 `_observe()`,**已在观察表里的 sid 跳过**
|
|
709
|
+
* ——枚举只负责「发现没见过的」,不会把事件学到的 turn 结果/挂起问答覆盖掉,故本方法
|
|
710
|
+
* 可安全重复调用(apply 时一次 + 1s 兜底一次)。`store` 是宿主内部字段、非正式契约:
|
|
711
|
+
* 形状不符或抛错时只 warn,`_enumerated` 保持 false ⇒ `/live` 报 `complete:false`,
|
|
712
|
+
* 平台按「未对齐」处理(宁可多等不可误放行),插件其余功能不受影响。
|
|
713
|
+
*
|
|
714
|
+
* @returns {boolean} 是否成功枚举(true = `/live` 可声明快照完整)
|
|
715
|
+
*/
|
|
716
|
+
_enumerateHostSessions() {
|
|
717
|
+
try {
|
|
718
|
+
const service = this.ctx.get('sessions');
|
|
719
|
+
const table = service && service.store;
|
|
720
|
+
if (!(table instanceof Map)) {
|
|
721
|
+
this.logger.warn('touchstone: 宿主 sessions.store 不可读,'
|
|
722
|
+
+ '/live 声明 complete=false(平台按未对齐处理)');
|
|
723
|
+
return false;
|
|
724
|
+
}
|
|
725
|
+
let n = 0;
|
|
726
|
+
for (const entry of table.values()) {
|
|
727
|
+
const session = entry && entry.session;
|
|
728
|
+
if (!session || !session.id) continue; // 脏 entry(无活会话):跳过
|
|
729
|
+
// 已在观察表里的(事件先到 / 平台自建已登记)不再覆盖:`_observe` 是整条覆盖写,
|
|
730
|
+
// 会把事件学到的 turn 结果、挂起问答清成 null——枚举只负责「发现没见过的」。
|
|
731
|
+
if (this.observed.has(String(session.id))) continue;
|
|
732
|
+
this._observe(session);
|
|
733
|
+
n += 1;
|
|
734
|
+
}
|
|
735
|
+
this._enumerated = true;
|
|
736
|
+
this.logger.info(`touchstone: 枚举宿主已有会话 ${n} 条(/live complete=true)`);
|
|
737
|
+
return true;
|
|
738
|
+
} catch (err) {
|
|
739
|
+
this.logger.warn(`touchstone: 枚举宿主会话失败(/live 声明 complete=false): ${
|
|
740
|
+
(err && err.message) || err}`);
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
543
745
|
/**
|
|
544
746
|
* 推一帧**状态**帧给全局订阅者(P4):入环(溢出丢最旧)+ 广播。
|
|
545
747
|
* 帧形:`{seq, time, type, session_id, data}`——`seq` 是全局单调序号,
|
|
@@ -597,7 +799,7 @@ export class AgentDriver {
|
|
|
597
799
|
* listener 均不触发,prepend 的才触发)。prepend 只抢「先看」的位置,是否让位
|
|
598
800
|
* 由下面的分支决定,故 Web GUI 对**外部会话**的行为一个字节都没变。
|
|
599
801
|
*
|
|
600
|
-
*
|
|
802
|
+
* 三条分支:
|
|
601
803
|
* 1) 平台自持会话(`/session` 建的池内会话,看板卡与任务轮都走它):**双通道**——
|
|
602
804
|
* 插件认领(平台 `/answer` 兑现,Touchstone 会话窗渲染选择框可作答)**并且**
|
|
603
805
|
* 把请求交回下游原生链路(dsh Web GUI 同样弹提问框、同样可作答),两侧先答者胜
|
|
@@ -609,23 +811,30 @@ export class AgentDriver {
|
|
|
609
811
|
* 平台的 answerable/作答通道全程空转(#791:Touchstone 会话窗不出现选择框)。
|
|
610
812
|
* · 为什么要让位(2026-10-06 修 #837):纯认领时该 waterfall 到插件为止,dsh Web
|
|
611
813
|
* GUI 收不到提问 —— 提问框只在 Touchstone 侧出现。预期是**两侧都显示**。
|
|
612
|
-
* 2)
|
|
613
|
-
*
|
|
814
|
+
* 2) **池外但平台已看管**的外部会话(C 批 T3,2026-10-10;用户在 dsh GUI 直跑、平台
|
|
815
|
+
* 只 `POST /watch` 声明看管的):与 ①**同一套双通道**——认领(挂起标记带 call_id
|
|
816
|
+
* ⇒ 平台 answerable=true、`/answer` 兑现 waterfall)**并且**原生链路照旧打开
|
|
817
|
+
* (dsh GUI 照旧弹框),两侧先答者胜;**不接管**该会话(不建池条目)。这正是 T2
|
|
818
|
+
* 投递回落(`/prompt`·`/steer`)在作答侧的对称面:看得见就要答得上。
|
|
819
|
+
* 3) 其它会话(用户自己直跑且平台**未**看管的):只旁听(发状态帧,平台据此把卡置
|
|
820
|
+
* 阻塞、把 ext 行按挂起处置),`next()` 让原生作答者(dsh Web GUI)照旧作答;
|
|
821
|
+
* `_isWatched` 是唯一闸门,未看管会话的行为与 C 批之前一个字节不差。
|
|
614
822
|
*/
|
|
615
823
|
_onQuestionRequest(request, next) {
|
|
616
824
|
const agent = request && request.agent;
|
|
617
825
|
const sid = agent ? String(agent.id) : '';
|
|
618
826
|
const waitCallId = String((request && request.wait && request.wait.callId) || '');
|
|
619
827
|
const entry = sid ? this.sessions.get(sid) : undefined;
|
|
620
|
-
|
|
621
|
-
|
|
828
|
+
// ① 池内 / ② 池外+看管:同一套认领(`_isWatched` 只在这里开口,未看管照旧走 ③)
|
|
829
|
+
if (entry !== undefined || (sid && this._isWatched(sid))) {
|
|
830
|
+
// 双通道。标识优先取宿主给的 wait.callId(timed 模式),
|
|
622
831
|
// 否则取 `ask_user_question` 的真实 tool callId(legacy 模式),再否则自造
|
|
623
832
|
const callId = waitCallId || this._takeQuestionCallId(sid) || this._nextQuestionId();
|
|
624
833
|
// 先留原宿主取消信号(轮次中止/停卡):打开原生通道会替换请求对象上的
|
|
625
834
|
// `signal`(见 `_openNativeLane`),平台侧的收口仍要认**原信号**
|
|
626
835
|
const hostSignal = request && request.signal;
|
|
627
836
|
const lane = this._openNativeLane(request, next);
|
|
628
|
-
return this._holdQuestion(
|
|
837
|
+
return this._holdQuestion(sid, callId, request, hostSignal, lane);
|
|
629
838
|
}
|
|
630
839
|
if (!sid) return next();
|
|
631
840
|
this._publishQuestionMark(sid, waitCallId, request);
|
|
@@ -681,7 +890,8 @@ export class AgentDriver {
|
|
|
681
890
|
}
|
|
682
891
|
|
|
683
892
|
/**
|
|
684
|
-
*
|
|
893
|
+
* 认领一个会话的提问(池内自持会话、看管的外部会话共用,C 批 T3 起形参由池条目
|
|
894
|
+
* 改为 `sid`):登记进 `this.questions`、发挂起状态帧,返回的 Promise
|
|
685
895
|
* 由**两个作答通道先到者**兑现——① 平台 `/answer`;② 原生链路(dsh GUI 提问框,
|
|
686
896
|
* `_openNativeLane`)。返回值直接交回宿主 `ask_user_question`。
|
|
687
897
|
*
|
|
@@ -697,8 +907,7 @@ export class AgentDriver {
|
|
|
697
907
|
* 中止(用户停卡/cancel):宿主 abort 信号触发 → reject 普通 Error,宿主 `ask()`
|
|
698
908
|
* 见「信号已中止」会归一为 ASK_ABORTED(tool result 记中断,与旧行为一致)。
|
|
699
909
|
*/
|
|
700
|
-
_holdQuestion(
|
|
701
|
-
const sid = entry.sessionId;
|
|
910
|
+
_holdQuestion(sid, callId, request, hostSignal, lane) {
|
|
702
911
|
return new Promise((resolve, reject) => {
|
|
703
912
|
const pending = { sid, callId, mark: null, done: false, finish: null, resolve, reject,
|
|
704
913
|
lane: lane || null,
|
|
@@ -835,7 +1044,11 @@ export class AgentDriver {
|
|
|
835
1044
|
return mark;
|
|
836
1045
|
}
|
|
837
1046
|
|
|
838
|
-
/**
|
|
1047
|
+
/**
|
|
1048
|
+
* 清挂起标记(作答送达/提问收口/审批收口):状态流发一帧非 asked 态即清。
|
|
1049
|
+
* 提问与审批**共用**同一个 interaction 槽(`entry.interaction` / `observed.interaction`),
|
|
1050
|
+
* 故两者走同一个清口(审批的认领收口见 `_holdApproval` / `_dropClaims`)。
|
|
1051
|
+
*/
|
|
839
1052
|
_clearQuestionMark(sid) {
|
|
840
1053
|
if (!sid) return;
|
|
841
1054
|
const entry = this.sessions.get(sid);
|
|
@@ -1000,7 +1213,12 @@ export class AgentDriver {
|
|
|
1000
1213
|
const path = url.pathname.slice(DRIVER_PREFIX.length) || '/';
|
|
1001
1214
|
try {
|
|
1002
1215
|
if (path === '/health' && req.method === 'GET') return this._json(res, 200, this._health());
|
|
1003
|
-
if (path === '/live' && req.method === 'GET')
|
|
1216
|
+
if (path === '/live' && req.method === 'GET') {
|
|
1217
|
+
// `complete` = 宿主已有会话是否已枚举(A 批):只有它为 true,Python 侧才敢把
|
|
1218
|
+
// 「空表」当成「宿主里确实没有会话」;否则空表一律按未知处理(不写列、不推断)。
|
|
1219
|
+
return this._json(res, 200, { sessions: this.live(),
|
|
1220
|
+
complete: this._enumerated === true });
|
|
1221
|
+
}
|
|
1004
1222
|
if (path === '/status' && req.method === 'GET') return this._status(res, url);
|
|
1005
1223
|
// `?scope=state` = 全局状态流(P4,Python EventHub 唯一连接);缺省 = 单会话事件流
|
|
1006
1224
|
if (path === '/events' && req.method === 'GET') {
|
|
@@ -1027,6 +1245,8 @@ export class AgentDriver {
|
|
|
1027
1245
|
// 会话归档(看板「已完成」双向同步,2026-10-05)
|
|
1028
1246
|
if (path === '/archived' && req.method === 'GET') return this._archived(res);
|
|
1029
1247
|
if (path === '/archive' && req.method === 'POST') return await this._archive(res, req);
|
|
1248
|
+
// 看管声明(C 批,2026-10-10):外部会话注入开关——T2 投递回落/T3 提问认领/T4 审批的门
|
|
1249
|
+
if (path === '/watch' && req.method === 'POST') return await this._watch(res, req);
|
|
1030
1250
|
this._json(res, 404, { error: `未知驱动端点 ${path}` });
|
|
1031
1251
|
} catch (err) {
|
|
1032
1252
|
this.logger.warn(`touchstone: 驱动 ${path} 失败: ${err && err.message}`);
|
|
@@ -1042,14 +1262,63 @@ export class AgentDriver {
|
|
|
1042
1262
|
prefix: DRIVER_PREFIX,
|
|
1043
1263
|
live: this.sessions.size,
|
|
1044
1264
|
observed: this.observed.size,
|
|
1265
|
+
watched: this.watched.size, // 平台声明的看管会话数(C 批;T2~T4 的作用域)
|
|
1266
|
+
// 宿主已有会话是否枚举成功(A 批):热重载后可见性恢复与否,看这一位
|
|
1267
|
+
enumerated: this._enumerated === true,
|
|
1045
1268
|
};
|
|
1046
1269
|
}
|
|
1047
1270
|
|
|
1048
|
-
/**
|
|
1271
|
+
/**
|
|
1272
|
+
* `POST /watch` {session_id, on?}:声明/撤销「平台看管该会话」(C 批,2026-10-10)。
|
|
1273
|
+
*
|
|
1274
|
+
* 语义:`on !== false`(缺省 true)= 纳入看管,`on:false` = 撤销;两者都幂等
|
|
1275
|
+
* (重复声明不报错,回执以 `watched` 给出**当前实际状态**,平台据此核对)。
|
|
1276
|
+
* 只写内存表、不碰宿主会话,故对未知 sid 也返回 200——`/watch` 是「声明意图」,
|
|
1277
|
+
* 不是「建立会话」;sid 是否真实存在由 T2 投递时的 404 兜底暴露。
|
|
1278
|
+
* 状态码约定:200=已记录(含幂等重复、含未知 sid);400=session_id 为空。
|
|
1279
|
+
*/
|
|
1280
|
+
async _watch(res, req) {
|
|
1281
|
+
const body = await this._body(req);
|
|
1282
|
+
const sid = String(body.session_id || '');
|
|
1283
|
+
if (!sid) {
|
|
1284
|
+
this._json(res, 400, { error: 'session_id 不能为空' });
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
const on = body.on !== false;
|
|
1288
|
+
if (on) this.watched.add(sid); else this.watched.delete(sid);
|
|
1289
|
+
this._json(res, 200, { ok: true, session_id: sid, watched: this.watched.has(sid) });
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* 单会话状态:含 last_seq(Python 订阅 SSE 时的续传基准)。
|
|
1294
|
+
*
|
|
1295
|
+
* C 批(T2,2026-10-10)加**并列**回落:池外但平台看管(`/watch`)且宿主仍有活 agent
|
|
1296
|
+
* 的会话,回最小实时态 `{session_id, status, external, interaction}`。**不含 `last_seq`**:
|
|
1297
|
+
* 外部会话的会话事件 seq 不进本池(`_onSessionEvent` 对非池内会话早退),平台侧基线
|
|
1298
|
+
* 口径由 `dshevents` 统一处理(设计 §4.3)。看管过但宿主已无活 agent 的会话仍走原文案
|
|
1299
|
+
* 404(控制者裁定:只规定「watched + 活 agent」这一档的回落形状,不动 /status 的既有
|
|
1300
|
+
* 404 文案),未看管会话行为一字不变。
|
|
1301
|
+
*
|
|
1302
|
+
* 重构(评审 Important 1):池外判定改调 `_externalTarget`(与 `/prompt`·`/steer` 共用
|
|
1303
|
+
* 的**唯一**闸门)。此处传 `res=null` 干跑——闸门只判定不写响应,于是「watched 但无活
|
|
1304
|
+
* agent」与本端点的既有 404 原文案自然合流到下面同一个出口,判定逻辑不再有第三份副本。
|
|
1305
|
+
*/
|
|
1049
1306
|
_status(res, url) {
|
|
1050
1307
|
const sid = url.searchParams.get('session_id') || '';
|
|
1051
1308
|
const entry = this.sessions.get(sid);
|
|
1052
1309
|
if (!entry) {
|
|
1310
|
+
// 池外:共用闸门(干跑,不写响应)判定「已看管 + 宿主有活 agent」这一档
|
|
1311
|
+
const live = this._externalTarget(null, sid);
|
|
1312
|
+
if (live) {
|
|
1313
|
+
this._json(res, 200, {
|
|
1314
|
+
session_id: sid,
|
|
1315
|
+
status: live.status,
|
|
1316
|
+
external: true,
|
|
1317
|
+
// 挂起实况取旁听表(`_observe` 写入):未旁听到即 null(平台既有口径,不推断)
|
|
1318
|
+
interaction: (this.observed.get(sid) || {}).interaction || null,
|
|
1319
|
+
});
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1053
1322
|
this._json(res, 404, { error: `会话不在驱动池中: ${sid}` });
|
|
1054
1323
|
return;
|
|
1055
1324
|
}
|
|
@@ -1254,12 +1523,35 @@ export class AgentDriver {
|
|
|
1254
1523
|
}
|
|
1255
1524
|
}
|
|
1256
1525
|
|
|
1257
|
-
/**
|
|
1526
|
+
/**
|
|
1527
|
+
* 投递一轮提示词:平台的「一轮」= 一次 followup + 等 turn/end(Python 侧等 SSE 帧)。
|
|
1528
|
+
*
|
|
1529
|
+
* C 批(T2,2026-10-10)加**并列**回落分支:池外但平台看管(`/watch`)且宿主仍有活
|
|
1530
|
+
* agent 的会话(用户在 dsh GUI 直跑的),直接对宿主 agent `followup` 投递、**不接管**,
|
|
1531
|
+
* 回执带 `external:true`。判定顺序(设计 §3.2):池内原路 → watched + 活 agent 回落
|
|
1532
|
+
* → 404 分档(已看管无活 agent / 未看管)。未看管会话的 404 文案与行为一字不变——
|
|
1533
|
+
* 仍走 `_lookup` 这个既有唯一出口。
|
|
1534
|
+
*
|
|
1535
|
+
* 重构(评审 Important 1):池外三档判定收敛到 `_externalTarget`(唯一闸门),本端点只留
|
|
1536
|
+
* 自己独有的三行——空 prompt→400、`live.followup` 投递、200 `external:true` 回执。
|
|
1537
|
+
*/
|
|
1258
1538
|
async _prompt(res, req) {
|
|
1259
1539
|
const body = await this._body(req);
|
|
1260
|
-
const
|
|
1261
|
-
if (!entry) return;
|
|
1540
|
+
const sid = String(body.session_id || '');
|
|
1262
1541
|
const text = String(body.prompt == null ? '' : body.prompt);
|
|
1542
|
+
const live = this._externalTarget(res, sid);
|
|
1543
|
+
if (live === null) return; // ② 已看管但宿主无活 agent:闸门已回 404 分档文案
|
|
1544
|
+
if (live !== undefined) { // ③ 已看管且宿主有活 agent:直投,不接管
|
|
1545
|
+
if (!text) {
|
|
1546
|
+
this._json(res, 400, { error: 'prompt 不能为空' });
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
live.followup(this._userMessage(text));
|
|
1550
|
+
this._json(res, 200, { ok: true, session_id: sid, external: true });
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
const entry = this._lookup(res, body); // ① 池内/未看管:既有路径
|
|
1554
|
+
if (!entry) return;
|
|
1263
1555
|
if (!text) {
|
|
1264
1556
|
this._json(res, 400, { error: 'prompt 不能为空' });
|
|
1265
1557
|
return;
|
|
@@ -1271,19 +1563,31 @@ export class AgentDriver {
|
|
|
1271
1563
|
/**
|
|
1272
1564
|
* 回答挂起的提问(2026-10-04 修 #791 起分两路;2026-10-06 修 #837 起 dsh GUI 也可答):
|
|
1273
1565
|
*
|
|
1274
|
-
* ①
|
|
1275
|
-
*
|
|
1276
|
-
*
|
|
1566
|
+
* ① 插件**认领中**的提问(平台自持会话、以及 C 批 T3 起**看管的外部会话**的在途提问,
|
|
1567
|
+
* `this.questions` 命中):兑现认领 Promise——返回值由 waterfall 直接交回宿主
|
|
1568
|
+
* `ask_user_question`。这是平台答**在途**提问的通道(见 `_onQuestionRequest` 注释)。
|
|
1277
1569
|
* ② 认领表未命中:退回宿主 `ctx.userQuestions.answer(agent, callId, …)`——
|
|
1278
1570
|
* 只对「continued」(前台等待超时后)的提问有效,覆盖 timed 模式超时后
|
|
1279
1571
|
* 平台迟到作答的场景;accepted=false 表示问题已不存在/已应答(含**用户在
|
|
1280
1572
|
* dsh GUI 先答**的情形:#837 起两侧同题,先答者胜),平台侧按
|
|
1281
1573
|
* 40405「问题已不存在」放弃重试,不空转。
|
|
1574
|
+
*
|
|
1575
|
+
* 池外分支(C 批 T3):池内/未看管仍走 `_lookup` 这个既有唯一出口(404 文案一字不变);
|
|
1576
|
+
* **看管的外部会话**放行——认领表命中即兑现,否则对宿主活 agent(`_liveAgent`)走 ②;
|
|
1577
|
+
* 「看管但宿主已无活 agent」与未看管同走 404 原文案。这里**不套用** `_externalTarget`:
|
|
1578
|
+
* 该闸门对「看管但无活 agent」自写的是 `/prompt`·`/steer` 的分档文案,而本端点要维持
|
|
1579
|
+
* 既有单串;且认领表命中时与「宿主是否还有活 agent」无关(在途提问必须能兑现)。
|
|
1580
|
+
* 判定必须发生在写响应**之前**,不能先 `_lookup` 写了 404 再补第二个响应。
|
|
1282
1581
|
*/
|
|
1283
1582
|
async _answer(res, req) {
|
|
1284
1583
|
const body = await this._body(req);
|
|
1285
|
-
const
|
|
1286
|
-
|
|
1584
|
+
const sid = String(body.session_id || '');
|
|
1585
|
+
// 池内 / 未看管的池外:既有路径一字不动(`_lookup` 是唯一 404 出口)
|
|
1586
|
+
const entry = this.sessions.get(sid);
|
|
1587
|
+
if (entry === undefined && !(sid && this._isWatched(sid))) {
|
|
1588
|
+
this._lookup(res, body);
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1287
1591
|
const callId = String(body.call_id || '');
|
|
1288
1592
|
if (!callId) {
|
|
1289
1593
|
this._json(res, 400, { error: 'call_id 不能为空' });
|
|
@@ -1292,14 +1596,26 @@ export class AgentDriver {
|
|
|
1292
1596
|
const answers = Array.isArray(body.answers) ? body.answers : [];
|
|
1293
1597
|
const held = this.questions.get(callId);
|
|
1294
1598
|
if (held !== undefined) {
|
|
1295
|
-
if (held.sid !==
|
|
1599
|
+
if (held.sid !== sid) {
|
|
1296
1600
|
this._json(res, 409, { error: '提问不属于该会话' });
|
|
1297
1601
|
return;
|
|
1298
1602
|
}
|
|
1299
1603
|
this._settleQuestion(callId, { answers });
|
|
1300
|
-
this._json(res, 200, { ok: true, accepted: true, session_id:
|
|
1604
|
+
this._json(res, 200, { ok: true, accepted: true, session_id: sid });
|
|
1301
1605
|
return;
|
|
1302
1606
|
}
|
|
1607
|
+
// 认领表未命中:池内用池条目上的 agent(含接管条目的 agent=null,原样交给宿主);
|
|
1608
|
+
// 看管的外部会话用宿主活 agent,无活 agent 即与未看管同走 404 原文案。
|
|
1609
|
+
let agent;
|
|
1610
|
+
if (entry !== undefined) {
|
|
1611
|
+
agent = entry.agent;
|
|
1612
|
+
} else {
|
|
1613
|
+
agent = this._liveAgent(sid);
|
|
1614
|
+
if (!agent) {
|
|
1615
|
+
this._lookup(res, body);
|
|
1616
|
+
return;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1303
1619
|
const svc = this.agentCtx ? this.agentCtx.get('userQuestions') : null;
|
|
1304
1620
|
if (!svc || typeof svc.answer !== 'function') {
|
|
1305
1621
|
this._json(res, 503, { error: 'userQuestions 服务不可用' });
|
|
@@ -1307,20 +1623,41 @@ export class AgentDriver {
|
|
|
1307
1623
|
}
|
|
1308
1624
|
let accepted = false;
|
|
1309
1625
|
try {
|
|
1310
|
-
accepted = svc.answer(
|
|
1626
|
+
accepted = svc.answer(agent, callId, { answers });
|
|
1311
1627
|
} catch (err) {
|
|
1312
1628
|
this._json(res, 409, { error: `作答被拒: ${(err && err.message) || err}` });
|
|
1313
1629
|
return;
|
|
1314
1630
|
}
|
|
1315
|
-
if (accepted) this._clearQuestionMark(
|
|
1316
|
-
this._json(res, 200, { ok: true, accepted, session_id:
|
|
1631
|
+
if (accepted) this._clearQuestionMark(sid); // 受理即清挂起标记
|
|
1632
|
+
this._json(res, 200, { ok: true, accepted, session_id: sid });
|
|
1317
1633
|
}
|
|
1318
1634
|
|
|
1319
|
-
/**
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1635
|
+
/**
|
|
1636
|
+
* 插话:运行中 steer 到最近 step 边界(空闲则直接起一个 turn)。
|
|
1637
|
+
*
|
|
1638
|
+
* C 批(T2)回落与 `/prompt` 同款(设计 §3.2):池外 + 看管 + 宿主有活 agent ⇒ 对宿主
|
|
1639
|
+
* agent `steer` 直投并回 `external:true`;未看管会话行为一字不变。
|
|
1640
|
+
*
|
|
1641
|
+
* 重构(评审 Important 1):池外三档判定收敛到 `_externalTarget`(唯一闸门),本端点只留
|
|
1642
|
+
* 自己独有的三行——空 prompt→400、`live.steer` 插话、200 `external:true` 回执。
|
|
1643
|
+
*/
|
|
1644
|
+
async _steer(res, req) {
|
|
1645
|
+
const body = await this._body(req);
|
|
1646
|
+
const sid = String(body.session_id || '');
|
|
1323
1647
|
const text = String(body.prompt == null ? '' : body.prompt);
|
|
1648
|
+
const live = this._externalTarget(res, sid);
|
|
1649
|
+
if (live === null) return; // ② 已看管但宿主无活 agent:闸门已回 404 分档文案
|
|
1650
|
+
if (live !== undefined) { // ③ 已看管且宿主有活 agent:直投,不接管
|
|
1651
|
+
if (!text) {
|
|
1652
|
+
this._json(res, 400, { error: 'prompt 不能为空' });
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
live.steer(this._userMessage(text));
|
|
1656
|
+
this._json(res, 200, { ok: true, session_id: sid, external: true });
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
const entry = this._lookup(res, body); // ① 池内/未看管:既有路径
|
|
1660
|
+
if (!entry) return;
|
|
1324
1661
|
if (!text) {
|
|
1325
1662
|
this._json(res, 400, { error: 'prompt 不能为空' });
|
|
1326
1663
|
return;
|
|
@@ -1369,11 +1706,21 @@ export class AgentDriver {
|
|
|
1369
1706
|
/**
|
|
1370
1707
|
* `approval/request` waterfall:返回 outcome 即「认领」,调用 next() 则让位。
|
|
1371
1708
|
*
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
1374
|
-
*
|
|
1375
|
-
*
|
|
1376
|
-
*
|
|
1709
|
+
* 注册选项是 `{prepend: true}`(C 批 T4):与提问同源,dsh Web 客户端问答桥先注册且
|
|
1710
|
+
* 认领后不再 next(),append 注册的 listener 在真机上永远收不到。prepend **只抢「先看」
|
|
1711
|
+
* 位置**,是否让位由下面的分支决定——池内未接管 / 看管外无活 agent / 未看管三类一律
|
|
1712
|
+
* `next()`,故 C 批之前的外部会话与未看管会话行为一个字节都没变。三条分支:
|
|
1713
|
+
*
|
|
1714
|
+
* 1) 池内(平台自持会话)且平台经 `/permission` 接管过(`holdApprovals`):**认领**,
|
|
1715
|
+
* 把请求挂起等 `/approval` 送达 `allowed-once|rejected|cancelled`;请求 signal abort
|
|
1716
|
+
* (撤回/取消)时落 `'cancelled'` 收口,不悬挂。**纯让位**(未接管/已有在途认领)
|
|
1717
|
+
* 时走 `next()`,GUI 的审批弹窗照常作答。
|
|
1718
|
+
* 2) **池外但平台已看管**(C 批 T4:用户 dsh GUI 直跑、平台只 `POST /watch` 声明看管的)
|
|
1719
|
+
* 且宿主仍有活 agent:与提问侧同款**双通道**——认领(挂起标记 `answerable:true` 带
|
|
1720
|
+
* `ap-<n>` id,平台 `/approval` 兑现 waterfall)**并且**原生链路照旧打开(dsh GUI 的
|
|
1721
|
+
* 审批框照常弹、照常可答),两侧先答者胜、另一侧收口(竞速口径见 `_holdApproval`)。
|
|
1722
|
+
* **不接管**该会话(不建池条目)。「看管但宿主已无活 agent」落回 `next()` 让位。
|
|
1723
|
+
* 3) 其它(用户直跑且**未**看管):原样 `next()` 让位,与 C 批之前逐字一致。
|
|
1377
1724
|
*
|
|
1378
1725
|
* 注意 `ApprovalRequestEvent` **没有 id**(id 由服务内部生成、只出现在
|
|
1379
1726
|
* `approval/asked|decided` 审计事件里),故这里自生成 `ap-<n>` 作为平台侧
|
|
@@ -1384,43 +1731,251 @@ export class AgentDriver {
|
|
|
1384
1731
|
const agent = request && request.agent;
|
|
1385
1732
|
const sid = String((agent && agent.session && agent.session.id) || '');
|
|
1386
1733
|
const entry = this.sessions.get(sid);
|
|
1387
|
-
|
|
1734
|
+
// ① 池内:既有认领逻辑逐字不动(未接管 / 已有在途认领 ⇒ 让位)
|
|
1735
|
+
if (entry !== undefined) {
|
|
1736
|
+
if (!entry.holdApprovals || entry.pendingApproval) return next();
|
|
1737
|
+
const id = `ap-${++this._approvalSeq}`;
|
|
1738
|
+
const mark = { kind: 'approval', id,
|
|
1739
|
+
tool: (request && request.toolName) || '',
|
|
1740
|
+
call_id: String((request && request.callId) || ''),
|
|
1741
|
+
reason: (request && request.reason) || '',
|
|
1742
|
+
answerable: true };
|
|
1743
|
+
entry.interaction = mark;
|
|
1744
|
+
entry.publish({ sid, seq: null, time: Date.now(), type: 'driver/interaction',
|
|
1745
|
+
data: { state: 'asked', interaction: mark } });
|
|
1746
|
+
this._publishState('driver/interaction', { sid, data: { state: 'asked', interaction: mark } });
|
|
1747
|
+
return new Promise((resolve) => {
|
|
1748
|
+
const settle = (outcome) => {
|
|
1749
|
+
if (!entry.pendingApproval || entry.pendingApproval.id !== id) return;
|
|
1750
|
+
entry.pendingApproval = null;
|
|
1751
|
+
entry.interaction = null;
|
|
1752
|
+
resolve(outcome);
|
|
1753
|
+
};
|
|
1754
|
+
entry.pendingApproval = { id, settle };
|
|
1755
|
+
const signal = request && request.signal;
|
|
1756
|
+
if (signal) {
|
|
1757
|
+
if (signal.aborted) return settle('cancelled');
|
|
1758
|
+
try {
|
|
1759
|
+
signal.addEventListener('abort', () => settle('cancelled'), { once: true });
|
|
1760
|
+
} catch { /* 无 addEventListener 的实现:忽略,靠 /approval 收口 */ }
|
|
1761
|
+
}
|
|
1762
|
+
return undefined;
|
|
1763
|
+
});
|
|
1764
|
+
}
|
|
1765
|
+
// ② 池外 + 看管 + 宿主有活 agent:双通道(认领 + 原生框照旧弹),不接管会话
|
|
1766
|
+
if (sid && this._isWatched(sid) && this._liveAgent(sid)) {
|
|
1767
|
+
// 同会话已有在途认领:让位(与池内 `entry.pendingApproval` 同款)。若在这里覆盖
|
|
1768
|
+
// `approvals` 槽,前一条认领的 Promise 就再无兑现可能(正是 `_dropClaims` 要消灭的
|
|
1769
|
+
// 悬挂态);让位后它仍可经 dsh GUI 作答,不丢审批。
|
|
1770
|
+
if (this.approvals.has(sid)) return next();
|
|
1771
|
+
// 先留原宿主取消信号(轮次中止/会话销毁):打开原生通道会替换请求对象上的
|
|
1772
|
+
// `signal`(见 `_openNativeLane`),平台侧的收口仍要认**原信号**
|
|
1773
|
+
const hostSignal = request && request.signal;
|
|
1774
|
+
const lane = this._openNativeLane(request, next);
|
|
1775
|
+
return this._holdApproval(sid, request, hostSignal, lane);
|
|
1776
|
+
}
|
|
1777
|
+
// ③ 未看管 / 看管但宿主无活 agent:原样让位(GUI 照常可答)
|
|
1778
|
+
return next();
|
|
1779
|
+
} catch (err) {
|
|
1780
|
+
this.logger.warn(`touchstone: approval/request 处理异常: ${(err && err.message) || err}`);
|
|
1781
|
+
return next();
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* 认领一个**看管外部会话**的审批(C 批 T4;池内会话仍走 `entry.pendingApproval` 既有
|
|
1787
|
+
* 单槽,本方法只管池外):登记进 `this.approvals`、发挂起标记,返回的 Promise 由
|
|
1788
|
+
* **两个作答通道先到者**兑现——① 平台 `/approval`(`_settleApproval`);
|
|
1789
|
+
* ② 原生链路(dsh GUI 审批框,`_openNativeLane`)。返回值直接交回宿主 `approval/request`
|
|
1790
|
+
* waterfall(outcome 字符串)。
|
|
1791
|
+
*
|
|
1792
|
+
* 与 `_holdQuestion` 同构(两侧都显示、都可答,先答者胜):
|
|
1793
|
+
* · 原生链路**成功**即胜出:清平台挂起标记(Touchstone 侧的审批框随之收起),平台侧
|
|
1794
|
+
* 迟到的 `/approval` 落到「认领表已空」⇒ 409 收口,不重复兑现。
|
|
1795
|
+
* · 原生链路**失败**不算数(GUI 关掉框、客户端异常、无客户端的 profile…):平台通道
|
|
1796
|
+
* 继续等 `/approval`。真正的失败只由宿主取消信号(轮次 abort / 会话销毁)给出。
|
|
1797
|
+
* · 平台先答:`_settleApproval` 主动 abort 原生通道的取消信号 → dsh GUI 的审批框自行
|
|
1798
|
+
* 收起(见 `_openNativeLane`),不留「答了也没人收」的死框。
|
|
1799
|
+
*
|
|
1800
|
+
* 中止(停卡/cancel,宿主 signal abort):清标记 + 收起 GUI 框 + reject,宿主按
|
|
1801
|
+
* 「审批请求已中止」归一,不悬挂(与 `_holdQuestion` 的中止口径一致)。
|
|
1802
|
+
*
|
|
1803
|
+
* 单槽约束:`approvals` 按 sid 索引(与池内 `entry.pendingApproval` 同款),同一会话
|
|
1804
|
+
* 同时只有一条在途认领;若宿主并发抛来第二条,`_onApprovalRequest` 让位给它走原生通道,
|
|
1805
|
+
* 不覆盖本槽(否则前一条的 Promise 永远兑现不了)。
|
|
1806
|
+
*
|
|
1807
|
+
* @param {string} sid - 看管中的外部会话 id(**没有**池条目,故标记只能走 observed)
|
|
1808
|
+
* @param {object} request - 宿主 `approval/request` 请求对象(toolName/callId/reason)
|
|
1809
|
+
* @param {AbortSignal|undefined} hostSignal - 原宿主取消信号(替换前的)
|
|
1810
|
+
* @param {{promise: Promise<any>, cancel: Function}} lane - 原生作答通道
|
|
1811
|
+
* @returns {Promise<string>} 平台的 outcome(`allowed-once|rejected|cancelled`)
|
|
1812
|
+
*/
|
|
1813
|
+
_holdApproval(sid, request, hostSignal, lane) {
|
|
1814
|
+
return new Promise((resolve, reject) => {
|
|
1388
1815
|
const id = `ap-${++this._approvalSeq}`;
|
|
1389
|
-
const
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1816
|
+
const pending = { sid, id, mark: null, done: false, finish: null, resolve, reject,
|
|
1817
|
+
lane: lane || null,
|
|
1818
|
+
cancelLane: () => { if (lane) lane.cancel(new Error('the platform answered the approval first')); } };
|
|
1819
|
+
this.approvals.set(sid, pending);
|
|
1820
|
+
/** 收口(幂等):摘表 + 解绑中止监听 + 清挂起标记 */
|
|
1821
|
+
pending.finish = () => {
|
|
1822
|
+
if (pending.done) return;
|
|
1823
|
+
pending.done = true;
|
|
1824
|
+
if (this.approvals.get(sid) === pending) this.approvals.delete(sid);
|
|
1825
|
+
if (pending.onAbort && hostSignal
|
|
1826
|
+
&& typeof hostSignal.removeEventListener === 'function') {
|
|
1827
|
+
hostSignal.removeEventListener('abort', pending.onAbort);
|
|
1828
|
+
}
|
|
1829
|
+
// 挂起标记与提问共用同一个 interaction 槽(`_clearQuestionMark` 即该槽的收口口)
|
|
1830
|
+
this._clearQuestionMark(sid);
|
|
1831
|
+
};
|
|
1832
|
+
// 原生通道(dsh GUI):成功即胜出并清平台挂起标记;失败一律忽略(平台通道仍在)
|
|
1833
|
+
if (lane) {
|
|
1834
|
+
lane.promise.then((value) => {
|
|
1835
|
+
if (pending.done) return;
|
|
1836
|
+
pending.finish();
|
|
1837
|
+
resolve(value);
|
|
1838
|
+
}, () => { /* 见 docstring:旁路故障不判死审批 */ });
|
|
1839
|
+
}
|
|
1840
|
+
if (hostSignal) {
|
|
1841
|
+
if (hostSignal.aborted) {
|
|
1842
|
+
pending.finish();
|
|
1843
|
+
pending.cancelLane();
|
|
1844
|
+
reject(new Error('approval request aborted before the platform answered'));
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
pending.onAbort = () => {
|
|
1848
|
+
pending.finish();
|
|
1849
|
+
pending.cancelLane();
|
|
1850
|
+
reject(new Error('approval request aborted before the platform answered'));
|
|
1851
|
+
};
|
|
1852
|
+
hostSignal.addEventListener('abort', pending.onAbort);
|
|
1853
|
+
}
|
|
1854
|
+
pending.mark = this._publishApprovalMark(sid, id, request);
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/**
|
|
1859
|
+
* 兑现认领中的**外部会话**审批(平台 `/approval` 送达):true=命中并已交付宿主。
|
|
1860
|
+
* 平台先答时顺带 abort 原生通道的取消信号——dsh GUI 的审批框随之收起。
|
|
1861
|
+
*
|
|
1862
|
+
* `approval_id` 空串按池内分支同款语义处理(不指定 = 兑现当前在途那条);
|
|
1863
|
+
* 显式给了 id 但不符 ⇒ false(不误兑现别的审批)。
|
|
1864
|
+
*
|
|
1865
|
+
* @param {string} sid - 看管中的外部会话 id
|
|
1866
|
+
* @param {string} approvalId - 平台回传的 `ap-<n>`(可空)
|
|
1867
|
+
* @param {string} outcome - `allowed-once|rejected|cancelled`(调用方已校验)
|
|
1868
|
+
* @returns {boolean} 命中并兑现即 true;无在途认领或 id 不符即 false
|
|
1869
|
+
*/
|
|
1870
|
+
_settleApproval(sid, approvalId, outcome) {
|
|
1871
|
+
const pending = this.approvals.get(sid);
|
|
1872
|
+
if (pending === undefined) return false;
|
|
1873
|
+
const wanted = String(approvalId == null ? '' : approvalId);
|
|
1874
|
+
if (wanted && wanted !== pending.id) return false;
|
|
1875
|
+
pending.finish();
|
|
1876
|
+
pending.cancelLane();
|
|
1877
|
+
pending.resolve(outcome);
|
|
1878
|
+
return true;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
/**
|
|
1882
|
+
* 发布「审批挂起」标记(与 `_publishQuestionMark` 同款,仅用于**池外**认领):
|
|
1883
|
+
* 外部会话没有池条目,故写 `observed`(平台 `/status`、`/live` 的挂起实况来源)
|
|
1884
|
+
* 并推全局状态流;池内分支保持既有的 inline 写法(`entry.publish` + 状态流)逐字不动。
|
|
1885
|
+
*
|
|
1886
|
+
* @returns {object} 标记本身(形状:`{kind:'approval', id, tool, call_id, reason, answerable}`)
|
|
1887
|
+
*/
|
|
1888
|
+
_publishApprovalMark(sid, id, request) {
|
|
1889
|
+
const mark = { kind: 'approval', id,
|
|
1890
|
+
tool: (request && request.toolName) || '',
|
|
1891
|
+
call_id: String((request && request.callId) || ''),
|
|
1892
|
+
reason: (request && request.reason) || '',
|
|
1893
|
+
answerable: true };
|
|
1894
|
+
if (!sid) return mark;
|
|
1895
|
+
const entry = this.sessions.get(sid);
|
|
1896
|
+
const seen = this.observed.get(sid);
|
|
1897
|
+
if (entry) {
|
|
1394
1898
|
entry.interaction = mark;
|
|
1395
1899
|
entry.publish({ sid, seq: null, time: Date.now(), type: 'driver/interaction',
|
|
1396
1900
|
data: { state: 'asked', interaction: mark } });
|
|
1397
|
-
this._publishState('driver/interaction', { sid, data: { state: 'asked', interaction: mark } });
|
|
1398
|
-
return new Promise((resolve) => {
|
|
1399
|
-
const settle = (outcome) => {
|
|
1400
|
-
if (!entry.pendingApproval || entry.pendingApproval.id !== id) return;
|
|
1401
|
-
entry.pendingApproval = null;
|
|
1402
|
-
entry.interaction = null;
|
|
1403
|
-
resolve(outcome);
|
|
1404
|
-
};
|
|
1405
|
-
entry.pendingApproval = { id, settle };
|
|
1406
|
-
const signal = request && request.signal;
|
|
1407
|
-
if (signal) {
|
|
1408
|
-
if (signal.aborted) return settle('cancelled');
|
|
1409
|
-
try {
|
|
1410
|
-
signal.addEventListener('abort', () => settle('cancelled'), { once: true });
|
|
1411
|
-
} catch { /* 无 addEventListener 的实现:忽略,靠 /approval 收口 */ }
|
|
1412
|
-
}
|
|
1413
|
-
return undefined;
|
|
1414
|
-
});
|
|
1415
|
-
} catch (err) {
|
|
1416
|
-
this.logger.warn(`touchstone: approval/request 处理异常: ${(err && err.message) || err}`);
|
|
1417
|
-
return next();
|
|
1418
1901
|
}
|
|
1902
|
+
if (seen) seen.interaction = mark;
|
|
1903
|
+
this._publishState('driver/interaction', { sid, data: { state: 'asked', interaction: mark } });
|
|
1904
|
+
return mark;
|
|
1419
1905
|
}
|
|
1420
1906
|
|
|
1421
|
-
/**
|
|
1907
|
+
/**
|
|
1908
|
+
* 收口某会话的**全部在途认领**(C 批 T4):提问认领 reject、审批认领 reject、挂起标记
|
|
1909
|
+
* 一并清掉。调用点两处——`session/disposed`(宿主会话被销毁)与 `dispose()`(插件停用)。
|
|
1910
|
+
*
|
|
1911
|
+
* 为什么必须有:认领的 Promise 是宿主 `ask()`/审批 waterfall 的返回值,会话没了就**再无
|
|
1912
|
+
* 兑现可能**;只把它留在表里 = 宿主侧永久悬挂 + 平台侧挂起标记残留(卡永远显示「等作答」)。
|
|
1913
|
+
* T3 有意不写 disposed 清理,明文把这一态留给本批(计划分工)。
|
|
1914
|
+
*
|
|
1915
|
+
* 收口口径:一律 **reject**(与 `dispose()` 里提问的全量收口同一口径),宿主见异常/信号
|
|
1916
|
+
* 中止自会归一为中断;`finish()` 幂等,重复调用/已兑现的条目是纯 no-op。
|
|
1917
|
+
*
|
|
1918
|
+
* @param {string} sid - 会话 id(池内/池外皆可;按 `pending.sid` 匹配)
|
|
1919
|
+
*/
|
|
1920
|
+
_dropClaims(sid) {
|
|
1921
|
+
const key = String(sid || '');
|
|
1922
|
+
if (!key) return;
|
|
1923
|
+
// 只在**确实摘掉了认领**时才补发挂起标记收口帧:会话被销毁时本方法对每个 sid 都会跑,
|
|
1924
|
+
// 无认领的会话(尤其**未看管**会话)不能因此多出一帧状态变更——那是可观测的行为漂移。
|
|
1925
|
+
let dropped = false;
|
|
1926
|
+
for (const [callId, pending] of [...this.questions]) {
|
|
1927
|
+
if (pending.sid !== key) continue;
|
|
1928
|
+
try {
|
|
1929
|
+
pending.finish();
|
|
1930
|
+
pending.cancelLane();
|
|
1931
|
+
pending.reject(new Error('host session disposed before the question settled'));
|
|
1932
|
+
dropped = true;
|
|
1933
|
+
} catch {
|
|
1934
|
+
/* 已收口/已兑现:忽略 */
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
const approval = this.approvals.get(key);
|
|
1938
|
+
if (approval !== undefined) {
|
|
1939
|
+
try {
|
|
1940
|
+
approval.finish();
|
|
1941
|
+
approval.cancelLane();
|
|
1942
|
+
approval.reject(new Error('host session disposed before the approval settled'));
|
|
1943
|
+
dropped = true;
|
|
1944
|
+
} catch {
|
|
1945
|
+
/* 已收口/已兑现:忽略 */
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
// 两条链都没命中就不发帧(`finish()` 本身已清标记,这里只兜「认领在别处摘了表、
|
|
1949
|
+
// 标记却还在」的残留态)
|
|
1950
|
+
if (dropped) this._clearQuestionMark(key);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
/**
|
|
1954
|
+
* `POST /approval`:{session_id, approval_id?, decision} → 兑现挂起的审批。
|
|
1955
|
+
*
|
|
1956
|
+
* 池外分支(C 批 T4):**看管中**的外部会话放行——认领表(`approvals`)命中即兑现
|
|
1957
|
+
* (宿主 waterfall 收到 outcome),否则回 409「当前没有等待中的审批(或已由 GUI 作答)」
|
|
1958
|
+
* (GUI 先答/已收口时的迟到作答就落这里,与池内分支同串)。decision 先在外部支校验,
|
|
1959
|
+
* 非法一律 400、**绝不**把非法 outcome 递宿主。池内与未看管仍走 `_lookup` 这个既有唯一
|
|
1960
|
+
* 404 出口(文案一字不变):判定必须发生在写响应**之前**,不能先 `_lookup` 写了 404
|
|
1961
|
+
* 再补第二个响应(与 `_answer` 同款理由)。
|
|
1962
|
+
*/
|
|
1422
1963
|
async _approval(res, req) {
|
|
1423
1964
|
const body = await this._body(req);
|
|
1965
|
+
const sid = String(body.session_id || '');
|
|
1966
|
+
if (this.sessions.get(sid) === undefined && sid && this._isWatched(sid)) {
|
|
1967
|
+
const decision = String(body.decision == null ? '' : body.decision);
|
|
1968
|
+
if (!['allowed-once', 'rejected', 'cancelled'].includes(decision)) {
|
|
1969
|
+
this._json(res, 400, { error: 'decision 非法(allowed-once/rejected/cancelled)' });
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
if (this._settleApproval(sid, body.approval_id, decision)) {
|
|
1973
|
+
this._json(res, 200, { ok: true, accepted: true, session_id: sid, outcome: decision });
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1976
|
+
this._json(res, 409, { error: '当前没有等待中的审批(或已由 GUI 作答)' });
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1424
1979
|
const entry = this._lookup(res, body);
|
|
1425
1980
|
if (!entry) return;
|
|
1426
1981
|
const pending = entry.pendingApproval;
|
|
@@ -1676,22 +2231,64 @@ export class AgentDriver {
|
|
|
1676
2231
|
* 而 `commands` 可注入且 `command-compact`(`/compact`)在本 profile 已装载
|
|
1677
2232
|
* (`--dump-config` 可见)。命令是异步长任务(要过一次模型),故**触发即回**
|
|
1678
2233
|
* `{started:true}`;真正的失败只落宿主日志(平台侧 compact 语义本来就是"发起")。
|
|
2234
|
+
*
|
|
2235
|
+
* 池外回落(2026-10-10,用户报障「卡片 945 无法 compact」):平台自建卡会话在宿主侧
|
|
2236
|
+
* 重载/重启后只剩观察(`/live` 的 `owned:false`)而**宿主里仍活着**——此时 `/compact`
|
|
2237
|
+
* 若只认驱动池(旧行为)就必 404「会话不在驱动池中」,前端把这条内部文案原样 toast。
|
|
2238
|
+
* 语义与 `/rename` 的池外回落同款,判定复用**唯一闸门** `_externalTarget`:
|
|
2239
|
+
* ① 池内 ⇒ 既有路径(`entry.agent` 触发,回执不带 `external`);
|
|
2240
|
+
* ② 已看管但宿主无活 agent ⇒ 闸门自写 404 分档文案(会话已结束);
|
|
2241
|
+
* ③ 未看管 ⇒ 走 `_lookup` 这个既有唯一 404 出口(原文案一字不变);
|
|
2242
|
+
* ④ 已看管且宿主有活 agent ⇒ 对**宿主活 agent** 触发压缩(回执带 `external:true`),
|
|
2243
|
+
* **不接管**(不入池、不建 handle、不动 `owned`)。
|
|
2244
|
+
*
|
|
2245
|
+
* 为什么池外只能走「直投活 agent」而不是「先接管再压缩」:外部会话的 agent 已在宿主
|
|
2246
|
+
* `agents` store 里注册,`/session` resume(`agents.enter()`)对已注册 id 直接抛
|
|
2247
|
+
* `agent "…" is already registered` ⇒ 接管必 500(C 批 T1 的实测结论)。
|
|
1679
2248
|
*/
|
|
1680
2249
|
async _compact(res, req) {
|
|
1681
2250
|
const body = await this._body(req);
|
|
1682
|
-
const
|
|
1683
|
-
|
|
2251
|
+
const sid = String((body && body.session_id) || '');
|
|
2252
|
+
const entry = this.sessions.get(sid);
|
|
2253
|
+
let agent = entry && entry.agent;
|
|
2254
|
+
let external = false;
|
|
2255
|
+
if (!entry) {
|
|
2256
|
+
const live = this._externalTarget(res, sid);
|
|
2257
|
+
if (live === null) return; // ② 已看管无活 agent:闸门已回 404 分档
|
|
2258
|
+
if (live === undefined) { // ③ 未看管:既有唯一 404 出口(原文案)
|
|
2259
|
+
this._lookup(res, body);
|
|
2260
|
+
return;
|
|
2261
|
+
}
|
|
2262
|
+
agent = live; // ④ 已看管 + 宿主有活 agent:直投,不接管
|
|
2263
|
+
external = true;
|
|
2264
|
+
}
|
|
1684
2265
|
const commands = this.ctx.get('commands');
|
|
1685
2266
|
if (!commands || typeof commands.execute !== 'function') {
|
|
1686
2267
|
this._json(res, 503, { error: 'commands 服务不可用,无法触发 /compact' });
|
|
1687
2268
|
return;
|
|
1688
2269
|
}
|
|
1689
2270
|
const controller = new AbortController();
|
|
1690
|
-
Promise.resolve(commands.execute(
|
|
1691
|
-
.then(() =>
|
|
2271
|
+
Promise.resolve(commands.execute(agent, '/compact', [], controller.signal))
|
|
2272
|
+
.then((out) => {
|
|
2273
|
+
// 回执形状(`@deepseek-ai/dsh-commands` 的 `execute`):`{commandId, result:{kind,text}}`;
|
|
2274
|
+
// 语法/命令名不解析时 resolve `undefined`(该 profile 没挂 command-compact)。关键是
|
|
2275
|
+
// **`kind:'error'` 也是正常 resolve**(真实失败之一:会话非 idle ⇒ busy,见
|
|
2276
|
+
// `dsh-command-compact` 的 expectedFailure 文案)——旧实现无条件记「完成」,会把
|
|
2277
|
+
// 「压根没压」写成成功。池外会话多正被用户驱动,撞 busy 的概率高于池内,故按实况记账
|
|
2278
|
+
// (HTTP 语义不变:仍是「触发即回」,这里只让宿主日志说真话)。
|
|
2279
|
+
const result = (out && out.result) || null;
|
|
2280
|
+
if (!result) {
|
|
2281
|
+
this.logger.warn(`touchstone: /compact 未执行 sid=${sid}: 该 profile 未装载 /compact 命令`);
|
|
2282
|
+
} else if (result.kind === 'error') {
|
|
2283
|
+
this.logger.warn(`touchstone: /compact 未生效 sid=${sid}: ${result.text || '未知原因'}`);
|
|
2284
|
+
} else {
|
|
2285
|
+
this.logger.info(`touchstone: /compact 完成 sid=${sid}${result.text ? `: ${result.text}` : ''}`);
|
|
2286
|
+
}
|
|
2287
|
+
})
|
|
1692
2288
|
.catch((err) => this.logger.warn(
|
|
1693
|
-
`touchstone: /compact 失败 sid=${
|
|
1694
|
-
this._json(res, 200, { ok: true, session_id:
|
|
2289
|
+
`touchstone: /compact 失败 sid=${sid}: ${(err && err.message) || err}`));
|
|
2290
|
+
this._json(res, 200, { ok: true, session_id: sid, started: true,
|
|
2291
|
+
...(external ? { external: true } : {}) });
|
|
1695
2292
|
}
|
|
1696
2293
|
|
|
1697
2294
|
/**
|
|
@@ -1764,11 +2361,35 @@ export class AgentDriver {
|
|
|
1764
2361
|
this._json(res, 200, { ok: true, session_id: entry.sessionId, new_session_id: newSid });
|
|
1765
2362
|
}
|
|
1766
2363
|
|
|
1767
|
-
/**
|
|
2364
|
+
/**
|
|
2365
|
+
* `POST /rename`:会话标题(平台侧「会话命名」;与 dsh 侧栏同一份标题)。
|
|
2366
|
+
*
|
|
2367
|
+
* 池外回落(2026-10-10 用户需求「TS 看板卡改名 → DSH 会话名同步」):平台**看管**
|
|
2368
|
+
* (`/watch`,只声明不接管)且宿主仍有活 agent 的池外会话(用户在 dsh GUI 直跑的)
|
|
2369
|
+
* ⇒ 直接改宿主会话标题,回执带 `external:true`。判定复用 `/prompt`·`/steer` 的
|
|
2370
|
+
* **唯一闸门** `_externalTarget`,三档语义一字不差:
|
|
2371
|
+
* ① 池内 ⇒ 原路径(本函数开头已单独处理);
|
|
2372
|
+
* ② 已看管但宿主无活 agent ⇒ 闸门自写 404 分档文案(会话已结束);
|
|
2373
|
+
* ③ 未看管 ⇒ 走 `_lookup` 这个既有唯一 404 出口(原文案不变);**判定顺序同
|
|
2374
|
+
* `/prompt`**:先定档再校验空标题——池外未看管的空标题仍先撞 404,不被 400 截胡。
|
|
2375
|
+
* 为什么**不接管**(不入池、不建 handle、不动 `owned`):改名只需要宿主
|
|
2376
|
+
* `sessionController.rename`(内部 `resolveAgent(sessionId)` 对任何宿主可解析的会话
|
|
2377
|
+
* 都可用),一旦收养就会改 `owned` 与项目占用/`ext:` 行口径,代价远大于改名本身。
|
|
2378
|
+
*/
|
|
1768
2379
|
async _rename(res, req) {
|
|
1769
2380
|
const body = await this._body(req);
|
|
1770
|
-
const
|
|
1771
|
-
|
|
2381
|
+
const sid = String((body && body.session_id) || '');
|
|
2382
|
+
const entry = this.sessions.get(sid);
|
|
2383
|
+
let external = false;
|
|
2384
|
+
if (!entry) {
|
|
2385
|
+
const live = this._externalTarget(res, sid);
|
|
2386
|
+
if (live === null) return; // ② 已看管无活 agent:闸门已回 404 分档
|
|
2387
|
+
if (live === undefined) { // ③ 未看管:既有唯一 404 出口(原文案)
|
|
2388
|
+
this._lookup(res, body);
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
external = true; // 已被看管且宿主有活 agent:改名回落
|
|
2392
|
+
}
|
|
1772
2393
|
const title = String(body.title == null ? '' : body.title).trim();
|
|
1773
2394
|
if (!title) {
|
|
1774
2395
|
this._json(res, 400, { error: 'title 不能为空' });
|
|
@@ -1779,9 +2400,9 @@ export class AgentDriver {
|
|
|
1779
2400
|
this._json(res, 503, { error: 'sessionController 服务不可用,无法改名' });
|
|
1780
2401
|
return;
|
|
1781
2402
|
}
|
|
1782
|
-
const value = await controller.rename({ sessionId:
|
|
2403
|
+
const value = await controller.rename({ sessionId: sid, title });
|
|
1783
2404
|
this._json(res, 200, {
|
|
1784
|
-
ok: true, session_id:
|
|
2405
|
+
ok: true, session_id: sid, ...(external ? { external: true } : {}),
|
|
1785
2406
|
title: String((value && value.title) || title),
|
|
1786
2407
|
});
|
|
1787
2408
|
}
|