lark-relay 0.4.13 → 0.4.15

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
 
@@ -81,10 +81,21 @@ lark-relay take --app <app> --chats oc_xxx --render text
81
81
  盯同一 app 请用不同 `--name` 隔离游标。
82
82
 
83
83
  **游标要自己收尾**:它是「关了会话、过阵子接着盯」的续接凭据,所以引擎**不会**
84
- 自动清理不活跃的游标 -- 自动清掉就等于下次重挂要么全量重放、要么跳过停机期的消息。
85
- 代价是不再用的游标会一直留在 `status` 里显示「落后 N 条」,而 store 只保留几天,
84
+ 自动清理还有积压的游标 -- 自动清掉就等于下次重挂跳过这段已落盘的消息。
85
+ 代价是不再用的游标会一直留在 `status` 里显示「有 N 条待消费」,而 store 只保留几天,
86
86
  那个 N 会随过期删除而变小(**看着像追上了,其实是消息没了**)。
87
- 一次性监听、验完的排查、不再值守的任务,收尾时 `rm -rf ~/.lark-relay/cursors/<name>`。
87
+
88
+ 一次性监听、验完的排查、不再值守的任务,收尾时:
89
+
90
+ ```bash
91
+ lark-relay cursors # ● 在跑 / ○ 已消费完 / ⚠ 有积压没人跑
92
+ lark-relay cursors --prune # 清「已消费完」的(删掉无损)
93
+ ```
94
+
95
+ 判据只有一条:按这个游标续接还剩几条没消费。剩 0 条才无条件可清;
96
+ 还有积压的要 `--prune <name> --force` 明确确认(删了就跳过那些消息)。
97
+ 别手工 `rm -rf` 游标目录 -- 那会漏掉同名的 `<app>.seen.json`(去重记录),
98
+ 残留会让重挂后头几条消息被误判成重复而静默丢掉。
88
99
 
89
100
  ## 设计取舍
90
101
 
@@ -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,10 +17,8 @@ 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
 
