/** * 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`. A killed (`aborted`) agent is revived the same way: its * transcript is still on disk, so ensureLive reopens it as a fresh `parked` * generation and cold-revives it. * * Park/dispose is gated against concurrent ensureLive/peer sends: * - 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, postmortem, untilAborted } from "@oh-my-pi/pi-utils"; import type { AgentSessionHandle } from "../session/agent-session-handle"; import type { AgentSessionDisposeOptions } from "../session/agent-session-types"; import { trackLateCleanup } from "../utils/late-cleanup"; import { type AgentRef, type AgentRefExpectation, AgentRegistry, type AgentRegistryApi, MAIN_AGENT_ID, type RegistryEvent, } from "./agent-registry"; import { getAgentTombstonePath } from "./agent-tombstone"; export type AgentReviver = (expected: AgentRef) => Promise; const AGENT_RELEASE_GRACE_MS = 5000; async function persistAgentTombstone(sessionFile: string): Promise { try { await fs.writeFile(getAgentTombstonePath(sessionFile), "", { encoding: "utf8", flag: "wx", mode: 0o600 }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } } /** Drop the kill marker of a reopened agent so later persisted scans list it as parked, not killed. */ async function removeAgentTombstone(id: string, sessionFile: string): Promise { try { await fs.rm(getAgentTombstonePath(sessionFile), { force: true }); } catch (error) { logger.warn("AgentLifecycleManager: failed to remove kill tombstone of reopened agent", { id, error: String(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; export interface AdoptOptions { /** TTL before an idle agent is parked. <= 0 disables parking. */ idleTtlMs: number; /** Recreates a live AgentSession from the ref's sessionFile after parking. */ revive?: AgentReviver; /** Releases resources that must survive parking but end with the agent lifecycle. */ onRelease?: () => Promise; /** * Called once when this adoption stops holding `revive` — released, replaced by * a re-adopt, removed from the registry, or its reviver forgotten — so whoever * tracks the reviver can let go of it. */ onReviverDropped?: () => void; } interface AdoptedAgent { ref: AgentRef; idleTtlMs: number; revive?: AgentReviver; onRelease?: () => Promise; onReviverDropped?: () => void; timer?: NodeJS.Timeout; } /** Fire (at most once) the adoption's reviver-dropped hook. */ function dropReviver(adopted: AdoptedAgent | undefined): void { const hook = adopted?.onReviverDropped; if (!adopted || !hook) return; adopted.onReviverDropped = undefined; try { hook(); } catch (error) { logger.warn("AgentLifecycleManager: onReviverDropped hook failed", { error: String(error) }); } } 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; /** 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; } interface ReopeningAgent { /** The killed ref being reopened. */ ref: AgentRef; /** Resolves to the fresh `parked` generation. */ promise: Promise; } /** How {@link AgentLifecycleApi.release} ends an agent. */ export interface AgentReleaseOptions { /** Explicit kill: keep a terminal `aborted` row and persist the kill marker. */ tombstone?: boolean; /** Forwarded to the live session's dispose (e.g. the reason its `session_exit` record carries). */ dispose?: AgentSessionDisposeOptions; } /** Public surface of the process-global agent lifecycle, implemented locally on main and by a proxy in workers. */ export interface AgentLifecycleApi { /** Install the factory used to cold-revive `parked` refs restored from disk. */ setPersistedSubagentReviverFactory(factory: PersistedSubagentReviverFactory, idleTtlMs: () => number): void; /** Take ownership of a finished subagent and arm its idle TTL. */ adopt(id: string, opts: AdoptOptions, expected?: AgentRefExpectation): void; /** True if the id is adopted (and still bound to `expected` when given). */ has(id: string, expected?: AgentRefExpectation): boolean; /** Reclaim a provably-dead parked corpse so a fresh spawn can reuse its id. */ reclaimDeadCorpse(id: string, expected: AgentRef): Promise; /** True when this lifecycle owns `registry` (identity comparison). */ manages(registry: AgentRegistryApi): boolean; /** True while a park is disposing this agent's session. */ isParking(id: string, expected?: AgentRefExpectation): boolean; /** Dispose the live session of an adopted agent and mark it `parked`. */ park(id: string): Promise; /** Return the live session, reviving it if parked or killed. */ ensureLive(id: string): Promise; /** Dispose if live and drop timers; `tombstone` keeps a terminal `aborted` row. */ release(id: string, expected?: AgentRefExpectation, options?: AgentReleaseOptions): Promise; /** Tear down every adopted agent. */ dispose(deadlineAt?: number): Promise; } export class AgentLifecycleManager implements AgentLifecycleApi { static #global: AgentLifecycleManager | undefined; /** Lifecycle installed by a worker bootstrap; takes precedence over the local manager. */ static #installed: AgentLifecycleApi | undefined; /** The installed lifecycle; lazily a local manager bound to {@link AgentRegistry.global}. */ static global(): AgentLifecycleApi { if (AgentLifecycleManager.#installed) return AgentLifecycleManager.#installed; const current = AgentLifecycleManager.#global; if (current) { // The manager captures its registry at construction and subscribes to // it for the manager's lifetime. A test that swaps the global registry // (`AgentRegistry.resetGlobalForTests`) without also resetting this // manager would strand it on the dead instance: terminal transitions // (`release`) would mutate the old registry while consumers subscribe // to the new one, so `status_changed` never reaches them (issue #11432). // Rebind by retiring the stale manager and reconstructing against the // current global registry. In production the registry is never reset, so // this always short-circuits and the singleton is stable. if (current.#registry === AgentRegistry.global()) return current; current.#retire(); } AgentLifecycleManager.#global = new AgentLifecycleManager(); return AgentLifecycleManager.#global; } /** Install the process-global lifecycle. Worker bootstrap only. */ static installGlobal(api: AgentLifecycleApi): void { AgentLifecycleManager.#installed = api; } /** Reset the global manager. Test-only. */ static resetGlobalForTests(): void { const current = AgentLifecycleManager.#global; if (current) current.#retire(); AgentLifecycleManager.#global = undefined; AgentLifecycleManager.#installed = undefined; } /** Detach from the registry and cancel every pending timer/park/revival. */ #retire(): void { this.#unsubscribe?.(); this.#unsubscribe = undefined; for (const adopted of this.#adopted.values()) { clearTimeout(adopted.timer); } this.#adopted.clear(); this.#revivals.clear(); this.#reopens.clear(); this.#parks.clear(); this.#persistedReviverFactory = undefined; } readonly #registry: AgentRegistryApi; readonly #adopted = new Map(); /** * 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(); /** In-flight revives, bound to the parked ref that initiated them, so concurrent {@link ensureLive} calls coalesce. */ readonly #revivals = new Map(); /** In-flight reopens of killed refs, bound to the killed ref, so concurrent {@link ensureLive} calls coalesce. */ readonly #reopens = new Map(); #unsubscribe: (() => void) | undefined; #persistedReviverFactory: PersistedSubagentReviverFactory | undefined; /** Resolves the TTL applied when a cold-revived ref is adopted on demand (read per revive). */ #persistedReviveTtlMs: () => number = () => 0; /** Set once {@link dispose} runs; blocks late revivals from adopting into a torn-down manager. */ #disposed = false; constructor(registry: AgentRegistryApi = 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. `idleTtlMs` * is resolved per revive so a live `task.agentIdleTtlMs` change applies. */ 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) { opts.onReviverDropped?.(); 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 }); opts.onReviverDropped?.(); return; } const existing = this.#adopted.get(id); clearTimeout(existing?.timer); const adopted: AdoptedAgent = { ref, idleTtlMs: opts.idleTtlMs, revive: opts.revive, onRelease: opts.onRelease, onReviverDropped: opts.onReviverDropped, }; dropReviver(existing); this.#adopted.set(id, adopted); this.#armTimer(id, adopted); } /** * Drop `revive` from the adoption of `id` — a warm reviver whose host thread * died with the agent's session. The adoption itself stays, so its * `onRelease` (e.g. a retained isolation workspace) still runs at final * release, and the next {@link ensureLive} cold-revives through the persisted * reviver factory. Main-thread bridging only. Returns true when dropped. */ forgetReviver(id: string, revive: AgentReviver): boolean { const adopted = this.#adopted.get(id); if (adopted?.revive !== revive) return false; clearTimeout(adopted.timer); adopted.timer = undefined; adopted.revive = undefined; dropReviver(adopted); return true; } /** 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), ); } /** * Reclaim a provably-dead parked corpse so a fresh spawn can reuse its id. * Refuses live, adopted, in-flight, or cold-revivable refs. For a parked ref * restored from disk, the persisted factory is consulted before removal * because cold revivers are created lazily by {@link ensureLive}. * * Only refs in the registry this manager owns are touched; the transcript * stays readable at `history://`. Returns true when the corpse was * unregistered. */ async reclaimDeadCorpse(id: string, expected: AgentRef): Promise { const ref = this.#registry.get(id); if (ref !== expected || ref.status !== "parked" || ref.session) return false; if (this.#adopted.has(id) || this.#parks.has(id) || this.#revivals.has(id)) return false; const persistedFactory = ref.sessionFile ? this.#persistedReviverFactory : undefined; if (persistedFactory) { try { if (await persistedFactory(ref)) return false; } catch (error) { logger.warn("AgentLifecycleManager.reclaimDeadCorpse: persisted reviver probe failed", { id, error: error instanceof Error ? error.message : String(error), }); return false; } // The factory awaited I/O; another lifecycle operation may now own or // have replaced this ref. Revalidate every reclaim invariant. if (this.#registry.get(id) !== ref || ref.status !== "parked" || ref.session) return false; if (this.#adopted.has(id) || this.#parks.has(id) || this.#revivals.has(id)) return false; } return this.#registry.unregister(id, ref); } /** * 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: AgentRegistryApi): 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}/peer sends 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 { 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, 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; // Preserve cost before detaching the only live source. The session usage // index includes off-transcript model calls; remove completed task-result // billing so child rows remain the sole owners of nested spend. try { const usage = session.sessionManager.getUsageStatistics(); const directCost = usage.cost - usage.subagentCost; if (Number.isFinite(directCost) && directCost >= 0) { this.#registry.setHistory(id, { directCost }, ref.sessionFile ?? undefined); } } catch (error) { logger.debug("AgentLifecycleManager.park: cost snapshot failed", { id, error: String(error) }); } // 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 or * killed. Throws a plain Error if the id is unknown, or its transcript * cannot be revived (no reviver, no session contract, workspace gone). * 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 { 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; } } let 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.status === "aborted") ref = await this.#reopen(id, ref); // A revive attaches its session before it finishes initializing it: join the revive rather // than hand out a session whose extensions have not re-registered their tools yet. const inflight = this.#revivals.get(id); if (inflight?.ref === ref) return inflight.promise; if (ref.session) return ref.session; 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); } } /** * Turn a killed ref into a fresh `parked` generation on the same transcript * and drop its kill marker, so the cold-revive path can rebuild it. Probes the * persisted factory first: a transcript nothing can revive stays killed. * Concurrent calls for the same killed ref share one reopen. */ async #reopen(id: string, killed: AgentRef): Promise { const inflight = this.#reopens.get(id); if (inflight?.ref === killed) return inflight.promise; const pending: ReopeningAgent = { ref: killed, promise: this.#reopenKilled(id, killed) }; this.#reopens.set(id, pending); try { return await pending.promise; } finally { if (this.#reopens.get(id) === pending) this.#reopens.delete(id); } } async #reopenKilled(id: string, killed: AgentRef): Promise { const sessionFile = killed.sessionFile; if (!sessionFile) throw new Error(`Agent "${id}" was killed and has no transcript to resume from.`); const factory = this.#persistedReviverFactory; if (!factory) { throw new Error( `Agent "${id}" was killed and cannot be revived (no reviver registered). Read history://${id} for what it did.`, ); } if (killed.session) { throw new Error(`Agent "${id}" is still shutting down after its kill; retry once it has stopped.`); } // Only decides whether the kill can be undone; the cold-revive path builds // its own reviver for the fresh generation. const reviver = await factory(killed); if (this.#disposed) { throw new Error( `Agent "${id}" revival aborted: its lifecycle was disposed while its transcript was being checked.`, ); } if (!reviver) { throw new Error( `Agent "${id}" was killed and its transcript cannot be resumed (no session contract, isolated run, or its workspace is gone). Read history://${id} for what it did.`, ); } const reopened = this.#registry.reopen(id, killed); if (!reopened) throw new Error(`Agent "${id}" changed while its transcript was being checked.`); await removeAgentTombstone(id, sessionFile); return reopened; } /** * 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 { let adoption = this.#adopted.get(id); // A reviver-less adoption of this ref (its warm reviver's thread died) keeps // its release hook through cold revival and restores it if that fails. const prior = adoption?.ref === ref ? adoption : undefined; let revive = prior?.revive; 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: prior?.idleTtlMs ?? this.#persistedReviveTtlMs(), revive, onRelease: prior?.onRelease, }; 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) { if (prior) this.#adopted.set(id, prior); else 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 explicit-kill path. The kill stops the agent, * it does not destroy it: {@link ensureLive} (a message, Hub revive/focus) * reopens the transcript. */ async release(id: string, expected?: AgentRefExpectation, options?: AgentReleaseOptions): Promise { 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; const onRelease = adopted && adopted.ref === ref ? adopted.onRelease : undefined; if (!ref) return false; if (adopted?.ref === ref) { clearTimeout(adopted.timer); this.#adopted.delete(id); dropReviver(adopted); } 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; } const live = this.#registry.get(id) === ref ? ref.session : null; if (options?.tombstone) { // Apply the terminal transition synchronously, before any await. The // dying session's own dispose path calls unregisterUnlessParked // (sdk.ts), which spares a ref only when it is already `aborted` AND // already detached; awaiting persistAgentTombstone before this // transition left a window in which that unregister deleted the ref // (issue #10531). Persisting the sidecar afterward is safe: within the // process the still-registered `aborted` row already blocks re-adoption // via the `if (!registry.get(id))` discovery guard, and the sidecar // only needs to exist before a later cross-restart discovery pass — // release awaits the write below before returning. // Detach before publishing `aborted`: setStatus emits synchronously, so // every subscriber must observe a terminal ref with session === null. if (!this.#registry.detachSession(id, ref) || !this.#registry.setStatus(id, "aborted", ref)) { logger.warn("AgentLifecycleManager.release: terminal transition rejected", { id }); } } try { if (options?.tombstone && ref.sessionFile) await persistAgentTombstone(ref.sessionFile); } finally { // Detaching removes the registry's only route to the live session. Always // dispose the captured session, even when tombstone persistence fails. if (live) { try { await live.dispose(options?.dispose); } catch (error) { logger.warn("AgentLifecycleManager.release: session dispose failed", { id, error: String(error) }); } } try { await onRelease?.(); } catch (error) { logger.warn("AgentLifecycleManager.release: owned resource cleanup 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 { this.#unsubscribe?.(); this.#disposed = true; this.#unsubscribe = undefined; const ids = [...new Set([...this.#adopted.keys(), ...this.#parks.keys()])]; // A turn still running (a revived or woken agent) is cut off by this // teardown, not stopped: its session records a process exit, so // `omp --restore` goes on with it. const cutOff: AgentReleaseOptions = { dispose: { reason: postmortem.Reason.EXIT } }; await Promise.all( ids.map(async id => { const running = this.#registry.get(id)?.status === "running"; const release = this.release(id, undefined, running ? cutOff : undefined).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 { 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; // A session still owing turns to IRC messages (held by a run publishing // its result, or queued) would drop them when parked. Try again once the // TTL passes anew. if (adopted.ref.session?.ircWakesPending) { this.#armTimer(id, adopted); return; } 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); dropReviver(adopted); 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); } } }