@remnic/core 9.3.712 → 9.3.713
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-boundary.d.ts +1 -1
- package/dist/access-boundary.js +2 -2
- package/dist/access-cli.js +5 -5
- package/dist/access-http.d.ts +1 -1
- package/dist/access-http.js +7 -7
- package/dist/access-mcp.d.ts +7 -1
- package/dist/access-mcp.js +6 -6
- package/dist/access-operations-batch.js +3 -3
- package/dist/access-operations.d.ts +1 -1
- package/dist/access-operations.js +5 -5
- package/dist/access-schema.d.ts +10 -0
- package/dist/access-schema.js +1 -1
- package/dist/{access-service-Dx2rSJjH.d.ts → access-service-DwVmvgBg.d.ts} +8 -1
- package/dist/access-service.d.ts +1 -1
- package/dist/access-service.js +1 -1
- package/dist/access-surface-catalog.d.ts +1 -1
- package/dist/{chunk-I3BT2IDW.js → chunk-DJNKN3XY.js} +31 -21
- package/dist/chunk-DJNKN3XY.js.map +1 -0
- package/dist/{chunk-NUWZFSKC.js → chunk-FVZ2ISIX.js} +9 -1
- package/dist/chunk-FVZ2ISIX.js.map +1 -0
- package/dist/{chunk-5KQCOIPW.js → chunk-JC3MIKR4.js} +4 -4
- package/dist/{chunk-ZVDRL6SK.js → chunk-RMT7PAVW.js} +2 -2
- package/dist/{chunk-YVWZW37D.js → chunk-UHN2TNSB.js} +25 -2
- package/dist/chunk-UHN2TNSB.js.map +1 -0
- package/dist/{chunk-Z3B2YW56.js → chunk-VUNI6Y3D.js} +4 -4
- package/dist/{chunk-A2R5NV6O.js → chunk-VW24K2Q4.js} +40 -25
- package/dist/chunk-VW24K2Q4.js.map +1 -0
- package/dist/{chunk-AMXOCZHE.js → chunk-ZDUF7K5V.js} +3 -3
- package/dist/{cli-DTWRoJEI.d.ts → cli-DfDMJqPP.d.ts} +1 -1
- package/dist/cli.d.ts +2 -2
- package/dist/cli.js +8 -8
- package/dist/index.d.ts +2 -2
- package/dist/index.js +8 -8
- package/dist/mcp-memory-inspector-app.d.ts +1 -1
- package/dist/schemas.d.ts +22 -22
- package/dist/transfer/types.d.ts +12 -12
- package/package.json +2 -2
- package/src/access-http.ts +45 -19
- package/src/access-mcp.test.ts +1 -0
- package/src/access-mcp.ts +34 -17
- package/src/access-schema.ts +8 -0
- package/src/access-service-observe-idempotency.test.ts +308 -0
- package/src/access-service.ts +63 -3
- package/dist/chunk-A2R5NV6O.js.map +0 -1
- package/dist/chunk-I3BT2IDW.js.map +0 -1
- package/dist/chunk-NUWZFSKC.js.map +0 -1
- package/dist/chunk-YVWZW37D.js.map +0 -1
- /package/dist/{chunk-5KQCOIPW.js.map → chunk-JC3MIKR4.js.map} +0 -0
- /package/dist/{chunk-ZVDRL6SK.js.map → chunk-RMT7PAVW.js.map} +0 -0
- /package/dist/{chunk-Z3B2YW56.js.map → chunk-VUNI6Y3D.js.map} +0 -0
- /package/dist/{chunk-AMXOCZHE.js.map → chunk-ZDUF7K5V.js.map} +0 -0
package/src/access-mcp.test.ts
CHANGED
|
@@ -477,6 +477,7 @@ test("MCP session override is injected only into tools that accept sessionKey",
|
|
|
477
477
|
namespace: undefined,
|
|
478
478
|
authenticatedPrincipal: undefined,
|
|
479
479
|
skipExtraction: false,
|
|
480
|
+
idempotencyKey: undefined,
|
|
480
481
|
cwd: undefined,
|
|
481
482
|
projectTag: undefined,
|
|
482
483
|
});
|
package/src/access-mcp.ts
CHANGED
|
@@ -50,6 +50,12 @@ type McpRequestOptions = {
|
|
|
50
50
|
sessionKeyOverride?: string;
|
|
51
51
|
sessionId?: string;
|
|
52
52
|
correlationId?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Write-quota enforcer forwarded into observe's idempotency lock
|
|
55
|
+
* (issue #1649). Only the MCP-over-HTTP transport supplies this (it owns
|
|
56
|
+
* the write-rate-limit window); the standalone MCP server has no quota.
|
|
57
|
+
*/
|
|
58
|
+
enforceWriteQuota?: () => void | Promise<void>;
|
|
53
59
|
};
|
|
54
60
|
|
|
55
61
|
type McpTool = {
|
|
@@ -1156,6 +1162,11 @@ export class EngramMcpServer {
|
|
|
1156
1162
|
},
|
|
1157
1163
|
description: "Conversation messages to observe",
|
|
1158
1164
|
},
|
|
1165
|
+
idempotencyKey: {
|
|
1166
|
+
type: "string",
|
|
1167
|
+
description:
|
|
1168
|
+
"Optional idempotency key (issue #1649). Deduplicates a retried observe POST server-side so the batch is ingested once even when the HTTP response is lost. Reusing the key with a different payload is rejected.",
|
|
1169
|
+
},
|
|
1159
1170
|
namespace: { type: "string" },
|
|
1160
1171
|
skipExtraction: { type: "boolean" },
|
|
1161
1172
|
cwd: { type: "string", description: "Working directory for auto git-context resolution." },
|
|
@@ -2374,7 +2385,7 @@ export class EngramMcpServer {
|
|
|
2374
2385
|
argumentsObject = { ...argumentsObject, sessionKey: options.sessionKeyOverride };
|
|
2375
2386
|
}
|
|
2376
2387
|
const effectivePrincipal = options?.principalOverride ?? this.authenticatedPrincipal;
|
|
2377
|
-
const result = await this.callTool(name, argumentsObject, effectivePrincipal, options?.sessionId);
|
|
2388
|
+
const result = await this.callTool(name, argumentsObject, effectivePrincipal, options?.sessionId, options?.enforceWriteQuota);
|
|
2378
2389
|
return {
|
|
2379
2390
|
jsonrpc: "2.0",
|
|
2380
2391
|
id,
|
|
@@ -2562,7 +2573,7 @@ export class EngramMcpServer {
|
|
|
2562
2573
|
}));
|
|
2563
2574
|
}
|
|
2564
2575
|
|
|
2565
|
-
private async callTool(name: string, args: Record<string, unknown>, effectivePrincipal?: string, mcpSessionId?: string): Promise<unknown> {
|
|
2576
|
+
private async callTool(name: string, args: Record<string, unknown>, effectivePrincipal?: string, mcpSessionId?: string, enforceWriteQuota?: () => void | Promise<void>): Promise<unknown> {
|
|
2566
2577
|
// Migrated operations dispatch through the access boundary (issue #1525):
|
|
2567
2578
|
// one registry entry owns schema validation, normalization (rules
|
|
2568
2579
|
// 17/28/36/48/51), and error mapping for every surface. The switch
|
|
@@ -3104,21 +3115,27 @@ export class EngramMcpServer {
|
|
|
3104
3115
|
);
|
|
3105
3116
|
case "engram.observe": {
|
|
3106
3117
|
const body = parseMcpRequest("observe", args);
|
|
3107
|
-
return this.service.observe(
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3118
|
+
return this.service.observe(
|
|
3119
|
+
{
|
|
3120
|
+
sessionKey: body.sessionKey,
|
|
3121
|
+
messages: body.messages.map((message) => ({
|
|
3122
|
+
role: message.role,
|
|
3123
|
+
content: message.content,
|
|
3124
|
+
parts: message.parts ?? undefined,
|
|
3125
|
+
rawContent: message.rawContent ?? undefined,
|
|
3126
|
+
sourceFormat: message.sourceFormat ?? undefined,
|
|
3127
|
+
})),
|
|
3128
|
+
skipExtraction: body.skipExtraction === true,
|
|
3129
|
+
idempotencyKey: body.idempotencyKey,
|
|
3130
|
+
namespace: body.namespace,
|
|
3131
|
+
authenticatedPrincipal: effectivePrincipal,
|
|
3132
|
+
cwd: body.cwd,
|
|
3133
|
+
projectTag: body.projectTag,
|
|
3134
|
+
},
|
|
3135
|
+
// Forward the transport's write-quota enforcer into observe's
|
|
3136
|
+
// idempotency lock so a replay is never 429'd (issue #1649).
|
|
3137
|
+
enforceWriteQuota ? { enforceWriteQuota } : undefined,
|
|
3138
|
+
);
|
|
3122
3139
|
}
|
|
3123
3140
|
case "engram.lcm_search":
|
|
3124
3141
|
return this.service.lcmSearch({
|
package/src/access-schema.ts
CHANGED
|
@@ -216,6 +216,14 @@ export const observeRequestSchema = z.object({
|
|
|
216
216
|
messages: z.array(messageSchema).min(1, "messages must be a non-empty array"),
|
|
217
217
|
namespace: namespaceSchema,
|
|
218
218
|
skipExtraction: z.boolean().optional(),
|
|
219
|
+
/**
|
|
220
|
+
* Optional idempotency key for server-side dedup of retried observe POSTs
|
|
221
|
+
* (issue #1649). A retry that reaches the daemon after the first attempt
|
|
222
|
+
* already processed is replayed from cache instead of re-ingested. When the
|
|
223
|
+
* key is reused with a DIFFERENT payload the request is rejected as a
|
|
224
|
+
* conflict (same contract as memory_store/suggestion_submit).
|
|
225
|
+
*/
|
|
226
|
+
idempotencyKey: idempotencyKeySchema,
|
|
219
227
|
/** Working directory for auto git-context resolution (issue #569). */
|
|
220
228
|
cwd: z.string().trim().min(1, "cwd must be non-empty when provided").max(2048).optional(),
|
|
221
229
|
/**
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #1649: a retried observe POST (same `idempotencyKey`) must be deduplicated
|
|
3
|
+
* server-side — the batch is ingested exactly once even when the HTTP response
|
|
4
|
+
* is lost and the client replays the request. Without the key the daemon
|
|
5
|
+
* re-runs every side effect under `skipDedupeCheck: true` and the turn is
|
|
6
|
+
* queued for extraction twice.
|
|
7
|
+
*
|
|
8
|
+
* Verified here against the `EngramAccessService.observe` path with a stub
|
|
9
|
+
* orchestrator that records every namespace-bearing side effect (LCM enqueue +
|
|
10
|
+
* extraction replay), the same probe shape used by the #1495 observe-scope
|
|
11
|
+
* tests. The idempotency store is filesystem-backed, so `memoryDir` is a real
|
|
12
|
+
* temp directory (not the synthetic path the scope tests use, which never touch
|
|
13
|
+
* the store because they omit the key).
|
|
14
|
+
*/
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import test from "node:test";
|
|
20
|
+
|
|
21
|
+
import { EngramAccessService } from "./access-service.js";
|
|
22
|
+
import { EngramAccessInputError } from "./access-service.js";
|
|
23
|
+
import { Orchestrator } from "./orchestrator.js";
|
|
24
|
+
import type { EngramAccessObserveRequest } from "./access-service.js";
|
|
25
|
+
import type { CodingContext, PluginConfig } from "./types.js";
|
|
26
|
+
|
|
27
|
+
interface ObserveProbe {
|
|
28
|
+
orch: Orchestrator;
|
|
29
|
+
contexts: Map<string, CodingContext>;
|
|
30
|
+
lcmCalls: Array<{ sessionKey: string }>;
|
|
31
|
+
extractionCalls: Array<{
|
|
32
|
+
sessionKeys: string[];
|
|
33
|
+
writeNamespaceOverride?: string;
|
|
34
|
+
principalOverride?: string;
|
|
35
|
+
}>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeObserveProbe(memoryDir: string): ObserveProbe {
|
|
39
|
+
const contexts = new Map<string, CodingContext>();
|
|
40
|
+
const lcmCalls: ObserveProbe["lcmCalls"] = [];
|
|
41
|
+
const extractionCalls: ObserveProbe["extractionCalls"] = [];
|
|
42
|
+
|
|
43
|
+
const config = {
|
|
44
|
+
namespacesEnabled: true,
|
|
45
|
+
defaultNamespace: "default",
|
|
46
|
+
sharedNamespace: "shared",
|
|
47
|
+
namespacePolicies: [
|
|
48
|
+
{ name: "pi-geek", readPrincipals: ["pi-geek"], writePrincipals: ["pi-geek"] },
|
|
49
|
+
],
|
|
50
|
+
codingMode: { projectScope: true },
|
|
51
|
+
memoryDir,
|
|
52
|
+
// Disable objective-state snapshots so the probe doesn't need a writable
|
|
53
|
+
// storage backend — the idempotency contract under test is LCM + extraction.
|
|
54
|
+
objectiveStateMemoryEnabled: false,
|
|
55
|
+
objectiveStateSnapshotWritesEnabled: false,
|
|
56
|
+
principalFromSessionKeyMode: "prefix",
|
|
57
|
+
principalFromSessionKeyRules: [{ match: "pi-geek:", principal: "pi-geek" }],
|
|
58
|
+
recallCrossNamespaceBudgetEnabled: false,
|
|
59
|
+
recallCrossNamespaceBudgetWindowMs: 60_000,
|
|
60
|
+
recallCrossNamespaceBudgetSoftLimit: 10,
|
|
61
|
+
recallCrossNamespaceBudgetHardLimit: 30,
|
|
62
|
+
} as unknown as PluginConfig;
|
|
63
|
+
|
|
64
|
+
const orch = {
|
|
65
|
+
config,
|
|
66
|
+
getCodingContextForSession: (sk: string | undefined) =>
|
|
67
|
+
(sk ? contexts.get(sk) : null) ?? null,
|
|
68
|
+
setCodingContextForSession: (sk: string, ctx: CodingContext | null) => {
|
|
69
|
+
if (ctx === null) contexts.delete(sk);
|
|
70
|
+
else contexts.set(sk, ctx);
|
|
71
|
+
},
|
|
72
|
+
applyCodingNamespaceOverlay: (sk: string | undefined, base: string) =>
|
|
73
|
+
Orchestrator.prototype.applyCodingNamespaceOverlay.call(orch, sk, base),
|
|
74
|
+
getStorage: async (ns: string) => ({ dir: join(memoryDir, "storage", ns) }),
|
|
75
|
+
lcmEngine: {
|
|
76
|
+
enabled: true,
|
|
77
|
+
enqueueObserveMessages: (sessionKey: string) => {
|
|
78
|
+
lcmCalls.push({ sessionKey });
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
ingestReplayBatch: async (
|
|
82
|
+
turns: Array<{ sessionKey: string }>,
|
|
83
|
+
options: { writeNamespaceOverride?: string; principalOverride?: string } = {},
|
|
84
|
+
) => {
|
|
85
|
+
extractionCalls.push({
|
|
86
|
+
sessionKeys: turns.map((t) => t.sessionKey),
|
|
87
|
+
writeNamespaceOverride: options.writeNamespaceOverride,
|
|
88
|
+
principalOverride: options.principalOverride,
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
} as unknown as Orchestrator;
|
|
92
|
+
|
|
93
|
+
return { orch, contexts, lcmCalls, extractionCalls };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function observeRequest(
|
|
97
|
+
overrides: Partial<EngramAccessObserveRequest>,
|
|
98
|
+
): EngramAccessObserveRequest {
|
|
99
|
+
return {
|
|
100
|
+
sessionKey: "pi-geek:abc123",
|
|
101
|
+
messages: [
|
|
102
|
+
{ role: "user", content: "what database are we using?" },
|
|
103
|
+
{ role: "assistant", content: "we use postgres for the primary store" },
|
|
104
|
+
],
|
|
105
|
+
...overrides,
|
|
106
|
+
} as EngramAccessObserveRequest;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
test("#1649 a retried observe with the same idempotencyKey is deduplicated to a single ingest", async () => {
|
|
110
|
+
const memoryDir = mkdtempSync(join(tmpdir(), "remnic-observe-idem-"));
|
|
111
|
+
try {
|
|
112
|
+
const probe = makeObserveProbe(memoryDir);
|
|
113
|
+
const service = new EngramAccessService(probe.orch);
|
|
114
|
+
const key = "observe-batch-pi-geek-abc123-#1";
|
|
115
|
+
|
|
116
|
+
const first = await service.observe(observeRequest({ idempotencyKey: key }));
|
|
117
|
+
// First attempt is a real ingest.
|
|
118
|
+
assert.equal(first.idempotencyReplay, undefined, "first attempt is not a replay");
|
|
119
|
+
assert.equal(probe.extractionCalls.length, 1, "first attempt ingests once");
|
|
120
|
+
assert.equal(probe.lcmCalls.length, 1, "first attempt archives LCM once");
|
|
121
|
+
|
|
122
|
+
// Retry: same key, same payload — the daemon already processed the body, so
|
|
123
|
+
// this is the response-lost-after-process case the issue describes.
|
|
124
|
+
const second = await service.observe(observeRequest({ idempotencyKey: key }));
|
|
125
|
+
assert.equal(second.idempotencyReplay, true, "retry is served from the cache");
|
|
126
|
+
assert.equal(second.accepted, first.accepted, "retry reports the same accepted count");
|
|
127
|
+
assert.equal(
|
|
128
|
+
second.effectiveNamespace,
|
|
129
|
+
first.effectiveNamespace,
|
|
130
|
+
"retry reports the same effective namespace",
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
// The defining contract: NO second ingest, NO second LCM archive.
|
|
134
|
+
assert.equal(probe.extractionCalls.length, 1, "retry must not queue extraction a second time");
|
|
135
|
+
assert.equal(probe.lcmCalls.length, 1, "retry must not archive LCM a second time");
|
|
136
|
+
} finally {
|
|
137
|
+
rmSync(memoryDir, { recursive: true, force: true });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("#1649 observe without an idempotencyKey is never deduplicated (backward compatible)", async () => {
|
|
142
|
+
const memoryDir = mkdtempSync(join(tmpdir(), "remnic-observe-nokey-"));
|
|
143
|
+
try {
|
|
144
|
+
const probe = makeObserveProbe(memoryDir);
|
|
145
|
+
const service = new EngramAccessService(probe.orch);
|
|
146
|
+
|
|
147
|
+
await service.observe(observeRequest({}));
|
|
148
|
+
await service.observe(observeRequest({}));
|
|
149
|
+
|
|
150
|
+
assert.equal(probe.extractionCalls.length, 2, "no key ⇒ both calls ingest");
|
|
151
|
+
assert.equal(probe.lcmCalls.length, 2, "no key ⇒ both calls archive LCM");
|
|
152
|
+
} finally {
|
|
153
|
+
rmSync(memoryDir, { recursive: true, force: true });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("#1649 reusing an idempotencyKey with a divergent payload is rejected as a conflict", async () => {
|
|
158
|
+
const memoryDir = mkdtempSync(join(tmpdir(), "remnic-observe-conflict-"));
|
|
159
|
+
try {
|
|
160
|
+
const probe = makeObserveProbe(memoryDir);
|
|
161
|
+
const service = new EngramAccessService(probe.orch);
|
|
162
|
+
const key = "observe-batch-pi-geek-abc123-#2";
|
|
163
|
+
|
|
164
|
+
await service.observe(observeRequest({ idempotencyKey: key }));
|
|
165
|
+
|
|
166
|
+
// Same key, DIFFERENT messages — a stale key must never silently mask a
|
|
167
|
+
// unrelated batch. Same contract as memory_store/suggestion_submit.
|
|
168
|
+
await assert.rejects(
|
|
169
|
+
service.observe(
|
|
170
|
+
observeRequest({
|
|
171
|
+
idempotencyKey: key,
|
|
172
|
+
messages: [
|
|
173
|
+
{ role: "user", content: "completely different question" },
|
|
174
|
+
{ role: "assistant", content: "completely different answer" },
|
|
175
|
+
],
|
|
176
|
+
}),
|
|
177
|
+
),
|
|
178
|
+
(err: unknown) =>
|
|
179
|
+
err instanceof EngramAccessInputError &&
|
|
180
|
+
/idempotencyKey reuse conflict/.test(err.message),
|
|
181
|
+
"divergent payload under a reused key must throw a conflict",
|
|
182
|
+
);
|
|
183
|
+
} finally {
|
|
184
|
+
rmSync(memoryDir, { recursive: true, force: true });
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("#1649 review fix: enforceWriteQuota runs only on a cache miss, never on a replay", async () => {
|
|
189
|
+
const memoryDir = mkdtempSync(join(tmpdir(), "remnic-observe-quota-hook-"));
|
|
190
|
+
try {
|
|
191
|
+
const probe = makeObserveProbe(memoryDir);
|
|
192
|
+
const service = new EngramAccessService(probe.orch);
|
|
193
|
+
const key = "observe-batch-pi-geek-abc123-#3";
|
|
194
|
+
let quotaChecks = 0;
|
|
195
|
+
const enforceWriteQuota = () => {
|
|
196
|
+
quotaChecks += 1;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
await service.observe(observeRequest({ idempotencyKey: key }), { enforceWriteQuota });
|
|
200
|
+
assert.equal(quotaChecks, 1, "quota is enforced exactly once on the real ingest");
|
|
201
|
+
|
|
202
|
+
const replay = await service.observe(observeRequest({ idempotencyKey: key }), { enforceWriteQuota });
|
|
203
|
+
assert.equal(replay.idempotencyReplay, true, "second call is a replay");
|
|
204
|
+
assert.equal(quotaChecks, 1, "quota is NOT re-checked on a replay (response-lost retry must not 429)");
|
|
205
|
+
assert.equal(probe.extractionCalls.length, 1, "replay did not re-ingest");
|
|
206
|
+
} finally {
|
|
207
|
+
rmSync(memoryDir, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("#1649 review fix: the same idempotencyKey under a different principal is a conflict, not a silent replay", async () => {
|
|
212
|
+
const memoryDir = mkdtempSync(join(tmpdir(), "remnic-observe-principal-conflict-"));
|
|
213
|
+
try {
|
|
214
|
+
const probe = makeObserveProbe(memoryDir);
|
|
215
|
+
const service = new EngramAccessService(probe.orch);
|
|
216
|
+
const key = "observe-batch-shared-key-#4";
|
|
217
|
+
|
|
218
|
+
// Principal alice ingests under the key.
|
|
219
|
+
await service.observe(
|
|
220
|
+
observeRequest({ idempotencyKey: key, authenticatedPrincipal: "alice" }),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// A different principal reusing the same key + same body must NOT replay
|
|
224
|
+
// alice's cached response — the fingerprint folds in authenticatedPrincipal,
|
|
225
|
+
// so this is a conflict (defends against cross-identity replay when the
|
|
226
|
+
// principal is supplied out-of-band via HTTP/MCP auth).
|
|
227
|
+
await assert.rejects(
|
|
228
|
+
service.observe(
|
|
229
|
+
observeRequest({ idempotencyKey: key, authenticatedPrincipal: "bob" }),
|
|
230
|
+
),
|
|
231
|
+
(err: unknown) =>
|
|
232
|
+
err instanceof EngramAccessInputError &&
|
|
233
|
+
/idempotencyKey reuse conflict/.test(err.message),
|
|
234
|
+
"cross-principal key reuse must throw a conflict",
|
|
235
|
+
);
|
|
236
|
+
} finally {
|
|
237
|
+
rmSync(memoryDir, { recursive: true, force: true });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("#1649 review fix: rebinding the session's ambient coding context makes the same key a conflict", async () => {
|
|
242
|
+
const memoryDir = mkdtempSync(join(tmpdir(), "remnic-observe-ambient-scope-"));
|
|
243
|
+
try {
|
|
244
|
+
const probe = makeObserveProbe(memoryDir);
|
|
245
|
+
const service = new EngramAccessService(probe.orch);
|
|
246
|
+
const key = "observe-batch-ambient-scope-#5";
|
|
247
|
+
const req = observeRequest({ idempotencyKey: key });
|
|
248
|
+
|
|
249
|
+
// No explicit namespace/cwd/projectTag and no ambient context yet — the
|
|
250
|
+
// scope resolves to the default store. This caches the response.
|
|
251
|
+
await service.observe(req);
|
|
252
|
+
assert.equal(probe.extractionCalls.length, 1, "first observe ingests once");
|
|
253
|
+
|
|
254
|
+
// The session is now rebound to a coding project by an external caller
|
|
255
|
+
// (e.g. a memory_store with cwd, or a direct setCodingContextForSession).
|
|
256
|
+
// The resolved writeNamespace changed, so reusing the same key + same body
|
|
257
|
+
// must NOT silently replay — it is a conflict.
|
|
258
|
+
probe.contexts.set(req.sessionKey, {
|
|
259
|
+
projectId: "tag:remnic",
|
|
260
|
+
branch: null,
|
|
261
|
+
rootPath: "/projects/remnic",
|
|
262
|
+
defaultBranch: null,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await assert.rejects(
|
|
266
|
+
service.observe(req),
|
|
267
|
+
(err: unknown) =>
|
|
268
|
+
err instanceof EngramAccessInputError &&
|
|
269
|
+
/idempotencyKey reuse conflict/.test(err.message),
|
|
270
|
+
"key reuse after ambient scope rebind must throw a conflict, not silently replay",
|
|
271
|
+
);
|
|
272
|
+
} finally {
|
|
273
|
+
rmSync(memoryDir, { recursive: true, force: true });
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("#1649 review fix: projectTag observe replays correctly despite scope resolution side effects", async () => {
|
|
278
|
+
// The fingerprint folds in the RESOLVED writeNamespace (not the raw ambient
|
|
279
|
+
// context). resolveMemoryScopePlan seeds the session context from projectTag
|
|
280
|
+
// on the first call; the replay reads the SAME seeded context and resolves the
|
|
281
|
+
// SAME writeNamespace. This proves the fingerprint is stable across replays
|
|
282
|
+
// even though the scope resolution mutates session state on the first call.
|
|
283
|
+
const memoryDir = mkdtempSync(join(tmpdir(), "remnic-observe-tag-replay-"));
|
|
284
|
+
try {
|
|
285
|
+
const probe = makeObserveProbe(memoryDir);
|
|
286
|
+
const service = new EngramAccessService(probe.orch);
|
|
287
|
+
const key = "observe-batch-tag-replay-#6";
|
|
288
|
+
const req = observeRequest({ idempotencyKey: key, projectTag: "remnic" });
|
|
289
|
+
|
|
290
|
+
const first = await service.observe(req);
|
|
291
|
+
assert.equal(first.idempotencyReplay, undefined, "first attempt is a real ingest");
|
|
292
|
+
|
|
293
|
+
// The first observe's resolveMemoryScopePlan seeded a coding context for
|
|
294
|
+
// this session. Verify it was actually set (the side effect we depend on).
|
|
295
|
+
assert.ok(
|
|
296
|
+
probe.contexts.get(req.sessionKey),
|
|
297
|
+
"first observe seeded a coding context via resolveMemoryScopePlan",
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// Retry with the same key + same body + same projectTag — the pre-resolved
|
|
301
|
+
// writeNamespace matches because the seeded context produces the same scope.
|
|
302
|
+
const replay = await service.observe(req);
|
|
303
|
+
assert.equal(replay.idempotencyReplay, true, "projectTag observe replays despite scope-resolution side effects");
|
|
304
|
+
assert.equal(probe.extractionCalls.length, 1, "replay did not re-ingest");
|
|
305
|
+
} finally {
|
|
306
|
+
rmSync(memoryDir, { recursive: true, force: true });
|
|
307
|
+
}
|
|
308
|
+
});
|
package/src/access-service.ts
CHANGED
|
@@ -1008,6 +1008,8 @@ export interface EngramAccessObserveRequest {
|
|
|
1008
1008
|
namespace?: string;
|
|
1009
1009
|
authenticatedPrincipal?: string;
|
|
1010
1010
|
skipExtraction?: boolean;
|
|
1011
|
+
/** Optional idempotency key (issue #1649): a retried POST with the same key is deduplicated server-side; divergent reuse is a conflict. */
|
|
1012
|
+
idempotencyKey?: string;
|
|
1011
1013
|
/**
|
|
1012
1014
|
* Working directory of the calling agent session (issue #569 wiring).
|
|
1013
1015
|
* When provided and no `codingContext` is attached for this session,
|
|
@@ -1067,6 +1069,8 @@ export interface EngramAccessObserveResponse {
|
|
|
1067
1069
|
scopeDebug?: EngramAccessScopeDebug;
|
|
1068
1070
|
lcmArchived: boolean;
|
|
1069
1071
|
extractionQueued: boolean;
|
|
1072
|
+
/** True when replayed from the idempotency cache (issue #1649); lets the HTTP surface skip the write-quota slot, matching memory_store replay semantics. */
|
|
1073
|
+
idempotencyReplay?: boolean;
|
|
1070
1074
|
}
|
|
1071
1075
|
|
|
1072
1076
|
export interface EngramAccessLcmSearchRequest {
|
|
@@ -2523,8 +2527,8 @@ export class EngramAccessService {
|
|
|
2523
2527
|
}
|
|
2524
2528
|
}
|
|
2525
2529
|
|
|
2526
|
-
private async handleIdempotentWrite<T extends
|
|
2527
|
-
operation:
|
|
2530
|
+
private async handleIdempotentWrite<T extends { idempotencyReplay?: boolean }>(options: {
|
|
2531
|
+
operation: string;
|
|
2528
2532
|
idempotencyKey?: string;
|
|
2529
2533
|
requestFingerprint: unknown;
|
|
2530
2534
|
skip?: boolean;
|
|
@@ -5700,7 +5704,63 @@ export class EngramAccessService {
|
|
|
5700
5704
|
return shapeMemorySummary(memory, baseDir, disclosure, rawExcerpts);
|
|
5701
5705
|
}
|
|
5702
5706
|
|
|
5703
|
-
async observe(
|
|
5707
|
+
async observe(
|
|
5708
|
+
request: EngramAccessObserveRequest,
|
|
5709
|
+
hooks?: { enforceWriteQuota?: () => void | Promise<void> },
|
|
5710
|
+
): Promise<EngramAccessObserveResponse> {
|
|
5711
|
+
// Issue #1649: dedup retried observe POSTs server-side. A retry with the
|
|
5712
|
+
// same `idempotencyKey` replays the cached response and skips every side
|
|
5713
|
+
// effect (LCM/extraction/objective-state); divergent payload OR principal
|
|
5714
|
+
// reuse is a conflict (fingerprint folds in authenticatedPrincipal, so a
|
|
5715
|
+
// cross-identity replay can never be silent). `enforceWriteQuota` runs as
|
|
5716
|
+
// `beforeExecute` inside the lock — only on a real miss — so a response-lost
|
|
5717
|
+
// retry never 429s even when the first attempt filled the window (#1434).
|
|
5718
|
+
//
|
|
5719
|
+
// The fingerprint folds in the EFFECTIVE coding context (#1649 codex P2):
|
|
5720
|
+
// runObserve's scope can be derived from the session's ATTACHED coding
|
|
5721
|
+
// context (which takes PRECEDENCE over per-call cwd/projectTag per
|
|
5722
|
+
// resolveMemoryScopePlan), so neither cwd/projectTag NOR the raw ambient
|
|
5723
|
+
// context alone fully captures the effective scope. The effective context
|
|
5724
|
+
// is computed the SAME way resolveMemoryScopePlan does — session-attached
|
|
5725
|
+
// first, per-call cwd/projectTag fallback — but READ-ONLY: it does NOT seed
|
|
5726
|
+
// the session, so a conflict/quota-rejection path leaves no orphaned binding.
|
|
5727
|
+
// The value is stable across replays because resolveMemoryScopePlan's
|
|
5728
|
+
// seeding writes the IDENTICAL context that resolveCodingContextFromOptions
|
|
5729
|
+
// derives — so getCodingContextForSession returns the same object on the
|
|
5730
|
+
// replay as resolveCodingContextFromOptions returned on the first call.
|
|
5731
|
+
// Only computed when a key is present; non-keyed observes skip the work.
|
|
5732
|
+
const idempotencyKey = request.idempotencyKey?.trim();
|
|
5733
|
+
let effectiveCodingContext: CodingContext | null | undefined;
|
|
5734
|
+
if (idempotencyKey && !(typeof request.namespace === "string" && request.namespace.trim().length > 0)) {
|
|
5735
|
+
// Explicit namespace pins the scope (resolveMemoryScopePlan returns early),
|
|
5736
|
+
// so the coding context is irrelevant — skip it to avoid false conflicts
|
|
5737
|
+
// when the session context changes under a namespace-pinned observe.
|
|
5738
|
+
// resolveCodingContextFromOptions self-gates on projectScope (no scattered
|
|
5739
|
+
// config read here).
|
|
5740
|
+
effectiveCodingContext =
|
|
5741
|
+
(typeof this.orchestrator.getCodingContextForSession === "function"
|
|
5742
|
+
? this.orchestrator.getCodingContextForSession(request.sessionKey)
|
|
5743
|
+
: null) ?? (await this.resolveCodingContextFromOptions(request));
|
|
5744
|
+
}
|
|
5745
|
+
return this.handleIdempotentWrite<EngramAccessObserveResponse>({
|
|
5746
|
+
operation: "observe",
|
|
5747
|
+
idempotencyKey: request.idempotencyKey,
|
|
5748
|
+
requestFingerprint: {
|
|
5749
|
+
sessionKey: request.sessionKey,
|
|
5750
|
+
messages: request.messages,
|
|
5751
|
+
namespace: request.namespace,
|
|
5752
|
+
skipExtraction: request.skipExtraction,
|
|
5753
|
+
authenticatedPrincipal: request.authenticatedPrincipal,
|
|
5754
|
+
cwd: request.cwd,
|
|
5755
|
+
projectTag: request.projectTag,
|
|
5756
|
+
effectiveCodingContext: effectiveCodingContext ?? null,
|
|
5757
|
+
},
|
|
5758
|
+
beforeExecute: hooks?.enforceWriteQuota,
|
|
5759
|
+
execute: () => this.runObserve(request),
|
|
5760
|
+
});
|
|
5761
|
+
}
|
|
5762
|
+
|
|
5763
|
+
private async runObserve(request: EngramAccessObserveRequest): Promise<EngramAccessObserveResponse> {
|
|
5704
5764
|
if (!request.sessionKey || typeof request.sessionKey !== "string" || request.sessionKey.trim().length === 0) {
|
|
5705
5765
|
throw new EngramAccessInputError("sessionKey is required and must be a non-empty string");
|
|
5706
5766
|
}
|