@mzzsfy/dsh-keep-working 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,787 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdirSync, mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+
7
+ // 扫描日志必须落在临时目录,否则测试会写进真实 home
8
+ process.env.DSH_HOME = mkdtempSync(join(tmpdir(), 'keep-working-test-'))
9
+
10
+ import { resolveConfig } from '../src/config.mjs'
11
+ import { listFreshByDirectory, scanSettled, scanStoredSessions } from '../src/scan.mjs'
12
+
13
+ const TURN = 3
14
+ const NOW = 10_000_000
15
+
16
+ const event = (seq, type, data) => ({ seq, time: NOW, type, data })
17
+
18
+ const turnStart = (seq, turn) => event(seq, 'turn/start', { turn })
19
+ const turnEnd = (seq, turn, reason) => event(seq, 'turn/end', { turn, reason })
20
+ const assistant = (seq, turn, text) =>
21
+ event(seq, 'assistant/message', {
22
+ turn,
23
+ step: 1,
24
+ message: { role: 'assistant', content: [{ type: 'text', text }] },
25
+ stream: [],
26
+ })
27
+ const userMessage = (seq, kind = 'user') =>
28
+ event(seq, 'user/message', { id: `m-${seq}`, source: { kind }, content: [{ type: 'text', text: 'go' }] })
29
+ const queuedMessage = (seq, text = '排队消息') => ({
30
+ id: `q-${seq}`,
31
+ role: 'user',
32
+ content: [{ type: 'text', text }],
33
+ source: { kind: 'user' },
34
+ })
35
+ const inboxSpliced = (seq, target, inserted, start = 0, removedCount = 0) =>
36
+ event(seq, 'agent/inbox/spliced', { target, start, removedCount, inserted })
37
+
38
+ const openTail = [
39
+ turnStart(1, TURN),
40
+ userMessage(2),
41
+ assistant(3, TURN, '没结案'),
42
+ turnEnd(4, TURN, { kind: 'completed' }),
43
+ ]
44
+
45
+ const danglingTail = [
46
+ turnStart(1, TURN),
47
+ userMessage(2),
48
+ assistant(3, TURN, '话说到一半'),
49
+ ]
50
+
51
+ const closedTail = [
52
+ turnStart(1, TURN),
53
+ userMessage(2),
54
+ assistant(3, TURN, '结论\nTASK_DONE'),
55
+ turnEnd(4, TURN, { kind: 'completed' }),
56
+ ]
57
+
58
+ function createHarness({ records, archived = [], goals, resumeFails = false, registryThrows = false } = {}) {
59
+ const resumed = []
60
+ const sent = []
61
+ const agentsById = new Map()
62
+ const query = {
63
+ listSessions: async () => records,
64
+ observeSession: async (id) => {
65
+ const record = records.find((item) => item.header.id === id)
66
+ return {
67
+ header: record.header,
68
+ events: record.events,
69
+ [Symbol.dispose]() {},
70
+ }
71
+ },
72
+ }
73
+ const ctx = {
74
+ get(name) {
75
+ if (name === 'sessionQuery') return query
76
+ if (name === 'goals') return goals
77
+ if (name === 'workspaceRegistry') {
78
+ if (registryThrows) throw new Error('workspace registry is not started yet')
79
+ return { archivedSessionIds: archived }
80
+ }
81
+ return undefined
82
+ },
83
+ agents: {
84
+ get: (id) => agentsById.get(id),
85
+ resume: async ({ resumeSessionId }) => {
86
+ if (resumeFails) throw new Error('resume failed')
87
+ resumed.push(resumeSessionId)
88
+ agentsById.set(resumeSessionId, {
89
+ id: resumeSessionId,
90
+ session: { id: resumeSessionId },
91
+ inbox: { nextTurn: [] },
92
+ followup: (message) => sent.push({ id: resumeSessionId, message, via: 'followup' }),
93
+ steer: (message) => sent.push({ id: resumeSessionId, message, via: 'steer' }),
94
+ })
95
+ return { agent: agentsById.get(resumeSessionId) }
96
+ },
97
+ },
98
+ workspaceRegistry: { archivedSessionIds: archived },
99
+ logger: { info() {}, warn() {} },
100
+ }
101
+ return { ctx, resumed, sent, agentsById }
102
+ }
103
+
104
+ const record = (id, events, extra = {}) => ({
105
+ header: { id, ...extra },
106
+ events,
107
+ })
108
+
109
+ test('目录扫描成功但没有近期会话时收工,不再每秒重扫', async () => {
110
+ const h = createHarness({ records: [] })
111
+ const base = h.ctx.get.bind(h.ctx)
112
+ // 目录路径需要持久层提供会话头,否则无法区分子会话
113
+ h.ctx.get = (name) => (name === 'sessionPersistence' ? { async list() { return [] }, async stat() { return undefined } } : base(name))
114
+ const result = await scanStoredSessions({
115
+ ctx: h.ctx,
116
+ config: resolveConfig(),
117
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
118
+ now: () => NOW,
119
+ scanDirectory: () => ({ items: [], partial: false }),
120
+ })
121
+ assert.equal(result.source, 'directory')
122
+ assert.equal(scanSettled(result), true, '目录快照为空表示近期无会话需恢复,不应继续重试')
123
+ })
124
+
125
+ test('目录部分可读时不当作权威快照', async () => {
126
+ const h = createHarness({ records: [] })
127
+ const base = h.ctx.get.bind(h.ctx)
128
+ h.ctx.get = (name) => (name === 'sessionPersistence' ? { async list() { return [] }, async stat() { return undefined } } : base(name))
129
+ // 有子目录读取失败:快照不完整,不能宣称近期没有待恢复会话
130
+ const result = await scanStoredSessions({
131
+ ctx: h.ctx,
132
+ config: resolveConfig(),
133
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
134
+ now: () => NOW,
135
+ scanDirectory: () => ({ items: [], partial: true }),
136
+ })
137
+ assert.equal(result.source, 'partial')
138
+ assert.equal(scanSettled(result), false, '快照不完整时必须重试')
139
+ })
140
+
141
+ test('目录部分可读但仍有候选时也不收工', async () => {
142
+ // 快照不完整时找到的候选只是全部候选的子集,漏掉的部分无从得知
143
+ assert.equal(scanSettled({ complete: true, source: 'partial', listed: 3 }), false)
144
+ })
145
+
146
+ test('目录不可用且持久层给出空快照时不算收工', async () => {
147
+ const h = createHarness({ records: [] })
148
+ const base = h.ctx.get.bind(h.ctx)
149
+ // 持久层能连上但列出空快照:服务尚未就绪的典型形态,不能与目录快照混为一谈
150
+ h.ctx.get = (name) => (name === 'sessionPersistence' ? { async list() { return [] } } : base(name))
151
+ const result = await scanStoredSessions({
152
+ ctx: h.ctx,
153
+ config: resolveConfig(),
154
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
155
+ now: () => NOW,
156
+ scanDirectory: () => undefined,
157
+ })
158
+ assert.equal(result.source, 'persistence')
159
+ assert.equal(scanSettled(result), false, '空快照可能只是尚未就绪,必须重试')
160
+ })
161
+
162
+ test('目录与持久层都不可用时全量回退且不算收工', async () => {
163
+ const h = createHarness({ records: [] })
164
+ const result = await scanStoredSessions({
165
+ ctx: h.ctx,
166
+ config: resolveConfig(),
167
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
168
+ now: () => NOW,
169
+ scanDirectory: () => undefined,
170
+ })
171
+ assert.equal(result.source, 'query')
172
+ assert.equal(scanSettled(result), false)
173
+ })
174
+
175
+ test('本轮预算未跑完时不收工', () => {
176
+ assert.equal(scanSettled({ complete: false, source: 'directory', listed: 0 }), false)
177
+ })
178
+
179
+ test('持久层给出候选会话时收工', () => {
180
+ assert.equal(scanSettled({ complete: true, source: 'persistence', listed: 3 }), true)
181
+ })
182
+
183
+ test('候选多于本轮恢复上限时不算扫完', async () => {
184
+ const h = createHarness({ records: [record('s1', danglingTail), record('s2', danglingTail), record('s3', danglingTail)] })
185
+ const items = ['s1', 's2', 's3'].map((id) => ({ id, path: `p/${id}`, mtimeMs: NOW }))
186
+ const result = await scanStoredSessions({
187
+ ctx: h.ctx,
188
+ config: resolveConfig({ scanLimit: 1 }),
189
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
190
+ now: () => NOW,
191
+ scanDirectory: () => ({ items, partial: false }),
192
+ })
193
+ assert.equal(result.resumed, 1)
194
+ assert.equal(result.complete, false, '还有候选未处理,不能宣称扫完')
195
+ assert.equal(scanSettled(result), false, '未扫完就必须继续重试')
196
+ })
197
+
198
+ test('持久层读会话头失败时不算快照完整', async () => {
199
+ const h = createHarness({ records: [] })
200
+ const base = h.ctx.get.bind(h.ctx)
201
+ h.ctx.get = (name) => (name === 'sessionPersistence' ? { async list() { return [] }, async stat() { throw new Error('service not ready') } } : base(name))
202
+ const result = await scanStoredSessions({
203
+ ctx: h.ctx,
204
+ config: resolveConfig(),
205
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
206
+ now: () => NOW,
207
+ scanDirectory: () => ({ items: [{ id: 's1', path: 'p/s1', mtimeMs: NOW }], partial: false }),
208
+ })
209
+ assert.equal(result.source, 'partial', '服务读失败不能让候选静默消失')
210
+ assert.equal(scanSettled(result), false)
211
+ })
212
+
213
+ test('拿不到会话头时不凭 id 伪装主会话恢复', async () => {
214
+ const h = createHarness({ records: [record('s1', danglingTail, { origin: 'subagent' })] })
215
+ const base = h.ctx.get.bind(h.ctx)
216
+ h.ctx.get = (name) => (name === 'sessionPersistence' ? { async list() { return [] }, async stat() { return undefined } } : base(name))
217
+ const result = await scanStoredSessions({
218
+ ctx: h.ctx,
219
+ config: resolveConfig(),
220
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
221
+ now: () => NOW,
222
+ scanDirectory: () => ({ items: [{ id: 's1', path: 'p/s1', mtimeMs: NOW }], partial: false }),
223
+ })
224
+ assert.equal(result.resumed, 0, '无法判定子会话身份时不得恢复')
225
+ assert.equal(h.sent.length, 0)
226
+ assert.equal(result.source, 'directory', '拿不到头部不等于读取失败,不该误报快照不完整')
227
+ })
228
+
229
+ test('会话根目录不存在时视为权威空快照', async () => {
230
+ const { mkdtempSync: mkdtemp } = await import('node:fs')
231
+ const { tmpdir: temp } = await import('node:os')
232
+ const { join: joinPath } = await import('node:path')
233
+ const { listFreshByDirectory } = await import('../src/scan.mjs')
234
+ // 新装或被清空:home 下没有 sessions 目录
235
+ const home = mkdtemp(joinPath(temp(), 'keep-working-nodir-'))
236
+ const previous = process.env.DSH_HOME
237
+ process.env.DSH_HOME = home
238
+ try {
239
+ const found = listFreshByDirectory(resolveConfig(), NOW)
240
+ assert.deepEqual(found.items, [])
241
+ assert.equal(found.partial, false, '目录不存在等于没有会话,不是读取失败')
242
+ } finally {
243
+ if (previous === undefined) delete process.env.DSH_HOME
244
+ else process.env.DSH_HOME = previous
245
+ }
246
+ })
247
+
248
+ test('目录命中且能读到会话头时按头部信息恢复', async () => {
249
+ const h = createHarness({ records: [record('s1', danglingTail)] })
250
+ const base = h.ctx.get.bind(h.ctx)
251
+ h.ctx.get = (name) => (name === 'sessionPersistence' ? {
252
+ async list() { return [] },
253
+ async stat(id) { return { header: { id, agentPreset: 'default' }, eventCount: danglingTail.length } },
254
+ } : base(name))
255
+ const result = await scanStoredSessions({
256
+ ctx: h.ctx,
257
+ config: resolveConfig(),
258
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
259
+ now: () => NOW,
260
+ scanDirectory: () => ({ items: [{ id: 's1', path: 'p/s1', mtimeMs: NOW }], partial: false }),
261
+ })
262
+ assert.equal(result.resumed, 1, '目录命中且头部可读时应恢复')
263
+ assert.equal(result.source, 'directory')
264
+ assert.deepEqual(h.resumed, ['s1'])
265
+ })
266
+
267
+ test('会话头没有 preset 时仍按主会话恢复', async () => {
268
+ // 老会话的头部可能没有 agentPreset:这不影响是不是子会话的判定
269
+ const h = createHarness({ records: [record('s1', danglingTail)] })
270
+ const base = h.ctx.get.bind(h.ctx)
271
+ h.ctx.get = (name) => (name === 'sessionPersistence' ? {
272
+ async list() { return [] },
273
+ async stat(id) { return { header: { id }, eventCount: danglingTail.length } },
274
+ } : base(name))
275
+ const result = await scanStoredSessions({
276
+ ctx: h.ctx,
277
+ config: resolveConfig(),
278
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
279
+ now: () => NOW,
280
+ scanDirectory: () => ({ items: [{ id: 's1', path: 'p/s1', mtimeMs: NOW }], partial: false }),
281
+ })
282
+ assert.equal(result.resumed, 1, '缺少 preset 不是跳过候选的理由')
283
+ })
284
+
285
+ test('部分会话头读取失败仍算快照不完整', async () => {
286
+ const h = createHarness({ records: [record('s1', danglingTail), record('s2', danglingTail)] })
287
+ const base = h.ctx.get.bind(h.ctx)
288
+ h.ctx.get = (name) => (name === 'sessionPersistence' ? {
289
+ async list() { return [] },
290
+ async stat(id) {
291
+ if (id === 's2') throw new Error('read failed')
292
+ return { header: { id, agentPreset: 'default' }, eventCount: danglingTail.length }
293
+ },
294
+ } : base(name))
295
+ const items = ['s1', 's2'].map((id) => ({ id, path: `p/${id}`, mtimeMs: NOW }))
296
+ const result = await scanStoredSessions({
297
+ ctx: h.ctx,
298
+ config: resolveConfig(),
299
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
300
+ now: () => NOW,
301
+ scanDirectory: () => ({ items, partial: false }),
302
+ })
303
+ assert.equal(result.source, 'partial', '只要有一个读失败,快照就不完整')
304
+ assert.equal(scanSettled(result), false)
305
+ })
306
+
307
+ test('会话根路径不是目录时交给持久层回退', async () => {
308
+ const { mkdtempSync: mkdtemp, writeFileSync } = await import('node:fs')
309
+ const { tmpdir: temp } = await import('node:os')
310
+ const { join: joinPath } = await import('node:path')
311
+ const { listFreshByDirectory } = await import('../src/scan.mjs')
312
+ const home = mkdtemp(joinPath(temp(), 'keep-working-notdir-'))
313
+ // sessions 是普通文件:ENOTDIR 不同于 ENOENT,不能当成"没有会话"
314
+ writeFileSync(joinPath(home, 'sessions'), 'x')
315
+ const previous = process.env.DSH_HOME
316
+ process.env.DSH_HOME = home
317
+ try {
318
+ assert.equal(listFreshByDirectory(resolveConfig(), NOW), undefined, '非 ENOENT 应回退持久层')
319
+ } finally {
320
+ if (previous === undefined) delete process.env.DSH_HOME
321
+ else process.env.DSH_HOME = previous
322
+ }
323
+ })
324
+
325
+ test('会话根目录不存在时整轮扫描直接收工', async () => {
326
+ const { mkdtempSync: mkdtemp } = await import('node:fs')
327
+ const { tmpdir: temp } = await import('node:os')
328
+ const { join: joinPath } = await import('node:path')
329
+ const home = mkdtemp(joinPath(temp(), 'keep-working-settle-'))
330
+ const h = createHarness({ records: [] })
331
+ const base = h.ctx.get.bind(h.ctx)
332
+ h.ctx.get = (name) => (name === 'sessionPersistence' ? { async list() { return [] }, async stat() { return undefined } } : base(name))
333
+ const previous = process.env.DSH_HOME
334
+ process.env.DSH_HOME = home
335
+ try {
336
+ // 走真实的 listFreshByDirectory:根目录不存在必须让整轮直接收工,而不是回退持久层重试
337
+ const result = await scanStoredSessions({
338
+ ctx: h.ctx,
339
+ config: resolveConfig(),
340
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
341
+ now: () => NOW,
342
+ })
343
+ assert.equal(result.source, 'directory')
344
+ assert.equal(scanSettled(result), true, '没有会话目录就是没有会话,不应继续重试')
345
+ } finally {
346
+ if (previous === undefined) delete process.env.DSH_HOME
347
+ else process.env.DSH_HOME = previous
348
+ }
349
+ })
350
+
351
+ test('单个产物缺失不算快照不完整', async () => {
352
+ const { mkdtempSync: mkdtemp, mkdirSync, writeFileSync } = await import('node:fs')
353
+ const { tmpdir: temp } = await import('node:os')
354
+ const { join: joinPath } = await import('node:path')
355
+ const { listFreshByDirectory } = await import('../src/scan.mjs')
356
+ const home = mkdtemp(joinPath(temp(), 'keep-working-partial-'))
357
+ const dir = joinPath(home, 'sessions', 'ws', 'session-a')
358
+ mkdirSync(dir, { recursive: true })
359
+ // 只有非 zstd 形态的产物:可能是别的后端或旧代际,属常态
360
+ writeFileSync(joinPath(dir, 'session.v4.jsonl'), 'x')
361
+ const previous = process.env.DSH_HOME
362
+ process.env.DSH_HOME = home
363
+ try {
364
+ const found = listFreshByDirectory(resolveConfig(), NOW)
365
+ assert.equal(found.partial, false, '缺少 zstd 产物不代表目录不可读')
366
+ } finally {
367
+ if (previous === undefined) delete process.env.DSH_HOME
368
+ else process.env.DSH_HOME = previous
369
+ }
370
+ })
371
+
372
+ test('未闭合的存储会话被物化并补一条恢复消息', async () => {
373
+ const h = createHarness({ records: [record('s1', danglingTail)] })
374
+ const result = await scanStoredSessions({
375
+ ctx: h.ctx,
376
+ config: resolveConfig(),
377
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
378
+ now: () => NOW,
379
+ scanDirectory: () => undefined,
380
+ })
381
+ assert.equal(result.resumed, 1)
382
+ assert.deepEqual(h.resumed, ['s1'])
383
+ assert.equal(h.sent.length, 1)
384
+ })
385
+
386
+ test('已结案的会话不恢复', async () => {
387
+ const h = createHarness({ records: [record('s1', closedTail)] })
388
+ const result = await scanStoredSessions({
389
+ ctx: h.ctx,
390
+ config: resolveConfig(),
391
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
392
+ now: () => NOW,
393
+ scanDirectory: () => undefined,
394
+ })
395
+ assert.equal(result.resumed, 0)
396
+ assert.equal(h.sent.length, 0)
397
+ })
398
+
399
+ test('正常结束但仍有排队消息的会话被恢复,恢复消息插队发送', async () => {
400
+ for (const tail of [openTail, closedTail]) {
401
+ const events = [...tail, inboxSpliced(5, 'next-turn', [queuedMessage(5)])]
402
+ const h = createHarness({ records: [record('s1', events)] })
403
+ const result = await scanStoredSessions({
404
+ ctx: h.ctx,
405
+ config: resolveConfig(),
406
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
407
+ now: () => NOW,
408
+ scanDirectory: () => undefined,
409
+ })
410
+ assert.equal(result.resumed, 1)
411
+ assert.equal(h.sent.length, 1)
412
+ assert.equal(h.sent[0].via, 'steer', '排队消息存在时必须插队,不能追加到 next-turn 队尾')
413
+ }
414
+ })
415
+
416
+ test('未闭合回合带排队消息时同样插队恢复', async () => {
417
+ const events = [...danglingTail, inboxSpliced(4, 'next-turn', [queuedMessage(4)])]
418
+ const h = createHarness({ records: [record('s1', events)] })
419
+ const result = await scanStoredSessions({
420
+ ctx: h.ctx,
421
+ config: resolveConfig(),
422
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
423
+ now: () => NOW,
424
+ scanDirectory: () => undefined,
425
+ })
426
+ assert.equal(result.resumed, 1)
427
+ assert.equal(h.sent[0].via, 'steer')
428
+ })
429
+
430
+ test('next-step 的运行时上下文不算排队消息,真人插话才算', async () => {
431
+ const context = [{ id: 'c-1', role: 'user', content: [], source: { kind: 'runtime-context' } }]
432
+ const withContext = createHarness({
433
+ records: [record('s1', [...openTail, inboxSpliced(5, 'next-step', context)])],
434
+ })
435
+ const contextResult = await scanStoredSessions({
436
+ ctx: withContext.ctx,
437
+ config: resolveConfig(),
438
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
439
+ now: () => NOW,
440
+ scanDirectory: () => undefined,
441
+ })
442
+ assert.equal(contextResult.resumed, 0)
443
+
444
+ const withSteering = createHarness({
445
+ records: [record('s1', [...openTail, inboxSpliced(5, 'next-step', [queuedMessage(5)])])],
446
+ })
447
+ const steeringResult = await scanStoredSessions({
448
+ ctx: withSteering.ctx,
449
+ config: resolveConfig(),
450
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
451
+ now: () => NOW,
452
+ scanDirectory: () => undefined,
453
+ })
454
+ assert.equal(steeringResult.resumed, 1)
455
+ assert.equal(withSteering.sent[0].via, 'steer')
456
+ })
457
+
458
+ test('用户主动取消的回合即使有排队消息也不恢复', async () => {
459
+ const events = [
460
+ ...openTail.slice(0, 3),
461
+ turnEnd(4, TURN, { kind: 'aborted', reason: { kind: 'user' } }),
462
+ inboxSpliced(5, 'next-turn', [queuedMessage(5)]),
463
+ ]
464
+ const h = createHarness({ records: [record('s1', events)] })
465
+ const result = await scanStoredSessions({
466
+ ctx: h.ctx,
467
+ config: resolveConfig(),
468
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
469
+ now: () => NOW,
470
+ scanDirectory: () => undefined,
471
+ })
472
+ assert.equal(result.resumed, 0)
473
+ assert.equal(h.sent.length, 0)
474
+ })
475
+
476
+ test('排队消息本身超出新鲜窗口时不恢复', async () => {
477
+ const stale = [...openTail, inboxSpliced(5, 'next-turn', [queuedMessage(5)])].map((item) => ({
478
+ ...item,
479
+ time: NOW - 60 * 60_000,
480
+ }))
481
+ const h = createHarness({ records: [record('s1', stale)] })
482
+ const result = await scanStoredSessions({
483
+ ctx: h.ctx,
484
+ config: resolveConfig(),
485
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
486
+ now: () => NOW,
487
+ scanDirectory: () => undefined,
488
+ })
489
+ assert.equal(result.resumed, 0)
490
+ })
491
+
492
+ test('子会话不单独恢复', async () => {
493
+ const h = createHarness({ records: [record('s1', danglingTail, { origin: 'subagent' })] })
494
+ const result = await scanStoredSessions({
495
+ ctx: h.ctx,
496
+ config: resolveConfig(),
497
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
498
+ now: () => NOW,
499
+ scanDirectory: () => undefined,
500
+ })
501
+ assert.equal(result.resumed, 0)
502
+ })
503
+
504
+ test('旧格式子会话(parentSession + delegationDepth)不恢复', async () => {
505
+ for (const extra of [
506
+ { parentSession: 'root', delegationDepth: 1 },
507
+ { parentSession: 'root', delegationDepth: 2 },
508
+ ]) {
509
+ const h = createHarness({ records: [record('s1', danglingTail, extra)] })
510
+ const result = await scanStoredSessions({
511
+ ctx: h.ctx,
512
+ config: resolveConfig(),
513
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
514
+ now: () => NOW,
515
+ scanDirectory: () => undefined,
516
+ })
517
+ assert.equal(result.resumed, 0)
518
+ }
519
+ const fork = createHarness({ records: [record('s1', danglingTail, { parentSession: 'root', delegationDepth: 0 })] })
520
+ const result = await scanStoredSessions({
521
+ ctx: fork.ctx,
522
+ config: resolveConfig(),
523
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
524
+ now: () => NOW,
525
+ scanDirectory: () => undefined,
526
+ })
527
+ assert.equal(result.resumed, 1, 'depth 0 是普通 fork,应参与恢复')
528
+ })
529
+
530
+ test('已归档会话不恢复', async () => {
531
+ const h = createHarness({ records: [record('s1', openTail)], archived: ['s1'] })
532
+ const result = await scanStoredSessions({
533
+ ctx: h.ctx,
534
+ config: resolveConfig(),
535
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
536
+ now: () => NOW,
537
+ scanDirectory: () => undefined,
538
+ })
539
+ assert.equal(result.resumed, 0)
540
+ })
541
+
542
+ test('超出新鲜窗口的会话不恢复', async () => {
543
+ const stale = openTail.map((item) => ({ ...item, time: NOW - 60 * 60_000 }))
544
+ const h = createHarness({ records: [record('s1', stale)] })
545
+ const result = await scanStoredSessions({
546
+ ctx: h.ctx,
547
+ config: resolveConfig(),
548
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
549
+ now: () => NOW,
550
+ scanDirectory: () => undefined,
551
+ })
552
+ assert.equal(result.resumed, 0)
553
+ })
554
+
555
+ test('用户主动取消的会话不恢复', async () => {
556
+ const tail = [...openTail.slice(0, 3), turnEnd(4, TURN, { kind: 'aborted', reason: { kind: 'user' } })]
557
+ const h = createHarness({ records: [record('s1', tail)] })
558
+ const result = await scanStoredSessions({
559
+ ctx: h.ctx,
560
+ config: resolveConfig(),
561
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
562
+ now: () => NOW,
563
+ scanDirectory: () => undefined,
564
+ })
565
+ assert.equal(result.resumed, 0)
566
+ })
567
+
568
+ test('未闭合的回合按未结案恢复', async () => {
569
+ const tail = [turnStart(1, TURN), userMessage(2)]
570
+ const h = createHarness({ records: [record('s1', tail)] })
571
+ const result = await scanStoredSessions({
572
+ ctx: h.ctx,
573
+ config: resolveConfig(),
574
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
575
+ now: () => NOW,
576
+ scanDirectory: () => undefined,
577
+ })
578
+ assert.equal(result.resumed, 1)
579
+ })
580
+
581
+ test('scanLimit 限制单轮恢复数量', async () => {
582
+ const records = [
583
+ record('s1', openTail),
584
+ record('s2', danglingTail),
585
+ record('s3', danglingTail),
586
+ ]
587
+ const h = createHarness({ records })
588
+ const result = await scanStoredSessions({
589
+ ctx: h.ctx,
590
+ config: resolveConfig({ scanLimit: 2 }),
591
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
592
+ now: () => NOW,
593
+ scanDirectory: () => undefined,
594
+ })
595
+ assert.equal(result.resumed, 2)
596
+ })
597
+
598
+ test('物化失败不阻断后续会话', async () => {
599
+ const h = createHarness({ records: [record('s1', openTail)], resumeFails: true })
600
+ const result = await scanStoredSessions({
601
+ ctx: h.ctx,
602
+ config: resolveConfig(),
603
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
604
+ now: () => NOW,
605
+ scanDirectory: () => undefined,
606
+ })
607
+ assert.equal(result.resumed, 0)
608
+ })
609
+
610
+ test('goal 失活时重新武装', async () => {
611
+ const resumedGoals = []
612
+ const goals = {
613
+ get: (agent) => ({ id: 'g1', revision: 4, phase: 'active', activation: 'disarmed', agent: agent.id }),
614
+ resume: (agent, ref) => resumedGoals.push({ agent: agent.id, ref }),
615
+ }
616
+ const h = createHarness({ records: [record('s1', danglingTail)], goals })
617
+ await scanStoredSessions({
618
+ ctx: h.ctx,
619
+ config: resolveConfig({ resumeGoals: true }),
620
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
621
+ now: () => NOW,
622
+ scanDirectory: () => undefined,
623
+ })
624
+ assert.deepEqual(resumedGoals, [{ agent: 's1', ref: { id: 'g1', revision: 4 } }])
625
+ })
626
+
627
+ test('scanOnBoot 关闭时不扫描', async () => {
628
+ const h = createHarness({ records: [record('s1', openTail)] })
629
+ const result = await scanStoredSessions({
630
+ ctx: h.ctx,
631
+ config: resolveConfig({ scanOnBoot: false }),
632
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
633
+ now: () => NOW,
634
+ scanDirectory: () => undefined,
635
+ })
636
+ assert.equal(result.resumed, 0)
637
+ assert.equal(h.resumed.length, 0)
638
+ })
639
+
640
+ test('预算用尽时标记未完成', async () => {
641
+ const h = createHarness({ records: [record('s1', openTail), record('s2', danglingTail)] })
642
+ const result = await scanStoredSessions({
643
+ ctx: h.ctx,
644
+ config: resolveConfig(),
645
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
646
+ now: () => NOW,
647
+ scanDirectory: () => undefined,
648
+ budgetMs: 0,
649
+ })
650
+ assert.equal(result.complete, false)
651
+ assert.equal(result.resumed, 0)
652
+ })
653
+
654
+ test('下一轮跳过已裁决会话,继续处理剩余', async () => {
655
+ const h = createHarness({ records: [record('s1', closedTail), record('s2', danglingTail)] })
656
+ const seen = new Set(['s1'])
657
+ const result = await scanStoredSessions({
658
+ ctx: h.ctx,
659
+ config: resolveConfig(),
660
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
661
+ now: () => NOW,
662
+ scanDirectory: () => undefined,
663
+ seen,
664
+ })
665
+ assert.equal(result.complete, true)
666
+ assert.equal(result.scanned, 1)
667
+ assert.deepEqual(h.resumed, ['s2'])
668
+ })
669
+
670
+ test('归档集合未就绪时本轮不恢复', async () => {
671
+ const h = createHarness({ records: [record('s1', danglingTail)], registryThrows: true })
672
+ const result = await scanStoredSessions({
673
+ ctx: h.ctx,
674
+ config: resolveConfig(),
675
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
676
+ now: () => NOW,
677
+ scanDirectory: () => undefined,
678
+ })
679
+ assert.equal(result.resumed, 0)
680
+ assert.equal(result.complete, false)
681
+ assert.deepEqual(h.resumed, [])
682
+ })
683
+
684
+ test('目录扫描按修改时间筛出近期会话', async () => {
685
+ const { mkdtempSync, mkdirSync, writeFileSync, utimesSync } = await import('node:fs')
686
+ const { tmpdir } = await import('node:os')
687
+ const { join } = await import('node:path')
688
+ const { listFreshByDirectory } = await import('../src/scan.mjs')
689
+
690
+ const home = mkdtempSync(join(tmpdir(), 'keep-working-dir-'))
691
+ const artifact = (id) => {
692
+ const dir = join(home, 'sessions', 'ws', id)
693
+ mkdirSync(dir, { recursive: true })
694
+ const file = join(dir, 'session.v4.jsonl.zstd')
695
+ writeFileSync(file, 'x')
696
+ return file
697
+ }
698
+ const freshPath = artifact('session-fresh')
699
+ const stalePath = artifact('session-stale')
700
+ const nowSeconds = NOW / 1000
701
+ utimesSync(freshPath, nowSeconds, nowSeconds)
702
+ utimesSync(stalePath, nowSeconds - 3600, nowSeconds - 3600)
703
+
704
+ const previous = process.env.DSH_HOME
705
+ process.env.DSH_HOME = home
706
+ try {
707
+ const found = listFreshByDirectory(resolveConfig(), NOW)
708
+ assert.equal(found.partial, false, '全部子项可读时快照完整')
709
+ assert.deepEqual(found.items.map((item) => item.id), ['session-fresh'])
710
+ } finally {
711
+ if (previous === undefined) delete process.env.DSH_HOME
712
+ else process.env.DSH_HOME = previous
713
+ }
714
+ })
715
+
716
+ test('会话查询服务缺失是终态,本轮直接收工', async () => {
717
+ const result = await scanStoredSessions({
718
+ ctx: { get: () => undefined, agents: { get: () => undefined } },
719
+ config: resolveConfig(),
720
+ createUserMessage: () => ({ id: 'auto-1' }),
721
+ scanDirectory: () => undefined,
722
+ })
723
+ assert.equal(result.settled, true)
724
+ assert.equal(scanSettled(result), true, '缺服务不能按秒重试到新鲜窗口耗尽')
725
+ })
726
+
727
+ test('扫描关闭与全局暂停同样是终态', async () => {
728
+ for (const overrides of [{ scanOnBoot: false }, { paused: true }]) {
729
+ const result = await scanStoredSessions({
730
+ ctx: { get: () => undefined, agents: { get: () => undefined } },
731
+ config: resolveConfig(overrides),
732
+ createUserMessage: () => ({ id: 'auto-1' }),
733
+ scanDirectory: () => undefined,
734
+ })
735
+ assert.equal(scanSettled(result), true)
736
+ }
737
+ })
738
+
739
+ test('空回合恢复开关关闭时重启扫描不补发', async () => {
740
+ const silentTail = [
741
+ turnStart(1, TURN),
742
+ userMessage(2),
743
+ turnEnd(4, TURN, { kind: 'completed' }),
744
+ ]
745
+ for (const [resumeSilentTurns, expected] of [
746
+ [false, 0],
747
+ [true, 1],
748
+ ]) {
749
+ const h = createHarness({ records: [record('s-silent', silentTail)] })
750
+ const result = await scanStoredSessions({
751
+ ctx: h.ctx,
752
+ config: resolveConfig({ resumeSilentTurns }),
753
+ createUserMessage: (options) => ({ id: 'auto-1', ...options }),
754
+ now: () => NOW,
755
+ scanDirectory: () => undefined,
756
+ })
757
+ assert.equal(result.resumed, expected, `resumeSilentTurns=${resumeSilentTurns}`)
758
+ assert.equal(h.sent.length, expected)
759
+ }
760
+ })
761
+
762
+ test('第零代与多代际产物都能被目录扫描识别', async () => {
763
+ const home = mkdtempSync(join(tmpdir(), 'keep-working-gen-'))
764
+ const workspace = join(home, 'sessions', 'ws')
765
+ const nowSeconds = NOW / 1000
766
+ const make = (sessionId, fileName) => {
767
+ const dir = join(workspace, sessionId)
768
+ mkdirSync(dir, { recursive: true })
769
+ const file = join(dir, fileName)
770
+ writeFileSync(file, 'x')
771
+ utimesSync(file, nowSeconds, nowSeconds)
772
+ }
773
+ make('session-zero', 'session.jsonl.zstd')
774
+ make('session-v3', 'session.v3.jsonl.zstd')
775
+ make('session-v5', 'session.v5.jsonl.zstd')
776
+
777
+ const previous = process.env.DSH_HOME
778
+ process.env.DSH_HOME = home
779
+ try {
780
+ const found = listFreshByDirectory(resolveConfig(), NOW)
781
+ assert.equal(found.partial, false)
782
+ assert.deepEqual(found.items.map((item) => item.id).sort(), ['session-v3', 'session-v5', 'session-zero'])
783
+ } finally {
784
+ if (previous === undefined) delete process.env.DSH_HOME
785
+ else process.env.DSH_HOME = previous
786
+ }
787
+ })