feat: lifecycle manager, backlog auto-claim, task decomposition, and verification gate (#365)

* feat: wire lifecycle manager, backlog auto-claim, and dashboard overhaul

- Start LifecycleManager in dashboard server (30s polling) so reactions
  actually fire: CI failures, review comments, merge conflicts are now
  auto-forwarded to agents
- Add backlog auto-claim poller (60s interval) that watches for issues
  labeled `agent:backlog` and auto-spawns agent sessions up to max
  concurrent limit (5)
- Add tabbed dashboard UI: Board (kanban), Backlog (issue queue), PRs
- Add issue creation form in dashboard — creates GitHub issues with
  `agent:backlog` label for immediate agent pickup
- Add API routes: /api/backlog, /api/issues, /api/setup-labels
- Pass notifier config through plugin registry (slack webhook fix)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add task decomposition layer (classify → decompose → recurse)

Adds LLM-driven recursive task decomposition upstream of session spawning.
Complex issues are broken into atomic subtasks before agents start working.
Each agent receives lineage context (where it fits in the hierarchy) and
sibling awareness (what parallel agents are doing).

Core changes:
- New decomposer module (core/src/decomposer.ts) — classify, decompose,
  plan tree, lineage formatting, using Claude API
- Extended SessionSpawnConfig with lineage/siblings fields
- Prompt builder Layer 4: decomposition context (hierarchy + siblings)
- ProjectConfig.decomposer config section with Zod validation
- Tracker plugin: added removeLabels support for label management

CLI:
- `ao spawn <project> <issue> --decompose` flag
- `--max-depth <n>` option for decomposition depth
- Spawns multiple sessions with lineage context for composite tasks

Backlog poller:
- Respects project.decomposer.enabled for auto-decomposition
- Posts plan as issue comment when requireApproval=true
- Auto-spawns subtasks with lineage when requireApproval=false

Config example:
  projects:
    my-app:
      decomposer:
        enabled: true
        maxDepth: 3
        requireApproval: true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add verification gate — issues stay open until human confirms fix

PR merge no longer auto-closes GitHub issues. Instead:

1. On PR merge: issue labeled `merged-unverified`, stays open
2. Human checks staging, then runs `ao verify <issue>` to close
3. Or `ao verify <issue> --fail` to flag verification failure

Changes:
- services.ts: labelIssuesForVerification() replaces closeIssuesForMergedSessions()
- New CLI command: `ao verify` (verify/fail/list modes)
- New API route: GET/POST /api/verify
- Dashboard: new Verify tab with one-click verify/fail buttons
- ao status: shows count of issues awaiting verification
- Idle session detection + auto-nudge reaction
- Use TERMINAL_STATUSES in batch-spawn dedup check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: rename decomposerConfig to avoid variable shadowing

Addresses Bugbot medium severity issue where inner  variable
shadowed outer  from getServices().

* fix: update pnpm-lock.yaml for new @anthropic-ai/sdk dependency

* fix: resolve remaining merge conflicts and syntax errors

- Remove leftover conflict markers in types.ts
- Remove orphaned code in services.ts
- Fix semicolon to comma in config.ts
- Remove unused import in verify.ts

* fix: address final Bugbot issues

- requireApproval path now exits early with continue to prevent
  fall-through to in-progress label and session spawned comment
- remove packages/core/package-lock.json (pnpm workspace should only
  use root pnpm-lock.yaml)

* fix: idle sessions now transition back to working

When agent resumes activity after being idle, the status correctly
transitions to 'working' instead of remaining stuck in 'idle' state.

* fix(backlog): remove agent:backlog label when claiming issues

When claiming issues from the backlog, the poller now removes the
agent:backlog label in addition to adding agent:in-progress. This
prevents duplicate work if all spawned sessions reach terminal status
and the poller rediscovers the issue.

* fix(test): use Set for TERMINAL_STATUSES mock

The mock for TERMINAL_STATUSES was an array, but the real export is a
ReadonlySet. Changed to use a Set so tests with non-empty sessions won't
crash when calling .has().

* fix(web): resolve backlog/dashboard regressions after branch sync

* fix(web): align dashboard events hook and SSE test mocks

* fix(notifier-openclaw): apply exponential delay from retry index

* fix(integration-tests): align openclaw retry delay expectation

* fix(web): keep dashboard header stats in sync

* fix(openclaw): keep first retry at base delay

---------

Co-authored-by: Agent <agent@example.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Harsh <harsh@Ubuntu-24-Forrest.lan>
Co-authored-by: Harsh <harsh@example.com>
This commit is contained in:
Harsh Batheja 2026-03-10 12:31:25 +05:30 committed by GitHub
parent 650d3ad695
commit 4edf19df32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 2058 additions and 116 deletions

View File

@ -1,7 +1,17 @@
import chalk from "chalk";
import ora from "ora";
import type { Command } from "commander";
import { loadConfig, type OrchestratorConfig } from "@composio/ao-core";
import {
loadConfig,
decompose,
getLeaves,
getSiblings,
formatPlanTree,
TERMINAL_STATUSES,
type OrchestratorConfig,
type DecomposerConfig,
DEFAULT_DECOMPOSER_CONFIG,
} from "@composio/ao-core";
import { exec } from "../lib/shell.js";
import { banner } from "../lib/format.js";
import { getSessionManager } from "../lib/create-session-manager.js";
@ -118,6 +128,8 @@ export function registerSpawn(program: Command): void {
.option("--agent <name>", "Override the agent plugin (e.g. codex, claude-code)")
.option("--claim-pr <pr>", "Immediately claim an existing PR for the spawned session")
.option("--assign-on-github", "Assign the claimed PR to the authenticated GitHub user")
.option("--decompose", "Decompose issue into subtasks before spawning")
.option("--max-depth <n>", "Max decomposition depth (default: 3)")
.action(
async (
projectId: string,
@ -127,6 +139,8 @@ export function registerSpawn(program: Command): void {
agent?: string;
claimPr?: string;
assignOnGithub?: boolean;
decompose?: boolean;
maxDepth?: string;
},
) => {
const config = loadConfig();
@ -144,13 +158,68 @@ export function registerSpawn(program: Command): void {
process.exit(1);
}
const claimOptions: SpawnClaimOptions = {
claimPr: opts.claimPr,
assignOnGithub: opts.assignOnGithub,
};
try {
await runSpawnPreflight(config, projectId, { claimPr: opts.claimPr });
await runSpawnPreflight(config, projectId, claimOptions);
await ensureLifecycleWorker(config, projectId);
await spawnSession(config, projectId, issueId, opts.open, opts.agent, {
claimPr: opts.claimPr,
assignOnGithub: opts.assignOnGithub,
});
if (opts.decompose && issueId) {
// Decompose the issue before spawning
const project = config.projects[projectId];
const decompConfig: DecomposerConfig = {
...DEFAULT_DECOMPOSER_CONFIG,
...(project.decomposer ?? {}),
maxDepth: opts.maxDepth
? parseInt(opts.maxDepth, 10)
: (project.decomposer?.maxDepth ?? 3),
};
const spinner = ora("Decomposing task...").start();
const issueTitle = issueId;
const plan = await decompose(issueTitle, decompConfig);
const leaves = getLeaves(plan.tree);
spinner.succeed(`Decomposed into ${chalk.bold(String(leaves.length))} subtasks`);
console.log();
console.log(chalk.dim(formatPlanTree(plan.tree)));
console.log();
if (leaves.length <= 1) {
console.log(chalk.yellow("Task is atomic — spawning directly."));
await spawnSession(config, projectId, issueId, opts.open, opts.agent, claimOptions);
} else {
// Create child issues and spawn sessions with lineage context
const sm = await getSessionManager(config);
console.log(chalk.bold(`Spawning ${leaves.length} sessions with lineage context...`));
console.log();
for (const leaf of leaves) {
const siblings = getSiblings(plan.tree, leaf.id);
try {
const session = await sm.spawn({
projectId,
issueId, // All work on the same parent issue for now
lineage: leaf.lineage,
siblings,
agent: opts.agent,
});
console.log(` ${chalk.green("✓")} ${session.id}${leaf.description}`);
} catch (err) {
console.error(
` ${chalk.red("✗")} ${leaf.description}${err instanceof Error ? err.message : err}`,
);
}
await new Promise((r) => setTimeout(r, 500));
}
}
} else {
await spawnSession(config, projectId, issueId, opts.open, opts.agent, claimOptions);
}
} catch (err) {
console.error(chalk.red(`${err instanceof Error ? err.message : String(err)}`));
process.exit(1);
@ -199,12 +268,12 @@ export function registerBatchSpawn(program: Command): void {
const spawnedIssues = new Set<string>();
// Load existing sessions once before the loop to avoid repeated reads + enrichment.
// Exclude dead/killed sessions so crashed sessions don't block respawning.
const deadStatuses = new Set(["killed", "done", "exited"]);
// Exclude terminal sessions so completed/merged sessions don't block respawning
// (e.g. when an issue is reopened after its PR was merged).
const existingSessions = await sm.list(projectId);
const existingIssueMap = new Map(
existingSessions
.filter((s) => s.issueId && !deadStatuses.has(s.status))
.filter((s) => s.issueId && !TERMINAL_STATUSES.has(s.status))
.map((s) => [s.issueId!.toLowerCase(), s.id]),
);

View File

@ -8,6 +8,8 @@ import {
type CIStatus,
type ReviewDecision,
type ActivityState,
type Tracker,
type ProjectConfig,
loadConfig,
} from "@composio/ao-core";
import { git, getTmuxSessions, getTmuxActivity } from "../lib/shell.js";
@ -286,6 +288,41 @@ export function registerStatus(program: Command): void {
` ${totalSessions} active session${totalSessions !== 1 ? "s" : ""} across ${projectIds.length} project${projectIds.length !== 1 ? "s" : ""}`,
),
);
// Check for issues awaiting verification across all projects
try {
const { createPluginRegistry } = await import("@composio/ao-core");
const registry = createPluginRegistry();
await registry.loadFromConfig(config, (pkg: string) => import(pkg));
let unverifiedTotal = 0;
for (const projectId of projectIds) {
const project: ProjectConfig | undefined = config.projects[projectId];
if (!project?.tracker) 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,
);
unverifiedTotal += issues.length;
} catch {
// Tracker query failed — not critical
}
}
if (unverifiedTotal > 0) {
console.log(
chalk.yellow(
`${unverifiedTotal} issue${unverifiedTotal !== 1 ? "s" : ""} awaiting verification (use \`ao verify --list\` to see them)`,
),
);
}
} catch {
// Plugin registry or tracker unavailable — skip silently
}
console.log();
}
});

