8c975352b8
---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---
183 lines
5.6 KiB
TypeScript
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;
|
|
}
|
|
} |