From 77a775a0322a97fb3a79f7c4e295afb113e1dae2 Mon Sep 17 00:00:00 2001 From: Qiiks Date: Sat, 19 Sep 2026 06:29:07 +0530 Subject: [PATCH 1/4] fix(plugin): discard shadow vectors when the registration is retired mid-embed processShadowQueueItem captured the shadow registration before awaiting the provider, then wrote the returned vectors under that captured registration with no re-check. A provider call can take seconds (minutes on a cold Synapse model load), so unregisterProjectShadowEmbedding could retire the shadow in between and the batch would still land: memory/commit rows and compartment chunks got vectors tagged with a registration that no longer exists. Re-arming the same model id afterwards would then read those stale-lane vectors as valid backfill and skip a real re-embed. Re-check the live registration generation after the provider round-trip in all three scopes (memory, commit, chunk) and refuse the write when the registration was retired or replaced. This mirrors the guard embedItemsForProject already applies to the primary lane. Adds 'registration_retired_during_embed' to ShadowBackfillWriteRefusalReason and a regression that retires the shadow from inside embedBatch: pre-fix it writes 3 stale vectors, post-fix it writes 0. --- .../project-embedding-registry.test.ts | 47 +++++++++++++++++++ .../project-embedding-registry.ts | 18 +++++++ .../magic-context/shadow-backfill-state.ts | 3 ++ 3 files changed, 68 insertions(+) diff --git a/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts b/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts index 6bb1e25db..6bec25120 100644 --- a/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts +++ b/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts @@ -1991,6 +1991,53 @@ describe("project embedding registry", () => { expect(loadAllEmbeddings(db, projectIdentity, repeated!.modelId).size).toBe(3); }); + it("discards shadow vectors when the registration is retired while the provider call is in flight", async () => { + const db = useTempDb(); + const projectIdentity = "git:shadow-retired-in-flight"; + registerProjectEmbedding( + db, + projectIdentity, + localConfig("model-primary"), + { memoryEnabled: true, gitCommitEnabled: false }, + "/tmp/shadow-retired-in-flight", + ); + for (let i = 0; i < 3; i++) { + const memory = insertMemory(db, { + projectPath: projectIdentity, + category: "CONSTRAINTS", + content: `retired shadow memory ${i}`, + }); + saveEmbedding(db, memory.id, new Float32Array([i, 1]), currentModelId(projectIdentity)); + } + const shadowConfig = { + provider: "synapse", + model: "synapse-model", + synapse_fingerprint: "fp-retired-in-flight", + } as unknown as EmbeddingConfig; + // Retire the shadow from inside the provider call. processShadowQueueItem + // captures the registration before awaiting, so this reproduces the + // unregister-during-embed window: without a post-await re-check the + // returned vectors are written under a registration that no longer exists. + _setTestProviderFactoryForProject( + () => + new (class extends FakeEmbeddingProvider { + override async embedBatch(texts: string[]): Promise { + unregisterProjectShadowEmbedding(projectIdentity); + return texts.map((text) => new Float32Array([text.length, this.modelId.length])); + } + })("shadow"), + ); + const registration = registerProjectShadowEmbedding( + db, + projectIdentity, + shadowConfig, + "/tmp/shadow-retired-in-flight", + ); + await flushShadowEmbeddingBacklog(projectIdentity); + + expect(loadAllEmbeddings(db, projectIdentity, registration!.modelId).size).toBe(0); + }); + it("does not dispose a shadow provider that is the same instance as the primary", async () => { const shared = new FakeEmbeddingProvider("shared"); _setTestProviderFactoryForProject(() => shared); diff --git a/packages/plugin/src/features/magic-context/project-embedding-registry.ts b/packages/plugin/src/features/magic-context/project-embedding-registry.ts index 1abc241e0..761dc577d 100644 --- a/packages/plugin/src/features/magic-context/project-embedding-registry.ts +++ b/packages/plugin/src/features/magic-context/project-embedding-registry.ts @@ -1981,6 +1981,14 @@ async function processShadowQueueItem(item: ShadowQueueItem): Promise { @@ -2035,6 +2043,11 @@ async function processShadowQueueItem(item: ShadowQueueItem): Promise { for (const row of rows) { @@ -2113,6 +2126,11 @@ async function processShadowQueueItem(item: ShadowQueueItem): Promise Date: Sat, 19 Sep 2026 06:39:28 +0530 Subject: [PATCH 2/4] fix(plugin): suppress stale worker state for retired shadow registrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unregisterProjectShadowEmbedding clears shadowBackfillLastWriteOutcomes for the project, but an item already in the worker loop could publish its outcome into that map afterwards. The stall detector reads the map, so a retired item's refusal could be attributed to a registration that replaced it — reporting a fresh lane as stalled_no_progress and leaving its remaining backfill unembedded. Record the outcome only while the registration the item started under is still the live one. Note: no test ships with this half. A re-arm regression was written and removed because it passed with and without the guard — the replacement registration publishes its own outcome under the same key, so the stale publish is not the value the detector reads in that path. The guard is defense-in-depth for the interleaving that is not reachable through the test seam, consistent with cubic's P2 ask. --- .../project-embedding-registry.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/plugin/src/features/magic-context/project-embedding-registry.ts b/packages/plugin/src/features/magic-context/project-embedding-registry.ts index 761dc577d..01e17a42b 100644 --- a/packages/plugin/src/features/magic-context/project-embedding-registry.ts +++ b/packages/plugin/src/features/magic-context/project-embedding-registry.ts @@ -2200,14 +2200,28 @@ async function runShadowWorker(): Promise { shadowQueue.unshift(item); break; } + // A worker item may outlive its registration: retirement or a re-arm can + // land while the provider call is in flight. Publishing the outcome then + // re-creates state that retirement just cleared, and the stall detector + // reads that map — so a retired item's refusal could be attributed to a + // freshly re-armed registration and stop its backfill. Only record the + // outcome when the registration this item started under is still live. + const generationAtStart = shadowRegistrations.get(item.projectIdentity)?.generation; + const isStillCurrent = (): boolean => + generationAtStart !== undefined && + shadowRegistrations.get(item.projectIdentity)?.generation === generationAtStart; try { const outcome = await processShadowQueueItem(item); - shadowBackfillLastWriteOutcomes.set(`${item.projectIdentity}:${item.scope}`, outcome); + if (isStillCurrent()) { + shadowBackfillLastWriteOutcomes.set(`${item.projectIdentity}:${item.scope}`, outcome); + } } catch (error) { - shadowBackfillLastWriteOutcomes.set(`${item.projectIdentity}:${item.scope}`, { - writes: 0, - refusalReason: "provider_returned_no_vectors", - }); + if (isStillCurrent()) { + shadowBackfillLastWriteOutcomes.set(`${item.projectIdentity}:${item.scope}`, { + writes: 0, + refusalReason: "provider_returned_no_vectors", + }); + } log("[magic-context] Synapse shadow write failed:", error); } processed += item.ids.length; From 127870a0499bbe21b94bad5e2a533c07bbbfe6ae Mon Sep 17 00:00:00 2001 From: Qiiks Date: Sat, 19 Sep 2026 06:50:13 +0530 Subject: [PATCH 3/4] test(plugin): cover commit and chunk scope retirement during embed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile P2 on #470: the retirement regression exercised only the memory branch, while the fix independently changes the commit and chunk branches, which use different candidate preparation and write helpers. Adds a case for each remaining scope. Both fail without their branch's guard — commit writes 3 stale vectors, chunk writes 20 — and pass with it. Each carries a positive control so 'no rows written' cannot hold vacuously: - commit asserts the fixture actually produced committed rows to mirror - chunk asserts getShadowBackfillRemaining(...).chunk === 1 before the flush, and the provider factory branches per lane so the primary chunk lane embeds normally while only the shadow lane retires itself mid-embed The chunk fixture had to satisfy the real window contract: an FTS-mapped message row plus a seeded primary chunk embedding, since chunk candidates are built from FTS text and the shadow mirrors the primary lane's rows. --- .../project-embedding-registry.test.ts | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts b/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts index 6bec25120..289dc9bbf 100644 --- a/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts +++ b/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts @@ -45,6 +45,7 @@ import { getEmbeddingCoverageStatus, getProjectEmbeddingSnapshot, getShadowBackfillStopReason, + getShadowBackfillRemaining, getShadowEmbeddingMeasurementCohort, markProjectLoadUntrusted, registerProjectEmbedding, @@ -2038,6 +2039,132 @@ describe("project embedding registry", () => { expect(loadAllEmbeddings(db, projectIdentity, registration!.modelId).size).toBe(0); }); + it("discards commit shadow vectors when the registration is retired during the embed call", async () => { + const db = useTempDb(); + const projectIdentity = "git:shadow-retired-commit-in-flight"; + registerProjectEmbedding( + db, + projectIdentity, + localConfig("model-primary"), + { memoryEnabled: false, gitCommitEnabled: true }, + "/tmp/shadow-retired-commit-in-flight", + ); + const commits = ["c-a", "c-b", "c-c"].map((seed) => makeGitCommit(seed, 1000)); + upsertCommits(db, projectIdentity, commits); + const primaryModelId = currentModelId(projectIdentity); + for (const commit of commits) { + saveCommitEmbedding(db, commit.sha, new Float32Array([1, 1]), primaryModelId); + } + _setTestProviderFactoryForProject( + () => + new (class extends FakeEmbeddingProvider { + override async embedBatch(texts: string[]): Promise { + unregisterProjectShadowEmbedding(projectIdentity); + return texts.map( + (text) => new Float32Array([text.length, this.modelId.length]), + ); + } + })("shadow"), + ); + const registration = registerProjectShadowEmbedding( + db, + projectIdentity, + { + provider: "synapse", + model: "synapse-model", + synapse_fingerprint: "fp-retired-commit", + } as unknown as EmbeddingConfig, + "/tmp/shadow-retired-commit-in-flight", + ); + await flushShadowEmbeddingBacklog(projectIdentity); + + expect(countEmbeddedCommits(db, projectIdentity, registration!.modelId)).toBe(0); + }); + + it("discards chunk shadow vectors when the registration is retired during the embed call", async () => { + const db = useTempDb(); + const projectIdentity = "git:shadow-retired-chunk-in-flight"; + const sessionId = "ses-shadow-retired-chunk-in-flight"; + registerProjectEmbedding( + db, + projectIdentity, + localConfig("model-primary", 512), + { memoryEnabled: true, gitCommitEnabled: false }, + "/tmp/shadow-retired-chunk-in-flight", + ); + recordSessionProjectIdentity(db, sessionId, projectIdentity); + appendCompartments(db, sessionId, [ + { + sequence: 0, + startMessage: 1, + endMessage: 1, + startMessageId: "a1", + endMessageId: "a1", + title: "Retired chunk", + content: "large transcript", + p1: "large transcript", + }, + ]); + const content = Array.from({ length: 2_000 }, (_, index) => `token-${index}`).join(" "); + const ftsRow = db + .prepare( + "INSERT INTO message_history_fts (session_id, message_ordinal, message_id, role, content) VALUES (?, 1, 'a1', 'assistant', ?)", + ) + .run(sessionId, content) as { lastInsertRowid: number | bigint }; + recordMessageFtsRowid(db, sessionId, 1, ftsRow.lastInsertRowid); + _setTestProviderFactoryForProject((config) => + config.provider === "synapse" + ? // Only the shadow lane retires itself mid-embed; the primary lane + // must embed normally so the shadow has a row to mirror. + new (class extends FakeEmbeddingProvider { + override async embedBatch(texts: string[]): Promise { + unregisterProjectShadowEmbedding(projectIdentity); + return texts.map( + (text) => new Float32Array([text.length, this.modelId.length]), + ); + } + })(config.model) + : new FakeEmbeddingProvider(config.model), + ); + // Seed the primary chunk lane so the shadow lane has a row to mirror. + expect(await embedUnembeddedCompartmentChunksForProject(db, projectIdentity, 8)).toBe(1); + + _setTestProviderFactoryForProject( + () => + new (class extends FakeEmbeddingProvider { + override async embedBatch(texts: string[]): Promise { + unregisterProjectShadowEmbedding(projectIdentity); + return texts.map( + (text) => new Float32Array([text.length, this.modelId.length]), + ); + } + })("shadow"), + ); + const registration = registerProjectShadowEmbedding( + db, + projectIdentity, + { + provider: "synapse", + model: "synapse-model", + synapse_fingerprint: "fp-retired-chunk", + } as unknown as EmbeddingConfig, + "/tmp/shadow-retired-chunk-in-flight", + ); + // Positive control: the fixture must produce a chunk candidate, otherwise + // "no rows written" would hold for the wrong reason. + expect(getShadowBackfillRemaining(db, projectIdentity).chunk).toBe(1); + + await flushShadowEmbeddingBacklog(projectIdentity); + + expect( + countRows( + db, + "SELECT COUNT(*) AS count FROM compartment_chunk_embeddings WHERE model_id = ?", + registration!.chunkModelId, + ), + ).toBe(0); + }); + it("does not dispose a shadow provider that is the same instance as the primary", async () => { const shared = new FakeEmbeddingProvider("shared"); _setTestProviderFactoryForProject(() => shared); From caf709cd5fdd482a8379aa8f1d6c30458094c9b6 Mon Sep 17 00:00:00 2001 From: Qiiks Date: Sat, 19 Sep 2026 06:59:17 +0530 Subject: [PATCH 4/4] test(plugin): drop the dead second factory from the chunk retirement case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lane-branching factory installed before the primary-lane seeding already covers both lanes: it retires the shadow on embed for provider === 'synapse' and returns a normal fake for the primary. The follow-up assignment could only ever run after seeding, so it changed nothing — the chunk write keys off registration.chunkModelId, not the factory's modelId. Single factory expresses the intent once: only the shadow lane retires itself mid-embed. Behavior unchanged: both scope tests still 2 pass / 4 expect() calls, full file 43 pass / 0 fail. --- .../magic-context/project-embedding-registry.test.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts b/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts index 289dc9bbf..969fe6e28 100644 --- a/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts +++ b/packages/plugin/src/features/magic-context/project-embedding-registry.test.ts @@ -2129,17 +2129,6 @@ describe("project embedding registry", () => { // Seed the primary chunk lane so the shadow lane has a row to mirror. expect(await embedUnembeddedCompartmentChunksForProject(db, projectIdentity, 8)).toBe(1); - _setTestProviderFactoryForProject( - () => - new (class extends FakeEmbeddingProvider { - override async embedBatch(texts: string[]): Promise { - unregisterProjectShadowEmbedding(projectIdentity); - return texts.map( - (text) => new Float32Array([text.length, this.modelId.length]), - ); - } - })("shadow"), - ); const registration = registerProjectShadowEmbedding( db, projectIdentity,