Skip to content

Commit b6ce724

Browse files
author
Tehan
committed
feat(scheduler): add cache_ttl "never" sentinel for always-warm lanes
Lanes kept warm by external keepwarm mechanisms (prewarm proxies, dedicated cache-keep tools) re-warm the provider prompt cache out-of-band, so MC's idle>TTL heuristic false-positives: both TTL consumers (scheduler idle-execute and the ttl_idle m[0] fold) initiate a rebuild believed free that is actually a full paid cache-write (measured 450-560K tokens on large sessions). cache_ttl: "never" (string or per-model value, case-insensitive) disables both consumers: parseCacheTtl returns Infinity, so elapsed>ttl / elapsed>=ttl never fire. Rust scheduler mirrors the sentinel with u64::MAX (predicates already use saturating_sub). Status surfaces render "never expires (always-warm lane)" instead of a bogus countdown: /ctx-status, the TUI sidebar (JSON-safe cacheNeverExpires flag on StatusDetail), and Pi's status dialog (which previously parsed the TTL with a private fallback parser). hardCacheExpired extracted to a pure computeHardCacheExpired helper with direct coverage of the never-chain. Tradeoff documented in CONFIGURATION.md: on a genuinely cold start the free-fold window is not detected on such lanes; mutations then apply at the execute threshold.
1 parent e1fe14a commit b6ce724

20 files changed

Lines changed: 258 additions & 56 deletions

File tree

CONFIGURATION.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ Per-model overrides for mixed-model workflows:
8888

8989
Supported formats: `"30s"`, `"5m"`, `"1h"`.
9090

91+
**`"never"` sentinel:** set `cache_ttl` to `"never"` to disable the idle-TTL heuristic entirely. Both consumers —
92+
the scheduler (which converts defer passes to execute after TTL expiry) and the HARD-fold trigger (which folds
93+
m[1] into m[0] on a "free" prefix rebuild) — no longer act on idle time. Use this on lanes kept warm by an
94+
external keepwarm mechanism (prewarm proxies, dedicated cache-keep tools) where the idle heuristic false-positives
95+
and causes paid full-prefix cache-writes. After a genuine cold start (e.g. the keepwarm process died), MC
96+
won't detect the free-fold window on that lane — mutations then apply only at the execute threshold.
97+
9198
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.
9299

93100
---

