diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 93376a89c..6d6ab5e52 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -88,6 +88,13 @@ Per-model overrides for mixed-model workflows: Supported formats: `"30s"`, `"5m"`, `"1h"`. +**`"never"` sentinel:** set `cache_ttl` to `"never"` to disable the idle-TTL heuristic entirely. Both consumers — +the scheduler (which converts defer passes to execute after TTL expiry) and the HARD-fold trigger (which folds +m[1] into m[0] on a "free" prefix rebuild) — no longer act on idle time. Use this on lanes kept warm by an +external keepwarm mechanism (prewarm proxies, dedicated cache-keep tools) where the idle heuristic false-positives +and causes paid full-prefix cache-writes. After a genuine cold start (e.g. the keepwarm process died), MC +won't detect the free-fold window on that lane — mutations then apply only at the execute threshold. + Higher-tier models with longer cache windows benefit from a longer TTL. Setting it too low wastes cache hits. Setting it too high delays reduction on long sessions. --- diff --git a/assets/magic-context.schema.json b/assets/magic-context.schema.json index aaac49342..4f630cf3e 100644 --- a/assets/magic-context.schema.json +++ b/assets/magic-context.schema.json @@ -1075,7 +1075,7 @@ }, "cache_ttl": { "default": "5m", - "description": "Cache TTL: string (e.g. \"5m\") or per-model object ({ default: \"5m\", \"model-id\": \"10m\" })", + "description": "Cache TTL: string (e.g. \"5m\", \"1h\", \"30s\") or per-model object ({ default: \"5m\", \"model-id\": \"10m\" }). Set to \"never\" for lanes kept warm by an external keepwarm proxy — disables the idle-TTL heuristic so MC never initiates a rebuild based on elapsed time.", "anyOf": [ { "type": "string" diff --git a/crates/mc-module/src/scheduler.rs b/crates/mc-module/src/scheduler.rs index 3120f0c3d..d460a3111 100644 --- a/crates/mc-module/src/scheduler.rs +++ b/crates/mc-module/src/scheduler.rs @@ -288,6 +288,9 @@ struct CompiledPattern { /// Parse a cache idle TTL string into milliseconds. pub fn parse_cache_ttl(ttl: &str) -> Result { let normalized = ttl.trim(); + if normalized.eq_ignore_ascii_case("never") { + return Ok(u64::MAX); + } let (number, multiplier) = if !normalized.is_empty() && normalized.chars().all(|c| c.is_ascii_digit()) { (normalized, 1.0) @@ -1217,4 +1220,38 @@ mod tests { PassClass::EmergencyForce ); } + + #[test] + fn parse_cache_ttl_never_returns_u64_max() { + assert_eq!(parse_cache_ttl("never"), Ok(u64::MAX)); + assert_eq!(parse_cache_ttl("NEVER"), Ok(u64::MAX)); + assert_eq!(parse_cache_ttl(" never "), Ok(u64::MAX)); + assert_eq!(parse_cache_ttl("Never"), Ok(u64::MAX)); + assert_eq!(parse_cache_ttl("5m"), Ok(300_000)); + assert_eq!(parse_cache_ttl("bad-format"), Err(CacheTtlParseError)); + } + + #[test] + fn never_ttl_predicates_are_always_false() { + // Scheduler: elapsed > u64::MAX is never true. + assert!(!ttl_execute_fired(u64::MAX, 0, u64::MAX)); + assert!(!ttl_execute_fired(1_000_000, 0, u64::MAX)); + // Hard: elapsed >= u64::MAX is never true. + assert!(!ttl_hard_expired(u64::MAX, 1, u64::MAX)); + assert!(!ttl_hard_expired(1_000_000, 1, u64::MAX)); + } + + #[test] + fn never_ttl_scheduler_stays_deferred() { + let mut inputs = base_inputs(); + // 10-day-old last response, well past any normal TTL + inputs.session.last_response_time_ms = 1_000; + inputs.session.cache_ttl = "never".to_string(); + inputs.now_ms = 1_000 + 10 * 24 * 60 * 60 * 1000; + // Below threshold, so TTL is the only path to execute + inputs.usage.percentage = 50.0; + let outcome = decide(&inputs); + assert!(!outcome.idle_ttl_fired); + assert_eq!(outcome.pass, PassDecision::Defer); + } } diff --git a/packages/docs/src/content/docs/reference/configuration.md b/packages/docs/src/content/docs/reference/configuration.md index 633147e32..9ce7cf965 100644 --- a/packages/docs/src/content/docs/reference/configuration.md +++ b/packages/docs/src/content/docs/reference/configuration.md @@ -46,7 +46,7 @@ When and how aggressively Magic Context manages the session's context window. Pe | Key | Type | Default | Description | |---|---|---|---| -| `cache_ttl` | string \\| map | `"5m"` | Cache TTL: string (e.g. "5m") or per-model object ({ default: "5m", "model-id": "10m" }) | +| `cache_ttl` | string \\| map | `"5m"` | Cache TTL: string (e.g. "5m", "1h", "30s") or per-model object ({ default: "5m", "model-id": "10m" }). Set to "never" for lanes kept warm by an external keepwarm proxy — disables the idle-TTL heuristic so MC never initiates a rebuild based on elapsed time. | | `execute_threshold_percentage` | number (20–80) \\| map | `65` | Context percentage that forces queued operations to execute. Number or per-model object ({ default: 65, "provider/model": 45 }). Values above 80 are rejected because the runtime caps at 80% for cache safety (MAX_EXECUTE_THRESHOLD). Default: DEFAULT_EXECUTE_THRESHOLD_PERCENTAGE | | `execute_threshold_tokens` | object | — | Absolute token thresholds per model. When matched, overrides execute_threshold_percentage for that model. Accepts `default` for all models or per-model keys. Values above 80% × context_limit are clamped with a warning log. Min 5_000, max 2_000_000. | | `execute_threshold_tokens.default` | number (5000–2000000) | — | | diff --git a/packages/pi-plugin/src/dialogs/status-dialog.ts b/packages/pi-plugin/src/dialogs/status-dialog.ts index b6605dd8f..79b3e4f52 100644 --- a/packages/pi-plugin/src/dialogs/status-dialog.ts +++ b/packages/pi-plugin/src/dialogs/status-dialog.ts @@ -12,6 +12,7 @@ import { } from "@earendil-works/pi-tui"; import { getCompartments } from "@magic-context/core/features/magic-context/compartment-storage"; import { getMemoryCount } from "@magic-context/core/features/magic-context/memory/storage-memory"; +import { parseCacheTtl } from "@magic-context/core/features/magic-context/scheduler"; import type { ContextDatabase } from "@magic-context/core/features/magic-context/storage"; import { getOrCreateSessionMeta } from "@magic-context/core/features/magic-context/storage-meta"; import { getSessionWorkMetrics } from "@magic-context/core/features/magic-context/storage-meta-persisted"; @@ -322,7 +323,9 @@ function renderInner( } · ${ s.cacheExpired ? theme.fg("warning", "expired") - : `${Math.round(s.cacheRemainingMs / 1000)}s remaining` + : s.cacheRemainingMs === Number.POSITIVE_INFINITY + ? "never expires (always-warm lane)" + : `${Math.round(s.cacheRemainingMs / 1000)}s remaining` }`, ); lines.push(""); @@ -519,11 +522,20 @@ export function buildPiStatusDetail( }, ); const cacheTtl = meta.cacheTtl || "5m"; - const cacheTtlMs = parseTtlString(cacheTtl); + let cacheTtlMs: number; + try { + cacheTtlMs = parseCacheTtl(cacheTtl); + } catch { + cacheTtlMs = 5 * 60 * 1000; + } + const neverExpires = cacheTtlMs === Number.POSITIVE_INFINITY; const elapsed = meta.lastResponseTime > 0 ? Date.now() - meta.lastResponseTime : 0; - const cacheRemainingMs = - meta.lastResponseTime > 0 ? Math.max(0, cacheTtlMs - elapsed) : cacheTtlMs; + const cacheRemainingMs = neverExpires + ? Number.POSITIVE_INFINITY + : meta.lastResponseTime > 0 + ? Math.max(0, cacheTtlMs - elapsed) + : cacheTtlMs; const cacheExpired = meta.lastResponseTime > 0 && cacheRemainingMs === 0; const historyBlockTokens = compartmentTokens + factTokens; const historyBudgetPercentage = deps.historyBudgetPercentage ?? 0.15; @@ -748,22 +760,6 @@ function readPendingOpsCount(db: ContextDatabase, sessionId: string): number { } } -function parseTtlString(ttl: string): number { - const match = ttl.match(/^(\d+)(s|m|h)$/); - if (!match) return 5 * 60 * 1000; - const val = Number.parseInt(match[1] ?? "5", 10); - switch (match[2]) { - case "s": - return val * 1000; - case "m": - return val * 60 * 1000; - case "h": - return val * 3600 * 1000; - default: - return 5 * 60 * 1000; - } -} - function safeRead(fn: () => T, fallback: T): T { try { return fn(); diff --git a/packages/plugin/src/config/schema/magic-context.ts b/packages/plugin/src/config/schema/magic-context.ts index e9643bac7..163af70e5 100644 --- a/packages/plugin/src/config/schema/magic-context.ts +++ b/packages/plugin/src/config/schema/magic-context.ts @@ -596,7 +596,7 @@ export const MagicContextConfigSchema = z .union([z.string(), z.object({ default: z.string() }).catchall(z.string())]) .default("5m") .describe( - 'Cache TTL: string (e.g. "5m") or per-model object ({ default: "5m", "model-id": "10m" })', + 'Cache TTL: string (e.g. "5m", "1h", "30s") or per-model object ({ default: "5m", "model-id": "10m" }). Set to "never" for lanes kept warm by an external keepwarm proxy — disables the idle-TTL heuristic so MC never initiates a rebuild based on elapsed time.', ), toast_duration_ms: z .number() diff --git a/packages/plugin/src/features/magic-context/scheduler.test.ts b/packages/plugin/src/features/magic-context/scheduler.test.ts index e7bd60319..d1e4d5bee 100644 --- a/packages/plugin/src/features/magic-context/scheduler.test.ts +++ b/packages/plugin/src/features/magic-context/scheduler.test.ts @@ -119,6 +119,36 @@ describe("createScheduler", () => { expect(decision).toBe("defer"); }); + + it("never executes from TTL idle when cacheTtl is 'never'", () => { + const scheduler = createScheduler({ executeThresholdPercentage: 65 }); + // Last response was 10 days ago — well past any normal TTL. + const tenDaysAgo = BASE_TIME - 10 * 24 * 60 * 60 * 1000; + const sessionMeta = createSessionMeta({ + cacheTtl: "never", + lastResponseTime: tenDaysAgo, + }); + // Percentage is below threshold, so the only path to "execute" is TTL expiry. + const contextUsage = createContextUsage(50); + + const decision = scheduler.shouldExecute(sessionMeta, contextUsage, BASE_TIME); + + expect(decision).toBe("defer"); + }); + + it("still executes on threshold when cacheTtl is 'never'", () => { + const scheduler = createScheduler({ executeThresholdPercentage: 65 }); + const sessionMeta = createSessionMeta({ + cacheTtl: "never", + lastResponseTime: BASE_TIME - 10_000, + }); + // At threshold — should still execute. + const contextUsage = createContextUsage(65); + + const decision = scheduler.shouldExecute(sessionMeta, contextUsage, BASE_TIME); + + expect(decision).toBe("execute"); + }); }); describe("parseCacheTtl", () => { @@ -138,6 +168,13 @@ describe("parseCacheTtl", () => { expect(milliseconds).toBe(300_000); }); + it("returns Infinity for 'never' regardless of casing and whitespace", () => { + expect(parseCacheTtl("never")).toBe(Number.POSITIVE_INFINITY); + expect(parseCacheTtl("NEVER")).toBe(Number.POSITIVE_INFINITY); + expect(parseCacheTtl(" never ")).toBe(Number.POSITIVE_INFINITY); + expect(parseCacheTtl("Never")).toBe(Number.POSITIVE_INFINITY); + }); + it("throws on invalid ttl format", () => { expect(() => parseCacheTtl("bad-format")).toThrow(); }); diff --git a/packages/plugin/src/features/magic-context/scheduler.ts b/packages/plugin/src/features/magic-context/scheduler.ts index 6f10b1e18..5f550c977 100644 --- a/packages/plugin/src/features/magic-context/scheduler.ts +++ b/packages/plugin/src/features/magic-context/scheduler.ts @@ -30,6 +30,12 @@ interface SchedulerConfig { export function parseCacheTtl(ttl: string): number { const normalizedTtl = ttl.trim(); + // "never" sentinel: lanes kept warm by external keepwarm proxies — the idle + // heuristic is disabled, so MC never initiates a rebuild based on elapsed time. + if (normalizedTtl.toLowerCase() === "never") { + return Number.POSITIVE_INFINITY; + } + // Intentional: bare numeric strings are treated as milliseconds. The setup CLI writes // "5m" or "59m" so users don't encounter bare numbers through normal config paths. if (NUMERIC_PATTERN.test(normalizedTtl)) { diff --git a/packages/plugin/src/hooks/magic-context/execute-status.test.ts b/packages/plugin/src/hooks/magic-context/execute-status.test.ts index c0d55f89d..6b7c64cdc 100644 --- a/packages/plugin/src/hooks/magic-context/execute-status.test.ts +++ b/packages/plugin/src/hooks/magic-context/execute-status.test.ts @@ -69,4 +69,21 @@ describe("executeStatus", () => { expect(status).not.toContain("[clamped:"); db.close(); }); + + test("renders 'never expires' for cacheTtl 'never'", () => { + const db = new Database(":memory:"); + initializeDatabase(db); + getOrCreateSessionMeta(db, SESSION_ID); + db.prepare("UPDATE session_meta SET cache_ttl = 'never' WHERE session_id = ?").run( + SESSION_ID, + ); + + const status = executeStatus(db, SESSION_ID, 20); + + expect(status).toContain("- Configured: never"); + expect(status).toContain("- Remaining: never expires (always-warm lane)"); + expect(status).toContain("never expires"); + expect(status).not.toContain("Infinity"); + db.close(); + }); }); diff --git a/packages/plugin/src/hooks/magic-context/execute-status.ts b/packages/plugin/src/hooks/magic-context/execute-status.ts index af2adf63a..e0ad77198 100644 --- a/packages/plugin/src/hooks/magic-context/execute-status.ts +++ b/packages/plugin/src/hooks/magic-context/execute-status.ts @@ -127,8 +127,8 @@ export function executeStatus( "### Cache TTL", `- Configured: ${meta.cacheTtl}`, `- Last response: ${meta.lastResponseTime > 0 ? `${Math.round(elapsed / 1000)}s ago` : "never"}`, - `- Remaining: ${cacheExpired ? "expired" : `${Math.round(remainingMs / 1000)}s`}`, - `- Queue will auto-execute: ${cacheExpired ? "yes (cache expired)" : `when TTL expires or context >= ${executeThresholdPercentage}%`}`, + `- Remaining: ${cacheExpired ? "expired" : ttlMs === Number.POSITIVE_INFINITY ? "never expires (always-warm lane)" : `${Math.round(remainingMs / 1000)}s`}`, + `- Queue will auto-execute: ${cacheExpired ? "yes (cache expired)" : ttlMs === Number.POSITIVE_INFINITY ? `when context >= ${executeThresholdPercentage}%` : `when TTL expires or context >= ${executeThresholdPercentage}%`}`, "", "### Execute Threshold", `- Execute threshold: ${formatExecuteThreshold(thresholdDetail, displayContextLimit)}`, diff --git a/packages/plugin/src/hooks/magic-context/m0m1-taxonomy.test.ts b/packages/plugin/src/hooks/magic-context/m0m1-taxonomy.test.ts index 1b294f6cb..ff636851f 100644 --- a/packages/plugin/src/hooks/magic-context/m0m1-taxonomy.test.ts +++ b/packages/plugin/src/hooks/magic-context/m0m1-taxonomy.test.ts @@ -241,6 +241,30 @@ describe("m[0]/m[1] materialization taxonomy", () => { expect(again.reason).not.toBe("ttl_idle"); }); + it("consumer-contract: mustMaterialize respects cacheExpired=false (never-ttl lanes stay warm)", () => { + db = makeDb(); + const projectDirectory = makeProjectDir(); + appendCompartments(db, SESSION_ID, [compartment(0, "A", "Alpha baseline")]); + pass({ projectDirectory, isCacheBustingPass: true }); + + // "never" resolves to Infinity in parseCacheTtl, so hardCacheExpired is + // always false. Verify mustMaterialize does NOT fire ttl_idle even when + // lastResponseTime is ancient — the "never" lane stays warm. + const tPast = Date.now() - 10 * 24 * 60 * 60 * 1000; // 10 days ago + db.prepare( + "UPDATE session_meta SET cached_m0_materialized_at = ? WHERE session_id = ?", + ).run(tPast - 1000, SESSION_ID); + const neverHard: M0HardSignals = { + ...BASE_HARD, + cacheExpired: false, // "never" → hardCacheExpired stays false + lastResponseTime: tPast, + }; + + const result = pass({ projectDirectory, isCacheBustingPass: true, hard: neverHard }); + expect(result.reason).not.toBe("ttl_idle"); + expect(result.rematerialized).toBe(false); + }); + it("pressure backstop: small m[0] + large m[1] folds via the absolute m[1] cap", () => { // The ratio test (m1 > 15% of m0) is suppressed when m[0] is below the // 2000-char floor, so without an absolute cap a tiny-m[0] session could diff --git a/packages/plugin/src/hooks/magic-context/transform-context-state.test.ts b/packages/plugin/src/hooks/magic-context/transform-context-state.test.ts index a9d09c50b..17a74d504 100644 --- a/packages/plugin/src/hooks/magic-context/transform-context-state.test.ts +++ b/packages/plugin/src/hooks/magic-context/transform-context-state.test.ts @@ -10,6 +10,7 @@ import { updateSessionMeta, } from "../../features/magic-context/storage"; import type { ContextUsage } from "../../features/magic-context/types"; +import { computeHardCacheExpired } from "./transform"; import { loadContextUsage } from "./transform-context-state"; const tempDirs: string[] = []; @@ -132,3 +133,69 @@ describe("loadContextUsage", () => { expect(contextUsageMap.has("ses-missing")).toBe(false); }); }); + +describe("computeHardCacheExpired", () => { + it("returns false for 'never' even with a 10-day-old lastResponseTime", () => { + const now = Date.now(); + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + expect(computeHardCacheExpired("never", tenDaysAgo, now)).toBe(false); + expect(computeHardCacheExpired("NEVER", tenDaysAgo, now)).toBe(false); + }); + + it("returns true when idle exceeds the TTL", () => { + const now = Date.now(); + const tenMinutesAgo = now - 10 * 60 * 1000; + expect(computeHardCacheExpired("5m", tenMinutesAgo, now)).toBe(true); + }); + + it("returns false when idle is within the TTL", () => { + const now = Date.now(); + const oneMinuteAgo = now - 60 * 1000; + expect(computeHardCacheExpired("5m", oneMinuteAgo, now)).toBe(false); + }); + + it("returns false when lastResponseTime is 0 (never responded)", () => { + expect(computeHardCacheExpired("5m", 0, Date.now())).toBe(false); + }); + + it("falls back to 5m on invalid TTL", () => { + const now = Date.now(); + const sixMinutesAgo = now - 6 * 60 * 1000; + expect(computeHardCacheExpired("garbage", sixMinutesAgo, now)).toBe(true); + }); + + it("invokes onInvalid callback for invalid TTL but not for valid ones", () => { + const now = Date.now(); + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + const invalidErrors: unknown[] = []; + + // Invalid TTL: callback fires + 5m fallback applied + const result = computeHardCacheExpired("bad-format", tenDaysAgo, now, (error) => { + invalidErrors.push(error); + }); + expect(invalidErrors.length).toBe(1); + expect(invalidErrors[0]).toBeInstanceOf(Error); + expect(result).toBe(true); // 5m fallback, 10-day-old → expired + + // Valid TTLs: callback NOT invoked + const validErrors: unknown[] = []; + computeHardCacheExpired("never", tenDaysAgo, now, (error) => { + validErrors.push(error); + }); + expect(validErrors.length).toBe(0); + + computeHardCacheExpired("5m", tenDaysAgo, now, (error) => { + validErrors.push(error); + }); + expect(validErrors.length).toBe(0); + }); + + it("omitting onInvalid is harmless (5m fallback still applies)", () => { + const now = Date.now(); + const tenDaysAgo = now - 10 * 24 * 60 * 60 * 1000; + // No callback — should not throw, should still fall back to 5m + expect(computeHardCacheExpired("bad-format", tenDaysAgo, now)).toBe(true); + // "never" with no callback still works + expect(computeHardCacheExpired("never", tenDaysAgo, now)).toBe(false); + }); +}); diff --git a/packages/plugin/src/hooks/magic-context/transform.ts b/packages/plugin/src/hooks/magic-context/transform.ts index cbd98a280..91d4efae1 100644 --- a/packages/plugin/src/hooks/magic-context/transform.ts +++ b/packages/plugin/src/hooks/magic-context/transform.ts @@ -235,6 +235,33 @@ export function __getMessageTokensCacheForTest( return getMessageTokensCache(sessionId); } +/** + * Compute whether the provider cache expired due to idle time. + * Extracted so callers that don't run the full transform pipeline can still + * evaluate the TTL idle window with the same parseCacheTtl semantics. + * + * Returns false when cacheTtl is "never" (Infinity) because any finite + * elapsed time is < Infinity. + * + * @param onInvalid Optional callback invoked when cacheTtl fails to parse; + * the 5m fallback is applied AFTER the callback returns. + */ +export function computeHardCacheExpired( + cacheTtl: string, + lastResponseTime: number, + now: number, + onInvalid?: (error: unknown) => void, +): boolean { + let ttlMs: number; + try { + ttlMs = parseCacheTtl(cacheTtl); + } catch (error) { + onInvalid?.(error); + ttlMs = 5 * 60 * 1000; + } + return lastResponseTime > 0 && now - lastResponseTime >= ttlMs; +} + /** * Extract the provider/model from the last assistant message in the array. * Used for early model-change detection before loadContextUsage. @@ -1870,16 +1897,15 @@ export function createTransform(deps: TransformDeps) { const hardModelKey = hardModel ? `${hardModel.providerID}/${hardModel.modelID}` : ""; const hardSystemHash = typeof sessionMeta.systemPromptHash === "string" ? sessionMeta.systemPromptHash : ""; - let hardTtlMs = 5 * 60 * 1000; - try { - hardTtlMs = parseCacheTtl(sessionMeta.cacheTtl); - } catch (error) { - passOutcome.record("invalid-cache-ttl-fallback"); - sessionLog(sessionId, "invalid cache_ttl; using the 5m default:", error); - } - const hardCacheExpired = - sessionMeta.lastResponseTime > 0 && - Date.now() - sessionMeta.lastResponseTime >= hardTtlMs; + const hardCacheExpired = computeHardCacheExpired( + sessionMeta.cacheTtl, + sessionMeta.lastResponseTime, + Date.now(), + (error) => { + passOutcome.record("invalid-cache-ttl-fallback"); + sessionLog(sessionId, "invalid cache_ttl; using the 5m default:", error); + }, + ); const m0HardSignals = { systemHash: hardSystemHash, modelKey: hardModelKey, diff --git a/packages/plugin/src/plugin/rpc-handlers.test.ts b/packages/plugin/src/plugin/rpc-handlers.test.ts index 0add5bb28..d8cc7e80b 100644 --- a/packages/plugin/src/plugin/rpc-handlers.test.ts +++ b/packages/plugin/src/plugin/rpc-handlers.test.ts @@ -321,3 +321,35 @@ describe("buildStatusDetail — history token reuse (council audit bg_51106601 # } }); }); + +describe("buildStatusDetail — cacheNeverExpires with 'never' TTL", () => { + test("sets cacheNeverExpires: true when cache_ttl is 'never'", () => { + const db = createTestDb(); + try { + const sessionId = "ses-status-never"; + const directory = process.cwd(); + + // Force-create the session meta row so the UPDATE lands on an existing row. + db.prepare(`INSERT INTO session_meta (session_id) VALUES (?)`).run(sessionId); + // Seed last_response_time: the cacheNeverExpires branch only runs + // inside `if (lastResponseTime > 0)` — without this the test would + // pass even if Infinity leaked into cacheRemainingMs. + db.prepare( + "UPDATE session_meta SET cache_ttl = ?, last_response_time = ? WHERE session_id = ?", + ).run("never", Date.now() - 60_000, sessionId); + + const detail = buildStatusDetail(db, sessionId, directory); + + expect(detail.cacheNeverExpires).toBe(true); + expect(detail.cacheExpired).toBe(false); + // Infinity must NOT leak into the numeric RPC field — JSON.stringify + // converts Infinity to null, violating the StatusDetail contract. + expect(detail.cacheRemainingMs).toBe(0); + const roundTripped = JSON.parse(JSON.stringify(detail)); + expect(roundTripped.cacheRemainingMs).toBe(0); + expect(roundTripped.cacheRemainingMs).not.toBeNull(); + } finally { + closeQuietly(db); + } + }); +}); diff --git a/packages/plugin/src/plugin/rpc-handlers.ts b/packages/plugin/src/plugin/rpc-handlers.ts index 837aadaea..6f9ce6637 100644 --- a/packages/plugin/src/plugin/rpc-handlers.ts +++ b/packages/plugin/src/plugin/rpc-handlers.ts @@ -7,6 +7,7 @@ import { getMostRecentTaskRunAt } from "../features/magic-context/dreamer/storag import { resolveProjectIdentity } from "../features/magic-context/memory/project-identity"; import { getMural } from "../features/magic-context/mural/storage-mural"; import { getEmbeddingCoverageStatus } from "../features/magic-context/project-embedding-registry"; +import { parseCacheTtl } from "../features/magic-context/scheduler"; import { type ContextDatabase as Database, openDatabase, @@ -144,20 +145,11 @@ async function loadRustSessionStatus( } } -function parseTtlString(ttl: string): number { - const match = ttl.match(/^(\d+)(s|m|h)$/); - if (!match) return 5 * 60 * 1000; - const val = Number.parseInt(match[1], 10); - const unit = match[2]; - switch (unit) { - case "s": - return val * 1000; - case "m": - return val * 60 * 1000; - case "h": - return val * 3600 * 1000; - default: - return 5 * 60 * 1000; +function safeParseTtl(ttl: string): number { + try { + return parseCacheTtl(ttl); + } catch { + return 5 * 60 * 1000; } } @@ -576,6 +568,7 @@ export function buildStatusDetail( cacheTtlMs: 0, cacheRemainingMs: 0, cacheExpired: false, + cacheNeverExpires: false, executeThreshold: 65, executeThresholdMode: "percentage", protectedTagCount: 20, @@ -702,11 +695,22 @@ export function buildStatusDetail( } else if (base.usagePercentage > 0) { detail.contextLimit = Math.round(base.inputTokens / (base.usagePercentage / 100)); } - detail.cacheTtlMs = parseTtlString(detail.cacheTtl); + detail.cacheTtlMs = safeParseTtl(detail.cacheTtl); + if (detail.cacheTtlMs === Number.POSITIVE_INFINITY) { + detail.cacheNeverExpires = true; + detail.cacheTtlMs = 0; + } if (detail.lastResponseTime > 0) { const elapsed = Date.now() - detail.lastResponseTime; - detail.cacheRemainingMs = Math.max(0, detail.cacheTtlMs - elapsed); - detail.cacheExpired = detail.cacheRemainingMs === 0; + if (detail.cacheNeverExpires) { + // Infinity does not survive JSON-RPC; cacheNeverExpires is the + // authoritative flag — the TUI keys on it first. + detail.cacheRemainingMs = 0; + detail.cacheExpired = false; + } else { + detail.cacheRemainingMs = Math.max(0, detail.cacheTtlMs - elapsed); + detail.cacheExpired = detail.cacheRemainingMs === 0; + } } // History compression diff --git a/packages/plugin/src/shared/rpc-types.ts b/packages/plugin/src/shared/rpc-types.ts index 8e48e29ee..32611470f 100644 --- a/packages/plugin/src/shared/rpc-types.ts +++ b/packages/plugin/src/shared/rpc-types.ts @@ -114,6 +114,8 @@ export interface StatusDetail extends SidebarSnapshot { cacheTtlMs: number; cacheRemainingMs: number; cacheExpired: boolean; + /** True when cacheTtl is "never" — the idle-TTL heuristic is disabled on this lane. */ + cacheNeverExpires?: boolean; executeThreshold: number; /** * Which config source produced `executeThreshold`. "tokens" means diff --git a/packages/plugin/src/tui-compiled/data/context-db.ts b/packages/plugin/src/tui-compiled/data/context-db.ts index 3f9a6e51c..9675c7e4e 100644 --- a/packages/plugin/src/tui-compiled/data/context-db.ts +++ b/packages/plugin/src/tui-compiled/data/context-db.ts @@ -194,6 +194,7 @@ export async function loadStatusDetail( cacheTtlMs: 0, cacheRemainingMs: 0, cacheExpired: false, + cacheNeverExpires: false, executeThreshold: 65, executeThresholdMode: "percentage", protectedTagCount: 20, diff --git a/packages/plugin/src/tui-compiled/index.tsx b/packages/plugin/src/tui-compiled/index.tsx index a4c43bc64..7c82b0102 100644 --- a/packages/plugin/src/tui-compiled/index.tsx +++ b/packages/plugin/src/tui-compiled/index.tsx @@ -573,7 +573,7 @@ const StatusDialog = props => { }, l: "Remaining", get v() { - return _$memo(() => !!s().cacheExpired)() ? "expired" : `${Math.round(s().cacheRemainingMs / 1000)}s`; + return _$memo(() => !!s().cacheExpired)() ? "expired" : _$memo(() => !!s().cacheNeverExpires)() ? "never expires (always-warm lane)" : `${Math.round(s().cacheRemainingMs / 1000)}s`; }, get fg() { return _$memo(() => !!s().cacheExpired)() ? t().warning : t().textMuted; @@ -585,7 +585,7 @@ const StatusDialog = props => { }, l: "Auto-execute", get v() { - return _$memo(() => !!s().cacheExpired)() ? "yes (expired)" : `at TTL or ≥${formatThresholdPercent(s().executeThreshold)}%`; + return _$memo(() => !!s().cacheExpired)() ? "yes (expired)" : _$memo(() => !!s().cacheNeverExpires)() ? `at ≥${formatThresholdPercent(s().executeThreshold)}%` : `at TTL or ≥${formatThresholdPercent(s().executeThreshold)}%`; }, get fg() { return t().textMuted; diff --git a/packages/plugin/src/tui/data/context-db.ts b/packages/plugin/src/tui/data/context-db.ts index 3f9a6e51c..9675c7e4e 100644 --- a/packages/plugin/src/tui/data/context-db.ts +++ b/packages/plugin/src/tui/data/context-db.ts @@ -194,6 +194,7 @@ export async function loadStatusDetail( cacheTtlMs: 0, cacheRemainingMs: 0, cacheExpired: false, + cacheNeverExpires: false, executeThreshold: 65, executeThresholdMode: "percentage", protectedTagCount: 20, diff --git a/packages/plugin/src/tui/index.tsx b/packages/plugin/src/tui/index.tsx index 707f77c6c..26d8f5247 100644 --- a/packages/plugin/src/tui/index.tsx +++ b/packages/plugin/src/tui/index.tsx @@ -312,8 +312,8 @@ const StatusDialog = (props: { api: TuiPluginApi; s: StatusDetail }) => { 0 ? `${Math.round(elapsed() / 1000)}s ago` : "never"} /> - - + + Memory