@gavinc-cn/touchstone-dsh 0.2.0 → 0.4.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 (116) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +23 -15
  3. package/README.zh.md +17 -12
  4. package/auth.py +0 -0
  5. package/backend/lib/access.js +44 -0
  6. package/backend/lib/agents.js +341 -0
  7. package/backend/lib/args.js +94 -0
  8. package/backend/lib/auth.js +195 -0
  9. package/backend/lib/board.js +6227 -0
  10. package/backend/lib/builtin-assets.js +1177 -0
  11. package/backend/lib/casescan.js +456 -0
  12. package/backend/lib/chat.js +2090 -0
  13. package/backend/lib/cookie.js +76 -0
  14. package/backend/lib/db.js +2260 -0
  15. package/backend/lib/dshdriver.js +1148 -0
  16. package/backend/lib/dshevents.js +851 -0
  17. package/backend/lib/export-cases.js +261 -0
  18. package/backend/lib/feishu.js +1747 -0
  19. package/backend/lib/feishu_cfg.js +262 -0
  20. package/backend/lib/feishu_conv.js +1484 -0
  21. package/backend/lib/feishu_device.js +428 -0
  22. package/backend/lib/feishu_inbound.js +2077 -0
  23. package/backend/lib/feishu_provision.js +886 -0
  24. package/backend/lib/feishu_rest.js +398 -0
  25. package/backend/lib/feishu_slash.js +568 -0
  26. package/backend/lib/feishu_ws.js +950 -0
  27. package/backend/lib/http.js +312 -0
  28. package/backend/lib/lib.js +973 -0
  29. package/backend/lib/loadcase.js +749 -0
  30. package/backend/lib/loadgen.js +1407 -0
  31. package/backend/lib/localbus.js +164 -0
  32. package/backend/lib/lock.js +426 -0
  33. package/backend/lib/proc.js +361 -0
  34. package/backend/lib/prompts.js +1021 -0
  35. package/backend/lib/pybase64.js +114 -0
  36. package/backend/lib/pyjson.js +214 -0
  37. package/backend/lib/query.js +125 -0
  38. package/backend/lib/rag.js +1310 -0
  39. package/backend/lib/runner.js +4546 -0
  40. package/backend/lib/seed_hint.js +40 -0
  41. package/backend/lib/servercaps.js +182 -0
  42. package/backend/lib/serverstate.js +227 -0
  43. package/backend/lib/sessparse.js +1176 -0
  44. package/backend/lib/sse.js +207 -0
  45. package/backend/lib/static.js +84 -0
  46. package/backend/lib/taskjson.js +302 -0
  47. package/backend/lib/waitq.js +2114 -0
  48. package/backend/lib/worktree.js +588 -0
  49. package/backend/lib/zstd-frames.js +218 -0
  50. package/backend/routes/admin.js +553 -0
  51. package/backend/routes/agents.js +149 -0
  52. package/backend/routes/assets.js +145 -0
  53. package/backend/routes/board.js +1228 -0
  54. package/backend/routes/feishu.js +269 -0
  55. package/backend/routes/feishu_app.js +249 -0
  56. package/backend/routes/feishu_hook.js +358 -0
  57. package/backend/routes/index.js +298 -0
  58. package/backend/routes/projects.js +1138 -0
  59. package/backend/routes/sessions.js +1003 -0
  60. package/backend/routes/tasks.js +1530 -0
  61. package/backend/schema/fixtures/ancient.sql +189 -0
  62. package/backend/schema/fixtures/pre_alter.sql +177 -0
  63. package/backend/schema/migrations.json +154 -0
  64. package/backend/schema/schema.json +1451 -0
  65. package/backend/schema/schema.sql +216 -0
  66. package/backend/server.js +505 -0
  67. package/board.py +96 -13
  68. package/builtin_assets.py +0 -0
  69. package/builtin_prompts/free_style/flow.md +0 -0
  70. package/builtin_prompts/free_style/load_test.md +0 -0
  71. package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
  72. package/builtin_prompts/free_style/templates/case_template.md +0 -0
  73. package/builtin_prompts/free_style/templates/index_template.md +0 -0
  74. package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
  75. package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
  76. package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
  77. package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
  78. package/builtin_prompts/free_style/templates/status_template.md +0 -0
  79. package/chat.py +33 -0
  80. package/db.py +294 -129
  81. package/dsh-plugin/cordis.patch.yml +0 -0
  82. package/dsh-plugin/install.sh +22 -8
  83. package/dsh-plugin/lib/client.bundle.js +111 -2
  84. package/dsh-plugin/lib/index.js +340 -29
  85. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  86. package/dsh-plugin/scripts/build.mjs +0 -0
  87. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  88. package/dsh-plugin/scripts/select-bundle.mjs +0 -0
  89. package/dsh-plugin/src/client.js +111 -2
  90. package/dshevents.py +0 -0
  91. package/export_cases.py +0 -0
  92. package/extensions/dsh-autocommit/asset.json +0 -0
  93. package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
  94. package/extensions/dsh-autocommit/pkg/package.json +0 -0
  95. package/feishu.py +417 -3
  96. package/feishu_conv.py +0 -0
  97. package/lib.py +0 -0
  98. package/lifecycle.py +116 -13
  99. package/loadcase.py +0 -0
  100. package/loadgen.py +0 -0
  101. package/localbus.py +0 -0
  102. package/migrate_p7b.py +0 -0
  103. package/package.json +6 -1
  104. package/platcompat.py +0 -0
  105. package/prompts.py +0 -0
  106. package/rag.py +0 -0
  107. package/requirements.txt +0 -0
  108. package/server.py +155 -11
  109. package/touchstone.py +0 -0
  110. package/waitq.py +0 -0
  111. package/webui/dist-plugin/assets/index-3QsYQi7y.css +1 -0
  112. package/webui/dist-plugin/assets/index-B8AMtW7y.js +486 -0
  113. package/webui/dist-plugin/index.html +2 -2
  114. package/worktree.py +0 -0
  115. package/webui/dist-plugin/assets/index-JLwyZZxC.js +0 -484
  116. package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
package/board.py CHANGED
@@ -96,6 +96,30 @@ def card_workspace(project, card):
96
96
  return wt or project["project_dir"]
97
97
 
98
98
 
99
+ def card_ts_ms(ts_str):
100
+ """库内时间串(`YYYY-MM-DD HH:MM:SS`,db.now_str 口径=**服务器本地时间**)→ 毫秒 epoch。
101
+
102
+ 卡面「最后更新时间」(2026-10-10 批次)的下发形态:库里存的是无时区标记的本地
103
+ 时间串(本容器=UTC,Windows 原生运行=+8),前端若直接 `new Date(串)` 会按浏览器
104
+ 本地时区解析 ⇒ 在 UTC 服务器 + 北京浏览器下整整差 8 小时。故一律在服务端换算成
105
+ epoch(与前端既有的 `scheduled_at`/`last_error_at` 同口径),前端只管按浏览器
106
+ 时区渲染。
107
+
108
+ 解释口径必须与写入口径互逆:`db.now_str` 用 `time.strftime`(本地时间),此处用
109
+ `time.mktime`(同按本地时区反解)——Linux(UTC)/Windows(+8) 下都自洽,见
110
+ tests/test_board_card_time.py 的 TZ 钉子用例。
111
+
112
+ 坏值/空值返回 None(前端不渲染该小字),绝不抛异常打穿读板路径;不用 0 兜底
113
+ (0 会被前端当 1970 年渲染)。
114
+ """
115
+ if not ts_str:
116
+ return None
117
+ try:
118
+ return int(time.mktime(time.strptime(ts_str, "%Y-%m-%d %H:%M:%S"))) * 1000
119
+ except (TypeError, ValueError, OverflowError, OSError):
120
+ return None
121
+
122
+
99
123
  def card_json(row, running=False, busy=False, msg_queued=False, queue_state=None):
