lark-relay 0.4.14 → 0.5.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/README.md CHANGED
@@ -63,7 +63,7 @@ lark-relay take --app <app> --chats oc_xxx --render text
63
63
  空参数跑一下会输出完整的照做指导(查群 ID -> 验 bot 在群 -> 监听三步),exit 0。
64
64
  起来后 stderr 先打一行「监听中」-- 没这行就是没起来,看 stderr 报错。
65
65
  退出码:0=有一批,4=超时没消息(正常,再起一个),2=参数错,3=游标被占用。
66
- 参数拼错(如 `--chat-id`)会硬失败并给出改写后的命令,不静默吞掉。
66
+ 参数拼错(如 `--chat-id`)会硬失败并给出正确写法,不静默吞掉。
67
67
 
68
68
  业务过滤只有 `--filter`(jq 表达式)一个口子,不为每个业务加参数。
69
69
 
@@ -80,11 +80,24 @@ lark-relay take --app <app> --chats oc_xxx --render text
80
80
  多消费者**共享 store、各持游标**,不做「处理完即删」-- 谁都不能替别人删。
81
81
  盯同一 app 请用不同 `--name` 隔离游标。
82
82
 
83
- **游标要自己收尾**:它是「关了会话、过阵子接着盯」的续接凭据,所以引擎**不会**
84
- 自动清理不活跃的游标 -- 自动清掉就等于下次重挂要么全量重放、要么跳过停机期的消息。
85
- 代价是不再用的游标会一直留在 `status` 里显示「落后 N 条」,而 store 只保留几天,
86
- 那个 N 会随过期删除而变小(**看着像追上了,其实是消息没了**)。
87
- 一次性监听、验完的排查、不再值守的任务,收尾时 `rm -rf ~/.lark-relay/cursors/<name>`。
83
+ **游标会自动回收**:空闲超过 2 天的游标由 collect 删掉(每小时自查一次,
84
+ store 的日期目录回收同一个 gc)。判据是「多久没人用过它」——
85
+ 不需要你记得收尾,也不需要你判断该不该删。
86
+
87
+ ```bash
88
+ lark-relay cursors # ● 在跑 / ○ 空闲 Nh / ⚠ 空闲超期待回收
89
+ lark-relay cursors --forget <name> # 不等超期,现在就清
90
+ ```
91
+
92
+ 为什么是空闲时长而不是「落后多少条」:落后量是**遗弃的症状,不是反面** ——
93
+ 被遗弃的游标落后会一路涨,拿它当判据会把该清的保护起来、该留的删掉
94
+ (2026-09-06 实测)。空闲时长与遗弃程度同向单调。
95
+
96
+ 为什么 2 天够:take 是**实时场景**的取用口。空闲一两天以上再重挂,
97
+ 下一次消费一定从新的开始;要历史消息有专门的接口,不靠游标回放。
98
+ 改期限:`LARK_RELAY_CURSOR_TTL_DAYS=<天>`。
99
+
100
+ 在跑的游标(有 take 持锁)一律不回收 —— take 会立刻把它写回来。
88
101
 
89
102
  ## 设计取舍
90
103
 
@@ -45,7 +45,7 @@ async function main() {
45
45
  }
46
46
 
47
47
  const a = parseArgs(argv, { flags: ['help'], keys: KEYS })
