Files
oh-my-pi/packages/coding-agent/src/registry/agent-lifecycle.ts
T
roboomp dc47d73e9c fix(agent): recreated global lifecycle after disposal
Clear the disposed singleton after teardown so a later top-level session receives a fresh manager with an active registry subscription. Keep the retired manager marked disposed so its in-flight revivals still reject and clean up late sessions.

Add regression coverage for sequential top-level lifecycle owners cold-reviving in the same process.

Fixes #8114
2026-08-09 23:51:52 +00:00

526 lines
20 KiB
TypeScript

/**
* AgentLifecycleManager - Owns the idle → parked → revived lifecycle of
* adopted subagents.
*
* The task executor hands a finished agent over via {@link AgentLifecycleManager.adopt};
* from then on the manager arms a TTL timer whenever the agent goes `idle`,
* parks it on expiry (disposes the live session, keeps the AgentRef +
* sessionFile), and revives it on demand through
* {@link AgentLifecycleManager.ensureLive}. Only this manager flips
* `parked` ↔ `idle`.
*
* Park/dispose is gated against concurrent ensureLive/hub-send:
* - A disposing session is never handed out.
* - ensureLive during an in-flight park either cancels the park (session still
* live) or waits for detach+park and then revives.
* - Concurrent ensureLive/park operations coalesce per id.
*
* Every adoption, park, and revival is bound to the exact {@link AgentRef} it
* started from, so stale async work (a late finalizer, a cancelled initializer,
* a superseded revive) can never clobber a newer same-id ref.
*/
import * as fs from "node:fs/promises";
import { logger, untilAborted } from "@oh-my-pi/pi-utils";
import type { AgentSession } from "../session/agent-session";
import { trackLateCleanup } from "../utils/late-cleanup";
import {
type AgentRef,
type AgentRefExpectation,
AgentRegistry,
getAgentTombstonePath,
MAIN_AGENT_ID,
type RegistryEvent,
} from "./agent-registry";
export type AgentReviver = (expected: AgentRef) => Promise<AgentSession>;
const AGENT_RELEASE_GRACE_MS = 5000;
async function persistAgentTombstone(sessionFile: string): Promise<void> {
try {
await fs.writeFile(getAgentTombstonePath(sessionFile), "", { encoding: "utf8", flag: "wx", mode: 0o600 });
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
}
/**
* Builds a reviver for a `parked` ref restored from disk (Agent Hub scan,
* collab mirror, resumed process) that carries a sessionFile but no in-memory
* adoption. Returns undefined when the ref cannot be faithfully rebuilt (no
* persisted session contract, or its workspace is gone). Injected from the
* top-level session so this manager stays free of sdk/SessionManager imports.
*/
export type PersistedSubagentReviverFactory = (ref: AgentRef) => Promise<AgentReviver | undefined>;
export interface AdoptOptions {
/** TTL before an idle agent is parked. <= 0 disables parking. */
idleTtlMs: number;
/** Recreates a live AgentSession from the ref's sessionFile. Absent => not resumable after park (e.g. isolated runs). */
revive?: AgentReviver;
}
interface AdoptedAgent {
ref: AgentRef;
idleTtlMs: number;
revive?: AgentReviver;
timer?: NodeJS.Timeout;
}
interface ParkInFlight {
/** The exact ref this park was started for. */
ref: AgentRef;
/** Resolves when the park attempt finishes (success, cancel, or dispose error). */
promise: Promise<void>;
/** Cancel before the session is detached. Returns true if cancel took effect. */
cancel: () => boolean;
/** True once cancel() succeeded (ensureLive kept the live session). */
cancelled: boolean;
/** True once the live session has been detached and status is parked. */
detached: boolean;
}
interface RevivingAgent {
ref: AgentRef;
promise: Promise<AgentSession>;
}
export class AgentLifecycleManager {
static #global: AgentLifecycleManager | undefined;
static global(): AgentLifecycleManager {
if (!AgentLifecycleManager.#global) {
AgentLifecycleManager.#global = new AgentLifecycleManager();
}
return AgentLifecycleManager.#global;
}
/** Reset the global manager. Test-only. */
static resetGlobalForTests(): void {
const current = AgentLifecycleManager.#global;
if (current) {
current.#unsubscribe?.();
current.#unsubscribe = undefined;
for (const adopted of current.#adopted.values()) {
clearTimeout(adopted.timer);
}
current.#adopted.clear();
current.#revivals.clear();
current.#parks.clear();
current.#persistedReviverFactory = undefined;
}
AgentLifecycleManager.#global = undefined;
}
readonly #registry: AgentRegistry;
readonly #adopted = new Map<string, AdoptedAgent>();
/**
* In-flight park attempts, each bound to the ref it started from. A park is
* cancelable until the live session is detached; after detach, ensureLive
* waits for the park and revives.
*/
readonly #parks = new Map<string, ParkInFlight>();
/** In-flight revives, bound to the parked ref that initiated them, so concurrent {@link ensureLive} calls coalesce. */
readonly #revivals = new Map<string, RevivingAgent>();
#unsubscribe: (() => void) | undefined;
#persistedReviverFactory: PersistedSubagentReviverFactory | undefined;
/** TTL applied when a cold-revived ref is adopted on demand. */
#persistedReviveTtlMs = 0;
/** Set once {@link dispose} runs; blocks late revivals from adopting into a torn-down manager. */
#disposed = false;
constructor(registry: AgentRegistry = AgentRegistry.global()) {
this.#registry = registry;
this.#unsubscribe = registry.onChange(event => this.#onRegistryEvent(event));
}
/**
* Install the factory used to cold-revive `parked` refs restored from disk
* (Agent Hub scan, collab mirror, resumed process) — they carry a sessionFile
* but no adoption. Set by the top-level session, which owns the ambient deps
* (auth, models, MCP, artifacts) the factory needs at revive time.
*/
setPersistedSubagentReviverFactory(factory: PersistedSubagentReviverFactory, idleTtlMs: number): void {
this.#persistedReviverFactory = factory;
this.#persistedReviveTtlMs = idleTtlMs;
}
/**
* Take ownership of a finished subagent. Caller has already set registry
* status to "idle". Arms the TTL timer (idleTtlMs <= 0 adopts without one).
* When `expected` is given, the adoption is refused if the id no longer
* resolves to that ref (or that ref's session).
*/
adopt(id: string, opts: AdoptOptions, expected?: AgentRefExpectation): void {
if (id === MAIN_AGENT_ID) return;
const ref = this.#registry.get(id);
if (!ref || (expected !== undefined && ref !== expected && ref.session !== expected)) {
logger.warn("AgentLifecycleManager.adopt: unknown or replaced agent id", { id });
return;
}
const existing = this.#adopted.get(id);
clearTimeout(existing?.timer);
const adopted: AdoptedAgent = { ref, idleTtlMs: opts.idleTtlMs, revive: opts.revive };
this.#adopted.set(id, adopted);
this.#armTimer(id, adopted);
}
/** True if the id is adopted (parked or live) — and, when `expected` is given, still bound to that ref. */
has(id: string, expected?: AgentRefExpectation): boolean {
const adopted = this.#adopted.get(id);
return Boolean(
adopted && (expected === undefined || adopted.ref === expected || adopted.ref.session === expected),
);
}
/**
* True when this manager owns `registry` — i.e. its adopt/park/revive state
* describes that registry's refs. Lets a caller holding a specific registry
* (e.g. a custom-registry {@link IrcBus} that fell back to the global
* manager) skip lifecycle gating that would consult unrelated park state.
*/
manages(registry: AgentRegistry): boolean {
return this.#registry === registry;
}
/**
* True while {@link park} is disposing this agent's session (lets dispose
* hooks distinguish park from teardown). False once the park is cancelled
* by ensureLive or after detach+dispose completes. When `expected` is
* given, only a park bound to that ref (or its session) counts.
*/
isParking(id: string, expected?: AgentRefExpectation): boolean {
const park = this.#parks.get(id);
return Boolean(
park && !park.cancelled && (expected === undefined || park.ref === expected || park.ref.session === expected),
);
}
/**
* Dispose the live session, detach it from the registry, and mark the
* agent `parked`. No-op unless the id is adopted and live.
*
* The session is detached (and status flipped to `parked`) *before*
* `session.dispose()` so concurrent {@link ensureLive}/hub-send never
* observe or inject into a disposing session. A concurrent ensureLive that
* arrives before detach cancels the park and keeps the live session.
*/
async park(id: string): Promise<void> {
const existing = this.#parks.get(id);
if (existing) return existing.promise;
const adopted = this.#adopted.get(id);
if (!adopted) return;
const ref = this.#registry.get(id);
if (!ref || adopted.ref !== ref) return;
const session = ref.session;
if (!session) return;
if (adopted.timer) {
clearTimeout(adopted.timer);
adopted.timer = undefined;
}
let cancelled = false;
const park: ParkInFlight = {
ref,
promise: undefined as unknown as Promise<void>,
cancel: () => {
// Cancel only before detach — once detached the old session is already
// leaving the registry and must finish disposing.
if (park.detached || cancelled) return cancelled;
cancelled = true;
park.cancelled = true;
return true;
},
cancelled: false,
detached: false,
};
park.promise = (async () => {
try {
// Yield so a same-tick ensureLive/hub-send can cancel before we
// commit to dispose. Deterministic with Promise microtasks; no timers.
await Promise.resolve();
if (cancelled) return;
// Re-check liveness: release/unregister/replace may have raced us.
const live = this.#registry.get(id);
if (live !== ref || !live.session || live.session !== session) return;
if (this.#adopted.get(id)?.ref !== ref) return;
// Commit: detach + parked *before* dispose so callers never see a
// dying session via ref.session / idle status.
park.detached = true;
this.#registry.detachSession(id, ref);
this.#registry.setStatus(id, "parked", ref);
try {
await session.dispose();
} catch (error) {
logger.warn("AgentLifecycleManager.park: session dispose failed", { id, error: String(error) });
}
} finally {
// Only clear if we are still the in-flight entry (a later park would
// have replaced us only after we resolved).
if (this.#parks.get(id) === park) this.#parks.delete(id);
}
})();
this.#parks.set(id, park);
return park.promise;
}
/**
* Return the live session, reviving from the sessionFile if parked.
* Throws a plain Error if the id is unknown or parked without a reviver.
* Concurrent calls share one in-flight revive.
*
* Never returns a session that is mid-dispose: an in-flight park is either
* cancelled (session still live) or awaited to completion before revive.
*/
async ensureLive(id: string): Promise<AgentSession> {
const park = this.#parks.get(id);
if (park) {
const parked = this.#registry.get(id);
// Cancel if the live session is still attached — keep it instead of
// thrashing dispose + revive.
if (parked?.session && !park.detached && park.cancel()) {
await park.promise;
const kept = this.#registry.get(id)?.session;
if (kept) {
// Park cleared the idle timer; re-arm so TTL park still works.
const adopted = this.#adopted.get(id);
if (adopted && adopted.ref === parked && parked.status === "idle") this.#armTimer(id, adopted);
return kept;
}
} else {
// Already committed to detach (or no live session): wait for park,
// then fall through to the revive path.
await park.promise;
}
}
const ref = this.#registry.get(id);
if (!ref) {
throw new Error(
`Unknown agent "${id}" — it was never registered or has been released. If a transcript exists, read history://${id}.`,
);
}
if (ref.session) return ref.session;
const inflight = this.#revivals.get(id);
if (inflight?.ref === ref) return inflight.promise;
const revival = this.#resolveAndRevive(id, ref);
const pending: RevivingAgent = { ref, promise: revival };
this.#revivals.set(id, pending);
try {
return await revival;
} finally {
if (this.#revivals.get(id) === pending) this.#revivals.delete(id);
}
}
/**
* Resolve a reviver and bring the agent back to a live session. A ref
* restored from disk is `parked` with a sessionFile but no in-memory
* adoption; build a reviver via the injected persisted-subagent factory and
* adopt it so the agent rejoins the normal idle↔parked lifecycle. Throws
* when the agent is not revivable or no reviver can be produced.
*/
async #resolveAndRevive(id: string, ref: AgentRef): Promise<AgentSession> {
let adoption = this.#adopted.get(id);
let revive = adoption?.ref === ref ? adoption.revive : undefined;
let coldAdopted = false;
if (!revive && ref.status === "parked" && ref.sessionFile && this.#persistedReviverFactory) {
revive = await this.#persistedReviverFactory(ref);
// Teardown can complete during the factory await. A late cold revive must
// not cold-adopt (and later attach a live session + TTL) into a disposed
// manager — reject deterministically before creating any session.
if (this.#disposed) {
throw new Error(
`Agent "${id}" revival aborted: its lifecycle was disposed while its persisted session was being prepared.`,
);
}
if (revive) {
adoption = { ref, idleTtlMs: this.#persistedReviveTtlMs, revive };
this.#adopted.set(id, adoption);
coldAdopted = true;
}
}
if (this.#registry.get(id) !== ref) {
throw new Error(`Agent "${id}" changed while its persisted session was being prepared.`);
}
if (ref.status !== "parked" || !revive || !adoption) {
throw new Error(
`Agent "${id}" is ${ref.status} and cannot be revived${revive ? "" : " (no reviver registered)"}. Its transcript remains readable at history://${id}.`,
);
}
try {
return await this.#revive(id, revive, ref, adoption);
} catch (error) {
// A failed cold revive (stale ctx, missing cwd, bad MCP) must not leave a
// poisoned reviver stuck in #adopted — drop it so a later ensureLive
// rebuilds via the factory (which may have fresher context by then).
if (coldAdopted && this.#adopted.get(id) === adoption) this.#adopted.delete(id);
throw error;
}
}
/**
* Dispose if live and drop timers. When `expected` is given, only a ref
* matching it is released; a stale release can never take down a newer
* same-id ref. Returns true when a matching ref was released.
*
* By default the ref is unregistered (teardown / one-shot removal). Pass
* `tombstone: true` for an explicit kill: the ref is kept registered as a
* terminal `aborted` row (session detached) instead of being removed, so a
* later persisted-subagent scan (e.g. Agent Hub reopen) skips it via its
* `if (!registry.get(id))` guard rather than re-adopting the surviving
* on-disk transcript as a fresh `parked` row. Mirrors
* `finalizeSubagentLifecycle`'s genuine-kill path.
*/
async release(id: string, expected?: AgentRefExpectation, options?: { tombstone?: boolean }): Promise<boolean> {
const adopted = this.#adopted.get(id);
const current = this.#registry.get(id);
const currentMatches =
current && (expected === undefined || current === expected || current.session === expected);
const adoptedMatches =
adopted && (expected === undefined || adopted.ref === expected || adopted.ref.session === expected);
const ref = currentMatches ? current : adoptedMatches ? adopted.ref : undefined;
if (!ref) return false;
if (adopted?.ref === ref) {
clearTimeout(adopted.timer);
this.#adopted.delete(id);
}
const park = this.#parks.get(id);
if (park && park.ref === ref) {
// Prefer cancel when the session is still live so release owns dispose.
if (!park.detached) park.cancel();
await park.promise;
}
if (options?.tombstone) {
// Persist the terminal decision before detaching the session. The
// sidecar prevents a later discovery pass from reviving this transcript
// as a fresh parked ref.
if (ref.sessionFile) await persistAgentTombstone(ref.sessionFile);
this.#registry.setStatus(id, "aborted", ref);
}
const live = this.#registry.get(id) === ref ? ref.session : null;
if (options?.tombstone) this.#registry.detachSession(id, ref);
if (live) {
try {
await live.dispose();
} catch (error) {
logger.warn("AgentLifecycleManager.release: session dispose failed", { id, error: String(error) });
}
}
if (!options?.tombstone) this.#registry.unregister(id, ref);
return true;
}
/** Teardown everything; disposing the global manager makes its next owner a fresh instance. */
async dispose(deadlineAt: number = Date.now() + AGENT_RELEASE_GRACE_MS): Promise<void> {
this.#unsubscribe?.();
this.#disposed = true;
this.#unsubscribe = undefined;
const ids = [...new Set([...this.#adopted.keys(), ...this.#parks.keys()])];
await Promise.all(
ids.map(async id => {
const release = this.release(id).then(() => {});
try {
await untilAborted(AbortSignal.timeout(Math.max(0, deadlineAt - Date.now())), () => release);
} catch (error) {
if (Date.now() >= deadlineAt) {
trackLateCleanup(release, { id, resource: "adopted-agent" });
}
logger.warn("Agent cleanup exceeded its deadline", {
id,
error: error instanceof Error ? error.message : String(error),
});
}
}),
);
this.#revivals.clear();
this.#parks.clear();
this.#persistedReviverFactory = undefined;
if (AgentLifecycleManager.#global === this) AgentLifecycleManager.#global = undefined;
}
async #revive(id: string, revive: AgentReviver, ref: AgentRef, adopted: AdoptedAgent): Promise<AgentSession> {
const session = await revive(ref);
if (this.#disposed) {
// The owning lifecycle tore down while the reviver was in flight; dispose
// the freshly built session instead of attaching it, and fail the waiter.
await session.dispose();
throw new Error(
`Agent "${id}" revival aborted: its lifecycle was disposed while its persisted session was reviving.`,
);
}
let liveRef = this.#registry.get(id);
if (liveRef === ref && ref.status === "parked" && !ref.session) {
// A simple reviver returned a session without claiming the parked ref;
// attach it here while the exact ref is still revivable.
if (!this.#registry.attachSession(id, session, ref.sessionFile, ref)) {
await session.dispose();
throw new Error(`Agent "${id}" changed before its persisted session could attach.`);
}
liveRef = ref;
} else if (
liveRef !== ref ||
liveRef.status !== "running" ||
liveRef.session !== session ||
liveRef.kind !== ref.kind ||
liveRef.parentId !== ref.parentId ||
liveRef.sessionFile !== ref.sessionFile
) {
// createAgentSession may have already claimed this exact parked ref and
// attached the returned session. Any other state — especially an
// `aborted` tombstone set while revive() was in flight — is stale.
await session.dispose();
throw new Error(`Agent "${id}" was replaced or became terminal while its persisted session was reviving.`);
}
adopted.ref = liveRef;
// Emits status_changed → "idle", which re-arms the TTL timer below.
if (!this.#registry.setStatus(id, "idle", liveRef)) {
await session.dispose();
throw new Error(`Agent "${id}" changed before its persisted session became idle.`);
}
return session;
}
#armTimer(id: string, adopted: AdoptedAgent): void {
if (adopted.idleTtlMs <= 0) return;
clearTimeout(adopted.timer);
const timer = setTimeout(() => {
adopted.timer = undefined;
void this.park(id);
}, adopted.idleTtlMs);
timer.unref?.();
adopted.timer = timer;
}
#onRegistryEvent(event: RegistryEvent): void {
const adopted = this.#adopted.get(event.ref.id);
if (!adopted || adopted.ref !== event.ref) return;
if (event.type === "removed") {
clearTimeout(adopted.timer);
this.#adopted.delete(event.ref.id);
return;
}
if (event.type !== "status_changed") return;
if (event.ref.status === "running") {
if (adopted.timer) {
clearTimeout(adopted.timer);
adopted.timer = undefined;
}
} else if (event.ref.status === "idle") {
// Don't re-arm while a park is in flight — the park owns the transition.
if (this.#parks.has(event.ref.id)) return;
this.#armTimer(event.ref.id, adopted);
}
}
}