@whaletalk/dsh-codex-collab 0.1.6 → 0.1.9

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.
@@ -0,0 +1,208 @@
1
+ /**
2
+ * fs-lock.mjs —— 本机跨进程写锁(零依赖),供收件箱与任务关联存储复用。
3
+ *
4
+ * 互斥只靠一条:**一个共享路径**上的一次**原子独占创建**(先把完整元数据写进唯一命名的
5
+ * `pending-<token>.json`,再 `linkSync` 到锁路径;平台不支持硬链接时退化为 `wx` 独占创建 + 立即写入)。
6
+ * 共享路径只能被创建成功一次,所以同一时刻至多一个持有者。
7
+ *
8
+ * **在线竞争期间绝不自动回收**:在共享路径上"检查持有者 → 再删除/改名"无法原子化,回收者可能
9
+ * 删掉别人刚创建的新锁(ABA/TOCTOU,已被真实复现过)。因此只有三种结果:
10
+ * - 持有者活着 → 有限等待,超时抛 lock-timeout;
11
+ * - 持有者已死 → 二次确认(锁仍在、仍是同一把、pid 仍不可达)后抛 lock-stale-holder(可恢复,人工清理);
12
+ * - 元数据不可确认 → 抛 lock-unverified-holder(可恢复,人工清理)。
13
+ * 宁可阻塞等待人工处理,也不冒"两个写者"的风险。
14
+ */
15
+ import fs from 'node:fs'
16
+ import os from 'node:os'
17
+ import path from 'node:path'
18
+
19
+ export const LOCK_REASONS = {
20
+ /** 持有者活着,等不到锁(可重试)。 */
21
+ TIMEOUT: 'lock-timeout',
22
+ /** 持有者已死:不自动回收,需人工清理(可恢复错误)。 */
23
+ STALE_HOLDER: 'lock-stale-holder',
24
+ /** 锁存在但无法确认持有者:不按时间偷(可恢复错误)。 */
25
+ UNVERIFIED_HOLDER: 'lock-unverified-holder',
26
+ }
27
+
28
+ /** 观察到"无法确认持有者"的元数据后,最多再观察这么久(容忍原子创建的极小空窗),然后 fail closed。 */
29
+ const UNREADABLE_GRACE_MS = 200
30
+ /** 残留 pending 文件的清理年龄下限:先证明其记录里的 pid 已死,再删(名字唯一,不涉及共享路径)。 */
31
+ const PENDING_STALE_MS = 1000
32
+ const DEFAULT_TIMEOUT_MS = 10000
33
+
34
+ const uid = (prefix) => prefix + '-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10)
35
+
36
+ /** 同步睡眠(不占 CPU);Node 主线程允许 Atomics.wait。 */
37
+ const sleep = (ms) => {
38
+ try {
39
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
40
+ } catch (e) {
41
+ const end = Date.now() + ms
42
+ while (Date.now() < end) { /* spin */ }
43
+ }
44
+ }
45
+
46
+ export const isProcessAlive = (pid) => {
47
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return false
48
+ try {
49
+ process.kill(pid, 0)
50
+ return true
51
+ } catch (e) {
52
+ if (e !== null && typeof e === 'object' && e.code === 'ESRCH') return false
53
+ // EPERM 等:进程存在但不是我们能发信号的 → 视为活着(宁可等,也不抢)
54
+ return true
55
+ }
56
+ }
57
+
58
+ const lockError = (reason, message, extra) => Object.assign(new Error(message), { reason, ...(extra === undefined ? {} : extra) })
59
+
60
+ /**
61
+ * 在 `lockFile` 上创建一把跨进程写锁。
62
+ * @param {{lockFile: string, timeoutMs?: number}} options
63
+ */
64
+ export const createFileLock = (options) => {
65
+ const lockFile = path.resolve(options.lockFile)
66
+ const lockDir = path.dirname(lockFile)
67
+ const timeoutMs = typeof options.timeoutMs === 'number' && options.timeoutMs > 0 ? options.timeoutMs : DEFAULT_TIMEOUT_MS
68
+
69
+ const ensureDir = () => { fs.mkdirSync(lockDir, { recursive: true }) }
70
+
71
+ /** 读锁的持有者元数据。读不动/解析失败/没有 pid → 无法确认持有者(调用方必须 fail closed)。 */
72
+ const readLockMeta = () => {
73
+ let text
74
+ try {
75
+ text = fs.readFileSync(lockFile, 'utf8')
76
+ } catch (e) {
77
+ if (e !== null && typeof e === 'object' && e.code === 'ENOENT') return { ok: false, missing: true, why: 'lock file is gone' }
78
+ return { ok: false, missing: false, why: 'unreadable (' + e.message + ')' }
79
+ }
80
+ let value
81
+ try {
82
+ value = JSON.parse(text)
83
+ } catch (e) {
84
+ return { ok: false, missing: false, why: 'content is not valid JSON' }
85
+ }
86
+ if (value === null || typeof value !== 'object' || typeof value.pid !== 'number' || !Number.isInteger(value.pid)) {
87
+ return { ok: false, missing: false, why: 'content has no owner pid' }
88
+ }
89
+ return { ok: true, meta: value }
90
+ }
91
+
92
+ /**
93
+ * 原子独占创建:先写完整元数据到唯一命名的 pending 文件,再硬链接到锁路径。
94
+ * link 原子且独占,且"内容完整才可能可见";不支持硬链接时退化为 `wx` + 立即写入
95
+ * (极小空窗内竞争者会读到不可确认的元数据 → fail closed,只会短暂报错,绝不误删)。
96
+ */
97
+ const publishLock = (meta) => {
98
+ ensureDir()
99
+ const intentPath = path.join(lockDir, 'pending-' + meta.token + '.json')
100
+ fs.writeFileSync(intentPath, JSON.stringify(meta), 'utf8')
101
+ try {
102
+ fs.linkSync(intentPath, lockFile)
103
+ return true
104
+ } catch (e) {
105
+ if (e !== null && typeof e === 'object' && e.code === 'EEXIST') return false
106
+ try {
107
+ fs.writeFileSync(lockFile, JSON.stringify(meta), { encoding: 'utf8', flag: 'wx' })
108
+ return true
109
+ } catch (e2) {
110
+ if (e2 !== null && typeof e2 === 'object' && e2.code === 'EEXIST') return false
111
+ throw e2
112
+ }
113
+ } finally {
114
+ try { fs.unlinkSync(intentPath) } catch (e) { /* 已被清理 */ }
115
+ }
116
+ }
117
+
118
+ /** 清理残留 pending:只在能证明其记录里的 pid 已死、且已过短宽限时删(名字唯一,不碰共享路径)。 */
119
+ const sweepPending = () => {
120
+ let names
121
+ try { names = fs.readdirSync(lockDir) } catch (e) { return }
122
+ for (const name of names) {
123
+ if (!name.startsWith('pending-')) continue
124
+ const file = path.join(lockDir, name)
125
+ try {
126
+ const age = Date.now() - fs.statSync(file).mtimeMs
127
+ if (age < PENDING_STALE_MS) continue
128
+ const owner = JSON.parse(fs.readFileSync(file, 'utf8'))
129
+ if (owner !== null && typeof owner === 'object' && isProcessAlive(owner.pid)) continue
130
+ fs.unlinkSync(file)
131
+ } catch (e) { /* 读不到/已被清理:下一轮再说 */ }
132
+ }
133
+ }
134
+
135
+ const acquire = () => {
136
+ ensureDir()
137
+ const token = uid('lock')
138
+ const meta = { token, pid: process.pid, host: os.hostname(), at: Date.now() }
139
+ const deadline = Date.now() + timeoutMs
140
+ let unreadableSince
141
+
142
+ for (;;) {
143
+ sweepPending()
144
+
145
+ // 1) 唯一的进入方式:原子独占创建。
146
+ if (publishLock(meta)) return { token }
147
+
148
+ // 2) 已存在:读元数据判定,只决定"等待 / 报错",绝不删除或改名。
149
+ const info = readLockMeta()
150
+ if (info.missing === true) continue // 刚好被持有者释放:重试
151
+ if (info.ok === true) {
152
+ if (isProcessAlive(info.meta.pid)) {
153
+ if (Date.now() > deadline) {
154
+ const holder = 'pid ' + info.meta.pid
155
+ throw lockError(LOCK_REASONS.TIMEOUT, 'could not acquire the write lock within ' + timeoutMs + 'ms; it is held by ' + holder + ' (a live holder is never stolen). Retry later.', { holder, file: lockFile })
156
+ }
157
+ sleep(15)
158
+ continue
159
+ }
160
+ // 持有者看起来已死——但它可能刚刚**正常释放并退出**(读到内容之后、检查存活之前)。
161
+ // 报 stale 之前必须二次确认:锁文件仍在、还是同一把(token 相同)、pid 仍不可达。
162
+ const confirm = readLockMeta()
163
+ if (confirm.missing === true) continue
164
+ if (confirm.ok === true && confirm.meta.token === info.meta.token && !isProcessAlive(confirm.meta.pid)) {
165
+ throw lockError(
166
+ LOCK_REASONS.STALE_HOLDER,
167
+ 'the write lock is held by pid ' + info.meta.pid + ', which is no longer running. Refusing to auto-reclaim it online: removing a shared lock path is racy (a reclaimer can delete a lock someone else just created). '
168
+ + 'Stop all writers, then delete ' + lockFile + ' manually and retry.',
169
+ { file: lockFile, stalePid: info.meta.pid },
170
+ )
171
+ }
172
+ continue
173
+ }
174
+
175
+ // 3) 元数据空/损坏:给原子创建留一点观察窗(只延迟报错,绝不删除),随后 fail closed。
176
+ if (unreadableSince === undefined) unreadableSince = Date.now()
177
+ if (Date.now() - unreadableSince >= UNREADABLE_GRACE_MS) {
178
+ throw lockError(
179
+ LOCK_REASONS.UNVERIFIED_HOLDER,
180
+ 'the write lock at ' + lockFile + ' has no verifiable owner (' + info.why + '); refusing to reclaim it on a timer. '
181
+ + 'Stop the writers and inspect that file; remove it yourself only if you are sure no writer is running.',
182
+ { file: lockFile },
183
+ )
184
+ }
185
+ sleep(15)
186
+ }
187
+ }
188
+
189
+ const release = (handle) => {
190
+ // 只删自己创建的那把锁:核对 token 后删除。注意——这只是"尽力而为"的一致性检查,
191
+ // **不是互斥的基础**;互斥来自独占创建 + 从不自动回收(没有任何自动路径会删活锁)。
192
+ const info = readLockMeta()
193
+ if (info.ok === true && info.meta.token === handle.token) {
194
+ try { fs.unlinkSync(lockFile) } catch (e) { /* 已被外部清理 */ }
195
+ }
196
+ }
197
+
198
+ const withLock = (fn) => {
199
+ const handle = acquire()
200
+ try {
201
+ return fn()
202
+ } finally {
203
+ release(handle)
204
+ }
205
+ }
206
+
207
+ return { lockFile, timeoutMs, acquire, release, withLock, readLockMeta }
208
+ }
@@ -27,8 +27,18 @@ export const REASONS = {
27
27
  BAD_REQUEST: 'session-bad-request',
28
28
  GATEWAY_INTERNAL: 'session-gateway-internal',
29
29
  DISPATCH_FAILED: 'session-dispatch-failed',
30
+ /** 带来源的会话任务不能精确关联到单个请求回合:在执行前拒绝 steer。 */
31
+ ORIGIN_STEER_UNSUPPORTED: 'origin-steer-unsupported',
32
+ /** 关联到了请求回合,但该回合以非 completed 结束(aborted/blocked/error/…)。 */
33
+ TURN_FAILED: 'session-turn-failed',
34
+ /** 没有任何证据能确认这个请求回合的结局(不猜、不重发)。 */
35
+ UNCONFIRMED: 'execution-state-unconfirmed',
30
36
  }
