pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
224 lines (223 loc) • 9.65 kB
JavaScript
/**
* Quiet-window scheduler for pi 0.80.6's `agent_settled` extension event (#483).
*
* `agent_settled` fires after the SDK's `_runAgentPrompt` finally-block sets
* `_isAgentRunActive = false` — i.e. once the whole agent run (including any
* auto-retry/continue loop) is fully done, on BOTH normal completion and
* aborts/errors (it lives in a `finally`, not a success-only branch). It
* fires strictly after `agent_end`/`turn_end` for that run. Unlike
* `turn_end` — which fires while the next turn may already be looming and
* therefore bounds its cascade-settle wait tightly — `agent_settled` is a
* genuine idle window: nothing else is queued behind it until the user
* types again. That makes it the right home for expensive, deferrable work
* that would otherwise contend with live per-edit traffic.
*
* Feature detection: the SDK's extension registration (`pi.on(event, fn)`)
* pushes onto a plain `Map<string, handler[]>` keyed by the literal event
* string, with no allowlist or validation — registering an unknown event
* name is a silent no-op on older hosts (the emit side only look up
* handlers for events it actually emits). Registration therefore can't
* throw; we still wrap it in try/catch defensively, and every task run
* through this scheduler is independently isolated so a host that never
* fires the event simply never executes them.
*
* The SDK awaits each registered handler in sequence
* (`core/extensions/runner.js` `emit()`), and that `emit()` call is itself
* awaited inside `_emitAgentSettled()`, which is awaited inside
* `_runAgentPrompt`'s `finally`. A slow handler would therefore delay
* `_runAgentPrompt` returning — so the handler registered here must not
* await the task chain; it kicks the chain off unawaited (fire-and-forget)
* and returns immediately.
*/
import { readInstanceRegistry, updateHeartbeat, } from "./instance-registry.js";
import { logLatency } from "./latency-logger.js";
import { sampleProcesses } from "./resource-sampler.js";
import { toPositiveFinite } from "./env-utils.js";
// Module-level registry so future work (#458 Tier-3 reconcile, #236
// enrichment) can plug into the same scheduler without touching it.
const _tasks = [];
/**
* Register a task to run sequentially during the quiet window. Each task is
* isolated in its own try/catch — one throwing never prevents the rest from
* running, and no failure propagates out of `runQuietWindow`.
*/
export function registerQuietWindowTask(name, fn) {
_tasks.push({ name, fn });
}
/** Test-only: clear the task registry between test files/cases. */
export function _resetQuietWindowTasksForTests() {
_tasks.length = 0;
}
// --- Kill switch (lazy, memoized — house style per clients/runtime-config.ts) ---
let _enabledCache;
/** `PI_LENS_QUIET_WINDOW=0` disables the whole scheduler (no-op, no logging). */
export function isQuietWindowEnabled() {
if (_enabledCache !== undefined)
return _enabledCache;
_enabledCache = process.env.PI_LENS_QUIET_WINDOW !== "0";
return _enabledCache;
}
/** Test-only: clear the memoized kill-switch read. */
export function _resetQuietWindowEnabledForTests() {
_enabledCache = undefined;
}
const DEFAULT_QUIET_WINDOW_WAIT_MS = 15_000;
/**
* Bounded wait for the quiet-window's own settle attempts (currently just
* the carried-over cascade drain). Lazy env read, `Number.isFinite`-guarded
* so a malformed value falls back to the default instead of poisoning
* `Math.max`/`setTimeout` with `NaN` (see PR #109).
*/
export function quietWindowWaitMs() {
const raw = toPositiveFinite(process.env.PI_LENS_QUIET_WINDOW_WAIT_MS);
return raw > 0 ? raw : DEFAULT_QUIET_WINDOW_WAIT_MS;
}
// Re-entrancy guard: agent_settled can fire multiple times per session
// (once per completed/aborted run). If a previous quiet-window run is still
// in flight when the next fires, skip rather than queue/overlap.
let _inProgress = false;
/**
* Run every registered quiet-window task sequentially, logging a
* `quiet_window` phase to the latency log. Never throws — every task
* failure is swallowed and logged; callers should invoke this
* fire-and-forget (do not await inside an SDK-awaited event handler).
*/
export async function runQuietWindow(deps) {
// `runtime` is accepted for API symmetry with turn_end's deps shape and
// for future built-in tasks that may need it directly; today's built-ins
// close over `getRuntime` via registerBuiltinQuietWindowTasks instead.
const { dbg, cwd } = deps;
if (!isQuietWindowEnabled()) {
logLatency({
type: "phase",
filePath: cwd ?? "<pi-lens>",
phase: "quiet_window",
durationMs: 0,
metadata: { skipped: "disabled" },
});
return;
}
if (_inProgress) {
dbg("quiet_window: skipping — a previous run is still in progress");
logLatency({
type: "phase",
filePath: cwd ?? "<pi-lens>",
phase: "quiet_window",
durationMs: 0,
metadata: { skipped: "in-progress" },
});
return;
}
_inProgress = true;
const totalStart = Date.now();
const results = [];
try {
for (const task of _tasks) {
const taskStart = Date.now();
let ok = true;
try {
await task.fn();
}
catch (err) {
ok = false;
dbg(`quiet_window: task "${task.name}" failed: ${err}`);
}
results.push({
name: task.name,
durationMs: Date.now() - taskStart,
ok,
});
}
}
finally {
_inProgress = false;
logLatency({
type: "phase",
filePath: cwd ?? "<pi-lens>",
phase: "quiet_window",
durationMs: Date.now() - totalStart,
metadata: { tasks: results },
});
}
}
/**
* Register the two built-in quiet-window tasks (#483):
* 1. carried-over cascade settle — a second, more generous attempt at
* draining cascade computes still pending after the turn_end cap
* (the #450 carry-over set in RuntimeCoordinator).
* 2. instance-registry heartbeat refresh (#449) — off the turn hot path.
*
* Idempotent guard via a module flag so repeated calls (e.g. multiple
* extension activations in tests) don't double-register.
*/
let _builtinsRegistered = false;
export function registerBuiltinQuietWindowTasks(getRuntime) {
if (_builtinsRegistered)
return;
_builtinsRegistered = true;
registerQuietWindowTask("cascade_carry_over_settle", async () => {
const runtime = getRuntime();
await runtime.settleCascadeRuns(quietWindowWaitMs());
});
registerQuietWindowTask("instance_registry_heartbeat", async () => {
await updateHeartbeat(await buildHeartbeatResourcePatchBounded());
});
}
/**
* #620 sampling can never take longer than this to answer — on Windows it
* shells out to PowerShell/CIM (via `pidusage`), which is normally fast but
* has no hard upper bound the way an in-process computation would. Per this
* repo's "async work needs both bounds" convention (a bulk/background step
* needs a timeout, not just a happy-path await), a slow/hung sample must
* still let the quiet-window task — and, transitively, the caller awaiting
* `runQuietWindow` — return promptly rather than block on it indefinitely.
*/
const HEARTBEAT_SAMPLE_TIMEOUT_MS = 2000;
/** Race `buildHeartbeatResourcePatch` against `HEARTBEAT_SAMPLE_TIMEOUT_MS`;
* a timeout resolves to `{}` (same "leave everything untouched" semantics as
* any other sampling failure — see the module docstring below). */
async function buildHeartbeatResourcePatchBounded() {
return Promise.race([
buildHeartbeatResourcePatch(),
new Promise((resolve) => setTimeout(() => resolve({}), HEARTBEAT_SAMPLE_TIMEOUT_MS)),
]);
}
/**
* #620: sample this process's host CPU% plus every currently-recorded LSP
* child's CPU%/RSS, once per heartbeat tick, and shape it into the patch
* `updateHeartbeat` expects. Lives here (not clients/instance-registry.ts)
* to keep that module a pure data store with no `pidusage` dependency of its
* own — this is the one call site that knows both "what's the heartbeat
* cadence" (quiet-window, off the turn hot path) and "what pids to sample"
* (this process's own registry entry).
*
* Best-effort end to end: any failure (registry read, sampling) resolves to
* `{}` — an empty patch leaves `updateHeartbeat` free to fall back to its own
* `process.memoryUsage().rss` default and leaves cpu/child values untouched,
* never zeroed.
*/
async function buildHeartbeatResourcePatch() {
try {
const instances = await readInstanceRegistry();
const self = instances.find((instance) => instance.pid === process.pid);
const childPids = self?.lspChildren.map((child) => child.pid) ?? [];
const usage = await sampleProcesses([process.pid, ...childPids]);
const childUsage = {};
for (const pid of childPids) {
const sample = usage.get(pid);
if (sample)
childUsage[pid] = sample;
}
return {
cpuPercent: usage.get(process.pid)?.cpuPercent,
childUsage,
};
}
catch {
return {};
}
}
/** Test-only: undo registerBuiltinQuietWindowTasks' idempotency guard. */
export function _resetBuiltinQuietWindowRegistrationForTests() {
_builtinsRegistered = false;
}