feat(lifecycle): send actionable CI failure details

Prefer SCM CI failure summaries when composing ci-failed agent messages, including failed job/step, run URL, and log tail. Fall back to check names/statuses for SCM plugins without getCIFailureSummary, and remove the generic default ci-failed text so default handling relies on the lifecycle-composed payload.

State invariants preserved: this only changes ci-failed reaction payload composition and dispatch hashing. It does not change lifecycle status decisions, state-machine transitions, ci-failed persistence, retry/escalation thresholds, or stable-passing tracker reset semantics.
This commit is contained in:
i-trytoohard 2026-05-12 17:21:22 +05:30
parent 2df8b66b9c
commit c854183090
3 changed files with 145 additions and 25 deletions

View File

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

View File

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

View File

@ -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<void> {
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<string> {
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<void> {
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>("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",