@gavinc-cn/touchstone-dsh 0.1.0 → 0.2.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.
Files changed (58) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +22 -1
  3. package/README.zh.md +12 -1
  4. package/agents.py +0 -0
  5. package/auth.py +0 -0
  6. package/board.py +1119 -93
  7. package/builtin_assets.py +0 -0
  8. package/builtin_prompts/free_style/flow.md +0 -0
  9. package/builtin_prompts/free_style/load_test.md +0 -0
  10. package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
  11. package/builtin_prompts/free_style/templates/case_template.md +0 -0
  12. package/builtin_prompts/free_style/templates/index_template.md +0 -0
  13. package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
  14. package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
  15. package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
  16. package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
  17. package/builtin_prompts/free_style/templates/status_template.md +0 -0
  18. package/chat.py +590 -17
  19. package/db.py +63 -12
  20. package/dsh-plugin/cordis.patch.yml +7 -3
  21. package/dsh-plugin/install.sh +8 -5
  22. package/dsh-plugin/lib/agent-driver.js +895 -126
  23. package/dsh-plugin/lib/client.bundle.js +94 -9
  24. package/dsh-plugin/lib/index.js +399 -20
  25. package/dsh-plugin/scripts/build.mjs +0 -0
  26. package/dsh-plugin/scripts/prepack-check.mjs +0 -0
  27. package/dsh-plugin/scripts/select-bundle.mjs +0 -0
  28. package/dsh-plugin/src/client.js +94 -9
  29. package/dshdriver.py +74 -5
  30. package/dshevents.py +100 -6
  31. package/export_cases.py +0 -0
  32. package/extensions/dsh-autocommit/asset.json +0 -0
  33. package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
  34. package/extensions/dsh-autocommit/pkg/package.json +0 -0
  35. package/feishu.py +1226 -42
  36. package/feishu_conv.py +920 -0
  37. package/lib.py +0 -0
  38. package/lifecycle.py +0 -0
  39. package/loadcase.py +0 -0
  40. package/loadgen.py +0 -0
  41. package/localbus.py +0 -0
  42. package/migrate_p7b.py +0 -0
  43. package/package.json +8 -3
  44. package/platcompat.py +0 -0
  45. package/prompts.py +0 -0
  46. package/rag.py +0 -0
  47. package/requirements.txt +2 -1
  48. package/runner.py +11 -0
  49. package/server.py +264 -30
  50. package/sessparse.py +244 -44
  51. package/touchstone.py +8 -4
  52. package/waitq.py +23 -0
  53. package/webui/dist-plugin/assets/index-JLwyZZxC.js +484 -0
  54. package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +1 -0
  55. package/webui/dist-plugin/index.html +2 -2
  56. package/worktree.py +100 -0
  57. package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
  58. package/webui/dist-plugin/assets/index-eHpxBfP6.js +0 -473
package/lib.py CHANGED
File without changes
package/lifecycle.py CHANGED
File without changes
package/loadcase.py CHANGED
File without changes
package/loadgen.py CHANGED
File without changes
package/localbus.py CHANGED
File without changes
package/migrate_p7b.py CHANGED
File without changes
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.2.0",
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/platcompat.py CHANGED
File without changes
package/prompts.py CHANGED
File without changes
package/rag.py CHANGED
File without changes
package/requirements.txt CHANGED
@@ -4,6 +4,7 @@ zstandard>=0.25.0
4
4
  # 飞书开放平台 REST(M2 入站:回复/补丁/bot 信息;tenant_access_token 自管,2h-5min 刷新)
5
5
  requests>=2.31
6
6
  # 飞书 ws 长连接(M2 入站事件 im.message.receive_v1;仅承担长连接,REST 走 requests)
7
- lark-oapi>=1.3.0
7
+ # ≥1.5.5:M5 配置自动化用 lark_oapi.register_app 做「扫码一键建应用」(低版本无此入口)
8
+ lark-oapi>=1.5.5
8
9
  # Windows 原生运行:进程树终止/PID 探活/端口归属(platcompat Windows 分支懒加载,Linux 运行时不导入)
9
10
  psutil>=5.9.0
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
@@ -17,6 +17,7 @@ runner.py(任务轮次执行)、prompts.py(轮次 prompt)。
17
17
 
18
18
  import base64
19
19
  import collections
20
+ import faulthandler
20
21
  import json
21
22
  import os
22
23
  import re
@@ -31,6 +32,12 @@ import time
31
32
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
32
33
  from urllib.parse import parse_qs, quote, unquote
33
34
 