View File

@ -0,0 +1,178 @@
import chalk from "chalk";
import type { Command } from "commander";
import {
type Tracker,
type ProjectConfig,
type OrchestratorConfig,
type PluginRegistry,
loadConfig,
} from "@composio/ao-core";
/**
* Resolve the target project from config.
* If --project is given, use that. If there is exactly one project, use it.
* Otherwise, error out asking the user to specify --project.
*/
function resolveProject(
config: OrchestratorConfig,
projectOpt?: string,
): { projectId: string; project: ProjectConfig } {
if (projectOpt) {
const project = config.projects[projectOpt];
if (!project) {
console.error(chalk.red(`Unknown project: ${projectOpt}`));
process.exit(1);
}
return { projectId: projectOpt, project };
}
const ids = Object.keys(config.projects);
if (ids.length === 0) {
console.error(chalk.red("No projects configured. Run `ao init` first."));
process.exit(1);
}
if (ids.length > 1) {
console.error(
chalk.red(`Multiple projects found. Specify one with --project: ${ids.join(", ")}`),
);
process.exit(1);
}
return { projectId: ids[0], project: config.projects[ids[0]] };
}
/**
* Get a Tracker instance from the plugin registry for the given project.
*/
async function getTracker(
config: OrchestratorConfig,
project: ProjectConfig,
): Promise<{ tracker: Tracker; registry: PluginRegistry }> {
if (!project.tracker) {
console.error(chalk.red("No tracker configured for this project."));
process.exit(1);
}
// getSessionManager internally creates the registry; we need the registry
// directly, so we replicate the same pattern from create-session-manager.
const { createPluginRegistry } = await import("@composio/ao-core");
const registry = createPluginRegistry();
await registry.loadFromConfig(config, (pkg: string) => import(pkg));
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker) {
console.error(chalk.red(`Tracker plugin "${project.tracker.plugin}" not found.`));
process.exit(1);
}
return { tracker, registry };
}
export function registerVerify(program: Command): void {
program
.command("verify")
.description("Mark an issue as verified (or failed) after checking the fix on staging")
.argument("[issue]", "Issue number or identifier to verify")
.option("-p, --project <id>", "Project ID (required if multiple projects)")
.option("--fail", "Mark verification as failed instead of passing")
.option("-c, --comment <msg>", "Custom comment to add")
.option("-l, --list", "List all issues with merged-unverified label")
.action(
async (
issue: string | undefined,
opts: { project?: string; fail?: boolean; comment?: string; list?: boolean },
) => {
let config: OrchestratorConfig;
try {
config = loadConfig();
} catch {
console.error(chalk.red("No config found. Run `ao init` first."));
process.exit(1);
return;
}
const { projectId, project } = resolveProject(config, opts.project);
const { tracker } = await getTracker(config, project);
// --list mode: show all merged-unverified issues
if (opts.list) {
if (!tracker.listIssues) {
console.error(chalk.red("Tracker does not support listing issues."));
process.exit(1);
}
const issues = await tracker.listIssues(
{ state: "open", labels: ["merged-unverified"] },
project,
);
if (issues.length === 0) {
console.log(chalk.dim("No merged-unverified issues found."));
return;
}
console.log(chalk.bold(`Merged-unverified issues in ${project.name || projectId}:\n`));
for (const i of issues) {
const labels = i.labels.length > 0 ? chalk.dim(` [${i.labels.join(", ")}]`) : "";
console.log(` ${chalk.cyan(i.id)} ${i.title}${labels}`);
if (i.url) {
console.log(` ${chalk.dim(i.url)}`);
}
}
console.log(
chalk.dim(
`\n ${issues.length} issue${issues.length !== 1 ? "s" : ""} awaiting verification`,
),
);
return;
}
// Verify/fail mode: requires an issue argument
if (!issue) {
console.error(chalk.red("Issue number is required. Usage: ao verify <issue>"));
process.exit(1);
}
if (!tracker.updateIssue) {
console.error(chalk.red("Tracker does not support updating issues."));
process.exit(1);
}
if (opts.fail) {
// Verification failed
const comment = opts.comment ?? "Verification failed — problem persists on staging.";
await tracker.updateIssue(
issue,
{
state: "open",
labels: ["verification-failed"],
removeLabels: ["merged-unverified"],
comment,
},
project,
);
console.log(chalk.red(`Issue ${issue} marked as verification-failed (kept open).`));
console.log(chalk.dim(` Comment: ${comment}`));
} else {
// Verification passed
const comment = opts.comment ?? "Verified — fix confirmed on staging.";
await tracker.updateIssue(
issue,
{
state: "closed",
labels: ["verified"],
removeLabels: ["merged-unverified"],
comment,
},
project,
);
console.log(chalk.green(`Issue ${issue} verified and closed.`));
console.log(chalk.dim(` Comment: ${comment}`));
}
},
);
}

View File

@ -11,6 +11,7 @@ import { registerDashboard } from "./commands/dashboard.js";
import { registerOpen } from "./commands/open.js";
import { registerStart, registerStop } from "./commands/start.js";
import { registerLifecycleWorker } from "./commands/lifecycle-worker.js";
import { registerVerify } from "./commands/verify.js";
const program = new Command();
@ -31,5 +32,6 @@ registerReviewCheck(program);
registerDashboard(program);
registerOpen(program);
registerLifecycleWorker(program);
registerVerify(program);
program.parse();

View File

@ -43,6 +43,7 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0",
"yaml": "^2.7.0",
"zod": "^3.24.0"
},

View File

