fix(core): repair a renumbered migration whose ledger row outlived its column

Reported from a dev instance: `column "memory_focus" does not exist` on every chat-session
read, so the task planner chat 500s and never opens — with a startup that reports success.

A ledger row asserts "a migration with this NUMBER ran". That is not the same claim as "this
COLUMN exists" once a migration has been renumbered, and this one was renumbered four times
— 0059 -> 0060 -> 0061 -> 0065 -> 0066 — each time because an upstream batch claimed the
sequence first. A database can therefore carry a row from one numbering while a different
migration owned that number on the boot that recorded it. The applier trusts the ledger
absolutely, skips the migration, and leaves a schema that does not match it. Nothing fails at
startup; everything fails afterwards, because Drizzle's `select()` emits the binary's full
column list and one missing column breaks every read of the table.

The defence already existed one table over: `0047` task recommendations verifies its
materialized column in addition to the marker and replays its idempotent SQL. The lesson had
been learned and not generalized. Both migrations renumbered on this branch — 0066 memory
focus and 0067 session contention wait state — now carry it, and both SQL files are
`ADD COLUMN IF NOT EXISTS`, so a replay over a healthy schema costs nothing.

Two PostgreSQL regression tests reproduce the drifted state exactly (marker present, column
dropped) and prove the replay materializes the column and stays idempotent on a second pass.

pnpm lint 0 errors, test:gate green, core typecheck clean, schema-applier 80/80 against a
real PostgreSQL.
This commit is contained in:
Fusion Agent
2026-08-26 09:03:59 +00:00
parent 1c26a4bf4b
commit b956a7c8eb
3 changed files with 134 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fixes chat failing with "column memory_focus does not exist" — the missing column is now repaired at startup.
category: fix
dev: A ledger row asserts that a migration with a given NUMBER ran, which is not the same claim as "this column exists" once a migration has been renumbered. `0066_chat_session_memory_focus.sql` was renumbered four times (0059 → 0060 → 0061 → 0065 → 0066) as upstream batches claimed each sequence, so a database can carry a row from one numbering while a different migration owned that number on the boot that recorded it. The applier then trusts the ledger, skips the migration, and reports a successful startup over a schema that does not match it; every `chat_sessions` read then fails with `column "memory_focus" does not exist`, because Drizzle's `select()` emits the binary's full column list. Both migrations renumbered on this branch (0066 memory focus, 0067 session contention wait state) now verify their materialized columns in addition to the marker and replay their idempotent `ADD COLUMN IF NOT EXISTS` when a column is absent — the same defence `0047` task recommendations already carried. Covered by two PostgreSQL regression tests that reproduce the drifted state exactly.

View File