35
+ # 原生崩溃取证(2026-10-09):本进程 2026-10-08 21:48 在插件形态下两次被 SIGSEGV 带走
36
+ # (薄壳只留一行 `sig=SIGSEGV`,无任何 Python 栈)。faulthandler 常开:SIGSEGV/SIGABRT 等
37
+ # 致命中止时把**各线程的 Python 栈**打印到 stderr ⇒ 插件形态落 `<库目录>/plugin-backend.log`
38
+ # (薄壳逐行落盘)、独立形态落 `.run/server.log`。零成本、不改变任何行为。
39
+ faulthandler.enable()
40
+
34
41
  import auth
35
42
  import board
36
43
  import builtin_assets
@@ -375,6 +382,29 @@ def _board_session_running(owner_cid, proj, sid):
375
382
  return board.web_session_busy(proj, sid)
376
383
 
377
384
 
385
+ def _session_owned(sid):
386
+ """会话归属(会话端点 meta.owned;C 批 T8,2026-10-10):**三态**返回。
387
+
388
+ - `True` = 平台自持(驱动池内会话):平台的停止/中断本来就有效,前端照现状渲染;
389
+ - `False` = 外部会话(用户在 dsh GUI 里直跑/接管):轮次由 dsh GUI 持有,平台停不了,
390
+ 前端据此出「外部会话」提示条并把「停止」置灰;
391
+ - `None` = 注册表未知(未连接 / 热重载后 /live 未对齐 / 没见过该 sid):前端
392
+ **按「池内」渲染**,与现状一致——「未知 ≠ 外部」是本批统一判定阶梯,
393
+ 绝不据不可信快照反向推断成外部会话。
394
+
395
+ 判定阶梯与投递前置闸 `chat._external_preflight` 的第 ②③④ 步逐条对齐(未知放行、
396
+ 不可信快照放行、owned 为真放行,其余才是外部)。
397
+
398
+ 为什么不是 `bool(...)`:外部会话上报的也是 `owned:false`,与「注册表未知」的兜底
399
+ 值撞车——布尔下发会让断连/未对齐窗口里的池内会话被误判成外部(误出提示条,还误
400
+ 停用「停止」/「取消排队」)。故未知一律下发 `None`,把「未知 ≠ 外部」落进线上格式。
401
+ """
402
+ st = dshevents.get(sid)
403
+ if st is None or not dshevents.aligned():
404
+ return None
405
+ return bool(st.get("owned"))
406
+
407
+
378
408
  def _session_permission_mode(state):
