@gavinc-cn/touchstone-dsh 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/README.zh.md +1 -0
- package/agents.py +0 -0
- package/board.py +348 -26
- package/chat.py +0 -0
- package/db.py +1 -1
- package/dsh-plugin/install.sh +0 -0
- package/dsh-plugin/lib/agent-driver.js +190 -42
- package/dsh-plugin/lib/index.js +181 -22
- package/dshdriver.py +0 -0
- package/dshevents.py +35 -3
- package/feishu.py +29 -8
- package/package.json +8 -3
- package/runner.py +11 -0
- package/server.py +46 -4
- package/sessparse.py +95 -1
- package/touchstone.py +2 -1
- package/webui/dist-plugin/assets/index-DtwKoi1T.js +473 -0
- package/webui/dist-plugin/index.html +1 -1
- package/worktree.py +100 -0
- package/webui/dist-plugin/assets/index-eHpxBfP6.js +0 -473
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
**An AI-agent-driven automated testing platform.** Point it at a repository; it explores the code, writes and runs test cases, files what breaks, fixes it, and re-verifies the fix.
|
|
6
6
|
|
|
7
7
|
[](LICENSE)
|
|
8
|
+
[](https://www.npmjs.com/package/@gavinc-cn/touchstone-dsh)
|
|
8
9
|
[](https://www.python.org/)
|
|
9
10
|
[](https://nodejs.org/)
|
|
10
11
|
[](https://github.com/deepseek-ai/deepseek-harness)
|
package/README.zh.md
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
把失败沉淀成 bug 报告,修完再复测一遍。
|
|
7
7
|
|
|
8
8
|
[](LICENSE)
|
|
9
|
+
[](https://www.npmjs.com/package/@gavinc-cn/touchstone-dsh)
|
|
9
10
|
[](https://www.python.org/)
|
|
10
11
|
[](https://nodejs.org/)
|
|
11
12
|
[](https://github.com/deepseek-ai/deepseek-harness)
|
package/agents.py
CHANGED
|
File without changes
|
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)
|
|
@@ -3034,6 +3256,11 @@ SYNC_BUSY_MTIME_S = 120
|
|
|
3034
3256
|
SYNC_TITLE_MAX = 200 # 标题上限(沿用建卡既有截断:insert_board_card 调用处 [:200])
|
|
3035
3257
|
SYNC_DESC_MAX = 2000 # 描述上限(防一次长提问把看板负载撑大)
|
|
3036
3258
|
|
|
3259
|
+
# 已跑过「存量子代理卡软删收口」的项目 id(`sync_sessions` 尾巴,2026-10-07)。
|
|
3260
|
+
# 枚举侧过滤后不会再产生子代理卡,故每项目一轮足够;也保证用户从回收站还原后
|
|
3261
|
+
# 不被反复软删(进程重启后重建空集,重跑一轮幂等)。
|
|
3262
|
+
_SYNC_SUBAGENT_SWEPT = set()
|
|
3263
|
+
|
|
3037
3264
|
|
|
3038
3265
|
def _split_first_prompt(text):
|
|
3039
3266
|
"""首问原文 → (标题, 描述):首行=标题、其余行=描述。
|
|
@@ -3104,7 +3331,8 @@ def sync_sessions(project):
|
|
|
3104
3331
|
描述;首问未落盘时回落会话标题、再回落 sid 短码,落盘后由本函数补齐),绑定
|
|
3105
3332
|
主会话,busy→doing / 空闲→review;存量 sync 卡:首问补齐标题/描述(用户改过
|
|
3106
3333
|
名的不动)+ dsh 族列映射移交调和器(事件驱动),此处仅归档→done(人工拖到
|
|
3107
|
-
todo/blocked/done 后不再自动搬);会话存储被删的 sync 卡自动进 done
|
|
3334
|
+
todo/blocked/done 后不再自动搬);会话存储被删的 sync 卡自动进 done;绑子代理
|
|
3335
|
+
会话的存量 sync 卡收口进回收站(每项目一轮,见函数尾注释)。
|
|
3108
3336
|
sync 卡不占 runner 项目占用(外部会话平台控制不了,防堵死统一队列)。
|
|
3109
3337
|
"""
|
|
3110
3338
|
if not settings_of(project["id"]).get("sync_sessions", True):
|
|
@@ -3156,13 +3384,32 @@ def sync_sessions(project):
|
|
|
3156
3384
|
want = "done" if archived else None
|
|
3157
3385
|
if want and c["column_key"] != want:
|
|
3158
3386
|
db.update_board_card(c["id"], column_key=want)
|
|
3387
|
+
# —— 存量子代理卡收口(2026-10-07 实障修复)——
|
|
3388
|
+
# 新口径下 `sessparse.list_sessions` 已过滤子代理会话(不再建新卡),但历史版本按
|
|
3389
|
+
# 子代理会话建出的卡还留在板面上(实测全机 72 张、70 张永留「待审核」——子代理
|
|
3390
|
+
# 不会被归档、存储也不会被删,归档同步与下面「存储被删→done」两条规则都够不着)。
|
|
3391
|
+
# 这里**软删进回收站**(可还原;不删会话文件、不动会话与依赖)。两条边界:
|
|
3392
|
+
# ① 只认 origin='sync' 自动卡——用户自建卡即便绑了子代理会话也是用户的东西,不碰;
|
|
3393
|
+
# ② 每项目只跑一轮——A 之后不会再产生这类卡,一轮足够;用户从回收站**还原**后
|
|
3394
|
+
# 同一进程内不会被反复软删(尊重显式操作),进程重启后重跑一轮(幂等)。
|
|
3395
|
+
if project["id"] not in _SYNC_SUBAGENT_SWEPT:
|
|
3396
|
+
dropped = 0
|
|
3397
|
+
for c in cards:
|
|
3398
|
+
sids = set(_card_sids(c))
|
|
3399
|
+
if c["origin"] != "sync" or not sids or _has_active_run(c["id"]):
|
|
3400
|
+
continue
|
|
3401
|
+
if any(sessparse.is_subagent(family, s) for s in sids):
|
|
3402
|
+
db.trash_board_card(c["id"])
|
|
3403
|
+
dropped += 1
|
|
3404
|
+
_SYNC_SUBAGENT_SWEPT.add(project["id"])
|
|
3405
|
+
if dropped:
|
|
3406
|
+
print(f"[board-sync] 子代理会话卡收口: 软删 {dropped} 张"
|
|
3407
|
+
f"(项目 {project['id']},见 spec/queue/排队与占用.md)")
|
|
3159
3408
|
# 存储被删 → done(list 只回 50 条,不能用「不在列表」判定删除,必须逐卡查存在性)
|
|
3160
3409
|
for c in cards:
|
|
3161
3410
|
if c["origin"] != "sync" or c["column_key"] == "done" or _has_active_run(c["id"]):
|
|
3162
3411
|
continue
|
|
3163
|
-
sids = set(
|
|
3164
|
-
if c["session_id"]:
|
|
3165
|
-
sids.add(c["session_id"])
|
|
3412
|
+
sids = set(_card_sids(c))
|
|
3166
3413
|
if sids and not any(sessparse.session_exists(family, s) for s in sids):
|
|
3167
3414
|
db.update_board_card(c["id"], column_key="done")
|
|
3168
3415
|
return created
|
|
@@ -3188,6 +3435,42 @@ def _has_active_run(card_id):
|
|
|
3188
3435
|
return card_id in _RUNS
|
|
3189
3436
|
|
|
3190
3437
|
|
|
3438
|
+
def _wt_in_flight(card_id):
|
|
3439
|
+
"""worktree 直起窗口在场判定(`_WT_STARTING`,见 `_enter_doing_worktree`)。
|
|
3440
|
+
|
|
3441
|
+
worktree 卡不落 c: 行(「不进统一队列」语义),起跑窗口只能靠这个进程内
|
|
3442
|
+
登记集表征:`_wt_start_begin` 在入口登记、`finally` 在 `start_card`(内部
|
|
3443
|
+
登记 `_RUNS`)之后才 `_wt_start_end` 释放 ⇒ 在场区间恰好覆盖起跑窗口。"""
|
|
3444
|
+
with _WT_STARTING_LOCK:
|
|
3445
|
+
return card_id in _WT_STARTING
|
|
3446
|
+
|
|
3447
|
+
|
|
3448
|
+
def _starting_window(card_id, starting_ids=None):
|
|
3449
|
+
"""卡片是否处于「起跑窗口」:占位保护已解除,但会话尚未证实起跑。
|
|
3450
|
+
|
|
3451
|
+
窗口的成因(2026-10-07 卡 902/903 实障):`dequeue_start` / `_enter_doing_worktree`
|
|
3452
|
+
先落 `doing` + 清 `block_kind='queue'` 占位,**之后**才起会话;而 `_RUNS`
|
|
3453
|
+
在 `_start_web` 末尾(create_session → 会话默认值 → turn 基线 → `dsh_send`
|
|
3454
|
+
全跑完)才登记。这段 1~2 秒里 dsh 侧读到的 busy=False 是「turn 还没开始」,
|
|
3455
|
+
不是「会话已结束」——调和器若据此按忙/闲搬列,正在起跑的卡会被搬去
|
|
3456
|
+
「待审核」(用户约定:运行中的卡都在「正在开发」列),且随后 `has_run`
|
|
3457
|
+
早退不再自愈,卡片整轮滞留错列。
|
|
3458
|
+
|
|
3459
|
+
三条起跑路径各自的窗口表征:
|
|
3460
|
+
- 统一队列路径(含 force 落表行):c: 行 `state=starting`(拾取 →
|
|
3461
|
+
`card_started` 置 running);
|
|
3462
|
+
- worktree 直起路径(无 c: 行):`_WT_STARTING` 在场(见 `_wt_in_flight`);
|
|
3463
|
+
- 已登记在管条目(`_RUNS`)=已证实起跑,**一律不算窗口**——正常忙/闲
|
|
3464
|
+
映射(含「送达恢复」那种 running 行而无在管条目的形态:会话结束要能
|
|
3465
|
+
落回待审核,这条口径不能被本判据吞掉)。
|
|
3466
|
+
`starting_ids` 为调用方的批量读口预取值(`waitq.starting_card_ids()`,
|
|
3467
|
+
调和器一轮一次),缺省自行单查(`_iw_apply` 写前复核用)。"""
|
|
3468
|
+
if _has_active_run(card_id):
|
|
3469
|
+
return False
|
|
3470
|
+
ids = waitq.starting_card_ids() if starting_ids is None else starting_ids
|
|
3471
|
+
return card_id in ids or _wt_in_flight(card_id)
|
|
3472
|
+
|
|
3473
|
+
|
|
3191
3474
|
def run_pid(card_id):
|
|
3192
3475
|
"""在管卡会话子进程 pid(无运行条目/web 族无 proc → None;R11④ 进程存活
|
|
3193
3476
|
裁活的写入侧数据源,落行 evidence.pid)。"""
|
|
@@ -3236,7 +3519,9 @@ def _iw_interaction(family, proj, sid, busy_hint=None):
|
|
|
3236
3519
|
交互卡片(选项+描述+自定义输入 / 批准-拒绝)。
|
|
3237
3520
|
options 逐项 = `_dsh_option` 归一后的 `{id, label, description}`(2026-10-04
|
|
3238
3521
|
起带选项描述,修 #791);qid 取自插件的提问标识(认领中的提问非空 ⇒
|
|
3239
|
-
answerable=True ⇒ 会话窗渲染单选/多选框 + 「提交」)。
|
|
3522
|
+
answerable=True ⇒ 会话窗渲染单选/多选框 + 「提交」)。2026-10-06 修 #837 起
|
|
3523
|
+
dsh Web GUI 同时弹同一个提问框(插件的原生作答通道):两侧都能答、先答者胜,
|
|
3524
|
+
GUI 先答时平台迟到作答拿 accepted=false(上层按 40405 收口)。
|
|
3240
3525
|
异常/无 sid 返回 None(该轮跳过,不误移不报错)。
|
|
3241
3526
|
单族化后只有 dsh_plugin:读 `dshevents` 注册表(busy 来自 agent/status
|
|
3242
3527
|
事件,挂起来自 `user-questions/request` waterfall 与 `approval/asked`
|
|
@@ -3320,7 +3605,7 @@ _RECONCILE_FIELDS = {
|
|
|
3320
3605
|
|
|
3321
3606
|
|
|
3322
3607
|
def _reconcile_action_for(family, card, r, has_run=False, live_msg=False,
|
|
3323
|
-
stop_recent=False, archived=None):
|
|
3608
|
+
stop_recent=False, archived=None, starting=False):
|
|
3324
3609
|
"""调和状态机(纯函数,单测覆盖):按会话实况 r(pending/busy)、归档态
|
|
3325
3610
|
archived 与当前卡状态得出动作,action ∈
|
|
3326
3611
|
{'block'(进阻塞+interaction),
|
|
@@ -3339,8 +3624,15 @@ def _reconcile_action_for(family, card, r, has_run=False, live_msg=False,
|
|
|
3339
3624
|
并不 busy,仅凭 busy 会漏判;阻塞列卡片在消息投递后即应回开发列(用户
|
|
3340
3625
|
约定:所有正在运行的卡片都在「正在开发」列)。
|
|
3341
3626
|
不干预规则:退场族不动(调用方已过滤,此处兜底保纯函数安全);
|
|
3342
|
-
todo/done 终态不动;平台在管运行(has_run=True)的卡只做交互 block/recover
|
|
3343
|
-
busy/空闲流转归 _finish_run
|
|
3627
|
+
todo/done 终态不动;平台在管运行(has_run=True)的卡只做交互 block/recover、
|
|
3628
|
+
busy/空闲流转归 _finish_run 拥有(防与轮收尾抢写)——**唯一例外**是卡片在
|
|
3629
|
+
「待审核」而会话确实在跑(起跑窗口误判的遗留形态)→ 归位 doing,见
|
|
3630
|
+
`starting` 段与 2026-10-07 批次说明。
|
|
3631
|
+
`starting`(起跑窗口,判据见 `_starting_window`):占位已清、会话尚未证实
|
|
3632
|
+
起跑——此窗口内 busy=False 是「turn 还没开始」而非「已结束」,**不做忙/闲
|
|
3633
|
+
列映射**,只防御性放行 block(2026-10-07 修卡 902/903:此前正在起跑的
|
|
3634
|
+
doing 卡被误判空闲搬去「待审核」,`_RUNS` 登记后早退不再自愈,卡片整轮
|
|
3635
|
+
滞留错列——用户约定:运行中的卡都在「正在开发」列)。
|
|
3344
3636
|
queue 排队占位卡只放行 block(2026-09-10 修复):占位不等于会话已停——
|
|
3345
3637
|
运行中卡交互阻塞解除后落 doing/queue 排队等串行位(_recover_to_doing),
|
|
3346
3638
|
dsh 会话照跑、稍后仍可能再提问;此前 queue 卡被一律跳过,这类提问
|
|
@@ -3380,6 +3672,12 @@ def _reconcile_action_for(family, card, r, has_run=False, live_msg=False,
|
|
|
3380
3672
|
return "to_doing" if running else None
|
|
3381
3673
|
if card["block_kind"] == "queue":
|
|
3382
3674
|
return "block" if pending else None
|
|
3675
|
+
if starting:
|
|
3676
|
+
# 起跑窗口(`_starting_window`):占位已清、会话尚未证实起跑——此时的
|
|
3677
|
+
# busy=False 是「turn 还没开始」而非「已结束」,**不做忙/闲列映射**
|
|
3678
|
+
# (否则 doing 卡被判空闲搬去待审核;2026-10-07 卡 902/903 实障)。
|
|
3679
|
+
# 窗口内提问不可能出现(prompt 还没下发),仅作防御性放行。
|
|
3680
|
+
return "block" if pending else None
|
|
3383
3681
|
if pending:
|
|
3384
3682
|
if card["column_key"] == "blocked" and card["block_kind"] == "interaction":
|
|
3385
3683
|
return None # 已在交互阻塞,等待作答
|
|
@@ -3395,6 +3693,13 @@ def _reconcile_action_for(family, card, r, has_run=False, live_msg=False,
|
|
|
3395
3693
|
if card["column_key"] == "blocked" and not card["block_kind"]:
|
|
3396
3694
|
return "to_doing" if (running or has_run) else "to_review"
|
|
3397
3695
|
if has_run:
|
|
3696
|
+
# 在管运行:列流转归 _finish_run(防与轮收尾抢写)。唯一例外——卡片在
|
|
3697
|
+
# 「待审核」而会话确实在跑:只可能来自起跑窗口内的误判(本批次修复前的
|
|
3698
|
+
# 遗留形态:窗口内被搬去待审核、`_RUNS` 登记后早退不再自愈),按用户
|
|
3699
|
+
# 约定「运行中的卡都在正在开发列」归位。stop_recent 宽限内不搬:刚被
|
|
3700
|
+
# 平台停过会话(停止/拖离开发列)的卡,busy 可能是 abort 生效前的残留。
|
|
3701
|
+
if card["column_key"] == "review" and running and not stop_recent:
|
|
3702
|
+
return "to_doing"
|
|
3398
3703
|
return None # 在管运行:列流转归 _finish_run
|
|
3399
3704
|
if card["column_key"] == "doing":
|
|
3400
3705
|
return None if running else "to_review"
|
|
@@ -3405,7 +3710,8 @@ def _reconcile_action_for(family, card, r, has_run=False, live_msg=False,
|
|
|
3405
3710
|
|
|
3406
3711
|
def _iw_apply(family, card, action, r):
|
|
3407
3712
|
"""执行调和动作(read-verify-write)。写库前用 get_board_card 重读当前行,
|
|
3408
|
-
并重评 has_run/live_msg/stop_recent
|
|
3713
|
+
并重评 has_run/live_msg/stop_recent/起跑窗口(`_starting_window`)、重跑状态机:
|
|
3714
|
+
仅当动作仍成立才写库;
|
|
3409
3715
|
否则跳过——保护用户手动拖卡/其他写者不被调和窗口内的旧判定覆盖
|
|
3410
3716
|
(事件快照到落库之间卡状态可能已变/已删;live_msg 实时重取
|
|
3411
3717
|
——消息单元刚结束/被取消时状态机结论可能翻转为不动;stop_recent 实时
|
|
@@ -3425,7 +3731,8 @@ def _iw_apply(family, card, action, r):
|
|
|
3425
3731
|
live_msg = chat.live_of_sid((cur["session_id"] or "").strip())
|
|
3426
3732
|
if _reconcile_action_for(family, cur, r, has_run, live_msg,
|
|
3427
3733
|
_stop_recent(cur["id"]),
|
|
3428
|
-
dshevents.archived(cur["session_id"] or "")
|
|
3734
|
+
dshevents.archived(cur["session_id"] or ""),
|
|
3735
|
+
_starting_window(cur["id"])) != action:
|
|
3429
3736
|
return # 状态已变:跳过,不写库
|
|
3430
3737
|
if action == "to_done":
|
|
3431
3738
|
# dsh 侧归档 → 卡片进「已完成」(2026-10-05 反向规则):走容器迁移原语收口
|
|
@@ -3553,6 +3860,8 @@ def _iw_once():
|
|
|
3553
3860
|
now = int(time.time() * 1000)
|
|
3554
3861
|
ext_changed = False
|
|
3555
3862
|
seen_sids = set() # 本轮在场的归档判定 sid(_ARCH_SEEN 有界清理用)
|
|
3863
|
+
# 起跑窗口批量读口(一轮一次;逐卡点查会成 N+1,见 `_starting_window`)
|
|
3864
|
+
starting_ids = waitq.starting_card_ids()
|
|
3556
3865
|
for proj in db.list_projects_all():
|
|
3557
3866
|
if proj["archived"]:
|
|
3558
3867
|
continue
|
|
@@ -3593,14 +3902,25 @@ def _iw_once():
|
|
|
3593
3902
|
continue # 无会话(从未起跑的真排队卡等)无可探测
|
|
3594
3903
|
r = _iw_interaction(fam, proj, sid)
|
|
3595
3904
|
if r is None:
|
|
3596
|
-
|
|
3905
|
+
# 实况读不到:两种语义必须分开(2026-10-07 实障修复,卡 870)——
|
|
3906
|
+
# ① 中枢**断连**=真未知:保行(宁多等不可误放行,原口径);
|
|
3907
|
+
# ② 中枢**在线**而注册表无此 sid = 该会话已不在 dsh 会话池
|
|
3908
|
+
# (`session/disposed` 摘条目,或重连对齐时被 `/live` 快照
|
|
3909
|
+
# 覆盖掉)⇒ 外部会话已结束:不 hold,交本轮
|
|
3910
|
+
# `_ext_stale_finish` 按「不在目标集」收口。
|
|
3911
|
+
# 旧口径把两者混为一谈,一律 hold 保行——子代理会话留下的 ext
|
|
3912
|
+
# 行因此永久占位(项目常驻「有外部会话在跑」⇒ 永不补位,实障
|
|
3913
|
+
# 里卡 870 排队 5 小时、项目 119 积 10 条僵尸行)。
|
|
3914
|
+
if not dshevents.connected() and not _platform_holds(card["id"]) \
|
|
3915
|
+
and not _subagent_session(sid):
|
|
3597
3916
|
ext_hold.add(card["id"]) # 占用未知:保留既有行(old 探针口径)
|
|
3598
3917
|
continue # 实况读不到:该卡本轮跳过,不搬列
|
|
3599
3918
|
# —— 外部条目占用候选(v2d T4):busy 且非挂起、且平台不持有 ——
|
|
3600
3919
|
# 挂起(pending)不算占用(豁免面②:挂起即出队,不建行);平台
|
|
3601
3920
|
# 持有者(活跃 c: 行/在管条目)自占前缀位,不建行(防 ext:/c: 双计)。
|
|
3602
3921
|
if r.get("busy") and not r.get("pending") \
|
|
3603
|
-
and not _platform_holds(card["id"])
|
|
3922
|
+
and not _platform_holds(card["id"]) \
|
|
3923
|
+
and not _subagent_session(sid, r.get("origin")):
|
|
3604
3924
|
ext_busy[card["id"]] = sid
|
|
3605
3925
|
if _has_active_run(card["id"]):
|
|
3606
3926
|
# 平台在管运行卡:行心跳 + 证据(v3c:证据迁行,直调;
|
|
@@ -3615,7 +3935,9 @@ def _iw_once():
|
|
|
3615
3935
|
live_msg = chat.live_of_sid(sid) # 本卡消息在跑/排队(消息驱动的会话)
|
|
3616
3936
|
action = _reconcile_action_for(fam, card, r,
|
|
3617
3937
|
_has_active_run(card["id"]), live_msg,
|
|
3618
|
-
_stop_recent(card["id"]), arch
|
|
3938
|
+
_stop_recent(card["id"]), arch,
|
|
3939
|
+
_starting_window(card["id"],
|
|
3940
|
+
starting_ids))
|
|
3619
3941
|
if action:
|
|
3620
3942
|
_iw_apply(fam, card, action, r)
|
|
3621
3943
|
except Exception:
|
package/chat.py
CHANGED
|
File without changes
|
package/db.py
CHANGED
|
@@ -1089,7 +1089,7 @@ def update_board_card(card_id, mark_unread=True, **fields):
|
|
|
1089
1089
|
|
|
1090
1090
|
|
|
1091
1091
|
def mark_card_viewed(card_id):
|
|
1092
|
-
"""
|
|
1092
|
+
"""清卡片「状态有更新·用户未打开」标记(用户打开卡片详情 / 点卡面操作行按钮时调用)。
|
|
1093
1093
|
|
|
1094
1094
|
只写 `unread=0`,**刻意不刷 updated_at**:查看不是卡片内容变更,顺带刷时间
|
|
1095
1095
|
会污染「更新新→旧」排序口径与 `list_queued_board_cards` 的排队顺序(该查询
|
package/dsh-plugin/install.sh
CHANGED
|
File without changes
|