48
- if (a._unknown.length) return rejectUnknown(PROG, argv, a._unknown, KEYS)
48
+ if (a._unknown.length) return rejectUnknown(PROG, a._unknown, KEYS)
49
49
  if (a.help) {
50
50
  process.stdout.write(`${help.COLLECT_HELP}\n`)
51
51
  return 0
@@ -64,14 +64,18 @@ async function main() {
64
64
  return 2
65
65
  }
66
66
 
67
- // retain 必须落在消费侧的扫描窗口内,否则事件留在盘上却永远扫不到 =
68
- // 静默丢消息(正是 store.js 那条注释警告的)。两个值在两个进程里,
69
- // 只能在这里把不变量钉死:装完新版直接起不来,好过悄悄丢
70
- const retain = numStrict(a.retain, 3)
71
- if (retain === null || !(retain >= 1) || retain > store.SCAN_DAYS) {
67
+ // retain 必须**正好等于**消费侧的扫描窗口。两个方向都是事故:
68
+ // retain > 窗口:事件留在盘上却永远扫不到(store.js 那条注释警告的)
69
+ // retain < 窗口:窗口最老那几天已被 gc 删空,却仍被算作「窗口内」——
70
+ // 停在那里的游标判成「可续接」,而数据一条都不在了
71
+ // (2026-09-06 实测 retain=3 / 窗口=4 D-3 就是这种空档)
72
+ // 两个值在两个进程里,只能在这里把不变量钉死:装完新版直接起不来,好过悄悄丢
73
+ const retain = numStrict(a.retain, store.SCAN_DAYS)
74
+ if (retain === null || retain !== store.SCAN_DAYS) {
72
75
  process.stderr.write(
73
- `--retain 需要 1~${store.SCAN_DAYS} 之间(消费侧扫描窗口 ${store.SCAN_DAYS} 天),当前:${a.retain}\n` +
74
- `要保留更久得同时调大 LARK_RELAY_SCAN_DAYS,否则超窗口的事件留着也扫不到\n`,
76
+ `--retain 必须等于消费侧扫描窗口 ${store.SCAN_DAYS} 天,当前:${a.retain}\n` +
77
+ `要改保留期就同时改两边:LARK_RELAY_SCAN_DAYS=<N> 且 --retain <N>。\n` +
78
+ `留得比窗口久 -> 事件在盘上却扫不到;短于窗口 -> 空目录被当成「还能续接」\n`,
75
79
  )
76
80
  return 2
77
81
  }
package/bin/lark-relay.js CHANGED
@@ -17,16 +17,14 @@ const COMMANDS = ['take', 'status', 'cursors', 'guide']
17
17
 
18
18
  // collect 搬家提示。能力是**搬走了不是删了**,所以不能只说「未知命令」
19
19
  const MOVED_HINT =
20
- `collect 已拆成独立命令 -- 它是常驻服务,不该和在场取用挤在一个入口:\n` +
21
- ` lark-relay-collect # 前台跑(调试)\n` +
22
- ` lark-relay-collect --help # 参数与常驻部署\n` +
23
- `同一个包就带,不用另外装。\n`
20
+ `collect 已拆成独立命令(同一个包就带,不用另外装):\n` +
21
+ ` lark-relay-collect --help # 前台调试与常驻部署\n`
24
22
 
25
23
  const USAGE = `lark-relay ${VERSION} -- Lark 事件中继站(在场取用)
26
24
 
27
25
  lark-relay take ... 在场取用:阻塞等一批 -> 输出 -> 退出
28
26
  lark-relay status 谁在跑 / 各 app 积压 / 各游标位置
29
- lark-relay cursors 游标治理:看谁还在值守 / --prune 清失效的
27
+ lark-relay cursors 游标状态:谁在值守 / 空闲多久(超期自动回收)
30
28
  lark-relay guide 一页用法(装完先读这个)
31
29
 
32
30
  采集底座是独立命令(常驻服务):lark-relay-collect
@@ -68,7 +66,7 @@ async function main() {
68
66
  default: {
69
67
  // 拼错 collect(collectt / colect)同样走搬家提示 —— 意图明显是采集,
70
68
  // 给「未知命令」等于让人以为功能没了。判据:编辑距离 <=2 且不像任何现有命令
71
- if (cmd !== 'collect' && suggest(cmd, ['collect']) === 'collect' && !suggest(cmd, COMMANDS)) {
69
+ if (suggest(cmd, ['collect']) === 'collect' && !suggest(cmd, COMMANDS)) {
72
70
  process.stderr.write(`未知命令:${cmd}。${MOVED_HINT}`)
73
71
  return 2
74
72
  }
@@ -81,7 +79,7 @@ async function main() {
81
79
 
82
80
  async function cmdTake(argv) {
83
81
  const a = parseArgs(argv, { flags: ['help'], keys: TAKE_KEYS })
84
- if (a._unknown.length) return rejectUnknown('lark-relay take', argv, a._unknown, TAKE_KEYS)
82
+ if (a._unknown.length) return rejectUnknown('lark-relay take', a._unknown, TAKE_KEYS)
85
83
  const larkcli = require('../lib/larkcli')
86
84
 
87
85
  // 空参数输出完整照做指导,而非报错 -- AI 的真实行为是「先空参数跑一下看看」。
@@ -140,32 +138,28 @@ async function cmdTake(argv) {
140
138
  process.stderr.write(`--since 只能是 now 或 all,当前:${since}\n`)
141
139
  return 2
142
140
  }
143
- // 游标初始化 / 自愈。两种情况都落到「按 --since now 重新起头」:
144
- // ① 没有游标 —— 首次跑,消灭旧架构「预热游标」那一步
145
- // 游标已 stale —— 位置超出扫描窗口,那段消息 collect 的 gc 已经整目录删了。
146
- // 续接它和 seed now **行为完全一样**(scanWindow 扫不到那些日期),
147
- // 但留着会让 status 一直虚报积压。所以当场自愈,并说清楚发生了什么。
148
- // ⚠️ 只动自己这一个游标,不碰别人的 —— 自愈的边界就在这里。
149
- // ⚠️ --since all 时不自愈:用户显式要全量重放,尊重它
141
+ // 游标初始化:没有游标就按 --since now 起头(消灭旧架构「预热游标」那一步)。
142
+ //
143
+ // ⚠️ 2026-09-06 删掉了这里的「stale 自愈」。它曾在游标位置超出扫描窗口时
144
+ // 把游标**重置到当前最新**,理由写的是「续接它和 seed now 行为完全一样」。
145
+ // 那个理由是错的,实测证伪:afterCursor 比的是「事件日期 > 游标日期」,
146
+ // 游标越老放行的事件越多。实测游标停在 D-5、窗口内有 3 条未消费时,
147
+ // 保留游标 -> 取到 3 条 重置到 now -> 取到 0 条
148
+ // 也就是说那个「自愈」每次都在**静默丢掉窗口内的全部积压**。
149
+ // 现在:游标一律原样保留,能取到的照常取。
150
+ //
151
+ // 不再需要「位置早于窗口」那条告知了 —— 遗弃的游标由 collect 按空闲时长自动回收
152
+ // (见 lib/cursors.js),不靠位置推断,也不靠人清。
150
153
  if (since === 'now') {
151
- const existing = store.readCursor(name, a.app)
152
- if (existing === null) {
153
- store.seedCursorNow(name, a.app)
154
- } else {
155
- const { cursorDay, isStaleDay, oldestScannedDay } = require('../lib/cursors')
156
- const day = cursorDay(existing)
157
- if (isStaleDay(day)) {
158
- // 说明行必须打在「监听中」**之前** —— 那一行是约定的启动信号
159
- // (「没这行就是没起来」),不能被别的输出插到中间
160
- process.stderr.write(
161
- `游标 ${name} 停在 ${day},已超扫描窗口(最早 ${oldestScannedDay()})\n` +
162
- `-> 那段消息已被 gc 回收,按 --since now 重新起头\n`,
163
- )
164
- store.seedCursorNow(name, a.app)
165
- }
166
- }
154
+ if (store.readCursor(name, a.app) === null) store.seedCursorNow(name, a.app)
167
155
  }
168
156
 
157
+ // 标记「有人来取过」。回收判据是空闲时长,而它读 mtime --
158
+ // ⚠️ 必须在这里显式 touch:take 的正常路径只在**有批次**时写游标,
159
+ // 空手超时/被 SIGTERM 都不写(实测起一个 take 再 kill,mtime 一动不动)。
160
+ // 不 touch 就会把「安静的群盯了两天没消息」误判成遗弃并回收
161
+ store.touchCursor(name, a.app)
162
+
169
163
  // 时长参数校验:负数/非数字过去会静默退回默认值或原样生效
170
164
  // (实测 --debounce -5 被接受并打印「防抖=-5s」,--timeout abc 悄悄变成 12 小时)
171
165
  const dur = (val, def, label) => {
@@ -217,52 +211,78 @@ async function cmdTake(argv) {
217
211
  return 0
218
212
  }
219
213
 
214
+ // cursors:**只读展示 + 一个提前忘掉的逃生舱**。
215
+ //
216
+ // ⚠️ 这里曾有 --prune / --force 与一套「该不该删」的判断(2026-09-06 删)。
217
+ // 删的理由不是简化:那套东西要求人拿着一个数字做破坏性决定,而**判据错了两次**
218
+ // (先按游标日期、后按待消费数,方向都是反的 —— 见 lib/cursors.js 文件头)。
219
+ // 现在回收由 collect 按空闲时长自动做,人不必判断,所以那些档位和确认步骤
220
+ // 一起失去了存在理由。留一个 --forget 给「现在就想清干净」。
220
221
  function cmdCursors(argv) {
221
- const CURSORS_FLAGS = ['prune', 'force', 'json', 'help']
222
+ const CURSORS_FLAGS = ['forget', 'json', 'help']
222
223
  const a = parseArgs(argv, { flags: CURSORS_FLAGS, keys: [] })
223
- if (a._unknown.length) return rejectUnknown('lark-relay cursors', argv, a._unknown, CURSORS_FLAGS)
224
+ if (a._unknown.length) return rejectUnknown('lark-relay cursors', a._unknown, CURSORS_FLAGS)
224
225
  if (a.help) {
225
226
  process.stdout.write(`${help.CURSORS_HELP}\n`)
226
227
  return 0
227
228
  }
228
229
  const cursors = require('../lib/cursors')
230
+ const ttlDays = Math.round(cursors.IDLE_TTL_MS / 86400000)
231
+
232
+ // 人话的空闲时长。回收判据就是这个数,所以它必须是最显眼的一列
233
+ const idleTxt = (ms) => {
234
+ if (!Number.isFinite(ms)) return '未知'
235
+ // 向下取整:这个数是回收判据,宁可少报不可多报 --
236
+ // Math.round 会把 2.5 天显示成「3d」,看着像已经超了 2 天的 TTL 更多
237
+ const m = Math.floor(ms / 60000)
238
+ if (m < 60) return `${m}m`
239
+ const h = Math.floor(m / 60)
240
+ return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`
241
+ }
229
242
 
230
- // 只读模式:列清单。默认不删任何东西 —— 删数据必须显式 --prune
231
- if (!a.prune) {
243
+ // 只读模式:列清单。删数据必须显式 --forget
244
+ if (!a.forget) {
232
245
  const rows = cursors.listCursors()
233
246
  if (a.json) {
234
247
  process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`)
235
248
  return 0
236
249
  }
237
250
  if (!rows.length) {
238
- process.stdout.write('没有游标(还没跑过 take,或已全部清理)\n')
251
+ process.stdout.write('没有游标(还没跑过 take,或已全部回收)\n')
239
252
  return 0
240
253
  }
241
- const mark = { live: '●', idle: '○', stale: '⚠' }
254
+ const mark = { live: '●', idle: '○', expiring: '⚠' }
242
255
  const note = {
243
- live: (r) => `在跑 (pid ${r.pid})`,
244
- idle: () => '暂停,可续接',
245
- stale: () => '超扫描窗口,续接取不到东西 -> 可清理',
256
+ live: (r) => `在跑 (pid ${r.pid}, 待消费 ${r.pending})`,
257
+ idle: (r) => `空闲 ${idleTxt(r.idleMs)}(待消费 ${r.pending},重挂 take 接着盯)`,
258
+ expiring: (r) => `空闲 ${idleTxt(r.idleMs)} 超 ${ttlDays} 天 -> 下轮 gc 自动回收`,
246
259
  }
247
260
  for (const r of rows) {
261
+ process.stdout.write(`${mark[r.state]} ${r.name} app=${r.app} ${note[r.state](r)}\n`)
262
+ }
263
+ const expiring = rows.filter((r) => r.state === 'expiring').length
264
+ if (expiring) {
248
265
  process.stdout.write(
249
- `${mark[r.state]} ${r.name} app=${r.app} 位置=${r.day} ${note[r.state](r)}\n`,
266
+ `\n${expiring} 个已超 ${ttlDays} 天空闲,collect 每小时自查时会回收 —— 不用手动清\n`,
250
267
  )
251
268
  }
252
- const stale = rows.filter((r) => r.state === 'stale').length
253
- if (stale) process.stdout.write(`\n${stale} 个 stale -> lark-relay cursors --prune\n`)
254
269
  return 0
255
270
  }
256
271
 
257
- // --prune:位置参数是要删的身份;没给就只清 stale 的那些
272
+ // --forget <name>...:不等 TTL,现在就清
258
273
  const names = a._
259
- const res = cursors.prune(names, { force: !!a.force })
274
+ if (!names.length) {
275
+ process.stderr.write(
276
+ `--forget 需要点名要忘掉哪个身份:lark-relay cursors --forget <name>...\n` +
277
+ `(不点名的批量清理已经不需要了 —— 空闲超 ${ttlDays} 天的由 collect 自动回收)\n`,
278
+ )
279
+ return 2
280
+ }
281
+ const res = cursors.forget(names)
260
282
 
261
283
  // removed 的元素是 `name/app` —— 删的单位是 app,一个身份可能盯多个
262
- for (const n of res.removed) process.stdout.write(`已删 ${n}\n`)
284
+ for (const n of res.removed) process.stdout.write(`已忘掉 ${n}\n`)
263
285
  for (const n of res.missing) process.stderr.write(`没有这个游标身份:${n}\n`)
264
-
265
- // 在跑的不删 -- take 会立刻把游标写回来。只有显式点名时才报告(见 cursors.prune)
266
286
  for (const r of res.refused) {
267
287
  process.stderr.write(
268
288
  `跳过 ${r.name}:有 take 正在用它(pid ${r.pid})。\n` +
@@ -270,28 +290,14 @@ function cmdCursors(argv) {
270
290
  )
271
291
  }
272
292
 
273
- // 「○ 暂停」的游标还能续接,删了就丢消费位置 -- 要 --force 再确认一次。
274
- // 2026-09-06 真实踩到:点名删一个刚退出 take 的 idle 游标,那段消息的消费位置没了
275
- for (const f of res.needForce) {
276
- process.stderr.write(
277
- `⚠ ${f.name}/${f.app} 是「○ 暂停」,位置 ${f.day} 仍在扫描窗口内\n` +
278
- ` 删了就丢续接凭据:重挂时会按 --since now 跳过这段已落盘的消息\n` +
279
- ` 确认要删 -> lark-relay cursors --prune ${f.name} --force\n`,
280
- )
281
- }
282
-
283
- if (!res.removed.length && !res.missing.length && !res.refused.length && !res.needForce.length) {
284
- process.stdout.write(names.length ? '没删任何东西\n' : '没有 stale 游标,无需清理\n')
285
- }
286
- // 点名的目标一个都没删成才算失败
287
293
  if (res.missing.length) return 2
288
- if ((res.refused.length || res.needForce.length) && !res.removed.length) return 3
294
+ if (res.refused.length && !res.removed.length) return 3
289
295
  return 0
290
296
  }
291
297
 
292
298
  async function cmdStatus(argv) {
293
299
  const a = parseArgs(argv, { flags: ['json', 'help'], keys: [] })
294
- if (a._unknown.length) return rejectUnknown('lark-relay status', argv, a._unknown, ['json'])
300
+ if (a._unknown.length) return rejectUnknown('lark-relay status', a._unknown, ['json'])
295
301
  if (a.help) {
296
302
  process.stdout.write(`${help.STATUS_HELP}\n`)
297
303
  return 0
package/lib/args.js CHANGED
@@ -104,14 +104,18 @@ function suggest(unknown, allowed) {
104
104
  return null
105
105
  }
106
106
 
107
- // 未知参数 -> stderr 短错误 + 候选提示 + 改写后的命令,exit 2。
107
+ // 未知参数 -> stderr 短错误 + 候选提示,exit 2。
108
108
  // 不打印完整用法 -- 那看着像 --help 成功,会被当成「正常退出但没消息」
109
109
  //
110
110
  // ⚠️ `prog` 必须由调用方传入,不能写死 'lark-relay':两个 bin 共用这段,
111
- // 而提示里那行「你是不是想跑这个」是要照抄执行的 —— 打出一个不存在的命令
111
+ // 而提示里那行是要照抄执行的 —— 打出一个不存在的命令
112
112
  // (如已拆走的 `lark-relay collect`)会把人引向死路。
113
113
  // lark-relay 传 `lark-relay <子命令>`,lark-relay-collect 传自己的名字。
114
- function rejectUnknown(prog, argv, unknown, allowed, out = process.stderr) {
114
+ //
115
+ // 注:2026-09-06 删掉了 rewriteArgv(把整条 argv 重写成可照跑的命令行,约 35 行)。
116
+ // 它是纯投机 —— 参数总共 9 个,`suggest` 已经点名了该写哪个,
117
+ // 再拼一条完整命令行属于替调用方猜意图,而那 35 行要长期跟着参数表走
118
+ function rejectUnknown(prog, unknown, allowed, out = process.stderr) {
115
119
  const names = [...new Set(unknown)]
116
120
  const shown = names.map((k) => `--${k}`).join(', ')
117
121
  let hint = ''
@@ -122,46 +126,8 @@ function rejectUnknown(prog, argv, unknown, allowed, out = process.stderr) {
122
126
  break
123
127
  }
124
128
  }
125
- const rewrite = rewriteArgv(argv, names, allowed)
126
- const tail = rewrite.length ? `\n你是不是想跑这个?\n ${prog} ${rewrite.join(' ')}` : ''
127
- out.write(`未知参数:${shown}${hint}${tail}\n跑 \`${prog}\` 看完整用法\n`)
129
+ out.write(`未知参数:${shown}${hint}\n跑 \`${prog}\` 看完整用法\n`)
128
130
  return 2
129
131
  }
130
132
 
131
- // 把未知参数替换成候选,拼出可照跑的命令行;无候选的未知参数(连同它的值)丢弃
132
- function rewriteArgv(argv, unknown, allowed) {
133
- const map = new Map()
134
- for (const k of unknown) {
135
- const s = suggest(k, allowed)
136
- if (s && !map.has(k)) map.set(k, s)
137
- }
138
- if (!map.size) return []
139
- const out = []
140
- for (let i = 0; i < argv.length; i++) {
141
- const a = argv[i]
142
- if (a.startsWith('--') && a !== '--') {
143
- const eq = a.indexOf('=')
144
- const k = eq !== -1 ? a.slice(2, eq) : a.slice(2)
145
- if (map.has(k)) {
146
- out.push(eq !== -1 ? `--${map.get(k)}=${a.slice(eq + 1)}` : `--${map.get(k)}`)
147
- if (eq === -1) {
148
- const next = argv[i + 1]
149
- if (next !== undefined && !next.startsWith('--')) {
150
- out.push(next)
151
- i++
152
- }
153
- }
154
- continue
155
- }
156
- if (eq === -1) {
157
- const next = argv[i + 1]
158
- if (next !== undefined && !next.startsWith('--')) i++
159
- }
160
- continue
161
- }
162
- out.push(a)
163
- }
164
- return out
165
- }
166
-
167
- module.exports = { parseArgs, numStrict, list, suggest, rejectUnknown, rewriteArgv }
133
+ module.exports = { parseArgs, numStrict, list, suggest, rejectUnknown }
package/lib/collect.js CHANGED
@@ -9,7 +9,9 @@
9
9
  const readline = require('readline')
10
10
  const larkcli = require('./larkcli')
11
11
  const store = require('./store')
12
+ const cursors = require('./cursors')
12
13
  const { paths, ensureDir } = require('./paths')
14
+ const { acquireExclusive } = require('./lock')
13
15
  const { init: logInit, logLine } = require('./log')
14
16
 
15
17
  const EVENT_KEY = process.env.LARK_RELAY_EVENT_KEY || 'im.message.receive_v1'
@@ -120,6 +122,26 @@ async function runCollect(opts) {
120
122
  // 非 launchd 场景(前台调试)是 no-op,logLine 退化成裸 stderr 直写
121
123
  logInit()
122
124
 
125
+ // 互斥:同一台机器只该有一个 collect。
126
+ // ⚠️ 这比 take 的锁更要紧,而它长期没有:同一 app 服务端只放行一个 event bus,
127
+ // 第二个 collect 起来会把线上那个挤掉,双方按 1s->2s->...->60s 退避互抢,
128
+ // 实测每 app 重启约 10 次、累计约 5 分钟才稳定 —— 那 5 分钟的消息**永久丢失**。
129
+ // 现实诱因不是手滑:COLLECT_HELP 教「前台跑(调试)」,deploy-launchd.sh 在
130
+ // bootstrap 失败时更直接建议「应急顶住采集:lark-relay-collect」--
131
+ // 若 launchd 那个其实还活着,照做就是自己抢自己。
132
+ // 拿不到锁就退出并给出判据,不留「看着在跑其实在互抢」的中间态
133
+ const lock = acquireExclusive('collect')
134
+ if (!lock.ok) {
135
+ logLine(
136
+ `error: 已有一个 lark-relay-collect 在跑(pid ${lock.holder}),本进程退出。\n` +
137
+ `同一 app 服务端只放行一个 event bus —— 两个 collect 会互相挤掉并退避重连,\n` +
138
+ `实测约 5 分钟才稳定,那段时间的消息永久丢失。\n` +
139
+ `确认是谁在跑: launchctl print system/com.adaex.lark-relay-collect\n` +
140
+ `要接管请先停掉它:sudo launchctl bootout system/com.adaex.lark-relay-collect`,
141
+ )
142
+ return 1
143
+ }
144
+
123
145
  const exclude = new Set(opts.exclude || [])
124
146
  let profiles
125
147
  try {
@@ -170,22 +192,34 @@ async function runCollect(opts) {
170
192
  const collectors = chosen.map((p) => new AppCollector(p.name, opts))
171
193
  for (const c of collectors) c.start()
172
194
 
173
- // gc 在本进程内每小时自查:回收逻辑就是「删超期日期目录」一句话,
174
- // 另起 gc 命令 + systemd timer 是为一行逻辑加两个部署件。
175
- const gcTimer = setInterval(() => {
195
+ // gc 在本进程内每小时自查:回收逻辑就是「删超期的东西」,
196
+ // 另起 gc 命令 + timer 是为几行逻辑加两个部署件。
197
+ // store 按天删目录,cursors 按空闲时长删 —— 同构,故同处一地
198
+ const runGc = () => {
176
199
  try {
177
200
  const s = store.gcStore(opts.retain)
178
- if (s.length) {
179
- logLine(`gc: store ${s.length} 个日期目录`)
201
+ if (s.length) logLine(`gc: 删 store ${s.length} 个日期目录`)
202
+ } catch (err) {
203
+ logLine(`gc: store 回收失败 ${err.message}`)
204
+ }
205
+ try {
206
+ // 游标回收:空闲超 TTL 的自动删。**这是纪律「不盯了就清游标」的落地方式** --
207
+ // 那条纪律三处成文却零执行,靠人记不住;而判据本身还错过两次
208
+ // (见 lib/cursors.js 文件头)。引擎自己过期,不需要人判断
209
+ const c = cursors.gcCursors()
210
+ if (c.length) {
211
+ logLine(
212
+ `gc: 回收 ${c.length} 个空闲超 ${Math.round(cursors.IDLE_TTL_MS / 86400000)} 天的游标:` +
213
+ `${c.join(' ')}`,
214
+ )
180
215
  }
181
216
  } catch (err) {
182
- logLine(`gc: 失败 ${err.message}`)
217
+ logLine(`gc: 游标回收失败 ${err.message}`)
183
218
  }
184
- }, GC_INTERVAL_MS)
219
+ }
220
+ const gcTimer = setInterval(runGc, GC_INTERVAL_MS)
185
221
  // 启动时先跑一次,别等一小时
186
- try {
187
- store.gcStore(opts.retain)
188
- } catch {}
222
+ runGc()
189
223
 
190
224
  return await new Promise((resolve) => {
191
225
  let shuttingDown = false
package/lib/cursors.js CHANGED
@@ -1,41 +1,47 @@
1
1
  'use strict'
2
2
 
3
- // cursors:游标治理 -- 判定每个游标是「在跑 / 暂停可续接 / 已失效」,并支持清理。
3
+ // cursors:游标治理 -- 只做两件事:**如实展示**每个游标的状态,
4
+ // 以及**按空闲时长自动回收**(由 collect 的 gc 每小时调一次)。
4
5
  //
5
- // 为什么需要这个模块:纪律「不盯了就删游标」写在 AGENTS.md help 三处,
6
- // 却从落地到 2026-09-06 一次都没被执行 -- 8 个游标全留着,0 个在值守。
7
- // 根因不是人不自觉,是**没有反馈回路**:
8
- // status 把死游标和活消费者渲染成同一个样子,看不出谁有人在跑
9
- // 唯一的信号「落后 N 条」**越久越小** -- store 只留几天,gc 删掉的部分不再计入 lag,
10
- // 于是越该清理的游标看起来越健康(实测 novel-watch-lianhua 373 条一路缩水)
11
- // 所以这里给出机械判据,让状态可见、清理一键化,而不是把纪律再写一遍。
6
+ // ⚠️ 这个模块 2026-09-06 一天内改了三版。前两版都错在同一个地方:
7
+ // **让人拿着一个推断量去做破坏性决定**。记下来免得再走一遍:
8
+ //
9
+ // 第一版判据「游标日期超出扫描窗口 -> 删掉无损」。
10
+ // 实测证伪:afterCursor 比的是「事件日期 > 游标日期」,游标越老放行的事件**越多**。
11
+ // 停在 D-5 的游标能取到窗口内 3 条,而 --since now 取到 0 条。
12
+ // 照它删/重置就是丢掉全部积压。
13
+ // ② 第二版改成实测量「pending = 按这个游标续接还剩几条」,以为方向对了。
14
+ // **仍然是反的** —— 实测两种剧本:
15
+ // 被遗弃两天的游标(app 还在收消息) -> pending 50 -> 判 backlog -> 保护它
16
+ // 活跃任务刚处理完一批、take 暂退 -> pending 0 -> 判 drained -> 删掉它
17
+ // 该清的被保护,该留的被删。因为**落后量是遗弃的症状,不是遗弃的反面**。
18
+ // 更糟:只要 app 还在收消息,被遗弃游标的 pending 单调增长,永不归零 --
19
+ // 裸 prune 对真正该清的东西**永久无效**(实测 gc 删掉游标所在那天后 pending 仍不变)。
20
+ //
21
+ // **正解:判据是「多久没人用过它」,不是「它落后多少」。**
22
+ // mtime 一直都在,从来没被读过。这个方向与遗弃程度同向单调:越久没碰越该清。
23
+ // 而且它让清理**不再需要人做判断** -- 前两次犯错都发生在
24
+ // 「人拿着一个数字决定删不删」这一步,这一版把那一步整个删掉:引擎自己过期。
12
25
  const fs = require('fs')
13
26
  const path = require('path')
14
27
  const store = require('./store')
15
- const { paths, dayKeyOffset } = require('./paths')
28
+ const { paths } = require('./paths')
16
29
  const { lockPathFor, holderPid } = require('./lock')
17
30
 
18
- // stale 判据的地基(**整个方案的正确性都压在这一条上**):
19
- // 游标位置的日期早于扫描窗口最老的一天时,续接它和 --since now 行为**完全等价** --
20
- // scanWindow 只看最近 SCAN_DAYS 天的目录,更早的日期 collect 的 gc 已经整目录删了,
21
- // afterCursor 过滤后必然为空。也就是说**删掉这种游标是无损的**,不会丢任何还能取到的消息。
31
+ // 空闲多久算遗弃。默认 2 天。
22
32
  //
23
- // ⚠️ 反过来说,窗口**内**的游标不能自动删 -- 那才是「关了会话、过阵子接着盯」的续接凭据,
24
- // 删了等于跳过停机期已落盘的消息。这是 idle 与 stale 必须分开的全部理由
25
- function oldestScannedDay() {
26
- return dayKeyOffset(-(store.SCAN_DAYS - 1))
27
- }
28
-
29
- // 游标值形如 `2026-09-04/<stamp>_<pid>_<seq>.json`,取日期段。
30
- // YYYY-MM-DD 字典序 == 时间序,故可直接字符串比较(store.afterCursor 已在用这个性质)
31
- function cursorDay(key) {
32
- return String(key || '').split('/')[0] || null
33
- }
34
-
35
- function isStaleDay(day) {
36
- if (!day) return true // 读不出日期的游标续接不了,按失效处理
37
- return day < oldestScannedDay()
38
- }
33
+ // 为什么是 2 天而不是更长(2026-09-06 用户定):take 是**实时场景**的取用口 --
34
+ // 「关了会话过阵子接着盯」的窗口本来就短。空闲一两天以上再重挂,
35
+ // 下一次消费一定是从新的开始;真要历史消息有专门的接口,不靠游标回放。
36
+ //
37
+ // 代价说清楚:超过 TTL 的游标被回收后,重挂按 --since now 起头,
38
+ // 跳过这段已落盘的消息。但 store 只保留 SCAN_DAYS(默认 3)天 --
39
+ // TTL 2 天时,被回收的游标最多也就跳过盘上还剩的那一天多,
40
+ // 而那种情况本身就是「两天没人管」。这个代价是可接受的,且是**显式**的
41
+ const IDLE_TTL_MS = (() => {
42
+ const d = Number(process.env.LARK_RELAY_CURSOR_TTL_DAYS)
43
+ return (Number.isFinite(d) && d > 0 ? d : 2) * 86400_000
44
+ })()
39
45
 
40
46
  // 活消费者判定:复用 take 已经在用的锁,不另造注册表。
41
47
  // take 用 `take-<name>` 作锁键(bin/lark-relay.js),锁目录里的 pid 文件就是持有者
@@ -43,9 +49,17 @@ function livePidOf(name) {
43
49
  return holderPid(`take-${name}`)
44
50
  }
45
51
 
46
- // 枚举 cursors/ 下的全部游标。
47
- // 一个 name 目录下可能有多个 app 文件,外加 dedup 的 <app>.seen.json 与落盘用的 .tmp --
48
- // 只有「没后缀的那些」才是游标本身,别把 dedup 文件当成一个 app
52
+ /**
53
+ * 枚举 cursors/ 下的全部游标。
54
+ *
55
+ * 一个 name 目录下可能有多个 app 文件,外加 dedup 的 <app>.seen.json 与落盘用的 .tmp --
56
+ * 只有「没后缀的那些」才是游标本身,别把 dedup 文件当成一个 app。
57
+ *
58
+ * @returns {Array<{name,app,cursor,day,state,pid,idleMs,pending}>}
59
+ * state: live(有 take 持锁) | idle(空闲但在 TTL 内) | expiring(超 TTL,下轮 gc 回收)
60
+ * idleMs: 距最后一次被使用多久 —— **回收判据**
61
+ * pending: 按此游标续接还剩几条 —— 仅作展示(它不是回收判据,见文件头 ②)
62
+ */
49
63
  function listCursors() {
50
64
  let names
51
65
  try {
@@ -53,6 +67,14 @@ function listCursors() {
53
67
  } catch {
54
68
  return [] // 还没跑过任何 take
55
69
  }
70
+ // 同一 app 可能被多个身份盯着,scanWindow 一次就够 —— 每行都扫一遍是平方级浪费
71
+ const scanCache = new Map()
72
+ const scanOf = (app) => {
73
+ if (!scanCache.has(app)) scanCache.set(app, store.scanWindow(app))
74
+ return scanCache.get(app)
75
+ }
76
+
77
+ const now = Date.now()
56
78
  const out = []
57
79
  for (const name of names.sort()) {
58
80
  const dir = path.join(paths.cursors, name)
@@ -68,11 +90,20 @@ function listCursors() {
68
90
  if (f.startsWith('.') || f.endsWith('.tmp') || f.endsWith('.seen.json')) continue
69
91
  const key = store.readCursor(name, f)
70
92
  if (!key) continue
71
- const day = cursorDay(key)
72
- // live 优先于 stale:take 正在跑就以它为准 -- 它马上会把游标推到当前位置,
73
- // 此刻位置旧只说明它刚起来还没追上,不代表失效
74
- const state = pid ? 'live' : isStaleDay(day) ? 'stale' : 'idle'
75
- out.push({ name, app: f, cursor: key, day, state, pid })
93
+ const mtime = store.cursorMtime(name, f)
94
+ const idleMs = mtime === null ? Infinity : Math.max(0, now - mtime)
95
+ // live 优先:take 正在跑就是在用,不看空闲时长
96
+ const state = pid ? 'live' : idleMs >= IDLE_TTL_MS ? 'expiring' : 'idle'
97
+ out.push({
98
+ name,
99
+ app: f,
100
+ cursor: key,
101
+ day: String(key).split('/')[0] || null,
102
+ state,
103
+ pid,
104
+ idleMs,
105
+ pending: scanOf(f).filter((e) => store.afterCursor(e, key)).length,
106
+ })
76
107
  }
77
108
  }
78
109
  return out
@@ -83,9 +114,9 @@ function listCursors() {
83
114
  // 被误判成重复而静默丢掉(TTL 6 小时,见 dedup.js)。
84
115
  //
85
116
  // ⚠️ 粒度必须是 app 而不是整个身份目录:一个 --name 可以同时盯多个 app
86
- // (cursors/<name>/appA、<name>/appB),而 stale 是**逐 app 判定**的。
87
- // 早先这里 rm -rf 整个 name 目录,于是「appA 已过期、appB 还在窗口内」时
88
- // 清 appA 会把 appB 那个**还能续接**的游标一起删掉(实测复现)。
117
+ // (cursors/<name>/appA、<name>/appB),而空闲时长是**逐 app 判定**的。
118
+ // 早先这里 rm -rf 整个 name 目录,于是「appA 空闲很久、appB 昨天还在用」时
119
+ // 清 appA 会把 appB 一起删掉(实测复现)。
89
120
  function removeCursorApp(name, app) {
90
121
  const dir = path.join(paths.cursors, name)
91
122
  let ok = false
@@ -103,7 +134,7 @@ function removeCursorApp(name, app) {
103
134
  }
104
135
 
105
136
  // 顺带清掉该身份的锁目录(若持有者已死)。
106
- // prune 删了游标却留着 take-<name>.lock,会在 run/ 里积累孤儿锁 --
137
+ // 删了游标却留着 take-<name>.lock 会在 run/ 里积累孤儿锁 --
107
138
  // 不致命(下次抢锁会夺走陈旧锁),但既然在清理就一并收干净
108
139
  function removeStaleLock(name) {
109
140
  if (holderPid(`take-${name}`)) return // 还活着,不碰
@@ -113,17 +144,42 @@ function removeStaleLock(name) {
113
144
  }
114
145
 
115
146
  /**
116
- * 清理游标。
117
- * @param {string[]} names 指定要删的身份(该身份下全部 app);空数组 = 只删 stale 的那些 app
118
- * @param {{force?:boolean}} opts force 才允许删「○ 暂停」(窗口内)的游标
119
- * @returns {{removed:string[], refused:Array<{name:string,pid:number}>, missing:string[],
120
- * needForce:Array<{name:string,app:string,day:string}>}}
121
- * removed 的元素形如 `name/app` -- 删的单位是 app,不是整个身份。
122
- * refused 只在**显式点名**时填充:裸 prune 遇到在跑的游标是静默跳过,不算例外
147
+ * 自动回收:删掉空闲超过 TTL 的游标。**由 collect 的 gc 每小时调一次**,
148
+ * store.gcStore 完全同构 —— 都是「删超期的东西」,不需要人参与。
149
+ *
150
+ * 这是本模块存在的理由。前两版把回收做成「人拿着一个数字敲 --prune」,
151
+ * 结果纪律三处成文、零执行,而判据本身还错了两次。
152
+ * 引擎自己过期不需要人记得,也不需要人判断。
153
+ *
154
+ * ⚠️ 有 take 持锁的一律不动 —— 它正在用,而且它会立刻把游标写回来。
155
+ *
156
+ * @returns {string[]} 被回收的 `name/app`
157
+ */
158
+ function gcCursors() {
159
+ const removed = []
160
+ const touchedNames = new Set()
161
+ for (const c of listCursors()) {
162
+ if (c.state !== 'expiring') continue
163
+ if (removeCursorApp(c.name, c.app)) {
164
+ removed.push(`${c.name}/${c.app}`)
165
+ touchedNames.add(c.name)
166
+ }
167
+ }
168
+ for (const n of touchedNames) removeStaleLock(n)
169
+ return removed
170
+ }
171
+
172
+ /**
173
+ * 逃生舱:立刻忘掉指定身份(该身份下全部 app),不等 TTL。
174
+ * 用于「刚验完、现在就想清干净」。
175
+ *
176
+ * 不设 --force 二次确认:回收本来就会自动发生,人只是提前几天,
177
+ * 没有「删了会丢消息」的额外判断负担 —— 那正是前两版的坑。
178
+ *
179
+ * @returns {{removed:string[], refused:Array<{name,pid}>, missing:string[]}}
123
180
  */
124
- function prune(names = [], opts = {}) {
181
+ function forget(names = []) {
125
182
  const all = listCursors()
126
- // 一个 name 可能有多个 app 行,按身份归并 -- live 判定是按身份的(锁以 name 为键)
127
183
  const byName = new Map()
128
184
  for (const c of all) {
129
185
  if (!byName.has(c.name)) byName.set(c.name, [])
@@ -133,11 +189,8 @@ function prune(names = [], opts = {}) {
133
189
  const removed = []
134
190
  const refused = []
135
191
  const missing = []
136
- const needForce = []
137
192
 
138
- const targets = names.length ? names : [...byName.keys()]
139
-
140
- for (const name of targets) {
193
+ for (const name of names) {
141
194
  const rows = byName.get(name)
142
195
  if (!rows) {
143
196
  missing.push(name)
@@ -145,24 +198,12 @@ function prune(names = [], opts = {}) {
145
198
  }
146
199
  const live = rows.find((r) => r.state === 'live')
147
200
  if (live) {
148
- // 显式点名时必须解释为什么没删(用户明确要求了);
149
- // 而裸 prune 的意图是「清掉失效的」-- 在跑的游标本就不在这个视野里,
150
- // 报告它是噪音(实测:3 个 take 在跑时,一次日常清理吐 3 段无关提示)
151
- if (names.length) refused.push({ name, pid: live.pid })
201
+ // 在跑的不删:take 会立刻把游标写回来,删了只是骗自己
202
+ refused.push({ name, pid: live.pid })
152
203
  continue
153
204
  }
154
- // 显式点名 = 清该身份的全部 app(用户明确不盯了);
155
- // 裸 prune = 只清 stale 的那些 app,窗口内的留着续接
156
- const victims = names.length ? rows : rows.filter((r) => r.state === 'stale')
157
205
  let deletedAny = false
158
- for (const r of victims) {
159
- // ⚠️ 「○ 暂停」= 位置还在扫描窗口内,**删了就丢续接凭据**(重挂会按 --since now
160
- // 跳过这段已落盘的消息)。stale 删掉无损,idle 不是 -- 故 idle 要 --force 兜一道。
161
- // 2026-09-06 真实踩到:点名删一个刚退出 take 的 idle 游标,消费位置就此丢失
162
- if (r.state === 'idle' && !opts.force) {
163
- needForce.push({ name, app: r.app, day: r.day })
164
- continue
165
- }
206
+ for (const r of rows) {
166
207
  if (removeCursorApp(name, r.app)) {
167
208
  removed.push(`${name}/${r.app}`)
168
209
  deletedAny = true
@@ -171,14 +212,13 @@ function prune(names = [], opts = {}) {
171
212
  if (deletedAny) removeStaleLock(name)
172
213
  }
173
214
 
174
- return { removed, refused, missing, needForce }
215
+ return { removed, refused, missing }
175
216
  }
176
217
 
177
218
  module.exports = {
219
+ IDLE_TTL_MS,
178
220
  listCursors,
179
- prune,
221
+ gcCursors,
222
+ forget,
180
223
  removeCursorApp,
181
- cursorDay,
182
- isStaleDay,
183
- oldestScannedDay,
184
224
  }
package/lib/help.js CHANGED
@@ -48,10 +48,8 @@ function takeHelp(apps) {
48
48
  最佳实践
49
49
  · 一直等不到消息 -> 九成是 bot 不在群,回第二步验(最常见故障)
50
50
  · 多个消费者盯同一 app 用不同 --name,游标互不干扰
51
- · 不盯了就清游标:lark-relay cursors --prune
52
- 引擎不主动删你的续接凭据(它是跨会话接着盯的凭据)。留着会在 status
53
- 一直挂「落后 N 条」,而 store 只保留几天 -- N 会随过期删除而变小,
54
- 看着像追上了,其实是消息没了`
51
+ · **不用手动清游标**:空闲超 2 天的由 collect 自动回收。
52
+ 想立刻清干净:lark-relay cursors --forget <name>`
55
53
  }
56
54
 
57
55
  const COLLECT_HELP = `把全部 lark-cli profile 的事件收下来,原子落盘。零参数、零配置。
@@ -67,6 +65,11 @@ app 列表实时读 \`lark-cli profile list\`,新增 profile 自动纳入,无需
67
65
  tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是死循环)。
68
66
  用户重新 login 后下次启动自动纳入。
69
67
 
68
+ ⚠️ **同一台机器只能有一个 collect**,第二个会拒绝启动并报出持有者 pid。
69
+ 不是洁癖:同一 app 服务端只放行一个 event bus,两个 collect 会互相挤掉并按
70
+ 1s->2s->...->60s 退避重连,实测约 5 分钟才稳定 -- 那段时间的消息永久丢失。
71
+ 所以下面那条「前台跑(调试)」在常驻服务已在跑时会直接退出,不会抢它。
72
+
70
73
  为什么必须常驻:lark 事件是流式的,进程不在的时刻消息永久丢失(实测:消息发出
71
74
  8s 后才起 consumer,收到 0 条)。collect 独立常驻,才能让 take 侧
72
75
  崩了、AI 跑 30 分钟、会话关几小时,都不丢消息。
@@ -81,8 +84,9 @@ tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是
81
84
 
82
85
  参数
83
86
  --exclude <apps> 排除指定 profile(逗号分隔)
84
- --retain N store 保留天数(默认 3,上限 4 = 消费侧扫描窗口;
85
- 再大就会「留着却扫不到」,故硬拒)
87
+ --retain N store 保留天数(默认 3,**必须等于**消费侧扫描窗口;
88
+ 留久了「在盘上却扫不到」,短了「空目录被当成还能续接」,
89
+ 要改就同时设 LARK_RELAY_SCAN_DAYS)
86
90
 
87
91
  环境变量
88
92
  LARK_RELAY_LOG_MAX_MB 单个日志文件上限(默认 16),超了轮转
@@ -105,37 +109,47 @@ const STATUS_HELP = `一眼看清全局:谁在跑、积压多少、游标在哪
105
109
  lark-relay status # 概览
106
110
  lark-relay status --json # 机器可读
107
111
 
108
- 消费者三态:
109
- ● 在跑 有 take 持锁,pid 是它
110
- 暂停 没人在跑,但位置还在扫描窗口内 -- 再起一个 take 能接着盯
111
- stale 位置已超扫描窗口,那段消息 gc 已回收,续接取不到东西 -> 该清理
112
+ 消费者状态:
113
+ ● 在跑 有 take 持锁,pid 是它
114
+ 空闲 Nh 没人在跑,但最近用过 —— 重挂 take 接着盯
115
+ 空闲 Nd 超过空闲期,下轮 gc 自动回收(不用手动清)
116
+
117
+ 判据是**空闲多久**,不是落后多少条。落后量只作展示:
118
+ 它是遗弃的症状而不是反面 —— 被遗弃的游标落后会一路涨,
119
+ 拿它当清理判据会把该清的保护起来、该留的删掉(2026-09-06 实测)。
112
120
 
113
- 排障入口:「○ 暂停」而你以为它在盯 -> 它的 take 没起来或早退了;
121
+ 排障入口:「○ 空闲」而你以为它在盯 -> 它的 take 没起来或早退了;
114
122
  「无消费者」= 白采,可考虑 --exclude。
115
- stale 游标清理:lark-relay cursors --prune
116
123
  collect 行不是 running -> tail -f ~/.lark-relay/logs/collect.err.log
117
124
  (launchd 不进统一日志),collect 停摆的每一秒都在丢消息。`
118
125
 
119
- const CURSORS_HELP = `游标治理:看清哪些还在值守,清掉已失效的。
126
+ const CURSORS_HELP = `游标状态:谁在值守、空闲多久。**清理是自动的,不用你记。**
120
127
 
121
128
  lark-relay cursors # 列出全部游标及状态
122
- lark-relay cursors --prune # 清掉全部 stale 的
123
- lark-relay cursors --prune <name>... # 清指定身份的全部 app
124
- lark-relay cursors --prune <name> --force # 连「○ 暂停」的一起清
129
+ lark-relay cursors --json # 机器可读
130
+ lark-relay cursors --forget <name>... # 不等超期,现在就清掉
131
+
132
+ 状态:● 在跑 / ○ 空闲(最近用过)/ ⚠ 空闲超期(下轮 gc 回收)
133
+
134
+ **空闲超过 2 天的游标由 collect 自动回收**(每小时自查一次,与 store 的
135
+ 日期目录回收同一个 gc)。判据是「多久没人用过它」-- 与遗弃程度同向单调,
136
+ 且不需要人做判断。默认 2 天:take 是实时场景的取用口,
137
+ 空闲一两天以上再重挂,下一次消费一定从新的开始;要历史消息有专门的接口。
138
+ 改期限:LARK_RELAY_CURSOR_TTL_DAYS=<天>
125
139
 
126
- 三态:● 在跑 / ○ 暂停(窗口内,可续接) / ⚠ stale(超窗口,续接无意义)
140
+ ⚠️ 判据**不是**「落后多少条」,也不是「游标停在哪天」 -- 这两个都试过、都错了
141
+ (方向反的,见下)。落后量只作展示。
127
142
 
128
- **只有 stale 是无条件可清的**:
129
- 在跑 不删。take 会立刻把游标写回来
130
- ○ 暂停 要 --force。它还能续接,删了重挂就按 --since now 跳过这段消息
131
- ⚠ stale 直接清。那段消息 gc 已回收,续接和 --since now 等价 -> 删它无损
143
+ 在跑的一律不回收 —— take 会立刻把游标写回来
144
+ 被回收后重挂 = 按 --since now 起头,跳过那段已落盘的消息。
145
+ 这正是「两天没人管」该有的行为;真要接着取,在超期前重挂就行
132
146
 
133
- 删的单位是 **app**,不是身份:一个 --name 可以同时盯多个 app,
134
- 裸 --prune 只清其中 stale 的那些(在跑的静默跳过)。
135
- 连带删该 app 的 dedup 记录(<app>.seen.json),不留会让重挂后头几条被误判重复。
147
+ --forget 是逃生舱(验完了、现在就想清干净)。它不需要二次确认 --
148
+ 回收本来会自动发生,人只是提前几天。
149
+ 删的单位是 app 目录下的每个 app,连带删该 app 的 dedup 记录
150
+ (<app>.seen.json),不留会让重挂后头几条被误判重复。
136
151
 
137
- 退出码:0 正常,2 参数错或点名的身份不存在,
138
- 3 点名的目标一个都没清掉(在跑,或需要 --force)`
152
+ 退出码:0 正常,2 参数错或点名的身份不存在,3 点名的在跑(没清掉)`
139
153
 
140
154
  const GUIDE = `lark-relay -- Lark 事件中继站。两件事:收下来 / 我来取。
141
155
 
@@ -158,12 +172,10 @@ take 四步(照做)
158
172
  起来后 stderr 有一行「监听中」-- 没有就是没起来,看 stderr。
159
173
  一批处理完再起一个。不要自己写 while 循环 -- 进程退出会通知你。
160
174
  退出码:0=有一批,4=超时没消息(正常,再起一个),2=参数错,3=游标被占用。
161
- 4. 不盯了收尾 lark-relay cursors --prune
162
- 游标为「关了会话、过阵子接着盯」而存在,引擎不主动删你的续接凭据。
163
- 临时监听、验完的排查、不再值守的任务,收尾时自己清 --
164
- 不清的代价:status 里一直挂着假积压(数字会随 store 过期变小,
165
- 看着像追上了,其实是消息永久没了)。
166
- 显式清掉某个身份:lark-relay cursors --prune <name>
175
+ 4. 不盯了就走开 什么都不用做
176
+ 空闲超 2 天的游标由 collect 自动回收(每小时自查),不用你记得清理。
177
+ 想立刻清干净:lark-relay cursors --forget <name>
178
+ 看状态:lark-relay cursors( 空闲 / ⚠ 超期待回收 / ● 在跑)
167
179
 
168
180
  常见错误
169
181
  · 拿不到消息 -> 九成是 bot 不在群。验 bot 必须 --as user,
package/lib/larkcli.js CHANGED
@@ -23,32 +23,15 @@ function run(args, opts = {}) {
23
23
  })
24
24
  }
25
25
 
26
- // lark-cli 的 stdout 可能有前置非 JSON 行(如「installed successfully」自更新提示),
27
- // 剥到第一个 { 起。这是 lark-cli 的输出契约,故与 run 同处一地。
28
- //
29
- // ⚠️ **只适用于对象响应**:数组响应会被切坏 ——
30
- // stripToJson('[{"a":1}]') → '{"a":1}]' → JSON.parse 抛错。
31
- // listProfiles 解析的正是数组,所以它不能用这个(它直接 JSON.parse)
32
- function stripToJson(s) {
33
- const i = String(s).indexOf('{')
34
- return i === -1 ? '{}' : String(s).slice(i)
35
- }
36
-
37
- // execFile 失败时错误信息散在三个字段里,取第一个有内容的
38
- function errText(err) {
39
- return String(err.stdout || err.stderr || err.message || err)
40
- }
41
-
42
- // 压成单行并截断,给 stderr 日志用。行首那段才是错误码/原因所在
43
- function tail(s, n = 300) {
44
- return String(s).replace(/\n/g, ' ').slice(0, n)
45
- }
46
-
47
26
  // profile list 默认输出 JSON,无需 --json。
48
27
  // 两类不可用,都要跳过,否则 consume 一直失败重试(死循环):
49
28
  // - tokenStatus 为 expired:授权过期,永久失败直到用户重新 login
50
29
  // - 没有 user 字段:该 profile 从未 auth 过(实测未登录的 profile 只有
51
30
  // name/appId/brand/active 四个字段,无 user 也无 tokenStatus)
31
+ //
32
+ // 注:2026-09-06 删掉了 stripToJson / errText / tail 三个导出 —— 零调用方
33
+ // (含测试)。stripToJson 还带着 6 行「只适用于对象响应」的警告,
34
+ // 等于为没人调用的函数维护陷阱说明。要用再从 git 历史取
52
35
  async function listProfiles() {
53
36
  const { stdout } = await run(['profile', 'list'])
54
37
  const arr = JSON.parse(stdout)
@@ -78,4 +61,4 @@ function spawnConsume(profile, eventKey, extraArgs = []) {
78
61
  return spawn(CLI, args, { stdio: ['pipe', 'pipe', 'pipe'] })
79
62
  }
80
63
 
81
- module.exports = { CLI, run, stripToJson, errText, tail, listProfiles, spawnConsume }
64
+ module.exports = { CLI, run, listProfiles, spawnConsume }
package/lib/render.js CHANGED
Binary file
package/lib/status.js CHANGED
@@ -50,19 +50,13 @@ function collectHint() {
50
50
  return `sudo launchctl bootstrap system /Library/LaunchDaemons/${COLLECT_LABEL}.plist`
51
51
  }
52
52
 
53
- // 消费者 = cursors/ 下的目录。枚举与三态判定都在 lib/cursors.js,这里只做展示 ——
54
- // 「哪些游标存在、谁还活着」是治理逻辑,status 和 cursors 子命令必须用同一份口径
53
+ // 消费者 = cursors/ 下的目录。枚举与状态判定都在 lib/cursors.js,这里只做展示 ——
54
+ // 「哪些游标存在、谁还活着、还剩多少没消费」是治理逻辑,
55
+ // status 和 cursors 子命令必须用同一份口径
55
56
  function listConsumers() {
56
57
  return cursors.listCursors()
57
58
  }
58
59
 
59
- // 落后多少条:游标之后还剩几个事件没消费
60
- function lagOf(app, cursor) {
61
- const all = store.scanWindow(app)
62
- if (!cursor) return all.length
63
- return all.filter((e) => store.afterCursor(e, cursor)).length
64
- }
65
-
66
60
  async function buildStatus() {
67
61
  const { state, pid } = serviceState()
68
62
 
@@ -90,10 +84,18 @@ async function buildStatus() {
90
84
  latestMs = Number(ev?.timestamp || ev?.create_time) || null
91
85
  }
92
86
  const cs = (byApp.get(app) || []).map((c) => {
93
- // stale lag 不算也不显示 —— 那个数字是误导的来源:gc 删掉的部分不再计入,
94
- // 于是越久没消费看起来越「追上了」,而实际是消息永久没了
95
- const lag = c.state === 'stale' ? null : lagOf(app, c.cursor)
96
- return { name: c.name, lag, state: c.state, pid: c.pid, cursor: c.cursor, day: c.day }
87
+ // lag = 实测待消费条数。它是**展示量,不是治理判据** ——
88
+ // 判据是空闲时长(见 lib/cursors.js):落后量是遗弃的症状,不是遗弃的反面,
89
+ // 拿它决定删不删会把「该清的保护起来、该留的删掉」(2026-09-06 实测)
90
+ return {
91
+ name: c.name,
92
+ lag: c.pending,
93
+ state: c.state,
94
+ pid: c.pid,
95
+ cursor: c.cursor,
96
+ day: c.day,
97
+ idleMs: c.idleMs,
98
+ }
97
99
  })
98
100
  rows.push({ app, today: store.countToday(app), latestMs, consumers: cs })
99
101
  }
@@ -127,9 +129,18 @@ function formatStatus(s) {
127
129
  }
128
130
  const w = (str, n) => String(str) + ' '.repeat(Math.max(1, n - dispWidth(str)))
129
131
  out.push(`${w('app', 8)}${w('今日事件', 10)}${w('最新事件', 13)}消费者`)
130
- let staleCount = 0
132
+ let expiringCount = 0
133
+ const idleTxt = (ms) => {
134
+ if (!Number.isFinite(ms)) return '未知'
135
+ // 向下取整:这个数是回收判据,宁可少报不可多报 --
136
+ // Math.round 会把 2.5 天显示成「3d」,看着像已经超了 2 天的 TTL 更多
137
+ const m = Math.floor(ms / 60000)
138
+ if (m < 60) return `${m}m`
139
+ const h = Math.floor(m / 60)
140
+ return h < 48 ? `${h}h` : `${Math.floor(h / 24)}d`
141
+ }
131
142
  for (const r of s.apps) {
132
- // 消费者各占一行:三态要一眼分得清,挤在一行里 ●/○/⚠ 会被淹掉。
143
+ // 消费者各占一行:状态要一眼分得清,挤在一行里 ●/○/⚠ 会被淹掉。
133
144
  // 「谁在跑」是排障第一问,以前得靠猜(全都长成 `name(落后 N 条)`)
134
145
  if (!r.consumers.length) {
135
146
  out.push(`${w(r.app, 8)}${w(r.today, 10)}${w(ago(r.latestMs), 13)}—(无消费者)`)
@@ -137,28 +148,25 @@ function formatStatus(s) {
137
148
  }
138
149
  out.push(`${w(r.app, 8)}${w(r.today, 10)}${w(ago(r.latestMs), 13)}`.trimEnd())
139
150
  for (const c of r.consumers) {
140
- const lagTxt = c.lag === 0 ? '游标追平' : `落后 ${c.lag} 条`
141
151
  let mark
142
152
  let note
143
153
  if (c.state === 'live') {
144
154
  mark = '●'
145
- note = `在跑 (pid ${c.pid}, ${lagTxt})`
146
- } else if (c.state === 'stale') {
147
- staleCount++
155
+ note = `在跑 (pid ${c.pid}, ${c.lag === 0 ? '游标追平' : `落后 ${c.lag} 条`})`
156
+ } else if (c.state === 'expiring') {
157
+ expiringCount++
148
158
  mark = '⚠'
149
- // 不给 lag:它已经不代表「还能取到多少」。给位置和原因才可行动
150
- note = `stale (游标停在 ${c.day || '?'},已超 ${cursors.oldestScannedDay()} 的扫描窗口,续接取不到东西)`
159
+ note = `空闲 ${idleTxt(c.idleMs)} —— 已超期,下轮 gc 自动回收`
151
160
  } else {
152
161
  mark = '○'
153
- note = `暂停 (${lagTxt},可续接)`
162
+ note = `空闲 ${idleTxt(c.idleMs)} (待消费 ${c.lag},重挂 take 接着盯)`
154
163
  }
155
164
  out.push(` ${w(c.name, 24)}${mark} ${note}`)
156
165
  }
157
166
  }
158
- if (staleCount) {
167
+ if (expiringCount) {
159
168
  out.push('')
160
- out.push(`⚠ ${staleCount} stale 游标 —— 那段消息 gc 已回收,留着只会虚报积压`)
161
- out.push(' 清理:lark-relay cursors --prune')
169
+ out.push(`⚠ ${expiringCount} 个游标已超空闲期 —— collect 每小时自查时回收,不用手动清`)
162
170
  }
163
171
 
164
172
  return out.join('\n')
package/lib/store.js CHANGED
@@ -7,11 +7,17 @@ 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
- // ⚠️ collect --retain 是两个进程里的两个值,没法在运行时互相读取,
10
+ // scan 窗口天数。只扫窗口内是为了避免全目录排序(旧架构每轮全扫 4467 个文件)。
11
+ //
12
+ // ⚠️ 必须**等于** store 保留天数,不是「>= 就行」。两边差 1 会造出一个三不管的日子:
13
+ // 2026-09-06 实测 SCAN_DAYS=4 / retain=3 时,D-3 被 gcStore 整目录删掉了,
14
+ // 却仍被算作「窗口内」-> 停在那天的游标被当成「还能续接」,而那天的数据一条都不在了。
15
+ // 方向反过来(窗口 < 保留)则是「留着却扫不到」。
16
+ // 两个方向都错,所以判据不是不等式而是等号 —— 默认值就按等号配。
17
+ //
18
+ // 与 collect 的 --retain 是两个进程里的两个值,没法在运行时互相读取,
13
19
  // 故由 collect 启动时校验(见 bin/lark-relay-collect.js),这里导出给它用
14
- const SCAN_DAYS = Number(process.env.LARK_RELAY_SCAN_DAYS || 4)
20
+ const SCAN_DAYS = Number(process.env.LARK_RELAY_SCAN_DAYS || 3)
15
21
 
16
22
  // 原子落盘:同目录 写 .tmp → rename。
17
23
  // 这是 partial write 的正解 —— lark-cli --output-dir 先建 0 字节再填充,
@@ -122,6 +128,35 @@ function writeCursor(name, app, key) {
122
128
  fs.renameSync(tmp, file)
123
129
  }
124
130
 
131
+ // 把游标标记成「刚被用过」。回收判据是空闲时长(见 lib/cursors.js),
132
+ // 而它读的是 mtime —— 所以「有人在用」必须能刷新 mtime。
133
+ //
134
+ // ⚠️ 为什么不能只靠 writeCursor 顺带刷新:take 的 abort 路径是
135
+ // `if (batch.length && commit) writeCursor` —— **空手被 SIGTERM 时不写**。
136
+ // 实测:游标写入后起一个 take、3 秒后 kill,mtime 一动不动。
137
+ // 于是「一个安静的群盯了两天没消息」会被误判成遗弃并回收。
138
+ // 故 take 启动时显式 touch 一次,让「有人来取过」这件事本身就算活动
139
+ function touchCursor(name, app) {
140
+ const file = paths.cursor(name, app)
141
+ try {
142
+ const now = new Date()
143
+ fs.utimesSync(file, now, now)
144
+ return true
145
+ } catch {
146
+ return false // 还没有游标文件(首次跑),seedCursorNow 会建
147
+ }
148
+ }
149
+
150
+ // 游标最后一次被使用的时间(ms)。读不到返回 null。
151
+ // mtime 而非 atime:atime 会被 status/cursors 的只读扫描刷新,那不算「在用」
152
+ function cursorMtime(name, app) {
153
+ try {
154
+ return fs.statSync(paths.cursor(name, app)).mtimeMs
155
+ } catch {
156
+ return null
157
+ }
158
+ }
159
+
125
160
  // --since now:游标落到当前最新,不重放历史。
126
161
  // 默认值反过来就消灭了旧架构 adhoc SOP 的「预热游标」那一步。
127
162
  function seedCursorNow(name, app) {
@@ -184,6 +219,8 @@ module.exports = {
184
219
  readEvent,
185
220
  readCursor,
186
221
  writeCursor,
222
+ touchCursor,
223
+ cursorMtime,
187
224
  seedCursorNow,
188
225
  afterCursor,
189
226
  entryKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-relay",
3
- "version": "0.4.14",
3
+ "version": "0.5.0",
4
4
  "description": "Lark event relay: collect events to disk, take a batch when you need it.",
5
5
  "keywords": [
6
6
  "lark",