fix(robomp): prefer live events over stale latest rows
This commit is contained in:
@@ -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");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user