Files
ci/src/core/session-manager.ts
T
Jon Chery 8c975352b8
CI / build-and-test (push) Has been cancelled
Publish to npm / publish (push) Has been cancelled
feat(P01-P05): multi-session support & execute-phase persona specialization — SESSION-01..05, PERSONA-01..11, CLI-01..04, INTEG-01..05
---ci---
phase: 1-5
milestone: v0.11
project: ci
status: execute
decisions:
  - id: D-092
    decision: Independent sessions via AgentSession (not shared state)
    rationale: Aligns with git-native model; sessions communicate through commits and .ciagent/ files
    confidence: 0.90
  - id: D-093
    decision: Personas as runtime configs (not new Agent classes)
    rationale: Less code, more flexible. Persona md files define domain knowledge and framework opinions.
    confidence: 0.88
  - id: D-094
    decision: Lead developer as task decomposer (not separate pipeline stage)
    rationale: EXECUTE stays one stage. Lead decomposes before execution, each persona group runs.
    confidence: 0.85
  - id: D-095
    decision: File-based git locking (not DB or IPC)
    rationale: Git-native. .session-lock files are simple JSON with session ID, timestamp, project slug.
    confidence: 0.87
  - id: D-096
    decision: Territory enforcement with warn/strict modes
    rationale: Warn for teams learning boundaries. Strict for mature projects. Configurable per-project.
    confidence: 0.82
  - id: D-097
    decision: Task decomposition by file patterns + requirement IDs
    rationale: File patterns are deterministic; no LLM needed. Requirement IDs in PLAN.md already map to domains.
    confidence: 0.88
requirements:
  covered: [SESSION-01, SESSION-02, SESSION-03, SESSION-04, SESSION-05, PERSONA-01, PERSONA-02, PERSONA-03, PERSONA-04, PERSONA-05, PERSONA-06, PERSONA-07, PERSONA-08, PERSONA-09, PERSONA-10, PERSONA-11, CLI-01, CLI-02, CLI-03, CLI-04, INTEG-01, INTEG-02, INTEG-03, INTEG-04, INTEG-05]
---/ci---
2026-06-01 17:43:06 +00:00

183 lines
5.6 KiB
TypeScript

import { CIAgentConfig } from "../types/config.js";
import { SessionInfo, SessionStatus } from "../types/session.js";
import { AgentSession } from "./agent-session.js";
import { AgentContext, AgentResult } from "../agents/base.js";
import { loadConfig } from "./config.js";
import * as path from "node:path";
import * as fs from "node:fs";
import * as os from "node:os";
export class SessionManager {
private sessions: Map<string, AgentSession> = new Map();
private config: CIAgentConfig;
private projectPath: string;
constructor(projectPath: string, config?: CIAgentConfig) {
this.projectPath = projectPath;
this.config = config || loadConfig(projectPath);
}
createSession(projectSlug: string): AgentSession {
const session = new AgentSession(this.projectPath, projectSlug, this.config);
this.sessions.set(session.getId(), session);
return session;
}
async runSession(sessionId: string, context: AgentContext): Promise<AgentResult> {
const session = this.sessions.get(sessionId);
if (!session) {
return {
success: false,
output: `Session ${sessionId} not found`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: `Session ${sessionId} not found`,
};
}
return session.run(context);
}
async runAllSessions(
projectSlugs: string[],
contextFactory: (slug: string) => AgentContext,
parallel: boolean = false
): Promise<Record<string, AgentResult>> {
const results: Record<string, AgentResult> = {};
const maxConcurrent = this.config.sessions?.max_concurrent_sessions || 3;
if (parallel && projectSlugs.length > 1) {
const batches: string[][] = [];
const concurrency = Math.min(maxConcurrent, projectSlugs.length);
for (let i = 0; i < projectSlugs.length; i += concurrency) {
batches.push(projectSlugs.slice(i, i + concurrency));
}
for (const batch of batches) {
const batchResults = await Promise.allSettled(
batch.map(async (slug): Promise<[string, AgentResult]> => {
const session = this.createSession(slug);
const context = contextFactory(slug);
const result = await session.run(context);
return [slug, result];
})
);
for (const settled of batchResults) {
if (settled.status === "fulfilled") {
const [slug, result] = settled.value;
results[slug] = result;
} else {
const slug = batch[batchResults.indexOf(settled)];
results[slug] = {
success: false,
output: `Session failed for ${slug}`,
artifacts_created: 0,
decisions: 0,
escalations: 0,
duration_ms: 0,
error: settled.reason instanceof Error ? settled.reason.message : String(settled.reason),
};
}
}
}
} else {
for (const slug of projectSlugs) {
const session = this.createSession(slug);
const context = contextFactory(slug);
const result = await session.run(context);
results[slug] = result;
}
}
return results;
}
cancelSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
return session.cancel();
}
pauseSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
return session.pause();
}
resumeSession(sessionId: string): boolean {
const session = this.sessions.get(sessionId);
if (!session) return false;
return session.resume();
}
getSession(sessionId: string): AgentSession | undefined {
return this.sessions.get(sessionId);
}
listSessions(): SessionInfo[] {
return Array.from(this.sessions.values()).map((s) => s.getSessionInfo());
}
listActiveSessions(): SessionInfo[] {
return this.listSessions().filter(
(s) => s.status === "running" || s.status === "paused"
);
}
loadPersistedSessions(): SessionInfo[] {
const ciDir = path.join(this.projectPath, ".ciagent");
if (!fs.existsSync(ciDir)) return [];
const sessions: SessionInfo[] = [];
const dirs = [ciDir];
try {
const config = loadConfig(this.projectPath);
if (config.projects && config.projects.length > 0) {
for (const project of config.projects) {
dirs.push(path.join(ciDir, project.slug));
}
}
} catch {}
for (const dir of dirs) {
if (!fs.existsSync(dir)) continue;
const files = fs.readdirSync(dir);
for (const file of files) {
if (file.startsWith(".session-") && file.endsWith(".json")) {
const sessionId = file.replace(".session-", "").replace(".json", "");
const slug = dir === ciDir ? "" : path.basename(dir);
const session = AgentSession.loadState(this.projectPath, sessionId, slug || undefined);
if (session) {
sessions.push(session.getSessionInfo());
}
}
}
}
return sessions;
}
cleanupStaleSessions(): number {
const timeout = this.config.sessions?.session_timeout_ms || 3600000;
const now = Date.now();
let cleaned = 0;
for (const [id, session] of this.sessions.entries()) {
const info = session.getSessionInfo();
const age = now - new Date(info.last_updated).getTime();
if ((info.status === "running" || info.status === "paused") && age > timeout) {
session.cancel();
this.sessions.delete(id);
cleaned++;
}
}
return cleaned;
}
}