@gavinc-cn/touchstone-dsh 0.0.0-stage → 0.1.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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +335 -2
  3. package/README.zh.md +317 -0
  4. package/agents.py +247 -0
  5. package/auth.py +86 -0
  6. package/board.py +4229 -0
  7. package/builtin_assets.py +808 -0
  8. package/builtin_prompts/free_style/flow.md +301 -0
  9. package/builtin_prompts/free_style/load_test.md +114 -0
  10. package/builtin_prompts/free_style/templates/bug_report_template.md +41 -0
  11. package/builtin_prompts/free_style/templates/case_template.md +23 -0
  12. package/builtin_prompts/free_style/templates/index_template.md +41 -0
  13. package/builtin_prompts/free_style/templates/load_charts_template.md +104 -0
  14. package/builtin_prompts/free_style/templates/load_index_template.md +5 -0
  15. package/builtin_prompts/free_style/templates/load_plan_template.md +76 -0
  16. package/builtin_prompts/free_style/templates/load_scenario_template.md +49 -0
  17. package/builtin_prompts/free_style/templates/status_template.md +18 -0
  18. package/chat.py +616 -0
  19. package/db.py +1407 -0
  20. package/dsh-plugin/cordis.patch.yml +21 -0
  21. package/dsh-plugin/install.sh +79 -0
  22. package/dsh-plugin/lib/agent-driver.js +1888 -0
  23. package/dsh-plugin/lib/client.bundle.js +397 -0
  24. package/dsh-plugin/lib/index.js +284 -0
  25. package/dsh-plugin/scripts/build.mjs +37 -0
  26. package/dsh-plugin/scripts/prepack-check.mjs +61 -0
  27. package/dsh-plugin/scripts/select-bundle.mjs +51 -0
  28. package/dsh-plugin/src/client.js +384 -0
  29. package/dshdriver.py +702 -0
  30. package/dshevents.py +438 -0
  31. package/export_cases.py +160 -0
  32. package/extensions/dsh-autocommit/asset.json +18 -0
  33. package/extensions/dsh-autocommit/pkg/lib/index.mjs +361 -0
  34. package/extensions/dsh-autocommit/pkg/package.json +10 -0
  35. package/feishu.py +2139 -0
  36. package/lib.py +245 -0
  37. package/lifecycle.py +417 -0
  38. package/loadcase.py +558 -0
  39. package/loadgen.py +678 -0
  40. package/localbus.py +91 -0
  41. package/migrate_p7b.py +191 -0
  42. package/package.json +74 -4
  43. package/platcompat.py +272 -0
  44. package/prompts.py +666 -0
  45. package/rag.py +553 -0
  46. package/requirements.txt +9 -0
  47. package/runner.py +2471 -0
  48. package/server.py +5337 -0
  49. package/sessparse.py +638 -0
  50. package/touchstone.py +512 -0
  51. package/waitq.py +1319 -0
  52. package/webui/dist-plugin/assets/index-B0WoP1Qy.css +1 -0
  53. package/webui/dist-plugin/assets/index-eHpxBfP6.js +473 -0
  54. package/webui/dist-plugin/index.html +18 -0
  55. package/worktree.py +293 -0
