422 lines
14 KiB
TypeScript
422 lines
14 KiB
TypeScript
import "server-only";
|
|
|
|
/**
|
|
* Server-side singleton for core services.
|
|
*
|
|
* Lazily initializes config, plugin registry, and session manager.
|
|
* Cached in globalThis to survive Next.js HMR reloads in development.
|
|
*
|
|
* NOTE: Plugins are explicitly imported here because Next.js webpack
|
|
* cannot resolve dynamic `import(variable)` expressions used by the
|
|
* core plugin registry's loadBuiltins(). Static imports let webpack
|
|
* bundle them correctly.
|
|
*/
|
|
|
|
import {
|
|
loadConfig,
|
|
createPluginRegistry,
|
|
createSessionManager,
|
|
createLifecycleManager,
|
|
decompose,
|
|
getLeaves,
|
|
getSiblings,
|
|
formatPlanTree,
|
|
type OrchestratorConfig,
|
|
type PluginRegistry,
|
|
type OpenCodeSessionManager,
|
|
type LifecycleManager,
|
|
type SCM,
|
|
type ProjectConfig,
|
|
type Tracker,
|
|
type Issue,
|
|
type Session,
|
|
type DecomposerConfig,
|
|
DEFAULT_DECOMPOSER_CONFIG,
|
|
isOrchestratorSession,
|
|
TERMINAL_STATUSES,
|
|
} from "@composio/ao-core";
|
|
|
|
// Static plugin imports — webpack needs these to be string literals
|
|
import pluginRuntimeTmux from "@composio/ao-plugin-runtime-tmux";
|
|
import pluginAgentClaudeCode from "@composio/ao-plugin-agent-claude-code";
|
|
import pluginAgentCursor from "@composio/ao-plugin-agent-cursor";
|
|
import pluginAgentOpencode from "@composio/ao-plugin-agent-opencode";
|
|
import pluginWorkspaceWorktree from "@composio/ao-plugin-workspace-worktree";
|
|
import pluginScmGithub from "@composio/ao-plugin-scm-github";
|
|
import pluginTrackerGithub from "@composio/ao-plugin-tracker-github";
|
|
import pluginTrackerLinear from "@composio/ao-plugin-tracker-linear";
|
|
|
|
export interface Services {
|
|
config: OrchestratorConfig;
|
|
registry: PluginRegistry;
|
|
sessionManager: OpenCodeSessionManager;
|
|
lifecycleManager: LifecycleManager;
|
|
}
|
|
|
|
// Cache in globalThis for Next.js HMR stability
|
|
const globalForServices = globalThis as typeof globalThis & {
|
|
_aoServices?: Services;
|
|
_aoServicesInit?: Promise<Services>;
|
|
};
|
|
|
|
/** Get (or lazily initialize) the core services singleton. */
|
|
export function getServices(): Promise<Services> {
|
|
if (globalForServices._aoServices) {
|
|
return Promise.resolve(globalForServices._aoServices);
|
|
}
|
|
if (!globalForServices._aoServicesInit) {
|
|
globalForServices._aoServicesInit = initServices().catch((err) => {
|
|
// Clear the cached promise so the next call retries instead of
|
|
// permanently returning a rejected promise.
|
|
globalForServices._aoServicesInit = undefined;
|
|
throw err;
|
|
});
|
|
}
|
|
return globalForServices._aoServicesInit;
|
|
}
|
|
|
|
async function initServices(): Promise<Services> {
|
|
const config = loadConfig();
|
|
const registry = createPluginRegistry();
|
|
|
|
// Register plugins explicitly (webpack can't handle dynamic import() in core)
|
|
registry.register(pluginRuntimeTmux);
|
|
registry.register(pluginAgentClaudeCode);
|
|
registry.register(pluginAgentCursor);
|
|
registry.register(pluginAgentOpencode);
|
|
registry.register(pluginWorkspaceWorktree);
|
|
registry.register(pluginScmGithub);
|
|
registry.register(pluginTrackerGithub);
|
|
registry.register(pluginTrackerLinear);
|
|
|
|
const sessionManager = createSessionManager({ config, registry });
|
|
|
|
// Start the lifecycle manager — polls sessions every 30s, triggers reactions
|
|
// (CI failure → send fix message, review comments → forward to agent, etc.)
|
|
const lifecycleManager = createLifecycleManager({ config, registry, sessionManager });
|
|
lifecycleManager.start(30_000);
|
|
|
|
const services = { config, registry, sessionManager, lifecycleManager };
|
|
globalForServices._aoServices = services;
|
|
return services;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Backlog auto-claim — polls for labeled issues and auto-spawns agents
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const BACKLOG_LABEL = "agent:backlog";
|
|
const BACKLOG_POLL_INTERVAL = 60_000; // 1 minute
|
|
const MAX_CONCURRENT_AGENTS = 5; // Max active agent sessions across all projects
|
|
|
|
const globalForBacklog = globalThis as typeof globalThis & {
|
|
_aoBacklogStarted?: boolean;
|
|
_aoBacklogTimer?: ReturnType<typeof setInterval>;
|
|
};
|
|
|
|
/** Start the backlog auto-claim loop. Idempotent — safe to call multiple times. */
|
|
export function startBacklogPoller(): void {
|
|
if (globalForBacklog._aoBacklogStarted) return;
|
|
globalForBacklog._aoBacklogStarted = true;
|
|
|
|
// Run immediately, then on interval
|
|
void pollBacklog();
|
|
globalForBacklog._aoBacklogTimer = setInterval(() => void pollBacklog(), BACKLOG_POLL_INTERVAL);
|
|
}
|
|
|
|
// Track which issues we've already processed to avoid repeated API calls
|
|
const processedIssues = new Set<string>();
|
|
|
|
/** Label GitHub issues for verification when their PRs have been merged. */
|
|
async function labelIssuesForVerification(
|
|
sessions: Session[],
|
|
config: OrchestratorConfig,
|
|
registry: PluginRegistry,
|
|
): Promise<void> {
|
|
const mergedSessions = sessions.filter(
|
|
(s) =>
|
|
s.status === "merged" && s.issueId && !processedIssues.has(`${s.projectId}:${s.issueId}`),
|
|
);
|
|
|
|
for (const session of mergedSessions) {
|
|
const key = `${session.projectId}:${session.issueId}`;
|
|
const project = config.projects[session.projectId];
|
|
if (!project?.tracker?.plugin) {
|
|
processedIssues.add(key);
|
|
continue;
|
|
}
|
|
|
|
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
|
|
if (!tracker?.updateIssue) {
|
|
processedIssues.add(key);
|
|
continue;
|
|
}
|
|
|
|
const issueId = session.issueId;
|
|
if (!issueId) {
|
|
processedIssues.add(key);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
await tracker.updateIssue(
|
|
issueId,
|
|
{
|
|
labels: ["merged-unverified"],
|
|
removeLabels: ["agent:backlog", "agent:in-progress"],
|
|
comment: `PR merged. Issue awaiting human verification on staging.`,
|
|
},
|
|
project,
|
|
);
|
|
} catch (err) {
|
|
console.error(`[backlog] Failed to close issue ${session.issueId}:`, err);
|
|
}
|
|
processedIssues.add(key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect reopened issues (open + agent:done label) and swap the label
|
|
* back to agent:backlog so pollBacklog picks them up on the next cycle.
|
|
*/
|
|
async function relabelReopenedIssues(
|
|
config: OrchestratorConfig,
|
|
registry: PluginRegistry,
|
|
): Promise<void> {
|
|
for (const [, project] of Object.entries(config.projects)) {
|
|
if (!project.tracker?.plugin) continue;
|
|
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
|
|
if (!tracker?.listIssues || !tracker.updateIssue) continue;
|
|
|
|
let reopened: Issue[];
|
|
try {
|
|
reopened = await tracker.listIssues(
|
|
{ state: "open", labels: ["agent:done"], limit: 20 },
|
|
project,
|
|
);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
for (const issue of reopened) {
|
|
try {
|
|
await tracker.updateIssue(
|
|
issue.id,
|
|
{
|
|
labels: [BACKLOG_LABEL],
|
|
removeLabels: ["agent:done"],
|
|
comment: "Issue reopened — returning to agent backlog.",
|
|
},
|
|
project,
|
|
);
|
|
console.log(`[backlog] Relabeled reopened issue ${issue.id} → ${BACKLOG_LABEL}`);
|
|
} catch (err) {
|
|
console.error(`[backlog] Failed to relabel reopened issue ${issue.id}:`, err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function pollBacklog(): Promise<void> {
|
|
try {
|
|
const { config, registry, sessionManager } = await getServices();
|
|
|
|
// Get all sessions
|
|
const allSessions = await sessionManager.list();
|
|
// Label issues for verification when PRs are merged
|
|
await labelIssuesForVerification(allSessions, config, registry);
|
|
|
|
// Detect reopened issues: open state + agent:done label → relabel as agent:backlog
|
|
await relabelReopenedIssues(config, registry);
|
|
|
|
const allSessionPrefixes = Object.entries(config.projects).map(
|
|
([id, p]) => p.sessionPrefix ?? id,
|
|
);
|
|
const workerSessions = allSessions.filter(
|
|
(session) =>
|
|
!isOrchestratorSession(
|
|
session,
|
|
config.projects[session.projectId]?.sessionPrefix ?? session.projectId,
|
|
allSessionPrefixes,
|
|
) && !TERMINAL_STATUSES.has(session.status),
|
|
);
|
|
const activeIssueIds = new Set(
|
|
workerSessions
|
|
.map((session) => session.issueId?.toLowerCase())
|
|
.filter((issueId): issueId is string => Boolean(issueId)),
|
|
);
|
|
|
|
// Auto-scaling: respect max concurrent agents
|
|
let availableSlots = MAX_CONCURRENT_AGENTS - workerSessions.length;
|
|
if (availableSlots <= 0) return; // At capacity
|
|
|
|
for (const [projectId, project] of Object.entries(config.projects)) {
|
|
if (availableSlots <= 0) break;
|
|
if (!project.tracker?.plugin) continue;
|
|
|
|
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
|
|
if (!tracker?.listIssues) continue;
|
|
|
|
let backlogIssues: Issue[];
|
|
try {
|
|
backlogIssues = await tracker.listIssues(
|
|
{ state: "open", labels: [BACKLOG_LABEL], limit: 10 },
|
|
project,
|
|
);
|
|
} catch {
|
|
continue; // Tracker unavailable — skip this project
|
|
}
|
|
|
|
for (const issue of backlogIssues) {
|
|
if (availableSlots <= 0) break;
|
|
|
|
// Skip if already being worked on
|
|
if (activeIssueIds.has(issue.id.toLowerCase())) continue;
|
|
|
|
try {
|
|
const decompConfig = project.decomposer;
|
|
const shouldDecompose = decompConfig?.enabled ?? false;
|
|
|
|
if (shouldDecompose) {
|
|
// Decompose the issue before spawning
|
|
const taskDescription = `${issue.title}\n\n${issue.description}`;
|
|
const decomposerConfig: DecomposerConfig = {
|
|
...DEFAULT_DECOMPOSER_CONFIG,
|
|
...decompConfig,
|
|
};
|
|
|
|
console.log(`[backlog] Decomposing issue ${issue.id}: "${issue.title}"`);
|
|
const plan = await decompose(taskDescription, decomposerConfig);
|
|
const leaves = getLeaves(plan.tree);
|
|
|
|
if (leaves.length <= 1) {
|
|
// Atomic — spawn directly
|
|
await sessionManager.spawn({ projectId, issueId: issue.id });
|
|
availableSlots--;
|
|
} else if (decomposerConfig.requireApproval) {
|
|
// Post plan as comment and wait for human approval
|
|
const treeText = formatPlanTree(plan.tree);
|
|
if (tracker.updateIssue) {
|
|
await tracker.updateIssue(
|
|
issue.id,
|
|
{
|
|
comment: `## Decomposition Plan\n\n\`\`\`\n${treeText}\n\`\`\`\n\n${leaves.length} subtasks identified. Remove \`agent:backlog\` and add \`agent:decompose-approved\` to execute.`,
|
|
labels: ["agent:decompose-pending"],
|
|
removeLabels: ["agent:backlog"],
|
|
},
|
|
project,
|
|
);
|
|
}
|
|
console.log(
|
|
`[backlog] Posted decomposition plan for ${issue.id} (${leaves.length} subtasks, awaiting approval)`,
|
|
);
|
|
continue;
|
|
} else {
|
|
// Auto-execute: spawn each leaf with lineage context
|
|
console.log(
|
|
`[backlog] Auto-executing decomposition for ${issue.id} (${leaves.length} subtasks)`,
|
|
);
|
|
for (const leaf of leaves) {
|
|
if (availableSlots <= 0) break;
|
|
const siblings = getSiblings(plan.tree, leaf.id);
|
|
await sessionManager.spawn({
|
|
projectId,
|
|
issueId: issue.id,
|
|
lineage: leaf.lineage,
|
|
siblings,
|
|
});
|
|
availableSlots--;
|
|
}
|
|
}
|
|
} else {
|
|
// No decomposition — spawn directly (classic behavior)
|
|
await sessionManager.spawn({ projectId, issueId: issue.id });
|
|
availableSlots--;
|
|
}
|
|
|
|
activeIssueIds.add(issue.id.toLowerCase());
|
|
|
|
// Mark as claimed on the tracker
|
|
if (tracker.updateIssue) {
|
|
await tracker.updateIssue(
|
|
issue.id,
|
|
{
|
|
labels: ["agent:in-progress"],
|
|
removeLabels: ["agent:backlog"],
|
|
comment: "Claimed by agent orchestrator — session spawned.",
|
|
},
|
|
project,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.error(`[backlog] Failed to spawn session for issue ${issue.id}:`, err);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("[backlog] Poll failed:", err);
|
|
}
|
|
}
|
|
|
|
/** Get backlog issues across all projects (for dashboard display). */
|
|
export async function getBacklogIssues(): Promise<Array<Issue & { projectId: string }>> {
|
|
const results: Array<Issue & { projectId: string }> = [];
|
|
try {
|
|
const { config, registry } = await getServices();
|
|
for (const [projectId, project] of Object.entries(config.projects)) {
|
|
if (!project.tracker?.plugin) continue;
|
|
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
|
|
if (!tracker?.listIssues) continue;
|
|
|
|
try {
|
|
const issues = await tracker.listIssues(
|
|
{ state: "open", labels: [BACKLOG_LABEL], limit: 20 },
|
|
project,
|
|
);
|
|
for (const issue of issues) {
|
|
results.push({ ...issue, projectId });
|
|
}
|
|
} catch {
|
|
// Skip unavailable trackers
|
|
}
|
|
}
|
|
} catch {
|
|
// Services unavailable
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/** Get issues labeled merged-unverified across all projects (for dashboard verify tab). */
|
|
export async function getVerifyIssues(): Promise<Array<Issue & { projectId: string }>> {
|
|
const results: Array<Issue & { projectId: string }> = [];
|
|
try {
|
|
const { config, registry } = await getServices();
|
|
for (const [projectId, project] of Object.entries(config.projects)) {
|
|
if (!project.tracker?.plugin) continue;
|
|
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
|
|
if (!tracker?.listIssues) continue;
|
|
|
|
try {
|
|
const issues = await tracker.listIssues(
|
|
{ state: "open", labels: ["merged-unverified"], limit: 20 },
|
|
project,
|
|
);
|
|
for (const issue of issues) {
|
|
results.push({ ...issue, projectId });
|
|
}
|
|
} catch {
|
|
// Skip unavailable trackers
|
|
}
|
|
}
|
|
} catch {
|
|
// Services unavailable
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/** Resolve the SCM plugin for a project. Returns null if not configured. */
|
|
export function getSCM(registry: PluginRegistry, project: ProjectConfig | undefined): SCM | null {
|
|
if (!project?.scm?.plugin) return null;
|
|
return registry.get<SCM>("scm", project.scm.plugin);
|
|
}
|