379
409
  """会话级权限档(会话窗「权限」控件的数据源,2026-10-04 修恒置灰)。
380
410
 
@@ -453,6 +483,7 @@ PUBLIC_PAGES = {"/login"}
453
483
 
454
484
  # 「首次登录强制改密」白名单:must_change_pw=1 的账号只放行这三个端点,其余 API 一律 403
455
485
  # (2026-10-02:未设 TS_ADMIN_PASSWORD 时种子口令改为随机生成,登录后必须先改密)
486
+ # 2026-10-08 增例外:dsh 插件形态的免登信任头路径**整体跳过**此门(见 _must_change_pw_blocked)
456
487
  MUST_CHANGE_PW_ALLOW = ("/api/auth/me", "/api/auth/change_password", "/api/auth/logout")
457
488
 
458
489
  # 压测指标 SSE 首次连接的回放行数上限(2026-09-19 压测面板重构批次):
@@ -1031,6 +1062,16 @@ class Handler(BaseHTTPRequestHandler):
1031
1062
  if path == "/api/me/feishu/slash-commands":
1032
1063
  self._api_me_feishu_slash_set(body)
1033
1064
  return
1065
+ if path == "/api/me/feishu/provision":
1066
+ self._api_me_feishu_provision_set(body)
1067
+ return
1068
+ if path == "/api/me/feishu/doctor":
1069
+ self._api_me_feishu_doctor(body)
1070
+ return
1071
+ if path == "/api/me/feishu-hook/apply":
1072
+ # 项目推送绑定批量应用(设置页「同时应用到我的全部项目」,2026-10-10)
1073
+ self._api_me_feishu_hook_apply(body)
1074
+ return
1034
1075
  m = re.match(r"^/api/admin/users/(\d+)$", path)
1035
1076
  if m:
1036
1077
  self._api_admin_update_user(int(m.group(1)), body)
@@ -1086,6 +1127,11 @@ class Handler(BaseHTTPRequestHandler):
1086
1127
  if m:
1087
1128
  self._api_board_card_action(int(m.group(1)), int(m.group(2)), m.group(3), body)
1088
1129
  return
1130
+ # worktree 改动回流:把合并任务交给卡片会话(回统一队列排队,2026-10-07 批次)
1131
+ m = re.match(r"^/api/projects/(\d+)/board/cards/(\d+)/worktree/merge$", path)
1132
+ if m:
1133
+ self._api_board_card_worktree_merge(int(m.group(1)), int(m.group(2)))
1134
+ return
1089
1135
  # 卡片「已查看」(2026-10-07 批次):前端打开卡片详情时调用,清「有更新」标记
1090
1136
  m = re.match(r"^/api/projects/(\d+)/board/cards/(\d+)/viewed$", path)
1091
1137
  if m:
@@ -1271,6 +1317,8 @@ class Handler(BaseHTTPRequestHandler):
1271
1317
  self._api_me_feishu_outbox()
1272
1318
  elif path == "/api/me/feishu/slash-commands":
1273
1319
  self._api_me_feishu_slash_get()
1320
+ elif path == "/api/me/feishu/provision":
1321
+ self._api_me_feishu_provision_get()
1274
1322
  elif path == "/api/admin/users":
1275
1323
  self._api_admin_list_users()
1276
1324
  elif path == "/api/admin/rag":
@@ -1526,11 +1574,18 @@ class Handler(BaseHTTPRequestHandler):
1526
1574
  self.wfile.write(body)
1527
1575
 
1528
1576
  def _api_change_password(self):
1577
+ """改密。插件形态(免登信任头)下**不校验原口令**(2026-10-08)。
1578
+
1579
+ 为什么:插件形态首装的随机一次性口令只印在启动横幅里(薄壳曾整段丢弃),
1580
+ 存量库的旧口令用户也可能不知道——要求原口令等于把面板锁死。信任头本就等价
1581
+ admin 全权(_current_user),这里放行不新增任何权限面。
1582
+ """
1529
1583
  user = self._current_user()
1530
1584
  data = self._read_json() or {}
1531
1585
  old_pw = _pw_str(data.get("old_password"))
1532
1586
  new_pw = _pw_str(data.get("new_password"))
1533
- if not auth.verify_password(old_pw, user["pass_hash"], user["salt"]):
1587
+ if not self._trusted_admin_request() and \
1588
+ not auth.verify_password(old_pw, user["pass_hash"], user["salt"]):
1534
1589
  self._respond(400, '{"error":"原密码错误"}'.encode("utf-8"),
1535
1590
  "application/json; charset=utf-8")
1536
1591
  return
@@ -1678,11 +1733,17 @@ class Handler(BaseHTTPRequestHandler):
1678
1733
 
1679
1734
  def _api_me_feishu_cfg_get(self):
1680
1735
  """当前用户飞书配置读取(所有用户可访问,各看各的):
1681
- webhook 打码、secret 只回布尔 + 本人入站长连接状态。"""
1736
+ webhook 打码、secret 只回布尔 + 本人入站长连接状态。
1737
+
1738
+ `push_enabled` = 推送总控(2026-10-10 新增,默认 true)——与旧 `enabled`
1739
+ (只管「未单独设置的项目按默认事件推送」那条回落腿 + 入站连接)是两个开关,
1740
+ 前端把两者分别渲染并各自加文案说明。"""
1682
1741
  user = self._current_user()
1683
1742
  cfg = feishu.user_config(user["id"])