100
124
  """卡片 Row -> 前端 dict。running=平台在管运行;busy=会话级运行
101
125
  (平台运行或外部 busy——用户在 dsh GUI 直跑的同步卡会话,前端徽标
@@ -112,7 +136,11 @@ def card_json(row, running=False, busy=False, msg_queued=False, queue_state=None
112
136
  (queue_state_of 判定序最高级即 answer 在场 ⟺ answer_pending,免二次点查)。
113
137
  worktree(2026-10-06 批次):独立 worktree 卡的落点绝对路径,空串=普通卡
114
138
  (前端据此渲染 🌿 徽标与详情行;只写不改口径见 plan D3)——经 `_card_opt`
115
- 读取,兼容无该列的老库。"""
139
+ 读取,兼容无该列的老库。
140
+ updated_at_ts(2026-10-10 批次):`updated_at` 的毫秒 epoch 形态(`card_ts_ms`
141
+ 换算,服务器本地时间解释),供卡面渲染「最后更新时间」的相对时间/本地绝对时间;
142
+ 语义务必与 `updated_at` 一致=**卡片行最后被改写的时刻**(agent 在会话里干活不刷
143
+ 它,会话活动时间是另一回事)。坏值/空值 ⇒ None,前端不渲染该小字。"""
116
144
  if queue_state is None:
117
145
  answer = is_answer_pending(row["id"])
118
146
  queue_state = queue_state_of(row, running, busy, msg_queued, answer=answer)
@@ -130,6 +158,7 @@ def card_json(row, running=False, busy=False, msg_queued=False, queue_state=None
130
158
  "last_error": row["last_error"], "last_error_at": row["last_error_at"],
131
159
  "worktree": _card_opt(row, "worktree"),
132
160
  "created_at": row["created_at"], "updated_at": row["updated_at"],
161
+ "updated_at_ts": card_ts_ms(row["updated_at"]),
133
162
  # 状态有更新·用户未打开(2026-10-07 批次):卡面打「有更新」标记的依据,
134
163
  # 置位/清除全在 db 层派生维护,读侧只透传布尔值。
135
164
  "unread": bool(_card_opt(row, "unread") or 0),
@@ -1528,6 +1557,11 @@ _runs_lock = threading.Lock()
1528
1557
  # 单卡片 prompt 长度上限(与 chat.MESSAGE_MAX 对齐)
1529
1558
  PROMPT_MAX = 20000
1530
1559
 
1560
+ # 平台任务提示词的段标(`build_task_prompt` 的首段)。除拼提示词外还用作**判据**:
1561
+ # 会话首问以它开头 ⇒ 这个会话是平台按某张卡片的内容拼提示词启动的(2026-10-16 起,
1562
+ # 这类卡的标题/描述也跟随 DSH,见 `_is_platform_task_session`)。
1563
+ TASK_PROMPT_TAG = "【任务描述】"
1564
+
1531
1565
 
1532
1566
  def build_task_prompt(project, card, extra=""):
1533
1567
  """全量任务提示词:任务描述(卡片标题并入描述)+ 工作目录 + 附加段。
@@ -1551,7 +1585,7 @@ def build_task_prompt(project, card, extra=""):
1551
1585
  desc = (card["description"] or "").strip()
1552
1586
  body = "\n\n".join(seg for seg in (title, desc) if seg) or "(无)"
1553
1587
  workspace = card_workspace(project, card)
