@gavinc-cn/touchstone-dsh 0.2.0 → 0.4.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/LICENSE +0 -0
- package/README.md +23 -15
- package/README.zh.md +17 -12
- package/auth.py +0 -0
- 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 +6227 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2090 -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 +1747 -0
- package/backend/lib/feishu_cfg.js +262 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +2077 -0
- package/backend/lib/feishu_provision.js +886 -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 +426 -0
- package/backend/lib/proc.js +361 -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 +4546 -0
- package/backend/lib/seed_hint.js +40 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +227 -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 +1228 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +249 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +298 -0
- package/backend/routes/projects.js +1138 -0
- package/backend/routes/sessions.js +1003 -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 +505 -0
- package/board.py +96 -13
- package/builtin_assets.py +0 -0
- package/builtin_prompts/free_style/flow.md +0 -0
- package/builtin_prompts/free_style/load_test.md +0 -0
- package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
- package/builtin_prompts/free_style/templates/case_template.md +0 -0
- package/builtin_prompts/free_style/templates/index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
- package/builtin_prompts/free_style/templates/status_template.md +0 -0
- package/chat.py +33 -0
- package/db.py +294 -129
- package/dsh-plugin/cordis.patch.yml +0 -0
- package/dsh-plugin/install.sh +22 -8
- package/dsh-plugin/lib/client.bundle.js +111 -2
- package/dsh-plugin/lib/index.js +340 -29
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/build.mjs +0 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/scripts/select-bundle.mjs +0 -0
- package/dsh-plugin/src/client.js +111 -2
- package/dshevents.py +0 -0
- package/export_cases.py +0 -0
- package/extensions/dsh-autocommit/asset.json +0 -0
- package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
- package/extensions/dsh-autocommit/pkg/package.json +0 -0
- package/feishu.py +417 -3
- package/feishu_conv.py +0 -0
- package/lib.py +0 -0
- package/lifecycle.py +116 -13
- package/loadcase.py +0 -0
- package/loadgen.py +0 -0
- package/localbus.py +0 -0
- package/migrate_p7b.py +0 -0
- package/package.json +6 -1
- package/platcompat.py +0 -0
- package/prompts.py +0 -0
- package/rag.py +0 -0
- package/requirements.txt +0 -0
- package/server.py +155 -11
- package/touchstone.py +0 -0
- package/waitq.py +0 -0
- package/webui/dist-plugin/assets/index-3QsYQi7y.css +1 -0
- package/webui/dist-plugin/assets/index-B8AMtW7y.js +486 -0
- package/webui/dist-plugin/index.html +2 -2
- package/worktree.py +0 -0
- package/webui/dist-plugin/assets/index-JLwyZZxC.js +0 -484
- package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
|
@@ -0,0 +1,2090 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session 对话的**消息生命周期**(`chat.py` 1189 行的 Node 对应物;M3 只读子集 + M4 写/流程口)。
|
|
3
|
+
*
|
|
4
|
+
* 权威语义来源是 `chat.py` 的模块 docstring(下面逐条搬过来,改代码前必读)。
|
|
5
|
+
*
|
|
6
|
+
* ══════════════════════════════════════════════════════════════════════════════
|
|
7
|
+
* 核心模型:消息是统一队列的第三类单元 `m:<msg_id>`
|
|
8
|
+
* ══════════════════════════════════════════════════════════════════════════════
|
|
9
|
+
* ① 用户在会话窗发的每条消息都走**统一队列**:消息行登记在 `chat_msgs`(权威,用户可见
|
|
10
|
+
* 生命周期 queued→running→done/yielded/error/cancelled),队列行在 `wait_items`
|
|
11
|
+
* (kind=msg,管「队列拾取互斥」)——两套状态机互不替代。
|
|
12
|
+
* ② 入队后由 `runner` 的 worker 补位拾取(同项目串行:项目忙就按入队顺序等),拾起后
|
|
13
|
+
* 调 `runUnit(msgId)`。
|
|
14
|
+
* ③ `runUnit` 负责「投递 + 等 turn 结束」:`msgClaim` 原子 queued→running(与 `injectNow`
|
|
15
|
+
* 的 `waitq.claim` 互斥互补),执行体按**行 + 提交时定格的 meta 快照**重建(闭包不可
|
|
16
|
+
* 持久化,故 family/model/comment_id 定格进等待项 meta),终态 done/yielded/error 落表,
|
|
17
|
+
* 且**等待项行的终态在同一函数闭环**(裁决 R6:终态归执行体,runner 影子段不再管 m:)。
|
|
18
|
+
* ④ turn 挂起等用户作答(`driver/interaction`)⇒ **提前让位**:STATE_YIELDED——消息已送达、
|
|
19
|
+
* 运行位(等待项行)即释放,队首单元照常起跑;用户作答后会话恢复并重新成为占用源。
|
|
20
|
+
* ⑤ 单元执行期间持有项目占用 ⇒ 该项目后续任务/卡片也要等它结束(**双向串行**,
|
|
21
|
+
* 「任务按项目串行」红线)。
|
|
22
|
+
* ⑥ 重启存活:排队消息照常执行;执行中被中断的记 error、**不重投**(msg 重发非幂等,
|
|
23
|
+
* 见 `waitq.msg_recover_fail`)。
|
|
24
|
+
* ⑦ 「立即注入」(`injectNow`):用户点按钮即撤销排队并立刻投递(dsh:steer 注入当前 turn
|
|
25
|
+
* 的最近 step 边界,会话空闲则起一轮)——不等队列、不等 turn 结束、不持运行位。
|
|
26
|
+
* ⑧ 外部会话(C 批 T5):用户在 dsh GUI 里直跑/接管的会话(`owned:false`)也能投递,但必须
|
|
27
|
+
* 在**入队之前**过 `externalPreflight`(宿主有活 agent ∧ 平台已向驱动声明看管);前提
|
|
28
|
+
* 不成立即抛 `DeliveryRefused`,消息**不落 chat_msgs/wait_items 行**——把「必然失败的
|
|
29
|
+
* 静默排队」变成「发送即明确文案」。池内会话与未对齐(未知)路径**不会**抛它。
|
|
30
|
+
* ⑨ 并发约束(由 server 层保证):同一会话同时最多一个对话单元;同一会话可连发多条消息
|
|
31
|
+
* (按入队顺序串行执行)。
|
|
32
|
+
*
|
|
33
|
+
* ══════════════════════════════════════════════════════════════════════════════
|
|
34
|
+
* `_CHATS`(进程内对话登记表):Node 侧是普通 Map,**不再需要锁**
|
|
35
|
+
* ══════════════════════════════════════════════════════════════════════════════
|
|
36
|
+
* Python 用 `_CHATS` dict + `threading.Lock`(HTTP 线程池 / runner 线程 / SSE 线程并发读写,
|
|
37
|
+
* 连 `_new_msg_id` 的发号都共用那把锁)。Node 是**单事件循环**:本模块每一次「读—改—写」
|
|
38
|
+
* 都跑在同一个 tick 里(中间没有 await),`dshevents` 的订阅回调也是同步调用 ⇒ 进程内不存在
|
|
39
|
+
* 并发交错,锁没有可保护的对象。要守的纪律只有一条:**不要把 await 塞进这些临界区**
|
|
40
|
+
* (例:`state` 里 `rec.exit_code` 的顺手回填在 await 前后各自成段)。同理 `_DSH_SUB` 的
|
|
41
|
+
* 「旁表 + 锁」退化成「旁表 + 同 tick 原子操作」——Python 的 `_dsh_sub_reap_locked` 在 Node
|
|
42
|
+
* 侧没有锁可持(`dshSubReap`)。**不引入 worker_threads**(那会让上述论证失效,届时要重审)。
|
|
43
|
+
*
|
|
44
|
+
* ══════════════════════════════════════════════════════════════════════════════
|
|
45
|
+
* 同步 / 异步契约(本文件最容易做错的一处)
|
|
46
|
+
* ══════════════════════════════════════════════════════════════════════════════
|
|
47
|
+
* Python 是线程 + `time.sleep` / `threading.Event` 阻塞;Node 的对应写法是 `await` + 定时器
|
|
48
|
+
* (setTimeout 的 promise 版)。故下列函数是 **async,调用方必须 await**:
|
|
49
|
+
* `submit` / `start` / `runUnit` / `injectNow` / `stop` / `sendNow` / `dshSendNow` /
|
|
50
|
+
* `dshSend` / `dshBaselineSince` / `dshWaitTurn` / `dshWaitTurnViaEvents` / `waitTurn` /
|
|
51
|
+
* `waitWebBusy` / `injectSend` / `reviveHostSession` / `dshBusy`;`state`/`running` 见下。
|
|
52
|
+
* 其余一律**同步**(不许 await),尤其 `rebuildRun` / `rebuildInject`——**载荷读取是同步的**,
|
|
53
|
+
* 只有它们返回的 thunk 才是 async:Python `_rebuild_run(msg_id)` 先同步读行/meta/项目行、
|
|
54
|
+
* 再返回闭包,Node 逐点保留这个时序(`msgPayload` 同步读,thunk 里才 await 投递)。
|
|
55
|
+
* **所有 `waitq.*` / `db.*` 调用一律同步直调**(`node:sqlite` 是同步 API):绝不把它们包进
|
|
56
|
+
* `then`/`Promise.all` 里去改变调用顺序或事务边界。
|
|
57
|
+
*
|
|
58
|
+
* ══════════════════════════════════════════════════════════════════════════════
|
|
59
|
+
* `state` / `running`:**一律 async**(有意差异 ①)
|
|
60
|
+
* ══════════════════════════════════════════════════════════════════════════════
|
|
61
|
+
* Python `state()` 是同步函数(无 `_CHATS` 记录时直接 return base,有记录才调同步的
|
|
62
|
+
* `dsh_busy`)。Node 侧 `dshBusy` 是**远端读**、只能 async ⇒ `state`/`running` **整体
|
|
63
|
+
* async,调用方一律 await**:`await` 一处都不能漏,否则 Promise 会被静默序列化成 `{}`
|
|
64
|
+
* (M4 的路由接线点 `routes/sessions.js` 已按此口径 await)。
|
|
65
|
+
* 分支本身逐点照 Python:无 sid / 无登记记录 / 非 dsh 记录 ⇒ base(不做任何 I/O);
|
|
66
|
+
* 有 dsh 记录 ⇒ 读一次驱动状态,`!busy` 且 `exit_code` 为 null 时顺手回填 0。
|
|
67
|
+
*
|
|
68
|
+
* ══════════════════════════════════════════════════════════════════════════════
|
|
69
|
+
* M5 缝:board 投递桥(**注入式,不许静默成功**)
|
|
70
|
+
* ══════════════════════════════════════════════════════════════════════════════
|
|
71
|
+
* Python 里 chat 对 board 的依赖只有三处,Node 侧 board.js(M5)尚无对应函数,故做成显式
|
|
72
|
+
* 注入口 `setBoardBridge({ensureWatch, deliverUnit, deliverNow})`;未注册时:
|
|
73
|
+
* ① `externalPreflight` 第⑤步的 `board._ensure_watch(sid)` ⇒ `ensureWatch` 视作**返回
|
|
74
|
+
* false** ⇒ 外部会话按 Python 的「看管声明失败」文案拒投(逐字一致,不落行);
|
|
75
|
+
* ② `rebuildRun` 的卡片评论分支 `board._deliver_unit(...)` ⇒ 抛「卡片投递桥未接线(M5)」
|
|
76
|
+
* 错,由 `runUnit`(或 `submit` 的同步路径)收口成该 msg 单元的 **error 终态**
|
|
77
|
+
* (`chat_msgs.error` 就是该文案);
|
|
78
|
+
* ③ `rebuildInject` 的卡片评论分支 `board._deliver_now(...)` ⇒ 同上;`injectNow` 按投递
|
|
79
|
+
* 失败路径把消息放回排队(returnToWaiting + msgRequeue)后**原样上抛**。
|
|
80
|
+
* 三条都是响亮失败,绝无静默成功。
|
|
81
|
+
*
|
|
82
|
+
* ══════════════════════════════════════════════════════════════════════════════
|
|
83
|
+
* 与 Python 的有意差异(逐条 + 理由)
|
|
84
|
+
* ══════════════════════════════════════════════════════════════════════════════
|
|
85
|
+
* ① `state`/`running` 一律 async(Python 是同步函数;见上)。
|
|
86
|
+
* ② `dshBusy`/`dshBaselineSince` 的宽容读:Python 对非 dict 响应 `.get()` 会 AttributeError,
|
|
87
|
+
* Node 用 `(st || {})` 吞成 false/空串(与 M3 本文件既有口径、`dshdriver.js` 差异⑦同源;
|
|
88
|
+
* 只影响「驱动返回畸形正文」这一异常形态)。
|
|
89
|
+
* ③ `msgsOfSid` 的 120 字截断按 UTF-16 码元(Python 按码点)——只服务前端 chip 预览。
|
|
90
|
+
* ④ `msgEnqueue` 的 `queue` 参数逐字对应 `waitq.py:1179` 的形参表(`queue=false` =
|
|
91
|
+
* 「runner 单例缺位的同步路径不写等待项」,P5 R13 的孤儿行收口)。
|
|
92
|
+
* ⑤ 等 turn 的本地节拍/时限全走毫秒定时器,但**判定顺序、超时文案、时限口径与 Python 逐条
|
|
93
|
+
* 同**(`TICK`/`TURN_START_GRACE`/`DSH_WAIT_TURN_TIMEOUT` 保持 Python 的秒值)。
|
|
94
|
+
* ⑥ 终态钩子若返回 thenable,本文件补一个 `.catch` 留痕(Node 的未处理拒绝会杀进程;Python
|
|
95
|
+
* 的同步钩子没有这个形态)——钩子的调用点与调用语义不变。
|
|
96
|
+
* ⑦ waitq 的 M4 写口经**运行期符号门**调用(`wq(name)` + `missingWaitqSymbols()`):并行
|
|
97
|
+
* 移植窗口里缺符号时抛**指名**的中文错误,而不是整文件链接失败(只读口照常可用)。
|
|
98
|
+
* ⑧ `lib.ensureRuntimeDirs` / `runner.appendLog` / `runner.LOG_DIR_NAME` 已由 M4 的兄弟模块
|
|
99
|
+
* 落地,本文件**优先用共享实现**(`runtimeDir`/`logDirName`/`appendLog` 三个薄包装就是
|
|
100
|
+
* 唯一读点);本地等价兜底保留,用于并行移植窗口与旧树(避免静态 import 缺符号导致
|
|
101
|
+
* 整文件链接失败)。
|
|
102
|
+
*
|
|
103
|
+
* 零 npm 依赖:只用 `node:` 内建模块 + 本仓库内模块。
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
import fs from 'node:fs';
|
|
107
|
+
import path from 'node:path';
|
|
108
|
+
|
|
109
|
+
import { agentFamily } from './agents.js';
|
|
110
|
+
import { getProject, withReadDb } from './db.js';
|
|
111
|
+
import * as dshevents from './dshevents.js';
|
|
112
|
+
import {
|
|
113
|
+
DshDriverError,
|
|
114
|
+
TurnWaiter,
|
|
115
|
+
cancel as driverCancel,
|
|
116
|
+
feedInto as driverFeedInto,
|
|
117
|
+
prompt as driverPrompt,
|
|
118
|
+
resumeSession as driverResumeSession,
|
|
119
|
+
status as driverStatus,
|
|
120
|
+
steer as driverSteer,
|
|
121
|
+
} from './dshdriver.js';
|
|
122
|
+
import * as libMod from './lib.js';
|
|
123
|
+
import { pyJsonDumps } from './pyjson.js';
|
|
124
|
+
import * as runnerMod from './runner.js';
|
|
125
|
+
import * as waitq from './waitq.js';
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* session_id -> 对话记录:`{task_id, started_at, message, exit_code, log_path, dsh_plugin?, since?}`。
|
|
129
|
+
*
|
|
130
|
+
* 登记写入点是 `register`(`_register`),读取点是 `state` / `stop` / 等 turn 时的
|
|
131
|
+
* `log_path` 缺省回落。**Node 单事件循环下不需要锁**(见文件头「不再需要锁」),
|
|
132
|
+
* 用 Map 而不是对象:Python dict 的对应物,`get` 无原型污染。
|
|
133
|
+
*/
|
|
134
|
+
export const CHAT_REGISTRY = new Map();
|
|
135
|
+
|
|
136
|
+
/** msg_id 自增序号(`_MSG_SEQ`;仅发号用,记录本体在 `chat_msgs` 表) */
|
|
137
|
+
let MSG_SEQ = 0;
|
|
138
|
+
|
|
139
|
+
/** 消息状态机(逐条对齐 chat.py:54-59) */
|
|
140
|
+
export const STATE_QUEUED = 'queued'; // 已入队,等项目空闲
|
|
141
|
+
export const STATE_RUNNING = 'running'; // 正在执行(发送中 / 等 turn 结束)
|
|
142
|
+
export const STATE_DONE = 'done'; // 已送达且 turn 结束
|
|
143
|
+
export const STATE_YIELDED = 'yielded'; // 已送达;turn 挂起等作答,运行位已让出
|
|
144
|
+
export const STATE_ERROR = 'error'; // 执行失败(error 字段为原因)
|
|
145
|
+
export const STATE_CANCELLED = 'cancelled'; // 排队中被取消
|
|
146
|
+
|
|
147
|
+
/** 单条对话消息长度上限(`MESSAGE_MAX`;站点 `POST …/messages` 与飞书通用对话共用) */
|
|
148
|
+
export const MESSAGE_MAX = 20000;
|
|
149
|
+
/** 终态记录保留时长(秒)——前端取过失败原因后回收(`MSG_KEEP_SEC`) */
|
|
150
|
+
export const MSG_KEEP_SEC = 600;
|
|
151
|
+
/** 消息登记表规模上限(`MSG_MAX`):超出丢最旧的终态记录 */
|
|
152
|
+
export const MSG_MAX = 200;
|
|
153
|
+
/** turn 启动宽限(秒):未见 `turn/started` 判「turn 压根没起来」(`TURN_START_GRACE`) */
|
|
154
|
+
export const TURN_START_GRACE = 60;
|
|
155
|
+
/** 等 turn 结束的本地检查节拍(秒,与 runner 轮次轮询一致;`TICK`) */
|
|
156
|
+
export const TICK = 2;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* dsh 等 turn 结束的**总时限**(秒,P7a 缺陷 G 兜底)。
|
|
160
|
+
*
|
|
161
|
+
* 链路保活正常但服务端就是不推 `turn/end` 时,只等 `TurnWaiter` 会永远等下去——实测
|
|
162
|
+
* (假 driver 单订阅缺陷期间)`chat_msgs` 卡 running、`wait_items` 的 `m:` 行卡 starting
|
|
163
|
+
* 4 分钟以上、项目运行位永久被占,只有重启能解。到点写一行会话日志并按「本轮结束」
|
|
164
|
+
* 返回 null,让行收口、补位继续;纯本地 deadline,不引入远端轮询。45 分钟口径:runner
|
|
165
|
+
* 侧没有轮次硬超时(唯一护栏是「turn 压根没起来」的 120s),故取「一轮合法长跑」的
|
|
166
|
+
* 经验上限,够长到不误杀正常长轮;环境变量可覆盖(单测调小用)。
|
|
167
|
+
*/
|
|
168
|
+
export const DSH_WAIT_TURN_TIMEOUT = 2700.0;
|
|
169
|
+
/** 总时限的环境变量名(`DSH_WAIT_TURN_TIMEOUT_ENV`;每次调用现读,进程内可热改) */
|
|
170
|
+
export const DSH_WAIT_TURN_TIMEOUT_ENV = 'TS_DSH_WAIT_TURN_TIMEOUT';
|
|
171
|
+
|
|
172
|
+
/** 外部会话投递总开关(回滚阀):`TS_EXTERNAL_DELIVER=0` ⇒ 外部会话一律拒投 */
|
|
173
|
+
export const EXTERNAL_DELIVER_ENV = 'TS_EXTERNAL_DELIVER';
|
|
174
|
+
/** 宿主会话存活兜底总开关(回滚阀):`TS_HOST_SESSION_REVIVE=0` ⇒ 不接回 */
|
|
175
|
+
export const HOST_REVIVE_ENV = 'TS_HOST_SESSION_REVIVE';
|
|
176
|
+
|
|
177
|
+
/** `_DSH_SUB` 条数上限(超出按建订阅时刻从旧到新回收) */
|
|
178
|
+
export const DSH_SUB_MAX = 32;
|
|
179
|
+
/** 预订阅惰性过期(秒):投递后无人取走即回收 */
|
|
180
|
+
export const DSH_SUB_TTL = 120.0;
|
|
181
|
+
|
|
182
|
+
/** 可续聊的 agent 族白名单(`chat.CHAT_FAMILIES`,单族化后只剩 dsh 插件) */
|
|
183
|
+
export const CHAT_FAMILIES = Object.freeze(['dsh_plugin']);
|
|
184
|
+
|
|
185
|
+
/** 「卡片投递桥未接线(M5)」——board.js 的两条卡片投递口还没移植时的响亮失败文案 */
|
|
186
|
+
export const CARD_BRIDGE_MISSING = '卡片投递桥未接线(M5):board._deliver_unit/_deliver_now '
|
|
187
|
+
+ '尚未移植,卡片评论消息无法投递';
|
|
188
|
+
|
|
189
|
+
/** 运行产物目录名兜底(`runner.LOG_DIR_NAME`;共享常量优先,见 `logDirName`) */
|
|
190
|
+
const LOG_DIR_NAME = '.web';
|
|
191
|
+
/** 运行产物目录清单兜底(`lib.RUNTIME_DIR_NAMES`,顺序即 .gitignore 追加顺序) */
|
|
192
|
+
const RUNTIME_DIR_NAMES = ['.web', '.live', 'board_media'];
|
|
193
|
+
|
|
194
|
+
// --------------------------------------------------------------------------
|
|
195
|
+
// 错误类(逐条对齐 chat.py:77-94;`instanceof` 可判定,中文文案逐字一致)
|
|
196
|
+
// --------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 「立即注入」被拒(消息已不在排队中 / 不支持注入):消息未动,仍在队列。
|
|
200
|
+
*
|
|
201
|
+
* 与投递失败的区分:被拒 → 端点 400、消息继续排队;投递失败 → 端点 502、消息按原位次
|
|
202
|
+
* 退回排队(`returnToWaiting` 不动 seq,见 `injectNow`)。
|
|
203
|
+
* Python 是 `RuntimeError` 子类;Node 的对应物是 `Error` 子类(判据一律 `instanceof`)。
|
|
204
|
+
*/
|
|
205
|
+
export class InjectRefused extends Error {
|
|
206
|
+
/** @param {string} message 中文文案(端点原样回给前端) */
|
|
207
|
+
constructor(message) {
|
|
208
|
+
super(message);
|
|
209
|
+
this.name = 'InjectRefused';
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 外部会话投递前置闸拒绝(C 批 T5):消息**未入队、未落行**。
|
|
215
|
+
*
|
|
216
|
+
* 与投递失败(`DshDriverError`,消息落 `chat_msgs.state=error`)的区分:本异常在入队
|
|
217
|
+
* **之前**由 `externalPreflight` 抛出,`chat_msgs`/`wait_items` 都查不到这次发送——
|
|
218
|
+
* 把「必然失败的静默排队」变成「点发送即刻的明确文案」。触发面只有外部会话
|
|
219
|
+
* (`owned:false`)且前提不成立:宿主无活 agent(会话已结束)/ 看管声明失败 / 回滚阀关闭。
|
|
220
|
+
* 池内会话与未对齐(未知)路径**不会**抛本异常。
|
|
221
|
+
*/
|
|
222
|
+
export class DeliveryRefused extends Error {
|
|
223
|
+
/** @param {string} message 中文文案(端点原样回给前端) */
|
|
224
|
+
constructor(message) {
|
|
225
|
+
super(message);
|
|
226
|
+
this.name = 'DeliveryRefused';
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 本会话消息的展示视图(排队/执行中 + 近期失败):
|
|
232
|
+
* `{id, text, state, error, created_at}`。
|
|
233
|
+
*
|
|
234
|
+
* 终态正常记录(done/yielded/cancelled)不发——前端 chip 由「被会话收录」自行清除。
|
|
235
|
+
* `text` 截 120 字(Python 切片 `[:120]`;Node 的 `slice` 按 UTF-16 码元,与 Python 按
|
|
236
|
+
* 码点不同——中文 BMP 内一致,emoji 等代理对可能差 1 个字符。**有意的形态差异**:
|
|
237
|
+
* 截断只服务前端 chip 的短预览,不参与任何判据)。
|
|
238
|
+
*
|
|
239
|
+
* 末尾**再按 created_at 排一次序**:SQL 已是 `ORDER BY created_at, id`,这里的重复排序
|
|
240
|
+
* 照抄 Python(`out.sort` 稳定 ⇒ 同 created_at 时保持 SQL 的 id 序,结果不变)。
|
|
241
|
+
*
|
|
242
|
+
* @param {string} sid
|
|
243
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接
|
|
244
|
+
* @returns {Array<{id: string, text: string, state: string, error: string, created_at: number}>}
|
|
245
|
+
*/
|
|
246
|
+
export function msgsOfSid(sid, options = {}) {
|
|
247
|
+
if (!sid) return [];
|
|
248
|
+
const rows = waitq.msgRows({ sid, states: [STATE_QUEUED, STATE_RUNNING, STATE_ERROR] }, options);
|
|
249
|
+
const out = rows.map((r) => ({
|
|
250
|
+
id: r.id,
|
|
251
|
+
text: String(r.message === null || r.message === undefined ? '' : r.message).slice(0, 120),
|
|
252
|
+
state: r.state,
|
|
253
|
+
error: r.error,
|
|
254
|
+
created_at: r.created_at,
|
|
255
|
+
}));
|
|
256
|
+
out.sort((a, b) => {
|
|
257
|
+
if (a.created_at === b.created_at) return 0;
|
|
258
|
+
return a.created_at < b.created_at ? -1 : 1;
|
|
259
|
+
});
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 本会话是否有排队/执行中的消息单元(`m:` 入队后到结束/取消之间)。
|
|
265
|
+
*
|
|
266
|
+
* 看板调和器判定「本卡会话即将或正在由平台消息驱动」用:消息进统一队列后、轮到执行前
|
|
267
|
+
* 会话并不 busy,仅凭 web busy 会漏判——阻塞列卡片在消息排队/执行期间应视作运行中
|
|
268
|
+
* (回「正在开发」列)。表驱动:`state ∈ {queued,running}` 任一行即真。
|
|
269
|
+
*
|
|
270
|
+
* @param {string} sid
|
|
271
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接
|
|
272
|
+
* @returns {boolean}
|
|
273
|
+
*/
|
|
274
|
+
export function liveOfSid(sid, options = {}) {
|
|
275
|
+
if (!sid) return false;
|
|
276
|
+
return waitq.msgRows({ sid, states: [STATE_QUEUED, STATE_RUNNING] }, options).length > 0;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 有**排队中**消息(尚未轮到执行)的卡片 id 集合(看板 payload 批量预计算用)。
|
|
281
|
+
*
|
|
282
|
+
* 数据源 = `waitq.msg_queued_card_ids()`:`chat_msgs` 里 `state='queued'` 且
|
|
283
|
+
* `card_id IS NOT NULL` 的 distinct 集合。卡片会话的「排队中」徽标就靠它——平台已收下
|
|
284
|
+
* 消息、等空闲送达,卡片按「排队中」呈现且不点亮「会话运行中」。
|
|
285
|
+
*
|
|
286
|
+
* ⚠ **集合元素口径(2026-10-12 M4 归位为 int,与 Python 一致)**:Python 的
|
|
287
|
+
* `chat.queued_card_ids()` 就是 `waitq.msg_queued_card_ids()` 的直通壳(`chat.py:438`),
|
|
288
|
+
* 返回 **int 集合**;M3 的 Node 只读子集把实现放在了本文件并改成**字符串**集合、
|
|
289
|
+
* `board.js` 配套按 `String(r.id)` 查——两侧自洽,所以读端点对拍看不出差异(值只喂给
|
|
290
|
+
* `card_json` 的布尔位)。M4 的 op 级对拍把它照出来了(集合元素类型即契约),故:
|
|
291
|
+
* 实现归位 `waitq.msgQueuedCardIds`(int),本函数退化为**直通**(保留导出名,
|
|
292
|
+
* 兼容 M3 的 `board.js` 调用面),`board.js` 改成按 `Number(r.id)` 查。
|
|
293
|
+
* 非数字 `card_id`(TEXT 存量行的理论形态)在 Node 侧是 `NaN`——Python 侧 `int()`
|
|
294
|
+
* 直接抛,两侧都属不可达形态,不额外兜。
|
|
295
|
+
*
|
|
296
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接
|
|
297
|
+
* @returns {Set<number>}
|
|
298
|
+
*/
|
|
299
|
+
export function queuedCardIds(options = {}) {
|
|
300
|
+
return waitq.msgQueuedCardIds(options);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 消息记录只读摘要(端点归属校验用):
|
|
305
|
+
* `{id, sid, project_id, task_id, card_id, state}`。记录不存在返回 null。
|
|
306
|
+
*
|
|
307
|
+
* `state` 的口径(照 Python,P4 起):**优先读等待项行**(`waitq.getActive(KIND_MSG,…)`
|
|
308
|
+
* 的 `state`)——行态才是队列权威(waiting/starting/running/…);行不在场时回落
|
|
309
|
+
* `chat_msgs.state`(queued/running/done/error/cancelled)。
|
|
310
|
+
*
|
|
311
|
+
* @param {string} msgId
|
|
312
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接
|
|
313
|
+
* @returns {{id: string, sid: string, project_id: number, task_id: number|null,
|
|
314
|
+
* card_id: number|null, state: string}|null}
|
|
315
|
+
*/
|
|
316
|
+
export function msgInfo(msgId, options = {}) {
|
|
317
|
+
const r = msgId ? waitq.msgGet(msgId, options) : null;
|
|
318
|
+
if (r === null) return null;
|
|
319
|
+
// `state` **取 chat_msgs 行**(照 Python `msg_info`:`{"state": r["state"]}`,chat.py:449)。
|
|
320
|
+
// M4 的实现曾在「有活跃等待项行」时改取等待项行的 state——那是自创口径:两表在竞态窗口内
|
|
321
|
+
// 本就不同步,谁覆盖谁都没有依据,而 Python 是唯一真源。M4.5 由 S2 报告点出(现状零影响,
|
|
322
|
+
// 因为唯一调用方 `_inject_msg` 只读 id/sid/project_id/task_id,但它会潜伏到有人按 state
|
|
323
|
+
// 判「还在不在排队」的那天)。
|
|
324
|
+
return {
|
|
325
|
+
id: r.id,
|
|
326
|
+
sid: r.sid,
|
|
327
|
+
project_id: r.project_id,
|
|
328
|
+
task_id: r.task_id,
|
|
329
|
+
card_id: r.card_id,
|
|
330
|
+
state: r.state,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* dsh 插件会话是否正在跑 turn(驱动不可用按 false,交由发送路径报错)。
|
|
336
|
+
*
|
|
337
|
+
* 读插件内存态(`/status`)——会话状态由 dsh 侧 `agent/status` 事件维护,这里只是读一次
|
|
338
|
+
* (Python 语)。**Node 侧是 async**(M2 驱动客户端全异步,见文件头同步/异步契约)。
|
|
339
|
+
*
|
|
340
|
+
* 异常口径逐点照 Python:`except dshdriver.DshDriverError: return False` ⇒ 只有
|
|
341
|
+
* `DshDriverError` 被吞成 false;其他异常照常外抛(Python 同)。Python 的
|
|
342
|
+
* `response.get("status")` 在响应非 dict 时会 AttributeError(不是 DshDriverError ⇒
|
|
343
|
+
* 上抛),Node 用 `(st || {})` 会把它吞成 false ⇒ **有意的宽容化**(文件头差异②),
|
|
344
|
+
* 与 M2 `dshdriver.js` 文件头差异⑦「宽容读」同一口径。
|
|
345
|
+
*
|
|
346
|
+
* @param {string} sid
|
|
347
|
+
* @returns {Promise<boolean>}
|
|
348
|
+
*/
|
|
349
|
+
export async function dshBusy(sid) {
|
|
350
|
+
try {
|
|
351
|
+
const st = await driverStatus(sid);
|
|
352
|
+
return (st || {}).status === 'running';
|
|
353
|
+
} catch (err) {
|
|
354
|
+
if (err && err.name === 'DshDriverError') return false;
|
|
355
|
+
throw err;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 对话状态:`{running, queued, msgs, started_at?, message?, exit_code?}`。
|
|
361
|
+
*
|
|
362
|
+
* `running` = 有进行中的对话(dsh 族读插件内存态会话状态);`queued` = 排队中消息数;
|
|
363
|
+
* `msgs` = 排队/执行中与近期失败消息(前端排队 chip 与失败提示用)。
|
|
364
|
+
*
|
|
365
|
+
* 判定阶梯与 Python 逐条同(`chat.py:1141-1165`):
|
|
366
|
+
* ① 先算 `base`(表驱动,**再**判 sid——故空 sid 也照样把 `queued`/`msgs` 算出来);
|
|
367
|
+
* ② 无 sid / 无登记记录 / 非 dsh 记录 ⇒ 直接返回 base(**不做任何 I/O**,与 Python 早退同支);
|
|
368
|
+
* ③ 有 dsh 记录 ⇒ `dshBusy(sid)`;`!busy` 且 `exit_code === null` 时**顺手把进程内记录的
|
|
369
|
+
* `exit_code` 写成 0**(Python 原样如此,不落库),返回带三键的对象。
|
|
370
|
+
* **async**(`dshBusy` 是远端读):调用方一律 `await`,否则拿到的是 Promise 真值。
|
|
371
|
+
*
|
|
372
|
+
* ⚠ 返回的 base 是**新对象**(每次调用现造):调用方可以就地改字段(`routes/sessions.js`
|
|
373
|
+
* 就地把 `running` 与看板 busy 合并),不会污染登记表或影响下一次调用。
|
|
374
|
+
*
|
|
375
|
+
* @param {string} sid
|
|
376
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接
|
|
377
|
+
* @returns {Promise<{running: boolean, queued: number, msgs: object[],
|
|
378
|
+
* started_at?: number, message?: string, exit_code?: number|null}>}
|
|
379
|
+
*/
|
|
380
|
+
export async function state(sid, options = {}) {
|
|
381
|
+
const msgs = msgsOfSid(sid, options);
|
|
382
|
+
const base = {
|
|
383
|
+
running: false,
|
|
384
|
+
queued: msgs.filter((m) => m.state === STATE_QUEUED).length,
|
|
385
|
+
msgs,
|
|
386
|
+
};
|
|
387
|
+
if (!sid) return base; // 与 Python 的 `if not sid: return base` 同支
|
|
388
|
+
const rec = CHAT_REGISTRY.get(sid);
|
|
389
|
+
if (rec === undefined) return base; // 与 Python 的 `rec is None: return base` 同支
|
|
390
|
+
if (!rec.dsh_plugin) return base; // 防呆:非 dsh 记录不再产生,按空闲呈现
|
|
391
|
+
const busy = await dshBusy(sid);
|
|
392
|
+
if (!busy && rec.exit_code === null) rec.exit_code = 0;
|
|
393
|
+
return {
|
|
394
|
+
...base,
|
|
395
|
+
running: busy,
|
|
396
|
+
started_at: rec.started_at,
|
|
397
|
+
message: rec.message,
|
|
398
|
+
exit_code: rec.exit_code,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* 是否有进行中的对话(Python `state(sid)["running"]` 的直读)。
|
|
404
|
+
* **async**,与 `state` 同(**调用方一律 await**,见文件头差异①)。
|
|
405
|
+
*
|
|
406
|
+
* @param {string} sid
|
|
407
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接
|
|
408
|
+
* @returns {Promise<boolean>}
|
|
409
|
+
*/
|
|
410
|
+
export async function running(sid, options = {}) {
|
|
411
|
+
return (await state(sid, options)).running;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* 项目 agent 族(`chat.submit_task` 里 `runner.agent_family(project["agent_path"])` 的
|
|
416
|
+
* 等价物;单族化后只回 `dsh_plugin` / `retired`)。
|
|
417
|
+
*
|
|
418
|
+
* 本函数是 `agents.agentFamily` 的**直通**——放在这里的唯一理由是让可续聊白名单
|
|
419
|
+
* (`CHAT_FAMILIES`)有一个入口可查;不复制族判定逻辑(唯一真源在 `agents.js`)。
|
|
420
|
+
*
|
|
421
|
+
* @param {string} agentPath
|
|
422
|
+
* @returns {string}
|
|
423
|
+
*/
|
|
424
|
+
export function familyOfProject(agentPath) {
|
|
425
|
+
return agentFamily(agentPath);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
429
|
+
// M4:消息生命周期(发号 / 前置闸 / submit / 执行体 / 取消 / 注入 / 投递 / 等 turn)
|
|
430
|
+
// 函数顺序尽量对齐 chat.py:112(_new_msg_id)→ 1189(stop)
|
|
431
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
432
|
+
|
|
433
|
+
// --------------------------------------------------------------------------
|
|
434
|
+
// 小工具(纯函数,全部同步)
|
|
435
|
+
// --------------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Python `str(e)` 的等价物:异常 → 可落库/可打日志的中文文本。
|
|
439
|
+
*
|
|
440
|
+
* 为什么不能直接 `String(err)`:`new Error('消息行不存在')` 的 `String()` 是
|
|
441
|
+
* `"Error: 消息行不存在"`,而 Python `str(RuntimeError('消息行不存在'))` 是
|
|
442
|
+
* `"消息行不存在"`——这个文本会进 `chat_msgs.error` 与端点响应,必须逐字一致。
|
|
443
|
+
* `DshDriverError` 覆写了 `toString()`(纯消息),故走 `String()`。
|
|
444
|
+
*
|
|
445
|
+
* @param {unknown} err
|
|
446
|
+
* @returns {string}
|
|
447
|
+
*/
|
|
448
|
+
function errText(err) {
|
|
449
|
+
if (err === null || err === undefined) return String(err);
|
|
450
|
+
if (err instanceof DshDriverError) return String(err);
|
|
451
|
+
if (err instanceof Error) return err.message;
|
|
452
|
+
return String(err);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Python `float(raw)` 的等价物(取不到/非法一律回 `fallback`)。
|
|
457
|
+
*
|
|
458
|
+
* 覆盖下划线数字字面量(`float('1_000')` = 1000.0)、`inf`/`nan` 三种拼写;
|
|
459
|
+
* 显式拒绝 `0x`/`0o`/`0b` 前缀(Python `float()` 对它们抛 ValueError ⇒ 回落常量,
|
|
460
|
+
* 而 JS 的 `Number('0x10')` 会给 16,方向相反)。
|
|
461
|
+
*
|
|
462
|
+
* @param {unknown} raw
|
|
463
|
+
* @param {number} [fallback]
|
|
464
|
+
* @returns {number}
|
|
465
|
+
*/
|
|
466
|
+
function pyFloatOr(raw, fallback = 0) {
|
|
467
|
+
const text = String(raw === null || raw === undefined ? '' : raw).trim();
|
|
468
|
+
if (!text) return fallback;
|
|
469
|
+
const norm = text.replace(/_/g, '');
|
|
470
|
+
const lower = norm.toLowerCase();
|
|
471
|
+
if (lower === 'inf' || lower === '+inf' || lower === 'infinity' || lower === '+infinity') {
|
|
472
|
+
return Infinity;
|
|
473
|
+
}
|
|
474
|
+
if (lower === '-inf' || lower === '-infinity') return -Infinity;
|
|
475
|
+
if (lower === 'nan' || lower === '+nan' || lower === '-nan') return NaN;
|
|
476
|
+
if (/^[+-]?0[xob]/i.test(norm)) return fallback;
|
|
477
|
+
const val = Number(norm);
|
|
478
|
+
return Number.isFinite(val) || Number.isNaN(val) ? val : fallback;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Python `f"{x:g}"` 的等价物(6 位有效数字、去尾零、指数阈值 −4/6)——只服务超时文案。
|
|
483
|
+
*
|
|
484
|
+
* @param {number} value
|
|
485
|
+
* @returns {string}
|
|
486
|
+
*/
|
|
487
|
+
function pyG(value) {
|
|
488
|
+
const n = Number(value);
|
|
489
|
+
if (!Number.isFinite(n)) return String(n);
|
|
490
|
+
if (n === 0) return '0';
|
|
491
|
+
const prec = 6;
|
|
492
|
+
const exp = Math.floor(Math.log10(Math.abs(n)));
|
|
493
|
+
if (exp < -4 || exp >= prec) {
|
|
494
|
+
const [mant0, expPart] = n.toExponential(prec - 1).split('e');
|
|
495
|
+
let mant = mant0;
|
|
496
|
+
if (mant.includes('.')) mant = mant.replace(/0+$/, '').replace(/\.$/, '');
|
|
497
|
+
const sign = expPart.startsWith('-') ? '-' : '+';
|
|
498
|
+
const abs = String(Math.abs(Number(expPart))).padStart(2, '0');
|
|
499
|
+
return `${mant}e${sign}${abs}`;
|
|
500
|
+
}
|
|
501
|
+
let text = n.toPrecision(prec);
|
|
502
|
+
if (text.includes('.')) text = text.replace(/0+$/, '').replace(/\.$/, '');
|
|
503
|
+
return text;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* `since` / seq 归一成整数(对齐 Python `int(x or 0)`;坏值一律 0)。
|
|
508
|
+
* @param {unknown} value
|
|
509
|
+
* @returns {number}
|
|
510
|
+
*/
|
|
511
|
+
function toIntSeq(value) {
|
|
512
|
+
if (value === null || value === undefined || value === '' || value === false) return 0;
|
|
513
|
+
const n = Number(value);
|
|
514
|
+
return Number.isFinite(n) ? Math.trunc(n) : 0;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* 等待项行 meta 解析(`_msg_meta`):坏 JSON / 非对象按空对象。
|
|
519
|
+
*
|
|
520
|
+
* 与 `waitq.msgMeta` 的区别:本函数吃**调用方手上那一行**(活跃行),不做二次查询
|
|
521
|
+
* ——Python `_msg_meta(wrow)` 的调用点全部如此。
|
|
522
|
+
*
|
|
523
|
+
* @param {object|null} wrow 等待项行(可为 null)
|
|
524
|
+
* @returns {object}
|
|
525
|
+
*/
|
|
526
|
+
function parseMeta(wrow) {
|
|
527
|
+
const raw = wrow === null || wrow === undefined ? '' : wrow.meta;
|
|
528
|
+
let meta;
|
|
529
|
+
try {
|
|
530
|
+
meta = JSON.parse(raw || '{}');
|
|
531
|
+
} catch {
|
|
532
|
+
return {};
|
|
533
|
+
}
|
|
534
|
+
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return {};
|
|
535
|
+
return meta;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* 运行产物目录(`lib.runtime_dir`):优先用 `lib.js` 的共享实现。
|
|
540
|
+
* @param {string} workDir
|
|
541
|
+
* @param {string} name
|
|
542
|
+
* @returns {string}
|
|
543
|
+
*/
|
|
544
|
+
function runtimeDir(workDir, name) {
|
|
545
|
+
if (typeof libMod.runtimeDir === 'function') return libMod.runtimeDir(workDir, name);
|
|
546
|
+
return path.join(workDir, name);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* 运行产物目录名:优先用 `runner.js` 的共享常量(单一真源),缺则回落本地兜底。
|
|
551
|
+
* @returns {string}
|
|
552
|
+
*/
|
|
553
|
+
function logDirName() {
|
|
554
|
+
return typeof runnerMod.LOG_DIR_NAME === 'string' ? runnerMod.LOG_DIR_NAME : LOG_DIR_NAME;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* 运行产物目录清单:优先用 `lib.js` 的共享常量(单一真源),缺则回落本地兜底。
|
|
559
|
+
* @returns {string[]}
|
|
560
|
+
*/
|
|
561
|
+
function runtimeDirNames() {
|
|
562
|
+
return Array.isArray(libMod.RUNTIME_DIR_NAMES) ? [...libMod.RUNTIME_DIR_NAMES] : RUNTIME_DIR_NAMES;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 追加文本到日志(`runner.append_log`,runner.py:2171):以二进制写、utf-8、吞掉所有
|
|
567
|
+
* 写失败(空路径/目录不存在/无权限都不许影响队列收口)。
|
|
568
|
+
*
|
|
569
|
+
* 优先用 `runner.js` 的共享实现(M4 若补上就该走它);暂缺时本地等价兜底(文件头差异⑧)。
|
|
570
|
+
*
|
|
571
|
+
* @param {string} logPath
|
|
572
|
+
* @param {string} text
|
|
573
|
+
*/
|
|
574
|
+
function appendLog(logPath, text) {
|
|
575
|
+
if (typeof runnerMod.appendLog === 'function') {
|
|
576
|
+
runnerMod.appendLog(logPath, text);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (!logPath) return;
|
|
580
|
+
try {
|
|
581
|
+
fs.appendFileSync(logPath, Buffer.from(text, 'utf8'));
|
|
582
|
+
} catch {
|
|
583
|
+
// 与 Python `except OSError: pass` 同:日志写失败绝不影响投递/收口
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* 确保运行产物目录与 `.gitignore` 就绪(`lib.ensure_runtime_dirs`,lib.py:149)。
|
|
589
|
+
*
|
|
590
|
+
* 优先用 `lib.js` 的共享实现;暂缺时本地等价兜底(文件头差异⑧)。语义逐点:
|
|
591
|
+
* - 建 `.web`/`.live`/`board_media`(已存在跳过,失败静默);
|
|
592
|
+
* - `.gitignore` 缺哪条补哪条(**已有内容一律不动**;文件不存在则新建);
|
|
593
|
+
* - 目录/gitignore 写不进去不抛错(后续日志写入自然报启动失败)。
|
|
594
|
+
*
|
|
595
|
+
* @param {string} workDir
|
|
596
|
+
*/
|
|
597
|
+
function ensureRuntimeDirs(workDir) {
|
|
598
|
+
if (typeof libMod.ensureRuntimeDirs === 'function') {
|
|
599
|
+
libMod.ensureRuntimeDirs(workDir);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
const names = runtimeDirNames();
|
|
603
|
+
for (const name of names) {
|
|
604
|
+
try {
|
|
605
|
+
fs.mkdirSync(runtimeDir(workDir, name), { recursive: true });
|
|
606
|
+
} catch {
|
|
607
|
+
// 静默(同 Python `except OSError: pass`)
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const gi = path.join(workDir, '.gitignore');
|
|
611
|
+
let text = '';
|
|
612
|
+
try {
|
|
613
|
+
text = fs.readFileSync(gi, 'utf8');
|
|
614
|
+
} catch {
|
|
615
|
+
text = ''; // 读不到(不存在/无权限):按空文本处理,仍尝试追加
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
// Python `splitlines()`:\n / \r\n / \r 都算换行
|
|
619
|
+
const lines = new Set(text.split(/\r\n|\r|\n/).map((line) => line.trim()));
|
|
620
|
+
const missing = names
|
|
621
|
+
.filter((name) => !lines.has(`${name}/`))
|
|
622
|
+
.map((name) => `${name}/`);
|
|
623
|
+
if (missing.length) {
|
|
624
|
+
let out = '';
|
|
625
|
+
if (text && !text.endsWith('\n')) out += '\n';
|
|
626
|
+
out += `${missing.join('\n')}\n`;
|
|
627
|
+
fs.appendFileSync(gi, out, 'utf8');
|
|
628
|
+
}
|
|
629
|
+
} catch {
|
|
630
|
+
// 静默(同 Python `except OSError: pass`)
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// --------------------------------------------------------------------------
|
|
635
|
+
// waitq M4 写口的运行期符号门(并行移植窗口;文件头差异⑦)
|
|
636
|
+
// --------------------------------------------------------------------------
|
|
637
|
+
|
|
638
|
+
/** M4 需要的 waitq 写口符号(缺谁报谁;只读口不在表内——它们是 M3 已冻结的存量) */
|
|
639
|
+
const WQ_WRITE_SYMBOLS = Object.freeze([
|
|
640
|
+
'msgEnqueue', 'msgPrune', 'msgClaim', 'msgRequeue', 'msgFinish', 'msgCancel',
|
|
641
|
+
'finishByTarget', 'markEvidence', 'claim', 'cancel', 'returnToWaiting',
|
|
642
|
+
]);
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* 当前 `waitq.js` 还缺哪些 M4 写口符号(空数组=齐备)。
|
|
646
|
+
*
|
|
647
|
+
* 存在的理由:命名 import 在**链接期**校验,缺一个符号会让整个 chat.js 加载失败
|
|
648
|
+
* (连只读口都跑不起来);M4 的 waitq 写口是并行移植的另一路,故这里改成运行期查表,
|
|
649
|
+
* 缺符号时抛**指名**的中文错误。合并完成后本函数返回 `[]`,等价于静态约束。
|
|
650
|
+
*
|
|
651
|
+
* @returns {string[]}
|
|
652
|
+
*/
|
|
653
|
+
export function missingWaitqSymbols() {
|
|
654
|
+
return WQ_WRITE_SYMBOLS.filter((name) => typeof waitq[name] !== 'function');
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* 取一个 waitq 写口函数(缺符号时报明确的错,而不是 TypeError)。
|
|
659
|
+
* @param {string} name
|
|
660
|
+
* @returns {Function}
|
|
661
|
+
*/
|
|
662
|
+
function wq(name) {
|
|
663
|
+
const fn = waitq[name];
|
|
664
|
+
if (typeof fn !== 'function') {
|
|
665
|
+
throw new Error(`waitq.js 缺写口符号 ${name}(M4 并行移植未完成,见 chat.js 文件头差异⑦)`);
|
|
666
|
+
}
|
|
667
|
+
return fn;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// --------------------------------------------------------------------------
|
|
671
|
+
// 进程内旁表与注入点
|
|
672
|
+
// --------------------------------------------------------------------------
|
|
673
|
+
|
|
674
|
+
/** sid -> 投递前会话 last_seq:等 turn/end 的 SSE 续传起点(`_DSH_BASELINE`) */
|
|
675
|
+
export const DSH_BASELINE = new Map();
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* sid -> 本次投递是否 steer(`_DSH_INJECT`)。
|
|
679
|
+
*
|
|
680
|
+
* 用途只有一个:`dshWaitTurnViaEvents` 判定「本轮 turn/start 帧早于订阅」时,仅当本次
|
|
681
|
+
* 投递是 steer 注入才按「已在跑」播种 `started`。**每次 `dshSend` 都写**(true/false
|
|
682
|
+
* 都写,旧值必须被覆盖——否则上一次 steer 的残留 true 会毒化下一次 followup 的等待)。
|
|
683
|
+
* 池内等待器 `dshWaitTurn` 不读它。
|
|
684
|
+
*/
|
|
685
|
+
export const DSH_INJECT = new Map();
|
|
686
|
+
|
|
687
|
+
/** 消息单元终态钩子 `fn(msgId, state)`(`_MSG_DONE_HOOK`;单一注册位,后注册覆盖先注册) */
|
|
688
|
+
let MSG_DONE_HOOK = null;
|
|
689
|
+
|
|
690
|
+
/** board 投递桥(M5 缝;未注册时三处依赖按文件头「M5 缝」各走响亮失败) */
|
|
691
|
+
let BOARD_BRIDGE = null;
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* 注册 board 投递桥(M5 接线口):`{ensureWatch, deliverUnit, deliverNow}`。
|
|
695
|
+
*
|
|
696
|
+
* - `ensureWatch(sid)` → boolean:对应 `board._ensure_watch`(外部会话投递前置闸第⑤步);
|
|
697
|
+
* - `deliverUnit(project, card, comment, message, inject, options)`:对应
|
|
698
|
+
* `board._deliver_unit`(卡片评论排队投递,等 turn 结束);
|
|
699
|
+
* - `deliverNow(project, card, comment, message, inject, options)`:对应
|
|
700
|
+
* `board._deliver_now`(立即注入,不等 turn)。
|
|
701
|
+
*
|
|
702
|
+
* 未注册(或某个口缺失)时的行为见文件头「M5 缝」:**绝不静默成功**。
|
|
703
|
+
* 传 `null` 注销(回到未接线态)。
|
|
704
|
+
*
|
|
705
|
+
* @param {{ensureWatch?: Function, deliverUnit?: Function, deliverNow?: Function}|null} bridge
|
|
706
|
+
*/
|
|
707
|
+
export function setBoardBridge(bridge) {
|
|
708
|
+
BOARD_BRIDGE = bridge || null;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** 测试注入点:runner 单例(生产链路不调用;见 `runnerInstance`) */
|
|
712
|
+
let RUNNER_TEST_INSTANCE;
|
|
713
|
+
let RUNNER_TEST_SET = false;
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* 注入 runner 替身(**测试专用**;`setRunnerInstance(null)` 恢复读真单例)。
|
|
717
|
+
*
|
|
718
|
+
* 为什么需要这个口:Python 测试直接 `runner.INSTANCE = Fake()`,而 Node 的 ESM 命名空间
|
|
719
|
+
* 对象不可写(`runnerMod.INSTANCE = x` 会 TypeError)。本函数是 chat 侧**唯一**的 runner
|
|
720
|
+
* 单例读点(`runnerInstance`)的注入面,生产链路从不调用。
|
|
721
|
+
*
|
|
722
|
+
* @param {object|null} inst
|
|
723
|
+
*/
|
|
724
|
+
export function setRunnerInstance(inst) {
|
|
725
|
+
RUNNER_TEST_INSTANCE = inst === undefined ? null : inst;
|
|
726
|
+
RUNNER_TEST_SET = true;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* 当前 runner 单例(Python `runner.INSTANCE`)。
|
|
731
|
+
*
|
|
732
|
+
* 读 `runner.js` 的模块级可写单例(`export let INSTANCE`)——命名空间对象按属性读,
|
|
733
|
+
* 每次调用都取最新绑定值(Python 的模块全局语义)。
|
|
734
|
+
*
|
|
735
|
+
* @returns {object|null}
|
|
736
|
+
*/
|
|
737
|
+
function runnerInstance() {
|
|
738
|
+
if (RUNNER_TEST_SET) return RUNNER_TEST_INSTANCE;
|
|
739
|
+
return runnerMod.INSTANCE === undefined ? null : runnerMod.INSTANCE;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// --------------------------------------------------------------------------
|
|
743
|
+
// 发号与外部会话前置闸(chat.py:112-166)
|
|
744
|
+
// --------------------------------------------------------------------------
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* 消息 id:毫秒时间戳-自增序号(无冒号,可直接作队列键后缀)。
|
|
748
|
+
*
|
|
749
|
+
* Python 用模块锁保护 `_MSG_SEQ`(与 `_CHATS` 共用);Node 单事件循环下自增天然原子
|
|
750
|
+
* (见文件头「不再需要锁」),故本函数**同步且无锁**。
|
|
751
|
+
*
|
|
752
|
+
* @returns {string}
|
|
753
|
+
*/
|
|
754
|
+
export function newMsgId() {
|
|
755
|
+
MSG_SEQ += 1;
|
|
756
|
+
return `${Date.now()}-${MSG_SEQ}`;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* 外部会话投递是否开启(`TS_EXTERNAL_DELIVER=0` 关闭;缺省/非法值按开启)。
|
|
761
|
+
*
|
|
762
|
+
* **每次调用现读**(与 `dshWaitTurnLimit` 同口径):值在进程内可热改,不必重启平台。
|
|
763
|
+
*
|
|
764
|
+
* @param {{env?: NodeJS.ProcessEnv}} [options]
|
|
765
|
+
* @returns {boolean}
|
|
766
|
+
*/
|
|
767
|
+
export function externalDeliverEnabled({ env = process.env } = {}) {
|
|
768
|
+
return String(env[EXTERNAL_DELIVER_ENV] || '').trim() !== '0';
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* 投递前置闸(C 批 T5):外部会话前提不成立时 **抛 `DeliveryRefused`**(不入队)。
|
|
773
|
+
*
|
|
774
|
+
* 判定阶梯(设计 §5.2;顺序即优先级):
|
|
775
|
+
* ① 空 sid ⇒ 放行(既有路径自己报「会话 id 为空」,不改语义);
|
|
776
|
+
* ② 注册表未知(`dshevents.get` 为 null:未连接 / 没见过该 sid)⇒ 放行;
|
|
777
|
+
* ③ 注册表未对齐(`dshevents.aligned()` 为假:热重载后 /live 空表,快照不可信)⇒ 放行
|
|
778
|
+
* ——**未知 ≠ 外部**,绝不据不可信快照拒投(与「断连=未知」同一不变量);
|
|
779
|
+
* ④ `owned` 为真(平台自持会话)⇒ 放行,走池内原路**一个字节不变**;
|
|
780
|
+
* ⑤ 其余=外部会话(用户在 dsh GUI 里直跑/接管):要求「宿主有活 agent」
|
|
781
|
+
* (`status != 'unknown'`)∧「看管声明成功」(`board._ensure_watch`,幂等、失败不重试)
|
|
782
|
+
* ——任一不满足即拒投,并按分类文案抛错。
|
|
783
|
+
* 回滚阀 `TS_EXTERNAL_DELIVER=0`:第⑤步整段退化为「外部会话一律拒绝」(连看管都不声明),
|
|
784
|
+
* 池内/未知路径照旧放行。
|
|
785
|
+
*
|
|
786
|
+
* 为什么放在 `submit` 首行:投递的**唯一入口**是统一队列(chat_msgs 行 + m: 等待项一个
|
|
787
|
+
* 事务),一旦落行就必然要等 worker 拾取、失败也只能落 error 终态——闸必须在落行之前。
|
|
788
|
+
*
|
|
789
|
+
* @param {string} sid
|
|
790
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
791
|
+
* @throws {DeliveryRefused}
|
|
792
|
+
*/
|
|
793
|
+
export function externalPreflight(sid, options = {}) {
|
|
794
|
+
if (!sid) return;
|
|
795
|
+
const st = dshevents.get(sid);
|
|
796
|
+
if (st === null) return; // 未知:保持现状(真失败仍由投递错误兜底)
|
|
797
|
+
if (!dshevents.aligned()) return; // 不可信快照:不据此拒投(未知 ≠ 外部)
|
|
798
|
+
if (st.owned) return; // 平台自持会话:池内原路
|
|
799
|
+
// —— 以下为外部会话(用户在 dsh GUI 直跑/接管):需要显式许可 ——
|
|
800
|
+
if (!externalDeliverEnabled(options)) {
|
|
801
|
+
throw new DeliveryRefused(
|
|
802
|
+
'该会话是 dsh 直跑会话,平台已关闭外部会话投递'
|
|
803
|
+
+ `(${EXTERNAL_DELIVER_ENV}=0),请到 dsh 会话窗口发送`,
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
if (String(st.status || '') === 'unknown') {
|
|
807
|
+
// 宿主里没有活 agent(会话已结束/被销毁):投递必然 404,别让用户白等
|
|
808
|
+
throw new DeliveryRefused('会话已结束,无法投递(宿主无活动 agent)');
|
|
809
|
+
}
|
|
810
|
+
// 看管声明(`board._ensure_watch`):M5 未接线时视作**返回 false**(文件头 M5 缝①)
|
|
811
|
+
const bridge = BOARD_BRIDGE;
|
|
812
|
+
const ensureWatch = bridge !== null && typeof bridge.ensureWatch === 'function'
|
|
813
|
+
? bridge.ensureWatch : null;
|
|
814
|
+
if (ensureWatch === null || !ensureWatch(sid)) {
|
|
815
|
+
throw new DeliveryRefused(
|
|
816
|
+
'该会话是 dsh 直跑会话,平台未获投递许可(看管声明失败:插件过旧或不可达),'
|
|
817
|
+
+ '请到 dsh 会话窗口发送',
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// --------------------------------------------------------------------------
|
|
823
|
+
// 提交消息单元(chat.py:169-214)
|
|
824
|
+
// --------------------------------------------------------------------------
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* 登记一条会话消息并投入统一队列(项目忙则排队,空闲则立即执行)。
|
|
828
|
+
*
|
|
829
|
+
* P3 起权威在 `chat_msgs` 表 + `msg` 等待项(`waitq.msgEnqueue` 一个事务双写)。执行体不用
|
|
830
|
+
* 闭包(闭包不可持久化):`family`/`model` 在提交时定格进等待项 meta,执行时按行+快照重建
|
|
831
|
+
* (`rebuildRun`,与闭包捕获语义等价);dsh_plugin 的消息可「立即注入」(`rebuildInject`)。
|
|
832
|
+
*
|
|
833
|
+
* 参数映射(Python → Node,业务参数收成一个对象、末位仍是同一份 `options`):
|
|
834
|
+
* `task_id` / `card_id` / `comment_id` / `inject` / `family` / `model` / `extra_meta`
|
|
835
|
+
* → `taskId` / `cardId` / `commentId` / `inject` / `family` / `model` / `extraMeta`。
|
|
836
|
+
* `comment_id`:卡片评论投递路径的评论行 id(终态写回 `db.update_board_comment` 用),
|
|
837
|
+
* 随 meta 持久化(chat_msgs 无此列,P3 裁决 R1)。
|
|
838
|
+
* `extraMeta`:调用方附加载荷(如飞书回流的 `{feishu: {...}}`),键与 family/model/
|
|
839
|
+
* comment_id **平铺**进同一个 meta;键名由调用方自带命名空间。**覆盖顺序照 Python**:
|
|
840
|
+
* family/model → comment_id(有值时)→ extraMeta(可覆盖前两者)。
|
|
841
|
+
* `taskId` 与 `cardId` 同时为空=纯会话消息(飞书通用对话等):无任务轮次、无卡片评论
|
|
842
|
+
* 写回,按行重建时走纯会话投递分支。
|
|
843
|
+
* 无 runner 单例(单测/独立脚本)时**同步执行并按原样上抛异常**(保持既有行为,行照常落表、
|
|
844
|
+
* 不写等待项——P5 R13 收口:无拾取方,等待项只会成为孤儿行);该分支返回终态记录。
|
|
845
|
+
*
|
|
846
|
+
* **async**:无 runner 分支要 await 投递与等 turn(Python 是阻塞线程)。
|
|
847
|
+
*
|
|
848
|
+
* @param {number} projectId
|
|
849
|
+
* @param {string} sid 会话 id
|
|
850
|
+
* @param {string} message 消息原文
|
|
851
|
+
* @param {{taskId?: number|null, cardId?: number|null, commentId?: number|null,
|
|
852
|
+
* inject?: boolean, family?: string, model?: string, extraMeta?: object|null,
|
|
853
|
+
* db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
854
|
+
* @returns {Promise<{id: string, state: string, queued: boolean}>}
|
|
855
|
+
* @throws {DeliveryRefused} 外部会话前置闸拒投(消息**未落行**)
|
|
856
|
+
*/
|
|
857
|
+
export async function submit(projectId, sid, message, options = {}) {
|
|
858
|
+
const {
|
|
859
|
+
taskId = null, cardId = null, commentId = null, inject = false,
|
|
860
|
+
family = '', model = '', extraMeta = null,
|
|
861
|
+
} = options;
|
|
862
|
+
externalPreflight(sid, options); // 首行前置闸:外部会话未获许可即拒投(不落行)
|
|
863
|
+
const msgId = newMsgId();
|
|
864
|
+
wq('msgPrune')(MSG_KEEP_SEC, MSG_MAX, options); // 终态回收(msg_prune 表版,R8)
|
|
865
|
+
const inst = runnerInstance();
|
|
866
|
+
const payloadMeta = {
|
|
867
|
+
family,
|
|
868
|
+
model,
|
|
869
|
+
...(commentId ? { comment_id: commentId } : {}),
|
|
870
|
+
...(extraMeta || {}),
|
|
871
|
+
};
|
|
872
|
+
wq('msgEnqueue')(msgId, projectId, sid, message, taskId, cardId, Boolean(inject),
|
|
873
|
+
payloadMeta, inst !== null, options);
|
|
874
|
+
if (inst === null) {
|
|
875
|
+
// 无统一队列:同步执行(异常上抛);消息行照常落表、不写等待项(P5 R13)。
|
|
876
|
+
// 载荷不经等待行重建——提交参数直传(与 Python `_rebuild_run(msg_id, meta=payload_meta)` 同)。
|
|
877
|
+
if (!wq('msgClaim')(msgId, options)) {
|
|
878
|
+
return { id: msgId, state: STATE_QUEUED, queued: false };
|
|
879
|
+
}
|
|
880
|
+
let res;
|
|
881
|
+
try {
|
|
882
|
+
res = await rebuildRun(msgId, payloadMeta, options)();
|
|
883
|
+
} catch (err) {
|
|
884
|
+
// error 落行(⑧)+ 随异常上抛给端点
|
|
885
|
+
wq('msgFinish')(msgId, STATE_ERROR, errText(err).slice(0, 300), options);
|
|
886
|
+
throw err;
|
|
887
|
+
}
|
|
888
|
+
const final = res === STATE_YIELDED ? STATE_YIELDED : STATE_DONE;
|
|
889
|
+
wq('msgFinish')(msgId, final, '', options);
|
|
890
|
+
return { id: msgId, state: final, queued: false };
|
|
891
|
+
}
|
|
892
|
+
// 提交时项目已被占用 ⇒ 本消息必然排队(前台据此显示「排队中」)
|
|
893
|
+
const queued = projectBusy(projectId, options);
|
|
894
|
+
inst.submitMsg(msgId, projectId, sid);
|
|
895
|
+
return { id: msgId, state: STATE_QUEUED, queued };
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* 项目是否忙(Python `Runner.unit_busy(project_id)`):M3 起 `runner.js` 已把它实现为
|
|
900
|
+
* **模块级纯表读**(其文件头写明它就是 `chat.submit` 的 queued 字段数据源),故优先用它;
|
|
901
|
+
* 退回实例方法只作兼容(旧的/自定义 runner 单例)。
|
|
902
|
+
*
|
|
903
|
+
* @param {number} projectId
|
|
904
|
+
* @param {object} options
|
|
905
|
+
* @returns {boolean}
|
|
906
|
+
*/
|
|
907
|
+
function projectBusy(projectId, options) {
|
|
908
|
+
if (typeof runnerMod.unitBusy === 'function') {
|
|
909
|
+
return Boolean(runnerMod.unitBusy(projectId, options));
|
|
910
|
+
}
|
|
911
|
+
const inst = runnerInstance();
|
|
912
|
+
if (inst !== null && typeof inst.unitBusy === 'function') {
|
|
913
|
+
return Boolean(inst.unitBusy(projectId));
|
|
914
|
+
}
|
|
915
|
+
return false;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// --------------------------------------------------------------------------
|
|
919
|
+
// 执行体重建(chat.py:217-290)
|
|
920
|
+
// --------------------------------------------------------------------------
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* 执行体重建的公共载荷(消息行 / 等待项 meta / 项目行)。**同步**(不许 await)。
|
|
924
|
+
*
|
|
925
|
+
* `family`/`model` 为提交时快照(裁决 R2,与闭包捕获等价);`project` 行执行时现读
|
|
926
|
+
* (与任务轮次同口径);项目缺失抛错(落消息 error,文案与 Python 逐字同)。
|
|
927
|
+
*
|
|
928
|
+
* @param {string} msgId
|
|
929
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
930
|
+
* @returns {{row: object, meta: object, project: object}}
|
|
931
|
+
*/
|
|
932
|
+
export function msgPayload(msgId, options = {}) {
|
|
933
|
+
const row = waitq.msgGet(msgId, options);
|
|
934
|
+
if (row === null) throw new Error('消息行不存在');
|
|
935
|
+
const meta = parseMeta(waitq.getActive(waitq.KIND_MSG, msgId, options));
|
|
936
|
+
const project = getProject(row.project_id, options);
|
|
937
|
+
if (project === null || project === undefined) throw new Error('项目不存在');
|
|
938
|
+
return { row, meta, project };
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* 按行重建消息执行体(P3:闭包改按 kind 分派——设计 §5.3/§6.2)。
|
|
943
|
+
*
|
|
944
|
+
* 三条分支(顺序照 Python):
|
|
945
|
+
* ① 纯会话消息(task_id 与 card_id 皆空,如飞书通用对话)→ `sendNow`(合成 task dict 只为
|
|
946
|
+
* 沿用既有调用形状:单族世界里 `sendNow` 只做族校验与 dsh 分派,不消费其中的 id/model);
|
|
947
|
+
* ② 任务侧消息(task_id 非空)→ `sendNow`(合成 task dict 同上);
|
|
948
|
+
* ③ 卡片评论(card_id 非空)→ `board._deliver_unit`(合成 card/comment dict,执行体只消费
|
|
949
|
+
* id/session_id/model 与 comment id)——**M5 未接线即抛**(文件头 M5 缝②)。
|
|
950
|
+
* `meta`:调用方已有载荷时直接给定(P5 R13 同步路径不写等待项,提交时定格的 family/model
|
|
951
|
+
* 无行可重建,由 `submit` 透传;缺省读等待项行)。**载荷读取同步发生**(与 Python 的
|
|
952
|
+
* `row, _meta, project = _msg_payload(msg_id)` 同一时序),返回的 thunk 才是 async。
|
|
953
|
+
*
|
|
954
|
+
* @param {string} msgId
|
|
955
|
+
* @param {object|null} [meta]
|
|
956
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
957
|
+
* @returns {() => Promise<string|null>}
|
|
958
|
+
*/
|
|
959
|
+
export function rebuildRun(msgId, meta = null, options = {}) {
|
|
960
|
+
const { row, meta: rowMeta, project } = msgPayload(msgId, options);
|
|
961
|
+
const useMeta = meta === null || meta === undefined ? rowMeta : meta;
|
|
962
|
+
if (row.task_id === null && row.card_id === null) {
|
|
963
|
+
return async () => sendNow({ id: null, model: useMeta.model || '' }, project,
|
|
964
|
+
useMeta.family || '', row.sid, row.message, Boolean(row.inject), options);
|
|
965
|
+
}
|
|
966
|
+
if (row.task_id !== null) {
|
|
967
|
+
const task = { id: row.task_id, model: useMeta.model || '' };
|
|
968
|
+
return async () => sendNow(task, project, useMeta.family || '', row.sid,
|
|
969
|
+
row.message, Boolean(row.inject), options);
|
|
970
|
+
}
|
|
971
|
+
const card = { id: row.card_id, session_id: row.sid, model: useMeta.model || '' };
|
|
972
|
+
const comment = { id: useMeta.comment_id || 0 };
|
|
973
|
+
return async () => {
|
|
974
|
+
const bridge = BOARD_BRIDGE;
|
|
975
|
+
if (bridge === null || typeof bridge.deliverUnit !== 'function') {
|
|
976
|
+
throw new Error(CARD_BRIDGE_MISSING);
|
|
977
|
+
}
|
|
978
|
+
return bridge.deliverUnit(project, card, comment, row.message, Boolean(row.inject), options);
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* 「立即注入」投递体重建(与 `rebuildRun` 同载荷源,chat.py:273-290):
|
|
984
|
+
* ① 纯会话消息(task_id 与 card_id 皆空)→ `injectSend`(steer 注入当前 turn,投递后即
|
|
985
|
+
* 返回,不落卡片);② 任务侧 → `injectSend`(同上,带任务 id);
|
|
986
|
+
* ③ 卡片评论 → `board._deliver_now(inject=True)`——**M5 未接线即抛**(文件头 M5 缝③)。
|
|
987
|
+
* 族不支持由 `injectNow` 前置拦截(读 meta.family)。
|
|
988
|
+
*
|
|
989
|
+
* @param {string} msgId
|
|
990
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
991
|
+
* @returns {() => Promise<void>}
|
|
992
|
+
*/
|
|
993
|
+
export function rebuildInject(msgId, options = {}) {
|
|
994
|
+
const { row, meta, project } = msgPayload(msgId, options);
|
|
995
|
+
if (row.task_id === null && row.card_id === null) {
|
|
996
|
+
return async () => injectSend(project, row.sid, null, row.message, meta.model || '', options);
|
|
997
|
+
}
|
|
998
|
+
if (row.task_id !== null) {
|
|
999
|
+
return async () => injectSend(project, row.sid, row.task_id, row.message,
|
|
1000
|
+
meta.model || '', options);
|
|
1001
|
+
}
|
|
1002
|
+
const card = { id: row.card_id, session_id: row.sid, model: meta.model || '' };
|
|
1003
|
+
const comment = { id: meta.comment_id || 0 };
|
|
1004
|
+
return async () => {
|
|
1005
|
+
const bridge = BOARD_BRIDGE;
|
|
1006
|
+
if (bridge === null || typeof bridge.deliverNow !== 'function') {
|
|
1007
|
+
throw new Error(CARD_BRIDGE_MISSING);
|
|
1008
|
+
}
|
|
1009
|
+
return bridge.deliverNow(project, card, comment, row.message, true, options);
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* 注册/注销(`fn=null`)消息单元终态钩子;回调异常由 `fireMsgDone` 吞掉。
|
|
1015
|
+
*
|
|
1016
|
+
* 单一注册位(后注册覆盖先注册):注册方是 `feishu_conv.start`(server.main 经
|
|
1017
|
+
* `feishu.start_notifier` 调用一次)。未注册时 `runUnit` 行为与加钩子前逐字等价。
|
|
1018
|
+
*
|
|
1019
|
+
* @param {((msgId: string, state: string) => any)|null} fn
|
|
1020
|
+
*/
|
|
1021
|
+
export function setMsgDoneHook(fn) {
|
|
1022
|
+
MSG_DONE_HOOK = fn === undefined ? null : fn;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* 触发消息单元终态钩子(done/yielded/error 各一次);**异常绝不影响队列收口**。
|
|
1027
|
+
*
|
|
1028
|
+
* 钩子是旁路(飞书回流的答复推送):它跑在**终态记录已落库之后**,自身抛错(推送失败、
|
|
1029
|
+
* 会话读失败等)只留痕——队列收口与钩子成败解耦。
|
|
1030
|
+
* Node 增补(文件头差异⑥):钩子返回 thenable 时补一个 `.catch` 留痕(Python 的同步钩子
|
|
1031
|
+
* 没有这个形态,而未处理的 Promise 拒绝在 Node 会杀进程)。
|
|
1032
|
+
*
|
|
1033
|
+
* @param {string} msgId
|
|
1034
|
+
* @param {string} state
|
|
1035
|
+
*/
|
|
1036
|
+
export function fireMsgDone(msgId, state) {
|
|
1037
|
+
const fn = MSG_DONE_HOOK;
|
|
1038
|
+
if (fn === null || fn === undefined) return;
|
|
1039
|
+
try {
|
|
1040
|
+
const ret = fn(msgId, state);
|
|
1041
|
+
if (ret !== null && ret !== undefined && typeof ret.then === 'function') {
|
|
1042
|
+
Promise.resolve(ret).catch((err) => {
|
|
1043
|
+
console.log(`[chat] 终态钩子异常: ${msgId}: ${errText(err)}`);
|
|
1044
|
+
});
|
|
1045
|
+
}
|
|
1046
|
+
} catch (err) {
|
|
1047
|
+
console.log(`[chat] 终态钩子异常: ${msgId}: ${errText(err)}`);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// --------------------------------------------------------------------------
|
|
1052
|
+
// 统一队列执行体(chat.py:321-378)
|
|
1053
|
+
// --------------------------------------------------------------------------
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* 统一队列执行体(runner worker 调用):执行一条待跑消息;**异常落记录不抛出**。
|
|
1057
|
+
*
|
|
1058
|
+
* P3:状态权威在 `chat_msgs`——`msgClaim` 原子 queued→running(与 `injectNow` 的
|
|
1059
|
+
* `waitq.claim` 互斥互补:等待项被取消/被注入抢先时此处让行);执行体按行+快照重建;
|
|
1060
|
+
* 终态 done/yielded/error 落表,等待项行终态在同函数闭环(裁决 R6)。yielded=挂起让位:
|
|
1061
|
+
* turn 等用户作答,运行位即行——行终态即释放,队首单元照常起跑。三条终态路各触发一次
|
|
1062
|
+
* `fireMsgDone`(done=推答复、yielded=不推、error=推失败回执)。
|
|
1063
|
+
*
|
|
1064
|
+
* **async,调用方必须 await**(Python 里 runner 在自己的线程里同步调它)。
|
|
1065
|
+
*
|
|
1066
|
+
* @param {string} msgId
|
|
1067
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1068
|
+
* @returns {Promise<void>}
|
|
1069
|
+
*/
|
|
1070
|
+
export async function runUnit(msgId, options = {}) {
|
|
1071
|
+
try {
|
|
1072
|
+
const row = waitq.msgGet(msgId, options);
|
|
1073
|
+
if (row === null || row.state !== STATE_QUEUED) {
|
|
1074
|
+
reapOrphanClaim(msgId, options); // 竞态窗口 A 收口(P4 必带⑦)
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
if (!wq('msgClaim')(msgId, options)) {
|
|
1078
|
+
reapOrphanClaim(msgId, options); // 竞态窗口 A 收口(P4 必带⑦)
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
let res;
|
|
1082
|
+
try {
|
|
1083
|
+
res = await rebuildRun(msgId, null, options)();
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
const text = errText(err);
|
|
1086
|
+
wq('msgFinish')(msgId, STATE_ERROR, text, options);
|
|
1087
|
+
wq('finishByTarget')(waitq.KIND_MSG, msgId, waitq.STATE_FAILED, text, options);
|
|
1088
|
+
fireMsgDone(msgId, STATE_ERROR);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
if (res === STATE_YIELDED) {
|
|
1092
|
+
console.log(`[chat] 消息单元挂起让位:m:${msgId}(turn 等待作答)`);
|
|
1093
|
+
wq('markEvidence')(waitq.KIND_MSG, msgId,
|
|
1094
|
+
{ desc: '消息投递挂起让位', reason: 'turn 等待作答' }, options);
|
|
1095
|
+
wq('msgFinish')(msgId, STATE_YIELDED, '', options);
|
|
1096
|
+
wq('finishByTarget')(waitq.KIND_MSG, msgId, waitq.STATE_DONE, '', options);
|
|
1097
|
+
fireMsgDone(msgId, STATE_YIELDED);
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
wq('msgFinish')(msgId, STATE_DONE, '', options);
|
|
1101
|
+
wq('finishByTarget')(waitq.KIND_MSG, msgId, waitq.STATE_DONE, '', options);
|
|
1102
|
+
fireMsgDone(msgId, STATE_DONE);
|
|
1103
|
+
} catch (err) { // 防「线程」拖死(DB 故障等);行滞留由 selfcheck 兜底
|
|
1104
|
+
console.log(`[chat] 消息单元异常: ${msgId}: ${errText(err)}`);
|
|
1105
|
+
try { // 尽力把等待项落 failed(chat_msgs 已不可写时由 selfcheck 兜底)
|
|
1106
|
+
wq('finishByTarget')(waitq.KIND_MSG, msgId, waitq.STATE_FAILED,
|
|
1107
|
+
errText(err).slice(0, 300), options);
|
|
1108
|
+
} catch {
|
|
1109
|
+
// 等待项也写不动:留给 selfcheck(与 Python 的空 except 同)
|
|
1110
|
+
}
|
|
1111
|
+
fireMsgDone(msgId, STATE_ERROR); // 最外层异常同样只触发一次
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* 竞态窗口 A 收口(P4 必带⑦):早退时若等待项仍挂在「本执行体」名下(`claimed_by=worker`
|
|
1117
|
+
* ——排除「立即注入」的 claim,inject-now 路径自行收口),而消息行已不在排队,说明 claim
|
|
1118
|
+
* 后、状态迁移前行被并发取消——等待项补终态,防 starting 永挂。窗口 B(inject capture/
|
|
1119
|
+
* cancel 交错)由 `waitq.claim` 原子性天然闭合(claim 失败即拒绝),无需代码。
|
|
1120
|
+
*
|
|
1121
|
+
* @param {string} msgId
|
|
1122
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1123
|
+
*/
|
|
1124
|
+
export function reapOrphanClaim(msgId, options = {}) {
|
|
1125
|
+
const wrow = waitq.getActive(waitq.KIND_MSG, msgId, options);
|
|
1126
|
+
if (wrow !== null && wrow.state === waitq.STATE_STARTING && wrow.claimed_by === 'worker') {
|
|
1127
|
+
wq('finishByTarget')(waitq.KIND_MSG, msgId, waitq.STATE_FAILED,
|
|
1128
|
+
'消息已不在排队中', options);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// --------------------------------------------------------------------------
|
|
1133
|
+
// 取消(chat.py:381-399)
|
|
1134
|
+
// --------------------------------------------------------------------------
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* 取消排队中的消息(已开始执行的返回 false)。返回是否命中。
|
|
1138
|
+
*
|
|
1139
|
+
* P3:`chat_msgs` 的 queued→cancelled 原子守卫为仲裁点(R12),等待项权威取消与内存键
|
|
1140
|
+
* 摘除随后——与 worker claim 竞态下不双执行、不复活终态行。**同步**。
|
|
1141
|
+
*
|
|
1142
|
+
* @param {string} msgId
|
|
1143
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1144
|
+
* @returns {boolean}
|
|
1145
|
+
*/
|
|
1146
|
+
export function cancel(msgId, options = {}) {
|
|
1147
|
+
if (!wq('msgCancel')(msgId, '已取消', options)) return false;
|
|
1148
|
+
wq('cancel')(waitq.KIND_MSG, msgId, '已取消', null, options);
|
|
1149
|
+
const inst = runnerInstance();
|
|
1150
|
+
if (inst !== null) inst.removeMsg(msgId);
|
|
1151
|
+
return true;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* 取消排队中的消息(按会话 sid 或卡片 id 过滤);返回取消条数。停止入口共用。
|
|
1156
|
+
*
|
|
1157
|
+
* 参数映射:Python `cancel_queued(sid=None, card_id=None)` → Node 收一个对象
|
|
1158
|
+
* `{sid, cardId}`(多关键字参、且两者语义对称,对象形式避免「谁在前」的调用错误)。
|
|
1159
|
+
*
|
|
1160
|
+
* @param {{sid?: string|null, cardId?: number|string|null}} [filter]
|
|
1161
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1162
|
+
* @returns {number}
|
|
1163
|
+
*/
|
|
1164
|
+
export function cancelQueued({ sid = null, cardId = null } = {}, options = {}) {
|
|
1165
|
+
if (!sid && !cardId) return 0;
|
|
1166
|
+
const rows = waitq.msgRows({ sid, cardId, states: [STATE_QUEUED] }, options);
|
|
1167
|
+
let count = 0;
|
|
1168
|
+
for (const row of rows) {
|
|
1169
|
+
if (cancel(row.id, options)) count += 1;
|
|
1170
|
+
}
|
|
1171
|
+
return count;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* 取消排队中的消息**并回带原文**(`mode=cancel_queue` 端点的实现;用户点「取消排队」)。
|
|
1176
|
+
*
|
|
1177
|
+
* 与 `cancelQueued` 的差别只有「回带」:后者是停止入口共用的计数壳。用户显式取消排队时,
|
|
1178
|
+
* 前端要把消息原文放回输入框,而 `msgsOfSid` 下发给前端的展示文本被截到 120 字——
|
|
1179
|
+
* 回填必须由服务端给**全量原文**,否则长消息被静默截断。
|
|
1180
|
+
*
|
|
1181
|
+
* 逐行只在 `cancel()` 真命中(`chat_msgs` 的 queued→cancelled 原子守卫是仲裁点,R12)
|
|
1182
|
+
* 时才收进结果:与 worker claim 竞态下已被抢走执行的消息既不会被取消、也不会回带,
|
|
1183
|
+
* 前端不会把仍在执行的消息错放回输入框。返回按入队顺序(`msgRows` 的 created_at,id)。
|
|
1184
|
+
*
|
|
1185
|
+
* `filter.msgId` 非空 ⇒ 只取消该条(归属校验由端点层做,本函数只管状态)。
|
|
1186
|
+
*
|
|
1187
|
+
* @param {{sid?: string|null, cardId?: number|string|null, msgId?: string|null}} [filter]
|
|
1188
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1189
|
+
* @returns {{cancelled: number, msgs: Array<{id: string, text: string}>}}
|
|
1190
|
+
*/
|
|
1191
|
+
export function cancelQueuedDetail({ sid = null, cardId = null, msgId = null } = {}, options = {}) {
|
|
1192
|
+
if (!sid && !cardId && !msgId) return { cancelled: 0, msgs: [] };
|
|
1193
|
+
let rows;
|
|
1194
|
+
if (msgId) {
|
|
1195
|
+
const one = waitq.msgGet(msgId, options);
|
|
1196
|
+
if (one === null || one.state !== STATE_QUEUED) return { cancelled: 0, msgs: [] };
|
|
1197
|
+
rows = [one];
|
|
1198
|
+
} else {
|
|
1199
|
+
rows = waitq.msgRows({ sid, cardId, states: [STATE_QUEUED] }, options);
|
|
1200
|
+
}
|
|
1201
|
+
const msgs = [];
|
|
1202
|
+
for (const row of rows) {
|
|
1203
|
+
// 先取原文再取消:取消是终态迁移,但仍按「命中才回带」的次序写,读一次即用一次
|
|
1204
|
+
const text = row.message === null || row.message === undefined ? '' : String(row.message);
|
|
1205
|
+
if (!cancel(row.id, options)) continue;
|
|
1206
|
+
msgs.push({ id: row.id, text });
|
|
1207
|
+
}
|
|
1208
|
+
return { cancelled: msgs.length, msgs };
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// --------------------------------------------------------------------------
|
|
1212
|
+
// 立即注入(chat.py:452-493)
|
|
1213
|
+
// --------------------------------------------------------------------------
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* 「立即注入」:把平台排队中的消息单元立即投递到目标会话,不再等统一队列。
|
|
1217
|
+
*
|
|
1218
|
+
* P3:互斥点改为等待项 `waitq.claim` 原子抢占(同 P2「立即送达」模式)——与 worker 的
|
|
1219
|
+
* claim 只有一方成功;`chat_msgs` 的 queued→running 由 `msgClaim` 承载状态迁移。抢到后先把
|
|
1220
|
+
* 单元从统一队列摘除(`runner.removeMsg`,内存键专用、不碰等待项行)。
|
|
1221
|
+
*
|
|
1222
|
+
* 被拒(消息不在排队中/不支持注入)抛 `InjectRefused`——消息未动、仍在队列;
|
|
1223
|
+
* 投递失败:等待项 `returnToWaiting`(行 id 稳定、seq 不动=按原位次放回,裁决 R4)+
|
|
1224
|
+
* `chat_msgs` 退回 queued,**原样上抛**(端点转 502);成功返回 undefined(终态 done)。
|
|
1225
|
+
*
|
|
1226
|
+
* **async**(要 await 投递 thunk)。
|
|
1227
|
+
*
|
|
1228
|
+
* @param {string} msgId
|
|
1229
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1230
|
+
* @returns {Promise<void>}
|
|
1231
|
+
* @throws {InjectRefused}
|
|
1232
|
+
*/
|
|
1233
|
+
export async function injectNow(msgId, options = {}) {
|
|
1234
|
+
const row = waitq.msgGet(msgId, options);
|
|
1235
|
+
if (row === null) throw new InjectRefused('消息不存在或已回收');
|
|
1236
|
+
if (row.state !== STATE_QUEUED) throw new InjectRefused('该消息已不在排队中(可能已开始发送)');
|
|
1237
|
+
const wrow = waitq.getActive(waitq.KIND_MSG, msgId, options);
|
|
1238
|
+
const meta = parseMeta(wrow);
|
|
1239
|
+
if (meta.family !== 'dsh_plugin') throw new InjectRefused('该消息不支持立即注入');
|
|
1240
|
+
if (wrow === null || !wq('claim')(wrow.id, 'inject-now', options)) {
|
|
1241
|
+
throw new InjectRefused('该消息已不在排队中(可能已开始发送)');
|
|
1242
|
+
}
|
|
1243
|
+
const inst = runnerInstance();
|
|
1244
|
+
if (inst !== null) inst.removeMsg(msgId); // 出队:唤醒 worker 重挑(权威取消在下行 claim)
|
|
1245
|
+
if (!wq('msgClaim')(msgId, options)) { // 状态迁移(被取消的竞态:放回等待)
|
|
1246
|
+
wq('returnToWaiting')(wrow.id, 0.0, false, options);
|
|
1247
|
+
throw new InjectRefused('该消息已不在排队中(可能已开始发送)');
|
|
1248
|
+
}
|
|
1249
|
+
try {
|
|
1250
|
+
await rebuildInject(msgId, options)();
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
// 投递失败:等待项放回(行 id 稳定、seq 不动=按原位次放回,meta 随行保留)+ 状态退回
|
|
1253
|
+
// 排队——消息不丢,语义与「投递失败仍可在队列里等下一轮」一致;错误由调用方上报
|
|
1254
|
+
// 前端 toast(端点 502)
|
|
1255
|
+
wq('returnToWaiting')(wrow.id, 0.0, false, options);
|
|
1256
|
+
wq('msgRequeue')(msgId, options);
|
|
1257
|
+
if (inst !== null) inst.submitMsg(msgId, row.project_id, row.sid);
|
|
1258
|
+
throw err;
|
|
1259
|
+
}
|
|
1260
|
+
wq('msgFinish')(msgId, STATE_DONE, '', options);
|
|
1261
|
+
wq('finishByTarget')(waitq.KIND_MSG, msgId, waitq.STATE_DONE, '', options);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// --------------------------------------------------------------------------
|
|
1265
|
+
// 对话登记(chat.py:498-506)
|
|
1266
|
+
// --------------------------------------------------------------------------
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* 登记对话会话(`_CHATS`):供 `state` 查询与 `stop` 停止。**同步**。
|
|
1270
|
+
*
|
|
1271
|
+
* @param {string} sid
|
|
1272
|
+
* @param {number|null} taskId
|
|
1273
|
+
* @param {string} message 消息原文(记录里截 200 字,照 Python `message[:200]`)
|
|
1274
|
+
* @param {{logPath?: string, dshPlugin?: boolean, since?: number|null}} [opts]
|
|
1275
|
+
*/
|
|
1276
|
+
export function register(sid, taskId, message, { logPath = '', dshPlugin = false, since = null } = {}) {
|
|
1277
|
+
const rec = {
|
|
1278
|
+
task_id: taskId,
|
|
1279
|
+
started_at: Date.now(),
|
|
1280
|
+
// Python `message[:200]` 是**按码点**截断;`.slice` 是 UTF-16 码元(emoji 会提前截半)
|
|
1281
|
+
message: [...String(message === null || message === undefined ? '' : message)]
|
|
1282
|
+
.slice(0, 200).join(''),
|
|
1283
|
+
exit_code: null,
|
|
1284
|
+
log_path: logPath,
|
|
1285
|
+
};
|
|
1286
|
+
if (dshPlugin) {
|
|
1287
|
+
// since = 投递前会话 last_seq(等 turn/end 的 SSE 续传起点)
|
|
1288
|
+
rec.dsh_plugin = true;
|
|
1289
|
+
rec.since = since;
|
|
1290
|
+
}
|
|
1291
|
+
CHAT_REGISTRY.set(sid, rec);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1295
|
+
// dsh 插件族(路线 A:进程内会话,事件驱动等待)
|
|
1296
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
1297
|
+
|
|
1298
|
+
// --------------------------------------------------------------------------
|
|
1299
|
+
// 投递前预订阅(缺陷 A 修复;chat.py:523-660)
|
|
1300
|
+
// --------------------------------------------------------------------------
|
|
1301
|
+
//
|
|
1302
|
+
// 缺陷(真机 run1–run7,6/6 命中):外部会话的普通 followup 投递里,`turn/start` 帧到中枢
|
|
1303
|
+
// 在 +0.072~0.143s,而等待器的 `dshevents.subscribe` 在 +0.084~0.152s ⇒ **帧早到 9~13ms**;
|
|
1304
|
+
// 全局状态流对进程内订阅者**无回放** ⇒ 该帧永远看不到 ⇒ `started` 恒假 ⇒ 本轮 `turn/end`
|
|
1305
|
+
// 被丢弃 ⇒ 只能等满 `TURN_START_GRACE`(60s) 收口。有卡路由 `ext:` 行兜住占用(只是状态滞后
|
|
1306
|
+
// 60s);**无卡路(任务侧直送 / 飞书绑定会话)会真的提前释放项目运行位**,与仍在跑的外部
|
|
1307
|
+
// turn 并发改同一工作区 ⇒ 违「任务按项目串行」红线。
|
|
1308
|
+
//
|
|
1309
|
+
// 修法:把「订阅 + 帧队列」抽成 `TurnSubscription`,`dshSend` 在**投递之前**先建订阅并放进
|
|
1310
|
+
// 旁表 `DSH_SUB`(与 `DSH_BASELINE`/`DSH_INJECT` 同款「投递时记、等待时取走即弃」);
|
|
1311
|
+
// `dshWaitTurnViaEvents` 优先取走预订阅,取不到再退回「现订阅」(老行为)。硬约束:
|
|
1312
|
+
// ① 只对会走事件流路的那一档建(`dshevents.get(sid).owned === false`):池内/未知的投递
|
|
1313
|
+
// 零改动、零多余订阅(池内等待器 `dshWaitTurn` 根本不读这张旁表);
|
|
1314
|
+
// ② 不等待的投递不留悬挂订阅:旁表有界(`DSH_SUB_MAX` + TTL 惰性过期 `DSH_SUB_TTL`),
|
|
1315
|
+
// 出表/取走/投递抛异常一律 `dshevents.unsubscribe`(回调常驻中枢消费者列表,泄漏会让
|
|
1316
|
+
// 每次状态帧都白调一次);
|
|
1317
|
+
// ③ 帧队列**只收本 sid 的帧**(状态帧低频 ⇒ 无人取走的队列也有界),等待器里的 sid 过滤照旧;
|
|
1318
|
+
// ④ **不拿 `status == "running"` 无条件播种 `started`**(I1 安全阀不许写宽)。
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* 一次性帧口袋的内部队列:`put` 同步入队/唤醒等待者,`getNowait` 立即取,
|
|
1322
|
+
* `get(timeoutMs)` 到期 resolve `null`(对应 Python 的 `queue.Empty`)。
|
|
1323
|
+
*
|
|
1324
|
+
* Node 不能阻塞事件循环,故 Python `queue.Queue.get(timeout=…)` 的对应物是「等待者集合 +
|
|
1325
|
+
* 定时器」的 Promise。帧一定是对象,故 `null` 作「空」哨兵是安全的。
|
|
1326
|
+
*/
|
|
1327
|
+
class FrameQueue {
|
|
1328
|
+
constructor() {
|
|
1329
|
+
this._items = [];
|
|
1330
|
+
this._waiters = [];
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/** 入队(有挂起的取者则直接交棒,不入队) */
|
|
1334
|
+
put(item) {
|
|
1335
|
+
const waiter = this._waiters.shift();
|
|
1336
|
+
if (waiter !== undefined) {
|
|
1337
|
+
if (waiter.timer !== null) clearTimeout(waiter.timer);
|
|
1338
|
+
waiter.resolve(item);
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
this._items.push(item);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/** 立即取一帧(无则 null,对应 Python `get_nowait()` 的 `queue.Empty`) */
|
|
1345
|
+
getNowait() {
|
|
1346
|
+
return this._items.length ? this._items.shift() : null;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* 等一帧(超时 resolve null)。
|
|
1351
|
+
* @param {number} timeoutMs
|
|
1352
|
+
* @returns {Promise<object|null>}
|
|
1353
|
+
*/
|
|
1354
|
+
get(timeoutMs) {
|
|
1355
|
+
if (this._items.length) return Promise.resolve(this._items.shift());
|
|
1356
|
+
return new Promise((resolve) => {
|
|
1357
|
+
const waiter = { resolve, timer: null };
|
|
1358
|
+
waiter.timer = setTimeout(() => {
|
|
1359
|
+
const idx = this._waiters.indexOf(waiter);
|
|
1360
|
+
if (idx >= 0) this._waiters.splice(idx, 1);
|
|
1361
|
+
resolve(null);
|
|
1362
|
+
}, Math.max(0, timeoutMs));
|
|
1363
|
+
this._waiters.push(waiter);
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* 投递前建立的一次性全局状态流订阅:本 sid 的帧 → 队列。
|
|
1370
|
+
*
|
|
1371
|
+
* 一个实例=一次投递的「帧口袋」:`dshSend` 建它(`dshSubOpen`)→ `dshWaitTurnViaEvents`
|
|
1372
|
+
* 取走并当帧队列用(`dshSubTake`)→ 等待器 `finally` 或回收路径 `close()`(退订,幂等)。
|
|
1373
|
+
* 取不到预订阅时等待器自建同款实例(老行为)。
|
|
1374
|
+
*/
|
|
1375
|
+
export class TurnSubscription {
|
|
1376
|
+
/** @param {string} sid */
|
|
1377
|
+
constructor(sid) {
|
|
1378
|
+
this.sid = String(sid === null || sid === undefined ? '' : sid);
|
|
1379
|
+
/** 回调(事件消费侧)→ 等待器(本函数)的交接口 */
|
|
1380
|
+
this.frames = new FrameQueue();
|
|
1381
|
+
/** 建订阅时刻(秒,对齐 Python `time.time()`)——TTL/超限回收的排序键 */
|
|
1382
|
+
this.created = Date.now() / 1000;
|
|
1383
|
+
this._closed = false;
|
|
1384
|
+
// 回调存成**对象同一性稳定**的引用:订阅与退订必须传同一个对象(`dshevents.unsubscribe`
|
|
1385
|
+
// 按 `indexOf` 同一性摘除;每次属性访问都会新建 bound method,故存一次,两个方向都稳)
|
|
1386
|
+
this._cb = (frame) => this.onFrame(frame);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* 中枢订阅回调:只收本 sid 的帧并入队。
|
|
1391
|
+
*
|
|
1392
|
+
* 契约要求短小(跑在事件消费回调里):这里只做一次字符串比较 + `put`。按 sid 过滤让
|
|
1393
|
+
* 「无人取走的预订阅」队列也保持有界——状态帧是低频事件。
|
|
1394
|
+
*
|
|
1395
|
+
* @param {object} frame
|
|
1396
|
+
*/
|
|
1397
|
+
onFrame(frame) {
|
|
1398
|
+
if (this._closed) return;
|
|
1399
|
+
if (String((frame || {}).session_id || '') !== this.sid) return;
|
|
1400
|
+
this.frames.put(frame);
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/** 退订(幂等):回调必须从中枢订阅者列表里摘掉,否则常驻泄漏 */
|
|
1404
|
+
close() {
|
|
1405
|
+
if (this._closed) return;
|
|
1406
|
+
this._closed = true;
|
|
1407
|
+
dshevents.unsubscribe(this._cb);
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** sid -> `TurnSubscription`(投递时建、等待时取走即弃;`_DSH_SUB`) */
|
|
1412
|
+
export const DSH_SUB = new Map();
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* 旁表惰性回收(Python `_dsh_sub_reap_locked`):过 TTL 与超条数上限的条目出表。
|
|
1416
|
+
*
|
|
1417
|
+
* 返回**待退订**列表(出表后再 `close()`):Python 侧注释说「`close()` 要拿中枢的订阅锁,
|
|
1418
|
+
* 不在本锁内做」;Node 单事件循环下没有锁可持(凑巧与 Python 的结论一致:先出表、再退订),
|
|
1419
|
+
* 保留同样的两段式形态以免日后加锁时踩坑。
|
|
1420
|
+
*
|
|
1421
|
+
* @returns {TurnSubscription[]}
|
|
1422
|
+
*/
|
|
1423
|
+
export function dshSubReap() {
|
|
1424
|
+
const now = Date.now() / 1000;
|
|
1425
|
+
const stale = [];
|
|
1426
|
+
for (const [sid, sub] of [...DSH_SUB.entries()]) {
|
|
1427
|
+
if (now - sub.created > DSH_SUB_TTL) {
|
|
1428
|
+
DSH_SUB.delete(sid);
|
|
1429
|
+
stale.push(sub);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
const over = DSH_SUB.size - DSH_SUB_MAX;
|
|
1433
|
+
if (over > 0) {
|
|
1434
|
+
// 超限:按建订阅时刻从旧到新回收(同一时刻大量投递时也收敛回上限之内);
|
|
1435
|
+
// Array.prototype.sort 自 ES2019 起稳定 ⇒ 同刻条目保持插入序(与 Python sorted 同)
|
|
1436
|
+
const oldest = [...DSH_SUB.entries()]
|
|
1437
|
+
.sort((a, b) => a[1].created - b[1].created)
|
|
1438
|
+
.slice(0, over);
|
|
1439
|
+
for (const [sid, sub] of oldest) {
|
|
1440
|
+
DSH_SUB.delete(sid);
|
|
1441
|
+
stale.push(sub);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
return stale;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* 投递**之前**建预订阅并放进旁表;返回该订阅(同 sid 的旧订阅覆盖即退订)。
|
|
1449
|
+
*
|
|
1450
|
+
* 只应由 `dshSend` 在「会话是外部(`owned === false`)」时调用——那是 `waitTurn` 会分流到
|
|
1451
|
+
* 事件流路、等待器会取用它的唯一一档。
|
|
1452
|
+
*
|
|
1453
|
+
* @param {string} sid
|
|
1454
|
+
* @returns {TurnSubscription}
|
|
1455
|
+
*/
|
|
1456
|
+
export function dshSubOpen(sid) {
|
|
1457
|
+
const sub = new TurnSubscription(sid);
|
|
1458
|
+
dshevents.subscribe(sub._cb);
|
|
1459
|
+
const old = DSH_SUB.get(sub.sid);
|
|
1460
|
+
DSH_SUB.set(sub.sid, sub);
|
|
1461
|
+
// 入表**之后**回收:条数上限对「当前表」恒成立(新订阅 created 最新,不会被本次摘掉)
|
|
1462
|
+
const stale = dshSubReap();
|
|
1463
|
+
for (const item of stale) item.close(); // 过期/超限的旧订阅:真退订
|
|
1464
|
+
if (old !== undefined) old.close(); // 同 sid 上一次没被取走的预订阅:回收
|
|
1465
|
+
return sub;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* 取走该 sid 的预订阅(无则 null):取走即从旁表删除。
|
|
1470
|
+
*
|
|
1471
|
+
* 退订责任随之转移给调用方(等待器在 `finally` 里 `close()`)——旁表只负责「投递到取走」
|
|
1472
|
+
* 这段短窗口内的保管,取走即弃与 `DSH_BASELINE` 同款语义。
|
|
1473
|
+
*
|
|
1474
|
+
* @param {string} sid
|
|
1475
|
+
* @returns {TurnSubscription|null}
|
|
1476
|
+
*/
|
|
1477
|
+
export function dshSubTake(sid) {
|
|
1478
|
+
const stale = dshSubReap();
|
|
1479
|
+
const key = String(sid === null || sid === undefined ? '' : sid);
|
|
1480
|
+
const sub = DSH_SUB.get(key);
|
|
1481
|
+
if (sub !== undefined) DSH_SUB.delete(key);
|
|
1482
|
+
for (const item of stale) item.close();
|
|
1483
|
+
return sub === undefined ? null : sub;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* 按对象回收一个预订阅(投递失败路径):仅当旁表里仍是它才删,随后退订。
|
|
1488
|
+
* @param {TurnSubscription|null} sub
|
|
1489
|
+
*/
|
|
1490
|
+
export function dshSubDrop(sub) {
|
|
1491
|
+
if (sub === null || sub === undefined) return;
|
|
1492
|
+
if (DSH_SUB.get(sub.sid) === sub) DSH_SUB.delete(sub.sid);
|
|
1493
|
+
sub.close();
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// --------------------------------------------------------------------------
|
|
1497
|
+
// 宿主会话存活兜底(修卡 934;chat.py:675-734)
|
|
1498
|
+
// --------------------------------------------------------------------------
|
|
1499
|
+
//
|
|
1500
|
+
// 现场:dsh 会话活在宿主进程内,插件**热重载 / 宿主重启**会把所有会话 dispose 掉——真机
|
|
1501
|
+
// 实证两张卡的会话同时落 `turn/end {kind:"aborted", reason:{kind:"disposed"}}`。平台侧
|
|
1502
|
+
// 卡片/任务的 `session_id` 仍在库、卡照旧在列,用户再发评论 / 点「立即注入」时驱动回 404
|
|
1503
|
+
// 「会话不在驱动池中」(用户无从判断该怎么办)。
|
|
1504
|
+
//
|
|
1505
|
+
// 判据与调和器「会话确已结束」同源:`dshevents.aligned()`(中枢可信)∧ 注册表里没有这个
|
|
1506
|
+
// sid ⇒ 宿主确实不再持有它。此时按平台既有「有 sid 就 resume」的语义把会话接回池内,再走
|
|
1507
|
+
// 原投递。未知(未对齐 / 断连)一律不动:未知 ≠ 已失去,零请求、既有行为一个字节不变。
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* 接回总开关(回滚阀):`TS_HOST_SESSION_REVIVE=0` ⇒ 不接回,退回修复前行为。
|
|
1511
|
+
* @param {{env?: NodeJS.ProcessEnv}} [options]
|
|
1512
|
+
* @returns {boolean}
|
|
1513
|
+
*/
|
|
1514
|
+
export function hostReviveEnabled({ env = process.env } = {}) {
|
|
1515
|
+
return String(env[HOST_REVIVE_ENV] || '').trim() !== '0';
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/**
|
|
1519
|
+
* 宿主是否**确已**不再持有该会话(可信快照 ∧ 注册表无此 sid)。
|
|
1520
|
+
*
|
|
1521
|
+
* 两个条件是「确已」的全部证据:`aligned()` 为假(中枢断连 / 热重载后快照未对齐)时注册表
|
|
1522
|
+
* 本就不可信,读不到 sid 只代表**未知**——按全局不变量「未知 ≠ 已结束」,一律返回 false
|
|
1523
|
+
* (调用方不动)。读口异常同样按未知处理。**同步**。
|
|
1524
|
+
*
|
|
1525
|
+
* @param {string} sid
|
|
1526
|
+
* @returns {boolean}
|
|
1527
|
+
*/
|
|
1528
|
+
export function hostSessionLost(sid) {
|
|
1529
|
+
if (!sid) return false;
|
|
1530
|
+
try {
|
|
1531
|
+
if (!dshevents.aligned()) return false;
|
|
1532
|
+
return dshevents.get(sid) === null;
|
|
1533
|
+
} catch {
|
|
1534
|
+
return false; // 读口异常按未知,不阻断投递
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/**
|
|
1539
|
+
* 把「宿主已失去」的会话按既有 resume 语义接回;返回是否**可以投递**。
|
|
1540
|
+
*
|
|
1541
|
+
* true:会话本来就在宿主里(零请求),或刚被成功接回,或回滚阀关闭(退回旧行为,失败仍由
|
|
1542
|
+
* 驱动 404 兜底)。false:确已失去且接不回(会话文件不存在 / 驱动不可达 / 令牌错)——调用方
|
|
1543
|
+
* 据此给用户明确文案,而不再发一次必然 404 的投递。
|
|
1544
|
+
* `cwd`/`task` 只在真接回时下传(前者对齐 `board.card_workspace`:worktree 卡;后者是驱动侧
|
|
1545
|
+
* 的会话标签,与 `_start_web` 同形)。
|
|
1546
|
+
*
|
|
1547
|
+
* **async**(`resumeSession` 是驱动调用)。
|
|
1548
|
+
*
|
|
1549
|
+
* @param {string} sid
|
|
1550
|
+
* @param {string} [cwd]
|
|
1551
|
+
* @param {string} [task]
|
|
1552
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1553
|
+
* @returns {Promise<boolean>}
|
|
1554
|
+
*/
|
|
1555
|
+
export async function reviveHostSession(sid, cwd = '', task = '', options = {}) {
|
|
1556
|
+
if (!hostReviveEnabled(options) || !hostSessionLost(sid)) return true;
|
|
1557
|
+
try {
|
|
1558
|
+
// Python `resume_session(sid, cwd=cwd, task=task)`;Node 形参序为 (sid, task, cwd)
|
|
1559
|
+
await driverResumeSession(sid, task, cwd);
|
|
1560
|
+
} catch (err) {
|
|
1561
|
+
if (err && err.name === 'DshDriverError') {
|
|
1562
|
+
console.log(`[chat] 会话接回失败 sid=${sid}: ${errText(err)}`);
|
|
1563
|
+
return false;
|
|
1564
|
+
}
|
|
1565
|
+
throw err;
|
|
1566
|
+
}
|
|
1567
|
+
console.log(`[chat] 宿主已失去会话,按 resume 接回:sid=${sid}`
|
|
1568
|
+
+ `(cwd=${cwd || '-'} task=${task || '-'})`);
|
|
1569
|
+
return true;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// --------------------------------------------------------------------------
|
|
1573
|
+
// 投递(chat.py:737-794)
|
|
1574
|
+
// --------------------------------------------------------------------------
|
|
1575
|
+
|
|
1576
|
+
/**
|
|
1577
|
+
* 投递基线 seq(等 turn/end 的起点):驱动 `/status` 拿不到就回落中枢注册表。
|
|
1578
|
+
*
|
|
1579
|
+
* 判据是「**拿不到** `last_seq`」而不是「`/status` 抛错」:T2 落地后,watched + 活 agent 的
|
|
1580
|
+
* 外部会话 `/status` 由 `_externalTarget` 干跑后按外部形状回 200,响应里**刻意没有**
|
|
1581
|
+
* `last_seq`——只 catch 异常会让回落分支永不触发、`since` 恒 0(等轮次会把基线之前的历史帧
|
|
1582
|
+
* 当成本轮)。两个来源按「先权威后本地」取:
|
|
1583
|
+
* ① 驱动 `/status`(池内会话的权威口径,一次读,不是轮询);
|
|
1584
|
+
* ② `dshevents.get(sid).last_seq`(中枢已折好的**本地**值,零请求;外部会话/驱动不可达用它)。
|
|
1585
|
+
* 两者都取不到返回 0(保持既有「无基线」语义)。**async**。
|
|
1586
|
+
*
|
|
1587
|
+
* @param {string} sid
|
|
1588
|
+
* @returns {Promise<number>}
|
|
1589
|
+
*/
|
|
1590
|
+
export async function dshBaselineSince(sid) {
|
|
1591
|
+
let seq = null;
|
|
1592
|
+
try {
|
|
1593
|
+
const st = await driverStatus(sid);
|
|
1594
|
+
seq = (st || {}).last_seq; // 宽容读:见文件头差异②
|
|
1595
|
+
} catch (err) {
|
|
1596
|
+
if (err && err.name === 'DshDriverError') seq = null;
|
|
1597
|
+
else throw err;
|
|
1598
|
+
}
|
|
1599
|
+
if (seq === null || seq === undefined) {
|
|
1600
|
+
seq = (dshevents.get(sid) || {}).last_seq;
|
|
1601
|
+
}
|
|
1602
|
+
return toIntSeq(seq);
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* dsh 插件族投递(chat 与 board 共用):记基线 → prompt / steer → 返回基线 seq。
|
|
1607
|
+
*
|
|
1608
|
+
* 投递语义:dsh 的 `followup` 在会话忙时排进 agent inbox(服务端排队),`steer` 则注入当前
|
|
1609
|
+
* turn 的最近 step 边界——平台的「发送 / 立即注入」两种语义因此一一对应,无需子进程。
|
|
1610
|
+
* 异常抛 `DshDriverError` 由调用方处置。基线来源见 `dshBaselineSince`(外部会话没有
|
|
1611
|
+
* `last_seq` ⇒ 回落中枢)。投递的同时把「本次是否 steer」记进旁表 `DSH_INJECT`(I1):
|
|
1612
|
+
* 外部会话的等待器据此决定要不要按「已在跑的 turn」播种 `started`。
|
|
1613
|
+
* **投递前预订阅(缺陷 A 修复)**:外部会话先建 `TurnSubscription` 放进 `DSH_SUB`
|
|
1614
|
+
* ——`turn/start` 帧可能在 `POST` 返回前后几毫秒到达,等投递返回再订阅就永远看不到;
|
|
1615
|
+
* 投递失败当场回收该预订阅。**async**。
|
|
1616
|
+
*
|
|
1617
|
+
* @param {string} sid
|
|
1618
|
+
* @param {string} message
|
|
1619
|
+
* @param {boolean} [inject]
|
|
1620
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1621
|
+
* @returns {Promise<number>} 投递基线 seq
|
|
1622
|
+
*/
|
|
1623
|
+
export async function dshSend(sid, message, inject = false, options = {}) {
|
|
1624
|
+
if (!sid) throw new DshDriverError(-1, '会话 id 为空,无法投递');
|
|
1625
|
+
// 外部会话(`owned === false`)= `waitTurn` 会分流到事件流路的那一档:投递前先订阅;
|
|
1626
|
+
// 池内/未知不建(池内等待器不读它)。
|
|
1627
|
+
let sub = null;
|
|
1628
|
+
if ((dshevents.get(sid) || {}).owned === false) sub = dshSubOpen(sid);
|
|
1629
|
+
let since;
|
|
1630
|
+
try {
|
|
1631
|
+
since = await dshBaselineSince(sid);
|
|
1632
|
+
if (inject) await driverSteer(sid, message);
|
|
1633
|
+
else await driverPrompt(sid, message);
|
|
1634
|
+
} catch (err) {
|
|
1635
|
+
// 投递失败:预订阅无人会取走,当场回收(不留悬挂订阅)
|
|
1636
|
+
dshSubDrop(sub);
|
|
1637
|
+
throw err;
|
|
1638
|
+
}
|
|
1639
|
+
DSH_BASELINE.set(sid, since);
|
|
1640
|
+
DSH_INJECT.set(sid, Boolean(inject)); // I1:true/false 都写(覆盖上一次投递的旧值)
|
|
1641
|
+
return since;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// --------------------------------------------------------------------------
|
|
1645
|
+
// 等 turn 结束(chat.py:797-1013)
|
|
1646
|
+
// --------------------------------------------------------------------------
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* 等 turn 结束的总时限(秒):常量默认 + 环境变量覆盖(非法/非正值回落常量)。
|
|
1650
|
+
* @param {{env?: NodeJS.ProcessEnv}} [options]
|
|
1651
|
+
* @returns {number}
|
|
1652
|
+
*/
|
|
1653
|
+
export function dshWaitTurnLimit({ env = process.env } = {}) {
|
|
1654
|
+
const raw = String(env[DSH_WAIT_TURN_TIMEOUT_ENV] || '').trim();
|
|
1655
|
+
if (raw) {
|
|
1656
|
+
const val = pyFloatOr(raw, 0);
|
|
1657
|
+
if (val > 0) return val; // NaN/Infinity 的判据与 Python `val > 0` 同(Infinity 合法)
|
|
1658
|
+
}
|
|
1659
|
+
return DSH_WAIT_TURN_TIMEOUT;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* 写「等待 turn 结束超时」留痕:会话日志 + 平台日志(插件形态落 plugin-backend.log)。
|
|
1664
|
+
*
|
|
1665
|
+
* 断连与总时限两条收口共用同一行文案(不变量:断连也必须按**超时口径**收口并留痕,绝不判
|
|
1666
|
+
* 「正常结束」);`detail` 只补原因,便于事后区分是哪条收口。`appendLog` 对空/不可写路径
|
|
1667
|
+
* 静默跳过(既有语义),故再打一行平台日志兜底——外部卡片投递可能没有 `CHAT_REGISTRY`
|
|
1668
|
+
* 记录(`logPath` 为空),此时留痕不能丢。
|
|
1669
|
+
*
|
|
1670
|
+
* @param {string} logPath
|
|
1671
|
+
* @param {string} detail
|
|
1672
|
+
*/
|
|
1673
|
+
function logWaitTimeout(logPath, detail) {
|
|
1674
|
+
const line = `### 会话等待 turn 结束超时(${detail})`;
|
|
1675
|
+
appendLog(logPath, `${line}\n`);
|
|
1676
|
+
console.log(`[chat] ${line}(日志: ${logPath || '无'})`);
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
/**
|
|
1680
|
+
* 等 dsh 会话一次 turn 结束(事件驱动,零远端轮询)。
|
|
1681
|
+
*
|
|
1682
|
+
* 返回 null(turn 正常/异常/超时收口)或 STATE_YIELDED(turn 挂起等作答,让出运行位)。
|
|
1683
|
+
* `since` 缺省取本次投递记录的基线;基线丢失(进程重启等)时退回读当前 `last_seq`。
|
|
1684
|
+
* `logPath`:超时原因行的落点(缺省取本会话对话日志 `CHAT_REGISTRY[sid].log_path`)。
|
|
1685
|
+
* P7a 缺陷 G:整轮等待带**总时限**(`DSH_WAIT_TURN_TIMEOUT`,环境变量可覆盖)——链路保活
|
|
1686
|
+
* 正常但服务端不推 `turn/end` 时不再永久挂起;到点写一行
|
|
1687
|
+
* `### 会话等待 turn 结束超时(N s 无 turn/end)` 并按「本轮结束」返回 null,消息单元据此
|
|
1688
|
+
* 收口(m: 行 done、项目运行位释放、补位继续)。**async,调用方必须 await**。
|
|
1689
|
+
*
|
|
1690
|
+
* @param {string} sid
|
|
1691
|
+
* @param {number|null} [since]
|
|
1692
|
+
* @param {boolean} [yieldOnInteraction]
|
|
1693
|
+
* @param {string} [logPath]
|
|
1694
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1695
|
+
* @returns {Promise<string|null>}
|
|
1696
|
+
*/
|
|
1697
|
+
export async function dshWaitTurn(sid, since = null, yieldOnInteraction = true, logPath = '',
|
|
1698
|
+
options = {}) {
|
|
1699
|
+
if (since === null || since === undefined) {
|
|
1700
|
+
const base = DSH_BASELINE.get(sid);
|
|
1701
|
+
DSH_BASELINE.delete(sid); // 取走即弃
|
|
1702
|
+
if (base !== undefined) since = base;
|
|
1703
|
+
}
|
|
1704
|
+
if (since === null || since === undefined) {
|
|
1705
|
+
try {
|
|
1706
|
+
const st = await driverStatus(sid);
|
|
1707
|
+
since = toIntSeq((st || {}).last_seq);
|
|
1708
|
+
} catch (err) {
|
|
1709
|
+
if (err && err.name === 'DshDriverError') return null;
|
|
1710
|
+
throw err;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
if (!logPath) {
|
|
1714
|
+
const rec = CHAT_REGISTRY.get(sid);
|
|
1715
|
+
logPath = (rec || {}).log_path || '';
|
|
1716
|
+
}
|
|
1717
|
+
const limit = dshWaitTurnLimit(options);
|
|
1718
|
+
const waiter = new TurnWaiter();
|
|
1719
|
+
let stopRequested = false;
|
|
1720
|
+
const stop = () => stopRequested;
|
|
1721
|
+
// feedInto 自带兜底(链路异常 → waiter.fail,返回 null);这里只防「万一 reject」变成
|
|
1722
|
+
// 未处理拒绝(Python 的守护线程没有这个形态,属 Node 必需的兜底)
|
|
1723
|
+
const feeding = driverFeedInto(sid, since, waiter, stop);
|
|
1724
|
+
if (feeding !== null && feeding !== undefined && typeof feeding.catch === 'function') {
|
|
1725
|
+
feeding.catch(() => { /* 收尾竞态:等待器已有 fail 通道 */ });
|
|
1726
|
+
}
|
|
1727
|
+
const startedMs = Date.now();
|
|
1728
|
+
const deadlineMs = startedMs + limit * 1000;
|
|
1729
|
+
try {
|
|
1730
|
+
for (;;) {
|
|
1731
|
+
// 等待窗口 = min(轮询节拍, 距总时限剩余):既保留 TICK 节拍(未启动宽限的本地检查点),
|
|
1732
|
+
// 又让总时限到点即返回(不再多等一个 TICK)
|
|
1733
|
+
const now = Date.now();
|
|
1734
|
+
const windowMs = Math.max(50, Math.min(TICK * 1000, deadlineMs - now));
|
|
1735
|
+
const event = await waiter.wait(windowMs);
|
|
1736
|
+
if (event === 'turn_end') return null;
|
|
1737
|
+
if (Date.now() >= deadlineMs) {
|
|
1738
|
+
// 总时限兜底:写清原因后按「本轮结束」返回,绝不把行永远钉在执行态
|
|
1739
|
+
//(logPath 为空时 appendLog 静默跳过,不影响收口)
|
|
1740
|
+
appendLog(logPath, `### 会话等待 turn 结束超时(${pyG(limit)}s 无 turn/end)\n`);
|
|
1741
|
+
return null;
|
|
1742
|
+
}
|
|
1743
|
+
if (event === 'interaction') {
|
|
1744
|
+
if (yieldOnInteraction) return STATE_YIELDED; // 已送达;挂起让位(运行位即释放)
|
|
1745
|
+
continue;
|
|
1746
|
+
}
|
|
1747
|
+
if (event === 'error') return null;
|
|
1748
|
+
if (!waiter.started && Date.now() - startedMs > TURN_START_GRACE * 1000) {
|
|
1749
|
+
return null; // turn 未启动:不占着项目位
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
} finally {
|
|
1753
|
+
stopRequested = true;
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
/**
|
|
1758
|
+
* 等 dsh 会话一次 turn 结束——**全局状态流版**(外部会话专用,C 批 T6)。
|
|
1759
|
+
*
|
|
1760
|
+
* 与 `dshWaitTurn`(按会话 SSE)的分工:外部会话不在驱动池中,`/events?session_id=` 与
|
|
1761
|
+
* `/status` 同属池内门禁(404)⇒ 按会话 SSE 一帧都收不到,旧实现随即按「本轮结束」返回
|
|
1762
|
+
* (把「读不到」误判成功)。这里改订阅插件的**全局状态流**(`dshevents`:插件对每个会话都发
|
|
1763
|
+
* `turn/start`/`turn/end`/`driver/interaction`),平台侧**零请求**——等待只是从进程内队列取帧,
|
|
1764
|
+
* 加上每 `TICK` 一次的本地判定(不是远端状态轮询)。
|
|
1765
|
+
*
|
|
1766
|
+
* 返回 null(本轮结束/超时/断连收口)或 STATE_YIELDED(挂起等作答,让出运行位)。
|
|
1767
|
+
* `since`:本轮起点 seq(缺省先取本次投递基线 `DSH_BASELINE`,再回落中枢 `last_seq`)——只用于
|
|
1768
|
+
* 过滤 `turn/start`(`event_seq > since` 才算「本轮起来了」),避免上一轮遗留的 turn/start 被
|
|
1769
|
+
* 误当本轮。**帧口袋优先用投递前的预订阅**(缺陷 A):取不到退回「现订阅」(窗口只是收窄)。
|
|
1770
|
+
* **举证责任在订阅**:`started` 的判定阶梯一个字未改(不许无条件按 `status=='running'` 播种)。
|
|
1771
|
+
* 判定阶梯(顺序即优先级):
|
|
1772
|
+
* ⓪ steer 注入「已在跑的 turn」按已在跑播种 `started`(I1,两个条件缺一不可);
|
|
1773
|
+
* ① 本 sid `turn/start` 且 `data.event_seq > since` ⇒ 记 started;
|
|
1774
|
+
* ② started 且本 sid `turn/end` ⇒ 返回 null(本轮结束);
|
|
1775
|
+
* ③ 本 sid `driver/interaction{state:'asked'}` ⇒ STATE_YIELDED(让位);
|
|
1776
|
+
* ④ 中枢断连(`dshevents.connected()` 为假)⇒ 超时行留痕 + 返回 null(断连=未知:绝不判
|
|
1777
|
+
* 「正常结束」,也绝不把项目运行位钉死);
|
|
1778
|
+
* ⑤ 到 `dshWaitTurnLimit()` 总时限 ⇒ 同款超时行 + 返回 null;
|
|
1779
|
+
* ⑥ started 为假且超 `TURN_START_GRACE` ⇒ 返回 null(turn 压根没起来,不占位)。
|
|
1780
|
+
*
|
|
1781
|
+
* @param {string} sid
|
|
1782
|
+
* @param {number|null} [since]
|
|
1783
|
+
* @param {string} [logPath]
|
|
1784
|
+
* @param {boolean} [yieldOnInteraction]
|
|
1785
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1786
|
+
* @returns {Promise<string|null>}
|
|
1787
|
+
*/
|
|
1788
|
+
export async function dshWaitTurnViaEvents(sid, since = null, logPath = '',
|
|
1789
|
+
yieldOnInteraction = true, options = {}) {
|
|
1790
|
+
if (!sid) return null;
|
|
1791
|
+
if (since === null || since === undefined) {
|
|
1792
|
+
const base = DSH_BASELINE.get(sid);
|
|
1793
|
+
DSH_BASELINE.delete(sid); // 本次投递记下的基线(取走即弃)
|
|
1794
|
+
if (base !== undefined) since = base;
|
|
1795
|
+
}
|
|
1796
|
+
const injectedRaw = DSH_INJECT.get(sid);
|
|
1797
|
+
DSH_INJECT.delete(sid); // 本次投递是否 steer(同上取走即弃)
|
|
1798
|
+
const injected = Boolean(injectedRaw);
|
|
1799
|
+
if (since === null || since === undefined) {
|
|
1800
|
+
since = (dshevents.get(sid) || {}).last_seq;
|
|
1801
|
+
}
|
|
1802
|
+
since = toIntSeq(since);
|
|
1803
|
+
if (!logPath) {
|
|
1804
|
+
const rec = CHAT_REGISTRY.get(sid);
|
|
1805
|
+
logPath = (rec || {}).log_path || '';
|
|
1806
|
+
}
|
|
1807
|
+
const limit = dshWaitTurnLimit(options);
|
|
1808
|
+
const t0 = Date.now();
|
|
1809
|
+
const deadlineMs = t0 + limit * 1000;
|
|
1810
|
+
// —— I1:steer 注入「已在跑的 turn」按已在跑播种 started ——
|
|
1811
|
+
// 两个条件**缺一不可**:① `injected`(本次投递是 steer,`dshSend` 记下的已知事实);
|
|
1812
|
+
// ② 入口本地读一次中枢注册表,实况 `status === "running"`(零请求,不是轮询)。
|
|
1813
|
+
// 为什么需要:steer 的目标是**已经在跑的** turn,它的 `turn/start` 帧早于本订阅
|
|
1814
|
+
//(全局状态流对进程内订阅者无回放),而 `since`(外部会话取中枢 `last_seq`)恒 ≥ 该
|
|
1815
|
+
// turn/start 的 `event_seq` ⇒ 过滤条件 `seq > since` 恒假 ⇒ `started` 恒假 ⇒ 本轮
|
|
1816
|
+
// `turn/end` 被丢弃,只能等满 TURN_START_GRACE(60s):`m:` 行落 done、项目运行位提前释放,
|
|
1817
|
+
// 而外部 turn 仍在跑 ⇒ 下一个排队单元与它**并发写同一工作区**(违「任务按项目串行」红线)。
|
|
1818
|
+
// 为什么不能无条件读 `status=='running'` 当初值:followup 排在正在跑的**旧** turn 之后时
|
|
1819
|
+
//(消息已进 agent inbox、本轮尚未 turn/start),旧 turn 的 `turn/end` 会被误判成本轮结束。
|
|
1820
|
+
let started = injected && (dshevents.get(sid) || {}).status === 'running';
|
|
1821
|
+
// —— 帧口袋(缺陷 A):优先取投递前的预订阅,取不到再自建(老行为)——
|
|
1822
|
+
let sub = dshSubTake(sid);
|
|
1823
|
+
if (sub === null) {
|
|
1824
|
+
sub = new TurnSubscription(sid);
|
|
1825
|
+
dshevents.subscribe(sub._cb);
|
|
1826
|
+
}
|
|
1827
|
+
const inbox = sub.frames;
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* 处理一帧:返回结论(null / 'turn_end' / 'interaction'),必要时推进 started。
|
|
1831
|
+
* 只认本 sid 的帧(全局流上混着所有会话的帧);跨会话帧直接忽略。
|
|
1832
|
+
*
|
|
1833
|
+
* @param {object} frame
|
|
1834
|
+
* @returns {string|null}
|
|
1835
|
+
*/
|
|
1836
|
+
const consume = (frame) => {
|
|
1837
|
+
if (String((frame || {}).session_id || '') !== sid) return null;
|
|
1838
|
+
const typ = String((frame || {}).type || '');
|
|
1839
|
+
const data = (frame || {}).data || {};
|
|
1840
|
+
if (typ === 'turn/start') {
|
|
1841
|
+
const seq = data.event_seq;
|
|
1842
|
+
if (Number.isInteger(seq) && seq > since) started = true;
|
|
1843
|
+
return null;
|
|
1844
|
+
}
|
|
1845
|
+
if (typ === 'turn/end') {
|
|
1846
|
+
// started 是「本轮起来了」的闩:started 之前到达的 turn/end 属于上一轮(投递排队/
|
|
1847
|
+
// 在途时的遗留帧),不能当成本轮结束。started 有两个来源:① 本函数按 turn/start
|
|
1848
|
+
//(seq > since)置真;② 入口按 I1 播种(steer 注入已在跑的 turn)。
|
|
1849
|
+
return started ? 'turn_end' : null;
|
|
1850
|
+
}
|
|
1851
|
+
if (typ === 'driver/interaction' && String(data.state || '') === 'asked') {
|
|
1852
|
+
if (yieldOnInteraction) return 'interaction';
|
|
1853
|
+
// 不让位时继续等:提问意味着 turn 已在跑(同 TurnWaiter.feed 口径)
|
|
1854
|
+
started = true;
|
|
1855
|
+
}
|
|
1856
|
+
return null;
|
|
1857
|
+
};
|
|
1858
|
+
|
|
1859
|
+
try {
|
|
1860
|
+
for (;;) {
|
|
1861
|
+
let frame = inbox.getNowait();
|
|
1862
|
+
if (frame === null) {
|
|
1863
|
+
// 队列已取空:做一轮本地判定(链路/时限/宽限),再按节拍阻塞等帧
|
|
1864
|
+
const now = Date.now();
|
|
1865
|
+
if (!dshevents.connected()) {
|
|
1866
|
+
logWaitTimeout(logPath, `状态流断连:${pyG((now - t0) / 1000)}s 无 turn/end`);
|
|
1867
|
+
return null;
|
|
1868
|
+
}
|
|
1869
|
+
if (now >= deadlineMs) {
|
|
1870
|
+
logWaitTimeout(logPath, `${pyG(limit)}s 无 turn/end`);
|
|
1871
|
+
return null;
|
|
1872
|
+
}
|
|
1873
|
+
if (!started && now - t0 > TURN_START_GRACE * 1000) {
|
|
1874
|
+
return null; // turn 未启动:不占着项目位
|
|
1875
|
+
}
|
|
1876
|
+
// 阻塞等下一帧:窗口 = min(节拍, 距总时限余量, 未起轮时的启动宽限余量)
|
|
1877
|
+
// ——让宽限/时限到点即判定,不被 TICK 推迟(TICK 可被环境放大)
|
|
1878
|
+
let windowMs = Math.min(TICK * 1000, deadlineMs - now);
|
|
1879
|
+
if (!started) windowMs = Math.min(windowMs, TURN_START_GRACE * 1000 - (now - t0));
|
|
1880
|
+
frame = await inbox.get(Math.max(50, windowMs));
|
|
1881
|
+
if (frame === null) continue; // 等价 Python `queue.Empty: continue`
|
|
1882
|
+
}
|
|
1883
|
+
const verdict = consume(frame);
|
|
1884
|
+
if (verdict === 'turn_end') return null;
|
|
1885
|
+
if (verdict === 'interaction') return STATE_YIELDED; // 已送达;挂起让位
|
|
1886
|
+
}
|
|
1887
|
+
} finally {
|
|
1888
|
+
// 订阅必须回收(回调常驻中枢消费者列表里);预订阅与自建订阅同款:取走即弃
|
|
1889
|
+
//(`dshSubTake` 已出旁表)+ 这里 `close()` 幂等退订。
|
|
1890
|
+
sub.close();
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
// --------------------------------------------------------------------------
|
|
1895
|
+
// 消息投递体(chat.py:1016-1122)
|
|
1896
|
+
// --------------------------------------------------------------------------
|
|
1897
|
+
|
|
1898
|
+
/**
|
|
1899
|
+
* dsh 插件族消息投递体:写对话日志 → 投递 → 等 turn 结束。
|
|
1900
|
+
*
|
|
1901
|
+
* turn 结束由插件推来的 turn/end 帧判定(事件驱动,零轮询);「挂起等作答」由
|
|
1902
|
+
* driver/interaction 帧实时告知。等轮次走 `waitTurn` 分流口(T6 收口):外部会话
|
|
1903
|
+
* (无卡消息路径的飞书绑定会话、平台重启后变 `owned:false` 的任务会话)订阅**全局状态流**
|
|
1904
|
+
* ——按会话 SSE 对池外会话是 404(「读不到」会被误判成本轮结束)。本会话的基线 `since` 与
|
|
1905
|
+
* 对话日志落点 `logPath` 逐字透传(缺陷 G 的总时限原因行必须落这条日志)。
|
|
1906
|
+
*
|
|
1907
|
+
* **async**。
|
|
1908
|
+
*
|
|
1909
|
+
* @param {object} project 项目行(消费 `work_dir`)
|
|
1910
|
+
* @param {string} sid
|
|
1911
|
+
* @param {string} message
|
|
1912
|
+
* @param {boolean} inject
|
|
1913
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1914
|
+
* @returns {Promise<string|null>}
|
|
1915
|
+
*/
|
|
1916
|
+
export async function dshSendNow(project, sid, message, inject, options = {}) {
|
|
1917
|
+
ensureRuntimeDirs(project.work_dir);
|
|
1918
|
+
const logDir = runtimeDir(project.work_dir, logDirName());
|
|
1919
|
+
const logPath = path.join(logDir, `chat_dsh_${Math.trunc(Date.now() / 1000)}.log`);
|
|
1920
|
+
// 这一处**不用** appendLog:Python 是裸 `open(log_path, "ab")`——写失败(目录建不出来等)
|
|
1921
|
+
// 会让本条消息落 error,而 `runner.append_log` 的语义是吞 OSError(那是超时留痕的口径)。
|
|
1922
|
+
// `Buffer.from(…, 'utf8')` 对孤立代理对给 U+FFFD,等价 Python 的 errors="replace"。
|
|
1923
|
+
fs.appendFileSync(logPath, Buffer.from(
|
|
1924
|
+
`### PROMPT ${pyJsonDumps(message)}\n### DRIVER dsh_plugin sid=${sid} `
|
|
1925
|
+
+ `${inject ? 'steer' : 'followup'}\n`, 'utf8',
|
|
1926
|
+
));
|
|
1927
|
+
const since = await dshSend(sid, message, inject, options);
|
|
1928
|
+
register(sid, null, message, { logPath, dshPlugin: true, since });
|
|
1929
|
+
// 分流口:池内/未知 ⇒ 既有按会话 SSE(显式传本会话日志 + 基线);外部 ⇒ 全局状态流
|
|
1930
|
+
return waitTurn(project.work_dir, sid, 'dsh_plugin', since, logPath, options);
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
/**
|
|
1934
|
+
* 等一次会话 turn 结束(dsh 单族:订阅事件流等 turn/end,挂起即让位)。
|
|
1935
|
+
*
|
|
1936
|
+
* dsh_plugin:不走轮询——直接订阅插件推来的事件流等 turn/end,「挂起等作答」由
|
|
1937
|
+
* driver/interaction 帧实时告知(见 `dshWaitTurn`)。不设总时长上限(跑到 turn 结束,点
|
|
1938
|
+
* 「停止」可中断),turn 未启动的宽限判定(`TURN_START_GRACE`)与总时限兜底
|
|
1939
|
+
* (`DSH_WAIT_TURN_TIMEOUT`)都在 `dshWaitTurn` 内。挂起让位:turn 挂起等用户作答时返回
|
|
1940
|
+
* STATE_YIELDED——消息已送达,调用方据此落让位终态并释放运行位,队首单元照常起跑。
|
|
1941
|
+
*
|
|
1942
|
+
* 非 dsh 族(已全部退场):无 turn 可等,恒返回 null(无投递路径,不会走到)。**async**。
|
|
1943
|
+
*
|
|
1944
|
+
* @param {string} projectDir
|
|
1945
|
+
* @param {string} sid
|
|
1946
|
+
* @param {string} family
|
|
1947
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1948
|
+
* @returns {Promise<string|null>}
|
|
1949
|
+
*/
|
|
1950
|
+
export async function waitWebBusy(projectDir, sid, family, options = {}) {
|
|
1951
|
+
if (family !== 'dsh_plugin') return null;
|
|
1952
|
+
return dshWaitTurn(sid, null, true, '', options);
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* 等一次会话 turn 结束的**分流口**(C 批 T6):按会话归属选等待通道。
|
|
1957
|
+
*
|
|
1958
|
+
* 外部会话(用户在 dsh GUI 里直跑/接管,注册表 `owned:false`)不在驱动池中,
|
|
1959
|
+
* `/events?session_id=` 与 `/status` 同属池内门禁(404)⇒ 按会话 SSE 等不到任何帧,「等不到」
|
|
1960
|
+
* 又被当成「本轮结束」(外部会话被误判成功的根因)。故外部会话改走**全局状态流**
|
|
1961
|
+
* (`dshWaitTurnViaEvents`:插件每会话都发 turn/start|turn/end,平台侧零请求)。
|
|
1962
|
+
*
|
|
1963
|
+
* 其余情况一律走既有 `waitWebBusy`(按会话 SSE),**行为一个字节不变**:
|
|
1964
|
+
* - `owned:true` 平台自持会话:权威等待通道就是按会话 SSE;
|
|
1965
|
+
* - 注册表未知(`dshevents.get` 返回 null:链路断连 / 没见过该 sid):**未知 ≠ 外部**
|
|
1966
|
+
* ——此时无法判定它是外部会话,若改走事件流路,链路降级期会把池内会话立刻按超时收口
|
|
1967
|
+
* (运行位提前释放),是净回归。
|
|
1968
|
+
* 未对齐(热重载后 /live 空快照、旧表保留)不单独判:链路仍在线、帧照旧折叠,行里的
|
|
1969
|
+
* `owned` 仍是可用判定依据(与前置闸同口径:只有「读不到行」才算未知)。
|
|
1970
|
+
*
|
|
1971
|
+
* `since` / `logPath` 语义分两种调用形状(照 Python):
|
|
1972
|
+
* - **都不传**(既有形状):池内/未知照旧原样调 `waitWebBusy`;
|
|
1973
|
+
* - **传了任一个**:池内/未知改走 `dshWaitTurn`——与旧 `dshSendNow` 直调同参数同返回值。
|
|
1974
|
+
* 外部分支两种形状一致:`dshWaitTurnViaEvents`。**async**。
|
|
1975
|
+
*
|
|
1976
|
+
* @param {string} projectDir
|
|
1977
|
+
* @param {string} sid
|
|
1978
|
+
* @param {string} family
|
|
1979
|
+
* @param {number|null} [since]
|
|
1980
|
+
* @param {string} [logPath]
|
|
1981
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
1982
|
+
* @returns {Promise<string|null>}
|
|
1983
|
+
*/
|
|
1984
|
+
export async function waitTurn(projectDir, sid, family, since = null, logPath = '', options = {}) {
|
|
1985
|
+
const st = sid ? dshevents.get(sid) : null;
|
|
1986
|
+
if (st === null || st.owned) {
|
|
1987
|
+
if ((since === null || since === undefined) && !logPath) {
|
|
1988
|
+
return waitWebBusy(projectDir, sid, family, options); // 既有形状:一字未动
|
|
1989
|
+
}
|
|
1990
|
+
if (family !== 'dsh_plugin') return null; // 族守卫(与 waitWebBusy 同口径)
|
|
1991
|
+
return dshWaitTurn(sid, since, true, logPath, options);
|
|
1992
|
+
}
|
|
1993
|
+
return dshWaitTurnViaEvents(sid, since, logPath, true, options);
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
/**
|
|
1997
|
+
* 任务/纯会话消息的实际发送(统一队列单元执行体调用)。
|
|
1998
|
+
*
|
|
1999
|
+
* dsh_plugin(唯一族):投递 = driver 的 followup/steer,等 turn 结束由事件流判定
|
|
2000
|
+
* (见 `dshSendNow`);turn 挂起等作答则返回 STATE_YIELDED(挂起让位)——返回时才释放项目
|
|
2001
|
+
* 占用,执行期间不会与项目内其他单元并发(让位除外:挂起即提前释放)。**async**。
|
|
2002
|
+
*
|
|
2003
|
+
* @param {object} task
|
|
2004
|
+
* @param {object} project
|
|
2005
|
+
* @param {string} family
|
|
2006
|
+
* @param {string} sid
|
|
2007
|
+
* @param {string} message
|
|
2008
|
+
* @param {boolean} inject
|
|
2009
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
2010
|
+
* @returns {Promise<string|null>}
|
|
2011
|
+
*/
|
|
2012
|
+
export async function sendNow(task, project, family, sid, message, inject, options = {}) {
|
|
2013
|
+
if (family !== 'dsh_plugin') {
|
|
2014
|
+
// 单族世界:其余族已全部退场(B0 防呆口径),走到这儿说明有残留调用路径
|
|
2015
|
+
throw new Error(`该智能体族已下线,请在项目设置里改绑 dsh 插件:${family || '未知'}`);
|
|
2016
|
+
}
|
|
2017
|
+
return dshSendNow(project, sid, message, inject, options);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
/**
|
|
2021
|
+
* 「立即注入」投递体(`injectNow` 调用;不等本轮结束)。
|
|
2022
|
+
*
|
|
2023
|
+
* dsh_plugin(唯一族):直接 steer(dsh 的 steer 语义就是注入最近 step 边界,空闲则起一轮)
|
|
2024
|
+
* ——不轮询 turn 结束、不持有项目占用,因为用户点「立即注入」的语义就是不要排队。
|
|
2025
|
+
* `project`/`model` 保留为兼容既有调用签名(dsh 投递不带模型)。**async**。
|
|
2026
|
+
*
|
|
2027
|
+
* @param {object} project
|
|
2028
|
+
* @param {string} sid
|
|
2029
|
+
* @param {number|null} taskId
|
|
2030
|
+
* @param {string} message
|
|
2031
|
+
* @param {string} model
|
|
2032
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
2033
|
+
* @returns {Promise<void>}
|
|
2034
|
+
*/
|
|
2035
|
+
export async function injectSend(project, sid, taskId, message, model, options = {}) {
|
|
2036
|
+
const since = await dshSend(sid, message, true, options);
|
|
2037
|
+
register(sid, taskId, message, { dshPlugin: true, since });
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
// --------------------------------------------------------------------------
|
|
2041
|
+
// 入口(chat.py:1125-1189)
|
|
2042
|
+
// --------------------------------------------------------------------------
|
|
2043
|
+
|
|
2044
|
+
/**
|
|
2045
|
+
* 发起对话(走统一队列):登记消息单元并提交 runner,返回消息记录 dict。
|
|
2046
|
+
*
|
|
2047
|
+
* - 项目空闲:worker 立刻拾起执行(毫秒级),`queued=false`;
|
|
2048
|
+
* - 项目忙(同项目有任务/卡片会话在跑):按入队顺序排队,`queued=true`。
|
|
2049
|
+
* 排队中的消息可「立即注入」(不等队列直接投递,见 `injectNow`):family/model 提交时定格
|
|
2050
|
+
* 进等待项 meta,执行时按快照重建(见 `submit`)。**async**。
|
|
2051
|
+
*
|
|
2052
|
+
* @param {object} task 任务行(消费 `id` / `session_id` / `model`)
|
|
2053
|
+
* @param {object} project 项目行(消费 `id` / `agent_path` / `model`)
|
|
2054
|
+
* @param {string} message
|
|
2055
|
+
* @param {boolean} [inject]
|
|
2056
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
2057
|
+
* @returns {Promise<{id: string, state: string, queued: boolean}>}
|
|
2058
|
+
*/
|
|
2059
|
+
export async function start(task, project, message, inject = false, options = {}) {
|
|
2060
|
+
const family = agentFamily(project.agent_path);
|
|
2061
|
+
const sid = task.session_id;
|
|
2062
|
+
const model = String(task.model || '').trim() || String(project.model || '').trim();
|
|
2063
|
+
return submit(project.id, sid, message, {
|
|
2064
|
+
taskId: task.id, inject, family, model, ...options,
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/**
|
|
2069
|
+
* 停止进行中的对话:先取消排队中的消息,再中断正在跑的 turn。
|
|
2070
|
+
*
|
|
2071
|
+
* dsh 插件族:cancel 优雅中断当前 turn(保留已流式交付的文本);inbox 里的排队项一并清掉
|
|
2072
|
+
* (停止 = 不再执行这条会话上排着的工作)。返回是否命中了运行中的对话(排队取消不算命中)。
|
|
2073
|
+
* **async**(中断是驱动调用)。
|
|
2074
|
+
*
|
|
2075
|
+
* @param {string} sid
|
|
2076
|
+
* @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
2077
|
+
* @returns {Promise<boolean>}
|
|
2078
|
+
*/
|
|
2079
|
+
export async function stop(sid, options = {}) {
|
|
2080
|
+
cancelQueued({ sid }, options);
|
|
2081
|
+
const rec = CHAT_REGISTRY.get(sid);
|
|
2082
|
+
if (rec === undefined || !rec.dsh_plugin) return false;
|
|
2083
|
+
try {
|
|
2084
|
+
await driverCancel(sid);
|
|
2085
|
+
return true;
|
|
2086
|
+
} catch (err) {
|
|
2087
|
+
if (err && err.name === 'DshDriverError') return false;
|
|
2088
|
+
throw err;
|
|
2089
|
+
}
|
|
2090
|
+
}
|