refactor: convert ManagedAgent to discriminated unions and remove AgentSnapshot

Replace ManagedAgent with discriminated union keyed on lifecycle state
to make impossible states unrepresentable. Remove AgentSnapshot type
and toSnapshot method entirely - consumers now receive ManagedAgent
directly.

- Define ManagedAgent as discriminated union with 5 lifecycle states
- Add comprehensive immutability protection (deep clone + freeze)
- Update getAgent/listAgents to return immutable ManagedAgent views
- Remove AgentSnapshot type and toSnapshot method
- Update event emissions to use ManagedAgent

Breaking change: All consumers must now use ManagedAgent or projection
functions instead of AgentSnapshot.

Tasks 2 & 3 of 8 in agent architecture refactor.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2025-11-30 00:02:47 +00:00
co-authored by Claude
parent 2794a5b63c
commit 8a708aab8b
+278 -88
View File
@@ -33,27 +33,8 @@ export const AGENT_LIFECYCLE_STATUSES = [
export type AgentLifecycleStatus = export type AgentLifecycleStatus =
(typeof AGENT_LIFECYCLE_STATUSES)[number]; (typeof AGENT_LIFECYCLE_STATUSES)[number];
export type AgentSnapshot = {
id: string;
provider: AgentProvider;
cwd: string;
model: string | null;
createdAt: Date;
updatedAt: Date;
lastUserMessageAt: Date | null;
status: AgentLifecycleStatus;
sessionId: string | null;
capabilities: AgentCapabilityFlags;
currentModeId: string | null;
availableModes: AgentMode[];
pendingPermissions: AgentPermissionRequest[];
persistence: AgentPersistenceHandle | null;
lastUsage?: AgentUsage;
lastError?: string;
};
export type AgentManagerEvent = export type AgentManagerEvent =
| { type: "agent_state"; agent: AgentSnapshot } | { type: "agent_state"; agent: ManagedAgent }
| { type: "agent_stream"; agentId: string; event: AgentStreamEvent }; | { type: "agent_stream"; agentId: string; event: AgentStreamEvent };
export type AgentSubscriber = (event: AgentManagerEvent) => void; export type AgentSubscriber = (event: AgentManagerEvent) => void;
@@ -83,29 +64,70 @@ export type WaitForAgentResult = {
lastMessage: string | null; lastMessage: string | null;
}; };
type ManagedAgent = { type ManagedAgentBase = {
id: string; id: string;
provider: AgentProvider; provider: AgentProvider;
cwd: string; cwd: string;
session: AgentSession;
sessionId: string | null;
capabilities: AgentCapabilityFlags; capabilities: AgentCapabilityFlags;
config: AgentSessionConfig; config: AgentSessionConfig;
status: AgentLifecycleStatus;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
availableModes: AgentMode[]; availableModes: AgentMode[];
currentModeId: string | null; currentModeId: string | null;
pendingPermissions: Map<string, AgentPermissionRequest>; pendingPermissions: Map<string, AgentPermissionRequest>;
pendingRun: AsyncGenerator<AgentStreamEvent> | null;
timeline: AgentTimelineItem[]; timeline: AgentTimelineItem[];
persistence: AgentPersistenceHandle | null; persistence: AgentPersistenceHandle | null;
lastUsage?: AgentUsage;
lastError?: string;
historyPrimed: boolean; historyPrimed: boolean;
lastUserMessageAt: Date | null; lastUserMessageAt: Date | null;
sessionId: string | null;
lastUsage?: AgentUsage;
lastError?: string;
}; };
type ManagedAgentWithSession = ManagedAgentBase & {
session: AgentSession;
};
type ManagedAgentInitializing = ManagedAgentWithSession & {
lifecycle: "initializing";
pendingRun: null;
};
type ManagedAgentIdle = ManagedAgentWithSession & {
lifecycle: "idle";
pendingRun: null;
};
type ManagedAgentRunning = ManagedAgentWithSession & {
lifecycle: "running";
pendingRun: AsyncGenerator<AgentStreamEvent>;
};
type ManagedAgentError = ManagedAgentWithSession & {
lifecycle: "error";
pendingRun: null;
lastError: string;
};
type ManagedAgentClosed = ManagedAgentBase & {
lifecycle: "closed";
session: null;
pendingRun: null;
};
export type ManagedAgent =
| ManagedAgentInitializing
| ManagedAgentIdle
| ManagedAgentRunning
| ManagedAgentError
| ManagedAgentClosed;
type ActiveManagedAgent =
| ManagedAgentInitializing
| ManagedAgentIdle
| ManagedAgentRunning
| ManagedAgentError;
type SubscriptionRecord = { type SubscriptionRecord = {
callback: AgentSubscriber; callback: AgentSubscriber;
agentId: string | null; agentId: string | null;
@@ -117,6 +139,23 @@ const BUSY_STATUSES: AgentLifecycleStatus[] = [
"running", "running",
]; ];
const READ_ONLY_AGENT_ERROR_MESSAGE =
"ManagedAgent views returned by AgentManager are read-only";
class ReadonlyPendingPermissionsMap<K, V> extends Map<K, V> {
override set(): this {
throw new Error(READ_ONLY_AGENT_ERROR_MESSAGE);
}
override delete(): boolean {
throw new Error(READ_ONLY_AGENT_ERROR_MESSAGE);
}
override clear(): void {
throw new Error(READ_ONLY_AGENT_ERROR_MESSAGE);
}
}
function isAgentBusy(status: AgentLifecycleStatus): boolean { function isAgentBusy(status: AgentLifecycleStatus): boolean {
return BUSY_STATUSES.includes(status); return BUSY_STATUSES.includes(status);
} }
@@ -137,7 +176,7 @@ function createAbortError(
export class AgentManager { export class AgentManager {
private readonly clients = new Map<AgentProvider, AgentClient>(); private readonly clients = new Map<AgentProvider, AgentClient>();
private readonly agents = new Map<string, ManagedAgent>(); private readonly agents = new Map<string, ActiveManagedAgent>();
private readonly subscribers = new Set<SubscriptionRecord>(); private readonly subscribers = new Set<SubscriptionRecord>();
private readonly maxTimelineItems: number; private readonly maxTimelineItems: number;
private readonly idFactory: () => string; private readonly idFactory: () => string;
@@ -170,11 +209,17 @@ export class AgentManager {
if (record.agentId) { if (record.agentId) {
const agent = this.agents.get(record.agentId); const agent = this.agents.get(record.agentId);
if (agent) { if (agent) {
callback({ type: "agent_state", agent: this.toSnapshot(agent) }); callback({
type: "agent_state",
agent: this.createAgentView(agent),
});
} }
} else { } else {
for (const agent of this.agents.values()) { for (const agent of this.agents.values()) {
callback({ type: "agent_state", agent: this.toSnapshot(agent) }); callback({
type: "agent_state",
agent: this.createAgentView(agent),
});
} }
} }
} }
@@ -184,9 +229,9 @@ export class AgentManager {
}; };
} }
listAgents(): AgentSnapshot[] { listAgents(): ManagedAgent[] {
return Array.from(this.agents.values()).map((agent) => return Array.from(this.agents.values()).map((agent) =>
this.toSnapshot(agent) this.createAgentView(agent)
); );
} }
@@ -228,9 +273,9 @@ export class AgentManager {
.slice(0, limit); .slice(0, limit);
} }
getAgent(id: string): AgentSnapshot | null { getAgent(id: string): ManagedAgent | null {
const agent = this.agents.get(id); const agent = this.agents.get(id);
return agent ? this.toSnapshot(agent) : null; return agent ? this.createAgentView(agent) : null;
} }
getTimeline(id: string): AgentTimelineItem[] { getTimeline(id: string): AgentTimelineItem[] {
@@ -241,7 +286,7 @@ export class AgentManager {
async createAgent( async createAgent(
config: AgentSessionConfig, config: AgentSessionConfig,
agentId?: string agentId?: string
): Promise<AgentSnapshot> { ): Promise<ManagedAgent> {
const normalizedConfig = await this.normalizeConfig(config); const normalizedConfig = await this.normalizeConfig(config);
const client = this.requireClient(normalizedConfig.provider); const client = this.requireClient(normalizedConfig.provider);
const session = await client.createSession(normalizedConfig); const session = await client.createSession(normalizedConfig);
@@ -256,7 +301,7 @@ export class AgentManager {
handle: AgentPersistenceHandle, handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>, overrides?: Partial<AgentSessionConfig>,
agentId?: string agentId?: string
): Promise<AgentSnapshot> { ): Promise<ManagedAgent> {
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>; const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
const mergedConfig = { const mergedConfig = {
...metadata, ...metadata,
@@ -277,7 +322,7 @@ export class AgentManager {
); );
} }
async refreshAgentFromPersistence(agentId: string): Promise<AgentSnapshot> { async refreshAgentFromPersistence(agentId: string): Promise<ManagedAgent> {
const existing = this.requireAgent(agentId); const existing = this.requireAgent(agentId);
const handle = existing.persistence; const handle = existing.persistence;
if (!handle) { if (!handle) {
@@ -311,9 +356,16 @@ export class AgentManager {
async closeAgent(agentId: string): Promise<void> { async closeAgent(agentId: string): Promise<void> {
const agent = this.requireAgent(agentId); const agent = this.requireAgent(agentId);
this.agents.delete(agentId); this.agents.delete(agentId);
agent.status = "closed"; const session = agent.session;
await agent.session.close(); const closedAgent: ManagedAgent = {
this.emitState(agent); ...agent,
lifecycle: "closed",
session: null,
pendingRun: null,
sessionId: agent.sessionId,
};
await session.close();
this.emitState(closedAgent);
} }
async setAgentMode(agentId: string, modeId: string): Promise<void> { async setAgentMode(agentId: string, modeId: string): Promise<void> {
@@ -382,16 +434,14 @@ export class AgentManager {
prompt: AgentPromptInput, prompt: AgentPromptInput,
options?: AgentRunOptions options?: AgentRunOptions
): AsyncGenerator<AgentStreamEvent> { ): AsyncGenerator<AgentStreamEvent> {
const agent = this.requireAgent(agentId); const existingAgent = this.requireAgent(agentId);
if (agent.status === "closed") { if (existingAgent.pendingRun) {
throw new Error(`Agent ${agentId} is closed`);
}
if (agent.pendingRun) {
throw new Error(`Agent ${agentId} already has an active run`); throw new Error(`Agent ${agentId} already has an active run`);
} }
const agent = existingAgent as ActiveManagedAgent;
const iterator = agent.session.stream(prompt, options); const iterator = agent.session.stream(prompt, options);
agent.status = "running"; agent.lifecycle = "running";
agent.pendingRun = iterator; agent.pendingRun = iterator;
agent.lastError = undefined; agent.lastError = undefined;
this.emitState(agent); this.emitState(agent);
@@ -410,11 +460,12 @@ export class AgentManager {
return; return;
} }
agent.pendingRun = null; const mutableAgent = agent as ActiveManagedAgent;
agent.status = error ? "error" : "idle"; mutableAgent.pendingRun = null;
agent.lastError = error; mutableAgent.lifecycle = error ? "error" : "idle";
agent.persistence = agent.session.describePersistence(); mutableAgent.lastError = error;
this.emitState(agent); mutableAgent.persistence = mutableAgent.session.describePersistence();
this.emitState(mutableAgent);
}; };
const self = this; const self = this;
@@ -491,6 +542,11 @@ export class AgentManager {
return Array.from(agent.pendingPermissions.values()); return Array.from(agent.pendingPermissions.values());
} }
private peekPendingPermission(agent: ManagedAgent): AgentPermissionRequest | null {
const iterator = agent.pendingPermissions.values().next();
return iterator.done ? null : iterator.value;
}
async primeAgentHistory(agentId: string): Promise<void> { async primeAgentHistory(agentId: string): Promise<void> {
const agent = this.requireAgent(agentId); const agent = this.requireAgent(agentId);
await this.primeHistory(agent); await this.primeHistory(agent);
@@ -532,18 +588,18 @@ export class AgentManager {
} }
const immediatePermission = snapshot.pendingPermissions[0] ?? null; const immediatePermission = this.peekPendingPermission(snapshot);
if (immediatePermission) { if (immediatePermission) {
return { return {
status: snapshot.status, status: snapshot.lifecycle,
permission: immediatePermission, permission: immediatePermission,
lastMessage: this.getLastAssistantMessage(agentId) lastMessage: this.getLastAssistantMessage(agentId)
}; };
} }
if (!isAgentBusy(snapshot.status)) { if (!isAgentBusy(snapshot.lifecycle)) {
return { return {
status: snapshot.status, status: snapshot.lifecycle,
permission: null, permission: null,
lastMessage: this.getLastAssistantMessage(agentId) lastMessage: this.getLastAssistantMessage(agentId)
}; };
@@ -562,7 +618,7 @@ export class AgentManager {
return; return;
} }
let currentStatus: AgentLifecycleStatus = snapshot.status; let currentStatus: AgentLifecycleStatus = snapshot.lifecycle;
// Bug #3 Fix: Declare unsubscribe and abortHandler upfront so cleanup can reference them // Bug #3 Fix: Declare unsubscribe and abortHandler upfront so cleanup can reference them
let unsubscribe: (() => void) | null = null; let unsubscribe: (() => void) | null = null;
@@ -615,13 +671,13 @@ export class AgentManager {
(event) => { (event) => {
// Bug #2 Fix: Only handle agent_state events, remove redundant agent_stream handling // Bug #2 Fix: Only handle agent_state events, remove redundant agent_stream handling
if (event.type === "agent_state") { if (event.type === "agent_state") {
currentStatus = event.agent.status; currentStatus = event.agent.lifecycle;
const pending = event.agent.pendingPermissions[0] ?? null; const pending = this.peekPendingPermission(event.agent);
if (pending) { if (pending) {
finish(pending); finish(pending);
return; return;
} }
if (!isAgentBusy(event.agent.status)) { if (!isAgentBusy(event.agent.lifecycle)) {
finish(null); finish(null);
} }
} }
@@ -635,12 +691,12 @@ export class AgentManager {
session: AgentSession, session: AgentSession,
config: AgentSessionConfig, config: AgentSessionConfig,
agentId: string agentId: string
): Promise<AgentSnapshot> { ): Promise<ManagedAgent> {
if (this.agents.has(agentId)) { if (this.agents.has(agentId)) {
throw new Error(`Agent with id ${agentId} already exists`); throw new Error(`Agent with id ${agentId} already exists`);
} }
const managed: ManagedAgent = { const managed = {
id: agentId, id: agentId,
provider: config.provider, provider: config.provider,
cwd: config.cwd, cwd: config.cwd,
@@ -648,7 +704,7 @@ export class AgentManager {
sessionId: session.id, sessionId: session.id,
capabilities: session.capabilities, capabilities: session.capabilities,
config, config,
status: "initializing", lifecycle: "initializing",
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
availableModes: [], availableModes: [],
@@ -659,18 +715,18 @@ export class AgentManager {
persistence: session.describePersistence(), persistence: session.describePersistence(),
historyPrimed: false, historyPrimed: false,
lastUserMessageAt: null, lastUserMessageAt: null,
}; } as ActiveManagedAgent;
this.agents.set(agentId, managed); this.agents.set(agentId, managed);
this.emitState(managed); this.emitState(managed);
await this.refreshSessionState(managed); await this.refreshSessionState(managed);
managed.status = "idle"; managed.lifecycle = "idle";
this.emitState(managed); this.emitState(managed);
return this.toSnapshot(managed); return this.createAgentView(managed);
} }
private async refreshSessionState(agent: ManagedAgent): Promise<void> { private async refreshSessionState(agent: ActiveManagedAgent): Promise<void> {
try { try {
const modes = await agent.session.getAvailableModes(); const modes = await agent.session.getAvailableModes();
agent.availableModes = modes; agent.availableModes = modes;
@@ -694,7 +750,7 @@ export class AgentManager {
} }
} }
private async primeHistory(agent: ManagedAgent): Promise<void> { private async primeHistory(agent: ActiveManagedAgent): Promise<void> {
if (agent.historyPrimed) { if (agent.historyPrimed) {
return; return;
} }
@@ -709,7 +765,7 @@ export class AgentManager {
} }
private handleStreamEvent( private handleStreamEvent(
agent: ManagedAgent, agent: ActiveManagedAgent,
event: AgentStreamEvent, event: AgentStreamEvent,
options?: { fromHistory?: boolean } options?: { fromHistory?: boolean }
): void { ): void {
@@ -763,7 +819,10 @@ export class AgentManager {
} }
private emitState(agent: ManagedAgent): void { private emitState(agent: ManagedAgent): void {
this.dispatch({ type: "agent_state", agent: this.toSnapshot(agent) }); this.dispatch({
type: "agent_state",
agent: this.createAgentView(agent),
});
} }
private dispatchStream(agentId: string, event: AgentStreamEvent): void { private dispatchStream(agentId: string, event: AgentStreamEvent): void {
@@ -790,25 +849,156 @@ export class AgentManager {
} }
} }
private toSnapshot(agent: ManagedAgent): AgentSnapshot { private createAgentView(agent: ManagedAgent): ManagedAgent {
const clone = this.cloneAgent(agent);
clone.pendingPermissions = this.createReadonlyPendingPermissionsMap(
clone.pendingPermissions
);
this.deepFreezeValue(clone.availableModes);
this.deepFreezeValue(clone.timeline);
this.deepFreezeValue(clone.capabilities);
this.deepFreezeValue(clone.config);
if (clone.persistence) {
this.deepFreezeValue(clone.persistence);
}
if (clone.lastUsage) {
this.deepFreezeValue(clone.lastUsage);
}
if (clone.lastUserMessageAt) {
Object.freeze(clone.lastUserMessageAt);
}
Object.freeze(clone.createdAt);
Object.freeze(clone.updatedAt);
return Object.freeze(clone) as ManagedAgent;
}
private cloneAgent(agent: ManagedAgent): ManagedAgent {
return { return {
id: agent.id, ...agent,
provider: agent.provider, createdAt: new Date(agent.createdAt.getTime()),
cwd: agent.cwd, updatedAt: new Date(agent.updatedAt.getTime()),
model: agent.config.model ?? null, lastUserMessageAt: agent.lastUserMessageAt
createdAt: agent.createdAt, ? new Date(agent.lastUserMessageAt.getTime())
updatedAt: agent.updatedAt, : null,
lastUserMessageAt: agent.lastUserMessageAt, pendingPermissions: new Map(
status: agent.status, Array.from(agent.pendingPermissions.entries()).map(
sessionId: agent.sessionId, ([key, value]) => [key, this.cloneValue(value)]
capabilities: agent.capabilities, )
currentModeId: agent.currentModeId, ),
availableModes: agent.availableModes, availableModes: this.cloneValue(agent.availableModes),
pendingPermissions: Array.from(agent.pendingPermissions.values()), timeline: this.cloneValue(agent.timeline),
persistence: agent.persistence, capabilities: this.cloneValue(agent.capabilities),
lastUsage: agent.lastUsage, config: this.cloneValue(agent.config),
lastError: agent.lastError, persistence: agent.persistence
}; ? this.cloneValue(agent.persistence)
: agent.persistence,
lastUsage: agent.lastUsage ? this.cloneValue(agent.lastUsage) : undefined,
} as ManagedAgent;
}
private createReadonlyPendingPermissionsMap(
source: Map<string, AgentPermissionRequest>
): Map<string, AgentPermissionRequest> {
const readonlyMap = new ReadonlyPendingPermissionsMap(source);
for (const request of readonlyMap.values()) {
this.deepFreezeValue(request);
}
return Object.freeze(readonlyMap) as Map<string, AgentPermissionRequest>;
}
private cloneValue<T>(value: T): T {
if (value === null || typeof value !== "object") {
return value;
}
if (value instanceof Date) {
return new Date(value.getTime()) as T;
}
if (Array.isArray(value)) {
return value.map((item) => this.cloneValue(item)) as T;
}
if (value instanceof Map) {
const clone = new Map();
for (const [key, entryValue] of value.entries()) {
clone.set(this.cloneValue(key), this.cloneValue(entryValue));
}
return clone as T;
}
if (value instanceof Set) {
const clone = new Set();
for (const entryValue of value.values()) {
clone.add(this.cloneValue(entryValue));
}
return clone as T;
}
if (!this.isPlainObject(value)) {
return value;
}
const clonedObject: Record<string, unknown> = {};
for (const [key, entryValue] of Object.entries(value)) {
clonedObject[key] = this.cloneValue(entryValue);
}
return clonedObject as T;
}
private deepFreezeValue<T>(value: T, seen = new WeakSet<object>()): T {
if (value === null || typeof value !== "object") {
return value;
}
if (value instanceof Date) {
Object.freeze(value);
return value;
}
const objectValue = value as object;
if (seen.has(objectValue)) {
return value;
}
seen.add(objectValue);
if (Array.isArray(value)) {
for (const item of value) {
this.deepFreezeValue(item, seen);
}
Object.freeze(value);
return value;
}
if (value instanceof Map) {
for (const mapValue of value.values()) {
this.deepFreezeValue(mapValue, seen);
}
Object.freeze(value);
return value;
}
if (value instanceof Set) {
for (const setValue of value.values()) {
this.deepFreezeValue(setValue, seen);
}
Object.freeze(value);
return value;
}
if (!this.isPlainObject(value)) {
return value;
}
const record = value as Record<string, unknown>;
for (const key of Object.keys(record)) {
this.deepFreezeValue(record[key], seen);
}
Object.freeze(record);
return value;
}
private isPlainObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== "object") {
return false;
}
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
} }
private async normalizeConfig( private async normalizeConfig(
@@ -845,7 +1035,7 @@ export class AgentManager {
return client; return client;
} }
private requireAgent(id: string): ManagedAgent { private requireAgent(id: string): ActiveManagedAgent {
const agent = this.agents.get(id); const agent = this.agents.get(id);
if (!agent) { if (!agent) {
throw new Error(`Unknown agent '${id}'`); throw new Error(`Unknown agent '${id}'`);