@ -65,6 +65,20 @@ const AgentSpecificConfigSchema = z
})
.passthrough();
const DecomposerConfigSchema = z
.object({
enabled: z.boolean().default(false),
maxDepth: z.number().min(1).max(5).default(3),
model: z.string().default("claude-sonnet-4-20250514"),
requireApproval: z.boolean().default(true),
})
.default({
enabled: false,
maxDepth: 3,
model: "claude-sonnet-4-20250514",
requireApproval: true,
});
const ProjectConfigSchema = z.object({
name: z.string().optional(),
repo: z.string(),
@ -90,6 +104,7 @@ const ProjectConfigSchema = z.object({
.enum(["reuse", "delete", "ignore", "delete-new", "ignore-new", "kill-previous"])
.optional(),
opencodeIssueSessionStrategy: z.enum(["reuse", "delete", "ignore"]).optional(),
decomposer: DecomposerConfigSchema.optional(),
});
const DefaultPluginsSchema = z.object({
@ -258,6 +273,14 @@ function applyDefaultReactions(config: OrchestratorConfig): OrchestratorConfig {
priority: "action",
message: "PR is ready to merge",
},
"agent-idle": {
auto: true,
action: "send-to-agent",
message:
"You appear to be idle. If your task is not complete, continue working — write the code, commit, push, and create a PR. If you are blocked, explain what is blocking you.",
retries: 2,
escalateAfter: "15m",
},
"agent-stuck": {
auto: true,
action: "notify",

View File

@ -0,0 +1,276 @@
/**
* Task Decomposer LLM-driven recursive task decomposition.
*
* Classifies issues as atomic (one agent can handle it) or composite
* (needs to be broken into subtasks). Composite tasks are recursively
* decomposed until all leaves are atomic.
*
* Integration: sits upstream of SessionManager.spawn(). When enabled,
* complex issues are decomposed into child issues before agents are spawned.
*/
import Anthropic from "@anthropic-ai/sdk";
// =============================================================================
// TYPES
// =============================================================================
export type TaskKind = "atomic" | "composite";
export type TaskStatus = "pending" | "decomposing" | "ready" | "running" | "done" | "failed";
export interface TaskNode {
id: string; // hierarchical: "1", "1.2", "1.2.3"
depth: number;
description: string;
kind?: TaskKind;
status: TaskStatus;
lineage: string[]; // ancestor descriptions root→parent
children: TaskNode[];
result?: string;
issueId?: string; // tracker issue created for this subtask
sessionId?: string; // AO session working on this task
}
export interface DecompositionPlan {
id: string;
rootTask: string;
tree: TaskNode;
maxDepth: number;
phase: "decomposing" | "review" | "approved" | "executing" | "done" | "failed";
createdAt: string;
approvedAt?: string;
parentIssueId?: string;
}
export interface DecomposerConfig {
/** Enable auto-decomposition for backlog issues (default: false) */
enabled: boolean;
/** Max recursion depth (default: 3) */
maxDepth: number;
/** Model to use for decomposition (default: claude-sonnet-4-20250514) */
model: string;
/** Require human approval before executing decomposed plans (default: true) */
requireApproval: boolean;
}
export const DEFAULT_DECOMPOSER_CONFIG: DecomposerConfig = {
enabled: false,
maxDepth: 3,
model: "claude-sonnet-4-20250514",
requireApproval: true,
};
// =============================================================================
// LINEAGE CONTEXT
// =============================================================================
/** Format the task lineage as an indented hierarchy for LLM context. */
export function formatLineage(lineage: string[], current: string): string {
const parts = lineage.map((desc, i) => `${" ".repeat(i)}${i}. ${desc}`);
parts.push(`${" ".repeat(lineage.length)}${lineage.length}. ${current} <-- (this task)`);
return parts.join("\n");
}
/** Format sibling tasks for awareness context. */
export function formatSiblings(siblings: string[], current: string): string {
if (siblings.length === 0) return "";
const lines = siblings.map((s) => (s === current ? ` - ${s} <-- (you)` : ` - ${s}`));
return `Sibling tasks being worked on in parallel:\n${lines.join("\n")}`;
}
// =============================================================================
// LLM CALLS
// =============================================================================
const CLASSIFY_SYSTEM = `You decide whether a software task is "atomic" or "composite".
- "atomic" = a developer can implement this directly without needing to plan further. It may involve multiple steps, but they're all part of one coherent unit of work.
- "composite" = this clearly contains 2+ independent concerns that should be worked on separately (e.g., backend + frontend, or auth + database + UI).
Decision heuristics:
- If the task names a single feature, endpoint, component, or module: atomic.
- If the task bundles unrelated concerns (e.g., "build auth and set up CI"): composite.
- If you're at depth 2 or deeper in the hierarchy, it is almost certainly atomic only mark composite if you can name 2+ truly independent deliverables.
- When in doubt, choose atomic. Over-decomposition creates more overhead than under-decomposition.
Respond with ONLY the word "atomic" or "composite". Nothing else.`;
const DECOMPOSE_SYSTEM = `You are a pragmatic task decomposition engine for software projects.
Given a composite task, break it into the MINIMUM number of subtasks needed:
- A simple task might only need 2 subtasks.
- A complex task might need up to 7, but only if each is truly distinct.
- Do NOT pad with extra subtasks. Do NOT create "test and polish" or "define requirements" subtasks.
- Do NOT create subtasks that overlap or restate each other.
- Each subtask should represent real, distinct work.
Think about how an experienced developer would actually split this work.
Respond with a JSON array of strings, each being a subtask description. Example:
["Implement Stripe webhook handler", "Build subscription management UI"]
Nothing else just the JSON array.`;
async function classifyTask(
client: Anthropic,
model: string,
task: string,
lineage: string[],
): Promise<TaskKind> {
const context = formatLineage(lineage, task);
const res = await client.messages.create({
model,
max_tokens: 10,
system: CLASSIFY_SYSTEM,
messages: [{ role: "user", content: `Task hierarchy:\n${context}` }],
});
const text = res.content[0].type === "text" ? res.content[0].text.trim().toLowerCase() : "";
return text === "composite" ? "composite" : "atomic";
}
async function decomposeTask(
client: Anthropic,
model: string,
task: string,
lineage: string[],
): Promise<string[]> {
const context = formatLineage(lineage, task);
const res = await client.messages.create({
model,
max_tokens: 1024,
system: DECOMPOSE_SYSTEM,
messages: [{ role: "user", content: `Task hierarchy:\n${context}` }],
});
const text = res.content[0].type === "text" ? res.content[0].text.trim() : "[]";
const jsonMatch = text.match(/\[[\s\S]*\]/);
if (!jsonMatch) {
throw new Error(`Decomposition failed — no JSON array in response: ${text}`);
}
const subtasks = JSON.parse(jsonMatch[0]) as string[];
if (!Array.isArray(subtasks) || subtasks.length < 2) {
throw new Error(`Decomposition produced ${subtasks.length} subtasks — need at least 2`);
}
return subtasks;
}
// =============================================================================
// TREE OPERATIONS
// =============================================================================
function createTaskNode(
id: string,
description: string,
depth: number,
lineage: string[],
): TaskNode {
return { id, depth, description, status: "pending", lineage, children: [] };
}
/** Recursively decompose a task tree (planning phase — no execution). */
async function planTree(
client: Anthropic,
model: string,
task: TaskNode,
maxDepth: number,
): Promise<TaskNode> {
const kind = task.depth >= maxDepth ? "atomic" : await classifyTask(client, model, task.description, task.lineage);
task.kind = kind;
if (kind === "atomic") {
task.status = "ready";
return task;
}
task.status = "decomposing";
const subtaskDescriptions = await decomposeTask(client, model, task.description, task.lineage);
const childLineage = [...task.lineage, task.description];
task.children = subtaskDescriptions.map((desc, i) =>
createTaskNode(`${task.id}.${i + 1}`, desc, task.depth + 1, childLineage),
);
// Recurse on children concurrently
await Promise.all(task.children.map((child) => planTree(client, model, child, maxDepth)));
task.status = "ready";
return task;
}
// =============================================================================
// PUBLIC API
// =============================================================================
/** Create a decomposition plan for a task. */
export async function decompose(
taskDescription: string,
config: DecomposerConfig = DEFAULT_DECOMPOSER_CONFIG,
): Promise<DecompositionPlan> {
const client = new Anthropic();
const tree = createTaskNode("1", taskDescription, 0, []);
await planTree(client, config.model, tree, config.maxDepth);
return {
id: `plan-${Date.now()}`,
rootTask: taskDescription,
tree,
maxDepth: config.maxDepth,
phase: config.requireApproval ? "review" : "approved",
createdAt: new Date().toISOString(),
};
}
/** Collect all leaf (atomic) tasks from a tree. */
export function getLeaves(task: TaskNode): TaskNode[] {
if (task.children.length === 0) return [task];
return task.children.flatMap(getLeaves);
}
/** Get sibling task descriptions for a given task. */
export function getSiblings(root: TaskNode, taskId: string): string[] {
function findParent(node: TaskNode): TaskNode | null {
for (const child of node.children) {
if (child.id === taskId) return node;
const found = findParent(child);
if (found) return found;
}
return null;
}
const parent = findParent(root);
if (!parent) return [];
return parent.children.filter((c) => c.id !== taskId).map((c) => c.description);
}
/** Format the plan tree as a human-readable string. */
export function formatPlanTree(task: TaskNode, indent = 0): string {
const prefix = " ".repeat(indent);
const kindTag = task.kind === "atomic" ? "[ATOMIC]" : task.kind === "composite" ? "[COMPOSITE]" : "";
const statusTag = task.status !== "ready" ? ` (${task.status})` : "";
let line = `${prefix}${task.id}. ${kindTag} ${task.description}${statusTag}`;
if (task.children.length > 0) {
const childLines = task.children.map((c) => formatPlanTree(c, indent + 1)).join("\n");
line += "\n" + childLines;
}
return line;
}
/** Propagate done/failed status up the tree. */
export function propagateStatus(task: TaskNode): void {
if (task.children.length === 0) return;
task.children.forEach(propagateStatus);
if (task.children.every((c) => c.status === "done")) {
task.status = "done";
} else if (task.children.some((c) => c.status === "failed")) {
task.status = "failed";
} else if (task.children.some((c) => c.status === "running" || c.status === "done")) {
task.status = "running";
}
}

View File

@ -55,6 +55,25 @@ export type { LifecycleManagerDeps } from "./lifecycle-manager.js";
export { buildPrompt, BASE_AGENT_PROMPT } from "./prompt-builder.js";
export type { PromptBuildConfig } from "./prompt-builder.js";
// Decomposer — LLM-driven task decomposition
export {
decompose,
getLeaves,
getSiblings,
formatPlanTree,
formatLineage,
formatSiblings,
propagateStatus,
DEFAULT_DECOMPOSER_CONFIG,
} from "./decomposer.js";
export type {
TaskNode,
TaskKind,
TaskStatus,
DecompositionPlan,
DecomposerConfig,
} from "./decomposer.js";
// Orchestrator prompt — generates orchestrator context for `ao start`
export { generateOrchestratorPrompt } from "./orchestrator-prompt.js";
export type { OrchestratorPromptConfig } from "./orchestrator-prompt.js";

View File

@ -160,6 +160,8 @@ function statusToEventType(_from: SessionStatus | undefined, to: SessionStatus):
return "merge.completed";
case "needs_input":
return "session.needs_input";
case "idle":
return "session.idle";
case "stuck":
return "session.stuck";
case "errored":
@ -184,6 +186,8 @@ function eventToReactionKey(eventType: EventType): string | null {
return "merge-conflicts";
case "merge.ready":
return "approved-and-green";
case "session.idle":
return "agent-idle";
case "session.stuck":
return "agent-stuck";
case "session.needs_input":
@ -322,7 +326,8 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
if (activityState) {
if (activityState.state === "waiting_input") return "needs_input";
if (activityState.state === "exited") return "killed";
// active/ready/idle/blocked — proceed to PR checks below
if (activityState.state === "idle") return "idle";
// active/ready/blocked — proceed to PR checks below
} else {
// getActivityState returned null — fall back to terminal output parsing
const runtime = registry.get<Runtime>(
@ -400,6 +405,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
// 5. Default: if agent is active, it's working
if (
session.status === "spawning" ||
session.status === "idle" ||
session.status === SESSION_STATUS.STUCK ||
session.status === SESSION_STATUS.NEEDS_INPUT
) {

View File

@ -58,6 +58,12 @@ export interface PromptBuildConfig {
/** Explicit user prompt (appended last) */
userPrompt?: string;
/** Decomposition context — ancestor task chain (from decomposer) */
lineage?: string[];
/** Decomposition context — sibling task descriptions (from decomposer) */
siblings?: string[];
}
// =============================================================================
@ -159,6 +165,25 @@ export function buildPrompt(config: PromptBuildConfig): string {
sections.push(`## Project Rules\n${userRules}`);
}
// Layer 4: Decomposition context (lineage + siblings)
if (config.lineage && config.lineage.length > 0) {
const hierarchy = config.lineage.map((desc, i) => `${" ".repeat(i)}${i}. ${desc}`);
// Add current task marker using issueId or last lineage entry
const currentLabel = config.issueId ?? "this task";
hierarchy.push(`${" ".repeat(config.lineage.length)}${config.lineage.length}. ${currentLabel} <-- (this task)`);
sections.push(
`## Task Hierarchy\nThis task is part of a larger decomposed plan. Your place in the hierarchy:\n\n\`\`\`\n${hierarchy.join("\n")}\n\`\`\`\n\nStay focused on YOUR specific task. Do not implement functionality that belongs to other tasks in the hierarchy.`,
);
}
if (config.siblings && config.siblings.length > 0) {
const siblingLines = config.siblings.map((s) => ` - ${s}`);
sections.push(
`## Parallel Work\nSibling tasks being worked on in parallel:\n${siblingLines.join("\n")}\n\nDo not duplicate work that sibling tasks handle. If you need interfaces/types from siblings, define reasonable stubs.`,
);
}
// Explicit user prompt (appended last, highest priority)
if (config.userPrompt) {
sections.push(`## Additional Instructions\n${config.userPrompt}`);

View File

@ -825,6 +825,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
issueId: spawnConfig.issueId,
issueContext,
userPrompt: spawnConfig.prompt,
lineage: spawnConfig.lineage,
siblings: spawnConfig.siblings,
});
// Get agent launch config and create runtime — clean up workspace on failure

View File

@ -38,6 +38,7 @@ export type SessionStatus =
| "stuck"
| "errored"
| "killed"
| "idle"
| "done"
| "terminated";
@ -85,6 +86,7 @@ export const SESSION_STATUS = {
NEEDS_INPUT: "needs_input" as const,
STUCK: "stuck" as const,
ERRORED: "errored" as const,
IDLE: "idle" as const,
KILLED: "killed" as const,
DONE: "done" as const,
TERMINATED: "terminated" as const,
@ -180,6 +182,10 @@ export interface SessionSpawnConfig {
agent?: string;
/** Override the OpenCode subagent for this session (e.g. "sisyphus", "oracle") */
subagent?: string;
/** Decomposition context — ancestor task chain (passed to prompt builder) */
lineage?: string[];
/** Decomposition context — sibling task descriptions (passed to prompt builder) */
siblings?: string[];
}
/** Config for creating an orchestrator session */
@ -488,6 +494,7 @@ export interface IssueFilters {
export interface IssueUpdate {
state?: "open" | "in_progress" | "closed";
labels?: string[];
removeLabels?: string[];
assignee?: string;
comment?: string;
}
@ -729,6 +736,7 @@ export type EventType =
| "session.working"
| "session.exited"
| "session.killed"
| "session.idle"
| "session.stuck"
| "session.needs_input"
| "session.errored"
@ -921,6 +929,18 @@ export interface ProjectConfig {
| "kill-previous";
opencodeIssueSessionStrategy?: "reuse" | "delete" | "ignore";
/** Task decomposition configuration */
decomposer?: {
/** Enable auto-decomposition for backlog issues (default: false) */
enabled: boolean;
/** Max recursion depth (default: 3) */
maxDepth: number;
/** Model to use for decomposition (default: claude-sonnet-4-20250514) */
model: string;
/** Require human approval before executing decomposed plans (default: true) */
requireApproval: boolean;
};
}
export interface TrackerConfig {

View File

@ -108,7 +108,7 @@ describe("notifier-openclaw integration", () => {
await vi.advanceTimersByTimeAsync(0);
expect(fetchMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(100);
await vi.advanceTimersByTimeAsync(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
await promise;

View File

@ -6,11 +6,7 @@ import {
type OrchestratorEvent,
type PluginModule,
} from "@composio/ao-core";
import {
isRetryableHttpStatus,
normalizeRetryConfig,
validateUrl,
} from "@composio/ao-core/utils";
import { isRetryableHttpStatus, normalizeRetryConfig, validateUrl } from "@composio/ao-core/utils";
export const manifest = {
name: "openclaw",
@ -113,7 +109,8 @@ function formatActionsLine(actions: NotifyAction[]): string {
}
export function create(config?: Record<string, unknown>): Notifier {
const url = (typeof config?.url === "string" ? config.url : undefined) ??
const url =
(typeof config?.url === "string" ? config.url : undefined) ??
"http://127.0.0.1:18789/hooks/agent";
const token =
(typeof config?.token === "string" ? config.token : undefined) ??

View File

@ -265,6 +265,19 @@ function createGitHubTracker(): Tracker {
await gh(["issue", "reopen", identifier, "--repo", project.repo]);
}
// Handle label removal
if (update.removeLabels && update.removeLabels.length > 0) {
await gh([
"issue",
"edit",
identifier,
"--repo",
project.repo,
"--remove-label",
update.removeLabels.join(","),
]);
}
// Handle label changes
if (update.labels && update.labels.length > 0) {
await gh([

View File

@ -130,6 +130,10 @@ const mockRegistry: PluginRegistry = {
loadFromConfig: vi.fn(async () => {}),
};
const mockLifecycleManager = {
getStates: vi.fn(() => new Map()),
};
const mockConfig: OrchestratorConfig = {
configPath: "/tmp/ao-test/agent-orchestrator.yaml",
port: 3000,
@ -155,8 +159,10 @@ vi.mock("@/lib/services", () => ({
config: mockConfig,
registry: mockRegistry,
sessionManager: mockSessionManager,
lifecycleManager: mockLifecycleManager,
})),
getSCM: vi.fn(() => mockSCM),
startBacklogPoller: vi.fn(() => {}),
}));
// ── Import routes after mocking ───────────────────────────────────────

View File

@ -43,6 +43,18 @@ vi.mock("@composio/ao-core", () => ({
loadConfig: mockLoadConfig,
createPluginRegistry: () => mockRegistry,
createSessionManager: mockCreateSessionManager,
createLifecycleManager: () => ({
start: vi.fn(),
stop: vi.fn(),
getStates: vi.fn(),
check: vi.fn(),
}),
decompose: vi.fn(),
getLeaves: vi.fn(),
getSiblings: vi.fn(),
formatPlanTree: vi.fn(),
DEFAULT_DECOMPOSER_CONFIG: {},
TERMINAL_STATUSES: new Set(["merged", "killed"]) as ReadonlySet<string>,
}));
vi.mock("@composio/ao-plugin-runtime-tmux", () => ({ default: tmuxPlugin }));
@ -97,3 +109,95 @@ describe("services", () => {
expect(mockCreateSessionManager).toHaveBeenCalledTimes(1);
});
});
describe("pollBacklog", () => {
const mockUpdateIssue = vi.fn();
const mockListIssues = vi.fn();
const mockSpawn = vi.fn();
beforeEach(async () => {
vi.resetModules();
mockRegister.mockClear();
mockCreateSessionManager.mockReset();
mockLoadConfig.mockReset();
mockUpdateIssue.mockClear();
mockListIssues.mockClear();
mockSpawn.mockClear();
mockLoadConfig.mockReturnValue({
configPath: "/tmp/agent-orchestrator.yaml",
port: 3000,
readyThresholdMs: 300_000,
defaults: { runtime: "tmux", agent: "claude-code", workspace: "worktree", notifiers: [] },
projects: {
"test-project": {
path: "/tmp/test-project",
tracker: { plugin: "github" },
backlog: { label: "agent:backlog", maxConcurrent: 5 },
},
},
notifiers: {},
notificationRouting: { urgent: [], action: [], warning: [], info: [] },
reactions: {},
});
mockCreateSessionManager.mockReturnValue({
spawn: mockSpawn,
list: vi.fn().mockResolvedValue([]),
});
delete (globalThis as typeof globalThis & { _aoServices?: unknown })._aoServices;
delete (globalThis as typeof globalThis & { _aoServicesInit?: unknown })._aoServicesInit;
});
afterEach(() => {
delete (globalThis as typeof globalThis & { _aoServices?: unknown })._aoServices;
delete (globalThis as typeof globalThis & { _aoServicesInit?: unknown })._aoServicesInit;
});
it("removes agent:backlog label when claiming an issue", async () => {
mockListIssues.mockResolvedValue([
{
id: "123",
title: "Test Issue",
description: "Test description",
url: "https://github.com/test/test/issues/123",
state: "open",
labels: ["agent:backlog"],
},
]);
mockRegistry.get.mockImplementation((slot: string) => {
if (slot === "tracker") {
return {
name: "github",
listIssues: mockListIssues,
updateIssue: mockUpdateIssue,
};
}
if (slot === "agent") {
return { name: "claude-code" };
}
if (slot === "runtime") {
return { name: "tmux" };
}
if (slot === "workspace") {
return { name: "worktree" };
}
return null;
});
const { pollBacklog } = await import("../lib/services");
await pollBacklog();
expect(mockUpdateIssue).toHaveBeenCalledWith(
"123",
{
labels: ["agent:in-progress"],
removeLabels: ["agent:backlog"],
comment: "Claimed by agent orchestrator — session spawned.",
},
expect.objectContaining({ tracker: { plugin: "github" } }),
);
});
});

View File

@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { getBacklogIssues, startBacklogPoller } from "@/lib/services";
export const dynamic = "force-dynamic";
/**
* GET /api/backlog List backlog issues (labeled agent:backlog)
* Also starts the backlog auto-claim poller on first call.
*/
export async function GET() {
// Start the backlog poller (idempotent)
startBacklogPoller();
try {
const issues = await getBacklogIssues();
return NextResponse.json({ issues });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Failed to fetch backlog" },
{ status: 500 },
);
}
}

View File

@ -1,4 +1,4 @@
import { getServices } from "@/lib/services";
import { getServices, startBacklogPoller } from "@/lib/services";
import { sessionToDashboard } from "@/lib/serialize";
import { getAttentionLevel } from "@/lib/types";
@ -8,22 +8,28 @@ export const dynamic = "force-dynamic";
* GET /api/events SSE stream for real-time lifecycle events
*
* Sends session state updates to connected clients.
* Polls SessionManager.list() on an interval (no SSE push from core yet).
* Also starts the lifecycle manager and backlog poller on first connection.
*/
export async function GET(): Promise<Response> {
const encoder = new TextEncoder();
let heartbeat: ReturnType<typeof setInterval> | undefined;
let updates: ReturnType<typeof setInterval> | undefined;
// Start backlog poller (idempotent — lifecycle manager is started in services.ts)
startBacklogPoller();
const stream = new ReadableStream({
start(controller) {
// Send initial snapshot
void (async () => {
try {
const { sessionManager } = await getServices();
const { sessionManager, lifecycleManager } = await getServices();
const sessions = await sessionManager.list();
const dashboardSessions = sessions.map(sessionToDashboard);
// Include lifecycle state map for debugging/display
const lifecycleStates = Object.fromEntries(lifecycleManager.getStates());
const initialEvent = {
type: "snapshot",
sessions: dashboardSessions.map((s) => ({
@ -33,10 +39,10 @@ export async function GET(): Promise<Response> {
attentionLevel: getAttentionLevel(s),
lastActivityAt: s.lastActivityAt,
})),
lifecycle: lifecycleStates,
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`));
} catch {
// If services aren't available, send empty snapshot
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ type: "snapshot", sessions: [] })}\n\n`),
);
@ -62,7 +68,6 @@ export async function GET(): Promise<Response> {
const sessions = await sessionManager.list();
dashboardSessions = sessions.map(sessionToDashboard);
} catch {
// Transient service error — skip this poll, retry on next interval
return;
}
@ -79,7 +84,6 @@ export async function GET(): Promise<Response> {
};
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
} catch {
// enqueue failure means the stream is closed — clean up both intervals
clearInterval(updates);
clearInterval(heartbeat);
}

View File

@ -0,0 +1,104 @@
import { type NextRequest, NextResponse } from "next/server";
import { getServices } from "@/lib/services";
import { validateString } from "@/lib/validation";
import type { Tracker } from "@composio/ao-core";
export const dynamic = "force-dynamic";
/**
* GET /api/issues List open issues from all configured trackers.
* Query params: ?state=open|closed|all&label=agent:backlog&project=Saas-code
*/
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const state = (searchParams.get("state") ?? "open") as "open" | "closed" | "all";
const label = searchParams.get("label") ?? undefined;
const projectFilter = searchParams.get("project") ?? undefined;
try {
const { config, registry } = await getServices();
const allIssues: Array<{ projectId: string; id: string; title: string; url: string; state: string; labels: string[] }> = [];
for (const [projectId, project] of Object.entries(config.projects)) {
if (projectFilter && projectId !== projectFilter) continue;
if (!project.tracker) continue;
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker?.listIssues) continue;
try {
const issues = await tracker.listIssues(
{ state, labels: label ? [label] : undefined, limit: 50 },
project,
);
for (const issue of issues) {
allIssues.push({ projectId, ...issue });
}
} catch {
// Skip unavailable trackers
}
}
return NextResponse.json({ issues: allIssues });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Failed to fetch issues" },
{ status: 500 },
);
}
}
/**
* POST /api/issues Create a new issue and optionally add to backlog.
* Body: { projectId, title, description, addToBacklog?: boolean }
*/
export async function POST(request: NextRequest) {
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const titleErr = validateString(body.title, "title", 200);
if (titleErr) {
return NextResponse.json({ error: titleErr }, { status: 400 });
}
const projectId = body.projectId as string;
if (!projectId) {
return NextResponse.json({ error: "projectId is required" }, { status: 400 });
}
try {
const { config, registry } = await getServices();
const project = config.projects[projectId];
if (!project) {
return NextResponse.json({ error: `Unknown project: ${projectId}` }, { status: 404 });
}
if (!project.tracker) {
return NextResponse.json({ error: "No tracker configured for this project" }, { status: 422 });
}
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker?.createIssue) {
return NextResponse.json({ error: "Tracker does not support issue creation" }, { status: 422 });
}
const labels = body.addToBacklog ? ["agent:backlog"] : [];
const issue = await tracker.createIssue(
{
title: body.title as string,
description: (body.description as string) ?? "",
labels,
},
project,
);
return NextResponse.json({ issue: { projectId, ...issue } }, { status: 201 });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Failed to create issue" },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
import { getServices } from "@/lib/services";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export const dynamic = "force-dynamic";
const LABELS = [
{ name: "agent:backlog", color: "6B7280", description: "Available for agent to claim" },
{ name: "agent:in-progress", color: "7C3AED", description: "Agent is working on this" },
{ name: "agent:blocked", color: "DC2626", description: "Agent is blocked" },
{ name: "agent:done", color: "16A34A", description: "Agent completed this" },
];
/**
* POST /api/setup-labels Create agent labels on all configured repos.
* Idempotent skips labels that already exist.
*/
export async function POST() {
try {
const { config } = await getServices();
const results: Array<{ repo: string; label: string; status: string }> = [];
for (const project of Object.values(config.projects)) {
if (!project.repo) continue;
for (const label of LABELS) {
try {
await execFileAsync("gh", [
"label", "create", label.name,
"--repo", project.repo,
"--color", label.color,
"--description", label.description,
"--force",
], { timeout: 10_000 });
results.push({ repo: project.repo, label: label.name, status: "created" });
} catch {
results.push({ repo: project.repo, label: label.name, status: "exists" });
}
}
}
return NextResponse.json({ results });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Failed to setup labels" },
{ status: 500 },
);
}
}

View File

@ -0,0 +1,91 @@
import { type NextRequest, NextResponse } from "next/server";
import { getVerifyIssues, getServices } from "@/lib/services";
import type { Tracker } from "@composio/ao-core";
export const dynamic = "force-dynamic";
/**
* GET /api/verify List issues with `merged-unverified` label
*/
export async function GET() {
try {
const issues = await getVerifyIssues();
return NextResponse.json({ issues });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Failed to fetch verify issues" },
{ status: 500 },
);
}
}
/**
* POST /api/verify Verify or fail an issue
* Body: { issueId: string, projectId: string, action: "verify" | "fail", comment?: string }
*/
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { issueId, projectId, action, comment } = body as {
issueId: string;
projectId: string;
action: "verify" | "fail";
comment?: string;
};
if (!issueId || !projectId || !action) {
return NextResponse.json(
{ error: "Missing required fields: issueId, projectId, action" },
{ status: 400 },
);
}
if (action !== "verify" && action !== "fail") {
return NextResponse.json(
{ error: 'action must be "verify" or "fail"' },
{ status: 400 },
);
}
const { config, registry } = await getServices();
const project = config.projects[projectId];
if (!project?.tracker) {
return NextResponse.json({ error: `Project ${projectId} has no tracker` }, { status: 404 });
}
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker?.updateIssue) {
return NextResponse.json({ error: "Tracker does not support updateIssue" }, { status: 500 });
}
if (action === "verify") {
await tracker.updateIssue(
issueId,
{
state: "closed",
labels: ["verified", "agent:done"],
removeLabels: ["merged-unverified"],
comment: comment || "Verified — fix confirmed on staging.",
},
project,
);
} else {
await tracker.updateIssue(
issueId,
{
labels: ["verification-failed"],
removeLabels: ["merged-unverified"],
comment: comment || "Verification failed — problem persists on staging.",
},
project,
);
}
return NextResponse.json({ ok: true });
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Failed to update issue" },
{ status: 500 },
);
}
}

View File

@ -25,9 +25,11 @@ export default async function Home() {
let sessions: DashboardSession[] = [];
let orchestratorId: string | null = null;
let globalPause: GlobalPauseState | null = null;
let projectIds: string[] = [];
const projectName = getProjectName();
try {
const { config, registry, sessionManager } = await getServices();
projectIds = Object.keys(config.projects);
const allSessions = await sessionManager.list();
globalPause = resolveGlobalPause(allSessions);
@ -113,6 +115,7 @@ export default async function Home() {
orchestratorId={orchestratorId}
projectName={projectName}
initialGlobalPause={globalPause}
projectIds={projectIds}
/>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useMemo, useState, useEffect, useCallback, type FormEvent } from "react";
import {
type DashboardSession,
type DashboardStats,
@ -16,25 +16,45 @@ import { PRTableRow } from "./PRStatus";
import { DynamicFavicon } from "./DynamicFavicon";
import { useSessionEvents } from "@/hooks/useSessionEvents";
interface BacklogIssue {
id: string;
title: string;
url: string;
state: string;
labels: string[];
projectId: string;
}
interface DashboardProps {
initialSessions: DashboardSession[];
stats: DashboardStats;
orchestratorId?: string | null;
projectName?: string;
initialGlobalPause?: GlobalPauseState | null;
projectIds?: string[];
}
type Tab = "board" | "backlog" | "verify" | "prs";
const KANBAN_LEVELS = ["working", "pending", "review", "respond", "merge"] as const;
export function Dashboard({
initialSessions,
stats,
stats: _initialStats,
orchestratorId,
projectName,
initialGlobalPause,
projectIds = [],
}: DashboardProps) {
const { sessions, globalPause } = useSessionEvents(initialSessions, initialGlobalPause ?? null);
const [rateLimitDismissed, setRateLimitDismissed] = useState(false);
const [activeTab, setActiveTab] = useState<Tab>("board");
const [backlogIssues, setBacklogIssues] = useState<BacklogIssue[]>([]);
const [backlogLoading, setBacklogLoading] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
const [verifyIssues, setVerifyIssues] = useState<BacklogIssue[]>([]);
const [verifyLoading, setVerifyLoading] = useState(false);
const grouped = useMemo(() => {
const zones: Record<AttentionLevel, DashboardSession[]> = {
merge: [],
@ -57,6 +77,77 @@ export function Dashboard({
.sort((a, b) => mergeScore(a) - mergeScore(b));
}, [sessions]);
// Fetch backlog issues
const fetchBacklog = useCallback(async () => {
setBacklogLoading(true);
try {
const res = await fetch("/api/backlog");
if (res.ok) {
const data = await res.json();
setBacklogIssues(data.issues ?? []);
}
} catch {
// Non-critical
} finally {
setBacklogLoading(false);
}
}, []);
// Fetch verify issues
const fetchVerify = useCallback(async () => {
setVerifyLoading(true);
try {
const res = await fetch("/api/verify");
if (res.ok) {
const data = await res.json();
setVerifyIssues(data.issues ?? []);
}
} catch {
// Non-critical
} finally {
setVerifyLoading(false);
}
}, []);
useEffect(() => {
if (activeTab === "verify") {
fetchVerify();
const interval = setInterval(fetchVerify, 30_000);
return () => clearInterval(interval);
}
}, [activeTab, fetchVerify]);
const handleVerifyAction = async (
issueId: string,
projectId: string,
action: "verify" | "fail",
) => {
try {
const res = await fetch("/api/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ issueId, projectId, action }),
});
if (res.ok) {
setVerifyIssues((prev) =>
prev.filter((i) => !(i.id === issueId && i.projectId === projectId)),
);
} else {
console.error("Failed to update verify status:", await res.text());
}
} catch (err) {
console.error("Failed to update verify status:", err);
}
};
useEffect(() => {
if (activeTab === "backlog") {
fetchBacklog();
const interval = setInterval(fetchBacklog, 30_000);
return () => clearInterval(interval);
}
}, [activeTab, fetchBacklog]);
const handleSend = async (sessionId: string, message: string) => {
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/send`, {
method: "POST",
@ -102,35 +193,93 @@ export function Dashboard({
[sessions],
);
const liveStats = useMemo<DashboardStats>(
() => ({
totalSessions: sessions.length,
workingSessions: sessions.filter((s) => s.activity !== null && s.activity !== "exited")
.length,
openPRs: sessions.filter((s) => s.pr?.state === "open").length,
needsReview: sessions.filter(
(s) => s.pr && !s.pr.isDraft && s.pr.reviewDecision === "pending",
).length,
}),
[sessions],
);
// Counts for tab badges
const backlogCount = backlogIssues.length;
const verifyCount = verifyIssues.length;
const prCount = openPRs.length;
const needsAttention = grouped.respond.length + grouped.merge.length;
return (
<div className="px-8 py-7">
<DynamicFavicon sessions={sessions} projectName={projectName} />
{/* Header */}
<div className="mb-8 flex items-center justify-between border-b border-[var(--color-border-subtle)] pb-6">
<div className="mb-6 flex items-center justify-between border-b border-[var(--color-border-subtle)] pb-5">
<div className="flex items-center gap-6">
<h1 className="text-[17px] font-semibold tracking-[-0.02em] text-[var(--color-text-primary)]">
Orchestrator
{projectName ?? "Orchestrator"}
</h1>
<StatusLine stats={stats} />
<StatusLine stats={liveStats} needsAttention={needsAttention} />
</div>
{orchestratorId && (
<a
href={`/sessions/${encodeURIComponent(orchestratorId)}`}
className="orchestrator-btn flex items-center gap-2 rounded-[7px] px-4 py-2 text-[12px] font-semibold hover:no-underline"
>
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)] opacity-80" />
orchestrator
<svg
className="h-3 w-3 opacity-70"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
<div className="flex items-center gap-3">
{orchestratorId && (
<a
href={`/sessions/${encodeURIComponent(orchestratorId)}`}
className="orchestrator-btn flex items-center gap-2 rounded-[7px] px-4 py-2 text-[12px] font-semibold hover:no-underline"
>
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
</svg>
</a>
)}
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-accent)] opacity-80" />
orchestrator
<svg
className="h-3 w-3 opacity-70"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3" />
</svg>
</a>
)}
</div>
</div>
{/* Tab bar */}
<div className="mb-6 flex items-center gap-1 border-b border-[var(--color-border-subtle)]">
<TabButton
active={activeTab === "board"}
onClick={() => setActiveTab("board")}
badge={needsAttention > 0 ? needsAttention : undefined}
badgeColor="var(--color-status-error)"
>
Board
</TabButton>
<TabButton
active={activeTab === "backlog"}
onClick={() => setActiveTab("backlog")}
badge={backlogCount > 0 ? backlogCount : undefined}
badgeColor="var(--color-accent)"
>
Backlog
</TabButton>
<TabButton
active={activeTab === "verify"}
onClick={() => setActiveTab("verify")}
badge={verifyCount > 0 ? verifyCount : undefined}
badgeColor="rgb(245, 158, 11)"
>
Verify
</TabButton>
<TabButton
active={activeTab === "prs"}
onClick={() => setActiveTab("prs")}
badge={prCount > 0 ? prCount : undefined}
badgeColor="var(--color-status-ready)"
>
Pull Requests
</TabButton>
</div>
{globalPause && (
@ -176,86 +325,500 @@ export function Dashboard({
</div>
)}
{/* Kanban columns for active zones */}
{hasKanbanSessions && (
<div className="mb-8 flex gap-4 overflow-x-auto pb-2">
{KANBAN_LEVELS.map((level) =>
grouped[level].length > 0 ? (
<div key={level} className="min-w-[200px] flex-1">
<AttentionZone
level={level}
sessions={grouped[level]}
variant="column"
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
/>
</div>
) : null,
{/* Board tab */}
{activeTab === "board" && (
<>
{/* Kanban columns for active zones */}
{hasKanbanSessions ? (
<div className="mb-8 flex gap-4 overflow-x-auto pb-2">
{KANBAN_LEVELS.map((level) =>
grouped[level].length > 0 ? (
<div key={level} className="min-w-[200px] flex-1">
<AttentionZone
level={level}
sessions={grouped[level]}
variant="column"
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
/>
</div>
) : null,
)}
</div>
) : (
<EmptyState
title="No active sessions"
description="Add issues to your backlog or spawn agents from the CLI"
action={
<button
onClick={() => setActiveTab("backlog")}
className="rounded-md bg-[var(--color-accent)] px-4 py-2 text-[12px] font-semibold text-[var(--color-text-inverse)] hover:opacity-90"
>
View Backlog
</button>
}
/>
)}
{/* Done — full-width grid below Kanban */}
{grouped.done.length > 0 && (
<div className="mb-8">
<AttentionZone
level="done"
sessions={grouped.done}
variant="grid"
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
/>
</div>
)}
</>
)}
{/* Backlog tab */}
{activeTab === "backlog" && (
<div>
<div className="mb-4 flex items-center justify-between">
<p className="text-[12px] text-[var(--color-text-secondary)]">
Issues labeled{" "}
<code className="rounded bg-[var(--color-bg-subtle)] px-1.5 py-0.5 text-[11px] text-[var(--color-accent)]">
agent:backlog
</code>{" "}
are auto-claimed by agents. Max {5} concurrent.
</p>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
className="flex items-center gap-1.5 rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-[12px] font-semibold text-[var(--color-text-inverse)] hover:opacity-90"
>
<svg
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
viewBox="0 0 24 24"
>
<path d="M12 5v14M5 12h14" />
</svg>
New Issue
</button>
</div>
{showCreateForm && (
<CreateIssueForm
projectIds={projectIds}
onCreated={() => {
setShowCreateForm(false);
fetchBacklog();
}}
onCancel={() => setShowCreateForm(false)}
/>
)}
{backlogLoading && backlogIssues.length === 0 ? (
<div className="py-12 text-center text-[12px] text-[var(--color-text-tertiary)]">
Loading backlog...
</div>
) : backlogIssues.length === 0 ? (
<EmptyState
title="Backlog is empty"
description={`Add the "agent:backlog" label to GitHub issues, or create one above`}
/>
) : (
<div className="space-y-2">
{backlogIssues.map((issue) => (
<BacklogCard key={`${issue.projectId}-${issue.id}`} issue={issue} />
))}
</div>
)}
</div>
)}
{/* Done — full-width grid below Kanban */}
{grouped.done.length > 0 && (
<div className="mb-8">
<AttentionZone
level="done"
sessions={grouped.done}
variant="grid"
onSend={handleSend}
onKill={handleKill}
onMerge={handleMerge}
onRestore={handleRestore}
/>
{/* Verify tab */}
{activeTab === "verify" && (
<div>
<div className="mb-4">
<p className="text-[12px] text-[var(--color-text-secondary)]">
Issues labeled{" "}
<code
className="rounded bg-[var(--color-bg-subtle)] px-1.5 py-0.5 text-[11px]"
style={{ color: "rgb(245, 158, 11)" }}
>
merged-unverified
</code>{" "}
need human verification on staging.
</p>
</div>
{verifyLoading && verifyIssues.length === 0 ? (
<div className="py-12 text-center text-[12px] text-[var(--color-text-tertiary)]">
Loading issues to verify...
</div>
) : verifyIssues.length === 0 ? (
<EmptyState
title="Nothing to verify"
description="All merged issues have been verified"
/>
) : (
<div className="space-y-2">
{verifyIssues.map((issue) => (
<VerifyCard
key={`${issue.projectId}-${issue.id}`}
issue={issue}
onVerify={() => handleVerifyAction(issue.id, issue.projectId, "verify")}
onFail={() => handleVerifyAction(issue.id, issue.projectId, "fail")}
/>
))}
</div>
)}
</div>
)}
{/* PR Table */}
{openPRs.length > 0 && (
<div className="mx-auto max-w-[900px]">
<h2 className="mb-3 px-1 text-[10px] font-bold uppercase tracking-[0.10em] text-[var(--color-text-tertiary)]">
Pull Requests
</h2>
<div className="overflow-hidden rounded-[6px] border border-[var(--color-border-default)]">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-[var(--color-border-muted)]">
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
PR
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Title
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Size
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
CI
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Review
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Unresolved
</th>
</tr>
</thead>
<tbody>
{openPRs.map((pr) => (
<PRTableRow key={pr.number} pr={pr} />
))}
</tbody>
</table>
</div>
</div>
{/* PRs tab */}
{activeTab === "prs" && (
<>
{openPRs.length > 0 ? (
<div className="mx-auto max-w-[900px]">
<div className="overflow-hidden rounded-[6px] border border-[var(--color-border-default)]">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-[var(--color-border-muted)]">
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
PR
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Title
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Size
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
CI
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Review
</th>
<th className="px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Unresolved
</th>
</tr>
</thead>
<tbody>
{openPRs.map((pr) => (
<PRTableRow key={pr.number} pr={pr} />
))}
</tbody>
</table>
</div>
</div>
) : (
<EmptyState
title="No open PRs"
description="Agents will create PRs when they push code"
/>
)}
</>
)}
</div>
);
}
function StatusLine({ stats }: { stats: DashboardStats }) {
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function TabButton({
active,
onClick,
badge,
badgeColor,
children,
}: {
active: boolean;
onClick: () => void;
badge?: number;
badgeColor?: string;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`relative px-4 py-2.5 text-[12px] font-semibold transition-colors ${
active
? "border-b-2 border-[var(--color-accent)] text-[var(--color-text-primary)]"
: "border-b-2 border-transparent text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
}`}
>
{children}
{badge !== undefined && badge > 0 && (
<span
className="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-bold text-white"
style={{ backgroundColor: badgeColor ?? "var(--color-accent)" }}
>
{badge}
</span>
)}
</button>
);
}
function EmptyState({
title,
description,
action,
}: {
title: string;
description: string;
action?: React.ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-[var(--color-border-subtle)] py-16">
<div className="text-[14px] font-medium text-[var(--color-text-secondary)]">{title}</div>
<div className="max-w-[400px] text-center text-[12px] text-[var(--color-text-tertiary)]">
{description}
</div>
{action && <div className="mt-2">{action}</div>}
</div>
);
}
function BacklogCard({ issue }: { issue: BacklogIssue }) {
return (
<a
href={issue.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] px-4 py-3 transition-colors hover:border-[var(--color-border-default)] hover:no-underline"
>
<svg
className="h-4 w-4 shrink-0 text-[var(--color-status-ready)]"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v8M8 12h8" />
</svg>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-medium text-[var(--color-text-primary)] truncate">
#{issue.id} {issue.title}
</div>
<div className="mt-0.5 flex items-center gap-2">
<span className="text-[11px] text-[var(--color-text-tertiary)]">{issue.projectId}</span>
{issue.labels
.filter((l) => l !== "agent:backlog")
.map((label) => (
<span
key={label}
className="rounded-full bg-[var(--color-bg-subtle)] px-2 py-0.5 text-[10px] text-[var(--color-text-secondary)]"
>
{label}
</span>
))}
</div>
</div>
<span className="rounded-full bg-[rgba(88,166,255,0.1)] px-2.5 py-1 text-[10px] font-semibold text-[var(--color-accent)]">
queued
</span>
</a>
);
}
function VerifyCard({
issue,
onVerify,
onFail,
}: {
issue: BacklogIssue;
onVerify: () => Promise<void>;
onFail: () => Promise<void>;
}) {
const [acting, setActing] = useState<"verify" | "fail" | null>(null);
const handleAction = async (action: "verify" | "fail", handler: () => Promise<void>) => {
setActing(action);
try {
await handler();
} finally {
setActing(null);
}
};
return (
<div className="flex items-center gap-3 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] px-4 py-3">
<svg
className="h-4 w-4 shrink-0"
style={{ color: "rgb(245, 158, 11)" }}
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4M12 16h.01" />
</svg>
<div className="flex-1 min-w-0">
<a
href={issue.url}
target="_blank"
rel="noopener noreferrer"
className="text-[13px] font-medium text-[var(--color-text-primary)] truncate hover:underline"
>
#{issue.id} {issue.title}
</a>
<div className="mt-0.5 flex items-center gap-2">
<span className="text-[11px] text-[var(--color-text-tertiary)]">{issue.projectId}</span>
{issue.labels
.filter((l) => l !== "merged-unverified")
.map((label) => (
<span
key={label}
className="rounded-full bg-[var(--color-bg-subtle)] px-2 py-0.5 text-[10px] text-[var(--color-text-secondary)]"
>
{label}
</span>
))}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => handleAction("verify", onVerify)}
disabled={acting !== null}
className="rounded-md bg-[rgba(46,160,67,0.15)] px-3 py-1.5 text-[11px] font-semibold text-[rgb(46,160,67)] hover:bg-[rgba(46,160,67,0.25)] disabled:opacity-50"
>
{acting === "verify" ? "..." : "Verified"}
</button>
<button
onClick={() => handleAction("fail", onFail)}
disabled={acting !== null}
className="rounded-md bg-[rgba(248,81,73,0.15)] px-3 py-1.5 text-[11px] font-semibold text-[rgb(248,81,73)] hover:bg-[rgba(248,81,73,0.25)] disabled:opacity-50"
>
{acting === "fail" ? "..." : "Failed"}
</button>
</div>
</div>
);
}
function CreateIssueForm({
projectIds,
onCreated,
onCancel,
}: {
projectIds: string[];
onCreated: () => void;
onCancel: () => void;
}) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [selectedProject, setSelectedProject] = useState(projectIds[0] ?? "");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!title.trim() || !selectedProject) return;
setSubmitting(true);
setError(null);
try {
const res = await fetch("/api/issues", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId: selectedProject,
title: title.trim(),
description: description.trim(),
addToBacklog: true,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? "Failed to create issue");
}
setTitle("");
setDescription("");
onCreated();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create issue");
} finally {
setSubmitting(false);
}
};
return (
<form
onSubmit={handleSubmit}
className="mb-6 rounded-lg border border-[var(--color-border-default)] bg-[var(--color-bg-surface)] p-4"
>
{projectIds.length > 1 && (
<div className="mb-3">
<select
value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)}
className="w-full rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-base)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-accent)]"
>
{projectIds.map((id) => (
<option key={id} value={id}>
{id}
</option>
))}
</select>
</div>
)}
<div className="mb-3">
<input
type="text"
placeholder="Issue title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-base)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-accent)]"
autoFocus
/>
</div>
<div className="mb-3">
<textarea
placeholder="Description (optional — be specific about what the agent should do)"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full rounded-md border border-[var(--color-border-default)] bg-[var(--color-bg-base)] px-3 py-2 text-[13px] text-[var(--color-text-primary)] placeholder-[var(--color-text-tertiary)] outline-none focus:border-[var(--color-accent)] resize-none"
/>
</div>
{error && <div className="mb-3 text-[11px] text-[var(--color-status-error)]">{error}</div>}
<div className="flex items-center justify-between">
<span className="text-[11px] text-[var(--color-text-tertiary)]">
Will be created with <code className="text-[var(--color-accent)]">agent:backlog</code>{" "}
label
</span>
<div className="flex gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-md px-3 py-1.5 text-[12px] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]"
>
Cancel
</button>
<button
type="submit"
disabled={submitting || !title.trim()}
className="rounded-md bg-[var(--color-accent)] px-4 py-1.5 text-[12px] font-semibold text-[var(--color-text-inverse)] hover:opacity-90 disabled:opacity-50"
>
{submitting ? "Creating..." : "Create & Queue"}
</button>
</div>
</div>
</form>
);
}
function StatusLine({ stats, needsAttention }: { stats: DashboardStats; needsAttention: number }) {
if (stats.totalSessions === 0) {
return <span className="text-[13px] text-[var(--color-text-muted)]">no sessions</span>;
}
@ -266,8 +829,8 @@ function StatusLine({ stats }: { stats: DashboardStats }) {
? [{ value: stats.workingSessions, label: "working", color: "var(--color-status-working)" }]
: []),
...(stats.openPRs > 0 ? [{ value: stats.openPRs, label: "PRs" }] : []),
...(stats.needsReview > 0
? [{ value: stats.needsReview, label: "need review", color: "var(--color-status-attention)" }]
...(needsAttention > 0
? [{ value: needsAttention, label: "need attention", color: "var(--color-status-error)" }]
: []),
];

View File

@ -14,11 +14,23 @@ 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,
TERMINAL_STATUSES,
} from "@composio/ao-core";
// Static plugin imports — webpack needs these to be string literals
@ -34,6 +46,7 @@ export interface Services {
config: OrchestratorConfig;
registry: PluginRegistry;
sessionManager: OpenCodeSessionManager;
lifecycleManager: LifecycleManager;
}
// Cache in globalThis for Next.js HMR stability
@ -73,11 +86,313 @@ async function initServices(): Promise<Services> {
const sessionManager = createSessionManager({ config, registry });
const services = { config, registry, sessionManager };
// 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) {
processedIssues.add(key);
continue;
}
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker?.updateIssue) {
processedIssues.add(key);
continue;
}
try {
await tracker.updateIssue(
session.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) 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 workerSessions = allSessions.filter(
(s) => !s.id.endsWith("-orchestrator") && !TERMINAL_STATUSES.has(s.status),
);
const activeIssueIds = new Set(
workerSessions.filter((s) => s.issueId).map((s) => s.issueId!.toLowerCase()),
);
// 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) 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) 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) 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) return null;

View File

@ -129,6 +129,9 @@ importers:
packages/core:
dependencies:
'@anthropic-ai/sdk':
specifier: ^0.52.0
version: 0.52.0
yaml:
specifier: ^2.7.0
version: 2.8.2
@ -674,6 +677,10 @@ packages:
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
'@anthropic-ai/sdk@0.52.0':
resolution: {integrity: sha512-d4c+fg+xy9e46c8+YnrrgIQR45CZlAi7PwdzIfDXDM6ACxEZli1/fxhURsq30ZpMZy6LvSkr41jGq5aF5TD7rQ==}
hasBin: true
'@asamuzakjp/css-color@3.2.0':
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
@ -4094,6 +4101,8 @@ snapshots:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@anthropic-ai/sdk@0.52.0': {}
'@asamuzakjp/css-color@3.2.0':
dependencies:
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)