@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
@@ -0,0 +1,4546 @@
1
+ /**
2
+ * 任务执行引擎(`runner.py` 2482 行的 Node 对应物):**M3 只读口 + M4 调度内核**。
3
+ *
4
+ * ══════════════════════════════════════════════════════════════════════════════
5
+ * 覆盖面(本文件已搬什么 / 没搬什么)
6
+ * ══════════════════════════════════════════════════════════════════════════════
7
+ * - **M3(读路径,先落地)**:`unitBusy` / `unitState` 两个纯 waitq 表读口 + 窗口几何
8
+ * 纯函数(`prefixMembers` / `prefixWindowBlocked` / `windowN`)+ `LOG_DIR_NAME` /
9
+ * `appendLog`。它们是会话详情页「队列」徽标、提交时 `queued` 字段与 `project_busy`
10
+ * 的数据源,本批次**不改语义**。
11
+ * - **M4(统一队列写路径首次落地)**:`runner.py` 的**调度内核**——模块级 helper
12
+ * (`notify_task_failed` / `_answer_row_sid` / `_session_msg_keys` / `_claim_unit` /
13
+ * `holders_text` / `SELFCHECK_*` / `set_liveness_probe` / `reconcile_units` /
14
+ * `start_unit_selfcheck`,runner.py:84-186、2379-2482)与整个 `Runner` 类
15
+ * (runner.py:188-1160)。行为逐点照 Python:补位器 `_pick_locked` 的五个闸
16
+ * (not_before / 队首阻塞 / 占位与状态校验 / pipeline 前段 / 前缀窗口 + ext 闸)、
17
+ * claim 的 rowcount 仲裁、claim 后行口径窗口复判、四类单元的收口映射、recover 的
18
+ * 四类重建矩阵、stop 的 waiting/starting 分流、周期自检与启动对账。
19
+ * - **不在本批次**(M5/M6 起分批接线;**仍未搬的命中即走下方「缝」或响亮失败**):
20
+ * 答案送达体(`board._deliver_answer_unit`)、卡片收尾点(`board.finish`)、周期自检的
21
+ * 两个 board 钩子(`board.unit_managed` / `board.refresh_ext_rows`)、飞书通知
22
+ * (`feishu.task_failed`)。**M6.5 三批(R1/R2/R3)已把 `runner.py` 的执行体搬完**:
23
+ * 入口本体 + bug/复测联动(R1)、脚本复测族 + 压测发压族(R2)、轮次族 + 日志/读侧族(R3)。
24
+ * - **M6.5 分批接线(分节注释在各族开头)**:R1 = 入口本体(`_process_card` /
25
+ * `_process_task`)与 bug/复测联动族;R2 = 脚本复测族(`_script_*` / `_run_one_script`)
26
+ * 与压测发压族(`_run_load_phase` / `_next_load_round_no` / `_load_cmd` / `_spawn_load` /
27
+ * `_wait_killed` / `_finish_load` / `_write_load_report` / `_fail_load` /
28
+ * `_spawn_retest_task`);R3 = 轮次族(`_run_round` / `_run_round_dshplugin` /
29
+ * `_wait_turn_end_dsh` / `_should_stop`)与日志/读侧族(`build_prompt` / `_content_text` /
30
+ * `dsh_event_to_log_line` / `_dsh_stream_worker` / `parse_session_hint` /
31
+ * `summary_entry_text` / `_tail_text` / `read_log_tail` / `read_live_summary`)。
32
+ * 差异㉕ 里「未实现时调用点抛 TypeError」的过渡态随之退场(三处调用点全部有实现)。
33
+ *
34
+ * ══════════════════════════════════════════════════════════════════════════════
35
+ * 缝(可注入;默认要么响亮失败、要么照 Python 静默——逐条见下)
36
+ * ══════════════════════════════════════════════════════════════════════════════
37
+ * ① `setUnitHandlers({processTask, processCard, deliverAnswerUnit})`:`_worker` 的四路
38
+ * 分派体。**未接线抛具名错误** `UnitHandlerMissing`(文案见 `UNIT_HANDLER_MISSING`,
39
+ * 点明到期里程碑)——异常的落点照 Python 的 except/finally 逐条走(见 `runPickedUnit`),
40
+ * 于是「t: 行落 failed / c: 行落 failed / a: 行放回 waiting」都可断言,**绝不静默成功**。
41
+ * ② `setBoardFinishHook(fn)`:`board.finish(key, reason)` 的等价口。未接线只打一行
42
+ * `[runner] board.finish 未接线(M5)…`(Python 该子步是「尽力而为的回滚收尾」,
43
+ * 抛了也只打日志,故这里不抛)。
44
+ * ③ `setBoardSelfcheckHooks({unitManaged, refreshExtRows})`:周期自检的两个 board 读钩子。
45
+ * 未接线时 `unitManaged=null`(照 Python「无豁免判据」)、`refreshExtRows` 跳过一拍
46
+ * (Python 的 `board.refresh_ext_rows` 异常只打一行,跳过等价于那一支)。
47
+ * ④ `setFailureNotifier(fn)`:飞书任务失败旁路(**M7 接线**)。未接线**静默**——照
48
+ * Python `notify_task_failed` 的 `except: pass`「通知永不影响调度主流程」。
49
+ * ⑤ `setLivenessProbe(fn)`:行判活探针(模块级,照 Python `set_liveness_probe`;board 注册)。
50
+ *
51
+ * ══════════════════════════════════════════════════════════════════════════════
52
+ * 与 Python 的形态差异(逐条 + 理由;**均为形态差异,行为判据一律照 Python**)
53
+ * ══════════════════════════════════════════════════════════════════════════════
54
+ * ① **无线程**:Python `Runner.__init__` 起 `WORKERS=6` 条 daemon 线程跑 `_worker`;
55
+ * Node 起 6 个 **async 循环**(`this._workers` 存 promise,Python `_threads` 存
56
+ * Thread)。循环体内的 `await` 是让位点——Python 靠 GIL + 线程切换,Node 靠事件循环。
57
+ * ② **`threading.Condition` → `Waiter`**:Python 的 `self._cond` 同时是互斥锁与唤醒面;
58
+ * Node 单事件循环下**无并发交错**,锁没有可保护的对象,故 `Waiter` 只保留唤醒语义
59
+ * (`notifyAll()` 唤醒全部等待者、`wait(timeoutMs)` 返回 promise 且超时也 resolve)。
60
+ * `_worker` 的 **5s 节拍兜底(补位时机④)逐字保留**:`pickLocked()` 返回 null ⇒
61
+ * `await waiter.wait(5000)` 后重跑补位器。
62
+ * ③ **锁语义 = 「临界区不许 await」**:Python `with self._cond:` 段在 Node 侧退化为
63
+ * 「同一 tick 内的同步段」——`pickLocked()` 与 `claimAndStartLocked()` 之间**没有
64
+ * await**,等价于 Python 持锁完成「挑选 + claim + 窗口复判」(双拾取在结构上不可达)。
65
+ * 纪律:所有 `_xxxLocked` 命名的方法体内**不许出现 await**(命名与 Python 对齐,
66
+ * 提醒读者这里是原子段)。
67
+ * ④ **定时器 ref 口径(两处不同,别一刀切)**:
68
+ * - `Waiter.wait(timeoutMs)` 的等待定时器**保持 ref**:它是「worker 在等活」的唯一表征,
69
+ * unref 掉会让事件循环在**没有任何其它活动**时直接判定退出,等待中的 promise 永远
70
+ * 悬着——Node 22 的 `node --test` 会把它报成 `cancelledByParent: Promise resolution
71
+ * is still pending but the event loop has already resolved`(实测)。生产由 HTTP
72
+ * 服务持循环、退出走 `process.exit()`,故 ref 不影响停机。
73
+ * - `startUnitSelfcheck` 的周期定时器**一律 `unref()`**:它是「周期任务」而非「在等活」,
74
+ * 不许让进程因为有它而退不出去(= Python daemon 线程语义)。
75
+ * ⑤ **轮次执行体在 M5/M6**:`_process_task` / `_process_card` / `board._deliver_answer_unit`
76
+ * 一律走注入缝(见上),未接线响亮失败。
77
+ * ⑥ **`stop()` 是 async**:Python 里它同步调 `dshdriver.cancel`(线程内阻塞);Node 侧
78
+ * `dshdriver.cancel` 是远端 IO ⇒ `stop` 返回 promise,调用方必须 await。
79
+ * `platcompat.kill_tree(pid, SIGTERM)` → `proc.js:killTree(pid, {sig:'SIGTERM'})`
80
+ * (**按本批次指定的口径映射**)。两者语义并非逐字同,明示在此:Python 的 POSIX 分支
81
+ * 只有一次 `killpg(pgid, SIGTERM)`(非阻塞、失败抛 OSError 由调用方吞);Node 的
82
+ * `killTree` 是「逐 pid 投 SIGTERM → 最多等 `KILL_GRACE_MS`(1500ms) 轮询 → 对未退者升级
83
+ * SIGKILL」,且**同步阻塞事件循环**(Python 只阻塞那一条 HTTP 线程)。后果:停一个真在
84
+ * 跑的进程树时,Node 侧最长 1.5s 内全体请求/SSE 停摆且会升级强杀。M4 期间 `_procs` 恒空
85
+ * (M6 才填),故当下不可达;**M6.5-R2 已接线**(`spawnLoad` 的停止/超时两支真会调它,
86
+ * `_procs` 也真会被填):对拍实测两支的返回值与 Python **逐字节同**(协作支 `[0,'']`、
87
+ * 宽限到期支 `[-15,'用户停止']`),即「单投 SIGTERM + 升级 SIGKILL」在本用例形状
88
+ * (`/bin/sh -c …` 子进程)下与 `killpg` 可观测等价;**残余差异**(最长 1.5s 同步阻塞、
89
+ * 会升级 SIGKILL、Windows 无 psutil 分支)仍按上文本登记,要逐字同就换 `process.kill` 单投。
90
+ * ⑦ **`_procs` 键保持 int task_id**:`Map<int, {pid}>`(Python `dict[int, Popen]`)。
91
+ * 取值面只有 `stop()`(`killTree(proc.pid)`)与 worker finally 的 `pop`;Node 侧存
92
+ * 「有 `.pid` 的对象」(M6 的轮次执行体决定具体形态)。
93
+ * ⑧ **`INSTANCE` 由 `setInstance` 写**:ESM 的命名空间对象不可写(`runner.INSTANCE = x`
94
+ * 会 TypeError,Python 侧是 `runner.INSTANCE = inst`)⇒ 模块级 `export let INSTANCE`
95
+ * + `setInstance(inst)` 显式写口(`chat.js` 的 `setRunnerInstance` 同因)。
96
+ * `reconcileUnits` / `startUnitSelfcheck` / `setLivenessProbe` 读它,None 则直接 return
97
+ * (照 Python 措辞)。
98
+ * ⑨ **`recover()` 是 async**:它要发飞书失败通知(M7 缝可能是异步 IO)。同步临界段
99
+ * (行映射 + 任务映射 + 落库)**一段不 await**;通知在临界段之后逐任务 await
100
+ * (Python 是「循环内同步通知」)——差异只在通知/日志的时点,行态与写序不变。
101
+ * ⑩ **`_worker` 的单单元执行段抽成 `runPickedUnit(key)`**:分派 + `except` + `finally`
102
+ * 三分支逐条照 Python(runner.py:1073-1159),抽出来的唯一目的是让单测能**手动
103
+ * pump 一步**(不起后台循环)。返回值 `{err, cardOk}` 是 Node 追加的观测面
104
+ * (Python 该段无返回值)——行为零影响。
105
+ * ⑪ **`_worker` 的两段都加了保底 catch**(`runPickedUnit` 之外):Node 的未捕获异常会
106
+ * **打死整个进程**(Python 只死一条 daemon 线程)⇒ ①「拾取 + claim」段、②「等单元
107
+ * 执行体」段各自 `console.log` 一行后跳过本轮(「响亮 + 继续」),绝不让一行坏数据或
108
+ * 一次收口写失败(`finally` 里的 DB 故障)带走服务。**单元分派/except/finally 本体照
109
+ * Python 逐条保留,不吞任何异常**——保底只在它们之外兜底。
110
+ * ⑫ **`runPickedUnit` 的 a: except 分支两处 Node 侧加固**(本分支在 Python 侧实际不可达:
111
+ * `runner.py:1087` 直接 `import board` 调执行体,不存在「口没接线」这个态;Node 侧是
112
+ * 缝,未注册即抛 ⇒ 本分支的可达场景就是**接线缺口**,或有异常从执行体逃逸):
113
+ * ① **多一行日志**(Python 静默放回重投)——放回→立即重拾→再抛是热旋,日志是唯一可见性;
114
+ * ② **放回带退避**(`not_before = now + ANSWER_EXC_BACKOFF_S`;Python `runner.py:1110`
115
+ * 传 `waitq.return_to_waiting` 的缺省 `0.0`)——2026-10-16 真机事故:接线缺口 + 零延迟
116
+ * 重投 ⇒ **微任务级热旋**(await 全落已决 promise)把事件循环的宏任务队列饿死:后端
117
+ * 50% CPU、日志 5.5KB/s 涨到 17MB、连 SIGTERM 都收不到(只能 SIGKILL),TS 会话详情
118
+ * 的读端点随事件循环一起卡死;毒行在 `wait_items` 里 ⇒ 重启后端立刻续旋
119
+ * (用户观感「重启 DSH 也没用」)。**行态语义一字不改**:仍落 `waiting`、`retries`
120
+ * 不涨(`retries` 只记业务重试,判据见 `backend/test/runner-core.test.mjs` ⑮),只把
121
+ * 「立即重拾」改成「30s 后重拾」;退避档与 `board.ANSWER_BACKOFF_S` 同值
122
+ * (不在本文件 import board:会成环,见差异㉘)。
123
+ * ⑬ **两处 board/db 口缺失,就地等价实现**(本文件私有、不导出成公开 API):
124
+ * `board._card_unit_active`(`_answer_exempt_keys_locked` 用;board.js 的 M3 只读子集
125
+ * 没有这个口,判据本身只是一次 waitq 行读)与 `db.update_board_card(last_error=…)`
126
+ * (worker 的 c: 异常分支用;db.js 尚无卡片写口,就地 UPDATE 两列 + `updated_at`,
127
+ * SQL 与 `db.py:update_board_card` 同款)。
128
+ * ⑭ **`read_log_tail` 的落地路径**:`_kill_residual` 要读日志尾找 `### PID`;M6 的读侧工具
129
+ * 当时未移植,故本文件先落一份**私有** `readLogTail(path, limit)`(同名同语义、不导出)。
130
+ * **M6.5-R3 已把它转为正式导出**(`read_log_tail` 的 Node 名),并修掉私有版对
131
+ * `limit<=0` 的切片偏差(见差异㊹)。
132
+ * ⑮ **两处「Python 会抛、Node 不抛」的防御**(同源理由:别让一行坏数据打死循环):
133
+ * ① `json.loads(payload).get(...)`:Python 只吃 `ValueError`(坏 JSON),非对象的
134
+ * `AttributeError` 会打死 worker 线程 ⇒ Node 一律「坏 JSON / 非对象 ⇒ 按无前段处理」;
135
+ * ② `_claim_and_start_locked` 的 **t: 分支** Python 无 `None` 检查(`task["project_id"]`
136
+ * 在任务行于 pick→claim 窗口被删时 AttributeError 打死线程)⇒ Node 按「放弃本次拾取」
137
+ * 返回 `null`(与同函数 c:/m:/a: 分支的既有防御姿态一致;行保持 waiting,终态归取消方)。
138
+ * ⑯ **`int()` / 时间戳解析用共享工具**:`lib.pyIntOr` / `lib.pyIsDigits`(Python `int()`
139
+ * 的容错版,ASCII 数字面,见 lib.js 文件头);`created_at → epoch 秒` 的
140
+ * `time.mktime(time.strptime(...))` 等价物是私有 `localSeconds`(waitq.js 有同口径
141
+ * 私有实现但未导出)。
142
+ * ⑰ **`_finalize_load_rounds`**:Python 是 `@staticmethod`,Node 侧写成实例方法
143
+ * (要收末位 options 袋;调用面只有 `recover`)。
144
+ * ⑱ **`processCard` / `processTask` 是 async,缝签名不变**:`rag.refresh`(RAG 索引刷新)
145
+ * 与 `board.dequeueStart`(卡片起跑)在 Node 是 async(Python 侧同步)⇒ 本批两条
146
+ * 执行体一律返回 promise。`setUnitHandlers` 注册的口本就允许返回 promise
147
+ * (`runPickedUnit` 一律 `await callUnitHandler(...)`),故**接线面零改动**:R4 注册
148
+ * `(id) => inst.processTask(id)` 即可,缝的形参/键名一字不改。
149
+ * ⑲ **Python 同步 `board.dequeue_start` vs Node async**:Python 的 `dequeue_start` 在
150
+ * 调用线程里同步阻塞到「起跑有结论」;Node 的 `dequeueStart` 是异步(起会话是 IO,
151
+ * 中途让位)。差异只在**让位点**:返回值语义(`True`/`False`)、行态、失败回滚
152
+ * 照 Python 逐条不变。⚠ 由此 `runPickedUnit` 对 `c:` 口的等待**必须** `await` 到
153
+ * promise 落地(否则 `cardOk` 拿到的是 pending promise ⇒ finally 的
154
+ * 「起会话失败 / 卡片状态不符」两支全部错判)——**本批只记文档、不改
155
+ * `runPickedUnit`**(接线在 R4;现口径已是 `await callUnitHandler(...)`,天然满足)。
156
+ * ⑳ **`STAGES` 在本文件独立定义**(`runner.py:64` 同源注释:与 `server.STAGES` / 前端
157
+ * STAGES 一致,**防循环依赖不 import server**):Node 侧 `routes/` 与前端另有各自
158
+ * 一份;runner 不 import server/routes(会成环,且 M4 起本文件就该独立可载)。
159
+ * 两侧同值同序,判据照 Python。
160
+ * ㉑ **三处新调用点沿用差异⑮①的防御姿态**:`json.loads(payload or "{}")` 后 `.get(...)`
161
+ * 的三处新调用面(`_process_task` 的 pipeline 分支 / `_mark_retest_cases` 的
162
+ * `only_cases` / `_maybe_auto_retest` 的去重扫描)在 Python 只吃 `ValueError`,
163
+ * 非对象的 `AttributeError` 会打死 worker 线程 ⇒ Node 一律「坏 JSON / 非对象 ⇒ 按
164
+ * `{}` 处理」(= 私有 `payloadObject`)。行为判据照 Python 的**可达面**(payload 由
165
+ * 平台自己写,非对象只在坏数据/人工改库时出现)。
166
+ * ㉒ **`STAGES.index(...)` → 私有 `stageIndex(...)`**:Python `tuple.index` 缺项抛
167
+ * `ValueError`(`_apply_bug_result` 的 pipeline 分支据此把该轮打成失败);Node 的
168
+ * `indexOf` 会**静默返回 -1**(等于静默降级)⇒ 显式抛 `RangeError`。抛/不抛与落点
169
+ * 照 Python;错误**类名**不同属既有「Node 错误类型名」口径(同差异⑧的错误码口径)。
170
+ * ㉓ **正则口径**:`AFFECTED_LINE_RE` 的 `\s` 用 `lib.PY_WS`(Python `str` 正则的
171
+ * Unicode 空白表,见 lib.js 文件头);`\d` 按 **ASCII**(同 lib.js 文件头「已知有意
172
+ * 保留的差异 ③」——`CASE_DIR_RE` / `BUG_DIR_RE` / `FS\d{4}` 两侧同为 ASCII,真实
173
+ * 输入里不可能分叉);`BUG_DIR_RE` 的 `$` **不补**「末尾换行之前」这一支
174
+ * (Python `$` 匹配 `"…\n"`、JS 不匹配)——目录名带换行属荒谬输入,登记不改。
175
+ * ㉔ **`line[:12]` 按码点切**:Python 下标切片按**码点**,JS `String.slice` 按 UTF-16
176
+ * 码元 ⇒ 本文件用 `[...line].slice(0, 12).join('')` 同口径(代理对不被劈开,
177
+ * `_extract_affected_ids` 的「含『无』即判无」窗口因此逐字符等价)。
178
+ * ㉕ **`_run_round` / `_run_script_retest` / `_run_load_phase` / `_should_stop` 的调用契约**:
179
+ * Node 名 `runRound` / `runScriptRetest` / `runLoadPhase` / `shouldStop`,形参照 Python
180
+ * 位置参数 + 末位 options 袋;**`runRound` 返回 `[exitCode, sessionId]`**
181
+ * (Python 元组 → Node 数组:probe 的 tuple/list 转 JSON 后同形,对拍不需要
182
+ * `shape` 适配)。R1/R2 期间只写调用点、不写空壳(未实现即 `TypeError`,**响亮**),
183
+ * **R2/R3 已分别落地**:三处调用点现在都有实现,过渡态退场。
184
+ * ㉖ **`_log_task_line` 不吞异常**(对拍实测修正):Python 的 `except (OSError, AttributeError)`
185
+ * 两个成员的**可达面**都不需要 Node 侧再兜:OSError 已由 `appendLog` 内吞掉,
186
+ * AttributeError 在 `rounds[-1]["log_path"]` 这个调用形状下不可达。真正可达的是
187
+ * `task is None` ⇒ `task["id"]` 抛 **TypeError**——它**不在** except 元组里,Python 侧是
188
+ * **外抛**(对拍 A 侧实测:`kind=TypeError`,`'NoneType' object is not subscriptable`)。
189
+ * 故 Node 侧**不写 try/catch**、同抛(错误文本随语言不同:引擎文案 vs Python 文案;
190
+ * 本文件不去逐字复刻解释器文案,登记为残余差异)。该分支不进对拍用例(`seq` 会连错误
191
+ * 文本一起比),只在 `backend/test/runner-bug.test.mjs` 钉「抛 TypeError」这一判据。
192
+ * ㉗ **`db.insertTask` 的字段袋是 camelCase**(`autoFix` / `stopType` / `stopValue` /
193
+ * `taskType`:db.js 既有形态),与 `updateTask` 的 **snake_case 列名袋**不同;
194
+ * `_maybe_auto_retest` 照 db.js 的口调用(Python 同名关键字参数两侧同序)。
195
+ * ㉘ **`_process_card` 的 board 走函数内 `await import('./board.js')`**:board.js
196
+ * 模块级 import runner.js ⇒ 静态 import 成环;Python 用函数内 `import board` 防循环,
197
+ * Node 的等价物即函数内动态 import——**位置也照 Python**(在卡片/项目两道校验
198
+ * 之后,三条 `return None` 分支因此不触发 board 加载)。
199
+ * ㉙ **本族解释器口径(M6.5 冻结契约,R2 落地)**:用户夹具脚本(`verify.py`、方案包
200
+ * `run.py`)是 agent 按协议生成的 **Python** ⇒ `runOneScript` 与 `loadCmd` 的脚本驱动支
201
+ * 两侧都跑 python3:A 侧 `sys.executable`、B 侧 `whichSync('python3')`(**运行期解析一次
202
+ * 并缓存**;解析不到抛具名 `PythonInterpreterMissing`——**绝不回落 `'python3'` 字面量、
203
+ * 绝不抛空**)。`loadCmd` 的**场景驱动**支是平台自己的引擎:A 侧
204
+ * `[sys.executable, <repo>/loadgen.py, 'run', …]`、B 侧
205
+ * `[process.execPath, <repo>/backend/lib/loadgen.js, 'run', …]` ⇒ 两侧 argv[0]/argv[1]
206
+ * 是「本族解释器 + 本族入口」,**逐字节不可比是设计使然**(用例对这两个位置逐路径
207
+ * `ignore` 并写明理由),其余九处产物路径与参数逐字节比。`PYTHONPATH=ROOT_DIR` 两侧
208
+ * 同值保留(用户 `run.py` 要 `import loadgen` 这个 Python 库);M8 退役 Python 时重审。
209
+ * ㉚ **`_script_live` 的全关键字形参 → 一个 `opts` 袋**:Python
210
+ * `def _script_live(self, project, *, phase=None, current=None, event=None, stats=None,
211
+ * label="脚本复测")`;Node `scriptLive(project, opts = {})`(键名与 Python 同名、`label`
212
+ * 缺省同值)。两处 `if x:` 的**真值判定照 Python**:`stats`/`event` 走 `rag.pyTruthy`
213
+ * (JS 的 `{}` 是真值、Python 的假值 ⇒ 不写这条就会多写一笔 stats/events)、`current`
214
+ * 用 `is not None`(`{}` 照写)。**非对象形状照 Python 抛**:live.json 顶层非对象
215
+ * (Python `list["k"]=…`/`None[...]` 抛 TypeError,**不在** `except (OSError, ValueError)`
216
+ * 里)、`stats` 非对象、`events` 非列表,Node 各抛 TypeError(错误类型名/文案不同属既有
217
+ * 口径,同⑧/㉒)。
218
+ * ㉛ **`_next_load_round_no`(@classmethod)/ `_load_cmd` 与 `_write_load_report`
219
+ * (@staticmethod)在 Node 一律写成实例方法**:① 收末位 options 袋;② **对拍可达性的
220
+ * 硬约束**——量具 `seq` 只能经**实例**取方法(static 挂在构造函数上、**不沿原型链**,
221
+ * `inst[fn]` 取不到)⇒ 写成 static 就对拍不可达。`nextLoadRoundNo` 读
222
+ * `Runner.LOAD_ROUND_NO`(对应 `cls.LOAD_ROUND_NO`);三个函数体都不读 `this`,
223
+ * 语义逐点照 Python。(同⑰的处置。)
224
+ * ㉜ **`_spawn_load` 的三处形态映射**:`stderr=subprocess.STDOUT` → `stdio: ['ignore', fd, fd]`
225
+ * (同一个 `openSync(logPath,'a')` 的 fd 传两次,spawn 后父进程立即 `closeSync`);
226
+ * `start_new_session=True` → `proc.spawnSession` 的 `detached: true`;`Popen` 的
227
+ * **同步 OSError** → `spawnSession` 后等 `'spawn'/'error'` 事件(`waitSpawned`)——
228
+ * Node 的启动失败是**异步**的,不等就等于把「解释器/脚本不存在」误判成「进程在跑」。
229
+ * 停止/超时两支调 `killTree(proc.pid, {sig:'SIGTERM'})`(口径差异与实测见⑥)。
230
+ * ㉝ **`_wait_killed(proc, timeout=5)` 的单位与子进程错误形状**:Python 的 `timeout` 是**秒**
231
+ * ⇒ Node 形参 `timeoutMs = 5000`(毫秒,名字即单位;对拍只经 `spawnLoad` 间接覆盖)。
232
+ * 子进程两族的异常逐条对应、**不合并不互相兜底**:`TimeoutExpired` ↔ 私有
233
+ * `ScriptTimeout`(带已收输出,照 `e.stdout/e.stderr`)、`OSError` ↔ 私有
234
+ * `ScriptSpawnError`(Node 的 `'error'` 事件)。`subprocess.run(timeout=…)` 超时后
235
+ * `process.kill()`(POSIX=SIGKILL)再收尾输出 ↔ Node `child.kill('SIGKILL')` + 等
236
+ * `'close'` 拿全量输出。被信号杀死:Python `returncode=-signum`、Node `'exit'` 给
237
+ * `(null,'SIGTERM')` ⇒ 私有 `exitCodeOf` 按 `os.constants.signals` 映射回 `-15` 这类值
238
+ * (对拍实测:宽限到期支两侧都 `-15`)。**错误文案不复刻**(`### 脚本启动失败: …` 里
239
+ * Python 是 `[Errno 2] No such file or directory: …`、Node 是 `spawn … ENOENT`);
240
+ * 该支已进对拍(返回值 `[1, execution_<戳>_script.md]` 相等、记录写不进去),文案差登记。
241
+ * ㉞ **`ThreadPoolExecutor(4)` + `as_completed` → 私有 `createCompletionPool(limit)`**:
242
+ * 有界并发、**完成序**产出;消费方提前退出后 `drain()` = `shutdown(wait=True,
243
+ * cancel_futures=True)`(排队未启动的取消、已启动的等收口);任务异常按 `Future.result()`
244
+ * 口径在 `next()` **原样外抛**(不吞、不降级)。完成序是**可见行为**(`results` →
245
+ * `failed_ids` → payload.only_cases 与轮次日志/事件序),故用例夹具的 `verify.py` 耗时
246
+ * 必须彼此分开(对拍实测:`only_cases` 顺序两侧逐字节同,未用任何归一器抹平顺序)。
247
+ * ㉟ **`tailText` 的落地路径**:`_finish_load` 要 `_tail_text`(发压失败原因摘要),而它属
248
+ * R3 的「日志/读侧」族 ⇒ R2 先落一份同名同语义的**私有**实现(同⑭的处置)。
249
+ * **M6.5-R3 已转为正式导出**(`_tail_text` 的 Node 名),切片按**码点**(同㉔)。
250
+ * ㊱ **`_run_one_script` 的并发阈值走实例属性**:Python 读 `self.SCRIPT_RETEST_TIMEOUT` /
251
+ * `self.SCRIPT_WORKERS`(类属性)⇒ Node 在构造器把它们**镜像到实例属性**(JS 的 static
252
+ * 字段**不沿原型链**,`this.X` 取不到),方法体读 `this.SCRIPT_*`:单测因此可给实例覆写
253
+ * 一个极小超时来钉超时支(对拍注入不了——A 侧类属性只能由用例之外的代码改)。
254
+ *
255
+ * ── M6.5-R3(轮次族 + 日志/读侧族)新增差异 ──────────────────────────────────────────
256
+ * ㊲ **退场族守卫抛同名 `RuntimeError`**:Python `raise RuntimeError(RETIRED_MSG)`;Node 借
257
+ * `prompts.js` 已导出的 `RuntimeError`(`constructor.name` = `RuntimeError`)同抛。
258
+ * 用同名类不是洁癖:对拍 `seq` 的失败步会逐字比 `kind`(探针取 `type(exc).__name__`、
259
+ * worker 取 `constructor.name`),裸 `Error` 会造出一条只能靠白名单盖住的永久差异。
260
+ * ㊳ **轮次族是 async**:`dshdriver` 全族(`available`/`ensureSession`/`status`/`prompt`)与
261
+ * `prompts.first_round_prompt` 在 Node 侧都是 Promise(M2/M6-4 的既有形态差异)⇒
262
+ * `runRound` / `runRoundDshplugin` / `waitTurnEndDsh` 与**模块级** `buildPrompt` 一律 async
263
+ * (`buildPrompt` 是 Promise 这一点是 R1 就登记的契约:`runRoundDshplugin` 必须 await 它,
264
+ * 否则写进 `### PROMPT` 行的是 `[object Promise]`)。
265
+ * ㊴ **`threading.Thread` → async 任务**:Python 起 `_dsh_stream_worker` 线程再
266
+ * `join(timeout=5)`;Node 起一个 async 任务存 promise,`finally` 里置停止标志
267
+ * (`stream_stop.set()` ↔ `streamStop.aborted = true`,**在 `dshStreamWorker` 边界归一成
268
+ * 函数探针**再交给驱动)后 `await Promise.race([task, 5s 定时器])`(定时器 `unref`,同差异④
269
+ * 的「在等活」口径)。**该任务的 rejection 只打一行 `[runner] …`**(`.catch`):Python 线程内
270
+ * 异常只打 traceback,绝不该带崩主流程;`feedInto` 自身已把链路异常转成 `waiter.fail()`。
271
+ *
272
+ * R3 在此抓到一个**驱动侧真缺陷并已修**(记录:M6.5 记录 §三「真问题②」):`dshdriver.js`
273
+ * 的 `consumeEvents` 曾用与 `stopProbe` **同一条松判据**(`typeof stop.aborted === 'boolean'`)
274
+ * 认定 AbortSignal,于是普通 `{aborted:false}` 对象被塞进 `fetch(..., {signal})`,整轮以
275
+ * `### 事件流异常: signal.addEventListener is not a function` 收口。现驱动侧新增
276
+ * `isAbortSignal()`(要求 `addEventListener` 是函数)区分「停止探针」与「真 AbortSignal」,
277
+ * 两条形态都可传;runner 侧的「归一成函数」保留为**防御性写法**(不依赖驱动的鸭子判据)。
278
+ * ㊵ **`dshStreamWorker` 跳过 `null` 行**:Python 是 `append_log(log_path, None)` ⇒
279
+ * `None.encode` 抛 `AttributeError`(**不在** `except OSError` 里)⇒ 冒泡到
280
+ * `dshdriver.feed_into.handle` 的 `except Exception: pass`,**一个字节都不写**。
281
+ * Node 的 `appendLog` 走 `String(text)`,直接传 `null` 会写出字面量 `"null"` ⇒ 这里显式
282
+ * 跳过 `null`,落盘字节与 Python 同效(`dshEventToLogLine` 的 `null`/`''` 语义保持分明)。
283
+ * ㊶ **Python 语义小工具(本文件私有)**:`pyGet`(`d.get`,非 dict 抛 `AttributeError`)、
284
+ * `pyGetItem`(`d[k]`,缺键抛 `KeyError`)、`pyStr`(`str(x)`:`None`→`"None"`、
285
+ * `True`→`"True"`)、`pyInt`(`int(x)`:bool→0/1、有限数字截断、字符串按十进制,
286
+ * 失败抛 `ValueError`、非有限浮点抛 `OverflowError`)、`pyTypeName`/`pyReprString`。
287
+ * `AttributeError`/`KeyError`/`ValueError`/`OverflowError` **复用 `rag.js` 的同名类**
288
+ * (`constructor.name` 与 Python `type(exc).__name__` 逐字相同 ⇒ 对拍 `kind` 可比)。
289
+ * **不是**「顺手防御」:这五条正是「Python 会抛的地方 Node 也要抛」的落点——
290
+ * `readLiveSummary` 的顶层非对象、`summaryEntryText` 的缺 `kind`、`dshEventToLogLine`
291
+ * 的非 dict 帧都**照抛**(各自对应 Python 未进 `except` 元组的那类异常)。
292
+ * ㊷ **`DSH_TURN_START_GRACE` 镜像到实例属性**:Python 在 `_wait_turn_end_dsh` 里读模块全局
293
+ * (`runner.DSH_TURN_START_GRACE`,单测可 monkeypatch);Node 的 ESM 导出绑定外部不可写
294
+ * ⇒ 构造器镜像一份、方法体读 `this.DSH_TURN_START_GRACE`(同㊱的 `SCRIPT_*` 处置)。
295
+ * 行为零差异(默认同值 120.0),目的是让单测能钉「turn 未启动」支而不用真等 120s。
296
+ * ㊸ **`dshEventToLogLine` 用 `null` 表示「无需落盘」**(照 Python `None`):`turn/end`、
297
+ * `user/message`、`agent/status`、未知类型、以及「只有工具调用无文本」的 assistant 事件
298
+ * 都返回 `null`——**不是空串**,`''` 与 `null` 在调用方(差异㊵)与对拍判据里含义不同。
299
+ * ㊹ **两处读侧切片/编码口径的修正与统一**:① `readLogTail` 的 `limit` 照 Python
300
+ * `readlines()[-limit:]`(`0` ⇒ 全文、负数 ⇒ 丢开头 |limit| 行)——私有前身写的
301
+ * `slice(max(0, len - limit))` 在 `limit<=0` 时返回空串(调用面全用正数故未暴露);
302
+ * ② 读文件改走 `readText`(universal newlines 归一 `\r\n`/`\r` → `\n`,照 Python 文本模式)
303
+ * 并保留 `errors="replace"` 的 U+FFFD 容错。
304
+ *
305
+ * 零 npm 依赖:只用 `node:` 内建模块 + 本仓库内模块。
306
+ */
307
+
308
+ import { spawn } from 'node:child_process';
309
+ import fs from 'node:fs';
310
+ import os from 'node:os';
311
+ import path from 'node:path';
312
+ import { fileURLToPath } from 'node:url';
313
+
314
+ import { RETIRED_FAMILY, RETIRED_MSG, agentFamily, whichSync } from './agents.js';
315
+ import * as chat from './chat.js';
316
+ import {
317
+ deleteRounds,
318
+ finishRound,
319
+ getBoardCard,
320
+ getProject,
321
+ getTask,
322
+ insertTask,
323
+ insertRound,
324
+ listAllTasks,
325
+ listRounds,
326
+ listTasks,
327
+ nowStr,
328
+ rowOpt,
329
+ updateTask,
330
+ withReadDb,
331
+ withWriteDb,
332
+ } from './db.js';
333
+ import { sessionEffort } from './dshevents.js';
334
+ import {
335
+ DshDriverError,
336
+ TurnWaiter,
337
+ applySessionDefaults,
338
+ available as driverAvailable,
339
+ cancel as driverCancel,
340
+ ensureSession,
341
+ feedInto,
342
+ prompt as driverPrompt,
343
+ splitModel,
344
+ status as driverStatus,
345
+ } from './dshdriver.js';
346
+ import { matchLevel, scanCases } from './export-cases.js';
347
+ import * as loadcase from './loadcase.js';
348
+ import { strftime_local as strftime } from './loadgen.js';
349
+ import {
350
+ PY_WS,
351
+ VERIFY_SCRIPT_NAME,
352
+ bugCases,
353
+ bugCasesScripted,
354
+ casesAllPassed,
355
+ ensureGitignore,
356
+ ensureRuntimeDirs,
357
+ findCaseDirs,
358
+ gitLogChanges,
359
+ isDir,
360
+ isFile,
361
+ markCasesRetest,
362
+ pyIntOr,
363
+ pyIsDigits,
364
+ pyJoin,
365
+ pySorted,
366
+ readText,
367
+ runtimeDir,
368
+ setMdField,
369
+ } from './lib.js';
370
+ import { killTree, spawnSession } from './proc.js';
371
+ import * as prompts from './prompts.js';
372
+ import { pyJsonDumps, pyJsonDumpsIndent } from './pyjson.js';
373
+ import { AttributeError, KeyError, OverflowError, ValueError } from './rag.js';
374
+ import * as rag from './rag.js';
375
+ import * as sessparse from './sessparse.js';
376
+ import { DIALOGUE_RAW_LIMIT, DIALOGUE_TEXT_LIMIT } from './taskjson.js';
377
+ import * as waitq from './waitq.js';
378
+ import {
379
+ KIND_TASK, activeExt, activeItems, getActive, memberKey, STATE_WAITING,
380
+ } from './waitq.js';
381
+
382
+ /** 运行期机器产物目录名(`runner.LOG_DIR_NAME`;`.web` 下放轮次/对话/看板日志) */
383
+ export const LOG_DIR_NAME = '.web';
384
+
385
+ /**
386
+ * 追加文本到日志(二进制写入,utf-8)。`runner.append_log`(runner.py:2171)直译:
387
+ * **任何写失败都吞掉**——日志不是业务,写不进去不该把一轮任务带崩。
388
+ *
389
+ * `errors="replace"` 对应 Node 的 `Buffer.from(str, 'utf8')`:Node 字符串本就合法 UTF-16,
390
+ * 编码时孤立代理项落成 U+FFFD,与 Python replace 口径一致。空路径直接返回(Python 侧
391
+ * `open("")` 抛 OSError 被吞,等价)。
392
+ *
393
+ * @param {string} logPath
394
+ * @param {string} text
395
+ */
396
+ export function appendLog(logPath, text) {
397
+ if (!logPath) return;
398
+ try {
399
+ fs.appendFileSync(logPath, Buffer.from(String(text), 'utf8'));
400
+ } catch { /* 路径空/不可写:静默(与 Python 同) */ }
401
+ }
402
+
403
+ /** parallel 模式队首责任区长度(`Runner.PARALLEL_PREFIX_N`;serial 恒 1,t: 恒 1) */
404
+ export const PARALLEL_PREFIX_N = 5;
405
+
406
+ /** 队列键前缀 → 等待项 kind(`runner._KIND_OF_KEY`;**有意不含 ext**——外部条目永不被拾取) */
407
+ export const KIND_OF_KEY = Object.freeze({
408
+ t: 'task', c: 'card', m: 'msg', a: 'answer',
409
+ });
410
+ /** kind → 队列键前缀(`runner._KEY_PREFIX`) */
411
+ export const KEY_PREFIX = Object.freeze({
412
+ task: 't', card: 'c', msg: 'm', answer: 'a',
413
+ });
414
+
415
+ // --------------------------------------------------------------------------
416
+ // 窗口几何(runner.py:136-164 的唯一出处 `_prefix_window_blocked` 的 Node 形)
417
+ // --------------------------------------------------------------------------
418
+
419
+ /**
420
+ * 调度口径运行前缀成员键集(`Runner._prefix_members`,v3a R5①):
421
+ * `Map<project_id, Set<队列键>>` = state ∈ PREFIX_STATES(starting/running)行的队列键。
422
+ *
423
+ * 成员**只由行构成、唯一来源(R1)**;`finishing` 行不计(收尾窗口不再占补位名额,
424
+ * 位次口径另含 finishing,见裁决 R7);ext 行入场即 running,天然落入本集合。
425
+ *
426
+ * @param {object[]} rows 已取到的活跃行清单(调用方传,免二次全表读)
427
+ * @returns {Map<number, Set<string>>}
428
+ */
429
+ export function prefixMembers(rows) {
430
+ const members = new Map();
431
+ for (const row of rows) {
432
+ if (row.state !== 'starting' && row.state !== 'running') continue;
433
+ if (!members.has(row.project_id)) members.set(row.project_id, new Set());
434
+ members.get(row.project_id).add(memberKey(row.kind, row.target_id));
435
+ }
436
+ return members;
437
+ }
438
+
439
+ /**
440
+ * 运行前缀窗口闸**唯一出处**(`runner._prefix_window_blocked`,v3a R1):
441
+ * 前缀键集 `keys` 扣除**本单元自己的键** `key` 与折抵键 `exempt` 后 ≥ N ⇒ 留队(true)。
442
+ *
443
+ * 逐点照抄的三条:
444
+ * - `keys` = 行口径前缀成员键集;`n` = 候选单元的窗口 N;
445
+ * - **t: 键在场时 N 收 1**(任务串行红线)——a:/c:/m: 候选中任一前缀含 `t:` 即窗口 1;
446
+ * - `key = null` 表示「不排除任何键」(项目整窗读口用)。
447
+ *
448
+ * `a:` 候选的折抵(`exempt`)由调用方给(`_answer_exempt_keys_locked` 的等价物);
449
+ * `ext:` 键**不折抵**(2026-09-28 #610 收敛:外部会话同样压答案投递)。
450
+ *
451
+ * 纯函数(不碰连接、不读进程内状态),故对拍可直接调。
452
+ *
453
+ * @param {Set<string>} keys
454
+ * @param {string|null} key 本单元自己的键(null = 不排除)
455
+ * @param {string} kind 本单元 kind
456
+ * @param {number} n
457
+ * @param {Iterable<string>} [exempt]
458
+ * @returns {boolean}
459
+ */
460
+ export function prefixWindowBlocked(keys, key, kind, n, exempt = []) {
461
+ let limit = n;
462
+ const others = new Set(keys);
463
+ if (key !== null && key !== undefined) others.delete(key);
464
+ if (kind !== KIND_TASK) {
465
+ for (const k of others) {
466
+ if (k.startsWith('t:')) { limit = 1; break; }
467
+ }
468
+ }
469
+ if (kind === 'answer') {
470
+ for (const k of exempt) others.delete(k);
471
+ }
472
+ return others.size >= limit;
473
+ }
474
+
475
+ /**
476
+ * 补位窗口 N(`Runner._window_n`,裁决 R5③):`t:` 单元恒 1;看板卡单元族
477
+ * c:/m:/a: 按项目模式 parallel → `PARALLEL_PREFIX_N`、serial → 1。
478
+ *
479
+ * mode 读取锚点 = `board.settings_of`(读取层已把未知/缺省值归一为 serial)。
480
+ * **本函数就是那个锚点的内联实现**(不 import board,理由见文件头差异②):
481
+ * `board_settings.json` 坏 JSON / 非对象 ⇒ `{}` ⇒ mode 取默认 `serial`(与
482
+ * `board.settings_of` 的「只认 mode 键 + 白名单归一」逐点等价)。
483
+ *
484
+ * @param {string} kind
485
+ * @param {number} projectId
486
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接
487
+ * @returns {number}
488
+ */
489
+ export function windowN(kind, projectId, options = {}) {
490
+ if (kind === KIND_TASK) return 1;
491
+ let settings = {};
492
+ if (projectId) {
493
+ const row = withReadDb(options, (handle) => handle
494
+ .prepare('SELECT json FROM board_settings WHERE project_id=?').get(projectId));
495
+ if (row !== undefined) {
496
+ try {
497
+ const parsed = JSON.parse(row.json);
498
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
499
+ settings = parsed;
500
+ }
501
+ } catch {
502
+ settings = {}; // 坏 JSON 降级(同 db.get_board_settings)
503
+ }
504
+ }
505
+ }
506
+ // MODE_VALUES = ("serial", "parallel");未知/缺省一律 serial(读取层归一)
507
+ return settings.mode === 'parallel' ? PARALLEL_PREFIX_N : 1;
508
+ }
509
+
510
+ // --------------------------------------------------------------------------
511
+ // 两个只读口
512
+ // --------------------------------------------------------------------------
513
+
514
+ /**
515
+ * 项目当前是否忙 = 新单元必须留队(**与 `_pick_locked` 放行口径一致**)。
516
+ *
517
+ * - 前缀窗口已满(serial ≡「任一前缀行即忙」;parallel N=5);
518
+ * - 项目存在活跃 `ext:` 行(外部会话在跑)——**与 N 无关**:parallel 下一条 ext 行
519
+ * 也挡全部平台单元。
520
+ *
521
+ * 动因(照 Python docstring):`unit_busy` 是提交时「本单元会不会排队」的展示判据
522
+ * (`chat.submit` 的 queued 字段 / 会话 meta 的 project_busy),必须与调度侧的真实
523
+ * 放行结论一致,否则 parallel 项目会显示「不排队」而实际留队。
524
+ *
525
+ * @param {number} projectId
526
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接
527
+ * @returns {boolean}
528
+ */
529
+ export function unitBusy(projectId, options = {}) {
530
+ if (!projectId) return false;
531
+ const rows = activeItems(projectId, options);
532
+ const keys = prefixMembers(rows).get(projectId) || new Set();
533
+ if (prefixWindowBlocked(keys, null, 'card', windowN('card', projectId, options))) {
534
+ return true;
535
+ }
536
+ return activeExt(projectId, options);
537
+ }
538
+
539
+ /**
540
+ * 单元在统一队列中的态(会话详情页标题「队列」徽标的数据源;只读)。
541
+ *
542
+ * 返回 `{state:'running'|'queued'|'idle', pos, total}`(**键名与 Python 逐字同**,
543
+ * 它直接进 JSON 响应):
544
+ * - `running`:本单元有**运行中行**在场(活跃非等待行——waiting 即排队)——
545
+ * t:/m: 拾取即置 starting 跨整轮、c: 起跑证实后跨轮 running、a: 送达期瞬态;
546
+ * 行不在场(未入队/已终态/键失效)按 idle;
547
+ * - `queued`:等待项表里仍有本单元的 waiting 行(表驱动,R2);**v2a T4 起位次含
548
+ * 运行前缀**:`pos` = 前缀成员数(项目内 starting/running/finishing 行)+
549
+ * waiting 行中 seq 更小者 + 1(1 基,即"本项目前面还有几个"),`total` = 项目成员
550
+ * 总数(行)。他项目单元不计入位次——同项目串行才是"前面还有几个"的实际约束;
551
+ * - `idle`:表中数不到本单元的活跃行(未入队/已终态/键失效)。
552
+ *
553
+ * 逐点保留的两个细节(照 Python):
554
+ * ① 运行分支的**项目比对是防御**:行在他项目(调用方传参漂移)按空闲返回,
555
+ * 比报运行中安全;
556
+ * ② 位次分支里 `ahead` 的累加条件是「尚未遇到本行 ∧ 该键不在前缀里」——**同键的
557
+ * 前方 waiting 行不重复计位**(前缀键集是集合,同单元多行只计一次前缀)。
558
+ *
559
+ * @param {string} key 队列键(`t:<id>` / `c:<id>` / `m:<id>` / `a:<id>`)
560
+ * @param {number} projectId
561
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接
562
+ * @returns {{state: string, pos: number, total: number}}
563
+ */
564
+ export function unitState(key, projectId, options = {}) {
565
+ const idle = { state: 'idle', pos: 0, total: 0 };
566
+ const text = String(key === null || key === undefined ? '' : key);
567
+ const kind = KIND_OF_KEY[text.charAt(0)];
568
+ if (kind !== undefined && projectId) {
569
+ // running = 本单元有运行中行(v3d 行口径):活跃非等待即运行上报
570
+ const row = getActive(kind, text.slice(2), options);
571
+ if (row !== null && row.project_id === projectId && row.state !== STATE_WAITING) {
572
+ return { state: 'running', pos: 0, total: 0 };
573
+ }
574
+ }
575
+ if (kind === undefined || !projectId) return idle;
576
+ const prefixKeys = new Set();
577
+ const totalKeys = new Set();
578
+ const waiting = []; // [成员键, 行],seq 升序
579
+ for (const row of activeItems(projectId, options)) {
580
+ const rk = memberKey(row.kind, row.target_id);
581
+ totalKeys.add(rk);
582
+ if (row.state === STATE_WAITING) waiting.push([rk, row]);
583
+ else prefixKeys.add(rk); // starting/running/finishing:位次前缀(R7 全计)
584
+ }
585
+ let mine = false;
586
+ let ahead = 0;
587
+ for (const [rk, row] of waiting) {
588
+ if (row.kind === kind && String(row.target_id) === text.slice(2)) {
589
+ mine = true; // 本单元 waiting 行(活跃唯一索引保证唯一)
590
+ } else if (!mine && !prefixKeys.has(rk)) {
591
+ ahead += 1; // waiting 且 seq 更小者(升序遍历即排在前面)
592
+ }
593
+ }
594
+ if (!mine) return idle; // 表中数不到(行已终态/取消):按空闲
595
+ return {
596
+ state: 'queued',
597
+ pos: prefixKeys.size + ahead + 1,
598
+ total: totalKeys.size,
599
+ };
600
+ }
601
+
602
+ // ══════════════════════════════════════════════════════════════════════════
603
+ // M4:调度内核(runner.py 模块级 helper 84-186 + Runner 类 188-1160 + 2379-2482)
604
+ // ══════════════════════════════════════════════════════════════════════════
605
+
606
+ // --------------------------------------------------------------------------
607
+ // 常量(runner.py:239-240、196-207)
608
+ // --------------------------------------------------------------------------
609
+
610
+ /** 等待项周期自检间隔(秒,`SELFCHECK_INTERVAL`;run_int 自检线程节拍) */
611
+ export const SELFCHECK_INTERVAL = 60.0;
612
+ /**
613
+ * 行年龄豁免(秒,`SELFCHECK_MIN_AGE`):拾取/建行 → 行置 running/证据落行的毫秒窗——
614
+ * 年轻行自检不处置不告警,防误杀新生行。
615
+ */
616
+ export const SELFCHECK_MIN_AGE = 15.0;
617
+
618
+ /** worker 无活可拾时的等待上限(毫秒;Python `self._cond.wait(timeout=5)` 的 5s 节拍) */
619
+ export const WAIT_TICK_MS = 5000;
620
+
621
+ /** `Runner.WORKERS`:同时在跑的单元数上限(= 循环数) */
622
+ export const WORKERS = 6;
623
+ /** `Runner.LOAD_ROUND_NO`:压测任务首次发压的轮次号(第 1 轮 agent 出方案包) */
624
+ export const LOAD_ROUND_NO = 2;
625
+
626
+ // --------------------------------------------------------------------------
627
+ // M6.5-R2:脚本复测族 + 压测发压族的模块级常量(runner.py:53-60、1519-1520)
628
+ // --------------------------------------------------------------------------
629
+
630
+ /** `Runner.SCRIPT_RETEST_TIMEOUT`:单脚本执行超时(秒;脚本只做单用例「调用+断言」) */
631
+ export const SCRIPT_RETEST_TIMEOUT = 120;
632
+ /** `Runner.SCRIPT_WORKERS`:脚本复测并发数(`ThreadPoolExecutor(4)`) */
633
+ export const SCRIPT_WORKERS = 4;
634
+ /**
635
+ * 发压硬上限(秒,`LOAD_MAX_SECONDS`;runner.py:57):超过即杀进程树并按失败收尾。
636
+ * 「脚本驱动为任意代码,平台只做超时上限 + 停止宽限 + 杀进程树 + 输出回收四件事」。
637
+ */
638
+ export const LOAD_MAX_SECONDS = 7200;
639
+ /** 用户停止后给脚本的优雅收尾宽限(秒,`LOAD_STOP_GRACE`) */
640
+ export const LOAD_STOP_GRACE = 10.0;
641
+ /** 发压进程轮询间隔(秒,`LOAD_POLL_INTERVAL`):等退出 + 查停止请求 */
642
+ export const LOAD_POLL_INTERVAL = 0.5;
643
+ /**
644
+ * 项目根(`runner.ROOT_DIR` = `os.path.dirname(os.path.abspath(__file__))` 的对应物):
645
+ * 脚本驱动的 `PYTHONPATH` 注入点(脚本可 `import loadgen`)与场景驱动入口
646
+ * `<repo>/loadgen.py` 的所在目录。与 `db.js` 的 `REPO_ROOT` 同值(各自算,不互相 import)。
647
+ */
648
+ export const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
649
+
650
+ /** `### PID <n>` 平台进度标记(`PID_MARK_RE`):`_kill_residual` 从轮次日志尾找残留进程 */
651
+ const PID_MARK_RE = /### PID (\d+)/g;
652
+
653
+ // --------------------------------------------------------------------------
654
+ // M6.5-R3:轮次族 + 日志/读侧族的模块级常量(runner.py:44、62、2255-2260)
655
+ // --------------------------------------------------------------------------
656
+
657
+ /**
658
+ * dsh 插件族(路线 A)轮次参数:turn 启动宽限(秒,`DSH_TURN_START_GRACE`,runner.py:62)——
659
+ * followup 后 turn/start 由 agent loop 立刻 append,超过该宽限仍无事件即视为链路/宿主异常,
660
+ * 收口本轮。
661
+ *
662
+ * 访问路径的形态差异(见文件头差异㊷):Python 在 `_wait_turn_end_dsh` 里读**模块全局**,
663
+ * Node 侧镜像到实例属性(`this.DSH_TURN_START_GRACE`)供单测钉「turn 未启动」支。
664
+ */
665
+ export const DSH_TURN_START_GRACE = 120.0;
666
+
667
+ /**
668
+ * `session.resume_hint` 的 session_id 提取正则(`SESSION_HINT_RE`,runner.py:44)。
669
+ * `\s` 用 `PY_WS`(Python `str` 正则的 Unicode 空白表,见文件头差异㉓);**非全局**,
670
+ * 照 Python 的 `re.compile` 口径(`parseSessionHint` 内部另建全局副本做 `findall`)。
671
+ */
672
+ export const SESSION_HINT_RE = new RegExp(`"session_id"${PY_WS}*:${PY_WS}*"([^"]+)"`);
673
+
674
+ /**
675
+ * 对话条目的两个长度上限(`runner.DIALOGUE_TEXT_LIMIT` / `DIALOGUE_RAW_LIMIT`,
676
+ * runner.py:2255-2260):工具结果/助手摘要单条最大码点数 6000,JSON 内容直接截断时保留 1200。
677
+ *
678
+ * **从 `taskjson.js` 再导出**(M6 的 `read_dialogue` 已落这两个常量,值同):不定义第二份
679
+ * 同值常量——两处一旦分叉,`read_dialogue` 的截断与 `summary_entry_text` 的截断就会静默不一致。
680
+ */
681
+ export { DIALOGUE_RAW_LIMIT, DIALOGUE_TEXT_LIMIT };
682
+
683
+ // --------------------------------------------------------------------------
684
+ // M6.5-R1:入口本体与 bug/复测联动的模块级常量(runner.py:51-77)
685
+ // --------------------------------------------------------------------------
686
+
687
+ /**
688
+ * 生命周期阶段有序 key(`runner.STAGES`,runner.py:64):与 `server.STAGES` / 前端 STAGES
689
+ * 一致;**防循环依赖不 import server**(见文件头差异⑳)。`_apply_bug_result` 的 pipeline
690
+ * 分支按它比较「终点是否含复测阶段」。
691
+ */
692
+ export const STAGES = Object.freeze(
693
+ ['gen_case', 'execute', 'report', 'analyze', 'fix', 'deploy', 'retest'],
694
+ );
695
+
696
+ /**
697
+ * `bug_report` 目录名时间戳前缀正则(`BUG_DIR_RE`,runner.py:51;与 server.py 一致),
698
+ * 匹配组为标题。`_pipeline_bugs` 用它过滤目录名、`_maybe_auto_retest` 用它取任务标题。
699
+ * `\d` 按 ASCII、`$` 不补末尾换行(见文件头差异㉓)。
700
+ */
701
+ const BUG_DIR_RE = /^\d{8}_\d{4}_(.+)$/;
702
+
703
+ /**
704
+ * 日期范围复测:agent 输出固定行「受影响用例:FS0001、FS0002」(行后 200 字符内取 id)。
705
+ * `\s` 用 `PY_WS`(Python `str` 正则的 Unicode 空白表)、`[^\n]` 同 Python(见差异㉓)。
706
+ */
707
+ export const AFFECTED_LINE_RE = new RegExp(`受影响用例[::]${PY_WS}*([^\\n]{0,200})`);
708
+
709
+ /**
710
+ * 从文本提取「受影响用例:…」行中的用例 id(`_extract_affected_ids`,runner.py:69):
711
+ * 去重排序(`sorted(set(...))` 的码点序);命中行前 12 个**码点**里含「无」即判无。
712
+ *
713
+ * @param {string} text
714
+ * @returns {string[]}
715
+ */
716
+ export function extractAffectedIds(text) {
717
+ const m = AFFECTED_LINE_RE.exec(text == null ? '' : String(text));
718
+ if (m === null) return [];
719
+ const line = m[1];
720
+ // `line[:12]` 按码点切(见差异㉔):`[...str]` 逐码点展开,代理对不被劈开
721
+ if ([...line].slice(0, 12).join('').includes('无')) return [];
722
+ return pySorted(new Set(line.match(/FS\d{4}/g) || []));
723
+ }
724
+
725
+ /** `_extract_affected_ids` 的 Python 名别名(照 board.js `_dequeue_start` 的既有形态) */
726
+ export const _extract_affected_ids = extractAffectedIds;
727
+
728
+ /**
729
+ * `json.loads(payload or "{}")` 的**对象版**(见文件头差异⑮①㉑):坏 JSON / 非对象一律 `{}`。
730
+ * Python 侧非对象会在 `.get(...)` 处抛 `AttributeError` 打死 worker 线程——Node 按「无前段」
731
+ * 处理(与 `answerRowSid` / `payloadParentId` 同姿态)。
732
+ *
733
+ * @param {unknown} payload
734
+ * @returns {Record<string, any>}
735
+ */
736
+ function payloadObject(payload) {
737
+ let parsed;
738
+ try {
739
+ parsed = JSON.parse(payload || '{}');
740
+ } catch {
741
+ return {};
742
+ }
743
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
744
+ return parsed;
745
+ }
746
+
747
+ /**
748
+ * `STAGES.index(stage)` 的等价物(见文件头差异㉒):缺项**抛错**(Python `tuple.index` 的
749
+ * `ValueError`),绝不静默返回 -1——那是「静默降级」(`_apply_bug_result` 的 pipeline 分支
750
+ * 靠这次抛出把该轮打成失败)。
751
+ *
752
+ * @param {string} stage
753
+ * @returns {number}
754
+ */
755
+ function stageIndex(stage) {
756
+ const idx = STAGES.indexOf(stage);
757
+ if (idx < 0) throw new RangeError(`'${stage}' is not in tuple`);
758
+ return idx;
759
+ }
760
+
761
+ // --------------------------------------------------------------------------
762
+ // 缝(注入点;默认行为逐条见文件头「缝」)
763
+ // --------------------------------------------------------------------------
764
+
765
+ /**
766
+ * 三个单元执行体的**未接线文案**(按到期里程碑点名)。`_worker` 分派到未接线的口时抛
767
+ * `UnitHandlerMissing`,message = `「<本表文案>:<键> 无法执行」`。
768
+ */
769
+ export const UNIT_HANDLER_MISSING = Object.freeze({
770
+ task: '任务单元轮次执行体未移植(M6)',
771
+ card: '卡片单元起跑体未移植(M5)',
772
+ // 2026-10-16:实现体 M5 就在 `board.deliverAnswerUnit`,缺的只是 server.js 的注册
773
+ // ⇒ 文案改成「未接线」(原「未移植」在真机上把人指向了错的排查方向)。
774
+ answer: '答案送达体未接线(setUnitHandlers 缺 deliverAnswerUnit)',
775
+ });
776
+
777
+ /** 单元执行体未接线(`setUnitHandlers` 的缺口):具名错误,调用方可 `instanceof` 判定 */
778
+ export class UnitHandlerMissing extends Error {
779
+ /** @param {string} message 中文文案(点明到期里程碑 + 队列键) */
780
+ constructor(message) {
781
+ super(message);
782
+ this.name = 'UnitHandlerMissing';
783
+ }
784
+ }
785
+
786
+ /**
787
+ * `a:` 执行体**异常兜底**放回时的退避秒数(见文件头差异⑫②)。
788
+ *
789
+ * 为什么是 30.0:与业务退避 `board.ANSWER_BACKOFF_S`(`_answer_retry_or_fail`)同档——
790
+ * 「送达失败等一会儿再投」在两侧是同一个语义;**不 import board**(模块级 import 会成环,
791
+ * 见文件头差异㉘),故此处独立定义并在单测里钉住「放回后当轮不可再拾取」。
792
+ */
793
+ export const ANSWER_EXC_BACKOFF_S = 30.0;
794
+
795
+ /** 单元执行体注册表(`setUnitHandlers` 唯一写点;全 null = 未接线) */
796
+ let UNIT_HANDLERS = Object.freeze({
797
+ processTask: null, processCard: null, deliverAnswerUnit: null,
798
+ });
799
+
800
+ /**
801
+ * 注册三个单元执行体(M4 缝;**整组替换**,与 `chat.setBoardBridge` 同款语义):
802
+ *
803
+ * - `processTask(taskId)`:对应 `Runner._process_task`(**M6**:轮次循环/发压/复测);
804
+ * - `processCard(cardId)`:对应 `Runner._process_card`(**M5**:卡片起跑体;返回
805
+ * `true`=起跑成功 / `false`=起会话失败 / `null`=卡片缺失或状态不符。**返回 `undefined`
806
+ * 按 `null` 处理**——JS 里「没写 return」正是 Python `return None` 的等价物);
807
+ * - `deliverAnswerUnit(cardId)`:对应 `board._deliver_answer_unit`(**M5**:作答送达)。
808
+ *
809
+ * 三个口可以是同步函数,也可以返回 promise(`runPickedUnit` 一律 await)。未接线的口被
810
+ * 分派到时抛 `UnitHandlerMissing`——**绝不静默成功**;异常落点照 Python 的 except/finally
811
+ * 分支收口(t:/c: 行落终态、a: 行放回 waiting)。
812
+ *
813
+ * ⚠ `a:` 口未接线时该单元会「放回 waiting → 立即被重拾 → 再抛」热旋(Python 同形:其
814
+ * `board._deliver_answer_unit` 正常不抛)——运行日志里能直接看到,接线后消失。
815
+ * **2026-10-16(M10)`server.js` 已把三路口全部接真**(`a:` 那次是真机热旋被发现后补的:
816
+ * 50% CPU + 日志 5.5KB/s);本文件不校验接线态,接线判据在 `server-wiring.test.mjs`「接线④」。
817
+ *
818
+ * 只传一个口即把其余口置空;传 `{}` / `null` 全注销(回到未接线态)。
819
+ *
820
+ * @param {{processTask?: Function|null, processCard?: Function|null,
821
+ * deliverAnswerUnit?: Function|null}|null} [handlers]
822
+ */
823
+ export function setUnitHandlers(handlers = {}) {
824
+ const h = handlers || {};
825
+ UNIT_HANDLERS = Object.freeze({
826
+ processTask: typeof h.processTask === 'function' ? h.processTask : null,
827
+ processCard: typeof h.processCard === 'function' ? h.processCard : null,
828
+ deliverAnswerUnit: typeof h.deliverAnswerUnit === 'function' ? h.deliverAnswerUnit : null,
829
+ });
830
+ }
831
+
832
+ /** 当前三个单元执行体的接线态(只读快照;单测/接线自检用) */
833
+ export function unitHandlers() {
834
+ return { ...UNIT_HANDLERS };
835
+ }
836
+
837
+ /** `board.finish` 收尾口(M5 缝;单一注册位,后注册覆盖先注册) */
838
+ let BOARD_FINISH_HOOK = null;
839
+
840
+ /**
841
+ * 注册 `board.finish(key, reason)` 等价口(**M5 接线**;`null` 注销)。
842
+ *
843
+ * 调用面只有 `runPickedUnit` 的 c: finally 两支「起跑失败回滚」(`card_ok === false` 或
844
+ * `null`)——Python 那里是 `board.finish(key, "起跑失败回滚")`,属尽力而为的回滚收尾
845
+ * (失败只打日志)。故未接线时**只打一行** `[runner] board.finish 未接线(M5)…`、不抛。
846
+ *
847
+ * @param {((key: string, reason: string) => any)|null} [fn]
848
+ */
849
+ export function setBoardFinishHook(fn) {
850
+ BOARD_FINISH_HOOK = typeof fn === 'function' ? fn : null;
851
+ }
852
+
853
+ /** 周期自检的 board 钩子(`board.unit_managed` 收口豁免判据 / `board.refresh_ext_rows` 对账) */
854
+ let BOARD_UNIT_MANAGED = null;
855
+ let BOARD_REFRESH_EXT_ROWS = null;
856
+
857
+ /**
858
+ * 注册周期自检的两个 board 读钩子(**M5 接线**;缺省 `null`):
859
+ *
860
+ * - `unitManaged(row)` → boolean:`board.unit_managed`(平台在管 `_RUNS` 单元的收口豁免,
861
+ * 自检不越权收口);未接线传 null = 无豁免判据(照 Python 缺省);
862
+ * - `refreshExtRows(reason)`:`board.refresh_ext_rows`(外部条目周期对账,收僵尸 ext 行);
863
+ * 未接线**跳过这一拍**(等价于 Python 里该调用抛异常后只打一行的那一支)。
864
+ *
865
+ * @param {{unitManaged?: Function|null, refreshExtRows?: Function|null}} [hooks]
866
+ */
867
+ export function setBoardSelfcheckHooks(hooks = {}) {
868
+ const h = hooks || {};
869
+ BOARD_UNIT_MANAGED = typeof h.unitManaged === 'function' ? h.unitManaged : null;
870
+ BOARD_REFRESH_EXT_ROWS = typeof h.refreshExtRows === 'function' ? h.refreshExtRows : null;
871
+ }
872
+
873
+ /** 任务失败旁路通知口(`feishu.task_failed`;**M7 接线**) */
874
+ let FAILURE_NOTIFIER = null;
875
+
876
+ /**
877
+ * 注册任务失败通知口(**M7 接线**;`null` 注销)。
878
+ *
879
+ * 签名照 `feishu.task_failed(project_id, task_id, task_name, task_type, error, skipped_n=0)`:
880
+ * `(projectId, taskId, taskName, taskType, error, skippedN) => any`(可返回 promise)。
881
+ * 未接线时 `notifyTaskFailed` **静默返回**——照 Python `except: pass`「通知永不影响调度
882
+ * 主流程」;任何异常同样被吞。
883
+ *
884
+ * @param {Function|null} [fn]
885
+ */
886
+ export function setFailureNotifier(fn) {
887
+ FAILURE_NOTIFIER = typeof fn === 'function' ? fn : null;
888
+ }
889
+
890
+ // --------------------------------------------------------------------------
891
+ // Waiter:`threading.Condition` 的 Node 对应物(只保留唤醒语义,见文件头差异②)
892
+ // --------------------------------------------------------------------------
893
+
894
+ /**
895
+ * 等待/唤醒面(`self._cond` 的 Node 形态):`notifyAll()` 唤醒**全部**等待者,
896
+ * `wait(timeoutMs)` 返回 promise 且**超时也 resolve**(= Python `Condition.wait(timeout)`
897
+ * 返回 False 的那一支;返回值本文件不使用)。
898
+ *
899
+ * 与 Python 的两处形态差异:
900
+ * - **没有锁**:Node 单事件循环下没有并发交错可保护(临界区 = 同一 tick 的同步段);
901
+ * - **定时器 unref**:不让一个空转的等待吊住进程退出(= Python daemon 线程语义)。
902
+ */
903
+ export class Waiter {
904
+ constructor() {
905
+ /** @type {Set<Function>} 当前等待者(resolve 回调) */
906
+ this._waiters = new Set();
907
+ }
908
+
909
+ /** 唤醒全部等待者(Python `notify_all()`)。 */
910
+ notifyAll() {
911
+ const waiters = [...this._waiters];
912
+ this._waiters.clear();
913
+ for (const wake of waiters) wake();
914
+ }
915
+
916
+ /**
917
+ * 等一次唤醒或超时(两者都 resolve;无返回值契约)。
918
+ * @param {number} [timeoutMs] 0/负数 = 无限等(本文件恒传 5s 节拍)
919
+ * @returns {Promise<void>}
920
+ */
921
+ wait(timeoutMs = 0) {
922
+ return new Promise((resolve) => {
923
+ let timer = null;
924
+ let done = false;
925
+ const finish = () => {
926
+ if (done) return;
927
+ done = true;
928
+ this._waiters.delete(finish);
929
+ if (timer !== null) clearTimeout(timer);
930
+ resolve();
931
+ };
932
+ this._waiters.add(finish);
933
+ if (timeoutMs && timeoutMs > 0) {
934
+ timer = setTimeout(finish, timeoutMs); // 保持 ref:见文件头差异④
935
+ }
936
+ });
937
+ }
938
+
939
+ /** 当前等待者个数(诊断/单测用) */
940
+ get size() {
941
+ return this._waiters.size;
942
+ }
943
+ }
944
+
945
+ // --------------------------------------------------------------------------
946
+ // 模块级可写单例(Python `runner.INSTANCE`)
947
+ // --------------------------------------------------------------------------
948
+
949
+ /**
950
+ * 模块级单例引用(`runner.INSTANCE`):server 构造单例后经 `setInstance` 赋值,board 侧与
951
+ * 本文件的自检/对账经此访问调度器(None 时 board 退化为直起路径、对账/自检直接 return)。
952
+ *
953
+ * **ESM 不能从外部写导出变量**(`runnerMod.INSTANCE = x` 会 TypeError,Python 侧是普通模块
954
+ * 全局)⇒ 写口是 `setInstance(inst)`(见文件头差异⑧)。
955
+ *
956
+ * @type {Runner|null}
957
+ */
958
+ export let INSTANCE = null;
959
+
960
+ /**
961
+ * 写模块级单例(`runner.INSTANCE = inst` 的等价口)。传 `null` 拆离(单测还原:自检循环
962
+ * 下一拍发现 INSTANCE 为 None 即自终)。
963
+ * @param {Runner|null} inst
964
+ */
965
+ export function setInstance(inst) {
966
+ INSTANCE = inst === undefined ? null : inst;
967
+ }
968
+
969
+ // --------------------------------------------------------------------------
970
+ // 私有小工具(不导出;逐条见文件头差异⑬⑭⑯)
971
+ // --------------------------------------------------------------------------
972
+
973
+ /**
974
+ * 异常 → 文本(照 Python `str(e)`)。`DshDriverError` 覆写了 `toString()`(纯消息)故走
975
+ * `String()`;其余 `Error` 取 `message`(不把 `Error: ` 前缀带进用户可见文案)。
976
+ * @param {unknown} err
977
+ * @returns {string}
978
+ */
979
+ function errText(err) {
980
+ if (err === null || err === undefined) return String(err);
981
+ if (err instanceof DshDriverError) return String(err);
982
+ if (err instanceof Error) return err.message;
983
+ return String(err);
984
+ }
985
+
986
+ /**
987
+ * 本地时间戳文本(`YYYY-MM-DD HH:MM:SS`)→ epoch 秒;解析失败抛错。
988
+ *
989
+ * 等价于 `time.mktime(time.strptime(text, "%Y-%m-%d %H:%M:%S"))`(月/日/时/分/秒放宽到
990
+ * 1-2 位,照 strptime 的非补零接受面)。waitq.js 有同口径私有实现 `parseLocalSeconds`
991
+ * 但未导出,故这里保留一份私有前身(调用方一律 try/catch 并回落「当前时刻」)。
992
+ *
993
+ * @param {unknown} text
994
+ * @returns {number} epoch 秒
995
+ */
996
+ function localSeconds(text) {
997
+ if (typeof text !== 'string') throw new TypeError('时间戳必须是字符串');
998
+ const m = /^(\d{4})-(\d{1,2})-(\d{1,2}) (\d{1,2}):(\d{1,2}):(\d{1,2})$/.exec(text);
999
+ if (m === null) throw new RangeError(`时间戳格式不符: ${text}`);
1000
+ const ms = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]),
1001
+ Number(m[4]), Number(m[5]), Number(m[6])).getTime();
1002
+ if (Number.isNaN(ms)) throw new RangeError(`时间戳不可解析: ${text}`);
1003
+ return ms / 1000;
1004
+ }
1005
+
1006
+ /**
1007
+ * Python 类型名(只用于错误文案;`int`/`float` 在 JS 里已不可分,按是否整数猜)。
1008
+ * 与 `rag.js` / `prompts.js` 的同名私有实现同口径(各文件不互相 import 私有名)。
1009
+ * @param {unknown} value
1010
+ * @returns {string}
1011
+ */
1012
+ function pyTypeName(value) {
1013
+ if (value === null || value === undefined) return 'NoneType';
1014
+ if (Array.isArray(value)) return 'list';
1015
+ switch (typeof value) {
1016
+ case 'boolean': return 'bool';
1017
+ case 'number': return Number.isInteger(value) ? 'int' : 'float';
1018
+ case 'string': return 'str';
1019
+ case 'object': return 'dict';
1020
+ default: return typeof value;
1021
+ }
1022
+ }
1023
+
1024
+ /** Python `repr(str)` 的可用子集(单引号,转义反斜杠与单引号)——只用于 `KeyError` 文案 */
1025
+ function pyReprString(text) {
1026
+ return `'${String(text).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
1027
+ }
1028
+
1029
+ /**
1030
+ * Python `d.get(k[, default])`(见文件头差异㊶):只有**键不存在**才给默认值(键在但为
1031
+ * `None` 仍返回 `None`);**非 dict 抛 `AttributeError`**(`'list' object has no attribute 'get'`,
1032
+ * 照 Python 的 `except` 元组口径:这类异常在 Python 侧一样外抛)。
1033
+ *
1034
+ * @param {unknown} value
1035
+ * @param {string} key
1036
+ * @param {unknown} [fallback]
1037
+ * @returns {unknown}
1038
+ */
1039
+ function pyGet(value, key, fallback = undefined) {
1040
+ if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value)) {
1041
+ throw new AttributeError(`'${pyTypeName(value)}' object has no attribute 'get'`);
1042
+ }
1043
+ return Object.hasOwn(value, key) ? value[key] : fallback;
1044
+ }
1045
+
1046
+ /** Python `d[k]`(键不存在抛 `KeyError`,消息是键的 repr) */
1047
+ function pyGetItem(value, key) {
1048
+ if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value)) {
1049
+ throw new TypeError(`'${pyTypeName(value)}' object is not subscriptable`);
1050
+ }
1051
+ if (!Object.hasOwn(value, key)) throw new KeyError(pyReprString(key));
1052
+ return value[key];
1053
+ }
1054
+
1055
+ /**
1056
+ * Python `str(x)`(本文件用到的子集:`None` → `"None"`、`True/False`、数字、字符串;
1057
+ * 容器走近似的 repr)。**不能用 `String(x)`**:`None`/`True` 会变成 `null`/`true`,
1058
+ * 那是与 Python 逐字节不可比的形态(见文件头差异㊶)。
1059
+ * @param {unknown} value
1060
+ * @returns {string}
1061
+ */
1062
+ function pyStr(value) {
1063
+ if (value === null || value === undefined) return 'None';
1064
+ if (typeof value === 'string') return value;
1065
+ if (typeof value === 'boolean') return value ? 'True' : 'False';
1066
+ if (typeof value === 'number') return String(value);
1067
+ if (Array.isArray(value)) return `[${value.map(pyRepr).join(', ')}]`;
1068
+ if (typeof value === 'object') {
1069
+ return `{${Object.keys(value).map((k) => `${pyRepr(k)}: ${pyRepr(value[k])}`).join(', ')}}`;
1070
+ }
1071
+ throw new TypeError(`str() 不支持 ${typeof value}`);
1072
+ }
1073
+
1074
+ /** Python `repr(x)` 的可用子集(str 走 repr 规则,其余用 `pyStr`) */
1075
+ function pyRepr(value) {
1076
+ return typeof value === 'string' ? pyReprString(value) : pyStr(value);
1077
+ }
1078
+
1079
+ /**
1080
+ * Python `int(x)`(见文件头差异㊶):`bool` → 0/1、有限数字**向零截断**、字符串按十进制解析
1081
+ * (容两侧空白与正负号);解析失败抛 `ValueError`、非有限浮点抛 `OverflowError`、
1082
+ * `None` 抛 `TypeError`——**三类都不吞**,由调用方按 Python 的 `except` 元组各自处置
1083
+ * (`_should_stop` 的 duration 支三条全吃、`_run_round_dshplugin` 的 stats 支只吃
1084
+ * `(ValueError, TypeError, KeyError)` ⇒ 溢出照 Python 外抛)。
1085
+ *
1086
+ * @param {unknown} value
1087
+ * @returns {number}
1088
+ */
1089
+ function pyInt(value) {
1090
+ if (value === null || value === undefined) {
1091
+ throw new TypeError(`int() argument must be a string or a number, not '${pyTypeName(value)}'`);
1092
+ }
1093
+ if (typeof value === 'boolean') return value ? 1 : 0;
1094
+ if (typeof value === 'number') {
1095
+ if (!Number.isFinite(value)) throw new OverflowError(`cannot convert float infinity to integer`);
1096
+ return Math.trunc(value);
1097
+ }
1098
+ const n = pyIntOr(value, null);
1099
+ if (n === null) {
1100
+ throw new ValueError(`invalid literal for int() with base 10: ${pyStr(value)}`);
1101
+ }
1102
+ return n;
1103
+ }
1104
+
1105
+ /** 流任务收口等待上限(毫秒;Python `stream_thread.join(timeout=5)` 的 5 秒) */
1106
+ const STREAM_JOIN_TIMEOUT_MS = 5000;
1107
+
1108
+ /** 「等 `ms` 毫秒」的定时器 promise(**unref**:它是兜底上限,不该让进程因它退不出去,见差异④) */
1109
+ function joinTimeout(ms) {
1110
+ return new Promise((resolve) => {
1111
+ const timer = setTimeout(resolve, ms);
1112
+ if (typeof timer.unref === 'function') timer.unref();
1113
+ });
1114
+ }
1115
+
1116
+ /**
1117
+ * 读日志末尾 `limit` 行文本(`runner.read_log_tail`,runner.py:2340)。编码容错 = Node 的
1118
+ * utf8 解码(孤立字节落 U+FFFD,照 `errors="replace"`);换行先经 `readText` 归一
1119
+ * (Python 文本模式是 universal newlines:`\r\n`/`\r` → `\n`)。
1120
+ *
1121
+ * `limit` 的切片语义照 Python `f.readlines()[-limit:]`:`limit>0` 取末尾 limit 行、
1122
+ * `limit=0` 取**全文**(`[-0:]` 即 `[0:]`)、`limit<0` **丢掉开头 |limit| 行**(`[|limit|:]`,
1123
+ * 超过行数则空串)。**R3 修正**:M4 起的私有前身写成 `slice(max(0, len - limit))`,
1124
+ * 对 `limit<=0` 会返回空串(`slice(len)`)——与 Python 差了「0 ⇒ 全文、负数 ⇒ 丢开头」
1125
+ * 两条;调用面(`_kill_residual` / `_tail_text`)都用正数故一直没暴露。见文件头差异㊹。
1126
+ *
1127
+ * @param {string} logPath
1128
+ * @param {number} [limit]
1129
+ * @returns {string}
1130
+ */
1131
+ export function readLogTail(logPath, limit = 200) {
1132
+ if (!logPath || !isFile(logPath)) return '';
1133
+ const text = readText(logPath); // OSError → ''(Python 同)
1134
+ // Python `readlines()` 的等价切分:**每行保留自己的换行**(末行可能没有),
1135
+ // 故不能用 `split("\n")`(那会为结尾换行多造一个空元素,limit 小的时候结果不同)。
1136
+ const lines = text.match(/[^\n]*\n|[^\n]+/g) || [];
1137
+ // `limit === 0` 必须单独判:JS 里 `len - 0 === len` ⇒ `slice(len)` 会给出空串,
1138
+ // 而 Python `[-0:]` 就是 `[0:]`(全文)。
1139
+ let start;
1140
+ if (limit > 0) start = Math.max(0, lines.length - limit);
1141
+ else if (limit === 0) start = 0;
1142
+ else start = Math.min(-limit, lines.length);
1143
+ return lines.slice(start).join('');
1144
+ }
1145
+
1146
+ /**
1147
+ * 活跃 `c:` 行在场判定(`board._card_unit_active` 的就地等价实现,见文件头差异⑬;
1148
+ * board.js 的 M3 只读子集没有这个口):state ∈ (starting, running, finishing)。
1149
+ *
1150
+ * 语义逐字照 board.py:1880-1899:**行在 = 单元在管**(拾取→会话证实窗口 / finishing 收尾
1151
+ * 瞬态都算),读失败按不在场(不阻断开始路径)。
1152
+ *
1153
+ * @param {number} cardId
1154
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1155
+ * @returns {boolean}
1156
+ */
1157
+ function cardUnitActive(cardId, options = {}) {
1158
+ try {
1159
+ const row = waitq.getActive(waitq.KIND_CARD, cardId, options);
1160
+ return row !== null && (row.state === waitq.STARTING || row.state === waitq.RUNNING
1161
+ || row.state === waitq.FINISHING);
1162
+ } catch {
1163
+ return false;
1164
+ }
1165
+ }
1166
+
1167
+ /**
1168
+ * 卡片记错(`db.update_board_card(card_id, last_error=…, last_error_at=…)` 的就地等价实现,
1169
+ * 见文件头差异⑬):db.js 尚无卡片写口,故就地 UPDATE 两列 + `updated_at`(SQL 与
1170
+ * `db.py:update_board_card` 同款:字段白名单内的两列、无 `column_key` ⇒ 不派生 done_at /
1171
+ * unread)。写失败上抛由调用方吞(worker 的 c: 异常分支照 Python `except Exception: pass`)。
1172
+ *
1173
+ * @param {number} cardId
1174
+ * @param {string} message 截断后的错误文本
1175
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1176
+ */
1177
+ function setCardLastError(cardId, message, options = {}) {
1178
+ withWriteDb(options, (handle) => {
1179
+ handle.prepare('UPDATE board_cards SET last_error=?,last_error_at=?, updated_at=?'
1180
+ + ' WHERE id=?').run(message, nowStr(), nowStr(), cardId);
1181
+ });
1182
+ }
1183
+
1184
+ // --------------------------------------------------------------------------
1185
+ // waitq 权威 helper(runner.py:84-186)
1186
+ // --------------------------------------------------------------------------
1187
+
1188
+ /**
1189
+ * 任务失败/中断飞书旁路推送(`notify_task_failed`,runner.py:84):读任务行取项目/名称后转
1190
+ * `feishu.task_failed`;任务行已删或任何异常静默跳过(**通知永不影响调度主流程**)。
1191
+ *
1192
+ * **async**(飞书通知可能是远端 IO,见文件头差异⑨):未接线 M7 缝时静默返回。
1193
+ *
1194
+ * @param {number} taskId
1195
+ * @param {string} error
1196
+ * @param {number} [skippedN] 连带跳过的后段任务条数
1197
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1198
+ * @returns {Promise<void>}
1199
+ */
1200
+ export async function notifyTaskFailed(taskId, error, skippedN = 0, options = {}) {
1201
+ try {
1202
+ const t = getTask(taskId, options);
1203
+ if (t === null || t === undefined) return;
1204
+ if (FAILURE_NOTIFIER === null) return; // M7 未接线:静默(照 except: pass)
1205
+ await FAILURE_NOTIFIER(t.project_id, t.id, t.name, t.task_type || 'normal',
1206
+ error, skippedN);
1207
+ } catch {
1208
+ // 照 Python:通知异常一律吞(绝不把调度带崩)
1209
+ }
1210
+ }
1211
+
1212
+ /**
1213
+ * answer 等待项行 meta 里的目标会话 sid(`_answer_row_sid`,runner.py:106;坏 JSON 按空串):
1214
+ * `_pick_locked` 的「占位者即本会话自身」判定用。
1215
+ *
1216
+ * 形态差异:Python 只吃 `ValueError`(坏 JSON),非 dict 的 `AttributeError` 会外抛;Node
1217
+ * 侧一律按空串(见文件头差异⑮)。
1218
+ *
1219
+ * @param {object} row wait_items 行
1220
+ * @returns {string}
1221
+ */
1222
+ export function answerRowSid(row) {
1223
+ try {
1224
+ const meta = JSON.parse((row && row.meta) || '{}');
1225
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return '';
1226
+ return meta.sid ? String(meta.sid) : '';
1227
+ } catch {
1228
+ return '';
1229
+ }
1230
+ }
1231
+
1232
+ /**
1233
+ * 同项目 + 同 sid 的**运行中** `m:` 行键集(`_session_msg_keys`,runner.py:115;R7 折抵判据)。
1234
+ *
1235
+ * 唯一判据出处:a: 单元折抵(`answerExemptKeysLocked`)与 `sessionHolds` 读口共用——
1236
+ * 「占位者即该会话自己的消息单元」在行口径下的等价形态。只认运行中行
1237
+ * (state ∈ PREFIX_STATES):等待中的消息尚未占位。
1238
+ *
1239
+ * @param {object[]} rows 已取到的活跃行清单(调用方传,免二次全表读)
1240
+ * @param {number} projectId
1241
+ * @param {string} sid
1242
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1243
+ * @returns {Set<string>} 队列键集(`m:<msg_id>`)
1244
+ */
1245
+ export function sessionMsgKeys(rows, projectId, sid, options = {}) {
1246
+ if (!sid) return new Set();
1247
+ const mids = new Set();
1248
+ for (const r of rows) {
1249
+ if (r.project_id === projectId && r.kind === waitq.KIND_MSG
1250
+ && waitq.PREFIX_STATES.includes(r.state)) {
1251
+ mids.add(String(r.target_id));
1252
+ }
1253
+ }
1254
+ if (mids.size === 0) return new Set();
1255
+ const out = new Set();
1256
+ for (const m of waitq.msgRows({ sid }, options)) {
1257
+ if (mids.has(String(m.id))) out.add(`m:${m.id}`);
1258
+ }
1259
+ return out;
1260
+ }
1261
+
1262
+ /**
1263
+ * 队列键拾取 → 对应等待项 claim(`_claim_unit`,runner.py:161;P4:四类全直调,权威互斥点)。
1264
+ *
1265
+ * `false` = 行已被取消(停卡/移列/停止)或被「立即送达/立即注入」抢占:调用方撤销本次接手
1266
+ * 回等待循环,终态由取消方/直投方负责。claim 瞬时故障(sqlite 抖动)不得炸掉 worker
1267
+ * 循环——异常按 claim 失败处理,行保持 waiting 待下轮。
1268
+ *
1269
+ * @param {string} key 队列键(`t:|c:|m:|a:` + target)
1270
+ * @param {string} [claimer]
1271
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1272
+ * @returns {boolean}
1273
+ */
1274
+ export function claimUnit(key, claimer = 'worker', options = {}) {
1275
+ const kind = KIND_OF_KEY[String(key).charAt(0)];
1276
+ if (kind === undefined) return true; // 未知键前缀:照 Python(无 kind 即放行)
1277
+ try {
1278
+ return Boolean(waitq.claimByTarget(kind, String(key).slice(2), claimer, options));
1279
+ } catch (err) {
1280
+ console.log(`[waitq] ${kind} claim 失败(按未拾取处理): ${errText(err)}`);
1281
+ return false;
1282
+ }
1283
+ }
1284
+
1285
+ /**
1286
+ * 占位者明细文本(`holders_text`,runner.py:177;等待日志用):项目运行前缀成员明细
1287
+ * 「key(evidence,Ns)」,空 → 「未知」。占位者 = 该项目前缀行(`projectHolderDetails`)。
1288
+ *
1289
+ * @param {Runner} inst
1290
+ * @param {number} projectId
1291
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1292
+ * @returns {string}
1293
+ */
1294
+ export function holdersText(inst, projectId, options = {}) {
1295
+ const now = Date.now() / 1000;
1296
+ const items = inst.projectHolderDetails(projectId, options);
1297
+ if (!items.length) return '未知';
1298
+ return items.map((d) => `${d.key}(${d.evidence || '未知'},`
1299
+ + `${Math.trunc(now - d.since)}s)`).join(',');
1300
+ }
1301
+
1302
+ // --------------------------------------------------------------------------
1303
+ // Runner(runner.py:188-1160)
1304
+ // --------------------------------------------------------------------------
1305
+
1306
+ /**
1307
+ * 单例运行器:`submit` / `submitCard` 入队(任务与看板卡片混合 FIFO),多个执行循环按项目
1308
+ * 串行调度(同项目串行,跨项目并行)。
1309
+ *
1310
+ * 形态:Python 的 6 条 daemon 线程 → Node 的 6 个 async 循环;`self._cond`(Condition)→
1311
+ * `this._cond`(`Waiter`,只保留唤醒语义)。**所有 `*Locked` 方法体里不许出现 await**
1312
+ * (= Python 持 `_cond` 的原子段,见文件头差异③)。
1313
+ */
1314
+ export class Runner {
1315
+ /**
1316
+ * 同时在跑的单元数上限(= 执行循环数):同一项目内任务严格串行,不同项目的单元可并行;
1317
+ * agent 真实并发上限仍由「每项目前缀窗口」约束(serial 1 / parallel 5,t: 恒 1)。
1318
+ */
1319
+ static WORKERS = WORKERS;
1320
+
1321
+ /**
1322
+ * 压测任务首次发压的轮次号:第 1 轮 agent 出方案包,首次发压记为第 2 轮;复压在此之上
1323
+ * 递增(此常量只作为「首次发压」的基准,存量语义)。
1324
+ */
1325
+ static LOAD_ROUND_NO = LOAD_ROUND_NO;
1326
+
1327
+ /**
1328
+ * 脚本复测的单脚本超时(秒)与并发数(`SCRIPT_RETEST_TIMEOUT` / `SCRIPT_WORKERS`,
1329
+ * runner.py:1519)。构造器把它们**镜像到实例属性**(Python 是类属性、经 `self.` 读;
1330
+ * JS 静态字段不沿原型链 ⇒ `this.X` 取不到),方法体读 `this.SCRIPT_*`——单测因此可给
1331
+ * 实例覆写一个极小超时来钉超时支。
1332
+ */
1333
+ static SCRIPT_RETEST_TIMEOUT = SCRIPT_RETEST_TIMEOUT;
1334
+ static SCRIPT_WORKERS = SCRIPT_WORKERS;
1335
+
1336
+ /** parallel 模式队首责任区长度(= 模块级 `PARALLEL_PREFIX_N`;serial 恒 1,t: 恒 1) */
1337
+ static PARALLEL_PREFIX_N = PARALLEL_PREFIX_N;
1338
+
1339
+ /**
1340
+ * @param {boolean} [bootGate] `true`:执行循环就绪后先停在**启动闸**后,直到
1341
+ * `releaseBootGate()` 放行(服务启动序:init_db → recover → board.recover →
1342
+ * reconcile_units 全跑完再放行);默认 `false` = 构造即可拾取(单测直建语义不变)。
1343
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1344
+ * 实例级 db 选项袋(Node 追加:Python 靠模块级缺省库路径,Node 的读/写口一律收末位
1345
+ * options;实例级袋子是该类所有方法的缺省值,单测用它把整实例钉在临时库上)。
1346
+ */
1347
+ constructor(bootGate = false, options = {}) {
1348
+ /** 等待/唤醒面(`self._cond`;无锁语义,见文件头差异②) */
1349
+ this._cond = new Waiter();
1350
+ /** 实例级 db 选项袋(见构造器 @param) */
1351
+ this._options = { ...options };
1352
+ /** task_id(int) -> 当前 agent 进程句柄(`{pid}`;仅任务单元,**键保持 int**) */
1353
+ this._procs = new Map();
1354
+ /** 已请求停止的任务 id(int 集合;`Set`) */
1355
+ this._stop_requested = new Set();
1356
+ /** 行判活探针(board 经 `setLivenessProbe` 注册;`_liveness_probe`) */
1357
+ this._liveness_probe = null;
1358
+ /**
1359
+ * 脚本复测的单脚本超时(秒)与并发数:Python 是**类属性**、经 `self.` 读
1360
+ * (`self.SCRIPT_RETEST_TIMEOUT` / `self.SCRIPT_WORKERS`)⇒ 实例上镜像一份
1361
+ * (JS 的静态字段**不沿原型链**,`this.X` 取不到;镜像后与 Python 同形:
1362
+ * 单测可给实例覆写一个极小超时来钉超时支)。类常量仍以
1363
+ * `Runner.SCRIPT_RETEST_TIMEOUT` / `Runner.SCRIPT_WORKERS` 为准。
1364
+ */
1365
+ this.SCRIPT_RETEST_TIMEOUT = Runner.SCRIPT_RETEST_TIMEOUT;
1366
+ this.SCRIPT_WORKERS = Runner.SCRIPT_WORKERS;
1367
+ /**
1368
+ * turn 启动宽限(秒,`DSH_TURN_START_GRACE`):Python 在 `_wait_turn_end_dsh` 里读
1369
+ * **模块全局**(`runner.DSH_TURN_START_GRACE`,单测可 monkeypatch 它来钉「turn 未启动」支);
1370
+ * Node 的 ESM 导出不可写 ⇒ 镜像到实例属性供单测覆写(同 `SCRIPT_*` 的处置,见文件头差异㊷)。
1371
+ */
1372
+ this.DSH_TURN_START_GRACE = DSH_TURN_START_GRACE;
1373
+ /** 启动闸(`null` = 无闸/已放行;见 `releaseBootGate`) */
1374
+ this._bootGate = bootGate ? createGate() : null;
1375
+ /** 执行循环的 promise(Python `_threads`;诊断用,不 await 到底) */
1376
+ this._workers = [];
1377
+ for (let i = 0; i < Runner.WORKERS; i += 1) this._workers.push(this.worker());
1378
+ }
1379
+
1380
+ // ---------- 队列键规范(`_tkey` / `_ckey` / `_mkey` / `_akey`) ----------
1381
+
1382
+ /** 任务队列键(`t:<task_id>`) */
1383
+ static tkey(taskId) {
1384
+ return `t:${taskId}`;
1385
+ }
1386
+
1387
+ /** 看板卡片队列键(`c:<card_id>`) */
1388
+ static ckey(cardId) {
1389
+ return `c:${cardId}`;
1390
+ }
1391
+
1392
+ /** 会话消息队列键(`m:<msg_id>`;msg_id 字符串,不带冒号) */
1393
+ static mkey(msgId) {
1394
+ return `m:${msgId}`;
1395
+ }
1396
+
1397
+ /** 答案队列键(`a:<card_id>`;target=卡片 id,与 c: 同目标空间、前缀不同) */
1398
+ static akey(cardId) {
1399
+ return `a:${cardId}`;
1400
+ }
1401
+
1402
+ /**
1403
+ * 解析本次调用的 db 选项袋:实例级 `_options` 打底,调用方给的覆盖(`null` = 原样用
1404
+ * 实例级)。
1405
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}|null} [options]
1406
+ * @returns {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}}
1407
+ */
1408
+ opt(options = null) {
1409
+ if (options === null || options === undefined) return this._options;
1410
+ return { ...this._options, ...options };
1411
+ }
1412
+
1413
+ // ---------- 对外 API ----------
1414
+
1415
+ /**
1416
+ * 入队任务(`submit`,runner.py:261;调用方已把 status 置为 queued)。
1417
+ *
1418
+ * P4:权威写入即入队(`waitq.enqueue` 插 waiting 行,行即队列位次),写表成功后 notify
1419
+ * ——「先表后队」由写序天然保证。行已活跃(重复 submit/排队中 restart)时幂等复用,
1420
+ * 位次不动。
1421
+ *
1422
+ * @param {number} taskId
1423
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1424
+ */
1425
+ submit(taskId, options = null) {
1426
+ const opts = this.opt(options);
1427
+ const t = getTask(taskId, opts);
1428
+ if (t !== null && t !== undefined) {
1429
+ waitq.enqueue(waitq.KIND_TASK, taskId, t.project_id, null, 0.0, opts);
1430
+ }
1431
+ this._cond.notifyAll();
1432
+ if (t !== null && t !== undefined && this.unitBusy(t.project_id, opts)) {
1433
+ // 排队等待诊断(P4 必带⑤):前排成员明细格式「key(evidence,Ns)」
1434
+ console.log(`[runner] 任务排队等待:t:${taskId}`
1435
+ + `(前排:${holdersText(this, t.project_id, opts)})`);
1436
+ }
1437
+ }
1438
+
1439
+ /**
1440
+ * 看板卡片入队(`submit_card`,runner.py:277;统一队列:与任务同 FIFO,同项目串行;幂等)。
1441
+ *
1442
+ * P4:经 `waitq.enqueueCard` 一个事务写等待项 + 排队占位投影(占位已在 doing+queue 时
1443
+ * 条件跳过——不覆盖 answer 路径空 block_text)。`extra`(打回意见)/ `fromColumn`
1444
+ * (起跑失败回列目标)随 meta 持久化(重启不再丢)。
1445
+ * `afterPrefix=true`(v2b T2):续跑/手动恢复(review/blocked)改走原子变体
1446
+ * `waitq.insertCardAfterPrefix`——插「运行前缀后」= 等待区最前(行+占位仍单事务)。
1447
+ *
1448
+ * @param {number} cardId
1449
+ * @param {string} [extra]
1450
+ * @param {string} [fromColumn]
1451
+ * @param {boolean} [afterPrefix]
1452
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1453
+ */
1454
+ submitCard(cardId, extra = '', fromColumn = '', afterPrefix = false, options = null) {
1455
+ const opts = this.opt(options);
1456
+ const card = getBoardCard(cardId, opts);
1457
+ if (card !== null && card !== undefined) {
1458
+ const from = fromColumn || card.column_key;
1459
+ if (afterPrefix) {
1460
+ waitq.insertCardAfterPrefix(cardId, card.project_id, extra, from,
1461
+ waitq.CARD_QUEUE_TEXT, opts);
1462
+ } else {
1463
+ waitq.enqueueCard(cardId, card.project_id, extra, from, waitq.CARD_QUEUE_TEXT, opts);
1464
+ }
1465
+ }
1466
+ this._cond.notifyAll();
1467
+ if (card !== null && card !== undefined && this.unitBusy(card.project_id, opts)) {
1468
+ console.log(`[runner] 卡片排队等待:c:${cardId}`
1469
+ + `(前排:${holdersText(this, card.project_id, opts)})`);
1470
+ }
1471
+ }
1472
+
1473
+ /**
1474
+ * 会话消息入队(`submit_msg`,runner.py:302;统一队列第三类单元;幂等)。
1475
+ *
1476
+ * 等待项/消息行的权威写入归 `chat.submit`(`waitq.msgEnqueue`)——行即登记,此处只唤醒
1477
+ * 执行循环(`msgId`/`projectId`/`sid` 形参保留调用面稳定,不再登记任何内存镜像)。
1478
+ *
1479
+ * @param {string} msgId
1480
+ * @param {number} [projectId]
1481
+ * @param {string} [sid]
1482
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1483
+ */
1484
+ submitMsg(msgId, projectId = 0, sid = '', options = null) {
1485
+ // 形参保留调用面稳定(Python 侧同样不读它们;行已由 chat.submit 写入)
1486
+ this._cond.notifyAll();
1487
+ }
1488
+
1489
+ /**
1490
+ * 会话消息出队(`remove_msg`,runner.py:318;取消排队;已在执行的消息不走本接口,由
1491
+ * `chat.stop` 中断)。权威取消归 `chat.cancel`——本函数只剩唤醒(让已取消的行尽快退出
1492
+ * 拾取视野)。
1493
+ *
1494
+ * @param {string} msgId
1495
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1496
+ */
1497
+ removeMsg(msgId, options = null) {
1498
+ this._cond.notifyAll();
1499
+ }
1500
+
1501
+ /**
1502
+ * 答案单元入队(`submit_answer`,runner.py:326;统一队列第四类单元;幂等)。
1503
+ *
1504
+ * 权威在 `wait_items` 表——调用方(board 作答排队路径 / recover 重建)先写行再调本方法,
1505
+ * 此处只唤醒执行循环。可拾取性由 `pickLocked` 按 answer 行判定。
1506
+ *
1507
+ * @param {number} cardId
1508
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1509
+ */
1510
+ submitAnswer(cardId, options = null) {
1511
+ this._cond.notifyAll();
1512
+ }
1513
+
1514
+ /**
1515
+ * 答案单元出队(`remove_answer`,runner.py:338;停卡/移列放弃、「立即送达」接管时摘除;
1516
+ * 幂等)。权威取消归 board 的 `waitq.cancel`,本函数只剩唤醒。
1517
+ *
1518
+ * @param {number} cardId
1519
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1520
+ */
1521
+ removeAnswer(cardId, options = null) {
1522
+ this._cond.notifyAll();
1523
+ }
1524
+
1525
+ /**
1526
+ * 消息是否仍排队等待执行(`msg_waiting`,runner.py:345;前台展示用;查等待项表——
1527
+ * waiting 行即排队)。
1528
+ *
1529
+ * @param {string} msgId
1530
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1531
+ * @returns {boolean}
1532
+ */
1533
+ msgWaiting(msgId, options = null) {
1534
+ const row = waitq.getActive(waitq.KIND_MSG, msgId, this.opt(options));
1535
+ return row !== null && row.state === waitq.STATE_WAITING;
1536
+ }
1537
+
1538
+ // ---------- 窗口几何(实例方法委托模块级实现,禁止第二份) ----------
1539
+
1540
+ /**
1541
+ * 补位窗口 N(`_window_n`,runner.py:350):t: 单元恒 1(任务串行红线);看板卡单元族
1542
+ * c:/m:/a: 按项目模式 parallel → `PARALLEL_PREFIX_N`、serial → 1。
1543
+ *
1544
+ * **委托**模块级 `windowN(kind, projectId, options)`(注意参数序与 Python 实例方法相反:
1545
+ * 模块级是(kind, projectId))。
1546
+ *
1547
+ * @param {number} projectId
1548
+ * @param {string} kind
1549
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1550
+ * @returns {number}
1551
+ */
1552
+ windowN(projectId, kind, options = null) {
1553
+ return windowN(kind, projectId, this.opt(options));
1554
+ }
1555
+
1556
+ /**
1557
+ * 前缀窗口是否已满(`_prefix_window_full`,runner.py:363):项目运行前缀**行数**
1558
+ * (state ∈ PREFIX_STATES)≥ N;前缀内有 t: 成员时恒 1。决策体 = 模块级
1559
+ * `prefixWindowBlocked`(key=null:项目整窗,不排除任何键)。
1560
+ *
1561
+ * @param {number} projectId
1562
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1563
+ * @returns {boolean}
1564
+ */
1565
+ prefixWindowFull(projectId, options = null) {
1566
+ const opts = this.opt(options);
1567
+ const keys = this.prefixMembers(activeItems(projectId, opts)).get(projectId) || new Set();
1568
+ return prefixWindowBlocked(keys, null, waitq.KIND_CARD,
1569
+ this.windowN(projectId, waitq.KIND_CARD, opts));
1570
+ }
1571
+
1572
+ /**
1573
+ * claim 后行口径窗口复判(`_unit_window_blocked`,runner.py:376;R1):`pickLocked` 窗口闸的
1574
+ * claim 侧孪生——项目前缀行扣除**本单元自己的行**与折抵键后是否 ≥ N。
1575
+ *
1576
+ * @param {number} projectId
1577
+ * @param {string} key
1578
+ * @param {string} kind
1579
+ * @param {Iterable<string>} [exempt]
1580
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1581
+ * @returns {boolean}
1582
+ */
1583
+ unitWindowBlocked(projectId, key, kind, exempt = [], options = null) {
1584
+ const opts = this.opt(options);
1585
+ const keys = this.prefixMembers(activeItems(projectId, opts)).get(projectId) || new Set();
1586
+ return prefixWindowBlocked(keys, key, kind, this.windowN(projectId, kind, opts), exempt);
1587
+ }
1588
+
1589
+ /**
1590
+ * 项目当前是否忙 = 新单元必须留队(`unit_busy`,runner.py:389;**与 `pickLocked` 放行口径
1591
+ * 一致**):前缀窗口已满,或项目存在活跃 `ext:` 行(**与 N 无关**)。
1592
+ *
1593
+ * **委托**模块级 `unitBusy(projectId, options)`(M3 已落地,语义逐点同)。
1594
+ *
1595
+ * @param {number} projectId
1596
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1597
+ * @returns {boolean}
1598
+ */
1599
+ unitBusy(projectId, options = null) {
1600
+ return unitBusy(projectId, this.opt(options));
1601
+ }
1602
+
1603
+ // ---------- 卡片起跑证实 / 收尾 ----------
1604
+
1605
+ /**
1606
+ * 卡片起跑证实(`card_started`,runner.py:408;六条路径共用):把卡行置 running
1607
+ * (`waitq.enterRunning`,行即条目)+ 证据落行(`waitq.markEvidence`)。入口幂等且恒成功,
1608
+ * 返回值保留给调用方做流控(恒 `true`)。
1609
+ *
1610
+ * @param {number} cardId
1611
+ * @param {number} projectId
1612
+ * @param {object|null} [ext] 登记证据(desc / reason / pid)
1613
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1614
+ * @returns {boolean} 恒 true
1615
+ */
1616
+ cardStarted(cardId, projectId, ext = null, options = null) {
1617
+ const opts = this.opt(options);
1618
+ const payload = { desc: '卡片会话占用' };
1619
+ Object.assign(payload, ext || {});
1620
+ waitq.enterRunning(waitq.KIND_CARD, cardId, projectId, null, opts);
1621
+ waitq.markEvidence(waitq.KIND_CARD, cardId, payload, opts);
1622
+ this._cond.notifyAll();
1623
+ return true;
1624
+ }
1625
+
1626
+ /**
1627
+ * 卡片会话结束收尾(`card_finished`,runner.py:429):行收口门禁 = (running, finishing)
1628
+ * ——starting 行不收(起跑失败行终态归 worker finally,本函数不得抢先标 done)。行收口
1629
+ * 成功时由唯一收尾点 `board.finish` 打确定性日志;本方法只做「行终态 + notify」两段。
1630
+ *
1631
+ * @param {number} cardId
1632
+ * @param {string} [reason] 收尾标签(本方法只用它出日志——实际日志归 `board.finish`)
1633
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1634
+ * @returns {boolean} 是否真实收口一行(门禁命中且落终态成功)
1635
+ */
1636
+ cardFinished(cardId, reason = '', options = null) {
1637
+ const opts = this.opt(options);
1638
+ const row = waitq.getActive(waitq.KIND_CARD, cardId, opts);
1639
+ let collected = false;
1640
+ if (row !== null && (row.state === waitq.RUNNING || row.state === waitq.FINISHING)) {
1641
+ collected = waitq.finishByTarget(waitq.KIND_CARD, cardId, waitq.STATE_DONE, '', opts);
1642
+ }
1643
+ this._cond.notifyAll();
1644
+ return collected;
1645
+ }
1646
+
1647
+ /**
1648
+ * 项目占位者明细(`project_holder_details`,runner.py:454;诊断用,行口径):
1649
+ * `[{key, since, evidence}]`。占位者 = 该项目运行前缀成员(state ∈ PREFIX_STATES 的活跃
1650
+ * 行,唯一来源);`since` = 行创建时间(epoch 秒,坏值按当前时刻——年龄按 0 报);
1651
+ * `evidence` = 行 evidence JSON 的 `evidence`,回落登记描述 `desc`。
1652
+ *
1653
+ * @param {number} projectId
1654
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1655
+ * @returns {Array<{key: string, since: number, evidence: string}>}
1656
+ */
1657
+ projectHolderDetails(projectId, options = null) {
1658
+ const opts = this.opt(options);
1659
+ const now = Date.now() / 1000;
1660
+ const out = [];
1661
+ for (const row of activeItems(projectId, opts)) {
1662
+ if (!waitq.PREFIX_STATES.includes(row.state)) continue;
1663
+ let ev;
1664
+ try {
1665
+ ev = JSON.parse(row.evidence || '{}');
1666
+ if (ev === null || typeof ev !== 'object' || Array.isArray(ev)) ev = {};
1667
+ } catch {
1668
+ ev = {};
1669
+ }
1670
+ let since;
1671
+ try {
1672
+ since = localSeconds(row.created_at);
1673
+ } catch {
1674
+ since = now; // ValueError/TypeError/OverflowError 同侧
1675
+ }
1676
+ out.push({
1677
+ key: memberKey(row.kind, row.target_id),
1678
+ since,
1679
+ evidence: ev.evidence || ev.desc || '',
1680
+ });
1681
+ }
1682
+ return out;
1683
+ }
1684
+
1685
+ /**
1686
+ * 项目运行前缀里是否有该会话自己的消息单元(`session_holds`,runner.py:482;行口径:同 sid
1687
+ * 的活跃 m: 行,`chat_msgs.sid` 反查)。用来区分「项目忙,但忙的正是本会话」——三方互等
1688
+ * 死锁(卡 391 实障)的解锁判据。
1689
+ *
1690
+ * @param {number} projectId
1691
+ * @param {string} sid
1692
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1693
+ * @returns {boolean}
1694
+ */
1695
+ sessionHolds(projectId, sid, options = null) {
1696
+ if (!sid) return false;
1697
+ const opts = this.opt(options);
1698
+ const rows = activeItems(projectId, opts);
1699
+ return sessionMsgKeys(rows, projectId, sid, opts).size > 0;
1700
+ }
1701
+
1702
+ /**
1703
+ * 运行前缀成员变化(`notify_busy_change`,runner.py:500)时由 board 调用:唤醒空闲执行循环
1704
+ * 重新挑选(否则外部会话结束无人 notify,排队卡滞留;5s 节拍兜底仍在)。
1705
+ *
1706
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1707
+ */
1708
+ notifyBusyChange(options = null) {
1709
+ this._cond.notifyAll();
1710
+ }
1711
+
1712
+ /**
1713
+ * 单元在统一队列中的态(`unit_state`,runner.py:507;会话详情页「队列」徽标的数据源)。
1714
+ *
1715
+ * **委托**模块级 `unitState(key, projectId, options)`(M3 已落地:running / queued+位次 /
1716
+ * idle 三态,键名 `{state, pos, total}` 直接进 JSON 响应)。
1717
+ *
1718
+ * @param {string} key
1719
+ * @param {number} projectId
1720
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1721
+ * @returns {{state: string, pos: number, total: number}}
1722
+ */
1723
+ unitState(key, projectId, options = null) {
1724
+ return unitState(key, projectId, this.opt(options));
1725
+ }
1726
+
1727
+ // ---------- 停止 / 重启 / 复压 / 续跑 / 删除 ----------
1728
+
1729
+ /**
1730
+ * 停止任务(`stop`,runner.py:559):waiting(排队中)→ 取消等待项并立即标 stopped+跳后段;
1731
+ * starting(运行中)或无行但在跑 → 杀进程,由轮边界收口。
1732
+ *
1733
+ * 排队分支的「键在队列」check-then-act 换成 `waitq.cancel` 的 rowcount 守卫(与 worker
1734
+ * claim 竞态下不双执行);但 `cancel` 的状态守卫覆盖全部活跃态,运行中任务的行从拾取到
1735
+ * finally 整程 starting——直接 cancel 会把「停止运行中任务」误判成排队命中,故先
1736
+ * `getActive` 验行态分流:waiting → cancel+stopped(读-写竞态由 rowcount 兜底);
1737
+ * starting/无行 → 只加 `_stop_requested` + 杀进程,轮边界统一落 stopped/跳后段。
1738
+ *
1739
+ * **async**(Python 同步调 `dshdriver.cancel`;Node 侧那是远端 IO,见文件头差异⑥)。
1740
+ *
1741
+ * @param {number} taskId
1742
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1743
+ * @returns {Promise<void>}
1744
+ */
1745
+ async stop(taskId, options = null) {
1746
+ const opts = this.opt(options);
1747
+ this._stop_requested.add(taskId);
1748
+ const row = waitq.getActive(waitq.KIND_TASK, taskId, opts);
1749
+ const queued = row !== null && row.state === waitq.STATE_WAITING
1750
+ && waitq.cancel(waitq.KIND_TASK, taskId, '用户停止', null, opts); // rowcount 仲裁
1751
+ if (queued) {
1752
+ updateTask(taskId, { status: 'stopped', ended_at: nowStr() }, opts);
1753
+ this.skipPipelineChildren(taskId, '前段任务已停止,后段跳过', opts);
1754
+ }
1755
+ const proc = this._procs.get(taskId);
1756
+ this._cond.notifyAll();
1757
+ if (proc !== null && proc !== undefined) {
1758
+ try {
1759
+ killTree(proc.pid, { sig: 'SIGTERM' }); // 同步实现(投递 + 宽限轮询,不抛)
1760
+ } catch {
1761
+ // 照 Python `except OSError: pass`
1762
+ }
1763
+ }
1764
+ // dsh 插件族任务无本地进程可杀:直接 cancel 当前 turn(轮询循环的 _stop_requested 检查
1765
+ // 兜底;cancel 失败说明 turn 已结束,忽略)
1766
+ const t = getTask(taskId, opts);
1767
+ if (t !== null && t !== undefined && t.session_id) {
1768
+ const proj = getProject(t.project_id, opts);
1769
+ const family = proj ? agentFamily(proj.agent_path) : '';
1770
+ if (family === 'dsh_plugin') {
1771
+ try {
1772
+ await driverCancel(t.session_id);
1773
+ } catch (err) {
1774
+ if (!(err instanceof DshDriverError)) throw err; // 照 Python 只吃 DshDriverError
1775
+ }
1776
+ }
1777
+ }
1778
+ }
1779
+
1780
+ /**
1781
+ * 重启任务(`restart`,runner.py:603):清掉停止标记,重置轮次计数与状态后重新入队。
1782
+ * 旧轮次记录一并清空(重启=从第一轮重跑,避免 round_no 重复)。
1783
+ *
1784
+ * @param {number} taskId
1785
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1786
+ */
1787
+ restart(taskId, options = null) {
1788
+ const opts = this.opt(options);
1789
+ this._stop_requested.delete(taskId);
1790
+ deleteRounds(taskId, opts);
1791
+ const t = getTask(taskId, opts);
1792
+ updateTask(taskId, {
1793
+ status: 'queued',
1794
+ current_round: 0,
1795
+ new_bugs: 0,
1796
+ error: '',
1797
+ ended_at: null,
1798
+ session_id: t ? t.session_id : null,
1799
+ }, opts);
1800
+ this.submit(taskId, opts);
1801
+ }
1802
+
1803
+ /**
1804
+ * 复压(`rerun_load`,runner.py:617):跳过 agent 轮,用现有方案包再跑一次发压运行。
1805
+ * 与 restart 的区别:不清轮次、不动 session、不重跑 agent(方案包不改),只追加一次
1806
+ * `kind='load'` 的发压运行。`load_rerun=1` **必须落库**(排队行可跨服务重启存活)。
1807
+ *
1808
+ * @param {number} taskId
1809
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1810
+ */
1811
+ rerunLoad(taskId, options = null) {
1812
+ const opts = this.opt(options);
1813
+ this._stop_requested.delete(taskId);
1814
+ updateTask(taskId, { status: 'queued', load_rerun: 1, error: '', ended_at: null }, opts);
1815
+ this.submit(taskId, opts);
1816
+ }
1817
+
1818
+ /**
1819
+ * 续跑任务(`resume`,runner.py:634;「继续」):保留轮次记录、轮次计数与会话,从下一轮
1820
+ * 接着跑。`fresh_prompt` 标记由 server 层在入队前置 1(续跑第一轮用完整首轮提示词)。
1821
+ *
1822
+ * @param {number} taskId
1823
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1824
+ */
1825
+ resume(taskId, options = null) {
1826
+ const opts = this.opt(options);
1827
+ this._stop_requested.delete(taskId);
1828
+ updateTask(taskId, { status: 'queued', error: '', ended_at: null }, opts);
1829
+ this.submit(taskId, opts);
1830
+ }
1831
+
1832
+ /**
1833
+ * 删除前置处理(`remove`,runner.py:647):任务若在排队中则取消其等待项(运行中的任务由
1834
+ * server 层拒绝删除)。`waitq.cancel` 直调即出队(行即队列;幂等;非 waiting 行不被复活)。
1835
+ *
1836
+ * @param {number} taskId
1837
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1838
+ */
1839
+ remove(taskId, options = null) {
1840
+ waitq.cancel(waitq.KIND_TASK, taskId, '任务删除', null, this.opt(options));
1841
+ }
1842
+
1843
+ // ---------- 服务启动恢复 ----------
1844
+
1845
+ /**
1846
+ * 服务启动恢复(`recover`,runner.py:655;P4 重启矩阵,四类单元各归其位):
1847
+ *
1848
+ * - 任务侧:仅执行中(running)任务打断——杀残留进程组 + interrupted + 「前段任务被服务
1849
+ * 重启中断,后段跳过」;queued 任务不再被打断(waiting t: 行存活即续跑),缺行的排队
1850
+ * 任务防御性补建;
1851
+ * - 等待项即队列:waiting 的 t:/c:/m:/a: 行全量存活(位次以表 seq 为准);
1852
+ * - starting/running/finishing 按 v2a T1 映射收口:a: 放回 waiting 重投(放回计入
1853
+ * n_requeue);m: 记 `error=服务重启中断`(重发非幂等,`waitq.msgRecoverFail`);
1854
+ * t: 任务仍 queued → 放回 waiting 续跑,非 queued 维持 cancel;c: 不在此收口——交
1855
+ * board.recover 按实况映射;
1856
+ * - **非 waiting 收口先于 queued 防御补建**(顺序硬约束:放回的行让补建检查看到活跃行、
1857
+ * 不重复插入);单测用 wait_items 触发器把这两步的先后钉在审计表里。
1858
+ *
1859
+ * **async**(飞书失败通知是 IO;通知在同步临界段之后逐任务 await,见文件头差异⑨)。
1860
+ *
1861
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1862
+ * @returns {Promise<void>}
1863
+ */
1864
+ async recover(options = null) {
1865
+ const opts = this.opt(options);
1866
+ const tasks = listAllTasks(opts).map((r) => ({ ...r }));
1867
+ this._procs.clear();
1868
+ this._stop_requested.clear();
1869
+ // —— 非 waiting 收口先行,waiting 行存活计数同趟 ——
1870
+ let nAlive = 0;
1871
+ let nRequeue = 0;
1872
+ for (const arow of waitq.activeItems(null, opts)) {
1873
+ const kind = arow.kind;
1874
+ const target = arow.target_id;
1875
+ if (arow.state === waitq.STATE_WAITING) {
1876
+ nAlive += 1;
1877
+ if (kind === waitq.KIND_MSG || kind === waitq.KIND_ANSWER) {
1878
+ nRequeue += 1; // m:/a: waiting 行存活(无镜像可建)
1879
+ }
1880
+ continue;
1881
+ }
1882
+ // starting/running/finishing 行(claimed 已被 starting 吸收——同一收口面)
1883
+ if (kind === waitq.KIND_CARD) {
1884
+ continue; // c: 交 board.recover 按实况收口
1885
+ }
1886
+ if (kind === waitq.KIND_ANSWER) {
1887
+ waitq.returnToWaiting(arow.id, 0.0, false, opts); // 重投
1888
+ nRequeue += 1; // 放回计入重投
1889
+ } else if (kind === waitq.KIND_MSG) {
1890
+ waitq.markFailed(arow.id, '服务重启中断', opts);
1891
+ waitq.msgRecoverFail(target, '服务重启中断', opts); // 不重投
1892
+ } else if (kind === waitq.KIND_TASK) {
1893
+ // 任务仍 queued(claim↔置 running 间崩溃)→ 放回续跑;非 queued → 维持 cancel
1894
+ const trow = pyIsDigits(target) ? getTask(pyIntOr(target, 0), opts) : null;
1895
+ if (trow !== null && trow !== undefined && trow.status === 'queued') {
1896
+ waitq.returnToWaiting(arow.id, 0.0, false, opts);
1897
+ nRequeue += 1;
1898
+ } else {
1899
+ waitq.cancel(kind, target, '服务重启中断', null, opts);
1900
+ }
1901
+ }
1902
+ }
1903
+ const interrupted = []; // 待通知任务(await 不进同步临界段)
1904
+ for (const t of tasks) {
1905
+ if (t.status !== 'running') {
1906
+ if (t.status === 'queued' && waitq.getActive(waitq.KIND_TASK, t.id, opts) === null) {
1907
+ // 防御对账:排队任务缺等待项行(异常丢失)→ 补建(**在非 waiting 收口之后**跑,
1908
+ // 放回的行不会重复补——行活跃唯一索引 + enqueue 幂等双保险)
1909
+ waitq.enqueue(waitq.KIND_TASK, t.id, t.project_id, null, 0.0, opts);
1910
+ }
1911
+ continue;
1912
+ }
1913
+ // 仅执行中任务打断(杀残留 + interrupted + 通知 + 跳后段);queued 存活
1914
+ this.killResidual(t.id, opts);
1915
+ this.finalizeLoadRounds(t.id, opts);
1916
+ updateTask(t.id, { status: 'interrupted', error: '服务重启中断', ended_at: nowStr() }, opts);
1917
+ this.skipPipelineChildren(t.id, '前段任务被服务重启中断,后段跳过', opts);
1918
+ interrupted.push(t.id);
1919
+ }
1920
+ for (const taskId of interrupted) {
1921
+ await notifyTaskFailed(taskId, '服务重启中断', 0, opts);
1922
+ }
1923
+ if (nAlive) {
1924
+ console.log(`[runner] 服务重启:waiting 等待项存活 ${nAlive} 条`
1925
+ + `(t:/c:/m:/a: 行即队列;m:/a: 存活 ${nRequeue} 条)`);
1926
+ }
1927
+ this._cond.notifyAll();
1928
+ }
1929
+
1930
+ /**
1931
+ * 放行启动闸(`release_boot_gate`,runner.py:734;幂等)。
1932
+ *
1933
+ * 服务启动序硬约束:init_db → recover → board.recover → reconcile_units **全部跑完**后调用
1934
+ * 本方法——此后循环才开始拾取,第一次 `pickLocked` 看到的即启动对账后的权威行态。
1935
+ * 闸未开的实例(`bootGate=false` ⇒ `_bootGate === null`)调用为 no-op。
1936
+ */
1937
+ releaseBootGate() {
1938
+ if (this._bootGate === null) return;
1939
+ this._bootGate.release();
1940
+ }
1941
+
1942
+ // ---------- 残留收口 / 后段跳过 ----------
1943
+
1944
+ /**
1945
+ * 按最新轮次日志中记录的 PID 尝试杀进程(`_kill_residual`,runner.py:748;尽力而为)。
1946
+ *
1947
+ * 形态差异:Python `os.kill(pid, SIGTERM)` → Node `process.kill(pid, 'SIGTERM')`(**单 pid**,
1948
+ * 不是整树——与 Python 逐点同);日志尾读取用私有 `readLogTail`(见文件头差异⑭)。
1949
+ *
1950
+ * @param {number} taskId
1951
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1952
+ */
1953
+ killResidual(taskId, options = null) {
1954
+ const opts = this.opt(options);
1955
+ const task = getTask(taskId, opts);
1956
+ if (task === null || task === undefined) return;
1957
+ const rounds = listRounds(taskId, opts);
1958
+ for (const r of [...rounds].reverse()) {
1959
+ if (r.status !== 'running' || !r.log_path) continue;
1960
+ const text = readLogTail(r.log_path, 20);
1961
+ for (const m of text.matchAll(PID_MARK_RE)) {
1962
+ try {
1963
+ process.kill(Number(m[1]), 'SIGTERM');
1964
+ } catch {
1965
+ // 照 Python `except OSError: pass`(ESRCH/EPERM 等)
1966
+ }
1967
+ }
1968
+ }
1969
+ }
1970
+
1971
+ /**
1972
+ * 把残留的进行中发压运行行收口为 interrupted(`_finalize_load_rounds`,runner.py:765;服务
1973
+ * 重启打断)。只碰 `kind='load'` 行;agent 轮次的收口沿用既有口径(轮末 `finishRound`)。
1974
+ *
1975
+ * @param {number} taskId
1976
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1977
+ */
1978
+ finalizeLoadRounds(taskId, options = null) {
1979
+ const opts = this.opt(options);
1980
+ for (const r of listRounds(taskId, opts)) {
1981
+ if (r.kind === 'load' && r.status === 'running') {
1982
+ finishRound(r.id, 'interrupted', null, '服务重启中断', r.log_path, opts);
1983
+ }
1984
+ }
1985
+ }
1986
+
1987
+ /**
1988
+ * 前段任务未正常完成(停止/失败/中断)时,跳过其排队中的后段任务(`_skip_pipeline_children`,
1989
+ * runner.py:777):后段标 done、原因写入 error 字段;已在跑/已结束的不动。返回本次跳过的
1990
+ * 后段条数(失败通知文案用)。
1991
+ *
1992
+ * @param {number} taskId 前段任务 id
1993
+ * @param {string} reason
1994
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1995
+ * @returns {number}
1996
+ */
1997
+ skipPipelineChildren(taskId, reason, options = null) {
1998
+ const opts = this.opt(options);
1999
+ let n = 0;
2000
+ for (const t of listAllTasks(opts)) {
2001
+ if ((t.task_type || 'normal') !== 'pipeline' || t.status !== 'queued') continue;
2002
+ const parentId = payloadParentId(t.payload);
2003
+ if (parentId !== taskId) continue;
2004
+ updateTask(t.id, { status: 'done', error: reason, ended_at: nowStr() }, opts);
2005
+ waitq.cancel(waitq.KIND_TASK, t.id, reason, null, opts);
2006
+ n += 1;
2007
+ }
2008
+ return n;
2009
+ }
2010
+
2011
+ // ---------- 补位器 ----------
2012
+
2013
+ /**
2014
+ * 调度口径运行前缀成员(`_prefix_members`,runner.py:800):委托模块级 `prefixMembers`
2015
+ * (M3 已落地;成员只由行构成、唯一来源 R1)。
2016
+ *
2017
+ * @param {object[]} rows
2018
+ * @returns {Map<number, Set<string>>}
2019
+ */
2020
+ prefixMembers(rows) {
2021
+ return prefixMembers(rows);
2022
+ }
2023
+
2024
+ /**
2025
+ * a: 行自身占位折抵(`_answer_exempt_keys_locked`,runner.py:819):目标会话被「同 sid 的
2026
+ * m: 运行单元」占着时该占用不计入前缀;本卡 c: 活跃行同豁免(送达答案是解锁动作,卡 391
2027
+ * 死锁修复)。外部条目(ext 行)**不在折抵面**。返回应从本单元前缀计数中折抵的键集。
2028
+ *
2029
+ * **锁内方法**(`*Locked` 命名,见文件头差异③):体内不许 await。
2030
+ *
2031
+ * @param {object} row a: 等待项行(调用方已做 target 数字防御)
2032
+ * @param {object[]} rows 调用方已取到的活跃行清单
2033
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2034
+ * @returns {Set<string>}
2035
+ */
2036
+ answerExemptKeysLocked(row, rows, options = null) {
2037
+ const opts = this.opt(options);
2038
+ const exempt = new Set();
2039
+ const ansCid = pyIntOr(row.target_id, null); // 调用方已做 ValueError 防御
2040
+ if (ansCid !== null && cardUnitActive(ansCid, opts)) {
2041
+ exempt.add(`c:${ansCid}`);
2042
+ }
2043
+ for (const k of sessionMsgKeys(rows, row.project_id, answerRowSid(row), opts)) {
2044
+ exempt.add(k);
2045
+ }
2046
+ return exempt;
2047
+ }
2048
+
2049
+ /**
2050
+ * 补位器(`_pick_locked`,runner.py:838):队列即 `wait_items` 表,遍历 `activeItems`
2051
+ * (seq 升序),逐项目只看等待区队首行:
2052
+ *
2053
+ * ① 折抵后前缀长度 ≥ N(project) → 本项目本轮不启动;
2054
+ * ② not_before 未到 / 卡片占位非 doing+queue / 任务非 queued / pipeline 前段未 done
2055
+ * → 队首留队、**本项目本轮不再向下补位**(`headBlocked`);无效单元就地终态化;
2056
+ * ③ a: 行自身占位折抵(`answerExemptKeysLocked`);
2057
+ * ④ **项目外部条目闸**:该项目存在活跃 `ext:` 行 → 本项目本轮不补位(**与 N 无关**);
2058
+ * ⑤ 可启动 → 返回键,由 `claimAndStartLocked` 收口。
2059
+ *
2060
+ * **锁内方法**:体内不许 await(Python 在 `with self._cond` 里跑)。
2061
+ *
2062
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2063
+ * @returns {string|null} 队列键或 null(本轮无可补位单元)
2064
+ */
2065
+ pickLocked(options = null) {
2066
+ const opts = this.opt(options);
2067
+ const rows = activeItems(null, opts); // seq 升序;四活跃态全集
2068
+ const members = this.prefixMembers(rows); // 前缀成员=行
2069
+ const extProjects = new Set(rows.filter((r) => r.kind === waitq.KIND_EXT)
2070
+ .map((r) => r.project_id));
2071
+ const headBlocked = new Set(); // 本轮队首不可启动的项目
2072
+ const nowSec = Date.now() / 1000;
2073
+ for (const row of rows) {
2074
+ if (row.state !== waitq.STATE_WAITING) continue; // 前缀成员行留队
2075
+ const projectId = row.project_id;
2076
+ if (headBlocked.has(projectId)) continue; // 队首已留队:不再向下补位
2077
+ if (Number(row.not_before || 0) > nowSec) {
2078
+ headBlocked.add(projectId); // 退避窗口未到:留队
2079
+ continue;
2080
+ }
2081
+ const kind = row.kind;
2082
+ const uid = row.target_id;
2083
+ if (KEY_PREFIX[kind] === undefined) {
2084
+ // 未知 kind 的 waiting 行(封闭集外的理论残留):不拾不炸,留队待自检/对账收口
2085
+ // (ext 行恒 running 入场,不会走本支)
2086
+ continue;
2087
+ }
2088
+ const key = `${KEY_PREFIX[kind]}:${uid}`;
2089
+ if (kind === waitq.KIND_CARD) {
2090
+ // 看板卡片单元:仍在排队占位态才有效(占位仅 doing+queue)
2091
+ const cid = pyIntOr(uid, null);
2092
+ if (cid === null) continue; // c: 脏行:跳过不摘除(防 int() 炸循环)
2093
+ const card = getBoardCard(cid, opts);
2094
+ if (card === null || card.block_kind !== 'queue' || card.column_key !== 'doing') {
2095
+ waitq.cancel(waitq.KIND_CARD, uid, '占位失效', null, opts);
2096
+ continue;
2097
+ }
2098
+ } else if (kind === waitq.KIND_TASK) {
2099
+ const tid = pyIntOr(uid, null);
2100
+ if (tid === null) continue; // t: 脏行(同 c: 防御口径):跳过不摘除
2101
+ const task = getTask(tid, opts);
2102
+ if (task === null || task.status !== 'queued') {
2103
+ waitq.cancel(waitq.KIND_TASK, uid, '非排队态', null, opts);
2104
+ continue;
2105
+ }
2106
+ if ((task.task_type || 'normal') === 'pipeline') {
2107
+ // 后段任务:前段未 done 则留队(防前段被重启重排后后段抢先跑)
2108
+ const parentId = payloadParentId(task.payload);
2109
+ const parent = parentId ? getTask(parentId, opts) : null;
2110
+ if (parent === null || parent === undefined) {
2111
+ updateTask(tid, { status: 'done', error: '前段任务不存在,后段跳过', ended_at: nowStr() }, opts);
2112
+ waitq.cancel(waitq.KIND_TASK, uid, '前段不存在', null, opts);
2113
+ continue;
2114
+ }
2115
+ if (parent.status !== 'done') {
2116
+ headBlocked.add(projectId);
2117
+ continue;
2118
+ }
2119
+ }
2120
+ }
2121
+ // (m:/a: 行即登记:无额外有效性检查——行消失即不在扫描集内)
2122
+ // —— 前缀窗口闸:队首可启动 ⇔ 折抵后前缀长度 < N ——
2123
+ const prefix = members.get(projectId) || new Set();
2124
+ let exempt = [];
2125
+ if (kind === waitq.KIND_ANSWER) {
2126
+ if (pyIntOr(uid, null) === null) {
2127
+ // a: 脏行(target 非数字):不可投递,就地摘除(防 int() 炸循环;窗口闸在豁免之后)
2128
+ waitq.cancel(waitq.KIND_ANSWER, uid, '脏行清理', null, opts);
2129
+ continue;
2130
+ }
2131
+ exempt = this.answerExemptKeysLocked(row, rows, opts);
2132
+ }
2133
+ if (prefixWindowBlocked(prefix, key, kind,
2134
+ this.windowN(projectId, kind, opts), exempt)) {
2135
+ headBlocked.add(projectId); // 前缀窗口已满:留队
2136
+ continue;
2137
+ }
2138
+ // —— 项目外部条目闸(与窗口 N 无关;判据=该行在场)——
2139
+ if (extProjects.has(projectId)) {
2140
+ headBlocked.add(projectId);
2141
+ continue;
2142
+ }
2143
+ return key;
2144
+ }
2145
+ return null;
2146
+ }
2147
+
2148
+ /**
2149
+ * 锁内拾取收口(`_claim_and_start_locked`,runner.py:974;须与 `pickLocked` 同一 tick 调用):
2150
+ * 解析单元项目 → claim 仲裁 → 行口径窗口复判。返回 `projectId`=成功接手;`null`=放弃本次
2151
+ * 拾取(回等待循环)。
2152
+ *
2153
+ * claim 必须在「锁内」且先于接手判定:表驱动拾取的除重点从内存 `_queue.remove` 移到 claim
2154
+ * 的行级 rowcount 原子性;claim 失败/复判失败即完整放弃拾取——行保持 waiting(行即队列,
2155
+ * 无需重入队),终态由取消方/直投方负责。
2156
+ *
2157
+ * **锁内方法**:体内不许 await。
2158
+ *
2159
+ * @param {string} key
2160
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2161
+ * @returns {number|null} project_id 或 null
2162
+ */
2163
+ claimAndStartLocked(key, options = null) {
2164
+ const opts = this.opt(options);
2165
+ let projectId;
2166
+ let answerRow = null;
2167
+ if (key.startsWith('c:')) {
2168
+ const cid = pyIntOr(key.slice(2), null);
2169
+ const card = cid === null ? null : getBoardCard(cid, opts);
2170
+ if (card === null || card === undefined) {
2171
+ // 挑选通过后卡片被删除(未 claim 未接手,直接回等待循环;下一轮 pick 落「占位失效」)
2172
+ return null;
2173
+ }
2174
+ projectId = card.project_id;
2175
+ } else if (key.startsWith('m:')) {
2176
+ // 会话消息单元:项目从等待项行取(权威在表)——行已消失=拾取判定与本行消失的竞态
2177
+ const mrow = waitq.getActive(waitq.KIND_MSG, key.slice(2), opts);
2178
+ if (mrow === null) return null;
2179
+ projectId = mrow.project_id;
2180
+ } else if (key.startsWith('a:')) {
2181
+ // 答案单元:项目从等待项行取(行已消失=同上的窗口竞态)
2182
+ const aid = pyIntOr(key.slice(2), null);
2183
+ answerRow = aid === null ? null : waitq.getActive(waitq.KIND_ANSWER, aid, opts);
2184
+ if (answerRow === null) return null;
2185
+ projectId = answerRow.project_id;
2186
+ } else {
2187
+ const tid = pyIntOr(key.slice(2), null);
2188
+ const task = tid === null ? null : getTask(tid, opts);
2189
+ if (task === null || task === undefined) return null; // 任务行已删:同 c: 防御口径
2190
+ projectId = task.project_id;
2191
+ }
2192
+ // —— claim 仲裁先行(权威互斥点,全四类直调)——
2193
+ if (!claimUnit(key, 'worker', opts)) {
2194
+ this._cond.notifyAll();
2195
+ return null;
2196
+ }
2197
+ // —— 行口径窗口复判(pick 与 claim 之间可能有锁外起跑)——
2198
+ let exempt = [];
2199
+ if (key.charAt(0) === 'a') {
2200
+ exempt = this.answerExemptKeysLocked(answerRow, activeItems(null, opts), opts);
2201
+ }
2202
+ if (this.unitWindowBlocked(projectId, key, KIND_OF_KEY[key.charAt(0)], exempt, opts)) {
2203
+ const arow = waitq.getActive(KIND_OF_KEY[key.charAt(0)], key.slice(2), opts);
2204
+ if (arow !== null) waitq.returnToWaiting(arow.id, 0.0, false, opts);
2205
+ this._cond.notifyAll();
2206
+ return null;
2207
+ }
2208
+ this._cond.notifyAll();
2209
+ return projectId;
2210
+ }
2211
+
2212
+ // ---------- 工作者 ----------
2213
+
2214
+ /**
2215
+ * 单条执行循环(`_worker`,runner.py:1047):启动闸 → 补位(pick/claim)→ 分派执行。
2216
+ *
2217
+ * 三段的形态见文件头差异①③⑪:`await` 只出现在「等闸 / 5s 节拍 / 等单元执行体」三处,
2218
+ * pick 与 claim 之间**没有 await**(= Python 持 `_cond` 的原子段)。
2219
+ *
2220
+ * @returns {Promise<void>} 永不 resolve(循环);`this._workers` 持有它
2221
+ */
2222
+ async worker() {
2223
+ if (this._bootGate !== null) await this._bootGate.promise;
2224
+ while (true) {
2225
+ let key = null;
2226
+ let projectId = null;
2227
+ try {
2228
+ while (true) {
2229
+ key = this.pickLocked();
2230
+ if (key !== null) break;
2231
+ // 补位时机④:5s 节拍兜底唤醒——队首头阻塞(not_before 退避/前缀窗口满)且静场
2232
+ // 无外部事件时,无超时等待会无人再 notify,整个项目饥饿到下个事件
2233
+ await this._cond.wait(WAIT_TICK_MS);
2234
+ }
2235
+ projectId = this.claimAndStartLocked(key);
2236
+ } catch (err) {
2237
+ // Node 专属保底(见文件头差异⑪):未捕获异常会打死整个进程,故「响亮 + 跳过本轮」
2238
+ console.log(`[runner] 补位/拾取异常(本轮跳过): ${errText(err)}`);
2239
+ await this._cond.wait(WAIT_TICK_MS);
2240
+ continue;
2241
+ }
2242
+ if (projectId === null) continue;
2243
+ try {
2244
+ await this.runPickedUnit(key);
2245
+ } catch (err) {
2246
+ // 见文件头差异⑪②:单元收口段(finally 里的行终态写)自身抛异常时,Python 只死一条
2247
+ // 线程,Node 会打死进程 ⇒ 响亮 + 跳过本轮(行态留给周期自检/对账裁活)
2248
+ console.log(`[runner] 单元收口异常(跳过本轮): ${key}: ${errText(err)}`);
2249
+ await this._cond.wait(WAIT_TICK_MS);
2250
+ }
2251
+ }
2252
+ }
2253
+
2254
+ /**
2255
+ * 单单元执行段(`_worker` 的 try/except/finally 三分支,runner.py:1073-1159 逐条照搬;
2256
+ * 独立成方法只为让单测能**手动 pump 一步**,见文件头差异⑩)。
2257
+ *
2258
+ * 分派:`c:` → `processCard`、`m:` → `await chat.runUnit`、`a:` → `deliverAnswerUnit`、
2259
+ * 其余 → `processTask`(三个口见 `setUnitHandlers`;未接线抛 `UnitHandlerMissing`)。
2260
+ * 终态:`t:` 行由 finally 落 done/failed;`c:` 行**不在 finally 收口**(跨轮持有归
2261
+ * `board.finish`),只在确定性「无会话」结局时收口;`a:`/`m:` 由执行体自洽。
2262
+ *
2263
+ * @param {string} key
2264
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2265
+ * @returns {Promise<{err: string, cardOk: boolean|null, error: Error|null}>} 观测面
2266
+ * (Node 追加,行为零影响):`err` = 截断后的异常文本(终于终态的那个)、`cardOk` =
2267
+ * 卡片单元结果、`error` = 原始异常对象(未接线时即 `UnitHandlerMissing`,供单测
2268
+ * `instanceof` 判定)
2269
+ */
2270
+ async runPickedUnit(key, options = null) {
2271
+ const opts = this.opt(options);
2272
+ let err = ''; // 单元异常文本(finally 终态用)
2273
+ let error = null; // 原始异常对象(观测面)
2274
+ let cardOk = null; // 卡片单元结果(processCard 返回值)
2275
+ try {
2276
+ if (key.startsWith('c:')) {
2277
+ // `undefined` 归一为 `null`:JS 里「没写 return」的等价物正是 Python 的 `return None`
2278
+ // (卡片缺失或状态不符 ⇒ finally 走 cancel + 回滚收尾),不归一它会静默什么都不做
2279
+ const raw = await callUnitHandler('card', key, UNIT_HANDLERS.processCard,
2280
+ pyIntOr(key.slice(2), null));
2281
+ cardOk = raw === undefined ? null : raw;
2282
+ } else if (key.startsWith('m:')) {
2283
+ // 会话消息执行体(chat.js:投递并等 turn 结束);自身吞异常并落消息状态
2284
+ await chat.runUnit(key.slice(2), opts);
2285
+ } else if (key.startsWith('a:')) {
2286
+ await callUnitHandler('answer', key, UNIT_HANDLERS.deliverAnswerUnit,
2287
+ pyIntOr(key.slice(2), null));
2288
+ } else {
2289
+ await callUnitHandler('task', key, UNIT_HANDLERS.processTask,
2290
+ pyIntOr(key.slice(2), null));
2291
+ }
2292
+ } catch (e) { // 保底:任何异常不拖死循环
2293
+ error = e;
2294
+ err = errText(e).slice(0, 300); // 影子层:finally 按 err 记失败终态
2295
+ if (key.startsWith('c:')) {
2296
+ // 卡片单元异常:记错到卡片(起会话失败的排队态回滚由 board.dequeue_start 自身负责)
2297
+ try {
2298
+ const cid = pyIntOr(key.slice(2), null);
2299
+ if (cid !== null) setCardLastError(cid, err, opts);
2300
+ } catch {
2301
+ // 照 Python `except Exception: pass`
2302
+ }
2303
+ } else if (key.startsWith('m:')) {
2304
+ // 消息单元异常:状态落盘由 chat.runUnit 内部负责,此处不记任务行
2305
+ } else if (key.startsWith('a:')) {
2306
+ // 答案执行体异常兜底(业务失败已在执行体内部分流,此处只防未预期异常把行永久挂在
2307
+ // starting):放回 waiting 重入队(**带退避**,见文件头差异⑫②),不计业务重试额度;
2308
+ // 放回失败(行已被取消)则不再重入队
2309
+ console.log(`[runner] 答案单元异常(放回等待重投): ${key}: ${err}`); // 见文件头差异⑫①
2310
+ try {
2311
+ const arow = waitq.getActive(waitq.KIND_ANSWER, key.slice(2), opts);
2312
+ const notBefore = Date.now() / 1000 + ANSWER_EXC_BACKOFF_S;
2313
+ if (arow !== null && waitq.returnToWaiting(arow.id, notBefore, false, opts)) {
2314
+ this.submitAnswer(pyIntOr(key.slice(2), null), opts);
2315
+ }
2316
+ } catch {
2317
+ // 放回失败(行已被取消)则不再重入队
2318
+ }
2319
+ } else {
2320
+ const tid = pyIntOr(key.slice(2), null);
2321
+ if (tid !== null) {
2322
+ updateTask(tid, { status: 'failed', error: errText(e), ended_at: nowStr() }, opts);
2323
+ const n = this.skipPipelineChildren(tid, '前段任务执行失败,后段跳过', opts);
2324
+ await notifyTaskFailed(tid, errText(e), n, opts);
2325
+ }
2326
+ }
2327
+ } finally {
2328
+ if (key.startsWith('t:')) {
2329
+ // _procs 键保持 int task_id(stop() 里 `_procs.get(taskId)` 不变)
2330
+ this._procs.delete(pyIntOr(key.slice(2), null));
2331
+ }
2332
+ this._cond.notifyAll();
2333
+ // —— 等待项终态:answer/msg 由执行体自洽;t:/c: 直调(权威)——
2334
+ if (key.charAt(0) === 't') {
2335
+ waitq.finishByTarget(waitq.KIND_TASK, key.slice(2),
2336
+ err ? waitq.STATE_FAILED : waitq.STATE_DONE, err, opts);
2337
+ } else if (key.charAt(0) === 'c') {
2338
+ // 卡片行跨轮持有(归 board.finish 唯一收尾点):此处只处理确定性「无会话」结局;
2339
+ // err(processCard 抛异常)时会话状态不明,保守不收口——残留由周期自检/重启对账裁活
2340
+ if (err) {
2341
+ waitq.finishByTarget(waitq.KIND_CARD, key.slice(2), waitq.STATE_FAILED, err, opts);
2342
+ } else if (cardOk === false) {
2343
+ waitq.finishByTarget(waitq.KIND_CARD, key.slice(2), waitq.STATE_FAILED,
2344
+ '起会话失败', opts);
2345
+ await this.boardFinish(key, '起跑失败回滚');
2346
+ } else if (cardOk === null) {
2347
+ waitq.cancel(waitq.KIND_CARD, key.slice(2), '卡片状态不符', null, opts);
2348
+ await this.boardFinish(key, '起跑失败回滚');
2349
+ }
2350
+ }
2351
+ }
2352
+ return { err, cardOk, error };
2353
+ }
2354
+
2355
+ /**
2356
+ * `board.finish(key, reason)` 的缝调用(M5;见 `setBoardFinishHook`):未接线只打一行、
2357
+ * 不抛;钩子自身抛异常照 Python「起跑失败回滚收尾失败(自检兜底)」打一行。
2358
+ *
2359
+ * @param {string} key
2360
+ * @param {string} reason
2361
+ * @returns {Promise<void>}
2362
+ */
2363
+ async boardFinish(key, reason) {
2364
+ if (BOARD_FINISH_HOOK === null) {
2365
+ console.log(`[runner] board.finish 未接线(M5):${key}(${reason})——收尾子步跳过`);
2366
+ return;
2367
+ }
2368
+ try {
2369
+ await BOARD_FINISH_HOOK(key, reason);
2370
+ } catch (e) {
2371
+ console.log(`[waitq] ${key} 起跑失败回滚收尾失败(自检兜底): ${errText(e)}`);
2372
+ }
2373
+ }
2374
+
2375
+ // ══════════════════════════════════════════════════════════════════════
2376
+ // M6.5-R1:入口本体(runner.py:1161-1300)
2377
+ // ══════════════════════════════════════════════════════════════════════
2378
+
2379
+ /**
2380
+ * 看板卡片单元(`_process_card`,runner.py:1161):项目串行位已由队列保证(拾取即行置
2381
+ * starting 占位)。落 doing + 起会话(快路径,无多轮循环),`c:` 行跨轮 running 直到
2382
+ * board 回调 `card_finished`——`cardStarted` 把行置 running 后不终态化(行即运行前缀
2383
+ * 成员,会话结束才收口落 done)。
2384
+ *
2385
+ * 起会话失败由 `board.dequeueStart` 清占位并回 `from_column` 列,用户重按开始重新入队。
2386
+ * 返回 `true`=起跑成功 / `false`=起会话失败 / `null`=卡片缺失或状态不符(调度判定用;
2387
+ * JS 的「没写 return」= Python `return None`,由 `runPickedUnit` 归一)。
2388
+ *
2389
+ * **async**(`board.dequeueStart` 是异步 IO,见文件头差异⑱⑲):返回 promise,缝不变。
2390
+ *
2391
+ * @param {number} cardId
2392
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2393
+ * @returns {Promise<boolean|null>}
2394
+ */
2395
+ async processCard(cardId, options = null) {
2396
+ const opts = this.opt(options);
2397
+ const card = getBoardCard(cardId, opts);
2398
+ if (card === null || card === undefined || card.block_kind !== 'queue'
2399
+ || card.column_key !== 'doing') {
2400
+ return null;
2401
+ }
2402
+ const project = getProject(card.project_id, opts);
2403
+ if (project === null || project === undefined) return null;
2404
+ // 函数内动态 import 防循环(board.js 模块级 `import * as runnerMod from './runner.js'`;
2405
+ // 位置照 Python 的 `import board`:在两道校验之后,见文件头差异㉘)
2406
+ const board = await import('./board.js');
2407
+ if (await board.dequeueStart(project, card, opts)) {
2408
+ // 起跑证实:把 c: 行置 running(行即运行成员,此后占着项目运行位)+ 登记证据落行
2409
+ // (有 proc 补 pid——进程存活裁活证据,启动对账收口 CLI 卡残留的主路径)
2410
+ const ext = { desc: '卡片会话占用' };
2411
+ const pid = board.runPid(cardId);
2412
+ if (pid) ext.pid = pid;
2413
+ return Boolean(this.cardStarted(cardId, card.project_id, ext, opts));
2414
+ }
2415
+ return false;
2416
+ }
2417
+
2418
+ /**
2419
+ * 任务单元轮次执行体(`_process_task`,runner.py:1188):入口守卫 → 写 running →
2420
+ * 四类快路径(script_retest / stress 复压 / retest_bug 预标记 / pipeline payload 固化)
2421
+ * → 轮次循环(每轮重读任务行;停止/失败/复测收尾/rag 刷新/自动复测/停止条件)。
2422
+ *
2423
+ * **async**(`rag.refresh` / `notifyTaskFailed` / R2R3 的执行体都是异步,见差异⑱)。
2424
+ * `_run_round` / `_run_script_retest` / `_run_load_phase` / `_should_stop` 属后续批次
2425
+ * (R2/R3),本批**只写调用**:未实现即 `TypeError`(响亮,见差异㉕)。
2426
+ *
2427
+ * @param {number} taskId
2428
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2429
+ * @returns {Promise<void>}
2430
+ */
2431
+ async processTask(taskId, options = null) {
2432
+ const opts = this.opt(options);
2433
+ let task = getTask(taskId, opts);
2434
+ if (task === null || task === undefined || task.status !== 'queued') return;
2435
+ const project = getProject(task.project_id, opts);
2436
+ if (project === null || project === undefined) {
2437
+ updateTask(taskId, { status: 'failed', error: '项目不存在', ended_at: nowStr() }, opts);
2438
+ await notifyTaskFailed(taskId, '项目不存在', 0, opts);
2439
+ return;
2440
+ }
2441
+ const agent = agentFamily(project.agent_path);
2442
+ if (agent === RETIRED_FAMILY) {
2443
+ // 兜底防呆:存量项目仍绑着退场族可执行名/旧虚拟前缀(迁移脚本尚未执行)时,给明确
2444
+ // 错误,而不是静默按 dsh 跑或 AttributeError。
2445
+ updateTask(taskId, { status: 'failed', error: RETIRED_MSG, ended_at: nowStr() }, opts);
2446
+ await notifyTaskFailed(taskId, RETIRED_MSG, 0, opts);
2447
+ return;
2448
+ }
2449
+ updateTask(taskId, {
2450
+ status: 'running', started_at: nowStr(), error: '', ended_at: null,
2451
+ }, opts);
2452
+
2453
+ // 脚本复测任务:平台直接批量执行各用例 verify.py,不经 agent 轮次
2454
+ if ((task.task_type || 'normal') === 'script_retest') {
2455
+ await this.runScriptRetest(project, task, opts);
2456
+ return;
2457
+ }
2458
+
2459
+ // 复压:跳过 agent 轮,直接用现有方案包再跑一次发压运行。
2460
+ // 标记用后即清(对齐 fresh_prompt 纪律),避免异常重入时又跑成「只发压」。
2461
+ if ((task.task_type || 'normal') === 'stress' && task.load_rerun) {
2462
+ updateTask(taskId, { load_rerun: 0 }, opts);
2463
+ await this.runLoadPhase(project, getTask(taskId, opts), opts);
2464
+ return;
2465
+ }
2466
+
2467
+ // 复测任务开始前:把关联 case 的 status.md 标记为「需要复测」
2468
+ if ((task.task_type || 'normal') === 'retest_bug') {
2469
+ this.markRetestCases(project, task, opts);
2470
+ }
2471
+
2472
+ // 后段任务:固化前段任务期间产出的 bug 报告清单进 payload(创建时清单还不存在);
2473
+ // 清单为空(前段没有产出任何 bug)则无事可做直接结束
2474
+ if ((task.task_type || 'normal') === 'pipeline') {
2475
+ const bugs = this.pipelineBugs(project, task.created_at, opts);
2476
+ const payloadObj = payloadObject(task.payload);
2477
+ payloadObj.bug_dirs = bugs;
2478
+ updateTask(taskId, { payload: pyJsonDumps(payloadObj) }, opts);
2479
+ if (!bugs.length) {
2480
+ updateTask(taskId, { status: 'done', ended_at: nowStr() }, opts);
2481
+ return;
2482
+ }
2483
+ // 照 Python:此处**不加** `None` 检查(任务行被并发删除时,下一行的
2484
+ // `task.current_round` 抛 TypeError,与 Python `task["current_round"]` 同形;
2485
+ // 异常落点归 `runPickedUnit` 的 except——绝不在这里静默成「假成功」)
2486
+ task = getTask(taskId, opts);
2487
+ }
2488
+
2489
+ // 首轮不空跑:起始轮次 + 任务级停止条件
2490
+ let roundNo = task.current_round + 1;
2491
+ while (true) {
2492
+ if (this._stop_requested.has(taskId)) {
2493
+ updateTask(taskId, { status: 'stopped', ended_at: nowStr() }, opts);
2494
+ this.skipPipelineChildren(taskId, '前段任务已停止,后段跳过', opts);
2495
+ return;
2496
+ }
2497
+ const [exitCode, sessionId] = await this.runRound(project, task, roundNo, agent, opts);
2498
+ task = getTask(taskId, opts); // 每轮后刷新
2499
+ if (task === null || task === undefined) return;
2500
+ const newSid = sessionId || task.session_id;
2501
+ // fresh_prompt 只影响本轮提示词(续跑第一轮),用后即清
2502
+ updateTask(taskId, {
2503
+ session_id: newSid, current_round: roundNo, fresh_prompt: 0,
2504
+ }, opts);
2505
+ if (exitCode !== 0) {
2506
+ if (this._stop_requested.has(taskId)) {
2507
+ updateTask(taskId, { status: 'stopped', ended_at: nowStr() }, opts);
2508
+ this.skipPipelineChildren(taskId, '前段任务已停止,后段跳过', opts);
2509
+ } else {
2510
+ updateTask(taskId, {
2511
+ status: 'failed', error: `第 ${roundNo} 轮退出码 ${exitCode}`, ended_at: nowStr(),
2512
+ }, opts);
2513
+ const n = this.skipPipelineChildren(taskId, '前段任务执行失败,后段跳过', opts);
2514
+ await notifyTaskFailed(taskId, `第 ${roundNo} 轮退出码 ${exitCode}`, n, opts);
2515
+ }
2516
+ return;
2517
+ }
2518
+ // 日期范围复测(无 bug 报告):首轮成功后解析 agent 输出的受影响用例清单写入
2519
+ // payload.only_cases(后续轮/续跑限定复测该子集,标记可见)
2520
+ if ((task.task_type || 'normal') === 'retest_bug'
2521
+ && (task.date_from || task.date_to) && roundNo === 1) {
2522
+ await this.parseRetestRangeCases(project, task, newSid, agent, roundNo, opts);
2523
+ task = getTask(taskId, opts);
2524
+ if (task === null || task === undefined) return;
2525
+ }
2526
+ // fix / retest_bug 任务收尾:更新 bug 报告状态
2527
+ this.applyBugResult(project, task, opts);
2528
+ // RAG 索引轮后刷新:探索/回归轮次可能新增用例(同项目串行窗口,无并发写库);
2529
+ // 刷新失败仅记任务日志,不阻塞轮次收尾
2530
+ if (['normal', 'regression'].includes(task.task_type || 'normal')) {
2531
+ try {
2532
+ const stat = await rag.refresh(project.cases_root,
2533
+ (m) => this.logTaskLine(task, m, opts));
2534
+ if (stat && stat.enabled && stat.embedded) {
2535
+ this.logTaskLine(task, `RAG 索引刷新:新嵌 ${stat.embedded} 条`, opts);
2536
+ }
2537
+ } catch (exc) {
2538
+ this.logTaskLine(task, `RAG 索引刷新失败(忽略):${errText(exc)}`, opts);
2539
+ }
2540
+ }
2541
+ // fix 任务 自动复测=开:修复成功后自动创建复测任务(去重)
2542
+ if ((task.task_type || 'normal') === 'fix' && task.auto_retest && !task.end_stage) {
2543
+ this.maybeAutoRetest(project, task, opts);
2544
+ }
2545
+ // 停止条件判定
2546
+ if (this.shouldStop(task, roundNo, opts)) {
2547
+ if ((task.task_type || 'normal') === 'stress') {
2548
+ // 压测:轮次条件恒为 1 轮 agent,随后进入平台发压阶段
2549
+ //(内部自管最终状态 done/failed/stopped,这里直接返回)
2550
+ await this.runLoadPhase(project, task, opts);
2551
+ return;
2552
+ }
2553
+ updateTask(taskId, { status: 'done', ended_at: nowStr() }, opts);
2554
+ return;
2555
+ }
2556
+ roundNo += 1;
2557
+ }
2558
+ }
2559
+
2560
+ // ══════════════════════════════════════════════════════════════════════
2561
+ // M6.5-R1:bug / 复测联动(runner.py:1301-1519、1977-1994)
2562
+ // ══════════════════════════════════════════════════════════════════════
2563
+
2564
+ /**
2565
+ * 从 payload 解析关联 bug 报告目录名(`_linked_bug_dir`,runner.py:1301),无效返回 `null`。
2566
+ * `project` 形参照 Python 保留(本函数不读它)。
2567
+ *
2568
+ * @param {object} project
2569
+ * @param {object} task
2570
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2571
+ * @returns {string|null}
2572
+ */
2573
+ linkedBugDir(project, task, options = null) { // eslint-disable-line no-unused-vars
2574
+ return payloadObject(task.payload).bug_dir || null;
2575
+ }
2576
+
2577
+ /**
2578
+ * 前段任务 `created_at` 之后产出的 bug 报告目录名列表(`_pipeline_bugs`,runner.py:1308;
2579
+ * 按分钟粒度比较)。
2580
+ *
2581
+ * 目录名格式 `YYYYMMDD_HHMM_标题`;与 `since`(`YYYY-MM-DD HH:MM:SS`)的前 16 位字符串
2582
+ * 比较;目录不存在或无 bug 报告目录时返回空列表(`os.listdir` 的 OSError 整支吞掉)。
2583
+ * 目录名排序照 Python `sorted()`(码点序,见 `pySorted`)。
2584
+ *
2585
+ * @param {object} project
2586
+ * @param {string} since
2587
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2588
+ * @returns {string[]}
2589
+ */
2590
+ pipelineBugs(project, since, options = null) { // eslint-disable-line no-unused-vars
2591
+ let names;
2592
+ try {
2593
+ names = fs.readdirSync(project.bug_dir);
2594
+ } catch {
2595
+ return []; // 照 Python `except OSError: return []`
2596
+ }
2597
+ const sinceMin = String(since || '').slice(0, 16);
2598
+ const bugs = [];
2599
+ for (const n of pySorted(names)) {
2600
+ if (!BUG_DIR_RE.test(n)) continue;
2601
+ const key = `${n.slice(0, 4)}-${n.slice(4, 6)}-${n.slice(6, 8)} `
2602
+ + `${n.slice(9, 11)}:${n.slice(11, 13)}`;
2603
+ if (key >= sinceMin && isDir(pyJoin(project.bug_dir, n))) bugs.push(n);
2604
+ }
2605
+ return bugs;
2606
+ }
2607
+
2608
+ /**
2609
+ * 复测任务开始前,把关联 case 的 `status.md` 状态改写为「需要复测」(`_mark_retest_cases`,
2610
+ * runner.py:1328)。
2611
+ *
2612
+ * payload 带 `only_cases`(脚本复测失败升级的复核任务)时只标记该子集,其余已通过用例的
2613
+ * 「通过」状态保持不动。改成功才记一行任务日志(`markCasesRetest` 的返回集)。
2614
+ *
2615
+ * @param {object} project
2616
+ * @param {object} task
2617
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2618
+ */
2619
+ markRetestCases(project, task, options = null) {
2620
+ const opts = this.opt(options);
2621
+ const bug = this.linkedBugDir(project, task, opts);
2622
+ if (!bug) return;
2623
+ let caseIds = bugCases(pyJoin(project.bug_dir, bug)).ids;
2624
+ const only = payloadObject(task.payload).only_cases || [];
2625
+ if (only.length) {
2626
+ const onlySet = new Set(only);
2627
+ caseIds = caseIds.filter((cid) => onlySet.has(cid));
2628
+ }
2629
+ const marked = markCasesRetest(project.cases_root, caseIds);
2630
+ if (marked.length) {
2631
+ this.logTaskLine(task, `平台预标记复测用例:${marked.join('、')}`, opts);
2632
+ }
2633
+ }
2634
+
2635
+ /**
2636
+ * 日期范围复测:首轮后解析「受影响用例:FS…」行 → `payload.only_cases`
2637
+ * (`_parse_retest_range_cases`,runner.py:1349)。
2638
+ *
2639
+ * 解析路径依次:① 会话最后 assistant 文本(`sessparse.load`);② 轮次日志全文窗口匹配。
2640
+ * 两者皆无时按范围内变更文件粗筛兜底(`export_cases._match_level`);仍无候选则只记日志、
2641
+ * 不限定范围。只保留案例库中真实存在的用例 id;结果写入 `payload.only_cases`。
2642
+ *
2643
+ * **async**:RAG 语义追加支里的 `rag.refresh` 是异步(Python 同步,见差异⑱);异常语义
2644
+ * 照 Python 的 `except Exception`(检索失败静默跳过、维持原行为)。
2645
+ *
2646
+ * @param {object} project
2647
+ * @param {object} task
2648
+ * @param {string} sessionId
2649
+ * @param {string} agent 族名(只有 `dsh_plugin` 会走到这里)
2650
+ * @param {number} roundNo
2651
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2652
+ * @returns {Promise<void>}
2653
+ */
2654
+ async parseRetestRangeCases(project, task, sessionId, agent, roundNo, options = null) {
2655
+ const opts = this.opt(options);
2656
+ // 单族化后只有 dsh 插件族会走到这里:会话按 dsh 解析族读取
2657
+ if (agent !== 'dsh_plugin') return;
2658
+ const family = 'dsh';
2659
+ if (!sessparse.FAMILIES.includes(family)) return;
2660
+ let ids = [];
2661
+ if (sessionId) {
2662
+ try {
2663
+ const data = sessparse.load(family, sessionId, 'main', 0);
2664
+ for (const e of (data.entries || [])) {
2665
+ if (e.kind === 'assistant' && e.text) {
2666
+ ids = extractAffectedIds(e.text);
2667
+ if (ids.length) break;
2668
+ }
2669
+ }
2670
+ } catch {
2671
+ ids = []; // 照 Python `except Exception: ids = []`
2672
+ }
2673
+ }
2674
+ if (!ids.length) {
2675
+ try {
2676
+ const logPath = pyJoin(runtimeDir(project.work_dir, LOG_DIR_NAME),
2677
+ `task_${task.id}_round_${roundNo}.log`);
2678
+ ids = extractAffectedIds(readText(logPath));
2679
+ } catch {
2680
+ ids = []; // 照 Python `except OSError`(readText 不抛)
2681
+ }
2682
+ }
2683
+ // 只在案例库按 id 定位成功的用例子集才算数
2684
+ let chosen;
2685
+ let how;
2686
+ if (ids.length && findCaseDirs(project.cases_root, ids).length === new Set(ids).size) {
2687
+ chosen = ids;
2688
+ how = 'agent 判断';
2689
+ } else {
2690
+ chosen = [];
2691
+ how = '变更文件粗筛';
2692
+ const log = gitLogChanges(project.project_dir, task.date_from, task.date_to);
2693
+ if (log.ok) {
2694
+ const changed = log.files || [];
2695
+ const more = log.commits || [];
2696
+ for (const rec of scanCases(project.cases_root)) {
2697
+ if (rec.code_files.some((cf) => changed.some((ch) => matchLevel(cf, ch)))) {
2698
+ chosen.push(rec.id);
2699
+ }
2700
+ }
2701
+ if (!chosen.length && more.length === 1) {
2702
+ // 单提交且无文件(如仅 merge/rebase)不硬凑候选
2703
+ }
2704
+ chosen = pySorted(new Set(chosen));
2705
+ // RAG 语义追加:变更文件+提交摘录作 query,语义 top-k 中非路径命中者补充进候选
2706
+ //(path 级命中恒保留;检索失败静默跳过,维持原行为)。
2707
+ // 配置门:语义追加是增强能力,未配置 RAG 时保持现状(path 粗筛原行为)
2708
+ let sem = [];
2709
+ if (rag.load_config()) {
2710
+ let semMeta = null;
2711
+ try {
2712
+ [sem, semMeta] = await rag.retrieve_detail(project.cases_root,
2713
+ rag.change_query(log), 15);
2714
+ } catch {
2715
+ // 照 Python `except Exception: pass`(sem 保持 [])
2716
+ }
2717
+ // 向量路降级可见化:查询嵌入失败记任务日志(via 分布随 how 行体现)
2718
+ if (semMeta && semMeta.configured && !semMeta.vec_ok) {
2719
+ this.logTaskLine(task, `RAG 检索向量路失败,已降级 BM25:${semMeta.vec_error}`, opts);
2720
+ }
2721
+ } else {
2722
+ sem = [];
2723
+ }
2724
+ const chosenSet = new Set(chosen);
2725
+ const extra = sem.filter((r) => !chosenSet.has(r.id)).map((r) => r.id);
2726
+ if (extra.length) {
2727
+ chosen = chosen.concat(extra);
2728
+ const nVec = sem.filter((r) => String(r.via || '').includes('vec')).length;
2729
+ how = `变更文件粗筛+语义追加${extra.length}条`
2730
+ + `(检索 bm25+vec ${nVec} / 仅bm25 ${sem.length - nVec})`;
2731
+ }
2732
+ }
2733
+ if (!chosen.length) {
2734
+ this.logTaskLine(task, '未解析到『受影响用例』行且变更文件粗筛无候选,本轮不限复测范围',
2735
+ opts);
2736
+ return;
2737
+ }
2738
+ this.logTaskLine(task, '未解析到『受影响用例』行,按范围内变更文件粗筛', opts);
2739
+ }
2740
+ const payload = payloadObject(task.payload);
2741
+ payload.only_cases = chosen;
2742
+ updateTask(task.id, { payload: pyJsonDumps(payload) }, opts);
2743
+ this.logTaskLine(task, `受影响用例(${how}):${chosen.join('、')}`, opts);
2744
+ }
2745
+
2746
+ /**
2747
+ * fix / retest_bug / pipeline 任务一轮成功后,按结果更新 bug 报告状态
2748
+ * (`_apply_bug_result`,runner.py:1435)。
2749
+ *
2750
+ * 联动范围由 `end_stage` 决定:实际执行了「复测」阶段才按用例通过情况定论;fix 终点=
2751
+ * 报告分析置「已分析」;复测任务「仅重新部署」(end=deploy)未复测不联动;存量行
2752
+ * (end 为空)维持拆块前行为。pipeline 的终点比较用 `stageIndex`(缺项抛错,见差异㉒)。
2753
+ *
2754
+ * @param {object} project
2755
+ * @param {object} task
2756
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2757
+ */
2758
+ applyBugResult(project, task, options = null) {
2759
+ const opts = this.opt(options);
2760
+ const taskType = task.task_type || 'normal';
2761
+ const endStage = task.end_stage || '';
2762
+ if (taskType === 'fix') {
2763
+ if (endStage === 'retest') {
2764
+ this.judgeBugByCases(project, task, opts);
2765
+ } else if (endStage === 'analyze') {
2766
+ this.setBugStatus(project, task, '已分析', null, opts);
2767
+ } else {
2768
+ this.setBugStatus(project, task, '修复完成,待复测', null, opts);
2769
+ }
2770
+ } else if (taskType === 'retest_bug') {
2771
+ if (endStage === 'deploy') return; // 仅重新部署:未复测,不联动
2772
+ this.judgeBugByCases(project, task, opts);
2773
+ } else if (taskType === 'pipeline') {
2774
+ // 后段:终点含复测阶段时逐枚定论清单内 bug
2775
+ if (stageIndex(endStage || 'report') < stageIndex('retest')) return;
2776
+ const bugDirs = payloadObject(task.payload).bug_dirs || [];
2777
+ for (const bug of bugDirs) this.judgeOneBug(project, task, bug, opts);
2778
+ }
2779
+ }
2780
+
2781
+ /**
2782
+ * 按任务关联 bug 的用例当前通过情况定论(`_judge_bug_by_cases`,runner.py:1466;复测联动
2783
+ * 共用入口)。
2784
+ *
2785
+ * @param {object} project
2786
+ * @param {object} task
2787
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2788
+ */
2789
+ judgeBugByCases(project, task, options = null) {
2790
+ const opts = this.opt(options);
2791
+ const bug = this.linkedBugDir(project, task, opts);
2792
+ if (!bug) return;
2793
+ this.judgeOneBug(project, task, bug, opts);
2794
+ }
2795
+
2796
+ /**
2797
+ * 单枚 bug:关联用例全部通过 → 已修复(复测通过),否则 → 复测未通过
2798
+ * (`_judge_one_bug`,runner.py:1473)。
2799
+ *
2800
+ * @param {object} project
2801
+ * @param {object} task
2802
+ * @param {string} bug bug 报告目录名
2803
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2804
+ */
2805
+ judgeOneBug(project, task, bug, options = null) {
2806
+ const opts = this.opt(options);
2807
+ const caseIds = bugCases(pyJoin(project.bug_dir, bug)).ids;
2808
+ if (casesAllPassed(project.cases_root, caseIds)) {
2809
+ this.setBugStatus(project, task, '已修复(复测通过)', bug, opts);
2810
+ this.logTaskLine(task, `${bug} 关联用例全部通过,bug 状态 → 已修复(复测通过)`, opts);
2811
+ } else {
2812
+ this.setBugStatus(project, task, '复测未通过', bug, opts);
2813
+ this.logTaskLine(task, `${bug} 存在未通过用例,bug 状态 → 复测未通过`, opts);
2814
+ }
2815
+ }
2816
+
2817
+ /**
2818
+ * 自动复测:fix 任务一轮成功修复后,为同一 bug 创建一轮复测任务
2819
+ * (`_maybe_auto_retest`,runner.py:1483)。
2820
+ *
2821
+ * 去重:同 bug 已存在排队/运行中的复测任务时不重复创建(fix 任务重启重跑会再次进入此
2822
+ * 逻辑,靠该检查避免复测任务堆叠);已完成的复测任务不拦截,允许再次发起复测。创建后
2823
+ * 日志中记录任务号。选型:关联用例全部已固化时直接走平台脚本复测(零 agent 成本)。
2824
+ *
2825
+ * @param {object} project
2826
+ * @param {object} task
2827
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2828
+ */
2829
+ maybeAutoRetest(project, task, options = null) {
2830
+ const opts = this.opt(options);
2831
+ const bug = this.linkedBugDir(project, task, opts);
2832
+ if (!bug) return;
2833
+ for (const t of listTasks(task.project_id, opts)) {
2834
+ if (!['retest_bug', 'script_retest'].includes(t.task_type)
2835
+ || !['queued', 'running'].includes(t.status)) {
2836
+ continue;
2837
+ }
2838
+ if (payloadObject(t.payload).bug_dir === bug) {
2839
+ this.logTaskLine(task, '自动复测:同 bug 已有排队/运行中的复测任务,不再新建', opts);
2840
+ return;
2841
+ }
2842
+ }
2843
+ const m = BUG_DIR_RE.exec(bug);
2844
+ const name = `复测 ${m ? m[1] : bug}`;
2845
+ // 自动复测选型:关联用例全部已固化时直接走平台脚本复测(零 agent 成本)
2846
+ const caseIds = bugCases(pyJoin(project.bug_dir, bug)).ids;
2847
+ const taskType = bugCasesScripted(project.cases_root, caseIds)
2848
+ ? 'script_retest' : 'retest_bug';
2849
+ // db.insertTask 的字段袋是 camelCase(见文件头差异㉗)
2850
+ const tid = insertTask(task.project_id, {
2851
+ name, autoFix: 0, retest: '不复测', stopType: 'rounds', stopValue: '1',
2852
+ taskType, payload: pyJsonDumps({ bug_dir: bug }),
2853
+ }, opts);
2854
+ this.submit(tid, opts);
2855
+ this.logTaskLine(task, `自动复测:已创建复测任务 #${tid}「${name}」`, opts);
2856
+ }
2857
+
2858
+ /**
2859
+ * 改写 `bug_report.md` 的 **状态** 字段(`_set_bug_status`,runner.py:1977;默认取任务
2860
+ * 关联 bug,pipeline 逐枚传入 `bugDir`)。
2861
+ *
2862
+ * @param {object} project
2863
+ * @param {object} task
2864
+ * @param {string} status
2865
+ * @param {string|null} [bugDir]
2866
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2867
+ */
2868
+ setBugStatus(project, task, status, bugDir = null, options = null) {
2869
+ const opts = this.opt(options);
2870
+ const bug = bugDir || this.linkedBugDir(project, task, opts);
2871
+ if (!bug) return;
2872
+ setMdField(pyJoin(project.bug_dir, bug, 'bug_report.md'), '状态', status);
2873
+ }
2874
+
2875
+ /**
2876
+ * 追加一行平台提示到该任务最新轮次日志(`_log_task_line`,runner.py:1985)。
2877
+ *
2878
+ * 无轮次行 / 无 log_path 时静默(照 Python)。**不吞任何异常**(见文件头差异㉖):Python 的
2879
+ * `except (OSError, AttributeError)` 里 OSError 已由 `appendLog` 内吞掉、AttributeError 在
2880
+ * 本调用形状下不可达;`task` 为 `None` 时 Python `task["id"]` 抛的是 **TypeError(不在
2881
+ * except 元组里,真的外抛)**——Node 侧同抛(错误文本随语言不同,见差异㉖)。
2882
+ *
2883
+ * @param {object} task
2884
+ * @param {string} text
2885
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2886
+ */
2887
+ logTaskLine(task, text, options = null) {
2888
+ const opts = this.opt(options);
2889
+ const rounds = listRounds(task.id, opts);
2890
+ if (rounds.length) {
2891
+ const logPath = rounds[rounds.length - 1].log_path;
2892
+ if (logPath) appendLog(logPath, `### ${text}\n`);
2893
+ }
2894
+ }
2895
+
2896
+ // ══════════════════════════════════════════════════════════════════════
2897
+ // M6.5-R2:脚本复测族(runner.py:1519-1706)
2898
+ //
2899
+ // Python 侧的物理顺序是 `_spawn_retest_task` → `_set_bug_status` → `_log_task_line`,
2900
+ // 后两个已随 R1 落地;本批按「脚本复测族 → 压测发压族」的顺序追加在类末尾(方法快照
2901
+ // 顺序与语义无关,逐函数的 Python 行号在各方法注释里给)。
2902
+ // ══════════════════════════════════════════════════════════════════════
2903
+
2904
+ /**
2905
+ * 解析脚本复测任务的关联用例目录,并二次校验全部已固化(`_script_case_dirs`,
2906
+ * runner.py:1521)。
2907
+ *
2908
+ * 返回 `[[case_id, 目录绝对路径], …]`;bug 无效或存在未固化用例返回 `null`
2909
+ * (创建后脚本被删等竞态的防御,调用方整单回落 agent 复测)。`[case_id, dir]` 的
2910
+ * 二元组形态与 `lib.findCaseDirs` 同(Python 也是 list of tuple,量具转数组后同形)。
2911
+ *
2912
+ * @param {object} project
2913
+ * @param {object} task
2914
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2915
+ * @returns {Array<[string, string]>|null}
2916
+ */
2917
+ scriptCaseDirs(project, task, options = null) {
2918
+ const opts = this.opt(options);
2919
+ const bug = this.linkedBugDir(project, task, opts);
2920
+ if (!bug) return null;
2921
+ const { ids: caseIds } = bugCases(pyJoin(project.bug_dir, bug));
2922
+ const dirs = findCaseDirs(project.cases_root, caseIds);
2923
+ if (!dirs.length || dirs.length !== new Set(caseIds).size) return null;
2924
+ if (dirs.some(([, d]) => !isFile(pyJoin(d, VERIFY_SCRIPT_NAME)))) return null;
2925
+ return dirs;
2926
+ }
2927
+
2928
+ /**
2929
+ * 执行脚本复测:并发跑各用例 `verify.py`,按退出码回写状态并联动 bug 报告
2930
+ * (`_run_script_retest`,runner.py:1539)。
2931
+ *
2932
+ * 退出码语义(flow.md「脚本固化与复测协议」):0=通过;1=断言不符(疑似回归,状态保持
2933
+ * 「需要复测」并升级 agent 复核);2=环境问题(跳过不计失败)。全部通过时 bug 报告 →
2934
+ * 「已修复(复测通过)」;有失败时报告状态不动,由升级的复核任务闭环(防脚本腐化误报
2935
+ * 直接定罪)。
2936
+ *
2937
+ * 并发形态:Python `ThreadPoolExecutor(SCRIPT_WORKERS)` + `as_completed`,**完成序**决定
2938
+ * `results`(进而决定 `failed_ids` 顺序与日志/事件序)。Node 用 `createCompletionPool`
2939
+ * 同口径(见该函数注释);消费方提前退出(用户停止)时排队未启动的取消、在跑的最多等
2940
+ * 一个脚本超时周期(= `shutdown(wait=True, cancel_futures=True)`)。
2941
+ *
2942
+ * @param {object} project
2943
+ * @param {object} task
2944
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2945
+ * @returns {Promise<void>}
2946
+ */
2947
+ async runScriptRetest(project, task, options = null) {
2948
+ const opts = this.opt(options);
2949
+ const taskId = task.id;
2950
+ ensureRuntimeDirs(project.work_dir);
2951
+ const logPath = pyJoin(runtimeDir(project.work_dir, LOG_DIR_NAME),
2952
+ `task_${taskId}_round_1.log`);
2953
+ const roundId = insertRound(taskId, 1, logPath, 'agent', '', opts);
2954
+ const envLabel = String(project.env_label || '').trim() || '本机';
2955
+
2956
+ const dirs = this.scriptCaseDirs(project, task, opts);
2957
+ if (dirs === null) {
2958
+ appendLog(logPath, '### 存在未固化/解析不到的用例,本任务不执行,改建 agent 复测任务\n');
2959
+ const bug = this.linkedBugDir(project, task, opts);
2960
+ if (bug) {
2961
+ this.spawnRetestTask(project, bug, [], logPath, '复测',
2962
+ { ...opts, exclude_task_id: taskId });
2963
+ }
2964
+ finishRound(roundId, 'done', 0, '前置校验未通过,已回落 agent 复测', logPath, opts);
2965
+ updateTask(taskId, { status: 'done', ended_at: nowStr() }, opts);
2966
+ return;
2967
+ }
2968
+
2969
+ appendLog(logPath, `### 脚本复测开始 ${strftime(new Date(), '%H:%M:%S')}`
2970
+ + ` 用例 ${dirs.length} 条 环境=${envLabel}\n`);
2971
+ this.scriptLive(project, { phase: 'execute', stats: { passed: 0, failed: 0, skipped: 0 } });
2972
+
2973
+ const results = []; // [case_id, 退出码],按完成顺序
2974
+ const pool = createCompletionPool(this.SCRIPT_WORKERS);
2975
+ try {
2976
+ for (const [cid, d] of dirs) {
2977
+ this.scriptLive(project, {
2978
+ phase: 'execute',
2979
+ current: { id: cid, relpath: path.relative(project.cases_root, d) },
2980
+ event: {
2981
+ ts: nowStr(), type: 'case_start', case_id: cid, message: '脚本复测开始',
2982
+ },
2983
+ });
2984
+ pool.submit({ cid, d, fn: () => this.runOneScript(d, envLabel, opts) });
2985
+ }
2986
+ while (true) {
2987
+ // 停止:取消排队未启动的脚本(已启动的最多等一个超时周期),不再消费剩余结果
2988
+ if (this._stop_requested.has(taskId)) break;
2989
+ const entry = await pool.next();
2990
+ if (entry === null) break;
2991
+ const { cid, d } = entry.job;
2992
+ if (!entry.ok) throw entry.error; // 照 `fut.result()`:异常原样外抛
2993
+ const [code, execName] = entry.value;
2994
+ this.scriptApplyStatus(d, code, envLabel, opts);
2995
+ const etype = { 0: 'case_pass', 1: 'case_fail' }[code] || 'case_skip';
2996
+ this.scriptLive(project, {
2997
+ event: {
2998
+ ts: nowStr(), type: etype, case_id: cid, message: `脚本退出码 ${code}(${execName})`,
2999
+ },
3000
+ });
3001
+ appendLog(logPath, `### ${cid} exit=${code} 记录=${execName}\n`);
3002
+ results.push([cid, code]);
3003
+ }
3004
+ } finally {
3005
+ // 取消排队未启动的脚本;已启动的在跑脚本最多等一个超时周期
3006
+ await pool.drain();
3007
+ }
3008
+
3009
+ if (this._stop_requested.has(taskId)) {
3010
+ appendLog(logPath, `### 脚本复测被停止 ${strftime(new Date(), '%H:%M:%S')}\n`);
3011
+ finishRound(roundId, 'failed', -1, '用户停止', logPath, opts);
3012
+ updateTask(taskId, { status: 'stopped', ended_at: nowStr() }, opts);
3013
+ return;
3014
+ }
3015
+
3016
+ const passed = results.filter(([, c]) => c === 0).length;
3017
+ const failed = results.filter(([, c]) => c === 1).length;
3018
+ const skipped = results.filter(([, c]) => c !== 0 && c !== 1).length;
3019
+ const summaryLine = `SCRIPT_RETEST 通过${passed}失败${failed}环境跳过${skipped}`;
3020
+ appendLog(logPath, `### ${summaryLine} ${strftime(new Date(), '%H:%M:%S')}\n`);
3021
+ finishRound(roundId, 'done', 0, summaryLine, logPath, opts);
3022
+ this.scriptLive(project, {
3023
+ phase: 'done',
3024
+ stats: { passed, failed, skipped },
3025
+ current: {},
3026
+ });
3027
+
3028
+ const bug = this.linkedBugDir(project, task, opts);
3029
+ if (bug) {
3030
+ if (results.length && failed === 0 && skipped === 0) {
3031
+ this.setBugStatus(project, task, '已修复(复测通过)', null, opts);
3032
+ appendLog(logPath, '### 关联用例全部通过,bug 状态 → 已修复(复测通过)\n');
3033
+ } else if (failed) {
3034
+ const failedIds = results.filter(([, c]) => c === 1).map(([cid]) => cid);
3035
+ this.spawnRetestTask(project, bug, failedIds, logPath, '复核',
3036
+ { ...opts, exclude_task_id: taskId });
3037
+ }
3038
+ }
3039
+ updateTask(taskId, { status: 'done', ended_at: nowStr() }, opts);
3040
+ }
3041
+
3042
+ /**
3043
+ * 执行单个用例的 `verify.py`,落盘 execution 记录(`_run_one_script`,runner.py:1624)。
3044
+ *
3045
+ * 返回 `[归一化退出码, 记录文件名]`:超时/启动失败/非 0/1/2 退出码统一按 1(疑似回归)
3046
+ * 处理,输出全文存证;退出码 0/1/2 原样保留。
3047
+ *
3048
+ * **本族解释器口径**(M6.5 冻结,见文件头差异㉙):用户夹具脚本是 agent 按协议生成的
3049
+ * Python(`verify.py`)⇒ 两侧都跑 python3(A 侧 `sys.executable`、B 侧 `which python3`
3050
+ * 解析出的绝对路径),`cwd=case_dir`、env 加 `TS_ENV_LABEL`。
3051
+ *
3052
+ * @param {string} caseDir
3053
+ * @param {string} envLabel
3054
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
3055
+ * @returns {Promise<[number, string]>}
3056
+ */
3057
+ async runOneScript(caseDir, envLabel, options = null) {
3058
+ this.opt(options); // 形参袋照契约收下(本函数只读环境)
3059
+ const execName = `execution_${strftime(new Date(), '%Y%m%d_%H%M')}_script.md`;
3060
+ const env = { ...process.env, TS_ENV_LABEL: envLabel };
3061
+ let code;
3062
+ let output;
3063
+ try {
3064
+ const proc = await runCapture([pythonExe(), VERIFY_SCRIPT_NAME], caseDir, env,
3065
+ this.SCRIPT_RETEST_TIMEOUT * 1000);
3066
+ code = proc.code;
3067
+ output = Buffer.concat([proc.stdout, proc.stderr]);
3068
+ if (![0, 1, 2].includes(code)) {
3069
+ // 非 0/1/2 的退出码(信号杀死/OOM/脚本 sys.exit(3) 等)按设计视同退出码 1
3070
+ // (疑似回归),避免被当作环境跳过导致复测闭环静默卡死
3071
+ output = Buffer.concat([output,
3072
+ Buffer.from(`\n### 非协议退出码 ${code},按疑似回归处理\n`, 'utf8')]);
3073
+ code = 1;
3074
+ }
3075
+ } catch (err) {
3076
+ if (err instanceof ScriptTimeout) {
3077
+ code = 1;
3078
+ output = Buffer.concat([err.stdout, err.stderr,
3079
+ Buffer.from(`\n### 执行超时(>${this.SCRIPT_RETEST_TIMEOUT}s),按疑似回归处理\n`,
3080
+ 'utf8')]);
3081
+ } else if (err instanceof ScriptSpawnError) {
3082
+ code = 1;
3083
+ output = Buffer.from(`### 脚本启动失败: ${errText(err)}\n`, 'utf8');
3084
+ } else {
3085
+ throw err;
3086
+ }
3087
+ }
3088
+ const head = `# 脚本复测执行记录\n\n- **时间**: ${strftime(new Date(), '%Y-%m-%d %H:%M')}\n`
3089
+ + `- **执行环境**: ${envLabel}\n- **退出码**: ${code}\n\n`
3090
+ + '## 脚本输出\n\n```\n'
3091
+ + output.toString('utf8') + '\n```\n';
3092
+ try {
3093
+ fs.writeFileSync(pyJoin(caseDir, execName), head, 'utf8');
3094
+ } catch { /* 照 Python `except OSError: pass`(目录不可写/不存在都不算失败) */ }
3095
+ return [code, execName];
3096
+ }
3097
+
3098
+ /**
3099
+ * 按脚本退出码回写用例 `status.md` 的字段行(`_script_apply_status`,runner.py:1663;
3100
+ * 「执行记录」表不动,execution 文件即记录)。四行**顺序照 Python**(字段名寻址,
3101
+ * 顺序只影响首次写入时追加行的先后)。
3102
+ *
3103
+ * 写失败由 `setMdField` 内吞(照 Python `lib.set_md_field` 的 `except OSError`)。
3104
+ *
3105
+ * @param {string} caseDir
3106
+ * @param {number} code
3107
+ * @param {string} envLabel
3108
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
3109
+ */
3110
+ scriptApplyStatus(caseDir, code, envLabel, options = null) {
3111
+ this.opt(options);
3112
+ const status = { 0: '通过', 1: '需要复测' }[code] || '跳过';
3113
+ const summary = {
3114
+ 0: '脚本复测通过',
3115
+ 1: '脚本复测判定失败,待人工复核(见 execution_*_script.md)',
3116
+ }[code] || '脚本复测环境异常,跳过(见 execution_*_script.md)';
3117
+ const sp = pyJoin(caseDir, 'status.md');
3118
+ setMdField(sp, '状态', status);
3119
+ setMdField(sp, '最近执行时间', strftime(new Date(), '%Y-%m-%d %H:%M'));
3120
+ setMdField(sp, '执行环境', envLabel);
3121
+ setMdField(sp, '结果摘要', summary);
3122
+ }
3123
+
3124
+ /**
3125
+ * 脚本复测/压测发压共用的 `live.json` 维护:读改写 + tmp 原子覆盖
3126
+ * (`_script_live`,runner.py:1675;结构沿用 agent 写入约定)。
3127
+ *
3128
+ * Python 是**全关键字形参**(`phase=` / `current=` / `event=` / `stats=` / `label=`)⇒
3129
+ * Node 收成一个 `opts` 袋(键名与 Python 一致,`label` 缺省 `'脚本复测'`),见文件头差异㉚。
3130
+ * 读失败(不存在/坏 JSON)按空对象;**非对象顶层照 Python 抛**(Python 的 `list["x"]=…`
3131
+ * / `None[...]` 是 TypeError,不在 `except (OSError, ValueError)` 里)。
3132
+ *
3133
+ * `current` 用 `is not None` 判定(Python `if current is not None:`);`stats`/`event`
3134
+ * 用 Python 真值判定(空 dict 跳过)——JS 的 `{}` 是真值,故走 `rag.pyTruthy`。
3135
+ *
3136
+ * @param {object} project
3137
+ * @param {{phase?: string, current?: object, event?: object, stats?: object,
3138
+ * label?: string}} [opts]
3139
+ */
3140
+ scriptLive(project, opts = {}) {
3141
+ const { phase = null, current = null, event = null, stats = null,
3142
+ label = '脚本复测' } = opts || {};
3143
+ const livePath = pyJoin(runtimeDir(project.work_dir, '.live'), 'live.json');
3144
+ let live;
3145
+ try {
3146
+ live = JSON.parse(fs.readFileSync(livePath, 'utf8'));
3147
+ } catch {
3148
+ live = {}; // 照 `except (OSError, ValueError)`
3149
+ }
3150
+ if (live === null || typeof live !== 'object' || Array.isArray(live)) {
3151
+ throw new TypeError('live.json 顶层不是对象(照 Python 在此抛 TypeError,非吞)');
3152
+ }
3153
+ live.updated_at = nowStr();
3154
+ if (phase) {
3155
+ live.phase = phase;
3156
+ live.phase_label = label;
3157
+ }
3158
+ if (current !== null) live.current_case = current;
3159
+ if (rag.pyTruthy(stats)) {
3160
+ const prev = live.stats;
3161
+ if (prev !== undefined && prev !== null
3162
+ && (typeof prev !== 'object' || Array.isArray(prev))) {
3163
+ throw new TypeError('live.json 的 stats 不是对象(照 Python `dict(...)` 抛,非吞)');
3164
+ }
3165
+ live.stats = { ...(prev || {}), ...stats };
3166
+ }
3167
+ if (rag.pyTruthy(event)) {
3168
+ const prev = live.events;
3169
+ const events = (prev === undefined || prev === null) ? [] : prev;
3170
+ if (!Array.isArray(events)) {
3171
+ throw new TypeError('live.json 的 events 不是列表(照 Python `append` 抛,非吞)');
3172
+ }
3173
+ events.push(event);
3174
+ live.events = events.slice(-200);
3175
+ }
3176
+ try {
3177
+ const tmp = `${livePath}.tmp`;
3178
+ fs.writeFileSync(tmp, pyJsonDumpsIndent(live, 1), 'utf8');
3179
+ fs.renameSync(tmp, livePath);
3180
+ } catch { /* 照 Python `except OSError: pass`(.live 不在等) */ }
3181
+ }
3182
+
3183
+ // ══════════════════════════════════════════════════════════════════════
3184
+ // M6.5-R2:压测发压族(runner.py:1708-1985)
3185
+ // ══════════════════════════════════════════════════════════════════════
3186
+
3187
+ /**
3188
+ * stress 任务的发压阶段:解析方案包 → 统一脚本驱动发压 → 报告与状态收尾
3189
+ * (`_run_load_phase`,runner.py:1708)。
3190
+ *
3191
+ * 驱动由 `loadcase.resolve` 四级回落:run.py 自定义脚本 / scenario.json 场景
3192
+ * (平台调 loadgen 的 run 子命令)/ 存量 `scenario_<id>.json`;都缺 → failed。
3193
+ * 两种驱动共用同一套托管(`spawnLoad`):`cwd=方案包目录`、`TS_*` 环境变量、
3194
+ * stdout+stderr 进该次运行的日志、用户停止先落 stop 文件再宽限杀进程树、
3195
+ * 超时上限 `LOAD_MAX_SECONDS`。状态自管:非 0 退出/超时 → failed;用户停止 → stopped;
3196
+ * 正常结束 → done。
3197
+ *
3198
+ * 2026-10-06 复压批次:**一次调用 = 一次发压运行**(load run)。运行键 `run_key` = 本次
3199
+ * 开始时刻(秒级),日志/指标通道/旧快照/报告全部按它切分,同一方案包可反复运行且互不
3200
+ * 覆盖;运行在 rounds 表落一行 `kind='load'`(round_no 递增),任务状态跟随最近一次运行。
3201
+ *
3202
+ * `finally` 是**兜底**:任何支路(含未预期异常)退出前都把 `live.json` 收尾到 done,
3203
+ * 避免监控卡在发压中;各支路自身的收尾调用可带 stats(`scriptLive` 为读改写合并,
3204
+ * finally 不传 stats 即保留已写入的 `load_summary`)。
3205
+ *
3206
+ * @param {object} project
3207
+ * @param {object} task
3208
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
3209
+ * @returns {Promise<void>}
3210
+ */
3211
+ async runLoadPhase(project, task, options = null) {
3212
+ const opts = this.opt(options);
3213
+ const taskId = task.id;
3214
+ ensureRuntimeDirs(project.work_dir);
3215
+ const runKey = loadcase.new_run_key();
3216
+ const logPath = loadcase.load_log_path(project.work_dir, taskId, runKey);
3217
+ const snapPath = loadcase.snapshot_path(project.work_dir, taskId, runKey);
3218
+ const metricsPath = loadcase.metrics_path(project.work_dir, taskId, runKey);
3219
+ const stopPath = loadcase.stop_file_path(project.work_dir, taskId);
3220
+ // 只清停止标记:产物按运行分文件,上一次运行的报告/曲线/日志都留着
3221
+ try {
3222
+ fs.rmSync(stopPath);
3223
+ } catch { /* 照 Python `except OSError: pass` */ }
3224
+ try {
3225
+ const roundId = insertRound(taskId, this.nextLoadRoundNo(taskId, opts), logPath,
3226
+ 'load', runKey, opts);
3227
+ this.scriptLive(project, { phase: 'execute', label: '压力测试' });
3228
+ const c = loadcase.resolve(project.cases_root, taskId);
3229
+ if (c.driver === null) {
3230
+ await this.failLoad(project, task, roundId, logPath,
3231
+ `压测方案缺失(${c.case_dir} 下需有 run.py 或 scenario.json,`
3232
+ + `存量布局为 load/scenario_${taskId}.json)`, opts);
3233
+ return;
3234
+ }
3235
+ const [, chartsErr] = loadcase.load_charts(c.charts_path);
3236
+ if (chartsErr) appendLog(logPath, `### 图表声明回退默认面板:${chartsErr}\n`);
3237
+ // 报告目录是机器产物:在案例库 load/.gitignore 幂等补忽略项
3238
+ ensureGitignore(pyJoin(project.cases_root, 'load'), 'report/');
3239
+ const cmd = this.loadCmd(c, project, taskId, metricsPath, snapPath, logPath, runKey);
3240
+ appendLog(logPath, `### 压测开始 ${strftime(new Date(), '%H:%M:%S')}`
3241
+ + ` 运行键=${runKey} 驱动=${c.driver} cwd=${c.case_dir}\n`
3242
+ + `### CMD ${cmd.join(' ')}\n`);
3243
+ const [code, note] = await this.spawnLoad(project, taskId, c, cmd, logPath,
3244
+ stopPath, metricsPath, opts);
3245
+ await this.finishLoad(project, task, roundId, logPath, c, code, note,
3246
+ metricsPath, runKey, opts);
3247
+ } finally {
3248
+ // 兜底:未预期异常也落 phase=done(正常/停止路径重复调用一次,幂等无害)
3249
+ this.scriptLive(project, { phase: 'done', label: '压力测试' });
3250
+ }
3251
+ }
3252
+
3253
+ /**
3254
+ * 下一次发压运行的 `round_no`:已有最大轮次 + 1(`_next_load_round_no`,runner.py:1770;
3255
+ * 首次发压恒为 2)。
3256
+ *
3257
+ * 轮次编号在任务内单调递增,重启(换方案)会清空轮次重新从 1 起算;`LOAD_ROUND_NO`
3258
+ * 只作为「首次发压」的历史基准保留。Python 是 `@classmethod`(读 `cls.LOAD_ROUND_NO`)
3259
+ * ⇒ Node 读 `Runner.LOAD_ROUND_NO`(见文件头差异㉛:写成实例方法以收末位 options 袋)。
3260
+ *
3261
+ * @param {number} taskId
3262
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
3263
+ * @returns {number}
3264
+ */
3265
+ nextLoadRoundNo(taskId, options = null) {
3266
+ const opts = this.opt(options);
3267
+ const rows = listRounds(taskId, opts);
3268
+ return Math.max(Runner.LOAD_ROUND_NO - 1, ...rows.map((r) => r.round_no)) + 1;
3269
+ }
3270
+
3271
+ /**
3272
+ * 发压命令行:`run.py` 直接跑;场景驱动走 loadgen 的 `run` 子命令
3273
+ * (`_load_cmd`,runner.py:1780)。**Python 是 `@staticmethod`,Node 写成实例方法**
3274
+ * (同文件头差异⑰的处置):量具的 `seq` 只能经**实例**取方法(static 挂在构造函数上,
3275
+ * `inst[fn]` 取不到)⇒ 这是对拍可达性的硬约束;函数体不读 `this`,语义逐点照 Python。
3276
+ *
3277
+ * 脚本驱动传路径靠 `TS_*` 环境变量;场景驱动额外用命令行参数把指标/快照/日志/报告/
3278
+ * 运行键五处产物交给引擎(CLI 见 loadgen.main)。
3279
+ *
3280
+ * **本族解释器口径**(文件头差异㉙):脚本驱动两侧都是 python3(用户脚本是 Python);
3281
+ * 场景驱动是平台自己的引擎 —— A 侧 `[sys.executable, <repo>/loadgen.py, 'run', …]`、
3282
+ * B 侧 `[process.execPath, <repo>/backend/lib/loadgen.js, 'run', …]`,两侧 argv[0]/argv[1]
3283
+ * 是「本族解释器 + 本族入口」,**逐字节不可比是设计使然**(其余九处参数逐字节比)。
3284
+ *
3285
+ * @param {object} c `loadcase.resolve` 的返回包
3286
+ * @param {object} project
3287
+ * @param {number} taskId
3288
+ * @param {string} metricsPath
3289
+ * @param {string} snapPath
3290
+ * @param {string} logPath
3291
+ * @param {string} [runKey]
3292
+ * @returns {string[]}
3293
+ */
3294
+ loadCmd(c, project, taskId, metricsPath, snapPath, logPath, runKey = '') {
3295
+ if (c.driver === loadcase.DRIVER_SCRIPT) {
3296
+ return [pythonExe(), c.script_path];
3297
+ }
3298
+ return [process.execPath, pyJoin(ROOT_DIR, 'backend', 'lib', 'loadgen.js'), 'run',
3299
+ '--scenario', c.scenario_path,
3300
+ '--metrics', metricsPath, '--snapshot', snapPath, '--log', logPath,
3301
+ '--stop-file', loadcase.stop_file_path(project.work_dir, taskId),
3302
+ '--report-dir', loadcase.report_dir(project.cases_root),
3303
+ '--task-id', String(taskId), '--run-key', runKey];
3304
+ }
3305
+
3306
+ /**
3307
+ * 托管发压进程:注入 `TS_*` 环境、登记进程表、处理停止与超时
3308
+ * (`_spawn_load`,runner.py:1796)。
3309
+ *
3310
+ * 返回 `[exit_code, 说明]`:`exit_code=null` 表示被平台杀掉(超时/停止宽限到);
3311
+ * 说明文本用于日志与失败原因。停止走「先落 stop 文件 → 宽限 `LOAD_STOP_GRACE` →
3312
+ * 杀进程树」,与用户可见的「优雅停止」语义一致。`metricsPath` 为该次运行的指标通道
3313
+ * 路径(按运行键切分),脚本只读环境变量。
3314
+ *
3315
+ * 形态差异(文件头差异㉜):`subprocess.STDOUT` → 同一 fd 传两次 `stdio[1]/stdio[2]`;
3316
+ * `start_new_session=True` → `spawnSession` 的 `detached: true`;`platcompat.kill_tree`
3317
+ * → `killTree`(口径差异见文件头旧②);Python `Popen` 的同步 OSError → 等 Node 的
3318
+ * `'spawn'/'error'` 事件(`spawnSession` 注释)。
3319
+ *
3320
+ * @param {object} project
3321
+ * @param {number} taskId
3322
+ * @param {object} c
3323
+ * @param {string[]} cmd
3324
+ * @param {string} logPath
3325
+ * @param {string} stopPath
3326
+ * @param {string} metricsPath
3327
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
3328
+ * @returns {Promise<[number|null, string]>}
3329
+ */
3330
+ async spawnLoad(project, taskId, c, cmd, logPath, stopPath, metricsPath,
3331
+ options = null) {
3332
+ this.opt(options);
3333
+ const env = { ...process.env };
3334
+ Object.assign(env, {
3335
+ TS_TASK_ID: String(taskId),
3336
+ TS_CASE_DIR: c.case_dir,
3337
+ TS_METRICS_FILE: metricsPath,
3338
+ TS_STOP_FILE: stopPath,
3339
+ TS_WORK_DIR: project.work_dir,
3340
+ TS_PROJECT_DIR: project.project_dir,
3341
+ TS_CASES_ROOT: project.cases_root,
3342
+ TS_MAX_SECONDS: String(LOAD_MAX_SECONDS),
3343
+ // 脚本可 import loadgen(MetricsWriter / 库函数);输出不缓冲便于日志实时可读
3344
+ PYTHONPATH: ROOT_DIR + path.delimiter + (env.PYTHONPATH || ''),
3345
+ PYTHONUNBUFFERED: '1',
3346
+ });
3347
+ let proc = null;
3348
+ try {
3349
+ const fd = fs.openSync(logPath, 'a'); // 照 Python `open(log_path, "ab")`
3350
+ try {
3351
+ proc = spawnSession(cmd, { cwd: c.case_dir, env, stdio: ['ignore', fd, fd] });
3352
+ await waitSpawned(proc); // Popen 的同步 OSError ↔ 'error' 事件
3353
+ } finally {
3354
+ fs.closeSync(fd); // 父进程不留 fd(子进程已各自 dup)
3355
+ }
3356
+ } catch (err) {
3357
+ return [null, `发压进程启动失败: ${errText(err)}`];
3358
+ }
3359
+ appendLog(logPath, `### PID ${proc.pid}\n`);
3360
+ this._procs.set(taskId, proc);
3361
+ const deadline = Date.now() + LOAD_MAX_SECONDS * 1000;
3362
+ let stopAskedAt = null;
3363
+ try {
3364
+ while (true) {
3365
+ const code = await waitExit(proc, LOAD_POLL_INTERVAL * 1000);
3366
+ if (code !== WAIT_TIMEOUT) return [code, ''];
3367
+ const now = Date.now();
3368
+ if (stopAskedAt === null && this._stop_requested.has(taskId)) {
3369
+ stopAskedAt = now;
3370
+ try {
3371
+ fs.writeFileSync(stopPath, 'stop\n', 'utf8');
3372
+ } catch { /* 照 Python `except OSError: pass` */ }
3373
+ appendLog(logPath, `### 收到停止请求 ${strftime(new Date(), '%H:%M:%S')}`
3374
+ + ',等脚本收尾\n');
3375
+ }
3376
+ if (stopAskedAt !== null && now - stopAskedAt >= LOAD_STOP_GRACE * 1000) {
3377
+ appendLog(logPath, '### 停止宽限期到,杀进程树\n');
3378
+ killTree(proc.pid, { sig: 'SIGTERM' });
3379
+ return [await this.waitKilled(proc), '用户停止'];
3380
+ }
3381
+ if (now >= deadline) {
3382
+ appendLog(logPath, `### 超过发压上限 ${LOAD_MAX_SECONDS}s,杀进程树\n`);
3383
+ killTree(proc.pid, { sig: 'SIGTERM' });
3384
+ return [await this.waitKilled(proc), `超过发压上限 ${LOAD_MAX_SECONDS}s`];
3385
+ }
3386
+ }
3387
+ } finally {
3388
+ // 与 agent 轮次同款:退出即摘除进程表(stop() 拿不到已死进程无副作用)
3389
+ this._procs.delete(taskId);
3390
+ }
3391
+ }
3392
+
3393
+ /**
3394
+ * 杀进程树后短暂等待回收;仍未退出返回 `null`(`_wait_killed`,runner.py:1861;
3395
+ * Python 的 `timeout=5` 是**秒**,Node 侧参数名与单位改为 `timeoutMs=5000`,见差异㉝)。
3396
+ *
3397
+ * @param {import('node:child_process').ChildProcess} proc
3398
+ * @param {number} [timeoutMs]
3399
+ * @returns {Promise<number|null>}
3400
+ */
3401
+ async waitKilled(proc, timeoutMs = 5000) {
3402
+ const code = await waitExit(proc, timeoutMs);
3403
+ return code === WAIT_TIMEOUT ? null : code;
3404
+ }
3405
+
3406
+ /**
3407
+ * 发压收尾:报告落盘 + round/task 状态 + `live.json`(`_finish_load`,runner.py:1868;
3408
+ * 状态自管)。
3409
+ *
3410
+ * 报告:场景驱动由 loadgen CLI 落盘(沿用其 JSON+Markdown 形状);脚本驱动由平台按
3411
+ * summary 行生成最小报告 —— 两者同目录同命名规则(`task<id>_<运行键>`),读侧按运行键/
3412
+ * 最新取用,不区分驱动。**停止优先于退出码判定**。
3413
+ *
3414
+ * @param {object} project
3415
+ * @param {object} task
3416
+ * @param {number} roundId
3417
+ * @param {string} logPath
3418
+ * @param {object} c
3419
+ * @param {number|null} code
3420
+ * @param {string} note
3421
+ * @param {string} metricsPath
3422
+ * @param {string} [runKey]
3423
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
3424
+ * @returns {Promise<void>}
3425
+ */
3426
+ async finishLoad(project, task, roundId, logPath, c, code, note, metricsPath,
3427
+ runKey = '', options = null) {
3428
+ const opts = this.opt(options);
3429
+ const taskId = task.id;
3430
+ const stopped = this._stop_requested.has(taskId);
3431
+ const values = loadcase.read_summary(metricsPath);
3432
+ const summaryLine = loadcase.summarize_values(values) + (stopped ? '(用户停止)' : '');
3433
+ const mdName = this.writeLoadReport(project, taskId, c, values, stopped, logPath,
3434
+ runKey, opts);
3435
+ if (stopped) {
3436
+ appendLog(logPath, `### 压测被停止 ${strftime(new Date(), '%H:%M:%S')}\n`);
3437
+ finishRound(roundId, 'failed', -1, `用户停止 ${summaryLine}`, logPath, opts);
3438
+ updateTask(taskId, { status: 'stopped', ended_at: nowStr() }, opts);
3439
+ } else if (code !== 0) {
3440
+ const tail = tailText(logPath);
3441
+ let err = note || `发压脚本退出码 ${code === null ? 'None' : code}`;
3442
+ if (tail) err = `${err}:${tail}`;
3443
+ await this.failLoad(project, task, roundId, logPath, err, opts);
3444
+ return;
3445
+ } else {
3446
+ appendLog(logPath, `### ${summaryLine} 报告=${mdName}\n`);
3447
+ finishRound(roundId, 'done', 0, summaryLine, logPath, opts);
3448
+ updateTask(taskId, { status: 'done', ended_at: nowStr() }, opts);
3449
+ }
3450
+ this.scriptLive(project, {
3451
+ phase: 'done', label: '压力测试', stats: { load_summary: summaryLine },
3452
+ });
3453
+ }
3454
+
3455
+ /**
3456
+ * 报告落盘(`_write_load_report`,runner.py:1901)。**Python 是 `@staticmethod`,Node 写成
3457
+ * 实例方法**(理由同 `loadCmd`:`seq` 经实例调用 + 收末位 options 袋;函数体不读 `this`)。
3458
+ *
3459
+ * 脚本驱动由平台生成、场景驱动读 loadgen 已写的那份;返回报告文件名(Markdown,日志里
3460
+ * 记一行便于人查);失败只记日志不改变状态(`except OSError` 兜整段,含 listdir)。
3461
+ * 「取最新」用 `mtimeNs`(BigInt 纳秒)比较,比 `mtimeMs` 更贴 Python `getmtime` 的精度。
3462
+ *
3463
+ * @param {object} project
3464
+ * @param {number} taskId
3465
+ * @param {object} c
3466
+ * @param {object} values
3467
+ * @param {boolean} stopped
3468
+ * @param {string} logPath
3469
+ * @param {string} [runKey]
3470
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
3471
+ * @returns {string}
3472
+ */
3473
+ writeLoadReport(project, taskId, c, values, stopped, logPath, runKey = '',
3474
+ options = null) {
3475
+ void options; // 形参袋照契约收下(本函数只碰文件)
3476
+ const repDir = loadcase.report_dir(project.cases_root);
3477
+ try {
3478
+ if (c.driver === loadcase.DRIVER_SCRIPT) {
3479
+ const payload = {
3480
+ case: path.basename(c.case_dir),
3481
+ driver: path.basename(c.script_path || ''),
3482
+ run_key: runKey,
3483
+ started_at: strftime(new Date(), '%Y-%m-%d %H:%M:%S'),
3484
+ duration_sec: '', stopped: Boolean(stopped),
3485
+ summary: values,
3486
+ };
3487
+ const [, mdPath] = loadcase.write_minimal_report(payload, repDir, taskId, runKey);
3488
+ return path.basename(mdPath);
3489
+ }
3490
+ // 场景驱动:loadgen 已按同一运行键落盘,直接回报文件名
3491
+ if (runKey) {
3492
+ const stem = loadcase.report_stem(repDir, taskId, runKey);
3493
+ if (isFile(`${stem}.md`)) return path.basename(`${stem}.md`);
3494
+ }
3495
+ const names = fs.readdirSync(repDir)
3496
+ .filter((n) => n.startsWith(`task${taskId}_`) && n.endsWith('.md'));
3497
+ if (names.length) {
3498
+ let newest = names[0];
3499
+ let stamp = fs.statSync(pyJoin(repDir, newest), { bigint: true }).mtimeNs;
3500
+ for (const n of names.slice(1)) {
3501
+ const t = fs.statSync(pyJoin(repDir, n), { bigint: true }).mtimeNs;
3502
+ if (t > stamp) { newest = n; stamp = t; } // 并列取先见者(同 Python `max`)
3503
+ }
3504
+ return newest;
3505
+ }
3506
+ } catch (err) {
3507
+ appendLog(logPath, `### 报告写盘失败: ${errText(err)}\n`);
3508
+ }
3509
+ return '';
3510
+ }
3511
+
3512
+ /**
3513
+ * 发压失败收尾(`_fail_load`,runner.py:1934;方案缺失/启动失败/非 0 退出/超时共用一条
3514
+ * 路径)。Python 侧同步调 `notify_task_failed`,Node 侧该口是 async ⇒ 本函数 async
3515
+ * (见文件头差异⑨同源理由),调用方一律 `await`。
3516
+ *
3517
+ * @param {object} project
3518
+ * @param {object} task
3519
+ * @param {number} roundId
3520
+ * @param {string} logPath
3521
+ * @param {string} err
3522
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
3523
+ * @returns {Promise<void>}
3524
+ */
3525
+ async failLoad(project, task, roundId, logPath, err, options = null) {
3526
+ const opts = this.opt(options);
3527
+ appendLog(logPath, `### ${err}\n`);
3528
+ finishRound(roundId, 'failed', -1, err, logPath, opts);
3529
+ updateTask(task.id, { status: 'failed', error: err, ended_at: nowStr() }, opts);
3530
+ await notifyTaskFailed(task.id, err, 0, opts);
3531
+ this.scriptLive(project, {
3532
+ phase: 'done', label: '压力测试', stats: { load_summary: err },
3533
+ });
3534
+ }
3535
+
3536
+ /**
3537
+ * 创建 agent 复测/复核任务(`retest_bug`),带同 bug 去重(`_spawn_retest_task`,
3538
+ * runner.py:1943)。
3539
+ *
3540
+ * `onlyCases` 非空时写入 payload(失败子集复核);`exclude_task_id`(发起方任务 id,
3541
+ * script_retest 的升级/回落调用方,**进 options 袋、名字照 Python 关键字参数**)在去重时
3542
+ * 跳过。返回任务 id,去重命中返回 `null`。
3543
+ *
3544
+ * @param {object} project
3545
+ * @param {string} bug
3546
+ * @param {string[]} onlyCases
3547
+ * @param {string} logPath
3548
+ * @param {string} prefix
3549
+ * @param {{exclude_task_id?: number, db?: object, dbPath?: string,
3550
+ * env?: NodeJS.ProcessEnv}} [options]
3551
+ * @returns {number|null}
3552
+ */
3553
+ spawnRetestTask(project, bug, onlyCases, logPath, prefix, options = null) {
3554
+ const opts = this.opt(options);
3555
+ const excludeTaskId = opts.exclude_task_id === undefined ? null : opts.exclude_task_id;
3556
+ for (const t of listTasks(project.id, opts)) {
3557
+ // 排除当前任务自身——升级/回落调用发生在自身置 done 之前,
3558
+ // 不跳过的话自身(running、同 bug_dir)恒命中去重,任务永远建不出来
3559
+ if (t.id === excludeTaskId) continue;
3560
+ if (!['retest_bug', 'script_retest'].includes(t.task_type)
3561
+ || !['queued', 'running'].includes(t.status)) {
3562
+ continue;
3563
+ }
3564
+ // 坏 JSON / 非对象 ⇒ 按 `{}`(照差异㉑的防御姿态;Python 只吃 ValueError)
3565
+ if (payloadObject(t.payload).bug_dir === bug) {
3566
+ appendLog(logPath, `### ${prefix}:同 bug 已有排队/运行中的复测任务,不再新建\n`);
3567
+ return null;
3568
+ }
3569
+ }
3570
+ const payload = { bug_dir: bug };
3571
+ if (onlyCases && onlyCases.length) payload.only_cases = onlyCases;
3572
+ const m = BUG_DIR_RE.exec(bug);
3573
+ const title = m ? m[1] : bug;
3574
+ const name = `${prefix} ${title}`;
3575
+ // db.insertTask 的字段袋是 camelCase(见文件头差异㉗)
3576
+ const tid = insertTask(project.id, {
3577
+ name, autoFix: 0, retest: '不复测', stopType: 'rounds', stopValue: '1',
3578
+ taskType: 'retest_bug', payload: pyJsonDumps(payload),
3579
+ }, opts);
3580
+ this.submit(tid, opts);
3581
+ const extra = (onlyCases && onlyCases.length) ? `仅含 ${onlyCases.join('、')}` : '';
3582
+ appendLog(logPath, `### ${prefix}:已创建复测任务 #${tid}「${name}」${extra}\n`);
3583
+ return tid;
3584
+ }
3585
+
3586
+ // ══════════════════════════════════════════════════════════════════════
3587
+ // M6.5-R3:轮次族(runner.py:1996-2156)
3588
+ //
3589
+ // Python 侧的物理顺序是 `_log_task_line` → `_run_round` → `_run_round_dshplugin` →
3590
+ // `_wait_turn_end_dsh` → `_should_stop`,前一个已随 R1/R2 落地;本批按同序追加在类末尾。
3591
+ // ══════════════════════════════════════════════════════════════════════
3592
+
3593
+ /**
3594
+ * 执行一轮:交给本族实现(单族化后只剩 dsh 插件族)。返回 `[exitCode, sessionId]`
3595
+ * (`_run_round`,runner.py:1996)。
3596
+ *
3597
+ * P7b B4:kimi/claude/opencode/hermes/deepseek(CLI) 与 kimi_web/opencode_web 的轮次路径
3598
+ * (含本地子进程 spawn、CLI compact、status 轮询等 turn 结束)全部退场;退场族任务在
3599
+ * `processTask` 入口即以 `RETIRED_MSG` 失败,走不到这里。
3600
+ *
3601
+ * 非 `dsh_plugin` 抛 `RuntimeError(RETIRED_MSG)`——**同名 JS 错误类**(借 `prompts.js` 的
3602
+ * 导出),这样对拍里 `kind` 与 Python `type(exc).__name__` 逐字相同(见文件头差异㊲)。
3603
+ * 本函数与 `runRoundDshplugin` 都是 async(`dshdriver` 全族在 Node 侧是 Promise,见差异①)。
3604
+ *
3605
+ * @param {object} project
3606
+ * @param {object} task
3607
+ * @param {number} roundNo
3608
+ * @param {string} agent
3609
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
3610
+ * @returns {Promise<[number, string]>}
3611
+ */
3612
+ async runRound(project, task, roundNo, agent, options = null) { // eslint-disable-line no-unused-vars
3613
+ if (agent !== 'dsh_plugin') throw new prompts.RuntimeError(RETIRED_MSG); // 防御:入口守卫漏了才会到这
3614
+ return this.runRoundDshplugin(project, task, roundNo, options);
3615
+ }
3616
+
3617
+ /**
3618
+ * dsh 插件族一轮:进程内常驻会话 + followup 投递 + SSE 等 turn/end
3619
+ * (`_run_round_dshplugin`,runner.py:2007)。
3620
+ *
3621
+ * 关键差异(方案 §3.2 A2、§8.1;原为 CLI/web 两族共存时的对比,现为本族固有性质):
3622
+ * - 会话是 **dsh 宿主进程内的 agent**(插件持 handle),不是外部 CLI 子进程:round 1 建会话、
3623
+ * 续轮 resume,多轮共享同一上下文(消灭「每轮独立会话」);
3624
+ * - 轮次结束由插件推来的 turn/end 帧判定(零 status 轮询);
3625
+ * - 停止 = driver.cancel 优雅中断(保留已流式交付的文本),不是杀进程树;
3626
+ * - 会话事件同时翻译成平台既有轮次日志行(`### PROMPT` + `{"role":...}`),故
3627
+ * `read_dialogue` / 「修复过程」界面无需改动即可渲染;
3628
+ * - 无 compact 调用:dsh 自己的 agent loop 管上下文压缩。
3629
+ *
3630
+ * **线程 → async 任务**(见文件头差异㊴):Python 起 `threading.Thread(target=_dsh_stream_worker…)`
3631
+ * 再 `join(timeout=5)`;Node 起一个 async 任务存 promise,`finally` 里置停止标志后
3632
+ * `await Promise.race([task, 5s 定时器])`——**该任务的 rejection 只打一行**(Python 线程内
3633
+ * 异常只打 traceback,不带崩主流程)。停止信号用 `{aborted:false}` 对象(`feedInto` 认识
3634
+ * `{aborted}` 形态),`stream_stop.set()` ↔ `aborted = true`。
3635
+ *
3636
+ * @param {object} project
3637
+ * @param {object} task
3638
+ * @param {number} roundNo
3639
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
3640
+ * @returns {Promise<[number, string]>}
3641
+ */
3642
+ async runRoundDshplugin(project, task, roundNo, options = null) {
3643
+ const opts = this.opt(options);
3644
+ ensureRuntimeDirs(project.work_dir);
3645
+ const logPath = pyJoin(runtimeDir(project.work_dir, LOG_DIR_NAME),
3646
+ `task_${task.id}_round_${roundNo}.log`);
3647
+ const roundId = insertRound(task.id, roundNo, logPath, 'agent', '', opts);
3648
+ let sid = task.session_id || '';
3649
+ const waiter = new TurnWaiter();
3650
+ const streamStop = { aborted: false };
3651
+ let streamTask = null;
3652
+ let exitCode;
3653
+ try {
3654
+ if (!(await driverAvailable())) {
3655
+ throw new DshDriverError(
3656
+ -1, 'dsh agent 驱动不可用(插件未安装/未启用,或 agents 服务未就绪)');
3657
+ }
3658
+ // 模型值统一 `provider/id`(模型下拉口径);宿主 `/session` 不拆前缀,
3659
+ // 这里先拆成 provider + 裸 id 下传(无前缀的存量值 provider='',沿用宿主当前 provider)
3660
+ const [modelProvider, modelId] = splitModel(
3661
+ String(task.model || '').trim() || String(project.model || '').trim());
3662
+ sid = await ensureSession(sid, project.project_dir, `task-${task.id}`, modelId, modelProvider);
3663
+ // 项目级思考等级(2026-10-04):会话实时态里已有显式档位就保持(用户在会话详情页改过、
3664
+ // 以及上轮应用过的项目默认都记在这里),否则回落项目默认。best-effort:档位与模型不匹配
3665
+ // 等问题只记一行日志,本轮照常跑(**权限档不下发**:任务会话没有交互作答面,
3666
+ // manual(approval=ask) 会让轮次永久挂起等一个没人能答的审批)。
3667
+ const effort = sessionEffort(sid)
3668
+ || String(rowOpt(project, 'reasoning_effort') || '').trim();
3669
+ if (effort) {
3670
+ appendLog(logPath, `### 会话默认思考等级 effort=${effort}\n`);
3671
+ await applySessionDefaults(sid, modelId, modelProvider, effort, '',
3672
+ (t) => appendLog(logPath, t));
3673
+ }
3674
+ if (sid !== (task.session_id || '')) {
3675
+ // sid 在本轮开始即精确入库(不再等轮后从 stdout/mtime 猜):会话窗口运行期间即可按
3676
+ // exact sid 读事件;续轮直接用同一 sid resume
3677
+ updateTask(task.id, { session_id: sid }, opts);
3678
+ appendLog(logPath, pyJsonDumps({
3679
+ role: 'meta', type: 'session.resume_hint', session_id: sid,
3680
+ }) + '\n');
3681
+ }
3682
+ appendLog(logPath, `### 会话 ${sid}(dsh 插件·进程内常驻)\n`);
3683
+ // 订阅必须先于投递:否则会漏掉紧跟 followup 的 turn/start
3684
+ const since = pyInt(pyGet(await driverStatus(sid), 'last_seq', null) || 0);
3685
+ streamTask = dshStreamWorker(sid, since, waiter, logPath, streamStop);
3686
+ const prompt = await buildPrompt(task, roundNo, (m) => this.logTaskLine(task, m, opts), opts);
3687
+ appendLog(logPath, `### PROMPT ${pyJsonDumps(prompt)}\n`);
3688
+ appendLog(logPath, `### 轮次 ${roundNo} 开始 ${strftime(new Date(), '%H:%M:%S')} `
3689
+ + 'driver=dsh_plugin\n');
3690
+ await driverPrompt(sid, prompt);
3691
+ exitCode = await this.waitTurnEndDsh(task, sid, waiter, logPath, opts);
3692
+ } catch (e) {
3693
+ if (!(e instanceof DshDriverError)) throw e; // 照 Python:只吃 DshDriverError
3694
+ finishRound(roundId, 'failed', -1, `dsh 驱动调用失败: ${e}`, logPath, opts);
3695
+ updateTask(task.id, { error: `dsh 驱动调用失败: ${e}` }, opts);
3696
+ return [-1, task.session_id];
3697
+ } finally {
3698
+ streamStop.aborted = true;
3699
+ if (streamTask !== null) {
3700
+ // P7a 缺陷 C:stop 置位后 SSE 任务由 `sse.js` 的停止探针秒级唤醒(shutdown 连接打断
3701
+ // 阻塞读,实测 ~0.1s)。此前 stop 只在读返回后才被检查,SSE 空闲时要等下一帧 keepalive
3702
+ // ——真插件 15s,于是这里必然白等满 5s。timeout 仅作兜底,正常路径不会用满;流任务若真
3703
+ // 超时也由下一轮/后续订阅自然收口。
3704
+ await Promise.race([
3705
+ streamTask.catch((e) => {
3706
+ // Python:线程内异常只打 traceback,不带崩主流程(`feedInto` 自身已吞链路异常)
3707
+ console.log(`[runner] dsh 事件流任务异常(忽略): ${errText(e)}`);
3708
+ }),
3709
+ joinTimeout(STREAM_JOIN_TIMEOUT_MS),
3710
+ ]);
3711
+ }
3712
+ }
3713
+ appendLog(logPath, `### 轮次 ${roundNo} 结束 ${strftime(new Date(), '%H:%M:%S')} `
3714
+ + `exit=${exitCode}\n`);
3715
+ const summary = readLiveSummary(project.work_dir);
3716
+ finishRound(roundId, exitCode === 0 ? 'done' : 'failed', exitCode, summary, logPath, opts);
3717
+ try {
3718
+ const stats = pyGetItem(JSON.parse(summary), 'stats');
3719
+ updateTask(task.id, { new_bugs: pyInt(pyGet(stats, 'new_bug_reports', 0)) }, opts);
3720
+ } catch (e) {
3721
+ // 照 Python `except (ValueError, TypeError, KeyError): pass` 逐条(OverflowError 不在
3722
+ // 元组里 ⇒ 外抛;`JSON.parse` 的 SyntaxError 对应 ValueError)
3723
+ if (!(e instanceof ValueError || e instanceof TypeError || e instanceof KeyError
3724
+ || e instanceof SyntaxError)) {
3725
+ throw e;
3726
+ }
3727
+ }
3728
+ return [exitCode, sid];
3729
+ }
3730
+
3731
+ /**
3732
+ * 等事件流推来的 turn/end(零远端轮询;`_wait_turn_end_dsh`,runner.py:2097)。
3733
+ *
3734
+ * 结束条件:waiter 收到 turn/end 帧(completed→0 / aborted→130 / 其余→1)。
3735
+ * 本地 5s tick 只做三件事,都不碰远端:响应任务停止标记(再补一次 cancel 兜底)、
3736
+ * 检查 SSE 链路错误、检查 turn 是否压根没起来(宽限期内无 turn/start)。
3737
+ *
3738
+ * 形参映射:`waiter.wait_turn(5.0)` → `await waiter.wait(5000)`(毫秒,见差异②);
3739
+ * `waiter.turn_reason`/`exit_code` → `waiter.turnReason`/`waiter.exitCode`。
3740
+ *
3741
+ * @param {object} task
3742
+ * @param {string} sid
3743
+ * @param {TurnWaiter} waiter
3744
+ * @param {string} logPath
3745
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
3746
+ * @returns {Promise<number>} 轮次退出码
3747
+ */
3748
+ async waitTurnEndDsh(task, sid, waiter, logPath, options = null) { // eslint-disable-line no-unused-vars
3749
+ const waitStarted = Date.now() / 1000;
3750
+ for (;;) {
3751
+ const event = await waiter.wait(5000);
3752
+ if (event === 'turn_end') {
3753
+ appendLog(logPath, `### 轮次结束 turn_reason=${pyStr(waiter.turnReason)}\n`);
3754
+ return waiter.exitCode;
3755
+ }
3756
+ if (event === 'interaction') {
3757
+ // 提问/审批已推给用户:本轮不结束(dsh 侧会话挂起等作答),记一行日志后继续等
3758
+ // turn/end(用户作答后同一 turn 继续跑)
3759
+ appendLog(logPath, '### 会话等待人工输入(提问/审批)\n');
3760
+ continue;
3761
+ }
3762
+ if (event === 'error') {
3763
+ appendLog(logPath, `### 事件流异常: ${waiter.error}\n`);
3764
+ return 1;
3765
+ }
3766
+ if (this._stop_requested.has(task.id)) {
3767
+ try {
3768
+ await driverCancel(sid); // runner.stop 已调过一次,这里兜底
3769
+ } catch (e) {
3770
+ if (!(e instanceof DshDriverError)) throw e;
3771
+ appendLog(logPath, `### cancel 失败: ${e}\n`);
3772
+ }
3773
+ return 130;
3774
+ }
3775
+ if (!waiter.started && Date.now() / 1000 - waitStarted > this.DSH_TURN_START_GRACE) {
3776
+ appendLog(logPath, `### turn 未启动(${this.DSH_TURN_START_GRACE}s 未见 turn/start)\n`);
3777
+ try {
3778
+ await driverCancel(sid);
3779
+ } catch (e) {
3780
+ if (!(e instanceof DshDriverError)) throw e;
3781
+ }
3782
+ return 1;
3783
+ }
3784
+ }
3785
+ }
3786
+
3787
+ /**
3788
+ * 任务级停止条件:轮数达到 / 累计新 bug 达到 / 截止时间到 / 运行时长达到,任一命中即停
3789
+ * (`_should_stop`,runner.py:2132)。
3790
+ *
3791
+ * `deadline` 支:Python 是 `db.now_str() >= stop_value.replace("T", " ")`,`except AttributeError`
3792
+ * ——`stop_value` 为 `None`/数字(无 `.replace`)时判「不停」;Node 侧对应的异常是 `TypeError`
3793
+ * (只有 `String` 有 `replace`),逐条对齐见文件头差异㊶。
3794
+ *
3795
+ * `duration` 支:秒数解析失败(`int()` 的 `ValueError`)、起始时刻缺失/格式不符
3796
+ * (`strptime` 的 `ValueError`)都判「不停」——Node 侧的对应异常是 `ValueError`(`pyInt`)与
3797
+ * `RangeError`(`localSeconds`)。
3798
+ *
3799
+ * 未知类型兜底只跑一轮(`round_no >= 1`)。
3800
+ *
3801
+ * @param {object} task
3802
+ * @param {number} roundNo
3803
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 本函数不读库选项
3804
+ * (`db.now_str()` 无参),为形参表契约保留
3805
+ * @returns {boolean}
3806
+ */
3807
+ shouldStop(task, roundNo, options = null) { // eslint-disable-line no-unused-vars
3808
+ const stopType = task.stop_type;
3809
+ const stopValue = task.stop_value;
3810
+ if (stopType === 'rounds') {
3811
+ return roundNo >= pyInt(stopValue || '1');
3812
+ }
3813
+ if (stopType === 'bugs') {
3814
+ return pyInt(task.new_bugs || 0) >= pyInt(stopValue || '1');
3815
+ }
3816
+ if (stopType === 'deadline') {
3817
+ try {
3818
+ return nowStr() >= stopValue.replace('T', ' ');
3819
+ } catch (e) {
3820
+ if (e instanceof TypeError) return false; // 照 Python `except AttributeError`
3821
+ throw e;
3822
+ }
3823
+ }
3824
+ if (stopType === 'duration') {
3825
+ // stop_value 为秒数:从任务开始时间起算,时长达到即停
3826
+ const startedRaw = task.started_at || task.created_at || '';
3827
+ const started = startedRaw.split('.')[0];
3828
+ try {
3829
+ const secs = pyInt(stopValue || '0');
3830
+ const startedTs = localSeconds(started);
3831
+ return Date.now() / 1000 - startedTs >= secs;
3832
+ } catch (e) {
3833
+ // 照 Python `except (ValueError, TypeError, OverflowError)`:int()/strptime/mktime
3834
+ // 三类失败都判「不停」(`started.split` 的异常发生在上面的 try 之外 ⇒ 照 Python 外抛)
3835
+ if (e instanceof ValueError || e instanceof TypeError || e instanceof OverflowError
3836
+ || e instanceof RangeError) {
3837
+ return false;
3838
+ }
3839
+ throw e;
3840
+ }
3841
+ }
3842
+ return roundNo >= 1; // 未知类型兜底只跑一轮
3843
+ }
3844
+ }
3845
+
3846
+ // --------------------------------------------------------------------------
3847
+ // 私有小工具(Runner 之后集中一处,供类内与模块级共用)
3848
+ // --------------------------------------------------------------------------
3849
+
3850
+ /**
3851
+ * Python 解释器绝对路径解析失败(`pythonExe` 抛它)。
3852
+ *
3853
+ * 为什么值得一个具名错误:`_run_one_script` / `_load_cmd` 的脚本驱动支**必须**跑 python3
3854
+ * (用户夹具脚本是 agent 按协议生成的 Python)。PATH 里找不到就**响亮报错**——绝不回落
3855
+ * `'python3'` 字面量(那会把「找不到解释器」变成子进程 ENOENT 的假业务失败)、绝不抛空。
3856
+ */
3857
+ export class PythonInterpreterMissing extends Error {
3858
+ constructor(message) {
3859
+ super(message);
3860
+ this.name = 'PythonInterpreterMissing';
3861
+ }
3862
+ }
3863
+
3864
+ /** 解释器解析结果缓存(运行期只解析一次;`process.env.PATH` 在本进程生命周期内不变) */
3865
+ let pythonExeCache = null;
3866
+
3867
+ /**
3868
+ * 解析本机 python3 的**绝对路径**(A 侧 `sys.executable` 的对应物,见文件头差异㉙)。
3869
+ * 用 `agents.whichSync`(`shutil.which` 的 POSIX 子集);解析失败抛 `PythonInterpreterMissing`。
3870
+ *
3871
+ * @returns {string}
3872
+ */
3873
+ export function pythonExe() {
3874
+ if (pythonExeCache === null) {
3875
+ const found = whichSync('python3');
3876
+ if (!found) {
3877
+ throw new PythonInterpreterMissing(
3878
+ 'PATH 里找不到 python3(脚本复测的 verify.py 与发压 run.py 都是 Python 夹具脚本,'
3879
+ + '必须用本机 python3 解释器执行;不回落字面量 "python3")',
3880
+ );
3881
+ }
3882
+ pythonExeCache = found;
3883
+ }
3884
+ return pythonExeCache;
3885
+ }
3886
+
3887
+ /** 子进程超时(`subprocess.TimeoutExpired` 的对应物;带已收到的输出,照 `e.stdout/e.stderr`) */
3888
+ class ScriptTimeout extends Error {
3889
+ constructor(stdout, stderr) {
3890
+ super('script timeout');
3891
+ this.name = 'ScriptTimeout';
3892
+ this.stdout = stdout;
3893
+ this.stderr = stderr;
3894
+ }
3895
+ }
3896
+
3897
+ /**
3898
+ * 子进程**启动失败**(`subprocess` 的 OSError 分支:ENOENT/EACCES/坏 cwd 等)。
3899
+ * 与 `ScriptTimeout` 分开是为了让调用方照 Python 的 `except TimeoutExpired` / `except OSError`
3900
+ * 两条子句逐条对应(**不合并、不互相兜底**)。
3901
+ */
3902
+ class ScriptSpawnError extends Error {
3903
+ constructor(cause) {
3904
+ super(cause && cause.message !== undefined ? cause.message : String(cause));
3905
+ this.name = 'ScriptSpawnError';
3906
+ this.cause = cause;
3907
+ }
3908
+ }
3909
+
3910
+ /** `'exit'` 事件 → Python `returncode`:正常退出给 code,信号杀死给 `-signum`(照 CPython) */
3911
+ function exitCodeOf(code, signal) {
3912
+ if (code !== null && code !== undefined) return code;
3913
+ if (signal) {
3914
+ const n = os.constants && os.constants.signals ? os.constants.signals[signal] : undefined;
3915
+ return typeof n === 'number' ? -n : -1;
3916
+ }
3917
+ return -1;
3918
+ }
3919
+
3920
+ /**
3921
+ * 跑一个子进程并收全 stdout/stderr(`subprocess.run(capture_output=True, timeout=…)` 的
3922
+ * 最小等价物;M6.5-R2 私有)。
3923
+ *
3924
+ * 逐条对齐 CPython 的语义:
3925
+ * - 超时:`process.kill()`(POSIX 上是 SIGKILL)后**继续收输出**,再抛 `TimeoutExpired`
3926
+ * (带已收到的 stdout/stderr);
3927
+ * - 启动失败:抛 `ScriptSpawnError`(对应 `OSError`);
3928
+ * - 退出码:信号杀死 → `-signum`(见 `exitCodeOf`);
3929
+ * - `env` 是**完整环境**(调用方给 `{...process.env, …}`,照 `dict(os.environ)`)。
3930
+ *
3931
+ * @param {string[]} argv
3932
+ * @param {string} cwd
3933
+ * @param {NodeJS.ProcessEnv} env
3934
+ * @param {number} timeoutMs
3935
+ * @returns {Promise<{code: number, stdout: Buffer, stderr: Buffer}>}
3936
+ */
3937
+ function runCapture(argv, cwd, env, timeoutMs) {
3938
+ return new Promise((resolve, reject) => {
3939
+ let child;
3940
+ try {
3941
+ child = spawn(argv[0], argv.slice(1), { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
3942
+ } catch (error) {
3943
+ reject(new ScriptSpawnError(error));
3944
+ return;
3945
+ }
3946
+ const out = [];
3947
+ const err = [];
3948
+ let timedOut = false;
3949
+ let settled = false;
3950
+ const timer = setTimeout(() => {
3951
+ timedOut = true;
3952
+ try {
3953
+ child.kill('SIGKILL');
3954
+ } catch { /* 照 CPython `process.kill()`:尽力而为 */ }
3955
+ }, timeoutMs);
3956
+ child.stdout.on('data', (chunk) => out.push(chunk));
3957
+ child.stderr.on('data', (chunk) => err.push(chunk));
3958
+ child.on('error', (error) => {
3959
+ if (settled) return;
3960
+ settled = true;
3961
+ clearTimeout(timer);
3962
+ reject(new ScriptSpawnError(error));
3963
+ });
3964
+ child.on('close', (code, signal) => {
3965
+ if (settled) return;
3966
+ settled = true;
3967
+ clearTimeout(timer);
3968
+ const stdout = Buffer.concat(out);
3969
+ const stderr = Buffer.concat(err);
3970
+ if (timedOut) {
3971
+ reject(new ScriptTimeout(stdout, stderr));
3972
+ return;
3973
+ }
3974
+ resolve({ code: exitCodeOf(code, signal), stdout, stderr });
3975
+ });
3976
+ });
3977
+ }
3978
+
3979
+ /** 等进程退出(毫秒)的哨兵:`waitExit` 超时返回它(Python `TimeoutExpired` 的返回位) */
3980
+ const WAIT_TIMEOUT = Symbol('wait-timeout');
3981
+
3982
+ /**
3983
+ * 等进程退出最多 `timeoutMs`:退出给归一后的退出码,超时给 `WAIT_TIMEOUT`。
3984
+ * `proc.wait(timeout=…)` 的对应物(`waitKilled` / `spawnLoad` 的轮询都用它)。
3985
+ *
3986
+ * @param {import('node:child_process').ChildProcess} proc
3987
+ * @param {number} timeoutMs
3988
+ * @returns {Promise<number|typeof WAIT_TIMEOUT>}
3989
+ */
3990
+ function waitExit(proc, timeoutMs) {
3991
+ return new Promise((resolve) => {
3992
+ if (proc.exitCode !== null || proc.signalCode !== null) {
3993
+ resolve(exitCodeOf(proc.exitCode, proc.signalCode));
3994
+ return;
3995
+ }
3996
+ let timer = null;
3997
+ const onExit = (code, signal) => {
3998
+ if (timer !== null) clearTimeout(timer);
3999
+ resolve(exitCodeOf(code, signal));
4000
+ };
4001
+ proc.once('exit', onExit);
4002
+ timer = setTimeout(() => {
4003
+ proc.removeListener('exit', onExit);
4004
+ resolve(WAIT_TIMEOUT);
4005
+ }, timeoutMs);
4006
+ });
4007
+ }
4008
+
4009
+ /**
4010
+ * 等 `spawn` 真正落地:`'spawn'` 事件解决,`'error'` 事件抛 `ScriptSpawnError`。
4011
+ * 存在理由是 Node 与 Python 的**失败时点**不同(Python `Popen` 同步抛 OSError,
4012
+ * Node 走事件)——见 `proc.spawnSession` 注释与文件头差异㉜。
4013
+ *
4014
+ * @param {import('node:child_process').ChildProcess} proc
4015
+ * @returns {Promise<void>}
4016
+ */
4017
+ function waitSpawned(proc) {
4018
+ return new Promise((resolve, reject) => {
4019
+ proc.once('spawn', () => resolve());
4020
+ proc.once('error', (error) => reject(new ScriptSpawnError(error)));
4021
+ });
4022
+ }
4023
+
4024
+ /**
4025
+ * 有界并发「完成序」池(Python `ThreadPoolExecutor(max_workers=limit)` + `as_completed`
4026
+ * 的等价物,M6.5-R2 私有)。
4027
+ *
4028
+ * 语义逐条照 `_run_script_retest` 的用法(runner.py:1560-1600):
4029
+ * - `submit` 全部先提交(排队),最多 `limit` 个在跑(提交序启动);
4030
+ * - `next()` 按**完成序**产出 `{job, ok, value|error}`(不是提交序)——完成序是可见行为,
4031
+ * `results` 的顺序决定 `failed_ids` 与日志/事件序;
4032
+ * - 消费方提前 `break` 后调 `drain()` = `shutdown(wait=True, cancel_futures=True)`:
4033
+ * 排队未启动的取消,已在跑的最多等一个脚本超时周期(本实现直接等它们收口);
4034
+ * - 任务抛出的异常按 `Future.result()` 口径在 `next()` 时**原样外抛**(不吞、不降级)。
4035
+ *
4036
+ * @param {number} limit
4037
+ * @returns {{submit: Function, next: Function, drain: Function}}
4038
+ */
4039
+ function createCompletionPool(limit) {
4040
+ const queue = []; // 已提交未启动
4041
+ const running = new Set(); // 在跑的 Promise
4042
+ const done = []; // 完成序结果队列
4043
+ let notify = null; // 单消费者唤醒
4044
+ let closed = false;
4045
+
4046
+ const wake = () => {
4047
+ if (notify !== null) {
4048
+ const fn = notify;
4049
+ notify = null;
4050
+ fn();
4051
+ }
4052
+ };
4053
+ const pump = () => {
4054
+ while (!closed && running.size < limit && queue.length > 0) {
4055
+ const job = queue.shift();
4056
+ const task = (async () => {
4057
+ try {
4058
+ return { job, ok: true, value: await job.fn() };
4059
+ } catch (error) {
4060
+ return { job, ok: false, error };
4061
+ }
4062
+ })();
4063
+ const tracked = task.then((entry) => {
4064
+ running.delete(tracked);
4065
+ done.push(entry);
4066
+ pump();
4067
+ wake();
4068
+ });
4069
+ running.add(tracked);
4070
+ }
4071
+ };
4072
+
4073
+ return {
4074
+ submit(job) {
4075
+ queue.push(job);
4076
+ pump();
4077
+ },
4078
+ async next() {
4079
+ while (done.length === 0 && running.size > 0) {
4080
+ await new Promise((res) => { notify = res; });
4081
+ }
4082
+ return done.length > 0 ? done.shift() : null;
4083
+ },
4084
+ async drain() {
4085
+ closed = true;
4086
+ queue.length = 0;
4087
+ wake();
4088
+ await Promise.all([...running]);
4089
+ },
4090
+ };
4091
+ }
4092
+
4093
+ /** Python `str.splitlines()` 的切分点(`\r\n` 算一个边界;比 `split("\n")` 多 8 个字符) */
4094
+ const SPLITLINES_RE = /\r\n|\r|\n|\v|\f|\x1c|\x1d|\x1e|\x85|\u2028|\u2029/;
4095
+
4096
+ /** Python `str.splitlines()`(`_tail_text` 用;同 builtin-assets.js 的私有实现口径) */
4097
+ function splitLines(text) {
4098
+ const s = String(text);
4099
+ return s.length === 0 ? [] : s.split(SPLITLINES_RE);
4100
+ }
4101
+
4102
+ /**
4103
+ * 发压失败原因摘要:取日志尾部非平台标记行(`runner._tail_text`,runner.py:2351)。
4104
+ *
4105
+ * "### " 开头是平台自己写的进度/状态标记,对用户不是有效信息,故剔除;空行同样剔除;
4106
+ * 其余行各自 `strip()` 后用**单空格**连接,最后按**码点**取末尾 `limit` 个字符
4107
+ * (`" ".join(lines)[-limit:]`,照文件头差异㉔)。
4108
+ *
4109
+ * R2 曾以私有先行版本落地(差异㉟),R3 起本函数就是正式实现(`_finish_load` 与
4110
+ * 对拍用例共用同一份)。
4111
+ *
4112
+ * @param {string} logPath
4113
+ * @param {number} [limit]
4114
+ * @returns {string}
4115
+ */
4116
+ export function tailText(logPath, limit = 300) {
4117
+ const lines = splitLines(readLogTail(logPath, 40))
4118
+ .map((ln) => ln.trim())
4119
+ .filter((ln) => ln && !ln.startsWith('### '));
4120
+ const joined = lines.join(' ');
4121
+ return [...joined].slice(-limit).join('');
4122
+ }
4123
+
4124
+ /**
4125
+ * 建启动闸(`threading.Event` 的 Node 形态):`promise` 被 await、`release()` 放行(幂等)。
4126
+ * @returns {{promise: Promise<void>, release: Function}}
4127
+ */
4128
+ function createGate() {
4129
+ let release = null;
4130
+ const promise = new Promise((resolve) => { release = resolve; });
4131
+ return { promise, release: () => { if (release !== null) release(); } };
4132
+ }
4133
+
4134
+ /**
4135
+ * 调用单元执行体(未接线抛具名错误 `UnitHandlerMissing`,文案点明到期里程碑)。
4136
+ *
4137
+ * @param {'task'|'card'|'answer'} port
4138
+ * @param {string} key 队列键(进文案)
4139
+ * @param {Function|null} fn 已注册的执行体
4140
+ * @param {number|null} unitId
4141
+ * @returns {Promise<any>}
4142
+ */
4143
+ async function callUnitHandler(port, key, fn, unitId) {
4144
+ if (typeof fn !== 'function') {
4145
+ throw new UnitHandlerMissing(`${UNIT_HANDLER_MISSING[port]}:${key} 无法执行`);
4146
+ }
4147
+ return await fn(unitId);
4148
+ }
4149
+
4150
+ /**
4151
+ * `json.loads(task["payload"] or "{}").get("parent_task_id")` 的防御版(见文件头差异⑮):
4152
+ * 坏 JSON / 非对象 / 缺键一律 `null`(= 无前段)。
4153
+ * @param {unknown} payload
4154
+ * @returns {number|null}
4155
+ */
4156
+ function payloadParentId(payload) {
4157
+ try {
4158
+ const parsed = JSON.parse(payload || '{}');
4159
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
4160
+ const pid = parsed.parent_task_id;
4161
+ if (pid === null || pid === undefined || pid === '') return null;
4162
+ return pyIntOr(pid, null);
4163
+ } catch {
4164
+ return null;
4165
+ }
4166
+ }
4167
+
4168
+ // ══════════════════════════════════════════════════════════════════════════
4169
+ // M6.5-R3:日志 / 读侧族(runner.py:2159-2385 的模块级部分)
4170
+ //
4171
+ // Python 侧的物理顺序:`build_prompt` → `append_log`(M3 已落)→ `_content_text` →
4172
+ // `dsh_event_to_log_line` → `_dsh_stream_worker` → `parse_session_hint` → 两个长度常量 →
4173
+ // `read_dialogue`(M6 落 `taskjson.js`)→ `summary_entry_text` → `read_log_tail` →
4174
+ // `_tail_text` → `read_live_summary`。其中 `readLogTail` / `tailText` 在 R2 已以私有先行版本
4175
+ // 存在(差异⑭/㉟),本批转为正式导出(见差异㊹)。
4176
+ // ══════════════════════════════════════════════════════════════════════════
4177
+
4178
+ /**
4179
+ * 本轮提示词选择:round 1 或 `fresh_prompt=1`(「继续」续跑第一轮)用完整首轮提示词;
4180
+ * 其余续轮用短提示词(`build_prompt`,runner.py:2159)。
4181
+ *
4182
+ * `log` 可选任务日志回调,透传首轮构建(日期范围复测的 RAG 检索统计落日志)。
4183
+ *
4184
+ * **async**(见文件头差异㊳):`prompts.first_round_prompt` 在 Node 侧是 async(M6-4 的
4185
+ * 「prompt 构建要读 rag/提交摘录」异步着色)⇒ 本函数返回 Promise,调用方必须 `await`
4186
+ * (`runRoundDshplugin` 已 await)。`options` 是 `db.get_project` 的 Node 选项袋。
4187
+ *
4188
+ * @param {object} task
4189
+ * @param {number} roundNo
4190
+ * @param {((text: string) => void)|null} [log]
4191
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
4192
+ * @returns {Promise<string>}
4193
+ */
4194
+ export async function buildPrompt(task, roundNo, log = null, options = null) {
4195
+ if (roundNo === 1 || task.fresh_prompt) {
4196
+ const project = getProject(task.project_id, options === null ? {} : options);
4197
+ return await prompts.firstRoundPrompt(project, task, log);
4198
+ }
4199
+ return prompts.nextRoundPrompt(roundNo, task);
4200
+ }
4201
+
4202
+ /**
4203
+ * dsh 消息 content(块数组)里的 text 块拼接;非数组/无文本返回空串(`_content_text`,
4204
+ * runner.py:2180)。
4205
+ *
4206
+ * `isinstance(content, str)` → `typeof === 'string'`;`str(block.get("text") or "")` →
4207
+ * `pyStr(...)`(`None` 落 `""`,`True` 落 `"True"`——**不是** JS 的 `"true"`,见差异㊶)。
4208
+ *
4209
+ * @param {unknown} content
4210
+ * @returns {string}
4211
+ */
4212
+ export function contentText(content) {
4213
+ if (typeof content === 'string') return content;
4214
+ if (!Array.isArray(content)) return '';
4215
+ const parts = [];
4216
+ for (const block of content) {
4217
+ if (block !== null && typeof block === 'object' && !Array.isArray(block)
4218
+ && block.type === 'text') {
4219
+ parts.push(pyStr(block.text || ''));
4220
+ }
4221
+ }
4222
+ return parts.join('');
4223
+ }
4224
+
4225
+ /**
4226
+ * 把 dsh 会话事件帧翻译成平台既有轮次日志行(`dsh_event_to_log_line`,runner.py:2193);
4227
+ * 无需落盘的事件返回 `null`(照 Python `None`,**不是**空串——见差异㊸)。
4228
+ *
4229
+ * 复用 `read_dialogue` 已识别的三种行形态,因此「修复过程」界面零改动:
4230
+ * - assistant/message → `{"role":"assistant","content":<文本>}`
4231
+ * - tool/call → `{"role":"assistant","content":"","tool_calls":[{function:{name,arguments}}]}`
4232
+ * - tool/result → `{"role":"tool","tool_call_id":<callId>,"content":<文本>}`
4233
+ * 其余事件(turn/step 边界、request/*、审批审计等)落到 `### ` 前缀的平台标记行,
4234
+ * 对用户可读但不参与对话渲染。
4235
+ *
4236
+ * @param {object} frame SSE 帧 `{seq, time, type, session_id, data}`
4237
+ * @returns {string|null} 行文本(含结尾换行)或 `null`
4238
+ */
4239
+ export function dshEventToLogLine(frame) {
4240
+ const ftype = pyGet(frame, 'type', null);
4241
+ const data = pyGet(frame, 'data', null) || {};
4242
+ if (ftype === 'assistant/message') {
4243
+ const msg = pyGet(data, 'message', null) || {};
4244
+ const text = contentText(pyGet(msg, 'content', null));
4245
+ if (!text) return null; // 只请求工具、无文本的 assistant 事件:由 tool/call 行承载
4246
+ const marker = pyGet(data, 'interrupted', null) ? '(本轮被中断,以下为已交付内容)' : '';
4247
+ return pyJsonDumps({ role: 'assistant', content: marker + text, tool_calls: [] }) + '\n';
4248
+ }
4249
+ if (ftype === 'tool/call') {
4250
+ return pyJsonDumps({
4251
+ role: 'assistant',
4252
+ content: '',
4253
+ tool_calls: [{
4254
+ function: {
4255
+ name: pyStr(pyGet(data, 'name', null) || ''),
4256
+ arguments: pyStr(pyGet(data, 'arguments', null) || ''),
4257
+ },
4258
+ }],
4259
+ }) + '\n';
4260
+ }
4261
+ if (ftype === 'tool/result') {
4262
+ const msg = pyGet(data, 'message', null) || {};
4263
+ let text = contentText(pyGet(msg, 'content', null));
4264
+ const err = pyGet(data, 'error', null) || {};
4265
+ if (rag.pyTruthy(err)) {
4266
+ text = (text ? `${text}\n` : '') + `[工具错误 ${pyGet(err, 'name', '')}: `
4267
+ + `${pyGet(err, 'code', '')} ${pyGet(err, 'reason', '')}]`;
4268
+ }
4269
+ return pyJsonDumps({
4270
+ role: 'tool',
4271
+ tool_call_id: pyStr(pyGet(msg, 'toolCallId', null) || ''),
4272
+ content: text,
4273
+ }) + '\n';
4274
+ }
4275
+ if (ftype === 'turn/end') {
4276
+ return null; // 结束行由 waitTurnEndDsh 统一写(含 reason)
4277
+ }
4278
+ if (ftype === 'driver/interaction') {
4279
+ return '### 会话等待人工输入(提问/审批已推给用户)\n';
4280
+ }
4281
+ if (ftype === 'user/message') {
4282
+ return null; // 平台 prompt 已由 ### PROMPT 行记录,避免重复
4283
+ }
4284
+ if (ftype === 'agent/status') {
4285
+ return null;
4286
+ }
4287
+ return null;
4288
+ }
4289
+
4290
+ /**
4291
+ * SSE 订阅任务体:把会话事件翻译落日志并喂给 `TurnWaiter`(失败即唤醒等待者)
4292
+ * (`_dsh_stream_worker`,runner.py:2239)。
4293
+ *
4294
+ * 轮次主线程只等 waiter 的 turn/end,不做任何远端轮询——这是方案 §3.3「事件驱动替代轮询」
4295
+ * 在任务侧的落点。
4296
+ *
4297
+ * **停止信号在 `feedInto` 边界统一成函数**(照 `chat.js` 的既有形态):调用方给它
4298
+ * `{aborted:boolean}` 对象(照 Python `threading.Event` 的 `is_set()` 语义,
4299
+ * `runRoundDshplugin` 里 `stream_stop.set()` ↔ `streamStop.aborted = true`)或直接给函数,
4300
+ * 这里归一成 `() => boolean` 再传给驱动。**为什么不把 `{aborted}` 对象直接交给 `feedInto`**:
4301
+ * `dshdriver.js:consumeEvents` 会把「带 `aborted` 布尔字段的对象」当成 **AbortSignal** 塞给
4302
+ * `fetch`(`opts.signal = stop`),普通对象没有 `addEventListener` ⇒ 实测
4303
+ * `### 事件流异常: signal.addEventListener is not a function`,整轮被判超时失败
4304
+ * (见 R3 记录「真问题」第 1 条;修 `dshdriver.js` 不在 R3 写作用域内)。
4305
+ *
4306
+ * **`null` 行不落盘**(见差异㊵):Python 是 `append_log(log_path, None)` → `None.encode` 抛
4307
+ * AttributeError → 被 `feed_into.handle` 吞掉、**一个字节都不写**;Node 的 `appendLog` 走
4308
+ * `String(text)` 会把 `null` 写成字面量 `"null"` ⇒ 这里显式跳过,字节级同效。
4309
+ *
4310
+ * @param {string} sid
4311
+ * @param {number} since
4312
+ * @param {TurnWaiter} waiter
4313
+ * @param {string} logPath
4314
+ * @param {(() => any)|{aborted: boolean}|AbortSignal|null} stop
4315
+ * @param {((frame: object) => void)|null} [onFrame] Node 追加的可选附加处理(Python 无)
4316
+ * @returns {Promise<{frames:number,lastSeq:number,stopped:boolean}|null>}
4317
+ */
4318
+ export function dshStreamWorker(sid, since, waiter, logPath, stop, onFrame = null) {
4319
+ const probe = typeof stop === 'function'
4320
+ ? () => Boolean(stop())
4321
+ : () => Boolean(stop !== null && stop !== undefined && stop.aborted === true);
4322
+ return feedInto(sid, since, waiter, probe, (frame) => {
4323
+ const line = dshEventToLogLine(frame);
4324
+ if (line !== null) appendLog(logPath, line);
4325
+ if (typeof onFrame === 'function') onFrame(frame);
4326
+ });
4327
+ }
4328
+
4329
+ /**
4330
+ * 从日志尾部解析 `session.resume_hint` 的 session_id(`parse_session_hint`,runner.py:2250;
4331
+ * 只认最后的 meta 行)。无命中返回 `null`(照 Python `None`)。
4332
+ *
4333
+ * @param {string} logPath
4334
+ * @returns {string|null}
4335
+ */
4336
+ export function parseSessionHint(logPath) {
4337
+ const text = readLogTail(logPath, 200);
4338
+ const found = [...text.matchAll(new RegExp(SESSION_HINT_RE.source, 'g'))];
4339
+ return found.length ? found[found.length - 1][1] : null;
4340
+ }
4341
+
4342
+ /**
4343
+ * 对话条目的单行摘要(前端折叠态/列表用),避免渲染超长工具结果
4344
+ * (`summary_entry_text`,runner.py:2327)。
4345
+ *
4346
+ * `entry["kind"]` 是**下标取键**(缺键抛 `KeyError`,消息是键的 repr ⇒ Node 侧 `pyGetItem`
4347
+ * 同形,见差异㊶);`user`/`meta` 不截断,`assistant`/`tool_result` 截到
4348
+ * `DIALOGUE_RAW_LIMIT`(**码点**口径,见差异㉔);未知 kind 返回空串。
4349
+ *
4350
+ * @param {object} entry
4351
+ * @returns {string}
4352
+ */
4353
+ export function summaryEntryText(entry) {
4354
+ const kind = pyGetItem(entry, 'kind');
4355
+ if (kind === 'user') return pyGet(entry, 'text', '');
4356
+ if (kind === 'assistant') return headText(pyGet(entry, 'text', ''), DIALOGUE_RAW_LIMIT);
4357
+ if (kind === 'tool_result') return headText(pyGet(entry, 'text', ''), DIALOGUE_RAW_LIMIT);
4358
+ if (kind === 'meta') return pyGet(entry, 'text', '');
4359
+ return '';
4360
+ }
4361
+
4362
+ /** Python `s[:limit]` 的**码点**口径(非 str 抛 `TypeError`,照 Python 的下标语义;差异㉔) */
4363
+ function headText(value, limit) {
4364
+ if (typeof value !== 'string') {
4365
+ throw new TypeError(`'${pyTypeName(value)}' object is not subscriptable`);
4366
+ }
4367
+ return [...value].slice(0, limit).join('');
4368
+ }
4369
+
4370
+ /**
4371
+ * 读 `<工作目录>/.live/live.json` 的核心字段作为轮次摘要(JSON 字符串)
4372
+ * (`read_live_summary`,runner.py:2361)。
4373
+ *
4374
+ * 缺文件/读不动(Python `OSError`)或坏 JSON(Python `ValueError`)⇒ 空串;顶层非对象
4375
+ * (Python `live.get` 的 `AttributeError`,**不在** except 元组里)⇒ 外抛(见差异㊶)。
4376
+ * 四个键的**顺序**照 Python 的 dict 字面量(`pyJsonDumps` 保序)⇒ 与 `json.dumps` 逐字节同。
4377
+ *
4378
+ * @param {string} workDir
4379
+ * @returns {string}
4380
+ */
4381
+ export function readLiveSummary(workDir) {
4382
+ const file = pyJoin(runtimeDir(workDir, '.live'), 'live.json');
4383
+ let raw;
4384
+ try {
4385
+ raw = fs.readFileSync(file, 'utf8');
4386
+ } catch {
4387
+ return ''; // OSError(不存在/不可读/是目录)
4388
+ }
4389
+ let live;
4390
+ try {
4391
+ live = JSON.parse(raw);
4392
+ } catch {
4393
+ return ''; // ValueError(json.JSONDecodeError)
4394
+ }
4395
+ if (live === null || typeof live !== 'object' || Array.isArray(live)) {
4396
+ throw new AttributeError(`'${pyTypeName(live)}' object has no attribute 'get'`);
4397
+ }
4398
+ return pyJsonDumps({
4399
+ phase: pyGet(live, 'phase', ''),
4400
+ round: pyGet(live, 'round', null),
4401
+ stats: pyGet(live, 'stats', {}),
4402
+ stop_condition: pyGet(live, 'stop_condition', {}),
4403
+ });
4404
+ }
4405
+
4406
+
4407
+ // ══════════════════════════════════════════════════════════════════════════
4408
+
4409
+ /**
4410
+ * `startUnitSelfcheck` 幂等守卫(`_SELFCHECK_STARTED`):生产路径(`stop=None`)重复启动直接
4411
+ * return;注入 stop 的单测路径不记账(每例自带终止信号)。
4412
+ */
4413
+ let SELFCHECK_STARTED = false;
4414
+
4415
+ /**
4416
+ * 注册行判活探针(`set_liveness_probe`,runner.py:2385;board 注册,v3c 行口径):仅在
4417
+ * `reconcileUnits` / `startUnitSelfcheck` 的锁外上下文调用,允许慢 REST(web busy)。
4418
+ *
4419
+ * 照 Python 措辞:`INSTANCE` 为 None 时直接 return(单例未构造 = 无处可注册)。
4420
+ *
4421
+ * @param {((row: object) => string|null)|null} fn 探针(返回 `alive`/`dead`/`unknown`/null)
4422
+ */
4423
+ export function setLivenessProbe(fn) {
4424
+ if (INSTANCE !== null) INSTANCE._liveness_probe = fn;
4425
+ }
4426
+
4427
+ /**
4428
+ * 启动对账收尾(`reconcile_units`,runner.py:2392;顺序硬约束:`runner.recover` →
4429
+ * `board.recover` 之后):按活跃等待项行裁活(证据链见 `waitq.unitVerdict`——web busy 三态
4430
+ * 探针 / 任务与消息行状态 / 卡行存在性 / 行 evidence 的 pid 存活);可证死 → 行收口
4431
+ * (标签「启动对账:可证明失效」),活/未知保留。处置清单逐条打日志 + 一行汇总
4432
+ * (活跃行数为 0 时也落一行:稳定的「对账已跑」证据)。**有收口必唤醒执行循环**。
4433
+ *
4434
+ * `INSTANCE` 为 None 时直接 return(照 Python)。`options` 缺省取 INSTANCE 的实例级选项袋。
4435
+ *
4436
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}|null} [options]
4437
+ */
4438
+ export function reconcileUnits(options = null) {
4439
+ const inst = INSTANCE;
4440
+ if (inst === null) return;
4441
+ const opts = inst.opt(options);
4442
+ const probe = inst._liveness_probe === undefined ? null : inst._liveness_probe;
4443
+ let nRows = 0;
4444
+ let nClosed = 0;
4445
+ for (const d of waitq.reconcileUnits(probe, opts)) {
4446
+ nRows += 1;
4447
+ if (d.closed) nClosed += 1;
4448
+ console.log(`[waitq] 对账 ${d.key}(项目 ${d.project_id}):${d.verdict}`
4449
+ + (d.closed ? ',已收口' : ''));
4450
+ }
4451
+ console.log(`[waitq] 对账完成:活跃行 ${nRows} 条,收口 ${nClosed} 条`);
4452
+ if (nClosed) inst._cond.notifyAll();
4453
+ }
4454
+
4455
+ /**
4456
+ * 等待项周期自检循环(`start_unit_selfcheck`,runner.py:2425):**仅可证明失效**自动收口,
4457
+ * 其余告警;健康时静默。收口豁免:每拍注入 `BOARD_UNIT_MANAGED`(`board.unit_managed`)作
4458
+ * `managed` 判据——平台在管单元的行收尾归巡视,自检不越权。周期尾部顺带
4459
+ * `waitq.pruneFinished()` 回收终态行;`refreshExtRows` 钩子(未接线跳过)做外部条目周期对账。
4460
+ *
4461
+ * 形态(见文件头差异①②④):Python 是 daemon 线程 + `_SELFCHECK_STARTED` 幂等守卫;Node 用
4462
+ * **递归 `setTimeout` + `unref()`**(首轮宽限 = 先等一个周期再首查;`unref` 保证进程不因此
4463
+ * 不退出),拍与拍之间**不重叠**(下一拍在本拍体跑完后才排)。
4464
+ *
4465
+ * `stop` 支持两种形态(缺省 `null` = 生产路径,走幂等守卫):
4466
+ * - **AbortSignal**:`signal.aborted` 或 `abort` 事件即终止(推荐,单测用
4467
+ * `AbortSignal.timeout(ms)` 或自建 controller);
4468
+ * - **`{promise, cancel}`**:`promise` resolve 即终止(`cancel` 只在句柄上,形态自由)。
4469
+ * 传了 `stop` 就越过幂等守卫(同一进程可起多个受控实例,供单测)。
4470
+ *
4471
+ * `INSTANCE` 为 None 时直接 return(`null` 句柄);循环内每拍重读 INSTANCE,拆离即自终。
4472
+ *
4473
+ * @param {number} [interval] 周期(秒;`SELFCHECK_INTERVAL`)
4474
+ * @param {number} [minAge] 行年龄豁免(秒;`SELFCHECK_MIN_AGE`)
4475
+ * @param {AbortSignal|{promise?: Promise<any>, cancel?: Function}|null} [stop]
4476
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}|null} [options]
4477
+ * @returns {{cancel: Function, done: Promise<void>}|null} 句柄(Node 追加:测试可确定性收尾)
4478
+ */
4479
+ export function startUnitSelfcheck(interval = SELFCHECK_INTERVAL, minAge = SELFCHECK_MIN_AGE,
4480
+ stop = null, options = null) {
4481
+ if (INSTANCE === null) return null;
4482
+ if (stop === null || stop === undefined) {
4483
+ if (SELFCHECK_STARTED) return null;
4484
+ SELFCHECK_STARTED = true;
4485
+ }
4486
+ const intervalMs = Math.max(0, Number(interval) || 0) * 1000;
4487
+ let stopped = false;
4488
+ let timer = null;
4489
+ let finishLoop = null;
4490
+ const done = new Promise((resolve) => { finishLoop = resolve; });
4491
+ const onStop = () => {
4492
+ if (stopped) return;
4493
+ stopped = true;
4494
+ if (timer !== null) {
4495
+ clearTimeout(timer);
4496
+ timer = null;
4497
+ }
4498
+ finishLoop();
4499
+ };
4500
+ if (stop && typeof stop.addEventListener === 'function') { // AbortSignal
4501
+ if (stop.aborted) onStop();
4502
+ else stop.addEventListener('abort', onStop, { once: true });
4503
+ } else if (stop && typeof stop.then === 'function') { // Promise
4504
+ stop.then(onStop, onStop);
4505
+ } else if (stop && stop.promise && typeof stop.promise.then === 'function') {
4506
+ stop.promise.then(onStop, onStop);
4507
+ }
4508
+ const schedule = () => {
4509
+ if (stopped) return;
4510
+ timer = setTimeout(tick, intervalMs);
4511
+ if (typeof timer.unref === 'function') timer.unref(); // 见文件头差异④
4512
+ };
4513
+ const tick = async () => {
4514
+ timer = null;
4515
+ try {
4516
+ const inst = INSTANCE;
4517
+ if (inst === null) { // 单例已拆(测试还原):循环自终
4518
+ onStop();
4519
+ return;
4520
+ }
4521
+ const opts = inst.opt(options);
4522
+ const probe = inst._liveness_probe === undefined ? null : inst._liveness_probe;
4523
+ // managed:平台在管(_RUNS)单元的收口豁免判据(未接线传 null = 无豁免)
4524
+ const lines = waitq.selfcheckUnits(probe, minAge, BOARD_UNIT_MANAGED, opts);
4525
+ for (const line of lines) console.log(`[waitq-selfcheck] ${line}`);
4526
+ if (lines.some((l) => l.startsWith('自检收口可证死行'))) {
4527
+ // 自检收口的串行位立即唤醒执行循环补位(等下个外部事件会让排队单元无谓停滞)
4528
+ inst._cond.notifyAll();
4529
+ }
4530
+ if (BOARD_REFRESH_EXT_ROWS !== null) {
4531
+ try {
4532
+ await BOARD_REFRESH_EXT_ROWS('外部条目周期对账');
4533
+ } catch (e) { // 同 Python 的兜底口径:只打一行,不打断自检
4534
+ console.log(`[waitq-selfcheck] 外部条目对账异常: ${errText(e)}`);
4535
+ }
4536
+ }
4537
+ const n = waitq.pruneFinished(undefined, opts); // 周期尾部:终态等待项回收
4538
+ if (n) console.log(`[waitq-selfcheck] 终态等待项回收 ${n} 条`);
4539
+ } catch (e) {
4540
+ console.log(`[waitq-selfcheck] 异常: ${errText(e)}`); // 循环不 crash
4541
+ }
4542
+ schedule();
4543
+ };
4544
+ schedule();
4545
+ return { cancel: onStop, done };
4546
+ }