1554
- p = (f"【任务描述】{body}\n"
1588
+ p = (f"{TASK_PROMPT_TAG}{body}\n"
1555
1589
  f"【工作目录】{workspace}")
1556
1590
  wt = (_card_opt(card, "worktree") or "").strip()
1557
1591
  if wt:
@@ -2275,6 +2309,17 @@ def compact_session(project, card, sid):
2275
2309
  外部会话的 agent 已在宿主 store 注册,`/session` resume 必撞
2276
2310
  `agent "…" is already registered` ⇒ 500(C 批 T1 实测)。
2277
2311
 
2312
+ **会话存活前置(2026-10-17 修卡 907「TS 侧 /compact 对已结束会话必失败」)**:
2313
+ 上面那条「不能先接管」只对**宿主里仍活着**的会话成立;对宿主里**根本没有活 agent**
2314
+ 的会话(dsh web 重启、插件重载、久未接续的旧卡——用户报障卡 907 就是 9 天前的
2315
+ 平台自建卡),驱动 `/compact` 必 404「会话已结束(宿主无活动 agent)」,上面两跳
2316
+ 再怎么补声明也救不回来。而 dsh GUI 自己的 `/compact` 对冷会话是**按需 resume 再执行
2317
+ 命令**(`dsh-api-session-controller.resolve` → `agents.resume`),故这里与投递腿
2318
+ `_deliver_now` 同款:先 `_ensure_card_session` 接回(判据/边界同它——中枢未对齐按
2319
+ 「未知」不动、sync 卡不收养、回滚阀 `TS_HOST_SESSION_REVIVE=0` 时不动,均为零请求),
2320
+ 再走下面的压缩两跳。接不回**不在此收口**:照旧走两跳,由驱动 404 文案兜底(与修复前
2321
+ 完全一致)。
2322
+
2278
2323
  sid 必须属于该卡片(sessions 清单或主 session_id),不支持的族抛
2279
2324
  RuntimeError(server 转 400)。
2280
2325
  """
@@ -2285,6 +2330,8 @@ def compact_session(project, card, sid):
2285
2330
  # 单族化:非 dsh 即退场族项目(migrate_p7b 改写后不再出现),
2286
2331
  # 给「改绑 dsh 插件」的明确提示(B0 防呆口径)
2287
2332
  raise RuntimeError(agents.RETIRED_MSG)
2333
+ # 会话存活前置:宿主已失去该会话 ⇒ 先按 resume 语义接回,再压缩(与投递同款)
2334
+ _ensure_card_session(project, card, sid)
2288
2335
  # 路线 A P3(2026-10-03):走驱动 `/compact`(插件侧执行宿主 `/compact`
2289
2336
  # 命令;`ctx.compaction` 服务在插件 ctx 不可注入,命令路径是 P0 探针的结论)。
2290
2337
  # 触发即返回——压缩在 dsh 进程内异步进行(要过一次模型),失败落宿主日志。
@@ -3421,6 +3468,9 @@ SYNC_BUSY_MTIME_S = 120
3421
3468
  # 用户改名后,同步节拍跟着改卡标题)、描述 = 主会话第一次用户提问**全文**。
3422
3469
  # 旧口径(2026-10-07~10-10:「首问首行进标题、其余行进描述」)已退场,存量卡由
3423
3470
  # `_sync_card_follow` 在首个同步拍回填(详见 board spec §48)。
3471
+ # 2026-10-16 扩面(用户诉求「卡面标题/正文与 DSH 保持一致」):同一口径从
3472
+ # `origin='sync'` 卡扩到**平台从看板启动过会话的卡**(判据 = 会话首问是平台拼的
3473
+ # 任务提示词,见 `_is_platform_task_session`);列语义与存量收口仍只服务 sync 卡。
3424
3474
  SYNC_TITLE_MAX = 200 # 标题上限(沿用建卡既有截断:insert_board_card 调用处 [:200])
3425
3475
  SYNC_DESC_MAX = 2000 # 描述上限(防一次长提问把看板负载撑大)
3426
3476
 
@@ -3582,19 +3632,45 @@ def _auto_title_forms(item, sid):
3582
3632
  return forms
3583
3633
 
3584
3634
 
3635
+ def _in_first_prompt(text, item):
3636
+ """卡面字段是否仍是「平台按卡片内容拼进首问」的**原文形态**(判自动写入用)。
3637
+
3638
+ 平台起会话时把卡片标题/描述原样拼进首问(`build_task_prompt`:标题并入
3639
+ 【任务描述】段、与描述空行分隔)⇒ 卡面字段只要没被用户改过,就必然能在首问
3640
+ 原文里原样找到。用户后来改写过的文本通常不在(⇒ 不覆盖,尊重用户编辑)。
3641
+ 空/纯空白一律 False(空标题由 `_auto_title_forms` 那一档负责)。
3642
+ """
3643
+ t = (text or "").strip()
3644
+ return bool(t) and t in (item.get("first_prompt") or "")
3645
+
3646
+
3647
+ def _is_platform_task_session(item):
3648
+ """该会话是否「平台按某张卡片的内容拼提示词启动的」(判据 = 首问以段标开头)。
3649
+
3650
+ 卡片启动腿(`enter_doing` → `build_task_prompt`)拼的首问以 `TASK_PROMPT_TAG`
3651
+ 开头;用户在 dsh GUI 里直跑的会话首问是用户原文、续跑腿是「继续」,都不会命中。
3652
+ 用于把正向同步(DSH 标题/首问 → 卡面)从 sync 卡扩到**平台从看板启动过会话的卡**
3653
+ (2026-10-16 用户诉求:卡面标题/正文与 DSH 保持一致;见 spec/board §48)。
3654
+ """
3655
+ return (item.get("first_prompt") or "").lstrip().startswith(TASK_PROMPT_TAG)
3656
+
3657
+
3585
3658
  def _sync_card_follow(card, item, sid):
3586
- """存量 sync 卡按「DSH 当前标题 + 首问全文」补齐的字段 dict(无变化返回 {})。
3659
+ """卡面按「DSH 当前标题 + 首问全文」补齐的字段 dict(无变化返回 {})。
3587
3660
 
3588
3661
  只认「主会话」:遍历到的会话不是卡的主会话(多会话并集里的子会话)时不动。
3589
- 只在卡仍由平台自动写入时动(卡标题 ∈ `_auto_title_forms`:空 / sid 短码 / 任一枚
3590
- 会话标题事件 / 旧口径的首问首行)——用户在卡面行内改过标题就整张卡不再自动覆盖
3591
- (描述也不动)。描述仅在为空、或仍是**旧口径自动写入**(首问除首行)时改成首问
3592
- 全文;用户写过的描述不覆盖。
3662
+ 只在卡仍由平台自动写入时动——卡标题 ∈ `_auto_title_forms`(空 / sid 短码 / 任一枚
3663
+ 会话标题事件 / 旧口径的首问首行)**或** 仍是平台拼进首问的原文(`_in_first_prompt`,
3664
+ 2026-10-16 增:服务「平台从看板启动过会话的卡」——它的标题是用户在卡里敲的原文,
3665
+ 不在任何标题事件里);用户在卡面行内改过标题就整张卡不再自动覆盖(描述也不动)。
3666
+ 描述仅在为空、仍是**旧口径自动写入**(首问除首行)、或仍是平台拼进首问的原文时
3667
+ 改成首问全文;用户写过的描述不覆盖。
3593
3668
  """
3594
3669
  if (card["session_id"] or sid) != sid:
3595
3670
  return {}
3596
3671
  cur_title = (card["title"] or "").strip()
3597
- if cur_title not in _auto_title_forms(item, sid):
3672
+ if (cur_title not in _auto_title_forms(item, sid)
3673
+ and not _in_first_prompt(cur_title, item)):
3598
3674
  return {}
3599
3675
  title, desc = _sync_card_fields(item, sid)
3600
3676
  out = {}
@@ -3602,7 +3678,8 @@ def _sync_card_follow(card, item, sid):
3602
3678
  out["title"] = title
3603
3679
  cur_desc = (card["description"] or "").strip()
3604
3680
  legacy_desc = _split_first_prompt(item.get("first_prompt") or "")[1]
3605
- if desc and cur_desc != desc and cur_desc in ("", legacy_desc):
3681
+ if (desc and cur_desc != desc
3682
+ and (cur_desc in ("", legacy_desc) or _in_first_prompt(cur_desc, item))):
3606
3683
  out["description"] = desc
3607
3684
  return out
3608
3685
 
@@ -3624,6 +3701,10 @@ def sync_sessions(project):
3624
3701
  todo/blocked/done 后不再自动搬);会话存储被删的 sync 卡自动进 done;绑子代理