@@ -781,6 +781,80 @@ pgDescribe("schema-applier: VAL-SCHEMA-001 final-schema parity (table counts)",
await assertTaskLifecycleOutboxOwnershipContract(ctx);
});
/*
FNXC:MemoryFocus 2026-08-26-08:31:
THE LEDGER CAN LIE ABOUT A RENUMBERED MIGRATION.
A ledger row asserts "a migration with this NUMBER ran". The memory-focus migration was renumbered
four times (0059 → 0060 → 0061 → 0065 → 0066), each time because an upstream batch claimed the
sequence first, so a database can carry a row for one numbering while a different migration owned
that number on the boot that recorded it. The applier then trusts the ledger absolutely, skips the
migration, and reports a successful startup over a schema that does not match it.
Reproduced from a real dev database: `column "memory_focus" does not exist` on every chat-session
read — `select()` emits the binary's full column list — so every chat query 500s and the task
planner chat never opens, with nothing wrong at startup.
The repair is the same one `recommendations` already carries: verify the materialized column, not
only the marker, and replay the idempotent `ADD COLUMN IF NOT EXISTS`.
*/
it("repairs a database whose ledger claims memory focus but whose column is missing", async () => {
ctx = await setupFreshDb();
await applySchemaBaseline(ctx.db, { pluginHooks: [] });
// The exact drifted state: marker present, column absent.
await ctx.db.execute(sql.raw(`ALTER TABLE project.chat_sessions DROP COLUMN memory_focus;`));
expect(await getAppliedMigrations(ctx.db)).toContain(CHAT_SESSION_MEMORY_FOCUS_VERSION);
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(true);
const columns = (await ctx.db.execute(sql`
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'project' AND table_name = 'chat_sessions' AND column_name = 'memory_focus'
`)) as unknown as Array<{ column_name: string }>;
expect(columns, "the replay must materialize the column the ledger already claimed").toHaveLength(1);
expect(await getAppliedMigrations(ctx.db)).toContain(CHAT_SESSION_MEMORY_FOCUS_VERSION);
// Idempotent: a second pass over a healthy schema changes nothing and still succeeds.
await applySchemaBaseline(ctx.db, { pluginHooks: [] });
const afterSecondPass = (await ctx.db.execute(sql`
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'project' AND table_name = 'chat_sessions' AND column_name = 'memory_focus'
`)) as unknown as Array<{ column_name: string }>;
expect(afterSecondPass).toHaveLength(1);
});
/*
FNXC:WorkspaceContention 2026-08-26-08:31:
The other migration renumbered on this branch (0066 → 0067, because released chat memory focus owns
0066) carries the identical hazard and therefore the identical defence.
*/
it("repairs a database whose ledger claims session contention wait state but whose columns are missing", async () => {
ctx = await setupFreshDb();
await applySchemaBaseline(ctx.db, { pluginHooks: [] });
await ctx.db.execute(sql.raw(`
ALTER TABLE project.tasks
DROP COLUMN session_contention_hold_count,
DROP COLUMN session_contention_wait_reason;
`));
expect(await getAppliedMigrations(ctx.db)).toContain(SESSION_CONTENTION_WAIT_STATE_VERSION);
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(true);
const columns = (await ctx.db.execute(sql`
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'project' AND table_name = 'tasks'
AND column_name IN ('session_contention_hold_count', 'session_contention_wait_reason')
ORDER BY column_name
`)) as unknown as Array<{ column_name: string }>;
expect(columns.map((row) => row.column_name))
.toEqual(["session_contention_hold_count", "session_contention_wait_reason"]);
});
/*
FNXC:PostgresMigrationColumnCoverage 2026-07-14-13:17:
A cluster that already recorded migrations through 0006 must receive every late SQLite column before cutover retries. This is the production failure shape: the initial copy is blocked while the target schema is otherwise fully initialized.

View File

@@ -1387,13 +1387,64 @@ export async function applySchemaBaseline(
}
/* FNXC:MemoryFocus 2026-08-14-10:30: register 0066 explicitly (renumbered from 0061 on 2026-08-20 — the upstream FN-066..FN-094 batch owns 0061-0064 — and from 0065 on 2026-08-23 when upstream's FN-149 claimed 0065). */
if (!chatSessionMemoryFocusAlreadyApplied) {
/*
FNXC:MemoryFocus 2026-08-26-08:31:
VERIFY THE COLUMN, NOT ONLY THE LEDGER ROW — the same defence `recommendations` already carries
above, for the same reason, one table over.
A ledger row asserts "a migration with this NUMBER ran". For a migration renumbered four times
(0059 → 0060 → 0061 → 0065 → 0066, each time because upstream claimed the sequence first) that is
not the same claim as "this COLUMN exists": a database carrying a row from one numbering while
another migration owned that number on the boot that recorded it converges to a ledger the applier
trusts absolutely and a schema that does not match it.
Measured on a real dev database: `column "memory_focus" does not exist` on every chat-session read,
because `select()` emits the binary's full column list. Every chat query 500s, the task planner
chat never opens, and startup reports success — the applier had nothing left to do.
The SQL is `ADD COLUMN IF NOT EXISTS`, so replaying it is free when the column is already there.
A settings-only schema has no chat_sessions relation and stays a no-op.
*/
const chatSessionMemoryFocusColumnState = (await tx.execute(sql`
SELECT
to_regclass('project.chat_sessions') IS NOT NULL AS chat_sessions_exists,
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'project'
AND table_name = 'chat_sessions'
AND column_name = 'memory_focus'
) AS memory_focus_exists
`)) as unknown as Array<{ chat_sessions_exists: boolean; memory_focus_exists: boolean }>;
const chatSessionMemoryFocusColumnMissing = chatSessionMemoryFocusColumnState[0]?.chat_sessions_exists
&& !chatSessionMemoryFocusColumnState[0]?.memory_focus_exists;
if (!chatSessionMemoryFocusAlreadyApplied || chatSessionMemoryFocusColumnMissing) {
const migrationSql = await readFile(CHAT_SESSION_MEMORY_FOCUS_MIGRATION_PATH, "utf8");
await tx.execute(sql.raw(migrationSql));
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${CHAT_SESSION_MEMORY_FOCUS_VERSION}) ON CONFLICT (version) DO NOTHING`);
schemaChanged = true;
}
if (!sessionContentionWaitStateAlreadyApplied) {
/*
FNXC:WorkspaceContention 2026-08-26-08:31:
The OTHER renumbered migration on this branch (0066 → 0067, because released chat memory focus
owns 0066) carries the identical ledger-versus-schema hazard, so it takes the identical defence:
verify the materialized columns, not only the marker. Its SQL is `ADD COLUMN IF NOT EXISTS`, so a
replay over a healthy schema is free.
*/
const sessionContentionColumnState = (await tx.execute(sql`
SELECT
to_regclass('project.tasks') IS NOT NULL AS tasks_exists,
EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'project'
AND table_name = 'tasks'
AND column_name = 'session_contention_wait_reason'
) AS wait_reason_exists
`)) as unknown as Array<{ tasks_exists: boolean; wait_reason_exists: boolean }>;
const sessionContentionColumnsMissing = sessionContentionColumnState[0]?.tasks_exists
&& !sessionContentionColumnState[0]?.wait_reason_exists;
if (!sessionContentionWaitStateAlreadyApplied || sessionContentionColumnsMissing) {
const migrationSql = await readFile(SESSION_CONTENTION_WAIT_STATE_MIGRATION_PATH, "utf8");
await tx.execute(sql.raw(migrationSql));
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${SESSION_CONTENTION_WAIT_STATE_VERSION}) ON CONFLICT (version) DO NOTHING`);