@remnic/core 10.0.93 → 10.0.95
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/dist/access-admin-ops-surface.js +29 -29
- package/dist/access-cli.js +29 -29
- package/dist/access-extraction-force-flush.js +29 -29
- package/dist/access-http-briefing.js +29 -29
- package/dist/access-http-query.js +29 -29
- package/dist/access-http.js +29 -29
- package/dist/access-identity-continuity-surface.js +29 -29
- package/dist/access-lcm-surface.js +29 -29
- package/dist/access-mcp.js +29 -29
- package/dist/access-observe-write-surface.js +29 -29
- package/dist/access-operations-batch.js +29 -29
- package/dist/access-operations.js +29 -29
- package/dist/access-recall-concurrency.js +29 -29
- package/dist/access-recall-response.js +29 -29
- package/dist/access-recall-surface.js +29 -29
- package/dist/access-service.js +29 -29
- package/dist/active-recall.js +2 -2
- package/dist/briefing.js +3 -3
- package/dist/causal-consolidation.js +4 -4
- package/dist/{chunk-2LMA22UU.js → chunk-4EZWSDFL.js} +3 -3
- package/dist/{chunk-PRAUN6PN.js → chunk-BN2EBCLB.js} +2 -2
- package/dist/{chunk-B33YWUJV.js → chunk-BPK32FRM.js} +2 -2
- package/dist/{chunk-NAQ4A5IX.js → chunk-CL4JU3DF.js} +3 -3
- package/dist/{chunk-BGHCSLE5.js → chunk-D5XG2KPS.js} +2 -2
- package/dist/{chunk-YKJBSMMP.js → chunk-DDATG2SF.js} +63 -43
- package/dist/{chunk-YKJBSMMP.js.map → chunk-DDATG2SF.js.map} +1 -1
- package/dist/{chunk-EFL24KOI.js → chunk-DNY63QWJ.js} +2 -2
- package/dist/{chunk-5YT2IIFX.js → chunk-FQAQ63QA.js} +2 -2
- package/dist/{chunk-WQURALGV.js → chunk-FWRXW7CZ.js} +7 -3
- package/dist/chunk-FWRXW7CZ.js.map +1 -0
- package/dist/{chunk-XIYLWEXC.js → chunk-FZZMLGWQ.js} +2 -2
- package/dist/{chunk-ZSE774RV.js → chunk-GBJUTX44.js} +2 -2
- package/dist/{chunk-ATG2PQ4V.js → chunk-IL3RHBWM.js} +3 -3
- package/dist/{chunk-OJXJWNFY.js → chunk-ILY5SSOM.js} +2 -2
- package/dist/{chunk-XRDYRWZI.js → chunk-IQ55QGY5.js} +2 -2
- package/dist/{chunk-BTI4C4MA.js → chunk-J7BIFOYP.js} +2 -2
- package/dist/{chunk-WKSQTMU7.js → chunk-LNGELD2M.js} +2 -2
- package/dist/{chunk-GQXOBKPL.js → chunk-LS7PT3YD.js} +3 -3
- package/dist/{chunk-SGQPR5SX.js → chunk-OUVYYKYU.js} +2 -2
- package/dist/{chunk-CGJYL633.js → chunk-QHARXWTC.js} +2 -2
- package/dist/{chunk-SVT5IA2H.js → chunk-QMFAMOT2.js} +2 -2
- package/dist/{chunk-NT7F7WJQ.js → chunk-RFUL2ZIQ.js} +291 -93
- package/dist/chunk-RFUL2ZIQ.js.map +1 -0
- package/dist/{chunk-622V2AZA.js → chunk-RKABLU5M.js} +7 -7
- package/dist/{chunk-MH632KPO.js → chunk-RNG7S3FY.js} +2 -2
- package/dist/{chunk-MUX7IL77.js → chunk-SUTEBYGI.js} +2 -2
- package/dist/{chunk-FMKILENU.js → chunk-WGGRZYSQ.js} +7 -7
- package/dist/{chunk-ETBVCRVL.js → chunk-X5HGU2EM.js} +2 -2
- package/dist/{chunk-TJ4F6H4H.js → chunk-XJ2S5UBF.js} +2 -2
- package/dist/{chunk-TRQE3RYR.js → chunk-YIXJSW5M.js} +2 -2
- package/dist/{chunk-3CYOCE6U.js → chunk-Z3FTSHT5.js} +2 -2
- package/dist/{chunk-RM2YUFH6.js → chunk-ZDV5YWID.js} +2 -2
- package/dist/cli.js +29 -29
- package/dist/coding/export-okf-codegraph.js +2 -2
- package/dist/compounding/engine.js +4 -4
- package/dist/config.js +2 -2
- package/dist/connectors/codex-materialize-runner.js +3 -3
- package/dist/connectors/index.js +3 -3
- package/dist/convergence-refresh.js +5 -5
- package/dist/corpus-watermark.js +5 -5
- package/dist/entity-retrieval.js +3 -3
- package/dist/explicit-capture.js +29 -29
- package/dist/extraction.js +3 -3
- package/dist/harmonic-retrieval.js +3 -3
- package/dist/index.js +29 -29
- package/dist/location/index.js +4 -4
- package/dist/maintenance/memory-governance.js +3 -3
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
- package/dist/maintenance/rebuild-memory-projection-runner.js +5 -5
- package/dist/maintenance/rebuild-memory-projection.js +4 -4
- package/dist/namespaces/migrate.js +4 -4
- package/dist/namespaces/storage.js +3 -3
- package/dist/operator-doctor-corpus.js +6 -6
- package/dist/operator-toolkit.js +9 -9
- package/dist/orchestration/backlog-scheduler.js +30 -30
- package/dist/orchestration/buffer-drain-runner.js +30 -30
- package/dist/orchestration/extraction-persist-failure.d.ts +25 -1
- package/dist/orchestration/extraction-persist-failure.d.ts.map +1 -1
- package/dist/orchestration/extraction-persist.d.ts +3 -6
- package/dist/orchestration/extraction-persist.d.ts.map +1 -1
- package/dist/orchestration/maintenance.js +11 -11
- package/dist/orchestrator.js +29 -29
- package/dist/recall-navigation.js +29 -29
- package/dist/resume-bundles.js +2 -2
- package/dist/semantic-consolidation.js +4 -4
- package/dist/semantic-rule-promotion.js +3 -3
- package/dist/semantic-rule-verifier.js +3 -3
- package/dist/source-agent-qualifier.js +3 -3
- package/dist/standup/build.js +3 -3
- package/dist/storage/buffer-drain-claims.d.ts +97 -12
- package/dist/storage/buffer-drain-claims.d.ts.map +1 -1
- package/dist/storage/buffer-drain-claims.js +1 -1
- package/dist/storage/buffer-session-files.d.ts +4 -0
- package/dist/storage/buffer-session-files.d.ts.map +1 -1
- package/dist/storage/buffer-session-files.js +1 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +2 -2
- package/dist/transfer/export-okf.js +3 -3
- package/dist/verified-recall.js +3 -3
- package/dist/who-knows-cli.js +4 -4
- package/dist/who-knows.js +3 -3
- package/package.json +2 -2
- package/src/buffer-drain-handover.test.ts +5 -1
- package/src/orchestration/extraction-persist-failure.test.ts +90 -0
- package/src/orchestration/extraction-persist-failure.ts +39 -1
- package/src/orchestration/extraction-persist.ts +29 -21
- package/src/orchestrator-persist-branch-failure.test.ts +271 -0
- package/src/storage/buffer-drain-claims.test.ts +385 -1
- package/src/storage/buffer-drain-claims.ts +380 -97
- package/src/storage/buffer-session-files.ts +4 -0
- package/src/storage.ts +5 -1
- package/dist/chunk-NT7F7WJQ.js.map +0 -1
- package/dist/chunk-WQURALGV.js.map +0 -1
- /package/dist/{chunk-2LMA22UU.js.map → chunk-4EZWSDFL.js.map} +0 -0
- /package/dist/{chunk-PRAUN6PN.js.map → chunk-BN2EBCLB.js.map} +0 -0
- /package/dist/{chunk-B33YWUJV.js.map → chunk-BPK32FRM.js.map} +0 -0
- /package/dist/{chunk-NAQ4A5IX.js.map → chunk-CL4JU3DF.js.map} +0 -0
- /package/dist/{chunk-BGHCSLE5.js.map → chunk-D5XG2KPS.js.map} +0 -0
- /package/dist/{chunk-EFL24KOI.js.map → chunk-DNY63QWJ.js.map} +0 -0
- /package/dist/{chunk-5YT2IIFX.js.map → chunk-FQAQ63QA.js.map} +0 -0
- /package/dist/{chunk-XIYLWEXC.js.map → chunk-FZZMLGWQ.js.map} +0 -0
- /package/dist/{chunk-ZSE774RV.js.map → chunk-GBJUTX44.js.map} +0 -0
- /package/dist/{chunk-ATG2PQ4V.js.map → chunk-IL3RHBWM.js.map} +0 -0
- /package/dist/{chunk-OJXJWNFY.js.map → chunk-ILY5SSOM.js.map} +0 -0
- /package/dist/{chunk-XRDYRWZI.js.map → chunk-IQ55QGY5.js.map} +0 -0
- /package/dist/{chunk-BTI4C4MA.js.map → chunk-J7BIFOYP.js.map} +0 -0
- /package/dist/{chunk-WKSQTMU7.js.map → chunk-LNGELD2M.js.map} +0 -0
- /package/dist/{chunk-GQXOBKPL.js.map → chunk-LS7PT3YD.js.map} +0 -0
- /package/dist/{chunk-SGQPR5SX.js.map → chunk-OUVYYKYU.js.map} +0 -0
- /package/dist/{chunk-CGJYL633.js.map → chunk-QHARXWTC.js.map} +0 -0
- /package/dist/{chunk-SVT5IA2H.js.map → chunk-QMFAMOT2.js.map} +0 -0
- /package/dist/{chunk-622V2AZA.js.map → chunk-RKABLU5M.js.map} +0 -0
- /package/dist/{chunk-MH632KPO.js.map → chunk-RNG7S3FY.js.map} +0 -0
- /package/dist/{chunk-MUX7IL77.js.map → chunk-SUTEBYGI.js.map} +0 -0
- /package/dist/{chunk-FMKILENU.js.map → chunk-WGGRZYSQ.js.map} +0 -0
- /package/dist/{chunk-ETBVCRVL.js.map → chunk-X5HGU2EM.js.map} +0 -0
- /package/dist/{chunk-TJ4F6H4H.js.map → chunk-XJ2S5UBF.js.map} +0 -0
- /package/dist/{chunk-TRQE3RYR.js.map → chunk-YIXJSW5M.js.map} +0 -0
- /package/dist/{chunk-3CYOCE6U.js.map → chunk-Z3FTSHT5.js.map} +0 -0
- /package/dist/{chunk-RM2YUFH6.js.map → chunk-ZDV5YWID.js.map} +0 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
|
|
6
|
+
import { resetExtractionLlmHealthForTests } from "./extraction-llm-health.js";
|
|
7
|
+
import { Orchestrator } from "./orchestrator.js";
|
|
8
|
+
import type { StorageManager } from "./storage.js";
|
|
9
|
+
import {
|
|
10
|
+
cleanupDir,
|
|
11
|
+
harmonicNodeStoreStatus,
|
|
12
|
+
makeHarmonicLifecycleConfig,
|
|
13
|
+
mkTempMemoryDir,
|
|
14
|
+
} from "./testing/orchestrator-lite.js";
|
|
15
|
+
import type { ExtractionResult } from "./types.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* #3288: a branch that throws after an earlier fact in the same batch is
|
|
19
|
+
* durable must still land that fact's batch-end effects (behavior signal,
|
|
20
|
+
* harmonic node, temporal/tag index entry, QMD request) before the error
|
|
21
|
+
* reaches the caller. A retry exact-dedupes the earlier fact, so this failure
|
|
22
|
+
* path is the only chance those effects get.
|
|
23
|
+
*/
|
|
24
|
+
const EARLIER = "Prefers terse queue names for background jobs in Project Gamma.";
|
|
25
|
+
|
|
26
|
+
type BranchCase = {
|
|
27
|
+
label: string;
|
|
28
|
+
config?: Parameters<typeof makeHarmonicLifecycleConfig>[1];
|
|
29
|
+
/** Outputs persisted after the EARLIER fact; one of them hits the injected failure. */
|
|
30
|
+
later: Partial<ExtractionResult>;
|
|
31
|
+
/** Installs the failure and returns the error the pass must rethrow. */
|
|
32
|
+
inject: (orchestrator: Orchestrator, storage: StorageManager, memoryDir: string) => Promise<Error>;
|
|
33
|
+
/**
|
|
34
|
+
* Proves the failing branch had already committed its own write when it
|
|
35
|
+
* threw and returns that write's id. It is durable too, so batch-end
|
|
36
|
+
* finalization must cover it as well as the earlier fact.
|
|
37
|
+
*/
|
|
38
|
+
committedBeforeFailure?: (storage: StorageManager) => Promise<string>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
async function assertEarlierFactFinalized(branch: BranchCase): Promise<void> {
|
|
42
|
+
const memoryDir = await mkTempMemoryDir(`persist-branch-${branch.label}`);
|
|
43
|
+
const config = makeHarmonicLifecycleConfig(memoryDir, {
|
|
44
|
+
chunkingEnabled: false,
|
|
45
|
+
multiGraphMemoryEnabled: false,
|
|
46
|
+
factDeduplicationEnabled: true,
|
|
47
|
+
extractionMinImportanceLevel: "trivial",
|
|
48
|
+
entityActivityLogEnabled: false,
|
|
49
|
+
queryAwareIndexingEnabled: true,
|
|
50
|
+
...branch.config,
|
|
51
|
+
});
|
|
52
|
+
const orchestrator = new Orchestrator(config);
|
|
53
|
+
const storage = await orchestrator.getStorageForNamespace();
|
|
54
|
+
const temporalIdBatches: string[][] = [];
|
|
55
|
+
// Finalization starts the temporal/tag update without awaiting it; cleanup
|
|
56
|
+
// waits for it so the temp dir is not removed under its lock.
|
|
57
|
+
const temporalUpdates: Promise<void>[] = [];
|
|
58
|
+
// Bracket access is TypeScript's typed escape hatch for the private method; the spy only wraps it.
|
|
59
|
+
const originalTemporal = orchestrator["updateTemporalTagIndexes"].bind(orchestrator);
|
|
60
|
+
orchestrator["updateTemporalTagIndexes"] = (target, persistedIds) => {
|
|
61
|
+
temporalIdBatches.push([...persistedIds]);
|
|
62
|
+
const update = originalTemporal(target, persistedIds);
|
|
63
|
+
temporalUpdates.push(update);
|
|
64
|
+
return update;
|
|
65
|
+
};
|
|
66
|
+
let qmdMaintenanceCalls = 0;
|
|
67
|
+
const originalQmd = orchestrator.requestQmdMaintenance.bind(orchestrator);
|
|
68
|
+
orchestrator.requestQmdMaintenance = () => {
|
|
69
|
+
qmdMaintenanceCalls += 1;
|
|
70
|
+
originalQmd();
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
await storage.ensureDirectories();
|
|
74
|
+
const failure = await branch.inject(orchestrator, storage, memoryDir);
|
|
75
|
+
const result: ExtractionResult = {
|
|
76
|
+
entities: [],
|
|
77
|
+
questions: [],
|
|
78
|
+
profileUpdates: [],
|
|
79
|
+
...branch.later,
|
|
80
|
+
facts: [
|
|
81
|
+
{ content: EARLIER, category: "preference", confidence: 0.95, tags: ["queue-naming"] },
|
|
82
|
+
...(branch.later.facts ?? []),
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
await assert.rejects(
|
|
87
|
+
orchestrator.persistExtraction(result, storage, null, { sessionKey: `session:${branch.label}` }),
|
|
88
|
+
(error: Error) => error === failure,
|
|
89
|
+
"the branch failure must reach the caller unchanged",
|
|
90
|
+
);
|
|
91
|
+
const branchWriteId = await branch.committedBeforeFailure?.(storage);
|
|
92
|
+
|
|
93
|
+
const earlier = (await storage.readAllMemories()).filter((m) => m.frontmatter.category === "preference");
|
|
94
|
+
assert.equal(earlier.length, 1, "the earlier fact must stay durable");
|
|
95
|
+
const factId = earlier[0]!.frontmatter.id;
|
|
96
|
+
assert.ok(
|
|
97
|
+
(await storage.readBehaviorSignals(50)).some(
|
|
98
|
+
(s) => s.memoryId === factId && s.signalType === "preference_affinity",
|
|
99
|
+
),
|
|
100
|
+
"the earlier fact's behavior signal must land before the rethrow",
|
|
101
|
+
);
|
|
102
|
+
const harmonicSources = (await harmonicNodeStoreStatus(memoryDir)).latestNode?.sourceMemoryIds ?? [];
|
|
103
|
+
for (const id of branchWriteId ? [factId, branchWriteId] : [factId]) {
|
|
104
|
+
assert.ok(harmonicSources.includes(id), `the harmonic node must cover durable write ${id} before the rethrow`);
|
|
105
|
+
assert.ok(
|
|
106
|
+
temporalIdBatches.some((ids) => ids.includes(id)),
|
|
107
|
+
`the temporal/tag index update must cover durable write ${id} before the rethrow`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
assert.ok(qmdMaintenanceCalls > 0, "the partial batch must still schedule QMD maintenance");
|
|
111
|
+
} finally {
|
|
112
|
+
await Promise.allSettled(temporalUpdates);
|
|
113
|
+
orchestrator.requestQmdMaintenance = originalQmd;
|
|
114
|
+
orchestrator["updateTemporalTagIndexes"] = originalTemporal;
|
|
115
|
+
try {
|
|
116
|
+
await orchestrator.destroy();
|
|
117
|
+
} finally {
|
|
118
|
+
resetExtractionLlmHealthForTests();
|
|
119
|
+
await cleanupDir(memoryDir);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
test("#3288: a chunk write failure after the chunked parent is durable finalizes the earlier fact", async () => {
|
|
125
|
+
await assertEarlierFactFinalized({
|
|
126
|
+
label: "chunk-write",
|
|
127
|
+
config: {
|
|
128
|
+
chunkingEnabled: true,
|
|
129
|
+
chunkingTargetTokens: 12,
|
|
130
|
+
chunkingMinTokens: 10,
|
|
131
|
+
chunkingOverlapSentences: 0,
|
|
132
|
+
},
|
|
133
|
+
later: {
|
|
134
|
+
facts: [
|
|
135
|
+
{
|
|
136
|
+
content:
|
|
137
|
+
"Project Gamma runs its nightly export on a dedicated worker pool. " +
|
|
138
|
+
"The export writes compressed archives to the cold storage bucket. " +
|
|
139
|
+
"Each archive is verified against a checksum manifest before upload. " +
|
|
140
|
+
"Failed uploads are retried three times with exponential backoff.",
|
|
141
|
+
category: "fact",
|
|
142
|
+
confidence: 0.9,
|
|
143
|
+
tags: ["export"],
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
inject: async (_orchestrator, storage) => {
|
|
148
|
+
const failure = new Error("synthetic chunk write failure");
|
|
149
|
+
storage.writeChunk = async () => {
|
|
150
|
+
throw failure;
|
|
151
|
+
};
|
|
152
|
+
return failure;
|
|
153
|
+
},
|
|
154
|
+
committedBeforeFailure: async (storage) => {
|
|
155
|
+
const parents = (await storage.readAllMemories()).filter((m) => m.frontmatter.tags?.includes("chunked"));
|
|
156
|
+
assert.equal(parents.length, 1, "the chunked parent must be durable before the chunk write throws");
|
|
157
|
+
return parents[0]!.frontmatter.id;
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const MERGE_TARGET_ID = "merge-target-gamma";
|
|
163
|
+
const MERGE_SEED = "The Project Gamma billing service deploys on a Tuesday cadence.";
|
|
164
|
+
const MERGE_INCOMING = "The Project Gamma billing service deploys at 09:00 UTC sharp.";
|
|
165
|
+
|
|
166
|
+
/** Seed an active merge target and a deterministic merge judge for MERGE_INCOMING only. */
|
|
167
|
+
async function armSemanticMerge(orchestrator: Orchestrator, memoryDir: string): Promise<void> {
|
|
168
|
+
const created = new Date(Date.now() - 3600_000).toISOString();
|
|
169
|
+
const seededDir = path.join(memoryDir, "facts", created.slice(0, 10));
|
|
170
|
+
await mkdir(seededDir, { recursive: true });
|
|
171
|
+
await writeFile(
|
|
172
|
+
path.join(seededDir, `${MERGE_TARGET_ID}.md`),
|
|
173
|
+
[
|
|
174
|
+
"---",
|
|
175
|
+
`id: ${MERGE_TARGET_ID}`,
|
|
176
|
+
"category: fact",
|
|
177
|
+
`created: ${created}`,
|
|
178
|
+
`updated: ${created}`,
|
|
179
|
+
"source: extraction",
|
|
180
|
+
"confidence: 0.9",
|
|
181
|
+
"confidenceTier: explicit",
|
|
182
|
+
"status: active",
|
|
183
|
+
"importanceScore: 0.9",
|
|
184
|
+
"importanceLevel: high",
|
|
185
|
+
"---",
|
|
186
|
+
"",
|
|
187
|
+
MERGE_SEED,
|
|
188
|
+
"",
|
|
189
|
+
].join("\n"),
|
|
190
|
+
"utf8",
|
|
191
|
+
);
|
|
192
|
+
// Band neighbor for the incoming fact only ([minSimilarity, threshold)).
|
|
193
|
+
const embeddings = orchestrator.embeddingFallback;
|
|
194
|
+
embeddings.isAvailable = async () => true;
|
|
195
|
+
embeddings.search = async (query) => (query === MERGE_INCOMING ? [{ id: MERGE_TARGET_ID, score: 0.85, path: "" }] : []);
|
|
196
|
+
embeddings.indexFile = async () => {};
|
|
197
|
+
embeddings.removeFromIndex = async () => {};
|
|
198
|
+
// Deterministic merge judge; every other local-LLM prompt gets no answer.
|
|
199
|
+
orchestrator.localLlm.chatCompletion = async (messages) =>
|
|
200
|
+
messages[0]?.role === "system" && messages[0].content.startsWith("You maintain a long-term memory store")
|
|
201
|
+
? {
|
|
202
|
+
content: JSON.stringify({
|
|
203
|
+
decision: "merge",
|
|
204
|
+
targetId: MERGE_TARGET_ID,
|
|
205
|
+
mergedContent: `${MERGE_SEED} ${MERGE_INCOMING}`,
|
|
206
|
+
reason: "deterministic judge for the merge-branch failure test",
|
|
207
|
+
}),
|
|
208
|
+
}
|
|
209
|
+
: null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
test("#3288: a semantic-merge post-effect failure after the merge commits finalizes the earlier fact", async () => {
|
|
213
|
+
await assertEarlierFactFinalized({
|
|
214
|
+
label: "semantic-merge",
|
|
215
|
+
config: {
|
|
216
|
+
embeddingFallbackEnabled: true,
|
|
217
|
+
versioningEnabled: true,
|
|
218
|
+
semanticMerge: { enabled: true },
|
|
219
|
+
multiGraphMemoryEnabled: true,
|
|
220
|
+
},
|
|
221
|
+
later: {
|
|
222
|
+
facts: [{ content: MERGE_INCOMING, category: "fact", confidence: 0.9, tags: [] }],
|
|
223
|
+
},
|
|
224
|
+
inject: async (orchestrator, _storage, memoryDir) => {
|
|
225
|
+
await armSemanticMerge(orchestrator, memoryDir);
|
|
226
|
+
// The merged-target graph rebuild fails open, but the edge-cache
|
|
227
|
+
// invalidation that follows it is not wrapped: an unavailable graph
|
|
228
|
+
// index escapes runMergedTargetPostEffects after the merge committed.
|
|
229
|
+
const failure = new Error("synthetic graph index failure");
|
|
230
|
+
orchestrator["graphIndexFor"] = () => {
|
|
231
|
+
throw failure;
|
|
232
|
+
};
|
|
233
|
+
return failure;
|
|
234
|
+
},
|
|
235
|
+
committedBeforeFailure: async (storage) => {
|
|
236
|
+
const target = await storage.getMemoryByIdIncludingArchived(MERGE_TARGET_ID);
|
|
237
|
+
assert.ok(target?.content.includes(MERGE_INCOMING), "the merge must be committed before the post effect throws");
|
|
238
|
+
return MERGE_TARGET_ID;
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("#3288: a profile append failure after an earlier fact is durable finalizes that fact", async () => {
|
|
244
|
+
await assertEarlierFactFinalized({
|
|
245
|
+
label: "profile-append",
|
|
246
|
+
later: { profileUpdates: ["Works mostly on the Project Gamma export pipeline."] },
|
|
247
|
+
inject: async (_orchestrator, storage) => {
|
|
248
|
+
const failure = new Error("synthetic profile append failure");
|
|
249
|
+
storage.appendToProfile = async () => {
|
|
250
|
+
throw failure;
|
|
251
|
+
};
|
|
252
|
+
return failure;
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("#3288: a question write failure after an earlier fact is durable finalizes that fact", async () => {
|
|
258
|
+
await assertEarlierFactFinalized({
|
|
259
|
+
label: "question-write",
|
|
260
|
+
later: {
|
|
261
|
+
questions: [{ question: "Which bucket holds the Project Gamma archives?", context: "export pipeline", priority: 0.5 }],
|
|
262
|
+
},
|
|
263
|
+
inject: async (_orchestrator, storage) => {
|
|
264
|
+
const failure = new Error("synthetic question write failure");
|
|
265
|
+
storage.writeQuestion = async () => {
|
|
266
|
+
throw failure;
|
|
267
|
+
};
|
|
268
|
+
return failure;
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtempSync, readdirSync, existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, utimesSync } from "node:fs";
|
|
2
|
+
import { mkdtempSync, readdirSync, existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, rmSync, statSync, utimesSync } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
import os from "node:os";
|
|
4
5
|
import { hostname } from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
7
|
+
import { z } from "zod";
|
|
6
8
|
import test from "node:test";
|
|
7
9
|
|
|
8
10
|
import { BufferDrainClaims, type AckOutcome, type DrainLease } from "./buffer-drain-claims.js";
|
|
@@ -23,6 +25,10 @@ function plainIo() {
|
|
|
23
25
|
writeFileSync(filePath, content);
|
|
24
26
|
rmSync(tmp, { force: true });
|
|
25
27
|
},
|
|
28
|
+
append: async (filePath: string, content: string) => {
|
|
29
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
30
|
+
appendFileSync(filePath, content);
|
|
31
|
+
},
|
|
26
32
|
};
|
|
27
33
|
}
|
|
28
34
|
|
|
@@ -51,6 +57,50 @@ function makeLease(ownerId = "worker-1"): DrainLease {
|
|
|
51
57
|
return { ownerId, pid: process.pid, host: hostname(), acquiredAt: now, heartbeatAt: now, leaseMs: 60_000 };
|
|
52
58
|
}
|
|
53
59
|
|
|
60
|
+
/** The claim body file for `key` inside a setup()'s stateDir. */
|
|
61
|
+
function drainFile(stateDir: string, key: string): string {
|
|
62
|
+
return path.join(stateDir, "buffer", "drain", bufferSessionFileName(key));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The append-only inbox next to the claim body. */
|
|
66
|
+
function inboxFile(stateDir: string, key: string): string {
|
|
67
|
+
return `${drainFile(stateDir, key)}.inbox.jsonl`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One inbox record as mergeIntoClaim writes it: newline-prefixed JSONL with a
|
|
71
|
+
* unique id, so a torn tail always ends at the next record's leading newline
|
|
72
|
+
* and parses as its own (skipped) line. */
|
|
73
|
+
function record(key: string, entryState: BufferEntryState, id = randomUUID()): string {
|
|
74
|
+
return `\n${JSON.stringify({ id, key, ...entryState })}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** plainIo() with call recording on the seam the cost tests assert on. */
|
|
78
|
+
function spyIo() {
|
|
79
|
+
const io = plainIo();
|
|
80
|
+
const readPaths: string[] = [];
|
|
81
|
+
const writePaths: string[] = [];
|
|
82
|
+
const appendPaths: string[] = [];
|
|
83
|
+
return {
|
|
84
|
+
io: {
|
|
85
|
+
read: async (filePath: string) => {
|
|
86
|
+
readPaths.push(filePath);
|
|
87
|
+
return io.read(filePath);
|
|
88
|
+
},
|
|
89
|
+
write: async (filePath: string, content: string) => {
|
|
90
|
+
writePaths.push(filePath);
|
|
91
|
+
return io.write(filePath, content);
|
|
92
|
+
},
|
|
93
|
+
append: async (filePath: string, content: string) => {
|
|
94
|
+
appendPaths.push(filePath);
|
|
95
|
+
return io.append(filePath, content);
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
readPaths,
|
|
99
|
+
writePaths,
|
|
100
|
+
appendPaths,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
54
104
|
test("claimFromSessionFile moves a session file into the drain dir atomically", async () => {
|
|
55
105
|
const s = setup();
|
|
56
106
|
try {
|
|
@@ -770,3 +820,337 @@ test("a late arrival of an ambiguous identity does not replace a source occurren
|
|
|
770
820
|
s.cleanup();
|
|
771
821
|
}
|
|
772
822
|
});
|
|
823
|
+
|
|
824
|
+
// ─── Append-only claim inbox ────────────────────────────────────────────────
|
|
825
|
+
|
|
826
|
+
test("a kill between the inbox append and any body write loses nothing", async () => {
|
|
827
|
+
const s = setup();
|
|
828
|
+
try {
|
|
829
|
+
s.seedSession("sess-1", entry([turn("a")]));
|
|
830
|
+
await s.claims.claimFromSessionFile("sess-1", makeLease());
|
|
831
|
+
const bodyBefore = readFileSync(drainFile(s.stateDir, "sess-1"), "utf8");
|
|
832
|
+
|
|
833
|
+
// The owner hands its entry in. Whatever kills the process after this
|
|
834
|
+
// call resolves must not lose the handed-over turns: the inbox record is
|
|
835
|
+
// already durable and the claim body is untouched.
|
|
836
|
+
await s.claims.mergeIntoClaim("sess-1", entry([turn("a"), turn("b", "2026-11-01T00:01:00.000Z")]));
|
|
837
|
+
|
|
838
|
+
assert.equal(readFileSync(drainFile(s.stateDir, "sess-1"), "utf8"), bodyBefore, "mergeIntoClaim must not rewrite the claim body");
|
|
839
|
+
// Fresh instance = the next process after the kill: it folds the inbox.
|
|
840
|
+
const revived = new BufferDrainClaims(s.stateDir, plainIo());
|
|
841
|
+
const claim = await revived.load("sess-1");
|
|
842
|
+
assert.deepEqual(claim?.entry.turns.map((t) => t.content), ["a", "b"], "a handed-over record survives a crash before compaction");
|
|
843
|
+
} finally {
|
|
844
|
+
s.cleanup();
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
test("a kill between the body write and the inbox delete replays nothing", async () => {
|
|
849
|
+
const s = setup();
|
|
850
|
+
try {
|
|
851
|
+
s.seedSession("sess-1", entry([turn("t1")]));
|
|
852
|
+
await s.claims.claimFromSessionFile("sess-1", makeLease());
|
|
853
|
+
await s.claims.mergeIntoClaim("sess-1", entry([turn("t2", "2026-11-01T00:02:00.000Z")]));
|
|
854
|
+
const loaded = await s.claims.load("sess-1");
|
|
855
|
+
assert.ok(loaded);
|
|
856
|
+
assert.deepEqual(loaded.entry.turns.map((t) => t.content), ["t1", "t2"]);
|
|
857
|
+
|
|
858
|
+
// The drainer retains t2 for retry (moves it to retainedTurns) and is
|
|
859
|
+
// killed right after the body write lands, before the inbox is deleted.
|
|
860
|
+
// A replay by identity count alone would re-add t2 to `turns` (its count
|
|
861
|
+
// there is now zero); only an exact consumed-record marker prevents that.
|
|
862
|
+
const base = plainIo();
|
|
863
|
+
const crashingIo = {
|
|
864
|
+
...base,
|
|
865
|
+
write: async (filePath: string, content: string) => {
|
|
866
|
+
await base.write(filePath, content);
|
|
867
|
+
if (filePath === drainFile(s.stateDir, "sess-1")) throw new Error("simulated kill after the body write");
|
|
868
|
+
},
|
|
869
|
+
};
|
|
870
|
+
const crashing = new BufferDrainClaims(s.stateDir, crashingIo);
|
|
871
|
+
await assert.rejects(
|
|
872
|
+
crashing.writeEntry("sess-1", "worker-1", { ...entry([turn("t1")]), retainedTurns: [turn("t2", "2026-11-01T00:02:00.000Z")] }, loaded.entry),
|
|
873
|
+
/simulated kill/,
|
|
874
|
+
);
|
|
875
|
+
assert.equal(existsSync(inboxFile(s.stateDir, "sess-1")), true, "fixture: the kill left the inbox behind");
|
|
876
|
+
|
|
877
|
+
const revived = new BufferDrainClaims(s.stateDir, plainIo());
|
|
878
|
+
const claim = await revived.load("sess-1");
|
|
879
|
+
assert.deepEqual(claim?.entry.turns.map((t) => t.content), ["t1"], "the replayed record adds nothing to turns");
|
|
880
|
+
assert.deepEqual(claim?.entry.retainedTurns?.map((t) => t.content), ["t2"], "t2 stays only in retainedTurns");
|
|
881
|
+
assert.equal((await revived.load("sess-1"))?.entry.turns.length, 1, "repeated folds add nothing");
|
|
882
|
+
} finally {
|
|
883
|
+
s.cleanup();
|
|
884
|
+
}
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
test("a drained turn handed in by a stale owner never reappears through the inbox fold", async () => {
|
|
888
|
+
const s = setup();
|
|
889
|
+
try {
|
|
890
|
+
s.seedSession("sess-1", entry([turn("t1")]));
|
|
891
|
+
await s.claims.claimFromSessionFile("sess-1", makeLease());
|
|
892
|
+
const loaded = await s.claims.load("sess-1");
|
|
893
|
+
assert.ok(loaded);
|
|
894
|
+
await s.claims.writeEntry("sess-1", "worker-1", entry([]), loaded.entry);
|
|
895
|
+
|
|
896
|
+
// The owner's in-memory entry still holds t1: handing it in must not
|
|
897
|
+
// resurrect the extracted turn, through the body or the inbox.
|
|
898
|
+
await s.claims.mergeIntoClaim("sess-1", entry([turn("t1")]));
|
|
899
|
+
|
|
900
|
+
const claim = await s.claims.load("sess-1");
|
|
901
|
+
assert.deepEqual(claim?.entry.turns, [], "the stale copy stays subtracted after the fold");
|
|
902
|
+
const revived = new BufferDrainClaims(s.stateDir, plainIo());
|
|
903
|
+
assert.deepEqual((await revived.load("sess-1"))?.entry.turns, [], "a fresh process folds to the same verdict");
|
|
904
|
+
} finally {
|
|
905
|
+
s.cleanup();
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
test("concurrent appends and a progress write lose nothing and duplicate nothing, and a drainer that lost its lease cannot consume segments", async () => {
|
|
910
|
+
const s = setup();
|
|
911
|
+
try {
|
|
912
|
+
s.seedSession("sess-1", entry([turn("t1")]));
|
|
913
|
+
await s.claims.claimFromSessionFile("sess-1", makeLease());
|
|
914
|
+
const loaded = await s.claims.load("sess-1");
|
|
915
|
+
assert.ok(loaded);
|
|
916
|
+
|
|
917
|
+
// Two live owners hand entries in while the drainer writes progress:
|
|
918
|
+
// all three serialize on the claim lock and converge without loss.
|
|
919
|
+
await Promise.all([
|
|
920
|
+
s.claims.mergeIntoClaim("sess-1", entry([turn("t2", "2026-11-01T00:02:00.000Z")])),
|
|
921
|
+
s.claims.mergeIntoClaim("sess-1", entry([turn("t3", "2026-11-01T00:03:00.000Z")])),
|
|
922
|
+
s.claims.writeEntry("sess-1", "worker-1", entry([]), loaded.entry),
|
|
923
|
+
]);
|
|
924
|
+
// One more hand-in after the interleaving: whatever order the lock chose,
|
|
925
|
+
// the inbox now holds an unconsumed record the fenced drainer must not eat.
|
|
926
|
+
await s.claims.mergeIntoClaim("sess-1", entry([turn("t4", "2026-11-01T00:04:00.000Z")]));
|
|
927
|
+
const claim = await s.claims.load("sess-1");
|
|
928
|
+
assert.ok(claim);
|
|
929
|
+
assert.deepEqual(claim.entry.turns.map((t) => t.content).sort(), ["t2", "t3", "t4"], "handed-in turns all survive the interleaving");
|
|
930
|
+
assert.deepEqual(claim.drained?.map((t) => t.content), ["t1"], "the extracted turn stays tombstoned");
|
|
931
|
+
|
|
932
|
+
// A drainer that lost its lease is fenced before it can compact away the
|
|
933
|
+
// merged records.
|
|
934
|
+
await assert.rejects(
|
|
935
|
+
s.claims.writeEntry("sess-1", "intruder", entry([]), claim.entry),
|
|
936
|
+
/no longer owned by intruder/,
|
|
937
|
+
);
|
|
938
|
+
assert.equal(existsSync(inboxFile(s.stateDir, "sess-1")), true, "the fenced drainer consumed no segments");
|
|
939
|
+
const after = await s.claims.load("sess-1");
|
|
940
|
+
assert.ok(after);
|
|
941
|
+
assert.equal(after.entry.turns.length, 3, "the fenced write changed nothing");
|
|
942
|
+
|
|
943
|
+
// The real owner's next progress write compacts: body holds everything,
|
|
944
|
+
// inbox consumed.
|
|
945
|
+
await s.claims.writeEntry("sess-1", "worker-1", entry([]), after.entry);
|
|
946
|
+
assert.equal(existsSync(inboxFile(s.stateDir, "sess-1")), false, "compaction consumes the inbox after the body write");
|
|
947
|
+
const final = await s.claims.load("sess-1");
|
|
948
|
+
assert.deepEqual(final?.entry.turns, [], "all pending turns are drained");
|
|
949
|
+
assert.deepEqual(final?.drained?.map((t) => t.content).sort(), ["t1", "t2", "t3", "t4"]);
|
|
950
|
+
} finally {
|
|
951
|
+
s.cleanup();
|
|
952
|
+
}
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
test("mergeIntoClaim on a large claim never reads or writes the body", async () => {
|
|
956
|
+
const s = setup();
|
|
957
|
+
try {
|
|
958
|
+
const filler = "x".repeat(750_000);
|
|
959
|
+
const stamps = (i: number): string => `2026-11-01T00:${String(Math.floor(i / 60)).padStart(2, "0")}:${String(i % 60).padStart(2, "0")}.000Z`;
|
|
960
|
+
const bigEntry = entry(Array.from({ length: 40 }, (_, i) => turn(`${filler}-${i}`, stamps(i))));
|
|
961
|
+
s.seedSession("sess-1", bigEntry);
|
|
962
|
+
await s.claims.claimFromSessionFile("sess-1", makeLease());
|
|
963
|
+
const bodyPath = drainFile(s.stateDir, "sess-1");
|
|
964
|
+
assert.ok(readFileSync(bodyPath, "utf8").length > 30_000_000, "fixture: the claim body is ~30 MB");
|
|
965
|
+
|
|
966
|
+
const spied = spyIo();
|
|
967
|
+
const claims = new BufferDrainClaims(s.stateDir, spied.io);
|
|
968
|
+
await claims.mergeIntoClaim("sess-1", entry([turn("new", "2026-11-01T01:00:00.000Z")]));
|
|
969
|
+
|
|
970
|
+
assert.equal(spied.readPaths.filter((p) => p === bodyPath).length, 0, "the body is never read");
|
|
971
|
+
assert.equal(spied.writePaths.filter((p) => p === bodyPath).length, 0, "the body is never written");
|
|
972
|
+
assert.deepEqual(spied.appendPaths, [`${bodyPath}.inbox.jsonl`], "exactly one inbox append");
|
|
973
|
+
} finally {
|
|
974
|
+
s.cleanup();
|
|
975
|
+
}
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
test("a torn last inbox record is skipped and does not corrupt following records", async () => {
|
|
979
|
+
const s = setup();
|
|
980
|
+
try {
|
|
981
|
+
s.seedSession("sess-1", entry([turn("a")]));
|
|
982
|
+
await s.claims.claimFromSessionFile("sess-1", makeLease());
|
|
983
|
+
mkdirSync(path.dirname(inboxFile(s.stateDir, "sess-1")), { recursive: true });
|
|
984
|
+
// A valid record, then a torn tail (a kill mid-append, no trailing
|
|
985
|
+
// newline) — and then a record appended after the garbage by the next
|
|
986
|
+
// owner, fused onto the torn bytes by append semantics.
|
|
987
|
+
writeFileSync(
|
|
988
|
+
inboxFile(s.stateDir, "sess-1"),
|
|
989
|
+
`${record("sess-1", entry([turn("b", "2026-11-01T00:01:00.000Z")]))}\n{"turns":[{"rol` +
|
|
990
|
+
record("sess-1", entry([turn("c", "2026-11-01T00:02:00.000Z")])),
|
|
991
|
+
);
|
|
992
|
+
|
|
993
|
+
const claim = await s.claims.load("sess-1");
|
|
994
|
+
assert.deepEqual(claim?.entry.turns.map((t) => t.content), ["a", "b", "c"], "the torn tail is skipped; records around it survive");
|
|
995
|
+
|
|
996
|
+
// A fresh append after the garbage lands cleanly and folds in order.
|
|
997
|
+
await s.claims.mergeIntoClaim("sess-1", entry([turn("d", "2026-11-01T00:03:00.000Z")]));
|
|
998
|
+
const after = await s.claims.load("sess-1");
|
|
999
|
+
assert.deepEqual(after?.entry.turns.map((t) => t.content), ["a", "b", "c", "d"]);
|
|
1000
|
+
} finally {
|
|
1001
|
+
s.cleanup();
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
test("foldAllInboxes folds every inbox into claim bodies the old format reads", async () => {
|
|
1006
|
+
const s = setup();
|
|
1007
|
+
try {
|
|
1008
|
+
s.seedSession("sess-1", entry([turn("a")]));
|
|
1009
|
+
await s.claims.claimFromSessionFile("sess-1", makeLease());
|
|
1010
|
+
await s.claims.mergeIntoClaim("sess-1", entry([turn("b", "2026-11-01T00:01:00.000Z")]));
|
|
1011
|
+
|
|
1012
|
+
assert.equal(await s.claims.foldAllInboxes(), 1, "one claim folded");
|
|
1013
|
+
assert.equal(existsSync(inboxFile(s.stateDir, "sess-1")), false, "the inbox is consumed");
|
|
1014
|
+
|
|
1015
|
+
// Old-format reader: the plain body file holds every pending turn.
|
|
1016
|
+
const oldView = z
|
|
1017
|
+
.object({ entry: z.object({ turns: z.array(z.object({ content: z.string() })) }) })
|
|
1018
|
+
.parse(JSON.parse(readFileSync(drainFile(s.stateDir, "sess-1"), "utf8")));
|
|
1019
|
+
assert.deepEqual(oldView.entry.turns.map((t) => t.content).sort(), ["a", "b"], "the pre-inbox build sees the folded turns");
|
|
1020
|
+
|
|
1021
|
+
assert.equal(await s.claims.foldAllInboxes(), 0, "a second pass finds nothing to fold");
|
|
1022
|
+
const claim = await s.claims.load("sess-1");
|
|
1023
|
+
assert.deepEqual(claim?.entry.turns.map((t) => t.content), ["a", "b"]);
|
|
1024
|
+
} finally {
|
|
1025
|
+
s.cleanup();
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
test("listings count pending turns from the sidecar and inbox without parsing claim bodies", async () => {
|
|
1030
|
+
const s = setup();
|
|
1031
|
+
try {
|
|
1032
|
+
s.seedSession("sess-1", entry([turn("a")]));
|
|
1033
|
+
await s.claims.claimFromSessionFile("sess-1", makeLease());
|
|
1034
|
+
await s.claims.mergeIntoClaim("sess-1", entry([turn("b", "2026-11-01T00:01:00.000Z")]));
|
|
1035
|
+
const bodyPath = drainFile(s.stateDir, "sess-1");
|
|
1036
|
+
|
|
1037
|
+
const spied = spyIo();
|
|
1038
|
+
const claims = new BufferDrainClaims(s.stateDir, spied.io);
|
|
1039
|
+
const page = await claims.listPage(undefined, 10);
|
|
1040
|
+
assert.deepEqual(page.entries.map((e) => e.turnCount), [2], "the listing counts body and inbox turns");
|
|
1041
|
+
assert.equal(spied.readPaths.filter((p) => p === bodyPath).length, 0, "the body is never parsed for a count");
|
|
1042
|
+
|
|
1043
|
+
const listed = await claims.list();
|
|
1044
|
+
assert.deepEqual(listed.map((e) => e.turnCount), [2]);
|
|
1045
|
+
|
|
1046
|
+
// Fallback: a missing sidecar (pre-inbox claim) parses the body once and
|
|
1047
|
+
// still counts the inbox.
|
|
1048
|
+
rmSync(`${bodyPath}.count`, { force: true });
|
|
1049
|
+
const fallback = await claims.listPage(undefined, 10);
|
|
1050
|
+
assert.deepEqual(fallback.entries.map((e) => e.turnCount), [2], "the body-parse fallback stays exact");
|
|
1051
|
+
} finally {
|
|
1052
|
+
s.cleanup();
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
test("release drops the inbox with the claim and a later claim never inherits it", async () => {
|
|
1057
|
+
const s = setup();
|
|
1058
|
+
try {
|
|
1059
|
+
s.seedSession("sess-1", entry([turn("t1")]));
|
|
1060
|
+
await s.claims.claimFromSessionFile("sess-1", makeLease());
|
|
1061
|
+
await s.claims.mergeIntoClaim("sess-1", entry([turn("t2", "2026-11-01T00:02:00.000Z")]));
|
|
1062
|
+
await s.claims.release("sess-1");
|
|
1063
|
+
assert.equal(existsSync(inboxFile(s.stateDir, "sess-1")), false, "release leaves no orphan inbox");
|
|
1064
|
+
|
|
1065
|
+
// A crash can still leave an orphan inbox next to no body; a fresh claim
|
|
1066
|
+
// for the same key must not replay it.
|
|
1067
|
+
mkdirSync(path.dirname(inboxFile(s.stateDir, "sess-1")), { recursive: true });
|
|
1068
|
+
writeFileSync(inboxFile(s.stateDir, "sess-1"), record("sess-1", entry([turn("stale", "2026-11-01T00:09:00.000Z")])));
|
|
1069
|
+
s.seedSession("sess-1", entry([turn("fresh", "2026-11-01T00:10:00.000Z")]));
|
|
1070
|
+
assert.equal(await s.claims.claimFromSessionFile("sess-1", makeLease()), "claimed");
|
|
1071
|
+
assert.deepEqual((await s.claims.load("sess-1"))?.entry.turns.map((t) => t.content), ["fresh"], "the orphan record is not replayed into the new claim");
|
|
1072
|
+
} finally {
|
|
1073
|
+
s.cleanup();
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
/** Forge the cached counts a same-size, same-mtime rewrite could leave behind. */
|
|
1078
|
+
function forgeSummary(stateDir: string, key: string, counts: { pending: number; drained: number }): void {
|
|
1079
|
+
const bodyPath = drainFile(stateDir, key);
|
|
1080
|
+
const fixed = new Date("2026-11-01T00:00:00.000Z");
|
|
1081
|
+
utimesSync(bodyPath, fixed, fixed);
|
|
1082
|
+
const { size, mtimeMs } = statSync(bodyPath);
|
|
1083
|
+
writeFileSync(`${bodyPath}.count`, JSON.stringify({ key, size, mtimeMs, ...counts }));
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
test("a stale count summary can never make a claim with pending turns read as drained", async () => {
|
|
1087
|
+
const s = setup();
|
|
1088
|
+
try {
|
|
1089
|
+
s.seedSession("sess-1", entry([turn("t1")]));
|
|
1090
|
+
await s.claims.claimFromSessionFile("sess-1", makeLease());
|
|
1091
|
+
await s.claims.releaseLease("sess-1", "worker-1");
|
|
1092
|
+
// The body holds one pending turn and nothing drained; the forged cache
|
|
1093
|
+
// claims the opposite under the body's exact size and mtime.
|
|
1094
|
+
forgeSummary(s.stateDir, "sess-1", { pending: 0, drained: 1 });
|
|
1095
|
+
|
|
1096
|
+
assert.equal(await s.claims.isDrained("sess-1"), false, "the cache alone never answers drained");
|
|
1097
|
+
assert.deepEqual((await s.claims.load("sess-1"))?.entry.turns.map((t) => t.content), ["t1"], "the pending turn is intact");
|
|
1098
|
+
} finally {
|
|
1099
|
+
s.cleanup();
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
test("save hands over, never releases, when a stale summary claims a pending claim is drained", async () => {
|
|
1104
|
+
const s = setup();
|
|
1105
|
+
try {
|
|
1106
|
+
const files = new BufferSessionFiles(s.stateDir, plainIo());
|
|
1107
|
+
s.seedSession("sess-1", entry([turn("t1")]));
|
|
1108
|
+
await files.claims.claimFromSessionFile("sess-1", makeLease());
|
|
1109
|
+
await files.claims.releaseLease("sess-1", "worker-1");
|
|
1110
|
+
forgeSummary(s.stateDir, "sess-1", { pending: 0, drained: 1 });
|
|
1111
|
+
|
|
1112
|
+
const outcome = await files.save("sess-1", entry([turn("t2", "2026-11-01T00:02:00.000Z")]));
|
|
1113
|
+
|
|
1114
|
+
assert.equal(outcome.status, "handed_over", "the owner hands in instead of consuming a tombstone");
|
|
1115
|
+
assert.equal(files.claims.isClaimed("sess-1"), true, "the claim is not released");
|
|
1116
|
+
assert.equal(existsSync(s.sessionFile("sess-1")), false, "no session file replaced the claim");
|
|
1117
|
+
assert.deepEqual((await files.claims.load("sess-1"))?.entry.turns.map((t) => t.content), ["t1", "t2"], "no turn was lost");
|
|
1118
|
+
} finally {
|
|
1119
|
+
s.cleanup();
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
test("a pending claim is judged not drained without parsing its body", async () => {
|
|
1124
|
+
const s = setup();
|
|
1125
|
+
try {
|
|
1126
|
+
s.seedSession("sess-1", entry([turn("t1")]));
|
|
1127
|
+
await s.claims.claimFromSessionFile("sess-1", makeLease());
|
|
1128
|
+
await s.claims.releaseLease("sess-1", "worker-1");
|
|
1129
|
+
const spied = spyIo();
|
|
1130
|
+
const claims = new BufferDrainClaims(s.stateDir, spied.io);
|
|
1131
|
+
|
|
1132
|
+
assert.equal(await claims.isDrained("sess-1"), false);
|
|
1133
|
+
assert.equal(spied.readPaths.filter((p) => p === drainFile(s.stateDir, "sess-1")).length, 0, "the hot path never reads the body");
|
|
1134
|
+
} finally {
|
|
1135
|
+
s.cleanup();
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
test("a finished drain is verified with one real read before it reads as drained", async () => {
|
|
1140
|
+
const s = setup();
|
|
1141
|
+
try {
|
|
1142
|
+
s.seedSession("sess-1", entry([turn("t1")]));
|
|
1143
|
+
await s.claims.claimFromSessionFile("sess-1", makeLease());
|
|
1144
|
+
const loaded = await s.claims.load("sess-1");
|
|
1145
|
+
assert.ok(loaded);
|
|
1146
|
+
await s.claims.writeEntry("sess-1", "worker-1", entry([]), loaded.entry);
|
|
1147
|
+
await s.claims.releaseLease("sess-1", "worker-1");
|
|
1148
|
+
const spied = spyIo();
|
|
1149
|
+
const claims = new BufferDrainClaims(s.stateDir, spied.io);
|
|
1150
|
+
|
|
1151
|
+
assert.equal(await claims.isDrained("sess-1"), true);
|
|
1152
|
+
assert.equal(spied.readPaths.filter((p) => p === drainFile(s.stateDir, "sess-1")).length, 1, "the TRUE answer parses the body once");
|
|
1153
|
+
} finally {
|
|
1154
|
+
s.cleanup();
|
|
1155
|
+
}
|
|
1156
|
+
});
|