Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
2 changes: 1 addition & 1 deletion assets/magic-context.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
37 changes: 37 additions & 0 deletions crates/mc-module/src/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,9 @@ struct CompiledPattern {
/// Parse a cache idle TTL string into milliseconds.
pub fn parse_cache_ttl(ttl: &str) -> Result<u64, CacheTtlParseError> {
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)
Expand Down Expand Up @@ -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);
}
}
2 changes: 1 addition & 1 deletion packages/docs/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> | `"5m"` | Cache TTL: string (e.g. "5m") or per-model object ({ default: "5m", "model-id": "10m" }) |
| `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. |
| `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 |
| `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) | — | |
Expand Down
36 changes: 16 additions & 20 deletions packages/pi-plugin/src/dialogs/status-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<T>(fn: () => T, fallback: T): T {
try {
return fn();
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin/src/config/schema/magic-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
37 changes: 37 additions & 0 deletions packages/plugin/src/features/magic-context/scheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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();
});
Expand Down
6 changes: 6 additions & 0 deletions packages/plugin/src/features/magic-context/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
17 changes: 17 additions & 0 deletions packages/plugin/src/hooks/magic-context/execute-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
4 changes: 2 additions & 2 deletions packages/plugin/src/hooks/magic-context/execute-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`,
Expand Down
24 changes: 24 additions & 0 deletions packages/plugin/src/hooks/magic-context/m0m1-taxonomy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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);
});
});
Loading
Loading