[codex] Add runtime lifecycle recovery and live issue visibility (#4419)

This commit is contained in:
Dotta 2026-04-24 15:50:32 -05:00 committed by GitHub
parent 9a8d219949
commit 5a0c1979cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
121 changed files with 9625 additions and 2044 deletions

View file

@ -147,6 +147,10 @@ function makeRun(id: string, status: HeartbeatRun["status"], createdAt: string,
logBytes: null,
logSha256: null,
logCompressed: false,
lastOutputAt: null,
lastOutputSeq: 0,
lastOutputStream: null,
lastOutputBytes: null,
errorCode: null,
externalRunId: null,
processPid: null,
@ -837,6 +841,7 @@ describe("inbox helpers", () => {
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
hideRoutineExecutions: true,
},
}).map((issue) => issue.id),
@ -856,6 +861,7 @@ describe("inbox helpers", () => {
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
hideRoutineExecutions: true,
},
}),
@ -875,6 +881,7 @@ describe("inbox helpers", () => {
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
hideRoutineExecutions: true,
},
}),
@ -940,6 +947,7 @@ describe("inbox helpers", () => {
labels: ["label-1"],
projects: ["project-1"],
workspaces: ["workspace-1"],
liveOnly: true,
hideRoutineExecutions: false,
},
});
@ -954,6 +962,7 @@ describe("inbox helpers", () => {
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
hideRoutineExecutions: true,
},
});
@ -969,6 +978,7 @@ describe("inbox helpers", () => {
labels: ["label-1"],
projects: ["project-1"],
workspaces: ["workspace-1"],
liveOnly: true,
hideRoutineExecutions: false,
},
});
@ -983,6 +993,7 @@ describe("inbox helpers", () => {
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
hideRoutineExecutions: true,
},
});
@ -1000,6 +1011,7 @@ describe("inbox helpers", () => {
labels: null,
projects: ["project-1"],
workspaces: ["workspace-1", false],
liveOnly: "yes",
hideRoutineExecutions: "yes",
},
}));
@ -1015,6 +1027,7 @@ describe("inbox helpers", () => {
labels: [],
projects: ["project-1"],
workspaces: ["workspace-1"],
liveOnly: false,
hideRoutineExecutions: false,
},
});

View file

