@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/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/last_turn_reason/
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, "interaction": None,
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
- lines.append("请到站点会话窗口处理")
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
- hint = "该等待不支持远程作答,请到站点会话窗口处理"
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.0",
4
- "description": "Touchstone 自动化测试平台的 dsh 插件(自包含): Node 薄壳托管 Python 后端 + /touchstone 前缀反代 + 进程内 agent 驱动, 浏览器半经 exports[\"./client\"] 提供侧栏入口与全屏面板; 包内附完整平台源码与预构建插件前端",
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
- 前端打开卡片详情(点击卡面正文 / 详情按钮)时调用,清 `board_cards.unread`。
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
- self._respond(200, json.dumps(err).encode("utf-8"),
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>`(早期/桌面端写入,如 64655aea-… 的会话目录名即其 id)。
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})")