diff --git a/packages/core/src/__tests__/lifecycle-manager.test.ts b/packages/core/src/__tests__/lifecycle-manager.test.ts index 0f304dc21..3ae2a06aa 100644 --- a/packages/core/src/__tests__/lifecycle-manager.test.ts +++ b/packages/core/src/__tests__/lifecycle-manager.test.ts @@ -2365,7 +2365,64 @@ describe("reactions", () => { expect(sentMessage).toContain("Potential issue detected"); }); - it("dispatches CI failure details with check names and URLs on subsequent polls", async () => { + it("dispatches CI failure summary with failed step and log tail", async () => { + config.reactions = { + "ci-failed": { + auto: true, + action: "send-to-agent", + retries: 3, + escalateAfter: 3, + }, + }; + + const ciChecks = [ + { + name: "build", + status: "failed", + url: "https://github.com/org/repo/actions/runs/123/job/456", + conclusion: "FAILURE", + }, + ]; + const mockSCM = createMockSCM({ + getCISummary: vi.fn().mockResolvedValue("failing"), + getCIFailureSummary: vi.fn().mockResolvedValue({ + failedJobs: [ + { + name: "build", + failedStep: "Run pnpm test", + runUrl: "https://github.com/org/repo/actions/runs/123/job/456", + logTail: + "AssertionError: expected true to be false\nProcess completed with exit code 1", + }, + ], + }), + enrichSessionsPRBatch: mockBatchEnrichment({ ciStatus: "failing", ciChecks }), + }); + 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); + const sentMessage = vi.mocked(mockSessionManager.send).mock.calls[0]![1]; + expect(sentMessage).toContain("CI is failing on your PR."); + expect(sentMessage).toContain("Failed: build → Run pnpm test"); + expect(sentMessage).toContain("Run: https://github.com/org/repo/actions/runs/123/job/456"); + expect(sentMessage).toContain("Log tail (last 2 lines):"); + expect(sentMessage).toContain("AssertionError: expected true to be false"); + expect(sentMessage).toContain("Fix the issues and push again."); + }); + + it("falls back to check names and URLs when SCM lacks getCIFailureSummary", async () => { config.reactions = { "ci-failed": { auto: true, @@ -2419,6 +2476,7 @@ describe("reactions", () => { expect(lm.getStates().get("app-1")).toBe("ci_failed"); expect(mockSessionManager.send).toHaveBeenCalledTimes(1); const sentMessage = vi.mocked(mockSessionManager.send).mock.calls[0]![1]; + expect(sentMessage).toContain("CI checks are failing on your PR."); expect(sentMessage).toContain("lint"); expect(sentMessage).toContain("typecheck"); expect(sentMessage).toContain("https://github.com/org/repo/actions/runs/123"); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 17b77505b..37c59d3ce 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -669,8 +669,6 @@ function applyDefaultReactions(config: OrchestratorConfig): OrchestratorConfig { "ci-failed": { auto: true, action: "send-to-agent", - message: - "CI is failing on your PR. Investigate the failures, fix the issues, and push again.", retries: 2, escalateAfter: 2, }, diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index d8562fe4c..25823d5bd 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -39,6 +39,8 @@ import { type ProjectConfig as _ProjectConfig, type PREnrichmentData, type CICheck, + type CIFailureSummary, + type PRInfo, type ReviewComment, type ReviewSummary, type ProcessProbeResult, @@ -109,6 +111,12 @@ const PERSISTENT_REACTION_KEYS = new Set(["ci-failed"]); * next real CI failure incident. */ const CI_PASSING_STABLE_THRESHOLD = 2; +type TransitionReaction = { + key: string; + result: ReactionResult | null; + messageEnriched?: boolean; +}; + type WorkspaceBranchProbe = | { kind: "branch"; branch: string } | { kind: "detached" } @@ -1644,7 +1652,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan session: Session, _oldStatus: SessionStatus, newStatus: SessionStatus, - transitionReaction?: { key: string; result: ReactionResult | null }, + transitionReaction?: TransitionReaction, ): Promise { const project = config.projects[session.projectId]; if (!project || !session.pr) return; @@ -1919,11 +1927,32 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return lines.join("\n"); } - /** - * 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 { + function isFailedCICheck(check: CICheck): boolean { + return check.status === "failed" || check.conclusion?.toUpperCase() === "FAILURE"; + } + + function formatCIFailureSummaryMessage(summary: CIFailureSummary): string { + const lines = ["CI is failing on your PR.", ""]; + + for (const job of summary.failedJobs) { + const failed = job.failedStep ? `${job.name} → ${job.failedStep}` : job.name; + lines.push(`Failed: ${failed}`); + lines.push(`Run: ${job.runUrl}`); + + if (job.logTail) { + const lineCount = job.logTail.split(/\r?\n/).length; + const lineLabel = lineCount === 1 ? "line" : "lines"; + lines.push("", `Log tail (last ${lineCount} ${lineLabel}):`, "```", job.logTail, "```"); + } + + lines.push(""); + } + + lines.push("Fix the issues and push again."); + return lines.join("\n"); + } + + function formatCIFailureChecksFallback(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; @@ -1934,11 +1963,35 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan return lines.join("\n"); } + /** + * Format CI failures into a human-readable message for the agent. + * Uses SCM-provided failed job/step/log details when available and falls + * back to check names/statuses/links for SCM plugins that do not implement it. + */ + async function formatCIFailureMessage( + scm: SCM, + pr: PRInfo, + failedChecks: CICheck[], + ): Promise { + if (scm.getCIFailureSummary) { + try { + const summary = await scm.getCIFailureSummary(pr); + if (summary?.failedJobs.length) { + return formatCIFailureSummaryMessage(summary); + } + } catch { + // Fall back to check names when summary enrichment fails. + } + } + + return formatCIFailureChecksFallback(failedChecks); + } + async function maybeDispatchCIFailureDetails( session: Session, _oldStatus: SessionStatus, newStatus: SessionStatus, - transitionReaction?: { key: string; result: ReactionResult | null }, + transitionReaction?: TransitionReaction, ): Promise { const project = config.projects[session.projectId]; if (!project || !session.pr) return; @@ -1992,9 +2045,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan } } - const failedChecks = checks.filter( - (c) => c.status === "failed" || c.conclusion?.toUpperCase() === "FAILURE", - ); + const failedChecks = checks.filter(isFailedCICheck); if (failedChecks.length === 0) return; const ciFingerprint = makeFingerprint( @@ -2013,12 +2064,14 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan }); } - // If the transition reaction already sent a ci-failed reaction (now enriched - // with detailed check info from the batch cache), record the dispatch hash so - // subsequent polls don't re-send the same failure details. + // If the transition reaction already delivered an enriched agent message, + // or handled a non-agent action, record the dispatch hash so subsequent + // polls don't re-send the same failure details. if ( transitionReaction?.key === ciReactionKey && - transitionReaction.result?.success + transitionReaction.result?.success && + (transitionReaction.messageEnriched === true || + transitionReaction.result.action !== "send-to-agent") ) { updateSessionMetadata(session, { lastCIFailureDispatchHash: ciFingerprint, @@ -2039,7 +2092,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan reactionConfig.action && (reactionConfig.auto !== false || reactionConfig.action === "notify") ) { - const detailedMessage = formatCIFailureMessage(failedChecks); + const detailedMessage = await formatCIFailureMessage(scm, session.pr, failedChecks); try { if (reactionConfig.action === "send-to-agent") { @@ -2335,7 +2388,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan } const newStatus = assessment.status; const lifecycleChanged = session.metadata["lifecycle"] !== JSON.stringify(session.lifecycle); - let transitionReaction: { key: string; result: ReactionResult | null } | undefined; + let transitionReaction: TransitionReaction | undefined; const nextLifecycleEvidence = assessment.evidence; const nextDetectingAttempts = @@ -2473,18 +2526,29 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan if (reactionKey) { let reactionConfig = getReactionConfigForSession(session, reactionKey); + let messageEnriched = false; - // Enrich CI failure message with actual check details from batch cache - if (reactionKey === "ci-failed" && session.pr && reactionConfig) { + // Enrich CI failure message with failed job/step/log details when + // batch check data is already available. If it is not, the + // post-transition CI dispatcher below fetches checks and sends the + // composed message without altering lifecycle state transitions. + if ( + reactionKey === "ci-failed" && + session.pr && + reactionConfig?.action === "send-to-agent" + ) { const prKey = `${session.pr.owner}/${session.pr.repo}#${session.pr.number}`; const cachedData = prEnrichmentCache.get(prKey); - if (cachedData?.ciChecks) { - const failedChecks = cachedData.ciChecks.filter((c) => c.status === "failed"); + const project = config.projects[session.projectId]; + const scm = project?.scm?.plugin ? registry.get("scm", project.scm.plugin) : null; + if (cachedData?.ciChecks && scm) { + const failedChecks = cachedData.ciChecks.filter(isFailedCICheck); if (failedChecks.length > 0) { reactionConfig = { ...reactionConfig, - message: formatCIFailureMessage(failedChecks), + message: await formatCIFailureMessage(scm, session.pr, failedChecks), }; + messageEnriched = true; } } } @@ -2493,7 +2557,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan // auto: false skips automated agent actions but still allows notifications if (reactionConfig.auto !== false || reactionConfig.action === "notify") { const reactionResult = await executeReaction(session, reactionKey, reactionConfig); - transitionReaction = { key: reactionKey, result: reactionResult }; + transitionReaction = { key: reactionKey, result: reactionResult, messageEnriched }; observer.recordOperation({ metric: "lifecycle_poll", operation: "lifecycle.transition.reaction",