lark-relay 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/card.js +83 -60
- package/lib/claude.js +22 -12
- package/lib/collect.js +37 -7
- package/lib/dispatch.js +33 -12
- package/lib/filter.js +9 -4
- package/lib/larkcli.js +1 -1
- package/lib/lock.js +82 -25
- package/lib/paths.js +12 -6
- package/lib/store.js +24 -12
- package/lib/take.js +5 -0
- package/package.json +1 -1
package/lib/card.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
// card display:CardKit 流式打字机,单卡原地更新。
|
|
3
|
+
// card display:CardKit 流式打字机,单卡原地更新。
|
|
4
4
|
// 五步流:建卡实体 → 发引用消息 → 流式 PUT body → 收尾全卡重构 → 关流式定格。
|
|
5
5
|
//
|
|
6
6
|
// ⚠️ 两处双层编码(踩过):
|
|
@@ -64,17 +64,30 @@ class CardSession {
|
|
|
64
64
|
this.replyInThread = replyInThread
|
|
65
65
|
this.cardId = null
|
|
66
66
|
this.messageId = null
|
|
67
|
-
this.seq = 0 // int32 从 1
|
|
67
|
+
this.seq = 0 // int32 从 1 起
|
|
68
68
|
this.lastPutAt = 0
|
|
69
69
|
this.acc = ''
|
|
70
70
|
this.proc = ''
|
|
71
71
|
this.pendingSay = null
|
|
72
|
+
// 串行队列:所有卡片写操作排队执行。
|
|
73
|
+
// ⚠️ onEvent 里的 update() 是 fire-and-forget(不 await),若并发发出,
|
|
74
|
+
// 晚发的可能先到 → 服务端拒较小 sequence;更糟的是 in-flight 的 update
|
|
75
|
+
// (seq 小、body 大所以慢)在 finalize/stop 之后才到达 →
|
|
76
|
+
// **卡片从最终结论退回中间过程文本并定格在那里**(流式已关,不会再刷新)。
|
|
77
|
+
// 实测到达顺序曾是 finalize(2) → stop(3) → update(1)
|
|
78
|
+
this.q = Promise.resolve()
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
next() {
|
|
75
82
|
return ++this.seq
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
// 把卡片写操作排进串行队列。seq 在**真正执行时**才取,保证发出顺序 == seq 顺序
|
|
86
|
+
_enqueue(fn) {
|
|
87
|
+
this.q = this.q.then(fn, fn)
|
|
88
|
+
return this.q
|
|
89
|
+
}
|
|
90
|
+
|
|
78
91
|
// 步骤 ①:建卡片实体 → card_id。data 是**字符串**(双层编码)
|
|
79
92
|
async create() {
|
|
80
93
|
const body = JSON.stringify({ type: 'card_json', data: JSON.stringify(initCard()) })
|
|
@@ -115,71 +128,81 @@ class CardSession {
|
|
|
115
128
|
|
|
116
129
|
// 步骤 ③:流式期单 body 元素全量替换(打字机)。
|
|
117
130
|
// 全量替换故本地要维护累积串;单次失败可容忍(下次 PUT 是全量,自愈)。
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
+
// 走串行队列:调用方 fire-and-forget 也不会乱序
|
|
132
|
+
update(text) {
|
|
133
|
+
return this._enqueue(async () => {
|
|
134
|
+
const now = Date.now()
|
|
135
|
+
if (now - this.lastPutAt < THROTTLE_MS) return // 仅防频控,不为攒批
|
|
136
|
+
this.lastPutAt = now
|
|
137
|
+
const content = mdfix(text || '🔧 正在查阅…')
|
|
138
|
+
try {
|
|
139
|
+
await larkcli.run([
|
|
140
|
+
'--profile', this.profile, 'api', 'PUT',
|
|
141
|
+
`/open-apis/cardkit/v1/cards/${this.cardId}/elements/body/content`,
|
|
142
|
+
'--as', 'bot',
|
|
143
|
+
'--data', JSON.stringify({ content, sequence: this.next() }),
|
|
144
|
+
])
|
|
145
|
+
} catch {}
|
|
146
|
+
})
|
|
131
147
|
}
|
|
132
148
|
|
|
133
|
-
// 步骤 ④:收尾全卡重构。注意外层键是 card(内含 {type,data}),不是 ③ 的 content
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
elements
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
149
|
+
// 步骤 ④:收尾全卡重构。注意外层键是 card(内含 {type,data}),不是 ③ 的 content。
|
|
150
|
+
// 排在队尾执行 —— 必须等所有 in-flight update 落地,否则会被它们覆盖
|
|
151
|
+
finalize(result) {
|
|
152
|
+
return this._enqueue(async () => {
|
|
153
|
+
const elements = []
|
|
154
|
+
if (this.proc.trim()) {
|
|
155
|
+
elements.push({
|
|
156
|
+
tag: 'collapsible_panel',
|
|
157
|
+
expanded: false,
|
|
158
|
+
header: { title: { tag: 'markdown', content: '💭 思考与执行过程(点击展开)' } },
|
|
159
|
+
elements: [{ tag: 'markdown', content: mdfix(this.proc) }],
|
|
160
|
+
})
|
|
161
|
+
elements.push({ tag: 'hr' })
|
|
162
|
+
}
|
|
163
|
+
// 无过程内容时只放结论,不放空面板
|
|
164
|
+
elements.push({ tag: 'markdown', content: mdfix(result) })
|
|
165
|
+
const card = {
|
|
166
|
+
schema: '2.0',
|
|
167
|
+
config: { streaming_mode: false },
|
|
168
|
+
body: { elements },
|
|
169
|
+
}
|
|
170
|
+
const body = JSON.stringify({
|
|
171
|
+
card: { type: 'card_json', data: JSON.stringify(card) },
|
|
172
|
+
sequence: this.next(),
|
|
142
173
|
})
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
174
|
+
try {
|
|
175
|
+
const { stdout } = await larkcli.run([
|
|
176
|
+
'--profile', this.profile, 'api', 'PUT',
|
|
177
|
+
`/open-apis/cardkit/v1/cards/${this.cardId}`,
|
|
178
|
+
'--as', 'bot', '--data', body,
|
|
179
|
+
])
|
|
180
|
+
return /"ok":\s*true/.test(stdout)
|
|
181
|
+
} catch (err) {
|
|
182
|
+
const tail = String(err.stdout || err.stderr || err.message)
|
|
183
|
+
.replace(/\n/g, ' ')
|
|
184
|
+
.slice(0, 200)
|
|
185
|
+
process.stderr.write(`card: 收尾全卡更新失败 → ${tail}\n`)
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
155
188
|
})
|
|
156
|
-
try {
|
|
157
|
-
const { stdout } = await larkcli.run([
|
|
158
|
-
'--profile', this.profile, 'api', 'PUT',
|
|
159
|
-
`/open-apis/cardkit/v1/cards/${this.cardId}`,
|
|
160
|
-
'--as', 'bot', '--data', body,
|
|
161
|
-
])
|
|
162
|
-
return /"ok":\s*true/.test(stdout)
|
|
163
|
-
} catch (err) {
|
|
164
|
-
const tail = String(err.stdout || err.stderr || err.message).replace(/\n/g, ' ').slice(0, 200)
|
|
165
|
-
process.stderr.write(`card: 收尾全卡更新失败 → ${tail}\n`)
|
|
166
|
-
return false
|
|
167
|
-
}
|
|
168
189
|
}
|
|
169
190
|
|
|
170
191
|
// 步骤 ⑤:关流式定格。settings 的值是**字符串化的 JSON**(双层转义)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
192
|
+
stop() {
|
|
193
|
+
return this._enqueue(async () => {
|
|
194
|
+
try {
|
|
195
|
+
await larkcli.run([
|
|
196
|
+
'--profile', this.profile, 'api', 'PATCH',
|
|
197
|
+
`/open-apis/cardkit/v1/cards/${this.cardId}/settings`,
|
|
198
|
+
'--as', 'bot',
|
|
199
|
+
'--data', JSON.stringify({
|
|
200
|
+
settings: JSON.stringify({ config: { streaming_mode: false } }),
|
|
201
|
+
sequence: this.next(),
|
|
202
|
+
}),
|
|
203
|
+
])
|
|
204
|
+
} catch {}
|
|
205
|
+
})
|
|
183
206
|
}
|
|
184
207
|
|
|
185
208
|
// 流式事件三种 kind。pendingSay 延迟一段:每轮最后一段 say 就是最终结论
|
package/lib/claude.js
CHANGED
|
@@ -7,10 +7,12 @@ const os = require('os')
|
|
|
7
7
|
const path = require('path')
|
|
8
8
|
const { spawn } = require('child_process')
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
|
|
10
|
+
// 模型分发交给一个 shell 函数 `claude()` —— 由它做白名单校验与后端路由,
|
|
11
|
+
// 是模型名的单一事实源。包不自己维护模型清单(那样新增后端要改两处)。
|
|
12
|
+
//
|
|
13
|
+
// LR_CC_RC 指向定义了 `claude()` 的 rc 文件。默认值只是个约定俗成的位置,
|
|
14
|
+
// 部署时按实际环境设置:Environment=LR_CC_RC=%h/path/to/claude.sh
|
|
15
|
+
const CC_RC = process.env.LR_CC_RC || path.join(os.homedir(), '.claude-route.sh')
|
|
14
16
|
|
|
15
17
|
// sha1(task|scope) 前 32 位切成 8-4-4-4-12
|
|
16
18
|
function deriveUuid(task, scope) {
|
|
@@ -86,15 +88,23 @@ function runClaude(opts) {
|
|
|
86
88
|
args.push('--output-format', stream ? 'stream-json' : 'json')
|
|
87
89
|
if (stream) args.push('--verbose')
|
|
88
90
|
|
|
89
|
-
// 经 bash -lc source
|
|
91
|
+
// 经 bash -lc source <rc> 后调 claude() —— 模型分发与白名单校验都在那里。
|
|
92
|
+
// rc 不存在时**不当致命错误**:降级到 PATH 里的 claude,只在指定了 --model 时才必须有 rc
|
|
93
|
+
// (否则 --model 传给原生 claude 会是另一套语义)。
|
|
94
|
+
// ⚠️ CC_RC 必须 shellQuote:曾直接内插进双引号里的 echo,
|
|
95
|
+
// LR_CC_RC='/x$(命令)' 会被执行(实测)。所有插进 script 的值一律走 shellQuote
|
|
90
96
|
const quoted = args.map(shellQuote).join(' ')
|
|
91
|
-
const
|
|
92
|
-
const script =
|
|
93
|
-
`set -o pipefail\n` +
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
97
|
+
const rcQ = shellQuote(CC_RC)
|
|
98
|
+
const script = model
|
|
99
|
+
? `set -o pipefail\n` +
|
|
100
|
+
`if ! source ${rcQ} 2>/dev/null; then\n` +
|
|
101
|
+
` printf '%s\\n' "cc: 指定了 --model 但 source 失败,设 LR_CC_RC 指向定义 claude() 的文件" >&2\n` +
|
|
102
|
+
` exit 3\n` +
|
|
103
|
+
`fi\n` +
|
|
104
|
+
`claude --model ${shellQuote(model)} ${quoted}\n`
|
|
105
|
+
: `set -o pipefail\n` +
|
|
106
|
+
`source ${rcQ} 2>/dev/null || true\n` +
|
|
107
|
+
`claude ${quoted}\n`
|
|
98
108
|
|
|
99
109
|
return new Promise((resolve) => {
|
|
100
110
|
const child = spawn('bash', ['-lc', script], {
|
package/lib/collect.js
CHANGED
|
@@ -24,6 +24,7 @@ class AppCollector {
|
|
|
24
24
|
this.stopped = false
|
|
25
25
|
this.backoffMs = 1000
|
|
26
26
|
this.restarts = 0
|
|
27
|
+
this.healthyTimer = null
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
start() {
|
|
@@ -50,39 +51,55 @@ class AppCollector {
|
|
|
50
51
|
try {
|
|
51
52
|
store.writeEvent(this.app, obj, this.seq++)
|
|
52
53
|
this.received++
|
|
53
|
-
// 收到第一条就说明这个 app 通了,重置退避
|
|
54
|
-
this.backoffMs = 1000
|
|
55
54
|
} catch (err) {
|
|
56
55
|
process.stderr.write(`[${this.app}] error: 落盘失败 ${err.message}\n`)
|
|
57
56
|
}
|
|
58
57
|
})
|
|
59
58
|
|
|
59
|
+
// 退避重置的判据是「子进程活够久」而非「收到消息」:
|
|
60
|
+
// 某 app 长期没消息但连接正常时,退避会一路顶到 60s 上限再也不降,
|
|
61
|
+
// 占空比掉到 50% 以下 —— 而那段时间的消息是永久丢的
|
|
62
|
+
this.healthyTimer = setTimeout(() => {
|
|
63
|
+
this.backoffMs = 1000
|
|
64
|
+
}, 30_000)
|
|
65
|
+
|
|
60
66
|
// 不加 --quiet:它会隐藏事件丢失。stderr 的 ready/exit/drop 诊断进 journald
|
|
61
67
|
child.stderr.on('data', (buf) => {
|
|
62
68
|
const text = buf.toString().trimEnd()
|
|
63
69
|
if (text) process.stderr.write(`[${this.app}] ${text}\n`)
|
|
64
70
|
})
|
|
65
71
|
|
|
66
|
-
|
|
72
|
+
// spawn 失败(lark-cli 被卸载 / 升级中途 / PATH 变化)只发 error+close,**没有 exit**。
|
|
73
|
+
// 把重启逻辑挂在 close 上,否则该 app 会永久停摆而主进程看着还 active
|
|
74
|
+
let restarted = false
|
|
75
|
+
const scheduleRestart = (why) => {
|
|
76
|
+
if (restarted || this.stopped) return
|
|
77
|
+
restarted = true
|
|
78
|
+
clearTimeout(this.healthyTimer)
|
|
67
79
|
this.child = null
|
|
68
|
-
if (this.stopped) return
|
|
69
80
|
this.restarts++
|
|
70
|
-
const why = signal ? `signal ${signal}` : `code ${code}`
|
|
71
81
|
process.stderr.write(
|
|
72
|
-
`[${this.app}] consume 退出(${why}),${Math.round(this.backoffMs / 1000)}s
|
|
82
|
+
`[${this.app}] consume 退出(${why}),${Math.round(this.backoffMs / 1000)}s 后重启 ` +
|
|
83
|
+
`—— 这段时间该 app 的消息会永久丢失\n`,
|
|
73
84
|
)
|
|
74
85
|
// 每 app 一个子进程;某个挂了单独重启,不影响其他
|
|
75
86
|
setTimeout(() => this.start(), this.backoffMs)
|
|
76
87
|
this.backoffMs = Math.min(this.backoffMs * 2, 60_000)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
child.on('close', (code, signal) => {
|
|
91
|
+
scheduleRestart(signal ? `signal ${signal}` : `code ${code}`)
|
|
77
92
|
})
|
|
78
93
|
|
|
79
94
|
child.on('error', (err) => {
|
|
80
95
|
process.stderr.write(`[${this.app}] spawn 失败:${err.message}\n`)
|
|
96
|
+
scheduleRestart(`spawn error ${err.code || err.message}`)
|
|
81
97
|
})
|
|
82
98
|
}
|
|
83
99
|
|
|
84
100
|
stop() {
|
|
85
101
|
this.stopped = true
|
|
102
|
+
clearTimeout(this.healthyTimer)
|
|
86
103
|
// 优雅停止:SIGTERM。禁 SIGKILL —— 否则漏卸载服务端订阅
|
|
87
104
|
if (this.child) this.child.kill('SIGTERM')
|
|
88
105
|
}
|
|
@@ -94,7 +111,20 @@ async function runCollect(opts) {
|
|
|
94
111
|
ensureDir(paths.ledger)
|
|
95
112
|
|
|
96
113
|
const exclude = new Set(opts.exclude || [])
|
|
97
|
-
|
|
114
|
+
let profiles
|
|
115
|
+
try {
|
|
116
|
+
profiles = await larkcli.listProfiles()
|
|
117
|
+
} catch (err) {
|
|
118
|
+
const e = new Error(
|
|
119
|
+
`读不到 lark-cli profile 列表:${(err.message || '').split('\n')[0]}\n` +
|
|
120
|
+
`collect 依赖 lark-cli 提供认证与事件总线。检查:\n` +
|
|
121
|
+
` which ${larkcli.CLI} # 装了吗、在 PATH 里吗\n` +
|
|
122
|
+
` ${larkcli.CLI} profile list # 能跑吗\n` +
|
|
123
|
+
`systemd 单元里 PATH 要用 fnm default alias 的稳定路径,不能用会话级的 multishell 路径`,
|
|
124
|
+
)
|
|
125
|
+
e.userFacing = true
|
|
126
|
+
throw e
|
|
127
|
+
}
|
|
98
128
|
|
|
99
129
|
const chosen = []
|
|
100
130
|
const skipped = []
|
package/lib/dispatch.js
CHANGED
|
@@ -210,28 +210,49 @@ async function run(task, opts = {}) {
|
|
|
210
210
|
process.on('SIGINT', onSig)
|
|
211
211
|
|
|
212
212
|
for (;;) {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
213
|
+
let res
|
|
214
|
+
try {
|
|
215
|
+
res = await takeBatch({
|
|
216
|
+
app: cfg.app,
|
|
217
|
+
chats,
|
|
218
|
+
name: consumer,
|
|
219
|
+
filter: cfg.filter,
|
|
220
|
+
debounceMs,
|
|
221
|
+
maxWaitMs: 75_000,
|
|
222
|
+
timeoutMs: opts.once ? 300_000 : 12 * 3600 * 1000,
|
|
223
|
+
signal: ac.signal,
|
|
224
|
+
})
|
|
225
|
+
} catch (err) {
|
|
226
|
+
// 取批阶段的异常绝不能让进程退出:Restart=on-failure 会变成每 5s 崩一次,
|
|
227
|
+
// 而卡住的那条消息永远在队首 → 任务彻底停摆且看不出原因
|
|
228
|
+
process.stderr.write(`dispatch: ${task} 取批失败,15s 后重试 → ${err.stack || err}\n`)
|
|
229
|
+
if (opts.once) return 1
|
|
230
|
+
await new Promise((r) => setTimeout(r, 15_000))
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
223
233
|
|
|
224
234
|
if (res.events.length) {
|
|
225
235
|
// 一个防抖窗口会吐同群多话题的消息,只按 chat_id 分组会**串话题**
|
|
226
|
-
|
|
236
|
+
const groups = groupByChatThread(res.events)
|
|
237
|
+
for (let i = 0; i < groups.length; i++) {
|
|
238
|
+
// 收到停止信号后不再开新一轮 claude:单元 TimeoutStopSec=30,
|
|
239
|
+
// 而一轮最长 1800s → 会被 SIGKILL,卡片停在占位符、结论不回群。
|
|
240
|
+
// 游标此时也没推(take 的 abort 分支只在有批次时推),故剩余组下次重新取到
|
|
241
|
+
if (stopping) {
|
|
242
|
+
process.stderr.write(
|
|
243
|
+
`dispatch: ${task} 正在停止,剩余 ${groups.length - i} 组留到下次\n`,
|
|
244
|
+
)
|
|
245
|
+
break
|
|
246
|
+
}
|
|
227
247
|
try {
|
|
228
|
-
await handleGroup(t,
|
|
248
|
+
await handleGroup(t, groups[i])
|
|
229
249
|
} catch (err) {
|
|
230
250
|
process.stderr.write(`dispatch: ${task} 处理组失败 ${err.stack || err}\n`)
|
|
231
251
|
}
|
|
232
252
|
}
|
|
233
253
|
} else if (opts.once) {
|
|
234
254
|
process.stderr.write(`dispatch: 没等到消息(${res.reason})\n`)
|
|
255
|
+
return res.reason === 'timeout' ? 4 : 0 // 与 take 对齐:4 = 等超时,0 = 正常
|
|
235
256
|
}
|
|
236
257
|
|
|
237
258
|
if (opts.once || stopping) return 0
|
package/lib/filter.js
CHANGED
|
@@ -37,11 +37,16 @@ function applyFilter(events, expr) {
|
|
|
37
37
|
maxBuffer: 64 * 1024 * 1024,
|
|
38
38
|
})
|
|
39
39
|
} catch (err) {
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// 保守放行 + warn,不抛 —— 抛出去会让 dispatch 崩溃循环:
|
|
41
|
+
// 表达式对某类事件报错(如 `.mentions[].id` 遇到没有 mentions 的消息)时,
|
|
42
|
+
// 那条消息永远卡在队首,游标推不动,systemd 每 5s 重启一次,该任务彻底停摆。
|
|
43
|
+
// 放行的代价是模型多看几条本该滤掉的消息,远小于停摆
|
|
44
|
+
process.stderr.write(
|
|
45
|
+
`warn: --filter 求值失败,本批不过滤(${events.length} 条放行)。表达式:${expr}\n` +
|
|
46
|
+
` ${(err.stderr || err.message || '').trim().split('\n')[0]}\n` +
|
|
47
|
+
` 提示:字段可能不存在,用 ? 和 // 兜底,如 '(.mentions // []) | any(.id == "ou_xxx")'\n`,
|
|
42
48
|
)
|
|
43
|
-
|
|
44
|
-
throw e
|
|
49
|
+
return events
|
|
45
50
|
}
|
|
46
51
|
const verdicts = out.split('\n').filter((l) => l.length > 0)
|
|
47
52
|
// 求值行数应与事件数一致;不一致说明表达式产出多值,保守放行全部并 warn
|
package/lib/larkcli.js
CHANGED
|
@@ -135,7 +135,7 @@ async function sendCard(profile, chatId, card, opts = {}) {
|
|
|
135
135
|
return JSON.parse(stdout)
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
// reply 返回体的 thread_id 是 null
|
|
138
|
+
// reply 返回体的 thread_id 是 null,要单独查消息详情才拿得到(实测)。
|
|
139
139
|
async function getMessageThreadId(profile, messageId) {
|
|
140
140
|
try {
|
|
141
141
|
const { stdout } = await run([
|
package/lib/lock.js
CHANGED
|
@@ -1,43 +1,75 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
3
|
// 两种锁,语义不同,别混:
|
|
4
|
-
// - 消费者锁(非阻塞):同一 --name 不该有两个 take 在跑,抢不到直接退出并说清楚。
|
|
4
|
+
// - 消费者锁(非阻塞):同一 --name 不该有两个 take/dispatch 在跑,抢不到直接退出并说清楚。
|
|
5
5
|
// 否则两个进程互相推游标,批次会被撕成两半。
|
|
6
6
|
// - (任务,群)锁(阻塞 -w 1800):dispatch 每群串行。并发会写坏 append-only ledger,
|
|
7
|
-
// 且同群两轮 claude
|
|
7
|
+
// 且同群两轮 claude 会互相打断。超时视为「别人正在干」,静默跳过。
|
|
8
8
|
const fs = require('fs')
|
|
9
9
|
const path = require('path')
|
|
10
10
|
const { paths, ensureDir } = require('./paths')
|
|
11
11
|
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// 互斥原语:**把「已写好 pid 的目录」原子 rename 到锁位置**。
|
|
13
|
+
// rename 到一个已存在的非空目录会失败(ENOTEMPTY/EEXIST),这就是原子的 test-and-set,
|
|
14
|
+
// 且锁一出现就带着 pid —— 没有任何中间态。
|
|
15
|
+
//
|
|
16
|
+
// ⚠️ 走过的弯路(都实测复现了多重持锁,别退回去):
|
|
17
|
+
// ① pidfile + O_EXCL + 「删陈旧锁再重试」:两个进程可以都 rmSync 成功(force 不报错)
|
|
18
|
+
// 再都 openSync 成功 → 同时持锁(12 进程抢锁 40% 复现)
|
|
19
|
+
// ② 写临时文件 + rename 覆盖 + 回读确认:输家的 rename 可能落在赢家回读之后
|
|
20
|
+
// ③ mkdir 目录 + 之后再写 pid:mkdir 与写 pid 之间有窗口 —— 别的进程看到空锁目录、
|
|
21
|
+
// 读不到 pid,就把它当陈旧锁夺走(实测 12 进程里最多 4 个同时持锁)
|
|
14
22
|
function acquireExclusive(key) {
|
|
15
|
-
const
|
|
16
|
-
|
|
23
|
+
const safe = sanitize(key)
|
|
24
|
+
const runDir = ensureDir(paths.run)
|
|
25
|
+
const dir = path.join(runDir, `${safe}.lock`)
|
|
26
|
+
const pidFile = path.join(dir, 'pid')
|
|
27
|
+
const me = String(process.pid)
|
|
28
|
+
|
|
29
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
30
|
+
// 备好一个「完整的锁」:目录 + 里面的 pid
|
|
31
|
+
const staging = path.join(runDir, `.${safe}.${process.pid}.${attempt}.staging`)
|
|
17
32
|
try {
|
|
18
|
-
|
|
19
|
-
fs.
|
|
20
|
-
fs.
|
|
21
|
-
const release = () => {
|
|
22
|
-
try {
|
|
23
|
-
if (fs.readFileSync(file, 'utf8').trim() === String(process.pid)) fs.rmSync(file, { force: true })
|
|
24
|
-
} catch {}
|
|
25
|
-
}
|
|
26
|
-
process.once('exit', release)
|
|
27
|
-
return { ok: true, file, release }
|
|
33
|
+
fs.rmSync(staging, { recursive: true, force: true })
|
|
34
|
+
fs.mkdirSync(staging, { recursive: true })
|
|
35
|
+
fs.writeFileSync(path.join(staging, 'pid'), me)
|
|
28
36
|
} catch (err) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
cleanup(staging)
|
|
38
|
+
throw err
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 原子上位:目标不存在才成功
|
|
42
|
+
try {
|
|
43
|
+
fs.renameSync(staging, dir)
|
|
44
|
+
return mkHandle(dir, pidFile, me)
|
|
45
|
+
} catch (err) {
|
|
46
|
+
cleanup(staging)
|
|
47
|
+
if (!['ENOTEMPTY', 'EEXIST', 'EISDIR', 'ENOTDIR'].includes(err.code)) throw err
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 锁已被占。看持有者还活着吗
|
|
51
|
+
const holder = readPid(pidFile)
|
|
52
|
+
if (holder === process.pid) return mkHandle(dir, pidFile, me) // 自己已持有,复用
|
|
53
|
+
if (holder && alive(holder)) return { ok: false, file: dir, holder }
|
|
54
|
+
|
|
55
|
+
// 陈旧锁:整体 rename 挪走(原子,只有一个进程能挪成功),确认是死锁后删掉再重试。
|
|
56
|
+
// 不能直接 rm —— 删和建之间别人可能刚上位,会把活锁删掉
|
|
57
|
+
const claim = `${dir}.steal.${process.pid}.${attempt}`
|
|
58
|
+
try {
|
|
59
|
+
fs.renameSync(dir, claim)
|
|
60
|
+
} catch {
|
|
61
|
+
continue // 没抢到搬运权,重新判定
|
|
62
|
+
}
|
|
63
|
+
const stolen = readPid(path.join(claim, 'pid'))
|
|
64
|
+
if (stolen && stolen !== process.pid && alive(stolen)) {
|
|
35
65
|
try {
|
|
36
|
-
fs.
|
|
66
|
+
fs.renameSync(claim, dir) // 误抢了活锁,原样放回
|
|
37
67
|
} catch {}
|
|
68
|
+
return { ok: false, file: dir, holder: stolen }
|
|
38
69
|
}
|
|
70
|
+
cleanup(claim)
|
|
39
71
|
}
|
|
40
|
-
return { ok: false, file, holder: readPid(
|
|
72
|
+
return { ok: false, file: dir, holder: readPid(pidFile) }
|
|
41
73
|
}
|
|
42
74
|
|
|
43
75
|
// 阻塞版:等到拿到锁或超时。dispatch 每(任务,群)一把。
|
|
@@ -46,11 +78,36 @@ async function acquireBlocking(key, timeoutMs = 1800_000, pollMs = 500) {
|
|
|
46
78
|
for (;;) {
|
|
47
79
|
const lock = acquireExclusive(key)
|
|
48
80
|
if (lock.ok) return lock
|
|
49
|
-
if (Date.now() >= deadline)
|
|
81
|
+
if (Date.now() >= deadline) {
|
|
82
|
+
return { ok: false, file: lock.file, holder: lock.holder, timedOut: true }
|
|
83
|
+
}
|
|
50
84
|
await new Promise((r) => setTimeout(r, pollMs))
|
|
51
85
|
}
|
|
52
86
|
}
|
|
53
87
|
|
|
88
|
+
function mkHandle(dir, pidFile, me) {
|
|
89
|
+
let released = false
|
|
90
|
+
const release = () => {
|
|
91
|
+
if (released) return
|
|
92
|
+
released = true
|
|
93
|
+
try {
|
|
94
|
+
// 只删自己的锁 —— 别删掉夺锁者的
|
|
95
|
+
if (fs.readFileSync(pidFile, 'utf8').trim() === me) {
|
|
96
|
+
fs.rmSync(dir, { recursive: true, force: true })
|
|
97
|
+
}
|
|
98
|
+
} catch {}
|
|
99
|
+
}
|
|
100
|
+
// 注:SIGKILL 不会跑 exit 钩子 → 留下陈旧锁,靠上面的死进程检测清理
|
|
101
|
+
process.once('exit', release)
|
|
102
|
+
return { ok: true, file: dir, release }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function cleanup(p) {
|
|
106
|
+
try {
|
|
107
|
+
fs.rmSync(p, { recursive: true, force: true })
|
|
108
|
+
} catch {}
|
|
109
|
+
}
|
|
110
|
+
|
|
54
111
|
function readPid(file) {
|
|
55
112
|
try {
|
|
56
113
|
const n = Number(fs.readFileSync(file, 'utf8').trim())
|
package/lib/paths.js
CHANGED
|
@@ -33,14 +33,20 @@ function ensureDir(dir) {
|
|
|
33
33
|
return dir
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
// 事件文件名
|
|
37
|
-
// pid+seq
|
|
36
|
+
// 事件文件名 <stamp>_<pid>_<seq>.json —— stamp 前缀让字典序 == 写入序,
|
|
37
|
+
// pid+seq 保证同一 stamp 内多进程/多事件不撞名。
|
|
38
|
+
//
|
|
39
|
+
// ⚠️ stamp 必须**严格单调递增**,否则消费侧游标会跨过后写的小 stamp 事件 → 永久丢消息。
|
|
40
|
+
// 曾用 `Date.now()*1e6 + hrtime%1e6`:毫秒边界与 hrtime 整毫秒边界有固定相位差,
|
|
41
|
+
// 同一毫秒内落在回绕点两侧的事件,后写的 stamp 反而更小 —— 实测 30 万次里 257 次倒退,
|
|
42
|
+
// 端到端丢 0.28%。且 NTP step / 时钟回拨会让整体变小 → 之后全部事件都在游标之前 → 100% 全丢。
|
|
43
|
+
// 现在:墙上时间只作下界,内部计数器保证单调,时钟回拨也只会让 stamp 走平不会倒退。
|
|
44
|
+
let lastStamp = 0n
|
|
45
|
+
|
|
38
46
|
function eventFilename(seq) {
|
|
39
|
-
const ns = process.hrtime.bigint()
|
|
40
47
|
const wall = BigInt(Date.now()) * 1000000n
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return `${stamp}_${process.pid}_${seq}.json`
|
|
48
|
+
lastStamp = wall > lastStamp ? wall : lastStamp + 1n
|
|
49
|
+
return `${lastStamp}_${process.pid}_${seq}.json`
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
// 本地日期 YYYY-MM-DD。按天分目录,gc 整目录删。
|
package/lib/store.js
CHANGED
|
@@ -7,6 +7,10 @@ const fs = require('fs')
|
|
|
7
7
|
const path = require('path')
|
|
8
8
|
const { paths, ensureDir, eventFilename, dayKey, dayKeyOffset } = require('./paths')
|
|
9
9
|
|
|
10
|
+
// scan 窗口天数。必须 >= store 保留天数,否则「保留着却扫不到」= 静默丢消息。
|
|
11
|
+
// 只扫窗口内是为了避免全目录排序(旧架构每轮全扫 4467 个文件)
|
|
12
|
+
const SCAN_DAYS = Number(process.env.LARK_RELAY_SCAN_DAYS || 4)
|
|
13
|
+
|
|
10
14
|
// 原子落盘:同目录 写 .tmp → rename。
|
|
11
15
|
// 这是 partial write 的正解 —— lark-cli --output-dir 先建 0 字节再填充,
|
|
12
16
|
// 游标可能跨过半成品导致事件永久丢失。
|
|
@@ -21,15 +25,22 @@ function writeEvent(app, obj, seq) {
|
|
|
21
25
|
return { day, name, file: dest }
|
|
22
26
|
}
|
|
23
27
|
|
|
24
|
-
//
|
|
28
|
+
// 排序键:stamp → pid → seq,数值比较(不靠字典序,避免位宽变化时错序)
|
|
29
|
+
// ⚠️ 必须容忍非法文件名:任何手工放进 store 的 .json(notes.json / 备份 / README.json)
|
|
30
|
+
// 都会进到这里。曾用 BigInt 裸解析 → SyntaxError 让 take/status/dispatch 全线崩溃
|
|
31
|
+
// (且需目录里 ≥2 个文件才触发,因为单元素 sort 不调比较器 —— 攒够两个突然全崩)
|
|
32
|
+
const NAME_RE = /^(\d+)_(\d+)_(\d+)$/
|
|
33
|
+
|
|
25
34
|
function parseKey(name) {
|
|
26
35
|
const base = name.endsWith('.json') ? name.slice(0, -5) : name
|
|
27
|
-
const
|
|
28
|
-
return {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
const m = NAME_RE.exec(base)
|
|
37
|
+
if (!m) return { stamp: 0n, pid: 0, seq: 0, valid: false }
|
|
38
|
+
return { stamp: BigInt(m[1]), pid: Number(m[2]), seq: Number(m[3]), valid: true }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 合法事件文件名判据。scanWindow 用它过滤掉杂项文件,不让它们参与排序/消费
|
|
42
|
+
function isEventName(name) {
|
|
43
|
+
return name.endsWith('.json') && NAME_RE.test(name.slice(0, -5))
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
function cmpName(a, b) {
|
|
@@ -57,8 +68,10 @@ function afterCursor(entry, cursor) {
|
|
|
57
68
|
return cmpName(entry.name, cName) > 0
|
|
58
69
|
}
|
|
59
70
|
|
|
60
|
-
// 只扫今天 +
|
|
61
|
-
|
|
71
|
+
// 只扫今天 + 昨天…… 的窗口跟随 retain,避免「保留 3 天却只扫 2 天」:
|
|
72
|
+
// 消费者停机超过 1 天后,盘上还在的事件会永久扫不到(游标停在更早的日期,
|
|
73
|
+
// 而窗口已经不覆盖它 → 静默丢失)。实测:游标停在 D-2、第三天启动即复现
|
|
74
|
+
function scanWindow(app, days = SCAN_DAYS) {
|
|
62
75
|
const out = []
|
|
63
76
|
for (let i = 0; i < days; i++) {
|
|
64
77
|
const day = dayKeyOffset(-i)
|
|
@@ -70,8 +83,7 @@ function scanWindow(app, days = 2) {
|
|
|
70
83
|
continue
|
|
71
84
|
}
|
|
72
85
|
for (const name of names) {
|
|
73
|
-
if (!name
|
|
74
|
-
if (name.startsWith('.')) continue
|
|
86
|
+
if (!isEventName(name)) continue // 跳过 .tmp、隐藏文件、手工放进来的杂项 .json
|
|
75
87
|
out.push({ app, day, name, file: path.join(dir, name) })
|
|
76
88
|
}
|
|
77
89
|
}
|
|
@@ -119,7 +131,7 @@ function seedCursorNow(name, app) {
|
|
|
119
131
|
function countToday(app) {
|
|
120
132
|
const dir = paths.appDay(app, dayKey())
|
|
121
133
|
try {
|
|
122
|
-
return fs.readdirSync(dir).filter(
|
|
134
|
+
return fs.readdirSync(dir).filter(isEventName).length // 不把杂项 .json 算成事件
|
|
123
135
|
} catch {
|
|
124
136
|
return 0
|
|
125
137
|
}
|
package/lib/take.js
CHANGED
|
@@ -76,7 +76,12 @@ async function takeBatch(opts) {
|
|
|
76
76
|
let commit = cursor
|
|
77
77
|
|
|
78
78
|
for (;;) {
|
|
79
|
+
// abort(SIGTERM/SIGINT)时必须和正常返回一样落盘 —— 否则批次已吐给调用方
|
|
80
|
+
// (AI 已看到、dispatch 已跑完 claude 并回群),游标却没推、dedup 没落盘,
|
|
81
|
+
// 重启后同一批会**重跑一遍**(实测:重复回群 + ledger 两行)
|
|
79
82
|
if (signal && signal.aborted) {
|
|
83
|
+
if (batch.length && commit) store.writeCursor(name, app, commit)
|
|
84
|
+
dedup.flush()
|
|
80
85
|
return { events: batch, reason: batch.length ? 'batch' : 'signal', cursor: commit }
|
|
81
86
|
}
|
|
82
87
|
|