diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index 2829598ef..a02d290eb 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -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 ", "Override the agent plugin (e.g. codex, claude-code)") .option("--claim-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 ", "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(); // 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]), ); diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 8a213b714..d03b9c22c 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -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", 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(); } }); diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts new file mode 100644 index 000000000..4b639ed63 --- /dev/null +++ b/packages/cli/src/commands/verify.ts @@ -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", 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 ", "Project ID (required if multiple projects)") + .option("--fail", "Mark verification as failed instead of passing") + .option("-c, --comment ", "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 ")); + 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}`)); + } + }, + ); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f7cf1bbb6..6ee253ccd 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -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(); diff --git a/packages/core/package.json b/packages/core/package.json index cb4c47974..3c6bc66b9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -43,6 +43,7 @@ "clean": "rm -rf dist" }, "dependencies": { + "@anthropic-ai/sdk": "^0.52.0", "yaml": "^2.7.0", "zod": "^3.24.0" }, diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 1607615ca..11ca6fb23 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -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", diff --git a/packages/core/src/decomposer.ts b/packages/core/src/decomposer.ts new file mode 100644 index 000000000..4868238a8 --- /dev/null +++ b/packages/core/src/decomposer.ts @@ -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 { + 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 { + 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 { + 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 { + 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"; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e973aa2dd..9526c66c2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index b6e75bb78..f05547f14 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -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( @@ -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 ) { diff --git a/packages/core/src/prompt-builder.ts b/packages/core/src/prompt-builder.ts index eb0a972fe..2cbcdccbb 100644 --- a/packages/core/src/prompt-builder.ts +++ b/packages/core/src/prompt-builder.ts @@ -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}`); diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index d2fa9387b..3a072f9cb 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -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 diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ccbdf0104..d083f2c88 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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 { diff --git a/packages/integration-tests/src/notifier-openclaw.integration.test.ts b/packages/integration-tests/src/notifier-openclaw.integration.test.ts index 2d29a527d..6fb85ea60 100644 --- a/packages/integration-tests/src/notifier-openclaw.integration.test.ts +++ b/packages/integration-tests/src/notifier-openclaw.integration.test.ts @@ -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; diff --git a/packages/plugins/notifier-openclaw/src/index.ts b/packages/plugins/notifier-openclaw/src/index.ts index a5b10166b..f016b2420 100644 --- a/packages/plugins/notifier-openclaw/src/index.ts +++ b/packages/plugins/notifier-openclaw/src/index.ts @@ -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): 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) ?? diff --git a/packages/plugins/tracker-github/src/index.ts b/packages/plugins/tracker-github/src/index.ts index 7f3afef26..e86d638c7 100644 --- a/packages/plugins/tracker-github/src/index.ts +++ b/packages/plugins/tracker-github/src/index.ts @@ -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([ diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index b2467a077..289f881e3 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -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 ─────────────────────────────────────── diff --git a/packages/web/src/__tests__/services.test.ts b/packages/web/src/__tests__/services.test.ts index 944cdc3f1..e4f94dd64 100644 --- a/packages/web/src/__tests__/services.test.ts +++ b/packages/web/src/__tests__/services.test.ts @@ -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, })); 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" } }), + ); + }); +}); diff --git a/packages/web/src/app/api/backlog/route.ts b/packages/web/src/app/api/backlog/route.ts new file mode 100644 index 000000000..27c869017 --- /dev/null +++ b/packages/web/src/app/api/backlog/route.ts @@ -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 }, + ); + } +} diff --git a/packages/web/src/app/api/events/route.ts b/packages/web/src/app/api/events/route.ts index 2d37eeab8..653531f3a 100644 --- a/packages/web/src/app/api/events/route.ts +++ b/packages/web/src/app/api/events/route.ts @@ -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 { const encoder = new TextEncoder(); let heartbeat: ReturnType | undefined; let updates: ReturnType | 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 { 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 { 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 { }; 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); } diff --git a/packages/web/src/app/api/issues/route.ts b/packages/web/src/app/api/issues/route.ts new file mode 100644 index 000000000..607360b9d --- /dev/null +++ b/packages/web/src/app/api/issues/route.ts @@ -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", 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 | 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", 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 }, + ); + } +} diff --git a/packages/web/src/app/api/setup-labels/route.ts b/packages/web/src/app/api/setup-labels/route.ts new file mode 100644 index 000000000..7b0c32465 --- /dev/null +++ b/packages/web/src/app/api/setup-labels/route.ts @@ -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 }, + ); + } +} diff --git a/packages/web/src/app/api/verify/route.ts b/packages/web/src/app/api/verify/route.ts new file mode 100644 index 000000000..5456b654f --- /dev/null +++ b/packages/web/src/app/api/verify/route.ts @@ -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", 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 }, + ); + } +} diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 33e9d2f1e..68194f7e9 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -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} /> ); } diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 76dd6f41f..cdca98b17 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -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("board"); + const [backlogIssues, setBacklogIssues] = useState([]); + const [backlogLoading, setBacklogLoading] = useState(false); + const [showCreateForm, setShowCreateForm] = useState(false); + const [verifyIssues, setVerifyIssues] = useState([]); + const [verifyLoading, setVerifyLoading] = useState(false); + const grouped = useMemo(() => { const zones: Record = { 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( + () => ({ + 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 (
+ {/* Header */} -
+

