Skip to content

Commit 21ef95c

Browse files
committed
fix(release): close two P1 data-integrity gaps + announcement/docs for v0.26.0
Release-audit council found two silent-data-loss blockers; both fixed: 1. Collision-merge embedding loss (relocate-memory.ts). When moving a memory that collides with an equivalent at the target, the source row was DELETEd — FK-cascading its memory_embeddings away — without transferring an embedding to the surviving target. A merged memory could end up with no vector. Now INSERT OR IGNORE the source embedding onto the target before the delete (the two are equivalent: same category + normalized_hash, so either vector is valid). +1 regression test. 2. migrate-session cross-DB split-brain (migrate-session.ts). The two databases are separate files, so OpenCode committed first and context.db committed later; a context.db failure left OpenCode moved but Magic Context not. Capture the session's prior column values and add a compensating rollback: if the context.db transaction throws, restore OpenCode to its pre-migration state so the two stay consistent (best-effort; the user's pre-run backup is the final safety net). +1 regression test. Also (SHOULD-FIX from the audit): - Narrow the release-notes + CONFIGURATION.md fallback claim: the session-model last resort is HISTORIAN-ONLY; dreamer/sidekick use only their configured fallback_models (verified in compartment-runner-historian.ts vs dreamer/runner). - Bump ANNOUNCEMENT_VERSION 0.25.0 -> 0.26.0 with this release's features. Cache-stability core verified SAFE by the council (6/6 unanimous): perf scoping is speed-only, D2 redacted-skip + gate sound, embedding identity unaffected, migration v38 correct. Gate: plugin 2166/0, CLI 178/0 (+2), tsc + biome clean.
1 parent 89a4746 commit 21ef95c

5 files changed

Lines changed: 94 additions & 7 deletions

File tree

CONFIGURATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ Each hidden agent (historian, dreamer, sidekick) uses the `model` you configure
250250
If the configured primary fails (auth, transient, or returns unusable output), the fallback order is:
251251

252252
1. Your explicit `fallback_models` for that agent, in order.
253-
2. Your active session model, as a last resort (a model you're already using).
253+
2. **Historian only:** your active session model, as a last resort (a model you're already using). The dreamer and sidekick use only their configured `fallback_models`.
254254

255255
If you set no `fallback_models`, a failing primary simply retries — it never jumps to an unconfigured model. Set `fallback_models` to add alternates of your own (each `"provider/model-id"`).
256256

packages/cli/src/commands/migrate-session.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,4 +409,47 @@ describe("applyMigrateSession — memory actions", () => {
409409
).c,
410410
).toBe(1);
411411
});
412+
413+
it("move collision: source embedding is preserved on the surviving target (no silent loss)", () => {
414+
const { oc, ctx } = setup();
415+
// FROM row HAS an embedding; the equivalent TO row does NOT.
416+
insertMemory(ctx, FROM, SID, { category: "NAMING", content: "dup", withEmbedding: true });
417+
const targetId = Number(
418+
(
419+
ctx
420+
.prepare(
421+
`INSERT INTO memories (project_path, category, content, normalized_hash, importance, source_session_id, seen_count, status, created_at)
422+
VALUES (?, 'NAMING', 'dup', ?, 50, NULL, 1, 'active', 0) RETURNING id`,
423+
)
424+
.get(TO, `hash-${hashCounter}`) as { id: number }
425+
).id,
426+
);
427+
const plan = planMigrateSession(SID, "/home/u/benchmarks", makeDeps(oc, ctx));
428+
const res = applyMigrateSession(plan, "move-originated", makeDeps(oc, ctx));
429+
expect(res.memoriesMerged).toBe(1);
430+
// The surviving target row must now carry an embedding adopted from the
431+
// deleted source — without the transfer the FK-cascade would lose it.
432+
expect(
433+
(
434+
ctx
435+
.prepare("SELECT COUNT(*) AS c FROM memory_embeddings WHERE memory_id = ?")
436+
.get(targetId) as { c: number }
437+
).c,
438+
).toBe(1);
439+
});
440+
441+
it("compensates the OpenCode move when the context.db transaction fails (no split-brain)", () => {
442+
const { oc, ctx } = setup();
443+
// Force the context.db transaction to throw AFTER the OpenCode commit by
444+
// dropping a table its transaction writes to.
445+
ctx.exec("DROP TABLE compartment_chunk_embeddings");
446+
const plan = planMigrateSession(SID, "/home/u/benchmarks", makeDeps(oc, ctx));
447+
expect(() => applyMigrateSession(plan, "leave", makeDeps(oc, ctx))).toThrow();
448+
// OpenCode must be restored to its pre-migration values.
449+
const session = oc
450+
.prepare("SELECT directory, project_id FROM session WHERE id = ?")
451+
.get(SID) as { directory: string; project_id: string };
452+
expect(session.directory).toBe("/old/dir");
453+
expect(session.project_id).toBe("global");
454+
});
412455
});

