@gavinc-cn/touchstone-dsh 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +335 -2
- package/README.zh.md +317 -0
- package/agents.py +247 -0
- package/auth.py +86 -0
- package/board.py +4229 -0
- package/builtin_assets.py +808 -0
- package/builtin_prompts/free_style/flow.md +301 -0
- package/builtin_prompts/free_style/load_test.md +114 -0
- package/builtin_prompts/free_style/templates/bug_report_template.md +41 -0
- package/builtin_prompts/free_style/templates/case_template.md +23 -0
- package/builtin_prompts/free_style/templates/index_template.md +41 -0
- package/builtin_prompts/free_style/templates/load_charts_template.md +104 -0
- package/builtin_prompts/free_style/templates/load_index_template.md +5 -0
- package/builtin_prompts/free_style/templates/load_plan_template.md +76 -0
- package/builtin_prompts/free_style/templates/load_scenario_template.md +49 -0
- package/builtin_prompts/free_style/templates/status_template.md +18 -0
- package/chat.py +616 -0
- package/db.py +1407 -0
- package/dsh-plugin/cordis.patch.yml +21 -0
- package/dsh-plugin/install.sh +79 -0
- package/dsh-plugin/lib/agent-driver.js +1888 -0
- package/dsh-plugin/lib/client.bundle.js +397 -0
- package/dsh-plugin/lib/index.js +284 -0
- package/dsh-plugin/scripts/build.mjs +37 -0
- package/dsh-plugin/scripts/prepack-check.mjs +61 -0
- package/dsh-plugin/scripts/select-bundle.mjs +51 -0
- package/dsh-plugin/src/client.js +384 -0
- package/dshdriver.py +702 -0
- package/dshevents.py +438 -0
- package/export_cases.py +160 -0
- package/extensions/dsh-autocommit/asset.json +18 -0
- package/extensions/dsh-autocommit/pkg/lib/index.mjs +361 -0
- package/extensions/dsh-autocommit/pkg/package.json +10 -0
- package/feishu.py +2139 -0
- package/lib.py +245 -0
- package/lifecycle.py +417 -0
- package/loadcase.py +558 -0
- package/loadgen.py +678 -0
- package/localbus.py +91 -0
- package/migrate_p7b.py +191 -0
- package/package.json +74 -4
- package/platcompat.py +272 -0
- package/prompts.py +666 -0
- package/rag.py +553 -0
- package/requirements.txt +9 -0
- package/runner.py +2471 -0
- package/server.py +5337 -0
- package/sessparse.py +638 -0
- package/touchstone.py +512 -0
- package/waitq.py +1319 -0
- package/webui/dist-plugin/assets/index-B0WoP1Qy.css +1 -0
- package/webui/dist-plugin/assets/index-eHpxBfP6.js +473 -0
- package/webui/dist-plugin/index.html +18 -0
- package/worktree.py +293 -0
package/board.py
ADDED
|
@@ -0,0 +1,4229 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Touchstone 看板(Board):卡片管理 + 门禁仲裁 + agent 会话驱动 + 定时调度。
|
|
3
|
+
|
|
4
|
+
复刻 dsh-kanban(设计:doc_ai/20260831_0659_看板复刻设计.md):
|
|
5
|
+
- 五列 todo/doing/blocked/review/done,一项目一看板,数据按 project_id 隔离;
|
|
6
|
+
- 移列统一走 move_card 服务端仲裁:父任务未完成拦截;serial 模式进 doing 落阻塞列
|
|
7
|
+
排队(block_kind='queue')入 runner 统一队列——与测试任务共享每项目串行 FIFO,
|
|
8
|
+
队列拾起自动起跑(dequeue_start),会话结束释放占用自动补位(force 可跳过排队直起);
|
|
9
|
+
- 开始开发 = 起项目绑定的 agent 会话(单族:dsh 插件会话在 dsh 宿主进程内,经
|
|
10
|
+
dshdriver HTTP 驱动 + dshevents 事件中枢做项目串行调度:sid 立即可知、运行中可插话、
|
|
11
|
+
abort 停止;退场族项目在起会话处返回明确错误),
|
|
12
|
+
会话结束 → 卡片自动进待审核并释放占用补位;
|
|
13
|
+
- 定时开工由 start_scheduler 的守护线程每 30s 扫描(不依赖页面打开)。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import glob
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import platcompat
|
|
21
|
+
import signal
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.parse
|
|
26
|
+
import urllib.request
|
|
27
|
+
|
|
28
|
+
import agents
|
|
29
|
+
import chat
|
|
30
|
+
import db
|
|
31
|
+
import dshevents
|
|
32
|
+
import dshdriver
|
|
33
|
+
import feishu
|
|
34
|
+
import lib
|
|
35
|
+
import runner
|
|
36
|
+
import sessparse
|
|
37
|
+
import waitq
|
|
38
|
+
import worktree
|
|
39
|
+
|
|
40
|
+
# 五列定义(顺序即前端列序)
|
|
41
|
+
COLUMNS = ("todo", "doing", "blocked", "review", "done")
|
|
42
|
+
|
|
43
|
+
# 会话实况三态(2026-09-19 运行判定收口·先行):判定「会话是否在运行」的唯一读路径。
|
|
44
|
+
# running=在跑;idle=明确空闲;unknown=读不到(事件中枢未连接/无信号)——调度场景必须保守
|
|
45
|
+
# (不补位/保留旧值),展示场景由调用方适配为 False(见 web_session_busy)。
|
|
46
|
+
STATE_RUNNING = "running"
|
|
47
|
+
STATE_IDLE = "idle"
|
|
48
|
+
STATE_UNKNOWN = "unknown"
|
|
49
|
+
|
|
50
|
+
# 看板设置默认值(board_settings.json 缺省补齐);sort=每列排序方案:
|
|
51
|
+
# manual=手动拖拽顺序(sort_order),entered_desc=进入已完成时间新→旧(仅 done 列)
|
|
52
|
+
SETTINGS_DEFAULT = {"mode": "serial", "sync_sessions": True,
|
|
53
|
+
"jira": {"url": "", "user": "", "token": ""},
|
|
54
|
+
"sort": {"todo": "manual", "doing": "manual", "blocked": "manual",
|
|
55
|
+
"review": "manual", "done": "entered_desc"}}
|
|
56
|
+
|
|
57
|
+
# 列排序方案合法值(server 设置端点逐列校验用);entered_desc 仅 done 列可用
|
|
58
|
+
SORT_MODES = ("manual", "created_desc", "updated_desc", "title", "entered_desc")
|
|
59
|
+
|
|
60
|
+
# 并行模式合法值(2026-09-17 需求修订,两档):serial=同时只跑一个任务(由统一队列
|
|
61
|
+
# 每项目串行承载);parallel=不限制。原第三档 readonly-parallel(2026-08-31 看板复刻
|
|
62
|
+
# 的过渡档,行为恒等同 serial)已删除——存量行在 settings_of 读取层归一为 serial,
|
|
63
|
+
# server 设置端点按本常量白名单校验
|
|
64
|
+
MODE_VALUES = ("serial", "parallel")
|
|
65
|
+
|
|
66
|
+
# 门禁临界区锁:「父依赖判定 + 直起落列」原子化(防并发双进 doing)。排队占位
|
|
67
|
+
# 落列/入队统一走锁外 waitq.enqueue_card 单事务(P4 R6 单写);落 doing 即占住
|
|
68
|
+
# 串行位;serial 上限改由 runner 统一队列承载(每项目串行 FIFO,与测试任务混排);
|
|
69
|
+
# 起会话一律在锁外(dsh 建会话/投递为驱动 REST 往返,持锁会阻塞全平台门禁)。
|
|
70
|
+
# 锁顺序固定 _gate_lock → _runs_lock,持 _gate_lock 时不得再调会取 _gate_lock 的函数
|
|
71
|
+
_gate_lock = threading.Lock()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _card_opt(row, key, default=""):
|
|
75
|
+
"""读卡片可选列(列缺失/值为 NULL 一律回落 default)。
|
|
76
|
+
|
|
77
|
+
`board_cards.worktree` 是 kanban 一期遗留列(`db.py` 建表里有、`db.migrate()`
|
|
78
|
+
2026-10-06 批次才补 ALTER),比一期更早的库可能没有该列——sqlite3.Row 缺列会
|
|
79
|
+
抛 IndexError,故所有新列的读取一律经本函数,不直接下标访问。
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
v = row[key]
|
|
83
|
+
except (IndexError, KeyError):
|
|
84
|
+
return default
|
|
85
|
+
return default if v is None else v
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def card_workspace(project, card):
|
|
89
|
+
"""卡片会话的工作目录(本批新增,见 plan §3.3):
|
|
90
|
+
|
|
91
|
+
独立 worktree 卡(`board_cards.worktree` 非空)用其 worktree 路径,其余一律
|
|
92
|
+
仍用 `project_dir`——这是「卡片级 cwd」的唯一读口,起会话/resume/首轮提示词/
|
|
93
|
+
在管条目记账全部经它取值,避免同一语义散落多处。
|
|
94
|
+
"""
|
|
95
|
+
wt = (_card_opt(card, "worktree") or "").strip()
|
|
96
|
+
return wt or project["project_dir"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def card_json(row, running=False, busy=False, msg_queued=False, queue_state=None):
|
|
100
|
+
"""卡片 Row -> 前端 dict。running=平台在管运行;busy=会话级运行
|
|
101
|
+
(平台运行或外部 busy——用户在 dsh GUI 直跑的同步卡会话,前端徽标
|
|
102
|
+
running||busy 都点亮)。answer_pending=已作答·待送达(答案排队,
|
|
103
|
+
wait_items kind=answer 活跃行,2026-09-14;P2 起权威在表)——此时会话实况
|
|
104
|
+
仍 busy(提问挂起中)但平台
|
|
105
|
+
已收下答案、等空闲送达,前端按「排队中」展示且不点亮「会话运行中」。
|
|
106
|
+
msg_queued=本卡有平台排队中的会话消息单元(chat.queued_card_ids 批量
|
|
107
|
+
预计算,2026-09-14)——展示态同款:平台已收下消息、等空闲送达,前端按
|
|
108
|
+
「排队中」展示且不点亮「会话运行中」。
|
|
109
|
+
queue_state(P6 展示派生,最后一枚键):五态+空闲单枚举(判定收口
|
|
110
|
+
queue_state_of,前端只渲染);缺省按四参现算(hidden sites 调用点零改动),
|
|
111
|
+
board_payload 走批量预计算显式传入,此时 answer_pending 与之同源
|
|
112
|
+
(queue_state_of 判定序最高级即 answer 在场 ⟺ answer_pending,免二次点查)。
|
|
113
|
+
worktree(2026-10-06 批次):独立 worktree 卡的落点绝对路径,空串=普通卡
|
|
114
|
+
(前端据此渲染 🌿 徽标与详情行;只写不改口径见 plan D3)——经 `_card_opt`
|
|
115
|
+
读取,兼容无该列的老库。"""
|
|
116
|
+
if queue_state is None:
|
|
117
|
+
answer = is_answer_pending(row["id"])
|
|
118
|
+
queue_state = queue_state_of(row, running, busy, msg_queued, answer=answer)
|
|
119
|
+
else:
|
|
120
|
+
answer = queue_state == QS_ANSWER
|
|
121
|
+
return {"id": row["id"], "project_id": row["project_id"], "title": row["title"],
|
|
122
|
+
"description": row["description"], "column": row["column_key"],
|
|
123
|
+
"sort_order": row["sort_order"], "session_id": row["session_id"],
|
|
124
|
+
"sessions": json.loads(row["sessions"] or "[]"),
|
|
125
|
+
"block_kind": row["block_kind"], "block_text": row["block_text"],
|
|
126
|
+
"parent_card_id": row["parent_card_id"],
|
|
127
|
+
"origin": row["origin"], "done_at": row["done_at"],
|
|
128
|
+
"trashed": bool(row["trashed"]), "trashed_at": row["trashed_at"],
|
|
129
|
+
"scheduled_at": row["scheduled_at"], "jira_key": row["jira_key"],
|
|
130
|
+
"last_error": row["last_error"], "last_error_at": row["last_error_at"],
|
|
131
|
+
"worktree": _card_opt(row, "worktree"),
|
|
132
|
+
"created_at": row["created_at"], "updated_at": row["updated_at"],
|
|
133
|
+
# 状态有更新·用户未打开(2026-10-07 批次):卡面打「有更新」标记的依据,
|
|
134
|
+
# 置位/清除全在 db 层派生维护,读侧只透传布尔值。
|
|
135
|
+
"unread": bool(_card_opt(row, "unread") or 0),
|
|
136
|
+
"running": running, "busy": busy,
|
|
137
|
+
"answer_pending": answer,
|
|
138
|
+
"msg_queued": msg_queued,
|
|
139
|
+
"queue_state": queue_state}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def comment_json(row):
|
|
143
|
+
"""评论 Row -> 前端 dict。"""
|
|
144
|
+
return {"id": row["id"], "card_id": row["card_id"], "text": row["text"],
|
|
145
|
+
"sent": bool(row["sent"]), "session_id": row["session_id"],
|
|
146
|
+
"sent_text": row["sent_text"], "created_at": row["created_at"]}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def settings_of(project_id):
|
|
150
|
+
"""看板设置(缺省补 SETTINGS_DEFAULT;jira 与 sort 逐字段深合并,
|
|
151
|
+
存量行缺字段时落默认值)。mode 读取层归一:已删档的 readonly-parallel
|
|
152
|
+
(2026-09-17)与任何未知值一律按 serial 处理,存量行无需迁移脚本。"""
|
|
153
|
+
s = dict(SETTINGS_DEFAULT)
|
|
154
|
+
saved = db.get_board_settings(project_id)
|
|
155
|
+
s.update({k: v for k, v in saved.items() if k in s})
|
|
156
|
+
if s["mode"] not in MODE_VALUES:
|
|
157
|
+
s["mode"] = "serial"
|
|
158
|
+
jira = dict(SETTINGS_DEFAULT["jira"])
|
|
159
|
+
jira.update(saved.get("jira") or {})
|
|
160
|
+
s["jira"] = jira
|
|
161
|
+
sort = dict(SETTINGS_DEFAULT["sort"])
|
|
162
|
+
sort.update({k: v for k, v in (saved.get("sort") or {}).items() if k in sort})
|
|
163
|
+
s["sort"] = sort
|
|
164
|
+
return s
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _doing_queue_order(project_id):
|
|
168
|
+
"""doing 列展示序=队序的排序键映射(v2c T2,裁决 R11 小口径;v2d T4 加 ext 行):
|
|
169
|
+
{card_id: (rank, seq)}——c:/ext: 行非 waiting(starting/running/finishing)=
|
|
170
|
+
运行位区 rank0 按 seq(ext 行 target=卡 id:外部条目的运行位次由此接管,
|
|
171
|
+
原「无活跃行落 sort_order 兜底」自然退场);c: 行 waiting=等待区 rank2 按
|
|
172
|
+
seq;无 c: 行的 answer 占位卡以 a: 行 seq 落等待区 rank2(答案单元占用该队位)。
|
|
173
|
+
无活跃行的卡(idle 同步卡)不在映射内——调用方落 rank1 按 sort_order 相对序。"""
|
|
174
|
+
order, answers = {}, []
|
|
175
|
+
for r in waitq.active_items(project_id):
|
|
176
|
+
try:
|
|
177
|
+
tid = int(r["target_id"])
|
|
178
|
+
except (TypeError, ValueError):
|
|
179
|
+
continue
|
|
180
|
+
if r["kind"] in (waitq.KIND_CARD, waitq.KIND_EXT):
|
|
181
|
+
order[tid] = (0 if r["state"] != waitq.WAITING else 2, r["seq"])
|
|
182
|
+
elif r["kind"] == waitq.KIND_ANSWER:
|
|
183
|
+
answers.append((tid, r["seq"]))
|
|
184
|
+
for tid, seq in answers:
|
|
185
|
+
order.setdefault(tid, (2, seq)) # c:/ext: 行优先于 a: 行(同卡双行时)
|
|
186
|
+
return order
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# 任务状态 → 看板列映射(v2c T4,裁决 R16;v2 §2.1【已定】):进行中→doing、
|
|
190
|
+
# 失败/中断/停止→review、完成→done;映射外状态不上板(防御未知新态)
|
|
191
|
+
_TASK_BOARD_COLUMN = {"queued": "doing", "running": "doing",
|
|
192
|
+
"failed": "review", "interrupted": "review",
|
|
193
|
+
"stopped": "review", "done": "done"}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def board_payload(project_id, running_map=None):
|
|
197
|
+
"""看板全量:cards + comments + settings + active_tasks。running_map: card_id -> bool。
|
|
198
|
+
卡片 busy=会话级运行(平台在管或 web 族外部 busy,web_busy_map 批量预计算;
|
|
199
|
+
非 web 族恒 False)。active_tasks=任务条目三列上映(v2c T4,裁决 R16:
|
|
200
|
+
queued/running→doing、failed/interrupted/stopped→review、done→done;
|
|
201
|
+
doing 序=运行中在前+创建先后,review/done 序=结束时间新→旧;前端按条目
|
|
202
|
+
column 字段分列只读渲染,列统计口径=看板卡片不含任务);
|
|
203
|
+
2026-09-06 起测试任务也上板(最初仅 doing 列只读展示,与卡片共用统一队列)。
|
|
204
|
+
卡片 msg_queued=本卡有平台排队中的会话消息单元(chat.queued_card_ids 一次
|
|
205
|
+
批量取集合,2026-09-14——前端据此显示「排队中」徽标)。
|
|
206
|
+
卡片 queue_state(P6 展示派生):queue_states 一次批量算出(answer 活跃行/
|
|
207
|
+
msg 排队集合/外部条目行各一次查询,消除逐卡点查的 N+1 形态)。
|
|
208
|
+
doing 列卡片按 wait_items seq 下发(v2c T2,裁决 R11 小口径:展示序=队序——
|
|
209
|
+
运行位区按 seq、无行卡落运行位区尾、等待区按 seq);其余列维持
|
|
210
|
+
list_board_cards 原序(sort_order 展示序,非队列容器)。"""
|
|
211
|
+
running_map = running_map or {}
|
|
212
|
+
proj = db.get_project(project_id)
|
|
213
|
+
busy_map = web_busy_map(proj, running_map) if proj is not None else {}
|
|
214
|
+
queued_cards = chat.queued_card_ids()
|
|
215
|
+
rows = db.list_board_cards(project_id)
|
|
216
|
+
states = queue_states(proj, rows, running_map, busy_map, queued_cards)
|
|
217
|
+
cards = [card_json(r, bool(running_map.get(r["id"])),
|
|
218
|
+
bool(running_map.get(r["id"]))
|
|
219
|
+
or bool(busy_map.get(r["session_id"])),
|
|
220
|
+
r["id"] in queued_cards,
|
|
221
|
+
states.get(r["id"]))
|
|
222
|
+
for r in rows]
|
|
223
|
+
# doing 列原位重排为队序(稳定:仅 doing 内部相对序变化,他列原序不动)
|
|
224
|
+
doing_order = _doing_queue_order(project_id)
|
|
225
|
+
doing_cards = sorted(
|
|
226
|
+
(c for c in cards if c["column"] == "doing"),
|
|
227
|
+
key=lambda c: doing_order.get(c["id"], (1, c["sort_order"] or 0, c["id"])))
|
|
228
|
+
it = iter(doing_cards)
|
|
229
|
+
cards = [next(it) if c["column"] == "doing" else c for c in cards]
|
|
230
|
+
comments = [comment_json(r) for r in db.list_board_comments(project_id)]
|
|
231
|
+
doing_t, term_t = [], []
|
|
232
|
+
for r in db.list_tasks(project_id):
|
|
233
|
+
col = _TASK_BOARD_COLUMN.get(r["status"])
|
|
234
|
+
if col is None:
|
|
235
|
+
continue # 映射外状态不上板(防御未知新态)
|
|
236
|
+
entry = {"id": r["id"], "project_id": r["project_id"],
|
|
237
|
+
"name": r["name"],
|
|
238
|
+
"task_type": r["task_type"] or "normal",
|
|
239
|
+
"status": r["status"], "current_round": r["current_round"],
|
|
240
|
+
"session_id": r["session_id"], "error": r["error"],
|
|
241
|
+
"created_at": r["created_at"], "started_at": r["started_at"],
|
|
242
|
+
"ended_at": r["ended_at"], "column": col}
|
|
243
|
+
(doing_t if col == "doing" else term_t).append(entry)
|
|
244
|
+
# doing 列保持现状序(运行中在前,排队按创建先后≈统一队列 FIFO 顺序);
|
|
245
|
+
# review/done 列按结束时间新→旧(与 done 列 entered_desc 默认同向)
|
|
246
|
+
doing_t.sort(key=lambda t: (t["status"] != "running", t["created_at"] or ""))
|
|
247
|
+
term_t.sort(key=lambda t: t["ended_at"] or "", reverse=True)
|
|
248
|
+
return {"cards": cards, "comments": comments, "settings": settings_of(project_id),
|
|
249
|
+
"active_tasks": doing_t + term_t}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _descendant_ids(project_id, card_id):
|
|
253
|
+
"""card_id 的全部后代 id 集合(沿 parent_card_id 链向下,防循环依赖用)。"""
|
|
254
|
+
children = {}
|
|
255
|
+
for r in db.list_board_cards(project_id):
|
|
256
|
+
if r["parent_card_id"]:
|
|
257
|
+
children.setdefault(r["parent_card_id"], []).append(r["id"])
|
|
258
|
+
out, stack = set(), [card_id]
|
|
259
|
+
while stack:
|
|
260
|
+
cur = stack.pop()
|
|
261
|
+
for cid in children.get(cur, []):
|
|
262
|
+
if cid not in out:
|
|
263
|
+
out.add(cid)
|
|
264
|
+
stack.append(cid)
|
|
265
|
+
return out
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def set_parent(project_id, card_id, parent_id):
|
|
269
|
+
"""设置父任务依赖。parent_id 为自身/后代/非法值/跨项目返回 'cycle',否则落库返回 None。"""
|
|
270
|
+
if parent_id:
|
|
271
|
+
try:
|
|
272
|
+
parent_id = int(parent_id)
|
|
273
|
+
except (TypeError, ValueError):
|
|
274
|
+
return "cycle" # 非数字输入按非法依赖处理,避免抛异常打穿 API 层
|
|
275
|
+
if parent_id == card_id or parent_id in _descendant_ids(project_id, card_id):
|
|
276
|
+
return "cycle"
|
|
277
|
+
parent = db.get_board_card(parent_id)
|
|
278
|
+
if parent is None or parent["project_id"] != project_id:
|
|
279
|
+
return "cycle" # 父卡不存在或属他人/他项目,按非法依赖处理
|
|
280
|
+
if parent["trashed"]:
|
|
281
|
+
return "cycle" # 父卡已在回收站(列表/依赖树均不可见),按非法依赖处理
|
|
282
|
+
db.update_board_card(card_id, parent_card_id=parent_id or None)
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def move_card(project, card_id, target, block_text="", before_id=None):
|
|
287
|
+
"""移列总入口(服务端仲裁)。返回 (card_dict, None) 或 (None, err_dict)。
|
|
288
|
+
|
|
289
|
+
err_dict 两种:{"error": "..."}(硬错误,列未变——含进 doing 起会话失败回滚原列);
|
|
290
|
+
{"blocked": "parent-not-done"}(父任务拦截,列未变,前端弹确认)。
|
|
291
|
+
queue 拦截不算错误:卡片落「正在开发」列排队占位(block_kind='queue')入统一队列,正常返回。
|
|
292
|
+
规则:doing 不可直接拖回 todo;离开 todo 清定时;doing→doing 同列拖放走
|
|
293
|
+
手势映射(v2c T1,裁决 R10——before_id 不再丢弃、不再直接返回,force/停止/
|
|
294
|
+
调序/no-op 四分支见 _doing_gesture);其余同列早退不变;跨列移卡默认落目标
|
|
295
|
+
列尾(=目标列 MAX+1,与建卡同语义);目标列排序为 manual 且传入 before_id 时
|
|
296
|
+
整列重写 sort_order 把卡插到 before_id 之前(before_id=None=列尾;before_id 不在
|
|
297
|
+
目标列 400——跨列拖拽支持插入任意位置,与列内拖排 reorder 同语义);非 manual
|
|
298
|
+
目标列忽略 before_id(显示顺序由排序方案决定,插入无意义);离开 doing 即出队
|
|
299
|
+
(v3b 容器语义:容器迁移 = 出队,统一原语 `_leave_doing` 先停运行中会话再行
|
|
300
|
+
收口,统一队列自动补位;2026-09-13 起平台不做任何提交,改动提交由外部 hook
|
|
301
|
+
扩展负责);非 doing 列卡移列防御性停会话
|
|
302
|
+
(防评论投递等 headless 续接残留);手动进 blocked 记 block_kind='manual' +
|
|
303
|
+
block_text;进其他列清除阻塞标记;进 doing 委托 _enter_doing(门禁/排队/起
|
|
304
|
+
会话统一语义);任何移列(目标非 doing)放弃该卡待送达的答案(权威取消
|
|
305
|
+
wait_items 行,2026-09-17——卡已移走,迟到送达会唤醒已停会话、与拖拽意图相悖);
|
|
306
|
+
进/出「已完成」先做 dsh 归档/取消归档(2026-10-05 双向同步:任一会话归档失败
|
|
307
|
+
即整体放弃本次移列并 400 报错,见 `_archive_card_sessions`)。
|
|
308
|
+
2026-10-07 起本路径落列一律 `mark_unread=False`:拖列/按钮移列都是用户本人
|
|
309
|
+
在看板上发起的操作,不是「用户没看见的状态更新」,不该由平台反过来提醒用户。
|
|
310
|
+
"""
|
|
311
|
+
card = db.get_board_card(card_id)
|
|
312
|
+
if card is None:
|
|
313
|
+
return None, {"error": "card not found"}
|
|
314
|
+
if target not in COLUMNS:
|
|
315
|
+
return None, {"error": "bad column"}
|
|
316
|
+
src = card["column_key"]
|
|
317
|
+
if src == target:
|
|
318
|
+
if target != "doing":
|
|
319
|
+
return card_json(card), None
|
|
320
|
+
# doing→doing 同列拖放:手势映射(v2c T1,裁决 R10;此前直接返回、
|
|
321
|
+
# before_id 被丢弃)。force/停止/调序/no-op 四分支见 _doing_gesture
|
|
322
|
+
return _doing_gesture(project, card, before_id)
|
|
323
|
+
if src == "doing" and target == "todo":
|
|
324
|
+
return None, {"error": "doing 不可直接拖回 todo"}
|
|
325
|
+
# 归档同步(2026-10-05):进/出「已完成」先做归档面,失败即整体放弃本次移列
|
|
326
|
+
# (硬失败 400 报前端,卡片列不变)。**归档先行**的理由:先落列再归档的话,
|
|
327
|
+
# 失败回滚要连「已停会话/已出队/已改排序」一起退,代价与风险都大;先行失败
|
|
328
|
+
# 则一个字节都没动。拖入 doing 的取消归档在 `_enter_doing` 里做(start 端点
|
|
329
|
+
# 同经该入口,保持单一写点)。
|
|
330
|
+
if target == "done" and src != "done":
|
|
331
|
+
err = _archive_card_sessions(card, True, "进入已完成")
|
|
332
|
+
if err:
|
|
333
|
+
return None, {"error": err}
|
|
334
|
+
if src == "done" and target not in ("done", "doing"):
|
|
335
|
+
err = _archive_card_sessions(card, False, "离开已完成")
|
|
336
|
+
if err:
|
|
337
|
+
return None, {"error": err}
|
|
338
|
+
if target == "doing":
|
|
339
|
+
return _enter_doing(project, card)
|
|
340
|
+
fields = {"column_key": target}
|
|
341
|
+
if target == "blocked":
|
|
342
|
+
fields["block_kind"] = "manual"
|
|
343
|
+
fields["block_text"] = block_text or ""
|
|
344
|
+
else:
|
|
345
|
+
fields["block_kind"] = None
|
|
346
|
+
fields["block_text"] = ""
|
|
347
|
+
if src == "todo":
|
|
348
|
+
fields["scheduled_at"] = None # 离开待开发清定时
|
|
349
|
+
if before_id is not None and settings_of(card["project_id"])["sort"].get(target) == "manual":
|
|
350
|
+
# 跨列插入指定位置(manual 列):整列重写 sort_order,把卡插到 before 前
|
|
351
|
+
# (与 reorder_card 同语义;被移动卡自身序号由 plan 决定,下方统一落库)。
|
|
352
|
+
# 先校验 before_id 在该列,否则 400(防误插他列/凭空卡)
|
|
353
|
+
col_cards = [r for r in db.list_board_cards(card["project_id"])
|
|
354
|
+
if r["column_key"] == target]
|
|
355
|
+
try:
|
|
356
|
+
plan = move_into_plan(col_cards, card_id, before_id)
|
|
357
|
+
except ValueError as e:
|
|
358
|
+
return None, {"error": str(e)}
|
|
359
|
+
with db.connect() as conn:
|
|
360
|
+
conn.executemany("UPDATE board_cards SET sort_order=? WHERE id=?",
|
|
361
|
+
[(s, c) for c, s in plan])
|
|
362
|
+
else:
|
|
363
|
+
# 跨列移卡落目标列尾(manual 列拖入即列尾;与建卡 MAX+1 同语义)
|
|
364
|
+
with db.connect() as conn:
|
|
365
|
+
fields["sort_order"] = conn.execute(
|
|
366
|
+
"SELECT COALESCE(MAX(sort_order),0)+1 FROM board_cards"
|
|
367
|
+
" WHERE project_id=? AND column_key=?",
|
|
368
|
+
(card["project_id"], target)).fetchone()[0]
|
|
369
|
+
db.update_board_card(card_id, mark_unread=False, **fields)
|
|
370
|
+
if is_answer_pending(card_id):
|
|
371
|
+
# 拖离当前列 = 放弃该卡已作答·待送达的答案(2026-09-17,见 docstring)。
|
|
372
|
+
# 覆盖 doing/queue 直接落手动阻塞等不走 stop_card 的路径;停止/删除
|
|
373
|
+
# 路径的清理在 stop_card。P2 起权威取消 + 摘除内存队列键。
|
|
374
|
+
waitq.cancel(waitq.KIND_ANSWER, card_id, "停止/移列放弃")
|
|
375
|
+
inst = runner.INSTANCE
|
|
376
|
+
if inst is not None:
|
|
377
|
+
inst.remove_answer(card_id)
|
|
378
|
+
if card["block_kind"] == "queue":
|
|
379
|
+
# 排队占位拖离:显式出队(拾取判定兜底摘除)。P4 单写:行+占位原子取消,
|
|
380
|
+
# 行已终态/占位已被上方落列写改写则守卫 no-op——runner.INSTANCE 缺位也照清
|
|
381
|
+
waitq.cancel_card_wait(card_id, "卡片出队")
|
|
382
|
+
if src == "doing" and target == "blocked":
|
|
383
|
+
if card["block_kind"] == "queue":
|
|
384
|
+
pass # 排队占位卡无会话无工作区改动(行已在上方显式出队),直接落手动阻塞
|
|
385
|
+
else:
|
|
386
|
+
# 拖入阻塞=离开开发容器 ⇒ 出队(v3b 统一原语:停会话 + 行收口)
|
|
387
|
+
_leave_doing(db.get_board_card(card_id), reason="出队-拖入阻塞")
|
|
388
|
+
elif src == "doing":
|
|
389
|
+
# 离开开发列即出队(用户约定:非 doing 卡不得有活会话)——拖入
|
|
390
|
+
# review/done 时先停会话再行收口;_finish_run 见列非 doing 自动跳过
|
|
391
|
+
# review 流转。主动停(SIGTERM/REST abort)不记 last_error
|
|
392
|
+
_leave_doing(db.get_board_card(card_id), reason="移列释放")
|
|
393
|
+
elif target != "doing":
|
|
394
|
+
# 防御:非 doing 列的卡也不应留活会话(如评论投递在 review 卡拉起的 headless 续接)
|
|
395
|
+
stop_card(card_id)
|
|
396
|
+
return card_json(db.get_board_card(card_id)), None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ---------- doing 列内拖放手势(v2c T1,裁决 R10;v2 §2.4 三手势 + 运行位互拖 no-op) ----------
|
|
400
|
+
|
|
401
|
+
# 运行位分区判据(与 queue_state 派生同源——行即成员:starting/running ∈ 运行
|
|
402
|
+
# 前缀;finishing 为收尾瞬态,按运行位处理——停止手势对其无害、调序无意义)。
|
|
403
|
+
# 运行位判据并入口径见 `_gesture_run_position`(行态 ∪ 在管条目;v3a 起 c: 行
|
|
404
|
+
# 跨轮 running 存活,行态单看已能认出运行位)。
|
|
405
|
+
_GESTURE_RUN_STATES = (waitq.STARTING, waitq.RUNNING, waitq.FINISHING)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _gesture_run_position(card_id):
|
|
409
|
+
"""手势「运行位」判据(收口轮 C1;= 平台持有该卡单元 ∧ 非 answer 占位)。
|
|
410
|
+
|
|
411
|
+
复用 `_platform_holds`(本卡有活跃 c: 行 ∨ 在管条目 `_RUNS`):
|
|
412
|
+
运行位的卡可能正处于两种表征之一——跨轮 c: 行(v3a 起行 running 存活,
|
|
413
|
+
行即持有者)、在管会话(`_RUNS`,起跑/收尾窗口);单看行态在起跑/收尾毫秒
|
|
414
|
+
窗口会漏判,手势随即静默失效(前端按 `card.running` 照弹确认框,服务端 200
|
|
415
|
+
无副作用=确认框说谎,实障根因)——在管条目分支为该形态兜底。
|
|
416
|
+
排除面与前端 `isRunGesture` 逐点对齐:answer 占位卡(a: 活跃行在场)不参与
|
|
417
|
+
手势(T3 fix round 2 口径);外部 busy 卡(sync/外部直跑,无平台持有者)
|
|
418
|
+
三者皆无 → 仍按非队列卡 no-op,不误开手势。
|
|
419
|
+
"""
|
|
420
|
+
return not is_answer_pending(card_id) and _platform_holds(card_id)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _drop_prev_seq(project_id, next_seq, exclude_row_id):
|
|
424
|
+
"""落点上界 seq:项目活跃行中 seq < next_seq(None=列尾取全体)且非被拖行
|
|
425
|
+
的最大 seq;无则 None(区首)。手势落点 reposition 的 prev 界计算。"""
|
|
426
|
+
prev = None
|
|
427
|
+
for r in waitq.active_items(project_id):
|
|
428
|
+
if r["id"] == exclude_row_id:
|
|
429
|
+
continue
|
|
430
|
+
if next_seq is not None and r["seq"] >= next_seq:
|
|
431
|
+
continue
|
|
432
|
+
if prev is None or r["seq"] > prev:
|
|
433
|
+
prev = r["seq"]
|
|
434
|
+
return prev
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _waiting_cards_above(project_id, next_seq):
|
|
438
|
+
"""落点上方(seq < next_seq;None=列尾)的等待区卡数(c: waiting 行)。
|
|
439
|
+
停止手势严格判定用:运行卡拖到等待卡「后面」才算停止——落点上方须确有
|
|
440
|
+
等待卡;落在等待区最前(before 首等待卡)=仍在运行位尾,no-op。"""
|
|
441
|
+
n = 0
|
|
442
|
+
for r in waitq.active_items(project_id):
|
|
443
|
+
if r["kind"] != waitq.KIND_CARD or r["state"] != waitq.WAITING:
|
|
444
|
+
continue
|
|
445
|
+
if next_seq is not None and r["seq"] >= next_seq:
|
|
446
|
+
continue
|
|
447
|
+
n += 1
|
|
448
|
+
return n
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _doing_gesture(project, card, before_id):
|
|
452
|
+
"""doing→doing 同列拖放的手势映射(v2c T1,裁决 R10;v2 §2.4)。
|
|
453
|
+
|
|
454
|
+
分区判据=卡片持有面(活跃 c: 行 ∪ 在管条目;`_gesture_run_position`):
|
|
455
|
+
starting/running/finishing 行 ∈ 运行位(运行前缀成员),waiting 行 ∈ 等待区;
|
|
456
|
+
**运行位的卡由跨轮 running 的 c: 行直接表征,在管条目分支为起跑/收尾窗口兜底**
|
|
457
|
+
——行态单看漏判时手势静默 no-op 而前端照弹确认框;
|
|
458
|
+
无成员面的卡(sync 外部卡永不拾取、idle/answer 占位卡)不参与手势,原样返回
|
|
459
|
+
200。落点分区=before_id 指向卡的成员面;before_id=None=列尾按等待区区尾处理;
|
|
460
|
+
before_id 卡须同项目同列且在队列内,否则 400/无手势。
|
|
461
|
+
四分支:
|
|
462
|
+
|
|
463
|
+
- 等待卡落运行位(before 运行卡)=force 手势(v2 §2.4① 跨过运行位往上
|
|
464
|
+
拖=强制运行,「落哪算哪」例外):_enter_doing(force=True) 立即起跑
|
|
465
|
+
(v2b T4 落表机制:插入前缀尾+直入 starting,起跑证实置行 running),
|
|
466
|
+
再把起跑行 reposition 到落点区间(at=落点 seq,best-effort——起跑已
|
|
467
|
+
成功,挪序失败不翻转手势结果;落点卡无活跃行时无 seq 可参照,跳过挪序);
|
|
468
|
+
- 运行卡落等待区深处(before 等待卡且其上方另有等待卡 / 列尾且有等待
|
|
469
|
+
卡)=停止手势(v2 §2.4② 拖到等待卡后面=停止当前任务):递归走既有
|
|
470
|
+
「离开 doing 先停后释放」路径落待审核——确认框是前端(T3)的事,
|
|
471
|
+
服务端幂等执行;
|
|
472
|
+
- 等待卡落等待区=调序:waitq.reposition 把 c: 行挪到落点(仅 UPDATE
|
|
473
|
+
seq,行 id 稳定;拾取/展示单轨接线在 T2,本批只改队序);
|
|
474
|
+
- 运行卡落运行位=no-op(200,位次/状态零变化;v2 §2.4「允许、无实际
|
|
475
|
+
效果」)。
|
|
476
|
+
"""
|
|
477
|
+
drag_row = waitq.get_active(waitq.KIND_CARD, card["id"])
|
|
478
|
+
drag_run = (_gesture_run_position(card["id"]) if drag_row is None
|
|
479
|
+
else drag_row["state"] in _GESTURE_RUN_STATES)
|
|
480
|
+
if drag_row is None and not drag_run:
|
|
481
|
+
# 非队列卡(sync/idle/answer 占位):无手势语义,原样返回
|
|
482
|
+
return card_json(card), None
|
|
483
|
+
if before_id is not None:
|
|
484
|
+
try:
|
|
485
|
+
before_id = int(before_id)
|
|
486
|
+
except (TypeError, ValueError):
|
|
487
|
+
return None, {"error": "目标位置卡不在该列"}
|
|
488
|
+
if before_id == card["id"]:
|
|
489
|
+
return card_json(card), None # 自己压自己=no-op(防御)
|
|
490
|
+
before_card = db.get_board_card(before_id)
|
|
491
|
+
if before_card is None or before_card["trashed"] \
|
|
492
|
+
or before_card["project_id"] != card["project_id"] \
|
|
493
|
+
or before_card["column_key"] != "doing":
|
|
494
|
+
return None, {"error": "目标位置卡不在该列"}
|
|
495
|
+
before_row = waitq.get_active(waitq.KIND_CARD, before_id)
|
|
496
|
+
if before_row is None:
|
|
497
|
+
if not _gesture_run_position(before_id):
|
|
498
|
+
# 落点卡非队列成员(sync/idle/answer 占位):无手势语义
|
|
499
|
+
return card_json(card), None
|
|
500
|
+
# 落点卡无活跃行但在管(起跑/收尾窗口,_RUNS 承载运行位):按运行位
|
|
501
|
+
# 处理;无行即无落点 seq(force 挪序跳过,best-effort 语义不变)
|
|
502
|
+
before_seq = None
|
|
503
|
+
before_run = True
|
|
504
|
+
else:
|
|
505
|
+
before_seq = before_row["seq"]
|
|
506
|
+
before_run = before_row["state"] in _GESTURE_RUN_STATES
|
|
507
|
+
else:
|
|
508
|
+
before_row = before_seq = None
|
|
509
|
+
before_run = False # 列尾=等待区区尾
|
|
510
|
+
pid = card["project_id"]
|
|
511
|
+
if drag_run:
|
|
512
|
+
if before_run:
|
|
513
|
+
# 运行位互拖:no-op(位次/状态零变化,v2 §2.4「允许、无实际效果」)
|
|
514
|
+
return card_json(card), None
|
|
515
|
+
if _waiting_cards_above(pid, before_seq) == 0:
|
|
516
|
+
# 落等待区最前=运行位尾:no-op(防同位拖放误停运行中会话)
|
|
517
|
+
return card_json(card), None
|
|
518
|
+
# 停止手势:复用既有「离开 doing 先停后释放」路径落待审核(递归一层;
|
|
519
|
+
# 停止/释放/列写全部幂等,竞态下重复执行无害)
|
|
520
|
+
return move_card(project, card["id"], "review")
|
|
521
|
+
if before_run:
|
|
522
|
+
# force 手势:跨过运行位往上拖=强制运行(落哪算哪,v2 §2.2 例外)
|
|
523
|
+
new_card, err = _enter_doing(project, db.get_board_card(card["id"]),
|
|
524
|
+
force=True)
|
|
525
|
+
if err is not None:
|
|
526
|
+
return None, err
|
|
527
|
+
# at=落点 seq 区间:把起跑行挪到目标运行行之前(best-effort;落点卡无活跃
|
|
528
|
+
# 行=无 seq 参照,跳过)
|
|
529
|
+
row = waitq.get_active(waitq.KIND_CARD, card["id"])
|
|
530
|
+
if row is not None and before_seq is not None:
|
|
531
|
+
try:
|
|
532
|
+
waitq.reposition(row["id"],
|
|
533
|
+
_drop_prev_seq(pid, before_seq, row["id"]),
|
|
534
|
+
before_seq)
|
|
535
|
+
except ValueError:
|
|
536
|
+
pass
|
|
537
|
+
return new_card, None
|
|
538
|
+
# 调序手势:等待区内部(before 等待卡 / 列尾)
|
|
539
|
+
prev_seq = _drop_prev_seq(pid, before_seq, drag_row["id"])
|
|
540
|
+
if prev_seq is None and before_seq is None:
|
|
541
|
+
return card_json(card), None # 队列仅自身:无位可挪
|
|
542
|
+
try:
|
|
543
|
+
waitq.reposition(drag_row["id"], prev_seq, before_seq)
|
|
544
|
+
except ValueError:
|
|
545
|
+
return None, {"error": "调序落点非法"}
|
|
546
|
+
return card_json(db.get_board_card(card["id"])), None
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# ---------- 列内手动拖排(manual 模式限定,sort_order 整列重写) ----------
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def reorder_plan(cards, card_id, before_id):
|
|
553
|
+
"""列内重排纯函数(单测覆盖):cards=该列当前有序卡片 row 列表。
|
|
554
|
+
|
|
555
|
+
返回 [(card_id, sort_order)] 全列重写清单(1..n);把 card_id 插到
|
|
556
|
+
before_id 之前(before_id=None 列尾)。card_id/before_id 不在列内抛
|
|
557
|
+
ValueError(调用方转 400)。"""
|
|
558
|
+
ids = [c["id"] for c in cards]
|
|
559
|
+
if card_id not in ids:
|
|
560
|
+
raise ValueError("卡片不在目标列")
|
|
561
|
+
if before_id is not None and before_id not in ids:
|
|
562
|
+
raise ValueError("目标位置卡不在该列")
|
|
563
|
+
if before_id == card_id:
|
|
564
|
+
before_id = None # 自己压自己按列尾处理(防御,正常流程不会发)
|
|
565
|
+
moving = next(c for c in cards if c["id"] == card_id)
|
|
566
|
+
rest = [c for c in cards if c["id"] != card_id]
|
|
567
|
+
out, placed = [], False
|
|
568
|
+
for c in rest:
|
|
569
|
+
if not placed and before_id is not None and c["id"] == before_id:
|
|
570
|
+
out.append(moving)
|
|
571
|
+
placed = True
|
|
572
|
+
out.append(c)
|
|
573
|
+
if not placed:
|
|
574
|
+
out.append(moving)
|
|
575
|
+
return [(c["id"], i + 1) for i, c in enumerate(out)]
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def move_into_plan(col_cards, card_id, before_id):
|
|
579
|
+
"""跨列插入纯函数(单测覆盖):col_cards=目标列当前有序卡片 row 列表
|
|
580
|
+
(不含被移动卡),把 card_id 插到 before_id 之前(before_id=None 列尾)。
|
|
581
|
+
|
|
582
|
+
返回 [(card_id, sort_order)] 整列重写清单(1..n)——被移动卡自身序号含在内,
|
|
583
|
+
调用方统一落库(与 reorder_plan 返回语义一致)。before_id 不在列内抛
|
|
584
|
+
ValueError(调用方转 400)。"""
|
|
585
|
+
if before_id is not None and before_id not in [c["id"] for c in col_cards]:
|
|
586
|
+
raise ValueError("目标位置卡不在该列")
|
|
587
|
+
out, placed = [], False
|
|
588
|
+
for c in col_cards:
|
|
589
|
+
if not placed and before_id is not None and c["id"] == before_id:
|
|
590
|
+
out.append(card_id)
|
|
591
|
+
placed = True
|
|
592
|
+
out.append(c["id"])
|
|
593
|
+
if not placed:
|
|
594
|
+
out.append(card_id)
|
|
595
|
+
return [(cid, i + 1) for i, cid in enumerate(out)]
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def reorder_card(project_id, card_id, before_id):
|
|
599
|
+
"""列内拖排入口(v2c T2,裁决 R11 双轨并单轨)。
|
|
600
|
+
|
|
601
|
+
doing 列:调序落点=wait_items 位次(`_reorder_doing_card`——waiting 行间
|
|
602
|
+
重排,仅 UPDATE seq,sort_order 不动;manual 限定解除,队序即展示序)。
|
|
603
|
+
其余列:仅 manual 模式可用(其余模式 400),sort_order 用直接 SQL 整列
|
|
604
|
+
重写(不走 update_board_card,避免拖排把全列 updated_at 刷掉、干扰
|
|
605
|
+
updated_desc 排序)——非队列容器,现行语义不变。返回 None=成功,
|
|
606
|
+
str=错误(server 转 400)。"""
|
|
607
|
+
card = db.get_board_card(card_id)
|
|
608
|
+
if card is None or card["project_id"] != project_id:
|
|
609
|
+
return "卡片不存在"
|
|
610
|
+
col = card["column_key"]
|
|
611
|
+
if col == "doing":
|
|
612
|
+
return _reorder_doing_card(project_id, card, before_id)
|
|
613
|
+
if settings_of(project_id)["sort"].get(col) != "manual":
|
|
614
|
+
return "当前列排序方案非手动,不可拖排"
|
|
615
|
+
cards = [r for r in db.list_board_cards(project_id) if r["column_key"] == col]
|
|
616
|
+
try:
|
|
617
|
+
plan = reorder_plan(cards, card_id, before_id)
|
|
618
|
+
except ValueError as e:
|
|
619
|
+
return str(e)
|
|
620
|
+
with db.connect() as conn:
|
|
621
|
+
# plan 元组为 (card_id, sort_order),SQL 占位符顺序为 (sort_order, id),
|
|
622
|
+
# 此处换位绑定(brief 原文直绑会把两列互换导致整列错序/空写)
|
|
623
|
+
conn.executemany("UPDATE board_cards SET sort_order=? WHERE id=?",
|
|
624
|
+
[(s, c) for c, s in plan])
|
|
625
|
+
return None
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _reorder_doing_card(project_id, card, before_id):
|
|
629
|
+
"""doing 列调序(v2c T2,裁决 R11):改写 wait_items 队序(waiting 行间
|
|
630
|
+
重排,waitq.reposition 仅 UPDATE seq、行 id 稳定;sort_order 不动)。
|
|
631
|
+
|
|
632
|
+
运行位行(starting/running/finishing)与非队列卡(sync 外部卡/idle/
|
|
633
|
+
answer 占位)不参与:no-op 返回成功——force/停止手势归 move_card 的
|
|
634
|
+
_doing_gesture(T1),本路径只做等待区调序;现行前端 manual 拖排对运行
|
|
635
|
+
卡同样只发 reorder,no-op 后展示层随 payload 队序回正、不刷错误。
|
|
636
|
+
before_id=None=等待区区尾;before_id 指向运行位卡/非队列卡时收拢到
|
|
637
|
+
等待区最前(「排到最前」语义);before_id 卡须同项目同列非回收站,
|
|
638
|
+
否则错误(server 转 400,文案与 reorder_plan 一致)。"""
|
|
639
|
+
if before_id is not None:
|
|
640
|
+
try:
|
|
641
|
+
before_id = int(before_id)
|
|
642
|
+
except (TypeError, ValueError):
|
|
643
|
+
return "目标位置卡不在该列"
|
|
644
|
+
if before_id == card["id"]:
|
|
645
|
+
return None # 自己压自己=no-op(与手势同口径防御)
|
|
646
|
+
before_card = db.get_board_card(before_id)
|
|
647
|
+
if before_card is None or before_card["trashed"] \
|
|
648
|
+
or before_card["project_id"] != project_id \
|
|
649
|
+
or before_card["column_key"] != "doing":
|
|
650
|
+
return "目标位置卡不在该列"
|
|
651
|
+
before_row = waitq.get_active(waitq.KIND_CARD, before_id)
|
|
652
|
+
else:
|
|
653
|
+
before_row = None
|
|
654
|
+
drag_row = waitq.get_active(waitq.KIND_CARD, card["id"])
|
|
655
|
+
if drag_row is None or drag_row["state"] != waitq.WAITING:
|
|
656
|
+
return None # 运行位行/非队列卡不参与调序:no-op
|
|
657
|
+
if before_row is not None and before_row["state"] == waitq.WAITING:
|
|
658
|
+
next_seq = before_row["seq"] # 落点=该等待卡之前
|
|
659
|
+
elif before_id is None:
|
|
660
|
+
next_seq = None # 列尾=等待区区尾
|
|
661
|
+
else:
|
|
662
|
+
# 落点收拢等待区最前:before 运行位/非队列卡 → 排到等待区首
|
|
663
|
+
next_seq = None
|
|
664
|
+
for r in waitq.active_items(project_id):
|
|
665
|
+
if r["kind"] == waitq.KIND_CARD and r["state"] == waitq.WAITING \
|
|
666
|
+
and r["id"] != drag_row["id"]:
|
|
667
|
+
next_seq = r["seq"] # active_items seq 升序:首行即区首
|
|
668
|
+
break
|
|
669
|
+
if next_seq is None:
|
|
670
|
+
return None # 等待区仅自身:无位可挪
|
|
671
|
+
prev_seq = _drop_prev_seq(project_id, next_seq, drag_row["id"])
|
|
672
|
+
if prev_seq is None and next_seq is None:
|
|
673
|
+
return None # 队列仅自身(防御,同手势口径)
|
|
674
|
+
try:
|
|
675
|
+
waitq.reposition(drag_row["id"], prev_seq, next_seq)
|
|
676
|
+
except ValueError:
|
|
677
|
+
return "调序落点非法"
|
|
678
|
+
return None
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
# ---------- 外部条目 ext 行(外部直跑会话的队列成员,v2d T4,裁决 R13/R15) ----------
|
|
682
|
+
# 用户在 dsh GUI 直接开始(或接管)的会话不受平台控制(无法
|
|
683
|
+
# stop/排队),但平台起的任务/卡片是可控的:外部会话在跑时,统一队列的拾起判据
|
|
684
|
+
# 须把它视作占着项目运行位(方向②「外部在跑时平台单元排队等它结束」,2026-09-07
|
|
685
|
+
# 语义修订不变)。v2 模型(设计 §2.2/§2.5.2-5):外部会话以 **wait_items
|
|
686
|
+
# kind=ext 行**入场——target=卡 id、meta 带 sid、入场即 running(不由平台启动)
|
|
687
|
+
# 入运行前缀(复数并存,位次计入),结束/消失经唯一收尾点 `finish("ext:<卡 id>")`
|
|
688
|
+
# 落终态并触发补位(时机③)。原 `_SYNC_BUSY` 内存集合(「有 doing+sync 卡实况
|
|
689
|
+
# busy 的项目」)随行即成员退场;v3d(R8)起**探针口径亦退役**(无注册面、无
|
|
690
|
+
# runner 锁内回调),语义以行判据保留:
|
|
691
|
+
# ① 调度侧 `_pick_locked` **项目外部条目闸**:该项目存在活跃 `ext:` 行 ⇒
|
|
692
|
+
# 本项目本轮不补位(与窗口 N 无关,parallel 亦留队)——判据直接来自行;
|
|
693
|
+
# ② `unit_busy` 同口径(窗口已满 ∨ 存在活跃 ext 行)——提交时「会不会排队」
|
|
694
|
+
# 与调度结论一致;
|
|
695
|
+
# ③ `_pick_locked` 对 `a:` 单元折抵 ext: 键(P2 R1 逐字保留;行口径等价承接)。
|
|
696
|
+
# 探测对象=项目活跃会话集合(设计 §2.5.2-5):单族化后只有 dsh_plugin——读
|
|
697
|
+
# `dshevents` 的进程内快照(**零请求**,见 `_ext_candidates`)。退场族无精确
|
|
698
|
+
# busy 信号不建 ext 行;挂起(interaction)
|
|
699
|
+
# 的会话不算占位(挂起即出队,队列继续执行下一个)。
|
|
700
|
+
# 建行对象=「无平台持有者(活跃 c: 行/在管条目)的实况 busy 会话卡」——sync 卡
|
|
701
|
+
# 天然无持有者,平台卡被用户 dsh GUI 接管(v2d T2 裸 busy
|
|
702
|
+
# 收尾后的窗口,I1 硬移交)同样成占位源:项目不再看着空闲、队列不在同项目并发
|
|
703
|
+
# 起单元;平台持有者在场则不建行(同一卡的前缀 ext:/c: 键双计结构性消除)。
|
|
704
|
+
# 残留窗口(旧探针同源,v3d 明示、设计已接受):外部会话刚起、调和器节拍
|
|
705
|
+
# 尚未建 ext 行时,补位器可能放进一个平台单元与外部会话并发——dsh 侧调和器
|
|
706
|
+
# 已事件化(`_iw_once` 由状态帧唤醒、60s 兜底对账),窗口收窄到事件延迟级
|
|
707
|
+
# (spec/board《看板增强》)。
|
|
708
|
+
# (`_ext_refresh` 的入队/恢复前同步刷新把这一窗口再收窄到外部会话已知的路径)。
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def ext_active(project_id):
|
|
712
|
+
"""项目是否有活跃 ext 行(=外部会话在跑,行读口):展示派生(queue_state 的
|
|
713
|
+
foreign_busy 判定)与 `runner.unit_busy` 的 ext 分量共用;调度侧的项目外部
|
|
714
|
+
条目闸在 `runner._pick_locked` 内直接读行集(同一判据的批量化)。"""
|
|
715
|
+
return waitq.active_ext(project_id)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _platform_holds(card_id):
|
|
719
|
+
"""平台是否持有该卡单元(本卡有活跃 c: 行或在管条目)——ext 行与平台单元
|
|
720
|
+
互斥判据:同一卡不得既被平台启动(活跃 c: 行占位)又有外部直跑行(前缀会
|
|
721
|
+
双计「c:<id>」与「ext:<id>」两个键;外部条目不建 c: 行,只能由本闸互斥)。
|
|
722
|
+
第二消费面:手势运行位判据 `_gesture_run_position`(收口轮 C1)复用本函数
|
|
723
|
+
——正常起跑稳态卡的持有面即「活跃 c: 行 ∨ 在管条目」(`_card_unit_active`
|
|
724
|
+
即既有行判据,不另造第二份;判据读失败按未持有,不阻断建行自愈)。"""
|
|
725
|
+
if _has_active_run(card_id):
|
|
726
|
+
return True
|
|
727
|
+
return _card_unit_active(card_id)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _ext_target(row):
|
|
731
|
+
"""ext 行 target(卡 id 字符串,`finish("ext:<target>")` 键同源)。"""
|
|
732
|
+
return str(row["target_id"])
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def upsert_ext(project, card_id, sid):
|
|
736
|
+
"""外部直跑会话 → ext 行(幂等;v2 §2.2 插入规则表「外部直跑」+ 裁决 R13)。
|
|
737
|
+
|
|
738
|
+
入场即 running 落「运行前缀尾」(insert_after_prefix 几何)、复数并存;
|
|
739
|
+
已有活跃行/Meta sid 缺失/平台持有者在场 → 不建行返回 False。
|
|
740
|
+
返回是否新建(调用方据变化唤醒补位;外部条目插入后前缀多为已占=不启动,
|
|
741
|
+
时机① 的检查照常)。"""
|
|
742
|
+
if not sid or _platform_holds(card_id):
|
|
743
|
+
return False
|
|
744
|
+
if waitq.get_active(waitq.KIND_EXT, card_id) is not None:
|
|
745
|
+
return False
|
|
746
|
+
waitq.insert_ext(project["id"], card_id, sid)
|
|
747
|
+
return True
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _ext_stale_finish(project_id, desired, hold):
|
|
751
|
+
"""项目活跃 ext 行对账:不在目标集(实况已不 busy/卡已非占位源)且非探测
|
|
752
|
+
不明(hold 保行)者经唯一收尾点 finish 收口。返回是否有行被收口。"""
|
|
753
|
+
changed = False
|
|
754
|
+
for row in waitq.active_ext_items(project_id):
|
|
755
|
+
target = _ext_target(row)
|
|
756
|
+
try:
|
|
757
|
+
cid = int(target)
|
|
758
|
+
except ValueError:
|
|
759
|
+
cid = None # 脏 target(理论项):不能建收尾键,跳过
|
|
760
|
+
if cid is not None and (cid in desired or cid in hold):
|
|
761
|
+
continue
|
|
762
|
+
if finish(f"ext:{target}", "外部会话结束"):
|
|
763
|
+
changed = True
|
|
764
|
+
return changed
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _finish_all_ext(project_id, reason):
|
|
768
|
+
"""项目内全部活跃 ext 行收口(退场族/项目已删的防御清理,返回值同
|
|
769
|
+
`_ext_stale_finish`)——退场族无 busy 信号、外部条目不可能有实况佐证,
|
|
770
|
+
残留行留着会永久占前缀位次堵死项目。"""
|
|
771
|
+
changed = False
|
|
772
|
+
for row in waitq.active_ext_items(project_id):
|
|
773
|
+
if finish(f"ext:{_ext_target(row)}", reason):
|
|
774
|
+
changed = True
|
|
775
|
+
return changed
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _session_card_map(cards):
|
|
779
|
+
"""{sid: 卡片 row} 反查(卡片 sessions 并集 + 主会话;绑定口径与
|
|
780
|
+
sync_sessions 的 bound 映射一致——外部条目对账与建卡投影看到同一张卡)。"""
|
|
781
|
+
bound = {}
|
|
782
|
+
for c in cards:
|
|
783
|
+
for s in json.loads(c["sessions"] or "[]"):
|
|
784
|
+
bound[s] = c
|
|
785
|
+
if c["session_id"]:
|
|
786
|
+
bound[c["session_id"]] = c
|
|
787
|
+
return bound
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _ext_candidates(project, cards):
|
|
791
|
+
"""项目活跃会话集合 → 占位目标/探测不明卡集(探测对象改造核,R15)。
|
|
792
|
+
|
|
793
|
+
候选=有会话且非终态列(todo/done)、非挂起 interaction 的卡(与调和器探测
|
|
794
|
+
面同域);平台持有者在场剔除(不能既是平台单元又是外部条目)。返回
|
|
795
|
+
(desired, hold, unbound, unknown):
|
|
796
|
+
- desired:{card_id: sid} 实况 busy(单族:读 `dshevents` 快照);
|
|
797
|
+
- hold:探测不明的卡——保留既有行(宁可多等不可误放行,调用方按「未知」
|
|
798
|
+
保行;dsh 分支探测失败直接抛错,不产 hold);
|
|
799
|
+
- unbound:实况 busy 但尚无卡的 sid(调用方按需补一次 sync_sessions 投影);
|
|
800
|
+
- unknown:不可结论的 sid 集合(调用方判「本轮探测是否已查清」)。
|
|
801
|
+
fam 为 None(退场族)返回空集——调用方按 `_finish_all_ext` 防御收口。
|
|
802
|
+
探测域收窄(2026-09-27,kimi 逐 sid 探测时代的产物,dsh 一次快照即全量):
|
|
803
|
+
只探「绑候选卡 + 未绑卡」会话——绑在 todo/done/挂起 interaction 卡上的
|
|
804
|
+
会话,busy 进不了 desired 也进不了 hold、又不在未绑卡判定面(bound 在册),
|
|
805
|
+
探测结果不影响任何输出。终态列卡的存量 ext 行不再被「探不明」无限保留,
|
|
806
|
+
`_ext_stale_finish` 可正常收口(终态列卡本就不在可建行域,行属残留)。
|
|
807
|
+
"""
|
|
808
|
+
fam = _web_family(project)
|
|
809
|
+
if fam is None:
|
|
810
|
+
return {}, set(), set(), set()
|
|
811
|
+
cards = [c for c in cards if c["session_id"]]
|
|
812
|
+
bound = _session_card_map(cards)
|
|
813
|
+
# 占用候选(可建行)域:非终态列、非挂起 interaction(挂起即出队,豁免面②);
|
|
814
|
+
# 会话→卡反查用全量卡(未绑卡判据不受候选域影响——绑在 todo/done 的会话
|
|
815
|
+
# 不算「未建卡」,不触发投影)
|
|
816
|
+
cand = {c["id"] for c in cards
|
|
817
|
+
if c["column_key"] not in ("todo", "done")
|
|
818
|
+
and not (c["column_key"] == "blocked"
|
|
819
|
+
and c["block_kind"] == "interaction")}
|
|
820
|
+
desired, hold, unbound, unknown = {}, set(), set(), set()
|
|
821
|
+
# 单族化后 fam 恒为 "dsh_plugin"(P7b:kimi/opencode 两族 web 驱动已退场)。
|
|
822
|
+
# 路线 A P4(2026-10-03):读 EventHub 快照(**零请求**)——原实现每轮一次
|
|
823
|
+
# `/live`。P1 打通时这条分支是 dsh「外部直跑占用(ext 行)」的探测口
|
|
824
|
+
# (此前掉进 opencode 分支必然失败,方案 §2.1 #16);事件化后由
|
|
825
|
+
# `agent/status`/`turn/*` 帧实时维护,调和器被事件唤醒时才读本地表。
|
|
826
|
+
# 未连接 ⇒ 抛错,让调用方 `_ext_refresh` 按「探测不可用」返回 None(保既有
|
|
827
|
+
# 行;宁可多等不可误放行)——绝不把未知当空闲。
|
|
828
|
+
if not dshevents.connected():
|
|
829
|
+
raise dshdriver.DshDriverError(-2, "状态事件中枢未连接(未知:保留既有行)")
|
|
830
|
+
rows = list(dshevents.snapshot().values())
|
|
831
|
+
pdir = os.path.abspath(project["project_dir"] or "")
|
|
832
|
+
for r in rows:
|
|
833
|
+
sid = str(r.get("session_id") or "")
|
|
834
|
+
if not sid or r.get("status") != "running":
|
|
835
|
+
continue # 空闲/未知不构成占用
|
|
836
|
+
# 只认本项目工作目录下的会话:dsh 会话池是宿主全局的,跨项目的在跑
|
|
837
|
+
# 会话不能算到本项目头上(ext 行按项目工作目录归属)
|
|
838
|
+
if os.path.abspath(str(r.get("cwd") or "")) != pdir:
|
|
839
|
+
continue
|
|
840
|
+
c = bound.get(sid)
|
|
841
|
+
if c is None:
|
|
842
|
+
unbound.add(sid) # 在跑但平台无卡:调用方补投影后再反查
|
|
843
|
+
elif c["id"] in cand and not _platform_holds(c["id"]):
|
|
844
|
+
desired[c["id"]] = sid
|
|
845
|
+
return desired, hold, unbound, unknown
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _ext_refresh(project):
|
|
849
|
+
"""入队/恢复前同步刷新 ext 行(裁决 R13/R15;闭合「外部会话刚起、调和器尚未
|
|
850
|
+
巡到」的窗口——原 `_sync_busy_refresh` 的同步职责移交本路径)。
|
|
851
|
+
|
|
852
|
+
按项目活跃会话集合精确对账:busy 会话建/保持行(在跑但尚无卡的会话先补一次
|
|
853
|
+
sync_sessions 投影再落行)、实况空闲的行收口。返回 ext 行集合是否变化
|
|
854
|
+
(True/False);探测不可用(事件中枢未连接/会话枚举失败)返回 None=未改动
|
|
855
|
+
(宁可多等不可误放行,旧口径逐字保留)。退场族无 busy 信号:不建行、
|
|
856
|
+
在场残留行防御收口(豁免面③)。
|
|
857
|
+
"""
|
|
858
|
+
fam = _web_family(project)
|
|
859
|
+
if fam is None:
|
|
860
|
+
return _finish_all_ext(project["id"], "退场族无外部占用信号")
|
|
861
|
+
try:
|
|
862
|
+
desired, hold, unbound, unknown = _ext_candidates(
|
|
863
|
+
project, db.list_board_cards(project["id"]))
|
|
864
|
+
if unbound:
|
|
865
|
+
# 实况在跑但尚无卡的会话:补一次 30s 投影(sync_sessions 幂等)再反查,
|
|
866
|
+
# 否则「会话已在跑、平台不知情」窗口照旧(R15 改探测对象的初衷)
|
|
867
|
+
sync_sessions(project)
|
|
868
|
+
desired, hold, unbound, unknown = _ext_candidates(
|
|
869
|
+
project, db.list_board_cards(project["id"]))
|
|
870
|
+
except Exception:
|
|
871
|
+
return None
|
|
872
|
+
if unknown and not desired:
|
|
873
|
+
return None # 探测不明且无确证 busy:本轮不动(保旧值)
|
|
874
|
+
changed = _ext_stale_finish(project["id"], desired, hold)
|
|
875
|
+
for cid, sid in desired.items():
|
|
876
|
+
if upsert_ext(project, cid, sid):
|
|
877
|
+
changed = True
|
|
878
|
+
return changed
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _recover_ext_rows(queued=()):
|
|
882
|
+
"""服务重启时的 ext 行映射(v2 §5 第 4 条;recover 尾部调用):
|
|
883
|
+
|
|
884
|
+
刷新面=**有活跃 ext 行的项目 ∪ 有排队卡的项目**(v2d 收口轮评审项 2:对齐
|
|
885
|
+
旧 `_sync_busy_refresh` 的调用面——平台停机期间外部会话起跑时行尚未落表,
|
|
886
|
+
只按「有行的项目」刷新会让排队卡所在项目在重启后照旧并发起单元)。
|
|
887
|
+
逐项目按活跃会话集合重新精确对账(`_ext_refresh`)——实况仍在 → 行保持
|
|
888
|
+
running(外部会话不受平台重启影响、占位不丢);实况空闲 → finish 收口让出
|
|
889
|
+
前缀位次;项目已删 → 全部行收口;探测不可用(None)→ 行保留(宁可多等)。
|
|
890
|
+
queued:本机排队卡行清单(recover 已算出,复用免二次查询)。
|
|
891
|
+
返回是否有行变化(调用方据变化唤醒补位)。"""
|
|
892
|
+
changed = False
|
|
893
|
+
pids = {row["project_id"] for row in waitq.active_ext_items()}
|
|
894
|
+
pids |= {c["project_id"] for c in (queued or ())}
|
|
895
|
+
for pid in pids:
|
|
896
|
+
proj = db.get_project(pid)
|
|
897
|
+
if proj is None:
|
|
898
|
+
if _finish_all_ext(pid, "服务重启外部条目对账"):
|
|
899
|
+
changed = True
|
|
900
|
+
continue
|
|
901
|
+
if _ext_refresh(proj):
|
|
902
|
+
changed = True
|
|
903
|
+
return changed
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def _enter_doing(project, card, extra="", force=False, worktree=False):
|
|
907
|
+
"""进 doing 统一入口(start 端点 / 拖入 doing / 定时开工共用)。
|
|
908
|
+
|
|
909
|
+
force=true:跳过父依赖与排队直接起会话(用户自担风险),reason 标签按实参分
|
|
910
|
+
「force 直起」/「serial 直起」;v2b T4 起 force 同时**落表**
|
|
911
|
+
(裁决 R12;v2 §2.2 规则二:强制启动→开始运行→放到队列前面)——
|
|
912
|
+
insert_card_force_start 单事务插前缀尾+直入 starting,起跑证实把行置 running
|
|
913
|
+
(v3a:card_started→enter_running 统一入口)留队构成运行前缀(位次计入),
|
|
914
|
+
终态化归 card_finished;
|
|
915
|
+
起跑失败行 failed + 回 from_column(dequeue_start 同款收口);
|
|
916
|
+
runner 单例缺位:不入队直接起(脱离 server 调试退化);
|
|
917
|
+
其余一律入 runner 统一队列(v2b T2 起 parallel 空闲直起废除,裁决 R9/R5——
|
|
918
|
+
serial/parallel 均走补位窗口 serial N=1 / parallel N=5):父依赖拦截返回
|
|
919
|
+
{"blocked": "parent-not-done"}(review 卡打回续改放行,现语义),否则入队——
|
|
920
|
+
入队落点分流(R9):from_column ∈ ("review","blocked")(续跑/手动恢复)经
|
|
921
|
+
原子变体 waitq.insert_card_after_prefix 插「运行前缀后」=等待区最前;
|
|
922
|
+
from_column == "todo"(新建首次起跑)经 waitq.enqueue_card 落等待区末尾
|
|
923
|
+
(现行语义不变)。两入口均单事务写等待项+doing/queue 排队占位(v2a 终审
|
|
924
|
+
契约原子红线);extra/from_column 随 meta 持久化,dequeue_start 消费,
|
|
925
|
+
重启不再丢。
|
|
926
|
+
外部占用(外部条目 ext 行)不经入队判定参与——一律入队后由补位器按
|
|
927
|
+
「前缀成员(ext 行即成员)+ 探针(项目活跃 ext 行在场)」在拾起侧留队;
|
|
928
|
+
入队前的 `_ext_refresh` 同步刷新保留(闭合「外部会话刚起、调和器尚未巡到」
|
|
929
|
+
竞态窗口,v2d T4 由原 `_sync_busy_refresh` 职责移交)。用户在 dsh GUI
|
|
930
|
+
直接开始的外部会话(sync 卡)不受平台控制,不持队列占用;其会话实况 busy
|
|
931
|
+
以 ext 行参与 runner 拾起判据(外部在跑时平台单元等它结束;挂起 interaction
|
|
932
|
+
态除外——挂起即出队)。
|
|
933
|
+
退场族防呆(P7b 单族化,B0 遗留项):`_web_family` 为 None(kimi/opencode/
|
|
934
|
+
claude/hermes/deepseek 旧 CLI 与旧 web 前缀项目)时**不落任何起会话路径**,
|
|
935
|
+
直接返回 {"error": agents.RETIRED_MSG}(server 端点按既有硬错误分支透出
|
|
936
|
+
400 JSON;起会话只有 dsh 插件一条路)。
|
|
937
|
+
锁内只做判定与落列,起会话在锁外(dsh 建会话/投递为 REST 往返,持锁会
|
|
938
|
+
阻塞全平台门禁)。返回 (card_dict, None) | (None, err_dict)。
|
|
939
|
+
|
|
940
|
+
独立 worktree 模式(2026-10-06 批次,plan D1/D3/D10):`worktree=True` 或卡片
|
|
941
|
+
已带 worktree 标记时**本条起跑路径整体转交 `_enter_doing_worktree`**——那条路
|
|
942
|
+
不写 `wait_items` 行(不占项目运行位、不阻塞同项目其他单元),语义与失败收口
|
|
943
|
+
见该函数 docstring;本函数其余分支保持原样(排队/force 直起一字未动)。
|
|
944
|
+
"""
|
|
945
|
+
if _web_family(project) is None:
|
|
946
|
+
# 退场族防呆(见 docstring):不建行、不入队、不动列——直接明确报错
|
|
947
|
+
return None, {"error": agents.RETIRED_MSG}
|
|
948
|
+
# 归档同步(2026-10-05):从「已完成」起跑(拖入开发列 / start 端点同经本入口)
|
|
949
|
+
# 先取消归档——dsh 的 ArchivedSessionGate 会拒绝已归档会话跑模型步,不先解开
|
|
950
|
+
# 续跑起不来。失败即整体放弃(列不动)并 400 报错;本入口是「离开已完成→doing」
|
|
951
|
+
# 的单一写点,move_card 对该目标不再重复调用(一次移列一次调用)。
|
|
952
|
+
if card["column_key"] == "done":
|
|
953
|
+
err = _archive_card_sessions(card, False, "离开已完成")
|
|
954
|
+
if err:
|
|
955
|
+
return None, {"error": err}
|
|
956
|
+
# 独立 worktree 模式(2026-10-06 批次,见 plan D1/D3/D10):用户在下拉里选
|
|
957
|
+
# 「在新 worktree 中开始」(worktree=True),或该卡此前已被标记为 worktree 卡
|
|
958
|
+
# (card.worktree 非空 ⇒ 打回续改/重试/再进开发列自动复用同一工作树)——
|
|
959
|
+
# 一律交给 `_enter_doing_worktree`:起跑但不入统一队列(不写 wait_items 行)。
|
|
960
|
+
if worktree or (_card_opt(card, "worktree") or "").strip():
|
|
961
|
+
return _enter_doing_worktree(project, card, extra, force=force,
|
|
962
|
+
want=bool(worktree))
|
|
963
|
+
if card["column_key"] == "doing" \
|
|
964
|
+
and card["block_kind"] == "queue" and not force:
|
|
965
|
+
# 幂等:已在队列(P4 单态:占位仅 doing+queue,存量 blocked+queue 由启动
|
|
966
|
+
# 迁移归位)。防御性补一次入队(submit_card 自身幂等不重复;行已在则
|
|
967
|
+
# enqueue_card 幂等复用),用户重按开始即可自愈
|
|
968
|
+
if runner.INSTANCE is not None:
|
|
969
|
+
runner.INSTANCE.submit_card(card["id"])
|
|
970
|
+
return card_json(card), None
|
|
971
|
+
# 在管单元预检(v2d T3,裁决 R6):活跃 c: 行(starting/running)或在管会话
|
|
972
|
+
# 条目在途 = 该卡单元已在跑/在起跑窗口——按「会话运行中」拦下。原口径由
|
|
973
|
+
# start_card 的 `_rec_active` 承担(只在起跑调用时才拦,且运行中的卡会被
|
|
974
|
+
# 先入队留下一枚排队占位残影);starting 态显式承担后前移到入口。仅入口面
|
|
975
|
+
# 检查:worker 拾取起跑(dequeue_start)与 force 落表不经过本函数此支。
|
|
976
|
+
if _card_unit_active(card["id"]) or _session_in_flight(card["id"]):
|
|
977
|
+
cur = db.get_board_card(card["id"])
|
|
978
|
+
if cur is None:
|
|
979
|
+
return None, {"error": "卡片不存在"} # 并发删卡竞态守卫(v2d T4)
|
|
980
|
+
return card_json(cur), {"error": "会话运行中,请等待完成或先停止"}
|
|
981
|
+
# 排队判定(v2b T2 起一律入队,裁决 R9/R5):serial/parallel 均入队走
|
|
982
|
+
# 补位窗口(parallel 空闲直起废除);外部占用不再经本判定参与(拾起侧
|
|
983
|
+
# 补位器探针留队,v2d T4 切 ext 行口径)。runner 单例缺位=调试退化直起。
|
|
984
|
+
want_queue = runner.INSTANCE is not None
|
|
985
|
+
force_row_id = None # force 落表行(v2b T4,R12;失败收口/成功证实用)
|
|
986
|
+
with _gate_lock:
|
|
987
|
+
if not force and card["column_key"] != "review":
|
|
988
|
+
parent_id = card["parent_card_id"]
|
|
989
|
+
if parent_id:
|
|
990
|
+
parent = db.get_board_card(parent_id)
|
|
991
|
+
if parent is not None and parent["column_key"] != "done":
|
|
992
|
+
return None, {"blocked": "parent-not-done"}
|
|
993
|
+
if not force and want_queue:
|
|
994
|
+
# 落列与入队统一走锁外 waitq.enqueue_card 单事务(R6);锁内判定/
|
|
995
|
+
# 父依赖门禁不变。排队占位落「正在开发」列(2026-09-06 设计:排队卡
|
|
996
|
+
# 进开发列显示「排队中」徽标,轮到才真正起会话)
|
|
997
|
+
queued = True
|
|
998
|
+
else:
|
|
999
|
+
# 直起(force / runner 缺位退化;v2b T2 起 parallel 直起废除):先把
|
|
1000
|
+
# 可能残留的排队态清干净
|
|
1001
|
+
# 再落 doing——卡片可能正挂在 runner 统一队列里等拾起(serial 入队后用户
|
|
1002
|
+
# force,或 recover 重建入队),不清则 worker 随后拾起与本次直起对撞
|
|
1003
|
+
# (P4 单写:行+占位原子取消;无条件不 gate INSTANCE——表存在即须清,
|
|
1004
|
+
# 行不在/占位已改则守卫 no-op)
|
|
1005
|
+
waitq.cancel_card_wait(card["id"], "force 直起")
|
|
1006
|
+
if force and runner.INSTANCE is not None:
|
|
1007
|
+
# force 落表(v2b T4,裁决 R12;v2 §2.2 规则二:强制启动→开始
|
|
1008
|
+
# 运行→放到队列前面):插入前缀尾(最后一个运行中条目之后)+
|
|
1009
|
+
# 直入 starting(单事务 out-of-band,不经 worker 拾取)——
|
|
1010
|
+
# 起跑证实转 running 留队构成运行前缀(位次计入),终态化归
|
|
1011
|
+
# card_finished(会话结束收口);meta 带 extra/from_column
|
|
1012
|
+
# (与队列 c: 行同型,审计/收口线索)
|
|
1013
|
+
force_row_id, _ = waitq.insert_card_force_start(
|
|
1014
|
+
card["id"], project["id"],
|
|
1015
|
+
meta={"extra": str(extra or ""),
|
|
1016
|
+
"from_column": card["column_key"]})
|
|
1017
|
+
fields = {"column_key": "doing", "block_kind": None, "block_text": "",
|
|
1018
|
+
"last_error": ""} # 落 doing 清陈旧错误(对齐旧 start_into_doing)
|
|
1019
|
+
if card["column_key"] == "todo":
|
|
1020
|
+
fields["scheduled_at"] = None # 离开待开发清定时
|
|
1021
|
+
# 用户本人点「开始/强制」(或 runner 缺位退化直起)引起的落列:
|
|
1022
|
+
# 不置「有更新」标记(排队路径经 waitq 落列,同样不置)
|
|
1023
|
+
db.update_board_card(card["id"], mark_unread=False, **fields)
|
|
1024
|
+
queued = False
|
|
1025
|
+
if queued:
|
|
1026
|
+
# 入队前同步刷新一次外部条目探测(ext 行对账,worker 拾起经
|
|
1027
|
+
# _pick_locked 的「前缀成员 + 探针」两道读取):闭合「外部会话刚起、
|
|
1028
|
+
# 调和器尚未巡到」的竞态窗口。探测不可用返回 None=未改动(宁可多等
|
|
1029
|
+
# 不可误放行);有变化时唤醒补位(时机①)。
|
|
1030
|
+
res = _ext_refresh(project)
|
|
1031
|
+
if res and runner.INSTANCE is not None:
|
|
1032
|
+
runner.INSTANCE.notify_busy_change()
|
|
1033
|
+
# 入队落点分流(v2b T2,R9):续跑/手动恢复(review/blocked)插
|
|
1034
|
+
# 「运行前缀后」=等待区最前;todo 首次起跑落等待区末尾。行+占位在
|
|
1035
|
+
# waitq 单事务内写(此刻卡片行未改列,from_column 取到排队前列——
|
|
1036
|
+
# 起跑失败回该列,R7)
|
|
1037
|
+
runner.INSTANCE.submit_card(
|
|
1038
|
+
card["id"], extra=extra,
|
|
1039
|
+
after_prefix=card["column_key"] in ("review", "blocked"))
|
|
1040
|
+
return card_json(db.get_board_card(card["id"])), None
|
|
1041
|
+
try:
|
|
1042
|
+
start_card(project, db.get_board_card(card["id"]), extra)
|
|
1043
|
+
except RuntimeError as e:
|
|
1044
|
+
# 与 runner 队列拾起(dequeue_start)对撞落败:对方已起跑成功(_RUNS 有活
|
|
1045
|
+
# 条目),放弃回滚、按当前态返回——否则会把对方落好的 doing 踩回原列
|
|
1046
|
+
# (胜者会话照跑,结束后 _finish_run 因列非 doing 跳过 review 流转,卡片滞留)
|
|
1047
|
+
rival_running = _session_in_flight(card["id"])
|
|
1048
|
+
if rival_running:
|
|
1049
|
+
if force_row_id is not None:
|
|
1050
|
+
# 对撞落败:force 落表行收口(对方已起跑,本行不再启动)
|
|
1051
|
+
waitq.mark_failed(force_row_id, "起跑对撞落败")
|
|
1052
|
+
return card_json(db.get_board_card(card["id"])), None
|
|
1053
|
+
# 起会话失败:回滚原列按硬错误返回(last_error 已由 start_card 记录;
|
|
1054
|
+
# runner 占用尚未登记,此路径不得调 finish 释放)
|
|
1055
|
+
if force_row_id is not None:
|
|
1056
|
+
# force 落表行同款收口(dequeue_start 起跑失败:行 failed + 回
|
|
1057
|
+
# from_column——上方回滚写即 from_column;不留 starting 泄漏行
|
|
1058
|
+
# 占前缀,用户重按开始/重 force 重新落行)
|
|
1059
|
+
waitq.mark_failed(force_row_id, str(e))
|
|
1060
|
+
# 回滚同样是用户这次「开始」的收场(错误串已由 400 回执给前端),不置标记
|
|
1061
|
+
db.update_board_card(card["id"], mark_unread=False,
|
|
1062
|
+
column_key=card["column_key"],
|
|
1063
|
+
block_kind=card["block_kind"],
|
|
1064
|
+
block_text=card["block_text"] or "")
|
|
1065
|
+
return None, {"error": str(e)}
|
|
1066
|
+
# 直起路径(force / runner 缺位退化)落表占住运行位(v3d 去占用后无第二登记:
|
|
1067
|
+
# 行即占位),reason 按实参分标签:
|
|
1068
|
+
# force 真参 →「force 直起」,serial 非 force(缺位退化)→「serial 直起」。
|
|
1069
|
+
# v2b T2 起 parallel 一律入队(免登记直起段废除);dequeue_start 队列拾起
|
|
1070
|
+
# 路径不受影响照常落行。
|
|
1071
|
+
if runner.INSTANCE is not None:
|
|
1072
|
+
ext = {"reason": "force 直起" if force else "serial 直起"}
|
|
1073
|
+
if force_row_id is not None:
|
|
1074
|
+
# CLI 判据(R11④,与 worker 起跑 _process_card 同款):有 proc 补
|
|
1075
|
+
# pid 进行 evidence——force 落表行+行证据同证,重启对账才能裁活
|
|
1076
|
+
# (否则 force 起跑无证据判 unknown 残留占前缀)
|
|
1077
|
+
pid = run_pid(card["id"])
|
|
1078
|
+
if pid:
|
|
1079
|
+
ext["pid"] = pid
|
|
1080
|
+
# 六条起跑路径统一:card_started 内部把行置 running(force 落表行
|
|
1081
|
+
# 由 enter_running 幂等续持为 running,不再在此单独 mark_running——
|
|
1082
|
+
# 统一入口才能覆盖「无活跃行/终态行」的 force 与送达恢复形态);
|
|
1083
|
+
# 行跨轮存活、终态化归 card_finished(会话结束收口,R12)
|
|
1084
|
+
runner.INSTANCE.card_started(card["id"], project["id"], ext=ext)
|
|
1085
|
+
return card_json(db.get_board_card(card["id"])), None
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def start_into_doing(project, card, extra="", force=False, worktree=False):
|
|
1089
|
+
"""start 端点专用:语义全部由 _enter_doing 承载(保留函数名,server 调用点不动)。
|
|
1090
|
+
|
|
1091
|
+
worktree=True = 用户在下拉里选「🌿 在新 worktree 中开始」(2026-10-06 批次)。
|
|
1092
|
+
"""
|
|
1093
|
+
return _enter_doing(project, card, extra, force, worktree)
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
# ---------- 独立 worktree 卡起跑(2026-10-06 批次) ----------
|
|
1097
|
+
|
|
1098
|
+
# worktree 卡起跑窗口防重入集合(进程内):这条路径的「worktree 创建 + 建会话」
|
|
1099
|
+
# 合计可达数秒,其间既没有 c: 行(`_card_unit_active` 查不到)也还没登记 `_RUNS`
|
|
1100
|
+
# (`_session_in_flight` 查不到),连点会起两个会话、第二个还会覆盖卡片 worktree
|
|
1101
|
+
# 语义。集合只作起跑窗口互斥,卡 id 进入即登记、finally 必清;超限粗粒度清理
|
|
1102
|
+
# (与 `_STOP_STAMP` 同款「重报无罪」策略,量小且短命)。
|
|
1103
|
+
_WT_STARTING = set()
|
|
1104
|
+
_WT_STARTING_LOCK = threading.Lock()
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def _wt_start_begin(card_id):
|
|
1108
|
+
"""登记 worktree 起跑窗口;已在窗口内返回 False(本次请求让位)。"""
|
|
1109
|
+
with _WT_STARTING_LOCK:
|
|
1110
|
+
if card_id in _WT_STARTING:
|
|
1111
|
+
return False
|
|
1112
|
+
if len(_WT_STARTING) > 256:
|
|
1113
|
+
_WT_STARTING.clear()
|
|
1114
|
+
_WT_STARTING.add(card_id)
|
|
1115
|
+
return True
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def _wt_start_end(card_id):
|
|
1119
|
+
"""释放 worktree 起跑窗口(finally 调用,幂等)。"""
|
|
1120
|
+
with _WT_STARTING_LOCK:
|
|
1121
|
+
_WT_STARTING.discard(card_id)
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _enter_doing_worktree(project, card, extra="", force=False, want=False):
|
|
1125
|
+
"""独立 worktree 起跑(plan D1/D3/D10;**不经统一队列**)。
|
|
1126
|
+
|
|
1127
|
+
与 `_enter_doing` 的队列/force 分支并列的第二条起跑路径:
|
|
1128
|
+
- `want=True`(用户点了「在新 worktree 中开始」):要求卡片**尚无主会话**
|
|
1129
|
+
(D10——dsh 的 resume 不传 cwd,已有会话再按 worktree cwd 续接会造成
|
|
1130
|
+
「平台以为在 worktree、agent 实际仍在主仓库」的静默背离),先 `worktree.create`
|
|
1131
|
+
建/复用工作树,失败按硬错误 400 返回(卡片列不动);
|
|
1132
|
+
- 卡片已带 `card.worktree` 标记(打回续改/重试/再进开发列):直接复用原工作树,
|
|
1133
|
+
不重复创建——worktree 模式随卡片持久化(D3)。
|
|
1134
|
+
|
|
1135
|
+
落列与起会话:`db.update_board_card(column_key='doing', block_kind=None,
|
|
1136
|
+
worktree=<路径>)`(**不写 wait_items 行、不调 submit_card/insert_card_force_start**)
|
|
1137
|
+
→ 锁外 `start_card`(会话 cwd 经 `card_workspace` 取 worktree 路径)。
|
|
1138
|
+
「不落行」的可行性依据(plan §2.2 逐条复核):`_platform_holds` 认 `_RUNS`
|
|
1139
|
+
条目 ⇒ 调和器不会给该卡建 ext 行(不会触发「ext 在场 ⇒ 项目不补位」);
|
|
1140
|
+
`finish()` 无活跃行时跳过行收口、仍搬列 ⇒ 会话结束照常落「待审核」;
|
|
1141
|
+
`selfcheck()` ③ 只报反向形态(卡不在 doing 列却有活跃行);补位器只遍历
|
|
1142
|
+
活跃行 ⇒ 该卡既不排队也不占运行位。副作用:该卡不出现在位次/占用明细读口
|
|
1143
|
+
(`project_holder_details`),与「不进队列」语义一致。
|
|
1144
|
+
|
|
1145
|
+
父依赖门禁与 `_enter_doing` 同口径(worktree 不豁免依赖,只豁免排队);
|
|
1146
|
+
force 语义沿用(跳过父依赖)。起会话失败:回原列 + `last_error`(既有语义),
|
|
1147
|
+
`card.worktree` 标记保留(路径已建,重按开始即复用,不留无主工作树)。
|
|
1148
|
+
返回 (card_dict, None) | (None, err_dict),与 `_enter_doing` 一致。
|
|
1149
|
+
"""
|
|
1150
|
+
card_id = card["id"]
|
|
1151
|
+
card_wt = (_card_opt(card, "worktree") or "").strip()
|
|
1152
|
+
if want and not card_wt and (card["session_id"] or "").strip():
|
|
1153
|
+
# D10:已有主会话(当初在 project_dir 起的)不允许切 worktree 模式
|
|
1154
|
+
return None, {"error": "该卡片已有主会话,无法切换到独立 worktree(请新建卡片)"}
|
|
1155
|
+
# 在管预检(同 `_enter_doing`)+ 起跑窗口互斥(本路径独有,见 _WT_STARTING 注释)
|
|
1156
|
+
if _card_unit_active(card_id) or _session_in_flight(card_id) \
|
|
1157
|
+
or not _wt_start_begin(card_id):
|
|
1158
|
+
cur = db.get_board_card(card_id)
|
|
1159
|
+
if cur is None:
|
|
1160
|
+
return None, {"error": "卡片不存在"}
|
|
1161
|
+
return card_json(cur), {"error": "会话运行中,请等待完成或先停止"}
|
|
1162
|
+
try:
|
|
1163
|
+
with _gate_lock:
|
|
1164
|
+
# 父依赖门禁:与排队路径同口径(worktree 只免排队,不免依赖)
|
|
1165
|
+
if not force and card["column_key"] != "review" and not card_wt:
|
|
1166
|
+
parent_id = card["parent_card_id"]
|
|
1167
|
+
if parent_id:
|
|
1168
|
+
parent = db.get_board_card(parent_id)
|
|
1169
|
+
if parent is not None and parent["column_key"] != "done":
|
|
1170
|
+
return None, {"blocked": "parent-not-done"}
|
|
1171
|
+
# worktree 创建/复用是慢操作(git 子进程,可能数秒),必须在锁外做
|
|
1172
|
+
path, branch, err = worktree.create(project, card_id)
|
|
1173
|
+
if err:
|
|
1174
|
+
return None, {"error": err}
|
|
1175
|
+
with _gate_lock:
|
|
1176
|
+
# 锁外创建期间可能有并发起跑(普通入队被 worker 拾起 / force 直起):
|
|
1177
|
+
# 复检一次,让位给已经在管的那条路,避免双会话
|
|
1178
|
+
if _session_in_flight(card_id):
|
|
1179
|
+
cur = db.get_board_card(card_id)
|
|
1180
|
+
if cur is None:
|
|
1181
|
+
return None, {"error": "卡片不存在"}
|
|
1182
|
+
return card_json(cur), None
|
|
1183
|
+
# 清可能残留的排队态(用户先点了「开始(入队)」又选 worktree 开始;
|
|
1184
|
+
# 或 recover 重建时入过队)——行+占位单事务取消,防 worker 随后拾起对撞
|
|
1185
|
+
waitq.cancel_card_wait(card_id, "worktree 直起")
|
|
1186
|
+
fields = {"column_key": "doing", "block_kind": None, "block_text": "",
|
|
1187
|
+
"last_error": "", "worktree": path}
|
|
1188
|
+
if card["column_key"] == "todo":
|
|
1189
|
+
fields["scheduled_at"] = None # 离开待开发清定时(对齐排队路径)
|
|
1190
|
+
# 用户本人下拉点「在新 worktree 中开始」引起的落列:不置「有更新」标记
|
|
1191
|
+
db.update_board_card(card_id, mark_unread=False, **fields)
|
|
1192
|
+
try:
|
|
1193
|
+
log_path = start_card(project, db.get_board_card(card_id), extra)
|
|
1194
|
+
except RuntimeError as e:
|
|
1195
|
+
# 与并发起跑对撞落败:对方已起跑成功,不动列、直接按当前态返回
|
|
1196
|
+
if _session_in_flight(card_id):
|
|
1197
|
+
return card_json(db.get_board_card(card_id)), None
|
|
1198
|
+
# 起会话失败:回原列(worktree 标记保留,重按开始即复用);本路径
|
|
1199
|
+
# 从未落行,无需行收口(finish 无行时幂等 no-op,此处不调);
|
|
1200
|
+
# 列回退同样是用户这次操作的收场(错误串已由前端 toast 提示),不置标记
|
|
1201
|
+
db.update_board_card(card_id, mark_unread=False,
|
|
1202
|
+
column_key=card["column_key"],
|
|
1203
|
+
block_kind=card["block_kind"],
|
|
1204
|
+
block_text=card["block_text"] or "")
|
|
1205
|
+
return None, {"error": str(e)}
|
|
1206
|
+
# 起跑行落一份 worktree 证据(与 _start_web 的会话日志同文件,便于事后核对)
|
|
1207
|
+
runner.append_log(log_path, f"### worktree 直起 path={path} branch={branch}\n")
|
|
1208
|
+
cur = db.get_board_card(card_id)
|
|
1209
|
+
return card_json(cur, running=True), None
|
|
1210
|
+
finally:
|
|
1211
|
+
_wt_start_end(card_id)
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def worktree_preview(project, card):
|
|
1215
|
+
"""卡片「在新 worktree 中开始」入口的预览(server 端点用,**不落盘**):
|
|
1216
|
+
|
|
1217
|
+
返回 {supported, reason, path, branch, exists}——supported=False 时 reason 是
|
|
1218
|
+
给用户看的中文原因(项目不是 git 仓库 / 卡片已有主会话,D10);前端据此把
|
|
1219
|
+
菜单项置灰并显示原因,避免用户点了必然失败的入口。
|
|
1220
|
+
"""
|
|
1221
|
+
branch = worktree.branch_for(card["id"])
|
|
1222
|
+
if (_card_opt(card, "worktree") or "").strip():
|
|
1223
|
+
# 已是 worktree 卡:续跑自动复用,无需再选(前端一般不再显示该入口)
|
|
1224
|
+
return {"supported": False, "reason": "该卡片已在独立 worktree 中运行",
|
|
1225
|
+
"path": _card_opt(card, "worktree"), "branch": branch,
|
|
1226
|
+
"exists": os.path.isdir(_card_opt(card, "worktree"))}
|
|
1227
|
+
if (card["session_id"] or "").strip():
|
|
1228
|
+
return {"supported": False,
|
|
1229
|
+
"reason": "该卡片已有主会话,不能切换到独立 worktree(请新建卡片)",
|
|
1230
|
+
"path": "", "branch": branch, "exists": False}
|
|
1231
|
+
pv = worktree.preview(project, card["id"])
|
|
1232
|
+
if not pv["ok"]:
|
|
1233
|
+
return {"supported": False, "reason": pv["error"], "path": "",
|
|
1234
|
+
"branch": branch, "exists": False}
|
|
1235
|
+
return {"supported": True, "reason": "", "path": pv["path"],
|
|
1236
|
+
"branch": pv["branch"], "exists": pv["exists"]}
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
def cleanup_card_worktree(project, card):
|
|
1240
|
+
"""清理卡片 worktree(server 端点用)。返回 (ok, err)。
|
|
1241
|
+
|
|
1242
|
+
运行中的卡拒绝(409 语义由 server 落):会话还在跑时删工作树=把 agent 的
|
|
1243
|
+
cwd 抽走,必须由用户先停卡。删除成功后**清空卡片 worktree 标记**(该卡回到
|
|
1244
|
+
普通模式;主会话若已是 worktree cwd,仍可续跑,但下次「开始」不再建树——
|
|
1245
|
+
这属用户显式选择,且 D10 只拦「已有会话切 worktree」,不拦反向)。
|
|
1246
|
+
"""
|
|
1247
|
+
if _card_unit_active(card["id"]) or _session_in_flight(card["id"]):
|
|
1248
|
+
return False, "会话运行中,请先停止卡片再清理 worktree"
|
|
1249
|
+
ok, err = worktree.remove(project, card)
|
|
1250
|
+
if not ok:
|
|
1251
|
+
return False, err
|
|
1252
|
+
db.update_board_card(card["id"], worktree="")
|
|
1253
|
+
return True, ""
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def dequeue_start(project, card):
|
|
1257
|
+
"""runner 拾到排队卡片后的起跑(队列已保证项目串行位)。
|
|
1258
|
+
|
|
1259
|
+
落 doing + 锁外起会话;起会话失败:清占位 + 卡回 from_column 列(随
|
|
1260
|
+
enqueue_card meta 持久化;非法/缺失回落 blocked)返回 False——不再留
|
|
1261
|
+
「占位但已出队」滞留态(P4 明示变更③),last_error 已由 start_card 记录,
|
|
1262
|
+
等待项 failed 由 runner finally 直调负责,用户重按开始即可重新入队。
|
|
1263
|
+
与 force 直起对撞落败(对方已起跑成功)时放弃回滚返回 True(卡片确已起跑,
|
|
1264
|
+
行占位由 _process_card 随后的 card_started 幂等补上)。
|
|
1265
|
+
"""
|
|
1266
|
+
row = waitq.get_active(waitq.KIND_CARD, card["id"])
|
|
1267
|
+
try:
|
|
1268
|
+
meta = json.loads((row["meta"] if row is not None else "") or "{}")
|
|
1269
|
+
if not isinstance(meta, dict):
|
|
1270
|
+
meta = {}
|
|
1271
|
+
except ValueError:
|
|
1272
|
+
meta = {}
|
|
1273
|
+
extra = str(meta.get("extra") or "")
|
|
1274
|
+
from_col = str(meta.get("from_column") or "")
|
|
1275
|
+
with _gate_lock:
|
|
1276
|
+
db.update_board_card(card["id"], column_key="doing",
|
|
1277
|
+
block_kind=None, block_text="", last_error="")
|
|
1278
|
+
try:
|
|
1279
|
+
start_card(project, db.get_board_card(card["id"]), extra)
|
|
1280
|
+
except RuntimeError:
|
|
1281
|
+
# 与 force 直起对撞落败:对方已起跑成功(_RUNS 有活条目),放弃回滚——
|
|
1282
|
+
# 回滚会把对方落好的 doing 踩回原列(胜者会话照跑、结束后 _finish_run
|
|
1283
|
+
# 因列非 doing 跳过 review 流转,卡片滞留)
|
|
1284
|
+
if _session_in_flight(card["id"]):
|
|
1285
|
+
return True
|
|
1286
|
+
# 起跑失败收口(P4 明示变更③):清占位 + 回 from_column 列——不再留
|
|
1287
|
+
# 「占位但已出队」滞留态;等待项终态归属:starting 行=worker finally
|
|
1288
|
+
# 直调(本路径落 failed)/ running,finishing 行=finish()→card_finished
|
|
1289
|
+
# 收口(v2b force 落表行,fix round 1 起门禁 (running,finishing);
|
|
1290
|
+
# v2d T1 收尾归 finish() 唯一收尾点);
|
|
1291
|
+
# last_error 已由 start_card 记录(update_board_card 白名单不触碰)。
|
|
1292
|
+
# 回滚补收口(列先归位,随后经唯一收尾点 finish 补位唤醒;行已由 runner
|
|
1293
|
+
# finally 直调落 failed/cancelled 时,finish 的收口子步幂等 no-op)
|
|
1294
|
+
back = from_col if from_col in COLUMNS and from_col != "doing" else "blocked"
|
|
1295
|
+
db.update_board_card(card["id"], column_key=back,
|
|
1296
|
+
block_kind=None, block_text="")
|
|
1297
|
+
finish(f"c:{card['id']}", "起跑失败回滚")
|
|
1298
|
+
return False
|
|
1299
|
+
return True
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
# ---------- 会话驱动 ----------
|
|
1303
|
+
|
|
1304
|
+
# card_id -> 运行条目(单族:dsh 插件会话在宿主进程内,恒 proc=None):
|
|
1305
|
+
# {"proc": None, "sid", "family", "project_dir", "started_at", "seen_busy",
|
|
1306
|
+
# "aborted", "turn_baseline", "log_path"}
|
|
1307
|
+
# (服务重启后丢失,recover 按会话实况重建)
|
|
1308
|
+
_RUNS = {}
|
|
1309
|
+
_runs_lock = threading.Lock()
|
|
1310
|
+
|
|
1311
|
+
# 单卡片 prompt 长度上限(与 chat.MESSAGE_MAX 对齐)
|
|
1312
|
+
PROMPT_MAX = 20000
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def build_task_prompt(project, card, extra=""):
|
|
1316
|
+
"""全量任务提示词:任务描述(卡片标题并入描述)+ 工作目录 + 附加段。
|
|
1317
|
+
|
|
1318
|
+
2026-09-14 用户约定:向 agent 发任务不区分任务标题与任务描述——标题并入
|
|
1319
|
+
【任务描述】段(与描述空行分隔),不再单列【任务标题】;空标题与卡面展示
|
|
1320
|
+
占位「未命名」(前端详情保存空标题所落的字面量)不参与拼接。
|
|
1321
|
+
2026-09-23 用户约定:去掉【约定】段(提问/审批前先提交本次会话改动)——
|
|
1322
|
+
提交契约不再随首轮 prompt 注入 agent,提交由环境侧自行决定(原可选扩展
|
|
1323
|
+
kimi-hooks 的 session_autocommit hook 已随 kimi 族退场,见 spec/board §18)。
|
|
1324
|
+
2026-09-29 用户约定:去掉尾部「请完成上述开发任务。」(卡 624「这一句可以
|
|
1325
|
+
去掉」,该句无信息增量)——首轮提示词到【工作目录】行收尾,不再补收束句;
|
|
1326
|
+
打回意见段的「请按修改意见继续完善。」是另一句,照旧保留(spec/board §40)。
|
|
1327
|
+
2026-10-06 批次:独立 worktree 卡的【工作目录】改指其 worktree 路径(经
|
|
1328
|
+
`card_workspace`),并追加一行说明分支与主仓库位置——否则 agent 会去动主
|
|
1329
|
+
仓库(plan D7);普通卡提示词一字未变。
|
|
1330
|
+
"""
|
|
1331
|
+
title = (card["title"] or "").strip()
|
|
1332
|
+
if title == "未命名":
|
|
1333
|
+
title = "" # 卡面空标题展示占位,不是任务信息
|
|
1334
|
+
desc = (card["description"] or "").strip()
|
|
1335
|
+
body = "\n\n".join(seg for seg in (title, desc) if seg) or "(无)"
|
|
1336
|
+
workspace = card_workspace(project, card)
|
|
1337
|
+
p = (f"【任务描述】{body}\n"
|
|
1338
|
+
f"【工作目录】{workspace}")
|
|
1339
|
+
wt = (_card_opt(card, "worktree") or "").strip()
|
|
1340
|
+
if wt:
|
|
1341
|
+
p += (f"\n(本任务运行在独立 git worktree 中:分支 {worktree.branch_for(card['id'])},"
|
|
1342
|
+
f"主仓库 {project['project_dir']})")
|
|
1343
|
+
if extra:
|
|
1344
|
+
p += f"\n【修改意见】{extra}\n请按修改意见继续完善。"
|
|
1345
|
+
return p[:PROMPT_MAX]
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def build_continue_prompt(extra=""):
|
|
1349
|
+
"""续跑提示词:卡片已有主会话、再次进「正在开发」时替代全量首轮提示词。
|
|
1350
|
+
|
|
1351
|
+
用户约定(2026-09-11):从阻塞/待审核/已完成再进「正在开发」(拖入、
|
|
1352
|
+
「重试」、「打回」均含)时不重发原任务——主会话里已带首轮任务上下文,
|
|
1353
|
+
重发会被 agent 当作新任务从头再走一遍;只发「继续」让它接着做。
|
|
1354
|
+
打回意见(extra)随行注入,否则用户的修改要求会丢。
|
|
1355
|
+
"""
|
|
1356
|
+
p = "继续"
|
|
1357
|
+
if extra:
|
|
1358
|
+
p += f"\n【修改意见】{extra}\n请按修改意见继续完善。"
|
|
1359
|
+
return p[:PROMPT_MAX]
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
def build_start_prompt(project, card, extra="", sid=""):
|
|
1363
|
+
"""起跑提示词选择:有可续接主会话(sid 非空)→ 续跑「继续」提示词,
|
|
1364
|
+
否则全量首轮提示词(build_task_prompt)。
|
|
1365
|
+
|
|
1366
|
+
sid 由调用方按族判定:单族后只有 dsh_plugin——有可续接主会话(走驱动
|
|
1367
|
+
resume)传其 sid,否则传空串——无可续会话自动回落全量,避免给无上下文的
|
|
1368
|
+
新会话发「继续」。
|
|
1369
|
+
卡上标题/描述即便有编辑一并忽略(用户约定:有会话只发「继续」)。
|
|
1370
|
+
"""
|
|
1371
|
+
if sid:
|
|
1372
|
+
return build_continue_prompt(extra)
|
|
1373
|
+
return build_task_prompt(project, card, extra)
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
# ---------- web 驱动助手(dsh_plugin:会话在 dsh 宿主进程内,经驱动 REST 实时交互) ----------
|
|
1377
|
+
|
|
1378
|
+
# starting 态超时阈值(秒):c: 行「拾取→会话证实」窗口上限(v2d T3,v2 §2.5.2-5,
|
|
1379
|
+
# 裁决 R6)。数值沿用原 WEB_TURN_START_TIMEOUT(90s)宽限补丁——补丁语义并入两处:
|
|
1380
|
+
# ① 行级对账 _reconcile_starting_rows(超龄 starting 行三态复核,调和器 5s 节拍);
|
|
1381
|
+
# ② 会话级宽限 _watch_runs_once(web turn 未起,在管条目复核同用本阈值)。
|
|
1382
|
+
_STARTING_TIMEOUT_S = 90
|
|
1383
|
+
|
|
1384
|
+
# unknown 分支告警节流(v2d T4 收口 T3 复审项③):同卡窗内只报一行——5s 节拍
|
|
1385
|
+
# 此前每拍一行刷屏;滞留卡仍周期性上报(可观测性不退)。
|
|
1386
|
+
_STARTING_ALERT_S = 300
|
|
1387
|
+
_starting_alert_at = {} # card_id -> 上次告警时间戳(进程内;粗粒度回收)
|
|
1388
|
+
_starting_alert_lock = threading.Lock()
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def _starting_alert_due(card_id):
|
|
1392
|
+
"""unknown 分支告警节流判据(见 `_STARTING_ALERT_S`):本卡到窗返回 True。"""
|
|
1393
|
+
now = time.time()
|
|
1394
|
+
with _starting_alert_lock:
|
|
1395
|
+
if len(_starting_alert_at) > 256:
|
|
1396
|
+
_starting_alert_at.clear() # 量小:粗粒度回收(重报无罪)
|
|
1397
|
+
if now - _starting_alert_at.get(card_id, 0) < _STARTING_ALERT_S:
|
|
1398
|
+
return False
|
|
1399
|
+
_starting_alert_at[card_id] = now
|
|
1400
|
+
return True
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def _web_family(project):
|
|
1404
|
+
"""项目 agent 属于 dsh 插件族时返回 "dsh_plugin",否则 None(退场族)。
|
|
1405
|
+
|
|
1406
|
+
单族化(P7b):kimi/opencode 两族(含其 web 驱动模块)已退场——
|
|
1407
|
+
`agents.agent_family` 只回 "dsh_plugin" 或 `agents.RETIRED_FAMILY`
|
|
1408
|
+
("retired",含旧 kimi/opencode/claude/hermes/deepseek CLI 与旧 web 前缀
|
|
1409
|
+
项目),后者一律按 None 处置:调用方据 None 走退场族防呆(见 start_card /
|
|
1410
|
+
`_enter_doing`),不再落任何起会话路径。
|
|
1411
|
+
dsh_plugin(路线 A)按 web 处置:会话不在本地子进程里跑,而是 dsh 宿主
|
|
1412
|
+
进程内的常驻 agent,平台只能「先建会话拿 sid → 投递 → 等 turn 结束」。
|
|
1413
|
+
"""
|
|
1414
|
+
family = agents.agent_family(project["agent_path"] or "")
|
|
1415
|
+
return family if family == "dsh_plugin" else None
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
def _session_state_family(family, project_dir, sid, wait=True):
|
|
1419
|
+
"""三态核心(族参数版;唯一会话状态读路径):单族化后只有 dsh_plugin——
|
|
1420
|
+
读 `dshevents` 的进程内注册表(**零请求**:状态由 dsh 的 agent/status 事件
|
|
1421
|
+
推来;未连接/未知会话 ⇒ unknown,绝不推断空闲);退场族 / 无 sid 无精确
|
|
1422
|
+
信号 → unknown。project_dir / wait 为既有调用面参数(dsh 读口是内存表,
|
|
1423
|
+
不阻塞 REST、取值不影响结果)。不 ensure、不抛异常(一切异常收敛为
|
|
1424
|
+
unknown,由调用方按场景处置)。"""
|
|
1425
|
+
if family is None or not sid:
|
|
1426
|
+
return STATE_UNKNOWN
|
|
1427
|
+
try:
|
|
1428
|
+
if family != "dsh_plugin":
|
|
1429
|
+
return STATE_UNKNOWN
|
|
1430
|
+
# 路线 A P4(2026-10-03):读口从「逐会话 /status」换成 EventHub 的
|
|
1431
|
+
# 进程内注册表——状态由 dsh 的 agent/status 事件推来,**零请求**。
|
|
1432
|
+
# 未连接/未知会话 ⇒ UNKNOWN(=未知,绝不推断空闲;调用方保持现状)。
|
|
1433
|
+
st = dshevents.get(sid)
|
|
1434
|
+
if st is None:
|
|
1435
|
+
return STATE_UNKNOWN
|
|
1436
|
+
return STATE_RUNNING if st.get("status") == "running" else STATE_IDLE
|
|
1437
|
+
except Exception:
|
|
1438
|
+
return STATE_UNKNOWN
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def session_state(proj, sid, wait=True):
|
|
1442
|
+
"""项目会话三态判定:agent_path 归族 + project_dir 定位(见族版核心注释)。"""
|
|
1443
|
+
return _session_state_family(_web_family(proj), proj["project_dir"], sid,
|
|
1444
|
+
wait=wait)
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def unit_liveness(row):
|
|
1448
|
+
"""行判活探针(v3c 行口径,经 runner.set_liveness_probe 注册给
|
|
1449
|
+
reconcile_units/selfcheck_units;锁外慢探测合法;输入=活跃等待项行):
|
|
1450
|
+
web 族卡按会话三态映射——running → alive;
|
|
1451
|
+
idle → dead(waitq 侧 R11a 降级不采信,跨轮间隙不误杀);unknown →
|
|
1452
|
+
unknown(探测失败/状态不明只告警)。退场族卡(无精确 busy 信号)
|
|
1453
|
+
与其他 kind 返回 None——交 waitq 内建 DB 证据(任务/消息行状态、卡行
|
|
1454
|
+
存在性、行 evidence 的 pid 存活)裁活。"""
|
|
1455
|
+
if row["kind"] != waitq.KIND_CARD:
|
|
1456
|
+
return None
|
|
1457
|
+
try:
|
|
1458
|
+
cid = int(row["target_id"])
|
|
1459
|
+
except (ValueError, TypeError):
|
|
1460
|
+
return None
|
|
1461
|
+
card = db.get_board_card(cid)
|
|
1462
|
+
if card is None:
|
|
1463
|
+
return None # 卡行已删:内建证据即可证死,无需探针
|
|
1464
|
+
proj = db.get_project(card["project_id"])
|
|
1465
|
+
if proj is None:
|
|
1466
|
+
return None
|
|
1467
|
+
family = _web_family(proj)
|
|
1468
|
+
if family is None:
|
|
1469
|
+
return None # 退场族卡:无精确 busy 信号,交内建证据
|
|
1470
|
+
st = _session_state_family(family, proj["project_dir"],
|
|
1471
|
+
(card["session_id"] or "").strip())
|
|
1472
|
+
if st == STATE_RUNNING:
|
|
1473
|
+
return "alive"
|
|
1474
|
+
if st == STATE_IDLE:
|
|
1475
|
+
return "dead" # R11a:waitq 侧降级 unknown,不擅动
|
|
1476
|
+
return "unknown"
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def unit_managed(row):
|
|
1480
|
+
"""行所属单元是否仍由平台在管(v3c 修复轮 Important-2;`waitq.selfcheck_units`
|
|
1481
|
+
的 `managed` 判据注入面):看板卡有在管条目(`_RUNS`)⇒ 其收尾归巡视
|
|
1482
|
+
`_finish_run`(R4「`_RUNS`= 收尾归属与实况探测依据」),自检不越权收口其行。
|
|
1483
|
+
|
|
1484
|
+
典型形态:dsh 会话刚结束而巡视尚未收尾——行证据已陈旧/近可证死(在
|
|
1485
|
+
`_unit_verdict` 里),但巡视的 30s 节拍(`_watch_once`)才是收口权威:`_finish_run`
|
|
1486
|
+
→ `finish` → `card_finished` 把行收成 `done`。自检若抢先,`card_finished` 的
|
|
1487
|
+
门禁 (running, finishing) 见行已终态 → 一次成功会话的终态被写死成 `failed`
|
|
1488
|
+
(审计污染 + 瞬时「卡在 doing 却无活跃行」的 I1 自报)。
|
|
1489
|
+
`_RUNS` 无条目的卡死行不受本判据保护——自检是那种行的唯一恢复路径。
|
|
1490
|
+
t:/m:/a: 行不在本判据面(平台在管面=看板卡会话;其行态由执行体收口)。"""
|
|
1491
|
+
if row["kind"] != waitq.KIND_CARD:
|
|
1492
|
+
return False
|
|
1493
|
+
try:
|
|
1494
|
+
cid = int(row["target_id"])
|
|
1495
|
+
except (ValueError, TypeError):
|
|
1496
|
+
return False
|
|
1497
|
+
return _has_active_run(cid)
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def _web_busy(family, project_dir, sid, wait=True):
|
|
1501
|
+
"""web 会话 turn 是否进行中(bool 适配:三态非 running 一律 False;核心不抛异常,
|
|
1502
|
+
调用方既有异常兜底结果不变)。已知边界(m8):busy 为会话级语义——同一 sid 被其他
|
|
1503
|
+
客户端(如 dsh GUI/TUI)使用/插话同样反映为 busy;仅注明,不改逻辑。
|
|
1504
|
+
wait=False(批量面)传透三态核心——dsh 读内存注册表,天然非阻塞。"""
|
|
1505
|
+
return _session_state_family(family, project_dir, sid,
|
|
1506
|
+
wait=wait) == STATE_RUNNING
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
def web_session_busy(proj, sid):
|
|
1510
|
+
"""web 族单会话实况 busy(展示口径:unknown 按 False——不点亮动画不误报)。
|
|
1511
|
+
单族化后只有 dsh_plugin:读插件内存态(无需 ensure:会话活在 dsh 进程内,
|
|
1512
|
+
平台侧没有可拉起的服务);退场族 / 无 sid / 异常一律 False。"""
|
|
1513
|
+
family = _web_family(proj)
|
|
1514
|
+
if family is None or not sid:
|
|
1515
|
+
return False
|
|
1516
|
+
try:
|
|
1517
|
+
return session_state(proj, sid) == STATE_RUNNING
|
|
1518
|
+
except Exception:
|
|
1519
|
+
return False
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def web_busy_map(proj, running_map=None):
|
|
1523
|
+
"""web 族项目会话 busy 批量预计算(board_payload 的卡片 busy 字段源,
|
|
1524
|
+
前端 5s 轮询一次、后端集中算,前端不增请求)。单族化后只有 dsh_plugin:
|
|
1525
|
+
读 EventHub 的进程内快照——**零请求**(原 kimi 逐 sid /status、
|
|
1526
|
+
opencode 项目级 /session/status 两条批量探测路径已随两族退场)。未连接 ⇒ {}
|
|
1527
|
+
(busy 全 False,与「无信号」的展示口径一致:看板据此不点亮,不误判),
|
|
1528
|
+
下一帧事件到达即自动纠正;退场族返回 {}。
|
|
1529
|
+
|
|
1530
|
+
running_map 为既有调用面参数(dsh 分支不消费——探测域收窄是 kimi 逐 sid
|
|
1531
|
+
探测时代的成本优化,dsh 一次快照即全量卡)。"""
|
|
1532
|
+
fam = _web_family(proj)
|
|
1533
|
+
if fam is None:
|
|
1534
|
+
return {}
|
|
1535
|
+
# 路线 A P4(2026-10-03):读 EventHub 的进程内快照——**零请求**。
|
|
1536
|
+
snap = dshevents.snapshot()
|
|
1537
|
+
sids = {c["session_id"] for c in db.list_board_cards(proj["id"])
|
|
1538
|
+
if c["session_id"]}
|
|
1539
|
+
return {sid: bool((snap.get(sid) or {}).get("status") == "running")
|
|
1540
|
+
for sid in sids}
|
|
1541
|
+
|
|
1542
|
+
|
|
1543
|
+
def _web_abort(family, project_dir, sid):
|
|
1544
|
+
"""中断 web 会话当前 turn(单族:dsh_plugin cancel——dsh 的 cancel 保留已
|
|
1545
|
+
流式交付的文本)。family / project_dir 为既有调用面参数(dsh 无本地服务,
|
|
1546
|
+
cancel 只需 sid)。"""
|
|
1547
|
+
dshdriver.cancel(sid)
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
def _web_send(family, project, sid, text, model="", inject=False):
|
|
1551
|
+
"""向 web 会话发 prompt:单族化后只有 dsh_plugin——走进程内 drive 的 followup
|
|
1552
|
+
(busy 时排进 agent inbox,同为服务端排队语义;`inject=True` 改走 steer =
|
|
1553
|
+
注入当前 turn 的最近 step 边界),返回空串(dsh 无 prompt_id 概念,排队行以
|
|
1554
|
+
平台消息 id 表示)。model 为既有调用面参数(dsh 的模型在建会话时定格,续投
|
|
1555
|
+
不再带)。投递是**唯一出口**(`_deliver_now` / 起会话后续投都经这里)。"""
|
|
1556
|
+
chat.dsh_send(sid, text, inject=bool(inject))
|
|
1557
|
+
return ""
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
def _web_turn_baseline(family, project_dir, sid):
|
|
1561
|
+
"""发 prompt 前的 turn 归属基线(M2):dsh_plugin = (轮次结束原因, 会话事件
|
|
1562
|
+
seq)——seq 严格单调,比消息条数更可靠(事件流本身就是计数)。读取失败/
|
|
1563
|
+
未知返回 None(_web_turn_ran 按「跑过」容错,不误判负)。"""
|
|
1564
|
+
try:
|
|
1565
|
+
# P4:读 EventHub 注册表(turn 状态帧带会话事件 seq)——零请求。
|
|
1566
|
+
# 未知(未连接/不在表)返回 None,与读失败同口径(_web_turn_ran 容错)
|
|
1567
|
+
st = dshevents.get(sid)
|
|
1568
|
+
if st is None:
|
|
1569
|
+
return None
|
|
1570
|
+
return (st.get("last_turn_reason") or "", int(st.get("last_seq") or 0))
|
|
1571
|
+
except dshdriver.DshDriverError:
|
|
1572
|
+
return None
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
def _web_turn_ran(rec):
|
|
1576
|
+
"""本卡 turn 是否真跑过(web 监视 90s 未见 busy 时的复核,M2 按归属判定):
|
|
1577
|
+
与 _start_web 发 prompt 前记录的基线比较——dsh_plugin 看轮次原因变化或事件
|
|
1578
|
+
seq 增长。基线缺失或复核异常按「跑过」容错(不误判负)。"""
|
|
1579
|
+
baseline = rec.get("turn_baseline")
|
|
1580
|
+
if baseline is None:
|
|
1581
|
+
return True
|
|
1582
|
+
try:
|
|
1583
|
+
st = dshevents.get(rec["sid"])
|
|
1584
|
+
if st is None:
|
|
1585
|
+
return True # 未知:按「跑过」容错(不误判负)
|
|
1586
|
+
return (st.get("last_turn_reason") or "") != baseline[0] \
|
|
1587
|
+
or int(st.get("last_seq") or 0) > baseline[1]
|
|
1588
|
+
except dshdriver.DshDriverError:
|
|
1589
|
+
return True
|
|
1590
|
+
|
|
1591
|
+
|
|
1592
|
+
def _web_turn_error(rec):
|
|
1593
|
+
"""web 会话 turn 级错误上浮(_finish_run 用):无错返回空串。
|
|
1594
|
+
|
|
1595
|
+
dsh_plugin:驱动的 last_turn_reason(completed 正常;aborted 是用户主动中断
|
|
1596
|
+
不算错误;error/max-tokens/blocked 上浮为 turn 异常)。读口为 EventHub
|
|
1597
|
+
注册表,未知 ⇒ 无错(收尾路径不因此失败)。
|
|
1598
|
+
"""
|
|
1599
|
+
sid = rec.get("sid") or ""
|
|
1600
|
+
if not sid:
|
|
1601
|
+
return ""
|
|
1602
|
+
try:
|
|
1603
|
+
# P4:读 EventHub(turn/end 的归一 reason);未知 ⇒ 无错(收尾不因此失败)
|
|
1604
|
+
reason = str((dshevents.get(sid) or {}).get("last_turn_reason") or "")
|
|
1605
|
+
if reason in ("", "completed", "aborted"):
|
|
1606
|
+
return ""
|
|
1607
|
+
return f"turn 异常结束({reason})"
|
|
1608
|
+
except dshdriver.DshDriverError:
|
|
1609
|
+
return ""
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def _session_in_flight(card_id):
|
|
1613
|
+
"""卡片会话条目在途判定(原 `_rec_active` 判据面,v2d T3 退役改名):单族化
|
|
1614
|
+
后条目恒为 dsh(proc=None),在巡视弹出前在途(起跑证实→收尾之间,宽限期内
|
|
1615
|
+
busy 未起同样在途——防重入起第二会话);proc 分支保留为防御(历史残留条目)。
|
|
1616
|
+
|
|
1617
|
+
只回答「在管条目是否还在跑」,卡片运行态判定面(起跑门禁的 starting 支)
|
|
1618
|
+
已由 `_card_unit_active` 显式承担(裁决 R6)。对撞落败两处(_enter_doing /
|
|
1619
|
+
dequeue_start 的 except 分支)用本判据:对方起跑已登记条目即不抢回滚。
|
|
1620
|
+
"""
|
|
1621
|
+
with _runs_lock:
|
|
1622
|
+
rec = _RUNS.get(card_id)
|
|
1623
|
+
if rec is None:
|
|
1624
|
+
return False
|
|
1625
|
+
proc = rec.get("proc")
|
|
1626
|
+
return proc is None or proc.poll() is None
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def _card_unit_active(card_id):
|
|
1630
|
+
"""活跃 c: 行(starting/running/finishing)在场判定(v2d T3,裁决 R6):
|
|
1631
|
+
starting 态显式承担原「条目存在即运行中」的防重入职责——行在=单元在管
|
|
1632
|
+
(拾取→会话证实窗口、force 落表行),起跑窗口内即便还没登记在管条目也能
|
|
1633
|
+
拦住第二次起跑;finishing(收尾瞬态,v2d T4 收口 T3 复审项⑤)同样在管——
|
|
1634
|
+
收尾窗口内重入会与 finish() 的行收口/搬列抢跑。
|
|
1635
|
+
|
|
1636
|
+
读失败按不在场(不阻断开始路径;后续写操作自会报错)。只在入口面用
|
|
1637
|
+
(`_enter_doing` 预检):worker 拾取起跑(`dequeue_start`)与 force 落表
|
|
1638
|
+
路径自持 starting 行,不查本判据。
|
|
1639
|
+
|
|
1640
|
+
残余(明示,罕见):异常残留的活跃行(如自愈后的 running 行失去在管
|
|
1641
|
+
条目)会让该卡的重启被拦到 recover/重启收口为止——拦下优于「入队复用活跃行
|
|
1642
|
+
却永不拾取」的幽灵态(recover docstring 同款);超龄无证据由 30min 自检
|
|
1643
|
+
告警面暴露(`waitq.selfcheck` v2d T3 扩三态)。
|
|
1644
|
+
"""
|
|
1645
|
+
try:
|
|
1646
|
+
row = waitq.get_active(waitq.KIND_CARD, card_id)
|
|
1647
|
+
except Exception:
|
|
1648
|
+
return False
|
|
1649
|
+
return row is not None and row["state"] in (waitq.STARTING, waitq.RUNNING,
|
|
1650
|
+
waitq.FINISHING)
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
def _save_card_sid(card_id, sid):
|
|
1654
|
+
"""把 sid 并入卡片 session_id/sessions(仿 _finish_run 的归并写法)。
|
|
1655
|
+
卡片已删(起会话中途被删)返回 False,调用方据此放弃启动。"""
|
|
1656
|
+
card = db.get_board_card(card_id)
|
|
1657
|
+
if card is None:
|
|
1658
|
+
return False
|
|
1659
|
+
sessions = json.loads(card["sessions"] or "[]")
|
|
1660
|
+
if sid not in sessions:
|
|
1661
|
+
sessions.append(sid)
|
|
1662
|
+
db.update_board_card(card_id, session_id=sid,
|
|
1663
|
+
sessions=json.dumps(sessions, ensure_ascii=False))
|
|
1664
|
+
return True
|
|
1665
|
+
|
|
1666
|
+
|
|
1667
|
+
def _start_web(project, card, family, extra=""):
|
|
1668
|
+
"""web 驱动起会话(单族:dsh_plugin——会话在 dsh 宿主进程内):建会话(sid
|
|
1669
|
+
立即可知,**建完立即入库**,防后续步骤失败丢会话成孤儿)→ 设模型 → 记 turn
|
|
1670
|
+
归属基线 → 发 prompt → 登记 _RUNS(proc=None,靠事件中枢/巡视收尾,见
|
|
1671
|
+
_watch_once)。
|
|
1672
|
+
|
|
1673
|
+
有主会话时续接原会话(走驱动 resume,同一 sid 上下文续接,打回续改不丢
|
|
1674
|
+
上下文);续接与新建的提示词选择见 build_start_prompt(有可续接主会话只发
|
|
1675
|
+
「继续」);失败记 last_error 并抛 RuntimeError;起会话中途卡片被删则记日志
|
|
1676
|
+
并放弃(m4)。
|
|
1677
|
+
"""
|
|
1678
|
+
sid = (card["session_id"] or "").strip()
|
|
1679
|
+
prompt = build_start_prompt(project, card, extra, sid)
|
|
1680
|
+
lib.ensure_runtime_dirs(project["work_dir"])
|
|
1681
|
+
log_dir = lib.runtime_dir(project["work_dir"], runner.LOG_DIR_NAME)
|
|
1682
|
+
log_path = os.path.join(log_dir, f"board_{card['id']}_{int(time.time())}.log")
|
|
1683
|
+
model = (card["model"] or "").strip() or (project["model"] or "").strip()
|
|
1684
|
+
baseline = None
|
|
1685
|
+
# 卡片级工作目录(2026-10-06 批次):独立 worktree 卡用其 worktree 路径,
|
|
1686
|
+
# 其余仍是 project_dir(`card_workspace` 唯一读口)。会话创建/resume 的 cwd、
|
|
1687
|
+
# 在管条目记账、后续取消都用它——保证「worktree 卡的会话从头到尾在该路径」,
|
|
1688
|
+
# 与 plan §2.6 的「resume 不校验 cwd」风险对齐(创建与续接同路径才不背离)。
|
|
1689
|
+
workspace = card_workspace(project, card)
|
|
1690
|
+
try:
|
|
1691
|
+
runner.append_log(log_path,
|
|
1692
|
+
f"### PROMPT {json.dumps(prompt, ensure_ascii=False)}\n")
|
|
1693
|
+
if workspace != (project["project_dir"] or ""):
|
|
1694
|
+
runner.append_log(log_path, f"### WORKDIR {workspace}(独立 worktree)\n")
|
|
1695
|
+
# 路线 A:会话在 dsh 宿主进程内。新建用 dshdriver.create_session
|
|
1696
|
+
# (插件回执 sid);续接走 resume(同一 sid,上下文续接,对齐
|
|
1697
|
+
# runner._run_round_dshplugin 的 ensure_session 语义)。
|
|
1698
|
+
# 模型值统一 `provider/id`(模型下拉口径)而宿主 `/session` 不拆前缀 ⇒ 先拆开
|
|
1699
|
+
m_provider, m_id = dshdriver.split_model(model)
|
|
1700
|
+
if not sid:
|
|
1701
|
+
sid = dshdriver.create_session(workspace,
|
|
1702
|
+
task=f"card-{card['id']}",
|
|
1703
|
+
model=m_id, provider=m_provider)
|
|
1704
|
+
if not _save_card_sid(card["id"], sid): # 建完即入库
|
|
1705
|
+
runner.append_log(log_path, "### 卡片已被删除,会话启动放弃\n")
|
|
1706
|
+
raise RuntimeError("卡片已被删除,会话启动放弃")
|
|
1707
|
+
else:
|
|
1708
|
+
dshdriver.resume_session(sid, cwd=workspace,
|
|
1709
|
+
task=f"card-{card['id']}",
|
|
1710
|
+
model=m_id, provider=m_provider)
|
|
1711
|
+
# 项目级会话默认值(2026-10-04):思考等级 + 权限档,best-effort 下发
|
|
1712
|
+
# (失败只记日志,不因一个配置项起不来会话——见 dshdriver.apply_session_defaults)。
|
|
1713
|
+
# 取值优先「会话实时态里已有的显式值」(用户在会话详情页改过的档位跨
|
|
1714
|
+
# 「开始/打回续改」存活),否则回落项目默认(新会话即走项目默认)。
|
|
1715
|
+
effort = dshevents.session_effort(sid) or \
|
|
1716
|
+
str(db.row_opt(project, "reasoning_effort") or "").strip()
|
|
1717
|
+
pmode = dshevents.session_permission_mode(sid) or \
|
|
1718
|
+
str(db.row_opt(project, "permission_mode") or "").strip()
|
|
1719
|
+
if effort or pmode:
|
|
1720
|
+
runner.append_log(log_path, f"### 会话默认值 effort={effort or '(默认)'} "
|
|
1721
|
+
f"permission={pmode or '(默认)'}\n")
|
|
1722
|
+
dshdriver.apply_session_defaults(
|
|
1723
|
+
sid, model=m_id, provider=m_provider,
|
|
1724
|
+
reasoning_effort=effort, permission_mode=pmode,
|
|
1725
|
+
log=lambda t: runner.append_log(log_path, t))
|
|
1726
|
+
baseline = _web_turn_baseline(family, workspace, sid)
|
|
1727
|
+
# 起会话首投直接走驱动(与既有 dsh 分支一致):后续投递才经 _web_send
|
|
1728
|
+
# 统一出口(见 _deliver_now)
|
|
1729
|
+
chat.dsh_send(sid, prompt)
|
|
1730
|
+
except (dshdriver.DshDriverError, OSError) as e:
|
|
1731
|
+
db.update_board_card(card["id"], last_error=f"会话启动失败: {e}",
|
|
1732
|
+
last_error_at=int(time.time() * 1000))
|
|
1733
|
+
raise RuntimeError(f"会话启动失败: {e}")
|
|
1734
|
+
# 续接路径 sid 本就在库(归并写法幂等);此处兜建会话后卡片被删等边角(m4)
|
|
1735
|
+
if not _save_card_sid(card["id"], sid):
|
|
1736
|
+
runner.append_log(log_path, "### 卡片已被删除,会话启动放弃\n")
|
|
1737
|
+
raise RuntimeError("卡片已被删除,会话启动放弃")
|
|
1738
|
+
runner.append_log(log_path, f"### WEB {family} sid={sid} "
|
|
1739
|
+
f"model={model or '(宿主默认)'}\n")
|
|
1740
|
+
with _runs_lock:
|
|
1741
|
+
old = _RUNS.get(card["id"])
|
|
1742
|
+
if old is not None:
|
|
1743
|
+
# 双保险(正常路径已被 start_card 的 _session_in_flight + 入口
|
|
1744
|
+
# _card_unit_active 拦下,此为并发
|
|
1745
|
+
# 竞态防御):已有条目则中断本次启动,尽力 abort 刚建
|
|
1746
|
+
# 的会话回收之,防同卡双会话脱管
|
|
1747
|
+
try:
|
|
1748
|
+
_web_abort(family, workspace, sid)
|
|
1749
|
+
except dshdriver.DshDriverError:
|
|
1750
|
+
pass
|
|
1751
|
+
runner.append_log(log_path,
|
|
1752
|
+
"### 拒绝登记:已有运行中会话,本次会话已 abort\n")
|
|
1753
|
+
raise RuntimeError("会话运行中,拒绝重复启动")
|
|
1754
|
+
_RUNS[card["id"]] = {"proc": None, "sid": sid, "family": family,
|
|
1755
|
+
"project_dir": workspace,
|
|
1756
|
+
"started_at": int(time.time() * 1000),
|
|
1757
|
+
"seen_busy": False, "aborted": False,
|
|
1758
|
+
"turn_baseline": baseline, "log_path": log_path}
|
|
1759
|
+
return log_path
|
|
1760
|
+
|
|
1761
|
+
|
|
1762
|
+
def start_card(project, card, extra=""):
|
|
1763
|
+
"""开始开发 / 打回续改:起 dsh 插件实时会话(见 _start_web)。
|
|
1764
|
+
|
|
1765
|
+
提示词选择见 build_start_prompt:有可续接的主会话只发「继续」,
|
|
1766
|
+
否则(首跑 / 无可续会话)全量首轮提示词。
|
|
1767
|
+
|
|
1768
|
+
会话条目在途(_session_in_flight:web 条目未弹出):
|
|
1769
|
+
抛 RuntimeError(防重复起会话顶掉 _RUNS 在管条目)。
|
|
1770
|
+
**起跑窗口的 starting 态防重入**(原 `_rec_active`「条目存在即运行中」职责,
|
|
1771
|
+
v2d T3 裁决 R6)落在入口侧:`_enter_doing` 先过 `_card_unit_active`(活跃 c:
|
|
1772
|
+
行)再走本函数——本函数自身不能查该判据(worker 拾取起跑路径自持 starting 行)。
|
|
1773
|
+
退场族防呆(P7b 单族化,B0 遗留项):`_web_family` 为 None(旧 CLI / 旧 web
|
|
1774
|
+
前缀项目)时抛 RuntimeError(agents.RETIRED_MSG),不落任何起会话路径——
|
|
1775
|
+
调用方(_enter_doing / dequeue_start)按既有 RuntimeError 分支回滚并透出
|
|
1776
|
+
4xx JSON。
|
|
1777
|
+
启动失败:记 last_error 并抛 RuntimeError。
|
|
1778
|
+
"""
|
|
1779
|
+
if _session_in_flight(card["id"]):
|
|
1780
|
+
raise RuntimeError("会话运行中,请等待完成或先停止")
|
|
1781
|
+
family = _web_family(project)
|
|
1782
|
+
if family is None:
|
|
1783
|
+
# 退场族防呆(见 docstring):记 last_error 后抛(对齐启动失败约定)
|
|
1784
|
+
db.update_board_card(card["id"], last_error=agents.RETIRED_MSG,
|
|
1785
|
+
last_error_at=int(time.time() * 1000))
|
|
1786
|
+
raise RuntimeError(agents.RETIRED_MSG)
|
|
1787
|
+
return _start_web(project, card, family, extra)
|
|
1788
|
+
|
|
1789
|
+
|
|
1790
|
+
def deliver_comment(project, card, comment_row, inject=False, raw=False):
|
|
1791
|
+
"""评论投递主会话(默认包装【看板评论】前缀;raw=True 直发原文——会话
|
|
1792
|
+
详情页发送路径专用,用户本就在会话中对话,无需任务上下文包装;卡片评论
|
|
1793
|
+
区「投递」维持前缀以区分消息来源。两条路径都照旧落评论记录 sent_text)。
|
|
1794
|
+
|
|
1795
|
+
2026-09-10(统一队列消息单元):不再立即投递——登记会话消息单元进 runner
|
|
1796
|
+
统一队列,项目忙(同项目有任务/卡片会话在跑)时按入队顺序等待,项目空闲才
|
|
1797
|
+
投递;投递执行期间单元持有项目占用,故卡片会话也不会与项目内任务并发改代码。
|
|
1798
|
+
单族化后只有 dsh_plugin:目标会话在跑时普通投递走 followup(忙时排进 agent
|
|
1799
|
+
inbox,等同服务端排队,不拒绝);inject=True 的「立即注入」在执行体内走
|
|
1800
|
+
steer(见 chat.inject_now 与 _deliver_now)。
|
|
1801
|
+
"""
|
|
1802
|
+
family = _web_family(project)
|
|
1803
|
+
sid = card["session_id"]
|
|
1804
|
+
if raw:
|
|
1805
|
+
wrapped = comment_row["text"]
|
|
1806
|
+
else:
|
|
1807
|
+
wrapped = f"【看板评论】任务「{card['title']}」: {comment_row['text']}"
|
|
1808
|
+
# 进队前的前置校验:拦住必然失败的投递(无会话 / 退场族无投递通道)
|
|
1809
|
+
if family is None:
|
|
1810
|
+
raise RuntimeError(agents.RETIRED_MSG)
|
|
1811
|
+
if not sid:
|
|
1812
|
+
raise RuntimeError("尚无会话,请先开始开发")
|
|
1813
|
+
inst = runner.INSTANCE
|
|
1814
|
+
if inst is None:
|
|
1815
|
+
# 无统一队列(单测/独立脚本):保持既有立即投递行为
|
|
1816
|
+
_deliver_now(project, card, comment_row, wrapped, inject)
|
|
1817
|
+
return None
|
|
1818
|
+
# 排队中的消息可「立即注入」(见 chat.inject_now):family/model/
|
|
1819
|
+
# comment_id 提交时定格进等待项 meta(chat_msgs 零加列,P3 裁决 R1),执行时
|
|
1820
|
+
# 由 chat 侧按行+快照重建 _deliver_unit/_deliver_now
|
|
1821
|
+
return chat.submit(project["id"], sid, wrapped,
|
|
1822
|
+
card_id=card["id"], comment_id=comment_row["id"],
|
|
1823
|
+
inject=inject,
|
|
1824
|
+
family=runner.agent_family(project["agent_path"] or ""),
|
|
1825
|
+
model=(card["model"] or "").strip()
|
|
1826
|
+
or (project["model"] or "").strip())
|
|
1827
|
+
|
|
1828
|
+
|
|
1829
|
+
def _deliver_unit(project, card, comment_row, wrapped, inject):
|
|
1830
|
+
"""统一队列消息单元执行体:投递评论并等该会话结束(执行期间持有项目占用)。
|
|
1831
|
+
|
|
1832
|
+
返回即释放占用:dsh 等 busy 出现再回落(与任务 turn 同款判定,见
|
|
1833
|
+
chat.wait_web_busy);返回 chat.STATE_YIELDED = turn 挂起等作答、已让位
|
|
1834
|
+
(chat.run_unit 据此落终态)。尾部 proc 分支为 CLI 时代的防御残留
|
|
1835
|
+
(单族化后 _RUNS 条目恒为 dsh,proc=None)。
|
|
1836
|
+
"""
|
|
1837
|
+
_deliver_now(project, card, comment_row, wrapped, inject)
|
|
1838
|
+
family = _web_family(project)
|
|
1839
|
+
if family is not None:
|
|
1840
|
+
return chat.wait_web_busy(project["project_dir"], card["session_id"],
|
|
1841
|
+
family)
|
|
1842
|
+
with _runs_lock:
|
|
1843
|
+
rec = _RUNS.get(card["id"])
|
|
1844
|
+
proc = rec.get("proc") if rec is not None else None
|
|
1845
|
+
if proc is not None:
|
|
1846
|
+
proc.wait()
|
|
1847
|
+
|
|
1848
|
+
|
|
1849
|
+
def _deliver_now(project, card, comment_row, wrapped, inject=False):
|
|
1850
|
+
"""评论真正送达(立即路径与统一队列消息单元执行体共用;wrapped 为最终文本)。
|
|
1851
|
+
|
|
1852
|
+
单族化后只有 dsh_plugin:followup 忙时排进 agent inbox(等同服务端排队
|
|
1853
|
+
语义,不拒绝);inject=True 走 steer,注入当前 turn 的最近 step 边界
|
|
1854
|
+
(steer 失败不致命,评论仍在 inbox/队列)。退场族无投递通道,直接报错。
|
|
1855
|
+
"""
|
|
1856
|
+
family = _web_family(project)
|
|
1857
|
+
sid = card["session_id"]
|
|
1858
|
+
if family is not None:
|
|
1859
|
+
try:
|
|
1860
|
+
# dsh 的「立即注入」= steer(注入当前 turn 的最近 step 边界);
|
|
1861
|
+
# 普通投递 = followup(忙时排进 inbox,等同服务端排队);
|
|
1862
|
+
# 统一走 _web_send(单族投递唯一出口,测试打桩面也在此)
|
|
1863
|
+
_web_send(family, project, sid, wrapped, inject=bool(inject))
|
|
1864
|
+
except dshdriver.DshDriverError as e:
|
|
1865
|
+
raise RuntimeError(f"评论投递失败: {e}")
|
|
1866
|
+
db.update_board_comment(comment_row["id"], sent=1, session_id=sid,
|
|
1867
|
+
sent_text=wrapped)
|
|
1868
|
+
return
|
|
1869
|
+
# 退场族无投递通道(单族化):明确报错,不落已删除的 CLI 起子进程路径
|
|
1870
|
+
raise RuntimeError(agents.RETIRED_MSG)
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
# 卡片的「平台刚停过会话」时间戳(stop_card 登记):拖入阻塞等路径会先停会话,
|
|
1874
|
+
# web abort 生效前 busy 可能短暂仍为真——调和器在宽限期内不据 busy 做
|
|
1875
|
+
# 「阻塞→开发」搬列,防拖入阻塞瞬间被回弹(见 _stop_recent)。
|
|
1876
|
+
_STOP_STAMP = {}
|
|
1877
|
+
_STOP_GRACE_S = 15
|
|
1878
|
+
|
|
1879
|
+
|
|
1880
|
+
def _stop_recent(card_id):
|
|
1881
|
+
"""卡片是否刚被平台停过会话(宽限期内,见 _STOP_STAMP 注释)。"""
|
|
1882
|
+
ts = _STOP_STAMP.get(card_id)
|
|
1883
|
+
return ts is not None and (time.time() - ts) < _STOP_GRACE_S
|
|
1884
|
+
|
|
1885
|
+
|
|
1886
|
+
def stop_card(card_id):
|
|
1887
|
+
"""停止卡片运行中的会话(单族:dsh 走驱动 REST cancel;防御分支仍认
|
|
1888
|
+
进程组 kill)。返回是否命中。
|
|
1889
|
+
|
|
1890
|
+
先取消该卡片排队中的会话消息(2026-09-10:评论投递走统一队列,停止即放弃
|
|
1891
|
+
排队消息;已开始执行的投递随会话停止一并中断)。平台未在管(_RUNS 无记录)
|
|
1892
|
+
时的兜底:dsh 会话在 dsh 宿主进程内托管,按其 sid 仍可 REST cancel——覆盖
|
|
1893
|
+
外部同步卡(用户在 dsh GUI 直跑的会话自动建卡,不登记 _RUNS)与会话详情
|
|
1894
|
+
端点按实况 busy 点亮停止按钮的场景;无 sid 则无停止通道,返回 False。
|
|
1895
|
+
每次调用登记 _STOP_STAMP(调和器宽限期,见 _stop_recent):abort 生效前
|
|
1896
|
+
busy 可能短暂残留,拖入阻塞的卡片不被调和器按残留 busy 回搬开发列。
|
|
1897
|
+
同时放弃该卡待送达的答案(权威取消,2026-09-17;P2 起走 waitq.cancel):卡已停/被
|
|
1898
|
+
移列,迟到送达会唤醒已停会话、与用户意图相悖(同款「先取消排队消息」)。
|
|
1899
|
+
等待区卡(doing/queue 占位)停止即出队收口(v3b `_dequeue_card`:行取消 +
|
|
1900
|
+
归位待审核);运行中卡的收口归会话真正结束后的 `_finish_run`(会话未收尾前
|
|
1901
|
+
不得放行串行位)。"""
|
|
1902
|
+
now = time.time()
|
|
1903
|
+
_STOP_STAMP[card_id] = now
|
|
1904
|
+
if len(_STOP_STAMP) > 256: # 长期运行防累积:超限时清掉已过期项
|
|
1905
|
+
for cid in [c for c, t in _STOP_STAMP.items() if now - t > _STOP_GRACE_S]:
|
|
1906
|
+
_STOP_STAMP.pop(cid, None)
|
|
1907
|
+
chat.cancel_queued(card_id=card_id)
|
|
1908
|
+
if is_answer_pending(card_id): # 放弃该卡待送达答案(见 docstring)
|
|
1909
|
+
waitq.cancel(waitq.KIND_ANSWER, card_id, "停止/移列放弃")
|
|
1910
|
+
inst = runner.INSTANCE
|
|
1911
|
+
if inst is not None:
|
|
1912
|
+
inst.remove_answer(card_id)
|
|
1913
|
+
with _runs_lock:
|
|
1914
|
+
rec = _RUNS.get(card_id)
|
|
1915
|
+
if rec is None:
|
|
1916
|
+
card = db.get_board_card(card_id)
|
|
1917
|
+
if card is None:
|
|
1918
|
+
return False
|
|
1919
|
+
if card["column_key"] == "doing" and card["block_kind"] == "queue":
|
|
1920
|
+
# 等待区卡停止(v2b T3,裁决 R9:任意队列中卡可停止→待审核;原
|
|
1921
|
+
# 「等待区卡无停止入口」废除):从未起跑或续跑占位——无会话可停,
|
|
1922
|
+
# 统一走出队收口(v3b `_dequeue_card`:等待行取消/运行行终态化 +
|
|
1923
|
+
# 补位唤醒,to_column 一并归位待审核);排队消息/待送达答案已在
|
|
1924
|
+
# 上方先行取消
|
|
1925
|
+
_dequeue_card(card_id, "用户停止", to_column="review", mark_unread=False)
|
|
1926
|
+
return True
|
|
1927
|
+
proj = db.get_project(card["project_id"])
|
|
1928
|
+
if proj is None:
|
|
1929
|
+
return False
|
|
1930
|
+
fam = _web_family(proj)
|
|
1931
|
+
if fam is None:
|
|
1932
|
+
return False
|
|
1933
|
+
sid = (card["session_id"] or "").strip()
|
|
1934
|
+
if not sid:
|
|
1935
|
+
return False
|
|
1936
|
+
try:
|
|
1937
|
+
# P7a 缺陷 A:dsh 会话活在 dsh 宿主进程内,平台侧**没有可拉起的
|
|
1938
|
+
# 本地服务**——直接走驱动 cancel(原 kimi/oc 的 ensure_started 段已
|
|
1939
|
+
# 随两族退场;原先漏判会落 opencode 兜底把 `dsh-plugin:<路径>` 当
|
|
1940
|
+
# opencode 可执行文件 spawn → FileNotFoundError 打穿 HTTP handler,
|
|
1941
|
+
# 卡片「通过」/拖到 done/review/blocked 全部表现为连接被掐)。
|
|
1942
|
+
_web_abort(fam, proj["project_dir"], sid)
|
|
1943
|
+
return True
|
|
1944
|
+
except (dshdriver.DshDriverError, OSError):
|
|
1945
|
+
# OSError:网络/驱动异常不再打穿请求处理器
|
|
1946
|
+
return False
|
|
1947
|
+
if rec.get("proc") is None:
|
|
1948
|
+
# web 驱动:先锁内标记 aborted(宽限期内 turn 未起即被 stop 时,
|
|
1949
|
+
# _watch_once 据此按正常停止收尾而不误判负,M3),再走 REST abort;
|
|
1950
|
+
# abort 后 busy 回落,由 _watch_once 正常收尾(不算错误停止)
|
|
1951
|
+
with _runs_lock:
|
|
1952
|
+
rec["aborted"] = True
|
|
1953
|
+
try:
|
|
1954
|
+
_web_abort(rec["family"], rec["project_dir"], rec["sid"])
|
|
1955
|
+
return True
|
|
1956
|
+
except dshdriver.DshDriverError:
|
|
1957
|
+
return False
|
|
1958
|
+
if rec["proc"].poll() is not None:
|
|
1959
|
+
return False
|
|
1960
|
+
try:
|
|
1961
|
+
platcompat.kill_tree(rec["proc"].pid, signal.SIGTERM)
|
|
1962
|
+
return True
|
|
1963
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
1964
|
+
return False
|
|
1965
|
+
|
|
1966
|
+
|
|
1967
|
+
def running_map():
|
|
1968
|
+
"""card_id -> 是否有运行中的会话(单族条目恒 proc=None = 存在即运行中——
|
|
1969
|
+
宽限期内 busy 未起也报 True,防误判空闲;v2d T3 `_rec_active` 退役后本函数
|
|
1970
|
+
自持该展示口径,调度判定面改走 `_session_in_flight` + `_card_unit_active`
|
|
1971
|
+
显式事实面;
|
|
1972
|
+
已 abort 的 web 条目即报 False,不等巡视弹出(停止按钮即时反馈,
|
|
1973
|
+
收尾仍由 _watch_once 完成)。"""
|
|
1974
|
+
with _runs_lock:
|
|
1975
|
+
entries = list(_RUNS.items())
|
|
1976
|
+
out = {}
|
|
1977
|
+
for cid, rec in entries:
|
|
1978
|
+
proc = rec.get("proc")
|
|
1979
|
+
if proc is not None:
|
|
1980
|
+
out[cid] = proc.poll() is None
|
|
1981
|
+
else:
|
|
1982
|
+
out[cid] = not rec.get("aborted")
|
|
1983
|
+
return out
|
|
1984
|
+
|
|
1985
|
+
|
|
1986
|
+
def compact_session(project, card, sid):
|
|
1987
|
+
"""对卡片关联会话执行 compact(压缩上下文)。
|
|
1988
|
+
|
|
1989
|
+
单族化后只有 dsh_plugin:走驱动 `/compact`(插件侧执行宿主 `/compact`
|
|
1990
|
+
命令),触发即返回——压缩在 dsh 进程内异步进行(要过一次模型),失败落
|
|
1991
|
+
宿主日志(原 kimi CLI 子进程压缩与两族 web REST 压缩已随族退场)。
|
|
1992
|
+
|
|
1993
|
+
sid 必须属于该卡片(sessions 清单或主 session_id),不支持的族抛
|
|
1994
|
+
RuntimeError(server 转 400)。
|
|
1995
|
+
"""
|
|
1996
|
+
if sid not in json.loads(card["sessions"] or "[]") and sid != card["session_id"]:
|
|
1997
|
+
raise RuntimeError("会话不属于该卡片")
|
|
1998
|
+
family = runner.agent_family(project["agent_path"] or "")
|
|
1999
|
+
if family != "dsh_plugin":
|
|
2000
|
+
# 单族化:非 dsh 即退场族项目(migrate_p7b 改写后不再出现),
|
|
2001
|
+
# 给「改绑 dsh 插件」的明确提示(B0 防呆口径)
|
|
2002
|
+
raise RuntimeError(agents.RETIRED_MSG)
|
|
2003
|
+
# 路线 A P3(2026-10-03):走驱动 `/compact`(插件侧执行宿主 `/compact`
|
|
2004
|
+
# 命令;`ctx.compaction` 服务在插件 ctx 不可注入,命令路径是 P0 探针的结论)。
|
|
2005
|
+
# 触发即返回——压缩在 dsh 进程内异步进行(要过一次模型),失败落宿主日志。
|
|
2006
|
+
try:
|
|
2007
|
+
dshdriver.compact(sid)
|
|
2008
|
+
except dshdriver.DshDriverError as e:
|
|
2009
|
+
raise RuntimeError(f"compact 失败: {e}")
|
|
2010
|
+
|
|
2011
|
+
|
|
2012
|
+
def fork_compact_session(project, card, sid):
|
|
2013
|
+
"""对卡片关联会话执行「压缩并新建」(单族:dsh_plugin):fork 出完整副本 →
|
|
2014
|
+
把**新会话**压缩(其上下文随即变轻),与「新会话上下文轻」的目标一致;
|
|
2015
|
+
差别是新会话日志里仍保留完整历史(窗口渲染更重),已在 spec 注明。
|
|
2016
|
+
|
|
2017
|
+
sid 必须属于该卡片(sessions 清单或主 session_id)。返回 (新会话 sid, True)。
|
|
2018
|
+
"""
|
|
2019
|
+
if sid not in json.loads(card["sessions"] or "[]") and sid != card["session_id"]:
|
|
2020
|
+
raise RuntimeError("会话不属于该卡片")
|
|
2021
|
+
family = runner.agent_family(project["agent_path"] or "")
|
|
2022
|
+
if family != "dsh_plugin":
|
|
2023
|
+
raise RuntimeError(agents.RETIRED_MSG) # 退场族(见 compact_session)
|
|
2024
|
+
# 路线 A P3:dsh 没有「读压缩摘要再注入新会话」的等价物(原 kimi 族走其
|
|
2025
|
+
# 会话 wire 里的压缩摘要记录)。这里用**等价近似**:
|
|
2026
|
+
# fork 出完整副本 → 把**新会话**压缩(其上下文随即变轻),与「新会话上下文
|
|
2027
|
+
# 轻」目标一致;差别是新会话日志里仍保留完整历史(窗口渲染更重),已在 spec 注明。
|
|
2028
|
+
try:
|
|
2029
|
+
new_sid = str((dshdriver.fork(sid) or {}).get("new_session_id") or "")
|
|
2030
|
+
if not new_sid:
|
|
2031
|
+
raise RuntimeError("压缩新建失败: 驱动未返回新会话 id")
|
|
2032
|
+
dshdriver.ensure_session(new_sid, cwd=project["project_dir"],
|
|
2033
|
+
task=f"card-{card['id']}")
|
|
2034
|
+
dshdriver.compact(new_sid)
|
|
2035
|
+
title = (card["title"] or "").strip()
|
|
2036
|
+
if title:
|
|
2037
|
+
try:
|
|
2038
|
+
dshdriver.rename(new_sid, f"{title}(压缩续)")
|
|
2039
|
+
except dshdriver.DshDriverError:
|
|
2040
|
+
pass
|
|
2041
|
+
except dshdriver.DshDriverError as e:
|
|
2042
|
+
raise RuntimeError(f"压缩新建失败: {e}")
|
|
2043
|
+
return new_sid, True
|
|
2044
|
+
|
|
2045
|
+
|
|
2046
|
+
def fork_session(project, card, sid):
|
|
2047
|
+
"""fork 卡片关联会话(单族:dsh_plugin):经驱动完整复制源会话
|
|
2048
|
+
(历史/上下文全量保留,同 workspace/cwd),新会话标题「<卡标题>(fork)」。
|
|
2049
|
+
|
|
2050
|
+
与 fork_compact_session(压缩并新建)的区别:fork 是原样复制、不压缩,
|
|
2051
|
+
适合想从当前完整上下文开分支探索的场景;压缩新建只带摘要、上下文更轻。
|
|
2052
|
+
|
|
2053
|
+
sid 必须属于该卡片(sessions 清单或主 session_id)。返回新会话 sid。
|
|
2054
|
+
调用方(server 端点)负责把新 sid 绑定进卡片会话列表(bind_session 语义:
|
|
2055
|
+
仅追加列表,不抢占主会话)。
|
|
2056
|
+
"""
|
|
2057
|
+
if sid not in json.loads(card["sessions"] or "[]") and sid != card["session_id"]:
|
|
2058
|
+
raise RuntimeError("会话不属于该卡片")
|
|
2059
|
+
family = runner.agent_family(project["agent_path"] or "")
|
|
2060
|
+
if family != "dsh_plugin":
|
|
2061
|
+
raise RuntimeError(agents.RETIRED_MSG) # 退场族(见 compact_session)
|
|
2062
|
+
# 路线 A P3(2026-10-03):宿主 sessionController.fork 完整复制(同 cwd/历史)。
|
|
2063
|
+
# fork 出的新会话不在驱动池里——平台这里补一次 ensure_session 把它 resume
|
|
2064
|
+
# 进池,之后(改名/缩轮/投递)都按普通会话走;返回新 sid 交调用方绑卡。
|
|
2065
|
+
try:
|
|
2066
|
+
new_sid = str((dshdriver.fork(sid) or {}).get("new_session_id") or "")
|
|
2067
|
+
if not new_sid:
|
|
2068
|
+
raise RuntimeError("fork 失败: 驱动未返回新会话 id")
|
|
2069
|
+
dshdriver.ensure_session(new_sid, cwd=project["project_dir"],
|
|
2070
|
+
task=f"card-{card['id']}")
|
|
2071
|
+
title = (card["title"] or "").strip()
|
|
2072
|
+
if title:
|
|
2073
|
+
try:
|
|
2074
|
+
dshdriver.rename(new_sid, f"{title}(fork)")
|
|
2075
|
+
except dshdriver.DshDriverError:
|
|
2076
|
+
pass # 标题失败不影响 fork 主流程
|
|
2077
|
+
except dshdriver.DshDriverError as e:
|
|
2078
|
+
raise RuntimeError(f"fork 失败: {e}")
|
|
2079
|
+
return new_sid
|
|
2080
|
+
|
|
2081
|
+
|
|
2082
|
+
def finish(unit_key, reason, *, to_column=None, mark_unread=True):
|
|
2083
|
+
"""所有「条目结束」的唯一收口(v2 §2.5.2-4,裁决 R14;v2d T1)。
|
|
2084
|
+
|
|
2085
|
+
步骤:finishing 重入门禁(幂等)→ waitq.mark_finishing(running→finishing
|
|
2086
|
+
——finishing 态唯一生产路径,收尾瞬态仍计入位次前缀)→ runner.card_finished
|
|
2087
|
+
(行终态门禁 (running,finishing)→done;notify_all =补位时机②)→ 搬列
|
|
2088
|
+
(to_column 给定且列不同才写:column_key+清阻塞标记,
|
|
2089
|
+
与各 review 归位同款)。各族探测(进程退出 / SSE / 轮询回落 / 调和器)
|
|
2090
|
+
只负责触发 finish,不再各自收尾;卡片生命周期各释放点(_finish_run 正常尾
|
|
2091
|
+
/卡已删、_iw_apply to_review 出队归位、_leave_doing 容器迁移、move_card
|
|
2092
|
+
移列、delete_card_cleanup、dequeue_start 起跑失败回滚、runner worker finally
|
|
2093
|
+
c: 分支)逐处改调本函数(v3b 起经 `_dequeue_card` 统一行收口)。
|
|
2094
|
+
|
|
2095
|
+
mark_unread(2026-10-07 批次):搬列时是否给卡片置「状态有更新·用户未打开」
|
|
2096
|
+
标记,透传给 `db.update_board_card`。缺省 True=平台/agent 自己搬的列要标记
|
|
2097
|
+
(会话结束→待审核、归档同步→已完成最典型);`mark_unread=False` 只给「用户
|
|
2098
|
+
本人在看板上操作」的收尾路径(`stop_card` 的停止→待审核),用户不需要自己
|
|
2099
|
+
给自己发提醒。
|
|
2100
|
+
|
|
2101
|
+
行态口径(v2b fix round 1 逐字保留 + v2b 终审记录①):running/finishing
|
|
2102
|
+
行=本收尾点收口;starting 行不收(起跑失败行终态归 worker finally
|
|
2103
|
+
failed「起会话失败」,本函数不得抢先标 done);waiting 行不收(取消路径
|
|
2104
|
+
职责——cancel_card_wait / 作答排队防御 waiting-only)。runner 缺位跳过
|
|
2105
|
+
行收口(原 _release_card 同型 no-op)仍搬列。
|
|
2106
|
+
幂等:行 finishing 重入直接返回(并发/中断保护;中断残留由 recover/自检
|
|
2107
|
+
收口);行终态后重入各子步幂等——终态行态守卫、搬列列差守卫。
|
|
2108
|
+
占位/事实分离(v2 §2.5.2-1,v3d 收口):收尾只看行态,无「先查持有」特判
|
|
2109
|
+
——行即占位表征,行终态即出队。
|
|
2110
|
+
|
|
2111
|
+
ext 单元(v2d T4,裁决 R13):`ext:<卡 id>` = 外部条目收尾——外部会话不受
|
|
2112
|
+
平台控制、无平台收尾面,收尾=行终结(finishing→done)+
|
|
2113
|
+
唤醒补位(时机③),供调和器/入队刷新在「实况 busy 回落/会话消失/挂起出队/
|
|
2114
|
+
卡离开发列」时调用。只返回布尔(是否真实收尾一行,调用方判集合变化);
|
|
2115
|
+
runner 缺位同样收行(不依赖单例)。
|
|
2116
|
+
|
|
2117
|
+
reason 的落点(v3 终审修复):**真实收口一行**即打一行确定性日志——`c:` 行
|
|
2118
|
+
`[board] 卡片收尾:c:<id>(<reason>)`、`ext:` 行
|
|
2119
|
+
`[board] 外部条目收尾:ext:<target>(<reason>)`(行未收口时——行不在
|
|
2120
|
+
(running,finishing)、finishing 重入、终态重入——不落日志)。v3d 删租约层前
|
|
2121
|
+
这行由 `waitq.release` 的「释放租约 c:N(reason)」承担,删除后各收尾标签
|
|
2122
|
+
在运行/收尾态行上失去可观测落点,本日志按唯一收尾点口径补回(spec/queue §3
|
|
2123
|
+
「收尾单点与 reason 全表」的「落点口径」段)。
|
|
2124
|
+
"""
|
|
2125
|
+
if unit_key.startswith("ext:"):
|
|
2126
|
+
target = unit_key[4:]
|
|
2127
|
+
row = waitq.get_active(waitq.KIND_EXT, target)
|
|
2128
|
+
if row is None or row["state"] == waitq.FINISHING:
|
|
2129
|
+
return False # 无活跃行/finishing 重入:幂等 no-op
|
|
2130
|
+
waitq.mark_finishing(waitq.KIND_EXT, target) # running→finishing
|
|
2131
|
+
if waitq.finish_by_target(waitq.KIND_EXT, target): # → done
|
|
2132
|
+
print(f"[board] 外部条目收尾:ext:{target}({reason})", flush=True)
|
|
2133
|
+
if runner.INSTANCE is not None:
|
|
2134
|
+
runner.INSTANCE.notify_busy_change() # 补位时机③
|
|
2135
|
+
return True
|
|
2136
|
+
if not unit_key.startswith("c:"):
|
|
2137
|
+
return # 卡片单元外无收尾语义(t:/m:/a: 原位单点)
|
|
2138
|
+
card_id = int(unit_key[2:])
|
|
2139
|
+
inst = runner.INSTANCE
|
|
2140
|
+
if inst is not None:
|
|
2141
|
+
row = waitq.get_active(waitq.KIND_CARD, card_id)
|
|
2142
|
+
if row is not None and row["state"] == waitq.FINISHING:
|
|
2143
|
+
return # finishing 重入幂等(见 docstring)
|
|
2144
|
+
if row is not None and row["state"] == waitq.RUNNING:
|
|
2145
|
+
waitq.mark_finishing(waitq.KIND_CARD, card_id) # running→finishing
|
|
2146
|
+
if inst.card_finished(card_id, reason=reason): # 行收口成功 → reason 落日志
|
|
2147
|
+
print(f"[board] 卡片收尾:c:{card_id}({reason})", flush=True)
|
|
2148
|
+
if to_column is not None:
|
|
2149
|
+
card = db.get_board_card(card_id)
|
|
2150
|
+
if card is not None and card["column_key"] != to_column:
|
|
2151
|
+
db.update_board_card(card_id, mark_unread=mark_unread,
|
|
2152
|
+
column_key=to_column,
|
|
2153
|
+
block_kind=None, block_text="")
|
|
2154
|
+
|
|
2155
|
+
|
|
2156
|
+
def delete_card_cleanup(card_id):
|
|
2157
|
+
"""删除卡片前的平台侧清理(server 删除端点在 stop_card 之后、删库行之前调用)。
|
|
2158
|
+
|
|
2159
|
+
清平台侧残留:统一队列条目与运行行收口(v3b 起走 `_dequeue_card` 出队原语
|
|
2160
|
+
——等待行取消 / 运行行终态化,行+占位投影一并清)——防「卡删了但
|
|
2161
|
+
项目运行位被残留行堵死」。全部操作幂等(无活跃行即 no-op;finish 无活跃行/
|
|
2162
|
+
无 runner 退化 no-op),任何状态的卡片均可安全调用。
|
|
2163
|
+
"""
|
|
2164
|
+
_dequeue_card(card_id, "卡片删除")
|
|
2165
|
+
|
|
2166
|
+
|
|
2167
|
+
def _finish_run(card_id, rec, reason="会话结束收尾"):
|
|
2168
|
+
"""会话结束收尾:提取 session id 入库、doing/交互阻塞卡 → review
|
|
2169
|
+
(清交互阻塞标记)、记错误、释放 runner 占用。
|
|
2170
|
+
|
|
2171
|
+
reason:收尾 reason 标签(落卡片日志/审计),默认「会话结束收尾」。
|
|
2172
|
+
dsh 驱动:sid 启动时已入库,跳过日志回捞;错误优先经 _web_turn_error 上浮
|
|
2173
|
+
turn 级真实错误(事件中枢的 last_turn_reason 非 completed/aborted 即异常),
|
|
2174
|
+
无则取 rec["error"](turn 未开始超时兜底);用户主动 abort 不算错误
|
|
2175
|
+
(对齐 SIGTERM 语义)。尾部 proc 分支为 CLI 时代的防御残留(单族化后
|
|
2176
|
+
_RUNS 条目恒为 dsh,proc=None)。
|
|
2177
|
+
"""
|
|
2178
|
+
log_path = rec["log_path"]
|
|
2179
|
+
web_err = ""
|
|
2180
|
+
if rec.get("proc") is None:
|
|
2181
|
+
# dsh 驱动:sid 启动即知已入库;turn 级错误优先,超时错误兜底
|
|
2182
|
+
sid = rec.get("sid") or ""
|
|
2183
|
+
web_err = _web_turn_error(rec) or (rec.get("error") or "")
|
|
2184
|
+
if web_err:
|
|
2185
|
+
runner.append_log(log_path, f"### 会话异常结束: {web_err}\n")
|
|
2186
|
+
else:
|
|
2187
|
+
if rec.get("usage_path"):
|
|
2188
|
+
try:
|
|
2189
|
+
with open(rec["usage_path"], encoding="utf-8") as f:
|
|
2190
|
+
usid = str(json.load(f).get("session_id") or "")
|
|
2191
|
+
if usid:
|
|
2192
|
+
runner.append_log(log_path, json.dumps(
|
|
2193
|
+
{"role": "meta", "type": "session.resume_hint",
|
|
2194
|
+
"session_id": usid}, ensure_ascii=False) + "\n")
|
|
2195
|
+
except (OSError, ValueError):
|
|
2196
|
+
pass
|
|
2197
|
+
sid = runner.parse_session_hint(log_path)
|
|
2198
|
+
if not sid and rec.get("family") == "deepseek":
|
|
2199
|
+
sid = sessparse.dsh_latest_session(rec["project_dir"]) or ""
|
|
2200
|
+
card = db.get_board_card(card_id)
|
|
2201
|
+
if card is None:
|
|
2202
|
+
# 卡片已删:会话收尾无事可做,但 runner 项目占用必须释放,否则项目队列卡死
|
|
2203
|
+
finish(f"c:{card_id}", "会话结束-卡已删")
|
|
2204
|
+
return
|
|
2205
|
+
fields = {}
|
|
2206
|
+
if sid:
|
|
2207
|
+
sessions = json.loads(card["sessions"] or "[]")
|
|
2208
|
+
if sid not in sessions:
|
|
2209
|
+
sessions.append(sid)
|
|
2210
|
+
fields["session_id"] = sid
|
|
2211
|
+
fields["sessions"] = json.dumps(sessions, ensure_ascii=False)
|
|
2212
|
+
if rec.get("proc") is None:
|
|
2213
|
+
if web_err:
|
|
2214
|
+
fields["last_error"] = f"会话异常: {web_err}"
|
|
2215
|
+
fields["last_error_at"] = int(time.time() * 1000)
|
|
2216
|
+
else:
|
|
2217
|
+
fields["last_error"] = ""
|
|
2218
|
+
else:
|
|
2219
|
+
exit_code = rec["proc"].returncode
|
|
2220
|
+
# 用户主动 stop_card(SIGTERM,returncode=-15)属正常停止,不记错误
|
|
2221
|
+
if exit_code and exit_code != -15:
|
|
2222
|
+
fields["last_error"] = f"会话退出码 {exit_code}"
|
|
2223
|
+
fields["last_error_at"] = int(time.time() * 1000)
|
|
2224
|
+
else:
|
|
2225
|
+
fields["last_error"] = ""
|
|
2226
|
+
# 仅仍在 doing 的卡片自动流转;交互阻塞卡(block_kind='interaction')在
|
|
2227
|
+
# turn 结束时同样流转 review——等待态已随会话结束失效,滞留阻塞列会成死卡;
|
|
2228
|
+
# 手动阻塞恒为 block_kind='manual' 不受影响。用户手动拖走的卡两侧都不碰。
|
|
2229
|
+
# 搬列(column_key/清阻塞标记)交唯一收尾点的 `to_column` 参数落(v2d 收口轮:
|
|
2230
|
+
# finish 的 to_column 由此接上生产调用面,收尾点内「行终态→notify→搬列」
|
|
2231
|
+
# 一次表达;finish 内部各子步幂等,顺序差异无副作用)。
|
|
2232
|
+
to_review = card["column_key"] == "doing" or (
|
|
2233
|
+
card["column_key"] == "blocked" and card["block_kind"] == "interaction")
|
|
2234
|
+
if to_review:
|
|
2235
|
+
# 交互等待已随会话结束失效:清 watcher 缓存,防 stale pending 卡片
|
|
2236
|
+
# 在会话窗持续显示「等待回答」(watch 5s 窗口先于 30s tick 即可能残留)
|
|
2237
|
+
_iw_clear(card["session_id"] or "")
|
|
2238
|
+
if fields:
|
|
2239
|
+
db.update_board_card(card_id, **fields)
|
|
2240
|
+
if to_review:
|
|
2241
|
+
try:
|
|
2242
|
+
feishu.card_review(card["project_id"], card) # 待审核推送(默认事件关,旁路)
|
|
2243
|
+
except Exception:
|
|
2244
|
+
pass
|
|
2245
|
+
# 收尾:行终态化(唯一收尾点);to_column 给定且列不同才写(列差守卫)
|
|
2246
|
+
finish(f"c:{card_id}", reason, to_column="review" if to_review else None)
|
|
2247
|
+
|
|
2248
|
+
|
|
2249
|
+
def _watch_runs_once():
|
|
2250
|
+
"""在管会话收尾巡视段(_watch_once 的前半拆分;30s 全量对账节拍):
|
|
2251
|
+
结束会话收尾。
|
|
2252
|
+
|
|
2253
|
+
单族化后条目恒为 dsh(proc=None,会话在 dsh 宿主进程内):按事件中枢三态
|
|
2254
|
+
读实况——running 记 seen_busy;idle 按收尾判据处理;unknown(中枢断连/
|
|
2255
|
+
无信号)本轮跳过,下轮再看。
|
|
2256
|
+
_STARTING_TIMEOUT_S 内从未见 busy 时:已被 stop(aborted 标记)按正常
|
|
2257
|
+
停止收尾,否则经 _web_turn_ran 按 turn 归属基线复核——真跑过(短 turn
|
|
2258
|
+
漏观测)按正常收尾,没跑过按「turn 未开始」失败收尾(本阈值即原 90s 宽限
|
|
2259
|
+
补丁数值,v2d T3 起与行级 starting 超时对账同源为 _STARTING_TIMEOUT_S)。
|
|
2260
|
+
|
|
2261
|
+
**dsh_plugin(事件源族,P7a 缺陷 D)**:hub 已知时按注册表实况直接判——
|
|
2262
|
+
running 记 seen_busy;idle 且(已 aborted 或 turn 基线已推进)立即收尾,
|
|
2263
|
+
不必等「某拍观测到 busy」。这样短于巡视节拍的轮次、以及起跑窗口内的停止
|
|
2264
|
+
都不用空等 90s 宽限(原先串行位白占 ~71s)。hub 断连(None=未知)时保持
|
|
2265
|
+
现状,绝不把「读不到」推断成「已结束」。
|
|
2266
|
+
|
|
2267
|
+
收尾归属:只有本线程真正从 _RUNS 弹出的条目才收尾——被新会话顶替的旧条目
|
|
2268
|
+
不收(其卡片归新条目管)。
|
|
2269
|
+
"""
|
|
2270
|
+
with _runs_lock:
|
|
2271
|
+
entries = list(_RUNS.items())
|
|
2272
|
+
done = []
|
|
2273
|
+
now_ms = int(time.time() * 1000)
|
|
2274
|
+
for cid, rec in entries:
|
|
2275
|
+
proc = rec.get("proc")
|
|
2276
|
+
if proc is not None:
|
|
2277
|
+
if proc.poll() is not None:
|
|
2278
|
+
done.append((cid, rec))
|
|
2279
|
+
continue
|
|
2280
|
+
# dsh 条目:读 EventHub 三态(unknown=断连/无信号,本轮跳过,下轮再看)
|
|
2281
|
+
st = _session_state_family(rec["family"], rec["project_dir"], rec["sid"])
|
|
2282
|
+
if st == STATE_UNKNOWN:
|
|
2283
|
+
continue
|
|
2284
|
+
busy = st == STATE_RUNNING
|
|
2285
|
+
if busy:
|
|
2286
|
+
rec["seen_busy"] = True
|
|
2287
|
+
continue
|
|
2288
|
+
# 事件源族(dsh_plugin):hub 是权威实况,**不必等「某拍观测到 busy」**——
|
|
2289
|
+
# 短轮(短于 30s 巡视节拍)与「停止于起跑窗口」两种情形都在这里按结论
|
|
2290
|
+
# 收尾,否则要空等 _STARTING_TIMEOUT_S=90s 宽限、串行位白占(P7a 缺陷 D)。
|
|
2291
|
+
# 只在 hub 已知(非 None=断连未知)时启用:未知必须保持现状,不推断结束。
|
|
2292
|
+
if rec["family"] == "dsh_plugin":
|
|
2293
|
+
hub = dshevents.get(rec["sid"])
|
|
2294
|
+
if hub is not None:
|
|
2295
|
+
if str(hub.get("status") or "") == "running":
|
|
2296
|
+
rec["seen_busy"] = True
|
|
2297
|
+
continue
|
|
2298
|
+
if rec.get("aborted"):
|
|
2299
|
+
done.append((cid, rec))
|
|
2300
|
+
continue
|
|
2301
|
+
# 基线缺失(起跑时 hub 未连接)时不启用快路径:宁可等宽限,不用
|
|
2302
|
+
# 「读不到」推断「已结束」(dshevents 不变量:断连=未知)
|
|
2303
|
+
if rec.get("turn_baseline") is not None and _web_turn_ran(rec):
|
|
2304
|
+
done.append((cid, rec))
|
|
2305
|
+
continue
|
|
2306
|
+
if rec.get("seen_busy"):
|
|
2307
|
+
done.append((cid, rec))
|
|
2308
|
+
continue
|
|
2309
|
+
if now_ms - rec["started_at"] <= _STARTING_TIMEOUT_S * 1000:
|
|
2310
|
+
continue # turn 启动宽限期内(starting 窗口,行级对账同阈值)
|
|
2311
|
+
if rec.get("aborted"):
|
|
2312
|
+
# 宽限期内被 stop(turn 未起即 abort,M3):按正常停止收尾,不判负
|
|
2313
|
+
done.append((cid, rec))
|
|
2314
|
+
continue
|
|
2315
|
+
if not _web_turn_ran(rec):
|
|
2316
|
+
rec["error"] = f"turn 未开始({_STARTING_TIMEOUT_S}s 未见 busy)"
|
|
2317
|
+
done.append((cid, rec))
|
|
2318
|
+
with _runs_lock:
|
|
2319
|
+
popped = []
|
|
2320
|
+
for cid, rec in done:
|
|
2321
|
+
if _RUNS.get(cid) is rec: # 防巡视期间条目被新会话顶替
|
|
2322
|
+
_RUNS.pop(cid, None)
|
|
2323
|
+
popped.append((cid, rec))
|
|
2324
|
+
# 只收本线程真正弹出的条目:被新会话顶替的旧条目不重复 _finish_run
|
|
2325
|
+
# (见 docstring「收尾归属」)
|
|
2326
|
+
for cid, rec in popped:
|
|
2327
|
+
_finish_run(cid, rec)
|
|
2328
|
+
|
|
2329
|
+
|
|
2330
|
+
# ---------- starting 超时 + 对账兜底(v2d T3;v2 §2.5.2-5,裁决 R6) ----------
|
|
2331
|
+
|
|
2332
|
+
def _unit_evidence_pid(card_id):
|
|
2333
|
+
"""卡行 evidence 的 pid 读取(R11④ 进程证据读取口;
|
|
2334
|
+
无活跃行/无 pid/坏 JSON → None)。"""
|
|
2335
|
+
return waitq.evidence_pid(waitq.get_active(waitq.KIND_CARD, card_id))
|
|
2336
|
+
|
|
2337
|
+
|
|
2338
|
+
def _starting_evidence(card):
|
|
2339
|
+
"""超龄 starting 行的会话证据三态(alive / dead / unknown)+ 判定时读到的
|
|
2340
|
+
在管条目(rec,无则 None——dead 处置的身份守卫弹出用)。
|
|
2341
|
+
|
|
2342
|
+
判据链(可证才动、宁多等不误杀——与 unit_liveness / 行对账同哲学):
|
|
2343
|
+
① 在管条目在场(_RUNS):
|
|
2344
|
+
- dsh(web):会话 running → alive;会话 idle 且 turn 归属基线未动
|
|
2345
|
+
(_web_turn_ran=False,短 turn 漏观测面已排除)→ dead;其余
|
|
2346
|
+
(unknown / 短 turn 已跑 / 无基线)→ unknown;
|
|
2347
|
+
- CLI(原族,分支保留为防御):子进程存活 → alive;子进程已退 → unknown
|
|
2348
|
+
(会话确曾起过且已结束,错误/会话 id 归因归巡视 _finish_run,本函数不
|
|
2349
|
+
预判,防抢走收尾)。
|
|
2350
|
+
② 无在管条目(起跑路径异常残留):
|
|
2351
|
+
- dsh(web):会话 running → alive(实况在跑——行自愈,收尾仍归巡视);
|
|
2352
|
+
其余(idle/unknown)→ unknown(无基线反证不了「本次未起」,只告警);
|
|
2353
|
+
- CLI(原族,防御):无存活 pid 证据(行 evidence.pid 缺席或已退)→ dead
|
|
2354
|
+
(进程面无在跑:平台已不持有会话,起跑窗口不可能仍在推进)。残余面:
|
|
2355
|
+
原 CLI 起进程到登记 `_RUNS` 之间的毫秒级间隙孤儿进程不在本判据覆盖内
|
|
2356
|
+
(`_spawn` 已随单族化删除;evidence.pid 由起跑证实写入)——窗口极小,
|
|
2357
|
+
且随平台退出/recover 回收。
|
|
2358
|
+
"""
|
|
2359
|
+
cid = card["id"]
|
|
2360
|
+
with _runs_lock:
|
|
2361
|
+
rec = _RUNS.get(cid)
|
|
2362
|
+
if rec is not None:
|
|
2363
|
+
proc = rec.get("proc")
|
|
2364
|
+
if proc is not None:
|
|
2365
|
+
return ("alive" if proc.poll() is None else "unknown"), rec
|
|
2366
|
+
st = _session_state_family(rec.get("family"), rec.get("project_dir"),
|
|
2367
|
+
rec.get("sid") or "")
|
|
2368
|
+
if st == STATE_RUNNING:
|
|
2369
|
+
return "alive", rec
|
|
2370
|
+
if st == STATE_IDLE and not _web_turn_ran(rec):
|
|
2371
|
+
return "dead", rec
|
|
2372
|
+
return "unknown", rec
|
|
2373
|
+
proj = db.get_project(card["project_id"])
|
|
2374
|
+
family = _web_family(proj) if proj is not None else None
|
|
2375
|
+
if family is None:
|
|
2376
|
+
pid = _unit_evidence_pid(cid) # 进程证据读行 evidence.pid
|
|
2377
|
+
if pid is not None and platcompat.pid_alive(pid):
|
|
2378
|
+
return "alive", None # 进程面确活(orphan 在跑,行自愈)
|
|
2379
|
+
return "dead", None # 无在管条目 + 无存活进程 = 可证未起
|
|
2380
|
+
st = _session_state_family(family, proj["project_dir"],
|
|
2381
|
+
(card["session_id"] or "").strip())
|
|
2382
|
+
return ("alive" if st == STATE_RUNNING else "unknown"), None
|
|
2383
|
+
|
|
2384
|
+
|
|
2385
|
+
def _reconcile_starting_rows():
|
|
2386
|
+
"""starting 行超时对账(v2d T3;调和器 _iw_once 5s 节拍调用)。
|
|
2387
|
+
|
|
2388
|
+
背景(v2 §2.5.2-5,裁决 R6):c: 行 state=starting = 「拾取→会话证实」窗口
|
|
2389
|
+
(claim 后会话尚未证实运行,行即成员占着一个前缀位次);窗口内的崩溃/异常
|
|
2390
|
+
若把行永久钉在 starting,补位器会永远少一个位次。本函数对超龄
|
|
2391
|
+
(> _STARTING_TIMEOUT_S=90s,原 WEB_TURN_START_TIMEOUT 数值)行按
|
|
2392
|
+
_starting_evidence 复核三态处置:
|
|
2393
|
+
alive → waitq.mark_running 自愈(行转 running 留队构成运行前缀,不处置——
|
|
2394
|
+
卡列/在管条目全不动);
|
|
2395
|
+
dead → 行 failed(error="starting 超时")+ 卡回 meta.from_column(仅当卡
|
|
2396
|
+
仍在 doing=起跑窗口未被他路归位)+ finish(唯一收尾点:行收口 +
|
|
2397
|
+
补位 notify)+ 告警日志;在管条目随处置弹出(本单元既已判未起,
|
|
2398
|
+
放弃其收尾权——否则巡视侧随后按「turn 未开始」再收一遍,把卡列
|
|
2399
|
+
改写回 review);
|
|
2400
|
+
unknown → 只告警不处置(「未知 ≠ 结束」,REST 失败/基线缺失)。
|
|
2401
|
+
仅 c: 参与:t:/m:/a: 的 starting 是执行全程常态(拾取即持行),其崩溃残留
|
|
2402
|
+
归 recover 映射与队列自检(R11 边界沿用)。
|
|
2403
|
+
|
|
2404
|
+
两处 T3 复审收口(v2d T4 顺手):
|
|
2405
|
+
- aborted 条目(用户 stop_card 中断在跑会话)按 unknown 处置:卡死期用户停卡
|
|
2406
|
+
的语义是「正常停止」,交巡视侧 `_finish_run` 收尾(rec["aborted"] 已记),
|
|
2407
|
+
本函数不得抢先判「starting 超时」失败(否则卡片错误被覆写成超时);
|
|
2408
|
+
- unknown 告警按卡节流(`_STARTING_ALERT_S`,5s 节拍此前每拍一行)——
|
|
2409
|
+
滞留卡仍能周期上报,日志不被刷屏。
|
|
2410
|
+
"""
|
|
2411
|
+
cutoff = time.strftime("%Y-%m-%d %H:%M:%S",
|
|
2412
|
+
time.localtime(time.time() - _STARTING_TIMEOUT_S))
|
|
2413
|
+
now_ms = int(time.time() * 1000)
|
|
2414
|
+
for row in waitq.active_items():
|
|
2415
|
+
if row["kind"] != waitq.KIND_CARD or row["state"] != waitq.STARTING:
|
|
2416
|
+
continue
|
|
2417
|
+
claimed = row["claimed_at"] or ""
|
|
2418
|
+
if not claimed or claimed >= cutoff:
|
|
2419
|
+
continue # 未超龄(口径同为 YYYY-MM-DD HH:MM:SS 字符串)
|
|
2420
|
+
try:
|
|
2421
|
+
cid = int(row["target_id"])
|
|
2422
|
+
except ValueError:
|
|
2423
|
+
continue
|
|
2424
|
+
card = db.get_board_card(cid)
|
|
2425
|
+
if card is None:
|
|
2426
|
+
continue # 卡行已删:行收口归删除路径/recover(防御)
|
|
2427
|
+
try:
|
|
2428
|
+
verdict, rec = _starting_evidence(card)
|
|
2429
|
+
except Exception:
|
|
2430
|
+
verdict, rec = "unknown", None # 探测异常=状态不明:只告警(R11 口径)
|
|
2431
|
+
if verdict == "dead" and rec is not None and rec.get("aborted"):
|
|
2432
|
+
verdict = "unknown" # 用户停卡:交巡视按正常停止收尾(见 docstring)
|
|
2433
|
+
if verdict == "alive":
|
|
2434
|
+
if waitq.mark_running(waitq.KIND_CARD, cid):
|
|
2435
|
+
print(f"[board] starting 超时复核:卡 {cid} 实况在跑 → "
|
|
2436
|
+
f"mark_running 自愈(claimed_at={claimed})", flush=True)
|
|
2437
|
+
continue
|
|
2438
|
+
if verdict == "unknown":
|
|
2439
|
+
if _starting_alert_due(cid):
|
|
2440
|
+
print(f"[board] starting 超时告警(不可证未起,不处置):卡 {cid}"
|
|
2441
|
+
f" claimed_at={claimed}", flush=True)
|
|
2442
|
+
continue
|
|
2443
|
+
# dead:可证未起——行 failed + 卡回 from_column + 唯一收尾点 + 告警
|
|
2444
|
+
with _runs_lock:
|
|
2445
|
+
if rec is not None and _RUNS.get(cid) is rec:
|
|
2446
|
+
_RUNS.pop(cid, None) # 身份守卫同巡视:只弹判定时那条条目
|
|
2447
|
+
waitq.finish_by_target(waitq.KIND_CARD, cid,
|
|
2448
|
+
waitq.STATE_FAILED, "starting 超时")
|
|
2449
|
+
try:
|
|
2450
|
+
meta = json.loads((row["meta"] or "") or "{}")
|
|
2451
|
+
if not isinstance(meta, dict):
|
|
2452
|
+
meta = {}
|
|
2453
|
+
except ValueError:
|
|
2454
|
+
meta = {}
|
|
2455
|
+
fields = {"last_error": f"starting 超时({_STARTING_TIMEOUT_S}s 未证实启动)",
|
|
2456
|
+
"last_error_at": now_ms}
|
|
2457
|
+
back = str(meta.get("from_column") or "")
|
|
2458
|
+
if back not in COLUMNS or back == "doing":
|
|
2459
|
+
# force 落表行(from_column=doing,v2b T4)起跑未证实 → 回「待审核」
|
|
2460
|
+
# 更准(无外部输入等待语义,v2d 收口轮);缺失/非法仍沿用旧默认 blocked
|
|
2461
|
+
back = "review" if back == "doing" else "blocked"
|
|
2462
|
+
if card["column_key"] == "doing":
|
|
2463
|
+
# 仅起跑窗口(仍在 doing)回列;卡已被他路归位则只记错误不抢列
|
|
2464
|
+
fields.update(column_key=back, block_kind=None, block_text="")
|
|
2465
|
+
db.update_board_card(cid, **fields)
|
|
2466
|
+
finish(f"c:{cid}", "starting 超时") # 唯一收尾点:行收口 + notify 补位
|
|
2467
|
+
print(f"[board] starting 超时:卡 {cid} 超 {_STARTING_TIMEOUT_S}s 未证实启动 → "
|
|
2468
|
+
f"行 failed、卡回 {fields.get('column_key') or card['column_key']}"
|
|
2469
|
+
f"(claimed_at={claimed})", flush=True)
|
|
2470
|
+
|
|
2471
|
+
|
|
2472
|
+
def _watch_once():
|
|
2473
|
+
"""一轮巡视:结束会话收尾(_watch_runs_once)+ 定时到点开工(被门禁拦则
|
|
2474
|
+
清定时记错误;起会话失败则把卡置回 todo,避免僵在 doing 无进程)。"""
|
|
2475
|
+
_watch_runs_once()
|
|
2476
|
+
# 定时扫描:todo 列到点卡片经统一入口进 doing(整库扫描,量小;一律入队
|
|
2477
|
+
# (v2b T2 起 parallel 直起废除),门禁语义与手动开始一致)
|
|
2478
|
+
now_ms = int(time.time() * 1000)
|
|
2479
|
+
with db.connect() as conn:
|
|
2480
|
+
rows = conn.execute(
|
|
2481
|
+
"SELECT * FROM board_cards WHERE column_key='todo'"
|
|
2482
|
+
" AND scheduled_at IS NOT NULL AND scheduled_at<=?", (now_ms,)).fetchall()
|
|
2483
|
+
for r in rows:
|
|
2484
|
+
proj = db.get_project(r["project_id"])
|
|
2485
|
+
if proj is None or proj["archived"]: # 归档项目不自动开工
|
|
2486
|
+
continue
|
|
2487
|
+
_, err = _enter_doing(proj, r)
|
|
2488
|
+
if err is not None and "blocked" in err:
|
|
2489
|
+
# 父任务未 done:清定时记错误(不再每 30s 重试点燃)
|
|
2490
|
+
db.update_board_card(r["id"], scheduled_at=None,
|
|
2491
|
+
last_error=f"定时到点但被门禁拦截({err['blocked']})",
|
|
2492
|
+
last_error_at=now_ms)
|
|
2493
|
+
# 起会话失败无需再处理:_enter_doing 已置回 todo(last_error 已由 start_card
|
|
2494
|
+
# 记录;scheduled_at 已清不再补),与旧「置回 todo」行为一致
|
|
2495
|
+
# session 自动同步:逐项目归类外部 agent 会话(单项目异常不影响其他项目)
|
|
2496
|
+
for proj in db.list_projects_all(): # db.py:439 全项目列表(含他用户项目:sync 只读
|
|
2497
|
+
# 会话存储+建卡,归属由 project_id 隔离保证)
|
|
2498
|
+
if proj["archived"]: # 归档项目不自动同步
|
|
2499
|
+
continue
|
|
2500
|
+
try:
|
|
2501
|
+
sync_sessions(proj)
|
|
2502
|
+
except Exception:
|
|
2503
|
+
pass
|
|
2504
|
+
|
|
2505
|
+
|
|
2506
|
+
_SCHED_STARTED = False
|
|
2507
|
+
|
|
2508
|
+
|
|
2509
|
+
def start_scheduler():
|
|
2510
|
+
"""启动看板调度守护线程(幂等)。30s 全量对账节拍(_watch_once:运行巡视
|
|
2511
|
+
+ 定时开工 + 会话同步,现状扫尾不变);节拍间 5s 唤醒一次(原 opencode
|
|
2512
|
+
SSE 断流的 5s 轮询兜底已随两族退场删除——单族 dsh 的状态实况由交互调和器
|
|
2513
|
+
线程消费事件中枢,本线程只做 30s 全量对账)。"""
|
|
2514
|
+
global _SCHED_STARTED
|
|
2515
|
+
if _SCHED_STARTED:
|
|
2516
|
+
return
|
|
2517
|
+
_SCHED_STARTED = True
|
|
2518
|
+
|
|
2519
|
+
def _loop():
|
|
2520
|
+
beat = 0
|
|
2521
|
+
while True:
|
|
2522
|
+
try:
|
|
2523
|
+
if beat % 6 == 0:
|
|
2524
|
+
_watch_once() # 30s 全量对账节拍(现状扫尾不变)
|
|
2525
|
+
except Exception: # 守护线程不 crash 主服务
|
|
2526
|
+
pass
|
|
2527
|
+
beat += 1
|
|
2528
|
+
time.sleep(5)
|
|
2529
|
+
|
|
2530
|
+
threading.Thread(target=_loop, daemon=True, name="board-scheduler").start()
|
|
2531
|
+
|
|
2532
|
+
|
|
2533
|
+
def _recover_web_card(proj, card):
|
|
2534
|
+
"""web 驱动项目的 doing 卡重启恢复:会话在 dsh 宿主进程内(平台重启不影响),
|
|
2535
|
+
按事件中枢实况判定——running 重建 _RUNS 条目(seen_busy=True,proc=None)
|
|
2536
|
+
继续 watch,否则归位 review。免日志回捞/杀进程。
|
|
2537
|
+
无 session_id 或中枢未连接(读不到)一律归位 review(用户可重新开工)。
|
|
2538
|
+
|
|
2539
|
+
外部直跑卡(origin=sync,v2d T4):busy 时**不收养为平台单元**——外部会话
|
|
2540
|
+
不由平台启动(R13),其占位与收尾归外部条目 ext 行:recover 尾部的
|
|
2541
|
+
`_recover_ext_rows` 按项目活跃会话集合重新对账(实况在→ext 行保持 running
|
|
2542
|
+
入前缀、占位不丢),空闲时由调和器列映射回 review(≤5s,与收养路径同归)。
|
|
2543
|
+
收养行为(建 _RUNS 条目)会把不可控会话伪装成平台
|
|
2544
|
+
在管单元——ext 行被 `_platform_holds` 抑制。"""
|
|
2545
|
+
family = _web_family(proj)
|
|
2546
|
+
sid = (card["session_id"] or "").strip()
|
|
2547
|
+
busy = False
|
|
2548
|
+
if sid:
|
|
2549
|
+
try:
|
|
2550
|
+
busy = _web_busy(family, proj["project_dir"], sid) # 插件内存态,零 ensure
|
|
2551
|
+
except dshdriver.DshDriverError:
|
|
2552
|
+
busy = False
|
|
2553
|
+
if not busy:
|
|
2554
|
+
# 重启恢复的列回落不置「有更新」标记:这是平台自己重建视图(会话实况已不在
|
|
2555
|
+
# ⇒ 卡不该留在开发列),不是「你不在时卡片发生了什么」;否则每次重启都会给
|
|
2556
|
+
# 整列 doing 卡(本机实测十余张 sync 卡)一次性打满标记 = 噪音
|
|
2557
|
+
db.update_board_card(card["id"], column_key="review", mark_unread=False)
|
|
2558
|
+
return
|
|
2559
|
+
if (card["origin"] or "") == "sync":
|
|
2560
|
+
return # 外部直跑:不建 _RUNS(见 docstring)
|
|
2561
|
+
log_dir = lib.runtime_dir(proj["work_dir"], runner.LOG_DIR_NAME)
|
|
2562
|
+
log_path = os.path.join(log_dir, f"board_{card['id']}_{int(time.time())}.log")
|
|
2563
|
+
try:
|
|
2564
|
+
runner.append_log(log_path, f"### 服务重启恢复:web 会话 {sid} 仍在运行,继续监视\n")
|
|
2565
|
+
except OSError:
|
|
2566
|
+
pass # 日志写不进去不阻断恢复(watch 只依赖 sid)
|
|
2567
|
+
with _runs_lock:
|
|
2568
|
+
_RUNS[card["id"]] = {"proc": None, "sid": sid, "family": family,
|
|
2569
|
+
# 卡片级工作目录(独立 worktree 卡=其工作树路径,
|
|
2570
|
+
# 与 `_start_web` 记账同口径;普通卡仍是 project_dir)
|
|
2571
|
+
"project_dir": card_workspace(proj, card),
|
|
2572
|
+
"started_at": int(time.time() * 1000),
|
|
2573
|
+
"seen_busy": True, "aborted": False,
|
|
2574
|
+
"turn_baseline": None, "log_path": log_path}
|
|
2575
|
+
|
|
2576
|
+
|
|
2577
|
+
def recover():
|
|
2578
|
+
"""服务重启恢复:对每张 doing 卡按族归位,最后重建 runner 统一队列。
|
|
2579
|
+
|
|
2580
|
+
dsh 族:走 _recover_web_card(实况 running 重建 watch 条目,否则归位 review)。
|
|
2581
|
+
退场族(原 CLI 路径,保留为防御):回收 sid、杀残留进程组、归位 review。
|
|
2582
|
+
逐卡处理:
|
|
2583
|
+
1. 在项目 <work_dir>/.web/ 找最新 board_<cid>_*.log(按 mtime);
|
|
2584
|
+
2. parse_session_hint 回收 sid,非空则并入 sessions/session_id 落库;
|
|
2585
|
+
3. 按日志里 ### PID 标记 killpg SIGTERM 杀残留进程组(进程可能已死,异常豁免);
|
|
2586
|
+
4. 最后置 review。项目已删的卡直接置 review,跳过 1-3。
|
|
2587
|
+
统一队列重建:仍在运行的卡片(实况重建的 _RUNS 条目)重建运行态——
|
|
2588
|
+
`card_started`:`enter_running` 置行 running(行即条目,R11「live
|
|
2589
|
+
卡重建」)+ 证据落行(desc/reason/pid);排队占位卡(doing/blocked + queue)
|
|
2590
|
+
按 updated_at,id 顺序重新入队。
|
|
2591
|
+
c: 等待项非 waiting 行在重建段按 v2a T1 映射收口(裁决 R3:占位/无实况
|
|
2592
|
+
starting→cancelled 并对账补建、无实况 running/finishing→failed、实况
|
|
2593
|
+
busy→mark_running 证实后由 card_started 保持 running;
|
|
2594
|
+
v3a:起跑证实后行 running 跨轮存活,card_started 不再把行标 done)。
|
|
2595
|
+
"""
|
|
2596
|
+
with db.connect() as conn:
|
|
2597
|
+
rows = conn.execute(
|
|
2598
|
+
"SELECT * FROM board_cards WHERE column_key='doing'").fetchall()
|
|
2599
|
+
for r in rows:
|
|
2600
|
+
if r["block_kind"] == "queue":
|
|
2601
|
+
continue # 排队占位卡:无运行可恢复,留在开发列(统一队列重建会重新入队)
|
|
2602
|
+
proj = db.get_project(r["project_id"])
|
|
2603
|
+
if proj is not None and _web_family(proj) is not None:
|
|
2604
|
+
_recover_web_card(proj, r)
|
|
2605
|
+
continue
|
|
2606
|
+
if proj is not None:
|
|
2607
|
+
log_dir = lib.runtime_dir(proj["work_dir"], runner.LOG_DIR_NAME)
|
|
2608
|
+
logs = glob.glob(os.path.join(log_dir, "board_%d_*.log" % r["id"]))
|
|
2609
|
+
if logs:
|
|
2610
|
+
log_path = max(logs, key=os.path.getmtime)
|
|
2611
|
+
sid = runner.parse_session_hint(log_path)
|
|
2612
|
+
if sid:
|
|
2613
|
+
sessions = json.loads(r["sessions"] or "[]")
|
|
2614
|
+
if sid not in sessions:
|
|
2615
|
+
sessions.append(sid)
|
|
2616
|
+
db.update_board_card(r["id"], session_id=sid,
|
|
2617
|
+
sessions=json.dumps(sessions, ensure_ascii=False))
|
|
2618
|
+
# PID 标记写在日志头(原 CLI 起进程后立即落;`_spawn` 已删除),
|
|
2619
|
+
# 读头部窗口足够
|
|
2620
|
+
try:
|
|
2621
|
+
with open(log_path, encoding="utf-8", errors="replace") as f:
|
|
2622
|
+
head = f.read(65536)
|
|
2623
|
+
m = runner.PID_MARK_RE.search(head)
|
|
2624
|
+
except OSError:
|
|
2625
|
+
m = None
|
|
2626
|
+
if m:
|
|
2627
|
+
try:
|
|
2628
|
+
platcompat.kill_tree(int(m.group(1)), signal.SIGTERM)
|
|
2629
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
2630
|
+
pass # 进程可能已随重启消失
|
|
2631
|
+
# 同 `_recover_web_card`:重启恢复的列回落是平台重建视图,不置「有更新」标记
|
|
2632
|
+
db.update_board_card(r["id"], column_key="review", mark_unread=False)
|
|
2633
|
+
# 统一队列重建:运行中的卡片补回运行行(有 proc 在手补 evidence.pid——CLI
|
|
2634
|
+
# 判据,供自检 R11④ 进程存活裁活;P5 R10);排队卡片按序重新入队
|
|
2635
|
+
if runner.INSTANCE is not None:
|
|
2636
|
+
with _runs_lock:
|
|
2637
|
+
live = list(_RUNS)
|
|
2638
|
+
live_set = set(live)
|
|
2639
|
+
# —— c: 等待项行重启映射(v2a T1,裁决 R3;先于下方 card_started 重建段
|
|
2640
|
+
# 定行态——重建段的 enter_running 只负责「行置 running/建行」,行态裁决
|
|
2641
|
+
# 归本段)。waiting 行不动(存活);实况 busy(web 会话在跑,_RUNS 已
|
|
2642
|
+
# 重建)的 starting 行 mark_running 证实(running/finishing 保持;v3a 起
|
|
2643
|
+
# running 行即跨轮存活的运行成员,重启后继续在队);无实况
|
|
2644
|
+
# starting→cancelled(占位卡由下方对账补建重新入队=卡归位,已起跑卡已在
|
|
2645
|
+
# 上方归位 review);无实况 running/finishing→failed(卡已归 review)——
|
|
2646
|
+
for r in rows:
|
|
2647
|
+
wrow = waitq.get_active(waitq.KIND_CARD, r["id"])
|
|
2648
|
+
if wrow is None or wrow["state"] == waitq.STATE_WAITING:
|
|
2649
|
+
continue
|
|
2650
|
+
if r["id"] in live_set:
|
|
2651
|
+
if wrow["state"] == waitq.STATE_STARTING:
|
|
2652
|
+
waitq.mark_running(waitq.KIND_CARD, r["id"])
|
|
2653
|
+
elif wrow["state"] == waitq.STATE_STARTING:
|
|
2654
|
+
waitq.cancel(waitq.KIND_CARD, r["id"], "服务重启中断")
|
|
2655
|
+
else:
|
|
2656
|
+
waitq.finish_by_target(waitq.KIND_CARD, r["id"],
|
|
2657
|
+
waitq.STATE_FAILED, "服务重启中断")
|
|
2658
|
+
# —— c: 行全表兜底扫描(v2a T1 minor 收口,v2d T1):上方循环只覆盖
|
|
2659
|
+
# doing 卡;非 doing 卡的活跃 c: 行(结构性不可达——doing+queue 单态,
|
|
2660
|
+
# 防御纵深)按同口径收口。非 doing 卡必不在 live_set(_RUNS 重建只对
|
|
2661
|
+
# doing 卡),即恒无实况:starting→cancelled、running/finishing→failed
|
|
2662
|
+
# 「服务重启中断」;waiting 行不动(重启存活语义);兜底不搬列(列归位
|
|
2663
|
+
# 归上方 doing 卡主循环)。
|
|
2664
|
+
covered = {r["id"] for r in rows}
|
|
2665
|
+
for wrow in waitq.active_items():
|
|
2666
|
+
if wrow["kind"] != waitq.KIND_CARD \
|
|
2667
|
+
or wrow["state"] == waitq.STATE_WAITING:
|
|
2668
|
+
continue
|
|
2669
|
+
try:
|
|
2670
|
+
wcid = int(wrow["target_id"])
|
|
2671
|
+
except ValueError:
|
|
2672
|
+
wcid = None
|
|
2673
|
+
if wcid is not None and wcid in covered:
|
|
2674
|
+
continue
|
|
2675
|
+
if wrow["state"] == waitq.STATE_STARTING:
|
|
2676
|
+
waitq.cancel(waitq.KIND_CARD, wrow["target_id"], "服务重启中断")
|
|
2677
|
+
else:
|
|
2678
|
+
waitq.finish_by_target(waitq.KIND_CARD, wrow["target_id"],
|
|
2679
|
+
waitq.STATE_FAILED, "服务重启中断")
|
|
2680
|
+
for cid in live:
|
|
2681
|
+
c = db.get_board_card(cid)
|
|
2682
|
+
if c is not None:
|
|
2683
|
+
if (_card_opt(c, "worktree") or "").strip():
|
|
2684
|
+
# 独立 worktree 卡(2026-10-06 批次):起跑即**不落队列行**,
|
|
2685
|
+
# 这里不能补建——card_started→waitq.enter_running 会「行缺失则
|
|
2686
|
+
# 建行」,重启后凭空多出一条占项目运行位的 c: 行,等于把免排队
|
|
2687
|
+
# 的卡重新塞回队列(plan §2.2 唯一需要补守卫处)。运行事实由
|
|
2688
|
+
# `_recover_web_card` 重建的 `_RUNS` 条目承担,收尾照常走
|
|
2689
|
+
# `_finish_run`→`finish`(无行幂等)。
|
|
2690
|
+
continue
|
|
2691
|
+
ext = {"reason": "recover 重建"}
|
|
2692
|
+
with _runs_lock:
|
|
2693
|
+
rec = _RUNS.get(cid)
|
|
2694
|
+
if rec is not None and rec.get("proc") is not None:
|
|
2695
|
+
ext["pid"] = rec["proc"].pid
|
|
2696
|
+
# card_started 经 enter_running 确保行 running(上方行映射段
|
|
2697
|
+
# 已把实况 busy 的 starting 行标 running;行缺失/终态时在此补齐
|
|
2698
|
+
# 建行)——不再把行标 done(旧口径把跨轮状态另立表征)。
|
|
2699
|
+
# 同一入口把 desc/reason/pid 证据写入行 evidence(判活读行)
|
|
2700
|
+
runner.INSTANCE.card_started(cid, c["project_id"], ext=ext)
|
|
2701
|
+
queued = db.list_queued_board_cards()
|
|
2702
|
+
# 重建入队前刷新外部条目(ext 行重启映射,v2d T4;v2 §5 第 4 条):
|
|
2703
|
+
# 先于 submit_card 的 notify——否则重启后首个调和 tick(≤5s)前外部会话
|
|
2704
|
+
# 在跑也会被立即拾起,竞态窗口照旧。刷新面=有 ext 行的项目 ∪ 有排队卡的
|
|
2705
|
+
# 项目(v2d 收口轮评审项 2:停机期间起跑的外部会话行尚未落表,只按「有行
|
|
2706
|
+
# 的项目」刷新会漏);逐项目按活跃会话集合精确对账:实况仍在 → 行保持
|
|
2707
|
+
# running(外部会话不受平台重启影响,占位不丢);实况空闲/不可查(CLI 族)
|
|
2708
|
+
# → finish 收口让出前缀位次(补位时机③)。
|
|
2709
|
+
if _recover_ext_rows(queued) and runner.INSTANCE is not None:
|
|
2710
|
+
runner.INSTANCE.notify_busy_change()
|
|
2711
|
+
for c in queued:
|
|
2712
|
+
if waitq.get_active(waitq.KIND_CARD, c["id"]) is None:
|
|
2713
|
+
# P4 对账:waiting 行存活的卡不动(位次以表 seq 为准,不再按
|
|
2714
|
+
# updated_at,id 重排——变更②);仅行缺失才补建
|
|
2715
|
+
runner.INSTANCE.submit_card(c["id"])
|
|
2716
|
+
|
|
2717
|
+
|
|
2718
|
+
# ---------- Jira 导入(服务端 urllib 代理,避免浏览器 CORS;凭据不落地浏览器) ----------
|
|
2719
|
+
|
|
2720
|
+
JIRA_TIMEOUT = 10 # 秒
|
|
2721
|
+
|
|
2722
|
+
|
|
2723
|
+
def _jira_get(cfg, path):
|
|
2724
|
+
"""Jira GET(Basic Auth)。返回 (status, json_or_text);网络异常返回 (0, 错误串)。"""
|
|
2725
|
+
url = cfg["url"].rstrip("/") + path
|
|
2726
|
+
token = base64.b64encode(f"{cfg['user']}:{cfg['token']}".encode()).decode()
|
|
2727
|
+
req = urllib.request.Request(url, headers={
|
|
2728
|
+
"Authorization": f"Basic {token}", "Accept": "application/json"})
|
|
2729
|
+
try:
|
|
2730
|
+
with urllib.request.urlopen(req, timeout=JIRA_TIMEOUT) as resp:
|
|
2731
|
+
return resp.status, json.loads(resp.read().decode("utf-8"))
|
|
2732
|
+
except urllib.error.HTTPError as e:
|
|
2733
|
+
return e.code, e.read().decode("utf-8", errors="replace")[:500]
|
|
2734
|
+
except (urllib.error.URLError, OSError, ValueError) as e:
|
|
2735
|
+
return 0, str(e)
|
|
2736
|
+
|
|
2737
|
+
|
|
2738
|
+
def jira_myself(cfg):
|
|
2739
|
+
"""测试连接:GET /rest/api/3/myself。返回 {"ok", "name"|"error"}。"""
|
|
2740
|
+
if not (cfg.get("url") and cfg.get("user") and cfg.get("token")):
|
|
2741
|
+
return {"ok": False, "error": "请填全 Jira 地址 / 用户名 / token"}
|
|
2742
|
+
status, data = _jira_get(cfg, "/rest/api/3/myself")
|
|
2743
|
+
if status == 200 and isinstance(data, dict):
|
|
2744
|
+
return {"ok": True, "name": data.get("displayName") or data.get("name") or ""}
|
|
2745
|
+
return {"ok": False, "error": f"HTTP {status}: {data if isinstance(data, str) else json.dumps(data)[:200]}"}
|
|
2746
|
+
|
|
2747
|
+
|
|
2748
|
+
def jira_import(project_id):
|
|
2749
|
+
"""导入当前用户未解决的 Jira 工单为看板卡片(jira_key 去重,最多 50 条)。"""
|
|
2750
|
+
cfg = settings_of(project_id)["jira"]
|
|
2751
|
+
if not (cfg.get("url") and cfg.get("user") and cfg.get("token")):
|
|
2752
|
+
return {"ok": False, "error": "请先在设置中配置 Jira"}
|
|
2753
|
+
jql = urllib.parse.quote(
|
|
2754
|
+
"assignee=currentUser() AND resolution=Unresolved ORDER BY updated DESC")
|
|
2755
|
+
status, data = _jira_get(cfg, f"/rest/api/3/search?jql={jql}&maxResults=50")
|
|
2756
|
+
if status != 200 or not isinstance(data, dict):
|
|
2757
|
+
return {"ok": False, "error": f"HTTP {status}: {str(data)[:200]}"}
|
|
2758
|
+
existing = {r["jira_key"] for r in db.list_board_cards(project_id) if r["jira_key"]}
|
|
2759
|
+
added = skipped = 0
|
|
2760
|
+
for issue in data.get("issues") or []:
|
|
2761
|
+
key = issue.get("key") or ""
|
|
2762
|
+
if not key:
|
|
2763
|
+
continue
|
|
2764
|
+
if key in existing:
|
|
2765
|
+
skipped += 1
|
|
2766
|
+
continue
|
|
2767
|
+
fields = issue.get("fields") or {}
|
|
2768
|
+
summary = (fields.get("summary") or key)[:200]
|
|
2769
|
+
desc = f"Jira: {cfg['url'].rstrip('/')}/browse/{key}"
|
|
2770
|
+
db.insert_board_card(project_id, f"[{key}] {summary}", desc, jira_key=key)
|
|
2771
|
+
added += 1
|
|
2772
|
+
return {"ok": True, "added": added, "skipped": skipped}
|
|
2773
|
+
|
|
2774
|
+
|
|
2775
|
+
# ---------- 容器迁移 = 出队(卡离开「正在开发」容器即行收口;v3b 让行退场) ----------
|
|
2776
|
+
|
|
2777
|
+
def _dequeue_card(card_id, reason="", to_column=None, mark_unread=True):
|
|
2778
|
+
"""出队收口(v3b 行收口唯一实现):卡离开「正在开发」容器即出队。
|
|
2779
|
+
|
|
2780
|
+
容器语义(设计 §2.1 I1):只有「正在开发」是调度队列;卡迁移到阻塞 / 待审核 /
|
|
2781
|
+
已完成 / 删除时行随之收口——不再有独立的「释放占用」动作与触发判据。等待区行
|
|
2782
|
+
(含无活跃行时的占位投影)经 waitq.cancel_card_wait 取消(行 + 占位单事务),
|
|
2783
|
+
运行行经唯一收尾点 `finish(f"c:<id>")` 终态化(finishing→done +
|
|
2784
|
+
补位唤醒);to_column 给定则一并归位(列差守卫,与 finish 的搬列同语义)。
|
|
2785
|
+
无活跃行时各子步幂等 no-op;finish 在 runner 缺位时只归位不收行(退化路径,
|
|
2786
|
+
同唯一收尾点)。
|
|
2787
|
+
|
|
2788
|
+
`starting` 行处置(v3 终审修复补记,行为不变):本原语**既不取消也不收口
|
|
2789
|
+
starting 行**——起点行的终态归属「拾取方」:worker finally 的 `c:` 分支
|
|
2790
|
+
(起跑失败 → failed「起会话失败」/ 状态不符 → cancelled「卡片状态不符」)与
|
|
2791
|
+
starting 超时对账 `_reconcile_starting_rows`(可证未起才 failed + 卡回列);
|
|
2792
|
+
本原语对它的作用只剩「finish 的搬列/补位子步」。不顺手 cancel 的理由:起点
|
|
2793
|
+
行可能正被 worker 起会话,抢标会与拾取方抢同一行的终态标签(v2b fix round 1
|
|
2794
|
+
口径,`card_finished` 门禁同源);起跑失败终态化后路径仍经 `finish` 收口占位
|
|
2795
|
+
(`dequeue_start`/worker finally 双调幂等)。
|
|
2796
|
+
|
|
2797
|
+
mark_unread 透传给 finish 的搬列(2026-10-07 批次):用户本人操作导致的出队
|
|
2798
|
+
(`stop_card` 停止→待审核)传 False,不给自己置「有更新」标记。
|
|
2799
|
+
"""
|
|
2800
|
+
row = waitq.get_active(waitq.KIND_CARD, card_id)
|
|
2801
|
+
if row is None or row["state"] == waitq.WAITING:
|
|
2802
|
+
# 等待区行取消(行 + 占位投影单事务);无活跃行时只剩占位投影清理(原
|
|
2803
|
+
# delete_card_cleanup「无条件清占位」语义保留;投影不在场/no 行时 no-op)
|
|
2804
|
+
waitq.cancel_card_wait(card_id, reason)
|
|
2805
|
+
finish(f"c:{card_id}", reason, to_column=to_column, mark_unread=mark_unread)
|
|
2806
|
+
|
|
2807
|
+
|
|
2808
|
+
def _leave_doing(card, stop=True, reason=""):
|
|
2809
|
+
"""卡离开「正在开发」容器(v3b 统一原语,替代原让行动作 `_blocked_yield`)。
|
|
2810
|
+
|
|
2811
|
+
stop=True(手动拖入阻塞 / 拖离开发列 / 删除等路径):先停会话——stop_card
|
|
2812
|
+
内含取消本卡排队消息 / 放弃待送达答案,「非 doing 列不得有活会话」。
|
|
2813
|
+
stop=False(交互挂起自动落阻塞路径):只收行——abort 会清掉等待用户答复的
|
|
2814
|
+
pending 提问,且该会话在用户作答后照常续跑,不属「停止」语义。
|
|
2815
|
+
随后经 `_dequeue_card` 出队收口(行终态化 + 补位唤醒),
|
|
2816
|
+
统一队列立刻可补位下一个单元。
|
|
2817
|
+
2026-09-13 起平台不做任何提交(工作区改动由外部 hook 扩展在提问/回合结束
|
|
2818
|
+
时机提交,原 kimi-hooks 扩展已随 kimi 族退场,平台不介入)。
|
|
2819
|
+
"""
|
|
2820
|
+
if stop:
|
|
2821
|
+
stop_card(card["id"])
|
|
2822
|
+
_dequeue_card(card["id"], reason)
|
|
2823
|
+
|
|
2824
|
+
|
|
2825
|
+
# ---------- 会话归档同步(看板「已完成」⇄ dsh 归档集,2026-10-05) ----------
|
|
2826
|
+
#
|
|
2827
|
+
# 语义(用户口径):卡片在「已完成」⟺ 卡片**主会话**在 dsh 归档集里;卡片在
|
|
2828
|
+
# 「已完成」时其**全部**绑定会话都应归档,离开「已完成」时全部取消归档。
|
|
2829
|
+
# 两个写者:① 平台拖拽/起跑(`move_card` / `_enter_doing`,归档先行);
|
|
2830
|
+
# ② 用户在 dsh GUI 里归档/取消归档(调和器按 `dshevents` 归档集快照反向搬列)。
|
|
2831
|
+
# 失败口径(用户确认):用户动作路径**硬失败**——归档不成则本次移列整体放弃
|
|
2832
|
+
# (本次已改动的 sid 补偿回滚),server 端点按 400 报给前端;「会话不存在」
|
|
2833
|
+
# (宿主 410)属无同步对象,跳过该 sid 放行、卡片记一行 last_error 提示。
|
|
2834
|
+
# 调和器路径(dsh 侧已改,回滚不了)best-effort + 内存退避重试。
|
|
2835
|
+
|
|
2836
|
+
ARCHIVE_SYNC_ENV = "TS_ARCHIVE_SYNC" # "0" 关闭归档同步(应急开关,默认开)
|
|
2837
|
+
_ARCHIVE_TIMEOUT_S = 10 # 单次归档调用超时(驱动是宿主进程内调用,够用)
|
|
2838
|
+
_ARCHIVE_RETRY = {} # sid -> [尝试次数, 下次时间, 目标态]
|
|
2839
|
+
_ARCHIVE_RETRY_LOCK = threading.Lock()
|
|
2840
|
+
_ARCHIVE_RETRY_MAX = 5 # 重试次数上限(退避 5s→10s→20s→40s→80s)
|
|
2841
|
+
_ARCHIVE_RETRY_BASE_S = 5.0
|
|
2842
|
+
_ARCHIVE_RETRY_TICK_MAX = 8 # 每轮调和最多重试几个(防单轮打爆)
|
|
2843
|
+
# 外部取消归档的**边沿**检测(sid -> 上次观测到的归档态):只在 True→False 且卡片
|
|
2844
|
+
# 在 done 时动作——归档失败/断连(未知)不会把卡片踢出「已完成」(电平语义会抖动)。
|
|
2845
|
+
_ARCH_SEEN = {}
|
|
2846
|
+
_ARCH_SEEN_MAX = 4096 # 超限时按本轮在场集合清理(内存有界)
|
|
2847
|
+
|
|
2848
|
+
|
|
2849
|
+
def archive_sync_enabled():
|
|
2850
|
+
"""归档同步总开关(`TS_ARCHIVE_SYNC=0` 关闭;默认开)。"""
|
|
2851
|
+
return os.environ.get(ARCHIVE_SYNC_ENV, "1") not in ("0", "false", "False")
|
|
2852
|
+
|
|
2853
|
+
|
|
2854
|
+
def _card_sids(card):
|
|
2855
|
+
"""卡片全部绑定会话 id(主会话在前、去重去空)。"""
|
|
2856
|
+
out = []
|
|
2857
|
+
main = (card["session_id"] or "").strip()
|
|
2858
|
+
if main:
|
|
2859
|
+
out.append(main)
|
|
2860
|
+
try:
|
|
2861
|
+
sids = json.loads(card["sessions"] or "[]")
|
|
2862
|
+
except ValueError:
|
|
2863
|
+
sids = []
|
|
2864
|
+
if isinstance(sids, list):
|
|
2865
|
+
for sid in sids:
|
|
2866
|
+
s = str(sid or "").strip()
|
|
2867
|
+
if s and s not in out:
|
|
2868
|
+
out.append(s)
|
|
2869
|
+
return out
|
|
2870
|
+
|
|
2871
|
+
|
|
2872
|
+
def _archive_call(sid, archived):
|
|
2873
|
+
"""单会话归档/取消归档:返回 `"ok"` / `"unknown"` / 错误串。
|
|
2874
|
+
|
|
2875
|
+
`"unknown"` = 宿主明确回报「会话不存在」(`ARCHIVE_UNKNOWN_SESSION`)——
|
|
2876
|
+
调用方跳过该 sid(无同步对象),不算失败。
|
|
2877
|
+
"""
|
|
2878
|
+
try:
|
|
2879
|
+
dshdriver.archive(sid, archived=archived, timeout=_ARCHIVE_TIMEOUT_S)
|
|
2880
|
+
return "ok"
|
|
2881
|
+
except dshdriver.DshDriverError as e:
|
|
2882
|
+
if e.code == dshdriver.ARCHIVE_UNKNOWN_SESSION:
|
|
2883
|
+
return "unknown"
|
|
2884
|
+
return str(e)
|
|
2885
|
+
except Exception as e: # noqa: BLE001 — 归档面任何异常都不许打穿移列
|
|
2886
|
+
return str(e)
|
|
2887
|
+
|
|
2888
|
+
|
|
2889
|
+
def _record_card_error(card_id, text):
|
|
2890
|
+
"""给卡片记一行 last_error(归档同步的跳过/失败提示;写失败绝不上抛)。"""
|
|
2891
|
+
try:
|
|
2892
|
+
db.update_board_card(card_id, last_error=str(text)[:200],
|
|
2893
|
+
last_error_at=int(time.time() * 1000))
|
|
2894
|
+
except Exception:
|
|
2895
|
+
pass
|
|
2896
|
+
|
|
2897
|
+
|
|
2898
|
+
def _archive_card_sessions(card, archived, reason="", strict=True):
|
|
2899
|
+
"""把卡片全部绑定会话置为归档(True)/取消归档(False)。
|
|
2900
|
+
|
|
2901
|
+
返回 None=到位(含卡片无绑定会话、开关关闭、独立形态无 dsh 宿主);
|
|
2902
|
+
strict=True 时返回错误串=调用方须放弃本次移列(server 端点 400 报前端),
|
|
2903
|
+
且本次已改动的 sid 已**补偿回滚**到改动前状态。
|
|
2904
|
+
strict=False(调和器级联:dsh 侧已改、回滚不了)只记日志 + 退避重试。
|
|
2905
|
+
|
|
2906
|
+
会话不存在 → 跳过并记 last_error 提示(历史遗留 sid / 会话存储已删)。
|
|
2907
|
+
补偿只回滚「本次确实改动过、且改动前状态已知为相反值」的 sid——中枢未连接
|
|
2908
|
+
(归档集未知)时不猜,宁可留一次告警也不误改用户既有的归档。
|
|
2909
|
+
"""
|
|
2910
|
+
if not archive_sync_enabled() or not dshdriver.configured():
|
|
2911
|
+
return None # 关闭 / 独立形态:没有 dsh 宿主可同步
|
|
2912
|
+
sids = _card_sids(card)
|
|
2913
|
+
if not sids:
|
|
2914
|
+
return None
|
|
2915
|
+
prior = dshevents.archived_set() # None=未知(补偿判据见 docstring)
|
|
2916
|
+
changed = [] # 本次确认改动的 sid
|
|
2917
|
+
skipped = [] # 会话不存在(无同步对象)
|
|
2918
|
+
failed = [] # [(sid, err)];传输类失败通常连带,取首个即可
|
|
2919
|
+
for sid in sids:
|
|
2920
|
+
if prior is not None and (sid in prior) == bool(archived):
|
|
2921
|
+
# 已知已到位:归档集快照说它已是目标态 ⇒ 不必再打驱动(级联归档时主会话
|
|
2922
|
+
# 通常已在集合里,省一次幂等调用;快照陈旧也不影响正确性)
|
|
2923
|
+
continue
|
|
2924
|
+
res = _archive_call(sid, archived)
|
|
2925
|
+
if res == "ok":
|
|
2926
|
+
changed.append(sid)
|
|
2927
|
+
elif res == "unknown":
|
|
2928
|
+
skipped.append(sid)
|
|
2929
|
+
else:
|
|
2930
|
+
failed.append((sid, res))
|
|
2931
|
+
if strict:
|
|
2932
|
+
break # 用户路径:首次失败即回滚收口(不再打后续 sid)
|
|
2933
|
+
verb = "归档" if archived else "取消归档"
|
|
2934
|
+
if failed and strict:
|
|
2935
|
+
for sid in reversed(changed):
|
|
2936
|
+
was = None if prior is None else (sid in prior)
|
|
2937
|
+
if was is (not archived): # 原本状态与目标相反 ⇒ 本次真改过
|
|
2938
|
+
_archive_call(sid, not archived)
|
|
2939
|
+
sid, err = failed[0]
|
|
2940
|
+
msg = f"会话{verb}失败:{sid}({err})"
|
|
2941
|
+
_record_card_error(card["id"], msg)
|
|
2942
|
+
return msg
|
|
2943
|
+
if failed:
|
|
2944
|
+
for sid, err in failed:
|
|
2945
|
+
_archive_retry_add(sid, archived, err)
|
|
2946
|
+
print(f"[board] 归档同步未到位({reason or '调和'}):{len(failed)} 个会话待重试",
|
|
2947
|
+
flush=True)
|
|
2948
|
+
if skipped:
|
|
2949
|
+
_record_card_error(card["id"], f"归档跳过(会话不存在): {skipped[0]}")
|
|
2950
|
+
return None
|
|
2951
|
+
|
|
2952
|
+
|
|
2953
|
+
def archive_bound_session(card, sid, reason="新绑定会话"):
|
|
2954
|
+
"""卡片在「已完成」时把新绑定的会话也归档(I2 维护;best-effort + 退避重试)。
|
|
2955
|
+
|
|
2956
|
+
调用点:server 改卡端点的 `bind_session`(含 fork / 压缩新建后的绑定)——绑卡
|
|
2957
|
+
动作本身已生效,这里失败**不回滚绑定**(回滚会话列表代价更大),只记 last_error
|
|
2958
|
+
+ 进退避重试;卡片不在 done / 开关关闭 / 独立形态一律 no-op。
|
|
2959
|
+
"""
|
|
2960
|
+
if card is None or not sid:
|
|
2961
|
+
return
|
|
2962
|
+
if card["column_key"] != "done":
|
|
2963
|
+
return
|
|
2964
|
+
if not archive_sync_enabled() or not dshdriver.configured():
|
|
2965
|
+
return
|
|
2966
|
+
res = _archive_call(sid, True)
|
|
2967
|
+
if res == "unknown":
|
|
2968
|
+
_record_card_error(card["id"], f"归档跳过(会话不存在): {sid}")
|
|
2969
|
+
elif res != "ok":
|
|
2970
|
+
_archive_retry_add(sid, True, res)
|
|
2971
|
+
_record_card_error(card["id"], f"会话归档失败(待重试):{sid}")
|
|
2972
|
+
|
|
2973
|
+
|
|
2974
|
+
def _archive_retry_add(sid, archived, reason=""):
|
|
2975
|
+
"""登记一个待重试的归档/取消归档 sid(指数退避;次数用尽告警一次并放弃)。"""
|
|
2976
|
+
now = time.time()
|
|
2977
|
+
with _ARCHIVE_RETRY_LOCK:
|
|
2978
|
+
item = _ARCHIVE_RETRY.get(sid)
|
|
2979
|
+
attempts = (item[0] if item else 0) + 1
|
|
2980
|
+
if attempts > _ARCHIVE_RETRY_MAX:
|
|
2981
|
+
_ARCHIVE_RETRY.pop(sid, None)
|
|
2982
|
+
print(f"[board] 归档重试放弃:{sid}({reason})", flush=True)
|
|
2983
|
+
return
|
|
2984
|
+
delay = min(_ARCHIVE_RETRY_BASE_S * (2 ** (attempts - 1)), 300.0)
|
|
2985
|
+
_ARCHIVE_RETRY[sid] = [attempts, now + delay, bool(archived)]
|
|
2986
|
+
|
|
2987
|
+
|
|
2988
|
+
def _archive_retry_tick():
|
|
2989
|
+
"""调和器节拍里的归档重试(每轮最多 `_ARCHIVE_RETRY_TICK_MAX` 个)。
|
|
2990
|
+
|
|
2991
|
+
只服务调和器级联(strict=False)留下的未到位项;用户动作路径失败即回滚,
|
|
2992
|
+
不留待重试状态。
|
|
2993
|
+
"""
|
|
2994
|
+
if not archive_sync_enabled() or not dshdriver.configured():
|
|
2995
|
+
return
|
|
2996
|
+
now = time.time()
|
|
2997
|
+
with _ARCHIVE_RETRY_LOCK:
|
|
2998
|
+
due = [(sid, int(v[2])) for sid, v in _ARCHIVE_RETRY.items() if v[1] <= now]
|
|
2999
|
+
due = due[:_ARCHIVE_RETRY_TICK_MAX]
|
|
3000
|
+
for sid, _want in due:
|
|
3001
|
+
_ARCHIVE_RETRY.pop(sid, None) # 取出即摘(失败会重新登记、次数累计)
|
|
3002
|
+
for sid, want in due:
|
|
3003
|
+
res = _archive_call(sid, bool(want))
|
|
3004
|
+
if res not in ("ok", "unknown"):
|
|
3005
|
+
_archive_retry_add(sid, bool(want), res)
|
|
3006
|
+
|
|
3007
|
+
|
|
3008
|
+
def _archive_unarchive_edge(card):
|
|
3009
|
+
"""观测到「dsh 取消归档」边沿(True→False)且卡片在「已完成」→ 归位「待审核」。
|
|
3010
|
+
|
|
3011
|
+
用户口径(2026-10-05②):任一方改状态另一方同步——dsh 侧取消归档,卡片离开
|
|
3012
|
+
「已完成」;按 I2 顺带把其余绑定会话也取消归档(best-effort + 重试)。
|
|
3013
|
+
read-verify-write:写前重读卡片与归档态,状态已变/未知即跳过(防旧判定覆盖)。
|
|
3014
|
+
搬列经统一出队原语(`to_column` 落列 + 行收口),与 `_iw_apply` 同款。
|
|
3015
|
+
"""
|
|
3016
|
+
cur = db.get_board_card(card["id"])
|
|
3017
|
+
if cur is None or cur["column_key"] != "done":
|
|
3018
|
+
return
|
|
3019
|
+
if dshevents.archived(cur["session_id"] or "") is not False:
|
|
3020
|
+
return # 已变/未知:不动作
|
|
3021
|
+
_dequeue_card(cur["id"], "dsh 取消归档同步", to_column="review")
|
|
3022
|
+
_archive_card_sessions(cur, False, "取消归档级联", strict=False)
|
|
3023
|
+
|
|
3024
|
+
|
|
3025
|
+
# ---------- session 自动同步(外部 agent 会话 → 看板卡片归类) ----------
|
|
3026
|
+
|
|
3027
|
+
# 退场族 busy 近似(原 CLI 族,保留为防御):会话文件 mtime 距今小于该秒数
|
|
3028
|
+
# 视为运行中(mtime 语义随族而异,如 hermes 是 started_at=会话开始时间而非
|
|
3029
|
+
# 最后活动,近似语义为「刚开始不久」)
|
|
3030
|
+
SYNC_BUSY_MTIME_S = 120
|
|
3031
|
+
|
|
3032
|
+
# sync 卡标题/描述口径(2026-10-07):标题与描述都取「主会话第一次用户提问」——
|
|
3033
|
+
# 首行进标题、其余进描述(与手工建卡的 QuickAdd「首行=标题、其余行=描述」同约定)。
|
|
3034
|
+
SYNC_TITLE_MAX = 200 # 标题上限(沿用建卡既有截断:insert_board_card 调用处 [:200])
|
|
3035
|
+
SYNC_DESC_MAX = 2000 # 描述上限(防一次长提问把看板负载撑大)
|
|
3036
|
+
|
|
3037
|
+
|
|
3038
|
+
def _split_first_prompt(text):
|
|
3039
|
+
"""首问原文 → (标题, 描述):首行=标题、其余行=描述。
|
|
3040
|
+
|
|
3041
|
+
- 首行超 SYNC_TITLE_MAX 的溢出部分并入描述开头(截断不丢内容);
|
|
3042
|
+
- 描述超 SYNC_DESC_MAX 截断并补省略号;
|
|
3043
|
+
- 原文为空 → ('', '')(调用方自行兜底标题)。
|
|
3044
|
+
"""
|
|
3045
|
+
text = (text or "").strip()
|
|
3046
|
+
if not text:
|
|
3047
|
+
return "", ""
|
|
3048
|
+
lines = text.split("\n")
|
|
3049
|
+
first = lines[0].strip()
|
|
3050
|
+
rest = "\n".join(lines[1:]).strip()
|
|
3051
|
+
if len(first) > SYNC_TITLE_MAX:
|
|
3052
|
+
overflow = first[SYNC_TITLE_MAX:].strip()
|
|
3053
|
+
rest = "\n".join(x for x in (overflow, rest) if x)
|
|
3054
|
+
first = first[:SYNC_TITLE_MAX]
|
|
3055
|
+
if len(rest) > SYNC_DESC_MAX:
|
|
3056
|
+
rest = rest[:SYNC_DESC_MAX] + "…"
|
|
3057
|
+
return first, rest
|
|
3058
|
+
|
|
3059
|
+
|
|
3060
|
+
def _sync_card_fields(item, sid):
|
|
3061
|
+
"""sync 卡**新建**时的 (标题, 描述)。
|
|
3062
|
+
|
|
3063
|
+
优先「主会话第一次用户提问」(首行→标题、其余→描述);会话还没收到提问时
|
|
3064
|
+
回落会话标题事件、再回落 sid 短码——建卡后首问一旦落盘,由
|
|
3065
|
+
`_sync_card_follow` 在同步节拍里补齐(会话目录先建、提问后到的真实竞态,
|
|
3066
|
+
见 board spec「sync 会话归类」)。
|
|
3067
|
+
"""
|
|
3068
|
+
title, desc = _split_first_prompt(item.get("first_prompt") or "")
|
|
3069
|
+
if not title:
|
|
3070
|
+
title = ((item.get("title") or "") or sid[:12])[:SYNC_TITLE_MAX]
|
|
3071
|
+
return title, desc
|
|
3072
|
+
|
|
3073
|
+
|
|
3074
|
+
def _sync_card_follow(card, item, sid):
|
|
3075
|
+
"""存量 sync 卡按首问补齐的字段 dict(无变化返回 {},调用方直接 update)。
|
|
3076
|
+
|
|
3077
|
+
只认「主会话」:遍历到的会话不是卡的主会话(多会话并集里的子会话)时不动,
|
|
3078
|
+
对齐需求「主会话的第一次用户提问」。
|
|
3079
|
+
只在卡仍由平台自动写入时动:标题为空 / 等于 sid 短码 / 等于旧口径写入的会话
|
|
3080
|
+
标题事件文本 —— 用户在卡面行内改过标题(title 三者都不等)就整张卡不再自动
|
|
3081
|
+
覆盖;描述只在卡描述为空时填,用户写过的描述不覆盖。
|
|
3082
|
+
"""
|
|
3083
|
+
if (card["session_id"] or sid) != sid:
|
|
3084
|
+
return {}
|
|
3085
|
+
title, desc = _split_first_prompt(item.get("first_prompt") or "")
|
|
3086
|
+
if not title:
|
|
3087
|
+
return {}
|
|
3088
|
+
cur_title = (card["title"] or "").strip()
|
|
3089
|
+
auto_titles = {"", sid[:12], ((item.get("title") or "") or "")[:SYNC_TITLE_MAX]}
|
|
3090
|
+
if cur_title not in auto_titles:
|
|
3091
|
+
return {}
|
|
3092
|
+
out = {}
|
|
3093
|
+
if cur_title != title:
|
|
3094
|
+
out["title"] = title
|
|
3095
|
+
if desc and not (card["description"] or "").strip():
|
|
3096
|
+
out["description"] = desc
|
|
3097
|
+
return out
|
|
3098
|
+
|
|
3099
|
+
|
|
3100
|
+
def sync_sessions(project):
|
|
3101
|
+
"""自动同步项目 agent 会话到看板。返回新建卡 id 列表。
|
|
3102
|
+
|
|
3103
|
+
新建卡(origin='sync'):标题/描述=主会话第一次用户提问(首行→标题、其余→
|
|
3104
|
+
描述;首问未落盘时回落会话标题、再回落 sid 短码,落盘后由本函数补齐),绑定
|
|
3105
|
+
主会话,busy→doing / 空闲→review;存量 sync 卡:首问补齐标题/描述(用户改过
|
|
3106
|
+
名的不动)+ dsh 族列映射移交调和器(事件驱动),此处仅归档→done(人工拖到
|
|
3107
|
+
todo/blocked/done 后不再自动搬);会话存储被删的 sync 卡自动进 done。
|
|
3108
|
+
sync 卡不占 runner 项目占用(外部会话平台控制不了,防堵死统一队列)。
|
|
3109
|
+
"""
|
|
3110
|
+
if not settings_of(project["id"]).get("sync_sessions", True):
|
|
3111
|
+
return []
|
|
3112
|
+
raw_family = runner.agent_family(project["agent_path"])
|
|
3113
|
+
# 归族 → 解析族:单族化后只有 dsh_plugin,会话存储解析归 sessparse 的
|
|
3114
|
+
# "dsh" 族(与 server._sess_family 的 dsh_plugin→dsh 同口径);
|
|
3115
|
+
# 退场族不在解析白名单内,直接返回空(不建卡不搬列)。
|
|
3116
|
+
family = "dsh" if raw_family == "dsh_plugin" else raw_family
|
|
3117
|
+
if family not in sessparse.FAMILIES:
|
|
3118
|
+
return []
|
|
3119
|
+
items = sessparse.list_sessions(family, project["project_dir"])
|
|
3120
|
+
cards = db.list_board_cards(project["id"])
|
|
3121
|
+
bound = {} # sid -> 卡片 row(sessions 并集反查)
|
|
3122
|
+
for c in cards:
|
|
3123
|
+
for s in json.loads(c["sessions"] or "[]"):
|
|
3124
|
+
bound[s] = c
|
|
3125
|
+
if c["session_id"]:
|
|
3126
|
+
bound[c["session_id"]] = c
|
|
3127
|
+
created = []
|
|
3128
|
+
for it in items:
|
|
3129
|
+
sid = it["sid"]
|
|
3130
|
+
archived = bool(it.get("archived"))
|
|
3131
|
+
c = bound.get(sid)
|
|
3132
|
+
# busy 按需判定(2026-09-27):只有新卡分支消费 busy;存量 sync 卡只用
|
|
3133
|
+
# archived(列映射归调和器),不再为每个会话无条件探测。
|
|
3134
|
+
busy = False
|
|
3135
|
+
if c is None:
|
|
3136
|
+
busy = _sync_session_busy(project, raw_family, sid, it["mtime"])
|
|
3137
|
+
if c is None:
|
|
3138
|
+
title, desc = _sync_card_fields(it, sid)
|
|
3139
|
+
cid = db.insert_board_card(project["id"], title, desc)
|
|
3140
|
+
# 归档会话:不参与 busy 判定,直接落「已完成」
|
|
3141
|
+
col = "done" if archived else ("doing" if busy else "review")
|
|
3142
|
+
db.update_board_card(cid, session_id=sid, sessions=json.dumps([sid]),
|
|
3143
|
+
column_key=col, origin="sync")
|
|
3144
|
+
created.append(cid)
|
|
3145
|
+
elif c["origin"] == "sync":
|
|
3146
|
+
# 首问补齐(2026-10-07):标题/描述仍是平台自动写入形态时按首问覆盖
|
|
3147
|
+
# (覆盖「建卡时提问还没到」的竞态与旧口径的存量卡;用户改过名不动)
|
|
3148
|
+
fields = _sync_card_follow(c, it, sid)
|
|
3149
|
+
if fields:
|
|
3150
|
+
db.update_board_card(c["id"], **fields)
|
|
3151
|
+
if c["column_key"] in ("doing", "review") and not _has_active_run(c["id"]):
|
|
3152
|
+
# dsh 的 busy/空闲 列映射已移交调和器(事件驱动,异常不搬列);
|
|
3153
|
+
# 此处保留归档→done(archived 来源=list_sessions 的宿主归档集文件读,
|
|
3154
|
+
# 与调和器读驱动帧是两条独立通道:文件读不依赖驱动链路,重启/断连时
|
|
3155
|
+
# 也能把归档会话归类,故不删——两者同向、幂等)
|
|
3156
|
+
want = "done" if archived else None
|
|
3157
|
+
if want and c["column_key"] != want:
|
|
3158
|
+
db.update_board_card(c["id"], column_key=want)
|
|
3159
|
+
# 存储被删 → done(list 只回 50 条,不能用「不在列表」判定删除,必须逐卡查存在性)
|
|
3160
|
+
for c in cards:
|
|
3161
|
+
if c["origin"] != "sync" or c["column_key"] == "done" or _has_active_run(c["id"]):
|
|
3162
|
+
continue
|
|
3163
|
+
sids = set(json.loads(c["sessions"] or "[]"))
|
|
3164
|
+
if c["session_id"]:
|
|
3165
|
+
sids.add(c["session_id"])
|
|
3166
|
+
if sids and not any(sessparse.session_exists(family, s) for s in sids):
|
|
3167
|
+
db.update_board_card(c["id"], column_key="done")
|
|
3168
|
+
return created
|
|
3169
|
+
|
|
3170
|
+
|
|
3171
|
+
def _sync_session_busy(project, raw_family, sid, mtime):
|
|
3172
|
+
"""busy 判定:单族化后只有 dsh_plugin——读插件内存态(复用 _web_busy,
|
|
3173
|
+
wait=False 传透;EventHub 注册表天然非阻塞)。mtime 近似为 CLI 时代的防御
|
|
3174
|
+
残留(退场族走不到本函数:sync_sessions 已按解析族白名单提前返回)。
|
|
3175
|
+
异常视为空闲。"""
|
|
3176
|
+
if raw_family == "dsh_plugin":
|
|
3177
|
+
try:
|
|
3178
|
+
return _web_busy(raw_family, project["project_dir"], sid,
|
|
3179
|
+
wait=False)
|
|
3180
|
+
except Exception:
|
|
3181
|
+
return False
|
|
3182
|
+
return (time.time() - mtime) < SYNC_BUSY_MTIME_S
|
|
3183
|
+
|
|
3184
|
+
|
|
3185
|
+
def _has_active_run(card_id):
|
|
3186
|
+
"""卡片是否有平台在管的运行中会话(_RUNS 条目存在即运行中,同 _watch_once 判定)。"""
|
|
3187
|
+
with _runs_lock:
|
|
3188
|
+
return card_id in _RUNS
|
|
3189
|
+
|
|
3190
|
+
|
|
3191
|
+
def run_pid(card_id):
|
|
3192
|
+
"""在管卡会话子进程 pid(无运行条目/web 族无 proc → None;R11④ 进程存活
|
|
3193
|
+
裁活的写入侧数据源,落行 evidence.pid)。"""
|
|
3194
|
+
with _runs_lock:
|
|
3195
|
+
rec = _RUNS.get(card_id)
|
|
3196
|
+
if rec is not None and rec.get("proc") is not None:
|
|
3197
|
+
return rec["proc"].pid
|
|
3198
|
+
return None
|
|
3199
|
+
|
|
3200
|
+
|
|
3201
|
+
# ---------- 交互等待检测(InteractionWatcher,方案 A) ----------
|
|
3202
|
+
# 单族化后等待态只有 dsh 一个来源:`dshevents` 注册表的 interaction 字段——
|
|
3203
|
+
# 提问来自 `user-questions/request` waterfall、审批来自 `approval/asked`
|
|
3204
|
+
# 会话事件(都是 dsh 宿主**推**出来的实况,零请求)。
|
|
3205
|
+
# → 看板卡片自动移阻塞列(block_kind='interaction'),等待消失自动回「正在开发」。
|
|
3206
|
+
# 复用看板既有 block_kind/block_text 展示通道,不改表结构。
|
|
3207
|
+
_IW_LOCK = threading.Lock()
|
|
3208
|
+
_IW_CACHE = {} # sid -> {"pending": bool, "text": str, "ts": int}
|
|
3209
|
+
_IW_STARTED = False
|
|
3210
|
+
|
|
3211
|
+
|
|
3212
|
+
def _dsh_option(raw):
|
|
3213
|
+
"""dsh 提问选项 → 前端选项对象({id, label, description})。
|
|
3214
|
+
|
|
3215
|
+
2026-10-04(修 #791)起插件下发**对象**形态 `{label, description}`
|
|
3216
|
+
(dsh `AskUserQuestionOption` 的语义:label 主行 + description 次行,kimi web
|
|
3217
|
+
时代同形);此处对纯字符串形态保持兼容(旧插件/测试替身仍可能给 str)。
|
|
3218
|
+
平台侧选项 id 与 label 同值:dsh 的回答只认**选项标签**(selected=[label]),
|
|
3219
|
+
平台白名单校验与 `_dsh_answers` 都按该口径(见 `_dsh_answers` docstring)。"""
|
|
3220
|
+
if isinstance(raw, dict):
|
|
3221
|
+
label = str(raw.get("label") or raw.get("value") or "")
|
|
3222
|
+
desc = str(raw.get("description") or "")
|
|
3223
|
+
else:
|
|
3224
|
+
label, desc = str(raw if raw is not None else ""), ""
|
|
3225
|
+
return {"id": label, "label": label, "description": desc}
|
|
3226
|
+
|
|
3227
|
+
|
|
3228
|
+
def _iw_interaction(family, proj, sid, busy_hint=None):
|
|
3229
|
+
"""读 web 会话实况(等待态 + busy):返回
|
|
3230
|
+
{"pending": bool, "busy": bool, "kind": "question"|"approval"|"",
|
|
3231
|
+
"qid": str|None, "question": str|None, "options": list|None,
|
|
3232
|
+
"answerable": bool, "text": str};
|
|
3233
|
+
提问另带 questions(**逐题全量**:id/文本/header/body/options/multi_select/
|
|
3234
|
+
allow_other/other_label/other_description,1-4 题)与首题平面字段(header/body/
|
|
3235
|
+
multi_select/allow_other/other_label/other_description/wire)——供前端渲染
|
|
3236
|
+
交互卡片(选项+描述+自定义输入 / 批准-拒绝)。
|
|
3237
|
+
options 逐项 = `_dsh_option` 归一后的 `{id, label, description}`(2026-10-04
|
|
3238
|
+
起带选项描述,修 #791);qid 取自插件的提问标识(认领中的提问非空 ⇒
|
|
3239
|
+
answerable=True ⇒ 会话窗渲染单选/多选框 + 「提交」)。
|
|
3240
|
+
异常/无 sid 返回 None(该轮跳过,不误移不报错)。
|
|
3241
|
+
单族化后只有 dsh_plugin:读 `dshevents` 注册表(busy 来自 agent/status
|
|
3242
|
+
事件,挂起来自 `user-questions/request` waterfall 与 `approval/asked`
|
|
3243
|
+
会话事件,都是 dsh 宿主**推**出来的实况)——零请求;未连接/未知 ⇒ None
|
|
3244
|
+
(本轮跳过,不误移不报错)。busy_hint 为既有调用面参数(原 opencode
|
|
3245
|
+
项目级 busy map 预查值;dsh 分支不消费——实况随注册表一次取全)。"""
|
|
3246
|
+
if not sid:
|
|
3247
|
+
return None
|
|
3248
|
+
try:
|
|
3249
|
+
if family != "dsh_plugin":
|
|
3250
|
+
return None
|
|
3251
|
+
# 路线 A P4:读 EventHub 注册表(原为逐会话 `/status`)——busy 来自
|
|
3252
|
+
# dsh 的 agent/status 事件,挂起来自 `user-questions/request`
|
|
3253
|
+
# waterfall 与 `approval/asked` 会话事件,都是**推**出来的实况。
|
|
3254
|
+
# 未连接/未知 ⇒ None(本轮跳过,不误移不报错)。
|
|
3255
|
+
st = dshevents.get(sid)
|
|
3256
|
+
if st is None:
|
|
3257
|
+
return None
|
|
3258
|
+
busy = st.get("status") == "running"
|
|
3259
|
+
mark = st.get("interaction") or {}
|
|
3260
|
+
if not busy or not mark:
|
|
3261
|
+
return {"pending": False, "busy": busy, "qid": None,
|
|
3262
|
+
"question": None, "options": None,
|
|
3263
|
+
"answerable": True, "text": ""}
|
|
3264
|
+
if mark.get("kind") == "approval":
|
|
3265
|
+
# 审批:平台**认领**时(`/permission` 接管会话 → 插件在
|
|
3266
|
+
# waterfall 里挂起并写 answerable=true)可代答;未认领则由 dsh GUI
|
|
3267
|
+
# 作答,平台只展示(answerable=False,按钮置灰)。
|
|
3268
|
+
tool = str(mark.get("tool") or "")
|
|
3269
|
+
text = f"请求审批:{tool or '工具调用'}"
|
|
3270
|
+
return {"pending": True, "busy": True, "kind": "approval",
|
|
3271
|
+
"qid": None, "approval_id": str(mark.get("id") or ""),
|
|
3272
|
+
"tool": tool, "action": tool, "input": "",
|
|
3273
|
+
"question": text, "options": None,
|
|
3274
|
+
"answerable": bool(mark.get("answerable")), "text": text[:80]}
|
|
3275
|
+
qs = []
|
|
3276
|
+
for idx, q in enumerate(mark.get("questions") or []):
|
|
3277
|
+
q = q or {}
|
|
3278
|
+
qs.append({
|
|
3279
|
+
"id": str(q.get("id") or f"q_{idx}"),
|
|
3280
|
+
"question": q.get("question") or "",
|
|
3281
|
+
"header": q.get("header") or "",
|
|
3282
|
+
"body": q.get("detail") or "",
|
|
3283
|
+
"options": [_dsh_option(o) for o in (q.get("options") or [])],
|
|
3284
|
+
"multi_select": bool(q.get("multi")),
|
|
3285
|
+
"allow_other": True, # dsh 作答支持自由文本(custom 字段)
|
|
3286
|
+
"other_label": "其他",
|
|
3287
|
+
"other_description": "",
|
|
3288
|
+
})
|
|
3289
|
+
q0 = qs[0] if qs else {}
|
|
3290
|
+
question = q0.get("question") or "agent 正在等待你的回答"
|
|
3291
|
+
# qid 走插件给的 tool call id(= dsh userQuestions.answer 的 callId):
|
|
3292
|
+
# 平台作答链路(answer_interaction → _answer_deliver)原样复用
|
|
3293
|
+
call_id = str(mark.get("call_id") or "")
|
|
3294
|
+
return {"pending": True, "busy": True, "kind": "question",
|
|
3295
|
+
"qid": call_id, "questions": qs,
|
|
3296
|
+
"wire": q0.get("id") or "q_0",
|
|
3297
|
+
"question": question,
|
|
3298
|
+
"header": q0.get("header") or "",
|
|
3299
|
+
"body": q0.get("body") or "",
|
|
3300
|
+
"options": q0.get("options") or None,
|
|
3301
|
+
"multi_select": q0.get("multi_select"),
|
|
3302
|
+
"allow_other": True,
|
|
3303
|
+
"other_label": "其他",
|
|
3304
|
+
"other_description": "",
|
|
3305
|
+
"answerable": bool(call_id),
|
|
3306
|
+
"text": question[:80]}
|
|
3307
|
+
except Exception:
|
|
3308
|
+
return None # 读注册表异常:本轮跳过
|
|
3309
|
+
|
|
3310
|
+
|
|
3311
|
+
_RECONCILE_FIELDS = {
|
|
3312
|
+
# 动作 -> 落库字段(block 的 block_text 由调用方按检测摘录补)
|
|
3313
|
+
"recover": {"column_key": "doing", "block_kind": None, "block_text": ""},
|
|
3314
|
+
"to_doing": {"column_key": "doing", "block_kind": None, "block_text": ""},
|
|
3315
|
+
"to_review": {"column_key": "review", "block_kind": None, "block_text": ""},
|
|
3316
|
+
# to_done 生产路径走 `_dequeue_card(to_column="done")`(行收口 + 搬列一体);
|
|
3317
|
+
# 本项为兜底字段表,保持「动作 -> 字段」表完整(勿直写:会漏行收口)
|
|
3318
|
+
"to_done": {"column_key": "done", "block_kind": None, "block_text": ""},
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3321
|
+
|
|
3322
|
+
def _reconcile_action_for(family, card, r, has_run=False, live_msg=False,
|
|
3323
|
+
stop_recent=False, archived=None):
|
|
3324
|
+
"""调和状态机(纯函数,单测覆盖):按会话实况 r(pending/busy)、归档态
|
|
3325
|
+
archived 与当前卡状态得出动作,action ∈
|
|
3326
|
+
{'block'(进阻塞+interaction),
|
|
3327
|
+
'recover'(交互阻塞解除回 doing——2026-09-09 起在管运行卡按项目占用
|
|
3328
|
+
决定排队/直回(_iw_apply._recover_to_doing),收尾归 _finish_run),
|
|
3329
|
+
'to_doing'(运行中→正在开发), 'to_review'(空闲→待审核),
|
|
3330
|
+
'to_done'(主会话已被 dsh 归档→已完成,2026-10-05), None(不动)}。
|
|
3331
|
+
`archived`(None=未知/False=未归档/True=已归档)由调用方读 `dshevents.archived`
|
|
3332
|
+
(本地注册表,零请求)传入:**True 时优先于一切忙闲判定**直接落 to_done——
|
|
3333
|
+
dsh 归档即该会话的终态意图,此时再按 busy/idle 搬列会与归档同步打架;未知
|
|
3334
|
+
(None,中枢断连)一律不动作(不变量「断连=未知」)。
|
|
3335
|
+
映射规则(无平台在管运行时,设计 §2.4):提问→blocked(interaction);
|
|
3336
|
+
运行中→doing;空闲→review;阻塞解除按运行态回 doing/review。
|
|
3337
|
+
运行判定 running = busy or live_msg(2026-09-11):live_msg=本卡会话有
|
|
3338
|
+
排队/执行中的平台消息单元(chat.live_of_sid)——消息排队尚未执行时会话
|
|
3339
|
+
并不 busy,仅凭 busy 会漏判;阻塞列卡片在消息投递后即应回开发列(用户
|
|
3340
|
+
约定:所有正在运行的卡片都在「正在开发」列)。
|
|
3341
|
+
不干预规则:退场族不动(调用方已过滤,此处兜底保纯函数安全);
|
|
3342
|
+
todo/done 终态不动;平台在管运行(has_run=True)的卡只做交互 block/recover,
|
|
3343
|
+
busy/空闲流转归 _finish_run 拥有(防与轮收尾抢写)。
|
|
3344
|
+
queue 排队占位卡只放行 block(2026-09-10 修复):占位不等于会话已停——
|
|
3345
|
+
运行中卡交互阻塞解除后落 doing/queue 排队等串行位(_recover_to_doing),
|
|
3346
|
+
dsh 会话照跑、稍后仍可能再提问;此前 queue 卡被一律跳过,这类提问
|
|
3347
|
+
永不被发现,卡片停在「正在开发/排队中」而进不了阻塞列。busy/空闲一律
|
|
3348
|
+
不搬列(等串行位的卡不能被 busy 映射回 doing、更不能被空闲映射去待审核,
|
|
3349
|
+
列流转与行占位归统一队列拾取与 _finish_run 拥有)。
|
|
3350
|
+
manual 手动阻塞卡(2026-09-11 修复):默认不干预(用户停车位),但会话
|
|
3351
|
+
确实在运行时必须回「正在开发」——用户在会话详情页发消息(消息单元驱动
|
|
3352
|
+
会话)、外部直跑等都会让手动阻塞卡的会话转起来,此前这类卡片滞留阻塞列
|
|
3353
|
+
(真实故障:卡 210)。仅等待用户回答(pending)时维持阻塞态,不升级
|
|
3354
|
+
manual 标记;stop_recent=True(卡片刚被平台停过会话,拖入阻塞等)时
|
|
3355
|
+
busy 可能是 abort 生效前的残留,不据此回搬(live_msg 不受此限——消息
|
|
3356
|
+
驱动是强信号,用户主动发的消息照常回开发列)。
|
|
3357
|
+
blocked 且无阻塞标记(2026-09-17 归位):历史残留态(作答落列写入失败 /
|
|
3358
|
+
送达只清标记,卡 388 实障滞留阻塞列两天),运行中(或平台在管)→ to_doing、
|
|
3359
|
+
空闲 → to_review;有等待时上面 pending 分支先手,升格 interaction 阻塞。"""
|
|
3360
|
+
# 路线 A 打通(2026-10-03 P1):dsh_plugin 是唯一在族(会话常驻 dsh 宿主),
|
|
3361
|
+
# 交互/忙闲实况由 `_iw_interaction` 的 dsh 分支(插件内存态)给出。此前这里
|
|
3362
|
+
# 把 dsh 挡在门外,导致 dsh 卡片**永远不进 blocked、也不会自动回列**
|
|
3363
|
+
# (对齐缺口,见方案 §2.1 #15)。
|
|
3364
|
+
if family != "dsh_plugin":
|
|
3365
|
+
return None
|
|
3366
|
+
if archived is True:
|
|
3367
|
+
# 归档电平优先(2026-10-05):主会话已归档 ⇒ 卡片归「已完成」,含 todo 卡;
|
|
3368
|
+
# 已在 done 即无动作(幂等收敛点,不参与下面的忙闲映射)。
|
|
3369
|
+
return None if card["column_key"] == "done" else "to_done"
|
|
3370
|
+
if card["column_key"] in ("todo", "done"):
|
|
3371
|
+
return None
|
|
3372
|
+
pending = bool(r and r.get("pending"))
|
|
3373
|
+
busy = bool(r and r.get("busy"))
|
|
3374
|
+
running = busy or bool(live_msg)
|
|
3375
|
+
if card["block_kind"] == "manual":
|
|
3376
|
+
if pending:
|
|
3377
|
+
return None # 等待用户回答:维持阻塞态
|
|
3378
|
+
if stop_recent:
|
|
3379
|
+
return "to_doing" if live_msg else None
|
|
3380
|
+
return "to_doing" if running else None
|
|
3381
|
+
if card["block_kind"] == "queue":
|
|
3382
|
+
return "block" if pending else None
|
|
3383
|
+
if pending:
|
|
3384
|
+
if card["column_key"] == "blocked" and card["block_kind"] == "interaction":
|
|
3385
|
+
return None # 已在交互阻塞,等待作答
|
|
3386
|
+
return "block" # doing / review → 阻塞
|
|
3387
|
+
# 无等待
|
|
3388
|
+
if card["column_key"] == "blocked" and card["block_kind"] == "interaction":
|
|
3389
|
+
if has_run:
|
|
3390
|
+
return "recover"
|
|
3391
|
+
return "to_doing" if running else "to_review"
|
|
3392
|
+
# blocked 且无阻塞标记(2026-09-17 归位,见 docstring):历史残留态——作答
|
|
3393
|
+
# 落列写入失败 / 送达只清标记(卡 388 实障滞留阻塞列两天)。按会话实况归位:
|
|
3394
|
+
# 运行中(或平台在管)→ 回「正在开发」;空闲 → 回「待审核」。
|
|
3395
|
+
if card["column_key"] == "blocked" and not card["block_kind"]:
|
|
3396
|
+
return "to_doing" if (running or has_run) else "to_review"
|
|
3397
|
+
if has_run:
|
|
3398
|
+
return None # 在管运行:列流转归 _finish_run
|
|
3399
|
+
if card["column_key"] == "doing":
|
|
3400
|
+
return None if running else "to_review"
|
|
3401
|
+
if card["column_key"] == "review":
|
|
3402
|
+
return "to_doing" if running else None
|
|
3403
|
+
return None
|
|
3404
|
+
|
|
3405
|
+
|
|
3406
|
+
def _iw_apply(family, card, action, r):
|
|
3407
|
+
"""执行调和动作(read-verify-write)。写库前用 get_board_card 重读当前行,
|
|
3408
|
+
并重评 has_run/live_msg/stop_recent、重跑状态机:仅当动作仍成立才写库;
|
|
3409
|
+
否则跳过——保护用户手动拖卡/其他写者不被调和窗口内的旧判定覆盖
|
|
3410
|
+
(事件快照到落库之间卡状态可能已变/已删;live_msg 实时重取
|
|
3411
|
+
——消息单元刚结束/被取消时状态机结论可能翻转为不动;stop_recent 实时
|
|
3412
|
+
重取——窗口内用户刚把卡拖入阻塞时,旧判定不得把它回搬开发列)。
|
|
3413
|
+
已答待送达(wait_items 有活跃 answer 等待项)的卡一律跳过:其列归属由作答/送达路径独占
|
|
3414
|
+
(作答落 doing/queue、送达执行体归位),此处堵写回竞态——tick 先
|
|
3415
|
+
按旧快照(卡在 doing、会话提问中)算出 block,用户随后作答把卡落
|
|
3416
|
+
doing/queue,写前复核在 doing/queue + dsh 侧仍 pending 下重评同为 block,
|
|
3417
|
+
会把卡写回 blocked/interaction,待送达窗口内卡片漂出「正在开发」列
|
|
3418
|
+
(2026-09-17 复现修复;实障卡 389)。"""
|
|
3419
|
+
cur = db.get_board_card(card["id"])
|
|
3420
|
+
if cur is None:
|
|
3421
|
+
return # 卡片已删:跳过
|
|
3422
|
+
if is_answer_pending(card["id"]):
|
|
3423
|
+
return # 已答待送达:调和动作一律跳过(见 docstring)
|
|
3424
|
+
has_run = _has_active_run(card["id"])
|
|
3425
|
+
live_msg = chat.live_of_sid((cur["session_id"] or "").strip())
|
|
3426
|
+
if _reconcile_action_for(family, cur, r, has_run, live_msg,
|
|
3427
|
+
_stop_recent(cur["id"]),
|
|
3428
|
+
dshevents.archived(cur["session_id"] or "")) != action:
|
|
3429
|
+
return # 状态已变:跳过,不写库
|
|
3430
|
+
if action == "to_done":
|
|
3431
|
+
# dsh 侧归档 → 卡片进「已完成」(2026-10-05 反向规则):走容器迁移原语收口
|
|
3432
|
+
# ——会话已由宿主归档并停工作(归档自带 stopActivity 语义),stop=False 不再
|
|
3433
|
+
# 发 cancel;行终态化 + 搬列由 finish 的 to_column 一次表达(列差守卫 +
|
|
3434
|
+
# done_at 派生),比直写列安全(不会留下活跃 c: 行占着项目运行位)。
|
|
3435
|
+
# 随后级联归档其余绑定会话:dsh 侧已改、回滚不了,故 best-effort + 退避
|
|
3436
|
+
# 重试(strict=False)。
|
|
3437
|
+
_dequeue_card(card["id"], "dsh 归档同步", to_column="done")
|
|
3438
|
+
_archive_card_sessions(db.get_board_card(card["id"]) or cur, True,
|
|
3439
|
+
"归档级联", strict=False)
|
|
3440
|
+
return
|
|
3441
|
+
if action == "block":
|
|
3442
|
+
if cur["block_kind"] == "queue":
|
|
3443
|
+
# 落阻塞 eager 丢排队(v2b T3,裁决 R9;v2 §2.1 阻塞独立容器不占
|
|
3444
|
+
# 队列位):c: 等待行即取消(行+占位原子),取代补位器「占位非
|
|
3445
|
+
# doing+queue」惰性 cancel——行不再留队等到拾取
|
|
3446
|
+
waitq.cancel_card_wait(card["id"], "落阻塞丢排队")
|
|
3447
|
+
db.update_board_card(card["id"], column_key="blocked",
|
|
3448
|
+
block_kind="interaction",
|
|
3449
|
+
block_text=(r.get("text") or "")[:80])
|
|
3450
|
+
try:
|
|
3451
|
+
feishu.card_blocked(cur["project_id"], cur, r) # 旁路推送:任何异常不影响调和
|
|
3452
|
+
except Exception as e:
|
|
3453
|
+
# 吞掉但留痕:推送链路静默失联只能靠这行发现(2026-09-28 实障:
|
|
3454
|
+
# sqlite3.Row.get 崩在这里被吞,四次提问零推送、零日志无从排查)
|
|
3455
|
+
print(f"[board] 交互阻塞飞书推送失败: {e!r}", flush=True)
|
|
3456
|
+
# 卡离开「正在开发」容器 ⇒ 出队(v3b:**无条件**行收口——落阻塞容器就
|
|
3457
|
+
# 一定出队,不再看 origin / _has_active_run:容器迁移本身即出队,运行行
|
|
3458
|
+
# 在此终态化,统一队列继续执行下一个)。不停止会话
|
|
3459
|
+
# (stop=False:abort 会清掉等待用户答复的 pending 提问,作答后会话照常
|
|
3460
|
+
# 续跑);不提交任何改动(2026-09-13 退场:平台不再自动提交,改动提交由
|
|
3461
|
+
# 外部 hook 扩展负责)。
|
|
3462
|
+
try:
|
|
3463
|
+
_leave_doing(cur, stop=False, reason="出队-交互阻塞")
|
|
3464
|
+
except Exception:
|
|
3465
|
+
pass # 出队失败不阻断调和
|
|
3466
|
+
return
|
|
3467
|
+
if action == "recover":
|
|
3468
|
+
# 平台在管运行期交互阻塞解除:直回 doing + 行补回(见 _recover_to_doing)
|
|
3469
|
+
_recover_to_doing(cur)
|
|
3470
|
+
return
|
|
3471
|
+
# to_doing(无在管运行卡回开发列)**不再落排队占位**(2026-09-13 修订,
|
|
3472
|
+
# 原 2026-09-09 语义「平台卡项目忙 → 落 doing/queue 排队占位入统一队列」废止):
|
|
3473
|
+
# 状态机上 to_doing 蕴含 running=True(会话实况 busy 或本卡消息单元在跑/排队
|
|
3474
|
+
# ——live_msg 已于 2026-09-11 例外直写),即卡正在跑、占位者即其自身;落排队
|
|
3475
|
+
# 占位会在 turn 结束后被统一队列拾起、重复发「继续」起一轮(卡片单元拾取判定
|
|
3476
|
+
# 撞不上 _RUNS),且「排队中」徽标与「会话运行中」矛盾同显(真实故障:卡 300)。
|
|
3477
|
+
# sync 卡(外部会话强制并行)本就走直写,此处统一。收尾(会话结束→待审核)
|
|
3478
|
+
# 归调和器下一轮判定。
|
|
3479
|
+
if action == "to_review":
|
|
3480
|
+
# 卡归位「待审核」容器 ⇒ 出队行收口(v3b:「to_review 兜底释放」作为独立
|
|
3481
|
+
# 释放概念退场——I1:不在正在开发容器即无活跃 c: 行)。送达恢复的会话不在
|
|
3482
|
+
# _RUNS(_finish_run 不会收尾),行若仍活跃必须在此收口,否则项目
|
|
3483
|
+
# 运行位泄漏——统一队列拒绝拾起该项目任何新单元、后续待送达答案永久等待
|
|
3484
|
+
# (2026-09-16 实障:卡 388 送达后占住项目 9 一天,卡 389 答案未送达)。
|
|
3485
|
+
# to_review 蕴含会话已空闲,无需停会话;经唯一收尾点 finish 幂等收口
|
|
3486
|
+
# (无活跃行时各子步 no-op),无需先查持有(v2d T1 裁决 R14:
|
|
3487
|
+
# 占位/事实分离——行即表征)。
|
|
3488
|
+
_leave_doing(cur, stop=False, reason="出队-归位待审核")
|
|
3489
|
+
db.update_board_card(card["id"], **_RECONCILE_FIELDS[action])
|
|
3490
|
+
|
|
3491
|
+
|
|
3492
|
+
def _recover_to_doing(card):
|
|
3493
|
+
"""平台在管运行卡的交互阻塞解除(2026-09-13 修订):一律直回 doing——
|
|
3494
|
+
调用方 recover 蕴含平台在管运行(_RUNS 条目存在),本卡会话仍在跑,
|
|
3495
|
+
「等串行位再起会话」的排队前置不存在,「新进入者不越过运行位」直接由
|
|
3496
|
+
行占位保证:
|
|
3497
|
+
|
|
3498
|
+
- 本卡已有活跃行(运行位在场)→ 直回 doing,行不动(补回幂等,等价);
|
|
3499
|
+
否则补回运行行(防恢复运行的会话与后续入队单元并发;收尾仍归 _finish_run)。
|
|
3500
|
+
runner 缺位(退化为直起路径)直写 doing。
|
|
3501
|
+
|
|
3502
|
+
2026-09-09 原语义「项目被其他单元占用(或运行中同步卡占用)→ 落
|
|
3503
|
+
doing/queue 排队占位入统一队列等位」于 2026-09-13 废止。真实故障(卡 300,
|
|
3504
|
+
session_53736c1e):作答恢复后落 doing/queue 等位(项目被卡 303 占用),
|
|
3505
|
+
会话 turn 未停照跑,前端「排队中」与「会话运行中」徽标矛盾同显。排队占位
|
|
3506
|
+
的语义是「等串行位再起会话」,而本卡会话已在跑、它本身就是占位者;且原
|
|
3507
|
+
路径轮到拾起时也只走 dequeue_start 撞自身 _RUNS 的竞争分支(仅补回行、
|
|
3508
|
+
不重复发 prompt),本修订把这一步前移为直接补回——语义等价且不产生假排队态
|
|
3509
|
+
(也消除「会话结束但收尾未跑,队列拾起重复起一轮」的窄窗口)。
|
|
3510
|
+
"""
|
|
3511
|
+
pid = card["project_id"]
|
|
3512
|
+
inst = runner.INSTANCE
|
|
3513
|
+
db.update_board_card(card["id"], **_RECONCILE_FIELDS["recover"])
|
|
3514
|
+
if inst is not None and not _card_unit_active(card["id"]):
|
|
3515
|
+
# 行口径判据(`_card_unit_active`,行即持有者,不另造第二判据):落阻塞
|
|
3516
|
+
# 等出队路径 c: 行已出队终态化,本路径按「复用终态行/建行」补回 running 行
|
|
3517
|
+
# ——卡回开发容器即有活跃行(I1 不变量),会话在跑期间占着项目运行位
|
|
3518
|
+
ext = {"reason": "阻塞解除恢复"}
|
|
3519
|
+
with _runs_lock:
|
|
3520
|
+
rec = _RUNS.get(card["id"])
|
|
3521
|
+
if rec is not None and rec.get("proc") is not None:
|
|
3522
|
+
ext["pid"] = rec["proc"].pid # CLI 判据(R11④ 进程存活裁活)
|
|
3523
|
+
inst.card_started(card["id"], pid, ext=ext)
|
|
3524
|
+
|
|
3525
|
+
|
|
3526
|
+
def _iw_once():
|
|
3527
|
+
"""一轮调和扫描(统一调和器,事件驱动 + 60s 兜底):未归档项目的 dsh 族
|
|
3528
|
+
卡片 → 守卫跳过 → 读会话实况(等待态+busy)+ 本卡消息单元实况(live_msg)→
|
|
3529
|
+
调和状态机 → 写缓存/落库;
|
|
3530
|
+
同轮维护外部条目 ext 行(v2d T4,裁决 R13/R15):实况 busy 且无平台持有者
|
|
3531
|
+
的会话卡建/保 ext 行(入场即入运行前缀)、实况空闲/挂起/卡离开发列的行
|
|
3532
|
+
收口(finish → 补位时机③);行集合变化 notify runner 唤醒排队单元重新挑选。
|
|
3533
|
+
实况读口=事件中枢注册表(`_iw_interaction`,零请求)——单族化后无逐卡 REST
|
|
3534
|
+
探测(原 kimi 逐 sid status 成本与 review 静止卡长节流均随两族退场);
|
|
3535
|
+
退场族无 busy 信号不建行(在场残留行防御收口)。
|
|
3536
|
+
守卫(设计 §2.4,2026-09-11 修订):todo、done 终态 / 无会话 sid 的卡不碰;
|
|
3537
|
+
**归档同步例外(2026-10-05)**:归档判定先于该守卫——主会话已归档的卡
|
|
3538
|
+
(不管在哪列,含 todo)落 to_done;外部取消归档的边沿把 done 卡送回待审核。
|
|
3539
|
+
manual 手动阻塞卡照常探测(状态机只在会话运行/消息驱动时回开发列,空闲
|
|
3540
|
+
维持不干预;刚被平台停过会话的宽限期内 busy 不作数,由状态机 stop_recent
|
|
3541
|
+
参数处理——abort 生效前 busy 可能短暂残留,防拖入阻塞瞬间被回弹);
|
|
3542
|
+
queue 排队占位卡照常探测(有 sid 才探——2026-09-10 修复:交互阻塞解除回
|
|
3543
|
+
排队等串行位期间会话仍在跑、仍可能提问;状态机对 queue 卡只放行 block,
|
|
3544
|
+
故 busy/空闲不会误搬列);blocked+无阻塞标记的残留态卡照常探测(2026-09-17:
|
|
3545
|
+
作答落列写入失败 / 送达只清标记的历史孤儿,状态机按会话实况归位,
|
|
3546
|
+
此前整类跳过致卡 388 滞留阻塞列两天);平台在管运行的卡只做交互检测
|
|
3547
|
+
(列流转归 _finish_run)。
|
|
3548
|
+
异常语义:读不到实况(中枢未连接/未知)该卡本轮跳过(不搬列,防把运行中卡
|
|
3549
|
+
误判完成搬去待审核;取代 sync_sessions 旧「异常视为空闲」;ext 行侧保留既有
|
|
3550
|
+
行=保留上一轮占用,宁可多等不可误放行——逐卡 `r is None` 走 `ext_hold`)。
|
|
3551
|
+
单卡异常不外抛(不影响其他卡/项目)。节拍尾部另跑 starting 超时对账
|
|
3552
|
+
(_reconcile_starting_rows,v2d T3,裁决 R6)。"""
|
|
3553
|
+
now = int(time.time() * 1000)
|
|
3554
|
+
ext_changed = False
|
|
3555
|
+
seen_sids = set() # 本轮在场的归档判定 sid(_ARCH_SEEN 有界清理用)
|
|
3556
|
+
for proj in db.list_projects_all():
|
|
3557
|
+
if proj["archived"]:
|
|
3558
|
+
continue
|
|
3559
|
+
fam = _web_family(proj)
|
|
3560
|
+
if fam is None:
|
|
3561
|
+
# 退场族无 busy 信号:不构成占用(在场残留行防御收口,防跨族历史堵死项目)
|
|
3562
|
+
if _finish_all_ext(proj["id"], "退场族无外部占用信号"):
|
|
3563
|
+
ext_changed = True
|
|
3564
|
+
continue
|
|
3565
|
+
ext_busy = {} # 本轮占位目标:{card_id: sid}(实况 busy)
|
|
3566
|
+
ext_hold = set() # 探测不明的占用候选卡(保留既有行)
|
|
3567
|
+
for card in db.list_board_cards(proj["id"]):
|
|
3568
|
+
try:
|
|
3569
|
+
# —— 归档同步(2026-10-05):读本地归档集快照(零请求),两条规则 ——
|
|
3570
|
+
# ①电平:主会话已归档 ⇒ 卡片归「已完成」(含 todo 卡;已 done 无动作);
|
|
3571
|
+
# ②边沿:外部「取消归档」(True→False)且卡片在 done ⇒ 回「待审核」。
|
|
3572
|
+
# 归档态未知(None,中枢断连/未对齐)一律不动作(不变量「断连=未知」)。
|
|
3573
|
+
sid = card["session_id"] or ""
|
|
3574
|
+
arch = dshevents.archived(sid) if sid else None
|
|
3575
|
+
if sid and arch is not None:
|
|
3576
|
+
seen_sids.add(sid)
|
|
3577
|
+
prev = _ARCH_SEEN.get(sid)
|
|
3578
|
+
_ARCH_SEEN[sid] = arch
|
|
3579
|
+
if prev is True and arch is False and card["column_key"] == "done":
|
|
3580
|
+
_archive_unarchive_edge(card)
|
|
3581
|
+
continue
|
|
3582
|
+
if arch is True and card["column_key"] != "done":
|
|
3583
|
+
_iw_apply(fam, card, "to_done", None)
|
|
3584
|
+
continue
|
|
3585
|
+
if card["column_key"] in ("todo", "done"):
|
|
3586
|
+
continue
|
|
3587
|
+
if (card["column_key"] == "blocked"
|
|
3588
|
+
and card["block_kind"] not in ("interaction", "queue",
|
|
3589
|
+
"manual", None)):
|
|
3590
|
+
continue # 其他 blocked 类型(防御);无标记残留态
|
|
3591
|
+
# (blocked+None)例外放行——状态机按会话实况归位(2026-09-17)
|
|
3592
|
+
if not sid:
|
|
3593
|
+
continue # 无会话(从未起跑的真排队卡等)无可探测
|
|
3594
|
+
r = _iw_interaction(fam, proj, sid)
|
|
3595
|
+
if r is None:
|
|
3596
|
+
if not _platform_holds(card["id"]):
|
|
3597
|
+
ext_hold.add(card["id"]) # 占用未知:保留既有行(old 探针口径)
|
|
3598
|
+
continue # 实况读不到:该卡本轮跳过,不搬列
|
|
3599
|
+
# —— 外部条目占用候选(v2d T4):busy 且非挂起、且平台不持有 ——
|
|
3600
|
+
# 挂起(pending)不算占用(豁免面②:挂起即出队,不建行);平台
|
|
3601
|
+
# 持有者(活跃 c: 行/在管条目)自占前缀位,不建行(防 ext:/c: 双计)。
|
|
3602
|
+
if r.get("busy") and not r.get("pending") \
|
|
3603
|
+
and not _platform_holds(card["id"]):
|
|
3604
|
+
ext_busy[card["id"]] = sid
|
|
3605
|
+
if _has_active_run(card["id"]):
|
|
3606
|
+
# 平台在管运行卡:行心跳 + 证据(v3c:证据迁行,直调;
|
|
3607
|
+
# touch_unit 幂等且异常由本层 per-card try 兜底,无活跃行 no-op)。
|
|
3608
|
+
# 证据串统一 `busy=1/0(poll)`(v2d T4)
|
|
3609
|
+
waitq.touch_unit(waitq.KIND_CARD, card["id"],
|
|
3610
|
+
f"busy={1 if r.get('busy') else 0}(poll)")
|
|
3611
|
+
with _IW_LOCK:
|
|
3612
|
+
c = _IW_CACHE.setdefault(sid, {})
|
|
3613
|
+
c.update(r) # pending/busy/qid/question/options/answerable/text
|
|
3614
|
+
c["ts"] = now
|
|
3615
|
+
live_msg = chat.live_of_sid(sid) # 本卡消息在跑/排队(消息驱动的会话)
|
|
3616
|
+
action = _reconcile_action_for(fam, card, r,
|
|
3617
|
+
_has_active_run(card["id"]), live_msg,
|
|
3618
|
+
_stop_recent(card["id"]), arch)
|
|
3619
|
+
if action:
|
|
3620
|
+
_iw_apply(fam, card, action, r)
|
|
3621
|
+
except Exception:
|
|
3622
|
+
continue
|
|
3623
|
+
# —— ext 行对账(每项目一轮;行集合变化唤醒补位,时机①/③)——
|
|
3624
|
+
try:
|
|
3625
|
+
if _ext_stale_finish(proj["id"], ext_busy, ext_hold):
|
|
3626
|
+
ext_changed = True
|
|
3627
|
+
for cid, sid in ext_busy.items():
|
|
3628
|
+
if upsert_ext(proj, cid, sid):
|
|
3629
|
+
ext_changed = True
|
|
3630
|
+
except Exception:
|
|
3631
|
+
continue
|
|
3632
|
+
if ext_changed and runner.INSTANCE is not None:
|
|
3633
|
+
runner.INSTANCE.notify_busy_change() # 前缀成员变化:唤醒排队单元重新挑选
|
|
3634
|
+
# 归档同步尾巴(2026-10-05):调和器级联失败留下的待重试项按退避重发;
|
|
3635
|
+
# 边沿表按在场集合做有界清理(只在超限时清,别丢边沿状态)。
|
|
3636
|
+
try:
|
|
3637
|
+
_archive_retry_tick()
|
|
3638
|
+
except Exception:
|
|
3639
|
+
pass # 重试异常不影响本轮其他对账
|
|
3640
|
+
if len(_ARCH_SEEN) > _ARCH_SEEN_MAX:
|
|
3641
|
+
for sid in [s for s in _ARCH_SEEN if s not in seen_sids]:
|
|
3642
|
+
_ARCH_SEEN.pop(sid, None)
|
|
3643
|
+
# starting 超时对账(v2d T3,裁决 R6):claim→会话证实窗口兜底——超龄
|
|
3644
|
+
# starting 行三态复核(alive 自愈 / 可证未起收尾 / 不可证只告警)。
|
|
3645
|
+
try:
|
|
3646
|
+
_reconcile_starting_rows()
|
|
3647
|
+
except Exception:
|
|
3648
|
+
pass # 守护线程不 crash(同 _iw_once 口径)
|
|
3649
|
+
|
|
3650
|
+
|
|
3651
|
+
def interaction_of_sid(sid):
|
|
3652
|
+
"""watcher 缓存读取(会话端点 meta 用):{pending, kind, qid, question,
|
|
3653
|
+
options, answerable, text, ts} 或 None(未检测/无等待)。"""
|
|
3654
|
+
if not sid:
|
|
3655
|
+
return None
|
|
3656
|
+
with _IW_LOCK:
|
|
3657
|
+
c = _IW_CACHE.get(sid)
|
|
3658
|
+
return dict(c) if c else None
|
|
3659
|
+
|
|
3660
|
+
|
|
3661
|
+
def is_answer_pending(card_id):
|
|
3662
|
+
"""该卡是否有已作答·待送达的答案(作答响应 queued 字段用;2026-09-13)。
|
|
3663
|
+
|
|
3664
|
+
P2 起权威在 wait_items 表(kind=answer 活跃行 waiting/starting/running/
|
|
3665
|
+
finishing,v2a T1 七枚举口径);
|
|
3666
|
+
原作答内存登记已退场。看板轮询频率下的单行索引读可接受;
|
|
3667
|
+
批量派生(P6 起)走 queue_states → waitq.answer_pending_card_ids 一次查询。"""
|
|
3668
|
+
return waitq.get_active(waitq.KIND_ANSWER, card_id) is not None
|
|
3669
|
+
|
|
3670
|
+
|
|
3671
|
+
# ---------- 展示派生(P6:各语义服务端唯一判定,前端只渲染) ----------
|
|
3672
|
+
|
|
3673
|
+
# queue_state 枚举值(裁决 R2 五态+空闲;v2a T4 加 starting 七枚举,裁决 R6;
|
|
3674
|
+
# 2026-09-25 加 interaction_pending 八枚举——提问挂起单列,#560)
|
|
3675
|
+
QS_IDLE = "idle" # 无等待无运行
|
|
3676
|
+
QS_QUEUED = "queued_serial" # 等串行位(本项目排队位次)
|
|
3677
|
+
QS_ANSWER = "answer_pending" # 已收下待送达(answer 活跃行在场)
|
|
3678
|
+
QS_INTERACTION = "interaction_pending" # 提问挂起等作答(block_kind=interaction;
|
|
3679
|
+
# turn 虽在跑但等的是用户输入,不亮「会话运行中」)
|
|
3680
|
+
QS_SERVER = "server_queued" # 服务端排队(dsh 宿主 inbox 队列,仅会话级)
|
|
3681
|
+
QS_FOREIGN = "foreign_busy" # 等外部会话(项目被外部同步会话占用)
|
|
3682
|
+
QS_STARTING = "starting" # 启动中:板卡已交 runner、会话未证实运行
|
|
3683
|
+
# (c: 行 starting;90s 宽限窗口内,v2d T3 超时处置)
|
|
3684
|
+
QS_RUNNING = "running" # 运行中(平台在管 running 或会话实况 busy)
|
|
3685
|
+
|
|
3686
|
+
|
|
3687
|
+
def card_starting(card_id):
|
|
3688
|
+
"""本卡 c: 等待项是否 starting 态(已交 runner 拾起、会话未证实运行;
|
|
3689
|
+
queue_state `starting` 枚举判据,v2a T4——单行索引点查,批量路径走
|
|
3690
|
+
waitq.starting_card_ids 一次查询)。"""
|
|
3691
|
+
row = waitq.get_active(waitq.KIND_CARD, card_id)
|
|
3692
|
+
return row is not None and row["state"] == waitq.STARTING
|
|
3693
|
+
|
|
3694
|
+
|
|
3695
|
+
def queue_state_of(row, running=False, busy=False, msg_queued=False,
|
|
3696
|
+
foreign=False, answer=None, starting=None):
|
|
3697
|
+
"""单卡 queue_state 纯判定(裁决 R10 优先级,互斥不变量在派生层保证)。
|
|
3698
|
+
|
|
3699
|
+
判定序(高→低):answer_pending > interaction_pending > running > starting
|
|
3700
|
+
> queued_serial/foreign_busy > idle(v2a T4 加 starting,裁决 R6:starting 行
|
|
3701
|
+
在场=已在启动,压排队态,与 running 的边界=是否证实运行;2026-09-25 加
|
|
3702
|
+
interaction_pending——提问挂起(block_kind=interaction)压 running:dsh
|
|
3703
|
+
turn 在等作答期间实况 busy=true,旧判定亮「会话运行中」与阻塞容器矛盾(#554))。
|
|
3704
|
+
「已收下待送达」与运行并存是唯一例外——本函数按优先级只回一枚枚举,
|
|
3705
|
+
卡片操作行(⚡ 送达)仍读 answer_pending 字段本体(契约不变)。
|
|
3706
|
+
foreign=True 仅当卡片处于排队占位且项目被外部会话占用时由调用方传入。
|
|
3707
|
+
answer/starting:批量预计算路径(queue_states)传入本卡 answer 活跃行 /
|
|
3708
|
+
c: 行 starting 在场与否,缺省分别按 is_answer_pending / card_starting
|
|
3709
|
+
点查(hidden sites 现算路径)。
|
|
3710
|
+
"""
|
|
3711
|
+
if answer is None:
|
|
3712
|
+
answer = is_answer_pending(row["id"])
|
|
3713
|
+
if answer:
|
|
3714
|
+
return QS_ANSWER
|
|
3715
|
+
if row["block_kind"] == "interaction": # 提问挂起:与 🤔 徽标同条件(列无关)
|
|
3716
|
+
return QS_INTERACTION
|
|
3717
|
+
if running or busy:
|
|
3718
|
+
return QS_RUNNING
|
|
3719
|
+
if starting is None:
|
|
3720
|
+
starting = card_starting(row["id"])
|
|
3721
|
+
if starting:
|
|
3722
|
+
return QS_STARTING
|
|
3723
|
+
if msg_queued or row["block_kind"] == "queue":
|
|
3724
|
+
return QS_FOREIGN if foreign else QS_QUEUED
|
|
3725
|
+
return QS_IDLE
|
|
3726
|
+
|
|
3727
|
+
|
|
3728
|
+
def queue_states(proj, rows, running_map=None, busy_map=None, queued_cards=None):
|
|
3729
|
+
"""批量派生 {card_id: queue_state}(board_payload 用,一次预计算避免 N+1:
|
|
3730
|
+
answer 活跃行、c: starting 行与 msg 排队集合各一次查询,外部占位一次行读)。
|
|
3731
|
+
|
|
3732
|
+
proj 非 None 时做项目级外部占位判定(项目有活跃 ext 行=外部会话在跑,
|
|
3733
|
+
`ext_active` 行读口);proj 为 None
|
|
3734
|
+
时按无项目上下文降级(foreign 恒 False,foreign_busy 不可能出现)。
|
|
3735
|
+
"""
|
|
3736
|
+
running_map = running_map or {}
|
|
3737
|
+
busy_map = busy_map or {}
|
|
3738
|
+
queued_cards = queued_cards or set()
|
|
3739
|
+
foreign = proj is not None and ext_active(proj["id"])
|
|
3740
|
+
answers = waitq.answer_pending_card_ids()
|
|
3741
|
+
startings = waitq.starting_card_ids()
|
|
3742
|
+
out = {}
|
|
3743
|
+
for r in rows:
|
|
3744
|
+
running = bool(running_map.get(r["id"]))
|
|
3745
|
+
out[r["id"]] = queue_state_of(
|
|
3746
|
+
r, running,
|
|
3747
|
+
running or bool(busy_map.get(r["session_id"])),
|
|
3748
|
+
r["id"] in queued_cards, foreign, answer=r["id"] in answers,
|
|
3749
|
+
starting=r["id"] in startings)
|
|
3750
|
+
return out
|
|
3751
|
+
|
|
3752
|
+
|
|
3753
|
+
_ANSWER_MAX_RETRIES = 3 # 送达失败重试上限(沿用原调和器口径)
|
|
3754
|
+
_ANSWER_BACKOFF_S = 30.0 # 放回后的退避窗口(not_before;原 5s 轮询改退避,§4.1)
|
|
3755
|
+
_QUESTION_GONE_CODE = 40405 # 提问不存在/已应答(沿用原 kimi envelope code;
|
|
3756
|
+
# dsh 作答链路用同值抛出,msg 兜底防口径漂移)
|
|
3757
|
+
|
|
3758
|
+
|
|
3759
|
+
def _answer_question_gone(err):
|
|
3760
|
+
"""「问题已不存在」判定(重试无意义):envelope code=40405 为主判,msg 含
|
|
3761
|
+
not found 为兜底(防驱动侧 code 口径漂移)。判据读 err.args[0]——
|
|
3762
|
+
DshDriverError 沿用 `(code, message)` 二元组约定。"""
|
|
3763
|
+
code = err.args[0] if err.args else None
|
|
3764
|
+
msg = str(err.args[1]) if len(err.args) > 1 else ""
|
|
3765
|
+
return code == _QUESTION_GONE_CODE or "not found" in msg.lower()
|
|
3766
|
+
|
|
3767
|
+
|
|
3768
|
+
def _answer_retry_or_fail(card_id, row, err):
|
|
3769
|
+
"""送达失败的统一处置:retries+1;未满上限放回 waiting(not_before 退避)
|
|
3770
|
+
并重入队;满上限 mark_failed + 清占位(watcher 重探 pending 后交互卡重现、
|
|
3771
|
+
用户可重答——自愈出口与原调和器一致)。"""
|
|
3772
|
+
retries = (row["retries"] or 0) + 1
|
|
3773
|
+
if retries < _ANSWER_MAX_RETRIES:
|
|
3774
|
+
waitq.return_to_waiting(row["id"],
|
|
3775
|
+
not_before=time.time() + _ANSWER_BACKOFF_S,
|
|
3776
|
+
bump_retry=True)
|
|
3777
|
+
inst = runner.INSTANCE
|
|
3778
|
+
if inst is not None:
|
|
3779
|
+
inst.submit_answer(card_id)
|
|
3780
|
+
print(f"[board] 答案送达失败(第 {retries} 次,将重试)"
|
|
3781
|
+
f" card={card_id}: {err}", flush=True)
|
|
3782
|
+
return
|
|
3783
|
+
waitq.mark_failed(row["id"], err)
|
|
3784
|
+
db.update_board_card(card_id, block_kind=None, block_text="")
|
|
3785
|
+
print(f"[board] 答案送达失败(重试 3 次放弃)card={card_id}: {err}", flush=True)
|
|
3786
|
+
|
|
3787
|
+
|
|
3788
|
+
def _dsh_answers(out):
|
|
3789
|
+
"""平台作答载荷(answer_interaction 的 out)→ dsh 原生 answer 载荷。
|
|
3790
|
+
|
|
3791
|
+
平台形态:[{"wire", "kind", "option_id", "option_ids", "text"}]
|
|
3792
|
+
dsh 形态:[{"id", "selected": [选项标签], "custom": 自由文本}]
|
|
3793
|
+
平台侧选项 id 与 label 同值(dsh 的 AskUserQuestionOption 只有标签语义,
|
|
3794
|
+
见 agent-driver 的 interaction 帧),故 selected 直接取 id。
|
|
3795
|
+
"""
|
|
3796
|
+
items = []
|
|
3797
|
+
for a in out or []:
|
|
3798
|
+
kind = str(a.get("kind") or "single")
|
|
3799
|
+
ids = [str(x) for x in (a.get("option_ids") or [])]
|
|
3800
|
+
one = str(a.get("option_id") or "")
|
|
3801
|
+
selected = ids if kind in ("multi", "multi_with_other") else ([one] if one else [])
|
|
3802
|
+
item = {"id": str(a.get("wire") or ""), "selected": selected}
|
|
3803
|
+
if kind in ("other", "multi_with_other"):
|
|
3804
|
+
item["custom"] = str(a.get("text") or "")
|
|
3805
|
+
items.append(item)
|
|
3806
|
+
return items
|
|
3807
|
+
|
|
3808
|
+
|
|
3809
|
+
def _answer_deliver(proj, meta):
|
|
3810
|
+
"""按权威行 meta 送达(worker 执行体与「立即送达」共用出口;v2b T1:
|
|
3811
|
+
审批作答同路入队后补位送达走同一执行体——meta 带 approval_id 走审批
|
|
3812
|
+
应答,否则走提问作答)。异常原样上抛,退避/重试/40405 分流由调用方
|
|
3813
|
+
处置。
|
|
3814
|
+
|
|
3815
|
+
dsh_plugin(路线 A,单族):meta 的 qid 是 dsh 的 tool callId,作答走插件
|
|
3816
|
+
`/answer` → 宿主 `ctx.userQuestions.answer`(与 Web GUI 同一条通道);
|
|
3817
|
+
「问题已不存在」由 accepted=false 表达——抛 DshDriverError(40405),上层
|
|
3818
|
+
`_answer_question_gone` 按 code 判定放弃(不空转重试)。
|
|
3819
|
+
|
|
3820
|
+
dsh 审批(2026-10-03 P3 第二批):平台认领了会话审批时(`/permission` 接管,
|
|
3821
|
+
插件在 waterfall 里挂起),meta 带 `outcome`(`allowed-once`/`rejected`)走
|
|
3822
|
+
插件 `/approval`;未认领(GUI 作答)时插件返回 409 → 这里转 40005 让上层按
|
|
3823
|
+
「再无待决」收口。
|
|
3824
|
+
"""
|
|
3825
|
+
if _web_family(proj) != "dsh_plugin":
|
|
3826
|
+
# 退场族无作答送达通道(单族化):明确报错,不落已删除的 kimi 配送段
|
|
3827
|
+
raise RuntimeError(agents.RETIRED_MSG)
|
|
3828
|
+
if meta.get("approval_id"):
|
|
3829
|
+
outcome = str(meta.get("outcome") or "")
|
|
3830
|
+
if not outcome:
|
|
3831
|
+
raise dshdriver.DshDriverError(40005, "dsh 审批缺少 outcome(不应发生)")
|
|
3832
|
+
try:
|
|
3833
|
+
dshdriver.answer_approval(str(meta.get("sid") or ""),
|
|
3834
|
+
str(meta.get("approval_id") or ""), outcome)
|
|
3835
|
+
except dshdriver.DshDriverError as e:
|
|
3836
|
+
if e.code in (404, 409):
|
|
3837
|
+
raise dshdriver.DshDriverError(
|
|
3838
|
+
_QUESTION_GONE_CODE, "审批已不存在(可能已由 dsh GUI 作答)")
|
|
3839
|
+
raise
|
|
3840
|
+
return
|
|
3841
|
+
accepted = dshdriver.answer_question(
|
|
3842
|
+
str(meta.get("sid") or ""), str(meta.get("qid") or ""),
|
|
3843
|
+
_dsh_answers(meta.get("answers") or []))
|
|
3844
|
+
if not accepted:
|
|
3845
|
+
raise dshdriver.DshDriverError(_QUESTION_GONE_CODE, "提问不存在或已应答")
|
|
3846
|
+
|
|
3847
|
+
|
|
3848
|
+
def _deliver_answer_unit(card_id):
|
|
3849
|
+
"""答案单元执行体(runner worker 调用;P2 起答案投递的唯一路径)。
|
|
3850
|
+
|
|
3851
|
+
前置:worker 拾取已 claim(runner._claim_unit 的 answer 直调分支)。
|
|
3852
|
+
本函数=原 _deliver_pending_answers 成功路径的队列化(设计 §8):
|
|
3853
|
+
载荷读自行 meta(重启后按表重建的答案同样送达);项目空闲由 _pick_locked
|
|
3854
|
+
保证(busy + 自身占位例外,等价原调和器判据);成功 mark_done → 卡归位
|
|
3855
|
+
→ card_started(顺序统一 R4;会话空闲后的行收口归调和器 to_review 分支的
|
|
3856
|
+
出队收口「出队-归位待审核」——v3b 前该动作名「兜底释放」,语义同为出队;
|
|
3857
|
+
v3a:card_started 同时把 c: 行置 running——本路径原先无卡行(作答排队只写
|
|
3858
|
+
占位),enter_running 按「建行/复用终态行」补回运行成员);「问题已不存在」
|
|
3859
|
+
(40405 类)直接 failed(清占位走自愈出口);其余失败交
|
|
3860
|
+
_answer_retry_or_fail;行已不活跃/项目不存在:cancel 并返回。"""
|
|
3861
|
+
row = waitq.get_active(waitq.KIND_ANSWER, card_id)
|
|
3862
|
+
if row is None:
|
|
3863
|
+
return # 已取消/已终态:无事可做
|
|
3864
|
+
try:
|
|
3865
|
+
meta = json.loads(row["meta"] or "{}")
|
|
3866
|
+
if not isinstance(meta, dict):
|
|
3867
|
+
meta = {}
|
|
3868
|
+
except ValueError:
|
|
3869
|
+
meta = {}
|
|
3870
|
+
proj = db.get_project(row["project_id"])
|
|
3871
|
+
if proj is None:
|
|
3872
|
+
waitq.cancel(waitq.KIND_ANSWER, card_id, "项目不存在")
|
|
3873
|
+
return
|
|
3874
|
+
try:
|
|
3875
|
+
_answer_deliver(proj, meta)
|
|
3876
|
+
except dshdriver.DshDriverError as e:
|
|
3877
|
+
if _answer_question_gone(e):
|
|
3878
|
+
waitq.mark_failed(row["id"], str(e))
|
|
3879
|
+
db.update_board_card(card_id, block_kind=None, block_text="")
|
|
3880
|
+
print(f"[board] 答案送达放弃(问题已不存在)card={card_id}: {e}",
|
|
3881
|
+
flush=True)
|
|
3882
|
+
return
|
|
3883
|
+
_answer_retry_or_fail(card_id, row, str(e))
|
|
3884
|
+
return
|
|
3885
|
+
except Exception as e:
|
|
3886
|
+
_answer_retry_or_fail(card_id, row, str(e))
|
|
3887
|
+
return
|
|
3888
|
+
# —— 送达成功:终态 → 列归位 → 行补回运行位(顺序统一,R4)——
|
|
3889
|
+
waitq.mark_done(row["id"])
|
|
3890
|
+
# 归位开发列:恢复的会话在跑=运行中卡在开发列;同时兜底落列失败滞留
|
|
3891
|
+
# blocked 的卡(2026-09-16 实障卡 388 场景,原自愈分支退场后由此兜底,R3)
|
|
3892
|
+
# 用户作答引发的回列(阻塞→开发)不置「有更新」标记:用户刚答完,答案送达
|
|
3893
|
+
# 与否由前端「排队中」徽标即时反映,再点一个未读是噪音
|
|
3894
|
+
db.update_board_card(card_id, column_key="doing", mark_unread=False,
|
|
3895
|
+
block_kind=None, block_text="")
|
|
3896
|
+
inst = runner.INSTANCE
|
|
3897
|
+
if inst is not None:
|
|
3898
|
+
# 行补回(本路径原先无卡行——作答排队只写占位):enter_running 建行/
|
|
3899
|
+
# 复用终态行,恢复的会话自此占着项目运行位(行即条目)
|
|
3900
|
+
inst.card_started(card_id, row["project_id"],
|
|
3901
|
+
ext={"reason": "送达恢复"}) # 占住运行位
|
|
3902
|
+
print(f"[board] 答案送达成功:card={card_id}", flush=True)
|
|
3903
|
+
|
|
3904
|
+
|
|
3905
|
+
def deliver_pending_answer_now(card_id):
|
|
3906
|
+
"""「立即送达」:把已作答·待送达的答案立即交给等待中的会话(不等项目空闲)。
|
|
3907
|
+
|
|
3908
|
+
用户按钮入口。P2 起 = claim + 直投(与消息 inject_now 同一抢占总模式):
|
|
3909
|
+
先原子 claim 等待项(互斥点——与 worker 拾取竞争时只有一方成功),成功者
|
|
3910
|
+
直接送达(本卡已不在此等待项上排队);claim 失败 = 已被送达/取消/worker
|
|
3911
|
+
执行中,按原
|
|
3912
|
+
口径拒绝。成功:mark_done → 卡归位 → card_started(顺序统一 R4);失败:
|
|
3913
|
+
「问题已不存在」(40405 类,必带①)对齐执行体口径立即 mark_failed + 清占位
|
|
3914
|
+
(重试无意义);其余 return_to_waiting 放回(不动 retries——手动失败不消耗
|
|
3915
|
+
重试额度)+ 重入队,错误交调用方提示。返回 (ok, error)。"""
|
|
3916
|
+
row = waitq.get_active(waitq.KIND_ANSWER, card_id)
|
|
3917
|
+
if row is None or row["state"] != waitq.STATE_WAITING:
|
|
3918
|
+
return False, "没有待送达的答案(可能已送达)"
|
|
3919
|
+
if not waitq.claim(row["id"], "deliver-now"):
|
|
3920
|
+
return False, "没有待送达的答案(可能已送达)"
|
|
3921
|
+
inst = runner.INSTANCE
|
|
3922
|
+
if inst is not None:
|
|
3923
|
+
inst.remove_answer(card_id) # 出队:不再等统一队列
|
|
3924
|
+
try:
|
|
3925
|
+
meta = json.loads(row["meta"] or "{}")
|
|
3926
|
+
if not isinstance(meta, dict):
|
|
3927
|
+
meta = {}
|
|
3928
|
+
except ValueError:
|
|
3929
|
+
meta = {}
|
|
3930
|
+
proj = db.get_project(row["project_id"])
|
|
3931
|
+
if proj is None:
|
|
3932
|
+
waitq.cancel(waitq.KIND_ANSWER, card_id, "项目不存在")
|
|
3933
|
+
return False, "项目不存在"
|
|
3934
|
+
try:
|
|
3935
|
+
_answer_deliver(proj, meta)
|
|
3936
|
+
except Exception as e:
|
|
3937
|
+
if _answer_question_gone(e):
|
|
3938
|
+
# 「问题已不存在」对齐执行体口径(必带①):立即放弃不空转重试 3 次
|
|
3939
|
+
waitq.mark_failed(row["id"], str(e))
|
|
3940
|
+
db.update_board_card(card_id, block_kind=None, block_text="")
|
|
3941
|
+
return False, f"回答失败:{e}"
|
|
3942
|
+
waitq.return_to_waiting(row["id"])
|
|
3943
|
+
if inst is not None:
|
|
3944
|
+
inst.submit_answer(card_id)
|
|
3945
|
+
return False, f"回答失败:{e}"
|
|
3946
|
+
waitq.mark_done(row["id"])
|
|
3947
|
+
# 「立即送达」同队列送达路径口径:用户作答引发的回列不置「有更新」标记
|
|
3948
|
+
db.update_board_card(card_id, column_key="doing", mark_unread=False,
|
|
3949
|
+
block_kind=None, block_text="")
|
|
3950
|
+
if inst is not None:
|
|
3951
|
+
# 「立即送达」= 用户自担路径:行补回运行位(与队列送达路径同款:
|
|
3952
|
+
# 行即条目,enter_running 建行/复用终态行)
|
|
3953
|
+
inst.card_started(card_id, row["project_id"],
|
|
3954
|
+
ext={"reason": "立即送达"}) # 占住运行位
|
|
3955
|
+
print(f"[board] 答案立即送达:card={card_id}", flush=True)
|
|
3956
|
+
return True, ""
|
|
3957
|
+
|
|
3958
|
+
|
|
3959
|
+
_IW_PROBE_TTL = 5.0 # 任务会话按需探测的缓存时效(与 watcher 扫描同频)
|
|
3960
|
+
|
|
3961
|
+
|
|
3962
|
+
def interaction_probe(family, proj, sid):
|
|
3963
|
+
"""按需探测一个会话的等待态并写入 watcher 缓存(任务会话端点/SSE 用)。
|
|
3964
|
+
|
|
3965
|
+
看板卡片由调和器统一维护(事件驱动 + 60s 兜底);任务会话没有调和器,
|
|
3966
|
+
会话端点每 tick 调本函数:缓存新鲜(≤_IW_PROBE_TTL)直接复用,否则探测
|
|
3967
|
+
一次;探测失败(None)保留旧缓存不清态(读口抖动不误清展示与作答白名单)。返回
|
|
3968
|
+
interaction_of_sid 同款 dict 或 None。"""
|
|
3969
|
+
if not sid:
|
|
3970
|
+
return None
|
|
3971
|
+
now = int(time.time() * 1000)
|
|
3972
|
+
with _IW_LOCK:
|
|
3973
|
+
c = _IW_CACHE.get(sid)
|
|
3974
|
+
fresh = bool(c and now - int(c.get("ts") or 0) < _IW_PROBE_TTL * 1000)
|
|
3975
|
+
if not fresh:
|
|
3976
|
+
r = _iw_interaction(family, proj, sid)
|
|
3977
|
+
if r is not None:
|
|
3978
|
+
with _IW_LOCK:
|
|
3979
|
+
cc = _IW_CACHE.setdefault(sid, {})
|
|
3980
|
+
cc.update(r)
|
|
3981
|
+
cc["ts"] = now
|
|
3982
|
+
return interaction_of_sid(sid)
|
|
3983
|
+
|
|
3984
|
+
|
|
3985
|
+
def _iw_clear(sid):
|
|
3986
|
+
"""清理 watcher 缓存中该 sid 的条目(回答成功后调用)。已答/已流转的卡若
|
|
3987
|
+
残留 stale pending,interaction_of_sid 会长期返回旧 pending(Task 3
|
|
3988
|
+
复审 Minor 3),导致会话窗重复显示已答提问、阻塞徽标不消失。"""
|
|
3989
|
+
if not sid:
|
|
3990
|
+
return
|
|
3991
|
+
with _IW_LOCK:
|
|
3992
|
+
_IW_CACHE.pop(sid, None)
|
|
3993
|
+
|
|
3994
|
+
|
|
3995
|
+
def _queue_answer_unit(proj, card_id, sid, meta):
|
|
3996
|
+
"""作答/审批统一入队(v2b T1,裁决 R8 一律入队):权威行插「运行中最后
|
|
3997
|
+
一个条目后面」(insert_after_prefix=前缀后/等待区最前;meta 携带完整
|
|
3998
|
+
送达载荷——重启存活的前提),落 doing/queue 排队占位(前端「排队中」
|
|
3999
|
+
徽标),补位启动时才由统一队列 worker 送达(执行体 _deliver_answer_unit,
|
|
4000
|
+
not_before 退避/3 次重试/40405 直 failed 分流不动)。
|
|
4001
|
+
|
|
4002
|
+
重复作答替换(2026-09-19 口径保留):enqueue 族 API 的幂等语义是「同类
|
|
4003
|
+
同目标已有活跃行 → 复用既有行、不合入新 meta」,再次直接入队会把改答
|
|
4004
|
+
静默吞掉、送达的仍是第一份。故先取消既有活跃行再全新插入;
|
|
4005
|
+
retries/not_before 随新答案重置是正确的(新作答取代旧尝试)。
|
|
4006
|
+
落列失败不自毁答案:等待项是权威事实源,worker 补位照常送达,列归位
|
|
4007
|
+
由送达执行体兜底(2026-09-16 实障卡 389 的「永久滞留」不可能复现)。
|
|
4008
|
+
"""
|
|
4009
|
+
if waitq.get_active(waitq.KIND_ANSWER, card_id) is not None:
|
|
4010
|
+
waitq.cancel(waitq.KIND_ANSWER, card_id, "重复作答替换")
|
|
4011
|
+
waitq.insert_after_prefix(waitq.KIND_ANSWER, card_id, proj["id"], meta=meta)
|
|
4012
|
+
inst = runner.INSTANCE
|
|
4013
|
+
if inst is not None:
|
|
4014
|
+
inst.submit_answer(card_id) # 内存键唤醒(补位时机①;可拾取性由 _pick_locked 判定)
|
|
4015
|
+
try:
|
|
4016
|
+
# 占位投影单写(P4 R6:等待单元是 answer 行不建 card 行,纯投影;
|
|
4017
|
+
# 列归位由送达执行体兜底)
|
|
4018
|
+
waitq.set_card_wait_placeholder(card_id, block_text="")
|
|
4019
|
+
# 防旧队列条目被拾起误发「继续」(不变量①;P4 R10:waitq.cancel
|
|
4020
|
+
# 一行防御——answer 与 card 同目标不同 kind、活跃唯一索引按 kind
|
|
4021
|
+
# 分离不冲突)。v2d T1 行态口径统一(v2b 终审记录①):只收 waiting
|
|
4022
|
+
# 行(防御本义=队列条目不被拾起)——running/finishing 行(force 落表
|
|
4023
|
+
# 在跑)终态化归 finish() 唯一收尾点、starting 归 worker finally,
|
|
4024
|
+
# 不再被防御 cancel 命中。
|
|
4025
|
+
waitq.cancel(waitq.KIND_CARD, card_id, "作答排队防御",
|
|
4026
|
+
states=(waitq.WAITING,))
|
|
4027
|
+
_iw_clear(sid) # 清交互缓存:前端不再显示作答卡
|
|
4028
|
+
except Exception as e:
|
|
4029
|
+
print(f"[board] 作答排队落列失败(答案已收,列待送达时归位)"
|
|
4030
|
+
f" card={card_id}: {e}", flush=True)
|
|
4031
|
+
|
|
4032
|
+
|
|
4033
|
+
def answer_interaction(proj, card, qid, answers):
|
|
4034
|
+
"""作答卡片会话的待答提问(单族:dsh_plugin,**整批提交全部子题**):先校验
|
|
4035
|
+
watcher 缓存确为 pending(防已答/过期 qid 重复提交),再校验每题的作答形态。
|
|
4036
|
+
看板卡路径(v2b T1,裁决 R8)**一律入队**(_queue_answer_unit:插前缀后/
|
|
4037
|
+
等待区最前 + doing/queue 占位,补位启动时 worker 才调驱动 `/answer` 一次
|
|
4038
|
+
提交送达——dsh 侧按 callId + 逐题 selected/custom 一次给全,缺项即未答);
|
|
4039
|
+
任务侧伪卡(无 id)保持直送。成功后清理 watcher 缓存(下轮调和重新检测刷新)。
|
|
4040
|
+
|
|
4041
|
+
answers = [{"wire": "q_0", "kind": "single|multi|other|multi_with_other",
|
|
4042
|
+
"option_id"/"option_ids"/"text": ...}, ...](前端逐题收集;
|
|
4043
|
+
2026-09-11 前只支持首题单条作答,其余子题被静默忽略)。
|
|
4044
|
+
|
|
4045
|
+
校验一律对缓存里的实况值取白名单:qid 走 st["qid"](既堵 URL 拼接注入
|
|
4046
|
+
——qid 原样进驱动 REST 路径,禁 / ? # 等字符混入,也防旧 qid 重放);
|
|
4047
|
+
每题 wire id 必须命中 st["questions"][].id 且不得重复、不得漏题(漏了
|
|
4048
|
+
dsh 侧视为未答,等于把子题丢掉——本函数存在的理由);选项 id 必须落在该题
|
|
4049
|
+
options 内;kind 与该题能力(multi_select / allow_other)对齐;自定义输入限长。
|
|
4050
|
+
返回 None=成功,str=错误信息(server 转 400 JSON {error})。"""
|
|
4051
|
+
sid = card["session_id"] or ""
|
|
4052
|
+
st = interaction_of_sid(sid) if sid else None
|
|
4053
|
+
if not st or not st.get("pending"):
|
|
4054
|
+
return "当前没有等待中的提问"
|
|
4055
|
+
if st.get("kind") != "question":
|
|
4056
|
+
return "当前等待的不是提问"
|
|
4057
|
+
if not qid or str(qid) != str(st.get("qid") or ""):
|
|
4058
|
+
return "提问不存在或已过期"
|
|
4059
|
+
if not isinstance(answers, list) or not answers:
|
|
4060
|
+
return "作答载荷非法"
|
|
4061
|
+
qs = [q for q in (st.get("questions") or []) if q]
|
|
4062
|
+
if not qs:
|
|
4063
|
+
# 兜底:旧缓存条目只有首题平面字段
|
|
4064
|
+
qs = [{"id": str(st.get("wire") or "q_0"),
|
|
4065
|
+
"options": st.get("options") or [],
|
|
4066
|
+
"multi_select": bool(st.get("multi_select")),
|
|
4067
|
+
"allow_other": bool(st.get("allow_other"))}]
|
|
4068
|
+
by_id = {str(q.get("id") or ""): q for q in qs}
|
|
4069
|
+
given = {}
|
|
4070
|
+
for a in answers:
|
|
4071
|
+
if not isinstance(a, dict):
|
|
4072
|
+
return "作答载荷非法"
|
|
4073
|
+
wid = str(a.get("wire") or "")
|
|
4074
|
+
if wid not in by_id:
|
|
4075
|
+
return "题目不存在或已过期"
|
|
4076
|
+
if wid in given:
|
|
4077
|
+
return "题目作答重复"
|
|
4078
|
+
given[wid] = a
|
|
4079
|
+
if len(given) != len(by_id):
|
|
4080
|
+
return "请回答全部问题"
|
|
4081
|
+
out = []
|
|
4082
|
+
for q in qs: # 按题目顺序组装(与前端展示序一致)
|
|
4083
|
+
wid = str(q.get("id") or "")
|
|
4084
|
+
a = given[wid]
|
|
4085
|
+
kind = str(a.get("kind") or "single")
|
|
4086
|
+
text = str(a.get("text") or "").strip()
|
|
4087
|
+
if kind not in ("single", "multi", "other", "multi_with_other"):
|
|
4088
|
+
return "作答形态非法"
|
|
4089
|
+
if kind in ("multi", "multi_with_other") and not q.get("multi_select"):
|
|
4090
|
+
return "该问题不支持多选"
|
|
4091
|
+
if kind in ("other", "multi_with_other") and not q.get("allow_other"):
|
|
4092
|
+
return "该问题不支持自定义输入"
|
|
4093
|
+
opts = q.get("options") or []
|
|
4094
|
+
valid_ids = {str(o.get("id") or "") for o in opts}
|
|
4095
|
+
ids = [str(x) for x in (a.get("option_ids") or [])]
|
|
4096
|
+
if kind in ("multi", "multi_with_other"):
|
|
4097
|
+
if kind == "multi" and not ids:
|
|
4098
|
+
return "未选择任何选项"
|
|
4099
|
+
if any(i not in valid_ids for i in ids):
|
|
4100
|
+
return "选项不存在或已过期"
|
|
4101
|
+
elif kind == "single" and opts and str(a.get("option_id") or "") not in valid_ids:
|
|
4102
|
+
# 单选:无 options 的问题不设白名单(兼容无选项提问),有则必须命中
|
|
4103
|
+
# (other 形态不带选项 id,不参与该白名单)
|
|
4104
|
+
return "选项不存在或已过期"
|
|
4105
|
+
if kind in ("other", "multi_with_other"):
|
|
4106
|
+
if not text:
|
|
4107
|
+
return "请输入内容"
|
|
4108
|
+
if len(text) > 2000:
|
|
4109
|
+
return "输入内容过长"
|
|
4110
|
+
out.append({"wire": wid, "kind": kind,
|
|
4111
|
+
"option_id": str(a.get("option_id") or ""),
|
|
4112
|
+
"option_ids": ids, "text": text})
|
|
4113
|
+
# 作答一律入队(v2b T1,裁决 R8):不再判项目忙/闲——闲时直送分支与
|
|
4114
|
+
# _own_occupancy 直送旁路已删除(自身占位豁免移入补位器,runner
|
|
4115
|
+
# ._answer_exempt_keys_locked,R5⑥),一律插「运行中最后一个条目后面」
|
|
4116
|
+
# (前缀后/等待区最前),卡落 doing/queue 排队占位,补位启动时才送达。
|
|
4117
|
+
# 仅看板卡(有卡 id)参与;任务侧会话没有卡片列语义,保持直送
|
|
4118
|
+
# (specQ §5 边界不变)。
|
|
4119
|
+
try:
|
|
4120
|
+
card_id = card["id"]
|
|
4121
|
+
except (KeyError, IndexError, TypeError):
|
|
4122
|
+
card_id = None
|
|
4123
|
+
if card_id is None:
|
|
4124
|
+
if _web_family(proj) != "dsh_plugin":
|
|
4125
|
+
# 退场族无作答通道(单族化):不落已删除的 kimi 配送段
|
|
4126
|
+
return f"回答失败:{agents.RETIRED_MSG}"
|
|
4127
|
+
try:
|
|
4128
|
+
# 路线 A:任务侧会话直送(卡片侧走作答排队,见 _answer_deliver);
|
|
4129
|
+
# 驱动调用失败(网络/序列化/提问已失效等)均须落到前端
|
|
4130
|
+
if not dshdriver.answer_question(sid, qid, _dsh_answers(out)):
|
|
4131
|
+
return "回答失败:提问不存在或已过期"
|
|
4132
|
+
except Exception as e:
|
|
4133
|
+
return f"回答失败:{e}"
|
|
4134
|
+
_iw_clear(sid)
|
|
4135
|
+
return None
|
|
4136
|
+
_queue_answer_unit(proj, card_id, sid,
|
|
4137
|
+
{"sid": sid, "qid": qid, "answers": out})
|
|
4138
|
+
return None
|
|
4139
|
+
|
|
4140
|
+
|
|
4141
|
+
def answer_approval(proj, card, approval_id, decision, scope=""):
|
|
4142
|
+
"""应答卡片会话的待审批请求(单族:dsh_plugin,manual 逐条确认档挂起的工具调用)。
|
|
4143
|
+
|
|
4144
|
+
与 answer_interaction 同款白名单校验:approval_id 必须是缓存里的实况值
|
|
4145
|
+
(防伪造/旧 id 重放);decision ∈ approved/rejected(平台两态),
|
|
4146
|
+
scope="session" 为「本会话内批准」——dsh 无该语义,显式拒绝(见下)。
|
|
4147
|
+
看板卡路径(v2b T1)一律入队、补位启动时才送达;任务侧保持直送。
|
|
4148
|
+
成功后清 watcher 缓存(下轮调和重新检测刷新)。
|
|
4149
|
+
返回 None=成功,str=错误信息(server 转 400 JSON {error})。"""
|
|
4150
|
+
sid = card["session_id"] or ""
|
|
4151
|
+
st = interaction_of_sid(sid) if sid else None
|
|
4152
|
+
if not st or not st.get("pending"):
|
|
4153
|
+
return "当前没有等待中的审批"
|
|
4154
|
+
if st.get("kind") != "approval":
|
|
4155
|
+
return "当前等待的不是审批"
|
|
4156
|
+
if not approval_id or str(approval_id) != str(st.get("approval_id") or ""):
|
|
4157
|
+
return "审批不存在或已过期"
|
|
4158
|
+
decision = str(decision or "")
|
|
4159
|
+
if decision not in ("approved", "rejected"):
|
|
4160
|
+
return "审批决定非法"
|
|
4161
|
+
scope = str(scope or "")
|
|
4162
|
+
if scope not in ("", "session"):
|
|
4163
|
+
return "审批范围非法"
|
|
4164
|
+
# dsh(2026-10-03 P3 第二批):把平台三态映射成宿主 ApprovalOutcome。
|
|
4165
|
+
# dsh 没有「本会话内批准」语义(outcome 只有 allowed-once/rejected/cancelled),
|
|
4166
|
+
# 故 scope=session 明确拒绝而不是静默降级。
|
|
4167
|
+
fam = runner.agent_family(proj["agent_path"] or "")
|
|
4168
|
+
if fam != "dsh_plugin":
|
|
4169
|
+
return f"审批失败:{agents.RETIRED_MSG}"
|
|
4170
|
+
if scope == "session":
|
|
4171
|
+
return "dsh 不支持「本会话内批准」(宿主无 session 级放行语义)"
|
|
4172
|
+
dsh_outcome = {"approved": "allowed-once", "rejected": "rejected"}[decision]
|
|
4173
|
+
# 审批作答同路入队(v2b T1,删恒直送):与提问作答同一插入规则——补位
|
|
4174
|
+
# 启动时由执行体按 meta 分发 answer_approval 送达。仅看板卡(有卡 id)
|
|
4175
|
+
# 参与;任务侧会话保持直送(specQ §5 边界不变)。
|
|
4176
|
+
try:
|
|
4177
|
+
card_id = card["id"]
|
|
4178
|
+
except (KeyError, IndexError, TypeError):
|
|
4179
|
+
card_id = None
|
|
4180
|
+
if card_id is None:
|
|
4181
|
+
try:
|
|
4182
|
+
dshdriver.answer_approval(sid, approval_id, dsh_outcome)
|
|
4183
|
+
except Exception as e: # noqa: BLE001
|
|
4184
|
+
return f"审批失败:{e}"
|
|
4185
|
+
_iw_clear(sid)
|
|
4186
|
+
return None
|
|
4187
|
+
meta = {"sid": sid, "approval_id": str(approval_id),
|
|
4188
|
+
"decision": decision, "scope": scope,
|
|
4189
|
+
"outcome": dsh_outcome} # 送达时直接用宿主 outcome
|
|
4190
|
+
_queue_answer_unit(proj, card_id, sid, meta)
|
|
4191
|
+
return None
|
|
4192
|
+
|
|
4193
|
+
|
|
4194
|
+
# 调和器节拍(P4 事件化,2026-10-03):
|
|
4195
|
+
# - 单族化(P7b)后只有 dsh 一族,**轮询退场**:只等 EventHub 事件,SAFETY 才
|
|
4196
|
+
# 兜底对账一次(节点掉线/丢帧的最后防线,不是状态轮询——醒后读的是本地注册表)。
|
|
4197
|
+
IW_SAFETY_SECONDS = 60.0
|
|
4198
|
+
IW_DEBOUNCE_SECONDS = 0.15 # 事件风暴合并:同批帧只跑一轮调和
|
|
4199
|
+
IW_MIN_INTERVAL = 0.5 # 两轮调和最小间隔(把 dsh 事件速率对 CPU 的影响封顶)
|
|
4200
|
+
|
|
4201
|
+
|
|
4202
|
+
def start_interaction_watcher():
|
|
4203
|
+
"""启动交互检测常驻线程(幂等;daemon 不阻塞主服务退出)。
|
|
4204
|
+
|
|
4205
|
+
P4(2026-10-03):由「每 5s 固定轮询」改为**事件驱动 + 兜底**——
|
|
4206
|
+
等 `dshevents.wait(timeout)`:dsh 侧任一状态帧到达即醒(立即调和),
|
|
4207
|
+
超时(单族部署 60s 兜底)只作为对账兜底。
|
|
4208
|
+
"""
|
|
4209
|
+
global _IW_STARTED
|
|
4210
|
+
if _IW_STARTED:
|
|
4211
|
+
return
|
|
4212
|
+
_IW_STARTED = True
|
|
4213
|
+
|
|
4214
|
+
def _loop():
|
|
4215
|
+
last = 0.0
|
|
4216
|
+
while True:
|
|
4217
|
+
try:
|
|
4218
|
+
changed = dshevents.wait(IW_SAFETY_SECONDS)
|
|
4219
|
+
if changed:
|
|
4220
|
+
time.sleep(IW_DEBOUNCE_SECONDS) # 合并同批事件
|
|
4221
|
+
now = time.monotonic()
|
|
4222
|
+
if now - last < IW_MIN_INTERVAL: # 事件密集时限速
|
|
4223
|
+
time.sleep(IW_MIN_INTERVAL - (now - last))
|
|
4224
|
+
last = time.monotonic()
|
|
4225
|
+
_iw_once()
|
|
4226
|
+
except Exception: # 守护线程不 crash
|
|
4227
|
+
pass
|
|
4228
|
+
|
|
4229
|
+
threading.Thread(target=_loop, daemon=True, name="board-interaction").start()
|