@gavinc-cn/touchstone-dsh 0.1.0 → 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 +22 -1
- package/README.zh.md +12 -1
- package/agents.py +0 -0
- package/auth.py +0 -0
- package/board.py +1119 -93
- 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 +63 -12
- package/dsh-plugin/cordis.patch.yml +7 -3
- package/dsh-plugin/install.sh +8 -5
- package/dsh-plugin/lib/agent-driver.js +895 -126
- package/dsh-plugin/lib/client.bundle.js +94 -9
- package/dsh-plugin/lib/index.js +399 -20
- 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 +100 -6
- 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 +1226 -42
- 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 +8 -3
- package/platcompat.py +0 -0
- package/prompts.py +0 -0
- package/rag.py +0 -0
- package/requirements.txt +2 -1
- package/runner.py +11 -0
- package/server.py +264 -30
- package/sessparse.py +244 -44
- package/touchstone.py +8 -4
- 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 +100 -0
- package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
- package/webui/dist-plugin/assets/index-eHpxBfP6.js +0 -473
package/board.py
CHANGED
|
@@ -283,11 +283,14 @@ def set_parent(project_id, card_id, parent_id):
|
|
|
283
283
|
return None
|
|
284
284
|
|
|
285
285
|
|
|
286
|
-
def move_card(project, card_id, target, block_text="", before_id=None
|
|
286
|
+
def move_card(project, card_id, target, block_text="", before_id=None,
|
|
287
|
+
merge_ack=False):
|
|
287
288
|
"""移列总入口(服务端仲裁)。返回 (card_dict, None) 或 (None, err_dict)。
|
|
288
289
|
|
|
289
|
-
err_dict
|
|
290
|
-
{"blocked": "parent-not-done"}
|
|
290
|
+
err_dict 三种:{"error": "..."}(硬错误,列未变——含进 doing 起会话失败回滚原列);
|
|
291
|
+
{"blocked": "parent-not-done"}(父任务拦截,列未变,前端弹确认);
|
|
292
|
+
{"merge_pending": {...}}(独立 worktree 卡进「已完成」且有提交没回流主分支,
|
|
293
|
+
列未变,前端弹「是否交给 agent 合并」,2026-10-07 批次)。
|
|
291
294
|
queue 拦截不算错误:卡片落「正在开发」列排队占位(block_kind='queue')入统一队列,正常返回。
|
|
292
295
|
规则:doing 不可直接拖回 todo;离开 todo 清定时;doing→doing 同列拖放走
|
|
293
296
|
手势映射(v2c T1,裁决 R10——before_id 不再丢弃、不再直接返回,force/停止/
|
|
@@ -307,6 +310,10 @@ def move_card(project, card_id, target, block_text="", before_id=None):
|
|
|
307
310
|
即整体放弃本次移列并 400 报错,见 `_archive_card_sessions`)。
|
|
308
311
|
2026-10-07 起本路径落列一律 `mark_unread=False`:拖列/按钮移列都是用户本人
|
|
309
312
|
在看板上发起的操作,不是「用户没看见的状态更新」,不该由平台反过来提醒用户。
|
|
313
|
+
独立 worktree 卡进「已完成」的额外闸(2026-10-07 批次):卡在独立工作树里开发
|
|
314
|
+
⇒ 改动在 `ts/card-<id>` 分支上、主分支一无所知;`merge_ack=False`(默认)时先
|
|
315
|
+
查有没有待合并提交,有则返回 `{"merge_pending": ...}` **不动列**,由用户决定
|
|
316
|
+
「交给 agent 合并」还是「仅通过」(后者由前端带 `merge_ack=true` 再来一次)。
|
|
310
317
|
"""
|
|
311
318
|
card = db.get_board_card(card_id)
|
|
312
319
|
if card is None:
|
|
@@ -322,6 +329,13 @@ def move_card(project, card_id, target, block_text="", before_id=None):
|
|
|
322
329
|
return _doing_gesture(project, card, before_id)
|
|
323
330
|
if src == "doing" and target == "todo":
|
|
324
331
|
return None, {"error": "doing 不可直接拖回 todo"}
|
|
332
|
+
# 待合并提交闸(2026-10-07 批次):独立 worktree 卡进「已完成」前先问一句——
|
|
333
|
+
# 放在归档同步**之前**,因为这条分支要「列不动」地返回(先归档再问会把
|
|
334
|
+
# 会话归档了却没过卡,留下不一致状态)。
|
|
335
|
+
if target == "done" and src != "done" and not merge_ack:
|
|
336
|
+
pend = merge_pending(project, card)
|
|
337
|
+
if pend:
|
|
338
|
+
return None, {"merge_pending": pend}
|
|
325
339
|
# 归档同步(2026-10-05):进/出「已完成」先做归档面,失败即整体放弃本次移列
|
|
326
340
|
# (硬失败 400 报前端,卡片列不变)。**归档先行**的理由:先落列再归档的话,
|
|
327
341
|
# 失败回滚要连「已停会话/已出队/已改排序」一起退,代价与风险都大;先行失败
|
|
@@ -787,6 +801,34 @@ def _session_card_map(cards):
|
|
|
787
801
|
return bound
|
|
788
802
|
|
|
789
803
|
|
|
804
|
+
def _subagent_session(sid, origin=None):
|
|
805
|
+
"""该会话是否 dsh **子代理会话**(占用豁免判据,2026-10-07)。
|
|
806
|
+
|
|
807
|
+
子代理是主会话的实现细节:它不构成项目外部占用(不建 ext 行、也不触发补投影)——
|
|
808
|
+
否则「一次派 8 个子代理」=项目被占满、排队单元永不补位(实障:卡 870 排队
|
|
809
|
+
5 小时,成因即两条子代理会话的 ext 行)。
|
|
810
|
+
|
|
811
|
+
`origin` 显式传入时用调用方手上的值(`_ext_candidates` 已在快照行里);
|
|
812
|
+
未传/为空=未知(旧插件未上报)→ 回落 `sessparse.is_subagent` 的会话头读口
|
|
813
|
+
(磁盘,按 stamp 缓存)。两处都判不了=False(按主会话处理:占用判定宁可多等,
|
|
814
|
+
不可误放行,与「断连=未知」同向)。
|
|
815
|
+
"""
|
|
816
|
+
val = origin
|
|
817
|
+
if val is None:
|
|
818
|
+
try:
|
|
819
|
+
val = (dshevents.get(sid) or {}).get("origin") or ""
|
|
820
|
+
except Exception:
|
|
821
|
+
val = ""
|
|
822
|
+
if str(val or "") == "subagent":
|
|
823
|
+
return True
|
|
824
|
+
if str(val or ""):
|
|
825
|
+
return False # 明确上报了别的 origin(主会话)
|
|
826
|
+
try:
|
|
827
|
+
return sessparse.is_subagent("dsh", sid)
|
|
828
|
+
except Exception:
|
|
829
|
+
return False
|
|
830
|
+
|
|
831
|
+
|
|
790
832
|
def _ext_candidates(project, cards):
|
|
791
833
|
"""项目活跃会话集合 → 占位目标/探测不明卡集(探测对象改造核,R15)。
|
|
792
834
|
|
|
@@ -833,6 +875,10 @@ def _ext_candidates(project, cards):
|
|
|
833
875
|
sid = str(r.get("session_id") or "")
|
|
834
876
|
if not sid or r.get("status") != "running":
|
|
835
877
|
continue # 空闲/未知不构成占用
|
|
878
|
+
# 子代理会话不是外部占用(2026-10-07):既不建行,也不进 unbound 触发投影
|
|
879
|
+
# (它本就不该有卡;旧插件未上报 origin 时回落会话头判定,见 _subagent_session)
|
|
880
|
+
if _subagent_session(sid, r.get("origin")):
|
|
881
|
+
continue
|
|
836
882
|
# 只认本项目工作目录下的会话:dsh 会话池是宿主全局的,跨项目的在跑
|
|
837
883
|
# 会话不能算到本项目头上(ext 行按项目工作目录归属)
|
|
838
884
|
if os.path.abspath(str(r.get("cwd") or "")) != pdir:
|
|
@@ -903,7 +949,67 @@ def _recover_ext_rows(queued=()):
|
|
|
903
949
|
return changed
|
|
904
950
|
|
|
905
951
|
|
|
906
|
-
def
|
|
952
|
+
def refresh_ext_rows(reason="外部条目周期对账"):
|
|
953
|
+
"""按项目活跃 ext 行的实况对账刷新(周期自检 / 启动补跑共用入口)。
|
|
954
|
+
|
|
955
|
+
对「有活跃 ext 行的项目」逐个跑 `_ext_refresh`(快照口径:中枢在线时以对齐过的
|
|
956
|
+
注册表为准——不在场即收口;未连接时返回 None=保行,绝不把未知当空闲)。有行
|
|
957
|
+
变化即唤醒补位(前缀成员变化,时机③),无变化不空唤醒。返回是否有行变化。
|
|
958
|
+
|
|
959
|
+
与 `_recover_ext_rows` 的分工(2026-10-07 实障修复):那个是**重启恢复**专用
|
|
960
|
+
(刷新面=有行项目 ∪ 有排队卡项目),执行点在 server 启动序里 `dshevents.start()`
|
|
961
|
+
之前 ⇒ 探测必然不可用、一律保行;本函数是**运行期兜底与启动补跑**——调和器的
|
|
962
|
+
逐卡探测(`_iw_once`)修好「在线但会话不在池」的口径后已能自动收口,本入口
|
|
963
|
+
覆盖「调和器未跑到/被 hold 语义挡住」的漏网行,也用于中枢首连后的启动补跑。
|
|
964
|
+
|
|
965
|
+
项目已删(行残留)走 `_finish_all_ext` 防御收口(同 recover 口径)。"""
|
|
966
|
+
changed = False
|
|
967
|
+
for pid in {row["project_id"] for row in waitq.active_ext_items()}:
|
|
968
|
+
proj = db.get_project(pid)
|
|
969
|
+
if proj is None:
|
|
970
|
+
if _finish_all_ext(pid, reason):
|
|
971
|
+
changed = True
|
|
972
|
+
continue
|
|
973
|
+
if _ext_refresh(proj):
|
|
974
|
+
changed = True
|
|
975
|
+
if changed and runner.INSTANCE is not None:
|
|
976
|
+
runner.INSTANCE.notify_busy_change() # 前缀成员变化:唤醒排队单元重新挑选
|
|
977
|
+
return changed
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
# 启动补跑等中枢首连的上限(秒):dsh 宿主同机,正常对齐是毫秒级;给足抖动余量
|
|
981
|
+
EXT_RECOVER_WAIT_SECONDS = 30.0
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def start_ext_recover_after_connect(timeout=EXT_RECOVER_WAIT_SECONDS):
|
|
985
|
+
"""启动补跑线程:等中枢首连就绪后跑一次 ext 行对账(2026-10-07 实障修复)。
|
|
986
|
+
|
|
987
|
+
`board.recover()` 尾部的 `_recover_ext_rows` 执行在 server 启动序里
|
|
988
|
+
`dshevents.start()` **之前**(顺序硬约束不变)⇒ 那时探测必然「不可用」、按
|
|
989
|
+
「宁可多等」一律保行——上一进程留下的僵尸 ext 行(会话早已结束、行仍
|
|
990
|
+
running)会继续占位,**重启也救不回来**(实障:卡 870 排队 5 小时、项目 119
|
|
991
|
+
积 10 条僵尸行堵死调度)。本函数由 server 在 `dshevents.start()` 之后调用:
|
|
992
|
+
后台线程等首连(上限 timeout,不阻塞启动与端口绑定),就绪即跑一次
|
|
993
|
+
`refresh_ext_rows`(快照口径收口僵尸行 + 唤醒补位)。
|
|
994
|
+
|
|
995
|
+
未配置驱动(独立形态)/超时未就绪:直接结束——行保原样,等调和器与自检
|
|
996
|
+
线程接手(它们的收口口径同样不把未知当空闲)。返回线程对象供测试 join。
|
|
997
|
+
"""
|
|
998
|
+
def _run():
|
|
999
|
+
try:
|
|
1000
|
+
if not dshevents.wait_connected(timeout):
|
|
1001
|
+
return # 未就绪:保行,交给运行期兜底
|
|
1002
|
+
refresh_ext_rows("启动补跑")
|
|
1003
|
+
except Exception as e: # noqa: BLE001 — 守护线程不 crash
|
|
1004
|
+
print(f"[board] 启动补跑 ext 行对账异常: {e}", flush=True)
|
|
1005
|
+
|
|
1006
|
+
th = threading.Thread(target=_run, daemon=True, name="board-ext-recover")
|
|
1007
|
+
th.start()
|
|
1008
|
+
return th
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _enter_doing(project, card, extra="", force=False, worktree=False,
|
|
1012
|
+
queue_worktree=False):
|
|
907
1013
|
"""进 doing 统一入口(start 端点 / 拖入 doing / 定时开工共用)。
|
|
908
1014
|
|
|
909
1015
|
force=true:跳过父依赖与排队直接起会话(用户自担风险),reason 标签按实参分
|
|
@@ -941,6 +1047,10 @@ def _enter_doing(project, card, extra="", force=False, worktree=False):
|
|
|
941
1047
|
已带 worktree 标记时**本条起跑路径整体转交 `_enter_doing_worktree`**——那条路
|
|
942
1048
|
不写 `wait_items` 行(不占项目运行位、不阻塞同项目其他单元),语义与失败收口
|
|
943
1049
|
见该函数 docstring;本函数其余分支保持原样(排队/force 直起一字未动)。
|
|
1050
|
+
|
|
1051
|
+
例外(2026-10-07 批次):`queue_worktree=True` 时**不转交**,worktree 卡照常走
|
|
1052
|
+
统一队列。唯一调用方是 `handoff_worktree_merge`(把合并任务交给 agent)——合并
|
|
1053
|
+
要动主仓库工作区,必须与项目内其他单元串行,免排队语义在这里有害。
|
|
944
1054
|
"""
|
|
945
1055
|
if _web_family(project) is None:
|
|
946
1056
|
# 退场族防呆(见 docstring):不建行、不入队、不动列——直接明确报错
|
|
@@ -957,7 +1067,9 @@ def _enter_doing(project, card, extra="", force=False, worktree=False):
|
|
|
957
1067
|
# 「在新 worktree 中开始」(worktree=True),或该卡此前已被标记为 worktree 卡
|
|
958
1068
|
# (card.worktree 非空 ⇒ 打回续改/重试/再进开发列自动复用同一工作树)——
|
|
959
1069
|
# 一律交给 `_enter_doing_worktree`:起跑但不入统一队列(不写 wait_items 行)。
|
|
960
|
-
|
|
1070
|
+
# queue_worktree=True(合并回交,2026-10-07)是唯一例外:走下面的排队路径,
|
|
1071
|
+
# 合并要动主仓库工作区,必须与项目内其他单元串行。
|
|
1072
|
+
if not queue_worktree and (worktree or (_card_opt(card, "worktree") or "").strip()):
|
|
961
1073
|
return _enter_doing_worktree(project, card, extra, force=force,
|
|
962
1074
|
want=bool(worktree))
|
|
963
1075
|
if card["column_key"] == "doing" \
|
|
@@ -1085,12 +1197,14 @@ def _enter_doing(project, card, extra="", force=False, worktree=False):
|
|
|
1085
1197
|
return card_json(db.get_board_card(card["id"])), None
|
|
1086
1198
|
|
|
1087
1199
|
|
|
1088
|
-
def start_into_doing(project, card, extra="", force=False, worktree=False
|
|
1200
|
+
def start_into_doing(project, card, extra="", force=False, worktree=False,
|
|
1201
|
+
queue_worktree=False):
|
|
1089
1202
|
"""start 端点专用:语义全部由 _enter_doing 承载(保留函数名,server 调用点不动)。
|
|
1090
1203
|
|
|
1091
1204
|
worktree=True = 用户在下拉里选「🌿 在新 worktree 中开始」(2026-10-06 批次)。
|
|
1205
|
+
queue_worktree=True = worktree 卡也走统一队列(合并回交,2026-10-07 批次)。
|
|
1092
1206
|
"""
|
|
1093
|
-
return _enter_doing(project, card, extra, force, worktree)
|
|
1207
|
+
return _enter_doing(project, card, extra, force, worktree, queue_worktree)
|
|
1094
1208
|
|
|
1095
1209
|
|
|
1096
1210
|
# ---------- 独立 worktree 卡起跑(2026-10-06 批次) ----------
|
|
@@ -1214,26 +1328,30 @@ def _enter_doing_worktree(project, card, extra="", force=False, want=False):
|
|
|
1214
1328
|
def worktree_preview(project, card):
|
|
1215
1329
|
"""卡片「在新 worktree 中开始」入口的预览(server 端点用,**不落盘**):
|
|
1216
1330
|
|
|
1217
|
-
返回 {supported, reason, path, branch, exists}——supported=False 时 reason
|
|
1218
|
-
|
|
1331
|
+
返回 {supported, reason, path, branch, exists, merge}——supported=False 时 reason
|
|
1332
|
+
是给用户看的中文原因(项目不是 git 仓库 / 卡片已有主会话,D10);前端据此把
|
|
1219
1333
|
菜单项置灰并显示原因,避免用户点了必然失败的入口。
|
|
1334
|
+
`merge`(2026-10-07 批次)= 待合并提交判定(`merge_pending`,None=无待合并):
|
|
1335
|
+
已经是 worktree 卡的卡片走本端点时前端据它决定「通过」要不要弹合并框。
|
|
1220
1336
|
"""
|
|
1221
1337
|
branch = worktree.branch_for(card["id"])
|
|
1338
|
+
pend = merge_pending(project, card)
|
|
1222
1339
|
if (_card_opt(card, "worktree") or "").strip():
|
|
1223
1340
|
# 已是 worktree 卡:续跑自动复用,无需再选(前端一般不再显示该入口)
|
|
1224
1341
|
return {"supported": False, "reason": "该卡片已在独立 worktree 中运行",
|
|
1225
1342
|
"path": _card_opt(card, "worktree"), "branch": branch,
|
|
1226
|
-
"exists": os.path.isdir(_card_opt(card, "worktree"))
|
|
1343
|
+
"exists": os.path.isdir(_card_opt(card, "worktree")),
|
|
1344
|
+
"merge": pend}
|
|
1227
1345
|
if (card["session_id"] or "").strip():
|
|
1228
1346
|
return {"supported": False,
|
|
1229
1347
|
"reason": "该卡片已有主会话,不能切换到独立 worktree(请新建卡片)",
|
|
1230
|
-
"path": "", "branch": branch, "exists": False}
|
|
1348
|
+
"path": "", "branch": branch, "exists": False, "merge": pend}
|
|
1231
1349
|
pv = worktree.preview(project, card["id"])
|
|
1232
1350
|
if not pv["ok"]:
|
|
1233
1351
|
return {"supported": False, "reason": pv["error"], "path": "",
|
|
1234
|
-
"branch": branch, "exists": False}
|
|
1352
|
+
"branch": branch, "exists": False, "merge": pend}
|
|
1235
1353
|
return {"supported": True, "reason": "", "path": pv["path"],
|
|
1236
|
-
"branch": pv["branch"], "exists": pv["exists"]}
|
|
1354
|
+
"branch": pv["branch"], "exists": pv["exists"], "merge": pend}
|
|
1237
1355
|
|
|
1238
1356
|
|
|
1239
1357
|
def cleanup_card_worktree(project, card):
|
|
@@ -1253,6 +1371,105 @@ def cleanup_card_worktree(project, card):
|
|
|
1253
1371
|
return True, ""
|
|
1254
1372
|
|
|
1255
1373
|
|
|
1374
|
+
# ---------- worktree 改动回流主分支(「通过」时的待合并闸,2026-10-07 批次) ----------
|
|
1375
|
+
#
|
|
1376
|
+
# 需求(用户 2026-10-07):独立 worktree 卡的改动在 `ts/card-<id>` 分支上,主分支
|
|
1377
|
+
# 一无所知;点「通过」时应当先看有没有待合并提交——没有就直接完成,有就把合并
|
|
1378
|
+
# 交给 agent(冲突只有 agent 能解),**平台自己不执行任何 git 合并命令**。
|
|
1379
|
+
#
|
|
1380
|
+
# 分工与边界:
|
|
1381
|
+
# - 平台(本模块):判定 `ahead`(见 `merge_pending`)+ 生成指令 + 把卡片连同指令
|
|
1382
|
+
# 送回统一队列(`handoff_worktree_merge`);不碰分支、不替用户决定合并结果。
|
|
1383
|
+
# - agent:在卡片会话里执行同步主分支、解冲突、回流合并(指令原文
|
|
1384
|
+
# `build_merge_instruction`),干完这轮会话结束 ⇒ 卡片按既有流程自动回待审核;
|
|
1385
|
+
# 用户再点「通过」时 `ahead` 已为 0,直接进「已完成」。
|
|
1386
|
+
# - worktree 卡平时**免排队**(不占项目运行位,plan D1);但合并要动主仓库工作区,
|
|
1387
|
+
# 必须与项目内其他单元串行 ⇒ 这条路径显式走统一队列(`queue_worktree=True`),
|
|
1388
|
+
# 卡片在开发列显示「排队中」,轮到才起会话。
|
|
1389
|
+
|
|
1390
|
+
# 合并任务指令的段落标记:投给卡片会话的指令以它开头,`build_start_prompt` 据此
|
|
1391
|
+
# 走专用提示词(不套打回意见的「【修改意见】…请按修改意见继续完善」壳——那会让
|
|
1392
|
+
# agent 以为要改代码,而不是合并分支)。生产者=`build_merge_instruction`。
|
|
1393
|
+
MERGE_TASK_MARK = "【合并任务】"
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def merge_pending(project, card):
|
|
1397
|
+
"""「通过」前的待合并提交判定(只服务独立 worktree 卡)。返回 dict 或 None。
|
|
1398
|
+
|
|
1399
|
+
None = 无需询问(非 worktree 卡 / 工作树读不到 / 没有待合并提交)——三种情形
|
|
1400
|
+
都按普通卡直接完成(用户 2026-10-07 约定:没有可合并的 commit 就直接进已完成;
|
|
1401
|
+
工作树被用户删掉等读不到的情形拦下来只会卡住「通过」,故一并放行)。
|
|
1402
|
+
有值 = 前端弹「是否交给 agent 合并」(列不动),字段:
|
|
1403
|
+
{branch, target, ahead, behind, dirty, dirty_count, path}。
|
|
1404
|
+
"""
|
|
1405
|
+
if not (_card_opt(card, "worktree") or "").strip():
|
|
1406
|
+
return None
|
|
1407
|
+
st = worktree.merge_status(project, card)
|
|
1408
|
+
if not st["ok"] or st["ahead"] <= 0:
|
|
1409
|
+
return None
|
|
1410
|
+
return {"branch": st["branch"], "target": st["target"],
|
|
1411
|
+
"ahead": st["ahead"], "behind": st["behind"],
|
|
1412
|
+
"dirty": st["dirty"], "dirty_count": st["dirty_count"],
|
|
1413
|
+
"path": st["path"]}
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def build_merge_instruction(project, card, st):
|
|
1417
|
+
"""生成交给 agent 的合并指令(原文即 prompt,见 `MERGE_TASK_MARK`)。
|
|
1418
|
+
|
|
1419
|
+
指令把两个路径都写死(工作树 / 主仓库)——agent 的 cwd 是工作树,回流的
|
|
1420
|
+
`git -C <主仓库> merge` 是跨目录操作,不写清楚它会去猜(plan D7 的教训)。
|
|
1421
|
+
合并口径按用户 2026-10-07 约定:**先同步主分支最新代码,再回流;尽量 ff,
|
|
1422
|
+
不能 ff 才普通 merge**;冲突由 agent 解,解不了必须 abort 回退,不留半合并态。
|
|
1423
|
+
"""
|
|
1424
|
+
main = (project["project_dir"] or "").strip()
|
|
1425
|
+
branch, target, path = st["branch"], st["target"], st["path"]
|
|
1426
|
+
dirty = int(st.get("dirty_count") or 0)
|
|
1427
|
+
head = [f"{MERGE_TASK_MARK}把卡片 #{card['id']} 的独立 worktree 改动合并回主分支。",
|
|
1428
|
+
f"工作树:{path}(分支 {branch},你的会话工作目录)",
|
|
1429
|
+
f"主仓库:{main}(主分支 {target})",
|
|
1430
|
+
f"待合并提交:{st['ahead']} 个"]
|
|
1431
|
+
if dirty:
|
|
1432
|
+
head.append(f"工作树当前有 {dirty} 处未提交改动,第一步先提交它们。")
|
|
1433
|
+
steps = [
|
|
1434
|
+
"步骤:",
|
|
1435
|
+
f"1. 提交工作树里的未提交改动(有的话),提交信息带上卡片号 #{card['id']}。",
|
|
1436
|
+
f"2. 同步主分支最新代码:若项目有远端,先在主仓库执行"
|
|
1437
|
+
f" `git -C {main} fetch`(无远端会失败,忽略即可);然后在工作树执行"
|
|
1438
|
+
f" `git -C {path} merge {target}`(主分支已合入过就跳过)。"
|
|
1439
|
+
f"冲突在工作树内解决,并跑相关测试确认。",
|
|
1440
|
+
f"3. 回流合并:在主仓库执行 `git -C {main} merge --ff-only {branch}`"
|
|
1441
|
+
f"(能快进就快进,主分支保持线性);若因主分支又前进而无法快进,"
|
|
1442
|
+
f"改用 `git -C {main} merge {branch}` 生成合并提交,冲突自己解决。",
|
|
1443
|
+
f"4. 不要 push,不要 rebase/重置主分支,不要改动与本卡无关的文件。",
|
|
1444
|
+
f"5. 结束后用一两句话报告:合并是否成功、目标分支名、合并后的 HEAD 短 sha;"
|
|
1445
|
+
f"失败就说明卡在哪一步。",
|
|
1446
|
+
f"若第 2/3 步冲突无法解决:`git -C {path} merge --abort`(工作树侧)或"
|
|
1447
|
+
f" `git -C {main} merge --abort`(主仓库侧)回退,"
|
|
1448
|
+
f"绝不留半合并状态,并在报告里说明卡在哪里。",
|
|
1449
|
+
]
|
|
1450
|
+
return "\n".join(head + [""] + steps)[:PROMPT_MAX]
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def handoff_worktree_merge(project, card):
|
|
1454
|
+
"""把「合并 worktree 改动回主分支」交给卡片会话(回统一队列排队)。
|
|
1455
|
+
|
|
1456
|
+
返回 (card_dict, None) 或 (None, err_dict)。平台**只投递不执行**:判定有
|
|
1457
|
+
待合并提交后,把 `build_merge_instruction` 的指令当打回意见送进统一队列
|
|
1458
|
+
(`queue_worktree=True` 让 worktree 卡走排队路径而不是免排队直起)——轮到该卡
|
|
1459
|
+
时才起会话,agent 干完这轮卡片自动回待审核,用户再点「通过」即完成。
|
|
1460
|
+
前置校验:无待合并提交 / 无主会话(没会话就没上下文可投递)直接报错。
|
|
1461
|
+
"""
|
|
1462
|
+
if not (card["session_id"] or "").strip():
|
|
1463
|
+
return None, {"error": "该卡片尚无主会话,无法交给 agent 合并"}
|
|
1464
|
+
st = worktree.merge_status(project, card)
|
|
1465
|
+
if not st["ok"]:
|
|
1466
|
+
return None, {"error": f"无法判定待合并提交:{st['error']}"}
|
|
1467
|
+
if st["ahead"] <= 0:
|
|
1468
|
+
return None, {"error": "该卡片没有待合并的提交(可直接通过)"}
|
|
1469
|
+
instruction = build_merge_instruction(project, card, st)
|
|
1470
|
+
return start_into_doing(project, card, instruction, queue_worktree=True)
|
|
1471
|
+
|
|
1472
|
+
|
|
1256
1473
|
def dequeue_start(project, card):
|
|
1257
1474
|
"""runner 拾到排队卡片后的起跑(队列已保证项目串行位)。
|
|
1258
1475
|
|
|
@@ -1367,7 +1584,12 @@ def build_start_prompt(project, card, extra="", sid=""):
|
|
|
1367
1584
|
resume)传其 sid,否则传空串——无可续会话自动回落全量,避免给无上下文的
|
|
1368
1585
|
新会话发「继续」。
|
|
1369
1586
|
卡上标题/描述即便有编辑一并忽略(用户约定:有会话只发「继续」)。
|
|
1587
|
+
合并回交指令(extra 以 MERGE_TASK_MARK 开头,2026-10-07 批次):**原文直发**,
|
|
1588
|
+
不套「【修改意见】…请按修改意见继续完善」壳——那是打回改代码的语义,合并
|
|
1589
|
+
任务要的是执行 git 步骤,混在一起会让 agent 跑偏。
|
|
1370
1590
|
"""
|
|
1591
|
+
if extra.startswith(MERGE_TASK_MARK):
|
|
1592
|
+
return extra[:PROMPT_MAX]
|
|
1371
1593
|
if sid:
|
|
1372
1594
|
return build_continue_prompt(extra)
|
|
1373
1595
|
return build_task_prompt(project, card, extra)
|
|
@@ -1787,10 +2009,13 @@ def start_card(project, card, extra=""):
|
|
|
1787
2009
|
return _start_web(project, card, family, extra)
|
|
1788
2010
|
|
|
1789
2011
|
|
|
1790
|
-
def deliver_comment(project, card, comment_row, inject=False
|
|
1791
|
-
"""
|
|
1792
|
-
|
|
1793
|
-
|
|
2012
|
+
def deliver_comment(project, card, comment_row, inject=False):
|
|
2013
|
+
"""评论投递主会话(恒发评论原文,不包任何前缀——2026-10-10 去前缀)。
|
|
2014
|
+
|
|
2015
|
+
2026-09-06 起会话详情页路径 `raw=True` 直发原文、卡片评论区「投递」仍包
|
|
2016
|
+
「【看板评论】任务「标题」: 」前缀以区分消息来源;2026-10-10 用户裁定**所有
|
|
2017
|
+
投递路径一律原文**(前缀在会话里是噪音)——前缀构造与 `raw` 开关一并删除,
|
|
2018
|
+
`sent_text` 照旧落实际发送文本(= 评论原文,评论记录仍可追溯)。
|
|
1794
2019
|
|
|
1795
2020
|
2026-09-10(统一队列消息单元):不再立即投递——登记会话消息单元进 runner
|
|
1796
2021
|
统一队列,项目忙(同项目有任务/卡片会话在跑)时按入队顺序等待,项目空闲才
|
|
@@ -1801,10 +2026,7 @@ def deliver_comment(project, card, comment_row, inject=False, raw=False):
|
|
|
1801
2026
|
"""
|
|
1802
2027
|
family = _web_family(project)
|
|
1803
2028
|
sid = card["session_id"]
|
|
1804
|
-
|
|
1805
|
-
wrapped = comment_row["text"]
|
|
1806
|
-
else:
|
|
1807
|
-
wrapped = f"【看板评论】任务「{card['title']}」: {comment_row['text']}"
|
|
2029
|
+
text = comment_row["text"]
|
|
1808
2030
|
# 进队前的前置校验:拦住必然失败的投递(无会话 / 退场族无投递通道)
|
|
1809
2031
|
if family is None:
|
|
1810
2032
|
raise RuntimeError(agents.RETIRED_MSG)
|
|
@@ -1813,12 +2035,12 @@ def deliver_comment(project, card, comment_row, inject=False, raw=False):
|
|
|
1813
2035
|
inst = runner.INSTANCE
|
|
1814
2036
|
if inst is None:
|
|
1815
2037
|
# 无统一队列(单测/独立脚本):保持既有立即投递行为
|
|
1816
|
-
_deliver_now(project, card, comment_row,
|
|
2038
|
+
_deliver_now(project, card, comment_row, text, inject)
|
|
1817
2039
|
return None
|
|
1818
2040
|
# 排队中的消息可「立即注入」(见 chat.inject_now):family/model/
|
|
1819
2041
|
# comment_id 提交时定格进等待项 meta(chat_msgs 零加列,P3 裁决 R1),执行时
|
|
1820
2042
|
# 由 chat 侧按行+快照重建 _deliver_unit/_deliver_now
|
|
1821
|
-
return chat.submit(project["id"], sid,
|
|
2043
|
+
return chat.submit(project["id"], sid, text,
|
|
1822
2044
|
card_id=card["id"], comment_id=comment_row["id"],
|
|
1823
2045
|
inject=inject,
|
|
1824
2046
|
family=runner.agent_family(project["agent_path"] or ""),
|
|
@@ -1826,19 +2048,19 @@ def deliver_comment(project, card, comment_row, inject=False, raw=False):
|
|
|
1826
2048
|
or (project["model"] or "").strip())
|
|
1827
2049
|
|
|
1828
2050
|
|
|
1829
|
-
def _deliver_unit(project, card, comment_row,
|
|
2051
|
+
def _deliver_unit(project, card, comment_row, text, inject):
|
|
1830
2052
|
"""统一队列消息单元执行体:投递评论并等该会话结束(执行期间持有项目占用)。
|
|
1831
2053
|
|
|
1832
|
-
返回即释放占用:dsh
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
2054
|
+
返回即释放占用:dsh 会话的等轮次走 `chat.wait_turn` 分流口(外部会话订阅全局
|
|
2055
|
+
状态流、平台自持会话走既有按会话 SSE,规则见该函数);返回
|
|
2056
|
+
chat.STATE_YIELDED = turn 挂起等作答、已让位(chat.run_unit 据此落终态)。
|
|
2057
|
+
尾部 proc 分支为 CLI 时代的防御残留(单族化后 _RUNS 条目恒为 dsh,proc=None)。
|
|
1836
2058
|
"""
|
|
1837
|
-
_deliver_now(project, card, comment_row,
|
|
2059
|
+
_deliver_now(project, card, comment_row, text, inject)
|
|
1838
2060
|
family = _web_family(project)
|
|
1839
2061
|
if family is not None:
|
|
1840
|
-
return chat.
|
|
1841
|
-
|
|
2062
|
+
return chat.wait_turn(project["project_dir"], card["session_id"],
|
|
2063
|
+
family)
|
|
1842
2064
|
with _runs_lock:
|
|
1843
2065
|
rec = _RUNS.get(card["id"])
|
|
1844
2066
|
proc = rec.get("proc") if rec is not None else None
|
|
@@ -1846,25 +2068,79 @@ def _deliver_unit(project, card, comment_row, wrapped, inject):
|
|
|
1846
2068
|
proc.wait()
|
|
1847
2069
|
|
|
1848
2070
|
|
|
1849
|
-
|
|
1850
|
-
|
|
2071
|
+
# 卡片会话已不在宿主时的投递文案(用户可见):不暴露「驱动池 / sid」这类内部
|
|
2072
|
+
# 链路词,只给「发生了什么 + 怎么办」。sync 卡与「接不回」两种情形共用一句
|
|
2073
|
+
# ——两者对用户的可执行动作相同(点「开始」重新拉起会话)。
|
|
2074
|
+
LOST_SESSION_MSG = ("会话已结束(宿主无活动 agent),消息未送达:"
|
|
2075
|
+
"请点「开始」重新拉起会话后再发送")
|
|
2076
|
+
|
|
2077
|
+
|
|
2078
|
+
def _ensure_card_session(project, card, sid):
|
|
2079
|
+
"""投递前确保宿主仍持有该卡会话(宿主已失去 ⇒ 按 resume 接回);返回可否投递。
|
|
2080
|
+
|
|
2081
|
+
报障现场(卡 934,2026-10-10):插件热重载把宿主里所有会话 dispose 掉之后,
|
|
2082
|
+
卡片 `session_id` 仍在库、卡照旧在列,用户发评论 / 点「立即注入」时驱动回
|
|
2083
|
+
404「会话不在驱动池中」——前端只能把这条内部文案原样 toast 出来。
|
|
2084
|
+
|
|
2085
|
+
判定阶梯(与调和器「会话确已结束」同源,见 `chat.host_session_lost`):
|
|
2086
|
+
① 宿主仍有该会话(注册表命中 / 中枢未对齐=未知)⇒ 放行,**零请求**,
|
|
2087
|
+
既有路径一个字节不变;
|
|
2088
|
+
② 确已失去 ∧ 平台自建卡(`origin` 非 `sync`)⇒ `chat.revive_host_session`
|
|
2089
|
+
按 `_start_web` 的续接语义 resume 回池(同一张卡、同一个 sid、同一上下文);
|
|
2090
|
+
③ 确已失去 ∧ sync 卡(`origin='sync'`=用户在 dsh GUI 直跑的会话)⇒ **不接回**:
|
|
2091
|
+
C 批设计「只投递不接管」,平台不擅自把用户的会话收养进自己的池,直接报
|
|
2092
|
+
明确文案(用户点「开始」=显式收养,那是既有路径)。
|
|
2093
|
+
接不回(会话文件缺失 / 驱动不可达)同样返回 False,由调用方报同一句文案。
|
|
2094
|
+
cwd 取 `card_workspace`(worktree 卡的会话必须接回它的工作树)。
|
|
2095
|
+
"""
|
|
2096
|
+
if not chat.host_session_lost(sid):
|
|
2097
|
+
return True # 会话在场/未知:不动作(零请求)
|
|
2098
|
+
cid = _card_opt(card, "id")
|
|
2099
|
+
row = db.get_board_card(cid) if cid else None
|
|
2100
|
+
origin = str(_card_opt(row if row is not None else card, "origin") or "")
|
|
2101
|
+
if origin == "sync":
|
|
2102
|
+
return False # 外部会话卡:不接管(见上 ③)
|
|
2103
|
+
return chat.revive_host_session(sid, cwd=card_workspace(project, row or card),
|
|
2104
|
+
task=f"card-{cid}")
|
|
2105
|
+
|
|
2106
|
+
|
|
2107
|
+
def _deliver_now(project, card, comment_row, text, inject=False):
|
|
2108
|
+
"""评论真正送达(立即路径与统一队列消息单元执行体共用;text 即评论原文)。
|
|
1851
2109
|
|
|
1852
2110
|
单族化后只有 dsh_plugin:followup 忙时排进 agent inbox(等同服务端排队
|
|
1853
2111
|
语义,不拒绝);inject=True 走 steer,注入当前 turn 的最近 step 边界
|
|
1854
2112
|
(steer 失败不致命,评论仍在 inbox/队列)。退场族无投递通道,直接报错。
|
|
2113
|
+
送达前两道人造兜底(顺序固定,都在 `_web_send` 之前):
|
|
2114
|
+
① **会话存活**(2026-10-10,修卡 934):宿主被插件热重载/重启清过之后,
|
|
2115
|
+
卡会话可能已不存在——`_ensure_card_session` 先接回再投递,接不回则报
|
|
2116
|
+
`LOST_SESSION_MSG`(可执行指引),不再把驱动 404 原文案抛给用户;
|
|
2117
|
+
② 重声明看管(C 批终审 I2,2026-10-10):本函数是评论**推送腿**的唯一出口,
|
|
2118
|
+
`_watch_prune` 的撤销判据只看活跃 `m:` 行(不看推送腿本身)。真实入口=平台
|
|
2119
|
+
重启后 `_rebuild_run → _deliver_unit → _deliver_now` 直投:`_WATCHED` 随进程
|
|
2120
|
+
清空、驱动侧 `watched` 也可能随插件重载清空 ⇒ 不补声明就撞驱动「未看管」404、
|
|
2121
|
+
`m:` 行落 error。与 `_answer_deliver` 同源同判据(`_watch_before_delivery`
|
|
2122
|
+
内部只对 `owned:false` 的池外会话声明,命中缓存零请求;池内/未知路径一个
|
|
2123
|
+
字节不变)。
|
|
1855
2124
|
"""
|
|
1856
2125
|
family = _web_family(project)
|
|
1857
2126
|
sid = card["session_id"]
|
|
1858
2127
|
if family is not None:
|
|
1859
2128
|
try:
|
|
2129
|
+
# 会话存活兜底(见上 ①):卡会话已被宿主丢掉时先 resume 接回再投递
|
|
2130
|
+
if not _ensure_card_session(project, card, sid):
|
|
2131
|
+
raise RuntimeError(LOST_SESSION_MSG)
|
|
2132
|
+
# 送达前重声明看管(C 批终审 I2):外部会话的看管可能已被 `_watch_prune`
|
|
2133
|
+
# 撤掉(平台重启后记账为空、驱动侧重载后 watched 为空),漏声明则下面
|
|
2134
|
+
# 这条驱动调用撞「未看管」404(见上)
|
|
2135
|
+
_watch_before_delivery(sid)
|
|
1860
2136
|
# dsh 的「立即注入」= steer(注入当前 turn 的最近 step 边界);
|
|
1861
2137
|
# 普通投递 = followup(忙时排进 inbox,等同服务端排队);
|
|
1862
2138
|
# 统一走 _web_send(单族投递唯一出口,测试打桩面也在此)
|
|
1863
|
-
_web_send(family, project, sid,
|
|
2139
|
+
_web_send(family, project, sid, text, inject=bool(inject))
|
|
1864
2140
|
except dshdriver.DshDriverError as e:
|
|
1865
2141
|
raise RuntimeError(f"评论投递失败: {e}")
|
|
1866
2142
|
db.update_board_comment(comment_row["id"], sent=1, session_id=sid,
|
|
1867
|
-
sent_text=
|
|
2143
|
+
sent_text=text)
|
|
1868
2144
|
return
|
|
1869
2145
|
# 退场族无投递通道(单族化):明确报错,不落已删除的 CLI 起子进程路径
|
|
1870
2146
|
raise RuntimeError(agents.RETIRED_MSG)
|
|
@@ -1990,6 +2266,15 @@ def compact_session(project, card, sid):
|
|
|
1990
2266
|
命令),触发即返回——压缩在 dsh 进程内异步进行(要过一次模型),失败落
|
|
1991
2267
|
宿主日志(原 kimi CLI 子进程压缩与两族 web REST 压缩已随族退场)。
|
|
1992
2268
|
|
|
2269
|
+
**池外两跳(2026-10-10 修「卡片 945 无法 compact」)**:宿主侧插件重载/dsh web
|
|
2270
|
+
重启后,平台自建的卡会话在驱动里只剩**观察**(`/live` 的 `owned:false`,宿主里
|
|
2271
|
+
仍活着)——旧实现只认驱动池,compact 必 404「会话不在驱动池中」,前端原样 toast。
|
|
2272
|
+
这里按 `rename_card_session` 同款两跳:首跳 404(=池外)⇒ `_ensure_watch(sid)` 补
|
|
2273
|
+
看管声明(幂等、**只声明不接管**)后重试一次;驱动侧 `/compact` 的池外回落分支按
|
|
2274
|
+
「看管 + 宿主有活 agent」放行(node 半同批实现)。为什么不能「先接管再压缩」:
|
|
2275
|
+
外部会话的 agent 已在宿主 store 注册,`/session` resume 必撞
|
|
2276
|
+
`agent "…" is already registered` ⇒ 500(C 批 T1 实测)。
|
|
2277
|
+
|
|
1993
2278
|
sid 必须属于该卡片(sessions 清单或主 session_id),不支持的族抛
|
|
1994
2279
|
RuntimeError(server 转 400)。
|
|
1995
2280
|
"""
|
|
@@ -2003,10 +2288,25 @@ def compact_session(project, card, sid):
|
|
|
2003
2288
|
# 路线 A P3(2026-10-03):走驱动 `/compact`(插件侧执行宿主 `/compact`
|
|
2004
2289
|
# 命令;`ctx.compaction` 服务在插件 ctx 不可注入,命令路径是 P0 探针的结论)。
|
|
2005
2290
|
# 触发即返回——压缩在 dsh 进程内异步进行(要过一次模型),失败落宿主日志。
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2291
|
+
last = ""
|
|
2292
|
+
for attempt in (1, 2):
|
|
2293
|
+
try:
|
|
2294
|
+
dshdriver.compact(sid)
|
|
2295
|
+
return
|
|
2296
|
+
except dshdriver.DshDriverError as e:
|
|
2297
|
+
last = str(e)
|
|
2298
|
+
pool_miss = getattr(e, "code", None) == 404
|
|
2299
|
+
# 只在首跳 404(池外)补一次看管声明;**force=True** 跳过平台侧陈旧记账
|
|
2300
|
+
# (驱动侧重载会清空 `watched` 表,`_WATCHED` 却仍写着「声明过」)。
|
|
2301
|
+
# 已看管但宿主无活 agent 的 404 重试同样失败,代价只是一次请求,换来判定
|
|
2302
|
+
# 不依赖文案匹配;且第二跳的文案更准确(「会话已结束(宿主无活动 agent)」
|
|
2303
|
+
# 取代含糊的「不在驱动池中」)。
|
|
2304
|
+
if attempt == 1 and pool_miss and _ensure_watch(sid, force=True):
|
|
2305
|
+
print(f"[board] 卡片 compact:池外会话补看管声明后重试 sid={sid}",
|
|
2306
|
+
flush=True)
|
|
2307
|
+
continue
|
|
2308
|
+
break
|
|
2309
|
+
raise RuntimeError(f"compact 失败: {last}")
|
|
2010
2310
|
|
|
2011
2311
|
|
|
2012
2312
|
def fork_compact_session(project, card, sid):
|
|
@@ -2469,10 +2769,86 @@ def _reconcile_starting_rows():
|
|
|
2469
2769
|
f"(claimed_at={claimed})", flush=True)
|
|
2470
2770
|
|
|
2471
2771
|
|
|
2772
|
+
def _heal_queue_placeholders():
|
|
2773
|
+
"""排队占位缺等待项行自愈(30s 全量对账节拍;2026-10-10 实障卡 936)。
|
|
2774
|
+
|
|
2775
|
+
背景:卡片停在「正在开发」+`block_kind='queue'`(前端「排队中」徽标的**唯一**
|
|
2776
|
+
来源,见 `queue_state_of`),但 wait_items 里没有对应的活跃 c: 行。调度权威是
|
|
2777
|
+
行——`runner._pick_locked` 只遍历等待项行,行不在场 ⇒ 补位器永远拾不到该卡,
|
|
2778
|
+
卡片永久僵在排队态(除用户重按「开始」的幂等补建或服务重启 `board.recover`
|
|
2779
|
+
尾段补建外**无任何自愈出口**),项目看着空闲而队列不动。本函数把 `recover`
|
|
2780
|
+
尾段那条「占位卡缺等待项行就补建」的对账规则**周期化**:逐张排队占位卡核对,
|
|
2781
|
+
行缺失即经 `runner.submit_card` 补建(幂等:行仍在则 `enqueue_card` 复用既有
|
|
2782
|
+
行、位次不动;补建落等待区末尾,与 recover 同几何——原排队位次已不可考)。
|
|
2783
|
+
|
|
2784
|
+
跳过四类(都不是「行丢了」,补行反而有害):
|
|
2785
|
+
- 已答待送达(活跃 a: 行在场):等待单元是 answer 行,本就不建 c: 行
|
|
2786
|
+
(`_queue_answer_unit` 单写口径),补 c: 行会让未送达答案与新一轮起跑打架;
|
|
2787
|
+
- 独立 worktree 卡:起跑不入队、不落行(spec §47),`block_kind='queue'`
|
|
2788
|
+
对它是残留投影,补行等于把它塞回统一队列;
|
|
2789
|
+
- 平台在管(`_has_active_run`):会话在跑、运行行缺失归收尾/调和器兜底,
|
|
2790
|
+
此处补行会与在跑会话并发起第二轮;
|
|
2791
|
+
- **会话实况在跑**(卡有 sid 且 `dshevents` 注册表报 `running`):占位卡也可能是
|
|
2792
|
+
「会话被外部直跑/重启后行丢失」形态(恢复运行的行未重建),补行会让补位器起跑
|
|
2793
|
+
同一会话再投一轮 prompt——行缺失交调和器/收尾路径处理,本函数只补「确实没人在
|
|
2794
|
+
跑」的卡(单族化后状态读口零请求、本地注册表;断连/未知不在此列,照常补建)。
|
|
2795
|
+
|
|
2796
|
+
检测到的异常形态各落一行诊断(`[board] 排队占位缺等待项行(占位在、行不在)…`);
|
|
2797
|
+
异常逐卡吞(补建失败也只留一行诊断),不影响其他卡;返回本次补建条数。
|
|
2798
|
+
`runner` 单例缺位(调试退化)时只报告不自愈——日志仍如实留痕。"""
|
|
2799
|
+
inst = runner.INSTANCE
|
|
2800
|
+
try:
|
|
2801
|
+
queued = db.list_queued_board_cards()
|
|
2802
|
+
except Exception as e: # noqa: BLE001
|
|
2803
|
+
print(f"[board] 排队占位自愈扫描失败: {e}", flush=True)
|
|
2804
|
+
return 0
|
|
2805
|
+
healed = 0
|
|
2806
|
+
for card in queued:
|
|
2807
|
+
try:
|
|
2808
|
+
cid = int(card["id"])
|
|
2809
|
+
except (TypeError, ValueError):
|
|
2810
|
+
continue # 脏 id:理论项,跳过
|
|
2811
|
+
try:
|
|
2812
|
+
if (_card_opt(card, "worktree") or "").strip():
|
|
2813
|
+
continue # worktree 卡不入队(§47)
|
|
2814
|
+
if waitq.get_active(waitq.KIND_CARD, cid) is not None:
|
|
2815
|
+
continue # 行在场:正常排队,不动
|
|
2816
|
+
if waitq.get_active(waitq.KIND_ANSWER, cid) is not None:
|
|
2817
|
+
continue # 已答待送达:单元是答案行
|
|
2818
|
+
if _has_active_run(cid):
|
|
2819
|
+
continue # 会话在跑:行归收尾路径
|
|
2820
|
+
sid = (card["session_id"] or "").strip()
|
|
2821
|
+
if sid:
|
|
2822
|
+
try:
|
|
2823
|
+
st = dshevents.get(sid) or {}
|
|
2824
|
+
except Exception: # noqa: BLE001 — 读口异常按未知
|
|
2825
|
+
st = {}
|
|
2826
|
+
if st.get("status") == "running":
|
|
2827
|
+
continue # 实况在跑:补行会重复投递
|
|
2828
|
+
# 占位在、行不在:`enqueue_card` 单事务红线(行+占位原子)声称此形态
|
|
2829
|
+
# 「从结构上消失」,出现即异常——先落一行异常报告,再补建
|
|
2830
|
+
print(f"[board] 排队占位缺等待项行(占位在、行不在):c:{cid}"
|
|
2831
|
+
f"(项目 {card['project_id']})", flush=True)
|
|
2832
|
+
if inst is None:
|
|
2833
|
+
continue # runner 缺位:只报告
|
|
2834
|
+
inst.submit_card(cid)
|
|
2835
|
+
healed += 1
|
|
2836
|
+
except Exception as e: # noqa: BLE001
|
|
2837
|
+
print(f"[board] 排队占位补建失败:c:{cid}: {e}", flush=True)
|
|
2838
|
+
return healed
|
|
2839
|
+
|
|
2840
|
+
|
|
2472
2841
|
def _watch_once():
|
|
2473
2842
|
"""一轮巡视:结束会话收尾(_watch_runs_once)+ 定时到点开工(被门禁拦则
|
|
2474
|
-
清定时记错误;起会话失败则把卡置回 todo,避免僵在 doing
|
|
2843
|
+
清定时记错误;起会话失败则把卡置回 todo,避免僵在 doing 无进程)+
|
|
2844
|
+
排队占位缺行自愈(`_heal_queue_placeholders`,实障卡 936)。"""
|
|
2475
2845
|
_watch_runs_once()
|
|
2846
|
+
# 排队占位缺行自愈(30s 节拍):占位在、行不在 ⇒ 补位器永远拾不到,卡片
|
|
2847
|
+
# 僵在「排队中」而项目看着空闲。补建规则与 recover 尾段同源(幂等)。
|
|
2848
|
+
try:
|
|
2849
|
+
_heal_queue_placeholders()
|
|
2850
|
+
except Exception: # noqa: BLE001
|
|
2851
|
+
pass # 巡视不因自愈失败中断(下一拍再来)
|
|
2476
2852
|
# 定时扫描:todo 列到点卡片经统一入口进 doing(整库扫描,量小;一律入队
|
|
2477
2853
|
# (v2b T2 起 parallel 直起废除),门禁语义与手动开始一致)
|
|
2478
2854
|
now_ms = int(time.time() * 1000)
|
|
@@ -2541,9 +2917,20 @@ def _recover_web_card(proj, card):
|
|
|
2541
2917
|
`_recover_ext_rows` 按项目活跃会话集合重新对账(实况在→ext 行保持 running
|
|
2542
2918
|
入前缀、占位不丢),空闲时由调和器列映射回 review(≤5s,与收养路径同归)。
|
|
2543
2919
|
收养行为(建 _RUNS 条目)会把不可控会话伪装成平台
|
|
2544
|
-
在管单元——ext 行被 `_platform_holds` 抑制。
|
|
2920
|
+
在管单元——ext 行被 `_platform_holds` 抑制。
|
|
2921
|
+
|
|
2922
|
+
就绪闸(2026-10-08 批次 C,bug_report/20261008_1935):本函数在启动序里必然
|
|
2923
|
+
早于 `dshevents.start()`(server.py 里 board.recover 先于 dshevents.start),
|
|
2924
|
+
此刻读任何会话都是「未知」;插件热重载后还会叠加「驱动实例重建、/live 空」
|
|
2925
|
+
——旧口径把「未知」当「空闲」,于是**正在运行的卡**被搬去待审核,且此后
|
|
2926
|
+
调和器读不到该会话、自愈无门(实测卡 915)。现在:有 sid 而中枢不可信
|
|
2927
|
+
(`dshevents.aligned()` 为 False)⇒ **不搬列**(也不建 _RUNS),保留原列等
|
|
2928
|
+
中枢对齐后由调和器按实况归位(`_iw_once`:在场按忙/闲、缺席按「已结束」,
|
|
2929
|
+
列写的时机从「未知时」挪到「已知时」)。"""
|
|
2545
2930
|
family = _web_family(proj)
|
|
2546
2931
|
sid = (card["session_id"] or "").strip()
|
|
2932
|
+
if sid and not dshevents.aligned():
|
|
2933
|
+
return # 未知 ≠ 空闲:不搬列,交调和器在可信后收口
|
|
2547
2934
|
busy = False
|
|
2548
2935
|
if sid:
|
|
2549
2936
|
try:
|
|
@@ -3029,18 +3416,37 @@ def _archive_unarchive_edge(card):
|
|
|
3029
3416
|
# 最后活动,近似语义为「刚开始不久」)
|
|
3030
3417
|
SYNC_BUSY_MTIME_S = 120
|
|
3031
3418
|
|
|
3032
|
-
# sync 卡标题/描述口径(2026-10-
|
|
3033
|
-
#
|
|
3419
|
+
# sync 卡标题/描述口径(2026-10-10 改):标题 = DSH **当前**会话标题(末枚
|
|
3420
|
+
# `session/title` 事件,与 dsh GUI 侧栏 last-wins 一致——DSH 侧 LLM 自动标题 /
|
|
3421
|
+
# 用户改名后,同步节拍跟着改卡标题)、描述 = 主会话第一次用户提问**全文**。
|
|
3422
|
+
# 旧口径(2026-10-07~10-10:「首问首行进标题、其余行进描述」)已退场,存量卡由
|
|
3423
|
+
# `_sync_card_follow` 在首个同步拍回填(详见 board spec §48)。
|
|
3034
3424
|
SYNC_TITLE_MAX = 200 # 标题上限(沿用建卡既有截断:insert_board_card 调用处 [:200])
|
|
3035
3425
|
SYNC_DESC_MAX = 2000 # 描述上限(防一次长提问把看板负载撑大)
|
|
3036
3426
|
|
|
3427
|
+
# 已跑过「存量子代理卡软删收口」的项目 id(`sync_sessions` 尾巴,2026-10-07)。
|
|
3428
|
+
# 枚举侧过滤后不会再产生子代理卡,故每项目一轮足够;也保证用户从回收站还原后
|
|
3429
|
+
# 不被反复软删(进程重启后重建空集,重跑一轮幂等)。
|
|
3430
|
+
_SYNC_SUBAGENT_SWEPT = set()
|
|
3431
|
+
|
|
3432
|
+
# 已跑过「存量误建卡软删收口」的项目 id(`sync_sessions` 尾巴,2026-10-09):
|
|
3433
|
+
# 历史版本建出的两类卡——**平台自己持有会话的卡**(B 类:任务 / 飞书会话被当成
|
|
3434
|
+
# 外部会话重复投影)与**空会话占位卡**(A 类:会话一个字都还没有就投影出
|
|
3435
|
+
# `session-xxxx`)——新版一律不再产生,存量按同款「每项目一轮 + 软删可还原」
|
|
3436
|
+
# 口径收口(理由与边界见 `sync_sessions` 尾巴注释)。
|
|
3437
|
+
_SYNC_MISBUILT_SWEPT = set()
|
|
3438
|
+
|
|
3037
3439
|
|
|
3038
3440
|
def _split_first_prompt(text):
|
|
3039
|
-
"""首问原文 → (
|
|
3441
|
+
"""首问原文 → (首行, 其余行):仅服务**回落标题**与**旧口径描述识别**。
|
|
3040
3442
|
|
|
3041
|
-
- 首行超 SYNC_TITLE_MAX
|
|
3042
|
-
-
|
|
3443
|
+
- 首行超 SYNC_TITLE_MAX 的溢出部分并入其余行开头(截断不丢内容);
|
|
3444
|
+
- 其余行超 SYNC_DESC_MAX 截断并补省略号;
|
|
3043
3445
|
- 原文为空 → ('', '')(调用方自行兜底标题)。
|
|
3446
|
+
|
|
3447
|
+
新口径下卡标题取 DSH 当前标题、描述取首问全文,本函数不再直接产生卡面字段;
|
|
3448
|
+
保留是因为两处仍要「旧口径形态」的字符串:无标题事件时以首问首行回落成标题
|
|
3449
|
+
(`_sync_card_fields`),以及判定卡描述是否仍是旧口径自动写入(`_sync_card_follow`)。
|
|
3044
3450
|
"""
|
|
3045
3451
|
text = (text or "").strip()
|
|
3046
3452
|
if not text:
|
|
@@ -3057,42 +3463,146 @@ def _split_first_prompt(text):
|
|
|
3057
3463
|
return first, rest
|
|
3058
3464
|
|
|
3059
3465
|
|
|
3466
|
+
def _full_first_prompt(text):
|
|
3467
|
+
"""首问原文 → 卡描述:首问**全文**(去首尾空白;超 SYNC_DESC_MAX 截断补省略号)。
|
|
3468
|
+
|
|
3469
|
+
与 `_split_first_prompt` 的「其余行」不同——不再丢掉首行(标题已由 DSH 会话
|
|
3470
|
+
标题承担,描述要的是完整提问)。
|
|
3471
|
+
"""
|
|
3472
|
+
raw = (text or "").strip()
|
|
3473
|
+
if not raw:
|
|
3474
|
+
return ""
|
|
3475
|
+
return raw[:SYNC_DESC_MAX] + "…" if len(raw) > SYNC_DESC_MAX else raw
|
|
3476
|
+
|
|
3477
|
+
|
|
3478
|
+
def _is_empty_session(item):
|
|
3479
|
+
"""空会话判定(A 类闸,2026-10-09):会话已存在但**还没有任何内容**。
|
|
3480
|
+
|
|
3481
|
+
「有内容」= 有首问(`first_prompt`)或有会话标题事件(`title`)——两者都空
|
|
3482
|
+
的会话就是用户刚在 dsh 侧建出来、一句话还没说的空壳。历史版本会给它建一张
|
|
3483
|
+
`session-xxxx` 占位卡(等首问落盘再补齐标题),对用户表现为「看板上凭空
|
|
3484
|
+
冒出一张没标题的卡」;新口径改为**不投影**,等首问/标题落盘后的下一个
|
|
3485
|
+
30s 节拍再建卡(届时标题口径直接正确,不再需要兜底与补齐两步)。
|
|
3486
|
+
|
|
3487
|
+
item 缺失(会话已不在本次枚举里,如存储被删)返回 False——那种情况交既有
|
|
3488
|
+
「存储被删→done」规则处理,本闸不表态。
|
|
3489
|
+
"""
|
|
3490
|
+
if not item:
|
|
3491
|
+
return False
|
|
3492
|
+
return not str(item.get("first_prompt") or "").strip() \
|
|
3493
|
+
and not str(item.get("title") or "").strip()
|
|
3494
|
+
|
|
3495
|
+
|
|
3496
|
+
def _is_placeholder_card(card, sids):
|
|
3497
|
+
"""卡面是否仍是「自动写入的占位形态」——存量收口的**前置硬闸**(2026-10-09 二版)。
|
|
3498
|
+
|
|
3499
|
+
占位形态 = 标题为空,或等于主会话 sid 短码(`sid[:12]`,即 `_sync_card_fields`
|
|
3500
|
+
的兜底名、`_sync_card_follow` 认的自动写入形态之一)。建卡时首问未落盘才会
|
|
3501
|
+
长这样;一旦有首问/任务提示词写进来,标题就成了真实内容。
|
|
3502
|
+
|
|
3503
|
+
为什么要有这道闸:存量收口的另两条判据(会话枚举是否为空 / sid 是否在
|
|
3504
|
+
tasks·飞书登记里)都依赖**易变的一次性输入**——首版上线真机首跑就因这种输入
|
|
3505
|
+
异常误删 13 张有真实标题的卡(918、697、709 等,事后复算判据全不成立)。
|
|
3506
|
+
卡面标题是卡自身的证据,不随会话解析成败摇摆;**真实标题的卡一律不自动收口**
|
|
3507
|
+
(宁漏不误删,漏下的交用户自己删)。
|
|
3508
|
+
"""
|
|
3509
|
+
title = (card["title"] or "").strip()
|
|
3510
|
+
if not title:
|
|
3511
|
+
return True
|
|
3512
|
+
# 标题 == 某个绑定会话的 sid 短码(`sids` 是集合,故用 any 而非取首元素)
|
|
3513
|
+
return any(title == str(s or "").strip()[:12] for s in sids if s)
|
|
3514
|
+
|
|
3515
|
+
|
|
3516
|
+
def _platform_owned_sids(project_id):
|
|
3517
|
+
"""本项目「平台自己持有会话位」的 sid 集合(B 类闸,2026-10-09)。
|
|
3518
|
+
|
|
3519
|
+
sync 卡的定位是**外部直跑会话**(用户在 dsh GUI 里直接跑、平台只是把它
|
|
3520
|
+
投影到看板)。平台自己建/持有的会话在平台侧已有入口,再投影一张卡就是
|
|
3521
|
+
重复,故建卡前剔除。两个来源:
|
|
3522
|
+
|
|
3523
|
+
- `tasks.session_id`:任务主会话(任务详情 / 日志里已有入口);
|
|
3524
|
+
- `feishu_bindings.cur_sid`:飞书通用对话当前绑定的会话(飞书侧有入口)。
|
|
3525
|
+
|
|
3526
|
+
看板卡自己的会话不用列——`sync_sessions` 的 bound 映射(卡片 sessions 并集)
|
|
3527
|
+
已经覆盖。站点会话窗发过消息的会话(`chat_msgs`)**不纳入**:那些会话常常
|
|
3528
|
+
正是 dsh GUI 建出来的外部会话(用户从会话窗投一句话而已),纳入会让它们
|
|
3529
|
+
永远建不出卡(含「建卡 30s 窗口内先投了消息」的竞态)。
|
|
3530
|
+
|
|
3531
|
+
一次查库、单轮复用;表缺失/结构演进按空集(归因失败绝不影响建卡)。
|
|
3532
|
+
"""
|
|
3533
|
+
sids = set()
|
|
3534
|
+
try:
|
|
3535
|
+
with db.connect() as conn:
|
|
3536
|
+
for sql, args in (
|
|
3537
|
+
("SELECT session_id AS sid FROM tasks"
|
|
3538
|
+
" WHERE project_id=? AND session_id<>''", (project_id,)),
|
|
3539
|
+
("SELECT cur_sid AS sid FROM feishu_bindings"
|
|
3540
|
+
" WHERE cur_project_id=? AND cur_sid<>''", (project_id,)),
|
|
3541
|
+
):
|
|
3542
|
+
try:
|
|
3543
|
+
sids.update(str(r["sid"]) for r in conn.execute(sql, args))
|
|
3544
|
+
except Exception:
|
|
3545
|
+
continue # 单表不可用只丢该项,不影响另一项与建卡
|
|
3546
|
+
except Exception:
|
|
3547
|
+
return set()
|
|
3548
|
+
return {s for s in sids if s}
|
|
3549
|
+
|
|
3550
|
+
|
|
3060
3551
|
def _sync_card_fields(item, sid):
|
|
3061
|
-
"""sync
|
|
3552
|
+
"""sync 卡平台自动写入形态的 (标题, 描述) 目标值。
|
|
3553
|
+
|
|
3554
|
+
标题 = **DSH 当前会话标题**(`item["title"]` = 末枚 `session/title` 事件,与
|
|
3555
|
+
dsh GUI 侧栏一致;无标题事件时回落首问首行、再回落 sid 短码);描述 = 首问
|
|
3556
|
+
**全文**。建卡与 `_sync_card_follow` 补齐共用同一口径(幂等)。
|
|
3062
3557
|
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
`_sync_card_follow`
|
|
3066
|
-
见 board spec「sync 会话归类」)。
|
|
3558
|
+
本函数只被**已通过空会话闸**的调用方调用——首问/标题两者皆空的会话根本不建卡
|
|
3559
|
+
(见 `_is_empty_session`);建卡后 DSH 侧标题自动更新(LLM 标题、用户改名)与
|
|
3560
|
+
首问落盘,都由 `_sync_card_follow` 在下一个同步节拍里跟上。
|
|
3067
3561
|
"""
|
|
3068
|
-
title
|
|
3562
|
+
title = (item.get("title") or "").strip()
|
|
3069
3563
|
if not title:
|
|
3070
|
-
title = (
|
|
3071
|
-
|
|
3564
|
+
title = _split_first_prompt(item.get("first_prompt") or "")[0]
|
|
3565
|
+
if not title:
|
|
3566
|
+
title = sid[:12]
|
|
3567
|
+
return title[:SYNC_TITLE_MAX], _full_first_prompt(item.get("first_prompt") or "")
|
|
3568
|
+
|
|
3569
|
+
|
|
3570
|
+
def _auto_title_forms(item, sid):
|
|
3571
|
+
"""平台**可能自动写入过**的卡标题集合(判「卡面标题是否仍属自动写入形态」)。
|
|
3572
|
+
|
|
3573
|
+
含:空、sid 短码、DSH 当前标题、**全部历史标题事件**、旧口径写入的首问首行。
|
|
3574
|
+
历史标题事件一项是为「建卡时只有兜底标题、LLM 标题后到」的窗口准备的——那时
|
|
3575
|
+
平台写进卡面的兜底标题已不是当前标题,只存在于会话的标题事件历史里;用户手工
|
|
3576
|
+
改过的标题不在此集合 ⇒ 整卡不再自动覆盖(尊重用户在卡面的改名)。
|
|
3577
|
+
"""
|
|
3578
|
+
forms = {"", sid[:12]}
|
|
3579
|
+
forms.add(((item.get("title") or ""))[:SYNC_TITLE_MAX])
|
|
3580
|
+
forms.update(str(t or "")[:SYNC_TITLE_MAX] for t in (item.get("titles") or []))
|
|
3581
|
+
forms.add(_split_first_prompt(item.get("first_prompt") or "")[0])
|
|
3582
|
+
return forms
|
|
3072
3583
|
|
|
3073
3584
|
|
|
3074
3585
|
def _sync_card_follow(card, item, sid):
|
|
3075
|
-
"""存量 sync
|
|
3586
|
+
"""存量 sync 卡按「DSH 当前标题 + 首问全文」补齐的字段 dict(无变化返回 {})。
|
|
3076
3587
|
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3588
|
+
只认「主会话」:遍历到的会话不是卡的主会话(多会话并集里的子会话)时不动。
|
|
3589
|
+
只在卡仍由平台自动写入时动(卡标题 ∈ `_auto_title_forms`:空 / sid 短码 / 任一枚
|
|
3590
|
+
会话标题事件 / 旧口径的首问首行)——用户在卡面行内改过标题就整张卡不再自动覆盖
|
|
3591
|
+
(描述也不动)。描述仅在为空、或仍是**旧口径自动写入**(首问除首行)时改成首问
|
|
3592
|
+
全文;用户写过的描述不覆盖。
|
|
3082
3593
|
"""
|
|
3083
3594
|
if (card["session_id"] or sid) != sid:
|
|
3084
3595
|
return {}
|
|
3085
|
-
title, desc = _split_first_prompt(item.get("first_prompt") or "")
|
|
3086
|
-
if not title:
|
|
3087
|
-
return {}
|
|
3088
3596
|
cur_title = (card["title"] or "").strip()
|
|
3089
|
-
|
|
3090
|
-
if cur_title not in auto_titles:
|
|
3597
|
+
if cur_title not in _auto_title_forms(item, sid):
|
|
3091
3598
|
return {}
|
|
3599
|
+
title, desc = _sync_card_fields(item, sid)
|
|
3092
3600
|
out = {}
|
|
3093
3601
|
if cur_title != title:
|
|
3094
3602
|
out["title"] = title
|
|
3095
|
-
|
|
3603
|
+
cur_desc = (card["description"] or "").strip()
|
|
3604
|
+
legacy_desc = _split_first_prompt(item.get("first_prompt") or "")[1]
|
|
3605
|
+
if desc and cur_desc != desc and cur_desc in ("", legacy_desc):
|
|
3096
3606
|
out["description"] = desc
|
|
3097
3607
|
return out
|
|
3098
3608
|
|
|
@@ -3100,11 +3610,20 @@ def _sync_card_follow(card, item, sid):
|
|
|
3100
3610
|
def sync_sessions(project):
|
|
3101
3611
|
"""自动同步项目 agent 会话到看板。返回新建卡 id 列表。
|
|
3102
3612
|
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3613
|
+
**建卡闸(2026-10-09 两处收窄,用户诉求「看板别再莫名冒卡」)**:
|
|
3614
|
+
- A 类 空会话不投影:会话已存在但一句话都还没有(首问/标题皆空)时不建卡,
|
|
3615
|
+
等首问或标题落盘后的下一个节拍再建(见 `_is_empty_session`);
|
|
3616
|
+
- B 类 平台自持会话不重复投影:`tasks.session_id` / `feishu_bindings.cur_sid`
|
|
3617
|
+
的会话平台侧已有入口,不再当成"外部直跑会话"投影成卡(见
|
|
3618
|
+
`_platform_owned_sids`)。
|
|
3619
|
+
|
|
3620
|
+
新建卡(origin='sync'):标题 = DSH 当前会话标题(无标题事件时回落首问首行、
|
|
3621
|
+
再回落 sid 短码)、描述 = 首问全文,绑定主会话,busy→doing / 空闲→review;
|
|
3622
|
+
存量 sync 卡:按 DSH 当前标题 + 首问全文补齐(DSH 侧标题自动更新/改名后跟着改;
|
|
3623
|
+
用户改过名的不动)+ dsh 族列映射移交调和器(事件驱动),此处仅归档→done(人工拖到
|
|
3624
|
+
todo/blocked/done 后不再自动搬);会话存储被删的 sync 卡自动进 done;绑子代理
|
|
3625
|
+
会话的存量 sync 卡收口进回收站(每项目一轮);上述两类误建卡的存量也按同款
|
|
3626
|
+
口径收口(每项目一轮,见函数尾注释)。
|
|
3108
3627
|
sync 卡不占 runner 项目占用(外部会话平台控制不了,防堵死统一队列)。
|
|
3109
3628
|
"""
|
|
3110
3629
|
if not settings_of(project["id"]).get("sync_sessions", True):
|
|
@@ -3124,6 +3643,8 @@ def sync_sessions(project):
|
|
|
3124
3643
|
bound[s] = c
|
|
3125
3644
|
if c["session_id"]:
|
|
3126
3645
|
bound[c["session_id"]] = c
|
|
3646
|
+
# B 类闸的 sid 集合(每轮一次查库,见 `_platform_owned_sids`)
|
|
3647
|
+
owned = _platform_owned_sids(project["id"])
|
|
3127
3648
|
created = []
|
|
3128
3649
|
for it in items:
|
|
3129
3650
|
sid = it["sid"]
|
|
@@ -3132,9 +3653,16 @@ def sync_sessions(project):
|
|
|
3132
3653
|
# busy 按需判定(2026-09-27):只有新卡分支消费 busy;存量 sync 卡只用
|
|
3133
3654
|
# archived(列映射归调和器),不再为每个会话无条件探测。
|
|
3134
3655
|
busy = False
|
|
3135
|
-
if c is None:
|
|
3656
|
+
if c is None and not _is_empty_session(it) and sid not in owned:
|
|
3136
3657
|
busy = _sync_session_busy(project, raw_family, sid, it["mtime"])
|
|
3137
3658
|
if c is None:
|
|
3659
|
+
# A 类闸:空会话不投影(建卡竞态由"等首问"取代旧的"先落 sid 短码
|
|
3660
|
+
# 兜底再补齐"——用户在板上看到的将只会有内容的会话)
|
|
3661
|
+
if _is_empty_session(it):
|
|
3662
|
+
continue
|
|
3663
|
+
# B 类闸:平台自持会话(任务/飞书)已有入口,不重复投影成卡
|
|
3664
|
+
if sid in owned:
|
|
3665
|
+
continue
|
|
3138
3666
|
title, desc = _sync_card_fields(it, sid)
|
|
3139
3667
|
cid = db.insert_board_card(project["id"], title, desc)
|
|
3140
3668
|
# 归档会话:不参与 busy 判定,直接落「已完成」
|
|
@@ -3156,13 +3684,68 @@ def sync_sessions(project):
|
|
|
3156
3684
|
want = "done" if archived else None
|
|
3157
3685
|
if want and c["column_key"] != want:
|
|
3158
3686
|
db.update_board_card(c["id"], column_key=want)
|
|
3687
|
+
# —— 存量子代理卡收口(2026-10-07 实障修复)——
|
|
3688
|
+
# 新口径下 `sessparse.list_sessions` 已过滤子代理会话(不再建新卡),但历史版本按
|
|
3689
|
+
# 子代理会话建出的卡还留在板面上(实测全机 72 张、70 张永留「待审核」——子代理
|
|
3690
|
+
# 不会被归档、存储也不会被删,归档同步与下面「存储被删→done」两条规则都够不着)。
|
|
3691
|
+
# 这里**软删进回收站**(可还原;不删会话文件、不动会话与依赖)。两条边界:
|
|
3692
|
+
# ① 只认 origin='sync' 自动卡——用户自建卡即便绑了子代理会话也是用户的东西,不碰;
|
|
3693
|
+
# ② 每项目只跑一轮——A 之后不会再产生这类卡,一轮足够;用户从回收站**还原**后
|
|
3694
|
+
# 同一进程内不会被反复软删(尊重显式操作),进程重启后重跑一轮(幂等)。
|
|
3695
|
+
if project["id"] not in _SYNC_SUBAGENT_SWEPT:
|
|
3696
|
+
dropped = 0
|
|
3697
|
+
for c in cards:
|
|
3698
|
+
sids = set(_card_sids(c))
|
|
3699
|
+
if c["origin"] != "sync" or not sids or _has_active_run(c["id"]):
|
|
3700
|
+
continue
|
|
3701
|
+
if any(sessparse.is_subagent(family, s) for s in sids):
|
|
3702
|
+
db.trash_board_card(c["id"])
|
|
3703
|
+
dropped += 1
|
|
3704
|
+
_SYNC_SUBAGENT_SWEPT.add(project["id"])
|
|
3705
|
+
if dropped:
|
|
3706
|
+
print(f"[board-sync] 子代理会话卡收口: 软删 {dropped} 张"
|
|
3707
|
+
f"(项目 {project['id']},见 spec/queue/排队与占用.md)")
|
|
3708
|
+
# —— 存量误建卡收口(2026-10-09;二版:「卡面占位形态」为前置硬闸)——
|
|
3709
|
+
# A/B 两闸只挡新建,历史版本已经建出的两类卡还留在板面上(实测:任务会话卡
|
|
3710
|
+
# 12 张、空会话占位卡 10 张,跨 4 个项目)。这里**软删进回收站**(可还原;
|
|
3711
|
+
# 不删会话文件、不动会话与依赖、不碰任务的任何记录)。判据与新建闸同源:
|
|
3712
|
+
# ① B 类:卡绑的 sid 是平台自持会话(tasks/飞书)——重复投影,收回;
|
|
3713
|
+
# ② A 类:卡绑的会话**全部**是空会话(首问/标题皆空)——没有任何内容可看;
|
|
3714
|
+
# 任一 sid 不在本轮枚举里(存储被删等)即不判空,交「存储被删→done」规则。
|
|
3715
|
+
# **首版实障(2026-10-09 当天)**:上述两条都依赖「易变的一次性输入」(会话
|
|
3716
|
+
# 枚举的解析结果 / tasks·飞书登记),真机首跑即在三个项目误删 13 张**有真实
|
|
3717
|
+
# 标题**的卡(918「现在TS作为DSH的插件…」、697/709 等;事后在库副本上复算,
|
|
3718
|
+
# 它们连判据中间值都不成立 ⇒ 属判据输入的一次性异常)。故补**前置硬闸**
|
|
3719
|
+
# `_is_placeholder_card`:只收口「卡面标题仍是自动写入占位形态(空 / 主会话
|
|
3720
|
+
# sid 短码)」的卡——卡面标题是卡自身的证据,不随会话解析成败摇摆;真实标题
|
|
3721
|
+
# (用户提问首行 / 任务提示词首行)一律不自动收口。代价=标题已补齐的误建卡
|
|
3722
|
+
# (任务会话卡)会漏删,交用户自行处理(**宁漏不误删**)。
|
|
3723
|
+
# 三条边界与子代理卡收口一致:只认 origin='sync' 自动卡(用户自建卡一字节
|
|
3724
|
+
# 不动)、平台在管的运行中卡跳过(`_has_active_run`)、每项目只跑一轮
|
|
3725
|
+
# (用户从回收站还原后同一进程内不再被反复软删;重启后重跑一轮,幂等)。
|
|
3726
|
+
if project["id"] not in _SYNC_MISBUILT_SWEPT:
|
|
3727
|
+
by_sid = {it["sid"]: it for it in items}
|
|
3728
|
+
dropped = 0
|
|
3729
|
+
for c in cards:
|
|
3730
|
+
if c["origin"] != "sync" or _has_active_run(c["id"]):
|
|
3731
|
+
continue
|
|
3732
|
+
sids = set(_card_sids(c))
|
|
3733
|
+
if not sids or not _is_placeholder_card(c, sids):
|
|
3734
|
+
continue # 卡面已有真实标题 ⇒ 非自动占位卡,绝不自动收口
|
|
3735
|
+
if any(s in owned for s in sids) or \
|
|
3736
|
+
all(_is_empty_session(by_sid.get(s)) for s in sids):
|
|
3737
|
+
db.trash_board_card(c["id"])
|
|
3738
|
+
dropped += 1
|
|
3739
|
+
_SYNC_MISBUILT_SWEPT.add(project["id"])
|
|
3740
|
+
if dropped:
|
|
3741
|
+
print(f"[board-sync] 误建卡收口: 软删 {dropped} 张"
|
|
3742
|
+
f"(项目 {project['id']}:平台自持会话卡 / 空会话占位卡,"
|
|
3743
|
+
f"见 board spec §48)")
|
|
3159
3744
|
# 存储被删 → done(list 只回 50 条,不能用「不在列表」判定删除,必须逐卡查存在性)
|
|
3160
3745
|
for c in cards:
|
|
3161
3746
|
if c["origin"] != "sync" or c["column_key"] == "done" or _has_active_run(c["id"]):
|
|
3162
3747
|
continue
|
|
3163
|
-
sids = set(
|
|
3164
|
-
if c["session_id"]:
|
|
3165
|
-
sids.add(c["session_id"])
|
|
3748
|
+
sids = set(_card_sids(c))
|
|
3166
3749
|
if sids and not any(sessparse.session_exists(family, s) for s in sids):
|
|
3167
3750
|
db.update_board_card(c["id"], column_key="done")
|
|
3168
3751
|
return created
|
|
@@ -3188,6 +3771,42 @@ def _has_active_run(card_id):
|
|
|
3188
3771
|
return card_id in _RUNS
|
|
3189
3772
|
|
|
3190
3773
|
|
|
3774
|
+
def _wt_in_flight(card_id):
|
|
3775
|
+
"""worktree 直起窗口在场判定(`_WT_STARTING`,见 `_enter_doing_worktree`)。
|
|
3776
|
+
|
|
3777
|
+
worktree 卡不落 c: 行(「不进统一队列」语义),起跑窗口只能靠这个进程内
|
|
3778
|
+
登记集表征:`_wt_start_begin` 在入口登记、`finally` 在 `start_card`(内部
|
|
3779
|
+
登记 `_RUNS`)之后才 `_wt_start_end` 释放 ⇒ 在场区间恰好覆盖起跑窗口。"""
|
|
3780
|
+
with _WT_STARTING_LOCK:
|
|
3781
|
+
return card_id in _WT_STARTING
|
|
3782
|
+
|
|
3783
|
+
|
|
3784
|
+
def _starting_window(card_id, starting_ids=None):
|
|
3785
|
+
"""卡片是否处于「起跑窗口」:占位保护已解除,但会话尚未证实起跑。
|
|
3786
|
+
|
|
3787
|
+
窗口的成因(2026-10-07 卡 902/903 实障):`dequeue_start` / `_enter_doing_worktree`
|
|
3788
|
+
先落 `doing` + 清 `block_kind='queue'` 占位,**之后**才起会话;而 `_RUNS`
|
|
3789
|
+
在 `_start_web` 末尾(create_session → 会话默认值 → turn 基线 → `dsh_send`
|
|
3790
|
+
全跑完)才登记。这段 1~2 秒里 dsh 侧读到的 busy=False 是「turn 还没开始」,
|
|
3791
|
+
不是「会话已结束」——调和器若据此按忙/闲搬列,正在起跑的卡会被搬去
|
|
3792
|
+
「待审核」(用户约定:运行中的卡都在「正在开发」列),且随后 `has_run`
|
|
3793
|
+
早退不再自愈,卡片整轮滞留错列。
|
|
3794
|
+
|
|
3795
|
+
三条起跑路径各自的窗口表征:
|
|
3796
|
+
- 统一队列路径(含 force 落表行):c: 行 `state=starting`(拾取 →
|
|
3797
|
+
`card_started` 置 running);
|
|
3798
|
+
- worktree 直起路径(无 c: 行):`_WT_STARTING` 在场(见 `_wt_in_flight`);
|
|
3799
|
+
- 已登记在管条目(`_RUNS`)=已证实起跑,**一律不算窗口**——正常忙/闲
|
|
3800
|
+
映射(含「送达恢复」那种 running 行而无在管条目的形态:会话结束要能
|
|
3801
|
+
落回待审核,这条口径不能被本判据吞掉)。
|
|
3802
|
+
`starting_ids` 为调用方的批量读口预取值(`waitq.starting_card_ids()`,
|
|
3803
|
+
调和器一轮一次),缺省自行单查(`_iw_apply` 写前复核用)。"""
|
|
3804
|
+
if _has_active_run(card_id):
|
|
3805
|
+
return False
|
|
3806
|
+
ids = waitq.starting_card_ids() if starting_ids is None else starting_ids
|
|
3807
|
+
return card_id in ids or _wt_in_flight(card_id)
|
|
3808
|
+
|
|
3809
|
+
|
|
3191
3810
|
def run_pid(card_id):
|
|
3192
3811
|
"""在管卡会话子进程 pid(无运行条目/web 族无 proc → None;R11④ 进程存活
|
|
3193
3812
|
裁活的写入侧数据源,落行 evidence.pid)。"""
|
|
@@ -3198,6 +3817,27 @@ def run_pid(card_id):
|
|
|
3198
3817
|
return None
|
|
3199
3818
|
|
|
3200
3819
|
|
|
3820
|
+
def _orphan_hold(card_id):
|
|
3821
|
+
"""本卡是否持有「无平台在管条目背书」的活跃 `c:` 行(**孤儿运行行**,缺陷 B 判据)。
|
|
3822
|
+
|
|
3823
|
+
生产上唯一的产生面=作答/立即送达**送达成功**时 `runner.card_started` 补回的
|
|
3824
|
+
「送达恢复」/「立即送达」行:那行由平台建,而目标会话是外部/接管会话(平台的
|
|
3825
|
+
`_RUNS` 里没有它)⇒ 行没有「在管条目」背书,收口只能靠调和器按实况判定
|
|
3826
|
+
(`_starting_window` 的 docstring 早已写明这类形态要能「落回待审核」)。
|
|
3827
|
+
|
|
3828
|
+
为什么必须与 `has_run` 分开判(缺陷 B,T9b 真机 6 轮 5 轮命中):调和状态机把
|
|
3829
|
+
`live_msg`(本卡有排队/执行中的 `m:` 行)算作「在跑」,而**孤儿行本身就在运行
|
|
3830
|
+
前缀里**——serial 项目里它把本卡自己的排队消息永久挡在队外
|
|
3831
|
+
(`runner._prefix_window_blocked`),状态机又因为那条消息「排队中」判本卡「在跑」
|
|
3832
|
+
⇒ 互等:行不收口、消息永远排不到、该卡与项目后续投递全被挡住(周期自检只打
|
|
3833
|
+
`[waitq-selfcheck] 行状态不明(仅告警)`)。故孤儿行只认**实况 busy**。
|
|
3834
|
+
|
|
3835
|
+
池内卡走不到这个形态:同一「送达恢复」动作发生在池内卡上时 `_RUNS` 条目在场
|
|
3836
|
+
(`has_run=True`),状态机在 `has_run` 分支早退、行由巡视线程 `_finish_run` 在
|
|
3837
|
+
会话空闲时收口(见 `_reconcile_action_for` 的不干预规则)。"""
|
|
3838
|
+
return _card_unit_active(card_id) and not _has_active_run(card_id)
|
|
3839
|
+
|
|
3840
|
+
|
|
3201
3841
|
# ---------- 交互等待检测(InteractionWatcher,方案 A) ----------
|
|
3202
3842
|
# 单族化后等待态只有 dsh 一个来源:`dshevents` 注册表的 interaction 字段——
|
|
3203
3843
|
# 提问来自 `user-questions/request` waterfall、审批来自 `approval/asked`
|
|
@@ -3236,7 +3876,9 @@ def _iw_interaction(family, proj, sid, busy_hint=None):
|
|
|
3236
3876
|
交互卡片(选项+描述+自定义输入 / 批准-拒绝)。
|
|
3237
3877
|
options 逐项 = `_dsh_option` 归一后的 `{id, label, description}`(2026-10-04
|
|
3238
3878
|
起带选项描述,修 #791);qid 取自插件的提问标识(认领中的提问非空 ⇒
|
|
3239
|
-
answerable=True ⇒ 会话窗渲染单选/多选框 + 「提交」)。
|
|
3879
|
+
answerable=True ⇒ 会话窗渲染单选/多选框 + 「提交」)。2026-10-06 修 #837 起
|
|
3880
|
+
dsh Web GUI 同时弹同一个提问框(插件的原生作答通道):两侧都能答、先答者胜,
|
|
3881
|
+
GUI 先答时平台迟到作答拿 accepted=false(上层按 40405 收口)。
|
|
3240
3882
|
异常/无 sid 返回 None(该轮跳过,不误移不报错)。
|
|
3241
3883
|
单族化后只有 dsh_plugin:读 `dshevents` 注册表(busy 来自 agent/status
|
|
3242
3884
|
事件,挂起来自 `user-questions/request` waterfall 与 `approval/asked`
|
|
@@ -3257,6 +3899,12 @@ def _iw_interaction(family, proj, sid, busy_hint=None):
|
|
|
3257
3899
|
return None
|
|
3258
3900
|
busy = st.get("status") == "running"
|
|
3259
3901
|
mark = st.get("interaction") or {}
|
|
3902
|
+
# T9 真机取证待办(2026-10-10):外部会话(owned:false)挂起等作答期间,宿主
|
|
3903
|
+
# `status` 是否为 `running` **尚未取证**——本批先按既有口径交付,判据保持不动。
|
|
3904
|
+
# 若实测为 `idle`,mark 非空的挂起会被本行判成 pending=False(answerable 随之
|
|
3905
|
+
# 丢失、站点上不可答),届时由控制者另派小修复把判据拆开:`mark` 为空 ⇒
|
|
3906
|
+
# pending False;`mark` 非空且 `call_id` 非空 ⇒ pending True(`busy` 照实回传,
|
|
3907
|
+
# 防陈旧 mark 复活卡片),并补一条对应的反例用例。
|
|
3260
3908
|
if not busy or not mark:
|
|
3261
3909
|
return {"pending": False, "busy": busy, "qid": None,
|
|
3262
3910
|
"question": None, "options": None,
|
|
@@ -3308,6 +3956,286 @@ def _iw_interaction(family, proj, sid, busy_hint=None):
|
|
|
3308
3956
|
return None # 读注册表异常:本轮跳过
|
|
3309
3957
|
|
|
3310
3958
|
|
|
3959
|
+
# ---------- 外部会话看管声明(C 批 T5,2026-10-10) ----------
|
|
3960
|
+
#
|
|
3961
|
+
# 背景:用户在 dsh GUI 里直跑/接管的会话(`/live` 里 `owned:false`)不在驱动池,
|
|
3962
|
+
# 投递必然 404。驱动侧因此加了 `POST /watch` 显式看管闸(见 dshdriver.watch_session);
|
|
3963
|
+
# 平台侧把「声明范围」限定为**平台确实要投递的会话**:有卡的会话(宿主里与 Touchstone
|
|
3964
|
+
# 无关的会话一律不碰)**加上**宿主上报为外部态(owned:false)的任务侧会话——后者由
|
|
3965
|
+
# 消息前置闸与作答送达前的 `_watch_before_delivery` 按需补声明(T7 评审 2026-10-10)。
|
|
3966
|
+
#
|
|
3967
|
+
# 声明点三处,互为兜底:
|
|
3968
|
+
# ① `_iw_once` 逐卡循环内(调和器扫描,覆盖常态);
|
|
3969
|
+
# ② `chat._external_preflight` 投递前置闸内(覆盖「卡刚建、扫描节拍还没跑到」的窗口);
|
|
3970
|
+
# ③ `_watch_before_delivery` 作答/审批送达前(覆盖无卡的**任务侧**外部会话)。
|
|
3971
|
+
# 撤销:`_watch_prune`(卡删除/换绑会话后回收,防集合无界增长)。
|
|
3972
|
+
# 失效:`_watch_generation_sync`(中枢重连边沿清空记账后重放,设计 §5.1)。
|
|
3973
|
+
# 语义:`_WATCHED` 是**进程内缓存**(「已声明过」的记账,避免每轮重复 POST);
|
|
3974
|
+
# 驱动侧 `watched` 才是权威。故集合清了只会多打一次幂等的 `/watch`,不会漏声明。
|
|
3975
|
+
_WATCHED: set[str] = set()
|
|
3976
|
+
_watch_lock = threading.Lock()
|
|
3977
|
+
# 看管代次:上次见到的中枢连接代次(`dshevents.stats()["reconnects"]`)。
|
|
3978
|
+
# None = 本进程尚未对过账(首次读只记基准,不清空——启动时集合本就为空)。
|
|
3979
|
+
_WATCH_GEN = None
|
|
3980
|
+
# 扫卡节流窗口(秒,C 批终审 I3,2026-10-10):`_iw_once` 由**每个状态帧**唤醒
|
|
3981
|
+
# (仅 IW_MIN_INTERVAL 限速),节拍尾部每轮都调 `_watch_prune()`;而扫卡 =
|
|
3982
|
+
# `list_projects_all()` 一次连接 + **每项目一次** `list_board_cards()`,只要有任何
|
|
3983
|
+
# 一个外部会话被看管,全量扫卡就跟着状态帧跑。节流到与调和器兜底拍
|
|
3984
|
+
# (`IW_SAFETY_SECONDS`=60s,见文件尾)同量级:被节流的只是「扫卡 + 撤销」,
|
|
3985
|
+
# **只影响回收的及时性**(多留一会儿看管是安全方向);代次检查是正确性路径,
|
|
3986
|
+
# 照旧每次进入都做(见 `_watch_prune`)。
|
|
3987
|
+
WATCH_PRUNE_INTERVAL = 60.0
|
|
3988
|
+
# 上次真正扫卡的时刻(`time.monotonic()`;0.0 = 本拍该扫)。就地读写都在 `_watch_lock` 内。
|
|
3989
|
+
_WATCH_PRUNE_AT = 0.0
|
|
3990
|
+
|
|
3991
|
+
|
|
3992
|
+
def _watch_generation_sync():
|
|
3993
|
+
"""中枢重连边沿 ⇒ 清空 `_WATCHED`;返回是否发生了清空(设计 §5.1 的失效通道)。
|
|
3994
|
+
|
|
3995
|
+
依据:`dshevents.stats()["reconnects"]`——中枢(`dshevents.EventHub`)每次
|
|
3996
|
+
「未连接 → 已连接」边沿加一,读口是**纯本地** `with self._cond`(零 HTTP 请求),
|
|
3997
|
+
故每次声明前读一次也不打驱动。为什么必须清:驱动侧 `watched` 是插件进程内的
|
|
3998
|
+
易失状态(`dispose()` / 插件重新 apply、宿主重载即清空),而平台记账仍写着
|
|
3999
|
+
「已声明过」——不清就会一直命中陈旧缓存、不再 POST `/watch`,投递撞驱动 404 落
|
|
4000
|
+
error,且卡还在时 `_watch_prune` 不撤 ⇒ 不自愈(bug_report/20261008_1935 记录的
|
|
4001
|
+
宿主侧重载场景)。清空只让下一次 `_ensure_watch` 重新声明一次(幂等 POST),
|
|
4002
|
+
不会漏声明。
|
|
4003
|
+
"""
|
|
4004
|
+
global _WATCH_GEN
|
|
4005
|
+
try:
|
|
4006
|
+
gen = dshevents.stats().get("reconnects")
|
|
4007
|
+
except Exception: # noqa: BLE001 — 读口异常不阻断,下轮再来
|
|
4008
|
+
return False
|
|
4009
|
+
if gen is None:
|
|
4010
|
+
return False
|
|
4011
|
+
with _watch_lock:
|
|
4012
|
+
prev, _WATCH_GEN = _WATCH_GEN, gen
|
|
4013
|
+
if prev is None or prev == gen:
|
|
4014
|
+
return False
|
|
4015
|
+
_WATCHED.clear()
|
|
4016
|
+
return True
|
|
4017
|
+
|
|
4018
|
+
|
|
4019
|
+
def _ensure_watch(sid, force=False):
|
|
4020
|
+
"""声明「平台看管该会话」(幂等,成功才入缓存);失败留痕返回 False,不重试。
|
|
4021
|
+
|
|
4022
|
+
先过**代次对账**(中枢重连 ⇒ 陈旧记账作废,见 `_watch_generation_sync`),
|
|
4023
|
+
再查缓存:已在 `_WATCHED` 内直接 True(零请求);否则
|
|
4024
|
+
`dshdriver.watch_session(sid)`,驱动确认 `watched=true` 才记进缓存。任何失败
|
|
4025
|
+
(旧插件无 `/watch` ⇒ 404、驱动不可达、令牌错)都只打印一行诊断并返回 False
|
|
4026
|
+
——**不重试**:调用方(`chat._external_preflight`)据此拒投并给明确文案,比反复
|
|
4027
|
+
打驱动更有用;下一轮调和器扫描会自然重试一次(缓存里没有它)。
|
|
4028
|
+
|
|
4029
|
+
`force=True` 跳过缓存命中、**强制重发一次声明**(成功后照常入缓存)。唯一调用方是
|
|
4030
|
+
`rename_card_session` 的 404 重试:那里的 404 恰恰说明「驱动侧认为没看管」,而
|
|
4031
|
+
`_WATCHED` 是**平台侧进程内记账**、可能与驱动侧脱节(实测 2026-10-10:带外
|
|
4032
|
+
`POST /watch {on:false}`、以及驱动在 `session/disposed` 时自清看管表,
|
|
4033
|
+
都会让缓存变陈旧)——缓存命中就不能让它挡住这次重试。
|
|
4034
|
+
"""
|
|
4035
|
+
sid = str(sid or "")
|
|
4036
|
+
if not sid:
|
|
4037
|
+
return False
|
|
4038
|
+
_watch_generation_sync()
|
|
4039
|
+
if not force:
|
|
4040
|
+
with _watch_lock:
|
|
4041
|
+
if sid in _WATCHED:
|
|
4042
|
+
return True
|
|
4043
|
+
try:
|
|
4044
|
+
ok = bool(dshdriver.watch_session(sid))
|
|
4045
|
+
except Exception as exc: # noqa: BLE001 — 失败降级为拒投
|
|
4046
|
+
hint = "(插件过旧:无 /watch 端点,请重装插件并重启 dsh web)" \
|
|
4047
|
+
if getattr(exc, "code", None) == dshdriver.WATCH_UNSUPPORTED else ""
|
|
4048
|
+
print(f"[board] 看管声明失败 sid={sid}: {exc}{hint}", flush=True)
|
|
4049
|
+
return False
|
|
4050
|
+
if not ok:
|
|
4051
|
+
print(f"[board] 看管声明失败 sid={sid}: 驱动未确认看管(watched=false)",
|
|
4052
|
+
flush=True)
|
|
4053
|
+
return False
|
|
4054
|
+
with _watch_lock:
|
|
4055
|
+
_WATCHED.add(sid)
|
|
4056
|
+
return True
|
|
4057
|
+
|
|
4058
|
+
|
|
4059
|
+
# 卡面改名的驱动调用超时(秒):best-effort 的同步链路,快失败比让 HTTP worker 挂满
|
|
4060
|
+
# 默认 120s 有用(2026-10-10 需求「TS 卡面改名 → DSH 会话名同步」)。
|
|
4061
|
+
RENAME_TIMEOUT = 10
|
|
4062
|
+
|
|
4063
|
+
|
|
4064
|
+
def rename_card_session(sid, title):
|
|
4065
|
+
"""卡面改名 → 同步改 DSH 会话标题(best-effort)。返回 `(ok, error, accepted)`。
|
|
4066
|
+
|
|
4067
|
+
两跳(2026-10-10 用户需求,B 档):
|
|
4068
|
+
① `dshdriver.rename`:覆盖**池内**会话(平台建/恢复过的:任务会话、平台起过的卡、
|
|
4069
|
+
会话窗发过消息被 resume 的);
|
|
4070
|
+
② 首跳 404(池外)⇒ `_ensure_watch(sid)` 声明看管(幂等、**只声明不接管**)后
|
|
4071
|
+
重试一次:覆盖用户在 dsh GUI 直跑、驱动只观察的会话——驱动侧 `/rename` 的
|
|
4072
|
+
池外回落分支按「看管 + 宿主有活 agent」放行(node 半同批实现)。
|
|
4073
|
+
失败**不抛**:卡面是用户的编辑,必须先生效;调用方(server 端点)把原因回给前端。
|
|
4074
|
+
`accepted` = 驱动回执里宿主**接受**的标题(可能被 dsh 规范化/按字节预算截断),
|
|
4075
|
+
成功时非空——调用方据此回写卡面,保证两侧逐字一致。
|
|
4076
|
+
"""
|
|
4077
|
+
sid = str(sid or "")
|
|
4078
|
+
title = str(title or "").strip()
|
|
4079
|
+
if not sid or not title:
|
|
4080
|
+
return False, "缺少会话或标题,未同步 DSH 会话名", ""
|
|
4081
|
+
last = ""
|
|
4082
|
+
for attempt in (1, 2):
|
|
4083
|
+
try:
|
|
4084
|
+
r = dshdriver.rename(sid, title, timeout=RENAME_TIMEOUT) or {}
|
|
4085
|
+
return True, "", str(r.get("title") or title)
|
|
4086
|
+
except Exception as exc: # noqa: BLE001 — 统一降级为原因串
|
|
4087
|
+
last = str(exc)
|
|
4088
|
+
pool_miss = getattr(exc, "code", None) == 404
|
|
4089
|
+
# 只在「池外」这一跳补一次看管声明(已看管无活 agent 的 404 重试同样失败,
|
|
4090
|
+
# 但代价只是一次请求,换来判定逻辑不依赖文案匹配)。**force=True**:这一跳的
|
|
4091
|
+
# 404 正说明驱动侧认为没看管,平台侧 `_WATCHED` 缓存可能陈旧(带外撤销、
|
|
4092
|
+
# 驱动在 session/disposed 时自清),不能被它挡住重发。
|
|
4093
|
+
if attempt == 1 and pool_miss and _ensure_watch(sid, force=True):
|
|
4094
|
+
print(f"[board] 卡面改名:池外会话补看管声明后重试 sid={sid}",
|
|
4095
|
+
flush=True)
|
|
4096
|
+
continue
|
|
4097
|
+
break
|
|
4098
|
+
return False, last, ""
|
|
4099
|
+
|
|
4100
|
+
|
|
4101
|
+
def _watch_card_refs():
|
|
4102
|
+
"""看管引用集:全部卡片的 `session_id ∪ sessions`。
|
|
4103
|
+
|
|
4104
|
+
含归档项目(归档 ≠ 删卡,归档项目的在跑会话照旧需要投递通道)。坏 JSON 按
|
|
4105
|
+
「无附加会话」防御(与卡片读侧同口径)。
|
|
4106
|
+
"""
|
|
4107
|
+
refs = set()
|
|
4108
|
+
for proj in db.list_projects_all():
|
|
4109
|
+
for card in db.list_board_cards(proj["id"]):
|
|
4110
|
+
sid = str(card["session_id"] or "")
|
|
4111
|
+
if sid:
|
|
4112
|
+
refs.add(sid)
|
|
4113
|
+
try:
|
|
4114
|
+
refs.update(str(s) for s in json.loads(card["sessions"] or "[]") if s)
|
|
4115
|
+
except ValueError:
|
|
4116
|
+
pass # 坏 JSON 按无附加会话(防御)
|
|
4117
|
+
return refs
|
|
4118
|
+
|
|
4119
|
+
|
|
4120
|
+
def _watch_msg_inflight(sid):
|
|
4121
|
+
"""该 sid 是否仍有在途消息(活跃 `m:` 行)——看管撤销判据的第二条。
|
|
4122
|
+
|
|
4123
|
+
读口用 `chat.live_of_sid`(内部即既有 waitq 读口
|
|
4124
|
+
`waitq.msg_rows(sid=…, states=("queued", "running"))`,不另写 SQL)。
|
|
4125
|
+
读不到按「有在途」保守处理:宁可少撤(下轮再试),不可误撤(在途消息送达
|
|
4126
|
+
撞驱动 404,正是本任务要消灭的「放行后静默失败」)。
|
|
4127
|
+
"""
|
|
4128
|
+
try:
|
|
4129
|
+
return bool(chat.live_of_sid(sid))
|
|
4130
|
+
except Exception: # noqa: BLE001 — 读不到按「有在途」保守
|
|
4131
|
+
return True
|
|
4132
|
+
|
|
4133
|
+
|
|
4134
|
+
def _watch_prune():
|
|
4135
|
+
"""撤掉**确无需要**的看管声明(`POST /watch {on:false}`),返回撤销条数。
|
|
4136
|
+
|
|
4137
|
+
判据(两条都成立才撤):
|
|
4138
|
+
① 无卡引用——引用集 = 全部卡片的 `session_id ∪ sessions`,见 `_watch_card_refs`;
|
|
4139
|
+
② 无在途消息——该 sid 已无活跃 `m:` 行,见 `_watch_msg_inflight`。前置闸会为
|
|
4140
|
+
**无卡**外部会话放行(飞书绑定会话、任务会话重载后变外部态等 `card_id/
|
|
4141
|
+
task_id` 皆空的 submit 路径),此时「无卡引用」≠「无人需要看管」:还在
|
|
4142
|
+
`m:` 行里排队的消息一旦被撤看管,送达时 `/prompt` 就撞驱动 404。
|
|
4143
|
+
|
|
4144
|
+
竞态(与前置闸的 TOCTOU):撤销判据在**同一临界区**内复核,不信任扫描快照
|
|
4145
|
+
——① `_WATCHED` 成员资格在锁内重查(快照之后并发的前置闸可能刚重新声明入集合);
|
|
4146
|
+
② 在途消息在锁内重查(快照之后可能刚落 `m:` 行)。撤销成功后**再复核一次**:
|
|
4147
|
+
窗口内若又冒出新声明/新在途消息(`on:false` 可能压掉了并发的 `on:true`),立即
|
|
4148
|
+
补偿重声明——宁可多打一次幂等 `/watch`,不可留「缓存无、驱动无」而在途消息撞
|
|
4149
|
+
404。驱动 I/O 全程在锁外(锁纪律:`_watch_lock` 只保护记账集合,绝不跨网络调用)。
|
|
4150
|
+
撤销失败的 sid 放回集合等下一轮重试。
|
|
4151
|
+
|
|
4152
|
+
节流(C 批终审 I3,2026-10-10):本函数由 `_iw_once` **每个状态帧**调用一次,
|
|
4153
|
+
而「扫卡」是 O(项目数 × 每项目卡数) 的 DB 读、`_watch_msg_inflight` 还在
|
|
4154
|
+
`_watch_lock` 内做 SQLite 查询。故「扫卡 + 撤销」节流到
|
|
4155
|
+
`WATCH_PRUNE_INTERVAL`(60s,与调和器兜底拍同量级):窗口内只剩两件零成本的事
|
|
4156
|
+
—— `_watch_generation_sync()`(本地代次读口)与「集合空则返回」。
|
|
4157
|
+
**代次检查不参与节流**(它是正确性路径:中枢重连边沿必须立刻作废旧记账,否则
|
|
4158
|
+
陈旧缓存永不自愈);被节流影响的**只有回收的及时性**(最多晚一拍撤销看管——
|
|
4159
|
+
多留一会儿看管是安全方向,且在途消息送达不受影响)。
|
|
4160
|
+
开销:`_WATCHED` 为空(常态)时零 DB 访问(早于节流判定,也不会消耗窗口——
|
|
4161
|
+
新声明入集合后本拍即扫)。
|
|
4162
|
+
"""
|
|
4163
|
+
global _WATCH_PRUNE_AT
|
|
4164
|
+
_watch_generation_sync() # 中枢重连过:陈旧记账先作废(撤销无意义)
|
|
4165
|
+
with _watch_lock:
|
|
4166
|
+
if not _WATCHED:
|
|
4167
|
+
return 0 # 常态:零 DB 访问(不消耗节流窗口)
|
|
4168
|
+
now = time.monotonic()
|
|
4169
|
+
if now - _WATCH_PRUNE_AT < WATCH_PRUNE_INTERVAL:
|
|
4170
|
+
return 0 # 本窗口已扫过:只推迟回收,不影响正确性
|
|
4171
|
+
_WATCH_PRUNE_AT = now # 开窗(含扫描异常:下一拍再试)
|
|
4172
|
+
refs = _watch_card_refs()
|
|
4173
|
+
with _watch_lock:
|
|
4174
|
+
candidates = sorted(_WATCHED - refs) # 粗筛快照;最终判据见下面的锁内复核
|
|
4175
|
+
n = 0
|
|
4176
|
+
for sid in candidates:
|
|
4177
|
+
with _watch_lock:
|
|
4178
|
+
# —— 撤销前锁内复核(唯一信任点)——
|
|
4179
|
+
if sid not in _WATCHED or sid in refs or _watch_msg_inflight(sid):
|
|
4180
|
+
continue
|
|
4181
|
+
_WATCHED.discard(sid) # 先出缓存:并发 _ensure_watch 转为重新声明
|
|
4182
|
+
try:
|
|
4183
|
+
dshdriver.watch_session(sid, on=False)
|
|
4184
|
+
except Exception as exc: # noqa: BLE001 — 留痕,下轮重试
|
|
4185
|
+
with _watch_lock:
|
|
4186
|
+
_WATCHED.add(sid) # 撤销失败:放回,下轮再试
|
|
4187
|
+
print(f"[board] 看管撤销失败 sid={sid}: {exc}", flush=True)
|
|
4188
|
+
continue
|
|
4189
|
+
# —— 撤销后复核:窗口内并发落下的新声明 / 新在途消息 ⇒ 补偿重声明 ——
|
|
4190
|
+
with _watch_lock:
|
|
4191
|
+
redeclared = sid in _WATCHED
|
|
4192
|
+
if redeclared:
|
|
4193
|
+
_WATCHED.discard(sid) # 清掉再声明:强制真发一次 POST
|
|
4194
|
+
if redeclared or _watch_msg_inflight(sid):
|
|
4195
|
+
_ensure_watch(sid)
|
|
4196
|
+
continue
|
|
4197
|
+
n += 1
|
|
4198
|
+
return n
|
|
4199
|
+
|
|
4200
|
+
|
|
4201
|
+
def _watch_before_delivery(sid):
|
|
4202
|
+
"""送达前对**非池内**(外部)会话重新声明一次看管(C 批 T7 控制器裁定,2026-10-10)。
|
|
4203
|
+
|
|
4204
|
+
为什么必须补(T5 与 T4 两处评审叠加出的真实洞):`_watch_prune` 的撤销判据是
|
|
4205
|
+
「无卡引用 ∧ 无在途 `m:` 行」——**不含活跃 `a:`(作答)行**;驱动的
|
|
4206
|
+
`/watch {on:false}` 也不会主动收口在途认领(要等 abort/disposed/dispose)。
|
|
4207
|
+
两者叠加:答案已入队未送达、或会话正挂起等作答(用户刚点作答)时点,prune 可能
|
|
4208
|
+
已把该 sid 的看管撤掉 ⇒ 送达 `/answer`·`/approval` 撞驱动「未看管」404 ⇒
|
|
4209
|
+
`a:` 行落 error,正是本批要消灭的失败类。故作答/审批的**唯一送达出口**
|
|
4210
|
+
(`_answer_deliver`,worker 补位与「立即送达」共用)在真正调驱动前补一次声明。
|
|
4211
|
+
|
|
4212
|
+
幂等且廉价:`_ensure_watch` 命中 `_WATCHED` 缓存时是**纯本地判断、零请求**,
|
|
4213
|
+
只有缓存/驱动侧确已被撤(prune 撤销、中枢重连失效)才真发一次 `POST /watch`。
|
|
4214
|
+
|
|
4215
|
+
判据用既有读口 `dshevents.get(sid)` 的 `owned`(不新增全局状态、不猜):
|
|
4216
|
+
`owned is False` ⇒ 外部会话(需要显式看管);`owned is True`(平台自持)与
|
|
4217
|
+
`None`(注册表未知/断连——未知 ≠ 外部)一律不声明,与 `chat._external_preflight`
|
|
4218
|
+
及 `_iw_once` 逐卡挂钩同一判定阶梯:池内与未知路径行为一个字节不变。
|
|
4219
|
+
本函数**不改** `_watch_prune` 判据(「未看管不认领」是驱动侧红线,驱动行为正确),
|
|
4220
|
+
只把「撤销窗口里到达的送达」自愈回来。
|
|
4221
|
+
|
|
4222
|
+
失败不阻断(`_ensure_watch` 自带留痕、不重试):声明失败时驱动同样回 404,由
|
|
4223
|
+
调用方既有的重试/放弃语义处置,错误口径与此前一致。
|
|
4224
|
+
|
|
4225
|
+
边界:本钩子**缩小但不关闭** `_watch_prune` 与「在途送达」之间的交错窗口——钩子
|
|
4226
|
+
返回后到驱动真正收到 `/answer`·`/approval` 之间仍可能被一次并发 prune 撤掉看管;
|
|
4227
|
+
该残留窗口的兜底在**送达重试**(`a:` 行的 3 次退避重试;每次重试都再走一遍本钩子
|
|
4228
|
+
重新声明),不为此引入锁或全局状态。
|
|
4229
|
+
"""
|
|
4230
|
+
sid = str(sid or "")
|
|
4231
|
+
if not sid:
|
|
4232
|
+
return
|
|
4233
|
+
st = dshevents.get(sid)
|
|
4234
|
+
if st is None or st.get("owned"):
|
|
4235
|
+
return # 未知(断连/没见过)与平台自持:不碰
|
|
4236
|
+
_ensure_watch(sid)
|
|
4237
|
+
|
|
4238
|
+
|
|
3311
4239
|
_RECONCILE_FIELDS = {
|
|
3312
4240
|
# 动作 -> 落库字段(block 的 block_text 由调用方按检测摘录补)
|
|
3313
4241
|
"recover": {"column_key": "doing", "block_kind": None, "block_text": ""},
|
|
@@ -3320,7 +4248,8 @@ _RECONCILE_FIELDS = {
|
|
|
3320
4248
|
|
|
3321
4249
|
|
|
3322
4250
|
def _reconcile_action_for(family, card, r, has_run=False, live_msg=False,
|
|
3323
|
-
stop_recent=False, archived=None
|
|
4251
|
+
stop_recent=False, archived=None, starting=False,
|
|
4252
|
+
orphan_hold=False):
|
|
3324
4253
|
"""调和状态机(纯函数,单测覆盖):按会话实况 r(pending/busy)、归档态
|
|
3325
4254
|
archived 与当前卡状态得出动作,action ∈
|
|
3326
4255
|
{'block'(进阻塞+interaction),
|
|
@@ -3334,13 +4263,26 @@ def _reconcile_action_for(family, card, r, has_run=False, live_msg=False,
|
|
|
3334
4263
|
(None,中枢断连)一律不动作(不变量「断连=未知」)。
|
|
3335
4264
|
映射规则(无平台在管运行时,设计 §2.4):提问→blocked(interaction);
|
|
3336
4265
|
运行中→doing;空闲→review;阻塞解除按运行态回 doing/review。
|
|
3337
|
-
运行判定 running = busy or live_msg(2026-09-11
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
4266
|
+
运行判定 running = busy or (live_msg ∧ ¬orphan_hold)(2026-09-11 立,2026-10-10
|
|
4267
|
+
加孤儿行例外):live_msg=本卡会话有排队/执行中的平台消息单元(chat.live_of_sid)
|
|
4268
|
+
——消息排队尚未执行时会话并不 busy,仅凭 busy 会漏判;阻塞列卡片在消息投递后
|
|
4269
|
+
即应回开发列(用户约定:所有正在运行的卡片都在「正在开发」列)。
|
|
4270
|
+
`orphan_hold`(`_orphan_hold`:本卡有活跃 `c:` 行但无 `_RUNS` 背书,生产上=
|
|
4271
|
+
作答/立即送达补回的「送达恢复」行)**只认实况 busy**:那条行本身就在运行前缀里,
|
|
4272
|
+
serial 项目里它把本卡自己的排队消息挡在队外,再把 live_msg 当「在跑」就是互等
|
|
4273
|
+
(缺陷 B:行不收口 ⇒ 消息永远排不到 ⇒ 行永远收不了;真机 6 轮 5 轮命中,该卡与
|
|
4274
|
+
项目后续投递被永久挡住)。会话可信空闲(busy=False)时这类行必须收口让队列继续
|
|
4275
|
+
——与「送达恢复行按实况落回待审核」的既有口径(`_starting_window`)同源。
|
|
3341
4276
|
不干预规则:退场族不动(调用方已过滤,此处兜底保纯函数安全);
|
|
3342
|
-
todo/done 终态不动;平台在管运行(has_run=True)的卡只做交互 block/recover
|
|
3343
|
-
busy/空闲流转归 _finish_run
|
|
4277
|
+
todo/done 终态不动;平台在管运行(has_run=True)的卡只做交互 block/recover、
|
|
4278
|
+
busy/空闲流转归 _finish_run 拥有(防与轮收尾抢写)——**唯一例外**是卡片在
|
|
4279
|
+
「待审核」而会话确实在跑(起跑窗口误判的遗留形态)→ 归位 doing,见
|
|
4280
|
+
`starting` 段与 2026-10-07 批次说明。
|
|
4281
|
+
`starting`(起跑窗口,判据见 `_starting_window`):占位已清、会话尚未证实
|
|
4282
|
+
起跑——此窗口内 busy=False 是「turn 还没开始」而非「已结束」,**不做忙/闲
|
|
4283
|
+
列映射**,只防御性放行 block(2026-10-07 修卡 902/903:此前正在起跑的
|
|
4284
|
+
doing 卡被误判空闲搬去「待审核」,`_RUNS` 登记后早退不再自愈,卡片整轮
|
|
4285
|
+
滞留错列——用户约定:运行中的卡都在「正在开发」列)。
|
|
3344
4286
|
queue 排队占位卡只放行 block(2026-09-10 修复):占位不等于会话已停——
|
|
3345
4287
|
运行中卡交互阻塞解除后落 doing/queue 排队等串行位(_recover_to_doing),
|
|
3346
4288
|
dsh 会话照跑、稍后仍可能再提问;此前 queue 卡被一律跳过,这类提问
|
|
@@ -3371,7 +4313,9 @@ def _reconcile_action_for(family, card, r, has_run=False, live_msg=False,
|
|
|
3371
4313
|
return None
|
|
3372
4314
|
pending = bool(r and r.get("pending"))
|
|
3373
4315
|
busy = bool(r and r.get("busy"))
|
|
3374
|
-
|
|
4316
|
+
# 孤儿运行行只认实况 busy(缺陷 B,见 `_orphan_hold` 与 docstring):
|
|
4317
|
+
# live_msg 不能再把它按「在跑」保住——行恰恰是那条消息起不来的原因
|
|
4318
|
+
running = busy or (bool(live_msg) and not orphan_hold)
|
|
3375
4319
|
if card["block_kind"] == "manual":
|
|
3376
4320
|
if pending:
|
|
3377
4321
|
return None # 等待用户回答:维持阻塞态
|
|
@@ -3380,6 +4324,12 @@ def _reconcile_action_for(family, card, r, has_run=False, live_msg=False,
|
|
|
3380
4324
|
return "to_doing" if running else None
|
|
3381
4325
|
if card["block_kind"] == "queue":
|
|
3382
4326
|
return "block" if pending else None
|
|
4327
|
+
if starting:
|
|
4328
|
+
# 起跑窗口(`_starting_window`):占位已清、会话尚未证实起跑——此时的
|
|
4329
|
+
# busy=False 是「turn 还没开始」而非「已结束」,**不做忙/闲列映射**
|
|
4330
|
+
# (否则 doing 卡被判空闲搬去待审核;2026-10-07 卡 902/903 实障)。
|
|
4331
|
+
# 窗口内提问不可能出现(prompt 还没下发),仅作防御性放行。
|
|
4332
|
+
return "block" if pending else None
|
|
3383
4333
|
if pending:
|
|
3384
4334
|
if card["column_key"] == "blocked" and card["block_kind"] == "interaction":
|
|
3385
4335
|
return None # 已在交互阻塞,等待作答
|
|
@@ -3395,6 +4345,13 @@ def _reconcile_action_for(family, card, r, has_run=False, live_msg=False,
|
|
|
3395
4345
|
if card["column_key"] == "blocked" and not card["block_kind"]:
|
|
3396
4346
|
return "to_doing" if (running or has_run) else "to_review"
|
|
3397
4347
|
if has_run:
|
|
4348
|
+
# 在管运行:列流转归 _finish_run(防与轮收尾抢写)。唯一例外——卡片在
|
|
4349
|
+
# 「待审核」而会话确实在跑:只可能来自起跑窗口内的误判(本批次修复前的
|
|
4350
|
+
# 遗留形态:窗口内被搬去待审核、`_RUNS` 登记后早退不再自愈),按用户
|
|
4351
|
+
# 约定「运行中的卡都在正在开发列」归位。stop_recent 宽限内不搬:刚被
|
|
4352
|
+
# 平台停过会话(停止/拖离开发列)的卡,busy 可能是 abort 生效前的残留。
|
|
4353
|
+
if card["column_key"] == "review" and running and not stop_recent:
|
|
4354
|
+
return "to_doing"
|
|
3398
4355
|
return None # 在管运行:列流转归 _finish_run
|
|
3399
4356
|
if card["column_key"] == "doing":
|
|
3400
4357
|
return None if running else "to_review"
|
|
@@ -3405,7 +4362,8 @@ def _reconcile_action_for(family, card, r, has_run=False, live_msg=False,
|
|
|
3405
4362
|
|
|
3406
4363
|
def _iw_apply(family, card, action, r):
|
|
3407
4364
|
"""执行调和动作(read-verify-write)。写库前用 get_board_card 重读当前行,
|
|
3408
|
-
并重评 has_run/live_msg/stop_recent
|
|
4365
|
+
并重评 has_run/live_msg/stop_recent/起跑窗口(`_starting_window`)、重跑状态机:
|
|
4366
|
+
仅当动作仍成立才写库;
|
|
3409
4367
|
否则跳过——保护用户手动拖卡/其他写者不被调和窗口内的旧判定覆盖
|
|
3410
4368
|
(事件快照到落库之间卡状态可能已变/已删;live_msg 实时重取
|
|
3411
4369
|
——消息单元刚结束/被取消时状态机结论可能翻转为不动;stop_recent 实时
|
|
@@ -3423,9 +4381,13 @@ def _iw_apply(family, card, action, r):
|
|
|
3423
4381
|
return # 已答待送达:调和动作一律跳过(见 docstring)
|
|
3424
4382
|
has_run = _has_active_run(card["id"])
|
|
3425
4383
|
live_msg = chat.live_of_sid((cur["session_id"] or "").strip())
|
|
4384
|
+
# 写前复核同样重取「孤儿运行行」(缺陷 B,短路口径同 `_iw_once`)
|
|
4385
|
+
orphan = bool(live_msg) and _orphan_hold(cur["id"])
|
|
3426
4386
|
if _reconcile_action_for(family, cur, r, has_run, live_msg,
|
|
3427
4387
|
_stop_recent(cur["id"]),
|
|
3428
|
-
dshevents.archived(cur["session_id"] or "")
|
|
4388
|
+
dshevents.archived(cur["session_id"] or ""),
|
|
4389
|
+
_starting_window(cur["id"]),
|
|
4390
|
+
orphan) != action:
|
|
3429
4391
|
return # 状态已变:跳过,不写库
|
|
3430
4392
|
if action == "to_done":
|
|
3431
4393
|
# dsh 侧归档 → 卡片进「已完成」(2026-10-05 反向规则):走容器迁移原语收口
|
|
@@ -3548,11 +4510,15 @@ def _iw_once():
|
|
|
3548
4510
|
异常语义:读不到实况(中枢未连接/未知)该卡本轮跳过(不搬列,防把运行中卡
|
|
3549
4511
|
误判完成搬去待审核;取代 sync_sessions 旧「异常视为空闲」;ext 行侧保留既有
|
|
3550
4512
|
行=保留上一轮占用,宁可多等不可误放行——逐卡 `r is None` 走 `ext_hold`)。
|
|
4513
|
+
看管声明(C 批 T5):逐卡对 `owned:false`(用户直跑的)会话调 `_ensure_watch`
|
|
4514
|
+
——声明范围=有卡会话;卡删除后的回收在节拍尾部 `_watch_prune`。
|
|
3551
4515
|
单卡异常不外抛(不影响其他卡/项目)。节拍尾部另跑 starting 超时对账
|
|
3552
4516
|
(_reconcile_starting_rows,v2d T3,裁决 R6)。"""
|
|
3553
4517
|
now = int(time.time() * 1000)
|
|
3554
4518
|
ext_changed = False
|
|
3555
4519
|
seen_sids = set() # 本轮在场的归档判定 sid(_ARCH_SEEN 有界清理用)
|
|
4520
|
+
# 起跑窗口批量读口(一轮一次;逐卡点查会成 N+1,见 `_starting_window`)
|
|
4521
|
+
starting_ids = waitq.starting_card_ids()
|
|
3556
4522
|
for proj in db.list_projects_all():
|
|
3557
4523
|
if proj["archived"]:
|
|
3558
4524
|
continue
|
|
@@ -3592,15 +4558,44 @@ def _iw_once():
|
|
|
3592
4558
|
if not sid:
|
|
3593
4559
|
continue # 无会话(从未起跑的真排队卡等)无可探测
|
|
3594
4560
|
r = _iw_interaction(fam, proj, sid)
|
|
4561
|
+
# —— 看管声明(C 批 T5):外部会话(用户直跑/接管,`owned:false`)
|
|
4562
|
+
# 的投递/作答通道以平台显式 `POST /watch` 为闸,声明范围=有卡会话
|
|
4563
|
+
# (本循环即「有卡」的天然边界;撤销见 `_watch_prune`)。幂等:
|
|
4564
|
+
# 只有新 sid 首见(或中枢重连后记账刚被清空,见
|
|
4565
|
+
# `_watch_generation_sync`)才真发请求,失败留痕不重试。
|
|
4566
|
+
# 读注册表判 owned(而非另发请求);未知(get 为 None)不声明。
|
|
4567
|
+
_st = dshevents.get(sid)
|
|
4568
|
+
if _st is not None and not _st.get("owned"):
|
|
4569
|
+
_ensure_watch(sid)
|
|
3595
4570
|
if r is None:
|
|
3596
|
-
|
|
4571
|
+
# 实况读不到:两种语义必须分开(2026-10-07 实障修复,卡 870)——
|
|
4572
|
+
# ① 中枢**断连**=真未知:保行(宁多等不可误放行,原口径);
|
|
4573
|
+
# ② 中枢**在线**而注册表无此 sid = 该会话已不在 dsh 会话池
|
|
4574
|
+
# (`session/disposed` 摘条目,或重连对齐时被 `/live` 快照
|
|
4575
|
+
# 覆盖掉)⇒ 外部会话已结束:不 hold,交本轮
|
|
4576
|
+
# `_ext_stale_finish` 按「不在目标集」收口。
|
|
4577
|
+
# 旧口径把两者混为一谈,一律 hold 保行——子代理会话留下的 ext
|
|
4578
|
+
# 行因此永久占位(项目常驻「有外部会话在跑」⇒ 永不补位,实障
|
|
4579
|
+
# 里卡 870 排队 5 小时、项目 119 积 10 条僵尸行)。
|
|
4580
|
+
if not dshevents.connected() and not _platform_holds(card["id"]) \
|
|
4581
|
+
and not _subagent_session(sid):
|
|
3597
4582
|
ext_hold.add(card["id"]) # 占用未知:保留既有行(old 探针口径)
|
|
4583
|
+
elif dshevents.aligned() and not _platform_holds(card["id"]):
|
|
4584
|
+
# ③ 中枢可信(在线 ∧ 已对齐)却查不到该 sid = 会话确已结束:
|
|
4585
|
+
# 补上卡列这一格(2026-10-08 批次 D,bug_report/20261008_1935)
|
|
4586
|
+
# ——旧口径只收 ext 行、列不动,会话静默消失的 doing 卡永滞
|
|
4587
|
+
# 开发列(卡 908/909/912/913 的原症状);对齐前一律不动,
|
|
4588
|
+
# 杜绝「热重载后注册表为空 ⇒ 把运行中的卡批量搬走」的误判。
|
|
4589
|
+
# 写前复核全在 `_iw_apply`:queue 占位 / 起跑窗口 / 平台持有 /
|
|
4590
|
+
# 已答待送达 / 归档(to_done)自动跳出(状态机重评)。
|
|
4591
|
+
_iw_apply(fam, card, "to_review", None)
|
|
3598
4592
|
continue # 实况读不到:该卡本轮跳过,不搬列
|
|
3599
4593
|
# —— 外部条目占用候选(v2d T4):busy 且非挂起、且平台不持有 ——
|
|
3600
4594
|
# 挂起(pending)不算占用(豁免面②:挂起即出队,不建行);平台
|
|
3601
4595
|
# 持有者(活跃 c: 行/在管条目)自占前缀位,不建行(防 ext:/c: 双计)。
|
|
3602
4596
|
if r.get("busy") and not r.get("pending") \
|
|
3603
|
-
and not _platform_holds(card["id"])
|
|
4597
|
+
and not _platform_holds(card["id"]) \
|
|
4598
|
+
and not _subagent_session(sid, r.get("origin")):
|
|
3604
4599
|
ext_busy[card["id"]] = sid
|
|
3605
4600
|
if _has_active_run(card["id"]):
|
|
3606
4601
|
# 平台在管运行卡:行心跳 + 证据(v3c:证据迁行,直调;
|
|
@@ -3613,9 +4608,16 @@ def _iw_once():
|
|
|
3613
4608
|
c.update(r) # pending/busy/qid/question/options/answerable/text
|
|
3614
4609
|
c["ts"] = now
|
|
3615
4610
|
live_msg = chat.live_of_sid(sid) # 本卡消息在跑/排队(消息驱动的会话)
|
|
4611
|
+
# 孤儿运行行(缺陷 B)只在「本卡确有排队/执行中的消息」时才影响判定
|
|
4612
|
+
# (无消息时 running 只看 busy,见 `_reconcile_action_for`)⇒ 短路
|
|
4613
|
+
# 掉那次行点查,平台在管卡的常规拍零额外 DB 访问。
|
|
4614
|
+
orphan = bool(live_msg) and _orphan_hold(card["id"])
|
|
3616
4615
|
action = _reconcile_action_for(fam, card, r,
|
|
3617
4616
|
_has_active_run(card["id"]), live_msg,
|
|
3618
|
-
_stop_recent(card["id"]), arch
|
|
4617
|
+
_stop_recent(card["id"]), arch,
|
|
4618
|
+
_starting_window(card["id"],
|
|
4619
|
+
starting_ids),
|
|
4620
|
+
orphan)
|
|
3619
4621
|
if action:
|
|
3620
4622
|
_iw_apply(fam, card, action, r)
|
|
3621
4623
|
except Exception:
|
|
@@ -3646,6 +4648,12 @@ def _iw_once():
|
|
|
3646
4648
|
_reconcile_starting_rows()
|
|
3647
4649
|
except Exception:
|
|
3648
4650
|
pass # 守护线程不 crash(同 _iw_once 口径)
|
|
4651
|
+
# 看管回收(C 批 T5):卡删除/换绑会话后撤掉驱动的看管声明(`_WATCHED` 为空时
|
|
4652
|
+
# 零 DB 访问;异常不影响本轮回调和的其余部分)。
|
|
4653
|
+
try:
|
|
4654
|
+
_watch_prune()
|
|
4655
|
+
except Exception:
|
|
4656
|
+
pass # 同上:回收失败下轮再来
|
|
3649
4657
|
|
|
3650
4658
|
|
|
3651
4659
|
def interaction_of_sid(sid):
|
|
@@ -3821,10 +4829,17 @@ def _answer_deliver(proj, meta):
|
|
|
3821
4829
|
插件在 waterfall 里挂起),meta 带 `outcome`(`allowed-once`/`rejected`)走
|
|
3822
4830
|
插件 `/approval`;未认领(GUI 作答)时插件返回 409 → 这里转 40005 让上层按
|
|
3823
4831
|
「再无待决」收口。
|
|
4832
|
+
|
|
4833
|
+
外部(池外)会话(C 批 T7,2026-10-10):真正调驱动前经 `_watch_before_delivery`
|
|
4834
|
+
幂等补声明看管——看管可能已被 `_watch_prune` 撤掉(其撤销判据不含活跃 `a:` 行),
|
|
4835
|
+
漏声明会让 `/answer`·`/approval` 撞「未看管」404、答案落 error。
|
|
3824
4836
|
"""
|
|
3825
4837
|
if _web_family(proj) != "dsh_plugin":
|
|
3826
4838
|
# 退场族无作答送达通道(单族化):明确报错,不落已删除的 kimi 配送段
|
|
3827
4839
|
raise RuntimeError(agents.RETIRED_MSG)
|
|
4840
|
+
# 送达前重声明看管(C 批 T7):外部会话的看管可能已被 `_watch_prune` 撤掉
|
|
4841
|
+
# (其判据不含活跃 `a:` 行),漏声明则下面两条驱动调用撞「未看管」404(见上)
|
|
4842
|
+
_watch_before_delivery(meta.get("sid"))
|
|
3828
4843
|
if meta.get("approval_id"):
|
|
3829
4844
|
outcome = str(meta.get("outcome") or "")
|
|
3830
4845
|
if not outcome:
|
|
@@ -4036,7 +5051,9 @@ def answer_interaction(proj, card, qid, answers):
|
|
|
4036
5051
|
看板卡路径(v2b T1,裁决 R8)**一律入队**(_queue_answer_unit:插前缀后/
|
|
4037
5052
|
等待区最前 + doing/queue 占位,补位启动时 worker 才调驱动 `/answer` 一次
|
|
4038
5053
|
提交送达——dsh 侧按 callId + 逐题 selected/custom 一次给全,缺项即未答);
|
|
4039
|
-
任务侧伪卡(无 id
|
|
5054
|
+
任务侧伪卡(无 id)保持直送,但直送前经 `_watch_before_delivery` 补声明看管
|
|
5055
|
+
(任务会话被宿主上报为外部态后撞「未看管」404 的修复,T7 评审 2026-10-10)。
|
|
5056
|
+
成功后清理 watcher 缓存(下轮调和重新检测刷新)。
|
|
4040
5057
|
|
|
4041
5058
|
answers = [{"wire": "q_0", "kind": "single|multi|other|multi_with_other",
|
|
4042
5059
|
"option_id"/"option_ids"/"text": ...}, ...](前端逐题收集;
|
|
@@ -4115,7 +5132,7 @@ def answer_interaction(proj, card, qid, answers):
|
|
|
4115
5132
|
# ._answer_exempt_keys_locked,R5⑥),一律插「运行中最后一个条目后面」
|
|
4116
5133
|
# (前缀后/等待区最前),卡落 doing/queue 排队占位,补位启动时才送达。
|
|
4117
5134
|
# 仅看板卡(有卡 id)参与;任务侧会话没有卡片列语义,保持直送
|
|
4118
|
-
# (specQ §5
|
|
5135
|
+
# (specQ §5 边界不变;直送前经 `_watch_before_delivery` 补声明看管,见下)。
|
|
4119
5136
|
try:
|
|
4120
5137
|
card_id = card["id"]
|
|
4121
5138
|
except (KeyError, IndexError, TypeError):
|
|
@@ -4124,6 +5141,11 @@ def answer_interaction(proj, card, qid, answers):
|
|
|
4124
5141
|
if _web_family(proj) != "dsh_plugin":
|
|
4125
5142
|
# 退场族无作答通道(单族化):不落已删除的 kimi 配送段
|
|
4126
5143
|
return f"回答失败:{agents.RETIRED_MSG}"
|
|
5144
|
+
# 送达前重声明看管(T7 评审 Important,控制器裁定 2026-10-10):任务会话
|
|
5145
|
+
# 宿主重载后会被驱动以 owned:false 上报(= 池外),而本直送分支不经
|
|
5146
|
+
# `_answer_deliver`;不补声明就撞「未看管」404,用户看到「回答失败:…」。
|
|
5147
|
+
# 判据同 `_answer_deliver`:owned:false 才声明,池内/未知零请求。
|
|
5148
|
+
_watch_before_delivery(sid)
|
|
4127
5149
|
try:
|
|
4128
5150
|
# 路线 A:任务侧会话直送(卡片侧走作答排队,见 _answer_deliver);
|
|
4129
5151
|
# 驱动调用失败(网络/序列化/提问已失效等)均须落到前端
|
|
@@ -4144,7 +5166,8 @@ def answer_approval(proj, card, approval_id, decision, scope=""):
|
|
|
4144
5166
|
与 answer_interaction 同款白名单校验:approval_id 必须是缓存里的实况值
|
|
4145
5167
|
(防伪造/旧 id 重放);decision ∈ approved/rejected(平台两态),
|
|
4146
5168
|
scope="session" 为「本会话内批准」——dsh 无该语义,显式拒绝(见下)。
|
|
4147
|
-
看板卡路径(v2b T1
|
|
5169
|
+
看板卡路径(v2b T1)一律入队、补位启动时才送达;任务侧保持直送,直送前同样经
|
|
5170
|
+
`_watch_before_delivery` 补声明看管(与提问腿同源,T7 评审 2026-10-10)。
|
|
4148
5171
|
成功后清 watcher 缓存(下轮调和重新检测刷新)。
|
|
4149
5172
|
返回 None=成功,str=错误信息(server 转 400 JSON {error})。"""
|
|
4150
5173
|
sid = card["session_id"] or ""
|
|
@@ -4172,12 +5195,15 @@ def answer_approval(proj, card, approval_id, decision, scope=""):
|
|
|
4172
5195
|
dsh_outcome = {"approved": "allowed-once", "rejected": "rejected"}[decision]
|
|
4173
5196
|
# 审批作答同路入队(v2b T1,删恒直送):与提问作答同一插入规则——补位
|
|
4174
5197
|
# 启动时由执行体按 meta 分发 answer_approval 送达。仅看板卡(有卡 id)
|
|
4175
|
-
# 参与;任务侧会话保持直送(specQ §5
|
|
5198
|
+
# 参与;任务侧会话保持直送(specQ §5 边界不变;直送前同样补声明看管,见下)。
|
|
4176
5199
|
try:
|
|
4177
5200
|
card_id = card["id"]
|
|
4178
5201
|
except (KeyError, IndexError, TypeError):
|
|
4179
5202
|
card_id = None
|
|
4180
5203
|
if card_id is None:
|
|
5204
|
+
# 送达前重声明看管(T7 评审 Important,控制器裁定 2026-10-10):同上
|
|
5205
|
+
# (提问腿)——任务会话池外化后,本直送分支同样需要先补声明再调 `/approval`。
|
|
5206
|
+
_watch_before_delivery(sid)
|
|
4181
5207
|
try:
|
|
4182
5208
|
dshdriver.answer_approval(sid, approval_id, dsh_outcome)
|
|
4183
5209
|
except Exception as e: # noqa: BLE001
|