Merge branch 'main' into cw/agent-details-conflict-1772802863659
# Conflicts: # docs/server-api.md
This commit is contained in:
155
apps/server/agent/lifecycle/controller.test.ts
Normal file
155
apps/server/agent/lifecycle/controller.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* AgentLifecycleController Tests — Regression coverage for event emissions.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { AgentLifecycleController } from './controller.js';
|
||||
import type { AgentRepository } from '../../db/repositories/agent-repository.js';
|
||||
import type { AccountRepository } from '../../db/repositories/account-repository.js';
|
||||
import type { SignalManager } from './signal-manager.js';
|
||||
import type { RetryPolicy } from './retry-policy.js';
|
||||
import type { AgentErrorAnalyzer } from './error-analyzer.js';
|
||||
import type { ProcessManager } from '../process-manager.js';
|
||||
import type { CleanupManager } from '../cleanup-manager.js';
|
||||
import type { CleanupStrategy } from './cleanup-strategy.js';
|
||||
import type { EventBus, AgentAccountSwitchedEvent } from '../../events/types.js';
|
||||
|
||||
function makeController(overrides: {
|
||||
repository?: Partial<AgentRepository>;
|
||||
accountRepository?: Partial<AccountRepository>;
|
||||
eventBus?: EventBus;
|
||||
}): AgentLifecycleController {
|
||||
const signalManager: SignalManager = {
|
||||
clearSignal: vi.fn(),
|
||||
checkSignalExists: vi.fn(),
|
||||
readSignal: vi.fn(),
|
||||
waitForSignal: vi.fn(),
|
||||
validateSignalFile: vi.fn(),
|
||||
};
|
||||
const retryPolicy: RetryPolicy = {
|
||||
maxAttempts: 3,
|
||||
backoffMs: [1000, 2000, 4000],
|
||||
shouldRetry: vi.fn().mockReturnValue(false),
|
||||
getRetryDelay: vi.fn().mockReturnValue(0),
|
||||
};
|
||||
const errorAnalyzer = { analyzeError: vi.fn() } as unknown as AgentErrorAnalyzer;
|
||||
const processManager = { getAgentWorkdir: vi.fn() } as unknown as ProcessManager;
|
||||
const cleanupManager = {} as unknown as CleanupManager;
|
||||
const cleanupStrategy = {
|
||||
shouldCleanup: vi.fn(),
|
||||
executeCleanup: vi.fn(),
|
||||
} as unknown as CleanupStrategy;
|
||||
|
||||
return new AgentLifecycleController(
|
||||
signalManager,
|
||||
retryPolicy,
|
||||
errorAnalyzer,
|
||||
processManager,
|
||||
overrides.repository as AgentRepository,
|
||||
cleanupManager,
|
||||
cleanupStrategy,
|
||||
overrides.accountRepository as AccountRepository | undefined,
|
||||
false,
|
||||
overrides.eventBus,
|
||||
);
|
||||
}
|
||||
|
||||
describe('AgentLifecycleController', () => {
|
||||
describe('handleAccountExhaustion', () => {
|
||||
it('emits agent:account_switched with correct payload when new account is available', async () => {
|
||||
const emittedEvents: AgentAccountSwitchedEvent[] = [];
|
||||
const eventBus: EventBus = {
|
||||
emit: vi.fn((event) => { emittedEvents.push(event as AgentAccountSwitchedEvent); }),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
};
|
||||
|
||||
const agentRecord = {
|
||||
id: 'agent-1',
|
||||
name: 'test-agent',
|
||||
accountId: 'old-account-id',
|
||||
provider: 'claude',
|
||||
};
|
||||
const newAccount = { id: 'new-account-id' };
|
||||
|
||||
const repository: Partial<AgentRepository> = {
|
||||
findById: vi.fn().mockResolvedValue(agentRecord),
|
||||
};
|
||||
const accountRepository: Partial<AccountRepository> = {
|
||||
markExhausted: vi.fn().mockResolvedValue(agentRecord),
|
||||
findNextAvailable: vi.fn().mockResolvedValue(newAccount),
|
||||
};
|
||||
|
||||
const controller = makeController({ repository, accountRepository, eventBus });
|
||||
|
||||
// Call private method via any-cast
|
||||
await (controller as any).handleAccountExhaustion('agent-1');
|
||||
|
||||
const accountSwitchedEvents = emittedEvents.filter(
|
||||
(e) => e.type === 'agent:account_switched'
|
||||
);
|
||||
expect(accountSwitchedEvents).toHaveLength(1);
|
||||
const event = accountSwitchedEvents[0];
|
||||
expect(event.type).toBe('agent:account_switched');
|
||||
expect(event.payload.agentId).toBe('agent-1');
|
||||
expect(event.payload.name).toBe('test-agent');
|
||||
expect(event.payload.previousAccountId).toBe('old-account-id');
|
||||
expect(event.payload.newAccountId).toBe('new-account-id');
|
||||
expect(event.payload.reason).toBe('account_exhausted');
|
||||
});
|
||||
|
||||
it('does not emit agent:account_switched when no new account is available', async () => {
|
||||
const eventBus: EventBus = {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
};
|
||||
|
||||
const agentRecord = {
|
||||
id: 'agent-2',
|
||||
name: 'test-agent-2',
|
||||
accountId: 'old-account-id',
|
||||
provider: 'claude',
|
||||
};
|
||||
|
||||
const repository: Partial<AgentRepository> = {
|
||||
findById: vi.fn().mockResolvedValue(agentRecord),
|
||||
};
|
||||
const accountRepository: Partial<AccountRepository> = {
|
||||
markExhausted: vi.fn().mockResolvedValue(agentRecord),
|
||||
findNextAvailable: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
const controller = makeController({ repository, accountRepository, eventBus });
|
||||
|
||||
await (controller as any).handleAccountExhaustion('agent-2');
|
||||
|
||||
expect(eventBus.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not emit when agent has no accountId', async () => {
|
||||
const eventBus: EventBus = {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
once: vi.fn(),
|
||||
};
|
||||
|
||||
const repository: Partial<AgentRepository> = {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'agent-3', name: 'x', accountId: null }),
|
||||
};
|
||||
const accountRepository: Partial<AccountRepository> = {
|
||||
markExhausted: vi.fn(),
|
||||
findNextAvailable: vi.fn(),
|
||||
};
|
||||
|
||||
const controller = makeController({ repository, accountRepository, eventBus });
|
||||
|
||||
await (controller as any).handleAccountExhaustion('agent-3');
|
||||
|
||||
expect(eventBus.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import type { RetryPolicy, AgentError } from './retry-policy.js';
|
||||
import { AgentExhaustedError, AgentFailureError } from './retry-policy.js';
|
||||
import type { AgentErrorAnalyzer } from './error-analyzer.js';
|
||||
import type { CleanupStrategy, AgentInfo } from './cleanup-strategy.js';
|
||||
import type { EventBus, AgentAccountSwitchedEvent } from '../../events/types.js';
|
||||
|
||||
const log = createModuleLogger('lifecycle-controller');
|
||||
|
||||
@@ -48,6 +49,7 @@ export class AgentLifecycleController {
|
||||
private cleanupStrategy: CleanupStrategy,
|
||||
private accountRepository?: AccountRepository,
|
||||
private debug: boolean = false,
|
||||
private eventBus?: EventBus,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -304,7 +306,7 @@ export class AgentLifecycleController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle account exhaustion by marking account as exhausted.
|
||||
* Handle account exhaustion by marking account as exhausted and emitting account_switched event.
|
||||
*/
|
||||
private async handleAccountExhaustion(agentId: string): Promise<void> {
|
||||
if (!this.accountRepository) {
|
||||
@@ -319,15 +321,34 @@ export class AgentLifecycleController {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousAccountId = agent.accountId;
|
||||
|
||||
// Mark account as exhausted for 1 hour
|
||||
const exhaustedUntil = new Date(Date.now() + 60 * 60 * 1000);
|
||||
await this.accountRepository.markExhausted(agent.accountId, exhaustedUntil);
|
||||
await this.accountRepository.markExhausted(previousAccountId, exhaustedUntil);
|
||||
|
||||
log.info({
|
||||
agentId,
|
||||
accountId: agent.accountId,
|
||||
accountId: previousAccountId,
|
||||
exhaustedUntil
|
||||
}, 'marked account as exhausted due to usage limits');
|
||||
|
||||
// Find the next available account and emit account_switched event
|
||||
const newAccount = await this.accountRepository.findNextAvailable(agent.provider ?? 'claude');
|
||||
if (newAccount && this.eventBus) {
|
||||
const event: AgentAccountSwitchedEvent = {
|
||||
type: 'agent:account_switched',
|
||||
timestamp: new Date(),
|
||||
payload: {
|
||||
agentId,
|
||||
name: agent.name,
|
||||
previousAccountId,
|
||||
newAccountId: newAccount.id,
|
||||
reason: 'account_exhausted',
|
||||
},
|
||||
};
|
||||
this.eventBus.emit(event);
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn({
|
||||
agentId,
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { AgentRepository } from '../../db/repositories/agent-repository.js'
|
||||
import type { AccountRepository } from '../../db/repositories/account-repository.js';
|
||||
import type { ProcessManager } from '../process-manager.js';
|
||||
import type { CleanupManager } from '../cleanup-manager.js';
|
||||
import type { EventBus } from '../../events/types.js';
|
||||
|
||||
export interface LifecycleFactoryOptions {
|
||||
repository: AgentRepository;
|
||||
@@ -21,6 +22,7 @@ export interface LifecycleFactoryOptions {
|
||||
cleanupManager: CleanupManager;
|
||||
accountRepository?: AccountRepository;
|
||||
debug?: boolean;
|
||||
eventBus?: EventBus;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,7 +34,8 @@ export function createLifecycleController(options: LifecycleFactoryOptions): Age
|
||||
processManager,
|
||||
cleanupManager,
|
||||
accountRepository,
|
||||
debug = false
|
||||
debug = false,
|
||||
eventBus,
|
||||
} = options;
|
||||
|
||||
// Create core components
|
||||
@@ -51,7 +54,8 @@ export function createLifecycleController(options: LifecycleFactoryOptions): Age
|
||||
cleanupManager,
|
||||
cleanupStrategy,
|
||||
accountRepository,
|
||||
debug
|
||||
debug,
|
||||
eventBus,
|
||||
);
|
||||
|
||||
return lifecycleController;
|
||||
|
||||
Reference in New Issue
Block a user