fix(#338): Add session cleanup on terminal states

- Add removeSession and scheduleSessionCleanup methods to AgentSpawnerService
- Schedule session cleanup after completed/failed/killed transitions
- Default 30 second delay before cleanup to allow status queries
- Implement OnModuleDestroy to clean up pending timers
- Add forwardRef injection to avoid circular dependency
- Add comprehensive tests for cleanup functionality

Refs #338
This commit is contained in:
Jason Woltje
2026-02-05 18:47:14 -06:00
parent 8d57191a91
commit a42f88d64c
4 changed files with 347 additions and 7 deletions

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger, HttpException, HttpStatus } from "@nestjs/common";
import { Injectable, Logger, HttpException, HttpStatus, OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import Anthropic from "@anthropic-ai/sdk";
import { randomUUID } from "crypto";
@@ -9,15 +9,23 @@ import {
AgentType,
} from "./types/agent-spawner.types";
/**
* Default delay in milliseconds before cleaning up sessions after terminal states
* This allows time for status queries before the session is removed
*/
const DEFAULT_SESSION_CLEANUP_DELAY_MS = 30000; // 30 seconds
/**
* Service responsible for spawning Claude agents using Anthropic SDK
*/
@Injectable()
export class AgentSpawnerService {
export class AgentSpawnerService implements OnModuleDestroy {
private readonly logger = new Logger(AgentSpawnerService.name);
private readonly anthropic: Anthropic;
private readonly sessions = new Map<string, AgentSession>();
private readonly maxConcurrentAgents: number;
private readonly sessionCleanupDelayMs: number;
private readonly cleanupTimers = new Map<string, NodeJS.Timeout>();
constructor(private readonly configService: ConfigService) {
const apiKey = this.configService.get<string>("orchestrator.claude.apiKey");
@@ -34,11 +42,27 @@ export class AgentSpawnerService {
this.maxConcurrentAgents =
this.configService.get<number>("orchestrator.spawner.maxConcurrentAgents") ?? 20;
// Default to 30 seconds if not configured
this.sessionCleanupDelayMs =
this.configService.get<number>("orchestrator.spawner.sessionCleanupDelayMs") ??
DEFAULT_SESSION_CLEANUP_DELAY_MS;
this.logger.log(
`AgentSpawnerService initialized with Claude SDK (max concurrent agents: ${String(this.maxConcurrentAgents)})`
`AgentSpawnerService initialized with Claude SDK (max concurrent agents: ${String(this.maxConcurrentAgents)}, cleanup delay: ${String(this.sessionCleanupDelayMs)}ms)`
);
}
/**
* Clean up all pending cleanup timers on module destroy
*/
onModuleDestroy(): void {
this.cleanupTimers.forEach((timer, agentId) => {
clearTimeout(timer);
this.logger.debug(`Cleared cleanup timer for agent ${agentId}`);
});
this.cleanupTimers.clear();
}
/**
* Spawn a new agent with the given configuration
* @param request Agent spawn request
@@ -100,6 +124,59 @@ export class AgentSpawnerService {
return Array.from(this.sessions.values());
}
/**
* Remove an agent session from the in-memory map
* @param agentId Unique agent identifier
* @returns true if session was removed, false if not found
*/
removeSession(agentId: string): boolean {
// Clear any pending cleanup timer for this agent
const timer = this.cleanupTimers.get(agentId);
if (timer) {
clearTimeout(timer);
this.cleanupTimers.delete(agentId);
}
const deleted = this.sessions.delete(agentId);
if (deleted) {
this.logger.log(`Session removed for agent ${agentId}`);
}
return deleted;
}
/**
* Schedule session cleanup after a delay
* This allows time for status queries before the session is removed
* @param agentId Unique agent identifier
* @param delayMs Optional delay in milliseconds (defaults to configured value)
*/
scheduleSessionCleanup(agentId: string, delayMs?: number): void {
const delay = delayMs ?? this.sessionCleanupDelayMs;
// Clear any existing timer for this agent
const existingTimer = this.cleanupTimers.get(agentId);
if (existingTimer) {
clearTimeout(existingTimer);
}
this.logger.debug(`Scheduling session cleanup for agent ${agentId} in ${String(delay)}ms`);
const timer = setTimeout(() => {
this.removeSession(agentId);
this.cleanupTimers.delete(agentId);
}, delay);
this.cleanupTimers.set(agentId, timer);
}
/**
* Get the number of pending cleanup timers (for testing)
* @returns Number of pending cleanup timers
*/
getPendingCleanupCount(): number {
return this.cleanupTimers.size;
}
/**
* Check if the concurrent agent limit has been reached
* @throws HttpException with 429 Too Many Requests if limit reached