@@ -1300,6 +948,18 @@ function PrettyPreview({ value }: { value: unknown }) {
if (typeof value === "string") {
return {value};
}
+ if (value && typeof value === "object" && !Array.isArray(value)) {
+ return (
+
+ {Object.entries(value as Record).map(([key, field]) => (
+
+
- {key}
+ - {typeof field === "object" && field !== null ? safeStringify(collapseDeep(field, 2)) : cellText(field)}
+
+ ))}
+
+ );
+ }
return (
{safeStringify(collapseDeep(value, 2))}
@@ -1307,6 +967,42 @@ function PrettyPreview({ value }: { value: unknown }) {
);
}
+function PreviewRowCard({ row, index }: { row: Record; index: number }) {
+ const titleKey = ["title", "name", "label"].find((key) => typeof row[key] === "string" && row[key] !== "");
+ const summaryKey = ["highlight", "description", "summary"].find((key) => typeof row[key] === "string" && row[key] !== "");
+ const url = typeof row.url === "string" && /^https?:\/\//i.test(row.url) ? row.url : null;
+ const details = Object.entries(row)
+ .filter(([key]) => key !== titleKey && key !== summaryKey && (key !== "url" || !url))
+ .sort(([left], [right]) => {
+ const order = ["url", "type", "status", "timestamp", "id"];
+ const leftRank = order.indexOf(left);
+ const rightRank = order.indexOf(right);
+ return (leftRank < 0 ? order.length : leftRank) - (rightRank < 0 ? order.length : rightRank);
+ });
+ return (
+
+
+
+ {titleKey ? cellText(row[titleKey]) : `Result ${index + 1}`}
+
+ {url &&
Open link}
+
+ {summaryKey &&
{cellText(row[summaryKey]).replace(/\*\*(.*?)\*\*/g, "$1")}
}
+ {details.length > 0 && (
+
+ {details.slice(0, 3).map(([key, field]) => (
+
+
- {key}
+ - {cellText(field)}
+
+ ))}
+
+ )}
+ {details.length > 3 &&
{details.length - 3} more {details.length === 4 ? "field" : "fields"} in the raw response
}
+
+ );
+}
+
function cellText(value: unknown): string {
if (value === null || value === undefined) return "—";
if (typeof value === "object") return Array.isArray(value) ? `[${value.length}]` : "{…}";
@@ -1325,8 +1021,8 @@ function collapseDeep(value: unknown, maxDepth: number, depth = 0): unknown {
return out;
}
-function RawResponseDisclosure({ value }: { value: unknown }) {
- const [showRaw, setShowRaw] = useState(false);
+function RawResponseDisclosure({ value, initiallyOpen = false }: { value: unknown; initiallyOpen?: boolean }) {
+ const [showRaw, setShowRaw] = useState(initiallyOpen);
if (value === undefined || value === null) return null;
return (
@@ -1384,6 +1080,7 @@ function ErrorResult({
))}
+ {outcome.result.result !== undefined && }
{needsReconnect ? "After reconnecting, run this action again." : "Adjust the input above and try again."}
diff --git a/ui/src/pages/apps/app-detail/PermissionsPanel.tsx b/ui/src/pages/apps/app-detail/PermissionsPanel.tsx
index 033a35f881..aa08b887c0 100644
--- a/ui/src/pages/apps/app-detail/PermissionsPanel.tsx
+++ b/ui/src/pages/apps/app-detail/PermissionsPanel.tsx
@@ -12,7 +12,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { cn } from "@/lib/utils";
import { type InstallState } from "@/lib/tool-installs";
import { QuarantinedActionsReview } from "./SetupPanel";
-import { ActionTestDialog } from "./TestPanel";
+import { ActionTestDialog } from "./ActionTestDialog";
import type { AccessDraft, AppDetailSectionProps } from "./types";
type ActionPermission = "off" | "ask" | "allowed";
diff --git a/ui/src/pages/apps/app-detail/TestPanel.test.tsx b/ui/src/pages/apps/app-detail/TestPanel.test.tsx
deleted file mode 100644
index 3b14ea5c5a..0000000000
--- a/ui/src/pages/apps/app-detail/TestPanel.test.tsx
+++ /dev/null
@@ -1,575 +0,0 @@
-// @vitest-environment jsdom
-
-import { flushSync } from "react-dom";
-import type { ReactNode } from "react";
-import { createRoot } from "react-dom/client";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import type { ToolCatalogEntry } from "@paperclipai/shared";
-import { TestPanel, errorHints } from "./TestPanel";
-
-const listTestAgentsMock = vi.hoisted(() => vi.fn());
-const getTestAgentAccessMock = vi.hoisted(() => vi.fn());
-const runTestCallMock = vi.hoisted(() => vi.fn());
-const getTestCallStatusMock = vi.hoisted(() => vi.fn());
-const declineActionRequestMock = vi.hoisted(() => vi.fn());
-
-vi.mock("@/api/tools", () => ({
- toolsApi: {
- listTestAgents: (connectionId: string) => listTestAgentsMock(connectionId),
- getTestAgentAccess: (connectionId: string, agentId: string) =>
- getTestAgentAccessMock(connectionId, agentId),
- runTestCall: (connectionId: string, input: unknown) => runTestCallMock(connectionId, input),
- getTestCallStatus: (connectionId: string, actionRequestId: string) =>
- getTestCallStatusMock(connectionId, actionRequestId),
- declineActionRequest: (companyId: string, actionRequestId: string) =>
- declineActionRequestMock(companyId, actionRequestId),
- },
-}));
-
-vi.mock("@/context/CompanyContext", () => ({
- useCompany: () => ({ selectedCompanyId: "company-1", selectedCompany: { id: "company-1", name: "Paperclip" } }),
-}));
-
-vi.mock("@/lib/router", () => ({
- Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
-
- {children}
-
- ),
-}));
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
-
-async function act(callback: () => void | Promise) {
- let result: void | Promise = undefined;
- flushSync(() => {
- result = callback();
- });
- await result;
-}
-
-async function flushReact() {
- for (let i = 0; i < 4; i += 1) {
- await act(async () => {
- await Promise.resolve();
- await new Promise((resolve) => window.setTimeout(resolve, 0));
- });
- }
-}
-
-/** The form field input — excludes the "Find an action" search box. */
-function formInput(): HTMLInputElement {
- const inputs = [...container.querySelectorAll("input")].filter(
- (i) => i.getAttribute("aria-label") !== "Find an action",
- );
- return inputs[0] as HTMLInputElement;
-}
-
-async function fillFormField(value: string) {
- const input = formInput();
- expect(input).toBeTruthy();
- const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
- await act(async () => {
- setter.call(input, value);
- input.dispatchEvent(new Event("input", { bubbles: true }));
- });
- await flushReact();
-}
-
-/** Wait past the 200ms minimum-spinner delay so the result panel renders. */
-async function settle() {
- await act(async () => {
- await new Promise((resolve) => window.setTimeout(resolve, 260));
- });
- await flushReact();
-}
-
-async function clickByText(text: string) {
- const btn = [...container.querySelectorAll("button")].find((b) => b.textContent?.trim() === text);
- expect(btn).toBeTruthy();
- await act(async () => btn!.click());
- await flushReact();
-}
-
-function catalogEntry(overrides: Partial = {}): ToolCatalogEntry {
- return {
- id: "catalog-read",
- companyId: "company-1",
- applicationId: "app-1",
- connectionId: "conn-1",
- entryKind: "tool",
- toolName: "read_sheet",
- title: "Read a sheet",
- description: "Get rows and cell values from a sheet.",
- inputSchema: {
- type: "object",
- properties: { spreadsheetId: { type: "string", title: "Spreadsheet" } },
- required: ["spreadsheetId"],
- },
- outputSchema: null,
- annotations: null,
- riskLevel: "read",
- isReadOnly: true,
- isWrite: false,
- isDestructive: false,
- status: "active",
- addedAt: new Date("2026-01-01T00:00:00Z"),
- version: null,
- schemaHash: null,
- firstSeenAt: new Date("2026-01-01T00:00:00Z"),
- lastSeenAt: new Date("2026-01-01T00:00:00Z"),
- reviewedAt: null,
- reviewedByAgentId: null,
- reviewedByUserId: null,
- createdAt: new Date("2026-01-01T00:00:00Z"),
- updatedAt: new Date("2026-01-01T00:00:00Z"),
- ...overrides,
- } as ToolCatalogEntry;
-}
-
-function agent(overrides: Record = {}) {
- return {
- id: "agent-claude",
- name: "ClaudeCoder",
- role: "engineer",
- title: "Engineer",
- status: "active",
- orgDepth: 1,
- effectiveAccess: {
- connectionId: "conn-1",
- toolCount: 3,
- allowedCount: 1,
- askFirstCount: 1,
- offCount: 1,
- lastChangedAt: null,
- lastChangedByAgentId: null,
- lastChangedByName: null,
- tools: [
- { toolName: "read_sheet", gatewayToolName: "gs__read_sheet", displayName: "Read a sheet", risk: "read", decision: "allowed", reasonCode: null, matchedPolicyIds: [] },
- { toolName: "append_row", gatewayToolName: "gs__append_row", displayName: "Append a row", risk: "write", decision: "ask_first", reasonCode: null, matchedPolicyIds: [] },
- { toolName: "delete_row", gatewayToolName: "gs__delete_row", displayName: "Delete a row", risk: "destructive", decision: "off", reasonCode: null, matchedPolicyIds: [] },
- ],
- },
- ...overrides,
- };
-}
-
-const readEntry = catalogEntry();
-const writeAskEntry = catalogEntry({
- id: "catalog-append",
- toolName: "append_row",
- title: "Append a row",
- description: "Add a new row to the bottom of a sheet.",
- isReadOnly: false,
- isWrite: true,
- riskLevel: "write",
-});
-const offEntry = catalogEntry({
- id: "catalog-delete",
- toolName: "delete_row",
- title: "Delete a row",
- description: "Remove a row from a sheet permanently.",
- isReadOnly: false,
- isWrite: true,
- isDestructive: true,
- riskLevel: "destructive",
-});
-
-let container: HTMLDivElement;
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-let root: any;
-let client: QueryClient;
-
-function renderPanel(
- active: ToolCatalogEntry[] = [readEntry, writeAskEntry, offEntry],
- quarantined: ToolCatalogEntry[] = [],
-) {
- root.render(
-
-
- ,
- );
-}
-
-beforeEach(() => {
- container = document.createElement("div");
- document.body.appendChild(container);
- root = createRoot(container);
- client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
- listTestAgentsMock.mockReset();
- getTestAgentAccessMock.mockReset();
- runTestCallMock.mockReset();
- getTestCallStatusMock.mockReset();
- declineActionRequestMock.mockReset();
- listTestAgentsMock.mockResolvedValue({ agents: [agent()] });
- getTestAgentAccessMock.mockResolvedValue({ access: agent().effectiveAccess });
- // Default ask-first polls report the request still waiting on approval.
- getTestCallStatusMock.mockResolvedValue({
- actionRequestId: "req-1",
- invocationId: "inv-2",
- phase: "waiting",
- parameters: { spreadsheetId: "sheet-123" },
- requestedAt: "2026-06-18T00:00:00.000Z",
- resolvedAt: null,
- durationMs: null,
- });
- window.sessionStorage.clear();
-});
-
-afterEach(() => {
- act(() => root.unmount());
- container.remove();
- window.sessionStorage.clear();
-});
-
-describe("TestPanel", () => {
- it("shows a lightweight agent-loading state before requesting permissions", async () => {
- listTestAgentsMock.mockImplementation(() => new Promise(() => undefined));
-
- await act(async () => renderPanel());
-
- expect(container.textContent).toContain("Loading agents…");
- expect(container.querySelector(".animate-spin")).toBeTruthy();
- expect(container.querySelectorAll('[data-slot="skeleton"]')).not.toHaveLength(0);
- expect(getTestAgentAccessMock).not.toHaveBeenCalled();
- });
-
- it("loads permissions only for the selected agent and reuses the cached summary", async () => {
- await act(async () => renderPanel());
- await flushReact();
-
- expect(getTestAgentAccessMock).toHaveBeenCalledTimes(1);
- expect(getTestAgentAccessMock).toHaveBeenCalledWith("conn-1", "agent-claude");
-
- await act(async () => root.unmount());
- root = createRoot(container);
- await act(async () => renderPanel());
- await flushReact();
-
- expect(getTestAgentAccessMock).toHaveBeenCalledTimes(1);
- });
-
- it("renders the test hierarchy and grouped actions with access badges", async () => {
- await act(async () => renderPanel());
- await flushReact();
-
- expect(container.textContent).toContain("Test an action");
- expect(container.textContent).toContain("Agent");
- expect(container.textContent).toContain("ClaudeCoder");
- expect(container.textContent).toContain("Allowed for 1 action · Ask first for 1 action · Off for 1 action");
- expect(container.textContent).toContain("Read (1)");
- expect(container.textContent).toContain("Write (2)");
- // Each action shows its decision badge.
- expect(container.textContent).toContain("Allowed");
- expect(container.textContent).toContain("Ask first");
- expect(container.textContent).toContain("Off");
- expect(container.querySelector(".bg-card")).toBeNull();
- });
-
- it("defaults to the highest-ranked accessible agent", async () => {
- listTestAgentsMock.mockResolvedValue({
- agents: [
- agent({ id: "agent-report", name: "A report", orgDepth: 2 }),
- agent({ id: "agent-root", name: "Root agent", orgDepth: 0 }),
- agent({ id: "agent-manager", name: "Manager", orgDepth: 1 }),
- ],
- });
-
- await act(async () => renderPanel());
- await flushReact();
-
- expect(container.textContent).toContain("Root agent");
- expect(container.textContent).not.toContain("A report");
- });
-
- it("shows the empty state when there are no actions", async () => {
- await act(async () => renderPanel([]));
- await flushReact();
- expect(container.textContent).toContain("Nothing to test yet");
- expect(container.textContent).toContain("Go to Permissions");
- });
-
- it("renders an allowed result panel with a row-count headline after a successful run", async () => {
- runTestCallMock.mockResolvedValue({
- decision: "allowed",
- invocationId: "inv-1",
- result: [
- { name: "Acme", stage: "Demo" },
- { name: "Globex", stage: "Trial" },
- ],
- });
- await act(async () => renderPanel());
- await flushReact();
-
- // Expand the allowed (read) action.
- const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet"));
- expect(trigger).toBeTruthy();
- await act(async () => trigger!.click());
- await flushReact();
-
- // Fill the required field then Run.
- await fillFormField("sheet-123");
- await clickByText("Run");
- await settle();
-
- expect(runTestCallMock).toHaveBeenCalledWith("conn-1", {
- agentId: "agent-claude",
- toolName: "read_sheet",
- parameters: { spreadsheetId: "sheet-123" },
- });
- expect(container.textContent).toContain("Worked. 2 rows came back.");
- expect(container.textContent).toContain("Ran as ClaudeCoder");
- expect(container.textContent).toContain("Preview");
- });
-
- it.each(["grant_credential_invalid", "oauth_insufficient_scope"])("guides %s failures to reconnect instead of changing action inputs", async (reasonCode) => {
- runTestCallMock.mockResolvedValue({
- decision: "allowed",
- invocationId: "inv-credential",
- error: { message: "The connection must be reconnected.", reasonCode },
- });
- await act(async () => renderPanel());
- await flushReact();
- const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet"));
- await act(async () => trigger!.click());
- await flushReact();
- await fillFormField("sheet-123");
- await clickByText("Run");
- await settle();
-
- expect(container.textContent).toContain("After reconnecting, run this action again.");
- expect(container.textContent).not.toContain("Adjust the input above");
- expect(container.textContent).not.toContain("Check the field formats");
- expect(container.textContent).toContain(reasonCode === "grant_credential_invalid"
- ? "Ask the connection owner to reconnect"
- : "allow the permissions required for this action");
- });
-
- it("renders a failure card (not 'Worked') when an allowed call returns isError:true", async () => {
- // The gateway let the call through (decision:"allowed") but the upstream MCP
- // tool failed at the tool layer — the envelope carries isError + an error.
- runTestCallMock.mockResolvedValue({
- decision: "allowed",
- invocationId: "inv-err",
- result: {
- content: "Invalid arguments: range is required",
- data: { content: [{ type: "text", text: "Invalid arguments: range is required" }], isError: true },
- error: "MCP tool returned an error result",
- },
- });
- await act(async () => renderPanel());
- await flushReact();
-
- const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet"));
- await act(async () => trigger!.click());
- await flushReact();
- await fillFormField("sheet-123");
- await clickByText("Run");
- await settle();
-
- expect(container.textContent).toContain("It didn't work.");
- expect(container.textContent).not.toContain("Worked.");
- // The actual MCP message wins over the generic gateway wrapper string.
- expect(container.textContent).toContain("Invalid arguments: range is required");
- });
-
- it("renders the ask-first card linking to Review", async () => {
- runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
- await act(async () => renderPanel());
- await flushReact();
-
- const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
- await act(async () => trigger!.click());
- await flushReact();
-
- await fillFormField("sheet-123");
- await clickByText("Run");
- await settle();
-
- expect(container.textContent).toContain("Sent for your OK.");
- expect(container.textContent).toContain("Cancel this request");
- // "Where" is populated from the polled action-request snapshot.
- expect(container.textContent).toContain("spreadsheetId: sheet-123");
- expect(container.textContent).toContain("Waiting ·");
- const reviewLink = [...container.querySelectorAll("a")].find((a) => a.textContent?.includes("Open Review tab"));
- expect(reviewLink?.getAttribute("href")).toBe("/apps/conn-1/review");
- });
-
- it("restores the ask-first card after leaving and returning to the Test tab", async () => {
- runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
- await act(async () => renderPanel());
- await flushReact();
-
- const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
- await act(async () => trigger!.click());
- await flushReact();
- await fillFormField("sheet-123");
- await clickByText("Run");
- await settle();
- expect(container.textContent).toContain("Sent for your OK.");
-
- await act(async () => root.unmount());
- root = createRoot(container);
- await act(async () => renderPanel());
- await flushReact();
-
- expect(runTestCallMock).toHaveBeenCalledTimes(1);
- expect(container.textContent).toContain("Sent for your OK.");
- expect(container.textContent).toContain("spreadsheetId: sheet-123");
- expect(container.textContent).toContain("Cancel this request");
- });
-
- it("shows 'Approved · running' while an approved ask-first call is executing", async () => {
- runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
- getTestCallStatusMock.mockResolvedValue({
- actionRequestId: "req-1",
- invocationId: "inv-2",
- phase: "running",
- parameters: { spreadsheetId: "sheet-123" },
- requestedAt: "2026-06-18T00:00:00.000Z",
- resolvedAt: "2026-06-18T00:00:05.000Z",
- durationMs: null,
- });
- await act(async () => renderPanel());
- await flushReact();
-
- const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
- await act(async () => trigger!.click());
- await flushReact();
- await fillFormField("sheet-123");
- await clickByText("Run");
- await settle();
-
- expect(container.textContent).toContain("Approved · running");
- // While running we don't offer cancel — the call is already on its way.
- expect(container.textContent).not.toContain("Cancel this request");
- });
-
- it("mutates the ask-first card into the allowed result once approved and done", async () => {
- runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
- getTestCallStatusMock.mockResolvedValue({
- actionRequestId: "req-1",
- invocationId: "inv-2",
- phase: "done",
- parameters: { spreadsheetId: "sheet-123" },
- result: [{ name: "Acme", stage: "Demo" }],
- requestedAt: "2026-06-18T00:00:00.000Z",
- resolvedAt: "2026-06-18T00:00:05.000Z",
- durationMs: 1200,
- });
- await act(async () => renderPanel());
- await flushReact();
-
- const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
- await act(async () => trigger!.click());
- await flushReact();
- await fillFormField("sheet-123");
- await clickByText("Run");
- await settle();
-
- // No longer the pending card — the real response is shown without re-running.
- expect(container.textContent).not.toContain("Sent for your OK.");
- expect(container.textContent).toContain("Worked.");
- expect(container.textContent).toContain("1.2s");
- });
-
- it("shows the denied status when an ask-first call is declined in Review", async () => {
- runTestCallMock.mockResolvedValue({ decision: "ask_first", invocationId: "inv-2", actionRequestId: "req-1" });
- getTestCallStatusMock.mockResolvedValue({
- actionRequestId: "req-1",
- invocationId: "inv-2",
- phase: "denied",
- parameters: { spreadsheetId: "sheet-123" },
- requestedAt: "2026-06-18T00:00:00.000Z",
- resolvedAt: "2026-06-18T00:00:05.000Z",
- durationMs: null,
- });
- await act(async () => renderPanel());
- await flushReact();
-
- const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Append a row"));
- await act(async () => trigger!.click());
- await flushReact();
- await fillFormField("sheet-123");
- await clickByText("Run");
- await settle();
-
- expect(container.textContent).toContain("Denied — see Review for why");
- expect(container.textContent).not.toContain("Cancel this request");
- });
-
- it("explains an off action and links to Permissions without calling the API", async () => {
- await act(async () => renderPanel());
- await flushReact();
-
- const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Delete a row"));
- await act(async () => trigger!.click());
- await flushReact();
-
- expect(container.textContent).toContain("Delete a row is off for ClaudeCoder.");
- const permLink = [...container.querySelectorAll("a")].find((a) => a.getAttribute("href") === "/apps/conn-1/permissions");
- expect(permLink).toBeTruthy();
- // No Run button is offered for an off action.
- expect([...container.querySelectorAll("button")].some((b) => b.textContent?.trim() === "Run")).toBe(false);
- expect(runTestCallMock).not.toHaveBeenCalled();
- });
-
- it("shows the 'Last changed by' audit hint when the access summary carries one", async () => {
- getTestAgentAccessMock.mockResolvedValue({
- access: {
- ...agent().effectiveAccess,
- lastChangedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
- lastChangedByAgentId: "agent-admin",
- lastChangedByName: "Dotta",
- },
- });
- await act(async () => renderPanel());
- await flushReact();
-
- const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Delete a row"));
- await act(async () => trigger!.click());
- await flushReact();
-
- expect(container.textContent).toContain("Last changed by Dotta");
- });
-
- it("surfaces quarantined actions in a 'New' group with the not-yet-on explanation", async () => {
- const quarantinedEntry = catalogEntry({
- id: "catalog-new",
- toolName: "rename_sheet",
- title: "Rename a sheet",
- description: "Change a sheet's name.",
- isReadOnly: false,
- isWrite: true,
- riskLevel: "write",
- status: "quarantined",
- });
- await act(async () => renderPanel([readEntry, writeAskEntry, offEntry], [quarantinedEntry]));
- await flushReact();
-
- expect(container.textContent).toContain("New (1)");
-
- const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Rename a sheet"));
- expect(trigger).toBeTruthy();
- await act(async () => trigger!.click());
- await flushReact();
-
- expect(container.textContent).toContain("This action is new and hasn't been turned on yet.");
- expect(runTestCallMock).not.toHaveBeenCalled();
- });
-});
-
-describe("errorHints", () => {
- it("maps known reason codes to tailored hints", () => {
- expect(errorHints("Requested entity was not found.", "NOT_FOUND").length).toBeGreaterThan(0);
- expect(errorHints("permission denied", "PERMISSION_DENIED")[0]).toContain("permission");
- expect(errorHints("bad input", "INVALID_ARGUMENT").length).toBeGreaterThan(0);
- expect(errorHints("slow down", "RATE_LIMIT")[0]).toContain("rate-limit");
- });
-
- it("returns the locked generic fallback for an unknown error", () => {
- expect(errorHints("boom", "tool_execution_failed")).toEqual(["Check the inputs above and try again."]);
- });
-});
diff --git a/ui/storybook/stories/action-permissions-test.stories.tsx b/ui/storybook/stories/action-permissions-test.stories.tsx
new file mode 100644
index 0000000000..38f2601ca3
--- /dev/null
+++ b/ui/storybook/stories/action-permissions-test.stories.tsx
@@ -0,0 +1,243 @@
+import { useEffect, useMemo } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ToolCatalogEntry, ToolConnectionTestCallResult } from "@paperclipai/shared";
+import { queryKeys } from "@/lib/queryKeys";
+import { ActionsSection } from "@/pages/apps/app-detail/PermissionsPanel";
+
+const CONNECTION = "conn-asana-storybook";
+const TOOL: ToolCatalogEntry = {
+ id: "asana-list-projects",
+ companyId: "company-storybook",
+ applicationId: "app-asana",
+ connectionId: CONNECTION,
+ entryKind: "tool",
+ toolName: "list_projects",
+ title: "List projects",
+ description: "Find projects available in this Asana workspace.",
+ inputSchema: { type: "object", properties: {} },
+ outputSchema: null,
+ annotations: null,
+ riskLevel: "read",
+ isReadOnly: true,
+ isWrite: false,
+ isDestructive: false,
+ status: "active",
+ addedAt: new Date("2026-09-01T00:00:00Z"),
+ version: null,
+ schemaHash: null,
+ firstSeenAt: new Date("2026-09-01T00:00:00Z"),
+ lastSeenAt: new Date("2026-10-01T00:00:00Z"),
+ reviewedAt: null,
+ reviewedByAgentId: null,
+ reviewedByUserId: null,
+ createdAt: new Date("2026-09-01T00:00:00Z"),
+ updatedAt: new Date("2026-10-01T00:00:00Z"),
+} as ToolCatalogEntry;
+
+const PROJECTS = [
+ { gid: "1214644669600482", name: "Q4 launch", resource_subtype: "default_project", team: "Marketing" },
+ { gid: "1214644669600483", name: "Customer onboarding", resource_subtype: "default_project", team: "Product" },
+ { gid: "1214644669600484", name: "Support operations", resource_subtype: "default_project", team: "Operations" },
+];
+
+const NOTION_TOOL = {
+ ...TOOL,
+ id: "notion-search",
+ applicationId: "app-notion",
+ toolName: "search",
+ title: "Search Notion",
+ description: "Search pages in your Notion workspace.",
+ inputSchema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
+} as ToolCatalogEntry;
+
+const NOTION_RESULTS = [
+ {
+ id: "11111111-2222-4333-8444-555555555555",
+ title: "Northstar launch plan",
+ url: "https://www.notion.so/11111111222243338444555555555555",
+ type: "page",
+ highlight: "The team is tracking launch milestones, owners, dependencies, and the decisions needed before the release window.",
+ timestamp: "2026-09-10T14:30:00.000Z",
+ },
+ {
+ id: "66666666-7777-4888-8999-aaaaaaaaaaaa",
+ title: "Launch customer research",
+ url: "https://www.notion.so/66666666777748888999aaaaaaaaaaaa",
+ type: "page",
+ highlight: "Interviews with early customers point to a clearer onboarding flow and better examples for complex team setups.",
+ timestamp: "2026-09-08T09:15:00.000Z",
+ },
+];
+
+function mcpResult(content: Array>, structuredContent: unknown = null, isError = false) {
+ return {
+ content: content.map((block) => block.type === "text" ? block.text : JSON.stringify(block)).join("\n"),
+ data: { content, structuredContent, isError, transport: "mcp_http", spawnedLocalProcess: false },
+ ...(isError ? { error: "MCP tool returned an error result" } : {}),
+ };
+}
+
+function seededClient(tool: ToolCatalogEntry) {
+ const client = new QueryClient({
+ defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
+ });
+ client.setQueryData(queryKeys.tools.testAgents(CONNECTION), {
+ agents: [{ id: "agent-ceo", name: "CEO", role: "ceo", title: "CEO", status: "active", orgDepth: 0 }],
+ });
+ client.setQueryData(queryKeys.tools.testAgentAccess(CONNECTION, "agent-ceo"), {
+ access: {
+ connectionId: CONNECTION,
+ toolCount: 1,
+ allowedCount: 1,
+ askFirstCount: 0,
+ offCount: 0,
+ lastChangedAt: null,
+ lastChangedByAgentId: null,
+ lastChangedByName: null,
+ tools: [{
+ toolName: tool.toolName,
+ gatewayToolName: `${tool.applicationId}__${tool.toolName}`,
+ displayName: tool.title,
+ risk: "read",
+ decision: "allowed",
+ reasonCode: null,
+ matchedPolicyIds: [],
+ }],
+ },
+ });
+ return client;
+}
+
+function openAndRun(query?: string) {
+ let step = 0;
+ const tick = (attempt: number) => {
+ if (step === 0) {
+ const button = [...document.querySelectorAll("button")].find((item) => item.textContent?.trim() === "Test");
+ if (button) { button.click(); step = 1; }
+ } else {
+ const dialog = document.querySelector('[role="dialog"]');
+ if (query && step === 1) {
+ const input = dialog?.querySelector('input[placeholder=""]') as HTMLInputElement | null;
+ if (input) {
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, query);
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ step = 2;
+ }
+ }
+ const run = [...(dialog?.querySelectorAll("button") ?? [])].find((item) => item.textContent?.trim() === "Run");
+ if (run && (!query || step === 2)) { run.click(); return; }
+ }
+ if (attempt < 80) window.setTimeout(() => tick(attempt + 1), 50);
+ };
+ tick(0);
+}
+
+function PermissionsTestStory({ result, tool = TOOL, appName = "Asana", query }: {
+ result: ToolConnectionTestCallResult;
+ tool?: ToolCatalogEntry;
+ appName?: string;
+ query?: string;
+}) {
+ const client = useMemo(() => seededClient(tool), [tool]);
+ useEffect(() => {
+ const original = window.fetch.bind(window);
+ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.includes(`/tool-connections/${CONNECTION}/test-calls`)) {
+ await new Promise((resolve) => window.setTimeout(resolve, 120));
+ return Response.json(result);
+ }
+ return original(input, init);
+ };
+ const timer = window.setTimeout(() => openAndRun(query), 150);
+ return () => { window.clearTimeout(timer); window.fetch = original; };
+ }, [query, result]);
+ return (
+
+
+
undefined}
+ onReviewQuarantined={() => undefined}
+ onRefreshActions={() => undefined}
+ />
+
+
+ );
+}
+
+const meta = {
+ title: "Apps/Action permissions test",
+ parameters: { layout: "fullscreen" },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+export const StructuredRows: Story = {
+ name: "Structured rows",
+ render: () => ,
+};
+
+export const StructuredOnly: Story = {
+ name: "Structured data without content blocks",
+ render: () => ,
+};
+
+export const JsonInText: Story = {
+ name: "JSON inside text",
+ render: () => ,
+};
+
+export const NotionWideSearch: Story = {
+ name: "Notion search with long fields",
+ render: () => ,
+};
+
+export const MixedBlocks: Story = {
+ name: "Mixed content blocks",
+ render: () => ,
+};
+
+export const ToolError: Story = {
+ name: "Tool error with raw response",
+ render: () => ,
+};
+
+export const MalformedBlock: Story = {
+ name: "Unexpected block with raw fallback",
+ render: () => ,
+};
diff --git a/ui/storybook/stories/test-tab.stories.tsx b/ui/storybook/stories/test-tab.stories.tsx
deleted file mode 100644
index e3a7f8391d..0000000000
--- a/ui/storybook/stories/test-tab.stories.tsx
+++ /dev/null
@@ -1,413 +0,0 @@
-import { useEffect, useMemo, useRef } from "react";
-import type { Meta, StoryObj } from "@storybook/react-vite";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import type {
- ToolCatalogEntry,
- ToolConnectionAccessSummary,
- ToolConnectionTestAgent,
- ToolConnectionTestCallResult,
- ToolConnectionTestDecision,
-} from "@paperclipai/shared";
-import { queryKeys } from "@/lib/queryKeys";
-import { TestPanel } from "@/pages/apps/app-detail/TestPanel";
-
-const CONNECTION = "conn-sheets";
-
-type StoryAgent = ToolConnectionTestAgent & {
- effectiveAccess: ToolConnectionAccessSummary;
-};
-
-// ---------------------------------------------------------------------------
-// Catalog (12 actions: 7 read, 5 write) — mirrors the PAP-11348 wireframes.
-// ---------------------------------------------------------------------------
-
-function tool(
- id: string,
- toolName: string,
- title: string,
- description: string,
- read: boolean,
- inputSchema: Record | null = null,
-): ToolCatalogEntry {
- return {
- id,
- companyId: "company-storybook",
- applicationId: "app-sheets",
- connectionId: CONNECTION,
- entryKind: "tool",
- name: toolName,
- toolName,
- title,
- description,
- inputSchema,
- outputSchema: null,
- annotations: null,
- riskLevel: read ? "read" : "write",
- isReadOnly: read,
- isWrite: !read,
- isDestructive: false,
- status: "active",
- addedAt: new Date("2026-06-01T00:00:00Z"),
- version: null,
- versionHash: null,
- schemaHash: null,
- firstSeenAt: new Date("2026-06-01T00:00:00Z"),
- lastSeenAt: new Date("2026-06-01T00:00:00Z"),
- reviewedAt: null,
- reviewedByAgentId: null,
- reviewedByUserId: null,
- createdAt: new Date("2026-06-01T00:00:00Z"),
- updatedAt: new Date("2026-06-01T00:00:00Z"),
- } as ToolCatalogEntry;
-}
-
-const READ_SHEET_SCHEMA = {
- type: "object",
- properties: {
- spreadsheetId: { type: "string", title: "Spreadsheet" },
- sheet: { type: "string", title: "Sheet" },
- range: { type: "string", title: "Range", description: "e.g. A1:F50" },
- },
- required: ["spreadsheetId", "sheet"],
-};
-
-const APPEND_ROW_SCHEMA = {
- type: "object",
- properties: {
- spreadsheetId: { type: "string", title: "Spreadsheet" },
- sheet: { type: "string", title: "Sheet" },
- values: { type: "string", title: "Values", description: "Comma-separated cell values" },
- },
- required: ["spreadsheetId", "sheet", "values"],
-};
-
-const DELETE_ROW_SCHEMA = {
- type: "object",
- properties: {
- spreadsheetId: { type: "string", title: "Spreadsheet" },
- rowNumber: { type: "number", title: "Row number" },
- },
- required: ["spreadsheetId", "rowNumber"],
-};
-
-const CATALOG: ToolCatalogEntry[] = [
- tool("r1", "list_spreadsheets", "List spreadsheets", "See the spreadsheets in this account.", true),
- tool("r2", "read_sheet", "Read a sheet", "Get rows and cell values from a sheet.", true, READ_SHEET_SCHEMA),
- tool("r3", "search_rows", "Search rows", "Find rows that match a query.", true),
- tool("r4", "get_cell", "Get a cell", "Read a single cell value.", true),
- tool("r5", "list_sheets", "List sheets", "See the tabs in a spreadsheet.", true),
- tool("r6", "get_metadata", "Get spreadsheet info", "Read a spreadsheet's title and tabs.", true),
- tool("r7", "describe_columns", "Describe columns", "List the column headers in a sheet.", true),
- tool("w1", "append_row", "Append a row", "Add a new row to the bottom of a sheet.", false, APPEND_ROW_SCHEMA),
- tool("w2", "update_cell", "Update a cell", "Change a single cell value.", false),
- tool("w3", "create_sheet", "Add a sheet", "Create a new tab in a spreadsheet.", false),
- tool("w4", "clear_range", "Clear a range", "Empty the cells in a range.", false),
- tool("w5", "delete_row", "Delete a row", "Remove a row from a sheet permanently.", false, DELETE_ROW_SCHEMA),
-];
-
-function decisionTool(entry: ToolCatalogEntry, decision: ToolConnectionTestDecision) {
- return {
- toolName: entry.toolName,
- gatewayToolName: `sheets__${entry.toolName}`,
- displayName: entry.title,
- risk: (entry.isReadOnly ? "read" : "write") as "read" | "write" | "destructive",
- decision,
- reasonCode: null,
- matchedPolicyIds: [],
- };
-}
-
-// Decisions: 9 allowed (7 read + 2 write), 2 ask first, 1 off.
-const DECISIONS: Record = {
- list_spreadsheets: "allowed",
- read_sheet: "allowed",
- search_rows: "allowed",
- get_cell: "allowed",
- list_sheets: "allowed",
- get_metadata: "allowed",
- describe_columns: "allowed",
- append_row: "ask_first",
- update_cell: "allowed",
- create_sheet: "allowed",
- clear_range: "ask_first",
- delete_row: "off",
-};
-
-function buildAgent(
- id: string,
- name: string,
- decisions: Record,
- orgDepth = 1,
-): StoryAgent {
- const tools = CATALOG.map((entry) => decisionTool(entry, decisions[entry.toolName]));
- return {
- id,
- name,
- role: "engineer",
- title: "Engineer",
- status: "active",
- orgDepth,
- effectiveAccess: {
- connectionId: CONNECTION,
- toolCount: tools.length,
- allowedCount: tools.filter((t) => t.decision === "allowed").length,
- askFirstCount: tools.filter((t) => t.decision === "ask_first").length,
- offCount: tools.filter((t) => t.decision === "off").length,
- lastChangedAt: null,
- lastChangedByAgentId: null,
- lastChangedByName: null,
- tools,
- },
- };
-}
-
-const AGENTS: StoryAgent[] = [
- buildAgent("agent-claude", "ClaudeCoder", DECISIONS, 0),
- buildAgent("agent-codex", "CodexCoder", {
- ...DECISIONS,
- create_sheet: "ask_first",
- clear_range: "off",
- }),
-];
-
-// ---------------------------------------------------------------------------
-// DOM driver — expand a row, fill its fields, press Run.
-// ---------------------------------------------------------------------------
-
-type Step =
- | { kind: "expand"; title: string }
- | { kind: "fill"; values: string[] }
- | { kind: "click"; label: string };
-
-function setInputValue(input: HTMLInputElement, value: string) {
- const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
- setter?.call(input, value);
- input.dispatchEvent(new Event("input", { bubbles: true }));
-}
-
-function runScript(steps: Step[]) {
- let index = 0;
- const tick = (attempt: number) => {
- if (index >= steps.length) return;
- const step = steps[index];
- if (step.kind === "expand") {
- const btn = Array.from(document.querySelectorAll("button")).find((b) =>
- b.textContent?.includes(step.title),
- );
- if (btn) {
- btn.click();
- index += 1;
- window.setTimeout(() => tick(0), 60);
- return;
- }
- } else if (step.kind === "fill") {
- const inputs = Array.from(document.querySelectorAll("input")).filter(
- (i) => i.getAttribute("aria-label") !== "Find an action" && i.getAttribute("aria-label") !== "Search agents",
- );
- if (inputs.length >= step.values.length) {
- step.values.forEach((value, i) => setInputValue(inputs[i] as HTMLInputElement, value));
- index += 1;
- window.setTimeout(() => tick(0), 60);
- return;
- }
- } else if (step.kind === "click") {
- const btn = Array.from(document.querySelectorAll("button")).find(
- (b) => b.textContent?.trim() === step.label,
- );
- if (btn) {
- btn.click();
- index += 1;
- window.setTimeout(() => tick(0), 60);
- return;
- }
- }
- if (attempt < 40) window.setTimeout(() => tick(attempt + 1), 50);
- };
- tick(0);
-}
-
-function seededClient(agents: StoryAgent[]): QueryClient {
- const client = new QueryClient({
- defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
- });
- client.setQueryData(queryKeys.tools.testAgents(CONNECTION), {
- agents: agents.map(({ effectiveAccess: _effectiveAccess, ...agent }) => agent),
- });
- for (const agent of agents) {
- client.setQueryData(queryKeys.tools.testAgentAccess(CONNECTION, agent.id), {
- access: agent.effectiveAccess,
- });
- }
- return client;
-}
-
-function TestHost({
- script,
- runResult,
- runDelayMs = 120,
- agents = AGENTS,
- quarantined = [],
-}: {
- script?: Step[];
- runResult?: ToolConnectionTestCallResult;
- runDelayMs?: number;
- agents?: StoryAgent[];
- quarantined?: ToolCatalogEntry[];
-}) {
- const client = useMemo(() => seededClient(agents), [agents]);
- const fetchPatched = useRef(false);
-
- // Stub the test-call POST so the run states render without a backend.
- useEffect(() => {
- if (fetchPatched.current) return;
- fetchPatched.current = true;
- const original = window.fetch.bind(window);
- window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
- const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
- if (url.includes("/test-calls")) {
- await new Promise((resolve) => window.setTimeout(resolve, runDelayMs));
- return Response.json(runResult ?? { decision: "allowed", invocationId: "inv-storybook", result: {} });
- }
- return original(input, init);
- };
- return () => {
- window.fetch = original;
- };
- }, [runResult, runDelayMs]);
-
- useEffect(() => {
- if (!script) return;
- const timer = window.setTimeout(() => runScript(script), 150);
- return () => window.clearTimeout(timer);
- }, [script]);
-
- return (
-
-
-
-
-
- );
-}
-
-const meta: Meta = {
- title: "Apps/Test tab (PAP-11350)",
- parameters: { layout: "fullscreen" },
-};
-export default meta;
-
-type Story = StoryObj;
-
-export const Default: Story = {
- name: "Default — grouped actions + access badges",
- render: () => ,
-};
-
-export const AllowedResult: Story = {
- name: "Allowed — result panel",
- render: () => (
-
- ),
-};
-
-export const AskFirstResult: Story = {
- name: "Ask first — sent for approval",
- render: () => (
-
- ),
-};
-
-export const ErrorResult: Story = {
- name: "Error — what the app said + what to try",
- render: () => (
-
- ),
-};
-
-export const RunningState: Story = {
- name: "Running — in-flight card",
- render: () => (
-
- ),
-};
-
-export const OffAction: Story = {
- name: "Off — explanation + open Permissions",
- render: () => ,
-};
-
-// PAP-11404 — Off side panel polish: audit hint + quarantined variant.
-
-const AGENTS_WITH_AUDIT: StoryAgent[] = AGENTS.map((agent, i) =>
- i === 0
- ? {
- ...agent,
- effectiveAccess: {
- ...agent.effectiveAccess,
- lastChangedAt: new Date("2026-06-17T09:00:00Z").toISOString(),
- lastChangedByAgentId: "agent-admin",
- lastChangedByName: "Dotta",
- },
- }
- : agent,
-);
-
-export const OffAuditHint: Story = {
- name: "Off — last-changed audit hint (PAP-11404)",
- render: () => (
-
- ),
-};
-
-const QUARANTINED: ToolCatalogEntry[] = [
- { ...tool("q1", "rename_sheet", "Rename a sheet", "Change a sheet's name.", false), status: "quarantined" } as ToolCatalogEntry,
-];
-
-export const QuarantinedAction: Story = {
- name: "New — quarantined action not yet on (PAP-11404)",
- render: () => (
-
- ),
-};
From 6f2ce27ca76b8ef3b90079a54e8ad00390b6e8cd Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Thu, 1 Oct 2026 10:00:25 -0700
Subject: [PATCH 06/10] fix(workspaces): prepare checkouts without a local seed
config (#14810)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task preparation can create an isolated Git worktree and run its
setup script.
> - The Paperclip repository setup script also prepares a seeded
development instance.
> - A server configured through environment variables can have no local
seed config.
> - This stops ordinary task preparation before the agent starts.
> - This pull request prepares checkout dependencies when no seed source
exists, while preserving errors for invalid sources and existing
development instances.
> - Tasks can start without creating or claiming a seeded development
runtime.
## Linked Issues or Issue Description
**What happened?**
A task with the Paperclip repository fails during setup when the host
has no repository-local or default instance config. The automatic
worktree provisioner requires a seed source even when the task only
needs the checkout.
**Expected behavior**
A plain checkout should prepare its dependencies without a local
development database. A missing custom source, invalid source path, or
existing development instance with a missing source should still fail.
Starting a seeded runtime must still require a valid source.
**Steps to reproduce**
1. Run an environment-configured Paperclip server without a local
instance config.
2. Add the Paperclip repository to a project.
3. Start a task that uses an isolated Git worktree without a custom
provision command.
4. Observe the setup error before agent execution.
**Paperclip version or commit**
Reproduced against `0d3e7bf6ac` with a real script subprocess and
workspace realization regression.
**Deployment mode**
Environment-configured server with external PostgreSQL.
**Additional context**
Searched open and closed GitHub PRs and issues. Related work: Refs
#14795 (seed-source diagnostics) and Refs #11733 (source validation).
This change keeps source validation and seed-readiness checks in place.
## What Changed
- Permit dependency setup when the default seed config is absent
(including the Docker image config path) and the worktree has no
development-instance state.
- Keep missing custom configs, invalid paths, and lost sources for
existing instances as errors.
- Create no config, environment file, or seed manifest for a plain
checkout.
- Keep dependency install failures visible and allow normal instance
setup once a source becomes available.
- Cover the setup script, seed-runtime refusal, and automatic server
worktree realization.
- Document the difference between checkout preparation and
seeded-runtime readiness.
## Verification
- Regression tests failed before the fix for absent-source checkout
preparation and dependency setup.
- `bash -n scripts/provision-worktree.sh`
- `node --test scripts/__tests__/provision-worktree-self-heal.test.mjs`
— 34 passed; 1 existing flock-dependent test skipped on macOS.
- Server regression — 2 passed, covering an unset config and the Docker
image default path.
- `pnpm build` — passed.
- `pnpm -r typecheck` — passed.
- All CI checks passed, including the full test shards, build,
typecheck, browser tests, and canary dry run.
- The first local `pnpm test:run` encountered two chat-test failures
because skill discovery selected an unrelated parent directory. Both
tests pass at the PR commit in a clean temporary checkout. The full
local run was not completed; the redundant clean run was stopped after
the complete CI suite passed.
- `git diff --check` and added-line secrets/PII scan passed.
- Greptile: 5/5, no comments. The branch has no merge conflicts.
- No live tenant deployment or task retry was performed.
## Risks
- A new checkout with no implicit seed config now completes dependency
setup. It has no seeded development instance. A runtime request still
fails until a valid source exists.
- Existing instances and custom source paths retain their failure
behavior. The script does not synthesize a source from environment
credentials or copy a live database.
- No schema, API, or task-setting changes. Revert the commit to restore
the previous setup behavior.
## Model Used
OpenAI Codex (GPT-6), with tool-assisted analysis, code edits, and local
tests. The runtime did not expose a verified model variant or
context-window size.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
Co-authored-by: Paperclip
---
doc/DEVELOPING.md | 5 +-
.../provision-worktree-self-heal.test.mjs | 116 +++++++++++++++-
scripts/provision-worktree.sh | 129 +++++++++++-------
.../src/__tests__/workspace-runtime.test.ts | 37 +++++
4 files changed, 228 insertions(+), 59 deletions(-)
diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md
index 983f2a4ca6..2615e61611 100644
--- a/doc/DEVELOPING.md
+++ b/doc/DEVELOPING.md
@@ -837,8 +837,9 @@ The default `worktree init` still seeds eagerly. A lean worktree (created withou
- `pnpm paperclipai worktree ensure-seeded` performs the deferred seed **exactly once**. It is lock-guarded and idempotent: only a complete `verified` manifest short-circuits it, so it is safe to call repeatedly and from concurrent processes. Managed workspaces derive the source from the control-plane-provided base project workspace when it carries its own `.paperclip/config.json`, and otherwise from the control plane's own registered instance config; either way the workspace's manifest never selects it. Manual worktrees must pass `--from-config`.
- `paperclipai run` calls `ensureWorktreeSeeded` automatically before doctor/boot. Managed runs transparently seed a lean worktree from their registered base workspace; an unmanaged lean worktree must first run `worktree ensure-seeded --from-config `.
-- Managed Paperclip git worktrees default to the repository's `scripts/provision-worktree.sh` when the strategy omits `provisionCommand`, so the isolated config and pending manifest cannot be silently skipped. Runtime startup also runs `scripts/provision-worktree-runtime.sh` automatically when no explicit runtime provision command is configured and the manifest is not verified. Explicitly configured provision commands still take precedence.
-- If the built-in provisioner reports an unavailable seed source config, decide whether the task needs a seeded development instance or only an isolated checkout. A seeded instance needs a canonical config from the registered base workspace or control-plane instance; environment-only server configuration does not provide that file. For a checkout-only worktree, explicitly set the `git_worktree` strategy's `provisionCommand` to `"true"`. This skips setup and does not establish runtime or seed readiness. Repair rejected symlinks or non-regular source files instead of treating those validation failures as a missing prerequisite.
+- Managed Paperclip git worktrees default to the repository's `scripts/provision-worktree.sh` when the strategy omits `provisionCommand`. When a registered source config exists, setup creates the isolated config and pending manifest. Runtime startup also runs `scripts/provision-worktree-runtime.sh` automatically when no explicit runtime provision command is configured and the manifest is not verified. Explicitly configured provision commands still take precedence.
+- An environment-configured server may have no local seed config. If neither the base checkout nor the default control-plane instance has one, a fresh worktree prepares its dependencies without creating a development instance. This also applies when `PAPERCLIP_CONFIG` names the default `$PAPERCLIP_HOME/instances/$PAPERCLIP_INSTANCE_ID/config.json` path, as the Docker image does. Setup creates no config, environment file, or seed manifest and does not claim runtime or seed readiness. A later request for a seeded development runtime still needs a canonical registered source config. Once that source exists, provisioning the checkout again creates the development instance normally.
+- A missing custom `PAPERCLIP_CONFIG`, rejected symlink, or non-regular source file still fails setup. An existing worktree with a config, environment file, or seed state also fails if its source disappears; setup never downgrades that instance to a plain checkout. Repair the source before retrying.
- The built-in deferred seed is recorded as its own terminal `workspace_seed` operation. A zero exit code is not enough for success: the operation succeeds only when `.paperclip/seed-manifest.json` contains complete verified evidence; failed, missing, or malformed manifests produce a failed operation with the seed phase in metadata.
- Worktrees created before lazy seeding shipped may have neither marker. Paperclip adopts them only after their configured database proves a compatible migration journal and the core Paperclip schema; otherwise managed startup creates a pending manifest and performs the normal verified seed. Manual markerless worktrees must provide `--from-config` so the source remains explicit.
diff --git a/scripts/__tests__/provision-worktree-self-heal.test.mjs b/scripts/__tests__/provision-worktree-self-heal.test.mjs
index bff638cdc4..dcedc7f075 100644
--- a/scripts/__tests__/provision-worktree-self-heal.test.mjs
+++ b/scripts/__tests__/provision-worktree-self-heal.test.mjs
@@ -102,7 +102,7 @@ process.exit(0);
return baseCwd;
}
-function runProvision(baseCwd, { pathPrefix, setupWorktree, setupInstance, existingWorktree } = {}) {
+function runProvision(baseCwd, { pathPrefix, setupWorktree, setupInstance, existingWorktree, env = {} } = {}) {
const worktreeCwd = existingWorktree ?? makeTempDir("paperclip-provision-worktree-");
setupWorktree?.(worktreeCwd);
const worktreesHome = makeTempDir("paperclip-provision-home-");
@@ -121,14 +121,16 @@ function runProvision(baseCwd, { pathPrefix, setupWorktree, setupInstance, exist
PAPERCLIP_HOME: paperclipHome,
PAPERCLIP_PROJECT_WORKSPACE_ID: "project-workspace-1",
PAPERCLIP_SEED_EXPECTED_COMPANY_ID: "company-1",
+ ...(typeof env === "function" ? env(paperclipHome) : env),
},
});
return { result, worktreeCwd, worktreesHome, paperclipHome };
}
-function runRuntimeProvision(baseCwd, worktreeCwd) {
+function runRuntimeProvision(baseCwd, worktreeCwd, { setupInstance } = {}) {
const worktreesHome = makeTempDir("paperclip-provision-runtime-home-");
const paperclipHome = makeInstanceHome();
+ setupInstance?.(paperclipHome);
return spawnSync("bash", [runtimeScript], {
cwd: worktreeCwd,
encoding: "utf8",
@@ -183,22 +185,122 @@ test("uses the base CLI when its import graph boots", () => {
);
});
-test("explains unavailable instance source config without creating target state", () => {
+for (const defaultConfigInEnv of [false, true]) {
+ test(`prepares a plain checkout without a default instance config (image default env: ${defaultConfigInEnv})`, () => {
+ const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
+ const { result, worktreeCwd } = runProvision(baseCwd, {
+ setupInstance: (home) => fs.rmSync(path.join(home, "instances", "default", "config.json")),
+ env: (home) => defaultConfigInEnv ? { PAPERCLIP_CONFIG: path.join(home, "instances", "default", "config.json") } : {},
+ });
+
+ assert.equal(result.status, 0, result.stderr);
+ assert.match(result.stderr, /preparing checkout dependencies without a seeded development instance/);
+ for (const file of ["config.json", ".env", "seed-manifest.json", "seed-pending", "seed-complete"]) {
+ assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip", file)), false);
+ }
+ assert.equal(fs.realpathSync(path.join(worktreeCwd, "cli", "node_modules")), path.join(baseCwd, "cli", "node_modules"));
+ assert.deepEqual(readCliInvocations(baseCwd), []);
+ });
+}
+
+test("rejects a missing custom seed source", () => {
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
const { result, worktreeCwd } = runProvision(baseCwd, {
- setupInstance: (home) => fs.rmSync(path.join(home, "instances", "default", "config.json")),
+ env: { PAPERCLIP_CONFIG: path.join(baseCwd, "missing", "config.json") },
});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /config is unavailable \(control-plane instance\)/);
assert.match(result.stderr, /For a seeded development instance, configure a canonical config/);
- assert.match(result.stderr, /Only for a checkout-only worktree/);
- assert.match(result.stderr, /workspaceStrategy\.provisionCommand to "true"/);
- assert.match(result.stderr, /does not prepare a development runtime/);
assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip")), false);
assert.deepEqual(readCliInvocations(baseCwd), []);
});
+test("initializes a previously plain checkout once a registered seed source is available", () => {
+ const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
+ const first = runProvision(baseCwd, {
+ setupInstance: (home) => fs.rmSync(path.join(home, "instances"), { recursive: true }),
+ });
+ assert.equal(first.result.status, 0, first.result.stderr);
+ assert.deepEqual(readCliInvocations(baseCwd), []);
+
+ const second = runProvision(baseCwd, { existingWorktree: first.worktreeCwd });
+ assert.equal(second.result.status, 0, second.result.stderr);
+ assert.equal(readWorktreeConfig(first.worktreeCwd).$meta.source, "fake-cli");
+ assert.equal(JSON.parse(fs.readFileSync(path.join(first.worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state, "pending");
+});
+
+test("a plain checkout without a source is not ready for a seeded runtime", () => {
+ const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
+ fs.mkdirSync(path.join(baseCwd, "scripts"));
+ fs.copyFileSync(script, path.join(baseCwd, "scripts", "provision-worktree.sh"));
+ const setupInstance = (home) => fs.rmSync(path.join(home, "instances"), { recursive: true });
+ const { result, worktreeCwd } = runProvision(baseCwd, { setupInstance });
+ assert.equal(result.status, 0, result.stderr);
+
+ const runtime = runRuntimeProvision(baseCwd, worktreeCwd, { setupInstance });
+ assert.notEqual(runtime.status, 0);
+ assert.match(runtime.stderr, /Worktree config still does not exist after built-in provisioning/);
+ assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json")), false);
+ assert.deepEqual(readCliInvocations(baseCwd), []);
+});
+
+for (const relativePath of ["instances/default/config.json", "instances/default"]) {
+ test(`rejects a dangling implicit source symlink at ${relativePath}`, () => {
+ const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
+ const { result, worktreeCwd } = runProvision(baseCwd, {
+ setupInstance(home) {
+ const sourcePath = path.join(home, relativePath);
+ fs.rmSync(sourcePath, { recursive: true });
+ fs.symlinkSync(path.join(home, "missing"), sourcePath);
+ },
+ });
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, /canonical/);
+ assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip")), false);
+ assert.deepEqual(readCliInvocations(baseCwd), []);
+ });
+}
+
+for (const file of ["config.json", ".env", "seed-manifest.json", "seed-pending", "seed-complete"]) {
+ test(`does not downgrade an existing development instance with ${file} when its source disappears`, () => {
+ const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
+ const { result, worktreeCwd } = runProvision(baseCwd, {
+ setupInstance: (home) => fs.rmSync(path.join(home, "instances", "default", "config.json")),
+ setupWorktree(root) {
+ fs.mkdirSync(path.join(root, ".paperclip"));
+ fs.writeFileSync(path.join(root, ".paperclip", file), "retained state\n");
+ },
+ });
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, /config is unavailable/);
+ assert.equal(fs.readFileSync(path.join(worktreeCwd, ".paperclip", file), "utf8"), "retained state\n");
+ assert.deepEqual(readCliInvocations(baseCwd), []);
+ });
+}
+
+for (const installExit of [0, 42]) {
+ test(`plain checkout dependency provisioning preserves install exit ${installExit}`, () => {
+ const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
+ const bin = makeTempDir("paperclip-checkout-pnpm-");
+ fs.writeFileSync(path.join(bin, "pnpm"), `#!/bin/sh\nprintf '%s\\n' "$*" >> pnpm-calls\nmkdir -p node_modules cli/node_modules\nexit ${installExit}\n`, { mode: 0o700 });
+ const { result, worktreeCwd } = runProvision(baseCwd, {
+ pathPrefix: bin,
+ setupInstance: (home) => fs.rmSync(path.join(home, "instances", "default", "config.json")),
+ setupWorktree(root) {
+ fs.writeFileSync(path.join(root, "package.json"), "{}\n");
+ fs.writeFileSync(path.join(root, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
+ },
+ });
+ assert.equal(result.status, installExit, result.stderr);
+ assert.match(fs.readFileSync(path.join(worktreeCwd, "pnpm-calls"), "utf8"), /^install --prod=false --frozen-lockfile/);
+ assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip", "pnpm-install-fingerprint")), installExit === 0);
+ assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip", "config.json")), false);
+ assert.equal(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json")), false);
+ assert.deepEqual(readCliInvocations(baseCwd), []);
+ });
+}
+
test("rejects a dangling base workspace config symlink instead of falling back", () => {
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
fs.mkdirSync(path.join(baseCwd, ".paperclip"), { recursive: true });
diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh
index 2418fa3bb0..bb57604c83 100644
--- a/scripts/provision-worktree.sh
+++ b/scripts/provision-worktree.sh
@@ -5,6 +5,7 @@ base_cwd="${PAPERCLIP_WORKSPACE_BASE_CWD:?PAPERCLIP_WORKSPACE_BASE_CWD is requir
worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}"
paperclip_home="${PAPERCLIP_HOME:-$HOME/.paperclip}"
paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}"
+default_source_config_path="$paperclip_home/instances/$paperclip_instance_id/config.json"
paperclip_dir="$worktree_cwd/.paperclip"
worktree_config_path="$paperclip_dir/config.json"
worktree_env_path="$paperclip_dir/.env"
@@ -53,26 +54,52 @@ if [[ ! -e "$source_config_path" && ! -L "$source_config_path" ]]; then
# A base workspace that is a plain checkout carries no instance config of its own.
# Fall back to the control plane's own registered instance config, which is process
# state this workspace cannot rewrite.
- source_config_path="${PAPERCLIP_CONFIG:-$paperclip_home/instances/$paperclip_instance_id/config.json}"
+ source_config_path="${PAPERCLIP_CONFIG:-$default_source_config_path}"
source_config_origin="control-plane instance"
-fi
-if [[ ! -f "$source_config_path" || -L "$source_config_path" ]]; then
- if [[ ! -e "$source_config_path" && ! -L "$source_config_path" ]]; then
- echo "Registered Paperclip seed source config is unavailable ($source_config_origin): $source_config_path" >&2
- echo "For a seeded development instance, configure a canonical config for that registered source before retrying." >&2
- echo 'Only for a checkout-only worktree, explicitly set workspaceStrategy.provisionCommand to "true". This skips setup; it does not prepare a development runtime.' >&2
- else
- echo "Registered Paperclip seed source config is not a canonical file ($source_config_origin): $source_config_path" >&2
- echo "Repair the registered source path; symlinks and non-regular files are not accepted." >&2
+ # Environment-configured servers need no local instance config. A new plain
+ # checkout can still prepare dependencies without inventing a seed source.
+ # The Docker image sets PAPERCLIP_CONFIG to this default even without a file.
+ # Custom sources and existing development instances must still fail closed.
+ if [[ "$source_config_path" == "$default_source_config_path" && ! -e "$source_config_path" && ! -L "$source_config_path" ]]; then
+ source_required=0
+ for target_state in "$worktree_config_path" "$worktree_env_path" "$seed_manifest_path" "$seed_pending_marker_path" "$seed_complete_marker_path"; do
+ if [[ -e "$target_state" || -L "$target_state" ]]; then
+ source_required=1
+ fi
+ done
+ if [[ "$source_required" -eq 0 ]]; then
+ # Do not mistake a broken or aliased parent path for an absent instance.
+ source_parent="$(dirname "$source_config_path")"
+ while [[ ! -e "$source_parent" && ! -L "$source_parent" ]]; do
+ source_parent="$(dirname "$source_parent")"
+ done
+ if [[ ! -d "$source_parent" || -L "$source_parent" || "$(cd "$source_parent" && pwd -P)" != "$source_parent" ]]; then
+ echo "Registered Paperclip seed source config has a non-canonical parent: $source_config_path" >&2
+ exit 1
+ fi
+ echo "No local Paperclip seed source config; preparing checkout dependencies without a seeded development instance." >&2
+ source_config_path=""
+ fi
fi
- exit 1
fi
-canonical_source_dir="$(cd "$(dirname "$source_config_path")" && pwd -P)"
-if [[ "$canonical_source_dir/config.json" != "$source_config_path" ]]; then
- echo "Registered Paperclip seed source config uses a symlink alias: $source_config_path" >&2
- exit 1
+if [[ -n "$source_config_path" ]]; then
+ if [[ ! -f "$source_config_path" || -L "$source_config_path" ]]; then
+ if [[ ! -e "$source_config_path" && ! -L "$source_config_path" ]]; then
+ echo "Registered Paperclip seed source config is unavailable ($source_config_origin): $source_config_path" >&2
+ echo "For a seeded development instance, configure a canonical config for that registered source before retrying." >&2
+ else
+ echo "Registered Paperclip seed source config is not a canonical file ($source_config_origin): $source_config_path" >&2
+ echo "Repair the registered source path; symlinks and non-regular files are not accepted." >&2
+ fi
+ exit 1
+ fi
+ canonical_source_dir="$(cd "$(dirname "$source_config_path")" && pwd -P)"
+ if [[ "$canonical_source_dir/config.json" != "$source_config_path" ]]; then
+ echo "Registered Paperclip seed source config uses a symlink alias: $source_config_path" >&2
+ exit 1
+ fi
+ source_env_path="$(dirname "$source_config_path")/.env"
fi
-source_env_path="$(dirname "$source_config_path")/.env"
mkdir -p "$paperclip_dir"
@@ -619,44 +646,46 @@ main().catch((error) => {
EOF
}
-if [[ -e "$worktree_config_path" && -e "$worktree_env_path" ]] && existing_worktree_config_is_usable; then
- echo "Reusing existing isolated Paperclip worktree config at $worktree_config_path" >&2
-else
- if [[ -e "$worktree_config_path" || -e "$worktree_env_path" ]]; then
- echo "Existing isolated Paperclip worktree config is stale for this host; regenerating." >&2
- fi
- if paperclipai_command_available; then
- if run_isolated_worktree_init; then
- :
- else
- init_exit_code=$?
- if [[ "$init_exit_code" -eq 127 ]]; then
- # Every CLI candidate was unusable (e.g. an unhealthy base install that
- # the repair could not fix); degrade instead of stranding the run.
- echo "No usable paperclipai CLI found; writing isolated fallback config without DB seeding." >&2
- write_fallback_worktree_config
- else
- # A CLI that ran and failed signals a real problem; do not paper over
- # it with an unseeded fallback config.
- echo "paperclipai worktree init failed (exit $init_exit_code); failing provisioning instead of writing an unseeded fallback config." >&2
- exit "$init_exit_code"
- fi
- fi
+if [[ -n "$source_config_path" ]]; then
+ if [[ -e "$worktree_config_path" && -e "$worktree_env_path" ]] && existing_worktree_config_is_usable; then
+ echo "Reusing existing isolated Paperclip worktree config at $worktree_config_path" >&2
else
- echo "paperclipai worktree init unavailable; writing isolated fallback config without DB seeding." >&2
- write_fallback_worktree_config
+ if [[ -e "$worktree_config_path" || -e "$worktree_env_path" ]]; then
+ echo "Existing isolated Paperclip worktree config is stale for this host; regenerating." >&2
+ fi
+ if paperclipai_command_available; then
+ if run_isolated_worktree_init; then
+ :
+ else
+ init_exit_code=$?
+ if [[ "$init_exit_code" -eq 127 ]]; then
+ # Every CLI candidate was unusable (e.g. an unhealthy base install that
+ # the repair could not fix); degrade instead of stranding the run.
+ echo "No usable paperclipai CLI found; writing isolated fallback config without DB seeding." >&2
+ write_fallback_worktree_config
+ else
+ # A CLI that ran and failed signals a real problem; do not paper over
+ # it with an unseeded fallback config.
+ echo "paperclipai worktree init failed (exit $init_exit_code); failing provisioning instead of writing an unseeded fallback config." >&2
+ exit "$init_exit_code"
+ fi
+ fi
+ else
+ echo "paperclipai worktree init unavailable; writing isolated fallback config without DB seeding." >&2
+ write_fallback_worktree_config
+ fi
+ created_worktree_config=1
fi
- created_worktree_config=1
-fi
-# The target config can predate a deployment-mode change on the registered
-# source, and older/fallback CLI writers may default this field independently.
-# Reconcile it after either create or reuse so the final guest config always
-# carries the source's deployment/auth contract without replacing its database.
-reconcile_worktree_deployment_mode
+ # The target config can predate a deployment-mode change on the registered
+ # source, and older/fallback CLI writers may default this field independently.
+ # Reconcile it after either create or reuse so the final guest config always
+ # carries the source's deployment/auth contract without replacing its database.
+ reconcile_worktree_deployment_mode
-if [[ "$created_worktree_config" -eq 1 && ! -e "$seed_manifest_path" && ! -e "$seed_pending_marker_path" && ! -e "$seed_complete_marker_path" ]]; then
- write_seed_pending_manifest
+ if [[ "$created_worktree_config" -eq 1 && ! -e "$seed_manifest_path" && ! -e "$seed_pending_marker_path" && ! -e "$seed_complete_marker_path" ]]; then
+ write_seed_pending_manifest
+ fi
fi
list_base_node_modules_paths() {
diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts
index fd0caefa8e..187135d195 100644
--- a/server/src/__tests__/workspace-runtime.test.ts
+++ b/server/src/__tests__/workspace-runtime.test.ts
@@ -920,6 +920,43 @@ describe("realizeExecutionWorkspace", () => {
}
});
+ it.each([false, true])("realizes a plain Paperclip checkout without a local seed instance (image default env: %s)", async (defaultConfigInEnv) => {
+ const repoRoot = await createTempRepo();
+ const paperclipHome = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-env-only-home-")));
+ const previousEnv = {
+ PAPERCLIP_HOME: process.env.PAPERCLIP_HOME,
+ PAPERCLIP_CONFIG: process.env.PAPERCLIP_CONFIG,
+ PAPERCLIP_INSTANCE_ID: process.env.PAPERCLIP_INSTANCE_ID,
+ };
+ try {
+ await fs.mkdir(path.join(repoRoot, "scripts"));
+ await fs.copyFile(provisionWorktreeScriptPath, path.join(repoRoot, "scripts", "provision-worktree.sh"));
+ await runGit(repoRoot, ["add", "scripts/provision-worktree.sh"]);
+ await runGit(repoRoot, ["commit", "-m", "Add Paperclip worktree provisioner"]);
+ process.env.PAPERCLIP_HOME = paperclipHome;
+ process.env.PAPERCLIP_INSTANCE_ID = "default";
+ if (defaultConfigInEnv) process.env.PAPERCLIP_CONFIG = path.join(paperclipHome, "instances", "default", "config.json");
+ else delete process.env.PAPERCLIP_CONFIG;
+
+ const workspace = await realizeWorktreeForTest(repoRoot, "HEAD");
+
+ expect(workspace.strategy).toBe("git_worktree");
+ expect(workspace.created).toBe(true);
+ expect(await readGit(workspace.cwd, ["branch", "--show-current"])).toBe(workspace.branchName);
+ for (const file of ["config.json", ".env", "seed-manifest.json", "seed-pending", "seed-complete"]) {
+ expect(existsSync(path.join(workspace.cwd, ".paperclip", file))).toBe(false);
+ }
+ expect(await fs.readdir(paperclipHome)).toEqual([]);
+ } finally {
+ for (const [key, value] of Object.entries(previousEnv)) {
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ await fs.rm(repoRoot, { recursive: true, force: true });
+ await fs.rm(paperclipHome, { recursive: true, force: true });
+ }
+ });
+
it("defaults the repo-provided worktree provisioner for git worktree strategies", async () => {
const repoRoot = await createTempRepo();
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
From efc2e6810e9bc0dc8cb412b0e7647c0db9821caa Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Thu, 1 Oct 2026 12:18:35 -0500
Subject: [PATCH 07/10] fix: show each task once in dashboard agent cards
(#14847)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip helps people manage AI agents and their tasks.
> - The dashboard shows recent agent activity in compact cards.
> - Those cards use run records, so two runs for one task can create
duplicate task cards.
> - An operator needs to see each task once when scanning the dashboard.
> - This pull request selects one run per linked task before it applies
the card limit.
> - The live runs page still shows each run for run inspection.
## Linked Issues or Issue Description
**What happened?**
The dashboard showed the same task in two agent cards when that task had
both an active run and a completed run.
**Expected behavior**
The dashboard should show a linked task at most once. It should keep the
active run card when one is present.
**Steps to reproduce**
1. Start an agent run for a task that already has a completed run.
2. Open the company dashboard.
3. Observe two cards linked to the same task.
**Paperclip version or commit**
Reproduced on the pre-change master at `8b4aa0692`.
**Deployment mode**
Local dev, built from source. The bug is in the core dashboard UI and
does not depend on an agent adapter or database mode.
## What Changed
- Select distinct linked tasks from capped active and recent run samples
before applying the dashboard card limit.
- Keep separate cards for runs without a linked task.
- Preserve the dashboard's count of additional distinct cards behind the
live-runs link.
- Add UI and embedded Postgres regression tests for duplicate runs and
document the dashboard rule.
- Give the existing multi-request cross-tenant authorization test enough
time on loaded CI runners.
## Verification
- `pnpm --filter @paperclipai/ui exec vitest run
src/components/ActiveAgentsPanel.test.tsx`
- `pnpm --filter @paperclipai/ui exec vitest run
src/api/heartbeats.test.ts`
- `pnpm exec vitest run server/src/__tests__/dashboard-service.test.ts
server/src/__tests__/agent-live-run-routes.test.ts`
- `pnpm exec vitest run
server/src/__tests__/agent-cross-tenant-authz-routes.test.ts`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui build`
- `pnpm -r typecheck`
- `pnpm build`
- `pnpm check:token-gates`
- Review the dashboard with an active and a completed run on the same
task. Confirm that it shows one card. Open Live agent runs to inspect
both run records.
## Risks
- A very high volume of recent runs for one task can fill the capped
sample and leave older tasks off the dashboard. The Live runs page
remains available for full run inspection.
- The dashboard may fetch up to 50 distinct run representatives to
preserve its overflow count. The default run API response and persisted
data are unchanged.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- OpenAI Codex, GPT-6. The runtime does not expose the exact model ID or
context window size to this task. The model used reasoning, tool calls,
and code execution.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
doc/SPEC-implementation.md | 8 +++
.../agent-cross-tenant-authz-routes.test.ts | 2 +-
.../src/__tests__/dashboard-service.test.ts | 37 ++++++++++++++
server/src/routes/agents.ts | 44 +++++++++-------
.../src/services/dashboard-run-selection.ts | 49 ++++++++++++++++++
ui/src/api/heartbeats.test.ts | 6 +++
ui/src/api/heartbeats.ts | 3 +-
ui/src/components/ActiveAgentsPanel.test.tsx | 50 +++++++++++++++++++
ui/src/components/ActiveAgentsPanel.tsx | 30 +++++++++--
ui/src/pages/Dashboard.tsx | 2 +-
10 files changed, 205 insertions(+), 26 deletions(-)
create mode 100644 server/src/services/dashboard-run-selection.ts
diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md
index 41b5de6d29..20f2d91f4c 100644
--- a/doc/SPEC-implementation.md
+++ b/doc/SPEC-implementation.md
@@ -1197,6 +1197,14 @@ Dashboard payload must include:
- month-to-date spend and budget utilization
- pending approvals count
+The dashboard agent cards show each linked task at most once. The server selects
+distinct task cards from bounded active and recent run samples before it applies
+the dashboard card limit. When multiple runs belong to one task, an active run
+takes precedence over completed runs.
+Runs without a linked task remain separate cards. The dashboard keeps the
+count of additional distinct cards in its link to the live runs page, which
+can show every run.
+
## 10.10 Error Semantics
- `400` validation error
diff --git a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts
index ddb3f40da0..d5fabca417 100644
--- a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts
+++ b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts
@@ -420,7 +420,7 @@ describe.sequential("agent cross-tenant route authorization", () => {
expect(res.body.error).toContain("Key not found");
expect(mockAgentService.getKeyById).toHaveBeenCalledWith(keyId);
expect(mockAgentService.revokeKey).not.toHaveBeenCalled();
- });
+ }, 30_000);
it("requires board access before clearing an agent error", async () => {
const app = await createApp({
diff --git a/server/src/__tests__/dashboard-service.test.ts b/server/src/__tests__/dashboard-service.test.ts
index 05a1a738c9..8642844ce5 100644
--- a/server/src/__tests__/dashboard-service.test.ts
+++ b/server/src/__tests__/dashboard-service.test.ts
@@ -6,6 +6,7 @@ import {
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { dashboardService, getUtcMonthStart } from "../services/dashboard.ts";
+import { selectDashboardRunIds } from "../services/dashboard-run-selection.ts";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -56,6 +57,42 @@ describeEmbeddedPostgres("dashboard service", () => {
await tempDb?.cleanup();
});
+ it("selects distinct task cards before limiting and keeps taskless runs separate", async () => {
+ const companyId = randomUUID();
+ const otherCompanyId = randomUUID();
+ const agentId = randomUUID();
+ const otherAgentId = randomUUID();
+ const repeatedIssueId = randomUUID();
+ const otherIssueId = randomUUID();
+ const activeRunId = randomUUID();
+ const otherTaskRunId = randomUUID();
+ const tasklessRunId = randomUUID();
+
+ await db.insert(companies).values([
+ { id: companyId, name: "Dashboard cards", issuePrefix: "CARDS" },
+ { id: otherCompanyId, name: "Other company", issuePrefix: "OTHER" },
+ ]);
+ await db.insert(agents).values([
+ { id: agentId, companyId, name: "Builder", role: "engineer", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} },
+ { id: otherAgentId, companyId: otherCompanyId, name: "Other", role: "engineer", adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, permissions: {} },
+ ]);
+ await db.insert(heartbeatRuns).values([
+ ...Array.from({ length: 6 }, (_, index) => ({
+ id: randomUUID(), companyId, agentId, status: "succeeded",
+ contextSnapshot: { issueId: repeatedIssueId },
+ createdAt: new Date(`2026-04-10T10:0${index}:00.000Z`),
+ })),
+ { id: activeRunId, companyId, agentId, status: "running", contextSnapshot: { issueId: repeatedIssueId }, createdAt: new Date("2026-04-10T08:00:00.000Z") },
+ { id: otherTaskRunId, companyId, agentId, status: "succeeded", contextSnapshot: { issueId: otherIssueId }, createdAt: new Date("2026-04-10T09:04:00.000Z") },
+ { id: tasklessRunId, companyId, agentId, status: "succeeded", createdAt: new Date("2026-04-10T09:03:00.000Z") },
+ { id: randomUUID(), companyId: otherCompanyId, agentId: otherAgentId, status: "running", contextSnapshot: { issueId: otherIssueId }, createdAt: new Date("2026-04-10T11:00:00.000Z") },
+ ]);
+
+ expect(await selectDashboardRunIds(db, companyId, 3)).toEqual([
+ activeRunId, otherTaskRunId, tasklessRunId,
+ ]);
+ });
+
it("aggregates the full 14-day run activity window without recent-run truncation", async () => {
const companyId = randomUUID();
const otherCompanyId = randomUUID();
diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts
index 3f15799110..494fdd5cce 100644
--- a/server/src/routes/agents.ts
+++ b/server/src/routes/agents.ts
@@ -15,6 +15,7 @@ import { applyConnectorSkills, resolveConnectorAssignments, annotateConnectorSki
import { getExecutionBlocker } from "../services/execution-blocker.js";
import { paperclipRunnerTransitionConfig, normalizeLegacyRunnerProvider, isPaperclipRunnerProvider } from "@paperclipai/adapter-utils";
import { executionProjectionForRun, executionProjectionsForRuns } from "../services/execution-projection.js";
+import { selectDashboardRunIds } from "../services/dashboard-run-selection.js";
import { Router, type NextFunction, type Request, type Response } from "express";
import { generateKeyPairSync, randomUUID } from "node:crypto";
import { readFile, rm } from "node:fs/promises";
@@ -6873,6 +6874,7 @@ export function agentRoutes(
// padded in and renders bogus "live" counts.
const minCount = readLiveRunsQueryInt(req.query.minCount, 50, 0);
const limit = readLiveRunsQueryInt(req.query.limit, 50, 50);
+ const distinctTasks = req.query.distinctTasks === "true";
const columns = {
id: heartbeatRuns.id,
@@ -6888,7 +6890,7 @@ export function agentRoutes(
createdAt: heartbeatRuns.createdAt,
agentId: heartbeatRuns.agentId,
agentName: agentsTable.name,
- agentAppearance: agentsTable.appearance,
+ agentAppearance: agentsTable.appearance,
adapterType: agentsTable.adapterType,
logBytes: heartbeatRuns.logBytes,
livenessState: heartbeatRuns.livenessState,
@@ -6916,10 +6918,25 @@ export function agentRoutes(
)
.orderBy(desc(heartbeatRuns.createdAt));
- const liveRuns = await liveRunsQuery.limit(limit);
+ const liveRuns = distinctTasks ? [] : await liveRunsQuery.limit(limit);
+ let rows = liveRuns;
const targetRunCount = Math.min(minCount, limit);
- if (targetRunCount > 0 && liveRuns.length < targetRunCount) {
+ if (distinctTasks) {
+ // Return enough representatives for the dashboard to count cards beyond
+ // its visible four, rather than stopping at the minimum display count.
+ const selectedIds = await selectDashboardRunIds(db, companyId, limit);
+ const selectedRows = selectedIds.length === 0 ? [] : await db
+ .select(columns)
+ .from(heartbeatRuns)
+ .innerJoin(agentsTable, eq(heartbeatRuns.agentId, agentsTable.id))
+ .where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.id, selectedIds)));
+ const byId = new Map(selectedRows.map((run) => [run.id, run]));
+ rows = selectedIds.flatMap((id) => {
+ const run = byId.get(id);
+ return run ? [run] : [];
+ });
+ } else if (targetRunCount > 0 && liveRuns.length < targetRunCount) {
const activeIds = liveRuns.map((r) => r.id);
const recentRuns = await db
.select(columns)
@@ -6935,24 +6952,15 @@ export function agentRoutes(
.orderBy(desc(heartbeatRuns.createdAt))
.limit(targetRunCount - liveRuns.length);
- const rows = [...liveRuns, ...recentRuns];
- const projections = await executionProjectionsForRuns(db, companyId, rows.map(run => run.id));
- res.json(await runRedactions.redactForRuns(companyId, await Promise.all(rows.map(async (run) => ({
- ...heartbeat.decorateActiveRunStatus(run),
- agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId),
- avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512),
- execution: projections.get(run.id) ?? null,
- outputSilence: await heartbeat.buildRunOutputSilence(run),
- })))));
- return;
+ rows = [...liveRuns, ...recentRuns];
}
- const projections = await executionProjectionsForRuns(db, companyId, liveRuns.map(run => run.id));
- res.json(await runRedactions.redactForRuns(companyId, await Promise.all(liveRuns.map(async (run) => ({
+ const projections = await executionProjectionsForRuns(db, companyId, rows.map(run => run.id));
+ res.json(await runRedactions.redactForRuns(companyId, await Promise.all(rows.map(async (run) => ({
...heartbeat.decorateActiveRunStatus(run),
- agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId),
- avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512),
- execution: projections.get(run.id) ?? null,
+ agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId),
+ avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512),
+ execution: projections.get(run.id) ?? null,
outputSilence: await heartbeat.buildRunOutputSilence(run),
})))));
});
diff --git a/server/src/services/dashboard-run-selection.ts b/server/src/services/dashboard-run-selection.ts
new file mode 100644
index 0000000000..1efe82f9da
--- /dev/null
+++ b/server/src/services/dashboard-run-selection.ts
@@ -0,0 +1,49 @@
+import type { Db } from "@paperclipai/db";
+import { heartbeatRuns } from "@paperclipai/db";
+import { and, desc, eq, inArray, sql } from "drizzle-orm";
+
+const MAX_CANDIDATES_PER_POOL = 1_000;
+const ACTIVE_STATUSES = ["queued", "running"];
+
+/** Select recent dashboard cards without ranking the company's full run history. */
+export async function selectDashboardRunIds(db: Db, companyId: string, limit: number): Promise {
+ if (limit <= 0) return [];
+
+ const columns = {
+ id: heartbeatRuns.id,
+ status: heartbeatRuns.status,
+ createdAt: heartbeatRuns.createdAt,
+ issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
+ };
+ const [activeRuns, recentRuns] = await Promise.all([
+ db.select(columns)
+ .from(heartbeatRuns)
+ .where(and(eq(heartbeatRuns.companyId, companyId), inArray(heartbeatRuns.status, ACTIVE_STATUSES)))
+ .orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id))
+ .limit(MAX_CANDIDATES_PER_POOL),
+ db.select(columns)
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.companyId, companyId))
+ .orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id))
+ .limit(MAX_CANDIDATES_PER_POOL),
+ ]);
+
+ const candidates = [...new Map([...activeRuns, ...recentRuns].map((run) => [run.id, run])).values()];
+ candidates.sort((a, b) => {
+ const activeOrder = Number(ACTIVE_STATUSES.includes(b.status)) - Number(ACTIVE_STATUSES.includes(a.status));
+ if (activeOrder !== 0) return activeOrder;
+ const createdOrder = b.createdAt.getTime() - a.createdAt.getTime();
+ return createdOrder || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0);
+ });
+
+ const selectedIds: string[] = [];
+ const seenCards = new Set();
+ for (const run of candidates) {
+ const cardKey = run.issueId ? `issue:${run.issueId}` : `run:${run.id}`;
+ if (seenCards.has(cardKey)) continue;
+ seenCards.add(cardKey);
+ selectedIds.push(run.id);
+ if (selectedIds.length === limit) break;
+ }
+ return selectedIds;
+}
diff --git a/ui/src/api/heartbeats.test.ts b/ui/src/api/heartbeats.test.ts
index 8d65eeb9ab..4de7d5a69f 100644
--- a/ui/src/api/heartbeats.test.ts
+++ b/ui/src/api/heartbeats.test.ts
@@ -55,6 +55,12 @@ describe("heartbeatsApi.liveRunsForCompany", () => {
expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/live-runs?minCount=50&limit=50");
});
+
+ it("requests distinct task cards before the server applies the dashboard limit", async () => {
+ await heartbeatsApi.liveRunsForCompany("company-1", { minCount: 4, limit: 4, distinctTasks: true });
+
+ expect(mockApi.get).toHaveBeenCalledWith("/companies/company-1/live-runs?minCount=4&limit=4&distinctTasks=true");
+ });
});
describe("heartbeatsApi.downloadProviderTrace", () => {
diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts
index cf913ef005..84c7990f66 100644
--- a/ui/src/api/heartbeats.ts
+++ b/ui/src/api/heartbeats.ts
@@ -232,7 +232,7 @@ export const heartbeatsApi = {
api.get(`/issues/${issueId}/active-run`),
liveRunsForCompany: (
companyId: string,
- options?: number | { minCount?: number; limit?: number },
+ options?: number | { minCount?: number; limit?: number; distinctTasks?: boolean },
) => {
const searchParams = new URLSearchParams();
if (typeof options === "number") {
@@ -241,6 +241,7 @@ export const heartbeatsApi = {
if (options.minCount)
searchParams.set("minCount", String(options.minCount));
if (options.limit) searchParams.set("limit", String(options.limit));
+ if (options.distinctTasks) searchParams.set("distinctTasks", "true");
}
const qs = searchParams.toString();
return api.get(
diff --git a/ui/src/components/ActiveAgentsPanel.test.tsx b/ui/src/components/ActiveAgentsPanel.test.tsx
index 80a7b2813f..532785a228 100644
--- a/ui/src/components/ActiveAgentsPanel.test.tsx
+++ b/ui/src/components/ActiveAgentsPanel.test.tsx
@@ -146,6 +146,7 @@ describe("ActiveAgentsPanel", () => {
expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", {
minCount: 4,
limit: undefined,
+ distinctTasks: false,
});
const moreLink = [...container.querySelectorAll("a")].find((anchor) =>
@@ -159,7 +160,54 @@ describe("ActiveAgentsPanel", () => {
});
});
+ it("shows each linked task once on the dashboard while keeping distinct taskless runs", async () => {
+ const repeatedIssueId = "issue-repeated";
+ mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
+ createIssueRun(1, repeatedIssueId),
+ { ...createIssueRun(2, repeatedIssueId), status: "succeeded", finishedAt: "2026-04-24T11:00:00.000Z" },
+ createIssueRun(3, "issue-other"),
+ createRun(4),
+ createRun(5),
+ createRun(6),
+ ]);
+
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await flushReact();
+
+ expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", {
+ minCount: 4,
+ limit: undefined,
+ distinctTasks: true,
+ });
+ expect([...container.querySelectorAll(".dashboard-agent-card")].map((card) =>
+ card.querySelector('a[aria-label$=". View run"]')?.getAttribute("href"),
+ )).toEqual([
+ "/agents/agent-1/runs/run-1",
+ "/agents/agent-3/runs/run-3",
+ "/agents/agent-4/runs/run-4",
+ "/agents/agent-5/runs/run-5",
+ ]);
+ expect(container.textContent).toContain("1 more active/recent run");
+
+ await act(async () => root.unmount());
+ });
+
it("can request the full live dashboard page limit without a hidden-runs link", async () => {
+ mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
+ createIssueRun(1, "issue-repeated"),
+ { ...createIssueRun(2, "issue-repeated"), status: "succeeded" },
+ ]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -184,9 +232,11 @@ describe("ActiveAgentsPanel", () => {
expect(mockHeartbeatsApi.liveRunsForCompany).toHaveBeenCalledWith("company-1", {
minCount: 50,
limit: 50,
+ distinctTasks: false,
});
expect(container.textContent).not.toContain("more active/recent");
expect(container.textContent).not.toContain("Run output");
+ expect(container.querySelectorAll(".dashboard-agent-card")).toHaveLength(2);
await act(async () => {
root.unmount();
diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx
index 34e12a65ce..b3aaf50a2d 100644
--- a/ui/src/components/ActiveAgentsPanel.tsx
+++ b/ui/src/components/ActiveAgentsPanel.tsx
@@ -44,6 +44,7 @@ interface ActiveAgentsPanelProps {
queryScope?: string;
showMoreLink?: boolean;
showTranscripts?: boolean;
+ dedupeLinkedTasks?: boolean;
}
export function ActiveAgentsPanel({
@@ -58,25 +59,44 @@ export function ActiveAgentsPanel({
queryScope = "dashboard",
showMoreLink = true,
showTranscripts = false,
+ dedupeLinkedTasks = false,
}: ActiveAgentsPanelProps) {
- const liveRunsQueryKey = [...queryKeys.liveRuns(companyId), queryScope, { minRunCount, fetchLimit }] as const;
+ const effectiveFetchLimit = fetchLimit;
+ const liveRunsQueryKey = [...queryKeys.liveRuns(companyId), queryScope, { minRunCount, fetchLimit: effectiveFetchLimit, dedupeLinkedTasks }] as const;
const sharedLiveRuns = useSharedPollingQuery({
companyId,
- resourceKey: `live-runs:${queryScope}:${minRunCount}:${fetchLimit ?? "default"}`,
+ resourceKey: `live-runs:${queryScope}:${minRunCount}:${effectiveFetchLimit ?? "default"}:${dedupeLinkedTasks}`,
queryKey: liveRunsQueryKey,
enabled: !!companyId,
leaderOnly: true,
});
const { data: liveRuns, dataUpdatedAt: liveRunsUpdatedAt } = useQuery({
queryKey: liveRunsQueryKey,
- queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, { minCount: minRunCount, limit: fetchLimit }),
+ queryFn: () => heartbeatsApi.liveRunsForCompany(companyId, {
+ minCount: minRunCount,
+ limit: effectiveFetchLimit,
+ distinctTasks: dedupeLinkedTasks,
+ }),
enabled: sharedLiveRuns.enabled,
});
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
const runs = liveRuns ?? [];
- const visibleRuns = useMemo(() => runs.slice(0, cardLimit), [cardLimit, runs]);
- const hiddenRunCount = Math.max(0, runs.length - visibleRuns.length);
+ const cardRuns = useMemo(() => {
+ if (!dedupeLinkedTasks) return runs;
+
+ // The endpoint orders active runs first, then recent completed runs. Keep
+ // the first run for each task so an active attempt wins over its history.
+ const seenIssueIds = new Set();
+ return runs.filter((run) => {
+ if (!run.issueId) return true;
+ if (seenIssueIds.has(run.issueId)) return false;
+ seenIssueIds.add(run.issueId);
+ return true;
+ });
+ }, [dedupeLinkedTasks, runs]);
+ const visibleRuns = useMemo(() => cardRuns.slice(0, cardLimit), [cardLimit, cardRuns]);
+ const hiddenRunCount = Math.max(0, cardRuns.length - visibleRuns.length);
const visibleIssueIds = useMemo(
() => [...new Set(visibleRuns.map((run) => run.issueId).filter((issueId): issueId is string => Boolean(issueId)))],
[visibleRuns],
diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx
index 756727fbaf..38bf9e51ef 100644
--- a/ui/src/pages/Dashboard.tsx
+++ b/ui/src/pages/Dashboard.tsx
@@ -374,7 +374,7 @@ export function Dashboard() {
)}
-
+
{data && (
<>
From 8f7baf2f7254cebc7250d775e1e2757269e42666 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 1 Oct 2026 11:13:22 -0700
Subject: [PATCH 08/10] chore(deps): bump @aws-sdk/client-s3 from 3.1122.0 to
3.1141.0 (#13475)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps
[@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3)
from 3.1122.0 to 3.1141.0.
Release notes
Sourced from @aws-sdk/client-s3's
releases.
v3.1141.0
3.1141.0(2026-09-25)
Chores
New Features
- client-connect: Agent Privacy During Hold is a new
privacy capability for Amazon Connect Voice that prevents agent audio
from being captured in call recordings or Contact Lens conversational
analytics during hold. When enabled, agents are automatically muted on
entering hold and unmuted on resuming the contact (03527f9e)
- client-qconnect: Release shapes for the proactive
agentic recommendations and the multi-knowledge base search features.
Increases the maximum length of QuickResponseContent. (e008332b)
- client-bedrock-agent: Adds support for calling VPC
configuration API's in Bedrock. These configurations allow the use of On
Prem connectors in Bedrock Managed Knowledge bases (18524dc6)
- client-mediaconnect: This release adds support for
RTMP push router outputs in AWS Elemental MediaConnect. (5bd8d80b)
- client-securityagent: This release adds the
ListActorMessages operation, which returns the multi-factor
authentication messages received at an actor's server-generated email
address (10e53d50)
- client-arc-region-switch: Adds a service quota
checker to Region switch to verify quota parity between your primary and
standby Region, and automatically submit quota limit increases. Adds an
optional EC2 Auto Scaling and ECS setting that waits for instances or
tasks in the scaled-up Region to be healthy in target groups. (cca33e38)
- client-bedrock-agentcore-control: Amazon Bedrock
AgentCore Payments now supports credential rotation for payment
connectors, letting you rotate API and wallet secrets for Quick Create
payment auths from the console. This release also adds Type and Creation
type columns to the payment managers views. (adca591f)
- client-neptune-graph: Add GraphIdentifier filter
for ListImportTasks (9b9aea9b)
- client-rekognition: This release adds support for
Feedback and Metadata in the GetFaceLivenessSessionResults response.
Feedback returns codes explaining why a Face Liveness check produced its
result. Metadata includes the client SDK type. (0831c361)
- client-glue: add support for table level federation
(a44458b7)
- client-wellarchitected: This change releases the
Well-Architected Agent, a generative AI service that analyzes a
customer's AWS environment and delivers personalized, prioritized
recommendations across cost, security, performance, and resilience. (d0656586)
For list of updated packages, view
updated-packages.md in
assets-3.1141.0.zip
v3.1140.0
3.1140.0(2026-09-24)
Documentation Changes
- client-route53resolver: Documentation updates for
Route 53 Resolver. Clarifies which Outpost Resolver operations apply to
first-generation AWS Outposts and that Resolver is managed automatically
on second-generation Outposts. Adds Local Network Interface subnet
compatibility notes for Resolver endpoints. (b4432aba)
- client-iot: Fixed ListV2LoggingLevels and
DeleteV2LoggingLevel documentation to include all supported target-types
(4dcf76d5)
New Features
- clients: update client endpoints as of 2026-09-24
(29a8566c)
- client-eventbridgev2: Introducing Amazon
EventBridge enhanced Custom event bus, a new shareable event bus for
organizational-scale event-driven applications feature ordered delivery,
deduplication, open event formats, and cross-account bus sharing. (69fbe6a2)
- client-datazone: Amazon DataZone now supports the
TOOLING blueprint category on CreateEnvironmentBlueprint,
UpdateEnvironmentBlueprint, GetEnvironmentBlueprint, and
ListEnvironmentBlueprints, for custom tooling blueprints.
CreateConnection now accepts roleArn in iamProperties. (591cd6f5)
- client-elasticache: Added tagging support for
ElastiCache Global DataStore. (0fa9da59)
- client-marketplace-discovery: AWS Marketplace
Discovery API now supports localized responses and SigV4a request
signing. It returns new fulfillment details, including AMI architecture,
EBS volume and security group information, SaaS quick-launch status, and
SageMaker input and output MIME types. (510673e3)
- client-redshift-data: Updates to the ListDatabases
and WorkgroupName validation (218c24e1)
- client-securityagent: Added support for Confluence
export, enabling customers to publish security findings to Confluence
pages. (81408527)
- client-cloudwatch: This release adds Create, Get,
Update, and DeleteResourceMetricsConfiguration to enable detailed metric
collection for an AWS resource, and adds UpdateOTelEnrichment plus
include and exclude filters on StartOTelEnrichment so you can choose
which metric namespaces CloudWatch enriches. (765cc1ce)
- client-eventbridge: Adds a ManagedBy field to the
DescribeEventBus and ListEventBuses responses, identifying the AWS
service that created an event bus on your behalf. (28a639b2)
Tests
- undici-http-handler: update bidi stream e2e test to
nova-2-sonic model (#8313)
(d9a37d9d)
... (truncated)
Changelog
Sourced from @aws-sdk/client-s3's
changelog.
Note: Version bump only for package
@aws-sdk/client-s3
Note: Version bump only for package
@aws-sdk/client-s3
Note: Version bump only for package
@aws-sdk/client-s3
Note: Version bump only for package
@aws-sdk/client-s3
Note: Version bump only for package
@aws-sdk/client-s3
Note: Version bump only for package
@aws-sdk/client-s3
... (truncated)
Commits
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
pnpm-lock.yaml | 312 ++++++++++++++++++++++----------------------
server/package.json | 2 +-
2 files changed, 157 insertions(+), 157 deletions(-)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3d48251ab0..6f2b5f9cef 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -875,8 +875,8 @@ importers:
server:
dependencies:
'@aws-sdk/client-s3':
- specifier: ^3.1122.0
- version: 3.1122.0
+ specifier: ^3.1141.0
+ version: 3.1141.0
'@chat-adapter/discord':
specifier: 4.39.0
version: 4.39.0(patch_hash=nywlhls7dd4npegrgax7jqdbxa)(zod@4.4.3)
@@ -1408,72 +1408,72 @@ packages:
react:
optional: true
- '@aws-sdk/checksums@3.1000.29':
- resolution: {integrity: sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==}
+ '@aws-sdk/checksums@3.1001.1':
+ resolution: {integrity: sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/client-s3@3.1122.0':
- resolution: {integrity: sha512-efrWRspDhSf+bbL/yOnM8IzZhX7FZqkb6K35XkIRk1KqR6Vxll6weZzmeiIWlYmERI7BmaB5BhGZaw4lrYtk7Q==}
+ '@aws-sdk/client-s3@3.1141.0':
+ resolution: {integrity: sha512-uOVH37xGLenAdJkCPCin/JJG2PgWrFcSsDnQ9+C9Zq8N9Oalo5ol4xmn5fG28iWAlA/b/9boQZgHbMh+UsIhcg==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/core@3.977.9':
- resolution: {integrity: sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==}
+ '@aws-sdk/core@3.978.1':
+ resolution: {integrity: sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-env@3.972.70':
- resolution: {integrity: sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==}
+ '@aws-sdk/credential-provider-env@3.972.72':
+ resolution: {integrity: sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-http@3.972.72':
- resolution: {integrity: sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==}
+ '@aws-sdk/credential-provider-http@3.972.74':
+ resolution: {integrity: sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-ini@3.973.15':
- resolution: {integrity: sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==}
+ '@aws-sdk/credential-provider-ini@3.973.17':
+ resolution: {integrity: sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-login@3.972.77':
- resolution: {integrity: sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==}
+ '@aws-sdk/credential-provider-login@3.972.79':
+ resolution: {integrity: sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-node@3.972.82':
- resolution: {integrity: sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==}
+ '@aws-sdk/credential-provider-node@3.972.84':
+ resolution: {integrity: sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-process@3.972.70':
- resolution: {integrity: sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==}
+ '@aws-sdk/credential-provider-process@3.972.72':
+ resolution: {integrity: sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-sso@3.973.14':
- resolution: {integrity: sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==}
+ '@aws-sdk/credential-provider-sso@3.973.16':
+ resolution: {integrity: sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/credential-provider-web-identity@3.972.76':
- resolution: {integrity: sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==}
+ '@aws-sdk/credential-provider-web-identity@3.972.78':
+ resolution: {integrity: sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/middleware-sdk-s3@3.972.75':
- resolution: {integrity: sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==}
+ '@aws-sdk/middleware-sdk-s3@3.972.77':
+ resolution: {integrity: sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/nested-clients@3.997.44':
- resolution: {integrity: sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==}
+ '@aws-sdk/nested-clients@3.997.46':
+ resolution: {integrity: sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/signature-v4-multi-region@3.996.46':
- resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==}
+ '@aws-sdk/signature-v4-multi-region@3.996.47':
+ resolution: {integrity: sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/token-providers@3.1116.0':
- resolution: {integrity: sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==}
+ '@aws-sdk/token-providers@3.1138.0':
+ resolution: {integrity: sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/types@3.974.5':
- resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==}
+ '@aws-sdk/types@3.974.6':
+ resolution: {integrity: sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==}
engines: {node: '>=20.0.0'}
- '@aws-sdk/xml-builder@3.972.40':
- resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==}
+ '@aws-sdk/xml-builder@3.972.41':
+ resolution: {integrity: sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==}
engines: {node: '>=20.0.0'}
'@aws/lambda-invoke-store@0.3.0':
@@ -4761,8 +4761,8 @@ packages:
resolution: {integrity: sha512-ItjyjEZml+LDH8CjcCLRLJHh7VZtevPKExrRN3l5KWyBliyDnGAeoO4Y+K+fFBmRpKLYVPgqWMX4THldv2HVtA==}
engines: {node: '>= 18', npm: '>= 8.6.0'}
- '@smithy/core@3.33.3':
- resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==}
+ '@smithy/core@3.35.1':
+ resolution: {integrity: sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.5.2':
@@ -4777,12 +4777,12 @@ packages:
resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==}
engines: {node: '>=18.0.0'}
- '@smithy/signature-v4@5.7.3':
- resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==}
+ '@smithy/signature-v4@5.7.4':
+ resolution: {integrity: sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==}
engines: {node: '>=18.0.0'}
- '@smithy/types@4.18.0':
- resolution: {integrity: sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==}
+ '@smithy/types@4.19.0':
+ resolution: {integrity: sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==}
engines: {node: '>=18.0.0'}
'@stablelib/base64@1.0.1':
@@ -9166,167 +9166,167 @@ snapshots:
'@types/react': 19.2.18
react: 19.2.8
- '@aws-sdk/checksums@3.1000.29':
+ '@aws-sdk/checksums@3.1001.1':
dependencies:
- '@aws-sdk/core': 3.977.9
- '@aws-sdk/types': 3.974.5
- '@smithy/core': 3.33.3
- '@smithy/types': 4.18.0
+ '@aws-sdk/core': 3.978.1
+ '@aws-sdk/types': 3.974.6
+ '@smithy/core': 3.35.1
+ '@smithy/types': 4.19.0
tslib: 2.8.1
- '@aws-sdk/client-s3@3.1122.0':
+ '@aws-sdk/client-s3@3.1141.0':
dependencies:
- '@aws-sdk/checksums': 3.1000.29
- '@aws-sdk/core': 3.977.9
- '@aws-sdk/credential-provider-node': 3.972.82
- '@aws-sdk/middleware-sdk-s3': 3.972.75
- '@aws-sdk/signature-v4-multi-region': 3.996.46
- '@aws-sdk/types': 3.974.5
- '@smithy/core': 3.33.3
+ '@aws-sdk/checksums': 3.1001.1
+ '@aws-sdk/core': 3.978.1
+ '@aws-sdk/credential-provider-node': 3.972.84
+ '@aws-sdk/middleware-sdk-s3': 3.972.77
+ '@aws-sdk/signature-v4-multi-region': 3.996.47
+ '@aws-sdk/types': 3.974.6
+ '@smithy/core': 3.35.1
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
- '@smithy/types': 4.18.0
+ '@smithy/types': 4.19.0
tslib: 2.8.1
- '@aws-sdk/core@3.977.9':
+ '@aws-sdk/core@3.978.1':
dependencies:
- '@aws-sdk/types': 3.974.5
- '@aws-sdk/xml-builder': 3.972.40
+ '@aws-sdk/types': 3.974.6
+ '@aws-sdk/xml-builder': 3.972.41
'@aws/lambda-invoke-store': 0.3.0
- '@smithy/core': 3.33.3
- '@smithy/signature-v4': 5.7.3
- '@smithy/types': 4.18.0
+ '@smithy/core': 3.35.1
+ '@smithy/signature-v4': 5.7.4
+ '@smithy/types': 4.19.0
bowser: 2.14.1
tslib: 2.8.1
- '@aws-sdk/credential-provider-env@3.972.70':
+ '@aws-sdk/credential-provider-env@3.972.72':
dependencies:
- '@aws-sdk/core': 3.977.9
- '@aws-sdk/types': 3.974.5
- '@smithy/core': 3.33.3
- '@smithy/types': 4.18.0
+ '@aws-sdk/core': 3.978.1
+ '@aws-sdk/types': 3.974.6
+ '@smithy/core': 3.35.1
+ '@smithy/types': 4.19.0
tslib: 2.8.1
- '@aws-sdk/credential-provider-http@3.972.72':
+ '@aws-sdk/credential-provider-http@3.972.74':
dependencies:
- '@aws-sdk/core': 3.977.9
- '@aws-sdk/types': 3.974.5
- '@smithy/core': 3.33.3
+ '@aws-sdk/core': 3.978.1
+ '@aws-sdk/types': 3.974.6
+ '@smithy/core': 3.35.1
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
- '@smithy/types': 4.18.0
+ '@smithy/types': 4.19.0
tslib: 2.8.1
- '@aws-sdk/credential-provider-ini@3.973.15':
+ '@aws-sdk/credential-provider-ini@3.973.17':
dependencies:
- '@aws-sdk/core': 3.977.9
- '@aws-sdk/credential-provider-env': 3.972.70
- '@aws-sdk/credential-provider-http': 3.972.72
- '@aws-sdk/credential-provider-login': 3.972.77
- '@aws-sdk/credential-provider-process': 3.972.70
- '@aws-sdk/credential-provider-sso': 3.973.14
- '@aws-sdk/credential-provider-web-identity': 3.972.76
- '@aws-sdk/nested-clients': 3.997.44
- '@aws-sdk/types': 3.974.5
- '@smithy/core': 3.33.3
+ '@aws-sdk/core': 3.978.1
+ '@aws-sdk/credential-provider-env': 3.972.72
+ '@aws-sdk/credential-provider-http': 3.972.74
+ '@aws-sdk/credential-provider-login': 3.972.79
+ '@aws-sdk/credential-provider-process': 3.972.72
+ '@aws-sdk/credential-provider-sso': 3.973.16
+ '@aws-sdk/credential-provider-web-identity': 3.972.78
+ '@aws-sdk/nested-clients': 3.997.46
+ '@aws-sdk/types': 3.974.6
+ '@smithy/core': 3.35.1
'@smithy/credential-provider-imds': 4.5.2
- '@smithy/types': 4.18.0
+ '@smithy/types': 4.19.0
tslib: 2.8.1
- '@aws-sdk/credential-provider-login@3.972.77':
+ '@aws-sdk/credential-provider-login@3.972.79':
dependencies:
- '@aws-sdk/core': 3.977.9
- '@aws-sdk/nested-clients': 3.997.44
- '@aws-sdk/types': 3.974.5
- '@smithy/core': 3.33.3
- '@smithy/types': 4.18.0
+ '@aws-sdk/core': 3.978.1
+ '@aws-sdk/nested-clients': 3.997.46
+ '@aws-sdk/types': 3.974.6
+ '@smithy/core': 3.35.1
+ '@smithy/types': 4.19.0
tslib: 2.8.1
- '@aws-sdk/credential-provider-node@3.972.82':
+ '@aws-sdk/credential-provider-node@3.972.84':
dependencies:
- '@aws-sdk/credential-provider-env': 3.972.70
- '@aws-sdk/credential-provider-http': 3.972.72
- '@aws-sdk/credential-provider-ini': 3.973.15
- '@aws-sdk/credential-provider-process': 3.972.70
- '@aws-sdk/credential-provider-sso': 3.973.14
- '@aws-sdk/credential-provider-web-identity': 3.972.76
- '@aws-sdk/types': 3.974.5
- '@smithy/core': 3.33.3
+ '@aws-sdk/credential-provider-env': 3.972.72
+ '@aws-sdk/credential-provider-http': 3.972.74
+ '@aws-sdk/credential-provider-ini': 3.973.17
+ '@aws-sdk/credential-provider-process': 3.972.72
+ '@aws-sdk/credential-provider-sso': 3.973.16
+ '@aws-sdk/credential-provider-web-identity': 3.972.78
+ '@aws-sdk/types': 3.974.6
+ '@smithy/core': 3.35.1
'@smithy/credential-provider-imds': 4.5.2
- '@smithy/types': 4.18.0
+ '@smithy/types': 4.19.0
tslib: 2.8.1
- '@aws-sdk/credential-provider-process@3.972.70':
+ '@aws-sdk/credential-provider-process@3.972.72':
dependencies:
- '@aws-sdk/core': 3.977.9
- '@aws-sdk/types': 3.974.5
- '@smithy/core': 3.33.3
- '@smithy/types': 4.18.0
+ '@aws-sdk/core': 3.978.1
+ '@aws-sdk/types': 3.974.6
+ '@smithy/core': 3.35.1
+ '@smithy/types': 4.19.0
tslib: 2.8.1
- '@aws-sdk/credential-provider-sso@3.973.14':
+ '@aws-sdk/credential-provider-sso@3.973.16':
dependencies:
- '@aws-sdk/core': 3.977.9
- '@aws-sdk/nested-clients': 3.997.44
- '@aws-sdk/token-providers': 3.1116.0
- '@aws-sdk/types': 3.974.5
- '@smithy/core': 3.33.3
- '@smithy/types': 4.18.0
+ '@aws-sdk/core': 3.978.1
+ '@aws-sdk/nested-clients': 3.997.46
+ '@aws-sdk/token-providers': 3.1138.0
+ '@aws-sdk/types': 3.974.6
+ '@smithy/core': 3.35.1
+ '@smithy/types': 4.19.0
tslib: 2.8.1
- '@aws-sdk/credential-provider-web-identity@3.972.76':
+ '@aws-sdk/credential-provider-web-identity@3.972.78':
dependencies:
- '@aws-sdk/core': 3.977.9
- '@aws-sdk/nested-clients': 3.997.44
- '@aws-sdk/types': 3.974.5
- '@smithy/core': 3.33.3
- '@smithy/types': 4.18.0
+ '@aws-sdk/core': 3.978.1
+ '@aws-sdk/nested-clients': 3.997.46
+ '@aws-sdk/types': 3.974.6
+ '@smithy/core': 3.35.1
+ '@smithy/types': 4.19.0
tslib: 2.8.1
- '@aws-sdk/middleware-sdk-s3@3.972.75':
+ '@aws-sdk/middleware-sdk-s3@3.972.77':
dependencies:
- '@aws-sdk/core': 3.977.9
- '@aws-sdk/signature-v4-multi-region': 3.996.46
- '@aws-sdk/types': 3.974.5
- '@smithy/core': 3.33.3
- '@smithy/types': 4.18.0
+ '@aws-sdk/core': 3.978.1
+ '@aws-sdk/signature-v4-multi-region': 3.996.47
+ '@aws-sdk/types': 3.974.6
+ '@smithy/core': 3.35.1
+ '@smithy/types': 4.19.0
tslib: 2.8.1
- '@aws-sdk/nested-clients@3.997.44':
+ '@aws-sdk/nested-clients@3.997.46':
dependencies:
- '@aws-sdk/core': 3.977.9
- '@aws-sdk/signature-v4-multi-region': 3.996.46
- '@aws-sdk/types': 3.974.5
- '@smithy/core': 3.33.3
+ '@aws-sdk/core': 3.978.1
+ '@aws-sdk/signature-v4-multi-region': 3.996.47
+ '@aws-sdk/types': 3.974.6
+ '@smithy/core': 3.35.1
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
- '@smithy/types': 4.18.0
+ '@smithy/types': 4.19.0
tslib: 2.8.1
- '@aws-sdk/signature-v4-multi-region@3.996.46':
+ '@aws-sdk/signature-v4-multi-region@3.996.47':
dependencies:
- '@aws-sdk/types': 3.974.5
- '@smithy/signature-v4': 5.7.3
- '@smithy/types': 4.18.0
+ '@aws-sdk/types': 3.974.6
+ '@smithy/signature-v4': 5.7.4
+ '@smithy/types': 4.19.0
tslib: 2.8.1
- '@aws-sdk/token-providers@3.1116.0':
+ '@aws-sdk/token-providers@3.1138.0':
dependencies:
- '@aws-sdk/core': 3.977.9
- '@aws-sdk/nested-clients': 3.997.44
- '@aws-sdk/types': 3.974.5
- '@smithy/core': 3.33.3
- '@smithy/types': 4.18.0
+ '@aws-sdk/core': 3.978.1
+ '@aws-sdk/nested-clients': 3.997.46
+ '@aws-sdk/types': 3.974.6
+ '@smithy/core': 3.35.1
+ '@smithy/types': 4.19.0
tslib: 2.8.1
- '@aws-sdk/types@3.974.5':
+ '@aws-sdk/types@3.974.6':
dependencies:
- '@smithy/types': 4.18.0
+ '@smithy/types': 4.19.0
tslib: 2.8.1
- '@aws-sdk/xml-builder@3.972.40':
+ '@aws-sdk/xml-builder@3.972.41':
dependencies:
- '@smithy/types': 4.18.0
+ '@smithy/types': 4.19.0
tslib: 2.8.1
'@aws/lambda-invoke-store@0.3.0': {}
@@ -12614,36 +12614,36 @@ snapshots:
- debug
- supports-color
- '@smithy/core@3.33.3':
+ '@smithy/core@3.35.1':
dependencies:
- '@smithy/types': 4.18.0
+ '@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/credential-provider-imds@4.5.2':
dependencies:
- '@smithy/core': 3.33.3
- '@smithy/types': 4.18.0
+ '@smithy/core': 3.35.1
+ '@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/fetch-http-handler@5.8.0':
dependencies:
- '@smithy/core': 3.33.3
- '@smithy/types': 4.18.0
+ '@smithy/core': 3.35.1
+ '@smithy/types': 4.19.0
tslib: 2.8.1
'@smithy/node-http-handler@4.12.1':
dependencies:
- '@smithy/core': 3.33.3
- '@smithy/types': 4.18.0
+ '@smithy/core': 3.35.1
+ '@smithy/types': 4.19.0
tslib: 2.8.1
- '@smithy/signature-v4@5.7.3':
+ '@smithy/signature-v4@5.7.4':
dependencies:
- '@smithy/core': 3.33.3
- '@smithy/types': 4.18.0
+ '@smithy/core': 3.35.1
+ '@smithy/types': 4.19.0
tslib: 2.8.1
- '@smithy/types@4.18.0':
+ '@smithy/types@4.19.0':
dependencies:
tslib: 2.8.1
diff --git a/server/package.json b/server/package.json
index fe28203048..8705db49b7 100644
--- a/server/package.json
+++ b/server/package.json
@@ -44,7 +44,7 @@
"typecheck": "pnpm run prepare:runner-vendor && pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit"
},
"dependencies": {
- "@aws-sdk/client-s3": "^3.1122.0",
+ "@aws-sdk/client-s3": "^3.1141.0",
"@chat-adapter/discord": "4.39.0",
"@chat-adapter/github": "4.39.0",
"@chat-adapter/slack": "4.39.0",
From dd9983b8945aa087e43d7d3694f694999d3e3a37 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Thu, 1 Oct 2026 12:14:48 -0700
Subject: [PATCH 09/10] fix(adapter-utils): release restore locks when a
process crashes (#14869)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent runs restore workspace files and collect instruction-file
changes.
> - Writers to the same target directory must wait for each other.
> - The current lock records a PID, which a new process can reuse after
a crash.
> - A reused PID can keep an orphaned lock alive and make each later run
fail.
> - This pull request makes a SQLite file lock decide ownership. The OS
releases it when the process exits.
> - Later runs can proceed after a crash, and concurrent live writers
remain protected.
## Linked Issues or Issue Description
Refs #10914. This addresses crash recovery. It does not cancel a stalled
operation in a process that is still alive.
Related work: #9667, #14787, and #12187. The earlier attempt in #9667
assumes one live server per lock root. This implementation uses an
OS-backed lock to support concurrent writers without treating a
different process token or an old timestamp as proof of a dead owner. It
retains the private lock root and bounded timeout diagnostics from the
merged changes.
After a process dies while holding a restore lock, a replacement process
can reuse its PID. The existing `process.kill(pid, 0)` check then
reports a live owner forever. Later runs can complete their model turn
but fail during file collection or restore.
## What Changed
- Hold a SQLite `BEGIN IMMEDIATE` transaction for each directory write.
Use the existing built-in `node:sqlite` dependency.
- Keep each lock database on a stable inode. Keep PID and time metadata
only for diagnostics.
- Retain the 30-second asynchronous wait and existing timeout error code
and diagnostic fields.
- Fail closed when an old directory lock exists. Document a
stopped-writer upgrade and rollback procedure.
- Add real child-process tests for crashes, PID reuse, live owners, and
connection cleanup. Cover callback failures, independent targets, stable
inodes, invalid lock files, and ambiguous legacy records.
## Verification
- Before the fix, the crash/PID-reuse test and the live-owner test both
failed. Both pass with this change.
- `pnpm exec vitest run
packages/adapter-utils/src/directory-merge-lock.test.ts
packages/adapter-utils/src/workspace-restore-merge.test.ts`: 56 tests
passed.
- Restore and agent-file working-copy integration tests: 118 tests
passed before the additional connection-cleanup test.
- `pnpm -r typecheck`: passed.
- `pnpm build`: passed.
- Full GitHub CI: all checks passed, including Linux workspace tests,
server test shards, build, typecheck, and browser tests.
- Greptile: 5/5, with no review threads or unresolved comments.
- `pnpm test:run`: started locally, then stopped with SIGINT (exit 130)
after full CI passed. The local serial run did not complete and is not
counted as a full local pass. The completed CI shards provide the
full-suite result.
## Risks
- **Upgrade and rollback require a drain.** Stop every old writer that
shares an instance root before switching protocols. Old and new versions
must not write concurrently.
- Existing legacy `.lock/` directories remain blocking. After all
writers stop, preserve run evidence and move those directories to an
operator scratch directory. The new code does not infer that they are
abandoned from PID or age.
- Never delete or replace a `.lock.sqlite` file while writers can run.
These small files remain after release.
- The shared filesystem must support reliable SQLite locking. Broken
network-filesystem locking is unsupported.
- This change prevents new orphaned ownership. It does not recover file
changes lost during earlier failed collections, or interrupt a live
operation that stalls.
- No application database migration or new native dependency is
required. See `doc/workspace-restore-locks.md` for the procedure.
## Model Used
OpenAI Codex based on GPT-6, with code execution and repository tools.
The exact model variant and context window are not exposed in this
session.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (focused regression and
integration suites; see the full-suite note above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
Co-authored-by: Paperclip
---
doc/DEVELOPING.md | 3 +
doc/workspace-restore-locks.md | 56 ++++++
.../src/directory-merge-lock.test.ts | 182 ++++++++++++++++++
.../src/workspace-restore-merge.test.ts | 13 +-
.../src/workspace-restore-merge.ts | 148 +++++++-------
5 files changed, 329 insertions(+), 73 deletions(-)
create mode 100644 doc/workspace-restore-locks.md
create mode 100644 packages/adapter-utils/src/directory-merge-lock.test.ts
diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md
index 2615e61611..c88890e98a 100644
--- a/doc/DEVELOPING.md
+++ b/doc/DEVELOPING.md
@@ -6,6 +6,9 @@ This project can run fully in local dev without setting up PostgreSQL manually.
For mode definitions and intended CLI behavior, see `doc/DEPLOYMENT-MODES.md`.
+For sandbox file synchronization, lock ownership, and the required upgrade
+procedure from directory locks, see [Workspace restore locks](workspace-restore-locks.md).
+
Current implementation status:
- canonical model: `local_trusted` and `authenticated` (with `private/public` exposure)
diff --git a/doc/workspace-restore-locks.md b/doc/workspace-restore-locks.md
new file mode 100644
index 0000000000..60a00fc0b7
--- /dev/null
+++ b/doc/workspace-restore-locks.md
@@ -0,0 +1,56 @@
+# Workspace restore locks
+
+Workspace restore and agent-file collection serialize writes to each canonical
+target directory. Their lock files live in
+`/locks/directory-merge`, outside the writable target. All writers
+must use the same instance root and a filesystem with reliable SQLite file
+locking. Network filesystems that do not provide that locking are not supported.
+
+Each target has a permanent `.lock.sqlite` file. An open SQLite
+`BEGIN IMMEDIATE` transaction holds its reserved file lock for the entire write
+operation. Contenders retry without blocking the Node event loop, up to the
+existing 30-second limit. Closing the connection releases the lock; the operating
+system also releases it when the process exits or crashes. Independent targets
+use different files and can proceed concurrently.
+
+This uses the same built-in `node:sqlite` dependency as workspace manifests.
+See [SQLite file locking](https://www.sqlite.org/lockingv3.html) for the reserved
+lock contract. No application database or schema migration is involved.
+
+The adjacent `.lock.owner.json` records a PID and creation time only for
+bounded timeout diagnostics. Missing, stale, or incorrect metadata cannot grant
+or retain ownership. PID reuse, PID namespaces, and wall-clock changes do not
+decide whether a writer owns the lock.
+
+**Never delete, replace, or move a `.lock.sqlite` file while an instance can
+write to it.** Its stable inode is part of the locking contract. Replacing it
+could let two writers lock different files for the same target. Files remain
+after release, including for targets that no longer exist. Their diagnostic
+owner sidecars are normally removed after release.
+
+## Upgrade from directory locks
+
+Older versions created `.lock/owner.json` and checked only whether the
+recorded PID existed. A server restart could reuse that PID and leave an orphaned
+lock permanently protected. Those records cannot establish a process lifetime or
+PID namespace, so the new implementation never guesses that a legacy holder is
+dead. An existing legacy directory continues to block admission.
+
+Do not run old and new lock protocols concurrently against the same instance
+root. An old process does not participate in the SQLite lock protocol.
+
+1. Drain and stop **all** old server and worker processes that can write to the
+ instance root, including processes on other hosts or in other containers.
+2. Preserve any unfinished-run evidence needed for recovery. After all writers
+ are stopped, move leftover legacy `.lock/` directories to an operator
+ scratch directory outside the lock root. Do not remove `.lock.sqlite` files.
+3. Start all writers on the new version. Verify that a run completes both the
+ provider turn and file collection/restore. A successful model response alone
+ does not prove that its local file changes were saved.
+
+The same drain requirement applies to rollback. Existing permanent SQLite files
+can remain on disk; older versions ignore them. Do not infer that a legacy lock
+is safe to remove from its age, an absent PID, or a successful task response.
+
+This change prevents new orphaned ownership. It cannot recover file changes
+that an earlier failed collection discarded.
diff --git a/packages/adapter-utils/src/directory-merge-lock.test.ts b/packages/adapter-utils/src/directory-merge-lock.test.ts
new file mode 100644
index 0000000000..c05305fb8d
--- /dev/null
+++ b/packages/adapter-utils/src/directory-merge-lock.test.ts
@@ -0,0 +1,182 @@
+import { execFile, spawn, type ChildProcess } from "node:child_process";
+import { createHash } from "node:crypto";
+import { once } from "node:events";
+import { access, link, mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { withDirectoryMergeLock, WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE } from "./workspace-restore-merge.js";
+
+describe("directory merge lock process lifetime", () => {
+ const loader = fileURLToPath(new URL("../../../cli/node_modules/tsx/dist/cli.mjs", import.meta.url));
+ const module = fileURLToPath(new URL("./workspace-restore-merge.ts", import.meta.url));
+ const directories: string[] = [];
+ const children: ChildProcess[] = [];
+ afterEach(async () => {
+ vi.restoreAllMocks();
+ for (const child of children.splice(0)) {
+ if (child.exitCode === null && child.signalCode === null) {
+ const exited = once(child, "exit");
+ child.kill("SIGKILL");
+ await exited;
+ }
+ }
+ await Promise.all(directories.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
+ });
+
+ async function fixture() {
+ const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-lock-process-"));
+ directories.push(root);
+ const target = path.join(root, "target");
+ await mkdir(target);
+ const env = { ...process.env, PAPERCLIP_HOME: path.join(root, "home"), PAPERCLIP_INSTANCE_ID: "test" };
+ const key = createHash("sha256").update(await realpath(target)).digest("hex");
+ const lock = path.join(env.PAPERCLIP_HOME, "instances", "test", "locks", "directory-merge", `${key}.lock`);
+ return { target, env, lock };
+ }
+
+ async function holder(target: string, env: NodeJS.ProcessEnv) {
+ const child = spawn(process.execPath, [loader, "--eval", `
+ import { withDirectoryMergeLock } from ${JSON.stringify(module)};
+ withDirectoryMergeLock(${JSON.stringify(target)}, async () => {
+ process.send?.("locked");
+ await new Promise(resolve => process.once("message", resolve));
+ }).then(() => process.exit(0)).catch(error => { console.error(error); process.exit(1); });
+ `], { env, stdio: ["ignore", "ignore", "pipe", "ipc"] });
+ children.push(child);
+ let stderr = "";
+ child.stderr?.on("data", (chunk) => { stderr += chunk; });
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error(`Child did not acquire lock: ${stderr}`)), 10_000);
+ child.once("message", () => { clearTimeout(timer); resolve(); });
+ child.once("error", (error) => { clearTimeout(timer); reject(error); });
+ child.once("exit", (code) => { clearTimeout(timer); reject(new Error(`Child exited ${code}: ${stderr}`)); });
+ });
+ return child;
+ }
+
+ // Both protocols carry diagnostic owner records. This also lets the crash
+ // regression demonstrate the old implementation's PID-reuse failure.
+ async function ownerPath(lock: string) {
+ return await access(`${lock}.owner.json`).then(() => `${lock}.owner.json`, () => path.join(lock, "owner.json"));
+ }
+
+ function expireWait() {
+ const now = Date.now();
+ return vi.spyOn(Date, "now").mockReturnValueOnce(now).mockReturnValue(now + 60_000);
+ }
+
+ it("recovers a killed holder even when its recorded PID has been reused", async () => {
+ const { target, env, lock } = await fixture();
+ const child = await holder(target, env);
+ const exited = once(child, "exit");
+ child.kill("SIGKILL");
+ await exited;
+ const recordPath = await ownerPath(lock);
+ const record = JSON.parse(await readFile(recordPath, "utf8"));
+ await writeFile(recordPath, JSON.stringify({ ...record, pid: process.pid }));
+ const clock = expireWait();
+ try {
+ await expect(withDirectoryMergeLock(target, async () => "restored", env)).resolves.toBe("restored");
+ } finally { clock.mockRestore(); }
+ }, 15_000);
+
+ it("protects a live holder in another process regardless of diagnostic PID or age", async () => {
+ const { target, env, lock } = await fixture();
+ const child = await holder(target, env);
+ const recordPath = await ownerPath(lock);
+ await writeFile(recordPath, JSON.stringify({ pid: 2_147_483_647, createdAt: "2000-01-01T00:00:00.000Z" }));
+ const contender = vi.fn();
+ const clock = expireWait();
+ try {
+ await expect(withDirectoryMergeLock(target, contender, env)).rejects.toMatchObject({ code: WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE });
+ } finally { clock.mockRestore(); }
+ expect(contender).not.toHaveBeenCalled();
+ const exited = once(child, "exit");
+ child.send("release");
+ await exited;
+ await expect(withDirectoryMergeLock(target, async () => "released", env)).resolves.toBe("released");
+ }, 15_000);
+
+ it("releases ownership when the protected operation throws", async () => {
+ const { target, env } = await fixture();
+ await expect(withDirectoryMergeLock(target, async () => { throw new Error("operation failed"); }, env)).rejects.toThrow("operation failed");
+ await expect(withDirectoryMergeLock(target, async () => "next", env)).resolves.toBe("next");
+ });
+
+ it("keeps the OS lock when a same-process contender closes its connection", async () => {
+ const { target, env } = await fixture();
+ await withDirectoryMergeLock(target, async () => {
+ const clock = expireWait();
+ try {
+ await expect(withDirectoryMergeLock(target, async () => undefined, env)).rejects.toMatchObject({ code: WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE });
+ } finally { clock.mockRestore(); }
+ // A same-process test alone cannot prove that the OS lock survived: on
+ // POSIX, closing an unmanaged descriptor can drop process-wide locks.
+ const result = await promisify(execFile)(process.execPath, [loader, "--eval", `
+ import { withDirectoryMergeLock, WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE } from ${JSON.stringify(module)};
+ const now = Date.now();
+ let calls = 0;
+ Date.now = () => now + calls++ * 60_000;
+ withDirectoryMergeLock(${JSON.stringify(target)}, async () => "entered")
+ .then(() => { console.error("Entered a live holder's lock"); process.exit(1); })
+ .catch(error => {
+ if (error.code !== WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE) { console.error(error); process.exit(1); }
+ console.log("blocked");
+ });
+ `], { env, timeout: 10_000 });
+ expect(result.stdout.trim()).toBe("blocked");
+ }, env);
+ }, 15_000);
+
+ it("does not serialize unrelated target directories", async () => {
+ const { target, env } = await fixture();
+ const other = `${target}-other`;
+ await mkdir(other);
+ await withDirectoryMergeLock(target, async () => {
+ await expect(withDirectoryMergeLock(other, async () => "independent", env)).resolves.toBe("independent");
+ }, env);
+ });
+
+ it("retains the same database inode across holders", async () => {
+ const { target, env, lock } = await fixture();
+ await withDirectoryMergeLock(target, async () => undefined, env);
+ const before = await stat(`${lock}.sqlite`);
+ await withDirectoryMergeLock(target, async () => undefined, env);
+ const after = await stat(`${lock}.sqlite`);
+ expect({ dev: after.dev, ino: after.ino }).toEqual({ dev: before.dev, ino: before.ino });
+ });
+
+ it("closes the kernel lock when writing diagnostics fails", async () => {
+ const { target, env, lock } = await fixture();
+ await mkdir(`${lock}.owner.json`, { recursive: true });
+ await expect(withDirectoryMergeLock(target, async () => undefined, env)).rejects.toThrow();
+ await rm(`${lock}.owner.json`, { recursive: true });
+ await expect(withDirectoryMergeLock(target, async () => "next", env)).resolves.toBe("next");
+ });
+
+ it.each(["missing", "malformed", "dead PID"])("does not guess ownership of a legacy lock with %s metadata", async (kind) => {
+ const { target, env, lock } = await fixture();
+ await mkdir(lock, { recursive: true });
+ if (kind !== "missing") await writeFile(path.join(lock, "owner.json"), kind === "malformed" ? "{broken" : JSON.stringify({ pid: 2_147_483_647, createdAt: "2000-01-01T00:00:00.000Z" }));
+ const contender = vi.fn();
+ const clock = expireWait();
+ try {
+ await expect(withDirectoryMergeLock(target, contender, env)).rejects.toMatchObject({ code: WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE });
+ } finally { clock.mockRestore(); }
+ expect(contender).not.toHaveBeenCalled();
+ expect((await stat(lock)).isDirectory()).toBe(true);
+ });
+
+ it.skipIf(process.platform === "win32").each(["symlink", "hard link"])("rejects a lock database that is a %s", async (kind) => {
+ const { target, env, lock } = await fixture();
+ await mkdir(path.dirname(lock), { recursive: true });
+ const decoy = `${target}-decoy`;
+ await writeFile(decoy, "keep");
+ await (kind === "symlink" ? symlink(decoy, `${lock}.sqlite`) : link(decoy, `${lock}.sqlite`));
+ await expect(withDirectoryMergeLock(target, async () => undefined, env)).rejects.toThrow("not a plain, unshared file");
+ await expect(readFile(decoy, "utf8")).resolves.toBe("keep");
+ });
+});
diff --git a/packages/adapter-utils/src/workspace-restore-merge.test.ts b/packages/adapter-utils/src/workspace-restore-merge.test.ts
index aaacd5d7da..bfa57996a9 100644
--- a/packages/adapter-utils/src/workspace-restore-merge.test.ts
+++ b/packages/adapter-utils/src/workspace-restore-merge.test.ts
@@ -376,7 +376,7 @@ describe("workspace restore merge", () => {
}
});
- it("creates the lock root at mode 0o700 and removes the lock directory after release", async () => {
+ it("keeps the private lock database and removes diagnostic ownership after release", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-"));
cleanupDirs.push(rootDir);
const paperclipHome = path.join(rootDir, "paperclip-home");
@@ -392,8 +392,11 @@ describe("workspace restore merge", () => {
});
expect((await stat(lockRootDir)).mode & 0o777).toBe(0o700);
- expect(entriesDuringLock).toHaveLength(1);
- await expect(readdir(lockRootDir)).resolves.toHaveLength(0);
+ expect(entriesDuringLock.filter((name) => name.endsWith(".owner.json"))).toHaveLength(1);
+ const entriesAfterRelease = await readdir(lockRootDir);
+ expect(entriesAfterRelease).toHaveLength(1);
+ expect(entriesAfterRelease[0]).toMatch(/\.lock\.sqlite$/);
+ expect((await stat(path.join(lockRootDir, entriesAfterRelease[0]!))).mode & 0o777).toBe(0o600);
});
it("classifies the real lock-timeout error by its stable code, never by the message text", async () => {
@@ -469,9 +472,9 @@ describe("workspace restore merge", () => {
});
} finally { clock.mockRestore(); }
expect(contender).not.toHaveBeenCalled();
- expect(await readdir(lockRoot)).toHaveLength(1);
+ expect((await readdir(lockRoot)).filter((name) => name.endsWith(".owner.json"))).toHaveLength(1);
});
- expect(await readdir(lockRoot)).toHaveLength(0);
+ expect(await readdir(lockRoot)).toEqual([expect.stringMatching(/\.lock\.sqlite$/)]);
});
it("delivers the timeout when the diagnostic owner read stalls and ignores its late rejection", async () => {
diff --git a/packages/adapter-utils/src/workspace-restore-merge.ts b/packages/adapter-utils/src/workspace-restore-merge.ts
index 755bce9be4..60bdd8cc90 100644
--- a/packages/adapter-utils/src/workspace-restore-merge.ts
+++ b/packages/adapter-utils/src/workspace-restore-merge.ts
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { constants as fsConstants, promises as fs } from "node:fs";
import path from "node:path";
+import { DatabaseSync } from "node:sqlite";
import { createWorkspaceManifest, WorkspaceManifestMap, workspacePathMatcher, type PathManifest, type WorkspacePaths, type WorkspaceManifestWriter } from "./workspace-manifest.js";
import { shouldExcludePath } from "./exclude-patterns.js";
import { resolvePaperclipInstanceRootForAdapter } from "./server-utils.js";
@@ -200,7 +201,7 @@ function entriesMatch(left: SnapshotEntry | null | undefined, right: SnapshotEnt
return false;
}
-const LOCK_STALE_MS = 30_000;
+const LOCK_WAIT_MS = 30_000;
const LOCK_DIAGNOSTIC_READ_TIMEOUT_MS = 100;
const activeDirectoryMergeLocks = new Set();
const MAX_LOCK_DIAGNOSTIC_AGE_MS = 7 * 24 * 60 * 60 * 1000;
@@ -212,7 +213,7 @@ export type DirectoryMergeLockOperation =
/** Evidence only: neither process age nor this module's holder set can prove
* that a lock in another process or PID namespace is safe to reclaim. */
-async function directoryMergeLockDiagnostics(lockDir: string, waitMs: number): Promise> {
+async function directoryMergeLockDiagnostics(lockDir: string, waitMs: number, ownerPath: string): Promise> {
const diagnostics: Record = {
ownerState: "unknown",
knownLocalHolder: activeDirectoryMergeLocks.has(lockDir),
@@ -224,7 +225,7 @@ async function directoryMergeLockDiagnostics(lockDir: string, waitMs: number): P
// Abort is best effort: race the read as well so a stalled filesystem
// cannot keep the original lock timeout from reaching its caller.
const raw = await Promise.race([
- fs.readFile(path.join(lockDir, "owner.json"), { encoding: "utf8", signal: controller.signal }),
+ fs.readFile(ownerPath, { encoding: "utf8", signal: controller.signal }),
new Promise((resolve) => {
timeout = setTimeout(() => resolve(undefined), LOCK_DIAGNOSTIC_READ_TIMEOUT_MS);
}),
@@ -339,75 +340,86 @@ export function describeWorkspaceRestoreFailure(code: WorkspaceRestoreFailureCod
}
}
-async function isLockStale(lockDir: string): Promise {
- try {
- const raw = await fs.readFile(path.join(lockDir, "owner.json"), "utf8");
- const owner = JSON.parse(raw) as { pid?: unknown };
- const pid = typeof owner.pid === "number" && Number.isFinite(owner.pid) && owner.pid > 0 ? owner.pid : null;
- if (pid === null) {
- // Owner record is unparseable / missing pid — treat as stale.
- return true;
- }
- try {
- process.kill(pid, 0);
- return false;
- } catch {
- return true;
- }
- } catch {
- // owner.json is missing or unreadable. A live holder also passes through
- // this exact state, briefly, between its own `fs.mkdir(lockDir)` and its
- // `fs.writeFile(owner.json)` below. Reading "missing" as "stale" here would
- // let a concurrent acquirer delete a live holder's lock directory during
- // that window. Mirror the materializePaperclipSkillCopy lock pattern: fall
- // back to the lock directory's own mtime, and only call it stale once the
- // directory itself has outlived the stale threshold.
- const stat = await fs.stat(lockDir).catch(() => null);
- return !stat || Date.now() - stat.mtimeMs > LOCK_STALE_MS;
- }
-}
-
async function acquireDirectoryMergeLock(lockDir: string, operation?: DirectoryMergeLockOperation): Promise<() => Promise> {
const startedAt = performance.now();
- const deadline = Date.now() + LOCK_STALE_MS;
- while (true) {
- try {
- await fs.mkdir(lockDir);
- await fs.writeFile(
- path.join(lockDir, "owner.json"),
- `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`,
- "utf8",
+ const deadline = Date.now() + LOCK_WAIT_MS;
+ const databasePath = `${lockDir}.sqlite`;
+ const ownerPath = `${lockDir}.owner.json`;
+ async function waitForLock(diagnosticOwnerPath: string) {
+ if (Date.now() >= deadline) {
+ const timeoutError: NodeJS.ErrnoException & { workspaceRestoreLock?: Record } = new Error(
+ `Timed out waiting for workspace restore lock at ${lockDir}`,
);
- activeDirectoryMergeLocks.add(lockDir);
- return async () => {
- try {
- await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
- } finally {
- activeDirectoryMergeLocks.delete(lockDir);
- }
- };
- } catch (error) {
- const code = error && typeof error === "object" ? (error as { code?: unknown }).code : null;
- if (code !== "EEXIST") throw error;
- // Stale-lock detection: if the owner PID is dead (SIGKILL / OOM / crash),
- // the lockDir would otherwise persist forever and stall restores. Mirror
- // the materializePaperclipSkillCopy lock pattern — remove and retry.
- if (await isLockStale(lockDir)) {
- await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
- continue;
- }
- if (Date.now() >= deadline) {
- const timeoutError: NodeJS.ErrnoException & { workspaceRestoreLock?: Record } = new Error(
- `Timed out waiting for workspace restore lock at ${lockDir}`,
- );
- timeoutError.code = WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE;
- // Keep the original timeout if the diagnostic read itself fails.
- timeoutError.workspaceRestoreLock = await directoryMergeLockDiagnostics(lockDir, performance.now() - startedAt).catch(() => undefined);
- if (operation && timeoutError.workspaceRestoreLock) timeoutError.workspaceRestoreLock.operation = operation;
- throw timeoutError;
- }
- await new Promise((resolve) => setTimeout(resolve, 50));
+ timeoutError.code = WORKSPACE_RESTORE_LOCK_TIMEOUT_CODE;
+ // Keep the original timeout if the diagnostic read itself fails.
+ timeoutError.workspaceRestoreLock = await directoryMergeLockDiagnostics(lockDir, performance.now() - startedAt, diagnosticOwnerPath).catch(() => undefined);
+ if (operation && timeoutError.workspaceRestoreLock) timeoutError.workspaceRestoreLock.operation = operation;
+ throw timeoutError;
}
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+
+ // SQLite's RESERVED file lock is the authority, including across processes
+ // and PID namespaces. It is released by the OS on a crash. The empty database
+ // is permanent: unlinking it would let contenders lock different inodes.
+ // node:sqlite is already required for workspace manifests; no native add-on
+ // or external flock command is needed on macOS, Linux, or Windows.
+ const databaseStat = await fs.lstat(databasePath).catch((error: NodeJS.ErrnoException) => {
+ if (error.code === "ENOENT") return null;
+ throw error;
+ });
+ if (databaseStat && (!databaseStat.isFile() || databaseStat.isSymbolicLink() || databaseStat.nlink !== 1)) {
+ throw new Error("Directory merge lock database is not a plain, unshared file.");
+ }
+ // Let SQLite create and manage every descriptor for this inode. On POSIX,
+ // closing a raw fs.open descriptor could release another connection's locks.
+ // The parent is private (0700), including while a new file is chmodded.
+ const database = new DatabaseSync(databasePath, { allowExtension: false });
+ try {
+ await fs.chmod(databasePath, 0o600);
+ // Never block the event loop while another async operation holds the lock.
+ database.exec("PRAGMA busy_timeout=0;");
+ while (true) {
+ try {
+ database.exec("BEGIN IMMEDIATE;");
+ break;
+ } catch (error) {
+ const code = (error as { errcode?: number }).errcode;
+ if (typeof code !== "number" || (code & 0xff) !== 5) throw error; // SQLITE_BUSY
+ await waitForLock(ownerPath);
+ }
+ }
+
+ // Old processes do not participate in the SQLite protocol. Never infer
+ // that a legacy owner is dead from PID existence, age, or missing metadata.
+ // Drain old writers before upgrading. A leftover legacy directory requires
+ // explicit offline cleanup; a live legacy holder can still release normally.
+ while (await fs.lstat(lockDir).then(() => true, (error: NodeJS.ErrnoException) => {
+ if (error.code === "ENOENT") return false;
+ throw error;
+ })) await waitForLock(path.join(lockDir, "owner.json"));
+
+ const owner = await fs.open(ownerPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_TRUNC | fsConstants.O_NOFOLLOW, 0o600);
+ try {
+ await owner.writeFile(`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, "utf8");
+ } finally { await owner.close(); }
+ activeDirectoryMergeLocks.add(lockDir);
+ let released = false;
+ return async () => {
+ if (released) return;
+ released = true;
+ try {
+ // This sidecar is diagnostic only. A failed removal cannot retain
+ // ownership, and the next holder replaces it while holding the DB lock.
+ await fs.unlink(ownerPath).catch(() => undefined);
+ } finally {
+ activeDirectoryMergeLocks.delete(lockDir);
+ database.close();
+ }
+ };
+ } catch (error) {
+ database.close();
+ throw error;
}
}
From 8ec4b84e1c92ddacea3a43864292db39b7fee269 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Thu, 1 Oct 2026 14:43:02 -0500
Subject: [PATCH 10/10] fix(chat): resume messages after failed runs without
duplicate delivery (#14857)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - A user can send a new message after a native run fails.
> - The server checks that the old execution has stopped before it
starts a fresh turn.
> - A failed run can retain a result accepted before checkpoint or
cleanup failed.
> - The continuation gate treated that saved result as active recovery
and held the new message forever.
> - This pull request removes that false liveness signal while retaining
controller, process, environment, and authorization checks.
> - Live staging then exposed a second defect: chat admission created a
successor without consuming the original deferred receipt, so completion
delivered the message again.
> - Consume that exact receipt atomically with admission, while
preserving separate turns for later chat messages.
## Linked Issues or Issue Description
**What happened?**
A new user message stayed in the queue with `controller_settling` after
the previous run had reached `terminal_failure`. The old coordinator had
no lease owner but still had a `resultId`. Its remote environment had a
verified stop receipt.
**Expected behavior**
Start one fresh turn after execution has stopped and normal admission
checks pass. Preserve the failed run and its accepted result as history.
**Steps to reproduce**
1. Accept a native result, then fail checkpoint or cleanup and exhaust
recovery.
2. Retain the result ID on the terminal failure record and stop the
execution environment.
3. Send a new user message. Before this fix, it waits forever for the
finished controller.
**Paperclip version or commit**
Reproduced in a database-backed regression test on `26900655b`.
**Deployment mode**
Server with a native runner and remote sandbox. Local process stop
checks also apply.
Related: https://github.com/paperclipai/paperclip/pull/14775. Searched
existing PRs for retained-result continuation fixes; no duplicate found.
## What Changed
- Remove the retained-result veto for terminal failures.
- Keep controller ownership, successor, process, environment cleanup,
pending decision, and ordinary admission checks.
- Add regressions for retained results, active execution, missing stop
evidence, and delayed remote cleanup.
- Atomically consume the resumed receipt in agent chat, even though chat
does not coalesce other queued messages.
- Reproduce completion-time duplicate promotion, race cleanup against
periodic recovery, and prove a subsequent chat message keeps its own
turn.
- Document that a saved result does not make a terminal failure active.
- Keep exhausted workspace export on its separate repair path, tested
through the production finalizer.
## Verification
- Red: retained-result admission failed with `controller_settling`
before the original fix. The new chat-specific regression then
reproduced duplicate promotion when the first reply finished.
- Green: 406 tests across native continuation, workspace-export
recovery, and the wake-queue module passed on `cbc531cc0`.
- The chat regressions exercise real Postgres transactions, simultaneous
recovery callbacks, successful completion, the production queue-drain
use case, and repeated drain attempts. A distinct follow-up remains a
separate turn.
- `pnpm -r typecheck` and `pnpm build` passed on `cbc531cc0`.
- The earlier full local test run encountered a timeout and follow-on
failure in unchanged AI connection-adoption tests; all 50 tests passed
on isolated rerun. That local run was stopped after the full CI test
matrix passed on the earlier head.
- All 54 CI checks passed on `cbc531cc0` (2 skipped), including the full
test matrix and browser shards. One unchanged interaction-route test
returned HTTP 500 on its first CI attempt; its full 84-test file passed
locally, and the failed shard passed on one targeted rerun.
- Greptile reviewed `cbc531cc0`: 5/5, no unresolved findings.
- Live staging first verified that the original saved message resumes
and receives a successful response; that test exposed the duplicate now
covered above.
- Deployed exact commit `cbc531cc0410e1ef6e8811c6c5c014c3528351ed` to
the affected staging workspace; deployment verification, health,
authentication, and startup recovery passed.
- Submitted a fresh message through the browser. The agent replied in 39
seconds; server records show exactly one successful run, native phase
`committed`, no error, and an empty queue. A later check more than a
minute after completion found no duplicate run.
## Risks
The change affects admission after native execution failure and
consumption of a resumed deferred receipt. A fresh turn must never
overlap the prior execution, and consuming one chat receipt must not
absorb later messages. Tests retain the controller, process, and
remote-stop guards. This change does not migrate data, apply an old
result, or reset the old retry budget.
## Model Used
OpenAI Codex (GPT-6). The exact runtime model identifier and context
window are not exposed in this session. Used reasoning, repository
inspection, code execution, database-backed tests, and browser
inspection.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
doc/execution-semantics.md | 2 +
.../explicit-native-continuation.test.ts | 186 ++++++++++++++++++
.../services/explicit-native-continuation.ts | 6 +-
server/src/services/heartbeat.ts | 8 +-
4 files changed, 200 insertions(+), 2 deletions(-)
diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md
index fb1c1cae67..df10f22d84 100644
--- a/doc/execution-semantics.md
+++ b/doc/execution-semantics.md
@@ -482,6 +482,8 @@ The handshake failure code is distinct from a session-identity mismatch. A timeo
An explicit recovery action is a typed liveness repair path for a source issue. It is the recovery primitive; the action can be rendered directly on the source issue or backed by a separate recovery issue when the repair needs its own work item.
+A terminal native failure can retain a result accepted before checkpoint or cleanup failed. That result is historical evidence, not a live controller. New user input may start a fresh turn after the controller and execution environment have stopped and ordinary admission checks pass. Preserve the failed run, its result, and its recovery budget. Do not commit the old result, infer action outcomes, or replay the failed turn. A message saved while cleanup is pending must be reconsidered after verified cleanup and delivered once. Admission must consume its deferred receipt in the same transaction that creates the new run, including in agent chat, where later messages keep their separate turns. Completing that run must not promote the consumed receipt again. Workspace-export repair retains its separate saved-result recovery path.
+
A new user message can continue a terminal native run whose process fields were cleared before local stop receipts existed. Admission must verify the exact run, runner, workspace, and provider session in the retained suspended state, with no active provider turn, pending tool call, or undelivered output. Missing or mismatched state keeps the hold. A later recorded process launch also keeps the hold until its stop is verified. Normal assignment, decision, controller, environment cleanup, and active-run gates still apply. The message starts one fresh conversation turn; it does not replay the failed run, reset its recovery budget, or certify unknown action outcomes.
The task thread exposes the existing guarded Retry action for failed or timed-out legacy conversation runs. Where the server supports an explicit new attempt after a stopped legacy conversation, the thread must not hide that action solely because the old run still has a recovery-needed projection. Native and process recovery holds, pending decisions, active execution, and other retry gates remain in force. When a gate hides Retry, the thread says the message is preserved instead of promising an unavailable action. This presentation change does not rewrite historical outcomes or certify prior actions.
diff --git a/server/src/services/explicit-native-continuation.test.ts b/server/src/services/explicit-native-continuation.test.ts
index 3c8c869137..d7aae395d6 100644
--- a/server/src/services/explicit-native-continuation.test.ts
+++ b/server/src/services/explicit-native-continuation.test.ts
@@ -1,3 +1,6 @@
+import { instanceSettingsService } from "./instance-settings.js";
+import { createPostgresWakeQueueAdapter } from "../modules/wake-queue/adapters/postgres.js";
+import { createReleaseIssueExecution } from "../modules/wake-queue/application/use-cases.js";
import * as nativeExecutor from "./native-runtime/native-session-executor.js";
import { appendHeartbeatRunEvent } from "./heartbeat-run-events.js";
import { recordNativeLocalProcessStop, hasNativeLocalProcessStop, PROCESS_START_REQUESTED } from "./native-local-process-stop.js";
@@ -19,6 +22,7 @@ import { buildExecutionContinuation } from "./execution-continuation.js";
import { heartbeatService, persistHeartbeatRunProcessMetadata, type HeartbeatEnvironmentRuntime } from "./heartbeat.js";
import { getExecutionBlocker } from "./execution-blocker.js";
import { createDurableChatWakeupRequest } from "./durable-chat-wakeup.js";
+import { recordNativeFinalizationFailure } from "./native-runtime/native-run-finalizer.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("explicit native conversation continuation", () => {
let database: Awaited>;
@@ -1029,6 +1033,188 @@ const support = await getEmbeddedPostgresTestSupport();
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).not.toBeNull();
}
});
+ async function seedResultBeforeTerminalFailure(f: Fixture) {
+ const contractId = randomUUID(), resultId = randomUUID();
+ await db.insert(completionContracts).values({ id: contractId, companyId: f.companyId, issueId: f.issueId,
+ revision: 1, schemaVersion: "paperclip.completion-contract.v1", policyVersion: "test",
+ risk: "standard", completionAuthority: "server_arbiter", incompleteCriteriaPolicy: "preserve_non_terminal",
+ contractJson: {}, canonicalSha256: contractId, createdByActorType: "system", createdByActorId: "test" });
+ await db.update(heartbeatRuns).set({ completionContractId: contractId, errorCode: "provider_transport_failed" })
+ .where(eq(heartbeatRuns.id, f.sourceRunId));
+ // Dickens returned a result before checkpoint/suspension failed. Accepting
+ // that result did not commit its disposition or make the failed run live.
+ await db.insert(nativeRunResults).values({ id: resultId, companyId: f.companyId, issueId: f.issueId,
+ runId: f.sourceRunId, completionContractId: contractId, serverFingerprint: resultId,
+ schemaStatus: "accepted", canonicalSha256: resultId,
+ resultJson: { terminal: { runTerminalState: "succeeded", reportedWorkDisposition: "blocked" } } });
+ await db.update(nativeRunFinalizations).set({ resultId, failureCode: "native_session_retry_exhausted" })
+ .where(eq(nativeRunFinalizations.runId, f.sourceRunId));
+ await db.update(issueRecoveryActions).set({ status: "active", cause: "native_session_retry_exhausted",
+ evidence: { runId: f.sourceRunId, coordinatorAttempt: 3, sourceFailureCode: "provider_transport_failed" },
+ }).where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
+ return resultId;
+ }
+ it.each(["stopped", "controller", "successor", "still_committing", "live_process", "identity_missing"])(
+ "continues after terminal failure with a retained result only when execution stopped (%s)", async scenario => {
+ const f = await seed(), resultId = await seedResultBeforeTerminalFailure(f);
+ await db.update(nativeRunFinalizations).set({
+ phase: scenario === "still_committing" ? "result_accepted" : "terminal_failure",
+ leaseOwner: scenario === "controller" ? "active-controller" : null,
+ failureDetail: scenario === "successor" ? { successorRunId: randomUUID() } : null,
+ }).where(eq(nativeRunFinalizations.runId, f.sourceRunId));
+ if (scenario === "live_process" || scenario === "identity_missing") {
+ await db.update(heartbeatRuns).set({ processPid: scenario === "live_process" ? process.pid : null })
+ .where(eq(heartbeatRuns.id, f.sourceRunId));
+ }
+ const result = await admit(f);
+ expect(result).toEqual(scenario === "stopped" ? { previousRunId: f.sourceRunId, commentId: f.commentId } : null);
+ expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toEqual(scenario === "stopped" ? null : expect.anything());
+ const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.sourceRunId));
+ expect(coordinator).toMatchObject({ resultId, attempt: 3,
+ phase: scenario === "still_committing" ? "result_accepted" : "terminal_failure" });
+ const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId));
+ expect(source).toMatchObject({ status: "failed", errorCode: "provider_transport_failed" });
+ const [savedResult] = await db.select().from(nativeRunResults).where(eq(nativeRunResults.id, resultId));
+ expect(savedResult.resultJson).toEqual({ terminal: { runTerminalState: "succeeded", reportedWorkDisposition: "blocked" } });
+ });
+ it.each(["task", "chat", "chat_with_next_message"])("delivers a queued %s message exactly once after a retained-result failure and delayed remote stop", async kind => {
+ const f = await seed(), resultId = await seedResultBeforeTerminalFailure(f);
+ const settings = instanceSettingsService(db);
+ const experimental = await settings.getExperimental();
+ await settings.updateExperimental({ enableAgentChat: true });
+ try {
+ if (kind !== "task") await db.update(issues).set({ conversationAgentId: f.agentId,
+ conversationUserId: "board", conversationState: "active",
+ }).where(eq(issues.id, f.issueId));
+ // Occupy the agent slot so admission is real without launching a provider.
+ await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, status: "running" });
+ const lease = { id: randomUUID(), companyId: f.companyId, heartbeatRunId: f.sourceRunId,
+ provider: "daytona", providerLeaseId: "dickens-stopped-sandbox" };
+ await db.insert(environmentLeases).values({ ...lease, status: "active", leasePolicy: "ephemeral" });
+ const heartbeat = heartbeatService(db);
+ const wake = await heartbeat.wakeup(f.agentId, {
+ source: "on_demand", triggerDetail: "manual", reason: "issue_commented",
+ requestedByActorType: "user", requestedByActorId: "board",
+ payload: { issueId: f.issueId, commentId: f.commentId },
+ contextSnapshot: { source: "issue.comment", issueId: f.issueId, wakeCommentId: f.commentId },
+ });
+ expect(wake).toBeNull();
+ const [waiting] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId));
+ expect(waiting).toMatchObject({ status: "deferred_issue_execution", runId: null,
+ payload: { executionWait: { reason: "remote_cleanup" } } });
+ let nextCommentId: string | null = null;
+ let nextWakeId: string | null = null;
+ if (kind === "chat_with_next_message") {
+ nextCommentId = randomUUID();
+ nextWakeId = randomUUID();
+ await db.insert(issueComments).values({ id: nextCommentId, companyId: f.companyId, issueId: f.issueId,
+ authorType: "user", authorUserId: "board", body: "A separate follow-up." });
+ await db.insert(agentWakeupRequests).values({ id: nextWakeId, companyId: f.companyId, agentId: f.agentId,
+ source: "on_demand", triggerDetail: "manual", reason: "issue_commented", status: "deferred_issue_execution",
+ requestedByActorType: "user", requestedByActorId: "board", payload: {
+ issueId: f.issueId, commentId: nextCommentId,
+ _paperclipWakeContext: { source: "issue.comment", issueId: f.issueId, wakeCommentId: nextCommentId, wakeReason: "issue_commented" },
+ },
+ });
+ }
+ const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId));
+ await heartbeat.resumeRemoteStopComments(source);
+ expect(await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, f.companyId), eq(heartbeatRuns.status, "queued")))).toHaveLength(0);
+ await db.update(environmentLeases).set({ status: "released", releasedAt: new Date(), cleanupStatus: "success",
+ metadata: { remoteExecutionTermination: remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: "stopped" }) },
+ }).where(eq(environmentLeases.id, lease.id));
+ // Cleanup and periodic recovery can race to dispatch the same receipt.
+ await Promise.all([heartbeat.resumeRemoteStopComments(source), heartbeat.resumeRemoteStopComments(source)]);
+ const successors = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, f.companyId), eq(heartbeatRuns.status, "queued")));
+ expect(successors).toHaveLength(1);
+ expect(successors[0].contextSnapshot).toMatchObject({ forceFreshSession: true, previousRunId: f.sourceRunId, wakeCommentId: f.commentId });
+ if (nextWakeId) {
+ expect(successors[0].contextSnapshot?.wakeCommentIds).not.toContain(nextCommentId);
+ const [nextWake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, nextWakeId));
+ expect(nextWake.status).toBe("deferred_issue_execution");
+ }
+ // Complete the admitted turn and run the same transactional queue drain
+ // used by finalization. A stale receipt must not create another turn.
+ await db.update(heartbeatRuns).set({ status: "succeeded", startedAt: new Date(), finishedAt: new Date() })
+ .where(eq(heartbeatRuns.id, successors[0].id));
+ await db.update(issues).set({ executionRunId: successors[0].id, status: "in_progress" })
+ .where(eq(issues.id, f.issueId));
+ const release = createReleaseIssueExecution({
+ issueLock: createPostgresWakeQueueAdapter(db, {
+ resolveResponsibleUserId: async () => "board",
+ getRoutineEnv: async () => ({ routineId: null, env: null, responsibleUserId: null }),
+ resolveSessionBeforeForWakeup: async () => null,
+ }),
+ recovery: { escalateStrandedAssignedIssue: async () => {}, escalateStrandedRecoveryIssueInPlace: async () => {} },
+ });
+ const drained = await release({ companyId: f.companyId, runId: successors[0].id, now: new Date(), suppressImmediateRecovery: true });
+ expect(drained.outcome.kind).toBe(nextWakeId ? "promoted" : "released");
+ await heartbeat.resumeRemoteStopComments(source);
+ await release({ companyId: f.companyId, runId: successors[0].id, now: new Date(), suppressImmediateRecovery: true });
+ const afterDrain = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, f.companyId), eq(heartbeatRuns.status, "queued")));
+ expect(afterDrain).toHaveLength(nextWakeId ? 1 : 0);
+ if (nextWakeId) {
+ expect(afterDrain[0]).toMatchObject({ wakeupRequestId: nextWakeId, contextSnapshot: { wakeCommentId: nextCommentId } });
+ expect(afterDrain[0].contextSnapshot?.wakeCommentIds ?? []).not.toContain(f.commentId);
+ }
+ const [delivered] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, waiting.id));
+ expect(delivered).toMatchObject({ status: "coalesced", runId: successors[0].id });
+ const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.sourceRunId));
+ expect(coordinator).toMatchObject({ phase: "terminal_failure", resultId, attempt: 3 });
+ expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull();
+ } finally {
+ await settings.updateExperimental({ enableAgentChat: experimental.enableAgentChat });
+ }
+ });
+ it("keeps exhausted workspace export on its own repair path after the sandbox stops", async () => {
+ const f = await seed(), resultId = await seedResultBeforeTerminalFailure(f);
+ await db.delete(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId));
+ await db.update(nativeRunFinalizations).set({ phase: "result_accepted", attempt: 1, failureCode: null, failureDetail: null })
+ .where(eq(nativeRunFinalizations.runId, f.sourceRunId));
+ await db.update(heartbeatRuns).set({ status: "running", nativePhase: "result_accepted", finishedAt: null })
+ .where(eq(heartbeatRuns.id, f.sourceRunId));
+ const lease = { id: randomUUID(), companyId: f.companyId, heartbeatRunId: f.sourceRunId,
+ provider: "daytona", providerLeaseId: "pending-workspace-export" };
+ await db.insert(environmentLeases).values({ ...lease, status: "active", leasePolicy: "ephemeral" });
+ const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId));
+ await db.update(heartbeatRuns).set({ runnerProfileJson: { ...run.runnerProfileJson,
+ nativeWorkspaceSync: { schema: "paperclip.native-workspace-sync/v1", state: "prepared",
+ descriptorSha256: "a".repeat(64), baselineSha256: "b".repeat(64), finalHostSha256: null,
+ workspaceId: randomUUID(), leaseId: lease.id, providerLeaseId: lease.providerLeaseId,
+ remoteCwd: "/work", resourceDisposition: "destroy" },
+ } }).where(eq(heartbeatRuns.id, f.sourceRunId));
+ for (let attempt = 0; attempt < 3; attempt++) {
+ await recordNativeFinalizationFailure({ db, runId: f.sourceRunId,
+ error: new Error("native_workspace_sync_out_failed"), failureScope: "workspace", projectRunStatus: true });
+ }
+ const [retainedLease] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, lease.id));
+ expect(retainedLease).toMatchObject({ status: "pending_cleanup", metadata: {
+ nativeWorkspaceExportResume: { runId: f.sourceRunId, leaseId: lease.id, resultId },
+ } });
+ await db.update(environmentLeases).set({ status: "released", releasedAt: new Date(), cleanupStatus: "success",
+ metadata: { ...retainedLease.metadata,
+ remoteExecutionTermination: remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: "stopped" }) },
+ }).where(eq(environmentLeases.id, lease.id));
+ const exportState = () => Promise.all([
+ db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId)),
+ db.select().from(nativeRunResults).where(eq(nativeRunResults.id, resultId)),
+ db.select().from(environmentLeases).where(eq(environmentLeases.id, lease.id)),
+ db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, f.issueId)),
+ ]);
+ const before = await exportState();
+ // Export repair is intentionally excluded before conversation stop checks.
+ // A new message must leave the accepted result, retention, and repair intact.
+ await db.update(issueComments).set({ createdAt: new Date(Date.now() + 1000) }).where(eq(issueComments.id, f.commentId));
+ expect(await admit(f)).toBeNull();
+ expect(await exportState()).toEqual(before);
+ const [coordinator] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.sourceRunId));
+ expect(coordinator).toMatchObject({ phase: "terminal_failure", resultId, attempt: 1,
+ failureCode: "native_workspace_sync_out_retry_exhausted", failureDetail: { workspaceFinalizeAttempt: 3 } });
+ const [action] = await db.select().from(issueRecoveryActions).where(and(
+ eq(issueRecoveryActions.sourceIssueId, f.issueId), eq(issueRecoveryActions.cause, "native_workspace_sync_out_retry_exhausted"),
+ ));
+ expect(action).toMatchObject({ status: "active", cause: "native_workspace_sync_out_retry_exhausted" });
+ });
it.each(["linked", "revoked", "unlinked", "wrong_author"])(
"admits a fresh Slack message after failure only with current linked-user authority (%s)", async scenario => {
const f = await seed();
diff --git a/server/src/services/explicit-native-continuation.ts b/server/src/services/explicit-native-continuation.ts
index 5c6c0349d1..2eafaf4d3e 100644
--- a/server/src/services/explicit-native-continuation.ts
+++ b/server/src/services/explicit-native-continuation.ts
@@ -212,9 +212,13 @@ export async function admitExplicitNativeContinuation(input: {
sql`${nativeRunResults.resultJson}->'terminal'->>'runTerminalState' = 'failed'`,
)).limit(1)
: [];
+ // A terminal failure may retain a result accepted before checkpoint or
+ // cleanup failed. That immutable result is history, not an active commit.
+ // Keep it intact and require the same controller/process/lease stop proofs
+ // before admitting new user input; never apply or replay the old result.
if (!cancelledStartup && coordinator && (
(coordinator.phase !== "terminal_failure" && !committedFailure) || coordinator.leaseOwner ||
- (coordinator.resultId && !committedFailure) || coordinator.failureDetail?.successorRunId)) return blocked("controller_settling",
+ coordinator.failureDetail?.successorRunId)) return blocked("controller_settling",
run.status === "cancelled" && !coordinator.leaseOwner
? "The cancelled run still needs verified cleanup. Your message is saved. Inspect the run and its environment for details."
: "Waiting for the previous run to finish recovery. Your message will start automatically.");
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index 57f0c5fa5b..14b8ed35ea 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -28484,8 +28484,12 @@ export function heartbeatService(
or(isNull(heartbeatRuns.nativeIssueId), eq(heartbeatRuns.nativeIssueId, issue.id)),
)).then(rows => rows[0] ?? null)
: null;
+ const canCoalesceComments = !isConversation(issue) && opts.allowRunCoalescing !== false;
+ // A resumed receipt already passed admission under this issue lock.
+ // Consume it with its successor even when chat keeps other messages
+ // in separate turns, or finalization will deliver it a second time.
const pendingComments =
- !isConversation(issue) && opts.allowRunCoalescing !== false &&
+ (executionWaitRequestId || canCoalesceComments) &&
!(await getExecutionBlocker(tx as unknown as Db, issue.companyId, issue.id))
? await tx
.select()
@@ -28496,11 +28500,13 @@ export function heartbeatService(
inArray(agentWakeupRequests.agentId, handoffSource ? [agentId, handoffSource.agentId] : [agentId]),
eq(agentWakeupRequests.status, "deferred_issue_execution"),
sql`${agentWakeupRequests.payload}->>'issueId' = ${issue.id}`,
+ canCoalesceComments ? undefined : eq(agentWakeupRequests.id, executionWaitRequestId!),
),
)
.orderBy(asc(agentWakeupRequests.requestedAt))
: [];
const adoptedComments = pendingComments.filter((wake) => {
+ if (wake.id === executionWaitRequestId) return true;
if (wake.id === opts.queuedCommentInterruptId || wake.id === opts.queuedCommentRequestId) return true;
const deferredPayload = parseObject(wake.payload);
const deferredContext = parseObject(