1684
1743
  self._respond(200, json.dumps({
1685
1744
  "enabled": bool(cfg.get("enabled", True)),
1745
+ # 缺键按开回显,与 feishu.notify_events 的判定口径一致(存量配置零迁移)
1746
+ "push_enabled": bool(cfg.get("push_enabled", True)),
1686
1747
  "default_webhook": self._mask_webhook(cfg.get("default_webhook", "")),
1687
1748
  "has_default_secret": bool(cfg.get("default_secret")),
1688
1749
  "base_url": cfg.get("base_url", ""),
@@ -1694,24 +1755,56 @@ class Handler(BaseHTTPRequestHandler):
1694
1755
  }, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
1695
1756
 
1696
1757
  def _api_me_feishu_cfg_set(self, body):
1697
- """当前用户飞书配置更新:字段缺省=不改,显式空串=清除
1698
- (前端对未编辑的 secret/webhook 输入框不下发该键,防误清)。
1699
- 应用凭据齐全时尝试拉起本用户入站长连接:首次配置免重启即时生效;
1700
- 已在运行的(变更凭据)为幂等 no-op,需重启站点重建。inbound_started 供前端提示。"""
1758
+ """当前用户飞书配置更新(业务在 feishu.save_user_config:字段缺省=不改、
1759
+ 显式空串=清除;凭据齐全时拉起入站长连接并**即时快检**)。
1760
+ 响应 = 既有字段 + verify(快检结果;凭据不全时 null)——前端据此即时提示对错。"""
1701
1761
  user = self._current_user()
1702
- cfg = feishu.user_config(user["id"])
1703
- if "enabled" in body:
1704
- cfg["enabled"] = 1 if body["enabled"] else 0
1705
- for key in ("default_webhook", "default_secret", "base_url",
1706
- "app_id", "app_secret"):
1707
- if key in body:
1708
- cfg[key] = str(body[key] or "").strip()
1709
- db.set_feishu_user_cfg(user["id"], cfg)
1710
- started = feishu.start_inbound_for(user["id"])
1762
+ out = feishu.save_user_config(user["id"], body)
1711
1763
  self._respond(200, json.dumps(
1712
- {"ok": True, "inbound_started": bool(started)},
1764
+ {"ok": True, **out},
1713
1765
  ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
1714
1766
 
1767
+ def _api_me_feishu_provision_get(self):
1768
+ """扫码建应用流程现状(设置页轮询口,只读):能力/总闸/状态/确认链接/步骤。"""
1769
+ user = self._current_user()
1770
+ self._respond(200, json.dumps(feishu.provision_status(user["id"]),
1771
+ ensure_ascii=False).encode("utf-8"),
1772
+ "application/json; charset=utf-8")
1773
+
1774
+ def _api_me_feishu_provision_set(self, body):
1775
+ """扫码建应用四动作(全部返回 200 + 摘要,页面据此渲染引导;非法 action 400):
1776
+ start=发起(force 覆盖已有应用)/ cancel=取消 / apply=补齐机器人能力+权限+
1777
+ 长连接订阅+事件+回调 / publish=提交发布(版本号缺省自动递增)。"""
1778
+ user = self._current_user()
1779
+ action = str(body.get("action") or "")
1780
+ uid = user["id"]
1781
+ if action == "start":
1782
+ out = feishu.provision_start(uid, force=bool(body.get("force")))
1783
+ elif action == "cancel":
1784
+ out = feishu.provision_cancel(uid)
1785
+ elif action == "apply":
1786
+ out = feishu.provision_apply(uid)
1787
+ elif action == "publish":
1788
+ out = feishu.provision_publish(uid,
1789
+ version=str(body.get("version") or "") or None,
1790
+ remark=str(body.get("remark") or ""),
1791
+ changelog=str(body.get("changelog") or ""))
1792
+ else:
1793
+ self._respond(400, json.dumps(
1794
+ {"error": "action 仅支持 start / cancel / apply / publish"},
1795
+ ensure_ascii=False).encode("utf-8"),
1796
+ "application/json; charset=utf-8")
1797
+ return
1798
+ self._respond(200, json.dumps(out, ensure_ascii=False).encode("utf-8"),
1799
+ "application/json; charset=utf-8")
1800
+
1801
+ def _api_me_feishu_doctor(self, body):
1802
+ """配置自检(八项):只读为主;send_probe=true 时额外发一条测试消息(60s 频控)。"""
1803
+ user = self._current_user()
1804
+ out = feishu.doctor(user["id"], send_probe=bool(body.get("send_probe")))
1805
+ self._respond(200, json.dumps(out, ensure_ascii=False).encode("utf-8"),
1806
+ "application/json; charset=utf-8")
1807
+
1715
1808
  def _api_me_feishu_outbox(self):
1716
1809
  """当前用户的最近投递记录(target 打码——URL 本身含 token 即凭据)。"""
1717
1810
  user = self._current_user()
@@ -1965,6 +2058,58 @@ class Handler(BaseHTTPRequestHandler):
1965
2058
  ",".join(cur_events), 1 if cur_enabled else 0)
1966
2059
  self._respond(200, b'{"ok":true}', "application/json; charset=utf-8")
1967
2060
 
2061
+ def _api_me_feishu_hook_apply(self, body):
2062
+ """项目推送绑定**批量应用**(设置页「同时应用到我的全部项目」,2026-10-10)。
2063
+
2064
+ 把一份配置写进当前用户**本人的全部未归档项目**(归档项目不再产生推送,
2065
+ 故不纳入;跨用户项目一律不碰——多用户隔离红线)。字段语义与单项目端点
2066
+ `_api_feishu_hook_set` 完全对齐:**缺省=不改该字段**,因此前端只在输入框
2067
+ 有内容时才带 `webhook_url`/`webhook_secret` 键 ⇒「留空 = 各项目保留原值」,
2068
+ 只统一「启用推送」与事件勾选;显式传空串=清除。`events` 走白名单校验。
2069
+
2070
+ 返回 {ok, updated, archived_skipped, projects}:projects = 实际写入的项目名
2071
+ (供前端回执列出「改了什么」)。
2072
+ """
2073
+ user = self._current_user()
2074
+ req_events = None
2075
+ if "events" in body:
2076
+ req_events = body["events"]
2077
+ if not isinstance(req_events, list) \
2078
+ or not set(req_events) <= set(feishu.FEISHU_EVENTS):
2079
+ self._respond(400, '{"error":"events 含非法事件"}'.encode("utf-8"),
2080
+ "application/json; charset=utf-8")
2081
+ return
2082
+ names, skipped = [], 0
2083
+ for proj in db.list_projects(user["id"]):
2084
+ if proj["archived"]:
2085
+ skipped += 1
2086
+ continue
2087
+ row = db.get_feishu_hook(proj["id"])
2088
+ cur_url = (row["webhook_url"] if row is not None else "")
2089
+ cur_secret = (row["webhook_secret"] if row is not None else "")
2090
+ cur_enabled = (bool(row["enabled"]) if row is not None else True)
2091
+ if row is not None:
2092
+ cur_events = [e for e in feishu.FEISHU_EVENTS
2093
+ if e in (row["events"] or "")]
2094
+ else:
2095
+ # 无绑定行的默认事件集与设置页回显一致(卡片待审核默认关)
2096
+ cur_events = list(feishu.FEISHU_DEFAULT_EVENTS)
2097
+ if "webhook_url" in body:
2098
+ cur_url = str(body["webhook_url"] or "").strip()
2099
+ if "webhook_secret" in body:
2100
+ cur_secret = str(body["webhook_secret"] or "").strip()
2101
+ if req_events is not None:
2102
+ cur_events = req_events
2103
+ if "enabled" in body:
2104
+ cur_enabled = bool(body["enabled"])
2105
+ db.set_feishu_hook(proj["id"], cur_url, cur_secret,
2106
+ ",".join(cur_events), 1 if cur_enabled else 0)
2107
+ names.append(proj["name"])
2108
+ self._respond(200, json.dumps(
2109
+ {"ok": True, "updated": len(names), "archived_skipped": skipped,
2110
+ "projects": names}, ensure_ascii=False).encode("utf-8"),
2111
+ "application/json; charset=utf-8")
2112
+
1968
2113
  # ---------- 个人飞书绑定(M2 入站身份映射) ----------
1969
2114
 
1970
2115
  def _api_me_feishu_bindcode(self):
@@ -2807,8 +2952,29 @@ class Handler(BaseHTTPRequestHandler):
2807
2952
  # 压缩新建后的绑定)也要归档——I2「done 卡全部绑定会话都归档」。
2808
2953
  # best-effort:失败只记提示 + 退避重试,不回滚绑定。
2809
2954
  board.archive_bound_session(db.get_board_card(card_id), newly_bound)
2810
- self._respond(200, json.dumps(board.card_json(db.get_board_card(card_id)),
2811
- ensure_ascii=False).encode("utf-8"),
2955
+ rename_info = None
2956
+ if "title" in fields and fields["title"].strip() != (card["title"] or "").strip():
2957
+ # 卡面改名 → 同步改 DSH 会话名(2026-10-10 用户需求):只认**改后的主会话**
2958
+ # (同一次 PATCH 里换绑/解绑以新状态为准),best-effort——失败不改卡面,
2959
+ # 只把原因回给前端(`session_rename.ok=false`)。
2960
+ fresh = db.get_board_card(card_id)
2961
+ sid = str((fresh["session_id"] if fresh else "") or "")
2962
+ if sid:
2963
+ ok, err, accepted = board.rename_card_session(sid, fields["title"].strip())
2964
+ rename_info = {"ok": ok, "session_id": sid}
2965
+ if not ok:
2966
+ rename_info["error"] = err
2967
+ elif accepted and accepted != fields["title"]:
2968
+ # 宿主规范化/截断(`normalizeSessionTitle` 的 UTF-8 字节预算,实测
2969
+ # 约 80 字节):**只如实上报,不回写卡面**——卡面是用户输入,平台不
2970
+ # 替用户缩短内容(2026-10-10 真机教训:回写会把长标题静默截断,
2971
+ # 用户原文丢失)。前端据 `accepted_title` 提示「DSH 侧标题被截断」,
2972
+ # 由用户自行决定是否缩短卡名。
2973
+ rename_info["accepted_title"] = accepted
2974
+ payload = board.card_json(db.get_board_card(card_id))
2975
+ if rename_info:
2976
+ payload["session_rename"] = rename_info
2977
+ self._respond(200, json.dumps(payload, ensure_ascii=False).encode("utf-8"),
2812
2978
  "application/json; charset=utf-8")
2813
2979
 
2814
2980
  def _api_board_delete_card(self, project_id, card_id):
@@ -2904,7 +3070,8 @@ class Handler(BaseHTTPRequestHandler):
2904
3070
  """标记卡片已查看(POST /api/projects/<pid>/board/cards/<cid>/viewed)。
2905
3071
 
2906
3072
  2026-10-07 批次「卡片状态有更新·用户还没打开过 ⇒ 卡面打标记」的清除端:
2907
- 前端打开卡片详情(点击卡面正文 / 详情按钮)时调用,清 `board_cards.unread`。
3073
+ 前端打开卡片详情(点击卡面正文 / 详情按钮)或点卡面操作行上任意一个按钮时调用,
3074
+ 清 `board_cards.unread`。
2908
3075
  幂等:本来就未读/标记已被别的标签页清掉都回 200;`changed` 说明本次是否真的
2909
3076
  清了标记(不清就不发看板变更信号,前端也不必重取)。归属校验走
2910
3077
  `_board_owned`(多用户隔离红线:越权访问他人卡片一律 404)。
@@ -2936,12 +3103,18 @@ class Handler(BaseHTTPRequestHandler):
2936
3103
  before_id = int(body["before_id"]) if body.get("before_id") is not None else None
2937
3104
  except (TypeError, ValueError):
2938
3105
  before_id = None
3106
+ # merge_ack(2026-10-07 批次):独立 worktree 卡进「已完成」的待合并闸。
3107
+ # 默认 false ⇒ 有待合并提交时 move_card 回 {"merge_pending": ...} 且列不动,
3108
+ # 前端弹「交给 agent 合并 / 仅通过」;用户选「仅通过」时前端带 true 再来一次,
3109
+ # 后端跳过该闸(= 旧行为,分支保留)。
2939
3110
  card_dict, err = board.move_card(row, card_id, target,
2940
3111
  (body.get("block_text") or "")[:2000],
2941
- before_id=before_id)
3112
+ before_id=before_id,
3113
+ merge_ack=bool(body.get("merge_ack")))
2942
3114
  if err:
2943
- if "blocked" in err: # 父任务拦截:前端弹确认框,不算错误
2944
- self._respond(200, json.dumps(err).encode("utf-8"),
3115
+ if "blocked" in err or "merge_pending" in err:
3116
+ # 父任务拦截 / 合并询问:前端弹确认框,不算错误(列未变)
3117
+ self._respond(200, json.dumps(err, ensure_ascii=False).encode("utf-8"),
2945
3118
  "application/json; charset=utf-8")
2946
3119
  return
2947
3120
  self._respond(400, json.dumps(err, ensure_ascii=False).encode("utf-8"),
@@ -3017,6 +3190,28 @@ class Handler(BaseHTTPRequestHandler):
3017
3190
  return
3018
3191
  self._respond(200, b'{"ok": true}', "application/json; charset=utf-8")
3019
3192
 
3193
+ def _api_board_card_worktree_merge(self, project_id, card_id):
3194
+ """把 worktree 改动回流主分支的合并任务交给卡片会话(2026-10-07 批次)。
3195
+
3196
+ `POST .../board/cards/<id>/worktree/merge`。「通过」时若判定有待合并提交
3197
+ (move 端点回 `merge_pending`),前端弹框让用户选——这条端点即「交给 agent
3198
+ 合并」:平台只投递指令 + 把卡片送回统一队列,agent 在卡片会话里执行同步
3199
+ 主分支与合并回流(冲突由 agent 解),干完这轮卡片自动回待审核,用户再点
3200
+ 「通过」时已无待合并提交、直接进「已完成」。
3201
+ 硬错误 400(无待合并提交 / 无主会话 / 工作树读不到);卡片运行中由
3202
+ `board._enter_doing` 的入口预检拦下(同样 400 带中文原因)。
3203
+ """
3204
+ row, card = self._board_owned(project_id, card_id)
3205
+ if row is None:
3206
+ return
3207
+ card_dict, err = board.handoff_worktree_merge(row, card)
3208
+ if err:
3209
+ self._respond(400, json.dumps(err, ensure_ascii=False).encode("utf-8"),
3210
+ "application/json; charset=utf-8")
3211
+ return
3212
+ self._respond(200, json.dumps({"card": card_dict}, ensure_ascii=False).encode("utf-8"),
3213
+ "application/json; charset=utf-8")
3214
+
3020
3215
  def _api_board_compact(self, project_id, card_id, body):
3021
3216
  """卡片会话 compact(压缩上下文)。busy 拒绝(409);族不支持 400;
3022
3217
  CLI 族异步触发即返回(日志落工作目录 .web/board_compact_*)。"""
@@ -3128,10 +3323,11 @@ class Handler(BaseHTTPRequestHandler):
3128
3323
  try:
3129
3324
  # inject=true 时立即注入运行中的当前轮(dsh 等 steer 能力会话生效);
3130
3325
  # 项目忙(同项目有单元在跑)时登记统一队列消息单元,返回 queued=true
3131
- #(前端显示「排队中」,注释见 board.deliver_comment)
3326
+ #(前端显示「排队中」,注释见 board.deliver_comment);投递文本恒为评论
3327
+ # 原文、不包【看板评论】前缀(2026-10-10 去前缀;旧客户端多传的 body.raw
3328
+ # 字段静默忽略,不再透传)
3132
3329
  rec = board.deliver_comment(row, card, target,
3133
- inject=bool(body.get("inject")),
3134
- raw=bool(body.get("raw")))
3330
+ inject=bool(body.get("inject")))
3135
3331
  except dshdriver.DshDriverError as e:
3136
3332
  # 409 = 会话运行中(deliver_comment 的忙拒绝);其余 code 按 500
3137
3333
  if e.args and e.args[0] == 409:
@@ -3292,17 +3488,26 @@ class Handler(BaseHTTPRequestHandler):
3292
3488
  # 卡片当前所在列(会话窗标题「卡片队列」徽标,键值同看板列:todo/doing/
3293
3489
  # blocked/review/done;前端转列名展示,随 2s 轮询跟随卡片移列)
3294
3490
  data["card_column"] = owner["column_key"]
3491
+ # 卡片是否跑在独立 worktree(2026-10-07 批次):会话窗「通过」要按
3492
+ # 看板同款口径弹「是否交给 agent 合并」(空串=普通卡)
3493
+ data["card_worktree"] = str(db.row_opt(owner, "worktree") or "").strip()
3295
3494
  # 已作答·待送达(答案排队,2026-09-14):前端在输入区上方渲染「待送达」行
3296
3495
  # 与「立即送达」按钮(POST .../cards/<cid>/answer/deliver,不等项目空闲)
3297
3496
  data["answer_pending"] = board.is_answer_pending(owner_cid)
3298
3497
  data["interaction"] = board.interaction_of_sid(sid)
3498
+ # 会话归属三态(C 批 T8):前端按 `owned === false` 出「外部会话」提示条并把
3499
+ # 「停止」置灰;`None`=注册表未知按「池内」渲染(判定口径见 _session_owned)
3500
+ data["owned"] = _session_owned(sid)
3299
3501
  data["family"] = family
3300
3502
  if family == "dsh_plugin":
3301
- # P6:ctx 圈数据源=EventHub 的 usage(事件推来,零请求)。
3302
- # dsh 的 TokenUsage 没有窗口上限 ⇒ max 缺省 None(前端不画环)。
3503
+ # 上下文圈(2026-10-10 修「恒为 0」):分子/分母都随上面的
3504
+ # `sessparse.load` 从会话存储的两个 last-wins 槽位带出来
3505
+ # (used=最新 usage 的 prompt 侧 token 数,max=最新
3506
+ # `request/context` 的 contextWindow,见 sessparse._dsh_prompt_tokens),
3507
+ # 此处**不再**用 EventHub 的 usage 覆盖:那条只有 total 口径、且插件
3508
+ # 后端一重启就清零;会话文件是持久的,本来就在本次请求里解析过
3509
+ # (零额外解压,`data["ctx"]` 由 load 结果带出,窗口未知时不带该键)。
3303
3510
  st = dshevents.get(sid)
3304
- if st and st.get("usage"):
3305
- data["ctx"] = {"used": st["usage"].get("total") or 0, "max": None}
3306
3511
  # 会话级模型(会话窗「模型」展示):事件流里学到就用它,否则留空
3307
3512
  # 由前端回落「项目默认模型」(既有兜底链)
3308
3513
  sel = (st or {}).get("model") or {}
@@ -4772,6 +4977,14 @@ class Handler(BaseHTTPRequestHandler):
4772
4977
  ensure_ascii=False).encode("utf-8"),
4773
4978
  "application/json; charset=utf-8")
4774
4979
  return
4980
+ except RuntimeError as e:
4981
+ # 投递前置闸拒投(chat.DeliveryRefused:外部会话前提不成立,C 批 T5/T8)——
4982
+ # 消息未入队、未落行,回 400 + 闸的中文文案(口径同 _api_board_session_comment)。
4983
+ # 必须排在 dshdriver.DshDriverError 之后:它也是 RuntimeError 子类,先命中 409/500;
4984
+ # 裸 RuntimeError 若逃逸,do_POST 无兜底 ⇒ 用户拿到连接重置而不是要求的明确文案。
4985
+ self._respond(400, json.dumps({"error": str(e)}, ensure_ascii=False).encode("utf-8"),
4986
+ "application/json; charset=utf-8")
4987
+ return
4775
4988
  self._respond(200, json.dumps(
4776
4989
  {"ok": True, "queued": bool(rec.get("queued")),
4777
4990
  "msg_id": rec.get("id") or "", "state": rec.get("state") or "",
@@ -4992,20 +5205,36 @@ class Handler(BaseHTTPRequestHandler):
4992
5205
 
4993
5206
  返回 True 表示已回 403、调用方直接 return。前端据 /api/auth/me 的
4994
5207
  must_change_password 渲染改密门;此处是服务端兜底(防绕过前端直调 API)。
5208
+
5209
+ 例外(2026-10-08,插件形态死锁修复):免登信任头路径整体跳过此闸。
5210
+ 插件形态下用户拿不到随机一次性口令(薄壳曾把横幅整段丢弃),拦在这里只会
5211
+ 把面板锁死——而信任头本已等价 admin(见 _current_user),跳过不降安全等级;
5212
+ 存量库 must_change_pw=1 在插件形态亦据此不再锁死,用户进面板后可在设置页设密码。
4995
5213
  """
4996
5214
  if not user or not user["must_change_pw"]:
4997
5215
  return False
5216
+ if self._trusted_admin_request():
5217
+ return False
4998
5218
  if path in MUST_CHANGE_PW_ALLOW:
4999
5219
  return False
5000
5220
  self._respond(403, '{"error":"请先修改初始口令"}'.encode("utf-8"),
5001
5221
  "application/json; charset=utf-8")
5002
5222
  return True
5003
5223
 
5224
+ def _trusted_admin_request(self):
5225
+ """本次请求是否由薄壳反代注入的免登信任头判为 admin(--trust-internal-user)。
5226
+
5227
+ 仅回环监听 + 显式开关时才可能为真(main() 已限制);该头等价 admin 全权,
5228
+ 故以此为据的放行(跳过强制改密门、免原口令改密)不新增任何权限面。
5229
+ """
5230
+ return bool(TRUST_INTERNAL_USER
5231
+ and self.headers.get(TRUST_USER_HEADER) == "admin")
5232
+
5004
5233
  def _current_user(self):
5005
5234
  """按 cookie 取会话用户,无效返回 None。"""
5006
5235
  # dsh 插件形态免登:--trust-internal-user 时反代注入的信任头视为 admin 登录
5007
5236
  # (main() 已限制仅回环监听可开启);后续 _owned_project 等隔离逻辑不变
5008
- if TRUST_INTERNAL_USER and self.headers.get(TRUST_USER_HEADER) == "admin":
5237
+ if self._trusted_admin_request():
5009
5238
  admin = db.get_user_by_name("admin")
5010
5239
  if admin is not None:
5011
5240
  return admin
@@ -5267,6 +5496,11 @@ def main():
5267
5496
  # 状态流),再起调和器——调和器改为等它的事件唤醒,不再固定 5s 轮询 dsh。
5268
5497
  # 独立形态(未下发驱动地址)下 start() 直接返回 False,不起线程。
5269
5498
  dshevents.start()
5499
+ # 启动补跑(2026-10-07 僵尸 ext 行实障修复):上面 board.recover() 里的 ext 行
5500
+ # 对账执行时中枢尚未连接 ⇒ 探测「不可用」、一律保行;上一进程留下的僵尸占用行
5501
+ # (会话已结束、行仍 running)会继续堵死该项目补位,重启也救不回来。这里等首连
5502
+ # 就绪后补跑一次对账(后台线程,不阻塞启动与端口绑定;未就绪直接结束)。
5503
+ board.start_ext_recover_after_connect()
5270
5504
  board.start_interaction_watcher()
5271
5505
  runner.start_unit_selfcheck() # 等待项周期自检线程(首轮宽限+年龄豁免),替代已退场的影子比对线程
5272
5506
  feishu.start_notifier() # 飞书 outbox 投递线程(未配置时空转)