pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
253 lines (252 loc) • 11.9 kB
JavaScript
/**
* Concurrent-session guard (#473).
*
* In-process subagent extensions (tintinweb/pi-subagents-style: a fresh
* `AgentSession` built and `bindExtensions()`-ed inside the SAME Node process
* as the parent pi session) reuse pi's process-global extension-loader cache,
* so the subagent's `session_start` re-invokes pi-lens's SAME module-scope
* singletons the parent is still using. Left unguarded, `handleSessionStart`
* destructively resets shared state (`resetLSPService({fast:true})` kills
* every live LSP client; `runtime.resetForSession()` bumps the session
* generation, silently orphaning the parent's in-flight continuations gated
* on `isCurrentSession`) while the parent is mid-turn.
*
* pi's own SDK contract only invalidates a captured ctx for SEQUENTIAL
* session replacement (`newSession`/`fork`/`switchSession`/`reload` —
* `ExtensionRunner.invalidate()`, called from `core/agent-session.js` on
* dispose). A concurrently-live sibling session's bind invalidates nothing.
* That asymmetry — is the PRIOR ctx still active or not — is the reliable,
* empirically-verified discriminator this module implements.
*
* Fail-safe direction is non-negotiable: whenever classification is
* uncertain, this module falls back to today's behavior (treat as a
* sequential replacement, i.e. run the full reset). It only suppresses the
* reset on POSITIVE evidence that a live sibling primary session exists.
*
* Kill switch: `PI_LENS_CONCURRENT_SESSION_GUARD=0` disables the guard
* entirely — every session_start classifies as if sequential (today's
* behavior), matching the lazy-env-read house style (see
* `subagent-mode.ts` / `runtime-config.ts`).
*/
/** Module-scope state — deliberately shared by construction. This module is
* loaded once per process by pi's process-global extension cache, so a
* concurrent in-process subagent session sees the SAME instance as the
* parent, which is exactly the signal this guard relies on. */
let activeCtx;
let activeSessionId;
let secondarySessionCount = 0;
/**
* PURE classifier — no I/O, no throws, fully unit-testable in isolation.
*
* Branches (fail-safe order matters):
* 1. No prior primary registered → `primary` (first session_start this
* process has seen; zero behavior change for the single-session case).
* 2. Prior exists, same stable session id → `sequential-replacement` (the
* same session re-announcing itself, e.g. resume/reload paths — must
* keep today's behavior, NOT be mistaken for a sibling).
* 3. Prior exists, `priorCtxActive === false` (confirmed invalidated) →
* `sequential-replacement` (the prior really was replaced/disposed —
* this IS the sequential case pi's own contract covers).
* 4. Prior exists, `priorCtxActive === true`, different session id →
* `concurrent-secondary` (positive evidence of a live sibling).
* 5. Prior exists, `priorCtxActive === undefined` (probe inconclusive) →
* `sequential-replacement` (fail toward today's behavior).
*/
export function classifySessionStart(input) {
const { hasPrior, priorCtxActive, sameSessionId } = input;
if (!hasPrior)
return "primary";
if (sameSessionId)
return "sequential-replacement";
if (priorCtxActive === false)
return "sequential-replacement";
if (priorCtxActive === true)
return "concurrent-secondary";
// priorCtxActive === undefined: inconclusive probe — fail-safe.
return "sequential-replacement";
}
/** Lazy env read (house style) — never memoized, so tests can flip it
* mid-run via `process.env` without a reset hook. */
function guardEnabled() {
return process.env.PI_LENS_CONCURRENT_SESSION_GUARD !== "0";
}
/**
* Impure probe: exercises a cheap, side-effect-free ctx accessor that the
* SDK's `ExtensionRunner.createContext()` wraps with `assertActive()`.
*
* Chosen accessor: `ctx.isIdle` (a bound method reading `runner.isIdleFn()`,
* i.e. pure process/session state — no mutation, no I/O). It is wrapped the
* same way every other guarded getter/method on the context is (`ui`,
* `cwd`, `mode`, `signal`, `sessionManager`, ...): `assertActive()` runs
* first and throws the SDK's stale-ctx error, matching the message fragment
* `"stale after session replacement"`
* (`ExtensionRunner.invalidate()`'s default message,
* `core/extensions/runner.js` in the installed
* `@earendil-works/pi-coding-agent` SDK dist). `isIdle` was picked over the
* plain getters (`cwd`, `mode`, `hasUI`) only for readability at call sites
* that already branch on idle state elsewhere in pi-lens; any of the other
* assertActive()-wrapped accessors would work identically for this probe.
*
* Returns:
* - `true` — the accessor call returned normally (ctx still active).
* - `false` — the accessor threw, and the message matches the known
* stale-ctx fragment (ctx confirmed invalidated by the SDK).
* - `undefined` — ctx has an unexpected shape (accessor missing / not a
* function), or the accessor threw something that does NOT look like the
* SDK's stale-ctx error (never assume — treat as inconclusive).
*
* Never throws out of this function; every branch is wrapped.
*/
export function probeCtxActive(ctx) {
try {
const candidate = ctx;
if (candidate === null ||
candidate === undefined ||
typeof candidate.isIdle !== "function") {
return undefined;
}
candidate.isIdle();
return true;
}
catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("stale after session replacement")) {
return false;
}
// Threw, but not the SDK's known stale-ctx error — don't guess.
return undefined;
}
}
/** Register the current session as the process's primary. Called for both
* `primary` and `sequential-replacement` classifications — a sequential
* replacement re-registers itself as the (new) primary, matching today's
* one-active-session-at-a-time behavior. */
export function registerPrimarySession(ctx, sessionId) {
activeCtx = ctx;
activeSessionId = sessionId;
secondarySessionCount = 0;
}
/** Register a concurrently-bound secondary (subagent) session. Does not
* touch the primary's ctx/session id. */
export function registerSecondarySession() {
secondarySessionCount += 1;
}
/**
* Classifies a `session_shutdown` firing the same fail-safe way as
* `classifySessionStart`: it is `secondary` ONLY when a DIFFERENT primary is
* registered (positively identified — ctx identity differs AND session ids
* are both known and differ) and that primary's ctx still probes active
* (positive evidence the shutting-down session is a live sibling, not the
* real parent exiting). Any inconclusive signal — no primary registered,
* same ctx object, same session id, EITHER session id unknown, or the
* primary's ctx probe returning `undefined`/`false` — classifies as
* `primary` so today's full-teardown behavior is preserved.
*
* The id-unknown guard matters: without it, a single ordinary session whose
* `sessionManager.getSessionId()` is unavailable (SDK drift) would register
* with `sessionId === undefined`, then at its OWN shutdown the same-id check
* couldn't fire, the probe of its own (still-live — pi invalidates on
* replacement, not shutdown) ctx would return true, and its teardown would
* be skipped on EVERY clean exit — leaking the LSP fleet (the #472 orphan
* class). Trade-off accepted: a REAL secondary that also has unknown ids
* now classifies `primary` (conservative miss — its teardown runs and hurts
* the parent, same as pre-#473 behavior), because uncertainty must never
* classify `secondary`.
*/
export function noteSessionShutdown(
// Load-bearing: ctx OBJECT IDENTITY is the definitive discriminator when
// available — if the shutting-down handler's ctx IS the registered
// primary's ctx, this is the primary regardless of session-id reads.
// (Note: pi's ExtensionRunner.emit() builds a FRESH ctx object per emit,
// so identity match is not expected with today's SDK — this check is
// defense-in-depth for SDK versions/paths that reuse a ctx.)
ctx, sessionId) {
if (ctx !== undefined && ctx === activeCtx) {
return "primary";
}
if (activeCtx === undefined && activeSessionId === undefined) {
return "primary";
}
if (sessionId !== undefined && sessionId === activeSessionId) {
return "primary";
}
// Uncertainty guard: if EITHER side's session id is unknown we cannot
// positively establish "different session", so never classify secondary.
if (sessionId === undefined || activeSessionId === undefined) {
return "primary";
}
const primaryStillActive = probeCtxActive(activeCtx);
if (primaryStillActive === true) {
return "secondary";
}
// primaryStillActive is false or undefined: fail-safe to primary.
return "primary";
}
export function getSecondarySessionCount() {
return secondarySessionCount;
}
export function decrementSecondarySessionCount() {
if (secondarySessionCount > 0)
secondarySessionCount -= 1;
}
/**
* Guard-aware wrapper used by callers (index.ts) so the kill switch lives in
* one place: when disabled, always report `sequential-replacement` (i.e.
* behave exactly as if this module didn't exist).
*/
export function classifySessionStartGuarded(input) {
if (!guardEnabled())
return input.hasPrior ? "sequential-replacement" : "primary";
return classifySessionStart(input);
}
/** Test-only: clears all module-scope state (house style — see
* `_resetSubagentModeForTests` / `slow-fs.ts`). */
export function _resetSessionLifecycleForTests() {
activeCtx = undefined;
activeSessionId = undefined;
secondarySessionCount = 0;
}
/**
* Single entry point `index.ts`'s `session_start` handler delegates to, so
* the classify → probe → register decision is unit-testable independent of
* the SDK's `pi.on("session_start", ...)` wiring (which cannot be invoked
* directly in tests).
*
* `ctx` is whatever the SDK handed the handler (only ever probed via
* {@link probeCtxActive}, never dereferenced otherwise, so passing a plain
* fake object in tests is safe). `sessionId` is the STABLE session id
* (`ctx.sessionManager.getSessionId()`), which may be `undefined`.
*/
export function decideSessionStart(ctx, sessionId) {
const hasPrior = activeCtx !== undefined || activeSessionId !== undefined;
const priorCtxActive = hasPrior ? probeCtxActive(activeCtx) : undefined;
// ctx OBJECT IDENTITY: if the SDK ever hands the SAME ctx object to a
// repeated session_start, that is by definition the same session
// re-announcing itself — sequential, never concurrent. (Not expected with
// today's SDK — ExtensionRunner.emit() builds a fresh ctx per emit — but
// identity is the one signal that can't false-positive, so honor it.)
const sameCtx = hasPrior && ctx !== undefined && ctx === activeCtx;
const sameSessionId = sameCtx ||
(hasPrior && sessionId !== undefined && sessionId === activeSessionId);
const classification = classifySessionStartGuarded({
hasPrior,
priorCtxActive,
sameSessionId,
});
if (classification === "concurrent-secondary") {
registerSecondarySession();
return {
classification,
runFullSessionStart: false,
secondaryCount: secondarySessionCount,
};
}
// "primary" or "sequential-replacement": register as the (new) primary
// and proceed exactly as today.
registerPrimarySession(ctx, sessionId);
return {
classification,
runFullSessionStart: true,
secondaryCount: secondarySessionCount,
};
}