dsh-memu-memory 1.2.0 → 1.2.1
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 +8 -4
- package/README.zh.md +9 -5
- package/docs/contributing.md +2 -2
- package/docs/env-and-commands.md +8 -0
- package/docs/troubleshooting.md +4 -3
- package/lib/activity-log.js +22 -5
- package/lib/binding-diagnostics.js +1 -1
- package/lib/client.js +302 -24
- package/lib/consolidation-worker.js +43 -14
- package/lib/curator.js +86 -31
- package/lib/graph.js +139 -5
- package/lib/index.js +33 -1
- package/lib/memory.js +314 -70
- package/lib/private.js +98 -6
- package/lib/rpc.js +45 -4
- package/lib/text-similarity.js +106 -0
- package/lib/tools.js +64 -8
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +24 -0
- package/package.json +3 -2
- package/scripts/replay.mjs +573 -0
- package/tests/appendix-b.test.mjs +6 -2
- package/tests/consolidation-credential.test.mjs +16 -5
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +142 -10
- package/tests/consolidation-snapshot.test.mjs +43 -4
- package/tests/curator-commit.test.mjs +277 -0
- package/tests/graph-cascade.test.mjs +190 -0
- package/tests/graph.test.mjs +17 -0
- package/tests/hybrid-recall.test.mjs +32 -2
- package/tests/managed-memory.test.mjs +55 -0
- package/tests/recall-dedup.test.mjs +417 -0
- package/tests/sidebar-beat.test.mjs +5 -1
- package/tests/vector-migration.test.mjs +58 -1
- package/tests/webui.test.mjs +3 -2
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-consolidation-feed-'))
|
|
8
|
+
process.env.DSH_MEMU_HOME = home
|
|
9
|
+
process.env.DSH_MEMU_MEMORY_HOME = home
|
|
10
|
+
|
|
11
|
+
const state = await import('../lib/state.js')
|
|
12
|
+
const { record, resetActivityLogCache, listEvents, clearEvents } = await import('../lib/activity-log.js')
|
|
13
|
+
const { dispatch } = await import('../lib/rpc.js')
|
|
14
|
+
|
|
15
|
+
test.after(() => {
|
|
16
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
// 侧边栏"整理提醒"与日志页的数据链路:跨会话取整理事件,服务端把会话标题与文件夹名
|
|
20
|
+
// 拼进每一行(客户端只回退到裸 id)。标题走宿主 readTitleSnapshots(批量一次)。
|
|
21
|
+
const ctx = {
|
|
22
|
+
get: (name) => {
|
|
23
|
+
if (name === 'sessionQuery') {
|
|
24
|
+
return {
|
|
25
|
+
readTitleSnapshots: async (ids) => ids.map((id) => ({
|
|
26
|
+
sessionId: id,
|
|
27
|
+
status: id === 'session-titled' ? 'fulfilled' : 'rejected',
|
|
28
|
+
...(id === 'session-titled' ? { value: { title: { title: ' princess 的深夜备份会话 ' } } } : {}),
|
|
29
|
+
})),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return undefined
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('getConsolidationFeed splits failures and successes, decorates titles, caps each column', async () => {
|
|
37
|
+
const library = state.createLibrary({ cwd: path.join(home, 'feed-proj'), name: '夜备份工作区' })
|
|
38
|
+
resetActivityLogCache()
|
|
39
|
+
// 8 条失败 + 8 条成功,交错落盘;另有别的 kind 的失败(不得混进来)。
|
|
40
|
+
for (let index = 0; index < 8; index += 1) {
|
|
41
|
+
record({ kind: 'consolidate', scope: 'private', ok: false, level: 'error', sessionId: index === 7 ? 'session-titled' : 'session-plain-' + index, libraryId: library.id, message: '整理失败 · 空闲 · case-' + index, detail: { reasonCode: 'fork-turn-failed' } })
|
|
42
|
+
record({ kind: 'consolidate', scope: 'private', ok: true, sessionId: 'session-titled', libraryId: library.id, message: '整理完成 · 空闲', detail: { count: index } })
|
|
43
|
+
}
|
|
44
|
+
record({ kind: 'recall', scope: 'private', ok: false, sessionId: 'session-titled', message: '召回失败不进整理提醒' })
|
|
45
|
+
|
|
46
|
+
const feed = await dispatch(ctx, 'getConsolidationFeed', { limit: 6 })
|
|
47
|
+
assert.equal(feed.failures.length, 6, 'failures capped at the requested column size')
|
|
48
|
+
assert.equal(feed.successes.length, 6, 'successes capped at the requested column size')
|
|
49
|
+
assert.ok(feed.failures.every(row => row.ok === false), 'the failure column holds only failures')
|
|
50
|
+
assert.ok(feed.successes.every(row => row.ok !== false), 'the success column holds only successes')
|
|
51
|
+
// 最新在前:失败列第一条是最后落盘的那条失败。
|
|
52
|
+
assert.match(feed.failures[0].message, /case-7/)
|
|
53
|
+
// 会话标题 + 文件夹名由服务端拼好;查不到标题的行回退 null(客户端用短 id)。
|
|
54
|
+
assert.equal(feed.failures[0].sessionTitle, 'princess 的深夜备份会话')
|
|
55
|
+
assert.equal(feed.failures[0].libraryName, '夜备份工作区')
|
|
56
|
+
const plain = feed.failures.find(row => row.sessionId === 'session-plain-6')
|
|
57
|
+
assert.equal(plain.sessionTitle, null)
|
|
58
|
+
// 行已瘦身:回执 detail 不整包透传,只留提醒需要的字段。
|
|
59
|
+
assert.equal(feed.failures[0].reasonCode, 'fork-turn-failed')
|
|
60
|
+
assert.equal(feed.failures[0].detail, undefined)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('getActivityLog decorates consolidation rows with session title and library name', async () => {
|
|
64
|
+
const library = state.createLibrary({ cwd: path.join(home, 'feed-proj-2'), name: '第二个工作区' })
|
|
65
|
+
resetActivityLogCache()
|
|
66
|
+
record({ kind: 'consolidate', scope: 'private', ok: false, level: 'error', sessionId: 'session-titled', libraryId: library.id, message: '整理失败 · 空闲 · worker-tools-ptc-only', detail: { reasonCode: 'worker-tools-ptc-only', toolSurface: ['run_code'] } })
|
|
67
|
+
|
|
68
|
+
const data = await dispatch(ctx, 'getActivityLog', { kind: 'consolidate', limit: 10 })
|
|
69
|
+
const row = data.events.find(item => item.message.includes('worker-tools-ptc-only'))
|
|
70
|
+
assert.ok(row, 'the consolidation row is returned')
|
|
71
|
+
assert.equal(row.sessionTitle, 'princess 的深夜备份会话')
|
|
72
|
+
assert.equal(row.libraryName, '第二个工作区')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('clearConsolidationFailures deletes only failed consolidate rows, everything else stays', async () => {
|
|
76
|
+
const library = state.createLibrary({ cwd: path.join(home, 'feed-clear'), name: '清除测试区' })
|
|
77
|
+
resetActivityLogCache()
|
|
78
|
+
// 同一个临时 home 累积了前面用例的日志:先全清,种进去的才是本用例的全部世界。
|
|
79
|
+
clearEvents()
|
|
80
|
+
record({ kind: 'consolidate', scope: 'private', ok: false, level: 'error', sessionId: 'session-titled', libraryId: library.id, message: '整理失败 · 其一', detail: { reasonCode: 'timeout' } })
|
|
81
|
+
record({ kind: 'consolidate', scope: 'private', ok: false, level: 'error', sessionId: 'session-titled', libraryId: library.id, message: '整理失败 · 其二', detail: { reasonCode: 'timeout' } })
|
|
82
|
+
record({ kind: 'consolidate', scope: 'private', ok: true, sessionId: 'session-titled', libraryId: library.id, message: '整理完成' })
|
|
83
|
+
record({ kind: 'recall', scope: 'private', ok: false, sessionId: 'session-titled', message: '召回失败不是整理失败' })
|
|
84
|
+
|
|
85
|
+
const result = await dispatch(ctx, 'clearConsolidationFailures', { limit: 6 })
|
|
86
|
+
assert.equal(result.cleared, 2, 'exactly the two failed consolidate rows are removed')
|
|
87
|
+
assert.equal((result.feed.failures || []).length, 0, 'the failure column comes back empty')
|
|
88
|
+
assert.equal((result.feed.successes || []).length, 1, 'the success flow survives')
|
|
89
|
+
// 其它 kind 的失败行与成功行都不受影响。
|
|
90
|
+
const kinds = listEvents({ limit: 50 })
|
|
91
|
+
assert.ok(kinds.some(r => r.kind === 'recall' && r.ok === false), 'non-consolidate failures stay')
|
|
92
|
+
assert.ok(kinds.some(r => r.kind === 'consolidate' && r.ok !== false), 'consolidate successes stay')
|
|
93
|
+
// 落盘一致:重读缓存后计数不变(rewrite 是权威,不是只在内存里删)。
|
|
94
|
+
resetActivityLogCache()
|
|
95
|
+
assert.equal(listEvents({ kind: 'consolidate', level: 'error', limit: 50 }).length, 0, 'the rewrite reached disk')
|
|
96
|
+
})
|
|
@@ -8,6 +8,7 @@ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-fork-'))
|
|
|
8
8
|
process.env.DSH_MEMU_HOME = home
|
|
9
9
|
|
|
10
10
|
const state = await import('../lib/state.js')
|
|
11
|
+
const activity = await import('../lib/activity-log.js')
|
|
11
12
|
const { requestPrivateConsolidation, isConsolidating } = await import('../lib/private.js')
|
|
12
13
|
|
|
13
14
|
test.after(() => {
|
|
@@ -52,8 +53,9 @@ function assertHostCreateAccepts(spec = {}) {
|
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
function host({ live = false, whenIdle = null, parentOptions = { provider: 'test-provider', model: 'test-model' }, requestConfig = null, extraEvents = [], transcriptSize = 0, turnReason = { kind: 'completed' }, emitTurnEnd = true } = {}) {
|
|
56
|
-
const rec = { liveFollowups: [], created: [], forkFollowups: [], disposed: [], sessionHandlers: [] }
|
|
56
|
+
function host({ live = false, whenIdle = null, parentOptions = { provider: 'test-provider', model: 'test-model' }, requestConfig = null, extraEvents = [], transcriptSize = 0, turnReason = { kind: 'completed' }, emitTurnEnd = true, headerTools = null, childCtxFactory = null } = {}) {
|
|
57
|
+
const rec = { liveFollowups: [], created: [], forkFollowups: [], disposed: [], sessionHandlers: [], workerToolRegistrations: [], workerToolDisposals: [] }
|
|
58
|
+
const childContexts = new Map()
|
|
57
59
|
const tick = String.fromCharCode(96)
|
|
58
60
|
const receiptFence = (range, identity) => [tick + tick + tick + 'memu-consolidation-receipt', JSON.stringify({ version: 1, ...identity, status: 'completed', outcome: 'no_value', checkedRange: range, createdIds: [], updatedIds: [], heldIds: [], reasonCode: null, retryable: false }), tick + tick + tick].join('\n')
|
|
59
61
|
// 真实长会话的规模:补齐不产生证据的事件,只影响 fork 成本判定。
|
|
@@ -72,16 +74,24 @@ function host({ live = false, whenIdle = null, parentOptions = { provider: 'test
|
|
|
72
74
|
get: (name) => {
|
|
73
75
|
if (name === 'agents') {
|
|
74
76
|
return {
|
|
75
|
-
get: (id) =>
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
get: (id) => {
|
|
78
|
+
// attachWorkerTools 用 childContexts 模拟"宿主在 create 后暴露的 agent ctx"。
|
|
79
|
+
if (childContexts.has(id)) return { ctx: childContexts.get(id) }
|
|
80
|
+
return (live
|
|
81
|
+
? {
|
|
82
|
+
followup: (message) => rec.liveFollowups.push({ id, message }),
|
|
83
|
+
...(parentOptions ? { options: parentOptions } : {}),
|
|
84
|
+
...(requestConfig || headerTools ? { session: { requestHeader: () => ({
|
|
85
|
+
...(requestConfig ? { config: requestConfig } : {}),
|
|
86
|
+
...(headerTools ? { tools: headerTools.map((toolName) => ({ name: toolName })) } : {}),
|
|
87
|
+
}) } } : {}),
|
|
88
|
+
}
|
|
89
|
+
: undefined)
|
|
90
|
+
},
|
|
82
91
|
create: async (spec) => {
|
|
83
92
|
assertHostCreateAccepts(spec)
|
|
84
93
|
rec.created.push(spec)
|
|
94
|
+
if (childCtxFactory) childContexts.set(spec.sessionId, childCtxFactory())
|
|
85
95
|
const childSession = { id: spec.sessionId }
|
|
86
96
|
return {
|
|
87
97
|
agent: {
|
|
@@ -354,4 +364,126 @@ test('a large transcript that was never consolidated does fork, because the back
|
|
|
354
364
|
const result = await requestPrivateConsolidation(ctx, 'fork-large-owed')
|
|
355
365
|
assert.equal(result.ok, true, 'the backlog bound lets this one through')
|
|
356
366
|
assert.equal(ctx.rec.created.length, 1, 'a large unconsolidated backlog is worth exactly one fork')
|
|
357
|
-
})
|
|
367
|
+
})
|
|
368
|
+
// ---------------------------------------------------------------- PTC 兼容
|
|
369
|
+
// 现场(2026-10-31,yuike-ptc 会话):PTC 模式下 request/header 的工具表只有 run_code,
|
|
370
|
+
// probe 曾据此判 unavailable(worker-tools-ptc-only),PTC 会话永远不被整理。宿主的
|
|
371
|
+
// PTC executor 明确 "only run_code is directly callable",其余工具走 run_code 程序里的
|
|
372
|
+
// SDK 绑定(tools.schemas(agent, toolSet) 枚举可见面)—— header 名单在 PTC 会话里
|
|
373
|
+
// 不承载"能不能写记忆"。修复后:PTC 不构成否证 + fork 时把读写四件挂进 worker 自己的
|
|
374
|
+
// scope(注册表按 scope 链取可见面,不扩散到其它会话)。
|
|
375
|
+
|
|
376
|
+
test('a PTC session (run_code only header) is consolidated and the worker gets write tools on its own scope', async () => {
|
|
377
|
+
const registered = []
|
|
378
|
+
const disposals = []
|
|
379
|
+
state.ensureSession('fork-ptc', { cwd: path.join(home, 'proj-ptc'), agentPreset: 'yuike-ptc' })
|
|
380
|
+
const ctx = host({
|
|
381
|
+
live: true,
|
|
382
|
+
headerTools: ['run_code'],
|
|
383
|
+
extraEvents: [
|
|
384
|
+
{ seq: 3, type: 'tool/ptc-dispatch-start', data: { rootCallId: 'r1', parentCallId: 'r1', subCallId: 'r1:ptc:1', name: 'read', arguments: {} } },
|
|
385
|
+
{ seq: 4, type: 'tool/ptc-dispatch', data: { rootCallId: 'r1', parentCallId: 'r1', subCallId: 'r1:ptc:1', name: 'read', arguments: {}, isError: false, content: [] } },
|
|
386
|
+
],
|
|
387
|
+
childCtxFactory: () => ({
|
|
388
|
+
tools: {
|
|
389
|
+
register: (tool) => {
|
|
390
|
+
registered.push(tool?.name)
|
|
391
|
+
return () => { disposals.push(tool?.name) }
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
}),
|
|
395
|
+
})
|
|
396
|
+
const result = await requestPrivateConsolidation(ctx, 'fork-ptc')
|
|
397
|
+
assert.equal(result.ok, true, 'a run_code header must not block consolidation: ' + String(result.error || ''))
|
|
398
|
+
assert.equal(result.settled, true)
|
|
399
|
+
assert.equal(ctx.rec.created.length, 1, 'the fork is created for the PTC conversation')
|
|
400
|
+
// 只挂读写四件;archive/delete/unpin 这些破坏性工具不得进 worker 的工具面。
|
|
401
|
+
assert.deepEqual([...registered].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
|
|
402
|
+
// 整理结束(finally)必须卸载挂载,不能给这条一次性 worker 留常驻注册。
|
|
403
|
+
assert.deepEqual([...disposals].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
|
|
404
|
+
// 全量挂载的活动元数据:attached=实际子集(此处=完整 wanted)、requested=完整 wanted、
|
|
405
|
+
// partial=false。三件一起钉住"成功路径不再含糊"。
|
|
406
|
+
const started = startedEntryFor(ctx, 'fork-ptc')
|
|
407
|
+
assert.deepEqual([...started.detail.workerToolsAttached].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
|
|
408
|
+
assert.deepEqual([...started.detail.workerToolsRequested].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
|
|
409
|
+
assert.equal(started.detail.workerToolsPartial, false, 'a full mount is not partial')
|
|
410
|
+
const followup = ctx.rec.forkFollowups[0]?.message
|
|
411
|
+
const brief = Array.isArray(followup?.content)
|
|
412
|
+
? followup.content.map((block) => block?.text || '').join('\n')
|
|
413
|
+
: String(followup || '')
|
|
414
|
+
assert.match(brief, /run_code/, 'the brief names the PTC transport')
|
|
415
|
+
assert.match(brief, /tools\.memu_private_remember/, 'the brief shows the SDK invocation shape')
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
// "整理已开始"那条活动日志:按 sessionId + phase=started 取,childId 对上这次的 fork。
|
|
419
|
+
function startedEntryFor(ctx, sessionId) {
|
|
420
|
+
const childId = ctx.rec.created[0]?.sessionId
|
|
421
|
+
const entry = activity.listEvents({ kind: 'consolidate', sessionId })
|
|
422
|
+
.find((row) => row.detail?.phase === 'started' && row.detail?.childId === childId)
|
|
423
|
+
assert.ok(entry, 'the consolidation-started activity entry exists')
|
|
424
|
+
return entry
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
test('a host registry refusing one write tool leaves a structured partial mount in the activity log', async () => {
|
|
428
|
+
// 宿主注册表对单件工具说"不"(重名、scope 锁定)是真实的部分挂载路径:registerTools
|
|
429
|
+
// 逐件 fail-open,其余三件照常挂上。活动日志必须记实际子集 + requested + partial,
|
|
430
|
+
// 而不是把 wanted 全量误报成已挂载(旧口径的缺陷)。
|
|
431
|
+
const registered = []
|
|
432
|
+
const disposals = []
|
|
433
|
+
state.ensureSession('fork-ptc-partial', { cwd: path.join(home, 'proj-ptc-partial'), agentPreset: 'yuike-ptc' })
|
|
434
|
+
const ctx = host({
|
|
435
|
+
live: true,
|
|
436
|
+
headerTools: ['run_code'],
|
|
437
|
+
childCtxFactory: () => ({
|
|
438
|
+
tools: {
|
|
439
|
+
register: (tool) => {
|
|
440
|
+
if (tool?.name === 'memu_private_remember') throw new Error('registry refused: name already bound')
|
|
441
|
+
registered.push(tool?.name)
|
|
442
|
+
return () => { disposals.push(tool?.name) }
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
}),
|
|
446
|
+
})
|
|
447
|
+
const result = await requestPrivateConsolidation(ctx, 'fork-ptc-partial')
|
|
448
|
+
assert.equal(result.ok, true, String(result.error || ''))
|
|
449
|
+
assert.equal(result.settled, true)
|
|
450
|
+
assert.deepEqual([...registered].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_update'],
|
|
451
|
+
'exactly the tools that actually registered are mounted')
|
|
452
|
+
assert.deepEqual([...disposals].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_update'],
|
|
453
|
+
'dispose unmounts exactly what was mounted')
|
|
454
|
+
const started = startedEntryFor(ctx, 'fork-ptc-partial')
|
|
455
|
+
assert.deepEqual([...started.detail.workerToolsAttached].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_update'],
|
|
456
|
+
'the log records the actual subset, not the full wanted list')
|
|
457
|
+
assert.deepEqual([...started.detail.workerToolsRequested].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
|
|
458
|
+
assert.equal(started.detail.workerToolsPartial, true)
|
|
459
|
+
// 挂载为部分子集时任务书仍带 PTC 调用形态:worker 知道写记忆走 run_code 的 SDK 绑定。
|
|
460
|
+
const followup = ctx.rec.forkFollowups[0]?.message
|
|
461
|
+
const brief = Array.isArray(followup?.content)
|
|
462
|
+
? followup.content.map((block) => block?.text || '').join('\n')
|
|
463
|
+
: String(followup || '')
|
|
464
|
+
assert.match(brief, /await tools\.memu_private_remember/, 'the brief keeps the PTC invocation shape on a partial mount')
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
test('a fork context without a tools service records a null mount, the request and the partial flag', async () => {
|
|
468
|
+
// 宿主不暴露 agent ctx(或 ctx 上没有 tools 服务)→ attachWorkerTools 的现有 warn 路径。
|
|
469
|
+
// 语义:workerToolsAttached=null = 挂载没有完成(与"挂全了"严格区分);requested 仍记
|
|
470
|
+
// 完整 wanted;workerToolsPartial=true —— "未证明拿到完整 wanted 集",一件都没有
|
|
471
|
+
// 是它最极端的形态。若这里标 false,false 会被一眼读成"全量挂载",元数据又不准了。
|
|
472
|
+
state.ensureSession('fork-ptc-notools', { cwd: path.join(home, 'proj-ptc-notools'), agentPreset: 'yuike-ptc' })
|
|
473
|
+
const ctx = host({ live: true, headerTools: ['run_code'] })
|
|
474
|
+
const result = await requestPrivateConsolidation(ctx, 'fork-ptc-notools')
|
|
475
|
+
assert.equal(result.ok, true, String(result.error || ''))
|
|
476
|
+
const started = startedEntryFor(ctx, 'fork-ptc-notools')
|
|
477
|
+
assert.equal(started.detail.workerToolsAttached, null, 'no mount completed, so the log must not claim one')
|
|
478
|
+
assert.deepEqual([...started.detail.workerToolsRequested].sort(), ['memu_private_list', 'memu_private_recall', 'memu_private_remember', 'memu_private_update'])
|
|
479
|
+
assert.equal(started.detail.workerToolsPartial, true)
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
test('a non-PTC header without write tools still refuses to fork', async () => {
|
|
483
|
+
state.ensureSession('fork-notools', { cwd: path.join(home, 'proj-notools'), agentPreset: 'coding' })
|
|
484
|
+
const ctx = host({ live: true, headerTools: ['read', 'shell', 'edit'] })
|
|
485
|
+
const result = await requestPrivateConsolidation(ctx, 'fork-notools')
|
|
486
|
+
assert.equal(result.ok, false)
|
|
487
|
+
assert.equal(result.reasonCode, 'worker-tools-missing')
|
|
488
|
+
assert.equal(ctx.rec.created.length, 0, 'no fork when the model surface provably lacks the write tools')
|
|
489
|
+
})
|
|
@@ -262,15 +262,54 @@ test('a header without memory tools is unavailable rather than a silent wasted f
|
|
|
262
262
|
assert.equal(probe.reasonCode, 'worker-tools-missing')
|
|
263
263
|
})
|
|
264
264
|
|
|
265
|
-
test('a PTC-only turn is
|
|
265
|
+
test('a PTC-only turn is not a refusal: the probe reports ptc and the SDK surface is checked at fork time', () => {
|
|
266
|
+
// 2026-10-31 口径变更:PTC 模式下 header 只列 transport(这里 ptc_exec,真实宿主为
|
|
267
|
+
// run_code),其余工具走 run_code 程序里的 SDK 绑定 —— header 名单不承载"能不能写",
|
|
268
|
+
// 不再产生 worker-tools-ptc-only 拒绝;fork 后由 attachWorkerTools 挂载读写工具。
|
|
266
269
|
const events = [
|
|
267
270
|
{ seq: 0, type: 'request/header', data: { header: { config: { provider: 'p', model: 'm' }, tools: [{ name: 'ptc_exec' }] } } },
|
|
268
|
-
{ seq: 1, type: 'tool/ptc-dispatch', data: {} },
|
|
271
|
+
{ seq: 1, type: 'tool/ptc-dispatch', data: { name: 'ptc_exec' } },
|
|
269
272
|
]
|
|
270
273
|
const probe = probeToolSurface({ ctx: null, sessionId: 's', events })
|
|
271
|
-
assert.equal(probe.ok,
|
|
272
|
-
assert.equal(probe.
|
|
274
|
+
assert.equal(probe.ok, true, 'a PTC surface can never disprove write ability')
|
|
275
|
+
assert.equal(probe.source, 'ptc-header')
|
|
273
276
|
assert.equal(probe.ptcObserved, true)
|
|
277
|
+
assert.equal(probe.hasWrite, null)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('a run_code header alone (no dispatch events yet) is still recognized as PTC', () => {
|
|
281
|
+
// 新 PTC 会话一次 run_code 都没跑过时也要认得出来:run_code 出现在名单里就是证据。
|
|
282
|
+
const events = [{ seq: 0, type: 'request/header', data: { header: { config: { provider: 'p', model: 'm' }, tools: [{ name: 'run_code' }] } } }]
|
|
283
|
+
const probe = probeToolSurface({ ctx: null, sessionId: 's', events })
|
|
284
|
+
assert.equal(probe.ok, true)
|
|
285
|
+
assert.equal(probe.source, 'ptc-header')
|
|
286
|
+
assert.equal(probe.ptcObserved, true)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test('a mixed surface (run_code beside native memu_*) proves write ability and keeps the PTC shape', () => {
|
|
290
|
+
// 混合面:PTC transport 与原生 memu_* 同时出现在 header 名单(preset 部分列出本插件、
|
|
291
|
+
// 或 PTC 与原生工具共存的会话)。probe 既不能当纯 PTC(hasWrite:null 会丢掉"名单里
|
|
292
|
+
// 明摆着有写工具"的证据),也不能当普通名单(ptcObserved:false 会丢掉 PTC transport)。
|
|
293
|
+
// hasWrite:true + ptcObserved:true 是钉住的口径;任务书(buildWorkerBrief)在混合面下
|
|
294
|
+
// 既要列原生名单、又要带 run_code 的 SDK 调用形态——两种 transport 都覆盖。
|
|
295
|
+
const events = [{
|
|
296
|
+
seq: 0,
|
|
297
|
+
type: 'request/header',
|
|
298
|
+
data: { header: { config: { provider: 'p', model: 'm' }, tools: [{ name: 'run_code' }, ...MEMU_TOOLS] } },
|
|
299
|
+
}]
|
|
300
|
+
const probe = probeToolSurface({ ctx: null, sessionId: 's', events })
|
|
301
|
+
assert.equal(probe.ok, true)
|
|
302
|
+
assert.equal(probe.hasWrite, true, 'a native write tool in the list still proves write ability')
|
|
303
|
+
assert.equal(probe.ptcObserved, true, 'run_code in the list is PTC evidence even beside native tools')
|
|
304
|
+
assert.equal(probe.source, 'header-event')
|
|
305
|
+
const brief = buildWorkerBrief({
|
|
306
|
+
snapshot: { fromSeq: 0, throughSeq: 5, eventCount: 6, hash: 'mix001' },
|
|
307
|
+
probe,
|
|
308
|
+
contract: 'MIXED-CONTRACT',
|
|
309
|
+
})
|
|
310
|
+
assert.match(brief, /run_code, memu_private_remember, memu_private_update/, 'the brief lists the native names')
|
|
311
|
+
assert.match(brief, /await tools\.memu_private_remember/, 'the brief keeps the PTC SDK invocation shape on a mixed surface')
|
|
312
|
+
assert.match(brief, /MIXED-CONTRACT/)
|
|
274
313
|
})
|
|
275
314
|
|
|
276
315
|
test('a missing tool list is unknown, not a refusal', () => {
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { registerHooks } from 'node:module'
|
|
7
|
+
|
|
8
|
+
// commitCuratorOutput regressions :
|
|
9
|
+
// 1. A held clone created by a target-changed conflict must NOT inherit the
|
|
10
|
+
// vector stamp of the batch commit (the batch committed `id`, not heldId),
|
|
11
|
+
// and membership must not count either the clone or the unindexed `id` row.
|
|
12
|
+
// 2. A global-index write failure after the batch commit must be compensated:
|
|
13
|
+
// every committed name receives a forget, and partial/complete outcomes are
|
|
14
|
+
// recorded to the activity log (compensation design, not a transaction).
|
|
15
|
+
//
|
|
16
|
+
// No engine, no download: './memu.js' is replaced before production modules
|
|
17
|
+
// load (same pattern as tests/managed-memory.test.mjs), and './state.js' is
|
|
18
|
+
// wrapped with a one-shot updateGlobalIndex failure switch. Everything else is
|
|
19
|
+
// the real module stack against a temp DSH_MEMU_HOME.
|
|
20
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-curator-commit-'))
|
|
21
|
+
process.env.DSH_MEMU_HOME = home
|
|
22
|
+
process.env.DSH_MEMU_MEMORY_HOME = home
|
|
23
|
+
|
|
24
|
+
const backend = {
|
|
25
|
+
commits: [],
|
|
26
|
+
forgets: [],
|
|
27
|
+
onCommit: null,
|
|
28
|
+
}
|
|
29
|
+
globalThis.__curatorCommitTest = {
|
|
30
|
+
async commit(args) {
|
|
31
|
+
const names = (args.payload?.recall_files || []).map((file) => file.name)
|
|
32
|
+
backend.commits.push({ names, route: args.route, db: args.db })
|
|
33
|
+
if (backend.onCommit) await backend.onCommit()
|
|
34
|
+
return { ok: true }
|
|
35
|
+
},
|
|
36
|
+
async forget(args) {
|
|
37
|
+
backend.forgets.push({ name: args.name, db: args.db })
|
|
38
|
+
return { deleted: 1, segments: 1 }
|
|
39
|
+
},
|
|
40
|
+
async retrieve() { return { files: [], segments: [], resources: [], raw: null } },
|
|
41
|
+
async listFiles() { return { recall_files: [], resources: [], raw: null } },
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// One-shot failure switch on updateGlobalIndex; every other state.js export is
|
|
45
|
+
// forwarded to the real module (local exports shadow the star re-export).
|
|
46
|
+
const indexHook = { failNext: 0, thrown: [] }
|
|
47
|
+
globalThis.__curatorIndexHook = indexHook
|
|
48
|
+
|
|
49
|
+
const realStateUrl = new URL('../lib/state.js', import.meta.url).href
|
|
50
|
+
const realMemuUrl = new URL('../lib/memu.js', import.meta.url).href
|
|
51
|
+
const memuSource = 'const bridge = globalThis.__curatorCommitTest;'
|
|
52
|
+
+ 'export const commit = (args) => bridge.commit(args), forget = (args) => bridge.forget(args),'
|
|
53
|
+
+ 'retrieve = (args) => bridge.retrieve(args), listFiles = (args) => bridge.listFiles(args);'
|
|
54
|
+
const stateSource = [
|
|
55
|
+
`import * as real from ${JSON.stringify(realStateUrl)}`,
|
|
56
|
+
'const hook = globalThis.__curatorIndexHook',
|
|
57
|
+
'export function updateGlobalIndex(mutator) {',
|
|
58
|
+
' if (hook.failNext > 0) { hook.failNext -= 1; const error = new Error("disk full (simulated index failure)"); hook.thrown.push(String(error.message)); throw error }',
|
|
59
|
+
' return real.updateGlobalIndex(mutator)',
|
|
60
|
+
'}',
|
|
61
|
+
`export * from ${JSON.stringify(realStateUrl)}`,
|
|
62
|
+
].join('\n')
|
|
63
|
+
const hooks = registerHooks({
|
|
64
|
+
resolve(specifier, context, nextResolve) {
|
|
65
|
+
if (specifier === './memu.js') return { url: 'data:text/javascript,' + encodeURIComponent(memuSource), shortCircuit: true }
|
|
66
|
+
if (specifier === './state.js') return { url: 'data:text/javascript,' + encodeURIComponent(stateSource), shortCircuit: true }
|
|
67
|
+
return nextResolve(specifier, context)
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const catalog = await import('../lib/embedding-catalog.js')
|
|
72
|
+
const vp = await import('../lib/vector-profile.js')
|
|
73
|
+
const vm = await import('../lib/vector-migration.js')
|
|
74
|
+
const curator = await import('../lib/curator.js')
|
|
75
|
+
const state = await import('../lib/state.js')
|
|
76
|
+
const activity = await import('../lib/activity-log.js')
|
|
77
|
+
|
|
78
|
+
const BGE = 'bge-small-zh-v1.5-int8'
|
|
79
|
+
// A second resolvable catalog entry so switchProfile has a verified profile to
|
|
80
|
+
// activate (same fixture trick as tests/vector-migration.test.mjs).
|
|
81
|
+
const baseEntry = catalog.getEmbeddingModel(BGE)
|
|
82
|
+
const altEntry = JSON.parse(JSON.stringify(baseEntry))
|
|
83
|
+
altEntry.id = 'bge-small-zh-v1.5-int8-alt'
|
|
84
|
+
altEntry.displayName = 'BGE alternative revision (curator commit fixture)'
|
|
85
|
+
altEntry.sources.modelscope.revision = 'c'.repeat(40)
|
|
86
|
+
altEntry.profileFingerprint = catalog.profileFingerprint(altEntry)
|
|
87
|
+
const models = { [BGE]: baseEntry, [altEntry.id]: altEntry }
|
|
88
|
+
vp.setCatalogEntryResolver((id) => models[id] ?? null)
|
|
89
|
+
|
|
90
|
+
state.updateSettings({ memoryBackend: 'api', vectorSource: 'local' })
|
|
91
|
+
|
|
92
|
+
const TARGET = 'gmem-target'
|
|
93
|
+
const TARGET_ENTRY = {
|
|
94
|
+
track: 'memory', domain: 'general', title: '部署窗口约定',
|
|
95
|
+
content: '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布。',
|
|
96
|
+
confidence: 0.9, status: 'active',
|
|
97
|
+
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
|
98
|
+
evidence: [{ sessionId: 'old', quote: 'old quote' }],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function seedGlobalIndex(entries = {}) {
|
|
102
|
+
state.saveGlobalIndex({ version: 1, entries: structuredClone(entries) })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function activateVectorGeneration() {
|
|
106
|
+
// In-memory stand-in for the sqlite vector store so the migration readback
|
|
107
|
+
// (syncShadow verifies committed items via listFiles) is faithful.
|
|
108
|
+
const dbs = new Map()
|
|
109
|
+
const rowsOf = (db) => {
|
|
110
|
+
if (!dbs.has(db)) dbs.set(db, new Map())
|
|
111
|
+
return dbs.get(db)
|
|
112
|
+
}
|
|
113
|
+
const migrator = vm.createVectorMigrator({
|
|
114
|
+
async listLibraries() { return [] },
|
|
115
|
+
// membership() 的戳腿/账本腿读的是真实索引;这里直接转发到真 state.js。
|
|
116
|
+
loadIndex(libraryId) {
|
|
117
|
+
return libraryId ? state.loadPrivateIndex(libraryId) : state.loadGlobalIndex()
|
|
118
|
+
},
|
|
119
|
+
async commit({ db, item }) { rowsOf(db).set(item.id, item.text) },
|
|
120
|
+
async forget({ db, itemId }) { rowsOf(db).delete(itemId) },
|
|
121
|
+
async listFiles({ db }) { return [...rowsOf(db).keys()] },
|
|
122
|
+
async smoke() { return { ok: true, checks: ['dimension=512'] } },
|
|
123
|
+
})
|
|
124
|
+
const switched = await migrator.switchProfile({ profile: BGE })
|
|
125
|
+
assert.ok(['switched', 'already-active'].includes(switched.status), 'an active generation must be available')
|
|
126
|
+
return { migrator, generation: switched.generation }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
test.after(async () => {
|
|
130
|
+
try { hooks.unregister?.() } catch {}
|
|
131
|
+
vp.resetCatalogEntryResolver()
|
|
132
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const mergeOutput = {
|
|
136
|
+
memories: [{
|
|
137
|
+
op: 'merge', domain: 'general', track: 'memory', target_id: TARGET,
|
|
138
|
+
title: '部署窗口的合并版本',
|
|
139
|
+
content: '每周四凌晨两点到四点是固定的部署窗口,窗口内只允许灰度发布,全量要等监控确认。',
|
|
140
|
+
confidence: 0.9,
|
|
141
|
+
evidence: [{ session_id: 's1', quote: '部署窗口只在周四凌晨' }],
|
|
142
|
+
}],
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
test('a held clone from a target-changed conflict carries no vector stamp and membership counts neither it nor the committed id row', async () => {
|
|
146
|
+
seedGlobalIndex({ [TARGET]: TARGET_ENTRY })
|
|
147
|
+
const { migrator, generation } = await activateVectorGeneration()
|
|
148
|
+
|
|
149
|
+
// The concurrent writer lands while the curator is between its index read and
|
|
150
|
+
// its index write: the backend commit is the only async seam in between.
|
|
151
|
+
backend.onCommit = async () => {
|
|
152
|
+
state.updateGlobalIndex((fresh) => {
|
|
153
|
+
fresh.entries[TARGET] = { ...fresh.entries[TARGET], content: '内容已被并发修改', updatedAt: '2026-02-02T00:00:00.000Z' }
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
const result = await curator.commitCuratorOutput(structuredClone(mergeOutput), { job: { step: 'committing' } })
|
|
157
|
+
backend.onCommit = null
|
|
158
|
+
|
|
159
|
+
assert.equal(result.merged, 1, 'the merge update is still accounted as attempted')
|
|
160
|
+
assert.equal(result.files.length, 1)
|
|
161
|
+
const committedId = result.files[0].name
|
|
162
|
+
assert.deepEqual(backend.commits.map((call) => call.names), [[committedId]], 'the batch committed exactly the new id')
|
|
163
|
+
|
|
164
|
+
const index = state.loadGlobalIndex()
|
|
165
|
+
// The held clone got its own id; the committed id row is NOT in the index.
|
|
166
|
+
const heldId = Object.keys(index.entries).find((id) => index.entries[id]?.reviewReason === 'target changed while curator was running')
|
|
167
|
+
assert.ok(heldId, 'the target-changed merge must be held for review')
|
|
168
|
+
assert.notEqual(heldId, committedId)
|
|
169
|
+
assert.equal(index.entries[committedId], undefined, 'the committed id must not land in the index when held')
|
|
170
|
+
const held = index.entries[heldId]
|
|
171
|
+
assert.equal(held.status, 'pending_review')
|
|
172
|
+
assert.equal(held.conflictingWith, TARGET)
|
|
173
|
+
// The regression: the clone spread `...entry` after the stamp loop stamped it.
|
|
174
|
+
assert.equal(Object.hasOwn(held, 'vector'), false, 'a held clone never entered the vector store, so it must not carry the stamp')
|
|
175
|
+
assert.equal(index.entries[TARGET].status, 'active', 'the untouched target is not superseded by a held clone')
|
|
176
|
+
|
|
177
|
+
// membership: the ledger leg keeps TARGET; the stamp leg admits the new
|
|
178
|
+
// create/update stamps only. Neither heldId (no stamp, not in ledger) nor the
|
|
179
|
+
// committed `id` (row exists in the backend, but not in the index) may appear.
|
|
180
|
+
const membership = migrator.membership()
|
|
181
|
+
assert.ok(membership, 'an active generation must produce a membership map')
|
|
182
|
+
const globalIds = membership.global || []
|
|
183
|
+
assert.ok(globalIds.includes(TARGET), 'the migrated target stays counted')
|
|
184
|
+
assert.ok(!globalIds.includes(heldId), 'the held clone is not counted as vector membership')
|
|
185
|
+
assert.ok(!globalIds.includes(committedId), 'the backend row `id` must not be attributed to the index it never landed in')
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('a clean create still carries the current-generation stamp and is counted by membership', async () => {
|
|
189
|
+
seedGlobalIndex({})
|
|
190
|
+
const { migrator, generation } = await activateVectorGeneration()
|
|
191
|
+
const output = {
|
|
192
|
+
memories: [{
|
|
193
|
+
op: 'create', domain: 'general', track: 'memory',
|
|
194
|
+
title: '独立的全新事实',
|
|
195
|
+
content: '用户倾向于在正式汇报里先给结论再给细节。',
|
|
196
|
+
confidence: 0.9,
|
|
197
|
+
evidence: [{ session_id: 's2', quote: '先说结论' }],
|
|
198
|
+
}],
|
|
199
|
+
}
|
|
200
|
+
const result = await curator.commitCuratorOutput(output, { job: { step: 'committing' } })
|
|
201
|
+
assert.equal(result.created, 1)
|
|
202
|
+
const index = state.loadGlobalIndex()
|
|
203
|
+
const entry = Object.values(index.entries)[0]
|
|
204
|
+
assert.equal(entry.status, 'active')
|
|
205
|
+
assert.equal(entry.vector?.route, 'managed-local')
|
|
206
|
+
assert.equal(Number(entry.vector?.generation), generation, 'the stamp names the active generation')
|
|
207
|
+
const membership = migrator.membership()
|
|
208
|
+
assert.deepEqual(membership.global, [result.files[0].name])
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('a global-index write failure after the batch commit is compensated name by name and reported', async () => {
|
|
212
|
+
seedGlobalIndex({})
|
|
213
|
+
await activateVectorGeneration()
|
|
214
|
+
const output = {
|
|
215
|
+
memories: [
|
|
216
|
+
{ op: 'create', domain: 'general', track: 'memory', title: '第一条', content: '第一条全新事实的内容,足够长以通过校验。', confidence: 0.9, evidence: [{ session_id: 's3', quote: '一' }] },
|
|
217
|
+
{ op: 'create', domain: 'general', track: 'memory', title: '第二条', content: '第二条全新事实的内容,与第一条没有关系。', confidence: 0.9, evidence: [{ session_id: 's4', quote: '二' }] },
|
|
218
|
+
],
|
|
219
|
+
}
|
|
220
|
+
indexHook.failNext = 1
|
|
221
|
+
await assert.rejects(
|
|
222
|
+
curator.commitCuratorOutput(structuredClone(output), { job: { step: 'committing' } }),
|
|
223
|
+
/disk full/,
|
|
224
|
+
'the original index error must surface, not the compensation result',
|
|
225
|
+
)
|
|
226
|
+
const committedNames = backend.commits.at(-1).names
|
|
227
|
+
assert.equal(committedNames.length, 2)
|
|
228
|
+
// Compensation: every committed name received a forget against the global db.
|
|
229
|
+
assert.deepEqual(
|
|
230
|
+
backend.forgets.map((call) => call.name).sort(),
|
|
231
|
+
[...committedNames].sort(),
|
|
232
|
+
'each just-committed name must be forgotten',
|
|
233
|
+
)
|
|
234
|
+
assert.ok(backend.forgets.every((call) => call.db === state.globalDbPath()))
|
|
235
|
+
// The index write failed, so nothing landed: the library must not gain entries
|
|
236
|
+
// and the curation failure stays the job's error.
|
|
237
|
+
assert.deepEqual(Object.keys(state.loadGlobalIndex().entries), [])
|
|
238
|
+
// The compensation outcome is visible in the activity log, not swallowed.
|
|
239
|
+
const event = activity.listEvents({ kind: 'curate' }).find((item) => item.level === 'error' || item.level === 'warn')
|
|
240
|
+
assert.ok(event, 'the compensation must be recorded')
|
|
241
|
+
assert.equal(event.ok, true, 'both names were compensated, so the record says so')
|
|
242
|
+
assert.deepEqual(event.detail.compensated.sort(), [...committedNames].sort())
|
|
243
|
+
assert.deepEqual(event.detail.failed, [])
|
|
244
|
+
assert.match(event.message, /回滚|补偿/)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('a compensation failure is reported per name and never hides the original error', async () => {
|
|
248
|
+
seedGlobalIndex({})
|
|
249
|
+
await activateVectorGeneration()
|
|
250
|
+
// Make forget fail for the second name only: partial compensation must be
|
|
251
|
+
// recorded name by name.
|
|
252
|
+
const originalForget = globalThis.__curatorCommitTest.forget
|
|
253
|
+
const forgetBaseline = backend.forgets.length
|
|
254
|
+
globalThis.__curatorCommitTest.forget = async (args) => {
|
|
255
|
+
if (backend.forgets.length > forgetBaseline) throw new Error('engine db is locked')
|
|
256
|
+
return originalForget(args)
|
|
257
|
+
}
|
|
258
|
+
const output = {
|
|
259
|
+
memories: [
|
|
260
|
+
{ op: 'create', domain: 'general', track: 'memory', title: '甲', content: '甲事实的内容,与乙无关。', confidence: 0.9, evidence: [{ session_id: 's5', quote: '甲' }] },
|
|
261
|
+
{ op: 'create', domain: 'general', track: 'memory', title: '乙', content: '乙事实的内容,与甲无关。', confidence: 0.9, evidence: [{ session_id: 's6', quote: '乙' }] },
|
|
262
|
+
],
|
|
263
|
+
}
|
|
264
|
+
indexHook.failNext = 1
|
|
265
|
+
await assert.rejects(
|
|
266
|
+
curator.commitCuratorOutput(structuredClone(output), { job: { step: 'committing' } }),
|
|
267
|
+
/disk full/,
|
|
268
|
+
)
|
|
269
|
+
globalThis.__curatorCommitTest.forget = originalForget
|
|
270
|
+
const event = activity.listEvents({ kind: 'curate' }).filter((item) => item.level === 'error').at(-1)
|
|
271
|
+
assert.ok(event, 'a partial compensation must be recorded at error level')
|
|
272
|
+
assert.equal(event.ok, false)
|
|
273
|
+
assert.equal(event.detail.compensated.length, 1, 'the name whose forget succeeded is recorded as compensated')
|
|
274
|
+
assert.equal(event.detail.failed.length, 1, 'the name whose forget failed is recorded with its reason')
|
|
275
|
+
assert.match(event.detail.failed[0].error, /engine db is locked/)
|
|
276
|
+
assert.match(event.message, /残留孤儿行/)
|
|
277
|
+
})
|