31
37
 
38
+ /** `turn/end` 的 reason.kind 枚举(来自 driver/typert):**只有 completed 算成功**。 */
39
+ export const TURN_END_KINDS = ['completed', 'aborted', 'blocked', 'error', 'max-tokens', 'interrupted', 'forked']
40
+ export const SUCCESS_TURN_END = 'completed'
41
+
32
42
  /** 候选列表回传给调用方时的上限,避免把整个会话库倒出去。 */
33
43
  export const MAX_CANDIDATES = 10
34
44
 
@@ -91,6 +101,128 @@ export const pickSessionResult = (streamed, extracted) => {
91
101
  return fromStream !== '' ? fromStream : '(无输出)'
92
102
  }
93
103
 
104
+ /**
105
+ * 请求 → DSH turn 的关联器(纯函数群)。
106
+ *
107
+ * 依据(本机 controller 0.2.0-rc.2 的实测字段):
108
+ * - `prompt.requestId` 会被写进 `user/message` 的 `event.data.source.rpcId`——这是"这条用户消息属于
109
+ * 哪次请求"的**唯一可靠凭据**(我们用 taskId 当 requestId);
110
+ * - **`user/message.data.turn` 不存在**:turn 号只能靠按 seq 有序跟踪"已 turn/start 但还没 turn/end"
111
+ * 的回合得到,遇到 rpcId 匹配的 user/message 时把它归到**当时仍未闭合**的那个 turn;
112
+ * - `assistant/message.data = {turn, step, message:{content:[...]}}`:只收自己那个 turn 的助手文本;
113
+ * - `turn/end.data = {turn, reason:{kind,…}}`:这是本次请求结束的依据;**只有 completed 算成功**;
114
+ * - `api-session/status` 只表示 agent 当前是否 running(连续跑 queued turn 时可能没有新的 running=true),
115
+ * 因此它**绝不能**当作某个请求完成的证明。
116
+ *
117
+ * 同一套规则被 live follow 帧、follow 开场 snapshot.records、以及轮询读日志共用;`seq` 去重保证
118
+ * 同一条事件被多条路径投递时也只算一次、不串文本。
119
+ */
120
+ export const createRequestTracker = (requestId) => ({
121
+ requestId: String(requestId),
122
+ /** 已 turn/start 但未 turn/end 的 turn 号(有序)。 */
123
+ openTurns: [],
124
+ /** 匹配到本请求的 turn 号(由 rpcId 匹配时那一刻的未闭合 turn 决定)。 */
125
+ turn: undefined,
126
+ sawRequest: false,
127
+ text: '',
128
+ /**
129
+ * **只记录"真正产生了效果"的事件 seq**(建立 turn 关联 / 追加自己 turn 的文字 / 收尾 / 记录 turn/start)。
130
+ * 当时因为"还不知道自己那个 turn"而无法使用的事件**不**标记——后续从完整日志回填时它们必须能被重新消费
131
+ * (否则快照缺前半段就会永久丢掉答案)。
132
+ */
133
+ usedSeqs: new Set(),
134
+ end: undefined,
135
+ lastSeq: 0,
136
+ })
137
+
138
+ const dataOf = (event) => (event.data !== undefined && event.data !== null && typeof event.data === 'object' ? event.data : {})
139
+
140
+ /**
141
+ * 消费一条会话事件。返回 `'settled'`(本请求的 turn 已结束)/ `'progress'` / `'ignored'`。
142
+ */
143
+ export const consumeSessionEvent = (tracker, event) => {
144
+ if (tracker === undefined || tracker === null) return 'ignored'
145
+ if (event === null || typeof event !== 'object') return 'ignored'
146
+ const seq = typeof event.seq === 'number' ? event.seq : undefined
147
+ if (seq !== undefined) {
148
+ if (tracker.usedSeqs.has(seq)) return 'ignored' // 已经用过的效果不重复施加
149
+ tracker.lastSeq = Math.max(tracker.lastSeq, seq)
150
+ }
151
+ const markUsed = () => { if (seq !== undefined) tracker.usedSeqs.add(seq) }
152
+ const data = dataOf(event)
153
+ if (event.type === 'turn/start') {
154
+ const turn = typeof data.turn === 'number' ? data.turn : undefined
155
+ if (turn !== undefined && !tracker.openTurns.includes(turn)) tracker.openTurns.push(turn)
156
+ markUsed()
157
+ return 'ignored'
158
+ }
159
+ if (event.type === 'user/message') {
160
+ const source = data.source !== undefined && data.source !== null && typeof data.source === 'object' ? data.source : {}
161
+ if (typeof source.rpcId !== 'string' || source.rpcId !== tracker.requestId) return 'ignored'
162
+ tracker.sawRequest = true
163
+ if (tracker.turn === undefined) {
164
+ // user/message 没有 data.turn:归到此刻仍未闭合的那个 turn(有 data.turn 时以它为准)
165
+ const fallback = tracker.openTurns.length > 0 ? tracker.openTurns[tracker.openTurns.length - 1] : undefined
166
+ tracker.turn = typeof data.turn === 'number' ? data.turn : fallback
167
+ }
168
+ // 只有真的建立了 turn 关联才算"用过";没有未闭合 turn 时留待回填。
169
+ if (tracker.turn !== undefined) markUsed()
170
+ return 'progress'
171
+ }
172
+ if (event.type === 'assistant/message') {
173
+ if (tracker.turn === undefined) return 'ignored' // 还不知道自己的 turn:留待回填
174
+ if (typeof data.turn === 'number' && data.turn !== tracker.turn) return 'ignored'
175
+ const text = assistantTextOf(event)
176
+ if (text === '') return 'ignored'
177
+ tracker.text += text
178
+ markUsed()
179
+ return 'progress'
180
+ }
181
+ if (event.type === 'turn/end') {
182
+ const turn = typeof data.turn === 'number' ? data.turn : undefined
183
+ if (turn !== undefined) tracker.openTurns = tracker.openTurns.filter((t) => t !== turn)
184
+ if (tracker.turn === undefined || turn !== tracker.turn) {
185
+ // 不是我们的回合:**不标记**,等自己那个 turn 绑定后若还有对应结束仍要能收到。
186
+ return 'ignored'
187
+ }
188
+ const reason = data.reason !== undefined && data.reason !== null && typeof data.reason === 'object' ? data.reason : {}
189
+ const kind = typeof reason.kind === 'string' ? reason.kind : 'unknown'
190
+ tracker.end = { kind, reason, seq }
191
+ markUsed()
192
+ return 'settled'
193
+ }
194
+ return 'ignored'
195
+ }
196
+
197
+ /**
198
+ * follow 开场的 snapshot 帧:`{type:'snapshot', records:[{type:'event', event:{…}}], …}`。
199
+ * **不能把 snapshot 整个丢掉**——快任务可能在派发与订阅之间就已经跑完,答案只在这个快照里。
200
+ */
201
+ export const consumeSnapshotFrame = (tracker, frame) => {
202
+ const records = frame !== null && frame !== undefined && Array.isArray(frame.records)
203
+ ? frame.records
204
+ : (frame !== null && frame !== undefined && Array.isArray(frame.events) ? frame.events : [])
205
+ let last = 'ignored'
206
+ for (const record of records) {
207
+ if (record === null || typeof record !== 'object') continue
208
+ const event = record.type === 'event' && record.event !== undefined && record.event !== null
209
+ ? record.event
210
+ : (typeof record.type === 'string' && record.seq !== undefined ? record : undefined)
211
+ if (event === undefined) continue
212
+ const outcome = consumeSessionEvent(tracker, event)
213
+ if (outcome === 'settled') return 'settled'
214
+ if (outcome === 'progress') last = 'progress'
215
+ }
216
+ return last
217
+ }
218
+
219
+ /** 由 tracker 得出终态结论:只有 completed 算成功;其他 kind 明确失败;没有证据=未确认。 */
220
+ export const turnOutcome = (end) => {
221
+ if (end === undefined || end === null) return { ok: false, unconfirmed: true, kind: 'unconfirmed' }
222
+ if (end.kind === SUCCESS_TURN_END) return { ok: true, unconfirmed: false, kind: end.kind }
223
+ return { ok: false, unconfirmed: false, kind: end.kind }
224
+ }
225
+
94
226
  /**
95
227
  * 判定一次派发的目标。
96
228
  *
@@ -232,6 +364,54 @@ export const looksLikeDisabledSearch = (error) => {
232
364
  return /search is disabled/i.test(text)
233
365
  }
234
366
 
367
+ /** 去空白后的字符 bigram 集合(CJK 友好:不需要分词)。 */
368
+ const bigramsOf = (value) => {
369
+ const text = typeof value === 'string' ? value.replace(/\s+/g, '') : ''
370
+ const out = new Set()
371
+ if (text.length === 1) out.add(text)
372
+ for (let i = 0; i + 1 < text.length; i++) out.add(text.slice(i, i + 2))
373
+ return out
374
+ }
375
+
376
+ /** 查询里的 bigram 有多少比例出现在标题里(0..1)。 */
377
+ export const titleSimilarity = (query, title) => {
378
+ const q = bigramsOf(query)
379
+ const t = bigramsOf(title)
380
+ if (q.size === 0 || t.size === 0) return 0
381
+ let shared = 0
382
+ for (const gram of q) if (t.has(gram)) shared++
383
+ return shared / q.size
384
+ }
385
+
386
+ /**
387
+ * `session-query-empty` 时给出的"近似候选"。
388
+ *
389
+ * 起因是一个真实事故:调用方搜「量化框架」没命中,又**没有别的办法确认标题**,于是
390
+ * 发了一条占位消息去"探测命中"——那条消息落进了用户自己的会话并跑成回合,污染了
391
+ * 对话。修法是不给"探测"留动机:查询落空时直接回一批候选标题(同 bigram 相似度排
392
+ * 序,无相似度的按最近更新补位),调用方靠只读信息就能改词重查。
393
+ */
394
+ export const rankTitleCandidates = (query, summaries, limit = 5) => {
395
+ const list = Array.isArray(summaries) ? summaries : []
396
+ const scored = []
397
+ for (const summary of list) {
398
+ if (summary === null || typeof summary !== 'object') continue
399
+ if (typeof summary.sessionId !== 'string' || summary.sessionId === '') continue
400
+ const title = typeof summary.title === 'string' && summary.title !== '' ? summary.title : ''
401
+ const updatedAt = typeof summary.updatedAt === 'number' ? summary.updatedAt : 0
402
+ scored.push({ sessionId: summary.sessionId, snippet: title, score: titleSimilarity(query, title), updatedAt })
403
+ }
404
+ const byRecency = (a, b) => b.updatedAt - a.updatedAt
405
+ const related = scored.filter((s) => s.score > 0).sort((a, b) => b.score - a.score || byRecency(a, b))
406
+ const rest = scored.filter((s) => s.score === 0).sort(byRecency)
407
+ const bound = Math.max(1, Math.min(Number(limit) || 5, MAX_CANDIDATES))
408
+ return [...related, ...rest].slice(0, bound).map((s) => ({
409
+ sessionId: s.sessionId,
410
+ snippet: s.snippet,
411
+ score: Math.round(s.score * 1000) / 1000,
412
+ }))
413
+ }
414
+
235
415
  /** 任务在 HTTP 行上的回显:调用方一眼能看出这次是"新建"还是"接进哪个会话"。 */