assets/magic-context.schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1075,7 +1075,7 @@
10751075
},
10761076
"cache_ttl": {
10771077
"default": "5m",
1078-
"description": "Cache TTL: string (e.g. \"5m\") or per-model object ({ default: \"5m\", \"model-id\": \"10m\" })",
1078+
"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.",
10791079
"anyOf": [
10801080
{
10811081
"type": "string"

crates/mc-module/src/scheduler.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,9 @@ struct CompiledPattern {
288288
/// Parse a cache idle TTL string into milliseconds.
289289
pub fn parse_cache_ttl(ttl: &str) -> Result<u64, CacheTtlParseError> {
290290
let normalized = ttl.trim();
291+
if normalized.eq_ignore_ascii_case("never") {
292+
return Ok(u64::MAX);
293+
}
291294
let (number, multiplier) =
292295
if !normalized.is_empty() && normalized.chars().all(|c| c.is_ascii_digit()) {
293296
(normalized, 1.0)
@@ -1217,4 +1220,38 @@ mod tests {
12171220
PassClass::EmergencyForce
12181221
);
12191222
}
1223+
1224+
#[test]
1225+
fn parse_cache_ttl_never_returns_u64_max() {
1226+
assert_eq!(parse_cache_ttl("never"), Ok(u64::MAX));
1227+
assert_eq!(parse_cache_ttl("NEVER"), Ok(u64::MAX));
1228+
assert_eq!(parse_cache_ttl(" never "), Ok(u64::MAX));
1229+
assert_eq!(parse_cache_ttl("Never"), Ok(u64::MAX));
1230+
assert_eq!(parse_cache_ttl("5m"), Ok(300_000));
1231+
assert_eq!(parse_cache_ttl("bad-format"), Err(CacheTtlParseError));
1232+
}
1233+
1234+
#[test]
1235+
fn never_ttl_predicates_are_always_false() {
1236+
// Scheduler: elapsed > u64::MAX is never true.
1237+
assert!(!ttl_execute_fired(u64::MAX, 0, u64::MAX));
1238+
assert!(!ttl_execute_fired(1_000_000, 0, u64::MAX));
1239+
// Hard: elapsed >= u64::MAX is never true.
1240+
assert!(!ttl_hard_expired(u64::MAX, 1, u64::MAX));
1241+
assert!(!ttl_hard_expired(1_000_000, 1, u64::MAX));
1242+
}
1243+
1244+
#[test]
1245+
fn never_ttl_scheduler_stays_deferred() {
1246+
let mut inputs = base_inputs();
1247+
// 10-day-old last response, well past any normal TTL
1248+
inputs.session.last_response_time_ms = 1_000;
1249+
inputs.session.cache_ttl = "never".to_string();
1250+
inputs.now_ms = 1_000 + 10 * 24 * 60 * 60 * 1000;
1251+
// Below threshold, so TTL is the only path to execute
1252+
inputs.usage.percentage = 50.0;
1253+
let outcome = decide(&inputs);
1254+
assert!(!outcome.idle_ttl_fired);
1255+
assert_eq!(outcome.pass, PassDecision::Defer);
1256+
}
12201257
}

packages/docs/src/content/docs/reference/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ When and how aggressively Magic Context manages the session's context window. Pe
4646

4747
| Key | Type | Default | Description |
4848
|---|---|---|---|
49-
| `cache_ttl` | string \\| map<string, string> | `"5m"` | Cache TTL: string (e.g. "5m") or per-model object ({ default: "5m", "model-id": "10m" }) |
49+
| `cache_ttl` | string \\| map<string, string> | `"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. |
5050
| `execute_threshold_percentage` | number (20–80) \\| map<string, number (20–80)> | `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 |
5151
| `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. |
5252
| `execute_threshold_tokens.default` | number (5000–2000000) || |

packages/pi-plugin/src/dialogs/status-dialog.ts

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from "@earendil-works/pi-tui";
1313
import { getCompartments } from "@magic-context/core/features/magic-context/compartment-storage";
1414
import { getMemoryCount } from "@magic-context/core/features/magic-context/memory/storage-memory";
15+
import { parseCacheTtl } from "@magic-context/core/features/magic-context/scheduler";
1516
import type { ContextDatabase } from "@magic-context/core/features/magic-context/storage";
1617
import { getOrCreateSessionMeta } from "@magic-context/core/features/magic-context/storage-meta";
1718
import { getSessionWorkMetrics } from "@magic-context/core/features/magic-context/storage-meta-persisted";
@@ -322,7 +323,9 @@ function renderInner(
322323
} · ${
323324
s.cacheExpired
324325
? theme.fg("warning", "expired")
325-
: `${Math.round(s.cacheRemainingMs / 1000)}s remaining`
326+
: s.cacheRemainingMs === Number.POSITIVE_INFINITY
327+
? "never expires (always-warm lane)"
328+
: `${Math.round(s.cacheRemainingMs / 1000)}s remaining`
326329
}`,
327330
);
328331
lines.push("");
@@ -519,11 +522,20 @@ export function buildPiStatusDetail(
519522
},
520523
);
521524
const cacheTtl = meta.cacheTtl || "5m";
522-
const cacheTtlMs = parseTtlString(cacheTtl);
525+
let cacheTtlMs: number;
526+
try {
527+
cacheTtlMs = parseCacheTtl(cacheTtl);
528+
} catch {
529+
cacheTtlMs = 5 * 60 * 1000;
530+
}
531+
const neverExpires = cacheTtlMs === Number.POSITIVE_INFINITY;
523532
const elapsed =
524533
meta.lastResponseTime > 0 ? Date.now() - meta.lastResponseTime : 0;
525-
const cacheRemainingMs =
526-
meta.lastResponseTime > 0 ? Math.max(0, cacheTtlMs - elapsed) : cacheTtlMs;
534+
const cacheRemainingMs = neverExpires
535+
? Number.POSITIVE_INFINITY
536+
: meta.lastResponseTime > 0
537+
? Math.max(0, cacheTtlMs - elapsed)
538+
: cacheTtlMs;
527539
const cacheExpired = meta.lastResponseTime > 0 && cacheRemainingMs === 0;
528540
const historyBlockTokens = compartmentTokens + factTokens;
529541
const historyBudgetPercentage = deps.historyBudgetPercentage ?? 0.15;
@@ -748,22 +760,6 @@ function readPendingOpsCount(db: ContextDatabase, sessionId: string): number {
748760
}
749761
}
750762

751-
function parseTtlString(ttl: string): number {
752-
const match = ttl.match(/^(\d+)(s|m|h)$/);
753-
if (!match) return 5 * 60 * 1000;
754-
const val = Number.parseInt(match[1] ?? "5", 10);
755-
switch (match[2]) {
756-
case "s":
757-
return val * 1000;
758-
case "m":
759-
return val * 60 * 1000;
760-
case "h":
761-
return val * 3600 * 1000;
762-
default:
763-
return 5 * 60 * 1000;
764-
}
765-
}
766-
767763
function safeRead<T>(fn: () => T, fallback: T): T {
768764
try {
769765
return fn();

packages/plugin/src/config/schema/magic-context.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -596,7 +596,7 @@ export const MagicContextConfigSchema = z
596596
.union([z.string(), z.object({ default: z.string() }).catchall(z.string())])
597597
.default("5m")
598598
.describe(
599-
'Cache TTL: string (e.g. "5m") or per-model object ({ default: "5m", "model-id": "10m" })',
599+
'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.',
600600
),
601601
toast_duration_ms: z
602602
.number()

packages/plugin/src/features/magic-context/scheduler.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,36 @@ describe("createScheduler", () => {
119119

120120
expect(decision).toBe("defer");
121121
});
122+
123+
it("never executes from TTL idle when cacheTtl is 'never'", () => {
124+
const scheduler = createScheduler({ executeThresholdPercentage: 65 });
125+
// Last response was 10 days ago — well past any normal TTL.
126+
const tenDaysAgo = BASE_TIME - 10 * 24 * 60 * 60 * 1000;
127+
const sessionMeta = createSessionMeta({
128+
cacheTtl: "never",
129+
lastResponseTime: tenDaysAgo,
130+
});
131+
// Percentage is below threshold, so the only path to "execute" is TTL expiry.
132+
const contextUsage = createContextUsage(50);
133+
134+
const decision = scheduler.shouldExecute(sessionMeta, contextUsage, BASE_TIME);
135+
136+
expect(decision).toBe("defer");
137+
});
138+
139+
it("still executes on threshold when cacheTtl is 'never'", () => {
140+
const scheduler = createScheduler({ executeThresholdPercentage: 65 });
141+
const sessionMeta = createSessionMeta({
142+
cacheTtl: "never",
143+
lastResponseTime: BASE_TIME - 10_000,
144+
});
145+
// At threshold — should still execute.
146+
const contextUsage = createContextUsage(65);
147+
148+
const decision = scheduler.shouldExecute(sessionMeta, contextUsage, BASE_TIME);
149+
150+
expect(decision).toBe("execute");
151+
});
122152
});
123153

124154
describe("parseCacheTtl", () => {
@@ -138,6 +168,13 @@ describe("parseCacheTtl", () => {
138168
expect(milliseconds).toBe(300_000);
139169
});
140170

171+
it("returns Infinity for 'never' regardless of casing and whitespace", () => {
172+
expect(parseCacheTtl("never")).toBe(Number.POSITIVE_INFINITY);
173+
expect(parseCacheTtl("NEVER")).toBe(Number.POSITIVE_INFINITY);
174+
expect(parseCacheTtl(" never ")).toBe(Number.POSITIVE_INFINITY);
175+
expect(parseCacheTtl("Never")).toBe(Number.POSITIVE_INFINITY);
176+
});
177+
141178
it("throws on invalid ttl format", () => {
142179
expect(() => parseCacheTtl("bad-format")).toThrow();
143180
});

packages/plugin/src/features/magic-context/scheduler.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ interface SchedulerConfig {
3030
export function parseCacheTtl(ttl: string): number {
3131
const normalizedTtl = ttl.trim();
3232

33+
// "never" sentinel: lanes kept warm by external keepwarm proxies — the idle
34+
// heuristic is disabled, so MC never initiates a rebuild based on elapsed time.
35+
if (normalizedTtl.toLowerCase() === "never") {
36+
return Number.POSITIVE_INFINITY;
37+
}
38+
3339
// Intentional: bare numeric strings are treated as milliseconds. The setup CLI writes
3440
// "5m" or "59m" so users don't encounter bare numbers through normal config paths.
3541
if (NUMERIC_PATTERN.test(normalizedTtl)) {

packages/plugin/src/hooks/magic-context/execute-status.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,21 @@ describe("executeStatus", () => {
6969
expect(status).not.toContain("[clamped:");
7070
db.close();
7171
});
72+
73+
test("renders 'never expires' for cacheTtl 'never'", () => {
74+
const db = new Database(":memory:");
75+
initializeDatabase(db);
76+
getOrCreateSessionMeta(db, SESSION_ID);
77+
db.prepare("UPDATE session_meta SET cache_ttl = 'never' WHERE session_id = ?").run(
78+
SESSION_ID,
79+
);
80+
81+
const status = executeStatus(db, SESSION_ID, 20);
82+
83+
expect(status).toContain("- Configured: never");
84+
expect(status).toContain("- Remaining: never expires (always-warm lane)");
85+
expect(status).toContain("never expires");
86+
expect(status).not.toContain("Infinity");
87+
db.close();
88+
});
7289
});

packages/plugin/src/hooks/magic-context/execute-status.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@ export function executeStatus(
127127
"### Cache TTL",
128128
`- Configured: ${meta.cacheTtl}`,
129129
`- Last response: ${meta.lastResponseTime > 0 ? `${Math.round(elapsed / 1000)}s ago` : "never"}`,
130-
`- Remaining: ${cacheExpired ? "expired" : `${Math.round(remainingMs / 1000)}s`}`,
131-
`- Queue will auto-execute: ${cacheExpired ? "yes (cache expired)" : `when TTL expires or context >= ${executeThresholdPercentage}%`}`,
130+
`- Remaining: ${cacheExpired ? "expired" : ttlMs === Number.POSITIVE_INFINITY ? "never expires (always-warm lane)" : `${Math.round(remainingMs / 1000)}s`}`,
131+
`- Queue will auto-execute: ${cacheExpired ? "yes (cache expired)" : ttlMs === Number.POSITIVE_INFINITY ? `when context >= ${executeThresholdPercentage}%` : `when TTL expires or context >= ${executeThresholdPercentage}%`}`,
132132
"",
133133
"### Execute Threshold",
134134
`- Execute threshold: ${formatExecuteThreshold(thresholdDetail, displayContextLimit)}`,

0 commit comments

Comments
 (0)