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:
parent
2df8b66b9c
commit
c854183090
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue