@gavinc-cn/touchstone-dsh 0.1.1 → 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 (90) hide show
  1. package/README.md +39 -11
  2. package/README.zh.md +25 -10
  3. package/backend/lib/access.js +44 -0
  4. package/backend/lib/agents.js +341 -0
  5. package/backend/lib/args.js +94 -0
  6. package/backend/lib/auth.js +195 -0
  7. package/backend/lib/board.js +6154 -0
  8. package/backend/lib/builtin-assets.js +1177 -0
  9. package/backend/lib/casescan.js +456 -0
  10. package/backend/lib/chat.js +2053 -0
  11. package/backend/lib/cookie.js +76 -0
  12. package/backend/lib/db.js +2260 -0
  13. package/backend/lib/dshdriver.js +1148 -0
  14. package/backend/lib/dshevents.js +851 -0
  15. package/backend/lib/export-cases.js +261 -0
  16. package/backend/lib/feishu.js +1738 -0
  17. package/backend/lib/feishu_cfg.js +228 -0
  18. package/backend/lib/feishu_conv.js +1484 -0
  19. package/backend/lib/feishu_device.js +428 -0
  20. package/backend/lib/feishu_inbound.js +1674 -0
  21. package/backend/lib/feishu_provision.js +855 -0
  22. package/backend/lib/feishu_rest.js +398 -0
  23. package/backend/lib/feishu_slash.js +568 -0
  24. package/backend/lib/feishu_ws.js +950 -0
  25. package/backend/lib/http.js +312 -0
  26. package/backend/lib/lib.js +973 -0
  27. package/backend/lib/loadcase.js +749 -0
  28. package/backend/lib/loadgen.js +1407 -0
  29. package/backend/lib/localbus.js +164 -0
  30. package/backend/lib/lock.js +405 -0
  31. package/backend/lib/proc.js +278 -0
  32. package/backend/lib/prompts.js +1021 -0
  33. package/backend/lib/pybase64.js +114 -0
  34. package/backend/lib/pyjson.js +214 -0
  35. package/backend/lib/query.js +125 -0
  36. package/backend/lib/rag.js +1310 -0
  37. package/backend/lib/runner.js +4520 -0
  38. package/backend/lib/servercaps.js +182 -0
  39. package/backend/lib/serverstate.js +237 -0
  40. package/backend/lib/sessparse.js +1176 -0
  41. package/backend/lib/sse.js +207 -0
  42. package/backend/lib/static.js +84 -0
  43. package/backend/lib/taskjson.js +302 -0
  44. package/backend/lib/waitq.js +2114 -0
  45. package/backend/lib/worktree.js +588 -0
  46. package/backend/lib/zstd-frames.js +218 -0
  47. package/backend/routes/admin.js +553 -0
  48. package/backend/routes/agents.js +149 -0
  49. package/backend/routes/assets.js +145 -0
  50. package/backend/routes/board.js +1220 -0
  51. package/backend/routes/feishu.js +269 -0
  52. package/backend/routes/feishu_app.js +215 -0
  53. package/backend/routes/feishu_hook.js +358 -0
  54. package/backend/routes/index.js +297 -0
  55. package/backend/routes/projects.js +1059 -0
  56. package/backend/routes/sessions.js +946 -0
  57. package/backend/routes/tasks.js +1530 -0
  58. package/backend/schema/fixtures/ancient.sql +189 -0
  59. package/backend/schema/fixtures/pre_alter.sql +177 -0
  60. package/backend/schema/migrations.json +154 -0
  61. package/backend/schema/schema.json +1451 -0
  62. package/backend/schema/schema.sql +216 -0
  63. package/backend/server.js +498 -0
  64. package/board.py +805 -72
  65. package/chat.py +590 -17
  66. package/db.py +356 -140
  67. package/dsh-plugin/cordis.patch.yml +7 -3
  68. package/dsh-plugin/install.sh +28 -11
  69. package/dsh-plugin/lib/agent-driver.js +710 -89
  70. package/dsh-plugin/lib/client.bundle.js +94 -9
  71. package/dsh-plugin/lib/index.js +529 -39
  72. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  73. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  74. package/dsh-plugin/src/client.js +94 -9
  75. package/dshdriver.py +74 -5
  76. package/dshevents.py +65 -3
  77. package/feishu.py +1197 -34
  78. package/feishu_conv.py +920 -0
  79. package/lifecycle.py +116 -13
  80. package/package.json +6 -1
  81. package/requirements.txt +2 -1
  82. package/server.py +218 -26
  83. package/sessparse.py +150 -44
  84. package/touchstone.py +6 -3
  85. package/waitq.py +23 -0
  86. package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
  87. package/webui/dist-plugin/assets/index-UrMyeAXF.js +484 -0
  88. package/webui/dist-plugin/index.html +2 -2
  89. package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
  90. package/webui/dist-plugin/assets/index-DtwKoi1T.js +0 -473
@@ -0,0 +1,164 @@
1
+ /**
2
+ * 进程内变更总线(`localbus.py` 91 行的 Node 对应物,Node 后端 M2)。
3
+ *
4
+ * 用途(与 Python 侧逐字同):看板/会话前端原先定时拉全量 payload;事件化后由本总线唤醒——
5
+ * 写路径(卡片增删改列、行收口)与 dsh 状态帧各发一次「值得重取」信号,前端在事件到达时
6
+ * 才重取,静置期零请求。
7
+ *
8
+ * 三条不变量(照抄 Python 模块头的设计要点,**改代码不得破坏**):
9
+ * ① **只发信号不带载荷**:topic = `['board', projectId]`,载荷恒空。携带增量需要维护
10
+ * 增量协议与丢失补发,而 payload 本身很小——「信号 + 客户端重取」最简且不会错;
11
+ * ② **序号单调**:每个 topic 一条从 1 开始的计数。订阅者记住自己上次看到的 seq,
12
+ * `wait()` 等到变化或超时(超时用于发 keepalive)。序号单调 ⇒ **漏唤醒只晚一拍,
13
+ * 不会永久丢失**;
14
+ * ③ **publish 绝不抛、O(1)**:发布点散布在 DB 写路径与 dsh 消费线程里,任何异常都必须
15
+ * 被吞掉(通知失败绝不能影响业务写)。
16
+ *
17
+ * 形态差异(Python → Node,唯一必要的差异,语义等价):
18
+ * - Python `wait(topic, since, timeout)` 是**条件变量阻塞**(`threading.Condition`);
19
+ * Node 是事件循环 ⇒ `wait(topic, since, timeoutMs)` 返回 **`Promise<number>`**,
20
+ * resolve 值是「唤醒/超时时刻该 topic 的当前序号」。调用方拿返回值与 `since` 比较即可
21
+ * 判断「有无变化」(超时时两者相等)——与 Python 返回值口径完全一致。
22
+ * - Python `notify_all()` 会唤醒**所有** topic 的等待者(被误唤醒者读回自己的 seq、值不变,
23
+ * 调用方本就按值判断);Node 只 settle 该 topic 的等待者。少一次无意义唤醒,观感一致。
24
+ * - topic 是数组(`['board', 7]`),而 Map/setTimeout 需要**稳定键** ⇒ 内部一律经
25
+ * `topicKey()` 归一为字符串;`stats().seq` 的键就是这个字符串键(Python 是元组键)。
26
+ * - `timeoutMs` 单位毫秒(Python 是秒);省略即无限等待(Python 的 `timeout` 是必填位置
27
+ * 参数,调用方一律会传)。
28
+ */
29
+ /** topic → 内部字符串键(JSON 文本;数组 topic 与字符串 topic 不会撞键)。
30
+ * 归一可能抛(如循环引用的 topic)——调用方 `publish()` 已把整段包进 try。 */
31
+ export function topicKey(topic) {
32
+ return JSON.stringify(topic);
33
+ }
34
+
35
+ /**
36
+ * 按 topic 计数的进程内变更总线(对齐 `localbus.ChangeBus`)。
37
+ *
38
+ * 内部状态:
39
+ * `_seq` Map<key, number> —— 每个 topic 的单调序号(未发布过视为 0);
40
+ * `_waiters` Set<{key, since, resolve, timer}> —— 当前挂起的等待者(诊断用计数即它的 size)。
41
+ */
42
+ export class ChangeBus {
43
+ constructor() {
44
+ this._seq = new Map();
45
+ this._waiters = new Set();
46
+ }
47
+
48
+ /**
49
+ * 发布一次变更:序号 +1 并唤醒该 topic 的等待者。**O(1) 且绝不抛**。
50
+ *
51
+ * 整段(含 topic 归一)包在 try 里:即使调用方递来无法序列化的 topic,也只是这一次通知
52
+ * 失败,业务写路径照常返回。
53
+ *
54
+ * @param {Array|string} topic
55
+ */
56
+ publish(topic) {
57
+ try {
58
+ const key = topicKey(topic);
59
+ this._seq.set(key, (this._seq.get(key) || 0) + 1);
60
+ this._wake(key);
61
+ } catch {
62
+ // noqa:通知失败绝不影响业务(对应 Python 侧的 except Exception: pass)
63
+ }
64
+ }
65
+
66
+ /**
67
+ * 当前序号(订阅者用它做「从这里之后的变化」的基准;未发布过为 0)。
68
+ *
69
+ * @param {Array|string} topic
70
+ * @returns {number}
71
+ */
72
+ seq(topic) {
73
+ return this._seq.get(topicKey(topic)) || 0;
74
+ }
75
+
76
+ /**
77
+ * 等到 `seq(topic) !== since` 或超时;resolve 当前序号。
78
+ *
79
+ * - 调用时就已经落后(`seq !== since`)⇒ **立即** resolve,不注册等待者;
80
+ * - 否则注册一个等待者,被 `publish` settle 或在 `timeoutMs` 后 settle(超时返回的序号
81
+ * 等于 `since`,调用方据此发 keepalive);
82
+ * - `timeoutMs` 省略/非有限值 ⇒ 无限等待(只可能被 publish 唤醒)。
83
+ *
84
+ * @param {Array|string} topic
85
+ * @param {number} since 调用方上次看到的序号
86
+ * @param {number} [timeoutMs]
87
+ * @returns {Promise<number>}
88
+ */
89
+ wait(topic, since, timeoutMs) {
90
+ const key = topicKey(topic);
91
+ const current = this._seq.get(key) || 0;
92
+ if (current !== since) return Promise.resolve(current);
93
+ return new Promise((resolve) => {
94
+ const entry = { key, since, resolve, timer: null };
95
+ if (typeof timeoutMs === 'number' && Number.isFinite(timeoutMs)) {
96
+ entry.timer = setTimeout(() => this._settle(entry), Math.max(0, timeoutMs));
97
+ }
98
+ this._waiters.add(entry);
99
+ });
100
+ }
101
+
102
+ /** 诊断(健康检查、测试断言用):`{topics, waiters, seq}`,seq 键为 `topicKey()` 口径 */
103
+ stats() {
104
+ const seq = {};
105
+ for (const [key, value] of this._seq) seq[key] = value;
106
+ return { topics: this._seq.size, waiters: this._waiters.size, seq };
107
+ }
108
+
109
+ /** 唤醒该 topic 上「序号已变化」的等待者(值未变的继续等,直到自己的超时) */
110
+ _wake(key) {
111
+ const current = this._seq.get(key) || 0;
112
+ for (const entry of [...this._waiters]) {
113
+ if (entry.key === key && entry.since !== current) this._settle(entry);
114
+ }
115
+ }
116
+
117
+ /** 结算一个等待者:清定时器 → 摘除 → resolve 当前序号(重复调用幂等) */
118
+ _settle(entry) {
119
+ if (!this._waiters.delete(entry)) return;
120
+ if (entry.timer) clearTimeout(entry.timer);
121
+ entry.resolve(this._seq.get(entry.key) || 0);
122
+ }
123
+ }
124
+
125
+ // 模块级单例:进程内唯一总线(server 与 db 共用)——对齐 Python 的 `BUS = ChangeBus()`
126
+ export const BUS = new ChangeBus();
127
+
128
+ /** 模块级发布(对齐 `localbus.publish`) */
129
+ export function publish(topic) {
130
+ BUS.publish(topic);
131
+ }
132
+
133
+ /** 模块级读序号(对齐 `localbus.seq`) */
134
+ export function seq(topic) {
135
+ return BUS.seq(topic);
136
+ }
137
+
138
+ /** 模块级等待(对齐 `localbus.wait`;返回 Promise 而非阻塞) */
139
+ export function wait(topic, since, timeoutMs) {
140
+ return BUS.wait(topic, since, timeoutMs);
141
+ }
142
+
143
+ /**
144
+ * 看板 topic 构造(单一出口,防各处拼错)。
145
+ *
146
+ * Python `board_topic` 走 `int(project_id)` 归一(`"9"` 与 `9` 同 topic);Node 用
147
+ * `Number()` 对齐(非数字得到 NaN,与 Python 的 ValueError 一样属于调用方错误)。
148
+ *
149
+ * @param {number|string} projectId
150
+ * @returns {Array} `['board', <number>]`
151
+ */
152
+ export function boardTopic(projectId) {
153
+ return ['board', Number(projectId)];
154
+ }
155
+
156
+ /**
157
+ * 单会话 topic(会话窗口事件流用;sid 为字符串,逐字符可比)。
158
+ *
159
+ * @param {string|number} sessionId
160
+ * @returns {Array} `['session', '<sid>']`
161
+ */
162
+ export function sessionTopic(sessionId) {
163
+ return ['session', String(sessionId)];
164
+ }
@@ -0,0 +1,405 @@
1
+ /**
2
+ * 同库单实例锁(Node 后端对 `lifecycle.SingleInstance` 的等价实现,M1)。
3
+ *
4
+ * 背景:Python 后端与 Node 后端共用 `~/.touchstone/touchstone.db`(或 `TOUCHSTONE_DB`
5
+ * 指定的库),同库双实例会双写任务/队列行。Python 侧用 `<db>.lock` 的
6
+ * `flock(LOCK_EX|LOCK_NB)` 强制互斥,第二个实例 **exit 3** 并打印占用者。
7
+ *
8
+ * Node 没有 stdlib `flock`,改用 **SQLite 自身的排它锁**、落在**同一个 `<db>.lock` 路径**
9
+ * 上(2026-10-11 实测,方案 §附录 C③ / 风险 R4):
10
+ * ① `new DatabaseSync(lockPath, { timeout: 0 })` + `PRAGMA busy_timeout=0`
11
+ * + `PRAGMA locking_mode=EXCLUSIVE`,再做一次写事务 ⇒ 排它锁**持有到连接关闭**
12
+ * (`locking_mode=EXCLUSIVE` 的语义:首次写入后不再释放文件锁);
13
+ * ② 第二个进程(Node 的 `node:sqlite` 或 Python 的 `sqlite3`)再试同一操作 ⇒ **立即**
14
+ * `database is locked`(Node:`code=ERR_SQLITE_ERROR errcode=5`;Python:`OperationalError`);
15
+ * ③ `kill -9` 持有者 ⇒ 内核释放文件锁,下一个进程随即拿到(**不需要任何清理动作**);
16
+ * ④ **跨语言互通**:flock 与 SQLite 锁互不相干,所以两边都要取 SQLite 锁才是真互斥
17
+ * ——本文件是 Node 侧那一半。
18
+ *
19
+ * 语义(逐条对齐 `lifecycle.SingleInstance`,行号见该文件 205-364):
20
+ * - 拿不到锁 ⇒ 读 `<db>.pid`(JSON `{pid, ppid, repo_dir, parent_watch, started_at}`;
21
+ * Node 版多写 `"backend": "node"` 便于排障);
22
+ * - 记录里 `parent_watch` 为真、其 `ppid` 已死、且 pid 命令行像本平台后端
23
+ * (`server.py` **或** `backend/server.js`)⇒ 判为**存量孤儿**,只向**该单个 pid**
24
+ * SIGTERM(绝不 killpg:孤儿常与宿主同组),在 `staleWaitMs` 内等它让出锁后**重试一次**;
25
+ * - 否则返回失败与同义文案,由 server.js 打印并以 **exit 3** 退出;
26
+ * - `--allow-shared-db`(`allowShared`)跳过整条锁流程,只打一条 warn;
27
+ * - 锁文件打不开/不可写 ⇒ **降级为无锁运行**(warn 后继续),绝不因锁问题让后端起不来;
28
+ * - `release()` 幂等:仅当 `.pid` 记录的 pid 仍是自己时删除记录,并关闭 SQLite 连接释放锁。
29
+ */
30
+ import fs from 'node:fs';
31
+ import os from 'node:os';
32
+ import path from 'node:path';
33
+ import { DatabaseSync } from 'node:sqlite';
34
+
35
+ import { isRunning, looksLikeBackend, pidAlive, terminatePid } from './proc.js';
36
+
37
+ /** 存量孤儿 SIGTERM 后等待其让出锁的上限(毫秒)。对齐 `lifecycle.STALE_WAIT = 5.0s` */
38
+ export const STALE_WAIT_MS = 5000;
39
+ /** 等待孤儿退出的轮询间隔(毫秒)。对齐 Python 侧 `time.sleep(0.1)` */
40
+ export const REAP_POLL_MS = 100;
41
+ /** 库路径缺省值(与 `db.py:DB_OVERRIDE` 的缺省逐字相同) */
42
+ export const DEFAULT_DB = '~/.touchstone/touchstone.db';
43
+ /** 锁库里的归属表名(内容仅供参考/排障:判据是 SQLite 的排它锁本身,不是表里那一行) */
44
+ const LOCK_TABLE = 'touchstone_instance_lock';
45
+
46
+ /** 同步 sleep 用的共享缓冲(Atomics.wait:主线程可用的同步等待,不占 CPU) */
47
+ const SLEEP_BUF = new Int32Array(new SharedArrayBuffer(4));
48
+
49
+ /** 同步睡眠(毫秒):收口等待是同步流程(对齐 Python 的 time.sleep) */
50
+ function sleepMs(ms) {
51
+ if (ms > 0) Atomics.wait(SLEEP_BUF, 0, 0, ms);
52
+ }
53
+
54
+ /**
55
+ * 数据库文件路径(对齐 `db.py:db_path()`:`TOUCHSTONE_DB` 覆盖,缺省 `~/.touchstone/touchstone.db`,
56
+ * 且对取值做 `~` 展开——展开口径与 `backend/lib/db.js:defaultDbPath` 的 `expandUser` 逐条相同)。
57
+ *
58
+ * **不变量:锁路径必须与「数据层真正打开的那个库」同源**(`<db>.lock` 锁错文件等于没锁)。
59
+ * Node 侧数据层的权威解析口是 `db.js:defaultDbPath()`(M1 已落地);本函数是**不依赖数据层**的
60
+ * 同口径实现,供入口与单测使用(放这里是为了让锁模块自包含、可脱离 schema 契约单独跑)。
61
+ * 两者对任意取值同口径(含 `TOUCHSTONE_DB=""`:按未设处理,与 `defaultDbPath` 的 `||` 一致;
62
+ * 该取值在 Python 侧会落到 `sqlite3.connect('')` 的临时库,本就不是可用配置)。
63
+ * M2 起 server.js 也可以直接改传
64
+ * `db.defaultDbPath()`,届时删除本函数即可(**不要**留两份各自演化的解析器)。
65
+ */
66
+ export function resolveDbPath({ env = process.env, home = os.homedir() } = {}) {
67
+ // 空串按「未设」处理(与 `db.js:defaultDbPath` 的 `||` 同口径):空串在 Python 侧会落到
68
+ // `sqlite3.connect('')` 的临时库(一个不可用的库),两侧都不该把它当成真路径。
69
+ const value = env && env.TOUCHSTONE_DB;
70
+ const raw = String(value ? value : DEFAULT_DB);
71
+ let expanded = raw;
72
+ if (raw === '~') expanded = home;
73
+ else if (raw.startsWith('~/') || raw.startsWith('~\\')) expanded = path.join(home, raw.slice(2));
74
+ return path.resolve(expanded);
75
+ }
76
+
77
+ /**
78
+ * 由库路径派生锁路径(对齐 `SingleInstance.__init__` 的两条 `+ ".lock"` / `+ ".pid"`)。
79
+ * `dbPath` 一律转绝对路径:Python 侧 `os.path.abspath(db_file)` 同款。
80
+ */
81
+ export function lockPaths(dbPath) {
82
+ const abs = path.resolve(String(dbPath));
83
+ return { dbPath: abs, lockPath: `${abs}.lock`, pidPath: `${abs}.pid` };
84
+ }
85
+
86
+ /** 读 `.pid` 归属记录:不存在/坏 JSON/不是对象 ⇒ null(对齐 Python `_read_record`) */
87
+ export function readPidRecord(pidPath) {
88
+ try {
89
+ const record = JSON.parse(fs.readFileSync(pidPath, 'utf8'));
90
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return null;
91
+ return record;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * 写 `.pid` 归属记录(临时文件 + rename 原子替换,对齐 Python `_write_record`)。
99
+ * 写不进去**不影响运行**(只是失去存量孤儿识别能力):记一条日志返回 false。
100
+ */
101
+ export function writePidRecord(pidPath, record, logger) {
102
+ const log = typeof logger === 'function' ? logger : () => {};
103
+ // 绝不覆写「活着的别家后端」的归属记录(2026-10-11 评审 P1):那是对方的排障凭据,
104
+ // 实测被覆盖并删除过一次(Node 起在同一库上,旧版 Python 实例的 `.pid` 就消失了)。
105
+ // 自己写自己(同 pid)允许——重启/重入路径要用。判活 + 命令行像后端,扛 PID 复用。
106
+ const existing = readPidRecord(pidPath);
107
+ const existingPid = existing ? Number(existing.pid) : 0;
108
+ if (existingPid > 0 && existingPid !== Number(record.pid)
109
+ && pidAlive(existingPid) && looksLikeBackend(existingPid)) {
110
+ log(`[lock] 已有活着的后端归属记录 pid=${existingPid},拒绝覆写 ${pidPath}`);
111
+ return false;
112
+ }
113
+ const tmp = `${pidPath}.tmp`;
114
+ try {
115
+ fs.writeFileSync(tmp, JSON.stringify(record), 'utf8');
116
+ fs.renameSync(tmp, pidPath);
117
+ return true;
118
+ } catch (error) {
119
+ log(`[lock] pid 记录写入失败(忽略): ${(error && error.message) || error}`);
120
+ return false;
121
+ }
122
+ }
123
+
124
+ /** 删 `.pid` 归属记录:不存在或删不掉都安全忽略(对齐 Python `_remove_record`) */
125
+ export function removePidRecord(pidPath) {
126
+ try {
127
+ fs.rmSync(pidPath, { force: true });
128
+ } catch {
129
+ /* 忽略:记录清理失败不影响锁(锁由内核在进程退出时释放) */
130
+ }
131
+ }
132
+
133
+ /**
134
+ * 父进程是否仍存活(对齐 `lifecycle.parent_alive`):ppid 非法或 <=1 视为父已死
135
+ * (POSIX 孤儿被 init 收养 ⇒ ppid=1;Windows 不收养,但 Node 侧同样以非法值兜底)。
136
+ */
137
+ export function parentAlive(ppid = process.ppid) {
138
+ const n = Number(ppid);
139
+ if (!Number.isInteger(n) || n <= 1) return false;
140
+ return pidAlive(n);
141
+ }
142
+
143
+ /** SQLITE_BUSY / "database is locked" 判定:区分「被别人占着」与「根本打不开」两类失败 */
144
+ export function isBusyError(error) {
145
+ if (!error) return false;
146
+ if (error.errcode === 5) return true; // SQLITE_BUSY
147
+ return /database (is|table is) locked|SQLITE_BUSY/i.test(String(error.message || ''));
148
+ }
149
+
150
+ /**
151
+ * 在 `<db>.lock` 上取 SQLite 排它锁(本模块的核心机制,已实测;见文件头 ①②③④)。
152
+ * 成功返回**持有锁的连接**(调用方必须一直持有到进程退出/释放:关连接即释放锁);
153
+ * 失败抛原错误(`isBusyError` 为真的 ⇒ 被占用;否则 ⇒ 打不开,调用方降级无锁)。
154
+ *
155
+ * 三件事的顺序不能变:`busy_timeout=0` 必须在任何语句前(否则会阻塞等待而不是立即报忙);
156
+ * `locking_mode=EXCLUSIVE` 必须在第一次写入前(写后再设就晚了,锁会在事务结束时放开)。
157
+ */
158
+ export function tryExclusiveLock(lockPath) {
159
+ const db = new DatabaseSync(lockPath, { timeout: 0 }); // 打开本身不加锁
160
+ try {
161
+ db.exec('PRAGMA busy_timeout=0'); // 立即失败,绝不阻塞在锁上
162
+ db.exec('PRAGMA locking_mode=EXCLUSIVE'); // 首写后锁不释放(持有到连接关闭)
163
+ db.exec('BEGIN IMMEDIATE');
164
+ db.exec(`CREATE TABLE IF NOT EXISTS ${LOCK_TABLE} `
165
+ + '(id INTEGER PRIMARY KEY CHECK (id = 1), pid INTEGER, backend TEXT, acquired_at INTEGER)');
166
+ db.exec(`INSERT OR REPLACE INTO ${LOCK_TABLE} (id, pid, backend, acquired_at) `
167
+ + `VALUES (1, ${process.pid}, 'node', ${Math.floor(Date.now() / 1000)})`);
168
+ db.exec('COMMIT');
169
+ return db;
170
+ } catch (error) {
171
+ try {
172
+ db.close();
173
+ } catch {
174
+ /* 关闭失败无妨:进程退出时内核会释放所有文件锁 */
175
+ }
176
+ throw error;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * 存量孤儿收口(对齐 `lifecycle.SingleInstance._reap_stale`,判定顺序逐条相同)。
182
+ * 返回被收口的 pid;未收口返回 0(记录过期 / 非父死感知托管 / 父仍活着 / 不像后端 / 等不到退出)。
183
+ *
184
+ * **只向该单个 pid 发 SIGTERM,绝不 killpg/杀进程组**:孤儿往往与它的宿主
185
+ * (甚至是正在启动我们的这个 dsh 进程)同组,整组终止会连带杀掉自己——Python 侧实测踩中。
186
+ */
187
+ export function reapStale({ record, staleWaitMs = STALE_WAIT_MS, logger } = {}) {
188
+ const log = typeof logger === 'function' ? logger : () => {};
189
+ if (!record || typeof record !== 'object') return 0;
190
+ const pid = Number(record.pid);
191
+ if (!Number.isInteger(pid) || pid <= 0 || !pidAlive(pid)) return 0; // 记录已过期:直接试锁
192
+ if (!record.parent_watch) return 0; // 非父死感知托管:绝不代杀
193
+ if (parentAlive(record.ppid)) return 0; // 其父还活着:真·另一实例
194
+ if (!looksLikeBackend(pid)) {
195
+ log(`[lock] pid 记录 ${pid} 不像本平台后端(PID 复用?),不处置`);
196
+ return 0;
197
+ }
198
+ log(`[lock] 发现存量孤儿后端 pid=${pid}(父已死),SIGTERM 收口…`);
199
+ terminatePid(pid); // 只杀单个 pid
200
+ const deadline = Date.now() + Math.max(Number(staleWaitMs) || 0, 0);
201
+ // 判活用 isRunning(僵尸算已退出):本循环是同步的(Atomics.wait 阻塞事件循环),
202
+ // 若孤儿恰好是本进程的子进程,父进程不 reap 它就一直 pidAlive=true,会白等满宽限。
203
+ // 僵尸不持有任何锁(内核已回收),按「已退出」处理既正确又不会误判存量实例。
204
+ while (isRunning(pid) && Date.now() < deadline) sleepMs(REAP_POLL_MS);
205
+ if (isRunning(pid)) {
206
+ log(`[lock] 孤儿 pid=${pid} 未在 ${staleWaitMs}ms 内退出,放弃收口`);
207
+ return 0;
208
+ }
209
+ log(`[lock] 存量孤儿 pid=${pid} 已收口`);
210
+ return pid;
211
+ }
212
+
213
+ /**
214
+ * 归属记录里的**活着的别家后端**(不是自己、进程存活、且命令行像后端)。返回记录或 null。
215
+ *
216
+ * 为什么需要这道额外检查(2026-10-11 评审 P1,**已在生产上实际发生过一次**):Node 只有
217
+ * SQLite 那道锁,而 M1 之前的 Python 实例只持 flock(Node 看不见)⇒ Node 能起在同一库上,
218
+ * 实测还覆写并删掉了生产实例的 `.pid`。`looksLikeBackend` 用来扛 PID 复用误判。
219
+ * @param {string} pidPath
220
+ * @returns {object|null}
221
+ */
222
+ export function liveForeignHolder(pidPath) {
223
+ const record = readPidRecord(pidPath);
224
+ if (!record) return null;
225
+ const pid = Number(record.pid);
226
+ if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return null;
227
+ if (!pidAlive(pid)) return null;
228
+ if (!looksLikeBackend(pid)) return null;
229
+ return record;
230
+ }
231
+
232
+ /** 锁文件不可用(非「被占用」)的拒绝文案:说清现象、危害与两条可行修复路径 */
233
+ export function unusableLockReason(lockPath, dbPath, error) {
234
+ return `单实例锁文件不可用:${lockPath}(库 ${dbPath})。原因:`
235
+ + `${(error && error.message) || error}。`
236
+ + '该文件应为 SQLite 库或 0 字节空文件;内容损坏/被换成目录时无法建立互斥,'
237
+ + '继续启动会与另一版后端同库双写(任务重复执行、队列行被抢)。'
238
+ + '修复:确认没有实例在跑之后把它移走再启动'
239
+ + `(mv ${lockPath} ${lockPath}.broken);`
240
+ + '或显式 --allow-shared-db 自担双写风险。';
241
+ }
242
+
243
+ /** 归属记录里已有活着的别家后端时的拒绝文案(含「未升级的旧版实例」这一真实成因) */
244
+ export function foreignHolderReason(record, dbPath, pidPath) {
245
+ return `同一数据库已有运行中的实例:pid=${record.pid} ppid=${record.ppid} `
246
+ + `repo=${record.repo_dir || '-'}(库 ${dbPath})。`
247
+ + '它可能是 M1 之前启动的旧版后端(只持 flock,本版看不见那道锁)。'
248
+ + '如需并行请用独立库(TOUCHSTONE_DB / 插件 extraEnv.TOUCHSTONE_DB),'
249
+ + `或先停掉它;确认记录已过期可删除 ${pidPath} 后重试。`;
250
+ }
251
+
252
+ /**
253
+ * 占用者明确时的失败文案(与 `lifecycle.py:339-344` **逐字同义**:
254
+ * pid/ppid/repo + 独立库出口 + `--allow-shared-db` 逃生口)。
255
+ */
256
+ export function instanceBusyReason(record, dbPath) {
257
+ const ppid = record.ppid === undefined || record.ppid === null ? '-' : record.ppid;
258
+ return `同一数据库已有运行中的实例:pid=${record.pid} ppid=${ppid} repo=${record.repo_dir || '-'}`
259
+ + `(库 ${dbPath})。如需并行请用独立库`
260
+ + `(TOUCHSTONE_DB / 插件 extraEnv.TOUCHSTONE_DB),`
261
+ + `或显式 --allow-shared-db 自担双写风险。`;
262
+ }
263
+
264
+ /**
265
+ * 锁被占但**没有归属记录**时的失败文案(与 Python `lifecycle.py` 的同名分支**同义**,
266
+ * 2026-10-11 两版对齐后的口径):
267
+ * - **绝不教用户「删除锁文件」**——删除会把 inode 换掉,新实例在新 inode 上取锁,
268
+ * 与仍在运行的占用者双写(flock 与 SQLite 锁同理),是危险的错误建议;
269
+ * - 只给两个安全出口:独立库(TOUCHSTONE_DB / 插件 extraEnv.TOUCHSTONE_DB)与
270
+ * `--allow-shared-db`(自担双写风险)——这也是 M1 对失败提示的硬要求。
271
+ * 可能出现的场景:另一版后端在「取锁成功、写 .pid 之前」被杀(Node 侧窗口),
272
+ * 或第三方进程占着同一把 SQLite 锁。
273
+ */
274
+ export function lockBusyReason(lockPath, dbPath) {
275
+ return `锁文件被占用且无归属记录:${lockPath}`
276
+ + `(库 ${dbPath})。占用者可能是另一版后端的异常窗口`
277
+ + `(取锁后、写 .pid 前被杀)或残留的第三方进程;`
278
+ + `如需并行请用独立库(TOUCHSTONE_DB / 插件 extraEnv.TOUCHSTONE_DB),`
279
+ + `或显式 --allow-shared-db 自担双写风险。`;
280
+ }
281
+
282
+ /**
283
+ * 取同库单实例锁(`lifecycle.SingleInstance.acquire` 的等价物)。
284
+ *
285
+ * @param {object} opts
286
+ * @param {string} opts.dbPath 库文件路径(锁 = `<dbPath>.lock`,记录 = `<dbPath>.pid`)
287
+ * @param {string} [opts.repoDir] 写进归属记录的仓库目录(排障用)
288
+ * @param {boolean} [opts.parentWatch] 本进程是否由父死感知托管(决定别家能否收口我们)
289
+ * @param {boolean} [opts.allowShared] `--allow-shared-db`:跳过整条锁流程
290
+ * @param {Function} [opts.logger] 日志口(缺省 stderr)
291
+ * @param {number} [opts.staleWaitMs] 存量孤儿等待上限(缺省 5s,单测可调小)
292
+ * @returns {{ok: boolean, reason: string, reapedPid: number, release: Function}}
293
+ * `ok=false` 时 `reason` 是给用户的完整中文说明(server.js 打印后 exit 3)。
294
+ */
295
+ export function acquireSingleInstance({
296
+ dbPath,
297
+ repoDir = '',
298
+ parentWatch = false,
299
+ allowShared = false,
300
+ logger,
301
+ staleWaitMs = STALE_WAIT_MS,
302
+ } = {}) {
303
+ const log = typeof logger === 'function' ? logger : (msg) => console.error(msg);
304
+ // dbPath 必填:漏传会让 path.resolve(undefined) 落在进程 CWD(`undefined.lock` 这种
305
+ // 脏产物实测出现过——锁错文件 = 等于没锁,必须在入口挡掉而不是静默继续)
306
+ if (typeof dbPath !== 'string' || !dbPath) {
307
+ throw new Error('acquireSingleInstance 需要 dbPath(库文件路径,锁与归属记录都由它派生)');
308
+ }
309
+ const paths = lockPaths(dbPath);
310
+ const state = { handle: null, held: false, reapedPid: 0 };
311
+
312
+ /**
313
+ * 释放锁与归属记录(幂等)。仅当**本次真的持锁**且 `.pid` 记录的 pid 仍是自己时才删记录
314
+ * (对齐 Python `release`;比它多一道 `held` 闸:失败/降级/共享模式下调用 release 也绝不
315
+ * 误删别人的记录)。关连接 = 释放 SQLite 锁;进程被 kill 时不需要走到这里(内核释放)。
316
+ */
317
+ const release = () => {
318
+ if (!state.held && state.handle === null) return;
319
+ const record = readPidRecord(paths.pidPath);
320
+ if (state.held && record && Number(record.pid) === process.pid) removePidRecord(paths.pidPath);
321
+ if (state.handle) {
322
+ try {
323
+ state.handle.close();
324
+ } catch {
325
+ /* 已关闭/已释放:忽略 */
326
+ }
327
+ state.handle = null;
328
+ }
329
+ state.held = false;
330
+ };
331
+
332
+ if (allowShared) {
333
+ log('[lock] --allow-shared-db:跳过同库单实例锁(自担双写风险)');
334
+ return { ok: true, reason: '', reapedPid: 0, release };
335
+ }
336
+
337
+ // 目录先建出来(对齐 Python `os.makedirs(dirname, exist_ok=True)`);建不出来不在这里报错,
338
+ // 下面打开锁文件时同样会失败,统一走「降级为无锁运行」分支。
339
+ try {
340
+ fs.mkdirSync(path.dirname(paths.dbPath), { recursive: true });
341
+ } catch {
342
+ /* 交给打开失败分支 */
343
+ }
344
+
345
+ let reaped = false;
346
+ for (;;) {
347
+ let handle = null;
348
+ try {
349
+ handle = tryExclusiveLock(paths.lockPath);
350
+ } catch (error) {
351
+ if (!isBusyError(error)) {
352
+ // **不再静默降级**(2026-10-11 评审 P1):锁文件是垃圾内容/是目录/不可读时,
353
+ // 降级放行 = 跨语言互斥无声消失(Node 没有 flock 可兜底),实测「先起 Python
354
+ // 再起 Node」两边都能就绪 ⇒ 同库双写。锁不可用属于**环境损坏**,必须显式拒绝
355
+ // 并给出可操作修复步骤;确要自担风险可显式 --allow-shared-db。
356
+ return { ok: false, reapedPid: 0, release,
357
+ reason: unusableLockReason(paths.lockPath, paths.dbPath, error) };
358
+ }
359
+ }
360
+ if (handle) {
361
+ // 拿到 SQLite 锁还不够:**M1 之前的 Python 实例只持 flock**(那道锁 Node 看不见),
362
+ // 所以还要看归属记录里有没有活着的别家后端。同库双写比「启动被拒」严重得多。
363
+ const holder = liveForeignHolder(paths.pidPath);
364
+ if (holder) {
365
+ try { handle.close(); } catch { /* 已关闭:忽略 */ }
366
+ return { ok: false, reapedPid: state.reapedPid, release,
367
+ reason: foreignHolderReason(holder, paths.dbPath, paths.pidPath) };
368
+ }
369
+ state.handle = handle;
370
+ state.held = true;
371
+ const wrote = writePidRecord(paths.pidPath, {
372
+ pid: process.pid,
373
+ ppid: process.ppid,
374
+ repo_dir: repoDir,
375
+ parent_watch: Boolean(parentWatch),
376
+ started_at: Math.floor(Date.now() / 1000),
377
+ backend: 'node', // Node 侧独有键:排障时一眼看出占用者版本
378
+ }, log);
379
+ if (!wrote && liveForeignHolder(paths.pidPath)) {
380
+ // 记录写不进去且里面是活着的别家:极窄的竞态窗口(另一个实例刚好在我们取锁后写记录)
381
+ state.held = false;
382
+ try { handle.close(); } catch { /* 忽略 */ }
383
+ state.handle = null;
384
+ return { ok: false, reapedPid: state.reapedPid, release,
385
+ reason: foreignHolderReason(liveForeignHolder(paths.pidPath), paths.dbPath,
386
+ paths.pidPath) };
387
+ }
388
+ return { ok: true, reason: '', reapedPid: state.reapedPid, release };
389
+ }
390
+
391
+ const record = readPidRecord(paths.pidPath);
392
+ if (record && !reaped) {
393
+ const reapedPid = reapStale({ record, staleWaitMs, logger: log });
394
+ if (reapedPid > 0) {
395
+ reaped = true; // 孤儿已收口:其锁随进程退出释放,重试一次
396
+ state.reapedPid = reapedPid;
397
+ continue;
398
+ }
399
+ }
400
+ const reason = record
401
+ ? instanceBusyReason(record, paths.dbPath)
402
+ : lockBusyReason(paths.lockPath, paths.dbPath);
403
+ return { ok: false, reason, reapedPid: state.reapedPid, release };
404
+ }
405
+ }