Skip to content
1 change: 1 addition & 0 deletions packages/coding-agent/.changes/on-demand-agent-peers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Made cross-worker agent lists current without broadcasting duplicate peer rosters.
59 changes: 36 additions & 23 deletions packages/coding-agent/src/modes/daemon/daemon-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,6 @@ export class AgentDaemon {
private readonly agentDir: string;
private readonly cronScheduler: AgentCronScheduler;
private readonly agentMessageRateLimiter = new AgentSessionMessageRateLimiter();
private readonly remoteAgentPeers = new Map<string, AgentSessionMessageAgentSummary>();
private readonly agentMessagePendingReservations = new Map<string, number>();
private readonly agentMessageTargetLocks = new Map<string, Promise<void>>();
private readonly agentMessageAcceptingTargets = new Set<string>();
Expand Down Expand Up @@ -3610,13 +3609,6 @@ export class AgentDaemon {
this.write(client, success(command.id, "detach"));
return;
}
case "worker_sync_agent_peers":
this.remoteAgentPeers.clear();
for (const peer of command.peers) {
this.remoteAgentPeers.set(peer.activeSessionId, peer);
}
this.writeWorkerSuccess(client, command);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mixed-version peer sync can hang

Medium Severity

Removing worker_sync_agent_peers leaves that command unmatched. Worker sockets still route any worker_* type into handleWorkerCommand, which now returns without writing a response. An older supervisor still broadcasts this command and waits 5s per sync, so list, create, and recovery stall for the whole mixed-version window instead of failing fast.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f39d98f. Configure here.

case "worker_archive_and_shutdown": {
for (const state of [...this.sessions.values()]) {
await this.closeSession(state, "killed");
Expand Down Expand Up @@ -5319,7 +5311,32 @@ export class AgentDaemon {
};
}

private async createAgentMessageListResult(current: ActiveSessionState): Promise<AgentSessionMessageListResult> {
private async listSupervisorAgentPeers(): Promise<AgentSessionMessageAgentSummary[]> {
const supervisorSocketPath = this.supervisorSocketPathFromEnv();
if (!this.options.worker || !supervisorSocketPath) return [];
const client = new DaemonClient(supervisorSocketPath);
try {
await client.connect(1000);
await client.waitForHello(1000);
const response = await client.request(
{ type: "list_agent_peers", workerToken: this.options.worker.authenticationToken },
5000,
);
if (!response.success) throw deserializeDaemonError(response);
// SAFETY: The authenticated supervisor constructs the peer response.
return (response.data as { peers: AgentSessionMessageAgentSummary[] }).peers;
} catch {
return [];
} finally {
client.close();
}
Comment thread
snimu marked this conversation as resolved.
}

private async createAgentMessageListResult(
current: ActiveSessionState,
peers?: AgentSessionMessageAgentSummary[],
): Promise<AgentSessionMessageListResult> {
peers ??= await this.listSupervisorAgentPeers();
const localAgents = this.listTargetableSessionStates(current).map((state) =>
this.createAgentMessageAgentSummary(state),
);
Expand Down Expand Up @@ -5352,28 +5369,24 @@ export class AgentDaemon {
});
}
const localIds = new Set(localAgents.map((agent) => agent.activeSessionId));
const remoteAgents = peers.filter(
(peer) => !localIds.has(peer.activeSessionId) && !this.closingSessions.has(peer.activeSessionId),
);
return {
current: this.createAgentSessionMessageEndpoint(current),
agents: [
...localAgents,
...[...this.remoteAgentPeers.values()].filter(
(peer) =>
peer.status !== "inactive" &&
!localIds.has(peer.activeSessionId) &&
!this.closingSessions.has(peer.activeSessionId),
),
],
agents: [...localAgents, ...remoteAgents],
};
}

private async createAgentFamilyCatalog(currentState?: ActiveSessionState): Promise<AgentFamilyCatalogEntry[]> {
const current =
currentState ?? [...this.sessions.values()].find((state) => !this.bindingSessions.has(state.activeSessionId));
const listed = current ? await this.createAgentMessageListResult(current) : { agents: [] };
const remotePeers = new Set(this.remoteAgentPeers.values());
const remotePeers = current ? await this.listSupervisorAgentPeers() : [];
const listed = current ? await this.createAgentMessageListResult(current, remotePeers) : { agents: [] };
const remotePeerSet = new Set(remotePeers);
const localAgents = current
? [this.createAgentMessageAgentSummary(current), ...listed.agents.filter((agent) => !remotePeers.has(agent))]
: listed.agents.filter((agent) => !remotePeers.has(agent));
? [this.createAgentMessageAgentSummary(current), ...listed.agents.filter((agent) => !remotePeerSet.has(agent))]
: listed.agents;
const activePaths = new Set(
localAgents.flatMap((agent) => (agent.sessionPath ? [canonicalSessionPath(agent.sessionPath)] : [])),
);
Expand Down Expand Up @@ -5414,7 +5427,7 @@ export class AgentDaemon {
...(agent.sessionPath ? { sessionPath: canonicalSessionPath(agent.sessionPath) } : {}),
});
};
for (const peer of this.remoteAgentPeers.values()) addAgent(peer);
for (const peer of remotePeers) addAgent(peer);
for (const agent of localAgents) addAgent(agent);
for (const state of this.sessions.values()) {
const entry = byId.get(state.runtime.session.sessionId);
Expand Down
10 changes: 7 additions & 3 deletions packages/coding-agent/src/modes/daemon/daemon-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ export const DAEMON_COMMAND_ENVELOPE_MIN_PROTOCOL_VERSION = 7;
// Revision 19 adds daemon-held session input pauses.
// Revision 20 lets cancellation target a prompt the session owns but has not started.
// Revision 21 adds capability-gated, session-scoped ACP MCP server replacement.
// Revision 22 scopes ACP MCP replacement and cleanup to a connection owner.
export const DAEMON_SCHEMA_REVISION = 22;
export const DAEMON_SCHEMA_ID = "protocol-7-schema-22-4d515169dc6b";
// Revision 23 lets workers query the supervisor agent roster on demand.
export const DAEMON_SCHEMA_REVISION = 23;
export const DAEMON_SCHEMA_ID = "protocol-7-schema-23-649fe649d15e";

export type DaemonProtocolName = typeof DAEMON_PROTOCOL_NAME;
export type DaemonProtocolVersion = number;
Expand Down Expand Up @@ -377,6 +377,7 @@ export type DaemonCommand =
includeClientOwned?: boolean;
}
| DaemonSavedSessionListCommand
| { id?: string; type: "list_agent_peers"; workerToken: string }
| ({
id?: string;
type: "create";
Expand Down Expand Up @@ -712,11 +713,13 @@ const SESSION_INPUT_PAUSE_COMMAND = {
minSchemaRevision: 19,
capability: "session_input_pause",
} as const;
const AGENT_PEER_LIST_COMMAND = { minProtocol: 7, minSchemaRevision: 23 } as const;

export const DAEMON_COMMAND_COMPATIBILITY = {
ack_result: LEGACY_DAEMON_COMMAND,
list: LEGACY_DAEMON_COMMAND,
list_saved_sessions: LEGACY_DAEMON_COMMAND,
list_agent_peers: AGENT_PEER_LIST_COMMAND,
create: LEGACY_DAEMON_COMMAND,
attach: LEGACY_DAEMON_COMMAND,
reattach: LEGACY_DAEMON_COMMAND,
Expand Down Expand Up @@ -1106,6 +1109,7 @@ const READ_ONLY_DAEMON_COMMANDS: ReadonlySet<DaemonCommand["type"]> = new Set([
"ack_result",
"list",
"list_saved_sessions",
"list_agent_peers",
"attach",
"reattach",
"agent_messages_status",
Expand Down
73 changes: 22 additions & 51 deletions packages/coding-agent/src/modes/daemon/daemon-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ const WORKER_STARTUP_GATE_FD = 3;
const DAEMON_COMMAND_TYPES: ReadonlySet<string> = new Set([
"ack_result",
"list",
"list_agent_peers",
"list_saved_sessions",
"create",
"attach",
Expand Down Expand Up @@ -638,7 +639,6 @@ export class DaemonSupervisor {
private commandJournal!: CommandRecoveryJournal;
private readonly streamReconstructor = new CompactAssistantStreamReconstructor();
private readonly compactCatchupInProgress = new Set<string>();
private agentPeerSyncQueue: Promise<void> = Promise.resolve();
Comment thread
cursor[bot] marked this conversation as resolved.
private readonly pendingSessionNames = new Set<string>();
private readonly catalog: DaemonCatalogClient;
private readonly settingsManager: SettingsManager;
Expand Down Expand Up @@ -737,7 +737,6 @@ export class DaemonSupervisor {
if (adoptionFailed) {
throw adoptionFailure;
}
await this.syncAgentPeers().catch((error) => this.log(`Could not synchronize agent peers: ${String(error)}`));
for (const worker of this.workers.values()) {
this.scheduleOwnedWorkerCleanup(worker);
}
Expand Down Expand Up @@ -1534,6 +1533,25 @@ export class DaemonSupervisor {
return undefined;
case "list":
return this.handleList(client, command);
case "list_agent_peers": {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const requester = [...this.workers.values()].find(
(worker) => worker.descriptor.authenticationToken === command.workerToken,
);
if (!requester) throw new Error("Worker authentication failed");
const peers = [...this.workers.values()]
.filter(
(worker) =>
worker !== requester &&
this.isLiveWorker(worker) &&
worker.descriptor.lifecycle === "ready" &&
worker.client !== undefined,
)
.flatMap((worker) => {
const root = worker.summaries.get(worker.descriptor.rootActiveSessionId);
return root ? [this.agentPeerSummary(root)] : [];
});
return success(command.id, command.type, { peers });
Comment thread
snimu marked this conversation as resolved.
}
case "list_saved_sessions":
return this.handleSavedSessionList(client, command);
case "create": {
Expand All @@ -1549,7 +1567,6 @@ export class DaemonSupervisor {
const response = await this.forwardToWorker(worker, withoutSupervisorCreateFields(command));
if (response.success && isSessionSummary(response.data)) {
await this.refreshWorkerSummaries(worker);
await this.syncAgentPeers().catch(() => undefined);
return { ...response, id: command.id, data: this.publicSummary(worker, response.data) };
}
return responseWithId(response, command.id);
Expand Down Expand Up @@ -2163,7 +2180,6 @@ export class DaemonSupervisor {
.filter((worker) => !this.isWorkerStopping(worker))
.map((worker) => this.refreshWorkerSummaries(worker).catch(() => undefined)),
);
await this.syncAgentPeers().catch((error) => this.log(`Could not synchronize agent peers: ${String(error)}`));
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.
Expand Down Expand Up @@ -2340,7 +2356,6 @@ export class DaemonSupervisor {
this.invalidateWorkerSessionInputPauses(worker, "Session worker stopped while input was paused");
this.workers.delete(worker.descriptor.workerId);
this.deleteWorkerDescriptor(worker);
await this.syncAgentPeers().catch(() => undefined);
return true;
}
// Fail fast before waiting on anything: only a confirmed-dead process is
Expand Down Expand Up @@ -2392,7 +2407,6 @@ export class DaemonSupervisor {
}
worker.launchEnv = undefined;
worker.transientCreateCommand = undefined;
await this.syncAgentPeers().catch((error) => this.log(`Could not synchronize agent peers: ${String(error)}`));
}

private async launchWorker(
Expand Down Expand Up @@ -2574,7 +2588,6 @@ export class DaemonSupervisor {
worker.launchEnv = undefined;
worker.transientCreateCommand = undefined;
}
await this.syncAgentPeers().catch((error) => this.log(`Could not synchronize agent peers: ${String(error)}`));
this.broadcastHeartbeatsChanged();
return worker;
} catch (error) {
Expand Down Expand Up @@ -2801,7 +2814,6 @@ export class DaemonSupervisor {
worker.descriptor.lifecycle = "recovering";
worker.descriptor.lastError = error.message;
this.persistWorker(worker);
void this.syncAgentPeers().catch(() => undefined);
void this.recoverWorker(worker);
}

Expand Down Expand Up @@ -2854,7 +2866,6 @@ export class DaemonSupervisor {
worker.descriptor.lifecycle = "recovering";
worker.descriptor.lastError = disconnectError.message;
this.persistWorker(worker);
void this.syncAgentPeers().catch(() => undefined);
void this.recoverWorker(worker);
return;
}
Expand Down Expand Up @@ -3082,9 +3093,6 @@ export class DaemonSupervisor {
worker.descriptor.lifecycle = "ready";
worker.descriptor.consecutiveFailures = 0;
this.persistWorker(worker);
await this.syncAgentPeers().catch((error) =>
this.log(`Could not synchronize agent peers after worker recovery: ${String(error)}`),
);
this.broadcastHeartbeatsChanged();
return;
} catch (error) {
Expand Down Expand Up @@ -3113,7 +3121,6 @@ export class DaemonSupervisor {
worker.descriptor.lifecycle = "failed";
worker.descriptor.lastError = "Waiting for a client with fresh runtime context";
this.persistWorker(worker);
await this.syncAgentPeers().catch(() => undefined);
return;
}
const safeToKillWorkerProcess =
Expand Down Expand Up @@ -3148,7 +3155,6 @@ export class DaemonSupervisor {
}
worker.descriptor.lifecycle = "failed";
this.persistWorker(worker);
await this.syncAgentPeers().catch(() => undefined);
this.log(`Worker ${worker.descriptor.workerId} failed after three recovery attempts`);
})().finally(() => {
worker.recovery = undefined;
Expand Down Expand Up @@ -3414,35 +3420,6 @@ export class DaemonSupervisor {
);
}

private syncAgentPeers(): Promise<void> {
const sync = this.agentPeerSyncQueue
.catch(() => undefined)
.then(async () => {
const readyWorkers = [...this.workers.values()].filter(
(worker): worker is ResidentWorker & { client: DaemonWorkerClient } =>
this.isLiveWorker(worker) && worker.descriptor.lifecycle === "ready" && worker.client !== undefined,
);
await Promise.all(
readyWorkers.map(async (worker) => {
const peers = [
...readyWorkers
.filter((candidate) => candidate !== worker)
.flatMap((candidate) => {
const root = candidate.summaries.get(candidate.descriptor.rootActiveSessionId);
return root ? [this.agentPeerSummary(root)] : [];
}),
];
const response = await worker.client.requestWorker({ type: "worker_sync_agent_peers", peers }, 5000);
if (!response.success) {
throw new Error(response.error);
}
}),
);
});
this.agentPeerSyncQueue = sync;
return sync;
}

private isVisibleWorker(worker: ResidentWorker): boolean {
return worker.descriptor.ownerClientId === undefined;
}
Expand Down Expand Up @@ -4546,17 +4523,13 @@ export class DaemonSupervisor {
this.writeSerialized(client, publicPayload);
}
if (outboundType === "session_replaced" || outboundType === "session_closed") {
void this.refreshWorkerSummaries(worker)
.then(() => this.syncAgentPeers())
.catch(() => undefined);
void this.refreshWorkerSummaries(worker).catch(() => undefined);
} else if (
sessionEventType === "turn_start" ||
sessionEventType === "turn_end" ||
sessionEventType === "rlm_child_update"
) {
void this.refreshWorkerSummaries(worker)
.then(() => this.syncAgentPeers())
.catch(() => undefined);
void this.refreshWorkerSummaries(worker).catch(() => undefined);
}
if (
decodedOutbound?.type === "session_closed" &&
Expand All @@ -4572,7 +4545,6 @@ export class DaemonSupervisor {
this.invalidateWorkerSessionInputPauses(worker, "Session worker stopped while input was paused");
this.workers.delete(worker.descriptor.workerId);
this.deleteWorkerDescriptor(worker);
void this.syncAgentPeers().catch(() => undefined);
}
}
}
Expand Down Expand Up @@ -5137,7 +5109,6 @@ export class DaemonSupervisor {
this.deleteWorkerDescriptor(worker);
}
if (!this.shuttingDown) {
void this.syncAgentPeers().catch(() => undefined);
this.broadcastHeartbeatsChanged();
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import { closeSync, readFileSync } from "node:fs";
import type {
AgentSessionMessageAgentSummary,
AgentSessionMessageDeliveryMode,
AgentSessionMessageSender,
} from "../../core/agent-messages.js";
import type { AgentSessionMessageDeliveryMode, AgentSessionMessageSender } from "../../core/agent-messages.js";
import type { IdleEvictionMinutes } from "../../core/session-action-store.js";

export { SESSION_LEASE_OWNER_ID_ENV, SESSION_LEASES_ENABLED_ENV } from "../../core/session-lease.js";
Expand Down Expand Up @@ -70,7 +66,6 @@ export type DaemonWorkerCommand =
supportsExtensionUi?: boolean;
}
| { id?: string; type: "worker_unsubscribe"; activeSessionId: string }
| { id?: string; type: "worker_sync_agent_peers"; peers: AgentSessionMessageAgentSummary[] }
| { id?: string; type: "worker_archive_and_shutdown" }
| {
id?: string;
Expand Down
Loading
Loading