Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ function populateForVersion(db: DatabaseType, version: number, state: ReplayStat
case 76:
case 77:
case 78:
case 80:
if (!state.armed) throw new Error(`migration v${version} reached an unarmed store`);
populateModuleOwnedRows(db, version, state);
return;
Expand Down Expand Up @@ -419,7 +420,11 @@ test("every migration lands on populated rows and v72+ stores stay armed", () =>
installMigrationLedgerFromSource(db);

for (const [index, migration] of MIGRATIONS.entries()) {
expect(migration.version).toBe(index + 1);
const expectedVersion = index + 1;
// Temporary merge-order gap: PR #340 owns v79; remove this allowance
// once its migration lands ahead of this PR's v80 migration.
const awaitingPr340 = expectedVersion === 79 && migration.version === 80;
expect(migration.version === expectedVersion || awaitingPr340).toBe(true);
assertPopulatedRowsLanded(db, state);
applyExactlyOneMigration(db, migration);
populateForVersion(db, migration.version, state);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ describe("migration v74: detected context-limit provenance", () => {
runMigrations(db);

expect(columnNames(db, "session_meta")).toContain("detected_context_limit_provenance");
expect(LATEST_SUPPORTED_VERSION).toBe(78);
expect(LATEST_SUPPORTED_VERSION).toBe(80);
expect(LATEST_SUPPORTED_VERSION).toBe(LATEST_MIGRATION_VERSION);
} finally {
closeQuietly(db);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe("migration v76: retina condition compilation", () => {
"compile_status",
]),
);
expect(LATEST_SUPPORTED_VERSION).toBe(78);
expect(LATEST_SUPPORTED_VERSION).toBe(80);
expect(LATEST_SUPPORTED_VERSION).toBe(LATEST_MIGRATION_VERSION);
expect(() =>
db
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe("migration v77: durable candidate provenance", () => {

expect(columnNames(db, "user_memories")).toContain("source_candidate_provenance");
expect(columnNames(db, "primers")).toContain("source_candidate_provenance");
expect(LATEST_SUPPORTED_VERSION).toBe(78);
expect(LATEST_SUPPORTED_VERSION).toBe(80);
expect(LATEST_SUPPORTED_VERSION).toBe(LATEST_MIGRATION_VERSION);
} finally {
closeQuietly(db);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe("migration v78: migration_pending journal", () => {
"phase",
"created_at",
]);
expect(LATEST_SUPPORTED_VERSION).toBe(78);
expect(LATEST_SUPPORTED_VERSION).toBe(80);
expect(LATEST_SUPPORTED_VERSION).toBe(LATEST_MIGRATION_VERSION);
} finally {
closeQuietly(db);
Expand Down
79 changes: 79 additions & 0 deletions packages/plugin/src/features/magic-context/migrations-v80.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/// <reference types="bun-types" />

import { describe, expect, test } from "bun:test";
import { Database } from "../../shared/sqlite";
import { closeQuietly } from "../../shared/sqlite-helpers";
import { LATEST_MIGRATION_VERSION, runMigrations } from "./migrations";
import { initializeDatabase, LATEST_SUPPORTED_VERSION } from "./storage-db";

function seedAppliedVersion(db: Database, version: number): void {
db.exec(`
CREATE TABLE schema_migrations (
version INTEGER PRIMARY KEY,
description TEXT NOT NULL,
applied_at INTEGER NOT NULL
);
`);
const insert = db.prepare(
"INSERT INTO schema_migrations (version, description, applied_at) VALUES (?, ?, ?)",
);
for (let current = 1; current <= version; current += 1) {
insert.run(current, `seed v${current}`, Date.now());
}
}

function columnNames(db: Database, table: string): string[] {
return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map(
(column) => column.name,
);
}

describe("migration v80: tokenless usage observation timestamp", () => {
test("fresh databases include the timestamp and align the schema fence", () => {
const db = new Database(":memory:");
try {
initializeDatabase(db);
runMigrations(db);

expect(columnNames(db, "session_meta")).toContain("last_usage_observed_at");
expect(LATEST_SUPPORTED_VERSION).toBe(80);
expect(LATEST_SUPPORTED_VERSION).toBe(LATEST_MIGRATION_VERSION);
} finally {
closeQuietly(db);
}
});

test("replaying from v79 preserves the observation time for legacy token usage", () => {
const db = new Database(":memory:");
try {
seedAppliedVersion(db, 79);
db.exec(`
CREATE TABLE session_meta (
session_id TEXT PRIMARY KEY,
last_context_percentage REAL DEFAULT 0,
last_input_tokens INTEGER DEFAULT 0,
last_response_time INTEGER
);
INSERT INTO session_meta (
session_id, last_context_percentage, last_input_tokens, last_response_time
) VALUES ('ses-legacy', 50, 50000, 123);
`);

runMigrations(db);
runMigrations(db);

expect(
db
.prepare("SELECT last_usage_observed_at FROM session_meta WHERE session_id = ?")
.get("ses-legacy"),
).toEqual({ last_usage_observed_at: 123 });
expect(
db
.prepare("SELECT COUNT(*) AS count FROM schema_migrations WHERE version = 80")
.get(),
).toEqual({ count: 1 });
} finally {
closeQuietly(db);
}
});
});
22 changes: 22 additions & 0 deletions packages/plugin/src/features/magic-context/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2818,6 +2818,28 @@
`);
},
},
{
// Temporary merge-order reservation: PR #340 owns v79, so this PR must
// remain v80 even while v79 is absent from this worktree.
version: 80,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When this PR is applied before PR #340, v80 becomes the high-water mark and the later v79 migration is skipped permanently. Land v79 first, or change migration selection to support out-of-order pending versions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/plugin/src/features/magic-context/migrations.ts, line 2824:

<comment>When this PR is applied before PR #340, v80 becomes the high-water mark and the later v79 migration is skipped permanently. Land v79 first, or change migration selection to support out-of-order pending versions.</comment>

<file context>
@@ -2818,6 +2818,21 @@ export const MIGRATIONS: Migration[] = [
+    {
+        // Temporary merge-order reservation: PR #340 owns v79, so this PR must
+        // remain v80 even while v79 is absent from this worktree.
+        version: 80,
+        description: "persist the original observation time for tokenless usage TTL",
+        up(db: Database): void {
</file context>

description: "persist the original observation time for tokenless usage TTL",
up(db: Database): void {
if (!tableExists(db, "session_meta")) return;
ensureColumn(
db,
"session_meta",
"last_usage_observed_at",
"INTEGER NOT NULL DEFAULT 0",
);
db.exec(`
UPDATE session_meta
SET last_usage_observed_at = last_response_time

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When an existing session received a tokenless response after its last usage sample, this migration records that later response time as the usage-observation time. The restored sample can then pass the TTL check and drive pressure decisions beyond its real freshness window; do not mark legacy usage fresh from a response-only timestamp unless the legacy event is known to contain usage, otherwise expire the legacy sample conservatively.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/plugin/src/features/magic-context/migrations.ts, line 2836:

<comment>When an existing session received a tokenless response after its last usage sample, this migration records that later response time as the usage-observation time. The restored sample can then pass the TTL check and drive pressure decisions beyond its real freshness window; do not mark legacy usage fresh from a response-only timestamp unless the legacy event is known to contain usage, otherwise expire the legacy sample conservatively.</comment>

<file context>
@@ -2831,6 +2831,13 @@ export const MIGRATIONS: Migration[] = [
             );
+            db.exec(`
+                UPDATE session_meta
+                   SET last_usage_observed_at = last_response_time
+                 WHERE last_usage_observed_at = 0
+                   AND last_input_tokens > 0
</file context>

WHERE last_usage_observed_at = 0
AND last_input_tokens > 0
AND last_response_time > 0;
`);
},
},
];

/**
Expand Down Expand Up @@ -2980,7 +3002,7 @@
);
throw new Error(
`Migration v${version} failed: ${error instanceof Error ? error.message : String(error)}. Database may need manual repair.`,
);

Check failure on line 3005 in packages/plugin/src/features/magic-context/migrations.ts

View workflow job for this annotation

GitHub Actions / Check (plugin)

error: Migration v80 failed: no such column: last_input_tokens. Database may need manual repair.

at runMigrations (/home/runner/work/magic-context/magic-context/packages/plugin/src/features/magic-context/migrations.ts:3005:13) at <anonymous> (/home/runner/work/magic-context/magic-context/packages/plugin/src/features/magic-context/migrations-v72.test.ts:46:13)

Check failure on line 3005 in packages/plugin/src/features/magic-context/migrations.ts

View workflow job for this annotation

GitHub Actions / Check (plugin)

error: Migration v80 failed: no such column: last_response_time. Database may need manual repair.

at runMigrations (/home/runner/work/magic-context/magic-context/packages/plugin/src/features/magic-context/migrations.ts:3005:13) at <anonymous> (/home/runner/work/magic-context/magic-context/packages/plugin/src/features/magic-context/migrations-v52.test.ts:49:13)

Check failure on line 3005 in packages/plugin/src/features/magic-context/migrations.ts

View workflow job for this annotation

GitHub Actions / Check (plugin)

error: Migration v80 failed: no such column: last_response_time. Database may need manual repair.

at runMigrations (/home/runner/work/magic-context/magic-context/packages/plugin/src/features/magic-context/migrations.ts:3005:13) at <anonymous> (/home/runner/work/magic-context/magic-context/packages/plugin/src/features/magic-context/migrations-v34.test.ts:147:13)

Check failure on line 3005 in packages/plugin/src/features/magic-context/migrations.ts

View workflow job for this annotation

GitHub Actions / Check (plugin)

error: Migration v80 failed: no such column: last_input_tokens. Database may need manual repair.

at runMigrations (/home/runner/work/magic-context/magic-context/packages/plugin/src/features/magic-context/migrations.ts:3005:13) at <anonymous> (/home/runner/work/magic-context/magic-context/packages/plugin/src/features/magic-context/migrations-v73.test.ts:48:13)

Check failure on line 3005 in packages/plugin/src/features/magic-context/migrations.ts

View workflow job for this annotation

GitHub Actions / Check (plugin)

error: Migration v80 failed: no such column: last_response_time. Database may need manual repair.

at runMigrations (/home/runner/work/magic-context/magic-context/packages/plugin/src/features/magic-context/migrations.ts:3005:13) at <anonymous> (/home/runner/work/magic-context/magic-context/packages/plugin/src/features/magic-context/migrations-v25.test.ts:36:13)

Check failure on line 3005 in packages/plugin/src/features/magic-context/migrations.ts

View workflow job for this annotation

GitHub Actions / Check (plugin)

error: Migration v80 failed: no such column: last_response_time. Database may need manual repair.

at runMigrations (/home/runner/work/magic-context/magic-context/packages/plugin/src/features/magic-context/migrations.ts:3005:13) at <anonymous> (/home/runner/work/magic-context/magic-context/packages/plugin/src/features/magic-context/migrations-v17.test.ts:95:9)

Check failure on line 3005 in packages/plugin/src/features/magic-context/migrations.ts

View workflow job for this annotation

GitHub Actions / Check (plugin)

error: Migration v80 failed: no such column: last_response_time. Database may need manual repair.

at runMigrations (/home/runner/work/magic-context/magic-context/packages/plugin/src/features/magic-context/migrations.ts:3005:13) at <anonymous> (/home/runner/work/magic-context/magic-context/packages/plugin/src/features/magic-context/migrations-v17.test.ts:79:9)

Check failure on line 3005 in packages/plugin/src/features/magic-context/migrations.ts

View workflow job for this annotation

GitHub Actions / Check (plugin)

error: Migration v80 failed: no such column: last_response_time. Database may need manual repair.

at runMigrations (/home/runner/work/magic-context/magic-context/packages/plugin/src/features/magic-context/migrations.ts:3005:13) at <anonymous> (/home/runner/work/magic-context/magic-context/packages/plugin/src/features/magic-context/migrations-v17.test.ts:58:9)

Check failure on line 3005 in packages/plugin/src/features/magic-context/migrations.ts

View workflow job for this annotation

GitHub Actions / Check (plugin)

error: Migration v80 failed: no such column: last_response_time. Database may need manual repair.

at runMigrations (/home/runner/work/magic-context/magic-context/packages/plugin/src/features/magic-context/migrations.ts:3005:13) at <anonymous> (/home/runner/work/magic-context/magic-context/packages/plugin/src/features/magic-context/migrations-v17.test.ts:35:9)
}
}

Expand Down
4 changes: 3 additions & 1 deletion packages/plugin/src/features/magic-context/storage-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export function __resetSchemaFenceStateForTests(): void {
lastMigrationOnOpenRefusal = null;
}

export const LATEST_SUPPORTED_VERSION = 78;
export const LATEST_SUPPORTED_VERSION = 80;

// chmod is meaningless on Windows (POSIX modes are not honored), so all
// permission tightening is skipped there. mkdir's `mode` is likewise ignored.
Expand Down Expand Up @@ -1475,6 +1475,7 @@ CREATE INDEX IF NOT EXISTS idx_dream_queue_pending ON dream_queue(started_at, en
cached_m1_bytes BLOB,
last_observed_model_key TEXT,
last_usage_context_limit INTEGER NOT NULL DEFAULT 0,
last_usage_observed_at INTEGER NOT NULL DEFAULT 0,
prior_boundary_ordinal INTEGER NOT NULL DEFAULT 1,
protected_tail_policy_version INTEGER NOT NULL DEFAULT 0,
protected_tail_drain_window_started_at INTEGER NOT NULL DEFAULT 0,
Expand Down Expand Up @@ -1880,6 +1881,7 @@ CREATE INDEX IF NOT EXISTS idx_dream_queue_pending ON dream_queue(started_at, en
ensureColumn(db, "session_meta", "cached_m1_bytes", "BLOB");
ensureColumn(db, "session_meta", "last_observed_model_key", "TEXT");
ensureColumn(db, "session_meta", "last_usage_context_limit", "INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "session_meta", "last_usage_observed_at", "INTEGER NOT NULL DEFAULT 0");
ensureColumn(db, "session_meta", "prior_boundary_ordinal", "INTEGER NOT NULL DEFAULT 1");
ensureColumn(db, "session_meta", "protected_tail_policy_version", "INTEGER NOT NULL DEFAULT 0");
ensureColumn(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { stableStringify } from "../../shared/stable-json";
import { ensureSessionMetaRow } from "./storage-meta-shared";
import type { ContextUsage } from "./types";

export const CONTEXT_USAGE_TTL_MS = 60 * 60 * 1_000;

const emergencyRecoveryArmedSessions = new Set<string>();
const emergencyRecoveryArmedAtBySession = new Map<string, number>();
const providerOverflowReconfirmedSessions = new Set<string>();
Expand Down Expand Up @@ -38,6 +40,7 @@ interface PersistedUsageRow {
last_response_time: number;
last_observed_model_key: string | null;
last_usage_context_limit: number | null;
last_usage_observed_at: number;
}

interface PersistedReasoningWatermarkRow {
Expand Down Expand Up @@ -198,7 +201,8 @@ function isPersistedUsageRow(row: unknown): row is PersistedUsageRow {
typeof r.last_input_tokens === "number" &&
typeof r.last_response_time === "number" &&
(typeof r.last_observed_model_key === "string" || r.last_observed_model_key === null) &&
(typeof r.last_usage_context_limit === "number" || r.last_usage_context_limit === null)
(typeof r.last_usage_context_limit === "number" || r.last_usage_context_limit === null) &&
typeof r.last_usage_observed_at === "number"
);
}

Expand Down Expand Up @@ -297,12 +301,15 @@ function getDefaultHistorianFailureState(): PersistedHistorianFailureState {
export function loadPersistedUsage(db: Database, sessionId: string): PersistedUsageState | null {
const result = db
.prepare(
"SELECT last_context_percentage, last_input_tokens, last_response_time, last_observed_model_key, last_usage_context_limit FROM session_meta WHERE session_id = ?",
"SELECT last_context_percentage, last_input_tokens, last_response_time, last_observed_model_key, last_usage_context_limit, last_usage_observed_at FROM session_meta WHERE session_id = ?",
)
.get(sessionId);

if (!isPersistedUsageRow(result)) return null;
const observedAt = result.last_usage_observed_at || result.last_response_time;
if (
!isPersistedUsageRow(result) ||
observedAt <= 0 ||
Date.now() - observedAt > CONTEXT_USAGE_TTL_MS ||
(result.last_context_percentage === 0 && result.last_input_tokens === 0)
) {
return null;
Expand All @@ -313,7 +320,7 @@ export function loadPersistedUsage(db: Database, sessionId: string): PersistedUs
percentage: result.last_context_percentage,
inputTokens: result.last_input_tokens,
},
updatedAt: result.last_response_time || Date.now(),
updatedAt: observedAt,
lastObservedModelKey: result.last_observed_model_key,
lastUsageContextLimit:
typeof result.last_usage_context_limit === "number"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export interface SessionMetaRow {
cached_m0_project_identity: string | null;
last_observed_model_key: string | null;
last_usage_context_limit: number | null;
last_usage_observed_at: number | null;
prior_boundary_ordinal: number | null;
protected_tail_policy_version: number | null;
protected_tail_drain_window_started_at: number | null;
Expand Down Expand Up @@ -107,6 +108,7 @@ export const SESSION_META_SELECT_COLUMNS = [
"cached_m0_project_identity",
"last_observed_model_key",
"last_usage_context_limit",
"last_usage_observed_at",
"prior_boundary_ordinal",
"protected_tail_policy_version",
"protected_tail_drain_window_started_at",
Expand Down Expand Up @@ -164,6 +166,7 @@ export const META_COLUMNS: Record<string, string> = {
cachedM0ProjectIdentity: "cached_m0_project_identity",
lastObservedModelKey: "last_observed_model_key",
lastUsageContextLimit: "last_usage_context_limit",
lastUsageObservedAt: "last_usage_observed_at",
priorBoundaryOrdinal: "prior_boundary_ordinal",
protectedTailPolicyVersion: "protected_tail_policy_version",
protectedTailDrainWindowStartedAt: "protected_tail_drain_window_started_at",
Expand Down Expand Up @@ -289,6 +292,7 @@ export function isSessionMetaRow(row: unknown): row is SessionMetaRow {
isStringOrNull(r.cached_m0_project_identity) &&
isStringOrNull(r.last_observed_model_key) &&
isNumberOrNull(r.last_usage_context_limit) &&
isNumberOrNull(r.last_usage_observed_at) &&
isNumberOrNull(r.prior_boundary_ordinal) &&
isNumberOrNull(r.protected_tail_policy_version) &&
isNumberOrNull(r.protected_tail_drain_window_started_at) &&
Expand Down Expand Up @@ -348,6 +352,7 @@ export function getDefaultSessionMeta(sessionId: string): SessionMeta {
cachedM0ProjectIdentity: null,
lastObservedModelKey: null,
lastUsageContextLimit: 0,
lastUsageObservedAt: 0,
priorBoundaryOrdinal: 1,
protectedTailPolicyVersion: 0,
protectedTailDrainWindowStartedAt: 0,
Expand Down Expand Up @@ -468,6 +473,7 @@ export function toSessionMeta(row: SessionMetaRow): SessionMeta {
cachedM0ProjectIdentity: stringOrNull(row.cached_m0_project_identity),
lastObservedModelKey: stringOrNull(row.last_observed_model_key),
lastUsageContextLimit: numOrZero(row.last_usage_context_limit),
lastUsageObservedAt: numOrZero(row.last_usage_observed_at),
priorBoundaryOrdinal: Math.max(1, numOrZero(row.prior_boundary_ordinal) || 1),
protectedTailPolicyVersion: numOrZero(row.protected_tail_policy_version),
protectedTailDrainWindowStartedAt: numOrZero(row.protected_tail_drain_window_started_at),
Expand Down
1 change: 1 addition & 0 deletions packages/plugin/src/features/magic-context/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export interface SessionMeta {
cachedM0ProjectIdentity: string | null;
lastObservedModelKey: string | null;
lastUsageContextLimit: number;
lastUsageObservedAt: number;
priorBoundaryOrdinal: number;
protectedTailPolicyVersion: number;
protectedTailDrainWindowStartedAt: number;
Expand Down
Loading
Loading