@gavinc-cn/touchstone-dsh 0.1.1 → 0.3.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/README.md +39 -11
- package/README.zh.md +25 -10
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6154 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2053 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1738 -0
- package/backend/lib/feishu_cfg.js +228 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +1674 -0
- package/backend/lib/feishu_provision.js +855 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +405 -0
- package/backend/lib/proc.js +278 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4520 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +237 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1220 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +215 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +297 -0
- package/backend/routes/projects.js +1059 -0
- package/backend/routes/sessions.js +946 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +498 -0
- package/board.py +805 -72
- package/chat.py +590 -17
- package/db.py +356 -140
- package/dsh-plugin/cordis.patch.yml +7 -3
- package/dsh-plugin/install.sh +28 -11
- package/dsh-plugin/lib/agent-driver.js +710 -89
- package/dsh-plugin/lib/client.bundle.js +94 -9
- package/dsh-plugin/lib/index.js +529 -39
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/src/client.js +94 -9
- package/dshdriver.py +74 -5
- package/dshevents.py +65 -3
- package/feishu.py +1197 -34
- package/feishu_conv.py +920 -0
- package/lifecycle.py +116 -13
- package/package.json +6 -1
- package/requirements.txt +2 -1
- package/server.py +218 -26
- package/sessparse.py +150 -44
- package/touchstone.py +6 -3
- package/waitq.py +23 -0
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
- package/webui/dist-plugin/assets/index-UrMyeAXF.js +484 -0
- package/webui/dist-plugin/index.html +2 -2
- package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
- package/webui/dist-plugin/assets/index-DtwKoi1T.js +0 -473
package/board.py
CHANGED
|
@@ -96,6 +96,30 @@ def card_workspace(project, card):
|
|
|
96
96
|
return wt or project["project_dir"]
|
|
97
97
|
|
|
98
98
|
|
|
99
|
+
def card_ts_ms(ts_str):
|
|
100
|
+
"""库内时间串(`YYYY-MM-DD HH:MM:SS`,db.now_str 口径=**服务器本地时间**)→ 毫秒 epoch。
|
|
101
|
+
|
|
102
|
+
卡面「最后更新时间」(2026-10-10 批次)的下发形态:库里存的是无时区标记的本地
|
|
103
|
+
时间串(本容器=UTC,Windows 原生运行=+8),前端若直接 `new Date(串)` 会按浏览器
|
|
104
|
+
本地时区解析 ⇒ 在 UTC 服务器 + 北京浏览器下整整差 8 小时。故一律在服务端换算成
|
|
105
|
+
epoch(与前端既有的 `scheduled_at`/`last_error_at` 同口径),前端只管按浏览器
|
|
106
|
+
时区渲染。
|
|
107
|
+
|
|
108
|
+
解释口径必须与写入口径互逆:`db.now_str` 用 `time.strftime`(本地时间),此处用
|
|
109
|
+
`time.mktime`(同按本地时区反解)——Linux(UTC)/Windows(+8) 下都自洽,见
|
|
110
|
+
tests/test_board_card_time.py 的 TZ 钉子用例。
|
|
111
|
+
|
|
112
|
+
坏值/空值返回 None(前端不渲染该小字),绝不抛异常打穿读板路径;不用 0 兜底
|
|
113
|
+
(0 会被前端当 1970 年渲染)。
|
|
114
|
+
"""
|
|
115
|
+
if not ts_str:
|
|
116
|
+
return None
|
|
117
|
+
try:
|
|
118
|
+
return int(time.mktime(time.strptime(ts_str, "%Y-%m-%d %H:%M:%S"))) * 1000
|
|
119
|
+
except (TypeError, ValueError, OverflowError, OSError):
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
99
123
|
def card_json(row, running=False, busy=False, msg_queued=False, queue_state=None):
|
|
100
124
|
"""卡片 Row -> 前端 dict。running=平台在管运行;busy=会话级运行
|
|
101
125
|
(平台运行或外部 busy——用户在 dsh GUI 直跑的同步卡会话,前端徽标
|
|
@@ -112,7 +136,11 @@ def card_json(row, running=False, busy=False, msg_queued=False, queue_state=None
|
|
|
112
136
|
(queue_state_of 判定序最高级即 answer 在场 ⟺ answer_pending,免二次点查)。
|
|
113
137
|
worktree(2026-10-06 批次):独立 worktree 卡的落点绝对路径,空串=普通卡
|
|
114
138
|
(前端据此渲染 🌿 徽标与详情行;只写不改口径见 plan D3)——经 `_card_opt`
|
|
115
|
-
读取,兼容无该列的老库。
|
|
139
|
+
读取,兼容无该列的老库。
|
|
140
|
+
updated_at_ts(2026-10-10 批次):`updated_at` 的毫秒 epoch 形态(`card_ts_ms`
|
|
141
|
+
换算,服务器本地时间解释),供卡面渲染「最后更新时间」的相对时间/本地绝对时间;
|
|
142
|
+
语义务必与 `updated_at` 一致=**卡片行最后被改写的时刻**(agent 在会话里干活不刷
|
|
143
|
+
它,会话活动时间是另一回事)。坏值/空值 ⇒ None,前端不渲染该小字。"""
|
|
116
144
|
if queue_state is None:
|
|
117
145
|
answer = is_answer_pending(row["id"])
|
|
118
146
|
queue_state = queue_state_of(row, running, busy, msg_queued, answer=answer)
|
|
@@ -130,6 +158,7 @@ def card_json(row, running=False, busy=False, msg_queued=False, queue_state=None
|
|
|
130
158
|
"last_error": row["last_error"], "last_error_at": row["last_error_at"],
|
|
131
159
|
"worktree": _card_opt(row, "worktree"),
|
|
132
160
|
"created_at": row["created_at"], "updated_at": row["updated_at"],
|
|
161
|
+
"updated_at_ts": card_ts_ms(row["updated_at"]),
|
|
133
162
|
# 状态有更新·用户未打开(2026-10-07 批次):卡面打「有更新」标记的依据,
|
|
134
163
|
# 置位/清除全在 db 层派生维护,读侧只透传布尔值。
|
|
135
164
|
"unread": bool(_card_opt(row, "unread") or 0),
|
|
@@ -2009,10 +2038,13 @@ def start_card(project, card, extra=""):
|
|
|
2009
2038
|
return _start_web(project, card, family, extra)
|
|
2010
2039
|
|
|
2011
2040
|
|
|
2012
|
-
def deliver_comment(project, card, comment_row, inject=False
|
|
2013
|
-
"""
|
|
2014
|
-
|
|
2015
|
-
|
|
2041
|
+
def deliver_comment(project, card, comment_row, inject=False):
|
|
2042
|
+
"""评论投递主会话(恒发评论原文,不包任何前缀——2026-10-10 去前缀)。
|
|
2043
|
+
|
|
2044
|
+
2026-09-06 起会话详情页路径 `raw=True` 直发原文、卡片评论区「投递」仍包
|
|
2045
|
+
「【看板评论】任务「标题」: 」前缀以区分消息来源;2026-10-10 用户裁定**所有
|
|
2046
|
+
投递路径一律原文**(前缀在会话里是噪音)——前缀构造与 `raw` 开关一并删除,
|
|
2047
|
+
`sent_text` 照旧落实际发送文本(= 评论原文,评论记录仍可追溯)。
|
|
2016
2048
|
|
|
2017
2049
|
2026-09-10(统一队列消息单元):不再立即投递——登记会话消息单元进 runner
|
|
2018
2050
|
统一队列,项目忙(同项目有任务/卡片会话在跑)时按入队顺序等待,项目空闲才
|
|
@@ -2023,10 +2055,7 @@ def deliver_comment(project, card, comment_row, inject=False, raw=False):
|
|
|
2023
2055
|
"""
|
|
2024
2056
|
family = _web_family(project)
|
|
2025
2057
|
sid = card["session_id"]
|
|
2026
|
-
|
|
2027
|
-
wrapped = comment_row["text"]
|
|
2028
|
-
else:
|
|
2029
|
-
wrapped = f"【看板评论】任务「{card['title']}」: {comment_row['text']}"
|
|
2058
|
+
text = comment_row["text"]
|
|
2030
2059
|
# 进队前的前置校验:拦住必然失败的投递(无会话 / 退场族无投递通道)
|
|
2031
2060
|
if family is None:
|
|
2032
2061
|
raise RuntimeError(agents.RETIRED_MSG)
|
|
@@ -2035,12 +2064,12 @@ def deliver_comment(project, card, comment_row, inject=False, raw=False):
|
|
|
2035
2064
|
inst = runner.INSTANCE
|
|
2036
2065
|
if inst is None:
|
|
2037
2066
|
# 无统一队列(单测/独立脚本):保持既有立即投递行为
|
|
2038
|
-
_deliver_now(project, card, comment_row,
|
|
2067
|
+
_deliver_now(project, card, comment_row, text, inject)
|
|
2039
2068
|
return None
|
|
2040
2069
|
# 排队中的消息可「立即注入」(见 chat.inject_now):family/model/
|
|
2041
2070
|
# comment_id 提交时定格进等待项 meta(chat_msgs 零加列,P3 裁决 R1),执行时
|
|
2042
2071
|
# 由 chat 侧按行+快照重建 _deliver_unit/_deliver_now
|
|
2043
|
-
return chat.submit(project["id"], sid,
|
|
2072
|
+
return chat.submit(project["id"], sid, text,
|
|
2044
2073
|
card_id=card["id"], comment_id=comment_row["id"],
|
|
2045
2074
|
inject=inject,
|
|
2046
2075
|
family=runner.agent_family(project["agent_path"] or ""),
|
|
@@ -2048,19 +2077,19 @@ def deliver_comment(project, card, comment_row, inject=False, raw=False):
|
|
|
2048
2077
|
or (project["model"] or "").strip())
|
|
2049
2078
|
|
|
2050
2079
|
|
|
2051
|
-
def _deliver_unit(project, card, comment_row,
|
|
2080
|
+
def _deliver_unit(project, card, comment_row, text, inject):
|
|
2052
2081
|
"""统一队列消息单元执行体:投递评论并等该会话结束(执行期间持有项目占用)。
|
|
2053
2082
|
|
|
2054
|
-
返回即释放占用:dsh
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2083
|
+
返回即释放占用:dsh 会话的等轮次走 `chat.wait_turn` 分流口(外部会话订阅全局
|
|
2084
|
+
状态流、平台自持会话走既有按会话 SSE,规则见该函数);返回
|
|
2085
|
+
chat.STATE_YIELDED = turn 挂起等作答、已让位(chat.run_unit 据此落终态)。
|
|
2086
|
+
尾部 proc 分支为 CLI 时代的防御残留(单族化后 _RUNS 条目恒为 dsh,proc=None)。
|
|
2058
2087
|
"""
|
|
2059
|
-
_deliver_now(project, card, comment_row,
|
|
2088
|
+
_deliver_now(project, card, comment_row, text, inject)
|
|
2060
2089
|
family = _web_family(project)
|
|
2061
2090
|
if family is not None:
|
|
2062
|
-
return chat.
|
|
2063
|
-
|
|
2091
|
+
return chat.wait_turn(project["project_dir"], card["session_id"],
|
|
2092
|
+
family)
|
|
2064
2093
|
with _runs_lock:
|
|
2065
2094
|
rec = _RUNS.get(card["id"])
|
|
2066
2095
|
proc = rec.get("proc") if rec is not None else None
|
|
@@ -2068,25 +2097,79 @@ def _deliver_unit(project, card, comment_row, wrapped, inject):
|
|
|
2068
2097
|
proc.wait()
|
|
2069
2098
|
|
|
2070
2099
|
|
|
2071
|
-
|
|
2072
|
-
|
|
2100
|
+
# 卡片会话已不在宿主时的投递文案(用户可见):不暴露「驱动池 / sid」这类内部
|
|
2101
|
+
# 链路词,只给「发生了什么 + 怎么办」。sync 卡与「接不回」两种情形共用一句
|
|
2102
|
+
# ——两者对用户的可执行动作相同(点「开始」重新拉起会话)。
|
|
2103
|
+
LOST_SESSION_MSG = ("会话已结束(宿主无活动 agent),消息未送达:"
|
|
2104
|
+
"请点「开始」重新拉起会话后再发送")
|
|
2105
|
+
|
|
2106
|
+
|
|
2107
|
+
def _ensure_card_session(project, card, sid):
|
|
2108
|
+
"""投递前确保宿主仍持有该卡会话(宿主已失去 ⇒ 按 resume 接回);返回可否投递。
|
|
2109
|
+
|
|
2110
|
+
报障现场(卡 934,2026-10-10):插件热重载把宿主里所有会话 dispose 掉之后,
|
|
2111
|
+
卡片 `session_id` 仍在库、卡照旧在列,用户发评论 / 点「立即注入」时驱动回
|
|
2112
|
+
404「会话不在驱动池中」——前端只能把这条内部文案原样 toast 出来。
|
|
2113
|
+
|
|
2114
|
+
判定阶梯(与调和器「会话确已结束」同源,见 `chat.host_session_lost`):
|
|
2115
|
+
① 宿主仍有该会话(注册表命中 / 中枢未对齐=未知)⇒ 放行,**零请求**,
|
|
2116
|
+
既有路径一个字节不变;
|
|
2117
|
+
② 确已失去 ∧ 平台自建卡(`origin` 非 `sync`)⇒ `chat.revive_host_session`
|
|
2118
|
+
按 `_start_web` 的续接语义 resume 回池(同一张卡、同一个 sid、同一上下文);
|
|
2119
|
+
③ 确已失去 ∧ sync 卡(`origin='sync'`=用户在 dsh GUI 直跑的会话)⇒ **不接回**:
|
|
2120
|
+
C 批设计「只投递不接管」,平台不擅自把用户的会话收养进自己的池,直接报
|
|
2121
|
+
明确文案(用户点「开始」=显式收养,那是既有路径)。
|
|
2122
|
+
接不回(会话文件缺失 / 驱动不可达)同样返回 False,由调用方报同一句文案。
|
|
2123
|
+
cwd 取 `card_workspace`(worktree 卡的会话必须接回它的工作树)。
|
|
2124
|
+
"""
|
|
2125
|
+
if not chat.host_session_lost(sid):
|
|
2126
|
+
return True # 会话在场/未知:不动作(零请求)
|
|
2127
|
+
cid = _card_opt(card, "id")
|
|
2128
|
+
row = db.get_board_card(cid) if cid else None
|
|
2129
|
+
origin = str(_card_opt(row if row is not None else card, "origin") or "")
|
|
2130
|
+
if origin == "sync":
|
|
2131
|
+
return False # 外部会话卡:不接管(见上 ③)
|
|
2132
|
+
return chat.revive_host_session(sid, cwd=card_workspace(project, row or card),
|
|
2133
|
+
task=f"card-{cid}")
|
|
2134
|
+
|
|
2135
|
+
|
|
2136
|
+
def _deliver_now(project, card, comment_row, text, inject=False):
|
|
2137
|
+
"""评论真正送达(立即路径与统一队列消息单元执行体共用;text 即评论原文)。
|
|
2073
2138
|
|
|
2074
2139
|
单族化后只有 dsh_plugin:followup 忙时排进 agent inbox(等同服务端排队
|
|
2075
2140
|
语义,不拒绝);inject=True 走 steer,注入当前 turn 的最近 step 边界
|
|
2076
2141
|
(steer 失败不致命,评论仍在 inbox/队列)。退场族无投递通道,直接报错。
|
|
2142
|
+
送达前两道人造兜底(顺序固定,都在 `_web_send` 之前):
|
|
2143
|
+
① **会话存活**(2026-10-10,修卡 934):宿主被插件热重载/重启清过之后,
|
|
2144
|
+
卡会话可能已不存在——`_ensure_card_session` 先接回再投递,接不回则报
|
|
2145
|
+
`LOST_SESSION_MSG`(可执行指引),不再把驱动 404 原文案抛给用户;
|
|
2146
|
+
② 重声明看管(C 批终审 I2,2026-10-10):本函数是评论**推送腿**的唯一出口,
|
|
2147
|
+
`_watch_prune` 的撤销判据只看活跃 `m:` 行(不看推送腿本身)。真实入口=平台
|
|
2148
|
+
重启后 `_rebuild_run → _deliver_unit → _deliver_now` 直投:`_WATCHED` 随进程
|
|
2149
|
+
清空、驱动侧 `watched` 也可能随插件重载清空 ⇒ 不补声明就撞驱动「未看管」404、
|
|
2150
|
+
`m:` 行落 error。与 `_answer_deliver` 同源同判据(`_watch_before_delivery`
|
|
2151
|
+
内部只对 `owned:false` 的池外会话声明,命中缓存零请求;池内/未知路径一个
|
|
2152
|
+
字节不变)。
|
|
2077
2153
|
"""
|
|
2078
2154
|
family = _web_family(project)
|
|
2079
2155
|
sid = card["session_id"]
|
|
2080
2156
|
if family is not None:
|
|
2081
2157
|
try:
|
|
2158
|
+
# 会话存活兜底(见上 ①):卡会话已被宿主丢掉时先 resume 接回再投递
|
|
2159
|
+
if not _ensure_card_session(project, card, sid):
|
|
2160
|
+
raise RuntimeError(LOST_SESSION_MSG)
|
|
2161
|
+
# 送达前重声明看管(C 批终审 I2):外部会话的看管可能已被 `_watch_prune`
|
|
2162
|
+
# 撤掉(平台重启后记账为空、驱动侧重载后 watched 为空),漏声明则下面
|
|
2163
|
+
# 这条驱动调用撞「未看管」404(见上)
|
|
2164
|
+
_watch_before_delivery(sid)
|
|
2082
2165
|
# dsh 的「立即注入」= steer(注入当前 turn 的最近 step 边界);
|
|
2083
2166
|
# 普通投递 = followup(忙时排进 inbox,等同服务端排队);
|
|
2084
2167
|
# 统一走 _web_send(单族投递唯一出口,测试打桩面也在此)
|
|
2085
|
-
_web_send(family, project, sid,
|
|
2168
|
+
_web_send(family, project, sid, text, inject=bool(inject))
|
|
2086
2169
|
except dshdriver.DshDriverError as e:
|
|
2087
2170
|
raise RuntimeError(f"评论投递失败: {e}")
|
|
2088
2171
|
db.update_board_comment(comment_row["id"], sent=1, session_id=sid,
|
|
2089
|
-
sent_text=
|
|
2172
|
+
sent_text=text)
|
|
2090
2173
|
return
|
|
2091
2174
|
# 退场族无投递通道(单族化):明确报错,不落已删除的 CLI 起子进程路径
|
|
2092
2175
|
raise RuntimeError(agents.RETIRED_MSG)
|
|
@@ -2212,6 +2295,15 @@ def compact_session(project, card, sid):
|
|
|
2212
2295
|
命令),触发即返回——压缩在 dsh 进程内异步进行(要过一次模型),失败落
|
|
2213
2296
|
宿主日志(原 kimi CLI 子进程压缩与两族 web REST 压缩已随族退场)。
|
|
2214
2297
|
|
|
2298
|
+
**池外两跳(2026-10-10 修「卡片 945 无法 compact」)**:宿主侧插件重载/dsh web
|
|
2299
|
+
重启后,平台自建的卡会话在驱动里只剩**观察**(`/live` 的 `owned:false`,宿主里
|
|
2300
|
+
仍活着)——旧实现只认驱动池,compact 必 404「会话不在驱动池中」,前端原样 toast。
|
|
2301
|
+
这里按 `rename_card_session` 同款两跳:首跳 404(=池外)⇒ `_ensure_watch(sid)` 补
|
|
2302
|
+
看管声明(幂等、**只声明不接管**)后重试一次;驱动侧 `/compact` 的池外回落分支按
|
|
2303
|
+
「看管 + 宿主有活 agent」放行(node 半同批实现)。为什么不能「先接管再压缩」:
|
|
2304
|
+
外部会话的 agent 已在宿主 store 注册,`/session` resume 必撞
|
|
2305
|
+
`agent "…" is already registered` ⇒ 500(C 批 T1 实测)。
|
|
2306
|
+
|
|
2215
2307
|
sid 必须属于该卡片(sessions 清单或主 session_id),不支持的族抛
|
|
2216
2308
|
RuntimeError(server 转 400)。
|
|
2217
2309
|
"""
|
|
@@ -2225,10 +2317,25 @@ def compact_session(project, card, sid):
|
|
|
2225
2317
|
# 路线 A P3(2026-10-03):走驱动 `/compact`(插件侧执行宿主 `/compact`
|
|
2226
2318
|
# 命令;`ctx.compaction` 服务在插件 ctx 不可注入,命令路径是 P0 探针的结论)。
|
|
2227
2319
|
# 触发即返回——压缩在 dsh 进程内异步进行(要过一次模型),失败落宿主日志。
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2320
|
+
last = ""
|
|
2321
|
+
for attempt in (1, 2):
|
|
2322
|
+
try:
|
|
2323
|
+
dshdriver.compact(sid)
|
|
2324
|
+
return
|
|
2325
|
+
except dshdriver.DshDriverError as e:
|
|
2326
|
+
last = str(e)
|
|
2327
|
+
pool_miss = getattr(e, "code", None) == 404
|
|
2328
|
+
# 只在首跳 404(池外)补一次看管声明;**force=True** 跳过平台侧陈旧记账
|
|
2329
|
+
# (驱动侧重载会清空 `watched` 表,`_WATCHED` 却仍写着「声明过」)。
|
|
2330
|
+
# 已看管但宿主无活 agent 的 404 重试同样失败,代价只是一次请求,换来判定
|
|
2331
|
+
# 不依赖文案匹配;且第二跳的文案更准确(「会话已结束(宿主无活动 agent)」
|
|
2332
|
+
# 取代含糊的「不在驱动池中」)。
|
|
2333
|
+
if attempt == 1 and pool_miss and _ensure_watch(sid, force=True):
|
|
2334
|
+
print(f"[board] 卡片 compact:池外会话补看管声明后重试 sid={sid}",
|
|
2335
|
+
flush=True)
|
|
2336
|
+
continue
|
|
2337
|
+
break
|
|
2338
|
+
raise RuntimeError(f"compact 失败: {last}")
|
|
2232
2339
|
|
|
2233
2340
|
|
|
2234
2341
|
def fork_compact_session(project, card, sid):
|
|
@@ -2691,10 +2798,86 @@ def _reconcile_starting_rows():
|
|
|
2691
2798
|
f"(claimed_at={claimed})", flush=True)
|
|
2692
2799
|
|
|
2693
2800
|
|
|
2801
|
+
def _heal_queue_placeholders():
|
|
2802
|
+
"""排队占位缺等待项行自愈(30s 全量对账节拍;2026-10-10 实障卡 936)。
|
|
2803
|
+
|
|
2804
|
+
背景:卡片停在「正在开发」+`block_kind='queue'`(前端「排队中」徽标的**唯一**
|
|
2805
|
+
来源,见 `queue_state_of`),但 wait_items 里没有对应的活跃 c: 行。调度权威是
|
|
2806
|
+
行——`runner._pick_locked` 只遍历等待项行,行不在场 ⇒ 补位器永远拾不到该卡,
|
|
2807
|
+
卡片永久僵在排队态(除用户重按「开始」的幂等补建或服务重启 `board.recover`
|
|
2808
|
+
尾段补建外**无任何自愈出口**),项目看着空闲而队列不动。本函数把 `recover`
|
|
2809
|
+
尾段那条「占位卡缺等待项行就补建」的对账规则**周期化**:逐张排队占位卡核对,
|
|
2810
|
+
行缺失即经 `runner.submit_card` 补建(幂等:行仍在则 `enqueue_card` 复用既有
|
|
2811
|
+
行、位次不动;补建落等待区末尾,与 recover 同几何——原排队位次已不可考)。
|
|
2812
|
+
|
|
2813
|
+
跳过四类(都不是「行丢了」,补行反而有害):
|
|
2814
|
+
- 已答待送达(活跃 a: 行在场):等待单元是 answer 行,本就不建 c: 行
|
|
2815
|
+
(`_queue_answer_unit` 单写口径),补 c: 行会让未送达答案与新一轮起跑打架;
|
|
2816
|
+
- 独立 worktree 卡:起跑不入队、不落行(spec §47),`block_kind='queue'`
|
|
2817
|
+
对它是残留投影,补行等于把它塞回统一队列;
|
|
2818
|
+
- 平台在管(`_has_active_run`):会话在跑、运行行缺失归收尾/调和器兜底,
|
|
2819
|
+
此处补行会与在跑会话并发起第二轮;
|
|
2820
|
+
- **会话实况在跑**(卡有 sid 且 `dshevents` 注册表报 `running`):占位卡也可能是
|
|
2821
|
+
「会话被外部直跑/重启后行丢失」形态(恢复运行的行未重建),补行会让补位器起跑
|
|
2822
|
+
同一会话再投一轮 prompt——行缺失交调和器/收尾路径处理,本函数只补「确实没人在
|
|
2823
|
+
跑」的卡(单族化后状态读口零请求、本地注册表;断连/未知不在此列,照常补建)。
|
|
2824
|
+
|
|
2825
|
+
检测到的异常形态各落一行诊断(`[board] 排队占位缺等待项行(占位在、行不在)…`);
|
|
2826
|
+
异常逐卡吞(补建失败也只留一行诊断),不影响其他卡;返回本次补建条数。
|
|
2827
|
+
`runner` 单例缺位(调试退化)时只报告不自愈——日志仍如实留痕。"""
|
|
2828
|
+
inst = runner.INSTANCE
|
|
2829
|
+
try:
|
|
2830
|
+
queued = db.list_queued_board_cards()
|
|
2831
|
+
except Exception as e: # noqa: BLE001
|
|
2832
|
+
print(f"[board] 排队占位自愈扫描失败: {e}", flush=True)
|
|
2833
|
+
return 0
|
|
2834
|
+
healed = 0
|
|
2835
|
+
for card in queued:
|
|
2836
|
+
try:
|
|
2837
|
+
cid = int(card["id"])
|
|
2838
|
+
except (TypeError, ValueError):
|
|
2839
|
+
continue # 脏 id:理论项,跳过
|
|
2840
|
+
try:
|
|
2841
|
+
if (_card_opt(card, "worktree") or "").strip():
|
|
2842
|
+
continue # worktree 卡不入队(§47)
|
|
2843
|
+
if waitq.get_active(waitq.KIND_CARD, cid) is not None:
|
|
2844
|
+
continue # 行在场:正常排队,不动
|
|
2845
|
+
if waitq.get_active(waitq.KIND_ANSWER, cid) is not None:
|
|
2846
|
+
continue # 已答待送达:单元是答案行
|
|
2847
|
+
if _has_active_run(cid):
|
|
2848
|
+
continue # 会话在跑:行归收尾路径
|
|
2849
|
+
sid = (card["session_id"] or "").strip()
|
|
2850
|
+
if sid:
|
|
2851
|
+
try:
|
|
2852
|
+
st = dshevents.get(sid) or {}
|
|
2853
|
+
except Exception: # noqa: BLE001 — 读口异常按未知
|
|
2854
|
+
st = {}
|
|
2855
|
+
if st.get("status") == "running":
|
|
2856
|
+
continue # 实况在跑:补行会重复投递
|
|
2857
|
+
# 占位在、行不在:`enqueue_card` 单事务红线(行+占位原子)声称此形态
|
|
2858
|
+
# 「从结构上消失」,出现即异常——先落一行异常报告,再补建
|
|
2859
|
+
print(f"[board] 排队占位缺等待项行(占位在、行不在):c:{cid}"
|
|
2860
|
+
f"(项目 {card['project_id']})", flush=True)
|
|
2861
|
+
if inst is None:
|
|
2862
|
+
continue # runner 缺位:只报告
|
|
2863
|
+
inst.submit_card(cid)
|
|
2864
|
+
healed += 1
|
|
2865
|
+
except Exception as e: # noqa: BLE001
|
|
2866
|
+
print(f"[board] 排队占位补建失败:c:{cid}: {e}", flush=True)
|
|
2867
|
+
return healed
|
|
2868
|
+
|
|
2869
|
+
|
|
2694
2870
|
def _watch_once():
|
|
2695
2871
|
"""一轮巡视:结束会话收尾(_watch_runs_once)+ 定时到点开工(被门禁拦则
|
|
2696
|
-
清定时记错误;起会话失败则把卡置回 todo,避免僵在 doing
|
|
2872
|
+
清定时记错误;起会话失败则把卡置回 todo,避免僵在 doing 无进程)+
|
|
2873
|
+
排队占位缺行自愈(`_heal_queue_placeholders`,实障卡 936)。"""
|
|
2697
2874
|
_watch_runs_once()
|
|
2875
|
+
# 排队占位缺行自愈(30s 节拍):占位在、行不在 ⇒ 补位器永远拾不到,卡片
|
|
2876
|
+
# 僵在「排队中」而项目看着空闲。补建规则与 recover 尾段同源(幂等)。
|
|
2877
|
+
try:
|
|
2878
|
+
_heal_queue_placeholders()
|
|
2879
|
+
except Exception: # noqa: BLE001
|
|
2880
|
+
pass # 巡视不因自愈失败中断(下一拍再来)
|
|
2698
2881
|
# 定时扫描:todo 列到点卡片经统一入口进 doing(整库扫描,量小;一律入队
|
|
2699
2882
|
# (v2b T2 起 parallel 直起废除),门禁语义与手动开始一致)
|
|
2700
2883
|
now_ms = int(time.time() * 1000)
|
|
@@ -2763,9 +2946,20 @@ def _recover_web_card(proj, card):
|
|
|
2763
2946
|
`_recover_ext_rows` 按项目活跃会话集合重新对账(实况在→ext 行保持 running
|
|
2764
2947
|
入前缀、占位不丢),空闲时由调和器列映射回 review(≤5s,与收养路径同归)。
|
|
2765
2948
|
收养行为(建 _RUNS 条目)会把不可控会话伪装成平台
|
|
2766
|
-
在管单元——ext 行被 `_platform_holds` 抑制。
|
|
2949
|
+
在管单元——ext 行被 `_platform_holds` 抑制。
|
|
2950
|
+
|
|
2951
|
+
就绪闸(2026-10-08 批次 C,bug_report/20261008_1935):本函数在启动序里必然
|
|
2952
|
+
早于 `dshevents.start()`(server.py 里 board.recover 先于 dshevents.start),
|
|
2953
|
+
此刻读任何会话都是「未知」;插件热重载后还会叠加「驱动实例重建、/live 空」
|
|
2954
|
+
——旧口径把「未知」当「空闲」,于是**正在运行的卡**被搬去待审核,且此后
|
|
2955
|
+
调和器读不到该会话、自愈无门(实测卡 915)。现在:有 sid 而中枢不可信
|
|
2956
|
+
(`dshevents.aligned()` 为 False)⇒ **不搬列**(也不建 _RUNS),保留原列等
|
|
2957
|
+
中枢对齐后由调和器按实况归位(`_iw_once`:在场按忙/闲、缺席按「已结束」,
|
|
2958
|
+
列写的时机从「未知时」挪到「已知时」)。"""
|
|
2767
2959
|
family = _web_family(proj)
|
|
2768
2960
|
sid = (card["session_id"] or "").strip()
|
|
2961
|
+
if sid and not dshevents.aligned():
|
|
2962
|
+
return # 未知 ≠ 空闲:不搬列,交调和器在可信后收口
|
|
2769
2963
|
busy = False
|
|
2770
2964
|
if sid:
|
|
2771
2965
|
try:
|
|
@@ -3251,8 +3445,11 @@ def _archive_unarchive_edge(card):
|
|
|
3251
3445
|
# 最后活动,近似语义为「刚开始不久」)
|
|
3252
3446
|
SYNC_BUSY_MTIME_S = 120
|
|
3253
3447
|
|
|
3254
|
-
# sync 卡标题/描述口径(2026-10-
|
|
3255
|
-
#
|
|
3448
|
+
# sync 卡标题/描述口径(2026-10-10 改):标题 = DSH **当前**会话标题(末枚
|
|
3449
|
+
# `session/title` 事件,与 dsh GUI 侧栏 last-wins 一致——DSH 侧 LLM 自动标题 /
|
|
3450
|
+
# 用户改名后,同步节拍跟着改卡标题)、描述 = 主会话第一次用户提问**全文**。
|
|
3451
|
+
# 旧口径(2026-10-07~10-10:「首问首行进标题、其余行进描述」)已退场,存量卡由
|
|
3452
|
+
# `_sync_card_follow` 在首个同步拍回填(详见 board spec §48)。
|
|
3256
3453
|
SYNC_TITLE_MAX = 200 # 标题上限(沿用建卡既有截断:insert_board_card 调用处 [:200])
|
|
3257
3454
|
SYNC_DESC_MAX = 2000 # 描述上限(防一次长提问把看板负载撑大)
|
|
3258
3455
|
|
|
@@ -3261,13 +3458,24 @@ SYNC_DESC_MAX = 2000 # 描述上限(防一次长提问把看板负载撑大
|
|
|
3261
3458
|
# 不被反复软删(进程重启后重建空集,重跑一轮幂等)。
|
|
3262
3459
|
_SYNC_SUBAGENT_SWEPT = set()
|
|
3263
3460
|
|
|
3461
|
+
# 已跑过「存量误建卡软删收口」的项目 id(`sync_sessions` 尾巴,2026-10-09):
|
|
3462
|
+
# 历史版本建出的两类卡——**平台自己持有会话的卡**(B 类:任务 / 飞书会话被当成
|
|
3463
|
+
# 外部会话重复投影)与**空会话占位卡**(A 类:会话一个字都还没有就投影出
|
|
3464
|
+
# `session-xxxx`)——新版一律不再产生,存量按同款「每项目一轮 + 软删可还原」
|
|
3465
|
+
# 口径收口(理由与边界见 `sync_sessions` 尾巴注释)。
|
|
3466
|
+
_SYNC_MISBUILT_SWEPT = set()
|
|
3467
|
+
|
|
3264
3468
|
|
|
3265
3469
|
def _split_first_prompt(text):
|
|
3266
|
-
"""首问原文 → (
|
|
3470
|
+
"""首问原文 → (首行, 其余行):仅服务**回落标题**与**旧口径描述识别**。
|
|
3267
3471
|
|
|
3268
|
-
- 首行超 SYNC_TITLE_MAX
|
|
3269
|
-
-
|
|
3472
|
+
- 首行超 SYNC_TITLE_MAX 的溢出部分并入其余行开头(截断不丢内容);
|
|
3473
|
+
- 其余行超 SYNC_DESC_MAX 截断并补省略号;
|
|
3270
3474
|
- 原文为空 → ('', '')(调用方自行兜底标题)。
|
|
3475
|
+
|
|
3476
|
+
新口径下卡标题取 DSH 当前标题、描述取首问全文,本函数不再直接产生卡面字段;
|
|
3477
|
+
保留是因为两处仍要「旧口径形态」的字符串:无标题事件时以首问首行回落成标题
|
|
3478
|
+
(`_sync_card_fields`),以及判定卡描述是否仍是旧口径自动写入(`_sync_card_follow`)。
|
|
3271
3479
|
"""
|
|
3272
3480
|
text = (text or "").strip()
|
|
3273
3481
|
if not text:
|
|
@@ -3284,42 +3492,146 @@ def _split_first_prompt(text):
|
|
|
3284
3492
|
return first, rest
|
|
3285
3493
|
|
|
3286
3494
|
|
|
3495
|
+
def _full_first_prompt(text):
|
|
3496
|
+
"""首问原文 → 卡描述:首问**全文**(去首尾空白;超 SYNC_DESC_MAX 截断补省略号)。
|
|
3497
|
+
|
|
3498
|
+
与 `_split_first_prompt` 的「其余行」不同——不再丢掉首行(标题已由 DSH 会话
|
|
3499
|
+
标题承担,描述要的是完整提问)。
|
|
3500
|
+
"""
|
|
3501
|
+
raw = (text or "").strip()
|
|
3502
|
+
if not raw:
|
|
3503
|
+
return ""
|
|
3504
|
+
return raw[:SYNC_DESC_MAX] + "…" if len(raw) > SYNC_DESC_MAX else raw
|
|
3505
|
+
|
|
3506
|
+
|
|
3507
|
+
def _is_empty_session(item):
|
|
3508
|
+
"""空会话判定(A 类闸,2026-10-09):会话已存在但**还没有任何内容**。
|
|
3509
|
+
|
|
3510
|
+
「有内容」= 有首问(`first_prompt`)或有会话标题事件(`title`)——两者都空
|
|
3511
|
+
的会话就是用户刚在 dsh 侧建出来、一句话还没说的空壳。历史版本会给它建一张
|
|
3512
|
+
`session-xxxx` 占位卡(等首问落盘再补齐标题),对用户表现为「看板上凭空
|
|
3513
|
+
冒出一张没标题的卡」;新口径改为**不投影**,等首问/标题落盘后的下一个
|
|
3514
|
+
30s 节拍再建卡(届时标题口径直接正确,不再需要兜底与补齐两步)。
|
|
3515
|
+
|
|
3516
|
+
item 缺失(会话已不在本次枚举里,如存储被删)返回 False——那种情况交既有
|
|
3517
|
+
「存储被删→done」规则处理,本闸不表态。
|
|
3518
|
+
"""
|
|
3519
|
+
if not item:
|
|
3520
|
+
return False
|
|
3521
|
+
return not str(item.get("first_prompt") or "").strip() \
|
|
3522
|
+
and not str(item.get("title") or "").strip()
|
|
3523
|
+
|
|
3524
|
+
|
|
3525
|
+
def _is_placeholder_card(card, sids):
|
|
3526
|
+
"""卡面是否仍是「自动写入的占位形态」——存量收口的**前置硬闸**(2026-10-09 二版)。
|
|
3527
|
+
|
|
3528
|
+
占位形态 = 标题为空,或等于主会话 sid 短码(`sid[:12]`,即 `_sync_card_fields`
|
|
3529
|
+
的兜底名、`_sync_card_follow` 认的自动写入形态之一)。建卡时首问未落盘才会
|
|
3530
|
+
长这样;一旦有首问/任务提示词写进来,标题就成了真实内容。
|
|
3531
|
+
|
|
3532
|
+
为什么要有这道闸:存量收口的另两条判据(会话枚举是否为空 / sid 是否在
|
|
3533
|
+
tasks·飞书登记里)都依赖**易变的一次性输入**——首版上线真机首跑就因这种输入
|
|
3534
|
+
异常误删 13 张有真实标题的卡(918、697、709 等,事后复算判据全不成立)。
|
|
3535
|
+
卡面标题是卡自身的证据,不随会话解析成败摇摆;**真实标题的卡一律不自动收口**
|
|
3536
|
+
(宁漏不误删,漏下的交用户自己删)。
|
|
3537
|
+
"""
|
|
3538
|
+
title = (card["title"] or "").strip()
|
|
3539
|
+
if not title:
|
|
3540
|
+
return True
|
|
3541
|
+
# 标题 == 某个绑定会话的 sid 短码(`sids` 是集合,故用 any 而非取首元素)
|
|
3542
|
+
return any(title == str(s or "").strip()[:12] for s in sids if s)
|
|
3543
|
+
|
|
3544
|
+
|
|
3545
|
+
def _platform_owned_sids(project_id):
|
|
3546
|
+
"""本项目「平台自己持有会话位」的 sid 集合(B 类闸,2026-10-09)。
|
|
3547
|
+
|
|
3548
|
+
sync 卡的定位是**外部直跑会话**(用户在 dsh GUI 里直接跑、平台只是把它
|
|
3549
|
+
投影到看板)。平台自己建/持有的会话在平台侧已有入口,再投影一张卡就是
|
|
3550
|
+
重复,故建卡前剔除。两个来源:
|
|
3551
|
+
|
|
3552
|
+
- `tasks.session_id`:任务主会话(任务详情 / 日志里已有入口);
|
|
3553
|
+
- `feishu_bindings.cur_sid`:飞书通用对话当前绑定的会话(飞书侧有入口)。
|
|
3554
|
+
|
|
3555
|
+
看板卡自己的会话不用列——`sync_sessions` 的 bound 映射(卡片 sessions 并集)
|
|
3556
|
+
已经覆盖。站点会话窗发过消息的会话(`chat_msgs`)**不纳入**:那些会话常常
|
|
3557
|
+
正是 dsh GUI 建出来的外部会话(用户从会话窗投一句话而已),纳入会让它们
|
|
3558
|
+
永远建不出卡(含「建卡 30s 窗口内先投了消息」的竞态)。
|
|
3559
|
+
|
|
3560
|
+
一次查库、单轮复用;表缺失/结构演进按空集(归因失败绝不影响建卡)。
|
|
3561
|
+
"""
|
|
3562
|
+
sids = set()
|
|
3563
|
+
try:
|
|
3564
|
+
with db.connect() as conn:
|
|
3565
|
+
for sql, args in (
|
|
3566
|
+
("SELECT session_id AS sid FROM tasks"
|
|
3567
|
+
" WHERE project_id=? AND session_id<>''", (project_id,)),
|
|
3568
|
+
("SELECT cur_sid AS sid FROM feishu_bindings"
|
|
3569
|
+
" WHERE cur_project_id=? AND cur_sid<>''", (project_id,)),
|
|
3570
|
+
):
|
|
3571
|
+
try:
|
|
3572
|
+
sids.update(str(r["sid"]) for r in conn.execute(sql, args))
|
|
3573
|
+
except Exception:
|
|
3574
|
+
continue # 单表不可用只丢该项,不影响另一项与建卡
|
|
3575
|
+
except Exception:
|
|
3576
|
+
return set()
|
|
3577
|
+
return {s for s in sids if s}
|
|
3578
|
+
|
|
3579
|
+
|
|
3287
3580
|
def _sync_card_fields(item, sid):
|
|
3288
|
-
"""sync
|
|
3581
|
+
"""sync 卡平台自动写入形态的 (标题, 描述) 目标值。
|
|
3582
|
+
|
|
3583
|
+
标题 = **DSH 当前会话标题**(`item["title"]` = 末枚 `session/title` 事件,与
|
|
3584
|
+
dsh GUI 侧栏一致;无标题事件时回落首问首行、再回落 sid 短码);描述 = 首问
|
|
3585
|
+
**全文**。建卡与 `_sync_card_follow` 补齐共用同一口径(幂等)。
|
|
3289
3586
|
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
`_sync_card_follow`
|
|
3293
|
-
见 board spec「sync 会话归类」)。
|
|
3587
|
+
本函数只被**已通过空会话闸**的调用方调用——首问/标题两者皆空的会话根本不建卡
|
|
3588
|
+
(见 `_is_empty_session`);建卡后 DSH 侧标题自动更新(LLM 标题、用户改名)与
|
|
3589
|
+
首问落盘,都由 `_sync_card_follow` 在下一个同步节拍里跟上。
|
|
3294
3590
|
"""
|
|
3295
|
-
title
|
|
3591
|
+
title = (item.get("title") or "").strip()
|
|
3296
3592
|
if not title:
|
|
3297
|
-
title = (
|
|
3298
|
-
|
|
3593
|
+
title = _split_first_prompt(item.get("first_prompt") or "")[0]
|
|
3594
|
+
if not title:
|
|
3595
|
+
title = sid[:12]
|
|
3596
|
+
return title[:SYNC_TITLE_MAX], _full_first_prompt(item.get("first_prompt") or "")
|
|
3597
|
+
|
|
3598
|
+
|
|
3599
|
+
def _auto_title_forms(item, sid):
|
|
3600
|
+
"""平台**可能自动写入过**的卡标题集合(判「卡面标题是否仍属自动写入形态」)。
|
|
3601
|
+
|
|
3602
|
+
含:空、sid 短码、DSH 当前标题、**全部历史标题事件**、旧口径写入的首问首行。
|
|
3603
|
+
历史标题事件一项是为「建卡时只有兜底标题、LLM 标题后到」的窗口准备的——那时
|
|
3604
|
+
平台写进卡面的兜底标题已不是当前标题,只存在于会话的标题事件历史里;用户手工
|
|
3605
|
+
改过的标题不在此集合 ⇒ 整卡不再自动覆盖(尊重用户在卡面的改名)。
|
|
3606
|
+
"""
|
|
3607
|
+
forms = {"", sid[:12]}
|
|
3608
|
+
forms.add(((item.get("title") or ""))[:SYNC_TITLE_MAX])
|
|
3609
|
+
forms.update(str(t or "")[:SYNC_TITLE_MAX] for t in (item.get("titles") or []))
|
|
3610
|
+
forms.add(_split_first_prompt(item.get("first_prompt") or "")[0])
|
|
3611
|
+
return forms
|
|
3299
3612
|
|
|
3300
3613
|
|
|
3301
3614
|
def _sync_card_follow(card, item, sid):
|
|
3302
|
-
"""存量 sync
|
|
3615
|
+
"""存量 sync 卡按「DSH 当前标题 + 首问全文」补齐的字段 dict(无变化返回 {})。
|
|
3303
3616
|
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3617
|
+
只认「主会话」:遍历到的会话不是卡的主会话(多会话并集里的子会话)时不动。
|
|
3618
|
+
只在卡仍由平台自动写入时动(卡标题 ∈ `_auto_title_forms`:空 / sid 短码 / 任一枚
|
|
3619
|
+
会话标题事件 / 旧口径的首问首行)——用户在卡面行内改过标题就整张卡不再自动覆盖
|
|
3620
|
+
(描述也不动)。描述仅在为空、或仍是**旧口径自动写入**(首问除首行)时改成首问
|
|
3621
|
+
全文;用户写过的描述不覆盖。
|
|
3309
3622
|
"""
|
|
3310
3623
|
if (card["session_id"] or sid) != sid:
|
|
3311
3624
|
return {}
|
|
3312
|
-
title, desc = _split_first_prompt(item.get("first_prompt") or "")
|
|
3313
|
-
if not title:
|
|
3314
|
-
return {}
|
|
3315
3625
|
cur_title = (card["title"] or "").strip()
|
|
3316
|
-
|
|
3317
|
-
if cur_title not in auto_titles:
|
|
3626
|
+
if cur_title not in _auto_title_forms(item, sid):
|
|
3318
3627
|
return {}
|
|
3628
|
+
title, desc = _sync_card_fields(item, sid)
|
|
3319
3629
|
out = {}
|
|
3320
3630
|
if cur_title != title:
|
|
3321
3631
|
out["title"] = title
|
|
3322
|
-
|
|
3632
|
+
cur_desc = (card["description"] or "").strip()
|
|
3633
|
+
legacy_desc = _split_first_prompt(item.get("first_prompt") or "")[1]
|
|
3634
|
+
if desc and cur_desc != desc and cur_desc in ("", legacy_desc):
|
|
3323
3635
|
out["description"] = desc
|
|
3324
3636
|
return out
|
|
3325
3637
|
|
|
@@ -3327,12 +3639,20 @@ def _sync_card_follow(card, item, sid):
|
|
|
3327
3639
|
def sync_sessions(project):
|
|
3328
3640
|
"""自动同步项目 agent 会话到看板。返回新建卡 id 列表。
|
|
3329
3641
|
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3642
|
+
**建卡闸(2026-10-09 两处收窄,用户诉求「看板别再莫名冒卡」)**:
|
|
3643
|
+
- A 类 空会话不投影:会话已存在但一句话都还没有(首问/标题皆空)时不建卡,
|
|
3644
|
+
等首问或标题落盘后的下一个节拍再建(见 `_is_empty_session`);
|
|
3645
|
+
- B 类 平台自持会话不重复投影:`tasks.session_id` / `feishu_bindings.cur_sid`
|
|
3646
|
+
的会话平台侧已有入口,不再当成"外部直跑会话"投影成卡(见
|
|
3647
|
+
`_platform_owned_sids`)。
|
|
3648
|
+
|
|
3649
|
+
新建卡(origin='sync'):标题 = DSH 当前会话标题(无标题事件时回落首问首行、
|
|
3650
|
+
再回落 sid 短码)、描述 = 首问全文,绑定主会话,busy→doing / 空闲→review;
|
|
3651
|
+
存量 sync 卡:按 DSH 当前标题 + 首问全文补齐(DSH 侧标题自动更新/改名后跟着改;
|
|
3652
|
+
用户改过名的不动)+ dsh 族列映射移交调和器(事件驱动),此处仅归档→done(人工拖到
|
|
3334
3653
|
todo/blocked/done 后不再自动搬);会话存储被删的 sync 卡自动进 done;绑子代理
|
|
3335
|
-
会话的存量 sync
|
|
3654
|
+
会话的存量 sync 卡收口进回收站(每项目一轮);上述两类误建卡的存量也按同款
|
|
3655
|
+
口径收口(每项目一轮,见函数尾注释)。
|
|
3336
3656
|
sync 卡不占 runner 项目占用(外部会话平台控制不了,防堵死统一队列)。
|
|
3337
3657
|
"""
|
|
3338
3658
|
if not settings_of(project["id"]).get("sync_sessions", True):
|
|
@@ -3352,6 +3672,8 @@ def sync_sessions(project):
|
|
|
3352
3672
|
bound[s] = c
|
|
3353
3673
|
if c["session_id"]:
|
|
3354
3674
|
bound[c["session_id"]] = c
|
|
3675
|
+
# B 类闸的 sid 集合(每轮一次查库,见 `_platform_owned_sids`)
|
|
3676
|
+
owned = _platform_owned_sids(project["id"])
|
|
3355
3677
|
created = []
|
|
3356
3678
|
for it in items:
|
|
3357
3679
|
sid = it["sid"]
|
|
@@ -3360,9 +3682,16 @@ def sync_sessions(project):
|
|
|
3360
3682
|
# busy 按需判定(2026-09-27):只有新卡分支消费 busy;存量 sync 卡只用
|
|
3361
3683
|
# archived(列映射归调和器),不再为每个会话无条件探测。
|
|
3362
3684
|
busy = False
|
|
3363
|
-
if c is None:
|
|
3685
|
+
if c is None and not _is_empty_session(it) and sid not in owned:
|
|
3364
3686
|
busy = _sync_session_busy(project, raw_family, sid, it["mtime"])
|
|
3365
3687
|
if c is None:
|
|
3688
|
+
# A 类闸:空会话不投影(建卡竞态由"等首问"取代旧的"先落 sid 短码
|
|
3689
|
+
# 兜底再补齐"——用户在板上看到的将只会有内容的会话)
|
|
3690
|
+
if _is_empty_session(it):
|
|
3691
|
+
continue
|
|
3692
|
+
# B 类闸:平台自持会话(任务/飞书)已有入口,不重复投影成卡
|
|
3693
|
+
if sid in owned:
|
|
3694
|
+
continue
|
|
3366
3695
|
title, desc = _sync_card_fields(it, sid)
|
|
3367
3696
|
cid = db.insert_board_card(project["id"], title, desc)
|
|
3368
3697
|
# 归档会话:不参与 busy 判定,直接落「已完成」
|
|
@@ -3405,6 +3734,42 @@ def sync_sessions(project):
|
|
|
3405
3734
|
if dropped:
|
|
3406
3735
|
print(f"[board-sync] 子代理会话卡收口: 软删 {dropped} 张"
|
|
3407
3736
|
f"(项目 {project['id']},见 spec/queue/排队与占用.md)")
|
|
3737
|
+
# —— 存量误建卡收口(2026-10-09;二版:「卡面占位形态」为前置硬闸)——
|
|
3738
|
+
# A/B 两闸只挡新建,历史版本已经建出的两类卡还留在板面上(实测:任务会话卡
|
|
3739
|
+
# 12 张、空会话占位卡 10 张,跨 4 个项目)。这里**软删进回收站**(可还原;
|
|
3740
|
+
# 不删会话文件、不动会话与依赖、不碰任务的任何记录)。判据与新建闸同源:
|
|
3741
|
+
# ① B 类:卡绑的 sid 是平台自持会话(tasks/飞书)——重复投影,收回;
|
|
3742
|
+
# ② A 类:卡绑的会话**全部**是空会话(首问/标题皆空)——没有任何内容可看;
|
|
3743
|
+
# 任一 sid 不在本轮枚举里(存储被删等)即不判空,交「存储被删→done」规则。
|
|
3744
|
+
# **首版实障(2026-10-09 当天)**:上述两条都依赖「易变的一次性输入」(会话
|
|
3745
|
+
# 枚举的解析结果 / tasks·飞书登记),真机首跑即在三个项目误删 13 张**有真实
|
|
3746
|
+
# 标题**的卡(918「现在TS作为DSH的插件…」、697/709 等;事后在库副本上复算,
|
|
3747
|
+
# 它们连判据中间值都不成立 ⇒ 属判据输入的一次性异常)。故补**前置硬闸**
|
|
3748
|
+
# `_is_placeholder_card`:只收口「卡面标题仍是自动写入占位形态(空 / 主会话
|
|
3749
|
+
# sid 短码)」的卡——卡面标题是卡自身的证据,不随会话解析成败摇摆;真实标题
|
|
3750
|
+
# (用户提问首行 / 任务提示词首行)一律不自动收口。代价=标题已补齐的误建卡
|
|
3751
|
+
# (任务会话卡)会漏删,交用户自行处理(**宁漏不误删**)。
|
|
3752
|
+
# 三条边界与子代理卡收口一致:只认 origin='sync' 自动卡(用户自建卡一字节
|
|
3753
|
+
# 不动)、平台在管的运行中卡跳过(`_has_active_run`)、每项目只跑一轮
|
|
3754
|
+
# (用户从回收站还原后同一进程内不再被反复软删;重启后重跑一轮,幂等)。
|
|
3755
|
+
if project["id"] not in _SYNC_MISBUILT_SWEPT:
|
|
3756
|
+
by_sid = {it["sid"]: it for it in items}
|
|
3757
|
+
dropped = 0
|
|
3758
|
+
for c in cards:
|
|
3759
|
+
if c["origin"] != "sync" or _has_active_run(c["id"]):
|
|
3760
|
+
continue
|
|
3761
|
+
sids = set(_card_sids(c))
|
|
3762
|
+
if not sids or not _is_placeholder_card(c, sids):
|
|
3763
|
+
continue # 卡面已有真实标题 ⇒ 非自动占位卡,绝不自动收口
|
|
3764
|
+
if any(s in owned for s in sids) or \
|
|
3765
|
+
all(_is_empty_session(by_sid.get(s)) for s in sids):
|
|
3766
|
+
db.trash_board_card(c["id"])
|
|
3767
|
+
dropped += 1
|
|
3768
|
+
_SYNC_MISBUILT_SWEPT.add(project["id"])
|
|
3769
|
+
if dropped:
|
|
3770
|
+
print(f"[board-sync] 误建卡收口: 软删 {dropped} 张"
|
|
3771
|
+
f"(项目 {project['id']}:平台自持会话卡 / 空会话占位卡,"
|
|
3772
|
+
f"见 board spec §48)")
|
|
3408
3773
|
# 存储被删 → done(list 只回 50 条,不能用「不在列表」判定删除,必须逐卡查存在性)
|
|
3409
3774
|
for c in cards:
|
|
3410
3775
|
if c["origin"] != "sync" or c["column_key"] == "done" or _has_active_run(c["id"]):
|
|
@@ -3481,6 +3846,27 @@ def run_pid(card_id):
|
|
|
3481
3846
|
return None
|
|
3482
3847
|
|
|
3483
3848
|
|
|
3849
|
+
def _orphan_hold(card_id):
|
|
3850
|
+
"""本卡是否持有「无平台在管条目背书」的活跃 `c:` 行(**孤儿运行行**,缺陷 B 判据)。
|
|
3851
|
+
|
|
3852
|
+
生产上唯一的产生面=作答/立即送达**送达成功**时 `runner.card_started` 补回的
|
|
3853
|
+
「送达恢复」/「立即送达」行:那行由平台建,而目标会话是外部/接管会话(平台的
|
|
3854
|
+
`_RUNS` 里没有它)⇒ 行没有「在管条目」背书,收口只能靠调和器按实况判定
|
|
3855
|
+
(`_starting_window` 的 docstring 早已写明这类形态要能「落回待审核」)。
|
|
3856
|
+
|
|
3857
|
+
为什么必须与 `has_run` 分开判(缺陷 B,T9b 真机 6 轮 5 轮命中):调和状态机把
|
|
3858
|
+
`live_msg`(本卡有排队/执行中的 `m:` 行)算作「在跑」,而**孤儿行本身就在运行
|
|
3859
|
+
前缀里**——serial 项目里它把本卡自己的排队消息永久挡在队外
|
|
3860
|
+
(`runner._prefix_window_blocked`),状态机又因为那条消息「排队中」判本卡「在跑」
|
|
3861
|
+
⇒ 互等:行不收口、消息永远排不到、该卡与项目后续投递全被挡住(周期自检只打
|
|
3862
|
+
`[waitq-selfcheck] 行状态不明(仅告警)`)。故孤儿行只认**实况 busy**。
|
|
3863
|
+
|
|
3864
|
+
池内卡走不到这个形态:同一「送达恢复」动作发生在池内卡上时 `_RUNS` 条目在场
|
|
3865
|
+
(`has_run=True`),状态机在 `has_run` 分支早退、行由巡视线程 `_finish_run` 在
|
|
3866
|
+
会话空闲时收口(见 `_reconcile_action_for` 的不干预规则)。"""
|
|
3867
|
+
return _card_unit_active(card_id) and not _has_active_run(card_id)
|
|
3868
|
+
|
|
3869
|
+
|
|
3484
3870
|
# ---------- 交互等待检测(InteractionWatcher,方案 A) ----------
|
|
3485
3871
|
# 单族化后等待态只有 dsh 一个来源:`dshevents` 注册表的 interaction 字段——
|
|
3486
3872
|
# 提问来自 `user-questions/request` waterfall、审批来自 `approval/asked`
|
|
@@ -3542,6 +3928,12 @@ def _iw_interaction(family, proj, sid, busy_hint=None):
|
|
|
3542
3928
|
return None
|
|
3543
3929
|
busy = st.get("status") == "running"
|
|
3544
3930
|
mark = st.get("interaction") or {}
|
|
3931
|
+
# T9 真机取证待办(2026-10-10):外部会话(owned:false)挂起等作答期间,宿主
|
|
3932
|
+
# `status` 是否为 `running` **尚未取证**——本批先按既有口径交付,判据保持不动。
|
|
3933
|
+
# 若实测为 `idle`,mark 非空的挂起会被本行判成 pending=False(answerable 随之
|
|
3934
|
+
# 丢失、站点上不可答),届时由控制者另派小修复把判据拆开:`mark` 为空 ⇒
|
|
3935
|
+
# pending False;`mark` 非空且 `call_id` 非空 ⇒ pending True(`busy` 照实回传,
|
|
3936
|
+
# 防陈旧 mark 复活卡片),并补一条对应的反例用例。
|
|
3545
3937
|
if not busy or not mark:
|
|
3546
3938
|
return {"pending": False, "busy": busy, "qid": None,
|
|
3547
3939
|
"question": None, "options": None,
|
|
@@ -3593,6 +3985,286 @@ def _iw_interaction(family, proj, sid, busy_hint=None):
|
|
|
3593
3985
|
return None # 读注册表异常:本轮跳过
|
|
3594
3986
|
|
|
3595
3987
|
|
|
3988
|
+
# ---------- 外部会话看管声明(C 批 T5,2026-10-10) ----------
|
|
3989
|
+
#
|
|
3990
|
+
# 背景:用户在 dsh GUI 里直跑/接管的会话(`/live` 里 `owned:false`)不在驱动池,
|
|
3991
|
+
# 投递必然 404。驱动侧因此加了 `POST /watch` 显式看管闸(见 dshdriver.watch_session);
|
|
3992
|
+
# 平台侧把「声明范围」限定为**平台确实要投递的会话**:有卡的会话(宿主里与 Touchstone
|
|
3993
|
+
# 无关的会话一律不碰)**加上**宿主上报为外部态(owned:false)的任务侧会话——后者由
|
|
3994
|
+
# 消息前置闸与作答送达前的 `_watch_before_delivery` 按需补声明(T7 评审 2026-10-10)。
|
|
3995
|
+
#
|
|
3996
|
+
# 声明点三处,互为兜底:
|
|
3997
|
+
# ① `_iw_once` 逐卡循环内(调和器扫描,覆盖常态);
|
|
3998
|
+
# ② `chat._external_preflight` 投递前置闸内(覆盖「卡刚建、扫描节拍还没跑到」的窗口);
|
|
3999
|
+
# ③ `_watch_before_delivery` 作答/审批送达前(覆盖无卡的**任务侧**外部会话)。
|
|
4000
|
+
# 撤销:`_watch_prune`(卡删除/换绑会话后回收,防集合无界增长)。
|
|
4001
|
+
# 失效:`_watch_generation_sync`(中枢重连边沿清空记账后重放,设计 §5.1)。
|
|
4002
|
+
# 语义:`_WATCHED` 是**进程内缓存**(「已声明过」的记账,避免每轮重复 POST);
|
|
4003
|
+
# 驱动侧 `watched` 才是权威。故集合清了只会多打一次幂等的 `/watch`,不会漏声明。
|
|
4004
|
+
_WATCHED: set[str] = set()
|
|
4005
|
+
_watch_lock = threading.Lock()
|
|
4006
|
+
# 看管代次:上次见到的中枢连接代次(`dshevents.stats()["reconnects"]`)。
|
|
4007
|
+
# None = 本进程尚未对过账(首次读只记基准,不清空——启动时集合本就为空)。
|
|
4008
|
+
_WATCH_GEN = None
|
|
4009
|
+
# 扫卡节流窗口(秒,C 批终审 I3,2026-10-10):`_iw_once` 由**每个状态帧**唤醒
|
|
4010
|
+
# (仅 IW_MIN_INTERVAL 限速),节拍尾部每轮都调 `_watch_prune()`;而扫卡 =
|
|
4011
|
+
# `list_projects_all()` 一次连接 + **每项目一次** `list_board_cards()`,只要有任何
|
|
4012
|
+
# 一个外部会话被看管,全量扫卡就跟着状态帧跑。节流到与调和器兜底拍
|
|
4013
|
+
# (`IW_SAFETY_SECONDS`=60s,见文件尾)同量级:被节流的只是「扫卡 + 撤销」,
|
|
4014
|
+
# **只影响回收的及时性**(多留一会儿看管是安全方向);代次检查是正确性路径,
|
|
4015
|
+
# 照旧每次进入都做(见 `_watch_prune`)。
|
|
4016
|
+
WATCH_PRUNE_INTERVAL = 60.0
|
|
4017
|
+
# 上次真正扫卡的时刻(`time.monotonic()`;0.0 = 本拍该扫)。就地读写都在 `_watch_lock` 内。
|
|
4018
|
+
_WATCH_PRUNE_AT = 0.0
|
|
4019
|
+
|
|
4020
|
+
|
|
4021
|
+
def _watch_generation_sync():
|
|
4022
|
+
"""中枢重连边沿 ⇒ 清空 `_WATCHED`;返回是否发生了清空(设计 §5.1 的失效通道)。
|
|
4023
|
+
|
|
4024
|
+
依据:`dshevents.stats()["reconnects"]`——中枢(`dshevents.EventHub`)每次
|
|
4025
|
+
「未连接 → 已连接」边沿加一,读口是**纯本地** `with self._cond`(零 HTTP 请求),
|
|
4026
|
+
故每次声明前读一次也不打驱动。为什么必须清:驱动侧 `watched` 是插件进程内的
|
|
4027
|
+
易失状态(`dispose()` / 插件重新 apply、宿主重载即清空),而平台记账仍写着
|
|
4028
|
+
「已声明过」——不清就会一直命中陈旧缓存、不再 POST `/watch`,投递撞驱动 404 落
|
|
4029
|
+
error,且卡还在时 `_watch_prune` 不撤 ⇒ 不自愈(bug_report/20261008_1935 记录的
|
|
4030
|
+
宿主侧重载场景)。清空只让下一次 `_ensure_watch` 重新声明一次(幂等 POST),
|
|
4031
|
+
不会漏声明。
|
|
4032
|
+
"""
|
|
4033
|
+
global _WATCH_GEN
|
|
4034
|
+
try:
|
|
4035
|
+
gen = dshevents.stats().get("reconnects")
|
|
4036
|
+
except Exception: # noqa: BLE001 — 读口异常不阻断,下轮再来
|
|
4037
|
+
return False
|
|
4038
|
+
if gen is None:
|
|
4039
|
+
return False
|
|
4040
|
+
with _watch_lock:
|
|
4041
|
+
prev, _WATCH_GEN = _WATCH_GEN, gen
|
|
4042
|
+
if prev is None or prev == gen:
|
|
4043
|
+
return False
|
|
4044
|
+
_WATCHED.clear()
|
|
4045
|
+
return True
|
|
4046
|
+
|
|
4047
|
+
|
|
4048
|
+
def _ensure_watch(sid, force=False):
|
|
4049
|
+
"""声明「平台看管该会话」(幂等,成功才入缓存);失败留痕返回 False,不重试。
|
|
4050
|
+
|
|
4051
|
+
先过**代次对账**(中枢重连 ⇒ 陈旧记账作废,见 `_watch_generation_sync`),
|
|
4052
|
+
再查缓存:已在 `_WATCHED` 内直接 True(零请求);否则
|
|
4053
|
+
`dshdriver.watch_session(sid)`,驱动确认 `watched=true` 才记进缓存。任何失败
|
|
4054
|
+
(旧插件无 `/watch` ⇒ 404、驱动不可达、令牌错)都只打印一行诊断并返回 False
|
|
4055
|
+
——**不重试**:调用方(`chat._external_preflight`)据此拒投并给明确文案,比反复
|
|
4056
|
+
打驱动更有用;下一轮调和器扫描会自然重试一次(缓存里没有它)。
|
|
4057
|
+
|
|
4058
|
+
`force=True` 跳过缓存命中、**强制重发一次声明**(成功后照常入缓存)。唯一调用方是
|
|
4059
|
+
`rename_card_session` 的 404 重试:那里的 404 恰恰说明「驱动侧认为没看管」,而
|
|
4060
|
+
`_WATCHED` 是**平台侧进程内记账**、可能与驱动侧脱节(实测 2026-10-10:带外
|
|
4061
|
+
`POST /watch {on:false}`、以及驱动在 `session/disposed` 时自清看管表,
|
|
4062
|
+
都会让缓存变陈旧)——缓存命中就不能让它挡住这次重试。
|
|
4063
|
+
"""
|
|
4064
|
+
sid = str(sid or "")
|
|
4065
|
+
if not sid:
|
|
4066
|
+
return False
|
|
4067
|
+
_watch_generation_sync()
|
|
4068
|
+
if not force:
|
|
4069
|
+
with _watch_lock:
|
|
4070
|
+
if sid in _WATCHED:
|
|
4071
|
+
return True
|
|
4072
|
+
try:
|
|
4073
|
+
ok = bool(dshdriver.watch_session(sid))
|
|
4074
|
+
except Exception as exc: # noqa: BLE001 — 失败降级为拒投
|
|
4075
|
+
hint = "(插件过旧:无 /watch 端点,请重装插件并重启 dsh web)" \
|
|
4076
|
+
if getattr(exc, "code", None) == dshdriver.WATCH_UNSUPPORTED else ""
|
|
4077
|
+
print(f"[board] 看管声明失败 sid={sid}: {exc}{hint}", flush=True)
|
|
4078
|
+
return False
|
|
4079
|
+
if not ok:
|
|
4080
|
+
print(f"[board] 看管声明失败 sid={sid}: 驱动未确认看管(watched=false)",
|
|
4081
|
+
flush=True)
|
|
4082
|
+
return False
|
|
4083
|
+
with _watch_lock:
|
|
4084
|
+
_WATCHED.add(sid)
|
|
4085
|
+
return True
|
|
4086
|
+
|
|
4087
|
+
|
|
4088
|
+
# 卡面改名的驱动调用超时(秒):best-effort 的同步链路,快失败比让 HTTP worker 挂满
|
|
4089
|
+
# 默认 120s 有用(2026-10-10 需求「TS 卡面改名 → DSH 会话名同步」)。
|
|
4090
|
+
RENAME_TIMEOUT = 10
|
|
4091
|
+
|
|
4092
|
+
|
|
4093
|
+
def rename_card_session(sid, title):
|
|
4094
|
+
"""卡面改名 → 同步改 DSH 会话标题(best-effort)。返回 `(ok, error, accepted)`。
|
|
4095
|
+
|
|
4096
|
+
两跳(2026-10-10 用户需求,B 档):
|
|
4097
|
+
① `dshdriver.rename`:覆盖**池内**会话(平台建/恢复过的:任务会话、平台起过的卡、
|
|
4098
|
+
会话窗发过消息被 resume 的);
|
|
4099
|
+
② 首跳 404(池外)⇒ `_ensure_watch(sid)` 声明看管(幂等、**只声明不接管**)后
|
|
4100
|
+
重试一次:覆盖用户在 dsh GUI 直跑、驱动只观察的会话——驱动侧 `/rename` 的
|
|
4101
|
+
池外回落分支按「看管 + 宿主有活 agent」放行(node 半同批实现)。
|
|
4102
|
+
失败**不抛**:卡面是用户的编辑,必须先生效;调用方(server 端点)把原因回给前端。
|
|
4103
|
+
`accepted` = 驱动回执里宿主**接受**的标题(可能被 dsh 规范化/按字节预算截断),
|
|
4104
|
+
成功时非空——调用方据此回写卡面,保证两侧逐字一致。
|
|
4105
|
+
"""
|
|
4106
|
+
sid = str(sid or "")
|
|
4107
|
+
title = str(title or "").strip()
|
|
4108
|
+
if not sid or not title:
|
|
4109
|
+
return False, "缺少会话或标题,未同步 DSH 会话名", ""
|
|
4110
|
+
last = ""
|
|
4111
|
+
for attempt in (1, 2):
|
|
4112
|
+
try:
|
|
4113
|
+
r = dshdriver.rename(sid, title, timeout=RENAME_TIMEOUT) or {}
|
|
4114
|
+
return True, "", str(r.get("title") or title)
|
|
4115
|
+
except Exception as exc: # noqa: BLE001 — 统一降级为原因串
|
|
4116
|
+
last = str(exc)
|
|
4117
|
+
pool_miss = getattr(exc, "code", None) == 404
|
|
4118
|
+
# 只在「池外」这一跳补一次看管声明(已看管无活 agent 的 404 重试同样失败,
|
|
4119
|
+
# 但代价只是一次请求,换来判定逻辑不依赖文案匹配)。**force=True**:这一跳的
|
|
4120
|
+
# 404 正说明驱动侧认为没看管,平台侧 `_WATCHED` 缓存可能陈旧(带外撤销、
|
|
4121
|
+
# 驱动在 session/disposed 时自清),不能被它挡住重发。
|
|
4122
|
+
if attempt == 1 and pool_miss and _ensure_watch(sid, force=True):
|
|
4123
|
+
print(f"[board] 卡面改名:池外会话补看管声明后重试 sid={sid}",
|
|
4124
|
+
flush=True)
|
|
4125
|
+
continue
|
|
4126
|
+
break
|
|
4127
|
+
return False, last, ""
|
|
4128
|
+
|
|
4129
|
+
|
|
4130
|
+
def _watch_card_refs():
|
|
4131
|
+
"""看管引用集:全部卡片的 `session_id ∪ sessions`。
|
|
4132
|
+
|
|
4133
|
+
含归档项目(归档 ≠ 删卡,归档项目的在跑会话照旧需要投递通道)。坏 JSON 按
|
|
4134
|
+
「无附加会话」防御(与卡片读侧同口径)。
|
|
4135
|
+
"""
|
|
4136
|
+
refs = set()
|
|
4137
|
+
for proj in db.list_projects_all():
|
|
4138
|
+
for card in db.list_board_cards(proj["id"]):
|
|
4139
|
+
sid = str(card["session_id"] or "")
|
|
4140
|
+
if sid:
|
|
4141
|
+
refs.add(sid)
|
|
4142
|
+
try:
|
|
4143
|
+
refs.update(str(s) for s in json.loads(card["sessions"] or "[]") if s)
|
|
4144
|
+
except ValueError:
|
|
4145
|
+
pass # 坏 JSON 按无附加会话(防御)
|
|
4146
|
+
return refs
|
|
4147
|
+
|
|
4148
|
+
|
|
4149
|
+
def _watch_msg_inflight(sid):
|
|
4150
|
+
"""该 sid 是否仍有在途消息(活跃 `m:` 行)——看管撤销判据的第二条。
|
|
4151
|
+
|
|
4152
|
+
读口用 `chat.live_of_sid`(内部即既有 waitq 读口
|
|
4153
|
+
`waitq.msg_rows(sid=…, states=("queued", "running"))`,不另写 SQL)。
|
|
4154
|
+
读不到按「有在途」保守处理:宁可少撤(下轮再试),不可误撤(在途消息送达
|
|
4155
|
+
撞驱动 404,正是本任务要消灭的「放行后静默失败」)。
|
|
4156
|
+
"""
|
|
4157
|
+
try:
|
|
4158
|
+
return bool(chat.live_of_sid(sid))
|
|
4159
|
+
except Exception: # noqa: BLE001 — 读不到按「有在途」保守
|
|
4160
|
+
return True
|
|
4161
|
+
|
|
4162
|
+
|
|
4163
|
+
def _watch_prune():
|
|
4164
|
+
"""撤掉**确无需要**的看管声明(`POST /watch {on:false}`),返回撤销条数。
|
|
4165
|
+
|
|
4166
|
+
判据(两条都成立才撤):
|
|
4167
|
+
① 无卡引用——引用集 = 全部卡片的 `session_id ∪ sessions`,见 `_watch_card_refs`;
|
|
4168
|
+
② 无在途消息——该 sid 已无活跃 `m:` 行,见 `_watch_msg_inflight`。前置闸会为
|
|
4169
|
+
**无卡**外部会话放行(飞书绑定会话、任务会话重载后变外部态等 `card_id/
|
|
4170
|
+
task_id` 皆空的 submit 路径),此时「无卡引用」≠「无人需要看管」:还在
|
|
4171
|
+
`m:` 行里排队的消息一旦被撤看管,送达时 `/prompt` 就撞驱动 404。
|
|
4172
|
+
|
|
4173
|
+
竞态(与前置闸的 TOCTOU):撤销判据在**同一临界区**内复核,不信任扫描快照
|
|
4174
|
+
——① `_WATCHED` 成员资格在锁内重查(快照之后并发的前置闸可能刚重新声明入集合);
|
|
4175
|
+
② 在途消息在锁内重查(快照之后可能刚落 `m:` 行)。撤销成功后**再复核一次**:
|
|
4176
|
+
窗口内若又冒出新声明/新在途消息(`on:false` 可能压掉了并发的 `on:true`),立即
|
|
4177
|
+
补偿重声明——宁可多打一次幂等 `/watch`,不可留「缓存无、驱动无」而在途消息撞
|
|
4178
|
+
404。驱动 I/O 全程在锁外(锁纪律:`_watch_lock` 只保护记账集合,绝不跨网络调用)。
|
|
4179
|
+
撤销失败的 sid 放回集合等下一轮重试。
|
|
4180
|
+
|
|
4181
|
+
节流(C 批终审 I3,2026-10-10):本函数由 `_iw_once` **每个状态帧**调用一次,
|
|
4182
|
+
而「扫卡」是 O(项目数 × 每项目卡数) 的 DB 读、`_watch_msg_inflight` 还在
|
|
4183
|
+
`_watch_lock` 内做 SQLite 查询。故「扫卡 + 撤销」节流到
|
|
4184
|
+
`WATCH_PRUNE_INTERVAL`(60s,与调和器兜底拍同量级):窗口内只剩两件零成本的事
|
|
4185
|
+
—— `_watch_generation_sync()`(本地代次读口)与「集合空则返回」。
|
|
4186
|
+
**代次检查不参与节流**(它是正确性路径:中枢重连边沿必须立刻作废旧记账,否则
|
|
4187
|
+
陈旧缓存永不自愈);被节流影响的**只有回收的及时性**(最多晚一拍撤销看管——
|
|
4188
|
+
多留一会儿看管是安全方向,且在途消息送达不受影响)。
|
|
4189
|
+
开销:`_WATCHED` 为空(常态)时零 DB 访问(早于节流判定,也不会消耗窗口——
|
|
4190
|
+
新声明入集合后本拍即扫)。
|
|
4191
|
+
"""
|
|
4192
|
+
global _WATCH_PRUNE_AT
|
|
4193
|
+
_watch_generation_sync() # 中枢重连过:陈旧记账先作废(撤销无意义)
|
|
4194
|
+
with _watch_lock:
|
|
4195
|
+
if not _WATCHED:
|
|
4196
|
+
return 0 # 常态:零 DB 访问(不消耗节流窗口)
|
|
4197
|
+
now = time.monotonic()
|
|
4198
|
+
if now - _WATCH_PRUNE_AT < WATCH_PRUNE_INTERVAL:
|
|
4199
|
+
return 0 # 本窗口已扫过:只推迟回收,不影响正确性
|
|
4200
|
+
_WATCH_PRUNE_AT = now # 开窗(含扫描异常:下一拍再试)
|
|
4201
|
+
refs = _watch_card_refs()
|
|
4202
|
+
with _watch_lock:
|
|
4203
|
+
candidates = sorted(_WATCHED - refs) # 粗筛快照;最终判据见下面的锁内复核
|
|
4204
|
+
n = 0
|
|
4205
|
+
for sid in candidates:
|
|
4206
|
+
with _watch_lock:
|
|
4207
|
+
# —— 撤销前锁内复核(唯一信任点)——
|
|
4208
|
+
if sid not in _WATCHED or sid in refs or _watch_msg_inflight(sid):
|
|
4209
|
+
continue
|
|
4210
|
+
_WATCHED.discard(sid) # 先出缓存:并发 _ensure_watch 转为重新声明
|
|
4211
|
+
try:
|
|
4212
|
+
dshdriver.watch_session(sid, on=False)
|
|
4213
|
+
except Exception as exc: # noqa: BLE001 — 留痕,下轮重试
|
|
4214
|
+
with _watch_lock:
|
|
4215
|
+
_WATCHED.add(sid) # 撤销失败:放回,下轮再试
|
|
4216
|
+
print(f"[board] 看管撤销失败 sid={sid}: {exc}", flush=True)
|
|
4217
|
+
continue
|
|
4218
|
+
# —— 撤销后复核:窗口内并发落下的新声明 / 新在途消息 ⇒ 补偿重声明 ——
|
|
4219
|
+
with _watch_lock:
|
|
4220
|
+
redeclared = sid in _WATCHED
|
|
4221
|
+
if redeclared:
|
|
4222
|
+
_WATCHED.discard(sid) # 清掉再声明:强制真发一次 POST
|
|
4223
|
+
if redeclared or _watch_msg_inflight(sid):
|
|
4224
|
+
_ensure_watch(sid)
|
|
4225
|
+
continue
|
|
4226
|
+
n += 1
|
|
4227
|
+
return n
|
|
4228
|
+
|
|
4229
|
+
|
|
4230
|
+
def _watch_before_delivery(sid):
|
|
4231
|
+
"""送达前对**非池内**(外部)会话重新声明一次看管(C 批 T7 控制器裁定,2026-10-10)。
|
|
4232
|
+
|
|
4233
|
+
为什么必须补(T5 与 T4 两处评审叠加出的真实洞):`_watch_prune` 的撤销判据是
|
|
4234
|
+
「无卡引用 ∧ 无在途 `m:` 行」——**不含活跃 `a:`(作答)行**;驱动的
|
|
4235
|
+
`/watch {on:false}` 也不会主动收口在途认领(要等 abort/disposed/dispose)。
|
|
4236
|
+
两者叠加:答案已入队未送达、或会话正挂起等作答(用户刚点作答)时点,prune 可能
|
|
4237
|
+
已把该 sid 的看管撤掉 ⇒ 送达 `/answer`·`/approval` 撞驱动「未看管」404 ⇒
|
|
4238
|
+
`a:` 行落 error,正是本批要消灭的失败类。故作答/审批的**唯一送达出口**
|
|
4239
|
+
(`_answer_deliver`,worker 补位与「立即送达」共用)在真正调驱动前补一次声明。
|
|
4240
|
+
|
|
4241
|
+
幂等且廉价:`_ensure_watch` 命中 `_WATCHED` 缓存时是**纯本地判断、零请求**,
|
|
4242
|
+
只有缓存/驱动侧确已被撤(prune 撤销、中枢重连失效)才真发一次 `POST /watch`。
|
|
4243
|
+
|
|
4244
|
+
判据用既有读口 `dshevents.get(sid)` 的 `owned`(不新增全局状态、不猜):
|
|
4245
|
+
`owned is False` ⇒ 外部会话(需要显式看管);`owned is True`(平台自持)与
|
|
4246
|
+
`None`(注册表未知/断连——未知 ≠ 外部)一律不声明,与 `chat._external_preflight`
|
|
4247
|
+
及 `_iw_once` 逐卡挂钩同一判定阶梯:池内与未知路径行为一个字节不变。
|
|
4248
|
+
本函数**不改** `_watch_prune` 判据(「未看管不认领」是驱动侧红线,驱动行为正确),
|
|
4249
|
+
只把「撤销窗口里到达的送达」自愈回来。
|
|
4250
|
+
|
|
4251
|
+
失败不阻断(`_ensure_watch` 自带留痕、不重试):声明失败时驱动同样回 404,由
|
|
4252
|
+
调用方既有的重试/放弃语义处置,错误口径与此前一致。
|
|
4253
|
+
|
|
4254
|
+
边界:本钩子**缩小但不关闭** `_watch_prune` 与「在途送达」之间的交错窗口——钩子
|
|
4255
|
+
返回后到驱动真正收到 `/answer`·`/approval` 之间仍可能被一次并发 prune 撤掉看管;
|
|
4256
|
+
该残留窗口的兜底在**送达重试**(`a:` 行的 3 次退避重试;每次重试都再走一遍本钩子
|
|
4257
|
+
重新声明),不为此引入锁或全局状态。
|
|
4258
|
+
"""
|
|
4259
|
+
sid = str(sid or "")
|
|
4260
|
+
if not sid:
|
|
4261
|
+
return
|
|
4262
|
+
st = dshevents.get(sid)
|
|
4263
|
+
if st is None or st.get("owned"):
|
|
4264
|
+
return # 未知(断连/没见过)与平台自持:不碰
|
|
4265
|
+
_ensure_watch(sid)
|
|
4266
|
+
|
|
4267
|
+
|
|
3596
4268
|
_RECONCILE_FIELDS = {
|
|
3597
4269
|
# 动作 -> 落库字段(block 的 block_text 由调用方按检测摘录补)
|
|
3598
4270
|
"recover": {"column_key": "doing", "block_kind": None, "block_text": ""},
|
|
@@ -3605,7 +4277,8 @@ _RECONCILE_FIELDS = {
|
|
|
3605
4277
|
|
|
3606
4278
|
|
|
3607
4279
|
def _reconcile_action_for(family, card, r, has_run=False, live_msg=False,
|
|
3608
|
-
stop_recent=False, archived=None, starting=False
|
|
4280
|
+
stop_recent=False, archived=None, starting=False,
|
|
4281
|
+
orphan_hold=False):
|
|
3609
4282
|
"""调和状态机(纯函数,单测覆盖):按会话实况 r(pending/busy)、归档态
|
|
3610
4283
|
archived 与当前卡状态得出动作,action ∈
|
|
3611
4284
|
{'block'(进阻塞+interaction),
|
|
@@ -3619,10 +4292,16 @@ def _reconcile_action_for(family, card, r, has_run=False, live_msg=False,
|
|
|
3619
4292
|
(None,中枢断连)一律不动作(不变量「断连=未知」)。
|
|
3620
4293
|
映射规则(无平台在管运行时,设计 §2.4):提问→blocked(interaction);
|
|
3621
4294
|
运行中→doing;空闲→review;阻塞解除按运行态回 doing/review。
|
|
3622
|
-
运行判定 running = busy or live_msg(2026-09-11
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
4295
|
+
运行判定 running = busy or (live_msg ∧ ¬orphan_hold)(2026-09-11 立,2026-10-10
|
|
4296
|
+
加孤儿行例外):live_msg=本卡会话有排队/执行中的平台消息单元(chat.live_of_sid)
|
|
4297
|
+
——消息排队尚未执行时会话并不 busy,仅凭 busy 会漏判;阻塞列卡片在消息投递后
|
|
4298
|
+
即应回开发列(用户约定:所有正在运行的卡片都在「正在开发」列)。
|
|
4299
|
+
`orphan_hold`(`_orphan_hold`:本卡有活跃 `c:` 行但无 `_RUNS` 背书,生产上=
|
|
4300
|
+
作答/立即送达补回的「送达恢复」行)**只认实况 busy**:那条行本身就在运行前缀里,
|
|
4301
|
+
serial 项目里它把本卡自己的排队消息挡在队外,再把 live_msg 当「在跑」就是互等
|
|
4302
|
+
(缺陷 B:行不收口 ⇒ 消息永远排不到 ⇒ 行永远收不了;真机 6 轮 5 轮命中,该卡与
|
|
4303
|
+
项目后续投递被永久挡住)。会话可信空闲(busy=False)时这类行必须收口让队列继续
|
|
4304
|
+
——与「送达恢复行按实况落回待审核」的既有口径(`_starting_window`)同源。
|
|
3626
4305
|
不干预规则:退场族不动(调用方已过滤,此处兜底保纯函数安全);
|
|
3627
4306
|
todo/done 终态不动;平台在管运行(has_run=True)的卡只做交互 block/recover、
|
|
3628
4307
|
busy/空闲流转归 _finish_run 拥有(防与轮收尾抢写)——**唯一例外**是卡片在
|
|
@@ -3663,7 +4342,9 @@ def _reconcile_action_for(family, card, r, has_run=False, live_msg=False,
|
|
|
3663
4342
|
return None
|
|
3664
4343
|
pending = bool(r and r.get("pending"))
|
|
3665
4344
|
busy = bool(r and r.get("busy"))
|
|
3666
|
-
|
|
4345
|
+
# 孤儿运行行只认实况 busy(缺陷 B,见 `_orphan_hold` 与 docstring):
|
|
4346
|
+
# live_msg 不能再把它按「在跑」保住——行恰恰是那条消息起不来的原因
|
|
4347
|
+
running = busy or (bool(live_msg) and not orphan_hold)
|
|
3667
4348
|
if card["block_kind"] == "manual":
|
|
3668
4349
|
if pending:
|
|
3669
4350
|
return None # 等待用户回答:维持阻塞态
|
|
@@ -3729,10 +4410,13 @@ def _iw_apply(family, card, action, r):
|
|
|
3729
4410
|
return # 已答待送达:调和动作一律跳过(见 docstring)
|
|
3730
4411
|
has_run = _has_active_run(card["id"])
|
|
3731
4412
|
live_msg = chat.live_of_sid((cur["session_id"] or "").strip())
|
|
4413
|
+
# 写前复核同样重取「孤儿运行行」(缺陷 B,短路口径同 `_iw_once`)
|
|
4414
|
+
orphan = bool(live_msg) and _orphan_hold(cur["id"])
|
|
3732
4415
|
if _reconcile_action_for(family, cur, r, has_run, live_msg,
|
|
3733
4416
|
_stop_recent(cur["id"]),
|
|
3734
4417
|
dshevents.archived(cur["session_id"] or ""),
|
|
3735
|
-
_starting_window(cur["id"])
|
|
4418
|
+
_starting_window(cur["id"]),
|
|
4419
|
+
orphan) != action:
|
|
3736
4420
|
return # 状态已变:跳过,不写库
|
|
3737
4421
|
if action == "to_done":
|
|
3738
4422
|
# dsh 侧归档 → 卡片进「已完成」(2026-10-05 反向规则):走容器迁移原语收口
|
|
@@ -3855,6 +4539,8 @@ def _iw_once():
|
|
|
3855
4539
|
异常语义:读不到实况(中枢未连接/未知)该卡本轮跳过(不搬列,防把运行中卡
|
|
3856
4540
|
误判完成搬去待审核;取代 sync_sessions 旧「异常视为空闲」;ext 行侧保留既有
|
|
3857
4541
|
行=保留上一轮占用,宁可多等不可误放行——逐卡 `r is None` 走 `ext_hold`)。
|
|
4542
|
+
看管声明(C 批 T5):逐卡对 `owned:false`(用户直跑的)会话调 `_ensure_watch`
|
|
4543
|
+
——声明范围=有卡会话;卡删除后的回收在节拍尾部 `_watch_prune`。
|
|
3858
4544
|
单卡异常不外抛(不影响其他卡/项目)。节拍尾部另跑 starting 超时对账
|
|
3859
4545
|
(_reconcile_starting_rows,v2d T3,裁决 R6)。"""
|
|
3860
4546
|
now = int(time.time() * 1000)
|
|
@@ -3901,6 +4587,15 @@ def _iw_once():
|
|
|
3901
4587
|
if not sid:
|
|
3902
4588
|
continue # 无会话(从未起跑的真排队卡等)无可探测
|
|
3903
4589
|
r = _iw_interaction(fam, proj, sid)
|
|
4590
|
+
# —— 看管声明(C 批 T5):外部会话(用户直跑/接管,`owned:false`)
|
|
4591
|
+
# 的投递/作答通道以平台显式 `POST /watch` 为闸,声明范围=有卡会话
|
|
4592
|
+
# (本循环即「有卡」的天然边界;撤销见 `_watch_prune`)。幂等:
|
|
4593
|
+
# 只有新 sid 首见(或中枢重连后记账刚被清空,见
|
|
4594
|
+
# `_watch_generation_sync`)才真发请求,失败留痕不重试。
|
|
4595
|
+
# 读注册表判 owned(而非另发请求);未知(get 为 None)不声明。
|
|
4596
|
+
_st = dshevents.get(sid)
|
|
4597
|
+
if _st is not None and not _st.get("owned"):
|
|
4598
|
+
_ensure_watch(sid)
|
|
3904
4599
|
if r is None:
|
|
3905
4600
|
# 实况读不到:两种语义必须分开(2026-10-07 实障修复,卡 870)——
|
|
3906
4601
|
# ① 中枢**断连**=真未知:保行(宁多等不可误放行,原口径);
|
|
@@ -3914,6 +4609,15 @@ def _iw_once():
|
|
|
3914
4609
|
if not dshevents.connected() and not _platform_holds(card["id"]) \
|
|
3915
4610
|
and not _subagent_session(sid):
|
|
3916
4611
|
ext_hold.add(card["id"]) # 占用未知:保留既有行(old 探针口径)
|
|
4612
|
+
elif dshevents.aligned() and not _platform_holds(card["id"]):
|
|
4613
|
+
# ③ 中枢可信(在线 ∧ 已对齐)却查不到该 sid = 会话确已结束:
|
|
4614
|
+
# 补上卡列这一格(2026-10-08 批次 D,bug_report/20261008_1935)
|
|
4615
|
+
# ——旧口径只收 ext 行、列不动,会话静默消失的 doing 卡永滞
|
|
4616
|
+
# 开发列(卡 908/909/912/913 的原症状);对齐前一律不动,
|
|
4617
|
+
# 杜绝「热重载后注册表为空 ⇒ 把运行中的卡批量搬走」的误判。
|
|
4618
|
+
# 写前复核全在 `_iw_apply`:queue 占位 / 起跑窗口 / 平台持有 /
|
|
4619
|
+
# 已答待送达 / 归档(to_done)自动跳出(状态机重评)。
|
|
4620
|
+
_iw_apply(fam, card, "to_review", None)
|
|
3917
4621
|
continue # 实况读不到:该卡本轮跳过,不搬列
|
|
3918
4622
|
# —— 外部条目占用候选(v2d T4):busy 且非挂起、且平台不持有 ——
|
|
3919
4623
|
# 挂起(pending)不算占用(豁免面②:挂起即出队,不建行);平台
|
|
@@ -3933,11 +4637,16 @@ def _iw_once():
|
|
|
3933
4637
|
c.update(r) # pending/busy/qid/question/options/answerable/text
|
|
3934
4638
|
c["ts"] = now
|
|
3935
4639
|
live_msg = chat.live_of_sid(sid) # 本卡消息在跑/排队(消息驱动的会话)
|
|
4640
|
+
# 孤儿运行行(缺陷 B)只在「本卡确有排队/执行中的消息」时才影响判定
|
|
4641
|
+
# (无消息时 running 只看 busy,见 `_reconcile_action_for`)⇒ 短路
|
|
4642
|
+
# 掉那次行点查,平台在管卡的常规拍零额外 DB 访问。
|
|
4643
|
+
orphan = bool(live_msg) and _orphan_hold(card["id"])
|
|
3936
4644
|
action = _reconcile_action_for(fam, card, r,
|
|
3937
4645
|
_has_active_run(card["id"]), live_msg,
|
|
3938
4646
|
_stop_recent(card["id"]), arch,
|
|
3939
4647
|
_starting_window(card["id"],
|
|
3940
|
-
starting_ids)
|
|
4648
|
+
starting_ids),
|
|
4649
|
+
orphan)
|
|
3941
4650
|
if action:
|
|
3942
4651
|
_iw_apply(fam, card, action, r)
|
|
3943
4652
|
except Exception:
|
|
@@ -3968,6 +4677,12 @@ def _iw_once():
|
|
|
3968
4677
|
_reconcile_starting_rows()
|
|
3969
4678
|
except Exception:
|
|
3970
4679
|
pass # 守护线程不 crash(同 _iw_once 口径)
|
|
4680
|
+
# 看管回收(C 批 T5):卡删除/换绑会话后撤掉驱动的看管声明(`_WATCHED` 为空时
|
|
4681
|
+
# 零 DB 访问;异常不影响本轮回调和的其余部分)。
|
|
4682
|
+
try:
|
|
4683
|
+
_watch_prune()
|
|
4684
|
+
except Exception:
|
|
4685
|
+
pass # 同上:回收失败下轮再来
|
|
3971
4686
|
|
|
3972
4687
|
|
|
3973
4688
|
def interaction_of_sid(sid):
|
|
@@ -4143,10 +4858,17 @@ def _answer_deliver(proj, meta):
|
|
|
4143
4858
|
插件在 waterfall 里挂起),meta 带 `outcome`(`allowed-once`/`rejected`)走
|
|
4144
4859
|
插件 `/approval`;未认领(GUI 作答)时插件返回 409 → 这里转 40005 让上层按
|
|
4145
4860
|
「再无待决」收口。
|
|
4861
|
+
|
|
4862
|
+
外部(池外)会话(C 批 T7,2026-10-10):真正调驱动前经 `_watch_before_delivery`
|
|
4863
|
+
幂等补声明看管——看管可能已被 `_watch_prune` 撤掉(其撤销判据不含活跃 `a:` 行),
|
|
4864
|
+
漏声明会让 `/answer`·`/approval` 撞「未看管」404、答案落 error。
|
|
4146
4865
|
"""
|
|
4147
4866
|
if _web_family(proj) != "dsh_plugin":
|
|
4148
4867
|
# 退场族无作答送达通道(单族化):明确报错,不落已删除的 kimi 配送段
|
|
4149
4868
|
raise RuntimeError(agents.RETIRED_MSG)
|
|
4869
|
+
# 送达前重声明看管(C 批 T7):外部会话的看管可能已被 `_watch_prune` 撤掉
|
|
4870
|
+
# (其判据不含活跃 `a:` 行),漏声明则下面两条驱动调用撞「未看管」404(见上)
|
|
4871
|
+
_watch_before_delivery(meta.get("sid"))
|
|
4150
4872
|
if meta.get("approval_id"):
|
|
4151
4873
|
outcome = str(meta.get("outcome") or "")
|
|
4152
4874
|
if not outcome:
|
|
@@ -4358,7 +5080,9 @@ def answer_interaction(proj, card, qid, answers):
|
|
|
4358
5080
|
看板卡路径(v2b T1,裁决 R8)**一律入队**(_queue_answer_unit:插前缀后/
|
|
4359
5081
|
等待区最前 + doing/queue 占位,补位启动时 worker 才调驱动 `/answer` 一次
|
|
4360
5082
|
提交送达——dsh 侧按 callId + 逐题 selected/custom 一次给全,缺项即未答);
|
|
4361
|
-
任务侧伪卡(无 id
|
|
5083
|
+
任务侧伪卡(无 id)保持直送,但直送前经 `_watch_before_delivery` 补声明看管
|
|
5084
|
+
(任务会话被宿主上报为外部态后撞「未看管」404 的修复,T7 评审 2026-10-10)。
|
|
5085
|
+
成功后清理 watcher 缓存(下轮调和重新检测刷新)。
|
|
4362
5086
|
|
|
4363
5087
|
answers = [{"wire": "q_0", "kind": "single|multi|other|multi_with_other",
|
|
4364
5088
|
"option_id"/"option_ids"/"text": ...}, ...](前端逐题收集;
|
|
@@ -4437,7 +5161,7 @@ def answer_interaction(proj, card, qid, answers):
|
|
|
4437
5161
|
# ._answer_exempt_keys_locked,R5⑥),一律插「运行中最后一个条目后面」
|
|
4438
5162
|
# (前缀后/等待区最前),卡落 doing/queue 排队占位,补位启动时才送达。
|
|
4439
5163
|
# 仅看板卡(有卡 id)参与;任务侧会话没有卡片列语义,保持直送
|
|
4440
|
-
# (specQ §5
|
|
5164
|
+
# (specQ §5 边界不变;直送前经 `_watch_before_delivery` 补声明看管,见下)。
|
|
4441
5165
|
try:
|
|
4442
5166
|
card_id = card["id"]
|
|
4443
5167
|
except (KeyError, IndexError, TypeError):
|
|
@@ -4446,6 +5170,11 @@ def answer_interaction(proj, card, qid, answers):
|
|
|
4446
5170
|
if _web_family(proj) != "dsh_plugin":
|
|
4447
5171
|
# 退场族无作答通道(单族化):不落已删除的 kimi 配送段
|
|
4448
5172
|
return f"回答失败:{agents.RETIRED_MSG}"
|
|
5173
|
+
# 送达前重声明看管(T7 评审 Important,控制器裁定 2026-10-10):任务会话
|
|
5174
|
+
# 宿主重载后会被驱动以 owned:false 上报(= 池外),而本直送分支不经
|
|
5175
|
+
# `_answer_deliver`;不补声明就撞「未看管」404,用户看到「回答失败:…」。
|
|
5176
|
+
# 判据同 `_answer_deliver`:owned:false 才声明,池内/未知零请求。
|
|
5177
|
+
_watch_before_delivery(sid)
|
|
4449
5178
|
try:
|
|
4450
5179
|
# 路线 A:任务侧会话直送(卡片侧走作答排队,见 _answer_deliver);
|
|
4451
5180
|
# 驱动调用失败(网络/序列化/提问已失效等)均须落到前端
|
|
@@ -4466,7 +5195,8 @@ def answer_approval(proj, card, approval_id, decision, scope=""):
|
|
|
4466
5195
|
与 answer_interaction 同款白名单校验:approval_id 必须是缓存里的实况值
|
|
4467
5196
|
(防伪造/旧 id 重放);decision ∈ approved/rejected(平台两态),
|
|
4468
5197
|
scope="session" 为「本会话内批准」——dsh 无该语义,显式拒绝(见下)。
|
|
4469
|
-
看板卡路径(v2b T1
|
|
5198
|
+
看板卡路径(v2b T1)一律入队、补位启动时才送达;任务侧保持直送,直送前同样经
|
|
5199
|
+
`_watch_before_delivery` 补声明看管(与提问腿同源,T7 评审 2026-10-10)。
|
|
4470
5200
|
成功后清 watcher 缓存(下轮调和重新检测刷新)。
|
|
4471
5201
|
返回 None=成功,str=错误信息(server 转 400 JSON {error})。"""
|
|
4472
5202
|
sid = card["session_id"] or ""
|
|
@@ -4494,12 +5224,15 @@ def answer_approval(proj, card, approval_id, decision, scope=""):
|
|
|
4494
5224
|
dsh_outcome = {"approved": "allowed-once", "rejected": "rejected"}[decision]
|
|
4495
5225
|
# 审批作答同路入队(v2b T1,删恒直送):与提问作答同一插入规则——补位
|
|
4496
5226
|
# 启动时由执行体按 meta 分发 answer_approval 送达。仅看板卡(有卡 id)
|
|
4497
|
-
# 参与;任务侧会话保持直送(specQ §5
|
|
5227
|
+
# 参与;任务侧会话保持直送(specQ §5 边界不变;直送前同样补声明看管,见下)。
|
|
4498
5228
|
try:
|
|
4499
5229
|
card_id = card["id"]
|
|
4500
5230
|
except (KeyError, IndexError, TypeError):
|
|
4501
5231
|
card_id = None
|
|
4502
5232
|
if card_id is None:
|
|
5233
|
+
# 送达前重声明看管(T7 评审 Important,控制器裁定 2026-10-10):同上
|
|
5234
|
+
# (提问腿)——任务会话池外化后,本直送分支同样需要先补声明再调 `/approval`。
|
|
5235
|
+
_watch_before_delivery(sid)
|
|
4503
5236
|
try:
|
|
4504
5237
|
dshdriver.answer_approval(sid, approval_id, dsh_outcome)
|
|
4505
5238
|
except Exception as e: # noqa: BLE001
|