@ -445,6 +445,7 @@ export function getInboxSearchSupplementIssues({
issueFilters,
currentUserId,
enableRoutineVisibilityFilter = false,
liveIssueIds,
}: {
query: string;
filteredWorkItems: InboxWorkItem[];
@ -453,6 +454,7 @@ export function getInboxSearchSupplementIssues({
issueFilters: IssueFilterState;
currentUserId?: string | null;
enableRoutineVisibilityFilter?: boolean;
liveIssueIds?: ReadonlySet<string>;
}): Issue[] {
const normalizedQuery = query.trim();
if (!normalizedQuery) return [];
@ -462,7 +464,7 @@ export function getInboxSearchSupplementIssues({
.map((item) => item.issue.id),
...archivedSearchIssues.map((issue) => issue.id),
]);
return applyIssueFilters(remoteIssues, issueFilters, currentUserId, enableRoutineVisibilityFilter)
return applyIssueFilters(remoteIssues, issueFilters, currentUserId, enableRoutineVisibilityFilter, liveIssueIds)
.filter((issue) => !visibleIssueIds.has(issue.id));
}

View file

@ -31,6 +31,7 @@ export interface IssueChatComment extends IssueComment {
queueState?: "queued";
queueTargetRunId?: string | null;
queueReason?: "hold" | "active_run" | "other";
followUpRequested?: boolean;
}
export interface IssueChatLinkedRun {
@ -43,6 +44,7 @@ export interface IssueChatLinkedRun {
startedAt: Date | string | null;
finishedAt?: Date | string | null;
hasStoredOutput?: boolean;
logBytes?: number | null;
}
export interface IssueChatTranscriptEntry {
@ -318,6 +320,7 @@ function createCommentMessage(args: {
queueTargetRunId: comment.queueTargetRunId ?? null,
queueReason: comment.queueReason ?? null,
interruptedRunId: comment.interruptedRunId ?? null,
followUpRequested: comment.followUpRequested === true,
};
if (comment.authorAgentId) {
@ -356,7 +359,9 @@ function createTimelineEventMessage(args: {
? "System"
: (formatAssigneeUserLabel(event.actorId, currentUserId, userLabelMap) ?? "Board");
const lines: string[] = [`${actorName} updated this issue`];
const lines: string[] = [
event.followUpRequested ? `${actorName} requested follow-up` : `${actorName} updated this issue`,
];
if (event.statusChange) {
lines.push(
`Status: ${event.statusChange.from ?? "none"} -> ${event.statusChange.to ?? "none"}`,
@ -387,6 +392,7 @@ function createTimelineEventMessage(args: {
actorId: event.actorId,
statusChange: event.statusChange ?? null,
assigneeChange: event.assigneeChange ?? null,
followUpRequested: event.followUpRequested === true,
},
},
};

View file

@ -2,7 +2,13 @@
import { describe, expect, it } from "vitest";
import type { Issue } from "@paperclipai/shared";
import { applyIssueFilters, countActiveIssueFilters, defaultIssueFilterState } from "./issue-filters";
import {
applyIssueFilters,
countActiveIssueFilters,
defaultIssueFilterState,
resolveIssueFilterWorkspaceId,
shouldIncludeIssueFilterWorkspaceOption,
} from "./issue-filters";
function makeIssue(overrides: Partial<Issue> = {}): Issue {
return {
@ -66,4 +72,100 @@ describe("issue filters", () => {
creators: ["user:user-1"],
})).toBe(1);
});
it("filters issues to live issue ids when live-only is enabled", () => {
const issues = [
makeIssue({ id: "live-issue" }),
makeIssue({ id: "idle-issue" }),
];
const filtered = applyIssueFilters(
issues,
{ ...defaultIssueFilterState, liveOnly: true },
null,
false,
new Set(["live-issue"]),
);
expect(filtered.map((issue) => issue.id)).toEqual(["live-issue"]);
});
it("counts the live-only filter as an active filter group", () => {
expect(countActiveIssueFilters({
...defaultIssueFilterState,
liveOnly: true,
})).toBe(1);
});
it("does not treat default project workspaces as workspace filter matches", () => {
const issue = makeIssue({
id: "default-workspace-issue",
projectId: "project-1",
projectWorkspaceId: "workspace-default",
});
const workspaceContext = {
defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "workspace-default"]]),
};
expect(resolveIssueFilterWorkspaceId(issue, workspaceContext)).toBeNull();
expect(applyIssueFilters(
[issue],
{ ...defaultIssueFilterState, workspaces: ["workspace-default"] },
null,
false,
undefined,
workspaceContext,
)).toEqual([]);
});
it("does not treat shared default execution workspaces as workspace filter matches", () => {
const issue = makeIssue({
id: "shared-default-issue",
projectId: "project-1",
projectWorkspaceId: "workspace-default",
executionWorkspaceId: "execution-shared-default",
});
const workspaceContext = {
executionWorkspaceById: new Map([[
"execution-shared-default",
{ mode: "shared_workspace", projectWorkspaceId: "workspace-default" },
]]),
defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "workspace-default"]]),
};
expect(resolveIssueFilterWorkspaceId(issue, workspaceContext)).toBeNull();
expect(shouldIncludeIssueFilterWorkspaceOption(
{ id: "execution-shared-default", mode: "shared_workspace", projectWorkspaceId: "workspace-default" },
new Set(["workspace-default"]),
)).toBe(false);
});
it("keeps non-default project and isolated execution workspaces filterable", () => {
const featureIssue = makeIssue({
id: "feature-issue",
projectId: "project-1",
projectWorkspaceId: "workspace-feature",
});
const executionIssue = makeIssue({
id: "execution-issue",
projectId: "project-1",
projectWorkspaceId: "workspace-default",
executionWorkspaceId: "execution-isolated",
});
const workspaceContext = {
executionWorkspaceById: new Map([[
"execution-isolated",
{ mode: "isolated_workspace", projectWorkspaceId: "workspace-default" },
]]),
defaultProjectWorkspaceIdByProjectId: new Map([["project-1", "workspace-default"]]),
};
expect(resolveIssueFilterWorkspaceId(featureIssue, workspaceContext)).toBe("workspace-feature");
expect(resolveIssueFilterWorkspaceId(executionIssue, workspaceContext)).toBe("execution-isolated");
expect(shouldIncludeIssueFilterWorkspaceOption({ id: "workspace-feature" }, new Set(["workspace-default"]))).toBe(true);
expect(shouldIncludeIssueFilterWorkspaceOption(
{ id: "execution-isolated", mode: "isolated_workspace", projectWorkspaceId: "workspace-default" },
new Set(["workspace-default"]),
)).toBe(true);
});
});

