@gavinc-cn/touchstone-dsh 0.2.0 → 0.3.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 (111) 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 +6154 -0
  10. package/backend/lib/builtin-assets.js +1177 -0
  11. package/backend/lib/casescan.js +456 -0
  12. package/backend/lib/chat.js +2053 -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 +1738 -0
  19. package/backend/lib/feishu_cfg.js +228 -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 +1674 -0
  23. package/backend/lib/feishu_provision.js +855 -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 +405 -0
  33. package/backend/lib/proc.js +278 -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 +4520 -0
  40. package/backend/lib/servercaps.js +182 -0
  41. package/backend/lib/serverstate.js +237 -0
  42. package/backend/lib/sessparse.js +1176 -0
  43. package/backend/lib/sse.js +207 -0
  44. package/backend/lib/static.js +84 -0
  45. package/backend/lib/taskjson.js +302 -0
  46. package/backend/lib/waitq.js +2114 -0
  47. package/backend/lib/worktree.js +588 -0
  48. package/backend/lib/zstd-frames.js +218 -0
  49. package/backend/routes/admin.js +553 -0
  50. package/backend/routes/agents.js +149 -0
  51. package/backend/routes/assets.js +145 -0
  52. package/backend/routes/board.js +1220 -0
  53. package/backend/routes/feishu.js +269 -0
  54. package/backend/routes/feishu_app.js +215 -0
  55. package/backend/routes/feishu_hook.js +358 -0
  56. package/backend/routes/index.js +297 -0
  57. package/backend/routes/projects.js +1059 -0
  58. package/backend/routes/sessions.js +946 -0
  59. package/backend/routes/tasks.js +1530 -0
  60. package/backend/schema/fixtures/ancient.sql +189 -0
  61. package/backend/schema/fixtures/pre_alter.sql +177 -0
  62. package/backend/schema/migrations.json +154 -0
  63. package/backend/schema/schema.json +1451 -0
  64. package/backend/schema/schema.sql +216 -0
  65. package/backend/server.js +498 -0
  66. package/board.py +30 -1
  67. package/builtin_assets.py +0 -0
  68. package/builtin_prompts/free_style/flow.md +0 -0
  69. package/builtin_prompts/free_style/load_test.md +0 -0
  70. package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
  71. package/builtin_prompts/free_style/templates/case_template.md +0 -0
  72. package/builtin_prompts/free_style/templates/index_template.md +0 -0
  73. package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
  74. package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
  75. package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
  76. package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
  77. package/builtin_prompts/free_style/templates/status_template.md +0 -0
  78. package/db.py +294 -129
  79. package/dsh-plugin/cordis.patch.yml +0 -0
  80. package/dsh-plugin/install.sh +22 -8
  81. package/dsh-plugin/lib/client.bundle.js +0 -0
  82. package/dsh-plugin/lib/index.js +299 -29
  83. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  84. package/dsh-plugin/scripts/build.mjs +0 -0
  85. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  86. package/dsh-plugin/scripts/select-bundle.mjs +0 -0
  87. package/dsh-plugin/src/client.js +0 -0
  88. package/dshevents.py +0 -0
  89. package/export_cases.py +0 -0
  90. package/extensions/dsh-autocommit/asset.json +0 -0
  91. package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
  92. package/extensions/dsh-autocommit/pkg/package.json +0 -0
  93. package/feishu_conv.py +0 -0
  94. package/lib.py +0 -0
  95. package/lifecycle.py +116 -13
  96. package/loadcase.py +0 -0
  97. package/loadgen.py +0 -0
  98. package/localbus.py +0 -0
  99. package/migrate_p7b.py +0 -0
  100. package/package.json +6 -1
  101. package/platcompat.py +0 -0
  102. package/prompts.py +0 -0
  103. package/rag.py +0 -0
  104. package/requirements.txt +0 -0
  105. package/touchstone.py +0 -0
  106. package/waitq.py +0 -0
  107. package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
  108. package/webui/dist-plugin/assets/{index-JLwyZZxC.js → index-UrMyeAXF.js} +110 -110
  109. package/webui/dist-plugin/index.html +2 -2
  110. package/worktree.py +0 -0
  111. package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
