@gavinc-cn/touchstone-dsh 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/README.zh.md +1 -0
- package/agents.py +0 -0
- package/board.py +348 -26
- package/chat.py +0 -0
- package/db.py +1 -1
- package/dsh-plugin/install.sh +0 -0
- package/dsh-plugin/lib/agent-driver.js +190 -42
- package/dsh-plugin/lib/index.js +181 -22
- package/dshdriver.py +0 -0
- package/dshevents.py +35 -3
- package/feishu.py +29 -8
- package/package.json +8 -3
- package/runner.py +11 -0
- package/server.py +46 -4
- package/sessparse.py +95 -1
- package/touchstone.py +2 -1
- package/webui/dist-plugin/assets/index-DtwKoi1T.js +473 -0
- package/webui/dist-plugin/index.html +1 -1
- package/worktree.py +100 -0
- package/webui/dist-plugin/assets/index-eHpxBfP6.js +0 -473
package/dshevents.py
CHANGED
|
@@ -104,8 +104,10 @@ class EventHub:
|
|
|
104
104
|
def get(self, sid):
|
|
105
105
|
"""单会话实时态;未连接或未知会话返回 None(=未知,不是空闲)。
|
|
106
106
|
|
|
107
|
-
字段:`session_id/status/cwd/task/owned/interaction/
|
|
108
|
-
updated_at`——与 `dshdriver.status()` 同形,供 board
|
|
107
|
+
字段:`session_id/status/cwd/task/owned/origin/interaction/
|
|
108
|
+
last_turn_reason/updated_at`——与 `dshdriver.status()` 同形,供 board 直接
|
|
109
|
+
替换读口。`origin`(2026-10-07 增):`"subagent"` = dsh 子代理会话,
|
|
110
|
+
`""` = 未知(旧插件未上报,board 侧另有磁盘兜底判据)。
|
|
109
111
|
"""
|
|
110
112
|
if not sid:
|
|
111
113
|
return None
|
|
@@ -174,6 +176,24 @@ class EventHub:
|
|
|
174
176
|
self._cond.wait(timeout)
|
|
175
177
|
return self._changed != before
|
|
176
178
|
|
|
179
|
+
def wait_connected(self, timeout):
|
|
180
|
+
"""等(首)次连接就绪:就绪返回 True,超时返回 False。
|
|
181
|
+
|
|
182
|
+
启动补跑用(server 在 `dshevents.start()` 之后等它就绪,再对账 ext 行
|
|
183
|
+
——2026-10-07 僵尸占用修复):`board.recover()` 执行时中枢尚未 start,
|
|
184
|
+
那次 ext 对账必然「探测不可用」而保行,需要一次连接就绪后的补跑。
|
|
185
|
+
未配置驱动/宿主不可达时至多等满 timeout;绝不把「未连接」当成就绪
|
|
186
|
+
(不变量 1「断连=未知」)。
|
|
187
|
+
"""
|
|
188
|
+
deadline = time.monotonic() + max(0.0, float(timeout))
|
|
189
|
+
with self._cond:
|
|
190
|
+
while not self._connected:
|
|
191
|
+
remain = deadline - time.monotonic()
|
|
192
|
+
if remain <= 0:
|
|
193
|
+
return False
|
|
194
|
+
self._cond.wait(remain)
|
|
195
|
+
return True
|
|
196
|
+
|
|
177
197
|
# ---------- 内部:连接与折叠 ----------
|
|
178
198
|
|
|
179
199
|
def _run(self):
|
|
@@ -227,6 +247,9 @@ class EventHub:
|
|
|
227
247
|
"cwd": row.get("cwd") or old.get("cwd") or "",
|
|
228
248
|
"task": row.get("task") or old.get("task") or "",
|
|
229
249
|
"owned": bool(row.get("owned", old.get("owned", False))),
|
|
250
|
+
# origin 是会话固有属性(subagent / 空=未知):快照没带就保留
|
|
251
|
+
# 事件里学到的值,别把已认出的子代理会话在重连对齐时又变成未知
|
|
252
|
+
"origin": str(row.get("origin") or old.get("origin") or ""),
|
|
230
253
|
# /live 的 interaction 可能为 None(此刻无等待);已挂起的
|
|
231
254
|
# interaction 若快照里没带,保留事件里学到的值,避免抖动
|
|
232
255
|
"interaction": row.get("interaction", old.get("interaction")),
|
|
@@ -293,7 +316,8 @@ class EventHub:
|
|
|
293
316
|
item = self._sessions.get(sid)
|
|
294
317
|
if item is None:
|
|
295
318
|
item = {"session_id": sid, "status": "", "cwd": "", "task": "",
|
|
296
|
-
"owned": False, "
|
|
319
|
+
"owned": False, "origin": "",
|
|
320
|
+
"interaction": None,
|
|
297
321
|
"last_turn_reason": None, "last_seq": 0,
|
|
298
322
|
"usage": None, "permission": None, "inbox": [],
|
|
299
323
|
"updated_at": time.time()}
|
|
@@ -345,6 +369,9 @@ class EventHub:
|
|
|
345
369
|
"cache_read": int(data.get("cache_read") or 0)}
|
|
346
370
|
elif typ == "session/created":
|
|
347
371
|
item["cwd"] = data.get("cwd") or item["cwd"]
|
|
372
|
+
# 子代理会话标记(2026-10-07):插件在 `session/created` 里上报
|
|
373
|
+
# 会话头行的 origin。缺字段保持旧值/空串=未知,绝不推断。
|
|
374
|
+
item["origin"] = str(data.get("origin") or item.get("origin") or "")
|
|
348
375
|
elif typ == "session/disposed":
|
|
349
376
|
self._sessions.pop(sid, None)
|
|
350
377
|
self._changed += 1
|
|
@@ -430,6 +457,11 @@ def wait(timeout):
|
|
|
430
457
|
return HUB.wait(timeout)
|
|
431
458
|
|
|
432
459
|
|
|
460
|
+
def wait_connected(timeout):
|
|
461
|
+
"""等中枢(首)次连接就绪(单例薄壳):就绪 True / 超时 False。"""
|
|
462
|
+
return HUB.wait_connected(timeout)
|
|
463
|
+
|
|
464
|
+
|
|
433
465
|
def subscribe(callback):
|
|
434
466
|
return HUB.subscribe(callback)
|
|
435
467
|
|
package/feishu.py
CHANGED
|
@@ -52,6 +52,17 @@ _MQ_TTL = 1800 # 暂存存活上限(秒):超时即丢,用户重
|
|
|
52
52
|
_MQ_MAX_BUCKETS = 64 # 暂存桶上限(卡×提问):极端情况下防无界增长
|
|
53
53
|
_MQ_FORM_MAX_BYTES = 950 # 单题多选 mini 表单 JSON 预算(飞书 form 元素硬限 1000)
|
|
54
54
|
|
|
55
|
+
# 不可远程作答(board._iw_interaction 的 `answerable=false`)时的**统一引导文案**
|
|
56
|
+
# (2026-10-07 纠偏)。这类等待的提问由**非平台自持会话**发起——用户在 dsh GUI 里
|
|
57
|
+
# 直跑的会话,以及宿主重启丢掉会话池身份后由 GUI 续跑的会话;此时插件只旁听、
|
|
58
|
+
# 挂起标记的 call_id 为空 ⇒ 站点会话窗(webui SessionView 按 answerable 门控选项
|
|
59
|
+
# 与「提交」)与飞书卡片都只展示、不出作答控件,**唯一能作答的地方是 dsh 宿主的
|
|
60
|
+
# 会话窗口**。旧文案「请到站点会话窗口处理」把用户引到同样答不了的地方,故统一
|
|
61
|
+
# 换成这段(旧文案出现的其余几处属于「可作答但飞书无处可点」的场合,站点会话窗
|
|
62
|
+
# 确实可答,保持原样不动)。
|
|
63
|
+
_NO_REMOTE_ANSWER_HINT = ("该等待不支持远程作答(提问由 dsh 直跑会话发起),"
|
|
64
|
+
"请在 dsh 会话窗口作答")
|
|
65
|
+
|
|
55
66
|
|
|
56
67
|
def user_config(user_id):
|
|
57
68
|
"""用户飞书配置(feishu_user_cfgs 表;enabled/base_url/默认 webhook/应用凭据)。
|
|
@@ -139,8 +150,10 @@ def _render(kind, ctx, project_id, base_url=""):
|
|
|
139
150
|
if kind == "blocked_interaction":
|
|
140
151
|
n_q = len(qs) or ctx.get("questions_count") or 0
|
|
141
152
|
if not ctx.get("answerable"):
|
|
142
|
-
# 不可远程作答的等待(answerable=false
|
|
143
|
-
|
|
153
|
+
# 不可远程作答的等待(answerable=false):引导回 dsh 会话窗口——
|
|
154
|
+
# 站点会话窗此时同样不出作答控件,旧文案「请到站点会话窗口处理」
|
|
155
|
+
# 会把用户引到死胡同(见 _NO_REMOTE_ANSWER_HINT)
|
|
156
|
+
lines.append(_NO_REMOTE_ANSWER_HINT)
|
|
144
157
|
elif n_q > 1:
|
|
145
158
|
# 多子题:一条指令按题号逐题答全(表单卡超 form 体积硬限时同样走这条)
|
|
146
159
|
lines.append(_multi_answer_help(cid, qs or [None] * int(n_q)))
|
|
@@ -472,12 +485,12 @@ def _build_interaction_card(ctx, project_name):
|
|
|
472
485
|
"content": _multi_click_hint(cid, qs, text_only,
|
|
473
486
|
forms)})
|
|
474
487
|
else:
|
|
475
|
-
# 不可远程作答(会话非平台自持):逐题只读展示 +
|
|
488
|
+
# 不可远程作答(会话非平台自持):逐题只读展示 + dsh 会话窗口引导。
|
|
476
489
|
# 2026-10-06 前的实现落到下面的单题分支,只渲染首题(q0),其余
|
|
477
490
|
# 子题在飞书上完全不可见(用户实障:agent 一次问 3 题只见第 1 题)。
|
|
478
491
|
elements.extend(_question_card_blocks(views))
|
|
479
492
|
elements.append({"tag": "markdown",
|
|
480
|
-
"content":
|
|
493
|
+
"content": _NO_REMOTE_ANSWER_HINT})
|
|
481
494
|
else:
|
|
482
495
|
md = [f"**agent 提问**:{ctx.get('question') or 'agent 正在等待你的回答'}",
|
|
483
496
|
f"卡片 #{cid} {(ctx.get('title') or '')[:60]}"]
|
|
@@ -486,7 +499,8 @@ def _build_interaction_card(ctx, project_name):
|
|
|
486
499
|
elements.append({"tag": "markdown", "content": "\n".join(md)})
|
|
487
500
|
hint = None
|
|
488
501
|
if not ctx.get("answerable"):
|
|
489
|
-
|
|
502
|
+
# 不可远程作答:站点会话窗也答不了(见 _NO_REMOTE_ANSWER_HINT)
|
|
503
|
+
hint = _NO_REMOTE_ANSWER_HINT
|
|
490
504
|
elif (ctx.get("questions_count") or len(qs) or 0) > 1:
|
|
491
505
|
hint = (f"该提问含 {ctx.get('questions_count') or len(qs)} 道子题,"
|
|
492
506
|
"请到站点会话窗口逐题作答")
|
|
@@ -1654,8 +1668,8 @@ def _answer_multi_click(p, c, value, action):
|
|
|
1654
1668
|
f"请回复「同意 {c['id']}」或「拒绝 {c['id']}」",
|
|
1655
1669
|
_toast("error", "该等待是审批"))
|
|
1656
1670
|
if not st.get("answerable"):
|
|
1657
|
-
return (
|
|
1658
|
-
_toast("error", "
|
|
1671
|
+
return (_NO_REMOTE_ANSWER_HINT,
|
|
1672
|
+
_toast("error", "请到 dsh 会话窗口作答"))
|
|
1659
1673
|
qs = [q for q in (st.get("questions") or []) if q]
|
|
1660
1674
|
n = len(qs)
|
|
1661
1675
|
if n < 1:
|
|
@@ -1803,7 +1817,7 @@ def _answer_by_token(p, c, token):
|
|
|
1803
1817
|
# 审批等待收到「作答」:引导用同意/拒绝指令(answer_interaction 只收提问)
|
|
1804
1818
|
return f"该等待是工具审批,请回复「同意 {c['id']}」或「拒绝 {c['id']}」"
|
|
1805
1819
|
if not st.get("answerable"):
|
|
1806
|
-
return
|
|
1820
|
+
return _NO_REMOTE_ANSWER_HINT
|
|
1807
1821
|
# 作答须一次给全全部子题(answers 逐题 record,漏项即未答):多子题走
|
|
1808
1822
|
# 「题号:值」逐题形态(2026-10-06 第二档),并与卡片点选暂存**合并**
|
|
1809
1823
|
# (2026-10-07 第三档)——文本只需给没点过的题,缺题仍不提交
|
|
@@ -1925,6 +1939,13 @@ def _execute_board_action(binding, intent, sender_open_id):
|
|
|
1925
1939
|
return f"卡片 #{c['id']} 不在「待审核」列(当前 {c['column_key']}),无法通过"
|
|
1926
1940
|
_, err = board.move_card(p, c["id"], "done")
|
|
1927
1941
|
if err:
|
|
1942
|
+
if "merge_pending" in err:
|
|
1943
|
+
# 独立 worktree 卡的待合并闸(2026-10-07 批次):飞书侧没有弹框,
|
|
1944
|
+
# 不替用户选「交给 agent 合并 / 仅通过」,引导去网页看板操作
|
|
1945
|
+
mp = err.get("merge_pending") or {}
|
|
1946
|
+
return (f"卡片 #{c['id']} 还有 {mp.get('ahead', 0)} 个提交没回流主分支"
|
|
1947
|
+
f"({mp.get('branch', '')} → {mp.get('target', '')});"
|
|
1948
|
+
f"请在网页看板上点「通过」,选择「交给 agent 合并」或「仅通过(不合并)」")
|
|
1928
1949
|
return "审核失败:" + str(err.get("error") or err.get("blocked") or "门禁拦截")
|
|
1929
1950
|
return f"✅ 卡片 #{c['id']} {(c['title'] or '')[:30]} 已审核通过"
|
|
1930
1951
|
if action == "reject":
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gavinc-cn/touchstone-dsh",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "AI-agent-driven automated testing platform for DeepSeek Harness (dsh): Markdown case library, bug reports, per-project serial queue, development board and built-in load testing.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"author": "Touchstone contributors",
|
|
6
7
|
"type": "module",
|
|
7
8
|
"main": "./dsh-plugin/lib/index.js",
|
|
8
9
|
"exports": {
|
|
@@ -72,5 +73,9 @@
|
|
|
72
73
|
"testing",
|
|
73
74
|
"automation",
|
|
74
75
|
"agent"
|
|
75
|
-
]
|
|
76
|
+
],
|
|
77
|
+
"publishConfig": {
|
|
78
|
+
"registry": "https://registry.npmjs.org",
|
|
79
|
+
"access": "public"
|
|
80
|
+
}
|
|
76
81
|
}
|
package/runner.py
CHANGED
|
@@ -2462,6 +2462,17 @@ def start_unit_selfcheck(interval=SELFCHECK_INTERVAL, min_age=SELFCHECK_MIN_AGE,
|
|
|
2462
2462
|
# 等下个外部事件会让排队单元无谓停滞一个周期以上)
|
|
2463
2463
|
with inst._cond:
|
|
2464
2464
|
inst._cond.notify_all()
|
|
2465
|
+
# 外部条目(ext 行)周期对账(2026-10-07 僵尸占用修复的兜底入口):
|
|
2466
|
+
# 调和器逐卡探测已修好「中枢在线、会话已不在 dsh 池」的口径
|
|
2467
|
+
# (见 `board._iw_once`);本拍覆盖「调和器线程未跑到 / 被保守
|
|
2468
|
+
# hold 语义挡住」的漏网行——否则僵尸 ext 行永久占位、项目永不
|
|
2469
|
+
# 补位(实障:卡 870 排队 5 小时)。中枢未连接时 `_ext_refresh`
|
|
2470
|
+
# 收敛为 None=保行(绝不把未知当空闲);行有变化时唤醒补位,
|
|
2471
|
+
# 异常只打一行、不打断自检线程。
|
|
2472
|
+
try:
|
|
2473
|
+
board.refresh_ext_rows("外部条目周期对账")
|
|
2474
|
+
except Exception as e: # noqa: BLE001 — 同兜底口径
|
|
2475
|
+
print(f"[waitq-selfcheck] 外部条目对账异常: {e}", flush=True)
|
|
2465
2476
|
n = waitq.prune_finished() # 周期尾部:终态等待项回收(R14)
|
|
2466
2477
|
if n:
|
|
2467
2478
|
print(f"[waitq-selfcheck] 终态等待项回收 {n} 条", flush=True)
|
package/server.py
CHANGED
|
@@ -1086,6 +1086,11 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
1086
1086
|
if m:
|
|
1087
1087
|
self._api_board_card_action(int(m.group(1)), int(m.group(2)), m.group(3), body)
|
|
1088
1088
|
return
|
|
1089
|
+
# worktree 改动回流:把合并任务交给卡片会话(回统一队列排队,2026-10-07 批次)
|
|
1090
|
+
m = re.match(r"^/api/projects/(\d+)/board/cards/(\d+)/worktree/merge$", path)
|
|
1091
|
+
if m:
|
|
1092
|
+
self._api_board_card_worktree_merge(int(m.group(1)), int(m.group(2)))
|
|
1093
|
+
return
|
|
1089
1094
|
# 卡片「已查看」(2026-10-07 批次):前端打开卡片详情时调用,清「有更新」标记
|
|
1090
1095
|
m = re.match(r"^/api/projects/(\d+)/board/cards/(\d+)/viewed$", path)
|
|
1091
1096
|
if m:
|
|
@@ -2904,7 +2909,8 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
2904
2909
|
"""标记卡片已查看(POST /api/projects/<pid>/board/cards/<cid>/viewed)。
|
|
2905
2910
|
|
|
2906
2911
|
2026-10-07 批次「卡片状态有更新·用户还没打开过 ⇒ 卡面打标记」的清除端:
|
|
2907
|
-
前端打开卡片详情(点击卡面正文 /
|
|
2912
|
+
前端打开卡片详情(点击卡面正文 / 详情按钮)或点卡面操作行上任意一个按钮时调用,
|
|
2913
|
+
清 `board_cards.unread`。
|
|
2908
2914
|
幂等:本来就未读/标记已被别的标签页清掉都回 200;`changed` 说明本次是否真的
|
|
2909
2915
|
清了标记(不清就不发看板变更信号,前端也不必重取)。归属校验走
|
|
2910
2916
|
`_board_owned`(多用户隔离红线:越权访问他人卡片一律 404)。
|
|
@@ -2936,12 +2942,18 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
2936
2942
|
before_id = int(body["before_id"]) if body.get("before_id") is not None else None
|
|
2937
2943
|
except (TypeError, ValueError):
|
|
2938
2944
|
before_id = None
|
|
2945
|
+
# merge_ack(2026-10-07 批次):独立 worktree 卡进「已完成」的待合并闸。
|
|
2946
|
+
# 默认 false ⇒ 有待合并提交时 move_card 回 {"merge_pending": ...} 且列不动,
|
|
2947
|
+
# 前端弹「交给 agent 合并 / 仅通过」;用户选「仅通过」时前端带 true 再来一次,
|
|
2948
|
+
# 后端跳过该闸(= 旧行为,分支保留)。
|
|
2939
2949
|
card_dict, err = board.move_card(row, card_id, target,
|
|
2940
2950
|
(body.get("block_text") or "")[:2000],
|
|
2941
|
-
before_id=before_id
|
|
2951
|
+
before_id=before_id,
|
|
2952
|
+
merge_ack=bool(body.get("merge_ack")))
|
|
2942
2953
|
if err:
|
|
2943
|
-
if "blocked" in err:
|
|
2944
|
-
|
|
2954
|
+
if "blocked" in err or "merge_pending" in err:
|
|
2955
|
+
# 父任务拦截 / 合并询问:前端弹确认框,不算错误(列未变)
|
|
2956
|
+
self._respond(200, json.dumps(err, ensure_ascii=False).encode("utf-8"),
|
|
2945
2957
|
"application/json; charset=utf-8")
|
|
2946
2958
|
return
|
|
2947
2959
|
self._respond(400, json.dumps(err, ensure_ascii=False).encode("utf-8"),
|
|
@@ -3017,6 +3029,28 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
3017
3029
|
return
|
|
3018
3030
|
self._respond(200, b'{"ok": true}', "application/json; charset=utf-8")
|
|
3019
3031
|
|
|
3032
|
+
def _api_board_card_worktree_merge(self, project_id, card_id):
|
|
3033
|
+
"""把 worktree 改动回流主分支的合并任务交给卡片会话(2026-10-07 批次)。
|
|
3034
|
+
|
|
3035
|
+
`POST .../board/cards/<id>/worktree/merge`。「通过」时若判定有待合并提交
|
|
3036
|
+
(move 端点回 `merge_pending`),前端弹框让用户选——这条端点即「交给 agent
|
|
3037
|
+
合并」:平台只投递指令 + 把卡片送回统一队列,agent 在卡片会话里执行同步
|
|
3038
|
+
主分支与合并回流(冲突由 agent 解),干完这轮卡片自动回待审核,用户再点
|
|
3039
|
+
「通过」时已无待合并提交、直接进「已完成」。
|
|
3040
|
+
硬错误 400(无待合并提交 / 无主会话 / 工作树读不到);卡片运行中由
|
|
3041
|
+
`board._enter_doing` 的入口预检拦下(同样 400 带中文原因)。
|
|
3042
|
+
"""
|
|
3043
|
+
row, card = self._board_owned(project_id, card_id)
|
|
3044
|
+
if row is None:
|
|
3045
|
+
return
|
|
3046
|
+
card_dict, err = board.handoff_worktree_merge(row, card)
|
|
3047
|
+
if err:
|
|
3048
|
+
self._respond(400, json.dumps(err, ensure_ascii=False).encode("utf-8"),
|
|
3049
|
+
"application/json; charset=utf-8")
|
|
3050
|
+
return
|
|
3051
|
+
self._respond(200, json.dumps({"card": card_dict}, ensure_ascii=False).encode("utf-8"),
|
|
3052
|
+
"application/json; charset=utf-8")
|
|
3053
|
+
|
|
3020
3054
|
def _api_board_compact(self, project_id, card_id, body):
|
|
3021
3055
|
"""卡片会话 compact(压缩上下文)。busy 拒绝(409);族不支持 400;
|
|
3022
3056
|
CLI 族异步触发即返回(日志落工作目录 .web/board_compact_*)。"""
|
|
@@ -3292,6 +3326,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
3292
3326
|
# 卡片当前所在列(会话窗标题「卡片队列」徽标,键值同看板列:todo/doing/
|
|
3293
3327
|
# blocked/review/done;前端转列名展示,随 2s 轮询跟随卡片移列)
|
|
3294
3328
|
data["card_column"] = owner["column_key"]
|
|
3329
|
+
# 卡片是否跑在独立 worktree(2026-10-07 批次):会话窗「通过」要按
|
|
3330
|
+
# 看板同款口径弹「是否交给 agent 合并」(空串=普通卡)
|
|
3331
|
+
data["card_worktree"] = str(db.row_opt(owner, "worktree") or "").strip()
|
|
3295
3332
|
# 已作答·待送达(答案排队,2026-09-14):前端在输入区上方渲染「待送达」行
|
|
3296
3333
|
# 与「立即送达」按钮(POST .../cards/<cid>/answer/deliver,不等项目空闲)
|
|
3297
3334
|
data["answer_pending"] = board.is_answer_pending(owner_cid)
|
|
@@ -5267,6 +5304,11 @@ def main():
|
|
|
5267
5304
|
# 状态流),再起调和器——调和器改为等它的事件唤醒,不再固定 5s 轮询 dsh。
|
|
5268
5305
|
# 独立形态(未下发驱动地址)下 start() 直接返回 False,不起线程。
|
|
5269
5306
|
dshevents.start()
|
|
5307
|
+
# 启动补跑(2026-10-07 僵尸 ext 行实障修复):上面 board.recover() 里的 ext 行
|
|
5308
|
+
# 对账执行时中枢尚未连接 ⇒ 探测「不可用」、一律保行;上一进程留下的僵尸占用行
|
|
5309
|
+
# (会话已结束、行仍 running)会继续堵死该项目补位,重启也救不回来。这里等首连
|
|
5310
|
+
# 就绪后补跑一次对账(后台线程,不阻塞启动与端口绑定;未就绪直接结束)。
|
|
5311
|
+
board.start_ext_recover_after_connect()
|
|
5270
5312
|
board.start_interaction_watcher()
|
|
5271
5313
|
runner.start_unit_selfcheck() # 等待项周期自检线程(首轮宽限+年龄豁免),替代已退场的影子比对线程
|
|
5272
5314
|
feishu.start_notifier() # 飞书 outbox 投递线程(未配置时空转)
|
package/sessparse.py
CHANGED
|
@@ -139,7 +139,8 @@ def _dsh_session_file(sid):
|
|
|
139
139
|
"""按会话 id 跨工作区 bucket 定位 zstd 会话文件(返回路径或 None)。
|
|
140
140
|
|
|
141
141
|
id 形态两代并存(本机实测 2026-10-02):`session-<uuid>`(dsh web/headless 新建)
|
|
142
|
-
与裸 `<uuid
|
|
142
|
+
与裸 `<uuid>`(**绝大多数是子代理会话**,少数是老主会话,如 64655aea-…;
|
|
143
|
+
2026-10-07 实测 82 个裸 uuid 目录里 73 个 origin=subagent、9 个主会话)。
|
|
143
144
|
文件名也带格式版本:`session.v4.jsonl.zstd`(62 个)/ `session.v3.jsonl.zstd`(1 个)
|
|
144
145
|
/ 无版本的 `session.jsonl.zstd`(9 个存量)——**只认无版本名会让 v3/v4 会话全部
|
|
145
146
|
found=false**(真机踩中),故按 `session*.jsonl.zstd` 通配并优先取版本号最大者。
|
|
@@ -160,6 +161,93 @@ def _dsh_format_version(path):
|
|
|
160
161
|
return int(m.group(1)) if m else 0
|
|
161
162
|
|
|
162
163
|
|
|
164
|
+
# dsh 会话**头行**缓存:{path: (stamp, header)}。头行承载 origin/delegationDepth/cwd,
|
|
165
|
+
# 是判「子代理会话」的唯一可靠依据(目录名不是判据,见 is_subagent)。
|
|
166
|
+
_DSH_HEADER_CACHE = {}
|
|
167
|
+
_DSH_HEADER_FRAMES = 4 # 头行最多向前找几帧(真机 304 个会话全在第 1 帧第 1 行)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _dsh_header(path):
|
|
171
|
+
"""读 dsh 会话**头行**(`{"type":"session","id":…,"origin":…,"delegationDepth":…}`),
|
|
172
|
+
读不到/无头行返回 {}(老样本与异常一律降级为空)。
|
|
173
|
+
|
|
174
|
+
只解前 `_DSH_HEADER_FRAMES` 帧即停(头行就是第 1 帧第 1 行,实测 278 字节)——
|
|
175
|
+
与 `_dsh_decompressed` 的全量解压相比,判一个会话是不是子代理不该付整段解压的
|
|
176
|
+
代价(枚举 300+ 会话时差一个量级)。按 (mtime_ns, size) 缓存:会话只追加,
|
|
177
|
+
stamp 变即失效重读。
|
|
178
|
+
"""
|
|
179
|
+
try:
|
|
180
|
+
st = os.stat(path)
|
|
181
|
+
stamp = (st.st_mtime_ns, st.st_size)
|
|
182
|
+
except OSError:
|
|
183
|
+
return {}
|
|
184
|
+
hit = _DSH_HEADER_CACHE.get(path)
|
|
185
|
+
if hit and hit[0] == stamp:
|
|
186
|
+
return hit[1]
|
|
187
|
+
header = {}
|
|
188
|
+
try:
|
|
189
|
+
with open(path, "rb") as f:
|
|
190
|
+
rest = f.read()
|
|
191
|
+
except OSError:
|
|
192
|
+
return {}
|
|
193
|
+
for _ in range(_DSH_HEADER_FRAMES):
|
|
194
|
+
if not rest:
|
|
195
|
+
break
|
|
196
|
+
obj = _DSH_DEC.decompressobj()
|
|
197
|
+
try:
|
|
198
|
+
text = obj.decompress(rest).decode("utf-8", "replace")
|
|
199
|
+
except zstandard.ZstdError:
|
|
200
|
+
break # 坏帧:头行读不到就按「未知」(=主会话)
|
|
201
|
+
rest = obj.unused_data # 余量即下一帧(逐帧解压,同 _dsh_decompressed)
|
|
202
|
+
for line in text.split("\n"):
|
|
203
|
+
line = line.strip()
|
|
204
|
+
if not line:
|
|
205
|
+
continue
|
|
206
|
+
d = _obj(line)
|
|
207
|
+
if d is not None and d.get("type") == "session":
|
|
208
|
+
header = d
|
|
209
|
+
break
|
|
210
|
+
if header:
|
|
211
|
+
break
|
|
212
|
+
_DSH_HEADER_CACHE[path] = (stamp, header)
|
|
213
|
+
return header
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _header_subagent(header):
|
|
217
|
+
"""头行 → 是否子代理会话:`origin=subagent` 为主判据,`delegationDepth>0` 兜底。
|
|
218
|
+
|
|
219
|
+
2026-10-07 全机实测 304 个会话目录:73 个 `origin=subagent`(且 delegationDepth
|
|
220
|
+
全为 1)、231 个主会话(origin 字段缺失、delegationDepth 全为 0),两集合严格互斥。
|
|
221
|
+
两个判据都留,是防 dsh 后续只写其一。
|
|
222
|
+
"""
|
|
223
|
+
if str(header.get("origin") or "") == "subagent":
|
|
224
|
+
return True
|
|
225
|
+
try:
|
|
226
|
+
return int(header.get("delegationDepth") or 0) > 0
|
|
227
|
+
except (TypeError, ValueError):
|
|
228
|
+
return False
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def is_subagent(family, sid):
|
|
232
|
+
"""该会话是否 dsh **子代理会话**(主会话派生的调研/审计子代理,非用户会话)。
|
|
233
|
+
|
|
234
|
+
子代理会话与主会话同 bucket、同格式,只能靠头行区分(见 `_header_subagent`;
|
|
235
|
+
**目录名不是判据**——裸 uuid 目录 82 个里 73 个是子代理,另 9 个是老主会话)。
|
|
236
|
+
子代理是主会话的实现细节:不建看板卡、不进「绑定已有会话」下拉、不按「外部会话
|
|
237
|
+
在跑」占项目运行位(看板 sync 投影 / server 绑定下拉 / board 占用三处共用本读口)。
|
|
238
|
+
|
|
239
|
+
族不在白名单 / 存储不存在 / 头行缺失一律 False(未知按主会话处理——占用判定
|
|
240
|
+
宁多等不可误放行,与 `dshevents` 的「断连=未知」同向)。
|
|
241
|
+
"""
|
|
242
|
+
if family not in FAMILIES:
|
|
243
|
+
return False
|
|
244
|
+
try:
|
|
245
|
+
path = _dsh_session_file(sid)
|
|
246
|
+
except Exception:
|
|
247
|
+
return False
|
|
248
|
+
return _header_subagent(_dsh_header(path)) if path else False
|
|
249
|
+
|
|
250
|
+
|
|
163
251
|
def dsh_bucket(cwd):
|
|
164
252
|
"""工作区路径 → dsh 会话 bucket 目录。
|
|
165
253
|
|
|
@@ -580,6 +668,10 @@ def _list_dsh(cwd):
|
|
|
580
668
|
|
|
581
669
|
目录名两代并存:`session-<uuid>` 与裸 `<uuid>`;文件名带格式版本
|
|
582
670
|
(session.v4.jsonl.zstd 等),故目录匹配与文件匹配都用通配(见 _dsh_session_file)。
|
|
671
|
+
**子代理会话不进列表**(2026-10-07 实障修复):它由主会话派生,被当成独立会话
|
|
672
|
+
会各自建卡(实测 72 张、70 张永留「待审核」)并在跑时占项目运行位,判据取会话
|
|
673
|
+
头行的 origin/delegationDepth(见 `is_subagent`)。裸 uuid 目录**不代表**是子代理:
|
|
674
|
+
实测 82 个里 73 个是子代理、9 个是老主会话。
|
|
583
675
|
归档只影响归档标记:会话目录仍在 bucket 里,列表照常枚举到它(平台据此把
|
|
584
676
|
归档会话建成落在「已完成」的 sync 卡 / 在绑定下拉里标 [已归档])。
|
|
585
677
|
"""
|
|
@@ -594,6 +686,8 @@ def _list_dsh(cwd):
|
|
|
594
686
|
except OSError:
|
|
595
687
|
continue
|
|
596
688
|
sid = os.path.basename(sdir)
|
|
689
|
+
if _header_subagent(_dsh_header(zfile)):
|
|
690
|
+
continue # 子代理会话:先判头行再解全量(省一次整段解压)
|
|
597
691
|
# 一次解压同时取标题与首问(同 stamp 缓存):看板 sync 卡建卡/补齐标题都要
|
|
598
692
|
# 首问,逐会话各调一次读口会重复解压同一个文件(见 _dsh_meta)
|
|
599
693
|
title, first_prompt = _dsh_meta(zfile)
|
package/touchstone.py
CHANGED
|
@@ -448,7 +448,8 @@ def cmd_test_full():
|
|
|
448
448
|
ran = 0
|
|
449
449
|
for name in ("e2e_board_continue.py", "e2e_board_stream.py", "e2e_chat_queue.py",
|
|
450
450
|
"e2e_unit_state.py", "e2e_stress_case.py", "e2e_board_archive.py",
|
|
451
|
-
"e2e_board_worktree.py"
|
|
451
|
+
"e2e_board_worktree.py", "e2e_ext_stale_recovery.py",
|
|
452
|
+
"e2e_subagent_no_occupancy.py"):
|
|
452
453
|
script = os.path.join(ROOT, "tests", name)
|
|
453
454
|
if not os.path.isfile(script):
|
|
454
455
|
print(f"(跳过: 未随附 tests/{name})")
|