View file

@ -1,5 +1,15 @@
import type { Issue } from "@paperclipai/shared";
export type IssueFilterWorkspaceLookup = {
mode?: string | null;
projectWorkspaceId?: string | null;
};
export type IssueFilterWorkspaceContext = {
executionWorkspaceById?: ReadonlyMap<string, IssueFilterWorkspaceLookup>;
defaultProjectWorkspaceIdByProjectId?: ReadonlyMap<string, string>;
};
export type IssueFilterState = {
statuses: string[];
priorities: string[];
@ -8,6 +18,7 @@ export type IssueFilterState = {
labels: string[];
projects: string[];
workspaces: string[];
liveOnly?: boolean;
hideRoutineExecutions: boolean;
};
@ -19,6 +30,7 @@ export const defaultIssueFilterState: IssueFilterState = {
labels: [],
projects: [],
workspaces: [],
liveOnly: false,
hideRoutineExecutions: false,
};
@ -59,6 +71,7 @@ export function normalizeIssueFilterState(value: unknown): IssueFilterState {
labels: normalizeIssueFilterValueArray(candidate.labels),
projects: normalizeIssueFilterValueArray(candidate.projects),
workspaces: normalizeIssueFilterValueArray(candidate.workspaces),
liveOnly: candidate.liveOnly === true,
hideRoutineExecutions: candidate.hideRoutineExecutions === true,
};
}
@ -68,9 +81,41 @@ export function toggleIssueFilterValue(values: string[], value: string): string[
}
export function resolveIssueFilterWorkspaceId(
issue: Pick<Issue, "executionWorkspaceId" | "projectWorkspaceId">,
issue: Pick<Issue, "executionWorkspaceId" | "projectId" | "projectWorkspaceId">,
context: IssueFilterWorkspaceContext = {},
): string | null {
return issue.executionWorkspaceId ?? issue.projectWorkspaceId ?? null;
const defaultProjectWorkspaceId = issue.projectId
? context.defaultProjectWorkspaceIdByProjectId?.get(issue.projectId) ?? null
: null;
if (issue.executionWorkspaceId) {
const executionWorkspace = context.executionWorkspaceById?.get(issue.executionWorkspaceId) ?? null;
const linkedProjectWorkspaceId =
executionWorkspace?.projectWorkspaceId ?? issue.projectWorkspaceId ?? null;
const isDefaultSharedExecutionWorkspace =
executionWorkspace?.mode === "shared_workspace"
&& linkedProjectWorkspaceId != null
&& linkedProjectWorkspaceId === defaultProjectWorkspaceId;
if (isDefaultSharedExecutionWorkspace) return null;
return issue.executionWorkspaceId;
}
if (issue.projectWorkspaceId) {
if (issue.projectWorkspaceId === defaultProjectWorkspaceId) return null;
return issue.projectWorkspaceId;
}
return null;
}
export function shouldIncludeIssueFilterWorkspaceOption(
workspace: { id: string; mode?: string | null; projectWorkspaceId?: string | null },
defaultProjectWorkspaceIds: ReadonlySet<string>,
): boolean {
if (defaultProjectWorkspaceIds.has(workspace.id)) return false;
return !(workspace.mode === "shared_workspace"
&& workspace.projectWorkspaceId != null
&& defaultProjectWorkspaceIds.has(workspace.projectWorkspaceId));
}
export function applyIssueFilters(
@ -78,8 +123,13 @@ export function applyIssueFilters(
state: IssueFilterState,
currentUserId?: string | null,
enableRoutineVisibilityFilter = false,
liveIssueIds?: ReadonlySet<string>,
workspaceContext: IssueFilterWorkspaceContext = {},
): Issue[] {
let result = issues;
if (state.liveOnly) {
result = result.filter((issue) => liveIssueIds?.has(issue.id) === true);
}
if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) {
result = result.filter((issue) => issue.originKind !== "routine_execution");
}
@ -112,7 +162,7 @@ export function applyIssueFilters(
}
if (state.workspaces.length > 0) {
result = result.filter((issue) => {
const workspaceId = resolveIssueFilterWorkspaceId(issue);
const workspaceId = resolveIssueFilterWorkspaceId(issue, workspaceContext);
return workspaceId != null && state.workspaces.includes(workspaceId);
});
}
@ -131,6 +181,7 @@ export function countActiveIssueFilters(
if (state.labels.length > 0) count += 1;
if (state.projects.length > 0) count += 1;
if (state.workspaces.length > 0) count += 1;
if (state.liveOnly) count += 1;
if (enableRoutineVisibilityFilter && state.hideRoutineExecutions) count += 1;
return count;
}

View file

@ -126,6 +126,80 @@ describe("extractIssueTimelineEvents", () => {
]);
});
it("marks explicit follow-up timeline updates", () => {
const events = extractIssueTimelineEvents([
{
id: "evt-follow-up",
companyId: "company-1",
actorType: "agent",
actorId: "agent-1",
action: "issue.updated",
entityType: "issue",
entityId: "issue-1",
agentId: "agent-1",
runId: "run-1",
createdAt: new Date("2026-03-31T12:01:00.000Z"),
details: {
status: "todo",
reopened: true,
reopenedFrom: "done",
source: "comment",
commentId: "comment-1",
resumeIntent: true,
followUpRequested: true,
},
},
] satisfies ActivityEvent[]);
expect(events).toEqual([
{
id: "evt-follow-up",
createdAt: new Date("2026-03-31T12:01:00.000Z"),
actorType: "agent",
actorId: "agent-1",
commentId: "comment-1",
followUpRequested: true,
statusChange: {
from: "done",
to: "todo",
},
},
]);
});
it("synthesizes non-status follow-up rows from comment activity", () => {
const events = extractIssueTimelineEvents([
{
id: "evt-comment-follow-up",
companyId: "company-1",
actorType: "agent",
actorId: "agent-1",
action: "issue.comment_added",
entityType: "issue",
entityId: "issue-1",
agentId: "agent-1",
runId: "run-1",
createdAt: new Date("2026-03-31T12:01:00.000Z"),
details: {
commentId: "comment-1",
resumeIntent: true,
followUpRequested: true,
},
},
] satisfies ActivityEvent[]);
expect(events).toEqual([
{
id: "evt-comment-follow-up",
createdAt: new Date("2026-03-31T12:01:00.000Z"),
actorType: "agent",
actorId: "agent-1",
commentId: "comment-1",
followUpRequested: true,
},
]);
});
it("ignores issue updates without visible status or assignee transitions", () => {
const events = extractIssueTimelineEvents([
{

View file

@ -18,6 +18,8 @@ export interface IssueTimelineEvent {
from: IssueTimelineAssignee;
to: IssueTimelineAssignee;
};
commentId?: string | null;
followUpRequested?: boolean;
}
function asRecord(value: unknown): Record<string, unknown> | null {
@ -53,11 +55,26 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un
const events: IssueTimelineEvent[] = [];
for (const event of activity ?? []) {
if (event.action !== "issue.updated") continue;
const details = asRecord(event.details);
if (!details) continue;
if (event.action === "issue.comment_added") {
if (details.followUpRequested !== true && details.resumeIntent !== true) continue;
if (details.reopened === true) continue;
const commentId = nullableString(details.commentId);
events.push({
id: event.id,
createdAt: event.createdAt,
actorType: event.actorType,
actorId: event.actorId,
commentId,
followUpRequested: true,
});
continue;
}
if (event.action !== "issue.updated") continue;
const previous = asRecord(details._previous);
const timelineEvent: IssueTimelineEvent = {
id: event.id,
@ -65,6 +82,10 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un
actorType: event.actorType,
actorId: event.actorId,
};
if (details.followUpRequested === true || details.resumeIntent === true) {
timelineEvent.followUpRequested = true;
timelineEvent.commentId = nullableString(details.commentId);
}
if (hasOwn(details, "status")) {
const from = nullableString(previous?.status) ?? nullableString(details.reopenedFrom);
@ -96,7 +117,7 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un
}
}
if (timelineEvent.statusChange || timelineEvent.assigneeChange) {
if (timelineEvent.statusChange || timelineEvent.assigneeChange || timelineEvent.followUpRequested) {
events.push(timelineEvent);
}
}

View file

@ -15,6 +15,8 @@ export function resolveIssueChatTranscriptRuns(args: {
id: run.id,
status: run.status,
adapterType: run.adapterType,
logBytes: run.logBytes,
lastOutputBytes: run.lastOutputBytes,
});
}
@ -23,6 +25,8 @@ export function resolveIssueChatTranscriptRuns(args: {
id: activeRun.id,
status: activeRun.status,
adapterType: activeRun.adapterType,
logBytes: activeRun.logBytes,
lastOutputBytes: activeRun.lastOutputBytes,
});
}
@ -35,6 +39,7 @@ export function resolveIssueChatTranscriptRuns(args: {
status: run.status,
adapterType,
hasStoredOutput: run.hasStoredOutput,
logBytes: run.logBytes,
});
}

View file

@ -12,6 +12,7 @@ export type IssueDetailHeaderSeed = {
identifier: string | null;
title: string;
status: Issue["status"];
blockerAttention?: Issue["blockerAttention"];
priority: Issue["priority"];
projectId: string | null;
projectName: string | null;
@ -47,11 +48,15 @@ function isIssueDetailHeaderSeed(value: unknown): value is IssueDetailHeaderSeed
candidate.originKind === undefined || typeof candidate.originKind === "string";
const hasOriginId =
candidate.originId === undefined || candidate.originId === null || typeof candidate.originId === "string";
const hasBlockerAttention =
candidate.blockerAttention === undefined
|| (typeof candidate.blockerAttention === "object" && candidate.blockerAttention !== null);
return (
typeof candidate.id === "string"
&& (candidate.identifier === null || typeof candidate.identifier === "string")
&& typeof candidate.title === "string"
&& typeof candidate.status === "string"
&& hasBlockerAttention
&& typeof candidate.priority === "string"
&& (candidate.projectId === null || typeof candidate.projectId === "string")
&& (candidate.projectName === null || typeof candidate.projectName === "string")
@ -66,6 +71,7 @@ function createIssueDetailHeaderSeed(issue: Issue): IssueDetailHeaderSeed {
identifier: issue.identifier ?? null,
title: issue.title,
status: issue.status,
blockerAttention: issue.blockerAttention,
priority: issue.priority,
projectId: issue.projectId ?? null,
projectName: issue.project?.name ?? null,

View file

@ -57,6 +57,19 @@ describe("collectLiveIssueIds", () => {
adapterType: "codex_local",
issueId: "issue-2",
},
{
id: "run-5",
status: "succeeded",
invocationSource: "scheduler",
triggerDetail: null,
startedAt: "2026-04-20T10:04:00.000Z",
finishedAt: "2026-04-20T10:05:00.000Z",
createdAt: "2026-04-20T10:04:00.000Z",
agentId: "agent-5",
agentName: "Done",
adapterType: "codex_local",
issueId: "completed-issue",
},
];
expect([...collectLiveIssueIds(liveRuns)]).toEqual(["issue-1", "issue-2"]);

View file

@ -1,9 +1,13 @@
import type { LiveRunForIssue } from "../api/heartbeats";
function isLiveRunStatus(status: string): boolean {
return status === "queued" || status === "running";
}
export function collectLiveIssueIds(liveRuns: readonly LiveRunForIssue[] | null | undefined): Set<string> {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
if (run.issueId && isLiveRunStatus(run.status)) ids.add(run.issueId);
}
return ids;
}

View file

@ -36,7 +36,8 @@ describe("runRetryState", () => {
).toMatchObject({
kind: "exhausted",
badgeLabel: "Retry exhausted",
detail: "Attempt 4 · Transient failure · No further automatic retry queued",
detail: "Attempt 4 · Transient failure · Automatic retries exhausted",
secondary: "Bounded retry exhausted after 4 scheduled attempts; no further automatic retry will be queued Manual intervention required.",
});
});
});

View file

@ -76,8 +76,10 @@ export function describeRunRetryState(run: RetryAwareRun): RunRetryStateSummary
kind: "exhausted",
badgeLabel: "Retry exhausted",
tone: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
detail: joinFragments([attemptLabel, reasonLabel, "No further automatic retry queued"]),
secondary: exhaustedReason,
detail: joinFragments([attemptLabel, reasonLabel, "Automatic retries exhausted"]),
secondary: exhaustedReason.includes("Manual intervention required")
? exhaustedReason
: `${exhaustedReason} Manual intervention required.`,
retryOfRunId,
};
}