@@ -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,28 +138,32 @@ 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
+ // 还打一行「那段消息已被 gc 回收」宽慰你。老游标本身工作得很好,不需要治。
150
+ //
151
+ // 位置早于窗口确实要说一句,但说的是「D-x 到 D-y 那段已被 gc 回收、找不回」
152
+ // (告知既成事实),而不是动用户的游标 —— 剩下能取到的照常取。
150
153
  if (since === 'now') {
151
154
  const existing = store.readCursor(name, a.app)
152
155
  if (existing === null) {
153
156
  store.seedCursorNow(name, a.app)
154
157
  } else {
155
- const { cursorDay, isStaleDay, oldestScannedDay } = require('../lib/cursors')
158
+ const { cursorDay, isPreWindowDay, oldestScannedDay } = require('../lib/cursors')
156
159
  const day = cursorDay(existing)
157
- if (isStaleDay(day)) {
160
+ if (isPreWindowDay(day)) {
158
161
  // 说明行必须打在「监听中」**之前** —— 那一行是约定的启动信号
159
162
  // (「没这行就是没起来」),不能被别的输出插到中间
160
163
  process.stderr.write(
161
- `游标 ${name} 停在 ${day},已超扫描窗口(最早 ${oldestScannedDay()})\n` +
162
- `-> 那段消息已被 gc 回收,按 --since now 重新起头\n`,
164
+ `游标 ${name} 停在 ${day},早于扫描窗口(最早 ${oldestScannedDay()})\n` +
165
+ `-> 那之前的消息已被 gc 回收、找不回;窗口内还在的会照常取\n`,
163
166
  )
164
- store.seedCursorNow(name, a.app)
165
167
  }
166
168
  }
167
169
  }
@@ -218,9 +220,9 @@ async function cmdTake(argv) {
218
220
  }
219
221
 
220
222
  function cmdCursors(argv) {
221
- const CURSORS_FLAGS = ['prune', 'json', 'help']
223
+ const CURSORS_FLAGS = ['prune', 'force', 'json', 'help']
222
224
  const a = parseArgs(argv, { flags: CURSORS_FLAGS, keys: [] })
223
- if (a._unknown.length) return rejectUnknown('lark-relay cursors', argv, a._unknown, CURSORS_FLAGS)
225
+ if (a._unknown.length) return rejectUnknown('lark-relay cursors', a._unknown, CURSORS_FLAGS)
224
226
  if (a.help) {
225
227
  process.stdout.write(`${help.CURSORS_HELP}\n`)
226
228
  return 0
@@ -238,50 +240,65 @@ function cmdCursors(argv) {
238
240
  process.stdout.write('没有游标(还没跑过 take,或已全部清理)\n')
239
241
  return 0
240
242
  }
241
- const mark = { live: '●', idle: '○', stale: '⚠' }
243
+ const mark = { live: '●', drained: '○', backlog: '⚠' }
242
244
  const note = {
243
- live: (r) => `在跑 (pid ${r.pid})`,
244
- idle: () => '暂停,可续接',
245
- stale: () => '超扫描窗口,续接取不到东西 -> 可清理',
245
+ live: (r) => `在跑 (pid ${r.pid}, 待消费 ${r.pending})`,
246
+ drained: () => '已消费完 -> 删掉无损',
247
+ backlog: (r) => `有 ${r.pending} 条待消费但没人在跑 -> 重挂 take 能接着取`,
246
248
  }
247
249
  for (const r of rows) {
250
+ // 位置早于扫描窗口 = 那之前的一段已被 gc 回收、找不回。
251
+ // ⚠️ 这**不是**可删许可(早先的 stale 判据就错在这一步):
252
+ // 窗口内还在的事件,老游标照样全部放行。能否删只看待消费数
253
+ const lost = r.lostSpan ? `,${r.day} 之前的已被 gc 回收` : ''
248
254
  process.stdout.write(
249
- `${mark[r.state]} ${r.name} app=${r.app} 位置=${r.day} ${note[r.state](r)}\n`,
255
+ `${mark[r.state]} ${r.name} app=${r.app} 位置=${r.day} ${note[r.state](r)}${lost}\n`,
250
256
  )
251
257
  }
252
- const stale = rows.filter((r) => r.state === 'stale').length
253
- if (stale) process.stdout.write(`\n${stale} stale -> lark-relay cursors --prune\n`)
258
+ const drained = rows.filter((r) => r.state === 'drained').length
259
+ if (drained) process.stdout.write(`\n${drained} 个已消费完 -> lark-relay cursors --prune\n`)
254
260
  return 0
255
261
  }
256
262
 
257
263
  // --prune:位置参数是要删的身份;没给就只清 stale 的那些
258
264
  const names = a._
259
- const res = cursors.prune(names)
265
+ const res = cursors.prune(names, { force: !!a.force })
260
266
 
261
267
  // removed 的元素是 `name/app` —— 删的单位是 app,一个身份可能盯多个
262
268
  for (const n of res.removed) process.stdout.write(`已删 ${n}\n`)
263
269
  for (const n of res.missing) process.stderr.write(`没有这个游标身份:${n}\n`)
264
270
 
265
- // 在跑的一律不删 —— take 会立刻把游标写回来,删了是白删还让人以为清理生效了
271
+ // 在跑的不删 -- take 会立刻把游标写回来。只有显式点名时才报告(见 cursors.prune)
266
272
  for (const r of res.refused) {
267
273
  process.stderr.write(
268
274
  `跳过 ${r.name}:有 take 正在用它(pid ${r.pid})。\n` +
269
275
  ` 要清它先停掉那个 take(kill ${r.pid}),否则它会把游标立刻写回来。\n`,
270
276
  )
271
277
  }
272
- if (!res.removed.length && !res.missing.length && !res.refused.length) {
273
- process.stdout.write(names.length ? '没删任何东西\n' : '没有 stale 游标,无需清理\n')
278
+
279
+ // 还有待消费的游标,删了就丢掉那些消息 -- --force 再确认一次。
280
+ // 2026-09-06 真实踩到:点名删一个刚退出 take 的游标,那段消息的消费位置没了
281
+ for (const f of res.needForce) {
282
+ process.stderr.write(
283
+ `⚠ ${f.name}/${f.app} 还有 ${f.pending} 条待消费(位置 ${f.day})\n` +
284
+ ` 删了就丢:重挂时会按 --since now 跳过这 ${f.pending} 条已落盘的消息\n` +
285
+ ` 想取走 -> lark-relay take --app ${f.app} --name ${f.name} --chats oc_xxx\n` +
286
+ ` 确认不要 -> lark-relay cursors --prune ${f.name} --force\n`,
287
+ )
288
+ }
289
+
290
+ if (!res.removed.length && !res.missing.length && !res.refused.length && !res.needForce.length) {
291
+ process.stdout.write(names.length ? '没删任何东西\n' : '没有已消费完的游标,无需清理\n')
274
292
  }
275
- // 退出码:点名的目标一个都没删成(全被拒/全不存在)才算失败 ——
276
- // 裸 prune 顺带跳过一个在跑的游标不是错误,那次清理是成功的
293
+ // 点名的目标一个都没删成才算失败
277
294
  if (res.missing.length) return 2
278
- if (res.refused.length && !res.removed.length && names.length) return 3
295
+ if ((res.refused.length || res.needForce.length) && !res.removed.length) return 3
279
296
  return 0
280
297
  }
281
298
 
282
299
  async function cmdStatus(argv) {
283
300
  const a = parseArgs(argv, { flags: ['json', 'help'], keys: [] })
284
- if (a._unknown.length) return rejectUnknown('lark-relay status', argv, a._unknown, ['json'])
301
+ if (a._unknown.length) return rejectUnknown('lark-relay status', a._unknown, ['json'])
285
302
  if (a.help) {
286
303
  process.stdout.write(`${help.STATUS_HELP}\n`)
287
304
  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
@@ -10,6 +10,7 @@ const readline = require('readline')
10
10
  const larkcli = require('./larkcli')
11
11
  const store = require('./store')
12
12
  const { paths, ensureDir } = require('./paths')
13
+ const { acquireExclusive } = require('./lock')
13
14
  const { init: logInit, logLine } = require('./log')
14
15
 
15
16
  const EVENT_KEY = process.env.LARK_RELAY_EVENT_KEY || 'im.message.receive_v1'
@@ -120,6 +121,26 @@ async function runCollect(opts) {
120
121
  // 非 launchd 场景(前台调试)是 no-op,logLine 退化成裸 stderr 直写
121
122
  logInit()
122
123
 
124
+ // 互斥:同一台机器只该有一个 collect。
125
+ // ⚠️ 这比 take 的锁更要紧,而它长期没有:同一 app 服务端只放行一个 event bus,
126
+ // 第二个 collect 起来会把线上那个挤掉,双方按 1s->2s->...->60s 退避互抢,
127
+ // 实测每 app 重启约 10 次、累计约 5 分钟才稳定 —— 那 5 分钟的消息**永久丢失**。
128
+ // 现实诱因不是手滑:COLLECT_HELP 教「前台跑(调试)」,deploy-launchd.sh 在
129
+ // bootstrap 失败时更直接建议「应急顶住采集:lark-relay-collect」--
130
+ // 若 launchd 那个其实还活着,照做就是自己抢自己。
131
+ // 拿不到锁就退出并给出判据,不留「看着在跑其实在互抢」的中间态
132
+ const lock = acquireExclusive('collect')
133
+ if (!lock.ok) {
134
+ logLine(
135
+ `error: 已有一个 lark-relay-collect 在跑(pid ${lock.holder}),本进程退出。\n` +
136
+ `同一 app 服务端只放行一个 event bus —— 两个 collect 会互相挤掉并退避重连,\n` +
137
+ `实测约 5 分钟才稳定,那段时间的消息永久丢失。\n` +
138
+ `确认是谁在跑: launchctl print system/com.adaex.lark-relay-collect\n` +
139
+ `要接管请先停掉它:sudo launchctl bootout system/com.adaex.lark-relay-collect`,
140
+ )
141
+ return 1
142
+ }
143
+
123
144
  const exclude = new Set(opts.exclude || [])
124
145
  let profiles
125
146
  try {
package/lib/cursors.js CHANGED
@@ -1,27 +1,34 @@
1
1
  'use strict'
2
2
 
3
- // cursors:游标治理 -- 判定每个游标是「在跑 / 暂停可续接 / 已失效」,并支持清理。
3
+ // cursors:游标治理 -- 判定每个游标能不能删,并支持清理。
4
4
  //
5
5
  // 为什么需要这个模块:纪律「不盯了就删游标」写在 AGENTS.md 和 help 三处,
6
6
  // 却从落地到 2026-09-06 一次都没被执行 -- 8 个游标全留着,0 个在值守。
7
- // 根因不是人不自觉,是**没有反馈回路**:
8
- // status 把死游标和活消费者渲染成同一个样子,看不出谁有人在跑
9
- // ② 唯一的信号「落后 N 条」**越久越小** -- store 只留几天,gc 删掉的部分不再计入 lag,
10
- // 于是越该清理的游标看起来越健康(实测 novel-watch-lianhua 从 373 条一路缩水)
11
- // 所以这里给出机械判据,让状态可见、清理一键化,而不是把纪律再写一遍。
7
+ // 根因不是人不自觉,是**没有反馈回路**:status 把死游标和活消费者渲染成同一个样子,
8
+ // 看不出谁有人在跑。所以这里给出机械判据,让状态可见、清理一键化。
9
+ //
10
+ // ⚠️ 2026-09-06 重写:上一版判据是**错的**,推理反了,实测证伪。
11
+ // 原文写「游标日期早于扫描窗口 -> 续接它和 --since now 完全等价 -> 删掉无损」。
12
+ // 但 afterCursor 比的是「事件日期 > 游标日期」-- **游标越老,通过过滤的事件越多**。
13
+ // 实测:游标停在 D-5、窗口内 D-2/D-1/D-0 各有 1 条未消费,
14
+ // 按该游标续接 -> 扫到 3 条(全是能取到的)
15
+ // --since now -> 扫到 0 条
16
+ // gc 删掉的只是游标所在那几天,窗口内的事件一条没删,而老游标恰好全部放行。
17
+ // 「续接取不到东西」正好说反了,照它删就是丢掉窗口内的全部积压。
18
+ //
19
+ // **正解:别再从游标日期推断,直接量。** 能不能无损删除只取决于一件事 --
20
+ // 按这个游标续接还能扫到几条(pending)。pending==0 才是真的删掉无损。
21
+ // 游标日期早于窗口是另一回事:它说明**那段已被 gc 回收、永久找不回**,
22
+ // 这是要告知的事实,不是可以删除的许可。两件事必须分开,混在一起就是上一版的错。
12
23
  const fs = require('fs')
13
24
  const path = require('path')
14
25
  const store = require('./store')
15
26
  const { paths, dayKeyOffset } = require('./paths')
16
27
  const { lockPathFor, holderPid } = require('./lock')
17
28
 
18
- // stale 判据的地基(**整个方案的正确性都压在这一条上**):
19
- // 游标位置的日期早于扫描窗口最老的一天时,续接它和 --since now 行为**完全等价** --
20
- // scanWindow 只看最近 SCAN_DAYS 天的目录,更早的日期 collect 的 gc 已经整目录删了,
21
- // afterCursor 过滤后必然为空。也就是说**删掉这种游标是无损的**,不会丢任何还能取到的消息。
22
- //
23
- // ⚠️ 反过来说,窗口**内**的游标不能自动删 -- 那才是「关了会话、过阵子接着盯」的续接凭据,
24
- // 删了等于跳过停机期已落盘的消息。这是 idle 与 stale 必须分开的全部理由
29
+ // 扫描窗口最老的一天。比它更早的日期,collect 的 gc 已经整目录删了。
30
+ // ⚠️ 这个值只用来**告知「那段找不回了」**,不参与「能否删除」的判定 --
31
+ // 那正是上一版把两件事混在一起犯的错
25
32
  function oldestScannedDay() {
26
33
  return dayKeyOffset(-(store.SCAN_DAYS - 1))
27
34
  }
@@ -32,8 +39,10 @@ function cursorDay(key) {
32
39
  return String(key || '').split('/')[0] || null
33
40
  }
34
41
 
35
- function isStaleDay(day) {
36
- if (!day) return true // 读不出日期的游标续接不了,按失效处理
42
+ // 游标位置是否早于扫描窗口 = 它与当前位置之间有一段已被 gc 回收、永久找不回。
43
+ // 注意这**不**代表游标没用了:窗口内的事件它照样全部放行(见文件头实测)
44
+ function isPreWindowDay(day) {
45
+ if (!day) return true // 读不出日期的游标,当作位置不明
37
46
  return day < oldestScannedDay()
38
47
  }
39
48
 
@@ -43,9 +52,17 @@ function livePidOf(name) {
43
52
  return holderPid(`take-${name}`)
44
53
  }
45
54
 
46
- // 枚举 cursors/ 下的全部游标。
47
- // 一个 name 目录下可能有多个 app 文件,外加 dedup 的 <app>.seen.json 与落盘用的 .tmp --
48
- // 只有「没后缀的那些」才是游标本身,别把 dedup 文件当成一个 app
55
+ /**
56
+ * 枚举 cursors/ 下的全部游标,并实测每个的待消费条数。
57
+ *
58
+ * 一个 name 目录下可能有多个 app 文件,外加 dedup 的 <app>.seen.json 与落盘用的 .tmp --
59
+ * 只有「没后缀的那些」才是游标本身,别把 dedup 文件当成一个 app。
60
+ *
61
+ * @returns {Array<{name,app,cursor,day,state,pid,pending,lostSpan}>}
62
+ * state: live(有 take 在跑) | backlog(有待消费,删了就丢) | drained(消费完了,删掉无损)
63
+ * pending: 按此游标续接还能扫到几条 —— **能否删除的唯一判据**
64
+ * lostSpan: 位置早于扫描窗口,中间那段已被 gc 回收、找不回(与能否删除无关,仅告知)
65
+ */
49
66
  function listCursors() {
50
67
  let names
51
68
  try {
@@ -53,6 +70,13 @@ function listCursors() {
53
70
  } catch {
54
71
  return [] // 还没跑过任何 take
55
72
  }
73
+ // 同一 app 可能被多个身份盯着,scanWindow 一次就够 —— 每行都扫一遍是平方级浪费
74
+ const scanCache = new Map()
75
+ const scanOf = (app) => {
76
+ if (!scanCache.has(app)) scanCache.set(app, store.scanWindow(app))
77
+ return scanCache.get(app)
78
+ }
79
+
56
80
  const out = []
57
81
  for (const name of names.sort()) {
58
82
  const dir = path.join(paths.cursors, name)
@@ -69,10 +93,11 @@ function listCursors() {
69
93
  const key = store.readCursor(name, f)
70
94
  if (!key) continue
71
95
  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 })
96
+ // 实测待消费数 —— 判据在这里,不在日期上
97
+ const pending = scanOf(f).filter((e) => store.afterCursor(e, key)).length
98
+ // live 优先:take 正在跑就以它为准,它自己会把游标推进
99
+ const state = pid ? 'live' : pending > 0 ? 'backlog' : 'drained'
100
+ out.push({ name, app: f, cursor: key, day, state, pid, pending, lostSpan: isPreWindowDay(day) })
76
101
  }
77
102
  }
78
103
  return out
@@ -83,9 +108,9 @@ function listCursors() {
83
108
  // 被误判成重复而静默丢掉(TTL 6 小时,见 dedup.js)。
84
109
  //
85
110
  // ⚠️ 粒度必须是 app 而不是整个身份目录:一个 --name 可以同时盯多个 app
86
- // (cursors/<name>/appA、<name>/appB),而 stale 是**逐 app 判定**的。
87
- // 早先这里 rm -rf 整个 name 目录,于是「appA 已过期、appB 还在窗口内」时
88
- // 清 appA 会把 appB 那个**还能续接**的游标一起删掉(实测复现)。
111
+ // (cursors/<name>/appA、<name>/appB),而状态是**逐 app 判定**的。
112
+ // 早先这里 rm -rf 整个 name 目录,于是「appA 已消费完、appB 还有积压」时
113
+ // 清 appA 会把 appB 那个**还有积压**的游标一起删掉(实测复现)。
89
114
  function removeCursorApp(name, app) {
90
115
  const dir = path.join(paths.cursors, name)
91
116
  let ok = false
@@ -114,11 +139,14 @@ function removeStaleLock(name) {
114
139
 
115
140
  /**
116
141
  * 清理游标。
117
- * @param {string[]} names 指定要删的身份(该身份下全部 app);空数组 = 只删 stale 的那些 app
118
- * @returns {{removed:string[], refused:Array<{name:string,pid:number}>, missing:string[]}}
119
- * removed 的元素形如 `name/app` -- 删的单位是 app,不是整个身份
142
+ * @param {string[]} names 指定要删的身份(该身份下全部 app);空数组 = 只删已消费完的
143
+ * @param {{force?:boolean}} opts force 才允许删还有积压(backlog)的游标
144
+ * @returns {{removed:string[], refused:Array<{name,pid}>, missing:string[],
145
+ * needForce:Array<{name,app,day,pending}>}}
146
+ * removed 的元素形如 `name/app` -- 删的单位是 app,不是整个身份。
147
+ * refused 只在**显式点名**时填充:裸 prune 遇到在跑的游标是静默跳过,不算例外
120
148
  */
121
- function prune(names = []) {
149
+ function prune(names = [], opts = {}) {
122
150
  const all = listCursors()
123
151
  // 一个 name 可能有多个 app 行,按身份归并 -- live 判定是按身份的(锁以 name 为键)
124
152
  const byName = new Map()
@@ -130,6 +158,7 @@ function prune(names = []) {
130
158
  const removed = []
131
159
  const refused = []
132
160
  const missing = []
161
+ const needForce = []
133
162
 
134
163
  const targets = names.length ? names : [...byName.keys()]
135
164
 
@@ -139,23 +168,35 @@ function prune(names = []) {
139
168
  missing.push(name)
140
169
  continue
141
170
  }
142
- // live 一律不删,即使显式点名:take 还在跑,它会立刻把游标写回来 --
143
- // 删了是白删,还会让人以为清理没生效
144
171
  const live = rows.find((r) => r.state === 'live')
145
172
  if (live) {
146
- refused.push({ name, pid: live.pid })
173
+ // 显式点名时必须解释为什么没删(用户明确要求了);
174
+ // 而裸 prune 的意图是「清掉已经没用的」-- 在跑的游标本就不在这个视野里,
175
+ // 报告它是噪音(实测:3 个 take 在跑时,一次日常清理吐 3 段无关提示)
176
+ if (names.length) refused.push({ name, pid: live.pid })
147
177
  continue
148
178
  }
149
179
  // 显式点名 = 清该身份的全部 app(用户明确不盯了);
150
- // 裸 prune = 只清 stale 的那些 app,窗口内的留着续接
151
- const victims = names.length ? rows : rows.filter((r) => r.state === 'stale')
180
+ // 裸 prune = 只清已消费完的,有积压的留着
181
+ const victims = names.length ? rows : rows.filter((r) => r.state === 'drained')
182
+ let deletedAny = false
152
183
  for (const r of victims) {
153
- if (removeCursorApp(name, r.app)) removed.push(`${name}/${r.app}`)
184
+ // ⚠️ 还有积压 = **删了就丢掉这 pending 条**(重挂时会按 --since now 跳过它们)
185
+ // drained 删掉无损,backlog 不是 -- 故 backlog 要 --force 兜一道。
186
+ // 2026-09-06 真实踩到:点名删一个刚退出 take 的游标,消费位置就此丢失
187
+ if (r.state === 'backlog' && !opts.force) {
188
+ needForce.push({ name, app: r.app, day: r.day, pending: r.pending })
189
+ continue
190
+ }
191
+ if (removeCursorApp(name, r.app)) {
192
+ removed.push(`${name}/${r.app}`)
193
+ deletedAny = true
194
+ }
154
195
  }
155
- if (victims.length) removeStaleLock(name)
196
+ if (deletedAny) removeStaleLock(name)
156
197
  }
157
198
 
158
- return { removed, refused, missing }
199
+ return { removed, refused, missing, needForce }
159
200
  }
160
201
 
161
202
  module.exports = {
@@ -163,6 +204,6 @@ module.exports = {
163
204
  prune,
164
205
  removeCursorApp,
165
206
  cursorDay,
166
- isStaleDay,
207
+ isPreWindowDay,
167
208
  oldestScannedDay,
168
209
  }
package/lib/help.js CHANGED
@@ -49,8 +49,8 @@ function takeHelp(apps) {
49
49
  · 一直等不到消息 -> 九成是 bot 不在群,回第二步验(最常见故障)
50
50
  · 多个消费者盯同一 app 用不同 --name,游标互不干扰
51
51
  · 不盯了就清游标:lark-relay cursors --prune
52
- 引擎不主动删你的续接凭据(它是跨会话接着盯的凭据)。留着会在 status 里
53
- 一直挂「落后 N 条」,而 store 只保留几天 -- N 会随过期删除而变小,
52
+ 引擎不主动删还有积压的游标(它是跨会话接着盯的凭据)。留着会在 status 里
53
+ 一直挂「有 N 条待消费」,而 store 只保留几天 -- N 会随过期删除而变小,
54
54
  看着像追上了,其实是消息没了`
55
55
  }
56
56
 
@@ -67,6 +67,11 @@ app 列表实时读 \`lark-cli profile list\`,新增 profile 自动纳入,无需
67
67
  tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是死循环)。
68
68
  用户重新 login 后下次启动自动纳入。
69
69
 
70
+ ⚠️ **同一台机器只能有一个 collect**,第二个会拒绝启动并报出持有者 pid。
71
+ 不是洁癖:同一 app 服务端只放行一个 event bus,两个 collect 会互相挤掉并按
72
+ 1s->2s->...->60s 退避重连,实测约 5 分钟才稳定 -- 那段时间的消息永久丢失。
73
+ 所以下面那条「前台跑(调试)」在常驻服务已在跑时会直接退出,不会抢它。
74
+
70
75
  为什么必须常驻:lark 事件是流式的,进程不在的时刻消息永久丢失(实测:消息发出
71
76
  8s 后才起 consumer,收到 0 条)。collect 独立常驻,才能让 take 侧
72
77
  崩了、AI 跑 30 分钟、会话关几小时,都不丢消息。
@@ -81,8 +86,9 @@ tokenStatus 为 expired 的 profile 自动跳过并 warn(永久失败,重试是
81
86
 
82
87
  参数
83
88
  --exclude <apps> 排除指定 profile(逗号分隔)
84
- --retain N store 保留天数(默认 3,上限 4 = 消费侧扫描窗口;
85
- 再大就会「留着却扫不到」,故硬拒)
89
+ --retain N store 保留天数(默认 3,**必须等于**消费侧扫描窗口;
90
+ 留久了「在盘上却扫不到」,短了「空目录被当成还能续接」,
91
+ 要改就同时设 LARK_RELAY_SCAN_DAYS)
86
92
 
87
93
  环境变量
88
94
  LARK_RELAY_LOG_MAX_MB 单个日志文件上限(默认 16),超了轮转
@@ -105,36 +111,45 @@ const STATUS_HELP = `一眼看清全局:谁在跑、积压多少、游标在哪
105
111
  lark-relay status # 概览
106
112
  lark-relay status --json # 机器可读
107
113
 
108
- 消费者三态:
109
- ● 在跑 有 take 持锁,pid 是它
110
- 暂停 没人在跑,但位置还在扫描窗口内 -- 再起一个 take 能接着盯
111
- stale 位置已超扫描窗口,那段消息 gc 已回收,续接取不到东西 -> 该清理
114
+ 消费者状态:
115
+ ● 在跑 有 take 持锁,pid 是它
116
+ 已消费完 没人在跑且没有待消费 -- 不盯了就清掉
117
+ N 条待消费 没人在跑但还有积压 -- 重挂一个 take 就能取走
118
+
119
+ 游标位置早于扫描窗口时会附一句「<日期> 之前的已被 gc 回收」:
120
+ 那是既成损失(store 只保留几天),但**不影响窗口内还能取到的部分**。
112
121
 
113
- 排障入口:「○ 暂停」而你以为它在盯 -> 它的 take 没起来或早退了;
122
+ 排障入口:「○ 已消费完」而你以为它在盯 -> 它的 take 没起来或早退了;
114
123
  「无消费者」= 白采,可考虑 --exclude。
115
- stale 游标清理:lark-relay cursors --prune
124
+ 清理已消费完的游标:lark-relay cursors --prune
116
125
  collect 行不是 running -> tail -f ~/.lark-relay/logs/collect.err.log
117
126
  (launchd 不进统一日志),collect 停摆的每一秒都在丢消息。`
118
127
 
119
- const CURSORS_HELP = `游标治理:看清哪些还在值守,清掉已失效的。
128
+ const CURSORS_HELP = `游标治理:看清哪些还在值守,清掉已经没用的。
120
129
 
121
130
  lark-relay cursors # 列出全部游标及状态
122
- lark-relay cursors --prune # 清掉全部 stale 的
123
- lark-relay cursors --prune <name>... # 清指定身份的全部 app(在跑的会跳过)
131
+ lark-relay cursors --prune # 清掉全部「已消费完」的
132
+ lark-relay cursors --prune <name>... # 清指定身份的全部 app
133
+ lark-relay cursors --prune <name> --force # 连「有积压」的一起清
134
+
135
+ 状态:● 在跑 / ○ 已消费完 / ⚠ 有 N 条待消费但没人在跑
124
136
 
125
- 三态:● 在跑 / ○ 暂停(窗口内,可续接) / ⚠ stale(超窗口,续接无意义)
137
+ **判据只有一条:按这个游标续接还剩几条没消费。**
138
+ ● 在跑 不删。take 会立刻把游标写回来
139
+ ○ 已消费完 直接清。剩 0 条,删掉无损
140
+ ⚠ 有 N 条待消费 要 --force。删了那 N 条就跳过了;想要就重挂 take 取走
141
+
142
+ ⚠️ 游标位置早于扫描窗口**不是**可删的理由(2026-09-06 实测纠正):
143
+ 那只说明「更早的一段已被 gc 回收」,而窗口内还在的事件老游标照样全部放行 --
144
+ 实测停在 D-5 的游标能取到窗口内 3 条,而 --since now 取到 0 条。
145
+ 按日期删游标或重置它,会静默丢掉这些消息。
126
146
 
127
147
  删的单位是 **app**,不是身份:一个 --name 可以同时盯多个 app,
128
- 裸 --prune 只清其中 stale 的那些,窗口内的留着续接(点名才整个身份清)。
148
+ 裸 --prune 只清其中已消费完的那些(在跑的静默跳过)。
129
149
  连带删该 app 的 dedup 记录(<app>.seen.json),不留会让重挂后头几条被误判重复。
130
150
 
131
- 为什么 stale 能放心删:消费侧只扫最近几天的目录,更早的日期 collect 的 gc
132
- 已整目录删掉。**续接一个超窗口的游标和 --since now 行为完全一样** --
133
- 删它不会丢任何还取得到的消息,留着只会让 status 虚报积压。
134
- 反过来,窗口内的「○ 暂停」不自动删 -- 那是「关了会话、过阵子接着盯」的续接凭据。
135
-
136
- 退出码:0 正常(含「跳过了在跑的那个」),2 参数错或点名的身份不存在,
137
- 3 点名的目标全在跑、一个都没清掉`
151
+ 退出码:0 正常,2 参数错或点名的身份不存在,
152
+ 3 点名的目标一个都没清掉(在跑,或需要 --force)`
138
153
 
139
154
  const GUIDE = `lark-relay -- Lark 事件中继站。两件事:收下来 / 我来取。
140
155
 
@@ -158,11 +173,11 @@ take 四步(照做)
158
173
  一批处理完再起一个。不要自己写 while 循环 -- 进程退出会通知你。
159
174
  退出码:0=有一批,4=超时没消息(正常,再起一个),2=参数错,3=游标被占用。
160
175
  4. 不盯了收尾 lark-relay cursors --prune
161
- 游标为「关了会话、过阵子接着盯」而存在,引擎不主动删你的续接凭据。
162
- 临时监听、验完的排查、不再值守的任务,收尾时自己清 --
163
- 不清的代价:status 里一直挂着假积压(数字会随 store 过期变小,
176
+ 游标为「关了会话、过阵子接着盯」而存在,引擎不主动删还有积压的游标。
177
+ --prune 只清「已消费完」的(删掉无损);还有待消费的会拦下来告诉你
178
+ 会丢几条,确认不要才 --prune <name> --force。
179
+ 不清的代价:status 里一直挂着积压数(它会随 store 过期变小,
164
180
  看着像追上了,其实是消息永久没了)。
165
- 显式清掉某个身份:lark-relay cursors --prune <name>
166
181
 
167
182
  常见错误
168
183
  · 拿不到消息 -> 九成是 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,19 @@ 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 直接用 listCursors 实测出来的 pending —— 它就是「按这个游标续接还剩几条」。
88
+ // ⚠️ 早先这里对 stale 游标刻意不算 lag,理由是「那个数字误导」。
89
+ // 实际反了:pending 一直是真实可取条数,误导的是当时那个 stale 判据本身
90
+ // (它以为老游标取不到东西,而老游标恰好全部放行)。现在只有一个口径
91
+ return {
92
+ name: c.name,
93
+ lag: c.pending,
94
+ state: c.state,
95
+ pid: c.pid,
96
+ cursor: c.cursor,
97
+ day: c.day,
98
+ lostSpan: c.lostSpan,
99
+ }
97
100
  })
98
101
  rows.push({ app, today: store.countToday(app), latestMs, consumers: cs })
99
102
  }
@@ -127,9 +130,9 @@ function formatStatus(s) {
127
130
  }
128
131
  const w = (str, n) => String(str) + ' '.repeat(Math.max(1, n - dispWidth(str)))
129
132
  out.push(`${w('app', 8)}${w('今日事件', 10)}${w('最新事件', 13)}消费者`)
130
- let staleCount = 0
133
+ let drainedCount = 0
131
134
  for (const r of s.apps) {
132
- // 消费者各占一行:三态要一眼分得清,挤在一行里 ●/○/⚠ 会被淹掉。
135
+ // 消费者各占一行:状态要一眼分得清,挤在一行里 ●/○/⚠ 会被淹掉。
133
136
  // 「谁在跑」是排障第一问,以前得靠猜(全都长成 `name(落后 N 条)`)
134
137
  if (!r.consumers.length) {
135
138
  out.push(`${w(r.app, 8)}${w(r.today, 10)}${w(ago(r.latestMs), 13)}—(无消费者)`)
@@ -137,27 +140,28 @@ function formatStatus(s) {
137
140
  }
138
141
  out.push(`${w(r.app, 8)}${w(r.today, 10)}${w(ago(r.latestMs), 13)}`.trimEnd())
139
142
  for (const c of r.consumers) {
140
- const lagTxt = c.lag === 0 ? '游标追平' : `落后 ${c.lag} 条`
141
143
  let mark
142
144
  let note
143
145
  if (c.state === 'live') {
144
146
  mark = '●'
145
- note = `在跑 (pid ${c.pid}, ${lagTxt})`
146
- } else if (c.state === 'stale') {
147
- staleCount++
148
- mark = '⚠'
149
- // 不给 lag:它已经不代表「还能取到多少」。给位置和原因才可行动
150
- note = `stale (游标停在 ${c.day || '?'},已超 ${cursors.oldestScannedDay()} 的扫描窗口,续接取不到东西)`
151
- } else {
147
+ note = `在跑 (pid ${c.pid}, ${c.lag === 0 ? '游标追平' : `落后 ${c.lag} 条`})`
148
+ } else if (c.state === 'drained') {
149
+ drainedCount++
152
150
  mark = '○'
153
- note = `暂停 (${lagTxt},可续接)`
151
+ note = '已消费完 (没人在跑,删掉无损)'
152
+ } else {
153
+ mark = '⚠'
154
+ note = `有 ${c.lag} 条待消费但没人在跑 —— 重挂 take 能接着取`
154
155
  }
156
+ // 位置早于扫描窗口:那之前的一段已被 gc 回收、找不回。
157
+ // 与「能不能删」无关(判据是上面的待消费数),但它是真实损失,必须说
158
+ if (c.lostSpan) note += `;${c.day} 之前的已被 gc 回收`
155
159
  out.push(` ${w(c.name, 24)}${mark} ${note}`)
156
160
  }
157
161
  }
158
- if (staleCount) {
162
+ if (drainedCount) {
159
163
  out.push('')
160
- out.push(`⚠ ${staleCount} stale 游标 —— 那段消息 gc 已回收,留着只会虚报积压`)
164
+ out.push(`○ ${drainedCount} 个游标已消费完且没人在跑 —— 不盯了就清掉,留着只是噪音`)
161
165
  out.push(' 清理:lark-relay cursors --prune')
162
166
  }
163
167
 
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
+ // 却仍被算作「窗口内」-> 停在那天的游标判成「可续接」、prune 还要 --force 才肯删,
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 字节再填充,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-relay",
3
- "version": "0.4.13",
3
+ "version": "0.4.15",
4
4
  "description": "Lark event relay: collect events to disk, take a batch when you need it.",
5
5
  "keywords": [
6
6
  "lark",