- Orchestrator + {projectName ?? "Orchestrator"}

- +
- {orchestratorId && ( - - - orchestrator - + {orchestratorId && ( + - - - - )} + + orchestrator + + + + + )} +
+
+ + {/* Tab bar */} +
+ setActiveTab("board")} + badge={needsAttention > 0 ? needsAttention : undefined} + badgeColor="var(--color-status-error)" + > + Board + + setActiveTab("backlog")} + badge={backlogCount > 0 ? backlogCount : undefined} + badgeColor="var(--color-accent)" + > + Backlog + + setActiveTab("verify")} + badge={verifyCount > 0 ? verifyCount : undefined} + badgeColor="rgb(245, 158, 11)" + > + Verify + + setActiveTab("prs")} + badge={prCount > 0 ? prCount : undefined} + badgeColor="var(--color-status-ready)" + > + Pull Requests +
{globalPause && ( @@ -176,86 +325,500 @@ export function Dashboard({
)} - {/* Kanban columns for active zones */} - {hasKanbanSessions && ( -
- {KANBAN_LEVELS.map((level) => - grouped[level].length > 0 ? ( -
- -
- ) : null, + {/* Board tab */} + {activeTab === "board" && ( + <> + {/* Kanban columns for active zones */} + {hasKanbanSessions ? ( +
+ {KANBAN_LEVELS.map((level) => + grouped[level].length > 0 ? ( +
+ +
+ ) : null, + )} +
+ ) : ( + 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 + + } + /> + )} + + {/* Done — full-width grid below Kanban */} + {grouped.done.length > 0 && ( +
+ +
+ )} + + )} + + {/* Backlog tab */} + {activeTab === "backlog" && ( +
+
+

+ Issues labeled{" "} + + agent:backlog + {" "} + are auto-claimed by agents. Max {5} concurrent. +

+ +
+ + {showCreateForm && ( + { + setShowCreateForm(false); + fetchBacklog(); + }} + onCancel={() => setShowCreateForm(false)} + /> + )} + + {backlogLoading && backlogIssues.length === 0 ? ( +
+ Loading backlog... +
+ ) : backlogIssues.length === 0 ? ( + + ) : ( +
+ {backlogIssues.map((issue) => ( + + ))} +
)}
)} - {/* Done — full-width grid below Kanban */} - {grouped.done.length > 0 && ( -
- + {/* Verify tab */} + {activeTab === "verify" && ( +
+
+

+ Issues labeled{" "} + + merged-unverified + {" "} + need human verification on staging. +

+
+ + {verifyLoading && verifyIssues.length === 0 ? ( +
+ Loading issues to verify... +
+ ) : verifyIssues.length === 0 ? ( + + ) : ( +
+ {verifyIssues.map((issue) => ( + handleVerifyAction(issue.id, issue.projectId, "verify")} + onFail={() => handleVerifyAction(issue.id, issue.projectId, "fail")} + /> + ))} +
+ )}
)} - {/* PR Table */} - {openPRs.length > 0 && ( -
-

- Pull Requests -

-
- - - - - - - - - - - - - {openPRs.map((pr) => ( - - ))} - -
- PR - - Title - - Size - - CI - - Review - - Unresolved -
-
-
+ {/* PRs tab */} + {activeTab === "prs" && ( + <> + {openPRs.length > 0 ? ( +
+
+ + + + + + + + + + + + + {openPRs.map((pr) => ( + + ))} + +
+ PR + + Title + + Size + + CI + + Review + + Unresolved +
+
+
+ ) : ( + + )} + )}
); } -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 ( + + ); +} + +function EmptyState({ + title, + description, + action, +}: { + title: string; + description: string; + action?: React.ReactNode; +}) { + return ( +
+
{title}
+
+ {description} +
+ {action &&
{action}
} +
+ ); +} + +function BacklogCard({ issue }: { issue: BacklogIssue }) { + return ( + + + + + +
+
+ #{issue.id} {issue.title} +
+
+ {issue.projectId} + {issue.labels + .filter((l) => l !== "agent:backlog") + .map((label) => ( + + {label} + + ))} +
+
+ + queued + +
+ ); +} + +function VerifyCard({ + issue, + onVerify, + onFail, +}: { + issue: BacklogIssue; + onVerify: () => Promise; + onFail: () => Promise; +}) { + const [acting, setActing] = useState<"verify" | "fail" | null>(null); + + const handleAction = async (action: "verify" | "fail", handler: () => Promise) => { + setActing(action); + try { + await handler(); + } finally { + setActing(null); + } + }; + + return ( +
+ + + + +
+ + #{issue.id} {issue.title} + +
+ {issue.projectId} + {issue.labels + .filter((l) => l !== "merged-unverified") + .map((label) => ( + + {label} + + ))} +
+
+
+ + +
+
+ ); +} + +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(null); + + const handleSubmit = async (e: FormEvent) => { + 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 ( +
+ {projectIds.length > 1 && ( +
+ +
+ )} +
+ 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 + /> +
+
+