Skip to content

Commit f9f19b5

Browse files
mason: fix plugin integrity races
Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
1 parent b0619b3 commit f9f19b5

16 files changed

Lines changed: 787 additions & 88 deletions

packages/plugin/src/features/magic-context/memory/storage-memory-embeddings.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ interface StoredModelIdRow {
1616
}
1717

1818
const saveEmbeddingStatements = new WeakMap<Database, PreparedStatement>();
19+
const saveEmbeddingIfHashMatchesStatements = new WeakMap<Database, PreparedStatement>();
1920
const loadAllEmbeddingsStatements = new WeakMap<Database, PreparedStatement>();
2021
const deleteEmbeddingStatements = new WeakMap<Database, PreparedStatement>();
2122
const getStoredModelIdStatements = new WeakMap<Database, PreparedStatement>();
@@ -57,6 +58,17 @@ function getSaveEmbeddingStatement(db: Database): PreparedStatement {
5758
return stmt;
5859
}
5960

61+
function getSaveEmbeddingIfHashMatchesStatement(db: Database): PreparedStatement {
62+
let stmt = saveEmbeddingIfHashMatchesStatements.get(db);
63+
if (!stmt) {
64+
stmt = db.prepare(
65+
"INSERT INTO memory_embeddings (memory_id, embedding, model_id) SELECT ?, ?, ? FROM memories WHERE id = ? AND normalized_hash = ? ON CONFLICT(memory_id, model_id) DO UPDATE SET embedding = excluded.embedding",
66+
);
67+
saveEmbeddingIfHashMatchesStatements.set(db, stmt);
68+
}
69+
return stmt;
70+
}
71+
6072
function getLoadAllEmbeddingsStatement(db: Database): PreparedStatement {
6173
let stmt = loadAllEmbeddingsStatements.get(db);
6274
if (!stmt) {
@@ -131,6 +143,28 @@ export function saveEmbedding(
131143
getSaveEmbeddingStatement(db).run(memoryId, blob, modelId);
132144
}
133145

146+
/** Save an embedding only if the memory row still has the same normalized hash
147+
* we embedded. If the content changed while the provider call was in flight,
148+
* the stale vector is discarded instead of resurrecting an out-of-date row. */
149+
export function saveEmbeddingIfHashMatches(
150+
db: Database,
151+
memoryId: number,
152+
embedding: Float32Array,
153+
modelId: string,
154+
normalizedHash: string,
155+
): boolean {
156+
const blob = Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
157+
return (
158+
getSaveEmbeddingIfHashMatchesStatement(db).run(
159+
memoryId,
160+
blob,
161+
modelId,
162+
memoryId,
163+
normalizedHash,
164+
).changes > 0
165+
);
166+
}
167+
134168
export function loadAllEmbeddings(
135169
db: Database,
136170
projectPath: string,

packages/plugin/src/features/magic-context/memory/storage-memory.ts

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@ export interface MemoryCountsByStatus {
117117
mergedIds: number[];
118118
}
119119

120+
export interface InsertMemoryResult {
121+
memory: Memory;
122+
inserted: boolean;
123+
}
124+
120125
interface MemoryCountByStatusRow {
121126
id: number;
122127
status: MemoryStatus;
@@ -221,6 +226,14 @@ function isVerificationStatus(value: unknown): value is VerificationStatus {
221226
return typeof value === "string" && value in VERIFICATION_STATUS_LOOKUP;
222227
}
223228

229+
function isUniqueConstraintError(error: unknown): boolean {
230+
return (
231+
error instanceof Error &&
232+
"code" in error &&
233+
(error as { code?: unknown }).code === "SQLITE_CONSTRAINT_UNIQUE"
234+
);
235+
}
236+
224237
function isNullableString(value: unknown): value is string | null {
225238
return value === null || typeof value === "string";
226239
}
@@ -500,16 +513,19 @@ function getMemoryCountsByStatusStatement(db: Database): PreparedStatement {
500513
return stmt;
501514
}
502515

503-
export function insertMemory(db: Database, input: MemoryInput): Memory {
504-
const now = Date.now();
505-
const normalizedHash = computeNormalizedHash(input.content);
516+
function buildInsertMemoryValues(
517+
input: MemoryInput,
518+
normalizedHash: string,
519+
now: number,
520+
includeImportance: boolean,
521+
): Array<string | number | null> {
506522
const insertValues: Array<string | number | null> = [
507523
input.projectPath,
508524
input.category,
509525
input.content,
510526
normalizedHash,
511527
];
512-
if (hasMemoryImportanceColumn(db)) {
528+
if (includeImportance) {
513529
insertValues.push(input.importance ?? 50);
514530
}
515531
insertValues.push(
@@ -530,18 +546,61 @@ export function insertMemory(db: Database, input: MemoryInput): Memory {
530546
null,
531547
input.metadataJson ?? null,
532548
);
533-
const result = getInsertMemoryStatement(db).run(...insertValues);
549+
return insertValues;
550+
}
534551

535-
const insertedResult = result as { lastInsertRowid?: number | bigint };
536-
const inserted = getMemoryById(db, Number(insertedResult.lastInsertRowid));
552+
function loadInsertedMemory(db: Database, rowid: number | bigint | undefined): Memory {
553+
const inserted = getMemoryById(db, Number(rowid));
537554
if (!inserted) {
538555
throw new Error("Failed to load inserted memory row");
539556
}
557+
return inserted;
558+
}
559+
560+
export function insertMemory(db: Database, input: MemoryInput): Memory {
561+
const now = Date.now();
562+
const normalizedHash = computeNormalizedHash(input.content);
563+
const insertValues = buildInsertMemoryValues(
564+
input,
565+
normalizedHash,
566+
now,
567+
hasMemoryImportanceColumn(db),
568+
);
569+
const result = getInsertMemoryStatement(db).run(...insertValues);
570+
571+
const insertedResult = result as { lastInsertRowid?: number | bigint };
572+
const inserted = loadInsertedMemory(db, insertedResult.lastInsertRowid);
540573

541574
invalidateProject(input.projectPath);
542575
return inserted;
543576
}
544577

578+
/**
579+
* Shared-DB callers can race between their exact-hash pre-check and INSERT. When
580+
* the unique constraint wins, treat it as the same exact-dedup path the tool uses:
581+
* bump seen_count/last_seen_at on the existing row and return it instead of
582+
* surfacing a transient write failure.
583+
*/
584+
export function insertMemoryIdempotent(db: Database, input: MemoryInput): InsertMemoryResult {
585+
try {
586+
return { memory: insertMemory(db, input), inserted: true };
587+
} catch (error) {
588+
if (!isUniqueConstraintError(error)) {
589+
throw error;
590+
}
591+
const normalizedHash = computeNormalizedHash(input.content);
592+
const existing = getMemoryByHash(db, input.projectPath, input.category, normalizedHash);
593+
if (!existing) {
594+
throw error;
595+
}
596+
updateMemorySeenCount(db, existing.id);
597+
return {
598+
memory: getMemoryById(db, existing.id) ?? existing,
599+
inserted: false,
600+
};
601+
}
602+
}
603+
545604
export function getMemoryByHash(
546605
db: Database,
547606
projectPath: string,

packages/plugin/src/features/magic-context/message-index-async.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ function countMessageRows(db: Database, sessionId: string, messageId: string): n
4949
return typeof row?.count === "number" ? row.count : 0;
5050
}
5151

52+
function searchMessageIds(db: Database, sessionId: string, ftsQuery: string): string[] {
53+
return (
54+
db
55+
.prepare(
56+
"SELECT message_id FROM message_history_fts WHERE session_id = ? AND message_history_fts MATCH ? ORDER BY bm25(message_history_fts), CAST(message_ordinal AS INTEGER) ASC",
57+
)
58+
.all(sessionId, ftsQuery) as Array<{ message_id: string }>
59+
).map((row) => row.message_id);
60+
}
61+
5262
describe("message-index-async", () => {
5363
let db: Database;
5464

@@ -92,6 +102,50 @@ describe("message-index-async", () => {
92102
expect(countMessageRows(db, "ses-overlap", "m-1")).toBe(1);
93103
});
94104

105+
it("reconciles a failed incremental hole even after a later incremental success advanced the watermark", async () => {
106+
const originalPrepare = db.prepare.bind(db);
107+
let failMessageId: string | null = "m-2";
108+
(db as unknown as { prepare: typeof db.prepare }).prepare = ((sql: string) => {
109+
const stmt = originalPrepare(sql);
110+
if (sql.startsWith("INSERT INTO message_history_fts")) {
111+
const run = stmt.run.bind(stmt);
112+
(stmt as unknown as { run: typeof stmt.run }).run = ((...args: unknown[]) => {
113+
if (failMessageId !== null && args[2] === failMessageId) {
114+
throw new Error("synthetic incremental failure");
115+
}
116+
return run(...(args as Parameters<typeof stmt.run>));
117+
}) as typeof stmt.run;
118+
}
119+
return stmt;
120+
}) as typeof db.prepare;
121+
122+
const fullHistory = [
123+
message("m-1", 1, "alpha indexed first"),
124+
message("m-2", 2, "beta hole should come back"),
125+
message("m-3", 3, "gamma later incremental succeeds"),
126+
];
127+
scheduleReconciliation(db, "ses-hole", () => [fullHistory[0]!]);
128+
await wait(20);
129+
expect(isSessionReconciled("ses-hole")).toBe(true);
130+
131+
scheduleIncrementalIndex(db, "ses-hole", "m-2", () => fullHistory[1] ?? null);
132+
await wait(140);
133+
expect(countMessageRows(db, "ses-hole", "m-2")).toBe(0);
134+
expect(isSessionReconciled("ses-hole")).toBe(false);
135+
136+
failMessageId = null;
137+
scheduleIncrementalIndex(db, "ses-hole", "m-3", () => fullHistory[2] ?? null);
138+
await wait(140);
139+
expect(countMessageRows(db, "ses-hole", "m-3")).toBe(1);
140+
141+
scheduleReconciliation(db, "ses-hole", () => fullHistory);
142+
await wait(20);
143+
144+
expect(searchMessageIds(db, "ses-hole", "beta")).toEqual(["m-2"]);
145+
expect(countMessageRows(db, "ses-hole", "m-3")).toBe(1);
146+
expect(isSessionReconciled("ses-hole")).toBe(true);
147+
});
148+
95149
it("clears and rebuilds after a removed message", async () => {
96150
const first = [message("m-1", 1, "old"), message("m-2", 2, "keep")];
97151
scheduleReconciliation(db, "ses-clear", () => first);

packages/plugin/src/features/magic-context/message-index-async.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
getLastIndexedOrdinal,
77
indexMessagesAfterOrdinal,
88
indexSingleMessage,
9+
markMessageIndexDirty,
910
} from "./message-index";
1011

1112
/**
@@ -47,8 +48,9 @@ function isDatabaseLockedError(error: unknown): boolean {
4748
* the same `(sessionId,messageId)` inside 100ms are dropped.
4849
* 2. Per-session lazy reconciliation: the first transform/hook touch schedules
4950
* one catch-up pass. It reads raw messages, resumes from
50-
* `message_history_index.last_indexed_ordinal`, inserts missing newer rows,
51-
* and advances the watermark to `messages.length`.
51+
* `message_history_index.last_indexed_ordinal`, rewinds from any recorded
52+
* dirty floor left by a failed incremental write, inserts missing rows, and
53+
* advances the watermark to `messages.length`.
5254
* 3. Revert/delete handling: `message.removed` clears all FTS rows + the
5355
* watermark and then re-runs reconciliation. Searches during that rebuild
5456
* window correctly see no message hits.
@@ -160,10 +162,13 @@ export function scheduleReconciliation(
160162
reconciliationScheduledSessions.add(sessionId);
161163

162164
defer(() => {
163-
void reconcileSessionIndex(db, sessionId, readMessages).catch((error) => {
164-
reconciliationScheduledSessions.delete(sessionId);
165-
logIndexingError(sessionId, "reconciliation", error);
166-
});
165+
void reconcileSessionIndex(db, sessionId, readMessages)
166+
.catch((error) => {
167+
logIndexingError(sessionId, "reconciliation", error);
168+
})
169+
.finally(() => {
170+
reconciliationScheduledSessions.delete(sessionId);
171+
});
167172
});
168173
}
169174

@@ -181,14 +186,22 @@ export function scheduleIncrementalIndex(
181186
const timer = setTimeout(() => {
182187
incrementalTimers.delete(key);
183188
pendingIncrementalKeys.add(key);
189+
let attemptedOrdinal: number | null = null;
184190
void runWithSessionLock(sessionId, () => {
185191
const message = readSingleMessage(sessionId, messageId);
186192
if (!message) {
187193
return;
188194
}
195+
attemptedOrdinal = message.ordinal;
189196
indexSingleMessage(db, sessionId, message);
190197
})
191198
.catch((error) => {
199+
markMessageIndexDirty(
200+
db,
201+
sessionId,
202+
attemptedOrdinal ?? getLastIndexedOrdinal(db, sessionId) + 1,
203+
);
204+
reconciledSessions.delete(sessionId);
192205
logIndexingError(sessionId, `incremental index for ${messageId}`, error);
193206
})
194207
.finally(() => {

0 commit comments

Comments
 (0)