From ebb73b6d79df2a0a8227bb35545bbe055c59f3d8 Mon Sep 17 00:00:00 2001 From: Harsh Batheja Date: Sat, 28 Mar 2026 14:20:09 +0530 Subject: [PATCH 01/39] fix: use TERMINAL_STATUSES in lifecycle poll accounting Replace hardcoded "merged"/"killed" checks in pollAll() with the canonical TERMINAL_STATUSES set from types.ts. This ensures sessions in done, terminated, cleanup, or errored states are correctly treated as inactive for polling, active-count, and all-complete detection. Closes #752 --- .../src/__tests__/lifecycle-manager.test.ts | 132 ++++++++++++++++++ packages/core/src/lifecycle-manager.ts | 9 +- 2 files changed, 137 insertions(+), 4 deletions(-) diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 37580ca1d..25be21d29 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -8,6 +8,7 @@ import type { SessionManager, Agent, ActivityState, + SessionStatus, } from "../types.js"; import { createTestEnvironment, @@ -779,6 +780,137 @@ describe("reactions", () => { }); }); +describe("pollAll terminal status accounting", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("treats all TERMINAL_STATUSES as inactive for all-complete", async () => { + const notifier = createMockNotifier(); + const registryWithNotifier: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string, name: string) => { + if (slot === "runtime") return plugins.runtime; + if (slot === "agent") return plugins.agent; + if (slot === "notifier" && name === "desktop") return notifier; + return null; + }), + }; + + // All sessions in various terminal states — should count as inactive + const terminalSessions = [ + makeSession({ id: "s-1", status: "killed" as SessionStatus }), + makeSession({ id: "s-2", status: "merged" as SessionStatus }), + makeSession({ id: "s-3", status: "done" as SessionStatus }), + makeSession({ id: "s-4", status: "errored" as SessionStatus }), + makeSession({ id: "s-5", status: "terminated" as SessionStatus }), + makeSession({ id: "s-6", status: "cleanup" as SessionStatus }), + ]; + + vi.mocked(mockSessionManager.list).mockResolvedValue(terminalSessions); + + // Route info-priority notifications to desktop so we can observe them + config.notificationRouting.info = ["desktop"]; + config.reactions = { + "all-complete": { auto: true, action: "notify" }, + }; + + const lm = createLifecycleManager({ + config, + registry: registryWithNotifier, + sessionManager: mockSessionManager, + }); + + lm.start(60_000); + // Let the immediate pollAll() run + await vi.advanceTimersByTimeAsync(0); + + expect(notifier.notify).toHaveBeenCalledWith( + expect.objectContaining({ type: "reaction.triggered" }), + ); + + lm.stop(); + }); + + it("does not fire all-complete when a session is in non-terminal status like done is missing", async () => { + const notifier = createMockNotifier(); + const registryWithNotifier: PluginRegistry = { + ...mockRegistry, + get: vi.fn().mockImplementation((slot: string, name: string) => { + if (slot === "runtime") return plugins.runtime; + if (slot === "agent") return plugins.agent; + if (slot === "notifier" && name === "desktop") return notifier; + return null; + }), + }; + + // Mix of terminal and active sessions + const sessions = [ + makeSession({ id: "s-1", status: "killed" as SessionStatus }), + makeSession({ id: "s-2", status: "working" as SessionStatus }), + ]; + + vi.mocked(mockSessionManager.list).mockResolvedValue(sessions); + + config.reactions = { + "all-complete": { auto: true, action: "notify" }, + }; + + const lm = createLifecycleManager({ + config, + registry: registryWithNotifier, + sessionManager: mockSessionManager, + }); + + lm.start(60_000); + await vi.advanceTimersByTimeAsync(0); + + // all-complete should NOT have fired — "working" is still active + const allCompleteNotifications = vi.mocked(notifier.notify).mock.calls.filter( + (call: unknown[]) => { + const event = call[0] as Record | undefined; + const data = event?.data as Record | undefined; + return event?.type === "reaction.triggered" && data?.reactionKey === "all-complete"; + }, + ); + expect(allCompleteNotifications).toHaveLength(0); + + lm.stop(); + }); + + it("skips polling sessions in terminal statuses like done or errored", async () => { + // Sessions in "done" / "errored" should not be polled + const sessions = [ + makeSession({ id: "s-done", status: "done" as SessionStatus }), + makeSession({ id: "s-errored", status: "errored" as SessionStatus }), + ]; + + vi.mocked(mockSessionManager.list).mockResolvedValue(sessions); + + // If these sessions were polled, determineStatus would call runtime.isAlive. + // Reset call count and verify it's not called. + vi.mocked(plugins.runtime.isAlive).mockClear(); + + const lm = createLifecycleManager({ + config, + registry: mockRegistry, + sessionManager: mockSessionManager, + }); + + lm.start(60_000); + await vi.advanceTimersByTimeAsync(0); + + // Terminal sessions should not be polled — runtime.isAlive should not be called + expect(plugins.runtime.isAlive).not.toHaveBeenCalled(); + + lm.stop(); + }); +}); + describe("getStates", () => { it("returns copy of states map", async () => { const lm = setupCheck("app-1", { diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 19a262b3c..28663df57 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -15,6 +15,7 @@ import { SESSION_STATUS, PR_STATE, CI_STATUS, + TERMINAL_STATUSES, type LifecycleManager, type SessionManager, type SessionId, @@ -547,7 +548,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan 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, { @@ -728,7 +729,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; } @@ -806,7 +807,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; }); @@ -830,7 +831,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; From 6bf098f480ec721ddc7b5c0cde26f7627a1c28ff Mon Sep 17 00:00:00 2001 From: Harsh Batheja Date: Mon, 30 Mar 2026 02:59:45 +0530 Subject: [PATCH 02/39] fix: start terminal WebSocket servers in `ao dashboard` dev mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ao dashboard` only spawned `next dev`, missing the terminal and direct-terminal WebSocket servers. This meant the live terminal in the dashboard never connected — the same bug that `ao start` didn't have because it uses `pnpm run dev` (which runs all three via concurrently). Detect dev mode (monorepo has `server/` dir) and use `pnpm run dev` instead of bare `next dev`, matching the behavior of `ao start`. --- packages/cli/src/commands/dashboard.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index cd61ef62a..8f494c77d 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -1,4 +1,6 @@ import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; import chalk from "chalk"; import type { Command } from "commander"; import { loadConfig } from "@composio/ao-core"; @@ -58,11 +60,21 @@ export function registerDashboard(program: Command): void { config.directTerminalPort, ); - const child = spawn("npx", ["next", "dev", "-p", String(port)], { - cwd: webDir, - stdio: ["inherit", "inherit", "pipe"], - env, - }); + // In dev mode (monorepo), use `pnpm run dev` which starts Next.js AND + // the terminal WebSocket servers via concurrently. Without the WS servers, + // the live terminal in the dashboard won't work. + const isDevMode = existsSync(resolve(webDir, "server")); + const child = isDevMode + ? spawn("pnpm", ["run", "dev"], { + cwd: webDir, + stdio: ["inherit", "inherit", "pipe"], + env, + }) + : spawn("npx", ["next", "dev", "-p", String(port)], { + cwd: webDir, + stdio: ["inherit", "inherit", "pipe"], + env, + }); const stderrChunks: string[] = []; From 58266a96e3f5fc907b76b3c085a0cffb87689d8d Mon Sep 17 00:00:00 2001 From: yyovil Date: Wed, 1 Apr 2026 22:30:22 +0530 Subject: [PATCH 03/39] Fixed ao's version mismatch problem --- packages/cli/src/index.ts | 3 ++- packages/cli/tsconfig.json | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8fb960663..3d3855800 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Command } from "commander"; +import packageJson from "../package.json" with { type: "json" }; import { registerInit } from "./commands/init.js"; import { registerStatus } from "./commands/status.js"; import { registerSpawn, registerBatchSpawn } from "./commands/spawn.js"; @@ -23,7 +24,7 @@ const program = new Command(); program .name("ao") .description("Agent Orchestrator — manage parallel AI coding agents") - .version("0.1.0"); + .version(packageJson.version); registerInit(program); registerStart(program); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 5a24989cd..aaf63f505 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -1,6 +1,8 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src" }, From d1e81e156763efdf8cbd0879f7e6903fa3e1215f Mon Sep 17 00:00:00 2001 From: yyovil Date: Wed, 1 Apr 2026 22:41:38 +0530 Subject: [PATCH 04/39] added the shield badge for current version --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4d67f1c3f..22d8f0920 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Spawn parallel AI coding agents, each in its own git worktree. Agents autonomously fix CI failures, address review comments, and open PRs — you supervise from one dashboard. [![GitHub stars](https://img.shields.io/github/stars/ComposioHQ/agent-orchestrator?style=flat-square)](https://github.com/ComposioHQ/agent-orchestrator/stargazers) +[![npm version](https://img.shields.io/npm/v/%40composio%2Fao?style=flat-square)](https://www.npmjs.com/package/@composio/ao) [![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE) [![PRs merged](https://img.shields.io/badge/PRs_merged-61-brightgreen?style=flat-square)](https://github.com/ComposioHQ/agent-orchestrator/pulls?q=is%3Amerged) [![Tests](https://img.shields.io/badge/test_cases-3%2C288-blue?style=flat-square)](https://github.com/ComposioHQ/agent-orchestrator/releases/tag/metrics-v1) From c4cd7c709848c70d423a321331f2f2108fbbbb87 Mon Sep 17 00:00:00 2001 From: Yyovil Date: Wed, 1 Apr 2026 23:37:37 +0530 Subject: [PATCH 05/39] Apply suggestion from @Copilot node16 will do the trick Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/cli/tsconfig.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index aaf63f505..5a24989cd 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -1,8 +1,6 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "module": "NodeNext", - "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src" }, From 2b8c7e8d9a318b0dfa13d472a5a80397e44b60e8 Mon Sep 17 00:00:00 2001 From: yyovil Date: Thu, 2 Apr 2026 00:08:50 +0530 Subject: [PATCH 06/39] Reverted back to createRequire method instead of static imports in ESM because of compatibility issues with node < v20.10.0 --- packages/cli/src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3d3855800..d721b19b8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { Command } from "commander"; -import packageJson from "../package.json" with { type: "json" }; +import { createRequire } from "node:module"; import { registerInit } from "./commands/init.js"; import { registerStatus } from "./commands/status.js"; import { registerSpawn, registerBatchSpawn } from "./commands/spawn.js"; @@ -19,6 +19,8 @@ import { registerSetup } from "./commands/setup.js"; import { registerPlugin } from "./commands/plugin.js"; import { getConfigInstruction } from "./lib/config-instruction.js"; +const require = createRequire(import.meta.url); +const packageJson = require("../package.json") as { version: string }; const program = new Command(); program From 76b5e42c6fc145bc6fcb05796bc03f1f25e8c3c3 Mon Sep 17 00:00:00 2001 From: yyovil Date: Thu, 2 Apr 2026 01:02:28 +0530 Subject: [PATCH 07/39] added missing tests for added loc --- packages/cli/__tests__/options/version.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 packages/cli/__tests__/options/version.test.ts diff --git a/packages/cli/__tests__/options/version.test.ts b/packages/cli/__tests__/options/version.test.ts new file mode 100644 index 000000000..71e2cc140 --- /dev/null +++ b/packages/cli/__tests__/options/version.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import packageJson from "../../package.json" with { type: "json" }; + +describe("ao --version", () => { + it("matches the CLI package version", () => { + const tsxEntry = fileURLToPath(new URL("../../node_modules/.bin/tsx", import.meta.url)); + const cliEntry = fileURLToPath(new URL("../../src/index.ts", import.meta.url)); + const output = execFileSync(tsxEntry, [cliEntry, "--version"], { encoding: "utf8" }).trim(); + + expect(output).toBe(packageJson.version); + }); +}); From 377d312a843f26fb2d8ce702cd6df5edaf77d239 Mon Sep 17 00:00:00 2001 From: Dhruv Sharma Date: Thu, 2 Apr 2026 11:21:43 +0530 Subject: [PATCH 08/39] ci: fix VPS deploy guard and fingerprint config --- .github/workflows/deploy-vps.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-vps.yml b/.github/workflows/deploy-vps.yml index 911622500..f42e3d7bc 100644 --- a/.github/workflows/deploy-vps.yml +++ b/.github/workflows/deploy-vps.yml @@ -19,6 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Verify SHA is current tip of main + id: verify_sha env: GH_TOKEN: ${{ github.token }} run: | @@ -26,17 +27,21 @@ jobs: 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 "should_deploy=false" >> "$GITHUB_OUTPUT" exit 0 fi echo "SHA verified: $DEPLOY_SHA is the current tip of main." + echo "should_deploy=true" >> "$GITHUB_OUTPUT" - name: Deploy via SSH + if: steps.verify_sha.outputs.should_deploy == 'true' uses: appleboy/ssh-action@v1.2.2 with: host: ${{ secrets.VPS_HOST }} username: root key: ${{ secrets.VPS_SSH_KEY }} - fingerprint: SHA256:Yat7fObKrTBZGxr0HGlN40oWhC/LlLN9SpjzPifsLzw + # Optional: set VPS_HOST_FINGERPRINT to enforce host key pinning. + fingerprint: ${{ secrets.VPS_HOST_FINGERPRINT }} script: | set -e DEPLOY_SHA="${{ github.event.workflow_run.head_sha }}" From 53ef778357853295f32e7314d1ba4d5dc180cf83 Mon Sep 17 00:00:00 2001 From: Harsh Batheja <40922251+harsh-batheja@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:23:08 +0530 Subject: [PATCH 09/39] feat: add CI failure detail notifications in lifecycle manager (#850) * feat: add CI failure detail notifications in lifecycle manager When CI fails on a PR, the lifecycle manager now fetches individual check details (names, statuses, URLs) and sends them to the worker session. This complements the existing static reaction message with actionable debugging information. Flow: - On first transition to ci_failed: static reaction message fires (existing) - On next poll: detailed CI failure info with check names and URLs dispatched - Fingerprinting prevents re-sending the same failure set - New/changed failures trigger fresh detailed notifications - Tracking metadata cleared when PR is merged/closed or CI passes Follows the same deduplication pattern as maybeDispatchReviewBacklog(). * fix: send CI details directly to avoid consuming reaction retry budget The detailed CI failure dispatch now uses sessionManager.send() directly instead of executeReaction(), so it doesn't increment the ci-failed reaction tracker. This prevents low retries/escalateAfter settings from causing premature escalation before the agent receives failure details. The transition reaction still owns escalation; the detailed dispatch is purely informational follow-up delivery. * feat: add merge conflict notifications in lifecycle manager Adds maybeDispatchMergeConflicts() that detects merge conflicts from the PR enrichment cache or getMergeability() and notifies the worker session. Conflicts are dispatched independently of session status since they can coexist with ci_failed, changes_requested, etc. - Uses the existing merge-conflicts reaction config - Dispatches once per conflict occurrence (tracks lastMergeConflictDispatched) - Clears tracking when conflicts resolve, allowing re-dispatch if they recur - Sends directly via sessionManager.send() (same pattern as CI details) * fix: use CICheck type instead of inline type declaration Replace inline Array<{ name, status, url, conclusion }> with the existing CICheck type from ./types.js for formatCIFailureMessage and the checks variable in maybeDispatchCIFailureDetails. * fix: resolve no-useless-assignment lint error in merge conflict check Declare hasConflicts without initial value since both branches of the if/else assign to it before it's read. * feat: show agent notification state in session page blockers The blockers section on both SessionDetail (IssuesList) and SessionCard (alert pills) now shows whether the agent has been notified about each blocker. Reads lifecycle manager dispatch metadata: - lastCIFailureDispatchHash for CI failures - lastMergeConflictDispatched for merge conflicts - lastPendingReviewDispatchHash for review comments Displays "agent notified" indicator next to blockers where the lifecycle manager has already forwarded the issue to the worker session. * fix: use lifecycle status as fallback for blockers when PR data is stale The blockers section now uses the lifecycle manager's session status metadata as a source of truth when PR enrichment data hasn't caught up. PR enrichment uses a 5-min cache and can timeout or be rate-limited, causing blockers to show stale/incorrect state. Changes: - IssuesList and getAlerts now check metadata["status"] (lifecycle manager state) alongside PR enrichment data - CI failing: shown when pr.ciStatus is "failing" OR lifecycle status is "ci_failed" - Changes requested: shown when pr.reviewDecision matches OR lifecycle status is "changes_requested" - Merge conflicts: shown when pr.mergeability.noConflicts is false OR lifecycle dispatch metadata indicates conflicts were detected * fix: fall back to getMergeability when cached hasConflicts is undefined When PREnrichmentData has hasConflicts as undefined (the field is typed as boolean | undefined), the previous check treated it as no conflicts. Now falls through to the getMergeability() call instead. * test: add coverage for CI/conflict notify action and recovery paths - Test CI tracking clears when CI recovers to passing - Test notify action for CI failure details (human notification path) - Test notify action for merge conflicts (human notification path) These cover the previously uncovered notify action branches and the CI recovery cleanup path in the lifecycle manager. * fix: resolve typecheck error in CI recovery test writeMetadata requires SessionMetadata type which doesn't include custom keys like lastCIFailureFingerprint. Rewrote the test to use setupCheck and let the lifecycle manager set tracking metadata naturally through the CI failure flow, then verify cleanup on recovery. * fix: don't use dispatch metadata for conflict detection in UI lastMergeConflictDispatched lingers after conflicts resolve until the lifecycle manager's next poll clears it. Using it as a conflict signal caused stale "merge conflict" alerts. Now only pr.mergeability.noConflicts drives conflict detection; the metadata is only used for the "agent notified" badge. * fix: use Promise.allSettled for dispatch functions to avoid orphaned rejections Promise.all rejects immediately on first failure, leaving in-flight promises unmonitored. Promise.allSettled waits for all to complete. --- .../src/__tests__/lifecycle-manager.test.ts | 550 ++++++++++++++++++ packages/core/src/lifecycle-manager.ts | 249 +++++++- packages/web/src/components/SessionCard.tsx | 40 +- packages/web/src/components/SessionDetail.tsx | 50 +- 4 files changed, 874 insertions(+), 15 deletions(-) diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 37580ca1d..f07c7a59e 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -749,6 +749,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") }); diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index b58d383a1..678619b43 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -33,6 +33,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"; @@ -867,6 +868,248 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan } } + /** + * Format CI check failures into a human-readable message for the agent. + * Includes check names, statuses, and links for debugging. + */ + function formatCIFailureMessage(failedChecks: CICheck[]): string { + const lines = [ + "CI checks are failing on your PR. Here are the failed checks:", + "", + ]; + for (const check of failedChecks) { + const status = check.conclusion ?? check.status; + const link = check.url ? ` — ${check.url}` : ""; + lines.push(`- **${check.name}**: ${status}${link}`); + } + lines.push( + "", + "Investigate the failures, fix the issues, and push again.", + ); + return lines.join("\n"); + } + + /** + * Dispatch CI failure details to the agent session when new or changed + * failures are detected. Follows the same fingerprinting/deduplication + * pattern as maybeDispatchReviewBacklog(). + */ + async function maybeDispatchCIFailureDetails( + session: Session, + _oldStatus: SessionStatus, + newStatus: SessionStatus, + transitionReaction?: { key: string; result: ReactionResult | null }, + ): Promise { + const project = config.projects[session.projectId]; + if (!project || !session.pr) return; + + const scm = project.scm ? registry.get("scm", project.scm.plugin) : null; + if (!scm) return; + + const ciReactionKey = "ci-failed"; + + // Clear tracking when PR is closed/merged + if (newStatus === "merged" || newStatus === "killed") { + clearReactionTracker(session.id, ciReactionKey); + updateSessionMetadata(session, { + lastCIFailureFingerprint: "", + lastCIFailureDispatchHash: "", + lastCIFailureDispatchAt: "", + }); + return; + } + + // Only dispatch CI details when in ci_failed state + if (newStatus !== "ci_failed") { + // CI is no longer failing — clear tracking so next failure is dispatched fresh + const lastFingerprint = session.metadata["lastCIFailureFingerprint"] ?? ""; + if (lastFingerprint) { + clearReactionTracker(session.id, ciReactionKey); + updateSessionMetadata(session, { + lastCIFailureFingerprint: "", + lastCIFailureDispatchHash: "", + lastCIFailureDispatchAt: "", + }); + } + return; + } + + // Fetch individual CI checks for failure details + let checks: CICheck[]; + try { + checks = await scm.getCIChecks(session.pr); + } catch { + // Failed to fetch checks — skip this cycle + return; + } + + const failedChecks = checks.filter( + (c) => c.status === "failed" || c.conclusion?.toUpperCase() === "FAILURE", + ); + if (failedChecks.length === 0) return; + + const ciFingerprint = makeFingerprint( + failedChecks.map((c) => `${c.name}:${c.status}:${c.conclusion ?? ""}`), + ); + const lastCIFingerprint = session.metadata["lastCIFailureFingerprint"] ?? ""; + const lastCIDispatchHash = session.metadata["lastCIFailureDispatchHash"] ?? ""; + + // Reset reaction tracker when failure set changes + if (ciFingerprint !== lastCIFingerprint && transitionReaction?.key !== ciReactionKey) { + clearReactionTracker(session.id, ciReactionKey); + } + if (ciFingerprint !== lastCIFingerprint) { + updateSessionMetadata(session, { + lastCIFailureFingerprint: ciFingerprint, + }); + } + + // If transition already sent a ci-failed reaction with the static message, + // skip this cycle but do NOT record dispatch hash — the next poll will send + // the detailed CI failure info with check names and URLs. + if ( + transitionReaction?.key === ciReactionKey && + transitionReaction.result?.success + ) { + return; + } + + // Skip if we already dispatched this exact failure set + if (ciFingerprint === lastCIDispatchHash) return; + + // Dispatch CI failure details directly via sessionManager.send() rather than + // executeReaction() to avoid consuming the ci-failed reaction's retry budget. + // The transition reaction owns escalation; this is a follow-up info delivery. + const reactionConfig = getReactionConfigForSession(session, ciReactionKey); + if ( + reactionConfig && + reactionConfig.action && + (reactionConfig.auto !== false || reactionConfig.action === "notify") + ) { + const detailedMessage = formatCIFailureMessage(failedChecks); + + try { + if (reactionConfig.action === "send-to-agent") { + await sessionManager.send(session.id, detailedMessage); + } else { + // For "notify" action, send to human notifiers instead + const event = createEvent("ci.failing", { + sessionId: session.id, + projectId: session.projectId, + message: detailedMessage, + data: { failedChecks: failedChecks.map((c) => c.name) }, + }); + await notifyHuman(event, reactionConfig.priority ?? "warning"); + } + + updateSessionMetadata(session, { + lastCIFailureDispatchHash: ciFingerprint, + lastCIFailureDispatchAt: new Date().toISOString(), + }); + } catch { + // Send failed — will retry on next poll cycle + } + } + } + + /** + * Dispatch merge conflict notifications to the agent session. + * Conflicts are detected from the PR enrichment cache or getMergeability() + * and dispatched independently of the session status (conflicts can coexist + * with ci_failed, changes_requested, etc.). + */ + async function maybeDispatchMergeConflicts( + session: Session, + newStatus: SessionStatus, + ): Promise { + const project = config.projects[session.projectId]; + if (!project || !session.pr) return; + + const scm = project.scm ? registry.get("scm", project.scm.plugin) : null; + if (!scm) return; + + const conflictReactionKey = "merge-conflicts"; + + // Clear tracking when PR is closed/merged + if (newStatus === "merged" || newStatus === "killed") { + clearReactionTracker(session.id, conflictReactionKey); + updateSessionMetadata(session, { + lastMergeConflictDispatched: "", + }); + return; + } + + // Only check for conflicts on open PRs + if ( + newStatus !== "pr_open" && + newStatus !== "ci_failed" && + newStatus !== "review_pending" && + newStatus !== "changes_requested" && + newStatus !== "approved" && + newStatus !== "mergeable" + ) { + return; + } + + // Check for conflicts using cached enrichment data or fallback to individual call + const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`; + const cachedData = prEnrichmentCache.get(prKey); + + let hasConflicts: boolean; + if (cachedData && cachedData.hasConflicts !== undefined) { + hasConflicts = cachedData.hasConflicts; + } else { + try { + const mergeReadiness = await scm.getMergeability(session.pr); + hasConflicts = !mergeReadiness.noConflicts; + } catch { + return; + } + } + + const lastDispatched = session.metadata["lastMergeConflictDispatched"] ?? ""; + + if (hasConflicts) { + // Already dispatched for current conflict state — skip + if (lastDispatched === "true") return; + + const reactionConfig = getReactionConfigForSession(session, conflictReactionKey); + if ( + reactionConfig && + reactionConfig.action && + (reactionConfig.auto !== false || reactionConfig.action === "notify") + ) { + try { + if (reactionConfig.action === "send-to-agent") { + const message = + reactionConfig.message ?? + "Your branch has merge conflicts. Rebase on the default branch and resolve them."; + await sessionManager.send(session.id, message); + } else { + const event = createEvent("merge.conflicts", { + sessionId: session.id, + projectId: session.projectId, + message: `${session.id}: PR has merge conflicts`, + }); + await notifyHuman(event, reactionConfig.priority ?? "warning"); + } + + updateSessionMetadata(session, { + lastMergeConflictDispatched: "true", + }); + } catch { + // Send failed — will retry on next poll cycle + } + } + } else if (lastDispatched === "true") { + // Conflicts resolved — clear so we can re-dispatch if they recur + clearReactionTracker(session.id, conflictReactionKey); + updateSessionMetadata(session, { + lastMergeConflictDispatched: "", + }); + } + } + /** Send a notification to all configured notifiers. */ async function notifyHuman(event: OrchestratorEvent, priority: EventPriority): Promise { const eventWithPriority = { ...event, priority }; @@ -972,7 +1215,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. */ diff --git a/packages/web/src/components/SessionCard.tsx b/packages/web/src/components/SessionCard.tsx index c893558f0..50061acf9 100644 --- a/packages/web/src/components/SessionCard.tsx +++ b/packages/web/src/components/SessionCard.tsx @@ -537,6 +537,9 @@ function SessionCardView({ session, onSend, onKill, onMerge, onRestore }: Sessio )} {alert.label} + {alert.notified && ( + · notified + )} {alert.actionLabel && (