@gavinc-cn/touchstone-dsh 0.1.1 → 0.2.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 +21 -1
- package/README.zh.md +11 -1
- package/auth.py +0 -0
- package/board.py +775 -71
- 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 +590 -17
- package/db.py +62 -11
- package/dsh-plugin/cordis.patch.yml +7 -3
- package/dsh-plugin/install.sh +8 -5
- package/dsh-plugin/lib/agent-driver.js +710 -89
- package/dsh-plugin/lib/client.bundle.js +94 -9
- package/dsh-plugin/lib/index.js +242 -22
- package/dsh-plugin/scripts/build.mjs +0 -0
- package/dsh-plugin/scripts/prepack-check.mjs +0 -0
- package/dsh-plugin/scripts/select-bundle.mjs +0 -0
- package/dsh-plugin/src/client.js +94 -9
- package/dshdriver.py +74 -5
- package/dshevents.py +65 -3
- 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 +1197 -34
- package/feishu_conv.py +920 -0
- package/lib.py +0 -0
- package/lifecycle.py +0 -0
- package/loadcase.py +0 -0
- package/loadgen.py +0 -0
- package/localbus.py +0 -0
- package/migrate_p7b.py +0 -0
- package/package.json +1 -1
- package/platcompat.py +0 -0
- package/prompts.py +0 -0
- package/rag.py +0 -0
- package/requirements.txt +2 -1
- package/server.py +218 -26
- package/sessparse.py +150 -44
- package/touchstone.py +6 -3
- package/waitq.py +23 -0
- package/webui/dist-plugin/assets/index-JLwyZZxC.js +484 -0
- package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +1 -0
- package/webui/dist-plugin/index.html +2 -2
- package/worktree.py +0 -0
- package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
- package/webui/dist-plugin/assets/index-DtwKoi1T.js +0 -473
package/chat.py
CHANGED
|
@@ -22,14 +22,21 @@
|
|
|
22
22
|
排队消息「立即注入」(2026-09-14):平台排队中的消息可不等队列,用户点按钮即
|
|
23
23
|
撤销排队并立即投递到目标会话(dsh:steer 注入当前 turn 的最近 step 边界,会话
|
|
24
24
|
空闲则立即起轮)——见 inject_now。
|
|
25
|
+
|
|
26
|
+
外部会话(C 批 T5,2026-10-10):用户在 dsh GUI 里直跑/接管的会话(`owned:false`)
|
|
27
|
+
也可投递,但要先在**入队前**过 `_external_preflight`(宿主有活 agent ∧ 平台已向
|
|
28
|
+
驱动声明看管,`board._ensure_watch`);前提不成立即抛 `DeliveryRefused`,消息
|
|
29
|
+
**不落 chat_msgs/wait_items 行**(「必然失败的静默排队」变成「发送即明确文案」)。
|
|
25
30
|
"""
|
|
26
31
|
|
|
27
32
|
import json
|
|
28
33
|
import os
|
|
34
|
+
import queue
|
|
29
35
|
import threading
|
|
30
36
|
import time
|
|
31
37
|
|
|
32
38
|
import db
|
|
39
|
+
import dshevents
|
|
33
40
|
import dshdriver
|
|
34
41
|
import lib
|
|
35
42
|
import runner
|
|
@@ -75,6 +82,24 @@ class InjectRefused(RuntimeError):
|
|
|
75
82
|
"""
|
|
76
83
|
|
|
77
84
|
|
|
85
|
+
class DeliveryRefused(RuntimeError):
|
|
86
|
+
"""外部会话投递前置闸拒绝(C 批 T5,2026-10-10):消息**未入队、未落行**。
|
|
87
|
+
|
|
88
|
+
与投递失败(`DshDriverError`,消息落 `chat_msgs.state=error`)的区分:本异常在
|
|
89
|
+
入队**之前**由 `_external_preflight` 抛出,chat_msgs/wait_items 都查不到这次
|
|
90
|
+
发送——把「必然失败的静默排队(用户等 16ms 才看到 404 + toast)」变成「点发送
|
|
91
|
+
即刻的明确文案」。触发面只有外部会话(`owned:false`)且前提不成立:
|
|
92
|
+
宿主无活 agent(会话已结束)/ 看管声明失败(未获投递许可)/ 回滚阀关闭。
|
|
93
|
+
池内会话与未对齐(未知)路径**不会**抛本异常(不变量:现状一个字节不变)。
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# 外部会话投递总开关(回滚阀,设计 §10):`TS_EXTERNAL_DELIVER=0` ⇒ 外部会话一律
|
|
98
|
+
# 拒投(前置闸退化为「外部会话一律拒绝」),池内会话完全不受影响。**每次调用现读**
|
|
99
|
+
# (与 `_dsh_wait_turn_limit` 同口径):值在进程内可热改,不必重启平台。
|
|
100
|
+
EXTERNAL_DELIVER_ENV = "TS_EXTERNAL_DELIVER"
|
|
101
|
+
|
|
102
|
+
|
|
78
103
|
# 可会话续聊的 agent 族(P7b 单族化:只剩 dsh 插件形态)。
|
|
79
104
|
# dsh_plugin(路线 A):会话在 dsh 宿主进程内常驻,投递 = followup/steer,
|
|
80
105
|
# 且 sid 由插件在轮次开始即精确返回(不再靠 mtime 猜)。已退场族不在列——
|
|
@@ -91,8 +116,58 @@ def _new_msg_id():
|
|
|
91
116
|
return f"{int(time.time() * 1000)}-{_MSG_SEQ[0]}"
|
|
92
117
|
|
|
93
118
|
|
|
119
|
+
def _external_deliver_enabled():
|
|
120
|
+
"""外部会话投递是否开启(`TS_EXTERNAL_DELIVER=0` 关闭;缺省/非法值按开启)。"""
|
|
121
|
+
return (os.environ.get(EXTERNAL_DELIVER_ENV) or "").strip() != "0"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _external_preflight(sid):
|
|
125
|
+
"""投递前置闸(C 批 T5):外部会话前提不成立时 **raise DeliveryRefused**(不入队)。
|
|
126
|
+
|
|
127
|
+
判定阶梯(设计 §5.2;顺序即优先级):
|
|
128
|
+
① 空 sid ⇒ 放行(既有路径自己报「会话 id 为空」,不改语义);
|
|
129
|
+
② 注册表未知(`dshevents.get` 为 None:未连接 / 没见过该 sid)⇒ 放行;
|
|
130
|
+
③ 注册表未对齐(`dshevents.aligned()` 为假:热重载后 /live 空表,快照不可信)
|
|
131
|
+
⇒ 放行——**未知 ≠ 外部**,绝不据不可信快照拒投(与「断连=未知」同一不变量);
|
|
132
|
+
④ `owned` 为真(平台自持会话)⇒ 放行,走池内原路**一个字节不变**;
|
|
133
|
+
⑤ 其余=外部会话(用户在 dsh GUI 里直跑/接管):要求「宿主有活 agent」
|
|
134
|
+
(`status != 'unknown'`,同插件 `live ? live.status : 'unknown'` 口径)
|
|
135
|
+
∧「看管声明成功」(`board._ensure_watch`,幂等,失败不重试)——任一不满足
|
|
136
|
+
即拒投,并按分类文案抛错。
|
|
137
|
+
回滚阀 `TS_EXTERNAL_DELIVER=0`:第 ⑤ 步整段退化为「外部会话一律拒绝」(连看管
|
|
138
|
+
都不声明),池内/未知路径照旧放行。
|
|
139
|
+
|
|
140
|
+
为什么放在 `submit` 首行:投递的**唯一入口**是统一队列(chat_msgs 行 + m: 等待项
|
|
141
|
+
一个事务),一旦落行就必然要等 worker 拾取、失败也只能落 error 终态——闸必须在
|
|
142
|
+
落行之前。**循环导入红线**:`board` 顶层 `import chat`,故 `import board` 写在
|
|
143
|
+
函数体内(同文件 `_rebuild_run`/`_rebuild_inject` 既有先例)。
|
|
144
|
+
"""
|
|
145
|
+
if not sid:
|
|
146
|
+
return
|
|
147
|
+
st = dshevents.get(sid)
|
|
148
|
+
if st is None:
|
|
149
|
+
return # 未知:保持现状(真失败仍由投递错误兜底)
|
|
150
|
+
if not dshevents.aligned():
|
|
151
|
+
return # 不可信快照:不据此拒投(未知 ≠ 外部)
|
|
152
|
+
if st.get("owned"):
|
|
153
|
+
return # 平台自持会话:池内原路
|
|
154
|
+
# —— 以下为外部会话(用户在 dsh GUI 直跑/接管):需要显式许可 ——
|
|
155
|
+
if not _external_deliver_enabled():
|
|
156
|
+
raise DeliveryRefused(
|
|
157
|
+
"该会话是 dsh 直跑会话,平台已关闭外部会话投递"
|
|
158
|
+
f"({EXTERNAL_DELIVER_ENV}=0),请到 dsh 会话窗口发送")
|
|
159
|
+
if str(st.get("status") or "") == "unknown":
|
|
160
|
+
# 宿主里没有活 agent(会话已结束/被销毁):投递必然 404,别让用户白等
|
|
161
|
+
raise DeliveryRefused("会话已结束,无法投递(宿主无活动 agent)")
|
|
162
|
+
import board # 函数内 import:board 顶层 import chat,防循环导入
|
|
163
|
+
if not board._ensure_watch(sid):
|
|
164
|
+
raise DeliveryRefused(
|
|
165
|
+
"该会话是 dsh 直跑会话,平台未获投递许可(看管声明失败:插件过旧或不可达),"
|
|
166
|
+
"请到 dsh 会话窗口发送")
|
|
167
|
+
|
|
168
|
+
|
|
94
169
|
def submit(project_id, sid, message, task_id=None, card_id=None, comment_id=None,
|
|
95
|
-
inject=False, family="", model=""):
|
|
170
|
+
inject=False, family="", model="", extra_meta=None):
|
|
96
171
|
"""登记一条会话消息并投入统一队列(项目忙则排队,空闲则立即执行)。
|
|
97
172
|
|
|
98
173
|
P3 起权威在 chat_msgs 表 + msg 等待项(waitq.msg_enqueue 一个事务双写)。
|
|
@@ -101,15 +176,22 @@ def submit(project_id, sid, message, task_id=None, card_id=None, comment_id=None
|
|
|
101
176
|
dsh_plugin 的消息可「立即注入」(_rebuild_inject)。
|
|
102
177
|
comment_id:卡片评论投递路径的评论行 id(终态写回 db.update_board_comment 用),
|
|
103
178
|
随 meta 持久化(chat_msgs 无此列,P3 计划裁决 R1)。
|
|
179
|
+
extra_meta:调用方附加载荷(如飞书回流的 {"feishu": {...}}),键与
|
|
180
|
+
family/model/comment_id **平铺**进同一个 meta;键名由调用方自带命名空间
|
|
181
|
+
(飞书侧统一 `{"feishu": {...}}`),避免与既有键相撞。
|
|
182
|
+
task_id 与 card_id 同时为空=纯会话消息(飞书通用对话等):无任务轮次、无
|
|
183
|
+
卡片评论写回,按行重建时走纯会话投递分支(见 _rebuild_run/_rebuild_inject)。
|
|
104
184
|
runner 单例不可用(单测/独立脚本)时同步执行并原样抛出异常(保持既有行为,
|
|
105
185
|
行照常落表、不写等待项——P5 R13 收口:无拾取方,等待项只会成为孤儿行)。
|
|
106
186
|
返回消息记录 dict(含 id/state/queued,供端点响应)。
|
|
107
187
|
"""
|
|
188
|
+
_external_preflight(sid) # 首行前置闸(C 批 T5):外部会话未获许可即拒投(不落行)
|
|
108
189
|
msg_id = _new_msg_id()
|
|
109
190
|
waitq.msg_prune(MSG_KEEP_SEC, MSG_MAX) # 终态回收(msg_prune 表版,R8)
|
|
110
191
|
inst = runner.INSTANCE
|
|
111
192
|
payload_meta = {"family": family, "model": model,
|
|
112
|
-
**({"comment_id": comment_id} if comment_id else {})
|
|
193
|
+
**({"comment_id": comment_id} if comment_id else {}),
|
|
194
|
+
**(extra_meta or {})}
|
|
113
195
|
waitq.msg_enqueue(msg_id, project_id, sid, message, task_id=task_id,
|
|
114
196
|
card_id=card_id, inject=bool(inject),
|
|
115
197
|
meta=payload_meta, queue=inst is not None)
|
|
@@ -159,8 +241,10 @@ def _msg_payload(msg_id):
|
|
|
159
241
|
def _rebuild_run(msg_id, meta=None):
|
|
160
242
|
"""按行重建消息执行体(P3:闭包改按 kind 分派——设计 §5.3/§6.2)。
|
|
161
243
|
|
|
162
|
-
|
|
163
|
-
|
|
244
|
+
纯会话消息(task_id 与 card_id 皆空,如飞书通用对话)→ _send_now(合成
|
|
245
|
+
task dict 只为沿用既有调用形状:单族世界里 _send_now 只做族校验与 dsh 分派,
|
|
246
|
+
不再消费其中的 id/model);
|
|
247
|
+
任务侧消息(task_id 非空)→ _send_now(合成 task dict 同上);
|
|
164
248
|
卡片评论(card_id 非空)→ board._deliver_unit(合成 card/comment dict,
|
|
165
249
|
执行体只消费 id/session_id/model 与 comment id);
|
|
166
250
|
函数内 import 防循环(同 runner._worker 对 board 的先例)。
|
|
@@ -169,6 +253,11 @@ def _rebuild_run(msg_id, meta=None):
|
|
|
169
253
|
row, _meta, project = _msg_payload(msg_id)
|
|
170
254
|
if meta is None:
|
|
171
255
|
meta = _meta
|
|
256
|
+
if row["task_id"] is None and row["card_id"] is None:
|
|
257
|
+
# 纯会话消息(飞书通用对话等):无任务无卡片,直接投递会话
|
|
258
|
+
return lambda: _send_now({"id": None, "model": meta.get("model", "")},
|
|
259
|
+
project, meta.get("family", ""),
|
|
260
|
+
row["sid"], row["message"], bool(row["inject"]))
|
|
172
261
|
if row["task_id"] is not None:
|
|
173
262
|
task = {"id": row["task_id"], "model": meta.get("model", "")}
|
|
174
263
|
return lambda: _send_now(task, project, meta.get("family", ""),
|
|
@@ -183,9 +272,13 @@ def _rebuild_run(msg_id, meta=None):
|
|
|
183
272
|
|
|
184
273
|
def _rebuild_inject(msg_id):
|
|
185
274
|
"""「立即注入」投递体重建(与 _rebuild_run 同载荷源):
|
|
186
|
-
|
|
187
|
-
|
|
275
|
+
纯会话消息(task_id 与 card_id 皆空)→ _inject_send(steer 注入当前 turn,
|
|
276
|
+
投递后即返回,不落卡片);任务侧 → _inject_send(同上,带任务 id);
|
|
277
|
+
卡片评论 → board._deliver_now(inject=True)。族不支持由 inject_now 前置拦截。"""
|
|
188
278
|
row, meta, project = _msg_payload(msg_id)
|
|
279
|
+
if row["task_id"] is None and row["card_id"] is None:
|
|
280
|
+
return lambda: _inject_send(project, row["sid"], None,
|
|
281
|
+
row["message"], meta.get("model", ""))
|
|
189
282
|
if row["task_id"] is not None:
|
|
190
283
|
return lambda: _inject_send(project, row["sid"], row["task_id"],
|
|
191
284
|
row["message"], meta.get("model", ""))
|
|
@@ -197,6 +290,34 @@ def _rebuild_inject(msg_id):
|
|
|
197
290
|
row["message"], inject=True)
|
|
198
291
|
|
|
199
292
|
|
|
293
|
+
_MSG_DONE_HOOK = None # 消息单元终态钩子 fn(msg_id, state)(飞书回流用,单一注册位)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def set_msg_done_hook(fn):
|
|
297
|
+
"""注册/注销(fn=None)消息单元终态钩子;回调异常由 _fire_msg_done 吞掉。
|
|
298
|
+
|
|
299
|
+
单一注册位(后注册覆盖先注册):注册方是 feishu_conv.start(server.main 经
|
|
300
|
+
feishu.start_notifier 调用一次)。未注册时 run_unit 行为与加钩子前逐字等价。
|
|
301
|
+
"""
|
|
302
|
+
global _MSG_DONE_HOOK
|
|
303
|
+
_MSG_DONE_HOOK = fn
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _fire_msg_done(msg_id, state):
|
|
307
|
+
"""触发消息单元终态钩子(done/yielded/error 各一次);异常绝不影响队列收口。
|
|
308
|
+
|
|
309
|
+
钩子是旁路(飞书回流的答复推送):它跑在**终态记录已落库之后**,自身抛错
|
|
310
|
+
(推送失败、会话读失败等)只留痕——队列收口与钩子成败解耦。
|
|
311
|
+
"""
|
|
312
|
+
fn = _MSG_DONE_HOOK
|
|
313
|
+
if fn is None:
|
|
314
|
+
return
|
|
315
|
+
try:
|
|
316
|
+
fn(msg_id, state)
|
|
317
|
+
except Exception as e:
|
|
318
|
+
print(f"[chat] 终态钩子异常: {msg_id}: {e}", flush=True)
|
|
319
|
+
|
|
320
|
+
|
|
200
321
|
def run_unit(msg_id):
|
|
201
322
|
"""统一队列执行体(runner worker 调用):执行一条待跑消息;异常落记录不抛出。
|
|
202
323
|
|
|
@@ -204,7 +325,9 @@ def run_unit(msg_id):
|
|
|
204
325
|
waitq.claim 互斥互补:等待项被取消/被注入抢先时此处让行);执行体按行+
|
|
205
326
|
快照重建;终态 done/yielded/error 落表,等待项行终态在同函数闭环(裁决 R6——
|
|
206
327
|
终态归执行体,runner 影子终态段不再管 m:)。yielded=挂起让位(2026-09-27):
|
|
207
|
-
turn 等用户作答,运行位即行——行终态即释放,队首单元照常起跑。
|
|
328
|
+
turn 等用户作答,运行位即行——行终态即释放,队首单元照常起跑。
|
|
329
|
+
三条终态路各触发一次 `_fire_msg_done`(Task 6 飞书回流钩子:done=推答复、
|
|
330
|
+
yielded=不推(交既有作答链路)、error=推失败回执)。"""
|
|
208
331
|
try:
|
|
209
332
|
row = waitq.msg_get(msg_id)
|
|
210
333
|
if row is None or row["state"] != STATE_QUEUED:
|
|
@@ -218,6 +341,7 @@ def run_unit(msg_id):
|
|
|
218
341
|
except Exception as e:
|
|
219
342
|
waitq.msg_finish(msg_id, STATE_ERROR, str(e))
|
|
220
343
|
waitq.finish_by_target(waitq.KIND_MSG, msg_id, waitq.STATE_FAILED, str(e))
|
|
344
|
+
_fire_msg_done(msg_id, STATE_ERROR)
|
|
221
345
|
return
|
|
222
346
|
if res == STATE_YIELDED:
|
|
223
347
|
print(f"[chat] 消息单元挂起让位:m:{msg_id}(turn 等待作答)", flush=True)
|
|
@@ -226,9 +350,11 @@ def run_unit(msg_id):
|
|
|
226
350
|
"reason": "turn 等待作答"})
|
|
227
351
|
waitq.msg_finish(msg_id, STATE_YIELDED)
|
|
228
352
|
waitq.finish_by_target(waitq.KIND_MSG, msg_id, waitq.STATE_DONE)
|
|
353
|
+
_fire_msg_done(msg_id, STATE_YIELDED)
|
|
229
354
|
return
|
|
230
355
|
waitq.msg_finish(msg_id, STATE_DONE)
|
|
231
356
|
waitq.finish_by_target(waitq.KIND_MSG, msg_id, waitq.STATE_DONE)
|
|
357
|
+
_fire_msg_done(msg_id, STATE_DONE)
|
|
232
358
|
except Exception as e: # 防线程拖死(DB 故障等);行滞留由 selfcheck 兜底
|
|
233
359
|
print(f"[chat] 消息单元异常: {msg_id}: {e}", flush=True)
|
|
234
360
|
try: # 尽力把等待项落 failed(chat_msgs 已不可写时由 selfcheck 兜底)
|
|
@@ -236,6 +362,7 @@ def run_unit(msg_id):
|
|
|
236
362
|
waitq.STATE_FAILED, str(e)[:300])
|
|
237
363
|
except Exception:
|
|
238
364
|
pass
|
|
365
|
+
_fire_msg_done(msg_id, STATE_ERROR) # 最外层异常同样只触发一次
|
|
239
366
|
|
|
240
367
|
|
|
241
368
|
def _reap_orphan_claim(msg_id):
|
|
@@ -385,6 +512,153 @@ def _register(sid, task_id, message, log_path="", dsh_plugin=False, since=None):
|
|
|
385
512
|
# 只在本进程、同一「投递→等待」序列内使用;wait 取走即弃(防陈旧基线误判)。
|
|
386
513
|
_DSH_BASELINE = {}
|
|
387
514
|
|
|
515
|
+
# sid -> 本次投递是否 steer(「立即注入」/inject=True)——与 `_DSH_BASELINE` 同款
|
|
516
|
+
# 「投递时记、等待时取走即弃」的旁表(I1 终审修复,2026-10-10)。
|
|
517
|
+
# 用途只有一个:`dsh_wait_turn_via_events` 判定「本轮 turn/start 帧早于订阅」时,
|
|
518
|
+
# 仅当**本次投递是 steer 注入**才按「已在跑」播种 `started`(见该函数 docstring);
|
|
519
|
+
# 每次 `dsh_send` 都写(True/False 都写,旧值必须被覆盖——否则上一次 steer 的
|
|
520
|
+
# 残留 True 会毒化下一次 followup 的等待)。池内等待器 `dsh_wait_turn` 不读它。
|
|
521
|
+
_DSH_INJECT = {}
|
|
522
|
+
|
|
523
|
+
# ---------- 投递前预订阅(缺陷 A 修复,2026-10-10 T9b 真机复跑发现) ----------
|
|
524
|
+
#
|
|
525
|
+
# 缺陷(真机 run1–run7,6/6 命中):外部会话的普通 followup 投递里,`turn/start`
|
|
526
|
+
# 帧到中枢在 +0.072~0.143s,而等待器的 `dshevents.subscribe` 在 +0.084~0.152s
|
|
527
|
+
# ⇒ **帧早到 9~13ms**;全局状态流对进程内订阅者**无回放** ⇒ 该帧永远看不到
|
|
528
|
+
# ⇒ `started` 恒假 ⇒ 本轮 `turn/end` 被丢弃 ⇒ 只能等满 `TURN_START_GRACE`(60s)
|
|
529
|
+
# 收口(`m:` 行在 turn/end 帧后 56~57s 才落 done)。有卡路由 `ext:` 行兜住占用
|
|
530
|
+
# (无实害,只是状态滞后 60s);**无卡路(任务侧直送 / 飞书绑定会话)会真的提前
|
|
531
|
+
# 释放项目运行位**,与仍在跑的外部 turn 并发改同一工作区 ⇒ 违「任务按项目串行」
|
|
532
|
+
# 红线。
|
|
533
|
+
#
|
|
534
|
+
# 修法:把「订阅 + 帧队列」从等待器里抽成 `_TurnSubscription`,`dsh_send` 在
|
|
535
|
+
# **投递之前**先建订阅并放进旁表 `_DSH_SUB`(与 `_DSH_BASELINE`/`_DSH_INJECT` 同款
|
|
536
|
+
# 「投递时记、等待时取走即弃」);`dsh_wait_turn_via_events` 优先取走预订阅,取不到
|
|
537
|
+
# 再退回「现订阅」(老行为——覆盖「预订阅与等待之间才转外部」等窗口)。
|
|
538
|
+
# 硬约束(逐条落在下面的实现里):
|
|
539
|
+
# ① **只对会走事件流路的那一档建**(`dshevents.get(sid)["owned"] is False`,正是
|
|
540
|
+
# `chat.wait_turn` 分流到事件流的判据):池内/未知的投递零改动、零多余订阅
|
|
541
|
+
# ——池内等待器 `dsh_wait_turn`(按会话 SSE)根本不读这张旁表;
|
|
542
|
+
# ② **不等待的投递不留悬挂订阅**(`chat._inject_send`、`board._deliver_now(
|
|
543
|
+
# inject=True)`、卡片首投等):旁表有界(条数上限 `DSH_SUB_MAX` + TTL 惰性过期
|
|
544
|
+
# `DSH_SUB_TTL`),出表/取走/投递抛异常一律 `dshevents.unsubscribe`(订阅回调
|
|
545
|
+
# 常驻中枢消费者列表,泄漏会让每次状态帧都白调一次);
|
|
546
|
+
# ③ 帧队列**只收本 sid 的帧**(状态帧低频 ⇒ 无人取走的队列也有界),等待器
|
|
547
|
+
# `_consume` 里的 sid 过滤照旧(双保险);
|
|
548
|
+
# ④ **不拿 `status == "running"` 无条件播种 `started`**(I1 安全阀不许写宽):
|
|
549
|
+
# 预订阅只解决「帧早到」,`started` 的判定阶梯一个字未改。
|
|
550
|
+
DSH_SUB_MAX = 32 # `_DSH_SUB` 条数上限(超出按建订阅时刻从旧到新回收)
|
|
551
|
+
DSH_SUB_TTL = 120.0 # 预订阅惰性过期(秒):投递后无人取走即回收
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
class _TurnSubscription:
|
|
555
|
+
"""投递前建立的一次性全局状态流订阅:本 sid 的帧 → 线程安全队列。
|
|
556
|
+
|
|
557
|
+
一个实例=一次投递的「帧口袋」:`dsh_send` 建它(`_dsh_sub_open`)→
|
|
558
|
+
`dsh_wait_turn_via_events` 取走并当帧队列用(`_dsh_sub_take`)→ 等待器 `finally`
|
|
559
|
+
或回收路径 `close()`(退订,幂等)。取不到预订阅时等待器自建同款实例(老行为)。
|
|
560
|
+
"""
|
|
561
|
+
|
|
562
|
+
def __init__(self, sid):
|
|
563
|
+
self.sid = str(sid or "")
|
|
564
|
+
self.frames = queue.Queue() # 回调(消费线程)→ 等待器(本线程)的交接口
|
|
565
|
+
self.created = time.time()
|
|
566
|
+
self._closed = False
|
|
567
|
+
# 回调存成**对象同一性稳定**的引用:订阅与退订必须传同一个对象(`dshevents`
|
|
568
|
+
# 的订阅者列表按 `==` 摘除本就够用,但既有用例按 `is` 钉「退订的就是订阅的
|
|
569
|
+
# 那一个回调」——回调常驻列表的泄漏判据;每次属性访问都会新建 bound method,
|
|
570
|
+
# 故存一次,两个方向都稳)。
|
|
571
|
+
self._cb = self._on_frame
|
|
572
|
+
|
|
573
|
+
def _on_frame(self, frame):
|
|
574
|
+
"""中枢订阅回调:只收本 sid 的帧并入队。
|
|
575
|
+
|
|
576
|
+
契约要求短小(跑在事件消费线程里):这里只做一次字符串比较 + `put`。
|
|
577
|
+
按 sid 过滤(等待器 `_consume` 里还有一层同样的过滤)让「无人取走的预订阅」
|
|
578
|
+
队列也保持有界——状态帧是低频事件,不会像按 token 的流那样爆量。
|
|
579
|
+
"""
|
|
580
|
+
if self._closed:
|
|
581
|
+
return
|
|
582
|
+
if str(frame.get("session_id") or "") != self.sid:
|
|
583
|
+
return
|
|
584
|
+
self.frames.put(frame)
|
|
585
|
+
|
|
586
|
+
def close(self):
|
|
587
|
+
"""退订(幂等):回调必须从中枢订阅者列表里摘掉,否则常驻泄漏。"""
|
|
588
|
+
if self._closed:
|
|
589
|
+
return
|
|
590
|
+
self._closed = True
|
|
591
|
+
dshevents.unsubscribe(self._cb)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
_DSH_SUB = {} # sid -> _TurnSubscription(投递时建、等待时取走即弃)
|
|
595
|
+
_DSH_SUB_LOCK = threading.Lock()
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _dsh_sub_reap_locked():
|
|
599
|
+
"""旁表惰性回收(**调用方须持 `_DSH_SUB_LOCK`**):过 TTL 与超条数上限的条目出表。
|
|
600
|
+
|
|
601
|
+
返回**待退订**列表(出锁后再 `close()`):`close()` 要拿中枢的订阅锁,不在本锁内
|
|
602
|
+
做,免得两把锁的获取顺序成为隐患(业务侧没有任何路径反向获取本锁)。
|
|
603
|
+
"""
|
|
604
|
+
now = time.time()
|
|
605
|
+
stale = []
|
|
606
|
+
for sid, sub in list(_DSH_SUB.items()):
|
|
607
|
+
if now - sub.created > DSH_SUB_TTL:
|
|
608
|
+
stale.append(_DSH_SUB.pop(sid))
|
|
609
|
+
over = len(_DSH_SUB) - DSH_SUB_MAX
|
|
610
|
+
if over > 0:
|
|
611
|
+
# 超限:按建订阅时刻从旧到新回收(同一时刻大量投递时也收敛回上限之内)
|
|
612
|
+
oldest = sorted(_DSH_SUB.items(), key=lambda kv: kv[1].created)[:over]
|
|
613
|
+
for sid, _sub in oldest:
|
|
614
|
+
stale.append(_DSH_SUB.pop(sid))
|
|
615
|
+
return stale
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _dsh_sub_open(sid):
|
|
619
|
+
"""投递**之前**建预订阅并放进旁表;返回该订阅(同 sid 的旧订阅覆盖即退订)。
|
|
620
|
+
|
|
621
|
+
只应由 `dsh_send` 在「会话是外部(`owned is False`)」时调用——那是
|
|
622
|
+
`chat.wait_turn` 会分流到事件流路、等待器会取用它的唯一一档。
|
|
623
|
+
"""
|
|
624
|
+
sub = _TurnSubscription(sid)
|
|
625
|
+
dshevents.subscribe(sub._cb)
|
|
626
|
+
with _DSH_SUB_LOCK:
|
|
627
|
+
old = _DSH_SUB.get(sub.sid)
|
|
628
|
+
_DSH_SUB[sub.sid] = sub
|
|
629
|
+
# 入表**之后**回收:条数上限对「当前表」恒成立(新订阅 created 最新,
|
|
630
|
+
# 不会被自己的这一次回收摘掉)
|
|
631
|
+
stale = _dsh_sub_reap_locked()
|
|
632
|
+
for item in stale:
|
|
633
|
+
item.close() # 过期/超限的旧订阅:真退订
|
|
634
|
+
if old is not None:
|
|
635
|
+
old.close() # 同 sid 上一次没被取走的预订阅:回收
|
|
636
|
+
return sub
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _dsh_sub_take(sid):
|
|
640
|
+
"""取走该 sid 的预订阅(无则 `None`):取走即从旁表删除。
|
|
641
|
+
|
|
642
|
+
退订责任随之转移给调用方(等待器在 `finally` 里 `close()`)——旁表只负责
|
|
643
|
+
「投递到取走」这段短窗口内的保管,取走即弃与 `_DSH_BASELINE` 同款语义。
|
|
644
|
+
"""
|
|
645
|
+
with _DSH_SUB_LOCK:
|
|
646
|
+
stale = _dsh_sub_reap_locked()
|
|
647
|
+
sub = _DSH_SUB.pop(str(sid or ""), None)
|
|
648
|
+
for item in stale:
|
|
649
|
+
item.close()
|
|
650
|
+
return sub
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _dsh_sub_drop(sub):
|
|
654
|
+
"""按对象回收一个预订阅(投递失败路径):仅当旁表里仍是它才删,随后退订。"""
|
|
655
|
+
if sub is None:
|
|
656
|
+
return
|
|
657
|
+
with _DSH_SUB_LOCK:
|
|
658
|
+
if _DSH_SUB.get(sub.sid) is sub:
|
|
659
|
+
_DSH_SUB.pop(sub.sid, None)
|
|
660
|
+
sub.close()
|
|
661
|
+
|
|
388
662
|
|
|
389
663
|
def dsh_busy(sid):
|
|
390
664
|
"""dsh 插件会话是否正在跑 turn(驱动不可用按 False,交由发送路径报错)。
|
|
@@ -398,24 +672,125 @@ def dsh_busy(sid):
|
|
|
398
672
|
return False
|
|
399
673
|
|
|
400
674
|
|
|
675
|
+
# ---------- 宿主会话存活兜底(2026-10-10,修卡 934) ----------
|
|
676
|
+
#
|
|
677
|
+
# 现场:dsh 会话活在宿主进程内,插件**热重载 / 宿主重启**会把所有会话 dispose 掉
|
|
678
|
+
# ——真机实证 2026-10-10 01:06:48 两张卡的会话同时落
|
|
679
|
+
# `turn/end {kind:"aborted", reason:{kind:"disposed"}}`。平台侧卡片/任务的
|
|
680
|
+
# `session_id` 仍在库、卡照旧在列,用户再发评论 / 点「立即注入」时驱动回 404
|
|
681
|
+
# 「会话不在驱动池中」(卡 934 报障:前端 toast 只有这句内部链路文案,用户无从
|
|
682
|
+
# 判断该怎么办)。
|
|
683
|
+
#
|
|
684
|
+
# 判据与调和器「会话确已结束」同源(`board._iw_once`):`dshevents.aligned()`
|
|
685
|
+
# (中枢可信)∧ 注册表里没有这个 sid ⇒ 宿主确实不再持有它。此时按平台既有
|
|
686
|
+
# 「有 sid 就 resume」的语义(`board._start_web` 的续接腿、`runner` 的
|
|
687
|
+
# `dshdriver.ensure_session` 同一出口)把会话接回池内,再走原投递。
|
|
688
|
+
# 未知(未对齐 / 断连)一律不动:未知 ≠ 已失去,零请求、既有行为一个字节不变。
|
|
689
|
+
|
|
690
|
+
HOST_REVIVE_ENV = "TS_HOST_SESSION_REVIVE"
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _host_revive_enabled():
|
|
694
|
+
"""接回总开关(回滚阀):`TS_HOST_SESSION_REVIVE=0` ⇒ 不接回,退回修复前行为。"""
|
|
695
|
+
return (os.environ.get(HOST_REVIVE_ENV) or "").strip() != "0"
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def host_session_lost(sid):
|
|
699
|
+
"""宿主是否**确已**不再持有该会话(可信快照 ∧ 注册表无此 sid)。
|
|
700
|
+
|
|
701
|
+
两个条件是「确已」的全部证据:`aligned()` 为假(中枢断连 / 热重载后快照未
|
|
702
|
+
对齐)时注册表本就不可信,读不到 sid 只代表**未知**——按全局不变量「未知 ≠
|
|
703
|
+
已结束」,一律返回 False(调用方不动)。读口异常同样按未知处理。
|
|
704
|
+
"""
|
|
705
|
+
if not sid:
|
|
706
|
+
return False
|
|
707
|
+
try:
|
|
708
|
+
if not dshevents.aligned():
|
|
709
|
+
return False
|
|
710
|
+
return dshevents.get(sid) is None
|
|
711
|
+
except Exception: # noqa: BLE001 — 读口异常按未知,不阻断投递
|
|
712
|
+
return False
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def revive_host_session(sid, cwd="", task=""):
|
|
716
|
+
"""把「宿主已失去」的会话按既有 resume 语义接回;返回是否**可以投递**。
|
|
717
|
+
|
|
718
|
+
True:会话本来就在宿主里(零请求),或刚被成功接回,或回滚阀关闭(退回旧行为,
|
|
719
|
+
失败仍由驱动 404 兜底)。
|
|
720
|
+
False:确已失去且接不回(会话文件不存在 / 驱动不可达 / 令牌错)——调用方据此
|
|
721
|
+
给用户明确文案,而不再发一次必然 404 的投递。
|
|
722
|
+
`cwd`/`task` 只在真接回时下传(前者对齐 `board.card_workspace`:worktree 卡;
|
|
723
|
+
后者是驱动侧的会话标签,与 `_start_web` 同形)。
|
|
724
|
+
"""
|
|
725
|
+
if not _host_revive_enabled() or not host_session_lost(sid):
|
|
726
|
+
return True
|
|
727
|
+
try:
|
|
728
|
+
dshdriver.resume_session(sid, cwd=cwd, task=task)
|
|
729
|
+
except dshdriver.DshDriverError as e:
|
|
730
|
+
print(f"[chat] 会话接回失败 sid={sid}: {e}", flush=True)
|
|
731
|
+
return False
|
|
732
|
+
print(f"[chat] 宿主已失去会话,按 resume 接回:sid={sid}"
|
|
733
|
+
f"(cwd={cwd or '-'} task={task or '-'})", flush=True)
|
|
734
|
+
return True
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _dsh_baseline_since(sid):
|
|
738
|
+
"""投递基线 seq(等 turn/end 的起点):驱动 `/status` 拿不到就回落中枢注册表。
|
|
739
|
+
|
|
740
|
+
判据是「**拿不到** `last_seq`」而不是「`/status` 抛错」(2026-10-10 T6 修正):
|
|
741
|
+
T2 落地后,watched + 活 agent 的外部会话 `/status` 由 `_externalTarget` 干跑后
|
|
742
|
+
按外部形状回 200,响应里**刻意没有** `last_seq`——只 catch 异常会让回落分支
|
|
743
|
+
永不触发、`since` 恒 0(等轮次会把基线之前的历史帧当成本轮)。
|
|
744
|
+
两个来源按「先权威后本地」取:
|
|
745
|
+
① 驱动 `/status`(池内会话的权威口径,一次同步读,不是轮询);
|
|
746
|
+
② `dshevents.get(sid)["last_seq"]`(中枢已折好的**本地**值,零请求;
|
|
747
|
+
外部会话、驱动不可达时用它)。
|
|
748
|
+
两者都取不到返回 0(保持既有「无基线」语义)。
|
|
749
|
+
"""
|
|
750
|
+
try:
|
|
751
|
+
seq = dshdriver.status(sid).get("last_seq")
|
|
752
|
+
except dshdriver.DshDriverError:
|
|
753
|
+
seq = None
|
|
754
|
+
if seq is None:
|
|
755
|
+
seq = (dshevents.get(sid) or {}).get("last_seq")
|
|
756
|
+
try:
|
|
757
|
+
return int(seq or 0)
|
|
758
|
+
except (TypeError, ValueError):
|
|
759
|
+
return 0
|
|
760
|
+
|
|
761
|
+
|
|
401
762
|
def dsh_send(sid, message, inject=False):
|
|
402
763
|
"""dsh 插件族投递(chat 与 board 共用):记基线 → prompt / steer → 返回基线 seq。
|
|
403
764
|
|
|
404
765
|
投递语义:dsh 的 `followup` 在会话忙时排进 agent inbox(服务端排队),`steer`
|
|
405
766
|
则注入当前 turn 的最近 step 边界——平台的「发送 / 立即注入」两种语义因此
|
|
406
767
|
一一对应,无需 spawn 子进程。异常抛 DshDriverError 由调用方处置。
|
|
768
|
+
基线来源见 `_dsh_baseline_since`(外部会话没有 `last_seq` ⇒ 回落中枢)。
|
|
769
|
+
投递的同时把「本次是否 steer」记进旁表 `_DSH_INJECT`(I1):外部会话的等待器
|
|
770
|
+
据此决定要不要按「已在跑的 turn」播种 `started`。
|
|
771
|
+
**投递前预订阅(缺陷 A 修复)**:外部会话先建 `_TurnSubscription` 放进 `_DSH_SUB`
|
|
772
|
+
——`turn/start` 帧可能在 `POST` 返回前后几毫秒到达,等投递返回再订阅就永远看不到
|
|
773
|
+
(全局状态流对进程内订阅者无回放);投递失败当场回收该预订阅。
|
|
407
774
|
"""
|
|
408
775
|
if not sid:
|
|
409
776
|
raise dshdriver.DshDriverError(-1, "会话 id 为空,无法投递")
|
|
777
|
+
# 外部会话(`owned is False`)= `chat.wait_turn` 会分流到事件流路的那一档:
|
|
778
|
+
# 投递前先订阅(见 `_TurnSubscription`);池内/未知不建(池内等待器不读它)。
|
|
779
|
+
sub = None
|
|
780
|
+
if (dshevents.get(sid) or {}).get("owned") is False:
|
|
781
|
+
sub = _dsh_sub_open(sid)
|
|
410
782
|
try:
|
|
411
|
-
since =
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
783
|
+
since = _dsh_baseline_since(sid)
|
|
784
|
+
if inject:
|
|
785
|
+
dshdriver.steer(sid, message)
|
|
786
|
+
else:
|
|
787
|
+
dshdriver.prompt(sid, message)
|
|
788
|
+
except Exception:
|
|
789
|
+
# 投递失败:预订阅无人会取走,当场回收(不留悬挂订阅)
|
|
790
|
+
_dsh_sub_drop(sub)
|
|
791
|
+
raise
|
|
418
792
|
_DSH_BASELINE[sid] = since
|
|
793
|
+
_DSH_INJECT[sid] = bool(inject) # I1:True/False 都写(覆盖上一次投递的旧值)
|
|
419
794
|
return since
|
|
420
795
|
|
|
421
796
|
|
|
@@ -487,11 +862,167 @@ def dsh_wait_turn(sid, since=None, yield_on_interaction=True, log_path=""):
|
|
|
487
862
|
stop.set()
|
|
488
863
|
|
|
489
864
|
|
|
865
|
+
def _log_wait_timeout(log_path, detail):
|
|
866
|
+
"""写「等待 turn 结束超时」留痕:会话日志 + 平台日志(插件形态落 plugin-backend.log)。
|
|
867
|
+
|
|
868
|
+
断连与总时限两条收口共用同一行文案(不变量:断连也必须按**超时口径**收口并留痕,
|
|
869
|
+
绝不判「正常结束」);`detail` 只补原因,便于事后区分是哪条收口。
|
|
870
|
+
`runner.append_log` 对空/不可写路径静默跳过(既有语义),故再打一行平台日志
|
|
871
|
+
兜底——外部卡片投递可能没有 `_CHATS` 记录(`log_path` 为空),此时留痕不能丢。
|
|
872
|
+
"""
|
|
873
|
+
line = f"### 会话等待 turn 结束超时({detail})"
|
|
874
|
+
runner.append_log(log_path, line + "\n")
|
|
875
|
+
print(f"[chat] {line}(日志: {log_path or '无'})", flush=True)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def dsh_wait_turn_via_events(sid, since=None, log_path="", yield_on_interaction=True):
|
|
879
|
+
"""等 dsh 会话一次 turn 结束——**全局状态流版**(外部会话专用,C 批 T6)。
|
|
880
|
+
|
|
881
|
+
与 `dsh_wait_turn`(按会话 SSE)的分工:外部会话不在驱动池中,
|
|
882
|
+
`/events?session_id=` 与 `/status` 同属池内门禁(404)⇒ 按会话 SSE 一帧都收不到,
|
|
883
|
+
旧实现随即按「本轮结束」返回(把「读不到」误判成功)。这里改订阅插件的**全局
|
|
884
|
+
状态流**(`dshevents`:插件对每个会话都发 `turn/start`/`turn/end`/
|
|
885
|
+
`driver/interaction`,见 `dshevents._on_frame`),平台侧**零请求**——等待只是从
|
|
886
|
+
进程内队列取帧,加上每 `TICK` 一次的本地判定(不是远端状态轮询)。
|
|
887
|
+
|
|
888
|
+
返回 None(本轮结束/超时/断连收口)或 STATE_YIELDED(挂起等作答,让出运行位)。
|
|
889
|
+
`since`:本轮起点 seq(缺省先取本次投递基线 `_DSH_BASELINE`,再回落中枢
|
|
890
|
+
`last_seq`)——只用于过滤 `turn/start`(`event_seq > since` 才算「本轮起来了」),
|
|
891
|
+
避免上一轮遗留的 turn/start 被误当本轮。
|
|
892
|
+
**帧口袋优先用投递前的预订阅**(缺陷 A):`dsh_send` 对**外部会话**在投递之前就
|
|
893
|
+
`dshevents.subscribe` 并把帧排进 `_TurnSubscription.frames`(旁表 `_DSH_SUB`),
|
|
894
|
+
这里 `_dsh_sub_take` 取走即用——`turn/start` 帧即便在投递返回后立刻到达也已在
|
|
895
|
+
口袋里;取不到(池内转外部、非 `dsh_send` 投递、旁表已回收)退回「现订阅」
|
|
896
|
+
(老行为:窗口只是收窄,不再是常态)。**举证责任在订阅**:`started` 的判定
|
|
897
|
+
阶梯一个字未改(不许无条件按 `status=='running'` 播种,见第 ⓪ 条)。
|
|
898
|
+
判定阶梯(顺序即优先级):
|
|
899
|
+
⓪ **steer 注入「已在跑的 turn」按已在跑播种 `started`**(I1,两个条件缺一不可,
|
|
900
|
+
见下方 `started` 初始化处的长注释);
|
|
901
|
+
① 本 sid `turn/start` 且 `data.event_seq > since` ⇒ 记 started;
|
|
902
|
+
② started 且本 sid `turn/end` ⇒ 返回 None(本轮结束);
|
|
903
|
+
③ 本 sid `driver/interaction{state:'asked'}` ⇒ STATE_YIELDED(让位);
|
|
904
|
+
④ 中枢断连(`dshevents.connected()` 为假)⇒ 超时行留痕 + 返回 None
|
|
905
|
+
(断连=未知:绝不判「正常结束」,也绝不把项目运行位钉死);
|
|
906
|
+
⑤ 到 `_dsh_wait_turn_limit()` 总时限 ⇒ 同款超时行 + 返回 None;
|
|
907
|
+
⑥ started 为假且超 `TURN_START_GRACE` ⇒ 返回 None(turn 压根没起来,不占位)。
|
|
908
|
+
`log_path` 缺省取本会话对话日志(`_CHATS[sid]["log_path"]`,同 `dsh_wait_turn`)。
|
|
909
|
+
"""
|
|
910
|
+
if not sid:
|
|
911
|
+
return None
|
|
912
|
+
if since is None:
|
|
913
|
+
since = _DSH_BASELINE.pop(sid, None) # 本次投递记下的基线(取走即弃)
|
|
914
|
+
injected = bool(_DSH_INJECT.pop(sid, None)) # 本次投递是否 steer(同上取走即弃)
|
|
915
|
+
if since is None:
|
|
916
|
+
since = (dshevents.get(sid) or {}).get("last_seq")
|
|
917
|
+
try:
|
|
918
|
+
since = int(since or 0)
|
|
919
|
+
except (TypeError, ValueError):
|
|
920
|
+
since = 0
|
|
921
|
+
if not log_path:
|
|
922
|
+
with _lock:
|
|
923
|
+
rec = _CHATS.get(sid)
|
|
924
|
+
log_path = (rec or {}).get("log_path") or ""
|
|
925
|
+
limit = _dsh_wait_turn_limit()
|
|
926
|
+
t0 = time.time()
|
|
927
|
+
deadline = t0 + limit
|
|
928
|
+
# —— I1(终审,2026-10-10):steer 注入「已在跑的 turn」按已在跑播种 started ——
|
|
929
|
+
# 两个条件**缺一不可**(下面两行即全部条件):
|
|
930
|
+
# ① `injected`:本次投递是 steer 注入(`dsh_send` 投递时记下的已知事实);
|
|
931
|
+
# ② 入口本地读一次中枢注册表,实况 `status == "running"`(零请求,不是轮询)。
|
|
932
|
+
# 为什么需要:steer 的目标是**已经在跑的** turn,它的 `turn/start` 帧早于本订阅
|
|
933
|
+
# (全局状态流对进程内订阅者无回放),而 `since`(外部会话取中枢 `last_seq`)恒
|
|
934
|
+
# ≥ 该 turn/start 的 `event_seq` ⇒ 过滤条件 `seq > since` 恒假 ⇒ `started` 恒假
|
|
935
|
+
# ⇒ 本轮 `turn/end` 被丢弃,只能等满 TURN_START_GRACE(60s):`m:` 行落 done、项目
|
|
936
|
+
# 运行位提前释放,而外部 turn 仍在跑 ⇒ 下一个排队单元与它**并发写同一工作区**
|
|
937
|
+
# (违「任务按项目串行」红线)。播种后由真 `turn/end` 帧收口。
|
|
938
|
+
# 为什么不能无条件读 `status=='running'` 当初值:followup 排在正在跑的**旧** turn
|
|
939
|
+
# 之后时(消息已进 agent inbox、本轮尚未 turn/start),旧 turn 的 `turn/end` 会被
|
|
940
|
+
# 误判成本轮结束——正是 T6 修掉的那类误判;故条件①必须同时成立。
|
|
941
|
+
started = injected and (dshevents.get(sid) or {}).get("status") == "running"
|
|
942
|
+
# —— 帧口袋(缺陷 A):优先取投递前的预订阅,取不到再自建(老行为)——
|
|
943
|
+
# 预订阅在 `dsh_send` 里建于**投递之前** ⇒ `turn/start` 帧只要在本轮等待开始
|
|
944
|
+
# 之后到达就不会漏(真机实测帧早于「投递后订阅」9~13ms)。自建分支覆盖
|
|
945
|
+
# 「预订阅不存在」的形态:非 `dsh_send` 投递、池内转外部、旁表已回收/过期。
|
|
946
|
+
sub = _dsh_sub_take(sid)
|
|
947
|
+
if sub is None:
|
|
948
|
+
sub = _TurnSubscription(sid)
|
|
949
|
+
dshevents.subscribe(sub._cb)
|
|
950
|
+
inbox = sub.frames # 消费线程 → 本函数:原始帧的线程安全交接
|
|
951
|
+
|
|
952
|
+
def _consume(frame):
|
|
953
|
+
"""处理一帧:返回结论(None / 'turn_end' / 'interaction'),必要时推进 started。
|
|
954
|
+
|
|
955
|
+
只认本 sid 的帧(全局流上混着所有会话的帧);跨会话帧直接忽略。
|
|
956
|
+
"""
|
|
957
|
+
nonlocal started
|
|
958
|
+
if str(frame.get("session_id") or "") != sid:
|
|
959
|
+
return None
|
|
960
|
+
typ = str(frame.get("type") or "")
|
|
961
|
+
data = frame.get("data") or {}
|
|
962
|
+
if typ == "turn/start":
|
|
963
|
+
seq = data.get("event_seq")
|
|
964
|
+
if isinstance(seq, int) and seq > since:
|
|
965
|
+
started = True
|
|
966
|
+
return None
|
|
967
|
+
if typ == "turn/end":
|
|
968
|
+
# started 是「本轮起来了」的闩:started 之前到达的 turn/end 属于上一轮
|
|
969
|
+
# (投递排队/在途时的遗留帧),不能当成本轮结束。started 有两个来源:
|
|
970
|
+
# ① 本函数按 `turn/start`(seq > since)置真;② 入口按 I1 播种(steer
|
|
971
|
+
# 注入已在跑的 turn——其 turn/start 帧早于订阅,见 started 初始化处)。
|
|
972
|
+
return "turn_end" if started else None
|
|
973
|
+
if typ == "driver/interaction" and str(data.get("state") or "") == "asked":
|
|
974
|
+
if yield_on_interaction:
|
|
975
|
+
return "interaction"
|
|
976
|
+
# 不让位时继续等:提问意味着 turn 已在跑(同 TurnWaiter.feed 口径)
|
|
977
|
+
started = True
|
|
978
|
+
return None
|
|
979
|
+
|
|
980
|
+
try:
|
|
981
|
+
while True:
|
|
982
|
+
try:
|
|
983
|
+
frame = inbox.get_nowait()
|
|
984
|
+
except queue.Empty:
|
|
985
|
+
# 队列已取空:做一轮本地判定(链路/时限/宽限),再按节拍阻塞等帧
|
|
986
|
+
now = time.time()
|
|
987
|
+
if not dshevents.connected():
|
|
988
|
+
_log_wait_timeout(
|
|
989
|
+
log_path, f"状态流断连:{now - t0:g}s 无 turn/end")
|
|
990
|
+
return None
|
|
991
|
+
if now >= deadline:
|
|
992
|
+
_log_wait_timeout(log_path, f"{limit:g}s 无 turn/end")
|
|
993
|
+
return None
|
|
994
|
+
if not started and now - t0 > TURN_START_GRACE:
|
|
995
|
+
return None # turn 未启动:不占着项目位
|
|
996
|
+
# 阻塞等下一帧:窗口 = min(节拍, 距总时限余量, 未起轮时的启动宽限余量)
|
|
997
|
+
# ——让宽限/时限到点即判定,不被 TICK 推迟(TICK 可被环境放大)
|
|
998
|
+
window = min(TICK, deadline - now)
|
|
999
|
+
if not started:
|
|
1000
|
+
window = min(window, TURN_START_GRACE - (now - t0))
|
|
1001
|
+
try:
|
|
1002
|
+
frame = inbox.get(timeout=max(0.05, window))
|
|
1003
|
+
except queue.Empty:
|
|
1004
|
+
continue
|
|
1005
|
+
verdict = _consume(frame)
|
|
1006
|
+
if verdict == "turn_end":
|
|
1007
|
+
return None
|
|
1008
|
+
if verdict == "interaction":
|
|
1009
|
+
return STATE_YIELDED # 已送达;挂起让位(运行位即释放)
|
|
1010
|
+
finally:
|
|
1011
|
+
# 订阅必须回收(回调常驻中枢消费者列表里);预订阅与自建订阅同款:
|
|
1012
|
+
# 取走即弃(`_dsh_sub_take` 已出旁表)+ 这里 `close()` 幂等退订。
|
|
1013
|
+
sub.close()
|
|
1014
|
+
|
|
1015
|
+
|
|
490
1016
|
def _dsh_send_now(project, sid, message, inject):
|
|
491
1017
|
"""dsh 插件族消息投递体:写对话日志 → 投递 → 等 turn 结束。
|
|
492
1018
|
|
|
493
1019
|
turn 结束由插件推来的 turn/end 帧判定(事件驱动,零轮询);「挂起等作答」
|
|
494
|
-
由 driver/interaction 帧实时告知。
|
|
1020
|
+
由 driver/interaction 帧实时告知。
|
|
1021
|
+
等轮次走 `wait_turn` 分流口(T6 收口):外部会话(无卡消息路径的飞书绑定会话、
|
|
1022
|
+
平台重启后变 `owned:false` 的任务会话)订阅**全局状态流**——按会话 SSE 对
|
|
1023
|
+
池外会话是 404(「读不到」会被误判成本轮结束)。本会话的基线 `since` 与
|
|
1024
|
+
对话日志落点 `log_path` 逐字透传(缺陷 G 的总时限原因行必须落这条日志)。
|
|
1025
|
+
"""
|
|
495
1026
|
lib.ensure_runtime_dirs(project["work_dir"])
|
|
496
1027
|
log_dir = lib.runtime_dir(project["work_dir"], runner.LOG_DIR_NAME)
|
|
497
1028
|
log_path = os.path.join(log_dir, f"chat_dsh_{int(time.time())}.log")
|
|
@@ -501,8 +1032,10 @@ def _dsh_send_now(project, sid, message, inject):
|
|
|
501
1032
|
f"{'steer' if inject else 'followup'}\n").encode("utf-8", errors="replace"))
|
|
502
1033
|
since = dsh_send(sid, message, inject)
|
|
503
1034
|
_register(sid, None, message, dsh_plugin=True, since=since, log_path=log_path)
|
|
504
|
-
#
|
|
505
|
-
|
|
1035
|
+
# 分流口:池内/未知 ⇒ 既有按会话 SSE(显式传本会话日志 + 基线,语义同旧直调);
|
|
1036
|
+
# 外部 ⇒ 全局状态流(同参数透传,见 wait_turn)
|
|
1037
|
+
return wait_turn(project["work_dir"], sid, "dsh_plugin",
|
|
1038
|
+
since=since, log_path=log_path)
|
|
506
1039
|
|
|
507
1040
|
|
|
508
1041
|
def wait_web_busy(project_dir, sid, family):
|
|
@@ -523,6 +1056,46 @@ def wait_web_busy(project_dir, sid, family):
|
|
|
523
1056
|
return dsh_wait_turn(sid)
|
|
524
1057
|
|
|
525
1058
|
|
|
1059
|
+
def wait_turn(project_dir, sid, family, since=None, log_path=""):
|
|
1060
|
+
"""等一次会话 turn 结束的**分流口**(C 批 T6):按会话归属选等待通道。
|
|
1061
|
+
|
|
1062
|
+
外部会话(用户在 dsh GUI 里直跑/接管,注册表 `owned:false`)不在驱动池中,
|
|
1063
|
+
`/events?session_id=` 与 `/status` 同属池内门禁(404)⇒ 按会话 SSE 等不到任何
|
|
1064
|
+
帧,「等不到」又被当成「本轮结束」(外部会话被误判成功的根因)。故外部会话改走
|
|
1065
|
+
**全局状态流**(`dsh_wait_turn_via_events`:插件每会话都发 `turn/start`/
|
|
1066
|
+
`turn/end`,平台侧零请求)。
|
|
1067
|
+
|
|
1068
|
+
其余情况一律走既有 `wait_web_busy`(按会话 SSE),**行为一个字节不变**:
|
|
1069
|
+
- `owned:true` 平台自持会话:权威等待通道就是按会话 SSE(重连补发、基线
|
|
1070
|
+
续传都在那边);
|
|
1071
|
+
- 注册表未知(`dshevents.get` 返回 None:链路断连 / 没见过该 sid):
|
|
1072
|
+
**未知 ≠ 外部**(与 `chat._external_preflight` 同一判定阶梯)——此时无法
|
|
1073
|
+
判定它是外部会话,若改走事件流路,链路降级期会把池内会话立刻按超时收口
|
|
1074
|
+
(运行位提前释放),是净回归。
|
|
1075
|
+
未对齐(热重载后 /live 空快照、旧表保留)不单独判:链路仍在线、帧照旧折叠,
|
|
1076
|
+
行里的 `owned` 仍是可用的判定依据(与前置闸同口径:只有「读不到行」才算未知)。
|
|
1077
|
+
|
|
1078
|
+
`since` / `log_path`(可选,C 批 T6 收口):承载「本轮基线」与「缺陷 G 总时限
|
|
1079
|
+
原因行的落点」(`_dsh_send_now` 消息路径显式传这两个;`board._deliver_unit`
|
|
1080
|
+
不传)。语义分两种调用形状:
|
|
1081
|
+
- **都不传**(既有形状):池内/未知照旧原样调 `wait_web_busy(project_dir,
|
|
1082
|
+
sid, family)` —— 含非 dsh 族的 None 守卫与 `_DSH_BASELINE` 兜底,逐字等价;
|
|
1083
|
+
- **传了任一个**:池内/未知改走 `dsh_wait_turn(sid, since, log_path=log_path)`
|
|
1084
|
+
—— 与旧 `_dsh_send_now` 直调同参数同返回值(绝不换成丢掉这两个参数的
|
|
1085
|
+
`wait_web_busy(...)`,那会让基线丢回 0、缺陷 G 原因行落错日志)。
|
|
1086
|
+
外部分支两种形状一致:`dsh_wait_turn_via_events(sid, since, log_path=log_path)`
|
|
1087
|
+
(`since` 为 None 时该函数自带基线回落:先 `_DSH_BASELINE` 再中枢 `last_seq`)。
|
|
1088
|
+
"""
|
|
1089
|
+
st = dshevents.get(sid) if sid else None
|
|
1090
|
+
if st is None or st.get("owned"):
|
|
1091
|
+
if since is None and not log_path:
|
|
1092
|
+
return wait_web_busy(project_dir, sid, family) # 既有形状:一字未动
|
|
1093
|
+
if family != "dsh_plugin":
|
|
1094
|
+
return None # 族守卫(与 wait_web_busy 同口径)
|
|
1095
|
+
return dsh_wait_turn(sid, since, log_path=log_path)
|
|
1096
|
+
return dsh_wait_turn_via_events(sid, since, log_path=log_path)
|
|
1097
|
+
|
|
1098
|
+
|
|
526
1099
|
def _send_now(task, project, family, sid, message, inject):
|
|
527
1100
|
"""任务会话消息的实际发送(统一队列单元执行体调用)。
|
|
528
1101
|
|