package/chat.py ADDED
@@ -0,0 +1,616 @@
1
+ #!/usr/bin/env python3
2
+ """Touchstone session 对话管理:向已有 agent 会话续发消息(走统一队列,项目内串行)。
3
+
4
+ 与 runner 的任务轮次执行的区别:
5
+ - 对话是用户在 session 窗口里随手发的单条消息,不走轮次/配额,但**进统一队列**:
6
+ 消息作为第三类单元("m:<msg_id>")入 runner 队列,项目忙时按入队顺序等待,
7
+ 项目空闲则立即执行——避免与项目内在跑的任务/卡片会话并发改同一份代码;
8
+ 单元执行期间持有项目占用,故该项目后续任务/卡片也要等它结束(双向串行);
9
+ turn 挂起等用户作答(driver/interaction)则提前让位(STATE_YIELDED,
10
+ 2026-09-27)——消息已送达、运行位释放,队首单元照常起跑,作答后会话恢复并
11
+ 重新成为占用源。
12
+ - 投递走 dsh 插件驱动(进程内会话,见 dshdriver):写对话日志 → followup/steer →
13
+ 订阅事件流等 turn/end(零状态轮询;对话内容以 agent 自己的 session 存储为准,
14
+ 前端轮询 session 文件即可看到流式更新);
15
+ - 会话登记在内存 _CHATS(session_id -> 记录),供状态查询与停止;消息登记 P3 起
16
+ 权威在 chat_msgs 表(等待项 wait_items kind=msg 管队列拾取互斥),重启后排队
17
+ 消息照常执行、执行中被中断的记 error(msg 重发非幂等不重投,见 recover)。
18
+
19
+ 并发约束(由 server 层保证):同一会话同时最多一个对话单元;同一会话可连发多条
20
+ 消息(按入队顺序串行执行)。
21
+
22
+ 排队消息「立即注入」(2026-09-14):平台排队中的消息可不等队列,用户点按钮即
23
+ 撤销排队并立即投递到目标会话(dsh:steer 注入当前 turn 的最近 step 边界,会话
24
+ 空闲则立即起轮)——见 inject_now。
25
+ """
26
+
27
+ import json
28
+ import os
29
+ import threading
30
+ import time
31
+
32
+ import db
33
+ import dshdriver
34
+ import lib
35
+ import runner
36
+ import waitq
37
+
38
+ # session_id -> {"task_id", "started_at", "message", "exit_code", "log_path"}
39
+ # dsh_plugin(路线 A)记录另带 "dsh_plugin": True 与 "since"(投递前会话 last_seq):
40
+ # 会话活在 dsh 宿主进程内、无本地子进程,运行态以插件推来的事件/status 为准
41
+ _CHATS = {}
42
+ _lock = threading.Lock()
43
+
44
+ _MSG_SEQ = [0] # msg_id 自增序号(仅发号用;记录本体 P3 起在 chat_msgs 表)
45
+
46
+ MESSAGE_MAX = 20000 # 单条对话消息长度上限
47
+ STATE_QUEUED = "queued" # 已入队,等项目空闲
48
+ STATE_RUNNING = "running" # 正在执行(发送中 / 等 turn 结束)
49
+ STATE_DONE = "done" # 已送达且 turn 结束
50
+ STATE_YIELDED = "yielded" # 已送达;turn 挂起等作答,运行位已让出(2026-09-27)
51
+ STATE_ERROR = "error" # 执行失败(error 字段为原因)
52
+ STATE_CANCELLED = "cancelled" # 排队中被取消
53
+
54
+ MSG_KEEP_SEC = 600 # 终态记录保留时长(前端取过失败原因后回收)
55
+ MSG_MAX = 200 # 登记表规模上限(超出丢最旧的终态记录)
56
+ TURN_START_GRACE = 60 # turn 启动宽限(未见 turn/started 判未启动)
57
+ TICK = 2 # 等 turn 结束的本地检查节拍(秒,与 runner 轮次轮询一致)
58
+ # dsh 等 turn 结束的**总时限**(秒,P7a 缺陷 G 兜底;见 dsh_wait_turn):
59
+ # 链路保活正常但服务端就是不推 turn/end 时,只等 TurnWaiter 会永远等下去——
60
+ # 实测(假 driver 单订阅缺陷期间)chat_msgs 卡 running、wait_items 的 m: 行卡
61
+ # starting 4 分钟以上、项目运行位永久被占,只有重启能解。到点写一行会话日志并按
62
+ # 「本轮结束」返回 None,让行收口、补位继续;纯本地 deadline,不引入远端轮询。
63
+ # 45 分钟口径:runner 侧没有轮次硬超时(一轮跑到 turn/end,唯一时间护栏是
64
+ # DSH_TURN_START_GRACE=120s 的「turn 压根没起来」),故取「一轮合法长跑」的
65
+ # 经验上限,够长到不误杀正常长轮;环境变量可覆盖(单测调小用)。
66
+ DSH_WAIT_TURN_TIMEOUT = 2700.0
67
+ DSH_WAIT_TURN_TIMEOUT_ENV = "TS_DSH_WAIT_TURN_TIMEOUT"
68
+
69
+
70
+ class InjectRefused(RuntimeError):
71
+ """「立即注入」被拒(消息已不在排队中 / 不支持注入):消息未动,仍在队列。
72
+
73
+ 与投递失败的区分:被拒 → 端点 400、消息继续排队;投递失败 → 端点 502、
74
+ 消息按原位次退回排队(return_to_waiting 不动 seq,见 inject_now)。
75
+ """
76
+
77
+
78
+ # 可会话续聊的 agent 族(P7b 单族化:只剩 dsh 插件形态)。
79
+ # dsh_plugin(路线 A):会话在 dsh 宿主进程内常驻,投递 = followup/steer,
80
+ # 且 sid 由插件在轮次开始即精确返回(不再靠 mtime 猜)。已退场族不在列——
81
+ # 会话窗对它们不再显示输入区,服务端 chat 端点也按「族已下线」拒绝。
82
+ CHAT_FAMILIES = ("dsh_plugin",)
83
+
84
+
85
+ # ---------- 消息登记(统一队列单元的状态侧;P3 起权威在 chat_msgs 表) ----------
86
+
87
+ def _new_msg_id():
88
+ """消息 id:毫秒时间戳-自增序号(无冒号,可直接作队列键后缀)。"""
89
+ with _lock: # 与 _CHATS 共用模块锁(消息登记锁已随表退场)
90
+ _MSG_SEQ[0] += 1
91
+ return f"{int(time.time() * 1000)}-{_MSG_SEQ[0]}"
92
+
93
+
94
+ def submit(project_id, sid, message, task_id=None, card_id=None, comment_id=None,
95
+ inject=False, family="", model=""):
96
+ """登记一条会话消息并投入统一队列(项目忙则排队,空闲则立即执行)。
97
+
98
+ P3 起权威在 chat_msgs 表 + msg 等待项(waitq.msg_enqueue 一个事务双写)。
99
+ 执行体不再用闭包(闭包不可持久化,设计 §5.3):family/model 在提交时定格进
100
+ 等待项 meta,执行时按行+快照重建(_rebuild_run,与闭包捕获语义等价);
101
+ dsh_plugin 的消息可「立即注入」(_rebuild_inject)。
102
+ comment_id:卡片评论投递路径的评论行 id(终态写回 db.update_board_comment 用),
103
+ 随 meta 持久化(chat_msgs 无此列,P3 计划裁决 R1)。
104
+ runner 单例不可用(单测/独立脚本)时同步执行并原样抛出异常(保持既有行为,
105
+ 行照常落表、不写等待项——P5 R13 收口:无拾取方,等待项只会成为孤儿行)。
106
+ 返回消息记录 dict(含 id/state/queued,供端点响应)。
107
+ """
108
+ msg_id = _new_msg_id()
109
+ waitq.msg_prune(MSG_KEEP_SEC, MSG_MAX) # 终态回收(msg_prune 表版,R8)
110
+ inst = runner.INSTANCE
111
+ payload_meta = {"family": family, "model": model,
112
+ **({"comment_id": comment_id} if comment_id else {})}
113
+ waitq.msg_enqueue(msg_id, project_id, sid, message, task_id=task_id,
114
+ card_id=card_id, inject=bool(inject),
115
+ meta=payload_meta, queue=inst is not None)
116
+ if inst is None:
117
+ # 无统一队列:同步执行(异常上抛);消息行照常落表、不写等待项
118
+ # (P5 R13 收口:孤儿等待项清零)。载荷不经等待行重建——提交参数直传。
119
+ if not waitq.msg_claim(msg_id):
120
+ return {"id": msg_id, "state": STATE_QUEUED, "queued": False}
121
+ try:
122
+ res = _rebuild_run(msg_id, meta=payload_meta)()
123
+ except Exception as e:
124
+ waitq.msg_finish(msg_id, STATE_ERROR, str(e)[:300]) # error 落行(⑧)+ 随异常上抛给端点
125
+ raise
126
+ final = STATE_YIELDED if res == STATE_YIELDED else STATE_DONE
127
+ waitq.msg_finish(msg_id, final)
128
+ return {"id": msg_id, "state": final, "queued": False}
129
+ # 提交时项目已被占用 → 本消息必然排队(前台据此显示「排队中」)
130
+ queued = bool(inst.unit_busy(project_id))
131
+ inst.submit_msg(msg_id, project_id, sid)
132
+ return {"id": msg_id, "state": STATE_QUEUED, "queued": queued}
133
+
134
+
135
+ def _msg_meta(wrow):
136
+ """等待项行 meta 解析(坏 JSON 按空对象;与 board 执行体同型)。"""
137
+ try:
138
+ meta = json.loads((wrow["meta"] if wrow is not None else "") or "{}")
139
+ return meta if isinstance(meta, dict) else {}
140
+ except ValueError:
141
+ return {}
142
+
143
+
144
+ def _msg_payload(msg_id):
145
+ """执行体重建的公共载荷(消息行 / 等待项 meta / 项目行)。
146
+
147
+ family/model 为提交时快照(裁决 R2,与闭包捕获等价);project 行执行时
148
+ 现读(与任务轮次同口径);项目缺失抛 RuntimeError(落消息 error)。"""
149
+ row = waitq.msg_get(msg_id)
150
+ if row is None:
151
+ raise RuntimeError("消息行不存在")
152
+ meta = _msg_meta(waitq.get_active(waitq.KIND_MSG, msg_id))
153
+ project = db.get_project(row["project_id"])
154
+ if project is None:
155
+ raise RuntimeError("项目不存在")
156
+ return row, meta, project
157
+
158
+
159
+ def _rebuild_run(msg_id, meta=None):
160
+ """按行重建消息执行体(P3:闭包改按 kind 分派——设计 §5.3/§6.2)。
161
+
162
+ 任务侧消息(task_id 非空)→ _send_now(合成 task dict 只为沿用既有调用形状:
163
+ 单族世界里 _send_now 只做族校验与 dsh 分派,不再消费其中的 id/model);
164
+ 卡片评论(card_id 非空)→ board._deliver_unit(合成 card/comment dict,
165
+ 执行体只消费 id/session_id/model 与 comment id);
166
+ 函数内 import 防循环(同 runner._worker 对 board 的先例)。
167
+ meta:调用方已有载荷时直接给定(P5 R13 同步路径不写等待项,提交时定格的
168
+ family/model 无行可重建,由 chat.submit 透传;缺省读等待项行)。"""
169
+ row, _meta, project = _msg_payload(msg_id)
170
+ if meta is None:
171
+ meta = _meta
172
+ if row["task_id"] is not None:
173
+ task = {"id": row["task_id"], "model": meta.get("model", "")}
174
+ return lambda: _send_now(task, project, meta.get("family", ""),
175
+ row["sid"], row["message"], bool(row["inject"]))
176
+ card = {"id": row["card_id"], "session_id": row["sid"],
177
+ "model": meta.get("model", "")}
178
+ comment = {"id": meta.get("comment_id") or 0}
179
+ import board
180
+ return lambda: board._deliver_unit(project, card, comment,
181
+ row["message"], bool(row["inject"]))
182
+
183
+
184
+ def _rebuild_inject(msg_id):
185
+ """「立即注入」投递体重建(与 _rebuild_run 同载荷源):
186
+ 任务侧 → _inject_send(steer 注入当前 turn,投递后即返回);卡片评论 →
187
+ board._deliver_now(inject=True)。族不支持由 inject_now 前置拦截。"""
188
+ row, meta, project = _msg_payload(msg_id)
189
+ if row["task_id"] is not None:
190
+ return lambda: _inject_send(project, row["sid"], row["task_id"],
191
+ row["message"], meta.get("model", ""))
192
+ card = {"id": row["card_id"], "session_id": row["sid"],
193
+ "model": meta.get("model", "")}
194
+ comment = {"id": meta.get("comment_id") or 0}
195
+ import board
196
+ return lambda: board._deliver_now(project, card, comment,
197
+ row["message"], inject=True)
198
+
199
+
200
+ def run_unit(msg_id):
201
+ """统一队列执行体(runner worker 调用):执行一条待跑消息;异常落记录不抛出。
202
+
203
+ P3:状态权威在 chat_msgs——msg_claim 原子 queued→running(与 inject_now 的
204
+ waitq.claim 互斥互补:等待项被取消/被注入抢先时此处让行);执行体按行+
205
+ 快照重建;终态 done/yielded/error 落表,等待项行终态在同函数闭环(裁决 R6——
206
+ 终态归执行体,runner 影子终态段不再管 m:)。yielded=挂起让位(2026-09-27):
207
+ turn 等用户作答,运行位即行——行终态即释放,队首单元照常起跑。"""
208
+ try:
209
+ row = waitq.msg_get(msg_id)
210
+ if row is None or row["state"] != STATE_QUEUED:
211
+ _reap_orphan_claim(msg_id) # 竞态窗口 A 收口(P4 必带⑦)
212
+ return
213
+ if not waitq.msg_claim(msg_id):
214
+ _reap_orphan_claim(msg_id) # 竞态窗口 A 收口(P4 必带⑦)
215
+ return
216
+ try:
217
+ res = _rebuild_run(msg_id)()
218
+ except Exception as e:
219
+ waitq.msg_finish(msg_id, STATE_ERROR, str(e))
220
+ waitq.finish_by_target(waitq.KIND_MSG, msg_id, waitq.STATE_FAILED, str(e))
221
+ return
222
+ if res == STATE_YIELDED:
223
+ print(f"[chat] 消息单元挂起让位:m:{msg_id}(turn 等待作答)", flush=True)
224
+ waitq.mark_evidence(waitq.KIND_MSG, msg_id,
225
+ {"desc": "消息投递挂起让位",
226
+ "reason": "turn 等待作答"})
227
+ waitq.msg_finish(msg_id, STATE_YIELDED)
228
+ waitq.finish_by_target(waitq.KIND_MSG, msg_id, waitq.STATE_DONE)
229
+ return
230
+ waitq.msg_finish(msg_id, STATE_DONE)
231
+ waitq.finish_by_target(waitq.KIND_MSG, msg_id, waitq.STATE_DONE)
232
+ except Exception as e: # 防线程拖死(DB 故障等);行滞留由 selfcheck 兜底
233
+ print(f"[chat] 消息单元异常: {msg_id}: {e}", flush=True)
234
+ try: # 尽力把等待项落 failed(chat_msgs 已不可写时由 selfcheck 兜底)
235
+ waitq.finish_by_target(waitq.KIND_MSG, msg_id,
236
+ waitq.STATE_FAILED, str(e)[:300])
237
+ except Exception:
238
+ pass
239
+
240
+
241
+ def _reap_orphan_claim(msg_id):
242
+ """竞态窗口 A 收口(P4 必带⑦):早退时若等待项仍挂在「本执行体」名下
243
+ (claimed_by=worker——排除「立即注入」的 claim,inject-now 路径自行收口),
244
+ 而消息行已不在排队,说明 claim 后、状态迁移前行被并发取消——等待项补终态,
245
+ 防 starting 永挂(v2a T1:claimed 改名 starting)。窗口 B(inject capture/
246
+ cancel 交错)由 waitq.claim 原子性天然闭合(claim 失败即拒绝),无需代码。"""
247
+ wrow = waitq.get_active(waitq.KIND_MSG, msg_id)
248
+ if wrow is not None and wrow["state"] == waitq.STATE_STARTING \
249
+ and wrow["claimed_by"] == "worker":
250
+ waitq.finish_by_target(waitq.KIND_MSG, msg_id,
251
+ waitq.STATE_FAILED, "消息已不在排队中")
252
+
253
+
254
+ def cancel(msg_id):
255
+ """取消排队中的消息(已开始执行的返回 False)。返回是否命中。
256
+
257
+ P3:chat_msgs 的 queued→cancelled 原子守卫为仲裁点(R12),等待项权威取消
258
+ 与内存键摘除随后——与 worker claim 竞态下不双执行、不复活终态行。"""
259
+ if not waitq.msg_cancel(msg_id):
260
+ return False
261
+ waitq.cancel(waitq.KIND_MSG, msg_id, "已取消")
262
+ if runner.INSTANCE is not None:
263
+ runner.INSTANCE.remove_msg(msg_id)
264
+ return True
265
+
266
+
267
+ def cancel_queued(sid=None, card_id=None):
268
+ """取消排队中的消息(按会话 sid 或卡片 id 过滤);返回取消条数。停止入口共用。"""
269
+ if not sid and not card_id:
270
+ return 0
271
+ rows = waitq.msg_rows(sid=sid, card_id=card_id, states=(STATE_QUEUED,))
272
+ return sum(1 for r in rows if cancel(r["id"]))
273
+
274
+
275
+ def msgs_of_sid(sid):
276
+ """本会话消息的展示视图(排队/执行中 + 近期失败):{id,text,state,error,created_at}。
277
+
278
+ 终态正常记录(done/yielded/cancelled)不发——前端 chip 由「被会话收录」自行清除。
279
+ P3 起查 chat_msgs(created_at 升序,idx_chat_msgs_sid 覆盖)。"""
280
+ if not sid:
281
+ return []
282
+ rows = waitq.msg_rows(sid=sid,
283
+ states=(STATE_QUEUED, STATE_RUNNING, STATE_ERROR))
284
+ out = [{"id": r["id"], "text": r["message"][:120], "state": r["state"],
285
+ "error": r["error"], "created_at": r["created_at"]} for r in rows]
286
+ out.sort(key=lambda r: r["created_at"])
287
+ return out
288
+
289
+
290
+ def live_of_sid(sid):
291
+ """本会话是否有排队/执行中的消息单元("m:" 入队后到结束/取消之间)。
292
+
293
+ 看板调和器判定「本卡会话即将或正在由平台消息驱动」用:消息进统一队列后、
294
+ 轮到执行前会话并不 busy,仅凭 web busy 会漏判——阻塞列卡片在消息排队/执行
295
+ 期间应视作运行中(回「正在开发」列)。P3 起查表:state∈{queued,running}
296
+ 任一行即真。"""
297
+ if not sid:
298
+ return False
299
+ return bool(waitq.msg_rows(sid=sid, states=(STATE_QUEUED, STATE_RUNNING)))
300
+
301
+
302
+ def queued_card_ids():
303
+ """有**排队中**消息(尚未轮到执行)的卡片 id 集合(看板 payload 批量预计算用,
304
+ 2026-09-14):卡片会话的「排队中」徽标数据源——消息进统一队列后、轮到执行前,
305
+ 平台已收下消息、等空闲送达,卡片按「排队中」呈现且不点亮「会话运行中」
306
+ (与作答待送达 answer_pending 同款展示语义,见 board.card_json)。
307
+
308
+ 仅统计 state=queued 且带卡片 id 的记录(执行中/终态不计;任务侧消息无
309
+ 卡片 id 天然排除)。P3 起查表:仅 state=queued 且带卡片 id 的行。
310
+ """
311
+ return waitq.msg_queued_card_ids()
312
+
313
+
314
+ def msg_info(msg_id):
315
+ """消息记录只读摘要(端点归属校验用):id/sid/project_id/task_id/card_id/state。
316
+
317
+ 记录不存在(含终态回收后)返回 None;终态 600s 回收窗口不变(R8)。"""
318
+ r = waitq.msg_get(msg_id) if msg_id else None
319
+ if r is None:
320
+ return None
321
+ return {"id": r["id"], "sid": r["sid"], "project_id": r["project_id"],
322
+ "task_id": r["task_id"], "card_id": r["card_id"], "state": r["state"]}
323
+
324
+
325
+ def inject_now(msg_id):
326
+ """「立即注入」:把平台排队中的消息单元立即投递到目标会话,不再等统一队列。
327
+
328
+ P3:互斥点改为等待项 waitq.claim 原子抢占(同 P2「立即送达」模式)——与
329
+ worker 的 _claim_unit(全类直调)只有一方成功;chat_msgs 的
330
+ queued→running 由 msg_claim 承载状态迁移。抢到后先把单元从统一队列摘除
331
+ (runner.remove_msg,内存键专用、不碰等待项行)。
332
+
333
+ 被拒(消息不在排队中/不支持注入)抛 InjectRefused——消息未动、仍在队列;
334
+ 投递失败:等待项 return_to_waiting(行 id 稳定、seq 不动=按原位次放回,
335
+ 裁决 R4——原「wait 行换 id、meta 丢失」红旗就此修复)+ chat_msgs 退回
336
+ queued,原样上抛(端点转 502);成功返回 None(终态 done)。"""
337
+ row = waitq.msg_get(msg_id)
338
+ if row is None:
339
+ raise InjectRefused("消息不存在或已回收")
340
+ if row["state"] != STATE_QUEUED:
341
+ raise InjectRefused("该消息已不在排队中(可能已开始发送)")
342
+ wrow = waitq.get_active(waitq.KIND_MSG, msg_id)
343
+ meta = _msg_meta(wrow)
344
+ if meta.get("family") != "dsh_plugin":
345
+ raise InjectRefused("该消息不支持立即注入")
346
+ if wrow is None or not waitq.claim(wrow["id"], "inject-now"):
347
+ raise InjectRefused("该消息已不在排队中(可能已开始发送)")
348
+ inst = runner.INSTANCE
349
+ if inst is not None:
350
+ inst.remove_msg(msg_id) # 出队:唤醒 worker 重挑(权威取消在下行 claim)
351
+ if not waitq.msg_claim(msg_id): # 状态迁移(被取消的竞态:放回等待)
352
+ waitq.return_to_waiting(wrow["id"])
353
+ raise InjectRefused("该消息已不在排队中(可能已开始发送)")
354
+ try:
355
+ _rebuild_inject(msg_id)()
356
+ except Exception:
357
+ # 投递失败:等待项放回(行 id 稳定、seq 不动=按原位次放回,meta 随行
358
+ # 保留)+ 状态退回排队——消息不丢,语义与「投递失败仍可在队列里等下一轮」一致;
359
+ # 错误由调用方上报前端 toast(端点 502)
360
+ waitq.return_to_waiting(wrow["id"])
361
+ waitq.msg_requeue(msg_id)
362
+ if inst is not None:
363
+ inst.submit_msg(msg_id, row["project_id"], row["sid"])
364
+ raise
365
+ waitq.msg_finish(msg_id, STATE_DONE)
366
+ waitq.finish_by_target(waitq.KIND_MSG, msg_id, waitq.STATE_DONE)
367
+
368
+
369
+ # ---------- 消息投递(消息单元执行体;唯一族 dsh 插件) ----------
370
+
371
+ def _register(sid, task_id, message, log_path="", dsh_plugin=False, since=None):
372
+ """登记对话会话(_CHATS):供 state 查询与 stop 停止。"""
373
+ rec = {"task_id": task_id, "started_at": int(time.time() * 1000),
374
+ "message": message[:200], "exit_code": None, "log_path": log_path}
375
+ if dsh_plugin:
376
+ # since = 投递前会话 last_seq(等 turn/end 的 SSE 续传起点)
377
+ rec.update({"dsh_plugin": True, "since": since})
378
+ with _lock:
379
+ _CHATS[sid] = rec
380
+
381
+
382
+ # ---------- dsh 插件族(路线 A:进程内会话,事件驱动等待) ----------
383
+
384
+ # sid -> 投递前会话 last_seq:等 turn/end 的 SSE 续传起点。
385
+ # 只在本进程、同一「投递→等待」序列内使用;wait 取走即弃(防陈旧基线误判)。
386
+ _DSH_BASELINE = {}
387
+
388
+
389
+ def dsh_busy(sid):
390
+ """dsh 插件会话是否正在跑 turn(驱动不可用按 False,交由发送路径报错)。
391
+
392
+ 读插件内存态(`/status`),不是轮询远端 agent——会话状态由 dsh 侧
393
+ `agent/status` 事件维护,这里只是同步读一次。
394
+ """
395
+ try:
396
+ return dshdriver.status(sid).get("status") == "running"
397
+ except dshdriver.DshDriverError:
398
+ return False
399
+
400
+
401
+ def dsh_send(sid, message, inject=False):
402
+ """dsh 插件族投递(chat 与 board 共用):记基线 → prompt / steer → 返回基线 seq。
403
+
404
+ 投递语义:dsh 的 `followup` 在会话忙时排进 agent inbox(服务端排队),`steer`
405
+ 则注入当前 turn 的最近 step 边界——平台的「发送 / 立即注入」两种语义因此
406
+ 一一对应,无需 spawn 子进程。异常抛 DshDriverError 由调用方处置。
407
+ """
408
+ if not sid:
409
+ raise dshdriver.DshDriverError(-1, "会话 id 为空,无法投递")
410
+ try:
411
+ since = int(dshdriver.status(sid).get("last_seq") or 0)
412
+ except dshdriver.DshDriverError:
413
+ since = 0
414
+ if inject:
415
+ dshdriver.steer(sid, message)
416
+ else:
417
+ dshdriver.prompt(sid, message)
418
+ _DSH_BASELINE[sid] = since
419
+ return since
420
+
421
+
422
+ def _dsh_wait_turn_limit():
423
+ """等 turn 结束的总时限(秒):常量默认 + 环境变量覆盖(非法/非正值回落常量)。"""
424
+ raw = (os.environ.get(DSH_WAIT_TURN_TIMEOUT_ENV) or "").strip()
425
+ if raw:
426
+ try:
427
+ val = float(raw)
428
+ except ValueError:
429
+ val = 0.0
430
+ if val > 0:
431
+ return val
432
+ return DSH_WAIT_TURN_TIMEOUT
433
+
434
+
435
+ def dsh_wait_turn(sid, since=None, yield_on_interaction=True, log_path=""):
436
+ """等 dsh 会话一次 turn 结束(事件驱动,零远端轮询)。
437
+
438
+ 返回 None(turn 正常/异常/超时收口)或 STATE_YIELDED(turn 挂起等作答,让出运行位)。
439
+ since 缺省取本次投递记录的基线;基线丢失(进程重启等)时退回读当前 last_seq。
440
+ log_path:超时原因行的落点(缺省取本会话对话日志 `_CHATS[sid]["log_path"]`)。
441
+ P7a 缺陷 G:整轮等待带**总时限**(DSH_WAIT_TURN_TIMEOUT,环境变量可覆盖)——
442
+ 链路保活正常但服务端不推 turn/end 时不再永久挂起;到点写一行
443
+ `### 会话等待 turn 结束超时(N s 无 turn/end)` 并按「本轮结束」返回 None,
444
+ 消息单元据此收口(m: 行 done、项目运行位释放、补位继续)。
445
+ """
446
+ if since is None:
447
+ since = _DSH_BASELINE.pop(sid, None)
448
+ if since is None:
449
+ try:
450
+ since = int(dshdriver.status(sid).get("last_seq") or 0)
451
+ except dshdriver.DshDriverError:
452
+ return None
453
+ if not log_path:
454
+ with _lock:
455
+ rec = _CHATS.get(sid)
456
+ log_path = (rec or {}).get("log_path") or ""
457
+ limit = _dsh_wait_turn_limit()
458
+ waiter = dshdriver.TurnWaiter()
459
+ stop = threading.Event()
460
+ threading.Thread(target=dshdriver.feed_into,
461
+ args=(sid, since, waiter, stop), daemon=True).start()
462
+ started = time.time()
463
+ deadline = started + limit
464
+ try:
465
+ while True:
466
+ # 等待窗口 = min(轮询节拍, 距总时限剩余):既保留 TICK 节拍(未启动宽限的
467
+ # 本地检查点),又让总时限到点即返回(不再多等一个 TICK)
468
+ event = waiter.wait_turn(max(0.05, min(TICK, deadline - time.time())))
469
+ if event == "turn_end":
470
+ return None
471
+ if time.time() >= deadline:
472
+ # 总时限兜底:写清原因后按「本轮结束」返回,绝不把行永远钉在执行态
473
+ # (log_path 为空时 runner.append_log 静默跳过,不影响收口)
474
+ runner.append_log(
475
+ log_path,
476
+ f"### 会话等待 turn 结束超时({limit:g}s 无 turn/end)\n")
477
+ return None
478
+ if event == "interaction":
479
+ if yield_on_interaction:
480
+ return STATE_YIELDED # 已送达;挂起让位(运行位即释放)
481
+ continue
482
+ if event == "error":
483
+ return None
484
+ if not waiter.started and time.time() - started > TURN_START_GRACE:
485
+ return None # turn 未启动:不占着项目位
486
+ finally:
487
+ stop.set()
488
+
489
+
490
+ def _dsh_send_now(project, sid, message, inject):
491
+ """dsh 插件族消息投递体:写对话日志 → 投递 → 等 turn 结束。
492
+
493
+ turn 结束由插件推来的 turn/end 帧判定(事件驱动,零轮询);「挂起等作答」
494
+ 由 driver/interaction 帧实时告知。"""
495
+ lib.ensure_runtime_dirs(project["work_dir"])
496
+ log_dir = lib.runtime_dir(project["work_dir"], runner.LOG_DIR_NAME)
497
+ log_path = os.path.join(log_dir, f"chat_dsh_{int(time.time())}.log")
498
+ with open(log_path, "ab") as logf:
499
+ logf.write((f"### PROMPT {json.dumps(message, ensure_ascii=False)}\n"
500
+ f"### DRIVER dsh_plugin sid={sid} "
501
+ f"{'steer' if inject else 'followup'}\n").encode("utf-8", errors="replace"))
502
+ since = dsh_send(sid, message, inject)
503
+ _register(sid, None, message, dsh_plugin=True, since=since, log_path=log_path)
504
+ # 显式传入本会话日志:总时限超时(缺陷 G)的原因行要落到这条对话日志上
505
+ return dsh_wait_turn(sid, since, log_path=log_path)
506
+
507
+
508
+ def wait_web_busy(project_dir, sid, family):
509
+ """等一次会话 turn 结束(dsh 单族:订阅事件流等 turn/end,挂起即让位)。
510
+
511
+ dsh_plugin(路线 A):不走轮询——直接订阅插件推来的事件流等 turn/end,
512
+ 「挂起等作答」由 driver/interaction 帧实时告知(见 dsh_wait_turn)。不设总时长
513
+ 上限(跑到 turn 结束,点「停止」可中断),turn 未启动的宽限判定(
514
+ TURN_START_GRACE)与总时限兜底(DSH_WAIT_TURN_TIMEOUT)都在 dsh_wait_turn 内。
515
+ 挂起让位(2026-09-27):turn 挂起等用户作答时返回 STATE_YIELDED——消息已送达,
516
+ 调用方据此落让位终态并释放运行位,队首单元照常起跑;用户作答后会话恢复,卡会话
517
+ 经调和器 ext 行重新成为占用源(对齐 ext 行「挂起即出队」,豁免面②同源语义)。
518
+
519
+ 非 dsh 族(已全部退场):无 turn 可等,恒返回 None(无投递路径,不会走到)。
520
+ """
521
+ if family != "dsh_plugin":
522
+ return None
523
+ return dsh_wait_turn(sid)
524
+
525
+
526
+ def _send_now(task, project, family, sid, message, inject):
527
+ """任务会话消息的实际发送(统一队列单元执行体调用)。
528
+
529
+ dsh_plugin(唯一族):投递 = driver 的 followup/steer,等 turn 结束由事件流判定
530
+ (见 _dsh_send_now);turn 挂起等作答则返回 STATE_YIELDED(挂起让位,
531
+ 2026-09-27)——返回时才释放项目占用,执行期间不会与项目内其他单元并发
532
+ (让位除外:挂起即提前释放,对齐 ext 行「挂起即出队」)。
533
+ """
534
+ if family != "dsh_plugin":
535
+ # 单族世界:其余族已全部退场(B0 防呆口径),走到这儿说明有残留调用路径
536
+ raise RuntimeError(
537
+ f"该智能体族已下线,请在项目设置里改绑 dsh 插件:{family or '未知'}")
538
+ return _dsh_send_now(project, sid, message, inject)
539
+
540
+
541
+ def _inject_send(project, sid, task_id, message, model):
542
+ """「立即注入」投递体(chat.inject_now 调用;不等本轮结束)。
543
+
544
+ dsh_plugin(唯一族):直接 steer(dsh 的 steer 语义就是注入最近 step 边界,
545
+ 空闲则起一轮)——不轮询 turn 结束、不持有项目占用,因为用户点「立即注入」的
546
+ 语义就是不要排队。project/model 保留为兼容既有调用签名(dsh 投递不带模型)。
547
+ """
548
+ since = dsh_send(sid, message, inject=True)
549
+ _register(sid, task_id, message, dsh_plugin=True, since=since)
550
+
551
+
552
+ def start(task, project, message, inject=False):
553
+ """发起对话(走统一队列):登记消息单元并提交 runner,返回消息记录 dict。
554
+
555
+ - 项目空闲:worker 立刻拾起执行(毫秒级),queued=False;
556
+ - 项目忙(同项目有任务/卡片会话在跑):按入队顺序排队,queued=True。
557
+ 排队中的消息可「立即注入」(不等队列直接投递,见 inject_now):dsh 族支持
558
+ (steer 注入当前 turn),family/model 提交时定格进等待项 meta,执行时按快照
559
+ 重建(见 submit)。
560
+ """
561
+ family = runner.agent_family(project["agent_path"])
562
+ sid = task["session_id"]
563
+ model = (task["model"] or "").strip() or (project["model"] or "").strip()
564
+ return submit(project["id"], sid, message, task_id=task["id"], inject=inject,
565
+ family=family, model=model)
566
+
567
+
568
+ def state(sid):
569
+ """对话状态:{running, queued, msgs, started_at?, message?, exit_code?}。
570
+
571
+ running=有进行中的对话(dsh 族读插件内存态会话状态);queued=排队中消息数;
572
+ msgs=排队/执行中与近期失败消息(前端排队 chip 与失败提示用)。
573
+ """
574
+ msgs = msgs_of_sid(sid)
575
+ base = {"running": False,
576
+ "queued": len([m for m in msgs if m["state"] == STATE_QUEUED]),
577
+ "msgs": msgs}
578
+ if not sid:
579
+ return base
580
+ with _lock:
581
+ rec = _CHATS.get(sid)
582
+ if rec is None:
583
+ return base
584
+ if not rec.get("dsh_plugin"):
585
+ return base # 防呆:非 dsh 记录不再产生,按空闲呈现
586
+ # dsh_plugin 无本地进程:running 读插件内存态(agent/status 事件维护),
587
+ # 一次同步读,不做轮询;turn 结束按 exit 0 呈现
588
+ busy = dsh_busy(sid)
589
+ if not busy and rec["exit_code"] is None:
590
+ rec["exit_code"] = 0
591
+ return {**base, "running": busy, "started_at": rec["started_at"],
592
+ "message": rec["message"], "exit_code": rec["exit_code"]}
593
+
594
+
595
+ def running(sid):
596
+ """是否有进行中的对话。"""
597
+ return state(sid)["running"]
598
+
599
+
600
+ def stop(sid):
601
+ """停止进行中的对话:先取消排队中的消息,再中断正在跑的 turn。
602
+
603
+ dsh 插件族:cancel 优雅中断当前 turn(保留已流式交付的文本);inbox 里的排队项
604
+ 一并清掉(停止 = 不再执行这条会话上排着的工作)。返回是否命中了运行中的对话
605
+ (排队取消不算命中)。
606
+ """
607
+ cancel_queued(sid=sid)
608
+ with _lock:
609
+ rec = _CHATS.get(sid)
610
+ if rec is None or not rec.get("dsh_plugin"):
611
+ return False
612
+ try:
613
+ dshdriver.cancel(sid)
614
+ return True
615
+ except dshdriver.DshDriverError:
616
+ return False