diff --git a/python/robomp/web/src/work-items.test.ts b/python/robomp/web/src/work-items.test.ts index b5ea0a98e..49f4ce026 100644 --- a/python/robomp/web/src/work-items.test.ts +++ b/python/robomp/web/src/work-items.test.ts @@ -497,4 +497,77 @@ describe("buildWorkItems", () => { expect(items.map(i => i.deliveryId)).toEqual(["run-valid-ts", "run-invalid-ts"]); }); + test("live running event outranks a newer failed latest_event for the same issue", () => { + const items = buildWorkItems( + status({ + issues: [ + issue({ + key: "owner/repo#20", + number: 20, + latest_event: latestEvent({ + delivery_id: "failed-newer", + state: "failed", + received_at: "2026-06-17T00:09:00Z", + last_error: "stale failure", + }), + }), + ], + running_events: [ + runningEvent({ + delivery_id: "live-older", + issue_key: "owner/repo#20", + received_at: "2026-06-17T00:01:00Z", + started_at: "2026-06-17T00:02:00Z", + }), + ], + }), + ); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + key: "owner/repo#20", + bucket: "running", + deliveryId: "live-older", + inflightOnly: false, + error: null, + }); + // Cancel button targets the live delivery, not the stale failed row. + expect(items[0].live?.delivery_id).toBe("live-older"); + // ActivityPill renders running, never the superseded failed state. + expect(items[0].latestEvent?.state).toBe("running"); + }); + + test("live running event outranks a newer done latest_event for the same issue", () => { + const items = buildWorkItems( + status({ + issues: [ + issue({ + key: "owner/repo#21", + number: 21, + latest_event: latestEvent({ + delivery_id: "done-newer", + state: "done", + received_at: "2026-06-17T00:09:00Z", + }), + }), + ], + running_events: [ + runningEvent({ + delivery_id: "live-older-2", + issue_key: "owner/repo#21", + received_at: "2026-06-17T00:01:00Z", + started_at: "2026-06-17T00:02:00Z", + }), + ], + }), + ); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + key: "owner/repo#21", + bucket: "running", + deliveryId: "live-older-2", + }); + expect(items[0].live?.delivery_id).toBe("live-older-2"); + expect(items[0].latestEvent?.state).toBe("running"); + }); + }); diff --git a/python/robomp/web/src/work-items.ts b/python/robomp/web/src/work-items.ts index b7e9556c0..b23e2fde3 100644 --- a/python/robomp/web/src/work-items.ts +++ b/python/robomp/web/src/work-items.ts @@ -76,11 +76,36 @@ export function buildWorkItems(status: StatusResponse): WorkItem[] { const live = runningByKey.get(key) ?? null; const inflightOnly = !live && inflightSet.has(key); const latest = issue.latest_event; + + // A matching live running_events entry is authoritative over the issue's + // own latest_event, which may be a newer failed/done row that the live run + // has not yet superseded. Render the live run so the card stays running, + // cancel-capable (deliveryId from the live delivery), and free of the + // stale failure. Non-live rows keep latest_event authority below. + if (live) { + items.push({ + key, + ref: { repo: issue.repo, number: issue.number }, + deliveryId: live.delivery_id, + issueState: issue.state, + classification: issue.classification, + branch: issue.branch, + prNumber: issue.pr_number, + latestEvent: latestEventFromRunning(live), + live, + inflightOnly: false, + bucket: "running", + error: null, + sortTs: parseTs(live.started_at ?? live.received_at ?? issue.updated_at), + }); + continue; + } + const latestState = latest?.state; const bucket: WorkBucket = latestState === "failed" ? "failed" - : live || inflightOnly || latestState === "running" + : inflightOnly || latestState === "running" ? "running" : latestState === "queued" ? "queued" @@ -95,11 +120,11 @@ export function buildWorkItems(status: StatusResponse): WorkItem[] { branch: issue.branch, prNumber: issue.pr_number, latestEvent: latest, - live, + live: null, inflightOnly, bucket, error: latestState === "failed" ? (latest?.last_error ?? null) : null, - sortTs: parseTs(live?.started_at ?? latest?.received_at ?? issue.updated_at), + sortTs: parseTs(latest?.received_at ?? issue.updated_at), }); } @@ -176,6 +201,20 @@ function latestEventFromRecent(event: RecentEvent): LatestEvent { }; } +// Synthesizes an ActivityPill-compatible latest event from a live running_events +// entry. State is pinned to "running" so a stale failed/done issue.latest_event +// cannot leak a terminal pill onto a card the live run still owns. +function latestEventFromRunning(event: RunningEvent): LatestEvent { + return { + delivery_id: event.delivery_id, + event_type: event.event_type, + state: "running", + attempts: event.attempts, + received_at: event.received_at, + last_error: null, + }; +} + function parseTs(value: string | null | undefined): number { const time = Date.parse(value ?? ""); return Number.isNaN(time) ? 0 : time;