diff --git a/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md b/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md new file mode 100644 index 0000000000..6baf51c96d --- /dev/null +++ b/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md @@ -0,0 +1,3 @@ +- Made the daemon supervisor own an event-driven agent roster: workers push roster deltas on session events and `list` is served from the supervisor's ledger with zero worker round-trips. Rows are as fresh as the owning worker's last delta; a silent worker's rows are annotated (recovering, last-heard-from) rather than dropped, and the surfaces that display those annotations ship in the follow-up PR. +- Tracked admitted subagent runs in the supervisor roster from the moment they are queued (they appear in `list` once their session exists), and kept passivated or evicted agents listed as inactive rows instead of disappearing (client-owned workers stay private: their rows are dropped when the worker goes away). +- Tracked worker liveness in the supervisor roster: a dead worker's rows are flagged "recovering" the moment its socket closes, and rows of silent workers carry a last-heard-from time. These fields are supervisor-internal here; the roster surfaces that display them ship in the follow-up PR. diff --git a/packages/coding-agent/src/modes/daemon/agent-roster.ts b/packages/coding-agent/src/modes/daemon/agent-roster.ts index 5c5e14ee84..ea6a8a2130 100644 --- a/packages/coding-agent/src/modes/daemon/agent-roster.ts +++ b/packages/coding-agent/src/modes/daemon/agent-roster.ts @@ -1,3 +1,6 @@ +import { canonicalSessionPath } from "../../core/session-lease.js"; +import type { SessionSummary } from "./daemon-session-list.js"; + // One status formula shared by every agent surface; surfaces adapt their inputs and never reimplement it. export type AgentRosterStatus = "running" | "idle" | "inactive"; @@ -16,3 +19,186 @@ export function classifyAgentStatus(input: AgentStatusInput): AgentRosterStatus if (!input.resident) return "inactive"; return input.busy || input.hasActiveHeartbeat ? "running" : "idle"; } + +export function isSessionSummaryBusy( + summary: Pick, +): boolean { + return summary.isSessionActive || summary.hasRunningRlmChildren === true; +} + +export function classifySessionRosterStatus( + summary: Pick< + SessionSummary, + "activeSessionId" | "activity" | "isSessionActive" | "hasRunningRlmChildren" | "hasActiveHeartbeat" + >, + queuedChild = false, +): AgentRosterStatus { + return classifyAgentStatus({ + resident: !!summary.activeSessionId, + queuedChild, + busy: summary.activity === "working" || isSessionSummaryBusy(summary), + hasActiveHeartbeat: summary.hasActiveHeartbeat === true, + }); +} + +export type RosterSessionSummary = Omit; + +export interface WorkerRosterEntry { + agentId: string; + queuedChild?: true; + seededCwd?: true; + summary: RosterSessionSummary; +} + +export interface AgentRosterEntry extends WorkerRosterEntry { + status: AgentRosterStatus; + statusLabel?: "queued" | "recovering" | "failed"; + lastHeardFromAt?: string; + workerId?: string; +} + +// Child ids are only unique per parent (32-bit, mkdir-checked); the parent path qualifies them daemon-wide. +export function rosterAgentIdForSummary( + summary: Pick< + SessionSummary, + "runtimeKind" | "rlmChildId" | "sessionId" | "parentSessionPath" | "parentActiveSessionId" + >, +): string { + if (summary.runtimeKind === "subagent" && summary.rlmChildId) { + // No-session parents have no path (and no ledger edge); their live parent id still disambiguates. + const parentKey = summary.parentSessionPath + ? canonicalSessionPath(summary.parentSessionPath) + : summary.parentActiveSessionId; + return parentKey ? `${parentKey}#${summary.rlmChildId}` : summary.rlmChildId; + } + return summary.sessionId; +} + +export function workerRosterEntryFromSummary(summary: SessionSummary): WorkerRosterEntry { + const { streamingMessage, sessionActions, diagnostics, ...slim } = summary; + return { agentId: rosterAgentIdForSummary(summary), summary: slim }; +} + +function classifyWorkerRosterEntry(entry: WorkerRosterEntry): AgentRosterStatus { + return classifySessionRosterStatus(entry.summary, entry.queuedChild === true); +} + +export function passivatedWorkerRosterEntry( + entry: WorkerRosterEntry, + registrations?: { hasRegisteredHeartbeat: boolean; hasRegisteredCronJob: boolean }, +): WorkerRosterEntry { + const { + activeSessionId, + hasActiveHeartbeat, + hasRegisteredHeartbeat, + hasRegisteredCronJob, + hasRunningRlmChildren, + isBashRunning, + isRunningTools, + workerState, + workerPid, + ...summary + } = entry.summary; + return { + agentId: entry.agentId, + summary: { + ...summary, + id: summary.sessionId, + activity: "idle", + isSessionActive: false, + isStreaming: false, + isCompacting: false, + attachedClients: 0, + ...(registrations?.hasRegisteredHeartbeat ? { hasRegisteredHeartbeat: true } : {}), + ...(registrations?.hasRegisteredCronJob ? { hasRegisteredCronJob: true } : {}), + }, + }; +} + +export function sessionSummaryFromRosterEntry(entry: WorkerRosterEntry): SessionSummary { + return { ...entry.summary, sessionActions: { queuedCount: 0, steering: [], followUps: [] } }; +} + +export class AgentRoster { + private readonly entries = new Map(); + private readonly agentIdByActiveSessionId = new Map(); + private readonly agentIdBySessionFile = new Map(); + + constructor(private readonly canonicalPath: (path: string) => string) {} + + values(): IterableIterator { + return this.entries.values(); + } + + get(agentId: string): AgentRosterEntry | undefined { + return this.entries.get(agentId); + } + + has(agentId: string): boolean { + return this.entries.has(agentId); + } + + byActiveSessionId(activeSessionId: string): AgentRosterEntry | undefined { + const agentId = this.agentIdByActiveSessionId.get(activeSessionId); + return agentId !== undefined ? this.entries.get(agentId) : undefined; + } + + bySessionFile(canonicalPath: string): AgentRosterEntry | undefined { + const agentId = this.agentIdBySessionFile.get(canonicalPath); + return agentId !== undefined ? this.entries.get(agentId) : undefined; + } + + hasSessionFile(canonicalPath: string): boolean { + return this.agentIdBySessionFile.has(canonicalPath); + } + + entriesForWorker(workerId: string): AgentRosterEntry[] { + return [...this.entries.values()].filter((entry) => entry.workerId === workerId); + } + + write(entry: WorkerRosterEntry, workerId?: string, statusLabel?: AgentRosterEntry["statusLabel"]): AgentRosterEntry { + const stored: AgentRosterEntry = { + ...entry, + status: classifyWorkerRosterEntry(entry), + ...(entry.queuedChild ? { statusLabel: "queued" as const } : statusLabel ? { statusLabel } : {}), + ...(workerId !== undefined ? { workerId } : {}), + }; + const previous = this.entries.get(entry.agentId); + if (previous) this.dropIndexes(previous); + if (stored.summary.sessionFile) { + const file = this.canonicalPath(stored.summary.sessionFile); + const existingAgentId = this.agentIdBySessionFile.get(file); + if (existingAgentId !== undefined && existingAgentId !== entry.agentId) { + this.delete(existingAgentId); + } + this.agentIdBySessionFile.set(file, entry.agentId); + } + if (stored.summary.activeSessionId) { + this.agentIdByActiveSessionId.set(stored.summary.activeSessionId, entry.agentId); + } + this.entries.set(entry.agentId, stored); + return stored; + } + + delete(agentId: string): void { + const entry = this.entries.get(agentId); + if (!entry) return; + this.dropIndexes(entry); + this.entries.delete(agentId); + } + + private dropIndexes(entry: AgentRosterEntry): void { + if ( + entry.summary.activeSessionId && + this.agentIdByActiveSessionId.get(entry.summary.activeSessionId) === entry.agentId + ) { + this.agentIdByActiveSessionId.delete(entry.summary.activeSessionId); + } + if (entry.summary.sessionFile) { + const file = this.canonicalPath(entry.summary.sessionFile); + if (this.agentIdBySessionFile.get(file) === entry.agentId) { + this.agentIdBySessionFile.delete(file); + } + } + } +} diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index c8601b2a3b..45f5af4653 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -11,7 +11,7 @@ import { createHash, randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { stat } from "node:fs/promises"; import { createConnection, createServer, type Server, type Socket } from "node:net"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { type Api, getLogger, type Model } from "@earendil-works/pi-ai"; import { createCliSubprocessEnv, createCliSubprocessLaunchSpec } from "../../cli/subprocess-launch.js"; import { @@ -124,6 +124,13 @@ import { type DaemonSocketClient, resolveActiveSessionState, } from "./active-session-state.js"; +import { + passivatedWorkerRosterEntry, + type RosterSessionSummary, + rosterAgentIdForSummary, + type WorkerRosterEntry, + workerRosterEntryFromSummary, +} from "./agent-roster.js"; import { createCompactAssistantDelta } from "./compact-session-stream.js"; import { DaemonClient } from "./daemon-client.js"; import { filterClientEnv, withClientEnv } from "./daemon-client-env.js"; @@ -165,6 +172,7 @@ import { inactiveLifecycleForSession, isActiveSessionBusy, type SessionSummary, + scheduledJobRegistrations, summaryForActiveSession, } from "./daemon-session-list.js"; import { DaemonSessionSummarizer } from "./daemon-session-summarizer.js"; @@ -182,11 +190,14 @@ import { DAEMON_WORKER_ACTIVE_SESSION_ID_ENV, DAEMON_WORKER_RECOVERY_JOURNAL_ENV, DAEMON_WORKER_ROLE_ENV, + DAEMON_WORKER_ROSTER_CAPABILITY, DAEMON_WORKER_SUPERVISOR_SOCKET_ENV, DAEMON_WORKER_TOKEN_ENV, type DaemonWorkerCommand, type DaemonWorkerFrameHeader, + type DaemonWorkerRosterOutbound, isDaemonWorkerFrameHeader, + ROSTER_HEARTBEAT_INTERVAL_MS, SESSION_LEASE_OWNER_ID_ENV, SESSION_LEASES_ENABLED_ENV, } from "./daemon-worker-protocol.js"; @@ -198,6 +209,7 @@ import { type RlmLedgerEdge, RlmSpawnLedger, readLegacyRlmSubagentRegistry as readLegacyRlmSubagentRegistryFile, + tombstoneSavedSessionDelete, } from "./rlm-ledger.js"; import { readRlmSubagentDisplayEntry, @@ -541,6 +553,15 @@ export class AgentDaemon { }, ); private readonly recoveryJournal?: WorkerRecoveryJournal; + private readonly rosterReporter: WorkerRosterReporterState = { + lastComposed: new Map(), + lastComposedJson: new Map(), + queuedChildren: new Map(), + removedAgentIds: new Map(), + snapshotPending: false, + }; + private rosterFlushScheduled = false; + private rosterHeartbeatTimer?: ReturnType; private rlmSpawnLedgerInstance?: RlmSpawnLedger; /** In-flight admission spawn appends, awaited (and consumed) by createRlmSubagentRuntime. */ private readonly pendingRlmSpawnAppends = new Map>(); @@ -571,7 +592,10 @@ export class AgentDaemon { this.log(`Cron job ${job.id} failed: ${error instanceof Error ? error.message : String(error)}`); }, }); - this.cronStore.onHeartbeatChange(() => this.broadcastGlobal({ type: "heartbeats_changed" })); + this.cronStore.onHeartbeatChange(() => { + this.broadcastGlobal({ type: "heartbeats_changed" }); + this.scheduleRosterFlush(); + }); } // The daemon runs detached with no terminal, so route its diagnostics to its @@ -646,6 +670,13 @@ export class AgentDaemon { if (!this.shuttingDown) { this.cronScheduler.start(); } + if (this.options.worker) { + this.rosterHeartbeatTimer = setInterval( + () => this.broadcastRosterFrame({ type: "roster_heartbeat" }), + ROSTER_HEARTBEAT_INTERVAL_MS, + ); + this.rosterHeartbeatTimer.unref(); + } this.startSupervisorMonitor(); } @@ -998,7 +1029,8 @@ export class AgentDaemon { // Mark handled so an early rejection cannot surface as an // unhandled-rejection crash before the admission path awaits it. spawnAppend.catch(() => undefined); - this.pendingRlmSpawnAppends.set(input.childId, spawnAppend); + // Child ids are only unique per parent; the parent scopes the pending-append key. + this.pendingRlmSpawnAppends.set(`${parentState.activeSessionId}#${input.childId}`, spawnAppend); } try { writeRlmSubagentDisplayEntry({ @@ -1103,6 +1135,13 @@ export class AgentDaemon { // dual-write era it has no other writer to fall back on, so a failed // append is a failed deletion. await this.rlmSpawnLedger().appendDelete({ childId, child: entry.sessionFile, reason }); + if (this.options.worker) { + this.rosterReporter.removedAgentIds.set( + this.rosterAgentIdForRlmChild(childId, entry.parentSessionFile), + basename(entry.sessionFile, ".jsonl"), + ); + this.scheduleRosterFlush(); + } // Deletion boundary: transcript + display tombstone are the durable // record and stay; the nested artifact dir is a runtime cache and goes. await this.deleteRlmSubagentArtifacts(childId, entry.sessionFile); @@ -1409,6 +1448,7 @@ export class AgentDaemon { } } onStateBound?.(state); + this.scheduleRosterFlush(); } catch (error) { state.unsubscribe?.(); this.sessions.delete(state.activeSessionId); @@ -2517,7 +2557,7 @@ export class AgentDaemon { }, ); } catch (error) { - this.pendingRlmSpawnAppends.delete(options.id); + this.pendingRlmSpawnAppends.delete(`${parentState.activeSessionId}#${options.id}`); throw error; } // Admission is complete only once the spawn record is durably in the @@ -2526,8 +2566,8 @@ export class AgentDaemon { // failed append therefore FAILS admission — the just-added child is // closed like any other admission failure rather than admitted as a // ghost the ledger-driven listing and hydration could never find. - const spawnAppend = this.pendingRlmSpawnAppends.get(options.id); - this.pendingRlmSpawnAppends.delete(options.id); + const spawnAppend = this.pendingRlmSpawnAppends.get(`${parentState.activeSessionId}#${options.id}`); + this.pendingRlmSpawnAppends.delete(`${parentState.activeSessionId}#${options.id}`); try { await spawnAppend; } catch (error) { @@ -3324,7 +3364,10 @@ export class AgentDaemon { type: "response", command: "worker_auth", success: true, + data: { capabilities: [DAEMON_WORKER_ROSTER_CAPABILITY] }, }); + this.rosterReporter.snapshotPending = true; + this.scheduleRosterFlush(); return; } if (this.options.worker) { @@ -3872,11 +3915,29 @@ export class AgentDaemon { if (this.findActiveSessionByFile(command.sessionPath)) { throw new Error("Cannot delete the currently active session"); } + const composedEntry = this.rosterEntryForSessionPath(canonicalSessionPath(command.sessionPath)); + const { deletedInfo, ledgerEdge } = await tombstoneSavedSessionDelete( + this.rlmSpawnLedger(), + command.sessionPath, + composedEntry?.summary, + ); const result = await this.deleteSavedSessionFile(command.sessionPath, { afterFileRemoved: () => { this.cancelScheduledJobsForSessionFile(command.sessionPath); }, }); + if (result.ok && this.options.worker) { + const removedAgentId = + composedEntry?.agentId ?? + (ledgerEdge ? this.rosterAgentIdForRlmChild(ledgerEdge.childId, ledgerEdge.parent) : deletedInfo?.id); + if (removedAgentId) { + this.rosterReporter.removedAgentIds.set( + removedAgentId, + composedEntry?.summary.sessionId ?? deletedInfo?.id, + ); + this.scheduleRosterFlush(); + } + } return success(command.id, "delete_saved_session", result); } @@ -4194,7 +4255,11 @@ export class AgentDaemon { const state = this.getSessionState(command.activeSessionId); const bash = state.runtime.session.executeBash(command.command); state.inFlightBash = Promise.allSettled([state.inFlightBash, bash]).then(() => undefined); - return success(command.id, "execute_bash_and_wait", await bash); + try { + return success(command.id, "execute_bash_and_wait", await bash); + } finally { + this.scheduleRosterFlush(); + } } case "abort_bash": { @@ -4492,6 +4557,7 @@ export class AgentDaemon { case "cron_add": { const state = this.getSessionState(command.activeSessionId); const job = this.createCronJobForState(state, command.schedule, command.prompt); + this.scheduleRosterFlush(); return success(command.id, "cron_add", { job }); } @@ -4505,6 +4571,7 @@ export class AgentDaemon { this.removeQueuedHeartbeatFollowUp(state, job); } this.cronScheduler.wake(); + this.scheduleRosterFlush(); return success(command.id, "cron_cancel", { job }); } @@ -4544,6 +4611,7 @@ export class AgentDaemon { await session.setModel(model, { waitForExtensions: !(session.isStreaming || session.isCompacting), }); + this.scheduleRosterFlush(); return success(command.id, "set_model", model); } @@ -4553,6 +4621,7 @@ export class AgentDaemon { const result = await session.cycleModel(command.direction, { waitForExtensions: !(session.isStreaming || session.isCompacting), }); + this.scheduleRosterFlush(); return success(command.id, "cycle_model", result ?? null); } @@ -6103,10 +6172,10 @@ export class AgentDaemon { } private findActiveSessionByFile(sessionPath: string): ActiveSessionState | undefined { - const resolvedSessionPath = resolve(sessionPath); + const canonicalPath = canonicalSessionPath(sessionPath); for (const state of this.sessions.values()) { const sessionFile = state.runtime.session.sessionFile; - if (sessionFile && resolve(sessionFile) === resolvedSessionPath) { + if (sessionFile && canonicalSessionPath(sessionFile) === canonicalPath) { return state; } } @@ -6302,6 +6371,11 @@ export class AgentDaemon { state.clients.clear(); this.acpMcpOwners.delete(state.activeSessionId); this.sessions.delete(state.activeSessionId); + // Archived top-level sessions leave the worker's list; subagent rows mirror the registry and stay. + if (!keepsResumeEntry && state.runtime.metadata.kind !== "subagent" && this.options.worker) { + this.rosterReporter.removedAgentIds.set(this.rosterAgentIdForState(state), state.runtime.session.sessionId); + } + this.scheduleRosterFlush(); if (isEmptyDraftSession) { const sessionFile = state.runtime.session.sessionFile; if (sessionFile) { @@ -6358,6 +6432,7 @@ export class AgentDaemon { } } this.stampRlmChildActiveSessionId(message); + this.observeRosterEvent(state, message); const sequencedMessage = this.addSessionEventMeta(state, message); let serialized: string | undefined; for (const client of state.clients) { @@ -6486,6 +6561,237 @@ export class AgentDaemon { } } + private rosterEntryForSessionPath(canonicalPath: string): WorkerRosterEntry | undefined { + for (const entry of this.rosterReporter.lastComposed.values()) { + if (entry.summary.sessionFile && canonicalSessionPath(entry.summary.sessionFile) === canonicalPath) { + return entry; + } + } + return undefined; + } + + private rosterAgentIdForState(state: ActiveSessionState): string { + const session = state.runtime.session; + const metadata = state.runtime.metadata; + if (metadata.kind === "subagent" && metadata.rlmChildId) { + return rosterAgentIdForSummary({ + runtimeKind: "subagent", + rlmChildId: metadata.rlmChildId, + sessionId: metadata.rlmChildId, + parentSessionPath: metadata.parentSessionFile, + parentActiveSessionId: metadata.parentActiveSessionId, + }); + } + return session.sessionId; + } + + private rosterAgentIdForRlmChild(childId: string, parentSessionPath: string | undefined): string { + return rosterAgentIdForSummary({ + runtimeKind: "subagent", + rlmChildId: childId, + sessionId: childId, + parentSessionPath, + }); + } + + private observeRosterEvent(state: ActiveSessionState, message: DaemonOutbound): void { + if (!this.options.worker) return; + if (message.type === "session_event") { + if (message.event.type === "rlm_child_update") { + this.observeRosterChildUpdate(state, message.event.child); + return; + } + if (!ROSTER_SESSION_EVENT_TRIGGERS.has(message.event.type)) return; + } else if ( + message.type !== "session_status" && + message.type !== "session_closed" && + message.type !== "session_replaced" + ) { + return; + } + this.scheduleRosterFlush(); + } + + private observeRosterChildUpdate(state: ActiveSessionState, child: AgentConnectionRlmChildAgentSnapshot): void { + const bound = child.activeSessionId !== undefined || this.hasSessionForRlmChild(state, child.id); + const entry = this.queuedChildRosterEntry(state, child); + if (!bound && (child.status === "queued" || child.status === "running")) { + this.rosterReporter.queuedChildren.set(entry.agentId, entry); + } else { + this.rosterReporter.queuedChildren.delete(entry.agentId); + } + this.scheduleRosterFlush(); + } + + private hasSessionForRlmChild(parentState: ActiveSessionState, childId: string): boolean { + for (const candidate of this.sessions.values()) { + const metadata = candidate.runtime.metadata; + if (metadata.rlmChildId === childId && metadata.parentActiveSessionId === parentState.activeSessionId) { + return true; + } + } + return false; + } + + private queuedChildRosterEntry( + state: ActiveSessionState, + child: AgentConnectionRlmChildAgentSnapshot, + ): WorkerRosterEntry { + const parentSession = state.runtime.session; + const summary: RosterSessionSummary = { + id: child.id, + lifecycle: "live", + activity: "idle", + isSessionActive: false, + runtimeKind: "subagent", + rlmDepth: (parentSession.rlmDepth ?? 0) + 1, + sessionId: child.id, + sessionName: child.sessionName, + cwd: parentSession.sessionManager.getCwd(), + isStreaming: false, + isCompacting: false, + attachedClients: 0, + messageCount: 0, + firstMessage: child.label, + parentActiveSessionId: state.activeSessionId, + parentSessionId: parentSession.sessionId, + parentSessionPath: parentSession.sessionFile, + rlmChildId: child.id, + }; + return { agentId: rosterAgentIdForSummary(summary), queuedChild: true, summary }; + } + + private scheduleRosterFlush(): void { + if (!this.options.worker || this.rosterFlushScheduled || this.shuttingDown) return; + this.rosterFlushScheduled = true; + setImmediate(() => { + this.rosterFlushScheduled = false; + try { + this.flushRoster(); + } catch (error) { + this.log(`could not publish roster delta: ${String(error)}`); + } + }); + } + + private flushRoster(): void { + const reporter = this.rosterReporter; + const entries = new Map(); + const scheduledJobs = this.cronStore.list(); + for (const summary of buildSessionList([...this.sessions.values()], [], scheduledJobs)) { + const entry = workerRosterEntryFromSummary(summary); + entries.set(entry.agentId, entry); + } + for (const [agentId, queued] of reporter.queuedChildren) { + if (entries.has(agentId)) { + reporter.queuedChildren.delete(agentId); + continue; + } + entries.set(agentId, queued); + } + // A terminal unbound child run owns no transcript: it is a removal, never a passivated row. + // A vanished row whose state lives on under a new sessionId was swapped in place + // (new_session/switch/fork): also a removal — plain list never served the old transcript. + const composedActiveIds = new Set(); + for (const entry of entries.values()) { + if (entry.summary.activeSessionId !== undefined) composedActiveIds.add(entry.summary.activeSessionId); + } + for (const [agentId, previous] of reporter.lastComposed) { + if (entries.has(agentId)) continue; + const swapped = + previous.summary.activeSessionId !== undefined && composedActiveIds.has(previous.summary.activeSessionId); + if (previous.queuedChild === true || swapped) { + reporter.removedAgentIds.set(agentId, previous.summary.sessionId); + } + } + for (const [agentId, targetSessionId] of reporter.removedAgentIds) { + const composed = entries.get(agentId); + // A new incarnation cancels the stale removal, as does a revived resident top-level row + // (switch-back, resume-after-archive); a resident subagent row with the removed sessionId + // is the mid-teardown race and stays suppressed. + const revived = composed?.summary.activeSessionId !== undefined && composed.summary.runtimeKind !== "subagent"; + if (composed && (composed.queuedChild === true || composed.summary.sessionId !== targetSessionId || revived)) { + reporter.removedAgentIds.delete(agentId); + continue; + } + entries.delete(agentId); + reporter.queuedChildren.delete(agentId); + } + const registrations = scheduledJobRegistrations(scheduledJobs); + for (const [agentId, previous] of reporter.lastComposed) { + if (!entries.has(agentId) && !reporter.removedAgentIds.has(agentId)) { + const file = previous.summary.sessionFile ? resolve(previous.summary.sessionFile) : undefined; + entries.set( + agentId, + passivatedWorkerRosterEntry(previous, { + hasRegisteredHeartbeat: file !== undefined && registrations.heartbeatSessionFiles.has(file), + hasRegisteredCronJob: file !== undefined && registrations.cronSessionFiles.has(file), + }), + ); + } + } + const changed: WorkerRosterEntry[] = []; + const nextJson = new Map(); + for (const entry of entries.values()) { + const json = JSON.stringify(entry); + nextJson.set(entry.agentId, json); + if (reporter.lastComposedJson.get(entry.agentId) !== json) changed.push(entry); + } + const removedAgentIds = [...reporter.removedAgentIds.keys()]; + reporter.lastComposed = new Map(entries); + reporter.lastComposedJson = nextJson; + if (!this.hasAuthenticatedSupervisorClient()) { + if (changed.length > 0 || removedAgentIds.length > 0) reporter.snapshotPending = true; + return; + } + if (reporter.snapshotPending) { + const delivered = this.broadcastRosterFrame({ + type: "roster_delta", + snapshot: true, + entries: [...entries.values()], + ...(removedAgentIds.length > 0 ? { removedAgentIds } : {}), + }); + if (delivered) { + reporter.snapshotPending = false; + reporter.removedAgentIds.clear(); + } + return; + } + if (changed.length === 0 && removedAgentIds.length === 0) return; + const delivered = this.broadcastRosterFrame({ + type: "roster_delta", + entries: changed, + ...(removedAgentIds.length > 0 ? { removedAgentIds } : {}), + }); + if (delivered) reporter.removedAgentIds.clear(); + else reporter.snapshotPending = true; + } + + private hasAuthenticatedSupervisorClient(): boolean { + for (const client of this.clients) { + if (this.supervisorClaims.has(client) && !client.socket.destroyed) { + return true; + } + } + return false; + } + + private broadcastRosterFrame(message: DaemonWorkerRosterOutbound): boolean { + const payload = Buffer.from(serializeJsonLine(message)); + let delivered = false; + for (const client of this.clients) { + if (!this.supervisorClaims.has(client) || client.socket.destroyed) { + continue; + } + // socket.write queues under backpressure, so a queued frame is delivered, never a loss gap. + client.socket.write( + encodePrivateFrame({ kind: "outbound", outboundType: message.type }, payload), + ); + delivered = true; + } + return delivered; + } + private recordWorkerRecoveryState(state: ActiveSessionState, operation: string, busyOverride?: boolean): void { if (!this.recoveryJournal) { return; @@ -6821,6 +7127,10 @@ export class AgentDaemon { clearTimeout(this.supervisorFenceTimer); this.supervisorFenceTimer = undefined; } + if (this.rosterHeartbeatTimer) { + clearInterval(this.rosterHeartbeatTimer); + this.rosterHeartbeatTimer = undefined; + } this.log(`shutting down (exit ${exitCode}); closing ${this.sessions.size} active session(s)`); const closingReason = this.getShutdownClosingReason(); for (const client of this.clients) { @@ -6852,6 +7162,32 @@ export class AgentDaemon { } } +interface WorkerRosterReporterState { + lastComposed: Map; + lastComposedJson: Map; + queuedChildren: Map; + /** Pending removals: agentId -> removed sessionId; a new incarnation of the id cancels it. */ + removedAgentIds: Map; + snapshotPending: boolean; +} + +const ROSTER_SESSION_EVENT_TRIGGERS = new Set([ + "turn_start", + "turn_end", + "bash_start", + "bash_end", + "compaction_start", + "compaction_end", + "auto_retry_start", + "auto_retry_end", + "tool_execution_start", + "tool_execution_end", + "message_end", + "session_action_update", + "session_info_changed", + "thinking_level_changed", +]); + function hasDaemonOutboundActiveSessionId( message: DaemonOutbound, ): message is DaemonOutbound & { activeSessionId: string } { diff --git a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts index 85ae56b6ec..5e8e8d4e48 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts @@ -9,7 +9,9 @@ import type { SessionActionSnapshot } from "../../core/session-action-store.js"; import type { AgentTaskState, SessionInfo } from "../../core/session-manager.js"; import type { AgentConnectionRlmChildAgentSnapshot } from "../agent-connection/types.js"; import type { ActiveSessionState } from "./active-session-state.js"; -import { type AgentRosterStatus, classifyAgentStatus } from "./agent-roster.js"; +import { isSessionSummaryBusy } from "./agent-roster.js"; + +export { classifySessionRosterStatus, isSessionSummaryBusy } from "./agent-roster.js"; // Durable lifecycle; decides agents-view visibility. Only "live" is shown. // "draft" = no message sent yet (discarded on close); "archived" = ctrl+x'd, @@ -100,17 +102,29 @@ export function resolveAttachModelFallbackMessage( return summary.model ? undefined : startupModelFallbackMessage; } -export function classifySessionRosterStatus(summary: SessionSummary): AgentRosterStatus { - return classifyAgentStatus({ - resident: !!summary.activeSessionId, - queuedChild: false, - busy: summary.activity === "working" || isSessionSummaryBusy(summary), - hasActiveHeartbeat: summary.hasActiveHeartbeat === true, - }); -} - -export function isSessionSummaryBusy(summary: SessionSummary): boolean { - return summary.isSessionActive || summary.hasRunningRlmChildren === true; +export function scheduledJobRegistrations(scheduledJobs: readonly AgentCronJob[]): { + activeHeartbeatSessionIds: Set; + heartbeatSessionIds: Set; + cronSessionIds: Set; + heartbeatSessionFiles: Set; + cronSessionFiles: Set; +} { + const activeHeartbeatSessionIds = new Set(); + const heartbeatSessionIds = new Set(); + const cronSessionIds = new Set(); + const heartbeatSessionFiles = new Set(); + const cronSessionFiles = new Set(); + for (const job of scheduledJobs) { + const heartbeat = isHeartbeatCronJob(job); + if (heartbeat && job.status === "active") activeHeartbeatSessionIds.add(job.activeSessionId); + // A paused heartbeat cannot fire, so unlike a live heartbeat (or a registered + // cron job) it must not silently pin a worker forever. + const registered = heartbeat ? job.status === "active" : job.status === "active" || job.status === "paused"; + if (!registered) continue; + (heartbeat ? heartbeatSessionIds : cronSessionIds).add(job.activeSessionId); + (heartbeat ? heartbeatSessionFiles : cronSessionFiles).add(resolve(job.sessionFile)); + } + return { activeHeartbeatSessionIds, heartbeatSessionIds, cronSessionIds, heartbeatSessionFiles, cronSessionFiles }; } /** Naming signals intent to return, so named sessions are exempt even when empty. */ @@ -130,23 +144,13 @@ export function buildSessionList( scheduledJobs: readonly AgentCronJob[] = [], ): SessionSummary[] { const activeBySessionFile = new Map(); - const heartbeatSessionIds = new Set(); - const registeredHeartbeatSessionIds = new Set(); - const registeredCronSessionIds = new Set(); - const registeredHeartbeatSessionFiles = new Set(); - const registeredCronSessionFiles = new Set(); - for (const job of scheduledJobs) { - const heartbeat = isHeartbeatCronJob(job); - if (heartbeat && job.status === "active") heartbeatSessionIds.add(job.activeSessionId); - // A paused heartbeat cannot fire, so unlike a live heartbeat (or a registered - // cron job) it must not silently pin a worker forever. - const registered = heartbeat ? job.status === "active" : job.status === "active" || job.status === "paused"; - if (!registered) continue; - const ids = heartbeat ? registeredHeartbeatSessionIds : registeredCronSessionIds; - const files = heartbeat ? registeredHeartbeatSessionFiles : registeredCronSessionFiles; - ids.add(job.activeSessionId); - files.add(resolve(job.sessionFile)); - } + const { + activeHeartbeatSessionIds: heartbeatSessionIds, + heartbeatSessionIds: registeredHeartbeatSessionIds, + cronSessionIds: registeredCronSessionIds, + heartbeatSessionFiles: registeredHeartbeatSessionFiles, + cronSessionFiles: registeredCronSessionFiles, + } = scheduledJobRegistrations(scheduledJobs); for (const activeSession of activeSessions) { const sessionFile = activeSession.runtime.session.sessionFile; diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 2c45674070..017164e64c 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -2,7 +2,7 @@ import { type ChildProcess, spawn } from "node:child_process"; import { createHash, randomBytes, randomUUID } from "node:crypto"; import { chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { createServer, type Server, type Socket } from "node:net"; -import { dirname, join, resolve } from "node:path"; +import { basename, dirname, join, resolve } from "node:path"; import { Writable } from "node:stream"; import { getLogger } from "@earendil-works/pi-ai"; import { createCliSubprocessEnv, createCliSubprocessLaunchSpec } from "../../cli/subprocess-launch.js"; @@ -56,6 +56,15 @@ import type { AgentConnectionHeartbeat } from "../agent-connection/types.js"; import { attachJsonlLineReader, serializeJsonLine } from "../rpc/jsonl.js"; import type { PrivateFrame } from "../session-worker/private-framing.js"; import { createActiveSessionId, type DaemonSocketClient } from "./active-session-state.js"; +import { + AgentRoster, + type AgentRosterEntry, + passivatedWorkerRosterEntry, + rosterAgentIdForSummary, + sessionSummaryFromRosterEntry, + type WorkerRosterEntry, + workerRosterEntryFromSummary, +} from "./agent-roster.js"; import { CommandRecoveryJournal, createCommandIdempotencyKey } from "./command-recovery-journal.js"; import { CompactAssistantStreamReconstructor, isCompactAssistantDelta } from "./compact-session-stream.js"; import { DAEMON_CATALOG_ROLE_ENV, DaemonCatalogClient } from "./daemon-catalog-process.js"; @@ -116,6 +125,7 @@ import { DAEMON_WORKER_ACTIVE_SESSION_ID_ENV, DAEMON_WORKER_RECOVERY_JOURNAL_ENV, DAEMON_WORKER_ROLE_ENV, + DAEMON_WORKER_ROSTER_CAPABILITY, DAEMON_WORKER_STARTUP_GATE_COMMIT, DAEMON_WORKER_STARTUP_GATE_FD_ENV, DAEMON_WORKER_SUPERVISOR_SOCKET_ENV, @@ -124,13 +134,20 @@ import { type DaemonWorkerDescriptor, type DaemonWorkerFrameHeader, type DaemonWorkerLifecycle, + type DaemonWorkerRosterOutbound, durableDaemonCreateCommand, durableDaemonWorkerDescriptor, + ROSTER_HEARTBEAT_INTERVAL_MS, SESSION_LEASE_OWNER_ID_ENV, SESSION_LEASES_ENABLED_ENV, } from "./daemon-worker-protocol.js"; import { MutationDrainLatch } from "./mutation-drain-latch.js"; -import { createRlmLedgerRegistrySeedSource, RlmSpawnLedger } from "./rlm-ledger.js"; +import { + createRlmLedgerRegistrySeedSource, + type RlmLedgerEdge, + RlmSpawnLedger, + tombstoneSavedSessionDelete, +} from "./rlm-ledger.js"; import { serializeSavedSessionInfo } from "./saved-session-info.js"; import { SNAPSHOT_TARGET_CHUNK_BYTES, SnapshotTranscriptCache } from "./snapshot-transcript-cache.js"; import { WorkerRecoveryJournal } from "./worker-recovery-journal.js"; @@ -140,6 +157,8 @@ type DaemonCommandBody = DistributiveOmit; const structuredLog = getLogger("coding-agent.daemon-supervisor"); const WORKER_CONNECT_TIMEOUT_MS = 30_000; +const ROSTER_WATCHDOG_INTERVAL_MS = 15_000; +const ROSTER_STALE_AFTER_MS = 3 * ROSTER_HEARTBEAT_INTERVAL_MS; const WORKER_REQUEST_TIMEOUT_MS = 24 * 60 * 60 * 1000; const INPUT_PAUSE_CLEANUP_TIMEOUT_MS = 5_000; const UPDATE_RESTART_MUTATION_DRAIN_TIMEOUT_MS = 80_000; @@ -293,6 +312,14 @@ interface ResidentWorker { ownerCleanupTimer?: ReturnType; promotedOwnerClientId?: string; updateRestartPrepareClient?: DaemonWorkerClient; + lastFrameAt?: number; + rosterStale?: boolean; + /** In-flight replacement connection during authentication; an allowed frame source alongside client. */ + pendingClient?: DaemonWorkerClient; + /** Bumped per applied roster frame; a summaries pull that straddles a frame must not gap-fill. */ + rosterEpoch?: number; + rosterApplyChain?: Promise; + rosterRepairPull?: Promise; } interface SnapshotDuplicateValidation { @@ -472,6 +499,14 @@ function isDaemonWorkerDescriptor(value: unknown, socketPath: string): value is ); } +class PreRosterWorkerError extends Error {} + +function workerAuthAdvertisesRoster(data: unknown): boolean { + if (typeof data !== "object" || data === null) return false; + const capabilities = (data as { capabilities?: unknown }).capabilities; + return Array.isArray(capabilities) && capabilities.includes(DAEMON_WORKER_ROSTER_CAPABILITY); +} + function sessionSummariesFromResponse(response: DaemonResponse): SessionSummary[] { if (!response.success || !response.data || typeof response.data !== "object" || !("sessions" in response.data)) { throw new Error("Session worker returned an invalid list response"); @@ -569,37 +604,6 @@ function normalizeCapabilities( return normalized; } -function mergeSessionLists(active: readonly SessionSummary[], saved: readonly SessionInfo[]): SessionSummary[] { - const activeByFile = new Map(); - for (const summary of active) { - if (summary.sessionFile) { - activeByFile.set(resolve(summary.sessionFile), summary); - } - } - const merged: SessionSummary[] = []; - const seenActiveIds = new Set(); - for (const session of saved) { - const resident = activeByFile.get(resolve(session.path)); - if (resident) { - merged.push({ - ...resident, - created: resident.created ?? session.created.toISOString(), - modified: resident.modified ?? session.modified.toISOString(), - firstMessage: resident.firstMessage ?? session.firstMessage, - }); - seenActiveIds.add(resident.activeSessionId ?? resident.id); - } else { - merged.push(summaryForInactiveSession(session)); - } - } - for (const summary of active) { - if (!seenActiveIds.has(summary.activeSessionId ?? summary.id)) { - merged.push(summary); - } - } - return merged; -} - export async function runDaemonSupervisorMode(options: DaemonSupervisorOptions): Promise { const socketPath = normalizeSocketPath(options.socketPath ?? defaultDaemonSocketPath()); const supervisor = new DaemonSupervisor(socketPath, options); @@ -643,6 +647,8 @@ export class DaemonSupervisor { private readonly pendingSessionNames = new Set(); private readonly catalog: DaemonCatalogClient; private readonly settingsManager: SettingsManager; + private rosterStore?: AgentRoster; + private rosterWatchdogTimer?: ReturnType; private rlmSpawnLedgerInstance?: RlmSpawnLedger; private idleEvictionTimer?: ReturnType; private idleEvictionSweep?: Promise; @@ -721,6 +727,7 @@ export class DaemonSupervisor { this.log(`Migrated ${migratedJobs} scheduled jobs into session artifacts`); } await this.catalog.start().catch((error) => this.log(`Could not start daemon catalog: ${String(error)}`)); + await this.seedRosterLedger(); let adoptionFailure: unknown; let adoptionFailed = false; await Promise.all( @@ -742,6 +749,8 @@ export class DaemonSupervisor { this.scheduleOwnedWorkerCleanup(worker); } this.scheduleIdleEvictionSweep(); + this.rosterWatchdogTimer = setInterval(() => this.sweepRosterStaleness(), ROSTER_WATCHDOG_INTERVAL_MS); + this.rosterWatchdogTimer.unref(); await this.ownership.updatePhase("owner"); this.log(`Prime Agent daemon supervisor ${this.generation} listening on ${this.socketPath}`); this.markReady(); @@ -782,6 +791,12 @@ export class DaemonSupervisor { this.idleEvictionTimer = undefined; } + private clearRosterWatchdogTimer(): void { + if (!this.rosterWatchdogTimer) return; + clearInterval(this.rosterWatchdogTimer); + this.rosterWatchdogTimer = undefined; + } + private scheduleIdleEvictionSweep(): void { if (this.shuttingDown || this.idleEvictionTimer || this.idleEvictionSweep) return; const delayMs = idleEvictionSweepIntervalMs(this.settingsManager.getIdleEvictionMinutes()); @@ -806,20 +821,21 @@ export class DaemonSupervisor { hasOwnerClient: worker.descriptor.ownerClientId !== undefined, isPreparingUpdateRestart: this.updateRestartPhase !== undefined || worker.updateRestartPrepareClient !== undefined, - sessions: [...worker.summaries.values()].map((summary) => { - const activeSessionId = summary.activeSessionId ?? summary.id; - return { - // Use the canonical busy projection: a parent remains active for - // residency purposes while any of its RLM descendants is running. - isSessionActive: isSessionSummaryBusy(summary), - attachedClients: [...this.clients].filter((client) => - client.attachedActiveSessionIds.has(activeSessionId), - ).length, - hasRegisteredHeartbeat: summary.hasRegisteredHeartbeat === true, - hasRegisteredCronJob: summary.hasRegisteredCronJob === true, - lastActivityAt: Date.parse(summary.lastActivityAt ?? ""), - }; - }), + sessions: this.workerRosterEntries(worker) + .filter((entry) => !entry.queuedChild) + .map(sessionSummaryFromRosterEntry) + .map((summary) => { + const activeSessionId = summary.activeSessionId ?? summary.id; + return { + isSessionActive: isSessionSummaryBusy(summary), + attachedClients: [...this.clients].filter((client) => + client.attachedActiveSessionIds.has(activeSessionId), + ).length, + hasRegisteredHeartbeat: summary.hasRegisteredHeartbeat === true, + hasRegisteredCronJob: summary.hasRegisteredCronJob === true, + lastActivityAt: Date.parse(summary.lastActivityAt ?? ""), + }; + }), }; } @@ -927,13 +943,12 @@ export class DaemonSupervisor { } } - /** Re-validates one worker on fresh summaries under the caller's fence, then passivates it. */ private async passivateWorkerIfStillEligible( worker: ResidentWorker, isStillEligible: () => boolean, describeEvicted: () => string, ): Promise { - await this.refreshWorkerSummaries(worker); + await this.refreshWorkerSummaries(worker, false, true); if (!isStillEligible()) return; await this.stopWorker(worker, true); this.log(describeEvicted()); @@ -952,7 +967,7 @@ export class DaemonSupervisor { return; } try { - await this.refreshWorkerSummaries(worker); + await this.refreshWorkerSummaries(worker, false, true); } catch { return; } @@ -981,7 +996,9 @@ export class DaemonSupervisor { ) { return false; } - const summaries = [...worker.summaries.values()]; + const summaries = this.workerRosterEntries(worker) + .filter((entry) => !entry.queuedChild) + .map(sessionSummaryFromRosterEntry); const hasAttachedClient = summaries.some((summary) => { const summaryActiveSessionId = summary.activeSessionId ?? summary.id; return [...this.clients].some((client) => client.attachedActiveSessionIds.has(summaryActiveSessionId)); @@ -1623,8 +1640,8 @@ export class DaemonSupervisor { worker.client !== undefined, ) .flatMap((worker) => { - const root = worker.summaries.get(worker.descriptor.rootActiveSessionId); - return root ? [this.agentPeerSummary(root)] : []; + const root = this.roster().byActiveSessionId(worker.descriptor.rootActiveSessionId); + return root ? [this.agentPeerSummary(sessionSummaryFromRosterEntry(root))] : []; }); return success(command.id, command.type, { peers }); } @@ -1642,16 +1659,16 @@ export class DaemonSupervisor { // A create forwarded to a recovering worker still surfaces an opaque lifecycle error. const response = await this.forwardToWorker(worker, withoutSupervisorCreateFields(command)); if (response.success && isSessionSummary(response.data)) { - await this.refreshWorkerSummaries(worker); + this.writeRosterEntry(workerRosterEntryFromSummary(response.data), worker); return { ...response, id: command.id, data: this.publicSummary(worker, response.data) }; } return responseWithId(response, command.id); } - const summary = worker.summaries.get(worker.descriptor.rootActiveSessionId); - if (!summary) { + const root = this.roster().byActiveSessionId(worker.descriptor.rootActiveSessionId); + if (!root) { throw new Error("Session worker started without a root session"); } - return success(command.id, "create", this.publicSummary(worker, summary)); + return success(command.id, "create", this.publicSummary(worker, sessionSummaryFromRosterEntry(root))); } case "attach": { const attached = await this.attachClient(client, command); @@ -2088,6 +2105,10 @@ export class DaemonSupervisor { `failed to append RLM ledger rename: ${error instanceof Error ? error.message : String(error)}`, ); }); + const entry = this.roster().bySessionFile(canonicalSessionPath(command.sessionPath)); + if (entry) { + this.writeRosterEntry({ ...entry, summary: { ...entry.summary, sessionName: target.name } }); + } return success(command.id, command.type); } const match = await this.findWorkerForClient(client, command.activeSessionId); @@ -2099,11 +2120,29 @@ export class DaemonSupervisor { } case "delete_saved_session": if (!command.activeSessionId) { - const active = this.findWorkerBySessionFile(command.sessionPath); - if (active) { + const deletedPath = canonicalSessionPath(command.sessionPath); + const entry = this.roster().bySessionFile(deletedPath); + if (entry?.summary.activeSessionId !== undefined) { throw new Error("Cannot delete the currently active session"); } + const owner = this.findWorkerBySessionFile(command.sessionPath); + if (owner) { + // A client-owned worker's files are invisible to other clients: a foreign delete is an unknown target. + this.assertWorkerAccessibleToClient(client, owner, command.sessionPath); + if (owner.client && !this.isWorkerStopping(owner)) { + return this.forwardToWorker(owner, command); + } + if (!(await this.reclaimStaleWorkerRegistration(owner))) { + throw new Error( + `Session worker is ${this.effectiveWorkerState(owner)}; retry the delete once it is reachable`, + ); + } + } + await tombstoneSavedSessionDelete(this.rlmSpawnLedger(), command.sessionPath, entry?.summary); const result = await this.catalog.delete(command.sessionPath); + if (result.ok && entry && this.roster().get(entry.agentId) === entry) { + this.roster().delete(entry.agentId); + } return success(command.id, command.type, result); } break; @@ -2148,9 +2187,10 @@ export class DaemonSupervisor { sessionPath, continueRecent: false, }); + const root = this.roster().byActiveSessionId(worker.descriptor.rootActiveSessionId); const summary = this.findSummaryInWorker(worker, sessionPath) ?? - worker.summaries.get(worker.descriptor.rootActiveSessionId); + (root ? sessionSummaryFromRosterEntry(root) : undefined); if (!summary) throw new Error("Woken session worker has no target session"); target = { worker, summary }; } @@ -2251,24 +2291,25 @@ export class DaemonSupervisor { client: DaemonSocketClient, command: Extract, ): Promise { - await Promise.all( - [...this.workers.values()] - .filter((worker) => !this.isWorkerStopping(worker)) - .map((worker) => this.refreshWorkerSummaries(worker).catch(() => undefined)), - ); - const clientOwnedWorkers = [...this.workers.values()].filter((worker) => !this.isVisibleWorker(worker)); - // Stopping workers stay listed (with an honest workerState) because this - // list also feeds busy-daemon safety checks in daemon-launch. - const active = [...this.workers.values()] - .filter( - (worker) => - this.isVisibleWorker(worker) || - (command.includeClientOwned === true && this.isWorkerAccessibleToClient(client, worker)), - ) - .flatMap((worker) => [...worker.summaries.values()].map((summary) => this.publicSummary(worker, summary))); - const busyClientOwnedSessionCount = clientOwnedWorkers - .flatMap((worker) => [...worker.summaries.values()]) - .filter(isSessionSummaryBusy).length; + const active: SessionSummary[] = []; + const activeByFile = new Map(); + let busyClientOwnedSessionCount = 0; + for (const entry of this.roster().values()) { + if (entry.queuedChild) continue; + const worker = entry.workerId !== undefined ? this.workers.get(entry.workerId) : undefined; + if (worker === undefined) continue; + const summary = this.publicSummary(worker, sessionSummaryFromRosterEntry(entry)); + if (this.isVisibleWorker(worker)) { + active.push(summary); + if (summary.sessionFile) activeByFile.set(canonicalSessionPath(summary.sessionFile), summary); + continue; + } + if (summary.sessionFile) activeByFile.set(canonicalSessionPath(summary.sessionFile), summary); + if (isSessionSummaryBusy(summary)) busyClientOwnedSessionCount += 1; + if (command.includeClientOwned === true && this.isWorkerAccessibleToClient(client, worker)) { + active.push(summary); + } + } const data = { sessions: active, ...(command.includeClientOwned ? { busyClientOwnedSessionCount } : {}), @@ -2277,8 +2318,74 @@ export class DaemonSupervisor { return success(command.id, "list", data); } const sessionDir = command.sessionDir ?? this.defaultSessionConfig.sessionDir; - const saved = await this.catalog.list(command.cwd ? resolve(command.cwd) : undefined, sessionDir); - return success(command.id, "list", { ...data, sessions: mergeSessionLists(active, saved) }); + const scanned = await this.catalog.list(command.cwd ? resolve(command.cwd) : undefined, sessionDir); + const cwd = command.cwd ? resolve(command.cwd) : undefined; + const merged: SessionSummary[] = []; + const servedRows = new Set(active); + const mergedActiveFiles = new Set(); + const scannedFiles = new Set(); + for (const info of scanned) { + const file = canonicalSessionPath(info.path); + scannedFiles.add(file); + const workerRow = activeByFile.get(file); + if (workerRow && servedRows.has(workerRow)) { + merged.push(workerRow); + mergedActiveFiles.add(file); + continue; + } + // The on-disk scan is public: an unserved (client-owned) worker row hides its live metadata only. + merged.push(summaryForInactiveSession(info)); + } + const offlineRows: AgentRosterEntry[] = []; + for (const entry of this.roster().values()) { + if (entry.queuedChild || entry.summary.activeSessionId !== undefined) continue; + if (entry.workerId !== undefined && this.workers.has(entry.workerId)) continue; + const file = entry.summary.sessionFile ? canonicalSessionPath(entry.summary.sessionFile) : undefined; + if (file === undefined || scannedFiles.has(file) || activeByFile.has(file)) continue; + offlineRows.push(entry); + } + for (const hydrated of await Promise.all(offlineRows.map((entry) => this.hydrateSeededEntry(entry)))) { + const summary = sessionSummaryFromRosterEntry(hydrated); + if (cwd !== undefined && resolve(summary.cwd) !== cwd) continue; + if (!this.matchesListSessionDir(summary, sessionDir)) continue; + merged.push(summary); + } + for (const summary of active) { + const file = summary.sessionFile ? canonicalSessionPath(summary.sessionFile) : undefined; + if (file !== undefined && mergedActiveFiles.has(file)) continue; + merged.push(summary); + } + return success(command.id, "list", { ...data, sessions: merged }); + } + + private async hydrateSeededEntry(entry: AgentRosterEntry): Promise { + if (entry.seededCwd !== true || !entry.summary.sessionFile) return entry; + const info = await readSessionInfo(entry.summary.sessionFile).catch(() => undefined); + if (!info) return entry; + const current = this.roster().get(entry.agentId); + if (current !== entry) return current ?? entry; + const { seededCwd, ...rest } = entry; + return this.roster().write( + { ...rest, summary: { ...entry.summary, cwd: info.cwd } }, + entry.workerId, + entry.statusLabel, + ); + } + + private matchesListSessionDir(summary: SessionSummary, sessionDir: string | undefined): boolean { + if (sessionDir === undefined) return true; + if (!summary.sessionFile) return false; + let file = resolve(summary.sessionFile); + let parentSessionPath = summary.parentSessionPath; + const visited = new Set(); + while (parentSessionPath !== undefined) { + const canonical = canonicalSessionPath(parentSessionPath); + if (visited.has(canonical)) break; + visited.add(canonical); + file = resolve(parentSessionPath); + parentSessionPath = this.roster().bySessionFile(canonical)?.summary.parentSessionPath; + } + return dirname(file) === resolve(sessionDir); } private async handleSavedSessionList( @@ -2424,7 +2531,7 @@ export class DaemonSupervisor { } this.assertWorkerCreateOwner(current, ownerClientId, sessionPath); if (!this.isWorkerReadyForCreate(current)) { - if (!current.summaries.has(current.descriptor.rootActiveSessionId)) { + if (!this.workerHasRosterRoot(current)) { throw new Error( `Session "${sessionPath}" worker is unavailable for reuse: assigned root session is missing`, ); @@ -2459,11 +2566,17 @@ export class DaemonSupervisor { return ( worker.descriptor.lifecycle === "ready" && worker.client !== undefined && - worker.summaries.has(worker.descriptor.rootActiveSessionId) && + this.workerHasRosterRoot(worker) && !this.isWorkerStopping(worker) ); } + private workerHasRosterRoot(worker: ResidentWorker): boolean { + return ( + this.roster().byActiveSessionId(worker.descriptor.rootActiveSessionId)?.workerId === worker.descriptor.workerId + ); + } + /** * A stopping worker whose process already died can strand its registration * (for example when the stop timed out and its finalization was interrupted @@ -2492,6 +2605,7 @@ export class DaemonSupervisor { await this.recoverUncertainWorkerOperations(worker, false); this.invalidateWorkerSessionInputPauses(worker, "Session worker stopped while input was paused"); this.workers.delete(worker.descriptor.workerId); + this.flipWorkerRosterEntriesInactive(worker); this.deleteWorkerDescriptor(worker); return true; } @@ -2730,7 +2844,7 @@ export class DaemonSupervisor { if ((summary.activeSessionId ?? summary.id) !== rootActiveSessionId) { throw new Error("Session worker did not preserve its assigned active session id"); } - worker.summaries.set(rootActiveSessionId, summary); + this.writeRosterEntry(workerRosterEntryFromSummary(summary), worker); worker.descriptor.rootSessionId = summary.sessionId; worker.descriptor.sessionFile = summary.sessionFile; await this.subscribeWorker(worker, rootActiveSessionId); @@ -2820,21 +2934,31 @@ export class DaemonSupervisor { try { await client.connect(Math.min(500, Math.max(50, deadline - Date.now()))); await client.waitForHello(1000); - await client.authenticateWorker( - worker.descriptor.authenticationToken, - this.supervisorAuthenticationClaim(), - 1000, - ); - await this.assertRecoveryAllowed(); - client.onFrame((frame) => this.handleWorkerFrame(worker, frame)); + // Listen before authenticating: the worker flushes its roster snapshot right after auth succeeds. + client.onFrame((frame) => this.handleWorkerFrame(worker, frame, client)); client.onClose((error) => void this.handleWorkerClose(worker, client, error)); - worker.client?.close(); - worker.client = client; - return client; + worker.pendingClient = client; + try { + const authResponse = await client.authenticateWorker( + worker.descriptor.authenticationToken, + this.supervisorAuthenticationClaim(), + 1000, + ); + await this.assertRecoveryAllowed(); + if (!workerAuthAdvertisesRoster(authResponse.data)) { + throw new PreRosterWorkerError("Session worker predates the roster protocol and must be restarted"); + } + worker.lastFrameAt = Date.now(); + worker.client?.close(); + worker.client = client; + return client; + } finally { + if (worker.pendingClient === client) worker.pendingClient = undefined; + } } catch (error) { lastError = error; client.close(); - if (isSupervisorRecoveryCancelled(error)) { + if (isSupervisorRecoveryCancelled(error) || error instanceof PreRosterWorkerError) { throw error; } await delay(25); @@ -2897,11 +3021,12 @@ export class DaemonSupervisor { } return; } + let observedProcessStartId: string | undefined; try { if (!isProcessAlive(worker.descriptor.pid)) { throw new Error("Session worker process is no longer running"); } - const observedProcessStartId = getProcessStartId(worker.descriptor.pid); + observedProcessStartId = getProcessStartId(worker.descriptor.pid); await this.connectWorker(worker, 2000); await this.subscribeWorker(worker, worker.descriptor.rootActiveSessionId); await this.refreshWorkerSummaries(worker, true); @@ -2918,10 +3043,55 @@ export class DaemonSupervisor { return; } this.log(`Could not adopt worker ${worker.descriptor.workerId}: ${String(error)}`); + // A client-owned worker's launch env lives only with its owner; recoverWorker parks it instead. + if (error instanceof PreRosterWorkerError && worker.descriptor.ownerClientId === undefined) { + try { + await this.restartPreRosterWorker(worker, observedProcessStartId); + return; + } catch (restartError) { + if (isSupervisorRecoveryCancelled(restartError)) { + return; + } + this.log(`Could not restart pre-roster worker ${worker.descriptor.workerId}: ${String(restartError)}`); + } + } await this.recoverWorker(worker); } } + private async restartPreRosterWorker( + worker: ResidentWorker, + observedProcessStartId: string | undefined, + ): Promise { + await this.assertRecoveryAllowed(); + if (worker.descriptor.processStartId === undefined && observedProcessStartId !== undefined) { + worker.descriptor.processStartId = observedProcessStartId; + } + const identity = () => this.processIdentity(worker.descriptor.pid, worker.descriptor.processStartId); + const initialIdentity = identity(); + await this.recoverUncertainWorkerOperations(worker, initialIdentity === "current"); + if (initialIdentity === "current") { + // SIGKILL is uninterceptable; this wait only covers kernel teardown of the old process and socket. + const killDeadline = Date.now() + 1000; + while (identity() === "current" && Date.now() < killDeadline) { + await delay(25); + } + } + const finalIdentity = identity(); + if (finalIdentity !== "gone" && finalIdentity !== "replaced") { + worker.descriptor.lifecycle = "failed"; + worker.descriptor.lastError = `Pre-roster worker process ${worker.descriptor.pid} is still running and cannot be replaced safely`; + this.persistWorker(worker); + this.markWorkerRosterEntries(worker, "failed"); + this.log(`Kept pre-roster worker ${worker.descriptor.workerId} failed: ${worker.descriptor.lastError}`); + return; + } + if (this.isWorkerRecoveryCancelled(worker)) { + return; + } + await this.launchWorker(worker.descriptor.createCommand, worker, worker.descriptor.ownerClientId); + } + private async handleWorkerClose(worker: ResidentWorker, client: DaemonWorkerClient, error: Error): Promise { if (worker.client !== client) { return; @@ -2959,6 +3129,7 @@ export class DaemonSupervisor { if (this.shuttingDown || worker.intentionalStop) { return; } + this.markWorkerRosterEntries(worker, "recovering"); try { await this.assertRecoveryAllowed(); } catch (recoveryError) { @@ -3280,6 +3451,7 @@ export class DaemonSupervisor { worker.descriptor.lifecycle = "failed"; worker.descriptor.lastError = "Waiting for a client with fresh runtime context"; this.persistWorker(worker); + this.markWorkerRosterEntries(worker, "failed"); return; } const safeToKillWorkerProcess = @@ -3314,6 +3486,7 @@ export class DaemonSupervisor { } worker.descriptor.lifecycle = "failed"; this.persistWorker(worker); + this.markWorkerRosterEntries(worker, "failed"); this.log(`Worker ${worker.descriptor.workerId} failed after three recovery attempts`); })().finally(() => { worker.recovery = undefined; @@ -3332,7 +3505,11 @@ export class DaemonSupervisor { private async recoverUncertainWorkerOperations(worker: ResidentWorker, killWorkerProcess = true): Promise { await this.assertRecoveryAllowed(); - if (killWorkerProcess) { + // Re-check at the last synchronous moment: the process can exit in the await gap and the PID recycle. + if ( + killWorkerProcess && + this.processIdentity(worker.descriptor.pid, worker.descriptor.processStartId) === "current" + ) { signalProcessGroupOrProcess(worker.descriptor.pid, "SIGKILL"); } const orphanProcessJournalPath = worker.descriptor.orphanProcessJournalPath; @@ -3401,14 +3578,25 @@ export class DaemonSupervisor { ); } - private async refreshWorkerSummaries(worker: ResidentWorker, recovery = false): Promise { + private async refreshWorkerSummaries( + worker: ResidentWorker, + recovery = false, + fillGaps = recovery, + retried = false, + ): Promise { if (this.isWorkerStopping(worker)) { throw new Error("Session worker is stopping"); } if (!worker.client) { throw new Error("Session worker is not connected"); } - const response = await worker.client.request({ type: "list" }, 5000); + const pullSource = worker.client; + const epochAtStart = worker.rosterEpoch ?? 0; + const response = await pullSource.request({ type: "list" }, 5000); + // A frame received mid-pull can remove rows this stale pull would resurrect; re-pull once, then skip the fill. + if (fillGaps && (worker.rosterEpoch ?? 0) !== epochAtStart && !retried) { + return this.refreshWorkerSummaries(worker, recovery, fillGaps, true); + } const summaries = sessionSummariesFromResponse(response); const nextSummaries = new Map(summaries.map((summary) => [summary.activeSessionId ?? summary.id, summary])); const root = nextSummaries.get(worker.descriptor.rootActiveSessionId); @@ -3416,6 +3604,11 @@ export class DaemonSupervisor { throw new Error(`Session worker omitted its root session during recovery`); } worker.summaries = nextSummaries; + if (fillGaps) { + await this.chainWorkerRosterApply(worker, pullSource, () => { + if ((worker.rosterEpoch ?? 0) === epochAtStart) this.syncRosterFromWorkerSummaries(worker); + }); + } for (const summary of summaries) { const activeSessionId = summary.activeSessionId ?? summary.id; if (summary.streamingMessage?.role === "assistant") { @@ -3428,30 +3621,35 @@ export class DaemonSupervisor { if (recovery) { await this.assertRecoveryAllowed(); } - worker.descriptor.rootSessionId = root.sessionId; - worker.descriptor.sessionFile = root.sessionFile; - worker.descriptor.createCommand = durableDaemonCreateCommand({ - type: "create", - sessionPath: root.sessionFile, - noSession: worker.descriptor.createCommand.noSession, + await this.chainWorkerRosterApply(worker, pullSource, () => { + if ((worker.rosterEpoch ?? 0) !== epochAtStart) return; + worker.descriptor.rootSessionId = root.sessionId; + worker.descriptor.sessionFile = root.sessionFile; + worker.descriptor.createCommand = durableDaemonCreateCommand({ + type: "create", + sessionPath: root.sessionFile, + noSession: worker.descriptor.createCommand.noSession, + }); + this.persistWorker(worker); }); - this.persistWorker(worker); } } 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), + const rosterRows = [...this.roster().values()]; + const entries = rosterRows.map((entry) => this.familyCatalogEntry(sessionSummaryFromRosterEntry(entry))); + const knownFiles = new Set( + rosterRows.flatMap((entry) => + entry.summary.sessionFile ? [canonicalSessionPath(entry.summary.sessionFile)] : [], + ), ); + const scanned = await this.catalog.list(undefined, this.defaultSessionConfig.sessionDir); + for (const info of scanned) { + if (knownFiles.has(canonicalSessionPath(info.path))) continue; + if ((info.rlmDepth ?? (info.parentSessionPath ? -1 : 0)) !== 0) continue; + entries.push(this.familyCatalogEntry(summaryForInactiveSession(info))); + } + return entries; } private async withSessionNameReservation( @@ -3483,6 +3681,296 @@ export class DaemonSupervisor { }); } + private roster(): AgentRoster { + this.rosterStore ??= new AgentRoster(canonicalSessionPath); + return this.rosterStore; + } + + private writeRosterEntry( + entry: WorkerRosterEntry, + worker?: ResidentWorker, + statusLabel?: AgentRosterEntry["statusLabel"], + ): AgentRosterEntry { + return this.roster().write(entry, worker?.descriptor.workerId, statusLabel); + } + + private workerOwnedRosterSummaryForPath(canonicalPath: string): SessionSummary | undefined { + const entry = this.roster().bySessionFile(canonicalPath); + if (!entry || entry.workerId === undefined || !this.workers.has(entry.workerId)) return undefined; + return sessionSummaryFromRosterEntry(entry); + } + + private workerRosterEntries(worker: ResidentWorker): AgentRosterEntry[] { + return this.roster().entriesForWorker(worker.descriptor.workerId); + } + + private async seedRosterLedger(): Promise { + try { + for (const info of await this.catalog.list(undefined, this.defaultSessionConfig.sessionDir)) { + const entry = workerRosterEntryFromSummary(summaryForInactiveSession(info)); + if (!this.roster().has(entry.agentId)) this.writeRosterEntry(entry); + } + } catch (error) { + this.log(`Could not seed the agent roster from the session catalog: ${String(error)}`); + } + try { + // Stat-reconciled: rows the disk cannot back (out-of-band transcript removal) never seed. + for (const edge of await this.rlmSpawnLedger().liveEdges()) { + const entry = this.rosterEntryForSpawnLedgerEdge(edge); + if (this.roster().has(entry.agentId)) continue; + if (this.roster().hasSessionFile(canonicalSessionPath(edge.child))) continue; + this.roster().write({ ...entry, seededCwd: true }); + } + } catch (error) { + this.log(`Could not seed the agent roster from the spawn ledger: ${String(error)}`); + } + } + + private rosterEntryForSpawnLedgerEdge(edge: RlmLedgerEdge): WorkerRosterEntry { + const persistedSessionId = basename(edge.child, ".jsonl"); + const summary: WorkerRosterEntry["summary"] = { + id: persistedSessionId, + lifecycle: "live", + activity: "idle", + isSessionActive: false, + runtimeKind: "subagent", + rlmDepth: edge.depth, + sessionId: persistedSessionId, + sessionFile: edge.child, + sessionName: edge.name, + cwd: dirname(edge.child), + isStreaming: false, + isCompacting: false, + attachedClients: 0, + messageCount: 0, + parentSessionPath: edge.parent, + rlmChildId: edge.childId, + }; + return { agentId: rosterAgentIdForSummary(summary), summary }; + } + + private consumeWorkerRosterDelta(worker: ResidentWorker, payload: Buffer, source?: DaemonWorkerClient): void { + let delta: Extract; + try { + delta = JSON.parse(payload.toString("utf8")) as Extract; + } catch { + return; + } + if (delta.type !== "roster_delta" || !Array.isArray(delta.entries)) return; + worker.rosterEpoch = (worker.rosterEpoch ?? 0) + 1; + const applySource = source ?? worker.client ?? worker.pendingClient; + if (!this.isWorkerRosterApplyCurrent(worker, applySource)) return; + if (delta.snapshot !== true && worker.rosterApplyChain === undefined) { + this.applyWorkerRosterDelta(worker, delta); + return; + } + this.chainWorkerRosterApply(worker, applySource, () => + delta.snapshot === true + ? this.applyWorkerRosterSnapshot(worker, delta, applySource) + : this.applyWorkerRosterDelta(worker, delta), + ); + } + + private chainWorkerRosterApply( + worker: ResidentWorker, + source: DaemonWorkerClient | undefined, + apply: () => void | Promise, + ): Promise { + const chained = (worker.rosterApplyChain ?? Promise.resolve()) + .then(() => { + if (!this.isWorkerRosterApplyCurrent(worker, source)) return; + return apply(); + }) + .catch((error: unknown) => { + this.log(`could not apply a roster frame: ${String(error)}`); + this.scheduleRosterRepairPull(worker); + }); + worker.rosterApplyChain = chained; + void chained.finally(() => { + if (worker.rosterApplyChain === chained) worker.rosterApplyChain = undefined; + }); + return chained; + } + + // An apply is valid only while its own source connection is current: dead connections' parked applies abort. + private isWorkerRosterApplyCurrent(worker: ResidentWorker, source: DaemonWorkerClient | undefined): boolean { + return ( + this.workers.get(worker.descriptor.workerId) === worker && + source !== undefined && + (source === worker.client || source === worker.pendingClient) + ); + } + + private scheduleRosterRepairPull(worker: ResidentWorker): void { + if (worker.rosterRepairPull || !this.isWorkerRosterApplyCurrent(worker, worker.client)) return; + // The marker stays set while the repair's own fill applies, so a failing repair never respawns itself. + worker.rosterRepairPull = this.refreshWorkerSummaries(worker, false, true) + .catch((error: unknown) => + this.log(`Roster repair pull failed for worker ${worker.descriptor.workerId}: ${String(error)}`), + ) + .finally(() => { + worker.rosterRepairPull = undefined; + }); + } + + private applyWorkerRosterDelta( + worker: ResidentWorker, + delta: Extract, + ): void { + for (const entry of delta.entries) { + this.writeRosterEntry(entry, worker); + this.syncRootDescriptorFromRosterEntry(worker, entry); + } + for (const agentId of delta.removedAgentIds ?? []) { + this.roster().delete(agentId); + } + } + + private async applyWorkerRosterSnapshot( + worker: ResidentWorker, + delta: Extract, + source?: DaemonWorkerClient, + ): Promise { + // Live edges are read before any deletion, so a reseeded child never surfaces as a transient removal. + let edgesFailed = false; + const edges = await this.rlmSpawnLedger() + .liveEdges() + .catch((error: unknown) => { + this.log(`Could not read the spawn ledger during a snapshot apply: ${String(error)}`); + edgesFailed = true; + return [] as RlmLedgerEdge[]; + }); + if (!this.isWorkerRosterApplyCurrent(worker, source ?? worker.client ?? worker.pendingClient)) return; + // Unreadable edges: skip the absentee sweep (it cannot tell registry children from stale rows) and repair by pull. + const sent = new Set(delta.entries.map((entry) => entry.agentId)); + const unclaimed = new Map(); + if (!edgesFailed) { + for (const entry of this.workerRosterEntries(worker)) { + if (sent.has(entry.agentId)) continue; + unclaimed.set(entry.agentId, entry); + this.roster().delete(entry.agentId); + } + } + for (const entry of delta.entries) { + this.writeRosterEntry(entry, worker); + this.syncRootDescriptorFromRosterEntry(worker, entry); + } + for (const agentId of delta.removedAgentIds ?? []) { + this.roster().delete(agentId); + } + if (edgesFailed) { + this.scheduleRosterRepairPull(worker); + return; + } + // A reseed keeps the previous claim and hydrated summary; a synthetic seed would drop + // lastActivityAt (pinning canEvictWorker on NaN) and flap the claim off on every snapshot. + // Only THIS worker's family reseeds: other families' rows are owned by their own workers or + // the startup seed, and a client-owned worker's dropped rows must not resurrect as public rows. + const workerRoot = worker.descriptor.sessionFile ?? worker.descriptor.createCommand.sessionPath; + const rootPath = workerRoot !== undefined ? canonicalSessionPath(workerRoot) : undefined; + const parentByChild = new Map( + edges.map((edge) => [canonicalSessionPath(edge.child), canonicalSessionPath(edge.parent)]), + ); + const familyRoot = (path: string): string => { + const visited = new Set(); + let current = path; + while (!visited.has(current)) { + visited.add(current); + const parent = parentByChild.get(current); + if (parent === undefined) return current; + current = parent; + } + return current; + }; + for (const edge of edges) { + if (rootPath === undefined || familyRoot(canonicalSessionPath(edge.child)) !== rootPath) continue; + const entry = this.rosterEntryForSpawnLedgerEdge(edge); + if (this.roster().has(entry.agentId)) continue; + if (this.roster().hasSessionFile(canonicalSessionPath(edge.child))) continue; + const previous = unclaimed.get(entry.agentId); + if (previous) { + const { status, statusLabel, lastHeardFromAt, workerId, ...rest } = previous; + this.writeRosterEntry(rest, worker); + continue; + } + this.roster().write({ ...entry, seededCwd: true }); + } + } + + private syncRootDescriptorFromRosterEntry(worker: ResidentWorker, entry: WorkerRosterEntry): void { + const summary = entry.summary; + if (summary.activeSessionId !== worker.descriptor.rootActiveSessionId) return; + if ( + worker.descriptor.rootSessionId === summary.sessionId && + worker.descriptor.sessionFile === summary.sessionFile + ) { + return; + } + worker.descriptor.rootSessionId = summary.sessionId; + worker.descriptor.sessionFile = summary.sessionFile; + worker.descriptor.createCommand = durableDaemonCreateCommand({ + type: "create", + sessionPath: summary.sessionFile, + noSession: worker.descriptor.createCommand.noSession, + }); + this.persistWorker(worker); + } + + // Behind the pull-epoch guard the pull is never staler than the row it replaces; never steal another worker's claim. + private syncRosterFromWorkerSummaries(worker: ResidentWorker): void { + for (const summary of worker.summaries.values()) { + const entry = workerRosterEntryFromSummary(summary); + const existing = this.roster().get(entry.agentId); + if (existing?.workerId !== undefined && existing.workerId !== worker.descriptor.workerId) continue; + this.writeRosterEntry(entry, worker); + } + } + + private markWorkerRosterEntries(worker: ResidentWorker, statusLabel: "recovering" | "failed" | undefined): void { + for (const entry of this.workerRosterEntries(worker)) { + if (!entry.queuedChild && entry.summary.activeSessionId === undefined) continue; + if (statusLabel === undefined) delete entry.statusLabel; + else entry.statusLabel = statusLabel; + } + } + + private flipWorkerRosterEntriesInactive(worker: ResidentWorker): void { + // Client-owned workers are ephemeral and private: their rows die with the registration. + const ephemeral = worker.descriptor.ownerClientId !== undefined; + for (const entry of this.workerRosterEntries(worker)) { + if (ephemeral || entry.queuedChild) { + this.roster().delete(entry.agentId); + continue; + } + this.writeRosterEntry(passivatedWorkerRosterEntry(entry)); + } + } + + private sweepRosterStaleness(now = Date.now()): void { + for (const worker of this.workers.values()) { + if (worker.client === undefined || worker.lastFrameAt === undefined) { + continue; + } + if (now - worker.lastFrameAt > ROSTER_STALE_AFTER_MS) { + const lastHeardFromAt = new Date(worker.lastFrameAt).toISOString(); + for (const entry of this.workerRosterEntries(worker)) { + entry.lastHeardFromAt = lastHeardFromAt; + } + worker.rosterStale = true; + } else if (worker.rosterStale) { + this.clearRosterStaleness(worker); + } + } + } + + private clearRosterStaleness(worker: ResidentWorker): void { + if (!worker.rosterStale) return; + worker.rosterStale = false; + for (const entry of this.workerRosterEntries(worker)) { + delete entry.lastHeardFromAt; + } + } + /** * Supervisor-side view of the spawn ledger for this supervisor's sessions * dir. Workers hold their own instances over the same file; every read @@ -3516,9 +4004,7 @@ export class DaemonSupervisor { name: 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); + const active = this.workerOwnedRosterSummaryForPath(targetPath); if (active) return this.summaryNameReservationInput(active, name); const siblings = await this.rlmLedgerSiblings(sessionPath); const saved = siblings.find((info) => canonicalSessionPath(info.path) === targetPath); @@ -3545,9 +4031,7 @@ export class DaemonSupervisor { private async assertSupervisorSavedSessionNameAvailable(sessionPath: string, name: string): Promise { const targetPath = canonicalSessionPath(sessionPath); - const active = [...this.workers.values()] - .flatMap((worker) => [...worker.summaries.values()]) - .find((summary) => summary.sessionFile && canonicalSessionPath(summary.sessionFile) === targetPath); + const active = this.workerOwnedRosterSummaryForPath(targetPath); if (active) return this.assertSupervisorSessionNameAvailable(active, name); const siblings = await this.rlmLedgerSiblings(sessionPath); const saved = siblings.find((info) => canonicalSessionPath(info.path) === targetPath); @@ -3678,7 +4162,9 @@ export class DaemonSupervisor { let matches = this.matchWorkers(selector, includeWorker); if (matches.length === 0) { await Promise.all( - [...this.workers.values()].map((worker) => this.refreshWorkerSummaries(worker).catch(() => undefined)), + [...this.workers.values()].map((worker) => + this.refreshWorkerSummaries(worker, false, true).catch(() => undefined), + ), ); matches = this.matchWorkers(selector, includeWorker); } @@ -3711,21 +4197,22 @@ export class DaemonSupervisor { private matchWorkers(selector: string, includeWorker?: (worker: ResidentWorker) => boolean): WorkerMatch[] { const exact: WorkerMatch[] = []; const suffix: WorkerMatch[] = []; - for (const worker of this.workers.values()) { - if (includeWorker && !includeWorker(worker)) { + for (const entry of this.roster().values()) { + if (entry.queuedChild) continue; + const worker = entry.workerId !== undefined ? this.workers.get(entry.workerId) : undefined; + if (!worker || (includeWorker && !includeWorker(worker))) { continue; } - for (const summary of worker.summaries.values()) { - const activeSessionId = summary.activeSessionId ?? summary.id; - const match = { worker, summary }; - if (activeSessionId === selector || summary.sessionId === selector || summary.sessionName === selector) { - exact.push(match); - } else if ( - matchesSessionIdSuffix(activeSessionId, selector) || - matchesSessionIdSuffix(summary.sessionId, selector) - ) { - suffix.push(match); - } + const summary = sessionSummaryFromRosterEntry(entry); + const activeSessionId = summary.activeSessionId ?? summary.id; + const match = { worker, summary }; + if (activeSessionId === selector || summary.sessionId === selector || summary.sessionName === selector) { + exact.push(match); + } else if ( + matchesSessionIdSuffix(activeSessionId, selector) || + matchesSessionIdSuffix(summary.sessionId, selector) + ) { + suffix.push(match); } } return exact.length > 0 ? exact : suffix; @@ -3733,7 +4220,9 @@ export class DaemonSupervisor { private findSummaryInWorker(worker: ResidentWorker, selector: string): SessionSummary | undefined { const pathSelector = looksLikeSessionPath(selector) ? canonicalSessionPath(selector) : undefined; - const summaries = [...worker.summaries.values()]; + const summaries = this.workerRosterEntries(worker) + .filter((entry) => !entry.queuedChild) + .map(sessionSummaryFromRosterEntry); const exact = summaries.find((summary) => { const activeSessionId = summary.activeSessionId ?? summary.id; return ( @@ -3756,11 +4245,11 @@ export class DaemonSupervisor { private findWorkerBySessionFile(sessionFile: string): ResidentWorker | undefined { const target = canonicalSessionPath(sessionFile); + const targetEntry = this.roster().bySessionFile(target); const matches = new Set(); for (const worker of this.workers.values()) { - const summaryMatches = [...worker.summaries.values()].some( - (summary) => summary.sessionFile && canonicalSessionPath(summary.sessionFile) === target, - ); + // The roster is the one live ownership source; the stale pull cache must not resurrect a match. + const summaryMatches = targetEntry?.workerId === worker.descriptor.workerId; const descriptorPath = worker.descriptor.sessionFile ? canonicalSessionPath(worker.descriptor.sessionFile) : undefined; @@ -3790,7 +4279,7 @@ export class DaemonSupervisor { return { ...response, id: command.id, data: this.publicSummary(worker, response.data) }; } if (command.type === "rename" && response.success && isSessionSummary(response.data)) { - await this.refreshWorkerSummaries(worker); + this.writeRosterEntry(workerRosterEntryFromSummary(response.data), worker); return { ...response, id: command.id, data: this.publicSummary(worker, response.data) }; } return responseWithId(response, command.id); @@ -4286,10 +4775,19 @@ export class DaemonSupervisor { ); } - private handleWorkerFrame(worker: ResidentWorker, frame: PrivateFrame): void { + private handleWorkerFrame( + worker: ResidentWorker, + frame: PrivateFrame, + source?: DaemonWorkerClient, + ): void { if (frame.header.kind !== "outbound") { return; } + if (source !== undefined && source !== worker.client && source !== worker.pendingClient) { + return; + } + worker.lastFrameAt = Date.now(); + this.clearRosterStaleness(worker); const { outboundType, activeSessionId, @@ -4298,6 +4796,13 @@ export class DaemonSupervisor { payloadEncoding, snapshotPurpose, } = frame.header; + if (outboundType === "roster_delta") { + this.consumeWorkerRosterDelta(worker, frame.payload, source); + return; + } + if (outboundType === "roster_heartbeat") { + return; + } if (outboundType === "heartbeats_changed") { worker.heartbeatSnapshotStale = true; this.broadcastHeartbeatsChanged(); @@ -4686,15 +5191,6 @@ export class DaemonSupervisor { } this.writeSerialized(client, publicPayload); } - if (outboundType === "session_replaced" || outboundType === "session_closed") { - void this.refreshWorkerSummaries(worker).catch(() => undefined); - } else if ( - sessionEventType === "turn_start" || - sessionEventType === "turn_end" || - sessionEventType === "rlm_child_update" - ) { - void this.refreshWorkerSummaries(worker).catch(() => undefined); - } if ( decodedOutbound?.type === "session_closed" && decodedOutbound.reason === "shutdown" && @@ -4708,6 +5204,7 @@ export class DaemonSupervisor { if ((this.workerStopCounts?.get(worker) ?? 0) === 0) { this.invalidateWorkerSessionInputPauses(worker, "Session worker stopped while input was paused"); this.workers.delete(worker.descriptor.workerId); + this.flipWorkerRosterEntriesInactive(worker); this.deleteWorkerDescriptor(worker); } } @@ -5269,6 +5766,7 @@ export class DaemonSupervisor { } this.invalidateWorkerSessionInputPauses(worker, "Session worker stopped while input was paused"); this.workers.delete(worker.descriptor.workerId); + this.flipWorkerRosterEntriesInactive(worker); if (removeDescriptor) { this.deleteWorkerDescriptor(worker); } @@ -5477,6 +5975,7 @@ export class DaemonSupervisor { private async cleanupSupervisorResourcesOnce(): Promise { this.shuttingDown = true; this.clearIdleEvictionTimer(); + this.clearRosterWatchdogTimer(); await this.idleEvictionSweep?.catch(() => undefined); for (const cleanup of this.signalCleanupHandlers.splice(0)) { await this.runCleanupStep("signal handler", cleanup); @@ -5576,6 +6075,7 @@ export class DaemonSupervisor { } this.shuttingDown = true; this.clearIdleEvictionTimer(); + this.clearRosterWatchdogTimer(); await this.idleEvictionSweep?.catch(() => undefined); if (closingReason) { for (const client of this.clients) { diff --git a/packages/coding-agent/src/modes/daemon/daemon-worker-client.ts b/packages/coding-agent/src/modes/daemon/daemon-worker-client.ts index b3e01b95ee..85ceea0994 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-worker-client.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-worker-client.ts @@ -116,11 +116,16 @@ export class DaemonWorkerClient { return this.requestWire(command, timeoutMs); } - async authenticateWorker(token: string, owner: DaemonWorkerAuthentication, timeoutMs = 3000): Promise { + async authenticateWorker( + token: string, + owner: DaemonWorkerAuthentication, + timeoutMs = 3000, + ): Promise> { const response = await this.requestWorker({ type: "worker_auth", token, ...owner }, timeoutMs); if (!response.success) { throw new Error(response.error); } + return response; } close(): void { diff --git a/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts b/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts index 7ce97bc04e..e5c23334d0 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts @@ -4,6 +4,7 @@ import type { IdleEvictionMinutes } from "../../core/session-action-store.js"; export { SESSION_LEASE_OWNER_ID_ENV, SESSION_LEASES_ENABLED_ENV } from "../../core/session-lease.js"; +import type { WorkerRosterEntry } from "./agent-roster.js"; import type { DaemonClientCapability, DaemonCommand, DaemonOutbound } from "./daemon-protocol.js"; export const DAEMON_WORKER_ROLE_ENV = "PRIME_AGENT_INTERNAL_DAEMON_WORKER"; @@ -15,6 +16,22 @@ export const DAEMON_WORKER_STARTUP_GATE_FD_ENV = "PRIME_AGENT_INTERNAL_DAEMON_WO export const DAEMON_WORKER_STARTUP_GATE_COMMIT = "start\n"; export type DaemonWorkerLifecycle = "starting" | "ready" | "recovering" | "stopping" | "failed"; +// Worker->supervisor roster frames live outside the client-facing DaemonOutbound schema. +export type DaemonWorkerRosterOutbound = + | { + type: "roster_delta"; + entries: WorkerRosterEntry[]; + removedAgentIds?: string[]; + snapshot?: true; + } + | { type: "roster_heartbeat" }; + +/** Advertised by new workers in the worker_auth response; absent on legacy workers. */ +export const DAEMON_WORKER_ROSTER_CAPABILITY = "agent_roster"; + +/** Idle keepalive cadence for worker->supervisor roster frames; the supervisor staleness threshold derives from it. */ +export const ROSTER_HEARTBEAT_INTERVAL_MS = 15_000; + export type DaemonWorkerFrameHeader = | { kind: "command"; @@ -24,7 +41,7 @@ export type DaemonWorkerFrameHeader = | { kind: "outbound"; requestId?: string; - outboundType: DaemonOutbound["type"]; + outboundType: DaemonOutbound["type"] | DaemonWorkerRosterOutbound["type"]; activeSessionId?: string; snapshotId?: string; sessionEventType?: string; diff --git a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts index d257678475..bca878476d 100644 --- a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts +++ b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts @@ -497,12 +497,14 @@ export class RlmSpawnLedger { }); } - private async familyUnlocked(): Promise { - const edges = [...this.replaySync().values()].filter((edge) => !edge.deleted); - const byChild = new Map(); - for (const edge of edges) { - byChild.set(canonicalSessionPath(edge.child), edge); - } + /** Live edges reconciled by stat, exactly like family(): a dead parent or child drops the edge. */ + liveEdges(): Promise { + return this.enqueue(() => this.liveEdgesUnlocked()); + } + + private async liveEdgesUnlocked( + edges = [...this.replaySync().values()].filter((edge) => !edge.deleted), + ): Promise { const statCache = new Map(); const exists = async (path: string): Promise => { const cached = statCache.get(path); @@ -516,12 +518,25 @@ export class RlmSpawnLedger { statCache.set(path, ok); return ok; }; - let alive: RlmLedgerEdge[] = []; + const alive: RlmLedgerEdge[] = []; for (const edge of edges) { if ((await exists(canonicalSessionPath(edge.child))) && (await exists(canonicalSessionPath(edge.parent)))) { alive.push(edge); } } + return alive; + } + + private async familyUnlocked(): Promise { + // One replay, one stat snapshot: byChild comes from the same alive set that emits child rows, + // so a child whose dead edge was reconciled away degrades to a root row instead of vanishing. + let alive: RlmLedgerEdge[] = await this.liveEdgesUnlocked( + [...this.replaySync().values()].filter((candidate) => !candidate.deleted), + ); + const byChild = new Map(); + for (const edge of alive) { + byChild.set(canonicalSessionPath(edge.child), edge); + } const rootPaths: string[] = []; let rootEntries: string[] = []; try { @@ -841,3 +856,29 @@ export class RlmSpawnLedger { return edges; } } + +// Shared user-delete policy: only a readable no-parent transcript is positively top-level; children and +// unknown targets tombstone via the ledger BEFORE the file delete (a tombstoned-but-undeleted file is +// the accepted orphan of a failed delete). +export async function tombstoneSavedSessionDelete( + ledger: RlmSpawnLedger, + sessionPath: string, + knownSummary: { runtimeKind?: "top-level" | "subagent" } | undefined, +): Promise<{ deletedInfo: SessionInfo | undefined; ledgerEdge: RlmLedgerEdge | undefined }> { + const deletedPath = canonicalSessionPath(sessionPath); + const deletedInfo = (await readSessionInfo(sessionPath).catch(() => null)) ?? undefined; + const knownChild = + knownSummary?.runtimeKind === "subagent" || + deletedInfo?.parentSessionPath !== undefined || + (deletedInfo?.rlmDepth ?? 0) > 0; + const positivelyTopLevel = !knownChild && (knownSummary !== undefined || deletedInfo !== undefined); + if (positivelyTopLevel) return { deletedInfo, ledgerEdge: undefined }; + const edges = await ledger.edges(); + // Tombstone every matching edge: a duplicate edge for the path (corrupt or raced appends) left + // live would resurrect a later recreation at that path as a subagent. + const matching = edges.filter((edge) => canonicalSessionPath(edge.child) === deletedPath); + for (const edge of matching) { + await ledger.appendDelete({ childId: edge.childId, child: sessionPath, reason: "user" }); + } + return { deletedInfo, ledgerEdge: matching[0] }; +} diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts new file mode 100644 index 0000000000..2493778330 --- /dev/null +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -0,0 +1,1639 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SessionManager } from "../src/core/session-manager.js"; +import type { ActiveSessionState } from "../src/modes/daemon/active-session-state.js"; +import { + type AgentRosterEntry, + type WorkerRosterEntry, + workerRosterEntryFromSummary, +} from "../src/modes/daemon/agent-roster.js"; +import { AgentDaemon } from "../src/modes/daemon/daemon-mode.js"; +import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js"; +import { DaemonSupervisor } from "../src/modes/daemon/daemon-supervisor.js"; +import type { DaemonWorkerRosterOutbound } from "../src/modes/daemon/daemon-worker-protocol.js"; +import { RlmSpawnLedger } from "../src/modes/daemon/rlm-ledger.js"; + +type RosterDelta = Extract; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const directory of tempDirs.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +// --- Worker-side roster reporter (daemon-mode) --- + +interface WorkerReporterFixture { + daemon: { + sessions: Map; + observeRosterEvent(state: ActiveSessionState, message: unknown): void; + flushRoster(): void; + rosterReporter: { + lastComposed: Map; + lastComposedJson: Map; + queuedChildren: Map; + removedAgentIds: Map; + snapshotPending: boolean; + }; + }; + sentDeltas: RosterDelta[]; + connection: { connected: boolean }; +} + +function makeWorkerReporter(connected = true): WorkerReporterFixture { + const sentDeltas: RosterDelta[] = []; + const connection = { connected }; + const daemon = Object.assign(Object.create(AgentDaemon.prototype), { + options: { worker: { authenticationToken: "token" } }, + sessions: new Map(), + cronStore: { list: () => [], cancelJobsForSession: () => [] }, + summarizer: { forget: () => {} }, + acpMcpOwners: new Map(), + rosterReporter: { + lastComposed: new Map(), + lastComposedJson: new Map(), + queuedChildren: new Map(), + removedAgentIds: new Map(), + snapshotPending: false, + }, + rosterFlushScheduled: false, + shuttingDown: false, + hasAuthenticatedSupervisorClient: () => connection.connected, + broadcastRosterFrame: (message: DaemonWorkerRosterOutbound) => { + if (message.type === "roster_delta") sentDeltas.push(message); + return connection.connected; + }, + log: vi.fn(), + }) as WorkerReporterFixture["daemon"]; + return { daemon, sentDeltas, connection }; +} + +function makeState(options: { + activeSessionId: string; + sessionId?: string; + sessionFile?: string; + kind?: "top-level" | "subagent"; + rlmChildId?: string; + parentActiveSessionId?: string; + parentSessionFile?: string; + messages?: AgentMessage[]; + isStreaming?: boolean; +}): ActiveSessionState { + return { + activeSessionId: options.activeSessionId, + clients: new Set(), + extensionUiRequests: new Map(), + lastEventSequence: 0, + runtime: { + dispose: async () => {}, + metadata: { + kind: options.kind ?? "top-level", + createdAt: 1, + ...(options.rlmChildId ? { rlmChildId: options.rlmChildId } : {}), + ...(options.parentActiveSessionId ? { parentActiveSessionId: options.parentActiveSessionId } : {}), + ...(options.parentSessionFile ? { parentSessionFile: options.parentSessionFile } : {}), + }, + diagnostics: [], + session: { + thinkingLevel: "off", + isStreaming: options.isStreaming ?? false, + isCompacting: false, + sessionFile: options.sessionFile, + sessionId: options.sessionId ?? `session-${options.activeSessionId}`, + rlmDepth: options.kind === "subagent" ? 1 : 0, + sessionName: `name-${options.activeSessionId}`, + sessionManager: { + getCwd: () => "/tmp/project", + getHeader: () => ({ timestamp: "2026-05-01T00:00:00.000Z" }), + getSessionDir: () => "/tmp/sessions", + hasUserContent: () => false, + appendSessionState: () => {}, + }, + messages: options.messages ?? [], + getRlmChildSnapshots: () => [], + hasRunningRlmChildren: () => false, + hasAcceptedPromptInFlight: false, + unfinishedActionCount: 0, + abort: async () => {}, + isSessionActive: options.isStreaming === true, + getCurrentRecap: () => undefined, + _contextTokensForCurrentMessages: () => undefined, + getSessionActionSnapshot: () => ({ queuedCount: 0, steering: [], followUps: [] }), + state: { streamingMessage: undefined, pendingToolCalls: new Set() }, + }, + }, + } as unknown as ActiveSessionState; +} + +function childUpdate(state: ActiveSessionState, child: Record) { + return { + type: "session_event", + activeSessionId: state.activeSessionId, + event: { type: "rlm_child_update", child }, + }; +} + +describe("worker roster reporter", () => { + it("carries an admitted run from queued through bind, late updates, supersede, and terminal-unbound removal", () => { + const { daemon, sentDeltas } = makeWorkerReporter(); + const parent = makeState({ activeSessionId: "parent-active" }); + daemon.sessions.set(parent.activeSessionId, parent); + + daemon.observeRosterEvent( + parent, + childUpdate(parent, { id: "child-1", label: "review the API", status: "queued", sessionDir: "/tmp/c" }), + ); + daemon.flushRoster(); + expect(sentDeltas[0]?.entries.find((entry) => entry.agentId === "parent-active#child-1")).toMatchObject({ + queuedChild: true, + summary: { runtimeKind: "subagent", parentActiveSessionId: "parent-active", firstMessage: "review the API" }, + }); + + // The same child id from a second parent stays a distinct row, qualified by parent path + // (or by the live parent id when a no-session parent has no path). + expect( + workerRosterEntryFromSummary( + summary({ + id: "c", + sessionId: "c", + runtimeKind: "subagent", + rlmChildId: "c", + parentActiveSessionId: "pa-1", + }), + ).agentId, + ).not.toBe( + workerRosterEntryFromSummary( + summary({ + id: "c", + sessionId: "c", + runtimeKind: "subagent", + rlmChildId: "c", + parentActiveSessionId: "pa-2", + }), + ).agentId, + ); + const parentB = makeState({ activeSessionId: "parent-b", sessionFile: "/tmp/parents/b.jsonl" }); + daemon.sessions.set(parentB.activeSessionId, parentB); + daemon.observeRosterEvent( + parentB, + childUpdate(parentB, { id: "child-1", label: "b", status: "queued", sessionDir: "/tmp/b" }), + ); + daemon.flushRoster(); + const collided = sentDeltas.at(-1)?.entries.find((entry) => entry.summary.rlmChildId === "child-1"); + expect(collided?.queuedChild).toBe(true); + expect(collided?.agentId).not.toBe("parent-active#child-1"); + + // The child session materializes: same agentId, one resident row, no queued marker. + const childState = makeState({ + activeSessionId: "child-active", + kind: "subagent", + rlmChildId: "child-1", + parentActiveSessionId: "parent-active", + messages: [{ role: "user", content: "hi" } as unknown as AgentMessage], + }); + daemon.sessions.set(childState.activeSessionId, childState); + daemon.observeRosterEvent( + parent, + childUpdate(parent, { + id: "child-1", + label: "review the API", + status: "running", + activeSessionId: "child-active", + }), + ); + daemon.flushRoster(); + const merged = sentDeltas.at(-1)?.entries.filter((entry) => entry.agentId === "parent-active#child-1") ?? []; + expect(merged).toHaveLength(1); + expect(merged[0]).toMatchObject({ summary: { activeSessionId: "child-active", lifecycle: "live" } }); + expect(merged[0]?.queuedChild).toBeUndefined(); + + daemon.sessions.delete(childState.activeSessionId); + daemon.flushRoster(); + // The closed session flips to a non-resident row instead of dropping or re-queueing. + const superseded = sentDeltas.at(-1)?.entries.find((entry) => entry.agentId === "parent-active#child-1"); + expect(superseded?.queuedChild).toBeUndefined(); + expect(superseded?.summary.activeSessionId).toBeUndefined(); + + // A run that terminates before binding is a removal, never a passivated phantom. + daemon.observeRosterEvent( + parent, + childUpdate(parent, { id: "child-2", label: "task", status: "queued", sessionDir: "/tmp/c" }), + ); + daemon.flushRoster(); + daemon.observeRosterEvent( + parent, + childUpdate(parent, { id: "child-2", label: "task", status: "cancelled", sessionDir: "/tmp/c" }), + ); + daemon.flushRoster(); + expect(sentDeltas.at(-1)?.removedAgentIds).toEqual(["parent-active#child-2"]); + daemon.rosterReporter.snapshotPending = true; + daemon.flushRoster(); + expect(sentDeltas.at(-1)?.snapshot).toBe(true); + expect(sentDeltas.at(-1)?.entries.some((entry) => entry.agentId === "parent-active#child-2")).toBe(false); + + // Bind window: the child session registers before any rlm_child_update reports the bind. + daemon.observeRosterEvent( + parent, + childUpdate(parent, { id: "child-3", label: "task", status: "queued", sessionDir: "/tmp/c" }), + ); + daemon.flushRoster(); + const boundState = makeState({ + activeSessionId: "child-3-active", + kind: "subagent", + rlmChildId: "child-3", + parentActiveSessionId: "parent-active", + messages: [{ role: "user", content: "hi" } as unknown as AgentMessage], + }); + daemon.sessions.set(boundState.activeSessionId, boundState); + daemon.flushRoster(); + const bound = sentDeltas.at(-1)?.entries.find((entry) => entry.agentId === "parent-active#child-3"); + expect(bound?.queuedChild).toBeUndefined(); + expect(bound?.summary.activeSessionId).toBe("child-3-active"); + expect(daemon.rosterReporter.queuedChildren.has("parent-active#child-3")).toBe(false); + }); + + it("cancels pending removals for reincarnated ids but keeps the removed incarnation suppressed", () => { + const { daemon, sentDeltas, connection } = makeWorkerReporter(); + const parent = makeState({ activeSessionId: "parent-active" }); + daemon.sessions.set(parent.activeSessionId, parent); + + // A deletion while disconnected leaves the removal pending; the id is then reused by a new admission. + connection.connected = false; + daemon.rosterReporter.removedAgentIds.set("parent-active#child-1", "old-session"); + daemon.flushRoster(); + daemon.observeRosterEvent( + parent, + childUpdate(parent, { id: "child-1", label: "again", status: "queued", sessionDir: "/tmp/c" }), + ); + daemon.flushRoster(); + connection.connected = true; + daemon.flushRoster(); + const snapshot = sentDeltas.at(-1); + expect(snapshot?.snapshot).toBe(true); + expect(snapshot?.removedAgentIds).toBeUndefined(); + expect( + snapshot?.entries.some((entry) => entry.agentId === "parent-active#child-1" && entry.queuedChild === true), + ).toBe(true); + + // The removed incarnation itself (same sessionId, mid-teardown) stays suppressed and never ghosts. + daemon.rosterReporter.queuedChildren.clear(); + const dying = makeState({ + activeSessionId: "child-active", + kind: "subagent", + rlmChildId: "child-2", + parentActiveSessionId: "parent-active", + messages: [{ role: "user", content: "hi" } as unknown as AgentMessage], + }); + daemon.sessions.set(dying.activeSessionId, dying); + daemon.flushRoster(); + daemon.rosterReporter.removedAgentIds.set("parent-active#child-2", "session-child-active"); + daemon.flushRoster(); + expect(sentDeltas.at(-1)?.removedAgentIds).toEqual(["parent-active#child-2"]); + daemon.sessions.delete(dying.activeSessionId); + daemon.flushRoster(); + expect(daemon.rosterReporter.lastComposed.has("parent-active#child-2")).toBe(false); + }); + + it("publishes a removal when an in-place session swap renames the row", () => { + const { daemon, sentDeltas, connection } = makeWorkerReporter(); + const state = makeState({ + activeSessionId: "root-active", + sessionId: "old-session", + sessionFile: "/tmp/sessions/old.jsonl", + messages: [{ role: "user", content: "hi" } as unknown as AgentMessage], + }); + daemon.sessions.set(state.activeSessionId, state); + daemon.flushRoster(); + + // switch_session/new_session/fork swap the runtime in place: same state, new sessionId. + const swapped = state.runtime.session as unknown as { sessionId: string; sessionFile: string }; + swapped.sessionId = "new-session"; + swapped.sessionFile = "/tmp/sessions/new.jsonl"; + daemon.flushRoster(); + + expect(sentDeltas.at(-1)?.removedAgentIds).toEqual(["old-session"]); + expect(sentDeltas.at(-1)?.entries.some((entry) => entry.agentId === "new-session")).toBe(true); + expect(daemon.rosterReporter.lastComposed.has("old-session")).toBe(false); + + // Swapping away and back while disconnected revives the old row (its pending removal cancels); + // the abandoned interim row is removed instead. + connection.connected = false; + swapped.sessionId = "interim-session"; + swapped.sessionFile = "/tmp/sessions/interim.jsonl"; + daemon.flushRoster(); + swapped.sessionId = "new-session"; + swapped.sessionFile = "/tmp/sessions/new.jsonl"; + daemon.flushRoster(); + connection.connected = true; + daemon.flushRoster(); + const snapshot = sentDeltas.at(-1); + expect(snapshot?.snapshot).toBe(true); + expect(snapshot?.removedAgentIds).toEqual(["interim-session"]); + expect(snapshot?.entries.some((entry) => entry.agentId === "new-session")).toBe(true); + expect(snapshot?.entries.some((entry) => entry.agentId === "interim-session")).toBe(false); + }); + + it("scopes pending spawn appends by parent so equal child ids cannot cross wires", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-spawn-key-")); + tempDirs.push(directory); + const daemon = new AgentDaemon(join(directory, "worker.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory }, + worker: { authenticationToken: "token" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + } as never); + const internals = daemon as unknown as { + pendingRlmSpawnAppends: Map>; + recordRlmSubagentState(parentState: ActiveSessionState, input: object): boolean; + }; + const spawn = (parent: ActiveSessionState, dir: string) => + internals.recordRlmSubagentState(parent, { + childId: "sub-1", + sessionName: "child", + sessionDir: join(directory, dir), + sessionFile: join(directory, dir, "child.jsonl"), + rlmDepth: 1, + rlmMaxDepth: 4, + status: "running", + }); + spawn(makeState({ activeSessionId: "parent-a", sessionFile: join(directory, "a.jsonl") }), "a"); + spawn(makeState({ activeSessionId: "parent-b", sessionFile: join(directory, "b.jsonl") }), "b"); + + expect(internals.pendingRlmSpawnAppends.size).toBe(2); + }); + + it("publishes a removal when an archived top-level close leaves the worker's list", async () => { + const { daemon, sentDeltas } = makeWorkerReporter(); + const state = makeState({ + activeSessionId: "root-active", + sessionFile: "/tmp/sessions/root.jsonl", + messages: [{ role: "user", content: "hi" } as unknown as AgentMessage], + }); + daemon.sessions.set(state.activeSessionId, state); + daemon.flushRoster(); + + await ( + daemon as unknown as { + closeSessionOnce( + state: ActiveSessionState, + reason: string, + waitForAbort: boolean, + cascadeChildren: boolean, + descendants: Set, + ): Promise; + } + ).closeSessionOnce(state, "killed", false, false, new Set()); + daemon.flushRoster(); + + // Archived by the kill: no passivated ghost, the disk scan is the only remaining source. + expect(sentDeltas.at(-1)?.removedAgentIds).toEqual(["session-root-active"]); + expect(daemon.rosterReporter.lastComposed.has("session-root-active")).toBe(false); + }); + + it("flushes cron and model changes that have no session-event carrier", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-cron-flush-")); + tempDirs.push(directory); + const daemon = new AgentDaemon(join(directory, "worker.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory }, + worker: { authenticationToken: "token" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + } as never); + const state = makeState({ + activeSessionId: "root-active", + sessionFile: join(directory, "sessions", "root.jsonl"), + }); + Object.assign(state.runtime, { cwd: directory }); + const internals = daemon as unknown as { + sessions: Map; + cronStore: { registerSessionArtifact(sessionId: string, artifactDir: string): boolean }; + handleCommand(client: object, command: object): Promise<{ success: boolean; data?: { job?: { id: string } } }>; + rosterReporter: { lastComposed: Map }; + }; + internals.cronStore.registerSessionArtifact("session-root-active", join(directory, "sessions", "root")); + internals.sessions.set(state.activeSessionId, state); + const client = { id: "client", attachedActiveSessionIds: new Set() }; + + const added = await internals.handleCommand(client, { + id: "cron-1", + type: "cron_add", + activeSessionId: "root-active", + schedule: "every 1h", + prompt: "check status", + }); + expect(added.success).toBe(true); + await new Promise((resolveSettle) => setImmediate(resolveSettle)); + const agentId = "session-root-active"; + expect(internals.rosterReporter.lastComposed.get(agentId)?.summary.hasRegisteredCronJob).toBe(true); + + const jobId = added.data?.job?.id; + if (!jobId) throw new Error("cron_add returned no job id"); + await internals.handleCommand(client, { + id: "cron-2", + type: "cron_cancel", + activeSessionId: "root-active", + jobId, + }); + await new Promise((resolveSettle) => setImmediate(resolveSettle)); + expect(internals.rosterReporter.lastComposed.get(agentId)?.summary.hasRegisteredCronJob).toBeUndefined(); + + // set_model has no session-event carrier either; its explicit flush publishes the new model. + const session = state.runtime.session as unknown as Record; + session.modelRegistry = { refreshAvailableModels: async () => [{ provider: "prov", id: "m2" }] }; + session.setModel = async (model: unknown) => { + session.model = model; + }; + await internals.handleCommand(client, { + id: "model-1", + type: "set_model", + activeSessionId: "root-active", + provider: "prov", + modelId: "m2", + }); + await new Promise((resolveSettle) => setImmediate(resolveSettle)); + expect(internals.rosterReporter.lastComposed.get(agentId)?.summary.model).toMatchObject({ id: "m2" }); + }); +}); + +// --- Supervisor-side roster ledger --- + +function summary(overrides: Partial & Pick): SessionSummary { + return { + lifecycle: "live", + activity: "idle", + isSessionActive: false, + cwd: "/tmp/project", + isStreaming: false, + isCompacting: false, + attachedClients: 0, + messageCount: 1, + sessionActions: { queuedCount: 0, steering: [], followUps: [] }, + ...overrides, + }; +} + +interface WorkerFixture { + descriptor: { + workerId: string; + pid: number; + rootActiveSessionId: string; + lifecycle: "ready" | "failed"; + processStartId?: string; + lastError?: string; + ownerClientId?: string; + rootSessionId?: string; + sessionFile?: string; + }; + client?: { request: ReturnType }; + summaries: Map; + intentionalStop: boolean; + snapshotCache: Map; + transcriptCaches: Map; + snapshotGenerations: Map; + snapshotLoads: Map; +} + +function makeWorker(workerId: string, overrides: Partial = {}): WorkerFixture { + return { + descriptor: { workerId, pid: 1234, rootActiveSessionId: `${workerId}-root-active`, lifecycle: "ready" }, + client: { request: vi.fn() }, + summaries: new Map(), + intentionalStop: false, + snapshotCache: new Map(), + transcriptCaches: new Map(), + snapshotGenerations: new Map(), + snapshotLoads: new Map(), + ...overrides, + }; +} + +interface SupervisorFixture { + workers: Map; + consumeWorkerRosterDelta(worker: WorkerFixture, payload: Buffer): void; + handleList( + client: object, + command: { id?: string; type: "list"; all?: boolean; includeClientOwned?: boolean; sessionDir?: string }, + ): { success: boolean; data?: { sessions: SessionSummary[]; busyClientOwnedSessionCount?: number } }; + handleWorkerClose(worker: WorkerFixture, client: object, error: Error): Promise; + handleWorkerFrame(worker: WorkerFixture, frame: unknown): void; + writeRosterEntry(entry: WorkerRosterEntry, worker?: WorkerFixture): AgentRosterEntry; + workerRosterEntries(worker: WorkerFixture): AgentRosterEntry[]; + flipWorkerRosterEntriesInactive(worker: WorkerFixture): void; + seedRosterLedger(): Promise; + roster(): { + get(agentId: string): AgentRosterEntry | undefined; + has(agentId: string): boolean; + values(): IterableIterator; + }; + refreshWorkerSummaries: ReturnType; +} + +function makeSupervisor(workers: WorkerFixture[], extra: Record = {}): SupervisorFixture { + return Object.assign(Object.create(DaemonSupervisor.prototype), { + workers: new Map(workers.map((worker) => [worker.descriptor.workerId, worker])), + clients: new Set(), + defaultSessionConfig: { agentDir: "/tmp", cwd: "/tmp" }, + catalog: { list: vi.fn(async () => []) }, + refreshWorkerSummaries: vi.fn(async () => {}), + persistWorker: vi.fn(), + invalidateWorkerSessionInputPauses: vi.fn(), + deferWorkerRecovery: vi.fn(), + assertRecoveryAllowed: vi.fn(async () => { + throw new Error("recovery halted for test"); + }), + log: vi.fn(), + ...extra, + }) as SupervisorFixture; +} + +/** Real supervisor over a temp agent dir for offline (no-worker) command routes. */ +function makeOfflineSupervisor(prefix: string, overrides: Record = {}) { + const directory = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(directory); + const sessionsDir = join(directory, "sessions"); + const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory, sessionDir: sessionsDir }, + descriptorDir: join(directory, "workers"), + }) as unknown as SupervisorFixture & { + handleCommand(client: object, command: object): Promise; + rlmSpawnLedger(): RlmSpawnLedger; + }; + Object.assign(supervisor, { catalog: { list: vi.fn(async () => []) }, ...overrides }); + return { directory, sessionsDir, supervisor }; +} + +function offlineClient() { + return { id: "client", attachedActiveSessionIds: new Set() }; +} + +function rosterDelta(entries: WorkerRosterEntry[], removedAgentIds?: string[], snapshot?: true): Buffer { + return Buffer.from( + JSON.stringify({ + type: "roster_delta", + entries, + ...(removedAgentIds ? { removedAgentIds } : {}), + ...(snapshot ? { snapshot } : {}), + }), + ); +} + +describe("supervisor roster ledger", () => { + it("serves list from the ledger with zero worker round-trips and exact busy counts", async () => { + const visible = makeWorker("visible"); + const owned = makeWorker("owned", { + descriptor: { + workerId: "owned", + pid: 1, + rootActiveSessionId: "owned-root-active", + lifecycle: "ready", + ownerClientId: "owner-client", + }, + }); + const supervisor = makeSupervisor([visible, owned], { + protocolClientIds: new WeakMap(), + }); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary(summary({ id: "v-active", sessionId: "v", activeSessionId: "v-active" })), + visible, + ); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ id: "o-active", sessionId: "o", activeSessionId: "o-active", isSessionActive: true }), + ), + owned, + ); + + const listed = await supervisor.handleList({}, { type: "list", includeClientOwned: true }); + + expect(listed.success).toBe(true); + expect(listed.data?.busyClientOwnedSessionCount).toBe(1); + // Client-owned sessions are excluded for a non-owner client; busy count stays exact. + expect(listed.data?.sessions.map((session) => session.sessionId)).toEqual(["v"]); + expect(visible.client?.request).not.toHaveBeenCalled(); + expect(owned.client?.request).not.toHaveBeenCalled(); + expect(supervisor.refreshWorkerSummaries).not.toHaveBeenCalled(); + + // Queued child rows stay ledger-internal until their session materializes. + { + const queuedWorker = makeWorker("worker-q"); + const queuedSupervisor = makeSupervisor([queuedWorker]); + queuedSupervisor.consumeWorkerRosterDelta( + queuedWorker, + rosterDelta([ + { + agentId: "child-1", + queuedChild: true, + summary: summary({ + id: "child-1", + sessionId: "child-1", + runtimeKind: "subagent", + rlmChildId: "child-1", + }), + }, + ]), + ); + expect((await queuedSupervisor.handleList({}, { type: "list" })).data?.sessions).toEqual([]); + expect((await queuedSupervisor.handleList({}, { type: "list", all: true })).data?.sessions).toEqual([]); + expect(queuedSupervisor.workerRosterEntries(queuedWorker)[0]).toMatchObject({ + status: "running", + statusLabel: "queued", + }); + } + }); + + it("replaces a worker's rows from a snapshot, deletes absentees, and reseeds only live ledger edges", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-snapshot-reseed-")); + tempDirs.push(directory); + const sessionsDir = join(directory, "sessions"); + const ledger = new RlmSpawnLedger(directory, sessionsDir); + const parentPath = join(sessionsDir, "root.jsonl"); + const passivatedPath = join(directory, "artifacts", "passivated-child.jsonl"); + const deletedPath = join(directory, "artifacts", "deleted-child.jsonl"); + mkdirSync(sessionsDir, { recursive: true }); + writeFileSync(parentPath, ""); + mkdirSync(dirname(passivatedPath), { recursive: true }); + writeFileSync(passivatedPath, ""); + await ledger.appendSpawn({ + childId: "passivated-child", + parent: parentPath, + child: passivatedPath, + depth: 1, + name: "passivated", + }); + await ledger.appendSpawn({ + childId: "deleted-child", + parent: parentPath, + child: deletedPath, + depth: 1, + name: "deleted", + }); + await ledger.appendDelete({ childId: "deleted-child", child: deletedPath, reason: "user" }); + // A foreign family's unclaimed edge (e.g. a client-owned worker's dropped child) must not + // resurrect through THIS worker's snapshot. + const foreignRoot = join(sessionsDir, "foreign.jsonl"); + const foreignChild = join(directory, "artifacts", "foreign-child.jsonl"); + writeFileSync(foreignRoot, ""); + writeFileSync(foreignChild, ""); + await ledger.appendSpawn({ + childId: "foreign-child", + parent: foreignRoot, + child: foreignChild, + depth: 1, + name: "foreign", + }); + const worker = makeWorker("worker-1"); + Object.assign(worker.descriptor, { sessionFile: parentPath }); + const supervisor = makeSupervisor([worker], { rlmSpawnLedger: () => ledger }); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "kept-active", + sessionId: "kept", + activeSessionId: "kept-active", + sessionFile: "/tmp/kept.jsonl", + }), + ), + worker, + ); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "passivated-child", + sessionId: "passivated-child", + sessionFile: passivatedPath, + runtimeKind: "subagent", + rlmChildId: "passivated-child", + parentSessionPath: parentPath, + }), + ), + worker, + ); + supervisor.consumeWorkerRosterDelta( + worker, + rosterDelta([ + { + agentId: "sessionless", + queuedChild: true, + summary: summary({ id: "sessionless", sessionId: "sessionless", runtimeKind: "subagent" }), + }, + ]), + ); + + // The restarted worker's replacing snapshot names only the row it still holds. + supervisor.consumeWorkerRosterDelta( + worker, + rosterDelta( + [ + workerRosterEntryFromSummary( + summary({ + id: "kept-active", + sessionId: "kept", + activeSessionId: "kept-active", + sessionFile: "/tmp/kept.jsonl", + isSessionActive: true, + }), + ), + ], + undefined, + true, + ), + ); + await vi.waitFor(() => expect(supervisor.roster().get("kept")).toMatchObject({ status: "running" })); + expect(supervisor.roster().has("sessionless")).toBe(false); + // The deleted-while-disconnected child stays out; the surviving one reseeds from its live edge. + const entries = [...supervisor.roster().values()]; + const reseeded = entries.find((entry) => entry.summary.rlmChildId === "passivated-child"); + expect(reseeded).toBeDefined(); + expect(reseeded?.summary.activeSessionId).toBeUndefined(); + expect(entries.some((entry) => entry.summary.rlmChildId === "deleted-child")).toBe(false); + expect(entries.some((entry) => entry.summary.rlmChildId === "foreign-child")).toBe(false); + }); + + it("drops a client-owned worker's rows on unregistration instead of leaking public inactive rows", async () => { + const owned = makeWorker("w-owned"); + Object.assign(owned.descriptor, { ownerClientId: "owner-client" }); + const supervisor = makeSupervisor([owned], { catalog: { list: vi.fn(async () => []) } }); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "o-active", + sessionId: "o", + activeSessionId: "o-active", + sessionFile: "/tmp/sessions/owned.jsonl", + }), + ), + owned, + ); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "oc", + sessionId: "oc", + sessionFile: "/tmp/artifacts/oc.jsonl", + runtimeKind: "subagent", + rlmChildId: "oc", + parentSessionPath: "/tmp/sessions/owned.jsonl", + }), + ), + owned, + ); + + supervisor.flipWorkerRosterEntriesInactive(owned); + supervisor.workers.delete("w-owned"); + + expect([...supervisor.roster().values()]).toHaveLength(0); + const listed = await supervisor.handleList( + { id: "intruder", attachedActiveSessionIds: new Set() }, + { type: "list", all: true }, + ); + expect(listed.data?.sessions).toEqual([]); + + // An unowned worker flips resident rows passivated and removes queued rows outright. + { + const worker = makeWorker("worker-1"); + const supervisor = makeSupervisor([worker]); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary(summary({ id: "r-active", sessionId: "r", activeSessionId: "r-active" })), + worker, + ); + supervisor.consumeWorkerRosterDelta( + worker, + rosterDelta([ + { + agentId: "queued-child", + queuedChild: true, + summary: summary({ id: "queued-child", sessionId: "queued-child", runtimeKind: "subagent" }), + }, + ]), + ); + expect(supervisor.roster().has("queued-child")).toBe(true); + + supervisor.flipWorkerRosterEntriesInactive(worker); + + // A terminal unbound child run owns no transcript: removal, never a fileless inactive ghost. + expect(supervisor.roster().has("queued-child")).toBe(false); + expect(supervisor.roster().get("r")).toMatchObject({ status: "inactive" }); + expect(supervisor.roster().get("r")?.workerId).toBeUndefined(); + } + }); + + it("seeds catalog and ledger rows list-all-only, serves resident worker rows, and keeps evicted rows inactive", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-seed-")); + tempDirs.push(directory); + const sessionsDir = join(directory, "sessions"); + const ledger = new RlmSpawnLedger(directory, sessionsDir); + const liveChildPath = join(directory, "artifacts", "live-child.jsonl"); + const deletedChildPath = join(directory, "artifacts", "deleted-child.jsonl"); + mkdirSync(sessionsDir, { recursive: true }); + writeFileSync(join(sessionsDir, "root.jsonl"), ""); + mkdirSync(dirname(liveChildPath), { recursive: true }); + writeFileSync(liveChildPath, ""); + await ledger.appendSpawn({ + childId: "live-child", + parent: join(sessionsDir, "root.jsonl"), + child: liveChildPath, + depth: 1, + name: "live-child", + }); + await ledger.appendSpawn({ + childId: "deleted-child", + parent: join(sessionsDir, "root.jsonl"), + child: deletedChildPath, + depth: 1, + name: "deleted-child", + }); + await ledger.appendDelete({ childId: "deleted-child", child: deletedChildPath, reason: "user" }); + // Removed out-of-band: no transcript backs this edge, so no row may serve it. + await ledger.appendSpawn({ + childId: "ghost-child", + parent: join(sessionsDir, "root.jsonl"), + child: join(directory, "artifacts", "ghost-child.jsonl"), + depth: 1, + name: "ghost-child", + }); + + const supervisor = makeSupervisor([], { + rlmSpawnLedger: () => ledger, + catalog: { + list: vi.fn(async () => [ + { + id: "saved-root", + path: join(sessionsDir, "root.jsonl"), + cwd: "/tmp/project", + created: new Date(0), + modified: new Date(0), + messageCount: 3, + firstMessage: "hello", + allMessagesText: "", + }, + ]), + }, + }); + await supervisor.seedRosterLedger(); + + // A push-only view needs saved top-level rows in the ledger itself, not only in list-all rescans. + expect(supervisor.roster().has("saved-root")).toBe(true); + const listed = await supervisor.handleList({}, { type: "list", all: true }); + const ids = listed.data?.sessions.map((session) => session.sessionId).sort(); + expect(ids).toEqual(["live-child", "saved-root"]); + expect(listed.data?.sessions.every((session) => session.activeSessionId === undefined)).toBe(true); + expect((await supervisor.handleList({}, { type: "list" })).data?.sessions).toEqual([]); + + // A live worker's rows: the active root and its passivated child are resident; seeded rows stay list-all-only. + const worker = makeWorker("worker-1"); + supervisor.workers.set("worker-1", worker); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "e-active", + sessionId: "evicted", + activeSessionId: "e-active", + sessionFile: join(sessionsDir, "evicted.jsonl"), + isSessionActive: true, + }), + ), + worker, + ); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "child-session", + sessionId: "child-session", + sessionFile: join(directory, "artifacts", "passive-child.jsonl"), + runtimeKind: "subagent", + rlmChildId: "passive-child", + }), + ), + worker, + ); + const resident = await supervisor.handleList({}, { type: "list" }); + expect(resident.data?.sessions.map((session) => session.sessionId).sort()).toEqual(["child-session", "evicted"]); + const passiveChild = resident.data?.sessions.find((session) => session.rlmChildId === "passive-child"); + expect(passiveChild).toMatchObject({ workerPid: 1234 }); + expect(passiveChild?.activeSessionId).toBeUndefined(); + + // One list-all serves resident worker rows and workerless seeded rows side by side. + const liveAll = await supervisor.handleList({}, { type: "list", all: true }); + expect(liveAll.data?.sessions.map((session) => session.sessionId).sort()).toEqual([ + "child-session", + "evicted", + "live-child", + "saved-root", + ]); + expect(liveAll.data?.sessions.some((session) => session.sessionId === "deleted-child")).toBe(false); + + // Eviction leaves the worker's rows behind as inactive instead of dropping them. + supervisor.workers.delete("worker-1"); + supervisor.flipWorkerRosterEntriesInactive(worker); + + const afterEvict = await supervisor.handleList({}, { type: "list", all: true }); + const evicted = afterEvict.data?.sessions.find((session) => session.sessionId === "evicted"); + expect(evicted).toBeDefined(); + expect(evicted?.activeSessionId).toBeUndefined(); + expect((await supervisor.handleList({}, { type: "list" })).data?.sessions).toEqual([]); + expect(afterEvict.data?.sessions.some((session) => session.sessionId === "deleted-child")).toBe(false); + }); + + it("removes the roster row on offline deletes, tombstones subagents, and never reseeds them", async () => { + const { directory, sessionsDir, supervisor } = makeOfflineSupervisor("prime-roster-offline-delete-", { + catalog: { delete: vi.fn(async () => ({ ok: true, method: "unlink" })), list: vi.fn(async () => []) }, + }); + const parentPath = join(sessionsDir, "root.jsonl"); + const childPath = join(directory, "artifacts", "child.jsonl"); + await supervisor + .rlmSpawnLedger() + .appendSpawn({ childId: "child-1", parent: parentPath, child: childPath, depth: 1, name: "child" }); + // A raced cross-process duplicate (appendSpawn's uniqueness check is per-process TOCTOU) + // must tombstone with the original, not stay live. + (supervisor.rlmSpawnLedger() as unknown as { appendRecord(record: object): void }).appendRecord({ + v: 1, + op: "spawn", + at: new Date().toISOString(), + childId: "child-dup", + parent: parentPath, + child: childPath, + depth: 1, + name: "dup", + }); + const childEntry = workerRosterEntryFromSummary( + summary({ + id: "child-1", + sessionId: "child-1", + sessionFile: childPath, + runtimeKind: "subagent", + rlmChildId: "child-1", + parentSessionPath: parentPath, + }), + ); + supervisor.writeRosterEntry(childEntry); + + await supervisor.handleCommand(offlineClient(), { type: "delete_saved_session", sessionPath: childPath }); + + expect(supervisor.roster().has(childEntry.agentId)).toBe(false); + await expect(supervisor.rlmSpawnLedger().edges()).resolves.toEqual([]); + + // A fresh supervisor over the same agent dir must not reseed the tombstoned child. + const reseeded = makeSupervisor([], { + rlmSpawnLedger: () => supervisor.rlmSpawnLedger(), + catalog: { list: vi.fn(async () => []) }, + }); + await reseeded.seedRosterLedger(); + expect([...reseeded.roster().values()]).toEqual([]); + + // An offline rename updates the row in place. + { + const { directory, supervisor } = makeOfflineSupervisor("prime-roster-offline-rename-"); + const sessionPath = join(directory, "saved.jsonl"); + Object.assign(supervisor, { + catalog: { rename: vi.fn(async () => {}), list: vi.fn(async () => []) }, + rlmLedgerSiblings: vi.fn(async () => [ + { + id: "saved-1", + path: sessionPath, + cwd: directory, + created: new Date(0), + modified: new Date(0), + messageCount: 1, + firstMessage: "", + allMessagesText: "", + }, + ]), + }); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ id: "saved-1", sessionId: "saved-1", sessionFile: sessionPath, sessionName: "old-name" }), + ), + ); + + await supervisor.handleCommand(offlineClient(), { + type: "rename_saved_session", + sessionPath, + name: "new-name", + }); + + expect(supervisor.roster().get("saved-1")?.summary.sessionName).toBe("new-name"); + } + + // A delete that fails on disk keeps the row. + { + const { directory, supervisor } = makeOfflineSupervisor("prime-roster-failed-delete-", { + catalog: { delete: vi.fn(async () => ({ ok: false, error: "busy file" })), list: vi.fn(async () => []) }, + }); + const sessionPath = join(directory, "saved.jsonl"); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary(summary({ id: "saved-1", sessionId: "saved-1", sessionFile: sessionPath })), + ); + + await supervisor.handleCommand(offlineClient(), { type: "delete_saved_session", sessionPath }); + + expect(supervisor.roster().has("saved-1")).toBe(true); + } + }); +}); + +describe("saved-session delete paths", () => { + it("routes offline deletes by owner reachability: forward, retryable reject, or reclaim", async () => { + const reachableRoster = makeWorker("w-roster"); + Object.assign(reachableRoster.descriptor, { createCommand: { type: "create" } }); + reachableRoster.client = { + request: vi.fn(async () => ({ type: "response", command: "delete_saved_session", success: true })), + }; + const unreachable = makeWorker("w-down"); + Object.assign(unreachable.descriptor, { + sessionFile: "/tmp/owned-down.jsonl", + createCommand: { type: "create" }, + }); + unreachable.client = undefined; + const failed = makeWorker("w-failed"); + Object.assign(failed.descriptor, { + sessionFile: "/tmp/owned-failed.jsonl", + createCommand: { type: "create" }, + lifecycle: "failed", + }); + failed.client = undefined; + const catalogDelete = vi.fn(async () => ({ ok: true, method: "unlink" })); + const reclaimStaleWorkerRegistration = vi.fn( + async (worker: { descriptor: { lifecycle: string; workerId: string } }) => { + if (worker.descriptor.lifecycle !== "failed") return false; + supervisor.workers.delete(worker.descriptor.workerId); + return true; + }, + ); + const supervisor = makeSupervisor([reachableRoster, unreachable, failed], { + catalog: { delete: catalogDelete, list: vi.fn(async () => []) }, + mutationDrain: { begin: vi.fn(), end: vi.fn() }, + reclaimStaleWorkerRegistration, + rlmSpawnLedger: () => ({ edges: vi.fn(async () => []) }), + }); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "roster-owned", + sessionId: "roster-owned", + sessionFile: "/tmp/owned-roster.jsonl", + runtimeKind: "subagent", + rlmChildId: "child-1", + }), + ), + reachableRoster, + ); + const internals = supervisor as unknown as { handleCommand(client: object, command: object): Promise }; + const client = { id: "client", attachedActiveSessionIds: new Set() }; + + // Roster and descriptor ownership both forward to a reachable owner instead of rejecting. + await internals.handleCommand(client, { type: "delete_saved_session", sessionPath: "/tmp/owned-roster.jsonl" }); + expect(reachableRoster.client.request).toHaveBeenCalledWith( + expect.objectContaining({ type: "delete_saved_session", sessionPath: "/tmp/owned-roster.jsonl" }), + expect.any(Number), + ); + expect(catalogDelete).not.toHaveBeenCalled(); + + // A live-but-disconnected owner rejects instead of deleting underneath the worker. + await expect( + internals.handleCommand(client, { type: "delete_saved_session", sessionPath: "/tmp/owned-down.jsonl" }), + ).rejects.toThrow(/retry the delete/); + expect(catalogDelete).not.toHaveBeenCalled(); + + // A dead failed registration is reclaimed, then the offline delete proceeds. + await internals.handleCommand(client, { type: "delete_saved_session", sessionPath: "/tmp/owned-failed.jsonl" }); + expect(reclaimStaleWorkerRegistration).toHaveBeenCalledWith(failed); + expect(catalogDelete).toHaveBeenCalledWith("/tmp/owned-failed.jsonl"); + }); + + it("rejects a foreign client's delete of a client-owned worker's passivated session as unknown", async () => { + const owned = makeWorker("w-owned"); + Object.assign(owned.descriptor, { + ownerClientId: "owner-client", + sessionFile: "/tmp/owned-private.jsonl", + createCommand: { type: "create" }, + }); + owned.client = { + request: vi.fn(async () => ({ type: "response", command: "delete_saved_session", success: true })), + }; + const catalogDelete = vi.fn(async () => ({ ok: true, method: "unlink" })); + const supervisor = makeSupervisor([owned], { + catalog: { delete: catalogDelete, list: vi.fn(async () => []) }, + mutationDrain: { begin: vi.fn(), end: vi.fn() }, + protocolClientIds: new Map(), + rlmSpawnLedger: () => ({ edges: vi.fn(async () => []) }), + }); + const internals = supervisor as unknown as { handleCommand(client: object, command: object): Promise }; + + await expect( + internals.handleCommand( + { id: "intruder", attachedActiveSessionIds: new Set() }, + { type: "delete_saved_session", sessionPath: "/tmp/owned-private.jsonl" }, + ), + ).rejects.toThrow("Unknown active session: /tmp/owned-private.jsonl"); + expect(owned.client.request).not.toHaveBeenCalled(); + expect(catalogDelete).not.toHaveBeenCalled(); + + // The owning client still routes the delete through its own worker. + await internals.handleCommand( + { id: "owner-client", attachedActiveSessionIds: new Set() }, + { type: "delete_saved_session", sessionPath: "/tmp/owned-private.jsonl" }, + ); + expect(owned.client.request).toHaveBeenCalled(); + }); + + it("resolves file ownership from the roster, never from the stale pull cache", () => { + const worker = makeWorker("worker-1"); + Object.assign(worker.descriptor, { createCommand: { type: "create" } }); + const supervisor = makeSupervisor([worker]); + worker.summaries.set( + "stale-active", + summary({ + id: "stale-active", + sessionId: "stale", + activeSessionId: "stale-active", + sessionFile: "/tmp/f.jsonl", + }), + ); + const internals = supervisor as unknown as { + findWorkerBySessionFile(sessionFile: string): WorkerFixture | undefined; + }; + + // The roster removed the row (e.g. an archived close); the old pull cache must not resurrect ownership. + expect(internals.findWorkerBySessionFile("/tmp/f.jsonl")).toBeUndefined(); + + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "live-active", + sessionId: "live", + activeSessionId: "live-active", + sessionFile: "/tmp/f.jsonl", + }), + ), + worker, + ); + expect(internals.findWorkerBySessionFile("/tmp/f.jsonl")).toBe(worker); + }); + + it("keeps a row rewritten during the delete's own await", async () => { + const { directory, supervisor } = makeOfflineSupervisor("prime-roster-delete-race-"); + const sessionPath = join(directory, "saved.jsonl"); + const stale = supervisor.writeRosterEntry( + workerRosterEntryFromSummary(summary({ id: "saved-1", sessionId: "saved-1", sessionFile: sessionPath })), + ); + Object.assign(supervisor, { + catalog: { + delete: vi.fn(async () => { + // A frame rewrites the row while the unlink is in flight. + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ id: "saved-1", sessionId: "saved-1", sessionFile: sessionPath }), + ), + ); + return { ok: true, method: "unlink" }; + }), + list: vi.fn(async () => []), + }, + }); + + await supervisor.handleCommand(offlineClient(), { type: "delete_saved_session", sessionPath }); + + expect(supervisor.roster().get("saved-1")).toBeDefined(); + expect(supervisor.roster().get("saved-1")).not.toBe(stale); + }); + + it("aborts a saved-child delete when the tombstone append fails", async () => { + const catalogDelete = vi.fn(async () => ({ ok: true, method: "unlink" })); + const { directory, sessionsDir, supervisor } = makeOfflineSupervisor("prime-roster-tombstone-fail-", { + catalog: { delete: catalogDelete, list: vi.fn(async () => []) }, + }); + const childPath = join(directory, "artifacts", "child.jsonl"); + Object.assign(supervisor, { + rlmSpawnLedger: () => ({ + edges: vi.fn(async () => [ + { childId: "child-1", child: childPath, parent: join(sessionsDir, "root.jsonl"), depth: 1, name: "c" }, + ]), + appendDelete: vi.fn(async () => { + throw new Error("ledger unwritable"); + }), + }), + }); + const childEntry = workerRosterEntryFromSummary( + summary({ + id: "child-1", + sessionId: "child-1", + sessionFile: childPath, + runtimeKind: "subagent", + rlmChildId: "child-1", + parentSessionPath: join(sessionsDir, "root.jsonl"), + }), + ); + supervisor.writeRosterEntry(childEntry); + + await expect( + supervisor.handleCommand(offlineClient(), { type: "delete_saved_session", sessionPath: childPath }), + ).rejects.toThrow("ledger unwritable"); + expect(catalogDelete).not.toHaveBeenCalled(); + expect(supervisor.roster().has(childEntry.agentId)).toBe(true); + }); +}); + +describe("review-round regressions", () => { + it("merges the per-call disk scan newest-first with disk authoritative for non-resident rows", async () => { + const worker = makeWorker("worker-1"); + const supervisor = makeSupervisor([worker], { + catalog: { + list: vi.fn(async () => [ + { + id: "external", + path: "/tmp/external.jsonl", + cwd: "/tmp/project", + created: new Date(0), + modified: new Date(0), + messageCount: 1, + firstMessage: "made after startup", + allMessagesText: "", + }, + { + id: "known", + path: "/tmp/known.jsonl", + cwd: "/tmp/project", + created: new Date(0), + modified: new Date(0), + messageCount: 1, + firstMessage: "", + allMessagesText: "", + }, + ]), + }, + }); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "known", + sessionId: "known", + sessionFile: "/tmp/known.jsonl", + sessionName: "stale-ledger-name", + }), + ), + ); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "res-active", + sessionId: "resident", + sessionFile: "/tmp/external.jsonl", + activeSessionId: "res-active", + }), + ), + worker, + ); + + const listed = await supervisor.handleList({}, { type: "list", all: true }); + // Newest-first catalog order with the resident row replacing its scanned file in place. + expect(listed.data?.sessions.map((session) => session.sessionId)).toEqual(["resident", "known"]); + // Disk is authoritative for non-resident rows; the stale ledger name loses. + expect(listed.data?.sessions.find((session) => session.sessionId === "known")?.sessionName).toBeUndefined(); + + // A client-owned worker's file: the live row stays private, the public scan lists it inactive. + { + const owned = makeWorker("w-owned"); + Object.assign(owned.descriptor, { ownerClientId: "owner-client", createCommand: { type: "create" } }); + const ownedPath = "/tmp/sessions/owned.jsonl"; + const supervisor = makeSupervisor([owned], { + protocolClientIds: new Map(), + catalog: { + list: vi.fn(async () => [ + { + id: "owned-session", + path: ownedPath, + cwd: "/tmp/project", + created: new Date(0), + modified: new Date(0), + messageCount: 2, + firstMessage: "private work", + allMessagesText: "", + }, + ]), + }, + }); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "owned-active", + sessionId: "owned-session", + activeSessionId: "owned-active", + sessionFile: ownedPath, + isSessionActive: true, + }), + ), + owned, + ); + + const listed = await supervisor.handleList( + { id: "intruder", attachedActiveSessionIds: new Set() }, + { type: "list", all: true }, + ); + + // The live row stays private; the public on-disk scan still lists the file as inactive. + expect(listed.data?.sessions).toHaveLength(1); + expect(listed.data?.sessions[0]).toMatchObject({ sessionId: "owned-session" }); + expect(listed.data?.sessions[0]?.activeSessionId).toBeUndefined(); + expect(listed.data?.sessions[0]?.workerPid).toBeUndefined(); + } + }); + + it("re-claims rows a concurrent snapshot reseeds instead of leaving them workerless", async () => { + const worker = makeWorker("worker-1"); + Object.assign(worker.descriptor, { createCommand: { type: "create" } }); + const child = summary({ + id: "x-session", + sessionId: "x-session", + sessionFile: "/tmp/artifacts/x.jsonl", + runtimeKind: "subagent", + rlmChildId: "x", + parentSessionPath: "/tmp/sessions/root.jsonl", + messageCount: 4, + lastActivityAt: "2026-08-01T10:00:00.000Z", + }); + const childEntry = workerRosterEntryFromSummary(child); + Object.assign(worker.descriptor, { sessionFile: "/tmp/sessions/root.jsonl" }); + let releaseEdges: (edges: unknown[]) => void = () => {}; + const edgesPromise = new Promise((resolveEdges) => { + releaseEdges = resolveEdges; + }); + const supervisor = makeSupervisor([worker], { + refreshWorkerSummaries: DaemonSupervisor.prototype["refreshWorkerSummaries" as never], + streamReconstructor: { seed: vi.fn(), clear: vi.fn() }, + rlmSpawnLedger: () => ({ liveEdges: () => edgesPromise }), + }); + supervisor.writeRosterEntry(childEntry, worker); + const root = summary({ id: "worker-1-root-active", sessionId: "root", activeSessionId: "worker-1-root-active" }); + worker.client = { + request: vi.fn(async () => ({ + type: "response", + command: "list", + success: true, + data: { sessions: [root, child] }, + })), + }; + + // A snapshot without the child arrives while its spawn-ledger pre-read is still in flight. + supervisor.consumeWorkerRosterDelta(worker, rosterDelta([workerRosterEntryFromSummary(root)], undefined, true)); + const refresh = ( + supervisor as unknown as { + refreshWorkerSummaries(worker: WorkerFixture, recovery: boolean, fillGaps: boolean): Promise; + } + ).refreshWorkerSummaries(worker, false, true); + releaseEdges([ + { childId: "x", parent: "/tmp/sessions/root.jsonl", child: "/tmp/artifacts/x.jsonl", depth: 1, name: "x" }, + ]); + await refresh; + // Settle any apply work a broken serialization would leave dangling past the pull. + await new Promise((resolveSettle) => setImmediate(resolveSettle)); + + const restored = supervisor.roster().get(childEntry.agentId); + expect(restored?.workerId).toBe("worker-1"); + // The reseed and queued fill keep the hydrated summary: no synthetic seed, no NaN eviction pin. + expect(restored?.summary.cwd).toBe("/tmp/project"); + expect(restored?.summary.lastActivityAt).toBe("2026-08-01T10:00:00.000Z"); + expect(restored?.summary.messageCount).toBe(4); + expect(restored?.seededCwd).toBeUndefined(); + }); + + it("keeps passive rows and repairs by pull when a snapshot's ledger pre-read fails", async () => { + const worker = makeWorker("worker-1"); + Object.assign(worker.descriptor, { createCommand: { type: "create" } }); + const refreshWorkerSummaries = vi.fn(async () => {}); + const supervisor = makeSupervisor([worker], { + refreshWorkerSummaries, + rlmSpawnLedger: () => ({ + liveEdges: vi.fn(async () => { + throw new Error("ledger unreadable"); + }), + }), + }); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "p-session", + sessionId: "p-session", + sessionFile: "/tmp/artifacts/p.jsonl", + runtimeKind: "subagent", + rlmChildId: "p", + parentSessionPath: "/tmp/sessions/root.jsonl", + }), + ), + worker, + ); + const root = summary({ id: "worker-1-root-active", sessionId: "root", activeSessionId: "worker-1-root-active" }); + + supervisor.consumeWorkerRosterDelta(worker, rosterDelta([workerRosterEntryFromSummary(root)], undefined, true)); + await new Promise((resolveSettle) => setImmediate(resolveSettle)); + + // Without readable edges the absentee sweep cannot run; the passive child survives, claimed. + const passiveRow = [...supervisor.roster().values()].find((entry) => entry.summary.rlmChildId === "p"); + expect(passiveRow?.workerId).toBe("worker-1"); + expect(supervisor.roster().get("root")?.workerId).toBe("worker-1"); + expect(refreshWorkerSummaries).toHaveBeenCalledTimes(1); + }); + + it("aborts queued roster applies when the worker stops during the snapshot ledger pre-read", async () => { + const worker = makeWorker("worker-1"); + const root = summary({ + id: "worker-1-root-active", + sessionId: "root", + activeSessionId: "worker-1-root-active", + sessionFile: "/tmp/sessions/root.jsonl", + }); + const rootEntry = workerRosterEntryFromSummary(root); + let releaseEdges: (edges: unknown[]) => void = () => {}; + const edgesPromise = new Promise((resolveEdges) => { + releaseEdges = resolveEdges; + }); + const supervisor = makeSupervisor([worker], { rlmSpawnLedger: () => ({ liveEdges: () => edgesPromise }) }); + supervisor.writeRosterEntry(rootEntry, worker); + + // The snapshot apply starts and blocks on the ledger pre-read; a delta queues behind it. + supervisor.consumeWorkerRosterDelta(worker, rosterDelta([rootEntry], undefined, true)); + await Promise.resolve(); + supervisor.consumeWorkerRosterDelta(worker, rosterDelta([rootEntry])); + // The stop lands mid pre-read: registration gone, rows flipped inactive. + supervisor.workers.delete("worker-1"); + supervisor.flipWorkerRosterEntriesInactive(worker); + releaseEdges([]); + await new Promise((resolveSettle) => setImmediate(resolveSettle)); + + const entry = supervisor.roster().get(rootEntry.agentId); + expect(entry?.workerId).toBeUndefined(); + expect(entry?.summary.activeSessionId).toBeUndefined(); + + // A socket close (registration intact) equally stales queued applies: recovering labels survive. + const closed = makeWorker("worker-2"); + const closedEntry = workerRosterEntryFromSummary( + summary({ id: "worker-2-root-active", sessionId: "root-2", activeSessionId: "worker-2-root-active" }), + ); + let releaseClosedEdges: (edges: unknown[]) => void = () => {}; + const closedSupervisor = makeSupervisor([closed], { + rlmSpawnLedger: () => ({ + liveEdges: () => + new Promise((resolveEdges) => { + releaseClosedEdges = resolveEdges; + }), + }), + }); + closedSupervisor.writeRosterEntry(closedEntry, closed); + closedSupervisor.consumeWorkerRosterDelta(closed, rosterDelta([closedEntry], undefined, true)); + await closedSupervisor.handleWorkerClose(closed, closed.client as object, new Error("worker died")); + // A reconnect starts authenticating before the parked apply resumes; the apply's source is dead. + (closed as unknown as { pendingClient: object }).pendingClient = {}; + releaseClosedEdges([]); + await new Promise((resolveSettle) => setImmediate(resolveSettle)); + + expect(closedSupervisor.workerRosterEntries(closed)[0]).toMatchObject({ statusLabel: "recovering" }); + + // Unchained (fast-path) deltas obey the same currency rule. + { + const worker = makeWorker("worker-1"); + const supervisor = makeSupervisor([]); + + supervisor.consumeWorkerRosterDelta( + worker, + rosterDelta([ + workerRosterEntryFromSummary(summary({ id: "z-active", sessionId: "z", activeSessionId: "z-active" })), + ]), + ); + + expect(supervisor.roster().has("z")).toBe(false); + } + }); + + it("keeps an unverifiable live pre-roster worker failed with no replacement", async () => { + const worker = makeWorker("worker-1"); + Object.assign(worker.descriptor, { pid: process.pid, processStartId: undefined }); + const launchWorker = vi.fn(); + const recoverUncertainWorkerOperations = vi.fn(async () => {}); + const supervisor = makeSupervisor([worker], { + assertRecoveryAllowed: vi.fn(async () => {}), + recoverUncertainWorkerOperations, + launchWorker, + }); + + await ( + supervisor as unknown as { + restartPreRosterWorker(worker: WorkerFixture, observedProcessStartId?: string): Promise; + } + ).restartPreRosterWorker(worker, undefined); + + expect(recoverUncertainWorkerOperations).toHaveBeenCalledWith(worker, false); + expect(launchWorker).not.toHaveBeenCalled(); + expect(worker.descriptor.lifecycle).toBe("failed"); + }); +}); + +describe("worker delete tombstone durability", () => { + function makeDeleteDaemon(directory: string, ledgerEdges: () => Promise) { + const daemon = new AgentDaemon(join(directory, "worker.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory, sessionDir: join(directory, "sessions") }, + worker: { authenticationToken: "token" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + } as never); + Object.assign(daemon, { rlmSpawnLedger: () => ({ edges: ledgerEdges }) }); + return daemon as unknown as { + handleCommand(client: object, command: object): Promise; + rosterReporter: { removedAgentIds: Map }; + }; + } + + it("deletes a top-level saved session without touching the spawn ledger", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-toplevel-delete-")); + tempDirs.push(directory); + const sessionsDir = join(directory, "sessions"); + const manager = SessionManager.create(directory, sessionsDir); + manager.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + manager.flushNow(); + const sessionPath = manager.getSessionFile(); + if (!sessionPath) throw new Error("Fixture session did not persist"); + const daemon = makeDeleteDaemon(directory, async () => { + throw new Error("ledger unreadable"); + }); + + await daemon.handleCommand( + { id: "client", attachedActiveSessionIds: new Set() }, + { type: "delete_saved_session", sessionPath }, + ); + + expect(existsSync(sessionPath)).toBe(false); + expect([...daemon.rosterReporter.removedAgentIds.keys()]).toEqual([manager.getSessionId()]); + }); + it("classifies an unreadable delete target through the ledger", async () => { + const setup = () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-unknown-delete-")); + tempDirs.push(directory); + const garbled = join(directory, "artifacts", "garbled.jsonl"); + mkdirSync(dirname(garbled), { recursive: true }); + writeFileSync(garbled, "not a session header\n"); + return { directory, garbled }; + }; + + // (a) A live edge classifies the unknown target as a child: tombstone first, then delete. + const withEdge = setup(); + const daemonWithEdge = new AgentDaemon(join(withEdge.directory, "worker.sock"), { + defaultSessionConfig: { agentDir: withEdge.directory, cwd: withEdge.directory }, + worker: { authenticationToken: "token" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + } as never) as unknown as { + rlmSpawnLedger(): RlmSpawnLedger; + handleCommand(client: object, command: object): Promise; + rosterReporter: { removedAgentIds: Map }; + }; + await daemonWithEdge.rlmSpawnLedger().appendSpawn({ + childId: "sub-9", + parent: join(withEdge.directory, "sessions", "root.jsonl"), + child: withEdge.garbled, + depth: 1, + name: "garbled", + }); + await daemonWithEdge.handleCommand( + { id: "client", attachedActiveSessionIds: new Set() }, + { type: "delete_saved_session", sessionPath: withEdge.garbled }, + ); + expect(existsSync(withEdge.garbled)).toBe(false); + await expect(daemonWithEdge.rlmSpawnLedger().edges()).resolves.toEqual([]); + // The published removal id is parent-qualified, never the bare child id. + expect([...daemonWithEdge.rosterReporter.removedAgentIds.keys()]).toEqual([expect.stringMatching(/#sub-9$/)]); + + // (b) An unreadable ledger aborts the unknown target's deletion. + const withFailure = setup(); + const daemonWithFailure = makeDeleteDaemon(withFailure.directory, async () => { + throw new Error("ledger unreadable"); + }); + await expect( + daemonWithFailure.handleCommand( + { id: "client", attachedActiveSessionIds: new Set() }, + { type: "delete_saved_session", sessionPath: withFailure.garbled }, + ), + ).rejects.toThrow("ledger unreadable"); + expect(existsSync(withFailure.garbled)).toBe(true); + expect(daemonWithFailure.rosterReporter.removedAgentIds.size).toBe(0); + + // (c) No edge: the unknown target deletes as a top-level session. + const withoutEdge = setup(); + const daemonWithoutEdge = new AgentDaemon(join(withoutEdge.directory, "worker.sock"), { + defaultSessionConfig: { agentDir: withoutEdge.directory, cwd: withoutEdge.directory }, + worker: { authenticationToken: "token" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + } as never) as unknown as { handleCommand(client: object, command: object): Promise }; + await daemonWithoutEdge.handleCommand( + { id: "client", attachedActiveSessionIds: new Set() }, + { type: "delete_saved_session", sessionPath: withoutEdge.garbled }, + ); + expect(existsSync(withoutEdge.garbled)).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index d41f4ec651..21b469a3ee 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -7434,6 +7434,40 @@ describe("daemon mode helpers", () => { } }); + it("blocks deleting a live session through a symlinked path", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-delete-symlink-")); + try { + const daemon = new AgentDaemon(join(tempDir, "daemon.sock"), { + defaultSessionConfig: { agentDir: tempDir, cwd: tempDir }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + }); + const internals = daemon as unknown as { + sessions: Map; + handleCommand(client: DaemonSocketClient, command: DaemonCommand): Promise; + }; + const sessionFile = join(tempDir, "live-session.jsonl"); + writeFileSync(sessionFile, ""); + const linkPath = join(tempDir, "live-session-link.jsonl"); + symlinkSync(sessionFile, linkPath); + const state = makeState("active-1"); + (state.runtime as { session?: unknown }).session = { sessionFile }; + internals.sessions.set(state.activeSessionId, state); + + await expect( + internals.handleCommand(makeClient("client-1", "active-1"), { + id: "command-1", + type: "delete_saved_session", + sessionPath: linkPath, + }), + ).rejects.toThrow("Cannot delete the currently active session"); + expect(existsSync(sessionFile)).toBe(true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("cancels scheduled jobs when a saved session is deleted", async () => { const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-delete-cron-")); try { diff --git a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts index 0941a3e552..a0359d6daa 100644 --- a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; 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"; +import { seedSupervisorRoster } from "./fixtures/roster-seed.js"; interface WorkerFixture { descriptor: { @@ -123,6 +124,7 @@ describe("daemon supervisor whole-tree eviction", () => { for (const worker of [idle, active, heartbeat, cron, attached]) { supervisor.workers.set(worker.descriptor.workerId, worker); } + seedSupervisorRoster(supervisor, idle, active, heartbeat, cron, attached); supervisor.clients.add({ id: "viewer", attachedActiveSessionIds: new Set(["attached-root"]) }); await supervisor.runIdleEvictionSweep(now); @@ -151,6 +153,7 @@ describe("daemon supervisor whole-tree eviction", () => { }); supervisor.workers.set("active", active); supervisor.workers.set("wholly-idle", whollyIdle); + seedSupervisorRoster(supervisor, active, whollyIdle); await supervisor.runIdleEvictionSweep(now); @@ -215,6 +218,7 @@ describe("daemon supervisor whole-tree eviction", () => { ]); supervisor.workers.set("paused", paused); supervisor.workers.set("active-heartbeat", active); + seedSupervisorRoster(supervisor, paused, active); await supervisor.runIdleEvictionSweep(now); @@ -284,7 +288,10 @@ describe("daemon supervisor whole-tree eviction", () => { }); const reopened = makeWorker("reopened", [rootSummary]); reopened.descriptor.rootActiveSessionId = "new-active-id"; - supervisor.createOrReuseWorker = vi.fn(async () => reopened); + supervisor.createOrReuseWorker = vi.fn(async () => { + seedSupervisorRoster(supervisor, reopened); + return reopened; + }); const client = { id: "viewer", attachedActiveSessionIds: new Set() }; const response = await supervisor.handleCommand(client, { @@ -325,8 +332,12 @@ describe("daemon supervisor whole-tree eviction", () => { data: { deliveryStatus: "delivered" }, }); supervisor.workers.set("source", source); + seedSupervisorRoster(supervisor, source); supervisor.catalog.resolve = vi.fn(async () => "/tmp/target.jsonl"); - supervisor.createOrReuseWorker = vi.fn(async () => target); + supervisor.createOrReuseWorker = vi.fn(async () => { + seedSupervisorRoster(supervisor, target); + return target; + }); const client = { id: "sender", attachedActiveSessionIds: new Set() }; const response = await supervisor.handleCommand(client, { @@ -369,6 +380,7 @@ describe("daemon supervisor whole-tree eviction", () => { data: { deliveryStatus: "delivered" }, }); supervisor.workers.set("shared", worker); + seedSupervisorRoster(supervisor, worker); const client = { id: "sender", attachedActiveSessionIds: new Set() }; const response = await supervisor.handleCommand(client, { @@ -401,6 +413,7 @@ describe("daemon supervisor whole-tree eviction", () => { const supervisor = makeSupervisor(); const source = makeWorker("source", [makeSummary("source-active", now)]); supervisor.workers.set("source", source); + seedSupervisorRoster(supervisor, source); supervisor.catalog.resolve = vi.fn(async () => { throw new Error("Unknown saved session: missing-target"); }); @@ -471,6 +484,7 @@ describe("daemon supervisor empty-session eviction on detach", () => { for (const worker of [empty, ...exempt]) { supervisor.workers.set(worker.descriptor.workerId, worker); } + seedSupervisorRoster(supervisor, empty, ...exempt); const first = makeDetachClient("first", ["empty-root"]); const viewer = makeDetachClient("viewer", [ "empty-root", @@ -515,6 +529,7 @@ describe("daemon supervisor empty-session eviction on detach", () => { }), ); supervisor.workers.set("swap", worker); + seedSupervisorRoster(supervisor, worker); const client = makeDetachClient("viewer", ["swap-root"]); supervisor.clients.add(client); @@ -544,6 +559,7 @@ describe("daemon supervisor empty-session eviction on detach", () => { }), ); supervisor.workers.set("gap", worker); + seedSupervisorRoster(supervisor, worker); const client = makeDetachClient("viewer", ["gap-root"]); supervisor.clients.add(client); @@ -572,6 +588,7 @@ describe("daemon supervisor empty-session eviction on detach", () => { const draftB = makeWorker("draft-b", [makeSummary("draft-b-root", now, { messageCount: 0 })]); supervisor.workers.set("draft-a", draftA); supervisor.workers.set("draft-b", draftB); + seedSupervisorRoster(supervisor, draftA, draftB); const client = makeDetachClient("viewer", ["draft-a-root", "draft-b-root"]); supervisor.clients.add(client); @@ -606,6 +623,7 @@ describe("daemon supervisor empty-session eviction on detach", () => { }), ); supervisor.workers.set("gap", worker); + seedSupervisorRoster(supervisor, worker); const client = makeDetachClient("viewer", ["gap-root"]); supervisor.clients.add(client); 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 74332846c2..10b3f9ad29 100644 --- a/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts @@ -13,6 +13,7 @@ import { DaemonClient } from "../src/modes/daemon/daemon-client.js"; import { success } from "../src/modes/daemon/daemon-protocol.js"; import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js"; import { DaemonSupervisor } from "../src/modes/daemon/daemon-supervisor.js"; +import { seedSupervisorRoster } from "./fixtures/roster-seed.js"; interface SupervisorInternals { workers: Map; @@ -32,6 +33,7 @@ interface SupervisorInternals { ): void; familyCatalogEntry(summary: SessionSummary): AgentFamilyCatalogEntry; handleCommand(client: object, command: Record): Promise; + seedRosterLedger(): Promise; } interface WorkerFixture { @@ -106,8 +108,9 @@ describe("daemon supervisor passive subagent topology", () => { sessionId: "aaaa6666777788889999dddd", }); const resident = worker("first", [child]); + seedSupervisorRoster(supervisor, resident); - expect(supervisor.findSummaryInWorker(resident, "88889999cccc")).toBe(child); + expect(supervisor.findSummaryInWorker(resident, "88889999cccc")).toEqual(child); }); it("rejects an explicit root name that collides with a saved root", async () => { @@ -136,6 +139,7 @@ describe("daemon supervisor passive subagent topology", () => { }, launchWorker, }); + await supervisor.seedRosterLedger(); await expect( supervisor.createOrReuseWorker("client", { type: "create", name: "duplicate-root" }), @@ -179,6 +183,7 @@ describe("daemon supervisor passive subagent topology", () => { ]), }, }); + await supervisor.seedRosterLedger(); await expect(supervisor.assertSupervisorSavedSessionNameAvailable(forkedPath, "duplicate-root")).rejects.toThrow( "an agent of that name already exists at depth 0 under this parent", @@ -211,6 +216,7 @@ describe("daemon supervisor passive subagent topology", () => { }, launchWorker, }); + await supervisor.seedRosterLedger(); await expect( supervisor.createOrReuseWorker("client", { type: "create", name: " duplicate-root " }), @@ -247,6 +253,7 @@ describe("daemon supervisor passive subagent topology", () => { list: vi.fn(async () => [target, duplicate]), }, }); + await supervisor.seedRosterLedger(); await expect(supervisor.assertSupervisorSavedSessionNameAvailable(targetPath, "taken")).rejects.toThrow( "an agent of that name already exists at depth 0 under this parent", @@ -496,6 +503,7 @@ describe("daemon supervisor passive subagent topology", () => { }) as unknown as SupervisorInternals; supervisor.workers.set("first", firstWorker); supervisor.workers.set("second", secondWorker); + seedSupervisorRoster(supervisor, firstWorker, secondWorker); Object.assign(supervisor, { catalog: { list: vi.fn(async () => []) } }); const client = { id: "client", attachedActiveSessionIds: new Set() }; @@ -539,6 +547,7 @@ describe("daemon supervisor passive subagent topology", () => { descriptorDir: join(directory, "workers"), }) as unknown as SupervisorInternals; supervisor.workers.set("owned", ownedWorker); + seedSupervisorRoster(supervisor, ownedWorker); Object.assign(supervisor, { catalog: { list: vi.fn(async () => []) } }); const workerClient = { id: "daemon-client:worker", attachedActiveSessionIds: new Set() }; @@ -611,6 +620,7 @@ describe("daemon supervisor passive subagent topology", () => { }) as unknown as SupervisorInternals; supervisor.workers.set("first", firstWorker); supervisor.workers.set("second", secondWorker); + seedSupervisorRoster(supervisor, firstWorker, secondWorker); Object.assign(supervisor, { catalog: { siblings: vi.fn(async () => []), @@ -798,6 +808,7 @@ describe("daemon supervisor passive subagent topology", () => { supervisor.workers.set("first", first); supervisor.workers.set("second", second); supervisor.workers.set("disconnected", disconnected); + seedSupervisorRoster(supervisor, first, second, disconnected); await client.connect(); await expect( diff --git a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts index 6e64fe9856..e34e8093b6 100644 --- a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts @@ -29,6 +29,7 @@ import { MutationDrainLatch } from "../src/modes/daemon/mutation-drain-latch.js" import { WorkerRecoveryJournal } from "../src/modes/daemon/worker-recovery-journal.js"; import type { PrivateFrame } from "../src/modes/session-worker/private-framing.js"; import * as childProcessModule from "../src/utils/child-process.js"; +import { seedSupervisorRoster } from "./fixtures/roster-seed.js"; import { createDeferred } from "./suite/scheduling.js"; const workerLaunchTestState = vi.hoisted(() => ({ @@ -478,6 +479,16 @@ describe("daemon worker supervisor monitoring", () => { const daemon = Object.assign(Object.create(AgentDaemon.prototype), { options: { worker: { authenticationToken: "token" } }, supervisorClaims: new Map(), + clients: new Set(), + sessions: new Map(), + cronStore: { list: () => [] }, + rosterReporter: { + lastComposed: new Map(), + lastComposedJson: new Map(), + queuedChildren: new Map(), + removedAgentIds: new Map(), + snapshotPending: false, + }, shuttingDown: false, clearSupervisorAvailabilityCheck: vi.fn(), scheduleSupervisorFenceCheck: vi.fn(), @@ -1721,6 +1732,7 @@ describe("daemon worker supervisor monitoring", () => { worker.descriptor.lifecycle = "ready"; worker.client = {}; worker.summaries.set(root.activeSessionId, root as SessionSummary); + seedSupervisorRoster(supervisor, worker); recovery.resolve(); await expect(reused).resolves.toBe(worker); @@ -1742,6 +1754,7 @@ describe("daemon worker supervisor monitoring", () => { worker.descriptor.lifecycle = "ready"; worker.client = {}; worker.summaries.set(root.activeSessionId, root as SessionSummary); + seedSupervisorRoster(supervisor, worker); }); const supervisor = Object.assign(Object.create(DaemonSupervisor.prototype), { workers: new Map([[worker.descriptor.workerId, worker]]), @@ -1785,6 +1798,7 @@ describe("daemon worker supervisor monitoring", () => { sessionPath: string, ): Promise; }; + seedSupervisorRoster(supervisor, worker); await expect(supervisor.reuseWorkerForCreate(worker, undefined, "/tmp/session.jsonl")).resolves.toBe(worker); expect(recoverWorker).toHaveBeenCalledOnce(); @@ -2072,6 +2086,7 @@ describe("daemon worker supervisor monitoring", () => { data?: { sessions: Array<{ activeSessionId?: string; id: string; workerState?: string }> }; }>; }; + seedSupervisorRoster(supervisor, liveWorker, stoppingWorker); const response = await supervisor.handleList({}, { id: "list-1", type: "list" }); @@ -3410,6 +3425,7 @@ describe("daemon worker supervisor monitoring", () => { command: { type: "attach"; activeSessionId: string }, ): Promise; }; + seedSupervisorRoster(supervisor, worker); await supervisor.attachClient(client, { type: "attach", activeSessionId }); @@ -3523,6 +3539,7 @@ describe("daemon worker supervisor monitoring", () => { command: { type: "attach"; activeSessionId: string; telemetryDisabled?: true }, ): Promise; }; + seedSupervisorRoster(supervisor, worker); await expect( supervisor.attachClient(client, { type: "attach", activeSessionId, telemetryDisabled: true }), @@ -3687,6 +3704,7 @@ describe("daemon worker supervisor monitoring", () => { }) as { attachClient(client: AttachClient, command: { type: "attach"; activeSessionId: string }): Promise; }; + seedSupervisorRoster(supervisor, worker); await expect(supervisor.attachClient(client, { type: "attach", activeSessionId })).rejects.toThrow( "snapshot failed", @@ -3763,6 +3781,35 @@ describe("daemon worker supervisor monitoring", () => { rmSync(root, { recursive: true, force: true }); } }); + + it("skips the recovery SIGKILL when the pid identity is no longer current", async () => { + const kill = vi.spyOn(process, "kill").mockReturnValue(true); + const makeSupervisorFixture = (identity: "current" | "replaced") => + Object.assign(Object.create(DaemonSupervisor.prototype), { + log: vi.fn(), + assertRecoveryAllowed: vi.fn(async () => {}), + // The verdict a caller computed before awaiting its way in can go stale; + // the signal must re-check identity at the last moment (PIDs recycle). + processIdentity: vi.fn(() => identity), + }) as { + recoverUncertainWorkerOperations( + worker: { descriptor: { workerId: string; pid: number; recoveryJournalPath: string } }, + killWorkerProcess: boolean, + ): Promise; + }; + const worker = { + descriptor: { workerId: "worker-1", pid: 987_654, recoveryJournalPath: join(tmpdir(), "absent.jsonl") }, + }; + + try { + await makeSupervisorFixture("replaced").recoverUncertainWorkerOperations(worker, true); + expect(kill).not.toHaveBeenCalled(); + await makeSupervisorFixture("current").recoverUncertainWorkerOperations(worker, true); + expect(kill).toHaveBeenCalled(); + } finally { + kill.mockRestore(); + } + }); it.each([ { name: "malformed data", data: undefined, error: /invalid update manifest/ }, { diff --git a/packages/coding-agent/test/daemon-supervisor-process.test.ts b/packages/coding-agent/test/daemon-supervisor-process.test.ts index 5d973738df..a425b18fd6 100644 --- a/packages/coding-agent/test/daemon-supervisor-process.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-process.test.ts @@ -1,6 +1,7 @@ import { type ChildProcess, spawn } from "node:child_process"; -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -16,7 +17,12 @@ import { readSessionInfo, SessionManager } from "../src/core/session-manager.js" import { DaemonAgentConnection } from "../src/modes/agent-connection/daemon-agent-connection.js"; import { DaemonClient, getDaemonSocketCloseReason } from "../src/modes/daemon/daemon-client.js"; import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js"; -import type { DaemonWorkerDescriptor } from "../src/modes/daemon/daemon-worker-protocol.js"; +import { + type DaemonWorkerDescriptor, + type DaemonWorkerFrameHeader, + isDaemonWorkerFrameHeader, +} from "../src/modes/daemon/daemon-worker-protocol.js"; +import { encodePrivateFrame, PrivateFrameDecoder } from "../src/modes/session-worker/private-framing.js"; const cliPath = resolve(__dirname, "../src/cli.ts"); const tsxPath = resolve(__dirname, "../../../node_modules/tsx/dist/cli.mjs"); @@ -46,6 +52,8 @@ afterEach(async () => { child.kill("SIGTERM"); } } + // Await owned exits before rmSync: a dying worker's log writer otherwise races it into ENOTEMPTY. + await Promise.all([...children].map((child) => waitForExit(child).catch(() => undefined))); children.clear(); for (const pid of workerPids) { try { @@ -63,9 +71,10 @@ afterEach(async () => { } } } + await Promise.all([...workerPids].map((pid) => waitForProcessGone(pid).catch(() => undefined))); workerPids.clear(); for (const directory of tempDirs.splice(0)) { - rmSync(directory, { recursive: true, force: true }); + rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } }); @@ -336,6 +345,133 @@ describe("daemon supervisor resident workers", () => { await waitForSocketGone(socketPath); }, 60_000); + it("restarts an adopted pre-roster worker from the current binary", async () => { + const directory = tempDir(); + const agentDir = join(directory, "agent"); + const projectDir = join(directory, "project"); + const sessionDir = join(agentDir, "sessions"); + mkdirSync(projectDir, { recursive: true }); + const manager = SessionManager.create(projectDir, sessionDir); + manager.appendMessage({ role: "user", content: "pre-roster fixture", timestamp: 1 }); + manager.flushNow(); + const sessionPath = manager.getSessionFile(); + const sessionId = manager.getSessionId(); + if (!sessionPath) throw new Error("Fixture session did not persist"); + + // A long-lived stand-in process plays the pre-roster worker's pid. + const legacyProcess = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000);"], { stdio: "ignore" }); + children.add(legacyProcess); + if (!legacyProcess.pid) throw new Error("Missing legacy process pid"); + + // A fake worker socket that authenticates without advertising the roster capability. + const workerSocketPath = join(directory, "legacy-worker.sock"); + const fakeWorker = createServer((socket) => { + const decoder = new PrivateFrameDecoder(isDaemonWorkerFrameHeader); + socket.write( + encodePrivateFrame( + { kind: "outbound", outboundType: "daemon_hello" }, + Buffer.from(`${JSON.stringify({ type: "daemon_hello" })}\n`), + ), + ); + socket.on("data", (chunk: Buffer) => { + for (const frame of decoder.push(chunk)) { + if (frame.header.kind !== "command") continue; + const command = JSON.parse(frame.payload.toString("utf8")) as { id: string; type: string }; + const data = + command.type === "list" + ? { + sessions: [ + { + id: "legacy-root-active", + activeSessionId: "legacy-root-active", + sessionId, + sessionFile: sessionPath, + lifecycle: "live", + activity: "idle", + isSessionActive: false, + cwd: projectDir, + isStreaming: false, + isCompacting: false, + attachedClients: 0, + messageCount: 1, + sessionActions: { queuedCount: 0, steering: [], followUps: [] }, + }, + ], + } + : {}; + socket.write( + encodePrivateFrame( + { kind: "outbound", outboundType: "response", requestId: frame.header.requestId }, + Buffer.from( + `${JSON.stringify({ id: command.id, type: "response", command: command.type, success: true, data })}\n`, + ), + ), + ); + } + }); + }); + const socketPath = join(directory, "daemon.sock"); + await new Promise((resolveListen) => fakeWorker.listen(workerSocketPath, resolveListen)); + const descriptorDir = join( + agentDir, + "daemon-workers", + createHash("sha256").update(socketPath).digest("hex").slice(0, 12), + ); + mkdirSync(descriptorDir, { recursive: true }); + const now = new Date().toISOString(); + writeFileSync( + join(descriptorDir, "legacy-worker.json"), + `${JSON.stringify({ + version: 2, + workerId: "legacy-worker", + pid: legacyProcess.pid, + socketPath: workerSocketPath, + recoveryJournalPath: join(descriptorDir, "legacy-worker.recovery.jsonl"), + supervisorSocketPath: socketPath, + authenticationToken: "legacy-token", + rootActiveSessionId: "legacy-root-active", + rootSessionId: sessionId, + sessionFile: sessionPath, + sessionDir, + createdAt: now, + updatedAt: now, + lifecycle: "ready", + createCommand: { type: "create", sessionPath }, + consecutiveFailures: 0, + })}\n`, + ); + + // Once the supervisor kills the old pid, its socket goes quiet exactly like a dead worker's. + legacyProcess.once("exit", () => { + fakeWorker.close(); + rmSync(workerSocketPath, { force: true }); + }); + const supervisor = spawnSupervisor(agentDir, socketPath, projectDir); + const client = await connectEventually(socketPath, supervisor); + let restarted: SessionSummary | undefined; + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const listed = await client.request({ type: "list" }); + restarted = requireSessionList(listed.success ? listed.data : undefined).find( + (candidate) => candidate.sessionId === sessionId, + ); + if (restarted?.workerState === "ready" && restarted.workerPid !== undefined) break; + restarted = undefined; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); + } + if (!restarted?.workerPid) { + throw new Error(`Pre-roster worker was not restarted:\n${readDaemonLogs(agentDir)}`); + } + workerPids.add(restarted.workerPid); + // A fresh current-binary worker owns the reloaded idle session; the fake pre-roster pid is not adopted. + expect(restarted.workerPid).not.toBe(legacyProcess.pid); + expect(restarted.isSessionActive).toBe(false); + expect(restarted.messageCount).toBe(1); + await waitForProcessGone(legacyProcess.pid); + fakeWorker.close(); + client.close(); + }, 90_000); + it("lists, creates, and attaches passive children through their owning worker", async () => { const root = tempDir(); const agentDir = join(root, "agent"); diff --git a/packages/coding-agent/test/fixtures/roster-seed.ts b/packages/coding-agent/test/fixtures/roster-seed.ts new file mode 100644 index 0000000000..439f827e22 --- /dev/null +++ b/packages/coding-agent/test/fixtures/roster-seed.ts @@ -0,0 +1,19 @@ +import { workerRosterEntryFromSummary } from "../../src/modes/daemon/agent-roster.js"; +import type { SessionSummary } from "../../src/modes/daemon/daemon-session-list.js"; + +interface RosterWorkerFixture { + descriptor: { workerId: string }; + summaries: Map; +} + +/** Seed a supervisor fixture's roster from worker fixtures' summaries (matchWorkers and eviction read it). */ +export function seedSupervisorRoster(supervisor: object, ...workers: RosterWorkerFixture[]): void { + const internals = supervisor as { + writeRosterEntry(entry: ReturnType, worker?: RosterWorkerFixture): unknown; + }; + for (const worker of workers) { + for (const summary of worker.summaries.values()) { + internals.writeRosterEntry(workerRosterEntryFromSummary(summary), worker); + } + } +} diff --git a/packages/coding-agent/test/rlm-ledger.test.ts b/packages/coding-agent/test/rlm-ledger.test.ts index 9932bfbfd3..6b86180a8f 100644 --- a/packages/coding-agent/test/rlm-ledger.test.ts +++ b/packages/coding-agent/test/rlm-ledger.test.ts @@ -311,10 +311,27 @@ describe("rlm spawn ledger", () => { }); await ledger.appendRename({ childId: "sub-11111111", child: child.file, name: "renamed-worker" }); + // A sessions-dir child whose parent transcript vanished degrades to a root row: the dead + // edge is reconciled away for suppression exactly as for emission. + const orphan = SessionManager.create(root, sessionsDir); + orphan.newSession(); + orphan.appendSessionInfo("orphan-root"); + orphan.flushNow(); + const orphanFile = orphan.getSessionFile(); + if (!orphanFile) throw new Error("Missing orphan file"); + await ledger.appendSpawn({ + childId: "sub-44444444", + parent: join(sessionsDir, "missing-parent.jsonl"), + child: orphanFile, + depth: 1, + name: "orphan", + }); + const family = await ledger.family(); expect(family.map((row) => [row.name, row.rlmDepth])).toEqual([ ["parent", 0], ["other-root", 0], + ["orphan-root", 0], ["renamed-worker", 1], ["nested", 2], ]); @@ -325,7 +342,7 @@ describe("rlm spawn ledger", () => { const siblings = await ledger.siblings(child.file); expect(siblings.map((row) => row.name)).toEqual(["renamed-worker"]); const rootSiblings = await ledger.siblings(parentFile); - expect(rootSiblings.map((row) => row.name)).toEqual(["parent", "other-root"]); + expect(rootSiblings.map((row) => row.name)).toEqual(["parent", "other-root", "orphan-root"]); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/packages/coding-agent/test/suite/regressions/4602-snapshot-transfer-idempotency.test.ts b/packages/coding-agent/test/suite/regressions/4602-snapshot-transfer-idempotency.test.ts index deb503d15c..56b99ebe0b 100644 --- a/packages/coding-agent/test/suite/regressions/4602-snapshot-transfer-idempotency.test.ts +++ b/packages/coding-agent/test/suite/regressions/4602-snapshot-transfer-idempotency.test.ts @@ -18,6 +18,7 @@ import { } from "../../../src/modes/daemon/daemon-worker-protocol.js"; import { SnapshotTranscriptCache } from "../../../src/modes/daemon/snapshot-transcript-cache.js"; import { type PrivateFrame, PrivateFrameDecoder } from "../../../src/modes/session-worker/private-framing.js"; +import { seedSupervisorRoster } from "../../fixtures/roster-seed.js"; const activeSessionId = "active-4602"; const snapshotId = "snapshot-4602"; @@ -350,6 +351,7 @@ describe("ENG-4602 snapshot transfer containment", () => { }; internals.clients.add(client); internals.workers.set(worker.descriptor.workerId, worker); + seedSupervisorRoster(supervisor, worker); internals.syncWorkerExtensionUi = vi.fn(async () => {}); internals.streamSnapshot = streamSnapshot; const messages: AgentMessage[] = [{ role: "user", content: "stable", timestamp: 1 }]; @@ -434,6 +436,7 @@ describe("ENG-4602 snapshot transfer containment", () => { }; internals.clients.add(client); internals.workers.set(worker.descriptor.workerId, worker); + seedSupervisorRoster(supervisor, worker); internals.streamSnapshot = streamSnapshot; const frames = snapshotFrames([{ role: "user", content: "stable", timestamp: 1 }]); for (const message of [frames.begin, frames.chunk, frames.end]) { @@ -501,6 +504,7 @@ describe("ENG-4602 snapshot transfer containment", () => { }; internals.clients.add(client); internals.workers.set(worker.descriptor.workerId, worker); + seedSupervisorRoster(supervisor, worker); internals.shuttingDown = true; internals.streamSnapshot = streamSnapshot; internals.persistWorker = persistWorker; diff --git a/packages/coding-agent/test/suite/regressions/4677-snapshot-catchup-replacement.test.ts b/packages/coding-agent/test/suite/regressions/4677-snapshot-catchup-replacement.test.ts index 19258b6f41..f39b18fdd2 100644 --- a/packages/coding-agent/test/suite/regressions/4677-snapshot-catchup-replacement.test.ts +++ b/packages/coding-agent/test/suite/regressions/4677-snapshot-catchup-replacement.test.ts @@ -17,6 +17,7 @@ import { DaemonSupervisor } from "../../../src/modes/daemon/daemon-supervisor.js import type { DaemonWorkerFrameHeader } from "../../../src/modes/daemon/daemon-worker-protocol.js"; import { SnapshotTranscriptCache } from "../../../src/modes/daemon/snapshot-transcript-cache.js"; import type { PrivateFrame } from "../../../src/modes/session-worker/private-framing.js"; +import { seedSupervisorRoster } from "../../fixtures/roster-seed.js"; const activeSessionId = "active-4677"; const directories: string[] = []; @@ -306,6 +307,7 @@ describe("ENG-4677 snapshot catch-up replacement", () => { handleWorkerFrame(worker: WorkerHarness, frame: PrivateFrame): void; }; internals.workers.set(worker.descriptor.workerId, worker); + seedSupervisorRoster(supervisor, worker); const attaching = internals.attachClient(client, { type: "attach", activeSessionId, @@ -434,6 +436,7 @@ describe("ENG-4677 snapshot catch-up replacement", () => { handleWorkerFrame(worker: WorkerHarness, frame: PrivateFrame): void; }; internals.workers.set(worker.descriptor.workerId, worker); + seedSupervisorRoster(supervisor, worker); const { messages: _firstMessages, ...firstSnapshot } = firstResult.snapshot; const firstBegin = { type: "session_snapshot_begin", @@ -697,6 +700,7 @@ describe("ENG-4677 snapshot catch-up replacement", () => { internals.clients.add(client); internals.workers.set(worker.descriptor.workerId, worker); + seedSupervisorRoster(supervisor, worker); internals.queueCatchup(client, activeSessionId, "replacement"); await internals.catchUpClient(client);