dsh-memu-memory 1.1.3 → 1.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.
- package/AGENTS.md +1 -1
- package/README.md +31 -2
- package/README.zh.md +30 -2
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +1 -1
- package/docs/emotion-model-spec.md +281 -0
- package/docs/host-api.md +2 -2
- package/docs/memory-semantics.md +12 -10
- package/docs/recall-benchmark.md +238 -0
- package/docs/troubleshooting.md +395 -395
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +1 -1
- package/lib/binding-diagnostics.js +8 -3
- package/lib/client.js +33 -11
- package/lib/consolidation-ledger.js +347 -0
- package/lib/consolidation-receipt.js +284 -0
- package/lib/consolidation-snapshot.js +274 -0
- package/lib/consolidation-worker.js +115 -0
- package/lib/cycle.js +40 -6
- package/lib/inject.js +104 -76
- package/lib/local-embedding-manager.js +24 -6
- package/lib/memory.js +221 -19
- package/lib/mood.js +268 -39
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +335 -40
- package/lib/prompts/private-consolidation.coding.md +1 -0
- package/lib/prompts/private-consolidation.md +1 -0
- package/lib/prompts/private-consolidation.mixed.md +1 -0
- package/lib/prompts/private-consolidation.rp.md +1 -0
- package/lib/prompts.js +4 -4
- package/lib/remote-summary.js +51 -25
- package/lib/rpc.js +9 -1
- package/lib/state.js +29 -12
- package/lib/tools.js +49 -2
- package/lib/vector-profile.js +27 -0
- package/package.json +2 -1
- package/scripts/acceptance-local.mjs +2 -1
- package/scripts/active-memory-ab-live.mjs +655 -0
- package/scripts/active-memory-ab.mjs +1302 -0
- package/scripts/chain-matrix.mjs +11 -1
- package/scripts/check-all.mjs +10 -1
- package/tests/activation.test.mjs +89 -0
- package/tests/active-memory-ab.test.mjs +745 -0
- package/tests/active-memory.test.mjs +129 -0
- package/tests/activity-log.test.mjs +126 -0
- package/tests/activity-wiring.test.mjs +103 -0
- package/tests/appendix-b.test.mjs +212 -0
- package/tests/appendix-b2.test.mjs +166 -0
- package/tests/audit-regression.test.mjs +272 -0
- package/tests/audit-safety.test.mjs +227 -0
- package/tests/backend-compensation.test.mjs +272 -0
- package/tests/backup.test.mjs +793 -0
- package/tests/binding-diagnostics.test.mjs +720 -0
- package/tests/browser.test.mjs +366 -0
- package/tests/client-render.test.mjs +892 -0
- package/tests/coalesce.test.mjs +113 -0
- package/tests/config.test.mjs +159 -0
- package/tests/consolidation-credential.test.mjs +320 -0
- package/tests/consolidation-fork.test.mjs +357 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +321 -0
- package/tests/consolidation-value.test.mjs +89 -0
- package/tests/context-scope.test.mjs +40 -0
- package/tests/cue.test.mjs +44 -0
- package/tests/curator-probe.test.mjs +433 -0
- package/tests/curator.test.mjs +150 -0
- package/tests/cycle.test.mjs +411 -0
- package/tests/disclosure.test.mjs +231 -0
- package/tests/doc-drift.test.mjs +222 -0
- package/tests/embedding-catalog.test.mjs +266 -0
- package/tests/embedding-download.test.mjs +391 -0
- package/tests/embedding-runtime.test.mjs +389 -0
- package/tests/emotion-chain.test.mjs +84 -0
- package/tests/emotion-contract.test.mjs +445 -0
- package/tests/engine.test.mjs +35 -0
- package/tests/fixtures/active-memory-ab/README.md +56 -0
- package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
- package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
- package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
- package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
- package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
- package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
- package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
- package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
- package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
- package/tests/fixtures/active-memory-ab/manifest.json +199 -0
- package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
- package/tests/fixtures/recall-bench/02-exact.json +153 -0
- package/tests/fixtures/recall-bench/03-negation.json +173 -0
- package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
- package/tests/fixtures/recall-bench/05-conflict.json +194 -0
- package/tests/fixtures/recall-bench/06-boundary.json +273 -0
- package/tests/fixtures/recall-bench/07-secret.json +191 -0
- package/tests/fixtures/recall-bench/08-coding.json +164 -0
- package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
- package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
- package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
- package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
- package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
- package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
- package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
- package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
- package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
- package/tests/fixtures/repetition-eval/02-en.json +97 -0
- package/tests/fixtures/repetition-eval/03-code.json +91 -0
- package/tests/fixtures/repetition-eval/04-names.json +116 -0
- package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
- package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
- package/tests/graph-view.test.mjs +187 -0
- package/tests/graph.test.mjs +77 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +92 -0
- package/tests/inbound.test.mjs +393 -0
- package/tests/inject-guide.test.mjs +364 -0
- package/tests/inject.test.mjs +222 -0
- package/tests/injection-budget-settings.test.mjs +35 -0
- package/tests/injection-budget.test.mjs +64 -0
- package/tests/injection-chain.test.mjs +191 -0
- package/tests/loader-register.mjs +3 -0
- package/tests/loader.mjs +12 -0
- package/tests/local-embedding-entry.test.mjs +95 -0
- package/tests/local-embedding-manager.test.mjs +476 -0
- package/tests/local-embedding-protocol.test.mjs +95 -0
- package/tests/local-embedding-worker.test.mjs +346 -0
- package/tests/managed-memory.test.mjs +536 -0
- package/tests/managed-runtime.test.mjs +40 -0
- package/tests/media.test.mjs +132 -0
- package/tests/migration-payload.test.mjs +42 -0
- package/tests/mood.test.mjs +109 -0
- package/tests/multiprocess.test.mjs +29 -0
- package/tests/new-session-gate.test.mjs +361 -0
- package/tests/package-shipping.test.mjs +84 -0
- package/tests/preset-scope.test.mjs +212 -0
- package/tests/prompts.test.mjs +250 -0
- package/tests/react-snapshot.test.mjs +23 -0
- package/tests/recall-bench.test.mjs +306 -0
- package/tests/remote.test.mjs +140 -0
- package/tests/rename.test.mjs +30 -0
- package/tests/repetition-detector-snapshot.test.mjs +231 -0
- package/tests/repetition-eval-live.test.mjs +177 -0
- package/tests/repetition-eval.test.mjs +373 -0
- package/tests/salient-date.test.mjs +142 -0
- package/tests/settings-beat.test.mjs +221 -0
- package/tests/short-term.test.mjs +166 -0
- package/tests/sidebar-beat.test.mjs +208 -0
- package/tests/sidebar-refresh.test.mjs +123 -0
- package/tests/sidebar.test.mjs +70 -0
- package/tests/snippet.test.mjs +47 -0
- package/tests/source-kind.test.mjs +52 -0
- package/tests/state.test.mjs +150 -0
- package/tests/stats.test.mjs +112 -0
- package/tests/storage.test.mjs +125 -0
- package/tests/stubs/dsh-llm.js +5 -0
- package/tests/stubs/dsh-session.js +7 -0
- package/tests/stubs/dsh-tools.js +101 -0
- package/tests/tool-schema.test.mjs +73 -0
- package/tests/tools.test.mjs +184 -0
- package/tests/ui-defaults.test.mjs +57 -0
- package/tests/update-fields.test.mjs +138 -0
- package/tests/vector-migration.test.mjs +733 -0
- package/tests/vector-profile.test.mjs +425 -0
- package/tests/visibility-migration.test.mjs +152 -0
- package/tests/webui-auth.test.mjs +454 -0
- package/tests/webui.test.mjs +164 -0
- package/tests/windows-env.test.mjs +89 -0
|
@@ -0,0 +1,357 @@
|
|
|
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-memory-fork-'))
|
|
8
|
+
process.env.DSH_MEMU_HOME = home
|
|
9
|
+
|
|
10
|
+
const state = await import('../lib/state.js')
|
|
11
|
+
const { requestPrivateConsolidation, isConsolidating } = await import('../lib/private.js')
|
|
12
|
+
|
|
13
|
+
test.after(() => {
|
|
14
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// The manual path (the sidebar button, RPC generatePrivate) shares one entry point
|
|
18
|
+
// with the idle sweep. Both now fork; neither may queue anything on the live agent,
|
|
19
|
+
// because a queued followup is what the host renders as a pending draft above the
|
|
20
|
+
// user's composer and what ends up as a permanent housekeeping turn in their chat.
|
|
21
|
+
// The host contract this plugin must satisfy, mirrored from the runtime:
|
|
22
|
+
// packages/core/session SessionStore.prepare() rebuilds a session header from a
|
|
23
|
+
// fixed meta whitelist and `Session` then throws "seeded session requires an
|
|
24
|
+
// inherited event count" when the count was not passed as a TOP-LEVEL option.
|
|
25
|
+
// The host drops an unknown meta key silently; this fake refuses it loudly, so a
|
|
26
|
+
// plugin that misplaces the field fails the test instead of dying in production.
|
|
27
|
+
const META_KEYS = ['cwd', 'parentSession', 'isSeeded', 'origin', 'delegationDepth', 'agentPreset']
|
|
28
|
+
|
|
29
|
+
function assertHostCreateAccepts(spec = {}) {
|
|
30
|
+
const meta = spec.meta ?? {}
|
|
31
|
+
const count = spec.inheritedEventCount
|
|
32
|
+
for (const key of Object.keys(meta)) {
|
|
33
|
+
if (!META_KEYS.includes(key)) {
|
|
34
|
+
throw new Error(`session meta "${key}" is not a header field: SessionStore.prepare() drops it`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (meta.isSeeded === true) {
|
|
38
|
+
if (spec.seed === undefined) throw new Error('seeded session requires an explicit constructor seed')
|
|
39
|
+
if (count === undefined) throw new Error('seeded session requires an inherited event count')
|
|
40
|
+
} else if (count !== undefined && count !== 0) {
|
|
41
|
+
throw new Error('unseeded session inherited event count must be 0')
|
|
42
|
+
}
|
|
43
|
+
// buildForkSeed() returns a seed object, not a bare array, so the length guard
|
|
44
|
+
// only applies to an array seed.
|
|
45
|
+
if (Array.isArray(spec.seed) && count !== undefined && count > spec.seed.length) {
|
|
46
|
+
throw new Error('session inherited event count exceeds its event log')
|
|
47
|
+
}
|
|
48
|
+
if (spec.agentOptions !== undefined) {
|
|
49
|
+
if (!spec.agentOptions || typeof spec.agentOptions.provider !== 'string' || typeof spec.agentOptions.model !== 'string') {
|
|
50
|
+
throw new Error('agentOptions must carry the parent provider and model')
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
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: [] }
|
|
57
|
+
const tick = String.fromCharCode(96)
|
|
58
|
+
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
|
+
// 真实长会话的规模:补齐不产生证据的事件,只影响 fork 成本判定。
|
|
60
|
+
const padding = Math.max(0, transcriptSize - 3 - extraEvents.length)
|
|
61
|
+
const filler = Array.from({ length: padding }, (_, i) => ({ seq: 3 + extraEvents.length + i, type: 'todo/write', data: {} }))
|
|
62
|
+
const ctx = {
|
|
63
|
+
rec,
|
|
64
|
+
on: (name, handler) => {
|
|
65
|
+
if (name !== 'session/event') return () => {}
|
|
66
|
+
rec.sessionHandlers.push(handler)
|
|
67
|
+
return () => {
|
|
68
|
+
const index = rec.sessionHandlers.indexOf(handler)
|
|
69
|
+
if (index >= 0) rec.sessionHandlers.splice(index, 1)
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
get: (name) => {
|
|
73
|
+
if (name === 'agents') {
|
|
74
|
+
return {
|
|
75
|
+
get: (id) => (live
|
|
76
|
+
? {
|
|
77
|
+
followup: (message) => rec.liveFollowups.push({ id, message }),
|
|
78
|
+
...(parentOptions ? { options: parentOptions } : {}),
|
|
79
|
+
...(requestConfig ? { session: { requestHeader: () => ({ config: requestConfig }) } } : {}),
|
|
80
|
+
}
|
|
81
|
+
: undefined),
|
|
82
|
+
create: async (spec) => {
|
|
83
|
+
assertHostCreateAccepts(spec)
|
|
84
|
+
rec.created.push(spec)
|
|
85
|
+
const childSession = { id: spec.sessionId }
|
|
86
|
+
return {
|
|
87
|
+
agent: {
|
|
88
|
+
session: childSession,
|
|
89
|
+
followup: (message) => {
|
|
90
|
+
rec.forkFollowups.push({ child: spec.sessionId, message })
|
|
91
|
+
if (emitTurnEnd) {
|
|
92
|
+
if (turnReason?.kind === 'completed') {
|
|
93
|
+
const throughSeq = 2 + extraEvents.length + padding
|
|
94
|
+
for (const handler of rec.sessionHandlers) {
|
|
95
|
+
handler(childSession, { type: 'assistant/message', seq: 99, data: { message: { content: [{ type: 'text', text: receiptFence({ fromSeq: 0, throughSeq }, state.getSessionBinding(spec.sessionId)) }] } } })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const handler of rec.sessionHandlers) {
|
|
99
|
+
handler(childSession, { type: 'turn/end', seq: 100, data: { turn: 1, reason: turnReason } })
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
whenIdle: whenIdle || (async () => {}),
|
|
104
|
+
},
|
|
105
|
+
dispose: async () => { rec.disposed.push(spec.sessionId) },
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (name === 'sessionQuery') {
|
|
111
|
+
return {
|
|
112
|
+
readSession: async () => ({
|
|
113
|
+
events: [
|
|
114
|
+
{ seq: 0, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: '这个 fork 为什么一直建不起来,帮我看下' }] } },
|
|
115
|
+
{ seq: 1, type: 'assistant/message', data: { message: { content: [{ type: 'tool-call', name: 'read', arguments: '{}' }] } } },
|
|
116
|
+
{ seq: 2, type: 'assistant/message', data: { message: { content: [{ type: 'text', text: '看到了' }] } } },
|
|
117
|
+
...extraEvents,
|
|
118
|
+
...filler,
|
|
119
|
+
],
|
|
120
|
+
session: { cwd: path.join(home, 'proj'), agentPreset: 'coding' },
|
|
121
|
+
}),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return undefined
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
return ctx
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
test('enabling local or remote vectors does not opt into remote summary', async () => {
|
|
131
|
+
const originalFetch = globalThis.fetch
|
|
132
|
+
let fetches = 0
|
|
133
|
+
globalThis.fetch = async () => { fetches += 1; throw new Error('summary API must not be called') }
|
|
134
|
+
try {
|
|
135
|
+
for (const vectorSource of ['local', 'api']) {
|
|
136
|
+
state.updateSettings({ memoryBackend: 'api', vectorSource })
|
|
137
|
+
const id = 'fork-vector-' + vectorSource
|
|
138
|
+
state.ensureSession(id, { cwd: path.join(home, id), agentPreset: 'coding' })
|
|
139
|
+
const ctx = host({ live: true })
|
|
140
|
+
const result = await requestPrivateConsolidation(ctx, id)
|
|
141
|
+
assert.equal(result.ok, true)
|
|
142
|
+
assert.equal(ctx.rec.created.length, 1, 'vector enable still delegates extraction to the conversation Agent')
|
|
143
|
+
assert.equal(ctx.rec.forkFollowups.length, 1)
|
|
144
|
+
}
|
|
145
|
+
assert.equal(fetches, 0)
|
|
146
|
+
} finally {
|
|
147
|
+
globalThis.fetch = originalFetch
|
|
148
|
+
state.updateSettings({ memoryBackend: 'agent', vectorSource: 'local' })
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('a live session is consolidated in a fork, not by queueing on itself', async () => {
|
|
153
|
+
const binding = state.ensureSession('fork-live', { cwd: path.join(home, 'proj'), agentPreset: 'coding' })
|
|
154
|
+
const parentOptions = { provider: 'test-provider', model: 'test-model', reasoningEffort: 'max', maxTokens: 262144 }
|
|
155
|
+
const ctx = host({ live: true, parentOptions })
|
|
156
|
+
const result = await requestPrivateConsolidation(ctx, 'fork-live')
|
|
157
|
+
assert.equal(result.ok, true)
|
|
158
|
+
assert.equal(result.settled, true)
|
|
159
|
+
assert.equal(ctx.rec.liveFollowups.length, 0, 'no queued draft on the live agent')
|
|
160
|
+
assert.equal(ctx.rec.created.length, 1, 'exactly one scratch fork')
|
|
161
|
+
assert.equal(ctx.rec.forkFollowups.length, 1, 'the fork does the work')
|
|
162
|
+
assert.equal(ctx.rec.disposed.length, 1, 'the fork is disposed')
|
|
163
|
+
const spec = ctx.rec.created[0]
|
|
164
|
+
assert.equal(spec.meta.parentSession, 'fork-live')
|
|
165
|
+
assert.equal(spec.meta.origin, 'subagent')
|
|
166
|
+
assert.equal(spec.meta.isSeeded, true)
|
|
167
|
+
assert.equal(spec.inheritedEventCount, 3, 'inherits the whole transcript as a top-level option')
|
|
168
|
+
assert.equal(Object.hasOwn(spec.meta, 'inheritedEventCount'), false, 'must not hide in the session meta: prepare() drops it')
|
|
169
|
+
assert.deepEqual(spec.agentOptions, parentOptions, 'the fork starts on the parent model route')
|
|
170
|
+
const child = state.getSessionBinding(spec.sessionId)
|
|
171
|
+
assert.equal(child, null, 'disposed worker binding is removed')
|
|
172
|
+
assert.equal(state.getSessionBinding('fork-live').lastSleepSeq, 2, 'only a completed turn advances the sleep cursor')
|
|
173
|
+
assert.equal(result.scanned, 3, 'report how much of the transcript was scanned')
|
|
174
|
+
assert.equal(result.fresh, 3, 'report how many events were new since the last consolidation')
|
|
175
|
+
assert.equal(result.wrote, 0, 'the fake fork writes nothing, but the metric must still be measured')
|
|
176
|
+
})
|
|
177
|
+
test('a live session inherits the route of its latest turn, not stale creation options', async () => {
|
|
178
|
+
state.ensureSession('fork-route', { cwd: path.join(home, 'proj-route'), agentPreset: 'coding' })
|
|
179
|
+
const ctx = host({
|
|
180
|
+
live: true,
|
|
181
|
+
parentOptions: { provider: 'creation', model: 'old-model' },
|
|
182
|
+
requestConfig: { provider: 'request', model: 'new-model', reasoningEffort: 'high', maxTokens: 32768 },
|
|
183
|
+
})
|
|
184
|
+
const result = await requestPrivateConsolidation(ctx, 'fork-route')
|
|
185
|
+
assert.equal(result.ok, true)
|
|
186
|
+
assert.deepEqual(ctx.rec.created[0].agentOptions, {
|
|
187
|
+
provider: 'request',
|
|
188
|
+
model: 'new-model',
|
|
189
|
+
reasoningEffort: 'high',
|
|
190
|
+
maxTokens: 32768,
|
|
191
|
+
}, 'requestHeader() is the route that served the parent, options can be stale')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('a closed session inherits the last request/header event', async () => {
|
|
195
|
+
state.ensureSession('fork-closed', { cwd: path.join(home, 'proj-closed'), agentPreset: 'coding' })
|
|
196
|
+
const ctx = host({
|
|
197
|
+
live: false,
|
|
198
|
+
extraEvents: [
|
|
199
|
+
{ seq: 3, type: 'request/header', data: { header: { config: { provider: 'old', model: 'old-model' } } } },
|
|
200
|
+
{ seq: 4, type: 'request/header', data: { header: { config: { provider: 'latest', model: 'latest-model', reasoningEffort: 'low', maxTokens: 4096 } } } },
|
|
201
|
+
],
|
|
202
|
+
})
|
|
203
|
+
const result = await requestPrivateConsolidation(ctx, 'fork-closed')
|
|
204
|
+
assert.equal(result.ok, true)
|
|
205
|
+
const spec = ctx.rec.created[0]
|
|
206
|
+
assert.equal(spec.inheritedEventCount, 5, 'the header events are part of the inherited log')
|
|
207
|
+
assert.deepEqual(spec.agentOptions, { provider: 'latest', model: 'latest-model', reasoningEffort: 'low', maxTokens: 4096 })
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('a session with no model source is refused before a doomed fork', async () => {
|
|
211
|
+
state.ensureSession('fork-no-model', { cwd: path.join(home, 'proj-no-model'), agentPreset: 'coding' })
|
|
212
|
+
const ctx = host({ live: true, parentOptions: null })
|
|
213
|
+
const result = await requestPrivateConsolidation(ctx, 'fork-no-model')
|
|
214
|
+
assert.equal(result.ok, false)
|
|
215
|
+
assert.match(result.error, /model\/provider/)
|
|
216
|
+
assert.equal(ctx.rec.created.length, 0, 'never create a fork that cannot resolve {{model}}')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
test('a failed fork turn is reported as failure and does not advance the sleep cursor', async () => {
|
|
220
|
+
state.ensureSession('fork-turn-error', { cwd: path.join(home, 'proj-turn-error'), agentPreset: 'coding' })
|
|
221
|
+
const ctx = host({
|
|
222
|
+
live: true,
|
|
223
|
+
turnReason: { kind: 'error', error: { message: 'prompt variable "{{model}}" has no value for this assembly', code: 'UNKNOWN' } },
|
|
224
|
+
})
|
|
225
|
+
const result = await requestPrivateConsolidation(ctx, 'fork-turn-error')
|
|
226
|
+
assert.equal(result.ok, false)
|
|
227
|
+
assert.equal(result.settled, false)
|
|
228
|
+
assert.match(result.error, /fork turn ended error/)
|
|
229
|
+
assert.match(result.error, /prompt variable/)
|
|
230
|
+
assert.equal(state.getSessionBinding('fork-turn-error').lastSleepSeq, undefined, 'a failed turn must be retried, not marked asleep')
|
|
231
|
+
assert.equal(ctx.rec.disposed.length, 1, 'the failed fork is still disposed')
|
|
232
|
+
assert.equal(ctx.rec.sessionHandlers.length, 0, 'the failed fork listener is removed')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('a fork that never reports turn/end is a failure, not a success', async () => {
|
|
236
|
+
state.ensureSession('fork-no-turn-end', { cwd: path.join(home, 'proj-no-turn-end'), agentPreset: 'coding' })
|
|
237
|
+
const ctx = host({ live: true, emitTurnEnd: false })
|
|
238
|
+
const result = await requestPrivateConsolidation(ctx, 'fork-no-turn-end')
|
|
239
|
+
assert.equal(result.ok, false)
|
|
240
|
+
assert.equal(result.settled, false)
|
|
241
|
+
assert.match(result.error, /no turn\/end/)
|
|
242
|
+
assert.equal(state.getSessionBinding('fork-no-turn-end').lastSleepSeq, undefined, 'without a turn/end event the cursor must not advance')
|
|
243
|
+
assert.equal(ctx.rec.disposed.length, 1, 'the unmatched fork is still disposed')
|
|
244
|
+
assert.equal(ctx.rec.sessionHandlers.length, 0, 'the listener is removed even when no turn/end arrives')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('a fork still running at the deadline reports a timeout instead of silence', async () => {
|
|
248
|
+
state.ensureSession('fork-timeout', { cwd: path.join(home, 'proj-timeout'), agentPreset: 'coding' })
|
|
249
|
+
const ctx = host({ live: true, whenIdle: () => new Promise(() => {}) })
|
|
250
|
+
const result = await requestPrivateConsolidation(ctx, 'fork-timeout', { maxWaitMs: 30 })
|
|
251
|
+
assert.equal(result.ok, false)
|
|
252
|
+
assert.equal(result.timedOut, true)
|
|
253
|
+
assert.equal(result.settled, false)
|
|
254
|
+
// The settings button renders result.error and nothing else; an empty string
|
|
255
|
+
// there is why two clicks in a row looked like the button did nothing.
|
|
256
|
+
assert.match(result.error, /timed out after \d+s/)
|
|
257
|
+
assert.equal(state.getSessionBinding('fork-timeout').lastSleepSeq, undefined, 'a timeout must not advance the sleep cursor')
|
|
258
|
+
assert.equal(ctx.rec.disposed.length, 1, 'the timed-out fork is stopped')
|
|
259
|
+
assert.equal(ctx.rec.sessionHandlers.length, 0, 'the listener is removed on timeout')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('a session already being consolidated is not forked twice', async () => {
|
|
263
|
+
state.ensureSession('fork-busy', { cwd: path.join(home, 'proj2'), agentPreset: 'coding' })
|
|
264
|
+
let release = null
|
|
265
|
+
const gate = new Promise((resolve) => { release = resolve })
|
|
266
|
+
const ctx = host({ live: true, whenIdle: () => gate })
|
|
267
|
+
const first = requestPrivateConsolidation(ctx, 'fork-busy')
|
|
268
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
269
|
+
assert.equal(isConsolidating('fork-busy'), true)
|
|
270
|
+
const second = await requestPrivateConsolidation(ctx, 'fork-busy')
|
|
271
|
+
assert.equal(second.ok, false)
|
|
272
|
+
assert.equal(second.running, true)
|
|
273
|
+
assert.equal(ctx.rec.created.length, 1, 'the second call must not fork again')
|
|
274
|
+
release()
|
|
275
|
+
const done = await first
|
|
276
|
+
assert.equal(done.ok, true)
|
|
277
|
+
assert.equal(isConsolidating('fork-busy'), false)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
test('two calls in the same tick fork once, not twice', async () => {
|
|
281
|
+
// The guard used to be checked before the first await and claimed only after
|
|
282
|
+
// readSession() and agents.create() resolved, so this exact pair raced past it
|
|
283
|
+
// and forked the same transcript twice on the real host.
|
|
284
|
+
state.ensureSession('fork-race', { cwd: path.join(home, 'proj3'), agentPreset: 'coding' })
|
|
285
|
+
let release = null
|
|
286
|
+
const gate = new Promise((resolve) => { release = resolve })
|
|
287
|
+
const ctx = host({ live: true, whenIdle: () => gate })
|
|
288
|
+
const both = Promise.all([
|
|
289
|
+
requestPrivateConsolidation(ctx, 'fork-race'),
|
|
290
|
+
requestPrivateConsolidation(ctx, 'fork-race'),
|
|
291
|
+
])
|
|
292
|
+
await new Promise((resolve) => setTimeout(resolve, 20))
|
|
293
|
+
release()
|
|
294
|
+
const [a, b] = await both
|
|
295
|
+
assert.equal(ctx.rec.created.length, 1, 'exactly one scratch fork for the pair')
|
|
296
|
+
const outcomes = [a, b].map((r) => r.running === true ? 'running' : r.ok === true ? 'ok' : 'error').sort()
|
|
297
|
+
assert.deepEqual(outcomes, ['ok', 'running'], 'the loser is told the session is already being consolidated')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('an unbound session is refused before any fork is attempted', async () => {
|
|
301
|
+
const ctx = host({ live: true })
|
|
302
|
+
const result = await requestPrivateConsolidation(ctx, 'fork-unbound')
|
|
303
|
+
assert.equal(result.ok, false)
|
|
304
|
+
assert.match(result.error, /not bound/)
|
|
305
|
+
assert.equal(ctx.rec.created.length, 0)
|
|
306
|
+
assert.equal(ctx.rec.liveFollowups.length, 0)
|
|
307
|
+
})
|
|
308
|
+
test('thin evidence holds the fork back until the starvation threshold', async () => {
|
|
309
|
+
state.ensureSession('fork-thin', { cwd: path.join(home, 'proj-thin'), agentPreset: 'coding' })
|
|
310
|
+
// 空闲 5 分钟:还在"短空闲"区间,证据又薄(一条短消息 + 一个工具调用),不唤醒。
|
|
311
|
+
state.setSessionBinding('fork-thin', { lastTurnAt: new Date(Date.now() - 5 * 60_000).toISOString() })
|
|
312
|
+
const ctx = host({ live: true })
|
|
313
|
+
const result = await requestPrivateConsolidation(ctx, 'fork-thin')
|
|
314
|
+
assert.equal(result.ok, true)
|
|
315
|
+
assert.equal(result.skipped, 'low-score')
|
|
316
|
+
assert.equal(ctx.rec.created.length, 0, 'a thin short-idle session must not pay for a fork')
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
test('starvation wakes a thin session so it can never be starved', async () => {
|
|
320
|
+
state.ensureSession('fork-starved', { cwd: path.join(home, 'proj-starved'), agentPreset: 'coding' })
|
|
321
|
+
state.setSessionBinding('fork-starved', { lastTurnAt: new Date(Date.now() - 40 * 60_000).toISOString() })
|
|
322
|
+
const ctx = host({ live: true })
|
|
323
|
+
const result = await requestPrivateConsolidation(ctx, 'fork-starved')
|
|
324
|
+
assert.equal(result.ok, true)
|
|
325
|
+
assert.equal(ctx.rec.created.length, 1, 'past the starvation threshold the check runs regardless of score')
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
test('a large transcript with nothing new since the cursor does not refork', async () => {
|
|
329
|
+
// 真机会话 5913 条事件、26MB 转录。游标停在末尾附近时,待整理区间只剩几条纯轮询:
|
|
330
|
+
// 没有新的真人回合,就没有可整理的证据,不该为此把整段转录重新种进 fork。
|
|
331
|
+
// (桩把填充事件全放在末尾,所以「大会话 + 末尾一句轻话」这种形态表达不出来;
|
|
332
|
+
// 另一侧「从未整理过就必须整理」由下一条用例覆盖。)
|
|
333
|
+
state.ensureSession('fork-large-thin', { cwd: path.join(home, 'proj-large'), agentPreset: 'coding' })
|
|
334
|
+
state.setSessionBinding('fork-large-thin', {
|
|
335
|
+
lastTurnAt: new Date(Date.now() - 40 * 60_000).toISOString(),
|
|
336
|
+
lastSleepSeq: 5905,
|
|
337
|
+
})
|
|
338
|
+
const ctx = host({ live: true, transcriptSize: 5913 })
|
|
339
|
+
const result = await requestPrivateConsolidation(ctx, 'fork-large-thin')
|
|
340
|
+
assert.equal(result.ok, true)
|
|
341
|
+
assert.equal(result.skipped, 'no-human-turn', 'the increment carries no human turn to consolidate')
|
|
342
|
+
assert.equal(result.settled, false, 'nothing was consolidated, so the cursor must not move')
|
|
343
|
+
assert.equal(ctx.rec.created.length, 0, 'no fork when the pending increment has no evidence')
|
|
344
|
+
assert.equal(state.getSessionBinding('fork-large-thin').lastSleepSeq, 5905, 'the cursor must not advance')
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
test('a large transcript that was never consolidated does fork, because the backlog is real', async () => {
|
|
348
|
+
// 同一条会话,但没有整理过:整段 5913 条事件都躺在 pending 里。
|
|
349
|
+
// 守卫要躲的是「为一句轻话重种整段转录」,不是「把从没整理过的大会话永远挡在门外」;
|
|
350
|
+
// 没有这条上界,长会话早期的决定永远进不了记忆——那不是省 token,是丢失。
|
|
351
|
+
state.ensureSession('fork-large-owed', { cwd: path.join(home, 'proj-large-owed'), agentPreset: 'coding' })
|
|
352
|
+
state.setSessionBinding('fork-large-owed', { lastTurnAt: new Date(Date.now() - 40 * 60_000).toISOString() })
|
|
353
|
+
const ctx = host({ live: true, transcriptSize: 5913 })
|
|
354
|
+
const result = await requestPrivateConsolidation(ctx, 'fork-large-owed')
|
|
355
|
+
assert.equal(result.ok, true, 'the backlog bound lets this one through')
|
|
356
|
+
assert.equal(ctx.rec.created.length, 1, 'a large unconsolidated backlog is worth exactly one fork')
|
|
357
|
+
})
|
|
@@ -0,0 +1,298 @@
|
|
|
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-memory-cons-ledger-'))
|
|
8
|
+
process.env.DSH_MEMU_HOME = home
|
|
9
|
+
|
|
10
|
+
const ledger = await import('../lib/consolidation-ledger.js')
|
|
11
|
+
const receipt = await import('../lib/consolidation-receipt.js')
|
|
12
|
+
|
|
13
|
+
test.after(() => {
|
|
14
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
let counter = 0
|
|
18
|
+
function binding(patch = {}) {
|
|
19
|
+
counter += 1
|
|
20
|
+
return {
|
|
21
|
+
scopeKey: 'd:/proj',
|
|
22
|
+
libraryId: 'lib_test',
|
|
23
|
+
domain: 'rp',
|
|
24
|
+
useLibrary: true,
|
|
25
|
+
useGlobal: false,
|
|
26
|
+
memoryOwnerSessionId: 'owner-' + counter,
|
|
27
|
+
memoryContext: { channelId: 'ch-' + counter },
|
|
28
|
+
...patch,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function snapshot(through = 20, from = 0) {
|
|
33
|
+
return { hash: 'hash-' + through + '-' + from, fromSeq: from, throughSeq: through, eventCount: through - from + 1, source: 'lease-live' }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// C 包第 5 条:checked/pending/inflight 三个边界分开记,一个失败不能把三个一起推错。
|
|
37
|
+
|
|
38
|
+
test('a job holds a lease and a second job is refused while it is live', () => {
|
|
39
|
+
const sessionId = 'led-lease'
|
|
40
|
+
const first = ledger.beginJob({ sessionId, jobId: 'job-1', snapshot: snapshot(20), binding: binding() })
|
|
41
|
+
assert.equal(first.ok, true)
|
|
42
|
+
assert.ok(first.job.leaseUntil > Date.now())
|
|
43
|
+
assert.equal(first.job.scopeGeneration.length, 8)
|
|
44
|
+
const second = ledger.beginJob({ sessionId, jobId: 'job-2', snapshot: snapshot(20), binding: binding() })
|
|
45
|
+
assert.equal(second.ok, false)
|
|
46
|
+
assert.equal(second.reasonCode, 'lease-held')
|
|
47
|
+
assert.equal(second.jobId, 'job-1')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('evidence advances the pending boundary only, never the checked one', () => {
|
|
51
|
+
const sessionId = 'led-pending'
|
|
52
|
+
const first = ledger.noteEvidence({ sessionId, fromSeq: 0, throughSeq: 12 })
|
|
53
|
+
assert.equal(first.changed, true)
|
|
54
|
+
assert.equal(first.pending.throughSeq, 12)
|
|
55
|
+
ledger.noteEvidence({ sessionId, fromSeq: 0, throughSeq: 30 })
|
|
56
|
+
const state = ledger.sessionState(sessionId)
|
|
57
|
+
assert.equal(state.pending.throughSeq, 30)
|
|
58
|
+
assert.equal(state.checked, null)
|
|
59
|
+
// 回退不会发生:更小的边界不会把 pending 拉回去。
|
|
60
|
+
ledger.noteEvidence({ sessionId, fromSeq: 0, throughSeq: 10 })
|
|
61
|
+
assert.equal(ledger.sessionState(sessionId).pending.throughSeq, 30)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('a completed job advances checked and clears pending', () => {
|
|
65
|
+
const sessionId = 'led-complete'
|
|
66
|
+
ledger.noteEvidence({ sessionId, throughSeq: 18 })
|
|
67
|
+
ledger.beginJob({ sessionId, jobId: 'job-c', snapshot: snapshot(20), binding: binding() })
|
|
68
|
+
const done = ledger.completeJob({
|
|
69
|
+
sessionId, jobId: 'job-c', snapshot: snapshot(20),
|
|
70
|
+
covered: { fromSeq: 0, throughSeq: 20 },
|
|
71
|
+
status: 'completed', outcome: 'created', counts: { created: 2 },
|
|
72
|
+
receipt: { status: 'completed', outcome: 'created', reasonCode: null },
|
|
73
|
+
})
|
|
74
|
+
assert.equal(done.advanced, true)
|
|
75
|
+
assert.equal(done.checked.throughSeq, 20)
|
|
76
|
+
assert.equal(done.checked.status, 'completed')
|
|
77
|
+
assert.equal(done.checked.snapshotHash, 'hash-20-0')
|
|
78
|
+
assert.equal(done.pending, null, 'the whole pending range was covered')
|
|
79
|
+
const state = ledger.sessionState(sessionId)
|
|
80
|
+
assert.equal(state.inflight, null)
|
|
81
|
+
assert.equal(state.attempts, 0)
|
|
82
|
+
assert.equal(state.history.at(-1).status, 'completed')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('a failed job keeps the range pending and backs off', () => {
|
|
86
|
+
const sessionId = 'led-fail'
|
|
87
|
+
ledger.beginJob({ sessionId, jobId: 'job-f', snapshot: snapshot(20), binding: binding() })
|
|
88
|
+
const failed = ledger.failJob({ sessionId, jobId: 'job-f', reasonCode: 'fork-turn-failed', error: 'boom' })
|
|
89
|
+
assert.equal(failed.pending.throughSeq, 20)
|
|
90
|
+
assert.ok(failed.retryAt, 'a retryable failure schedules a retry')
|
|
91
|
+
const state = ledger.sessionState(sessionId)
|
|
92
|
+
assert.equal(state.checked, null, 'a failure never advances the checked cursor')
|
|
93
|
+
assert.equal(state.lastReasonCode, 'fork-turn-failed')
|
|
94
|
+
assert.equal(state.attempts, 1)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('only completed and reasoned skipped receipts may advance', () => {
|
|
98
|
+
for (const [status, reasonCode, expected] of [['completed', null, true], ['skipped', 'no-value', true], ['skipped', null, false], ['failed', 'receipt-missing', false], ['deferred', 'open-turn', false]]) {
|
|
99
|
+
const sessionId = 'led-status-' + status + '-' + String(reasonCode)
|
|
100
|
+
ledger.beginJob({ sessionId, jobId: 'job-s', snapshot: snapshot(9), binding: binding() })
|
|
101
|
+
const done = ledger.completeJob({ sessionId, jobId: 'job-s', snapshot: snapshot(9), covered: { fromSeq: 0, throughSeq: 9 }, status, outcome: 'no_value', reasonCode, retryable: true })
|
|
102
|
+
assert.equal(done.advanced, expected, status + '/' + String(reasonCode) + ' advance=' + expected)
|
|
103
|
+
const state = ledger.sessionState(sessionId)
|
|
104
|
+
assert.equal(state.checked === null, !expected)
|
|
105
|
+
if (!expected) assert.equal(state.pending.throughSeq, 9, 'the range stays pending for the next attempt')
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('a receipt cannot claim more than the snapshot covered', () => {
|
|
110
|
+
const sessionId = 'led-clamp'
|
|
111
|
+
ledger.beginJob({ sessionId, jobId: 'job-k', snapshot: snapshot(20), binding: binding() })
|
|
112
|
+
const done = ledger.completeJob({
|
|
113
|
+
sessionId, jobId: 'job-k', snapshot: snapshot(20),
|
|
114
|
+
covered: { fromSeq: 0, throughSeq: 99 },
|
|
115
|
+
status: 'completed', outcome: 'no_value',
|
|
116
|
+
})
|
|
117
|
+
assert.equal(done.advanced, false, 'out-of-range coverage is rejected, never clamped')
|
|
118
|
+
assert.equal(done.checked, null)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('a partial cover keeps the rest pending', () => {
|
|
122
|
+
const sessionId = 'led-partial'
|
|
123
|
+
ledger.noteEvidence({ sessionId, throughSeq: 40 })
|
|
124
|
+
ledger.beginJob({ sessionId, jobId: 'job-p', snapshot: snapshot(40), binding: binding() })
|
|
125
|
+
const done = ledger.completeJob({ sessionId, jobId: 'job-p', snapshot: snapshot(40), covered: { fromSeq: 0, throughSeq: 25 }, status: 'completed', outcome: 'no_value' })
|
|
126
|
+
assert.equal(done.advanced, false)
|
|
127
|
+
assert.equal(done.checked, null, 'partial coverage must not advance checked')
|
|
128
|
+
assert.equal(done.pending.throughSeq, 40)
|
|
129
|
+
assert.equal(done.pending.fromSeq, 0)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('an expired lease is recovered as a failure, not as a completed run', () => {
|
|
133
|
+
const sessionId = 'led-zombie'
|
|
134
|
+
const started = Date.now() - 60_000
|
|
135
|
+
ledger.beginJob({ sessionId, jobId: 'job-z', snapshot: snapshot(12), binding: binding(), now: started, leaseMs: 1_000 })
|
|
136
|
+
const recovered = ledger.recoverStaleJobs({ now: Date.now() })
|
|
137
|
+
assert.ok(recovered.some((row) => row.sessionId === sessionId))
|
|
138
|
+
const state = ledger.sessionState(sessionId)
|
|
139
|
+
assert.equal(state.inflight, null)
|
|
140
|
+
assert.equal(state.checked, null, 'an interrupted job proves nothing')
|
|
141
|
+
assert.equal(state.pending.throughSeq, 12, 'its range is pending again')
|
|
142
|
+
assert.equal(state.lastReasonCode, 'lease-expired')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('a live lease is not recovered', () => {
|
|
146
|
+
const sessionId = 'led-live-lease'
|
|
147
|
+
ledger.beginJob({ sessionId, jobId: 'job-live', snapshot: snapshot(4), binding: binding() })
|
|
148
|
+
ledger.recoverStaleJobs({ now: Date.now() })
|
|
149
|
+
assert.ok(ledger.sessionState(sessionId).inflight, 'a job inside its lease keeps the session')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('the operation key is stable and records what was settled', () => {
|
|
153
|
+
const args = { sessionId: 'led-op', libraryId: 'lib_test', snapshotHash: 'hash-20-0' }
|
|
154
|
+
const key = ledger.operationKey(args)
|
|
155
|
+
assert.equal(key, ledger.operationKey({ ...args }))
|
|
156
|
+
assert.notEqual(key, ledger.operationKey({ ...args, snapshotHash: 'hash-21-0' }))
|
|
157
|
+
assert.equal(ledger.findOperation('led-op', key), null)
|
|
158
|
+
ledger.recordOperation({ sessionId: 'led-op', key, outcome: 'completed', ids: ['mem-1'] })
|
|
159
|
+
const found = ledger.findOperation('led-op', key)
|
|
160
|
+
assert.equal(found.outcome, 'completed')
|
|
161
|
+
assert.deepEqual(found.ids, ['mem-1'])
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('two sessions do not overwrite each other in the ledger', () => {
|
|
165
|
+
ledger.beginJob({ sessionId: 'led-a', jobId: 'job-a', snapshot: snapshot(3), binding: binding() })
|
|
166
|
+
ledger.beginJob({ sessionId: 'led-b', jobId: 'job-b', snapshot: snapshot(7), binding: binding() })
|
|
167
|
+
assert.equal(ledger.sessionState('led-a').inflight.jobId, 'job-a')
|
|
168
|
+
assert.equal(ledger.sessionState('led-b').inflight.jobId, 'job-b')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
test('the scope generation changes with the parent authorization', () => {
|
|
172
|
+
const base = binding()
|
|
173
|
+
const first = ledger.scopeGenerationOf(base)
|
|
174
|
+
assert.equal(first, ledger.scopeGenerationOf({ ...base }))
|
|
175
|
+
assert.notEqual(first, ledger.scopeGenerationOf({ ...base, libraryId: 'lib_other' }))
|
|
176
|
+
assert.notEqual(first, ledger.scopeGenerationOf({ ...base, useLibrary: false }))
|
|
177
|
+
assert.notEqual(first, ledger.scopeGenerationOf({ ...base, memoryContext: { channelId: 'other' } }))
|
|
178
|
+
assert.equal(ledger.scopeGenerationOf(null), null)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('a corrupt ledger blocks writes instead of silently replacing it', () => {
|
|
182
|
+
const other = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-cons-corrupt-'))
|
|
183
|
+
const previous = process.env.DSH_MEMU_HOME
|
|
184
|
+
process.env.DSH_MEMU_HOME = other
|
|
185
|
+
try {
|
|
186
|
+
fs.writeFileSync(path.join(other, 'consolidation.json'), '{ not json', 'utf8')
|
|
187
|
+
assert.throws(() => ledger.beginJob({ sessionId: 'led-corrupt', jobId: 'job-x', snapshot: snapshot(1), binding: binding() }), /refusing to overwrite|not valid JSON|busy/i)
|
|
188
|
+
assert.equal(ledger.sessionState('led-corrupt').inflight, null)
|
|
189
|
+
} finally {
|
|
190
|
+
process.env.DSH_MEMU_HOME = previous
|
|
191
|
+
try { fs.rmSync(other, { recursive: true, force: true }) } catch {}
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// ---- 结构化回执 ----
|
|
196
|
+
|
|
197
|
+
function fence(json) {
|
|
198
|
+
const tick = String.fromCharCode(96)
|
|
199
|
+
return [tick + tick + tick + receipt.RECEIPT_TAG, JSON.stringify(json, null, 2), tick + tick + tick].join('\n')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
test('a receipt without its fence block is missing, never guessed', () => {
|
|
203
|
+
const parsed = receipt.parseReceipt('我整理完了,写了两条记忆。')
|
|
204
|
+
assert.equal(parsed.ok, false)
|
|
205
|
+
assert.equal(parsed.reasonCode, 'receipt-missing')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('malformed JSON and invalid shapes are distinguishable failures', () => {
|
|
209
|
+
const tick = String.fromCharCode(96) + String.fromCharCode(96) + String.fromCharCode(96)
|
|
210
|
+
const broken = [tick + receipt.RECEIPT_TAG, '{ "status": "completed", ', tick].join('\n')
|
|
211
|
+
assert.equal(receipt.parseReceipt(broken).reasonCode, 'receipt-malformed')
|
|
212
|
+
const badStatus = receipt.parseReceipt(fence({ status: 'done', outcome: 'created', createdIds: ['mem-1'] }))
|
|
213
|
+
assert.equal(badStatus.reasonCode, 'receipt-invalid')
|
|
214
|
+
assert.match(badStatus.detail, /status must be one of/)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
test('created without ids, or skipped without a reasonCode, is invalid', () => {
|
|
218
|
+
assert.equal(receipt.parseReceipt(fence({ status: 'completed', outcome: 'created', createdIds: [] })).reasonCode, 'receipt-invalid')
|
|
219
|
+
assert.equal(receipt.parseReceipt(fence({ status: 'skipped', outcome: 'no_value' })).reasonCode, 'receipt-invalid')
|
|
220
|
+
const ok = receipt.parseReceipt(fence({ status: 'skipped', outcome: 'no_value', reasonCode: 'no-value', checkedRange: { fromSeq: 0, throughSeq: 4 } }))
|
|
221
|
+
assert.equal(ok.ok, true)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('no_value cannot smuggle in written ids', () => {
|
|
225
|
+
const parsed = receipt.parseReceipt(fence({ status: 'completed', outcome: 'no_value', createdIds: ['mem-1'], checkedRange: { fromSeq: 0, throughSeq: 4 } }))
|
|
226
|
+
assert.equal(parsed.reasonCode, 'receipt-invalid')
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test('the receipt is reconciled against the library, not taken on trust', () => {
|
|
230
|
+
const index = { entries: { 'mem-1': { status: 'active' }, 'mem-held': { status: 'pending_review' } } }
|
|
231
|
+
const parsed = receipt.parseReceipt(fence({
|
|
232
|
+
status: 'completed', outcome: 'created', createdIds: ['mem-1', 'mem-ghost'], checkedRange: { fromSeq: 0, throughSeq: 8 },
|
|
233
|
+
}))
|
|
234
|
+
assert.equal(parsed.ok, true)
|
|
235
|
+
const reconciled = receipt.reconcileReceipt({ receipt: parsed.receipt, index, beforeIds: new Set() })
|
|
236
|
+
assert.equal(reconciled.ok, false)
|
|
237
|
+
assert.match(reconciled.problems.join(' '), /mem-ghost/)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test('rewriting an existing id is an update, not a new memory', () => {
|
|
241
|
+
const index = { entries: { 'mem-1': { status: 'active', content: 'after' } } }
|
|
242
|
+
const parsed = receipt.parseReceipt(fence({ status: 'completed', outcome: 'created', createdIds: ['mem-1'], checkedRange: { fromSeq: 0, throughSeq: 4 } }))
|
|
243
|
+
const reconciled = receipt.reconcileReceipt({ receipt: parsed.receipt, index, beforeIds: new Set(['mem-1']), beforeEntries: { 'mem-1': receipt.entryFingerprint({ status: 'active', content: 'before' }) } })
|
|
244
|
+
assert.equal(reconciled.ok, true)
|
|
245
|
+
assert.deepEqual(reconciled.createdIds, [])
|
|
246
|
+
assert.deepEqual(reconciled.duplicateIds, ['mem-1'])
|
|
247
|
+
assert.equal(reconciled.outcome, 'updated')
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
test('an unchanged existing id cannot prove an update', () => {
|
|
251
|
+
const index = { entries: { 'mem-unchanged': { status: 'active', content: 'same' } } }
|
|
252
|
+
const parsed = receipt.parseReceipt(fence({ status: 'completed', outcome: 'updated', updatedIds: ['mem-unchanged'], checkedRange: { fromSeq: 0, throughSeq: 4 } }))
|
|
253
|
+
const reconciled = receipt.reconcileReceipt({
|
|
254
|
+
receipt: parsed.receipt,
|
|
255
|
+
index,
|
|
256
|
+
beforeIds: new Set(['mem-unchanged']),
|
|
257
|
+
beforeEntries: { 'mem-unchanged': receipt.entryFingerprint(index.entries['mem-unchanged']) },
|
|
258
|
+
})
|
|
259
|
+
assert.equal(reconciled.ok, false)
|
|
260
|
+
assert.match(reconciled.problems.join(' '), /no verified post-worker change/)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
test('a held id must really be pending_review', () => {
|
|
264
|
+
const index = { entries: { 'mem-1': { status: 'active' } } }
|
|
265
|
+
const parsed = receipt.parseReceipt(fence({ status: 'completed', outcome: 'no_value', heldIds: ['mem-1'], checkedRange: { fromSeq: 0, throughSeq: 4 } }))
|
|
266
|
+
const reconciled = receipt.reconcileReceipt({ receipt: parsed.receipt, index })
|
|
267
|
+
assert.equal(reconciled.ok, false)
|
|
268
|
+
assert.match(reconciled.problems.join(' '), /not pending_review/)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('decideCompletion: proof advances, guesswork does not', () => {
|
|
272
|
+
const index = { entries: { 'mem-7': { status: 'active' } } }
|
|
273
|
+
const good = receipt.decideCompletion({
|
|
274
|
+
text: fence({ status: 'completed', outcome: 'created', createdIds: ['mem-7'], checkedRange: { fromSeq: 0, throughSeq: 9 } }),
|
|
275
|
+
wrote: 1, index, beforeIds: new Set(),
|
|
276
|
+
})
|
|
277
|
+
assert.equal(good.advance, true)
|
|
278
|
+
assert.equal(good.outcome, 'created')
|
|
279
|
+
assert.deepEqual(good.counted, { created: 1, updated: 0, held: 0 })
|
|
280
|
+
const unverified = receipt.decideCompletion({ text: fence({ status: 'completed', outcome: 'created', createdIds: ['mem-404'], checkedRange: { fromSeq: 0, throughSeq: 9 } }), wrote: 0, index, beforeIds: new Set() })
|
|
281
|
+
assert.equal(unverified.advance, false)
|
|
282
|
+
assert.equal(unverified.reasonCode, 'receipt-unverified')
|
|
283
|
+
const silent = receipt.decideCompletion({ text: '我做了不少事情。', wrote: 0, index, beforeIds: new Set() })
|
|
284
|
+
assert.equal(silent.advance, false)
|
|
285
|
+
assert.equal(silent.reasonCode, 'receipt-missing')
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('decideCompletion never settles a silent worker on its own', () => {
|
|
289
|
+
// 结算空正文的规则已经上移到 private.js 的调用点:那里知道「worker 一个字没说、库里也没
|
|
290
|
+
// 多出条目」这一种情形(见 tests/consolidation-fork.test.mjs 与 tests/cycle.test.mjs)。
|
|
291
|
+
// 模块层只保留一个方向的保证:没有回执就不给凭证。
|
|
292
|
+
const silent = receipt.decideCompletion({ text: '', wrote: 0 })
|
|
293
|
+
assert.equal(silent.advance, false)
|
|
294
|
+
assert.equal(silent.reasonCode, 'receipt-missing')
|
|
295
|
+
const unattributed = receipt.decideCompletion({ text: '', wrote: 2 })
|
|
296
|
+
assert.equal(unattributed.advance, false)
|
|
297
|
+
assert.match(unattributed.error, /receipt|cannot settle/i)
|
|
298
|
+
})
|