Merge origin/master into master

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-27 15:12:02 -05:00
commit 0c170712e1
4 files changed
+265 -67

No files matched your search

@@ -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({
+45 -27
View File
@@ -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<ReturnType<typeof issuesSvc.create>>;
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",
+109 -23
View File
@@ -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 <div data-selected-company-id={selectedCompanyId ?? ""} />;
}
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<Company[]>((resolve) => {
resolveCompanies = resolve;
});
mockCompaniesApi.list.mockReturnValue(companiesPromise);
const seen: Array<string | null> = [];
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");
+47 -14
View File
@@ -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<CompanyContextValue | null>(null);
export function resolveBootstrapCompanySelection(input: {
companies: Array<Pick<Company, "id">>;
sidebarCompanies: Array<Pick<Company, "id">>;
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<Pick<Company, "id">>;
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<CompanySelectionSource>("bootstrap");
const [selectedCompanyId, setSelectedCompanyIdState] = useState<string | null>(null);
const { data: companies = [], isLoading, error } = useQuery({
const { data: companiesResult = { companies: [], unauthorized: false }, isLoading, error } = useQuery<CompanyListResult>({
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);