packages/cli/src/commands/migrate-session.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,16 @@ export function applyMigrateSession(
231231
sets.push("workspace_id = ?");
232232
params.push(null);
233233
}
234+
// The two databases are separate files, so a single ACID transaction across
235+
// them is impossible. To avoid a split-brain (OpenCode moved, context.db not),
236+
// capture the session's PRIOR column values now; if the (larger, second)
237+
// context.db transaction fails, we compensate by restoring OpenCode to these
238+
// values so the user is never left half-migrated.
239+
const restoreCols = ["directory", ...sets.map((s) => s.split(" = ")[0]).slice(1)];
240+
const priorRow = deps.opencodeDb
241+
.prepare(`SELECT ${restoreCols.join(", ")} FROM session WHERE id = ?`)
242+
.get(plan.sessionId) as Record<string, string | null> | undefined;
243+
234244
deps.opencodeDb.exec("BEGIN IMMEDIATE");
235245
try {
236246
deps.opencodeDb
@@ -242,6 +252,27 @@ export function applyMigrateSession(
242252
throw error;
243253
}
244254

255+
const compensateOpenCode = (): void => {
256+
if (!priorRow) return;
257+
try {
258+
const restoreSets = restoreCols.map((c) => `${c} = ?`).join(", ");
259+
const restoreParams = restoreCols.map((c) => priorRow[c] ?? null);
260+
deps.opencodeDb.exec("BEGIN IMMEDIATE");
261+
deps.opencodeDb
262+
.prepare(`UPDATE session SET ${restoreSets} WHERE id = ?`)
263+
.run(...restoreParams, plan.sessionId);
264+
deps.opencodeDb.exec("COMMIT");
265+
} catch {
266+
// Best-effort: the original error is what we rethrow. If even the
267+
// compensation fails the user still has their pre-run backup.
268+
try {
269+
deps.opencodeDb.exec("ROLLBACK");
270+
} catch {
271+
// ignore
272+
}
273+
}
274+
};
275+
245276
// 2. Magic Context side — re-stamp + memory action in one transaction.
246277
let memoriesRelocated = 0;
247278
let memoriesMerged = 0;
@@ -321,6 +352,9 @@ export function applyMigrateSession(
321352
deps.contextDb.exec("COMMIT");
322353
} catch (error) {
323354
deps.contextDb.exec("ROLLBACK");
355+
// Context.db rolled back atomically; now undo the already-committed
356+
// OpenCode move so the two databases stay consistent (no split-brain).
357+
compensateOpenCode();
324358
throw error;
325359
}
326360

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@ export function rekeyMemoryRowWithCollisionMerge(
8484
collision.id,
8585
);
8686
}
87+
// Preserve an embedding on the surviving target BEFORE the source row's
88+
// embedding FK-cascades away on DELETE (memory_embeddings.memory_id
89+
// REFERENCES memories(id) ON DELETE CASCADE). INSERT OR IGNORE keeps the
90+
// target's existing embedding when it has one; otherwise it adopts the
91+
// source's — so a merged memory never ends up with NO vector (the two are
92+
// equivalent: same category + normalized_hash, so either vector is valid).
93+
db.prepare(
94+
`INSERT OR IGNORE INTO memory_embeddings (memory_id, embedding, model_id)
95+
SELECT ?, embedding, model_id FROM memory_embeddings WHERE memory_id = ?`,
96+
).run(collision.id, rowId);
8797
db.prepare("DELETE FROM memories WHERE id = ?").run(rowId);
8898
return true;
8999
}

packages/plugin/src/shared/announcement.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,18 @@ import { getMagicContextStorageDir } from "./data-path";
2323
* Bump only when there are user-visible changes worth a startup dialog.
2424
* Does NOT need to match the published package version.
2525
*/
26-
export const ANNOUNCEMENT_VERSION = "0.25.0";
26+
export const ANNOUNCEMENT_VERSION = "0.26.0";
2727

2828
/**
2929
* Short, user-facing bullet strings. Keep each line ~80 chars or shorter so the
3030
* TUI dialog renders cleanly without horizontal scroll on a typical terminal.
3131
*/
3232
export const ANNOUNCEMENT_FEATURES: ReadonlyArray<string> = [
33-
"Old tool output is now reclaimed automatically: once a file read / search / command output has gone a full execute cycle unused, it's dropped on the next one — no need to call ctx_reduce for stale results.",
34-
"Recover anything that was dropped: ctx_expand({ message: N }) returns a dropped message's full content (every tool call's input + output) from storage. ctx_expand({ start, end, verbose: true }) lists a range message-by-message to find it.",
35-
"Searchable history made reliable: /ctx-embed shows embedding coverage and runs a resilient backfill (retries transient failures, no longer bails on the first hiccup); the active session now auto-embeds in the background. ctx_reduce guidance also reframed as deferred + recoverable so models trim spent output earlier.",
36-
"Pi: fixed /ctx-dream (was failing with 'Unknown named parameter') and local-embedding load failures on Windows/Desktop (#151, #128).",
37-
"Runaway background agents on weak/local models are now capped and force-stopped (#154, #152). Plus several prompt-cache busts removed.",
33+
"Faster on large sessions: per-message transform overhead is at least 2x lower on typical passes and up to ~10x lower when history summarization fires (no more multi-second pause on big sessions).",
34+
"No more surprise models: the built-in fallback chain is gone. Hidden agents only use the model (and fallback_models) you configure — no confusing 'model not found' for providers you never set up. `doctor` now records every historian run so real failures are visible.",
35+
"Anthropic thinking-block fix: clearing old reasoning no longer risks a stale-signature rejection on Claude / Bedrock / proxied-Claude routes. Plus fewer prompt-cache busts.",
36+
"Community fixes: TUI crash on the upgrade progress panel (#168), historian.disallowed_tools for weak models that loop on tool calls (#166), and a Pi-only config key leak (#167).",
37+
"New: doctor migrate-session re-homes a session (and optionally its memories) to another project, with a dry-run preview.",
3838
];
3939

4040
/**

0 commit comments

Comments
 (0)