diff --git a/packages/coding-agent/src/core/agent-messages.ts b/packages/coding-agent/src/core/agent-messages.ts index a030c98ef..1241e8efa 100644 --- a/packages/coding-agent/src/core/agent-messages.ts +++ b/packages/coding-agent/src/core/agent-messages.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { HostRequestHandler } from "./kernel/index.js"; +import { contextAwareHostRequestHandler, createHostRequestHandler, type HostRequestHandler } from "./kernel/index.js"; import type { CustomMessage } from "./messages.js"; import { canonicalSessionPath } from "./session-lease.js"; @@ -257,7 +257,20 @@ function sameAgentSessionNameParent( if (left.depth === 0 && right.depth === 0) { return true; } - return sameAgentFamilyParent(left, right, catalog); + if (sameAgentFamilyParent(left, right, catalog)) return true; + + // A passive child can outlive the active parent row that would normally + // resolve its family edge. Name reservation must still protect that parent's + // sibling namespace, but this weaker direct-claim fallback is deliberately + // not used for family reach: reach continues to require an unambiguous, + // catalog-resolved parent. + if (left.depth !== right.depth || left.depth === 0) return false; + return ( + (left.parentSessionId !== undefined && left.parentSessionId === right.parentSessionId) || + (left.parentSessionPath !== undefined && + right.parentSessionPath !== undefined && + canonicalSessionPath(left.parentSessionPath) === canonicalSessionPath(right.parentSessionPath)) + ); } function sameAgentFamilyParent( @@ -265,44 +278,38 @@ function sameAgentFamilyParent( right: AgentSessionNameScope, catalog: readonly AgentFamilyCatalogEntry[], ): boolean { - if (left.parentSessionPath !== undefined && left.parentSessionPath === right.parentSessionPath) { - return true; - } - if (left.parentSessionId !== undefined && left.parentSessionId === right.parentSessionId) { - return true; - } - const hasCatalogParentPair = (parentSessionId: string | undefined, parentSessionPath: string | undefined) => - parentSessionId !== undefined && - parentSessionPath !== undefined && - catalog.some( - (entry) => - (entry.id === parentSessionId && entry.sessionPath === parentSessionPath) || - (entry.parentSessionId === parentSessionId && entry.parentSessionPath === parentSessionPath), + if (left.depth === 0 && right.depth === 0) { + return ( + left.parentSessionId === undefined && + left.parentSessionPath === undefined && + right.parentSessionId === undefined && + right.parentSessionPath === undefined ); - if ( - hasCatalogParentPair(left.parentSessionId, right.parentSessionPath) || - hasCatalogParentPair(right.parentSessionId, left.parentSessionPath) - ) { - return true; } - if ( - left.depth === 0 && - right.depth === 0 && - left.parentSessionPath === undefined && - right.parentSessionPath === undefined && - left.parentSessionId === undefined && - right.parentSessionId === undefined - ) { - return true; - } - // Unresolved mixed identifiers stay unrelated to avoid false name conflicts across families. - return false; + if (left.depth !== right.depth || left.depth === 0) return false; + const parentFor = (child: AgentSessionNameScope) => { + const parents = catalog.filter((entry) => isAgentFamilyParent(entry, child)); + return parents.length === 1 ? parents[0] : undefined; + }; + const leftParent = parentFor(left); + const rightParent = parentFor(right); + return leftParent !== undefined && leftParent.id === rightParent?.id; } -function isAgentFamilyParent(parent: AgentFamilyCatalogEntry, child: AgentFamilyCatalogEntry): boolean { +/** + * Validates one persisted parent edge. A child may supply either durable + * identifier, but when it supplies both they must identify this same direct + * parent. This keeps contradictory records from becoming relatives through + * whichever identifier happens to match. + */ +function isAgentFamilyParent(parent: AgentFamilyCatalogEntry, child: AgentSessionNameScope): boolean { + if (child.depth <= 0 || parent.depth !== child.depth - 1) return false; + const claimsId = child.parentSessionId !== undefined; + const claimsPath = child.parentSessionPath !== undefined; return ( - (child.parentSessionPath !== undefined && child.parentSessionPath === parent.sessionPath) || - (child.parentSessionId !== undefined && child.parentSessionId === parent.id) + (claimsId || claimsPath) && + (!claimsId || child.parentSessionId === parent.id) && + (!claimsPath || child.parentSessionPath === parent.sessionPath) ); } @@ -310,19 +317,21 @@ function isAgentFamilyParent(parent: AgentFamilyCatalogEntry, child: AgentFamily export function agentFamilyRelationship( current: AgentFamilyCatalogEntry, target: AgentFamilyCatalogEntry, + catalog: readonly AgentFamilyCatalogEntry[] = [current, target], ): AgentFamilyRelationship | undefined { if (current.id === target.id) return undefined; if (isAgentFamilyParent(target, current)) return "parent"; if (isAgentFamilyParent(current, target)) return "child"; - if (current.depth === target.depth && sameAgentFamilyParent(current, target, [current, target])) return "sibling"; + if (sameAgentFamilyParent(current, target, catalog)) return "sibling"; return undefined; } export function assertAgentFamilyReach( current: AgentFamilyCatalogEntry, target: AgentFamilyCatalogEntry, + catalog?: readonly AgentFamilyCatalogEntry[], ): AgentFamilyRelationship { - const relationship = agentFamilyRelationship(current, target); + const relationship = agentFamilyRelationship(current, target, catalog); if (!relationship) throw new Error(AGENT_FAMILY_REACH_ERROR); return relationship; } @@ -526,11 +535,11 @@ export function createAgentMessageHostHandlers( controller: Pick, ): Record { return { - "agent_message.list_agents": async () => { + "agent_message.list_agents": createHostRequestHandler(async (_payload, _context) => { if (!controller.roster) throw new Error("agent family roster is not available in this session"); return (await controller.roster()) as unknown as Record; - }, - "agent_message.send": async (payload) => { + }, contextAwareHostRequestHandler), + "agent_message.send": createHostRequestHandler(async (payload, _context) => { if (typeof payload.message !== "string") { throw new Error("agent_message.send message must be a string"); } @@ -602,7 +611,7 @@ export function createAgentMessageHostHandlers( message: payload.message, receiverRole: payload.receiver_role as AgentFamilyRelationship, })) as unknown as Record; - }, + }, contextAwareHostRequestHandler), }; } diff --git a/packages/coding-agent/src/core/agent-observe.ts b/packages/coding-agent/src/core/agent-observe.ts index 045be7757..9adc0fe4d 100644 --- a/packages/coding-agent/src/core/agent-observe.ts +++ b/packages/coding-agent/src/core/agent-observe.ts @@ -1,4 +1,5 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import { contextAwareHostRequestHandler, createHostRequestHandler, type HostRequestHandlers } from "./kernel/index.js"; export const AGENT_OBSERVE_SKILL_NAME = "agent-observe"; export const AGENT_OBSERVE_IMPORT_NAME = "agent_observe"; @@ -67,25 +68,24 @@ export interface AgentObserveController { ): AgentObserveRecentMessagesResult | Promise; } -export function createAgentObserveHostHandlers(controller: AgentObserveController) { +export function createAgentObserveHostHandlers(controller: AgentObserveController): HostRequestHandlers { return { - "agent_observe.list": async () => controller.listAgents() as unknown as Record, - "agent_observe.get": async (payload: Record = {}) => { - if (typeof payload.target !== "string") { - throw new Error("agent_observe.get target must be a string"); - } + "agent_observe.list": createHostRequestHandler( + async (_payload, _context) => controller.listAgents() as unknown as Record, + contextAwareHostRequestHandler, + ), + "agent_observe.get": createHostRequestHandler(async (payload, _context) => { + if (typeof payload.target !== "string") throw new Error("agent_observe.get target must be a string"); return (await controller.getAgent(payload.target)) as unknown as Record; - }, - "agent_observe.recent": async (payload: Record = {}) => { - if (typeof payload.target !== "string") { - throw new Error("agent_observe.recent target must be a string"); - } + }, contextAwareHostRequestHandler), + "agent_observe.recent": createHostRequestHandler(async (payload, _context) => { + if (typeof payload.target !== "string") throw new Error("agent_observe.recent target must be a string"); return (await controller.recentMessages({ target: payload.target, limit: normalizeOptionalInteger(payload.limit, "agent_observe.recent limit"), maxChars: normalizeOptionalInteger(payload.max_chars ?? payload.maxChars, "agent_observe.recent max_chars"), })) as unknown as Record; - }, + }, contextAwareHostRequestHandler), }; } diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 46d2bd4f8..1f7f8a585 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -169,7 +169,12 @@ import { validateGoalBudget, validateGoalObjective, } from "./goals.js"; -import type { HostRequestHandlers, KernelSentAgentMessage } from "./kernel/index.js"; +import { + contextAwareHostRequestHandler, + createHostRequestHandler, + type HostRequestHandlers, + type KernelSentAgentMessage, +} from "./kernel/index.js"; import { type RestoreResult, snapshotPathIn } from "./kernel/state-snapshot.js"; import type { McpManager } from "./mcp/mcp-manager.js"; import { @@ -8766,25 +8771,37 @@ export class AgentSession { "rlm.find_models": createRlmFindModelsHostHandler((query, limit) => this.findRlmModels(query, limit)), "rlm.list_subagents": createRlmListSubagentsHostHandler(() => this.listRlmSubagents()), "rlm.delete_subagent": createRlmDeleteSubagentHostHandler((target) => this.deleteRlmSubagent(target)), - "model.info": async () => ({ - id: this.model?.id ?? null, - provider: this.model?.provider ?? null, - input: this.model?.input ?? [], - }), + "model.info": createHostRequestHandler( + async (_payload, _context) => ({ + id: this.model?.id ?? null, + provider: this.model?.provider ?? null, + input: this.model?.input ?? [], + }), + contextAwareHostRequestHandler, + ), }; if (this._includeGoals) { for (const type of ["goal.get", "goal.create", "goal.complete"]) { - handlers[type] = async (payload) => this.handleGoalHostRequest(type, payload); + handlers[type] = createHostRequestHandler( + async (payload, _context) => this.handleGoalHostRequest(type, payload), + contextAwareHostRequestHandler, + ); } } if (this._includeCompactSkill) { for (const type of ["compact.run", "compact.status"]) { - handlers[type] = async (payload) => this.handleCompactHostRequest(type, payload); + handlers[type] = createHostRequestHandler( + async (payload, _context) => this.handleCompactHostRequest(type, payload), + contextAwareHostRequestHandler, + ); } } if (this._autoRefineAllowedForSession()) { for (const type of ["refine.run", "refine.status"]) { - handlers[type] = async (payload) => this.handleRefineHostRequest(type, payload); + handlers[type] = createHostRequestHandler( + async (payload, _context) => this.handleRefineHostRequest(type, payload), + contextAwareHostRequestHandler, + ); } } if (this._rlmHeartbeatController) { @@ -8794,7 +8811,10 @@ export class AgentSession { "rlm_heartbeat.update", "rlm_heartbeat.delete", ]) { - handlers[type] = async (payload) => this.handleRlmHeartbeatHostRequest(type, payload); + handlers[type] = createHostRequestHandler( + async (payload, _context) => this.handleRlmHeartbeatHostRequest(type, payload), + contextAwareHostRequestHandler, + ); } } const visibleKernelSkillNames = new Set( diff --git a/packages/coding-agent/src/core/kernel/index.ts b/packages/coding-agent/src/core/kernel/index.ts index b760a2e1e..12d47c939 100644 --- a/packages/coding-agent/src/core/kernel/index.ts +++ b/packages/coding-agent/src/core/kernel/index.ts @@ -55,11 +55,105 @@ export class KernelBusyAfterInterruptError extends Error { /** Comm target the kernel-side `rlm.host_request` shim opens for typed host requests. */ export const HOST_COMM_TARGET = "host.request"; +/** A host-request payload is data only; dispatch authority is never taken from it. */ +export type HostRequestPayload = Record; + /** - * Handles one typed request from Python code running in the kernel. - * The returned record is sent back verbatim as the comm reply payload. + * Per-call authority minted by this dispatcher. Although its TypeScript shape is + * exported for handler implementations, a value is accepted only when this module + * has registered its object identity for the active request. */ -export type HostRequestHandler = (payload: Record) => Promise>; +export interface HostRequestContext { + readonly requestId: string; + readonly generation: number; + readonly signal: AbortSignal; + isCurrent(): boolean; +} + +const hostRequestHandlerBrand = Symbol("hostRequestHandler"); +const factoryCreatedHostRequestHandlers = new WeakSet(); +const dispatcherCreatedHostRequestContexts = new WeakSet(); + +/** Context-aware registrations are explicit; implementation arity is not authority. */ +export interface HostRequestHandlerOptions { + readonly contextAware: true; +} + +/** The stable marker makes default/rest callbacks unambiguous without Function.length. */ +export const contextAwareHostRequestHandler: HostRequestHandlerOptions = Object.freeze({ contextAware: true }); + +/** Implementations receive dispatcher-minted context; the marker makes rest/default callbacks unambiguous. */ +export type HostRequestHandlerImplementation = ( + payload: HostRequestPayload, + context: HostRequestContext, +) => Promise>; + +/** Public registration shape; provenance is always checked at dispatch time. */ +export type HostRequestHandler = HostRequestHandlerImplementation; + +/** A factory-minted capability. Raw, copied-brand, and fabricated handlers are rejected. */ +type HostRequestHandlerCapability = HostRequestHandler & { readonly [hostRequestHandlerBrand]: true }; + +function assertGenuineHostRequestContext(context: unknown): asserts context is HostRequestContext { + if (typeof context !== "object" || context === null || !dispatcherCreatedHostRequestContexts.has(context)) { + throw new Error("host request context is invalid"); + } +} + +function mintHostRequestContext( + requestId: string, + generation: number, + controller: AbortController, +): HostRequestContext { + let current = true; + const context: HostRequestContext = Object.freeze({ + requestId, + generation, + signal: controller.signal, + isCurrent: () => current && !controller.signal.aborted, + }); + dispatcherCreatedHostRequestContexts.add(context); + controller.signal.addEventListener( + "abort", + () => { + current = false; + dispatcherCreatedHostRequestContexts.delete(context); + }, + { once: true }, + ); + return context; +} + +/** + * Factory registration deliberately requires an explicit capability marker. This + * accepts binary, rest, and default-parameter implementations without treating + * Function.length as a security boundary. A missing marker rejects a unary + * JavaScript registration before it can execute. + */ +export function createHostRequestHandler< + T extends (payload: HostRequestPayload, context: HostRequestContext) => Promise>, +>(implementation: T, options: HostRequestHandlerOptions): HostRequestHandlerCapability { + if (options?.contextAware !== true) { + throw new Error("host request handlers require the context-aware marker"); + } + const handler = async (payload: HostRequestPayload, context: HostRequestContext) => { + assertGenuineHostRequestContext(context); + return implementation(payload, context); + }; + factoryCreatedHostRequestHandlers.add(handler); + return Object.defineProperty(handler, hostRequestHandlerBrand, { value: true }) as HostRequestHandlerCapability; +} + +/** Reject copied-symbol and raw-function forgeries before they observe payload data. */ +export function assertHostRequestHandler(value: unknown): asserts value is HostRequestHandlerCapability { + if ( + typeof value !== "function" || + (value as Partial)[hostRequestHandlerBrand] !== true || + !factoryCreatedHostRequestHandlers.has(value) + ) { + throw new Error("host request handler is not a dispatcher-created capability"); + } +} /** Host request handlers keyed by request type (e.g. "rlm.run", "goal.complete"). */ export type HostRequestHandlers = Record; @@ -540,6 +634,15 @@ export class KernelManager { // attribute their spawning program. private lastCellCode?: string; private readonly inFlightHostRequests = new Set>(); + /** Active requests own revocable dispatcher authority, keyed by comm identity. */ + private readonly activeHostRequestControllers = new Map(); + /** Monotonic terminal admission gate: shutdown never grants new host-request authority. */ + private hostRequestsClosed = false; + /** Incremented synchronously by every public terminal lifecycle entry point. */ + private terminalRevision = 0; + /** Once terminal, this manager can never reopen host-request admission. */ + private terminal = false; + private hostRequestGeneration = 0; private state: "idle" | "starting" | "running" | "shutdown" = "idle"; /** Memoized so concurrent callers all await the same in-flight startup. */ private startPromise?: Promise; @@ -568,6 +671,9 @@ export class KernelManager { } async start(options: KernelStartOptions = {}): Promise { + if (this.terminal) { + throw new Error("Kernel was disposed"); + } if (options.signal?.aborted) { throw createKernelStartupAbortError(); } @@ -582,6 +688,8 @@ export class KernelManager { private async doStart(startOptions: KernelStartOptions): Promise { if (this.state !== "idle") return; + if (this.terminal) throw new Error("Kernel was disposed"); + this.hostRequestsClosed = false; this.state = "starting"; installSignalHandlersOnce(); // Tracked from the moment startup begins so session cleanup and signal @@ -682,7 +790,7 @@ export class KernelManager { this.connection = conn; } catch (e) { const canRetryStartup = (this.state as string) !== "shutdown"; - await this.shutdown(); + await this.shutdownInternal(); if (canRetryStartup) this.state = "idle"; throw e; } @@ -703,11 +811,15 @@ export class KernelManager { await this.probeReady(); } catch (e) { const canRetryStartup = (this.state as string) !== "shutdown"; - await this.shutdown(); + await this.shutdownInternal(); if (canRetryStartup) this.state = "idle"; throw e; } + if (this.terminal) { + this.cleanupResources(); + throw new Error("Kernel was disposed during startup"); + } this.state = "running"; this.startForkedLivenessMonitor(); } @@ -811,11 +923,19 @@ export class KernelManager { } /** Queue and run a cell, serializing against all other executions. */ - private async enqueueExecute(code: string, opts: ExecuteOptions): Promise { + private async enqueueExecute( + code: string, + opts: ExecuteOptions, + lifecycle?: { allowTerminalRunning: true }, + ): Promise { if (opts.signal?.aborted) { return { stdout: "", stderr: "", status: "aborted", durationMs: 0 }; } - await this.start({ signal: opts.signal }); + if (lifecycle?.allowTerminalRunning) { + if (!this.terminal || !this.isRunning) throw new Error("Kernel is not running for terminal snapshot"); + } else { + await this.start({ signal: opts.signal }); + } if ((this.state as string) === "shutdown") { throw new Error("Kernel has been shut down"); } @@ -1197,6 +1317,7 @@ export class KernelManager { if (msgType === "comm_close") { this.commTargets.delete(commId); this.handledHostRequestCommIds.delete(commId); + this.revokeHostRequest(commId, "host request comm was closed"); return; } @@ -1218,24 +1339,37 @@ export class KernelManager { } } + private revokeHostRequest(commId: string, _reason: string): void { + const controller = this.activeHostRequestControllers.get(commId); + if (controller && !controller.signal.aborted) controller.abort(); + } + + private revokeAllHostRequests(reason: string): void { + for (const commId of this.activeHostRequestControllers.keys()) this.revokeHostRequest(commId, reason); + } + + /** Close host-request admission before any terminal await can admit fresh authority. */ + private beginHostRequestShutdown(reason: string): void { + this.hostRequestsClosed = true; + this.revokeAllHostRequests(reason); + } + private startHostRequestFromComm(commId: string, data: unknown): void { - if (this.handledHostRequestCommIds.has(commId)) { - return; - } + if (this.hostRequestsClosed) return; + if (this.handledHostRequestCommIds.has(commId)) return; this.handledHostRequestCommIds.add(commId); - + const controller = new AbortController(); + this.activeHostRequestControllers.set(commId, controller); + const context = mintHostRequestContext(uuid(), ++this.hostRequestGeneration, controller); const task = (async () => { + let result: Record; try { - const result = await this.handleHostRequest(data); - try { - await this.sendCommMessage(commId, { status: "ok", ...result }); - } catch (replyError) { - this.appendKernelDiagnostic( - `failed to send host request ok reply for comm ${commId}: ${errorMessage(replyError)}`, - ); - } + result = await this.handleHostRequest(data, context); + if (context.signal.aborted) throw new Error("host request authority was revoked"); } catch (error) { this.appendKernelDiagnostic(`host request failed for comm ${commId}: ${errorMessage(error)}`); + // A closed or replaced comm must not receive a stale error from its former request. + if (context.signal.aborted || this.activeHostRequestControllers.get(commId) !== controller) return; try { await this.sendCommMessage(commId, { status: "error", error: errorMessage(error) }); } catch (replyError) { @@ -1243,31 +1377,35 @@ export class KernelManager { `failed to send host request error reply for comm ${commId}: ${errorMessage(replyError)}`, ); } + return; + } + try { + await this.sendCommMessage(commId, { status: "ok", ...result }); + } catch (replyError) { + this.appendKernelDiagnostic( + `failed to send host request ok reply for comm ${commId}: ${errorMessage(replyError)}`, + ); } })(); this.inFlightHostRequests.add(task); void task.finally(() => { + controller.abort(); // settlement revokes a context retained by an asynchronous handler. + if (this.activeHostRequestControllers.get(commId) === controller) + this.activeHostRequestControllers.delete(commId); this.inFlightHostRequests.delete(task); }); } - private async handleHostRequest(data: unknown): Promise> { - if (!isRecord(data)) { - throw new Error("host request payload must be an object"); - } - if (typeof data.type !== "string" || data.type.length === 0) { + private async handleHostRequest(data: unknown, context: HostRequestContext): Promise> { + if (!isRecord(data)) throw new Error("host request payload must be an object"); + if (typeof data.type !== "string" || data.type.length === 0) throw new Error("host request payload must have a string type"); - } - const handler = this.options.hostHandlers?.[data.type]; - if (!handler) { - throw new Error(`host request type "${data.type}" is not available in this session`); - } - // Tag the request with the cell that triggered it. A blocking call is still - // the in-flight execution; detached spawns (asyncio.create_task) fire after - // the scheduling cell goes idle, so fall back to that last cell's source. + if (!handler) throw new Error(`host request type "${data.type}" is not available in this session`); + // Prove registration provenance before creating the implementation payload. + assertHostRequestHandler(handler); const cellSourceCode = this.activeExecution?.code ?? this.lastCellCode; - return handler({ ...data, cellSourceCode }); + return handler({ ...data, cellSourceCode }, context); } private async sendCommMessage(commId: string, data: Record): Promise { @@ -1286,6 +1424,7 @@ export class KernelManager { } private cleanupResources(killSignal: NodeJS.Signals = "SIGTERM"): void { + this.beginHostRequestShutdown("kernel resources are being cleaned up"); this.clearSnapshotTimer(); this.lateSentAgentMessageHandlers.clear(); if (this.forkedLivenessTimer) { @@ -1345,7 +1484,17 @@ export class KernelManager { } } - async shutdown(opts: { snapshot?: boolean } = {}): Promise { + /** Enter the one-way terminal state before any asynchronous teardown work begins. */ + private enterTerminal(reason: string): void { + if (this.terminal) return; + this.terminal = true; + this.terminalRevision++; + this.beginHostRequestShutdown(reason); + } + + /** Shutdown implementation shared with restart; unlike public shutdown, it is not terminal. */ + private async shutdownInternal(opts: { snapshot?: boolean } = {}): Promise { + this.beginHostRequestShutdown("kernel is shutting down"); if (this.state === "shutdown") { liveKernels.delete(this); this.cleanupResources(); @@ -1374,7 +1523,13 @@ export class KernelManager { this.cleanupResources(); } + async shutdown(opts: { snapshot?: boolean } = {}): Promise { + this.enterTerminal("kernel is shutting down"); + await this.shutdownInternal(opts); + } + async restart(): Promise { + const terminalRevision = this.terminalRevision; const prev = this.executionQueue; let resolveNext: () => void = () => {}; this.executionQueue = new Promise((r) => { @@ -1383,7 +1538,11 @@ export class KernelManager { await prev; try { - await this.shutdown(); + await this.shutdownInternal(); + if (this.terminal || terminalRevision !== this.terminalRevision) { + throw new Error("Kernel terminated during restart"); + } + this.hostRequestsClosed = false; this.state = "idle"; this.kernelStderr = ""; await this.start(); @@ -1393,6 +1552,7 @@ export class KernelManager { } async kill(): Promise { + this.enterTerminal("kernel is being killed"); this.state = "shutdown"; liveKernels.delete(this); this.cleanupResources("SIGKILL"); @@ -1403,11 +1563,19 @@ export class KernelManager { * the kernel isn't running or no snapshot target was configured. Never throws. */ async snapshotState(): Promise { + return this.snapshotStateInternal(false); + } + + private async snapshotStateInternal(allowTerminalRunning: boolean): Promise { const cfg = this.options.snapshot; if (!cfg || !this.isRunning) return null; const code = buildSnapshotCode(cfg.path, cfg.manifestPath, cfg.maxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES); try { - const r = await this.enqueueExecute(code, { maxOutputChars: SNAPSHOT_MAX_OUTPUT_CHARS, internal: true }); + const r = await this.enqueueExecute( + code, + { maxOutputChars: SNAPSHOT_MAX_OUTPUT_CHARS, internal: true }, + allowTerminalRunning ? { allowTerminalRunning: true } : undefined, + ); if (r.status !== "ok") { this.appendKernelDiagnostic(`state snapshot failed: ${r.error?.evalue ?? r.stderr}`); return null; @@ -1490,7 +1658,7 @@ export class KernelManager { if (timeout && typeof timeout === "object" && "unref" in timeout) timeout.unref(); }); try { - await Promise.race([this.snapshotState().then(() => undefined), guard]); + await Promise.race([this.snapshotStateInternal(true).then(() => undefined), guard]); } finally { if (timeout) clearTimeout(timeout); } @@ -1498,6 +1666,7 @@ export class KernelManager { /** Graceful cleanup. Waits briefly for in-flight host request handlers before closing sockets. */ dispose(): Promise { + this.enterTerminal("kernel is being disposed"); return (async () => { // Final namespace flush while the kernel is still live (session end / reload). await this.flushSnapshotForDispose(); @@ -1517,6 +1686,7 @@ export class KernelManager { /** Synchronous best-effort cleanup. Safe to call from `process.on('exit')`. */ disposeSync(): void { + this.enterTerminal("kernel is being disposed"); this.state = "shutdown"; liveKernels.delete(this); // TODO: replace this best-effort hard-exit path if Node exposes an awaitable process-exit cleanup hook. diff --git a/packages/coding-agent/src/core/mcp/mcp-manager.ts b/packages/coding-agent/src/core/mcp/mcp-manager.ts index 58eafb663..0111f637e 100644 --- a/packages/coding-agent/src/core/mcp/mcp-manager.ts +++ b/packages/coding-agent/src/core/mcp/mcp-manager.ts @@ -9,6 +9,7 @@ import { } from "@earendil-works/pi-ai/mcp"; import { registerOAuthProvider, unregisterOAuthProvider } from "@earendil-works/pi-ai/oauth"; import type { AuthStorage } from "../auth-storage.js"; +import { contextAwareHostRequestHandler, createHostRequestHandler, type HostRequestHandlers } from "../kernel/index.js"; import type { McpServerConfig } from "../settings-manager.js"; export interface McpManagerOptions { @@ -153,9 +154,9 @@ export class McpManager { } /** Host-request handlers exposed to the kernel. */ - hostHandlers(): Record) => Promise>> { - const handlers: Record) => Promise>> = { - "mcp.refresh": async (payload) => { + hostHandlers(): HostRequestHandlers { + const handlers: HostRequestHandlers = { + "mcp.refresh": createHostRequestHandler(async (payload, _context) => { const server = String(payload.server ?? ""); if (!server) throw new Error("mcp.refresh requires a server"); // getApiKey refreshes + rewrites auth.json under lock; Python re-reads. @@ -164,10 +165,10 @@ export class McpManager { const key = await this.authStorage.getApiKey(this.providerId(server)); if (!key) throw new Error(`Could not refresh credentials for ${server}`); return {}; - }, + }, contextAwareHostRequestHandler), // Resolved config so the kernel skill connects to the same URL the host // registered/authenticated (honors a user's mcpServers `url` override). - "mcp.config": async (payload) => { + "mcp.config": createHostRequestHandler(async (payload, _context) => { const server = String(payload.server ?? ""); if (!server) throw new Error("mcp.config requires a server"); const integration = this.integrations.get(server); @@ -177,18 +178,18 @@ export class McpManager { config.headers = integration.headers; } return config; - }, + }, contextAwareHostRequestHandler), }; // Only expose begin_login when an interactive login is actually wired, so the // kernel doesn't get a handler whose only behavior is to throw. const beginLogin = this.beginLogin; if (beginLogin) { - handlers["mcp.begin_login"] = async (payload) => { + handlers["mcp.begin_login"] = createHostRequestHandler(async (payload, _context) => { const server = String(payload.server ?? ""); if (!server) throw new Error("mcp.begin_login requires a server"); await beginLogin(server); return {}; - }; + }, contextAwareHostRequestHandler); } return handlers; } diff --git a/packages/coding-agent/src/core/rlm-runtime.ts b/packages/coding-agent/src/core/rlm-runtime.ts index e89472fce..04bc8fb85 100644 --- a/packages/coding-agent/src/core/rlm-runtime.ts +++ b/packages/coding-agent/src/core/rlm-runtime.ts @@ -2,7 +2,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Api, Model, ServiceTier } from "@earendil-works/pi-ai"; import type { AgentSession } from "./agent-session.js"; import type { ToolDefinition } from "./extensions/index.js"; -import type { HostRequestHandler } from "./kernel/index.js"; +import { contextAwareHostRequestHandler, createHostRequestHandler, type HostRequestHandler } from "./kernel/index.js"; export interface RlmRunRequest { prompt: string; @@ -150,7 +150,7 @@ export function findRlmModelMatches(query: string, models: Model[], limit: /** Adapt an RlmRunHandler into the typed "rlm.run" handler for the kernel host bridge. */ export function createRlmRunHostHandler(handler: RlmRunHandler): HostRequestHandler { - return async (payload) => { + return createHostRequestHandler(async (payload, _context) => { if (typeof payload.prompt !== "string") { throw new Error("rlm.run prompt must be a string"); } @@ -162,12 +162,12 @@ export function createRlmRunHostHandler(handler: RlmRunHandler): HostRequestHand cellSourceCode, }); return result as unknown as Record; - }; + }, contextAwareHostRequestHandler); } /** Search a bounded authenticated model catalog without adding it to the system prompt. */ export function createRlmFindModelsHostHandler(handler: RlmFindModelsHandler): HostRequestHandler { - return async (payload) => { + return createHostRequestHandler(async (payload, _context) => { if (typeof payload.query !== "string") { throw new Error("rlm.find_models query must be a string"); } @@ -176,26 +176,26 @@ export function createRlmFindModelsHostHandler(handler: RlmFindModelsHandler): H throw new Error(`rlm.find_models limit must be an integer from 1 to ${MAX_RLM_MODEL_SEARCH_LIMIT}`); } return { models: (await handler(payload.query, limit as number)).models }; - }; + }, contextAwareHostRequestHandler); } /** Expose the current parent session's RLM child registry to its kernel. */ export function createRlmListSubagentsHostHandler(handler: RlmListSubagentsHandler): HostRequestHandler { - return async () => { + return createHostRequestHandler(async (_payload, _context) => { const { subagents } = await handler(); return { subagents }; - }; + }, contextAwareHostRequestHandler); } /** Delete one direct child selected from the current parent session's registry. */ export function createRlmDeleteSubagentHostHandler(handler: RlmDeleteSubagentHandler): HostRequestHandler { - return async (payload) => { + return createHostRequestHandler(async (payload, _context) => { if (typeof payload.target !== "string" || !payload.target.trim()) { throw new Error("rlm.delete_subagent target must be a non-empty string"); } const { subagent, outcome } = await handler(payload.target.trim()); return outcome === undefined ? { subagent } : { subagent, outcome }; - }; + }, contextAwareHostRequestHandler); } export interface RlmSubagentRuntime { diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index f9161168e..e4075e774 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -1013,12 +1013,37 @@ export async function readSessionInfo(filePath: string): Promise>): Promise { +/** Parse catalog-authorized bytes without reopening their pathname. */ +export async function readSessionInfoFromBuffer( + filePath: string, + contents: Buffer, + metadata: { mtimeMs: number }, +): Promise { + async function* lines(): AsyncGenerator { + let start = 0; + while (start < contents.length) { + const end = contents.indexOf(0x0a, start); + if (end === -1) { + yield contents.subarray(start); + return; + } + yield contents.subarray(start, end); + start = end + 1; + } + } + return scanSessionInfo(filePath, { mtime: new Date(metadata.mtimeMs) } as Awaited>, lines()); +} + +async function scanSessionInfo( + filePath: string, + stats: Awaited>, + lines: AsyncIterable, +): Promise { try { let header: SessionHeader | undefined; let messageCount = 0; @@ -1029,7 +1054,7 @@ async function scanSessionInfo(filePath: string, stats: Awaited { type CatalogRequest = | { type: "request"; id: string; command: "list"; cwd?: string; sessionDir?: string } + | { type: "request"; id: string; command: "family"; sessionDir?: string } | { type: "request"; id: string; command: "resolve"; selector: string; cwd: string; sessionDir?: string } - | { type: "request"; id: string; command: "siblings"; sessionPath: string } + | { type: "request"; id: string; command: "siblings"; sessionPath: string; sessionDir?: string } | { type: "request"; id: string; command: "rename"; sessionPath: string; name: string } | { type: "request"; id: string; command: "delete"; sessionPath: string } | { type: "request"; id: string; command: "archive"; sessionPath: string; sessionId: string } @@ -66,42 +73,339 @@ interface SavedRlmSubagentRegistryEntry { status?: unknown; } -export async function listSavedSessionSiblings(sessionPath: string): Promise { - const target = await readSessionInfo(sessionPath); - if (!target) throw new Error(`Session not found: ${sessionPath}`); - if (!target.parentSessionPath) return [target]; - const parentPath = resolve(dirname(target.path), target.parentSessionPath); - const parent = await readSessionInfo(parentPath); - if (!parent) return [target]; - const registryPath = join(dirname(dirname(parent.path)), "session-artifacts", parent.id, "rlm-subagents.jsonl"); +const MAX_RLM_REGISTRY_BYTES = 1024 * 1024; +const MAX_RLM_REGISTRY_RECORDS = 10_000; +const MAX_RLM_FAMILY_EDGES = 10_000; +const MAX_RLM_FAMILY_NODES = 10_000; +const MAX_RLM_FAMILY_DEPTH = 64; + +interface ManagedRoot { + lexical: string; + fd: number; +} + +interface ManagedRoots { + session: ManagedRoot; + artifacts: ManagedRoot | undefined; +} + +interface TrustedFile { + path: string; + contents: Buffer; + mtimeMs: number; + dev: string; + ino: string; +} + +interface TrustedSession extends SessionInfo { + /** The header claim is intentionally separate from SessionInfo's legacy fallback. */ + persistedDepth: number; + persistedParentPath?: string; +} + +function rlmSubagentRegistryPath(parent: SessionInfo, roots: ManagedRoots): string | undefined { + const parentDir = dirname(parent.path); + const artifactDir = + parentDir === roots.session.lexical ? roots.artifacts?.lexical : join(parentDir, "session-artifacts"); + return artifactDir ? join(artifactDir, parent.id, "rlm-subagents.jsonl") : undefined; +} + +function isWithin(root: string, target: string): boolean { + const path = relative(root, target); + return path === "" || (!path.startsWith("..") && !isAbsolute(path)); +} + +function invalidFamilyTopology(reason: string): Error { + return new Error(`Invalid RLM artifact family topology: ${reason}`); +} + +let openAuthorityFdCountForTest = 0; +/** @internal */ +export function getOpenCatalogAuthorityFdCountForTest(): number { + return openAuthorityFdCountForTest; +} + +function openAuthorityRoot(path: string, optional = false): ManagedRoot | undefined { + try { + // O_NOFOLLOW binds authority to the directory itself, never a pathname target. + const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); + openAuthorityFdCountForTest++; + return { lexical: path, fd }; + } catch (error) { + if (optional && (error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw invalidFamilyTopology(`managed authority root is unavailable: ${String(error)}`); + } +} + +function managedRoots(sessionDir: string | undefined): ManagedRoots { + const sessionLexical = resolve(sessionDir ?? ""); + const session = openAuthorityRoot(sessionLexical)!; + try { + const artifacts = openAuthorityRoot(join(dirname(sessionLexical), "session-artifacts"), true); + return { session, artifacts }; + } catch (error) { + closeSync(session.fd); + throw error; + } +} + +function closeManagedRoots(roots: ManagedRoots): void { + closeSync(roots.session.fd); + openAuthorityFdCountForTest--; + if (roots.artifacts) { + closeSync(roots.artifacts.fd); + openAuthorityFdCountForTest--; + } +} + +const OPENAT_READ_HELPER = String.raw`import base64,json,os,stat,sys +MAX=134217728 +def reject(): raise ValueError("invalid") +def flags(directory=False): + value=os.O_RDONLY|os.O_NOFOLLOW + if directory: value|=os.O_DIRECTORY + return value +def main(): + req=json.loads(sys.stdin.buffer.read(131073)) + parts=req.get("parts"); limit=req.get("limit") + if not isinstance(parts,list) or not parts or not isinstance(limit,int) or limit<0 or limit>MAX: reject() + if any(not isinstance(p,str) or not p or p in (".","..") or "/" in p or "\\" in p for p in parts): reject() + current=os.dup(3) + try: + for part in parts[:-1]: + nxt=os.open(part,flags(True),dir_fd=current); os.close(current); current=nxt + fd=os.open(parts[-1],flags(False),dir_fd=current) + try: + before=os.fstat(fd) + if not stat.S_ISREG(before.st_mode) or before.st_size>limit: reject() + chunks=[]; total=0 + while True: + chunk=os.read(fd,min(65536,limit+1-total)) + if not chunk: break + chunks.append(chunk); total+=len(chunk) + if total>limit: reject() + after=os.fstat(fd) + if (before.st_dev,before.st_ino,before.st_mode)!=(after.st_dev,after.st_ino,after.st_mode): reject() + print(json.dumps({"data":base64.b64encode(b"".join(chunks)).decode("ascii"),"mtimeMs":after.st_mtime_ns/1000000,"dev":str(after.st_dev),"ino":str(after.st_ino)},separators=(",",":"))) + finally: os.close(fd) + finally: os.close(current) +try: main() +except FileNotFoundError: sys.exit(44) +except Exception: sys.stderr.write("catalog openat helper failed\n"); sys.exit(1) +`; + +/** Test-only seam runs after authority selection but before descriptor-relative traversal. */ +let beforeTrustedOpenForTest: ((path: string) => void) | undefined; +/** @internal */ +export function setCatalogBeforeTrustedOpenForTest(hook: ((path: string) => void) | undefined): void { + beforeTrustedOpenForTest = hook; +} + +function readTrustedFile(rawPath: string, roots: ManagedRoots, maxBytes: number): TrustedFile { + if (!isAbsolute(rawPath) || rawPath !== resolve(rawPath)) + throw invalidFamilyTopology("session path is not canonical"); + const root = [roots.session, roots.artifacts].find((candidate) => candidate && isWithin(candidate.lexical, rawPath)); + if (!root) throw invalidFamilyTopology("session path escapes managed roots"); + const suffix = relative(root.lexical, rawPath); + const parts = suffix.split(sep); + if (!suffix || parts.some((part) => !part || part === "." || part === "..")) { + throw invalidFamilyTopology("session path lacks a trusted file component"); + } + beforeTrustedOpenForTest?.(rawPath); + const result = spawnSync( + process.execPath === process.env.PRIME_AGENT_KERNEL_PYTHON + ? process.execPath + : (process.env.PRIME_AGENT_KERNEL_PYTHON ?? "python3"), + ["-I", "-c", OPENAT_READ_HELPER], + { + input: JSON.stringify({ parts, limit: maxBytes }), + encoding: "utf8", + timeout: 5_000, + maxBuffer: maxBytes * 2 + 64 * 1024, + stdio: ["pipe", "pipe", "pipe", root.fd], + shell: false, + }, + ); + if (result.status === 44) throw invalidFamilyTopology("descriptor-relative artifact is absent"); + if (result.error || result.status !== 0 || typeof result.stdout !== "string") { + throw invalidFamilyTopology("descriptor-relative artifact read failed"); + } + try { + const wire = JSON.parse(result.stdout) as { data?: unknown; mtimeMs?: unknown; dev?: unknown; ino?: unknown }; + if ( + typeof wire.data !== "string" || + typeof wire.mtimeMs !== "number" || + typeof wire.dev !== "string" || + typeof wire.ino !== "string" + ) + throw new Error("invalid"); + return { + path: rawPath, + contents: Buffer.from(wire.data, "base64"), + mtimeMs: wire.mtimeMs, + dev: wire.dev, + ino: wire.ino, + }; + } catch { + throw invalidFamilyTopology("descriptor-relative artifact response is invalid"); + } +} + +async function readTrustedSession(path: string, roots: ManagedRoots): Promise { + const trusted = readTrustedFile(path, roots, 128 * 1024 * 1024); + const headerLine = trusted.contents + .toString("utf8", 0, Math.min(trusted.contents.length, 256 * 1024)) + .split(/\r?\n/, 1)[0]; + let header: { type?: unknown; id?: unknown; parentSession?: unknown; rlmDepth?: unknown }; + try { + header = JSON.parse(headerLine ?? "") as typeof header; + } catch { + throw invalidFamilyTopology("session header is malformed"); + } + const hasParent = header.parentSession !== undefined; + const hasDepth = Number.isSafeInteger(header.rlmDepth) && (header.rlmDepth as number) >= 0; + if ( + header.type !== "session" || + typeof header.id !== "string" || + header.id === "" || + (hasParent && typeof header.parentSession !== "string") || + (hasParent && header.parentSession === "") || + (hasParent && !hasDepth) || + (!hasParent && header.rlmDepth !== undefined && !hasDepth) + ) + throw invalidFamilyTopology("session header lacks trustworthy topology claims"); + const persistedDepth = hasDepth ? (header.rlmDepth as number) : 0; + const info = await readSessionInfoFromBuffer(path, trusted.contents, { mtimeMs: trusted.mtimeMs }); + if (!info || info.id !== header.id) throw invalidFamilyTopology("session metadata does not match its header"); + return { + ...info, + path, + rlmDepth: persistedDepth, + persistedDepth, + ...(hasParent ? { persistedParentPath: header.parentSession as string } : {}), + }; +} + +async function readLatestRegistry( + path: string, + roots: ManagedRoots, +): Promise { let contents: string; try { - contents = await readFile(registryPath, "utf8"); + contents = readTrustedFile(path, roots, MAX_RLM_REGISTRY_BYTES).contents.toString("utf8"); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return [target]; + if ((error as Error).message.includes("descriptor-relative artifact is absent")) return undefined; throw error; } const latest = new Map(); + let records = 0; for (const line of contents.split(/\r?\n/)) { if (!line.trim()) continue; + if (++records > MAX_RLM_REGISTRY_RECORDS) throw invalidFamilyTopology("registry record limit exhausted"); + let entry: SavedRlmSubagentRegistryEntry; try { - const entry = JSON.parse(line) as SavedRlmSubagentRegistryEntry; - if (entry.type === "rlm_subagent" && typeof entry.childId === "string") latest.set(entry.childId, entry); + entry = JSON.parse(line) as SavedRlmSubagentRegistryEntry; } catch { - // Ignore malformed registry history just like the owning worker does. + throw invalidFamilyTopology("registry contains malformed JSON"); } + if ( + entry.type !== "rlm_subagent" || + typeof entry.childId !== "string" || + entry.childId === "" || + typeof entry.sessionFile !== "string" || + entry.sessionFile === "" || + (entry.status !== "running" && entry.status !== "completed" && entry.status !== "deleted") + ) { + throw invalidFamilyTopology("registry contains an invalid edge"); + } + latest.set(entry.childId, entry); } - const siblingPaths = new Set([resolve(target.path)]); - for (const entry of latest.values()) { - if (entry.status !== "deleted" && typeof entry.sessionFile === "string") - siblingPaths.add(resolve(entry.sessionFile)); + return [...latest.values()]; +} + +export async function listCatalogFamilySessions(sessionDir?: string): Promise { + const effectiveSessionDir = sessionDir ?? getSessionsDir(); + const roots = await SessionManager.listAll(undefined, effectiveSessionDir); + const authority = managedRoots(effectiveSessionDir); + try { + const sessions = new Map(); + const ids = new Map(); + for (const root of roots) { + const trusted = await readTrustedSession(root.path, authority); + if (trusted.persistedDepth !== 0 || trusted.persistedParentPath !== undefined) { + throw invalidFamilyTopology("managed session seed claims a parent"); + } + const existingPath = ids.get(trusted.id); + if (existingPath && existingPath !== trusted.path) + throw invalidFamilyTopology("family contains a duplicate session id"); + ids.set(trusted.id, trusted.path); + sessions.set(trusted.path, trusted); + } + let edges = 0; + const visited = new Set(); + const visit = async (parent: TrustedSession, depth: number, ancestors: ReadonlySet): Promise => { + if (depth > MAX_RLM_FAMILY_DEPTH) throw invalidFamilyTopology("family depth limit exhausted"); + const parentPath = parent.path; + if (ancestors.has(parentPath)) throw invalidFamilyTopology("family contains a cycle"); + if (visited.has(parentPath)) return; + visited.add(parentPath); + const registryPath = rlmSubagentRegistryPath(parent, authority); + if (!registryPath) return; + const entries = await readLatestRegistry(registryPath, authority); + if (!entries) return; + const childAncestors = new Set(ancestors); + childAncestors.add(parentPath); + for (const entry of entries) { + if (entry.status === "deleted") continue; + if (++edges > MAX_RLM_FAMILY_EDGES) throw invalidFamilyTopology("family edge limit exhausted"); + const childPath = entry.sessionFile as string; + if (childAncestors.has(childPath)) throw invalidFamilyTopology("family contains a cycle"); + const child = await readTrustedSession(childPath, authority); + if (child.id !== entry.childId) throw invalidFamilyTopology("registry child id does not match session id"); + if (child.persistedParentPath === undefined) + throw invalidFamilyTopology("child lacks a persisted parent path"); + const claimedParentPath = resolve(dirname(child.path), child.persistedParentPath); + readTrustedFile(claimedParentPath, authority, 128 * 1024 * 1024); + if (claimedParentPath !== parentPath) + throw invalidFamilyTopology("child parent path does not match traversed parent"); + if (child.persistedDepth !== parent.persistedDepth + 1) + throw invalidFamilyTopology("child depth does not equal parent depth plus one"); + const existingPath = ids.get(child.id); + if (existingPath && existingPath !== child.path) + throw invalidFamilyTopology("family contains a duplicate session id"); + const existing = sessions.get(child.path); + if ( + existing && + (existing.id !== child.id || + existing.persistedParentPath !== child.persistedParentPath || + existing.persistedDepth !== child.persistedDepth) + ) { + throw invalidFamilyTopology("family contains a conflicting duplicate"); + } + if (!existing && sessions.size >= MAX_RLM_FAMILY_NODES) + throw invalidFamilyTopology("family node limit exhausted"); + ids.set(child.id, child.path); + sessions.set(child.path, child); + await visit(child, depth + 1, childAncestors); + } + }; + for (const root of [...sessions.values()]) await visit(root, 0, new Set()); + return [...sessions.values()]; + } finally { + closeManagedRoots(authority); } - const siblings = await Promise.all([...siblingPaths].map((path) => readSessionInfo(path))); - return siblings.filter( - (info): info is SessionInfo => - info !== null && - info.parentSessionPath !== undefined && - resolve(dirname(info.path), info.parentSessionPath) === parentPath, +} +export async function listSavedSessionSiblings(sessionPath: string, sessionDir?: string): Promise { + const family = await listCatalogFamilySessions(sessionDir); + const targetPath = resolve(sessionPath); + const target = family.find((session) => session.path === targetPath); + if (!target) throw new Error(`Session not found: ${sessionPath}`); + if (!target.parentSessionPath) return [target]; + const parentPath = resolve(dirname(target.path), target.parentSessionPath); + return family.filter( + (session) => + session.parentSessionPath !== undefined && + resolve(dirname(session.path), session.parentSessionPath) === parentPath, ); } @@ -141,6 +445,7 @@ function isCatalogRequest(value: unknown): value is CatalogRequest { candidate.type === "request" && typeof candidate.id === "string" && (candidate.command === "list" || + candidate.command === "family" || candidate.command === "resolve" || candidate.command === "siblings" || candidate.command === "rename" || @@ -194,6 +499,16 @@ async function handleCatalogRequest(request: CatalogRequest): Promise { }); return; } + case "family": { + const sessions = await listCatalogFamilySessions(request.sessionDir); + sendCatalogMessage({ + type: "response", + id: request.id, + success: true, + data: { sessions: sessions.map(serializeSessionInfo) }, + }); + return; + } case "resolve": { const localMatch = resolveCatalogSessionMatch( await SessionManager.list(request.cwd, request.sessionDir), @@ -228,7 +543,11 @@ async function handleCatalogRequest(request: CatalogRequest): Promise { type: "response", id: request.id, success: true, - data: { sessions: (await listSavedSessionSiblings(request.sessionPath)).map(serializeSessionInfo) }, + data: { + sessions: (await listSavedSessionSiblings(request.sessionPath, request.sessionDir)).map( + serializeSessionInfo, + ), + }, }); return; case "rename": @@ -328,12 +647,23 @@ export class DaemonCatalogClient { return data.sessions.map(deserializeSessionInfo); } - async siblings(sessionPath: string): Promise { + async family(sessionDir?: string): Promise { + const data = await this.request<{ sessions: SessionInfoWire[] }>({ + type: "request", + id: randomUUID(), + command: "family", + sessionDir, + }); + return data.sessions.map(deserializeSessionInfo); + } + + async siblings(sessionPath: string, sessionDir?: string): Promise { const data = await this.request<{ sessions: SessionInfoWire[] }>({ type: "request", id: randomUUID(), command: "siblings", sessionPath, + sessionDir, }); return data.sessions.map(deserializeSessionInfo); } diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 3443cd3f8..164085ce0 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -404,6 +404,21 @@ type PassiveRlmSubagent = PassiveRlmRoot & { chain: PersistedRlmSubagentRegistryEntry[]; }; +type AgentFamilyCatalogSource = "saved" | "passive" | "resident" | "remote"; + +/** + * The public catalog has to retain a usable depth for legacy callers, while + * authorization must distinguish a persisted claim from a depth inferred by a + * legacy reader. These claim fields are deliberately private to catalog + * construction and never escape into agent-messages' public roster. + */ +type AgentFamilyCatalogCandidate = AgentFamilyCatalogEntry & { + source: AgentFamilyCatalogSource; + depthClaim?: number; + parentSessionIdClaim?: string; + parentSessionPathClaim?: string; +}; + class RuntimeOpenCancelledError extends Error {} class BoundSessionUnavailableError extends Error {} @@ -2860,18 +2875,30 @@ export class AgentDaemon { } private async createAgentObserveListResult(currentState: ActiveSessionState): Promise { + const catalog = await this.agentFamilyCatalogEntries(); + this.authoritativeAgentFamilyEntry(currentState, catalog); const agents = this.listTargetableSessionStates(currentState) - .filter( - (state) => - state.activeSessionId === currentState.activeSessionId || - this.isAgentFamilyReachable(currentState, state), - ) - .map((state) => this.createAgentObserveSummary(state, currentState)); + .filter((state) => { + if (state.activeSessionId === currentState.activeSessionId) return true; + try { + assertAgentFamilyReach( + this.authoritativeAgentFamilyEntry(currentState, catalog), + this.authoritativeAgentFamilyEntry(state, catalog), + catalog, + ); + return true; + } catch (error) { + if (error instanceof Error && error.message === AGENT_FAMILY_REACH_ERROR) return false; + throw error; + } + }) + .map((state) => this.createAgentObserveSummary(state, currentState, catalog)); const residentIds = new Set(agents.map((agent) => agent.activeSessionId)); for (const passive of await this.listPassiveRlmSubagents()) { if (residentIds.has(passive.info.id)) continue; try { - assertAgentFamilyReach(this.agentFamilyEntry(currentState), this.passiveAgentFamilyEntry(passive)); + const passiveEntry = this.authoritativeAgentFamilyEntryForSessionId(passive.info.id, catalog); + assertAgentFamilyReach(this.authoritativeAgentFamilyEntry(currentState, catalog), passiveEntry, catalog); } catch (error) { if (error instanceof Error && error.message === AGENT_FAMILY_REACH_ERROR) continue; throw error; @@ -2901,7 +2928,7 @@ export class AgentDaemon { residentIds.add(passive.info.id); } return { - current: this.createAgentObserveSummary(currentState, currentState), + current: this.createAgentObserveSummary(currentState, currentState, catalog), agents, }; } @@ -2910,10 +2937,12 @@ export class AgentDaemon { currentState: ActiveSessionState, target: string, ): Promise { - const targetState = await this.getOrHydrateAuthorizedAgentFamilyTarget(currentState, target); - this.assertAgentFamilyReachable(currentState, targetState); + const { targetState, catalog } = await this.getOrHydrateAuthorizedAgentFamilyTarget(currentState, target); + // Hydration may mutate live endpoint fields. Re-authorize and label only from the + // captured catalog that authorized the wake, never from a post-hydration rescan. + this.assertAgentFamilyReachable(currentState, targetState, catalog); return { - agent: this.createAgentObserveSummary(targetState, currentState), + agent: this.createAgentObserveSummary(targetState, currentState, catalog), }; } @@ -2921,14 +2950,15 @@ export class AgentDaemon { currentState: ActiveSessionState, input: AgentObserveRecentMessagesInput, ): Promise { - const targetState = await this.getOrHydrateAuthorizedAgentFamilyTarget(currentState, input.target); - this.assertAgentFamilyReachable(currentState, targetState); + const { targetState, catalog } = await this.getOrHydrateAuthorizedAgentFamilyTarget(currentState, input.target); + // See getAgent: an observation must use its original authorization snapshot. + this.assertAgentFamilyReachable(currentState, targetState, catalog); const limit = normalizeObserveLimit(input.limit); const maxChars = normalizeObserveMaxChars(input.maxChars); const messages = targetState.runtime.session.messages; const startIndex = Math.max(0, messages.length - limit); return { - agent: this.createAgentObserveSummary(targetState, currentState), + agent: this.createAgentObserveSummary(targetState, currentState, catalog), messages: messages .slice(startIndex) .map((message, offset) => createAgentObserveMessagePreview(message, startIndex + offset, maxChars)), @@ -2941,8 +2971,17 @@ export class AgentDaemon { private createAgentObserveSummary( state: ActiveSessionState, currentState: ActiveSessionState, + catalog: readonly AgentFamilyCatalogEntry[], ): AgentObserveAgentSummary { const summary = summaryForActiveSession(state); + // The catalog is the authorization snapshot, so relationship fields must not + // be re-derived from a runtime that may have changed while a passive target woke. + const topology = this.authoritativeAgentFamilyEntry(state, catalog); + const parentState = topology.parentSessionId + ? [...this.sessions.values()].find( + (candidate) => candidate.runtime.session.sessionId === topology.parentSessionId, + ) + : undefined; const session = state.runtime.session; const messages = session.messages; const latest = messages.at(-1); @@ -2971,8 +3010,8 @@ export class AgentDaemon { messageCount: summary.messageCount, queuedCount: summary.sessionActions.queuedCount, isSessionActive: summary.isSessionActive, - ...(summary.parentActiveSessionId ? { parentActiveSessionId: summary.parentActiveSessionId } : {}), - ...(summary.parentSessionId ? { parentSessionId: summary.parentSessionId } : {}), + ...(parentState ? { parentActiveSessionId: parentState.activeSessionId } : {}), + ...(topology.parentSessionId ? { parentSessionId: topology.parentSessionId } : {}), ...(summary.rlmChildId ? { rlmChildId: summary.rlmChildId } : {}), ...(summary.rlmParentNodeId ? { rlmParentNodeId: summary.rlmParentNodeId } : {}), ...(summary.firstMessage ? { firstMessage: summary.firstMessage } : {}), @@ -4950,6 +4989,8 @@ export class AgentDaemon { passive.rootParentState?.runtime.session.sessionFile ?? passive.rootInfo?.path, rlmDepth: info.rlmDepth ?? entry.rlmDepth, + // Passive rows are not resident daemon runtimes. Registry state is + // retained separately below and must not change roster lifecycle. status: "inactive", rlmChildId: entry.childId, rlmChildRegistryStatus: entry.status, @@ -5035,9 +5076,11 @@ export class AgentDaemon { } private async createAgentFamilyRoster(currentState: ActiveSessionState): Promise { - const catalog = await this.createAgentFamilyCatalog(currentState); - const current = catalog.find((entry) => entry.id === currentState.runtime.session.sessionId); - if (!current) throw new Error("Current agent is missing from the family catalog"); + // Roster membership is an authorization surface: capture the same immutable + // authoritative topology used by message delivery, never the legacy Map + // catalog whose duplicate IDs overwrite one another. + const catalog = await this.agentFamilyCatalogEntries(); + const current = this.authoritativeAgentFamilyEntry(currentState, catalog); return buildAgentFamilyRoster(current, catalog); } @@ -5255,54 +5298,255 @@ export class AgentDaemon { }; } - private passiveAgentFamilyEntry(passive: PassiveRlmSubagent): AgentFamilyCatalogEntry { - const entry = passive.entry; - const depth = passive.info.rlmDepth ?? entry.rlmDepth ?? passive.chain.length; + /** Capture persisted topology once for an authorization decision. */ + private async agentFamilyCatalogEntries(): Promise { + const saved = await SessionManager.listAll(undefined, this.options.defaultSessionConfig.sessionDir); + const entries: AgentFamilyCatalogCandidate[] = await Promise.all( + saved.map((info) => this.savedAgentFamilyCandidate(info)), + ); + // Artifact-resident descendants are absent from the saved-session scan. + for (const passive of await this.listPassiveRlmSubagents(saved, true)) { + entries.push(await this.passiveAgentFamilyCandidate(passive)); + } + for (const state of this.sessions.values()) entries.push(this.residentAgentFamilyCandidate(state)); + for (const peer of this.remoteAgentPeers.values()) entries.push(this.remoteAgentFamilyCandidate(peer)); + return Object.freeze(this.mergeEquivalentAgentFamilyCatalogEntries(entries)); + } + + /** The header is the only durable evidence that a saved depth/path was explicit. */ + private async persistedTopologyClaims(sessionPath: string): Promise<{ depth?: number; parentSessionPath?: string }> { + try { + const firstLine = (await readFile(sessionPath, "utf8")).split("\n", 1)[0]; + if (!firstLine) return {}; + const header = JSON.parse(firstLine) as { rlmDepth?: unknown; parentSession?: unknown }; + const depth = + typeof header.rlmDepth === "number" && Number.isSafeInteger(header.rlmDepth) && header.rlmDepth >= 0 + ? header.rlmDepth + : undefined; + const parentSessionPath = + typeof header.parentSession === "string" && header.parentSession + ? canonicalSessionPath( + isAbsolute(header.parentSession) + ? header.parentSession + : resolve(dirname(sessionPath), header.parentSession), + ) + : undefined; + return { ...(depth !== undefined ? { depth } : {}), ...(parentSessionPath ? { parentSessionPath } : {}) }; + } catch { + return {}; + } + } + + private async savedAgentFamilyCandidate(info: SessionInfo): Promise { + const claims = await this.persistedTopologyClaims(info.path); + return { + id: info.id, + ...(info.name ? { name: info.name } : {}), + // resolveSessionRlmDepth is retained only as a usable legacy fallback. + // It is deliberately not a claim and therefore cannot contradict an overlay. + depth: info.rlmDepth, + status: "inactive", + sessionPath: canonicalSessionPath(info.path), + source: "saved", + ...(claims.depth !== undefined ? { depthClaim: claims.depth } : {}), + ...(claims.parentSessionPath + ? { parentSessionPath: claims.parentSessionPath, parentSessionPathClaim: claims.parentSessionPath } + : {}), + }; + } + + private remoteAgentFamilyCandidate(peer: AgentSessionMessageAgentSummary): AgentFamilyCatalogCandidate { + // Remote peer state is untrusted topology input. In particular, never let a + // malformed subagent be silently coerced into a sibling root by `?? 0`. + const depth = peer.rlmDepth; + const hasParentSessionId = peer.parentSessionId !== undefined; + const hasParentSessionPath = peer.parentSessionPath !== undefined; + const parentSessionId = + typeof peer.parentSessionId === "string" && peer.parentSessionId.trim() ? peer.parentSessionId : undefined; const parentSessionPath = - depth > 0 - ? (entry.parentSessionFile ?? - passive.chain.at(-2)?.sessionFile ?? - passive.rootParentState?.runtime.session.sessionFile ?? - passive.rootInfo?.path) + typeof peer.parentSessionPath === "string" && peer.parentSessionPath.trim() + ? canonicalSessionPath(peer.parentSessionPath) : undefined; + if ( + typeof depth !== "number" || + !Number.isSafeInteger(depth) || + depth < 0 || + (depth === 0 && (hasParentSessionId || hasParentSessionPath)) || + (depth > 0 && !parentSessionId && !parentSessionPath) + ) { + throw new Error(AGENT_FAMILY_REACH_ERROR); + } return { - id: passive.info.id, - name: passive.info.name ?? entry.sessionName, + id: peer.sessionId, + ...(peer.sessionName ? { name: peer.sessionName } : {}), depth, - status: "idle", - ...(depth > 0 && entry.parentSessionId ? { parentSessionId: entry.parentSessionId } : {}), - ...(parentSessionPath ? { parentSessionPath: canonicalSessionPath(parentSessionPath) } : {}), + status: peer.status ?? "idle", + ...(peer.sessionPath ? { sessionPath: canonicalSessionPath(peer.sessionPath) } : {}), + source: "remote", + depthClaim: depth, + ...(parentSessionId ? { parentSessionId, parentSessionIdClaim: parentSessionId } : {}), + ...(parentSessionPath ? { parentSessionPath, parentSessionPathClaim: parentSessionPath } : {}), + }; + } + + private residentAgentFamilyCandidate(state: ActiveSessionState): AgentFamilyCatalogCandidate { + const entry = this.agentFamilyEntry(state); + const session = state.runtime.session; + const metadata = state.runtime.metadata; + const headerParent = this.resolveHeaderParentSessionPath(state); + const parentSessionPath = headerParent ?? metadata.parentSessionFile; + // A root has no parent claim. Do not let a stale runtime rlmDepth turn it + // into a depth-N orphan when its durable/root metadata still says root. + const isUnparentedTopLevel = + metadata.kind === "top-level" && !headerParent && !metadata.parentSessionId && !metadata.parentSessionFile; + const depthClaim = + !isUnparentedTopLevel && + typeof session.rlmDepth === "number" && + Number.isSafeInteger(session.rlmDepth) && + session.rlmDepth >= 0 + ? session.rlmDepth + : undefined; + return { + ...entry, + ...(isUnparentedTopLevel ? { depth: 0 } : {}), + source: "resident", + ...(depthClaim !== undefined ? { depthClaim } : {}), + ...(metadata.parentSessionId + ? { parentSessionId: metadata.parentSessionId, parentSessionIdClaim: metadata.parentSessionId } + : {}), + ...(parentSessionPath + ? { + parentSessionPath: canonicalSessionPath(parentSessionPath), + parentSessionPathClaim: canonicalSessionPath(parentSessionPath), + } + : {}), + }; + } + + /** + * Merge a durable row with a passive/resident view only if all *present* + * topology claims agree. In particular, the depth produced for legacy saved + * files is a fallback, not evidence against a newer explicit overlay. + */ + private mergeEquivalentAgentFamilyCatalogEntries( + entries: readonly AgentFamilyCatalogCandidate[], + ): AgentFamilyCatalogEntry[] { + const canonical = entries.map((entry) => ({ + ...entry, + ...(entry.sessionPath ? { sessionPath: canonicalSessionPath(entry.sessionPath) } : {}), + ...(entry.parentSessionPath ? { parentSessionPath: canonicalSessionPath(entry.parentSessionPath) } : {}), + ...(entry.parentSessionPathClaim + ? { parentSessionPathClaim: canonicalSessionPath(entry.parentSessionPathClaim) } + : {}), + })); + const compatible = (left: AgentFamilyCatalogCandidate, right: AgentFamilyCatalogCandidate) => + left.id === right.id && + left.sessionPath === right.sessionPath && + (left.depthClaim === undefined || right.depthClaim === undefined || left.depthClaim === right.depthClaim) && + (left.parentSessionPathClaim === undefined || + right.parentSessionPathClaim === undefined || + left.parentSessionPathClaim === right.parentSessionPathClaim) && + (left.parentSessionIdClaim === undefined || + right.parentSessionIdClaim === undefined || + left.parentSessionIdClaim === right.parentSessionIdClaim); + const groups: AgentFamilyCatalogCandidate[][] = []; + for (const entry of canonical) { + const group = groups.find((candidate) => candidate.every((member) => compatible(member, entry))); + if (group) group.push(entry); + else groups.push([entry]); + } + const sourceRank: Record = { + saved: 3, + passive: 2, + resident: 1, + remote: 0, + }; + const statusRank: Record = { + inactive: 0, + idle: 1, + running: 2, + }; + const stable = (values: readonly (string | undefined)[]) => + values.filter((value): value is string => value !== undefined).sort()[0]; + const preferred = ( + rows: readonly AgentFamilyCatalogCandidate[], + get: (row: AgentFamilyCatalogCandidate) => T | undefined, + ) => + [...rows] + .sort((left, right) => sourceRank[right.source] - sourceRank[left.source]) + .map(get) + .find((value): value is T => value !== undefined); + return groups.map((rows) => { + const status = rows.reduce( + (best, row) => (statusRank[row.status] > statusRank[best] ? row.status : best), + "inactive", + ); + const depth = preferred(rows, (row) => row.depthClaim) ?? preferred(rows, (row) => row.depth)!; + const parentSessionId = preferred(rows, (row) => row.parentSessionIdClaim); + const parentSessionPath = preferred(rows, (row) => row.parentSessionPathClaim); + return { + id: rows[0]!.id, + depth, + status, + ...(stable(rows.map((row) => row.name)) ? { name: stable(rows.map((row) => row.name)) } : {}), + ...(parentSessionId ? { parentSessionId } : {}), + ...(parentSessionPath ? { parentSessionPath } : {}), + ...(rows[0]!.sessionPath ? { sessionPath: rows[0]!.sessionPath } : {}), + }; + }); + } + + private async passiveAgentFamilyCandidate(passive: PassiveRlmSubagent): Promise { + const entry = passive.entry; + const claims = await this.persistedTopologyClaims(entry.sessionFile); + const registryParentPath = entry.parentSessionFile ? canonicalSessionPath(entry.parentSessionFile) : undefined; + const parentSessionPath = claims.parentSessionPath ?? registryParentPath; + const depthClaim = claims.depth ?? entry.rlmDepth; + return { + id: passive.info.id, + ...((passive.info.name ?? entry.sessionName) ? { name: passive.info.name ?? entry.sessionName } : {}), + depth: depthClaim ?? passive.info.rlmDepth, + status: "inactive", sessionPath: canonicalSessionPath(entry.sessionFile), + source: "passive", + ...(depthClaim !== undefined ? { depthClaim } : {}), + ...(entry.parentSessionId + ? { parentSessionId: entry.parentSessionId, parentSessionIdClaim: entry.parentSessionId } + : {}), + ...(parentSessionPath ? { parentSessionPath, parentSessionPathClaim: parentSessionPath } : {}), }; } private async getOrHydrateAuthorizedAgentFamilyTarget( currentState: ActiveSessionState, target: string, - ): Promise { + ): Promise<{ targetState: ActiveSessionState; catalog: readonly AgentFamilyCatalogEntry[] }> { + const catalog = await this.agentFamilyCatalogEntries(); try { - return this.getBoundSessionState(target); + return { targetState: this.getBoundSessionState(target), catalog }; } catch (error) { if (error instanceof BoundSessionUnavailableError) { const targetState = this.getSessionState(target); - this.assertAgentFamilyReachable(currentState, targetState); - return this.getOrHydrateBoundSessionState(target); + this.assertAgentFamilyReachable(currentState, targetState, catalog); + return { targetState: await this.getOrHydrateBoundSessionState(target), catalog }; } if (error instanceof AmbiguousActiveSessionError) { - const targetState = this.resolveAgentFamilySessionName(currentState, target, error); - return this.getOrHydrateBoundSessionState(targetState.activeSessionId); + const targetState = this.resolveAgentFamilySessionName(currentState, target, error, catalog); + return { targetState: await this.getOrHydrateBoundSessionState(targetState.activeSessionId), catalog }; } } const passive = await this.findPassiveRlmSubagent(target); - if (!passive) return this.getOrHydrateBoundSessionState(target); - assertAgentFamilyReach(this.agentFamilyEntry(currentState), this.passiveAgentFamilyEntry(passive)); - return this.hydratePassiveRlmSubagent(passive); + if (!passive) return { targetState: await this.getOrHydrateBoundSessionState(target), catalog }; + const passiveEntry = this.authoritativeAgentFamilyEntryForSessionId(passive.info.id, catalog); + assertAgentFamilyReach(this.authoritativeAgentFamilyEntry(currentState, catalog), passiveEntry, catalog); + return { targetState: await this.hydratePassiveRlmSubagent(passive), catalog }; } private resolveAgentFamilySessionName( currentState: ActiveSessionState, target: string, ambiguity: AmbiguousActiveSessionError, + catalog: readonly AgentFamilyCatalogEntry[], ): ActiveSessionState { const reachableMatches = new Map( [...this.sessions.values()] @@ -5311,7 +5555,7 @@ export class AgentDaemon { return ( (session.sessionId === target || session.sessionName === target) && (state.activeSessionId === currentState.activeSessionId || - this.isAgentFamilyReachable(currentState, state)) + this.isAgentFamilyReachable(currentState, state, catalog)) ); }) .map((state) => [state.activeSessionId, state]), @@ -5321,9 +5565,36 @@ export class AgentDaemon { return matches[0]!; } - private isAgentFamilyReachable(currentState: ActiveSessionState, targetState: ActiveSessionState): boolean { + private authoritativeAgentFamilyEntry( + state: ActiveSessionState, + catalog: readonly AgentFamilyCatalogEntry[], + ): AgentFamilyCatalogEntry { + return this.authoritativeAgentFamilyEntryForSessionId(state.runtime.session.sessionId, catalog); + } + + private authoritativeAgentFamilyEntryForSessionId( + sessionId: string, + catalog: readonly AgentFamilyCatalogEntry[], + ): AgentFamilyCatalogEntry { + const entries = catalog.filter((candidate) => candidate.id === sessionId); + if (entries.length !== 1) throw new Error(AGENT_FAMILY_REACH_ERROR); + return entries[0]!; + } + + private isAgentFamilyReachable( + currentState: ActiveSessionState, + targetState: ActiveSessionState, + catalog: readonly AgentFamilyCatalogEntry[] = [ + this.agentFamilyEntry(currentState), + this.agentFamilyEntry(targetState), + ], + ): boolean { try { - assertAgentFamilyReach(this.agentFamilyEntry(currentState), this.agentFamilyEntry(targetState)); + assertAgentFamilyReach( + this.authoritativeAgentFamilyEntry(currentState, catalog), + this.authoritativeAgentFamilyEntry(targetState, catalog), + catalog, + ); return true; } catch (error) { if (error instanceof Error && error.message === AGENT_FAMILY_REACH_ERROR) return false; @@ -5331,17 +5602,49 @@ export class AgentDaemon { } } - private assertAgentFamilyReachable(currentState: ActiveSessionState, targetState: ActiveSessionState): void { + private assertAgentFamilyReachable( + currentState: ActiveSessionState, + targetState: ActiveSessionState, + catalog: readonly AgentFamilyCatalogEntry[] = [ + this.agentFamilyEntry(currentState), + this.agentFamilyEntry(targetState), + ], + ): void { if (currentState.activeSessionId === targetState.activeSessionId) return; - assertAgentFamilyReach(this.agentFamilyEntry(currentState), this.agentFamilyEntry(targetState)); + assertAgentFamilyReach( + this.authoritativeAgentFamilyEntry(currentState, catalog), + this.authoritativeAgentFamilyEntry(targetState, catalog), + catalog, + ); } private agentMessageRelationship( fromState: ActiveSessionState | undefined, targetState: ActiveSessionState, + catalog: readonly AgentFamilyCatalogEntry[] = [ + this.agentFamilyEntry(targetState), + ...(fromState ? [this.agentFamilyEntry(fromState)] : []), + ], ): AgentFamilyRelationship | undefined { if (!fromState) return undefined; - return agentFamilyRelationship(this.agentFamilyEntry(targetState), this.agentFamilyEntry(fromState)); + return agentFamilyRelationship( + this.authoritativeAgentFamilyEntry(targetState, catalog), + this.authoritativeAgentFamilyEntry(fromState, catalog), + catalog, + ); + } + + private cliAgentMessageRelationship( + fromState: ActiveSessionState, + targetState: ActiveSessionState, + catalog: readonly AgentFamilyCatalogEntry[], + ): AgentFamilyRelationship | undefined { + try { + return this.agentMessageRelationship(fromState, targetState, catalog); + } catch (error) { + if (error instanceof Error && error.message === AGENT_FAMILY_REACH_ERROR) return undefined; + throw error; + } } private async sendAgentSessionMessage(options: { @@ -5358,27 +5661,48 @@ export class AgentDaemon { } const targetSelector = assertDirectAgentMessageTarget(options.targetSelector); const message = normalizeAgentSessionMessage(options.message, DEFAULT_AGENT_MESSAGE_MAX_CHARS); + // Agent-origin authorization uses one immutable persisted topology through + // selector resolution, wake, and delivery. CLI-origin topology is advisory + // label metadata and must not make an otherwise valid delivery unavailable. + let catalog: readonly AgentFamilyCatalogEntry[] | undefined; + if (options.fromState) { + try { + catalog = await this.agentFamilyCatalogEntries(); + } catch (error) { + if (options.origin === "agent") throw error; + this.log( + `Agent family catalog unavailable for CLI message relationship: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } let targetState: ActiveSessionState; try { targetState = this.getBoundSessionState(targetSelector); } catch (error) { if (error instanceof BoundSessionUnavailableError) { + const unavailableTarget = this.getSessionState(targetSelector); + if (this.closingSessions.has(unavailableTarget.activeSessionId)) throw error; if (options.origin === "agent" && options.fromState) { - this.assertAgentFamilyReachable(options.fromState, this.getSessionState(targetSelector)); + this.assertAgentFamilyReachable(options.fromState, unavailableTarget, catalog!); } targetState = await this.getOrHydrateBoundSessionState(targetSelector); } else { if (error instanceof AmbiguousActiveSessionError) { if (options.origin !== "agent" || !options.fromState) throw error; - const resolved = this.resolveAgentFamilySessionName(options.fromState, targetSelector, error); + const resolved = this.resolveAgentFamilySessionName(options.fromState, targetSelector, error, catalog!); targetState = await this.getOrHydrateBoundSessionState(resolved.activeSessionId); } else { const passiveSubagent = await this.findPassiveRlmSubagent(targetSelector); if (passiveSubagent) { if (options.origin === "agent" && options.fromState) { + const passiveEntry = this.authoritativeAgentFamilyEntryForSessionId( + passiveSubagent.info.id, + catalog!, + ); assertAgentFamilyReach( - this.agentFamilyEntry(options.fromState), - this.passiveAgentFamilyEntry(passiveSubagent), + this.authoritativeAgentFamilyEntry(options.fromState, catalog!), + passiveEntry, + catalog!, ); } targetState = await this.hydratePassiveRlmSubagent(passiveSubagent); @@ -5401,11 +5725,14 @@ export class AgentDaemon { } } } + if (this.closingSessions.has(targetState.activeSessionId)) { + throw new Error(`Active session ${targetState.activeSessionId} is closing`); + } if (options.fromState?.activeSessionId === targetState.activeSessionId) { throw new Error("Agent messaging cannot target the sending session"); } if (options.origin === "agent" && options.fromState) { - this.assertAgentFamilyReachable(options.fromState, targetState); + this.assertAgentFamilyReachable(options.fromState, targetState, catalog!); } const releaseQueueSlot = this.reserveAgentMessageQueueSlot(targetState); const senderKey = @@ -5423,7 +5750,12 @@ export class AgentDaemon { from: options.sender ?? this.createAgentSessionMessageSender(options.fromState, options.clientId ?? options.origin), - fromRelationship: this.agentMessageRelationship(options.fromState, targetState), + fromRelationship: + options.origin === "cli" && options.fromState + ? catalog + ? this.cliAgentMessageRelationship(options.fromState, targetState, catalog) + : undefined + : this.agentMessageRelationship(options.fromState, targetState, catalog), target: this.createAgentSessionMessageEndpoint(targetState), }; try { diff --git a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts index b26eb1bdc..bfe126f4e 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-protocol.ts @@ -60,8 +60,9 @@ export const DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION = 7; // Revision 14 carries the client's monotonic telemetry opt-out on attach and reattach. // Revision 15 adds the mutate_queued_message command and queue_message_mutation capability. // Revision 16 adds the "stopping" workerState and stops reporting disconnected workers as "ready". -export const DAEMON_SCHEMA_REVISION = 16; -export const DAEMON_SCHEMA_ID = "protocol-7-schema-16-1bcb9e7f1a49"; +// Revision 17 carries the catalog authority root for inactive saved-session renames. +export const DAEMON_SCHEMA_REVISION = 17; +export const DAEMON_SCHEMA_ID = "protocol-7-schema-17-ea658e1e8208"; export type DaemonProtocolName = typeof DAEMON_PROTOCOL_NAME; export type DaemonProtocolVersion = number; @@ -597,7 +598,14 @@ export type DaemonCommand = | { id?: string; type: "set_session_name"; activeSessionId: string; name: string; workerToken?: string } | { id?: string; type: "get_rlm_max_depth_status"; activeSessionId: string } | { id?: string; type: "set_rlm_max_depth"; activeSessionId: string; maxDepth: number; global?: boolean } - | { id?: string; type: "rename_saved_session"; activeSessionId?: string; sessionPath: string; name: string } + | { + id?: string; + type: "rename_saved_session"; + activeSessionId?: string; + sessionPath: string; + name: string; + sessionDir?: string; + } | { id?: string; type: "delete_saved_session"; activeSessionId?: string; sessionPath: string } | { id?: string; type: "get_session_context"; activeSessionId: string } | { id?: string; type: "get_session_tree"; activeSessionId: string } @@ -735,7 +743,7 @@ export const DAEMON_COMMAND_COMPATIBILITY = { set_session_name: LEGACY_DAEMON_COMMAND, get_rlm_max_depth_status: RLM_MAX_DEPTH_COMMAND, set_rlm_max_depth: RLM_MAX_DEPTH_COMMAND, - rename_saved_session: LEGACY_DAEMON_COMMAND, + rename_saved_session: { minProtocol: 7, minSchemaRevision: 17 }, delete_saved_session: LEGACY_DAEMON_COMMAND, get_session_context: LEGACY_DAEMON_COMMAND, get_session_tree: FLAT_SESSION_TREE_COMMAND, diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 92997edf4..c972d1a86 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -253,6 +253,12 @@ const DAEMON_COMMAND_TYPES: ReadonlySet = new Set([ "shutdown", ]); +type FamilyCatalogSource = "persisted" | "artifact" | "live"; + +type FamilyCatalogCandidate = AgentFamilyCatalogEntry & { + source: FamilyCatalogSource; +}; + interface ResidentWorker { descriptor: DaemonWorkerDescriptor; descriptorPath: string; @@ -1759,14 +1765,24 @@ export class DaemonSupervisor { return this.forwardToWorker(match.worker, command); } case "rename_saved_session": { - const target = await this.savedSessionNameReservationInput(command.sessionPath, command.name.trim()); + const match = command.activeSessionId + ? await this.findWorkerForClient(client, command.activeSessionId) + : undefined; + const sessionDir = + match?.worker.descriptor.createCommand.config?.sessionDir ?? + command.sessionDir ?? + this.defaultSessionConfig.sessionDir; + const target = await this.savedSessionNameReservationInput( + command.sessionPath, + command.name.trim(), + sessionDir, + ); return await this.withSessionNameReservation(target, async () => { - await this.assertSupervisorSavedSessionNameAvailable(command.sessionPath, target.name); - if (!command.activeSessionId) { + await this.assertSupervisorSavedSessionNameAvailable(command.sessionPath, target.name, sessionDir); + if (!match) { await this.catalog.rename(command.sessionPath, command.name); return success(command.id, command.type); } - const match = await this.findWorkerForClient(client, command.activeSessionId); return await this.forwardToWorker(match.worker, { ...command, activeSessionId: match.summary.activeSessionId ?? match.summary.id, @@ -1790,19 +1806,25 @@ export class DaemonSupervisor { const source = command.fromActiveSessionId ? await this.findWorkerForClient(client, command.fromActiveSessionId) : undefined; + // Hold this persisted topology through pre-wake and post-wake checks. + const sourceSessionDir = + source?.worker.descriptor.createCommand.config?.sessionDir ?? this.defaultSessionConfig.sessionDir; + const familyCatalog = + source && command.agentOrigin === true ? await this.familyCatalogEntries(sourceSessionDir) : undefined; + // Session IDs are the stable identities for this authorization snapshot. Do not + // rebuild either endpoint from worker summaries after the snapshot is captured. + const sourceSessionId = source?.summary.sessionId; + let targetSessionId: string; let target: WorkerMatch; try { target = await this.findWorkerForClient(client, command.targetActiveSessionId); + targetSessionId = target.summary.sessionId; } catch (error) { if (!(error instanceof Error) || !error.message.startsWith("Unknown active session:")) throw error; const cwd = source?.summary.cwd ?? this.defaultSessionConfig.cwd ?? process.cwd(); let sessionPath: string; try { - sessionPath = await this.catalog.resolve( - command.targetActiveSessionId, - cwd, - source?.worker.descriptor.createCommand.config?.sessionDir ?? this.defaultSessionConfig.sessionDir, - ); + sessionPath = await this.catalog.resolve(command.targetActiveSessionId, cwd, sourceSessionDir); } catch (catalogError) { // Preserve selector ambiguity so a2a senders can distinguish it from // the original unknown-active-session lookup failure. @@ -1811,18 +1833,21 @@ export class DaemonSupervisor { } throw error; } + const targetInfo = await readSessionInfo(sessionPath); + if (!targetInfo) throw new Error(`Unknown active session: ${command.targetActiveSessionId}`); + targetSessionId = targetInfo.id; if (source && command.agentOrigin === true) { - const targetInfo = await readSessionInfo(sessionPath); - if (!targetInfo) throw new Error(`Unknown active session: ${command.targetActiveSessionId}`); assertAgentFamilyReach( - this.familyCatalogEntry(source.summary), - this.familyCatalogEntry(summaryForInactiveSession(targetInfo)), + this.authoritativeFamilyCatalogEntry(familyCatalog!, sourceSessionId!), + this.authoritativeFamilyCatalogEntry(familyCatalog!, targetSessionId), + familyCatalog!, ); } const worker = await this.createOrReuseWorker(this.protocolClientId(client), { type: "create", sessionPath, continueRecent: false, + config: { sessionDir: sourceSessionDir }, }); const summary = this.findSummaryInWorker(worker, sessionPath) ?? @@ -1832,7 +1857,16 @@ export class DaemonSupervisor { } const targetActiveSessionId = target.summary.activeSessionId ?? target.summary.id; if (source && command.agentOrigin === true) { - assertAgentFamilyReach(this.familyCatalogEntry(source.summary), this.familyCatalogEntry(target.summary)); + // Waking must not substitute a different live session for the target that + // was authorized by the captured topology. + if (target.summary.sessionId !== targetSessionId) { + throw new Error("Agent reach is limited to parent, siblings, and children"); + } + assertAgentFamilyReach( + this.authoritativeFamilyCatalogEntry(familyCatalog!, sourceSessionId!), + this.authoritativeFamilyCatalogEntry(familyCatalog!, targetSessionId), + familyCatalog!, + ); } if (source) { if ((source.summary.activeSessionId ?? source.summary.id) === targetActiveSessionId) { @@ -1899,18 +1933,27 @@ export class DaemonSupervisor { if (command.type === "rename" || command.type === "set_session_name") { const reservation = this.summaryNameReservationInput(match.summary, command.name.trim()); return await this.withSessionNameReservation(reservation, async () => { - await this.assertSupervisorSessionNameAvailable(match.summary, reservation.name); + await this.assertSupervisorSessionNameAvailable( + match.summary, + reservation.name, + match.worker.descriptor.createCommand.config?.sessionDir ?? this.defaultSessionConfig.sessionDir, + ); return forward(); }); } return await forward(); } this.persistWorkerStopTombstone(match.worker, true); + const releaseStopOwnership = this.acquireWorkerStopOwnership(match.worker); let response: DaemonResponse; try { response = await this.forwardToWorker(match.worker, resolvedCommand); } finally { - await this.stopWorker(match.worker, true, false, true); + try { + await this.stopWorker(match.worker, true, false, true); + } finally { + releaseStopOwnership(); + } } return response; } finally { @@ -1963,7 +2006,7 @@ export class DaemonSupervisor { if ("activeSessionId" in command) { const match = await this.findWorkerForClient(client, command.activeSessionId); cwd = match.summary.cwd; - sessionDir = this.defaultSessionConfig.sessionDir; + sessionDir = match.worker.descriptor.createCommand.config?.sessionDir ?? this.defaultSessionConfig.sessionDir; activeSessionId = match.summary.activeSessionId ?? match.summary.id; } else { cwd = resolve(command.cwd); @@ -2004,6 +2047,7 @@ export class DaemonSupervisor { createCommand = { ...command, name: normalizedName }; } const ownerClientId = command.lifecycle === "client_owned" ? clientId : undefined; + const config = mergeAgentSessionRuntimeConfig(this.defaultSessionConfig, command.config); if (command.sessionPath) { const activeMatches = this.matchWorkers(command.sessionPath); if (activeMatches.length === 1 && !(await this.reclaimStaleWorkerRegistration(activeMatches[0]!.worker))) { @@ -2012,7 +2056,6 @@ export class DaemonSupervisor { if (activeMatches.length > 1) { throw new Error(`Ambiguous active session "${command.sessionPath}"`); } - const config = mergeAgentSessionRuntimeConfig(this.defaultSessionConfig, command.config); const sessionPath = looksLikeSessionPath(command.sessionPath) ? resolve(command.sessionPath) : await this.catalog.resolve(command.sessionPath, config.cwd ?? process.cwd(), config.sessionDir); @@ -2033,7 +2076,9 @@ export class DaemonSupervisor { } const opening = (async () => { if (!createCommand.name) return this.launchWorker(createCommand, undefined, ownerClientId); - const savedSiblings = createCommand.sessionPath ? await this.catalog.siblings(createCommand.sessionPath) : []; + const savedSiblings = createCommand.sessionPath + ? await this.catalog.siblings(createCommand.sessionPath, config.sessionDir) + : []; const target = savedSiblings.find( (session) => canonicalSessionPath(session.path) === canonicalSessionPath(createCommand.sessionPath!), ); @@ -2043,7 +2088,7 @@ export class DaemonSupervisor { if (target?.parentSessionPath && (target.rlmDepth ?? 0) > 0) { this.assertSavedSiblingNameAvailable(savedSiblings, target, createCommand.name!); } else { - await this.assertSupervisorSessionNameAvailable(targetSummary, createCommand.name!); + await this.assertSupervisorSessionNameAvailable(targetSummary, createCommand.name!, config.sessionDir); } return this.launchWorker(createCommand, undefined, ownerClientId); }); @@ -3003,19 +3048,93 @@ export class DaemonSupervisor { } } - private async familyCatalogEntries(): Promise { - const active = [...this.workers.values()].flatMap((worker) => [...worker.summaries.values()]); - const activePaths = new Set( - active.flatMap((summary) => (summary.sessionFile ? [canonicalSessionPath(summary.sessionFile)] : [])), - ); - const savedRoots = (await this.catalog.list()).filter( - (info) => - (info.rlmDepth ?? (info.parentSessionPath ? -1 : 0)) === 0 && - !activePaths.has(canonicalSessionPath(info.path)), - ); - return [...active, ...savedRoots.map((info) => summaryForInactiveSession(info))].map((summary) => - this.familyCatalogEntry(summary), + private async familyCatalogEntries(sessionDir?: string): Promise { + const live = [...this.workers.values()] + .flatMap((worker) => [...worker.summaries.values()]) + .map((summary): FamilyCatalogCandidate => ({ ...this.familyCatalogEntry(summary), source: "live" })); + // Keep the persisted row even when its path is currently active. A live + // overlay may fill in missing claims, but it may not silently replace a + // conflicting durable topology claim. + const saved = await this.catalog.list(undefined, sessionDir); + const savedPaths = new Set(saved.map((info) => canonicalSessionPath(info.path))); + const persisted = saved.map( + (info): FamilyCatalogCandidate => ({ + ...this.familyCatalogEntry(summaryForInactiveSession(info)), + source: "persisted", + }), ); + // The catalog's bounded registry walk supplies artifact-resident parents + // and descendants missing from list(). Preserve their durable rows in the + // immutable authorization snapshot; live overlays still cannot replace a + // conflicting topology claim. + const artifactSessions = this.catalog.family ? await this.catalog.family(sessionDir) : saved; + const artifacts = artifactSessions + .filter((info) => !savedPaths.has(canonicalSessionPath(info.path))) + .map( + (info): FamilyCatalogCandidate => ({ + ...this.familyCatalogEntry(summaryForInactiveSession(info)), + source: "artifact", + }), + ); + return Object.freeze(this.mergeEquivalentFamilyCatalogEntries([...persisted, ...artifacts, ...live])); + } + + /** + * Collapse durable/live duplicates only when their stable identity and every + * jointly-present topology claim agree. Incompatible candidates intentionally + * remain duplicated: authoritative endpoint lookup then fails closed. + */ + private mergeEquivalentFamilyCatalogEntries(entries: readonly FamilyCatalogCandidate[]): AgentFamilyCatalogEntry[] { + const compatible = (left: FamilyCatalogCandidate, right: FamilyCatalogCandidate) => + left.id === right.id && + (left.sessionPath === undefined || + right.sessionPath === undefined || + left.sessionPath === right.sessionPath) && + left.depth === right.depth && + (left.parentSessionId === undefined || + right.parentSessionId === undefined || + left.parentSessionId === right.parentSessionId) && + (left.parentSessionPath === undefined || + right.parentSessionPath === undefined || + left.parentSessionPath === right.parentSessionPath); + const groups: FamilyCatalogCandidate[][] = []; + for (const entry of entries) { + const group = groups.find((candidate) => candidate.every((member) => compatible(member, entry))); + if (group) group.push(entry); + else groups.push([entry]); + } + const statusRank: Record = { inactive: 0, idle: 1, running: 2 }; + const preferred = ( + rows: readonly FamilyCatalogCandidate[], + get: (row: FamilyCatalogCandidate) => T | undefined, + ): T | undefined => + [...rows] + .sort( + (left, right) => + (({ persisted: 0, artifact: 1, live: 2 })[right.source] ?? 0) - + ({ persisted: 0, artifact: 1, live: 2 }[left.source] ?? 0), + ) + .map(get) + .find((value): value is T => value !== undefined); + return groups.map((rows) => { + const exemplar = rows[0]!; + const name = preferred(rows, (row) => row.name); + const parentSessionId = preferred(rows, (row) => row.parentSessionId); + const parentSessionPath = preferred(rows, (row) => row.parentSessionPath); + const sessionPath = preferred(rows, (row) => row.sessionPath); + return { + id: exemplar.id, + depth: exemplar.depth, + status: rows.reduce( + (best, row) => (statusRank[row.status] > statusRank[best] ? row.status : best), + "inactive", + ), + ...(name ? { name } : {}), + ...(parentSessionId ? { parentSessionId } : {}), + ...(parentSessionPath ? { parentSessionPath } : {}), + ...(sessionPath ? { sessionPath } : {}), + }; + }); } private async withSessionNameReservation( @@ -3037,8 +3156,9 @@ export class DaemonSupervisor { private async assertSupervisorSessionNameAvailable( target: Pick, name: string, + sessionDir?: string, ): Promise { - assertAgentSessionNameAvailable(await this.familyCatalogEntries(), { + assertAgentSessionNameAvailable(await this.familyCatalogEntries(sessionDir), { name, depth: target.rlmDepth ?? 0, parentSessionId: target.parentSessionId, @@ -3050,13 +3170,14 @@ export class DaemonSupervisor { private async savedSessionNameReservationInput( sessionPath: string, name: string, + sessionDir?: string, ): Promise<{ name: string; depth: number; parentSessionId?: string; parentSessionPath?: string }> { const targetPath = canonicalSessionPath(sessionPath); const active = [...this.workers.values()] .flatMap((worker) => [...worker.summaries.values()]) .find((summary) => summary.sessionFile && canonicalSessionPath(summary.sessionFile) === targetPath); if (active) return this.summaryNameReservationInput(active, name); - const siblings = await this.catalog.siblings(sessionPath); + const siblings = await this.catalog.siblings(sessionPath, sessionDir ?? this.defaultSessionConfig.sessionDir); const saved = siblings.find((info) => canonicalSessionPath(info.path) === targetPath); if (!saved) throw new Error(`Session not found: ${sessionPath}`); return { @@ -3079,19 +3200,23 @@ export class DaemonSupervisor { }; } - private async assertSupervisorSavedSessionNameAvailable(sessionPath: string, name: string): Promise { + private async assertSupervisorSavedSessionNameAvailable( + sessionPath: string, + name: string, + sessionDir?: string, + ): Promise { const targetPath = canonicalSessionPath(sessionPath); const active = [...this.workers.values()] .flatMap((worker) => [...worker.summaries.values()]) .find((summary) => summary.sessionFile && canonicalSessionPath(summary.sessionFile) === targetPath); - if (active) return this.assertSupervisorSessionNameAvailable(active, name); - const siblings = await this.catalog.siblings(sessionPath); + if (active) return this.assertSupervisorSessionNameAvailable(active, name, sessionDir); + const siblings = await this.catalog.siblings(sessionPath, sessionDir ?? this.defaultSessionConfig.sessionDir); const saved = siblings.find((info) => canonicalSessionPath(info.path) === targetPath); if (!saved) throw new Error(`Session not found: ${sessionPath}`); if (saved.parentSessionPath && (saved.rlmDepth ?? 0) > 0) { this.assertSavedSiblingNameAvailable(siblings, saved, name); } else { - await this.assertSupervisorSessionNameAvailable(summaryForInactiveSession(saved), name); + await this.assertSupervisorSessionNameAvailable(summaryForInactiveSession(saved), name, sessionDir); } } @@ -3187,6 +3312,16 @@ export class DaemonSupervisor { return worker.client; } + /** Resolve both authorization endpoints exclusively from one captured topology snapshot. */ + private authoritativeFamilyCatalogEntry( + catalog: readonly AgentFamilyCatalogEntry[], + sessionId: string, + ): AgentFamilyCatalogEntry { + const matches = catalog.filter((entry) => entry.id === sessionId); + if (matches.length !== 1) throw new Error("Agent reach is limited to parent, siblings, and children"); + return matches[0]!; + } + private familyCatalogEntry(summary: SessionSummary): AgentFamilyCatalogEntry { const depth = summary.rlmDepth ?? (summary.parentSessionPath ? 1 : 0); return { @@ -4242,9 +4377,14 @@ export class DaemonSupervisor { !this.shuttingDown ) { worker.intentionalStop = true; - this.workers.delete(worker.descriptor.workerId); - this.deleteWorkerDescriptor(worker); - void this.syncAgentPeers().catch(() => undefined); + // An exact stop owns its registration and descriptor cleanup until its + // tuple assertions complete. A synchronous root shutdown event can arrive + // before its request resolves, so leave both intact while it is active. + if ((this.workerStopCounts?.get(worker) ?? 0) === 0) { + this.workers.delete(worker.descriptor.workerId); + this.deleteWorkerDescriptor(worker); + void this.syncAgentPeers().catch(() => undefined); + } } } @@ -4624,6 +4764,25 @@ export class DaemonSupervisor { return observed === processStartId ? "current" : "replaced"; } + /** + * Keep an exact stop's registration and descriptor authoritative while any + * part of its cleanup is in flight. Root kills acquire this before forwarding + * because a synchronous shutdown event may arrive before the worker replies. + */ + private acquireWorkerStopOwnership(worker: ResidentWorker): () => void { + if (!this.workerStopCounts) this.workerStopCounts = new Map(); + const stopCounts = this.workerStopCounts; + stopCounts.set(worker, (stopCounts.get(worker) ?? 0) + 1); + let released = false; + return () => { + if (released) return; + released = true; + const remaining = (stopCounts.get(worker) ?? 1) - 1; + if (remaining === 0) stopCounts.delete(worker); + else stopCounts.set(worker, remaining); + }; + } + private async stopWorker( worker: ResidentWorker, removeDescriptor: boolean, @@ -4632,15 +4791,11 @@ export class DaemonSupervisor { recoveryCleanup = false, directChild?: { child: ChildProcess; closed: Promise }, ): Promise { - if (!this.workerStopCounts) this.workerStopCounts = new Map(); - const stopCounts = this.workerStopCounts; - stopCounts.set(worker, (stopCounts.get(worker) ?? 0) + 1); + const releaseStopOwnership = this.acquireWorkerStopOwnership(worker); try { await this.stopWorkerUntracked(worker, removeDescriptor, force, archiveSession, recoveryCleanup, directChild); } finally { - const remaining = (stopCounts.get(worker) ?? 1) - 1; - if (remaining === 0) stopCounts.delete(worker); - else stopCounts.set(worker, remaining); + releaseStopOwnership(); } } diff --git a/packages/coding-agent/src/modes/daemon/saved-session-catalog.ts b/packages/coding-agent/src/modes/daemon/saved-session-catalog.ts index 6b22b1143..e3e42a12f 100644 --- a/packages/coding-agent/src/modes/daemon/saved-session-catalog.ts +++ b/packages/coding-agent/src/modes/daemon/saved-session-catalog.ts @@ -53,7 +53,7 @@ export async function renameDaemonSavedSession( const command: Extract = "activeSessionId" in context ? { type: "rename_saved_session", activeSessionId: context.activeSessionId, sessionPath, name } - : { type: "rename_saved_session", sessionPath, name }; + : { type: "rename_saved_session", sessionPath, name, sessionDir: context.sessionDir }; const response = await client.request(command); if (!response.success) { throw deserializeDaemonError(response); diff --git a/packages/coding-agent/test/acp-kernel-features.test.ts b/packages/coding-agent/test/acp-kernel-features.test.ts index de7660888..2d9a3d39d 100644 --- a/packages/coding-agent/test/acp-kernel-features.test.ts +++ b/packages/coding-agent/test/acp-kernel-features.test.ts @@ -10,6 +10,8 @@ import { acpUpdatesForSessionEvent } from "../src/modes/acp/acp-events.js"; import { PRIME_AGENT_META_NAMESPACE } from "../src/modes/acp/acp-meta.js"; import type { AgentConnectionSessionEvent } from "../src/modes/agent-connection/types.js"; +import { createTestHostHandlers } from "./host-request-context.js"; + /** * Real-kernel verification for ACP mode. * @@ -157,39 +159,39 @@ print(json.dumps({ }); }); - it("exposes rlm depth and subagent APIs to the kernel behind the ACP front end", { - tags: ["kernel-heavy"], - timeout: 180_000, - }, async () => { - provisioner = new IpythonKernelProvisioner(tempDir, { - pythonSkills: [AGENT_MESSAGE_SKILL], - env: { RLM_DEPTH: "0", RLM_MAX_DEPTH: "1" }, - hostHandlers: { - "rlm.list_subagents": async () => ({ - subagents: [ - { - rlm_child_id: "child-1", - active_session_id: "active-1", + it( + "exposes rlm depth and subagent APIs to the kernel behind the ACP front end", + { tags: ["kernel-heavy"], timeout: 180_000 }, + async () => { + provisioner = new IpythonKernelProvisioner(tempDir, { + pythonSkills: [AGENT_MESSAGE_SKILL], + env: { RLM_DEPTH: "0", RLM_MAX_DEPTH: "1" }, + hostHandlers: createTestHostHandlers({ + "rlm.list_subagents": async () => ({ + subagents: [ + { + rlm_child_id: "child-1", + active_session_id: "active-1", + session_id: "session-1", + session_name: "reviewer", + session_dir: tempDir, + status: "completed", + }, + ], + }), + "rlm.delete_subagent": async (payload) => ({ + subagent: { + rlm_child_id: String(payload.target), + active_session_id: null, session_id: "session-1", session_name: "reviewer", session_dir: tempDir, status: "completed", }, - ], - }), - "rlm.delete_subagent": async (payload) => ({ - subagent: { - rlm_child_id: String(payload.target), - active_session_id: null, - session_id: "session-1", - session_name: "reviewer", - session_dir: tempDir, - status: "completed", - }, + }), }), - }, - }); - const manager = await provisioner.ensure(); + }); + const manager = await provisioner.ensure(); const result = await manager.execute(` import json, os @@ -210,30 +212,30 @@ print(json.dumps({ expect(payload.max_depth).toBe("1"); }); - it("sends an agent-to-agent message from the kernel and surfaces it over ACP", { - tags: ["kernel-heavy"], - timeout: 180_000, - }, async () => { - provisioner = new IpythonKernelProvisioner(tempDir, { - pythonSkills: [AGENT_MESSAGE_SKILL], - hostHandlers: { - // The family roster: parent, siblings, and children of this agent. - "agent_message.list_agents": async () => ({ - current: { name: "root", id: "session-alpha", depth: 0 }, - entries: [{ relationship: "child", name: "reviewer", id: "session-beta", depth: 1, status: "idle" }], + it( + "sends an agent-to-agent message from the kernel and surfaces it over ACP", + { tags: ["kernel-heavy"], timeout: 180_000 }, + async () => { + provisioner = new IpythonKernelProvisioner(tempDir, { + pythonSkills: [AGENT_MESSAGE_SKILL], + hostHandlers: createTestHostHandlers({ + // The family roster: parent, siblings, and children of this agent. + "agent_message.list_agents": async () => ({ + current: { name: "root", id: "session-alpha", depth: 0 }, + entries: [{ relationship: "child", name: "reviewer", id: "session-beta", depth: 1, status: "idle" }], + }), + "agent_message.send": async (payload) => ({ + id: "agentmsg-acp", + source: "agent_message", + target: { activeSessionId: "beta", sessionId: "session-beta", sessionName: "reviewer" }, + message: payload.message, + deliveryStatus: "queued", + queuedAt: "2026-08-04T00:00:00.000Z", + deliveryMode: payload.mode ?? "auto", + }), }), - "agent_message.send": async (payload) => ({ - id: "agentmsg-acp", - source: "agent_message", - target: { activeSessionId: "beta", sessionId: "session-beta", sessionName: "reviewer" }, - message: payload.message, - deliveryStatus: "queued", - queuedAt: "2026-08-04T00:00:00.000Z", - deliveryMode: payload.mode ?? "auto", - }), - }, - }); - const manager = await provisioner.ensure(); + }); + const manager = await provisioner.ensure(); // The kernel venv is shared across test files. If a concurrently running // file rebuilt it without this skill, say so plainly rather than failing diff --git a/packages/coding-agent/test/agent-session-bus.test.ts b/packages/coding-agent/test/agent-session-bus.test.ts index 381dcbf53..75b530131 100644 --- a/packages/coding-agent/test/agent-session-bus.test.ts +++ b/packages/coding-agent/test/agent-session-bus.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { + AGENT_FAMILY_REACH_ERROR, AGENT_MESSAGE_SOURCE, AgentSessionMessageRateLimiter, assertAgentFamilyReach, @@ -14,6 +15,7 @@ import { parseAgentSessionMessagePromptId, sessionNameReservationKey, } from "../src/core/agent-messages.js"; +import { invokeHostRequestThroughKernelForTest as invokeHostRequestHandlerForTest } from "./host-request-context.js"; describe("agent session bus", () => { it("formats routed messages with sender and target context", () => { @@ -181,7 +183,7 @@ describe("agent session bus", () => { sendAgentMessage, }); - await handlers["agent_message.send"]!({ + await invokeHostRequestHandlerForTest(handlers["agent_message.send"]!, { message: "hello", receiver_role: "sibling", receiver_name: "reviewer", @@ -193,7 +195,9 @@ describe("agent session bus", () => { }); sendAgentMessage.mockClear(); - await expect(handlers["agent_message.send"]!({ target: "all", message: "status" })).resolves.toMatchObject({ + await expect( + invokeHostRequestHandlerForTest(handlers["agent_message.send"]!, { target: "all", message: "status" }), + ).resolves.toMatchObject({ receipts: [ { id: "root", deliveryStatus: "delivered" }, { id: "sibling", deliveryStatus: "delivered" }, @@ -204,7 +208,7 @@ describe("agent session bus", () => { sendAgentMessage.mockClear(); await expect( - handlers["agent_message.send"]!({ + invokeHostRequestHandlerForTest(handlers["agent_message.send"]!, { target: "all", message: "private", receiver_role: "sibling", @@ -224,9 +228,9 @@ describe("agent session bus", () => { sendAgentMessage, }); - await expect(handlers["agent_message.send"]!({ target: "reviewer", message: "status" })).rejects.toThrow( - "use receiver_role and receiver_name", - ); + await expect( + invokeHostRequestHandlerForTest(handlers["agent_message.send"]!, { target: "reviewer", message: "status" }), + ).rejects.toThrow("use receiver_role and receiver_name"); expect(sendAgentMessage).not.toHaveBeenCalled(); }); @@ -253,7 +257,9 @@ describe("agent session bus", () => { sendAgentMessage, }); - await expect(handlers["agent_message.send"]!({ target: "all", message: "status" })).resolves.toMatchObject({ + await expect( + invokeHostRequestHandlerForTest(handlers["agent_message.send"]!, { target: "all", message: "status" }), + ).resolves.toMatchObject({ receipts: [ { id: "root", deliveryStatus: "delivered" }, { target: "sibling", error: "rate limited" }, @@ -303,10 +309,11 @@ describe("agent session bus", () => { { id: "orphan-b", depth: 3, status: "inactive" }, ), ).toThrow("Agent reach is limited to parent, siblings, and children"); - expect(assertAgentFamilyReach(root, child)).toBe("child"); - expect(assertAgentFamilyReach(child, root)).toBe("parent"); - expect(assertAgentFamilyReach(child, sibling)).toBe("sibling"); - expect(assertAgentFamilyReach(sibling, idOnlySibling)).toBe("sibling"); + const catalog = [root, child, sibling, idOnlySibling, grandchild]; + expect(assertAgentFamilyReach(root, child, catalog)).toBe("child"); + expect(assertAgentFamilyReach(child, root, catalog)).toBe("parent"); + expect(assertAgentFamilyReach(child, sibling, catalog)).toBe("sibling"); + expect(assertAgentFamilyReach(sibling, idOnlySibling, catalog)).toBe("sibling"); expect(() => assertAgentFamilyReach(root, grandchild)).toThrow( "Agent reach is limited to parent, siblings, and children", ); @@ -395,6 +402,67 @@ describe("agent session bus", () => { ]); }); + it("reserves passive sibling names from direct canonical parent claims without broadening family reach", () => { + const catalog = [ + { id: "passive-id", name: "worker", depth: 1, status: "inactive" as const, parentSessionId: "parent" }, + { + id: "passive-path", + name: "path-worker", + depth: 1, + status: "inactive" as const, + parentSessionPath: "/tmp/prime-agent-parent/../parent.jsonl", + }, + ]; + + expect(() => + assertAgentSessionNameAvailable(catalog, { name: "worker", depth: 1, parentSessionId: "parent" }), + ).toThrow("an agent of that name already exists at depth 1 under this parent"); + expect(() => + assertAgentSessionNameAvailable(catalog, { + name: "path-worker", + depth: 1, + parentSessionPath: "/tmp/parent.jsonl", + }), + ).toThrow("an agent of that name already exists at depth 1 under this parent"); + // Direct claims reserve names only. They cannot synthesize a relationship + // while the parent record is unavailable from the catalog. + expect(() => assertAgentFamilyReach(catalog[0]!, catalog[1]!, catalog)).toThrow(AGENT_FAMILY_REACH_ERROR); + }); + it("resolves id-only and path-only catalog claims but rejects contradictory claims", () => { + const parent = { id: "parent", depth: 0, status: "running" as const, sessionPath: "/parent" }; + const idOnly = { + id: "id-only", + name: "id-worker", + depth: 1, + status: "inactive" as const, + parentSessionId: "parent", + }; + const pathOnly = { + id: "path-only", + name: "path-worker", + depth: 1, + status: "inactive" as const, + parentSessionPath: "/parent", + }; + const contradictory = { + id: "contradictory", + depth: 1, + status: "inactive" as const, + parentSessionId: "parent", + parentSessionPath: "/other", + }; + const catalog = [parent, idOnly, pathOnly, contradictory]; + expect(assertAgentFamilyReach(parent, idOnly, catalog)).toBe("child"); + expect(assertAgentFamilyReach(parent, pathOnly, catalog)).toBe("child"); + expect(() => assertAgentFamilyReach(parent, contradictory, catalog)).toThrow(AGENT_FAMILY_REACH_ERROR); + expect(() => + assertAgentSessionNameAvailable(catalog, { name: "id-worker", depth: 1, parentSessionId: "parent" }), + ).toThrow("an agent of that name already exists at depth 1 under this parent"); + expect(() => + assertAgentSessionNameAvailable(catalog, { name: "path-worker", depth: 1, parentSessionPath: "/parent" }), + ).toThrow("an agent of that name already exists at depth 1 under this parent"); + }); + it("builds a sorted nuclear-family roster with inactive members", () => { const catalog = [ { id: "root", name: "orchestrator", depth: 0, status: "running" as const, sessionPath: "/root" }, diff --git a/packages/coding-agent/test/agent-session-recursion.test.ts b/packages/coding-agent/test/agent-session-recursion.test.ts index f066f02b1..a768c333c 100644 --- a/packages/coding-agent/test/agent-session-recursion.test.ts +++ b/packages/coding-agent/test/agent-session-recursion.test.ts @@ -37,6 +37,11 @@ import type { Skill } from "../src/core/skills.js"; import { createSyntheticSourceInfo } from "../src/core/source-info.js"; import { type ActiveSessionState, resolveActiveSessionState } from "../src/modes/daemon/active-session-state.js"; import { AgentDaemon } from "../src/modes/daemon/daemon-mode.js"; +import { + createTestHostHandlers, + invokeHostRequestThroughKernelForTest as invokeHostRequestHandlerForTest, +} from "./host-request-context.js"; + import { createTestExtensionsResult, createTestResourceLoader } from "./utilities.js"; const model = getModel("anthropic", "claude-sonnet-4-5")!; @@ -328,7 +333,7 @@ describe("AgentSession rlm recursion", () => { outcome: "skipped_running", })); - await expect(deleteHandler({ target: subagent.rlm_child_id })).resolves.toEqual({ + await expect(invokeHostRequestHandlerForTest(deleteHandler, { target: subagent.rlm_child_id })).resolves.toEqual({ subagent, outcome: "skipped_running", }); @@ -743,7 +748,9 @@ describe("AgentSession rlm recursion", () => { if (!send) throw new Error("Missing agent_message.send host handler"); expect(child.repliedToParentSinceTask).toBe(false); - await expect(send({ message: "done", receiver_role: "parent" })).resolves.toMatchObject({ + await expect( + invokeHostRequestHandlerForTest(send, { message: "done", receiver_role: "parent" }), + ).resolves.toMatchObject({ message: "done", }); expect(sendAgentMessage).toHaveBeenCalledWith( @@ -802,7 +809,7 @@ describe("AgentSession rlm recursion", () => { const send = handlers["agent_message.send"]; if (!send) throw new Error("Missing agent_message.send host handler"); - const pendingSend = send({ + const pendingSend = invokeHostRequestHandlerForTest(send, { message: "hello", receiver_role: "child", receiver_name: spawned.rlm_child_id, @@ -865,7 +872,11 @@ describe("AgentSession rlm recursion", () => { if (!send) throw new Error("Missing agent_message.send host handler"); await expect( - send({ message: "follow-up", receiver_role: "child", receiver_name: spawned.rlm_child_id }), + invokeHostRequestHandlerForTest(send, { + message: "follow-up", + receiver_role: "child", + receiver_name: spawned.rlm_child_id, + }), ).resolves.toMatchObject({ message: "follow-up" }); expect(sendAgentMessage).toHaveBeenCalledWith( expect.objectContaining({ target: child.sessionId, message: "follow-up" }), @@ -901,7 +912,11 @@ describe("AgentSession rlm recursion", () => { const send = handlers["agent_message.send"]; if (!send) throw new Error("Missing agent_message.send host handler"); - const pendingSend = send({ message: "hello", receiver_role: "child", receiver_name: spawned.name }); + const pendingSend = invokeHostRequestHandlerForTest(send, { + message: "hello", + receiver_role: "child", + receiver_name: spawned.name, + }); rejectStartup?.(new Error("child startup failed")); await expect(pendingSend).rejects.toThrow("child startup failed"); @@ -957,7 +972,11 @@ describe("AgentSession rlm recursion", () => { if (!send) throw new Error("Missing agent_message.send host handler"); await expect( - send({ message: "hello", receiver_role: "child", receiver_name: "shared-child" }), + invokeHostRequestHandlerForTest(send, { + message: "hello", + receiver_role: "child", + receiver_name: "shared-child", + }), ).resolves.toMatchObject({ message: "hello" }); expect(sendAgentMessage).toHaveBeenCalledWith( expect.objectContaining({ target: "healthy-child-session", message: "hello" }), @@ -999,9 +1018,13 @@ describe("AgentSession rlm recursion", () => { const send = handlers["agent_message.send"]; if (!send) throw new Error("Missing agent_message.send host handler"); - await expect(send({ message: "hello", receiver_role: "child", receiver_name: "deleted-child" })).rejects.toThrow( - 'No child matches "deleted-child"', - ); + await expect( + invokeHostRequestHandlerForTest(send, { + message: "hello", + receiver_role: "child", + receiver_name: "deleted-child", + }), + ).rejects.toThrow('No child matches "deleted-child"'); releaseRuntimeCreation(); await waitFor(() => (root as unknown as InspectableRlmSession)._activeRlmChildRuns.size === 0); }); @@ -1038,7 +1061,7 @@ describe("AgentSession rlm recursion", () => { const send = handlers["agent_message.send"]; if (!send) throw new Error("Missing agent_message.send host handler"); - await expect(send({ target: "all", message: "status" })).resolves.toMatchObject({ + await expect(invokeHostRequestHandlerForTest(send, { target: "all", message: "status" })).resolves.toMatchObject({ receipts: [{ message: "status" }], }); expect(roster).toHaveBeenCalledTimes(1); @@ -1263,7 +1286,7 @@ describe("AgentSession rlm recursion", () => { vi.spyOn(child, "promptAndWait").mockImplementation(async () => { const send = (child as unknown as InspectableRlmSession)._createKernelHostHandlers()["agent_message.send"]; if (!send) throw new Error("Missing agent_message.send host handler"); - await send({ message: "done", receiver_role: "parent" }); + await invokeHostRequestHandlerForTest(send, { message: "done", receiver_role: "parent" }); const followUp = createAgentSessionMessage({ id: "agentmsg-parent-follow-up-after-reply", source: "agent_message", @@ -1509,13 +1532,15 @@ describe("AgentSession rlm recursion", () => { if (!listHandler || !deleteHandler) { throw new Error("Missing RLM subagent registry host handlers"); } - await expect(listHandler({})).resolves.toEqual(expectedRegistry); - await expect(deleteHandler({ target: expectedSessionName })).resolves.toEqual({ + await expect(invokeHostRequestHandlerForTest(listHandler, {})).resolves.toEqual(expectedRegistry); + await expect(invokeHostRequestHandlerForTest(deleteHandler, { target: expectedSessionName })).resolves.toEqual({ subagent: expectedRegistry.subagents[0], }); expect(root.getRlmChildSession(daemonChildId)).toBeUndefined(); expect(await root.listRlmSubagents()).toEqual({ subagents: [] }); - await expect(deleteHandler({ target: expectedSessionName })).rejects.toThrow("No direct RLM subagent matches"); + await expect(invokeHostRequestHandlerForTest(deleteHandler, { target: expectedSessionName })).rejects.toThrow( + "No direct RLM subagent matches", + ); root.dispose(); @@ -2904,7 +2929,7 @@ describe("AgentSession rlm recursion", () => { const replies: CapturedCommReply[] = []; const manager = new KernelManager({ python: process.execPath, - hostHandlers: { + hostHandlers: createTestHostHandlers({ "rlm.run": createRlmRunHostHandler(async ({ prompt }) => { active++; started++; @@ -2919,7 +2944,7 @@ describe("AgentSession rlm recursion", () => { model: "test/model", }; }), - }, + }), }); try { @@ -2964,7 +2989,7 @@ describe("AgentSession rlm recursion", () => { let promptSeen = ""; const manager = new KernelManager({ python: process.execPath, - hostHandlers: { + hostHandlers: createTestHostHandlers({ "rlm.run": createRlmRunHostHandler(async ({ prompt }) => { promptSeen = prompt; return { @@ -2975,7 +3000,7 @@ describe("AgentSession rlm recursion", () => { model: "test/model", }; }), - }, + }), }); try { @@ -3010,7 +3035,7 @@ describe("AgentSession rlm recursion", () => { const prompts: string[] = []; const manager = new KernelManager({ cwd: tempDir, - hostHandlers: { + hostHandlers: createTestHostHandlers({ "rlm.run": createRlmRunHostHandler(async ({ prompt }) => { prompts.push(prompt); return { @@ -3020,7 +3045,7 @@ describe("AgentSession rlm recursion", () => { model: "test/model", }; }), - }, + }), }); try { @@ -3089,7 +3114,7 @@ print(_result.name) const replies: CapturedCommReply[] = []; const manager = new KernelManager({ python: process.execPath, - hostHandlers: { + hostHandlers: createTestHostHandlers({ "rlm.run": createRlmRunHostHandler(async () => ({ answer: "unused", usage: { prompt_tokens: 1, completion_tokens: 1 }, @@ -3097,7 +3122,7 @@ print(_result.name) session_dir: null, model: "test/model", })), - }, + }), }); try { @@ -3136,7 +3161,7 @@ print(_result.name) }); const manager = new KernelManager({ python: process.execPath, - hostHandlers: { + hostHandlers: createTestHostHandlers({ "rlm.run": createRlmRunHostHandler(async () => { started = true; try { @@ -3146,12 +3171,14 @@ print(_result.name) handlerSettled = true; } }), - }, + }), }); const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); try { const kernel = manager as unknown as KernelCommTestApi; + const sendCommMessage = vi.fn(async () => {}); + kernel.sendCommMessage = sendCommMessage; kernel.handleCommMessage(rlmCommOpen("comm-dispose", "slow child")); @@ -3171,7 +3198,8 @@ print(_result.name) const kernelStderr = (manager as unknown as { kernelStderr: string }).kernelStderr; expect(kernelStderr).toContain("[kernel] host request failed for comm comm-dispose"); - expect(kernelStderr).toContain("[kernel] failed to send host request error reply for comm comm-dispose"); + expect(sendCommMessage).not.toHaveBeenCalled(); + expect(kernelStderr).not.toContain("[kernel] failed to send host request error reply for comm comm-dispose"); expect(stderrSpy).not.toHaveBeenCalled(); } finally { releaseChild(); diff --git a/packages/coding-agent/test/daemon-catalog-process.test.ts b/packages/coding-agent/test/daemon-catalog-process.test.ts index 53bc162f1..4aa7ed5bc 100644 --- a/packages/coding-agent/test/daemon-catalog-process.test.ts +++ b/packages/coding-agent/test/daemon-catalog-process.test.ts @@ -1,9 +1,15 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, relative } from "node:path"; import { describe, expect, it } from "vitest"; import { type SessionInfo, SessionManager } from "../src/core/session-manager.js"; -import { listSavedSessionSiblings, resolveCatalogSessionMatch } from "../src/modes/daemon/daemon-catalog-process.js"; +import { + getOpenCatalogAuthorityFdCountForTest, + listCatalogFamilySessions, + listSavedSessionSiblings, + resolveCatalogSessionMatch, + setCatalogBeforeTrustedOpenForTest, +} from "../src/modes/daemon/daemon-catalog-process.js"; function session(id: string, name: string | undefined, path: string): SessionInfo { return { @@ -20,17 +26,64 @@ function session(id: string, name: string | undefined, path: string): SessionInf }; } +function createCatalogFamilyFixture() { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-default-session-dir-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + const first = SessionManager.create(root, join(root, "session-artifacts", parent.getSessionId(), "sub-first")); + first.newSession({ parentSession: parent.getSessionFile(), rlmDepth: 1 }); + first.appendSessionInfo("first"); + const second = SessionManager.create(root, join(root, "session-artifacts", parent.getSessionId(), "sub-second")); + second.newSession({ parentSession: parent.getSessionFile(), rlmDepth: 1 }); + second.appendSessionInfo("second"); + const registry = join(dirname(sessionDir), "session-artifacts", parent.getSessionId(), "rlm-subagents.jsonl"); + mkdirSync(dirname(registry), { recursive: true }); + writeFileSync( + registry, + [ + { + type: "rlm_subagent", + childId: first.getSessionId(), + sessionFile: first.getSessionFile(), + status: "completed", + }, + { + type: "rlm_subagent", + childId: second.getSessionId(), + sessionFile: second.getSessionFile(), + status: "completed", + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n"), + ); + return { root, sessionDir, parent, first, second }; +} + +async function withDefaultSessionDir(sessionDir: string, callback: () => Promise): Promise { + const previousSessionDir = process.env.PRIME_AGENT_SESSION_DIR; + process.env.PRIME_AGENT_SESSION_DIR = sessionDir; + try { + return await callback(); + } finally { + if (previousSessionDir === undefined) delete process.env.PRIME_AGENT_SESSION_DIR; + else process.env.PRIME_AGENT_SESSION_DIR = previousSessionDir; + } +} + describe("daemon catalog selector resolution", () => { it("reads only a saved child's persisted sibling set", async () => { const root = mkdtempSync(join(tmpdir(), "prime-catalog-siblings-")); const sessionDir = join(root, "sessions"); const parent = SessionManager.create(root, sessionDir); - parent.newSession(); + parent.newSession({ rlmDepth: 0 }); parent.appendSessionInfo("parent"); - const first = SessionManager.create(root, join(root, "first")); + const first = SessionManager.create(root, join(root, "session-artifacts", parent.getSessionId(), "sub-first")); first.newSession({ parentSession: parent.getSessionFile(), rlmDepth: 1 }); first.appendSessionInfo("first"); - const second = SessionManager.create(root, join(root, "second")); + const second = SessionManager.create(root, join(root, "session-artifacts", parent.getSessionId(), "sub-second")); second.newSession({ parentSession: parent.getSessionFile(), rlmDepth: 1 }); second.appendSessionInfo("second"); const registry = join(dirname(sessionDir), "session-artifacts", parent.getSessionId(), "rlm-subagents.jsonl"); @@ -38,31 +91,74 @@ describe("daemon catalog selector resolution", () => { writeFileSync( registry, [ - { type: "rlm_subagent", childId: "first", sessionFile: first.getSessionFile(), status: "completed" }, - { type: "rlm_subagent", childId: "second", sessionFile: second.getSessionFile(), status: "completed" }, + { + type: "rlm_subagent", + childId: first.getSessionId(), + sessionFile: first.getSessionFile(), + status: "completed", + }, + { + type: "rlm_subagent", + childId: second.getSessionId(), + sessionFile: second.getSessionFile(), + status: "completed", + }, ] .map((entry) => JSON.stringify(entry)) .join("\n"), ); - await expect(listSavedSessionSiblings(first.getSessionFile()!)).resolves.toEqual([ + await expect(listSavedSessionSiblings(first.getSessionFile()!, sessionDir)).resolves.toEqual([ expect.objectContaining({ id: first.getSessionId(), name: "first" }), expect.objectContaining({ id: second.getSessionId(), name: "second" }), ]); }); + it("uses the configured default session directory for family catalogs", async () => { + const { root, sessionDir, parent, first, second } = createCatalogFamilyFixture(); + try { + await withDefaultSessionDir(sessionDir, async () => { + await expect(listCatalogFamilySessions()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: parent.getSessionId(), name: "parent" }), + expect.objectContaining({ id: first.getSessionId(), name: "first" }), + expect.objectContaining({ id: second.getSessionId(), name: "second" }), + ]), + ); + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + expect(getOpenCatalogAuthorityFdCountForTest()).toBe(0); + }); + + it("uses the configured default session directory for saved siblings", async () => { + const { root, sessionDir, first, second } = createCatalogFamilyFixture(); + try { + await withDefaultSessionDir(sessionDir, async () => { + await expect(listSavedSessionSiblings(first.getSessionFile()!)).resolves.toEqual([ + expect.objectContaining({ id: first.getSessionId(), name: "first" }), + expect.objectContaining({ id: second.getSessionId(), name: "second" }), + ]); + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + expect(getOpenCatalogAuthorityFdCountForTest()).toBe(0); + }); + it("resolves relative parent headers from each child session directory", async () => { const root = mkdtempSync(join(tmpdir(), "prime-catalog-relative-siblings-")); const sessionDir = join(root, "sessions"); const parent = SessionManager.create(root, sessionDir); - parent.newSession(); + parent.newSession({ rlmDepth: 0 }); parent.appendSessionInfo("parent"); const parentFile = parent.getSessionFile()!; - const firstDir = join(root, "first"); + const firstDir = join(root, "session-artifacts", parent.getSessionId(), "sub-first"); const first = SessionManager.create(root, firstDir); first.newSession({ parentSession: relative(firstDir, parentFile), rlmDepth: 1 }); first.appendSessionInfo("first"); - const secondDir = join(root, "second"); + const secondDir = join(root, "session-artifacts", parent.getSessionId(), "sub-second"); const second = SessionManager.create(root, secondDir); second.newSession({ parentSession: relative(secondDir, parentFile), rlmDepth: 1 }); second.appendSessionInfo("second"); @@ -71,19 +167,345 @@ describe("daemon catalog selector resolution", () => { writeFileSync( registry, [ - { type: "rlm_subagent", childId: "first", sessionFile: first.getSessionFile(), status: "completed" }, - { type: "rlm_subagent", childId: "second", sessionFile: second.getSessionFile(), status: "completed" }, + { + type: "rlm_subagent", + childId: first.getSessionId(), + sessionFile: first.getSessionFile(), + status: "completed", + }, + { + type: "rlm_subagent", + childId: second.getSessionId(), + sessionFile: second.getSessionFile(), + status: "completed", + }, ] .map((entry) => JSON.stringify(entry)) .join("\n"), ); - await expect(listSavedSessionSiblings(first.getSessionFile()!)).resolves.toEqual([ + await expect(listSavedSessionSiblings(first.getSessionFile()!, sessionDir)).resolves.toEqual([ expect.objectContaining({ id: first.getSessionId(), name: "first" }), expect.objectContaining({ id: second.getSessionId(), name: "second" }), ]); }); + it("treats an absent session-artifacts directory as an empty family registry", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-no-artifacts-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + + await expect(listCatalogFamilySessions(sessionDir)).resolves.toEqual([ + expect.objectContaining({ id: "parent", rlmDepth: 0 }), + ]); + expect(getOpenCatalogAuthorityFdCountForTest()).toBe(0); + }); + + it("walks a trusted depth-two artifact family and fails closed for hostile artifacts", async () => { + const makeFixture = (name: string, omitRootDepth = false) => { + const root = mkdtempSync(join(tmpdir(), name)); + const sessionDir = join(root, "sessions"); + const registryPath = (parentId: string) => { + const parentFile = [rootSession, parent, first, second] + .find((manager) => manager.getSessionId() === parentId) + ?.getSessionFile(); + return parentFile && dirname(parentFile) !== sessionDir + ? join(dirname(parentFile), "session-artifacts", parentId, "rlm-subagents.jsonl") + : join(root, "session-artifacts", parentId, "rlm-subagents.jsonl"); + }; + const writeRegistry = (parentId: string, entries: unknown[]) => { + const path = registryPath(parentId); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync( + path, + entries.map((entry) => (typeof entry === "string" ? entry : JSON.stringify(entry))).join("\n"), + ); + }; + const create = (id: string, dir: string, parentSession?: string, depth = 0) => { + const manager = SessionManager.create(root, dir); + manager.newSession({ id, parentSession, rlmDepth: depth }); + manager.appendSessionInfo(id); + return manager; + }; + const rootSession = create("root", sessionDir); + if (omitRootDepth) { + const rootFile = rootSession.getSessionFile()!; + const [header, ...entries] = readFileSync(rootFile, "utf8").trimEnd().split(/\r?\n/); + const persistedHeader = JSON.parse(header!) as Record; + delete persistedHeader.rlmDepth; + writeFileSync(rootFile, `${[JSON.stringify(persistedHeader), ...entries].join("\n")}\n`); + } + const parent = create( + "parent", + join(root, "session-artifacts", "root", "sub-parent"), + rootSession.getSessionFile(), + 1, + ); + const first = create( + "first", + join(root, "session-artifacts", "parent", "sub-first"), + parent.getSessionFile(), + 2, + ); + const second = create( + "second", + join(root, "session-artifacts", "parent", "sub-second"), + parent.getSessionFile(), + 2, + ); + writeRegistry("root", [ + { type: "rlm_subagent", childId: "parent", sessionFile: parent.getSessionFile(), status: "completed" }, + ]); + writeRegistry("parent", [ + { type: "rlm_subagent", childId: "first", sessionFile: first.getSessionFile(), status: "completed" }, + { type: "rlm_subagent", childId: "second", sessionFile: second.getSessionFile(), status: "completed" }, + ]); + return { root, sessionDir, rootSession, parent, first, second, registryPath, writeRegistry }; + }; + const valid = makeFixture("prime-catalog-family-valid-", true); + await expect(listCatalogFamilySessions(valid.sessionDir)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "root", rlmDepth: 0 }), + expect.objectContaining({ id: "parent", rlmDepth: 1 }), + expect.objectContaining({ id: "first", rlmDepth: 2 }), + expect.objectContaining({ id: "second", rlmDepth: 2 }), + ]), + ); + await expect(listSavedSessionSiblings(valid.first.getSessionFile()!, valid.sessionDir)).resolves.toEqual( + expect.arrayContaining([expect.objectContaining({ id: "first" }), expect.objectContaining({ id: "second" })]), + ); + + const cases: Array<[string, (fixture: ReturnType) => void]> = [ + [ + "id mismatch", + (fixture) => + fixture.writeRegistry("parent", [ + { + type: "rlm_subagent", + childId: "wrong", + sessionFile: fixture.first.getSessionFile(), + status: "completed", + }, + ]), + ], + [ + "parent mismatch", + (fixture) => { + const evil = SessionManager.create( + fixture.root, + join(fixture.root, "session-artifacts", "parent", "sub-evil"), + ); + evil.newSession({ id: "evil", parentSession: fixture.rootSession.getSessionFile(), rlmDepth: 2 }); + evil.appendSessionInfo("evil"); + fixture.writeRegistry("parent", [ + { type: "rlm_subagent", childId: "evil", sessionFile: evil.getSessionFile(), status: "completed" }, + ]); + }, + ], + [ + "depth mismatch", + (fixture) => { + const evil = SessionManager.create( + fixture.root, + join(fixture.root, "session-artifacts", "parent", "sub-evil"), + ); + evil.newSession({ id: "evil", parentSession: fixture.parent.getSessionFile(), rlmDepth: 7 }); + evil.appendSessionInfo("evil"); + fixture.writeRegistry("parent", [ + { type: "rlm_subagent", childId: "evil", sessionFile: evil.getSessionFile(), status: "completed" }, + ]); + }, + ], + [ + "external path", + (fixture) => + fixture.writeRegistry("parent", [ + { + type: "rlm_subagent", + childId: "evil", + sessionFile: join(tmpdir(), "outside.jsonl"), + status: "completed", + }, + ]), + ], + [ + "path alias", + (fixture) => + fixture.writeRegistry("parent", [ + { + type: "rlm_subagent", + childId: "first", + sessionFile: `${dirname(fixture.first.getSessionFile()!)}/../sub-first/first.jsonl`, + status: "completed", + }, + ]), + ], + [ + "cycle", + (fixture) => + fixture.writeRegistry("parent", [ + { + type: "rlm_subagent", + childId: "root", + sessionFile: fixture.rootSession.getSessionFile(), + status: "completed", + }, + ]), + ], + ["malformed", (fixture) => fixture.writeRegistry("parent", ["{not json"])], + [ + "record limit", + (fixture) => + fixture.writeRegistry( + "parent", + Array.from({ length: 10_001 }, (_, index) => ({ + type: "rlm_subagent", + childId: `bad-${index}`, + sessionFile: fixture.first.getSessionFile(), + status: "completed", + })), + ), + ], + ]; + for (const [label, mutate] of cases) { + const fixture = makeFixture(`prime-catalog-family-${label.replace(/\s/g, "-")}-`); + mutate(fixture); + await expect(listCatalogFamilySessions(fixture.sessionDir), label).rejects.toThrow( + "Invalid RLM artifact family topology", + ); + } + const symlink = makeFixture("prime-catalog-family-symlink-"); + const alias = join(symlink.root, "session-artifacts", "parent", "sub-alias", "first.jsonl"); + mkdirSync(dirname(alias), { recursive: true }); + symlinkSync(symlink.first.getSessionFile()!, alias); + symlink.writeRegistry("parent", [ + { type: "rlm_subagent", childId: "first", sessionFile: alias, status: "completed" }, + ]); + await expect(listCatalogFamilySessions(symlink.sessionDir)).rejects.toThrow( + "Invalid RLM artifact family topology", + ); + }); + + it("rejects symlinked roots and deterministic intermediate/final replacement races", async () => { + const make = (name: string) => { + const root = mkdtempSync(join(tmpdir(), name)); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + const childDir = join(root, "session-artifacts", "parent", "sub-child"); + const child = SessionManager.create(root, childDir); + child.newSession({ id: "child", parentSession: parent.getSessionFile(), rlmDepth: 1 }); + child.appendSessionInfo("child"); + const registry = join(root, "session-artifacts", "parent", "rlm-subagents.jsonl"); + mkdirSync(dirname(registry), { recursive: true }); + writeFileSync( + registry, + JSON.stringify({ + type: "rlm_subagent", + childId: "child", + sessionFile: child.getSessionFile(), + status: "completed", + }), + ); + return { root, sessionDir, parent, child, childDir, registry }; + }; + + const rootLink = make("prime-catalog-root-link-"); + const movedSessions = `${rootLink.sessionDir}-real`; + renameSync(rootLink.sessionDir, movedSessions); + symlinkSync(movedSessions, rootLink.sessionDir); + await expect(listCatalogFamilySessions(rootLink.sessionDir)).rejects.toThrow( + "Invalid RLM artifact family topology", + ); + + const intermediate = make("prime-catalog-intermediate-swap-"); + let swappedIntermediate = false; + setCatalogBeforeTrustedOpenForTest((path) => { + if (swappedIntermediate || path !== intermediate.child.getSessionFile()) return; + swappedIntermediate = true; + const moved = `${intermediate.childDir}-real`; + renameSync(intermediate.childDir, moved); + symlinkSync(moved, intermediate.childDir); + }); + await expect(listCatalogFamilySessions(intermediate.sessionDir)).rejects.toThrow( + "Invalid RLM artifact family topology", + ); + + const finalSwap = make("prime-catalog-final-swap-"); + let swappedFinal = false; + setCatalogBeforeTrustedOpenForTest((path) => { + if (swappedFinal || path !== finalSwap.child.getSessionFile()) return; + swappedFinal = true; + const moved = `${path}.real`; + renameSync(path, moved); + symlinkSync(moved, path); + }); + await expect(listCatalogFamilySessions(finalSwap.sessionDir)).rejects.toThrow( + "Invalid RLM artifact family topology", + ); + setCatalogBeforeTrustedOpenForTest(undefined); + }); + + it("parses session metadata from the same descriptor-bound bytes without a pathname reopen", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-no-reopen-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("bound-name"); + let removed = false; + setCatalogBeforeTrustedOpenForTest((path) => { + if (removed || path !== parent.getSessionFile()) return; + removed = true; + const original = `${path}.opened`; + renameSync(path, original); + writeFileSync(path, "not a session\n"); + }); + // The helper opens after the hook, so it must reject the replacement instead of + // authorizing stale bytes. This pins the absence of a later readSessionInfo reopen. + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("Invalid RLM artifact family topology"); + setCatalogBeforeTrustedOpenForTest(undefined); + rmSync(root, { recursive: true, force: true }); + }); + + it("closes authority descriptors after repeated hostile seed failures", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-fd-release-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + const hostile = SessionManager.create(root, sessionDir); + hostile.newSession({ id: "hostile", parentSession: parent.getSessionFile(), rlmDepth: 1 }); + hostile.appendSessionInfo("hostile"); + const baseline = getOpenCatalogAuthorityFdCountForTest(); + for (let attempt = 0; attempt < 64; attempt++) { + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("Invalid RLM artifact family topology"); + expect(getOpenCatalogAuthorityFdCountForTest()).toBe(baseline); + } + }); + + it("rejects an unregistered parent-claiming seed and conflicting duplicate identity", async () => { + const root = mkdtempSync(join(tmpdir(), "prime-catalog-orphan-seed-")); + const sessionDir = join(root, "sessions"); + const parent = SessionManager.create(root, sessionDir); + parent.newSession({ id: "parent", rlmDepth: 0 }); + parent.appendSessionInfo("parent"); + const orphan = SessionManager.create(root, sessionDir); + orphan.newSession({ id: "evil", parentSession: parent.getSessionFile(), rlmDepth: 1 }); + orphan.appendSessionInfo("evil"); + await expect(listCatalogFamilySessions(sessionDir)).rejects.toThrow("managed session seed claims a parent"); + + const duplicateRoot = mkdtempSync(join(tmpdir(), "prime-catalog-duplicate-id-")); + const duplicateDir = join(duplicateRoot, "sessions"); + const duplicate = SessionManager.create(duplicateRoot, duplicateDir); + duplicate.newSession({ id: "duplicate", rlmDepth: 0 }); + duplicate.appendSessionInfo("one"); + writeFileSync(join(duplicateDir, "alias.jsonl"), readFileSync(duplicate.getSessionFile()!)); + await expect(listCatalogFamilySessions(duplicateDir)).rejects.toThrow("duplicate session id"); + }); + it("treats an exact name colliding with another session id prefix as ambiguous", () => { const sessions = [ session("named-session-id", "target", "/tmp/by-name.jsonl"), diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index fe278efdb..045d63ab4 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it, vi } from "vitest"; import { AGENT_FAMILY_REACH_ERROR, type AgentSessionMessageController, + assertAgentFamilyReach, DEFAULT_AGENT_MESSAGE_MAX_CHARS, sessionNameReservationKey, } from "../src/core/agent-messages.js"; @@ -46,6 +47,53 @@ import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js" import { DAEMON_WORKER_SUPERVISOR_SOCKET_ENV } from "../src/modes/daemon/daemon-worker-protocol.js"; describe("daemon mode helpers", () => { + const useResidentCatalog = (daemon: AgentDaemon) => { + const internals = daemon as unknown as { + sessions: Map; + agentFamilyCatalogEntries?: () => Promise< + readonly import("../src/core/agent-messages.js").AgentFamilyCatalogEntry[] + >; + }; + internals.agentFamilyCatalogEntries = async () => + Object.freeze( + [...internals.sessions.values()].map((state) => { + const session = state.runtime.session; + const metadata = state.runtime.metadata; + const parentSessionId = + metadata.parentSessionId ?? + (metadata.parentActiveSessionId + ? internals.sessions.get(metadata.parentActiveSessionId)?.runtime.session.sessionId + : undefined); + return { + id: session.sessionId, + depth: session.rlmDepth ?? 0, + status: "running" as const, + ...(parentSessionId ? { parentSessionId } : {}), + ...(session.sessionFile ? { sessionPath: session.sessionFile } : {}), + }; + }), + ); + }; + + const installDeterministicAgentFamilyCatalog = (internals: object, states: readonly ActiveSessionState[]) => { + const catalogTarget = internals as { + agentFamilyCatalogEntries?: () => Promise< + readonly import("../src/core/agent-messages.js").AgentFamilyCatalogEntry[] + >; + }; + catalogTarget.agentFamilyCatalogEntries = vi.fn(async () => + Object.freeze( + states.map((state) => ({ + id: state.runtime.session.sessionId, + depth: state.runtime.session.rlmDepth ?? 0, + status: "running" as const, + ...(state.runtime.metadata.parentSessionId + ? { parentSessionId: state.runtime.metadata.parentSessionId } + : {}), + })), + ), + ); + }; it("preserves envelope client identity while registering prompt admission", () => { const daemon = new AgentDaemon("/tmp/unused-daemon.sock", { defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, @@ -241,6 +289,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); const send = internals.sendAgentSessionMessage({ targetSelector: targetState.activeSessionId, @@ -248,8 +297,9 @@ describe("daemon mode helpers", () => { fromState, origin: "agent", }); - await Promise.resolve(); - await Promise.resolve(); + for (let attempt = 0; attempt < 200 && acceptAgentMessagePrompt.mock.calls.length === 0; attempt++) { + await Promise.resolve(); + } expect(acceptAgentMessagePrompt).toHaveBeenCalledOnce(); resolvePrompt(); @@ -485,6 +535,69 @@ describe("daemon mode helpers", () => { } }); + it("fails closed for malformed remote peer topology on daemon ACL surfaces", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-malformed-remote-family.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const root = makeState("root"); + root.runtime = { + ...root.runtime, + cwd: "/tmp", + metadata: { kind: "top-level", createdAt: 1 }, + session: { + sessionId: "session-root", + sessionName: "root", + rlmDepth: 0, + isStreaming: false, + isSessionActive: false, + unfinishedActionCount: 0, + messages: [], + sessionActions: { queuedCount: 0, steering: [], followUps: [] }, + hasRunningRlmChildren: () => false, + sessionManager: { getSessionArtifactDir: () => undefined }, + }, + } as never; + const internals = daemon as unknown as { + sessions: Map; + remoteAgentPeers: Map>; + createAgentMessageController( + getCurrentState: () => ActiveSessionState | undefined, + ): AgentSessionMessageController; + createAgentObserveController(getCurrentState: () => ActiveSessionState): AgentObserveController; + }; + internals.sessions.set(root.activeSessionId, root); + const listAll = vi.spyOn(SessionManager, "listAll").mockResolvedValue([]); + try { + for (const malformed of [ + { rlmDepth: 0, parentSessionId: "session-root" }, + { rlmDepth: -1 }, + { rlmDepth: 1 }, + ]) { + internals.remoteAgentPeers.clear(); + internals.remoteAgentPeers.set("malformed-peer", { + activeSessionId: "malformed-peer", + sessionId: "session-malformed-peer", + sessionName: "malformed-peer", + runtimeKind: "subagent", + cwd: "/tmp/remote", + isStreaming: false, + unfinishedActionCount: 0, + ...malformed, + }); + const messaging = internals.createAgentMessageController(() => root); + const observe = internals.createAgentObserveController(() => root); + await expect(messaging.roster!()).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + await expect(messaging.sendAgentMessage({ target: "malformed-peer", message: "no" })).rejects.toThrow( + AGENT_FAMILY_REACH_ERROR, + ); + await expect(observe.listAgents()).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + } + } finally { + listAll.mockRestore(); + } + }); + it("canonicalizes symlinked paths in the family catalog and name reservations", async () => { const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-family-catalog-paths-")); try { @@ -621,6 +734,7 @@ describe("daemon mode helpers", () => { internals.sessions.set(parentState.activeSessionId, parentState); // A successfully completed RLM child remains idle in this daemon registry. internals.sessions.set(subagentState.activeSessionId, subagentState); + installDeterministicAgentFamilyCatalog(internals, [parentState, subagentState]); const controller = internals.createAgentMessageController(() => parentState); const subagentSummary = (await controller.listAgents()).agents.find( @@ -1204,6 +1318,7 @@ describe("daemon mode helpers", () => { }, worker: { authenticationToken: "worker-token" }, }); + useResidentCatalog(daemon); const parentState = makeState("parent"); const childState = makeState("child", parentState.activeSessionId); const sessionPrompt = vi.fn(async () => {}); @@ -1333,6 +1448,9 @@ describe("daemon mode helpers", () => { session: { sessionId: "session-source", sessionName: "Source", + sessionFile: "/tmp/source.jsonl", + rlmDepth: 0, + sessionManager: { getSessionArtifactDir: () => undefined }, isStreaming: false, sessionActions: { queuedCount: 0, steering: [], followUps: [] }, }, @@ -1371,6 +1489,9 @@ describe("daemon mode helpers", () => { cwd: "/tmp/remote", isStreaming: false, sessionActions: { queuedCount: 0, steering: [], followUps: [] }, + rlmDepth: 1, + parentSessionId: "session-source", + parentSessionPath: "/tmp/source.jsonl", }); internals.sendRemoteAgentSessionMessage = sendRemoteAgentSessionMessage; @@ -1833,6 +1954,7 @@ describe("daemon mode helpers", () => { internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetA.activeSessionId, targetA); internals.sessions.set(targetB.activeSessionId, targetB); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetA, targetB]); for (let i = 0; i < 3; i++) { await expect( @@ -2057,6 +2179,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); for (let i = 0; i < 3; i++) { await expect( @@ -2124,6 +2247,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); const first = internals.sendAgentSessionMessage({ targetSelector: targetState.activeSessionId, @@ -2201,18 +2325,22 @@ describe("daemon mode helpers", () => { return fromState; }); + installDeterministicAgentFamilyCatalog(internals, [...senders, targetState]); + const sends: Promise[] = []; const errors: unknown[] = []; for (const [i, fromState] of senders.entries()) { - void internals - .sendAgentSessionMessage({ - targetSelector: targetState.activeSessionId, - message: `message ${i}`, - fromState, - origin: "agent", - }) - .catch((error) => { - errors.push(error); - }); + sends.push( + internals + .sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: `message ${i}`, + fromState, + origin: "agent", + }) + .catch((error) => { + errors.push(error); + }), + ); } for (let attempt = 0; attempt < 200 && queueAgentMessagePrompt.mock.calls.length < 12; attempt++) { await Promise.resolve(); @@ -2220,6 +2348,7 @@ describe("daemon mode helpers", () => { // With reservations held past queue time, 12 concurrent senders would // count as 24 against the 20-slot cap and the tail would reject. + await Promise.all(sends); expect(errors).toEqual([]); expect(queueAgentMessagePrompt).toHaveBeenCalledTimes(12); }); @@ -2264,6 +2393,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); await expect( internals.sendAgentSessionMessage({ @@ -2287,6 +2417,7 @@ describe("daemon mode helpers", () => { throw new Error("unexpected runtime creation"); }, }); + useResidentCatalog(daemon); const makeBusyState = (name: string) => { const state = makeState(name); state.runtime = { @@ -2633,6 +2764,7 @@ describe("daemon mode helpers", () => { throw new Error("unexpected runtime creation"); }, }); + useResidentCatalog(daemon); const states = [ makeState("root"), makeState("child", "root"), @@ -2714,11 +2846,169 @@ describe("daemon mode helpers", () => { ); }); + it("authorizes depth-two passive siblings only from the persisted daemon topology", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-deep-passive-acl-")); + try { + const fixture = makePersistedRlmDaemonFixture(tempDir); + const secondGrandchildDir = join(fixture.childSessionDir, "sibling-grandchild"); + const secondGrandchild = SessionManager.create(tempDir, secondGrandchildDir); + secondGrandchild.newSession({ parentSession: fixture.childSessionFile, rlmDepth: 2 }); + secondGrandchild.flushNow(); + const secondGrandchildFile = secondGrandchild.getSessionFile(); + if (!secondGrandchildFile) throw new Error("Missing sibling grandchild session"); + const childRegistry = join(fixture.childArtifactDir, "rlm-subagents.jsonl"); + writeFileSync( + childRegistry, + `${readFileSync(childRegistry, "utf8")}${JSON.stringify({ + type: "rlm_subagent", + childId: "sibling-grandchild", + sessionName: "sibling-grandchild", + sessionDir: secondGrandchildDir, + sessionFile: secondGrandchildFile, + parentSessionId: fixture.childSessionId, + parentSessionFile: fixture.childSessionFile, + rlmDepth: 2, + status: "completed", + createdAt: 2, + updatedAt: "2026-01-01T00:00:02.000Z", + })}\n`, + ); + const internals = fixture.daemon as unknown as { + createRuntime(command: Extract): Promise; + agentFamilyCatalogEntries(): Promise< + readonly import("../src/core/agent-messages.js").AgentFamilyCatalogEntry[] + >; + }; + await internals.createRuntime({ type: "create", sessionPath: fixture.parentSessionFile }); + const catalog = await internals.agentFamilyCatalogEntries(); + const first = catalog.find((entry) => entry.id === fixture.grandchildSessionId); + const second = catalog.find((entry) => entry.id === secondGrandchild.getSessionId()); + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(catalog).toContainEqual(expect.objectContaining({ id: fixture.childSessionId, depth: 1 })); + expect(assertAgentFamilyReach(first!, second!, catalog)).toBe("sibling"); + + // A depth-two pair claiming the root cannot manufacture the missing depth-one edge. + expect(() => + assertAgentFamilyReach( + { ...first!, parentSessionId: fixture.parentSessionId, parentSessionPath: fixture.parentSessionFile }, + { ...second!, parentSessionId: fixture.parentSessionId, parentSessionPath: fixture.parentSessionFile }, + catalog, + ), + ).toThrow(AGENT_FAMILY_REACH_ERROR); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("observes residents from the captured family catalog rather than live endpoint fields", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-observe-captured-family.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const source = makeState("source"); + const target = makeState("target"); + for (const state of [source, target]) { + state.runtime = { + ...state.runtime, + metadata: { kind: "top-level", createdAt: 1 }, + diagnostics: [], + session: { + ...state.runtime.session, + messages: [], + hasRunningRlmChildren: vi.fn(() => false), + sessionManager: { + getHeader: vi.fn(() => ({})), + getCwd: vi.fn(() => "/tmp"), + getSessionArtifactDir: vi.fn(() => undefined), + }, + getSessionActionSnapshot: vi.fn(() => ({ queuedCount: 0, steering: [], followUps: [] })), + state: { streamingMessage: undefined, pendingToolCalls: new Map() }, + sessionId: `session-${state.activeSessionId}`, + sessionFile: `/tmp/${state.activeSessionId}.jsonl`, + rlmDepth: 0, + }, + } as never; + } + const internals = daemon as unknown as { + sessions: Map; + agentFamilyCatalogEntries(): Promise< + readonly import("../src/core/agent-messages.js").AgentFamilyCatalogEntry[] + >; + createAgentObserveController(getCurrentState: () => ActiveSessionState): AgentObserveController; + }; + internals.sessions.set(source.activeSessionId, source); + internals.sessions.set(target.activeSessionId, target); + Object.assign(internals, { + agentFamilyCatalogEntries: vi.fn(async () => + Object.freeze([ + { id: "left-parent", depth: 0, status: "inactive", sessionPath: "/tmp/left.jsonl" }, + { id: "right-parent", depth: 0, status: "inactive", sessionPath: "/tmp/right.jsonl" }, + { id: "session-source", depth: 1, status: "running", parentSessionId: "left-parent" }, + { id: "session-target", depth: 1, status: "running", parentSessionId: "right-parent" }, + ]), + ), + }); + + // The live root summaries would otherwise be sibling roots. Their conflicting + // topology cannot override the captured snapshot's unrelated parent edges. + const observed = await internals.createAgentObserveController(() => source).listAgents(); + expect(observed.agents.map((agent) => agent.activeSessionId)).toEqual(["source"]); + }); + + it("fails closed for every agent ACL surface when the captured catalog duplicates a stable session ID", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-duplicate-captured-family.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const parent = makeAgentFamilyState("parent", "parent"); + const source = makeAgentFamilyState("source", "source", parent.state); + const target = makeAgentFamilyState("target", "target", parent.state); + const internals = daemon as unknown as { + sessions: Map; + agentFamilyCatalogEntries(): Promise< + readonly import("../src/core/agent-messages.js").AgentFamilyCatalogEntry[] + >; + createAgentMessageController(getCurrentState: () => ActiveSessionState): AgentSessionMessageController; + createAgentObserveController(getCurrentState: () => ActiveSessionState): AgentObserveController; + }; + for (const fixture of [parent, source, target]) + internals.sessions.set(fixture.state.activeSessionId, fixture.state); + const sourceId = source.state.runtime.session.sessionId; + const parentId = parent.state.runtime.session.sessionId; + const targetId = target.state.runtime.session.sessionId; + const entries = [ + { id: parentId, depth: 0, status: "running" as const }, + { id: sourceId, depth: 1, status: "running" as const, parentSessionId: parentId }, + { id: sourceId, depth: 1, status: "running" as const, parentSessionId: "forged-parent" }, + { id: targetId, depth: 1, status: "running" as const, parentSessionId: parentId }, + ]; + + // The current observer must be resolved from the immutable catalog before + // self inclusion. Either duplicate ordering is ambiguous and must fail closed. + for (const catalog of [entries, [...entries.slice(0, 1), entries[2]!, entries[1]!, entries[3]!]]) { + internals.agentFamilyCatalogEntries = vi.fn(async () => Object.freeze(catalog)); + const observe = internals.createAgentObserveController(() => source.state); + await expect(observe.listAgents()).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + await expect(observe.getAgent(target.state.activeSessionId)).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + await expect(observe.recentMessages({ target: target.state.activeSessionId })).rejects.toThrow( + AGENT_FAMILY_REACH_ERROR, + ); + await expect( + internals + .createAgentMessageController(() => source.state) + .sendAgentMessage({ target: target.state.activeSessionId, message: "must not deliver" }), + ).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + } + expect(target.acceptAgentMessagePrompt).not.toHaveBeenCalled(); + }); + it("resolves a duplicate session name to the only family-reachable agent", async () => { const daemon = new AgentDaemon("/tmp/prime-agent-family-name-resolution.sock", { defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, createRuntime: vi.fn(), }); + useResidentCatalog(daemon); const observer = makeAgentFamilyState("observer", "observer"); const familyHelper = makeAgentFamilyState("family-helper", "helper", observer.state); const otherRoot = makeAgentFamilyState("other-root", "other-root"); @@ -2858,6 +3148,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); const first = internals.sendAgentSessionMessage({ targetSelector: targetState.activeSessionId, @@ -2871,15 +3162,17 @@ describe("daemon mode helpers", () => { fromState, origin: "agent", }); - await Promise.resolve(); - await Promise.resolve(); + for (let attempt = 0; attempt < 200 && prompt.mock.calls.length === 0; attempt++) { + await Promise.resolve(); + } expect(prompt).toHaveBeenCalledTimes(1); promptResolves[0]?.(); await expect(first).resolves.toMatchObject({ message: "first" }); - await Promise.resolve(); - await Promise.resolve(); + for (let attempt = 0; attempt < 200 && prompt.mock.calls.length < 2; attempt++) { + await Promise.resolve(); + } expect(prompt).toHaveBeenCalledTimes(2); expect(followUp).not.toHaveBeenCalled(); @@ -3213,6 +3506,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); const first = internals.sendAgentSessionMessage({ targetSelector: targetState.activeSessionId, @@ -3226,15 +3520,17 @@ describe("daemon mode helpers", () => { fromState, origin: "agent", }); - await Promise.resolve(); - await Promise.resolve(); + for (let attempt = 0; attempt < 200 && prompt.mock.calls.length === 0; attempt++) { + await Promise.resolve(); + } expect(prompt).toHaveBeenCalledTimes(1); (targetState.runtime.session as { isStreaming: boolean }).isStreaming = true; promptResolves[0]?.(); await expect(first).resolves.toMatchObject({ message: "first" }); - await Promise.resolve(); - await Promise.resolve(); + for (let attempt = 0; attempt < 200 && queueAgentMessagePrompt.mock.calls.length === 0; attempt++) { + await Promise.resolve(); + } expect(prompt).toHaveBeenCalledTimes(1); expect(queueAgentMessagePrompt).toHaveBeenCalledOnce(); @@ -3667,6 +3963,7 @@ describe("daemon mode helpers", () => { }; internals.sessions.set(fromState.activeSessionId, fromState); internals.sessions.set(targetState.activeSessionId, targetState); + installDeterministicAgentFamilyCatalog(internals, [fromState, targetState]); const first = internals.sendAgentSessionMessage({ targetSelector: targetState.activeSessionId, @@ -3680,8 +3977,9 @@ describe("daemon mode helpers", () => { fromState, origin: "agent", }); - await Promise.resolve(); - await Promise.resolve(); + for (let attempt = 0; attempt < 200 && acceptAgentMessagePrompt.mock.calls.length === 0; attempt++) { + await Promise.resolve(); + } (targetState.runtime.session as { unfinishedActionCount: number }).unfinishedActionCount = 20; resolveFirstPrompt(); @@ -4024,6 +4322,7 @@ describe("daemon mode helpers", () => { }): Promise; }; internals.sessions.set(state.activeSessionId, state); + installDeterministicAgentFamilyCatalog(internals, [state]); await expect( internals.sendAgentSessionMessage({ @@ -5745,13 +6044,15 @@ describe("daemon mode helpers", () => { sessionPath: fixture.parentSessionFile, }); - await internals - .createAgentMessageController(() => parentState) - .sendAgentMessage({ target: "renamed-worker", message: "report progress" }); - - // The nested header depth must win over the legacy depth-1 default so the - // woken child does not come up shallower than persisted. - expect(fixture.createRuntime.mock.calls[1]?.[0].sessionOptions?.rlmDepth).toBe(2); + // A root may not directly reach a depth-two child. The persisted header + // is authoritative even when legacy registry metadata omits depth, and denial + // must happen before hydration. + await expect( + internals + .createAgentMessageController(() => parentState) + .sendAgentMessage({ target: "renamed-worker", message: "report progress" }), + ).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + expect(fixture.createRuntime).toHaveBeenCalledOnce(); } finally { rmSync(tempDir, { recursive: true, force: true }); } @@ -9566,6 +9867,301 @@ describe("daemon mode helpers", () => { }), ).rejects.toThrow("Unknown active session: missing"); }); + + it("delivers disjoint CLI sends while preserving agent-origin catalog ACLs", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-cli-from-state.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const { state: fromState } = makeAgentFamilyState("source", "Source"); + const { state: targetState, acceptAgentMessagePrompt } = makeAgentFamilyState("target", "Target"); + const agentFamilyCatalogEntries = vi.fn(async () => + Object.freeze([ + { + id: fromState.runtime.session.sessionId, + depth: 1, + status: "running" as const, + parentSessionId: "source-parent", + }, + { + id: targetState.runtime.session.sessionId, + depth: 1, + status: "running" as const, + parentSessionId: "target-parent", + }, + ]), + ); + const internals = daemon as unknown as { + sessions: Map; + agentFamilyCatalogEntries: typeof agentFamilyCatalogEntries; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + internals.agentFamilyCatalogEntries = agentFamilyCatalogEntries; + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "CLI delivery ignores the family reach ACL", + fromState, + origin: "cli", + }), + ).resolves.toMatchObject({ + deliveryStatus: "delivered", + target: { activeSessionId: targetState.activeSessionId }, + }); + expect(acceptAgentMessagePrompt.mock.calls[0]?.[1]).toMatchObject({ + customMessage: { details: { fromRelationship: undefined } }, + }); + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "agent ACL must reject this", + fromState, + origin: "agent", + }), + ).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + expect(acceptAgentMessagePrompt).toHaveBeenCalledOnce(); + }); + + it("labels CLI sibling sends from an authoritative depth-1 catalog", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-cli-sibling-label.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const { state: parentState } = makeAgentFamilyState("parent", "Parent"); + const { state: fromState } = makeAgentFamilyState("source", "Source", parentState); + const { state: targetState, acceptAgentMessagePrompt } = makeAgentFamilyState("target", "Target", parentState); + const agentFamilyCatalogEntries = vi.fn(async () => + Object.freeze([ + { + id: parentState.runtime.session.sessionId, + depth: 0, + status: "running" as const, + }, + { + id: fromState.runtime.session.sessionId, + depth: 1, + status: "running" as const, + parentSessionId: parentState.runtime.session.sessionId, + }, + { + id: targetState.runtime.session.sessionId, + depth: 1, + status: "running" as const, + parentSessionId: parentState.runtime.session.sessionId, + }, + ]), + ); + const internals = daemon as unknown as { + sessions: Map; + agentFamilyCatalogEntries: typeof agentFamilyCatalogEntries; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(parentState.activeSessionId, parentState); + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + internals.agentFamilyCatalogEntries = agentFamilyCatalogEntries; + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "sent by the CLI sibling", + fromState, + origin: "cli", + }), + ).resolves.toMatchObject({ deliveryStatus: "delivered" }); + expect(agentFamilyCatalogEntries).toHaveBeenCalledOnce(); + expect(acceptAgentMessagePrompt.mock.calls[0]?.[1]).toMatchObject({ + customMessage: { details: { fromRelationship: "sibling" } }, + }); + }); + + it("omits a CLI sibling label when the catalog has ambiguous parents", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-cli-ambiguous-sibling.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const { state: firstParent } = makeAgentFamilyState("first-parent", "First parent"); + const { state: fromState } = makeAgentFamilyState("source", "Source", firstParent); + const { state: targetState, acceptAgentMessagePrompt } = makeAgentFamilyState("target", "Target", firstParent); + const agentFamilyCatalogEntries = vi.fn(async () => + Object.freeze([ + { + id: firstParent.runtime.session.sessionId, + depth: 0, + status: "running" as const, + }, + { + id: firstParent.runtime.session.sessionId, + depth: 0, + status: "running" as const, + }, + { + id: fromState.runtime.session.sessionId, + depth: 1, + status: "running" as const, + parentSessionId: firstParent.runtime.session.sessionId, + }, + { + id: targetState.runtime.session.sessionId, + depth: 1, + status: "running" as const, + parentSessionId: firstParent.runtime.session.sessionId, + }, + ]), + ); + const internals = daemon as unknown as { + sessions: Map; + agentFamilyCatalogEntries: typeof agentFamilyCatalogEntries; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + internals.agentFamilyCatalogEntries = agentFamilyCatalogEntries; + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "sent with ambiguous parent evidence", + fromState, + origin: "cli", + }), + ).resolves.toMatchObject({ deliveryStatus: "delivered" }); + expect(agentFamilyCatalogEntries).toHaveBeenCalledOnce(); + expect(acceptAgentMessagePrompt.mock.calls[0]?.[1]).toMatchObject({ + customMessage: { details: { fromRelationship: undefined } }, + }); + }); + + it.each(["sender", "target"] as const)( + "delivers an unlabeled CLI message with a duplicate %s endpoint while agent origin rejects it", + async (duplicate) => { + const daemon = new AgentDaemon(`/tmp/prime-agent-cli-duplicate-${duplicate}.sock`, { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const { state: fromState } = makeAgentFamilyState("source", "Source"); + const { state: targetState, acceptAgentMessagePrompt } = makeAgentFamilyState("target", "Target"); + const fromEntry = { + id: fromState.runtime.session.sessionId, + depth: 0, + status: "running" as const, + }; + const targetEntry = { + id: targetState.runtime.session.sessionId, + depth: 0, + status: "running" as const, + }; + const duplicateEntry = duplicate === "sender" ? fromEntry : targetEntry; + const agentFamilyCatalogEntries = vi.fn(async () => + Object.freeze([fromEntry, targetEntry, { ...duplicateEntry }]), + ); + const internals = daemon as unknown as { + sessions: Map; + agentFamilyCatalogEntries: typeof agentFamilyCatalogEntries; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + internals.agentFamilyCatalogEntries = agentFamilyCatalogEntries; + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "CLI delivery treats topology as advisory", + fromState, + origin: "cli", + }), + ).resolves.toMatchObject({ deliveryStatus: "delivered" }); + expect(acceptAgentMessagePrompt.mock.calls[0]?.[1]).toMatchObject({ + customMessage: { details: { fromRelationship: undefined } }, + }); + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "agent origin rejects ambiguous authority", + fromState, + origin: "agent", + }), + ).rejects.toThrow(AGENT_FAMILY_REACH_ERROR); + expect(acceptAgentMessagePrompt).toHaveBeenCalledOnce(); + }, + ); + + it("delivers an unlabeled CLI message when catalog acquisition fails while agent origin rejects it", async () => { + const daemon = new AgentDaemon("/tmp/prime-agent-cli-catalog-failure.sock", { + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + createRuntime: vi.fn(), + }); + const { state: fromState } = makeAgentFamilyState("source", "Source"); + const { state: targetState, acceptAgentMessagePrompt } = makeAgentFamilyState("target", "Target"); + const catalogError = new Error("catalog unavailable"); + const agentFamilyCatalogEntries = vi.fn(async () => { + throw catalogError; + }); + const log = vi.fn(); + const internals = daemon as unknown as { + sessions: Map; + agentFamilyCatalogEntries: typeof agentFamilyCatalogEntries; + log: typeof log; + sendAgentSessionMessage(options: { + targetSelector: string; + message: string; + fromState?: ActiveSessionState; + origin: "agent" | "cli"; + }): Promise; + }; + internals.sessions.set(fromState.activeSessionId, fromState); + internals.sessions.set(targetState.activeSessionId, targetState); + internals.agentFamilyCatalogEntries = agentFamilyCatalogEntries; + internals.log = log; + + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "CLI delivery survives catalog failure", + fromState, + origin: "cli", + }), + ).resolves.toMatchObject({ deliveryStatus: "delivered" }); + expect(log).toHaveBeenCalledWith( + "Agent family catalog unavailable for CLI message relationship: catalog unavailable", + ); + expect(acceptAgentMessagePrompt.mock.calls[0]?.[1]).toMatchObject({ + customMessage: { details: { fromRelationship: undefined } }, + }); + await expect( + internals.sendAgentSessionMessage({ + targetSelector: targetState.activeSessionId, + message: "agent origin rejects catalog failure", + fromState, + origin: "agent", + }), + ).rejects.toBe(catalogError); + expect(acceptAgentMessagePrompt).toHaveBeenCalledOnce(); + }); }); type CronAdmissionActivity = Partial<{ @@ -9694,7 +10290,7 @@ function makePersistedRlmDaemonFixture( const childId = "child-1"; const childSessionDir = join(parentArtifactDir, "sub-1234abcd"); const childManager = SessionManager.create(tempDir, childSessionDir); - childManager.newSession({ parentSession: parentSessionFile }); + childManager.newSession({ parentSession: parentSessionFile, rlmDepth: 1 }); childManager.appendSessionInfo("spawn-worker"); childManager.appendSessionInfo("renamed-worker"); childManager.appendMessage({ role: "user", content: "complete this task", timestamp: 1 }); @@ -9708,7 +10304,7 @@ function makePersistedRlmDaemonFixture( mkdirSync(childArtifactDir, { recursive: true }); const grandchildSessionDir = join(childSessionDir, "sub-deadbeef"); const grandchildManager = SessionManager.create(tempDir, grandchildSessionDir); - grandchildManager.newSession({ parentSession: childSessionFile }); + grandchildManager.newSession({ parentSession: childSessionFile, rlmDepth: 2 }); grandchildManager.appendSessionInfo("nested-worker"); grandchildManager.appendMessage({ role: "user", content: "complete the nested task", timestamp: 2 }); grandchildManager.flushNow(); @@ -9824,9 +10420,12 @@ function makePersistedRlmDaemonFixture( parentArtifactDir, parentSessionId: parentManager.getSessionId(), childId, + childSessionId: childManager.getSessionId(), childSessionFile, childSessionDir, + childArtifactDir, grandchildId, + grandchildSessionId: grandchildManager.getSessionId(), grandchildSessionFile, }; } diff --git a/packages/coding-agent/test/daemon-protocol.test.ts b/packages/coding-agent/test/daemon-protocol.test.ts index d7eb8acf4..466df631a 100644 --- a/packages/coding-agent/test/daemon-protocol.test.ts +++ b/packages/coding-agent/test/daemon-protocol.test.ts @@ -93,6 +93,19 @@ describe("daemon protocol helpers", () => { expect(DAEMON_DEFAULT_SERVER_CAPABILITIES).toContain("queue_message_mutation"); }); + it("accepts the old-client rename shape but rejects an old daemon for authority-aware renames", () => { + const oldClientCommand: DaemonCommand = { + type: "rename_saved_session", + sessionPath: "/tmp/session.jsonl", + name: "renamed", + }; + expect(getDaemonCommandCompatibilities(oldClientCommand)).toEqual([{ minProtocol: 7, minSchemaRevision: 17 }]); + expect(DAEMON_COMMAND_COMPATIBILITY.rename_saved_session).toEqual({ + minProtocol: 7, + minSchemaRevision: 17, + }); + }); + it("schema-gates the RLM max depth commands at their introducing revision", () => { expect(DAEMON_COMMAND_COMPATIBILITY.get_rlm_max_depth_status).toEqual({ minProtocol: 7, minSchemaRevision: 11 }); expect(DAEMON_COMMAND_COMPATIBILITY.set_rlm_max_depth).toEqual({ minProtocol: 7, minSchemaRevision: 11 }); diff --git a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts index 6b2542444..93543625b 100644 --- a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { SessionManager } from "../src/core/session-manager.js"; import { success } from "../src/modes/daemon/daemon-protocol.js"; import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js"; import { DaemonSupervisor, idleEvictionSweepIntervalMs } from "../src/modes/daemon/daemon-supervisor.js"; @@ -31,8 +32,17 @@ interface SupervisorInternals { workers: Map; clients: Set<{ id: string; attachedActiveSessionIds: Set }>; idleEvictionFence?: Promise; - catalog: { resolve: ReturnType; stop: ReturnType }; + catalog: { + resolve: ReturnType; + stop: ReturnType; + list?: ReturnType; + family?: ReturnType; + siblings?: ReturnType; + }; createOrReuseWorker: ReturnType; + familyCatalogEntries( + sessionDir?: string, + ): Promise; stopWorker: ReturnType; log: ReturnType; scheduleIdleEvictionSweep(): void; @@ -304,6 +314,50 @@ describe("daemon supervisor whole-tree eviction", () => { }); }); + it("uses the merged custom session directory for named saved-session siblings", async () => { + const supervisor = makeSupervisor(); + const sessionPath = "/tmp/custom-sessions/saved.jsonl"; + const target = { + id: "saved", + path: sessionPath, + cwd: "/tmp/project", + parentSessionPath: "/tmp/custom-sessions/parent.jsonl", + rlmDepth: 1, + created: new Date("2026-08-01T12:00:00.000Z"), + modified: new Date("2026-08-01T12:00:00.000Z"), + messageCount: 0, + firstMessage: "", + allMessagesText: "", + }; + supervisor.catalog.resolve = vi.fn(async () => sessionPath); + supervisor.catalog.siblings = vi.fn(async () => [target]); + const launched = makeWorker("launched", [makeSummary("launched-active", Date.now())]); + const launchWorker = vi.fn(async () => launched); + Object.assign(supervisor, { launchWorker }); + const createOrReuseWorker = ( + supervisor as unknown as { + createOrReuseWorker(clientId: string, command: object): Promise; + } + ).createOrReuseWorker.bind(supervisor); + + await expect( + createOrReuseWorker("client", { + id: "named-custom", + type: "create", + sessionPath: "saved", + name: "renamed", + config: { sessionDir: "/tmp/custom-sessions" }, + }), + ).resolves.toBe(launched); + expect(supervisor.catalog.resolve).toHaveBeenCalledWith("saved", expect.any(String), "/tmp/custom-sessions"); + expect(supervisor.catalog.siblings).toHaveBeenCalledWith(sessionPath, "/tmp/custom-sessions"); + expect(launchWorker).toHaveBeenCalledWith( + expect.objectContaining({ sessionPath, config: { sessionDir: "/tmp/custom-sessions" } }), + undefined, + undefined, + ); + }); + it("resolves a saved target in the source worker's create-time session directory", async () => { const now = Date.parse("2026-08-01T12:00:00.000Z"); const supervisor = makeSupervisor(); @@ -311,9 +365,19 @@ describe("daemon supervisor whole-tree eviction", () => { const source = makeWorker("source", [sourceSummary]); source.descriptor.createCommand.config = { sessionDir: "/tmp/custom-sessions" }; source.summaries = new Map([["source-active", sourceSummary]]); + // The wake path reads this row before authorizing it, so model an actual + // saved session rather than a summary whose sessionFile is not readable. + const targetDirectory = mkdtempSync(join(tmpdir(), "prime-supervisor-saved-target-")); + tempDirs.push(targetDirectory); + const targetManager = SessionManager.create(targetDirectory, join(targetDirectory, "sessions")); + targetManager.newSession(); + targetManager.appendSessionInfo("saved target"); + targetManager.flushNow(); + const targetPath = targetManager.getSessionFile(); + if (!targetPath) throw new Error("Missing saved target session path"); const targetSummary = makeSummary("target-active", now, { - sessionId: "target-session", - sessionFile: "/tmp/target.jsonl", + sessionId: targetManager.getSessionId(), + sessionFile: targetPath, }); const target = makeWorker("target", [targetSummary]); target.descriptor.rootActiveSessionId = "target-active"; @@ -324,7 +388,7 @@ describe("daemon supervisor whole-tree eviction", () => { data: { deliveryStatus: "delivered" }, }); supervisor.workers.set("source", source); - supervisor.catalog.resolve = vi.fn(async () => "/tmp/target.jsonl"); + supervisor.catalog.resolve = vi.fn(async () => targetPath); supervisor.createOrReuseWorker = vi.fn(async () => target); const client = { id: "sender", attachedActiveSessionIds: new Set() }; @@ -339,7 +403,12 @@ describe("daemon supervisor whole-tree eviction", () => { expect(supervisor.catalog.resolve).toHaveBeenCalledWith("target-session", "/tmp/project", "/tmp/custom-sessions"); expect(supervisor.createOrReuseWorker).toHaveBeenCalledWith( "sender", - expect.objectContaining({ type: "create", sessionPath: "/tmp/target.jsonl", continueRecent: false }), + expect.objectContaining({ + type: "create", + sessionPath: targetPath, + continueRecent: false, + config: { sessionDir: "/tmp/custom-sessions" }, + }), ); expect(target.client?.requestWorker).toHaveBeenCalledWith( expect.objectContaining({ @@ -441,4 +510,323 @@ describe("daemon supervisor whole-tree eviction", () => { ).rejects.toThrow('Ambiguous session selector "target"'); expect(supervisor.createOrReuseWorker).not.toHaveBeenCalled(); }); + + it("captures every inactive descendant for cross-worker sibling authorization", async () => { + const supervisor = makeSupervisor(); + const timestamp = new Date("2026-08-01T12:00:00.000Z"); + const catalog = [ + { + id: "root", + path: "/tmp/root.jsonl", + cwd: "/tmp", + rlmDepth: 0, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + }, + { + id: "middle", + path: "/tmp/middle.jsonl", + cwd: "/tmp", + parentSessionPath: "/tmp/root.jsonl", + rlmDepth: 1, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + }, + { + id: "first", + path: "/tmp/first.jsonl", + cwd: "/tmp", + parentSessionPath: "/tmp/middle.jsonl", + rlmDepth: 2, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + }, + { + id: "second", + path: "/tmp/second.jsonl", + cwd: "/tmp", + parentSessionPath: "/tmp/middle.jsonl", + rlmDepth: 2, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + }, + ]; + supervisor.catalog.list = vi.fn(async () => catalog); + Object.assign(supervisor.catalog, { family: vi.fn(async () => catalog) }); + const entries = await supervisor.familyCatalogEntries("/tmp/custom-sessions"); + expect(supervisor.catalog.list).toHaveBeenCalledWith(undefined, "/tmp/custom-sessions"); + expect(supervisor.catalog.family).toHaveBeenCalledWith("/tmp/custom-sessions"); + expect(entries.map((entry) => entry.id)).toEqual(["root", "middle", "first", "second"]); + const { assertAgentFamilyReach } = await import("../src/core/agent-messages.js"); + expect( + assertAgentFamilyReach( + entries.find((entry) => entry.id === "first")!, + entries.find((entry) => entry.id === "second")!, + entries, + ), + ).toBe("sibling"); + }); + + it("uses the source worker session directory for agent-origin family snapshots", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const source = makeWorker("source", [makeSummary("source-active", now, { sessionId: "source" })]); + source.descriptor.createCommand.config = { sessionDir: "/tmp/custom-sessions" }; + const target = makeWorker("target", [makeSummary("target-active", now, { sessionId: "target" })]); + target.client!.requestWorker.mockResolvedValue({ + type: "response", + command: "worker_deliver_message", + success: true, + data: { deliveryStatus: "delivered" }, + }); + supervisor.workers.set("source", source); + supervisor.workers.set("target", target); + const familyCatalogEntries = vi.fn(async () => + Object.freeze([ + { id: "root", depth: 0, status: "inactive" as const }, + { id: "source", depth: 1, status: "running" as const, parentSessionId: "root" }, + { id: "target", depth: 1, status: "running" as const, parentSessionId: "root" }, + ]), + ); + Object.assign(supervisor, { familyCatalogEntries }); + + await expect( + supervisor.handleCommand( + { id: "sender" }, + { + id: "custom-root", + type: "send_message", + agentOrigin: true, + fromActiveSessionId: "source-active", + targetActiveSessionId: "target-active", + message: "deliver", + }, + ), + ).resolves.toMatchObject({ success: true }); + expect(familyCatalogEntries).toHaveBeenCalledWith("/tmp/custom-sessions"); + }); + + it("rejects active topology that conflicts with the persisted row before remote delivery", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const sourceSummary = makeSummary("source-active", now, { + sessionId: "source", + rlmDepth: 1, + parentSessionPath: "/tmp/root.jsonl", + }); + const targetSummary = makeSummary("target-active", now, { + sessionId: "target", + sessionFile: "/tmp/target.jsonl", + rlmDepth: 1, + parentSessionPath: "/tmp/forged-parent.jsonl", + }); + const source = makeWorker("source", [sourceSummary]); + const target = makeWorker("target", [targetSummary]); + supervisor.workers.set("source", source); + supervisor.workers.set("target", target); + const timestamp = new Date(now); + const catalog = [ + { + id: "root", + path: "/tmp/root.jsonl", + cwd: "/tmp", + rlmDepth: 0, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + }, + { + id: "target", + path: "/tmp/target.jsonl", + cwd: "/tmp", + parentSessionPath: "/tmp/root.jsonl", + rlmDepth: 1, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + }, + ]; + supervisor.catalog.list = vi.fn(async () => catalog); + Object.assign(supervisor.catalog, { family: vi.fn(async () => catalog) }); + + await expect( + supervisor.handleCommand( + { id: "sender" }, + { + id: "persisted-conflict", + type: "send_message", + agentOrigin: true, + fromActiveSessionId: "source-active", + targetActiveSessionId: "target-active", + message: "deny", + }, + ), + ).rejects.toThrow("Agent reach is limited to parent, siblings, and children"); + expect(target.client?.requestWorker).not.toHaveBeenCalled(); + }); + + it("uses only the source custom directory when denying a conflicting family topology", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const sourceSummary = makeSummary("source-active", now, { + sessionId: "source", + rlmDepth: 1, + parentSessionPath: "/tmp/custom-sessions/root.jsonl", + }); + const targetSummary = makeSummary("target-active", now, { + sessionId: "target", + sessionFile: "/tmp/custom-sessions/target.jsonl", + rlmDepth: 1, + parentSessionPath: "/tmp/custom-sessions/forged-parent.jsonl", + }); + const source = makeWorker("source", [sourceSummary]); + source.descriptor.createCommand.config = { sessionDir: "/tmp/custom-sessions" }; + const target = makeWorker("target", [targetSummary]); + supervisor.workers.set("source", source); + supervisor.workers.set("target", target); + const timestamp = new Date(now); + const catalog = [ + { + id: "root", + path: "/tmp/custom-sessions/root.jsonl", + cwd: "/tmp", + rlmDepth: 0, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + allMessagesText: "", + }, + { + id: "target", + path: "/tmp/custom-sessions/target.jsonl", + cwd: "/tmp", + parentSessionPath: "/tmp/custom-sessions/root.jsonl", + rlmDepth: 1, + created: timestamp, + modified: timestamp, + messageCount: 0, + firstMessage: "", + allMessagesText: "", + }, + ]; + supervisor.catalog.list = vi.fn(async () => catalog); + supervisor.catalog.family = vi.fn(async () => catalog); + + await expect( + supervisor.handleCommand( + { id: "sender" }, + { + id: "custom-persisted-conflict", + type: "send_message", + agentOrigin: true, + fromActiveSessionId: "source-active", + targetActiveSessionId: "target-active", + message: "deny", + }, + ), + ).rejects.toThrow("Agent reach is limited to parent, siblings, and children"); + expect(supervisor.catalog.list).toHaveBeenCalledWith(undefined, "/tmp/custom-sessions"); + expect(supervisor.catalog.family).toHaveBeenCalledWith("/tmp/custom-sessions"); + expect(target.client?.requestWorker).not.toHaveBeenCalled(); + }); + + it("rejects duplicate snapshot identities before cross-worker delivery", async () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const supervisor = makeSupervisor(); + const sourceSummary = makeSummary("source-active", now, { sessionId: "source" }); + const targetSummary = makeSummary("target-active", now, { sessionId: "target" }); + const source = makeWorker("source", [sourceSummary]); + const target = makeWorker("target", [targetSummary]); + supervisor.workers.set("source", source); + supervisor.workers.set("target", target); + Object.assign(supervisor, { + familyCatalogEntries: vi.fn(async () => + Object.freeze([ + { id: "root", depth: 0, status: "inactive" as const }, + { id: "source", depth: 1, status: "running" as const, parentSessionId: "root" }, + { id: "target", depth: 1, status: "running" as const, parentSessionId: "root" }, + { id: "target", depth: 1, status: "running" as const, parentSessionId: "forged" }, + ]), + ), + }); + await expect( + supervisor.handleCommand( + { id: "sender" }, + { + id: "duplicate", + type: "send_message", + agentOrigin: true, + fromActiveSessionId: "source-active", + targetActiveSessionId: "target-active", + message: "deny", + }, + ), + ).rejects.toThrow("Agent reach is limited to parent, siblings, and children"); + expect(target.client?.requestWorker).not.toHaveBeenCalled(); + }); + + it("rejects a postwake session substitution without delivery", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-supervisor-postwake-substitution-")); + tempDirs.push(directory); + const parentManager = SessionManager.create(directory, join(directory, "sessions")); + parentManager.newSession({ rlmDepth: 0 }); + parentManager.flushNow(); + const parentPath = parentManager.getSessionFile(); + if (!parentPath) throw new Error("Missing parent session path"); + const targetManager = SessionManager.create(directory, join(directory, "sessions")); + targetManager.newSession({ parentSession: parentPath, rlmDepth: 1 }); + targetManager.flushNow(); + const targetPath = targetManager.getSessionFile(); + if (!targetPath) throw new Error("Missing target session path"); + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const sourceSummary = makeSummary("source-active", now, { sessionId: "source" }); + const source = makeWorker("source", [sourceSummary]); + const substituted = makeSummary("woken-active", now, { sessionId: "substitute", sessionFile: targetPath }); + const woken = makeWorker("woken", [substituted]); + const supervisor = makeSupervisor(); + supervisor.workers.set("source", source); + supervisor.catalog.resolve = vi.fn(async () => targetPath); + supervisor.createOrReuseWorker = vi.fn(async () => woken); + Object.assign(supervisor, { + familyCatalogEntries: vi.fn(async () => + Object.freeze([ + { id: parentManager.getSessionId(), depth: 0, status: "inactive" as const, sessionPath: parentPath }, + { id: "source", depth: 1, status: "running" as const, parentSessionId: parentManager.getSessionId() }, + { + id: targetManager.getSessionId(), + depth: 1, + status: "inactive" as const, + parentSessionPath: parentPath, + sessionPath: targetPath, + }, + ]), + ), + }); + await expect( + supervisor.handleCommand( + { id: "sender" }, + { + id: "substitution", + type: "send_message", + agentOrigin: true, + fromActiveSessionId: "source-active", + targetActiveSessionId: targetManager.getSessionId(), + message: "deny", + }, + ), + ).rejects.toThrow("Agent reach is limited to parent, siblings, and children"); + expect(supervisor.createOrReuseWorker).toHaveBeenCalledOnce(); + expect(woken.client?.requestWorker).not.toHaveBeenCalled(); + }); }); diff --git a/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts b/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts index 497c7e017..a7b96197a 100644 --- a/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts @@ -22,13 +22,14 @@ interface SupervisorInternals { clientId: string, command: { type: "create"; name?: string; sessionPath?: string }, ): Promise; - assertSupervisorSavedSessionNameAvailable(sessionPath: string, name: string): Promise; + assertSupervisorSavedSessionNameAvailable(sessionPath: string, name: string, sessionDir?: string): Promise; assertSavedSiblingNameAvailable( siblings: Array>, target: Record, name: string, ): void; familyCatalogEntry(summary: SessionSummary): AgentFamilyCatalogEntry; + familyCatalogEntries(): Promise; handleCommand(client: object, command: Record): Promise; } @@ -41,7 +42,7 @@ interface WorkerFixture { pid: number; authenticationToken: string; ownerClientId?: string; - createCommand: { config: { cwd: string } }; + createCommand: { config: { cwd: string; sessionDir?: string } }; }; client: { request: ReturnType; @@ -491,6 +492,7 @@ describe("daemon supervisor passive subagent topology", () => { releaseRename = resolve; }); const firstWorker = worker("first", [firstSummary]); + firstWorker.descriptor.createCommand.config.sessionDir = join(directory, "custom-sessions"); firstWorker.client.request.mockImplementation(async () => { await renameGate; return success(undefined, "rename_saved_session", firstSummary); @@ -503,10 +505,12 @@ describe("daemon supervisor passive subagent topology", () => { }) as unknown as SupervisorInternals; supervisor.workers.set("first", firstWorker); supervisor.workers.set("second", secondWorker); + const siblings = vi.fn(async () => []); + const list = vi.fn(async () => []); Object.assign(supervisor, { catalog: { - siblings: vi.fn(async () => []), - list: vi.fn(async () => []), + siblings, + list, }, }); const client = { id: "client", attachedActiveSessionIds: new Set() }; @@ -534,6 +538,8 @@ describe("daemon supervisor passive subagent topology", () => { expect(secondWorker.client.request).not.toHaveBeenCalled(); releaseRename(); await expect(first).resolves.toMatchObject({ success: true }); + expect(siblings).not.toHaveBeenCalled(); + expect(list).toHaveBeenCalledWith(undefined, join(directory, "custom-sessions")); }); it("serializes same-scope inactive renames across catalog validation and commit", async () => { @@ -563,9 +569,10 @@ describe("daemon supervisor passive subagent topology", () => { defaultSessionConfig: { agentDir: directory, cwd: directory }, descriptorDir: join(directory, "workers"), }) as unknown as SupervisorInternals; + const siblings = vi.fn(async () => saved); Object.assign(supervisor, { catalog: { - siblings: vi.fn(async () => saved), + siblings, rename, }, }); @@ -575,6 +582,7 @@ describe("daemon supervisor passive subagent topology", () => { type: "rename_saved_session", sessionPath: firstPath, name: "shared", + sessionDir: join(directory, "custom-sessions"), }); await vi.waitFor(() => expect(rename).toHaveBeenCalledOnce()); await expect( @@ -582,10 +590,12 @@ describe("daemon supervisor passive subagent topology", () => { type: "rename_saved_session", sessionPath: secondPath, name: "shared", + sessionDir: join(directory, "custom-sessions"), }), ).rejects.toThrow("an agent of that name already exists at depth 1 under this parent"); releaseRename(); await expect(first).resolves.toMatchObject({ success: true }); + expect(siblings).toHaveBeenCalledWith(firstPath, join(directory, "custom-sessions")); }); it("reserves named child creates by parent scope until worker launch completes", async () => { @@ -644,6 +654,156 @@ describe("daemon supervisor passive subagent topology", () => { await expect(first).resolves.toBe(launched); }); + it("authorizes live depth-two siblings through an artifact-resident parent across workers", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-supervisor-artifact-family-reach-")); + tempDirs.push(directory); + const parentPath = join(directory, "parent.jsonl"); + const parent = { + id: "artifact-parent", + path: parentPath, + cwd: directory, + created: new Date(0), + modified: new Date(0), + messageCount: 0, + firstMessage: "", + allMessagesText: "", + rlmDepth: 1, + }; + const source = summary({ + id: "source-active", + activeSessionId: "source-active", + sessionId: "source-session", + runtimeKind: "subagent", + rlmDepth: 2, + parentSessionId: parent.id, + }); + const target = summary({ + id: "target-active", + activeSessionId: "target-active", + sessionId: "target-session", + runtimeKind: "subagent", + rlmDepth: 2, + parentSessionId: parent.id, + }); + const sourceWorker = worker("source", [source]); + const targetWorker = worker("target", [target]); + targetWorker.client.requestWorker.mockResolvedValue({ + type: "response", + command: "worker_deliver_message", + success: true, + } as never); + const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory }, + descriptorDir: join(directory, "workers"), + }) as unknown as SupervisorInternals; + supervisor.workers.set("source", sourceWorker); + supervisor.workers.set("target", targetWorker); + Object.assign(supervisor, { catalog: { list: vi.fn(async () => []), family: vi.fn(async () => [parent]) } }); + + await expect( + supervisor.handleCommand( + { id: "client", attachedActiveSessionIds: new Set() }, + { + id: "message", + type: "send_message", + agentOrigin: true, + fromActiveSessionId: source.activeSessionId, + targetActiveSessionId: target.activeSessionId, + message: "hello sibling", + }, + ), + ).resolves.toMatchObject({ success: true }); + expect(targetWorker.client.requestWorker).toHaveBeenCalledWith( + expect.objectContaining({ type: "worker_deliver_message", targetActiveSessionId: target.activeSessionId }), + expect.any(Number), + ); + }); + + it.each([ + ["missing", []], + [ + "malformed", + [ + { + id: "artifact-parent", + path: join(tmpdir(), "malformed.jsonl"), + cwd: "", + created: new Date(0), + modified: new Date(0), + messageCount: 0, + firstMessage: "", + allMessagesText: "", + rlmDepth: 0, + }, + ], + ], + [ + "conflicting", + [ + { + id: "artifact-parent", + path: join(tmpdir(), "first-parent.jsonl"), + cwd: "", + created: new Date(0), + modified: new Date(0), + messageCount: 0, + firstMessage: "", + allMessagesText: "", + rlmDepth: 1, + }, + { + id: "artifact-parent", + path: join(tmpdir(), "second-parent.jsonl"), + cwd: "", + created: new Date(0), + modified: new Date(0), + messageCount: 0, + firstMessage: "", + allMessagesText: "", + rlmDepth: 1, + }, + ], + ], + ] as const)("denies live depth-two siblings when the artifact parent is %s", async (_kind, parents) => { + const directory = mkdtempSync(join(tmpdir(), "prime-supervisor-artifact-family-deny-")); + tempDirs.push(directory); + const child = (id: string) => + summary({ + id: `${id}-active`, + activeSessionId: `${id}-active`, + sessionId: `${id}-session`, + runtimeKind: "subagent", + rlmDepth: 2, + parentSessionId: "artifact-parent", + }); + const source = child("source"); + const target = child("target"); + const sourceWorker = worker("source", [source]); + const targetWorker = worker("target", [target]); + const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory }, + descriptorDir: join(directory, "workers"), + }) as unknown as SupervisorInternals; + supervisor.workers.set("source", sourceWorker); + supervisor.workers.set("target", targetWorker); + Object.assign(supervisor, { catalog: { list: vi.fn(async () => []), family: vi.fn(async () => parents) } }); + + await expect( + supervisor.handleCommand( + { id: "client", attachedActiveSessionIds: new Set() }, + { + id: "message", + type: "send_message", + agentOrigin: true, + fromActiveSessionId: source.activeSessionId, + targetActiveSessionId: target.activeSessionId, + message: "hello sibling", + }, + ), + ).rejects.toThrow("Agent reach is limited to parent, siblings, and children"); + expect(targetWorker.client.requestWorker).not.toHaveBeenCalled(); + }); + it("retains passive worker summaries but syncs only roots to cross-worker peer maps", async () => { const directory = mkdtempSync(join(tmpdir(), "prime-supervisor-passive-peers-")); tempDirs.push(directory); diff --git a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts index 03bb76264..9eb6f0002 100644 --- a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts @@ -1454,6 +1454,83 @@ describe("daemon worker supervisor monitoring", () => { await stopping; }); + it("keeps a root kill registration through a synchronous shutdown event until exact cleanup", async () => { + const worker = { + descriptor: { + workerId: "worker-root-kill", + pid: 123_456, + processStartId: "proc:entry", + rootActiveSessionId: "root-active", + lifecycle: "ready" as const, + }, + summaries: new Map([ + [ + "root-active", + { id: "root-active", sessionId: "root-session", activeSessionId: "root-active" } as SessionSummary, + ], + ]), + snapshotCache: new Map(), + transcriptCaches: new Map(), + snapshotGenerations: new Map(), + snapshotLoads: new Map(), + intentionalStop: false, + stopRevision: 0, + }; + const workers = new Map([[worker.descriptor.workerId, worker]]); + const deleteWorkerDescriptor = vi.fn(); + const stopWorkerUntracked = vi.fn(async (target: typeof worker, removeDescriptor: boolean) => { + // The root-kill ownership and this exact stop are both active here. + expect(supervisor.workerStopCounts.get(target)).toBe(2); + expect(workers.get(target.descriptor.workerId)).toBe(target); + workers.delete(target.descriptor.workerId); + if (removeDescriptor) deleteWorkerDescriptor(target); + }); + const supervisor = Object.assign(Object.create(DaemonSupervisor.prototype), { + workers, + workerStopCounts: new Map(), + clients: new Set(), + shuttingDown: false, + streamReconstructor: { observe: vi.fn() }, + invalidateWorkerSnapshot: vi.fn(), + refreshWorkerSummaries: vi.fn(async () => undefined), + syncAgentPeers: vi.fn(async () => undefined), + persistWorkerStopTombstone: vi.fn(), + deleteWorkerDescriptor, + broadcastHeartbeatsChanged: vi.fn(), + findWorkerForClient: vi.fn(async () => ({ + worker, + summary: worker.summaries.get("root-active"), + })), + forwardToWorker: vi.fn(async () => { + supervisor.handleWorkerFrame(worker, { + header: { kind: "outbound", outboundType: "session_closed", activeSessionId: "root-active" }, + payload: Buffer.from(JSON.stringify({ type: "session_closed", reason: "shutdown" })), + }); + // The event arrives before the forwarded kill resolves. + expect(workers.get(worker.descriptor.workerId)).toBe(worker); + expect(deleteWorkerDescriptor).not.toHaveBeenCalled(); + return success(undefined, "kill"); + }), + stopWorkerUntracked, + }) as { + workers: typeof workers; + workerStopCounts: Map; + handleCommand( + client: DaemonSocketClient, + command: { type: "kill"; activeSessionId: string }, + ): Promise; + handleWorkerFrame(target: typeof worker, frame: PrivateFrame): void; + }; + + await expect( + supervisor.handleCommand({} as DaemonSocketClient, { type: "kill", activeSessionId: "root-active" }), + ).resolves.toEqual(success(undefined, "kill")); + expect(stopWorkerUntracked).toHaveBeenCalledWith(worker, true, false, true, false, undefined); + expect(workers.has(worker.descriptor.workerId)).toBe(false); + expect(deleteWorkerDescriptor).toHaveBeenCalledWith(worker); + expect(supervisor.workerStopCounts.has(worker)).toBe(false); + }); + it("cancels an in-flight recovery after an intentional stop tombstone", async () => { vi.useFakeTimers(); type RecoveryWorker = { diff --git a/packages/coding-agent/test/host-request-context.ts b/packages/coding-agent/test/host-request-context.ts new file mode 100644 index 000000000..ec8e918e7 --- /dev/null +++ b/packages/coding-agent/test/host-request-context.ts @@ -0,0 +1,83 @@ +import { randomUUID } from "node:crypto"; +import { + contextAwareHostRequestHandler, + createHostRequestHandler, + type HostRequestContext, + type HostRequestHandlerImplementation, + KernelManager, +} from "../src/core/kernel/index.js"; + +/** Raw implementations are retained only for test-created business-unit fixtures. */ +const testHostHandlerImplementations = new WeakMap< + HostRequestHandlerImplementation, + HostRequestHandlerImplementation +>(); + +/** + * Calls a raw test fixture with synthetic context. This deliberately bypasses the + * production wrapper and never registers context identity with production authority. + */ +export async function invokeHostRequestHandlerForTest( + handler: HostRequestHandlerImplementation, + payload: Record, +): Promise> { + const implementation = testHostHandlerImplementations.get(handler); + if (!implementation) { + throw new Error("host request handler has no test-local raw implementation"); + } + const controller = new AbortController(); + const context: HostRequestContext = { + requestId: randomUUID(), + generation: 0, + signal: controller.signal, + isCurrent: () => !controller.signal.aborted, + }; + try { + return await implementation(payload, context); + } finally { + controller.abort(); + } +} + +/** + * Exercises a production capability only through the KernelManager's private + * dispatcher, preserving its authority and revocation boundary in integration tests. + */ +export async function invokeHostRequestThroughKernelForTest( + handler: HostRequestHandlerImplementation, + payload: Record, +): Promise> { + const manager = new KernelManager({ cwd: process.cwd(), hostHandlers: { test: handler } }); + const replies: Record[] = []; + const internals = manager as unknown as { + startHostRequestFromComm: (commId: string, data: unknown) => void; + sendCommMessage: (commId: string, data: Record) => Promise; + inFlightHostRequests: Set>; + }; + internals.sendCommMessage = async (_commId, data) => { + replies.push(data); + }; + internals.startHostRequestFromComm("test-host-request", { type: "test", ...payload }); + await Promise.allSettled([...internals.inFlightHostRequests]); + const reply = replies[0]; + if (!reply) throw new Error("test host request did not produce a reply"); + if (reply.status === "error") throw new Error(String(reply.error)); + const { status: _status, ...result } = reply; + return result; +} + +/** Build factory-minted handlers for test fixtures while retaining raw implementations locally. */ +export function createTestHostHandlers< + T extends Record< + string, + (payload: Record, context: HostRequestContext) => Promise> + >, +>(handlers: T): Record { + return Object.fromEntries( + Object.entries(handlers).map(([type, implementation]) => { + const handler = createHostRequestHandler(implementation, contextAwareHostRequestHandler); + testHostHandlerImplementations.set(handler, implementation); + return [type, handler]; + }), + ) as unknown as Record; +} diff --git a/packages/coding-agent/test/host-request-contract.test.ts b/packages/coding-agent/test/host-request-contract.test.ts new file mode 100644 index 000000000..64265b02f --- /dev/null +++ b/packages/coding-agent/test/host-request-contract.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + assertHostRequestHandler, + contextAwareHostRequestHandler, + createHostRequestHandler, + type HostRequestContext, +} from "../src/core/kernel/index.js"; +import { invokeHostRequestThroughKernelForTest as invokeHostRequestHandlerForTest } from "./host-request-context.js"; + +describe("staged host-request handler authority", () => { + it("rejects unary factory inputs before they run", () => { + let calls = 0; + const unary = async (_payload: Record): Promise> => { + calls += 1; + return {}; + }; + + expect(() => { + // @ts-expect-error Context-aware registration needs an explicit marker. + createHostRequestHandler(unary); + }).toThrow("host request handlers require the context-aware marker"); + expect(calls).toBe(0); + }); + + it("rejects missing or invalid context before a genuine handler runs", async () => { + let calls = 0; + const handler = createHostRequestHandler(async (_payload, _context) => { + calls += 1; + return {}; + }, contextAwareHostRequestHandler); + + assertHostRequestHandler(handler); + await expect(handler({}, undefined as unknown as HostRequestContext)).rejects.toThrow( + "host request context is invalid", + ); + await expect(handler({}, {} as HostRequestContext)).rejects.toThrow("host request context is invalid"); + await expect(invokeHostRequestHandlerForTest(handler, {})).resolves.toEqual({}); + expect(calls).toBe(1); + }); + + it("rejects copied-symbol forgeries through WeakSet provenance before they run", () => { + const genuine = createHostRequestHandler(async (_payload, _context) => ({}), contextAwareHostRequestHandler); + let calls = 0; + const forged = async (): Promise> => { + calls += 1; + return {}; + }; + const brand = Object.getOwnPropertySymbols(genuine)[0]; + Object.defineProperty(forged, brand, Object.getOwnPropertyDescriptor(genuine, brand)!); + + expect(() => assertHostRequestHandler(forged)).toThrow( + "host request handler is not a dispatcher-created capability", + ); + expect(calls).toBe(0); + }); + + it("uses explicit marker rather than implementation length for rest and default handlers", async () => { + const rest = createHostRequestHandler( + async (...args: [Record, HostRequestContext]) => ({ + requestId: args[1].requestId, + }), + contextAwareHostRequestHandler, + ); + const defaulted = createHostRequestHandler( + async (_payload, context = undefined as unknown as HostRequestContext) => ({ + generation: context.generation, + }), + contextAwareHostRequestHandler, + ); + expect((await invokeHostRequestHandlerForTest(rest, {})).requestId).toEqual(expect.any(String)); + expect((await invokeHostRequestHandlerForTest(defaulted, {})).generation).toBe(1); + }); + + it("does not accept a structural or payload-supplied context", async () => { + let calls = 0; + const handler = createHostRequestHandler(async (_payload, _context) => { + calls += 1; + return {}; + }, contextAwareHostRequestHandler); + const fabricated = { + requestId: "python", + generation: 1, + signal: new AbortController().signal, + isCurrent: () => true, + }; + await expect(handler({ context: fabricated }, fabricated)).rejects.toThrow("host request context is invalid"); + expect(calls).toBe(0); + }); +}); diff --git a/packages/coding-agent/test/kernel-abort.test.ts b/packages/coding-agent/test/kernel-abort.test.ts index 91d544cd1..6e1d51b78 100644 --- a/packages/coding-agent/test/kernel-abort.test.ts +++ b/packages/coding-agent/test/kernel-abort.test.ts @@ -1,5 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { AGENT_MESSAGE_DISPLAY_MIME, KernelManager, type KernelSentAgentMessage } from "../src/core/kernel/index.js"; +import { + AGENT_MESSAGE_DISPLAY_MIME, + contextAwareHostRequestHandler, + createHostRequestHandler, + type HostRequestContext, + KernelManager, + type KernelSentAgentMessage, +} from "../src/core/kernel/index.js"; async function waitForCalls(mock: { mock: { calls: unknown[][] } }, count: number): Promise { for (let i = 0; i < 20; i++) { @@ -250,6 +257,408 @@ describe("KernelManager abort handling", () => { expect(controlSend).toHaveBeenCalled(); }); + it("revokes a settled host-request context before a retained wrapper can replay it", async () => { + let capturedContext: HostRequestContext | undefined; + const implementation = vi.fn(async (_payload: Record, context: HostRequestContext) => { + capturedContext = context; + return { ok: true }; + }); + const handler = createHostRequestHandler(implementation, contextAwareHostRequestHandler); + const manager = new KernelManager({ cwd: process.cwd(), hostHandlers: { test: handler } }); + const internals = manager as unknown as { + state: "running"; + connection: { key: string }; + shell: { send: () => Promise; close: () => void }; + handleCommMessage: (message: { header: { msg_type: string }; content: Record }) => void; + sendCommMessage: (_commId: string, data: Record) => Promise; + inFlightHostRequests: Set>; + }; + internals.state = "running"; + internals.connection = { key: "test" }; + internals.shell = { send: async () => {}, close: () => {} }; + internals.sendCommMessage = async () => {}; + internals.handleCommMessage({ + header: { msg_type: "comm_open" }, + content: { comm_id: "request", target_name: "host.request", data: { type: "test" } }, + }); + await vi.waitFor(() => expect(capturedContext).toBeDefined()); + await Promise.allSettled([...internals.inFlightHostRequests]); + expect(capturedContext?.signal.aborted).toBe(true); + if (!capturedContext) throw new Error("Expected genuine host request context"); + await expect(handler({ type: "test" }, capturedContext)).rejects.toThrow("host request context is invalid"); + expect(implementation).toHaveBeenCalledTimes(1); + manager.disposeSync(); + }); + + it("revokes a comm-closed host-request context before a retained wrapper can replay it", async () => { + let capturedContext: HostRequestContext | undefined; + let resolveHandler: (() => void) | undefined; + const implementation = vi.fn(async (_payload: Record, context: HostRequestContext) => { + capturedContext = context; + await new Promise((resolve) => { + resolveHandler = resolve; + }); + return { ok: true }; + }); + const handler = createHostRequestHandler(implementation, contextAwareHostRequestHandler); + const manager = new KernelManager({ cwd: process.cwd(), hostHandlers: { test: handler } }); + const internals = manager as unknown as { + state: "running"; + connection: { key: string }; + shell: { send: () => Promise; close: () => void }; + handleCommMessage: (message: { header: { msg_type: string }; content: Record }) => void; + sendCommMessage: (_commId: string, data: Record) => Promise; + inFlightHostRequests: Set>; + }; + internals.state = "running"; + internals.connection = { key: "test" }; + internals.shell = { send: async () => {}, close: () => {} }; + internals.sendCommMessage = async () => {}; + internals.handleCommMessage({ + header: { msg_type: "comm_open" }, + content: { comm_id: "request", target_name: "host.request", data: { type: "test" } }, + }); + await vi.waitFor(() => expect(capturedContext).toBeDefined()); + internals.handleCommMessage({ header: { msg_type: "comm_close" }, content: { comm_id: "request" } }); + expect(capturedContext?.signal.aborted).toBe(true); + if (!capturedContext) throw new Error("Expected genuine host request context"); + await expect(handler({ type: "test" }, capturedContext)).rejects.toThrow("host request context is invalid"); + expect(implementation).toHaveBeenCalledTimes(1); + resolveHandler?.(); + await Promise.allSettled([...internals.inFlightHostRequests]); + manager.disposeSync(); + }); + + it("revokes host-request authority on comm close and never reads context from payload", async () => { + let contextSignal: AbortSignal | undefined; + let resolveHandler: (() => void) | undefined; + const handler = createHostRequestHandler(async (_payload, context) => { + contextSignal = context.signal; + await new Promise((resolve) => { + resolveHandler = resolve; + }); + return { current: context.isCurrent() }; + }, contextAwareHostRequestHandler); + const manager = new KernelManager({ cwd: process.cwd(), hostHandlers: { test: handler } }); + const sent: Record[] = []; + const internals = manager as unknown as { + state: "running"; + connection: { key: string }; + shell: { send: () => Promise; close: () => void }; + handleCommMessage: (message: { header: { msg_type: string }; content: Record }) => void; + sendCommMessage: (_commId: string, data: Record) => Promise; + inFlightHostRequests: Set>; + }; + internals.state = "running"; + internals.connection = { key: "test" }; + internals.shell = { send: async () => {}, close: () => {} }; + internals.sendCommMessage = async (_commId, data) => { + sent.push(data); + }; + internals.handleCommMessage({ + header: { msg_type: "comm_open" }, + content: { + comm_id: "request", + target_name: "host.request", + data: { type: "test", context: { forged: true } }, + }, + }); + await vi.waitFor(() => expect(contextSignal).toBeDefined()); + expect(contextSignal).toBeDefined(); + expect(contextSignal?.aborted).toBe(false); + internals.handleCommMessage({ header: { msg_type: "comm_close" }, content: { comm_id: "request" } }); + expect(contextSignal?.aborted).toBe(true); + resolveHandler?.(); + await Promise.allSettled([...internals.inFlightHostRequests]); + expect(sent).toEqual([]); + manager.disposeSync(); + }); + + it("does not send a stale error to a reopened comm with the same id", async () => { + let firstContext: HostRequestContext | undefined; + let releaseFirst: (() => void) | undefined; + const handler = createHostRequestHandler(async (_payload, context) => { + if (!firstContext) { + firstContext = context; + await new Promise((resolve) => { + releaseFirst = resolve; + }); + throw new Error("first request failed after close"); + } + return { request: "second" }; + }, contextAwareHostRequestHandler); + const manager = new KernelManager({ cwd: process.cwd(), hostHandlers: { test: handler } }); + const sent: Record[] = []; + const internals = manager as unknown as { + state: "running"; + connection: { key: string }; + shell: { send: () => Promise; close: () => void }; + handleCommMessage: (message: { header: { msg_type: string }; content: Record }) => void; + sendCommMessage: (_commId: string, data: Record) => Promise; + inFlightHostRequests: Set>; + }; + internals.state = "running"; + internals.connection = { key: "test" }; + internals.shell = { send: async () => {}, close: () => {} }; + internals.sendCommMessage = async (_commId, data) => { + sent.push(data); + }; + internals.handleCommMessage({ + header: { msg_type: "comm_open" }, + content: { comm_id: "reused", target_name: "host.request", data: { type: "test" } }, + }); + await vi.waitFor(() => expect(firstContext).toBeDefined()); + internals.handleCommMessage({ header: { msg_type: "comm_close" }, content: { comm_id: "reused" } }); + internals.handleCommMessage({ + header: { msg_type: "comm_open" }, + content: { comm_id: "reused", target_name: "host.request", data: { type: "test" } }, + }); + await vi.waitFor(() => expect(sent).toContainEqual({ status: "ok", request: "second" })); + releaseFirst?.(); + await Promise.allSettled([...internals.inFlightHostRequests]); + expect(sent).toEqual([{ status: "ok", request: "second" }]); + manager.disposeSync(); + }); + + it("sends one error reply when a current host request throws", async () => { + const handler = createHostRequestHandler(async () => { + throw new Error("handler failed"); + }, contextAwareHostRequestHandler); + const manager = new KernelManager({ cwd: process.cwd(), hostHandlers: { test: handler } }); + const sent: Record[] = []; + const internals = manager as unknown as { + state: "running"; + connection: { key: string }; + shell: { send: () => Promise; close: () => void }; + handleCommMessage: (message: { header: { msg_type: string }; content: Record }) => void; + sendCommMessage: (_commId: string, data: Record) => Promise; + inFlightHostRequests: Set>; + }; + internals.state = "running"; + internals.connection = { key: "test" }; + internals.shell = { send: async () => {}, close: () => {} }; + internals.sendCommMessage = async (_commId, data) => { + sent.push(data); + }; + internals.handleCommMessage({ + header: { msg_type: "comm_open" }, + content: { comm_id: "current", target_name: "host.request", data: { type: "test" } }, + }); + await Promise.allSettled([...internals.inFlightHostRequests]); + expect(sent).toEqual([{ status: "error", error: "handler failed" }]); + manager.disposeSync(); + }); + + it("does not report handler failure or send an error reply when the ok reply fails", async () => { + const implementation = vi.fn(async () => ({ completed: true })); + const handler = createHostRequestHandler(implementation, contextAwareHostRequestHandler); + const manager = new KernelManager({ cwd: process.cwd(), hostHandlers: { test: handler } }); + const sendCommMessage = vi.fn(async () => { + throw new Error("reply send failed"); + }); + const internals = manager as unknown as { + state: "running"; + connection: { key: string }; + shell: { send: () => Promise; close: () => void }; + handleCommMessage: (message: { header: { msg_type: string }; content: Record }) => void; + sendCommMessage: typeof sendCommMessage; + inFlightHostRequests: Set>; + kernelStderr: string; + }; + internals.state = "running"; + internals.connection = { key: "test" }; + internals.shell = { send: async () => {}, close: () => {} }; + internals.sendCommMessage = sendCommMessage; + internals.handleCommMessage({ + header: { msg_type: "comm_open" }, + content: { comm_id: "ok-reply-fails", target_name: "host.request", data: { type: "test" } }, + }); + await Promise.allSettled([...internals.inFlightHostRequests]); + expect(implementation).toHaveBeenCalledTimes(1); + expect(sendCommMessage).toHaveBeenCalledTimes(1); + expect(sendCommMessage).toHaveBeenCalledWith("ok-reply-fails", { status: "ok", completed: true }); + expect(internals.kernelStderr).toContain( + "failed to send host request ok reply for comm ok-reply-fails: reply send failed", + ); + expect(internals.kernelStderr).not.toContain("host request failed for comm ok-reply-fails"); + manager.disposeSync(); + }); + + it("closes host-request admission before snapshot shutdown flushes", async () => { + let capturedContext: HostRequestContext | undefined; + let releaseHandler: (() => void) | undefined; + let releaseFlush: (() => void) | undefined; + let markFlushEntered: (() => void) | undefined; + const flushEntered = new Promise((resolve) => { + markFlushEntered = resolve; + }); + const implementation = vi.fn(async (_payload: Record, context: HostRequestContext) => { + capturedContext = context; + await new Promise((resolve) => { + releaseHandler = resolve; + }); + return { ok: true }; + }); + const handler = createHostRequestHandler(implementation, contextAwareHostRequestHandler); + const manager = new KernelManager({ cwd: process.cwd(), hostHandlers: { test: handler } }); + const internals = manager as unknown as { + state: "running"; + connection: { key: string }; + shell: { send: () => Promise; close: () => void }; + handleCommMessage: (message: { header: { msg_type: string }; content: Record }) => void; + flushSnapshotForDispose: () => Promise; + activeHostRequestControllers: Map; + handledHostRequestCommIds: Set; + inFlightHostRequests: Set>; + }; + Object.assign(internals, { + state: "running", + connection: { key: "test" }, + shell: { send: async () => {}, close: () => {} }, + flushSnapshotForDispose: async () => { + markFlushEntered?.(); + await new Promise((resolve) => { + releaseFlush = resolve; + }); + }, + }); + internals.handleCommMessage({ + header: { msg_type: "comm_open" }, + content: { comm_id: "existing", target_name: "host.request", data: { type: "test" } }, + }); + await vi.waitFor(() => expect(capturedContext).toBeDefined()); + + const shutdownPromise = manager.shutdown({ snapshot: true }); + await flushEntered; + expect(capturedContext?.signal.aborted).toBe(true); + internals.handleCommMessage({ + header: { msg_type: "comm_open" }, + content: { comm_id: "late", target_name: "host.request", data: { type: "test" } }, + }); + expect(implementation).toHaveBeenCalledTimes(1); + expect(internals.activeHostRequestControllers.has("late")).toBe(false); + expect(internals.handledHostRequestCommIds.has("late")).toBe(false); + + releaseFlush?.(); + await shutdownPromise; + releaseHandler?.(); + await Promise.allSettled([...internals.inFlightHostRequests]); + }); + + it("closes host-request admission before dispose flushes", async () => { + let capturedContext: HostRequestContext | undefined; + let releaseHandler: (() => void) | undefined; + let releaseFlush: (() => void) | undefined; + let markFlushEntered: (() => void) | undefined; + const flushEntered = new Promise((resolve) => { + markFlushEntered = resolve; + }); + const implementation = vi.fn(async (_payload: Record, context: HostRequestContext) => { + capturedContext = context; + await new Promise((resolve) => { + releaseHandler = resolve; + }); + return { ok: true }; + }); + const handler = createHostRequestHandler(implementation, contextAwareHostRequestHandler); + const manager = new KernelManager({ cwd: process.cwd(), hostHandlers: { test: handler } }); + const internals = manager as unknown as { + state: "running"; + connection: { key: string }; + shell: { send: () => Promise; close: () => void }; + handleCommMessage: (message: { header: { msg_type: string }; content: Record }) => void; + flushSnapshotForDispose: () => Promise; + activeHostRequestControllers: Map; + handledHostRequestCommIds: Set; + inFlightHostRequests: Set>; + }; + Object.assign(internals, { + state: "running", + connection: { key: "test" }, + shell: { send: async () => {}, close: () => {} }, + flushSnapshotForDispose: async () => { + markFlushEntered?.(); + await new Promise((resolve) => { + releaseFlush = resolve; + }); + }, + }); + internals.handleCommMessage({ + header: { msg_type: "comm_open" }, + content: { comm_id: "existing", target_name: "host.request", data: { type: "test" } }, + }); + await vi.waitFor(() => expect(capturedContext).toBeDefined()); + + const disposePromise = manager.dispose(); + await flushEntered; + expect(capturedContext?.signal.aborted).toBe(true); + internals.handleCommMessage({ + header: { msg_type: "comm_open" }, + content: { comm_id: "late", target_name: "host.request", data: { type: "test" } }, + }); + expect(implementation).toHaveBeenCalledTimes(1); + expect(internals.activeHostRequestControllers.has("late")).toBe(false); + expect(internals.handledHostRequestCommIds.has("late")).toBe(false); + + releaseFlush?.(); + releaseHandler?.(); + await disposePromise; + }); + + it("restart reopens host-request admission only after shutdown settles", async () => { + let capturedContext: HostRequestContext | undefined; + let releaseShutdown: (() => void) | undefined; + const shutdownSettled = new Promise((resolve) => { + releaseShutdown = resolve; + }); + const implementation = vi.fn(async (_payload: Record, context: HostRequestContext) => { + capturedContext = context; + return { ok: true }; + }); + const handler = createHostRequestHandler(implementation, contextAwareHostRequestHandler); + const manager = new KernelManager({ cwd: process.cwd(), hostHandlers: { test: handler } }); + const internals = manager as unknown as { + hostRequestsClosed: boolean; + state: "idle" | "running" | "shutdown"; + connection: { key: string }; + shell: { send: () => Promise; close: () => void }; + shutdownInternal: () => Promise; + start: () => Promise; + handleCommMessage: (message: { header: { msg_type: string }; content: Record }) => void; + sendCommMessage: (_commId: string, data: Record) => Promise; + activeHostRequestControllers: Map; + inFlightHostRequests: Set>; + }; + internals.hostRequestsClosed = true; + internals.shutdownInternal = async () => { + expect(internals.hostRequestsClosed).toBe(true); + internals.state = "shutdown"; + await shutdownSettled; + }; + internals.start = async () => { + expect(internals.hostRequestsClosed).toBe(false); + internals.state = "running"; + internals.connection = { key: "test" }; + internals.shell = { send: async () => {}, close: () => {} }; + internals.sendCommMessage = async () => {}; + internals.handleCommMessage({ + header: { msg_type: "comm_open" }, + content: { comm_id: "restarted-request", target_name: "host.request", data: { type: "test" } }, + }); + }; + + const restartPromise = manager.restart(); + await Promise.resolve(); + expect(internals.hostRequestsClosed).toBe(true); + releaseShutdown?.(); + await restartPromise; + await Promise.allSettled([...internals.inFlightHostRequests]); + + expect(implementation).toHaveBeenCalledTimes(1); + expect(capturedContext?.signal.aborted).toBe(true); + expect(internals.activeHostRequestControllers.has("restarted-request")).toBe(false); + }); + it("fails a later execution fast when the interrupted cell never idles", async () => { vi.useFakeTimers(); const manager = new KernelManager({ cwd: process.cwd() }); @@ -313,4 +722,38 @@ describe("KernelManager abort handling", () => { expect(controlSend).toHaveBeenCalled(); manager.disposeSync(); }); + + it("does not restart or reopen host-request admission when disposal races shutdown", async () => { + let releaseShutdown: (() => void) | undefined; + let markShutdownEntered: (() => void) | undefined; + const shutdownEntered = new Promise((resolve) => { + markShutdownEntered = resolve; + }); + const shutdownBlocked = new Promise((resolve) => { + releaseShutdown = resolve; + }); + const manager = new KernelManager({ cwd: process.cwd() }); + const start = vi.fn(async () => {}); + const internals = manager as unknown as { + hostRequestsClosed: boolean; + state: "idle" | "running" | "shutdown"; + shutdownInternal: () => Promise; + start: () => Promise; + }; + internals.shutdownInternal = async () => { + markShutdownEntered?.(); + await shutdownBlocked; + }; + internals.start = start; + + const restartPromise = manager.restart(); + await shutdownEntered; + manager.disposeSync(); + expect(internals.hostRequestsClosed).toBe(true); + releaseShutdown?.(); + + await expect(restartPromise).rejects.toThrow("Kernel terminated during restart"); + expect(start).not.toHaveBeenCalled(); + expect(internals.hostRequestsClosed).toBe(true); + }); }); diff --git a/packages/coding-agent/test/kernel-agent-message-skill.test.ts b/packages/coding-agent/test/kernel-agent-message-skill.test.ts index 5e75b8883..d35bd191e 100644 --- a/packages/coding-agent/test/kernel-agent-message-skill.test.ts +++ b/packages/coding-agent/test/kernel-agent-message-skill.test.ts @@ -7,6 +7,8 @@ import { KernelManager, type KernelSentAgentMessage } from "../src/core/kernel/i import type { PythonSkillRuntimeInfo } from "../src/core/skills.js"; import { IpythonKernelProvisioner } from "../src/core/tools/ipython.js"; +import { createTestHostHandlers } from "./host-request-context.js"; + function bundledAgentMessageSkill(): PythonSkillRuntimeInfo { const packagePath = join(getBundledSkillsDir(), "agent-message"); return { @@ -44,7 +46,7 @@ describe("agent-message skill over the kernel host bridge", () => { const requests: Array<{ type: string; payload: Record }> = []; provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAgentMessageSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "agent_message.list_agents": async (payload) => { requests.push({ type: "agent_message.list_agents", payload }); return { @@ -64,7 +66,7 @@ describe("agent-message skill over the kernel host bridge", () => { queuedAt: "2026-06-16T00:00:00.000Z", }; }, - }, + }), }); const manager = await provisioner.ensure(); @@ -117,7 +119,7 @@ print(json.dumps({"agents": agents, "receipt": receipt}, sort_keys=True)) it("emits successful broadcast receipts and leaves short errors in the result", async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAgentMessageSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "agent_message.send": async (payload) => ({ receipts: [ { @@ -132,7 +134,7 @@ print(json.dumps({"agents": agents, "receipt": receipt}, sort_keys=True)) { target: "sibling", error: "rate limited" }, ], }), - }, + }), }); const manager = await provisioner.ensure(); @@ -162,11 +164,11 @@ print(json.dumps(receipt, sort_keys=True)) it("rejects broadcast combined with role selectors before reaching the host", async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAgentMessageSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "agent_message.send": async () => { throw new Error("should not reach host"); }, - }, + }), }); const manager = await provisioner.ensure(); @@ -183,11 +185,11 @@ except TypeError as error: it("rejects a positional name target before reaching the host", async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAgentMessageSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "agent_message.send": async () => { throw new Error("should not reach host"); }, - }, + }), }); const manager = await provisioner.ensure(); @@ -206,11 +208,11 @@ except TypeError as error: it("does not expose a queueable delivery mode", async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAgentMessageSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "agent_message.send": async () => { throw new Error("should not reach host"); }, - }, + }), }); const manager = await provisioner.ensure(); @@ -227,7 +229,7 @@ except TypeError as error: it("captures sent messages from detached tasks after the cell is idle", async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAgentMessageSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "agent_message.send": async (payload) => ({ id: "agentmsg-background", source: "agent_message", @@ -237,7 +239,7 @@ except TypeError as error: deliveredAt: "2026-07-10T00:00:00.000Z", deliveryMode: payload.mode, }), - }, + }), }); const manager = await provisioner.ensure(); diff --git a/packages/coding-agent/test/kernel-agent-observe-skill.test.ts b/packages/coding-agent/test/kernel-agent-observe-skill.test.ts index 0608bd6c0..c0b1b9eaf 100644 --- a/packages/coding-agent/test/kernel-agent-observe-skill.test.ts +++ b/packages/coding-agent/test/kernel-agent-observe-skill.test.ts @@ -6,6 +6,8 @@ import { getBundledSkillsDir } from "../src/config.js"; import type { PythonSkillRuntimeInfo } from "../src/core/skills.js"; import { IpythonKernelProvisioner } from "../src/core/tools/ipython.js"; +import { createTestHostHandlers } from "./host-request-context.js"; + function bundledAgentObserveSkill(): PythonSkillRuntimeInfo { const packagePath = join(getBundledSkillsDir(), "agent-observe"); return { @@ -35,7 +37,7 @@ describe("agent-observe skill over the kernel host bridge", () => { const requests: Array<{ type: string; payload: Record }> = []; provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAgentObserveSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "agent_observe.list": async (payload) => { requests.push({ type: "agent_observe.list", payload }); return { @@ -60,7 +62,7 @@ describe("agent-observe skill over the kernel host bridge", () => { truncated: false, }; }, - }, + }), }); const manager = await provisioner.ensure(); @@ -93,11 +95,11 @@ print(json.dumps({"agents": agents, "agent": agent, "recent": recent}, sort_keys it("validates argument types before sending to the host", async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAgentObserveSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "agent_observe.get": async () => { throw new Error("should not reach host"); }, - }, + }), }); const manager = await provisioner.ensure(); diff --git a/packages/coding-agent/test/kernel-attach-image-skill.test.ts b/packages/coding-agent/test/kernel-attach-image-skill.test.ts index b47be17c3..4f8f0186e 100644 --- a/packages/coding-agent/test/kernel-attach-image-skill.test.ts +++ b/packages/coding-agent/test/kernel-attach-image-skill.test.ts @@ -6,6 +6,8 @@ import { getBundledSkillsDir } from "../src/config.js"; import type { PythonSkillRuntimeInfo } from "../src/core/skills.js"; import { IpythonKernelProvisioner, imageBlocksFromAttachments } from "../src/core/tools/ipython.js"; +import { createTestHostHandlers } from "./host-request-context.js"; + // 1x1 transparent PNG. const PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; @@ -40,9 +42,9 @@ describe("attach-image skill over the kernel host bridge", () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAttachImageSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "model.info": async () => ({ id: "anthropic/claude-haiku-4.5", input: ["text", "image"] }), - }, + }), }); const manager = await provisioner.ensure(); @@ -63,9 +65,9 @@ describe("attach-image skill over the kernel host bridge", () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAttachImageSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "model.info": async () => ({ id: "anthropic/claude-haiku-4.5", input: ["text", "image"] }), - }, + }), }); const manager = await provisioner.ensure(); @@ -88,9 +90,9 @@ print(await attach_image(${JSON.stringify(imagePath)})) provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAttachImageSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "model.info": async () => ({ id: "anthropic/claude-haiku-4.5", input: ["text", "image"] }), - }, + }), }); const manager = await provisioner.ensure(); @@ -113,9 +115,9 @@ print(await attach_image(${JSON.stringify(imagePath)})) provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAttachImageSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "model.info": async () => ({ id: "anthropic/claude-haiku-4.5", input: ["text", "image"] }), - }, + }), }); const manager = await provisioner.ensure(); @@ -140,9 +142,9 @@ print(await attach_image(${JSON.stringify(imagePath)})) provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAttachImageSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "model.info": async () => ({ id: "anthropic/claude-haiku-4.5", input: ["text", "image"] }), - }, + }), }); const manager = await provisioner.ensure(); @@ -178,9 +180,9 @@ except ValueError as error: provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAttachImageSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "model.info": async () => ({ id: "anthropic/claude-haiku-4.5", input: ["text", "image"] }), - }, + }), }); const manager = await provisioner.ensure(); @@ -215,9 +217,9 @@ except ValueError as error: provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAttachImageSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "model.info": async () => ({ id: "openai/gpt-oss-120b", input: ["text"] }), - }, + }), }); const manager = await provisioner.ensure(); @@ -242,9 +244,9 @@ except RuntimeError as error: provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledAttachImageSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "model.info": async () => ({ id: "anthropic/claude-haiku-4.5", input: ["text", "image"] }), - }, + }), }); const manager = await provisioner.ensure(); diff --git a/packages/coding-agent/test/kernel-goal-skill.test.ts b/packages/coding-agent/test/kernel-goal-skill.test.ts index 239ffa351..44719174b 100644 --- a/packages/coding-agent/test/kernel-goal-skill.test.ts +++ b/packages/coding-agent/test/kernel-goal-skill.test.ts @@ -6,6 +6,8 @@ import { getBundledSkillsDir } from "../src/config.js"; import type { PythonSkillRuntimeInfo } from "../src/core/skills.js"; import { IpythonKernelProvisioner } from "../src/core/tools/ipython.js"; +import { createTestHostHandlers } from "./host-request-context.js"; + function bundledGoalSkill(): PythonSkillRuntimeInfo { const packagePath = join(getBundledSkillsDir(), "goal"); return { @@ -35,7 +37,7 @@ describe("goal skill over the kernel host bridge", { tags: ["kernel-heavy"] }, ( const requests: Array<{ type: string; payload: Record }> = []; provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledGoalSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "goal.create": async (payload) => { requests.push({ type: "goal.create", payload }); return { @@ -53,7 +55,7 @@ describe("goal skill over the kernel host bridge", { tags: ["kernel-heavy"] }, ( "Goal achieved. Report final budget usage to the user: tokens used: 7 of 10.", }; }, - }, + }), }); const manager = await provisioner.ensure(); @@ -85,11 +87,11 @@ print(_completed["goal"]["status"], _completed["completion_budget_report"]) it("surfaces host errors and missing handlers as Python exceptions", async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledGoalSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "goal.complete": async () => { throw new Error("cannot complete goal because this thread has no goal"); }, - }, + }), }); const manager = await provisioner.ensure(); @@ -128,9 +130,9 @@ except RuntimeError as error: it("rejects replies with an unexpected status instead of hanging", async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledGoalSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "goal.get": async () => ({ status: "partial" }), - }, + }), }); const manager = await provisioner.ensure(); diff --git a/packages/coding-agent/test/kernel-rlm-heartbeat-skill.test.ts b/packages/coding-agent/test/kernel-rlm-heartbeat-skill.test.ts index 8a47a0f91..bc2a0c21c 100644 --- a/packages/coding-agent/test/kernel-rlm-heartbeat-skill.test.ts +++ b/packages/coding-agent/test/kernel-rlm-heartbeat-skill.test.ts @@ -6,6 +6,8 @@ import { getBundledSkillsDir } from "../src/config.js"; import type { PythonSkillRuntimeInfo } from "../src/core/skills.js"; import { IpythonKernelProvisioner } from "../src/core/tools/ipython.js"; +import { createTestHostHandlers } from "./host-request-context.js"; + function bundledRlmHeartbeatSkill(): PythonSkillRuntimeInfo { const packagePath = join(getBundledSkillsDir(), "rlm-heartbeat"); return { @@ -35,7 +37,7 @@ describe("RLM heartbeat skill over the kernel host bridge", () => { const requests: Array<{ type: string; payload: Record }> = []; provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledRlmHeartbeatSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "rlm_heartbeat.create": async (payload) => { requests.push({ type: "rlm_heartbeat.create", payload }); return { @@ -85,7 +87,7 @@ describe("RLM heartbeat skill over the kernel host bridge", () => { }, }; }, - }, + }), }); const manager = await provisioner.ensure(); @@ -131,7 +133,7 @@ print(json.dumps({ it("surfaces missing host handlers as Python exceptions", async () => { provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledRlmHeartbeatSkill()], - hostHandlers: {}, + hostHandlers: createTestHostHandlers({}), }); const manager = await provisioner.ensure(); @@ -151,12 +153,12 @@ except RuntimeError as error: let hostRequestCount = 0; provisioner = new IpythonKernelProvisioner(tempDir, { pythonSkills: [bundledRlmHeartbeatSkill()], - hostHandlers: { + hostHandlers: createTestHostHandlers({ "rlm_heartbeat.create": async () => { hostRequestCount++; return {}; }, - }, + }), }); const manager = await provisioner.ensure(); diff --git a/packages/coding-agent/test/kernel-startup.test.ts b/packages/coding-agent/test/kernel-startup.test.ts index 225d57139..78b98e19a 100644 --- a/packages/coding-agent/test/kernel-startup.test.ts +++ b/packages/coding-agent/test/kernel-startup.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { KernelManager } from "../src/core/kernel/index.js"; let tempDir = ""; +let originalForkserver: string | undefined; function writeExecutable(filePath: string, content: string): void { writeFileSync(filePath, content); @@ -13,10 +14,14 @@ function writeExecutable(filePath: string, content: string): void { describe("KernelManager startup", () => { beforeEach(() => { + originalForkserver = process.env.PRIME_AGENT_KERNEL_FORKSERVER; + process.env.PRIME_AGENT_KERNEL_FORKSERVER = "0"; tempDir = mkdtempSync(join(tmpdir(), "prime-agent-kernel-startup-")); }); afterEach(() => { + if (originalForkserver === undefined) delete process.env.PRIME_AGENT_KERNEL_FORKSERVER; + else process.env.PRIME_AGENT_KERNEL_FORKSERVER = originalForkserver; if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ""; @@ -38,4 +43,72 @@ describe("KernelManager startup", () => { await manager.dispose(); } }); + + it("keeps a connection-resolution startup failure retryable", async () => { + const python = join(tempDir, "python"); + writeExecutable(python, ["#!/bin/sh", "sleep 30", ""].join("\n")); + const manager = new KernelManager({ python, cwd: tempDir }); + let rejectRetry: (error: Error) => void = () => {}; + let markRetryEntered: () => void = () => {}; + const retryEntered = new Promise((resolve) => { + markRetryEntered = resolve; + }); + let connectionAttempts = 0; + const internals = manager as unknown as { + state: "idle" | "starting" | "running" | "shutdown"; + terminal: boolean; + hostRequestsClosed: boolean; + waitForResolvedConnection: () => Promise; + }; + internals.waitForResolvedConnection = () => { + connectionAttempts++; + if (connectionAttempts === 1) return Promise.reject(new Error("connection unavailable")); + return new Promise((_resolve, reject) => { + rejectRetry = reject; + markRetryEntered(); + }); + }; + + await expect(manager.start()).rejects.toThrow("connection unavailable"); + expect(internals.terminal).toBe(false); + expect(internals.state).toBe("idle"); + + const retry = manager.start(); + await retryEntered; + expect(internals.hostRequestsClosed).toBe(false); + manager.disposeSync(); + rejectRetry(new Error("connection unavailable")); + await expect(retry).rejects.toThrow("connection unavailable"); + }); + + it("keeps disposal during startup terminal", async () => { + const python = join(tempDir, "python"); + writeExecutable(python, ["#!/bin/sh", "sleep 30", ""].join("\n")); + const manager = new KernelManager({ python, cwd: tempDir }); + let rejectConnection: (error: Error) => void = () => {}; + let markConnectionWaitEntered: () => void = () => {}; + const connectionWaitEntered = new Promise((resolve) => { + markConnectionWaitEntered = resolve; + }); + const internals = manager as unknown as { + terminal: boolean; + hostRequestsClosed: boolean; + waitForResolvedConnection: () => Promise; + }; + internals.waitForResolvedConnection = () => + new Promise((_resolve, reject) => { + rejectConnection = reject; + markConnectionWaitEntered(); + }); + + const start = manager.start(); + await connectionWaitEntered; + manager.disposeSync(); + rejectConnection(new Error("connection unavailable")); + + await expect(start).rejects.toThrow("connection unavailable"); + expect(internals.terminal).toBe(true); + expect(internals.hostRequestsClosed).toBe(true); + await expect(manager.start()).rejects.toThrow("Kernel was disposed"); + }); }); diff --git a/packages/coding-agent/test/kernel-state-roundtrip.test.ts b/packages/coding-agent/test/kernel-state-roundtrip.test.ts index b4d767c4a..d76eafb6a 100644 --- a/packages/coding-agent/test/kernel-state-roundtrip.test.ts +++ b/packages/coding-agent/test/kernel-state-roundtrip.test.ts @@ -174,4 +174,30 @@ describeIfKernel("kernel state snapshot round-trip (real kernel)", { tags: ["ker rmSync(autoDir, { recursive: true, force: true }); } }, 60_000); + + it.each([ + ["dispose", async (manager: KernelManager) => manager.dispose()], + ["shutdown(snapshot:true)", async (manager: KernelManager) => manager.shutdown({ snapshot: true })], + ] as const)("persists the final namespace through %s after the terminal fence", async (_label, terminate) => { + const finalDir = mkdtempSync(join(tmpdir(), "prime-agent-state-final-")); + const finalPath = join(finalDir, "final.dill"); + const cfg = { path: finalPath, manifestPath: join(finalDir, "final.json"), debounceMs: 60_000 }; + const writer = new KernelManager({ python: python as string, cwd: finalDir, snapshot: cfg }); + await writer.execute("final_only = 42"); + await terminate(writer); + await expect(writer.execute("late = 1")).rejects.toThrow("Kernel was disposed"); + expect(existsSync(finalPath)).toBe(true); + + const reader = new KernelManager({ python: python as string, cwd: finalDir, snapshot: cfg }); + try { + const restore = await reader.restoreState(); + expect(restore?.restored).toContain("final_only"); + const result = await reader.execute("print(final_only)"); + expect(result.stdout.trim()).toBe("42"); + } finally { + await reader.dispose(); + rmSync(finalDir, { recursive: true, force: true }); + } + }, 60_000); + }); diff --git a/packages/coding-agent/test/mcp-manager.test.ts b/packages/coding-agent/test/mcp-manager.test.ts index 366a207ee..504b83aac 100644 --- a/packages/coding-agent/test/mcp-manager.test.ts +++ b/packages/coding-agent/test/mcp-manager.test.ts @@ -7,6 +7,7 @@ import { AuthStorage } from "../src/core/auth-storage.js"; import { McpManager } from "../src/core/mcp/mcp-manager.js"; import { ModelRegistry } from "../src/core/model-registry.js"; import type { McpServerConfig } from "../src/core/settings-manager.js"; +import { invokeHostRequestThroughKernelForTest as invokeHostRequestHandlerForTest } from "./host-request-context.js"; describe("McpManager", () => { let tempDir: string; @@ -79,8 +80,10 @@ describe("McpManager", () => { // refresh with no credentials fails (so the kernel reports a refresh error, // not a false success), and a missing server arg is rejected. - await expect(handlers["mcp.refresh"]({ server: "linear" })).rejects.toThrow("Could not refresh"); - await expect(handlers["mcp.refresh"]({})).rejects.toThrow("requires a server"); + await expect(invokeHostRequestHandlerForTest(handlers["mcp.refresh"]!, { server: "linear" })).rejects.toThrow( + "Could not refresh", + ); + await expect(invokeHostRequestHandlerForTest(handlers["mcp.refresh"]!, {})).rejects.toThrow("requires a server"); }); it("exposes mcp.begin_login only when beginLogin is provided", async () => { @@ -93,7 +96,7 @@ describe("McpManager", () => { }); const handlers = manager.hostHandlers(); expect(Object.keys(handlers).sort()).toEqual(["mcp.begin_login", "mcp.config", "mcp.refresh"]); - await handlers["mcp.begin_login"]({ server: "linear" }); + await invokeHostRequestHandlerForTest(handlers["mcp.begin_login"]!, { server: "linear" }); expect(called).toBe("linear"); }); @@ -105,11 +108,13 @@ describe("McpManager", () => { }), }); const handlers = manager.hostHandlers(); - expect(await handlers["mcp.config"]({ server: "linear" })).toEqual({ + expect(await invokeHostRequestHandlerForTest(handlers["mcp.config"]!, { server: "linear" })).toEqual({ url: "https://proxy.test/mcp", headers: { "X-Extra": "1" }, }); - expect(await handlers["mcp.config"]({ server: "notion" })).toEqual({ url: "https://mcp.notion.com/mcp" }); + expect(await invokeHostRequestHandlerForTest(handlers["mcp.config"]!, { server: "notion" })).toEqual({ + url: "https://mcp.notion.com/mcp", + }); }); it("does not treat an oauth override of a catalog name as authed via the official stored cred", () => { diff --git a/packages/coding-agent/test/saved-session-catalog.test.ts b/packages/coding-agent/test/saved-session-catalog.test.ts index 30a7f33ac..a7e164fb4 100644 --- a/packages/coding-agent/test/saved-session-catalog.test.ts +++ b/packages/coding-agent/test/saved-session-catalog.test.ts @@ -145,7 +145,12 @@ describe("saved session catalog", () => { }); expect(fakeClient.commands).toEqual([ - { type: "rename_saved_session", sessionPath: "/tmp/sessions/one.jsonl", name: "One" }, + { + type: "rename_saved_session", + sessionPath: "/tmp/sessions/one.jsonl", + sessionDir: "/tmp/sessions", + name: "One", + }, { type: "delete_saved_session", sessionPath: "/tmp/sessions/one.jsonl" }, ]); }); diff --git a/packages/coding-agent/test/suite/regressions/4649-subagent-model-selection.test.ts b/packages/coding-agent/test/suite/regressions/4649-subagent-model-selection.test.ts index 61de890c0..b199a8e55 100644 --- a/packages/coding-agent/test/suite/regressions/4649-subagent-model-selection.test.ts +++ b/packages/coding-agent/test/suite/regressions/4649-subagent-model-selection.test.ts @@ -2,6 +2,7 @@ import { fauxAssistantMessage } from "@earendil-works/pi-ai"; import { describe, expect, it, vi } from "vitest"; import type { HostRequestHandlers } from "../../../src/core/kernel/index.js"; import { SessionManager } from "../../../src/core/session-manager.js"; +import { invokeHostRequestThroughKernelForTest as invokeHostRequestHandlerForTest } from "../../host-request-context.js"; import { createHarness } from "../harness.js"; const provider = "faux-eng-4649"; @@ -27,7 +28,7 @@ describe("ENG-4649 subagent model selection", () => { )._createKernelHostHandlers(); const findModels = handlers["rlm.find_models"]; if (!findModels) throw new Error("Missing rlm.find_models host handler"); - await expect(findModels({ query: "model 319", limit: 5 })).resolves.toEqual({ + await expect(invokeHostRequestHandlerForTest(findModels!, { query: "model 319", limit: 5 })).resolves.toEqual({ models: [ { provider, @@ -37,7 +38,9 @@ describe("ENG-4649 subagent model selection", () => { }, ], }); - await expect(findModels({ query: "model", limit: 21 })).rejects.toThrow("integer from 1 to 20"); + await expect(invokeHostRequestHandlerForTest(findModels!, { query: "model", limit: 21 })).rejects.toThrow( + "integer from 1 to 20", + ); harness.setResponses([fauxAssistantMessage("resolved child answer")]); const result = await harness.session.runRlmChild("use the requested model", {