pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
124 lines (123 loc) • 5.51 kB
JavaScript
/**
* Warm-server build-staleness detection (#535).
*
* The MCP server is long-lived (weeks, per #514/#256's warm/fresh doctrine) —
* it loads its code ONCE at process start and never re-reads disk. A
* `npm run build:dist` or a `git merge` that changes `dist/mcp/server.js` (or
* the in-place `mcp/server.js` during dev) never reaches an already-running
* warm server: it keeps serving the OLD schema/logic indefinitely. Dogfooding
* #517 caught this live — a post-#517 rebuild still answered with the
* pre-#517 `pilens_module_report` shape. That is the "plausible-but-wrong"
* failure mode the #240/#511 honesty doctrine exists to prevent, not a mere
* error — so the warm boundary must detect it and say so, never silently
* serve stale.
*
* Design: capture an mtime "stamp" of the server's own entry file at process
* start (module-level, once). On each call at the warm boundary, cheaply
* re-stat the SAME file and compare. A full content hash was considered and
* rejected for the hot path — mtime already changes on any rebuild/checkout
* that touches the file (build tools + git both update it), and hashing the
* whole bundle on every tool call is needless I/O; mtime is the same signal
* class the #492 cross-process reader already trusts for freshness.
*
Pure/testable: `computeBuildStamp`/`checkStaleness` take an injectable stat
* function so tests never touch the real filesystem or a real build.
*
* Throttle: re-stating on EVERY call would still be one syscall per call —
* cheap, but under a tool-call burst (e.g. a batch of `pilens_symbol_search`)
* that's needless repeated I/O for a condition that changes at most once per
* rebuild. `StalenessGate` re-stats at most once per `checkIntervalMs`
* (default 1000ms, matching the #492 precedent's "cheap per-call check, not
* per-call I/O" shape) and reuses the last verdict in between.
*/
import * as fs from "node:fs";
export const realStat = (targetPath) => {
try {
return fs.statSync(targetPath);
}
catch {
return undefined;
}
};
/**
* Captures the startup stamp for `entryPath` (the server's own loaded module
* file). Returns `undefined` if the entry can't be stat'd — e.g. running from
* a bundle/packaging layout where the on-disk file legitimately doesn't exist
* at that path; in that case staleness checking degrades to "always fresh"
* rather than false-flagging every call.
*/
export function computeBuildStamp(entryPath, stat = realStat) {
const info = stat(entryPath);
if (!info)
return undefined;
return { entryPath, mtimeMs: info.mtimeMs };
}
/**
* One-shot comparison: re-stats `stamp.entryPath` and compares against the
* startup mtime. A failed re-stat (file briefly missing mid-rebuild-write, or
* an installed-package layout) is treated as NOT stale — we only ever flag a
* build change we can positively observe, never assume one from an I/O
* hiccup (false positives would make every tool call route to fresh, which
* defeats the warm server's entire purpose).
*/
export function checkStaleness(stamp, stat = realStat) {
const info = stat(stamp.entryPath);
if (!info)
return { stale: false, stamp, currentMtimeMs: undefined };
return {
stale: info.mtimeMs !== stamp.mtimeMs,
stamp,
currentMtimeMs: info.mtimeMs,
};
}
/**
* Mtime-gated staleness gate for the hot call path (mirrors the #492
* cross-process reader's "one `fs.stat`, cache the verdict" shape): a burst
* of tool calls within `checkIntervalMs` of each other costs exactly ONE
* `fs.stat`, not one per call.
*/
export class StalenessGate {
stamp;
checkIntervalMs;
stat;
now;
lastCheckedAt;
lastVerdict = false;
constructor(stamp, options = {}) {
this.stamp = stamp;
this.checkIntervalMs = options.checkIntervalMs ?? 1000;
this.stat = options.stat ?? realStat;
this.now = options.now ?? Date.now;
}
/** True when the warm server's loaded code is known to be stale. */
isStale() {
if (!this.stamp)
return false; // no stamp captured — never flag
const nowMs = this.now();
if (this.lastCheckedAt !== undefined &&
nowMs - this.lastCheckedAt < this.checkIntervalMs) {
return this.lastVerdict;
}
this.lastCheckedAt = nowMs;
this.lastVerdict = checkStaleness(this.stamp, this.stat).stale;
return this.lastVerdict;
}
}
/** Env kill switch: `PI_LENS_WARM_STALENESS_CHECK=0` disables the whole guard. */
export function stalenessCheckEnabled(env = process.env) {
return env.PI_LENS_WARM_STALENESS_CHECK !== "0";
}
/**
* Standard advisory text appended when a tool result was served through the
* fresh-fork route because the warm build was stale.
*/
export const STALE_SERVED_BY_FRESH = "fresh (warm code stale — restart the Claude session to re-warm)";
/**
* Standard advisory text for tools that CANNOT be routed to a fresh fork
* (they depend on warm-process-only state: the in-memory review graph, the
* word-index cache, the warm LSP fleet) — honest degrade instead of routing.
*/
export const STALE_WARN_ONLY = "warmCodeStale: this result was served by the warm server's loaded code, " +
"which is older than the on-disk build (a rebuild or merge landed after " +
"this session started). Restart the Claude session to re-warm, or use " +
"pilens_rebuild + a tool that supports mode=fresh if available.";