Merge origin/main into ashish921998/fix-session-cleanup
This commit is contained in:
commit
db8c69da72
|
|
@ -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..."
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
[](https://github.com/ComposioHQ/agent-orchestrator/stargazers)
|
||||
[](https://www.npmjs.com/package/@composio/ao)
|
||||
[](LICENSE)
|
||||
[](https://github.com/ComposioHQ/agent-orchestrator/pulls?q=is%3Amerged)
|
||||
[](https://github.com/ComposioHQ/agent-orchestrator/releases/tag/metrics-v1)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.`));
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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]++;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue