diff --git a/.github/workflows/deploy-vps.yml b/.github/workflows/deploy-vps.yml index 911622500..0127041be 100644 --- a/.github/workflows/deploy-vps.yml +++ b/.github/workflows/deploy-vps.yml @@ -1,6 +1,12 @@ name: Deploy to VPS on: + workflow_dispatch: + inputs: + deploy_sha: + description: Optional exact commit SHA to deploy. Defaults to the head of the dispatched ref. + required: false + type: string workflow_run: workflows: [CI] types: [completed] @@ -13,40 +19,86 @@ concurrency: jobs: deploy: if: >- - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' + github.event_name == 'workflow_dispatch' || + ( + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + ) runs-on: ubuntu-latest steps: - - name: Verify SHA is current tip of main + - name: Resolve deploy target + id: resolve_deploy env: GH_TOKEN: ${{ github.token }} + DEPLOY_SHA_INPUT: ${{ inputs.deploy_sha }} run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + DEPLOY_SHA="$DEPLOY_SHA_INPUT" + if [ -z "$DEPLOY_SHA" ]; then + DEPLOY_SHA="${GITHUB_SHA}" + fi + if ! printf '%s' "$DEPLOY_SHA" | grep -Eq '^[0-9a-f]{40}$'; then + echo "Invalid deploy_sha: expected a 40-character lowercase hex SHA, got '$DEPLOY_SHA'." >&2 + exit 1 + fi + echo "Manual dispatch: deploying SHA $DEPLOY_SHA from ref ${GITHUB_REF_NAME}." + echo "deploy_sha=$DEPLOY_SHA" >> "$GITHUB_OUTPUT" + echo "fetch_ref=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + echo "should_deploy=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + DEPLOY_SHA="${{ github.event.workflow_run.head_sha }}" MAIN_SHA=$(gh api repos/${{ github.repository }}/git/ref/heads/main --jq '.object.sha') if [ "$DEPLOY_SHA" != "$MAIN_SHA" ]; then echo "Skipping: CI SHA $DEPLOY_SHA is not the current tip of main ($MAIN_SHA). Likely a stale rerun." + echo "deploy_sha=$DEPLOY_SHA" >> "$GITHUB_OUTPUT" + echo "fetch_ref=main" >> "$GITHUB_OUTPUT" + echo "should_deploy=false" >> "$GITHUB_OUTPUT" exit 0 fi echo "SHA verified: $DEPLOY_SHA is the current tip of main." + echo "deploy_sha=$DEPLOY_SHA" >> "$GITHUB_OUTPUT" + echo "fetch_ref=main" >> "$GITHUB_OUTPUT" + echo "should_deploy=true" >> "$GITHUB_OUTPUT" + + - name: Require VPS host fingerprint + env: + VPS_HOST_FINGERPRINT: ${{ secrets.VPS_HOST_FINGERPRINT }} + run: | + set -e + if [ -z "$VPS_HOST_FINGERPRINT" ]; then + echo "VPS_HOST_FINGERPRINT secret is required for host key verification." >&2 + exit 1 + fi - name: Deploy via SSH + if: steps.resolve_deploy.outputs.should_deploy == 'true' + env: + DEPLOY_SHA: ${{ steps.resolve_deploy.outputs.deploy_sha }} + FETCH_REF: ${{ steps.resolve_deploy.outputs.fetch_ref }} uses: appleboy/ssh-action@v1.2.2 with: host: ${{ secrets.VPS_HOST }} username: root key: ${{ secrets.VPS_SSH_KEY }} - fingerprint: SHA256:Yat7fObKrTBZGxr0HGlN40oWhC/LlLN9SpjzPifsLzw + fingerprint: ${{ secrets.VPS_HOST_FINGERPRINT }} + envs: DEPLOY_SHA,FETCH_REF script: | set -e - DEPLOY_SHA="${{ github.event.workflow_run.head_sha }}" cd /root/agent-orchestrator - echo "==> Fetching latest changes..." - git fetch origin main + echo "==> Ensuring full worktree..." + if [ "$(git config --bool core.sparseCheckout || echo false)" = "true" ]; then + git sparse-checkout disable + fi + echo "==> Fetching latest changes for $FETCH_REF..." + git fetch origin "$FETCH_REF" echo "==> Current: $(git rev-parse --short HEAD)" - echo "==> Deploying CI-passed SHA: ${DEPLOY_SHA:0:7}" - git checkout "$DEPLOY_SHA" + echo "==> Deploying target SHA: ${DEPLOY_SHA:0:7}" + git checkout --force "$DEPLOY_SHA" echo "==> Updated: $(git rev-parse --short HEAD)" + test -f packages/web/server/start-all.ts echo "==> Installing dependencies..." pnpm install --frozen-lockfile echo "==> Building..." diff --git a/README.md b/README.md index 4d67f1c3f..22d8f0920 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Spawn parallel AI coding agents, each in its own git worktree. Agents autonomously fix CI failures, address review comments, and open PRs — you supervise from one dashboard. [![GitHub stars](https://img.shields.io/github/stars/ComposioHQ/agent-orchestrator?style=flat-square)](https://github.com/ComposioHQ/agent-orchestrator/stargazers) +[![npm version](https://img.shields.io/npm/v/%40composio%2Fao?style=flat-square)](https://www.npmjs.com/package/@composio/ao) [![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE) [![PRs merged](https://img.shields.io/badge/PRs_merged-61-brightgreen?style=flat-square)](https://github.com/ComposioHQ/agent-orchestrator/pulls?q=is%3Amerged) [![Tests](https://img.shields.io/badge/test_cases-3%2C288-blue?style=flat-square)](https://github.com/ComposioHQ/agent-orchestrator/releases/tag/metrics-v1) diff --git a/packages/cli/__tests__/index.test.ts b/packages/cli/__tests__/index.test.ts new file mode 100644 index 000000000..01f781616 --- /dev/null +++ b/packages/cli/__tests__/index.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it, vi } from "vitest"; + +const parse = vi.fn(); + +vi.mock("../src/program.js", () => ({ + createProgram: () => ({ parse }), +})); + +describe("cli entrypoint", () => { + it("parses the created program", async () => { + await import("../src/index.js"); + expect(parse).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cli/__tests__/lib/session-utils.test.ts b/packages/cli/__tests__/lib/session-utils.test.ts index 5d42dadab..a2d072736 100644 --- a/packages/cli/__tests__/lib/session-utils.test.ts +++ b/packages/cli/__tests__/lib/session-utils.test.ts @@ -146,4 +146,49 @@ describe("isOrchestratorSessionName", () => { const config = makeConfig({ "my-app": { sessionPrefix: "app" } }); expect(isOrchestratorSessionName(config, "app-12", "my-app")).toBe(false); }); + + it("matches worktree orchestrator IDs (orchestrator-N) for a known project", () => { + const config = makeConfig({ "my-app": { sessionPrefix: "app" } }); + expect(isOrchestratorSessionName(config, "app-orchestrator-1", "my-app")).toBe(true); + expect(isOrchestratorSessionName(config, "app-orchestrator-42", "my-app")).toBe(true); + }); + + it("matches worktree orchestrator IDs without an explicit project", () => { + const config = makeConfig({ "my-app": { sessionPrefix: "app" } }); + expect(isOrchestratorSessionName(config, "app-orchestrator-1")).toBe(true); + }); + + it("does not false-positive on a worker when prefix ends with -orchestrator", () => { + const config = makeConfig({ "my-app": { sessionPrefix: "my-orchestrator" } }); + // my-orchestrator-1 is a worker session, not a worktree orchestrator + expect(isOrchestratorSessionName(config, "my-orchestrator-1", "my-app")).toBe(false); + // my-orchestrator-orchestrator is the canonical orchestrator + expect(isOrchestratorSessionName(config, "my-orchestrator-orchestrator", "my-app")).toBe(true); + }); + + it("does not cross-project false-positive when one prefix is another's {prefix}-orchestrator", () => { + // project A prefix "app", project B prefix "app-orchestrator" + // "app-orchestrator-1" is a worker of B, NOT an orchestrator of A + const config = makeConfig({ + "project-a": { sessionPrefix: "app" }, + "project-b": { sessionPrefix: "app-orchestrator" }, + }); + expect(isOrchestratorSessionName(config, "app-orchestrator-1")).toBe(false); + // "app-orchestrator-orchestrator-1" IS an orchestrator of B + expect(isOrchestratorSessionName(config, "app-orchestrator-orchestrator-1")).toBe(true); + }); + + it("does not cross-project false-positive when projectId is provided", () => { + // project A prefix "app", project B prefix "app-orchestrator" + // "app-orchestrator-1" is a worker of B — must not be classified as orchestrator of A + // even when called with projectId="project-a" + const config = makeConfig({ + "project-a": { sessionPrefix: "app" }, + "project-b": { sessionPrefix: "app-orchestrator" }, + }); + expect(isOrchestratorSessionName(config, "app-orchestrator-1", "project-a")).toBe(false); + // The canonical orchestrator of A is still recognized + expect(isOrchestratorSessionName(config, "app-orchestrator", "project-a")).toBe(true); + expect(isOrchestratorSessionName(config, "app-orchestrator-2", "project-a")).toBe(false); + }); }); diff --git a/packages/cli/__tests__/options/version.test.ts b/packages/cli/__tests__/options/version.test.ts new file mode 100644 index 000000000..506c1494e --- /dev/null +++ b/packages/cli/__tests__/options/version.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import packageJson from "../../package.json" with { type: "json" }; +import { getCliVersion } from "../../src/options/version.js"; + +describe("getCliVersion", () => { + it("matches the CLI package version", () => { + expect(getCliVersion()).toBe(packageJson.version); + }); +}); diff --git a/packages/cli/__tests__/program.test.ts b/packages/cli/__tests__/program.test.ts new file mode 100644 index 000000000..656ac51be --- /dev/null +++ b/packages/cli/__tests__/program.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import packageJson from "../package.json" with { type: "json" }; +import { createProgram } from "../src/program.js"; + +describe("createProgram", () => { + it("uses the CLI package version", () => { + expect(createProgram().version()).toBe(packageJson.version); + }); +}); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index d0e7fcc07..9edd697b9 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -343,7 +343,12 @@ async function sendTestNotifications( const targets = new Map(); for (const [name, notifierConfig] of configuredNotifiers) { - targets.set(notifierConfig.plugin, { label: name, pluginName: notifierConfig.plugin }); + if (notifierConfig.plugin) { + targets.set(notifierConfig.plugin, { label: name, pluginName: notifierConfig.plugin }); + } else { + // External plugin without explicit plugin name - manifest.name not yet resolved + warn(`${name}: notifier plugin name not resolved (external plugin may not be loaded yet)`); + } } for (const name of activeNotifierNames) { diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index 9d54635ce..e6713758f 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -131,6 +131,18 @@ async function spawnSession( if (branchStr) console.log(` Branch: ${chalk.dim(branchStr)}`); if (claimedPrUrl) console.log(` PR: ${chalk.dim(claimedPrUrl)}`); + // Warn if prompt delivery failed (for post-launch agents like Claude Code) + const promptDelivered = session.metadata?.promptDelivered; + if (promptDelivered === "false") { + console.log(); + console.warn( + chalk.yellow( + ` ⚠ Prompt delivery failed — agent may be idle.\n` + + ` Use '${chalk.cyan("ao send " + session.id + ' "message..."')}' to send instructions manually.`, + ), + ); + } + // Show the tmux name for attaching (stored in metadata or runtimeHandle) const tmuxTarget = session.runtimeHandle?.id ?? session.id; console.log(` Attach: ${chalk.dim(`tmux attach -t ${tmuxTarget}`)}`); @@ -159,7 +171,7 @@ export function registerSpawn(program: Command): void { .command("spawn") .description("Spawn a single agent session") .argument("[first]", "Issue identifier (project is auto-detected)") - .argument("[second]", "", /* hidden second arg to catch old two-arg usage */) + .argument("[second]", "" /* hidden second arg to catch old two-arg usage */) .option("--open", "Open session in terminal tab") .option("--agent ", "Override the agent plugin (e.g. codex, claude-code)") .option("--claim-pr ", "Immediately claim an existing PR for the spawned session") diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 625e06370..a65499cf3 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -74,7 +74,11 @@ async function gatherSessionInfo( scm: SCM, projectConfig: ReturnType, ): Promise { - const suppressPROwnership = isOrchestratorSession(session); + const sessionPrefix = projectConfig.projects[session.projectId]?.sessionPrefix ?? session.projectId; + const allSessionPrefixes = Object.entries(projectConfig.projects).map( + ([id, p]) => p.sessionPrefix ?? id, + ); + const suppressPROwnership = isOrchestratorSession(session, sessionPrefix, allSessionPrefixes); let branch = session.branch; const status = session.status; const summary = session.metadata["summary"] ?? null; @@ -144,7 +148,7 @@ async function gatherSessionInfo( return { name: session.id, - role: isOrchestratorSession(session) ? "orchestrator" : "worker", + role: isOrchestratorSession(session, sessionPrefix, allSessionPrefixes) ? "orchestrator" : "worker", branch, status, summary, @@ -386,7 +390,7 @@ export function registerStatus(program: Command): void { let unverifiedTotal = 0; for (const projectId of projectIds) { const project: ProjectConfig | undefined = config.projects[projectId]; - if (!project?.tracker) continue; + if (!project?.tracker?.plugin) continue; const tracker = registry.get("tracker", project.tracker.plugin); if (!tracker?.listIssues) continue; try { diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index 40d6ee043..c82223a87 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -60,6 +60,10 @@ async function getTracker( const registry = createPluginRegistry(); await registry.loadFromConfig(config, importPluginModuleFromSource); + if (!project.tracker.plugin) { + console.error(chalk.red("Project tracker plugin not configured.")); + process.exit(1); + } const tracker = registry.get("tracker", project.tracker.plugin); if (!tracker) { console.error(chalk.red(`Tracker plugin "${project.tracker.plugin}" not found.`)); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8fb960663..b7791ffbc 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,53 +1,5 @@ #!/usr/bin/env node -import { Command } from "commander"; -import { registerInit } from "./commands/init.js"; -import { registerStatus } from "./commands/status.js"; -import { registerSpawn, registerBatchSpawn } from "./commands/spawn.js"; -import { registerSession } from "./commands/session.js"; -import { registerSend } from "./commands/send.js"; -import { registerReviewCheck } from "./commands/review-check.js"; -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"; -import { registerDoctor } from "./commands/doctor.js"; -import { registerUpdate } from "./commands/update.js"; -import { registerSetup } from "./commands/setup.js"; -import { registerPlugin } from "./commands/plugin.js"; -import { getConfigInstruction } from "./lib/config-instruction.js"; +import { createProgram } from "./program.js"; -const program = new Command(); - -program - .name("ao") - .description("Agent Orchestrator — manage parallel AI coding agents") - .version("0.1.0"); - -registerInit(program); -registerStart(program); -registerStop(program); -registerStatus(program); -registerSpawn(program); -registerBatchSpawn(program); -registerSession(program); -registerSend(program); -registerReviewCheck(program); -registerDashboard(program); -registerOpen(program); -registerLifecycleWorker(program); -registerVerify(program); -registerDoctor(program); -registerUpdate(program); -registerSetup(program); -registerPlugin(program); - -program - .command("config-help") - .description("Show config schema and guide for creating agent-orchestrator.yaml") - .action(() => { - console.log(getConfigInstruction()); - }); - -program.parse(); +createProgram().parse(); diff --git a/packages/cli/src/lib/session-utils.ts b/packages/cli/src/lib/session-utils.ts index 2837afdb2..7f8e0c4b8 100644 --- a/packages/cli/src/lib/session-utils.ts +++ b/packages/cli/src/lib/session-utils.ts @@ -30,10 +30,27 @@ export function isOrchestratorSessionName( sessionName: string, projectId?: string, ): boolean { + // If sessionName is a numbered worker for any configured project, it is not an orchestrator. + // This guard runs first to prevent cross-project false positives: e.g. prefix "app" would + // match "app-orchestrator-1" as an orchestrator pattern, but if another project has prefix + // "app-orchestrator" then "app-orchestrator-1" is a worker, not an orchestrator. + for (const [id, project] of Object.entries(config.projects) as Array< + [string, OrchestratorConfig["projects"][string]] + >) { + const prefix = project.sessionPrefix || id; + if (matchesPrefix(sessionName, prefix)) return false; + } + if (projectId) { const project = config.projects[projectId]; - if (project && sessionName === `${project.sessionPrefix || projectId}-orchestrator`) { - return true; + if (project) { + const prefix = project.sessionPrefix || projectId; + if ( + sessionName === `${prefix}-orchestrator` || + new RegExp(`^${escapeRegex(prefix)}-orchestrator-\\d+$`).test(sessionName) + ) { + return true; + } } } @@ -41,7 +58,10 @@ export function isOrchestratorSessionName( [string, OrchestratorConfig["projects"][string]] >) { const prefix = project.sessionPrefix || id; - if (sessionName === `${prefix}-orchestrator`) { + if ( + sessionName === `${prefix}-orchestrator` || + new RegExp(`^${escapeRegex(prefix)}-orchestrator-\\d+$`).test(sessionName) + ) { return true; } } diff --git a/packages/cli/src/options/version.ts b/packages/cli/src/options/version.ts new file mode 100644 index 000000000..c8f49a58e --- /dev/null +++ b/packages/cli/src/options/version.ts @@ -0,0 +1,8 @@ +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +export function getCliVersion(): string { + const packageJson = require("../../package.json") as { version: string }; + return packageJson.version; +} diff --git a/packages/cli/src/program.ts b/packages/cli/src/program.ts new file mode 100644 index 000000000..883ba0f85 --- /dev/null +++ b/packages/cli/src/program.ts @@ -0,0 +1,54 @@ +import { Command } from "commander"; +import { registerInit } from "./commands/init.js"; +import { registerStatus } from "./commands/status.js"; +import { registerSpawn, registerBatchSpawn } from "./commands/spawn.js"; +import { registerSession } from "./commands/session.js"; +import { registerSend } from "./commands/send.js"; +import { registerReviewCheck } from "./commands/review-check.js"; +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"; +import { registerDoctor } from "./commands/doctor.js"; +import { registerUpdate } from "./commands/update.js"; +import { registerSetup } from "./commands/setup.js"; +import { registerPlugin } from "./commands/plugin.js"; +import { getConfigInstruction } from "./lib/config-instruction.js"; +import { getCliVersion } from "./options/version.js"; + +export function createProgram(): Command { + const program = new Command(); + + program + .name("ao") + .description("Agent Orchestrator — manage parallel AI coding agents") + .version(getCliVersion()); + + registerInit(program); + registerStart(program); + registerStop(program); + registerStatus(program); + registerSpawn(program); + registerBatchSpawn(program); + registerSession(program); + registerSend(program); + registerReviewCheck(program); + registerDashboard(program); + registerOpen(program); + registerLifecycleWorker(program); + registerVerify(program); + registerDoctor(program); + registerUpdate(program); + registerSetup(program); + registerPlugin(program); + + program + .command("config-help") + .description("Show config schema and guide for creating agent-orchestrator.yaml") + .action(() => { + console.log(getConfigInstruction()); + }); + + return program; +} diff --git a/packages/core/src/__tests__/config-validation.test.ts b/packages/core/src/__tests__/config-validation.test.ts index 0ba53c0cc..9de7d3dfb 100644 --- a/packages/core/src/__tests__/config-validation.test.ts +++ b/packages/core/src/__tests__/config-validation.test.ts @@ -1,5 +1,5 @@ /** - * Unit tests for config validation (project uniqueness, prefix collisions). + * Unit tests for config validation (project uniqueness, prefix collisions, external plugins). */ import { describe, it, expect } from "vitest"; @@ -582,3 +582,508 @@ describe("Config Defaults", () => { expect(validated.projects.proj1.scm).toEqual({ plugin: "gitlab" }); }); }); + +describe("Config Validation - External Plugin Schema", () => { + it("accepts tracker with plugin only (built-in)", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + plugin: "github", + }, + }, + }, + }; + + const validated = validateConfig(config); + expect(validated.projects.proj1.tracker?.plugin).toBe("github"); + }); + + it("accepts tracker with package only (external npm)", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + package: "@acme/ao-plugin-tracker-jira", + teamId: "TEAM-123", + }, + }, + }, + }; + + const validated = validateConfig(config); + // Plugin name should be auto-generated from package + expect(validated.projects.proj1.tracker?.plugin).toBe("jira"); + expect(validated.projects.proj1.tracker?.package).toBe("@acme/ao-plugin-tracker-jira"); + }); + + it("accepts tracker with path only (local plugin)", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + path: "./plugins/my-tracker", + }, + }, + }, + }; + + const validated = validateConfig(config); + // Plugin name should be auto-generated from path + expect(validated.projects.proj1.tracker?.plugin).toBe("my-tracker"); + expect(validated.projects.proj1.tracker?.path).toBe("./plugins/my-tracker"); + }); + + it("accepts tracker with both plugin and package (explicit naming)", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + plugin: "jira", + package: "@acme/ao-plugin-tracker-jira", + }, + }, + }, + }; + + const validated = validateConfig(config); + expect(validated.projects.proj1.tracker?.plugin).toBe("jira"); + expect(validated.projects.proj1.tracker?.package).toBe("@acme/ao-plugin-tracker-jira"); + }); + + it("rejects tracker with neither plugin nor package/path", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + teamId: "TEAM-123", + }, + }, + }, + }; + + expect(() => validateConfig(config)).toThrow(/plugin.*package.*path/i); + }); + + it("rejects tracker with both package and path", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + package: "@acme/ao-plugin-tracker-jira", + path: "./plugins/my-tracker", + }, + }, + }, + }; + + expect(() => validateConfig(config)).toThrow(/cannot have both/i); + }); + + it("accepts scm with package only", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + scm: { + package: "@acme/ao-plugin-scm-bitbucket", + }, + }, + }, + }; + + const validated = validateConfig(config); + expect(validated.projects.proj1.scm?.plugin).toBe("bitbucket"); + }); + + it("accepts notifier with package only", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + }, + }, + notifiers: { + teams: { + package: "@acme/ao-plugin-notifier-teams", + webhookUrl: "https://teams.webhook.url", + }, + }, + }; + + const validated = validateConfig(config); + expect(validated.notifiers["teams"]?.plugin).toBe("teams"); + expect(validated.notifiers["teams"]?.package).toBe("@acme/ao-plugin-notifier-teams"); + }); + + it("preserves plugin-specific config alongside package", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + package: "@acme/ao-plugin-tracker-jira", + host: "https://jira.company.com", + teamId: "TEAM-123", + }, + }, + }, + }; + + const validated = validateConfig(config); + expect(validated.projects.proj1.tracker?.host).toBe("https://jira.company.com"); + expect(validated.projects.proj1.tracker?.teamId).toBe("TEAM-123"); + }); +}); + +describe("collectExternalPluginConfigs", () => { + // Note: validateConfig() internally calls collectExternalPluginConfigs() and stores + // the results in config._externalPluginEntries. We test this by checking the stored entries. + + it("collects tracker with explicit plugin (validates manifest.name)", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + plugin: "jira", + package: "@acme/ao-plugin-tracker-jira", + }, + }, + }, + }); + + // Check entries stored by validateConfig + const entries = config._externalPluginEntries ?? []; + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + source: "projects.proj1.tracker", + location: { kind: "project", projectId: "proj1", configType: "tracker" }, + slot: "tracker", + package: "@acme/ao-plugin-tracker-jira", + expectedPluginName: "jira", // User explicitly specified plugin - will be validated + }); + }); + + it("collects scm with path (no explicit plugin - infers from manifest)", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + scm: { + path: "./plugins/my-scm", + }, + }, + }, + }); + + // Check entries stored by validateConfig + const entries = config._externalPluginEntries ?? []; + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + source: "projects.proj1.scm", + location: { kind: "project", projectId: "proj1", configType: "scm" }, + slot: "scm", + path: "./plugins/my-scm", + }); + // expectedPluginName should be undefined when plugin is not explicitly specified + // This allows any manifest.name to be accepted + expect(entries[0].expectedPluginName).toBeUndefined(); + }); + + it("collects notifier with package (no explicit plugin - infers from manifest)", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + }, + }, + notifiers: { + teams: { + package: "@acme/ao-plugin-notifier-teams", + }, + }, + }); + + // Check entries stored by validateConfig + const entries = config._externalPluginEntries ?? []; + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + source: "notifiers.teams", + location: { kind: "notifier", notifierId: "teams" }, + slot: "notifier", + package: "@acme/ao-plugin-notifier-teams", + }); + // expectedPluginName should be undefined when plugin is not explicitly specified + expect(entries[0].expectedPluginName).toBeUndefined(); + }); + + it("collects multiple external plugins", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + package: "@acme/ao-plugin-tracker-jira", + }, + scm: { + path: "./plugins/my-scm", + }, + }, + }, + notifiers: { + teams: { + package: "@acme/ao-plugin-notifier-teams", + }, + }, + }); + + // Check entries stored by validateConfig + const entries = config._externalPluginEntries ?? []; + expect(entries).toHaveLength(3); + }); + + it("ignores built-in plugins (plugin only)", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + plugin: "github", + }, + scm: { + plugin: "github", + }, + }, + }, + }); + + // No external plugins when only plugin name is specified (no package/path) + const entries = config._externalPluginEntries ?? []; + expect(entries).toHaveLength(0); + }); + + it("auto-generates plugins array entries from external plugin configs", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + package: "@acme/ao-plugin-tracker-jira", + }, + }, + }, + }); + + // plugins array should be auto-populated + expect(config.plugins).toBeDefined(); + expect(config.plugins).toContainEqual( + expect.objectContaining({ + source: "npm", + package: "@acme/ao-plugin-tracker-jira", + enabled: true, + }), + ); + }); + + it("stores external plugin entries on config for validation", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + plugin: "jira", + package: "@acme/ao-plugin-tracker-jira", + }, + }, + }, + }); + + expect(config._externalPluginEntries).toBeDefined(); + expect(config._externalPluginEntries).toHaveLength(1); + expect(config._externalPluginEntries?.[0]).toMatchObject({ + source: "projects.proj1.tracker", + expectedPluginName: "jira", + }); + }); +}); + +describe("External Plugin Name Generation", () => { + it("extracts plugin name from scoped npm package", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + package: "@acme/ao-plugin-tracker-jira", + }, + }, + }, + }); + + expect(config.projects.proj1.tracker?.plugin).toBe("jira"); + }); + + it("extracts plugin name from unscoped npm package", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + package: "ao-plugin-tracker-jira", + }, + }, + }, + }); + + expect(config.projects.proj1.tracker?.plugin).toBe("jira"); + }); + + it("extracts plugin name from local path", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + path: "./plugins/my-custom-tracker", + }, + }, + }, + }); + + expect(config.projects.proj1.tracker?.plugin).toBe("my-custom-tracker"); + }); + + it("extracts plugin name from absolute path", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + path: "/home/user/plugins/custom-tracker", + }, + }, + }, + }); + + expect(config.projects.proj1.tracker?.plugin).toBe("custom-tracker"); + }); + + it("does not override explicit plugin name", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + plugin: "my-custom-name", + package: "@acme/ao-plugin-tracker-jira", + }, + }, + }, + }); + + expect(config.projects.proj1.tracker?.plugin).toBe("my-custom-name"); + }); + + it("handles local path without slashes correctly", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + path: "my-tracker", + }, + }, + }, + }); + + // Should use the path as-is (not split by hyphens like npm packages) + expect(config.projects.proj1.tracker?.plugin).toBe("my-tracker"); + }); + + it("preserves multi-word plugin names from scoped npm packages", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + tracker: { + package: "@acme/ao-plugin-tracker-jira-cloud", + }, + }, + }, + }); + + // Should extract "jira-cloud" not just "cloud" + expect(config.projects.proj1.tracker?.plugin).toBe("jira-cloud"); + }); + + it("preserves multi-word plugin names from unscoped npm packages", () => { + const config = validateConfig({ + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + scm: { + package: "ao-plugin-scm-azure-devops", + }, + }, + }, + }); + + // Should extract "azure-devops" not just "devops" + expect(config.projects.proj1.scm?.plugin).toBe("azure-devops"); + }); +}); diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 37580ca1d..e18643aa0 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -8,6 +8,7 @@ import type { SessionManager, Agent, ActivityState, + SessionStatus, } from "../types.js"; import { createTestEnvironment, @@ -749,6 +750,556 @@ describe("reactions", () => { expect(metadata?.["lastAutomatedReviewDispatchHash"]).toBe("bot-1"); }); + it("dispatches CI failure details with check names and URLs on subsequent polls", async () => { + config.reactions = { + "ci-failed": { + auto: true, + action: "send-to-agent", + message: "CI is failing. Fix it.", + retries: 3, + escalateAfter: 3, + }, + }; + + const mockSCM = createMockSCM({ + getCISummary: vi.fn().mockResolvedValue("failing"), + getCIChecks: vi.fn().mockResolvedValue([ + { + name: "lint", + status: "failed", + url: "https://github.com/org/repo/actions/runs/123", + conclusion: "FAILURE", + }, + { + name: "test", + status: "passed", + url: "https://github.com/org/repo/actions/runs/124", + conclusion: "SUCCESS", + }, + { + name: "typecheck", + status: "failed", + url: "https://github.com/org/repo/actions/runs/125", + conclusion: "FAILURE", + }, + ]), + }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + }); + + vi.mocked(mockSessionManager.send).mockResolvedValue(undefined); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "pr_open", pr: makePR() }), + registry, + }); + + // First check: transition to ci_failed — sends the reaction message + await lm.check("app-1"); + expect(lm.getStates().get("app-1")).toBe("ci_failed"); + expect(mockSessionManager.send).toHaveBeenCalledTimes(1); + expect(mockSessionManager.send).toHaveBeenCalledWith("app-1", "CI is failing. Fix it."); + + vi.mocked(mockSessionManager.send).mockClear(); + + // Second check: still ci_failed, same failures — dispatches detailed CI info + await lm.check("app-1"); + expect(mockSessionManager.send).toHaveBeenCalledTimes(1); + const sentMessage = vi.mocked(mockSessionManager.send).mock.calls[0]![1]; + expect(sentMessage).toContain("lint"); + expect(sentMessage).toContain("typecheck"); + expect(sentMessage).toContain("https://github.com/org/repo/actions/runs/123"); + expect(sentMessage).toContain("https://github.com/org/repo/actions/runs/125"); + // Should NOT include the passing check + expect(sentMessage).not.toContain("runs/124"); + }); + + it("does not re-dispatch CI failure details when failure set is unchanged", async () => { + config.reactions = { + "ci-failed": { + auto: true, + action: "send-to-agent", + message: "CI is failing.", + retries: 3, + escalateAfter: 3, + }, + }; + + const mockSCM = createMockSCM({ + getCISummary: vi.fn().mockResolvedValue("failing"), + getCIChecks: vi.fn().mockResolvedValue([ + { name: "lint", status: "failed", conclusion: "FAILURE" }, + ]), + }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + }); + + vi.mocked(mockSessionManager.send).mockResolvedValue(undefined); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "pr_open", pr: makePR() }), + registry, + }); + + // First check: transition reaction + await lm.check("app-1"); + expect(mockSessionManager.send).toHaveBeenCalledTimes(1); + + vi.mocked(mockSessionManager.send).mockClear(); + + // Second check: dispatches CI details + await lm.check("app-1"); + expect(mockSessionManager.send).toHaveBeenCalledTimes(1); + + vi.mocked(mockSessionManager.send).mockClear(); + + // Third check: same failures — should NOT dispatch again + await lm.check("app-1"); + expect(mockSessionManager.send).not.toHaveBeenCalled(); + + const metadata = readMetadataRaw(env.sessionsDir, "app-1"); + expect(metadata?.["lastCIFailureDispatchHash"]).toBeTruthy(); + }); + + it("re-dispatches CI failure details when a new check fails", async () => { + config.reactions = { + "ci-failed": { + auto: true, + action: "send-to-agent", + message: "CI is failing.", + retries: 5, + escalateAfter: 5, + }, + }; + + const getCIChecksMock = vi.fn().mockResolvedValue([ + { name: "lint", status: "failed", conclusion: "FAILURE" }, + ]); + const mockSCM = createMockSCM({ + getCISummary: vi.fn().mockResolvedValue("failing"), + getCIChecks: getCIChecksMock, + }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + }); + + vi.mocked(mockSessionManager.send).mockResolvedValue(undefined); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "pr_open", pr: makePR() }), + registry, + }); + + // First check: transition + second poll to dispatch details + await lm.check("app-1"); + vi.mocked(mockSessionManager.send).mockClear(); + await lm.check("app-1"); + vi.mocked(mockSessionManager.send).mockClear(); + + // Third check: same failures — no dispatch + await lm.check("app-1"); + expect(mockSessionManager.send).not.toHaveBeenCalled(); + + // Now a different check fails too + getCIChecksMock.mockResolvedValue([ + { name: "lint", status: "failed", conclusion: "FAILURE" }, + { name: "test", status: "failed", conclusion: "FAILURE" }, + ]); + + await lm.check("app-1"); + expect(mockSessionManager.send).toHaveBeenCalledTimes(1); + const sentMessage = vi.mocked(mockSessionManager.send).mock.calls[0]![1]; + expect(sentMessage).toContain("lint"); + expect(sentMessage).toContain("test"); + }); + + it("clears CI failure tracking when PR is merged", async () => { + config.reactions = { + "ci-failed": { + auto: true, + action: "send-to-agent", + message: "CI is failing.", + }, + }; + + const mockSCM = createMockSCM({ + getCISummary: vi.fn().mockResolvedValue("failing"), + getCIChecks: vi.fn().mockResolvedValue([ + { name: "lint", status: "failed", conclusion: "FAILURE" }, + ]), + }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + }); + + vi.mocked(mockSessionManager.send).mockResolvedValue(undefined); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "pr_open", pr: makePR() }), + registry, + }); + + await lm.check("app-1"); + + // Now PR is merged + vi.mocked(mockSCM.getCISummary).mockResolvedValue("passing"); + vi.mocked(mockSCM.getPRState).mockResolvedValue("merged"); + + await lm.check("app-1"); + + const metadata = readMetadataRaw(env.sessionsDir, "app-1"); + expect(metadata?.["lastCIFailureFingerprint"]).toBeFalsy(); + expect(metadata?.["lastCIFailureDispatchHash"]).toBeFalsy(); + }); + + it("clears CI failure tracking when CI recovers to passing", async () => { + config.reactions = { + "ci-failed": { + auto: true, + action: "send-to-agent", + message: "CI is failing.", + }, + }; + + const getCISummaryMock = vi.fn().mockResolvedValue("failing"); + const getCIChecksMock = vi.fn().mockResolvedValue([ + { name: "lint", status: "failed", conclusion: "FAILURE" }, + ]); + const mockSCM = createMockSCM({ + getCISummary: getCISummaryMock, + getCIChecks: getCIChecksMock, + }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + }); + + vi.mocked(mockSessionManager.send).mockResolvedValue(undefined); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "pr_open", pr: makePR() }), + registry, + }); + + // First: transition to ci_failed, then dispatch details + await lm.check("app-1"); + await lm.check("app-1"); + + // Verify tracking was set + let metadata = readMetadataRaw(env.sessionsDir, "app-1"); + expect(metadata?.["lastCIFailureDispatchHash"]).toBeTruthy(); + + // CI recovers + getCISummaryMock.mockResolvedValue("passing"); + getCIChecksMock.mockResolvedValue([]); + await lm.check("app-1"); + + metadata = readMetadataRaw(env.sessionsDir, "app-1"); + expect(metadata?.["lastCIFailureFingerprint"]).toBeFalsy(); + expect(metadata?.["lastCIFailureDispatchHash"]).toBeFalsy(); + }); + + it("uses notify action for CI failure details when configured", async () => { + const notifier = createMockNotifier(); + + const configWithNotify = { + ...config, + reactions: { + "ci-failed": { + auto: true, + action: "notify" as const, + retries: 3, + escalateAfter: 3, + }, + }, + notificationRouting: { + ...config.notificationRouting, + warning: ["desktop"], + info: ["desktop"], + }, + }; + + const mockSCM = createMockSCM({ + getCISummary: vi.fn().mockResolvedValue("failing"), + getCIChecks: vi.fn().mockResolvedValue([ + { name: "lint", status: "failed", conclusion: "FAILURE" }, + ]), + }); + + const registry: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string, name: string) => { + if (slot === "runtime") return plugins.runtime; + if (slot === "agent") return plugins.agent; + if (slot === "scm") return mockSCM; + if (slot === "notifier" && name === "desktop") return notifier; + return null; + }), + }; + + const lm = setupCheck("app-1", { + session: makeSession({ status: "pr_open", pr: makePR() }), + registry, + configOverride: configWithNotify, + }); + + // First check: transition — notifier called for reaction + await lm.check("app-1"); + expect(notifier.notify).toHaveBeenCalled(); + + vi.mocked(notifier.notify).mockClear(); + + // Second check: CI detail dispatch via notify action + await lm.check("app-1"); + expect(notifier.notify).toHaveBeenCalled(); + expect(mockSessionManager.send).not.toHaveBeenCalled(); + }); + + it("uses notify action for merge conflicts when configured", async () => { + const notifier = createMockNotifier(); + + const configWithNotify = { + ...config, + reactions: { + "merge-conflicts": { + auto: true, + action: "notify" as const, + }, + }, + notificationRouting: { + ...config.notificationRouting, + warning: ["desktop"], + info: ["desktop"], + }, + }; + + const mockSCM = createMockSCM({ + getMergeability: vi.fn().mockResolvedValue({ + mergeable: false, + ciPassing: true, + approved: false, + noConflicts: false, + blockers: ["Merge conflicts"], + }), + }); + + const registry: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string, name: string) => { + if (slot === "runtime") return plugins.runtime; + if (slot === "agent") return plugins.agent; + if (slot === "scm") return mockSCM; + if (slot === "notifier" && name === "desktop") return notifier; + return null; + }), + }; + + const lm = setupCheck("app-1", { + session: makeSession({ status: "pr_open", pr: makePR() }), + registry, + configOverride: configWithNotify, + }); + + await lm.check("app-1"); + expect(notifier.notify).toHaveBeenCalled(); + expect(mockSessionManager.send).not.toHaveBeenCalled(); + }); + + it("dispatches merge conflict notification when PR has conflicts", async () => { + config.reactions = { + "merge-conflicts": { + auto: true, + action: "send-to-agent", + message: "Your branch has merge conflicts. Rebase and resolve them.", + }, + }; + + const mockSCM = createMockSCM({ + getMergeability: vi.fn().mockResolvedValue({ + mergeable: false, + ciPassing: true, + approved: false, + noConflicts: false, + blockers: ["Merge conflicts"], + }), + }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + }); + + vi.mocked(mockSessionManager.send).mockResolvedValue(undefined); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "pr_open", pr: makePR() }), + registry, + }); + + await lm.check("app-1"); + expect(mockSessionManager.send).toHaveBeenCalledWith( + "app-1", + "Your branch has merge conflicts. Rebase and resolve them.", + ); + }); + + it("does not re-dispatch merge conflict notification when already dispatched", async () => { + config.reactions = { + "merge-conflicts": { + auto: true, + action: "send-to-agent", + message: "Resolve merge conflicts.", + }, + }; + + const mockSCM = createMockSCM({ + getMergeability: vi.fn().mockResolvedValue({ + mergeable: false, + ciPassing: true, + approved: false, + noConflicts: false, + blockers: ["Merge conflicts"], + }), + }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + }); + + vi.mocked(mockSessionManager.send).mockResolvedValue(undefined); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "pr_open", pr: makePR() }), + registry, + }); + + await lm.check("app-1"); + expect(mockSessionManager.send).toHaveBeenCalledTimes(1); + + vi.mocked(mockSessionManager.send).mockClear(); + + // Second check — same conflicts, should not re-send + await lm.check("app-1"); + expect(mockSessionManager.send).not.toHaveBeenCalled(); + }); + + it("re-dispatches merge conflict notification after conflicts are resolved and recur", async () => { + config.reactions = { + "merge-conflicts": { + auto: true, + action: "send-to-agent", + message: "Resolve merge conflicts.", + }, + }; + + const getMergeabilityMock = vi.fn().mockResolvedValue({ + mergeable: false, + ciPassing: true, + approved: false, + noConflicts: false, + blockers: ["Merge conflicts"], + }); + const mockSCM = createMockSCM({ + getMergeability: getMergeabilityMock, + }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + }); + + vi.mocked(mockSessionManager.send).mockResolvedValue(undefined); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "pr_open", pr: makePR() }), + registry, + }); + + // First: conflicts detected, notification sent + await lm.check("app-1"); + expect(mockSessionManager.send).toHaveBeenCalledTimes(1); + vi.mocked(mockSessionManager.send).mockClear(); + + // Second: conflicts resolved + getMergeabilityMock.mockResolvedValue({ + mergeable: true, + ciPassing: true, + approved: false, + noConflicts: true, + blockers: [], + }); + await lm.check("app-1"); + expect(mockSessionManager.send).not.toHaveBeenCalled(); + + const metadata = readMetadataRaw(env.sessionsDir, "app-1"); + expect(metadata?.["lastMergeConflictDispatched"]).toBeFalsy(); + + // Third: conflicts recur — should re-dispatch + getMergeabilityMock.mockResolvedValue({ + mergeable: false, + ciPassing: true, + approved: false, + noConflicts: false, + blockers: ["Merge conflicts"], + }); + await lm.check("app-1"); + expect(mockSessionManager.send).toHaveBeenCalledTimes(1); + }); + + it("clears merge conflict tracking when PR is merged", async () => { + config.reactions = { + "merge-conflicts": { + auto: true, + action: "send-to-agent", + message: "Resolve merge conflicts.", + }, + }; + + const mockSCM = createMockSCM({ + getMergeability: vi.fn().mockResolvedValue({ + mergeable: false, + ciPassing: true, + approved: false, + noConflicts: false, + blockers: ["Merge conflicts"], + }), + }); + const registry = createMockRegistry({ + runtime: plugins.runtime, + agent: plugins.agent, + scm: mockSCM, + }); + + vi.mocked(mockSessionManager.send).mockResolvedValue(undefined); + + const lm = setupCheck("app-1", { + session: makeSession({ status: "pr_open", pr: makePR() }), + registry, + }); + + await lm.check("app-1"); + + // Now PR is merged + vi.mocked(mockSCM.getPRState).mockResolvedValue("merged"); + + await lm.check("app-1"); + + const metadata = readMetadataRaw(env.sessionsDir, "app-1"); + expect(metadata?.["lastMergeConflictDispatched"]).toBeFalsy(); + }); + it("notifies humans on significant transitions without reaction config", async () => { const notifier = createMockNotifier(); const mockSCM = createMockSCM({ getPRState: vi.fn().mockResolvedValue("merged") }); @@ -779,6 +1330,137 @@ describe("reactions", () => { }); }); +describe("pollAll terminal status accounting", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("treats all TERMINAL_STATUSES as inactive for all-complete", async () => { + const notifier = createMockNotifier(); + const registryWithNotifier: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string, name: string) => { + if (slot === "runtime") return plugins.runtime; + if (slot === "agent") return plugins.agent; + if (slot === "notifier" && name === "desktop") return notifier; + return null; + }), + }; + + // All sessions in various terminal states — should count as inactive + const terminalSessions = [ + makeSession({ id: "s-1", status: "killed" as SessionStatus }), + makeSession({ id: "s-2", status: "merged" as SessionStatus }), + makeSession({ id: "s-3", status: "done" as SessionStatus }), + makeSession({ id: "s-4", status: "errored" as SessionStatus }), + makeSession({ id: "s-5", status: "terminated" as SessionStatus }), + makeSession({ id: "s-6", status: "cleanup" as SessionStatus }), + ]; + + vi.mocked(mockSessionManager.list).mockResolvedValue(terminalSessions); + + // Route info-priority notifications to desktop so we can observe them + config.notificationRouting.info = ["desktop"]; + config.reactions = { + "all-complete": { auto: true, action: "notify" }, + }; + + const lm = createLifecycleManager({ + config, + registry: registryWithNotifier, + sessionManager: mockSessionManager, + }); + + lm.start(60_000); + // Let the immediate pollAll() run + await vi.advanceTimersByTimeAsync(0); + + expect(notifier.notify).toHaveBeenCalledWith( + expect.objectContaining({ type: "reaction.triggered" }), + ); + + lm.stop(); + }); + + it("does not fire all-complete when a session is in non-terminal status like done is missing", async () => { + const notifier = createMockNotifier(); + const registryWithNotifier: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string, name: string) => { + if (slot === "runtime") return plugins.runtime; + if (slot === "agent") return plugins.agent; + if (slot === "notifier" && name === "desktop") return notifier; + return null; + }), + }; + + // Mix of terminal and active sessions + const sessions = [ + makeSession({ id: "s-1", status: "killed" as SessionStatus }), + makeSession({ id: "s-2", status: "working" as SessionStatus }), + ]; + + vi.mocked(mockSessionManager.list).mockResolvedValue(sessions); + + config.reactions = { + "all-complete": { auto: true, action: "notify" }, + }; + + const lm = createLifecycleManager({ + config, + registry: registryWithNotifier, + sessionManager: mockSessionManager, + }); + + lm.start(60_000); + await vi.advanceTimersByTimeAsync(0); + + // all-complete should NOT have fired — "working" is still active + const allCompleteNotifications = vi.mocked(notifier.notify).mock.calls.filter( + (call: unknown[]) => { + const event = call[0] as Record | undefined; + const data = event?.data as Record | undefined; + return event?.type === "reaction.triggered" && data?.reactionKey === "all-complete"; + }, + ); + expect(allCompleteNotifications).toHaveLength(0); + + lm.stop(); + }); + + it("skips polling sessions in terminal statuses like done or errored", async () => { + // Sessions in "done" / "errored" should not be polled + const sessions = [ + makeSession({ id: "s-done", status: "done" as SessionStatus }), + makeSession({ id: "s-errored", status: "errored" as SessionStatus }), + ]; + + vi.mocked(mockSessionManager.list).mockResolvedValue(sessions); + + // If these sessions were polled, determineStatus would call runtime.isAlive. + // Reset call count and verify it's not called. + vi.mocked(plugins.runtime.isAlive).mockClear(); + + const lm = createLifecycleManager({ + config, + registry: mockRegistry, + sessionManager: mockSessionManager, + }); + + lm.start(60_000); + await vi.advanceTimersByTimeAsync(0); + + // Terminal sessions should not be polled — runtime.isAlive should not be called + expect(plugins.runtime.isAlive).not.toHaveBeenCalled(); + + lm.stop(); + }); +}); + describe("getStates", () => { it("returns copy of states map", async () => { const lm = setupCheck("app-1", { diff --git a/packages/core/src/__tests__/plugin-registry.test.ts b/packages/core/src/__tests__/plugin-registry.test.ts index 799c947fa..a297d0c78 100644 --- a/packages/core/src/__tests__/plugin-registry.test.ts +++ b/packages/core/src/__tests__/plugin-registry.test.ts @@ -262,6 +262,63 @@ describe("loadBuiltins", () => { }); }); + it("strips package loading metadata from notifier config", async () => { + const registry = createPluginRegistry(); + const fakeWebhook = makePlugin("notifier", "webhook"); + const cfg = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + notifiers: { + mywebhook: { + plugin: "webhook", + // package field is allowed for resolution but should be stripped: + package: "@composio/ao-plugin-notifier-webhook", + // These are plugin-specific fields that should be passed through: + url: "https://webhook.example.com/notify", + retries: 3, + }, + }, + }); + + await registry.loadBuiltins(cfg, async (pkg: string) => { + if (pkg === "@composio/ao-plugin-notifier-webhook") return fakeWebhook; + throw new Error(`Not found: ${pkg}`); + }); + + // Loading metadata (package) should be stripped to prevent leakage + // Plugin-specific fields (url, retries) should be passed through + expect(fakeWebhook.create).toHaveBeenCalledWith({ + url: "https://webhook.example.com/notify", + retries: 3, + configPath: "/test/config.yaml", + }); + }); + + it("warns and skips when path is used alongside plugin name in notifier config", async () => { + const registry = createPluginRegistry(); + const fakeWebhook = makePlugin("notifier", "webhook"); + const cfg = makeOrchestratorConfig({ + notifiers: { + mywebhook: { + plugin: "webhook", + path: "./some/path", // This triggers the collision check + }, + }, + }); + + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + + await registry.loadBuiltins(cfg, async (pkg: string) => { + if (pkg === "@composio/ao-plugin-notifier-webhook") return fakeWebhook; + return null; + }); + + expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('"path" field conflicts with reserved')); + stderrSpy.mockRestore(); + + // Plugin should not be registered due to config error + expect(registry.get("notifier", "webhook")).toBeNull(); + }); + it("does not match notifier key when explicit plugin points to another notifier", async () => { const registry = createPluginRegistry(); const fakeOpenClaw = makePlugin("notifier", "openclaw"); @@ -455,3 +512,424 @@ describe("loadFromConfig", () => { expect(registry.get("agent", "goose")).toBeNull(); }); }); + +describe("External plugin manifest validation", () => { + it("accepts matching manifest.name and expectedPluginName", async () => { + const registry = createPluginRegistry(); + + const mockPlugin = { + manifest: { name: "jira", slot: "tracker" as const, version: "1.0.0", description: "Jira tracker" }, + create: vi.fn(() => ({})), + }; + + const importFn = vi.fn(async () => mockPlugin); + + const config = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + plugins: [ + { name: "jira", source: "npm", package: "@acme/ao-plugin-tracker-jira", enabled: true }, + ], + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + name: "proj1", + defaultBranch: "main", + sessionPrefix: "test", + tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" }, + }, + }, + _externalPluginEntries: [ + { + source: "projects.proj1.tracker", + location: { kind: "project", projectId: "proj1", configType: "tracker" }, + slot: "tracker", + package: "@acme/ao-plugin-tracker-jira", + expectedPluginName: "jira", + }, + ], + }); + + // Should not throw + await registry.loadFromConfig(config, importFn); + expect(registry.get("tracker", "jira")).not.toBeNull(); + }); + + it("warns when manifest.name does not match expectedPluginName but still registers plugin", async () => { + const registry = createPluginRegistry(); + + const mockPlugin = { + manifest: { name: "jira-enterprise", slot: "tracker" as const, version: "1.0.0", description: "Jira Enterprise" }, + create: vi.fn(() => ({})), + }; + + const importFn = vi.fn(async () => mockPlugin); + + const config = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + plugins: [ + { name: "jira", source: "npm", package: "@acme/ao-plugin-tracker-jira", enabled: true }, + ], + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + name: "proj1", + defaultBranch: "main", + sessionPrefix: "test", + tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" }, + }, + }, + _externalPluginEntries: [ + { + source: "projects.proj1.tracker", + location: { kind: "project", projectId: "proj1", configType: "tracker" }, + slot: "tracker", + package: "@acme/ao-plugin-tracker-jira", + expectedPluginName: "jira", + }, + ], + }); + + // Should warn about validation failure but still register the plugin + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await registry.loadFromConfig(config, importFn); + + // Plugin should still be registered under its manifest.name + expect(registry.get("tracker", "jira-enterprise")).not.toBeNull(); + + // Should have logged a validation warning + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining("Config validation failed for projects.proj1.tracker"), + ); + stderrSpy.mockRestore(); + }); + + it("infers plugin name when expectedPluginName is not specified", async () => { + const registry = createPluginRegistry(); + + const mockPlugin = { + manifest: { name: "jira", slot: "tracker" as const, version: "1.0.0", description: "Jira tracker" }, + create: vi.fn(() => ({})), + }; + + const importFn = vi.fn(async () => mockPlugin); + + const config = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + plugins: [ + { name: "jira", source: "npm", package: "@acme/ao-plugin-tracker-jira", enabled: true }, + ], + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + name: "proj1", + defaultBranch: "main", + sessionPrefix: "test", + // Plugin field will be updated with manifest.name + tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" }, + }, + }, + _externalPluginEntries: [ + { + source: "projects.proj1.tracker", + location: { kind: "project", projectId: "proj1", configType: "tracker" }, + slot: "tracker", + package: "@acme/ao-plugin-tracker-jira", + // No expectedPluginName - should accept any manifest.name + }, + ], + }); + + await registry.loadFromConfig(config, importFn); + + // Plugin should be registered under manifest.name + expect(registry.get("tracker", "jira")).not.toBeNull(); + // Config should be updated with actual manifest.name + expect(config.projects.proj1.tracker?.plugin).toBe("jira"); + }); + + it("updates config with actual manifest.name for notifiers", async () => { + const registry = createPluginRegistry(); + + const mockPlugin = { + manifest: { name: "ms-teams", slot: "notifier" as const, version: "1.0.0", description: "Teams notifier" }, + create: vi.fn(() => ({})), + }; + + const importFn = vi.fn(async () => mockPlugin); + + const config = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + plugins: [ + { name: "teams", source: "npm", package: "@acme/ao-plugin-notifier-teams", enabled: true }, + ], + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + name: "proj1", + defaultBranch: "main", + sessionPrefix: "test", + }, + }, + notifiers: { + myteams: { plugin: "teams", package: "@acme/ao-plugin-notifier-teams" }, + }, + _externalPluginEntries: [ + { + source: "notifiers.myteams", + location: { kind: "notifier", notifierId: "myteams" }, + slot: "notifier", + package: "@acme/ao-plugin-notifier-teams", + // No expectedPluginName - will accept any manifest.name + }, + ], + }); + + await registry.loadFromConfig(config, importFn); + + // Config should be updated with actual manifest.name + expect(config.notifiers?.myteams?.plugin).toBe("ms-teams"); + // Plugin should be registered under manifest.name + expect(registry.get("notifier", "ms-teams")).not.toBeNull(); + }); + + it("passes notifier config to plugin even when manifest name differs from temp name", async () => { + const registry = createPluginRegistry(); + + const mockPlugin = { + manifest: { name: "ms-teams", slot: "notifier" as const, version: "1.0.0", description: "Teams notifier" }, + create: vi.fn(() => ({})), + }; + + const importFn = vi.fn(async () => mockPlugin); + + const config = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + plugins: [ + // Temp name is "teams" (from package name), but manifest.name is "ms-teams" + { name: "teams", source: "npm", package: "@acme/ao-plugin-notifier-teams", enabled: true }, + ], + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + name: "proj1", + defaultBranch: "main", + sessionPrefix: "test", + }, + }, + notifiers: { + myteams: { + plugin: "teams", // Temp name - will be updated to "ms-teams" + package: "@acme/ao-plugin-notifier-teams", + webhookUrl: "https://teams.webhook.url/abc123", + channel: "#alerts", + }, + }, + _externalPluginEntries: [ + { + source: "notifiers.myteams", + location: { kind: "notifier", notifierId: "myteams" }, + slot: "notifier", + package: "@acme/ao-plugin-notifier-teams", + // No expectedPluginName - config.plugin will be updated to manifest.name + }, + ], + }); + + await registry.loadFromConfig(config, importFn); + + // Config should be updated BEFORE extractPluginConfig is called + expect(config.notifiers?.myteams?.plugin).toBe("ms-teams"); + + // Plugin should receive its config (webhookUrl, channel) despite name mismatch + expect(mockPlugin.create).toHaveBeenCalledWith({ + webhookUrl: "https://teams.webhook.url/abc123", + channel: "#alerts", + configPath: "/test/config.yaml", + }); + + // Plugin should be registered + expect(registry.get("notifier", "ms-teams")).not.toBeNull(); + }); + + it("warns when plugin slot does not match config slot", async () => { + const registry = createPluginRegistry(); + + const mockPlugin = { + manifest: { name: "jira", slot: "notifier" as const, version: "1.0.0", description: "Wrong slot!" }, + create: vi.fn(() => ({})), + }; + + const importFn = vi.fn(async () => mockPlugin); + + const config = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + plugins: [ + { name: "jira", source: "npm", package: "@acme/ao-plugin-tracker-jira", enabled: true }, + ], + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + name: "proj1", + defaultBranch: "main", + sessionPrefix: "test", + tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" }, + }, + }, + _externalPluginEntries: [ + { + source: "projects.proj1.tracker", + location: { kind: "project", projectId: "proj1", configType: "tracker" }, + slot: "tracker", // Expected tracker + package: "@acme/ao-plugin-tracker-jira", + }, + ], + }); + + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await registry.loadFromConfig(config, importFn); + + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining("has slot \"notifier\" but was configured as \"tracker\""), + ); + stderrSpy.mockRestore(); + }); + + it("updates all projects sharing same external plugin with manifest.name", async () => { + const registry = createPluginRegistry(); + + const mockPlugin = { + manifest: { name: "jira-cloud", slot: "tracker" as const, version: "1.0.0", description: "Jira Cloud" }, + create: vi.fn(() => ({})), + }; + + const importFn = vi.fn(async () => mockPlugin); + + const config = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + plugins: [ + { name: "jira", source: "npm", package: "@acme/ao-plugin-tracker-jira", enabled: true }, + ], + projects: { + proj1: { + path: "/repos/test1", + repo: "org/test1", + name: "proj1", + defaultBranch: "main", + sessionPrefix: "test1", + tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" }, + }, + proj2: { + path: "/repos/test2", + repo: "org/test2", + name: "proj2", + defaultBranch: "main", + sessionPrefix: "test2", + // Same external plugin as proj1 + tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" }, + }, + }, + _externalPluginEntries: [ + { + source: "projects.proj1.tracker", + location: { kind: "project", projectId: "proj1", configType: "tracker" }, + slot: "tracker", + package: "@acme/ao-plugin-tracker-jira", + // No expectedPluginName - will accept any manifest.name + }, + { + source: "projects.proj2.tracker", + location: { kind: "project", projectId: "proj2", configType: "tracker" }, + slot: "tracker", + package: "@acme/ao-plugin-tracker-jira", + // No expectedPluginName - will accept any manifest.name + }, + ], + }); + + await registry.loadFromConfig(config, importFn); + + // Both projects should be updated with the actual manifest.name + expect(config.projects.proj1.tracker?.plugin).toBe("jira-cloud"); + expect(config.projects.proj2.tracker?.plugin).toBe("jira-cloud"); + // Plugin should be registered under manifest.name + expect(registry.get("tracker", "jira-cloud")).not.toBeNull(); + }); + + it("registers plugin even when one project has misconfigured expectedPluginName", async () => { + const registry = createPluginRegistry(); + + const mockPlugin = { + manifest: { name: "jira-cloud", slot: "tracker" as const, version: "1.0.0", description: "Jira Cloud" }, + create: vi.fn(() => ({})), + }; + + const importFn = vi.fn(async () => mockPlugin); + + const config = makeOrchestratorConfig({ + configPath: "/test/config.yaml", + plugins: [ + { name: "jira", source: "npm", package: "@acme/ao-plugin-tracker-jira", enabled: true }, + ], + projects: { + proj1: { + path: "/repos/test1", + repo: "org/test1", + name: "proj1", + defaultBranch: "main", + sessionPrefix: "test1", + tracker: { plugin: "jira", package: "@acme/ao-plugin-tracker-jira" }, + }, + proj2: { + path: "/repos/test2", + repo: "org/test2", + name: "proj2", + defaultBranch: "main", + sessionPrefix: "test2", + // Same external plugin but with WRONG explicit plugin name + tracker: { plugin: "wrong-name", package: "@acme/ao-plugin-tracker-jira" }, + }, + }, + _externalPluginEntries: [ + { + source: "projects.proj1.tracker", + location: { kind: "project", projectId: "proj1", configType: "tracker" }, + slot: "tracker", + package: "@acme/ao-plugin-tracker-jira", + // No expectedPluginName - will accept any manifest.name + }, + { + source: "projects.proj2.tracker", + location: { kind: "project", projectId: "proj2", configType: "tracker" }, + slot: "tracker", + package: "@acme/ao-plugin-tracker-jira", + expectedPluginName: "wrong-name", // Mismatches manifest.name "jira-cloud" + }, + ], + }); + + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + await registry.loadFromConfig(config, importFn); + + // Plugin should STILL be registered despite proj2's misconfiguration + expect(registry.get("tracker", "jira-cloud")).not.toBeNull(); + + // proj1 should be updated correctly (no expectedPluginName = accepts any) + expect(config.projects.proj1.tracker?.plugin).toBe("jira-cloud"); + + // proj2's config should NOT be updated (validation failed) + expect(config.projects.proj2.tracker?.plugin).toBe("wrong-name"); + + // Should have logged a warning about proj2's validation failure + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining("Config validation failed for projects.proj2.tracker"), + ); + + stderrSpy.mockRestore(); + }); +}); diff --git a/packages/core/src/__tests__/session-manager/spawn.test.ts b/packages/core/src/__tests__/session-manager/spawn.test.ts index 61ff18bed..b3c959998 100644 --- a/packages/core/src/__tests__/session-manager/spawn.test.ts +++ b/packages/core/src/__tests__/session-manager/spawn.test.ts @@ -1,11 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { - chmodSync, - mkdirSync, - readFileSync, - writeFileSync, - existsSync, -} from "node:fs"; +import { mkdirSync, readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { createSessionManager } from "../../session-manager.js"; import { validateConfig } from "../../config.js"; @@ -13,8 +7,6 @@ import { writeMetadata, readMetadata, readMetadataRaw, - deleteMetadata, - reserveSessionId, updateMetadata, } from "../../metadata.js"; import type { @@ -24,9 +16,13 @@ import type { Agent, Workspace, Tracker, - RuntimeHandle, } from "../../types.js"; -import { setupTestContext, teardownTestContext, makeHandle, type TestContext } from "../test-utils.js"; +import { + setupTestContext, + teardownTestContext, + makeHandle, + type TestContext, +} from "../test-utils.js"; import { installMockOpencode, installMockGit } from "./opencode-helpers.js"; let ctx: TestContext; @@ -41,7 +37,16 @@ let originalPath: string | undefined; beforeEach(() => { ctx = setupTestContext(); - ({ tmpDir, sessionsDir, mockRuntime, mockAgent, mockWorkspace, mockRegistry, config, originalPath } = ctx); + ({ + tmpDir, + sessionsDir, + mockRuntime, + mockAgent, + mockWorkspace, + mockRegistry, + config, + originalPath, + } = ctx); }); afterEach(() => { @@ -942,7 +947,8 @@ describe("spawn", () => { const sm = createSessionManager({ config, registry: registryWithFailingSend }); const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); - await vi.advanceTimersByTimeAsync(5_000); + // With retry logic (3 attempts at 3s, 6s, 9s delays before each attempt), need to advance 18s for all retries + await vi.advanceTimersByTimeAsync(18_000); const session = await spawnPromise; // Session should still be returned successfully despite sendMessage failure @@ -950,8 +956,10 @@ describe("spawn", () => { expect(session.status).toBe("spawning"); // Runtime should NOT have been destroyed expect(failingRuntime.destroy).not.toHaveBeenCalled(); + // Verify promptDelivered is set to false in metadata + expect(session.metadata.promptDelivered).toBe("false"); vi.useRealTimers(); - }); + }, 30_000); it("waits before sending post-launch prompt", async () => { vi.useFakeTimers(); @@ -972,28 +980,28 @@ describe("spawn", () => { const sm = createSessionManager({ config, registry: registryWithPostLaunch }); const spawnPromise = sm.spawn({ projectId: "my-app", prompt: "Fix the bug" }); - // Advance only 4s — not enough, message should not have been sent yet - await vi.advanceTimersByTimeAsync(4_000); + // Advance only 2s — not enough, message should not have been sent yet + await vi.advanceTimersByTimeAsync(2_000); expect(mockRuntime.sendMessage).not.toHaveBeenCalled(); - // Advance the remaining 1s — now it should fire + // Advance the remaining 1s — now the first attempt should fire (3s total = 3000 * 1) await vi.advanceTimersByTimeAsync(1_000); await spawnPromise; expect(mockRuntime.sendMessage).toHaveBeenCalled(); vi.useRealTimers(); - }); + }, 20_000); describe("spawnOrchestrator", () => { it("blocks orchestrator spawn while the project is globally paused", async () => { - writeMetadata(sessionsDir, "app-orchestrator", { + writeMetadata(sessionsDir, "app-orchestrator-1", { worktree: join(tmpDir, "my-app"), - branch: "main", + branch: "orchestrator/app-orchestrator-1", status: "working", role: "orchestrator", project: "my-app", runtimeHandle: JSON.stringify(makeHandle("rt-orchestrator")), }); - updateMetadata(sessionsDir, "app-orchestrator", { + updateMetadata(sessionsDir, "app-orchestrator-1", { globalPauseUntil: new Date(Date.now() + 60_000).toISOString(), globalPauseReason: "Rate limit reached", globalPauseSource: "app-9", @@ -1007,17 +1015,67 @@ describe("spawn", () => { expect(mockRuntime.create).not.toHaveBeenCalled(); }); + it("throws when no workspace plugin is configured", async () => { + const registryNoWorkspace: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return mockAgent; + return null; // no workspace plugin + }), + }; + const sm = createSessionManager({ config, registry: registryNoWorkspace }); + + await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow( + "spawnOrchestrator requires a workspace plugin", + ); + + // Reserved session metadata should be cleaned up + expect(readMetadataRaw(sessionsDir, "app-orchestrator-1")).toBeNull(); + expect(mockRuntime.create).not.toHaveBeenCalled(); + }); + it("creates orchestrator session with correct ID", async () => { const sm = createSessionManager({ config, registry: mockRegistry }); const session = await sm.spawnOrchestrator({ projectId: "my-app" }); - expect(session.id).toBe("app-orchestrator"); + expect(session.id).toBe("app-orchestrator-1"); expect(session.status).toBe("working"); expect(session.projectId).toBe("my-app"); - expect(session.branch).toBe("main"); + expect(session.branch).toBe("orchestrator/app-orchestrator-1"); expect(session.issueId).toBeNull(); - expect(session.workspacePath).toBe(join(tmpDir, "my-app")); + expect(session.workspacePath).toBe("/tmp/ws"); + }); + + it("creates a worktree with an orchestrator branch", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(mockWorkspace.create).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "app-orchestrator-1", + branch: "orchestrator/app-orchestrator-1", + projectId: "my-app", + }), + ); + }); + + it("uses the worktree path returned by the workspace plugin", async () => { + const worktreePath = join(tmpDir, "orchestrator-ws"); + (mockWorkspace.create as ReturnType).mockResolvedValueOnce({ + path: worktreePath, + branch: "orchestrator/app-orchestrator-1", + sessionId: "app-orchestrator-1", + projectId: "my-app", + }); + const sm = createSessionManager({ config, registry: mockRegistry }); + + const session = await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(session.workspacePath).toBe(worktreePath); + expect(session.branch).toBe("orchestrator/app-orchestrator-1"); }); it("writes metadata with proper fields", async () => { @@ -1025,23 +1083,113 @@ describe("spawn", () => { await sm.spawnOrchestrator({ projectId: "my-app" }); - const meta = readMetadata(sessionsDir, "app-orchestrator"); + const meta = readMetadata(sessionsDir, "app-orchestrator-1"); expect(meta).not.toBeNull(); expect(meta!.status).toBe("working"); expect(meta!.project).toBe("my-app"); - expect(meta!.worktree).toBe(join(tmpDir, "my-app")); - expect(meta!.branch).toBe("main"); + expect(meta!.branch).toBe("orchestrator/app-orchestrator-1"); expect(meta!.tmuxName).toBeDefined(); expect(meta!.runtimeHandle).toBeDefined(); }); + it("writes metadata with worktree path and orchestrator role", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + await sm.spawnOrchestrator({ projectId: "my-app" }); + + const meta = readMetadataRaw(sessionsDir, "app-orchestrator-1"); + expect(meta?.["role"]).toBe("orchestrator"); + expect(meta?.["branch"]).toBe("orchestrator/app-orchestrator-1"); + expect(meta?.["status"]).toBe("working"); + expect(meta?.["project"]).toBe("my-app"); + }); + + it("increments the orchestrator counter for each new session", async () => { + const sm = createSessionManager({ config, registry: mockRegistry }); + + const s1 = await sm.spawnOrchestrator({ projectId: "my-app" }); + const s2 = await sm.spawnOrchestrator({ projectId: "my-app" }); + + expect(s1.id).toBe("app-orchestrator-1"); + expect(s2.id).toBe("app-orchestrator-2"); + expect(mockWorkspace.create).toHaveBeenCalledTimes(2); + }); + + it("cleans up reserved metadata on workspace creation failure", async () => { + (mockWorkspace.create as ReturnType).mockRejectedValueOnce( + new Error("workspace creation failed"), + ); + const sm = createSessionManager({ config, registry: mockRegistry }); + + await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow( + "workspace creation failed", + ); + + // Reserved session file should be cleaned up + expect(readMetadataRaw(sessionsDir, "app-orchestrator-1")).toBeNull(); + }); + + it("destroys the worktree and metadata when runtime creation fails", async () => { + const worktreePath = join(tmpDir, "orchestrator-ws-rt-fail"); + (mockWorkspace.create as ReturnType).mockResolvedValueOnce({ + path: worktreePath, + branch: "orchestrator/app-orchestrator-1", + sessionId: "app-orchestrator-1", + projectId: "my-app", + }); + (mockRuntime.create as ReturnType).mockRejectedValueOnce( + new Error("runtime creation failed"), + ); + const sm = createSessionManager({ config, registry: mockRegistry }); + + await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow( + "runtime creation failed", + ); + + expect(mockWorkspace.destroy).toHaveBeenCalledWith(worktreePath); + expect(readMetadataRaw(sessionsDir, "app-orchestrator-1")).toBeNull(); + }); + + it("destroys the worktree when post-launch setup fails", async () => { + const worktreePath = join(tmpDir, "orchestrator-ws-postlaunch-fail"); + (mockWorkspace.create as ReturnType).mockResolvedValueOnce({ + path: worktreePath, + branch: "orchestrator/app-orchestrator-1", + sessionId: "app-orchestrator-1", + projectId: "my-app", + }); + const postLaunchError = new Error("post-launch setup failed"); + const agentWithPostLaunch: typeof mockAgent = { + ...mockAgent, + postLaunchSetup: vi.fn().mockRejectedValueOnce(postLaunchError), + }; + const registryWithPostLaunch: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return agentWithPostLaunch; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + const sm = createSessionManager({ config, registry: registryWithPostLaunch }); + + await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow( + "post-launch setup failed", + ); + + expect(mockRuntime.destroy).toHaveBeenCalled(); + expect(mockWorkspace.destroy).toHaveBeenCalledWith(worktreePath); + expect(readMetadataRaw(sessionsDir, "app-orchestrator-1")).toBeNull(); + }); + it("deletes previous OpenCode orchestrator sessions before starting", async () => { const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator.log"); const mockBin = installMockOpencode( tmpDir, JSON.stringify([ - { id: "ses_old", title: "AO:app-orchestrator", updated: "2025-01-01T00:00:00.000Z" }, - { id: "ses_new", title: "AO:app-orchestrator", updated: "2025-01-02T00:00:00.000Z" }, + { id: "ses_old", title: "AO:app-orchestrator-1", updated: "2025-01-01T00:00:00.000Z" }, + { id: "ses_new", title: "AO:app-orchestrator-1", updated: "2025-01-02T00:00:00.000Z" }, ]), deleteLogPath, ); @@ -1083,14 +1231,14 @@ describe("spawn", () => { expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith( expect.objectContaining({ - sessionId: "app-orchestrator", + sessionId: "app-orchestrator-1", projectConfig: expect.objectContaining({ agentConfig: expect.not.objectContaining({ opencodeSessionId: expect.any(String) }), }), }), ); - const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); + const meta = readMetadataRaw(sessionsDir, "app-orchestrator-1"); expect(meta?.["agent"]).toBe("opencode"); expect(meta?.["opencodeSessionId"]).toBeUndefined(); }); @@ -1102,7 +1250,7 @@ describe("spawn", () => { JSON.stringify([ { id: "ses_discovered_orchestrator", - title: "AO:app-orchestrator", + title: "AO:app-orchestrator-1", updated: 1_772_777_000_000, }, ]), @@ -1140,205 +1288,20 @@ describe("spawn", () => { const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); await sm.spawnOrchestrator({ projectId: "my-app" }); - const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); + const meta = readMetadataRaw(sessionsDir, "app-orchestrator-1"); expect(meta?.["opencodeSessionId"]).toBe("ses_discovered_orchestrator"); }); - it("reuses an existing orchestrator session when strategy is reuse", async () => { - const listLogPath = join(tmpDir, "opencode-list-orchestrator-reuse.log"); - const mockBin = join(tmpDir, "mock-bin-reuse-no-list"); - mkdirSync(mockBin, { recursive: true }); - const scriptPath = join(mockBin, "opencode"); - writeFileSync( - scriptPath, - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - 'if [[ "$1" == "session" && "$2" == "list" ]]; then', - ` printf '%s\\n' "$*" >> '${listLogPath.replace(/'/g, "'\\''")}'`, - " printf '[]\\n'", - " exit 0", - "fi", - "exit 0", - "", - ].join("\n"), - "utf-8", - ); - chmodSync(scriptPath, 0o755); - process.env.PATH = `${mockBin}:${originalPath ?? ""}`; - - const opencodeAgent: Agent = { - ...mockAgent, - name: "opencode", - }; - const registryWithOpenCode: PluginRegistry = { - ...mockRegistry, - get: vi.fn().mockImplementation((slot: string) => { - if (slot === "runtime") return mockRuntime; - if (slot === "agent") return opencodeAgent; - if (slot === "workspace") return mockWorkspace; - return null; - }), - }; - - const configWithReuse: OrchestratorConfig = { - ...config, - defaults: { ...config.defaults, agent: "opencode" }, - projects: { - ...config.projects, - "my-app": { - ...config.projects["my-app"], - agent: "opencode", - orchestratorSessionStrategy: "reuse", - }, - }, - }; - - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - agent: "opencode", - runtimeHandle: JSON.stringify(makeHandle("rt-existing")), - opencodeSessionId: "ses_existing", - createdAt: new Date().toISOString(), - }); - - const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); - const session = await sm.spawnOrchestrator({ projectId: "my-app" }); - - expect(session.id).toBe("app-orchestrator"); - expect(session.metadata["orchestratorSessionReused"]).toBe("true"); - expect(mockRuntime.create).not.toHaveBeenCalled(); - expect(mockRuntime.destroy).not.toHaveBeenCalled(); - expect(existsSync(listLogPath)).toBe(false); - }); - - it("destroys orphaned runtime when reuse strategy finds alive runtime but get returns null", async () => { - const deleteLogPath = join(tmpDir, "opencode-delete-orphaned-runtime.log"); - const mockBin = installMockOpencode(tmpDir, "[]", deleteLogPath); - process.env.PATH = `${mockBin}:${originalPath ?? ""}`; - - const opencodeAgent: Agent = { - ...mockAgent, - name: "opencode", - }; - const registryWithOpenCode: PluginRegistry = { - ...mockRegistry, - get: vi.fn().mockImplementation((slot: string) => { - if (slot === "runtime") return mockRuntime; - if (slot === "agent") return opencodeAgent; - if (slot === "workspace") return mockWorkspace; - return null; - }), - }; - - const configWithReuse: OrchestratorConfig = { - ...config, - defaults: { ...config.defaults, agent: "opencode" }, - projects: { - ...config.projects, - "my-app": { - ...config.projects["my-app"], - agent: "opencode", - orchestratorSessionStrategy: "reuse", - }, - }, - }; - - const orphanedHandle = makeHandle("rt-orphaned"); - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - agent: "opencode", - runtimeHandle: JSON.stringify(orphanedHandle), - createdAt: new Date().toISOString(), - }); - - vi.mocked(mockRuntime.isAlive).mockImplementation(async (handle: RuntimeHandle) => { - if (handle?.id === "rt-orphaned") { - deleteMetadata(sessionsDir, "app-orchestrator"); - return true; - } - return false; - }); - - const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); - const session = await sm.spawnOrchestrator({ projectId: "my-app" }); - - expect(session.id).toBe("app-orchestrator"); - expect(mockRuntime.destroy).toHaveBeenCalledWith(orphanedHandle); - expect(mockRuntime.create).toHaveBeenCalled(); - }); - - it("reuses mapped OpenCode session id when strategy is reuse and runtime is restarted", async () => { - const opencodeAgent: Agent = { - ...mockAgent, - name: "opencode", - }; - const registryWithOpenCode: PluginRegistry = { - ...mockRegistry, - get: vi.fn().mockImplementation((slot: string) => { - if (slot === "runtime") return mockRuntime; - if (slot === "agent") return opencodeAgent; - if (slot === "workspace") return mockWorkspace; - return null; - }), - }; - - const configWithReuse: OrchestratorConfig = { - ...config, - defaults: { ...config.defaults, agent: "opencode" }, - projects: { - ...config.projects, - "my-app": { - ...config.projects["my-app"], - agent: "opencode", - orchestratorSessionStrategy: "reuse", - }, - }, - }; - - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - agent: "opencode", - runtimeHandle: JSON.stringify(makeHandle("rt-existing")), - opencodeSessionId: "ses_existing", - createdAt: new Date().toISOString(), - }); - - vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); - - const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); - await sm.spawnOrchestrator({ projectId: "my-app" }); - - expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith( - expect.objectContaining({ - projectConfig: expect.objectContaining({ - agentConfig: expect.objectContaining({ opencodeSessionId: "ses_existing" }), - }), - }), - ); - const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); - expect(meta?.["opencodeSessionId"]).toBe("ses_existing"); - }); - - it("reuses archived OpenCode mapping for orchestrator when active metadata has no mapping", async () => { - const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator-reuse-archived.log"); + it("reuses mapped OpenCode session id when strategy is reuse and opencode lists it by title", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator-reuse-restart.log"); const mockBin = installMockOpencode( tmpDir, JSON.stringify([ - null, - { id: "ses_existing", title: "AO:app-orchestrator", updated: 1_772_777_000_000 }, + { + id: "ses_existing", + title: "AO:app-orchestrator-1", + updated: 1_772_777_000_000, + }, ]), deleteLogPath, ); @@ -1371,30 +1334,57 @@ describe("spawn", () => { }, }; - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - agent: "opencode", - runtimeHandle: JSON.stringify(makeHandle("rt-existing")), - opencodeSessionId: "ses_existing", - createdAt: new Date().toISOString(), - }); - deleteMetadata(sessionsDir, "app-orchestrator", true); - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - agent: "opencode", - runtimeHandle: JSON.stringify(makeHandle("rt-existing")), - createdAt: new Date().toISOString(), - }); + const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); + await sm.spawnOrchestrator({ projectId: "my-app" }); - vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); + expect(opencodeAgent.getLaunchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + projectConfig: expect.objectContaining({ + agentConfig: expect.objectContaining({ opencodeSessionId: "ses_existing" }), + }), + }), + ); + const meta = readMetadataRaw(sessionsDir, "app-orchestrator-1"); + expect(meta?.["opencodeSessionId"]).toBe("ses_existing"); + }); + + it("discovers OpenCode mapping by title when no archived mapping exists for new session id", async () => { + const deleteLogPath = join(tmpDir, "opencode-delete-orchestrator-reuse-title-fallback.log"); + const mockBin = installMockOpencode( + tmpDir, + JSON.stringify([ + { id: "ses_existing", title: "AO:app-orchestrator-1", updated: 1_772_777_000_000 }, + ]), + deleteLogPath, + ); + process.env.PATH = `${mockBin}:${originalPath ?? ""}`; + + const opencodeAgent: Agent = { + ...mockAgent, + name: "opencode", + }; + const registryWithOpenCode: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string) => { + if (slot === "runtime") return mockRuntime; + if (slot === "agent") return opencodeAgent; + if (slot === "workspace") return mockWorkspace; + return null; + }), + }; + + const configWithReuse: OrchestratorConfig = { + ...config, + defaults: { ...config.defaults, agent: "opencode" }, + projects: { + ...config.projects, + "my-app": { + ...config.projects["my-app"], + agent: "opencode", + orchestratorSessionStrategy: "reuse", + }, + }, + }; const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); await sm.spawnOrchestrator({ projectId: "my-app" }); @@ -1413,8 +1403,7 @@ describe("spawn", () => { const mockBin = installMockOpencode( tmpDir, JSON.stringify([ - null, - { id: "ses_title_match", title: "AO:app-orchestrator", updated: 1_772_777_000_000 }, + { id: "ses_title_match", title: "AO:app-orchestrator-1", updated: 1_772_777_000_000 }, ]), deleteLogPath, ); @@ -1447,19 +1436,6 @@ describe("spawn", () => { }, }; - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - agent: "opencode", - runtimeHandle: JSON.stringify(makeHandle("rt-existing")), - createdAt: new Date().toISOString(), - }); - - vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); - const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); await sm.spawnOrchestrator({ projectId: "my-app" }); @@ -1470,81 +1446,11 @@ describe("spawn", () => { }), }), ); - const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); + const meta = readMetadataRaw(sessionsDir, "app-orchestrator-1"); expect(meta?.["opencodeSessionId"]).toBe("ses_title_match"); }); - it("starts fresh without deleting prior OpenCode sessions when strategy is ignore", async () => { - const deleteLogPath = join(tmpDir, "opencode-delete-ignore.log"); - const mockBin = installMockOpencode( - tmpDir, - JSON.stringify([ - { id: "ses_old", title: "AO:app-orchestrator", updated: "2025-01-01T00:00:00.000Z" }, - ]), - deleteLogPath, - ); - process.env.PATH = `${mockBin}:${originalPath ?? ""}`; - - const opencodeAgent: Agent = { - ...mockAgent, - name: "opencode", - }; - const registryWithOpenCode: PluginRegistry = { - ...mockRegistry, - get: vi.fn().mockImplementation((slot: string) => { - if (slot === "runtime") return mockRuntime; - if (slot === "agent") return opencodeAgent; - if (slot === "workspace") return mockWorkspace; - return null; - }), - }; - - const configWithIgnoreNew: OrchestratorConfig = { - ...config, - defaults: { ...config.defaults, agent: "opencode" }, - projects: { - ...config.projects, - "my-app": { - ...config.projects["my-app"], - agent: "opencode", - orchestratorSessionStrategy: "ignore", - }, - }, - }; - - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - agent: "opencode", - runtimeHandle: JSON.stringify(makeHandle("rt-existing")), - createdAt: new Date().toISOString(), - }); - - vi.mocked(mockRuntime.isAlive).mockResolvedValueOnce(true); - - const sm = createSessionManager({ - config: configWithIgnoreNew, - registry: registryWithOpenCode, - }); - await sm.spawnOrchestrator({ projectId: "my-app" }); - - expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("rt-existing")); - expect(mockRuntime.create).toHaveBeenCalled(); - expect(existsSync(deleteLogPath)).toBe(false); - }); - - it("skips workspace creation", async () => { - const sm = createSessionManager({ config, registry: mockRegistry }); - - await sm.spawnOrchestrator({ projectId: "my-app" }); - - expect(mockWorkspace.create).not.toHaveBeenCalled(); - }); - - it("calls agent.setupWorkspaceHooks on project path", async () => { + it("calls agent.setupWorkspaceHooks on worktree path", async () => { const agentWithHooks: Agent = { ...mockAgent, setupWorkspaceHooks: vi.fn().mockResolvedValue(undefined), @@ -1563,7 +1469,7 @@ describe("spawn", () => { await sm.spawnOrchestrator({ projectId: "my-app" }); expect(agentWithHooks.setupWorkspaceHooks).toHaveBeenCalledWith( - join(tmpDir, "my-app"), + "/tmp/ws", expect.objectContaining({ dataDir: sessionsDir }), ); }); @@ -1575,7 +1481,7 @@ describe("spawn", () => { expect(mockRuntime.create).toHaveBeenCalledWith( expect.objectContaining({ - workspacePath: join(tmpDir, "my-app"), + workspacePath: "/tmp/ws", launchCommand: "mock-agent --start", }), ); @@ -1586,31 +1492,10 @@ describe("spawn", () => { await sm.spawnOrchestrator({ projectId: "my-app" }); - const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); + const meta = readMetadataRaw(sessionsDir, "app-orchestrator-1"); expect(meta?.["orchestratorSessionReused"]).toBeUndefined(); }); - it("respawns the orchestrator when stale metadata exists but the runtime is dead", async () => { - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - project: "my-app", - role: "orchestrator", - runtimeHandle: JSON.stringify(makeHandle("rt-stale")), - createdAt: new Date().toISOString(), - }); - - vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); - - const sm = createSessionManager({ config, registry: mockRegistry }); - await sm.spawnOrchestrator({ projectId: "my-app" }); - - expect(mockRuntime.create).toHaveBeenCalledTimes(1); - const meta = readMetadataRaw(sessionsDir, "app-orchestrator"); - expect(meta?.["runtimeHandle"]).toBe(JSON.stringify(makeHandle("rt-1"))); - }); - it("uses orchestratorModel when configured", async () => { const configWithOrchestratorModel: OrchestratorConfig = { ...config, @@ -1709,7 +1594,7 @@ describe("spawn", () => { expect(mockCodexAgent.getLaunchCommand).toHaveBeenCalled(); expect(mockAgent.getLaunchCommand).not.toHaveBeenCalled(); - expect(readMetadataRaw(sessionsDir, "app-orchestrator")?.["agent"]).toBe("codex"); + expect(readMetadataRaw(sessionsDir, "app-orchestrator-1")?.["agent"]).toBe("codex"); }); it("uses defaults orchestrator agent when project agent is not set", async () => { @@ -1756,7 +1641,7 @@ describe("spawn", () => { await sm.spawnOrchestrator({ projectId: "my-app" }); expect(mockCodexAgent.getLaunchCommand).toHaveBeenCalled(); - expect(readMetadataRaw(sessionsDir, "app-orchestrator")?.["agent"]).toBe("codex"); + expect(readMetadataRaw(sessionsDir, "app-orchestrator-1")?.["agent"]).toBe("codex"); }); it("keeps shared worker permissions when role-specific config only overrides model", async () => { @@ -1858,8 +1743,8 @@ describe("spawn", () => { // Should pass systemPromptFile (not inline systemPrompt) to avoid tmux truncation expect(mockAgent.getLaunchCommand).toHaveBeenCalledWith( expect.objectContaining({ - sessionId: "app-orchestrator", - systemPromptFile: expect.stringContaining("orchestrator-prompt.md"), + sessionId: "app-orchestrator-1", + systemPromptFile: expect.stringContaining("orchestrator-prompt-app-orchestrator-1.md"), }), ); @@ -1898,102 +1783,80 @@ describe("spawn", () => { expect(session.runtimeHandle).toEqual(makeHandle("rt-1")); }); - it("reuses existing orchestrator on reservation conflict when strategy is reuse", async () => { - const opencodeAgent: Agent = { - ...mockAgent, - name: "opencode", - }; - const registryWithOpenCode: PluginRegistry = { - ...mockRegistry, - get: vi.fn().mockImplementation((slot: string) => { - if (slot === "runtime") return mockRuntime; - if (slot === "agent") return opencodeAgent; - if (slot === "workspace") return mockWorkspace; - return null; - }), - }; - - const configWithReuse: OrchestratorConfig = { - ...config, - defaults: { ...config.defaults, agent: "opencode" }, - projects: { - ...config.projects, - "my-app": { - ...config.projects["my-app"], - agent: "opencode", - orchestratorSessionStrategy: "reuse", - }, - }, - }; - - writeMetadata(sessionsDir, "app-orchestrator", { + it("blocks spawn while the project is globally paused (orchestrator-N orchestrator)", async () => { + // Pause set by a worktree-based orchestrator + writeMetadata(sessionsDir, "app-orchestrator-1", { worktree: join(tmpDir, "my-app"), - branch: "main", + branch: "orchestrator/app-orchestrator-1", status: "working", role: "orchestrator", project: "my-app", - agent: "opencode", - runtimeHandle: JSON.stringify(makeHandle("rt-concurrent")), - opencodeSessionId: "ses_concurrent", - createdAt: new Date().toISOString(), + runtimeHandle: JSON.stringify(makeHandle("rt-orchestrator-1")), }); - - vi.mocked(mockRuntime.isAlive).mockResolvedValue(true); - - const sm = createSessionManager({ config: configWithReuse, registry: registryWithOpenCode }); - const session = await sm.spawnOrchestrator({ projectId: "my-app" }); - - expect(session.metadata["orchestratorSessionReused"]).toBe("true"); - expect(mockRuntime.create).not.toHaveBeenCalled(); - }); - - it("recovers reservation conflict when existing session is not usable", async () => { - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "killed", - role: "orchestrator", - project: "my-app", - runtimeHandle: JSON.stringify(makeHandle("rt-dead")), - createdAt: new Date().toISOString(), + updateMetadata(sessionsDir, "app-orchestrator-1", { + globalPauseUntil: new Date(Date.now() + 60_000).toISOString(), + globalPauseReason: "Rate limit reached", + globalPauseSource: "app-9", }); - vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); - - const sm = createSessionManager({ config, registry: mockRegistry }); - await expect(sm.spawnOrchestrator({ projectId: "my-app" })).resolves.toBeDefined(); - expect(mockRuntime.create).toHaveBeenCalledTimes(1); - }); - - it("creates only one runtime on reservation conflict", async () => { - writeMetadata(sessionsDir, "app-orchestrator", { - worktree: join(tmpDir, "my-app"), - branch: "main", - status: "working", - role: "orchestrator", - project: "my-app", - runtimeHandle: JSON.stringify(makeHandle("rt-existing")), - createdAt: new Date().toISOString(), - }); - - vi.mocked(mockRuntime.isAlive).mockResolvedValue(false); - - const sm = createSessionManager({ config, registry: mockRegistry }); - await expect(sm.spawnOrchestrator({ projectId: "my-app" })).resolves.toBeDefined(); - expect(mockRuntime.create).toHaveBeenCalledTimes(1); - }); - - it("does not delete an in-progress reservation file without runtime metadata", async () => { - expect(reserveSessionId(sessionsDir, "app-orchestrator")).toBe(true); - const sm = createSessionManager({ config, registry: mockRegistry }); await expect(sm.spawnOrchestrator({ projectId: "my-app" })).rejects.toThrow( - "already exists but is not in a reusable state", + "Project is paused due to model rate limit until", ); - expect(mockRuntime.create).not.toHaveBeenCalled(); - expect(readMetadataRaw(sessionsDir, "app-orchestrator")).toEqual({}); + }); + + it("does not skip pause check for orchestrator-N orchestrator when sending to workers", async () => { + // Worker session + writeMetadata(sessionsDir, "app-1", { + worktree: join(tmpDir, "ws-1"), + branch: "session/app-1", + status: "working", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-1")), + }); + // Pause set by orchestrator-1 + writeMetadata(sessionsDir, "app-orchestrator-1", { + worktree: join(tmpDir, "my-app"), + branch: "orchestrator/app-orchestrator-1", + status: "working", + role: "orchestrator", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-orchestrator-1")), + }); + updateMetadata(sessionsDir, "app-orchestrator-1", { + globalPauseUntil: new Date(Date.now() + 60_000).toISOString(), + globalPauseReason: "Rate limit", + globalPauseSource: "app-orchestrator-1", + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + + await expect(sm.send("app-1", "hello")).rejects.toThrow( + "Project is paused due to model rate limit until", + ); + }); + + it("allows orchestrator-N orchestrator session to send despite project pause", async () => { + // Orch-1 session itself is alive + writeMetadata(sessionsDir, "app-orchestrator-1", { + worktree: join(tmpDir, "my-app"), + branch: "orchestrator/app-orchestrator-1", + status: "working", + role: "orchestrator", + project: "my-app", + runtimeHandle: JSON.stringify(makeHandle("rt-orchestrator-1")), + }); + updateMetadata(sessionsDir, "app-orchestrator-1", { + globalPauseUntil: new Date(Date.now() + 60_000).toISOString(), + globalPauseReason: "Rate limit", + globalPauseSource: "app-orchestrator-1", + }); + + const sm = createSessionManager({ config, registry: mockRegistry }); + + // The orchestrator itself should be exempt from the pause + await expect(sm.send("app-orchestrator-1", "continue")).resolves.not.toThrow(); }); }); }); - diff --git a/packages/core/src/__tests__/types.test.ts b/packages/core/src/__tests__/types.test.ts index bf54e4b0c..911d36be0 100644 --- a/packages/core/src/__tests__/types.test.ts +++ b/packages/core/src/__tests__/types.test.ts @@ -4,19 +4,35 @@ import { isOrchestratorSession, isIssueNotFoundError } from "../types.js"; describe("isOrchestratorSession", () => { it("detects orchestrators by explicit role metadata", () => { expect( - isOrchestratorSession({ - id: "app-control", - metadata: { role: "orchestrator" }, - }), + isOrchestratorSession({ id: "app-control", metadata: { role: "orchestrator" } }, "app"), ).toBe(true); }); it("falls back to orchestrator naming for legacy sessions", () => { - expect(isOrchestratorSession({ id: "app-orchestrator", metadata: {} })).toBe(true); + expect(isOrchestratorSession({ id: "app-orchestrator", metadata: {} }, "app")).toBe(true); }); - it("does not classify worker sessions as orchestrators", () => { - expect(isOrchestratorSession({ id: "app-7", metadata: { role: "worker" } })).toBe(false); + it("detects numbered worktree orchestrators by prefix pattern", () => { + expect(isOrchestratorSession({ id: "app-orchestrator-1", metadata: {} }, "app")).toBe(true); + expect(isOrchestratorSession({ id: "app-orchestrator-42", metadata: {} }, "app")).toBe(true); + }); + + it("does not false-positive on worker sessions", () => { + expect(isOrchestratorSession({ id: "app-7", metadata: { role: "worker" } }, "app")).toBe(false); + }); + + it("does not false-positive when prefix ends with -orchestrator", () => { + // my-orchestrator-1 is a worker when prefix is "my-orchestrator" + expect( + isOrchestratorSession({ id: "my-orchestrator-1", metadata: {} }, "my-orchestrator"), + ).toBe(false); + // my-orchestrator-orchestrator-1 is the real worktree orchestrator + expect( + isOrchestratorSession( + { id: "my-orchestrator-orchestrator-1", metadata: {} }, + "my-orchestrator", + ), + ).toBe(true); }); }); diff --git a/packages/core/src/agent-selection.ts b/packages/core/src/agent-selection.ts index de8a76d77..158cb280e 100644 --- a/packages/core/src/agent-selection.ts +++ b/packages/core/src/agent-selection.ts @@ -20,9 +20,10 @@ export interface ResolvedAgentSelection { export function resolveSessionRole( sessionId: string, - metadata?: Record, + metadata: Record | undefined, + sessionPrefix: string, ): SessionRole { - return isOrchestratorSession({ id: sessionId, metadata }) ? "orchestrator" : "worker"; + return isOrchestratorSession({ id: sessionId, metadata }, sessionPrefix) ? "orchestrator" : "worker"; } export function resolveAgentSelection(params: { diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 13a805195..0da3db105 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -15,7 +15,7 @@ import { resolve, join, basename } from "node:path"; import { homedir } from "node:os"; import { parse as parseYaml } from "yaml"; import { z } from "zod"; -import { ConfigNotFoundError, type OrchestratorConfig } from "./types.js"; +import { ConfigNotFoundError, type ExternalPluginEntryRef, type OrchestratorConfig } from "./types.js"; import { generateSessionPrefix } from "./paths.js"; function inferScmPlugin(project: { @@ -50,6 +50,32 @@ function inferScmPlugin(project: { // ZOD SCHEMAS // ============================================================================= +/** + * Common validation for plugin config fields (tracker, scm, notifier). + * Must have either plugin (for built-ins) or package/path (for external plugins). + * Cannot have both package and path. + */ +function validatePluginConfigFields( + value: { plugin?: string; package?: string; path?: string }, + ctx: z.RefinementCtx, + configType: string, +): void { + // Must have either plugin or package/path + if (!value.plugin && !value.package && !value.path) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${configType} config requires either 'plugin' (for built-ins) or 'package'/'path' (for external plugins)`, + }); + } + // Cannot have both package and path + if (value.package && value.path) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${configType} config cannot have both 'package' and 'path' - use one or the other`, + }); + } +} + const ReactionConfigSchema = z.object({ auto: z.boolean().default(true), action: z.enum(["send-to-agent", "notify", "auto-merge"]).default("notify"), @@ -63,13 +89,18 @@ const ReactionConfigSchema = z.object({ const TrackerConfigSchema = z .object({ - plugin: z.string(), + plugin: z.string().optional(), + package: z.string().optional(), + path: z.string().optional(), }) - .passthrough(); + .passthrough() + .superRefine((value, ctx) => validatePluginConfigFields(value, ctx, "Tracker")); const SCMConfigSchema = z .object({ - plugin: z.string(), + plugin: z.string().optional(), + package: z.string().optional(), + path: z.string().optional(), webhook: z .object({ enabled: z.boolean().default(true), @@ -82,13 +113,17 @@ const SCMConfigSchema = z }) .optional(), }) - .passthrough(); + .passthrough() + .superRefine((value, ctx) => validatePluginConfigFields(value, ctx, "SCM")); const NotifierConfigSchema = z .object({ - plugin: z.string(), + plugin: z.string().optional(), + package: z.string().optional(), + path: z.string().optional(), }) - .passthrough(); + .passthrough() + .superRefine((value, ctx) => validatePluginConfigFields(value, ctx, "Notifier")); const AgentPermissionSchema = z .enum(["permissionless", "default", "auto-edit", "suggest", "skip"]) @@ -253,6 +288,181 @@ function expandPaths(config: OrchestratorConfig): OrchestratorConfig { return config; } +/** + * Generate a temporary plugin name from a package or path specifier. + * This name is used until the actual manifest.name is discovered during plugin loading. + * Format: extract the plugin name from the package/path, removing common prefixes. + * e.g., "@acme/ao-plugin-tracker-jira" -> "jira" + * e.g., "@acme/ao-plugin-tracker-jira-cloud" -> "jira-cloud" + * e.g., "./plugins/my-tracker" -> "my-tracker" + * e.g., "my-tracker" (local path without slashes) -> "my-tracker" + */ +function generateTempPluginName(pkg?: string, path?: string): string { + if (pkg) { + // Extract package name without scope: "@acme/ao-plugin-tracker-jira" -> "ao-plugin-tracker-jira" + const slashParts = pkg.split("/"); + const packageName = slashParts[slashParts.length - 1] ?? pkg; + + // Extract plugin name after ao-plugin-{slot}- prefix, preserving multi-word names like "jira-cloud" + const prefixMatch = packageName.match(/^ao-plugin-(?:runtime|agent|workspace|tracker|scm|notifier|terminal)-(.+)$/); + if (prefixMatch?.[1]) { + return prefixMatch[1]; + } + + // Non-standard package name (doesn't follow ao-plugin convention): use the full package name + // to avoid collisions. "plugin" from "custom-tracker-plugin" would collide with other packages + // that also end in "-plugin". The temp name is replaced with manifest.name after loading anyway. + return packageName; + } + + // Handle local paths: use the basename + // ./plugins/my-tracker -> my-tracker + // my-tracker -> my-tracker (no slashes is still a valid path) + if (path) { + const segments = path.split("/").filter((s) => s && s !== "." && s !== ".."); + return segments[segments.length - 1] ?? path; + } + + return "unknown"; +} + +/** + * Helper to process a single external plugin config entry. + * Expands home paths, generates temp plugin name if needed, and returns the entry ref. + */ +function processExternalPluginConfig( + pluginConfig: { plugin?: string; package?: string; path?: string }, + source: string, + location: ExternalPluginEntryRef["location"], + slot: ExternalPluginEntryRef["slot"], +): ExternalPluginEntryRef | null { + if (!pluginConfig.package && !pluginConfig.path) return null; + + // Expand home paths (~/...) for consistency with config.plugins + if (pluginConfig.path) { + pluginConfig.path = expandHome(pluginConfig.path); + } + + // Track if user explicitly specified plugin name (for validation) + const userSpecifiedPlugin = pluginConfig.plugin; + + // If plugin name not specified, generate a temporary one from package/path + if (!pluginConfig.plugin) { + pluginConfig.plugin = generateTempPluginName(pluginConfig.package, pluginConfig.path); + } + + return { + source, + location, + slot, + package: pluginConfig.package, + path: pluginConfig.path, + expectedPluginName: userSpecifiedPlugin, + }; +} + +/** + * Collect external plugin configs from tracker, scm, and notifier inline configs. + * These will be auto-added to config.plugins for loading. + * + * Also sets a temporary plugin name on configs that only have package/path, + * so that resolvePlugins() can look up the plugin by name. + * + * IMPORTANT: Only sets expectedPluginName when user explicitly specified `plugin`. + * When plugin is auto-generated, expectedPluginName is left undefined so that + * any manifest.name is accepted and the config is updated with it. + */ +export function collectExternalPluginConfigs(config: OrchestratorConfig): ExternalPluginEntryRef[] { + const entries: ExternalPluginEntryRef[] = []; + + // Collect from project tracker and scm configs + for (const [projectId, project] of Object.entries(config.projects)) { + if (project.tracker) { + const entry = processExternalPluginConfig( + project.tracker, + `projects.${projectId}.tracker`, + { kind: "project", projectId, configType: "tracker" }, + "tracker", + ); + if (entry) entries.push(entry); + } + + if (project.scm) { + const entry = processExternalPluginConfig( + project.scm, + `projects.${projectId}.scm`, + { kind: "project", projectId, configType: "scm" }, + "scm", + ); + if (entry) entries.push(entry); + } + } + + // Collect from global notifier configs + for (const [notifierId, notifierConfig] of Object.entries(config.notifiers ?? {})) { + if (notifierConfig) { + const entry = processExternalPluginConfig( + notifierConfig, + `notifiers.${notifierId}`, + { kind: "notifier", notifierId }, + "notifier", + ); + if (entry) entries.push(entry); + } + } + + return entries; +} + +/** + * Generate InstalledPluginConfig entries from external plugin entries. + * Merges with existing plugins, avoiding duplicates by package/path. + */ +function mergeExternalPlugins( + existingPlugins: OrchestratorConfig["plugins"], + externalEntries: ExternalPluginEntryRef[], +): OrchestratorConfig["plugins"] { + const plugins = [...(existingPlugins ?? [])]; + const seen = new Set(); + + // Track existing plugins by package/path + for (const plugin of plugins) { + if (plugin.package) seen.add(`package:${plugin.package}`); + if (plugin.path) seen.add(`path:${plugin.path}`); + } + + // Add external entries that aren't already present, or enable if disabled + for (const entry of externalEntries) { + const key = entry.package ? `package:${entry.package}` : `path:${entry.path}`; + if (seen.has(key)) { + // If the existing plugin is disabled but there's an inline reference, enable it + const existingPlugin = plugins.find( + (p) => + (entry.package && p.package === entry.package) || + (entry.path && p.path === entry.path), + ); + if (existingPlugin && existingPlugin.enabled === false) { + existingPlugin.enabled = true; + } + continue; + } + seen.add(key); + + // Generate a temporary name - will be replaced with manifest.name during loading + const tempName = entry.expectedPluginName ?? generateTempPluginName(entry.package, entry.path); + + plugins.push({ + name: tempName, + source: entry.package ? "npm" : "local", + package: entry.package, + path: entry.path, + enabled: true, + }); + } + + return plugins; +} + /** Apply defaults to project configs */ function applyProjectDefaults(config: OrchestratorConfig): OrchestratorConfig { for (const [id, project] of Object.entries(config.projects)) { @@ -544,6 +754,15 @@ export function validateConfig(raw: unknown): OrchestratorConfig { config = applyProjectDefaults(config); config = applyDefaultReactions(config); + // Collect external plugin configs from inline tracker/scm/notifier configs + // and merge them into config.plugins for loading + const externalPluginEntries = collectExternalPluginConfigs(config); + if (externalPluginEntries.length > 0) { + config.plugins = mergeExternalPlugins(config.plugins, externalPluginEntries); + // Store entries for manifest validation during plugin loading + config._externalPluginEntries = externalPluginEntries; + } + // Validate project uniqueness and prefix collisions validateProjectUniqueness(config); diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index b58d383a1..b2fc74c8e 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -15,6 +15,7 @@ import { SESSION_STATUS, PR_STATE, CI_STATUS, + TERMINAL_STATUSES, type LifecycleManager, type SessionManager, type SessionId, @@ -33,6 +34,7 @@ import { type EventPriority, type ProjectConfig as _ProjectConfig, type PREnrichmentData, + type CICheck, } from "./types.js"; import { updateMetadata } from "./metadata.js"; import { getSessionsDir } from "./paths.js"; @@ -234,7 +236,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const [owner, repo] = p.repo.split("/"); return owner === pr.owner && repo === pr.repo; }); - if (!project?.scm) continue; + if (!project?.scm?.plugin) continue; const pluginKey = project.scm.plugin; if (!prsByPlugin.has(pluginKey)) { @@ -343,13 +345,13 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan if (!project) return session.status; const agentName = resolveAgentSelection({ - role: resolveSessionRole(session.id, session.metadata), + role: resolveSessionRole(session.id, session.metadata, project.sessionPrefix), project, defaults: config.defaults, persistedAgent: session.metadata["agent"], }).agentName; const agent = registry.get("agent", agentName); - const scm = project.scm ? registry.get("scm", project.scm.plugin) : null; + const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null; // Track activity state across steps so stuck detection can run after PR checks let detectedIdleTimestamp: Date | null = null; @@ -725,13 +727,13 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan const project = config.projects[session.projectId]; if (!project || !session.pr) return; - const scm = project.scm ? registry.get("scm", project.scm.plugin) : null; + const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null; if (!scm) return; const humanReactionKey = "changes-requested"; const automatedReactionKey = "bugbot-comments"; - if (newStatus === "merged" || newStatus === "killed") { + if (TERMINAL_STATUSES.has(newStatus)) { clearReactionTracker(session.id, humanReactionKey); clearReactionTracker(session.id, automatedReactionKey); updateSessionMetadata(session, { @@ -867,6 +869,248 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan } } + /** + * Format CI check failures into a human-readable message for the agent. + * Includes check names, statuses, and links for debugging. + */ + function formatCIFailureMessage(failedChecks: CICheck[]): string { + const lines = [ + "CI checks are failing on your PR. Here are the failed checks:", + "", + ]; + for (const check of failedChecks) { + const status = check.conclusion ?? check.status; + const link = check.url ? ` — ${check.url}` : ""; + lines.push(`- **${check.name}**: ${status}${link}`); + } + lines.push( + "", + "Investigate the failures, fix the issues, and push again.", + ); + return lines.join("\n"); + } + + /** + * Dispatch CI failure details to the agent session when new or changed + * failures are detected. Follows the same fingerprinting/deduplication + * pattern as maybeDispatchReviewBacklog(). + */ + async function maybeDispatchCIFailureDetails( + session: Session, + _oldStatus: SessionStatus, + newStatus: SessionStatus, + transitionReaction?: { key: string; result: ReactionResult | null }, + ): Promise { + const project = config.projects[session.projectId]; + if (!project || !session.pr) return; + + const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null; + if (!scm) return; + + const ciReactionKey = "ci-failed"; + + // Clear tracking when PR is closed/merged + if (newStatus === "merged" || newStatus === "killed") { + clearReactionTracker(session.id, ciReactionKey); + updateSessionMetadata(session, { + lastCIFailureFingerprint: "", + lastCIFailureDispatchHash: "", + lastCIFailureDispatchAt: "", + }); + return; + } + + // Only dispatch CI details when in ci_failed state + if (newStatus !== "ci_failed") { + // CI is no longer failing — clear tracking so next failure is dispatched fresh + const lastFingerprint = session.metadata["lastCIFailureFingerprint"] ?? ""; + if (lastFingerprint) { + clearReactionTracker(session.id, ciReactionKey); + updateSessionMetadata(session, { + lastCIFailureFingerprint: "", + lastCIFailureDispatchHash: "", + lastCIFailureDispatchAt: "", + }); + } + return; + } + + // Fetch individual CI checks for failure details + let checks: CICheck[]; + try { + checks = await scm.getCIChecks(session.pr); + } catch { + // Failed to fetch checks — skip this cycle + return; + } + + const failedChecks = checks.filter( + (c) => c.status === "failed" || c.conclusion?.toUpperCase() === "FAILURE", + ); + if (failedChecks.length === 0) return; + + const ciFingerprint = makeFingerprint( + failedChecks.map((c) => `${c.name}:${c.status}:${c.conclusion ?? ""}`), + ); + const lastCIFingerprint = session.metadata["lastCIFailureFingerprint"] ?? ""; + const lastCIDispatchHash = session.metadata["lastCIFailureDispatchHash"] ?? ""; + + // Reset reaction tracker when failure set changes + if (ciFingerprint !== lastCIFingerprint && transitionReaction?.key !== ciReactionKey) { + clearReactionTracker(session.id, ciReactionKey); + } + if (ciFingerprint !== lastCIFingerprint) { + updateSessionMetadata(session, { + lastCIFailureFingerprint: ciFingerprint, + }); + } + + // If transition already sent a ci-failed reaction with the static message, + // skip this cycle but do NOT record dispatch hash — the next poll will send + // the detailed CI failure info with check names and URLs. + if ( + transitionReaction?.key === ciReactionKey && + transitionReaction.result?.success + ) { + return; + } + + // Skip if we already dispatched this exact failure set + if (ciFingerprint === lastCIDispatchHash) return; + + // Dispatch CI failure details directly via sessionManager.send() rather than + // executeReaction() to avoid consuming the ci-failed reaction's retry budget. + // The transition reaction owns escalation; this is a follow-up info delivery. + const reactionConfig = getReactionConfigForSession(session, ciReactionKey); + if ( + reactionConfig && + reactionConfig.action && + (reactionConfig.auto !== false || reactionConfig.action === "notify") + ) { + const detailedMessage = formatCIFailureMessage(failedChecks); + + try { + if (reactionConfig.action === "send-to-agent") { + await sessionManager.send(session.id, detailedMessage); + } else { + // For "notify" action, send to human notifiers instead + const event = createEvent("ci.failing", { + sessionId: session.id, + projectId: session.projectId, + message: detailedMessage, + data: { failedChecks: failedChecks.map((c) => c.name) }, + }); + await notifyHuman(event, reactionConfig.priority ?? "warning"); + } + + updateSessionMetadata(session, { + lastCIFailureDispatchHash: ciFingerprint, + lastCIFailureDispatchAt: new Date().toISOString(), + }); + } catch { + // Send failed — will retry on next poll cycle + } + } + } + + /** + * Dispatch merge conflict notifications to the agent session. + * Conflicts are detected from the PR enrichment cache or getMergeability() + * and dispatched independently of the session status (conflicts can coexist + * with ci_failed, changes_requested, etc.). + */ + async function maybeDispatchMergeConflicts( + session: Session, + newStatus: SessionStatus, + ): Promise { + const project = config.projects[session.projectId]; + if (!project || !session.pr) return; + + const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null; + if (!scm) return; + + const conflictReactionKey = "merge-conflicts"; + + // Clear tracking when PR is closed/merged + if (newStatus === "merged" || newStatus === "killed") { + clearReactionTracker(session.id, conflictReactionKey); + updateSessionMetadata(session, { + lastMergeConflictDispatched: "", + }); + return; + } + + // Only check for conflicts on open PRs + if ( + newStatus !== "pr_open" && + newStatus !== "ci_failed" && + newStatus !== "review_pending" && + newStatus !== "changes_requested" && + newStatus !== "approved" && + newStatus !== "mergeable" + ) { + return; + } + + // Check for conflicts using cached enrichment data or fallback to individual call + const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`; + const cachedData = prEnrichmentCache.get(prKey); + + let hasConflicts: boolean; + if (cachedData && cachedData.hasConflicts !== undefined) { + hasConflicts = cachedData.hasConflicts; + } else { + try { + const mergeReadiness = await scm.getMergeability(session.pr); + hasConflicts = !mergeReadiness.noConflicts; + } catch { + return; + } + } + + const lastDispatched = session.metadata["lastMergeConflictDispatched"] ?? ""; + + if (hasConflicts) { + // Already dispatched for current conflict state — skip + if (lastDispatched === "true") return; + + const reactionConfig = getReactionConfigForSession(session, conflictReactionKey); + if ( + reactionConfig && + reactionConfig.action && + (reactionConfig.auto !== false || reactionConfig.action === "notify") + ) { + try { + if (reactionConfig.action === "send-to-agent") { + const message = + reactionConfig.message ?? + "Your branch has merge conflicts. Rebase on the default branch and resolve them."; + await sessionManager.send(session.id, message); + } else { + const event = createEvent("merge.conflicts", { + sessionId: session.id, + projectId: session.projectId, + message: `${session.id}: PR has merge conflicts`, + }); + await notifyHuman(event, reactionConfig.priority ?? "warning"); + } + + updateSessionMetadata(session, { + lastMergeConflictDispatched: "true", + }); + } catch { + // Send failed — will retry on next poll cycle + } + } + } else if (lastDispatched === "true") { + // Conflicts resolved — clear so we can re-dispatch if they recur + clearReactionTracker(session.id, conflictReactionKey); + updateSessionMetadata(session, { + lastMergeConflictDispatched: "", + }); + } + } + /** Send a notification to all configured notifiers. */ async function notifyHuman(event: OrchestratorEvent, priority: EventPriority): Promise { const eventWithPriority = { ...event, priority }; @@ -912,7 +1156,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan }); // Reset allCompleteEmitted when any session becomes active again - if (newStatus !== "merged" && newStatus !== "killed") { + if (!TERMINAL_STATUSES.has(newStatus)) { allCompleteEmitted = false; } @@ -972,7 +1216,11 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan states.set(session.id, newStatus); } - await maybeDispatchReviewBacklog(session, oldStatus, newStatus, transitionReaction); + await Promise.allSettled([ + maybeDispatchReviewBacklog(session, oldStatus, newStatus, transitionReaction), + maybeDispatchCIFailureDetails(session, oldStatus, newStatus, transitionReaction), + maybeDispatchMergeConflicts(session, newStatus), + ]); } /** Run one polling cycle across all sessions. */ @@ -990,7 +1238,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan // (e.g., list() detected a dead runtime and marked it "killed" — we need to // process that transition even though the new status is terminal) const sessionsToCheck = sessions.filter((s) => { - if (s.status !== "merged" && s.status !== "killed") return true; + if (!TERMINAL_STATUSES.has(s.status)) return true; const tracked = states.get(s.id); return tracked !== undefined && tracked !== s.status; }); @@ -1018,7 +1266,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan } // Check if all sessions are complete (trigger reaction only once) - const activeSessions = sessions.filter((s) => s.status !== "merged" && s.status !== "killed"); + const activeSessions = sessions.filter((s) => !TERMINAL_STATUSES.has(s.status)); if (sessions.length > 0 && activeSessions.length === 0 && !allCompleteEmitted) { allCompleteEmitted = true; diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index d807d956d..e05e395ff 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -11,6 +11,7 @@ import { existsSync, readFileSync, statSync } from "node:fs"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import type { + ExternalPluginEntryRef, InstalledPluginConfig, PluginSlot, PluginManifest, @@ -60,30 +61,178 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> = { slot: "terminal", name: "web", pkg: "@composio/ao-plugin-terminal-web" }, ]; -/** Extract plugin-specific config from orchestrator config */ function extractPluginConfig( slot: PluginSlot, name: string, config: OrchestratorConfig, ): Record | undefined { - // Notifiers are configured under config.notifiers.. - // Match by key (e.g. "openclaw") or explicit plugin field. + // 1. Handle Notifier Slot if (slot === "notifier") { - for (const [notifierName, notifierConfig] of Object.entries(config.notifiers ?? {})) { + for (const [notifierId, notifierConfig] of Object.entries(config.notifiers ?? {})) { if (!notifierConfig || typeof notifierConfig !== "object") continue; const configuredPlugin = (notifierConfig as Record)["plugin"]; const hasExplicitPlugin = typeof configuredPlugin === "string" && configuredPlugin.length > 0; - const matches = hasExplicitPlugin ? configuredPlugin === name : notifierName === name; + const matches = hasExplicitPlugin ? configuredPlugin === name : notifierId === name; + if (matches) { - const { plugin: _plugin, ...rest } = notifierConfig as Record; - return config.configPath ? { ...rest, configPath: config.configPath } : rest; + return prepareConfig(slot, name, notifierId, notifierConfig, config.configPath); } } } + // 2. Handle Tracker and SCM Slots (Project-level) + // Tracker and SCM plugins are typically stateless singletons that receive + // project-specific config per-call (via ProjectConfig argument), not at create() time. + // This applies to BOTH built-in and external plugins to avoid order-dependent + // behavior when multiple projects share the same plugin but have different configs. + // Return undefined so plugins are initialized without project-specific config. + if (slot === "tracker" || slot === "scm") { + return undefined; + } + return undefined; } +/** + * Internal helper to validate and strip loading metadata from a plugin configuration. + * Reserved fields (plugin, package, path) are used for plugin resolution and stripped. + */ +function prepareConfig( + slot: string, + name: string, + sourceId: string, + rawConfig: Record, + configPath?: string, +): Record { + // Explicitly check for reserved fields to prevent silent stripping/collision. + // 'path' is reserved for local resolution; 'package' is reserved for npm resolution. + if ("package" in rawConfig && "path" in rawConfig) { + throw new Error( + `In ${slot} "${sourceId}": both "package" and "path" are specified. ` + + `Use "package" for npm plugins or "path" for local plugins, not both.`, + ); + } + + // If loading via built-in name or npm package, having a 'path' field is ambiguous: + // it could be a local plugin path (for loading) or a plugin config value. + // We reject this to avoid silently stripping a config value the user intended to pass. + const isBuiltin = BUILTIN_PLUGINS.some((b) => b.slot === slot && b.name === name); + if ((rawConfig.package || isBuiltin) && "path" in rawConfig) { + const loadingMethod = rawConfig.package ? `npm package "${rawConfig.package}"` : `built-in plugin "${name}"`; + throw new Error( + `In ${slot} "${sourceId}": "path" field conflicts with reserved plugin loading field. ` + + `You're loading via ${loadingMethod}, but also have a "path" field which would be stripped. ` + + `Rename your configuration field to something else (e.g., "apiPath", "webhookPath").`, + ); + } + + // Strip loading metadata fields (plugin, package, path) from config passed to plugin. + const { plugin: _plugin, package: _package, path: _path, ...rest } = rawConfig; + return configPath ? { ...rest, configPath } : rest; +} + +/** + * Build an index of external plugin entries by package/path for O(1) lookups. + * Multiple entries can share the same package/path (e.g., multiple projects using same plugin). + */ +function buildExternalPluginIndex( + externalEntries: ExternalPluginEntryRef[] | undefined, +): Map { + const index = new Map(); + if (!externalEntries) return index; + + for (const entry of externalEntries) { + const key = entry.package ? `package:${entry.package}` : `path:${entry.path}`; + const existing = index.get(key); + if (existing) { + existing.push(entry); + } else { + index.set(key, [entry]); + } + } + + return index; +} + +/** + * Find ALL external plugin entries that match a given plugin config. + * Used for manifest.name validation when loading inline tracker/scm/notifier plugins. + * + * Returns all matching entries because multiple projects may share the same + * external plugin (same package/path), and all their configs need to be updated + * with the actual manifest.name. + */ +function findAllExternalPluginEntries( + plugin: InstalledPluginConfig, + externalIndex: Map, +): ExternalPluginEntryRef[] { + if (plugin.package) { + return externalIndex.get(`package:${plugin.package}`) ?? []; + } + if (plugin.path) { + return externalIndex.get(`path:${plugin.path}`) ?? []; + } + return []; +} + +/** + * Validate that a plugin's manifest.name matches the expected name (if specified). + * Throws an error if there's a mismatch. + */ +function validateManifestName( + manifest: PluginManifest, + entry: ExternalPluginEntryRef, + specifier: string, +): void { + // If the user specified an explicit plugin name, validate it matches the manifest + if (entry.expectedPluginName && entry.expectedPluginName !== manifest.name) { + const specifierType = entry.package ? "package" : "path"; + throw new Error( + `Plugin manifest.name mismatch at ${entry.source}: ` + + `expected "${entry.expectedPluginName}" but ${specifierType} "${specifier}" has manifest.name "${manifest.name}". ` + + `Either update the 'plugin' field to match the actual manifest.name, or remove it to auto-infer.`, + ); + } +} + +/** + * Update the config with the actual plugin name after loading an external plugin. + * This ensures resolvePlugins() can look up the plugin by its manifest.name. + * + * Uses structured location data to avoid ambiguity from parsing dotted strings + * (project/notifier keys can legally contain dots). + */ +function updateConfigWithManifestName( + manifest: PluginManifest, + entry: ExternalPluginEntryRef, + config: OrchestratorConfig, +): void { + const { location, slot, source } = entry; + + // Use structured location to update the config + if (location.kind === "project") { + const { projectId, configType } = location; + const project = config.projects[projectId]; + if (project?.[configType]) { + project[configType]!.plugin = manifest.name; + } + } else if (location.kind === "notifier") { + const { notifierId } = location; + const notifierConfig = config.notifiers[notifierId]; + if (notifierConfig) { + notifierConfig.plugin = manifest.name; + } + } + + // Also validate slot matches + if (manifest.slot !== slot) { + process.stderr.write( + `[plugin-registry] Plugin at ${source} has slot "${manifest.slot}" but was configured as "${slot}". ` + + `The plugin will be registered under its declared slot "${manifest.slot}".\n`, + ); + } +} + export function isPluginModule(value: unknown): value is PluginModule { if (!value || typeof value !== "object") return false; const candidate = value as Partial; @@ -239,16 +388,25 @@ export function createPluginRegistry(): PluginRegistry { ): Promise { const doImport = importFn ?? ((pkg: string) => import(pkg)); for (const builtin of BUILTIN_PLUGINS) { + let mod; try { - const mod = normalizeImportedPluginModule(await doImport(builtin.pkg)); - if (mod) { + mod = normalizeImportedPluginModule(await doImport(builtin.pkg)); + } catch { + // Plugin not installed — that's fine, only load what's available + continue; + } + + if (mod) { + try { const pluginConfig = orchestratorConfig ? extractPluginConfig(builtin.slot, builtin.name, orchestratorConfig) : undefined; this.register(mod, pluginConfig); + } catch (error) { + process.stderr.write( + `[plugin-registry] Failed to load built-in plugin "${builtin.name}": ${error}\n`, + ); } - } catch { - // Plugin not installed — that's fine, only load what's available } } }, @@ -261,13 +419,15 @@ export function createPluginRegistry(): PluginRegistry { await this.loadBuiltins(config, importFn); const doImport = importFn ?? ((pkg: string) => import(pkg)); + // Build index once for O(1) lookups when matching plugins to external entries + const externalIndex = buildExternalPluginIndex(config._externalPluginEntries); for (const plugin of config.plugins ?? []) { if (plugin.enabled === false) continue; const specifier = resolvePluginSpecifier(plugin, config); if (!specifier) { - console.warn(`[plugin-registry] Could not resolve specifier for plugin "${plugin.name}" (source: ${plugin.source})`); + process.stderr.write(`[plugin-registry] Could not resolve specifier for plugin "${plugin.name}" (source: ${plugin.source})\n`); continue; } @@ -275,10 +435,34 @@ export function createPluginRegistry(): PluginRegistry { const mod = normalizeImportedPluginModule(await doImport(specifier)); if (!mod) continue; + // Check if this plugin was auto-added from inline tracker/scm/notifier config. + // Multiple projects may share the same external plugin, so find ALL matching entries. + // We validate and update configs FIRST, before extracting plugin config, because + // extractPluginConfig looks up by manifest.name which may differ from the temp name. + const matchingEntries = findAllExternalPluginEntries(plugin, externalIndex); + for (const externalEntry of matchingEntries) { + try { + // Validate manifest.name matches expectedPluginName (if specified) + validateManifestName(mod.manifest, externalEntry, specifier); + // Update the config with the actual manifest.name + updateConfigWithManifestName(mod.manifest, externalEntry, config); + } catch (validationError) { + // Log validation errors but don't abort - other projects can still use the plugin. + // The misconfigured project will fail later when it tries to use the plugin + // with the wrong name, giving a clearer error at point of use. + process.stderr.write( + `[plugin-registry] Config validation failed for ${externalEntry.source}: ${validationError}\n`, + ); + } + } + + // Extract plugin config AFTER updating configs with manifest.name. + // This ensures extractPluginConfig can find the config by manifest.name + // (e.g., manifest "ms-teams" after config was updated from temp "teams"). const pluginConfig = extractPluginConfig(mod.manifest.slot, mod.manifest.name, config); this.register(mod, pluginConfig); } catch (error) { - console.warn(`[plugin-registry] Failed to load plugin "${specifier}":`, error); + process.stderr.write(`[plugin-registry] Failed to load plugin "${specifier}": ${error}\n`); } } }, diff --git a/packages/core/src/recovery/validator.ts b/packages/core/src/recovery/validator.ts index 3a896e06f..410e0afbe 100644 --- a/packages/core/src/recovery/validator.ts +++ b/packages/core/src/recovery/validator.ts @@ -31,7 +31,7 @@ export async function validateSession( const runtimeName = project.runtime ?? config.defaults.runtime; const agentName = resolveAgentSelection({ - role: resolveSessionRole(sessionId, rawMetadata), + role: resolveSessionRole(sessionId, rawMetadata, project.sessionPrefix), project, defaults: config.defaults, persistedAgent: rawMetadata["agent"], diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index dcf127d2a..566fdeada 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -11,7 +11,7 @@ * Reference: scripts/claude-ao-session, scripts/send-to-session */ -import { statSync, existsSync, readdirSync, writeFileSync, mkdirSync, utimesSync } from "node:fs"; +import { statSync, existsSync, readdirSync, writeFileSync, mkdirSync, utimesSync, unlinkSync } from "node:fs"; import { execFile } from "node:child_process"; import { basename, join, resolve } from "node:path"; import { homedir } from "node:os"; @@ -41,7 +41,6 @@ import { type PluginRegistry, type RuntimeHandle, type Issue, - isOrchestratorSession, PR_STATE, } from "./types.js"; import { @@ -60,7 +59,6 @@ import { getWorktreesDir, getProjectBaseDir, generateTmuxName, - generateConfigHash, validateAndStoreOrigin, } from "./paths.js"; import { asValidOpenCodeSessionId } from "./opencode-session-id.js"; @@ -292,19 +290,40 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM sourceSessionId: string; } | null { const sessionsDir = getProjectSessionsDir(project); - const orchestratorId = `${project.sessionPrefix}-orchestrator`; - const orchestratorRaw = readMetadataRaw(sessionsDir, orchestratorId); - if (!orchestratorRaw) return null; - const until = parsePauseUntil(orchestratorRaw[GLOBAL_PAUSE_UNTIL_KEY]); - if (!until) return null; - if (until.getTime() <= Date.now()) return null; + // Build the candidate list using name-pattern matching so we never read + // raw metadata for worker sessions (avoids N file reads on every hot path). + // Do not pre-seed the canonical ID — with the numbered orchestrator scheme + // ({prefix}-orchestrator-N) it will rarely exist, and pre-seeding it would + // cause an unconditional extra readMetadataRaw on every hot-path invocation. + const canonicalOrchestratorId = `${project.sessionPrefix}-orchestrator`; + const orchestratorPattern = new RegExp(`^${escapeRegex(project.sessionPrefix)}-orchestrator-(\\d+)$`); + const candidateIds = new Set(); + for (const id of listMetadata(sessionsDir)) { + if (id === canonicalOrchestratorId || orchestratorPattern.test(id)) { + candidateIds.add(id); + } + } - return { - until, - reason: orchestratorRaw[GLOBAL_PAUSE_REASON_KEY] ?? "Model rate limit reached", - sourceSessionId: orchestratorRaw[GLOBAL_PAUSE_SOURCE_KEY] ?? "unknown", - }; + let best: { until: Date; reason: string; sourceSessionId: string } | null = null; + for (const sessionId of candidateIds) { + const raw = readMetadataRaw(sessionsDir, sessionId); + if (!raw) continue; + if (!isOrchestratorSessionRecord(sessionId, raw, project.sessionPrefix)) continue; + + const until = parsePauseUntil(raw[GLOBAL_PAUSE_UNTIL_KEY]); + if (!until || until.getTime() <= Date.now()) continue; + + if (!best || until.getTime() > best.until.getTime()) { + best = { + until, + reason: raw[GLOBAL_PAUSE_REASON_KEY] ?? "Model rate limit reached", + sourceSessionId: raw[GLOBAL_PAUSE_SOURCE_KEY] ?? "unknown", + }; + } + } + + return best; } function normalizePath(path: string): string { @@ -358,9 +377,17 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM function isOrchestratorSessionRecord( sessionId: string, raw: Record | null | undefined, + sessionPrefix?: string, ): boolean { if (!raw) return false; - return raw["role"] === "orchestrator" || sessionId.endsWith("-orchestrator"); + if (raw["role"] === "orchestrator" || sessionId.endsWith("-orchestrator")) return true; + // Check the -orchestrator-N pattern only when the prefix is known so the + // regex is anchored to the project prefix, preventing false-positives when + // the user-configured sessionPrefix itself ends with "-orchestrator". + if (sessionPrefix) { + return new RegExp(`^${escapeRegex(sessionPrefix)}-orchestrator-\\d+$`).test(sessionId); + } + return false; } function isCleanupProtectedSession( @@ -368,11 +395,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM sessionId: string, metadata?: Record | null, ): boolean { - const canonicalOrchestratorId = `${project.sessionPrefix}-orchestrator`; - return ( - sessionId === canonicalOrchestratorId || - isOrchestratorSession({ id: sessionId, metadata: metadata ?? undefined }) - ); + return isOrchestratorSessionRecord(sessionId, metadata ?? {}, project.sessionPrefix); } function applyMetadataUpdatesToRaw( @@ -422,9 +445,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM function repairSingleSessionMetadataOnRead( sessionsDir: string, record: ActiveSessionRecord, + sessionPrefix?: string, ): ActiveSessionRecord { const repaired = { ...record, raw: { ...record.raw } }; - if (!isOrchestratorSessionRecord(repaired.sessionName, repaired.raw)) { + if (!isOrchestratorSessionRecord(repaired.sessionName, repaired.raw, sessionPrefix)) { return repaired; } @@ -464,13 +488,14 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM function repairSessionMetadataOnRead( sessionsDir: string, records: ActiveSessionRecord[], + sessionPrefix?: string, ): ActiveSessionRecord[] { const repaired = records.map((record) => ({ ...record, raw: { ...record.raw } })); const duplicatePRAttachments = new Map(); for (const record of repaired) { - if (isOrchestratorSessionRecord(record.sessionName, record.raw)) { - record.raw = repairSingleSessionMetadataOnRead(sessionsDir, record).raw; + if (isOrchestratorSessionRecord(record.sessionName, record.raw, sessionPrefix)) { + record.raw = repairSingleSessionMetadataOnRead(sessionsDir, record, sessionPrefix).raw; continue; } @@ -531,7 +556,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM return [{ sessionName, raw, modifiedAt } satisfies ActiveSessionRecord]; }); - return repairSessionMetadataOnRead(sessionsDir, records); + return repairSessionMetadataOnRead(sessionsDir, records, project.sessionPrefix); } function markArchivedOpenCodeCleanup(sessionsDir: string, sessionId: SessionId): void { @@ -702,6 +727,66 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM ); } + /** + * Reserve a unique orchestrator identity ({prefix}-orchestrator-N) for a worktree-based orchestrator. + * Unlike worker sessions, orchestrator IDs are assigned locally without remote branch checks. + */ + function reserveNextOrchestratorIdentity( + project: ProjectConfig, + sessionsDir: string, + ): { num: number; sessionId: string; tmuxName: string | undefined } { + const orchestratorPrefix = `${project.sessionPrefix}-orchestrator`; + const usedNumbers = new Set(); + + const orchestratorPattern = new RegExp(`^${escapeRegex(orchestratorPrefix)}-(\\d+)$`); + for (const sessionName of [ + ...listMetadata(sessionsDir), + ...listArchivedSessionIds(sessionsDir), + ]) { + const match = sessionName.match(orchestratorPattern); + if (match) { + const parsed = Number.parseInt(match[1], 10); + if (!Number.isNaN(parsed)) usedNumbers.add(parsed); + } + } + + // Build worker-ID patterns for all other projects. If another project has + // sessionPrefix === orchestratorPrefix (e.g. project B has prefix "app-orchestrator"), + // then its workers are named "app-orchestrator-1", "app-orchestrator-2", etc. — which + // would collide with our orchestrator IDs. Detect this impossible configuration early. + for (const [otherProjectId, otherProject] of Object.entries(config.projects)) { + const otherPrefix = otherProject.sessionPrefix ?? otherProjectId; + if (otherPrefix === project.sessionPrefix) continue; + if (otherPrefix === orchestratorPrefix) { + // Another project's workers are "{otherPrefix}-\d+" which equals "{orchestratorPrefix}-\d+". + // Every candidate ID we would generate collides — fail immediately with a clear message. + throw new Error( + `Cannot spawn orchestrator for project "${project.sessionPrefix}": the orchestrator ID prefix "${orchestratorPrefix}" ` + + `conflicts with the session prefix of project "${otherProjectId}" ("${otherPrefix}"). ` + + `Rename one of the project sessionPrefix values to avoid this overlap.`, + ); + } + } + + let num = 1; + for (let attempts = 0; attempts < 10_000; attempts++) { + if (!usedNumbers.has(num)) { + const sessionId = `${orchestratorPrefix}-${num}`; + const tmuxName = config.configPath + ? generateTmuxName(config.configPath, orchestratorPrefix, num) + : undefined; + if (reserveSessionId(sessionsDir, sessionId)) { + return { num, sessionId, tmuxName }; + } + } + num += 1; + } + + throw new Error( + `Failed to reserve orchestrator session ID after 10000 attempts (prefix: ${orchestratorPrefix})`, + ); + } + /** Resolve which plugins to use for a project. */ function resolvePlugins(project: ProjectConfig, agentName?: string) { const runtime = registry.get("runtime", project.runtime ?? config.defaults.runtime); @@ -710,10 +795,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM "workspace", project.workspace ?? config.defaults.workspace, ); - const tracker = project.tracker - ? registry.get("tracker", project.tracker.plugin) - : null; - const scm = project.scm ? registry.get("scm", project.scm.plugin) : null; + // After config validation, plugin is always set if tracker/scm exists + // (either from user config or auto-generated from package/path) + const tracker = + project.tracker?.plugin ? registry.get("tracker", project.tracker.plugin) : null; + const scm = project.scm?.plugin ? registry.get("scm", project.scm.plugin) : null; return { runtime, agent, workspace, tracker, scm }; } @@ -724,7 +810,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM metadata: Record, ) { return resolveAgentSelection({ - role: resolveSessionRole(sessionId, metadata), + role: resolveSessionRole(sessionId, metadata, project.sessionPrefix), project, defaults: config.defaults, persistedAgent: metadata["agent"], @@ -765,11 +851,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM modifiedAt = undefined; } - const repaired = repairSingleSessionMetadataOnRead(sessionsDir, { - sessionName: sessionId, - raw, - modifiedAt, - }); + const repaired = repairSingleSessionMetadataOnRead( + sessionsDir, + { sessionName: sessionId, raw, modifiedAt }, + project.sessionPrefix, + ); return { raw: repaired.raw, sessionsDir, project, projectId }; } @@ -1073,7 +1159,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM AO_CALLER_TYPE: "agent", AO_PROJECT_ID: spawnConfig.projectId, AO_CONFIG_PATH: config.configPath, - ...(config.port !== undefined && config.port !== null && { AO_PORT: String(config.port) }), + ...(config.port !== undefined && + config.port !== null && { AO_PORT: String(config.port) }), }, }); } catch (err) { @@ -1179,15 +1266,42 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // exits after -p, so we send the prompt after it starts in interactive mode). // This is intentionally outside the try/catch above — a prompt delivery failure // should NOT destroy the session. The agent is running; user can retry with `ao send`. + let promptDelivered = false; if (plugins.agent.promptDelivery === "post-launch" && agentLaunchConfig.prompt) { - try { - // Wait for agent to start and be ready for input - await new Promise((resolve) => setTimeout(resolve, 5_000)); - await plugins.runtime.sendMessage(handle, agentLaunchConfig.prompt); - } catch { - // Non-fatal: agent is running but didn't receive the initial prompt. - // User can retry with `ao send`. + const maxRetries = 3; + const baseDelayMs = 3_000; + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + // Wait for agent to start and be ready for input + // Use exponential backoff: 3s, 6s, 9s between attempts + await new Promise((resolve) => setTimeout(resolve, baseDelayMs * attempt)); + await plugins.runtime.sendMessage(handle, agentLaunchConfig.prompt); + promptDelivered = true; + break; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + console.error( + `[session-manager] Prompt delivery attempt ${attempt}/${maxRetries} failed: ${lastError.message}`, + ); + } } + + if (!promptDelivered) { + console.error( + `[session-manager] FAILED to deliver prompt to session ${sessionId} after ${maxRetries} attempts. ` + + `User must send manually with 'ao send'. Last error: ${lastError?.message}`, + ); + } + + session.metadata["promptDelivered"] = String(promptDelivered); + } else if (agentLaunchConfig.prompt) { + session.metadata["promptDelivered"] = "true"; + } + + if (session.metadata["promptDelivered"]) { + updateMetadata(sessionsDir, sessionId, session.metadata); } return session; @@ -1219,29 +1333,88 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM throw new Error(`Agent plugin '${selection.agentName}' not found`); } - const sessionId = `${project.sessionPrefix}-orchestrator`; const orchestratorSessionStrategy = normalizeOrchestratorSessionStrategy( project.orchestratorSessionStrategy, ); - // Generate tmux name if using new architecture - let tmuxName: string | undefined; - if (config.configPath) { - const hash = generateConfigHash(config.configPath); - tmuxName = `${hash}-${sessionId}`; - } - // Get the sessions directory for this project const sessionsDir = getProjectSessionsDir(project); - // Validate and store .origin file + // Validate and store .origin file before reserving any identity so that + // a validation failure does not leave an orphaned metadata entry. if (config.configPath) { validateAndStoreOrigin(config.configPath, project.path); } + // Reserve a new unique orchestrator identity (e.g. {prefix}-orchestrator-1, -2, …). + // Each spawnOrchestrator call gets its own numbered session and isolated worktree. + const identity = reserveNextOrchestratorIdentity(project, sessionsDir); + const sessionId = identity.sessionId; + const tmuxName = identity.tmuxName; + + // Each orchestrator gets an isolated worktree on its own branch. + const branch = `orchestrator/${sessionId}`; + + if (!plugins.workspace) { + try { + deleteMetadata(sessionsDir, sessionId, false); + } catch { + /* best effort */ + } + throw new Error( + `spawnOrchestrator requires a workspace plugin but none is configured for project '${orchestratorConfig.projectId}'`, + ); + } + + let workspacePath: string; + try { + const wsInfo = await plugins.workspace.create({ + projectId: orchestratorConfig.projectId, + project, + sessionId, + branch, + }); + workspacePath = wsInfo.path; + } catch (err) { + try { + deleteMetadata(sessionsDir, sessionId, false); + } catch { + /* best effort */ + } + throw err; + } + + // Helper: undo worktree + metadata if anything between workspace creation + // and a fully-written metadata record fails. + const cleanupWorktreeAndMetadata = async (promptFile?: string): Promise => { + try { + // plugins.workspace is guaranteed non-null here: we threw above if it was null + await plugins.workspace!.destroy(workspacePath); + } catch { + /* best effort */ + } + try { + deleteMetadata(sessionsDir, sessionId, false); + } catch { + /* best effort */ + } + if (promptFile) { + try { + unlinkSync(promptFile); + } catch { + /* best effort */ + } + } + }; + // Setup agent hooks for automatic metadata updates - if (plugins.agent.setupWorkspaceHooks) { - await plugins.agent.setupWorkspaceHooks(project.path, { dataDir: sessionsDir }); + try { + if (plugins.agent.setupWorkspaceHooks) { + await plugins.agent.setupWorkspaceHooks(workspacePath, { dataDir: sessionsDir }); + } + } catch (err) { + await cleanupWorktreeAndMetadata(); + throw err; } // Write system prompt to a file to avoid shell/tmux truncation. @@ -1249,93 +1422,38 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // via tmux send-keys or paste-buffer. File-based approach is reliable. let systemPromptFile: string | undefined; if (orchestratorConfig.systemPrompt) { - const baseDir = getProjectBaseDir(config.configPath, project.path); - mkdirSync(baseDir, { recursive: true }); - systemPromptFile = join(baseDir, "orchestrator-prompt.md"); - writeFileSync(systemPromptFile, orchestratorConfig.systemPrompt, "utf-8"); - } - - const existingRaw = readMetadataRaw(sessionsDir, sessionId); - const existingOrchestrator = existingRaw?.["runtimeHandle"] - ? metadataToSession(sessionId, existingRaw, orchestratorConfig.projectId) - : null; - if (existingOrchestrator?.runtimeHandle) { - const existingAlive = await plugins.runtime - .isAlive(existingOrchestrator.runtimeHandle) - .catch(() => false); - if (existingAlive && orchestratorSessionStrategy === "reuse") { - const persistedRaw = readMetadataRaw(sessionsDir, sessionId); - if (persistedRaw?.["runtimeHandle"]) { - const persisted = metadataToSession( - sessionId, - persistedRaw, - orchestratorConfig.projectId, - ); - persisted.metadata["orchestratorSessionReused"] = "true"; - return persisted; - } - await plugins.runtime.destroy(existingOrchestrator.runtimeHandle).catch(() => undefined); - deleteMetadata(sessionsDir, sessionId, false); - } - if (existingAlive && orchestratorSessionStrategy !== "reuse") { - await plugins.runtime.destroy(existingOrchestrator.runtimeHandle).catch(() => undefined); - // Destroy runtime and delete metadata without archive for ignore strategy - deleteMetadata(sessionsDir, sessionId, false); - } - // For dead runtime, delete metadata so reserveSessionId can succeed: - // - With reuse strategy + opencode: archive to preserve opencodeSessionId for reuse lookup - // - With non-reuse strategy: delete without archive to respawn fresh - if (!existingAlive) { - deleteMetadata(sessionsDir, sessionId, orchestratorSessionStrategy === "reuse"); + try { + const baseDir = getProjectBaseDir(config.configPath, project.path); + mkdirSync(baseDir, { recursive: true }); + systemPromptFile = join(baseDir, `orchestrator-prompt-${sessionId}.md`); + writeFileSync(systemPromptFile, orchestratorConfig.systemPrompt, "utf-8"); + } catch (err) { + await cleanupWorktreeAndMetadata(systemPromptFile); + throw err; } } - // Atomically reserve the session ID before creating any resources. - // This prevents race conditions where concurrent spawnOrchestrator calls - // both see no existing session and proceed to create duplicate runtimes. - let reserved = reserveSessionId(sessionsDir, sessionId); - if (!reserved) { - // Reservation failed - another process reserved it first. - // Check if the session now exists and is alive. - const concurrentRaw = readMetadataRaw(sessionsDir, sessionId); - const concurrentSession = concurrentRaw?.["runtimeHandle"] - ? metadataToSession(sessionId, concurrentRaw, orchestratorConfig.projectId) - : null; - if (concurrentSession?.runtimeHandle) { - const concurrentAlive = await plugins.runtime - .isAlive(concurrentSession.runtimeHandle) - .catch(() => false); - if (concurrentAlive && orchestratorSessionStrategy === "reuse") { - concurrentSession.metadata["orchestratorSessionReused"] = "true"; - return concurrentSession; - } - if (!concurrentAlive) { - deleteMetadata(sessionsDir, sessionId, orchestratorSessionStrategy === "reuse"); - reserved = reserveSessionId(sessionsDir, sessionId); - } - } else { - reserved = reserveSessionId(sessionsDir, sessionId); + let reusableOpenCodeSessionId: string | undefined; + try { + reusableOpenCodeSessionId = + plugins.agent.name === "opencode" && orchestratorSessionStrategy === "reuse" + ? await resolveOpenCodeSessionReuse({ + sessionsDir, + criteria: { sessionId }, + strategy: "reuse", + }) + : undefined; + if (plugins.agent.name === "opencode" && orchestratorSessionStrategy === "delete") { + await resolveOpenCodeSessionReuse({ + sessionsDir, + criteria: { sessionId }, + strategy: "delete", + includeTitleDiscoveryForSessionId: true, + }); } - if (!reserved) { - throw new Error(`Session ${sessionId} already exists but is not in a reusable state`); - } - } - - const reusableOpenCodeSessionId = - plugins.agent.name === "opencode" && orchestratorSessionStrategy === "reuse" - ? await resolveOpenCodeSessionReuse({ - sessionsDir, - criteria: { sessionId }, - strategy: "reuse", - }) - : undefined; - if (plugins.agent.name === "opencode" && orchestratorSessionStrategy === "delete") { - await resolveOpenCodeSessionReuse({ - sessionsDir, - criteria: { sessionId }, - strategy: "delete", - includeTitleDiscoveryForSessionId: true, - }); + } catch (err) { + await cleanupWorktreeAndMetadata(systemPromptFile); + throw err; } // Get agent launch config — uses systemPromptFile, no issue/tracker interaction. @@ -1359,22 +1477,29 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM const launchCommand = plugins.agent.getLaunchCommand(agentLaunchConfig); const environment = plugins.agent.getEnvironment(agentLaunchConfig); - const handle = await plugins.runtime.create({ - sessionId: tmuxName ?? sessionId, - workspacePath: project.path, - launchCommand, - environment: { - ...environment, - AO_SESSION: sessionId, - AO_DATA_DIR: sessionsDir, - AO_SESSION_NAME: sessionId, - ...(tmuxName && { AO_TMUX_NAME: tmuxName }), - AO_CALLER_TYPE: "orchestrator", - AO_PROJECT_ID: orchestratorConfig.projectId, - AO_CONFIG_PATH: config.configPath, - ...(config.port !== undefined && config.port !== null && { AO_PORT: String(config.port) }) - }, - }); + // Create runtime — clean up worktree and metadata on failure + let handle: RuntimeHandle; + try { + handle = await plugins.runtime.create({ + sessionId: tmuxName ?? sessionId, + workspacePath, + launchCommand, + environment: { + ...environment, + AO_SESSION: sessionId, + AO_DATA_DIR: sessionsDir, + AO_SESSION_NAME: sessionId, + ...(tmuxName && { AO_TMUX_NAME: tmuxName }), + AO_CALLER_TYPE: "orchestrator", + AO_PROJECT_ID: orchestratorConfig.projectId, + AO_CONFIG_PATH: config.configPath, + ...(config.port !== undefined && config.port !== null && { AO_PORT: String(config.port) }), + }, + }); + } catch (err) { + await cleanupWorktreeAndMetadata(systemPromptFile); + throw err; + } // Write metadata and run post-launch setup const session: Session = { @@ -1382,10 +1507,10 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM projectId: orchestratorConfig.projectId, status: "working", activity: "active", - branch: project.defaultBranch, + branch, issueId: null, pr: null, - workspacePath: project.path, + workspacePath, runtimeHandle: handle, agentInfo: null, createdAt: new Date(), @@ -1397,8 +1522,8 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM try { writeMetadata(sessionsDir, sessionId, { - worktree: project.path, - branch: project.defaultBranch, + worktree: workspacePath, + branch, status: "working", role: "orchestrator", tmuxName, @@ -1437,11 +1562,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM } catch { /* best effort */ } - try { - deleteMetadata(sessionsDir, sessionId, false); - } catch { - /* best effort */ - } + await cleanupWorktreeAndMetadata(systemPromptFile); throw err; } @@ -1532,11 +1653,11 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM // If stat fails, timestamps will fall back to current time } - const repaired = repairSingleSessionMetadataOnRead(sessionsDir, { - sessionName: sessionId, - raw, - modifiedAt, - }); + const repaired = repairSingleSessionMetadataOnRead( + sessionsDir, + { sessionName: sessionId, raw, modifiedAt }, + project.sessionPrefix, + ); const session = metadataToSession(sessionId, repaired.raw, projectId, createdAt, modifiedAt); @@ -1787,8 +1908,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM async function send(sessionId: SessionId, message: string): Promise { const { raw, sessionsDir, project } = requireSessionRecord(sessionId); const pause = getProjectPause(project); - const orchestratorId = `${project.sessionPrefix}-orchestrator`; - if (pause && sessionId !== orchestratorId) { + if (pause && !isOrchestratorSessionRecord(sessionId, raw, project.sessionPrefix)) { throw new Error( `Project is paused due to model rate limit until ${pause.until.toISOString()} (${pause.reason}; source: ${pause.sourceSessionId})`, ); @@ -2080,7 +2200,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM if (!reference) throw new Error("PR reference is required"); const { raw, sessionsDir, project, projectId } = requireSessionRecord(sessionId); - if (isOrchestratorSessionRecord(sessionId, raw)) { + if (isOrchestratorSessionRecord(sessionId, raw, project.sessionPrefix)) { throw new Error(`Session ${sessionId} is an orchestrator session and cannot claim PRs`); } @@ -2107,7 +2227,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM ); for (const { sessionName, raw: otherRaw } of activeRecords) { - if (!otherRaw || isOrchestratorSessionRecord(sessionName, otherRaw)) continue; + if (!otherRaw || isOrchestratorSessionRecord(sessionName, otherRaw, project.sessionPrefix)) continue; const samePr = otherRaw["pr"] === pr.url; const sameBranch = diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a5c5f804a..1d6d0eee0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -189,11 +189,37 @@ export interface Session { metadata: Record; } -export function isOrchestratorSession(session: { - id: SessionId; - metadata?: Record; -}): boolean { - return session.metadata?.["role"] === "orchestrator" || session.id.endsWith("-orchestrator"); +export function isOrchestratorSession( + session: { id: SessionId; metadata?: Record }, + sessionPrefix?: string, + allSessionPrefixes?: string[], +): boolean { + if (session.metadata?.["role"] === "orchestrator" || session.id.endsWith("-orchestrator")) { + return true; + } + if (!sessionPrefix) { + return false; + } + const escaped = sessionPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + if (!new RegExp(`^${escaped}-orchestrator-\\d+$`).test(session.id)) { + return false; + } + // Guard against cross-project false positives: if the session ID is a plain + // numbered worker for any other known prefix (e.g. prefix "app-orchestrator" + // matches "app-orchestrator-1" as a worker), it is not an orchestrator. + if (allSessionPrefixes) { + for (const prefix of allSessionPrefixes) { + if (prefix === sessionPrefix) continue; + if ( + new RegExp( + `^${prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\d+$`, + ).test(session.id) + ) { + return false; + } + } + } + return true; } /** Config for creating a new session */ @@ -1013,6 +1039,43 @@ export interface OrchestratorConfig { /** Default reaction configs */ reactions: Record; + + /** + * Internal: External plugin entries collected from inline tracker/scm/notifier configs. + * Used by plugin-registry for manifest validation. Set automatically during config validation. + */ + _externalPluginEntries?: ExternalPluginEntryRef[]; +} + +/** + * Structured location of an external plugin config. + * Used to update config with manifest.name after loading (avoids parsing dotted strings). + */ +export type ExternalPluginLocation = + | { kind: "project"; projectId: string; configType: "tracker" | "scm" } + | { kind: "notifier"; notifierId: string }; + +/** + * Reference to an external plugin config (from inline tracker/scm/notifier configs). + * Used for manifest.name validation during plugin loading. + */ +export interface ExternalPluginEntryRef { + /** Where this config came from (for error messages) */ + source: string; + /** Structured location for updating config (avoids parsing source string) */ + location: ExternalPluginLocation; + /** The slot this plugin fills */ + slot: "tracker" | "scm" | "notifier"; + /** npm package name (if specified) */ + package?: string; + /** Local path (if specified) */ + path?: string; + /** + * Expected plugin name (manifest.name). + * Only set when user explicitly specified `plugin` field. + * When undefined, any manifest.name is accepted and config is updated with it. + */ + expectedPluginName?: string; } export interface DefaultPlugins { @@ -1135,13 +1198,41 @@ export interface ProjectConfig { } export interface TrackerConfig { - plugin: string; + /** + * Plugin name (manifest.name). Required when using built-in plugins. + * Optional when `package` or `path` is specified (will be inferred from manifest). + * When both plugin and package/path are specified, manifest.name must match plugin. + * + * POST-VALIDATION INVARIANT: After validateConfig(), this field is ALWAYS populated. + * Either from user input, inferred from repo (github/gitlab), or auto-generated from + * package/path via generateTempPluginName(). The optional typing exists for raw config + * input before validation. Downstream code can safely assume non-null after validation. + */ + plugin?: string; + /** npm package name for external plugins (e.g. "@acme/ao-plugin-tracker-jira") */ + package?: string; + /** Local filesystem path for external plugins (relative to config file or absolute) */ + path?: string; /** Plugin-specific config (e.g. teamId for Linear) */ [key: string]: unknown; } export interface SCMConfig { - plugin: string; + /** + * Plugin name (manifest.name). Required when using built-in plugins. + * Optional when `package` or `path` is specified (will be inferred from manifest). + * When both plugin and package/path are specified, manifest.name must match plugin. + * + * POST-VALIDATION INVARIANT: After validateConfig(), this field is ALWAYS populated. + * Either from user input, inferred from repo (github/gitlab), or auto-generated from + * package/path via generateTempPluginName(). The optional typing exists for raw config + * input before validation. Downstream code can safely assume non-null after validation. + */ + plugin?: string; + /** npm package name for external plugins (e.g. "@acme/ao-plugin-scm-bitbucket") */ + package?: string; + /** Local filesystem path for external plugins (relative to config file or absolute) */ + path?: string; webhook?: SCMWebhookConfig; [key: string]: unknown; } @@ -1157,7 +1248,21 @@ export interface SCMWebhookConfig { } export interface NotifierConfig { - plugin: string; + /** + * Plugin name (manifest.name). Required when using built-in plugins. + * Optional when `package` or `path` is specified (will be inferred from manifest). + * When both plugin and package/path are specified, manifest.name must match plugin. + * + * POST-VALIDATION INVARIANT: After validateConfig(), this field is ALWAYS populated. + * Either from user input or auto-generated from package/path via generateTempPluginName(). + * The optional typing exists for raw config input before validation. + * Downstream code can safely assume non-null after validation. + */ + plugin?: string; + /** npm package name for external plugins (e.g. "@acme/ao-plugin-notifier-teams") */ + package?: string; + /** Local filesystem path for external plugins (relative to config file or absolute) */ + path?: string; [key: string]: unknown; } diff --git a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts index b18563b6b..5abedf842 100644 --- a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts +++ b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts @@ -136,19 +136,23 @@ describe("Claude Code Activity Detection", () => { // Fallback cases (no JSONL data available) // ----------------------------------------------------------------------- - it("returns null when no session file exists yet", async () => { - // projectDir exists but is empty — no .jsonl files - expect(await agent.getActivityState(makeSession())).toBeNull(); + it("returns 'idle' when no session file exists yet", async () => { + // projectDir exists but is empty — no .jsonl files yet (freshly spawned session) + const session = makeSession(); + const result = await agent.getActivityState(session); + expect(result?.state).toBe("idle"); + // timestamp must be session.createdAt so stuck-detection can fire eventually + expect(result?.timestamp).toBe(session.createdAt); }); it("returns null when no workspacePath", async () => { expect(await agent.getActivityState(makeSession({ workspacePath: null }))).toBeNull(); }); - it("returns null when project directory does not exist", async () => { - // Point to a workspace whose project dir doesn't exist + it("returns 'idle' when project directory does not exist", async () => { + // Process is running but no Claude project dir yet — treat as idle const badPath = join(fakeHome, "nonexistent-workspace"); - expect(await agent.getActivityState(makeSession({ workspacePath: badPath }))).toBeNull(); + expect((await agent.getActivityState(makeSession({ workspacePath: badPath })))?.state).toBe("idle"); }); // ----------------------------------------------------------------------- @@ -297,8 +301,8 @@ describe("Claude Code Activity Detection", () => { it("ignores agent- prefixed JSONL files", async () => { writeJsonl([{ type: "user" }], 0, "agent-toolkit.jsonl"); - // No real session file → returns null (cannot determine activity) - expect(await agent.getActivityState(makeSession())).toBeNull(); + // No real session file → process is running, treat as idle + expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); }); it("reads last entry from multi-entry JSONL (not first)", async () => { diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 4f141415d..c10ed3b9f 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -733,8 +733,9 @@ function createClaudeCodeAgent(): Agent { const sessionFile = await findLatestSessionFile(projectDir); if (!sessionFile) { - // No session file found — cannot determine activity - return null; + // No session file yet — process is running but no conversation started. + // Treat as idle (waiting for first task). + return { state: "idle", timestamp: session.createdAt }; } const entry = await readLastJsonlEntry(sessionFile); diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 67f89bc8b..9aba8ff87 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -188,6 +188,7 @@ vi.mock("@/lib/services", () => ({ registry: mockRegistry, sessionManager: mockSessionManager, })), + getVerifyIssues: vi.fn(async () => []), getSCM: vi.fn(() => mockSCM), })); @@ -205,6 +206,7 @@ import { POST as mergePOST } from "@/app/api/prs/[id]/merge/route"; import { GET as eventsGET } from "@/app/api/events/route"; import { GET as observabilityGET } from "@/app/api/observability/route"; import { GET as runtimeTerminalGET } from "@/app/api/runtime/terminal/route"; +import { GET as verifyGET, POST as verifyPOST } from "@/app/api/verify/route"; function makeRequest(url: string, init?: RequestInit): NextRequest { return new NextRequest( @@ -986,4 +988,27 @@ describe("API Routes", () => { expect(data).toHaveProperty("projects"); }); }); + + describe("GET /api/verify", () => { + it("returns verify issues", async () => { + const res = await verifyGET(); + expect(res.status).toBe(200); + const data = await res.json(); + expect(Array.isArray(data.issues)).toBe(true); + }); + }); + + describe("POST /api/verify", () => { + it("returns 400 for invalid JSON body", async () => { + const req = makeRequest("/api/verify", { + method: "POST", + body: "not json", + headers: { "Content-Type": "application/json" }, + }); + const res = await verifyPOST(req); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toMatch(/Invalid JSON body/); + }); + }); }); diff --git a/packages/web/src/app/api/issues/route.ts b/packages/web/src/app/api/issues/route.ts index 892d3f6c8..3212afd34 100644 --- a/packages/web/src/app/api/issues/route.ts +++ b/packages/web/src/app/api/issues/route.ts @@ -21,7 +21,7 @@ export async function GET(request: NextRequest) { for (const [projectId, project] of Object.entries(config.projects)) { if (projectFilter && projectId !== projectFilter) continue; - if (!project.tracker) continue; + if (!project.tracker?.plugin) continue; const tracker = registry.get("tracker", project.tracker.plugin); if (!tracker?.listIssues) continue; @@ -76,7 +76,7 @@ export async function POST(request: NextRequest) { } const project = config.projects[projectId]; - if (!project.tracker) { + if (!project.tracker?.plugin) { return NextResponse.json({ error: "No tracker configured for this project" }, { status: 422 }); } diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index 3485e7231..7fb406e90 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -75,7 +75,17 @@ export async function GET(request: Request) { const allSessions = requestedProjectId ? await sessionManager.list() : coreSessions; - let workerSessions = visibleSessions.filter((session) => !isOrchestratorSession(session)); + const allSessionPrefixes = Object.entries(config.projects).map( + ([projectId, p]) => p.sessionPrefix ?? projectId, + ); + let workerSessions = visibleSessions.filter( + (session) => + !isOrchestratorSession( + session, + config.projects[session.projectId]?.sessionPrefix ?? session.projectId, + allSessionPrefixes, + ), + ); // Convert to dashboard format let dashboardSessions = workerSessions.map(sessionToDashboard); @@ -134,7 +144,7 @@ export async function GET(request: Request) { stats: computeStats(dashboardSessions), orchestratorId, orchestrators, - globalPause: resolveGlobalPause(allSessions), + globalPause: resolveGlobalPause(allSessions, config.projects), }, { status: 200 }, correlationId, diff --git a/packages/web/src/app/api/verify/route.ts b/packages/web/src/app/api/verify/route.ts index 12f059ab5..44c0ef627 100644 --- a/packages/web/src/app/api/verify/route.ts +++ b/packages/web/src/app/api/verify/route.ts @@ -26,7 +26,17 @@ export async function GET() { */ export async function POST(req: NextRequest) { try { - const body = await req.json(); + const body = (await req.json().catch(() => null)) as + | { + issueId?: string; + projectId?: string; + action?: "verify" | "fail"; + comment?: string; + } + | null; + if (!body) { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } const { issueId, projectId, action, comment } = body as { issueId: string; projectId: string; @@ -54,7 +64,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: projectErr }, { status: 404 }); } const project = config.projects[projectId]; - if (!project.tracker) { + if (!project.tracker?.plugin) { return NextResponse.json({ error: `Project ${projectId} has no tracker` }, { status: 422 }); } diff --git a/packages/web/src/app/sessions/[id]/page.test.tsx b/packages/web/src/app/sessions/[id]/page.test.tsx index ec96b721c..45eb15790 100644 --- a/packages/web/src/app/sessions/[id]/page.test.tsx +++ b/packages/web/src/app/sessions/[id]/page.test.tsx @@ -44,6 +44,16 @@ describe("SessionPage project polling", () => { beforeEach(() => { vi.useFakeTimers(); sessionDetailSpy.mockClear(); + + const eventSourceMock = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + close: vi.fn(), + readyState: 1, + }; + global.EventSource = vi.fn( + () => eventSourceMock as unknown as EventSource, + ) as unknown as typeof EventSource; }); afterEach(() => { diff --git a/packages/web/src/app/sessions/[id]/page.tsx b/packages/web/src/app/sessions/[id]/page.tsx index 453b6782c..cf0bf2fe3 100644 --- a/packages/web/src/app/sessions/[id]/page.tsx +++ b/packages/web/src/app/sessions/[id]/page.tsx @@ -4,18 +4,26 @@ import { useEffect, useState, useCallback, useRef } from "react"; import { useParams } from "next/navigation"; import { isOrchestratorSession } from "@composio/ao-core/types"; import { SessionDetail } from "@/components/SessionDetail"; -import { type DashboardSession, getAttentionLevel, type AttentionLevel } from "@/lib/types"; +import { type DashboardSession, type ActivityState, getAttentionLevel, type AttentionLevel } from "@/lib/types"; import { activityIcon } from "@/lib/activity-icons"; +import type { ProjectInfo } from "@/lib/project-name"; +import { useSSESessionActivity } from "@/hooks/useSSESessionActivity"; function truncate(s: string, max: number): string { return s.length > max ? s.slice(0, max) + "..." : s; } /** Build a descriptive tab title from session data. */ -function buildSessionTitle(session: DashboardSession): string { +function buildSessionTitle( + session: DashboardSession, + prefixByProject: Map, + activityOverride?: ActivityState | null, +): string { const id = session.id; - const emoji = session.activity ? (activityIcon[session.activity] ?? "") : ""; - const isOrchestrator = isOrchestratorSession(session); + const activity = activityOverride !== undefined ? activityOverride : session.activity; + const emoji = activity ? (activityIcon[activity] ?? "") : ""; + const allPrefixes = [...prefixByProject.values()]; + const isOrchestrator = isOrchestratorSession(session, prefixByProject.get(session.projectId), allPrefixes); let detail: string; @@ -56,20 +64,47 @@ export default function SessionPage() { const [projectOrchestratorId, setProjectOrchestratorId] = useState(undefined); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [prefixByProject, setPrefixByProject] = useState>(new Map()); const sessionProjectId = session?.projectId ?? null; - const sessionIsOrchestrator = session ? isOrchestratorSession(session) : false; + const allPrefixes = [...prefixByProject.values()]; + const sessionIsOrchestrator = session + ? isOrchestratorSession(session, prefixByProject.get(session.projectId), allPrefixes) + : false; const sessionProjectIdRef = useRef(null); const sessionIsOrchestratorRef = useRef(false); const resolvedProjectSessionsKeyRef = useRef(null); + const prefixByProjectRef = useRef>(new Map()); - // Update document title based on session data + // Keep prefixByProjectRef in sync so fetchProjectSessions (stable [] dep) reads latest map + useEffect(() => { + prefixByProjectRef.current = prefixByProject; + }, [prefixByProject]); + + // Fetch project prefix map once on mount so isOrchestratorSession can use the correct prefix + useEffect(() => { + fetch("/api/projects") + .then((res) => res.ok ? res.json() : null) + .then((data: { projects?: ProjectInfo[] } | null) => { + if (data?.projects) { + setPrefixByProject( + new Map(data.projects.map((p) => [p.id, p.sessionPrefix ?? p.id])), + ); + } + }) + .catch(() => {/* non-critical — falls back to role metadata check */}); + }, []); + + // Subscribe to SSE for real-time activity updates (title emoji) + const sseActivity = useSSESessionActivity(id); + + // Update document title based on session data + SSE activity override useEffect(() => { if (session) { - document.title = buildSessionTitle(session); + document.title = buildSessionTitle(session, prefixByProject, sseActivity?.activity); } else { document.title = `${id} | Session Detail`; } - }, [session, id]); + }, [session, id, prefixByProject, sseActivity]); useEffect(() => { sessionProjectIdRef.current = sessionProjectId; @@ -133,8 +168,9 @@ export default function SessionPage() { working: 0, done: 0, }; + const allPrefixes = [...prefixByProjectRef.current.values()]; for (const s of sessions) { - if (!isOrchestratorSession(s)) { + if (!isOrchestratorSession(s, prefixByProjectRef.current.get(s.projectId), allPrefixes)) { counts[getAttentionLevel(s) as AttentionLevel]++; } } diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 0ec40bc1f..3dbb05adf 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -14,7 +14,7 @@ import { isPRMergeReady, } from "@/lib/types"; import { AttentionZone } from "./AttentionZone"; -import { DynamicFavicon } from "./DynamicFavicon"; +import { DynamicFavicon, countNeedingAttention } from "./DynamicFavicon"; import { useSessionEvents } from "@/hooks/useSessionEvents"; import { ProjectSidebar } from "./ProjectSidebar"; import { ThemeToggle } from "./ThemeToggle"; @@ -72,10 +72,18 @@ function DashboardInner({ orchestrators, }: DashboardProps) { const orchestratorLinks = orchestrators ?? EMPTY_ORCHESTRATORS; - const { sessions, globalPause, connectionStatus } = useSessionEvents( + const initialAttentionLevels = useMemo(() => { + const levels: Record = {}; + for (const s of initialSessions) { + levels[s.id] = getAttentionLevel(s); + } + return levels; + }, [initialSessions]); + const { sessions, globalPause, connectionStatus, sseAttentionLevels } = useSessionEvents( initialSessions, initialGlobalPause, projectId, + initialAttentionLevels, ); const searchParams = useSearchParams(); const activeSessionId = searchParams.get("session") ?? undefined; @@ -139,6 +147,13 @@ function DashboardInner({ setActiveOrchestrators((current) => mergeOrchestrators(current, orchestratorLinks)); }, [orchestratorLinks]); + // Update document title with live attention counts from SSE + useEffect(() => { + const needsAttention = countNeedingAttention(sseAttentionLevels); + const label = projectName ?? "ao"; + document.title = needsAttention > 0 ? `${label} (${needsAttention} need attention)` : label; + }, [sseAttentionLevels, projectName]); + useEffect(() => { setMobileMenuOpen(false); }, [searchParams]); @@ -494,7 +509,7 @@ function DashboardInner({ )}