refactor(server): extract foreground run state from agent-manager (#666)

Centralize pending-run, waiter, and finalized-turn bookkeeping in a
new ForegroundRunState module so streamAgent, replaceAgentRun,
cancelAgentRun, reload/close, and stream dispatch share one
coordinator instead of hand-rolling the lifecycle in five places.
This commit is contained in:
Mohamed Boudra
2026-05-02 15:34:47 +08:00
committed by GitHub
parent 26737d3d70
commit 6225ec3008
2 changed files with 268 additions and 167 deletions
+36 -167
View File
@@ -48,6 +48,7 @@ import {
AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS, AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS,
AgentStreamCoalescer, AgentStreamCoalescer,
} from "./agent-stream-coalescer.js"; } from "./agent-stream-coalescer.js";
import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js";
import { getAgentProviderDefinition } from "./provider-manifest.js"; import { getAgentProviderDefinition } from "./provider-manifest.js";
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000; const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
@@ -177,22 +178,6 @@ interface HandleStreamEventOptions {
canonicalUserMessagesById?: ReadonlyMap<string, string>; canonicalUserMessagesById?: ReadonlyMap<string, string>;
} }
interface ForegroundTurnWaiter {
turnId: string;
callback: (event: AgentStreamEvent) => void;
settled: boolean;
settledPromise: Promise<void>;
resolveSettled: () => void;
}
interface PendingForegroundRun {
token: string;
started: boolean;
settled: boolean;
settledPromise: Promise<void>;
resolveSettled: () => void;
}
interface ManagedAgentBase { interface ManagedAgentBase {
id: string; id: string;
provider: AgentProvider; provider: AgentProvider;
@@ -399,7 +384,7 @@ export class AgentManager {
private readonly timelineStore = new InMemoryAgentTimelineStore(); private readonly timelineStore = new InMemoryAgentTimelineStore();
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>(); private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
private readonly sessionEventTails = new Map<string, Promise<void>>(); private readonly sessionEventTails = new Map<string, Promise<void>>();
private readonly pendingForegroundRuns = new Map<string, PendingForegroundRun>(); private readonly foregroundRuns = new ForegroundRunState();
private readonly subscribers = new Set<SubscriptionRecord>(); private readonly subscribers = new Set<SubscriptionRecord>();
private readonly idFactory: () => string; private readonly idFactory: () => string;
private readonly registry?: AgentStorage; private readonly registry?: AgentStorage;
@@ -524,7 +509,7 @@ export class AgentManager {
return ( return (
agent.lifecycle === "running" || agent.lifecycle === "running" ||
Boolean(agent.activeForegroundTurnId) || Boolean(agent.activeForegroundTurnId) ||
this.hasPendingForegroundRun(agentId) this.foregroundRuns.hasPendingRun(agentId)
); );
} }
@@ -848,11 +833,7 @@ export class AgentManager {
existing.unsubscribeSession(); existing.unsubscribeSession();
existing.unsubscribeSession = null; existing.unsubscribeSession = null;
} }
for (const waiter of existing.foregroundTurnWaiters) { this.foregroundRuns.clearAgent(agentId, existing);
this.settleForegroundTurnWaiter(waiter);
}
existing.foregroundTurnWaiters.clear();
this.settlePendingForegroundRun(agentId);
await this.closeReloadedSession(existing.session, agentId); await this.closeReloadedSession(existing.session, agentId);
// Preserve existing labels and timeline during reload. // Preserve existing labels and timeline during reload.
@@ -1291,18 +1272,18 @@ export class AgentManager {
agentId, agentId,
lifecycle: existingAgent.lifecycle, lifecycle: existingAgent.lifecycle,
activeForegroundTurnId: existingAgent.activeForegroundTurnId, activeForegroundTurnId: existingAgent.activeForegroundTurnId,
hasPendingForegroundRun: this.hasPendingForegroundRun(agentId), hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId),
promptType: typeof prompt === "string" ? "string" : "structured", promptType: typeof prompt === "string" ? "string" : "structured",
hasRunOptions: Boolean(options), hasRunOptions: Boolean(options),
}, },
"streamAgent: requested", "streamAgent: requested",
); );
if (existingAgent.activeForegroundTurnId || this.hasPendingForegroundRun(agentId)) { if (existingAgent.activeForegroundTurnId || this.foregroundRuns.hasPendingRun(agentId)) {
this.logger.trace( this.logger.trace(
{ {
agentId, agentId,
lifecycle: existingAgent.lifecycle, lifecycle: existingAgent.lifecycle,
hasPendingForegroundRun: this.hasPendingForegroundRun(agentId), hasPendingForegroundRun: this.foregroundRuns.hasPendingRun(agentId),
}, },
"streamAgent: rejected because a foreground run is already in flight", "streamAgent: rejected because a foreground run is already in flight",
); );
@@ -1313,12 +1294,11 @@ export class AgentManager {
agent.pendingReplacement = false; agent.pendingReplacement = false;
agent.lastError = undefined; agent.lastError = undefined;
const pendingRun = this.createPendingForegroundRun(); const pendingRun = this.foregroundRuns.createPendingRun(agentId);
this.pendingForegroundRuns.set(agentId, pendingRun);
const streamForwarder = async function* streamForwarder(this: AgentManager) { const streamForwarder = async function* streamForwarder(this: AgentManager) {
let turnId: string; let turnId: string;
let waiter: ForegroundTurnWaiter | null = null; let turnStream: ReturnType<ForegroundRunState["createTurnStream"]> | null = null;
try { try {
const result = await agent.session.startTurn(prompt, options); const result = await agent.session.startTurn(prompt, options);
turnId = result.turnId; turnId = result.turnId;
@@ -1330,7 +1310,7 @@ export class AgentManager {
error: errorMsg, error: errorMsg,
}); });
this.finalizeForegroundTurn(agent); this.finalizeForegroundTurn(agent);
this.settlePendingForegroundRun(agentId, pendingRun.token); this.foregroundRuns.settlePendingRun(agentId, pendingRun.token);
throw error; throw error;
} }
@@ -1348,55 +1328,18 @@ export class AgentManager {
"streamAgent: started", "streamAgent: started",
); );
// Create a pushable queue for this foreground turn turnStream = this.foregroundRuns.createTurnStream(turnId);
const queue: AgentStreamEvent[] = []; this.foregroundRuns.addWaiter(agent, turnStream.waiter);
let queueResolve: (() => void) | null = null;
let done = false;
let resolveSettled!: () => void;
const settledPromise = new Promise<void>((resolvePromise) => {
resolveSettled = resolvePromise;
});
waiter = {
turnId,
settled: false,
settledPromise,
resolveSettled,
callback: (event: AgentStreamEvent) => {
queue.push(event);
if (queueResolve) {
queueResolve();
queueResolve = null;
}
},
};
agent.foregroundTurnWaiters.add(waiter);
try { try {
while (!done) { for await (const event of turnStream.events(isTurnTerminalEvent)) {
while (queue.length > 0) { yield event;
const event = queue.shift()!;
yield event;
if (isTurnTerminalEvent(event)) {
done = true;
break;
}
}
if (!done && queue.length === 0) {
if (waiter.settled) {
break;
}
await new Promise<void>((resolvePromise) => {
queueResolve = resolvePromise;
});
}
} }
} finally { } finally {
if (waiter) { if (turnStream) {
agent.foregroundTurnWaiters.delete(waiter); this.foregroundRuns.deleteWaiter(agent, turnStream.waiter);
this.settleForegroundTurnWaiter(waiter);
} }
this.settlePendingForegroundRun(agentId, pendingRun.token); this.foregroundRuns.settlePendingRun(agentId, pendingRun.token);
if (!agent.activeForegroundTurnId) { if (!agent.activeForegroundTurnId) {
await this.refreshRuntimeInfo(agent); await this.refreshRuntimeInfo(agent);
} }
@@ -1409,7 +1352,7 @@ export class AgentManager {
private finalizeForegroundTurn(agent: ActiveManagedAgent, turnId?: string): void { private finalizeForegroundTurn(agent: ActiveManagedAgent, turnId?: string): void {
const mutableAgent = agent as ActiveManagedAgent; const mutableAgent = agent as ActiveManagedAgent;
if (turnId) { if (turnId) {
this.rememberFinalizedForegroundTurn(mutableAgent, turnId); this.foregroundRuns.rememberFinalizedTurn(mutableAgent, turnId);
} }
mutableAgent.activeForegroundTurnId = null; mutableAgent.activeForegroundTurnId = null;
const terminalError = mutableAgent.lastError; const terminalError = mutableAgent.lastError;
@@ -1455,7 +1398,7 @@ export class AgentManager {
if ( if (
snapshot.lifecycle !== "running" && snapshot.lifecycle !== "running" &&
!snapshot.activeForegroundTurnId && !snapshot.activeForegroundTurnId &&
!this.hasPendingForegroundRun(agentId) !this.foregroundRuns.hasPendingRun(agentId)
) { ) {
return this.streamAgent(agentId, prompt, options); return this.streamAgent(agentId, prompt, options);
} }
@@ -1495,7 +1438,7 @@ export class AgentManager {
throw new Error(`Agent ${agentId} not found`); throw new Error(`Agent ${agentId} not found`);
} }
const pendingRun = this.getPendingForegroundRun(agentId); const pendingRun = this.foregroundRuns.getPendingRun(agentId);
if ((snapshot.lifecycle === "running" || pendingRun?.started) && !snapshot.pendingReplacement) { if ((snapshot.lifecycle === "running" || pendingRun?.started) && !snapshot.pendingReplacement) {
return; return;
} }
@@ -1559,7 +1502,7 @@ export class AgentManager {
return true; return true;
} }
const currentPendingRun = this.getPendingForegroundRun(agentId); const currentPendingRun = this.foregroundRuns.getPendingRun(agentId);
if ( if (
(current.lifecycle === "running" || currentPendingRun?.started) && (current.lifecycle === "running" || currentPendingRun?.started) &&
!current.pendingReplacement !current.pendingReplacement
@@ -1632,7 +1575,7 @@ export class AgentManager {
async cancelAgentRun(agentId: string): Promise<boolean> { async cancelAgentRun(agentId: string): Promise<boolean> {
const agent = this.requireSessionAgent(agentId); const agent = this.requireSessionAgent(agentId);
const pendingRun = this.getPendingForegroundRun(agentId); const pendingRun = this.foregroundRuns.getPendingRun(agentId);
const foregroundTurnId = agent.activeForegroundTurnId; const foregroundTurnId = agent.activeForegroundTurnId;
const hasForegroundTurn = Boolean(foregroundTurnId); const hasForegroundTurn = Boolean(foregroundTurnId);
const isAutonomousRunning = agent.lifecycle === "running" && !hasForegroundTurn && !pendingRun; const isAutonomousRunning = agent.lifecycle === "running" && !hasForegroundTurn && !pendingRun;
@@ -1701,7 +1644,7 @@ export class AgentManager {
}); });
// The synthetic event unblocks the streamForwarder generator, whose finally // The synthetic event unblocks the streamForwarder generator, whose finally
// block settles the pending foreground run asynchronously. Wait for it. // block settles the pending foreground run asynchronously. Wait for it.
const staleRun = this.getPendingForegroundRun(agentId); const staleRun = this.foregroundRuns.getPendingRun(agentId);
if (staleRun && !staleRun.settled) { if (staleRun && !staleRun.settled) {
await staleRun.settledPromise; await staleRun.settledPromise;
} }
@@ -1876,7 +1819,7 @@ export class AgentManager {
throw new Error(`Agent ${agentId} not found`); throw new Error(`Agent ${agentId} not found`);
} }
const pendingForegroundRun = this.getPendingForegroundRun(agentId); const pendingForegroundRun = this.foregroundRuns.getPendingRun(agentId);
const hasForegroundTurn = const hasForegroundTurn =
Boolean(snapshot.activeForegroundTurnId) || Boolean(pendingForegroundRun); Boolean(snapshot.activeForegroundTurnId) || Boolean(pendingForegroundRun);
@@ -2198,17 +2141,13 @@ export class AgentManager {
agent.unsubscribeSession(); agent.unsubscribeSession();
agent.unsubscribeSession = null; agent.unsubscribeSession = null;
} }
for (const waiter of agent.foregroundTurnWaiters) { this.foregroundRuns.cancelWaiters(agent, (turnId) => ({
waiter.callback({ type: "turn_canceled",
type: "turn_canceled", provider: agent.provider,
provider: agent.provider, reason: cancelReason,
reason: cancelReason, turnId,
turnId: waiter.turnId, }));
}); this.foregroundRuns.settlePendingRun(agent.id);
this.settleForegroundTurnWaiter(waiter);
}
agent.foregroundTurnWaiters.clear();
this.settlePendingForegroundRun(agent.id);
return { return {
...agent, ...agent,
lifecycle: "closed", lifecycle: "closed",
@@ -2267,12 +2206,7 @@ export class AgentManager {
event: AgentStreamEvent, event: AgentStreamEvent,
): Promise<void> { ): Promise<void> {
const turnId = (event as { turnId?: string }).turnId; const turnId = (event as { turnId?: string }).turnId;
const matchingWaiters = const matchingWaiters = this.foregroundRuns.getMatchingWaiters(agent, turnId);
turnId == null
? []
: Array.from(agent.foregroundTurnWaiters).filter(
(waiter) => waiter.turnId === turnId && !waiter.settled,
);
const shouldNotifyWaiters = await this.handleStreamEvent(agent, event); const shouldNotifyWaiters = await this.handleStreamEvent(agent, event);
@@ -2280,70 +2214,9 @@ export class AgentManager {
return; return;
} }
for (const waiter of matchingWaiters) { this.foregroundRuns.notifyWaiters(matchingWaiters, event, {
waiter.callback(event); terminal: isTurnTerminalEvent(event),
if (isTurnTerminalEvent(event)) {
this.settleForegroundTurnWaiter(waiter);
}
}
}
private settleForegroundTurnWaiter(waiter: ForegroundTurnWaiter): void {
if (waiter.settled) {
return;
}
waiter.settled = true;
waiter.resolveSettled();
}
private rememberFinalizedForegroundTurn(agent: ActiveManagedAgent, turnId: string): void {
agent.finalizedForegroundTurnIds.add(turnId);
if (agent.finalizedForegroundTurnIds.size <= 50) {
return;
}
const oldest = agent.finalizedForegroundTurnIds.values().next().value;
if (oldest) {
agent.finalizedForegroundTurnIds.delete(oldest);
}
}
private createPendingForegroundRun(): PendingForegroundRun {
let resolveSettled!: () => void;
const settledPromise = new Promise<void>((resolvePromise) => {
resolveSettled = resolvePromise;
}); });
return {
token: randomUUID(),
started: false,
settled: false,
settledPromise,
resolveSettled,
};
}
private getPendingForegroundRun(agentId: string): PendingForegroundRun | null {
return this.pendingForegroundRuns.get(agentId) ?? null;
}
private hasPendingForegroundRun(agentId: string): boolean {
return this.pendingForegroundRuns.has(agentId);
}
private settlePendingForegroundRun(agentId: string, token?: string): void {
const pendingRun = this.pendingForegroundRuns.get(agentId);
if (!pendingRun) {
return;
}
if (token && pendingRun.token !== token) {
return;
}
this.pendingForegroundRuns.delete(agentId);
if (pendingRun.settled) {
return;
}
pendingRun.settled = true;
pendingRun.resolveSettled();
} }
private async resolveInitialPersistedTitle( private async resolveInitialPersistedTitle(
@@ -2466,11 +2339,7 @@ export class AgentManager {
return; return;
} }
for (const waiter of agent.foregroundTurnWaiters) { this.foregroundRuns.notifyAgentWaiters(agent, event);
if (waiter.turnId === turnId && !waiter.settled) {
waiter.callback(event);
}
}
} }
private async handleStreamEvent( private async handleStreamEvent(
@@ -2483,7 +2352,7 @@ export class AgentManager {
if ( if (
eventTurnId && eventTurnId &&
isTurnTerminalEvent(event) && isTurnTerminalEvent(event) &&
agent.finalizedForegroundTurnIds.has(eventTurnId) this.foregroundRuns.hasFinalizedTurn(agent, eventTurnId)
) { ) {
return false; return false;
} }
@@ -0,0 +1,232 @@
import { randomUUID } from "node:crypto";
import type { AgentStreamEvent } from "./agent-sdk-types.js";
export interface ForegroundTurnWaiter {
turnId: string;
callback: (event: AgentStreamEvent) => void;
settled: boolean;
settledPromise: Promise<void>;
resolveSettled: () => void;
}
export interface PendingForegroundRun {
token: string;
started: boolean;
settled: boolean;
settledPromise: Promise<void>;
resolveSettled: () => void;
}
export interface ForegroundRunAgentState {
foregroundTurnWaiters: Set<ForegroundTurnWaiter>;
finalizedForegroundTurnIds: Set<string>;
}
export class ForegroundRunState {
private readonly pendingRuns = new Map<string, PendingForegroundRun>();
createPendingRun(agentId: string): PendingForegroundRun {
const pendingRun = createPendingForegroundRun();
this.pendingRuns.set(agentId, pendingRun);
return pendingRun;
}
getPendingRun(agentId: string): PendingForegroundRun | null {
return this.pendingRuns.get(agentId) ?? null;
}
hasPendingRun(agentId: string): boolean {
return this.pendingRuns.has(agentId);
}
settlePendingRun(agentId: string, token?: string): void {
const pendingRun = this.pendingRuns.get(agentId);
if (!pendingRun) {
return;
}
if (token && pendingRun.token !== token) {
return;
}
this.pendingRuns.delete(agentId);
settlePendingForegroundRun(pendingRun);
}
createTurnStream(turnId: string): ForegroundTurnStream {
return new ForegroundTurnStream(turnId);
}
addWaiter(agent: ForegroundRunAgentState, waiter: ForegroundTurnWaiter): void {
agent.foregroundTurnWaiters.add(waiter);
}
deleteWaiter(agent: ForegroundRunAgentState, waiter: ForegroundTurnWaiter): void {
agent.foregroundTurnWaiters.delete(waiter);
this.settleWaiter(waiter);
}
settleWaiter(waiter: ForegroundTurnWaiter): void {
if (waiter.settled) {
return;
}
waiter.settled = true;
waiter.resolveSettled();
}
getMatchingWaiters(
agent: ForegroundRunAgentState,
turnId: string | undefined,
): ForegroundTurnWaiter[] {
if (turnId == null) {
return [];
}
return Array.from(agent.foregroundTurnWaiters).filter(
(waiter) => waiter.turnId === turnId && !waiter.settled,
);
}
notifyWaiters(
waiters: Iterable<ForegroundTurnWaiter>,
event: AgentStreamEvent,
options: { terminal: boolean },
): void {
for (const waiter of waiters) {
waiter.callback(event);
if (options.terminal) {
this.settleWaiter(waiter);
}
}
}
notifyAgentWaiters(
agent: ForegroundRunAgentState,
event: AgentStreamEvent,
options?: { terminal?: boolean },
): void {
const waiters = this.getMatchingWaiters(agent, eventTurnId(event));
this.notifyWaiters(waiters, event, { terminal: options?.terminal ?? false });
}
cancelWaiters(
agent: ForegroundRunAgentState,
createEvent: (turnId: string) => AgentStreamEvent,
): void {
for (const waiter of agent.foregroundTurnWaiters) {
waiter.callback(createEvent(waiter.turnId));
this.settleWaiter(waiter);
}
agent.foregroundTurnWaiters.clear();
}
clearAgent(agentId: string, agent: ForegroundRunAgentState): void {
for (const waiter of agent.foregroundTurnWaiters) {
this.settleWaiter(waiter);
}
agent.foregroundTurnWaiters.clear();
this.settlePendingRun(agentId);
}
rememberFinalizedTurn(agent: ForegroundRunAgentState, turnId: string): void {
agent.finalizedForegroundTurnIds.add(turnId);
if (agent.finalizedForegroundTurnIds.size <= 50) {
return;
}
const oldest = agent.finalizedForegroundTurnIds.values().next().value;
if (oldest) {
agent.finalizedForegroundTurnIds.delete(oldest);
}
}
hasFinalizedTurn(agent: ForegroundRunAgentState, turnId: string): boolean {
return agent.finalizedForegroundTurnIds.has(turnId);
}
}
export class ForegroundTurnStream {
private readonly queue: AgentStreamEvent[] = [];
private queueResolve: (() => void) | null = null;
readonly waiter: ForegroundTurnWaiter;
constructor(turnId: string) {
let resolveSettled!: () => void;
const settledPromise = new Promise<void>((resolvePromise) => {
resolveSettled = resolvePromise;
});
this.waiter = {
turnId,
settled: false,
settledPromise,
resolveSettled,
callback: (event) => {
this.queue.push(event);
this.wake();
},
};
}
async *events(
isTerminalEvent: (event: AgentStreamEvent) => boolean,
): AsyncGenerator<AgentStreamEvent> {
let done = false;
while (!done) {
while (this.queue.length > 0) {
const event = this.queue.shift()!;
yield event;
if (isTerminalEvent(event)) {
done = true;
break;
}
}
if (!done && this.queue.length === 0) {
if (this.waiter.settled) {
break;
}
await new Promise<void>((resolvePromise) => {
this.queueResolve = resolvePromise;
});
}
}
}
private wake(): void {
if (!this.queueResolve) {
return;
}
this.queueResolve();
this.queueResolve = null;
}
}
function createPendingForegroundRun(): PendingForegroundRun {
let resolveSettled!: () => void;
const settledPromise = new Promise<void>((resolvePromise) => {
resolveSettled = resolvePromise;
});
return {
token: randomUUID(),
started: false,
settled: false,
settledPromise,
resolveSettled,
};
}
function settlePendingForegroundRun(pendingRun: PendingForegroundRun): void {
if (pendingRun.settled) {
return;
}
pendingRun.settled = true;
pendingRun.resolveSettled();
}
function eventTurnId(event: AgentStreamEvent): string | undefined {
return (event as { turnId?: string }).turnId;
}