[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

@ -110,6 +110,8 @@ const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string
cancelled: { icon: Slash, color: "text-neutral-500 dark:text-neutral-400" },
};
const RUN_LOG_PAGE_BYTES = 256_000;
const REDACTED_ENV_VALUE = "***REDACTED***";
const SECRET_ENV_KEY_RE =
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
@ -3473,6 +3475,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const [logLoading, setLogLoading] = useState(!!run.logRef);
const [logError, setLogError] = useState<string | null>(null);
const [logOffset, setLogOffset] = useState(0);
const [hasMoreLog, setHasMoreLog] = useState(false);
const [loadingMoreLog, setLoadingMoreLog] = useState(false);
const [isFollowing, setIsFollowing] = useState(false);
const [isStreamingConnected, setIsStreamingConnected] = useState(false);
const [transcriptMode, setTranscriptMode] = useState<TranscriptMode>("nice");
@ -3627,6 +3631,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
pendingLogLineRef.current = "";
setLogLines([]);
setLogOffset(0);
setHasMoreLog(false);
setLoadingMoreLog(false);
setLogError(null);
if (!run.logRef && !isLive) {
@ -3637,25 +3643,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
}
setLogLoading(true);
const firstLimit =
typeof run.logBytes === "number" && run.logBytes > 0
? Math.min(Math.max(run.logBytes + 1024, 256_000), 2_000_000)
: 256_000;
const load = async () => {
try {
let offset = 0;
let first = true;
while (!cancelled) {
const result = await heartbeatsApi.log(run.id, offset, first ? firstLimit : 256_000);
if (cancelled) break;
appendLogContent(result.content, result.nextOffset === undefined);
const next = result.nextOffset ?? offset + result.content.length;
setLogOffset(next);
offset = next;
first = false;
if (result.nextOffset === undefined || isLive) break;
}
const result = await heartbeatsApi.log(run.id, 0, RUN_LOG_PAGE_BYTES);
if (cancelled) return;
appendLogContent(result.content, result.nextOffset === undefined);
const next = result.nextOffset ?? result.content.length;
setLogOffset(next);
setHasMoreLog(!isLive && result.nextOffset !== undefined);
} catch (err) {
if (!cancelled) {
if (isLive && isRunLogUnavailable(err)) {
@ -3675,6 +3670,23 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
};
}, [run.id, run.logRef, run.logBytes, isLive]);
async function loadMorePersistedLog() {
if (loadingMoreLog || !hasMoreLog) return;
setLoadingMoreLog(true);
setLogError(null);
try {
const result = await heartbeatsApi.log(run.id, logOffset, RUN_LOG_PAGE_BYTES);
appendLogContent(result.content, result.nextOffset === undefined);
const next = result.nextOffset ?? logOffset + result.content.length;
setLogOffset(next);
setHasMoreLog(result.nextOffset !== undefined);
} catch (err) {
setLogError(err instanceof Error ? err.message : "Failed to load more run log");
} finally {
setLoadingMoreLog(false);
}
}
// Poll for live updates
useEffect(() => {
if (!isLive || isStreamingConnected) return;
@ -3941,6 +3953,25 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
streaming={isLive}
emptyMessage={run.logRef ? "Waiting for transcript..." : "No persisted transcript for this run."}
/>
{hasMoreLog && (
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-border/60 pt-3">
<Button
type="button"
variant="outline"
size="xs"
onClick={loadMorePersistedLog}
disabled={loadingMoreLog}
>
{loadingMoreLog ? "Loading..." : "Load more log"}
</Button>
<span className="text-xs text-muted-foreground">
Showing the first {Math.round(logOffset / 1024).toLocaleString("en-US")} KB
{typeof run.logBytes === "number" && run.logBytes > 0
? ` of ${Math.round(run.logBytes / 1024).toLocaleString("en-US")} KB`
: ""}
</span>
</div>
)}
{logError && (
<div className="mt-3 rounded-xl border border-red-500/20 bg-red-500/[0.06] px-3 py-2 text-xs text-red-700 dark:text-red-300">
{logError}

View file

@ -356,7 +356,7 @@ export function Dashboard() {
<div className="flex items-start gap-2 sm:items-center sm:gap-3">
{/* Status icon - left column on mobile */}
<span className="shrink-0 sm:hidden">
<StatusIcon status={issue.status} />
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />
</span>
{/* Right column on mobile: title + metadata stacked */}
@ -365,7 +365,7 @@ export function Dashboard() {
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} /></span>
<span className="text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>

View file

@ -0,0 +1,64 @@
import { useEffect } from "react";
import { ArrowLeft, RadioTower } from "lucide-react";
import { Link } from "@/lib/router";
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
import { EmptyState } from "../components/EmptyState";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
const DASHBOARD_LIVE_RUN_LIMIT = 50;
export function DashboardLive() {
const { selectedCompanyId, companies } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
useEffect(() => {
setBreadcrumbs([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Live runs" },
]);
}, [setBreadcrumbs]);
if (!selectedCompanyId) {
return (
<EmptyState
icon={RadioTower}
message={companies.length === 0 ? "Create a company to view live runs." : "Select a company to view live runs."}
/>
);
}
return (
<div className="space-y-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<Link
to="/dashboard"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" />
Dashboard
</Link>
<h1 className="mt-2 text-2xl font-semibold tracking-normal text-foreground">Live agent runs</h1>
<p className="mt-1 text-sm text-muted-foreground">
Active runs first, followed by the most recent completed runs.
</p>
</div>
<div className="text-sm text-muted-foreground">Showing up to {DASHBOARD_LIVE_RUN_LIMIT}</div>
</div>
<ActiveAgentsPanel
companyId={selectedCompanyId}
title="Active / recent"
minRunCount={DASHBOARD_LIVE_RUN_LIMIT}
fetchLimit={DASHBOARD_LIVE_RUN_LIMIT}
cardLimit={DASHBOARD_LIVE_RUN_LIMIT}
gridClassName="gap-3 md:grid-cols-2 2xl:grid-cols-3"
cardClassName="h-[420px]"
emptyMessage="No active or recent agent runs."
queryScope="dashboard-live"
showMoreLink={false}
/>
</div>
);
}

View file

@ -25,6 +25,7 @@ import {
} from "../components/WorkspaceRuntimeControls";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { queryKeys } from "../lib/queryKeys";
import { cn, formatDateTime, issueUrl, projectRouteRef, projectWorkspaceUrl } from "../lib/utils";
@ -271,13 +272,7 @@ function ExecutionWorkspaceIssuesList({
refetchInterval: 5000,
});
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
}
return ids;
}, [liveRuns]);
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => issuesApi.update(id, data),

View file

@ -141,6 +141,10 @@ describe("FailedRunInboxRow", () => {
logBytes: null,
logSha256: null,
logCompressed: false,
lastOutputAt: null,
lastOutputSeq: 0,
lastOutputStream: null,
lastOutputBytes: null,
errorCode: null,
externalRunId: null,
processPid: null,

View file

@ -23,6 +23,7 @@ import {
countActiveIssueFilters,
type IssueFilterState,
} from "../lib/issue-filters";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { formatAssigneeUserLabel } from "../lib/assignees";
import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "../lib/company-members";
import {
@ -826,6 +827,7 @@ export function Inbox() {
enabled: !!selectedCompanyId,
refetchInterval: 5000,
});
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
@ -845,12 +847,12 @@ export function Inbox() {
const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]);
const touchedIssues = useMemo(() => getRecentTouchedIssues(touchedIssuesRaw), [touchedIssuesRaw]);
const visibleMineIssues = useMemo(
() => applyIssueFilters(mineIssues, issueFilters, currentUserId, true),
[mineIssues, issueFilters, currentUserId],
() => applyIssueFilters(mineIssues, issueFilters, currentUserId, true, liveIssueIds),
[mineIssues, issueFilters, currentUserId, liveIssueIds],
);
const visibleTouchedIssues = useMemo(
() => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true),
[touchedIssues, issueFilters, currentUserId],
() => applyIssueFilters(touchedIssues, issueFilters, currentUserId, true, liveIssueIds),
[touchedIssues, issueFilters, currentUserId, liveIssueIds],
);
const unreadTouchedIssues = useMemo(
() => visibleTouchedIssues.filter((issue) => issue.isUnreadForMe),
@ -1004,14 +1006,6 @@ export function Inbox() {
),
[heartbeatRuns, dismissedAtByKey],
);
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
}
return ids;
}, [liveRuns]);
const approvalsToRender = useMemo(() => {
let filtered = getApprovalsForTab(approvals ?? [], tab, allApprovalFilter, currentUserId);
if (tab === "mine") {
@ -1159,12 +1153,14 @@ export function Inbox() {
issueFilters,
currentUserId,
enableRoutineVisibilityFilter: true,
liveIssueIds,
}),
[
archivedSearchIssues,
currentUserId,
filteredWorkItems,
issueFilters,
liveIssueIds,
normalizedSearchQuery,
remoteIssueSearchResults,
],

View file

@ -56,6 +56,8 @@ export function InstanceExperimentalSettings() {
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
const enableIssueGraphLivenessAutoRecovery =
experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true;
return (
<div className="max-w-4xl space-y-6">
@ -128,6 +130,28 @@ export function InstanceExperimentalSettings() {
/>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Auto-Create Issue Recovery Tasks</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Let the heartbeat scheduler create recovery issues for issue dependency chains that have been stalled for
at least 24 hours.
</p>
</div>
<ToggleSwitch
checked={enableIssueGraphLivenessAutoRecovery}
onCheckedChange={() =>
toggleMutation.mutate({
enableIssueGraphLivenessAutoRecovery: !enableIssueGraphLivenessAutoRecovery,
})
}
disabled={toggleMutation.isPending}
aria-label="Toggle issue graph liveness auto-recovery"
/>
</div>
</section>
</div>
);
}

View file

@ -229,7 +229,9 @@ vi.mock("../components/ScrollToBottom", () => ({
}));
vi.mock("../components/StatusIcon", () => ({
StatusIcon: ({ status }: { status: string }) => <span>{status}</span>,
StatusIcon: ({ status, blockerAttention }: { status: string; blockerAttention?: Issue["blockerAttention"] }) => (
<span data-status-icon-state={blockerAttention?.state}>{status}</span>
),
}));
vi.mock("../components/PriorityIcon", () => ({
@ -814,6 +816,31 @@ describe("IssueDetail", () => {
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it("passes blocker attention to the issue detail header status icon", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({
status: "blocked",
blockerAttention: {
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2",
},
}));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.querySelector('[data-status-icon-state="covered"]')?.textContent).toBe("blocked");
});
it("refreshes subtree pause state after resuming a hold", async () => {
const childIssue = createIssue({
id: "child-1",
@ -1150,11 +1177,24 @@ describe("IssueDetail", () => {
.find((element) =>
typeof element.className === "string"
&& element.className.includes("overflow-y-auto")
&& element.textContent?.includes("Reason (required)"),
&& element.textContent?.includes("Reason (optional)"),
);
expect(bodyScrollRegion?.className).toContain("min-h-0");
expect(bodyScrollRegion?.className).toContain("overscroll-contain");
const cancelApplyButton = Array.from(dialogContent!.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Cancel 24 issues") as HTMLButtonElement | undefined;
expect(cancelApplyButton).toBeTruthy();
expect(cancelApplyButton!.disabled).toBe(true);
const confirmationCheckbox = dialogContent!.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
expect(confirmationCheckbox).toBeTruthy();
await act(async () => {
confirmationCheckbox!.click();
});
await flushReact();
expect(cancelApplyButton!.disabled).toBe(false);
const footer = Array.from(dialogContent!.querySelectorAll("div"))
.find((element) =>
typeof element.className === "string"

View file

@ -382,7 +382,7 @@ function IssueDetailLoadingState({
<div className="flex items-center gap-2 min-w-0 flex-wrap">
{headerSeed ? (
<>
<StatusIcon status={headerSeed.status} />
<StatusIcon status={headerSeed.status} blockerAttention={headerSeed.blockerAttention} />
<PriorityIcon priority={headerSeed.priority} />
{identifier ? (
<span className="text-sm font-mono text-muted-foreground shrink-0">{identifier}</span>
@ -692,6 +692,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
const commentsWithRunMeta = useMemo<IssueDetailComment[]>(() => {
const activeRunStartedAt = runningIssueRun?.startedAt ?? runningIssueRun?.createdAt ?? null;
const runMetaByCommentId = new Map<string, { runId: string; runAgentId: string | null; interruptedRunId: string | null }>();
const followUpCommentIds = new Set<string>();
const agentIdByRunId = new Map<string, string>();
for (const run of resolvedLinkedRuns) {
@ -710,10 +711,22 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
interruptedRunId,
});
}
for (const evt of resolvedActivity) {
if (evt.action !== "issue.comment_added") continue;
const details = evt.details ?? {};
const commentId = typeof details["commentId"] === "string" ? details["commentId"] : null;
if (!commentId) continue;
if (details["followUpRequested"] === true || details["resumeIntent"] === true) {
followUpCommentIds.add(commentId);
}
}
return comments.map((comment) => {
const meta = runMetaByCommentId.get(comment.id);
const nextComment: IssueDetailComment = meta ? { ...comment, ...meta } : { ...comment };
if (followUpCommentIds.has(comment.id)) {
nextComment.followUpRequested = true;
}
const queuedTargetRunId = locallyQueuedCommentRunIds.get(comment.id) ?? null;
const locallyQueuedComment = applyLocalQueuedIssueCommentState(nextComment, {
queuedTargetRunId,
@ -2702,7 +2715,7 @@ export function IssueDetail() {
const canApplyTreeControl =
Boolean(treeControlPreview)
&& !treeControlPreviewLoading
&& (treeControlMode !== "cancel" || (treeControlReason.trim().length > 0 && treeControlCancelConfirmed));
&& (treeControlMode !== "cancel" || treeControlCancelConfirmed);
const attachmentUploadButton = (
<>
<input
@ -2840,6 +2853,7 @@ export function IssueDetail() {
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<StatusIcon
status={issue.status}
blockerAttention={issue.blockerAttention}
onChange={(status) => updateIssue.mutate({ status })}
/>
<PriorityIcon
@ -3448,7 +3462,7 @@ export function IssueDetail() {
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">
{treeControlMode === "cancel" ? "Reason (required)" : "Reason (optional)"}
Reason (optional)
</label>
<Textarea
value={treeControlReason}

View file

@ -56,7 +56,7 @@ export function MyIssues() {
title={issue.title}
to={`/issues/${issue.identifier ?? issue.id}`}
leading={
<StatusIcon status={issue.status} />
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />
}
trailing={
<span className="text-xs text-muted-foreground">

View file

@ -24,6 +24,7 @@ import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent";
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { projectRouteRef } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs";
@ -175,13 +176,7 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
enabled: !!companyId,
});
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
}
return ids;
}, [liveRuns]);
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
const { data: issues, isLoading, error } = useQuery({
queryKey: queryKeys.issues.listByProject(companyId, projectId),

View file

@ -13,6 +13,7 @@ import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { groupBy } from "../lib/groupBy";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
@ -492,13 +493,7 @@ export function Routines() {
() => new Map((projects ?? []).map((project) => [project.id, project])),
[projects],
);
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
}
return ids;
}, [liveRuns]);
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
const routineGroups = useMemo(
() => buildRoutineGroups(routines ?? [], routineViewState.groupBy, projectById, agentById),
[agentById, projectById, routineViewState.groupBy, routines],