[codex] Deduplicate pipeline automation health warnings (#9090)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Pipeline health reports give operators early warnings when a
workflow step cannot run cleanly.
> - Failed stage automation is surfaced as an `automation_failed` health
warning for the affected item.
> - A single item can have repeated failed automation rows for the same
stage, especially after retries or repeated failed attempts.
> - Rendering every matching row creates duplicate warnings that make
the pipeline look noisier than it is.
> - This pull request deduplicates failed automation warnings by the
item/stage pair before adding them to the health report.
> - The benefit is that repeated failures for the same item in the same
stage produce one actionable warning, while distinct items still remain
visible.

## Linked Issues or Issue Description

Refs #8866

Bug: pipeline health could emit duplicate `automation_failed` warnings
when the input contained repeated failed automation rows for the same
live item and stage. Reviewers should expect one warning per
`stageId:caseId` pair, not one warning per backing execution row.

## What Changed

- Deduplicated failed automation warnings with per-stage case tracking
in `computePipelineHealth`, avoiding collision-prone composite string
keys before pushing `automation_failed` warnings.
- Added shared Vitest coverage for a single automation failure,
duplicate same-stage same-item dedupe, separate warnings for different
item IDs in the same stage, the same item ID in different stages, and
colon-delimited ID collision cases.
- Kept pipeline route behavior unchanged; this PR only changes shared
warning rendering and direct shared tests.

## Verification

- `pnpm vitest packages/shared/src/pipeline-health.test.ts`
  - 1 test file passed
  - 5 tests passed

- PR #9090 remote checks on `bbbb2d4627f5be17ca210dedb9edb91edd047df8`
  - All Paperclip CI/status checks passed
- Greptile Confidence Score: 5/5, 0 comments added, 0 unresolved
Greptile threads

No route test changed because this PR does not change the route's
failed-automation query or normalization behavior.

## Risks

Low risk. The change only suppresses duplicate `automation_failed`
warnings when both `stageId` and `caseId` match. Distinct items in the
same stage still produce separate warnings.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5 coding agent in the Paperclip local adapter
environment; exact model snapshot and context-window metadata were not
exposed in the runtime. Tool use and code execution were enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-06 12:12:18 -05:00 committed by GitHub
parent 8516700217
commit e936ea3905
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 171 additions and 0 deletions

View File

@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import {
computePipelineHealth,
type PipelineHealthFailedAutomationInput,
type PipelineHealthInput,
} from "./pipeline-health.js";
describe("computePipelineHealth", () => {
const baseInput: PipelineHealthInput = {
pipelineId: "pipeline-1",
stages: [],
agentsById: {},
pipelinesById: {},
};
it("emits one warning per failed automation item and stage", () => {
const failure: PipelineHealthFailedAutomationInput = {
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
caseId: "case-1",
caseTitle: "Case 1",
error: "Routine timed out",
};
const report = computePipelineHealth({
...baseInput,
failedAutomations: [failure],
});
expect(report.warnings).toHaveLength(1);
expect(report.warnings[0]).toMatchObject({
code: "automation_failed",
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
href: "/pipelines/pipeline-1/items/case-1",
hrefLabel: "Open item",
message: `Automation failed on "Case 1". Open the item to inspect the log and retry it.`,
});
});
it("deduplicates duplicate failed automation rows for the same stage and case", () => {
const failure: PipelineHealthFailedAutomationInput = {
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
caseId: "case-1",
caseTitle: "Case 1",
error: "Routine timed out",
};
const duplicateFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
caseId: "case-1",
caseTitle: "Case 1",
error: "Routine timed out",
};
const report = computePipelineHealth({
...baseInput,
failedAutomations: [failure, duplicateFailure],
});
const automationWarnings = report.warnings.filter((warning) => warning.code === "automation_failed");
expect(automationWarnings).toHaveLength(1);
});
it("keeps separate warnings for different case IDs in the same stage", () => {
const firstFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
caseId: "case-1",
caseTitle: "Case 1",
error: "Routine timed out",
};
const secondFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
caseId: "case-2",
caseTitle: "Case 2",
error: "Routine timed out",
};
const report = computePipelineHealth({
...baseInput,
failedAutomations: [firstFailure, secondFailure],
});
const automationWarnings = report.warnings.filter((warning) => warning.code === "automation_failed");
expect(automationWarnings).toHaveLength(2);
expect(automationWarnings.map((warning) => warning.href)).toEqual([
"/pipelines/pipeline-1/items/case-1",
"/pipelines/pipeline-1/items/case-2",
]);
});
it("keeps separate warnings for the same case ID in different stages", () => {
const firstFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage-1",
stageKey: "build",
stageName: "Build",
caseId: "case-1",
caseTitle: "Case 1",
error: "Routine timed out",
};
const secondFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage-2",
stageKey: "verify",
stageName: "Verify",
caseId: "case-1",
caseTitle: "Case 1",
error: "Routine timed out",
};
const report = computePipelineHealth({
...baseInput,
failedAutomations: [firstFailure, secondFailure],
});
const automationWarnings = report.warnings.filter((warning) => warning.code === "automation_failed");
expect(automationWarnings).toHaveLength(2);
expect(automationWarnings.map((warning) => warning.stageId)).toEqual(["stage-1", "stage-2"]);
});
it("keeps separate warnings when stage and case IDs would collide with colon-delimited keys", () => {
const firstFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage:one",
stageKey: "build",
stageName: "Build",
caseId: "case",
caseTitle: "Case 1",
error: "Routine timed out",
};
const secondFailure: PipelineHealthFailedAutomationInput = {
stageId: "stage",
stageKey: "verify",
stageName: "Verify",
caseId: "one:case",
caseTitle: "Case 2",
error: "Routine timed out",
};
const report = computePipelineHealth({
...baseInput,
failedAutomations: [firstFailure, secondFailure],
});
const automationWarnings = report.warnings.filter((warning) => warning.code === "automation_failed");
expect(automationWarnings).toHaveLength(2);
expect(automationWarnings.map((warning) => warning.href)).toEqual([
"/pipelines/pipeline-1/items/case",
"/pipelines/pipeline-1/items/one:case",
]);
});
});

View File

@ -339,7 +339,15 @@ export function computePipelineHealth(input: PipelineHealthInput): PipelineHealt
// the pipeline; a blank default in settings is a normal configuration.
}
const seenFailedAutomationCaseIdsByStage = new Map<string, Set<string>>();
for (const failure of input.failedAutomations ?? []) {
let seenCaseIds = seenFailedAutomationCaseIdsByStage.get(failure.stageId);
if (!seenCaseIds) {
seenCaseIds = new Set<string>();
seenFailedAutomationCaseIdsByStage.set(failure.stageId, seenCaseIds);
}
if (seenCaseIds.has(failure.caseId)) continue;
seenCaseIds.add(failure.caseId);
warnings.push({
code: "automation_failed",
stageId: failure.stageId,