Merge origin/main into ashish921998/fix-session-cleanup

This commit is contained in:
Ashish Huddar 2026-04-05 17:24:38 +05:30
commit db8c69da72
52 changed files with 4216 additions and 828 deletions

View File

@ -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..."

View File

@ -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)

View File

@ -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();
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -343,7 +343,12 @@ async function sendTestNotifications(
const targets = new Map<string, NotifierTarget>();
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) {

View File

@ -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 <name>", "Override the agent plugin (e.g. codex, claude-code)")
.option("--claim-pr <pr>", "Immediately claim an existing PR for the spawned session")

View File

@ -74,7 +74,11 @@ async function gatherSessionInfo(
scm: SCM,
projectConfig: ReturnType<typeof loadConfig>,
): Promise<SessionInfo> {
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>("tracker", project.tracker.plugin);
if (!tracker?.listIssues) continue;
try {

View File

@ -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>("tracker", project.tracker.plugin);
if (!tracker) {
console.error(chalk.red(`Tracker plugin "${project.tracker.plugin}" not found.`));

View File

@ -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();

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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");
});
});

View File

@ -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<string, unknown> | undefined;
const data = event?.data as Record<string, unknown> | 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", {

View File

@ -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();
});
});

View File

@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
path: worktreePath,
branch: "orchestrator/app-orchestrator-1",
sessionId: "app-orchestrator-1",
projectId: "my-app",
});
(mockRuntime.create as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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();
});
});
});

View File

@ -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);
});
});

View File

@ -20,9 +20,10 @@ export interface ResolvedAgentSelection {
export function resolveSessionRole(
sessionId: string,
metadata?: Record<string, string>,
metadata: Record<string, string> | undefined,
sessionPrefix: string,
): SessionRole {
return isOrchestratorSession({ id: sessionId, metadata }) ? "orchestrator" : "worker";
return isOrchestratorSession({ id: sessionId, metadata }, sessionPrefix) ? "orchestrator" : "worker";
}
export function resolveAgentSelection(params: {

View File

@ -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<string>();
// 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);

View File

@ -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>("agent", agentName);
const scm = project.scm ? registry.get<SCM>("scm", project.scm.plugin) : null;
const scm = project.scm?.plugin ? registry.get<SCM>("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>("scm", project.scm.plugin) : null;
const scm = project.scm?.plugin ? registry.get<SCM>("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<void> {
const project = config.projects[session.projectId];
if (!project || !session.pr) return;
const scm = project.scm?.plugin ? registry.get<SCM>("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<void> {
const project = config.projects[session.projectId];
if (!project || !session.pr) return;
const scm = project.scm?.plugin ? registry.get<SCM>("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<void> {
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;

View File

@ -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<string, unknown> | undefined {
// Notifiers are configured under config.notifiers.<id>.
// 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<string, unknown>)["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<string, unknown>;
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<string, unknown>,
configPath?: string,
): Record<string, unknown> {
// 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<string, ExternalPluginEntryRef[]> {
const index = new Map<string, ExternalPluginEntryRef[]>();
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<string, ExternalPluginEntryRef[]>,
): 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<PluginModule>;
@ -239,16 +388,25 @@ export function createPluginRegistry(): PluginRegistry {
): Promise<void> {
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`);
}
}
},

View File

@ -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"],

View File

@ -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<string>();
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<string, string> | 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<string, string> | 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<string, ActiveSessionRecord[]>();
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<number>();
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>("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>("tracker", project.tracker.plugin)
: null;
const scm = project.scm ? registry.get<SCM>("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>("tracker", project.tracker.plugin) : null;
const scm = project.scm?.plugin ? registry.get<SCM>("scm", project.scm.plugin) : null;
return { runtime, agent, workspace, tracker, scm };
}
@ -724,7 +810,7 @@ export function createSessionManager(deps: SessionManagerDeps): OpenCodeSessionM
metadata: Record<string, string>,
) {
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<void> => {
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<void> {
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 =

View File

@ -189,11 +189,37 @@ export interface Session {
metadata: Record<string, string>;
}
export function isOrchestratorSession(session: {
id: SessionId;
metadata?: Record<string, string>;
}): boolean {
return session.metadata?.["role"] === "orchestrator" || session.id.endsWith("-orchestrator");
export function isOrchestratorSession(
session: { id: SessionId; metadata?: Record<string, string> },
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<string, ReactionConfig>;
/**
* 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;
}

View File

@ -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 () => {

View File

@ -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);

View File

@ -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/);
});
});
});

View File

@ -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>("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 });
}

View File

@ -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,

View File

@ -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 });
}

View File

@ -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(() => {

View File

@ -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<string, string>,
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<string | null | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [prefixByProject, setPrefixByProject] = useState<Map<string, string>>(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<string | null>(null);
const sessionIsOrchestratorRef = useRef(false);
const resolvedProjectSessionsKeyRef = useRef<string | null>(null);
const prefixByProjectRef = useRef<Map<string, string>>(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]++;
}
}

View File

@ -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<string, AttentionLevel> = {};
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({
)}
<div className="dashboard-main flex-1 overflow-y-auto px-4 py-4 md:px-7 md:py-6">
<div id="mobile-dashboard-anchor" aria-hidden="true" />
<DynamicFavicon sessions={sessions} projectName={projectName} />
<DynamicFavicon sseAttentionLevels={sseAttentionLevels} projectName={projectName} />
<section className="dashboard-hero mb-5">
<div className="dashboard-hero__backdrop" />
<div className="dashboard-hero__content">

View File

@ -1,21 +1,21 @@
"use client";
import { useEffect } from "react";
import { type DashboardSession, type AttentionLevel, getAttentionLevel } from "@/lib/types";
import type { SSEAttentionMap } from "@/hooks/useSessionEvents";
/**
* Determine overall health from sessions.
* Determine overall health from SSE attention levels.
* - "green" all sessions working/done/pending, nothing needs attention
* - "yellow" some sessions need review or response
* - "red" critical: sessions stuck, errored, or needing immediate action
*/
function computeHealth(sessions: DashboardSession[]): "green" | "yellow" | "red" {
if (sessions.length === 0) return "green";
function computeHealthFromLevels(levels: SSEAttentionMap): "green" | "yellow" | "red" {
const entries = Object.values(levels);
if (entries.length === 0) return "green";
let hasYellow = false;
for (const session of sessions) {
const level: AttentionLevel = getAttentionLevel(session);
for (const level of entries) {
if (level === "respond") return "red";
if (level === "review" || level === "merge") hasYellow = true;
}
@ -38,20 +38,35 @@ function generateFaviconSvg(initial: string, color: string): string {
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
/** Count sessions that need human attention (respond, review, merge). */
export function countNeedingAttention(levels: SSEAttentionMap): number {
let count = 0;
for (const level of Object.values(levels)) {
if (level === "respond" || level === "review" || level === "merge") {
count++;
}
}
return count;
}
interface DynamicFaviconProps {
sessions: DashboardSession[];
/** Server-computed attention levels from SSE snapshots. */
sseAttentionLevels: SSEAttentionMap;
projectName?: string;
}
/**
* Client component that dynamically updates the browser favicon
* based on system health (session attention levels).
* based on system health (session attention levels from SSE).
*
* Uses server-computed attention levels from SSE snapshots for real-time
* updates, rather than recomputing from the full sessions array.
*/
export function DynamicFavicon({ sessions, projectName = "A" }: DynamicFaviconProps) {
export function DynamicFavicon({ sseAttentionLevels, projectName = "A" }: DynamicFaviconProps) {
const initial = projectName.charAt(0).toUpperCase();
useEffect(() => {
const health = computeHealth(sessions);
const health = computeHealthFromLevels(sseAttentionLevels);
const color = HEALTH_COLORS[health];
const href = generateFaviconSvg(initial, color);
@ -64,7 +79,7 @@ export function DynamicFavicon({ sessions, projectName = "A" }: DynamicFaviconPr
}
link.type = "image/svg+xml";
link.href = href;
}, [sessions, initial]);
}, [sseAttentionLevels, initial]);
return null;
}

View File

@ -21,8 +21,14 @@ interface ProjectSidebarProps {
type ProjectHealth = "red" | "yellow" | "green" | "gray";
function computeProjectHealth(sessions: DashboardSession[]): ProjectHealth {
const workers = sessions.filter((s) => !isOrchestratorSession(s));
function computeProjectHealth(
sessions: DashboardSession[],
prefixByProject: Map<string, string>,
allPrefixes: string[],
): ProjectHealth {
const workers = sessions.filter(
(s) => !isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes),
);
if (workers.length === 0) return "gray";
for (const s of workers) {
if (getAttentionLevel(s) === "respond") return "red";
@ -131,6 +137,16 @@ function ProjectSidebarInner({
router.push(pathname + `?project=${encodeURIComponent(projectId)}`);
};
const prefixByProject = useMemo(
() => new Map(projects.map((p) => [p.id, p.sessionPrefix ?? p.id])),
[projects],
);
const allPrefixes = useMemo(
() => projects.map((p) => p.sessionPrefix ?? p.id),
[projects],
);
const sessionsByProject = useMemo(() => {
const map = new Map<string, { all: DashboardSession[]; workers: DashboardSession[] }>();
let totalWorkers = 0;
@ -144,7 +160,7 @@ function ProjectSidebarInner({
map.set(s.projectId, entry);
}
entry.all.push(s);
if (!isOrchestratorSession(s)) {
if (!isOrchestratorSession(s, prefixByProject.get(s.projectId), allPrefixes)) {
entry.workers.push(s);
totalWorkers++;
}
@ -154,7 +170,7 @@ function ProjectSidebarInner({
}
return { map, totalWorkers, needsInput, reviewLoad };
}, [sessions]);
}, [sessions, prefixByProject, allPrefixes]);
const { totalWorkers: totalWorkerSessions, needsInput: needsInputCount, reviewLoad: reviewLoadCount } = sessionsByProject;
@ -168,7 +184,7 @@ function ProjectSidebarInner({
<div className="flex flex-1 flex-col items-center gap-2">
{projects.map((project) => {
const entry = sessionsByProject.map.get(project.id);
const health = entry ? computeProjectHealth(entry.all) : ("gray" as ProjectHealth);
const health = entry ? computeProjectHealth(entry.all, prefixByProject, allPrefixes) : ("gray" as ProjectHealth);
const isActive = activeProjectId === project.id;
const initial = project.name.charAt(0).toUpperCase();
return (
@ -287,7 +303,7 @@ function ProjectSidebarInner({
const entry = sessionsByProject.map.get(project.id);
const projectSessions = entry?.all ?? [];
const workerSessions = entry?.workers ?? [];
const health = computeProjectHealth(projectSessions);
const health = computeProjectHealth(projectSessions, prefixByProject, allPrefixes);
const isExpanded = expandedProjects.has(project.id);
const isActive = activeProjectId === project.id;

View File

@ -7,6 +7,7 @@ import {
type DashboardSession,
type DashboardPR,
type DashboardOrchestratorLink,
getAttentionLevel,
} from "@/lib/types";
import { useSessionEvents } from "@/hooks/useSessionEvents";
import { ProjectSidebar } from "./ProjectSidebar";
@ -35,7 +36,14 @@ export function PullRequestsPage({
orchestrators,
}: PullRequestsPageProps) {
const orchestratorLinks = orchestrators ?? EMPTY_ORCHESTRATORS;
const { sessions } = useSessionEvents(initialSessions, null, projectId);
const initialAttentionLevels = useMemo(() => {
const levels: Record<string, ReturnType<typeof getAttentionLevel>> = {};
for (const s of initialSessions) {
levels[s.id] = getAttentionLevel(s);
}
return levels;
}, [initialSessions]);
const { sessions, sseAttentionLevels } = useSessionEvents(initialSessions, null, projectId, initialAttentionLevels);
const searchParams = useSearchParams();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
@ -82,7 +90,7 @@ export function PullRequestsPage({
/>
) : null}
<div className="dashboard-main flex-1 overflow-y-auto px-4 py-4 md:px-7 md:py-6">
<DynamicFavicon sessions={sessions} projectName={projectName ? `${projectName} PRs` : "Pull Requests"} />
<DynamicFavicon sseAttentionLevels={sseAttentionLevels} projectName={projectName ? `${projectName} PRs` : "Pull Requests"} />
<section className="dashboard-hero mb-5">
<div className="dashboard-hero__backdrop" />
<div className="dashboard-hero__content">

View File

@ -542,6 +542,9 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio
</>
)}
{alert.label}
{alert.notified && (
<span className="ml-1 opacity-60" title="Agent has been notified"> · notified</span>
)}
</a>
{alert.actionLabel && (
<button
@ -717,6 +720,7 @@ interface Alert {
borderColor?: string;
url: string;
count?: number;
notified?: boolean;
actionLabel?: string;
actionMessage?: string;
actionClassName?: string;
@ -728,12 +732,37 @@ function getAlerts(session: DashboardSession): Alert[] {
if (isPRRateLimited(pr)) return [];
if (isPRUnenriched(pr)) return [];
const meta = session.metadata;
const alerts: Alert[] = [];
if (pr.ciStatus === CI_STATUS.FAILING) {
// The lifecycle manager's status is the most up-to-date source of truth.
// PR enrichment data can be stale (5-min cache) or unavailable (rate limit/timeout).
// Use lifecycle status as fallback when PR data hasn't caught up yet.
const lifecycleStatus = meta["status"];
const ciIsFailing = pr.ciStatus === CI_STATUS.FAILING || lifecycleStatus === "ci_failed";
const hasChangesRequested =
pr.reviewDecision === "changes_requested" || lifecycleStatus === "changes_requested";
const hasConflicts = !pr.mergeability.noConflicts;
if (ciIsFailing) {
const failedCheck = pr.ciChecks.find((c) => c.status === "failed");
const failCount = pr.ciChecks.filter((c) => c.status === "failed").length;
if (failCount === 0) {
if (failCount === 0 && pr.ciStatus !== CI_STATUS.FAILING) {
// Lifecycle says ci_failed but PR enrichment hasn't caught up — show generic alert
alerts.push({
key: "ci-fail",
label: "CI failing",
className: "",
color: "var(--color-alert-ci)",
borderColor: "var(--color-alert-ci)",
url: pr.url + "/checks",
notified: Boolean(meta["lastCIFailureDispatchHash"]),
actionLabel: "ask to fix",
actionMessage: `Please fix the failing CI checks on ${pr.url}`,
actionClassName: "bg-[var(--color-alert-ci-bg)] text-white hover:brightness-110",
});
} else if (failCount === 0) {
alerts.push({
key: "ci-unknown",
label: "CI unknown",
@ -749,6 +778,7 @@ function getAlerts(session: DashboardSession): Alert[] {
color: "var(--color-alert-ci)",
borderColor: "var(--color-alert-ci)",
url: failedCheck?.url ?? pr.url + "/checks",
notified: Boolean(meta["lastCIFailureDispatchHash"]),
actionLabel: "ask to fix",
actionMessage: `Please fix the failing CI checks on ${pr.url}`,
actionClassName: "bg-[var(--color-alert-ci-bg)] text-white hover:brightness-110",
@ -756,13 +786,14 @@ function getAlerts(session: DashboardSession): Alert[] {
}
}
if (pr.reviewDecision === "changes_requested") {
if (hasChangesRequested) {
alerts.push({
key: "changes",
label: "changes requested",
className: "",
color: "var(--color-alert-changes)",
url: pr.url,
notified: Boolean(meta["lastPendingReviewDispatchHash"]),
actionLabel: "ask to address",
actionMessage: `Please address the requested changes on ${pr.url}`,
actionClassName: "bg-[var(--color-alert-changes-bg)] text-white hover:brightness-110",
@ -780,13 +811,14 @@ function getAlerts(session: DashboardSession): Alert[] {
});
}
if (!pr.mergeability.noConflicts) {
if (hasConflicts) {
alerts.push({
key: "conflict",
label: "merge conflict",
className: "",
color: "var(--color-alert-conflict)",
url: pr.url,
notified: meta["lastMergeConflictDispatched"] === "true",
actionLabel: "ask to fix",
actionMessage: `Please resolve the merge conflicts on ${pr.url} by rebasing on the base branch`,
actionClassName: "bg-[var(--color-alert-conflict-bg)] text-white hover:brightness-110",

View File

@ -409,7 +409,7 @@ export function SessionDetail({
{pr ? (
<section id="session-pr-section" className="mt-6">
<SessionDetailPRCard pr={pr} sessionId={session.id} />
<SessionDetailPRCard pr={pr} sessionId={session.id} metadata={session.metadata} />
</section>
) : null}
</main>
@ -430,7 +430,7 @@ export function SessionDetail({
// ── Session detail PR card ────────────────────────────────────────────
function SessionDetailPRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: string }) {
function SessionDetailPRCard({ pr, sessionId, metadata }: { pr: DashboardPR; sessionId: string; metadata: Record<string, string> }) {
const [sendingComments, setSendingComments] = useState<Set<string>>(new Set());
const [sentComments, setSentComments] = useState<Set<string>>(new Set());
const [errorComments, setErrorComments] = useState<Set<string>>(new Set());
@ -569,7 +569,7 @@ function SessionDetailPRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: st
</span>
</div>
) : (
<IssuesList pr={pr} />
<IssuesList pr={pr} metadata={metadata} />
)}
{/* CI Checks */}
@ -664,10 +664,24 @@ function SessionDetailPRCard({ pr, sessionId }: { pr: DashboardPR; sessionId: st
// ── Issues list (pre-merge blockers) ─────────────────────────────────
function IssuesList({ pr }: { pr: DashboardPR }) {
const issues: Array<{ icon: string; color: string; text: string }> = [];
function IssuesList({ pr, metadata }: { pr: DashboardPR; metadata: Record<string, string> }) {
const issues: Array<{ icon: string; color: string; text: string; notified?: boolean }> = [];
if (pr.ciStatus === CI_STATUS.FAILING) {
const ciNotified = Boolean(metadata["lastCIFailureDispatchHash"]);
const conflictNotified = metadata["lastMergeConflictDispatched"] === "true";
const reviewNotified = Boolean(metadata["lastPendingReviewDispatchHash"]);
// The lifecycle manager's status is the most up-to-date source of truth.
// PR enrichment data can be stale (5-min cache) or unavailable (rate limit/timeout).
// Use lifecycle status as fallback when PR data hasn't caught up yet.
const lifecycleStatus = metadata["status"];
const ciIsFailing = pr.ciStatus === CI_STATUS.FAILING || lifecycleStatus === "ci_failed";
const hasChangesRequested =
pr.reviewDecision === "changes_requested" || lifecycleStatus === "changes_requested";
const hasConflicts = pr.state !== "merged" && !pr.mergeability.noConflicts;
if (ciIsFailing) {
const failCount = pr.ciChecks.filter((c) => c.status === "failed").length;
issues.push({
icon: "✗",
@ -676,13 +690,19 @@ function IssuesList({ pr }: { pr: DashboardPR }) {
failCount > 0
? `CI failing — ${failCount} check${failCount !== 1 ? "s" : ""} failed`
: "CI failing",
notified: ciNotified,
});
} else if (pr.ciStatus === CI_STATUS.PENDING) {
issues.push({ icon: "●", color: "var(--color-status-attention)", text: "CI pending" });
}
if (pr.reviewDecision === "changes_requested") {
issues.push({ icon: "✗", color: "var(--color-status-error)", text: "Changes requested" });
if (hasChangesRequested) {
issues.push({
icon: "✗",
color: "var(--color-status-error)",
text: "Changes requested",
notified: reviewNotified,
});
} else if (!pr.mergeability.approved) {
issues.push({
icon: "○",
@ -691,8 +711,13 @@ function IssuesList({ pr }: { pr: DashboardPR }) {
});
}
if (pr.state !== "merged" && !pr.mergeability.noConflicts) {
issues.push({ icon: "✗", color: "var(--color-status-error)", text: "Merge conflicts" });
if (hasConflicts) {
issues.push({
icon: "✗",
color: "var(--color-status-error)",
text: "Merge conflicts",
notified: conflictNotified,
});
}
if (!pr.mergeability.mergeable && issues.length === 0) {
@ -724,6 +749,11 @@ function IssuesList({ pr }: { pr: DashboardPR }) {
{issue.icon}
</span>
<span className="text-[var(--color-text-secondary)]">{issue.text}</span>
{issue.notified && (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
· agent notified
</span>
)}
</div>
))}
</div>

View File

@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import { countNeedingAttention, DynamicFavicon } from "../DynamicFavicon";
import type { SSEAttentionMap } from "@/hooks/useSessionEvents";
describe("countNeedingAttention", () => {
it("returns 0 for empty map", () => {
expect(countNeedingAttention({})).toBe(0);
});
it("returns 0 when all sessions are working/pending/done", () => {
const levels: SSEAttentionMap = {
"s-1": "working",
"s-2": "pending",
"s-3": "done",
};
expect(countNeedingAttention(levels)).toBe(0);
});
it("counts respond, review, and merge sessions", () => {
const levels: SSEAttentionMap = {
"s-1": "respond",
"s-2": "review",
"s-3": "merge",
"s-4": "working",
"s-5": "done",
};
expect(countNeedingAttention(levels)).toBe(3);
});
it("counts a single attention-needing session", () => {
const levels: SSEAttentionMap = {
"s-1": "respond",
};
expect(countNeedingAttention(levels)).toBe(1);
});
});
describe("DynamicFavicon", () => {
beforeEach(() => {
const existing = document.querySelector('link[rel="icon"]');
if (existing) existing.remove();
});
it("creates a green favicon when no sessions need attention", () => {
const levels: SSEAttentionMap = { "s-1": "working", "s-2": "done" };
render(<DynamicFavicon sseAttentionLevels={levels} projectName="Test" />);
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
expect(link).not.toBeNull();
expect(link!.href).toContain("%2322c55e"); // green
});
it("creates a yellow favicon when sessions need review", () => {
const levels: SSEAttentionMap = { "s-1": "review", "s-2": "working" };
render(<DynamicFavicon sseAttentionLevels={levels} projectName="Test" />);
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
expect(link!.href).toContain("%23eab308"); // yellow
});
it("creates a red favicon when sessions need response", () => {
const levels: SSEAttentionMap = { "s-1": "respond" };
render(<DynamicFavicon sseAttentionLevels={levels} projectName="Test" />);
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
expect(link!.href).toContain("%23ef4444"); // red
});
it("uses first letter of projectName as initial", () => {
const levels: SSEAttentionMap = {};
render(<DynamicFavicon sseAttentionLevels={levels} projectName="MyApp" />);
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
expect(link!.href).toContain("M"); // initial letter
});
it("defaults to A when no projectName given", () => {
const levels: SSEAttentionMap = {};
render(<DynamicFavicon sseAttentionLevels={levels} />);
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
expect(link!.href).toContain("A");
});
it("updates favicon when attention levels change", () => {
const { rerender } = render(
<DynamicFavicon sseAttentionLevels={{ "s-1": "working" }} projectName="Test" />,
);
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
expect(link!.href).toContain("%2322c55e"); // green
rerender(<DynamicFavicon sseAttentionLevels={{ "s-1": "respond" }} projectName="Test" />);
link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
expect(link!.href).toContain("%23ef4444"); // red
});
});

View File

@ -0,0 +1,207 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useSSESessionActivity } from "../useSSESessionActivity";
describe("useSSESessionActivity", () => {
let eventSourceMock: {
onmessage: ((event: MessageEvent) => void) | null;
onopen: (() => void) | null;
onerror: (() => void) | null;
readyState: number;
close: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
const eventSourceConstructor = vi.fn(() => {
const instance = {
onmessage: null as ((event: MessageEvent) => void) | null,
onopen: null as (() => void) | null,
onerror: null as (() => void) | null,
readyState: 0,
close: vi.fn(),
};
eventSourceMock = instance;
return instance as unknown as EventSource;
});
global.EventSource = Object.assign(eventSourceConstructor, {
CONNECTING: 0,
OPEN: 1,
CLOSED: 2,
}) as unknown as typeof EventSource;
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns null initially", () => {
const { result } = renderHook(() => useSSESessionActivity("session-1"));
expect(result.current).toBeNull();
});
it("updates activity when SSE snapshot contains the target session", () => {
const { result } = renderHook(() => useSSESessionActivity("session-1"));
act(() => {
eventSourceMock.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-1", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
{ id: "session-2", status: "working", activity: "idle", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
expect(result.current).toEqual({ activity: "active" });
});
it("updates when activity changes", () => {
const { result } = renderHook(() => useSSESessionActivity("session-1"));
act(() => {
eventSourceMock.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-1", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
expect(result.current?.activity).toBe("active");
act(() => {
eventSourceMock.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-1", status: "working", activity: "idle", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
expect(result.current?.activity).toBe("idle");
});
it("does not update when activity stays the same (referential stability)", () => {
const { result } = renderHook(() => useSSESessionActivity("session-1"));
act(() => {
eventSourceMock.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-1", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
const first = result.current;
act(() => {
eventSourceMock.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-1", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
expect(result.current).toBe(first);
});
it("ignores snapshots that do not contain the session", () => {
const { result } = renderHook(() => useSSESessionActivity("session-1"));
act(() => {
eventSourceMock.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-other", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
expect(result.current).toBeNull();
});
it("passes project parameter to EventSource URL", () => {
renderHook(() => useSSESessionActivity("session-1", "my-project"));
expect(global.EventSource).toHaveBeenCalledWith("/api/events?project=my-project");
});
it("uses default URL when no project is specified", () => {
renderHook(() => useSSESessionActivity("session-1"));
expect(global.EventSource).toHaveBeenCalledWith("/api/events");
});
it("closes EventSource on unmount", () => {
const { unmount } = renderHook(() => useSSESessionActivity("session-1"));
unmount();
expect(eventSourceMock.close).toHaveBeenCalled();
});
it("ignores non-snapshot events", () => {
const { result } = renderHook(() => useSSESessionActivity("session-1"));
act(() => {
eventSourceMock.onmessage!.call(eventSourceMock, {
data: JSON.stringify({ type: "heartbeat" }),
} as MessageEvent);
});
expect(result.current).toBeNull();
});
it("handles malformed JSON gracefully", () => {
const { result } = renderHook(() => useSSESessionActivity("session-1"));
act(() => {
eventSourceMock.onmessage!.call(eventSourceMock, {
data: "not-json",
} as MessageEvent);
});
expect(result.current).toBeNull();
});
it("resets activity and reconnects when sessionId changes", () => {
let currentSessionId = "session-1";
const { result, rerender } = renderHook(() =>
useSSESessionActivity(currentSessionId),
);
const firstInstance = eventSourceMock;
act(() => {
firstInstance.onmessage!.call(firstInstance, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-1", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
expect(result.current?.activity).toBe("active");
currentSessionId = "session-2";
rerender();
// State should reset to null immediately on sessionId change
expect(result.current).toBeNull();
// First EventSource should have been closed
expect(firstInstance.close).toHaveBeenCalled();
});
});

View File

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useSessionEvents } from "../useSessionEvents";
import type { DashboardSession, GlobalPauseState } from "../../lib/types";
import type { AttentionLevel, DashboardSession, GlobalPauseState } from "../../lib/types";
import { makeSession } from "../../__tests__/helpers";
describe("useSessionEvents", () => {
@ -664,6 +664,131 @@ describe("useSessionEvents", () => {
});
});
describe("sseAttentionLevels", () => {
it("starts with empty attention levels when no initial levels provided", () => {
const sessions = makeSessions(2);
const { result } = renderHook(() => useSessionEvents(sessions));
expect(result.current.sseAttentionLevels).toEqual({});
});
it("seeds attention levels from initialAttentionLevels parameter", () => {
const sessions = makeSessions(2);
const initialLevels = { "session-0": "working" as const, "session-1": "respond" as const };
const { result } = renderHook(() => useSessionEvents(sessions, null, undefined, initialLevels));
expect(result.current.sseAttentionLevels).toEqual({
"session-0": "working",
"session-1": "respond",
});
});
it("populates attention levels from SSE snapshot", () => {
const sessions = makeSessions(2);
const { result } = renderHook(() => useSessionEvents(sessions));
act(() => {
eventSourceMock!.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-0", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
{ id: "session-1", status: "needs_input", activity: "waiting_input", attentionLevel: "respond", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
expect(result.current.sseAttentionLevels).toEqual({
"session-0": "working",
"session-1": "respond",
});
});
it("updates attention levels when they change", () => {
const sessions = makeSessions(1);
const { result } = renderHook(() => useSessionEvents(sessions));
act(() => {
eventSourceMock!.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-0", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
expect(result.current.sseAttentionLevels["session-0"]).toBe("working");
act(() => {
eventSourceMock!.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-0", status: "needs_input", activity: "waiting_input", attentionLevel: "respond", lastActivityAt: new Date().toISOString() },
],
}),
} as MessageEvent);
});
expect(result.current.sseAttentionLevels["session-0"]).toBe("respond");
});
it("preserves referential stability when attention levels do not change", () => {
const sessions = makeSessions(1);
const { result } = renderHook(() => useSessionEvents(sessions));
const ts = new Date().toISOString();
act(() => {
eventSourceMock!.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-0", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: ts },
],
}),
} as MessageEvent);
});
const firstLevels = result.current.sseAttentionLevels;
act(() => {
eventSourceMock!.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
type: "snapshot",
sessions: [
{ id: "session-0", status: "working", activity: "active", attentionLevel: "working", lastActivityAt: ts },
],
}),
} as MessageEvent);
});
expect(result.current.sseAttentionLevels).toBe(firstLevels);
});
it("resets sseAttentionLevels to new initialAttentionLevels when initialSessions changes", () => {
const sessions1 = makeSessions(1);
let currentSessions = sessions1;
let currentLevels: Record<string, AttentionLevel> | undefined = { "session-0": "respond" as const };
const { result, rerender } = renderHook(() =>
useSessionEvents(currentSessions, null, undefined, currentLevels),
);
expect(result.current.sseAttentionLevels).toEqual({ "session-0": "respond" });
const sessions2 = makeSessions(1).map((s) => ({ ...s, id: "session-new" }));
const levels2 = { "session-new": "working" as const };
currentSessions = sessions2;
currentLevels = levels2;
rerender();
expect(result.current.sseAttentionLevels).toEqual({ "session-new": "working" });
expect(result.current.sseAttentionLevels["session-0"]).toBeUndefined();
});
});
describe("immediate first refresh", () => {
it("triggers /api/sessions fetch on first SSE snapshot even when membership matches", async () => {
const sessions = makeSessions(2);
@ -674,7 +799,6 @@ describe("useSessionEvents", () => {
renderHook(() => useSessionEvents(sessions));
// Send snapshot with SAME membership — should still refresh because lastRefreshAtRef starts at 0
await act(async () => {
eventSourceMock!.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
@ -708,7 +832,6 @@ describe("useSessionEvents", () => {
renderHook(() => useSessionEvents(sessions));
// First snapshot triggers refresh (lastRefreshAtRef = 0)
await act(async () => {
eventSourceMock!.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
@ -723,13 +846,10 @@ describe("useSessionEvents", () => {
} as MessageEvent);
});
// Wait for fetch to be called and fail
await waitFor(() => {
expect(fetch).toHaveBeenCalledTimes(1);
});
// Second snapshot should NOT trigger another immediate refresh
// because lastRefreshAtRef was updated on failure
await act(async () => {
eventSourceMock!.onmessage!.call(eventSourceMock, {
data: JSON.stringify({
@ -744,7 +864,6 @@ describe("useSessionEvents", () => {
} as MessageEvent);
});
// fetch should still only have been called once (the failed one)
expect(fetch).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,54 @@
"use client";
import { useEffect, useState } from "react";
import type { ActivityState, SSESnapshotEvent } from "@/lib/types";
interface SessionActivity {
activity: ActivityState | null;
}
/**
* Lightweight SSE subscriber that tracks a single session's activity state.
*
* Used by the session detail page to update document.title emoji in real-time
* without waiting for the full session HTTP poll cycle.
*/
export function useSSESessionActivity(
sessionId: string,
project?: string,
): SessionActivity | null {
const [activity, setActivity] = useState<SessionActivity | null>(null);
useEffect(() => {
setActivity(null);
const url = project ? `/api/events?project=${encodeURIComponent(project)}` : "/api/events";
const es = new EventSource(url);
let disposed = false;
es.onmessage = (event: MessageEvent) => {
if (disposed) return;
try {
const data = JSON.parse(event.data as string) as { type: string };
if (data.type !== "snapshot") return;
const snapshot = data as SSESnapshotEvent;
const match = snapshot.sessions.find((s) => s.id === sessionId);
if (!match) return;
setActivity((prev) => {
if (prev && prev.activity === match.activity) return prev;
return { activity: match.activity };
});
} catch {
return;
}
};
return () => {
disposed = true;
es.close();
};
}, [sessionId, project]);
return activity;
}

View File

@ -1,7 +1,13 @@
"use client";
import { useEffect, useReducer, useRef } from "react";
import type { DashboardSession, GlobalPauseState, SSESnapshotEvent } from "@/lib/types";
import {
getAttentionLevel,
type AttentionLevel,
type DashboardSession,
type GlobalPauseState,
type SSESnapshotEvent,
} from "@/lib/types";
/** Debounce before fetching full session list after membership change. */
const MEMBERSHIP_REFRESH_DELAY_MS = 120;
@ -12,21 +18,34 @@ const DISCONNECTED_GRACE_PERIOD_MS = 4000;
type ConnectionStatus = "connected" | "reconnecting" | "disconnected";
/** Server-computed attention levels from the latest SSE snapshot. */
export type SSEAttentionMap = Readonly<Record<string, AttentionLevel>>;
interface State {
sessions: DashboardSession[];
globalPause: GlobalPauseState | null;
connectionStatus: ConnectionStatus;
/** Attention levels from the latest SSE snapshot (server-computed, includes PR state). */
sseAttentionLevels: SSEAttentionMap;
}
type Action =
| { type: "reset"; sessions: DashboardSession[]; globalPause: GlobalPauseState | null }
| { type: "reset"; sessions: DashboardSession[]; globalPause: GlobalPauseState | null; sseAttentionLevels?: SSEAttentionMap }
| { type: "snapshot"; patches: SSESnapshotEvent["sessions"] }
| { type: "setConnection"; status: ConnectionStatus };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "reset":
return { ...state, sessions: action.sessions, globalPause: action.globalPause };
return {
...state,
sessions: action.sessions,
globalPause: action.globalPause,
...(action.sseAttentionLevels !== undefined
? { sseAttentionLevels: action.sseAttentionLevels }
: {}),
};
case "setConnection":
return { ...state, connectionStatus: action.status };
case "snapshot": {
@ -50,7 +69,25 @@ function reducer(state: State, action: Action): State {
lastActivityAt: patch.lastActivityAt,
};
});
return changed ? { ...state, sessions: next } : state;
// Build attention level map from server-computed values
const levels: Record<string, AttentionLevel> = {};
for (const p of action.patches) {
levels[p.id] = p.attentionLevel;
}
const sessionsChanged = changed;
const levelsChanged =
Object.keys(levels).length !== Object.keys(state.sseAttentionLevels).length ||
action.patches.some((p) => state.sseAttentionLevels[p.id] !== p.attentionLevel);
if (!sessionsChanged && !levelsChanged) return state;
return {
...state,
sessions: sessionsChanged ? next : state.sessions,
sseAttentionLevels: levelsChanged ? levels : state.sseAttentionLevels,
};
}
}
}
@ -68,13 +105,17 @@ export function useSessionEvents(
initialSessions: DashboardSession[],
initialGlobalPause?: GlobalPauseState | null,
project?: string,
initialAttentionLevels?: SSEAttentionMap,
): State {
const [state, dispatch] = useReducer(reducer, {
sessions: initialSessions,
globalPause: initialGlobalPause ?? null,
connectionStatus: "connected" as ConnectionStatus,
sseAttentionLevels: initialAttentionLevels ?? ({} as SSEAttentionMap),
});
const sessionsRef = useRef(state.sessions);
const initialAttentionLevelsRef = useRef(initialAttentionLevels);
initialAttentionLevelsRef.current = initialAttentionLevels;
const refreshingRef = useRef(false);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingMembershipKeyRef = useRef<string | null>(null);
@ -86,7 +127,12 @@ export function useSessionEvents(
}, [state.sessions]);
useEffect(() => {
dispatch({ type: "reset", sessions: initialSessions, globalPause: initialGlobalPause ?? null });
dispatch({
type: "reset",
sessions: initialSessions,
globalPause: initialGlobalPause ?? null,
sseAttentionLevels: initialAttentionLevelsRef.current ?? ({} as SSEAttentionMap),
});
}, [initialSessions, initialGlobalPause]);
useEffect(() => {
@ -131,10 +177,14 @@ export function useSessionEvents(
if (disposed || refreshController.signal.aborted || !updated?.sessions) return;
lastRefreshAtRef.current = Date.now();
const sseAttentionLevels = Object.fromEntries(
updated.sessions.map((s) => [s.id, getAttentionLevel(s)]),
) as SSEAttentionMap;
dispatch({
type: "reset",
sessions: updated.sessions,
globalPause: updated.globalPause ?? null,
sseAttentionLevels,
});
},
)

View File

@ -57,7 +57,7 @@ export const getDashboardPageData = cache(async function getDashboardPageData(pr
const { config, registry, sessionManager } = await getServices();
const allSessions = await sessionManager.list();
pageData.globalPause = resolveGlobalPause(allSessions);
pageData.globalPause = resolveGlobalPause(allSessions, config.projects);
const visibleSessions = filterProjectSessions(allSessions, projectFilter, config.projects);
pageData.orchestrators = listDashboardOrchestrators(visibleSessions, config.projects);

View File

@ -13,19 +13,27 @@ export interface GlobalPauseState {
}
export function resolveGlobalPause(
sessions: Array<{ id: string; metadata: Record<string, string> }>,
sessions: Array<{ id: string; projectId: string; metadata: Record<string, string> }>,
projects: Record<string, { sessionPrefix?: string }>,
): GlobalPauseState | null {
const allSessionPrefixes = Object.entries(projects).map(
([projectId, p]) => p.sessionPrefix ?? projectId,
);
let best: { pausedUntil: string; reason: string; sourceSessionId: string | null } | null = null;
for (const session of sessions) {
if (!isOrchestratorSession(session)) continue;
const sessionPrefix = projects[session.projectId]?.sessionPrefix ?? session.projectId;
if (!isOrchestratorSession(session, sessionPrefix, allSessionPrefixes)) continue;
const parsed = parsePauseUntil(session.metadata[GLOBAL_PAUSE_UNTIL_KEY]);
if (!parsed || parsed.getTime() <= Date.now()) continue;
return {
pausedUntil: parsed.toISOString(),
reason: session.metadata[GLOBAL_PAUSE_REASON_KEY] ?? "Model rate limit reached",
sourceSessionId: session.metadata[GLOBAL_PAUSE_SOURCE_KEY] ?? null,
};
if (!best || parsed.getTime() > new Date(best.pausedUntil).getTime()) {
best = {
pausedUntil: parsed.toISOString(),
reason: session.metadata[GLOBAL_PAUSE_REASON_KEY] ?? "Model rate limit reached",
sourceSessionId: session.metadata[GLOBAL_PAUSE_SOURCE_KEY] ?? null,
};
}
}
return null;
return best;
}

View File

@ -4,6 +4,7 @@ import { loadConfig } from "@composio/ao-core";
export interface ProjectInfo {
id: string;
name: string;
sessionPrefix?: string;
}
export const getProjectName = cache((): string => {
@ -37,6 +38,7 @@ export const getAllProjects = cache((): ProjectInfo[] => {
return Object.entries(config.projects).map(([id, project]) => ({
id,
name: project.name ?? id,
sessionPrefix: project.sessionPrefix ?? id,
}));
} catch {
return [];

View File

@ -44,6 +44,16 @@ export function filterWorkerSessions<T extends SessionLike>(
projectFilter: string | null | undefined,
projects: Record<string, ProjectWithPrefix>,
): T[] {
const workers = sessions.filter((s) => !isOrchestratorSession(s));
const allSessionPrefixes = Object.entries(projects).map(
([projectId, p]) => p.sessionPrefix ?? projectId,
);
const workers = sessions.filter(
(s) =>
!isOrchestratorSession(
s,
projects[s.projectId]?.sessionPrefix ?? s.projectId,
allSessionPrefixes,
),
);
return filterProjectSessions(workers, projectFilter, projects);
}

View File

@ -34,7 +34,7 @@ export function findWebhookProjects(
pathname: string,
): WebhookProjectMatch[] {
return Object.entries(config.projects).flatMap(([projectId, project]) => {
if (!project.scm) return [];
if (!project.scm?.plugin) return [];
const webhookPath = getProjectWebhookPath(project);
if (!webhookPath || webhookPath !== pathname) return [];
const scm = registry.get<SCM>("scm", project.scm.plugin);

View File

@ -73,8 +73,17 @@ export function listDashboardOrchestrators(
sessions: Session[],
projects: Record<string, ProjectConfig>,
): DashboardOrchestratorLink[] {
const allSessionPrefixes = Object.entries(projects).map(
([projectId, p]) => p.sessionPrefix ?? projectId,
);
return sessions
.filter((session) => isOrchestratorSession(session))
.filter((session) =>
isOrchestratorSession(
session,
projects[session.projectId]?.sessionPrefix ?? session.projectId,
allSessionPrefixes,
),
)
.map((session) => ({
id: session.id,
projectId: session.projectId,
@ -367,7 +376,7 @@ export async function enrichSessionsMetadataFast(
// Issue labels (synchronous string parsing, no API calls)
projects.forEach((project, i) => {
if (!dashboardSessions[i].issueUrl || !project?.tracker) return;
if (!dashboardSessions[i].issueUrl || !project?.tracker?.plugin) return;
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker) return;
enrichSessionIssue(dashboardSessions[i], tracker, project);
@ -405,7 +414,7 @@ export async function enrichSessionsMetadata(
if (!dashboardSessions[i].issueUrl || !dashboardSessions[i].issueLabel) {
return Promise.resolve();
}
if (!project?.tracker) return Promise.resolve();
if (!project?.tracker?.plugin) return Promise.resolve();
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker) return Promise.resolve();
return enrichSessionIssueTitle(dashboardSessions[i], tracker, project);

View File

@ -137,7 +137,7 @@ async function labelIssuesForVerification(
for (const session of mergedSessions) {
const key = `${session.projectId}:${session.issueId}`;
const project = config.projects[session.projectId];
if (!project?.tracker) {
if (!project?.tracker?.plugin) {
processedIssues.add(key);
continue;
}
@ -180,7 +180,7 @@ async function relabelReopenedIssues(
registry: PluginRegistry,
): Promise<void> {
for (const [, project] of Object.entries(config.projects)) {
if (!project.tracker) continue;
if (!project.tracker?.plugin) continue;
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker?.listIssues || !tracker.updateIssue) continue;
@ -225,8 +225,16 @@ export async function pollBacklog(): Promise<void> {
// Detect reopened issues: open state + agent:done label → relabel as agent:backlog
await relabelReopenedIssues(config, registry);
const allSessionPrefixes = Object.entries(config.projects).map(
([id, p]) => p.sessionPrefix ?? id,
);
const workerSessions = allSessions.filter(
(session) => !isOrchestratorSession(session) && !TERMINAL_STATUSES.has(session.status),
(session) =>
!isOrchestratorSession(
session,
config.projects[session.projectId]?.sessionPrefix ?? session.projectId,
allSessionPrefixes,
) && !TERMINAL_STATUSES.has(session.status),
);
const activeIssueIds = new Set(
workerSessions
@ -240,7 +248,7 @@ export async function pollBacklog(): Promise<void> {
for (const [projectId, project] of Object.entries(config.projects)) {
if (availableSlots <= 0) break;
if (!project.tracker) continue;
if (!project.tracker?.plugin) continue;
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker?.listIssues) continue;
@ -352,7 +360,7 @@ export async function getBacklogIssues(): Promise<Array<Issue & { projectId: str
try {
const { config, registry } = await getServices();
for (const [projectId, project] of Object.entries(config.projects)) {
if (!project.tracker) continue;
if (!project.tracker?.plugin) continue;
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker?.listIssues) continue;
@ -380,7 +388,7 @@ export async function getVerifyIssues(): Promise<Array<Issue & { projectId: stri
try {
const { config, registry } = await getServices();
for (const [projectId, project] of Object.entries(config.projects)) {
if (!project.tracker) continue;
if (!project.tracker?.plugin) continue;
const tracker = registry.get<Tracker>("tracker", project.tracker.plugin);
if (!tracker?.listIssues) continue;
@ -404,6 +412,6 @@ export async function getVerifyIssues(): Promise<Array<Issue & { projectId: stri
/** Resolve the SCM plugin for a project. Returns null if not configured. */
export function getSCM(registry: PluginRegistry, project: ProjectConfig | undefined): SCM | null {
if (!project?.scm) return null;
if (!project?.scm?.plugin) return null;
return registry.get<SCM>("scm", project.scm.plugin);
}