@@ -0,0 +1,2114 @@
1
+ /**
2
+ * 统一等待项核心(`waitq.py` 1342 行的 Node 对应物)——**读口 + 写口全量**。
3
+ *
4
+ * M3 搬读口(成员键 / 读口 / 位次 / 消息读口);**M4 补写口**:入队与分数序插入(前缀后
5
+ * / 消息落点 / 拖拽改序)、认领与起跑证实(`claim` / `markRunning` / `enterRunning`)、
6
+ * 状态机迁移(`markFinishing` / 终态 / 放回 / 取消)、行证据与心跳(`touchUnit` /
7
+ * `markEvidence` / `evidencePid`)、对账与自检(`selfcheck` / `reconcileUnits` /
8
+ * `selfcheckUnits` / `pruneFinished`)、卡片四操作(`enqueueCard` /
9
+ * `insertCardAfterPrefix` / `insertCardForceStart` / `cancelCardWait` /
10
+ * `setCardWaitPlaceholder`)、外部条目(`insertExt`)与消息六态迁移(`msgEnqueue` /
11
+ * `msgClaim` / `msgRequeue` / `msgFinish` / `msgCancel` / `msgRecoverFail` / `msgPrune`)。
12
+ * 函数排布**照 `waitq.py` 原始顺序**:SQL 文本逐字照抄、判据逐条对齐、rowcount 守卫与
13
+ * 返回类型(行 id / `[row_id, seq]` / 行对象 / bool / list)逐点一致。
14
+ *
15
+ * 排队模型的权威表述在 `doc_ai/spec/queue/排队与占用.md` 与 `waitq.py` 模块头(行即条目、
16
+ * 两表唯一写路径、v3 各批裁决 R1/R3/R4/R6/R7/R11/R13/R14 等)。
17
+ *
18
+ * ── 与 Python 的形态差异(唯一必要的差异,语义等价) ─────────────────────────────
19
+ * ① **连接取自 `options`**:Python `db.connect()` 是「每调用一条短连接」;Node 侧统一为
20
+ * `fn(业务参数…, {db?|dbPath?|env?})`——读口走 `withReadDb`、写口走 `withWriteDb`
21
+ * (事务边界见 ⑤)。传 `options.db`(已打开句柄)则复用且不关,缺省自开短连接并
22
+ * close(口径与 `db.js:withReadDb` 逐点同)。`memberKey` / `evidencePid` 这类不碰
23
+ * DB 的纯函数不收 `options`(与 Python 同形:它们只吃入参)。
24
+ * ② **行对象是普通对象**(`node:sqlite` 的 null 原型行已在这里摊平成普通对象):
25
+ * `row.state` 与 `row['state']` 等价,字段名保持 snake_case(跨语言契约)。
26
+ * ③ **`int(...)` 的 ValueError 分支**:Python `answer_pending_card_ids()` 用
27
+ * `int(r[0])`,target_id 非数字会**抛 ValueError**(不是跳过)。Node 侧
28
+ * `Number.parseInt('abc', 10)` 给 NaN,**不抛**;故显式补一个 NaN 判据跳过该行。
29
+ * 这是有意的宽松化:本函数只服务展示派生(N+1 消除),一行坏数据不该让整块看板
30
+ * 500;Python 侧真撞上会 500 且同样不可能给出正确答案。见 `answerPendingCardIds`。
31
+ * ④ SQL 里的活跃态 IN 片段由 `ACTIVE_STATES` 单点生成(`_ACTIVE_IN`),与 Python
32
+ * 同源同形——改状态机时两版一起改。
33
+ * ⑤ **事务边界**:Python `with db.connect() as conn:`(短连接 + 成功 commit / 异常
34
+ * rollback)→ Node `withWriteDb(options, fn)`(缺省自开短连接并包 `BEGIN`…`COMMIT`,
35
+ * 异常 `ROLLBACK` + 重抛;给 `{db}` 则**复用且不开事务**,边界归调用方)。块内复用
36
+ * 连接的助手一律 `xxxOn(handle, …)`;**严禁嵌套 `withWriteDb`**。差异只在加锁时刻:
37
+ * Python 的隐式事务在首条 DML 才 BEGIN,Node 在块首就 BEGIN(DEFERRED,纯 SELECT
38
+ * 只取 SHARED)——进程内单事件循环 + 同库单实例锁之下两者等价,见
39
+ * `db.js:withWriteDb` 的长注释。
40
+ * ⑥ **rowcount 守卫**:Python `cur.rowcount == 1` → Node `Number(info.changes) === 1`
41
+ * (`node:sqlite` 的 `run()` 返回 `{changes, lastInsertRowid}`)。
42
+ * ⑦ **IntegrityError 分支**:Python `except sqlite3.IntegrityError` 是**整个约束族**
43
+ * (唯一索引 2067 / NOT NULL 1299 都算)→ Node 判 `err.code === 'ERR_SQLITE_ERROR'`
44
+ * 且 `(err.errcode & 0xff) === 19`(`SQLITE_CONSTRAINT`=19)。重查兜底不存在时
45
+ * **重抛原错**(照 Python 的裸 `raise`)。
46
+ * ⑧ **异常面**:Python `raise ValueError` → Node `TypeError`(未知等待项 kind,消息含
47
+ * 「未知等待项」)/ `RangeError`(`reposition` 缺邻行、区间倒置)。JS 没有
48
+ * `ValueError`;对拍器(`tools/parity/waitq-run.mjs:errorToken`)把两者都归
49
+ * `value_error` 桶。
50
+ * ⑨ **JSON**:写侧一律 `pyJsonDumps`(= `json.dumps(..., ensure_ascii=False)` 逐字节、
51
+ * 键序=插入序;`db.js` 既有设施)。读侧(meta/evidence 合入前的解析)用 `JSON.parse`
52
+ * 并在失败时降级成 `{}`——`db.js` 的 `pyJsonLoads` **未导出**,故此处与 Python 有一
53
+ * 处已知差异:`NaN`/`Infinity` 字面量在 Python `json.loads` 合法、Node 侧按坏 JSON
54
+ * 降级。平台自身只往这两列写字符串/整数(`pyJsonDumps` 仅对非有限浮点吐这两种
55
+ * 字面量),该形态在本模块的 meta/evidence 上不可达;真出现时对拍会现形。
56
+ * ⑩ **Unicode 面**:Python `target.isdigit()` 认全角数字且 `int()` 能解析,Node 侧只认
57
+ * ASCII `^[0-9]+$`(其余按「引用不可解析」处理);Python `str(None)` 给 `'None'` 而
58
+ * JS `String(null)` 给 `'null'`。两者都只影响平台写不出的形态(target_id 一律由
59
+ * `str(int)` / 平台 id 拼写),本文件统一 `String(...)` + ASCII 数字,写成有意差异。
60
+ * ⑪ **心跳 / 毫秒戳**:Python `time.time()` → `Date.now() / 1000`(epoch 秒浮点);
61
+ * Python `int(time.time() * 1000)` → `Date.now()`(整数毫秒)。
62
+ * ⑫ **按码点截断**:Python `error[:300]` 按字符(码点)截断 → Node 用
63
+ * `Array.from(str).slice(0, 300).join('')`;`.slice(0, 300)` 按 UTF-16 码元会把 emoji
64
+ * 劈成半个代理对(`msgFinish` 的 300 字上限,见 `truncateChars`)。
65
+ * ⑬ **日志**:Python `print(..., flush=True)` → Node `console.log`(同 stdout 一行)。
66
+ * ⑭ **常量导出**:卡片排队占位文案 `_CARD_QUEUE_TEXT` → 导出 `CARD_QUEUE_TEXT`(M5 的
67
+ * board 写路径与单测要逐字比对这条中文串;Python 侧带下划线但同样被测试引用)。
68
+ * ⑮ **参数形态**:写口一律**按 `waitq.py` 的位置顺序平铺**,末位再加 `options`(对拍器
69
+ * 按位置传参,对象袋会错位)。`msgRows` 的对象袋是 M3 既有读口形态,未动。
70
+ * ⑯ **`position` 的返回形态**:M3 读口返回 `{pos, total}`,Python 是元组 `(pos, total)`
71
+ * ——对拍器把元组摊成 `[pos, total]`,故该 op 会现一条**已声明的形态差异**(读口按
72
+ * M3 契约不动,M4 未改)。
73
+ *
74
+ * 零 npm 依赖:只用 `node:` 内建模块 + 本仓库内模块(`db.js` 的连接契约、`pyjson.js`、
75
+ * `proc.js:pidAlive`)。
76
+ */
77
+
78
+ import { getBoardCard, getTask, nowStr, withReadDb, withWriteDb } from './db.js';
79
+ import { pidAlive } from './proc.js';
80
+ import { pyJsonDumps } from './pyjson.js';
81
+
82
+ // --------------------------------------------------------------------------
83
+ // 常量(逐条对齐 waitq.py:37-67;**改这里必须同时改 waitq.py**)
84
+ // --------------------------------------------------------------------------
85
+
86
+ /** 等待项五类 kind(v2d T4 加 ext:外部直跑会话,不由平台启动) */
87
+ export const KIND_TASK = 'task';
88
+ export const KIND_CARD = 'card';
89
+ export const KIND_MSG = 'msg';
90
+ export const KIND_ANSWER = 'answer';
91
+ export const KIND_EXT = 'ext';
92
+ export const KINDS = Object.freeze([KIND_TASK, KIND_CARD, KIND_MSG, KIND_ANSWER, KIND_EXT]);
93
+
94
+ /**
95
+ * 状态机七枚举(v2a T1,裁决 R3):claimed 被 starting 吸收。
96
+ * 注意 `STATE_*` 是**别名**(存量引用面),不是第二套取值——与 Python 同(waitq.py:55-57)。
97
+ */
98
+ export const WAITING = 'waiting';
99
+ export const STARTING = 'starting';
100
+ export const RUNNING = 'running';
101
+ export const FINISHING = 'finishing';
102
+ export const DONE = 'done';
103
+ export const FAILED = 'failed';
104
+ export const CANCELLED = 'cancelled';
105
+
106
+ /** 别名(照 waitq.py:55-57;`claimed` 无别名——唯一引用点已切到 STATE_STARTING) */
107
+ export const STATE_WAITING = WAITING;
108
+ export const STATE_STARTING = STARTING;
109
+ export const STATE_RUNNING = RUNNING;
110
+ export const STATE_FINISHING = FINISHING;
111
+ export const STATE_DONE = DONE;
112
+ export const STATE_FAILED = FAILED;
113
+ export const STATE_CANCELLED = CANCELLED;
114
+
115
+ /** 活跃态(活跃唯一索引 / active_items 扫描口径) */
116
+ export const ACTIVE_STATES = Object.freeze([WAITING, STARTING, RUNNING, FINISHING]);
117
+ /** 调度口径运行前缀(位次口径另含 FINISHING,见裁决 R7) */
118
+ export const PREFIX_STATES = Object.freeze([STARTING, RUNNING]);
119
+
120
+ /**
121
+ * 成员键前缀(行即成员;v3a 起前缀/位次/窗口的键空间)。拾取侧 `runner._KEY_PREFIX`
122
+ * 有意不含 ext——外部条目永不被平台拾取;本表含 ext 行(外部条目入场即 running,
123
+ * 是运行前缀的正式成员)。
124
+ */
125
+ const MEMBER_PREFIX = Object.freeze({
126
+ [KIND_TASK]: 't', [KIND_CARD]: 'c', [KIND_MSG]: 'm',
127
+ [KIND_ANSWER]: 'a', [KIND_EXT]: 'ext',
128
+ });
129
+
130
+ /** SQL IN 片段(唯一出处,照 waitq.py:59 的 `_ACTIVE_IN`) */
131
+ const ACTIVE_IN = ACTIVE_STATES.map((s) => `'${s}'`).join(',');
132
+
133
+ /**
134
+ * `return_to_waiting` 可放回态 = 活跃非 waiting(照 waitq.py:61 的 `_RETURNABLE_IN`;
135
+ * starting/running/finishing 三态同型放回)。
136
+ */
137
+ const RETURNABLE_IN = ACTIVE_STATES.filter((s) => s !== WAITING)
138
+ .map((s) => `'${s}'`).join(',');
139
+
140
+ // --------------------------------------------------------------------------
141
+ // M4 写路径常量(逐条对齐 waitq.py:240-241、704、947、971;**改这里必须同时改 waitq.py**)
142
+ // --------------------------------------------------------------------------
143
+
144
+ /**
145
+ * 分数序插入的前缀态 SQL IN 片段(照 waitq.py:241 的 `_PREFIX_IN`)。
146
+ *
147
+ * seq 为 REAL 分数序(SQLite INTEGER 亲和列原样存小数,无需 DDL 迁移):落尾仍
148
+ * `MAX(seq)+1`(现行语义不变),中间插入取相邻中点;间隙 < 阈值先对项目区间行等距重排
149
+ * (仅 UPDATE seq,行 id 稳定——沿用 P3 inject 裁决 R4 口径)。
150
+ */
151
+ const PREFIX_IN = PREFIX_STATES.map((s) => `'${s}'`).join(',');
152
+
153
+ /** 间隙阈值:低于此值先对区间行等距重排(仅 UPDATE seq)再插入(waitq.py:240) */
154
+ const SEQ_REBALANCE_EPS = 1e-9;
155
+
156
+ /**
157
+ * 活跃态超龄阈值(秒):超过且无可证明证据 = 可疑泄漏(waitq.py:704)。
158
+ * v2d T3 起判据覆盖 starting/running/finishing 三态。
159
+ */
160
+ export const CLAIM_STALE_S = 1800;
161
+
162
+ /** `wait_items` 终态行回收默认保留窗(秒;R14 口径保守,waitq.py:947) */
163
+ export const PRUNE_FINISHED_KEEP_S = 86400;
164
+
165
+ /**
166
+ * 卡片排队占位文案(照 waitq.py:971 的 `_CARD_QUEUE_TEXT`,与 `board._enter_doing`
167
+ * 原文案逐字一致)。前端与对拍都比字节,**一个字符都不许改**。
168
+ */
169
+ export const CARD_QUEUE_TEXT = '排队等待:统一队列';
170
+
171
+ // --------------------------------------------------------------------------
172
+ // 小工具
173
+ // --------------------------------------------------------------------------
174
+
175
+ /**
176
+ * `node:sqlite` 行(null 原型)→ 普通对象;未命中(undefined)归一为 null,
177
+ * 与 Python `fetchone() is None` 同判据(口径同 `db.js:plainRow`)。
178
+ * @param {object|undefined} row
179
+ * @returns {object|null}
180
+ */
181
+ function plainRow(row) {
182
+ return row === undefined ? null : { ...row };
183
+ }
184
+
185
+ /**
186
+ * 同上,批量。
187
+ * @param {object[]} rows
188
+ * @returns {object[]}
189
+ */
190
+ function plainRows(rows) {
191
+ return rows.map((row) => ({ ...row }));
192
+ }
193
+
194
+ /**
195
+ * 参数绑定前把 undefined 归一为 null(node:sqlite 拒绑 undefined;Python 侧等价于 None)。
196
+ * @param {unknown} value
197
+ * @returns {unknown}
198
+ */
199
+ function bindable(value) {
200
+ return value === undefined ? null : value;
201
+ }
202
+
203
+ /**
204
+ * `sqlite3.IntegrityError` 的 Node 对应物(**整个约束族**,不只是唯一索引)。
205
+ *
206
+ * Python `except sqlite3.IntegrityError` 也吃 NOT NULL(1299)等约束错——`enqueue` /
207
+ * `insert_after_prefix` / `enter_running` / `insert_ext` / 卡片入队都用同一 idiom:
208
+ * 「撞约束 → 重查活跃行 → 有则复用、没有则裸 `raise`」。故这里的判据必须是
209
+ * `SQLITE_CONSTRAINT`(主码 19)一族,而不是只认 2067。
210
+ *
211
+ * @param {unknown} err
212
+ * @returns {boolean}
213
+ */
214
+ function isIntegrityError(err) {
215
+ return Boolean(err) && err.code === 'ERR_SQLITE_ERROR' && (Number(err.errcode) & 0xff) === 19;
216
+ }
217
+
218
+ /**
219
+ * Python 字典/字符串/列表的**真值**判据(空 dict / 空串 / 空数组为假)。
220
+ *
221
+ * 用在照抄 `if meta:` / `if not patch:` 的地方:JS 里 `{}` 与 `[]` 都是 truthy,直接写
222
+ * `if (patch)` 会让 Python 侧「空 patch 直接 no-op」的分支跑成「解析 + 重写 JSON」——
223
+ * 列值会从坏 JSON/空串被归一成 `{}`,对拍看得见。
224
+ *
225
+ * @param {unknown} value
226
+ * @returns {boolean}
227
+ */
228
+ function hasEntries(value) {
229
+ if (value === null || value === undefined) return false;
230
+ if (typeof value === 'string') return value.length > 0;
231
+ if (Array.isArray(value)) return value.length > 0;
232
+ if (typeof value === 'object') return Object.keys(value).length > 0;
233
+ return Boolean(value);
234
+ }
235
+
236
+ /**
237
+ * 本地时间字符串(`YYYY-MM-DD HH:MM:SS`),照 Python `time.strftime(fmt, time.localtime(sec))`。
238
+ *
239
+ * 为什么不用 `nowStr()`:那个取「此刻」,而 `pruneFinished` / `selfcheck` 要的是
240
+ * 「此刻 - N 秒」的格式化文本(同库文本时间戳口径,字符串比较即时间序)。
241
+ *
242
+ * @param {number} seconds epoch 秒(可带小数,`Date` 内部按毫秒截断)
243
+ * @returns {string}
244
+ */
245
+ function localTimeStr(seconds) {
246
+ const d = new Date(seconds * 1000);
247
+ const pad = (n) => String(n).padStart(2, '0');
248
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
249
+ + ` ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
250
+ }
251
+
252
+ /**
253
+ * 本地时间字符串 → epoch 秒(照 Python `time.mktime(time.strptime(ts, fmt))`)。
254
+ *
255
+ * 只服务 `unitAge` 的回落分支,调用方把**任何**失败(类型不对/格式不符/NaN)当
256
+ * 「解析失败」处理——与 Python 的 `except (ValueError, TypeError, OverflowError)` 同侧。
257
+ * 月/日/时/分/秒放宽到 1-2 位(Python `%m` 同样接受非补零写法),其余按 `strptime` 的
258
+ * 严格面(整串必须匹配、不许有尾巴)。
259
+ *
260
+ * @param {unknown} text
261
+ * @returns {number} epoch 秒
262
+ * @throws {TypeError|RangeError} 非字符串 / 格式不符
263
+ */
264
+ function parseLocalSeconds(text) {
265
+ if (typeof text !== 'string') {
266
+ throw new TypeError(`时间戳必须是字符串(收到 ${text === null ? 'null' : typeof text})`);
267
+ }
268
+ const m = /^(\d{4})-(\d{1,2})-(\d{1,2}) (\d{1,2}):(\d{1,2}):(\d{1,2})$/.exec(text);
269
+ if (m === null) throw new RangeError(`时间戳格式不符: ${text}`);
270
+ const ms = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]),
271
+ Number(m[4]), Number(m[5]), Number(m[6])).getTime();
272
+ if (Number.isNaN(ms)) throw new RangeError(`时间戳不可解析: ${text}`);
273
+ return ms / 1000;
274
+ }
275
+
276
+ /**
277
+ * 按**码点**截断(照 Python 字符串切片 `s[:n]`)。
278
+ *
279
+ * `.slice(0, n)` 按 UTF-16 码元切,会把 emoji 之类的代理对劈成半个(写库即成坏 UTF-8
280
+ * 形态的孤立代理);`Array.from` 按码点迭代,与 CPython 的 str 语义一致。
281
+ *
282
+ * @param {unknown} text
283
+ * @param {number} n
284
+ * @returns {string}
285
+ */
286
+ function truncateChars(text, n) {
287
+ return Array.from(String(text === null || text === undefined ? '' : text)).slice(0, n).join('');
288
+ }
289
+
290
+ /**
291
+ * ASCII 数字串 → 整数(照 Python `target.isdigit()` + `int(target)` 的可用面);
292
+ * 非纯 ASCII 数字返回 null(= 引用不可解析,见文件头差异⑩)。
293
+ * @param {unknown} target
294
+ * @returns {number|null}
295
+ */
296
+ function targetInt(target) {
297
+ return typeof target === 'string' && /^[0-9]+$/.test(target) ? Number(target) : null;
298
+ }
299
+
300
+ // --------------------------------------------------------------------------
301
+ // 成员键与读口(waitq.py:70-232)
302
+ // --------------------------------------------------------------------------
303
+
304
+ /**
305
+ * 等待项行 → 成员键(`t:`/`c:`/`m:`/`a:`/`ext:` + target_id)。
306
+ *
307
+ * 前缀/位次/窗口的唯一键空间(行即成员,唯一来源 R1;v3d 起无第二来源)。
308
+ * 未知 kind 回落 `kind:target`(照 Python `_MEMBER_PREFIX.get(kind, kind)`)。
309
+ *
310
+ * @param {string} kind
311
+ * @param {string|number} targetId
312
+ * @returns {string}
313
+ */
314
+ export function memberKey(kind, targetId) {
315
+ const prefix = MEMBER_PREFIX[kind] !== undefined ? MEMBER_PREFIX[kind] : kind;
316
+ return `${prefix}:${targetId}`;
317
+ }
318
+
319
+ // --------------------------------------------------------------------------
320
+ // 写口 · meta 合入与入队(waitq.py:78-117)
321
+ // --------------------------------------------------------------------------
322
+
323
+ /**
324
+ * 行内 meta JSON 合入 patch(持连接调用;坏 JSON / 非对象按空对象处理)。
325
+ *
326
+ * 键序照 `dict.update`:既有键留在原位,新键按 patch 的顺序追加(`pyJsonDumps` 保插入序)。
327
+ *
328
+ * @param {object} handle 写连接(调用方持事务边界)
329
+ * @param {object} row `{id, meta}`(`getItemOn` / 显式 SELECT 的行)
330
+ * @param {object} patch 要合入的键值对
331
+ */
332
+ function mergeMetaOn(handle, row, patch) {
333
+ let cur;
334
+ try {
335
+ cur = JSON.parse(row.meta || '{}');
336
+ if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) cur = {};
337
+ } catch {
338
+ cur = {};
339
+ }
340
+ Object.assign(cur, patch);
341
+ handle.prepare('UPDATE wait_items SET meta=? WHERE id=?')
342
+ .run(pyJsonDumps(cur), bindable(row.id));
343
+ }
344
+
345
+ /**
346
+ * 入队等待项(幂等:同类同目标已有活跃行 → 返回既有行 id)。
347
+ *
348
+ * 并发安全(照 Python):INSERT 优先、活跃唯一索引兜底;撞唯一索引说明并发下已有活跃行,
349
+ * 重查并复用其 id(避免 check-then-insert 竞态抛 IntegrityError 被静默吞掉)。非活跃
350
+ * 唯一索引冲突(重查不到活跃行)**上抛原错**,交给调用方。
351
+ *
352
+ * @param {string} kind 五 kind 之一(未知抛 `TypeError`,照 Python `ValueError`)
353
+ * @param {string|number} targetId 目标 id(一律 `String(...)` 落 TEXT 列)
354
+ * @param {number} projectId
355
+ * @param {object|null} [meta] 行 meta(缺省 `{}`)
356
+ * @param {number} [notBefore] 最早可拾取时间(epoch 秒)
357
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 写连接(见 `db.js:withWriteDb`)
358
+ * @returns {number} 等待项 id(既有行 id 或新行 id)
359
+ */
360
+ export function enqueue(kind, targetId, projectId, meta = null, notBefore = 0.0, options = {}) {
361
+ if (!KINDS.includes(kind)) throw new TypeError(`未知等待项 kind: ${kind}`);
362
+ const target = String(targetId);
363
+ return withWriteDb(options, (handle) => {
364
+ try {
365
+ const info = handle.prepare(
366
+ 'INSERT INTO wait_items (project_id, kind, target_id, state, seq,'
367
+ + ' created_at, not_before, meta)'
368
+ + " VALUES (?,?,?,'waiting',"
369
+ + ' (SELECT COALESCE(MAX(seq),0)+1 FROM wait_items),?,?,?)',
370
+ ).run(bindable(projectId), kind, target, nowStr(),
371
+ Number(notBefore || 0), pyJsonDumps(meta || {}));
372
+ return Number(info.lastInsertRowid);
373
+ } catch (err) {
374
+ if (!isIntegrityError(err)) throw err;
375
+ const row = handle.prepare(
376
+ 'SELECT id FROM wait_items WHERE kind=? AND target_id=?'
377
+ + ` AND state IN (${ACTIVE_IN})`,
378
+ ).get(kind, target);
379
+ if (row === undefined) throw err; // 非活跃唯一索引冲突:上抛,交给调用方
380
+ return row.id;
381
+ }
382
+ });
383
+ }
384
+
385
+ /**
386
+ * 按 id 取等待项行(无则 null)。
387
+ * @param {number} itemId
388
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接(`db.js` 读路径契约;见 `db.js`)
389
+ * @returns {object|null}
390
+ */
391
+ export function getItem(itemId, options = {}) {
392
+ return withReadDb(options, (handle) => getItemOn(handle, itemId));
393
+ }
394
+
395
+ /** `getItem` 的持连接版本(内部复用;避免嵌套调用各开一条连接) */
396
+ function getItemOn(handle, itemId) {
397
+ if (itemId === null || itemId === undefined) return null;
398
+ return plainRow(handle.prepare('SELECT * FROM wait_items WHERE id=?').get(bindable(itemId)));
399
+ }
400
+
401
+ /**
402
+ * 取同类同目标的活跃等待项行(无则 null)。`target_id` 一律按字符串比对(行里存的是
403
+ * TEXT,Python 侧同样 `str(target_id)`)。
404
+ * @param {string} kind
405
+ * @param {string|number} targetId
406
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接(`db.js` 读路径契约;见 `db.js`)
407
+ * @returns {object|null}
408
+ */
409
+ export function getActive(kind, targetId, options = {}) {
410
+ return withReadDb(options, (handle) => getActiveOn(handle, kind, targetId));
411
+ }
412
+
413
+ /** `getActive` 的持连接版本 */
414
+ function getActiveOn(handle, kind, targetId) {
415
+ return plainRow(handle.prepare(
416
+ 'SELECT * FROM wait_items WHERE kind=? AND target_id=?'
417
+ + ` AND state IN (${ACTIVE_IN})`,
418
+ ).get(bindable(kind), bindable(String(targetId))));
419
+ }
420
+
421
+ /**
422
+ * 活跃等待项列表(seq 升序;projectId 给定时仅该项目)。
423
+ * @param {number|null} [projectId]
424
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接(`db.js` 读路径契约;见 `db.js`)
425
+ * @returns {object[]}
426
+ */
427
+ export function activeItems(projectId = null, options = {}) {
428
+ return withReadDb(options, (handle) => activeItemsOn(handle, projectId));
429
+ }
430
+
431
+ /** `activeItems` 的持连接版本 */
432
+ function activeItemsOn(handle, projectId = null) {
433
+ let sql = `SELECT * FROM wait_items WHERE state IN (${ACTIVE_IN})`;
434
+ const args = [];
435
+ if (projectId !== null && projectId !== undefined) {
436
+ sql += ' AND project_id=?';
437
+ args.push(projectId);
438
+ }
439
+ sql += ' ORDER BY seq';
440
+ return plainRows(handle.prepare(sql).all(...args.map(bindable)));
441
+ }
442
+
443
+ /**
444
+ * 有活跃 answer 等待项(已作答·待送达)的卡片 id 集合(P6 批量派生用;口径同为四活跃态行)。
445
+ *
446
+ * Python 用 `int(r[0])` ⇒ 非数字 target_id 抛 ValueError;Node 的 parseInt 给 NaN,
447
+ * 这里显式跳过 NaN 行(见文件头差异③:只服务展示派生,一行坏数据不该打穿读板)。
448
+ *
449
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接(`db.js` 读路径契约;见 `db.js`)
450
+ * @returns {Set<number>}
451
+ */
452
+ export function answerPendingCardIds(options = {}) {
453
+ return withReadDb(options, (handle) => {
454
+ const out = new Set();
455
+ for (const row of handle.prepare(
456
+ 'SELECT DISTINCT target_id FROM wait_items'
457
+ + ` WHERE kind=? AND state IN (${ACTIVE_IN})`,
458
+ ).all(KIND_ANSWER)) {
459
+ const cid = Number.parseInt(row.target_id, 10);
460
+ if (!Number.isNaN(cid)) out.add(cid);
461
+ }
462
+ return out;
463
+ });
464
+ }
465
+
466
+ /**
467
+ * `c:` 等待项为 starting 态的卡片 id 集合(queue_state `starting` 枚举批量派生用,裁决 R6)。
468
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接(`db.js` 读路径契约;见 `db.js`)
469
+ * @returns {Set<number>}
470
+ */
471
+ export function startingCardIds(options = {}) {
472
+ return withReadDb(options, (handle) => {
473
+ const out = new Set();
474
+ for (const row of handle.prepare(
475
+ 'SELECT DISTINCT target_id FROM wait_items WHERE kind=? AND state=\'starting\'',
476
+ ).all(KIND_CARD)) {
477
+ const cid = Number.parseInt(row.target_id, 10);
478
+ if (!Number.isNaN(cid)) out.add(cid);
479
+ }
480
+ return out;
481
+ });
482
+ }
483
+
484
+ /**
485
+ * 项目是否有活跃 ext 行(外部条目读口,v2d T4 裁决 R13)。
486
+ *
487
+ * 注意 v3a 契约:**不得**给 `active_ext` 增加任何「第二遍历源」(Python 侧同款注释)。
488
+ * 调度侧的项目外部条目闸不查本函数——`runner._pick_locked` 直接用本轮已取到的行集。
489
+ *
490
+ * @param {number} projectId
491
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接(`db.js` 读路径契约;见 `db.js`)
492
+ * @returns {boolean}
493
+ */
494
+ export function activeExt(projectId, options = {}) {
495
+ return withReadDb(options, (handle) => activeExtOn(handle, projectId));
496
+ }
497
+
498
+ /** `activeExt` 的持连接版本 */
499
+ function activeExtOn(handle, projectId) {
500
+ return handle.prepare(
501
+ 'SELECT 1 FROM wait_items WHERE project_id=? AND kind=?'
502
+ + ` AND state IN (${ACTIVE_IN}) LIMIT 1`,
503
+ ).get(bindable(projectId), KIND_EXT) !== undefined;
504
+ }
505
+
506
+ /**
507
+ * 活跃 ext 行列表(seq 升序;projectId 给定时仅该项目)。
508
+ * @param {number|null} [projectId]
509
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接(`db.js` 读路径契约;见 `db.js`)
510
+ * @returns {object[]}
511
+ */
512
+ export function activeExtItems(projectId = null, options = {}) {
513
+ let sql = `SELECT * FROM wait_items WHERE kind=? AND state IN (${ACTIVE_IN})`;
514
+ const args = [KIND_EXT];
515
+ if (projectId !== null && projectId !== undefined) {
516
+ sql += ' AND project_id=?';
517
+ args.push(projectId);
518
+ }
519
+ sql += ' ORDER BY seq';
520
+ return withReadDb(options, (handle) => plainRows(handle.prepare(sql).all(...args.map(bindable))));
521
+ }
522
+
523
+ /**
524
+ * 同项目 waiting 等待项位次 `(pos, total)`(1 基;非 waiting 返回 `(0, 0)`)。
525
+ *
526
+ * v2a T4 归一(裁决 R7,位次含运行前缀):
527
+ * pos = 前缀成员数 + waiting 中 seq 更小者 + 1;total = 项目成员总数。
528
+ * **v3a 读口切行**:前缀成员 = starting/running/finishing 行(唯一来源)——与
529
+ * `runner.unitState` 手算同口径。Python 无生产调用方(生产位次走 unit_state),
530
+ * 本函数留自测与对拍。
531
+ *
532
+ * **返回数组 `[pos, total]` 而非对象**(2026-10-12 M4 修正):Python 返回的是元组
533
+ * `(pos, total)`,M3 的 Node 只读子集误用了 `{pos, total}`——因为本函数**没有任何
534
+ * HTTP 调用方**,M3 的读端点对拍照不到它(M4 的 op 级对拍一跑就现形)。形态即契约,
535
+ * 故对齐 Python;`runner.unitState` 的 `{state, pos, total}` 是**另一个**函数(那个
536
+ * 确实是对象,照 Python),两者不要混。
537
+ *
538
+ * 逐点保留的两个细节(照 Python):
539
+ * ① **同键去重**:`prefix_keys`/`total_keys` 是集合,同单元多行只计一次;
540
+ * ② **`ahead` 在遇到本行之前累加**:升序遍历 + 「同键已计前缀则跳过」= 同键的
541
+ * 前方 waiting 行不重复计位。
542
+ *
543
+ * @param {number} itemId
544
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接(`db.js` 读路径契约;见 `db.js`)
545
+ * @returns {[number, number]} `[pos, total]`
546
+ */
547
+ export function position(itemId, options = {}) {
548
+ return withReadDb(options, (handle) => positionOn(handle, itemId));
549
+ }
550
+
551
+ /** `position` 的持连接版本 */
552
+ function positionOn(handle, itemId) {
553
+ const row = getItemOn(handle, itemId);
554
+ if (row === null || row.state !== STATE_WAITING) return [0, 0];
555
+ const rows = handle.prepare(
556
+ 'SELECT id, kind, target_id, state FROM wait_items WHERE project_id=?'
557
+ + ` AND state IN (${ACTIVE_IN}) ORDER BY seq`,
558
+ ).all(bindable(row.project_id));
559
+ const prefixKeys = new Set();
560
+ const totalKeys = new Set();
561
+ const waiting = []; // [行 id, 成员键],seq 升序
562
+ for (const r of rows) {
563
+ const rk = memberKey(r.kind, r.target_id);
564
+ totalKeys.add(rk);
565
+ if (r.state === STATE_WAITING) waiting.push([r.id, rk]);
566
+ else prefixKeys.add(rk); // starting/running/finishing:位次前缀
567
+ }
568
+ let ahead = 0;
569
+ for (const [rid, rk] of waiting) {
570
+ if (rid === itemId) {
571
+ // 本行即查询目标:位次 = 前缀成员数 + 前方未计前缀的等待行数 + 1
572
+ return [prefixKeys.size + ahead + 1, totalKeys.size];
573
+ }
574
+ if (prefixKeys.has(rk)) continue; // 同键已计前缀(同单元不重复计位)
575
+ ahead += 1; // waiting 且 seq 更小者(升序遍历)
576
+ }
577
+ return [0, 0];
578
+ }
579
+
580
+ // --------------------------------------------------------------------------
581
+ // 写口 · 分数序插入与认领(waitq.py:235-616,裁决 R4)
582
+ // --------------------------------------------------------------------------
583
+ // seq 为 REAL 分数序:落尾仍 MAX(seq)+1(enqueue 语义不变),中间插入取相邻中点;间隙
584
+ // < SEQ_REBALANCE_EPS 先对项目区间行等距重排(仅 UPDATE seq,行 id 稳定)。
585
+
586
+ /**
587
+ * 间隙挤压再平衡(waitq.py:244):本项目 seq ≤ hi 的全部行按序等距重排到 `(floor, hi]`
588
+ * (`floor` = 区间最小行的**全局**前驱 seq,无前驱取 `最小行 seq - 1`;hi 行本身落在原值
589
+ * 不动)。仅 UPDATE seq,行 id 稳定、行序不变;他项目行不改写(跨项目交错行可能被本项目
590
+ * 行越过——间隙挤压下的可接受扰动,小口径记录;呼出方需持连接,与本模块其他写函数同
591
+ * 事务语义)。返回重排行数。
592
+ *
593
+ * @param {object} handle 写连接
594
+ * @param {number} projectId
595
+ * @param {number} hi 区间上界(含)
596
+ * @returns {number} 重排行数
597
+ */
598
+ function rebalanceIntervalOn(handle, projectId, hi) {
599
+ const rows = handle.prepare(
600
+ 'SELECT id, seq FROM wait_items WHERE project_id=? AND seq <= ? ORDER BY seq, id',
601
+ ).all(bindable(projectId), hi);
602
+ const n = rows.length;
603
+ if (n === 0) return 0;
604
+ const pred = handle.prepare('SELECT MAX(seq) s FROM wait_items WHERE seq < ?')
605
+ .get(rows[0].seq).s;
606
+ const floor = (pred === null || pred === undefined) ? rows[0].seq - 1.0 : pred;
607
+ const step = (hi - floor) / n; // n 个点:floor+step … floor+n*step=hi(hi 行不动)
608
+ const upd = handle.prepare('UPDATE wait_items SET seq=? WHERE id=?');
609
+ rows.forEach((r, i) => upd.run(floor + step * (i + 1), r.id));
610
+ return n;
611
+ }
612
+
613
+ /**
614
+ * `(lo, hi)` 开区间中点 seq(waitq.py:266);间隙 < `SEQ_REBALANCE_EPS` 先对项目区间
615
+ * 等距重排(行 id 稳定),再取本项目 hi 前最末行与 hi 的中点(落点紧贴 hi 前)。
616
+ *
617
+ * 重排后本项目 hi 前无行的兜底取 `(lo, hi)` 浮点中点(float64 中点恒可表示且必落区间内;
618
+ * 旧 `hi-1.0` 在亚 eps 间隙会跌出下界——终审 Minor-1)。
619
+ *
620
+ * @param {object} handle 写连接
621
+ * @param {number} projectId
622
+ * @param {number} lo
623
+ * @param {number} hi
624
+ * @returns {number}
625
+ */
626
+ function midSeqOn(handle, projectId, lo, hi) {
627
+ if (hi - lo >= SEQ_REBALANCE_EPS) return (lo + hi) / 2;
628
+ rebalanceIntervalOn(handle, projectId, hi);
629
+ const last = handle.prepare(
630
+ 'SELECT MAX(seq) s FROM wait_items WHERE project_id=? AND seq < ?',
631
+ ).get(bindable(projectId), hi).s;
632
+ return (last === null || last === undefined) ? (lo + hi) / 2 : (last + hi) / 2;
633
+ }
634
+
635
+ /**
636
+ * 「最后一个运行中条目之后」= 等待区最前的位次(waitq.py:280;锁内/事务内调用;
637
+ * `insertAfterPrefix` 几何唯一出处,v2b T2 起 `insertCardAfterPrefix` 共用)。
638
+ *
639
+ * 位次几何(全局 seq 轴):P=本项目前缀行(state∈PREFIX_STATES)最大 seq;W=本项目等待行
640
+ * 最小 seq。W 存在 → 下界 `L=max(P.seq, W 的全局前驱.seq)`,`seq_new=(L+W.seq)/2`
641
+ * (保项目内「前缀全在等待前」不变量);W 不存在 → `seq_new=全局 MAX(seq)+1`(落尾,与
642
+ * `enqueue` 兼容)。间隙 < `SEQ_REBALANCE_EPS` 时对项目区间行按序等距重排(行 id 稳定)。
643
+ *
644
+ * @param {object} handle 写连接
645
+ * @param {number} projectId
646
+ * @returns {number}
647
+ */
648
+ function afterPrefixSeqOn(handle, projectId) {
649
+ const pfx = handle.prepare(
650
+ 'SELECT MAX(seq) s FROM wait_items WHERE project_id=?'
651
+ + ` AND state IN (${PREFIX_IN})`,
652
+ ).get(bindable(projectId)).s;
653
+ const w = handle.prepare(
654
+ "SELECT MIN(seq) s FROM wait_items WHERE project_id=? AND state='waiting'",
655
+ ).get(bindable(projectId)).s;
656
+ if (w === null || w === undefined) {
657
+ return handle.prepare('SELECT COALESCE(MAX(seq),0)+1 s FROM wait_items').get().s;
658
+ }
659
+ const pred = handle.prepare('SELECT MAX(seq) s FROM wait_items WHERE seq < ?').get(w).s;
660
+ const lows = [pfx, pred].filter((x) => x !== null && x !== undefined);
661
+ const lo = lows.length ? Math.max(...lows) : w - 1.0; // 无前驱:界外一步
662
+ return midSeqOn(handle, projectId, lo, w);
663
+ }
664
+
665
+ /**
666
+ * 「最后一个运行中条目之后」= 等待区最前(waitq.py:305,设计 §2.2 插入规则基本形态)。
667
+ *
668
+ * 位次几何见 `afterPrefixSeqOn`;活跃唯一索引冲突时幂等复用现行语义(返回既有行)。
669
+ * 返回 `[row_id, seq_new]`(照 Python 元组)。
670
+ *
671
+ * @param {string} kind 五 kind 之一(未知抛 `TypeError`)
672
+ * @param {string|number} target
673
+ * @param {number} projectId
674
+ * @param {object|null} [meta]
675
+ * @param {number} [notBefore]
676
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
677
+ * @returns {[number, number]}
678
+ */
679
+ export function insertAfterPrefix(kind, target, projectId, meta = null, notBefore = 0,
680
+ options = {}) {
681
+ if (!KINDS.includes(kind)) throw new TypeError(`未知等待项 kind: ${kind}`);
682
+ const targetId = String(target);
683
+ return withWriteDb(options, (handle) => {
684
+ try {
685
+ const seqNew = afterPrefixSeqOn(handle, projectId);
686
+ const info = handle.prepare(
687
+ 'INSERT INTO wait_items (project_id, kind, target_id, state, seq,'
688
+ + ' created_at, not_before, meta)'
689
+ + " VALUES (?,?,?,'waiting',?,?,?,?)",
690
+ ).run(bindable(projectId), kind, targetId, seqNew, nowStr(),
691
+ Number(notBefore || 0), pyJsonDumps(meta || {}));
692
+ return [Number(info.lastInsertRowid), seqNew];
693
+ } catch (err) {
694
+ if (!isIntegrityError(err)) throw err;
695
+ const row = handle.prepare(
696
+ 'SELECT id, seq FROM wait_items WHERE kind=? AND target_id=?'
697
+ + ` AND state IN (${ACTIVE_IN})`,
698
+ ).get(kind, targetId);
699
+ if (row === undefined) throw err; // 非活跃唯一索引冲突:上抛,交给调用方
700
+ return [row.id, row.seq];
701
+ }
702
+ });
703
+ }
704
+
705
+ /**
706
+ * 会话消息落点(waitq.py:333,v2 §2.2 插入规则的消息分支,2026-09-25 对齐「待对齐项」):
707
+ * 「最后一个运行中条目之后」,且排在同项目已排队的用户动作行(waiting `m:`/`a:`)之后
708
+ * ——消息优先于等待区卡片/任务(`c:`/`t:`),同项目用户动作之间保持到达序 FIFO(否则
709
+ * 第二条消息会插到第一条之前,同会话消息序被反转)。
710
+ *
711
+ * 几何(复用 `midSeqOn` 区间语义,锁内/事务内调用):base = 本项目「前缀行 ∪ 等待用户
712
+ * 动作行」的最大 seq;base 为空(无前缀无用户动作)退化为 `afterPrefixSeqOn`(等待区最
713
+ * 前);hi = 本项目 `seq>base` 的首个等待行,无 hi 落全局尾;lo = `max(base, hi 的全局
714
+ * 前驱)`,取 `(lo, hi)` 中点。
715
+ *
716
+ * @param {object} handle 写连接
717
+ * @param {number} projectId
718
+ * @returns {number}
719
+ */
720
+ function msgInsertSeqOn(handle, projectId) {
721
+ const base = handle.prepare(
722
+ 'SELECT MAX(seq) s FROM wait_items WHERE project_id=?'
723
+ + ` AND (state IN (${PREFIX_IN})`
724
+ + ` OR (state='waiting' AND kind IN ('${KIND_MSG}','${KIND_ANSWER}')))`,
725
+ ).get(bindable(projectId)).s;
726
+ if (base === null || base === undefined) return afterPrefixSeqOn(handle, projectId);
727
+ const hi = handle.prepare(
728
+ "SELECT MIN(seq) s FROM wait_items WHERE project_id=? AND state='waiting' AND seq > ?",
729
+ ).get(bindable(projectId), base).s;
730
+ if (hi === null || hi === undefined) {
731
+ return handle.prepare('SELECT COALESCE(MAX(seq),0)+1 s FROM wait_items').get().s;
732
+ }
733
+ const pred = handle.prepare('SELECT MAX(seq) s FROM wait_items WHERE seq < ?').get(hi).s;
734
+ const lo = (pred === null || pred === undefined) ? base : Math.max(base, pred);
735
+ return midSeqOn(handle, projectId, lo, hi);
736
+ }
737
+
738
+ /**
739
+ * 拖拽落点改序(waitq.py:362):`seq=(prev_seq+next_seq)/2`;仅 UPDATE seq,行 id 稳定
740
+ * (P3 inject R4 口径);间隙挤压先重排。prev/next 由调用方按落点邻行给出(`null` 表示
741
+ * 区首/区尾,取界外一步)。
742
+ *
743
+ * 行不存在返回 `false`;两界皆空或区间倒置(prev ≥ next)抛错(Node `RangeError`,
744
+ * 照 Python `ValueError`)。
745
+ *
746
+ * 行态守卫说明(v2c T2 补记,v2a 移交项):本函数**有意不设行态守卫**——调序手势/补位
747
+ * 几何需要挪 starting/running 行(行即成员,运行前缀也按 seq 展示与插位);终态行被挪
748
+ * 无害(读侧全部只认活跃行,seq 改动无消费方)。并发语义由调用方承载:worker 已 claim
749
+ * 的行被挪序不影响该次起跑(仅位次展示瞬态漂移),手势侧幂等兜底见 `board._doing_gesture`。
750
+ *
751
+ * @param {number} rowId
752
+ * @param {number|null} prevSeq
753
+ * @param {number|null} nextSeq
754
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
755
+ * @returns {boolean}
756
+ */
757
+ export function reposition(rowId, prevSeq = null, nextSeq = null, options = {}) {
758
+ const hasPrev = prevSeq !== null && prevSeq !== undefined;
759
+ const hasNext = nextSeq !== null && nextSeq !== undefined;
760
+ if (!hasPrev && !hasNext) {
761
+ throw new RangeError('reposition 需至少给一侧邻行 seq');
762
+ }
763
+ if (hasPrev && hasNext && Number(prevSeq) >= Number(nextSeq)) {
764
+ throw new RangeError('reposition 区间倒置:prev_seq 必须小于 next_seq');
765
+ }
766
+ return withWriteDb(options, (handle) => {
767
+ const row = handle.prepare('SELECT project_id FROM wait_items WHERE id=?')
768
+ .get(bindable(rowId));
769
+ if (row === undefined) return false;
770
+ const lo = hasPrev ? Number(prevSeq) : Number(nextSeq) - 1.0;
771
+ const hi = hasNext ? Number(nextSeq) : Number(prevSeq) + 1.0;
772
+ const seqNew = midSeqOn(handle, row.project_id, lo, hi);
773
+ handle.prepare('UPDATE wait_items SET seq=? WHERE id=?')
774
+ .run(seqNew, bindable(rowId));
775
+ return true;
776
+ });
777
+ }
778
+
779
+ /**
780
+ * 拾取(`waiting → starting`,原子:rowcount 判定,已被抢返回 `false`;waitq.py:390)。
781
+ *
782
+ * v2a T1(裁决 R3):目标态 claimed 改名 starting(claimed 语义被吸收),认领迁移同为
783
+ * rowcount 守卫,互斥语义逐字不变。
784
+ *
785
+ * @param {number} itemId
786
+ * @param {string} [claimer]
787
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
788
+ * @returns {boolean}
789
+ */
790
+ export function claim(itemId, claimer = '', options = {}) {
791
+ if (itemId === null || itemId === undefined) return false;
792
+ return withWriteDb(options, (handle) => {
793
+ const info = handle.prepare(
794
+ "UPDATE wait_items SET state='starting', claimed_at=?, claimed_by=?"
795
+ + " WHERE id=? AND state='waiting'",
796
+ ).run(nowStr(), bindable(claimer), bindable(itemId));
797
+ return Number(info.changes) === 1;
798
+ });
799
+ }
800
+
801
+ /**
802
+ * 按 `(kind,target)` 拾取(waitq.py:404):先查活跃行再原子 `claim`;无活跃行/已被抢
803
+ * 返回 `false`。
804
+ *
805
+ * @param {string} kind
806
+ * @param {string|number} targetId
807
+ * @param {string} [claimer]
808
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
809
+ * @returns {boolean}
810
+ */
811
+ export function claimByTarget(kind, targetId, claimer = '', options = {}) {
812
+ const row = getActive(kind, targetId, options);
813
+ return row !== null ? claim(row.id, claimer, options) : false;
814
+ }
815
+
816
+ /**
817
+ * `starting → running`(waitq.py:410):证实运行(proc 存活 / web busy / turn 基线确认)
818
+ * 后由起跑路径调用;非 starting 行返回 `false`(幂等守卫,不炸调用方)。
819
+ *
820
+ * v3a 补充:起跑证实(六条起跑路径)改经 `enterRunning`——本函数保留原语义不变(严格
821
+ * `starting→running`,recover 映射 / starting 超时自愈等既有调用方逐字不动)。
822
+ *
823
+ * @param {string} kind
824
+ * @param {string|number} targetId
825
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
826
+ * @returns {boolean}
827
+ */
828
+ export function markRunning(kind, targetId, options = {}) {
829
+ return withWriteDb(options, (handle) => {
830
+ const info = handle.prepare(
831
+ "UPDATE wait_items SET state='running' WHERE kind=? AND target_id=?"
832
+ + " AND state='starting'",
833
+ ).run(kind, String(targetId));
834
+ return Number(info.changes) === 1;
835
+ });
836
+ }
837
+
838
+ /**
839
+ * 确保 `(kind,target)` 有且只有一条 running 行(v3a 起跑证实统一入口;waitq.py:424)。
840
+ *
841
+ * 「行即条目」(v3 §2.2):`c:` 行在起跑证实后**保持 running 跨轮存活**,直到会话结束/
842
+ * 出队——旧口径在起跑瞬间把行终态化、跨轮占用另立表征,v3a 起退场。本函数是
843
+ * `markRunning` 的超集(后者严格 `starting→running`,保留给既有调用方):
844
+ *
845
+ * - 活跃行(waiting/starting/finishing)→ running(waiting 直接升格:起跑证实即运行,
846
+ * 不重排队、不新插行);
847
+ * - 已是 running → 幂等 `true`(行原样不动);
848
+ * - 终态行(done/failed/cancelled)→ **复用同一行**(同 id、同 seq)重开 running——出队
849
+ * (落阻塞/停止/移列/删除)后再起跑不新造行,位次与审计线索延续;
850
+ * - 无行 → 插入一行 `state=running`,位次几何与 `insertAfterPrefix` 一致
851
+ * (`afterPrefixSeqOn`:运行前缀尾 / 等待区最前)——force 直起、送达恢复、阻塞解除
852
+ * 归位、recover 重建等「无活跃行」路径在此补齐「行即成员」。
853
+ *
854
+ * 返回 `true` = 行已在 running(本入口幂等且不可失败:起跑证实的成败由起跑方自行判定,
855
+ * 行只是证据面)。并发安全:先查活跃行;写入撞活跃唯一索引 `(kind,target_id)`(他人刚
856
+ * 插入/重开活跃行)→ 重查复用其行,幂等不抛。meta 仅在新建/重开时合并(活跃行 meta 不动,
857
+ * 避免踩 extra/from_column 审计线索);**行证据同理**——重开终态行 = 新生命周期,
858
+ * evidence/last_seen 一并清零(旧会话证据不得残留到下一条命:pid 陈旧会误判判活/判死),
859
+ * 活跃行证据不动(由 `markEvidence` / `touchUnit` 续写)。
860
+ *
861
+ * @param {string} kind
862
+ * @param {string|number} targetId
863
+ * @param {number} projectId 无行时新建用
864
+ * @param {object|null} [meta] 仅新建/重开时合入
865
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
866
+ * @returns {boolean}
867
+ */
868
+ export function enterRunning(kind, targetId, projectId, meta = null, options = {}) {
869
+ const target = String(targetId);
870
+ return withWriteDb(options, (handle) => {
871
+ let row = handle.prepare(
872
+ 'SELECT id, state FROM wait_items WHERE kind=? AND target_id=?'
873
+ + ` AND state IN (${ACTIVE_IN})`,
874
+ ).get(kind, target);
875
+ if (row !== undefined) {
876
+ if (row.state !== RUNNING) {
877
+ handle.prepare("UPDATE wait_items SET state='running' WHERE id=?").run(row.id);
878
+ }
879
+ return true;
880
+ }
881
+ const prev = handle.prepare(
882
+ 'SELECT id, meta FROM wait_items WHERE kind=? AND target_id=?'
883
+ + ' ORDER BY id DESC LIMIT 1',
884
+ ).get(kind, target);
885
+ if (prev !== undefined) { // 终态行重开(同 id 同 seq,不新插行)
886
+ try {
887
+ handle.prepare(
888
+ "UPDATE wait_items SET state='running', ended_at=NULL,"
889
+ + " claimed_at=NULL, claimed_by=NULL, not_before=0,"
890
+ + " last_seen=0, evidence='' WHERE id=?",
891
+ ).run(prev.id);
892
+ } catch (err) {
893
+ if (!isIntegrityError(err)) throw err;
894
+ row = handle.prepare( // 并发:他人已重开同键行 → 落在活跃行上
895
+ 'SELECT id FROM wait_items WHERE kind=? AND target_id=?'
896
+ + ` AND state IN (${ACTIVE_IN})`,
897
+ ).get(kind, target);
898
+ if (row === undefined) throw err;
899
+ handle.prepare("UPDATE wait_items SET state='running' WHERE id=?").run(row.id);
900
+ }
901
+ if (hasEntries(meta)) mergeMetaOn(handle, prev, meta);
902
+ return true;
903
+ }
904
+ const seqNew = afterPrefixSeqOn(handle, projectId);
905
+ try {
906
+ handle.prepare(
907
+ 'INSERT INTO wait_items (project_id, kind, target_id, state, seq,'
908
+ + ' created_at, meta)'
909
+ + " VALUES (?,?,?,'running',?,?,?)",
910
+ ).run(bindable(projectId), kind, target, seqNew, nowStr(), pyJsonDumps(meta || {}));
911
+ } catch (err) {
912
+ if (!isIntegrityError(err)) throw err;
913
+ row = handle.prepare( // 并发:他人刚建活跃行 → 复用其行
914
+ 'SELECT id FROM wait_items WHERE kind=? AND target_id=?'
915
+ + ` AND state IN (${ACTIVE_IN})`,
916
+ ).get(kind, target);
917
+ if (row === undefined) throw err;
918
+ handle.prepare("UPDATE wait_items SET state='running' WHERE id=?").run(row.id);
919
+ }
920
+ return true;
921
+ });
922
+ }
923
+
924
+ /**
925
+ * `running → finishing`(waitq.py:496):唯一收尾点入口(v2d T1,生产面唯此一条——
926
+ * `board.finish` 只对 running 行调用)。SQL 另容忍 starting 作防御面(无生产调用方:
927
+ * 起跑失败 / starting 超时行的终态走 `finishByTarget` / worker finally,不经本迁移)。
928
+ *
929
+ * @param {string} kind
930
+ * @param {string|number} targetId
931
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
932
+ * @returns {boolean}
933
+ */
934
+ export function markFinishing(kind, targetId, options = {}) {
935
+ return withWriteDb(options, (handle) => {
936
+ const info = handle.prepare(
937
+ "UPDATE wait_items SET state='finishing' WHERE kind=? AND target_id=?"
938
+ + " AND state IN ('starting','running')",
939
+ ).run(kind, String(targetId));
940
+ return Number(info.changes) === 1;
941
+ });
942
+ }
943
+
944
+ /**
945
+ * 活跃项落终态(done/failed);非活跃返回 `false`(waitq.py:508)。
946
+ *
947
+ * 状态守卫落在 UPDATE 上(rowcount 判定),避免读-写之间被并发抢先落终态;error 的 meta
948
+ * 合并在同一事务内、仅落终态成功(`changes === 1`)时执行。
949
+ *
950
+ * @param {number} itemId
951
+ * @param {string} state 终态(`done`/`failed`)
952
+ * @param {string} [error]
953
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
954
+ * @returns {boolean}
955
+ */
956
+ function setTerminal(itemId, state, error = '', options = {}) {
957
+ if (itemId === null || itemId === undefined) return false;
958
+ return withWriteDb(options, (handle) => {
959
+ const info = handle.prepare(
960
+ 'UPDATE wait_items SET state=?, ended_at=?'
961
+ + ` WHERE id=? AND state IN (${ACTIVE_IN})`,
962
+ ).run(state, nowStr(), bindable(itemId));
963
+ if (Number(info.changes) !== 1) return false;
964
+ if (error) {
965
+ const row = handle.prepare('SELECT id, meta FROM wait_items WHERE id=?')
966
+ .get(bindable(itemId));
967
+ mergeMetaOn(handle, row, { error });
968
+ }
969
+ return true;
970
+ });
971
+ }
972
+
973
+ /**
974
+ * 标记完成(投递/送达/会话起跑成功的终态;waitq.py:530)。
975
+ * @param {number} itemId
976
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
977
+ * @returns {boolean}
978
+ */
979
+ export function markDone(itemId, options = {}) {
980
+ return setTerminal(itemId, DONE, '', options);
981
+ }
982
+
983
+ /**
984
+ * 标记执行失败(起会话失败/送达失败等;error 落 meta;waitq.py:535)。
985
+ * @param {number} itemId
986
+ * @param {string} [error]
987
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
988
+ * @returns {boolean}
989
+ */
990
+ export function markFailed(itemId, error = '', options = {}) {
991
+ return setTerminal(itemId, FAILED, error, options);
992
+ }
993
+
994
+ /**
995
+ * 按 `(kind,target)` 落终态;无活跃行返回 `false`(waitq.py:540)。
996
+ * @param {string} kind
997
+ * @param {string|number} targetId
998
+ * @param {string} [state] `done`(缺省)走 `markDone`,其余走 `markFailed`
999
+ * @param {string} [error]
1000
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1001
+ * @returns {boolean}
1002
+ */
1003
+ export function finishByTarget(kind, targetId, state = DONE, error = '', options = {}) {
1004
+ const row = getActive(kind, targetId, options);
1005
+ if (row === null) return false;
1006
+ if (state === DONE) return markDone(row.id, options);
1007
+ return markFailed(row.id, error, options);
1008
+ }
1009
+
1010
+ /**
1011
+ * `starting/running/finishing → waiting` 放回(重试退避:not_before 最早可拾取时间;
1012
+ * bump_retry 累加计数;waitq.py:550)。
1013
+ *
1014
+ * 状态守卫落在 UPDATE 上(活跃非 waiting 三态 + rowcount 判定):终态行不会被复活(否则
1015
+ * 会与活跃唯一索引冲突);retries 在 SQL 内累加,免先读后写。v2a T1:claimed 改名
1016
+ * starting;放回面扩到 running/finishing(recover 映射:`a:` 行三态同型放回重投)。
1017
+ *
1018
+ * @param {number} itemId
1019
+ * @param {number} [notBefore]
1020
+ * @param {boolean} [bumpRetry]
1021
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1022
+ * @returns {boolean}
1023
+ */
1024
+ export function returnToWaiting(itemId, notBefore = 0.0, bumpRetry = false, options = {}) {
1025
+ if (itemId === null || itemId === undefined) return false;
1026
+ return withWriteDb(options, (handle) => {
1027
+ const info = handle.prepare(
1028
+ "UPDATE wait_items SET state='waiting', claimed_at=NULL, claimed_by=NULL,"
1029
+ + ' not_before=?, retries=retries+?'
1030
+ + ` WHERE id=? AND state IN (${RETURNABLE_IN})`,
1031
+ ).run(Number(notBefore || 0), bumpRetry ? 1 : 0, bindable(itemId));
1032
+ return Number(info.changes) === 1;
1033
+ });
1034
+ }
1035
+
1036
+ /**
1037
+ * 取消活跃等待项(幂等:无活跃行返回 `false`;reason 落 meta;waitq.py:567)。
1038
+ *
1039
+ * 状态守卫落在 UPDATE 上(四活跃态 + rowcount 判定,与 `setTerminal` 同型):避免读-写
1040
+ * 之间被并发抢先落终态——终态行不再被改写成 cancelled;reason 的 meta 合并在同一事务内、
1041
+ * 仅取消成功(`changes === 1`)时执行。
1042
+ *
1043
+ * `states` 收窄可取消行态(缺省 `null` = 四活跃态,既有调用面逐字不变;v2d T1:作答排队
1044
+ * 防御只取 waiting——running/finishing 行终态化归 `board.finish` 唯一收尾点、starting 归
1045
+ * worker finally,行态口径统一)。空数组防御(v2d T1 minor 收口):`states=[]` 语义为
1046
+ * 「无可取消态」,直接返回 `false`——原样拼 SQL 会得到 `state IN ()` 语法错。
1047
+ *
1048
+ * @param {string} kind
1049
+ * @param {string|number} targetId
1050
+ * @param {string} [reason]
1051
+ * @param {string[]|null} [states]
1052
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1053
+ * @returns {boolean}
1054
+ */
1055
+ export function cancel(kind, targetId, reason = '', states = null, options = {}) {
1056
+ if (states !== null && states !== undefined && states.length === 0) {
1057
+ return false; // 无可取消态:直接 no-op
1058
+ }
1059
+ const scope = (states === null || states === undefined)
1060
+ ? ACTIVE_IN : states.map((s) => `'${s}'`).join(',');
1061
+ return withWriteDb(options, (handle) => {
1062
+ const row = handle.prepare(
1063
+ 'SELECT id, meta FROM wait_items WHERE kind=? AND target_id=?'
1064
+ + ` AND state IN (${scope})`,
1065
+ ).get(kind, String(targetId));
1066
+ if (row === undefined) return false;
1067
+ const info = handle.prepare(
1068
+ "UPDATE wait_items SET state='cancelled', ended_at=?"
1069
+ + ` WHERE id=? AND state IN (${scope})`,
1070
+ ).run(nowStr(), row.id);
1071
+ if (Number(info.changes) !== 1) {
1072
+ return false; // 读-写之间被并发落终态:不复活也不改写终态行
1073
+ }
1074
+ if (reason) mergeMetaOn(handle, row, { cancel_reason: reason });
1075
+ return true;
1076
+ });
1077
+ }
1078
+
1079
+ /**
1080
+ * 清空全部活跃等待项(返回条数;waitq.py:600)。
1081
+ *
1082
+ * `excludeKind`(str 或数组):answer/msg 已切权威(重启存活),recover 传
1083
+ * `[KIND_ANSWER, KIND_MSG]` 保留两类行(`runner.recover` 按表重建内存单元);task/card 仍
1084
+ * 随重启清空。P4 起 recover 不再使用(waiting 全量存活、starting 按类收口,R1)——函数
1085
+ * 保留供 selfcheck / 未来对账复用。
1086
+ *
1087
+ * ⚠ 事务形态照 Python:逐行 `cancel`(**每次一个独立事务**,不整体包一层)。
1088
+ *
1089
+ * @param {string} [reason]
1090
+ * @param {string|string[]|null} [excludeKind]
1091
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1092
+ * @returns {number}
1093
+ */
1094
+ export function cancelAllActive(reason = '', excludeKind = null, options = {}) {
1095
+ const excludes = new Set(typeof excludeKind === 'string'
1096
+ ? [excludeKind] : (excludeKind || []));
1097
+ let n = 0;
1098
+ for (const row of activeItems(null, options)) {
1099
+ if (excludes.has(row.kind)) continue;
1100
+ if (cancel(row.kind, row.target_id, reason, null, options)) n += 1;
1101
+ }
1102
+ return n;
1103
+ }
1104
+
1105
+ // --------------------------------------------------------------------------
1106
+ // 写口 · 行证据与心跳(waitq.py:619-699;v3c:证据/心跳迁到行,v3 §2.4,裁决 R2/R11)
1107
+ // --------------------------------------------------------------------------
1108
+ // last_seen=最近一次心跳时间戳、evidence=证据 JSON 文本(desc/reason/pid 登记证据 +
1109
+ // busy=…(poll|sse) 心跳证据)。写入口唯一:本段两函数 + enterRunning 的重开清证据;
1110
+ // 读口:evidencePid(进程证据)与 unitVerdict(对账/自检判活)。
1111
+
1112
+ /**
1113
+ * 行 evidence JSON 合入 patch → 序列化文本(waitq.py:624)。
1114
+ *
1115
+ * 坏 JSON / 非对象按空对象处理;列初值为空串(`db.migrate` 默认)等价空对象。
1116
+ *
1117
+ * @param {object} row `{evidence}` 行
1118
+ * @param {object|null} [patch]
1119
+ * @returns {string} JSON 文本(`pyJsonDumps` 口径)
1120
+ */
1121
+ function evidenceText(row, patch = null) {
1122
+ let cur;
1123
+ try {
1124
+ cur = JSON.parse(row.evidence || '{}');
1125
+ if (cur === null || typeof cur !== 'object' || Array.isArray(cur)) cur = {};
1126
+ } catch {
1127
+ cur = {};
1128
+ }
1129
+ Object.assign(cur, patch || {});
1130
+ return pyJsonDumps(cur);
1131
+ }
1132
+
1133
+ /**
1134
+ * 行心跳(waitq.py:639):刷新活跃行 last_seen;evidence 非空时合入行 evidence JSON
1135
+ * (键 `evidence` / `evidence_at`)。
1136
+ *
1137
+ * 刷新面=**活跃行**(等待/起始/运行/收尾):终态行不打点(心跳只对在场条目有意义,收口后
1138
+ * 的行不复活);无活跃行返回 `false`(不抛)。调用面:board 调和器(5s 节拍,仅对在管卡,
1139
+ * `busy=1/0(poll)`)与 SSE 钩子(`busy=1(sse)`)。
1140
+ *
1141
+ * @param {string} kind
1142
+ * @param {string|number} targetId
1143
+ * @param {string} [evidence]
1144
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1145
+ * @returns {boolean}
1146
+ */
1147
+ export function touchUnit(kind, targetId, evidence = '', options = {}) {
1148
+ const now = Date.now() / 1000; // 照 Python time.time()(epoch 秒浮点)
1149
+ return withWriteDb(options, (handle) => {
1150
+ const row = handle.prepare(
1151
+ 'SELECT id, evidence FROM wait_items WHERE kind=? AND target_id=?'
1152
+ + ` AND state IN (${ACTIVE_IN})`,
1153
+ ).get(kind, String(targetId));
1154
+ if (row === undefined) return false;
1155
+ const patch = evidence ? { evidence, evidence_at: now } : null;
1156
+ handle.prepare('UPDATE wait_items SET last_seen=?, evidence=? WHERE id=?')
1157
+ .run(now, evidenceText(row, patch), row.id);
1158
+ return true;
1159
+ });
1160
+ }
1161
+
1162
+ /**
1163
+ * 登记型证据写入(waitq.py:662;`card_started` 的 desc / reason / pid 登记面):合入活跃行
1164
+ * evidence JSON(同键覆盖)。
1165
+ *
1166
+ * 无活跃行或 patch 为空返回 `false`(幂等 no-op)。**不动 last_seen**——心跳归 `touchUnit`;
1167
+ * 判活读口只认活跃行,故无活跃行时写入无意义。
1168
+ *
1169
+ * @param {string} kind
1170
+ * @param {string|number} targetId
1171
+ * @param {object} patch
1172
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1173
+ * @returns {boolean}
1174
+ */
1175
+ export function markEvidence(kind, targetId, patch, options = {}) {
1176
+ if (!hasEntries(patch)) return false;
1177
+ return withWriteDb(options, (handle) => {
1178
+ const row = handle.prepare(
1179
+ 'SELECT id, evidence FROM wait_items WHERE kind=? AND target_id=?'
1180
+ + ` AND state IN (${ACTIVE_IN})`,
1181
+ ).get(kind, String(targetId));
1182
+ if (row === undefined) return false;
1183
+ handle.prepare('UPDATE wait_items SET evidence=? WHERE id=?')
1184
+ .run(evidenceText(row, patch), row.id);
1185
+ return true;
1186
+ });
1187
+ }
1188
+
1189
+ /**
1190
+ * 行 evidence 的 pid 读取口(waitq.py:683;R11④ 进程证据;无行/无 pid/坏 JSON → `null`)。
1191
+ *
1192
+ * v3c 起读行 evidence(`card_started` 写入)——CLI 族卡起跑成功后必有;web 族/拾取窗口
1193
+ * 无 pid 落 unknown(宁多等不误杀)。`bool` 不算 pid(Python `isinstance(True, int)` 为真,
1194
+ * 显式排除;JS 侧 `true` 本就不是 number,天然排除)。
1195
+ *
1196
+ * 已知差异(文件头差异⑩一族):Python `isinstance(pid, int)` 把 JSON 浮点字面量(`1.0`)
1197
+ * 判为非 pid,JS 侧 `1.0` 与 `1` 同值 ⇒ 会当 pid 用;平台只写 `str(int)` 形状,不可达。
1198
+ *
1199
+ * @param {object|null} row 行对象(`evidence` 列)
1200
+ * @returns {number|null}
1201
+ */
1202
+ export function evidencePid(row) {
1203
+ if (row === null || row === undefined) return null;
1204
+ let ev;
1205
+ try {
1206
+ ev = JSON.parse(row.evidence || '{}');
1207
+ } catch {
1208
+ return null;
1209
+ }
1210
+ if (ev === null || typeof ev !== 'object' || Array.isArray(ev)) return null;
1211
+ const pid = ev.pid;
1212
+ return (typeof pid === 'number' && Number.isInteger(pid)) ? pid : null;
1213
+ }
1214
+
1215
+ // --------------------------------------------------------------------------
1216
+ // 写口 · 自检与对账(waitq.py:702-963;P1 骨架 selfcheck 纯报告;v3c 全切行口径)
1217
+ // --------------------------------------------------------------------------
1218
+
1219
+ /**
1220
+ * 纯 DB 不变量自检,返回问题描述清单(空列表=健康;waitq.py:708)。
1221
+ *
1222
+ * 检查项(v3c 行口径,裁决 R12):
1223
+ * ① 同 `(kind,target)` 多条活跃等待项(唯一索引失效/绕过);
1224
+ * ② 活跃态(starting/running/finishing,v2d T3 扩面)**超龄且无可证明证据**
1225
+ * (`claimed_at` 越过 `CLAIM_STALE_S` 且判活三态非 alive、`last_seen` 心跳也不再
1226
+ * 新鲜)——可疑泄漏,**纯报告面**:行不因超龄被改写(可证死才自动收口的判据在
1227
+ * `unitVerdict`/`selfcheckUnits`,R11 边界沿用)。running/finishing 覆盖 force 落表行
1228
+ * (行留队构成运行前缀)与收尾瞬态;正常持轮会话有 pid 证据或 5s 心跳在场,不误报。
1229
+ * `claimed_at IS NULL` 的行不参与 ②(等待行本就长期在场:排队卡/消息/答案,超龄对其
1230
+ * 无意义);
1231
+ * ③ **I1 违约报告**(R12 新增,纯报告只告警不改行):卡片不在「正在开发」容器却有活跃
1232
+ * `c:` 行(阻塞/待审核/已完成/待开发一律出队,v3 §2.1 I1);卡行已删同列报告(行
1233
+ * 无主)。经唯一出队原语迁移的路径行先终态、列后搬,故稳态不报(仅搬列窗口的毫秒级
1234
+ * 瞬态可被 60s 自检撞见,无害)。
1235
+ *
1236
+ * ext 行(v2d T4 外部条目)不参与 ②(`claimed_at` 恒空——外部直跑不由平台启动、非认领
1237
+ * 行);其生灭归调和器/入队刷新按项目活跃会话集合对账,**且全程不在 ③ 的扫描集内**——
1238
+ * ③ 只取 `kind=KIND_CARD` 的活跃行。反向形态「占位在、行不在」**不在本函数面**:它要读
1239
+ * `board_cards` 的排队占位语义,归 `board._heal_queue_placeholders` 的 30s 节拍。
1240
+ *
1241
+ * @param {((row: object) => string|null)|null} [probe] 行判活探针(web busy 三态;
1242
+ * `selfcheckUnits` 透传,直调缺省 null)
1243
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1244
+ * @returns {string[]}
1245
+ */
1246
+ export function selfcheck(probe = null, options = {}) {
1247
+ const problems = [];
1248
+ const now = Date.now() / 1000;
1249
+ const cutoff = localTimeStr(now - CLAIM_STALE_S);
1250
+ return withReadDb(options, (handle) => {
1251
+ const readOptions = { db: handle };
1252
+ for (const r of handle.prepare(
1253
+ 'SELECT kind, target_id, COUNT(*) n FROM wait_items'
1254
+ + ` WHERE state IN (${ACTIVE_IN})`
1255
+ + ' GROUP BY kind, target_id HAVING n > 1',
1256
+ ).all()) {
1257
+ problems.push(`等待项重复: kind=${r.kind} target=${r.target_id} n=${r.n}`);
1258
+ }
1259
+ const stale = handle.prepare(
1260
+ 'SELECT * FROM wait_items'
1261
+ + " WHERE state IN ('starting','running','finishing')"
1262
+ + ' AND claimed_at IS NOT NULL AND claimed_at < ?',
1263
+ ).all(cutoff);
1264
+ const crow = handle.prepare(
1265
+ 'SELECT id, target_id, state FROM wait_items WHERE kind=?'
1266
+ + ` AND state IN (${ACTIVE_IN})`,
1267
+ ).all(KIND_CARD);
1268
+ // 卡列只取活跃 c: 行涉及的卡(60s 自检不做全表扫描;空集免查)
1269
+ const ids = [...new Set(crow.map((r) => r.target_id))].sort();
1270
+ const cards = new Map();
1271
+ if (ids.length) {
1272
+ const placeholders = ids.map(() => '?').join(',');
1273
+ for (const r of handle.prepare(
1274
+ `SELECT id, column_key FROM board_cards WHERE id IN (${placeholders})`,
1275
+ ).all(...ids.map(bindable))) {
1276
+ cards.set(String(r.id), r.column_key);
1277
+ }
1278
+ }
1279
+ for (const r of stale) {
1280
+ if (unitVerdict(r, probe, readOptions) === 'alive') {
1281
+ continue; // 有可证明证据(探针/内建 DB/pid 存活)
1282
+ }
1283
+ if (r.last_seen && now - r.last_seen < CLAIM_STALE_S) {
1284
+ continue; // 心跳新鲜:在管打点即活性佐证
1285
+ }
1286
+ problems.push(`${r.state} 超龄无证据: id=${r.id} kind=${r.kind}`
1287
+ + ` target=${r.target_id} claimed_at=${r.claimed_at}`);
1288
+ }
1289
+ for (const r of crow) { // ③ I1 违约报告(只告警不改行)
1290
+ const col = cards.get(r.target_id);
1291
+ if (col === undefined) {
1292
+ problems.push(`I1 违约: 卡行已删却有活跃 c: 行 id=${r.id}`
1293
+ + ` target=${r.target_id} state=${r.state}`);
1294
+ } else if (col !== 'doing') {
1295
+ problems.push(`I1 违约: 卡 ${r.target_id} 不在正在开发容器`
1296
+ + `(column=${col})却有活跃 c: 行 id=${r.id} state=${r.state}`);
1297
+ }
1298
+ }
1299
+ return problems;
1300
+ });
1301
+ }
1302
+
1303
+ /**
1304
+ * 行年龄(秒,自最近一次活动;waitq.py:781):`last_seen` 心跳优先,回落 `claimed_at` /
1305
+ * `created_at`(文本时间戳,同库口径)。
1306
+ *
1307
+ * 解析失败按 0(=**最年轻**,与「刚活动过的新生行」同侧):唯一消费面 `selfcheckUnits`
1308
+ * 的判据是「`unitAge(row) < min_age` → 跳过处置与告警」(年龄豁免),0 落豁免侧——宁可
1309
+ * 晚一拍再处置,也不对无时间戳的行抢先收口(v3 终审修复:原文写「视为最老,不误豁免」,
1310
+ * 与代码相反)。
1311
+ *
1312
+ * @param {object} row
1313
+ * @returns {number}
1314
+ */
1315
+ function unitAge(row) {
1316
+ const now = Date.now() / 1000;
1317
+ if (row.last_seen) return Math.max(0.0, now - Number(row.last_seen));
1318
+ const ts = row.claimed_at || row.created_at;
1319
+ try {
1320
+ return Math.max(0.0, now - parseLocalSeconds(ts));
1321
+ } catch {
1322
+ return 0.0; // ValueError/TypeError/OverflowError 同侧
1323
+ }
1324
+ }
1325
+
1326
+ /**
1327
+ * 可证死活跃行收口(启动对账 / 周期自检共用,v3c;waitq.py:798):起步前(waiting/
1328
+ * starting)→ cancelled;起步后(running/finishing)→ failed(error=reason)。
1329
+ *
1330
+ * 分流口径承 `runner.recover` 的按行收口映射(「服务重启中断」同款:未起跑的行本就是取消
1331
+ * 语义、已起跑的行按失败收口);两者都是一次 rowcount 守卫的终态写入(终态行不再被改写,
1332
+ * 与行状态机「出队即终态」一致)。命中即打一行日志(含 reason 标签——reason 同时落行
1333
+ * meta)。返回是否命中收口。
1334
+ *
1335
+ * @param {object} row 活跃行
1336
+ * @param {string} reason
1337
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1338
+ * @returns {boolean}
1339
+ */
1340
+ function closeDeadUnit(row, reason, options = {}) {
1341
+ const hit = (row.state === WAITING || row.state === STARTING)
1342
+ ? cancel(row.kind, row.target_id, reason, null, options)
1343
+ : markFailed(row.id, reason, options);
1344
+ if (hit) {
1345
+ console.log(`[waitq] 收口可证死行 ${memberKey(row.kind, row.target_id)}(${reason})`);
1346
+ }
1347
+ return hit;
1348
+ }
1349
+
1350
+ /**
1351
+ * 单条活跃行的判活(宁多等不误放行):返回 `"alive"`/`"dead"`/`"unknown"`
1352
+ * (waitq.py:818)。
1353
+ *
1354
+ * 判据次序(v3c 行口径):
1355
+ * ① 注入探针(会话 busy 一类外部证据;waitq 不 import board/dshdriver,维持纯 DB 模块
1356
+ * 边界)给出确活 → alive;② 探针判死**不采信**,降级 unknown(裁决 R11a:web 会话
1357
+ * idle/不在场是跨轮间隙常态,判死即误杀串行位);③ 探针缺席/失败/不明 → 内建 DB 证据
1358
+ * 兜底(任务行状态、消息行状态、卡行存在性、**行 evidence 的 pid 进程存活**——R11
1359
+ * 清单①②③④⑤);④ 皆无结论 → unknown。
1360
+ *
1361
+ * **`t:`/`m:` 对应任务/消息行 queued 一律 unknown 不证死**(评审 Important-1):queued 是
1362
+ * 「拾取 → 行置 running」新生窗口的天然形态,与行龄无关——做旧的 queued 残留由 recover
1363
+ * 放回/重建后重新拾起,无需以收口清场;可证死只认**终态行/行缺失/pid 进程退**(均非瞬态
1364
+ * 证据)。`ext:` 行(外部条目,v2d T4)只认「卡行已删」一支——外部会话实况归调和器按
1365
+ * 项目活跃会话集合对账,本函数不裁其活。
1366
+ *
1367
+ * @param {object} row 活跃行
1368
+ * @param {((row: object) => string|null)|null} probe
1369
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1370
+ * @returns {'alive'|'dead'|'unknown'}
1371
+ */
1372
+ function unitVerdict(row, probe, options = {}) {
1373
+ if (probe !== null && probe !== undefined) {
1374
+ let v = null;
1375
+ try {
1376
+ v = probe(row);
1377
+ } catch {
1378
+ v = null; // 探测失败=状态不明,走内建
1379
+ }
1380
+ if (v === 'alive') return 'alive';
1381
+ if (v === 'dead') return 'unknown'; // R11a:探针判死不擅动
1382
+ }
1383
+ const kind = row.kind;
1384
+ const target = row.target_id;
1385
+ if (kind === KIND_TASK) {
1386
+ const tid = targetInt(target);
1387
+ const t = tid === null ? null : getTask(tid, options);
1388
+ if (t === null) return 'dead'; // 任务行已删/引用不可解析(§7.2 ②)
1389
+ if (t.status === 'running') return 'alive';
1390
+ return t.status === 'queued' ? 'unknown' : 'dead'; // queued=新生窗口
1391
+ }
1392
+ if (kind === KIND_MSG) {
1393
+ const m = msgGet(target, options);
1394
+ if (m === null) return 'dead'; // 消息行缺失(§7.2 ③)
1395
+ if (m.state === 'running') return 'alive';
1396
+ return m.state === 'queued' ? 'unknown' : 'dead'; // queued=新生窗口
1397
+ }
1398
+ if (kind === KIND_CARD || kind === KIND_ANSWER) {
1399
+ const cid = targetInt(target);
1400
+ const card = cid === null ? null : getBoardCard(cid, options);
1401
+ if (card === null) return 'dead'; // 卡行已删/引用不可解析(§7.2 ①)
1402
+ // R11④ 进程证据(CLI 族卡起跑成功后由 card_started 写入行 evidence.pid,v3c 起读行):
1403
+ // 进程已退即可证死(启动对账收口 CLI 卡残留的主路径),存活即确活;pid 复用的误判
1404
+ // 方向是「确活」=宁多等。无 pid(拾取窗口/web 族)落 unknown——宁多等不误杀。
1405
+ const pid = evidencePid(row);
1406
+ if (pid !== null) return pidAlive(pid) ? 'alive' : 'dead';
1407
+ return 'unknown';
1408
+ }
1409
+ if (kind === KIND_EXT) {
1410
+ // 外部条目:卡行已删即无主(可证死);其余交调和器按实况集合对账
1411
+ const cid = targetInt(target);
1412
+ const card = cid === null ? null : getBoardCard(cid, options);
1413
+ return card === null ? 'dead' : 'unknown';
1414
+ }
1415
+ return 'unknown';
1416
+ }
1417
+
1418
+ /**
1419
+ * 启动对账(v3c 起行口径;waitq.py:877,设计 §7.1/R11):按**活跃等待项行**逐条判活;
1420
+ * 可证死 → 行收口(reason 标签「启动对账:可证明失效」);活/未知 → 保留。返回处置清单
1421
+ * `[{project_id, key, kind, verdict, closed}]`;卡/任务的用户可见收尾沿用既有路径
1422
+ * (调和器归位/interrupted),本函数只管队列层。
1423
+ *
1424
+ * 调用顺序硬约束:必须在 `runner.recover` → `board.recover` 之后跑——board.recover 已把
1425
+ * busy web 卡的行置 running 并重建在管条目,提前对账会把在跑的会话误判。新生窗口豁免
1426
+ * 落在 `unitVerdict`。**事务形态照 Python**:逐行各自独立事务(不整体包一层)。
1427
+ *
1428
+ * @param {((row: object) => string|null)|null} [probe]
1429
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1430
+ * @returns {{project_id: number, key: string, kind: string, verdict: string, closed: boolean}[]}
1431
+ */
1432
+ export function reconcileUnits(probe = null, options = {}) {
1433
+ const out = [];
1434
+ for (const row of activeItems(null, options)) {
1435
+ const verdict = unitVerdict(row, probe, options);
1436
+ let closed = false;
1437
+ if (verdict === 'dead') closed = closeDeadUnit(row, '启动对账:可证明失效', options);
1438
+ out.push({
1439
+ project_id: row.project_id,
1440
+ key: memberKey(row.kind, row.target_id),
1441
+ kind: row.kind,
1442
+ verdict,
1443
+ closed,
1444
+ });
1445
+ }
1446
+ return out;
1447
+ }
1448
+
1449
+ /**
1450
+ * 周期自检处置(v3c 起行口径;waitq.py:903,设计 §7.2/R11):仅对**可证明失效**的活跃行
1451
+ * 自动收口并返回告警/动作清单;状态不明只告警(R11/R11a:运行期 web idle 不判死——跨轮
1452
+ * 间隙误杀面,权威收口路径(`_finish_run` / 出队原语)在进程内可靠)。
1453
+ *
1454
+ * `minAge`:**行年龄豁免**(秒)——行自最近一次活动(last_seen 心跳回落 claimed_at/
1455
+ * created_at)距今不足 minAge 的跳过处置与告警(拾取/建行 → 行置 running 稍后的毫秒窗口,
1456
+ * 防误杀新生行,与判据内的新生窗口豁免互补防御)。缺省 0=不豁免(纯函数旧语义)。
1457
+ *
1458
+ * `managed`:**平台在管豁免判据**(v3c 修复轮 Important-2;`board.unit_managed` 经
1459
+ * `runner.start_unit_selfcheck` 注入,缺省 null=不豁免)——行所属单元仍在平台在管条目
1460
+ * (`_RUNS`)时,其收尾归巡视 `_finish_run`(R4 收尾归属),自检**不越权收口**:否则
1461
+ * CLI 卡 agent 进程刚退出(行 evidence.pid 已退=可证死形态)、巡视 30s 节拍未到时,自检会
1462
+ * 把一次成功会话的行抢先标 `failed`(污染作为调度权威的审计行,并瞬时造出「卡在 doing 却
1463
+ * 无活跃行」的 I1 违约自报);`card_finished` 门禁 (running, finishing) 届时已见终态行,
1464
+ * 错误标签定死。豁免只作用于**收口面**:`_RUNS` 无条目的卡死行(自检是唯一恢复路径)照常
1465
+ * 收口,不因本守卫停滞。
1466
+ *
1467
+ * 告警面(v3c 修复轮 Important-1)只覆盖**平台认领的在跑行**:waiting 行(排队单元/积压卡
1468
+ * ——天然无证据,属常态)与 `ext:` 行(外部条目生灭归调和器)不告警——逐条遍历会把 N 条
1469
+ * 积压变成每 60s N 行日志,违背「健康时静默」并淹没 R12 报告;`dead` 收口面不受此限
1470
+ * (waiting 泄漏行照常收口)。waiting 行同样免探针(无会话可探,且探针在其上只会把 dead
1471
+ * 降级 unknown——省 N 次 REST)。R12 的报告扩展(I1 违约报告、「活跃行超龄且无可证明
1472
+ * 证据」)在 `selfcheck()` 并入返回——**报告项只告警不改行**,自动收口只走 `dead` 一支。
1473
+ *
1474
+ * @param {((row: object) => string|null)|null} [probe]
1475
+ * @param {number} [minAge]
1476
+ * @param {((row: object) => boolean)|null} [managed]
1477
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1478
+ * @returns {string[]}
1479
+ */
1480
+ export function selfcheckUnits(probe = null, minAge = 0.0, managed = null, options = {}) {
1481
+ const problems = [];
1482
+ for (const row of activeItems(null, options)) {
1483
+ if (minAge > 0 && unitAge(row) < minAge) {
1484
+ continue; // 年龄豁免:新生行不动不告警
1485
+ }
1486
+ // waiting 行免探针(见 docstring);其余行按注入探针裁活
1487
+ const verdict = unitVerdict(row, row.state === WAITING ? null : probe, options);
1488
+ const key = memberKey(row.kind, row.target_id);
1489
+ if (verdict === 'dead') {
1490
+ if (managed !== null && managed !== undefined && managed(row)) {
1491
+ continue; // 平台在管:收尾归巡视(R4),自检不越权收口
1492
+ }
1493
+ closeDeadUnit(row, '周期自检:可证明失效', options);
1494
+ problems.push(`自检收口可证死行: ${key}`);
1495
+ } else if (verdict === 'unknown' && row.state !== WAITING && row.kind !== KIND_EXT) {
1496
+ problems.push(`行状态不明(仅告警): ${key}`);
1497
+ }
1498
+ }
1499
+ problems.push(...selfcheck(probe, options)); // 纯报告项并入(含 I1 违约)
1500
+ return problems;
1501
+ }
1502
+
1503
+ /**
1504
+ * 回收 `wait_items` 终态行(P6,裁决 R14;waitq.py:950):done/failed/cancelled 且
1505
+ * `ended_at` 距今超 keepSec 秒 → 删;活跃行不动。返回删除条数。
1506
+ *
1507
+ * 量级小、口径保守(默认 86400s);挂进 `runner.start_unit_selfcheck` 周期尾部(蹭既有
1508
+ * 线程,零新线程)。终态行只承载审计/排查看板,超窗删除不影响任何拾取/位次口径(读侧
1509
+ * 全部只认活跃行)。
1510
+ *
1511
+ * @param {number} [keepSec]
1512
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1513
+ * @returns {number} 删除条数
1514
+ */
1515
+ export function pruneFinished(keepSec = PRUNE_FINISHED_KEEP_S, options = {}) {
1516
+ const cutoff = localTimeStr(Date.now() / 1000 - Number(keepSec));
1517
+ return withWriteDb(options, (handle) => {
1518
+ const info = handle.prepare(
1519
+ "DELETE FROM wait_items WHERE state IN ('done','failed','cancelled')"
1520
+ + ' AND ended_at IS NOT NULL AND ended_at <= ?',
1521
+ ).run(cutoff);
1522
+ return Number(info.changes);
1523
+ });
1524
+ }
1525
+
1526
+ // --------------------------------------------------------------------------
1527
+ // 写口 · 卡片等待项原子操作(waitq.py:966-1139;P4 排队态单写:设计 §9)
1528
+ // --------------------------------------------------------------------------
1529
+ // 落点裁决(P4 计划 R6):路线图「board.py enqueue_card」指调用面;跨表原子只能在单连接
1530
+ // 单事务内做,故实现放本模块。`board_cards` 不在三表唯一写路径禁令内(禁令只锁
1531
+ // wait_items/chat_msgs 两表),本段是唯一允许写卡片占位的非 board 函数。
1532
+
1533
+ /**
1534
+ * 卡片的活跃 `c:` 行(无则 `null`;持连接调用——v3a 入队门禁共用读口;waitq.py:974)。
1535
+ * @param {object} handle
1536
+ * @param {string} target
1537
+ * @returns {object|null}
1538
+ */
1539
+ function activeCardRowOn(handle, target) {
1540
+ const row = handle.prepare(
1541
+ 'SELECT id, state, seq FROM wait_items WHERE kind=? AND target_id=?'
1542
+ + ` AND state IN (${ACTIVE_IN})`,
1543
+ ).get(KIND_CARD, target);
1544
+ return row === undefined ? null : { ...row };
1545
+ }
1546
+
1547
+ /**
1548
+ * 卡片排队占位投影(条件写:已处 `doing+queue` 时不改写——保 answer 排队路径的空
1549
+ * block_text;否则置 `doing+queue` 并清 `scheduled_at`;waitq.py:981)。
1550
+ *
1551
+ * @param {object} handle
1552
+ * @param {string} target 卡 id(TEXT 参数,SQLite 按列亲和转整数比对)
1553
+ * @param {string} blockText
1554
+ */
1555
+ function projectCardQueueOn(handle, target, blockText) {
1556
+ handle.prepare(
1557
+ "UPDATE board_cards SET column_key='doing', block_kind='queue',"
1558
+ + ' block_text=?, scheduled_at=NULL, updated_at=?'
1559
+ + " WHERE id=? AND NOT (block_kind='queue' AND column_key='doing')",
1560
+ ).run(bindable(blockText), nowStr(), bindable(target));
1561
+ }
1562
+
1563
+ /**
1564
+ * 卡片入队单事务体(`enqueueCard` / `insertCardAfterPrefix` 共用;v3a 幂等门禁;
1565
+ * waitq.py:991)。
1566
+ *
1567
+ * 位次由调用方给定几何(`seqSql`/`seqArgs`)。返回等待项 id。已有活跃 `c:` 行 → **复用**
1568
+ * (不插第二行,位次不动):
1569
+ * - waiting 行:维持现状语义(复用 + 占位投影照常);
1570
+ * - starting/running/finishing 行(v3a:起跑证实后跨轮存活的运行行):只复用,**不回退
1571
+ * 行态、也不写「排队等待」占位**——卡在跑而非排队,写占位会让展示与事实相反(旧口径
1572
+ * 起跑即终态化,此处撞到的是「无活跃行」,该分支是 v3a 新形态)。
1573
+ *
1574
+ * 无活跃行 → 插 waiting 行 + 占位投影(行+占位同事务,P4 单写原子红线)。并发安全:
1575
+ * INSERT 优先、活跃唯一索引兜底(撞索引=并发下已有活跃行 → 重查复用,与 `enqueue`/
1576
+ * `insertAfterPrefix` 同 idiom)。
1577
+ *
1578
+ * @param {object} handle 写连接
1579
+ * @param {string} target 卡 id 字符串
1580
+ * @param {number} projectId
1581
+ * @param {string} seqSql 位次 SQL 片段(`?` 或 `(SELECT COALESCE(MAX(seq),0)+1 …)`)
1582
+ * @param {unknown[]} seqArgs 位次片段占位参数(按序插在 target 之后)
1583
+ * @param {{extra?: string, fromColumn?: string, blockText?: string}} [opts]
1584
+ * @returns {number} 等待项 id
1585
+ */
1586
+ function cardEnqueueOn(handle, target, projectId, seqSql, seqArgs, opts = {}) {
1587
+ const { extra = '', fromColumn = '', blockText = CARD_QUEUE_TEXT } = opts;
1588
+ let row = activeCardRowOn(handle, target);
1589
+ let itemId;
1590
+ if (row === null) {
1591
+ let inserted = false;
1592
+ try {
1593
+ const info = handle.prepare(
1594
+ 'INSERT INTO wait_items (project_id, kind, target_id, state, seq,'
1595
+ + ` created_at, meta) VALUES (?,?,?,'waiting', ${seqSql},?,?)`,
1596
+ ).run(bindable(projectId), KIND_CARD, target, ...seqArgs.map(bindable), nowStr(),
1597
+ pyJsonDumps({
1598
+ extra: String(extra || ''),
1599
+ from_column: String(fromColumn || ''),
1600
+ }));
1601
+ itemId = Number(info.lastInsertRowid);
1602
+ inserted = true;
1603
+ } catch (err) {
1604
+ if (!isIntegrityError(err)) throw err;
1605
+ row = activeCardRowOn(handle, target);
1606
+ if (row === null) throw err; // 非活跃唯一索引冲突:上抛,交给调用方
1607
+ itemId = row.id;
1608
+ }
1609
+ if (inserted) { // 照 Python 的 try/else:无异常才写占位并返回
1610
+ projectCardQueueOn(handle, target, blockText);
1611
+ return itemId;
1612
+ }
1613
+ }
1614
+ itemId = row.id;
1615
+ if (row.state === WAITING) projectCardQueueOn(handle, target, blockText);
1616
+ return itemId;
1617
+ }
1618
+
1619
+ /**
1620
+ * 卡片入队(board 排队路径唯一写入口;waitq.py:1030):一个事务内写 card 等待项行 +
1621
+ * `board_cards` 占位投影——「占位在、行不在 / 行在、占位不在」从结构上消失。
1622
+ *
1623
+ * 幂等门禁(v3a,I5 同一卡至多一条活跃 `c:` 行):同类同目标已有活跃行 → 复用(同
1624
+ * `enqueue`,位次不动);其中 waiting 行照常落占位投影,starting/running/finishing 行
1625
+ * (跨轮存活的运行行)只复用、不回退行态也不写排队占位(见 `cardEnqueueOn`)。
1626
+ * `meta={"extra": 打回意见, "from_column": 排队前列}`——打回意见随 meta 持久化(不再依赖
1627
+ * 内存暂存,重启不再丢),from_column 供起跑失败回列(P4 计划裁决 R7)。
1628
+ *
1629
+ * @param {number|string} cardId
1630
+ * @param {number} projectId
1631
+ * @param {string} [extra]
1632
+ * @param {string} [fromColumn]
1633
+ * @param {string} [blockText]
1634
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1635
+ * @returns {number} 等待项 id
1636
+ */
1637
+ export function enqueueCard(cardId, projectId, extra = '', fromColumn = '',
1638
+ blockText = CARD_QUEUE_TEXT, options = {}) {
1639
+ const target = String(cardId);
1640
+ return withWriteDb(options, (handle) => cardEnqueueOn(
1641
+ handle, target, projectId,
1642
+ '(SELECT COALESCE(MAX(seq),0)+1 FROM wait_items)', [],
1643
+ { extra, fromColumn, blockText },
1644
+ ));
1645
+ }
1646
+
1647
+ /**
1648
+ * 卡片入队「运行前缀后」(续跑/手动恢复专用写入口,v2b T2 裁决 R9;waitq.py:1049):
1649
+ * 一个事务内写 card 等待项行(位次=前缀后/等待区最前,与 `insertAfterPrefix` 同几何
1650
+ * ——`afterPrefixSeqOn` 唯一出处)+ `board_cards` 占位投影。
1651
+ *
1652
+ * v2a 子批终审契约(绑定):续跑切换插入点前必须先落本原子变体——行与占位分两事务会留
1653
+ * 崩溃窗口「行在、占位不在」,合法行随后被「占位失效」摘除(P4 单写原子红线)。幂等
1654
+ * (复用既有行、位次不动——重复按开始不提前)、占位条件写(保 answer 路径空 block_text)、
1655
+ * meta 持久化(extra/from_column)语义与 `enqueueCard` 逐字一致(含 v3a 幂等门禁:已有运行
1656
+ * 行只复用不回退),仅位次不同。返回等待项 id。
1657
+ *
1658
+ * @param {number|string} cardId
1659
+ * @param {number} projectId
1660
+ * @param {string} [extra]
1661
+ * @param {string} [fromColumn]
1662
+ * @param {string} [blockText]
1663
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1664
+ * @returns {number} 等待项 id
1665
+ */
1666
+ export function insertCardAfterPrefix(cardId, projectId, extra = '', fromColumn = '',
1667
+ blockText = CARD_QUEUE_TEXT, options = {}) {
1668
+ const target = String(cardId);
1669
+ return withWriteDb(options, (handle) => {
1670
+ const seqNew = afterPrefixSeqOn(handle, projectId);
1671
+ return cardEnqueueOn(handle, target, projectId, '?', [seqNew],
1672
+ { extra, fromColumn, blockText });
1673
+ });
1674
+ }
1675
+
1676
+ /**
1677
+ * force 落表(v2b T4,裁决 R12;waitq.py:1070):一个事务内插「前缀尾」
1678
+ * (`afterPrefixSeqOn` 几何=最后一个运行中条目之后)+ **直入 starting**(out-of-band 立即
1679
+ * 启动,不经 worker 拾取)——行从不以 waiting 形态出现,结构上消除「worker 路过见 waiting
1680
+ * `c:` 行而占位失效摘除」的拾取竞态窗(force 卡无 `doing+queue` 占位,两段式 insert+claim
1681
+ * 留该窗)。起跑证实由调用方 `enterRunning`;会话结束由 `card_finished` 终态化(行即成员
1682
+ * 期间构成运行前缀、位次计入)。
1683
+ *
1684
+ * v3a 幂等门禁:已有活跃 `c:` 行 → **复用**(返回其 `[id, seq]`,行态与位次都不动)——
1685
+ * 旧口径按编程错误上抛。force 前调用方已 `cancelCardWait` 清残留,再撞=并发 force /
1686
+ * 起跑证实后的跨轮存活行,此时重复插行会撞活跃唯一索引、把行打回 starting 又会破坏「行即
1687
+ * 条目」语义。返回 `[row_id, seq_new]`。
1688
+ *
1689
+ * @param {number|string} cardId
1690
+ * @param {number} projectId
1691
+ * @param {object|null} [meta]
1692
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1693
+ * @returns {[number, number]}
1694
+ */
1695
+ export function insertCardForceStart(cardId, projectId, meta = null, options = {}) {
1696
+ const target = String(cardId);
1697
+ return withWriteDb(options, (handle) => {
1698
+ const row = activeCardRowOn(handle, target);
1699
+ if (row !== null) return [row.id, row.seq];
1700
+ const seqNew = afterPrefixSeqOn(handle, projectId);
1701
+ try {
1702
+ const info = handle.prepare(
1703
+ 'INSERT INTO wait_items (project_id, kind, target_id, state, seq,'
1704
+ + ' created_at, claimed_at, claimed_by, meta)'
1705
+ + " VALUES (?,?,?,'starting',?,?,?,'force',?)",
1706
+ ).run(bindable(projectId), KIND_CARD, target, seqNew, nowStr(), nowStr(),
1707
+ pyJsonDumps(meta || {}));
1708
+ return [Number(info.lastInsertRowid), seqNew];
1709
+ } catch (err) {
1710
+ if (!isIntegrityError(err)) throw err;
1711
+ const again = activeCardRowOn(handle, target);
1712
+ if (again === null) throw err; // 非活跃唯一索引冲突:上抛,交给调用方
1713
+ return [again.id, again.seq];
1714
+ }
1715
+ });
1716
+ }
1717
+
1718
+ /**
1719
+ * 卡片出队(board 取消路径唯一写入口;waitq.py:1104):一个事务内取消 card 等待项行 +
1720
+ * 清占位投影。双侧 rowcount 守卫:行已终态、或占位已被调用方改写(如 `move_card` 先落了
1721
+ * manual 阻塞)则对应侧 no-op——不复活、不覆盖新状态。返回等待项是否命中取消(占位清除
1722
+ * 与否不影响返回值)。
1723
+ *
1724
+ * @param {number|string} cardId
1725
+ * @param {string} [reason]
1726
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1727
+ * @returns {boolean}
1728
+ */
1729
+ export function cancelCardWait(cardId, reason = '', options = {}) {
1730
+ const target = String(cardId);
1731
+ return withWriteDb(options, (handle) => {
1732
+ const row = handle.prepare(
1733
+ 'SELECT id, meta FROM wait_items WHERE kind=? AND target_id=?'
1734
+ + ` AND state IN (${ACTIVE_IN})`,
1735
+ ).get(KIND_CARD, target);
1736
+ let hit = false;
1737
+ if (row !== undefined) {
1738
+ const info = handle.prepare(
1739
+ "UPDATE wait_items SET state='cancelled', ended_at=?"
1740
+ + ` WHERE id=? AND state IN (${ACTIVE_IN})`,
1741
+ ).run(nowStr(), row.id);
1742
+ if (Number(info.changes) === 1) {
1743
+ hit = true;
1744
+ if (reason) mergeMetaOn(handle, row, { cancel_reason: reason });
1745
+ }
1746
+ }
1747
+ handle.prepare(
1748
+ "UPDATE board_cards SET block_kind=NULL, block_text=''"
1749
+ + " WHERE id=? AND block_kind='queue'",
1750
+ ).run(bindable(target));
1751
+ return hit;
1752
+ });
1753
+ }
1754
+
1755
+ /**
1756
+ * 仅写占位投影(answer 排队路径专用:等待单元是 answer 行,不建 card 行;waitq.py:1131)。
1757
+ * 无条件置 `doing+queue`(与原作答排队分支语义等价;仅 `done_at` 不清这一不可达态差异
1758
+ * ——原分支同样不清,列归位由送达执行体兜底)。
1759
+ *
1760
+ * @param {number|string} cardId
1761
+ * @param {string} [blockText]
1762
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1763
+ * @returns {void}
1764
+ */
1765
+ export function setCardWaitPlaceholder(cardId, blockText = '', options = {}) {
1766
+ return withWriteDb(options, (handle) => {
1767
+ handle.prepare(
1768
+ "UPDATE board_cards SET column_key='doing', block_kind='queue',"
1769
+ + ' block_text=?, updated_at=? WHERE id=?',
1770
+ ).run(bindable(blockText), nowStr(), String(cardId));
1771
+ });
1772
+ }
1773
+
1774
+ // --------------------------------------------------------------------------
1775
+ // 写口 · 外部条目 ext 行(waitq.py:1142-1171;v2d T4,裁决 R13)
1776
+ // --------------------------------------------------------------------------
1777
+
1778
+ /**
1779
+ * 外部条目落表(waitq.py:1144):单事务插「运行前缀尾」(`afterPrefixSeqOn` 几何=最后一个
1780
+ * 运行中条目之后)+ **直入 running**——外部直跑会话不由平台启动,入场即视为在运行(行即
1781
+ * 成员构成运行前缀、位次计入「前面还有几个」;v2 §2.2【已定】)。
1782
+ *
1783
+ * target=看板卡 id、meta 带 sid;claimed_at 留空(无平台认领——selfcheck 的活跃态超龄无
1784
+ * 证据判据只覆盖被 claim 过的行,外部条目不算泄漏)。复数并存(多外部会话各自一行);并发
1785
+ * 安全:INSERT 优先、活跃唯一索引兜底——撞活跃唯一索引说明并发下已有活跃行(`upsert_ext`
1786
+ * 的 check-then-insert 窗口),重查复用其行(与 `enqueue`/`insertAfterPrefix` 同 idiom);
1787
+ * 其他冲突仍上抛。返回 `[row_id, seq_new]`。
1788
+ *
1789
+ * @param {number} projectId
1790
+ * @param {number|string} cardId
1791
+ * @param {string} [sid]
1792
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1793
+ * @returns {[number, number]}
1794
+ */
1795
+ export function insertExt(projectId, cardId, sid = '', options = {}) {
1796
+ const target = String(cardId);
1797
+ return withWriteDb(options, (handle) => {
1798
+ try {
1799
+ const seqNew = afterPrefixSeqOn(handle, projectId);
1800
+ const info = handle.prepare(
1801
+ 'INSERT INTO wait_items (project_id, kind, target_id, state, seq,'
1802
+ + ' created_at, meta)'
1803
+ + " VALUES (?,?,?,'running',?,?,?)",
1804
+ ).run(bindable(projectId), KIND_EXT, target, seqNew, nowStr(),
1805
+ pyJsonDumps({ sid: String(sid || '') }));
1806
+ return [Number(info.lastInsertRowid), seqNew];
1807
+ } catch (err) {
1808
+ if (!isIntegrityError(err)) throw err;
1809
+ const row = handle.prepare(
1810
+ 'SELECT id, seq FROM wait_items WHERE kind=? AND target_id=?'
1811
+ + ` AND state IN (${ACTIVE_IN})`,
1812
+ ).get(KIND_EXT, target);
1813
+ if (row === undefined) throw err; // 非活跃唯一索引冲突:上抛,交给调用方
1814
+ return [row.id, row.seq];
1815
+ }
1816
+ });
1817
+ }
1818
+
1819
+ // --------------------------------------------------------------------------
1820
+ // 消息(chat_msgs)读写(waitq.py:1174-1342)
1821
+ // --------------------------------------------------------------------------
1822
+ // 会话消息的状态权威(chat 内存登记转正);chat_msgs.state 用消息侧口径
1823
+ // queued|running|done|error|cancelled,与 wait_items.state(waiting|starting|…)分属两套
1824
+ // 状态机:等待项管「队列拾取互斥」,消息行管「用户可见生命周期」。
1825
+
1826
+ /**
1827
+ * 会话消息入队(`chat.submit` 专用写入口;waitq.py:1179):一个事务内写 chat_msgs 行
1828
+ * (`state=queued`,毫秒 epoch 时间戳)+ msg 等待项行(meta 携带执行体重建载荷
1829
+ * ——family/model 提交时快照与 comment_id,见 P3 计划裁决 R1)。
1830
+ *
1831
+ * `queue=false`(P5 R13):跳过等待项行、只写 chat_msgs——runner 单例缺位的同步路径专用
1832
+ * (消息不经队列、无拾取方,等待项只会成为无人认领的孤儿行)。
1833
+ *
1834
+ * msg_id 主键保证唯一(同 id 重复提交视为编程错误,约束错上抛);「先写行后补内存键」
1835
+ * 纪律由调用方保证(`chat.submit` 先本函数再 `inst.submit_msg`)。位次(2026-09-25 对齐
1836
+ * v2 §2.2):等待项落「最后一个运行中条目之后」且排在同项目已排队用户动作行(`m:`/`a:`)
1837
+ * 之后(`msgInsertSeqOn` 几何)——不再是全局队尾 `MAX(seq)+1`;消息优先于等待区卡片/任务,
1838
+ * 同项目消息间保持到达序 FIFO。
1839
+ *
1840
+ * @param {string} msgId
1841
+ * @param {number} projectId
1842
+ * @param {string} sid
1843
+ * @param {string} message
1844
+ * @param {number|null} [taskId]
1845
+ * @param {number|null} [cardId]
1846
+ * @param {boolean} [inject]
1847
+ * @param {object|null} [meta]
1848
+ * @param {boolean} [queue] false = 只写 chat_msgs、不建等待项
1849
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1850
+ * @returns {number|null} 等待项 id(`queue=false` 时 null)
1851
+ */
1852
+ export function msgEnqueue(msgId, projectId, sid, message, taskId = null, cardId = null,
1853
+ inject = false, meta = null, queue = true, options = {}) {
1854
+ const nowMs = Date.now();
1855
+ return withWriteDb(options, (handle) => {
1856
+ handle.prepare(
1857
+ 'INSERT INTO chat_msgs (id, project_id, sid, task_id, card_id,'
1858
+ + ' message, state, error, inject, created_at)'
1859
+ + " VALUES (?,?,?,?,?,?,'queued','',?,?)",
1860
+ ).run(String(msgId), bindable(projectId), bindable(sid), bindable(taskId),
1861
+ bindable(cardId), bindable(message), inject ? 1 : 0, nowMs);
1862
+ if (!queue) return null;
1863
+ const seqNew = msgInsertSeqOn(handle, projectId);
1864
+ const info = handle.prepare(
1865
+ 'INSERT INTO wait_items (project_id, kind, target_id, state, seq,'
1866
+ + ' created_at, meta)'
1867
+ + " VALUES (?,?,?,'waiting',?,?,?)",
1868
+ ).run(bindable(projectId), KIND_MSG, String(msgId), seqNew, nowStr(),
1869
+ pyJsonDumps(meta || {}));
1870
+ return Number(info.lastInsertRowid);
1871
+ });
1872
+ }
1873
+
1874
+
1875
+
1876
+ /**
1877
+ * 按 id 取消息行(无则 null;终态未回收也算——注入端点归属校验依赖)。
1878
+ *
1879
+ * 空 id(`''`/null/undefined)返回 null(照 Python `if not msg_id`)。
1880
+ *
1881
+ * @param {string} msgId
1882
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接(`db.js` 读路径契约;见 `db.js`)
1883
+ * @returns {object|null}
1884
+ */
1885
+ export function msgGet(msgId, options = {}) {
1886
+ return withReadDb(options, (handle) => msgGetOn(handle, msgId));
1887
+ }
1888
+
1889
+ /** `msgGet` 的持连接版本 */
1890
+ function msgGetOn(handle, msgId) {
1891
+ if (!msgId) return null;
1892
+ return plainRow(handle.prepare('SELECT * FROM chat_msgs WHERE id=?')
1893
+ .get(bindable(String(msgId))));
1894
+ }
1895
+
1896
+ /**
1897
+ * 消息等待项 meta(取该 target 的**最后一行**,含终态行):飞书回流钩子在终态收口
1898
+ * 之后读「提交时定格」的载荷(feishu 命名空间)。无行返回 `{}`,坏 JSON 亦 `{}`。
1899
+ *
1900
+ * 只读口:口径上不看 state(收口已把行落终态,活跃口径读不到),也不 join
1901
+ * chat_msgs——载荷只在等待项行上(chat_msgs 无 meta 列)。
1902
+ *
1903
+ * @param {string} msgId
1904
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接(`db.js` 读路径契约;见 `db.js`)
1905
+ * @returns {object}
1906
+ */
1907
+ export function msgMeta(msgId, options = {}) {
1908
+ return withReadDb(options, (handle) => msgMetaOn(handle, msgId));
1909
+ }
1910
+
1911
+ /** `msgMeta` 的持连接版本 */
1912
+ function msgMetaOn(handle, msgId) {
1913
+ const row = handle.prepare(
1914
+ 'SELECT meta FROM wait_items WHERE kind=? AND target_id=? ORDER BY id DESC LIMIT 1',
1915
+ ).get(KIND_MSG, bindable(String(msgId)));
1916
+ if (row === undefined) return {};
1917
+ let meta;
1918
+ try {
1919
+ meta = JSON.parse(row.meta || '{}');
1920
+ } catch {
1921
+ return {}; // 坏 JSON 按空对象(Python ValueError 分支)
1922
+ }
1923
+ // Python 侧非 dict 的 JSON(如 `[]`/`3`)也会原样返回;这里对齐成「非对象即空」
1924
+ // 是**有意收紧**:调用方一律 `meta.get(...)`,Node 侧非对象取属性会静默给 undefined
1925
+ // 而不报错——收紧成 {} 让两侧同为「读不到键」。
1926
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return {};
1927
+ return meta;
1928
+ }
1929
+
1930
+ /**
1931
+ * 消息行查询(chat 读侧专用):sid/cardId/states 可选过滤,`created_at, id` 升序。
1932
+ *
1933
+ * `states` 传数组(Python 传元组);空数组/未传 = 不过滤(照 Python `if states:`)。
1934
+ * 同毫秒按 id tie-break(P4 必带⑥)。
1935
+ *
1936
+ * @param {{sid?: string|null, cardId?: number|string|null, states?: string[]|null}} [opts]
1937
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接(`db.js` 读路径契约;见 `db.js`)
1938
+ * @returns {object[]}
1939
+ */
1940
+ export function msgRows({ sid = null, cardId = null, states = null } = {}, options = {}) {
1941
+ let sql = 'SELECT * FROM chat_msgs WHERE 1=1';
1942
+ const args = [];
1943
+ if (sid !== null && sid !== undefined) {
1944
+ sql += ' AND sid=?';
1945
+ args.push(sid);
1946
+ }
1947
+ if (cardId !== null && cardId !== undefined) {
1948
+ sql += ' AND card_id=?';
1949
+ args.push(cardId);
1950
+ }
1951
+ if (states && states.length) {
1952
+ sql += ` AND state IN (${states.map(() => '?').join(',')})`;
1953
+ args.push(...states);
1954
+ }
1955
+ sql += ' ORDER BY created_at, id'; // 同毫秒按 id tie-break
1956
+ return withReadDb(options, (handle) => plainRows(handle.prepare(sql).all(...args.map(bindable))));
1957
+ }
1958
+
1959
+ /**
1960
+ * 有**排队中**消息的卡片 id 集合(`waitq.py:1261` msg_queued_card_ids;看板「排队中」
1961
+ * 徽标批量数据源)。仅 `state='queued'` 且 `card_id` 非空。
1962
+ *
1963
+ * 元素是 **number**(照 Python:`{r["card_id"] for r in …}`,列为 INTEGER)。
1964
+ * M3 时本函数落在 `chat.js:queuedCardIds` 且返回**字符串**集合(调用方 `board.js`
1965
+ * 用 `has(String(id))` 自洽,因此 HTTP 层看不出差异);M4 归位到这里并改回 int——
1966
+ * 「值即契约」:`chat.queued_card_ids()` 在 Python 里正是本函数的直通壳
1967
+ * (`chat.py:438`),Node 侧同样让 `chat.queuedCardIds` 委托本函数(保留导出名,
1968
+ * 兼容 M3 的 `board.js` 调用面)。
1969
+ *
1970
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 读连接
1971
+ * @returns {Set<number>}
1972
+ */
1973
+ export function msgQueuedCardIds(options = {}) {
1974
+ return withReadDb(options, (handle) => {
1975
+ const out = new Set();
1976
+ for (const row of handle.prepare(
1977
+ "SELECT DISTINCT card_id FROM chat_msgs WHERE state='queued' AND card_id IS NOT NULL",
1978
+ ).all()) {
1979
+ if (row.card_id === null || row.card_id === undefined) continue;
1980
+ out.add(Number(row.card_id));
1981
+ }
1982
+ return out;
1983
+ });
1984
+ }
1985
+
1986
+ /**
1987
+ * `queued → running`(原子守卫;`started_at`=毫秒;waitq.py:1269)。worker 执行体与
1988
+ * `inject_now` 的状态迁移点(队列级互斥另有等待项 `claim`,两层守卫互补)。
1989
+ *
1990
+ * @param {string} msgId
1991
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
1992
+ * @returns {boolean}
1993
+ */
1994
+ export function msgClaim(msgId, options = {}) {
1995
+ return withWriteDb(options, (handle) => {
1996
+ const info = handle.prepare(
1997
+ "UPDATE chat_msgs SET state='running', started_at=?"
1998
+ + " WHERE id=? AND state='queued'",
1999
+ ).run(Date.now(), String(msgId));
2000
+ return Number(info.changes) === 1;
2001
+ });
2002
+ }
2003
+
2004
+ /**
2005
+ * `running → queued`(`inject_now` 投递失败退回;`started_at` 清零,同原内存语义;
2006
+ * waitq.py:1280)。
2007
+ *
2008
+ * @param {string} msgId
2009
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2010
+ * @returns {boolean}
2011
+ */
2012
+ export function msgRequeue(msgId, options = {}) {
2013
+ return withWriteDb(options, (handle) => {
2014
+ const info = handle.prepare(
2015
+ "UPDATE chat_msgs SET state='queued', started_at=NULL"
2016
+ + " WHERE id=? AND state='running'",
2017
+ ).run(String(msgId));
2018
+ return Number(info.changes) === 1;
2019
+ });
2020
+ }
2021
+
2022
+ /**
2023
+ * `running → done/error`(执行体终态;`ended_at`=毫秒;error 截 300 字与原内存一致;
2024
+ * waitq.py:1289)。
2025
+ *
2026
+ * @param {string} msgId
2027
+ * @param {string} state `done` 或 `error`
2028
+ * @param {string} [error]
2029
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2030
+ * @returns {boolean}
2031
+ */
2032
+ export function msgFinish(msgId, state, error = '', options = {}) {
2033
+ return withWriteDb(options, (handle) => {
2034
+ const info = handle.prepare(
2035
+ 'UPDATE chat_msgs SET state=?, error=?, ended_at=?'
2036
+ + " WHERE id=? AND state='running'",
2037
+ ).run(state, truncateChars(error, 300), Date.now(), String(msgId));
2038
+ return Number(info.changes) === 1;
2039
+ });
2040
+ }
2041
+
2042
+ /**
2043
+ * `queued → cancelled`(用户取消;queued 守卫=与 worker claim 的仲裁点;waitq.py:1299)。
2044
+ *
2045
+ * @param {string} msgId
2046
+ * @param {string} [error]
2047
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2048
+ * @returns {boolean}
2049
+ */
2050
+ export function msgCancel(msgId, error = '已取消', options = {}) {
2051
+ return withWriteDb(options, (handle) => {
2052
+ const info = handle.prepare(
2053
+ "UPDATE chat_msgs SET state='cancelled', error=?, ended_at=?"
2054
+ + " WHERE id=? AND state='queued'",
2055
+ ).run(bindable(error), Date.now(), String(msgId));
2056
+ return Number(info.changes) === 1;
2057
+ });
2058
+ }
2059
+
2060
+ /**
2061
+ * `queued/running → error`(recover 专用;waitq.py:1309):msg 重发非幂等,starting 不放回
2062
+ * waiting 只记败(P3 计划裁决 R3;error 经 `msgs_of_sid` 下发前端)。
2063
+ *
2064
+ * @param {string} msgId
2065
+ * @param {string} [error]
2066
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2067
+ * @returns {boolean}
2068
+ */
2069
+ export function msgRecoverFail(msgId, error = '服务重启中断', options = {}) {
2070
+ return withWriteDb(options, (handle) => {
2071
+ const info = handle.prepare(
2072
+ "UPDATE chat_msgs SET state='error', error=?, ended_at=?"
2073
+ + " WHERE id=? AND state IN ('queued','running')",
2074
+ ).run(bindable(error), Date.now(), String(msgId));
2075
+ return Number(info.changes) === 1;
2076
+ });
2077
+ }
2078
+
2079
+ /**
2080
+ * 回收终态消息行(chat 内存版等价迁移;`chat.submit` 调用;waitq.py:1320):
2081
+ * ① 终态(done/error/cancelled/yielded)且 `ended_at` 距今超 keepSec 秒 → 删;
2082
+ * ② 总行数超 maxRows → 按终态时间从旧到新删多余(活跃行不删)。
2083
+ * 等待项终态行不在本函数范围,由 `pruneFinished` 回收(P6,R14)。
2084
+ *
2085
+ * @param {number} keepSec
2086
+ * @param {number} maxRows
2087
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
2088
+ * @returns {number} 删除条数
2089
+ */
2090
+ export function msgPrune(keepSec, maxRows, options = {}) {
2091
+ const nowMs = Date.now();
2092
+ let n = 0;
2093
+ return withWriteDb(options, (handle) => {
2094
+ const info = handle.prepare(
2095
+ "DELETE FROM chat_msgs WHERE state IN"
2096
+ + " ('done','error','cancelled','yielded')"
2097
+ + ' AND ended_at IS NOT NULL AND ended_at <= ?',
2098
+ ).run(nowMs - Number(keepSec) * 1000);
2099
+ n += Number(info.changes);
2100
+ const extra = Number(handle.prepare('SELECT COUNT(*) n FROM chat_msgs').get().n)
2101
+ - Number(maxRows);
2102
+ if (extra > 0) {
2103
+ const ids = handle.prepare(
2104
+ "SELECT id FROM chat_msgs WHERE state IN"
2105
+ + " ('done','error','cancelled','yielded')"
2106
+ + ' ORDER BY ended_at LIMIT ?',
2107
+ ).all(extra).map((r) => r.id);
2108
+ const del = handle.prepare('DELETE FROM chat_msgs WHERE id=?');
2109
+ for (const id of ids) del.run(bindable(id));
2110
+ n += ids.length;
2111
+ }
2112
+ return n;
2113
+ });
2114
+ }