3625
3702
  会话的存量 sync 卡收口进回收站(每项目一轮);上述两类误建卡的存量也按同款
3626
3703
  口径收口(每项目一轮,见函数尾注释)。
3704
+ **平台从看板启动过会话的卡**(用户建卡 → 开始/回车启动,首问 = 平台拼的任务提示词,
3705
+ 见 `_is_platform_task_session`)同样按上面同一口径补齐卡面——但**列语义不动**:
3706
+ 归档/取消归档的列映射与各类存量收口仍只服务 `origin='sync'` 卡(用户自己的卡,
3707
+ 平台只同步标题/描述,不替用户搬列、不软删)。用户改过卡名 ⇒ 整卡不动(含描述)。
3627
3708
  sync 卡不占 runner 项目占用(外部会话平台控制不了,防堵死统一队列)。
3628
3709
  """
3629
3710
  if not settings_of(project["id"]).get("sync_sessions", True):
@@ -3670,13 +3751,15 @@ def sync_sessions(project):
3670
3751
  db.update_board_card(cid, session_id=sid, sessions=json.dumps([sid]),
3671
3752
  column_key=col, origin="sync")
3672
3753
  created.append(cid)
3673
- elif c["origin"] == "sync":
3674
- # 首问补齐(2026-10-07):标题/描述仍是平台自动写入形态时按首问覆盖
3675
- # (覆盖「建卡时提问还没到」的竞态与旧口径的存量卡;用户改过名不动)
3754
+ elif c["origin"] == "sync" or _is_platform_task_session(it):
3755
+ # 卡面补齐(2026-10-07 起服务 sync 卡;2026-10-16 起扩到「平台从看板启动过
3756
+ # 会话的卡」,见 `_sync_card_follow`):标题/描述仍是平台自动写入形态时按
3757
+ # DSH 当前标题 + 首问全文覆盖(用户改过名整卡不动)
3676
3758
  fields = _sync_card_follow(c, it, sid)
3677
3759
  if fields:
3678
3760
  db.update_board_card(c["id"], **fields)
3679
- if c["column_key"] in ("doing", "review") and not _has_active_run(c["id"]):
3761
+ if (c["origin"] == "sync" and c["column_key"] in ("doing", "review")
3762
+ and not _has_active_run(c["id"])):
3680
3763
  # dsh 的 busy/空闲 列映射已移交调和器(事件驱动,异常不搬列);
3681
3764
  # 此处保留归档→done(archived 来源=list_sessions 的宿主归档集文件读,
3682
3765
  # 与调和器读驱动帧是两条独立通道:文件读不依赖驱动链路,重启/断连时
package/builtin_assets.py CHANGED
File without changes
File without changes
File without changes
package/chat.py CHANGED
@@ -399,6 +399,39 @@ def cancel_queued(sid=None, card_id=None):
399
399
  return sum(1 for r in rows if cancel(r["id"]))
400
400
 
401
401
 
402
+ def cancel_queued_detail(sid=None, card_id=None, msg_id=None):
403
+ """取消排队中的消息**并回带原文**(`mode=cancel_queue` 端点的实现;用户点「取消排队」)。
404
+
405
+ 与 `cancel_queued` 的差别只有「回带」:后者是停止入口共用的计数壳。用户显式取消排队时,
406
+ 前端要把消息原文放回输入框,而 `msgs_of_sid` 下发给前端的展示文本被截到 120 字——
407
+ 回填必须由服务端给**全量原文**,否则长消息被静默截断。
408
+
409
+ 逐行只在 `cancel()` 真命中(chat_msgs 的 queued→cancelled 原子守卫是仲裁点,R12)
410
+ 时才收进结果:与 worker claim 竞态下已被抢走执行的消息既不会被取消、也不会回带,
411
+ 前端不会把仍在执行的消息错放回输入框。返回按入队顺序(msg_rows 的 created_at,id)。
412
+
413
+ `msg_id` 非空 ⇒ 只取消该条(归属校验由端点层做,本函数只管状态)。
414
+ 返回 `{"cancelled": n, "msgs": [{"id", "text"}]}`;空过滤返回空(不误伤全库)。
415
+ """
416
+ if not sid and not card_id and not msg_id:
417
+ return {"cancelled": 0, "msgs": []}
418
+ if msg_id:
419
+ row = waitq.msg_get(msg_id)
420
+ if row is None or row["state"] != STATE_QUEUED:
421
+ return {"cancelled": 0, "msgs": []}
422
+ rows = [row]
423
+ else:
424
+ rows = waitq.msg_rows(sid=sid, card_id=card_id, states=(STATE_QUEUED,))
425
+ msgs = []
426
+ for r in rows:
427
+ # 先取原文再取消:取消是终态迁移,但仍按「命中才回带」的次序写,读一次即用一次
428
+ text = r["message"] if r["message"] is not None else ""
429
+ if not cancel(r["id"]):
430
+ continue
431
+ msgs.append({"id": r["id"], "text": str(text)})
432
+ return {"cancelled": len(msgs), "msgs": msgs}
433
+
434
+
402
435
  def msgs_of_sid(sid):
403
436
  """本会话消息的展示视图(排队/执行中 + 近期失败):{id,text,state,error,created_at}。
404
437