diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 6814c8695d9..0723eb4e63a 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -511,7 +511,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { runId, claimedAt: now, finishedAt: new Date("2026-03-19T00:05:00.000Z"), - error: input.runStatus === "succeeded" ? null : (input.runError ?? "run failed before issue advanced"), + error: input.runStatus === "succeeded" + ? null + : ("runError" in input ? input.runError : "run failed before issue advanced"), }); await db.insert(heartbeatRuns).values({ @@ -533,8 +535,12 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { startedAt: now, finishedAt: new Date("2026-03-19T00:05:00.000Z"), updatedAt: new Date("2026-03-19T00:05:00.000Z"), - errorCode: input.runStatus === "succeeded" ? null : (input.runErrorCode ?? "process_lost"), - error: input.runStatus === "succeeded" ? null : (input.runError ?? "run failed before issue advanced"), + errorCode: input.runStatus === "succeeded" + ? null + : ("runErrorCode" in input ? input.runErrorCode : "process_lost"), + error: input.runStatus === "succeeded" + ? null + : ("runError" in input ? input.runError : "run failed before issue advanced"), }); await db.insert(issues).values([ @@ -1544,6 +1550,61 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(comments[0]?.body).toContain(`Recovery issue: [${recovery.identifier}]`); }); + it("redacts error-code-only stranded recovery failures in issue copy", async () => { + const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "failed", + retryReason: "issue_continuation_needed", + runErrorCode: "adapter_exit_code", + runError: null, + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.escalated).toBe(1); + + const recovery = await expectStrandedRecoveryArtifacts({ + companyId, + agentId, + issueId, + runId, + previousStatus: "in_progress", + retryReason: "issue_continuation_needed", + }); + expect(recovery.description).toContain("Latest retry failure details were withheld from the issue thread"); + expect(recovery.description).not.toContain("- Failure: none recorded"); + + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + expect(comments).toHaveLength(1); + expect(comments[0]?.body).toContain("Latest retry failure details were withheld from the issue thread"); + expect(comments[0]?.body).not.toContain("- Failure: none recorded"); + }); + + it("reuses the raced stranded recovery issue when duplicate active recovery creation conflicts", async () => { + const { companyId, issueId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "failed", + retryReason: "issue_continuation_needed", + }); + const heartbeat = heartbeatService(db); + + const results = await Promise.allSettled( + Array.from({ length: 8 }, () => heartbeat.reconcileStrandedAssignedIssues()), + ); + expect(results.every((result) => result.status === "fulfilled")).toBe(true); + + const recoveries = await db + .select() + .from(issues) + .where(and( + eq(issues.companyId, companyId), + eq(issues.originKind, "stranded_issue_recovery"), + eq(issues.originId, issueId), + )); + expect(recoveries).toHaveLength(1); + await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual([recoveries[0]?.id]); + }); + it("blocks stranded recovery issues in place instead of creating nested recovery issues", async () => { const sourceIssueId = randomUUID(); const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({ diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 3bffa3a0bd9..0f435d3bca5 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -102,7 +102,7 @@ function readNonEmptyString(value: unknown): string | null { function summarizeRunFailureForIssueComment(run: LatestIssueRun) { if (!run) return null; - if (readNonEmptyString(run.error)) { + if (readNonEmptyString(run.error) || readNonEmptyString(run.errorCode)) { return " Latest retry failure details were withheld from the issue thread; inspect the linked run for evidence."; } return null; @@ -814,6 +814,16 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) ); } + function isUniqueStrandedIssueRecoveryConflict(error: unknown) { + if (!error || typeof error !== "object") return false; + const maybe = error as { code?: string; constraint?: string; message?: string }; + return maybe.code === "23505" && + ( + maybe.constraint === "issues_active_stranded_issue_recovery_uq" || + typeof maybe.message === "string" && maybe.message.includes("issues_active_stranded_issue_recovery_uq") + ); + } + async function ensureSourceIssueBlockedByStaleEvaluation(input: { sourceIssue: typeof issues.$inferSelect | null; evaluationIssue: { id: string; identifier: string | null }; @@ -1267,32 +1277,40 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) if (!ownerAgentId) return null; const prefix = await getCompanyIssuePrefix(input.issue.companyId); - const recovery = await issuesSvc.create(input.issue.companyId, { - title: `Recover stalled issue ${input.issue.identifier ?? input.issue.title}`, - description: buildStrandedIssueRecoveryDescription({ - issue: input.issue, - latestRun: input.latestRun, - previousStatus: input.previousStatus, - prefix, - }), - status: "todo", - priority: input.issue.priority, - parentId: input.issue.id, - projectId: input.issue.projectId, - goalId: input.issue.goalId, - assigneeAgentId: ownerAgentId, - originKind: STRANDED_ISSUE_RECOVERY_ORIGIN_KIND, - originId: input.issue.id, - originRunId: input.latestRun?.id ?? null, - originFingerprint: [ - STRANDED_ISSUE_RECOVERY_ORIGIN_KIND, - input.issue.companyId, - input.issue.id, - input.latestRun?.id ?? "no-run", - ].join(":"), - billingCode: input.issue.billingCode, - inheritExecutionWorkspaceFromIssueId: input.issue.id, - }); + let recovery: Awaited>; + try { + recovery = await issuesSvc.create(input.issue.companyId, { + title: `Recover stalled issue ${input.issue.identifier ?? input.issue.title}`, + description: buildStrandedIssueRecoveryDescription({ + issue: input.issue, + latestRun: input.latestRun, + previousStatus: input.previousStatus, + prefix, + }), + status: "todo", + priority: input.issue.priority, + parentId: input.issue.id, + projectId: input.issue.projectId, + goalId: input.issue.goalId, + assigneeAgentId: ownerAgentId, + originKind: STRANDED_ISSUE_RECOVERY_ORIGIN_KIND, + originId: input.issue.id, + originRunId: input.latestRun?.id ?? null, + originFingerprint: [ + STRANDED_ISSUE_RECOVERY_ORIGIN_KIND, + input.issue.companyId, + input.issue.id, + input.latestRun?.id ?? "no-run", + ].join(":"), + billingCode: input.issue.billingCode, + inheritExecutionWorkspaceFromIssueId: input.issue.id, + }); + } catch (error) { + if (!isUniqueStrandedIssueRecoveryConflict(error)) throw error; + const raced = await findOpenStrandedIssueRecoveryIssue(input.issue.companyId, input.issue.id); + if (!raced) throw error; + return raced; + } await deps.enqueueWakeup(ownerAgentId, { source: "assignment", diff --git a/ui/src/context/CompanyContext.test.tsx b/ui/src/context/CompanyContext.test.tsx index 8bdbe913412..ec34c69e847 100644 --- a/ui/src/context/CompanyContext.test.tsx +++ b/ui/src/context/CompanyContext.test.tsx @@ -4,7 +4,13 @@ import { act, useEffect } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CompanyProvider, useCompany } from "./CompanyContext"; +import type { Company } from "@paperclipai/shared"; +import { + CompanyProvider, + resolveBootstrapCompanySelection, + shouldClearStoredCompanySelection, + useCompany, +} from "./CompanyContext"; const mockCompaniesApi = vi.hoisted(() => ({ list: vi.fn(), @@ -15,6 +21,33 @@ vi.mock("../api/companies", () => ({ companiesApi: mockCompaniesApi, })); +const activeCompany = { id: "company-1" }; +const secondActiveCompany = { id: "company-2" }; +const archivedCompany = { id: "archived-company" }; + +function makeCompany(id: string): Company { + return { + id, + name: "Paperclip", + description: null, + status: "active", + issuePrefix: "PAP", + issueCounter: 1, + budgetMonthlyCents: 0, + spentMonthlyCents: 0, + requireBoardApprovalForNewAgents: false, + feedbackDataSharingEnabled: false, + feedbackDataSharingConsentAt: null, + feedbackDataSharingConsentByUserId: null, + feedbackDataSharingTermsVersion: null, + brandColor: null, + logoAssetId: null, + logoUrl: null, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + function Probe({ onSelectedCompanyId }: { onSelectedCompanyId: (companyId: string | null) => void }) { const { selectedCompanyId } = useCompany(); useEffect(() => { @@ -23,6 +56,71 @@ function Probe({ onSelectedCompanyId }: { onSelectedCompanyId: (companyId: strin return
; } +describe("resolveBootstrapCompanySelection", () => { + it("does not expose a stale stored company id before companies load", () => { + expect(resolveBootstrapCompanySelection({ + companies: [], + sidebarCompanies: [], + selectedCompanyId: null, + storedCompanyId: "stale-company", + })).toBeNull(); + }); + + it("replaces a stale stored company id with the first loaded company", () => { + expect(resolveBootstrapCompanySelection({ + companies: [activeCompany], + sidebarCompanies: [activeCompany], + selectedCompanyId: null, + storedCompanyId: "stale-company", + })).toBe("company-1"); + }); + + it("keeps a valid selected company ahead of stored bootstrap state", () => { + expect(resolveBootstrapCompanySelection({ + companies: [activeCompany], + sidebarCompanies: [activeCompany], + selectedCompanyId: "company-1", + storedCompanyId: "stale-company", + })).toBe("company-1"); + }); + + it("keeps a valid stored company id instead of falling back to the first company", () => { + expect(resolveBootstrapCompanySelection({ + companies: [activeCompany, secondActiveCompany], + sidebarCompanies: [activeCompany, secondActiveCompany], + selectedCompanyId: null, + storedCompanyId: "company-2", + })).toBe("company-2"); + }); + + it("uses selectable sidebar companies before archived companies", () => { + expect(resolveBootstrapCompanySelection({ + companies: [archivedCompany, activeCompany], + sidebarCompanies: [activeCompany], + selectedCompanyId: null, + storedCompanyId: "archived-company", + })).toBe("company-1"); + }); +}); + +describe("shouldClearStoredCompanySelection", () => { + it("does not clear the stored company selection during an unauthorized company list response", () => { + expect(shouldClearStoredCompanySelection({ + companies: [], + isLoading: false, + unauthorized: true, + })).toBe(false); + }); + + it("clears the stored company selection when an authorized company list is empty", () => { + expect(shouldClearStoredCompanySelection({ + companies: [], + isLoading: false, + unauthorized: false, + })).toBe(true); + }); +}); + describe("CompanyProvider", () => { let container: HTMLDivElement; let root: Root; @@ -70,28 +168,11 @@ describe("CompanyProvider", () => { it("replaces a stale stored company id with the first loaded company", async () => { localStorage.setItem("paperclip.selectedCompanyId", "stale-company"); - mockCompaniesApi.list.mockResolvedValue([ - { - id: "company-1", - name: "Paperclip", - description: null, - status: "active", - issuePrefix: "PAP", - issueCounter: 1, - budgetMonthlyCents: 0, - spentMonthlyCents: 0, - requireBoardApprovalForNewAgents: false, - feedbackDataSharingEnabled: false, - feedbackDataSharingConsentAt: null, - feedbackDataSharingConsentByUserId: null, - feedbackDataSharingTermsVersion: null, - brandColor: null, - logoAssetId: null, - logoUrl: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - ]); + let resolveCompanies!: (companies: Company[]) => void; + const companiesPromise = new Promise((resolve) => { + resolveCompanies = resolve; + }); + mockCompaniesApi.list.mockReturnValue(companiesPromise); const seen: Array = []; await act(async () => { @@ -104,6 +185,11 @@ describe("CompanyProvider", () => { ); }); + await act(async () => { + resolveCompanies([makeCompany("company-1")]); + await companiesPromise; + }); + await vi.waitFor(() => { expect(seen).toEqual([null, "company-1"]); expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-1"); diff --git a/ui/src/context/CompanyContext.tsx b/ui/src/context/CompanyContext.tsx index 2cb95912b8e..18a043f0c35 100644 --- a/ui/src/context/CompanyContext.tsx +++ b/ui/src/context/CompanyContext.tsx @@ -14,6 +14,7 @@ import { ApiError } from "../api/client"; import { queryKeys } from "../lib/queryKeys"; import type { CompanySelectionSource } from "../lib/company-selection"; type CompanySelectionOptions = { source?: CompanySelectionSource }; +type CompanyListResult = { companies: Company[]; unauthorized: boolean }; interface CompanyContextValue { companies: Company[]; @@ -35,25 +36,55 @@ const STORAGE_KEY = "paperclip.selectedCompanyId"; const CompanyContext = createContext(null); +export function resolveBootstrapCompanySelection(input: { + companies: Array>; + sidebarCompanies: Array>; + selectedCompanyId: string | null; + storedCompanyId: string | null; +}) { + if (input.companies.length === 0) return null; + + const selectableCompanies = input.sidebarCompanies.length > 0 + ? input.sidebarCompanies + : input.companies; + if (input.selectedCompanyId && selectableCompanies.some((company) => company.id === input.selectedCompanyId)) { + return input.selectedCompanyId; + } + if (input.storedCompanyId && selectableCompanies.some((company) => company.id === input.storedCompanyId)) { + return input.storedCompanyId; + } + return selectableCompanies[0]?.id ?? null; +} + +export function shouldClearStoredCompanySelection(input: { + companies: Array>; + isLoading: boolean; + unauthorized: boolean; +}) { + return !input.isLoading && !input.unauthorized && input.companies.length === 0; +} + export function CompanyProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); const [selectionSource, setSelectionSource] = useState("bootstrap"); const [selectedCompanyId, setSelectedCompanyIdState] = useState(null); - const { data: companies = [], isLoading, error } = useQuery({ + const { data: companiesResult = { companies: [], unauthorized: false }, isLoading, error } = useQuery({ queryKey: queryKeys.companies.all, queryFn: async () => { try { - return await companiesApi.list(); + return { companies: await companiesApi.list(), unauthorized: false }; } catch (err) { if (err instanceof ApiError && err.status === 401) { - return []; + return { companies: [], unauthorized: true }; } throw err; } }, retry: false, }); + const companies = companiesResult.companies; + const companyListUnauthorized = companiesResult.unauthorized; const sidebarCompanies = useMemo( () => companies.filter((company) => company.status !== "archived"), [companies], @@ -63,24 +94,26 @@ export function CompanyProvider({ children }: { children: ReactNode }) { useEffect(() => { if (isLoading) return; if (companies.length === 0) { - if (selectedCompanyId !== null) { - setSelectedCompanyIdState(null); + if (shouldClearStoredCompanySelection({ companies, isLoading: false, unauthorized: companyListUnauthorized })) { + if (selectedCompanyId !== null) { + setSelectedCompanyIdState(null); + } + localStorage.removeItem(STORAGE_KEY); } - localStorage.removeItem(STORAGE_KEY); return; } - const selectableCompanies = sidebarCompanies.length > 0 ? sidebarCompanies : companies; - if (selectedCompanyId && selectableCompanies.some((c) => c.id === selectedCompanyId)) return; - - const stored = localStorage.getItem(STORAGE_KEY); - const next = stored && selectableCompanies.some((c) => c.id === stored) - ? stored - : selectableCompanies[0]!.id; + const next = resolveBootstrapCompanySelection({ + companies, + sidebarCompanies, + selectedCompanyId, + storedCompanyId: localStorage.getItem(STORAGE_KEY), + }); + if (next === null || next === selectedCompanyId) return; setSelectedCompanyIdState(next); setSelectionSource("bootstrap"); localStorage.setItem(STORAGE_KEY, next); - }, [companies, isLoading, selectedCompanyId, sidebarCompanies]); + }, [companies, companyListUnauthorized, isLoading, selectedCompanyId, sidebarCompanies]); const setSelectedCompanyId = useCallback((companyId: string, options?: CompanySelectionOptions) => { setSelectedCompanyIdState(companyId);