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:
parent
aefa6ef001
commit
f48c939d9b
|
|
@ -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]),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.52.0",
|
||||
"yaml": "^2.7.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) ??
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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" } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)" }]
|
||||
: []),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue