From 0f66949c38d3f0f0b5490710ee1ec19bac93058f Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 29 Aug 2026 14:14:31 +0200 Subject: [PATCH 01/48] feat(coding-agent): event-driven supervisor agent roster; serve list from the ledger Workers now push roster deltas to the supervisor on session events (roster_delta/roster_heartbeat worker frames, compute-on-event and send-only-if-changed, plus a 15s unref'd heartbeat tick). The supervisor keeps one roster ledger seeded at startup from the session catalog and the RLM spawn ledger (tombstones excluded), classifies status exactly once at write via classifyAgentStatus, and serves list, selector matching, family catalogs, and peer rosters from it. Deletions this enables: - handleList per-worker fan-out with its 5s timeout and silent stale summaries; list now does zero worker round-trips. - Event-triggered blanket refreshWorkerSummaries (kept only as a per-worker shim for legacy workers that do not advertise the roster capability in their worker_auth response). - mergeSessionLists; 'list all' is served from the already-merged ledger. - streamingMessage off the list wire (recovery/adoption refresh still seeds the stream reconstructor). Visibility and liveness: - Admitted child runs appear as queued roster rows before their session exists and merge into the session row when it binds. - Close, passivation, and eviction flip rows to inactive; rows are removed only for discarded drafts and spawn-ledger delete records. - A dead worker's rows are marked recovering natively on socket close and failed when recovery gives up; one 15s unref'd watchdog stamps lastHeardFromAt on rows of workers silent for more than 45s. busyClientOwnedSessionCount and daemon-launch busy checks are pinned by tests; roster frames live in the worker protocol, not the client schema, so no client protocol change ships in this part. Part 2 of 3 for the event-driven daemon-owned agent roster. ENG-5794 --- .../.changes/eng-5794-agent-roster-ledger.md | 3 + .../src/modes/daemon/agent-roster.ts | 169 ++++++ .../src/modes/daemon/daemon-mode.ts | 180 ++++++- .../src/modes/daemon/daemon-supervisor.ts | 402 ++++++++++---- .../src/modes/daemon/daemon-worker-client.ts | 7 +- .../modes/daemon/daemon-worker-protocol.ts | 12 +- .../test/daemon-agent-roster.test.ts | 492 ++++++++++++++++++ .../test/daemon-supervisor-eviction.test.ts | 12 +- .../daemon-supervisor-lazy-subagents.test.ts | 17 +- .../test/daemon-supervisor-monitor.test.ts | 18 +- ...4602-snapshot-transfer-idempotency.test.ts | 9 + .../4677-snapshot-catchup-replacement.test.ts | 9 + 12 files changed, 1215 insertions(+), 115 deletions(-) create mode 100644 packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md create mode 100644 packages/coding-agent/test/daemon-agent-roster.test.ts 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..67572a35fc --- /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, `list` is served from the supervisor's ledger with zero worker round-trips, and stale cached summaries can no longer be returned. +- Made admitted subagent runs visible in `list` before their session exists, and kept passivated or evicted agents listed as inactive rows instead of disappearing. +- Marked sessions of a dead worker "recovering" the moment its socket closes, and stamped a last-heard-from time on rows of silent workers instead of guessing. diff --git a/packages/coding-agent/src/modes/daemon/agent-roster.ts b/packages/coding-agent/src/modes/daemon/agent-roster.ts index 5c5e14ee84..4b76b63525 100644 --- a/packages/coding-agent/src/modes/daemon/agent-roster.ts +++ b/packages/coding-agent/src/modes/daemon/agent-roster.ts @@ -1,3 +1,5 @@ +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 +18,170 @@ export function classifyAgentStatus(input: AgentStatusInput): AgentRosterStatus if (!input.resident) return "inactive"; return input.busy || input.hasActiveHeartbeat ? "running" : "idle"; } + +// The wire and ledger shapes below carry a session summary stripped of its +// heavyweight per-event fields; `list` responses re-add an empty sessionActions. +export type RosterSessionSummary = Omit< + SessionSummary, + "streamingMessage" | "sessionActions" | "diagnostics" | "modelFallbackMessage" +>; + +export interface WorkerRosterEntry { + /** rlmChildId for subagents (stable queued->running->passivated), sessionId otherwise. */ + agentId: string; + /** Admitted child run whose session has not materialized yet. */ + queuedChild?: true; + summary: RosterSessionSummary; +} + +/** Supervisor-owned roster row: a worker entry plus supervisor-only state. */ +export interface AgentRosterEntry extends WorkerRosterEntry { + status: AgentRosterStatus; + statusLabel?: "queued" | "recovering" | "failed"; + /** Staleness marker set by the supervisor watchdog while the owning worker is silent. */ + lastHeardFromAt?: string; + /** Owning resident worker; absent for seeded entries no worker has claimed. */ + workerId?: string; +} + +export function rosterAgentIdForSummary( + summary: Pick, +): string { + return summary.runtimeKind === "subagent" && summary.rlmChildId ? summary.rlmChildId : summary.sessionId; +} + +export function workerRosterEntryFromSummary(summary: SessionSummary): WorkerRosterEntry { + const { streamingMessage, sessionActions, diagnostics, modelFallbackMessage, ...slim } = summary; + return { agentId: rosterAgentIdForSummary(summary), summary: slim }; +} + +/** The roster half of the classifier input; the queuedChild bit rides the entry itself. */ +export function classifyWorkerRosterEntry(entry: WorkerRosterEntry): AgentRosterStatus { + const summary = entry.summary; + return classifyAgentStatus({ + resident: !!summary.activeSessionId, + queuedChild: entry.queuedChild === true, + busy: summary.activity === "working" || summary.isSessionActive || summary.hasRunningRlmChildren === true, + hasActiveHeartbeat: summary.hasActiveHeartbeat === true, + }); +} + +/** Final entry for an agent whose runtime left memory; identity and display fields survive. */ +export function passivatedWorkerRosterEntry(entry: WorkerRosterEntry): WorkerRosterEntry { + const { + activeSessionId, + hasActiveHeartbeat, + hasRunningRlmChildren, + isBashRunning, + isRunningTools, + workerState, + workerPid, + ...summary + } = entry.summary; + return { + agentId: entry.agentId, + summary: { + ...summary, + // Inactive rows are keyed by their durable session id, like catalog rows. + id: summary.sessionId, + activity: "idle", + isSessionActive: false, + isStreaming: false, + isCompacting: false, + attachedClients: 0, + }, + }; +} + +export function sessionSummaryFromRosterEntry(entry: WorkerRosterEntry): SessionSummary { + return { ...entry.summary, sessionActions: { queuedCount: 0, steering: [], followUps: [] } }; +} + +/** + * Supervisor-owned roster store. Every write funnels through write() so the + * status is classified exactly once, and the session-file index converges + * catalog-seeded rows (keyed by sessionId) with worker rows (keyed by childId). + */ +export class AgentRosterLedger { + 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..bf8ddac398 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -124,6 +124,7 @@ import { type DaemonSocketClient, resolveActiveSessionState, } from "./active-session-state.js"; +import { passivatedWorkerRosterEntry, 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"; @@ -182,10 +183,12 @@ 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, SESSION_LEASE_OWNER_ID_ENV, SESSION_LEASES_ENABLED_ENV, @@ -541,6 +544,13 @@ export class AgentDaemon { }, ); private readonly recoveryJournal?: WorkerRecoveryJournal; + /** Last roster entry sent per agentId; deltas go out only on change. */ + private readonly rosterLastSent = new Map(); + /** Admitted child runs whose sessions have not materialized yet, keyed by childId. */ + private readonly rosterQueuedChildren = new Map(); + private readonly rosterRemovedAgentIds = new Set(); + 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 +581,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 +659,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(); } @@ -1103,6 +1123,8 @@ 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 }); + this.rosterRemovedAgentIds.add(childId); + 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 +1431,7 @@ export class AgentDaemon { } } onStateBound?.(state); + this.scheduleRosterFlush(); } catch (error) { state.unsubscribe?.(); this.sessions.delete(state.activeSessionId); @@ -3324,7 +3347,11 @@ export class AgentDaemon { type: "response", command: "worker_auth", success: true, + data: { capabilities: [DAEMON_WORKER_ROSTER_CAPABILITY] }, }); + // A (re)connected supervisor has no delta history; resend the full roster. + this.rosterLastSent.clear(); + this.scheduleRosterFlush(); return; } if (this.options.worker) { @@ -6302,6 +6329,9 @@ export class AgentDaemon { state.clients.clear(); this.acpMcpOwners.delete(state.activeSessionId); this.sessions.delete(state.activeSessionId); + // A discarded draft leaves no transcript, so its roster row goes with it. + if (isEmptyDraftSession) this.rosterRemovedAgentIds.add(this.rosterAgentIdForState(state)); + this.scheduleRosterFlush(); if (isEmptyDraftSession) { const sessionFile = state.runtime.session.sessionFile; if (sessionFile) { @@ -6358,6 +6388,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 +6517,134 @@ export class AgentDaemon { } } + private rosterAgentIdForState(state: ActiveSessionState): string { + const session = state.runtime.session; + const metadata = state.runtime.metadata; + return metadata.kind === "subagent" && metadata.rlmChildId ? metadata.rlmChildId : session.sessionId; + } + + 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 { + if (child.activeSessionId === undefined && (child.status === "queued" || child.status === "running")) { + this.rosterQueuedChildren.set(child.id, this.queuedChildRosterEntry(state, child)); + } else { + this.rosterQueuedChildren.delete(child.id); + } + this.scheduleRosterFlush(); + } + + private queuedChildRosterEntry( + state: ActiveSessionState, + child: AgentConnectionRlmChildAgentSnapshot, + ): WorkerRosterEntry { + const parentSession = state.runtime.session; + return { + agentId: child.id, + queuedChild: true, + summary: { + 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, + }, + }; + } + + 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 entries = new Map(); + for (const summary of buildSessionList([...this.sessions.values()], [], this.cronStore.list())) { + const entry = workerRosterEntryFromSummary(summary); + entries.set(entry.agentId, entry); + } + for (const [childId, queued] of this.rosterQueuedChildren) { + if (!entries.has(childId)) entries.set(childId, queued); + } + const removedAgentIds: string[] = []; + for (const agentId of this.rosterRemovedAgentIds) { + entries.delete(agentId); + this.rosterLastSent.delete(agentId); + this.rosterQueuedChildren.delete(agentId); + removedAgentIds.push(agentId); + } + this.rosterRemovedAgentIds.clear(); + for (const [agentId, previous] of this.rosterLastSent) { + // The runtime left memory (close or passivation): flip the row, never drop it. + if (!entries.has(agentId)) entries.set(agentId, passivatedWorkerRosterEntry(previous.entry)); + } + const changed: WorkerRosterEntry[] = []; + for (const [agentId, entry] of entries) { + const json = JSON.stringify(entry); + if (this.rosterLastSent.get(agentId)?.json === json) continue; + this.rosterLastSent.set(agentId, { json, entry }); + changed.push(entry); + } + if (changed.length === 0 && removedAgentIds.length === 0) return; + this.broadcastRosterFrame({ + type: "roster_delta", + entries: changed, + ...(removedAgentIds.length > 0 ? { removedAgentIds } : {}), + }); + } + + private broadcastRosterFrame(message: DaemonWorkerRosterOutbound): void { + const payload = Buffer.from(serializeJsonLine(message)); + for (const client of this.clients) { + if (client.transport !== "private-framed" || client.authenticated !== true || client.socket.destroyed) { + continue; + } + const accepted = client.socket.write( + encodePrivateFrame({ kind: "outbound", outboundType: message.type }, payload), + ); + if (!accepted) { + client.backpressured = true; + } + } + } + private recordWorkerRecoveryState(state: ActiveSessionState, operation: string, busyOverride?: boolean): void { if (!this.recoveryJournal) { return; @@ -6821,6 +6980,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 +7015,21 @@ export class AgentDaemon { } } +const ROSTER_HEARTBEAT_INTERVAL_MS = 15_000; + +// Session events that can change an agent's roster projection (status, activity, name, recap). +const ROSTER_SESSION_EVENT_TRIGGERS = new Set([ + "turn_start", + "turn_end", + "bash_start", + "bash_end", + "compaction_start", + "compaction_end", + "message_end", + "session_action_update", + "session_info_changed", +]); + function hasDaemonOutboundActiveSessionId( message: DaemonOutbound, ): message is DaemonOutbound & { activeSessionId: string } { diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 2c45674070..b07b66cb7e 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -56,6 +56,14 @@ 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 { + type AgentRosterEntry, + AgentRosterLedger, + passivatedWorkerRosterEntry, + 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 +124,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 +133,14 @@ import { type DaemonWorkerDescriptor, type DaemonWorkerFrameHeader, type DaemonWorkerLifecycle, + type DaemonWorkerRosterOutbound, durableDaemonCreateCommand, durableDaemonWorkerDescriptor, 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 } 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 +150,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 = 45_000; 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 +305,12 @@ interface ResidentWorker { ownerCleanupTimer?: ReturnType; promotedOwnerClientId?: string; updateRestartPrepareClient?: DaemonWorkerClient; + /** True once the worker advertised or used the roster-delta protocol. */ + rosterCapable?: boolean; + /** Wall-clock time of the last frame received from this worker. */ + lastFrameAt?: number; + /** True while the watchdog has stamped this worker's entries as stale. */ + rosterStale?: boolean; } interface SnapshotDuplicateValidation { @@ -472,6 +490,12 @@ function isDaemonWorkerDescriptor(value: unknown, socketPath: string): value is ); } +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 +593,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 +636,9 @@ export class DaemonSupervisor { private readonly pendingSessionNames = new Set(); private readonly catalog: DaemonCatalogClient; private readonly settingsManager: SettingsManager; + /** Supervisor-owned agent roster; every list/selector read is served from here. */ + private rosterStore?: AgentRosterLedger; + private rosterWatchdogTimer?: ReturnType; private rlmSpawnLedgerInstance?: RlmSpawnLedger; private idleEvictionTimer?: ReturnType; private idleEvictionSweep?: Promise; @@ -721,6 +717,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 +739,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 +781,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()); @@ -1623,8 +1628,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 }); } @@ -1647,11 +1652,11 @@ export class DaemonSupervisor { } 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); @@ -2247,28 +2252,29 @@ export class DaemonSupervisor { } } - private async handleList( - 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; + /** Served entirely from the roster ledger: zero worker round-trips, no stale-summary window. */ + private handleList(client: DaemonSocketClient, command: Extract): DaemonResponse { + const active: SessionSummary[] = []; + const inactive: SessionSummary[] = []; + let busyClientOwnedSessionCount = 0; + for (const entry of this.roster().values()) { + const worker = entry.workerId !== undefined ? this.workers.get(entry.workerId) : undefined; + if (!worker) { + if (command.all) inactive.push(sessionSummaryFromRosterEntry(entry)); + continue; + } + const summary = this.publicSummary(worker, sessionSummaryFromRosterEntry(entry)); + // Stopping workers stay listed (with an honest workerState) because this + // list also feeds busy-daemon safety checks in daemon-launch. + if (this.isVisibleWorker(worker)) { + active.push(summary); + continue; + } + if (isSessionSummaryBusy(summary)) busyClientOwnedSessionCount += 1; + if (command.includeClientOwned === true && this.isWorkerAccessibleToClient(client, worker)) { + active.push(summary); + } + } const data = { sessions: active, ...(command.includeClientOwned ? { busyClientOwnedSessionCount } : {}), @@ -2276,9 +2282,11 @@ export class DaemonSupervisor { if (!command.all) { 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 cwd = command.cwd ? resolve(command.cwd) : undefined; + const saved = (cwd ? inactive.filter((summary) => resolve(summary.cwd) === cwd) : inactive).sort( + (a, b) => Date.parse(b.modified ?? "") - Date.parse(a.modified ?? ""), + ); + return success(command.id, "list", { ...data, sessions: [...saved, ...active] }); } private async handleSavedSessionList( @@ -2492,6 +2500,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; } @@ -2731,6 +2740,7 @@ export class DaemonSupervisor { 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,12 +2830,14 @@ export class DaemonSupervisor { try { await client.connect(Math.min(500, Math.max(50, deadline - Date.now()))); await client.waitForHello(1000); - await client.authenticateWorker( + const authResponse = await client.authenticateWorker( worker.descriptor.authenticationToken, this.supervisorAuthenticationClaim(), 1000, ); await this.assertRecoveryAllowed(); + worker.rosterCapable = workerAuthAdvertisesRoster(authResponse.data); + worker.lastFrameAt = Date.now(); client.onFrame((frame) => this.handleWorkerFrame(worker, frame)); client.onClose((error) => void this.handleWorkerClose(worker, client, error)); worker.client?.close(); @@ -2959,6 +2971,8 @@ export class DaemonSupervisor { if (this.shuttingDown || worker.intentionalStop) { return; } + // Native, timer-free liveness: a closed worker socket marks its rows immediately. + this.markWorkerRosterEntries(worker, "recovering"); try { await this.assertRecoveryAllowed(); } catch (recoveryError) { @@ -3280,6 +3294,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 +3329,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; @@ -3424,6 +3440,7 @@ export class DaemonSupervisor { this.streamReconstructor.clear(activeSessionId); } } + this.syncWorkerSummariesIntoRoster(worker); if (root) { if (recovery) { await this.assertRecoveryAllowed(); @@ -3439,19 +3456,8 @@ export class DaemonSupervisor { } } - private async familyCatalogEntries(): Promise { - const active = [...this.workers.values()].flatMap((worker) => [...worker.summaries.values()]); - const activePaths = new Set( - active.flatMap((summary) => (summary.sessionFile ? [canonicalSessionPath(summary.sessionFile)] : [])), - ); - const savedRoots = (await this.catalog.list()).filter( - (info) => - (info.rlmDepth ?? (info.parentSessionPath ? -1 : 0)) === 0 && - !activePaths.has(canonicalSessionPath(info.path)), - ); - return [...active, ...savedRoots.map((info) => summaryForInactiveSession(info))].map((summary) => - this.familyCatalogEntry(summary), - ); + private familyCatalogEntries(): AgentFamilyCatalogEntry[] { + return [...this.roster().values()].map((entry) => this.familyCatalogEntry(sessionSummaryFromRosterEntry(entry))); } private async withSessionNameReservation( @@ -3474,7 +3480,7 @@ export class DaemonSupervisor { target: Pick, name: string, ): Promise { - assertAgentSessionNameAvailable(await this.familyCatalogEntries(), { + assertAgentSessionNameAvailable(this.familyCatalogEntries(), { name, depth: target.rlmDepth ?? 0, parentSessionId: target.parentSessionId, @@ -3483,6 +3489,175 @@ export class DaemonSupervisor { }); } + // --------------------------------------------------------------------- + // Agent roster ledger: the single supervisor-side projection every list + // and selector read is served from. Writes go through writeRosterEntry so + // status is classified exactly once, at write time. + // --------------------------------------------------------------------- + + /** Lazily created so long-lived test fixtures over the prototype get a roster too. */ + private roster(): AgentRosterLedger { + this.rosterStore ??= new AgentRosterLedger(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()) { + 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 { + // Ledger edges cover subagents whose transcripts live in artifact dirs + // the catalog scan never sees; tombstoned edges stay out. + for (const edge of await this.rlmSpawnLedger().edges()) { + if (this.roster().has(edge.childId)) continue; + if (this.roster().hasSessionFile(canonicalSessionPath(edge.child))) continue; + this.writeRosterEntry(this.rosterEntryForSpawnLedgerEdge(edge)); + } + } catch (error) { + this.log(`Could not seed the agent roster from the spawn ledger: ${String(error)}`); + } + } + + private rosterEntryForSpawnLedgerEdge(edge: RlmLedgerEdge): WorkerRosterEntry { + return { + agentId: edge.childId, + summary: { + id: edge.childId, + lifecycle: "live", + activity: "idle", + isSessionActive: false, + runtimeKind: "subagent", + rlmDepth: edge.depth, + sessionId: edge.childId, + sessionFile: edge.child, + sessionName: edge.name, + // The ledger records topology only; display fields hydrate lazily on open. + cwd: dirname(edge.child), + isStreaming: false, + isCompacting: false, + attachedClients: 0, + messageCount: 0, + parentSessionPath: edge.parent, + rlmChildId: edge.childId, + }, + }; + } + + private consumeWorkerRosterDelta(worker: ResidentWorker, payload: Buffer): 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.rosterCapable = true; + for (const entry of delta.entries) { + this.writeRosterEntry(entry, worker); + this.syncRootDescriptorFromRosterEntry(worker, entry); + } + for (const agentId of delta.removedAgentIds ?? []) { + this.roster().delete(agentId); + } + } + + /** Keeps the durable root pointers fresh now that event-driven summary refreshes are gone. */ + 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); + } + + private syncWorkerSummariesIntoRoster(worker: ResidentWorker): void { + const seen = new Set(); + for (const summary of worker.summaries.values()) { + const entry = workerRosterEntryFromSummary(summary); + seen.add(entry.agentId); + this.writeRosterEntry(entry, worker); + } + for (const entry of this.workerRosterEntries(worker)) { + if (seen.has(entry.agentId)) continue; + if (entry.queuedChild) continue; + this.writeRosterEntry(passivatedWorkerRosterEntry(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; + } + } + + /** A stopped or evicted worker leaves inactive rows behind, never gaps. */ + private flipWorkerRosterEntriesInactive(worker: ResidentWorker): void { + for (const entry of this.workerRosterEntries(worker)) { + this.writeRosterEntry(passivatedWorkerRosterEntry(entry)); + } + } + + private sweepRosterStaleness(now = Date.now()): void { + for (const worker of this.workers.values()) { + if (worker.client === undefined || worker.lastFrameAt === undefined || worker.rosterCapable !== true) { + 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 +3691,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 +3718,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); @@ -3677,8 +3848,11 @@ export class DaemonSupervisor { ): Promise { let matches = this.matchWorkers(selector, includeWorker); if (matches.length === 0) { + // Roster-capable workers push their sessions; only legacy workers can be stale here. await Promise.all( - [...this.workers.values()].map((worker) => this.refreshWorkerSummaries(worker).catch(() => undefined)), + [...this.workers.values()] + .filter((worker) => worker.rosterCapable !== true) + .map((worker) => this.refreshWorkerSummaries(worker).catch(() => undefined)), ); matches = this.matchWorkers(selector, includeWorker); } @@ -3711,21 +3885,21 @@ 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()) { + 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 +3907,7 @@ 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).map(sessionSummaryFromRosterEntry); const exact = summaries.find((summary) => { const activeSessionId = summary.activeSessionId ?? summary.id; return ( @@ -3756,11 +3930,10 @@ 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, - ); + const summaryMatches = targetEntry?.workerId === worker.descriptor.workerId; const descriptorPath = worker.descriptor.sessionFile ? canonicalSessionPath(worker.descriptor.sessionFile) : undefined; @@ -4290,6 +4463,8 @@ export class DaemonSupervisor { if (frame.header.kind !== "outbound") { return; } + worker.lastFrameAt = Date.now(); + this.clearRosterStaleness(worker); const { outboundType, activeSessionId, @@ -4298,6 +4473,13 @@ export class DaemonSupervisor { payloadEncoding, snapshotPurpose, } = frame.header; + if (outboundType === "roster_delta") { + this.consumeWorkerRosterDelta(worker, frame.payload); + return; + } + if (outboundType === "roster_heartbeat") { + return; + } if (outboundType === "heartbeats_changed") { worker.heartbeatSnapshotStale = true; this.broadcastHeartbeatsChanged(); @@ -4686,13 +4868,15 @@ 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" + if ( + worker.rosterCapable !== true && + (outboundType === "session_replaced" || + outboundType === "session_closed" || + sessionEventType === "turn_start" || + sessionEventType === "turn_end" || + sessionEventType === "rlm_child_update") ) { + // Legacy workers predate roster deltas; refreshing keeps their ledger rows fresh until they are replaced. void this.refreshWorkerSummaries(worker).catch(() => undefined); } if ( @@ -4708,6 +4892,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 +5454,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 +5663,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 +5763,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..78c1300073 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,15 @@ 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; never forwarded to TUI clients, so they +// live outside the client-facing DaemonOutbound schema. +export type DaemonWorkerRosterOutbound = + | { type: "roster_delta"; entries: WorkerRosterEntry[]; removedAgentIds?: string[] } + | { type: "roster_heartbeat" }; + +/** Advertised by new workers in the worker_auth response; absent on legacy workers. */ +export const DAEMON_WORKER_ROSTER_CAPABILITY = "agent_roster"; + export type DaemonWorkerFrameHeader = | { kind: "command"; @@ -24,7 +34,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/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts new file mode 100644 index 0000000000..dcf2ac01b6 --- /dev/null +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -0,0 +1,492 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +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; + rosterRemovedAgentIds: Set; + }; + sentDeltas: RosterDelta[]; +} + +function makeWorkerReporter(): WorkerReporterFixture { + const sentDeltas: RosterDelta[] = []; + const daemon = Object.assign(Object.create(AgentDaemon.prototype), { + options: { worker: { authenticationToken: "token" } }, + sessions: new Map(), + cronStore: { list: () => [] }, + rosterLastSent: new Map(), + rosterQueuedChildren: new Map(), + rosterRemovedAgentIds: new Set(), + rosterFlushScheduled: false, + shuttingDown: false, + broadcastRosterFrame: (message: DaemonWorkerRosterOutbound) => { + if (message.type === "roster_delta") sentDeltas.push(message); + }, + log: vi.fn(), + }) as WorkerReporterFixture["daemon"]; + return { daemon, sentDeltas }; +} + +function makeState(options: { + activeSessionId: string; + sessionId?: string; + kind?: "top-level" | "subagent"; + rlmChildId?: string; + messages?: AgentMessage[]; + isStreaming?: boolean; +}): ActiveSessionState { + return { + activeSessionId: options.activeSessionId, + clients: new Set(), + lastEventSequence: 0, + runtime: { + metadata: { + kind: options.kind ?? "top-level", + createdAt: 1, + ...(options.rlmChildId ? { rlmChildId: options.rlmChildId } : {}), + }, + diagnostics: [], + session: { + thinkingLevel: "off", + isStreaming: options.isStreaming ?? false, + isCompacting: false, + 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, + }, + messages: options.messages ?? [], + getRlmChildSnapshots: () => [], + hasRunningRlmChildren: () => false, + hasAcceptedPromptInFlight: false, + unfinishedActionCount: 0, + 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("publishes an admitted child run before its session exists and merges it on session bind", () => { + 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(); + + const queued = sentDeltas[0]?.entries.find((entry) => entry.agentId === "child-1"); + expect(queued).toMatchObject({ + agentId: "child-1", + queuedChild: true, + summary: { runtimeKind: "subagent", parentActiveSessionId: "parent-active", firstMessage: "review the API" }, + }); + + // The child session materializes: same agentId, one resident row, no queued marker. + const childState = makeState({ + activeSessionId: "child-active", + kind: "subagent", + rlmChildId: "child-1", + 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 === "child-1") ?? []; + expect(merged).toHaveLength(1); + expect(merged[0]).toMatchObject({ summary: { activeSessionId: "child-active", lifecycle: "live" } }); + expect(merged[0]?.queuedChild).toBeUndefined(); + }); + + it("sends deltas only on change and flips closed sessions to non-resident instead of dropping them", () => { + const { daemon, sentDeltas } = makeWorkerReporter(); + const state = makeState({ + activeSessionId: "root-active", + messages: [{ role: "user", content: "hi" } as unknown as AgentMessage], + }); + daemon.sessions.set(state.activeSessionId, state); + + daemon.flushRoster(); + daemon.flushRoster(); + expect(sentDeltas).toHaveLength(1); + + daemon.sessions.delete(state.activeSessionId); + daemon.flushRoster(); + const flipped = sentDeltas.at(-1)?.entries[0]; + expect(flipped).toMatchObject({ summary: { id: "session-root-active", isSessionActive: false } }); + expect(flipped?.summary.activeSessionId).toBeUndefined(); + expect(sentDeltas.at(-1)?.removedAgentIds).toBeUndefined(); + }); + + it("removes discarded and deleted agents explicitly", () => { + const { daemon, sentDeltas } = makeWorkerReporter(); + const draft = makeState({ activeSessionId: "draft-active" }); + daemon.sessions.set(draft.activeSessionId, draft); + daemon.flushRoster(); + + daemon.sessions.delete(draft.activeSessionId); + daemon.rosterRemovedAgentIds.add("session-draft-active"); + daemon.flushRoster(); + + expect(sentDeltas.at(-1)?.removedAgentIds).toEqual(["session-draft-active"]); + expect(sentDeltas.at(-1)?.entries).toHaveLength(0); + }); +}); + +// ------------------------------------------------------------------ +// 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"; + ownerClientId?: string; + }; + client?: { request: ReturnType }; + summaries: Map; + intentionalStop: boolean; + rosterCapable?: boolean; + lastFrameAt?: number; + rosterStale?: 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 }, + ): { success: boolean; data?: { sessions: SessionSummary[]; busyClientOwnedSessionCount?: number } }; + handleWorkerClose(worker: WorkerFixture, client: object, error: Error): Promise; + handleWorkerFrame(worker: WorkerFixture, frame: unknown): void; + sweepRosterStaleness(now?: number): void; + writeRosterEntry(entry: WorkerRosterEntry, worker?: WorkerFixture): AgentRosterEntry; + workerRosterEntries(worker: WorkerFixture): AgentRosterEntry[]; + flipWorkerRosterEntriesInactive(worker: WorkerFixture): void; + seedRosterLedger(): Promise; + 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(), + 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; +} + +function rosterDelta(entries: WorkerRosterEntry[], removedAgentIds?: string[]): Buffer { + return Buffer.from( + JSON.stringify({ type: "roster_delta", entries, ...(removedAgentIds ? { removedAgentIds } : {}) }), + ); +} + +describe("supervisor roster ledger", () => { + it("classifies at write, labels queued children, and merges them into their session row", () => { + const worker = makeWorker("worker-1"); + const supervisor = makeSupervisor([worker]); + + supervisor.consumeWorkerRosterDelta( + worker, + rosterDelta([ + { + agentId: "child-1", + queuedChild: true, + summary: summary({ + id: "child-1", + sessionId: "child-1", + runtimeKind: "subagent", + rlmChildId: "child-1", + }), + }, + ]), + ); + + let listed = supervisor.handleList({}, { type: "list" }); + expect(listed.data?.sessions.map((session) => session.sessionId)).toEqual(["child-1"]); + expect(supervisor.workerRosterEntries(worker)[0]).toMatchObject({ status: "running", statusLabel: "queued" }); + + supervisor.consumeWorkerRosterDelta( + worker, + rosterDelta([ + { + agentId: "child-1", + summary: summary({ + id: "child-active", + sessionId: "child-session", + activeSessionId: "child-active", + runtimeKind: "subagent", + rlmChildId: "child-1", + isSessionActive: true, + }), + }, + ]), + ); + + listed = supervisor.handleList({}, { type: "list" }); + expect(listed.data?.sessions).toHaveLength(1); + expect(listed.data?.sessions[0]).toMatchObject({ activeSessionId: "child-active", workerState: "ready" }); + expect(supervisor.workerRosterEntries(worker)[0]).toMatchObject({ status: "running" }); + expect(supervisor.workerRosterEntries(worker)[0]?.statusLabel).toBeUndefined(); + }); + + it("serves list from the ledger with zero worker round-trips and exact busy counts", () => { + 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 = 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(); + }); + + it("marks a dead worker's rows recovering natively on socket close", async () => { + const worker = makeWorker("worker-1"); + const supervisor = makeSupervisor([worker]); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary(summary({ id: "r-active", sessionId: "r", activeSessionId: "r-active" })), + worker, + ); + + const client = worker.client as object; + await supervisor.handleWorkerClose(worker, client, new Error("worker died")); + + const entry = supervisor.workerRosterEntries(worker)[0]; + expect(entry).toMatchObject({ statusLabel: "recovering" }); + expect(entry?.summary.activeSessionId).toBe("r-active"); + }); + + it("stamps staleness while a worker is silent and clears it when frames resume", () => { + const now = Date.parse("2026-08-01T12:00:00.000Z"); + const worker = makeWorker("worker-1", { rosterCapable: true, lastFrameAt: now - 60_000 }); + const supervisor = makeSupervisor([worker]); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary(summary({ id: "s-active", sessionId: "s", activeSessionId: "s-active" })), + worker, + ); + + supervisor.sweepRosterStaleness(now); + expect(supervisor.workerRosterEntries(worker)[0]?.lastHeardFromAt).toBe(new Date(now - 60_000).toISOString()); + + worker.lastFrameAt = now; + supervisor.sweepRosterStaleness(now); + expect(supervisor.workerRosterEntries(worker)[0]?.lastHeardFromAt).toBeUndefined(); + }); + + it("refreshes summaries on events only for workers without the roster capability", () => { + const legacy = makeWorker("legacy"); + const modern = makeWorker("modern", { rosterCapable: true }); + const supervisor = makeSupervisor([legacy, modern], { + streamReconstructor: { observe: vi.fn(), seed: vi.fn(), clear: vi.fn() }, + }); + const frame = (activeSessionId: string) => ({ + header: { + kind: "outbound", + outboundType: "session_event", + activeSessionId, + sessionEventType: "turn_end", + payloadEncoding: "jsonl", + }, + payload: Buffer.from(JSON.stringify({ type: "session_event", activeSessionId, event: { type: "turn_end" } })), + }); + + supervisor.handleWorkerFrame(legacy, frame("legacy-active")); + supervisor.handleWorkerFrame(modern, frame("modern-active")); + + expect(supervisor.refreshWorkerSummaries).toHaveBeenCalledTimes(1); + expect(supervisor.refreshWorkerSummaries).toHaveBeenCalledWith(legacy); + }); + + it("seeds from the session catalog and spawn ledger, skips tombstones, 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"); + 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" }); + + 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(); + + const listed = 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); + + // An evicted worker leaves its rows behind as inactive instead of dropping them. + const worker = makeWorker("worker-1"); + supervisor.workers.set("worker-1", worker); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ id: "e-active", sessionId: "evicted", activeSessionId: "e-active", isSessionActive: true }), + ), + worker, + ); + supervisor.workers.delete("worker-1"); + supervisor.flipWorkerRosterEntriesInactive(worker); + + const afterEvict = supervisor.handleList({}, { type: "list", all: true }); + const evicted = afterEvict.data?.sessions.find((session) => session.sessionId === "evicted"); + expect(evicted).toBeDefined(); + expect(evicted?.activeSessionId).toBeUndefined(); + expect(afterEvict.data?.sessions.some((session) => session.sessionId === "deleted-child")).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts index 0941a3e552..466a52a746 100644 --- a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts @@ -88,6 +88,11 @@ function makeWorker(id: string, summaries: SessionSummary[]): WorkerFixture { }; } +function seedSupervisorRoster(supervisor: SupervisorInternals, ...workers: WorkerFixture[]): void { + const internals = supervisor as unknown as { syncWorkerSummariesIntoRoster(worker: WorkerFixture): void }; + for (const worker of workers) internals.syncWorkerSummariesIntoRoster(worker); +} + function makeSupervisor(idleEvictionMinutes: number | "off" = 90): SupervisorInternals { const directory = mkdtempSync(join(tmpdir(), "prime-supervisor-eviction-")); tempDirs.push(directory); @@ -284,7 +289,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, { @@ -369,6 +377,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 +410,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"); }); 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..db7abd3cc1 100644 --- a/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts @@ -32,6 +32,8 @@ interface SupervisorInternals { ): void; familyCatalogEntry(summary: SessionSummary): AgentFamilyCatalogEntry; handleCommand(client: object, command: Record): Promise; + seedRosterLedger(): Promise; + syncWorkerSummariesIntoRoster(worker: WorkerFixture): void; } interface WorkerFixture { @@ -73,6 +75,10 @@ function summary(overrides: Partial & Pick { sessionId: "aaaa6666777788889999dddd", }); const resident = worker("first", [child]); + seedRoster(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 +143,7 @@ describe("daemon supervisor passive subagent topology", () => { }, launchWorker, }); + await supervisor.seedRosterLedger(); await expect( supervisor.createOrReuseWorker("client", { type: "create", name: "duplicate-root" }), @@ -179,6 +187,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 +220,7 @@ describe("daemon supervisor passive subagent topology", () => { }, launchWorker, }); + await supervisor.seedRosterLedger(); await expect( supervisor.createOrReuseWorker("client", { type: "create", name: " duplicate-root " }), @@ -247,6 +257,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 +507,7 @@ describe("daemon supervisor passive subagent topology", () => { }) as unknown as SupervisorInternals; supervisor.workers.set("first", firstWorker); supervisor.workers.set("second", secondWorker); + seedRoster(supervisor, firstWorker, secondWorker); Object.assign(supervisor, { catalog: { list: vi.fn(async () => []) } }); const client = { id: "client", attachedActiveSessionIds: new Set() }; @@ -539,6 +551,7 @@ describe("daemon supervisor passive subagent topology", () => { descriptorDir: join(directory, "workers"), }) as unknown as SupervisorInternals; supervisor.workers.set("owned", ownedWorker); + seedRoster(supervisor, ownedWorker); Object.assign(supervisor, { catalog: { list: vi.fn(async () => []) } }); const workerClient = { id: "daemon-client:worker", attachedActiveSessionIds: new Set() }; @@ -611,6 +624,7 @@ describe("daemon supervisor passive subagent topology", () => { }) as unknown as SupervisorInternals; supervisor.workers.set("first", firstWorker); supervisor.workers.set("second", secondWorker); + seedRoster(supervisor, firstWorker, secondWorker); Object.assign(supervisor, { catalog: { siblings: vi.fn(async () => []), @@ -798,6 +812,7 @@ describe("daemon supervisor passive subagent topology", () => { supervisor.workers.set("first", first); supervisor.workers.set("second", second); supervisor.workers.set("disconnected", disconnected); + seedRoster(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..7417195b36 100644 --- a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts @@ -292,6 +292,14 @@ function createHarness(canConnect: () => Promise): SupervisorMonitorHar }) as SupervisorMonitorHarness; } +function seedSupervisorRoster( + supervisor: object, + ...workers: Array<{ descriptor: { workerId: string }; summaries: Map }> +): void { + const internals = supervisor as { syncWorkerSummariesIntoRoster(worker: object): void }; + for (const worker of workers) internals.syncWorkerSummariesIntoRoster(worker); +} + describe("daemon worker supervisor monitoring", () => { afterEach(async () => { for (const { child } of workerLaunchTestState.spawned) { @@ -2067,13 +2075,14 @@ describe("daemon worker supervisor monitoring", () => { handleList( client: object, command: { id: string; type: "list" }, - ): Promise<{ + ): { success: boolean; data?: { sessions: Array<{ activeSessionId?: string; id: string; workerState?: string }> }; - }>; + }; }; + seedSupervisorRoster(supervisor, liveWorker, stoppingWorker); - const response = await supervisor.handleList({}, { id: "list-1", type: "list" }); + const response = supervisor.handleList({}, { id: "list-1", type: "list" }); expect(response.success).toBe(true); const sessions = response.data?.sessions ?? []; @@ -3410,6 +3419,7 @@ describe("daemon worker supervisor monitoring", () => { command: { type: "attach"; activeSessionId: string }, ): Promise; }; + seedSupervisorRoster(supervisor, worker); await supervisor.attachClient(client, { type: "attach", activeSessionId }); @@ -3523,6 +3533,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 +3698,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", 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..95b39bd6d1 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 @@ -350,6 +350,9 @@ describe("ENG-4602 snapshot transfer containment", () => { }; internals.clients.add(client); internals.workers.set(worker.descriptor.workerId, worker); + ( + supervisor as unknown as { syncWorkerSummariesIntoRoster(worker: WorkerHarness): void } + ).syncWorkerSummariesIntoRoster(worker); internals.syncWorkerExtensionUi = vi.fn(async () => {}); internals.streamSnapshot = streamSnapshot; const messages: AgentMessage[] = [{ role: "user", content: "stable", timestamp: 1 }]; @@ -434,6 +437,9 @@ describe("ENG-4602 snapshot transfer containment", () => { }; internals.clients.add(client); internals.workers.set(worker.descriptor.workerId, worker); + ( + supervisor as unknown as { syncWorkerSummariesIntoRoster(worker: WorkerHarness): void } + ).syncWorkerSummariesIntoRoster(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 +507,9 @@ describe("ENG-4602 snapshot transfer containment", () => { }; internals.clients.add(client); internals.workers.set(worker.descriptor.workerId, worker); + ( + supervisor as unknown as { syncWorkerSummariesIntoRoster(worker: WorkerHarness): void } + ).syncWorkerSummariesIntoRoster(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..dd79099f70 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 @@ -306,6 +306,9 @@ describe("ENG-4677 snapshot catch-up replacement", () => { handleWorkerFrame(worker: WorkerHarness, frame: PrivateFrame): void; }; internals.workers.set(worker.descriptor.workerId, worker); + ( + supervisor as unknown as { syncWorkerSummariesIntoRoster(worker: WorkerHarness): void } + ).syncWorkerSummariesIntoRoster(worker); const attaching = internals.attachClient(client, { type: "attach", activeSessionId, @@ -434,6 +437,9 @@ describe("ENG-4677 snapshot catch-up replacement", () => { handleWorkerFrame(worker: WorkerHarness, frame: PrivateFrame): void; }; internals.workers.set(worker.descriptor.workerId, worker); + ( + supervisor as unknown as { syncWorkerSummariesIntoRoster(worker: WorkerHarness): void } + ).syncWorkerSummariesIntoRoster(worker); const { messages: _firstMessages, ...firstSnapshot } = firstResult.snapshot; const firstBegin = { type: "session_snapshot_begin", @@ -697,6 +703,9 @@ describe("ENG-4677 snapshot catch-up replacement", () => { internals.clients.add(client); internals.workers.set(worker.descriptor.workerId, worker); + ( + supervisor as unknown as { syncWorkerSummariesIntoRoster(worker: WorkerHarness): void } + ).syncWorkerSummariesIntoRoster(worker); internals.queueCatchup(client, activeSessionId, "replacement"); await internals.catchUpClient(client); From 1573af14d11627e8b6ced7a3881ca38bf21995b9 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 29 Aug 2026 14:49:00 +0200 Subject: [PATCH 02/48] fix(coding-agent): review fixes for the supervisor agent roster - list keeps its resident-only contract: non-all list emits only sessions with an activeSessionId; queued child runs and passivated rows stay ledger-internal, and list all carries the non-resident rows (owned rows keep their workerState/workerPid). sessionDir on list all now filters rows by sessions dir (including its sibling session-artifacts tree) instead of being ignored. - Offline saved-session renames and deletes, and worker-side saved-session deletes, now write the roster ledger. - Supervisor (re)authentication makes the worker send a replacing roster snapshot; rows absent from the snapshot passivate when a transcript exists and are removed otherwise. Pending state commits only after a frame reaches an authenticated supervisor, and the supervisor registers its frame listener before authenticating so the snapshot cannot race. - Remaining worker.summaries read paths (wake fallback, create reuse and readiness) moved to the ledger; create-forward and rename refreshes are gated to legacy workers, with the returned summary written to the ledger. - The roster wire summary keeps modelFallbackMessage for the active-open path. - Queued-run supersession has one mechanism (session rows overwrite queued rows at flush); the run-lifecycle cleanup in observeRosterChildUpdate is pinned by a bind-then-close test, and the saved-delete test proves the supervisor removes the ledger row end-to-end. - Worker roster reporter state is created lazily so prototype-based fixtures exercising worker_auth cannot crash the flush path. ENG-5794 --- .../src/modes/daemon/agent-roster.ts | 16 +- .../src/modes/daemon/daemon-mode.ts | 112 ++++-- .../src/modes/daemon/daemon-supervisor.ts | 88 +++-- .../modes/daemon/daemon-worker-protocol.ts | 5 +- .../test/daemon-agent-roster.test.ts | 357 ++++++++++++++++-- .../test/daemon-supervisor-eviction.test.ts | 6 +- .../test/daemon-supervisor-monitor.test.ts | 4 + .../test/daemon-supervisor-process.test.ts | 8 +- 8 files changed, 500 insertions(+), 96 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/agent-roster.ts b/packages/coding-agent/src/modes/daemon/agent-roster.ts index 4b76b63525..e5b8470889 100644 --- a/packages/coding-agent/src/modes/daemon/agent-roster.ts +++ b/packages/coding-agent/src/modes/daemon/agent-roster.ts @@ -19,12 +19,8 @@ export function classifyAgentStatus(input: AgentStatusInput): AgentRosterStatus return input.busy || input.hasActiveHeartbeat ? "running" : "idle"; } -// The wire and ledger shapes below carry a session summary stripped of its -// heavyweight per-event fields; `list` responses re-add an empty sessionActions. -export type RosterSessionSummary = Omit< - SessionSummary, - "streamingMessage" | "sessionActions" | "diagnostics" | "modelFallbackMessage" ->; +// A session summary without its heavyweight per-event fields; `list` re-adds an empty sessionActions. +export type RosterSessionSummary = Omit; export interface WorkerRosterEntry { /** rlmChildId for subagents (stable queued->running->passivated), sessionId otherwise. */ @@ -51,7 +47,7 @@ export function rosterAgentIdForSummary( } export function workerRosterEntryFromSummary(summary: SessionSummary): WorkerRosterEntry { - const { streamingMessage, sessionActions, diagnostics, modelFallbackMessage, ...slim } = summary; + const { streamingMessage, sessionActions, diagnostics, ...slim } = summary; return { agentId: rosterAgentIdForSummary(summary), summary: slim }; } @@ -97,11 +93,7 @@ export function sessionSummaryFromRosterEntry(entry: WorkerRosterEntry): Session return { ...entry.summary, sessionActions: { queuedCount: 0, steering: [], followUps: [] } }; } -/** - * Supervisor-owned roster store. Every write funnels through write() so the - * status is classified exactly once, and the session-file index converges - * catalog-seeded rows (keyed by sessionId) with worker rows (keyed by childId). - */ +// Supervisor-owned roster store; write() classifies once and its file index converges seed and worker keys. export class AgentRosterLedger { private readonly entries = new Map(); private readonly agentIdByActiveSessionId = new Map(); diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index bf8ddac398..f87c0d8192 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -544,11 +544,7 @@ export class AgentDaemon { }, ); private readonly recoveryJournal?: WorkerRecoveryJournal; - /** Last roster entry sent per agentId; deltas go out only on change. */ - private readonly rosterLastSent = new Map(); - /** Admitted child runs whose sessions have not materialized yet, keyed by childId. */ - private readonly rosterQueuedChildren = new Map(); - private readonly rosterRemovedAgentIds = new Set(); + private rosterReporterState?: WorkerRosterReporterState; private rosterFlushScheduled = false; private rosterHeartbeatTimer?: ReturnType; private rlmSpawnLedgerInstance?: RlmSpawnLedger; @@ -1123,7 +1119,7 @@ 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 }); - this.rosterRemovedAgentIds.add(childId); + this.rosterReporter().removedAgentIds.add(childId); this.scheduleRosterFlush(); // Deletion boundary: transcript + display tombstone are the durable // record and stay; the nested artifact dir is a runtime cache and goes. @@ -3349,8 +3345,8 @@ export class AgentDaemon { success: true, data: { capabilities: [DAEMON_WORKER_ROSTER_CAPABILITY] }, }); - // A (re)connected supervisor has no delta history; resend the full roster. - this.rosterLastSent.clear(); + // A (re)connected supervisor has no delta history; the next flush sends a replacing snapshot. + this.rosterReporter().snapshotPending = true; this.scheduleRosterFlush(); return; } @@ -3899,11 +3895,16 @@ export class AgentDaemon { if (this.findActiveSessionByFile(command.sessionPath)) { throw new Error("Cannot delete the currently active session"); } + const deletedInfo = await readSessionInfo(command.sessionPath).catch(() => undefined); const result = await this.deleteSavedSessionFile(command.sessionPath, { afterFileRemoved: () => { this.cancelScheduledJobsForSessionFile(command.sessionPath); }, }); + if (deletedInfo) { + this.rosterReporter().removedAgentIds.add(deletedInfo.id); + this.scheduleRosterFlush(); + } return success(command.id, "delete_saved_session", result); } @@ -6330,7 +6331,7 @@ export class AgentDaemon { this.acpMcpOwners.delete(state.activeSessionId); this.sessions.delete(state.activeSessionId); // A discarded draft leaves no transcript, so its roster row goes with it. - if (isEmptyDraftSession) this.rosterRemovedAgentIds.add(this.rosterAgentIdForState(state)); + if (isEmptyDraftSession) this.rosterReporter().removedAgentIds.add(this.rosterAgentIdForState(state)); this.scheduleRosterFlush(); if (isEmptyDraftSession) { const sessionFile = state.runtime.session.sessionFile; @@ -6517,6 +6518,16 @@ export class AgentDaemon { } } + private rosterReporter(): WorkerRosterReporterState { + this.rosterReporterState ??= { + lastSent: new Map(), + queuedChildren: new Map(), + removedAgentIds: new Set(), + snapshotPending: false, + }; + return this.rosterReporterState; + } + private rosterAgentIdForState(state: ActiveSessionState): string { const session = state.runtime.session; const metadata = state.runtime.metadata; @@ -6543,9 +6554,9 @@ export class AgentDaemon { private observeRosterChildUpdate(state: ActiveSessionState, child: AgentConnectionRlmChildAgentSnapshot): void { if (child.activeSessionId === undefined && (child.status === "queued" || child.status === "running")) { - this.rosterQueuedChildren.set(child.id, this.queuedChildRosterEntry(state, child)); + this.rosterReporter().queuedChildren.set(child.id, this.queuedChildRosterEntry(state, child)); } else { - this.rosterQueuedChildren.delete(child.id); + this.rosterReporter().queuedChildren.delete(child.id); } this.scheduleRosterFlush(); } @@ -6595,43 +6606,75 @@ export class AgentDaemon { } private flushRoster(): void { + // Without a connected supervisor, pending state stays queued; auth triggers a replacing snapshot. + if (!this.hasAuthenticatedSupervisorClient()) return; + const reporter = this.rosterReporter(); const entries = new Map(); + for (const [childId, queued] of reporter.queuedChildren) { + entries.set(childId, queued); + } + // A materialized session row supersedes its queued-run row: same agentId, later insertion wins. for (const summary of buildSessionList([...this.sessions.values()], [], this.cronStore.list())) { const entry = workerRosterEntryFromSummary(summary); entries.set(entry.agentId, entry); } - for (const [childId, queued] of this.rosterQueuedChildren) { - if (!entries.has(childId)) entries.set(childId, queued); - } - const removedAgentIds: string[] = []; - for (const agentId of this.rosterRemovedAgentIds) { + const removedAgentIds = [...reporter.removedAgentIds]; + for (const agentId of removedAgentIds) { entries.delete(agentId); - this.rosterLastSent.delete(agentId); - this.rosterQueuedChildren.delete(agentId); - removedAgentIds.push(agentId); + reporter.queuedChildren.delete(agentId); } - this.rosterRemovedAgentIds.clear(); - for (const [agentId, previous] of this.rosterLastSent) { + if (reporter.snapshotPending) { + if (!this.broadcastRosterFrame({ type: "roster_delta", snapshot: true, entries: [...entries.values()] })) { + return; + } + reporter.snapshotPending = false; + reporter.removedAgentIds.clear(); + reporter.lastSent.clear(); + for (const [agentId, entry] of entries) { + reporter.lastSent.set(agentId, { json: JSON.stringify(entry), entry }); + } + return; + } + for (const [agentId, previous] of reporter.lastSent) { // The runtime left memory (close or passivation): flip the row, never drop it. - if (!entries.has(agentId)) entries.set(agentId, passivatedWorkerRosterEntry(previous.entry)); + if (!entries.has(agentId) && !reporter.removedAgentIds.has(agentId)) { + entries.set(agentId, passivatedWorkerRosterEntry(previous.entry)); + } } - const changed: WorkerRosterEntry[] = []; + const changed: Array<{ agentId: string; json: string; entry: WorkerRosterEntry }> = []; for (const [agentId, entry] of entries) { const json = JSON.stringify(entry); - if (this.rosterLastSent.get(agentId)?.json === json) continue; - this.rosterLastSent.set(agentId, { json, entry }); - changed.push(entry); + if (reporter.lastSent.get(agentId)?.json === json) continue; + changed.push({ agentId, json, entry }); } if (changed.length === 0 && removedAgentIds.length === 0) return; - this.broadcastRosterFrame({ + const delivered = this.broadcastRosterFrame({ type: "roster_delta", - entries: changed, + entries: changed.map(({ entry }) => entry), ...(removedAgentIds.length > 0 ? { removedAgentIds } : {}), }); + if (!delivered) return; + for (const { agentId, json, entry } of changed) { + reporter.lastSent.set(agentId, { json, entry }); + } + for (const agentId of removedAgentIds) { + reporter.lastSent.delete(agentId); + reporter.removedAgentIds.delete(agentId); + } + } + + private hasAuthenticatedSupervisorClient(): boolean { + for (const client of this.clients) { + if (client.transport === "private-framed" && client.authenticated === true && !client.socket.destroyed) { + return true; + } + } + return false; } - private broadcastRosterFrame(message: DaemonWorkerRosterOutbound): void { + private broadcastRosterFrame(message: DaemonWorkerRosterOutbound): boolean { const payload = Buffer.from(serializeJsonLine(message)); + let delivered = false; for (const client of this.clients) { if (client.transport !== "private-framed" || client.authenticated !== true || client.socket.destroyed) { continue; @@ -6639,10 +6682,12 @@ export class AgentDaemon { const accepted = client.socket.write( encodePrivateFrame({ kind: "outbound", outboundType: message.type }, payload), ); + delivered = true; if (!accepted) { client.backpressured = true; } } + return delivered; } private recordWorkerRecoveryState(state: ActiveSessionState, operation: string, busyOverride?: boolean): void { @@ -7017,6 +7062,15 @@ export class AgentDaemon { const ROSTER_HEARTBEAT_INTERVAL_MS = 15_000; +interface WorkerRosterReporterState { + /** Last entry sent per agentId; deltas go out only on change. */ + lastSent: Map; + /** Admitted child runs whose sessions have not materialized yet, keyed by childId. */ + queuedChildren: Map; + removedAgentIds: Set; + snapshotPending: boolean; +} + // Session events that can change an agent's roster projection (status, activity, name, recap). const ROSTER_SESSION_EVENT_TRIGGERS = new Set([ "turn_start", diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index b07b66cb7e..034e1e9920 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 { dirname, join, resolve, sep } from "node:path"; import { Writable } from "node:stream"; import { getLogger } from "@earendil-works/pi-ai"; import { createCliSubprocessEnv, createCliSubprocessLaunchSpec } from "../../cli/subprocess-launch.js"; @@ -636,7 +636,6 @@ export class DaemonSupervisor { private readonly pendingSessionNames = new Set(); private readonly catalog: DaemonCatalogClient; private readonly settingsManager: SettingsManager; - /** Supervisor-owned agent roster; every list/selector read is served from here. */ private rosterStore?: AgentRosterLedger; private rosterWatchdogTimer?: ReturnType; private rlmSpawnLedgerInstance?: RlmSpawnLedger; @@ -1647,7 +1646,8 @@ 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); + if (worker.rosterCapable !== true) await this.refreshWorkerSummaries(worker); return { ...response, id: command.id, data: this.publicSummary(worker, response.data) }; } return responseWithId(response, command.id); @@ -2093,6 +2093,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); @@ -2109,6 +2113,8 @@ export class DaemonSupervisor { throw new Error("Cannot delete the currently active session"); } const result = await this.catalog.delete(command.sessionPath); + const entry = this.roster().bySessionFile(canonicalSessionPath(command.sessionPath)); + if (entry) this.roster().delete(entry.agentId); return success(command.id, command.type, result); } break; @@ -2153,9 +2159,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 }; } @@ -2258,14 +2265,18 @@ export class DaemonSupervisor { const inactive: SessionSummary[] = []; let busyClientOwnedSessionCount = 0; for (const entry of this.roster().values()) { + // Sessionless queued-child rows stay ledger-internal until a push protocol can carry their label. + if (entry.queuedChild) continue; const worker = entry.workerId !== undefined ? this.workers.get(entry.workerId) : undefined; - if (!worker) { - if (command.all) inactive.push(sessionSummaryFromRosterEntry(entry)); + if (worker === undefined || entry.summary.activeSessionId === undefined) { + if (command.all) { + const base = sessionSummaryFromRosterEntry(entry); + inactive.push(worker ? this.publicSummary(worker, base) : base); + } continue; } const summary = this.publicSummary(worker, sessionSummaryFromRosterEntry(entry)); - // Stopping workers stay listed (with an honest workerState) because this - // list also feeds busy-daemon safety checks in daemon-launch. + // Stopping workers stay listed with an honest workerState; daemon-launch busy checks read this list. if (this.isVisibleWorker(worker)) { active.push(summary); continue; @@ -2283,12 +2294,22 @@ export class DaemonSupervisor { return success(command.id, "list", data); } const cwd = command.cwd ? resolve(command.cwd) : undefined; - const saved = (cwd ? inactive.filter((summary) => resolve(summary.cwd) === cwd) : inactive).sort( - (a, b) => Date.parse(b.modified ?? "") - Date.parse(a.modified ?? ""), - ); + const saved = inactive + .filter((summary) => cwd === undefined || resolve(summary.cwd) === cwd) + .filter((summary) => this.matchesListSessionDir(summary, command.sessionDir)) + .sort((a, b) => Date.parse(b.modified ?? "") - Date.parse(a.modified ?? "")); return success(command.id, "list", { ...data, sessions: [...saved, ...active] }); } + // A session belongs to a sessions dir directly or through the dir's sibling session-artifacts tree. + private matchesListSessionDir(summary: SessionSummary, sessionDir: string | undefined): boolean { + if (sessionDir === undefined) return true; + if (!summary.sessionFile) return false; + const dir = resolve(sessionDir); + const file = resolve(summary.sessionFile); + return file.startsWith(`${dir}${sep}`) || file.startsWith(`${join(dirname(dir), "session-artifacts")}${sep}`); + } + private async handleSavedSessionList( client: DaemonSocketClient, command: Extract, @@ -2432,7 +2453,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`, ); @@ -2467,11 +2488,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 @@ -2739,7 +2766,6 @@ 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; @@ -2830,16 +2856,17 @@ export class DaemonSupervisor { try { await client.connect(Math.min(500, Math.max(50, deadline - Date.now()))); await client.waitForHello(1000); + // Listen before authenticating: the worker flushes its roster snapshot right after auth succeeds. + client.onFrame((frame) => this.handleWorkerFrame(worker, frame)); + client.onClose((error) => void this.handleWorkerClose(worker, client, error)); const authResponse = await client.authenticateWorker( worker.descriptor.authenticationToken, this.supervisorAuthenticationClaim(), 1000, ); await this.assertRecoveryAllowed(); - worker.rosterCapable = workerAuthAdvertisesRoster(authResponse.data); + worker.rosterCapable = worker.rosterCapable === true || workerAuthAdvertisesRoster(authResponse.data); worker.lastFrameAt = Date.now(); - client.onFrame((frame) => this.handleWorkerFrame(worker, frame)); - client.onClose((error) => void this.handleWorkerClose(worker, client, error)); worker.client?.close(); worker.client = client; return client; @@ -3489,13 +3516,7 @@ export class DaemonSupervisor { }); } - // --------------------------------------------------------------------- - // Agent roster ledger: the single supervisor-side projection every list - // and selector read is served from. Writes go through writeRosterEntry so - // status is classified exactly once, at write time. - // --------------------------------------------------------------------- - - /** Lazily created so long-lived test fixtures over the prototype get a roster too. */ + // The agent roster: the single supervisor-side projection every list and selector read is served from. private roster(): AgentRosterLedger { this.rosterStore ??= new AgentRosterLedger(canonicalSessionPath); return this.rosterStore; @@ -3529,8 +3550,7 @@ export class DaemonSupervisor { this.log(`Could not seed the agent roster from the session catalog: ${String(error)}`); } try { - // Ledger edges cover subagents whose transcripts live in artifact dirs - // the catalog scan never sees; tombstoned edges stay out. + // Ledger edges cover subagents in artifact dirs the catalog never scans; tombstones stay out. for (const edge of await this.rlmSpawnLedger().edges()) { if (this.roster().has(edge.childId)) continue; if (this.roster().hasSessionFile(canonicalSessionPath(edge.child))) continue; @@ -3575,6 +3595,15 @@ export class DaemonSupervisor { } if (delta.type !== "roster_delta" || !Array.isArray(delta.entries)) return; worker.rosterCapable = true; + if (delta.snapshot === true) { + // Snapshot replacement: absent rows with a durable transcript passivate, sessionless rows go. + const sent = new Set(delta.entries.map((entry) => entry.agentId)); + for (const entry of this.workerRosterEntries(worker)) { + if (sent.has(entry.agentId)) continue; + if (entry.summary.sessionFile) this.writeRosterEntry(passivatedWorkerRosterEntry(entry), worker); + else this.roster().delete(entry.agentId); + } + } for (const entry of delta.entries) { this.writeRosterEntry(entry, worker); this.syncRootDescriptorFromRosterEntry(worker, entry); @@ -3584,7 +3613,7 @@ export class DaemonSupervisor { } } - /** Keeps the durable root pointers fresh now that event-driven summary refreshes are gone. */ + /** Root roster deltas maintain the persisted descriptor pointers (rootSessionId, sessionFile). */ private syncRootDescriptorFromRosterEntry(worker: ResidentWorker, entry: WorkerRosterEntry): void { const summary = entry.summary; if (summary.activeSessionId !== worker.descriptor.rootActiveSessionId) return; @@ -3963,7 +3992,8 @@ 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); + if (worker.rosterCapable !== true) await this.refreshWorkerSummaries(worker); return { ...response, id: command.id, data: this.publicSummary(worker, response.data) }; } return responseWithId(response, command.id); @@ -4876,7 +4906,7 @@ export class DaemonSupervisor { sessionEventType === "turn_end" || sessionEventType === "rlm_child_update") ) { - // Legacy workers predate roster deltas; refreshing keeps their ledger rows fresh until they are replaced. + // A legacy worker sends no roster deltas; refreshing keeps its ledger rows fresh. void this.refreshWorkerSummaries(worker).catch(() => undefined); } if ( 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 78c1300073..cd8587baeb 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts @@ -16,10 +16,9 @@ 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; never forwarded to TUI clients, so they -// live outside the client-facing DaemonOutbound schema. +// Worker->supervisor roster frames live outside the client-facing DaemonOutbound schema. export type DaemonWorkerRosterOutbound = - | { type: "roster_delta"; entries: WorkerRosterEntry[]; removedAgentIds?: string[] } + | { 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. */ diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index dcf2ac01b6..d2a5b5aace 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -1,19 +1,26 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { PassThrough } from "node:stream"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { ActiveSessionState } from "../src/modes/daemon/active-session-state.js"; +import { SessionManager } from "../src/core/session-manager.js"; +import type { ActiveSessionState, DaemonSocketClient } from "../src/modes/daemon/active-session-state.js"; import { type AgentRosterEntry, + sessionSummaryFromRosterEntry, 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 { + type DaemonWorkerRosterOutbound, + isDaemonWorkerFrameHeader, +} from "../src/modes/daemon/daemon-worker-protocol.js"; import { RlmSpawnLedger } from "../src/modes/daemon/rlm-ledger.js"; +import { PrivateFrameDecoder } from "../src/modes/session-worker/private-framing.js"; type RosterDelta = Extract; @@ -32,24 +39,34 @@ interface WorkerReporterFixture { sessions: Map; observeRosterEvent(state: ActiveSessionState, message: unknown): void; flushRoster(): void; - rosterRemovedAgentIds: Set; + rosterReporterState: { + lastSent: Map; + queuedChildren: Map; + removedAgentIds: Set; + snapshotPending: boolean; + }; }; sentDeltas: RosterDelta[]; } -function makeWorkerReporter(): WorkerReporterFixture { +function makeWorkerReporter(connected = true): WorkerReporterFixture { const sentDeltas: RosterDelta[] = []; const daemon = Object.assign(Object.create(AgentDaemon.prototype), { options: { worker: { authenticationToken: "token" } }, sessions: new Map(), cronStore: { list: () => [] }, - rosterLastSent: new Map(), - rosterQueuedChildren: new Map(), - rosterRemovedAgentIds: new Set(), + rosterReporterState: { + lastSent: new Map(), + queuedChildren: new Map(), + removedAgentIds: new Set(), + snapshotPending: false, + }, rosterFlushScheduled: false, shuttingDown: false, + hasAuthenticatedSupervisorClient: () => connected, broadcastRosterFrame: (message: DaemonWorkerRosterOutbound) => { if (message.type === "roster_delta") sentDeltas.push(message); + return connected; }, log: vi.fn(), }) as WorkerReporterFixture["daemon"]; @@ -155,6 +172,36 @@ describe("worker roster reporter", () => { expect(merged[0]?.queuedChild).toBeUndefined(); }); + it("keeps a superseded child run out of the roster after its session closes", () => { + 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: "task", status: "queued", sessionDir: "/tmp/c" }), + ); + const childState = makeState({ + activeSessionId: "child-active", + kind: "subagent", + rlmChildId: "child-1", + messages: [{ role: "user", content: "hi" } as unknown as AgentMessage], + }); + daemon.sessions.set(childState.activeSessionId, childState); + daemon.observeRosterEvent( + parent, + childUpdate(parent, { id: "child-1", label: "task", status: "running", activeSessionId: "child-active" }), + ); + daemon.flushRoster(); + + daemon.sessions.delete(childState.activeSessionId); + daemon.flushRoster(); + + const final = sentDeltas.at(-1)?.entries.find((entry) => entry.agentId === "child-1"); + expect(final?.queuedChild).toBeUndefined(); + expect(final?.summary.activeSessionId).toBeUndefined(); + expect(final?.summary.id).toBe("session-child-active"); + }); + it("sends deltas only on change and flips closed sessions to non-resident instead of dropping them", () => { const { daemon, sentDeltas } = makeWorkerReporter(); const state = makeState({ @@ -175,18 +222,40 @@ describe("worker roster reporter", () => { expect(sentDeltas.at(-1)?.removedAgentIds).toBeUndefined(); }); - it("removes discarded and deleted agents explicitly", () => { - const { daemon, sentDeltas } = makeWorkerReporter(); - const draft = makeState({ activeSessionId: "draft-active" }); - daemon.sessions.set(draft.activeSessionId, draft); + it("retains pending state until a frame reaches an authenticated supervisor", () => { + const { daemon, sentDeltas } = makeWorkerReporter(false); + const state = makeState({ activeSessionId: "root-active" }); + daemon.sessions.set(state.activeSessionId, state); + daemon.rosterReporterState.removedAgentIds.add("gone-agent"); + daemon.flushRoster(); - daemon.sessions.delete(draft.activeSessionId); - daemon.rosterRemovedAgentIds.add("session-draft-active"); + expect(sentDeltas).toHaveLength(0); + expect(daemon.rosterReporterState.lastSent.size).toBe(0); + expect(daemon.rosterReporterState.removedAgentIds.has("gone-agent")).toBe(true); + }); + + it("sends a replacing snapshot after supervisor (re)authentication and drops pending removals", () => { + const { daemon, sentDeltas } = makeWorkerReporter(); + const state = makeState({ + activeSessionId: "root-active", + messages: [{ role: "user", content: "hi" } as unknown as AgentMessage], + }); + daemon.sessions.set(state.activeSessionId, state); + daemon.rosterReporterState.removedAgentIds.add("stale-agent"); + daemon.rosterReporterState.snapshotPending = true; + daemon.flushRoster(); - expect(sentDeltas.at(-1)?.removedAgentIds).toEqual(["session-draft-active"]); - expect(sentDeltas.at(-1)?.entries).toHaveLength(0); + expect(sentDeltas).toHaveLength(1); + expect(sentDeltas[0]?.snapshot).toBe(true); + expect(sentDeltas[0]?.removedAgentIds).toBeUndefined(); + expect(sentDeltas[0]?.entries.map((entry) => entry.agentId)).toEqual(["session-root-active"]); + expect(daemon.rosterReporterState.snapshotPending).toBe(false); + expect(daemon.rosterReporterState.removedAgentIds.size).toBe(0); + + daemon.flushRoster(); + expect(sentDeltas).toHaveLength(1); }); }); @@ -257,6 +326,11 @@ interface SupervisorFixture { workerRosterEntries(worker: WorkerFixture): AgentRosterEntry[]; flipWorkerRosterEntriesInactive(worker: WorkerFixture): void; seedRosterLedger(): Promise; + roster(): { + get(agentId: string): AgentRosterEntry | undefined; + has(agentId: string): boolean; + values(): IterableIterator; + }; refreshWorkerSummaries: ReturnType; } @@ -276,14 +350,19 @@ function makeSupervisor(workers: WorkerFixture[], extra: Record }) as SupervisorFixture; } -function rosterDelta(entries: WorkerRosterEntry[], removedAgentIds?: string[]): Buffer { +function rosterDelta(entries: WorkerRosterEntry[], removedAgentIds?: string[], snapshot?: true): Buffer { return Buffer.from( - JSON.stringify({ type: "roster_delta", entries, ...(removedAgentIds ? { removedAgentIds } : {}) }), + JSON.stringify({ + type: "roster_delta", + entries, + ...(removedAgentIds ? { removedAgentIds } : {}), + ...(snapshot ? { snapshot } : {}), + }), ); } describe("supervisor roster ledger", () => { - it("classifies at write, labels queued children, and merges them into their session row", () => { + it("keeps queued child rows ledger-internal and lists them once their session materializes", () => { const worker = makeWorker("worker-1"); const supervisor = makeSupervisor([worker]); @@ -303,8 +382,8 @@ describe("supervisor roster ledger", () => { ]), ); - let listed = supervisor.handleList({}, { type: "list" }); - expect(listed.data?.sessions.map((session) => session.sessionId)).toEqual(["child-1"]); + expect(supervisor.handleList({}, { type: "list" }).data?.sessions).toEqual([]); + expect(supervisor.handleList({}, { type: "list", all: true }).data?.sessions).toEqual([]); expect(supervisor.workerRosterEntries(worker)[0]).toMatchObject({ status: "running", statusLabel: "queued" }); supervisor.consumeWorkerRosterDelta( @@ -324,13 +403,44 @@ describe("supervisor roster ledger", () => { ]), ); - listed = supervisor.handleList({}, { type: "list" }); + const listed = supervisor.handleList({}, { type: "list" }); expect(listed.data?.sessions).toHaveLength(1); expect(listed.data?.sessions[0]).toMatchObject({ activeSessionId: "child-active", workerState: "ready" }); expect(supervisor.workerRosterEntries(worker)[0]).toMatchObject({ status: "running" }); expect(supervisor.workerRosterEntries(worker)[0]?.statusLabel).toBeUndefined(); }); + it("keeps passivated children of a live worker out of the resident list but in list all", () => { + const worker = makeWorker("worker-1"); + const supervisor = makeSupervisor([worker]); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ id: "root-active", sessionId: "root", activeSessionId: "root-active" }), + ), + worker, + ); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "child-session", + sessionId: "child-session", + sessionFile: "/tmp/artifacts/child.jsonl", + runtimeKind: "subagent", + rlmChildId: "child-1", + }), + ), + worker, + ); + + const resident = supervisor.handleList({}, { type: "list" }); + expect(resident.data?.sessions.map((session) => session.sessionId)).toEqual(["root"]); + + const all = supervisor.handleList({}, { type: "list", all: true }); + const child = all.data?.sessions.find((session) => session.rlmChildId === "child-1"); + expect(child).toMatchObject({ workerPid: 1234 }); + expect(child?.activeSessionId).toBeUndefined(); + }); + it("serves list from the ledger with zero worker round-trips and exact busy counts", () => { const visible = makeWorker("visible"); const owned = makeWorker("owned", { @@ -367,6 +477,72 @@ describe("supervisor roster ledger", () => { expect(supervisor.refreshWorkerSummaries).not.toHaveBeenCalled(); }); + it("replaces a worker's rows from a snapshot frame", () => { + const worker = makeWorker("worker-1"); + const supervisor = makeSupervisor([worker]); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "kept-active", + sessionId: "kept", + activeSessionId: "kept-active", + sessionFile: "/tmp/kept.jsonl", + }), + ), + worker, + ); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "gone-active", + sessionId: "gone", + activeSessionId: "gone-active", + sessionFile: "/tmp/gone.jsonl", + }), + ), + worker, + ); + supervisor.consumeWorkerRosterDelta( + worker, + rosterDelta( + [ + { + agentId: "sessionless", + queuedChild: true, + summary: summary({ id: "sessionless", sessionId: "sessionless", runtimeKind: "subagent" }), + }, + ], + undefined, + ), + ); + + supervisor.consumeWorkerRosterDelta( + worker, + rosterDelta( + [ + workerRosterEntryFromSummary( + summary({ + id: "kept-active", + sessionId: "kept", + activeSessionId: "kept-active", + sessionFile: "/tmp/kept.jsonl", + isSessionActive: true, + }), + ), + ], + undefined, + true, + ), + ); + + expect(supervisor.roster().get("kept")).toMatchObject({ status: "running" }); + // Absent rows with a transcript passivate; the sessionless queued row vanishes with its run. + const gone = supervisor.roster().get("gone"); + expect(gone?.summary.activeSessionId).toBeUndefined(); + expect(gone?.status).toBe("inactive"); + expect(supervisor.roster().has("sessionless")).toBe(false); + }); + it("marks a dead worker's rows recovering natively on socket close", async () => { const worker = makeWorker("worker-1"); const supervisor = makeSupervisor([worker]); @@ -489,4 +665,143 @@ describe("supervisor roster ledger", () => { expect(evicted?.activeSessionId).toBeUndefined(); expect(afterEvict.data?.sessions.some((session) => session.sessionId === "deleted-child")).toBe(false); }); + + it("updates the roster on offline saved-session renames", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-offline-rename-")); + tempDirs.push(directory); + const sessionPath = join(directory, "saved.jsonl"); + const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory }, + descriptorDir: join(directory, "workers"), + }) as unknown as SupervisorFixture & { handleCommand(client: object, command: object): Promise }; + 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( + { id: "client", attachedActiveSessionIds: new Set() }, + { type: "rename_saved_session", sessionPath, name: "new-name" }, + ); + + expect(supervisor.roster().get("saved-1")?.summary.sessionName).toBe("new-name"); + }); + + it("removes the roster row on offline saved-session deletes", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-offline-delete-")); + tempDirs.push(directory); + const sessionPath = join(directory, "saved.jsonl"); + const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory }, + descriptorDir: join(directory, "workers"), + }) as unknown as SupervisorFixture & { handleCommand(client: object, command: object): Promise }; + Object.assign(supervisor, { + catalog: { delete: vi.fn(async () => ({ deleted: true })) }, + }); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary(summary({ id: "saved-1", sessionId: "saved-1", sessionFile: sessionPath })), + ); + + await supervisor.handleCommand( + { id: "client", attachedActiveSessionIds: new Set() }, + { type: "delete_saved_session", sessionPath }, + ); + + expect(supervisor.roster().has("saved-1")).toBe(false); + }); +}); + +describe("worker saved-session deletion reaches the supervisor roster", () => { + it("removes the deleted session's ledger row end-to-end", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-worker-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(); + const sessionId = manager.getSessionId(); + if (!sessionPath) throw new Error("Fixture session did not persist"); + + const daemon = new AgentDaemon(join(directory, "worker.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory, sessionDir: sessionsDir }, + worker: { + authenticationToken: "token", + workerId: "worker-1", + rootActiveSessionId: "root-active", + } as never, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + } as never); + const socket = new PassThrough(); + const written: Buffer[] = []; + socket.on("data", (chunk: Buffer) => written.push(Buffer.from(chunk))); + const supervisorClient = { + id: "supervisor", + socket, + transport: "private-framed", + authenticated: true, + attachedActiveSessionIds: new Set(), + detachInput: () => {}, + supportsExtensionUi: false, + capabilities: new Set(), + } as unknown as DaemonSocketClient; + const internals = daemon as unknown as { + clients: Set; + handleCommand(client: DaemonSocketClient, command: object): Promise; + flushRoster(): void; + }; + internals.clients.add(supervisorClient); + + await internals.handleCommand(supervisorClient, { type: "delete_saved_session", sessionPath }); + internals.flushRoster(); + + const decoder = new PrivateFrameDecoder(isDaemonWorkerFrameHeader); + const frames = decoder.push(Buffer.concat(written)); + const deltaFrame = frames.find( + (frame) => frame.header.kind === "outbound" && frame.header.outboundType === "roster_delta", + ); + if (!deltaFrame) throw new Error("Worker did not publish a roster delta"); + const delta = JSON.parse(deltaFrame.payload.toString("utf8")) as RosterDelta; + expect(delta.removedAgentIds).toEqual([sessionId]); + + const worker = makeWorker("worker-1", { rosterCapable: true }); + const supervisor = makeSupervisor([worker]); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary(summary({ id: sessionId, sessionId, sessionFile: sessionPath })), + worker, + ); + supervisor.consumeWorkerRosterDelta(worker, deltaFrame.payload); + expect(supervisor.roster().has(sessionId)).toBe(false); + }); +}); + +describe("roster entry projection", () => { + it("carries modelFallbackMessage through the roster round-trip", () => { + const source = summary({ + id: "m-active", + sessionId: "m", + activeSessionId: "m-active", + modelFallbackMessage: "No models available", + }); + const roundTripped = sessionSummaryFromRosterEntry(workerRosterEntryFromSummary(source)); + expect(roundTripped.modelFallbackMessage).toBe("No models available"); + }); }); diff --git a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts index 466a52a746..2807a30fe5 100644 --- a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts @@ -333,8 +333,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, { diff --git a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts index 7417195b36..2db8869b18 100644 --- a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts @@ -486,6 +486,7 @@ describe("daemon worker supervisor monitoring", () => { const daemon = Object.assign(Object.create(AgentDaemon.prototype), { options: { worker: { authenticationToken: "token" } }, supervisorClaims: new Map(), + clients: new Set(), shuttingDown: false, clearSupervisorAvailabilityCheck: vi.fn(), scheduleSupervisorFenceCheck: vi.fn(), @@ -1729,6 +1730,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); @@ -1750,6 +1752,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]]), @@ -1793,6 +1796,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(); diff --git a/packages/coding-agent/test/daemon-supervisor-process.test.ts b/packages/coding-agent/test/daemon-supervisor-process.test.ts index 5d973738df..0b4b227797 100644 --- a/packages/coding-agent/test/daemon-supervisor-process.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-process.test.ts @@ -400,11 +400,17 @@ describe("daemon supervisor resident workers", () => { if (!parentSummary.workerPid) throw new Error("Parent worker did not expose its pid"); workerPids.add(parentSummary.workerPid); - const beforeAttach = await client.request({ type: "list" }); + const beforeAttach = await client.request({ type: "list", all: true }); expect(beforeAttach.success).toBe(true); const passiveSummary = requireSessionList(beforeAttach.success ? beforeAttach.data : undefined).find( (summary) => summary.sessionFile === child.sessionFile, ); + const beforeAttachResident = await client.request({ type: "list" }); + expect( + requireSessionList(beforeAttachResident.success ? beforeAttachResident.data : undefined).every( + (summary) => summary.activeSessionId !== undefined, + ), + ).toBe(true); expect(passiveSummary).toMatchObject({ sessionId: child.manager.getSessionId(), sessionName: "passive-child-worker", From 1f5c3d2482728644bfdcfe8b74c068016b5e74c6 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 29 Aug 2026 15:12:14 +0200 Subject: [PATCH 03/48] fix(coding-agent): roster review fixes round two - Non-all list restores the pre-roster population exactly: worker-owned rows (materialized and passivated) stay listed; sessionless queued-child rows are served by no list form; seeded/offline rows remain all-only. - The reauth snapshot is the worker's complete roster: composition always runs (delivery-gated separately), passivated rows persist in a lastComposed map independent of delivery, and pending removedAgentIds ride the snapshot frame so deletions survive a disconnect; the supervisor applies removals after replacement. - Queued-run supersession has one mechanism at the queued-entry lifecycle: observeRosterChildUpdate deletes the queued row when the child's session is bound and its write guard rejects late queued updates for bound children; roster composition order carries no semantics (verified by insertion-order reversal). - list-all sessionDir scoping matches artifact-dir children through their owning root's sessions dir instead of the shared sibling artifacts tree, so sibling session dirs no longer leak each other's subagents. - Worker roster reporter state is a plain field initializer again; the prototype-based worker_auth fixture constructs the state it needs. ENG-5794 --- .../src/modes/daemon/daemon-mode.ts | 87 +++++---- .../src/modes/daemon/daemon-supervisor.ts | 24 +-- .../test/daemon-agent-roster.test.ts | 183 ++++++++++++++++-- .../test/daemon-supervisor-monitor.test.ts | 9 + .../test/daemon-supervisor-process.test.ts | 8 +- 5 files changed, 236 insertions(+), 75 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index f87c0d8192..d7451a5c12 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -544,7 +544,13 @@ export class AgentDaemon { }, ); private readonly recoveryJournal?: WorkerRecoveryJournal; - private rosterReporterState?: WorkerRosterReporterState; + private readonly rosterReporter: WorkerRosterReporterState = { + lastSent: new Map(), + lastComposed: new Map(), + queuedChildren: new Map(), + removedAgentIds: new Set(), + snapshotPending: false, + }; private rosterFlushScheduled = false; private rosterHeartbeatTimer?: ReturnType; private rlmSpawnLedgerInstance?: RlmSpawnLedger; @@ -1119,7 +1125,7 @@ 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 }); - this.rosterReporter().removedAgentIds.add(childId); + this.rosterReporter.removedAgentIds.add(childId); this.scheduleRosterFlush(); // Deletion boundary: transcript + display tombstone are the durable // record and stay; the nested artifact dir is a runtime cache and goes. @@ -3346,7 +3352,7 @@ export class AgentDaemon { data: { capabilities: [DAEMON_WORKER_ROSTER_CAPABILITY] }, }); // A (re)connected supervisor has no delta history; the next flush sends a replacing snapshot. - this.rosterReporter().snapshotPending = true; + this.rosterReporter.snapshotPending = true; this.scheduleRosterFlush(); return; } @@ -3902,7 +3908,7 @@ export class AgentDaemon { }, }); if (deletedInfo) { - this.rosterReporter().removedAgentIds.add(deletedInfo.id); + this.rosterReporter.removedAgentIds.add(deletedInfo.id); this.scheduleRosterFlush(); } return success(command.id, "delete_saved_session", result); @@ -6331,7 +6337,7 @@ export class AgentDaemon { this.acpMcpOwners.delete(state.activeSessionId); this.sessions.delete(state.activeSessionId); // A discarded draft leaves no transcript, so its roster row goes with it. - if (isEmptyDraftSession) this.rosterReporter().removedAgentIds.add(this.rosterAgentIdForState(state)); + if (isEmptyDraftSession) this.rosterReporter.removedAgentIds.add(this.rosterAgentIdForState(state)); this.scheduleRosterFlush(); if (isEmptyDraftSession) { const sessionFile = state.runtime.session.sessionFile; @@ -6518,16 +6524,6 @@ export class AgentDaemon { } } - private rosterReporter(): WorkerRosterReporterState { - this.rosterReporterState ??= { - lastSent: new Map(), - queuedChildren: new Map(), - removedAgentIds: new Set(), - snapshotPending: false, - }; - return this.rosterReporterState; - } - private rosterAgentIdForState(state: ActiveSessionState): string { const session = state.runtime.session; const metadata = state.runtime.metadata; @@ -6553,14 +6549,23 @@ export class AgentDaemon { } private observeRosterChildUpdate(state: ActiveSessionState, child: AgentConnectionRlmChildAgentSnapshot): void { - if (child.activeSessionId === undefined && (child.status === "queued" || child.status === "running")) { - this.rosterReporter().queuedChildren.set(child.id, this.queuedChildRosterEntry(state, child)); + // The one supersession point: a run with a bound session never has a queued row. + const bound = child.activeSessionId !== undefined || this.hasSessionForRlmChild(child.id); + if (!bound && (child.status === "queued" || child.status === "running")) { + this.rosterReporter.queuedChildren.set(child.id, this.queuedChildRosterEntry(state, child)); } else { - this.rosterReporter().queuedChildren.delete(child.id); + this.rosterReporter.queuedChildren.delete(child.id); } this.scheduleRosterFlush(); } + private hasSessionForRlmChild(childId: string): boolean { + for (const candidate of this.sessions.values()) { + if (candidate.runtime.metadata.rlmChildId === childId) return true; + } + return false; + } + private queuedChildRosterEntry( state: ActiveSessionState, child: AgentConnectionRlmChildAgentSnapshot, @@ -6606,40 +6611,46 @@ export class AgentDaemon { } private flushRoster(): void { - // Without a connected supervisor, pending state stays queued; auth triggers a replacing snapshot. - if (!this.hasAuthenticatedSupervisorClient()) return; - const reporter = this.rosterReporter(); + const reporter = this.rosterReporter; const entries = new Map(); - for (const [childId, queued] of reporter.queuedChildren) { - entries.set(childId, queued); - } - // A materialized session row supersedes its queued-run row: same agentId, later insertion wins. for (const summary of buildSessionList([...this.sessions.values()], [], this.cronStore.list())) { const entry = workerRosterEntryFromSummary(summary); entries.set(entry.agentId, entry); } - const removedAgentIds = [...reporter.removedAgentIds]; - for (const agentId of removedAgentIds) { + // Disjoint from session rows by the observeRosterChildUpdate lifecycle guard; insertion order is free. + for (const [childId, queued] of reporter.queuedChildren) { + entries.set(childId, queued); + } + for (const agentId of reporter.removedAgentIds) { entries.delete(agentId); reporter.queuedChildren.delete(agentId); } - if (reporter.snapshotPending) { - if (!this.broadcastRosterFrame({ type: "roster_delta", snapshot: true, entries: [...entries.values()] })) { - return; + // Rows whose runtime left memory flip to passivated and stay known, delivered or not. + for (const [agentId, previous] of reporter.lastComposed) { + if (!entries.has(agentId) && !reporter.removedAgentIds.has(agentId)) { + entries.set(agentId, passivatedWorkerRosterEntry(previous)); } + } + reporter.lastComposed = new Map(entries); + if (!this.hasAuthenticatedSupervisorClient()) return; + const removedAgentIds = [...reporter.removedAgentIds]; + if (reporter.snapshotPending) { + const delivered = this.broadcastRosterFrame({ + type: "roster_delta", + snapshot: true, + entries: [...entries.values()], + ...(removedAgentIds.length > 0 ? { removedAgentIds } : {}), + }); + if (!delivered) return; reporter.snapshotPending = false; - reporter.removedAgentIds.clear(); reporter.lastSent.clear(); for (const [agentId, entry] of entries) { reporter.lastSent.set(agentId, { json: JSON.stringify(entry), entry }); } - return; - } - for (const [agentId, previous] of reporter.lastSent) { - // The runtime left memory (close or passivation): flip the row, never drop it. - if (!entries.has(agentId) && !reporter.removedAgentIds.has(agentId)) { - entries.set(agentId, passivatedWorkerRosterEntry(previous.entry)); + for (const agentId of removedAgentIds) { + reporter.removedAgentIds.delete(agentId); } + return; } const changed: Array<{ agentId: string; json: string; entry: WorkerRosterEntry }> = []; for (const [agentId, entry] of entries) { @@ -7065,6 +7076,8 @@ const ROSTER_HEARTBEAT_INTERVAL_MS = 15_000; interface WorkerRosterReporterState { /** Last entry sent per agentId; deltas go out only on change. */ lastSent: Map; + /** Last composed roster, delivered or not; the source for passivated flips. */ + lastComposed: Map; /** Admitted child runs whose sessions have not materialized yet, keyed by childId. */ queuedChildren: Map; removedAgentIds: Set; diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 034e1e9920..deab5bffaf 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, sep } from "node:path"; +import { 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"; @@ -2265,14 +2265,11 @@ export class DaemonSupervisor { const inactive: SessionSummary[] = []; let busyClientOwnedSessionCount = 0; for (const entry of this.roster().values()) { - // Sessionless queued-child rows stay ledger-internal until a push protocol can carry their label. + // Sessionless queued-child rows are ledger-internal; no list form serves them. if (entry.queuedChild) continue; const worker = entry.workerId !== undefined ? this.workers.get(entry.workerId) : undefined; - if (worker === undefined || entry.summary.activeSessionId === undefined) { - if (command.all) { - const base = sessionSummaryFromRosterEntry(entry); - inactive.push(worker ? this.publicSummary(worker, base) : base); - } + if (worker === undefined) { + if (command.all) inactive.push(sessionSummaryFromRosterEntry(entry)); continue; } const summary = this.publicSummary(worker, sessionSummaryFromRosterEntry(entry)); @@ -2301,13 +2298,18 @@ export class DaemonSupervisor { return success(command.id, "list", { ...data, sessions: [...saved, ...active] }); } - // A session belongs to a sessions dir directly or through the dir's sibling session-artifacts tree. + // An artifact-dir child belongs to the sessions dir of its owning root session. private matchesListSessionDir(summary: SessionSummary, sessionDir: string | undefined): boolean { if (sessionDir === undefined) return true; if (!summary.sessionFile) return false; - const dir = resolve(sessionDir); - const file = resolve(summary.sessionFile); - return file.startsWith(`${dir}${sep}`) || file.startsWith(`${join(dirname(dir), "session-artifacts")}${sep}`); + let file = resolve(summary.sessionFile); + let parentSessionPath = summary.parentSessionPath; + for (let hops = 0; parentSessionPath !== undefined && hops < 32; hops++) { + file = resolve(parentSessionPath); + parentSessionPath = this.roster().bySessionFile(canonicalSessionPath(parentSessionPath))?.summary + .parentSessionPath; + } + return dirname(file) === resolve(sessionDir); } private async handleSavedSessionList( diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index d2a5b5aace..a8cfd37e46 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -39,38 +39,42 @@ interface WorkerReporterFixture { sessions: Map; observeRosterEvent(state: ActiveSessionState, message: unknown): void; flushRoster(): void; - rosterReporterState: { + rosterReporter: { lastSent: Map; + lastComposed: Map; queuedChildren: Map; removedAgentIds: Set; 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: () => [] }, - rosterReporterState: { + rosterReporter: { lastSent: new Map(), + lastComposed: new Map(), queuedChildren: new Map(), removedAgentIds: new Set(), snapshotPending: false, }, rosterFlushScheduled: false, shuttingDown: false, - hasAuthenticatedSupervisorClient: () => connected, + hasAuthenticatedSupervisorClient: () => connection.connected, broadcastRosterFrame: (message: DaemonWorkerRosterOutbound) => { if (message.type === "roster_delta") sentDeltas.push(message); - return connected; + return connection.connected; }, log: vi.fn(), }) as WorkerReporterFixture["daemon"]; - return { daemon, sentDeltas }; + return { daemon, sentDeltas, connection }; } function makeState(options: { @@ -226,37 +230,101 @@ describe("worker roster reporter", () => { const { daemon, sentDeltas } = makeWorkerReporter(false); const state = makeState({ activeSessionId: "root-active" }); daemon.sessions.set(state.activeSessionId, state); - daemon.rosterReporterState.removedAgentIds.add("gone-agent"); + daemon.rosterReporter.removedAgentIds.add("gone-agent"); daemon.flushRoster(); expect(sentDeltas).toHaveLength(0); - expect(daemon.rosterReporterState.lastSent.size).toBe(0); - expect(daemon.rosterReporterState.removedAgentIds.has("gone-agent")).toBe(true); + expect(daemon.rosterReporter.lastSent.size).toBe(0); + expect(daemon.rosterReporter.removedAgentIds.has("gone-agent")).toBe(true); }); - it("sends a replacing snapshot after supervisor (re)authentication and drops pending removals", () => { + it("sends a replacing snapshot after supervisor (re)authentication that carries pending removals", () => { const { daemon, sentDeltas } = makeWorkerReporter(); const state = makeState({ activeSessionId: "root-active", messages: [{ role: "user", content: "hi" } as unknown as AgentMessage], }); daemon.sessions.set(state.activeSessionId, state); - daemon.rosterReporterState.removedAgentIds.add("stale-agent"); - daemon.rosterReporterState.snapshotPending = true; + daemon.rosterReporter.removedAgentIds.add("deleted-agent"); + daemon.rosterReporter.snapshotPending = true; daemon.flushRoster(); expect(sentDeltas).toHaveLength(1); expect(sentDeltas[0]?.snapshot).toBe(true); - expect(sentDeltas[0]?.removedAgentIds).toBeUndefined(); + expect(sentDeltas[0]?.removedAgentIds).toEqual(["deleted-agent"]); expect(sentDeltas[0]?.entries.map((entry) => entry.agentId)).toEqual(["session-root-active"]); - expect(daemon.rosterReporterState.snapshotPending).toBe(false); - expect(daemon.rosterReporterState.removedAgentIds.size).toBe(0); + expect(daemon.rosterReporter.snapshotPending).toBe(false); + expect(daemon.rosterReporter.removedAgentIds.size).toBe(0); daemon.flushRoster(); expect(sentDeltas).toHaveLength(1); }); + + it("keeps a session that lived and died while disconnected as a durable row in the reauth snapshot", () => { + const { daemon, sentDeltas, connection } = makeWorkerReporter(); + const parent = makeState({ activeSessionId: "parent-active" }); + daemon.sessions.set(parent.activeSessionId, parent); + daemon.observeRosterEvent( + parent, + childUpdate(parent, { id: "child-1", label: "task", status: "queued", sessionDir: "/tmp/c" }), + ); + daemon.flushRoster(); + + connection.connected = false; + const childState = makeState({ + activeSessionId: "child-active", + kind: "subagent", + rlmChildId: "child-1", + messages: [{ role: "user", content: "hi" } as unknown as AgentMessage], + }); + daemon.sessions.set(childState.activeSessionId, childState); + daemon.observeRosterEvent( + parent, + childUpdate(parent, { id: "child-1", label: "task", status: "running", activeSessionId: "child-active" }), + ); + daemon.flushRoster(); + daemon.sessions.delete(childState.activeSessionId); + daemon.flushRoster(); + + connection.connected = true; + daemon.rosterReporter.snapshotPending = true; + daemon.flushRoster(); + + const snapshot = sentDeltas.at(-1); + expect(snapshot?.snapshot).toBe(true); + const childRow = snapshot?.entries.find((entry) => entry.agentId === "child-1"); + expect(childRow?.queuedChild).toBeUndefined(); + expect(childRow?.summary.id).toBe("session-child-active"); + expect(childRow?.summary.activeSessionId).toBeUndefined(); + }); + + it("ignores a late queued update for a child whose session is already bound", () => { + const { daemon, sentDeltas } = makeWorkerReporter(); + const parent = makeState({ activeSessionId: "parent-active" }); + daemon.sessions.set(parent.activeSessionId, parent); + const childState = makeState({ + activeSessionId: "child-active", + kind: "subagent", + rlmChildId: "child-1", + messages: [{ role: "user", content: "hi" } as unknown as AgentMessage], + }); + daemon.sessions.set(childState.activeSessionId, childState); + daemon.flushRoster(); + + // Crafted without activeSessionId: the lifecycle guard, not event stamping, must reject it. + daemon.observeRosterEvent( + parent, + childUpdate(parent, { id: "child-1", label: "task", status: "queued", sessionDir: "/tmp/c" }), + ); + daemon.sessions.delete(childState.activeSessionId); + daemon.flushRoster(); + + const final = sentDeltas.at(-1)?.entries.find((entry) => entry.agentId === "child-1"); + expect(final?.queuedChild).toBeUndefined(); + expect(final?.summary.id).toBe("session-child-active"); + }); }); // ------------------------------------------------------------------ @@ -317,7 +385,7 @@ interface SupervisorFixture { consumeWorkerRosterDelta(worker: WorkerFixture, payload: Buffer): void; handleList( client: object, - command: { id?: string; type: "list"; all?: boolean; includeClientOwned?: boolean }, + 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; @@ -410,7 +478,7 @@ describe("supervisor roster ledger", () => { expect(supervisor.workerRosterEntries(worker)[0]?.statusLabel).toBeUndefined(); }); - it("keeps passivated children of a live worker out of the resident list but in list all", () => { + it("keeps passivated children of a live worker in the resident list, seeded rows in list all only", () => { const worker = makeWorker("worker-1"); const supervisor = makeSupervisor([worker]); supervisor.writeRosterEntry( @@ -431,14 +499,22 @@ describe("supervisor roster ledger", () => { ), worker, ); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary(summary({ id: "seeded", sessionId: "seeded", sessionFile: "/tmp/seeded.jsonl" })), + ); const resident = supervisor.handleList({}, { type: "list" }); - expect(resident.data?.sessions.map((session) => session.sessionId)).toEqual(["root"]); - - const all = supervisor.handleList({}, { type: "list", all: true }); - const child = all.data?.sessions.find((session) => session.rlmChildId === "child-1"); + expect(resident.data?.sessions.map((session) => session.sessionId).sort()).toEqual(["child-session", "root"]); + const child = resident.data?.sessions.find((session) => session.rlmChildId === "child-1"); expect(child).toMatchObject({ workerPid: 1234 }); expect(child?.activeSessionId).toBeUndefined(); + + const all = supervisor.handleList({}, { type: "list", all: true }); + expect(all.data?.sessions.map((session) => session.sessionId).sort()).toEqual([ + "child-session", + "root", + "seeded", + ]); }); it("serves list from the ledger with zero worker round-trips and exact busy counts", () => { @@ -543,6 +619,22 @@ describe("supervisor roster ledger", () => { expect(supervisor.roster().has("sessionless")).toBe(false); }); + it("applies snapshot removals after replacement so deletions survive a disconnect", () => { + const worker = makeWorker("worker-1"); + const supervisor = makeSupervisor([worker]); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ id: "deleted", sessionId: "deleted", sessionFile: "/tmp/deleted.jsonl" }), + ), + worker, + ); + + supervisor.consumeWorkerRosterDelta(worker, rosterDelta([], ["deleted"], true)); + + // Without the removal, the absent-with-transcript rule would revive the row as a ghost. + expect(supervisor.roster().has("deleted")).toBe(false); + }); + it("marks a dead worker's rows recovering natively on socket close", async () => { const worker = makeWorker("worker-1"); const supervisor = makeSupervisor([worker]); @@ -646,6 +738,7 @@ describe("supervisor roster ledger", () => { 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(supervisor.handleList({}, { type: "list" }).data?.sessions).toEqual([]); // An evicted worker leaves its rows behind as inactive instead of dropping them. const worker = makeWorker("worker-1"); @@ -666,6 +759,56 @@ describe("supervisor roster ledger", () => { expect(afterEvict.data?.sessions.some((session) => session.sessionId === "deleted-child")).toBe(false); }); + it("scopes list all by sessions dir through owning topology, not the shared artifacts tree", () => { + const supervisor = makeSupervisor([]); + const base = "/tmp/agent-homes"; + const dirA = join(base, "a", "sessions"); + const dirB = join(base, "b", "sessions"); + const rootA = join(dirA, "root-a.jsonl"); + const rootB = join(dirB, "root-b.jsonl"); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary(summary({ id: "root-a", sessionId: "root-a", sessionFile: rootA })), + ); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary(summary({ id: "root-b", sessionId: "root-b", sessionFile: rootB })), + ); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "child-a", + sessionId: "child-a", + sessionFile: join(base, "a", "session-artifacts", "root-a", "child-a.jsonl"), + parentSessionPath: rootA, + runtimeKind: "subagent", + rlmChildId: "child-a", + rlmDepth: 1, + }), + ), + ); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "child-b", + sessionId: "child-b", + sessionFile: join(base, "b", "session-artifacts", "root-b", "child-b.jsonl"), + parentSessionPath: rootB, + runtimeKind: "subagent", + rlmChildId: "child-b", + rlmDepth: 1, + }), + ), + ); + + const listDir = (sessionDir: string) => + supervisor + .handleList({}, { type: "list", all: true, sessionDir }) + .data?.sessions.map((session) => session.sessionId) + .sort(); + + expect(listDir(dirA)).toEqual(["child-a", "root-a"]); + expect(listDir(dirB)).toEqual(["child-b", "root-b"]); + }); + it("updates the roster on offline saved-session renames", async () => { const directory = mkdtempSync(join(tmpdir(), "prime-roster-offline-rename-")); tempDirs.push(directory); diff --git a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts index 2db8869b18..f89aa1a7fb 100644 --- a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts @@ -487,6 +487,15 @@ describe("daemon worker supervisor monitoring", () => { options: { worker: { authenticationToken: "token" } }, supervisorClaims: new Map(), clients: new Set(), + sessions: new Map(), + cronStore: { list: () => [] }, + rosterReporter: { + lastSent: new Map(), + lastComposed: new Map(), + queuedChildren: new Map(), + removedAgentIds: new Set(), + snapshotPending: false, + }, shuttingDown: false, clearSupervisorAvailabilityCheck: vi.fn(), scheduleSupervisorFenceCheck: vi.fn(), diff --git a/packages/coding-agent/test/daemon-supervisor-process.test.ts b/packages/coding-agent/test/daemon-supervisor-process.test.ts index 0b4b227797..5d973738df 100644 --- a/packages/coding-agent/test/daemon-supervisor-process.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-process.test.ts @@ -400,17 +400,11 @@ describe("daemon supervisor resident workers", () => { if (!parentSummary.workerPid) throw new Error("Parent worker did not expose its pid"); workerPids.add(parentSummary.workerPid); - const beforeAttach = await client.request({ type: "list", all: true }); + const beforeAttach = await client.request({ type: "list" }); expect(beforeAttach.success).toBe(true); const passiveSummary = requireSessionList(beforeAttach.success ? beforeAttach.data : undefined).find( (summary) => summary.sessionFile === child.sessionFile, ); - const beforeAttachResident = await client.request({ type: "list" }); - expect( - requireSessionList(beforeAttachResident.success ? beforeAttachResident.data : undefined).every( - (summary) => summary.activeSessionId !== undefined, - ), - ).toBe(true); expect(passiveSummary).toMatchObject({ sessionId: child.manager.getSessionId(), sessionName: "passive-child-worker", From 62cb7b93fa6dce11e64cd34dbae7c65765bfdbf0 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 29 Aug 2026 16:00:51 +0200 Subject: [PATCH 04/48] fix(coding-agent): roster bot-review fixes - A child run that terminates before binding is a roster removal, never a passivated phantom row. - list all rescans the disk per call (supervisor-local catalog subprocess, no worker round-trips) and merges with the ledger, which wins for rows it knows; sessionDir defaults to the configured sessions dir, the seed scan passes it too, and name validation reads the same per-call catalog path. Seeding now exists for selectors, name checks, and liveness only. - Saved-session deletes publish removals only when the file was actually deleted, resolve the roster agent id through the ledger entry or spawn edge (childId for subagents), append the spawn-ledger tombstone so deleted subagents never reseed, and offline deletes of worker-owned passivated files forward to the owning worker instead of being rejected as active. - Roster frames respect backpressure: a non-drained socket gets no writes, delivery requires an accepted write, undelivered state stays uncommitted, and a drain re-flushes it. - Roster agent ids qualify child ids by parent path: child ids are 32-bit and uniqueness-checked only per parent (agent-session mkdir loop), so bare ids collide across parents at scale. - findWorker's miss path refreshes all workers once, closing the just-bound-but-unflushed routing window without reviving the hot-path fan-out; a summaries refresh no longer overwrites roster deltas that landed while its list request was in flight. - Seeded artifact-dir rows hydrate their real cwd lazily from the transcript header on first list-all use, keeping startup free of per-child file reads. ENG-5794 --- .../.changes/eng-5794-agent-roster-ledger.md | 2 +- .../src/modes/daemon/agent-roster.ts | 13 +- .../src/modes/daemon/daemon-mode.ts | 121 ++++-- .../src/modes/daemon/daemon-supervisor.ts | 167 ++++++--- .../test/daemon-agent-roster.test.ts | 346 ++++++++++++++++-- .../test/daemon-supervisor-monitor.test.ts | 6 +- 6 files changed, 540 insertions(+), 115 deletions(-) diff --git a/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md b/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md index 67572a35fc..d25e6c3f77 100644 --- a/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md +++ b/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md @@ -1,3 +1,3 @@ - Made the daemon supervisor own an event-driven agent roster: workers push roster deltas on session events, `list` is served from the supervisor's ledger with zero worker round-trips, and stale cached summaries can no longer be returned. -- Made admitted subagent runs visible in `list` before their session exists, and kept passivated or evicted agents listed as inactive rows instead of disappearing. +- 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. - Marked sessions of a dead worker "recovering" the moment its socket closes, and stamped a last-heard-from time on rows of silent workers instead of guessing. diff --git a/packages/coding-agent/src/modes/daemon/agent-roster.ts b/packages/coding-agent/src/modes/daemon/agent-roster.ts index e5b8470889..23385ff920 100644 --- a/packages/coding-agent/src/modes/daemon/agent-roster.ts +++ b/packages/coding-agent/src/modes/daemon/agent-roster.ts @@ -1,3 +1,4 @@ +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. @@ -27,6 +28,8 @@ export interface WorkerRosterEntry { agentId: string; /** Admitted child run whose session has not materialized yet. */ queuedChild?: true; + /** Supervisor seed marker: the cwd is synthetic until one transcript-header read. */ + seededCwd?: true; summary: RosterSessionSummary; } @@ -40,10 +43,16 @@ export interface AgentRosterEntry extends WorkerRosterEntry { 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, + summary: Pick, ): string { - return summary.runtimeKind === "subagent" && summary.rlmChildId ? summary.rlmChildId : summary.sessionId; + if (summary.runtimeKind === "subagent" && summary.rlmChildId) { + return summary.parentSessionPath + ? `${canonicalSessionPath(summary.parentSessionPath)}#${summary.rlmChildId}` + : summary.rlmChildId; + } + return summary.sessionId; } export function workerRosterEntryFromSummary(summary: SessionSummary): WorkerRosterEntry { diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index d7451a5c12..51fa6b4be0 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -124,7 +124,13 @@ import { type DaemonSocketClient, resolveActiveSessionState, } from "./active-session-state.js"; -import { passivatedWorkerRosterEntry, type WorkerRosterEntry, workerRosterEntryFromSummary } from "./agent-roster.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"; @@ -3221,6 +3227,8 @@ export class AgentDaemon { socket.on("error", cleanup); socket.on("drain", () => { client.backpressured = false; + // Undelivered roster state stayed uncommitted; the drained socket can take it now. + this.scheduleRosterFlush(); if (!client.snapshotStreaming) { void this.catchUpBackpressuredClient(client).catch((error) => this.log(`could not catch up snapshot client ${client.id}: ${String(error)}`), @@ -3901,15 +3909,43 @@ export class AgentDaemon { if (this.findActiveSessionByFile(command.sessionPath)) { throw new Error("Cannot delete the currently active session"); } + const deletedPath = canonicalSessionPath(command.sessionPath); const deletedInfo = await readSessionInfo(command.sessionPath).catch(() => undefined); + const ledgerEdge = ( + await this.rlmSpawnLedger() + .edges() + .catch(() => []) + ).find((edge) => canonicalSessionPath(edge.child) === deletedPath); const result = await this.deleteSavedSessionFile(command.sessionPath, { afterFileRemoved: () => { this.cancelScheduledJobsForSessionFile(command.sessionPath); }, }); - if (deletedInfo) { - this.rosterReporter.removedAgentIds.add(deletedInfo.id); - this.scheduleRosterFlush(); + // A file that still exists keeps its roster row; only a real deletion is published. + if (result.ok) { + if (ledgerEdge) { + await this.rlmSpawnLedger() + .appendDelete({ childId: ledgerEdge.childId, child: command.sessionPath, reason: "user" }) + .catch((error) => { + this.log( + `failed to append RLM ledger delete: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } + const removedAgentId = + this.rosterAgentIdForSessionPath(deletedPath) ?? + (ledgerEdge + ? rosterAgentIdForSummary({ + runtimeKind: "subagent", + rlmChildId: ledgerEdge.childId, + sessionId: deletedInfo?.id ?? ledgerEdge.childId, + parentSessionPath: ledgerEdge.parent, + }) + : deletedInfo?.id); + if (removedAgentId) { + this.rosterReporter.removedAgentIds.add(removedAgentId); + this.scheduleRosterFlush(); + } } return success(command.id, "delete_saved_session", result); } @@ -6524,6 +6560,15 @@ export class AgentDaemon { } } + private rosterAgentIdForSessionPath(canonicalPath: string): string | undefined { + for (const entry of this.rosterReporter.lastComposed.values()) { + if (entry.summary.sessionFile && canonicalSessionPath(entry.summary.sessionFile) === canonicalPath) { + return entry.agentId; + } + } + return undefined; + } + private rosterAgentIdForState(state: ActiveSessionState): string { const session = state.runtime.session; const metadata = state.runtime.metadata; @@ -6550,18 +6595,22 @@ export class AgentDaemon { private observeRosterChildUpdate(state: ActiveSessionState, child: AgentConnectionRlmChildAgentSnapshot): void { // The one supersession point: a run with a bound session never has a queued row. - const bound = child.activeSessionId !== undefined || this.hasSessionForRlmChild(child.id); + 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(child.id, this.queuedChildRosterEntry(state, child)); + this.rosterReporter.queuedChildren.set(entry.agentId, entry); } else { - this.rosterReporter.queuedChildren.delete(child.id); + this.rosterReporter.queuedChildren.delete(entry.agentId); } this.scheduleRosterFlush(); } - private hasSessionForRlmChild(childId: string): boolean { + private hasSessionForRlmChild(parentState: ActiveSessionState, childId: string): boolean { for (const candidate of this.sessions.values()) { - if (candidate.runtime.metadata.rlmChildId === childId) return true; + const metadata = candidate.runtime.metadata; + if (metadata.rlmChildId === childId && metadata.parentActiveSessionId === parentState.activeSessionId) { + return true; + } } return false; } @@ -6571,30 +6620,27 @@ export class AgentDaemon { child: AgentConnectionRlmChildAgentSnapshot, ): WorkerRosterEntry { const parentSession = state.runtime.session; - return { - agentId: child.id, - queuedChild: true, - summary: { - 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, - }, + 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 { @@ -6621,6 +6667,10 @@ export class AgentDaemon { for (const [childId, queued] of reporter.queuedChildren) { entries.set(childId, queued); } + // A terminal unbound child run owns no transcript: it is a removal, never a passivated row. + for (const [agentId, previous] of reporter.lastComposed) { + if (previous.queuedChild === true && !entries.has(agentId)) reporter.removedAgentIds.add(agentId); + } for (const agentId of reporter.removedAgentIds) { entries.delete(agentId); reporter.queuedChildren.delete(agentId); @@ -6690,13 +6740,18 @@ export class AgentDaemon { if (client.transport !== "private-framed" || client.authenticated !== true || client.socket.destroyed) { continue; } + // A non-drained socket gets nothing; uncommitted state re-flushes on drain. + if (client.backpressured === true) { + continue; + } const accepted = client.socket.write( encodePrivateFrame({ kind: "outbound", outboundType: message.type }, payload), ); - delivered = true; if (!accepted) { client.backpressured = true; + continue; } + delivered = true; } return delivered; } diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index deab5bffaf..21157ce3e5 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -60,6 +60,7 @@ import { type AgentRosterEntry, AgentRosterLedger, passivatedWorkerRosterEntry, + rosterAgentIdForSummary, sessionSummaryFromRosterEntry, type WorkerRosterEntry, workerRosterEntryFromSummary, @@ -311,6 +312,8 @@ interface ResidentWorker { lastFrameAt?: number; /** True while the watchdog has stamped this worker's entries as stale. */ rosterStale?: boolean; + /** Bumped per consumed roster delta; a summaries refresh must not overwrite newer deltas. */ + rosterDeltaGeneration?: number; } interface SnapshotDuplicateValidation { @@ -2108,13 +2111,28 @@ export class DaemonSupervisor { } case "delete_saved_session": if (!command.activeSessionId) { - const active = this.findWorkerBySessionFile(command.sessionPath); - if (active) { + const entry = this.roster().bySessionFile(canonicalSessionPath(command.sessionPath)); + if (entry?.summary.activeSessionId !== undefined) { throw new Error("Cannot delete the currently active session"); } + const owner = entry?.workerId !== undefined ? this.workers.get(entry.workerId) : undefined; + // The owning worker deletes its own passivated files and publishes the removal itself. + if (owner?.client && !this.isWorkerStopping(owner)) { + return this.forwardToWorker(owner, command); + } const result = await this.catalog.delete(command.sessionPath); - const entry = this.roster().bySessionFile(canonicalSessionPath(command.sessionPath)); - if (entry) this.roster().delete(entry.agentId); + if (result.ok) { + if (entry?.summary.rlmChildId) { + await this.rlmSpawnLedger() + .appendDelete({ childId: entry.summary.rlmChildId, child: command.sessionPath, reason: "user" }) + .catch((error) => { + this.log( + `failed to append RLM ledger delete: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } + if (entry) this.roster().delete(entry.agentId); + } return success(command.id, command.type, result); } break; @@ -2259,19 +2277,20 @@ export class DaemonSupervisor { } } - /** Served entirely from the roster ledger: zero worker round-trips, no stale-summary window. */ - private handleList(client: DaemonSocketClient, command: Extract): DaemonResponse { + /** Worker-owned rows come from the ledger with zero worker round-trips; list all rescans the disk. */ + private async handleList( + client: DaemonSocketClient, + command: Extract, + ): Promise { const active: SessionSummary[] = []; - const inactive: SessionSummary[] = []; + const workerOwnedFiles = new Set(); let busyClientOwnedSessionCount = 0; for (const entry of this.roster().values()) { // Sessionless queued-child rows are ledger-internal; no list form serves them. if (entry.queuedChild) continue; const worker = entry.workerId !== undefined ? this.workers.get(entry.workerId) : undefined; - if (worker === undefined) { - if (command.all) inactive.push(sessionSummaryFromRosterEntry(entry)); - continue; - } + if (worker === undefined) continue; + if (entry.summary.sessionFile) workerOwnedFiles.add(canonicalSessionPath(entry.summary.sessionFile)); const summary = this.publicSummary(worker, sessionSummaryFromRosterEntry(entry)); // Stopping workers stay listed with an honest workerState; daemon-launch busy checks read this list. if (this.isVisibleWorker(worker)) { @@ -2290,14 +2309,49 @@ export class DaemonSupervisor { if (!command.all) { return success(command.id, "list", data); } + // Disk owns saved state (external processes write session files); the ledger wins for rows it knows. + const sessionDir = command.sessionDir ?? this.defaultSessionConfig.sessionDir; + const scanned = await this.catalog + .list(command.cwd ? resolve(command.cwd) : undefined, sessionDir) + .catch(() => []); const cwd = command.cwd ? resolve(command.cwd) : undefined; - const saved = inactive - .filter((summary) => cwd === undefined || resolve(summary.cwd) === cwd) - .filter((summary) => this.matchesListSessionDir(summary, command.sessionDir)) - .sort((a, b) => Date.parse(b.modified ?? "") - Date.parse(a.modified ?? "")); + const saved: SessionSummary[] = []; + const savedFiles = new Set(); + for (const info of scanned) { + const file = canonicalSessionPath(info.path); + savedFiles.add(file); + if (workerOwnedFiles.has(file)) continue; + const entry = this.roster().bySessionFile(file); + saved.push(entry ? sessionSummaryFromRosterEntry(entry) : summaryForInactiveSession(info)); + } + for (const entry of this.roster().values()) { + // Ledger-only offline rows (artifact-dir children, flipped residents) ride along with the scan. + 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 || savedFiles.has(file) || workerOwnedFiles.has(file)) continue; + const summary = sessionSummaryFromRosterEntry(await this.hydrateSeededEntry(entry)); + if (cwd !== undefined && resolve(summary.cwd) !== cwd) continue; + if (!this.matchesListSessionDir(summary, sessionDir)) continue; + saved.push(summary); + } + saved.sort((a, b) => Date.parse(b.modified ?? "") - Date.parse(a.modified ?? "")); return success(command.id, "list", { ...data, sessions: [...saved, ...active] }); } + // Seeded artifact-dir rows carry a synthetic cwd until their transcript header is read once. + private async hydrateSeededEntry(entry: AgentRosterEntry): Promise { + if (entry.seededCwd !== true || !entry.summary.sessionFile) return entry; + const info = await readSessionInfo(entry.summary.sessionFile).catch(() => undefined); + const { seededCwd, ...rest } = entry; + if (!info) return entry; + return this.roster().write( + { ...rest, summary: { ...entry.summary, cwd: info.cwd } }, + entry.workerId, + entry.statusLabel, + ); + } + // An artifact-dir child belongs to the sessions dir of its owning root session. private matchesListSessionDir(summary: SessionSummary, sessionDir: string | undefined): boolean { if (sessionDir === undefined) return true; @@ -3453,6 +3507,7 @@ export class DaemonSupervisor { if (!worker.client) { throw new Error("Session worker is not connected"); } + const deltaGenerationAtStart = worker.rosterDeltaGeneration ?? 0; const response = await worker.client.request({ type: "list" }, 5000); const summaries = sessionSummariesFromResponse(response); const nextSummaries = new Map(summaries.map((summary) => [summary.activeSessionId ?? summary.id, summary])); @@ -3469,7 +3524,10 @@ export class DaemonSupervisor { this.streamReconstructor.clear(activeSessionId); } } - this.syncWorkerSummariesIntoRoster(worker); + // A delta that landed mid-refresh is newer than this list; it must not be overwritten. + if ((worker.rosterDeltaGeneration ?? 0) === deltaGenerationAtStart) { + this.syncWorkerSummariesIntoRoster(worker); + } if (root) { if (recovery) { await this.assertRecoveryAllowed(); @@ -3485,8 +3543,24 @@ export class DaemonSupervisor { } } - private familyCatalogEntries(): AgentFamilyCatalogEntry[] { - return [...this.roster().values()].map((entry) => this.familyCatalogEntry(sessionSummaryFromRosterEntry(entry))); + // Name validation reads the disk per call: external processes create root files after startup. + private async familyCatalogEntries(): Promise { + 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) + .catch(() => [] as SessionInfo[]); + 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( @@ -3509,7 +3583,7 @@ export class DaemonSupervisor { target: Pick, name: string, ): Promise { - assertAgentSessionNameAvailable(this.familyCatalogEntries(), { + assertAgentSessionNameAvailable(await this.familyCatalogEntries(), { name, depth: target.rlmDepth ?? 0, parentSessionId: target.parentSessionId, @@ -3542,9 +3616,10 @@ export class DaemonSupervisor { return this.roster().entriesForWorker(worker.descriptor.workerId); } + // Seeds selector resolution, name checks, and liveness; list all rescans the disk per call. private async seedRosterLedger(): Promise { try { - for (const info of await this.catalog.list()) { + 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); } @@ -3554,9 +3629,10 @@ export class DaemonSupervisor { try { // Ledger edges cover subagents in artifact dirs the catalog never scans; tombstones stay out. for (const edge of await this.rlmSpawnLedger().edges()) { - if (this.roster().has(edge.childId)) continue; + const entry = this.rosterEntryForSpawnLedgerEdge(edge); + if (this.roster().has(entry.agentId)) continue; if (this.roster().hasSessionFile(canonicalSessionPath(edge.child))) continue; - this.writeRosterEntry(this.rosterEntryForSpawnLedgerEdge(edge)); + this.roster().write({ ...entry, seededCwd: true }); } } catch (error) { this.log(`Could not seed the agent roster from the spawn ledger: ${String(error)}`); @@ -3564,28 +3640,26 @@ export class DaemonSupervisor { } private rosterEntryForSpawnLedgerEdge(edge: RlmLedgerEdge): WorkerRosterEntry { - return { - agentId: edge.childId, - summary: { - id: edge.childId, - lifecycle: "live", - activity: "idle", - isSessionActive: false, - runtimeKind: "subagent", - rlmDepth: edge.depth, - sessionId: edge.childId, - sessionFile: edge.child, - sessionName: edge.name, - // The ledger records topology only; display fields hydrate lazily on open. - cwd: dirname(edge.child), - isStreaming: false, - isCompacting: false, - attachedClients: 0, - messageCount: 0, - parentSessionPath: edge.parent, - rlmChildId: edge.childId, - }, + const summary: WorkerRosterEntry["summary"] = { + id: edge.childId, + lifecycle: "live", + activity: "idle", + isSessionActive: false, + runtimeKind: "subagent", + rlmDepth: edge.depth, + sessionId: edge.childId, + sessionFile: edge.child, + sessionName: edge.name, + // The ledger records topology only; display fields hydrate lazily on open. + 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): void { @@ -3597,6 +3671,7 @@ export class DaemonSupervisor { } if (delta.type !== "roster_delta" || !Array.isArray(delta.entries)) return; worker.rosterCapable = true; + worker.rosterDeltaGeneration = (worker.rosterDeltaGeneration ?? 0) + 1; if (delta.snapshot === true) { // Snapshot replacement: absent rows with a durable transcript passivate, sessionless rows go. const sent = new Set(delta.entries.map((entry) => entry.agentId)); @@ -3879,11 +3954,9 @@ export class DaemonSupervisor { ): Promise { let matches = this.matchWorkers(selector, includeWorker); if (matches.length === 0) { - // Roster-capable workers push their sessions; only legacy workers can be stale here. + // Miss path only: one bounded refresh closes the just-bound-but-unflushed routing window. await Promise.all( - [...this.workers.values()] - .filter((worker) => worker.rosterCapable !== true) - .map((worker) => this.refreshWorkerSummaries(worker).catch(() => undefined)), + [...this.workers.values()].map((worker) => this.refreshWorkerSummaries(worker).catch(() => undefined)), ); matches = this.matchWorkers(selector, includeWorker); } diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index a8cfd37e46..b1028073e7 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -80,8 +80,10 @@ function makeWorkerReporter(connected = true): WorkerReporterFixture { function makeState(options: { activeSessionId: string; sessionId?: string; + sessionFile?: string; kind?: "top-level" | "subagent"; rlmChildId?: string; + parentActiveSessionId?: string; messages?: AgentMessage[]; isStreaming?: boolean; }): ActiveSessionState { @@ -94,12 +96,14 @@ function makeState(options: { kind: options.kind ?? "top-level", createdAt: 1, ...(options.rlmChildId ? { rlmChildId: options.rlmChildId } : {}), + ...(options.parentActiveSessionId ? { parentActiveSessionId: options.parentActiveSessionId } : {}), }, 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}`, @@ -156,6 +160,7 @@ describe("worker roster reporter", () => { 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); @@ -188,6 +193,7 @@ describe("worker roster reporter", () => { 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); @@ -277,6 +283,7 @@ describe("worker roster reporter", () => { 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); @@ -308,6 +315,7 @@ describe("worker roster reporter", () => { 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); @@ -406,6 +414,8 @@ function makeSupervisor(workers: WorkerFixture[], extra: Record 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(), @@ -430,7 +440,7 @@ function rosterDelta(entries: WorkerRosterEntry[], removedAgentIds?: string[], s } describe("supervisor roster ledger", () => { - it("keeps queued child rows ledger-internal and lists them once their session materializes", () => { + it("keeps queued child rows ledger-internal and lists them once their session materializes", async () => { const worker = makeWorker("worker-1"); const supervisor = makeSupervisor([worker]); @@ -450,8 +460,8 @@ describe("supervisor roster ledger", () => { ]), ); - expect(supervisor.handleList({}, { type: "list" }).data?.sessions).toEqual([]); - expect(supervisor.handleList({}, { type: "list", all: true }).data?.sessions).toEqual([]); + expect((await supervisor.handleList({}, { type: "list" })).data?.sessions).toEqual([]); + expect((await supervisor.handleList({}, { type: "list", all: true })).data?.sessions).toEqual([]); expect(supervisor.workerRosterEntries(worker)[0]).toMatchObject({ status: "running", statusLabel: "queued" }); supervisor.consumeWorkerRosterDelta( @@ -471,14 +481,14 @@ describe("supervisor roster ledger", () => { ]), ); - const listed = supervisor.handleList({}, { type: "list" }); + const listed = await supervisor.handleList({}, { type: "list" }); expect(listed.data?.sessions).toHaveLength(1); expect(listed.data?.sessions[0]).toMatchObject({ activeSessionId: "child-active", workerState: "ready" }); expect(supervisor.workerRosterEntries(worker)[0]).toMatchObject({ status: "running" }); expect(supervisor.workerRosterEntries(worker)[0]?.statusLabel).toBeUndefined(); }); - it("keeps passivated children of a live worker in the resident list, seeded rows in list all only", () => { + it("keeps passivated children of a live worker in the resident list, seeded rows in list all only", async () => { const worker = makeWorker("worker-1"); const supervisor = makeSupervisor([worker]); supervisor.writeRosterEntry( @@ -503,13 +513,13 @@ describe("supervisor roster ledger", () => { workerRosterEntryFromSummary(summary({ id: "seeded", sessionId: "seeded", sessionFile: "/tmp/seeded.jsonl" })), ); - const resident = supervisor.handleList({}, { type: "list" }); + const resident = await supervisor.handleList({}, { type: "list" }); expect(resident.data?.sessions.map((session) => session.sessionId).sort()).toEqual(["child-session", "root"]); const child = resident.data?.sessions.find((session) => session.rlmChildId === "child-1"); expect(child).toMatchObject({ workerPid: 1234 }); expect(child?.activeSessionId).toBeUndefined(); - const all = supervisor.handleList({}, { type: "list", all: true }); + const all = await supervisor.handleList({}, { type: "list", all: true }); expect(all.data?.sessions.map((session) => session.sessionId).sort()).toEqual([ "child-session", "root", @@ -517,7 +527,7 @@ describe("supervisor roster ledger", () => { ]); }); - it("serves list from the ledger with zero worker round-trips and exact busy counts", () => { + 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: { @@ -542,7 +552,7 @@ describe("supervisor roster ledger", () => { owned, ); - const listed = supervisor.handleList({}, { type: "list", includeClientOwned: true }); + const listed = await supervisor.handleList({}, { type: "list", includeClientOwned: true }); expect(listed.success).toBe(true); expect(listed.data?.busyClientOwnedSessionCount).toBe(1); @@ -734,32 +744,38 @@ describe("supervisor roster ledger", () => { }); await supervisor.seedRosterLedger(); - const listed = supervisor.handleList({}, { type: "list", all: 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(supervisor.handleList({}, { type: "list" }).data?.sessions).toEqual([]); + expect((await supervisor.handleList({}, { type: "list" })).data?.sessions).toEqual([]); // An evicted worker leaves its rows behind as inactive instead of dropping them. const worker = makeWorker("worker-1"); supervisor.workers.set("worker-1", worker); supervisor.writeRosterEntry( workerRosterEntryFromSummary( - summary({ id: "e-active", sessionId: "evicted", activeSessionId: "e-active", isSessionActive: true }), + summary({ + id: "e-active", + sessionId: "evicted", + activeSessionId: "e-active", + sessionFile: join(sessionsDir, "evicted.jsonl"), + isSessionActive: true, + }), ), worker, ); supervisor.workers.delete("worker-1"); supervisor.flipWorkerRosterEntriesInactive(worker); - const afterEvict = supervisor.handleList({}, { type: "list", all: true }); + 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(afterEvict.data?.sessions.some((session) => session.sessionId === "deleted-child")).toBe(false); }); - it("scopes list all by sessions dir through owning topology, not the shared artifacts tree", () => { + it("scopes list all by sessions dir through owning topology, not the shared artifacts tree", async () => { const supervisor = makeSupervisor([]); const base = "/tmp/agent-homes"; const dirA = join(base, "a", "sessions"); @@ -799,14 +815,13 @@ describe("supervisor roster ledger", () => { ), ); - const listDir = (sessionDir: string) => - supervisor - .handleList({}, { type: "list", all: true, sessionDir }) - .data?.sessions.map((session) => session.sessionId) + const listDir = async (sessionDir: string) => + (await supervisor.handleList({}, { type: "list", all: true, sessionDir })).data?.sessions + .map((session) => session.sessionId) .sort(); - expect(listDir(dirA)).toEqual(["child-a", "root-a"]); - expect(listDir(dirB)).toEqual(["child-b", "root-b"]); + expect(await listDir(dirA)).toEqual(["child-a", "root-a"]); + expect(await listDir(dirB)).toEqual(["child-b", "root-b"]); }); it("updates the roster on offline saved-session renames", async () => { @@ -846,27 +861,52 @@ describe("supervisor roster ledger", () => { expect(supervisor.roster().get("saved-1")?.summary.sessionName).toBe("new-name"); }); - it("removes the roster row on offline saved-session deletes", async () => { + it("removes the roster row on offline deletes, tombstones subagents, and never reseeds them", async () => { const directory = mkdtempSync(join(tmpdir(), "prime-roster-offline-delete-")); tempDirs.push(directory); - const sessionPath = join(directory, "saved.jsonl"); + const sessionsDir = join(directory, "sessions"); + const parentPath = join(sessionsDir, "root.jsonl"); + const childPath = join(directory, "artifacts", "child.jsonl"); const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { - defaultSessionConfig: { agentDir: directory, cwd: directory }, + defaultSessionConfig: { agentDir: directory, cwd: directory, sessionDir: sessionsDir }, descriptorDir: join(directory, "workers"), - }) as unknown as SupervisorFixture & { handleCommand(client: object, command: object): Promise }; + }) as unknown as SupervisorFixture & { + handleCommand(client: object, command: object): Promise; + rlmSpawnLedger(): RlmSpawnLedger; + }; Object.assign(supervisor, { - catalog: { delete: vi.fn(async () => ({ deleted: true })) }, + catalog: { delete: vi.fn(async () => ({ ok: true, method: "unlink" })), list: vi.fn(async () => []) }, }); - supervisor.writeRosterEntry( - workerRosterEntryFromSummary(summary({ id: "saved-1", sessionId: "saved-1", sessionFile: sessionPath })), + await supervisor + .rlmSpawnLedger() + .appendSpawn({ childId: "child-1", parent: parentPath, child: childPath, depth: 1, name: "child" }); + 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( { id: "client", attachedActiveSessionIds: new Set() }, - { type: "delete_saved_session", sessionPath }, + { type: "delete_saved_session", sessionPath: childPath }, ); - expect(supervisor.roster().has("saved-1")).toBe(false); + 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([]); }); }); @@ -948,3 +988,251 @@ describe("roster entry projection", () => { expect(roundTripped.modelFallbackMessage).toBe("No models available"); }); }); + +describe("bot-round regressions", () => { + it("removes a child run that terminates before binding instead of passivating a phantom", () => { + 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: "task", status: "queued", sessionDir: "/tmp/c" }), + ); + daemon.flushRoster(); + + daemon.observeRosterEvent( + parent, + childUpdate(parent, { id: "child-1", label: "task", status: "cancelled", sessionDir: "/tmp/c" }), + ); + daemon.flushRoster(); + expect(sentDeltas.at(-1)?.removedAgentIds).toEqual(["child-1"]); + expect(sentDeltas.at(-1)?.entries.some((entry) => entry.agentId === "child-1")).toBe(false); + + daemon.rosterReporter.snapshotPending = true; + daemon.flushRoster(); + expect(sentDeltas.at(-1)?.snapshot).toBe(true); + expect(sentDeltas.at(-1)?.entries.some((entry) => entry.agentId === "child-1")).toBe(false); + }); + + it("qualifies colliding child ids from different parents by parent path", () => { + const { daemon, sentDeltas } = makeWorkerReporter(); + const parentA = makeState({ activeSessionId: "parent-a", sessionFile: "/tmp/a.jsonl" }); + const parentB = makeState({ activeSessionId: "parent-b", sessionFile: "/tmp/b.jsonl" }); + daemon.sessions.set(parentA.activeSessionId, parentA); + daemon.sessions.set(parentB.activeSessionId, parentB); + + daemon.observeRosterEvent( + parentA, + childUpdate(parentA, { id: "sub-1234", label: "a", status: "queued", sessionDir: "/tmp/a" }), + ); + daemon.observeRosterEvent( + parentB, + childUpdate(parentB, { id: "sub-1234", label: "b", status: "queued", sessionDir: "/tmp/b" }), + ); + daemon.flushRoster(); + + const queuedRows = sentDeltas.at(-1)?.entries.filter((entry) => entry.summary.rlmChildId === "sub-1234") ?? []; + expect(queuedRows).toHaveLength(2); + expect(new Set(queuedRows.map((entry) => entry.agentId)).size).toBe(2); + }); + + it("keeps roster state uncommitted until a frame reaches a drained socket", () => { + const write = vi.fn(() => false); + const socket = { destroyed: false, write }; + const client = { + transport: "private-framed", + authenticated: true, + backpressured: undefined as boolean | undefined, + socket, + }; + const daemon = Object.assign(Object.create(AgentDaemon.prototype), { + options: { worker: { authenticationToken: "token" } }, + sessions: new Map(), + cronStore: { list: () => [] }, + clients: new Set([client]), + rosterReporter: { + lastSent: new Map(), + lastComposed: new Map(), + queuedChildren: new Map(), + removedAgentIds: new Set(["deleted-agent"]), + snapshotPending: false, + }, + rosterFlushScheduled: false, + shuttingDown: false, + log: vi.fn(), + }) as { + flushRoster(): void; + rosterReporter: { removedAgentIds: Set; lastSent: Map }; + }; + + daemon.flushRoster(); + // write() returned false: the frame is not delivered; nothing commits. + expect(daemon.rosterReporter.removedAgentIds.has("deleted-agent")).toBe(true); + expect(daemon.rosterReporter.lastSent.size).toBe(0); + expect(client.backpressured).toBe(true); + + // A backpressured socket gets no further writes until it drains. + daemon.flushRoster(); + expect(write).toHaveBeenCalledTimes(1); + + client.backpressured = false; + write.mockReturnValue(true); + daemon.flushRoster(); + expect(daemon.rosterReporter.removedAgentIds.size).toBe(0); + }); + + it("merges the per-call disk scan with ledger rows, preferring the ledger for known files", 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: "renamed" }), + ), + ); + + const listed = await supervisor.handleList({}, { type: "list", all: true }); + const ids = listed.data?.sessions.map((session) => session.sessionId).sort(); + expect(ids).toEqual(["external", "known"]); + expect(listed.data?.sessions.find((session) => session.sessionId === "known")?.sessionName).toBe("renamed"); + }); + + it("forwards passive-child deletes to the owning worker instead of rejecting them", async () => { + const worker = makeWorker("worker-1"); + worker.client = { + request: vi.fn(async () => ({ type: "response", command: "delete_saved_session", success: true })), + }; + Object.assign(worker.descriptor, { lifecycle: "ready" }); + const catalogDelete = vi.fn(); + const supervisor = makeSupervisor([worker], { + catalog: { list: vi.fn(async () => []), delete: catalogDelete }, + mutationDrain: { begin: vi.fn(), end: vi.fn() }, + }); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "child-session", + sessionId: "child-session", + sessionFile: "/tmp/artifacts/child.jsonl", + runtimeKind: "subagent", + rlmChildId: "child-1", + }), + ), + worker, + ); + const internals = supervisor as unknown as { + handleCommand(client: object, command: object): Promise; + }; + + await internals.handleCommand( + { id: "client", attachedActiveSessionIds: new Set() }, + { type: "delete_saved_session", sessionPath: "/tmp/artifacts/child.jsonl" }, + ); + + expect(worker.client.request).toHaveBeenCalledWith( + expect.objectContaining({ type: "delete_saved_session", sessionPath: "/tmp/artifacts/child.jsonl" }), + expect.any(Number), + ); + expect(catalogDelete).not.toHaveBeenCalled(); + }); + + it("keeps the roster row when a delete fails on disk", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-failed-delete-")); + tempDirs.push(directory); + const sessionPath = join(directory, "saved.jsonl"); + const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory }, + descriptorDir: join(directory, "workers"), + }) as unknown as SupervisorFixture & { handleCommand(client: object, command: object): Promise }; + Object.assign(supervisor, { + catalog: { delete: vi.fn(async () => ({ ok: false, error: "busy file" })), list: vi.fn(async () => []) }, + }); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary(summary({ id: "saved-1", sessionId: "saved-1", sessionFile: sessionPath })), + ); + + await supervisor.handleCommand( + { id: "client", attachedActiveSessionIds: new Set() }, + { type: "delete_saved_session", sessionPath }, + ); + + expect(supervisor.roster().has("saved-1")).toBe(true); + }); + + it("routes a just-bound session through the miss-path refresh", async () => { + const target = summary({ id: "target-active", sessionId: "target", activeSessionId: "target-active" }); + const worker = makeWorker("worker-1", { rosterCapable: true }); + worker.client = { + request: vi.fn(async () => ({ + type: "response", + command: "list", + success: true, + data: { sessions: [target] }, + })), + }; + const supervisor = makeSupervisor([worker], { + refreshWorkerSummaries: DaemonSupervisor.prototype["refreshWorkerSummaries" as never], + syncWorkerSummariesIntoRoster: DaemonSupervisor.prototype["syncWorkerSummariesIntoRoster" as never], + streamReconstructor: { seed: vi.fn(), clear: vi.fn() }, + }); + const internals = supervisor as unknown as { + findWorker(selector: string): Promise<{ summary: SessionSummary }>; + }; + + const match = await internals.findWorker("target-active"); + expect(match.summary.sessionId).toBe("target"); + }); + + it("does not let a summaries refresh overwrite a newer roster delta", async () => { + const worker = makeWorker("worker-1", { rosterCapable: true }); + const stale = summary({ id: "s-active", sessionId: "s", activeSessionId: "s-active", sessionName: "stale" }); + const supervisor = makeSupervisor([worker], { + refreshWorkerSummaries: DaemonSupervisor.prototype["refreshWorkerSummaries" as never], + syncWorkerSummariesIntoRoster: DaemonSupervisor.prototype["syncWorkerSummariesIntoRoster" as never], + streamReconstructor: { seed: vi.fn(), clear: vi.fn() }, + }); + worker.client = { + request: vi.fn(async () => { + supervisor.consumeWorkerRosterDelta( + worker, + rosterDelta([ + workerRosterEntryFromSummary( + summary({ id: "s-active", sessionId: "s", activeSessionId: "s-active", sessionName: "fresh" }), + ), + ]), + ); + return { type: "response", command: "list", success: true, data: { sessions: [stale] } }; + }), + }; + + await ( + supervisor as unknown as { refreshWorkerSummaries(worker: WorkerFixture): Promise } + ).refreshWorkerSummaries(worker); + + expect(supervisor.roster().get("s")?.summary.sessionName).toBe("fresh"); + }); +}); diff --git a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts index f89aa1a7fb..945cc3baf9 100644 --- a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts @@ -2088,14 +2088,14 @@ describe("daemon worker supervisor monitoring", () => { handleList( client: object, command: { id: string; type: "list" }, - ): { + ): Promise<{ success: boolean; data?: { sessions: Array<{ activeSessionId?: string; id: string; workerState?: string }> }; - }; + }>; }; seedSupervisorRoster(supervisor, liveWorker, stoppingWorker); - const response = supervisor.handleList({}, { id: "list-1", type: "list" }); + const response = await supervisor.handleList({}, { id: "list-1", type: "list" }); expect(response.success).toBe(true); const sessions = response.data?.sessions ?? []; From 1bd50dfd6913f121cdedcf96dadffeafccd9d86a Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 29 Aug 2026 16:25:44 +0200 Subject: [PATCH 05/48] fix(coding-agent): roster bot-review fixes round two - Both remaining removal producers (rlm subagent deletion and discarded bound-child drafts) publish parent-qualified agent ids through one shared resolution (rosterAgentIdForRlmChild), matching the qualified row keys. - Delivery authority is the live supervisor claim: hasAuthenticated- SupervisorClient and broadcastRosterFrame require supervisorClaims membership, so a revoked socket can never satisfy delivery. - Generation-acked tombstone retention closes the kernel-write-vs-consumed gap: every roster frame carries a monotonic generation, delivered removals are retained as tombstones, the supervisor acks its last consumed generation in worker_auth, the reauth snapshot replays newer tombstones, and the worker prunes acked ones (recreated agents drop their stale tombstones at composition). - A refresh response staler than a mid-flight delta is discarded entirely (one bounded retry) instead of partially applied, and the eviction snapshot reads the roster so a busy delta always outranks a stale list. - Saved-child deletes append the spawn-ledger tombstone FIRST and abort on append failure; a tombstoned-but-undeleted file is the accepted orphan of a failed delete and keeps its roster row for retry. ENG-5794 --- .../src/modes/daemon/daemon-mode.ts | 90 +++++-- .../src/modes/daemon/daemon-supervisor.ts | 73 +++--- .../modes/daemon/daemon-worker-protocol.ts | 11 +- .../test/daemon-agent-roster.test.ts | 240 +++++++++++++++++- 4 files changed, 352 insertions(+), 62 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 51fa6b4be0..0fb7375862 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -555,6 +555,8 @@ export class AgentDaemon { lastComposed: new Map(), queuedChildren: new Map(), removedAgentIds: new Set(), + tombstones: new Map(), + generation: 0, snapshotPending: false, }; private rosterFlushScheduled = false; @@ -1131,7 +1133,7 @@ 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 }); - this.rosterReporter.removedAgentIds.add(childId); + this.rosterReporter.removedAgentIds.add(this.rosterAgentIdForRlmChild(childId, entry.parentSessionFile)); this.scheduleRosterFlush(); // Deletion boundary: transcript + display tombstone are the durable // record and stay; the nested artifact dir is a runtime cache and goes. @@ -3287,6 +3289,7 @@ export class AgentDaemon { supervisorPid?: unknown; supervisorProcessStartId?: unknown; supervisorSocketPath?: unknown; + rosterGeneration?: unknown; activeSessionId?: unknown; admissionId?: unknown; capabilities?: unknown; @@ -3319,6 +3322,7 @@ export class AgentDaemon { !Number.isInteger(parsed.supervisorPid) || (parsed.supervisorPid as number) <= 0 || (parsed.supervisorProcessStartId !== undefined && typeof parsed.supervisorProcessStartId !== "string") || + (parsed.rosterGeneration !== undefined && typeof parsed.rosterGeneration !== "number") || typeof parsed.supervisorSocketPath !== "string" ) { clearParsedAdmission(); @@ -3359,8 +3363,8 @@ export class AgentDaemon { success: true, data: { capabilities: [DAEMON_WORKER_ROSTER_CAPABILITY] }, }); - // A (re)connected supervisor has no delta history; the next flush sends a replacing snapshot. - this.rosterReporter.snapshotPending = true; + // A (re)connected supervisor replays from its acked generation; the next flush sends a replacing snapshot. + this.prepareRosterSnapshot(typeof parsed.rosterGeneration === "number" ? parsed.rosterGeneration : 0); this.scheduleRosterFlush(); return; } @@ -3916,6 +3920,14 @@ export class AgentDaemon { .edges() .catch(() => []) ).find((edge) => canonicalSessionPath(edge.child) === deletedPath); + // Tombstone first: a failed append aborts; a tombstoned-but-undeleted file is the accepted orphan of a failed delete. + if (ledgerEdge) { + await this.rlmSpawnLedger().appendDelete({ + childId: ledgerEdge.childId, + child: command.sessionPath, + reason: "user", + }); + } const result = await this.deleteSavedSessionFile(command.sessionPath, { afterFileRemoved: () => { this.cancelScheduledJobsForSessionFile(command.sessionPath); @@ -3923,25 +3935,9 @@ export class AgentDaemon { }); // A file that still exists keeps its roster row; only a real deletion is published. if (result.ok) { - if (ledgerEdge) { - await this.rlmSpawnLedger() - .appendDelete({ childId: ledgerEdge.childId, child: command.sessionPath, reason: "user" }) - .catch((error) => { - this.log( - `failed to append RLM ledger delete: ${error instanceof Error ? error.message : String(error)}`, - ); - }); - } const removedAgentId = this.rosterAgentIdForSessionPath(deletedPath) ?? - (ledgerEdge - ? rosterAgentIdForSummary({ - runtimeKind: "subagent", - rlmChildId: ledgerEdge.childId, - sessionId: deletedInfo?.id ?? ledgerEdge.childId, - parentSessionPath: ledgerEdge.parent, - }) - : deletedInfo?.id); + (ledgerEdge ? this.rosterAgentIdForRlmChild(ledgerEdge.childId, ledgerEdge.parent) : deletedInfo?.id); if (removedAgentId) { this.rosterReporter.removedAgentIds.add(removedAgentId); this.scheduleRosterFlush(); @@ -6572,7 +6568,20 @@ export class AgentDaemon { private rosterAgentIdForState(state: ActiveSessionState): string { const session = state.runtime.session; const metadata = state.runtime.metadata; - return metadata.kind === "subagent" && metadata.rlmChildId ? metadata.rlmChildId : session.sessionId; + if (metadata.kind === "subagent" && metadata.rlmChildId) { + return this.rosterAgentIdForRlmChild(metadata.rlmChildId, metadata.parentSessionFile); + } + return session.sessionId; + } + + /** The one resolution for subagent roster ids: childId qualified by its parent's session path. */ + private rosterAgentIdForRlmChild(childId: string, parentSessionPath: string | undefined): string { + return rosterAgentIdForSummary({ + runtimeKind: "subagent", + rlmChildId: childId, + sessionId: childId, + parentSessionPath, + }); } private observeRosterEvent(state: ActiveSessionState, message: DaemonOutbound): void { @@ -6643,6 +6652,14 @@ export class AgentDaemon { return { agentId: rosterAgentIdForSummary(summary), queuedChild: true, summary }; } + private prepareRosterSnapshot(ackedGeneration: number): void { + const reporter = this.rosterReporter; + for (const [agentId, generation] of reporter.tombstones) { + if (generation <= ackedGeneration) reporter.tombstones.delete(agentId); + } + reporter.snapshotPending = true; + } + private scheduleRosterFlush(): void { if (!this.options.worker || this.rosterFlushScheduled || this.shuttingDown) return; this.rosterFlushScheduled = true; @@ -6681,25 +6698,34 @@ export class AgentDaemon { entries.set(agentId, passivatedWorkerRosterEntry(previous)); } } + // An agent recreated after deletion outlives its old tombstone. + for (const agentId of entries.keys()) { + reporter.tombstones.delete(agentId); + } reporter.lastComposed = new Map(entries); if (!this.hasAuthenticatedSupervisorClient()) return; const removedAgentIds = [...reporter.removedAgentIds]; + const generation = reporter.generation + 1; if (reporter.snapshotPending) { + const replayedRemovals = [...new Set([...removedAgentIds, ...reporter.tombstones.keys()])]; const delivered = this.broadcastRosterFrame({ type: "roster_delta", snapshot: true, + generation, entries: [...entries.values()], - ...(removedAgentIds.length > 0 ? { removedAgentIds } : {}), + ...(replayedRemovals.length > 0 ? { removedAgentIds: replayedRemovals } : {}), }); if (!delivered) return; + reporter.generation = generation; reporter.snapshotPending = false; + for (const agentId of removedAgentIds) { + reporter.tombstones.set(agentId, generation); + reporter.removedAgentIds.delete(agentId); + } reporter.lastSent.clear(); for (const [agentId, entry] of entries) { reporter.lastSent.set(agentId, { json: JSON.stringify(entry), entry }); } - for (const agentId of removedAgentIds) { - reporter.removedAgentIds.delete(agentId); - } return; } const changed: Array<{ agentId: string; json: string; entry: WorkerRosterEntry }> = []; @@ -6711,22 +6737,26 @@ export class AgentDaemon { if (changed.length === 0 && removedAgentIds.length === 0) return; const delivered = this.broadcastRosterFrame({ type: "roster_delta", + generation, entries: changed.map(({ entry }) => entry), ...(removedAgentIds.length > 0 ? { removedAgentIds } : {}), }); if (!delivered) return; + reporter.generation = generation; for (const { agentId, json, entry } of changed) { reporter.lastSent.set(agentId, { json, entry }); } for (const agentId of removedAgentIds) { + reporter.tombstones.set(agentId, generation); reporter.lastSent.delete(agentId); reporter.removedAgentIds.delete(agentId); } } + // The live supervisor claim is the single delivery authority; revoked sockets cannot satisfy it. private hasAuthenticatedSupervisorClient(): boolean { for (const client of this.clients) { - if (client.transport === "private-framed" && client.authenticated === true && !client.socket.destroyed) { + if (this.supervisorClaims.has(client) && !client.socket.destroyed) { return true; } } @@ -6737,7 +6767,7 @@ export class AgentDaemon { const payload = Buffer.from(serializeJsonLine(message)); let delivered = false; for (const client of this.clients) { - if (client.transport !== "private-framed" || client.authenticated !== true || client.socket.destroyed) { + if (!this.supervisorClaims.has(client) || client.socket.destroyed) { continue; } // A non-drained socket gets nothing; uncommitted state re-flushes on drain. @@ -7133,9 +7163,13 @@ interface WorkerRosterReporterState { lastSent: Map; /** Last composed roster, delivered or not; the source for passivated flips. */ lastComposed: Map; - /** Admitted child runs whose sessions have not materialized yet, keyed by childId. */ + /** Admitted child runs whose sessions have not materialized yet, keyed by agentId. */ queuedChildren: Map; removedAgentIds: Set; + /** Delivered removals by frame generation, replayed to supervisors that never consumed them. */ + tombstones: Map; + /** Monotonic frame counter, bumped per delivered frame. */ + generation: number; snapshotPending: boolean; } diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 21157ce3e5..25a084d0e6 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -314,6 +314,8 @@ interface ResidentWorker { rosterStale?: boolean; /** Bumped per consumed roster delta; a summaries refresh must not overwrite newer deltas. */ rosterDeltaGeneration?: number; + /** Last worker frame generation consumed; acked back on (re)auth for tombstone replay. */ + rosterAckGeneration?: number; } interface SnapshotDuplicateValidation { @@ -813,20 +815,24 @@ 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 ?? ""), - }; - }), + // The roster carries deltas newer than any discarded refresh response; eviction must see them. + sessions: this.workerRosterEntries(worker) + .filter((entry) => !entry.queuedChild) + .map(sessionSummaryFromRosterEntry) + .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 ?? ""), + }; + }), }; } @@ -2120,19 +2126,16 @@ export class DaemonSupervisor { if (owner?.client && !this.isWorkerStopping(owner)) { return this.forwardToWorker(owner, command); } - const result = await this.catalog.delete(command.sessionPath); - if (result.ok) { - if (entry?.summary.rlmChildId) { - await this.rlmSpawnLedger() - .appendDelete({ childId: entry.summary.rlmChildId, child: command.sessionPath, reason: "user" }) - .catch((error) => { - this.log( - `failed to append RLM ledger delete: ${error instanceof Error ? error.message : String(error)}`, - ); - }); - } - if (entry) this.roster().delete(entry.agentId); + // Tombstone first: a failed append aborts; a tombstoned-but-undeleted file is the accepted orphan of a failed delete. + if (entry?.summary.rlmChildId) { + await this.rlmSpawnLedger().appendDelete({ + childId: entry.summary.rlmChildId, + child: command.sessionPath, + reason: "user", + }); } + const result = await this.catalog.delete(command.sessionPath); + if (result.ok && entry) this.roster().delete(entry.agentId); return success(command.id, command.type, result); } break; @@ -2917,7 +2920,10 @@ export class DaemonSupervisor { client.onClose((error) => void this.handleWorkerClose(worker, client, error)); const authResponse = await client.authenticateWorker( worker.descriptor.authenticationToken, - this.supervisorAuthenticationClaim(), + { + ...this.supervisorAuthenticationClaim(), + ...(worker.rosterAckGeneration !== undefined ? { rosterGeneration: worker.rosterAckGeneration } : {}), + }, 1000, ); await this.assertRecoveryAllowed(); @@ -3500,7 +3506,7 @@ export class DaemonSupervisor { ); } - private async refreshWorkerSummaries(worker: ResidentWorker, recovery = false): Promise { + private async refreshWorkerSummaries(worker: ResidentWorker, recovery = false, retried = false): Promise { if (this.isWorkerStopping(worker)) { throw new Error("Session worker is stopping"); } @@ -3509,6 +3515,11 @@ export class DaemonSupervisor { } const deltaGenerationAtStart = worker.rosterDeltaGeneration ?? 0; const response = await worker.client.request({ type: "list" }, 5000); + // A delta that landed mid-refresh is newer than this response; discard it and retry once. + if ((worker.rosterDeltaGeneration ?? 0) !== deltaGenerationAtStart) { + if (!retried) return this.refreshWorkerSummaries(worker, recovery, true); + return; + } const summaries = sessionSummariesFromResponse(response); const nextSummaries = new Map(summaries.map((summary) => [summary.activeSessionId ?? summary.id, summary])); const root = nextSummaries.get(worker.descriptor.rootActiveSessionId); @@ -3524,10 +3535,7 @@ export class DaemonSupervisor { this.streamReconstructor.clear(activeSessionId); } } - // A delta that landed mid-refresh is newer than this list; it must not be overwritten. - if ((worker.rosterDeltaGeneration ?? 0) === deltaGenerationAtStart) { - this.syncWorkerSummariesIntoRoster(worker); - } + this.syncWorkerSummariesIntoRoster(worker); if (root) { if (recovery) { await this.assertRecoveryAllowed(); @@ -3672,6 +3680,7 @@ export class DaemonSupervisor { if (delta.type !== "roster_delta" || !Array.isArray(delta.entries)) return; worker.rosterCapable = true; worker.rosterDeltaGeneration = (worker.rosterDeltaGeneration ?? 0) + 1; + if (typeof delta.generation === "number") worker.rosterAckGeneration = delta.generation; if (delta.snapshot === true) { // Snapshot replacement: absent rows with a durable transcript passivate, sessionless rows go. const sent = new Set(delta.entries.map((entry) => entry.agentId)); 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 cd8587baeb..e5d570717a 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts @@ -18,7 +18,14 @@ export type DaemonWorkerLifecycle = "starting" | "ready" | "recovering" | "stopp // 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_delta"; + entries: WorkerRosterEntry[]; + removedAgentIds?: string[]; + snapshot?: true; + /** Monotonic per-worker frame counter; the supervisor acks it back on (re)auth. */ + generation?: number; + } | { type: "roster_heartbeat" }; /** Advertised by new workers in the worker_auth response; absent on legacy workers. */ @@ -66,6 +73,8 @@ export type DaemonWorkerCommand = supervisorPid: number; supervisorProcessStartId?: string; supervisorSocketPath: string; + /** Last consumed roster generation for this worker; absent for a fresh supervisor. */ + rosterGeneration?: number; } | { id?: string; diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index b1028073e7..38f35799cf 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { PassThrough } from "node:stream"; @@ -44,6 +44,8 @@ interface WorkerReporterFixture { lastComposed: Map; queuedChildren: Map; removedAgentIds: Set; + tombstones: Map; + generation: number; snapshotPending: boolean; }; }; @@ -63,6 +65,8 @@ function makeWorkerReporter(connected = true): WorkerReporterFixture { lastComposed: new Map(), queuedChildren: new Map(), removedAgentIds: new Set(), + tombstones: new Map(), + generation: 0, snapshotPending: false, }, rosterFlushScheduled: false, @@ -84,6 +88,7 @@ function makeState(options: { kind?: "top-level" | "subagent"; rlmChildId?: string; parentActiveSessionId?: string; + parentSessionFile?: string; messages?: AgentMessage[]; isStreaming?: boolean; }): ActiveSessionState { @@ -97,6 +102,7 @@ function makeState(options: { createdAt: 1, ...(options.rlmChildId ? { rlmChildId: options.rlmChildId } : {}), ...(options.parentActiveSessionId ? { parentActiveSessionId: options.parentActiveSessionId } : {}), + ...(options.parentSessionFile ? { parentSessionFile: options.parentSessionFile } : {}), }, diagnostics: [], session: { @@ -948,10 +954,12 @@ describe("worker saved-session deletion reaches the supervisor roster", () => { } as unknown as DaemonSocketClient; const internals = daemon as unknown as { clients: Set; + supervisorClaims: Map; handleCommand(client: DaemonSocketClient, command: object): Promise; flushRoster(): void; }; internals.clients.add(supervisorClient); + internals.supervisorClaims.set(supervisorClient, {}); await internals.handleCommand(supervisorClient, { type: "delete_saved_session", sessionPath }); internals.flushRoster(); @@ -1050,11 +1058,14 @@ describe("bot-round regressions", () => { sessions: new Map(), cronStore: { list: () => [] }, clients: new Set([client]), + supervisorClaims: new Map([[client, {}]]), rosterReporter: { lastSent: new Map(), lastComposed: new Map(), queuedChildren: new Map(), removedAgentIds: new Set(["deleted-agent"]), + tombstones: new Map(), + generation: 0, snapshotPending: false, }, rosterFlushScheduled: false, @@ -1236,3 +1247,230 @@ describe("bot-round regressions", () => { expect(supervisor.roster().get("s")?.summary.sessionName).toBe("fresh"); }); }); + +describe("bot-round two regressions", () => { + it("publishes qualified removal ids from the rlm subagent deletion path", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-rlm-delete-")); + tempDirs.push(directory); + const sessionsDir = join(directory, "sessions"); + const manager = SessionManager.create(directory, sessionsDir); + manager.appendMessage({ role: "user", content: "parent", timestamp: 1 }); + manager.flushNow(); + const parentFile = manager.getSessionFile(); + if (!parentFile) throw new Error("Fixture parent did not persist"); + const childDir = join(directory, "artifacts", "sub-1"); + mkdirSync(childDir, { recursive: true }); + const childFile = join(childDir, "child.jsonl"); + const daemon = new AgentDaemon(join(directory, "worker.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory, sessionDir: sessionsDir }, + worker: { authenticationToken: "token" }, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + } as never); + const internals = daemon as unknown as { + rlmSpawnLedger(): RlmSpawnLedger; + recordRlmSubagentDeletion(parentState: ActiveSessionState, childId: string): Promise; + rosterReporter: { removedAgentIds: Set }; + }; + await internals + .rlmSpawnLedger() + .appendSpawn({ childId: "sub-1", parent: parentFile, child: childFile, depth: 1, name: "child" }); + const parentState = makeState({ activeSessionId: "parent-active", sessionFile: parentFile }); + + await internals.recordRlmSubagentDeletion(parentState, "sub-1"); + + const expected = workerRosterEntryFromSummary( + summary({ + id: "sub-1", + sessionId: "sub-1", + runtimeKind: "subagent", + rlmChildId: "sub-1", + parentSessionPath: parentFile, + }), + ).agentId; + expect(internals.rosterReporter.removedAgentIds.has(expected)).toBe(true); + expect(internals.rosterReporter.removedAgentIds.has("sub-1")).toBe(false); + }); + + it("publishes qualified removal ids for discarded bound-child drafts", () => { + const { daemon, sentDeltas } = makeWorkerReporter(); + const childState = makeState({ + activeSessionId: "child-active", + kind: "subagent", + rlmChildId: "sub-1", + parentActiveSessionId: "parent-active", + parentSessionFile: "/tmp/parents/root.jsonl", + }); + daemon.sessions.set(childState.activeSessionId, childState); + daemon.flushRoster(); + const composedId = sentDeltas[0]?.entries.find((entry) => entry.summary.rlmChildId === "sub-1")?.agentId; + if (!composedId) throw new Error("Missing composed child row"); + + daemon.sessions.delete(childState.activeSessionId); + const removalId = ( + daemon as unknown as { rosterAgentIdForState(state: ActiveSessionState): string } + ).rosterAgentIdForState(childState); + daemon.rosterReporter.removedAgentIds.add(removalId); + daemon.flushRoster(); + + expect(removalId).toBe(composedId); + expect(sentDeltas.at(-1)?.removedAgentIds).toEqual([composedId]); + }); + + it("delivers only through the live supervisor claim, never a revoked socket", () => { + const oldWrite = vi.fn(() => true); + const newWrite = vi.fn(() => true); + const oldClient = { + transport: "private-framed", + authenticated: true, + backpressured: undefined as boolean | undefined, + socket: { destroyed: false, write: oldWrite }, + }; + const newClient = { + transport: "private-framed", + authenticated: true, + backpressured: true as boolean | undefined, + socket: { destroyed: false, write: newWrite }, + }; + const daemon = Object.assign(Object.create(AgentDaemon.prototype), { + options: { worker: { authenticationToken: "token" } }, + sessions: new Map(), + cronStore: { list: () => [] }, + clients: new Set([oldClient, newClient]), + supervisorClaims: new Map([[newClient, {}]]), + rosterReporter: { + lastSent: new Map(), + lastComposed: new Map(), + queuedChildren: new Map(), + removedAgentIds: new Set(["deleted-agent"]), + tombstones: new Map(), + generation: 0, + snapshotPending: true, + }, + rosterFlushScheduled: false, + shuttingDown: false, + log: vi.fn(), + }) as { + flushRoster(): void; + rosterReporter: { removedAgentIds: Set; snapshotPending: boolean }; + }; + + daemon.flushRoster(); + expect(oldWrite).not.toHaveBeenCalled(); + expect(daemon.rosterReporter.removedAgentIds.has("deleted-agent")).toBe(true); + expect(daemon.rosterReporter.snapshotPending).toBe(true); + + newClient.backpressured = false; + daemon.flushRoster(); + expect(oldWrite).not.toHaveBeenCalled(); + expect(newWrite).toHaveBeenCalledTimes(1); + expect(daemon.rosterReporter.snapshotPending).toBe(false); + }); + + it("replays delivered removals to supervisors behind the acked generation and prunes on ack", () => { + const { daemon, sentDeltas } = makeWorkerReporter(); + const state = makeState({ + activeSessionId: "root-active", + messages: [{ role: "user", content: "hi" } as unknown as AgentMessage], + }); + daemon.sessions.set(state.activeSessionId, state); + daemon.flushRoster(); + daemon.sessions.delete(state.activeSessionId); + daemon.rosterReporter.removedAgentIds.add("session-root-active"); + daemon.rosterReporter.lastComposed.clear(); + daemon.flushRoster(); + const removalGeneration = daemon.rosterReporter.tombstones.get("session-root-active"); + expect(removalGeneration).toBeGreaterThan(0); + + const internals = daemon as unknown as { prepareRosterSnapshot(acked: number): void }; + // A supervisor that never consumed the removal gets it replayed on the snapshot. + internals.prepareRosterSnapshot((removalGeneration ?? 1) - 1); + daemon.flushRoster(); + expect(sentDeltas.at(-1)?.snapshot).toBe(true); + expect(sentDeltas.at(-1)?.removedAgentIds).toContain("session-root-active"); + + // A supervisor that acked the removal prunes the tombstone; nothing replays. + internals.prepareRosterSnapshot(daemon.rosterReporter.generation); + daemon.flushRoster(); + expect(daemon.rosterReporter.tombstones.size).toBe(0); + expect(sentDeltas.at(-1)?.removedAgentIds).toBeUndefined(); + + // A fresh supervisor (ack 0) would have replayed everything; the tombstone map is already pruned. + internals.prepareRosterSnapshot(0); + daemon.flushRoster(); + expect(sentDeltas.at(-1)?.removedAgentIds).toBeUndefined(); + }); + + it("keeps a busy worker unevicted when the refresh response is staler than a delta", async () => { + const worker = makeWorker("worker-1", { rosterCapable: true }); + const supervisor = makeSupervisor([worker], { + refreshWorkerSummaries: DaemonSupervisor.prototype["refreshWorkerSummaries" as never], + syncWorkerSummariesIntoRoster: DaemonSupervisor.prototype["syncWorkerSummariesIntoRoster" as never], + streamReconstructor: { seed: vi.fn(), clear: vi.fn() }, + }); + const staleIdle = summary({ id: "s-active", sessionId: "s", activeSessionId: "s-active" }); + worker.client = { + request: vi.fn(async () => { + supervisor.consumeWorkerRosterDelta( + worker, + rosterDelta([ + workerRosterEntryFromSummary( + summary({ id: "s-active", sessionId: "s", activeSessionId: "s-active", isSessionActive: true }), + ), + ]), + ); + return { type: "response", command: "list", success: true, data: { sessions: [staleIdle] } }; + }), + }; + const internals = supervisor as unknown as { + refreshWorkerSummaries(worker: WorkerFixture): Promise; + workerEvictionSnapshot(worker: WorkerFixture): { sessions: Array<{ isSessionActive: boolean }> }; + }; + + await internals.refreshWorkerSummaries(worker); + + const snapshot = internals.workerEvictionSnapshot(worker); + expect(snapshot.sessions).toHaveLength(1); + expect(snapshot.sessions[0]?.isSessionActive).toBe(true); + }); + + it("aborts a saved-child delete when the tombstone append fails", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-tombstone-fail-")); + tempDirs.push(directory); + const childPath = join(directory, "artifacts", "child.jsonl"); + const catalogDelete = vi.fn(async () => ({ ok: true, method: "unlink" })); + const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory }, + descriptorDir: join(directory, "workers"), + }) as unknown as SupervisorFixture & { handleCommand(client: object, command: object): Promise }; + Object.assign(supervisor, { + catalog: { delete: catalogDelete, list: vi.fn(async () => []) }, + rlmSpawnLedger: () => ({ + 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(directory, "sessions", "root.jsonl"), + }), + ); + supervisor.writeRosterEntry(childEntry); + + await expect( + supervisor.handleCommand( + { id: "client", attachedActiveSessionIds: new Set() }, + { type: "delete_saved_session", sessionPath: childPath }, + ), + ).rejects.toThrow("ledger unwritable"); + expect(catalogDelete).not.toHaveBeenCalled(); + expect(supervisor.roster().has(childEntry.agentId)).toBe(true); + }); +}); From e7ca988e6236b13d26316f08422d37bb4e3e8734 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 29 Aug 2026 16:39:17 +0200 Subject: [PATCH 06/48] fix(coding-agent): child deletes never proceed past an unreadable spawn ledger Child-ness of a saved-session delete target now comes from worker-held state (the file-indexed composed roster entry or the transcript's parent metadata), never from a ledger read that can fail. For a child target an edges() rejection or a failed tombstone append aborts before file deletion with the error surfaced and no removal published; top-level targets never touch the spawn ledger. ENG-5794 --- .../src/modes/daemon/daemon-mode.ts | 37 ++++++---- .../test/daemon-agent-roster.test.ts | 71 ++++++++++++++++++- 2 files changed, 92 insertions(+), 16 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 0fb7375862..c4fcb4ee21 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -3915,18 +3915,25 @@ export class AgentDaemon { } const deletedPath = canonicalSessionPath(command.sessionPath); const deletedInfo = await readSessionInfo(command.sessionPath).catch(() => undefined); - const ledgerEdge = ( - await this.rlmSpawnLedger() - .edges() - .catch(() => []) - ).find((edge) => canonicalSessionPath(edge.child) === deletedPath); - // Tombstone first: a failed append aborts; a tombstoned-but-undeleted file is the accepted orphan of a failed delete. - if (ledgerEdge) { - await this.rlmSpawnLedger().appendDelete({ - childId: ledgerEdge.childId, - child: command.sessionPath, - reason: "user", - }); + const composedEntry = this.rosterEntryForSessionPath(deletedPath); + // Child-ness comes from worker-held state, never from a ledger read that can fail. + const isChild = + composedEntry?.summary.runtimeKind === "subagent" || + deletedInfo?.parentSessionPath !== undefined || + (deletedInfo?.rlmDepth ?? 0) > 0; + let ledgerEdge: RlmLedgerEdge | undefined; + if (isChild) { + // An unreadable ledger aborts a child deletion: without the tombstone the child reseeds. + const edges = await this.rlmSpawnLedger().edges(); + ledgerEdge = edges.find((edge) => canonicalSessionPath(edge.child) === deletedPath); + // Tombstone first: a failed append aborts; a tombstoned-but-undeleted file is the accepted orphan of a failed delete. + if (ledgerEdge) { + await this.rlmSpawnLedger().appendDelete({ + childId: ledgerEdge.childId, + child: command.sessionPath, + reason: "user", + }); + } } const result = await this.deleteSavedSessionFile(command.sessionPath, { afterFileRemoved: () => { @@ -3936,7 +3943,7 @@ export class AgentDaemon { // A file that still exists keeps its roster row; only a real deletion is published. if (result.ok) { const removedAgentId = - this.rosterAgentIdForSessionPath(deletedPath) ?? + composedEntry?.agentId ?? (ledgerEdge ? this.rosterAgentIdForRlmChild(ledgerEdge.childId, ledgerEdge.parent) : deletedInfo?.id); if (removedAgentId) { this.rosterReporter.removedAgentIds.add(removedAgentId); @@ -6556,10 +6563,10 @@ export class AgentDaemon { } } - private rosterAgentIdForSessionPath(canonicalPath: string): string | undefined { + 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.agentId; + return entry; } } return undefined; diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index 38f35799cf..3f0d63af46 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { PassThrough } from "node:stream"; @@ -1474,3 +1474,72 @@ describe("bot-round two regressions", () => { expect(supervisor.roster().has(childEntry.agentId)).toBe(true); }); }); + +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: Set }; + }; + } + + it("aborts a child delete when the spawn ledger cannot be read", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-ledger-read-fail-")); + tempDirs.push(directory); + const sessionsDir = join(directory, "sessions"); + const parentManager = SessionManager.create(directory, sessionsDir); + parentManager.appendMessage({ role: "user", content: "parent", timestamp: 1 }); + parentManager.flushNow(); + const parentFile = parentManager.getSessionFile(); + if (!parentFile) throw new Error("Fixture parent did not persist"); + const childManager = SessionManager.create(directory, join(directory, "artifacts")); + childManager.newSession({ parentSession: parentFile }); + childManager.appendMessage({ role: "user", content: "child", timestamp: 2 }); + childManager.flushNow(); + const childFile = childManager.getSessionFile(); + if (!childFile) throw new Error("Fixture child did not persist"); + const daemon = makeDeleteDaemon(directory, async () => { + throw new Error("ledger unreadable"); + }); + + await expect( + daemon.handleCommand( + { id: "client", attachedActiveSessionIds: new Set() }, + { type: "delete_saved_session", sessionPath: childFile }, + ), + ).rejects.toThrow("ledger unreadable"); + + expect(existsSync(childFile)).toBe(true); + expect(daemon.rosterReporter.removedAgentIds.size).toBe(0); + }); + + 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.size).toBe(1); + }); +}); From d98a28fc073907a0cae94ced37cbe6bb735c911b Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 29 Aug 2026 16:48:46 +0200 Subject: [PATCH 07/48] fix(coding-agent): classify unreadable delete targets through the spawn ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saved-session delete targets discriminate three ways: a readable no-parent transcript (or composed top-level row) is positively top-level and skips the spawn ledger; a positively-child target keeps the unguarded tombstone-first path; an UNKNOWN target (no composed row, unreadable or corrupt header — readSessionInfo's null is normalized so it cannot pass as readable) classifies via the ledger, where an edge means child, no edge means top-level, and a failed read aborts before deletion with no removal published. ENG-5794 --- .../src/modes/daemon/daemon-mode.ts | 11 +-- .../test/daemon-agent-roster.test.ts | 71 ++++++++++++++++++- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index c4fcb4ee21..b65c63f882 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -3914,16 +3914,17 @@ export class AgentDaemon { throw new Error("Cannot delete the currently active session"); } const deletedPath = canonicalSessionPath(command.sessionPath); - const deletedInfo = await readSessionInfo(command.sessionPath).catch(() => undefined); + const deletedInfo = (await readSessionInfo(command.sessionPath).catch(() => null)) ?? undefined; const composedEntry = this.rosterEntryForSessionPath(deletedPath); - // Child-ness comes from worker-held state, never from a ledger read that can fail. - const isChild = + // Worker-held state classifies first; only a readable no-parent transcript is positively top-level. + const knownChild = composedEntry?.summary.runtimeKind === "subagent" || deletedInfo?.parentSessionPath !== undefined || (deletedInfo?.rlmDepth ?? 0) > 0; + const positivelyTopLevel = !knownChild && (composedEntry !== undefined || deletedInfo !== undefined); let ledgerEdge: RlmLedgerEdge | undefined; - if (isChild) { - // An unreadable ledger aborts a child deletion: without the tombstone the child reseeds. + if (!positivelyTopLevel) { + // Children and unknown targets classify via the ledger; an unreadable ledger aborts, else the child reseeds. const edges = await this.rlmSpawnLedger().edges(); ledgerEdge = edges.find((edge) => canonicalSessionPath(edge.child) === deletedPath); // Tombstone first: a failed append aborts; a tombstoned-but-undeleted file is the accepted orphan of a failed delete. diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index 3f0d63af46..25c001dbd7 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -1,6 +1,6 @@ -import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { PassThrough } from "node:stream"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -1542,4 +1542,71 @@ describe("worker delete tombstone durability", () => { expect(existsSync(sessionPath)).toBe(false); expect(daemon.rosterReporter.removedAgentIds.size).toBe(1); }); + 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: Set }; + }; + 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([]); + expect(daemonWithEdge.rosterReporter.removedAgentIds.size).toBe(1); + + // (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); + }); }); From 7f7dd96bedf7ad76d8ad4b6c69c8933aea4e2300 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 29 Aug 2026 17:52:08 +0200 Subject: [PATCH 08/48] fix(coding-agent): roster bot-review fixes round three - Offline deletes honor descriptor-based ownership: a worker owning the file without a claimed roster row forwards when reachable and rejects with a retryable error when its socket is down, so a transcript is never deleted underneath a live owner. - The supervisor offline delete uses the worker path's three-way discrimination (positively top-level, positively child, unknown-via- ledger with abort on an unreadable read), so catalog-seeded children without rlmChildId and unreadable targets still tombstone first. - The list session-dir parent walk uses a visited-set cycle guard instead of a hop cap, staying correct at any depth. - Seeded artifact rows derive their session id from the transcript filename so persisted-session-id selectors resolve before any worker delta; edge.childId remains the child identifier. - Worker frames carry their source connection and are dropped when a superseded connection's buffer flushes after a reconnect. - list all treats the disk as authoritative for non-resident rows, propagates scan failures instead of shrinking the list, and preserves the newest-first catalog order with worker rows replacing their scanned files in place. ENG-5794 --- .../src/modes/daemon/daemon-supervisor.ts | 119 +++++++++++------ .../test/daemon-agent-roster.test.ts | 122 +++++++++++++++++- 2 files changed, 200 insertions(+), 41 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 25a084d0e6..a4d459b77b 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"; @@ -2117,22 +2117,44 @@ export class DaemonSupervisor { } case "delete_saved_session": if (!command.activeSessionId) { - const entry = this.roster().bySessionFile(canonicalSessionPath(command.sessionPath)); + 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 = entry?.workerId !== undefined ? this.workers.get(entry.workerId) : undefined; - // The owning worker deletes its own passivated files and publishes the removal itself. - if (owner?.client && !this.isWorkerStopping(owner)) { - return this.forwardToWorker(owner, command); + // Descriptor paths cover owners whose rows are not claimed yet (startup, adoption, recovery). + const owner = + (entry?.workerId !== undefined ? this.workers.get(entry.workerId) : undefined) ?? + this.findWorkerBySessionFile(command.sessionPath); + if (owner) { + // The owning worker deletes its own passivated files and publishes the removal itself. + if (owner.client && !this.isWorkerStopping(owner)) { + return this.forwardToWorker(owner, command); + } + // Never delete under a live-but-unreachable owner; the caller retries after recovery. + throw new Error( + `Session worker is ${this.effectiveWorkerState(owner)}; retry the delete once it is reachable`, + ); } - // Tombstone first: a failed append aborts; a tombstoned-but-undeleted file is the accepted orphan of a failed delete. - if (entry?.summary.rlmChildId) { - await this.rlmSpawnLedger().appendDelete({ - childId: entry.summary.rlmChildId, - child: command.sessionPath, - reason: "user", - }); + const deletedInfo = (await readSessionInfo(command.sessionPath).catch(() => null)) ?? undefined; + // Roster/disk state classifies first; only a readable no-parent transcript is positively top-level. + const knownChild = + entry?.summary.runtimeKind === "subagent" || + deletedInfo?.parentSessionPath !== undefined || + (deletedInfo?.rlmDepth ?? 0) > 0; + const positivelyTopLevel = !knownChild && (entry !== undefined || deletedInfo !== undefined); + if (!positivelyTopLevel) { + // Children and unknown targets classify via the ledger; an unreadable ledger aborts, else the child reseeds. + const edges = await this.rlmSpawnLedger().edges(); + const edge = edges.find((candidate) => canonicalSessionPath(candidate.child) === deletedPath); + // Tombstone first: a failed append aborts; a tombstoned-but-undeleted file is the accepted orphan of a failed delete. + if (edge) { + await this.rlmSpawnLedger().appendDelete({ + childId: edge.childId, + child: command.sessionPath, + reason: "user", + }); + } } const result = await this.catalog.delete(command.sessionPath); if (result.ok && entry) this.roster().delete(entry.agentId); @@ -2286,20 +2308,21 @@ export class DaemonSupervisor { command: Extract, ): Promise { const active: SessionSummary[] = []; - const workerOwnedFiles = new Set(); + const activeByFile = new Map(); let busyClientOwnedSessionCount = 0; for (const entry of this.roster().values()) { // Sessionless queued-child rows are ledger-internal; no list form serves them. if (entry.queuedChild) continue; const worker = entry.workerId !== undefined ? this.workers.get(entry.workerId) : undefined; if (worker === undefined) continue; - if (entry.summary.sessionFile) workerOwnedFiles.add(canonicalSessionPath(entry.summary.sessionFile)); const summary = this.publicSummary(worker, sessionSummaryFromRosterEntry(entry)); // Stopping workers stay listed with an honest workerState; daemon-launch busy checks read this list. 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); @@ -2312,34 +2335,44 @@ export class DaemonSupervisor { if (!command.all) { return success(command.id, "list", data); } - // Disk owns saved state (external processes write session files); the ledger wins for rows it knows. + // Disk is authoritative for non-resident rows; a failed scan must fail the list, not shrink it. const sessionDir = command.sessionDir ?? this.defaultSessionConfig.sessionDir; - const scanned = await this.catalog - .list(command.cwd ? resolve(command.cwd) : undefined, sessionDir) - .catch(() => []); + const scanned = await this.catalog.list(command.cwd ? resolve(command.cwd) : undefined, sessionDir); const cwd = command.cwd ? resolve(command.cwd) : undefined; - const saved: SessionSummary[] = []; - const savedFiles = new Set(); + // Worker rows replace their scanned files in place so the newest-first catalog order survives. + const merged: SessionSummary[] = []; + const mergedActiveFiles = new Set(); + const scannedFiles = new Set(); for (const info of scanned) { const file = canonicalSessionPath(info.path); - savedFiles.add(file); - if (workerOwnedFiles.has(file)) continue; - const entry = this.roster().bySessionFile(file); - saved.push(entry ? sessionSummaryFromRosterEntry(entry) : summaryForInactiveSession(info)); + scannedFiles.add(file); + const workerRow = activeByFile.get(file); + if (workerRow) { + if (active.includes(workerRow)) { + merged.push(workerRow); + mergedActiveFiles.add(file); + } + continue; + } + merged.push(summaryForInactiveSession(info)); } for (const entry of this.roster().values()) { // Ledger-only offline rows (artifact-dir children, flipped residents) ride along with the scan. 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 || savedFiles.has(file) || workerOwnedFiles.has(file)) continue; + if (file === undefined || scannedFiles.has(file) || activeByFile.has(file)) continue; const summary = sessionSummaryFromRosterEntry(await this.hydrateSeededEntry(entry)); if (cwd !== undefined && resolve(summary.cwd) !== cwd) continue; if (!this.matchesListSessionDir(summary, sessionDir)) continue; - saved.push(summary); + merged.push(summary); } - saved.sort((a, b) => Date.parse(b.modified ?? "") - Date.parse(a.modified ?? "")); - return success(command.id, "list", { ...data, sessions: [...saved, ...active] }); + 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 }); } // Seeded artifact-dir rows carry a synthetic cwd until their transcript header is read once. @@ -2361,10 +2394,14 @@ export class DaemonSupervisor { if (!summary.sessionFile) return false; let file = resolve(summary.sessionFile); let parentSessionPath = summary.parentSessionPath; - for (let hops = 0; parentSessionPath !== undefined && hops < 32; hops++) { + // A visited set terminates cycles without capping legitimate depth. + 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(canonicalSessionPath(parentSessionPath))?.summary - .parentSessionPath; + parentSessionPath = this.roster().bySessionFile(canonical)?.summary.parentSessionPath; } return dirname(file) === resolve(sessionDir); } @@ -2916,7 +2953,7 @@ export class DaemonSupervisor { await client.connect(Math.min(500, Math.max(50, deadline - Date.now()))); await client.waitForHello(1000); // Listen before authenticating: the worker flushes its roster snapshot right after auth succeeds. - client.onFrame((frame) => this.handleWorkerFrame(worker, frame)); + client.onFrame((frame) => this.handleWorkerFrame(worker, frame, client)); client.onClose((error) => void this.handleWorkerClose(worker, client, error)); const authResponse = await client.authenticateWorker( worker.descriptor.authenticationToken, @@ -3648,14 +3685,16 @@ export class DaemonSupervisor { } private rosterEntryForSpawnLedgerEdge(edge: RlmLedgerEdge): WorkerRosterEntry { + // The persisted session id is the transcript's filename; edge.childId stays the child identifier. + const persistedSessionId = basename(edge.child, ".jsonl"); const summary: WorkerRosterEntry["summary"] = { - id: edge.childId, + id: persistedSessionId, lifecycle: "live", activity: "idle", isSessionActive: false, runtimeKind: "subagent", rlmDepth: edge.depth, - sessionId: edge.childId, + sessionId: persistedSessionId, sessionFile: edge.child, sessionName: edge.name, // The ledger records topology only; display fields hydrate lazily on open. @@ -4573,10 +4612,18 @@ 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; } + // A superseded connection's buffered frames must not outlive its replacement. + if (source !== undefined && worker.client !== undefined && source !== worker.client) { + return; + } worker.lastFrameAt = Date.now(); this.clearRosterStaleness(worker); const { diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index 25c001dbd7..c4074a8fa9 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -1092,7 +1092,7 @@ describe("bot-round regressions", () => { expect(daemon.rosterReporter.removedAgentIds.size).toBe(0); }); - it("merges the per-call disk scan with ledger rows, preferring the ledger for known files", async () => { + 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: { @@ -1122,14 +1122,31 @@ describe("bot-round regressions", () => { }); supervisor.writeRosterEntry( workerRosterEntryFromSummary( - summary({ id: "known", sessionId: "known", sessionFile: "/tmp/known.jsonl", sessionName: "renamed" }), + 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 }); - const ids = listed.data?.sessions.map((session) => session.sessionId).sort(); - expect(ids).toEqual(["external", "known"]); - expect(listed.data?.sessions.find((session) => session.sessionId === "known")?.sessionName).toBe("renamed"); + // 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(); }); it("forwards passive-child deletes to the owning worker instead of rejecting them", async () => { @@ -1194,6 +1211,92 @@ describe("bot-round regressions", () => { expect(supervisor.roster().has("saved-1")).toBe(true); }); + it("rejects offline deletes owned via descriptor or an unreachable worker, forwards reachable owners", async () => { + const reachable = makeWorker("w-reach"); + Object.assign(reachable.descriptor, { sessionFile: "/tmp/owned-reach.jsonl", createCommand: { type: "create" } }); + reachable.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 catalogDelete = vi.fn(async () => ({ ok: true, method: "unlink" })); + const supervisor = makeSupervisor([reachable, unreachable], { + catalog: { delete: catalogDelete, list: vi.fn(async () => []) }, + mutationDrain: { begin: vi.fn(), end: vi.fn() }, + }); + const internals = supervisor as unknown as { handleCommand(client: object, command: object): Promise }; + const client = { id: "client", attachedActiveSessionIds: new Set() }; + + // Descriptor ownership with a live socket forwards, roster row or not. + await internals.handleCommand(client, { type: "delete_saved_session", sessionPath: "/tmp/owned-reach.jsonl" }); + expect(reachable.client.request).toHaveBeenCalledWith( + expect.objectContaining({ type: "delete_saved_session" }), + expect.any(Number), + ); + + // 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(); + }); + + it("classifies unknown offline delete targets through the ledger", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-offline-unknown-")); + tempDirs.push(directory); + const garbled = join(directory, "artifacts", "garbled.jsonl"); + mkdirSync(dirname(garbled), { recursive: true }); + writeFileSync(garbled, "not a session header\n"); + const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory }, + descriptorDir: join(directory, "workers"), + }) as unknown as SupervisorFixture & { + handleCommand(client: object, command: object): Promise; + rlmSpawnLedger(): RlmSpawnLedger; + }; + Object.assign(supervisor, { + catalog: { delete: vi.fn(async () => ({ ok: true, method: "unlink" })), list: vi.fn(async () => []) }, + }); + await supervisor.rlmSpawnLedger().appendSpawn({ + childId: "sub-9", + parent: join(directory, "sessions", "r.jsonl"), + child: garbled, + depth: 1, + name: "g", + }); + + await supervisor.handleCommand( + { id: "client", attachedActiveSessionIds: new Set() }, + { type: "delete_saved_session", sessionPath: garbled }, + ); + + await expect(supervisor.rlmSpawnLedger().edges()).resolves.toEqual([]); + }); + + it("ignores buffered roster frames from a superseded worker connection", () => { + const worker = makeWorker("worker-1", { rosterCapable: true }); + const supervisor = makeSupervisor([worker], { + streamReconstructor: { observe: vi.fn(), seed: vi.fn(), clear: vi.fn() }, + }); + const staleClient = { request: vi.fn() }; + const frame = { + header: { kind: "outbound", outboundType: "roster_delta" }, + payload: rosterDelta([workerRosterEntryFromSummary(summary({ id: "ghost", sessionId: "ghost" }))]), + }; + + (supervisor as unknown as { handleWorkerFrame(w: object, f: object, source?: object): void }).handleWorkerFrame( + worker, + frame, + staleClient, + ); + + expect(supervisor.roster().has("ghost")).toBe(false); + }); + it("routes a just-bound session through the miss-path refresh", async () => { const target = summary({ id: "target-active", sessionId: "target", activeSessionId: "target-active" }); const worker = makeWorker("worker-1", { rosterCapable: true }); @@ -1447,6 +1550,15 @@ describe("bot-round two regressions", () => { Object.assign(supervisor, { catalog: { delete: catalogDelete, list: vi.fn(async () => []) }, rlmSpawnLedger: () => ({ + edges: vi.fn(async () => [ + { + childId: "child-1", + child: childPath, + parent: join(directory, "sessions", "root.jsonl"), + depth: 1, + name: "c", + }, + ]), appendDelete: vi.fn(async () => { throw new Error("ledger unwritable"); }), From a43fdbb0ebf02742bdae7668b450b6ef57e7fea9 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 29 Aug 2026 18:04:29 +0200 Subject: [PATCH 09/48] fix(coding-agent): roster bot-review fixes round four - Worker frames accept exactly the current client and the in-flight replacement (worker.pendingClient, set before authentication and cleared in a finally on success or rollback), so a replacing connection's immediate snapshot is never discarded while the old client is still installed. - Offline deletes reclaim a dead failed registration through the existing reclaim machinery before proceeding; live or recovering owners keep the retryable rejection. - Depth-33 parent chains and self-cycles are pinned for session-dir scoping, and seeded artifact rows are pinned to resolve by their persisted transcript id when the filename differs from the child id. ENG-5794 --- .../src/modes/daemon/daemon-supervisor.ts | 51 ++++--- .../test/daemon-agent-roster.test.ts | 144 ++++++++++++++++++ 2 files changed, 175 insertions(+), 20 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index a4d459b77b..678d0d4341 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -312,6 +312,8 @@ interface ResidentWorker { lastFrameAt?: number; /** True while the watchdog has stamped this worker's entries as stale. */ rosterStale?: boolean; + /** In-flight replacement connection during authentication; an allowed frame source alongside client. */ + pendingClient?: DaemonWorkerClient; /** Bumped per consumed roster delta; a summaries refresh must not overwrite newer deltas. */ rosterDeltaGeneration?: number; /** Last worker frame generation consumed; acked back on (re)auth for tombstone replay. */ @@ -2131,10 +2133,12 @@ export class DaemonSupervisor { if (owner.client && !this.isWorkerStopping(owner)) { return this.forwardToWorker(owner, command); } - // Never delete under a live-but-unreachable owner; the caller retries after recovery. - throw new Error( - `Session worker is ${this.effectiveWorkerState(owner)}; retry the delete once it is reachable`, - ); + // A failed registration with a dead process is reclaimed; anything else retries after recovery. + if (!(await this.reclaimStaleWorkerRegistration(owner))) { + throw new Error( + `Session worker is ${this.effectiveWorkerState(owner)}; retry the delete once it is reachable`, + ); + } } const deletedInfo = (await readSessionInfo(command.sessionPath).catch(() => null)) ?? undefined; // Roster/disk state classifies first; only a readable no-parent transcript is positively top-level. @@ -2955,20 +2959,27 @@ export class DaemonSupervisor { // 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)); - const authResponse = await client.authenticateWorker( - worker.descriptor.authenticationToken, - { - ...this.supervisorAuthenticationClaim(), - ...(worker.rosterAckGeneration !== undefined ? { rosterGeneration: worker.rosterAckGeneration } : {}), - }, - 1000, - ); - await this.assertRecoveryAllowed(); - worker.rosterCapable = worker.rosterCapable === true || workerAuthAdvertisesRoster(authResponse.data); - worker.lastFrameAt = Date.now(); - worker.client?.close(); - worker.client = client; - return client; + worker.pendingClient = client; + try { + const authResponse = await client.authenticateWorker( + worker.descriptor.authenticationToken, + { + ...this.supervisorAuthenticationClaim(), + ...(worker.rosterAckGeneration !== undefined + ? { rosterGeneration: worker.rosterAckGeneration } + : {}), + }, + 1000, + ); + await this.assertRecoveryAllowed(); + worker.rosterCapable = worker.rosterCapable === true || workerAuthAdvertisesRoster(authResponse.data); + 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(); @@ -4620,8 +4631,8 @@ export class DaemonSupervisor { if (frame.header.kind !== "outbound") { return; } - // A superseded connection's buffered frames must not outlive its replacement. - if (source !== undefined && worker.client !== undefined && source !== worker.client) { + // Exactly the current client and the in-flight replacement are trusted sources. + if (source !== undefined && source !== worker.client && source !== worker.pendingClient) { return; } worker.lastFrameAt = Date.now(); diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index c4074a8fa9..f67c60ea79 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -1297,6 +1297,150 @@ describe("bot-round regressions", () => { expect(supervisor.roster().has("ghost")).toBe(false); }); + it("accepts frames from the in-flight replacement connection and drops rolled-back sources", () => { + const worker = makeWorker("worker-1", { rosterCapable: true }); + const supervisor = makeSupervisor([worker], { + streamReconstructor: { observe: vi.fn(), seed: vi.fn(), clear: vi.fn() }, + }); + const replacement = { request: vi.fn() }; + const frame = (sessionId: string) => ({ + header: { kind: "outbound", outboundType: "roster_delta" }, + payload: rosterDelta([workerRosterEntryFromSummary(summary({ id: sessionId, sessionId }))]), + }); + const internals = supervisor as unknown as { + handleWorkerFrame(w: object, f: object, source?: object): void; + }; + + (worker as unknown as { pendingClient?: object }).pendingClient = replacement; + internals.handleWorkerFrame(worker, frame("mid-auth"), replacement); + expect(supervisor.roster().has("mid-auth")).toBe(true); + + // Failed auth rolls the pending source back; its buffered frames are dropped. + (worker as unknown as { pendingClient?: object }).pendingClient = undefined; + internals.handleWorkerFrame(worker, frame("rolled-back"), replacement); + expect(supervisor.roster().has("rolled-back")).toBe(false); + }); + + it("reclaims a dead failed owner before an offline delete but keeps recovering owners rejecting", async () => { + const failed = makeWorker("w-failed"); + Object.assign(failed.descriptor, { + sessionFile: "/tmp/owned-failed.jsonl", + createCommand: { type: "create" }, + lifecycle: "failed", + }); + failed.client = undefined; + const recovering = makeWorker("w-recovering"); + Object.assign(recovering.descriptor, { + sessionFile: "/tmp/owned-recovering.jsonl", + createCommand: { type: "create" }, + lifecycle: "recovering", + }); + recovering.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([failed, recovering], { + catalog: { delete: catalogDelete, list: vi.fn(async () => []) }, + mutationDrain: { begin: vi.fn(), end: vi.fn() }, + reclaimStaleWorkerRegistration, + rlmSpawnLedger: () => ({ edges: vi.fn(async () => []) }), + }); + const internals = supervisor as unknown as { handleCommand(client: object, command: object): Promise }; + const client = { id: "client", attachedActiveSessionIds: new Set() }; + + await internals.handleCommand(client, { type: "delete_saved_session", sessionPath: "/tmp/owned-failed.jsonl" }); + expect(reclaimStaleWorkerRegistration).toHaveBeenCalledWith(failed); + expect(catalogDelete).toHaveBeenCalledWith("/tmp/owned-failed.jsonl"); + + await expect( + internals.handleCommand(client, { type: "delete_saved_session", sessionPath: "/tmp/owned-recovering.jsonl" }), + ).rejects.toThrow(/retry the delete/); + }); + + it("walks parent chains beyond thirty-two hops and terminates on cycles", async () => { + const supervisor = makeSupervisor([]); + const base = "/tmp/deep-home"; + const dir = join(base, "sessions"); + let parentPath = join(dir, "root.jsonl"); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary(summary({ id: "root", sessionId: "root", sessionFile: parentPath })), + ); + for (let depth = 1; depth <= 33; depth++) { + const childPath = join(base, "session-artifacts", `d${depth}.jsonl`); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: `d${depth}`, + sessionId: `d${depth}`, + sessionFile: childPath, + runtimeKind: "subagent", + rlmChildId: `d${depth}`, + rlmDepth: depth, + parentSessionPath: parentPath, + }), + ), + ); + parentPath = childPath; + } + const listed = await supervisor.handleList({}, { type: "list", all: true, sessionDir: dir }); + expect(listed.data?.sessions.some((session) => session.sessionId === "d33")).toBe(true); + + // A cycle terminates instead of hanging; the cyclic row simply does not match the dir. + const cyclic = makeSupervisor([]); + cyclic.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ + id: "loop", + sessionId: "loop", + sessionFile: join(base, "session-artifacts", "loop.jsonl"), + runtimeKind: "subagent", + rlmChildId: "loop", + rlmDepth: 1, + parentSessionPath: join(base, "session-artifacts", "loop.jsonl"), + }), + ), + ); + const cyclicListed = await cyclic.handleList({}, { type: "list", all: true, sessionDir: dir }); + expect(cyclicListed.data?.sessions.some((session) => session.sessionId === "loop")).toBe(false); + }); + + it("resolves seeded artifact children by their persisted session id before any delta", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-seed-id-")); + tempDirs.push(directory); + const sessionsDir = join(directory, "sessions"); + const ledger = new RlmSpawnLedger(directory, sessionsDir); + const persistedId = "0a1b2c3d4e5f0a1b2c3d4e5f"; + const childPath = join(directory, "artifacts", `${persistedId}.jsonl`); + await ledger.appendSpawn({ + childId: "sub-abc", + parent: join(sessionsDir, "root.jsonl"), + child: childPath, + depth: 1, + name: "child", + }); + const worker = makeWorker("worker-1"); + const supervisor = makeSupervisor([worker], { + rlmSpawnLedger: () => ledger, + catalog: { list: vi.fn(async () => []) }, + }); + await supervisor.seedRosterLedger(); + // Claim the seeded row for a worker so selector matching can route to it. + const seeded = [...supervisor.roster().values()][0]; + if (!seeded) throw new Error("Missing seeded row"); + supervisor.writeRosterEntry(seeded, worker); + const internals = supervisor as unknown as { + findWorker(selector: string): Promise<{ summary: SessionSummary }>; + }; + + const match = await internals.findWorker(persistedId); + expect(match.summary.rlmChildId).toBe("sub-abc"); + }); + it("routes a just-bound session through the miss-path refresh", async () => { const target = summary({ id: "target-active", sessionId: "target", activeSessionId: "target-active" }); const worker = makeWorker("worker-1", { rosterCapable: true }); From ad7cdd4f7e725911c6271df029ba9fdc04d58a4e Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 29 Aug 2026 19:12:18 +0200 Subject: [PATCH 10/48] refactor(coding-agent): drop the lossless roster channel; disk is durable truth - Deltas become best-effort freshness hints: any undelivered, refused, or backpressured write just marks a pending snapshot, and one full replacing snapshot flows on (re)connect or drain. Generation counters, delivered-commit bookkeeping, tombstone retention with ack and prune, and the worker_auth rosterGeneration ack all go away. - The supervisor applies a snapshot atomically: it replaces the worker's rows, deletes absent rows outright, then reseeds subagent families from the spawn ledger with tombstoned edges filtered out. Tombstone-first delete classification stays on both delete paths. - Undelivered removal ids stay pending and ride the first delivered frame, so removals of unattributed rows survive backpressure. - The startup catalog seed goes away; list all, name checks, and worker matching already read disk per call, so only the spawn-ledger seed remains. - The roster test suite consolidates into lifecycle, delivery, delete- path, and regression groups: one queued-child lifecycle scenario, one snapshot escalation pin, one snapshot-replace-and-reseed pin, and an ownership-routing table replace the per-round accretions; the depth-33 walk pin drops with the machinery it guarded. ENG-5794 --- .../src/modes/daemon/daemon-mode.ts | 82 +- .../src/modes/daemon/daemon-supervisor.ts | 28 +- .../modes/daemon/daemon-worker-protocol.ts | 4 - .../test/daemon-agent-roster.test.ts | 925 +++++++----------- 4 files changed, 359 insertions(+), 680 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index b65c63f882..e57f90c681 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -551,12 +551,9 @@ export class AgentDaemon { ); private readonly recoveryJournal?: WorkerRecoveryJournal; private readonly rosterReporter: WorkerRosterReporterState = { - lastSent: new Map(), lastComposed: new Map(), queuedChildren: new Map(), removedAgentIds: new Set(), - tombstones: new Map(), - generation: 0, snapshotPending: false, }; private rosterFlushScheduled = false; @@ -3289,7 +3286,6 @@ export class AgentDaemon { supervisorPid?: unknown; supervisorProcessStartId?: unknown; supervisorSocketPath?: unknown; - rosterGeneration?: unknown; activeSessionId?: unknown; admissionId?: unknown; capabilities?: unknown; @@ -3322,7 +3318,6 @@ export class AgentDaemon { !Number.isInteger(parsed.supervisorPid) || (parsed.supervisorPid as number) <= 0 || (parsed.supervisorProcessStartId !== undefined && typeof parsed.supervisorProcessStartId !== "string") || - (parsed.rosterGeneration !== undefined && typeof parsed.rosterGeneration !== "number") || typeof parsed.supervisorSocketPath !== "string" ) { clearParsedAdmission(); @@ -3363,8 +3358,8 @@ export class AgentDaemon { success: true, data: { capabilities: [DAEMON_WORKER_ROSTER_CAPABILITY] }, }); - // A (re)connected supervisor replays from its acked generation; the next flush sends a replacing snapshot. - this.prepareRosterSnapshot(typeof parsed.rosterGeneration === "number" ? parsed.rosterGeneration : 0); + // A (re)connected supervisor gets one full replacing snapshot; disk is the durable truth behind it. + this.rosterReporter.snapshotPending = true; this.scheduleRosterFlush(); return; } @@ -6660,14 +6655,6 @@ export class AgentDaemon { return { agentId: rosterAgentIdForSummary(summary), queuedChild: true, summary }; } - private prepareRosterSnapshot(ackedGeneration: number): void { - const reporter = this.rosterReporter; - for (const [agentId, generation] of reporter.tombstones) { - if (generation <= ackedGeneration) reporter.tombstones.delete(agentId); - } - reporter.snapshotPending = true; - } - private scheduleRosterFlush(): void { if (!this.options.worker || this.rosterFlushScheduled || this.shuttingDown) return; this.rosterFlushScheduled = true; @@ -6706,59 +6693,41 @@ export class AgentDaemon { entries.set(agentId, passivatedWorkerRosterEntry(previous)); } } - // An agent recreated after deletion outlives its old tombstone. - for (const agentId of entries.keys()) { - reporter.tombstones.delete(agentId); + // Deltas are best-effort freshness hints; any miss escalates to one full replacing snapshot. + const changed: WorkerRosterEntry[] = []; + for (const entry of entries.values()) { + if (JSON.stringify(reporter.lastComposed.get(entry.agentId) ?? null) !== JSON.stringify(entry)) { + changed.push(entry); + } } - reporter.lastComposed = new Map(entries); - if (!this.hasAuthenticatedSupervisorClient()) return; const removedAgentIds = [...reporter.removedAgentIds]; - const generation = reporter.generation + 1; + reporter.lastComposed = new Map(entries); + if (!this.hasAuthenticatedSupervisorClient()) { + // Undelivered removals stay pending; they ride the first delivered frame. + if (changed.length > 0 || removedAgentIds.length > 0) reporter.snapshotPending = true; + return; + } if (reporter.snapshotPending) { - const replayedRemovals = [...new Set([...removedAgentIds, ...reporter.tombstones.keys()])]; const delivered = this.broadcastRosterFrame({ type: "roster_delta", snapshot: true, - generation, entries: [...entries.values()], - ...(replayedRemovals.length > 0 ? { removedAgentIds: replayedRemovals } : {}), + ...(removedAgentIds.length > 0 ? { removedAgentIds } : {}), }); - if (!delivered) return; - reporter.generation = generation; - reporter.snapshotPending = false; - for (const agentId of removedAgentIds) { - reporter.tombstones.set(agentId, generation); - reporter.removedAgentIds.delete(agentId); - } - reporter.lastSent.clear(); - for (const [agentId, entry] of entries) { - reporter.lastSent.set(agentId, { json: JSON.stringify(entry), entry }); + if (delivered) { + reporter.snapshotPending = false; + reporter.removedAgentIds.clear(); } return; } - const changed: Array<{ agentId: string; json: string; entry: WorkerRosterEntry }> = []; - for (const [agentId, entry] of entries) { - const json = JSON.stringify(entry); - if (reporter.lastSent.get(agentId)?.json === json) continue; - changed.push({ agentId, json, entry }); - } if (changed.length === 0 && removedAgentIds.length === 0) return; const delivered = this.broadcastRosterFrame({ type: "roster_delta", - generation, - entries: changed.map(({ entry }) => entry), + entries: changed, ...(removedAgentIds.length > 0 ? { removedAgentIds } : {}), }); - if (!delivered) return; - reporter.generation = generation; - for (const { agentId, json, entry } of changed) { - reporter.lastSent.set(agentId, { json, entry }); - } - for (const agentId of removedAgentIds) { - reporter.tombstones.set(agentId, generation); - reporter.lastSent.delete(agentId); - reporter.removedAgentIds.delete(agentId); - } + if (delivered) reporter.removedAgentIds.clear(); + else reporter.snapshotPending = true; } // The live supervisor claim is the single delivery authority; revoked sockets cannot satisfy it. @@ -7167,17 +7136,12 @@ export class AgentDaemon { const ROSTER_HEARTBEAT_INTERVAL_MS = 15_000; interface WorkerRosterReporterState { - /** Last entry sent per agentId; deltas go out only on change. */ - lastSent: Map; - /** Last composed roster, delivered or not; the source for passivated flips. */ + /** Last composed roster, delivered or not; the source for passivated flips and change hints. */ lastComposed: Map; /** Admitted child runs whose sessions have not materialized yet, keyed by agentId. */ queuedChildren: Map; removedAgentIds: Set; - /** Delivered removals by frame generation, replayed to supervisors that never consumed them. */ - tombstones: Map; - /** Monotonic frame counter, bumped per delivered frame. */ - generation: number; + /** Set on any undelivered change; the next flush sends one full replacing snapshot. */ snapshotPending: boolean; } diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 678d0d4341..b40c503571 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -316,8 +316,6 @@ interface ResidentWorker { pendingClient?: DaemonWorkerClient; /** Bumped per consumed roster delta; a summaries refresh must not overwrite newer deltas. */ rosterDeltaGeneration?: number; - /** Last worker frame generation consumed; acked back on (re)auth for tombstone replay. */ - rosterAckGeneration?: number; } interface SnapshotDuplicateValidation { @@ -2963,12 +2961,7 @@ export class DaemonSupervisor { try { const authResponse = await client.authenticateWorker( worker.descriptor.authenticationToken, - { - ...this.supervisorAuthenticationClaim(), - ...(worker.rosterAckGeneration !== undefined - ? { rosterGeneration: worker.rosterAckGeneration } - : {}), - }, + this.supervisorAuthenticationClaim(), 1000, ); await this.assertRecoveryAllowed(); @@ -3675,15 +3668,7 @@ export class DaemonSupervisor { // Seeds selector resolution, name checks, and liveness; list all rescans the disk per call. 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 { - // Ledger edges cover subagents in artifact dirs the catalog never scans; tombstones stay out. + // Ledger edges cover subagents the catalog never scans; top-level rows read disk per call instead. for (const edge of await this.rlmSpawnLedger().edges()) { const entry = this.rosterEntryForSpawnLedgerEdge(edge); if (this.roster().has(entry.agentId)) continue; @@ -3730,14 +3715,11 @@ export class DaemonSupervisor { if (delta.type !== "roster_delta" || !Array.isArray(delta.entries)) return; worker.rosterCapable = true; worker.rosterDeltaGeneration = (worker.rosterDeltaGeneration ?? 0) + 1; - if (typeof delta.generation === "number") worker.rosterAckGeneration = delta.generation; if (delta.snapshot === true) { - // Snapshot replacement: absent rows with a durable transcript passivate, sessionless rows go. + // A live worker's snapshot carries its passivated rows too; absence means removal, disk backs the rest. const sent = new Set(delta.entries.map((entry) => entry.agentId)); for (const entry of this.workerRosterEntries(worker)) { - if (sent.has(entry.agentId)) continue; - if (entry.summary.sessionFile) this.writeRosterEntry(passivatedWorkerRosterEntry(entry), worker); - else this.roster().delete(entry.agentId); + if (!sent.has(entry.agentId)) this.roster().delete(entry.agentId); } } for (const entry of delta.entries) { @@ -3747,6 +3729,8 @@ export class DaemonSupervisor { for (const agentId of delta.removedAgentIds ?? []) { this.roster().delete(agentId); } + // Deleted absentees with surviving transcripts reseed from the spawn ledger, tombstone-filtered. + if (delta.snapshot === true) void this.seedRosterLedger(); } /** Root roster deltas maintain the persisted descriptor pointers (rootSessionId, sessionFile). */ 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 e5d570717a..0f7e07f089 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts @@ -23,8 +23,6 @@ export type DaemonWorkerRosterOutbound = entries: WorkerRosterEntry[]; removedAgentIds?: string[]; snapshot?: true; - /** Monotonic per-worker frame counter; the supervisor acks it back on (re)auth. */ - generation?: number; } | { type: "roster_heartbeat" }; @@ -73,8 +71,6 @@ export type DaemonWorkerCommand = supervisorPid: number; supervisorProcessStartId?: string; supervisorSocketPath: string; - /** Last consumed roster generation for this worker; absent for a fresh supervisor. */ - rosterGeneration?: number; } | { id?: string; diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index f67c60ea79..0125f6367e 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -40,12 +40,9 @@ interface WorkerReporterFixture { observeRosterEvent(state: ActiveSessionState, message: unknown): void; flushRoster(): void; rosterReporter: { - lastSent: Map; lastComposed: Map; queuedChildren: Map; removedAgentIds: Set; - tombstones: Map; - generation: number; snapshotPending: boolean; }; }; @@ -61,12 +58,9 @@ function makeWorkerReporter(connected = true): WorkerReporterFixture { sessions: new Map(), cronStore: { list: () => [] }, rosterReporter: { - lastSent: new Map(), lastComposed: new Map(), queuedChildren: new Map(), removedAgentIds: new Set(), - tombstones: new Map(), - generation: 0, snapshotPending: false, }, rosterFlushScheduled: false, @@ -143,7 +137,7 @@ function childUpdate(state: ActiveSessionState, child: Record) } describe("worker roster reporter", () => { - it("publishes an admitted child run before its session exists and merges it on session bind", () => { + 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); @@ -153,10 +147,7 @@ describe("worker roster reporter", () => { childUpdate(parent, { id: "child-1", label: "review the API", status: "queued", sessionDir: "/tmp/c" }), ); daemon.flushRoster(); - - const queued = sentDeltas[0]?.entries.find((entry) => entry.agentId === "child-1"); - expect(queued).toMatchObject({ - agentId: "child-1", + expect(sentDeltas[0]?.entries.find((entry) => entry.agentId === "child-1")).toMatchObject({ queuedChild: true, summary: { runtimeKind: "subagent", parentActiveSessionId: "parent-active", firstMessage: "review the API" }, }); @@ -180,42 +171,61 @@ describe("worker roster reporter", () => { }), ); daemon.flushRoster(); - const merged = sentDeltas.at(-1)?.entries.filter((entry) => entry.agentId === "child-1") ?? []; expect(merged).toHaveLength(1); expect(merged[0]).toMatchObject({ summary: { activeSessionId: "child-active", lifecycle: "live" } }); expect(merged[0]?.queuedChild).toBeUndefined(); - }); - it("keeps a superseded child run out of the roster after its session closes", () => { - const { daemon, sentDeltas } = makeWorkerReporter(); - const parent = makeState({ activeSessionId: "parent-active" }); - daemon.sessions.set(parent.activeSessionId, parent); + // Crafted without activeSessionId: the lifecycle guard, not event stamping, must reject the late update. daemon.observeRosterEvent( parent, childUpdate(parent, { id: "child-1", label: "task", status: "queued", sessionDir: "/tmp/c" }), ); - 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.sessions.delete(childState.activeSessionId); + daemon.flushRoster(); + const superseded = sentDeltas.at(-1)?.entries.find((entry) => entry.agentId === "child-1"); + expect(superseded?.queuedChild).toBeUndefined(); + expect(superseded?.summary.id).toBe("session-child-active"); + 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-1", label: "task", status: "running", activeSessionId: "child-active" }), + 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(["child-2"]); + daemon.rosterReporter.snapshotPending = true; + daemon.flushRoster(); + expect(sentDeltas.at(-1)?.snapshot).toBe(true); + expect(sentDeltas.at(-1)?.entries.some((entry) => entry.agentId === "child-2")).toBe(false); + }); - daemon.sessions.delete(childState.activeSessionId); + it("qualifies colliding child ids from different parents by parent path", () => { + const { daemon, sentDeltas } = makeWorkerReporter(); + const parentA = makeState({ activeSessionId: "parent-a", sessionFile: "/tmp/a.jsonl" }); + const parentB = makeState({ activeSessionId: "parent-b", sessionFile: "/tmp/b.jsonl" }); + daemon.sessions.set(parentA.activeSessionId, parentA); + daemon.sessions.set(parentB.activeSessionId, parentB); + + daemon.observeRosterEvent( + parentA, + childUpdate(parentA, { id: "sub-1234", label: "a", status: "queued", sessionDir: "/tmp/a" }), + ); + daemon.observeRosterEvent( + parentB, + childUpdate(parentB, { id: "sub-1234", label: "b", status: "queued", sessionDir: "/tmp/b" }), + ); daemon.flushRoster(); - const final = sentDeltas.at(-1)?.entries.find((entry) => entry.agentId === "child-1"); - expect(final?.queuedChild).toBeUndefined(); - expect(final?.summary.activeSessionId).toBeUndefined(); - expect(final?.summary.id).toBe("session-child-active"); + const queuedRows = sentDeltas.at(-1)?.entries.filter((entry) => entry.summary.rlmChildId === "sub-1234") ?? []; + expect(queuedRows).toHaveLength(2); + expect(new Set(queuedRows.map((entry) => entry.agentId)).size).toBe(2); }); it("sends deltas only on change and flips closed sessions to non-resident instead of dropping them", () => { @@ -238,43 +248,7 @@ describe("worker roster reporter", () => { expect(sentDeltas.at(-1)?.removedAgentIds).toBeUndefined(); }); - it("retains pending state until a frame reaches an authenticated supervisor", () => { - const { daemon, sentDeltas } = makeWorkerReporter(false); - const state = makeState({ activeSessionId: "root-active" }); - daemon.sessions.set(state.activeSessionId, state); - daemon.rosterReporter.removedAgentIds.add("gone-agent"); - - daemon.flushRoster(); - - expect(sentDeltas).toHaveLength(0); - expect(daemon.rosterReporter.lastSent.size).toBe(0); - expect(daemon.rosterReporter.removedAgentIds.has("gone-agent")).toBe(true); - }); - - it("sends a replacing snapshot after supervisor (re)authentication that carries pending removals", () => { - const { daemon, sentDeltas } = makeWorkerReporter(); - const state = makeState({ - activeSessionId: "root-active", - messages: [{ role: "user", content: "hi" } as unknown as AgentMessage], - }); - daemon.sessions.set(state.activeSessionId, state); - daemon.rosterReporter.removedAgentIds.add("deleted-agent"); - daemon.rosterReporter.snapshotPending = true; - - daemon.flushRoster(); - - expect(sentDeltas).toHaveLength(1); - expect(sentDeltas[0]?.snapshot).toBe(true); - expect(sentDeltas[0]?.removedAgentIds).toEqual(["deleted-agent"]); - expect(sentDeltas[0]?.entries.map((entry) => entry.agentId)).toEqual(["session-root-active"]); - expect(daemon.rosterReporter.snapshotPending).toBe(false); - expect(daemon.rosterReporter.removedAgentIds.size).toBe(0); - - daemon.flushRoster(); - expect(sentDeltas).toHaveLength(1); - }); - - it("keeps a session that lived and died while disconnected as a durable row in the reauth snapshot", () => { + it("escalates undelivered changes to one replacing snapshot", () => { const { daemon, sentDeltas, connection } = makeWorkerReporter(); const parent = makeState({ activeSessionId: "parent-active" }); daemon.sessions.set(parent.activeSessionId, parent); @@ -283,7 +257,9 @@ describe("worker roster reporter", () => { childUpdate(parent, { id: "child-1", label: "task", status: "queued", sessionDir: "/tmp/c" }), ); daemon.flushRoster(); + const sentWhileConnected = sentDeltas.length; + // The channel drops; the child binds and dies while disconnected. connection.connected = false; const childState = makeState({ activeSessionId: "child-active", @@ -298,46 +274,75 @@ describe("worker roster reporter", () => { childUpdate(parent, { id: "child-1", label: "task", status: "running", activeSessionId: "child-active" }), ); daemon.flushRoster(); + expect(sentDeltas.length).toBe(sentWhileConnected); + expect(daemon.rosterReporter.snapshotPending).toBe(true); daemon.sessions.delete(childState.activeSessionId); daemon.flushRoster(); + // Reauthentication: one full replacing snapshot carries the durable row; absence conveys removals. connection.connected = true; - daemon.rosterReporter.snapshotPending = true; daemon.flushRoster(); - + expect(sentDeltas.length).toBe(sentWhileConnected + 1); const snapshot = sentDeltas.at(-1); expect(snapshot?.snapshot).toBe(true); + expect(snapshot?.removedAgentIds).toBeUndefined(); const childRow = snapshot?.entries.find((entry) => entry.agentId === "child-1"); expect(childRow?.queuedChild).toBeUndefined(); expect(childRow?.summary.id).toBe("session-child-active"); expect(childRow?.summary.activeSessionId).toBeUndefined(); + + daemon.flushRoster(); + expect(sentDeltas.length).toBe(sentWhileConnected + 1); }); - it("ignores a late queued update for a child whose session is already bound", () => { - const { daemon, sentDeltas } = makeWorkerReporter(); - const parent = makeState({ activeSessionId: "parent-active" }); - daemon.sessions.set(parent.activeSessionId, parent); - 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); + it("delivers only through the live drained supervisor claim and escalates refusals to a snapshot", () => { + const oldWrite = vi.fn(() => true); + const write = vi.fn(() => false); + const oldClient = { + transport: "private-framed", + authenticated: true, + backpressured: undefined as boolean | undefined, + socket: { destroyed: false, write: oldWrite }, + }; + const client = { + transport: "private-framed", + authenticated: true, + backpressured: undefined as boolean | undefined, + socket: { destroyed: false, write }, + }; + const daemon = Object.assign(Object.create(AgentDaemon.prototype), { + options: { worker: { authenticationToken: "token" } }, + sessions: new Map(), + cronStore: { list: () => [] }, + clients: new Set([oldClient, client]), + supervisorClaims: new Map([[client, {}]]), + rosterReporter: { + lastComposed: new Map(), + queuedChildren: new Map(), + removedAgentIds: new Set(["deleted-agent"]), + snapshotPending: false, + }, + rosterFlushScheduled: false, + shuttingDown: false, + log: vi.fn(), + }) as { flushRoster(): void; rosterReporter: { snapshotPending: boolean } }; + daemon.flushRoster(); + // The refused write commits nothing; the claimed socket is backpressured and a snapshot is owed. + expect(oldWrite).not.toHaveBeenCalled(); + expect(client.backpressured).toBe(true); + expect(daemon.rosterReporter.snapshotPending).toBe(true); - // Crafted without activeSessionId: the lifecycle guard, not event stamping, must reject it. - daemon.observeRosterEvent( - parent, - childUpdate(parent, { id: "child-1", label: "task", status: "queued", sessionDir: "/tmp/c" }), - ); - daemon.sessions.delete(childState.activeSessionId); + // A backpressured socket gets no further writes until it drains. daemon.flushRoster(); + expect(write).toHaveBeenCalledTimes(1); - const final = sentDeltas.at(-1)?.entries.find((entry) => entry.agentId === "child-1"); - expect(final?.queuedChild).toBeUndefined(); - expect(final?.summary.id).toBe("session-child-active"); + client.backpressured = false; + write.mockReturnValue(true); + daemon.flushRoster(); + expect(write).toHaveBeenCalledTimes(2); + expect(daemon.rosterReporter.snapshotPending).toBe(false); + expect(oldWrite).not.toHaveBeenCalled(); }); }); @@ -569,9 +574,31 @@ describe("supervisor roster ledger", () => { expect(supervisor.refreshWorkerSummaries).not.toHaveBeenCalled(); }); - it("replaces a worker's rows from a snapshot frame", () => { + 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"); + 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" }); const worker = makeWorker("worker-1"); - const supervisor = makeSupervisor([worker]); + const supervisor = makeSupervisor([worker], { rlmSpawnLedger: () => ledger }); supervisor.writeRosterEntry( workerRosterEntryFromSummary( summary({ @@ -586,28 +613,28 @@ describe("supervisor roster ledger", () => { supervisor.writeRosterEntry( workerRosterEntryFromSummary( summary({ - id: "gone-active", - sessionId: "gone", - activeSessionId: "gone-active", - sessionFile: "/tmp/gone.jsonl", + 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" }), - }, - ], - undefined, - ), + 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( @@ -626,29 +653,16 @@ describe("supervisor roster ledger", () => { true, ), ); + await new Promise((resolveTick) => setImmediate(resolveTick)); expect(supervisor.roster().get("kept")).toMatchObject({ status: "running" }); - // Absent rows with a transcript passivate; the sessionless queued row vanishes with its run. - const gone = supervisor.roster().get("gone"); - expect(gone?.summary.activeSessionId).toBeUndefined(); - expect(gone?.status).toBe("inactive"); expect(supervisor.roster().has("sessionless")).toBe(false); - }); - - it("applies snapshot removals after replacement so deletions survive a disconnect", () => { - const worker = makeWorker("worker-1"); - const supervisor = makeSupervisor([worker]); - supervisor.writeRosterEntry( - workerRosterEntryFromSummary( - summary({ id: "deleted", sessionId: "deleted", sessionFile: "/tmp/deleted.jsonl" }), - ), - worker, - ); - - supervisor.consumeWorkerRosterDelta(worker, rosterDelta([], ["deleted"], true)); - - // Without the removal, the absent-with-transcript rule would revive the row as a ghost. - expect(supervisor.roster().has("deleted")).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); }); it("marks a dead worker's rows recovering natively on socket close", async () => { @@ -708,7 +722,7 @@ describe("supervisor roster ledger", () => { expect(supervisor.refreshWorkerSummaries).toHaveBeenCalledWith(legacy); }); - it("seeds from the session catalog and spawn ledger, skips tombstones, and keeps evicted rows inactive", async () => { + it("seeds from the spawn ledger, skips tombstones, and keeps evicted rows inactive", async () => { const directory = mkdtempSync(join(tmpdir(), "prime-roster-seed-")); tempDirs.push(directory); const sessionsDir = join(directory, "sessions"); @@ -916,7 +930,7 @@ describe("supervisor roster ledger", () => { }); }); -describe("worker saved-session deletion reaches the supervisor roster", () => { +describe("saved-session delete paths", () => { it("removes the deleted session's ledger row end-to-end", async () => { const directory = mkdtempSync(join(tmpdir(), "prime-roster-worker-delete-")); tempDirs.push(directory); @@ -982,116 +996,203 @@ describe("worker saved-session deletion reaches the supervisor roster", () => { supervisor.consumeWorkerRosterDelta(worker, deltaFrame.payload); expect(supervisor.roster().has(sessionId)).toBe(false); }); -}); -describe("roster entry projection", () => { - it("carries modelFallbackMessage through the roster round-trip", () => { - const source = summary({ - id: "m-active", - sessionId: "m", - activeSessionId: "m-active", - modelFallbackMessage: "No models available", + 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 reachableDescriptor = makeWorker("w-desc"); + Object.assign(reachableDescriptor.descriptor, { + sessionFile: "/tmp/owned-desc.jsonl", + createCommand: { type: "create" }, }); - const roundTripped = sessionSummaryFromRosterEntry(workerRosterEntryFromSummary(source)); - expect(roundTripped.modelFallbackMessage).toBe("No models available"); - }); -}); - -describe("bot-round regressions", () => { - it("removes a child run that terminates before binding instead of passivating a phantom", () => { - 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: "task", status: "queued", sessionDir: "/tmp/c" }), + reachableDescriptor.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; + }, ); - daemon.flushRoster(); + const supervisor = makeSupervisor([reachableRoster, reachableDescriptor, 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() }; - daemon.observeRosterEvent( - parent, - childUpdate(parent, { id: "child-1", label: "task", status: "cancelled", sessionDir: "/tmp/c" }), + // 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), ); - daemon.flushRoster(); - expect(sentDeltas.at(-1)?.removedAgentIds).toEqual(["child-1"]); - expect(sentDeltas.at(-1)?.entries.some((entry) => entry.agentId === "child-1")).toBe(false); + await internals.handleCommand(client, { type: "delete_saved_session", sessionPath: "/tmp/owned-desc.jsonl" }); + expect(reachableDescriptor.client.request).toHaveBeenCalled(); + expect(catalogDelete).not.toHaveBeenCalled(); - daemon.rosterReporter.snapshotPending = true; - daemon.flushRoster(); - expect(sentDeltas.at(-1)?.snapshot).toBe(true); - expect(sentDeltas.at(-1)?.entries.some((entry) => entry.agentId === "child-1")).toBe(false); - }); + // 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(); - it("qualifies colliding child ids from different parents by parent path", () => { - const { daemon, sentDeltas } = makeWorkerReporter(); - const parentA = makeState({ activeSessionId: "parent-a", sessionFile: "/tmp/a.jsonl" }); - const parentB = makeState({ activeSessionId: "parent-b", sessionFile: "/tmp/b.jsonl" }); - daemon.sessions.set(parentA.activeSessionId, parentA); - daemon.sessions.set(parentB.activeSessionId, parentB); + // 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"); + }); - daemon.observeRosterEvent( - parentA, - childUpdate(parentA, { id: "sub-1234", label: "a", status: "queued", sessionDir: "/tmp/a" }), - ); - daemon.observeRosterEvent( - parentB, - childUpdate(parentB, { id: "sub-1234", label: "b", status: "queued", sessionDir: "/tmp/b" }), - ); - daemon.flushRoster(); + it("classifies unknown offline delete targets through the ledger", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-offline-unknown-")); + tempDirs.push(directory); + const garbled = join(directory, "artifacts", "garbled.jsonl"); + mkdirSync(dirname(garbled), { recursive: true }); + writeFileSync(garbled, "not a session header\n"); + const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory }, + descriptorDir: join(directory, "workers"), + }) as unknown as SupervisorFixture & { + handleCommand(client: object, command: object): Promise; + rlmSpawnLedger(): RlmSpawnLedger; + }; + Object.assign(supervisor, { + catalog: { delete: vi.fn(async () => ({ ok: true, method: "unlink" })), list: vi.fn(async () => []) }, + }); + await supervisor.rlmSpawnLedger().appendSpawn({ + childId: "sub-9", + parent: join(directory, "sessions", "r.jsonl"), + child: garbled, + depth: 1, + name: "g", + }); - const queuedRows = sentDeltas.at(-1)?.entries.filter((entry) => entry.summary.rlmChildId === "sub-1234") ?? []; - expect(queuedRows).toHaveLength(2); - expect(new Set(queuedRows.map((entry) => entry.agentId)).size).toBe(2); + await supervisor.handleCommand( + { id: "client", attachedActiveSessionIds: new Set() }, + { type: "delete_saved_session", sessionPath: garbled }, + ); + + await expect(supervisor.rlmSpawnLedger().edges()).resolves.toEqual([]); }); - it("keeps roster state uncommitted until a frame reaches a drained socket", () => { - const write = vi.fn(() => false); - const socket = { destroyed: false, write }; - const client = { - transport: "private-framed", - authenticated: true, - backpressured: undefined as boolean | undefined, - socket, - }; - const daemon = Object.assign(Object.create(AgentDaemon.prototype), { - options: { worker: { authenticationToken: "token" } }, - sessions: new Map(), - cronStore: { list: () => [] }, - clients: new Set([client]), - supervisorClaims: new Map([[client, {}]]), - rosterReporter: { - lastSent: new Map(), - lastComposed: new Map(), - queuedChildren: new Map(), - removedAgentIds: new Set(["deleted-agent"]), - tombstones: new Map(), - generation: 0, - snapshotPending: false, - }, - rosterFlushScheduled: false, - shuttingDown: false, - log: vi.fn(), - }) as { - flushRoster(): void; - rosterReporter: { removedAgentIds: Set; lastSent: Map }; - }; + it("keeps the roster row when a delete fails on disk", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-failed-delete-")); + tempDirs.push(directory); + const sessionPath = join(directory, "saved.jsonl"); + const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory }, + descriptorDir: join(directory, "workers"), + }) as unknown as SupervisorFixture & { handleCommand(client: object, command: object): Promise }; + Object.assign(supervisor, { + catalog: { delete: vi.fn(async () => ({ ok: false, error: "busy file" })), list: vi.fn(async () => []) }, + }); + supervisor.writeRosterEntry( + workerRosterEntryFromSummary(summary({ id: "saved-1", sessionId: "saved-1", sessionFile: sessionPath })), + ); - daemon.flushRoster(); - // write() returned false: the frame is not delivered; nothing commits. - expect(daemon.rosterReporter.removedAgentIds.has("deleted-agent")).toBe(true); - expect(daemon.rosterReporter.lastSent.size).toBe(0); - expect(client.backpressured).toBe(true); + await supervisor.handleCommand( + { id: "client", attachedActiveSessionIds: new Set() }, + { type: "delete_saved_session", sessionPath }, + ); - // A backpressured socket gets no further writes until it drains. - daemon.flushRoster(); - expect(write).toHaveBeenCalledTimes(1); + expect(supervisor.roster().has("saved-1")).toBe(true); + }); - client.backpressured = false; - write.mockReturnValue(true); - daemon.flushRoster(); - expect(daemon.rosterReporter.removedAgentIds.size).toBe(0); + it("aborts a saved-child delete when the tombstone append fails", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-tombstone-fail-")); + tempDirs.push(directory); + const childPath = join(directory, "artifacts", "child.jsonl"); + const catalogDelete = vi.fn(async () => ({ ok: true, method: "unlink" })); + const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory }, + descriptorDir: join(directory, "workers"), + }) as unknown as SupervisorFixture & { handleCommand(client: object, command: object): Promise }; + Object.assign(supervisor, { + catalog: { delete: catalogDelete, list: vi.fn(async () => []) }, + rlmSpawnLedger: () => ({ + edges: vi.fn(async () => [ + { + childId: "child-1", + child: childPath, + parent: join(directory, "sessions", "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(directory, "sessions", "root.jsonl"), + }), + ); + supervisor.writeRosterEntry(childEntry); + + await expect( + supervisor.handleCommand( + { id: "client", attachedActiveSessionIds: new Set() }, + { type: "delete_saved_session", sessionPath: childPath }, + ), + ).rejects.toThrow("ledger unwritable"); + expect(catalogDelete).not.toHaveBeenCalled(); + expect(supervisor.roster().has(childEntry.agentId)).toBe(true); }); +}); +describe("roster entry projection", () => { + it("carries modelFallbackMessage through the roster round-trip", () => { + const source = summary({ + id: "m-active", + sessionId: "m", + activeSessionId: "m-active", + modelFallbackMessage: "No models available", + }); + const roundTripped = sessionSummaryFromRosterEntry(workerRosterEntryFromSummary(source)); + expect(roundTripped.modelFallbackMessage).toBe("No models available"); + }); +}); + +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], { @@ -1149,160 +1250,11 @@ describe("bot-round regressions", () => { expect(listed.data?.sessions.find((session) => session.sessionId === "known")?.sessionName).toBeUndefined(); }); - it("forwards passive-child deletes to the owning worker instead of rejecting them", async () => { - const worker = makeWorker("worker-1"); - worker.client = { - request: vi.fn(async () => ({ type: "response", command: "delete_saved_session", success: true })), - }; - Object.assign(worker.descriptor, { lifecycle: "ready" }); - const catalogDelete = vi.fn(); - const supervisor = makeSupervisor([worker], { - catalog: { list: vi.fn(async () => []), delete: catalogDelete }, - mutationDrain: { begin: vi.fn(), end: vi.fn() }, - }); - supervisor.writeRosterEntry( - workerRosterEntryFromSummary( - summary({ - id: "child-session", - sessionId: "child-session", - sessionFile: "/tmp/artifacts/child.jsonl", - runtimeKind: "subagent", - rlmChildId: "child-1", - }), - ), - worker, - ); - const internals = supervisor as unknown as { - handleCommand(client: object, command: object): Promise; - }; - - await internals.handleCommand( - { id: "client", attachedActiveSessionIds: new Set() }, - { type: "delete_saved_session", sessionPath: "/tmp/artifacts/child.jsonl" }, - ); - - expect(worker.client.request).toHaveBeenCalledWith( - expect.objectContaining({ type: "delete_saved_session", sessionPath: "/tmp/artifacts/child.jsonl" }), - expect.any(Number), - ); - expect(catalogDelete).not.toHaveBeenCalled(); - }); - - it("keeps the roster row when a delete fails on disk", async () => { - const directory = mkdtempSync(join(tmpdir(), "prime-roster-failed-delete-")); - tempDirs.push(directory); - const sessionPath = join(directory, "saved.jsonl"); - const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { - defaultSessionConfig: { agentDir: directory, cwd: directory }, - descriptorDir: join(directory, "workers"), - }) as unknown as SupervisorFixture & { handleCommand(client: object, command: object): Promise }; - Object.assign(supervisor, { - catalog: { delete: vi.fn(async () => ({ ok: false, error: "busy file" })), list: vi.fn(async () => []) }, - }); - supervisor.writeRosterEntry( - workerRosterEntryFromSummary(summary({ id: "saved-1", sessionId: "saved-1", sessionFile: sessionPath })), - ); - - await supervisor.handleCommand( - { id: "client", attachedActiveSessionIds: new Set() }, - { type: "delete_saved_session", sessionPath }, - ); - - expect(supervisor.roster().has("saved-1")).toBe(true); - }); - - it("rejects offline deletes owned via descriptor or an unreachable worker, forwards reachable owners", async () => { - const reachable = makeWorker("w-reach"); - Object.assign(reachable.descriptor, { sessionFile: "/tmp/owned-reach.jsonl", createCommand: { type: "create" } }); - reachable.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 catalogDelete = vi.fn(async () => ({ ok: true, method: "unlink" })); - const supervisor = makeSupervisor([reachable, unreachable], { - catalog: { delete: catalogDelete, list: vi.fn(async () => []) }, - mutationDrain: { begin: vi.fn(), end: vi.fn() }, - }); - const internals = supervisor as unknown as { handleCommand(client: object, command: object): Promise }; - const client = { id: "client", attachedActiveSessionIds: new Set() }; - - // Descriptor ownership with a live socket forwards, roster row or not. - await internals.handleCommand(client, { type: "delete_saved_session", sessionPath: "/tmp/owned-reach.jsonl" }); - expect(reachable.client.request).toHaveBeenCalledWith( - expect.objectContaining({ type: "delete_saved_session" }), - expect.any(Number), - ); - - // 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(); - }); - - it("classifies unknown offline delete targets through the ledger", async () => { - const directory = mkdtempSync(join(tmpdir(), "prime-roster-offline-unknown-")); - tempDirs.push(directory); - const garbled = join(directory, "artifacts", "garbled.jsonl"); - mkdirSync(dirname(garbled), { recursive: true }); - writeFileSync(garbled, "not a session header\n"); - const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { - defaultSessionConfig: { agentDir: directory, cwd: directory }, - descriptorDir: join(directory, "workers"), - }) as unknown as SupervisorFixture & { - handleCommand(client: object, command: object): Promise; - rlmSpawnLedger(): RlmSpawnLedger; - }; - Object.assign(supervisor, { - catalog: { delete: vi.fn(async () => ({ ok: true, method: "unlink" })), list: vi.fn(async () => []) }, - }); - await supervisor.rlmSpawnLedger().appendSpawn({ - childId: "sub-9", - parent: join(directory, "sessions", "r.jsonl"), - child: garbled, - depth: 1, - name: "g", - }); - - await supervisor.handleCommand( - { id: "client", attachedActiveSessionIds: new Set() }, - { type: "delete_saved_session", sessionPath: garbled }, - ); - - await expect(supervisor.rlmSpawnLedger().edges()).resolves.toEqual([]); - }); - - it("ignores buffered roster frames from a superseded worker connection", () => { - const worker = makeWorker("worker-1", { rosterCapable: true }); - const supervisor = makeSupervisor([worker], { - streamReconstructor: { observe: vi.fn(), seed: vi.fn(), clear: vi.fn() }, - }); - const staleClient = { request: vi.fn() }; - const frame = { - header: { kind: "outbound", outboundType: "roster_delta" }, - payload: rosterDelta([workerRosterEntryFromSummary(summary({ id: "ghost", sessionId: "ghost" }))]), - }; - - (supervisor as unknown as { handleWorkerFrame(w: object, f: object, source?: object): void }).handleWorkerFrame( - worker, - frame, - staleClient, - ); - - expect(supervisor.roster().has("ghost")).toBe(false); - }); - - it("accepts frames from the in-flight replacement connection and drops rolled-back sources", () => { + it("trusts frames only from the current and in-flight replacement connections", () => { const worker = makeWorker("worker-1", { rosterCapable: true }); const supervisor = makeSupervisor([worker], { streamReconstructor: { observe: vi.fn(), seed: vi.fn(), clear: vi.fn() }, }); - const replacement = { request: vi.fn() }; const frame = (sessionId: string) => ({ header: { kind: "outbound", outboundType: "roster_delta" }, payload: rosterDelta([workerRosterEntryFromSummary(summary({ id: sessionId, sessionId }))]), @@ -1310,6 +1262,11 @@ describe("bot-round regressions", () => { const internals = supervisor as unknown as { handleWorkerFrame(w: object, f: object, source?: object): void; }; + const stale = { request: vi.fn() }; + const replacement = { request: vi.fn() }; + + internals.handleWorkerFrame(worker, frame("ghost"), stale); + expect(supervisor.roster().has("ghost")).toBe(false); (worker as unknown as { pendingClient?: object }).pendingClient = replacement; internals.handleWorkerFrame(worker, frame("mid-auth"), replacement); @@ -1321,94 +1278,6 @@ describe("bot-round regressions", () => { expect(supervisor.roster().has("rolled-back")).toBe(false); }); - it("reclaims a dead failed owner before an offline delete but keeps recovering owners rejecting", async () => { - const failed = makeWorker("w-failed"); - Object.assign(failed.descriptor, { - sessionFile: "/tmp/owned-failed.jsonl", - createCommand: { type: "create" }, - lifecycle: "failed", - }); - failed.client = undefined; - const recovering = makeWorker("w-recovering"); - Object.assign(recovering.descriptor, { - sessionFile: "/tmp/owned-recovering.jsonl", - createCommand: { type: "create" }, - lifecycle: "recovering", - }); - recovering.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([failed, recovering], { - catalog: { delete: catalogDelete, list: vi.fn(async () => []) }, - mutationDrain: { begin: vi.fn(), end: vi.fn() }, - reclaimStaleWorkerRegistration, - rlmSpawnLedger: () => ({ edges: vi.fn(async () => []) }), - }); - const internals = supervisor as unknown as { handleCommand(client: object, command: object): Promise }; - const client = { id: "client", attachedActiveSessionIds: new Set() }; - - await internals.handleCommand(client, { type: "delete_saved_session", sessionPath: "/tmp/owned-failed.jsonl" }); - expect(reclaimStaleWorkerRegistration).toHaveBeenCalledWith(failed); - expect(catalogDelete).toHaveBeenCalledWith("/tmp/owned-failed.jsonl"); - - await expect( - internals.handleCommand(client, { type: "delete_saved_session", sessionPath: "/tmp/owned-recovering.jsonl" }), - ).rejects.toThrow(/retry the delete/); - }); - - it("walks parent chains beyond thirty-two hops and terminates on cycles", async () => { - const supervisor = makeSupervisor([]); - const base = "/tmp/deep-home"; - const dir = join(base, "sessions"); - let parentPath = join(dir, "root.jsonl"); - supervisor.writeRosterEntry( - workerRosterEntryFromSummary(summary({ id: "root", sessionId: "root", sessionFile: parentPath })), - ); - for (let depth = 1; depth <= 33; depth++) { - const childPath = join(base, "session-artifacts", `d${depth}.jsonl`); - supervisor.writeRosterEntry( - workerRosterEntryFromSummary( - summary({ - id: `d${depth}`, - sessionId: `d${depth}`, - sessionFile: childPath, - runtimeKind: "subagent", - rlmChildId: `d${depth}`, - rlmDepth: depth, - parentSessionPath: parentPath, - }), - ), - ); - parentPath = childPath; - } - const listed = await supervisor.handleList({}, { type: "list", all: true, sessionDir: dir }); - expect(listed.data?.sessions.some((session) => session.sessionId === "d33")).toBe(true); - - // A cycle terminates instead of hanging; the cyclic row simply does not match the dir. - const cyclic = makeSupervisor([]); - cyclic.writeRosterEntry( - workerRosterEntryFromSummary( - summary({ - id: "loop", - sessionId: "loop", - sessionFile: join(base, "session-artifacts", "loop.jsonl"), - runtimeKind: "subagent", - rlmChildId: "loop", - rlmDepth: 1, - parentSessionPath: join(base, "session-artifacts", "loop.jsonl"), - }), - ), - ); - const cyclicListed = await cyclic.handleList({}, { type: "list", all: true, sessionDir: dir }); - expect(cyclicListed.data?.sessions.some((session) => session.sessionId === "loop")).toBe(false); - }); - it("resolves seeded artifact children by their persisted session id before any delta", async () => { const directory = mkdtempSync(join(tmpdir(), "prime-roster-seed-id-")); tempDirs.push(directory); @@ -1493,9 +1362,7 @@ describe("bot-round regressions", () => { expect(supervisor.roster().get("s")?.summary.sessionName).toBe("fresh"); }); -}); -describe("bot-round two regressions", () => { it("publishes qualified removal ids from the rlm subagent deletion path", async () => { const directory = mkdtempSync(join(tmpdir(), "prime-roster-rlm-delete-")); tempDirs.push(directory); @@ -1565,90 +1432,6 @@ describe("bot-round two regressions", () => { expect(sentDeltas.at(-1)?.removedAgentIds).toEqual([composedId]); }); - it("delivers only through the live supervisor claim, never a revoked socket", () => { - const oldWrite = vi.fn(() => true); - const newWrite = vi.fn(() => true); - const oldClient = { - transport: "private-framed", - authenticated: true, - backpressured: undefined as boolean | undefined, - socket: { destroyed: false, write: oldWrite }, - }; - const newClient = { - transport: "private-framed", - authenticated: true, - backpressured: true as boolean | undefined, - socket: { destroyed: false, write: newWrite }, - }; - const daemon = Object.assign(Object.create(AgentDaemon.prototype), { - options: { worker: { authenticationToken: "token" } }, - sessions: new Map(), - cronStore: { list: () => [] }, - clients: new Set([oldClient, newClient]), - supervisorClaims: new Map([[newClient, {}]]), - rosterReporter: { - lastSent: new Map(), - lastComposed: new Map(), - queuedChildren: new Map(), - removedAgentIds: new Set(["deleted-agent"]), - tombstones: new Map(), - generation: 0, - snapshotPending: true, - }, - rosterFlushScheduled: false, - shuttingDown: false, - log: vi.fn(), - }) as { - flushRoster(): void; - rosterReporter: { removedAgentIds: Set; snapshotPending: boolean }; - }; - - daemon.flushRoster(); - expect(oldWrite).not.toHaveBeenCalled(); - expect(daemon.rosterReporter.removedAgentIds.has("deleted-agent")).toBe(true); - expect(daemon.rosterReporter.snapshotPending).toBe(true); - - newClient.backpressured = false; - daemon.flushRoster(); - expect(oldWrite).not.toHaveBeenCalled(); - expect(newWrite).toHaveBeenCalledTimes(1); - expect(daemon.rosterReporter.snapshotPending).toBe(false); - }); - - it("replays delivered removals to supervisors behind the acked generation and prunes on ack", () => { - const { daemon, sentDeltas } = makeWorkerReporter(); - const state = makeState({ - activeSessionId: "root-active", - messages: [{ role: "user", content: "hi" } as unknown as AgentMessage], - }); - daemon.sessions.set(state.activeSessionId, state); - daemon.flushRoster(); - daemon.sessions.delete(state.activeSessionId); - daemon.rosterReporter.removedAgentIds.add("session-root-active"); - daemon.rosterReporter.lastComposed.clear(); - daemon.flushRoster(); - const removalGeneration = daemon.rosterReporter.tombstones.get("session-root-active"); - expect(removalGeneration).toBeGreaterThan(0); - - const internals = daemon as unknown as { prepareRosterSnapshot(acked: number): void }; - // A supervisor that never consumed the removal gets it replayed on the snapshot. - internals.prepareRosterSnapshot((removalGeneration ?? 1) - 1); - daemon.flushRoster(); - expect(sentDeltas.at(-1)?.snapshot).toBe(true); - expect(sentDeltas.at(-1)?.removedAgentIds).toContain("session-root-active"); - - // A supervisor that acked the removal prunes the tombstone; nothing replays. - internals.prepareRosterSnapshot(daemon.rosterReporter.generation); - daemon.flushRoster(); - expect(daemon.rosterReporter.tombstones.size).toBe(0); - expect(sentDeltas.at(-1)?.removedAgentIds).toBeUndefined(); - - // A fresh supervisor (ack 0) would have replayed everything; the tombstone map is already pruned. - internals.prepareRosterSnapshot(0); - daemon.flushRoster(); - expect(sentDeltas.at(-1)?.removedAgentIds).toBeUndefined(); - }); - it("keeps a busy worker unevicted when the refresh response is staler than a delta", async () => { const worker = makeWorker("worker-1", { rosterCapable: true }); const supervisor = makeSupervisor([worker], { @@ -1681,54 +1464,6 @@ describe("bot-round two regressions", () => { expect(snapshot.sessions).toHaveLength(1); expect(snapshot.sessions[0]?.isSessionActive).toBe(true); }); - - it("aborts a saved-child delete when the tombstone append fails", async () => { - const directory = mkdtempSync(join(tmpdir(), "prime-roster-tombstone-fail-")); - tempDirs.push(directory); - const childPath = join(directory, "artifacts", "child.jsonl"); - const catalogDelete = vi.fn(async () => ({ ok: true, method: "unlink" })); - const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { - defaultSessionConfig: { agentDir: directory, cwd: directory }, - descriptorDir: join(directory, "workers"), - }) as unknown as SupervisorFixture & { handleCommand(client: object, command: object): Promise }; - Object.assign(supervisor, { - catalog: { delete: catalogDelete, list: vi.fn(async () => []) }, - rlmSpawnLedger: () => ({ - edges: vi.fn(async () => [ - { - childId: "child-1", - child: childPath, - parent: join(directory, "sessions", "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(directory, "sessions", "root.jsonl"), - }), - ); - supervisor.writeRosterEntry(childEntry); - - await expect( - supervisor.handleCommand( - { id: "client", attachedActiveSessionIds: new Set() }, - { type: "delete_saved_session", sessionPath: childPath }, - ), - ).rejects.toThrow("ledger unwritable"); - expect(catalogDelete).not.toHaveBeenCalled(); - expect(supervisor.roster().has(childEntry.agentId)).toBe(true); - }); }); describe("worker delete tombstone durability", () => { From c6176b14ff9c8d4cb575e4c020bece539a3a1e0d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 29 Aug 2026 19:25:00 +0200 Subject: [PATCH 11/48] perf(coding-agent): cache roster row serializations across flushes Change detection reuses the previous flush's JSON strings, so a churny flush stringifies each current row once instead of twice. ENG-5794 --- .../coding-agent/src/modes/daemon/daemon-mode.ts | 12 +++++++++--- .../coding-agent/test/daemon-agent-roster.test.ts | 3 +++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index e57f90c681..4cadac69a9 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -552,6 +552,7 @@ export class AgentDaemon { private readonly recoveryJournal?: WorkerRecoveryJournal; private readonly rosterReporter: WorkerRosterReporterState = { lastComposed: new Map(), + lastComposedJson: new Map(), queuedChildren: new Map(), removedAgentIds: new Set(), snapshotPending: false, @@ -6694,14 +6695,17 @@ export class AgentDaemon { } } // Deltas are best-effort freshness hints; any miss escalates to one full replacing snapshot. + // Cached serializations keep churny flushes at one stringify per current row. const changed: WorkerRosterEntry[] = []; + const nextJson = new Map(); for (const entry of entries.values()) { - if (JSON.stringify(reporter.lastComposed.get(entry.agentId) ?? null) !== JSON.stringify(entry)) { - changed.push(entry); - } + const json = JSON.stringify(entry); + nextJson.set(entry.agentId, json); + if (reporter.lastComposedJson.get(entry.agentId) !== json) changed.push(entry); } const removedAgentIds = [...reporter.removedAgentIds]; reporter.lastComposed = new Map(entries); + reporter.lastComposedJson = nextJson; if (!this.hasAuthenticatedSupervisorClient()) { // Undelivered removals stay pending; they ride the first delivered frame. if (changed.length > 0 || removedAgentIds.length > 0) reporter.snapshotPending = true; @@ -7138,6 +7142,8 @@ const ROSTER_HEARTBEAT_INTERVAL_MS = 15_000; interface WorkerRosterReporterState { /** Last composed roster, delivered or not; the source for passivated flips and change hints. */ lastComposed: Map; + /** Serialized form of lastComposed, reused for change detection across flushes. */ + lastComposedJson: Map; /** Admitted child runs whose sessions have not materialized yet, keyed by agentId. */ queuedChildren: Map; removedAgentIds: Set; diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index 0125f6367e..42fe86d943 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -41,6 +41,7 @@ interface WorkerReporterFixture { flushRoster(): void; rosterReporter: { lastComposed: Map; + lastComposedJson: Map; queuedChildren: Map; removedAgentIds: Set; snapshotPending: boolean; @@ -59,6 +60,7 @@ function makeWorkerReporter(connected = true): WorkerReporterFixture { cronStore: { list: () => [] }, rosterReporter: { lastComposed: new Map(), + lastComposedJson: new Map(), queuedChildren: new Map(), removedAgentIds: new Set(), snapshotPending: false, @@ -318,6 +320,7 @@ describe("worker roster reporter", () => { supervisorClaims: new Map([[client, {}]]), rosterReporter: { lastComposed: new Map(), + lastComposedJson: new Map(), queuedChildren: new Map(), removedAgentIds: new Set(["deleted-agent"]), snapshotPending: false, From 24ca211e711959ff9a9f1fb8f96494f17d35f5ca Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 29 Aug 2026 19:51:25 +0200 Subject: [PATCH 12/48] refactor(coding-agent): restart pre-roster workers on adoption; drop the legacy shim - connectWorker rejects a worker_auth response without the roster capability, so adoption of a pre-roster worker routes through the existing recoverWorker machinery and respawns it from the current binary; sessions reload idle and resume on the next prompt. - The rosterCapable flag, the legacy event-refresh branch, both conditional refresh call sites, and the refresh-vs-delta race guard go away; deltas own the roster and pulled summaries only feed recovery stream seeding, eviction checks, and descriptor pointers. - syncWorkerSummariesIntoRoster shrinks to a gap filler: launch and recovery pulls fill missing rows and claim workerless seeded rows (registry children no delta composes) without ever overwriting delta-fed rows, so no ordering guard is needed. - Tests seed rosters via writeRosterEntry, eviction fixtures seed the delta-fed rows they previously got from refresh syncs, and a new pin covers the adoption restart routing. ENG-5794 --- .../src/modes/daemon/daemon-supervisor.ts | 58 +++------ .../test/daemon-agent-roster.test.ts | 116 +++++------------- .../test/daemon-supervisor-eviction.test.ts | 14 ++- .../daemon-supervisor-lazy-subagents.test.ts | 9 +- .../test/daemon-supervisor-monitor.test.ts | 11 +- ...4602-snapshot-transfer-idempotency.test.ts | 28 +++-- .../4677-snapshot-catchup-replacement.test.ts | 28 +++-- 7 files changed, 114 insertions(+), 150 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index b40c503571..9330125392 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -307,15 +307,12 @@ interface ResidentWorker { promotedOwnerClientId?: string; updateRestartPrepareClient?: DaemonWorkerClient; /** True once the worker advertised or used the roster-delta protocol. */ - rosterCapable?: boolean; /** Wall-clock time of the last frame received from this worker. */ lastFrameAt?: number; /** True while the watchdog has stamped this worker's entries as stale. */ rosterStale?: boolean; /** In-flight replacement connection during authentication; an allowed frame source alongside client. */ pendingClient?: DaemonWorkerClient; - /** Bumped per consumed roster delta; a summaries refresh must not overwrite newer deltas. */ - rosterDeltaGeneration?: number; } interface SnapshotDuplicateValidation { @@ -1656,7 +1653,6 @@ export class DaemonSupervisor { const response = await this.forwardToWorker(worker, withoutSupervisorCreateFields(command)); if (response.success && isSessionSummary(response.data)) { this.writeRosterEntry(workerRosterEntryFromSummary(response.data), worker); - if (worker.rosterCapable !== true) await this.refreshWorkerSummaries(worker); return { ...response, id: command.id, data: this.publicSummary(worker, response.data) }; } return responseWithId(response, command.id); @@ -2965,7 +2961,10 @@ export class DaemonSupervisor { 1000, ); await this.assertRecoveryAllowed(); - worker.rosterCapable = worker.rosterCapable === true || workerAuthAdvertisesRoster(authResponse.data); + // Pre-roster workers are restarted on adoption; sessions reload idle and resume on the next prompt. + if (!workerAuthAdvertisesRoster(authResponse.data)) { + throw new Error("Session worker predates the roster protocol and must be restarted"); + } worker.lastFrameAt = Date.now(); worker.client?.close(); worker.client = client; @@ -3547,20 +3546,15 @@ export class DaemonSupervisor { ); } - private async refreshWorkerSummaries(worker: ResidentWorker, recovery = false, retried = false): Promise { + /** Pulled summaries feed recovery seeding and eviction checks; deltas own the roster itself. */ + private async refreshWorkerSummaries(worker: ResidentWorker, recovery = 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 deltaGenerationAtStart = worker.rosterDeltaGeneration ?? 0; const response = await worker.client.request({ type: "list" }, 5000); - // A delta that landed mid-refresh is newer than this response; discard it and retry once. - if ((worker.rosterDeltaGeneration ?? 0) !== deltaGenerationAtStart) { - if (!retried) return this.refreshWorkerSummaries(worker, recovery, true); - return; - } const summaries = sessionSummariesFromResponse(response); const nextSummaries = new Map(summaries.map((summary) => [summary.activeSessionId ?? summary.id, summary])); const root = nextSummaries.get(worker.descriptor.rootActiveSessionId); @@ -3568,6 +3562,8 @@ export class DaemonSupervisor { throw new Error(`Session worker omitted its root session during recovery`); } worker.summaries = nextSummaries; + // Launch and recovery pulls carry registry children no delta composes; fill their missing rows. + if (recovery) this.fillRosterGapsFromWorkerSummaries(worker); for (const summary of summaries) { const activeSessionId = summary.activeSessionId ?? summary.id; if (summary.streamingMessage?.role === "assistant") { @@ -3576,7 +3572,6 @@ export class DaemonSupervisor { this.streamReconstructor.clear(activeSessionId); } } - this.syncWorkerSummariesIntoRoster(worker); if (root) { if (recovery) { await this.assertRecoveryAllowed(); @@ -3713,8 +3708,6 @@ export class DaemonSupervisor { return; } if (delta.type !== "roster_delta" || !Array.isArray(delta.entries)) return; - worker.rosterCapable = true; - worker.rosterDeltaGeneration = (worker.rosterDeltaGeneration ?? 0) + 1; if (delta.snapshot === true) { // A live worker's snapshot carries its passivated rows too; absence means removal, disk backs the rest. const sent = new Set(delta.entries.map((entry) => entry.agentId)); @@ -3753,17 +3746,12 @@ export class DaemonSupervisor { this.persistWorker(worker); } - private syncWorkerSummariesIntoRoster(worker: ResidentWorker): void { - const seen = new Set(); + /** Pulled rows fill gaps and claim workerless seeded rows; delta-fed rows are never overwritten. */ + private fillRosterGapsFromWorkerSummaries(worker: ResidentWorker): void { for (const summary of worker.summaries.values()) { const entry = workerRosterEntryFromSummary(summary); - seen.add(entry.agentId); - this.writeRosterEntry(entry, worker); - } - for (const entry of this.workerRosterEntries(worker)) { - if (seen.has(entry.agentId)) continue; - if (entry.queuedChild) continue; - this.writeRosterEntry(passivatedWorkerRosterEntry(entry), worker); + const existing = this.roster().get(entry.agentId); + if (existing === undefined || existing.workerId === undefined) this.writeRosterEntry(entry, worker); } } @@ -3784,7 +3772,7 @@ export class DaemonSupervisor { private sweepRosterStaleness(now = Date.now()): void { for (const worker of this.workers.values()) { - if (worker.client === undefined || worker.lastFrameAt === undefined || worker.rosterCapable !== true) { + if (worker.client === undefined || worker.lastFrameAt === undefined) { continue; } if (now - worker.lastFrameAt > ROSTER_STALE_AFTER_MS) { @@ -3997,9 +3985,13 @@ export class DaemonSupervisor { ): Promise { let matches = this.matchWorkers(selector, includeWorker); if (matches.length === 0) { - // Miss path only: one bounded refresh closes the just-bound-but-unflushed routing window. + // Miss path only: one bounded pull closes the just-bound-but-unflushed routing window. await Promise.all( - [...this.workers.values()].map((worker) => this.refreshWorkerSummaries(worker).catch(() => undefined)), + [...this.workers.values()].map((worker) => + this.refreshWorkerSummaries(worker) + .then(() => this.fillRosterGapsFromWorkerSummaries(worker)) + .catch(() => undefined), + ), ); matches = this.matchWorkers(selector, includeWorker); } @@ -4111,7 +4103,6 @@ export class DaemonSupervisor { } if (command.type === "rename" && response.success && isSessionSummary(response.data)) { this.writeRosterEntry(workerRosterEntryFromSummary(response.data), worker); - if (worker.rosterCapable !== true) await this.refreshWorkerSummaries(worker); return { ...response, id: command.id, data: this.publicSummary(worker, response.data) }; } return responseWithId(response, command.id); @@ -5024,17 +5015,6 @@ export class DaemonSupervisor { } this.writeSerialized(client, publicPayload); } - if ( - worker.rosterCapable !== true && - (outboundType === "session_replaced" || - outboundType === "session_closed" || - sessionEventType === "turn_start" || - sessionEventType === "turn_end" || - sessionEventType === "rlm_child_update") - ) { - // A legacy worker sends no roster deltas; refreshing keeps its ledger rows fresh. - void this.refreshWorkerSummaries(worker).catch(() => undefined); - } if ( decodedOutbound?.type === "session_closed" && decodedOutbound.reason === "shutdown" && diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index 42fe86d943..0fcab79a8d 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -701,30 +701,6 @@ describe("supervisor roster ledger", () => { expect(supervisor.workerRosterEntries(worker)[0]?.lastHeardFromAt).toBeUndefined(); }); - it("refreshes summaries on events only for workers without the roster capability", () => { - const legacy = makeWorker("legacy"); - const modern = makeWorker("modern", { rosterCapable: true }); - const supervisor = makeSupervisor([legacy, modern], { - streamReconstructor: { observe: vi.fn(), seed: vi.fn(), clear: vi.fn() }, - }); - const frame = (activeSessionId: string) => ({ - header: { - kind: "outbound", - outboundType: "session_event", - activeSessionId, - sessionEventType: "turn_end", - payloadEncoding: "jsonl", - }, - payload: Buffer.from(JSON.stringify({ type: "session_event", activeSessionId, event: { type: "turn_end" } })), - }); - - supervisor.handleWorkerFrame(legacy, frame("legacy-active")); - supervisor.handleWorkerFrame(modern, frame("modern-active")); - - expect(supervisor.refreshWorkerSummaries).toHaveBeenCalledTimes(1); - expect(supervisor.refreshWorkerSummaries).toHaveBeenCalledWith(legacy); - }); - it("seeds from the spawn ledger, skips tombstones, and keeps evicted rows inactive", async () => { const directory = mkdtempSync(join(tmpdir(), "prime-roster-seed-")); tempDirs.push(directory); @@ -1313,6 +1289,35 @@ describe("review-round regressions", () => { expect(match.summary.rlmChildId).toBe("sub-abc"); }); + it("restarts a pre-roster worker on adoption instead of adopting it", async () => { + const worker = makeWorker("legacy-worker"); + worker.client = undefined; + // A live pid routes adoption to the capability check instead of the dead-process branch. + Object.assign(worker.descriptor, { lifecycle: "recovering", pid: process.pid }); + const recoverWorker = vi.fn(async (target: object) => { + Object.assign((target as WorkerFixture).descriptor, { lifecycle: "ready" }); + }); + const subscribeWorker = vi.fn(); + const supervisor = makeSupervisor([worker], { + assertRecoveryAllowed: vi.fn(async () => {}), + connectWorker: vi.fn(async () => { + throw new Error("Session worker predates the roster protocol and must be restarted"); + }), + subscribeWorker, + recoverWorker, + persistWorker: vi.fn(), + broadcastHeartbeatsChanged: vi.fn(), + }); + const internals = supervisor as unknown as { adoptOrRecoverWorker(worker: object): Promise }; + + await internals.adoptOrRecoverWorker(worker); + + // The old process is never adopted; the existing recovery machinery restarts it from the current binary. + expect(recoverWorker).toHaveBeenCalledWith(worker); + expect(subscribeWorker).not.toHaveBeenCalled(); + expect(worker.descriptor.lifecycle).toBe("ready"); + }); + it("routes a just-bound session through the miss-path refresh", async () => { const target = summary({ id: "target-active", sessionId: "target", activeSessionId: "target-active" }); const worker = makeWorker("worker-1", { rosterCapable: true }); @@ -1326,7 +1331,6 @@ describe("review-round regressions", () => { }; const supervisor = makeSupervisor([worker], { refreshWorkerSummaries: DaemonSupervisor.prototype["refreshWorkerSummaries" as never], - syncWorkerSummariesIntoRoster: DaemonSupervisor.prototype["syncWorkerSummariesIntoRoster" as never], streamReconstructor: { seed: vi.fn(), clear: vi.fn() }, }); const internals = supervisor as unknown as { @@ -1337,35 +1341,6 @@ describe("review-round regressions", () => { expect(match.summary.sessionId).toBe("target"); }); - it("does not let a summaries refresh overwrite a newer roster delta", async () => { - const worker = makeWorker("worker-1", { rosterCapable: true }); - const stale = summary({ id: "s-active", sessionId: "s", activeSessionId: "s-active", sessionName: "stale" }); - const supervisor = makeSupervisor([worker], { - refreshWorkerSummaries: DaemonSupervisor.prototype["refreshWorkerSummaries" as never], - syncWorkerSummariesIntoRoster: DaemonSupervisor.prototype["syncWorkerSummariesIntoRoster" as never], - streamReconstructor: { seed: vi.fn(), clear: vi.fn() }, - }); - worker.client = { - request: vi.fn(async () => { - supervisor.consumeWorkerRosterDelta( - worker, - rosterDelta([ - workerRosterEntryFromSummary( - summary({ id: "s-active", sessionId: "s", activeSessionId: "s-active", sessionName: "fresh" }), - ), - ]), - ); - return { type: "response", command: "list", success: true, data: { sessions: [stale] } }; - }), - }; - - await ( - supervisor as unknown as { refreshWorkerSummaries(worker: WorkerFixture): Promise } - ).refreshWorkerSummaries(worker); - - expect(supervisor.roster().get("s")?.summary.sessionName).toBe("fresh"); - }); - it("publishes qualified removal ids from the rlm subagent deletion path", async () => { const directory = mkdtempSync(join(tmpdir(), "prime-roster-rlm-delete-")); tempDirs.push(directory); @@ -1434,39 +1409,6 @@ describe("review-round regressions", () => { expect(removalId).toBe(composedId); expect(sentDeltas.at(-1)?.removedAgentIds).toEqual([composedId]); }); - - it("keeps a busy worker unevicted when the refresh response is staler than a delta", async () => { - const worker = makeWorker("worker-1", { rosterCapable: true }); - const supervisor = makeSupervisor([worker], { - refreshWorkerSummaries: DaemonSupervisor.prototype["refreshWorkerSummaries" as never], - syncWorkerSummariesIntoRoster: DaemonSupervisor.prototype["syncWorkerSummariesIntoRoster" as never], - streamReconstructor: { seed: vi.fn(), clear: vi.fn() }, - }); - const staleIdle = summary({ id: "s-active", sessionId: "s", activeSessionId: "s-active" }); - worker.client = { - request: vi.fn(async () => { - supervisor.consumeWorkerRosterDelta( - worker, - rosterDelta([ - workerRosterEntryFromSummary( - summary({ id: "s-active", sessionId: "s", activeSessionId: "s-active", isSessionActive: true }), - ), - ]), - ); - return { type: "response", command: "list", success: true, data: { sessions: [staleIdle] } }; - }), - }; - const internals = supervisor as unknown as { - refreshWorkerSummaries(worker: WorkerFixture): Promise; - workerEvictionSnapshot(worker: WorkerFixture): { sessions: Array<{ isSessionActive: boolean }> }; - }; - - await internals.refreshWorkerSummaries(worker); - - const snapshot = internals.workerEvictionSnapshot(worker); - expect(snapshot.sessions).toHaveLength(1); - expect(snapshot.sessions[0]?.isSessionActive).toBe(true); - }); }); describe("worker delete tombstone durability", () => { diff --git a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts index 2807a30fe5..7d0797efce 100644 --- a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { workerRosterEntryFromSummary } from "../src/modes/daemon/agent-roster.js"; import { success } from "../src/modes/daemon/daemon-protocol.js"; import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js"; import { DaemonSupervisor, idleEvictionSweepIntervalMs } from "../src/modes/daemon/daemon-supervisor.js"; @@ -89,8 +90,14 @@ function makeWorker(id: string, summaries: SessionSummary[]): WorkerFixture { } function seedSupervisorRoster(supervisor: SupervisorInternals, ...workers: WorkerFixture[]): void { - const internals = supervisor as unknown as { syncWorkerSummariesIntoRoster(worker: WorkerFixture): void }; - for (const worker of workers) internals.syncWorkerSummariesIntoRoster(worker); + const internals = supervisor as unknown as { + writeRosterEntry(entry: ReturnType, worker?: WorkerFixture): unknown; + }; + for (const worker of workers) { + for (const summary of worker.summaries.values()) { + internals.writeRosterEntry(workerRosterEntryFromSummary(summary), worker); + } + } } function makeSupervisor(idleEvictionMinutes: number | "off" = 90): SupervisorInternals { @@ -128,6 +135,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); @@ -156,6 +164,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); @@ -220,6 +229,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); 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 db7abd3cc1..b249afa53d 100644 --- a/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts @@ -8,6 +8,7 @@ import { sessionNameReservationKey, } from "../src/core/agent-messages.js"; import { readSessionInfo, SessionManager } from "../src/core/session-manager.js"; +import { workerRosterEntryFromSummary } from "../src/modes/daemon/agent-roster.js"; import { DaemonCatalogClient } from "../src/modes/daemon/daemon-catalog-process.js"; import { DaemonClient } from "../src/modes/daemon/daemon-client.js"; import { success } from "../src/modes/daemon/daemon-protocol.js"; @@ -33,7 +34,7 @@ interface SupervisorInternals { familyCatalogEntry(summary: SessionSummary): AgentFamilyCatalogEntry; handleCommand(client: object, command: Record): Promise; seedRosterLedger(): Promise; - syncWorkerSummariesIntoRoster(worker: WorkerFixture): void; + writeRosterEntry(entry: ReturnType, worker?: WorkerFixture): unknown; } interface WorkerFixture { @@ -76,7 +77,11 @@ function summary(overrides: Partial & Pick }> ): void { - const internals = supervisor as { syncWorkerSummariesIntoRoster(worker: object): void }; - for (const worker of workers) internals.syncWorkerSummariesIntoRoster(worker); + const internals = supervisor as { + writeRosterEntry(entry: ReturnType, worker?: object): unknown; + }; + for (const worker of workers) { + for (const summary of worker.summaries.values()) { + internals.writeRosterEntry(workerRosterEntryFromSummary(summary), worker); + } + } } describe("daemon worker supervisor monitoring", () => { 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 95b39bd6d1..52c587053f 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 @@ -3,6 +3,7 @@ import { PassThrough } from "node:stream"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { describe, expect, it, vi } from "vitest"; import type { ActiveSessionState, DaemonSocketClient } from "../../../src/modes/daemon/active-session-state.js"; +import { workerRosterEntryFromSummary } from "../../../src/modes/daemon/agent-roster.js"; import { AgentDaemon } from "../../../src/modes/daemon/daemon-mode.js"; import { DAEMON_PROTOCOL_INFO, @@ -350,9 +351,12 @@ describe("ENG-4602 snapshot transfer containment", () => { }; internals.clients.add(client); internals.workers.set(worker.descriptor.workerId, worker); - ( - supervisor as unknown as { syncWorkerSummariesIntoRoster(worker: WorkerHarness): void } - ).syncWorkerSummariesIntoRoster(worker); + const seeder = supervisor as unknown as { + writeRosterEntry(entry: ReturnType, worker?: WorkerHarness): unknown; + }; + for (const summary of worker.summaries.values()) { + seeder.writeRosterEntry(workerRosterEntryFromSummary(summary), worker); + } internals.syncWorkerExtensionUi = vi.fn(async () => {}); internals.streamSnapshot = streamSnapshot; const messages: AgentMessage[] = [{ role: "user", content: "stable", timestamp: 1 }]; @@ -437,9 +441,12 @@ describe("ENG-4602 snapshot transfer containment", () => { }; internals.clients.add(client); internals.workers.set(worker.descriptor.workerId, worker); - ( - supervisor as unknown as { syncWorkerSummariesIntoRoster(worker: WorkerHarness): void } - ).syncWorkerSummariesIntoRoster(worker); + const seeder = supervisor as unknown as { + writeRosterEntry(entry: ReturnType, worker?: WorkerHarness): unknown; + }; + for (const summary of worker.summaries.values()) { + seeder.writeRosterEntry(workerRosterEntryFromSummary(summary), worker); + } internals.streamSnapshot = streamSnapshot; const frames = snapshotFrames([{ role: "user", content: "stable", timestamp: 1 }]); for (const message of [frames.begin, frames.chunk, frames.end]) { @@ -507,9 +514,12 @@ describe("ENG-4602 snapshot transfer containment", () => { }; internals.clients.add(client); internals.workers.set(worker.descriptor.workerId, worker); - ( - supervisor as unknown as { syncWorkerSummariesIntoRoster(worker: WorkerHarness): void } - ).syncWorkerSummariesIntoRoster(worker); + const seeder = supervisor as unknown as { + writeRosterEntry(entry: ReturnType, worker?: WorkerHarness): unknown; + }; + for (const summary of worker.summaries.values()) { + seeder.writeRosterEntry(workerRosterEntryFromSummary(summary), 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 dd79099f70..e9d0cd4ade 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 @@ -6,6 +6,7 @@ import { PassThrough } from "node:stream"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { DaemonSocketClient } from "../../../src/modes/daemon/active-session-state.js"; +import { workerRosterEntryFromSummary } from "../../../src/modes/daemon/agent-roster.js"; import { AgentDaemon } from "../../../src/modes/daemon/daemon-mode.js"; import { DAEMON_PROTOCOL_INFO, @@ -306,9 +307,12 @@ describe("ENG-4677 snapshot catch-up replacement", () => { handleWorkerFrame(worker: WorkerHarness, frame: PrivateFrame): void; }; internals.workers.set(worker.descriptor.workerId, worker); - ( - supervisor as unknown as { syncWorkerSummariesIntoRoster(worker: WorkerHarness): void } - ).syncWorkerSummariesIntoRoster(worker); + const seeder = supervisor as unknown as { + writeRosterEntry(entry: ReturnType, worker?: WorkerHarness): unknown; + }; + for (const summary of worker.summaries.values()) { + seeder.writeRosterEntry(workerRosterEntryFromSummary(summary), worker); + } const attaching = internals.attachClient(client, { type: "attach", activeSessionId, @@ -437,9 +441,12 @@ describe("ENG-4677 snapshot catch-up replacement", () => { handleWorkerFrame(worker: WorkerHarness, frame: PrivateFrame): void; }; internals.workers.set(worker.descriptor.workerId, worker); - ( - supervisor as unknown as { syncWorkerSummariesIntoRoster(worker: WorkerHarness): void } - ).syncWorkerSummariesIntoRoster(worker); + const seeder = supervisor as unknown as { + writeRosterEntry(entry: ReturnType, worker?: WorkerHarness): unknown; + }; + for (const summary of worker.summaries.values()) { + seeder.writeRosterEntry(workerRosterEntryFromSummary(summary), worker); + } const { messages: _firstMessages, ...firstSnapshot } = firstResult.snapshot; const firstBegin = { type: "session_snapshot_begin", @@ -703,9 +710,12 @@ describe("ENG-4677 snapshot catch-up replacement", () => { internals.clients.add(client); internals.workers.set(worker.descriptor.workerId, worker); - ( - supervisor as unknown as { syncWorkerSummariesIntoRoster(worker: WorkerHarness): void } - ).syncWorkerSummariesIntoRoster(worker); + const seeder = supervisor as unknown as { + writeRosterEntry(entry: ReturnType, worker?: WorkerHarness): unknown; + }; + for (const summary of worker.summaries.values()) { + seeder.writeRosterEntry(workerRosterEntryFromSummary(summary), worker); + } internals.queueCatchup(client, activeSessionId, "replacement"); await internals.catchUpClient(client); From 8cf2cbf40d1ee4a59c1ccb487bdd7913313c67f8 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 29 Aug 2026 20:38:21 +0200 Subject: [PATCH 13/48] fix(coding-agent): roster rework review fixes, supervisor and worker halves - Worker frames adopt real socket semantics: a write queued under backpressure IS delivered, so pending state clears on it; only an absent, destroyed, or unauthenticated claim socket is a loss gap, and one replacing snapshot closes it. Drains never resend queued frames. - Gap fills are epoch-guarded: every applied roster frame bumps a supervisor-local per-worker counter, a pull that straddled a frame re-pulls once, and a still-moving epoch skips the fill entirely so a stale list can never resurrect a just-removed row. - Snapshot applies pre-read the spawn ledger and queue later frames behind them per worker, so replacement, absentee deletion, and the tombstone-filtered reseed land atomically with no transient removal. - The startup catalog seed returns: a push-only view needs saved top-level rows in the ledger itself. Rows stay slim and list-all keeps its per-call disk rescan. - Pre-roster adoption performs a real bare restart: the durable descriptor is the whole respawn context, the old process is killed only under its observed identity, and launchWorker respawns from the current binary. Pinned end-to-end against a real supervisor with a capability-less fake worker, no recovery mocks. - Model, thinking-level, and rename changes reach subscribers: the thinking_level_changed trigger joins the roster event set and the four model/thinking handlers schedule a flush. ENG-5794 --- .../src/modes/daemon/daemon-mode.ts | 18 +- .../src/modes/daemon/daemon-supervisor.ts | 139 ++++++++-- .../test/daemon-agent-roster.test.ts | 250 +++++++++++++++--- .../test/daemon-supervisor-process.test.ts | 137 +++++++++- 4 files changed, 478 insertions(+), 66 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 4cadac69a9..a065e7c80a 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -3227,8 +3227,6 @@ export class AgentDaemon { socket.on("error", cleanup); socket.on("drain", () => { client.backpressured = false; - // Undelivered roster state stayed uncommitted; the drained socket can take it now. - this.scheduleRosterFlush(); if (!client.snapshotStreaming) { void this.catchUpBackpressuredClient(client).catch((error) => this.log(`could not catch up snapshot client ${client.id}: ${String(error)}`), @@ -4614,6 +4612,7 @@ export class AgentDaemon { await session.setModel(model, { waitForExtensions: !(session.isStreaming || session.isCompacting), }); + this.scheduleRosterFlush(); return success(command.id, "set_model", model); } @@ -4623,6 +4622,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); } @@ -4635,6 +4635,7 @@ export class AgentDaemon { case "set_thinking_level": { const state = this.getSessionState(command.activeSessionId); state.runtime.session.setThinkingLevel(command.level); + this.scheduleRosterFlush(); return success(command.id, "set_thinking_level"); } @@ -4647,6 +4648,7 @@ export class AgentDaemon { case "cycle_thinking_level": { const state = this.getSessionState(command.activeSessionId); const level = state.runtime.session.cycleThinkingLevel(); + this.scheduleRosterFlush(); return success(command.id, "cycle_thinking_level", level ? { level } : null); } @@ -6751,17 +6753,10 @@ export class AgentDaemon { if (!this.supervisorClaims.has(client) || client.socket.destroyed) { continue; } - // A non-drained socket gets nothing; uncommitted state re-flushes on drain. - if (client.backpressured === true) { - continue; - } - const accepted = client.socket.write( + // 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), ); - if (!accepted) { - client.backpressured = true; - continue; - } delivered = true; } return delivered; @@ -7162,6 +7157,7 @@ const ROSTER_SESSION_EVENT_TRIGGERS = new Set([ "message_end", "session_action_update", "session_info_changed", + "thinking_level_changed", ]); function hasDaemonOutboundActiveSessionId( diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 9330125392..a67a73b6af 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -306,13 +306,16 @@ interface ResidentWorker { ownerCleanupTimer?: ReturnType; promotedOwnerClientId?: string; updateRestartPrepareClient?: DaemonWorkerClient; - /** True once the worker advertised or used the roster-delta protocol. */ /** Wall-clock time of the last frame received from this worker. */ lastFrameAt?: number; /** True while the watchdog has stamped this worker's entries as stale. */ 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; + /** Serializes snapshot applications (and any deltas behind them) per worker. */ + rosterApplyChain?: Promise; } interface SnapshotDuplicateValidation { @@ -492,6 +495,9 @@ function isDaemonWorkerDescriptor(value: unknown, socketPath: string): value is ); } +/** Adoption-only signal: the connected worker predates the roster protocol and must be restarted. */ +class PreRosterWorkerError extends Error {} + function workerAuthAdvertisesRoster(data: unknown): boolean { if (typeof data !== "object" || data === null) return false; const capabilities = (data as { capabilities?: unknown }).capabilities; @@ -2963,7 +2969,7 @@ export class DaemonSupervisor { await this.assertRecoveryAllowed(); // Pre-roster workers are restarted on adoption; sessions reload idle and resume on the next prompt. if (!workerAuthAdvertisesRoster(authResponse.data)) { - throw new Error("Session worker predates the roster protocol and must be restarted"); + throw new PreRosterWorkerError("Session worker predates the roster protocol and must be restarted"); } worker.lastFrameAt = Date.now(); worker.client?.close(); @@ -2975,7 +2981,7 @@ export class DaemonSupervisor { } catch (error) { lastError = error; client.close(); - if (isSupervisorRecoveryCancelled(error)) { + if (isSupervisorRecoveryCancelled(error) || error instanceof PreRosterWorkerError) { throw error; } await delay(25); @@ -3038,11 +3044,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); @@ -3059,10 +3066,42 @@ export class DaemonSupervisor { return; } this.log(`Could not adopt worker ${worker.descriptor.workerId}: ${String(error)}`); + if (error instanceof PreRosterWorkerError) { + 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); } } + /** Bare restart for adopted pre-roster workers: the durable descriptor is the whole respawn context. */ + private async restartPreRosterWorker( + worker: ResidentWorker, + observedProcessStartId: string | undefined, + ): Promise { + await this.assertRecoveryAllowed(); + // The old process was reachable moments ago; its observed identity makes the kill safe. + if (worker.descriptor.processStartId === undefined && observedProcessStartId !== undefined) { + worker.descriptor.processStartId = observedProcessStartId; + } + const safeToKill = + isProcessAlive(worker.descriptor.pid) && + worker.descriptor.processStartId !== undefined && + getProcessStartId(worker.descriptor.pid) === worker.descriptor.processStartId; + await this.recoverUncertainWorkerOperations(worker, safeToKill); + 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; @@ -3547,14 +3586,24 @@ export class DaemonSupervisor { } /** Pulled summaries feed recovery seeding and eviction checks; deltas own the roster itself. */ - 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 epochAtStart = worker.rosterEpoch ?? 0; const response = await worker.client.request({ type: "list" }, 5000); + // A frame applied 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); @@ -3563,7 +3612,7 @@ export class DaemonSupervisor { } worker.summaries = nextSummaries; // Launch and recovery pulls carry registry children no delta composes; fill their missing rows. - if (recovery) this.fillRosterGapsFromWorkerSummaries(worker); + if (fillGaps && (worker.rosterEpoch ?? 0) === epochAtStart) this.fillRosterGapsFromWorkerSummaries(worker); for (const summary of summaries) { const activeSessionId = summary.activeSessionId ?? summary.id; if (summary.streamingMessage?.role === "assistant") { @@ -3663,7 +3712,16 @@ export class DaemonSupervisor { // Seeds selector resolution, name checks, and liveness; list all rescans the disk per call. private async seedRosterLedger(): Promise { try { - // Ledger edges cover subagents the catalog never scans; top-level rows read disk per call instead. + // A push-only view needs saved top-level rows in the ledger itself; rows stay slim, cwd hydrates lazily. + 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 { + // Ledger edges cover subagents in artifact dirs the catalog never scans; tombstones stay out. for (const edge of await this.rlmSpawnLedger().edges()) { const entry = this.rosterEntryForSpawnLedgerEdge(edge); if (this.roster().has(entry.agentId)) continue; @@ -3708,12 +3766,54 @@ export class DaemonSupervisor { return; } if (delta.type !== "roster_delta" || !Array.isArray(delta.entries)) return; - if (delta.snapshot === true) { - // A live worker's snapshot carries its passivated rows too; absence means removal, disk backs the rest. - const sent = new Set(delta.entries.map((entry) => entry.agentId)); - for (const entry of this.workerRosterEntries(worker)) { - if (!sent.has(entry.agentId)) this.roster().delete(entry.agentId); - } + if (delta.snapshot !== true && worker.rosterApplyChain === undefined) { + this.applyWorkerRosterDelta(worker, delta); + return; + } + // Snapshots pre-read the ledger, so later frames queue behind them to keep per-worker order. + const chained = (worker.rosterApplyChain ?? Promise.resolve()) + .then(() => + delta.snapshot === true + ? this.applyWorkerRosterSnapshot(worker, delta) + : this.applyWorkerRosterDelta(worker, delta), + ) + .catch((error: unknown) => this.log(`could not apply a roster frame: ${String(error)}`)); + worker.rosterApplyChain = chained; + void chained.finally(() => { + if (worker.rosterApplyChain === chained) worker.rosterApplyChain = undefined; + }); + } + + private applyWorkerRosterDelta( + worker: ResidentWorker, + delta: Extract, + ): void { + worker.rosterEpoch = (worker.rosterEpoch ?? 0) + 1; + 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, + ): Promise { + // Live edges are read before any deletion, so a reseeded child never surfaces as a transient removal. + const edges = await this.rlmSpawnLedger() + .edges() + .catch((error: unknown) => { + this.log(`Could not read the spawn ledger during a snapshot apply: ${String(error)}`); + return [] as RlmLedgerEdge[]; + }); + worker.rosterEpoch = (worker.rosterEpoch ?? 0) + 1; + // A live worker's snapshot carries its passivated rows too; absence means removal, disk backs the rest. + const sent = new Set(delta.entries.map((entry) => entry.agentId)); + for (const entry of this.workerRosterEntries(worker)) { + if (!sent.has(entry.agentId)) this.roster().delete(entry.agentId); } for (const entry of delta.entries) { this.writeRosterEntry(entry, worker); @@ -3722,8 +3822,13 @@ export class DaemonSupervisor { for (const agentId of delta.removedAgentIds ?? []) { this.roster().delete(agentId); } - // Deleted absentees with surviving transcripts reseed from the spawn ledger, tombstone-filtered. - if (delta.snapshot === true) void this.seedRosterLedger(); + // Deleted absentees with surviving transcripts reseed from the pre-read edges, tombstone-filtered. + for (const edge of edges) { + 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 }); + } } /** Root roster deltas maintain the persisted descriptor pointers (rootSessionId, sessionFile). */ @@ -3988,9 +4093,7 @@ export class DaemonSupervisor { // Miss path only: one bounded pull closes the just-bound-but-unflushed routing window. await Promise.all( [...this.workers.values()].map((worker) => - this.refreshWorkerSummaries(worker) - .then(() => this.fillRosterGapsFromWorkerSummaries(worker)) - .catch(() => undefined), + this.refreshWorkerSummaries(worker, false, true).catch(() => undefined), ), ); matches = this.matchWorkers(selector, includeWorker); diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index 0fcab79a8d..ed2ac65477 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -1,4 +1,5 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { connect, createServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { PassThrough } from "node:stream"; @@ -297,9 +298,14 @@ describe("worker roster reporter", () => { expect(sentDeltas.length).toBe(sentWhileConnected + 1); }); - it("delivers only through the live drained supervisor claim and escalates refusals to a snapshot", () => { + it("treats queued writes as delivered and snapshots only across loss gaps", () => { + const written: Buffer[] = []; + const write = vi.fn((chunk: Buffer) => { + written.push(Buffer.from(chunk)); + // Backpressure: the frame is queued in the socket, not refused. + return false; + }); const oldWrite = vi.fn(() => true); - const write = vi.fn(() => false); const oldClient = { transport: "private-framed", authenticated: true, @@ -328,24 +334,32 @@ describe("worker roster reporter", () => { rosterFlushScheduled: false, shuttingDown: false, log: vi.fn(), - }) as { flushRoster(): void; rosterReporter: { snapshotPending: boolean } }; + }) as { flushRoster(): void; rosterReporter: { snapshotPending: boolean; removedAgentIds: Set } }; daemon.flushRoster(); - // The refused write commits nothing; the claimed socket is backpressured and a snapshot is owed. + // The queued write IS delivered: nothing stays pending and only the claimed socket was written. + expect(write).toHaveBeenCalledTimes(1); expect(oldWrite).not.toHaveBeenCalled(); - expect(client.backpressured).toBe(true); - expect(daemon.rosterReporter.snapshotPending).toBe(true); + expect(daemon.rosterReporter.snapshotPending).toBe(false); + expect(daemon.rosterReporter.removedAgentIds.size).toBe(0); - // A backpressured socket gets no further writes until it drains. + // A destroyed claim socket is an actual loss gap: the change marks one pending snapshot. + client.socket.destroyed = true; + daemon.rosterReporter.removedAgentIds.add("lost-agent"); daemon.flushRoster(); expect(write).toHaveBeenCalledTimes(1); + expect(daemon.rosterReporter.snapshotPending).toBe(true); - client.backpressured = false; - write.mockReturnValue(true); + // The gap closes with one replacing snapshot; drains never resend queued frames. + client.socket.destroyed = false; + daemon.flushRoster(); daemon.flushRoster(); expect(write).toHaveBeenCalledTimes(2); - expect(daemon.rosterReporter.snapshotPending).toBe(false); - expect(oldWrite).not.toHaveBeenCalled(); + const decoder = new PrivateFrameDecoder(isDaemonWorkerFrameHeader); + const frames = decoder.push(Buffer.concat(written)); + const messages = frames.map((frame) => JSON.parse(frame.payload.toString("utf8")) as RosterDelta); + expect(messages[1]?.snapshot).toBe(true); + expect(messages[1]?.removedAgentIds).toEqual(["lost-agent"]); }); }); @@ -656,9 +670,7 @@ describe("supervisor roster ledger", () => { true, ), ); - await new Promise((resolveTick) => setImmediate(resolveTick)); - - expect(supervisor.roster().get("kept")).toMatchObject({ status: "running" }); + 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()]; @@ -701,7 +713,7 @@ describe("supervisor roster ledger", () => { expect(supervisor.workerRosterEntries(worker)[0]?.lastHeardFromAt).toBeUndefined(); }); - it("seeds from the spawn ledger, skips tombstones, and keeps evicted rows inactive", async () => { + it("seeds catalog and spawn-ledger rows, skips tombstones, and keeps evicted rows inactive", async () => { const directory = mkdtempSync(join(tmpdir(), "prime-roster-seed-")); tempDirs.push(directory); const sessionsDir = join(directory, "sessions"); @@ -743,6 +755,8 @@ describe("supervisor roster ledger", () => { }); 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"]); @@ -1289,33 +1303,199 @@ describe("review-round regressions", () => { expect(match.summary.rlmChildId).toBe("sub-abc"); }); - it("restarts a pre-roster worker on adoption instead of adopting it", async () => { - const worker = makeWorker("legacy-worker"); - worker.client = undefined; - // A live pid routes adoption to the capability check instead of the dead-process branch. - Object.assign(worker.descriptor, { lifecycle: "recovering", pid: process.pid }); - const recoverWorker = vi.fn(async (target: object) => { - Object.assign((target as WorkerFixture).descriptor, { lifecycle: "ready" }); + it("publishes model and name changes to the supervisor", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-model-")); + tempDirs.push(directory); + const daemon = new AgentDaemon(join(directory, "worker.sock"), { + defaultSessionConfig: { agentDir: directory, cwd: directory }, + worker: { + authenticationToken: "token", + workerId: "worker-1", + rootActiveSessionId: "root-active", + } as never, + createRuntime: async () => { + throw new Error("unexpected runtime creation"); + }, + } as never); + const socket = new PassThrough(); + const written: Buffer[] = []; + socket.on("data", (chunk: Buffer) => written.push(Buffer.from(chunk))); + const supervisorClient = { + id: "supervisor", + socket, + transport: "private-framed", + authenticated: true, + attachedActiveSessionIds: new Set(), + detachInput: () => {}, + supportsExtensionUi: false, + capabilities: new Set(), + } as unknown as DaemonSocketClient; + const internals = daemon as unknown as { + clients: Set; + supervisorClaims: Map; + sessions: Map; + handleCommand(client: DaemonSocketClient, command: object): Promise; + }; + internals.clients.add(supervisorClient); + internals.supervisorClaims.set(supervisorClient, {}); + const state = makeState({ + activeSessionId: "root-active", + messages: [{ role: "user", content: "hi" } as unknown as AgentMessage], + }); + const session = state.runtime.session as unknown as Record; + session.model = { provider: "prov", id: "m1" }; + session.modelRegistry = { + refreshAvailableModels: async () => [{ provider: "prov", id: "m2" }], + }; + session.setModel = async (model: unknown) => { + session.model = model; + }; + internals.sessions.set(state.activeSessionId, state); + + const decodeDeltas = () => + new PrivateFrameDecoder(isDaemonWorkerFrameHeader) + .push(Buffer.concat(written)) + .filter((frame) => frame.header.kind === "outbound" && frame.header.outboundType === "roster_delta") + .map((frame) => JSON.parse(frame.payload.toString("utf8")) as RosterDelta); + + await internals.handleCommand(supervisorClient, { + type: "set_model", + activeSessionId: "root-active", + provider: "prov", + modelId: "m2", + }); + await vi.waitFor(() => { + const rows = decodeDeltas().flatMap((delta) => delta.entries); + expect(rows.at(-1)?.summary.model).toMatchObject({ id: "m2" }); + }); + + // Rename: the handler updates the session and the runtime's info event triggers the flush. + session.setSessionName = (name: string) => { + session.sessionName = name; + }; + await internals.handleCommand(supervisorClient, { + type: "rename", + activeSessionId: "root-active", + name: "renamed-by-worker", + }); + ( + daemon as unknown as { observeRosterEvent(state: ActiveSessionState, message: unknown): void } + ).observeRosterEvent(state, { + type: "session_event", + activeSessionId: "root-active", + event: { type: "session_info_changed", name: "renamed-by-worker" }, + }); + await vi.waitFor(() => { + const rows = decodeDeltas().flatMap((delta) => delta.entries); + expect(rows.at(-1)?.summary.sessionName).toBe("renamed-by-worker"); + }); + }); + + it("skips the gap fill when a roster frame lands mid-pull", async () => { + const worker = makeWorker("worker-1"); + Object.assign(worker.descriptor, { createCommand: { type: "create" } }); + const staleChild = summary({ + id: "x-session", + sessionId: "x-session", + sessionFile: "/tmp/artifacts/x.jsonl", + runtimeKind: "subagent", + rlmChildId: "x", }); - const subscribeWorker = vi.fn(); + const staleEntry = workerRosterEntryFromSummary(staleChild); const supervisor = makeSupervisor([worker], { assertRecoveryAllowed: vi.fn(async () => {}), - connectWorker: vi.fn(async () => { - throw new Error("Session worker predates the roster protocol and must be restarted"); - }), - subscribeWorker, - recoverWorker, persistWorker: vi.fn(), - broadcastHeartbeatsChanged: vi.fn(), + refreshWorkerSummaries: DaemonSupervisor.prototype["refreshWorkerSummaries" as never], + streamReconstructor: { seed: vi.fn(), clear: vi.fn() }, }); - const internals = supervisor as unknown as { adoptOrRecoverWorker(worker: object): Promise }; + supervisor.writeRosterEntry(staleEntry, worker); + const root = summary({ id: "worker-1-root-active", sessionId: "root", activeSessionId: "worker-1-root-active" }); + let pulls = 0; + worker.client = { + request: vi.fn(async () => { + pulls += 1; + // Every pull straddles a frame: deletions keep landing while stale responses still carry the child. + supervisor.consumeWorkerRosterDelta(worker, rosterDelta([], [staleEntry.agentId])); + return { type: "response", command: "list", success: true, data: { sessions: [root, staleChild] } }; + }), + }; + + await ( + supervisor as unknown as { refreshWorkerSummaries(worker: WorkerFixture, recovery: boolean): Promise } + ).refreshWorkerSummaries(worker, true); - await internals.adoptOrRecoverWorker(worker); + expect(pulls).toBe(2); + expect(supervisor.roster().has(staleEntry.agentId)).toBe(false); + }); - // The old process is never adopted; the existing recovery machinery restarts it from the current binary. - expect(recoverWorker).toHaveBeenCalledWith(worker); - expect(subscribeWorker).not.toHaveBeenCalled(); - expect(worker.descriptor.lifecycle).toBe("ready"); + it("delivers one queued snapshot through a real backpressured worker socket", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-socket-")); + tempDirs.push(directory); + const socketPath = join(directory, "worker.sock"); + const received: Buffer[] = []; + let connected: (socket: import("node:net").Socket) => void = () => {}; + const connection = new Promise((resolveSocket) => { + connected = resolveSocket; + }); + const server = createServer((socket) => { + socket.on("data", (chunk: Buffer) => received.push(Buffer.from(chunk))); + connected(socket); + }); + await new Promise((resolveListen) => server.listen(socketPath, resolveListen)); + const clientSocket = connect(socketPath); + await new Promise((resolveConnect) => clientSocket.once("connect", () => resolveConnect())); + await connection; + + const client = { transport: "private-framed", authenticated: true, socket: clientSocket }; + const reporter = { + lastComposed: new Map(), + lastComposedJson: new Map(), + queuedChildren: new Map(), + removedAgentIds: new Set(), + snapshotPending: true, + }; + for (let index = 0; index < 3000; index++) { + const entry: WorkerRosterEntry = { + agentId: `child-${index}`, + queuedChild: true, + summary: summary({ + id: `child-${index}`, + sessionId: `child-${index}`, + runtimeKind: "subagent", + rlmChildId: `child-${index}`, + firstMessage: "x".repeat(512), + }), + }; + reporter.queuedChildren.set(entry.agentId, entry); + } + const daemon = Object.assign(Object.create(AgentDaemon.prototype), { + options: { worker: { authenticationToken: "token" } }, + sessions: new Map(), + cronStore: { list: () => [] }, + clients: new Set([client]), + supervisorClaims: new Map([[client, {}]]), + rosterReporter: reporter, + rosterFlushScheduled: false, + shuttingDown: false, + log: vi.fn(), + }) as { flushRoster(): void }; + + daemon.flushRoster(); + daemon.flushRoster(); + await vi.waitFor(() => { + const frames = new PrivateFrameDecoder(isDaemonWorkerFrameHeader).push(Buffer.concat(received)); + expect(frames.length).toBeGreaterThan(0); + }); + // One multi-megabyte snapshot: queued past the high-water mark, delivered once, never resent. + await new Promise((resolveDelay) => setTimeout(resolveDelay, 150)); + const frames = new PrivateFrameDecoder(isDaemonWorkerFrameHeader).push(Buffer.concat(received)); + expect(frames).toHaveLength(1); + const worker = makeWorker("worker-1"); + const supervisor = makeSupervisor([worker], { rlmSpawnLedger: () => ({ edges: vi.fn(async () => []) }) }); + supervisor.consumeWorkerRosterDelta(worker, frames[0]?.payload as Buffer); + await vi.waitFor(() => expect(supervisor.workerRosterEntries(worker)).toHaveLength(3000)); + clientSocket.destroy(); + server.close(); }); it("routes a just-bound session through the miss-path refresh", async () => { diff --git a/packages/coding-agent/test/daemon-supervisor-process.test.ts b/packages/coding-agent/test/daemon-supervisor-process.test.ts index 5d973738df..d4da8f0a95 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"); @@ -336,6 +342,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 was 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"); From 0082257dc5f45dc6b879d9b663e7f768b5f8f85c Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 29 Aug 2026 21:37:21 +0200 Subject: [PATCH 14/48] test(coding-agent): await owned process exits before teardown rmSync The shared afterEach now awaits every tracked child and worker pid before deleting temp directories, and rmSync retries transient failures, so a dying worker's log writer cannot race the cleanup into ENOTEMPTY. Hardened in the shared helper because every test in this file spawns supervisors and workers through the same teardown. ENG-5794 --- packages/coding-agent/test/daemon-supervisor-process.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/test/daemon-supervisor-process.test.ts b/packages/coding-agent/test/daemon-supervisor-process.test.ts index d4da8f0a95..c7c0de2593 100644 --- a/packages/coding-agent/test/daemon-supervisor-process.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-process.test.ts @@ -52,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 { @@ -69,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 }); } }); From 233b958b932670ed292c39fc26c13f30db40f2ea Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 29 Aug 2026 22:18:14 +0200 Subject: [PATCH 15/48] fix(coding-agent): serialize roster pulls with frame applies and harden owner resolution - bump the roster epoch at frame receipt and route pull gap-fills through the one per-worker apply chain (chainWorkerRosterApply) - resolve delete owners through findWorkerBySessionFile, which now also consults pulled worker summaries for unflushed child rows - restartPreRosterWorker launches a replacement only against a confirmed-stopped predecessor; unverifiable live processes keep the worker failed - canonicalize session paths in findActiveSessionByFile so the active guard matches the tombstone/removal side across symlinks - flush the roster projection after execute_bash_and_wait --- .../src/modes/daemon/daemon-mode.ts | 12 +++- .../src/modes/daemon/daemon-supervisor.ts | 67 +++++++++++++------ 2 files changed, 56 insertions(+), 23 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index a065e7c80a..ce4ccafe6b 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -4262,7 +4262,12 @@ 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 { + // executeBash appends transcript messages without bash_* events; flush the roster projection here. + this.scheduleRosterFlush(); + } } case "abort_bash": { @@ -6175,10 +6180,11 @@ export class AgentDaemon { } private findActiveSessionByFile(sessionPath: string): ActiveSessionState | undefined { - const resolvedSessionPath = resolve(sessionPath); + // Symlink-resolving canonicalization: guards compare the same path the tombstone/removal side uses. + 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; } } diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index a67a73b6af..1ce9238d08 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -2124,10 +2124,8 @@ export class DaemonSupervisor { if (entry?.summary.activeSessionId !== undefined) { throw new Error("Cannot delete the currently active session"); } - // Descriptor paths cover owners whose rows are not claimed yet (startup, adoption, recovery). - const owner = - (entry?.workerId !== undefined ? this.workers.get(entry.workerId) : undefined) ?? - this.findWorkerBySessionFile(command.sessionPath); + // Descriptor and summary paths cover owners whose rows are not flushed yet (startup, adoption, fresh children). + const owner = this.findWorkerBySessionFile(command.sessionPath); if (owner) { // The owning worker deletes its own passivated files and publishes the removal itself. if (owner.client && !this.isWorkerStopping(owner)) { @@ -3091,11 +3089,25 @@ export class DaemonSupervisor { if (worker.descriptor.processStartId === undefined && observedProcessStartId !== undefined) { worker.descriptor.processStartId = observedProcessStartId; } - const safeToKill = - isProcessAlive(worker.descriptor.pid) && - worker.descriptor.processStartId !== undefined && - getProcessStartId(worker.descriptor.pid) === worker.descriptor.processStartId; - await this.recoverUncertainWorkerOperations(worker, safeToKill); + 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); + } + } + // A replacement authenticates against the old socket unless the old process is confirmed stopped. + if (initialIdentity === "unknown" || identity() === "current") { + 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; } @@ -3600,7 +3612,7 @@ export class DaemonSupervisor { } const epochAtStart = worker.rosterEpoch ?? 0; const response = await worker.client.request({ type: "list" }, 5000); - // A frame applied mid-pull can remove rows this stale pull would resurrect; re-pull once, then skip the fill. + // 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); } @@ -3612,7 +3624,12 @@ export class DaemonSupervisor { } worker.summaries = nextSummaries; // Launch and recovery pulls carry registry children no delta composes; fill their missing rows. - if (fillGaps && (worker.rosterEpoch ?? 0) === epochAtStart) this.fillRosterGapsFromWorkerSummaries(worker); + // The fill queues behind in-flight frame applies and re-checks the epoch there, so it never treats an unapplied snapshot as stable. + if (fillGaps) { + await this.chainWorkerRosterApply(worker, () => { + if ((worker.rosterEpoch ?? 0) === epochAtStart) this.fillRosterGapsFromWorkerSummaries(worker); + }); + } for (const summary of summaries) { const activeSessionId = summary.activeSessionId ?? summary.id; if (summary.streamingMessage?.role === "assistant") { @@ -3766,29 +3783,35 @@ export class DaemonSupervisor { return; } if (delta.type !== "roster_delta" || !Array.isArray(delta.entries)) return; + // The epoch bumps at frame receipt, before any async apply work, so an in-flight pull sees this frame. + worker.rosterEpoch = (worker.rosterEpoch ?? 0) + 1; if (delta.snapshot !== true && worker.rosterApplyChain === undefined) { this.applyWorkerRosterDelta(worker, delta); return; } - // Snapshots pre-read the ledger, so later frames queue behind them to keep per-worker order. + this.chainWorkerRosterApply(worker, () => + delta.snapshot === true + ? this.applyWorkerRosterSnapshot(worker, delta) + : this.applyWorkerRosterDelta(worker, delta), + ); + } + + /** One per-worker serialization for every roster write: frames and pull fills apply in receipt order. */ + private chainWorkerRosterApply(worker: ResidentWorker, apply: () => void | Promise): Promise { const chained = (worker.rosterApplyChain ?? Promise.resolve()) - .then(() => - delta.snapshot === true - ? this.applyWorkerRosterSnapshot(worker, delta) - : this.applyWorkerRosterDelta(worker, delta), - ) + .then(apply) .catch((error: unknown) => this.log(`could not apply a roster frame: ${String(error)}`)); worker.rosterApplyChain = chained; void chained.finally(() => { if (worker.rosterApplyChain === chained) worker.rosterApplyChain = undefined; }); + return chained; } private applyWorkerRosterDelta( worker: ResidentWorker, delta: Extract, ): void { - worker.rosterEpoch = (worker.rosterEpoch ?? 0) + 1; for (const entry of delta.entries) { this.writeRosterEntry(entry, worker); this.syncRootDescriptorFromRosterEntry(worker, entry); @@ -3809,7 +3832,6 @@ export class DaemonSupervisor { this.log(`Could not read the spawn ledger during a snapshot apply: ${String(error)}`); return [] as RlmLedgerEdge[]; }); - worker.rosterEpoch = (worker.rosterEpoch ?? 0) + 1; // A live worker's snapshot carries its passivated rows too; absence means removal, disk backs the rest. const sent = new Set(delta.entries.map((entry) => entry.agentId)); for (const entry of this.workerRosterEntries(worker)) { @@ -4170,12 +4192,17 @@ export class DaemonSupervisor { }); } + /** The one owner resolution by session file: claimed roster rows, pulled summaries, then descriptor paths. */ 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 = targetEntry?.workerId === worker.descriptor.workerId; + const summaryMatches = + targetEntry?.workerId === worker.descriptor.workerId || + [...worker.summaries.values()].some( + (summary) => summary.sessionFile !== undefined && canonicalSessionPath(summary.sessionFile) === target, + ); const descriptorPath = worker.descriptor.sessionFile ? canonicalSessionPath(worker.descriptor.sessionFile) : undefined; From 8545c2097a852fe9928e39d34d6cd38d7490c19e Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 30 Aug 2026 00:30:22 +0200 Subject: [PATCH 16/48] test(coding-agent): pin snapshot/pull serialization, pre-roster restart guard, symlink delete guard - a pull fill queued behind an in-flight snapshot re-claims reseeded rows - an unverifiable live pre-roster worker stays failed with no replacement - delete_saved_session through a symlink hits the active-session guard --- .../test/daemon-agent-roster.test.ts | 75 ++++++++++++++++++- .../coding-agent/test/daemon-mode.test.ts | 34 +++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index ed2ac65477..4f3ed4ad16 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -387,7 +387,9 @@ interface WorkerFixture { workerId: string; pid: number; rootActiveSessionId: string; - lifecycle: "ready"; + lifecycle: "ready" | "failed"; + processStartId?: string; + lastError?: string; ownerClientId?: string; }; client?: { request: ReturnType }; @@ -1391,6 +1393,77 @@ describe("review-round regressions", () => { }); }); + 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", + }); + const childEntry = workerRosterEntryFromSummary(child); + 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: () => ({ edges: () => 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)); + + expect(supervisor.roster().get(childEntry.agentId)?.workerId).toBe("worker-1"); + }); + + it("keeps an unverifiable live pre-roster worker failed instead of launching a 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"); + }); + it("skips the gap fill when a roster frame lands mid-pull", async () => { const worker = makeWorker("worker-1"); Object.assign(worker.descriptor, { createCommand: { type: "create" } }); 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 { From 993b884265003fefd9e23a08f40f83cc96cc4bd0 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 30 Aug 2026 01:18:40 +0200 Subject: [PATCH 17/48] fix(coding-agent): abort queued roster applies for unregistered workers; launch only on a confirmed-stopped predecessor - chained frame applies and pull fills re-check the worker registration before running and after the snapshot's ledger pre-read, so a stop can never be overwritten by a resumed apply - a failed partial apply schedules one gap-fill pull as repair - restartPreRosterWorker launches only when the final identity verdict is gone or replaced; a current-to-unknown flip keeps the worker failed --- .../src/modes/daemon/daemon-supervisor.ts | 26 ++++++++-- .../test/daemon-agent-roster.test.ts | 51 +++++++++++++++++++ 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 1ce9238d08..d6086282a3 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -3099,8 +3099,9 @@ export class DaemonSupervisor { await delay(25); } } - // A replacement authenticates against the old socket unless the old process is confirmed stopped. - if (initialIdentity === "unknown" || identity() === "current") { + // Launch only against a confirmed-stopped predecessor; "unknown" may still hold the old socket. + 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); @@ -3796,11 +3797,20 @@ export class DaemonSupervisor { ); } - /** One per-worker serialization for every roster write: frames and pull fills apply in receipt order. */ + /** Queued frames and pull fills apply in receipt order and abort once the registration is gone; synchronous lifecycle writes need no queue. */ private chainWorkerRosterApply(worker: ResidentWorker, apply: () => void | Promise): Promise { const chained = (worker.rosterApplyChain ?? Promise.resolve()) - .then(apply) - .catch((error: unknown) => this.log(`could not apply a roster frame: ${String(error)}`)); + .then(() => { + if (!this.isWorkerRosterApplyCurrent(worker)) return; + return apply(); + }) + .catch((error: unknown) => { + // A partial apply may have deleted rows it never rewrote; one gap-fill pull repairs the ledger. + this.log(`could not apply a roster frame: ${String(error)}`); + if (this.isWorkerRosterApplyCurrent(worker) && worker.client) { + void this.refreshWorkerSummaries(worker, false, true).catch(() => undefined); + } + }); worker.rosterApplyChain = chained; void chained.finally(() => { if (worker.rosterApplyChain === chained) worker.rosterApplyChain = undefined; @@ -3808,6 +3818,10 @@ export class DaemonSupervisor { return chained; } + private isWorkerRosterApplyCurrent(worker: ResidentWorker): boolean { + return this.workers.get(worker.descriptor.workerId) === worker; + } + private applyWorkerRosterDelta( worker: ResidentWorker, delta: Extract, @@ -3832,6 +3846,8 @@ export class DaemonSupervisor { this.log(`Could not read the spawn ledger during a snapshot apply: ${String(error)}`); return [] as RlmLedgerEdge[]; }); + // A stop during the pre-read unregisters the worker and flips its rows inactive; applying now would resurrect them. + if (!this.isWorkerRosterApplyCurrent(worker)) return; // A live worker's snapshot carries its passivated rows too; absence means removal, disk backs the rest. const sent = new Set(delta.entries.map((entry) => entry.agentId)); for (const entry of this.workerRosterEntries(worker)) { diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index 4f3ed4ad16..ae35d5e66f 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -1442,6 +1442,35 @@ describe("review-round regressions", () => { expect(supervisor.roster().get(childEntry.agentId)?.workerId).toBe("worker-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: () => ({ edges: () => edgesPromise }) }); + supervisor.writeRosterEntry(rootEntry, worker); + + // A snapshot is mid pre-read and a delta is queued behind it when the stop lands. + supervisor.consumeWorkerRosterDelta(worker, rosterDelta([rootEntry], undefined, true)); + supervisor.consumeWorkerRosterDelta(worker, rosterDelta([rootEntry])); + 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(); + }); + it("keeps an unverifiable live pre-roster worker failed instead of launching a replacement", async () => { const worker = makeWorker("worker-1"); Object.assign(worker.descriptor, { pid: process.pid, processStartId: undefined }); @@ -1464,6 +1493,28 @@ describe("review-round regressions", () => { expect(worker.descriptor.lifecycle).toBe("failed"); }); + it("keeps a pre-roster worker failed when its identity turns unknown after the kill wait", async () => { + const worker = makeWorker("worker-1"); + const launchWorker = vi.fn(); + // The pid stays alive but its identity becomes unobservable right after the SIGKILL. + const processIdentity = vi.fn().mockReturnValueOnce("current").mockReturnValue("unknown"); + const supervisor = makeSupervisor([worker], { + assertRecoveryAllowed: vi.fn(async () => {}), + recoverUncertainWorkerOperations: vi.fn(async () => {}), + processIdentity, + launchWorker, + }); + + await ( + supervisor as unknown as { + restartPreRosterWorker(worker: WorkerFixture, observedProcessStartId?: string): Promise; + } + ).restartPreRosterWorker(worker, "start-id-1"); + + expect(launchWorker).not.toHaveBeenCalled(); + expect(worker.descriptor.lifecycle).toBe("failed"); + }); + it("skips the gap fill when a roster frame lands mid-pull", async () => { const worker = makeWorker("worker-1"); Object.assign(worker.descriptor, { createCommand: { type: "create" } }); From 9ef24b41649c8e3efc308ed0a2370c73cf9bf4f9 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 30 Aug 2026 01:24:50 +0200 Subject: [PATCH 18/48] test(coding-agent): let the stop land mid pre-read in the snapshot-abort pin --- packages/coding-agent/test/daemon-agent-roster.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index ae35d5e66f..fc8b309a43 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -1458,9 +1458,11 @@ describe("review-round regressions", () => { const supervisor = makeSupervisor([worker], { rlmSpawnLedger: () => ({ edges: () => edgesPromise }) }); supervisor.writeRosterEntry(rootEntry, worker); - // A snapshot is mid pre-read and a delta is queued behind it when the stop lands. + // 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([]); From 3163dae7563fb112f7dc6e22498303de2a96e233 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 30 Aug 2026 01:42:32 +0200 Subject: [PATCH 19/48] fix(coding-agent): single-flight roster repair pull with a logged failure - a per-worker marker caps repair pulls at one in flight; repeated apply failures reuse it and a failing repair cannot respawn itself - a failed repair logs one warning naming the worker --- .../src/modes/daemon/daemon-supervisor.ts | 20 ++++++++-- .../test/daemon-agent-roster.test.ts | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index d6086282a3..3a9a4e3c0d 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -316,6 +316,8 @@ interface ResidentWorker { rosterEpoch?: number; /** Serializes snapshot applications (and any deltas behind them) per worker. */ rosterApplyChain?: Promise; + /** Single-flight marker for the gap-fill pull that repairs a failed roster apply. */ + rosterRepairPull?: Promise; } interface SnapshotDuplicateValidation { @@ -3805,11 +3807,8 @@ export class DaemonSupervisor { return apply(); }) .catch((error: unknown) => { - // A partial apply may have deleted rows it never rewrote; one gap-fill pull repairs the ledger. this.log(`could not apply a roster frame: ${String(error)}`); - if (this.isWorkerRosterApplyCurrent(worker) && worker.client) { - void this.refreshWorkerSummaries(worker, false, true).catch(() => undefined); - } + this.scheduleRosterRepairPull(worker); }); worker.rosterApplyChain = chained; void chained.finally(() => { @@ -3822,6 +3821,19 @@ export class DaemonSupervisor { return this.workers.get(worker.descriptor.workerId) === worker; } + /** A partial apply may have deleted rows it never rewrote; one single-flight gap-fill pull repairs the ledger. */ + 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, diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index fc8b309a43..0b42fa6ecd 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -1473,6 +1473,43 @@ describe("review-round regressions", () => { expect(entry?.summary.activeSessionId).toBeUndefined(); }); + it("repairs failed roster applies with one single-flight pull that never respawns itself", async () => { + const worker = makeWorker("worker-1"); + let repairs = 0; + const supervisor = makeSupervisor([worker], { + applyWorkerRosterSnapshot: vi.fn(async () => { + throw new Error("apply exploded"); + }), + refreshWorkerSummaries: vi.fn(() => { + repairs += 1; + return new Promise(() => {}); + }), + }); + + supervisor.consumeWorkerRosterDelta(worker, rosterDelta([], undefined, true)); + supervisor.consumeWorkerRosterDelta(worker, rosterDelta([], undefined, true)); + supervisor.consumeWorkerRosterDelta(worker, rosterDelta([], undefined, true)); + await new Promise((resolveSettle) => setImmediate(resolveSettle)); + expect(repairs).toBe(1); + + // A repair that itself fails logs the worker and does not spawn another pull. + const failingWorker = makeWorker("worker-2"); + const log = vi.fn(); + const failingSupervisor = makeSupervisor([failingWorker], { + applyWorkerRosterSnapshot: vi.fn(async () => { + throw new Error("apply exploded"); + }), + refreshWorkerSummaries: vi.fn(async () => { + throw new Error("repair pull failed"); + }), + log, + }); + failingSupervisor.consumeWorkerRosterDelta(failingWorker, rosterDelta([], undefined, true)); + await new Promise((resolveSettle) => setImmediate(resolveSettle)); + expect(failingSupervisor.refreshWorkerSummaries).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith(expect.stringContaining("Roster repair pull failed for worker worker-2")); + }); + it("keeps an unverifiable live pre-roster worker failed instead of launching a replacement", async () => { const worker = makeWorker("worker-1"); Object.assign(worker.descriptor, { pid: process.pid, processStartId: undefined }); From c3eb977e6fa8e96c6f89433157db4601f3f9b56b Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 30 Aug 2026 02:10:02 +0200 Subject: [PATCH 20/48] test(coding-agent): reduce the roster suite to distinct behavior pins - drop the delete round-trip, supervisor unknown-target classification, modelFallbackMessage projection, discarded-draft removal ids, and the duplicated ledger-read-abort scenario; each surviving pin is named in the review ledger - one makeOfflineSupervisor helper replaces four hand-rolled real supervisor constructions; the queued-child test now also pins delta removals --- .../test/daemon-agent-roster.test.ts | 264 +++--------------- 1 file changed, 40 insertions(+), 224 deletions(-) diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index 0b42fa6ecd..b37914bce5 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -458,6 +458,26 @@ function makeSupervisor(workers: WorkerFixture[], extra: Record }) 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({ @@ -516,6 +536,10 @@ describe("supervisor roster ledger", () => { expect(listed.data?.sessions[0]).toMatchObject({ activeSessionId: "child-active", workerState: "ready" }); expect(supervisor.workerRosterEntries(worker)[0]).toMatchObject({ status: "running" }); expect(supervisor.workerRosterEntries(worker)[0]?.statusLabel).toBeUndefined(); + + // A published removal drops the row from the ledger. + supervisor.consumeWorkerRosterDelta(worker, rosterDelta([], ["child-1"])); + expect(supervisor.roster().has("child-1")).toBe(false); }); it("keeps passivated children of a live worker in the resident list, seeded rows in list all only", async () => { @@ -840,13 +864,8 @@ describe("supervisor roster ledger", () => { }); it("updates the roster on offline saved-session renames", async () => { - const directory = mkdtempSync(join(tmpdir(), "prime-roster-offline-rename-")); - tempDirs.push(directory); + const { directory, supervisor } = makeOfflineSupervisor("prime-roster-offline-rename-"); const sessionPath = join(directory, "saved.jsonl"); - const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { - defaultSessionConfig: { agentDir: directory, cwd: directory }, - descriptorDir: join(directory, "workers"), - }) as unknown as SupervisorFixture & { handleCommand(client: object, command: object): Promise }; Object.assign(supervisor, { catalog: { rename: vi.fn(async () => {}), list: vi.fn(async () => []) }, rlmLedgerSiblings: vi.fn(async () => [ @@ -868,30 +887,17 @@ describe("supervisor roster ledger", () => { ), ); - await supervisor.handleCommand( - { id: "client", attachedActiveSessionIds: new Set() }, - { type: "rename_saved_session", sessionPath, name: "new-name" }, - ); + await supervisor.handleCommand(offlineClient(), { type: "rename_saved_session", sessionPath, name: "new-name" }); expect(supervisor.roster().get("saved-1")?.summary.sessionName).toBe("new-name"); }); it("removes the roster row on offline deletes, tombstones subagents, and never reseeds them", async () => { - const directory = mkdtempSync(join(tmpdir(), "prime-roster-offline-delete-")); - tempDirs.push(directory); - const sessionsDir = join(directory, "sessions"); - const parentPath = join(sessionsDir, "root.jsonl"); - const childPath = join(directory, "artifacts", "child.jsonl"); - 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, { + 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" }); @@ -907,10 +913,7 @@ describe("supervisor roster ledger", () => { ); supervisor.writeRosterEntry(childEntry); - await supervisor.handleCommand( - { id: "client", attachedActiveSessionIds: new Set() }, - { type: "delete_saved_session", sessionPath: childPath }, - ); + 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([]); @@ -926,72 +929,6 @@ describe("supervisor roster ledger", () => { }); describe("saved-session delete paths", () => { - it("removes the deleted session's ledger row end-to-end", async () => { - const directory = mkdtempSync(join(tmpdir(), "prime-roster-worker-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(); - const sessionId = manager.getSessionId(); - if (!sessionPath) throw new Error("Fixture session did not persist"); - - const daemon = new AgentDaemon(join(directory, "worker.sock"), { - defaultSessionConfig: { agentDir: directory, cwd: directory, sessionDir: sessionsDir }, - worker: { - authenticationToken: "token", - workerId: "worker-1", - rootActiveSessionId: "root-active", - } as never, - createRuntime: async () => { - throw new Error("unexpected runtime creation"); - }, - } as never); - const socket = new PassThrough(); - const written: Buffer[] = []; - socket.on("data", (chunk: Buffer) => written.push(Buffer.from(chunk))); - const supervisorClient = { - id: "supervisor", - socket, - transport: "private-framed", - authenticated: true, - attachedActiveSessionIds: new Set(), - detachInput: () => {}, - supportsExtensionUi: false, - capabilities: new Set(), - } as unknown as DaemonSocketClient; - const internals = daemon as unknown as { - clients: Set; - supervisorClaims: Map; - handleCommand(client: DaemonSocketClient, command: object): Promise; - flushRoster(): void; - }; - internals.clients.add(supervisorClient); - internals.supervisorClaims.set(supervisorClient, {}); - - await internals.handleCommand(supervisorClient, { type: "delete_saved_session", sessionPath }); - internals.flushRoster(); - - const decoder = new PrivateFrameDecoder(isDaemonWorkerFrameHeader); - const frames = decoder.push(Buffer.concat(written)); - const deltaFrame = frames.find( - (frame) => frame.header.kind === "outbound" && frame.header.outboundType === "roster_delta", - ); - if (!deltaFrame) throw new Error("Worker did not publish a roster delta"); - const delta = JSON.parse(deltaFrame.payload.toString("utf8")) as RosterDelta; - expect(delta.removedAgentIds).toEqual([sessionId]); - - const worker = makeWorker("worker-1", { rosterCapable: true }); - const supervisor = makeSupervisor([worker]); - supervisor.writeRosterEntry( - workerRosterEntryFromSummary(summary({ id: sessionId, sessionId, sessionFile: sessionPath })), - worker, - ); - supervisor.consumeWorkerRosterDelta(worker, deltaFrame.payload); - expect(supervisor.roster().has(sessionId)).toBe(false); - }); - 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" } }); @@ -1070,81 +1007,30 @@ describe("saved-session delete paths", () => { expect(catalogDelete).toHaveBeenCalledWith("/tmp/owned-failed.jsonl"); }); - it("classifies unknown offline delete targets through the ledger", async () => { - const directory = mkdtempSync(join(tmpdir(), "prime-roster-offline-unknown-")); - tempDirs.push(directory); - const garbled = join(directory, "artifacts", "garbled.jsonl"); - mkdirSync(dirname(garbled), { recursive: true }); - writeFileSync(garbled, "not a session header\n"); - const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { - defaultSessionConfig: { agentDir: directory, cwd: directory }, - descriptorDir: join(directory, "workers"), - }) as unknown as SupervisorFixture & { - handleCommand(client: object, command: object): Promise; - rlmSpawnLedger(): RlmSpawnLedger; - }; - Object.assign(supervisor, { - catalog: { delete: vi.fn(async () => ({ ok: true, method: "unlink" })), list: vi.fn(async () => []) }, - }); - await supervisor.rlmSpawnLedger().appendSpawn({ - childId: "sub-9", - parent: join(directory, "sessions", "r.jsonl"), - child: garbled, - depth: 1, - name: "g", - }); - - await supervisor.handleCommand( - { id: "client", attachedActiveSessionIds: new Set() }, - { type: "delete_saved_session", sessionPath: garbled }, - ); - - await expect(supervisor.rlmSpawnLedger().edges()).resolves.toEqual([]); - }); - it("keeps the roster row when a delete fails on disk", async () => { - const directory = mkdtempSync(join(tmpdir(), "prime-roster-failed-delete-")); - tempDirs.push(directory); - const sessionPath = join(directory, "saved.jsonl"); - const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { - defaultSessionConfig: { agentDir: directory, cwd: directory }, - descriptorDir: join(directory, "workers"), - }) as unknown as SupervisorFixture & { handleCommand(client: object, command: object): Promise }; - Object.assign(supervisor, { + 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( - { id: "client", attachedActiveSessionIds: new Set() }, - { type: "delete_saved_session", sessionPath }, - ); + await supervisor.handleCommand(offlineClient(), { type: "delete_saved_session", sessionPath }); expect(supervisor.roster().has("saved-1")).toBe(true); }); it("aborts a saved-child delete when the tombstone append fails", async () => { - const directory = mkdtempSync(join(tmpdir(), "prime-roster-tombstone-fail-")); - tempDirs.push(directory); - const childPath = join(directory, "artifacts", "child.jsonl"); const catalogDelete = vi.fn(async () => ({ ok: true, method: "unlink" })); - const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), { - defaultSessionConfig: { agentDir: directory, cwd: directory }, - descriptorDir: join(directory, "workers"), - }) as unknown as SupervisorFixture & { handleCommand(client: object, command: object): Promise }; - Object.assign(supervisor, { + 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(directory, "sessions", "root.jsonl"), - depth: 1, - name: "c", - }, + { childId: "child-1", child: childPath, parent: join(sessionsDir, "root.jsonl"), depth: 1, name: "c" }, ]), appendDelete: vi.fn(async () => { throw new Error("ledger unwritable"); @@ -1158,35 +1044,19 @@ describe("saved-session delete paths", () => { sessionFile: childPath, runtimeKind: "subagent", rlmChildId: "child-1", - parentSessionPath: join(directory, "sessions", "root.jsonl"), + parentSessionPath: join(sessionsDir, "root.jsonl"), }), ); supervisor.writeRosterEntry(childEntry); await expect( - supervisor.handleCommand( - { id: "client", attachedActiveSessionIds: new Set() }, - { type: "delete_saved_session", sessionPath: childPath }, - ), + 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("roster entry projection", () => { - it("carries modelFallbackMessage through the roster round-trip", () => { - const source = summary({ - id: "m-active", - sessionId: "m", - activeSessionId: "m-active", - modelFallbackMessage: "No models available", - }); - const roundTripped = sessionSummaryFromRosterEntry(workerRosterEntryFromSummary(source)); - expect(roundTripped.modelFallbackMessage).toBe("No models available"); - }); -}); - 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"); @@ -1728,30 +1598,6 @@ describe("review-round regressions", () => { expect(internals.rosterReporter.removedAgentIds.has("sub-1")).toBe(false); }); - it("publishes qualified removal ids for discarded bound-child drafts", () => { - const { daemon, sentDeltas } = makeWorkerReporter(); - const childState = makeState({ - activeSessionId: "child-active", - kind: "subagent", - rlmChildId: "sub-1", - parentActiveSessionId: "parent-active", - parentSessionFile: "/tmp/parents/root.jsonl", - }); - daemon.sessions.set(childState.activeSessionId, childState); - daemon.flushRoster(); - const composedId = sentDeltas[0]?.entries.find((entry) => entry.summary.rlmChildId === "sub-1")?.agentId; - if (!composedId) throw new Error("Missing composed child row"); - - daemon.sessions.delete(childState.activeSessionId); - const removalId = ( - daemon as unknown as { rosterAgentIdForState(state: ActiveSessionState): string } - ).rosterAgentIdForState(childState); - daemon.rosterReporter.removedAgentIds.add(removalId); - daemon.flushRoster(); - - expect(removalId).toBe(composedId); - expect(sentDeltas.at(-1)?.removedAgentIds).toEqual([composedId]); - }); }); describe("worker delete tombstone durability", () => { @@ -1770,36 +1616,6 @@ describe("worker delete tombstone durability", () => { }; } - it("aborts a child delete when the spawn ledger cannot be read", async () => { - const directory = mkdtempSync(join(tmpdir(), "prime-roster-ledger-read-fail-")); - tempDirs.push(directory); - const sessionsDir = join(directory, "sessions"); - const parentManager = SessionManager.create(directory, sessionsDir); - parentManager.appendMessage({ role: "user", content: "parent", timestamp: 1 }); - parentManager.flushNow(); - const parentFile = parentManager.getSessionFile(); - if (!parentFile) throw new Error("Fixture parent did not persist"); - const childManager = SessionManager.create(directory, join(directory, "artifacts")); - childManager.newSession({ parentSession: parentFile }); - childManager.appendMessage({ role: "user", content: "child", timestamp: 2 }); - childManager.flushNow(); - const childFile = childManager.getSessionFile(); - if (!childFile) throw new Error("Fixture child did not persist"); - const daemon = makeDeleteDaemon(directory, async () => { - throw new Error("ledger unreadable"); - }); - - await expect( - daemon.handleCommand( - { id: "client", attachedActiveSessionIds: new Set() }, - { type: "delete_saved_session", sessionPath: childFile }, - ), - ).rejects.toThrow("ledger unreadable"); - - expect(existsSync(childFile)).toBe(true); - expect(daemon.rosterReporter.removedAgentIds.size).toBe(0); - }); - 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); From c4bcbca760df7e9a67bb06712215118c49d22c62 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 30 Aug 2026 02:15:35 +0200 Subject: [PATCH 21/48] test(coding-agent): drop an unused import after the projection pin removal --- packages/coding-agent/test/daemon-agent-roster.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index b37914bce5..b0d005a5b7 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -9,7 +9,6 @@ import { SessionManager } from "../src/core/session-manager.js"; import type { ActiveSessionState, DaemonSocketClient } from "../src/modes/daemon/active-session-state.js"; import { type AgentRosterEntry, - sessionSummaryFromRosterEntry, type WorkerRosterEntry, workerRosterEntryFromSummary, } from "../src/modes/daemon/agent-roster.js"; From 603e04454f4d62b73b2103f5c68acc8b7cb80cfd Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 30 Aug 2026 02:16:15 +0200 Subject: [PATCH 22/48] test(coding-agent): fix formatting after the removal-id pin cut --- packages/coding-agent/test/daemon-agent-roster.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index b0d005a5b7..e9fc8e596c 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -1596,7 +1596,6 @@ describe("review-round regressions", () => { expect(internals.rosterReporter.removedAgentIds.has(expected)).toBe(true); expect(internals.rosterReporter.removedAgentIds.has("sub-1")).toBe(false); }); - }); describe("worker delete tombstone durability", () => { From 4465a37e2f272d47cbdacf48866b060659879659 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 30 Aug 2026 02:35:21 +0200 Subject: [PATCH 23/48] test(coding-agent): final reviewer-directed roster suite cuts - collision qualification folds into the queued-child lifecycle pin - one population matrix covers seeding, resident worker rows, and eviction; the standalone passivated-children test is absorbed - the supervisor staleness sweep pin moves to the push-layer test only - the two pre-roster restart scenarios become one named table - the real-socket test drops its fixed sleep; the top-level delete pin asserts the exact removed session id --- .../test/daemon-agent-roster.test.ts | 157 ++++++------------ 1 file changed, 48 insertions(+), 109 deletions(-) diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index e9fc8e596c..cfcbce518c 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -154,6 +154,18 @@ describe("worker roster reporter", () => { 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. + 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("child-1"); + // The child session materializes: same agentId, one resident row, no queued marker. const childState = makeState({ activeSessionId: "child-active", @@ -208,28 +220,6 @@ describe("worker roster reporter", () => { expect(sentDeltas.at(-1)?.entries.some((entry) => entry.agentId === "child-2")).toBe(false); }); - it("qualifies colliding child ids from different parents by parent path", () => { - const { daemon, sentDeltas } = makeWorkerReporter(); - const parentA = makeState({ activeSessionId: "parent-a", sessionFile: "/tmp/a.jsonl" }); - const parentB = makeState({ activeSessionId: "parent-b", sessionFile: "/tmp/b.jsonl" }); - daemon.sessions.set(parentA.activeSessionId, parentA); - daemon.sessions.set(parentB.activeSessionId, parentB); - - daemon.observeRosterEvent( - parentA, - childUpdate(parentA, { id: "sub-1234", label: "a", status: "queued", sessionDir: "/tmp/a" }), - ); - daemon.observeRosterEvent( - parentB, - childUpdate(parentB, { id: "sub-1234", label: "b", status: "queued", sessionDir: "/tmp/b" }), - ); - daemon.flushRoster(); - - const queuedRows = sentDeltas.at(-1)?.entries.filter((entry) => entry.summary.rlmChildId === "sub-1234") ?? []; - expect(queuedRows).toHaveLength(2); - expect(new Set(queuedRows.map((entry) => entry.agentId)).size).toBe(2); - }); - it("sends deltas only on change and flips closed sessions to non-resident instead of dropping them", () => { const { daemon, sentDeltas } = makeWorkerReporter(); const state = makeState({ @@ -426,7 +416,6 @@ interface SupervisorFixture { ): { success: boolean; data?: { sessions: SessionSummary[]; busyClientOwnedSessionCount?: number } }; handleWorkerClose(worker: WorkerFixture, client: object, error: Error): Promise; handleWorkerFrame(worker: WorkerFixture, frame: unknown): void; - sweepRosterStaleness(now?: number): void; writeRosterEntry(entry: WorkerRosterEntry, worker?: WorkerFixture): AgentRosterEntry; workerRosterEntries(worker: WorkerFixture): AgentRosterEntry[]; flipWorkerRosterEntriesInactive(worker: WorkerFixture): void; @@ -541,45 +530,6 @@ describe("supervisor roster ledger", () => { expect(supervisor.roster().has("child-1")).toBe(false); }); - it("keeps passivated children of a live worker in the resident list, seeded rows in list all only", async () => { - const worker = makeWorker("worker-1"); - const supervisor = makeSupervisor([worker]); - supervisor.writeRosterEntry( - workerRosterEntryFromSummary( - summary({ id: "root-active", sessionId: "root", activeSessionId: "root-active" }), - ), - worker, - ); - supervisor.writeRosterEntry( - workerRosterEntryFromSummary( - summary({ - id: "child-session", - sessionId: "child-session", - sessionFile: "/tmp/artifacts/child.jsonl", - runtimeKind: "subagent", - rlmChildId: "child-1", - }), - ), - worker, - ); - supervisor.writeRosterEntry( - workerRosterEntryFromSummary(summary({ id: "seeded", sessionId: "seeded", sessionFile: "/tmp/seeded.jsonl" })), - ); - - const resident = await supervisor.handleList({}, { type: "list" }); - expect(resident.data?.sessions.map((session) => session.sessionId).sort()).toEqual(["child-session", "root"]); - const child = resident.data?.sessions.find((session) => session.rlmChildId === "child-1"); - expect(child).toMatchObject({ workerPid: 1234 }); - expect(child?.activeSessionId).toBeUndefined(); - - const all = await supervisor.handleList({}, { type: "list", all: true }); - expect(all.data?.sessions.map((session) => session.sessionId).sort()).toEqual([ - "child-session", - "root", - "seeded", - ]); - }); - it("serves list from the ledger with zero worker round-trips and exact busy counts", async () => { const visible = makeWorker("visible"); const owned = makeWorker("owned", { @@ -721,24 +671,7 @@ describe("supervisor roster ledger", () => { expect(entry?.summary.activeSessionId).toBe("r-active"); }); - it("stamps staleness while a worker is silent and clears it when frames resume", () => { - const now = Date.parse("2026-08-01T12:00:00.000Z"); - const worker = makeWorker("worker-1", { rosterCapable: true, lastFrameAt: now - 60_000 }); - const supervisor = makeSupervisor([worker]); - supervisor.writeRosterEntry( - workerRosterEntryFromSummary(summary({ id: "s-active", sessionId: "s", activeSessionId: "s-active" })), - worker, - ); - - supervisor.sweepRosterStaleness(now); - expect(supervisor.workerRosterEntries(worker)[0]?.lastHeardFromAt).toBe(new Date(now - 60_000).toISOString()); - - worker.lastFrameAt = now; - supervisor.sweepRosterStaleness(now); - expect(supervisor.workerRosterEntries(worker)[0]?.lastHeardFromAt).toBeUndefined(); - }); - - it("seeds catalog and spawn-ledger rows, skips tombstones, and keeps evicted rows inactive", async () => { + 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"); @@ -788,7 +721,7 @@ describe("supervisor roster ledger", () => { expect(listed.data?.sessions.every((session) => session.activeSessionId === undefined)).toBe(true); expect((await supervisor.handleList({}, { type: "list" })).data?.sessions).toEqual([]); - // An evicted worker leaves its rows behind as inactive instead of dropping them. + // 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( @@ -803,6 +736,28 @@ describe("supervisor roster ledger", () => { ), 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(); + + // Eviction leaves the worker's rows behind as inactive instead of dropping them. supervisor.workers.delete("worker-1"); supervisor.flipWorkerRosterEntriesInactive(worker); @@ -810,6 +765,7 @@ describe("supervisor roster ledger", () => { 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); }); @@ -1379,46 +1335,30 @@ describe("review-round regressions", () => { expect(log).toHaveBeenCalledWith(expect.stringContaining("Roster repair pull failed for worker worker-2")); }); - it("keeps an unverifiable live pre-roster worker failed instead of launching a replacement", async () => { + it.each([ + { scenario: "live but unverifiable", verdicts: undefined }, + { scenario: "current then unknown after the kill wait", verdicts: ["current", "unknown"] }, + ])("keeps a pre-roster worker failed with no replacement when its identity is $scenario", async ({ verdicts }) => { const worker = makeWorker("worker-1"); - Object.assign(worker.descriptor, { pid: process.pid, processStartId: undefined }); + if (!verdicts) 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, + ...(verdicts + ? { processIdentity: vi.fn().mockReturnValueOnce(verdicts[0]).mockReturnValue(verdicts[1]) } + : {}), }); 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"); - }); - - it("keeps a pre-roster worker failed when its identity turns unknown after the kill wait", async () => { - const worker = makeWorker("worker-1"); - const launchWorker = vi.fn(); - // The pid stays alive but its identity becomes unobservable right after the SIGKILL. - const processIdentity = vi.fn().mockReturnValueOnce("current").mockReturnValue("unknown"); - const supervisor = makeSupervisor([worker], { - assertRecoveryAllowed: vi.fn(async () => {}), - recoverUncertainWorkerOperations: vi.fn(async () => {}), - processIdentity, - launchWorker, - }); - - await ( - supervisor as unknown as { - restartPreRosterWorker(worker: WorkerFixture, observedProcessStartId?: string): Promise; - } - ).restartPreRosterWorker(worker, "start-id-1"); + ).restartPreRosterWorker(worker, verdicts ? "start-id-1" : undefined); + if (!verdicts) expect(recoverUncertainWorkerOperations).toHaveBeenCalledWith(worker, false); expect(launchWorker).not.toHaveBeenCalled(); expect(worker.descriptor.lifecycle).toBe("failed"); }); @@ -1519,7 +1459,6 @@ describe("review-round regressions", () => { expect(frames.length).toBeGreaterThan(0); }); // One multi-megabyte snapshot: queued past the high-water mark, delivered once, never resent. - await new Promise((resolveDelay) => setTimeout(resolveDelay, 150)); const frames = new PrivateFrameDecoder(isDaemonWorkerFrameHeader).push(Buffer.concat(received)); expect(frames).toHaveLength(1); const worker = makeWorker("worker-1"); @@ -1633,7 +1572,7 @@ describe("worker delete tombstone durability", () => { ); expect(existsSync(sessionPath)).toBe(false); - expect(daemon.rosterReporter.removedAgentIds.size).toBe(1); + expect([...daemon.rosterReporter.removedAgentIds]).toEqual([manager.getSessionId()]); }); it("classifies an unreadable delete target through the ledger", async () => { const setup = () => { From 1257659a001e78ec05342e2291a634ec337e62b9 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 30 Aug 2026 02:42:09 +0200 Subject: [PATCH 24/48] test(coding-agent): biome format for the population matrix --- packages/coding-agent/test/daemon-agent-roster.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index cfcbce518c..d4226150d2 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -749,10 +749,7 @@ describe("supervisor roster ledger", () => { worker, ); const resident = await supervisor.handleList({}, { type: "list" }); - expect(resident.data?.sessions.map((session) => session.sessionId).sort()).toEqual([ - "child-session", - "evicted", - ]); + 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(); From f56e5baff5d08fe88afb2ba021d94451a8ebc9bf Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 30 Aug 2026 02:52:03 +0200 Subject: [PATCH 25/48] test(coding-agent): pin resident and seeded rows side by side in one live list-all --- packages/coding-agent/test/daemon-agent-roster.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index d4226150d2..304de8c401 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -754,6 +754,16 @@ describe("supervisor roster ledger", () => { 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); From c88236ab639dc7be2c56397811ca1e60423a1e15 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 30 Aug 2026 03:04:43 +0200 Subject: [PATCH 26/48] =?UTF-8?q?chore(coding-agent):=20comment=20sweep=20?= =?UTF-8?q?=E2=80=94=20one-line=20present-tense=20rationale,=20drop=20dead?= =?UTF-8?q?=20fixture=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - condense the moved two-line busy-projection comment and the test section banners to one line each - present-tense fixes in two test comments - delete the dead rosterCapable/lastFrameAt/rosterStale fixture fields --- .../src/modes/daemon/daemon-supervisor.ts | 3 +-- .../test/daemon-agent-roster.test.ts | 17 +++++------------ .../test/daemon-supervisor-process.test.ts | 2 +- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 3a9a4e3c0d..e82d2937a4 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -827,8 +827,7 @@ export class DaemonSupervisor { .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. + // The canonical busy projection: a parent stays active for residency while any RLM descendant runs. isSessionActive: isSessionSummaryBusy(summary), attachedClients: [...this.clients].filter((client) => client.attachedActiveSessionIds.has(activeSessionId), diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index 304de8c401..3931d8ae6f 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -30,9 +30,7 @@ afterEach(() => { for (const directory of tempDirs.splice(0)) rmSync(directory, { recursive: true, force: true }); }); -// ------------------------------------------------------------------ -// Worker-side roster reporter (daemon-mode) -// ------------------------------------------------------------------ +// --- Worker-side roster reporter (daemon-mode) --- interface WorkerReporterFixture { daemon: { @@ -326,7 +324,7 @@ describe("worker roster reporter", () => { }) as { flushRoster(): void; rosterReporter: { snapshotPending: boolean; removedAgentIds: Set } }; daemon.flushRoster(); - // The queued write IS delivered: nothing stays pending and only the claimed socket was written. + // The queued write IS delivered: nothing stays pending and only the claimed socket receives the write. expect(write).toHaveBeenCalledTimes(1); expect(oldWrite).not.toHaveBeenCalled(); expect(daemon.rosterReporter.snapshotPending).toBe(false); @@ -352,9 +350,7 @@ describe("worker roster reporter", () => { }); }); -// ------------------------------------------------------------------ -// Supervisor-side roster ledger -// ------------------------------------------------------------------ +// --- Supervisor-side roster ledger --- function summary(overrides: Partial & Pick): SessionSummary { return { @@ -384,9 +380,6 @@ interface WorkerFixture { client?: { request: ReturnType }; summaries: Map; intentionalStop: boolean; - rosterCapable?: boolean; - lastFrameAt?: number; - rosterStale?: boolean; snapshotCache: Map; transcriptCaches: Map; snapshotGenerations: Map; @@ -1078,7 +1071,7 @@ describe("review-round regressions", () => { }); it("trusts frames only from the current and in-flight replacement connections", () => { - const worker = makeWorker("worker-1", { rosterCapable: true }); + const worker = makeWorker("worker-1"); const supervisor = makeSupervisor([worker], { streamReconstructor: { observe: vi.fn(), seed: vi.fn(), clear: vi.fn() }, }); @@ -1478,7 +1471,7 @@ describe("review-round regressions", () => { it("routes a just-bound session through the miss-path refresh", async () => { const target = summary({ id: "target-active", sessionId: "target", activeSessionId: "target-active" }); - const worker = makeWorker("worker-1", { rosterCapable: true }); + const worker = makeWorker("worker-1"); worker.client = { request: vi.fn(async () => ({ type: "response", diff --git a/packages/coding-agent/test/daemon-supervisor-process.test.ts b/packages/coding-agent/test/daemon-supervisor-process.test.ts index c7c0de2593..a425b18fd6 100644 --- a/packages/coding-agent/test/daemon-supervisor-process.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-process.test.ts @@ -463,7 +463,7 @@ describe("daemon supervisor resident workers", () => { 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 was not adopted. + // 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); From b262931a6a62cddab6ef61c20ae9b982ffebe1ef Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 30 Aug 2026 10:45:08 +0200 Subject: [PATCH 27/48] fix(coding-agent): republish retry/tool transitions and guard pulled root pointers - auto_retry_* and tool_execution_* events join the roster flush triggers: they flip isSessionActive/activity and isRunningTools; the flush already coalesces per tick and sends only changed rows - the pulled root descriptor persists through the per-worker apply chain under the epoch guard, so a stale list can never clobber pointers a frame updated mid-pull --- .../src/modes/daemon/daemon-mode.ts | 5 ++ .../src/modes/daemon/daemon-supervisor.ts | 18 +++--- .../test/daemon-agent-roster.test.ts | 62 +++++++++++++++++++ 3 files changed, 78 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index ce4ccafe6b..1537598a8c 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -7160,6 +7160,11 @@ const ROSTER_SESSION_EVENT_TRIGGERS = new Set([ "bash_end", "compaction_start", "compaction_end", + // Retry transitions flip isSessionActive/activity; tool transitions flip isRunningTools. + "auto_retry_start", + "auto_retry_end", + "tool_execution_start", + "tool_execution_end", "message_end", "session_action_update", "session_info_changed", diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index e82d2937a4..95ec28613e 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -3644,14 +3644,18 @@ 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, + // The pulled root persists through the same chain and epoch guard; a frame since the pull owns fresher pointers. + await this.chainWorkerRosterApply(worker, () => { + 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); } } diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index 3931d8ae6f..b08af7a06c 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -218,6 +218,30 @@ describe("worker roster reporter", () => { expect(sentDeltas.at(-1)?.entries.some((entry) => entry.agentId === "child-2")).toBe(false); }); + it("schedules a republish for retry and tool-execution transitions", () => { + const { daemon } = makeWorkerReporter(); + const state = makeState({ activeSessionId: "root-active" }); + daemon.sessions.set(state.activeSessionId, state); + const reporter = daemon as unknown as { rosterFlushScheduled: boolean }; + for (const type of ["auto_retry_start", "auto_retry_end", "tool_execution_start", "tool_execution_end"]) { + reporter.rosterFlushScheduled = false; + daemon.observeRosterEvent(state, { + type: "session_event", + activeSessionId: state.activeSessionId, + event: { type }, + }); + expect(reporter.rosterFlushScheduled, type).toBe(true); + } + // Events with no roster-visible field stay out of the trigger set. + reporter.rosterFlushScheduled = false; + daemon.observeRosterEvent(state, { + type: "session_event", + activeSessionId: state.activeSessionId, + event: { type: "message_start" }, + }); + expect(reporter.rosterFlushScheduled).toBe(false); + }); + it("sends deltas only on change and flips closed sessions to non-resident instead of dropping them", () => { const { daemon, sentDeltas } = makeWorkerReporter(); const state = makeState({ @@ -376,6 +400,8 @@ interface WorkerFixture { processStartId?: string; lastError?: string; ownerClientId?: string; + rootSessionId?: string; + sessionFile?: string; }; client?: { request: ReturnType }; summaries: Map; @@ -1218,6 +1244,42 @@ describe("review-round regressions", () => { }); }); + it("keeps frame-updated root pointers when a stale summaries pull lands", async () => { + const worker = makeWorker("worker-1"); + Object.assign(worker.descriptor, { createCommand: { type: "create" } }); + let releaseList: (response: unknown) => void = () => {}; + worker.client = { + request: vi.fn( + () => + new Promise((resolveList) => { + releaseList = resolveList; + }), + ), + }; + const supervisor = makeSupervisor([worker], { + refreshWorkerSummaries: DaemonSupervisor.prototype["refreshWorkerSummaries" as never], + streamReconstructor: { seed: vi.fn(), clear: vi.fn() }, + }); + const staleRoot = summary({ + id: "worker-1-root-active", + sessionId: "root", + activeSessionId: "worker-1-root-active", + sessionFile: "/tmp/sessions/old.jsonl", + }); + const freshRoot = { ...staleRoot, sessionFile: "/tmp/sessions/new.jsonl" }; + + const refresh = ( + supervisor as unknown as { refreshWorkerSummaries(worker: WorkerFixture, recovery: boolean): Promise } + ).refreshWorkerSummaries(worker, false); + // A frame updates the root pointers while the pull is still in flight. + supervisor.consumeWorkerRosterDelta(worker, rosterDelta([workerRosterEntryFromSummary(freshRoot)])); + expect(worker.descriptor.sessionFile).toBe("/tmp/sessions/new.jsonl"); + releaseList({ type: "response", command: "list", success: true, data: { sessions: [staleRoot] } }); + await refresh; + + expect(worker.descriptor.sessionFile).toBe("/tmp/sessions/new.jsonl"); + }); + 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" } }); From ec6cb7de7d6b891aef9093bcccc88874d1d4c7e8 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 30 Aug 2026 10:45:21 +0200 Subject: [PATCH 28/48] refactor(coding-agent): rename AgentRosterLedger to AgentRoster --- packages/coding-agent/src/modes/daemon/agent-roster.ts | 2 +- .../coding-agent/src/modes/daemon/daemon-supervisor.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/agent-roster.ts b/packages/coding-agent/src/modes/daemon/agent-roster.ts index 23385ff920..2e19609f58 100644 --- a/packages/coding-agent/src/modes/daemon/agent-roster.ts +++ b/packages/coding-agent/src/modes/daemon/agent-roster.ts @@ -103,7 +103,7 @@ export function sessionSummaryFromRosterEntry(entry: WorkerRosterEntry): Session } // Supervisor-owned roster store; write() classifies once and its file index converges seed and worker keys. -export class AgentRosterLedger { +export class AgentRoster { private readonly entries = new Map(); private readonly agentIdByActiveSessionId = new Map(); private readonly agentIdBySessionFile = new Map(); diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 95ec28613e..5c14f174d6 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -57,8 +57,8 @@ 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, - AgentRosterLedger, passivatedWorkerRosterEntry, rosterAgentIdForSummary, sessionSummaryFromRosterEntry, @@ -646,7 +646,7 @@ export class DaemonSupervisor { private readonly pendingSessionNames = new Set(); private readonly catalog: DaemonCatalogClient; private readonly settingsManager: SettingsManager; - private rosterStore?: AgentRosterLedger; + private rosterStore?: AgentRoster; private rosterWatchdogTimer?: ReturnType; private rlmSpawnLedgerInstance?: RlmSpawnLedger; private idleEvictionTimer?: ReturnType; @@ -3709,8 +3709,8 @@ export class DaemonSupervisor { } // The agent roster: the single supervisor-side projection every list and selector read is served from. - private roster(): AgentRosterLedger { - this.rosterStore ??= new AgentRosterLedger(canonicalSessionPath); + private roster(): AgentRoster { + this.rosterStore ??= new AgentRoster(canonicalSessionPath); return this.rosterStore; } From afafbfa6674b73af4ae5d9789a10990e26a032a2 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 17:17:37 +0200 Subject: [PATCH 29/48] refactor(coding-agent): one owner each for busy/status adapters, registration flags, delete tombstone policy, and the roster heartbeat contract - isSessionSummaryBusy and classifySessionRosterStatus move into agent-roster.ts (re-exported from daemon-session-list.ts for existing importers); classifyWorkerRosterEntry now delegates instead of re-inlining the busy predicate. - The user-delete classification + tombstone-first policy lives once in rlm-ledger.ts (tombstoneSavedSessionDelete); the worker and supervisor delete_saved_session routes both call it. - passivatedWorkerRosterEntry never freezes hasRegisteredHeartbeat/hasRegisteredCronJob: the worker flush recomputes them from the cron store via the extracted scheduledJobRegistrations index (the one registration truth); callers without a cron store strip them. - ROSTER_HEARTBEAT_INTERVAL_MS moves next to the roster capability in daemon-worker-protocol.ts; the supervisor staleness threshold derives from it (three missed heartbeats) instead of restating 45s. --- .../src/modes/daemon/agent-roster.ts | 41 +++++++++--- .../src/modes/daemon/daemon-mode.ts | 48 +++++++------- .../src/modes/daemon/daemon-session-list.ts | 62 ++++++++++--------- .../src/modes/daemon/daemon-supervisor.ts | 32 +++------- .../modes/daemon/daemon-worker-protocol.ts | 3 + .../src/modes/daemon/rlm-ledger.ts | 29 +++++++++ 6 files changed, 129 insertions(+), 86 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/agent-roster.ts b/packages/coding-agent/src/modes/daemon/agent-roster.ts index 2e19609f58..e77f52ad47 100644 --- a/packages/coding-agent/src/modes/daemon/agent-roster.ts +++ b/packages/coding-agent/src/modes/daemon/agent-roster.ts @@ -20,6 +20,29 @@ export function classifyAgentStatus(input: AgentStatusInput): AgentRosterStatus return input.busy || input.hasActiveHeartbeat ? "running" : "idle"; } +/** The one busy predicate over session summaries; every surface (list, roster, launch checks) derives from it. */ +export function isSessionSummaryBusy( + summary: Pick, +): boolean { + return summary.isSessionActive || summary.hasRunningRlmChildren === true; +} + +/** The one summary-shaped adapter over classifyAgentStatus; roster rows add only their queuedChild bit. */ +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, + }); +} + // A session summary without its heavyweight per-event fields; `list` re-adds an empty sessionActions. export type RosterSessionSummary = Omit; @@ -62,20 +85,20 @@ export function workerRosterEntryFromSummary(summary: SessionSummary): WorkerRos /** The roster half of the classifier input; the queuedChild bit rides the entry itself. */ export function classifyWorkerRosterEntry(entry: WorkerRosterEntry): AgentRosterStatus { - const summary = entry.summary; - return classifyAgentStatus({ - resident: !!summary.activeSessionId, - queuedChild: entry.queuedChild === true, - busy: summary.activity === "working" || summary.isSessionActive || summary.hasRunningRlmChildren === true, - hasActiveHeartbeat: summary.hasActiveHeartbeat === true, - }); + return classifySessionRosterStatus(entry.summary, entry.queuedChild === true); } /** Final entry for an agent whose runtime left memory; identity and display fields survive. */ -export function passivatedWorkerRosterEntry(entry: WorkerRosterEntry): WorkerRosterEntry { +export function passivatedWorkerRosterEntry( + entry: WorkerRosterEntry, + // Registration flags never freeze: callers with a cron store pass fresh values, others strip them. + registrations?: { hasRegisteredHeartbeat: boolean; hasRegisteredCronJob: boolean }, +): WorkerRosterEntry { const { activeSessionId, hasActiveHeartbeat, + hasRegisteredHeartbeat, + hasRegisteredCronJob, hasRunningRlmChildren, isBashRunning, isRunningTools, @@ -94,6 +117,8 @@ export function passivatedWorkerRosterEntry(entry: WorkerRosterEntry): WorkerRos isStreaming: false, isCompacting: false, attachedClients: 0, + ...(registrations?.hasRegisteredHeartbeat ? { hasRegisteredHeartbeat: true } : {}), + ...(registrations?.hasRegisteredCronJob ? { hasRegisteredCronJob: true } : {}), }, }; } diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 1537598a8c..1767d35c41 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -172,6 +172,7 @@ import { inactiveLifecycleForSession, isActiveSessionBusy, type SessionSummary, + scheduledJobRegistrations, summaryForActiveSession, } from "./daemon-session-list.js"; import { DaemonSessionSummarizer } from "./daemon-session-summarizer.js"; @@ -196,6 +197,7 @@ import { type DaemonWorkerFrameHeader, type DaemonWorkerRosterOutbound, isDaemonWorkerFrameHeader, + ROSTER_HEARTBEAT_INTERVAL_MS, SESSION_LEASE_OWNER_ID_ENV, SESSION_LEASES_ENABLED_ENV, } from "./daemon-worker-protocol.js"; @@ -207,6 +209,7 @@ import { type RlmLedgerEdge, RlmSpawnLedger, readLegacyRlmSubagentRegistry as readLegacyRlmSubagentRegistryFile, + tombstoneSavedSessionDelete, } from "./rlm-ledger.js"; import { readRlmSubagentDisplayEntry, @@ -3907,29 +3910,12 @@ export class AgentDaemon { if (this.findActiveSessionByFile(command.sessionPath)) { throw new Error("Cannot delete the currently active session"); } - const deletedPath = canonicalSessionPath(command.sessionPath); - const deletedInfo = (await readSessionInfo(command.sessionPath).catch(() => null)) ?? undefined; - const composedEntry = this.rosterEntryForSessionPath(deletedPath); - // Worker-held state classifies first; only a readable no-parent transcript is positively top-level. - const knownChild = - composedEntry?.summary.runtimeKind === "subagent" || - deletedInfo?.parentSessionPath !== undefined || - (deletedInfo?.rlmDepth ?? 0) > 0; - const positivelyTopLevel = !knownChild && (composedEntry !== undefined || deletedInfo !== undefined); - let ledgerEdge: RlmLedgerEdge | undefined; - if (!positivelyTopLevel) { - // Children and unknown targets classify via the ledger; an unreadable ledger aborts, else the child reseeds. - const edges = await this.rlmSpawnLedger().edges(); - ledgerEdge = edges.find((edge) => canonicalSessionPath(edge.child) === deletedPath); - // Tombstone first: a failed append aborts; a tombstoned-but-undeleted file is the accepted orphan of a failed delete. - if (ledgerEdge) { - await this.rlmSpawnLedger().appendDelete({ - childId: ledgerEdge.childId, - child: command.sessionPath, - reason: "user", - }); - } - } + 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); @@ -6680,7 +6666,8 @@ export class AgentDaemon { private flushRoster(): void { const reporter = this.rosterReporter; const entries = new Map(); - for (const summary of buildSessionList([...this.sessions.values()], [], this.cronStore.list())) { + const scheduledJobs = this.cronStore.list(); + for (const summary of buildSessionList([...this.sessions.values()], [], scheduledJobs)) { const entry = workerRosterEntryFromSummary(summary); entries.set(entry.agentId, entry); } @@ -6697,9 +6684,18 @@ export class AgentDaemon { reporter.queuedChildren.delete(agentId); } // Rows whose runtime left memory flip to passivated and stay known, delivered or not. + // Their registration flags come fresh from the cron store per flush; frozen flags would pin eviction. + const registrations = scheduledJobRegistrations(scheduledJobs); for (const [agentId, previous] of reporter.lastComposed) { if (!entries.has(agentId) && !reporter.removedAgentIds.has(agentId)) { - entries.set(agentId, passivatedWorkerRosterEntry(previous)); + 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), + }), + ); } } // Deltas are best-effort freshness hints; any miss escalates to one full replacing snapshot. @@ -7138,8 +7134,6 @@ export class AgentDaemon { } } -const ROSTER_HEARTBEAT_INTERVAL_MS = 15_000; - interface WorkerRosterReporterState { /** Last composed roster, delivered or not; the source for passivated flips and change hints. */ lastComposed: Map; 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..60f738ef5f 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,8 @@ 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"; + +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 +101,30 @@ 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; +/** The one registration index over scheduled jobs; summaries and passivated roster flips both read it. */ +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 5c14f174d6..ab9bfde94b 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -137,11 +137,17 @@ import { 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, type RlmLedgerEdge, 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"; @@ -152,7 +158,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 = 45_000; +// Three missed worker heartbeats: the watchdog stamps silence, it never drives recovery. +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; @@ -2139,26 +2146,7 @@ export class DaemonSupervisor { ); } } - const deletedInfo = (await readSessionInfo(command.sessionPath).catch(() => null)) ?? undefined; - // Roster/disk state classifies first; only a readable no-parent transcript is positively top-level. - const knownChild = - entry?.summary.runtimeKind === "subagent" || - deletedInfo?.parentSessionPath !== undefined || - (deletedInfo?.rlmDepth ?? 0) > 0; - const positivelyTopLevel = !knownChild && (entry !== undefined || deletedInfo !== undefined); - if (!positivelyTopLevel) { - // Children and unknown targets classify via the ledger; an unreadable ledger aborts, else the child reseeds. - const edges = await this.rlmSpawnLedger().edges(); - const edge = edges.find((candidate) => canonicalSessionPath(candidate.child) === deletedPath); - // Tombstone first: a failed append aborts; a tombstoned-but-undeleted file is the accepted orphan of a failed delete. - if (edge) { - await this.rlmSpawnLedger().appendDelete({ - childId: edge.childId, - child: command.sessionPath, - reason: "user", - }); - } - } + await tombstoneSavedSessionDelete(this.rlmSpawnLedger(), command.sessionPath, entry?.summary); const result = await this.catalog.delete(command.sessionPath); if (result.ok && entry) this.roster().delete(entry.agentId); return success(command.id, command.type, result); 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 0f7e07f089..e5c23334d0 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-worker-protocol.ts @@ -29,6 +29,9 @@ export type DaemonWorkerRosterOutbound = /** 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"; diff --git a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts index d257678475..7972780279 100644 --- a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts +++ b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts @@ -841,3 +841,32 @@ export class RlmSpawnLedger { return edges; } } + +/** + * One delete-classification policy for user deletes of saved sessions, shared + * by the worker and supervisor delete routes. In-memory state classifies + * first; only a readable no-parent transcript is positively top-level. + * Children and unknown targets classify via the ledger: an unreadable ledger + * aborts, and the tombstone appends 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(); + const ledgerEdge = edges.find((edge) => canonicalSessionPath(edge.child) === deletedPath); + if (ledgerEdge) { + await ledger.appendDelete({ childId: ledgerEdge.childId, child: sessionPath, reason: "user" }); + } + return { deletedInfo, ledgerEdge }; +} From 544b11eee509d1e71910881703476ba32d43613e Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 17:17:51 +0200 Subject: [PATCH 30/48] fix(coding-agent): supervisor roster correctness batch - Offline delete_saved_session asserts client access to the owning worker before forwarding or reclaiming: a foreign client's delete of a client-owned worker's passivated session is an unknown target again. - Adopted pre-roster workers with an owner are parked through recoverWorker (their launch env lives only with the owning client) instead of a bare descriptor respawn that would drop it. - A worker's queued-child rows are removed, not passivated, when its registration goes away: a terminal unbound run owns no transcript, and the fileless ghost row nothing could list or delete is gone. - Snapshot reseeds keep a passive registry child's previous worker claim, and gap fills also replace synthetic ledger seeds, so passive children stop flapping out of the non-all list and stale frozen rows stop feeding eviction. - hydrateSeededEntry re-checks the row after its header read; a frame that rebinds the agentId mid-read is never clobbered with the stale seed. - matchWorkers and findSummaryInWorker skip queued-child rows: there is no session to route to, and a queued name must not create false ambiguity. - familyCatalogEntries is fail-closed again: a failed catalog scan propagates instead of silently shrinking name-uniqueness checks. - The idle-eviction pull is documented as a responsiveness gate; the decision data comes from the delta-fed roster. - handleList list-all merge drops the O(n^2) includes() and overlaps seeded-row header reads. --- .../src/modes/daemon/daemon-supervisor.ts | 57 +++++++++++++++---- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index ab9bfde94b..b5f3782296 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -858,6 +858,7 @@ export class DaemonSupervisor { await Promise.all( [...this.workers.values()].map(async (worker) => { try { + // Responsiveness gate only: the eviction decision reads the delta-fed roster, not this pull's data. await this.refreshWorkerSummaries(worker); refreshed.add(worker); } catch { @@ -2135,6 +2136,8 @@ export class DaemonSupervisor { // Descriptor and summary paths cover owners whose rows are not flushed yet (startup, adoption, fresh children). 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); // The owning worker deletes its own passivated files and publishes the removal itself. if (owner.client && !this.isWorkerStopping(owner)) { return this.forwardToWorker(owner, command); @@ -2332,6 +2335,7 @@ export class DaemonSupervisor { const cwd = command.cwd ? resolve(command.cwd) : undefined; // Worker rows replace their scanned files in place so the newest-first catalog order survives. const merged: SessionSummary[] = []; + const servedRows = new Set(active); const mergedActiveFiles = new Set(); const scannedFiles = new Set(); for (const info of scanned) { @@ -2339,7 +2343,7 @@ export class DaemonSupervisor { scannedFiles.add(file); const workerRow = activeByFile.get(file); if (workerRow) { - if (active.includes(workerRow)) { + if (servedRows.has(workerRow)) { merged.push(workerRow); mergedActiveFiles.add(file); } @@ -2347,13 +2351,18 @@ export class DaemonSupervisor { } merged.push(summaryForInactiveSession(info)); } + const offlineRows: AgentRosterEntry[] = []; for (const entry of this.roster().values()) { // Ledger-only offline rows (artifact-dir children, flipped residents) ride along with the scan. 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; - const summary = sessionSummaryFromRosterEntry(await this.hydrateSeededEntry(entry)); + offlineRows.push(entry); + } + // Hydration reads one transcript header per still-seeded row; overlap the reads. + 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); @@ -2370,8 +2379,11 @@ export class DaemonSupervisor { private async hydrateSeededEntry(entry: AgentRosterEntry): Promise { if (entry.seededCwd !== true || !entry.summary.sessionFile) return entry; const info = await readSessionInfo(entry.summary.sessionFile).catch(() => undefined); - const { seededCwd, ...rest } = entry; if (!info) return entry; + // A frame can rewrite this agentId while the header read is in flight; never clobber the fresher row. + 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, @@ -3053,7 +3065,9 @@ export class DaemonSupervisor { return; } this.log(`Could not adopt worker ${worker.descriptor.workerId}: ${String(error)}`); - if (error instanceof PreRosterWorkerError) { + // A client-owned worker's launch env and recovery config live only with its owner; a bare + // descriptor respawn would drop them, so recoverWorker parks it failed until the owner returns. + if (error instanceof PreRosterWorkerError && worker.descriptor.ownerClientId === undefined) { try { await this.restartPreRosterWorker(worker, observedProcessStartId); return; @@ -3656,9 +3670,8 @@ export class DaemonSupervisor { entry.summary.sessionFile ? [canonicalSessionPath(entry.summary.sessionFile)] : [], ), ); - const scanned = await this.catalog - .list(undefined, this.defaultSessionConfig.sessionDir) - .catch(() => [] as SessionInfo[]); + // Fail closed: name-uniqueness checks must not pass because the scan silently shrank. + 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; @@ -3853,8 +3866,11 @@ export class DaemonSupervisor { if (!this.isWorkerRosterApplyCurrent(worker)) return; // A live worker's snapshot carries its passivated rows too; absence means removal, disk backs the rest. const sent = new Set(delta.entries.map((entry) => entry.agentId)); + const unclaimed = new Set(); for (const entry of this.workerRosterEntries(worker)) { - if (!sent.has(entry.agentId)) this.roster().delete(entry.agentId); + if (sent.has(entry.agentId)) continue; + unclaimed.add(entry.agentId); + this.roster().delete(entry.agentId); } for (const entry of delta.entries) { this.writeRosterEntry(entry, worker); @@ -3864,11 +3880,16 @@ export class DaemonSupervisor { this.roster().delete(agentId); } // Deleted absentees with surviving transcripts reseed from the pre-read edges, tombstone-filtered. + // A reseed keeps its previous claim: passive registry children list and attach through their live + // owner, and snapshots (which never compose them) must not flap that claim off. for (const edge of edges) { 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 }); + this.roster().write( + { ...entry, seededCwd: true }, + unclaimed.has(entry.agentId) ? worker.descriptor.workerId : undefined, + ); } } @@ -3892,12 +3913,14 @@ export class DaemonSupervisor { this.persistWorker(worker); } - /** Pulled rows fill gaps and claim workerless seeded rows; delta-fed rows are never overwritten. */ + /** Pulled rows fill gaps, claim workerless rows, and flesh out synthetic seeds; delta-fed rows are never overwritten. */ private fillRosterGapsFromWorkerSummaries(worker: ResidentWorker): void { for (const summary of worker.summaries.values()) { const entry = workerRosterEntryFromSummary(summary); const existing = this.roster().get(entry.agentId); - if (existing === undefined || existing.workerId === undefined) this.writeRosterEntry(entry, worker); + if (existing === undefined || existing.workerId === undefined || existing.seededCwd === true) { + this.writeRosterEntry(entry, worker); + } } } @@ -3912,6 +3935,11 @@ export class DaemonSupervisor { /** A stopped or evicted worker leaves inactive rows behind, never gaps. */ private flipWorkerRosterEntriesInactive(worker: ResidentWorker): void { for (const entry of this.workerRosterEntries(worker)) { + // A terminal unbound child run owns no transcript: it is a removal, never a passivated row. + if (entry.queuedChild) { + this.roster().delete(entry.agentId); + continue; + } this.writeRosterEntry(passivatedWorkerRosterEntry(entry)); } } @@ -4169,6 +4197,8 @@ export class DaemonSupervisor { const exact: WorkerMatch[] = []; const suffix: WorkerMatch[] = []; for (const entry of this.roster().values()) { + // A queued child has no session to route a command to; its name must not shadow or collide. + if (entry.queuedChild) continue; const worker = entry.workerId !== undefined ? this.workers.get(entry.workerId) : undefined; if (!worker || (includeWorker && !includeWorker(worker))) { continue; @@ -4190,7 +4220,10 @@ export class DaemonSupervisor { private findSummaryInWorker(worker: ResidentWorker, selector: string): SessionSummary | undefined { const pathSelector = looksLikeSessionPath(selector) ? canonicalSessionPath(selector) : undefined; - const summaries = this.workerRosterEntries(worker).map(sessionSummaryFromRosterEntry); + // A queued child has no session to route a command to; its name must not shadow or collide. + const summaries = this.workerRosterEntries(worker) + .filter((entry) => !entry.queuedChild) + .map(sessionSummaryFromRosterEntry); const exact = summaries.find((summary) => { const activeSessionId = summary.activeSessionId ?? summary.id; return ( From bfcb78e49bae22ead7e39631b20e1ebd2cb146cf Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 17:17:51 +0200 Subject: [PATCH 31/48] test(coding-agent): pin the roster correctness batch - foreign client delete of a client-owned worker's passivated session rejects as unknown - worker unregistration removes queued rows instead of passivating unlistable ghosts - hydrateSeededEntry never clobbers a row rebound during its header read - passive registry children keep their worker claim across snapshots that omit them and stay in the non-all list; the queued gap fill also replaces the synthetic ledger seed with the pulled summary - the worker reporter fixture carries the real lastComposedJson field --- .../test/daemon-agent-roster.test.ts | 139 +++++++++++++++++- .../test/daemon-supervisor-monitor.test.ts | 2 +- 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index b08af7a06c..1f5cba6332 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -690,6 +690,103 @@ describe("supervisor roster ledger", () => { expect(entry?.summary.activeSessionId).toBe("r-active"); }); + it("removes queued rows on worker unregistration instead of passivating unlistable ghosts", () => { + 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("never clobbers a row rebound while its seeded header read was in flight", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-hydrate-race-")); + tempDirs.push(directory); + const manager = SessionManager.create(directory, join(directory, "artifacts")); + manager.appendMessage({ role: "user", content: "child fixture", timestamp: 1 }); + manager.flushNow(); + const sessionFile = manager.getSessionFile(); + if (!sessionFile) throw new Error("Fixture session did not persist"); + const supervisor = makeSupervisor([]); + const stale = supervisor.writeRosterEntry({ + agentId: "raced", + seededCwd: true, + summary: summary({ id: "raced", sessionId: "raced", sessionFile, cwd: join(directory, "artifacts") }), + }); + // A frame rebinds the agentId while the header read would be in flight. + const live = supervisor.writeRosterEntry( + workerRosterEntryFromSummary( + summary({ id: "raced", sessionId: "raced", activeSessionId: "raced-active", sessionFile }), + ), + ); + + const internals = supervisor as unknown as { + hydrateSeededEntry(entry: AgentRosterEntry): Promise; + }; + const hydrated = await internals.hydrateSeededEntry(stale); + + expect(hydrated).toBe(live); + expect(supervisor.roster().get("raced")).toBe(live); + expect(supervisor.roster().get("raced")?.summary.activeSessionId).toBe("raced-active"); + }); + + it("keeps claimed passive children in the non-all list across snapshots that omit them", async () => { + const directory = mkdtempSync(join(tmpdir(), "prime-roster-claim-stability-")); + tempDirs.push(directory); + const sessionsDir = join(directory, "sessions"); + const ledger = new RlmSpawnLedger(directory, sessionsDir); + const parentPath = join(sessionsDir, "root.jsonl"); + const childPath = join(directory, "artifacts", "p.jsonl"); + await ledger.appendSpawn({ childId: "p", parent: parentPath, child: childPath, depth: 1, name: "p" }); + const worker = makeWorker("worker-1"); + Object.assign(worker.descriptor, { createCommand: { type: "create" } }); + const supervisor = makeSupervisor([worker], { rlmSpawnLedger: () => ledger }); + const root = summary({ id: "worker-1-root-active", sessionId: "root", activeSessionId: "worker-1-root-active" }); + const passive = summary({ + id: "p-session", + sessionId: "p-session", + sessionFile: childPath, + runtimeKind: "subagent", + rlmChildId: "p", + parentSessionPath: parentPath, + }); + worker.summaries.set("worker-1-root-active", root); + worker.summaries.set("p-session", passive); + ( + supervisor as unknown as { fillRosterGapsFromWorkerSummaries(worker: WorkerFixture): void } + ).fillRosterGapsFromWorkerSummaries(worker); + + // A snapshot composes only live sessions; the passive registry child must not flap off the worker. + supervisor.consumeWorkerRosterDelta(worker, rosterDelta([workerRosterEntryFromSummary(root)], undefined, true)); + await new Promise((resolveSettle) => setImmediate(resolveSettle)); + + const passiveRow = [...supervisor.roster().values()].find((entry) => entry.summary.rlmChildId === "p"); + expect(passiveRow?.workerId).toBe("worker-1"); + const listed = await supervisor.handleList({}, { type: "list" }); + expect(listed.data?.sessions.map((session) => session.sessionId).sort()).toEqual([ + passiveRow?.summary.sessionId, + "root", + ]); + }); + 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); @@ -988,6 +1085,42 @@ describe("saved-session delete paths", () => { 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("keeps the roster row when a delete fails on disk", async () => { const { directory, supervisor } = makeOfflineSupervisor("prime-roster-failed-delete-", { catalog: { delete: vi.fn(async () => ({ ok: false, error: "busy file" })), list: vi.fn(async () => []) }, @@ -1326,7 +1459,11 @@ describe("review-round regressions", () => { // Settle any apply work a broken serialization would leave dangling past the pull. await new Promise((resolveSettle) => setImmediate(resolveSettle)); - expect(supervisor.roster().get(childEntry.agentId)?.workerId).toBe("worker-1"); + const restored = supervisor.roster().get(childEntry.agentId); + expect(restored?.workerId).toBe("worker-1"); + // The queued fill also replaced the synthetic ledger seed with the pulled summary. + expect(restored?.summary.cwd).toBe("/tmp/project"); + expect(restored?.seededCwd).toBeUndefined(); }); it("aborts queued roster applies when the worker stops during the snapshot ledger pre-read", async () => { diff --git a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts index 7064f7ac82..e183d1f1c4 100644 --- a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts @@ -497,8 +497,8 @@ describe("daemon worker supervisor monitoring", () => { sessions: new Map(), cronStore: { list: () => [] }, rosterReporter: { - lastSent: new Map(), lastComposed: new Map(), + lastComposedJson: new Map(), queuedChildren: new Map(), removedAgentIds: new Set(), snapshotPending: false, From 02381f772117fbccf73cbafe58d717c64c7f0106 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 17:52:01 +0200 Subject: [PATCH 32/48] fix(coding-agent): let composed session rows beat lingering queued markers addRuntime registers a child session before the bind-reporting rlm_child_update arrives; a roster flush in that window replaced the resident row with its sessionless queued stub. Session rows now win at compose time and clear the stale queued marker. --- .../src/modes/daemon/daemon-mode.ts | 7 ++++- .../test/daemon-agent-roster.test.ts | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 1767d35c41..eab1b83a97 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -6671,8 +6671,13 @@ export class AgentDaemon { const entry = workerRosterEntryFromSummary(summary); entries.set(entry.agentId, entry); } - // Disjoint from session rows by the observeRosterChildUpdate lifecycle guard; insertion order is free. + // Session rows win: a run whose session already composed is bound, and the rlm_child_update + // that clears the queued marker can trail the session's own first events. for (const [childId, queued] of reporter.queuedChildren) { + if (entries.has(childId)) { + reporter.queuedChildren.delete(childId); + continue; + } entries.set(childId, queued); } // A terminal unbound child run owns no transcript: it is a removal, never a passivated row. diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index 1f5cba6332..aa15ddd491 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -218,6 +218,33 @@ describe("worker roster reporter", () => { expect(sentDeltas.at(-1)?.entries.some((entry) => entry.agentId === "child-2")).toBe(false); }); + it("lets a composed session row beat a lingering queued marker during the bind window", () => { + 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: "task", status: "queued", sessionDir: "/tmp/c" }), + ); + daemon.flushRoster(); + + // The child session registers before any rlm_child_update reports the bind. + 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.flushRoster(); + + const row = sentDeltas.at(-1)?.entries.find((entry) => entry.agentId === "child-1"); + expect(row?.queuedChild).toBeUndefined(); + expect(row?.summary.activeSessionId).toBe("child-active"); + expect(daemon.rosterReporter.queuedChildren.size).toBe(0); + }); + it("schedules a republish for retry and tool-execution transitions", () => { const { daemon } = makeWorkerReporter(); const state = makeState({ activeSessionId: "root-active" }); From 6fb3c6ae2cefbb7573622b1b0838e8bf46ac216b Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 17:53:02 +0200 Subject: [PATCH 33/48] fix(coding-agent): keep unserved worker files listed as inactive rows in list all A client-owned worker's row sits in activeByFile even when the client is not served it; the list-all merge then dropped both the live row and the catalog row, hiding the session entirely. The on-disk scan is public (no list surface filters it by ownership), so the file lists as a plain inactive row again, exactly like before the roster ledger. --- .../src/modes/daemon/daemon-supervisor.ts | 10 ++-- .../test/daemon-agent-roster.test.ts | 46 +++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index b5f3782296..b000cb1768 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -2342,13 +2342,13 @@ export class DaemonSupervisor { const file = canonicalSessionPath(info.path); scannedFiles.add(file); const workerRow = activeByFile.get(file); - if (workerRow) { - if (servedRows.has(workerRow)) { - merged.push(workerRow); - mergedActiveFiles.add(file); - } + if (workerRow && servedRows.has(workerRow)) { + merged.push(workerRow); + mergedActiveFiles.add(file); continue; } + // A worker row this client is not served (client-owned, no includeClientOwned) hides the live + // metadata only: the on-disk scan is public, so the file still lists as a plain inactive row. merged.push(summaryForInactiveSession(info)); } const offlineRows: AgentRosterEntry[] = []; diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index aa15ddd491..f2278782fc 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -775,6 +775,52 @@ describe("supervisor roster ledger", () => { expect(supervisor.roster().get("raced")?.summary.activeSessionId).toBe("raced-active"); }); + it("lists a client-owned worker's file as a plain inactive row for other clients", async () => { + 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("keeps claimed passive children in the non-all list across snapshots that omit them", async () => { const directory = mkdtempSync(join(tmpdir(), "prime-roster-claim-stability-")); tempDirs.push(directory); From d80d7b303bc3e5b830e128a152d3b734ceab94b9 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 17:54:25 +0200 Subject: [PATCH 34/48] fix(coding-agent): guard roster applies against dead registrations and unreadable ledgers - Unchained (fast-path) deltas now re-check registration currency exactly like chained applies: a late frame from an unregistered or replaced worker registration cannot resurrect its rows with a stale claim. - A snapshot whose spawn-ledger pre-read fails skips the absentee sweep and reseed (it cannot tell registry children from stale rows without edges), keeps applying the snapshot's own entries, and schedules the single-flight repair pull instead of silently deleting passive children. --- .../src/modes/daemon/daemon-supervisor.ts | 21 ++++++-- .../test/daemon-agent-roster.test.ts | 51 +++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index b000cb1768..f7674c49f9 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -3792,6 +3792,9 @@ export class DaemonSupervisor { if (delta.type !== "roster_delta" || !Array.isArray(delta.entries)) return; // The epoch bumps at frame receipt, before any async apply work, so an in-flight pull sees this frame. worker.rosterEpoch = (worker.rosterEpoch ?? 0) + 1; + // The same currency rule chained applies re-check at apply time: a frame from an unregistered + // or replaced registration must never resurrect its rows. + if (!this.isWorkerRosterApplyCurrent(worker)) return; if (delta.snapshot !== true && worker.rosterApplyChain === undefined) { this.applyWorkerRosterDelta(worker, delta); return; @@ -3856,21 +3859,27 @@ export class DaemonSupervisor { delta: Extract, ): 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() .edges() .catch((error: unknown) => { this.log(`Could not read the spawn ledger during a snapshot apply: ${String(error)}`); + edgesFailed = true; return [] as RlmLedgerEdge[]; }); // A stop during the pre-read unregisters the worker and flips its rows inactive; applying now would resurrect them. if (!this.isWorkerRosterApplyCurrent(worker)) return; // A live worker's snapshot carries its passivated rows too; absence means removal, disk backs the rest. + // Without readable edges the absentee sweep cannot tell registry children from stale rows, so the + // destructive half is skipped: rows survive and the single-flight repair pull refreshes them. const sent = new Set(delta.entries.map((entry) => entry.agentId)); const unclaimed = new Set(); - for (const entry of this.workerRosterEntries(worker)) { - if (sent.has(entry.agentId)) continue; - unclaimed.add(entry.agentId); - this.roster().delete(entry.agentId); + if (!edgesFailed) { + for (const entry of this.workerRosterEntries(worker)) { + if (sent.has(entry.agentId)) continue; + unclaimed.add(entry.agentId); + this.roster().delete(entry.agentId); + } } for (const entry of delta.entries) { this.writeRosterEntry(entry, worker); @@ -3879,6 +3888,10 @@ export class DaemonSupervisor { for (const agentId of delta.removedAgentIds ?? []) { this.roster().delete(agentId); } + if (edgesFailed) { + this.scheduleRosterRepairPull(worker); + return; + } // Deleted absentees with surviving transcripts reseed from the pre-read edges, tombstone-filtered. // A reseed keeps its previous claim: passive registry children list and attach through their live // owner, and snapshots (which never compose them) must not flap that claim off. diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index f2278782fc..bedfcbcbb1 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -1539,6 +1539,57 @@ describe("review-round regressions", () => { expect(restored?.seededCwd).toBeUndefined(); }); + it("drops unchained deltas from an unregistered worker registration", () => { + 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 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: () => ({ + edges: 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({ From 05fb070839e0c281a49bab6e4a1c07491cddf1aa Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 17:55:13 +0200 Subject: [PATCH 35/48] fix(coding-agent): drop client-owned workers' roster rows on unregistration Passivating an owned worker's rows strips the workerId and turns private rows into public inactive rows (path/cwd/name/message metadata) served to every client through offline list paths and roster reads. Client-owned workers are ephemeral, so their rows die with the registration; the public disk scan still lists whatever files actually persist. --- .../src/modes/daemon/daemon-supervisor.ts | 6 ++- .../test/daemon-agent-roster.test.ts | 40 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index f7674c49f9..c768e145a5 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -3947,9 +3947,13 @@ export class DaemonSupervisor { /** A stopped or evicted worker leaves inactive rows behind, never gaps. */ private flipWorkerRosterEntriesInactive(worker: ResidentWorker): void { + // Client-owned workers are ephemeral (normal completion removes them without archiving) and + // their rows are private to the owner; passivating would strip the workerId and turn them into + // public inactive rows. The public disk scan still lists whatever files actually persist. + const ephemeral = worker.descriptor.ownerClientId !== undefined; for (const entry of this.workerRosterEntries(worker)) { // A terminal unbound child run owns no transcript: it is a removal, never a passivated row. - if (entry.queuedChild) { + if (ephemeral || entry.queuedChild) { this.roster().delete(entry.agentId); continue; } diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index bedfcbcbb1..98d182c2dd 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -717,6 +717,46 @@ describe("supervisor roster ledger", () => { expect(entry?.summary.activeSessionId).toBe("r-active"); }); + 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([]); + }); + it("removes queued rows on worker unregistration instead of passivating unlistable ghosts", () => { const worker = makeWorker("worker-1"); const supervisor = makeSupervisor([worker]); From 5fa2b95c91f1f5f1b1c1ca9ea6fb90577622adde Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 18:08:21 +0200 Subject: [PATCH 36/48] fix(coding-agent): re-verify pid identity at the last moment before the recovery SIGKILL --- .../src/modes/daemon/daemon-supervisor.ts | 7 ++++- .../test/daemon-supervisor-monitor.test.ts | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index c768e145a5..2a2aae805a 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -3532,7 +3532,12 @@ export class DaemonSupervisor { private async recoverUncertainWorkerOperations(worker: ResidentWorker, killWorkerProcess = true): Promise { await this.assertRecoveryAllowed(); - if (killWorkerProcess) { + // Callers verify identity before awaiting their way here, so re-check at the last + // synchronous moment: the old process can exit in that gap and the PID can recycle. + if ( + killWorkerProcess && + this.processIdentity(worker.descriptor.pid, worker.descriptor.processStartId) === "current" + ) { signalProcessGroupOrProcess(worker.descriptor.pid, "SIGKILL"); } const orphanProcessJournalPath = worker.descriptor.orphanProcessJournalPath; diff --git a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts index e183d1f1c4..afd4606892 100644 --- a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts @@ -3795,6 +3795,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/ }, { From 642963eb98c83d1867a959a31bd5007c6e8c64b3 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 18:33:45 +0200 Subject: [PATCH 37/48] fix(coding-agent): import the moved busy predicate for the empty-session evictability rule --- packages/coding-agent/src/modes/daemon/daemon-session-list.ts | 1 + 1 file changed, 1 insertion(+) 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 60f738ef5f..496f6d5032 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts @@ -9,6 +9,7 @@ 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 { isSessionSummaryBusy } from "./agent-roster.js"; export { classifySessionRosterStatus, isSessionSummaryBusy } from "./agent-roster.js"; From a1766fa7f3ebcab3c3ea727e4db4980b0fb3c0ba Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 18:42:48 +0200 Subject: [PATCH 38/48] fix(coding-agent): serve empty-detach eviction from the roster with write-through pulls Adapts the empty-session last-detach eviction (from the idle-eviction fix round on main) to the roster world with one decision source: - isEmptyDetachEvictionCandidate reads the worker's non-queued roster rows instead of the worker.summaries pull cache. - The hook's two pulls stay as responsiveness gates and now write through: syncRosterFromWorkerSummaries (formerly the gap fill) lets a worker's own rows take the pull's fields, so the post-drain re-read deterministically sees a schedule registered by a mutation admitted mid-refresh. The pull-epoch guard keeps every write-through at least as fresh as the row it replaces, and rows claimed by another worker are never stolen. - The detach-eviction tests seed the supervisor roster like the other adapted suites (matchWorkers is roster-backed). Semantics of the empty-detach eviction are unchanged: empty + unnamed + not busy + no registrations + no attached clients, last detach only, client-owned workers excluded, fence coordination intact. --- .../src/modes/daemon/daemon-supervisor.ts | 30 ++++++++++++------- .../test/daemon-agent-roster.test.ts | 4 +-- .../test/daemon-supervisor-eviction.test.ts | 5 ++++ 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 2a2aae805a..4d8bcccd9f 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -952,13 +952,15 @@ export class DaemonSupervisor { } } - /** Re-validates one worker on fresh summaries under the caller's fence, then passivates it. */ + /** Re-validates one worker on a fresh pull under the caller's fence, then passivates it. */ private async passivateWorkerIfStillEligible( worker: ResidentWorker, isStillEligible: () => boolean, describeEvicted: () => string, ): Promise { - await this.refreshWorkerSummaries(worker); + // Responsiveness gate and write-through: the eligibility re-read serves from the roster rows + // this pull refreshes, so a mutation drained just before it cannot be missed. + await this.refreshWorkerSummaries(worker, false, true); if (!isStillEligible()) return; await this.stopWorker(worker, true); this.log(describeEvicted()); @@ -977,7 +979,8 @@ export class DaemonSupervisor { return; } try { - await this.refreshWorkerSummaries(worker); + // Responsiveness gate and write-through: the candidate check reads the roster rows this pull refreshes. + await this.refreshWorkerSummaries(worker, false, true); } catch { return; } @@ -1006,7 +1009,10 @@ export class DaemonSupervisor { ) { return false; } - const summaries = [...worker.summaries.values()]; + // One decision source: the delta-fed roster, freshened by this path's write-through pulls. + 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)); @@ -3636,7 +3642,7 @@ export class DaemonSupervisor { // The fill queues behind in-flight frame applies and re-checks the epoch there, so it never treats an unapplied snapshot as stable. if (fillGaps) { await this.chainWorkerRosterApply(worker, () => { - if ((worker.rosterEpoch ?? 0) === epochAtStart) this.fillRosterGapsFromWorkerSummaries(worker); + if ((worker.rosterEpoch ?? 0) === epochAtStart) this.syncRosterFromWorkerSummaries(worker); }); } for (const summary of summaries) { @@ -3931,14 +3937,18 @@ export class DaemonSupervisor { this.persistWorker(worker); } - /** Pulled rows fill gaps, claim workerless rows, and flesh out synthetic seeds; delta-fed rows are never overwritten. */ - private fillRosterGapsFromWorkerSummaries(worker: ResidentWorker): void { + /** + * Pulled rows write through to the roster: gaps fill, workerless and seeded rows claim, and this + * worker's own rows take the pull's fields. Every call sits behind the pull-epoch guard, so no + * frame has landed since the pull started and the pull is never staler than the row it replaces. + * Rows claimed by another worker are never stolen. + */ + 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 === undefined || existing.workerId === undefined || existing.seededCwd === true) { - this.writeRosterEntry(entry, worker); - } + if (existing?.workerId !== undefined && existing.workerId !== worker.descriptor.workerId) continue; + this.writeRosterEntry(entry, worker); } } diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index 98d182c2dd..00b5ba9e13 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -884,8 +884,8 @@ describe("supervisor roster ledger", () => { worker.summaries.set("worker-1-root-active", root); worker.summaries.set("p-session", passive); ( - supervisor as unknown as { fillRosterGapsFromWorkerSummaries(worker: WorkerFixture): void } - ).fillRosterGapsFromWorkerSummaries(worker); + supervisor as unknown as { syncRosterFromWorkerSummaries(worker: WorkerFixture): void } + ).syncRosterFromWorkerSummaries(worker); // A snapshot composes only live sessions; the passive registry child must not flap off the worker. supervisor.consumeWorkerRosterDelta(worker, rosterDelta([workerRosterEntryFromSummary(root)], undefined, true)); diff --git a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts index 7d0797efce..c65a75e9f0 100644 --- a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts @@ -495,6 +495,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", @@ -539,6 +540,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); @@ -568,6 +570,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); @@ -596,6 +599,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); @@ -630,6 +634,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); From b1852238ecfd0eec6517d7393cd645ea7bf13bfe Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 18:44:54 +0200 Subject: [PATCH 39/48] fix(coding-agent): flush the roster on plain cron job add and cancel cronStore.onHeartbeatChange only fires on the heartbeat catalog signature, and cron_add/cron_cancel emit no session event, so hasRegisteredCronJob on the roster row went stale: the idle sweep could evict a worker whose only reason to stay resident was a fresh cron job, or keep a cancelled one pinned forever. The handlers flush explicitly, like set_model does for events that have no session-event carrier. --- .../src/modes/daemon/daemon-mode.ts | 4 ++ .../test/daemon-agent-roster.test.ts | 49 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index eab1b83a97..98b96fa75c 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -4551,6 +4551,8 @@ export class AgentDaemon { case "cron_add": { const state = this.getSessionState(command.activeSessionId); const job = this.createCronJobForState(state, command.schedule, command.prompt); + // Plain cron jobs fire no heartbeat-change or session event; flush hasRegisteredCronJob here. + this.scheduleRosterFlush(); return success(command.id, "cron_add", { job }); } @@ -4564,6 +4566,8 @@ export class AgentDaemon { this.removeQueuedHeartbeatFollowUp(state, job); } this.cronScheduler.wake(); + // Plain cron jobs fire no heartbeat-change or session event; flush hasRegisteredCronJob here. + this.scheduleRosterFlush(); return success(command.id, "cron_cancel", { job }); } diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index 00b5ba9e13..9eb8c816f1 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -245,6 +245,55 @@ describe("worker roster reporter", () => { expect(daemon.rosterReporter.queuedChildren.size).toBe(0); }); + it("flushes hasRegisteredCronJob on cron_add and cron_cancel without any session event", 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(); + }); + it("schedules a republish for retry and tool-execution transitions", () => { const { daemon } = makeWorkerReporter(); const state = makeState({ activeSessionId: "root-active" }); From 6c66b5f642b2c970d72662aedc4f4115d3d5dd36 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 18:46:01 +0200 Subject: [PATCH 40/48] fix(coding-agent): keep the hydrated summary when a snapshot reseeds a claimed child The absentee reseed wrote a synthetic ledger seed (no lastActivityAt, messageCount 0, artifact-dir cwd) over a previously hydrated claimed row. Every worker snapshot goes through this for passive registry children, and Date.parse(undefined) = NaN made canEvictWorker permanently false while the degraded row persisted; plain list served the degraded fields too. The reseed now rewrites the previous entry's summary (claim and data both survive); only rows with no prior entry get the synthetic workerless seed. --- .../src/modes/daemon/daemon-supervisor.ts | 20 +++++++++++-------- .../test/daemon-agent-roster.test.ts | 8 ++++++++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 4d8bcccd9f..b5ce0b1efc 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -3884,11 +3884,11 @@ export class DaemonSupervisor { // Without readable edges the absentee sweep cannot tell registry children from stale rows, so the // destructive half is skipped: rows survive and the single-flight repair pull refreshes them. const sent = new Set(delta.entries.map((entry) => entry.agentId)); - const unclaimed = new Set(); + const unclaimed = new Map(); if (!edgesFailed) { for (const entry of this.workerRosterEntries(worker)) { if (sent.has(entry.agentId)) continue; - unclaimed.add(entry.agentId); + unclaimed.set(entry.agentId, entry); this.roster().delete(entry.agentId); } } @@ -3904,16 +3904,20 @@ export class DaemonSupervisor { return; } // Deleted absentees with surviving transcripts reseed from the pre-read edges, tombstone-filtered. - // A reseed keeps its previous claim: passive registry children list and attach through their live - // owner, and snapshots (which never compose them) must not flap that claim off. + // A reseed keeps its previous claim AND its hydrated summary: passive registry children list and + // attach through their live owner, snapshots (which never compose them) must not flap that claim + // off, and a synthetic seed would drop lastActivityAt and pin canEvictWorker on NaN. for (const edge of edges) { 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 }, - unclaimed.has(entry.agentId) ? worker.descriptor.workerId : undefined, - ); + 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 }); } } diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index 9eb8c816f1..a69ce07a97 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -929,6 +929,8 @@ describe("supervisor roster ledger", () => { runtimeKind: "subagent", rlmChildId: "p", parentSessionPath: parentPath, + messageCount: 4, + lastActivityAt: "2026-08-01T10:00:00.000Z", }); worker.summaries.set("worker-1-root-active", root); worker.summaries.set("p-session", passive); @@ -942,6 +944,12 @@ describe("supervisor roster ledger", () => { const passiveRow = [...supervisor.roster().values()].find((entry) => entry.summary.rlmChildId === "p"); expect(passiveRow?.workerId).toBe("worker-1"); + // The reseed keeps the hydrated summary: a synthetic seed would drop lastActivityAt (NaN pins + // canEvictWorker false forever) and degrade list output to messageCount 0 with a synthetic cwd. + expect(passiveRow?.summary.lastActivityAt).toBe("2026-08-01T10:00:00.000Z"); + expect(passiveRow?.summary.messageCount).toBe(4); + expect(passiveRow?.summary.cwd).toBe("/tmp/project"); + expect(passiveRow?.seededCwd).toBeUndefined(); const listed = await supervisor.handleList({}, { type: "list" }); expect(listed.data?.sessions.map((session) => session.sessionId).sort()).toEqual([ passiveRow?.summary.sessionId, From 634a19d36a18c87e0cdf561381d8b20cdae8075f Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 18:47:21 +0200 Subject: [PATCH 41/48] chore(coding-agent): roster review nits - flushRoster's queuedChildren loop var is an agentId (parent-qualified), not a bare childId; name it so. - set/cycle_thinking_level drop their explicit roster flushes: an actual change emits thinking_level_changed, which is a trigger already (the set_model flushes stay - model changes emit no session event). - Non-worker daemons no longer accumulate removedAgentIds that no flush ever drains. - The changelog stops presenting recovering/last-heard-from as user-visible in this PR; the surfaces that display them ship in the follow-up. --- .../.changes/eng-5794-agent-roster-ledger.md | 2 +- .../src/modes/daemon/daemon-mode.ts | 22 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md b/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md index d25e6c3f77..1d15e771b3 100644 --- a/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md +++ b/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md @@ -1,3 +1,3 @@ - Made the daemon supervisor own an event-driven agent roster: workers push roster deltas on session events, `list` is served from the supervisor's ledger with zero worker round-trips, and stale cached summaries can no longer be returned. - 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. -- Marked sessions of a dead worker "recovering" the moment its socket closes, and stamped a last-heard-from time on rows of silent workers instead of guessing. +- 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/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 98b96fa75c..9256ba8c52 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -1134,8 +1134,11 @@ 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 }); - this.rosterReporter.removedAgentIds.add(this.rosterAgentIdForRlmChild(childId, entry.parentSessionFile)); - this.scheduleRosterFlush(); + // Only worker daemons flush removals; a non-worker daemon must not grow the set unbounded. + if (this.options.worker) { + this.rosterReporter.removedAgentIds.add(this.rosterAgentIdForRlmChild(childId, entry.parentSessionFile)); + 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); @@ -3922,7 +3925,8 @@ export class AgentDaemon { }, }); // A file that still exists keeps its roster row; only a real deletion is published. - if (result.ok) { + // Only worker daemons flush removals; a non-worker daemon must not grow the set unbounded. + if (result.ok && this.options.worker) { const removedAgentId = composedEntry?.agentId ?? (ledgerEdge ? this.rosterAgentIdForRlmChild(ledgerEdge.childId, ledgerEdge.parent) : deletedInfo?.id); @@ -4629,8 +4633,8 @@ export class AgentDaemon { case "set_thinking_level": { const state = this.getSessionState(command.activeSessionId); + // An actual change emits thinking_level_changed, which is a roster trigger already. state.runtime.session.setThinkingLevel(command.level); - this.scheduleRosterFlush(); return success(command.id, "set_thinking_level"); } @@ -4642,8 +4646,8 @@ export class AgentDaemon { case "cycle_thinking_level": { const state = this.getSessionState(command.activeSessionId); + // An actual change emits thinking_level_changed, which is a roster trigger already. const level = state.runtime.session.cycleThinkingLevel(); - this.scheduleRosterFlush(); return success(command.id, "cycle_thinking_level", level ? { level } : null); } @@ -6677,12 +6681,12 @@ export class AgentDaemon { } // Session rows win: a run whose session already composed is bound, and the rlm_child_update // that clears the queued marker can trail the session's own first events. - for (const [childId, queued] of reporter.queuedChildren) { - if (entries.has(childId)) { - reporter.queuedChildren.delete(childId); + for (const [agentId, queued] of reporter.queuedChildren) { + if (entries.has(agentId)) { + reporter.queuedChildren.delete(agentId); continue; } - entries.set(childId, queued); + entries.set(agentId, queued); } // A terminal unbound child run owns no transcript: it is a removal, never a passivated row. for (const [agentId, previous] of reporter.lastComposed) { From 4bff2b42522da2c69df55923ef578eeedf09058f Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 19:11:18 +0200 Subject: [PATCH 42/48] fix(coding-agent): scope pending roster removals to one incarnation and fence applies on socket close - A pending removal now records the sessionId it removes. A row composed again under the same agentId with a different sessionId (or a re-admitted queued run) is a new incarnation and cancels the stale removal instead of being suppressed from every flush including the reconnect snapshot; the removed incarnation itself stays suppressed mid-teardown so a deleted child cannot ghost back as a passivated row. - isWorkerRosterApplyCurrent also requires a live (or authenticating) connection: an apply left in flight by a closed socket can no longer rewrite rows and drop the recovering label handleWorkerClose just set. Reconnection resumes applies through the pending client. --- .../src/modes/daemon/daemon-mode.ts | 37 ++++++-- .../src/modes/daemon/daemon-supervisor.ts | 7 +- .../test/daemon-agent-roster.test.ts | 85 ++++++++++++++++--- .../test/daemon-supervisor-monitor.test.ts | 2 +- 4 files changed, 110 insertions(+), 21 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 9256ba8c52..3d1994e9f0 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 { @@ -557,7 +557,7 @@ export class AgentDaemon { lastComposed: new Map(), lastComposedJson: new Map(), queuedChildren: new Map(), - removedAgentIds: new Set(), + removedAgentIds: new Map(), snapshotPending: false, }; private rosterFlushScheduled = false; @@ -1136,7 +1136,10 @@ export class AgentDaemon { await this.rlmSpawnLedger().appendDelete({ childId, child: entry.sessionFile, reason }); // Only worker daemons flush removals; a non-worker daemon must not grow the set unbounded. if (this.options.worker) { - this.rosterReporter.removedAgentIds.add(this.rosterAgentIdForRlmChild(childId, entry.parentSessionFile)); + this.rosterReporter.removedAgentIds.set( + this.rosterAgentIdForRlmChild(childId, entry.parentSessionFile), + basename(entry.sessionFile, ".jsonl"), + ); this.scheduleRosterFlush(); } // Deletion boundary: transcript + display tombstone are the durable @@ -3931,7 +3934,10 @@ export class AgentDaemon { composedEntry?.agentId ?? (ledgerEdge ? this.rosterAgentIdForRlmChild(ledgerEdge.childId, ledgerEdge.parent) : deletedInfo?.id); if (removedAgentId) { - this.rosterReporter.removedAgentIds.add(removedAgentId); + this.rosterReporter.removedAgentIds.set( + removedAgentId, + composedEntry?.summary.sessionId ?? deletedInfo?.id, + ); this.scheduleRosterFlush(); } } @@ -6375,7 +6381,9 @@ export class AgentDaemon { this.acpMcpOwners.delete(state.activeSessionId); this.sessions.delete(state.activeSessionId); // A discarded draft leaves no transcript, so its roster row goes with it. - if (isEmptyDraftSession) this.rosterReporter.removedAgentIds.add(this.rosterAgentIdForState(state)); + if (isEmptyDraftSession && this.options.worker) { + this.rosterReporter.removedAgentIds.set(this.rosterAgentIdForState(state), state.runtime.session.sessionId); + } this.scheduleRosterFlush(); if (isEmptyDraftSession) { const sessionFile = state.runtime.session.sessionFile; @@ -6690,9 +6698,18 @@ export class AgentDaemon { } // A terminal unbound child run owns no transcript: it is a removal, never a passivated row. for (const [agentId, previous] of reporter.lastComposed) { - if (previous.queuedChild === true && !entries.has(agentId)) reporter.removedAgentIds.add(agentId); + if (previous.queuedChild === true && !entries.has(agentId)) { + reporter.removedAgentIds.set(agentId, previous.summary.sessionId); + } } - for (const agentId of reporter.removedAgentIds) { + for (const [agentId, targetSessionId] of reporter.removedAgentIds) { + const composed = entries.get(agentId); + // A new incarnation of the id cancels the stale pending removal; the removed incarnation + // itself (same sessionId, mid-teardown) stays suppressed so it cannot ghost as passivated. + if (composed && (composed.queuedChild === true || composed.summary.sessionId !== targetSessionId)) { + reporter.removedAgentIds.delete(agentId); + continue; + } entries.delete(agentId); reporter.queuedChildren.delete(agentId); } @@ -6720,7 +6737,7 @@ export class AgentDaemon { nextJson.set(entry.agentId, json); if (reporter.lastComposedJson.get(entry.agentId) !== json) changed.push(entry); } - const removedAgentIds = [...reporter.removedAgentIds]; + const removedAgentIds = [...reporter.removedAgentIds.keys()]; reporter.lastComposed = new Map(entries); reporter.lastComposedJson = nextJson; if (!this.hasAuthenticatedSupervisorClient()) { @@ -7154,7 +7171,9 @@ interface WorkerRosterReporterState { lastComposedJson: Map; /** Admitted child runs whose sessions have not materialized yet, keyed by agentId. */ queuedChildren: Map; - removedAgentIds: Set; + /** Pending removals: agentId -> the sessionId being removed. A row composed again with a + * different sessionId (or a re-admitted run) is a new incarnation and cancels the removal. */ + removedAgentIds: Map; /** Set on any undelivered change; the next flush sends one full replacing snapshot. */ snapshotPending: boolean; } diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index b5ce0b1efc..84ee738cb5 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -3836,7 +3836,12 @@ export class DaemonSupervisor { } private isWorkerRosterApplyCurrent(worker: ResidentWorker): boolean { - return this.workers.get(worker.descriptor.workerId) === worker; + // A closed connection stales its queued applies: rows marked "recovering" on socket close must + // not be rewritten by an apply the dead connection left behind. Reconnection resumes applies. + return ( + this.workers.get(worker.descriptor.workerId) === worker && + (worker.client ?? worker.pendingClient) !== undefined + ); } /** A partial apply may have deleted rows it never rewrote; one single-flight gap-fill pull repairs the ledger. */ diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index a69ce07a97..79d5213a0e 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -41,7 +41,7 @@ interface WorkerReporterFixture { lastComposed: Map; lastComposedJson: Map; queuedChildren: Map; - removedAgentIds: Set; + removedAgentIds: Map; snapshotPending: boolean; }; }; @@ -60,7 +60,7 @@ function makeWorkerReporter(connected = true): WorkerReporterFixture { lastComposed: new Map(), lastComposedJson: new Map(), queuedChildren: new Map(), - removedAgentIds: new Set(), + removedAgentIds: new Map(), snapshotPending: false, }, rosterFlushScheduled: false, @@ -245,6 +245,46 @@ describe("worker roster reporter", () => { expect(daemon.rosterReporter.queuedChildren.size).toBe(0); }); + 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("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 === "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("child-2", "session-child-active"); + daemon.flushRoster(); + expect(sentDeltas.at(-1)?.removedAgentIds).toEqual(["child-2"]); + daemon.sessions.delete(dying.activeSessionId); + daemon.flushRoster(); + expect(daemon.rosterReporter.lastComposed.has("child-2")).toBe(false); + }); + it("flushes hasRegisteredCronJob on cron_add and cron_cancel without any session event", async () => { const directory = mkdtempSync(join(tmpdir(), "prime-roster-cron-flush-")); tempDirs.push(directory); @@ -415,13 +455,16 @@ describe("worker roster reporter", () => { lastComposed: new Map(), lastComposedJson: new Map(), queuedChildren: new Map(), - removedAgentIds: new Set(["deleted-agent"]), + removedAgentIds: new Map([["deleted-agent", undefined]]), snapshotPending: false, }, rosterFlushScheduled: false, shuttingDown: false, log: vi.fn(), - }) as { flushRoster(): void; rosterReporter: { snapshotPending: boolean; removedAgentIds: Set } }; + }) as { + flushRoster(): void; + rosterReporter: { snapshotPending: boolean; removedAgentIds: Map }; + }; daemon.flushRoster(); // The queued write IS delivered: nothing stays pending and only the claimed socket receives the write. @@ -432,7 +475,7 @@ describe("worker roster reporter", () => { // A destroyed claim socket is an actual loss gap: the change marks one pending snapshot. client.socket.destroyed = true; - daemon.rosterReporter.removedAgentIds.add("lost-agent"); + daemon.rosterReporter.removedAgentIds.set("lost-agent", undefined); daemon.flushRoster(); expect(write).toHaveBeenCalledTimes(1); expect(daemon.rosterReporter.snapshotPending).toBe(true); @@ -1716,6 +1759,28 @@ describe("review-round regressions", () => { 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: () => ({ + edges: () => + 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")); + releaseClosedEdges([]); + await new Promise((resolveSettle) => setImmediate(resolveSettle)); + + expect(closedSupervisor.workerRosterEntries(closed)[0]).toMatchObject({ statusLabel: "recovering" }); }); it("repairs failed roster applies with one single-flight pull that never respawns itself", async () => { @@ -1843,7 +1908,7 @@ describe("review-round regressions", () => { lastComposed: new Map(), lastComposedJson: new Map(), queuedChildren: new Map(), - removedAgentIds: new Set(), + removedAgentIds: new Map(), snapshotPending: true, }; for (let index = 0; index < 3000; index++) { @@ -1934,7 +1999,7 @@ describe("review-round regressions", () => { const internals = daemon as unknown as { rlmSpawnLedger(): RlmSpawnLedger; recordRlmSubagentDeletion(parentState: ActiveSessionState, childId: string): Promise; - rosterReporter: { removedAgentIds: Set }; + rosterReporter: { removedAgentIds: Map }; }; await internals .rlmSpawnLedger() @@ -1969,7 +2034,7 @@ describe("worker delete tombstone durability", () => { Object.assign(daemon, { rlmSpawnLedger: () => ({ edges: ledgerEdges }) }); return daemon as unknown as { handleCommand(client: object, command: object): Promise; - rosterReporter: { removedAgentIds: Set }; + rosterReporter: { removedAgentIds: Map }; }; } @@ -1992,7 +2057,7 @@ describe("worker delete tombstone durability", () => { ); expect(existsSync(sessionPath)).toBe(false); - expect([...daemon.rosterReporter.removedAgentIds]).toEqual([manager.getSessionId()]); + expect([...daemon.rosterReporter.removedAgentIds.keys()]).toEqual([manager.getSessionId()]); }); it("classifies an unreadable delete target through the ledger", async () => { const setup = () => { @@ -2015,7 +2080,7 @@ describe("worker delete tombstone durability", () => { } as never) as unknown as { rlmSpawnLedger(): RlmSpawnLedger; handleCommand(client: object, command: object): Promise; - rosterReporter: { removedAgentIds: Set }; + rosterReporter: { removedAgentIds: Map }; }; await daemonWithEdge.rlmSpawnLedger().appendSpawn({ childId: "sub-9", diff --git a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts index afd4606892..432fc8ae1e 100644 --- a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts @@ -500,7 +500,7 @@ describe("daemon worker supervisor monitoring", () => { lastComposed: new Map(), lastComposedJson: new Map(), queuedChildren: new Map(), - removedAgentIds: new Set(), + removedAgentIds: new Map(), snapshotPending: false, }, shuttingDown: false, From 2ee3fc817f003aebf0145ba6077830adc92d3893 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 19:30:00 +0200 Subject: [PATCH 43/48] chore(coding-agent): slim roster comments and consolidate roster tests Comments: 153 -> 33 added src comment lines. Kept only notes resolving real ambiguity (pull-epoch guard, close fence, reseed/NaN rationale, incarnation suppression, privacy rules, backpressure delivery assumption, pid-recycle and SIGKILL-wait justifications, wire-schema notes); deleted all narration. Tests: one behavior test per contract. Merged into their parent behavior test: bind-window compose-wins, trigger republish, queued rows ledger-internal, queued-ghost flip, offline rename + failed-disk delete, client-owned inactive list row, unchained-delta currency, set_model no-carrier flush, reseed data quality, qualified removal ids. Deleted pins whose behavior another test or the process-suite E2E already proves: undelivered-change escalation, real- socket backpressured snapshot, recovering-on-close (asserted in the close- fence test), frame-source trust, seeded selector resolution, root-pointer epoch persist, miss-path refresh routing, hydrate race, sessions-dir topology scoping, delivery-semantics mock twin, descriptor-path delete-routing variant. --- .../src/modes/daemon/agent-roster.ts | 14 - .../src/modes/daemon/daemon-mode.ts | 34 +- .../src/modes/daemon/daemon-session-list.ts | 1 - .../src/modes/daemon/daemon-supervisor.ts | 88 +- .../src/modes/daemon/rlm-ledger.ts | 11 +- .../test/daemon-agent-roster.test.ts | 1017 ++++------------- 6 files changed, 220 insertions(+), 945 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/agent-roster.ts b/packages/coding-agent/src/modes/daemon/agent-roster.ts index e77f52ad47..30c7b3419d 100644 --- a/packages/coding-agent/src/modes/daemon/agent-roster.ts +++ b/packages/coding-agent/src/modes/daemon/agent-roster.ts @@ -20,14 +20,12 @@ export function classifyAgentStatus(input: AgentStatusInput): AgentRosterStatus return input.busy || input.hasActiveHeartbeat ? "running" : "idle"; } -/** The one busy predicate over session summaries; every surface (list, roster, launch checks) derives from it. */ export function isSessionSummaryBusy( summary: Pick, ): boolean { return summary.isSessionActive || summary.hasRunningRlmChildren === true; } -/** The one summary-shaped adapter over classifyAgentStatus; roster rows add only their queuedChild bit. */ export function classifySessionRosterStatus( summary: Pick< SessionSummary, @@ -43,26 +41,19 @@ export function classifySessionRosterStatus( }); } -// A session summary without its heavyweight per-event fields; `list` re-adds an empty sessionActions. export type RosterSessionSummary = Omit; export interface WorkerRosterEntry { - /** rlmChildId for subagents (stable queued->running->passivated), sessionId otherwise. */ agentId: string; - /** Admitted child run whose session has not materialized yet. */ queuedChild?: true; - /** Supervisor seed marker: the cwd is synthetic until one transcript-header read. */ seededCwd?: true; summary: RosterSessionSummary; } -/** Supervisor-owned roster row: a worker entry plus supervisor-only state. */ export interface AgentRosterEntry extends WorkerRosterEntry { status: AgentRosterStatus; statusLabel?: "queued" | "recovering" | "failed"; - /** Staleness marker set by the supervisor watchdog while the owning worker is silent. */ lastHeardFromAt?: string; - /** Owning resident worker; absent for seeded entries no worker has claimed. */ workerId?: string; } @@ -83,15 +74,12 @@ export function workerRosterEntryFromSummary(summary: SessionSummary): WorkerRos return { agentId: rosterAgentIdForSummary(summary), summary: slim }; } -/** The roster half of the classifier input; the queuedChild bit rides the entry itself. */ export function classifyWorkerRosterEntry(entry: WorkerRosterEntry): AgentRosterStatus { return classifySessionRosterStatus(entry.summary, entry.queuedChild === true); } -/** Final entry for an agent whose runtime left memory; identity and display fields survive. */ export function passivatedWorkerRosterEntry( entry: WorkerRosterEntry, - // Registration flags never freeze: callers with a cron store pass fresh values, others strip them. registrations?: { hasRegisteredHeartbeat: boolean; hasRegisteredCronJob: boolean }, ): WorkerRosterEntry { const { @@ -110,7 +98,6 @@ export function passivatedWorkerRosterEntry( agentId: entry.agentId, summary: { ...summary, - // Inactive rows are keyed by their durable session id, like catalog rows. id: summary.sessionId, activity: "idle", isSessionActive: false, @@ -127,7 +114,6 @@ export function sessionSummaryFromRosterEntry(entry: WorkerRosterEntry): Session return { ...entry.summary, sessionActions: { queuedCount: 0, steering: [], followUps: [] } }; } -// Supervisor-owned roster store; write() classifies once and its file index converges seed and worker keys. export class AgentRoster { private readonly entries = new Map(); private readonly agentIdByActiveSessionId = new Map(); diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 3d1994e9f0..9aa974bd3c 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -1134,7 +1134,6 @@ 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 }); - // Only worker daemons flush removals; a non-worker daemon must not grow the set unbounded. if (this.options.worker) { this.rosterReporter.removedAgentIds.set( this.rosterAgentIdForRlmChild(childId, entry.parentSessionFile), @@ -3366,7 +3365,6 @@ export class AgentDaemon { success: true, data: { capabilities: [DAEMON_WORKER_ROSTER_CAPABILITY] }, }); - // A (re)connected supervisor gets one full replacing snapshot; disk is the durable truth behind it. this.rosterReporter.snapshotPending = true; this.scheduleRosterFlush(); return; @@ -3927,8 +3925,6 @@ export class AgentDaemon { this.cancelScheduledJobsForSessionFile(command.sessionPath); }, }); - // A file that still exists keeps its roster row; only a real deletion is published. - // Only worker daemons flush removals; a non-worker daemon must not grow the set unbounded. if (result.ok && this.options.worker) { const removedAgentId = composedEntry?.agentId ?? @@ -4261,7 +4257,6 @@ export class AgentDaemon { try { return success(command.id, "execute_bash_and_wait", await bash); } finally { - // executeBash appends transcript messages without bash_* events; flush the roster projection here. this.scheduleRosterFlush(); } } @@ -4561,7 +4556,6 @@ export class AgentDaemon { case "cron_add": { const state = this.getSessionState(command.activeSessionId); const job = this.createCronJobForState(state, command.schedule, command.prompt); - // Plain cron jobs fire no heartbeat-change or session event; flush hasRegisteredCronJob here. this.scheduleRosterFlush(); return success(command.id, "cron_add", { job }); } @@ -4576,7 +4570,6 @@ export class AgentDaemon { this.removeQueuedHeartbeatFollowUp(state, job); } this.cronScheduler.wake(); - // Plain cron jobs fire no heartbeat-change or session event; flush hasRegisteredCronJob here. this.scheduleRosterFlush(); return success(command.id, "cron_cancel", { job }); } @@ -4639,7 +4632,6 @@ export class AgentDaemon { case "set_thinking_level": { const state = this.getSessionState(command.activeSessionId); - // An actual change emits thinking_level_changed, which is a roster trigger already. state.runtime.session.setThinkingLevel(command.level); return success(command.id, "set_thinking_level"); } @@ -4652,7 +4644,6 @@ export class AgentDaemon { case "cycle_thinking_level": { const state = this.getSessionState(command.activeSessionId); - // An actual change emits thinking_level_changed, which is a roster trigger already. const level = state.runtime.session.cycleThinkingLevel(); return success(command.id, "cycle_thinking_level", level ? { level } : null); } @@ -6180,7 +6171,6 @@ export class AgentDaemon { } private findActiveSessionByFile(sessionPath: string): ActiveSessionState | undefined { - // Symlink-resolving canonicalization: guards compare the same path the tombstone/removal side uses. const canonicalPath = canonicalSessionPath(sessionPath); for (const state of this.sessions.values()) { const sessionFile = state.runtime.session.sessionFile; @@ -6380,7 +6370,6 @@ export class AgentDaemon { state.clients.clear(); this.acpMcpOwners.delete(state.activeSessionId); this.sessions.delete(state.activeSessionId); - // A discarded draft leaves no transcript, so its roster row goes with it. if (isEmptyDraftSession && this.options.worker) { this.rosterReporter.removedAgentIds.set(this.rosterAgentIdForState(state), state.runtime.session.sessionId); } @@ -6588,7 +6577,6 @@ export class AgentDaemon { return session.sessionId; } - /** The one resolution for subagent roster ids: childId qualified by its parent's session path. */ private rosterAgentIdForRlmChild(childId: string, parentSessionPath: string | undefined): string { return rosterAgentIdForSummary({ runtimeKind: "subagent", @@ -6617,7 +6605,6 @@ export class AgentDaemon { } private observeRosterChildUpdate(state: ActiveSessionState, child: AgentConnectionRlmChildAgentSnapshot): void { - // The one supersession point: a run with a bound session never has a queued row. const bound = child.activeSessionId !== undefined || this.hasSessionForRlmChild(state, child.id); const entry = this.queuedChildRosterEntry(state, child); if (!bound && (child.status === "queued" || child.status === "running")) { @@ -6687,8 +6674,7 @@ export class AgentDaemon { const entry = workerRosterEntryFromSummary(summary); entries.set(entry.agentId, entry); } - // Session rows win: a run whose session already composed is bound, and the rlm_child_update - // that clears the queued marker can trail the session's own first events. + // The rlm_child_update that clears a queued marker can trail the bound session's own first events. for (const [agentId, queued] of reporter.queuedChildren) { if (entries.has(agentId)) { reporter.queuedChildren.delete(agentId); @@ -6704,8 +6690,7 @@ export class AgentDaemon { } for (const [agentId, targetSessionId] of reporter.removedAgentIds) { const composed = entries.get(agentId); - // A new incarnation of the id cancels the stale pending removal; the removed incarnation - // itself (same sessionId, mid-teardown) stays suppressed so it cannot ghost as passivated. + // A new incarnation cancels the stale removal; the removed one stays suppressed mid-teardown. if (composed && (composed.queuedChild === true || composed.summary.sessionId !== targetSessionId)) { reporter.removedAgentIds.delete(agentId); continue; @@ -6713,8 +6698,6 @@ export class AgentDaemon { entries.delete(agentId); reporter.queuedChildren.delete(agentId); } - // Rows whose runtime left memory flip to passivated and stay known, delivered or not. - // Their registration flags come fresh from the cron store per flush; frozen flags would pin eviction. const registrations = scheduledJobRegistrations(scheduledJobs); for (const [agentId, previous] of reporter.lastComposed) { if (!entries.has(agentId) && !reporter.removedAgentIds.has(agentId)) { @@ -6728,8 +6711,6 @@ export class AgentDaemon { ); } } - // Deltas are best-effort freshness hints; any miss escalates to one full replacing snapshot. - // Cached serializations keep churny flushes at one stringify per current row. const changed: WorkerRosterEntry[] = []; const nextJson = new Map(); for (const entry of entries.values()) { @@ -6741,7 +6722,6 @@ export class AgentDaemon { reporter.lastComposed = new Map(entries); reporter.lastComposedJson = nextJson; if (!this.hasAuthenticatedSupervisorClient()) { - // Undelivered removals stay pending; they ride the first delivered frame. if (changed.length > 0 || removedAgentIds.length > 0) reporter.snapshotPending = true; return; } @@ -6768,7 +6748,6 @@ export class AgentDaemon { else reporter.snapshotPending = true; } - // The live supervisor claim is the single delivery authority; revoked sockets cannot satisfy it. private hasAuthenticatedSupervisorClient(): boolean { for (const client of this.clients) { if (this.supervisorClaims.has(client) && !client.socket.destroyed) { @@ -7165,20 +7144,14 @@ export class AgentDaemon { } interface WorkerRosterReporterState { - /** Last composed roster, delivered or not; the source for passivated flips and change hints. */ lastComposed: Map; - /** Serialized form of lastComposed, reused for change detection across flushes. */ lastComposedJson: Map; - /** Admitted child runs whose sessions have not materialized yet, keyed by agentId. */ queuedChildren: Map; - /** Pending removals: agentId -> the sessionId being removed. A row composed again with a - * different sessionId (or a re-admitted run) is a new incarnation and cancels the removal. */ + /** Pending removals: agentId -> removed sessionId; a new incarnation of the id cancels it. */ removedAgentIds: Map; - /** Set on any undelivered change; the next flush sends one full replacing snapshot. */ snapshotPending: boolean; } -// Session events that can change an agent's roster projection (status, activity, name, recap). const ROSTER_SESSION_EVENT_TRIGGERS = new Set([ "turn_start", "turn_end", @@ -7186,7 +7159,6 @@ const ROSTER_SESSION_EVENT_TRIGGERS = new Set([ "bash_end", "compaction_start", "compaction_end", - // Retry transitions flip isSessionActive/activity; tool transitions flip isRunningTools. "auto_retry_start", "auto_retry_end", "tool_execution_start", 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 496f6d5032..5e8e8d4e48 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-session-list.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-session-list.ts @@ -102,7 +102,6 @@ export function resolveAttachModelFallbackMessage( return summary.model ? undefined : startupModelFallbackMessage; } -/** The one registration index over scheduled jobs; summaries and passivated roster flips both read it. */ export function scheduledJobRegistrations(scheduledJobs: readonly AgentCronJob[]): { activeHeartbeatSessionIds: Set; heartbeatSessionIds: Set; diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 84ee738cb5..bbd53317f0 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -158,7 +158,6 @@ type DaemonCommandBody = DistributiveOmit; const structuredLog = getLogger("coding-agent.daemon-supervisor"); const WORKER_CONNECT_TIMEOUT_MS = 30_000; const ROSTER_WATCHDOG_INTERVAL_MS = 15_000; -// Three missed worker heartbeats: the watchdog stamps silence, it never drives recovery. 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; @@ -313,17 +312,13 @@ interface ResidentWorker { ownerCleanupTimer?: ReturnType; promotedOwnerClientId?: string; updateRestartPrepareClient?: DaemonWorkerClient; - /** Wall-clock time of the last frame received from this worker. */ lastFrameAt?: number; - /** True while the watchdog has stamped this worker's entries as stale. */ 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; - /** Serializes snapshot applications (and any deltas behind them) per worker. */ rosterApplyChain?: Promise; - /** Single-flight marker for the gap-fill pull that repairs a failed roster apply. */ rosterRepairPull?: Promise; } @@ -504,7 +499,6 @@ function isDaemonWorkerDescriptor(value: unknown, socketPath: string): value is ); } -/** Adoption-only signal: the connected worker predates the roster protocol and must be restarted. */ class PreRosterWorkerError extends Error {} function workerAuthAdvertisesRoster(data: unknown): boolean { @@ -827,14 +821,12 @@ export class DaemonSupervisor { hasOwnerClient: worker.descriptor.ownerClientId !== undefined, isPreparingUpdateRestart: this.updateRestartPhase !== undefined || worker.updateRestartPrepareClient !== undefined, - // The roster carries deltas newer than any discarded refresh response; eviction must see them. sessions: this.workerRosterEntries(worker) .filter((entry) => !entry.queuedChild) .map(sessionSummaryFromRosterEntry) .map((summary) => { const activeSessionId = summary.activeSessionId ?? summary.id; return { - // The canonical busy projection: a parent stays active for residency while any RLM descendant runs. isSessionActive: isSessionSummaryBusy(summary), attachedClients: [...this.clients].filter((client) => client.attachedActiveSessionIds.has(activeSessionId), @@ -858,7 +850,6 @@ export class DaemonSupervisor { await Promise.all( [...this.workers.values()].map(async (worker) => { try { - // Responsiveness gate only: the eviction decision reads the delta-fed roster, not this pull's data. await this.refreshWorkerSummaries(worker); refreshed.add(worker); } catch { @@ -952,14 +943,11 @@ export class DaemonSupervisor { } } - /** Re-validates one worker on a fresh pull under the caller's fence, then passivates it. */ private async passivateWorkerIfStillEligible( worker: ResidentWorker, isStillEligible: () => boolean, describeEvicted: () => string, ): Promise { - // Responsiveness gate and write-through: the eligibility re-read serves from the roster rows - // this pull refreshes, so a mutation drained just before it cannot be missed. await this.refreshWorkerSummaries(worker, false, true); if (!isStillEligible()) return; await this.stopWorker(worker, true); @@ -979,7 +967,6 @@ export class DaemonSupervisor { return; } try { - // Responsiveness gate and write-through: the candidate check reads the roster rows this pull refreshes. await this.refreshWorkerSummaries(worker, false, true); } catch { return; @@ -1009,7 +996,6 @@ export class DaemonSupervisor { ) { return false; } - // One decision source: the delta-fed roster, freshened by this path's write-through pulls. const summaries = this.workerRosterEntries(worker) .filter((entry) => !entry.queuedChild) .map(sessionSummaryFromRosterEntry); @@ -2139,16 +2125,13 @@ export class DaemonSupervisor { if (entry?.summary.activeSessionId !== undefined) { throw new Error("Cannot delete the currently active session"); } - // Descriptor and summary paths cover owners whose rows are not flushed yet (startup, adoption, fresh children). 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); - // The owning worker deletes its own passivated files and publishes the removal itself. if (owner.client && !this.isWorkerStopping(owner)) { return this.forwardToWorker(owner, command); } - // A failed registration with a dead process is reclaimed; anything else retries after recovery. if (!(await this.reclaimStaleWorkerRegistration(owner))) { throw new Error( `Session worker is ${this.effectiveWorkerState(owner)}; retry the delete once it is reachable`, @@ -2302,7 +2285,6 @@ export class DaemonSupervisor { } } - /** Worker-owned rows come from the ledger with zero worker round-trips; list all rescans the disk. */ private async handleList( client: DaemonSocketClient, command: Extract, @@ -2311,12 +2293,10 @@ export class DaemonSupervisor { const activeByFile = new Map(); let busyClientOwnedSessionCount = 0; for (const entry of this.roster().values()) { - // Sessionless queued-child rows are ledger-internal; no list form serves them. 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)); - // Stopping workers stay listed with an honest workerState; daemon-launch busy checks read this list. if (this.isVisibleWorker(worker)) { active.push(summary); if (summary.sessionFile) activeByFile.set(canonicalSessionPath(summary.sessionFile), summary); @@ -2335,11 +2315,9 @@ export class DaemonSupervisor { if (!command.all) { return success(command.id, "list", data); } - // Disk is authoritative for non-resident rows; a failed scan must fail the list, not shrink it. const sessionDir = command.sessionDir ?? this.defaultSessionConfig.sessionDir; const scanned = await this.catalog.list(command.cwd ? resolve(command.cwd) : undefined, sessionDir); const cwd = command.cwd ? resolve(command.cwd) : undefined; - // Worker rows replace their scanned files in place so the newest-first catalog order survives. const merged: SessionSummary[] = []; const servedRows = new Set(active); const mergedActiveFiles = new Set(); @@ -2353,20 +2331,17 @@ export class DaemonSupervisor { mergedActiveFiles.add(file); continue; } - // A worker row this client is not served (client-owned, no includeClientOwned) hides the live - // metadata only: the on-disk scan is public, so the file still lists as a plain inactive row. + // 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()) { - // Ledger-only offline rows (artifact-dir children, flipped residents) ride along with the scan. 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); } - // Hydration reads one transcript header per still-seeded row; overlap the reads. 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; @@ -2381,7 +2356,6 @@ export class DaemonSupervisor { return success(command.id, "list", { ...data, sessions: merged }); } - // Seeded artifact-dir rows carry a synthetic cwd until their transcript header is read once. private async hydrateSeededEntry(entry: AgentRosterEntry): Promise { if (entry.seededCwd !== true || !entry.summary.sessionFile) return entry; const info = await readSessionInfo(entry.summary.sessionFile).catch(() => undefined); @@ -2397,13 +2371,11 @@ export class DaemonSupervisor { ); } - // An artifact-dir child belongs to the sessions dir of its owning root session. 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; - // A visited set terminates cycles without capping legitimate depth. const visited = new Set(); while (parentSessionPath !== undefined) { const canonical = canonicalSessionPath(parentSessionPath); @@ -2972,7 +2944,6 @@ export class DaemonSupervisor { 1000, ); await this.assertRecoveryAllowed(); - // Pre-roster workers are restarted on adoption; sessions reload idle and resume on the next prompt. if (!workerAuthAdvertisesRoster(authResponse.data)) { throw new PreRosterWorkerError("Session worker predates the roster protocol and must be restarted"); } @@ -3071,8 +3042,7 @@ export class DaemonSupervisor { return; } this.log(`Could not adopt worker ${worker.descriptor.workerId}: ${String(error)}`); - // A client-owned worker's launch env and recovery config live only with its owner; a bare - // descriptor respawn would drop them, so recoverWorker parks it failed until the owner returns. + // 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); @@ -3088,13 +3058,11 @@ export class DaemonSupervisor { } } - /** Bare restart for adopted pre-roster workers: the durable descriptor is the whole respawn context. */ private async restartPreRosterWorker( worker: ResidentWorker, observedProcessStartId: string | undefined, ): Promise { await this.assertRecoveryAllowed(); - // The old process was reachable moments ago; its observed identity makes the kill safe. if (worker.descriptor.processStartId === undefined && observedProcessStartId !== undefined) { worker.descriptor.processStartId = observedProcessStartId; } @@ -3108,7 +3076,6 @@ export class DaemonSupervisor { await delay(25); } } - // Launch only against a confirmed-stopped predecessor; "unknown" may still hold the old socket. const finalIdentity = identity(); if (finalIdentity !== "gone" && finalIdentity !== "replaced") { worker.descriptor.lifecycle = "failed"; @@ -3161,7 +3128,6 @@ export class DaemonSupervisor { if (this.shuttingDown || worker.intentionalStop) { return; } - // Native, timer-free liveness: a closed worker socket marks its rows immediately. this.markWorkerRosterEntries(worker, "recovering"); try { await this.assertRecoveryAllowed(); @@ -3612,7 +3578,6 @@ export class DaemonSupervisor { ); } - /** Pulled summaries feed recovery seeding and eviction checks; deltas own the roster itself. */ private async refreshWorkerSummaries( worker: ResidentWorker, recovery = false, @@ -3638,8 +3603,6 @@ export class DaemonSupervisor { throw new Error(`Session worker omitted its root session during recovery`); } worker.summaries = nextSummaries; - // Launch and recovery pulls carry registry children no delta composes; fill their missing rows. - // The fill queues behind in-flight frame applies and re-checks the epoch there, so it never treats an unapplied snapshot as stable. if (fillGaps) { await this.chainWorkerRosterApply(worker, () => { if ((worker.rosterEpoch ?? 0) === epochAtStart) this.syncRosterFromWorkerSummaries(worker); @@ -3657,7 +3620,6 @@ export class DaemonSupervisor { if (recovery) { await this.assertRecoveryAllowed(); } - // The pulled root persists through the same chain and epoch guard; a frame since the pull owns fresher pointers. await this.chainWorkerRosterApply(worker, () => { if ((worker.rosterEpoch ?? 0) !== epochAtStart) return; worker.descriptor.rootSessionId = root.sessionId; @@ -3672,7 +3634,6 @@ export class DaemonSupervisor { } } - // Name validation reads the disk per call: external processes create root files after startup. private async familyCatalogEntries(): Promise { const rosterRows = [...this.roster().values()]; const entries = rosterRows.map((entry) => this.familyCatalogEntry(sessionSummaryFromRosterEntry(entry))); @@ -3681,7 +3642,6 @@ export class DaemonSupervisor { entry.summary.sessionFile ? [canonicalSessionPath(entry.summary.sessionFile)] : [], ), ); - // Fail closed: name-uniqueness checks must not pass because the scan silently shrank. const scanned = await this.catalog.list(undefined, this.defaultSessionConfig.sessionDir); for (const info of scanned) { if (knownFiles.has(canonicalSessionPath(info.path))) continue; @@ -3720,7 +3680,6 @@ export class DaemonSupervisor { }); } - // The agent roster: the single supervisor-side projection every list and selector read is served from. private roster(): AgentRoster { this.rosterStore ??= new AgentRoster(canonicalSessionPath); return this.rosterStore; @@ -3744,10 +3703,8 @@ export class DaemonSupervisor { return this.roster().entriesForWorker(worker.descriptor.workerId); } - // Seeds selector resolution, name checks, and liveness; list all rescans the disk per call. private async seedRosterLedger(): Promise { try { - // A push-only view needs saved top-level rows in the ledger itself; rows stay slim, cwd hydrates lazily. 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); @@ -3756,7 +3713,6 @@ export class DaemonSupervisor { this.log(`Could not seed the agent roster from the session catalog: ${String(error)}`); } try { - // Ledger edges cover subagents in artifact dirs the catalog never scans; tombstones stay out. for (const edge of await this.rlmSpawnLedger().edges()) { const entry = this.rosterEntryForSpawnLedgerEdge(edge); if (this.roster().has(entry.agentId)) continue; @@ -3769,7 +3725,6 @@ export class DaemonSupervisor { } private rosterEntryForSpawnLedgerEdge(edge: RlmLedgerEdge): WorkerRosterEntry { - // The persisted session id is the transcript's filename; edge.childId stays the child identifier. const persistedSessionId = basename(edge.child, ".jsonl"); const summary: WorkerRosterEntry["summary"] = { id: persistedSessionId, @@ -3781,7 +3736,6 @@ export class DaemonSupervisor { sessionId: persistedSessionId, sessionFile: edge.child, sessionName: edge.name, - // The ledger records topology only; display fields hydrate lazily on open. cwd: dirname(edge.child), isStreaming: false, isCompacting: false, @@ -3801,10 +3755,7 @@ export class DaemonSupervisor { return; } if (delta.type !== "roster_delta" || !Array.isArray(delta.entries)) return; - // The epoch bumps at frame receipt, before any async apply work, so an in-flight pull sees this frame. worker.rosterEpoch = (worker.rosterEpoch ?? 0) + 1; - // The same currency rule chained applies re-check at apply time: a frame from an unregistered - // or replaced registration must never resurrect its rows. if (!this.isWorkerRosterApplyCurrent(worker)) return; if (delta.snapshot !== true && worker.rosterApplyChain === undefined) { this.applyWorkerRosterDelta(worker, delta); @@ -3817,7 +3768,6 @@ export class DaemonSupervisor { ); } - /** Queued frames and pull fills apply in receipt order and abort once the registration is gone; synchronous lifecycle writes need no queue. */ private chainWorkerRosterApply(worker: ResidentWorker, apply: () => void | Promise): Promise { const chained = (worker.rosterApplyChain ?? Promise.resolve()) .then(() => { @@ -3836,15 +3786,13 @@ export class DaemonSupervisor { } private isWorkerRosterApplyCurrent(worker: ResidentWorker): boolean { - // A closed connection stales its queued applies: rows marked "recovering" on socket close must - // not be rewritten by an apply the dead connection left behind. Reconnection resumes applies. + // A closed connection stales its queued applies; reconnection (pendingClient) resumes them. return ( this.workers.get(worker.descriptor.workerId) === worker && (worker.client ?? worker.pendingClient) !== undefined ); } - /** A partial apply may have deleted rows it never rewrote; one single-flight gap-fill pull repairs the ledger. */ 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. @@ -3883,11 +3831,8 @@ export class DaemonSupervisor { edgesFailed = true; return [] as RlmLedgerEdge[]; }); - // A stop during the pre-read unregisters the worker and flips its rows inactive; applying now would resurrect them. if (!this.isWorkerRosterApplyCurrent(worker)) return; - // A live worker's snapshot carries its passivated rows too; absence means removal, disk backs the rest. - // Without readable edges the absentee sweep cannot tell registry children from stale rows, so the - // destructive half is skipped: rows survive and the single-flight repair pull refreshes them. + // 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) { @@ -3908,10 +3853,8 @@ export class DaemonSupervisor { this.scheduleRosterRepairPull(worker); return; } - // Deleted absentees with surviving transcripts reseed from the pre-read edges, tombstone-filtered. - // A reseed keeps its previous claim AND its hydrated summary: passive registry children list and - // attach through their live owner, snapshots (which never compose them) must not flap that claim - // off, and a synthetic seed would drop lastActivityAt and pin canEvictWorker on NaN. + // 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. for (const edge of edges) { const entry = this.rosterEntryForSpawnLedgerEdge(edge); if (this.roster().has(entry.agentId)) continue; @@ -3926,7 +3869,6 @@ export class DaemonSupervisor { } } - /** Root roster deltas maintain the persisted descriptor pointers (rootSessionId, sessionFile). */ private syncRootDescriptorFromRosterEntry(worker: ResidentWorker, entry: WorkerRosterEntry): void { const summary = entry.summary; if (summary.activeSessionId !== worker.descriptor.rootActiveSessionId) return; @@ -3946,12 +3888,7 @@ export class DaemonSupervisor { this.persistWorker(worker); } - /** - * Pulled rows write through to the roster: gaps fill, workerless and seeded rows claim, and this - * worker's own rows take the pull's fields. Every call sits behind the pull-epoch guard, so no - * frame has landed since the pull started and the pull is never staler than the row it replaces. - * Rows claimed by another worker are never stolen. - */ + // 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); @@ -3969,14 +3906,10 @@ export class DaemonSupervisor { } } - /** A stopped or evicted worker leaves inactive rows behind, never gaps. */ private flipWorkerRosterEntriesInactive(worker: ResidentWorker): void { - // Client-owned workers are ephemeral (normal completion removes them without archiving) and - // their rows are private to the owner; passivating would strip the workerId and turn them into - // public inactive rows. The public disk scan still lists whatever files actually persist. + // 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)) { - // A terminal unbound child run owns no transcript: it is a removal, never a passivated row. if (ephemeral || entry.queuedChild) { this.roster().delete(entry.agentId); continue; @@ -4200,7 +4133,6 @@ export class DaemonSupervisor { ): Promise { let matches = this.matchWorkers(selector, includeWorker); if (matches.length === 0) { - // Miss path only: one bounded pull closes the just-bound-but-unflushed routing window. await Promise.all( [...this.workers.values()].map((worker) => this.refreshWorkerSummaries(worker, false, true).catch(() => undefined), @@ -4238,7 +4170,6 @@ export class DaemonSupervisor { const exact: WorkerMatch[] = []; const suffix: WorkerMatch[] = []; for (const entry of this.roster().values()) { - // A queued child has no session to route a command to; its name must not shadow or collide. if (entry.queuedChild) continue; const worker = entry.workerId !== undefined ? this.workers.get(entry.workerId) : undefined; if (!worker || (includeWorker && !includeWorker(worker))) { @@ -4261,7 +4192,6 @@ export class DaemonSupervisor { private findSummaryInWorker(worker: ResidentWorker, selector: string): SessionSummary | undefined { const pathSelector = looksLikeSessionPath(selector) ? canonicalSessionPath(selector) : undefined; - // A queued child has no session to route a command to; its name must not shadow or collide. const summaries = this.workerRosterEntries(worker) .filter((entry) => !entry.queuedChild) .map(sessionSummaryFromRosterEntry); @@ -4285,7 +4215,6 @@ export class DaemonSupervisor { }); } - /** The one owner resolution by session file: claimed roster rows, pulled summaries, then descriptor paths. */ private findWorkerBySessionFile(sessionFile: string): ResidentWorker | undefined { const target = canonicalSessionPath(sessionFile); const targetEntry = this.roster().bySessionFile(target); @@ -4829,7 +4758,6 @@ export class DaemonSupervisor { if (frame.header.kind !== "outbound") { return; } - // Exactly the current client and the in-flight replacement are trusted sources. if (source !== undefined && source !== worker.client && source !== worker.pendingClient) { return; } diff --git a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts index 7972780279..c4a92f9d5f 100644 --- a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts +++ b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts @@ -842,14 +842,9 @@ export class RlmSpawnLedger { } } -/** - * One delete-classification policy for user deletes of saved sessions, shared - * by the worker and supervisor delete routes. In-memory state classifies - * first; only a readable no-parent transcript is positively top-level. - * Children and unknown targets classify via the ledger: an unreadable ledger - * aborts, and the tombstone appends before the file delete — a - * tombstoned-but-undeleted file is the accepted orphan of a failed delete. - */ +// 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, diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index 79d5213a0e..60c97a5b80 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -1,12 +1,10 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { connect, createServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { PassThrough } from "node:stream"; 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, DaemonSocketClient } from "../src/modes/daemon/active-session-state.js"; +import type { ActiveSessionState } from "../src/modes/daemon/active-session-state.js"; import { type AgentRosterEntry, type WorkerRosterEntry, @@ -15,12 +13,8 @@ import { 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, - isDaemonWorkerFrameHeader, -} from "../src/modes/daemon/daemon-worker-protocol.js"; +import type { DaemonWorkerRosterOutbound } from "../src/modes/daemon/daemon-worker-protocol.js"; import { RlmSpawnLedger } from "../src/modes/daemon/rlm-ledger.js"; -import { PrivateFrameDecoder } from "../src/modes/session-worker/private-framing.js"; type RosterDelta = Extract; @@ -216,33 +210,26 @@ describe("worker roster reporter", () => { daemon.flushRoster(); expect(sentDeltas.at(-1)?.snapshot).toBe(true); expect(sentDeltas.at(-1)?.entries.some((entry) => entry.agentId === "child-2")).toBe(false); - }); - it("lets a composed session row beat a lingering queued marker during the bind window", () => { - const { daemon, sentDeltas } = makeWorkerReporter(); - const parent = makeState({ activeSessionId: "parent-active" }); - daemon.sessions.set(parent.activeSessionId, parent); + // Bind window: the child session registers before any rlm_child_update reports the bind. daemon.observeRosterEvent( parent, - childUpdate(parent, { id: "child-1", label: "task", status: "queued", sessionDir: "/tmp/c" }), + childUpdate(parent, { id: "child-3", label: "task", status: "queued", sessionDir: "/tmp/c" }), ); daemon.flushRoster(); - - // The child session registers before any rlm_child_update reports the bind. - const childState = makeState({ - activeSessionId: "child-active", + const boundState = makeState({ + activeSessionId: "child-3-active", kind: "subagent", - rlmChildId: "child-1", + rlmChildId: "child-3", parentActiveSessionId: "parent-active", messages: [{ role: "user", content: "hi" } as unknown as AgentMessage], }); - daemon.sessions.set(childState.activeSessionId, childState); + daemon.sessions.set(boundState.activeSessionId, boundState); daemon.flushRoster(); - - const row = sentDeltas.at(-1)?.entries.find((entry) => entry.agentId === "child-1"); - expect(row?.queuedChild).toBeUndefined(); - expect(row?.summary.activeSessionId).toBe("child-active"); - expect(daemon.rosterReporter.queuedChildren.size).toBe(0); + const bound = sentDeltas.at(-1)?.entries.find((entry) => entry.agentId === "child-3"); + expect(bound?.queuedChild).toBeUndefined(); + expect(bound?.summary.activeSessionId).toBe("child-3-active"); + expect(daemon.rosterReporter.queuedChildren.has("child-3")).toBe(false); }); it("cancels pending removals for reincarnated ids but keeps the removed incarnation suppressed", () => { @@ -285,7 +272,7 @@ describe("worker roster reporter", () => { expect(daemon.rosterReporter.lastComposed.has("child-2")).toBe(false); }); - it("flushes hasRegisteredCronJob on cron_add and cron_cancel without any session event", async () => { + 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"), { @@ -332,30 +319,22 @@ describe("worker roster reporter", () => { }); await new Promise((resolveSettle) => setImmediate(resolveSettle)); expect(internals.rosterReporter.lastComposed.get(agentId)?.summary.hasRegisteredCronJob).toBeUndefined(); - }); - it("schedules a republish for retry and tool-execution transitions", () => { - const { daemon } = makeWorkerReporter(); - const state = makeState({ activeSessionId: "root-active" }); - daemon.sessions.set(state.activeSessionId, state); - const reporter = daemon as unknown as { rosterFlushScheduled: boolean }; - for (const type of ["auto_retry_start", "auto_retry_end", "tool_execution_start", "tool_execution_end"]) { - reporter.rosterFlushScheduled = false; - daemon.observeRosterEvent(state, { - type: "session_event", - activeSessionId: state.activeSessionId, - event: { type }, - }); - expect(reporter.rosterFlushScheduled, type).toBe(true); - } - // Events with no roster-visible field stay out of the trigger set. - reporter.rosterFlushScheduled = false; - daemon.observeRosterEvent(state, { - type: "session_event", - activeSessionId: state.activeSessionId, - event: { type: "message_start" }, + // 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", }); - expect(reporter.rosterFlushScheduled).toBe(false); + await new Promise((resolveSettle) => setImmediate(resolveSettle)); + expect(internals.rosterReporter.lastComposed.get(agentId)?.summary.model).toMatchObject({ id: "m2" }); }); it("sends deltas only on change and flips closed sessions to non-resident instead of dropping them", () => { @@ -370,6 +349,20 @@ describe("worker roster reporter", () => { daemon.flushRoster(); expect(sentDeltas).toHaveLength(1); + const reporter = daemon as unknown as { rosterFlushScheduled: boolean }; + for (const [type, scheduled] of [ + ["tool_execution_start", true], + ["message_start", false], + ] as const) { + reporter.rosterFlushScheduled = false; + daemon.observeRosterEvent(state, { + type: "session_event", + activeSessionId: state.activeSessionId, + event: { type }, + }); + expect(reporter.rosterFlushScheduled, type).toBe(scheduled); + } + daemon.sessions.delete(state.activeSessionId); daemon.flushRoster(); const flipped = sentDeltas.at(-1)?.entries[0]; @@ -377,120 +370,6 @@ describe("worker roster reporter", () => { expect(flipped?.summary.activeSessionId).toBeUndefined(); expect(sentDeltas.at(-1)?.removedAgentIds).toBeUndefined(); }); - - it("escalates undelivered changes to one replacing snapshot", () => { - const { daemon, sentDeltas, connection } = makeWorkerReporter(); - const parent = makeState({ activeSessionId: "parent-active" }); - daemon.sessions.set(parent.activeSessionId, parent); - daemon.observeRosterEvent( - parent, - childUpdate(parent, { id: "child-1", label: "task", status: "queued", sessionDir: "/tmp/c" }), - ); - daemon.flushRoster(); - const sentWhileConnected = sentDeltas.length; - - // The channel drops; the child binds and dies while disconnected. - connection.connected = false; - 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: "task", status: "running", activeSessionId: "child-active" }), - ); - daemon.flushRoster(); - expect(sentDeltas.length).toBe(sentWhileConnected); - expect(daemon.rosterReporter.snapshotPending).toBe(true); - daemon.sessions.delete(childState.activeSessionId); - daemon.flushRoster(); - - // Reauthentication: one full replacing snapshot carries the durable row; absence conveys removals. - connection.connected = true; - daemon.flushRoster(); - expect(sentDeltas.length).toBe(sentWhileConnected + 1); - const snapshot = sentDeltas.at(-1); - expect(snapshot?.snapshot).toBe(true); - expect(snapshot?.removedAgentIds).toBeUndefined(); - const childRow = snapshot?.entries.find((entry) => entry.agentId === "child-1"); - expect(childRow?.queuedChild).toBeUndefined(); - expect(childRow?.summary.id).toBe("session-child-active"); - expect(childRow?.summary.activeSessionId).toBeUndefined(); - - daemon.flushRoster(); - expect(sentDeltas.length).toBe(sentWhileConnected + 1); - }); - - it("treats queued writes as delivered and snapshots only across loss gaps", () => { - const written: Buffer[] = []; - const write = vi.fn((chunk: Buffer) => { - written.push(Buffer.from(chunk)); - // Backpressure: the frame is queued in the socket, not refused. - return false; - }); - const oldWrite = vi.fn(() => true); - const oldClient = { - transport: "private-framed", - authenticated: true, - backpressured: undefined as boolean | undefined, - socket: { destroyed: false, write: oldWrite }, - }; - const client = { - transport: "private-framed", - authenticated: true, - backpressured: undefined as boolean | undefined, - socket: { destroyed: false, write }, - }; - const daemon = Object.assign(Object.create(AgentDaemon.prototype), { - options: { worker: { authenticationToken: "token" } }, - sessions: new Map(), - cronStore: { list: () => [] }, - clients: new Set([oldClient, client]), - supervisorClaims: new Map([[client, {}]]), - rosterReporter: { - lastComposed: new Map(), - lastComposedJson: new Map(), - queuedChildren: new Map(), - removedAgentIds: new Map([["deleted-agent", undefined]]), - snapshotPending: false, - }, - rosterFlushScheduled: false, - shuttingDown: false, - log: vi.fn(), - }) as { - flushRoster(): void; - rosterReporter: { snapshotPending: boolean; removedAgentIds: Map }; - }; - - daemon.flushRoster(); - // The queued write IS delivered: nothing stays pending and only the claimed socket receives the write. - expect(write).toHaveBeenCalledTimes(1); - expect(oldWrite).not.toHaveBeenCalled(); - expect(daemon.rosterReporter.snapshotPending).toBe(false); - expect(daemon.rosterReporter.removedAgentIds.size).toBe(0); - - // A destroyed claim socket is an actual loss gap: the change marks one pending snapshot. - client.socket.destroyed = true; - daemon.rosterReporter.removedAgentIds.set("lost-agent", undefined); - daemon.flushRoster(); - expect(write).toHaveBeenCalledTimes(1); - expect(daemon.rosterReporter.snapshotPending).toBe(true); - - // The gap closes with one replacing snapshot; drains never resend queued frames. - client.socket.destroyed = false; - daemon.flushRoster(); - daemon.flushRoster(); - expect(write).toHaveBeenCalledTimes(2); - const decoder = new PrivateFrameDecoder(isDaemonWorkerFrameHeader); - const frames = decoder.push(Buffer.concat(written)); - const messages = frames.map((frame) => JSON.parse(frame.payload.toString("utf8")) as RosterDelta); - expect(messages[1]?.snapshot).toBe(true); - expect(messages[1]?.removedAgentIds).toEqual(["lost-agent"]); - }); }); // --- Supervisor-side roster ledger --- @@ -616,58 +495,6 @@ function rosterDelta(entries: WorkerRosterEntry[], removedAgentIds?: string[], s } describe("supervisor roster ledger", () => { - it("keeps queued child rows ledger-internal and lists them once their session materializes", async () => { - const worker = makeWorker("worker-1"); - const supervisor = makeSupervisor([worker]); - - supervisor.consumeWorkerRosterDelta( - worker, - rosterDelta([ - { - agentId: "child-1", - queuedChild: true, - summary: summary({ - id: "child-1", - sessionId: "child-1", - runtimeKind: "subagent", - rlmChildId: "child-1", - }), - }, - ]), - ); - - expect((await supervisor.handleList({}, { type: "list" })).data?.sessions).toEqual([]); - expect((await supervisor.handleList({}, { type: "list", all: true })).data?.sessions).toEqual([]); - expect(supervisor.workerRosterEntries(worker)[0]).toMatchObject({ status: "running", statusLabel: "queued" }); - - supervisor.consumeWorkerRosterDelta( - worker, - rosterDelta([ - { - agentId: "child-1", - summary: summary({ - id: "child-active", - sessionId: "child-session", - activeSessionId: "child-active", - runtimeKind: "subagent", - rlmChildId: "child-1", - isSessionActive: true, - }), - }, - ]), - ); - - const listed = await supervisor.handleList({}, { type: "list" }); - expect(listed.data?.sessions).toHaveLength(1); - expect(listed.data?.sessions[0]).toMatchObject({ activeSessionId: "child-active", workerState: "ready" }); - expect(supervisor.workerRosterEntries(worker)[0]).toMatchObject({ status: "running" }); - expect(supervisor.workerRosterEntries(worker)[0]?.statusLabel).toBeUndefined(); - - // A published removal drops the row from the ledger. - supervisor.consumeWorkerRosterDelta(worker, rosterDelta([], ["child-1"])); - expect(supervisor.roster().has("child-1")).toBe(false); - }); - it("serves list from the ledger with zero worker round-trips and exact busy counts", async () => { const visible = makeWorker("visible"); const owned = makeWorker("owned", { @@ -702,6 +529,33 @@ describe("supervisor roster ledger", () => { 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 () => { @@ -793,22 +647,6 @@ describe("supervisor roster ledger", () => { expect(entries.some((entry) => entry.summary.rlmChildId === "deleted-child")).toBe(false); }); - it("marks a dead worker's rows recovering natively on socket close", async () => { - const worker = makeWorker("worker-1"); - const supervisor = makeSupervisor([worker]); - supervisor.writeRosterEntry( - workerRosterEntryFromSummary(summary({ id: "r-active", sessionId: "r", activeSessionId: "r-active" })), - worker, - ); - - const client = worker.client as object; - await supervisor.handleWorkerClose(worker, client, new Error("worker died")); - - const entry = supervisor.workerRosterEntries(worker)[0]; - expect(entry).toMatchObject({ statusLabel: "recovering" }); - expect(entry?.summary.activeSessionId).toBe("r-active"); - }); - 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" }); @@ -847,157 +685,34 @@ describe("supervisor roster ledger", () => { { type: "list", all: true }, ); expect(listed.data?.sessions).toEqual([]); - }); - - it("removes queued rows on worker unregistration instead of passivating unlistable ghosts", () => { - 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("never clobbers a row rebound while its seeded header read was in flight", async () => { - const directory = mkdtempSync(join(tmpdir(), "prime-roster-hydrate-race-")); - tempDirs.push(directory); - const manager = SessionManager.create(directory, join(directory, "artifacts")); - manager.appendMessage({ role: "user", content: "child fixture", timestamp: 1 }); - manager.flushNow(); - const sessionFile = manager.getSessionFile(); - if (!sessionFile) throw new Error("Fixture session did not persist"); - const supervisor = makeSupervisor([]); - const stale = supervisor.writeRosterEntry({ - agentId: "raced", - seededCwd: true, - summary: summary({ id: "raced", sessionId: "raced", sessionFile, cwd: join(directory, "artifacts") }), - }); - // A frame rebinds the agentId while the header read would be in flight. - const live = supervisor.writeRosterEntry( - workerRosterEntryFromSummary( - summary({ id: "raced", sessionId: "raced", activeSessionId: "raced-active", sessionFile }), - ), - ); - - const internals = supervisor as unknown as { - hydrateSeededEntry(entry: AgentRosterEntry): Promise; - }; - const hydrated = await internals.hydrateSeededEntry(stale); - - expect(hydrated).toBe(live); - expect(supervisor.roster().get("raced")).toBe(live); - expect(supervisor.roster().get("raced")?.summary.activeSessionId).toBe("raced-active"); - }); - - it("lists a client-owned worker's file as a plain inactive row for other clients", async () => { - 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 () => [ + // 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([ { - id: "owned-session", - path: ownedPath, - cwd: "/tmp/project", - created: new Date(0), - modified: new Date(0), - messageCount: 2, - firstMessage: "private work", - allMessagesText: "", + agentId: "queued-child", + queuedChild: true, + summary: summary({ id: "queued-child", sessionId: "queued-child", runtimeKind: "subagent" }), }, ]), - }, - }); - supervisor.writeRosterEntry( - workerRosterEntryFromSummary( - summary({ - id: "owned-active", - sessionId: "owned-session", - activeSessionId: "owned-active", - sessionFile: ownedPath, - isSessionActive: true, - }), - ), - owned, - ); + ); + expect(supervisor.roster().has("queued-child")).toBe(true); - const listed = await supervisor.handleList( - { id: "intruder", attachedActiveSessionIds: new Set() }, - { type: "list", all: true }, - ); + supervisor.flipWorkerRosterEntriesInactive(worker); - // 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("keeps claimed passive children in the non-all list across snapshots that omit them", async () => { - const directory = mkdtempSync(join(tmpdir(), "prime-roster-claim-stability-")); - tempDirs.push(directory); - const sessionsDir = join(directory, "sessions"); - const ledger = new RlmSpawnLedger(directory, sessionsDir); - const parentPath = join(sessionsDir, "root.jsonl"); - const childPath = join(directory, "artifacts", "p.jsonl"); - await ledger.appendSpawn({ childId: "p", parent: parentPath, child: childPath, depth: 1, name: "p" }); - const worker = makeWorker("worker-1"); - Object.assign(worker.descriptor, { createCommand: { type: "create" } }); - const supervisor = makeSupervisor([worker], { rlmSpawnLedger: () => ledger }); - const root = summary({ id: "worker-1-root-active", sessionId: "root", activeSessionId: "worker-1-root-active" }); - const passive = summary({ - id: "p-session", - sessionId: "p-session", - sessionFile: childPath, - runtimeKind: "subagent", - rlmChildId: "p", - parentSessionPath: parentPath, - messageCount: 4, - lastActivityAt: "2026-08-01T10:00:00.000Z", - }); - worker.summaries.set("worker-1-root-active", root); - worker.summaries.set("p-session", passive); - ( - supervisor as unknown as { syncRosterFromWorkerSummaries(worker: WorkerFixture): void } - ).syncRosterFromWorkerSummaries(worker); - - // A snapshot composes only live sessions; the passive registry child must not flap off the worker. - supervisor.consumeWorkerRosterDelta(worker, rosterDelta([workerRosterEntryFromSummary(root)], undefined, true)); - await new Promise((resolveSettle) => setImmediate(resolveSettle)); - - const passiveRow = [...supervisor.roster().values()].find((entry) => entry.summary.rlmChildId === "p"); - expect(passiveRow?.workerId).toBe("worker-1"); - // The reseed keeps the hydrated summary: a synthetic seed would drop lastActivityAt (NaN pins - // canEvictWorker false forever) and degrade list output to messageCount 0 with a synthetic cwd. - expect(passiveRow?.summary.lastActivityAt).toBe("2026-08-01T10:00:00.000Z"); - expect(passiveRow?.summary.messageCount).toBe(4); - expect(passiveRow?.summary.cwd).toBe("/tmp/project"); - expect(passiveRow?.seededCwd).toBeUndefined(); - const listed = await supervisor.handleList({}, { type: "list" }); - expect(listed.data?.sessions.map((session) => session.sessionId).sort()).toEqual([ - passiveRow?.summary.sessionId, - "root", - ]); + // 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 () => { @@ -1105,84 +820,6 @@ describe("supervisor roster ledger", () => { expect(afterEvict.data?.sessions.some((session) => session.sessionId === "deleted-child")).toBe(false); }); - it("scopes list all by sessions dir through owning topology, not the shared artifacts tree", async () => { - const supervisor = makeSupervisor([]); - const base = "/tmp/agent-homes"; - const dirA = join(base, "a", "sessions"); - const dirB = join(base, "b", "sessions"); - const rootA = join(dirA, "root-a.jsonl"); - const rootB = join(dirB, "root-b.jsonl"); - supervisor.writeRosterEntry( - workerRosterEntryFromSummary(summary({ id: "root-a", sessionId: "root-a", sessionFile: rootA })), - ); - supervisor.writeRosterEntry( - workerRosterEntryFromSummary(summary({ id: "root-b", sessionId: "root-b", sessionFile: rootB })), - ); - supervisor.writeRosterEntry( - workerRosterEntryFromSummary( - summary({ - id: "child-a", - sessionId: "child-a", - sessionFile: join(base, "a", "session-artifacts", "root-a", "child-a.jsonl"), - parentSessionPath: rootA, - runtimeKind: "subagent", - rlmChildId: "child-a", - rlmDepth: 1, - }), - ), - ); - supervisor.writeRosterEntry( - workerRosterEntryFromSummary( - summary({ - id: "child-b", - sessionId: "child-b", - sessionFile: join(base, "b", "session-artifacts", "root-b", "child-b.jsonl"), - parentSessionPath: rootB, - runtimeKind: "subagent", - rlmChildId: "child-b", - rlmDepth: 1, - }), - ), - ); - - const listDir = async (sessionDir: string) => - (await supervisor.handleList({}, { type: "list", all: true, sessionDir })).data?.sessions - .map((session) => session.sessionId) - .sort(); - - expect(await listDir(dirA)).toEqual(["child-a", "root-a"]); - expect(await listDir(dirB)).toEqual(["child-b", "root-b"]); - }); - - it("updates the roster on offline saved-session renames", async () => { - 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"); - }); - 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 () => []) }, @@ -1216,6 +853,55 @@ describe("supervisor roster ledger", () => { }); 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); + } }); }); @@ -1226,14 +912,6 @@ describe("saved-session delete paths", () => { reachableRoster.client = { request: vi.fn(async () => ({ type: "response", command: "delete_saved_session", success: true })), }; - const reachableDescriptor = makeWorker("w-desc"); - Object.assign(reachableDescriptor.descriptor, { - sessionFile: "/tmp/owned-desc.jsonl", - createCommand: { type: "create" }, - }); - reachableDescriptor.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", @@ -1255,7 +933,7 @@ describe("saved-session delete paths", () => { return true; }, ); - const supervisor = makeSupervisor([reachableRoster, reachableDescriptor, unreachable, failed], { + const supervisor = makeSupervisor([reachableRoster, unreachable, failed], { catalog: { delete: catalogDelete, list: vi.fn(async () => []) }, mutationDrain: { begin: vi.fn(), end: vi.fn() }, reclaimStaleWorkerRegistration, @@ -1282,8 +960,6 @@ describe("saved-session delete paths", () => { expect.objectContaining({ type: "delete_saved_session", sessionPath: "/tmp/owned-roster.jsonl" }), expect.any(Number), ); - await internals.handleCommand(client, { type: "delete_saved_session", sessionPath: "/tmp/owned-desc.jsonl" }); - expect(reachableDescriptor.client.request).toHaveBeenCalled(); expect(catalogDelete).not.toHaveBeenCalled(); // A live-but-disconnected owner rejects instead of deleting underneath the worker. @@ -1334,20 +1010,6 @@ describe("saved-session delete paths", () => { expect(owned.client.request).toHaveBeenCalled(); }); - it("keeps the roster row when a delete fails on disk", async () => { - 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); - }); - 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-", { @@ -1440,190 +1102,53 @@ describe("review-round regressions", () => { 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(); - }); - - it("trusts frames only from the current and in-flight replacement connections", () => { - const worker = makeWorker("worker-1"); - const supervisor = makeSupervisor([worker], { - streamReconstructor: { observe: vi.fn(), seed: vi.fn(), clear: vi.fn() }, - }); - const frame = (sessionId: string) => ({ - header: { kind: "outbound", outboundType: "roster_delta" }, - payload: rosterDelta([workerRosterEntryFromSummary(summary({ id: sessionId, sessionId }))]), - }); - const internals = supervisor as unknown as { - handleWorkerFrame(w: object, f: object, source?: object): void; - }; - const stale = { request: vi.fn() }; - const replacement = { request: vi.fn() }; - - internals.handleWorkerFrame(worker, frame("ghost"), stale); - expect(supervisor.roster().has("ghost")).toBe(false); - - (worker as unknown as { pendingClient?: object }).pendingClient = replacement; - internals.handleWorkerFrame(worker, frame("mid-auth"), replacement); - expect(supervisor.roster().has("mid-auth")).toBe(true); - - // Failed auth rolls the pending source back; its buffered frames are dropped. - (worker as unknown as { pendingClient?: object }).pendingClient = undefined; - internals.handleWorkerFrame(worker, frame("rolled-back"), replacement); - expect(supervisor.roster().has("rolled-back")).toBe(false); - }); - - it("resolves seeded artifact children by their persisted session id before any delta", async () => { - const directory = mkdtempSync(join(tmpdir(), "prime-roster-seed-id-")); - tempDirs.push(directory); - const sessionsDir = join(directory, "sessions"); - const ledger = new RlmSpawnLedger(directory, sessionsDir); - const persistedId = "0a1b2c3d4e5f0a1b2c3d4e5f"; - const childPath = join(directory, "artifacts", `${persistedId}.jsonl`); - await ledger.appendSpawn({ - childId: "sub-abc", - parent: join(sessionsDir, "root.jsonl"), - child: childPath, - depth: 1, - name: "child", - }); - const worker = makeWorker("worker-1"); - const supervisor = makeSupervisor([worker], { - rlmSpawnLedger: () => ledger, - catalog: { list: vi.fn(async () => []) }, - }); - await supervisor.seedRosterLedger(); - // Claim the seeded row for a worker so selector matching can route to it. - const seeded = [...supervisor.roster().values()][0]; - if (!seeded) throw new Error("Missing seeded row"); - supervisor.writeRosterEntry(seeded, worker); - const internals = supervisor as unknown as { - findWorker(selector: string): Promise<{ summary: SessionSummary }>; - }; - - const match = await internals.findWorker(persistedId); - expect(match.summary.rlmChildId).toBe("sub-abc"); - }); - it("publishes model and name changes to the supervisor", async () => { - const directory = mkdtempSync(join(tmpdir(), "prime-roster-model-")); - tempDirs.push(directory); - const daemon = new AgentDaemon(join(directory, "worker.sock"), { - defaultSessionConfig: { agentDir: directory, cwd: directory }, - worker: { - authenticationToken: "token", - workerId: "worker-1", - rootActiveSessionId: "root-active", - } as never, - createRuntime: async () => { - throw new Error("unexpected runtime creation"); - }, - } as never); - const socket = new PassThrough(); - const written: Buffer[] = []; - socket.on("data", (chunk: Buffer) => written.push(Buffer.from(chunk))); - const supervisorClient = { - id: "supervisor", - socket, - transport: "private-framed", - authenticated: true, - attachedActiveSessionIds: new Set(), - detachInput: () => {}, - supportsExtensionUi: false, - capabilities: new Set(), - } as unknown as DaemonSocketClient; - const internals = daemon as unknown as { - clients: Set; - supervisorClaims: Map; - sessions: Map; - handleCommand(client: DaemonSocketClient, command: object): Promise; - }; - internals.clients.add(supervisorClient); - internals.supervisorClaims.set(supervisorClient, {}); - const state = makeState({ - activeSessionId: "root-active", - messages: [{ role: "user", content: "hi" } as unknown as AgentMessage], - }); - const session = state.runtime.session as unknown as Record; - session.model = { provider: "prov", id: "m1" }; - session.modelRegistry = { - refreshAvailableModels: async () => [{ provider: "prov", id: "m2" }], - }; - session.setModel = async (model: unknown) => { - session.model = model; - }; - internals.sessions.set(state.activeSessionId, state); - - const decodeDeltas = () => - new PrivateFrameDecoder(isDaemonWorkerFrameHeader) - .push(Buffer.concat(written)) - .filter((frame) => frame.header.kind === "outbound" && frame.header.outboundType === "roster_delta") - .map((frame) => JSON.parse(frame.payload.toString("utf8")) as RosterDelta); - - await internals.handleCommand(supervisorClient, { - type: "set_model", - activeSessionId: "root-active", - provider: "prov", - modelId: "m2", - }); - await vi.waitFor(() => { - const rows = decodeDeltas().flatMap((delta) => delta.entries); - expect(rows.at(-1)?.summary.model).toMatchObject({ id: "m2" }); - }); - - // Rename: the handler updates the session and the runtime's info event triggers the flush. - session.setSessionName = (name: string) => { - session.sessionName = name; - }; - await internals.handleCommand(supervisorClient, { - type: "rename", - activeSessionId: "root-active", - name: "renamed-by-worker", - }); - ( - daemon as unknown as { observeRosterEvent(state: ActiveSessionState, message: unknown): void } - ).observeRosterEvent(state, { - type: "session_event", - activeSessionId: "root-active", - event: { type: "session_info_changed", name: "renamed-by-worker" }, - }); - await vi.waitFor(() => { - const rows = decodeDeltas().flatMap((delta) => delta.entries); - expect(rows.at(-1)?.summary.sessionName).toBe("renamed-by-worker"); - }); - }); - - it("keeps frame-updated root pointers when a stale summaries pull lands", async () => { - const worker = makeWorker("worker-1"); - Object.assign(worker.descriptor, { createCommand: { type: "create" } }); - let releaseList: (response: unknown) => void = () => {}; - worker.client = { - request: vi.fn( - () => - new Promise((resolveList) => { - releaseList = resolveList; + // 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, }), - ), - }; - const supervisor = makeSupervisor([worker], { - refreshWorkerSummaries: DaemonSupervisor.prototype["refreshWorkerSummaries" as never], - streamReconstructor: { seed: vi.fn(), clear: vi.fn() }, - }); - const staleRoot = summary({ - id: "worker-1-root-active", - sessionId: "root", - activeSessionId: "worker-1-root-active", - sessionFile: "/tmp/sessions/old.jsonl", - }); - const freshRoot = { ...staleRoot, sessionFile: "/tmp/sessions/new.jsonl" }; + ), + owned, + ); - const refresh = ( - supervisor as unknown as { refreshWorkerSummaries(worker: WorkerFixture, recovery: boolean): Promise } - ).refreshWorkerSummaries(worker, false); - // A frame updates the root pointers while the pull is still in flight. - supervisor.consumeWorkerRosterDelta(worker, rosterDelta([workerRosterEntryFromSummary(freshRoot)])); - expect(worker.descriptor.sessionFile).toBe("/tmp/sessions/new.jsonl"); - releaseList({ type: "response", command: "list", success: true, data: { sessions: [staleRoot] } }); - await refresh; - - expect(worker.descriptor.sessionFile).toBe("/tmp/sessions/new.jsonl"); + 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 () => { @@ -1636,6 +1161,8 @@ describe("review-round regressions", () => { runtimeKind: "subagent", rlmChildId: "x", parentSessionPath: "/tmp/sessions/root.jsonl", + messageCount: 4, + lastActivityAt: "2026-08-01T10:00:00.000Z", }); const childEntry = workerRosterEntryFromSummary(child); let releaseEdges: (edges: unknown[]) => void = () => {}; @@ -1674,25 +1201,13 @@ describe("review-round regressions", () => { const restored = supervisor.roster().get(childEntry.agentId); expect(restored?.workerId).toBe("worker-1"); - // The queued fill also replaced the synthetic ledger seed with the pulled summary. + // 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("drops unchained deltas from an unregistered worker registration", () => { - 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 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" } }); @@ -1781,6 +1296,21 @@ describe("review-round regressions", () => { 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("repairs failed roster applies with one single-flight pull that never respawns itself", async () => { @@ -1884,142 +1414,6 @@ describe("review-round regressions", () => { expect(pulls).toBe(2); expect(supervisor.roster().has(staleEntry.agentId)).toBe(false); }); - - it("delivers one queued snapshot through a real backpressured worker socket", async () => { - const directory = mkdtempSync(join(tmpdir(), "prime-roster-socket-")); - tempDirs.push(directory); - const socketPath = join(directory, "worker.sock"); - const received: Buffer[] = []; - let connected: (socket: import("node:net").Socket) => void = () => {}; - const connection = new Promise((resolveSocket) => { - connected = resolveSocket; - }); - const server = createServer((socket) => { - socket.on("data", (chunk: Buffer) => received.push(Buffer.from(chunk))); - connected(socket); - }); - await new Promise((resolveListen) => server.listen(socketPath, resolveListen)); - const clientSocket = connect(socketPath); - await new Promise((resolveConnect) => clientSocket.once("connect", () => resolveConnect())); - await connection; - - const client = { transport: "private-framed", authenticated: true, socket: clientSocket }; - const reporter = { - lastComposed: new Map(), - lastComposedJson: new Map(), - queuedChildren: new Map(), - removedAgentIds: new Map(), - snapshotPending: true, - }; - for (let index = 0; index < 3000; index++) { - const entry: WorkerRosterEntry = { - agentId: `child-${index}`, - queuedChild: true, - summary: summary({ - id: `child-${index}`, - sessionId: `child-${index}`, - runtimeKind: "subagent", - rlmChildId: `child-${index}`, - firstMessage: "x".repeat(512), - }), - }; - reporter.queuedChildren.set(entry.agentId, entry); - } - const daemon = Object.assign(Object.create(AgentDaemon.prototype), { - options: { worker: { authenticationToken: "token" } }, - sessions: new Map(), - cronStore: { list: () => [] }, - clients: new Set([client]), - supervisorClaims: new Map([[client, {}]]), - rosterReporter: reporter, - rosterFlushScheduled: false, - shuttingDown: false, - log: vi.fn(), - }) as { flushRoster(): void }; - - daemon.flushRoster(); - daemon.flushRoster(); - await vi.waitFor(() => { - const frames = new PrivateFrameDecoder(isDaemonWorkerFrameHeader).push(Buffer.concat(received)); - expect(frames.length).toBeGreaterThan(0); - }); - // One multi-megabyte snapshot: queued past the high-water mark, delivered once, never resent. - const frames = new PrivateFrameDecoder(isDaemonWorkerFrameHeader).push(Buffer.concat(received)); - expect(frames).toHaveLength(1); - const worker = makeWorker("worker-1"); - const supervisor = makeSupervisor([worker], { rlmSpawnLedger: () => ({ edges: vi.fn(async () => []) }) }); - supervisor.consumeWorkerRosterDelta(worker, frames[0]?.payload as Buffer); - await vi.waitFor(() => expect(supervisor.workerRosterEntries(worker)).toHaveLength(3000)); - clientSocket.destroy(); - server.close(); - }); - - it("routes a just-bound session through the miss-path refresh", async () => { - const target = summary({ id: "target-active", sessionId: "target", activeSessionId: "target-active" }); - const worker = makeWorker("worker-1"); - worker.client = { - request: vi.fn(async () => ({ - type: "response", - command: "list", - success: true, - data: { sessions: [target] }, - })), - }; - const supervisor = makeSupervisor([worker], { - refreshWorkerSummaries: DaemonSupervisor.prototype["refreshWorkerSummaries" as never], - streamReconstructor: { seed: vi.fn(), clear: vi.fn() }, - }); - const internals = supervisor as unknown as { - findWorker(selector: string): Promise<{ summary: SessionSummary }>; - }; - - const match = await internals.findWorker("target-active"); - expect(match.summary.sessionId).toBe("target"); - }); - - it("publishes qualified removal ids from the rlm subagent deletion path", async () => { - const directory = mkdtempSync(join(tmpdir(), "prime-roster-rlm-delete-")); - tempDirs.push(directory); - const sessionsDir = join(directory, "sessions"); - const manager = SessionManager.create(directory, sessionsDir); - manager.appendMessage({ role: "user", content: "parent", timestamp: 1 }); - manager.flushNow(); - const parentFile = manager.getSessionFile(); - if (!parentFile) throw new Error("Fixture parent did not persist"); - const childDir = join(directory, "artifacts", "sub-1"); - mkdirSync(childDir, { recursive: true }); - const childFile = join(childDir, "child.jsonl"); - const daemon = new AgentDaemon(join(directory, "worker.sock"), { - defaultSessionConfig: { agentDir: directory, cwd: directory, sessionDir: sessionsDir }, - worker: { authenticationToken: "token" }, - createRuntime: async () => { - throw new Error("unexpected runtime creation"); - }, - } as never); - const internals = daemon as unknown as { - rlmSpawnLedger(): RlmSpawnLedger; - recordRlmSubagentDeletion(parentState: ActiveSessionState, childId: string): Promise; - rosterReporter: { removedAgentIds: Map }; - }; - await internals - .rlmSpawnLedger() - .appendSpawn({ childId: "sub-1", parent: parentFile, child: childFile, depth: 1, name: "child" }); - const parentState = makeState({ activeSessionId: "parent-active", sessionFile: parentFile }); - - await internals.recordRlmSubagentDeletion(parentState, "sub-1"); - - const expected = workerRosterEntryFromSummary( - summary({ - id: "sub-1", - sessionId: "sub-1", - runtimeKind: "subagent", - rlmChildId: "sub-1", - parentSessionPath: parentFile, - }), - ).agentId; - expect(internals.rosterReporter.removedAgentIds.has(expected)).toBe(true); - expect(internals.rosterReporter.removedAgentIds.has("sub-1")).toBe(false); - }); }); describe("worker delete tombstone durability", () => { @@ -2095,7 +1489,8 @@ describe("worker delete tombstone durability", () => { ); expect(existsSync(withEdge.garbled)).toBe(false); await expect(daemonWithEdge.rlmSpawnLedger().edges()).resolves.toEqual([]); - expect(daemonWithEdge.rosterReporter.removedAgentIds.size).toBe(1); + // 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(); From 1a6b9c1007da86fdd468a6c24ee7da53d4d35664 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 20:00:17 +0200 Subject: [PATCH 44/48] fix(coding-agent): roster identity and staleness fixes from the sixth review round - The offline delete's roster cleanup deletes only the row object it observed: a write during the tombstone/unlink awaits replaces the row, and deleting by agentId alone would kill the replacement. - Subagent roster ids fall back to the live parent id when the parent has no session path (--no-session parents never write ledger edges), so children of two such parents cannot collide on the per-parent 32-bit child id. - An archived top-level close (killed/completed/replaced; not shutdown/update) publishes a roster removal instead of leaving a passivated "live" ghost: the worker's list no longer carries the session and the disk scan serves the archived file honestly. Subagent rows keep passivating, mirroring the registry's completed children. - Roster applies are fenced by their own source connection: an apply parked on the spawn-ledger read by a dead connection can no longer resume during a reconnect's pre-auth window and clear the recovering labels, while the authenticating connection's own post-auth snapshot still applies immediately. --- .../src/modes/daemon/agent-roster.ts | 13 +- .../src/modes/daemon/daemon-mode.ts | 12 +- .../src/modes/daemon/daemon-supervisor.ts | 44 ++++--- .../test/daemon-agent-roster.test.ts | 117 +++++++++++++++--- 4 files changed, 149 insertions(+), 37 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/agent-roster.ts b/packages/coding-agent/src/modes/daemon/agent-roster.ts index 30c7b3419d..893f82ba7a 100644 --- a/packages/coding-agent/src/modes/daemon/agent-roster.ts +++ b/packages/coding-agent/src/modes/daemon/agent-roster.ts @@ -59,12 +59,17 @@ export interface AgentRosterEntry extends WorkerRosterEntry { // Child ids are only unique per parent (32-bit, mkdir-checked); the parent path qualifies them daemon-wide. export function rosterAgentIdForSummary( - summary: Pick, + summary: Pick< + SessionSummary, + "runtimeKind" | "rlmChildId" | "sessionId" | "parentSessionPath" | "parentActiveSessionId" + >, ): string { if (summary.runtimeKind === "subagent" && summary.rlmChildId) { - return summary.parentSessionPath - ? `${canonicalSessionPath(summary.parentSessionPath)}#${summary.rlmChildId}` - : 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; } diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 9aa974bd3c..5f04725342 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -6370,7 +6370,9 @@ export class AgentDaemon { state.clients.clear(); this.acpMcpOwners.delete(state.activeSessionId); this.sessions.delete(state.activeSessionId); - if (isEmptyDraftSession && this.options.worker) { + // An archived or discarded top-level session leaves the worker's list; only the disk scan serves + // it now. Subagent rows stay passivated to mirror the registry's completed children. + if (!keepsResumeEntry && state.runtime.metadata.kind !== "subagent" && this.options.worker) { this.rosterReporter.removedAgentIds.set(this.rosterAgentIdForState(state), state.runtime.session.sessionId); } this.scheduleRosterFlush(); @@ -6572,7 +6574,13 @@ export class AgentDaemon { const session = state.runtime.session; const metadata = state.runtime.metadata; if (metadata.kind === "subagent" && metadata.rlmChildId) { - return this.rosterAgentIdForRlmChild(metadata.rlmChildId, metadata.parentSessionFile); + return rosterAgentIdForSummary({ + runtimeKind: "subagent", + rlmChildId: metadata.rlmChildId, + sessionId: metadata.rlmChildId, + parentSessionPath: metadata.parentSessionFile, + parentActiveSessionId: metadata.parentActiveSessionId, + }); } return session.sessionId; } diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index bbd53317f0..3fa23dae41 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -2140,7 +2140,10 @@ export class DaemonSupervisor { } await tombstoneSavedSessionDelete(this.rlmSpawnLedger(), command.sessionPath, entry?.summary); const result = await this.catalog.delete(command.sessionPath); - if (result.ok && entry) this.roster().delete(entry.agentId); + // A write during the awaits replaces the row object; only the observed row may be deleted. + if (result.ok && entry && this.roster().get(entry.agentId) === entry) { + this.roster().delete(entry.agentId); + } return success(command.id, command.type, result); } break; @@ -3590,8 +3593,9 @@ export class DaemonSupervisor { if (!worker.client) { throw new Error("Session worker is not connected"); } + const pullSource = worker.client; const epochAtStart = worker.rosterEpoch ?? 0; - const response = await worker.client.request({ type: "list" }, 5000); + 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); @@ -3604,7 +3608,7 @@ export class DaemonSupervisor { } worker.summaries = nextSummaries; if (fillGaps) { - await this.chainWorkerRosterApply(worker, () => { + await this.chainWorkerRosterApply(worker, pullSource, () => { if ((worker.rosterEpoch ?? 0) === epochAtStart) this.syncRosterFromWorkerSummaries(worker); }); } @@ -3620,7 +3624,7 @@ export class DaemonSupervisor { if (recovery) { await this.assertRecoveryAllowed(); } - await this.chainWorkerRosterApply(worker, () => { + await this.chainWorkerRosterApply(worker, pullSource, () => { if ((worker.rosterEpoch ?? 0) !== epochAtStart) return; worker.descriptor.rootSessionId = root.sessionId; worker.descriptor.sessionFile = root.sessionFile; @@ -3747,7 +3751,7 @@ export class DaemonSupervisor { return { agentId: rosterAgentIdForSummary(summary), summary }; } - private consumeWorkerRosterDelta(worker: ResidentWorker, payload: Buffer): void { + private consumeWorkerRosterDelta(worker: ResidentWorker, payload: Buffer, source?: DaemonWorkerClient): void { let delta: Extract; try { delta = JSON.parse(payload.toString("utf8")) as Extract; @@ -3756,22 +3760,27 @@ export class DaemonSupervisor { } if (delta.type !== "roster_delta" || !Array.isArray(delta.entries)) return; worker.rosterEpoch = (worker.rosterEpoch ?? 0) + 1; - if (!this.isWorkerRosterApplyCurrent(worker)) return; + 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, () => + this.chainWorkerRosterApply(worker, applySource, () => delta.snapshot === true - ? this.applyWorkerRosterSnapshot(worker, delta) + ? this.applyWorkerRosterSnapshot(worker, delta, applySource) : this.applyWorkerRosterDelta(worker, delta), ); } - private chainWorkerRosterApply(worker: ResidentWorker, apply: () => void | Promise): Promise { + private chainWorkerRosterApply( + worker: ResidentWorker, + source: DaemonWorkerClient | undefined, + apply: () => void | Promise, + ): Promise { const chained = (worker.rosterApplyChain ?? Promise.resolve()) .then(() => { - if (!this.isWorkerRosterApplyCurrent(worker)) return; + if (!this.isWorkerRosterApplyCurrent(worker, source)) return; return apply(); }) .catch((error: unknown) => { @@ -3785,16 +3794,18 @@ export class DaemonSupervisor { return chained; } - private isWorkerRosterApplyCurrent(worker: ResidentWorker): boolean { - // A closed connection stales its queued applies; reconnection (pendingClient) resumes them. + // An apply is valid only while its own source connection is still the current or authenticating + // one: a dead connection's parked applies must not resume during (or after) a reconnect. + private isWorkerRosterApplyCurrent(worker: ResidentWorker, source: DaemonWorkerClient | undefined): boolean { return ( this.workers.get(worker.descriptor.workerId) === worker && - (worker.client ?? worker.pendingClient) !== undefined + source !== undefined && + (source === worker.client || source === worker.pendingClient) ); } private scheduleRosterRepairPull(worker: ResidentWorker): void { - if (worker.rosterRepairPull || !this.isWorkerRosterApplyCurrent(worker) || !worker.client) return; + 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) => @@ -3821,6 +3832,7 @@ export class DaemonSupervisor { 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; @@ -3831,7 +3843,7 @@ export class DaemonSupervisor { edgesFailed = true; return [] as RlmLedgerEdge[]; }); - if (!this.isWorkerRosterApplyCurrent(worker)) return; + 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(); @@ -4772,7 +4784,7 @@ export class DaemonSupervisor { snapshotPurpose, } = frame.header; if (outboundType === "roster_delta") { - this.consumeWorkerRosterDelta(worker, frame.payload); + this.consumeWorkerRosterDelta(worker, frame.payload, source); return; } if (outboundType === "roster_heartbeat") { diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index 60c97a5b80..f81e6bee2c 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -49,7 +49,9 @@ function makeWorkerReporter(connected = true): WorkerReporterFixture { const daemon = Object.assign(Object.create(AgentDaemon.prototype), { options: { worker: { authenticationToken: "token" } }, sessions: new Map(), - cronStore: { list: () => [] }, + cronStore: { list: () => [], cancelJobsForSession: () => [] }, + summarizer: { forget: () => {} }, + acpMcpOwners: new Map(), rosterReporter: { lastComposed: new Map(), lastComposedJson: new Map(), @@ -83,8 +85,10 @@ function makeState(options: { return { activeSessionId: options.activeSessionId, clients: new Set(), + extensionUiRequests: new Map(), lastEventSequence: 0, runtime: { + dispose: async () => {}, metadata: { kind: options.kind ?? "top-level", createdAt: 1, @@ -106,12 +110,14 @@ function makeState(options: { 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, @@ -141,12 +147,34 @@ describe("worker roster reporter", () => { childUpdate(parent, { id: "child-1", label: "review the API", status: "queued", sessionDir: "/tmp/c" }), ); daemon.flushRoster(); - expect(sentDeltas[0]?.entries.find((entry) => entry.agentId === "child-1")).toMatchObject({ + 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. + // 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( @@ -156,7 +184,7 @@ describe("worker roster reporter", () => { 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("child-1"); + expect(collided?.agentId).not.toBe("parent-active#child-1"); // The child session materializes: same agentId, one resident row, no queued marker. const childState = makeState({ @@ -177,7 +205,7 @@ describe("worker roster reporter", () => { }), ); daemon.flushRoster(); - const merged = sentDeltas.at(-1)?.entries.filter((entry) => entry.agentId === "child-1") ?? []; + 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(); @@ -189,7 +217,7 @@ describe("worker roster reporter", () => { ); daemon.sessions.delete(childState.activeSessionId); daemon.flushRoster(); - const superseded = sentDeltas.at(-1)?.entries.find((entry) => entry.agentId === "child-1"); + const superseded = sentDeltas.at(-1)?.entries.find((entry) => entry.agentId === "parent-active#child-1"); expect(superseded?.queuedChild).toBeUndefined(); expect(superseded?.summary.id).toBe("session-child-active"); expect(superseded?.summary.activeSessionId).toBeUndefined(); @@ -205,11 +233,11 @@ describe("worker roster reporter", () => { childUpdate(parent, { id: "child-2", label: "task", status: "cancelled", sessionDir: "/tmp/c" }), ); daemon.flushRoster(); - expect(sentDeltas.at(-1)?.removedAgentIds).toEqual(["child-2"]); + 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 === "child-2")).toBe(false); + 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( @@ -226,10 +254,10 @@ describe("worker roster reporter", () => { }); daemon.sessions.set(boundState.activeSessionId, boundState); daemon.flushRoster(); - const bound = sentDeltas.at(-1)?.entries.find((entry) => entry.agentId === "child-3"); + 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("child-3")).toBe(false); + expect(daemon.rosterReporter.queuedChildren.has("parent-active#child-3")).toBe(false); }); it("cancels pending removals for reincarnated ids but keeps the removed incarnation suppressed", () => { @@ -239,7 +267,7 @@ describe("worker roster reporter", () => { // A deletion while disconnected leaves the removal pending; the id is then reused by a new admission. connection.connected = false; - daemon.rosterReporter.removedAgentIds.set("child-1", "old-session"); + daemon.rosterReporter.removedAgentIds.set("parent-active#child-1", "old-session"); daemon.flushRoster(); daemon.observeRosterEvent( parent, @@ -251,7 +279,9 @@ describe("worker roster reporter", () => { const snapshot = sentDeltas.at(-1); expect(snapshot?.snapshot).toBe(true); expect(snapshot?.removedAgentIds).toBeUndefined(); - expect(snapshot?.entries.some((entry) => entry.agentId === "child-1" && entry.queuedChild === true)).toBe(true); + 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(); @@ -264,12 +294,40 @@ describe("worker roster reporter", () => { }); daemon.sessions.set(dying.activeSessionId, dying); daemon.flushRoster(); - daemon.rosterReporter.removedAgentIds.set("child-2", "session-child-active"); + daemon.rosterReporter.removedAgentIds.set("parent-active#child-2", "session-child-active"); daemon.flushRoster(); - expect(sentDeltas.at(-1)?.removedAgentIds).toEqual(["child-2"]); + expect(sentDeltas.at(-1)?.removedAgentIds).toEqual(["parent-active#child-2"]); daemon.sessions.delete(dying.activeSessionId); daemon.flushRoster(); - expect(daemon.rosterReporter.lastComposed.has("child-2")).toBe(false); + expect(daemon.rosterReporter.lastComposed.has("parent-active#child-2")).toBe(false); + }); + + 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 () => { @@ -1010,6 +1068,33 @@ describe("saved-session delete paths", () => { expect(owned.client.request).toHaveBeenCalled(); }); + 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-", { @@ -1292,6 +1377,8 @@ describe("review-round regressions", () => { 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)); From afc7623028653b6ffd35ac2761e7716801c5275d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 20:07:52 +0200 Subject: [PATCH 45/48] chore(coding-agent): second slim pass on roster tests and comments - One shared roster-seeding fixture (test/fixtures/roster-seed.ts) replaces the five per-suite copies. - Deleted mechanism pins with accepted residual risk: flush change-dedup and trigger-set micro-pins (the lifecycle test still pins the closed-session flip), the single-flight repair pull, the mid-pull epoch skip, the crafted late-update guard phase, and the second pre-roster identity scenario. - Another comment pass: dropped notes that restate the guard beside them. --- .../src/modes/daemon/daemon-mode.ts | 4 +- .../src/modes/daemon/daemon-supervisor.ts | 8 +- .../test/daemon-agent-roster.test.ts | 129 +----------------- .../test/daemon-supervisor-eviction.test.ts | 13 +- .../daemon-supervisor-lazy-subagents.test.ts | 21 +-- .../test/daemon-supervisor-monitor.test.ts | 16 +-- .../coding-agent/test/fixtures/roster-seed.ts | 19 +++ ...4602-snapshot-transfer-idempotency.test.ts | 23 +--- .../4677-snapshot-catchup-replacement.test.ts | 23 +--- 9 files changed, 43 insertions(+), 213 deletions(-) create mode 100644 packages/coding-agent/test/fixtures/roster-seed.ts diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 5f04725342..42a8774b4c 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -6370,8 +6370,7 @@ export class AgentDaemon { state.clients.clear(); this.acpMcpOwners.delete(state.activeSessionId); this.sessions.delete(state.activeSessionId); - // An archived or discarded top-level session leaves the worker's list; only the disk scan serves - // it now. Subagent rows stay passivated to mirror the registry's completed children. + // 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); } @@ -6682,7 +6681,6 @@ export class AgentDaemon { const entry = workerRosterEntryFromSummary(summary); entries.set(entry.agentId, entry); } - // The rlm_child_update that clears a queued marker can trail the bound session's own first events. for (const [agentId, queued] of reporter.queuedChildren) { if (entries.has(agentId)) { reporter.queuedChildren.delete(agentId); diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 3fa23dae41..c4a2b7c2ad 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -2140,7 +2140,6 @@ export class DaemonSupervisor { } await tombstoneSavedSessionDelete(this.rlmSpawnLedger(), command.sessionPath, entry?.summary); const result = await this.catalog.delete(command.sessionPath); - // A write during the awaits replaces the row object; only the observed row may be deleted. if (result.ok && entry && this.roster().get(entry.agentId) === entry) { this.roster().delete(entry.agentId); } @@ -2363,7 +2362,6 @@ export class DaemonSupervisor { if (entry.seededCwd !== true || !entry.summary.sessionFile) return entry; const info = await readSessionInfo(entry.summary.sessionFile).catch(() => undefined); if (!info) return entry; - // A frame can rewrite this agentId while the header read is in flight; never clobber the fresher row. const current = this.roster().get(entry.agentId); if (current !== entry) return current ?? entry; const { seededCwd, ...rest } = entry; @@ -3507,8 +3505,7 @@ export class DaemonSupervisor { private async recoverUncertainWorkerOperations(worker: ResidentWorker, killWorkerProcess = true): Promise { await this.assertRecoveryAllowed(); - // Callers verify identity before awaiting their way here, so re-check at the last - // synchronous moment: the old process can exit in that gap and the PID can recycle. + // 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" @@ -3794,8 +3791,7 @@ export class DaemonSupervisor { return chained; } - // An apply is valid only while its own source connection is still the current or authenticating - // one: a dead connection's parked applies must not resume during (or after) a reconnect. + // 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 && diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index f81e6bee2c..3d3313265b 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -210,16 +210,11 @@ describe("worker roster reporter", () => { expect(merged[0]).toMatchObject({ summary: { activeSessionId: "child-active", lifecycle: "live" } }); expect(merged[0]?.queuedChild).toBeUndefined(); - // Crafted without activeSessionId: the lifecycle guard, not event stamping, must reject the late update. - daemon.observeRosterEvent( - parent, - childUpdate(parent, { id: "child-1", label: "task", status: "queued", sessionDir: "/tmp/c" }), - ); 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.id).toBe("session-child-active"); expect(superseded?.summary.activeSessionId).toBeUndefined(); // A run that terminates before binding is a removal, never a passivated phantom. @@ -394,40 +389,6 @@ describe("worker roster reporter", () => { await new Promise((resolveSettle) => setImmediate(resolveSettle)); expect(internals.rosterReporter.lastComposed.get(agentId)?.summary.model).toMatchObject({ id: "m2" }); }); - - it("sends deltas only on change and flips closed sessions to non-resident instead of dropping them", () => { - const { daemon, sentDeltas } = makeWorkerReporter(); - const state = makeState({ - activeSessionId: "root-active", - messages: [{ role: "user", content: "hi" } as unknown as AgentMessage], - }); - daemon.sessions.set(state.activeSessionId, state); - - daemon.flushRoster(); - daemon.flushRoster(); - expect(sentDeltas).toHaveLength(1); - - const reporter = daemon as unknown as { rosterFlushScheduled: boolean }; - for (const [type, scheduled] of [ - ["tool_execution_start", true], - ["message_start", false], - ] as const) { - reporter.rosterFlushScheduled = false; - daemon.observeRosterEvent(state, { - type: "session_event", - activeSessionId: state.activeSessionId, - event: { type }, - }); - expect(reporter.rosterFlushScheduled, type).toBe(scheduled); - } - - daemon.sessions.delete(state.activeSessionId); - daemon.flushRoster(); - const flipped = sentDeltas.at(-1)?.entries[0]; - expect(flipped).toMatchObject({ summary: { id: "session-root-active", isSessionActive: false } }); - expect(flipped?.summary.activeSessionId).toBeUndefined(); - expect(sentDeltas.at(-1)?.removedAgentIds).toBeUndefined(); - }); }); // --- Supervisor-side roster ledger --- @@ -1400,107 +1361,27 @@ describe("review-round regressions", () => { } }); - it("repairs failed roster applies with one single-flight pull that never respawns itself", async () => { + it("keeps an unverifiable live pre-roster worker failed with no replacement", async () => { const worker = makeWorker("worker-1"); - let repairs = 0; - const supervisor = makeSupervisor([worker], { - applyWorkerRosterSnapshot: vi.fn(async () => { - throw new Error("apply exploded"); - }), - refreshWorkerSummaries: vi.fn(() => { - repairs += 1; - return new Promise(() => {}); - }), - }); - - supervisor.consumeWorkerRosterDelta(worker, rosterDelta([], undefined, true)); - supervisor.consumeWorkerRosterDelta(worker, rosterDelta([], undefined, true)); - supervisor.consumeWorkerRosterDelta(worker, rosterDelta([], undefined, true)); - await new Promise((resolveSettle) => setImmediate(resolveSettle)); - expect(repairs).toBe(1); - - // A repair that itself fails logs the worker and does not spawn another pull. - const failingWorker = makeWorker("worker-2"); - const log = vi.fn(); - const failingSupervisor = makeSupervisor([failingWorker], { - applyWorkerRosterSnapshot: vi.fn(async () => { - throw new Error("apply exploded"); - }), - refreshWorkerSummaries: vi.fn(async () => { - throw new Error("repair pull failed"); - }), - log, - }); - failingSupervisor.consumeWorkerRosterDelta(failingWorker, rosterDelta([], undefined, true)); - await new Promise((resolveSettle) => setImmediate(resolveSettle)); - expect(failingSupervisor.refreshWorkerSummaries).toHaveBeenCalledTimes(1); - expect(log).toHaveBeenCalledWith(expect.stringContaining("Roster repair pull failed for worker worker-2")); - }); - - it.each([ - { scenario: "live but unverifiable", verdicts: undefined }, - { scenario: "current then unknown after the kill wait", verdicts: ["current", "unknown"] }, - ])("keeps a pre-roster worker failed with no replacement when its identity is $scenario", async ({ verdicts }) => { - const worker = makeWorker("worker-1"); - if (!verdicts) Object.assign(worker.descriptor, { pid: process.pid, processStartId: undefined }); + 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, - ...(verdicts - ? { processIdentity: vi.fn().mockReturnValueOnce(verdicts[0]).mockReturnValue(verdicts[1]) } - : {}), }); await ( supervisor as unknown as { restartPreRosterWorker(worker: WorkerFixture, observedProcessStartId?: string): Promise; } - ).restartPreRosterWorker(worker, verdicts ? "start-id-1" : undefined); + ).restartPreRosterWorker(worker, undefined); - if (!verdicts) expect(recoverUncertainWorkerOperations).toHaveBeenCalledWith(worker, false); + expect(recoverUncertainWorkerOperations).toHaveBeenCalledWith(worker, false); expect(launchWorker).not.toHaveBeenCalled(); expect(worker.descriptor.lifecycle).toBe("failed"); }); - - it("skips the gap fill when a roster frame lands mid-pull", async () => { - const worker = makeWorker("worker-1"); - Object.assign(worker.descriptor, { createCommand: { type: "create" } }); - const staleChild = summary({ - id: "x-session", - sessionId: "x-session", - sessionFile: "/tmp/artifacts/x.jsonl", - runtimeKind: "subagent", - rlmChildId: "x", - }); - const staleEntry = workerRosterEntryFromSummary(staleChild); - const supervisor = makeSupervisor([worker], { - assertRecoveryAllowed: vi.fn(async () => {}), - persistWorker: vi.fn(), - refreshWorkerSummaries: DaemonSupervisor.prototype["refreshWorkerSummaries" as never], - streamReconstructor: { seed: vi.fn(), clear: vi.fn() }, - }); - supervisor.writeRosterEntry(staleEntry, worker); - const root = summary({ id: "worker-1-root-active", sessionId: "root", activeSessionId: "worker-1-root-active" }); - let pulls = 0; - worker.client = { - request: vi.fn(async () => { - pulls += 1; - // Every pull straddles a frame: deletions keep landing while stale responses still carry the child. - supervisor.consumeWorkerRosterDelta(worker, rosterDelta([], [staleEntry.agentId])); - return { type: "response", command: "list", success: true, data: { sessions: [root, staleChild] } }; - }), - }; - - await ( - supervisor as unknown as { refreshWorkerSummaries(worker: WorkerFixture, recovery: boolean): Promise } - ).refreshWorkerSummaries(worker, true); - - expect(pulls).toBe(2); - expect(supervisor.roster().has(staleEntry.agentId)).toBe(false); - }); }); describe("worker delete tombstone durability", () => { diff --git a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts index c65a75e9f0..a0359d6daa 100644 --- a/packages/coding-agent/test/daemon-supervisor-eviction.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-eviction.test.ts @@ -2,10 +2,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { workerRosterEntryFromSummary } from "../src/modes/daemon/agent-roster.js"; import { success } from "../src/modes/daemon/daemon-protocol.js"; import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js"; import { DaemonSupervisor, idleEvictionSweepIntervalMs } from "../src/modes/daemon/daemon-supervisor.js"; +import { seedSupervisorRoster } from "./fixtures/roster-seed.js"; interface WorkerFixture { descriptor: { @@ -89,17 +89,6 @@ function makeWorker(id: string, summaries: SessionSummary[]): WorkerFixture { }; } -function seedSupervisorRoster(supervisor: SupervisorInternals, ...workers: WorkerFixture[]): void { - const internals = supervisor as unknown as { - writeRosterEntry(entry: ReturnType, worker?: WorkerFixture): unknown; - }; - for (const worker of workers) { - for (const summary of worker.summaries.values()) { - internals.writeRosterEntry(workerRosterEntryFromSummary(summary), worker); - } - } -} - function makeSupervisor(idleEvictionMinutes: number | "off" = 90): SupervisorInternals { const directory = mkdtempSync(join(tmpdir(), "prime-supervisor-eviction-")); tempDirs.push(directory); 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 b249afa53d..10b3f9ad29 100644 --- a/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-lazy-subagents.test.ts @@ -8,12 +8,12 @@ import { sessionNameReservationKey, } from "../src/core/agent-messages.js"; import { readSessionInfo, SessionManager } from "../src/core/session-manager.js"; -import { workerRosterEntryFromSummary } from "../src/modes/daemon/agent-roster.js"; import { DaemonCatalogClient } from "../src/modes/daemon/daemon-catalog-process.js"; 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; @@ -34,7 +34,6 @@ interface SupervisorInternals { familyCatalogEntry(summary: SessionSummary): AgentFamilyCatalogEntry; handleCommand(client: object, command: Record): Promise; seedRosterLedger(): Promise; - writeRosterEntry(entry: ReturnType, worker?: WorkerFixture): unknown; } interface WorkerFixture { @@ -76,14 +75,6 @@ function summary(overrides: Partial & Pick { sessionId: "aaaa6666777788889999dddd", }); const resident = worker("first", [child]); - seedRoster(supervisor, resident); + seedSupervisorRoster(supervisor, resident); expect(supervisor.findSummaryInWorker(resident, "88889999cccc")).toEqual(child); }); @@ -512,7 +503,7 @@ describe("daemon supervisor passive subagent topology", () => { }) as unknown as SupervisorInternals; supervisor.workers.set("first", firstWorker); supervisor.workers.set("second", secondWorker); - seedRoster(supervisor, firstWorker, secondWorker); + seedSupervisorRoster(supervisor, firstWorker, secondWorker); Object.assign(supervisor, { catalog: { list: vi.fn(async () => []) } }); const client = { id: "client", attachedActiveSessionIds: new Set() }; @@ -556,7 +547,7 @@ describe("daemon supervisor passive subagent topology", () => { descriptorDir: join(directory, "workers"), }) as unknown as SupervisorInternals; supervisor.workers.set("owned", ownedWorker); - seedRoster(supervisor, ownedWorker); + seedSupervisorRoster(supervisor, ownedWorker); Object.assign(supervisor, { catalog: { list: vi.fn(async () => []) } }); const workerClient = { id: "daemon-client:worker", attachedActiveSessionIds: new Set() }; @@ -629,7 +620,7 @@ describe("daemon supervisor passive subagent topology", () => { }) as unknown as SupervisorInternals; supervisor.workers.set("first", firstWorker); supervisor.workers.set("second", secondWorker); - seedRoster(supervisor, firstWorker, secondWorker); + seedSupervisorRoster(supervisor, firstWorker, secondWorker); Object.assign(supervisor, { catalog: { siblings: vi.fn(async () => []), @@ -817,7 +808,7 @@ describe("daemon supervisor passive subagent topology", () => { supervisor.workers.set("first", first); supervisor.workers.set("second", second); supervisor.workers.set("disconnected", disconnected); - seedRoster(supervisor, first, second, 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 432fc8ae1e..e34e8093b6 100644 --- a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts @@ -8,7 +8,6 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { getProcessStartId } from "../src/core/session-lease.js"; import type { DaemonSocketClient } from "../src/modes/daemon/active-session-state.js"; -import { workerRosterEntryFromSummary } from "../src/modes/daemon/agent-roster.js"; import { CommandRecoveryJournal } from "../src/modes/daemon/command-recovery-journal.js"; import { DaemonCatalogClient } from "../src/modes/daemon/daemon-catalog-process.js"; import { DaemonClient } from "../src/modes/daemon/daemon-client.js"; @@ -30,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(() => ({ @@ -293,20 +293,6 @@ function createHarness(canConnect: () => Promise): SupervisorMonitorHar }) as SupervisorMonitorHarness; } -function seedSupervisorRoster( - supervisor: object, - ...workers: Array<{ descriptor: { workerId: string }; summaries: Map }> -): void { - const internals = supervisor as { - writeRosterEntry(entry: ReturnType, worker?: object): unknown; - }; - for (const worker of workers) { - for (const summary of worker.summaries.values()) { - internals.writeRosterEntry(workerRosterEntryFromSummary(summary), worker); - } - } -} - describe("daemon worker supervisor monitoring", () => { afterEach(async () => { for (const { child } of workerLaunchTestState.spawned) { 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/suite/regressions/4602-snapshot-transfer-idempotency.test.ts b/packages/coding-agent/test/suite/regressions/4602-snapshot-transfer-idempotency.test.ts index 52c587053f..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 @@ -3,7 +3,6 @@ import { PassThrough } from "node:stream"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { describe, expect, it, vi } from "vitest"; import type { ActiveSessionState, DaemonSocketClient } from "../../../src/modes/daemon/active-session-state.js"; -import { workerRosterEntryFromSummary } from "../../../src/modes/daemon/agent-roster.js"; import { AgentDaemon } from "../../../src/modes/daemon/daemon-mode.js"; import { DAEMON_PROTOCOL_INFO, @@ -19,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"; @@ -351,12 +351,7 @@ describe("ENG-4602 snapshot transfer containment", () => { }; internals.clients.add(client); internals.workers.set(worker.descriptor.workerId, worker); - const seeder = supervisor as unknown as { - writeRosterEntry(entry: ReturnType, worker?: WorkerHarness): unknown; - }; - for (const summary of worker.summaries.values()) { - seeder.writeRosterEntry(workerRosterEntryFromSummary(summary), worker); - } + seedSupervisorRoster(supervisor, worker); internals.syncWorkerExtensionUi = vi.fn(async () => {}); internals.streamSnapshot = streamSnapshot; const messages: AgentMessage[] = [{ role: "user", content: "stable", timestamp: 1 }]; @@ -441,12 +436,7 @@ describe("ENG-4602 snapshot transfer containment", () => { }; internals.clients.add(client); internals.workers.set(worker.descriptor.workerId, worker); - const seeder = supervisor as unknown as { - writeRosterEntry(entry: ReturnType, worker?: WorkerHarness): unknown; - }; - for (const summary of worker.summaries.values()) { - seeder.writeRosterEntry(workerRosterEntryFromSummary(summary), 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]) { @@ -514,12 +504,7 @@ describe("ENG-4602 snapshot transfer containment", () => { }; internals.clients.add(client); internals.workers.set(worker.descriptor.workerId, worker); - const seeder = supervisor as unknown as { - writeRosterEntry(entry: ReturnType, worker?: WorkerHarness): unknown; - }; - for (const summary of worker.summaries.values()) { - seeder.writeRosterEntry(workerRosterEntryFromSummary(summary), 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 e9d0cd4ade..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 @@ -6,7 +6,6 @@ import { PassThrough } from "node:stream"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { DaemonSocketClient } from "../../../src/modes/daemon/active-session-state.js"; -import { workerRosterEntryFromSummary } from "../../../src/modes/daemon/agent-roster.js"; import { AgentDaemon } from "../../../src/modes/daemon/daemon-mode.js"; import { DAEMON_PROTOCOL_INFO, @@ -18,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[] = []; @@ -307,12 +307,7 @@ describe("ENG-4677 snapshot catch-up replacement", () => { handleWorkerFrame(worker: WorkerHarness, frame: PrivateFrame): void; }; internals.workers.set(worker.descriptor.workerId, worker); - const seeder = supervisor as unknown as { - writeRosterEntry(entry: ReturnType, worker?: WorkerHarness): unknown; - }; - for (const summary of worker.summaries.values()) { - seeder.writeRosterEntry(workerRosterEntryFromSummary(summary), worker); - } + seedSupervisorRoster(supervisor, worker); const attaching = internals.attachClient(client, { type: "attach", activeSessionId, @@ -441,12 +436,7 @@ describe("ENG-4677 snapshot catch-up replacement", () => { handleWorkerFrame(worker: WorkerHarness, frame: PrivateFrame): void; }; internals.workers.set(worker.descriptor.workerId, worker); - const seeder = supervisor as unknown as { - writeRosterEntry(entry: ReturnType, worker?: WorkerHarness): unknown; - }; - for (const summary of worker.summaries.values()) { - seeder.writeRosterEntry(workerRosterEntryFromSummary(summary), worker); - } + seedSupervisorRoster(supervisor, worker); const { messages: _firstMessages, ...firstSnapshot } = firstResult.snapshot; const firstBegin = { type: "session_snapshot_begin", @@ -710,12 +700,7 @@ describe("ENG-4677 snapshot catch-up replacement", () => { internals.clients.add(client); internals.workers.set(worker.descriptor.workerId, worker); - const seeder = supervisor as unknown as { - writeRosterEntry(entry: ReturnType, worker?: WorkerHarness): unknown; - }; - for (const summary of worker.summaries.values()) { - seeder.writeRosterEntry(workerRosterEntryFromSummary(summary), worker); - } + seedSupervisorRoster(supervisor, worker); internals.queueCatchup(client, activeSessionId, "replacement"); await internals.catchUpClient(client); From 5dc767450fa76a3e527fc065272bbaedbb994a76 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 20:47:54 +0200 Subject: [PATCH 46/48] =?UTF-8?q?fix(coding-agent):=20seventh=20review=20r?= =?UTF-8?q?ound=20=E2=80=94=20spawn-append=20scoping,=20stat-reconciled=20?= =?UTF-8?q?seeds,=20one=20file-ownership=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pendingRlmSpawnAppends is keyed by parent + childId at every site: child ids are only unique per parent, and a cross-parent collision made one admission await the wrong ledger append while the other proceeded without awaiting its own durable spawn record. - The roster's ledger seeding and snapshot reseeds read liveEdges(), the ledger's own stat-reconciled view (the rule family() already owned): rows whose transcript was removed out-of-band never serve in list --all. Tombstone-first covers in-band deletes; this covers external removal. - findWorkerBySessionFile no longer consults the stale pull cache: the roster claim and the durable descriptor paths are the ownership sources, so a removed row cannot route a create back to a worker that would answer with its root session. - classifyWorkerRosterEntry is module-private (no consumer outside the module). - The changelog notes the client-owned exception to inactive-row retention. --- .../.changes/eng-5794-agent-roster-ledger.md | 2 +- .../src/modes/daemon/agent-roster.ts | 2 +- .../src/modes/daemon/daemon-mode.ts | 9 +- .../src/modes/daemon/daemon-supervisor.ts | 12 ++- .../src/modes/daemon/rlm-ledger.ts | 22 +++-- .../test/daemon-agent-roster.test.ts | 88 ++++++++++++++++++- 6 files changed, 112 insertions(+), 23 deletions(-) diff --git a/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md b/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md index 1d15e771b3..cfb36c1a16 100644 --- a/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md +++ b/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md @@ -1,3 +1,3 @@ - Made the daemon supervisor own an event-driven agent roster: workers push roster deltas on session events, `list` is served from the supervisor's ledger with zero worker round-trips, and stale cached summaries can no longer be returned. -- 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. +- 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 893f82ba7a..ea6a8a2130 100644 --- a/packages/coding-agent/src/modes/daemon/agent-roster.ts +++ b/packages/coding-agent/src/modes/daemon/agent-roster.ts @@ -79,7 +79,7 @@ export function workerRosterEntryFromSummary(summary: SessionSummary): WorkerRos return { agentId: rosterAgentIdForSummary(summary), summary: slim }; } -export function classifyWorkerRosterEntry(entry: WorkerRosterEntry): AgentRosterStatus { +function classifyWorkerRosterEntry(entry: WorkerRosterEntry): AgentRosterStatus { return classifySessionRosterStatus(entry.summary, entry.queuedChild === true); } diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 42a8774b4c..7f626ddd6c 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -1029,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({ @@ -2556,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 @@ -2565,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) { diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index c4a2b7c2ad..6f25b115ca 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -3714,7 +3714,8 @@ export class DaemonSupervisor { this.log(`Could not seed the agent roster from the session catalog: ${String(error)}`); } try { - for (const edge of await this.rlmSpawnLedger().edges()) { + // 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; @@ -3833,7 +3834,7 @@ export class DaemonSupervisor { // 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() - .edges() + .liveEdges() .catch((error: unknown) => { this.log(`Could not read the spawn ledger during a snapshot apply: ${String(error)}`); edgesFailed = true; @@ -4228,11 +4229,8 @@ export class DaemonSupervisor { const targetEntry = this.roster().bySessionFile(target); const matches = new Set(); for (const worker of this.workers.values()) { - const summaryMatches = - targetEntry?.workerId === worker.descriptor.workerId || - [...worker.summaries.values()].some( - (summary) => summary.sessionFile !== undefined && 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; diff --git a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts index c4a92f9d5f..4c62dfced2 100644 --- a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts +++ b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts @@ -497,12 +497,13 @@ export class RlmSpawnLedger { }); } - private async familyUnlocked(): Promise { + /** 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(): 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); - } const statCache = new Map(); const exists = async (path: string): Promise => { const cached = statCache.get(path); @@ -516,12 +517,21 @@ 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 { + const byChild = new Map(); + for (const edge of [...this.replaySync().values()].filter((candidate) => !candidate.deleted)) { + byChild.set(canonicalSessionPath(edge.child), edge); + } + let alive: RlmLedgerEdge[] = await this.liveEdgesUnlocked(); const rootPaths: string[] = []; let rootEntries: string[] = []; try { diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index 3d3313265b..f063bf3302 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -297,6 +297,36 @@ describe("worker roster reporter", () => { expect(daemon.rosterReporter.lastComposed.has("parent-active#child-2")).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({ @@ -585,6 +615,10 @@ describe("supervisor roster ledger", () => { 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, @@ -741,6 +775,10 @@ describe("supervisor roster ledger", () => { 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"), @@ -756,6 +794,14 @@ describe("supervisor roster ledger", () => { 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, @@ -1029,6 +1075,40 @@ describe("saved-session delete paths", () => { 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"); @@ -1218,7 +1298,7 @@ describe("review-round regressions", () => { const supervisor = makeSupervisor([worker], { refreshWorkerSummaries: DaemonSupervisor.prototype["refreshWorkerSummaries" as never], streamReconstructor: { seed: vi.fn(), clear: vi.fn() }, - rlmSpawnLedger: () => ({ edges: () => edgesPromise }), + rlmSpawnLedger: () => ({ liveEdges: () => edgesPromise }), }); supervisor.writeRosterEntry(childEntry, worker); const root = summary({ id: "worker-1-root-active", sessionId: "root", activeSessionId: "worker-1-root-active" }); @@ -1261,7 +1341,7 @@ describe("review-round regressions", () => { const supervisor = makeSupervisor([worker], { refreshWorkerSummaries, rlmSpawnLedger: () => ({ - edges: vi.fn(async () => { + liveEdges: vi.fn(async () => { throw new Error("ledger unreadable"); }), }), @@ -1304,7 +1384,7 @@ describe("review-round regressions", () => { const edgesPromise = new Promise((resolveEdges) => { releaseEdges = resolveEdges; }); - const supervisor = makeSupervisor([worker], { rlmSpawnLedger: () => ({ edges: () => edgesPromise }) }); + 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. @@ -1329,7 +1409,7 @@ describe("review-round regressions", () => { let releaseClosedEdges: (edges: unknown[]) => void = () => {}; const closedSupervisor = makeSupervisor([closed], { rlmSpawnLedger: () => ({ - edges: () => + liveEdges: () => new Promise((resolveEdges) => { releaseClosedEdges = resolveEdges; }), From 0acfeb22bfe96f9c3b8ef9a0af16b3761f26e5e4 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 31 Aug 2026 21:13:09 +0200 Subject: [PATCH 47/48] fix(coding-agent): remove, not passivate, rows renamed by in-place session swaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new_session/switch_session/fork swap the runtime under the same state: the activeSessionId survives while the sessionId (and so the top-level agentId) changes. The old agentId vanished from composition without a close, so the passivation-retention loop kept serving it as a stale claimed row that plain list never carried before the roster. The flush loop now treats a vanished row whose activeSessionId still composes under a different agentId as a removal — one owner for every swap origin, no per-command bookkeeping — and the pending-removal cancel rule also revives resident top-level rows (switch-back, resume-after-archive) while the resident-subagent teardown race stays suppressed. --- .../src/modes/daemon/daemon-mode.ts | 18 +++++++-- .../test/daemon-agent-roster.test.ts | 39 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 7f626ddd6c..45f5af4653 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -6690,15 +6690,27 @@ export class AgentDaemon { 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 (previous.queuedChild === true && !entries.has(agentId)) { + 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; the removed one stays suppressed mid-teardown. - if (composed && (composed.queuedChild === true || composed.summary.sessionId !== targetSessionId)) { + // 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; } diff --git a/packages/coding-agent/test/daemon-agent-roster.test.ts b/packages/coding-agent/test/daemon-agent-roster.test.ts index f063bf3302..01756d5074 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -297,6 +297,45 @@ describe("worker roster reporter", () => { 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); From 385f5660fb57bb26518d2599b48bf4e718a45ad7 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Tue, 1 Sep 2026 10:30:52 +0200 Subject: [PATCH 48/48] =?UTF-8?q?fix(coding-agent):=20ninth=20review=20rou?= =?UTF-8?q?nd=20=E2=80=94=20one=20family=20snapshot,=20family-scoped=20res?= =?UTF-8?q?eeds,=20filter-all=20tombstones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - family() builds its child suppression from the same single replay + stat snapshot that emits child rows: a sessions-dir child whose parent transcript vanished degrades to a root row instead of disappearing, and a concurrent cross-process append can no longer make the two views disagree. - Snapshot reseeds are scoped to the snapshotting worker's own family: the reseed exists to restore that worker's absentee-swept registry children, and resurrecting other families' unclaimed rows leaked a client-owned worker's just-dropped children back into list --all as public rows (the ownership record is already gone by then, so this scoping IS the privacy rule). - Transcript deletes tombstone every edge matching the path: appendSpawn's per-process uniqueness check leaves a cross-process TOCTOU window, and a raced duplicate left live would resurrect a later recreation as a subagent. - The changelog states the staleness behavior honestly: rows are as fresh as the worker's last delta, silence is annotated rather than hidden. --- .../.changes/eng-5794-agent-roster-ledger.md | 2 +- .../src/modes/daemon/daemon-supervisor.ts | 19 +++++++++++++ .../src/modes/daemon/rlm-ledger.ts | 23 +++++++++------ .../test/daemon-agent-roster.test.ts | 28 +++++++++++++++++++ packages/coding-agent/test/rlm-ledger.test.ts | 19 ++++++++++++- 5 files changed, 81 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md b/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md index cfb36c1a16..6baf51c96d 100644 --- a/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md +++ b/packages/coding-agent/.changes/eng-5794-agent-roster-ledger.md @@ -1,3 +1,3 @@ -- Made the daemon supervisor own an event-driven agent roster: workers push roster deltas on session events, `list` is served from the supervisor's ledger with zero worker round-trips, and stale cached summaries can no longer be returned. +- 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/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 6f25b115ca..017164e64c 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -3864,7 +3864,26 @@ export class DaemonSupervisor { } // 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; diff --git a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts index 4c62dfced2..bca878476d 100644 --- a/packages/coding-agent/src/modes/daemon/rlm-ledger.ts +++ b/packages/coding-agent/src/modes/daemon/rlm-ledger.ts @@ -502,8 +502,9 @@ export class RlmSpawnLedger { return this.enqueue(() => this.liveEdgesUnlocked()); } - private async liveEdgesUnlocked(): Promise { - const edges = [...this.replaySync().values()].filter((edge) => !edge.deleted); + 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); @@ -527,11 +528,15 @@ export class RlmSpawnLedger { } 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 [...this.replaySync().values()].filter((candidate) => !candidate.deleted)) { + for (const edge of alive) { byChild.set(canonicalSessionPath(edge.child), edge); } - let alive: RlmLedgerEdge[] = await this.liveEdgesUnlocked(); const rootPaths: string[] = []; let rootEntries: string[] = []; try { @@ -869,9 +874,11 @@ export async function tombstoneSavedSessionDelete( const positivelyTopLevel = !knownChild && (knownSummary !== undefined || deletedInfo !== undefined); if (positivelyTopLevel) return { deletedInfo, ledgerEdge: undefined }; const edges = await ledger.edges(); - const ledgerEdge = edges.find((edge) => canonicalSessionPath(edge.child) === deletedPath); - if (ledgerEdge) { - await ledger.appendDelete({ childId: ledgerEdge.childId, child: sessionPath, reason: "user" }); + // 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 }; + 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 index 01756d5074..2493778330 100644 --- a/packages/coding-agent/test/daemon-agent-roster.test.ts +++ b/packages/coding-agent/test/daemon-agent-roster.test.ts @@ -673,7 +673,21 @@ describe("supervisor roster ledger", () => { 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( @@ -737,6 +751,7 @@ describe("supervisor roster ledger", () => { 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 () => { @@ -933,6 +948,18 @@ describe("supervisor roster ledger", () => { 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", @@ -1330,6 +1357,7 @@ describe("review-round regressions", () => { 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; 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 }); }