UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

267 lines (266 loc) • 12.9 kB
/** * Tier-aware cascade-lane wait policy (#458, re-scope №2). * * The cascade/deferred lane (`computeCascadeForFile`'s neighbor-touch fan-out * in `clients/dispatch/integration.ts`) actively opens neighbor files against * their LSP client and waits up to a per-touch budget (~1000ms cold-snapshot / * 2000ms warm) for `textDocument/publishDiagnostics` before deciding the * neighbor is clean. For a Tier-3 server — one that is `push-only` AND known * to publish NOTHING on a clean→clean transition (see * docs/lsp-capability-matrix.md; today that's typescript-language-server, the * lone core-set instance) — that wait can never distinguish "clean" from * "still analyzing"; it always burns its full budget. Dogfooding measured * ~221 such `lsp_diagnostics_timeout` events/day. * * pi 0.80.6's `agent_settled` quiet window (#483, `clients/quiet-window.ts`) * gives the cascade lane a place to resolve that ambiguity OUT of the * per-touch budget: fire the touch (didOpen/didChange still happens, so the * server starts real work), record it as outstanding, and reconcile against * whatever landed in the client's diagnostics cache by the time the agent run * goes idle. A touch nothing arrived for by then is recorded `unresolved` — * never silently treated as `clean` (the #240 doctrine: a missing answer is * not an affirmative answer). * * This module is deliberately NOT hardcoded to server names for the "should * this file skip its in-lane wait" question: it reads the live capability * snapshot's `workspaceDiagnosticsSupport.mode` (from * `detectWorkspaceDiagnosticsSupport`, cached at `initialize`) and combines it * with the `silentOnClean` marker on that server's `DiagnosticStrategy` * (`server-strategies.ts`) — the same per-server behavioral-knowledge table * the rest of the LSP layer already uses. A server with no live snapshot yet, * or whose mode isn't `push-only`, or that isn't marked `silentOnClean`, is * NOT tier-3 — the caller keeps today's full in-lane wait. Fail-safe is * always "wait like before". * * #524/#529/#541/#558: a server id can now be backed by more than one actual * binary — "typescript" is classic typescript-language-server OR TS7's * native `tsc --lsp --stdio` (PR #526). PR #526 originally routed the * native-ts7 variant through the fail-safe "waits" path because * `silentOnClean` had only been measured against the classic server; #541 * (2026-07-11) briefly lifted that exclusion after a clean-signal probe run * appeared to show native-ts7 silent too. A follow-up dual-environment * re-measurement (2026-07-12, nightly CI on Linux AND a live local run on * Windows dev, same `typescript@7.0.2` both times) found native-ts7 now * publishes 2 version-less diagnostic sets on the clean transition * (`cleanPubs=2(v:0)`) — it is NOT silent. Classic is unaffected and * confirmed still silent (`cleanPubs=0(v:0)`) in the same run. This is * therefore an EVIDENCE-BASED revert, not the original unverified caution: * native-ts7's clean-signal behavior IS known, and it is "publishes, not * silent". The snapshot's `launchVariant` marker again routes a native-ts7 * snapshot through "waits" while the shared `silentOnClean` flag stays * `true` for classic. `scripts/probe-clean-signal.mjs`'s drift check no * longer compares native-ts7 rows against the shared marker (it now expects * `false` for them explicitly) — see that file's header for the regression * watch this sets up for a future TS7 build that becomes silent again. */ import { logCascade } from "../cascade-logger.js"; import { logLatency } from "../latency-logger.js"; import { normalizeMapKey } from "../path-utils.js"; import { registerQuietWindowTask } from "../quiet-window.js"; import { getServersForFileWithConfig } from "./config.js"; import { getStrategy } from "./server-strategies.js"; // --- Kill switch (lazy, memoized — house style per clients/runtime-config.ts / // clients/quiet-window.ts's isQuietWindowEnabled) --- let _enabledCache; /** `PI_LENS_TIER_AWARE_CASCADE=0` disables the whole feature: every cascade * touch waits in-lane exactly as it did before #458, no outstanding-touch * bookkeeping, no reconcile task registered. */ export function isTierAwareCascadeEnabled() { if (_enabledCache !== undefined) return _enabledCache; _enabledCache = process.env.PI_LENS_TIER_AWARE_CASCADE !== "0"; return _enabledCache; } /** Test-only: clear the memoized kill-switch read. */ export function _resetTierAwareCascadeEnabledForTests() { _enabledCache = undefined; } /** * Classify whether `filePath`'s PRIMARY language server is a cascade-lane * Tier-3 (push-only, silent-on-clean) server. Ambiguous or missing capability * data is always `"waits"` (today's behavior) — this function must never be * the reason a real answer gets missed. */ export function classifyCascadeWaitTier(lspService, filePath, snapshots) { void lspService; // kept in the signature for call-site clarity/typing only const servers = getServersForFileWithConfig(filePath).filter((s) => s.role !== "auxiliary"); const primary = servers[0]; if (!primary) return "waits"; const snapshot = snapshots.find((s) => s.serverId === primary.id); if (!snapshot) return "waits"; // no live snapshot yet — fail-safe const mode = snapshot.workspaceDiagnosticsSupport?.mode; if (mode !== "push-only") return "waits"; // pull servers are always affirmative const strategy = getStrategy(primary.id); if (strategy.silentOnClean !== true) return "waits"; // 2*/unknown push-only // #524/#529/#541/#558: `silentOnClean` on a server-id-keyed strategy is // only proven against the variant it was actually measured against. // "typescript" today means either classic typescript-language-server // (confirmed silent-on-clean, 2026-07-12 dual-environment re-measurement) // or TS7's native `tsc --lsp --stdio` (the SAME re-measurement found it // publishes 2 version-less diagnostic sets on clean — NOT silent, a // drift from the earlier #541 measurement). A native-ts7 snapshot must // NOT inherit the classic verdict: fall through to "waits", the same // ambiguous/fail-safe path an unmarked or non-push-only server already // takes. `launchVariant === "classic"` or absent (older snapshots that // predate the marker) keeps today's tier-3 behavior exactly. if (snapshot.launchVariant === "native-ts7") return "waits"; return "tier3-silent"; } // Keyed by normalized file path. A later touch for the same file simply // replaces the earlier entry (only the most recent touch matters — an // older touch's diagnostics, if they ever arrive, are still a strict superset // concern the newer touch already re-supersedes via didOpen/didChange). const _outstandingTouches = new Map(); /** * Record a Tier-3 cascade touch that skipped its in-lane wait. Called right * after the (still-performed) didOpen/didChange notify, before returning * without waiting. `touchedAt` must be sampled BEFORE the notify (see the * field doc) so the reconcile comparison can never misread a publish that * raced the record as pre-touch. */ export function recordOutstandingCascadeTouch(entry) { _outstandingTouches.set(normalizeMapKey(entry.filePath), entry); } /** Test-only: clear the outstanding-touch registry between test cases. */ export function _resetOutstandingCascadeTouchesForTests() { _outstandingTouches.clear(); } /** Test-only: peek at the registry without mutating it. */ export function _getOutstandingCascadeTouchesForTests() { return [..._outstandingTouches.values()]; } /** * Reconcile every outstanding Tier-3 touch against the LSP client's current * diagnostics cache. For each: * - If the client holds a PER-FILE diagnostics entry for the touched file * whose publish timestamp (`getAllDiagnostics()`'s `ts` — the max of the * push/pull timestamps for that file, client.ts) is newer than the * touch's pre-notify `touchedAt`, something published for THAT FILE since * the touch — record `resolved-found` (diagnostics present) or * `resolved-clean` (empty, but PROVEN empty by an actual publish for that * file after the touch). A client-WIDE signal is deliberately not used: * it advances on any file's publish, so it could falsely "prove" a silent * neighbor clean when a sibling neighbor published (#240). * - If nothing published for the file by settle time, record `unresolved` — * per the #240 doctrine this is NEVER treated as clean. * * Client lookup is WARM-ONLY (`getWarmClientForFile`): the quiet window must * never resurrect an idle-reaped server (a full tsserver spawn + cold index) * just to write a log line. A warm-miss ⇒ `unresolved`. * * Always drains the whole registry (each entry is independently resolved; * one entry's client lookup failing doesn't block the rest) and never * throws — callers (the quiet-window task) must be fail-safe. */ export async function reconcileOutstandingCascadeTouches(lspService) { const outcomes = []; const entries = [..._outstandingTouches.entries()]; _outstandingTouches.clear(); for (const [key, touch] of entries) { const ageMs = Date.now() - touch.touchedAt; try { const spawned = await lspService.getWarmClientForFile(touch.filePath); if (!spawned || spawned.client.serverId !== touch.serverId) { outcomes.push({ filePath: touch.filePath, serverId: touch.serverId, outcome: "unresolved", ageMs, }); continue; } const entry = spawned.client .getAllDiagnostics() .get(normalizeMapKey(touch.filePath)); if (!entry || entry.ts <= touch.touchedAt) { // No per-file publish since the touch (or ever) — a missing answer // is not a clean answer. outcomes.push({ filePath: touch.filePath, serverId: touch.serverId, outcome: "unresolved", ageMs, }); continue; } outcomes.push({ filePath: touch.filePath, serverId: touch.serverId, outcome: entry.diags.length > 0 ? "resolved-found" : "resolved-clean", ageMs, diagnosticCount: entry.diags.length, }); } catch (err) { outcomes.push({ filePath: touch.filePath, serverId: touch.serverId, outcome: "unresolved", ageMs, }); logLatency({ type: "phase", phase: "cascade_tier3_reconcile_error", filePath: key, durationMs: 0, metadata: { error: String(err) }, }); } } return outcomes; } let _reconcileTaskRegistered = false; /** * Register the Tier-3 reconcile task with the quiet-window scheduler * (`clients/quiet-window.ts`). Idempotent — safe to call more than once * (e.g. multiple extension activations in tests). */ export function registerCascadeTierReconcileTask(getLspService) { if (_reconcileTaskRegistered) return; _reconcileTaskRegistered = true; registerQuietWindowTask("cascade_tier3_reconcile", async () => { if (!isTierAwareCascadeEnabled()) return; if (_outstandingTouches.size === 0) return; const outcomes = await reconcileOutstandingCascadeTouches(getLspService()); if (outcomes.length === 0) return; let resolvedFound = 0; let resolvedClean = 0; let unresolved = 0; let ageSumMs = 0; for (const o of outcomes) { if (o.outcome === "resolved-found") resolvedFound++; else if (o.outcome === "resolved-clean") resolvedClean++; else unresolved++; ageSumMs += o.ageMs; } const avgAgeMs = Math.round(ageSumMs / outcomes.length); logCascade({ phase: "cascade_tier3_reconcile", filePath: "<quiet-window>", metadata: { count: outcomes.length, resolvedFound, resolvedClean, unresolved, avgAgeMs, outcomes, }, }); }); } /** Test-only: undo registerCascadeTierReconcileTask's idempotency guard. */ export function _resetCascadeTierReconcileRegistrationForTests() { _reconcileTaskRegistered = false; }