236
416
  export const taskResponse = (task) => {
237
417
  const out = { taskId: task.taskId, status: task.status, kind: task.kind }
@@ -246,5 +426,12 @@ export const taskResponse = (task) => {
246
426
  out.target = { kind: 'worker', cwd: target.cwd, lane: target.lane }
247
427
  }
248
428
  if (Array.isArray(task.notes) && task.notes.length > 0) out.notes = task.notes
429
+ // delivered=true:prompt 已被目标会话接受(进了它的队列)。调用方据此知道
430
+ // "别重发"——即便随后我们的观察超时并报错,消息也可能正在队列里等着跑。
431
+ if (task.target !== undefined && task.target !== null && task.target.kind === 'session' && task.delivered === true) {
432
+ out.delivered = true
433
+ }
434
+ // 来源关联(R1):回显发起这次任务的 Codex 对话 id,便于跨回合按 origin+taskId 精确收取。
435
+ if (typeof task.originThreadId === 'string' && task.originThreadId !== '') out.originThreadId = task.originThreadId
249
436
  return out
250
437
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * task-table.mjs —— 网关内存任务表的淘汰策略(纯函数,可单测)。
3
+ *
4
+ * 为什么单独抽出来:原来三个入口都无条件 `tasks.splice(0, tasks.length - 200)`,会把
5
+ * **仍在执行**的任务挤掉,导致完成回调/会话观察器 `find(taskId)` 找不到它(结果丢失)。
6
+ * 现在只淘汰**终态**任务,且永不淘汰刚创建的那一条。
7
+ */
8
+
9
+ export const DEFAULT_TASK_LIMIT = 200
10
+
11
+ /**
12
+ * 把任务放进表里,并在超限时淘汰终态任务。
13
+ * @param {Array<object>} tasks 任务表(原地修改)
14
+ * @param {object} task 新任务
15
+ * @param {number} [limit]
16
+ * @returns {Array<object>} 同一个数组
17
+ */
18
+ export const rememberTask = (tasks, task, limit = DEFAULT_TASK_LIMIT) => {
19
+ tasks.push(task)
20
+ const bound = typeof limit === 'number' && limit > 0 ? limit : DEFAULT_TASK_LIMIT
21
+ while (tasks.length > bound) {
22
+ // 只淘汰终态(非 accepted/running)且不是刚加进来的那一条。
23
+ const index = tasks.findIndex((t) => t !== task && t.status !== 'accepted' && t.status !== 'running')
24
+ if (index === -1) break // 全是活任务:宁可超过上限,也不丢执行中的任务
25
+ tasks.splice(index, 1)
26
+ }
27
+ return tasks
28
+ }
29
+
30
+ /** 是否处于"还在执行"的状态(完成回调需要能找到它)。 */
31
+ export const isActiveTask = (task) => task !== null && typeof task === 'object'
32
+ && (task.status === 'accepted' || task.status === 'running')