Recovery runs in the background. Task lists keep their ordinary status without
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx
index 30294009db..43705c1593 100644
--- a/ui/src/pages/IssueDetail.test.tsx
+++ b/ui/src/pages/IssueDetail.test.tsx
@@ -1677,8 +1677,10 @@ describe("IssueDetail", () => {
if (reassign) {
expect(mockIssuesApi.update).toHaveBeenCalledWith(issue.identifier, {
comment: "Inspect the new file",
+ commentClientRequestId: undefined,
assigneeAgentId: "agent-2",
assigneeUserId: null,
+ assigneeAdapterOverrides: null,
attachmentIds: [id],
});
expect(mockIssuesApi.addComment).not.toHaveBeenCalled();
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index 766100aac2..691901b44b 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -2,6 +2,7 @@ import { WorkspaceExportRecovery } from "../components/WorkspaceExportRecovery";
import { useUserPreferences } from "../hooks/useUserPreferences";
import { DispositionRecoveryProvider } from "../components/DispositionRecoveryNotice";
import { AgentAvatar } from "@/components/AgentAvatar";
+import { mergeComposerRunSettings, type ComposerRunSettings } from "@/components/task-chat/composer-run-settings";
import { AgentIdentity } from "@/components/AgentIdentity";
import { clearLegacyChatMessageRequests } from "@/lib/chat-message-request";
import { agentChatDraft } from "@/lib/agent-chat-draft";
@@ -1256,6 +1257,7 @@ type IssueDetailChatTabProps = {
draftKey: string;
reassignOptions: Array<{ id: string; label: string; searchText?: string }>;
currentAssigneeValue: string;
+ assigneeAdapterOverrides?: Issue["assigneeAdapterOverrides"];
suggestedAssigneeValue: string;
mentions: MentionOption[];
conversationMode?: boolean;
@@ -1274,6 +1276,7 @@ type IssueDetailChatTabProps = {
reassignment?: CommentReassignment,
attachmentIds?: string[],
clientRequestId?: string,
+ runSettings?: ComposerRunSettings,
) => Promise;
onReviewConversation: () => Promise;
onImageUpload: (file: File) => Promise;
@@ -1383,6 +1386,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
draftKey,
reassignOptions,
currentAssigneeValue,
+ assigneeAdapterOverrides,
suggestedAssigneeValue,
mentions,
conversationMode,
@@ -2409,6 +2413,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
enableReassign={!conversationMode}
reassignOptions={reassignOptions}
currentAssigneeValue={currentAssigneeValue}
+ assigneeAdapterOverrides={assigneeAdapterOverrides}
suggestedAssigneeValue={suggestedAssigneeValue}
mentions={mentions}
composerPause={composerPause}
@@ -4867,20 +4872,34 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
reassignment,
attachmentIds,
clientRequestId,
+ runSettings,
}: {
body: string;
reopen?: boolean;
interrupt?: boolean;
- reassignment: CommentReassignment;
+ reassignment?: CommentReassignment;
attachmentIds?: string[];
clientRequestId?: string;
+ runSettings?: ComposerRunSettings;
}) =>
issuesApi.update(issueId!, {
comment: body,
commentClientRequestId: clientRequestId,
...(attachmentIds?.length ? { attachmentIds } : {}),
- assigneeAgentId: reassignment.assigneeAgentId,
- assigneeUserId: reassignment.assigneeUserId,
+ ...(reassignment ? {
+ assigneeAgentId: reassignment.assigneeAgentId,
+ assigneeUserId: reassignment.assigneeUserId,
+ } : {}),
+ ...(runSettings || reassignment ? {
+ assigneeAdapterOverrides: runSettings
+ ? mergeComposerRunSettings(
+ issue?.assigneeAdapterOverrides,
+ agentMap.get(reassignment?.assigneeAgentId ?? issue?.assigneeAgentId ?? "")?.adapterType,
+ runSettings,
+ Boolean(reassignment),
+ )
+ : null,
+ } : {}),
...(reopen ? { status: "todo" } : {}),
...(interrupt ? { interrupt } : {}),
}),
@@ -6226,14 +6245,16 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
reassignment?: CommentReassignment,
attachmentIds?: string[],
clientRequestId?: string,
+ runSettings?: ComposerRunSettings,
) => {
- if (reassignment) {
+ if (reassignment || runSettings) {
await addCommentAndReassign.mutateAsync({
body,
reopen,
reassignment,
attachmentIds,
clientRequestId,
+ runSettings,
});
return;
}
@@ -7786,6 +7807,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
projectId={issue.projectId ?? null}
issueStatus={issue.status}
issueAssigneeAgentId={issue.assigneeAgentId}
+ assigneeAdapterOverrides={issue.assigneeAdapterOverrides}
issueWorkMode={issue.workMode ?? "standard"}
executionRunId={issue.executionRunId ?? null}
blockedBy={issue.blockedBy ?? []}
diff --git a/ui/storybook/.storybook/main.ts b/ui/storybook/.storybook/main.ts
index 943b2c7198..6d14ad8a04 100644
--- a/ui/storybook/.storybook/main.ts
+++ b/ui/storybook/.storybook/main.ts
@@ -26,7 +26,7 @@ const config: StorybookConfig = {
docs: {
autodocs: true,
},
- viteFinal: async (baseConfig, { configType }) =>
+ viteFinal: async (baseConfig) =>
mergeConfig(baseConfig, {
define: {
"import.meta.env.VITE_PAPERCLIP_INSTANCE_URL": JSON.stringify(paperclipInstanceOrigin),
@@ -42,9 +42,7 @@ const config: StorybookConfig = {
// The app's own dev server hoists one React and never hit this.
dedupe: ["react", "react-dom"],
alias: {
- ...(configType === "PRODUCTION" ? {
- "@/lib/agent-avatar-url": path.resolve(storybookConfigDir, "../fixtures/agent-avatar-url.ts"),
- } : {}),
+ "@/lib/agent-avatar-url": path.resolve(storybookConfigDir, "../fixtures/agent-avatar-url.ts"),
"@": path.resolve(storybookConfigDir, "../../src"),
lexical: path.resolve(storybookConfigDir, "../../node_modules/lexical/dist/Lexical.mjs"),
// Vite's bundled `node:crypto` polyfill omits `createHash`, which
diff --git a/ui/storybook/prototypes/composer-model-picker/ComposerModelPickerPreview.tsx b/ui/storybook/prototypes/composer-model-picker/ComposerModelPickerPreview.tsx
new file mode 100644
index 0000000000..aac6e2955e
--- /dev/null
+++ b/ui/storybook/prototypes/composer-model-picker/ComposerModelPickerPreview.tsx
@@ -0,0 +1,293 @@
+import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
+import { ArrowLeft, ArrowUp, Check, ChevronDown, Plus, RotateCcw, Search, X, Zap } from "lucide-react";
+import { AgentAvatar } from "@/components/AgentAvatar";
+import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { ComposerAddMenu, ComposerModeChip } from "@/components/task-chat/ComposerAddMenu";
+import { nextWorkMode } from "@/lib/work-mode-meta";
+import type { IssueWorkMode } from "@paperclipai/shared";
+import { cn } from "@/lib/utils";
+import { composerAgentAppearance, composerAgents, effortChoices, effortLabels, fastModeAvailable, modelLabel, type ComposerAgent } from "./fixtures";
+import "./picker.css";
+
+export type ComposerModelPickerPreviewProps = {
+ agentId?: string;
+ initialModel?: string;
+ initialEffort?: string;
+ initialFast?: boolean;
+ initialPanel?: "closed" | "settings" | "models" | "agents";
+ initialSearch?: string;
+ initialAssigneeSearch?: string;
+ initialMode?: IssueWorkMode;
+ compact?: boolean;
+};
+
+type SentMessage = { text: string; agent: string; model: string | null; effort: string | null; fast: boolean };
+
+function AgentMark({ agent, size = 24 }: { agent: ComposerAgent; size?: 16 | 24 }) {
+ return ;
+}
+
+function ModelRow({ option, selected, onSelect }: {
+ option: { id: string; label: string; detail?: string };
+ selected: boolean;
+ onSelect: (id: string) => void;
+}) {
+ return (
+ onSelect(option.id)}
+ className="flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-left text-sm transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none">
+
+ {option.label}
+ {option.id}
+
+ {option.detail ? {option.detail} : null}
+ {selected ? : null}
+
+ );
+}
+
+function AnimatedPickerBody({ children }: { children: ReactNode }) {
+ const contentRef = useRef(null);
+ const [height, setHeight] = useState(null);
+
+ useLayoutEffect(() => {
+ const content = contentRef.current;
+ if (!content) return;
+ const measure = () => setHeight(content.getBoundingClientRect().height);
+ measure();
+ const observer = new ResizeObserver(measure);
+ observer.observe(content);
+ return () => observer.disconnect();
+ }, []);
+
+ return ;
+}
+
+export function ComposerModelPickerPreview({
+ agentId = "codex", initialModel, initialEffort, initialFast = false,
+ initialPanel = "closed", initialSearch = "", initialAssigneeSearch = "", initialMode = "standard", compact = false,
+}: ComposerModelPickerPreviewProps) {
+ const [agent, setAgent] = useState(composerAgents.find((item) => item.id === agentId) ?? composerAgents[0]);
+ const [modelOverride, setModelOverride] = useState(initialModel ?? null);
+ const [effortOverride, setEffortOverride] = useState(initialEffort ?? null);
+ const [fast, setFast] = useState(initialFast);
+ const [pickerOpen, setPickerOpen] = useState(initialPanel !== "closed");
+ const [view, setView] = useState<"settings" | "models" | "agents">(initialPanel === "closed" ? "settings" : initialPanel);
+ const [search, setSearch] = useState(initialSearch);
+ const [assigneeSearch, setAssigneeSearch] = useState(initialAssigneeSearch);
+ const [highlightedAssigneeIndex, setHighlightedAssigneeIndex] = useState(0);
+ const [draft, setDraft] = useState("");
+ const [mode, setMode] = useState(initialMode);
+ const [attachments, setAttachments] = useState([]);
+ const fileInputRef = useRef(null);
+ const [messages, setMessages] = useState([]);
+ const [mobile, setMobile] = useState(() => typeof window !== "undefined" && window.matchMedia("(max-width: 639px)").matches);
+
+ useEffect(() => {
+ const query = window.matchMedia("(max-width: 639px)");
+ const update = () => setMobile(query.matches);
+ update();
+ query.addEventListener("change", update);
+ return () => query.removeEventListener("change", update);
+ }, []);
+
+ const model = modelOverride ?? agent.defaultModel ?? "";
+ const choices = effortChoices(agent, model);
+ const effectiveEffort = effortOverride && choices.includes(effortOverride) ? effortOverride : null;
+ const effortIndex = effectiveEffort ? choices.indexOf(effectiveEffort) + 1 : 0;
+ const effortLabel = effectiveEffort ? effortLabels[effectiveEffort] ?? effectiveEffort : "Default";
+ const fastAvailable = fastModeAvailable(agent, model);
+ const modelAvailable = Boolean(agent.defaultModel || agent.models.length || agent.manualPattern);
+ const query = search.trim();
+ const filtered = agent.models.filter((option) =>
+ `${option.label} ${option.id} ${option.detail ?? ""}`.toLowerCase().includes(query.toLowerCase()),
+ );
+ const filteredAssignees = composerAgents.filter((item) =>
+ `${item.name} ${item.role} ${item.harness} ${item.provider ?? ""}`.toLowerCase().includes(assigneeSearch.trim().toLowerCase()),
+ );
+ const exactCatalogMatch = agent.models.some((option) => option.id.toLowerCase() === query.toLowerCase());
+ const manualValid = query.length > 0 && !/\s/.test(query)
+ && (agent.provider !== "OpenRouter" || query.startsWith("openrouter/"));
+
+ function reset() {
+ setModelOverride(null);
+ setEffortOverride(null);
+ setFast(false);
+ }
+
+ function chooseModel(next: string | null) {
+ setModelOverride(next);
+ setEffortOverride(null);
+ setFast(false);
+ setSearch("");
+ setView("settings");
+ }
+
+ function chooseAgent(next: ComposerAgent) {
+ setAgent(next);
+ reset();
+ setSearch("");
+ setAssigneeSearch("");
+ setHighlightedAssigneeIndex(0);
+ setView("settings");
+ }
+
+ function send() {
+ if (!draft.trim()) return;
+ setMessages((current) => [...current, {
+ text: draft.trim(), agent: agent.name, model: model || null,
+ effort: effectiveEffort, fast: fast && fastAvailable,
+ }]);
+ setDraft("");
+ }
+
+ function handlePickerOpenChange(open: boolean) {
+ setPickerOpen(open);
+ if (!open) {
+ setView("settings");
+ setSearch("");
+ setAssigneeSearch("");
+ setHighlightedAssigneeIndex(0);
+ }
+ }
+
+ const pickerTrigger = (
+
+
+ {agent.name}
+ ·
+ {modelAvailable ? modelLabel(agent, model) : "Harness default"}
+ {effectiveEffort ? {effortLabel} : null}
+
+
+ );
+
+ const mobileCloseButton = mobile ? : null;
+
+ const pickerBody = (
+ view === "settings" ? (
+
+
+
{ setAssigneeSearch(""); setHighlightedAssigneeIndex(0); setView("agents"); }} aria-label="Choose assignee" className="flex min-w-0 flex-1 items-center gap-2 rounded-md px-1 py-1 text-left hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
+
+ {agent.name} {agent.harness}{agent.provider ? ` · ${agent.provider}` : ""}
+
+
+ {modelAvailable && !choices.length ?
: null}
+ {mobileCloseButton}
+
+ {modelAvailable ?
setView("models")} className="mt-3 flex w-full items-center gap-2 rounded-md px-1 py-1 text-left hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="Choose exact model">
+ Model {modelLabel(agent, model)}
+
+ :
{agent.noModelReason}
}
+ {modelAvailable && choices.length ? (
+
+
+ {fastAvailable ? setFast((current) => !current)} aria-label="Fast mode" aria-pressed={fast} title="Fast mode · faster responses, higher usage" className={cn("grid size-8 shrink-0 place-items-center rounded-md hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", fast ? "composer-picker-accent bg-accent" : "text-muted-foreground")}> : }
+ {effortLabel}
+
+
+
setEffortOverride(Number(event.target.value) === 0 ? null : choices[Number(event.target.value) - 1])}
+ className="composer-effort-range mt-3 w-full" style={{ "--fill": `${(effortIndex / choices.length) * 100}%` } as CSSProperties} />
+
+ ) : null}
+
+ ) : view === "agents" ? (
+
+
{ setAssigneeSearch(""); setHighlightedAssigneeIndex(0); setView("settings"); }} aria-label="Back to selection" className="grid size-7 place-items-center rounded-md hover:bg-accent"> Choose assignee
Each agent keeps its configured harness.
{mobileCloseButton}
+
{ setAssigneeSearch(event.target.value); setHighlightedAssigneeIndex(0); }} onKeyDown={(event) => {
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); setHighlightedAssigneeIndex((current) => filteredAssignees.length ? (current + (event.key === "ArrowDown" ? 1 : -1) + filteredAssignees.length) % filteredAssignees.length : 0); }
+ if (event.key === "Enter" && filteredAssignees.length) { event.preventDefault(); chooseAgent(filteredAssignees[Math.min(highlightedAssigneeIndex, filteredAssignees.length - 1)]); }
+ }} placeholder="Search assignees…" aria-label="Search assignees" aria-controls="composer-assignees" aria-activedescendant={filteredAssignees[highlightedAssigneeIndex] ? `composer-assignee-${filteredAssignees[highlightedAssigneeIndex].id}` : undefined} className="h-9 w-full rounded-md border border-border bg-background pl-8 pr-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" />
+
+ {filteredAssignees.map((item, index) =>
setHighlightedAssigneeIndex(index)} onClick={() => chooseAgent(item)} className={cn("flex w-full items-center gap-2 rounded-md px-2 py-2 text-left focus-visible:bg-accent focus-visible:outline-none", highlightedAssigneeIndex === index ? "bg-accent" : "hover:bg-accent")}>
+
+ {item.name} {item.role}
+ {item.harness}
+ {agent.id === item.id ? : null}
+ )}
+ {!filteredAssignees.length ?
No matches.
: null}
+
+
+ ) : (
+
+
{ setView("settings"); setSearch(""); }} aria-label="Back to selection" className="grid size-7 place-items-center rounded-md hover:bg-accent"> Choose model
{agent.harness}{agent.provider ? ` · ${agent.provider}` : ""}
{mobileCloseButton}
+
setSearch(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter" && manualValid && !exactCatalogMatch) chooseModel(query); }} placeholder="Search or paste a model ID" aria-label="Search or paste a model ID" className="h-9 w-full rounded-md border border-border bg-background pl-8 pr-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" />
+
+ {!query ?
chooseModel(null)} className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left hover:bg-accent focus-visible:bg-accent focus-visible:outline-none">Use agent default {modelLabel(agent, agent.defaultModel ?? "")} {modelOverride === null ? : null} : null}
+ {filtered.map((option) =>
chooseModel(id)} />)}
+ {!filtered.length && query ? No catalog match.
: null}
+
+ {query && !exactCatalogMatch ?
chooseModel(query)} className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-sm hover:bg-accent disabled:cursor-not-allowed disabled:opacity-40">Use exact ID {query} {!manualValid ?
{agent.provider === "OpenRouter" ? "Use openrouter/provider/model with no spaces." : "Model IDs cannot contain spaces."}
: null}
: null}
+
{agent.manualPattern ? `Custom IDs: ${agent.manualPattern}. Provider access is checked when the run starts.` : "Only models for this harness are shown."}
+
+ )
+ );
+
+ return (
+
+
+
+
+
+ Agent conversation
+ Model and effort can be chosen for the next message
+
+
+
+
+
+
{agent.name} · {agent.role}
+
I can take the next step. Pick the model and effort you want me to use, then send your instructions.
+
+ {messages.map((message, index) => (
+
+
{message.text}
+
To {message.agent}{message.model ? ` · ${message.model}` : ""}{message.effort ? ` · ${effortLabels[message.effort] ?? message.effort}` : ""}{message.fast ? " · Fast" : ""}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/ui/storybook/prototypes/composer-model-picker/ComposerRunSettingsLiveStory.tsx b/ui/storybook/prototypes/composer-model-picker/ComposerRunSettingsLiveStory.tsx
new file mode 100644
index 0000000000..8f7c2a4421
--- /dev/null
+++ b/ui/storybook/prototypes/composer-model-picker/ComposerRunSettingsLiveStory.tsx
@@ -0,0 +1,124 @@
+import { useRef, useState } from "react";
+import { ArrowUp } from "lucide-react";
+import type { Agent, IssueWorkMode } from "@paperclipai/shared";
+import { AgentAvatar } from "@/components/AgentAvatar";
+import { ComposerRunSettingsPicker } from "@/components/task-chat/ComposerRunSettingsPicker";
+import { ComposerAddMenu, ComposerModeChip } from "@/components/task-chat/ComposerAddMenu";
+import { nextWorkMode } from "@/lib/work-mode-meta";
+import { DEFAULT_COMPOSER_RUN_SETTINGS, mergeComposerRunSettings, type ComposerRunSettings } from "@/components/task-chat/composer-run-settings";
+import { cn } from "@/lib/utils";
+import { composerAgentAppearance, composerAgents } from "./fixtures";
+
+const agents = new Map(composerAgents.map((fixture) => [fixture.id, {
+ id: fixture.id, companyId: "storybook", name: fixture.name, role: fixture.role,
+ appearance: composerAgentAppearance(fixture.id),
+ adapterType: fixture.adapterType,
+ adapterConfig: {
+ ...(fixture.defaultModel ? { model: fixture.defaultModel } : {}),
+ ...(fixture.provider === "OpenRouter" ? { provider: "openrouter" } : {}),
+ },
+ defaultEnvironmentId: null,
+} as Agent]));
+const options = composerAgents.map((item) => ({
+ id: `agent:${item.id}`, label: item.name,
+ searchText: `${item.name} ${item.role} ${item.harness} ${item.provider ?? ""}`,
+}));
+
+export interface LiveStoryProps {
+ agentId?: string;
+ initialModel?: string;
+ initialEffort?: string;
+ initialFast?: boolean;
+ initialPanel?: "closed" | "settings" | "models" | "agents";
+ initialSearch?: string;
+ initialAssigneeSearch?: string;
+ initialMode?: IssueWorkMode;
+ mobile?: boolean;
+ compact?: boolean;
+}
+
+export function ComposerRunSettingsLiveStory({
+ agentId = "codex", initialModel, initialEffort, initialFast = false,
+ initialPanel = "closed", initialSearch = "", initialAssigneeSearch = "",
+ initialMode = "standard",
+ mobile = false, compact = false,
+}: LiveStoryProps) {
+ const [assignee, setAssignee] = useState(`agent:${agentId}`);
+ const [settings, setSettings] = useState(
+ initialModel || initialEffort || initialFast
+ ? { model: initialModel ?? null, effort: initialEffort ?? null, fast: initialFast }
+ : null,
+ );
+ const [draft, setDraft] = useState("");
+ const [mode, setMode] = useState(initialMode);
+ const [attachments, setAttachments] = useState([]);
+ const [sent, setSent] = useState>([]);
+ const fileInputRef = useRef(null);
+ const selectedAgent = composerAgents.find((item) => `agent:${item.id}` === assignee) ?? null;
+ const agent = agents.get(selectedAgent?.id ?? "codex");
+ const overrides = selectedAgent
+ ? mergeComposerRunSettings(null, selectedAgent.adapterType, settings ?? DEFAULT_COMPOSER_RUN_SETTINGS)
+ : null;
+
+ function send() {
+ if (!draft.trim()) return;
+ setSent((current) => [...current, { body: draft.trim(), agent: selectedAgent?.name ?? "No assignee", overrides }]);
+ setDraft("");
+ }
+
+ return
+
+
+
+
+ Agent conversation
+ Model and effort can be chosen for the next message
+
+
+
+
+
+
{selectedAgent?.name} · {selectedAgent?.role}
+
I can take the next step. Pick the model and effort you want me to use, then send your instructions.
+
+ {sent.map((message, index) =>
+
{message.body}
+
To {message.agent} · {JSON.stringify(message.overrides ?? "agent default")}
+
)}
+
+
+
+
+
;
+}
diff --git a/ui/storybook/prototypes/composer-model-picker/README.md b/ui/storybook/prototypes/composer-model-picker/README.md
new file mode 100644
index 0000000000..11df65023e
--- /dev/null
+++ b/ui/storybook/prototypes/composer-model-picker/README.md
@@ -0,0 +1,23 @@
+# Composer model and effort picker
+
+The approved design is `ComposerModelPickerPreview.tsx`, and the production control is `ui/src/components/task-chat/ComposerRunSettingsPicker.tsx`. Both appear under **Tasks → Composer → Model and effort picker**: the numbered design cases cover harness and model states, while **App picker in composer** and **App picker on mobile** render the production control with the same layout. The task composer passes its selection through the issue update request with the comment, so the next run reads the saved task adapter overrides. Settings stay on the task until changed or reset.
+
+One composer control opens a picker with searchable assignees at the top, then the model and a slider for effort. Its capsule stays on the right beside Send at both desktop and mobile widths. Assignee search matches names, roles, harnesses, and providers, and supports keyboard selection. The selected assignee fixes the harness and provider profile; model search is limited to that profile. The picker supports an exact model ID for harnesses that accept one. The selected effort name sits above the slider between a conditional fast-mode icon on the left and a reset icon on the right. When effort capability is unknown, the entire effort section is omitted. The picker animates its height as content changes and uses a centered, scrollable modal on mobile. Sending a message adds an in-memory transcript bubble with the selected settings.
+
+The stories use fixture state and do not alter task execution. The current adapter model API returns only `{ id, label }`; it cannot tell the client which OpenCode/OpenRouter variants a particular model accepts. The production control therefore uses the model default for those models and for unknown custom IDs. Model capability metadata would allow more precise sliders later.
+
+## Harness coverage
+
+| Harness | Model selection | Effort | Fast mode |
+| --- | --- | --- | --- |
+| Codex | Curated/search/manual | Model-specific Codex levels | Known supported models only |
+| Claude Code | Curated/search/manual | Low, medium, high on known models | No |
+| OpenCode with OpenRouter | Search and `openrouter/provider/model` manual ID | Uses model default until variant metadata is available | No |
+| Pi | Search/manual | Off through extra high on known models | No |
+| Kimi Code, CLI engine | Search/manual | Low, high, max on advertised capable models | No |
+| Gemini, Cursor, Grok, Hermes CLI | Search/manual | Not offered | No |
+| Cursor Cloud | Manual ID, account default | Not offered | No |
+| Paperclip Runner with Codex profile | Codex catalog only | Not offered until the Runner advertises model capabilities | No |
+| Process, HTTP, OpenClaw Gateway, Hermes Gateway | No per-message model setting | Not offered | No |
+
+The fixture models reflect repository adapter contracts as of 2026-09-26; provider availability can still depend on the installed CLI, account, environment, or connection. Switching agents clears the draft run settings. The two remote gateway harnesses deliberately leave model choice with their upstream service.
diff --git a/ui/storybook/prototypes/composer-model-picker/fixtures.ts b/ui/storybook/prototypes/composer-model-picker/fixtures.ts
new file mode 100644
index 0000000000..0e7a01136c
--- /dev/null
+++ b/ui/storybook/prototypes/composer-model-picker/fixtures.ts
@@ -0,0 +1,101 @@
+import { codexLocalReasoningEffortsForModel, isCodexLocalFastModeSupported, isCodexLocalKnownModel } from "@paperclipai/adapter-codex-local";
+import { modelSupportsEffort, KIMI_SUPPORTED_EFFORTS } from "@paperclipai/adapter-kimi-local";
+import { AGENT_PALETTE_IDS, appearanceForPalette } from "@paperclipai/shared";
+
+export type ModelOption = { id: string; label: string; detail?: string };
+export type ComposerAgent = {
+ id: string;
+ name: string;
+ role: string;
+ harness: string;
+ adapterType: string;
+ provider?: string;
+ defaultModel?: string;
+ defaultLabel?: string;
+ models: ModelOption[];
+ manualPattern?: string;
+ noModelReason?: string;
+};
+
+const codexModels: ModelOption[] = [
+ { id: "gpt-5.6-sol", label: "GPT-5.6 Sol", detail: "Agent default" },
+ { id: "gpt-6-astra", label: "GPT-6 Astra", detail: "Extended effort range" },
+ { id: "gpt-5.6-terra", label: "GPT-5.6 Terra" },
+ { id: "gpt-5.6-luna", label: "GPT-5.6 Luna" },
+ { id: "gpt-5.5", label: "GPT-5.5" },
+ { id: "gpt-5.4-mini", label: "GPT-5.4 Mini", detail: "Fast mode unavailable" },
+];
+
+export const composerAgents: ComposerAgent[] = [
+ { id: "codex", name: "Codie", role: "Engineering", harness: "Codex", adapterType: "codex_local", defaultModel: "gpt-5.6-sol", models: codexModels, manualPattern: "Model ID, e.g. gpt-5.6-sol" },
+ { id: "claude", name: "Clara", role: "Research", harness: "Claude Code", adapterType: "claude_local", defaultModel: "claude-sonnet-5", models: [
+ { id: "claude-sonnet-5", label: "Claude Sonnet 5" },
+ { id: "claude-opus-5", label: "Claude Opus 5" },
+ { id: "claude-haiku-4-5", label: "Claude Haiku 4.5" },
+ ], manualPattern: "Claude model ID" },
+ { id: "openrouter", name: "Nora", role: "Product", harness: "OpenCode", adapterType: "opencode_local", provider: "OpenRouter", defaultModel: "openrouter/anthropic/claude-sonnet-4.6", models: [
+ { id: "openrouter/anthropic/claude-sonnet-4.6", label: "Claude Sonnet 4.6", detail: "OpenRouter" },
+ { id: "openrouter/google/gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview", detail: "OpenRouter" },
+ { id: "openrouter/deepseek/deepseek-v4-flash", label: "DeepSeek V4 Flash", detail: "OpenRouter" },
+ ], manualPattern: "openrouter/provider/model" },
+ { id: "pi", name: "Pia", role: "Tooling", harness: "Pi", adapterType: "pi_local", defaultModel: "openrouter/anthropic/claude-sonnet-4.6", models: [
+ { id: "openrouter/anthropic/claude-sonnet-4.6", label: "Claude Sonnet 4.6", detail: "OpenRouter" },
+ { id: "openai/gpt-5.6-sol", label: "GPT-5.6 Sol", detail: "OpenAI" },
+ ], manualPattern: "provider/model" },
+ { id: "kimi", name: "Kimi", role: "Planning", harness: "Kimi Code", adapterType: "kimi_local", provider: "CLI engine", defaultModel: "kimi-code/k3", models: [
+ { id: "kimi-code/k3", label: "K3", detail: "Supports effort on CLI" },
+ { id: "kimi-code/kimi-for-coding", label: "K2.8 Preview", detail: "Supports effort on CLI" },
+ { id: "kimi-code/kimi-for-coding-highspeed", label: "K2.7 Coding Highspeed", detail: "Uses model default" },
+ { id: "kimi-code/k3-256k", label: "K3 (256K)", detail: "Supports effort on CLI" },
+ ], manualPattern: "kimi-code/model" },
+ { id: "gemini", name: "Gem", role: "Analysis", harness: "Gemini CLI", adapterType: "gemini_local", defaultModel: "auto", models: [
+ { id: "auto", label: "Auto" },
+ { id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
+ { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
+ ], manualPattern: "Gemini model ID" },
+ { id: "cursor", name: "Cora", role: "Design", harness: "Cursor", adapterType: "cursor", defaultModel: "auto", models: [
+ { id: "auto", label: "Auto" },
+ { id: "composer-1.5", label: "Composer 1.5" },
+ { id: "sonnet-4.6", label: "Sonnet 4.6" },
+ ], manualPattern: "Cursor model ID" },
+ { id: "cursor-cloud", name: "Cloudy", role: "Remote engineering", harness: "Cursor Cloud", adapterType: "cursor_cloud", defaultLabel: "Account default", models: [], manualPattern: "Cursor Cloud model ID" },
+ { id: "runner", name: "Runa", role: "Operations", harness: "Paperclip Runner", adapterType: "paperclip_runner", provider: "Codex profile", defaultModel: "gpt-5.6-sol", models: codexModels, manualPattern: "Codex model ID" },
+ { id: "grok", name: "Grok", role: "Investigation", harness: "Grok CLI", adapterType: "grok_local", defaultModel: "grok-build", models: [{ id: "grok-build", label: "Grok Build" }], manualPattern: "Grok model ID" },
+ { id: "hermes", name: "Hermes", role: "Operations", harness: "Hermes CLI", adapterType: "hermes_local", defaultModel: "auto", models: [{ id: "auto", label: "Auto" }], manualPattern: "Hermes model ID" },
+ { id: "process", name: "Relay", role: "Automation", harness: "Process", adapterType: "process", models: [], noModelReason: "This agent runs a command. Its harness does not expose a model or effort setting." },
+ { id: "http", name: "Hook", role: "Integration", harness: "HTTP", adapterType: "http", models: [], noModelReason: "This agent calls an HTTP endpoint. The destination service chooses its model." },
+ { id: "openclaw", name: "Ollie", role: "Support", harness: "OpenClaw Gateway", adapterType: "openclaw_gateway", models: [], noModelReason: "This gateway chooses its model remotely; Paperclip has no model catalog or per-message setting for it." },
+ { id: "hermes-gateway", name: "Hera", role: "Remote operations", harness: "Hermes Gateway", adapterType: "hermes_gateway", models: [], noModelReason: "This Hermes gateway chooses its model remotely; Paperclip cannot override it here." },
+];
+
+/** Share the capsule-avatar palettes used by the agent persona stories. */
+export function composerAgentAppearance(agentId: string) {
+ const index = Math.max(0, composerAgents.findIndex((agent) => agent.id === agentId));
+ return appearanceForPalette(AGENT_PALETTE_IDS[index % AGENT_PALETTE_IDS.length]);
+}
+
+export const effortLabels: Record = {
+ minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Extra High", max: "Max", ultra: "Ultra", off: "Off",
+};
+
+export function effortChoices(agent: ComposerAgent, model: string): readonly string[] {
+ if (agent.adapterType === "codex_local" || agent.id === "runner") return isCodexLocalKnownModel(model) ? codexLocalReasoningEffortsForModel(model) : [];
+ if (!agent.models.some((option) => option.id === model)) return [];
+ if (agent.adapterType === "claude_local") return ["low", "medium", "high"];
+ if (agent.adapterType === "pi_local") return ["off", "minimal", "low", "medium", "high", "xhigh"];
+ if (agent.adapterType === "kimi_local" && modelSupportsEffort(model)) return KIMI_SUPPORTED_EFFORTS;
+ // The current model API only returns id/label. OpenCode/OpenRouter variants are
+ // model-specific, so a guessed generic slider would send unsupported values.
+ return [];
+}
+
+export function fastModeAvailable(agent: ComposerAgent, model: string): boolean {
+ return (agent.adapterType === "codex_local" || agent.id === "runner")
+ && isCodexLocalKnownModel(model)
+ && isCodexLocalFastModeSupported(model);
+}
+
+export function modelLabel(agent: ComposerAgent, model: string): string {
+ if (!model) return agent.defaultLabel ?? "Harness default";
+ return agent.models.find((option) => option.id === model)?.label ?? model;
+}
diff --git a/ui/storybook/prototypes/composer-model-picker/picker.css b/ui/storybook/prototypes/composer-model-picker/picker.css
new file mode 100644
index 0000000000..2ce405e6c3
--- /dev/null
+++ b/ui/storybook/prototypes/composer-model-picker/picker.css
@@ -0,0 +1,46 @@
+.composer-effort-range {
+ display: block;
+ appearance: none;
+ height: calc(var(--spacing) * 2);
+ border-radius: var(--radius-lg);
+ background: linear-gradient(to right, var(--tc-mode-ask) var(--fill), var(--muted) var(--fill));
+ cursor: pointer;
+}
+
+.composer-effort-range::-webkit-slider-thumb {
+ appearance: none;
+ width: calc(var(--spacing) * 5);
+ height: calc(var(--spacing) * 5);
+ border: calc(var(--spacing) * 0.5) solid var(--popover);
+ border-radius: 50%;
+ background: var(--tc-mode-ask);
+ box-shadow: var(--shadow-sm);
+}
+
+.composer-effort-range::-moz-range-thumb {
+ width: calc(var(--spacing) * 5);
+ height: calc(var(--spacing) * 5);
+ border: calc(var(--spacing) * 0.5) solid var(--popover);
+ border-radius: 50%;
+ background: var(--tc-mode-ask);
+ box-shadow: var(--shadow-sm);
+}
+
+.composer-effort-range:focus-visible { outline: calc(var(--spacing) * 0.5) solid var(--ring); outline-offset: calc(var(--spacing) * 0.5); }
+
+.composer-picker-accent { color: var(--tc-mode-ask); }
+
+.composer-picker-auto-height {
+ overflow: hidden;
+ transition: height var(--motion-duration-base) var(--motion-ease-out);
+}
+
+.composer-picker-mobile-dialog {
+ width: calc(100vw - var(--spacing) * 4);
+ max-width: calc(var(--spacing) * 96);
+ max-height: calc(100dvh - var(--spacing) * 8);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .composer-picker-auto-height { transition: none; }
+}
diff --git a/ui/storybook/stories/agent-personas.stories.tsx b/ui/storybook/stories/agent-personas.stories.tsx
index 007972cf98..d4a7069629 100644
--- a/ui/storybook/stories/agent-personas.stories.tsx
+++ b/ui/storybook/stories/agent-personas.stories.tsx
@@ -14,7 +14,7 @@ const meta = {
title: "Agents/Personas",
component: AgentCharacter,
args: { appearance, size: 256, state: "listening", label: "Chief of Staff" },
- parameters: { docs: { description: { component: "Persistent cap-v1 identities. Avatars request on-demand PNGs from Paperclip; the hero alone loads ClipLab. Development Storybook uses PAPERCLIP_STORYBOOK_API_URL. Published Storybook packages PNGs from the same API renderer automatically during its build, including every preset and both densities; no running API is required." } } },
+ parameters: { docs: { description: { component: "Persistent cap-v1 identities. The hero alone loads ClipLab. Development Storybook renders capsule PNGs locally on demand; published Storybook packages the same PNGs during its build, including every preset and both densities. Neither needs a running API." } } },
argTypes: {
state: { control: "select", options: CHARACTER_STATES },
size: { control: "select", options: AGENT_AVATAR_SIZES },
diff --git a/ui/storybook/stories/composer-add-menu.stories.tsx b/ui/storybook/stories/composer-add-menu.stories.tsx
new file mode 100644
index 0000000000..b8a5e92fe8
--- /dev/null
+++ b/ui/storybook/stories/composer-add-menu.stories.tsx
@@ -0,0 +1,304 @@
+import { useState, type CSSProperties } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, waitFor, within } from "storybook/test";
+import { ChevronLeft, Ellipsis } from "lucide-react";
+import type { IssueAttachment, IssueWorkMode, RunnerGoalCapability } from "@paperclipai/shared";
+import { MobileBottomNav } from "@/components/MobileBottomNav";
+import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
+import { TaskChatComposerDock } from "@/components/task-chat/TaskChatComposerDock";
+import { composerAgentAppearance, composerAgents } from "../prototypes/composer-model-picker/fixtures";
+
+const agentMap = new Map(composerAgents.map((agent) => [agent.id, {
+ id: agent.id,
+ name: agent.name,
+ appearance: composerAgentAppearance(agent.id),
+}]));
+const assignees = composerAgents.map((agent) => ({
+ id: `agent:${agent.id}`,
+ label: agent.name,
+ searchText: `${agent.name} ${agent.role} ${agent.harness}`,
+}));
+
+const goalCapability: RunnerGoalCapability = {
+ availability: "available",
+ verified: true,
+ actions: ["set", "pause", "resume", "clear"],
+ autonomousUpdates: true,
+ persistentAcrossResume: true,
+ maxObjectiveChars: 4_000,
+ tokenBudgetControl: true,
+ usageReporting: true,
+};
+
+interface ComposerAddStoryProps {
+ initialMode: IssueWorkMode;
+ goalAvailable: boolean;
+ mobile: boolean;
+ mobileContext: boolean;
+ fullBleedMobileContext: boolean;
+}
+
+function ComposerAddStory({ initialMode, goalAvailable, mobile, mobileContext, fullBleedMobileContext }: ComposerAddStoryProps) {
+ const [workMode, setWorkMode] = useState(initialMode);
+ const [sent, setSent] = useState([]);
+ const [goal, setGoal] = useState(null);
+
+ async function attachFile(file: File): Promise {
+ return {
+ id: crypto.randomUUID(), companyId: "storybook", issueId: "composer-story",
+ issueCommentId: null, assetId: crypto.randomUUID(), provider: "storybook",
+ objectKey: file.name, contentType: file.type, byteSize: file.size, sha256: "storybook",
+ originalFilename: file.name, createdByAgentId: null, createdByUserId: "storybook",
+ createdAt: new Date(), updatedAt: new Date(), contentPath: URL.createObjectURL(file),
+ };
+ }
+
+ const composer = setSent((messages) => [...messages, body])}
+ workMode={workMode}
+ onWorkModeChange={setWorkMode}
+ onAttachImage={attachFile}
+ enableReassign
+ reassignOptions={assignees}
+ agentMap={agentMap}
+ currentAssigneeValue="agent:codex"
+ runnerGoalCapability={goalAvailable ? goalCapability : { ...goalCapability, availability: "unsupported", actions: [] }}
+ onRunnerGoalCommand={goalAvailable ? async (command) => {
+ if (command.action === "create") setGoal(command.objective);
+ } : undefined}
+ mobile={mobile}
+ />;
+
+ if (mobileContext) return
+
+
+ PAP-1074 · Composer on mobile
+
+
+
+ {/* Production's full-width chat tab cancels the page's p-4 with -mx-4. */}
+
+
+
Tune the composer spacing for a phone screen.
+
The bottom navigation stays visible while writing.
+ {goal ?
Goal: {goal}
: null}
+ {sent.map((body, index) =>
{body}
)}
+
+
+
{composer}
+
+
+
+
;
+
+ return
+
+
+
+ {goal ?
Goal: {goal}
: null}
+ {sent.map((body, index) =>
{body}
)}
+
+ {composer}
+
+
;
+}
+
+const meta = {
+ title: "Composer/Add menu",
+ component: ComposerAddStory,
+ parameters: {
+ layout: "fullscreen",
+ options: { showPanel: false },
+ docs: { description: { component: "The production task composer. The Add menu opens upward on desktop and as a dialog on mobile for files, supported goals, Plan mode, and Ask mode. Plan and Ask are exclusive; selecting a mode shows a removable chip. Cmd+. cycles standard, Plan, and Ask. Mobile stories include capsule agent avatars and the actual bottom navigation." } },
+ },
+ args: { initialMode: "standard", goalAvailable: true, mobile: false, mobileContext: false, fullBleedMobileContext: false },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+async function openAdd(canvasElement: HTMLElement) {
+ const page = within(canvasElement.ownerDocument.body);
+ await userEvent.click(page.getByRole("button", { name: "Add to composer" }));
+ return page;
+}
+
+export const AddMenu: Story = {
+ name: "01 · Plus menu with goal",
+ play: async ({ canvasElement }) => {
+ const page = await openAdd(canvasElement);
+ await expect(page.getByRole("menuitem", { name: /Files and images/ })).toBeVisible();
+ await expect(page.getByRole("menuitem", { name: /Goal/ })).toBeVisible();
+ await expect(page.getByRole("menuitem", { name: /Plan mode/ })).toBeVisible();
+ await expect(page.getByRole("menuitem", { name: /Ask mode/ })).toBeVisible();
+ },
+};
+
+export const PlanChip: Story = {
+ name: "02 · Plan mode chip",
+ args: { initialMode: "planning" },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ const chip = page.getByRole("button", { name: "Remove Plan mode" });
+ const assignee = page.getByTestId("task-chat-composer-assignee");
+ await expect(chip).toBeVisible();
+ await expect(chip.getBoundingClientRect().height).toBe(assignee.getBoundingClientRect().height);
+ await expect(Math.abs(chip.getBoundingClientRect().top - assignee.getBoundingClientRect().top)).toBeLessThanOrEqual(1);
+ },
+};
+
+export const AskChip: Story = {
+ name: "03 · Ask mode chip",
+ args: { initialMode: "ask" },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ const chip = page.getByRole("button", { name: "Remove Ask mode" });
+ const assignee = page.getByTestId("task-chat-composer-assignee");
+ await expect(chip).toBeVisible();
+ await expect(chip.getBoundingClientRect().height).toBe(assignee.getBoundingClientRect().height);
+ await expect(Math.abs(chip.getBoundingClientRect().top - assignee.getBoundingClientRect().top)).toBeLessThanOrEqual(1);
+ },
+};
+
+export const SwitchModes: Story = {
+ name: "04 · Choose and remove a mode",
+ play: async ({ canvasElement }) => {
+ const page = await openAdd(canvasElement);
+ await userEvent.click(page.getByRole("menuitem", { name: /Plan mode/ }));
+ await expect(page.getByRole("button", { name: "Remove Plan mode" })).toBeVisible();
+ await userEvent.click(page.getByRole("button", { name: "Add to composer" }));
+ await userEvent.click(page.getByRole("menuitem", { name: /Ask mode/ }));
+ await expect(page.queryByRole("button", { name: "Remove Plan mode" })).not.toBeInTheDocument();
+ await userEvent.click(page.getByRole("button", { name: "Remove Ask mode" }));
+ await expect(page.queryByRole("button", { name: /Remove .* mode/ })).not.toBeInTheDocument();
+ },
+};
+
+export const FileUpload: Story = {
+ name: "05 · Attached file",
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ const input = canvasElement.querySelector('input[type="file"]')!;
+ await userEvent.upload(input, new File(["Storybook attachment"], "launch-plan.txt", { type: "text/plain" }));
+ await expect(page.getByText("launch-plan.txt")).toBeVisible();
+ },
+};
+
+export const GoalUnavailable: Story = {
+ name: "06 · Agent without goals",
+ args: { goalAvailable: false },
+ play: async ({ canvasElement }) => {
+ const page = await openAdd(canvasElement);
+ await expect(page.queryByRole("menuitem", { name: /Goal/ })).not.toBeInTheDocument();
+ },
+};
+
+export const Mobile: Story = {
+ name: "07 · Mobile Add dialog",
+ args: { mobile: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const page = await openAdd(canvasElement);
+ await expect(page.getByRole("dialog", { name: "Add" })).toBeVisible();
+ await expect(page.getByRole("button", { name: /Plan mode/ })).toBeVisible();
+ await expect(page.getByRole("button", { name: /Files and images/ })).toBeVisible();
+ },
+};
+
+export const GoalDraft: Story = {
+ name: "08 · Start a supported goal",
+ play: async ({ canvasElement }) => {
+ const page = await openAdd(canvasElement);
+ await userEvent.click(page.getByRole("menuitem", { name: /Goal/ }));
+ await expect(page.getByRole("textbox", { name: "editable markdown" })).toHaveTextContent("/goal");
+ },
+};
+
+export const MobileWithBottomBar: Story = {
+ name: "09 · Mobile with bottom bar",
+ args: { mobile: true, mobileContext: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ await expect(page.getByRole("navigation", { name: "Mobile navigation" })).toBeVisible();
+ await expect(page.getByTestId("task-chat-composer-dock")).toBeVisible();
+ await expect(page.getByTestId("task-chat-composer-assignee").querySelector('[data-slot="agent-avatar"] img')).toBeVisible();
+ },
+};
+
+export const MobilePlanWithBottomBar: Story = {
+ name: "10 · Mobile plan and attachment with bottom bar",
+ args: { initialMode: "planning", mobile: true, mobileContext: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ const input = canvasElement.querySelector('input[type="file"]')!;
+ await userEvent.upload(input, new File(["Mobile layout"], "notes.txt", { type: "text/plain" }));
+ await expect(page.getByText("notes.txt")).toBeVisible();
+ await expect(page.getByRole("button", { name: "Remove Plan mode" })).toBeVisible();
+ await expect(page.getByRole("navigation", { name: "Mobile navigation" })).toBeVisible();
+ const plus = page.getByRole("button", { name: "Add to composer" }).getBoundingClientRect();
+ const send = page.getByRole("button", { name: "Send" }).getBoundingClientRect();
+ const chip = page.getByRole("button", { name: "Remove Plan mode" }).getBoundingClientRect();
+ await expect(send.width).toBe(send.height);
+ await expect(Math.abs(plus.top - send.top)).toBeLessThanOrEqual(1);
+ await expect(Math.abs(chip.top - send.top)).toBeLessThanOrEqual(1);
+ await expect(page.getByRole("button", { name: "Remove Plan mode" }).querySelector(".sr-only")?.textContent).toBe("Plan mode");
+ },
+};
+
+export const MobileAskWithBottomBar: Story = {
+ name: "10b · Mobile Ask with bottom bar",
+ args: { initialMode: "ask", mobile: true, mobileContext: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ const chip = page.getByRole("button", { name: "Remove Ask mode" }).getBoundingClientRect();
+ const send = page.getByRole("button", { name: "Send" }).getBoundingClientRect();
+ await expect(send.width).toBe(send.height);
+ await expect(Math.abs(chip.top - send.top)).toBeLessThanOrEqual(1);
+ await expect(page.getByRole("button", { name: "Remove Ask mode" }).querySelector(".sr-only")?.textContent).toBe("Ask mode");
+ await expect(page.getByRole("navigation", { name: "Mobile navigation" })).toBeVisible();
+ },
+};
+
+export const MobileFullBleedChatWithBottomBar: Story = {
+ name: "11 · Mobile full-width chat with bottom bar",
+ args: { mobile: true, mobileContext: true, fullBleedMobileContext: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ await expect(page.getByRole("navigation", { name: "Mobile navigation" })).toBeVisible();
+ await expect(page.getByTestId("task-chat-composer-dock")).toBeVisible();
+ await expect(page.getByTestId("task-chat-composer-assignee").querySelector('[data-slot="agent-avatar"] img')).toBeVisible();
+ },
+};
+
+export const MobileAddDialogWithBottomBar: Story = {
+ name: "12 · Mobile Add dialog with bottom bar",
+ args: { mobile: true, mobileContext: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ await expect(page.getByRole("navigation", { name: "Mobile navigation" })).toBeVisible();
+ await openAdd(canvasElement);
+ await expect(page.getByRole("dialog", { name: "Add" })).toBeVisible();
+ },
+};
+
+export const MobileGoalFromAddDialog: Story = {
+ name: "13 · Mobile Goal keeps editor focus",
+ args: { mobile: true, mobileContext: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const page = await openAdd(canvasElement);
+ await userEvent.click(page.getByRole("button", { name: /Goal Keep pursuing/ }));
+ const editor = page.getByRole("textbox", { name: "editable markdown" });
+ await expect(editor).toHaveTextContent("/goal");
+ await waitFor(() => expect(editor).toHaveFocus());
+ },
+};
diff --git a/ui/storybook/stories/composer-mobile-assignee.stories.tsx b/ui/storybook/stories/composer-mobile-assignee.stories.tsx
new file mode 100644
index 0000000000..1f5f8d70b1
--- /dev/null
+++ b/ui/storybook/stories/composer-mobile-assignee.stories.tsx
@@ -0,0 +1,36 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, within } from "storybook/test";
+import { ComposerRunSettingsLiveStory } from "../prototypes/composer-model-picker/ComposerRunSettingsLiveStory";
+
+const meta = {
+ title: "Composer/Mobile assignee picker",
+ component: ComposerRunSettingsLiveStory,
+ parameters: {
+ layout: "fullscreen",
+ docs: { description: { component: "The same assignee, model, and effort picker shown on desktop, in its responsive mobile dialog. Agent capsule avatars use the shared persona palettes." } },
+ },
+ args: { agentId: "codex", mobile: true, compact: true, initialPanel: "agents" },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const PickerOpen: Story = {
+ globals: { viewport: { value: "mobile", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ await expect(page.getByTestId("composer-mobile-dialog")).toBeVisible();
+ await expect(page.getByRole("listbox", { name: "Assignees" })).toBeVisible();
+ await expect(page.getByTestId("task-chat-composer-assignee").querySelector('[data-slot="agent-avatar"] img')).toBeVisible();
+ },
+};
+
+export const ComposerClosed: Story = {
+ args: { initialPanel: "closed" },
+ globals: { viewport: { value: "mobile", isRotated: false } },
+};
+
+export const SearchAssignees: Story = {
+ args: { initialAssigneeSearch: "Claude" },
+ globals: { viewport: { value: "mobile", isRotated: false } },
+};
diff --git a/ui/storybook/stories/composer-model-picker.stories.tsx b/ui/storybook/stories/composer-model-picker.stories.tsx
new file mode 100644
index 0000000000..ec421f5faf
--- /dev/null
+++ b/ui/storybook/stories/composer-model-picker.stories.tsx
@@ -0,0 +1,293 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, within } from "storybook/test";
+import { ComposerModelPickerPreview } from "../prototypes/composer-model-picker/ComposerModelPickerPreview";
+import { ComposerRunSettingsLiveStory } from "../prototypes/composer-model-picker/ComposerRunSettingsLiveStory";
+
+const meta = {
+ title: "Composer/Model and effort picker",
+ component: ComposerModelPickerPreview,
+ parameters: {
+ layout: "fullscreen",
+ options: { showPanel: false },
+ docs: { description: { component:
+ "The approved composer picker design and two matching stories using the production picker. The assignee capsule stays beside Send on desktop and mobile. The assignee determines the harness and catalog; changing assignees clears per-message overrides. Effort is selected only with a model-specific slider where levels are known. The picker animates its height as content changes and opens as a modal on mobile. Custom IDs are accepted for harnesses that support them, while OpenRouter requires openrouter/provider/model. A fast-mode icon sits to the left of the effort label only for supported known Codex models, and the reset icon sits to the right."
+ } },
+ },
+ args: { agentId: "codex", initialPanel: "closed" },
+ render: (args) => ,
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const DefaultComposer: Story = {
+ name: "01 · Unified assignee and model picker",
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ const capsule = screen.getByRole("button", { name: "Select assignee, model and effort" });
+ const send = screen.getByRole("button", { name: "Send message" });
+ await expect(send.getBoundingClientRect().left - capsule.getBoundingClientRect().right).toBeLessThanOrEqual(16);
+ },
+};
+export const EffortSlider: Story = {
+ name: "02 · Codex effort slider",
+ args: { initialPanel: "settings", initialEffort: "high" },
+};
+export const ExactEffort: Story = {
+ name: "02b · Slider at Extra High",
+ args: { initialPanel: "settings", initialEffort: "xhigh" },
+};
+export const ExactModelList: Story = {
+ name: "03 · Search exact Codex models",
+ args: { initialPanel: "models" },
+};
+export const AstraFastMode: Story = {
+ name: "04 · Astra · fast icon active",
+ args: { initialModel: "gpt-6-astra", initialEffort: "ultra", initialFast: true, initialPanel: "settings" },
+};
+export const FastModeToggle: Story = {
+ name: "04b · Toggle fast icon",
+ args: { initialPanel: "settings" },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ const toggle = screen.getByRole("button", { name: "Fast mode" });
+ await userEvent.click(toggle);
+ await expect(toggle).toHaveAttribute("aria-pressed", "true");
+ },
+};
+export const FastModeUnavailable: Story = {
+ name: "05 · Model without fast mode",
+ args: { initialModel: "gpt-5.4-mini", initialPanel: "settings" },
+};
+export const CodexCustomUnknown: Story = {
+ name: "05b · Custom Codex ID · capabilities unknown",
+ args: { initialModel: "my-private-codex-model", initialPanel: "settings" },
+};
+export const ResetToAgentDefault: Story = {
+ name: "06 · Reset model, effort and fast mode",
+ args: { initialModel: "gpt-6-astra", initialEffort: "ultra", initialFast: true, initialPanel: "settings" },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ await userEvent.click(screen.getByRole("button", { name: "Reset to agent default" }));
+ await expect(screen.getByTestId("selected-effort")).toHaveTextContent("Default");
+ await expect(screen.getByRole("button", { name: "Choose exact model" })).toHaveTextContent("GPT-5.6 Sol");
+ await expect(screen.getByRole("button", { name: "Fast mode" })).toHaveAttribute("aria-pressed", "false");
+ },
+};
+export const Claude: Story = {
+ name: "07 · Claude Code · low to high",
+ args: { agentId: "claude", initialPanel: "settings", initialEffort: "medium" },
+};
+export const OpenRouterSearch: Story = {
+ name: "08 · OpenRouter · search this provider",
+ args: { agentId: "openrouter", initialPanel: "models", initialSearch: "deepseek" },
+};
+export const OpenRouterCustomId: Story = {
+ name: "09 · OpenRouter · pasted custom ID",
+ args: { agentId: "openrouter", initialModel: "openrouter/qwen/qwen3-coder-next", initialPanel: "settings" },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ await expect(screen.queryByRole("slider")).toBeNull();
+ await expect(screen.queryByText(/Effort levels are not advertised/)).toBeNull();
+ await expect(screen.getByRole("button", { name: "Reset to agent default" })).toBeVisible();
+ },
+};
+export const OpenRouterManualEntry: Story = {
+ name: "10 · OpenRouter · type exact model",
+ args: { agentId: "openrouter", initialPanel: "models", initialSearch: "openrouter/qwen/qwen3-coder-next" },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ await userEvent.click(screen.getByRole("button", { name: /Use exact ID/ }));
+ await expect(screen.getByRole("button", { name: "Choose exact model" })).toHaveTextContent("openrouter/qwen/qwen3-coder-next");
+ await expect(screen.queryByRole("slider")).toBeNull();
+ await expect(screen.queryByText(/Effort levels are not advertised/)).toBeNull();
+ },
+};
+export const OpenRouterPasteProposal: Story = {
+ name: "10b · OpenRouter · paste proposal",
+ args: { agentId: "openrouter", initialPanel: "models", initialSearch: "openrouter/qwen/qwen3-coder-next" },
+};
+export const OpenRouterInvalidId: Story = {
+ name: "10c · OpenRouter · invalid ID guidance",
+ args: { agentId: "openrouter", initialPanel: "models", initialSearch: "anthropic/claude-sonnet-4.6" },
+};
+export const PiThinking: Story = {
+ name: "11 · Pi · thinking levels",
+ args: { agentId: "pi", initialPanel: "settings", initialEffort: "high" },
+};
+export const KimiSupported: Story = {
+ name: "12 · Kimi K3 · low, high, max",
+ args: { agentId: "kimi", initialPanel: "settings", initialEffort: "high" },
+};
+export const KimiModelDefault: Story = {
+ name: "13 · Kimi highspeed · no effort override",
+ args: { agentId: "kimi", initialModel: "kimi-code/kimi-for-coding-highspeed", initialPanel: "settings" },
+};
+export const GeminiModelOnly: Story = {
+ name: "14 · Gemini · model only",
+ args: { agentId: "gemini", initialPanel: "settings" },
+};
+export const CursorModelOnly: Story = {
+ name: "15 · Cursor · model only",
+ args: { agentId: "cursor", initialPanel: "settings" },
+};
+export const CursorCloudManual: Story = {
+ name: "15b · Cursor Cloud · manual model ID",
+ args: { agentId: "cursor-cloud", initialPanel: "models" },
+};
+export const RunnerCodexProfile: Story = {
+ name: "16 · Runner · Codex profile",
+ args: { agentId: "runner", initialPanel: "models" },
+};
+export const GrokModelOnly: Story = {
+ name: "17 · Grok · model only",
+ args: { agentId: "grok", initialPanel: "settings" },
+};
+export const HermesManual: Story = {
+ name: "17b · Hermes CLI · manual model ID",
+ args: { agentId: "hermes", initialPanel: "models" },
+};
+export const ProcessNoModel: Story = {
+ name: "18 · Process · no model setting",
+ args: { agentId: "process", initialPanel: "settings" },
+};
+export const HttpNoModel: Story = {
+ name: "18b · HTTP · remote model",
+ args: { agentId: "http", initialPanel: "settings" },
+};
+export const GatewayNoModel: Story = {
+ name: "19 · OpenClaw · remote model",
+ args: { agentId: "openclaw", initialPanel: "settings" },
+};
+export const HermesGatewayNoModel: Story = {
+ name: "19b · Hermes Gateway · remote model",
+ args: { agentId: "hermes-gateway", initialPanel: "settings" },
+};
+export const AgentMenu: Story = {
+ name: "20 · Searchable assignee list",
+ args: { initialPanel: "agents" },
+};
+export const AssigneeSearch: Story = {
+ name: "20a · Search by assignee harness",
+ args: { initialPanel: "agents", initialAssigneeSearch: "OpenRouter" },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ await expect(screen.getByRole("searchbox", { name: "Search assignees" })).toHaveValue("OpenRouter");
+ await expect(screen.getAllByRole("option")).toHaveLength(1);
+ await expect(screen.getByRole("option", { name: /Nora/ })).toBeVisible();
+ },
+};
+export const AssigneeSearchNoMatches: Story = {
+ name: "20aa · Assignee search has no matches",
+ args: { initialPanel: "agents", initialAssigneeSearch: "unknown teammate" },
+};
+export const AssigneeSearchKeyboard: Story = {
+ name: "20ab · Choose searched assignee with Enter",
+ args: { initialPanel: "agents", initialAssigneeSearch: "OpenRouter" },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ await userEvent.type(screen.getByRole("searchbox", { name: "Search assignees" }), "{Enter}");
+ await expect(screen.getByRole("button", { name: "Choose exact model" })).toHaveTextContent("Claude Sonnet 4.6");
+ },
+};
+export const AgentSwitchClearsOverrides: Story = {
+ name: "20b · Switching agents resets overrides",
+ args: { initialModel: "gpt-6-astra", initialEffort: "ultra", initialFast: true, initialPanel: "agents" },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ await userEvent.click(screen.getByRole("option", { name: /Nora/ }));
+ await expect(screen.getByRole("button", { name: "Choose exact model" })).toHaveTextContent("Claude Sonnet 4.6");
+ await expect(screen.queryByRole("slider")).toBeNull();
+ await expect(screen.queryByText(/Effort levels are not advertised/)).toBeNull();
+ await expect(screen.queryByRole("button", { name: "Fast mode" })).toBeNull();
+ },
+};
+export const Mobile: Story = {
+ name: "21 · Mobile · centered picker modal",
+ args: { agentId: "codex", initialPanel: "settings", compact: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ await expect(screen.getByTestId("composer-mobile-dialog")).toBeVisible();
+ await expect(screen.getByRole("slider", { name: "Effort" })).toBeVisible();
+ },
+};
+export const MobileAssigneeSearch: Story = {
+ name: "21b · Mobile assignee search",
+ args: { agentId: "codex", initialPanel: "agents", initialAssigneeSearch: "Claude", compact: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+};
+export const MobileOpenRouterCustomId: Story = {
+ name: "21c · Mobile · custom model without effort",
+ args: { agentId: "openrouter", initialModel: "openrouter/qwen/qwen3-coder-next", initialPanel: "settings", compact: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ await expect(screen.getByTestId("composer-mobile-dialog")).toBeVisible();
+ await expect(screen.queryByRole("slider")).toBeNull();
+ await expect(screen.queryByText(/Effort levels are not advertised/)).toBeNull();
+ },
+};
+export const MobileModelSearch: Story = {
+ name: "21d · Mobile · searchable model modal",
+ args: { agentId: "openrouter", initialPanel: "models", initialSearch: "deepseek", compact: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+};
+export const Light: Story = {
+ name: "22 · Light theme",
+ args: { agentId: "claude", initialPanel: "settings" },
+ globals: { theme: "light" },
+};
+
+export const ProductionComposer: Story = {
+ name: "23 · App picker in composer",
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ const capsule = screen.getByTestId("task-chat-composer-assignee");
+ const send = screen.getByRole("button", { name: "Send message" });
+ await expect(send.getBoundingClientRect().left - capsule.getBoundingClientRect().right).toBeLessThanOrEqual(16);
+ },
+};
+
+export const ProductionMobileComposer: Story = {
+ name: "23b · App picker on mobile",
+ render: () => ,
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ const capsule = screen.getByTestId("task-chat-composer-assignee");
+ const send = screen.getByRole("button", { name: "Send message" });
+ await expect(send.getBoundingClientRect().left - capsule.getBoundingClientRect().right).toBeLessThanOrEqual(16);
+ },
+};
+
+export const IntermediateWidthPlan: Story = {
+ name: "24 · Narrow desktop with Plan and open picker",
+ args: { compact: true, initialMode: "planning", initialPanel: "settings" },
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ const chip = screen.getByRole("button", { name: "Remove Plan mode" }).getBoundingClientRect();
+ const capsule = screen.getByRole("button", { name: "Select assignee, model and effort" }).getBoundingClientRect();
+ const send = screen.getByRole("button", { name: "Send message" }).getBoundingClientRect();
+ await expect(chip.height).toBe(capsule.height);
+ await expect(Math.abs(send.top - capsule.top)).toBeLessThanOrEqual(1);
+ await expect(send.left - capsule.right).toBeLessThanOrEqual(16);
+ await expect(screen.getByTestId("composer-model-popover")).toBeVisible();
+ },
+};
+
+export const ProductionIntermediateWidthPlan: Story = {
+ name: "24b · App picker at narrow desktop width",
+ render: () => ,
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement.ownerDocument.body);
+ const chip = screen.getByRole("button", { name: "Remove Plan mode" }).getBoundingClientRect();
+ const capsule = screen.getByTestId("task-chat-composer-assignee").getBoundingClientRect();
+ const send = screen.getByRole("button", { name: "Send message" }).getBoundingClientRect();
+ await expect(chip.height).toBe(capsule.height);
+ await expect(Math.abs(send.top - capsule.top)).toBeLessThanOrEqual(1);
+ await expect(send.left - capsule.right).toBeLessThanOrEqual(16);
+ await expect(screen.getByTestId("composer-model-popover")).toBeVisible();
+ },
+};
diff --git a/ui/storybook/stories/composer-question-flow.stories.tsx b/ui/storybook/stories/composer-question-flow.stories.tsx
new file mode 100644
index 0000000000..d044c9f009
--- /dev/null
+++ b/ui/storybook/stories/composer-question-flow.stories.tsx
@@ -0,0 +1,122 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, within } from "storybook/test";
+import type { PaperclipQuestionResponse, PaperclipQuestionSet } from "@paperclipai/adapter-utils";
+import { QuestionForm, QuestionResponseSummary } from "@/components/task-chat/QuestionForm";
+import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
+import { Button } from "@/components/ui/button";
+
+const composerQuestions: PaperclipQuestionSet = {
+ schema: "paperclip.question_set.v1",
+ title: "Express app — scope",
+ questions: [
+ {
+ id: "storage",
+ prompt: "Does it need to store anything?",
+ answerMode: "single_select",
+ required: true,
+ options: [
+ { id: "memory", label: "No — in-memory is fine", description: "Fastest to something running; state dies on restart." },
+ { id: "sqlite", label: "SQLite file", description: "Real persistence, zero infrastructure. Good default for a first version." },
+ { id: "postgres", label: "Postgres", description: "Needs a database to point at." },
+ ],
+ customAnswer: { enabled: true },
+ },
+ {
+ id: "features",
+ prompt: "Which features should it include?",
+ helpText: "Choose all that apply, then click Next.",
+ answerMode: "multi_select",
+ required: true,
+ options: [
+ { id: "auth", label: "Sign in" },
+ { id: "search", label: "Search" },
+ { id: "uploads", label: "File uploads" },
+ ],
+ },
+ {
+ id: "timing",
+ prompt: "When should we start?",
+ answerMode: "single_select",
+ required: true,
+ options: [
+ { id: "now", label: "Now" },
+ { id: "later", label: "Later" },
+ ],
+ },
+ ],
+};
+
+function InteractiveComposerQuestions() {
+ const [response, setResponse] = useState(null);
+ const [open, setOpen] = useState(true);
+ const [reset, setReset] = useState(0);
+ return (
+
+ {response ? (
+
+ ) : null}
+
{}}
+ workMode="standard"
+ takeover={open ? {
+ id: `composer-questions-${reset}`,
+ label: composerQuestions.title!,
+ pendingCount: 1,
+ inlineSkip: true,
+ content: { setResponse(next); setOpen(false); }}
+ />,
+ onDismiss: () => setOpen(false),
+ onSkip: () => setOpen(false),
+ } : null}
+ />
+ { setResponse(null); setReset((value) => value + 1); setOpen(true); }}>
+ Restart questions
+
+
+ );
+}
+
+const meta = {
+ title: "Composer/Question flow",
+ component: InteractiveComposerQuestions,
+ parameters: {
+ layout: "padded",
+ docs: { description: { component: "A structured question card above the usable composer. Choices stay selected until Next or Submit, and Other opens a text field." } },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const QuestionsAndComposer: Story = {};
+export const AnswersSubmitted: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole("radio", { name: "Other" }));
+ await expect(canvas.getByText("1 of 3")).toBeVisible();
+ await expect(canvas.getByTestId("question-other-answer-composer")).toBeVisible();
+ await userEvent.click(canvas.getByRole("radio", { name: /SQLite file/ }));
+ await expect(canvas.getByText("1 of 3")).toBeVisible();
+ await userEvent.click(canvas.getByRole("button", { name: "Next" }));
+ await expect(canvas.getByText("2 of 3")).toBeVisible();
+ await userEvent.click(canvas.getByRole("checkbox", { name: "Sign in" }));
+ await userEvent.click(canvas.getByRole("checkbox", { name: "Search" }));
+ await expect(canvas.getByText("2 of 3")).toBeVisible();
+ await userEvent.click(canvas.getByRole("button", { name: "Next" }));
+ await userEvent.click(canvas.getByRole("radio", { name: "Now" }));
+ await expect(canvas.getByText("3 of 3")).toBeVisible();
+ await expect(canvas.queryByText("Answers submitted")).not.toBeInTheDocument();
+ await userEvent.click(canvas.getByRole("button", { name: "Submit answers" }));
+ await expect(canvas.getByText("Answers submitted")).toBeVisible();
+ await expect(canvas.getByText("SQLite file", { exact: true })).toBeVisible();
+ await expect(canvas.getByText("Sign in, Search", { exact: true })).toBeVisible();
+ },
+};
diff --git a/ui/storybook/stories/composer-queued-messages.stories.tsx b/ui/storybook/stories/composer-queued-messages.stories.tsx
new file mode 100644
index 0000000000..b2dfe5a561
--- /dev/null
+++ b/ui/storybook/stories/composer-queued-messages.stories.tsx
@@ -0,0 +1,157 @@
+import { useState } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, within } from "storybook/test";
+import type { IssueQueuedCommentEntry, IssueQueuedCommentQueue } from "@paperclipai/shared";
+import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
+import { TaskChatQueuedMessages } from "@/components/task-chat/TaskChatQueuedMessages";
+import { composerAgentAppearance, composerAgents } from "../prototypes/composer-model-picker/fixtures";
+
+const agentMap = new Map(composerAgents.map((agent) => [agent.id, {
+ id: agent.id,
+ name: agent.name,
+ appearance: composerAgentAppearance(agent.id),
+}]));
+
+function queuedEntry(body: string, id: string, position: number): IssueQueuedCommentEntry {
+ return {
+ comment: {
+ id,
+ companyId: "storybook",
+ issueId: "composer-queue-story",
+ authorType: "user",
+ authorAgentId: null,
+ authorUserId: "storybook-user",
+ body,
+ presentation: null,
+ metadata: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ position,
+ canEdit: true,
+ canDiscard: true,
+ };
+}
+
+const initialEntries = [
+ queuedEntry("Check the mobile layout after this pass.", "queued-1", 0),
+ queuedEntry("Then update the accessibility notes.", "queued-2", 1),
+];
+
+type QueueStoryProps = {
+ protocol: IssueQueuedCommentQueue["protocol"];
+ steeringDisposition: IssueQueuedCommentQueue["steeringDisposition"];
+ initialEdit: boolean;
+ mobile: boolean;
+};
+
+function QueuedComposer({ protocol, steeringDisposition, initialEdit, mobile }: QueueStoryProps) {
+ const [entries, setEntries] = useState(initialEntries);
+ const [editingId, setEditingId] = useState(initialEdit ? "queued-1" : null);
+ const [sent, setSent] = useState([]);
+ const [notice, setNotice] = useState(null);
+ const [running, setRunning] = useState(true);
+ const queue: IssueQueuedCommentQueue = {
+ issueId: "composer-queue-story",
+ queueId: "queue-story",
+ state: "deferred",
+ targetRunId: running ? "active-run" : null,
+ revision: "story-revision",
+ protocol,
+ steeringDisposition,
+ entries,
+ };
+
+ function remove(id: string) {
+ setEntries((current) => current.filter((entry) => entry.comment.id !== id)
+ .map((entry, position) => ({ ...entry, position })));
+ }
+
+ return (
+
+
+
+
+
I’m updating the composer and checking the responsive layout.
+ {sent.map((body, index) =>
{body}
)}
+ {notice ?
{notice}
: null}
+
+
+ {entries.length ?
setEntries((current) => ids.map((id, position) => ({
+ ...current.find((entry) => entry.comment.id === id)!, position,
+ })))}
+ onSteer={async (id) => { remove(id); setNotice("Message steered into the active turn."); }}
+ onInterrupt={async () => { setRunning(false); setNotice("The active turn was interrupted."); }}
+ onDiscard={async (id) => { remove(id); setNotice("Queued message discarded."); }}
+ /> : null}
+
+ {
+ if (running) {
+ setEntries((current) => [...current, queuedEntry(body, `queued-${Date.now()}`, current.length)]);
+ setNotice("Message queued for the next turn.");
+ } else {
+ setSent((current) => [...current, body]);
+ }
+ }}
+ workMode="standard"
+ onStop={running ? async () => setRunning(false) : undefined}
+ queuedEdit={editingId ? {
+ commentId: editingId,
+ body: entries.find((entry) => entry.comment.id === editingId)?.comment.body ?? "",
+ } : null}
+ onSaveQueuedEdit={async (id, body) => {
+ setEntries((current) => current.map((entry) => entry.comment.id === id
+ ? { ...entry, comment: { ...entry.comment, body } }
+ : entry));
+ setEditingId(null);
+ }}
+ onCancelQueuedEdit={() => setEditingId(null)}
+ enableReassign
+ reassignOptions={composerAgents.map((agent) => ({ id: `agent:${agent.id}`, label: agent.name }))}
+ currentAssigneeValue="agent:codex"
+ agentMap={agentMap}
+ mobile={mobile}
+ />
+
+
+
+
+ );
+}
+
+const meta = {
+ title: "Composer/Steering and queued messages",
+ component: QueuedComposer,
+ parameters: {
+ layout: "fullscreen",
+ docs: { description: { component: "The production queued-message strip sits above the production composer. Steer, edit, reorder, discard, and legacy interrupt can be tried locally." } },
+ },
+ args: { protocol: "paperclip_runner_v1", steeringDisposition: "available", initialEdit: false, mobile: false },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const QueuedWhileRunning: Story = {};
+export const SteerIntoActiveTurn: Story = {
+ play: async ({ canvasElement }) => {
+ const page = within(canvasElement.ownerDocument.body);
+ await userEvent.click(page.getByTestId("task-chat-queued-steer-queued-1"));
+ await expect(page.queryByText("Check the mobile layout after this pass.")).not.toBeInTheDocument();
+ await expect(page.getByText("Message steered into the active turn.")).toBeVisible();
+ },
+};
+export const EditQueuedMessage: Story = { args: { initialEdit: true } };
+export const LegacyInterrupt: Story = { args: { protocol: "legacy" } };
+export const SteeringUnavailable: Story = { args: { steeringDisposition: "unsupported" } };
+export const MobileQueue: Story = {
+ args: { mobile: true },
+ globals: { viewport: { value: "mobile", isRotated: false } },
+};
diff --git a/ui/storybook/stories/issue-thread-interactions.stories.tsx b/ui/storybook/stories/issue-thread-interactions.stories.tsx
index a4bd74796b..371082cbda 100644
--- a/ui/storybook/stories/issue-thread-interactions.stories.tsx
+++ b/ui/storybook/stories/issue-thread-interactions.stories.tsx
@@ -1,9 +1,4 @@
import { useEffect, useRef, useState } from "react";
-import { expect, userEvent, waitFor, within } from "storybook/test";
-import type { PaperclipQuestionResponse, PaperclipQuestionSet } from "@paperclipai/adapter-utils";
-import { QuestionForm, QuestionResponseSummary } from "@/components/task-chat/QuestionForm";
-import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
-import { Button } from "@/components/ui/button";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { IssueChatThread } from "@/components/IssueChatThread";
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
@@ -501,121 +496,6 @@ export const SuggestedTasksRejected: Story = {
),
};
-const composerQuestions: PaperclipQuestionSet = {
- schema: "paperclip.question_set.v1",
- title: "Express app — scope",
- questions: [
- {
- id: "storage",
- prompt: "Does it need to store anything?",
- answerMode: "single_select",
- required: true,
- options: [
- { id: "memory", label: "No — in-memory is fine", description: "Fastest to something running; state dies on restart." },
- { id: "sqlite", label: "SQLite file", description: "Real persistence, zero infrastructure. Good default for a first version." },
- { id: "postgres", label: "Postgres", description: "Needs a database to point at." },
- ],
- customAnswer: { enabled: true },
- },
- {
- id: "features",
- prompt: "Which features should it include?",
- helpText: "Choose all that apply, then click Next.",
- answerMode: "multi_select",
- required: true,
- options: [
- { id: "auth", label: "Sign in" },
- { id: "search", label: "Search" },
- { id: "uploads", label: "File uploads" },
- ],
- },
- {
- id: "timing",
- prompt: "When should we start?",
- answerMode: "single_select",
- required: true,
- options: [
- { id: "now", label: "Now" },
- { id: "later", label: "Later" },
- ],
- },
- ],
-};
-
-function InteractiveComposerQuestions() {
- const [response, setResponse] = useState(null);
- const [open, setOpen] = useState(true);
- const [reset, setReset] = useState(0);
- return (
-
- {response ? (
-
- ) : null}
-
{}}
- workMode="standard"
- takeover={open ? {
- id: `composer-questions-${reset}`,
- label: composerQuestions.title!,
- pendingCount: 1,
- inlineSkip: true,
- content: { setResponse(next); setOpen(false); }}
- />,
- onDismiss: () => setOpen(false),
- onSkip: () => setOpen(false),
- } : null}
- />
- { setResponse(null); setReset((value) => value + 1); setOpen(true); }}>
- Restart questions
-
-
- );
-}
-
-/** Single choices advance, while Other, multi-select, and final submission wait. */
-export const ComposerQuestionsAutoAdvance: Story = {
- render: () => (
-
-
-
-
-
- ),
-};
-
-export const ComposerQuestionsAutoAdvanceVerified: Story = {
- ...ComposerQuestionsAutoAdvance,
- play: async ({ canvasElement }) => {
- const canvas = within(canvasElement);
- await userEvent.click(canvas.getByRole("radio", { name: "Other" }));
- await expect(canvas.getByText("1 of 3")).toBeVisible();
- await expect(canvas.getByTestId("question-other-answer-composer")).toBeVisible();
- await userEvent.click(canvas.getByRole("radio", { name: /SQLite file/ }));
- await waitFor(() => expect(canvas.getByText("2 of 3")).toBeVisible());
- await userEvent.click(canvas.getByRole("checkbox", { name: "Sign in" }));
- await userEvent.click(canvas.getByRole("checkbox", { name: "Search" }));
- await expect(canvas.getByText("2 of 3")).toBeVisible();
- await userEvent.click(canvas.getByRole("button", { name: "Next" }));
- await userEvent.click(canvas.getByRole("radio", { name: "Now" }));
- await expect(canvas.getByText("3 of 3")).toBeVisible();
- await expect(canvas.queryByText("Answers submitted")).not.toBeInTheDocument();
- await userEvent.click(canvas.getByRole("button", { name: "Submit answers" }));
- await expect(canvas.getByText("Answers submitted")).toBeVisible();
- await expect(canvas.getByText("SQLite file", { exact: true })).toBeVisible();
- await expect(canvas.getByText("Sign in, Search", { exact: true })).toBeVisible();
- },
-};
-
export const AskUserQuestionsPending: Story = {
render: () => (
diff --git a/ui/storybook/stories/mobile-entity-pickers.stories.tsx b/ui/storybook/stories/mobile-entity-pickers.stories.tsx
index b3972ef2d1..ea06c277d4 100644
--- a/ui/storybook/stories/mobile-entity-pickers.stories.tsx
+++ b/ui/storybook/stories/mobile-entity-pickers.stories.tsx
@@ -3,7 +3,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { userEvent, within } from "storybook/test";
import { InlineEntitySelector, type InlineEntityOption } from "@/components/InlineEntitySelector";
import { SearchableSelect } from "@/components/SearchableSelect";
-import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
const assignees: InlineEntityOption[] = [
{ id: "agent-product", label: "Product Lead", searchText: "planning product" },
@@ -60,25 +59,6 @@ export const ProjectPicker: Story = {
render: () => ,
};
-export const ComposerAssigneePicker: Story = {
- render: () => (
-
- undefined}
- workMode="standard"
- mobile
- enableReassign
- reassignOptions={assignees.map((option) => ({ ...option, id: `agent:${option.id}` }))}
- currentAssigneeValue=""
- />
-
- ),
- play: async () => {
- const page = within(document.body);
- await userEvent.click(await page.findByTestId("task-chat-composer-assignee"));
- },
-};
-
export const SearchableSelectModal: Story = {
render: () => (
diff --git a/ui/storybook/stories/paused-composer.stories.tsx b/ui/storybook/stories/paused-composer.stories.tsx
index 1cdcf7f807..83670ad3fa 100644
--- a/ui/storybook/stories/paused-composer.stories.tsx
+++ b/ui/storybook/stories/paused-composer.stories.tsx
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { PausedComposerPreview } from "../prototypes/PausedTaskComposer";
const meta = {
- title: "Tasks/Composer/Paused task takeover",
+ title: "Composer/Paused task takeover",
component: PausedComposerPreview,
parameters: {
layout: "fullscreen",
diff --git a/ui/storybook/stories/session-goals.stories.tsx b/ui/storybook/stories/session-goals.stories.tsx
index 4ebf644936..b963440518 100644
--- a/ui/storybook/stories/session-goals.stories.tsx
+++ b/ui/storybook/stories/session-goals.stories.tsx
@@ -159,7 +159,7 @@ function SessionGoalStates() {
}
const meta = {
- title: "Product/Agent session goals",
+ title: "Composer/Session goals",
component: SessionGoalStates,
parameters: {
docs: {
diff --git a/ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx b/ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx
new file mode 100644
index 0000000000..5fe19d676e
--- /dev/null
+++ b/ui/storybook/stories/task-chat-interaction-above-composer.stories.tsx
@@ -0,0 +1,255 @@
+import { useState, type CSSProperties } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, userEvent, within } from "storybook/test";
+import type { IssueThreadInteraction, IssueWorkMode } from "@paperclipai/shared";
+import { ChevronLeft, Ellipsis } from "lucide-react";
+import { MobileBottomNav } from "@/components/MobileBottomNav";
+import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
+import { TaskChatComposerDock } from "@/components/task-chat/TaskChatComposerDock";
+import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard";
+import { TaskChatProtocolCard } from "@/components/task-chat/TaskChatProtocolCard";
+import type { TaskChatRuntimeRequestItem } from "@/components/task-chat/task-chat-model";
+import {
+ genericPendingRequestConfirmationInteraction,
+ pendingAskUserQuestionsInteraction,
+ pendingRequestCheckboxConfirmationInteraction,
+ pendingRequestConfirmationInteraction,
+ pendingRequestItemVerdictsInteraction,
+ pendingSuggestedTasksInteraction,
+ pendingToolActionWriteInteraction,
+ issueThreadInteractionFixtureMeta,
+} from "@/fixtures/issueThreadInteractionFixtures";
+import { composerAgentAppearance, composerAgents } from "../prototypes/composer-model-picker/fixtures";
+import { storybookAgentMap } from "../fixtures/paperclipData";
+
+const agentMap = new Map(composerAgents.map((agent) => [agent.id, {
+ id: agent.id,
+ name: agent.name,
+ appearance: composerAgentAppearance(agent.id),
+}]));
+const assignees = composerAgents.map((agent) => ({ id: `agent:${agent.id}`, label: agent.name }));
+const mobileQuestionInteraction = {
+ ...pendingAskUserQuestionsInteraction,
+ id: "interaction-mobile-question",
+ title: "Question",
+ payload: {
+ ...pendingAskUserQuestionsInteraction.payload,
+ title: "Question",
+ questions: [{
+ ...pendingAskUserQuestionsInteraction.payload.questions[0],
+ id: "draft-choice",
+ prompt: "Should I use the existing draft?",
+ helpText: undefined,
+ options: [
+ { id: "keep", label: "Use the existing draft" },
+ { id: "fresh", label: "Start fresh" },
+ ],
+ }],
+ },
+};
+
+const mobileDetailedQuestionInteraction = {
+ ...pendingAskUserQuestionsInteraction,
+ id: "interaction-mobile-detailed-question",
+ payload: {
+ ...pendingAskUserQuestionsInteraction.payload,
+ questions: [pendingAskUserQuestionsInteraction.payload.questions[0]],
+ },
+};
+
+function InteractionAboveComposer({ interaction, mobile = false }: { interaction: IssueThreadInteraction; mobile?: boolean }) {
+ const [workMode, setWorkMode] = useState
("standard");
+ const [open, setOpen] = useState(true);
+ const [pending, setPending] = useState(true);
+ const [messages, setMessages] = useState([]);
+ const resolve = () => { setPending(false); setOpen(false); };
+ const content = (
+ resolve()}
+ onRejectInteraction={async () => resolve()}
+ onSubmitInteractionAnswers={async () => resolve()}
+ onSubmitInteractionVerdicts={async () => resolve()}
+ />
+ );
+ const composer = (
+ setMessages((current) => [...current, body])}
+ workMode={workMode}
+ onWorkModeChange={setWorkMode}
+ enableReassign
+ currentAssigneeValue="agent:codex"
+ reassignOptions={assignees}
+ agentMap={agentMap}
+ mobile={mobile}
+ takeover={pending && open ? {
+ id: interaction.id,
+ label: interaction.title ?? "Pending input",
+ pendingCount: 1,
+ content,
+ onDismiss: () => setOpen(false),
+ onSkip: resolve,
+ inlineSkip: interaction.kind === "ask_user_questions" || interaction.kind === "request_item_verdicts",
+ hideSkip: interaction.kind !== "ask_user_questions" && interaction.kind !== "request_item_verdicts",
+ hideLabel: interaction.kind === "request_confirmation" && Boolean(interaction.payload.toolAction),
+ } : null}
+ pendingTakeover={pending ? { count: 1, label: "1 pending input", onOpen: () => setOpen(true) } : null}
+ />
+ );
+
+ if (mobile) return (
+
+
+
+ PAP-1715 · Interaction review
+
+
+
+
+
+ {messages.map((body, index) =>
{body}
)}
+
+
+
{composer}
+
+
+
+
+ );
+
+ return (
+
+
+
+
+ {messages.map((body, index) =>
{body}
)}
+
+ {composer}
+
+
+ );
+}
+
+const meta = {
+ title: "Composer/Interaction above composer",
+ component: InteractionAboveComposer,
+ parameters: {
+ layout: "fullscreen",
+ options: { showPanel: false },
+ docs: { description: { component: "The production task interaction card and composer shown together. Dismissal leaves a pending indicator; Skip or a decision resolves the card. The editor remains available throughout." } },
+ },
+ args: { interaction: pendingAskUserQuestionsInteraction, mobile: false },
+} satisfies Meta;
+export default meta;
+type Story = StoryObj;
+
+export const AskUserQuestions: Story = {};
+export const AskUserQuestionsAndSend: Story = {
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const editor = canvas.getByTestId("mdx-editor");
+ await userEvent.click(editor);
+ await userEvent.type(editor, "I can keep working while I decide.");
+ await userEvent.click(canvas.getByRole("button", { name: "Send" }));
+ await expect(canvas.getByText("I can keep working while I decide.")).toBeVisible();
+ await expect(canvas.getByTestId("task-chat-composer-takeover")).toBeVisible();
+ },
+};
+export const PlanReview: Story = { args: { interaction: pendingRequestConfirmationInteraction } };
+export const SimpleConfirmation: Story = { args: { interaction: genericPendingRequestConfirmationInteraction } };
+export const CheckboxConfirmation: Story = { args: { interaction: pendingRequestCheckboxConfirmationInteraction } };
+export const ItemVerdicts: Story = { args: { interaction: pendingRequestItemVerdictsInteraction } };
+export const SuggestedTasks: Story = { args: { interaction: pendingSuggestedTasksInteraction } };
+export const ToolReview: Story = { args: { interaction: pendingToolActionWriteInteraction } };
+
+const runtimeRequest: TaskChatRuntimeRequestItem = {
+ id: "runtime-question",
+ kind: "protocol",
+ surface: "runtime_request",
+ runId: "storybook-run",
+ requestId: "runtime-question",
+ requestKind: "user_input",
+ turnId: "storybook-turn",
+ requestType: "input",
+ status: "pending",
+ prompt: "The agent needs your input to continue.",
+ choices: [],
+ fields: [],
+ questionSet: {
+ schema: "paperclip.question_set.v1",
+ title: "Runtime question",
+ questions: [{
+ id: "environment",
+ prompt: "Which environment should the run target?",
+ answerMode: "single_select",
+ required: true,
+ options: [{ id: "staging", label: "Staging" }, { id: "production", label: "Production" }],
+ }],
+ },
+};
+
+function RuntimeQuestionAboveComposer() {
+ const [open, setOpen] = useState(true);
+ return
+
+ {}}
+ workMode="standard"
+ takeover={open ? {
+ id: runtimeRequest.id,
+ label: "Runtime question",
+ pendingCount: 1,
+ content: setOpen(false)} />,
+ onDismiss: () => setOpen(false),
+ onSkip: () => setOpen(false),
+ inlineSkip: true,
+ } : null}
+ />
+
+
;
+}
+
+export const RuntimeQuestion: Story = { render: () => };
+export const MobileQuestionsWithBottomBar: Story = {
+ args: { interaction: mobileQuestionInteraction, mobile: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.getByTestId("task-chat-composer-takeover")).toBeVisible();
+ await expect(canvas.getByTestId("task-chat-composer-input")).toBeVisible();
+ await expect(canvas.getByRole("navigation", { name: "Mobile navigation" })).toBeVisible();
+ },
+};
+export const MobileDetailedQuestionsWithBottomBar: Story = {
+ name: "Mobile detailed questions with bottom bar",
+ args: { interaction: mobileDetailedQuestionInteraction, mobile: true },
+ globals: { viewport: { value: "mobile", isRotated: false } },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ const card = canvas.getByTestId("task-chat-composer-takeover");
+ await expect(canvas.getByRole("radio", { name: /Only collapse hidden descendants/ })).toBeVisible();
+ await expect(canvas.getByRole("radio", { name: /Collapse all descendants by default/ })).toBeVisible();
+ await expect(canvas.getByRole("radio", { name: "Other" })).toBeVisible();
+ await expect(card.scrollHeight).toBeLessThanOrEqual(card.clientHeight);
+ const composer = canvas.getByTestId("task-chat-composer-input");
+ const nav = canvas.getByRole("navigation", { name: "Mobile navigation" });
+ await expect(composer).toBeVisible();
+ await expect(card.getBoundingClientRect().bottom).toBeLessThan(composer.getBoundingClientRect().top);
+ await expect(composer.getBoundingClientRect().bottom).toBeLessThan(nav.getBoundingClientRect().top);
+ },
+};
+export const MobileConfirmationWithBottomBar: Story = {
+ args: { interaction: genericPendingRequestConfirmationInteraction, mobile: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+};
+export const MobileToolReviewWithBottomBar: Story = {
+ args: { interaction: pendingToolActionWriteInteraction, mobile: true },
+ globals: { viewport: { value: "mobile1", isRotated: false } },
+};
diff --git a/ui/storybook/stories/task-execution-controls.stories.tsx b/ui/storybook/stories/task-execution-controls.stories.tsx
index c6f748934d..27d72d4340 100644
--- a/ui/storybook/stories/task-execution-controls.stories.tsx
+++ b/ui/storybook/stories/task-execution-controls.stories.tsx
@@ -318,7 +318,7 @@ function TaskExecutionExample({
}
const meta = {
- title: "Tasks/Execution Controls",
+ title: "Composer/Execution controls",
component: TaskExecutionExample,
parameters: { layout: "padded" },
} satisfies Meta;
From faf72cb1d5663bee913826b55e7ddb280ccb4185 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Mon, 28 Sep 2026 16:15:14 -0700
Subject: [PATCH 3/8] fix: preserve company context across browser hot reload
(#14482)
## Thinking Path
> - Paperclip uses a shared company context for browser providers and
consumers.
> - Vite can load a new consumer module while an older provider is
mounted.
> - Recreating the context disconnects that consumer from the mounted
provider.
> - The consumer then reports a missing provider even though one is in
its React ancestry.
> - This change preserves the context object across development module
refreshes.
> - Bounded global error diagnostics distinguish development bundles and
otherwise context-free promise rejections.
## Linked Issues or Issue Description
**What happened?**
A refreshed company consumer can throw `useCompany must be used within a
CompanyProvider`. A real Vite and Chromium reproduction confirms that a
retained provider and a refreshed consumer can hold different context
objects. Global promise rejections also lack the bounded document state
already attached to React boundary errors.
**Expected behavior**
A refreshed consumer should read the mounted provider. Error reports
should identify the loaded bundle mode and bounded browser state while
preserving monitoring opt-in, sign-out, and privacy behavior.
**Steps to reproduce**
Run `pnpm test:e2e:browser-context`. The isolated Vite fixture renders
the real CompanyProvider, imports a new timestamped consumer module, and
renders that consumer below the retained provider. The test fails before
the context change and passes after it. The SDK regression invokes its
real unhandled-rejection handler with an undefined reason.
## What Changed
- Keep the React context object in Vite's per-module `hot.data`. Account
values stay in React.
- Add an isolated browser regression with mocked API responses and no
live instance, discovered by the existing Chrome CI shards.
- Add document-state diagnostics to global errors while preserving
earlier boundary snapshots.
- Tag events with development or production bundle mode and the type of
an unhandled rejected value.
- Document the new test command and diagnostic fields.
## Verification
- Company context, browser context, and Sentry suites: 59 passed.
- `pnpm test:e2e:browser-context`: passed in Chromium. The original
context code fails the reproduction.
- Real SDK tests preserve DSN/sign-out behavior and omit request
context, breadcrumbs, and private DOM data.
- `pnpm check:token-gates`: passed.
- `pnpm -r typecheck`: passed.
- `pnpm build`: passed.
- Local `pnpm test:run` exited in the general-server phase: 13,819
passed, 86 skipped, 21 failures in unchanged filesystem and host-port
suites. Cache permission and long-path failures also reproduce on the
unmodified base; seven other failures involve local runtime port
ownership. This is not a full local pass.
- Full Linux CI and review passed at a0b5270662 (53 successful checks;
two conditional checks skipped). Three jobs interrupted by a runner
shutdown passed on rerun. Greptile is 5/5 with no unresolved comments.
The real browser regression also passed in the normal Chrome CI shard
(3.2 seconds).
- Code-owner approval remains required because the dedicated test
command changes `package.json`.
- Scanned the diff and PR text for credentials and private data before
pushing.
## Risks
The context cache applies only to development hot reload. It retains the
context object, not account state. Production continues to create an
ordinary React context. The diagnostic hook adds only bounded state and
type values, preserves boundary snapshots, and returns the original
event if enrichment fails. It does not suppress errors or restore raw
breadcrumbs. The global rejection diagnostics do not identify the
promise's originating operation by themselves.
## Model Used
OpenAI GPT-6 through Codex, with repository inspection, code editing,
and command execution. The runtime does not expose a more specific model
revision 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 (focused suites and
Chromium regression; full local limitations documented 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 | 13 ++++
doc/observability.md | 12 ++-
package.json | 1 +
tests/e2e/company-context-refresh.spec.ts | 77 +++++++++++++++++++
.../e2e/playwright-company-context.config.ts | 16 ++++
ui/src/context/CompanyContext.tsx | 7 +-
ui/src/lib/browser-error-context.ts | 13 +++-
ui/src/lib/sentry.test.ts | 51 +++++++++---
ui/src/lib/sentry.ts | 29 ++++++-
9 files changed, 199 insertions(+), 20 deletions(-)
create mode 100644 tests/e2e/company-context-refresh.spec.ts
create mode 100644 tests/e2e/playwright-company-context.config.ts
diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md
index e07ba3f82c..317bdbbefe 100644
--- a/doc/DEVELOPING.md
+++ b/doc/DEVELOPING.md
@@ -1641,3 +1641,16 @@ disconnected, visible active queries refresh every 15 seconds. This fallback
stops when the socket opens, the tab is hidden, or the provider unmounts. A
reconnected socket also refreshes visible queries to recover missed events.
Run log views retain their existing HTTP polling fallback.
+
+### Company context during hot reload
+
+The company React context retains only its object identity in Vite's per-module
+`hot.data`. Provider values remain in the mounted React tree and keep their normal
+account scope. This lets a refreshed consumer read a provider from the preceding
+module version. Production builds do not use the development cache.
+
+Run `pnpm test:e2e:browser-context` to test this with real Vite modules and
+Chromium. The test starts its own loopback Vite server and mocks API responses;
+it needs no running Paperclip instance or provider credentials. The same spec lives
+in the default `test:e2e` discovery tree, so the existing Chrome CI shards run it
+on pull requests.
diff --git a/doc/observability.md b/doc/observability.md
index 7b5bef23c8..fb2a8b15ee 100644
--- a/doc/observability.md
+++ b/doc/observability.md
@@ -418,13 +418,21 @@ Application and route error-boundary reports also include:
URLs, arguments, and unrecognized lines are omitted. Parsing examines at
most 16 KiB of input. Production builds preserve function names so this
trace remains useful after minification; this adds some bundle size.
+
+All browser error reports, including global promise rejections, include:
+
+- `browser_build_mode`: `development` or `production`, from the loaded bundle.
+- `browser_rejection_kind`: the primitive type of an unhandled rejected value
+ (or `null`). The diagnostic does not read object properties or copy the value.
- `browser_state`: document readiness, visibility, and a boolean indicating
the `translated-ltr` or `translated-rtl` root class used by browser translation.
The marker is evidence of DOM translation, not proof of the error's cause;
its absence does not exclude other translators or DOM-changing extensions.
-These fields are captured at the failure, before asynchronous reporting, and
-attached only to that event. They include no component props, DOM text, HTML,
+Boundary state is captured at the failure, before asynchronous reporting.
+Global reports without that snapshot read document state before sending.
+All fields are attached only to that event. They include no component props,
+DOM text, HTML,
element identifiers, arbitrary CSS classes, route, or query string. Failed
diagnostic reads do not prevent the original exception from being reported.
The monitoring gate and sign-out behavior still apply. This context does not
diff --git a/package.json b/package.json
index f5566737bf..fc3c8b0bae 100644
--- a/package.json
+++ b/package.json
@@ -71,6 +71,7 @@
"test:e2e:runner:models:update": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/openrouter-models-update.ts",
"test:e2e:runner:history:publish": "node cli/node_modules/tsx/dist/cli.mjs tests/runner-e2e/history-publish.ts",
"test:runner-recovery": "vitest run server/src/services/native-runtime/native-replacement-evidence.test.ts server/src/services/native-runtime/stopped-codex-turn.test.ts server/src/services/native-runtime/native-safe-replacement.test.ts",
+ "test:e2e:browser-context": "playwright test --config tests/e2e/playwright-company-context.config.ts",
"test:e2e:runner:browser-support": "playwright test --config tests/runner-e2e/playwright-support.config.ts",
"test:e2e:runner:unit": "vitest run --config tests/runner-e2e/vitest.config.ts",
"test:e2e:runner:typecheck": "tsc -p tests/runner-e2e/tsconfig.json",
diff --git a/tests/e2e/company-context-refresh.spec.ts b/tests/e2e/company-context-refresh.spec.ts
new file mode 100644
index 0000000000..309bd841b8
--- /dev/null
+++ b/tests/e2e/company-context-refresh.spec.ts
@@ -0,0 +1,77 @@
+import path from "node:path";
+import { expect, test } from "@playwright/test";
+import { createServer, type ViteDevServer } from "../../ui/node_modules/vite/dist/node/index.js";
+
+let server: ViteDevServer;
+let origin: string;
+const uiRoot = path.resolve(import.meta.dirname, "../../ui");
+const probePath = path.join(uiRoot, "src/__company_context_probe.tsx");
+
+// Use real Vite module instances and React contexts. Re-importing a refreshed
+// consumer must still see a provider retained from the previous module version.
+test.beforeAll(async () => {
+ server = await createServer({
+ root: uiRoot,
+ configFile: false,
+ resolve: { alias: { "@": path.join(uiRoot, "src") } },
+ esbuild: { jsx: "automatic" },
+ server: { host: "127.0.0.1", port: 0 },
+ plugins: [{
+ name: "company-context-regression",
+ resolveId(id) { if (id === "/src/__company_context_probe.tsx") return probePath; },
+ load(id) {
+ if (id !== probePath) return;
+ return `
+ import React from "react";
+ import { createRoot } from "react-dom/client";
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+ import { CompanyProvider, useCompany } from "/src/context/CompanyContext.tsx";
+ const root = createRoot(document.getElementById("root"));
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ function render(hook, generation) {
+ function Consumer() { return {generation + ":" + (hook().selectedCompanyId ?? "loading")} ; }
+ root.render( );
+ }
+ render(useCompany, "initial");
+ window.refreshConsumer = async () => {
+ const refreshed = await import(/* @vite-ignore */ "/src/context/CompanyContext.tsx?t=" + Date.now());
+ render(refreshed.useCompany, "refreshed");
+ };
+ `;
+ },
+ configureServer(vite) {
+ vite.middlewares.use((req, res, next) => {
+ if (req.url !== "/") return next();
+ res.setHeader("Content-Type", "text/html");
+ res.end('
');
+ });
+ },
+ }],
+ });
+ await server.listen();
+ const address = server.httpServer!.address();
+ if (!address || typeof address === "string") throw new Error("Missing test server address");
+ origin = `http://127.0.0.1:${address.port}`;
+});
+
+test.afterAll(async () => { await server?.close(); });
+
+test("a refreshed company consumer reads the retained provider", async ({ page }) => {
+ await page.route("**/api/**", async (route) => {
+ const pathname = new URL(route.request().url()).pathname;
+ if (!pathname.startsWith("/api/")) return route.continue();
+ const body = pathname === "/api/auth/get-session"
+ ? { user: { id: "test-user" }, session: { userId: "test-user" } }
+ : pathname === "/api/companies"
+ ? [{ id: "test-company", name: "Test company", status: "active" }]
+ : [];
+ await route.fulfill({ json: body });
+ });
+ const errors: string[] = [];
+ page.on("pageerror", (error) => { errors.push(error.message); });
+ await page.goto(origin);
+ await expect(page.getByRole("main")).toHaveText("initial:test-company");
+ await page.evaluate(() => (window as unknown as { refreshConsumer: () => Promise }).refreshConsumer());
+ await expect(page.getByRole("main")).toHaveText("refreshed:test-company");
+ expect(errors).toEqual([]);
+});
diff --git a/tests/e2e/playwright-company-context.config.ts b/tests/e2e/playwright-company-context.config.ts
new file mode 100644
index 0000000000..15c5f3a830
--- /dev/null
+++ b/tests/e2e/playwright-company-context.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ testDir: ".",
+ testMatch: "company-context-refresh.spec.ts",
+ workers: 1,
+ timeout: 30_000,
+ use: {
+ headless: true,
+ ...(process.env.PAPERCLIP_PLAYWRIGHT_CHANNEL
+ ? { channel: process.env.PAPERCLIP_PLAYWRIGHT_CHANNEL }
+ : {}),
+ },
+ outputDir: "./test-results",
+ reporter: "list",
+});
diff --git a/ui/src/context/CompanyContext.tsx b/ui/src/context/CompanyContext.tsx
index 64e41ae37c..677875216f 100644
--- a/ui/src/context/CompanyContext.tsx
+++ b/ui/src/context/CompanyContext.tsx
@@ -6,6 +6,7 @@ import {
useMemo,
useRef,
useState,
+ type Context,
type ReactNode,
} from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
@@ -46,7 +47,11 @@ interface CompanyContextValue {
const STORAGE_KEY = "paperclip.selectedCompanyId";
-const CompanyContext = createContext(null);
+// A refresh can replace consumers before a mounted provider is replaced. Keep
+// their context identity in Vite's per-module data; never store account state.
+const CompanyContext: Context =
+ import.meta.hot?.data?.companyContext ?? createContext(null);
+if (import.meta.hot?.data) import.meta.hot.data.companyContext = CompanyContext;
export function resolveBootstrapCompanySelection(input: {
companies: Array>;
diff --git a/ui/src/lib/browser-error-context.ts b/ui/src/lib/browser-error-context.ts
index b0b7b89644..700e6f62dd 100644
--- a/ui/src/lib/browser-error-context.ts
+++ b/ui/src/lib/browser-error-context.ts
@@ -21,9 +21,8 @@ export function sanitizeComponentStack(stack: string | null | undefined): string
return frames.length ? `\n${frames.join("\n")}` : undefined;
}
-/** A bounded snapshot, taken at the error rather than when the capture queue runs. */
-export function buildBrowserErrorContext(details: BrowserErrorDetails) {
- const componentStack = sanitizeComponentStack(details.componentStack);
+/** Bounded document state; never read page content, locations, or identifiers. */
+export function readBrowserErrorState() {
const browserState: {
ready_state?: "loading" | "interactive" | "complete";
visibility_state?: "visible" | "hidden";
@@ -47,11 +46,17 @@ export function buildBrowserErrorContext(details: BrowserErrorDetails) {
browserState.translation_marker = classes.contains("translated-ltr") || classes.contains("translated-rtl");
} catch { /* diagnostic unavailable */ }
}
+ return browserState;
+}
+
+/** A bounded snapshot, taken at the error rather than when the capture queue runs. */
+export function buildBrowserErrorContext(details: BrowserErrorDetails) {
+ const componentStack = sanitizeComponentStack(details.componentStack);
return {
tags: { react_error_boundary: details.boundary },
contexts: {
react: componentStack ? { componentStack } : {},
- browser_state: browserState,
+ browser_state: readBrowserErrorState(),
},
};
}
diff --git a/ui/src/lib/sentry.test.ts b/ui/src/lib/sentry.test.ts
index 734be7edf1..4e87c29b20 100644
--- a/ui/src/lib/sentry.test.ts
+++ b/ui/src/lib/sentry.test.ts
@@ -327,7 +327,11 @@ describe("browser error diagnostics with the real SDK", () => {
init: (options: Parameters[0]) => Sentry.init({
...options,
transport: () => ({ send: async () => ({}), flush: async () => true }),
- beforeSend: (event) => { events.push(event as unknown as Record); return event; },
+ beforeSend: async (event, hint) => {
+ const enriched = await options?.beforeSend?.(event, hint) ?? event;
+ events.push(enriched as unknown as Record);
+ return enriched;
+ },
}),
}));
const gate = await importFreshSentry();
@@ -354,7 +358,7 @@ describe("browser error diagnostics with the real SDK", () => {
},
});
expect(events[1]).not.toHaveProperty("contexts.react");
- expect(events[1]).not.toHaveProperty("contexts.browser_state");
+ expect(events[1]).toHaveProperty("contexts.browser_state.translation_marker", false);
expect(events[1]).not.toHaveProperty("tags.react_error_boundary");
for (const event of events) {
expect(event).not.toHaveProperty("request");
@@ -424,12 +428,12 @@ describe("buildBrowserSentryInitOptions", () => {
expect(options.tracesSampleRate).toBe(0);
});
- it("holds no beforeSend hook and no custom filter function", async () => {
+ it("adds diagnostics without a transaction filter", async () => {
const { buildBrowserSentryInitOptions } = await importFreshSentry();
const options = buildBrowserSentryInitOptions(DSN);
- expect(options.beforeSend).toBeUndefined();
+ expect(options.beforeSend).toBeTypeOf("function");
expect(options.beforeSendTransaction).toBeUndefined();
});
@@ -467,10 +471,8 @@ describe("captured event shape against the real @sentry/browser SDK", () => {
/**
* Initialize the real SDK with this module's exact options, plus a
* transport stub so no event leaves the test process, plus `beforeSend`
- * so the test can inspect the resolved event before it would have been
- * sent. `beforeSend` here is test-only introspection — the shipped module
- * adds no `beforeSend` of its own (see the "holds no beforeSend hook"
- * test above).
+ * so the test can inspect the resolved event after the production diagnostic
+ * hook runs, before it would have been sent.
*/
async function initRealSentryForTest(
onEvent: (event: Record) => void,
@@ -478,17 +480,42 @@ describe("captured event shape against the real @sentry/browser SDK", () => {
) {
const { buildBrowserSentryInitOptions } = await importFreshSentry();
const Sentry = await import("@sentry/browser");
+ const options = buildBrowserSentryInitOptions(DSN, environment);
Sentry.init({
- ...buildBrowserSentryInitOptions(DSN, environment),
+ ...options,
transport: () => ({ send: async () => ({}), flush: async () => true }),
- beforeSend: (event) => {
- onEvent(event as unknown as Record);
- return event;
+ beforeSend: async (event, hint) => {
+ const enriched = await options.beforeSend?.(event, hint) ?? event;
+ onEvent(enriched as unknown as Record);
+ return enriched;
},
});
return Sentry;
}
+ it("enriches an undefined global rejection without page data or suppression", async () => {
+ let captured: Record | null = null;
+ const Sentry = await initRealSentryForTest((event) => { captured = event; }, "staging");
+ const previousHandler = window.onunhandledrejection;
+ try {
+ expect(previousHandler).toBeTypeOf("function");
+ document.documentElement.classList.add("translated-ltr", "private-class");
+ window.onunhandledrejection?.call(window, { reason: undefined } as PromiseRejectionEvent);
+ await Sentry.flush(2000);
+ expect(captured).toMatchObject({
+ tags: { browser_rejection_kind: "undefined", browser_build_mode: "development" },
+ contexts: { browser_state: { translation_marker: true } },
+ exception: { values: [{ type: "UnhandledRejection" }] },
+ });
+ expect(captured).not.toHaveProperty("request");
+ expect((captured as unknown as Record).breadcrumbs).toBeUndefined();
+ expect(JSON.stringify(captured)).not.toContain("private-class");
+ } finally {
+ document.documentElement.classList.remove("translated-ltr", "private-class");
+ await Sentry.close();
+ }
+ });
+
it.each([
["staging", "staging"],
["production", "production"],
diff --git a/ui/src/lib/sentry.ts b/ui/src/lib/sentry.ts
index 7d714793dc..852e2b855b 100644
--- a/ui/src/lib/sentry.ts
+++ b/ui/src/lib/sentry.ts
@@ -38,7 +38,7 @@
// (`GlobalHandlers`), the two React error boundaries, deduplicates a repeat
// event (`Dedupe`), and links a caused-by chain (`LinkedErrors`).
-import { buildBrowserErrorContext, type BrowserErrorDetails } from "./browser-error-context";
+import { buildBrowserErrorContext, readBrowserErrorState, type BrowserErrorDetails } from "./browser-error-context";
let queue: Promise = Promise.resolve();
@@ -175,6 +175,33 @@ export function buildBrowserSentryInitOptions(
: undefined,
tracesSampleRate: 0,
sendDefaultPii: false,
+ beforeSend: (event, hint) => {
+ // Global handlers do not pass through our React boundaries. Give their
+ // reports the same bounded document state, without URLs or breadcrumbs.
+ // Preserve a boundary's earlier snapshot across the asynchronous queue.
+ try {
+ event.contexts = {
+ ...event.contexts,
+ browser_state: event.contexts?.browser_state ?? readBrowserErrorState(),
+ };
+ event.tags = {
+ ...event.tags,
+ browser_build_mode: import.meta.env.DEV ? "development" : "production",
+ };
+ if (event.exception?.values?.some((value) =>
+ value.mechanism?.type === "onunhandledrejection"
+ || value.mechanism?.type === "auto.browser.global_handlers.onunhandledrejection",
+ )) {
+ // The SDK supplies the rejected value directly. Classify without
+ // reading object properties, coercing strings, or copying its value.
+ const reason = hint.originalException;
+ event.tags.browser_rejection_kind = reason === null ? "null" : typeof reason;
+ }
+ } catch {
+ // Diagnostics must not replace or discard the original error.
+ }
+ return event;
+ },
integrations: (defaults) =>
defaults.filter(
(integration) => integration.name !== "HttpContext" && integration.name !== "Breadcrumbs",
From 4f6cf5b3ffda14ff8922f2cddeceb52d2bd545b7 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Mon, 28 Sep 2026 16:16:05 -0700
Subject: [PATCH 4/8] fix: prevent run identity locks from blocking audit
checks (#14478)
Use NO KEY UPDATE for identity locks so audit foreign-key checks can proceed while identity writers remain serialized. Preserve task-before-run ordering, company scoping, and foreign keys.
Verified with PostgreSQL concurrency regressions, focused tests, typecheck, build, and full CI.
Co-Authored-By: Paperclip
---
doc/DATABASE.md | 9 +++++
server/src/__tests__/run-identity.test.ts | 47 +++++++++++++++++++++--
server/src/services/run-identity.ts | 18 ++++++---
3 files changed, 65 insertions(+), 9 deletions(-)
diff --git a/doc/DATABASE.md b/doc/DATABASE.md
index 3c82a93a2b..151581873d 100644
--- a/doc/DATABASE.md
+++ b/doc/DATABASE.md
@@ -165,6 +165,15 @@ idempotent actor synchronization operations, not arbitrary transactions. A
persistent outage still fails the request after the bounded retries; each
connection attempt remains subject to the configured database connect timeout.
+## Execution identity row locks
+
+Identity initialization, credential acquisition, and steering reconciliation lock
+the task before its run. These operations use `FOR NO KEY UPDATE`: they change
+identity state, not parent keys. The lock still serializes identity writers and
+blocks concurrent task or run updates. It allows audit inserts to retain their
+foreign-key `KEY SHARE` locks without waiting on identity acquisition. The audit
+foreign keys and their deletion behavior remain enforced.
+
## Switching between modes
The database mode is controlled by `DATABASE_URL`:
diff --git a/server/src/__tests__/run-identity.test.ts b/server/src/__tests__/run-identity.test.ts
index 9155a9b9fa..d6dbbbfc3f 100644
--- a/server/src/__tests__/run-identity.test.ts
+++ b/server/src/__tests__/run-identity.test.ts
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
-import { agentWakeupRequests, agents, companies, createDb, heartbeatRuns, heartbeatRunEvents, issueComments, issueThreadInteractions, issues } from "@paperclipai/db";
+import { agentWakeupRequests, agents, companies, createDb, heartbeatRuns, heartbeatRunEvents, issueComments, issueThreadInteractions, issues, secretAccessEvents } from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { acceptSteeredIdentity, captureRunIdentity, initializeRunIdentity, listRunIdentityContexts, rejectSteeredIdentity, reserveSteeredIdentity } from "../services/run-identity.js";
@@ -217,11 +217,11 @@ const support = await getEmbeddedPostgresTestSupport();
}
});
- it("does not deadlock identity initialization against a task mutation that also updates the run", async () => {
+ it.each(["update", "no key update"] as const)("serializes identity initialization behind a task's %s lock", async (lockMode) => {
const input = await seed();
let initialization!: ReturnType;
await db.transaction(async (tx) => {
- await tx.select().from(issues).where(eq(issues.id, input.issueId)).for("update");
+ await tx.select().from(issues).where(eq(issues.id, input.issueId)).for(lockMode);
const [backend] = await tx.execute(sql`select pg_backend_pid() as pid`) as unknown as Array<{ pid: number }>;
initialization = initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
// Wait until initialization is blocked by this task mutation, rather than
@@ -241,6 +241,47 @@ const support = await getEmbeddedPostgresTestSupport();
await expect(initialization).resolves.toMatchObject({ responsibleUserId: "A" });
});
+ it.each(["initialize", "capture"] as const)(
+ "can %s identity while an audit append holds foreign-key locks",
+ async (operation) => {
+ const input = await seed();
+ if (operation === "capture") {
+ await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "instruction" });
+ }
+ // A real append holds KEY SHARE on both parent rows until it commits.
+ // Bound the other connection's wait so a conflicting lock fails this
+ // regression instead of leaving both transactions waiting for each other.
+ const identityDb = createDb(`${database.connectionString}?options=-c%20lock_timeout%3D1000`, {
+ maxConnections: 1,
+ });
+ const [settings] = await identityDb.execute(sql`show lock_timeout`);
+ expect(settings?.lock_timeout).toBe("1s");
+ await db.transaction(async (audit) => {
+ await audit.insert(secretAccessEvents).values({
+ companyId: input.companyId,
+ heartbeatRunId: input.runId,
+ issueId: input.issueId,
+ provider: "local_encrypted",
+ actorType: "agent",
+ actorId: input.agentId,
+ consumerType: "agent",
+ consumerId: input.agentId,
+ outcome: "granted",
+ });
+ if (operation === "initialize") {
+ await expect(initializeRunIdentity(identityDb, {
+ ...input, messageIds: [], responsibleUserId: "A", cause: "instruction",
+ })).resolves.toMatchObject({ responsibleUserId: "A" });
+ } else {
+ await expect(captureRunIdentity(identityDb, input)).resolves.toMatchObject({
+ run: { responsibleUserId: "A" },
+ context: { responsibleUserId: "A" },
+ });
+ }
+ });
+ },
+ );
+
it("does not turn a company-default fallback into personal consent on continuation", async () => {
const input = await seed();
await initializeRunIdentity(db, { ...input, messageIds: [], responsibleUserId: "A", cause: "company_default" });
diff --git a/server/src/services/run-identity.ts b/server/src/services/run-identity.ts
index dfc5f5bb89..496b58719d 100644
--- a/server/src/services/run-identity.ts
+++ b/server/src/services/run-identity.ts
@@ -57,7 +57,13 @@ export async function explicitOperatorRunIdentity(
export type RunIdentityContext = typeof runIdentityContexts.$inferSelect;
type Executor = Pick;
-/** Match task mutation ordering: lock the task before the run, never the reverse. */
+/**
+ * Lock the task before the run, matching task mutation ordering. Identity
+ * operations do not change parent keys: NO KEY UPDATE still serializes writers
+ * and steering, while allowing audit inserts to check their foreign keys.
+ * FOR UPDATE can deadlock with an append that holds KEY SHARE on the run and
+ * then checks the task while identity capture holds the task and waits on the run.
+ */
async function lockIdentityTask(
executor: Pick,
companyId: string,
@@ -85,7 +91,7 @@ async function lockIdentityTask(
.select({ id: issues.id })
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)))
- .for("update");
+ .for("no key update");
}
async function append(
@@ -181,7 +187,7 @@ export async function initializeRunIdentity(
eq(heartbeatRuns.companyId, input.companyId),
),
)
- .for("update");
+ .for("no key update");
if (!run) throw forbidden("Run identity does not belong to this company");
if (run.activeIdentityContextId) {
const [current] = await tx
@@ -345,7 +351,7 @@ export async function reserveSteeredIdentity(
eq(heartbeatRuns.companyId, input.companyId),
),
)
- .for("update");
+ .for("no key update");
// Processes started before the broker rollout keep their original environment.
if (!run?.activeIdentityContextId) return null;
const [pending] = await tx
@@ -439,7 +445,7 @@ export async function captureRunIdentity(
eq(heartbeatRuns.agentId, input.agentId),
),
)
- .for("update");
+ .for("no key update");
if (!run || run.status !== "running")
throw forbidden(
"Credential acquisition requires this agent's active run",
@@ -512,7 +518,7 @@ export async function reconcileSteeredIdentity(
eq(heartbeatRuns.companyId, context.companyId),
),
)
- .for("update");
+ .for("no key update");
if (!run) return;
await acceptSteeredIdentity(tx, context);
});
From ad1f7e98ead66595c4d73a5e18801dec494bc0ee Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Mon, 28 Sep 2026 16:16:19 -0700
Subject: [PATCH 5/8] fix: retain diagnostic reasons for native runner failures
(#14481)
Retain bounded reasons for runner identity, harness recovery, and provider-pack read failures. Preserve existing ownership and cleanup proofs and compatibility with receipt-gated chat recovery.
Verified with executor, recovery, diagnostic privacy, typecheck, build, and full CI checks.
Co-Authored-By: Paperclip
---
doc/observability.md | 11 ++++
.../chat-channels.integration.test.ts | 14 +++--
server/src/services/chat-channels.ts | 6 +-
.../native-session-executor.test.ts | 60 ++++++++++++++++++-
.../native-runtime/native-session-executor.ts | 60 +++++++++++--------
5 files changed, 117 insertions(+), 34 deletions(-)
diff --git a/doc/observability.md b/doc/observability.md
index fb2a8b15ee..491e8e35c8 100644
--- a/doc/observability.md
+++ b/doc/observability.md
@@ -486,6 +486,17 @@ These fields contain build identifiers; they add no tenant or user identity.
`errorCode`, and `agentAdapter`. The server redacts the error message and
the error code before it sends the event.
+Native runner identity and harness failures retain their existing error prefixes.
+Their terminal messages now include a bounded guard reason, such as
+`session_scope_mismatch`, `durable_identity_unreadable`, or
+`backup_without_reusable_lease`. Provider-pack read failures distinguish a missing
+file, invalid JSON, permission denial, invalid path type, and other I/O errors.
+These reasons contain no session identifiers, provider output, or filesystem
+paths. They help diagnose recurrence; they do not authorize a retry, quarantine,
+replacement, or a weaker identity check. Existing chat recovery recognizes the
+same failure category with or without a reason suffix; it still requires the
+exact cleanup receipt, checkpoint, and absence of provider work.
+
**Server events the default integrations add**
- `OnUncaughtException` — each uncaught exception on the main thread, at
diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts
index 85d968cbc1..d04154ee97 100644
--- a/server/src/__tests__/chat-channels.integration.test.ts
+++ b/server/src/__tests__/chat-channels.integration.test.ts
@@ -56157,9 +56157,13 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
"leased",
"wrong_thread",
"source_edited",
- ])(
- "retries only the original pre-provider Telegram request after exact cleanup: %s",
- async (mode) => {
+ "different_error",
+ ].flatMap((mode) => [
+ "runner_state_identity_mismatch",
+ "runner_state_identity_mismatch: prior_owner_active",
+ ].map((errorMessage) => ({ mode, errorMessage }))))(
+ "retries only the original pre-provider Telegram request after exact cleanup: $mode ($errorMessage)",
+ async ({ mode, errorMessage }) => {
const context = await committedChatResponseRecoveryFixture("telegram");
const providerAccount =
mode === "null_account"
@@ -56286,7 +56290,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
agentId: context.fixture.assignedAgentId,
status: "failed",
errorCode: "adapter_failed",
- error: "runner_state_identity_mismatch",
+ error: mode === "different_error"
+ ? errorMessage.replace("runner_state_identity_mismatch", "runner_state_identity_mismatch_other")
+ : errorMessage,
finishedAt: new Date(),
wakeupRequestId: action.id,
runtimeMode: "native",
diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts
index dd3694dc6a..a4c67d20b3 100644
--- a/server/src/services/chat-channels.ts
+++ b/server/src/services/chat-channels.ts
@@ -12006,7 +12006,11 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
run.nativeIssueId === issueId &&
run.status === "failed" &&
run.errorCode === "adapter_failed" &&
- run.error === "runner_state_identity_mismatch" &&
+ // A diagnostic reason does not change this failure category. The exact
+ // checkpoint, cleanup receipt, and no-provider-work proofs below still
+ // decide whether the original request can be retried.
+ (run.error === "runner_state_identity_mismatch" ||
+ run.error?.startsWith("runner_state_identity_mismatch: ")) &&
run.nativePhase === "observed" &&
coordinator.phase === "observed" &&
coordinator.attempt === 0 &&
diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts
index d533986d90..b6d7316911 100644
--- a/server/src/services/native-runtime/native-session-executor.test.ts
+++ b/server/src/services/native-runtime/native-session-executor.test.ts
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { readFileSync } from "node:fs";
import {
access,
cp,
@@ -56,6 +57,11 @@ import { buildNativeHeartbeatPreparationSpans } from "./native-run-trace.js";
import { NativeRunnerOwnershipUnverifiedError } from "./native-runner-ownership.js";
import type { AdapterRuntimeEvent } from "../../adapters/index.js";
+vi.mock("node:fs", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, readFileSync: vi.fn(actual.readFileSync) };
+});
+
const githubAccess = vi.hoisted(() => ({
activate: vi.fn((_binding: { runId: string }) => vi.fn()),
stop: vi.fn(async () => undefined),
@@ -1171,6 +1177,54 @@ describe("remote provider pack manifest", () => {
});
});
+describe("provider pack read diagnostics", () => {
+ it.each([
+ ["EACCES", "permission_denied"],
+ ["EPERM", "permission_denied"],
+ ["EIO", "io_error"],
+ ])("reports %s without exposing the underlying filesystem message", (code, reason) => {
+ const root = join(tmpdir(), "private-provider-pack");
+ const manifestPath = join(root, "provider-pack.json");
+ const cause = Object.assign(new Error(`${code}: cannot read ${manifestPath}`), {
+ code,
+ path: manifestPath,
+ });
+ vi.mocked(readFileSync).mockImplementationOnce(() => { throw cause; });
+ let failure: Error | undefined;
+ try { readRemoteProviderPackManifest(root); } catch (error) { failure = error as Error; }
+ expect(readFileSync).toHaveBeenLastCalledWith(manifestPath, "utf8");
+ expect(failure?.message).toBe(`runner_remote_provider_artifact_incompatible: provider-pack.json is unreadable (${reason})`);
+ expect(failure?.message).not.toContain(root);
+ expect(failure?.message).not.toContain(code);
+ expect(failure?.cause).toBe(cause);
+ });
+
+ it("classifies a JSON null manifest as incompatible instead of a TypeError", async () => {
+ const root = await mkdtemp(join(tmpdir(), "paperclip-null-pack-"));
+ try {
+ await writeFile(join(root, "provider-pack.json"), "null");
+ expect(() => readRemoteProviderPackManifest(root)).toThrow(
+ "runner_remote_provider_artifact_incompatible: provider pack pins or source revision do not match",
+ );
+ } finally { await rm(root, { recursive: true, force: true }); }
+ });
+
+ it.each(["missing", "invalid_json", "invalid_path_type"])("reports %s without putting the path in the terminal message", async (reason) => {
+ const root = await mkdtemp(join(tmpdir(), "paperclip-private-pack-"));
+ try {
+ const manifestPath = join(root, "provider-pack.json");
+ if (reason === "invalid_json") await writeFile(manifestPath, "{ private-invalid-json");
+ if (reason === "invalid_path_type") await mkdir(manifestPath);
+ let failure: Error | undefined;
+ try { readRemoteProviderPackManifest(root); } catch (error) { failure = error as Error; }
+ expect(failure?.message).toBe(`runner_remote_provider_artifact_incompatible: provider-pack.json is unreadable (${reason})`);
+ expect(failure?.message).not.toContain(root);
+ expect(failure?.message).not.toContain("private-invalid-json");
+ expect(failure?.cause).toBeDefined();
+ } finally { await rm(root, { recursive: true, force: true }); }
+ });
+});
+
describe("native harness persistence profiles", () => {
const profile = (provider: Record, driverKind: string) =>
resolveNativeHarnessPersistenceProfile({
@@ -1477,7 +1531,7 @@ describe("verified native harness backups", () => {
},
sourceProviderLeaseId: "sandbox-1",
}),
- ).toThrow("runner_harness_state_mismatch");
+ ).toThrow("runner_harness_state_mismatch: backup_provider_identity_missing");
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -9119,7 +9173,7 @@ describe("runnerd provider runtime wiring", () => {
execution: currentExecution,
runnerInstanceId: "runner-after-running-prior-scope",
}),
- ).rejects.toThrow("runner_state_identity_mismatch");
+ ).rejects.toThrow("runner_state_identity_mismatch: prior_owner_active");
await expect(access(scopedRoot)).resolves.toBeUndefined();
await expect(access(join(stateBase, "quarantine"))).rejects.toThrow();
expect(state.createBackend).not.toHaveBeenCalled();
@@ -10691,7 +10745,7 @@ describe("runnerd provider runtime wiring", () => {
}
// Ambiguous ordinary recovery must still fail closed. Only the runtime's
// explicitly admitted replacement may retire these prior-session backups.
- await expect(prepareReplacement()).rejects.toThrow("runner_harness_state_mismatch");
+ await expect(prepareReplacement()).rejects.toThrow("runner_harness_state_mismatch: backup_without_reusable_lease");
await expect(backend.openReplacementSession!({
identity: { runId: execution.binding.runId }, workingDirectory: execution.workspace.cwd,
} as never, {} as never)).resolves.toBe(replacement);
diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts
index 7b682232f8..1e75231a0b 100644
--- a/server/src/services/native-runtime/native-session-executor.ts
+++ b/server/src/services/native-runtime/native-session-executor.ts
@@ -1691,7 +1691,7 @@ function migrateLegacyRunnerdStateRoot(input: {
// A legacy path does not encode the full session scope. A mismatch may be
// valid live state owned by another agent/workspace, so refusing the claim
// is safe but moving that ambiguous directory is not.
- throw new Error("runner_state_identity_mismatch");
+ throw new Error("runner_state_identity_mismatch: legacy_owner_unverified");
}
if (
exactRun &&
@@ -1704,7 +1704,7 @@ function migrateLegacyRunnerdStateRoot(input: {
)
) {
quarantineRunnerdStateRoot(input.legacy, "identity_indeterminate");
- throw new Error("runner_state_identity_mismatch");
+ throw new Error("runner_state_identity_mismatch: legacy_authority_indeterminate");
}
try {
renameSync(input.legacy, input.scoped);
@@ -1728,7 +1728,7 @@ function migrateLegacyRunnerdStateRoot(input: {
durableIdentityMatchesSession(scopedIdentity, input.execution),
);
if (!exactScopedRun && !sameVerifiedPriorRun) {
- throw new Error("runner_state_identity_mismatch");
+ throw new Error("runner_state_identity_mismatch: migration_destination_owner_changed");
}
}
return input.scoped;
@@ -4573,7 +4573,7 @@ async function migrateRunnerdStateRootForExecution(input: {
await recoverQuiescentRunnerdState({ ...input, scoped });
}
if (input.restartRecovery?.kind === "reattach_remote_runner" && !existsSync(scoped)) {
- throw new Error("runner_state_identity_mismatch");
+ throw new Error("runner_state_identity_mismatch: remote_reattach_root_missing");
}
if (existsSync(scoped)) {
if (!isSafeNativeStateDirectory(scoped)) {
@@ -4593,14 +4593,14 @@ async function migrateRunnerdStateRootForExecution(input: {
input.restartRecovery?.kind !== "reattach_remote_runner") {
quarantineRunnerdStateRoot(scoped, "identity_indeterminate");
}
- throw new Error("runner_state_identity_mismatch");
+ throw new Error("runner_state_identity_mismatch: durable_identity_unreadable");
}
if (!durableIdentityMatchesSession(identity, input.execution)) {
if (input.restartRecovery?.kind !== "reattach_existing_runner" &&
input.restartRecovery?.kind !== "reattach_remote_runner") {
quarantineRunnerdStateRoot(scoped, "identity_mismatch");
}
- throw new Error("runner_state_identity_mismatch");
+ throw new Error("runner_state_identity_mismatch: session_scope_mismatch");
}
if (input.restartRecovery?.kind === "bootstrap_incomplete") {
if (runnerdStateProvesIncompleteBootstrap(scoped)) {
@@ -4610,7 +4610,7 @@ async function migrateRunnerdStateRootForExecution(input: {
// Database evidence alone cannot distinguish a never-connected runner
// from a partially-persisted provider bootstrap. Only the durable PRP
// root can authorize a fresh bootstrap; anything else stays fail-closed.
- throw new Error("runner_state_identity_mismatch");
+ throw new Error("runner_state_identity_mismatch: bootstrap_not_proven_incomplete");
}
if (input.restartRecovery?.kind === "reattach_remote_runner") {
await verifyRemoteRunnerReattachment({
@@ -4631,7 +4631,7 @@ async function migrateRunnerdStateRootForExecution(input: {
if (input.restartRecovery?.kind !== "reattach_existing_runner") {
quarantineRunnerdStateRoot(scoped, "identity_indeterminate");
}
- throw new Error("runner_state_identity_mismatch");
+ throw new Error("runner_state_identity_mismatch: authority_indeterminate");
}
} else {
const verification = await verifyPriorRunnerdStateForSessionScope({
@@ -4654,7 +4654,7 @@ async function migrateRunnerdStateRootForExecution(input: {
: "identity_indeterminate",
);
}
- throw new Error("runner_state_identity_mismatch");
+ throw new Error(`runner_state_identity_mismatch: prior_owner_${verification}`);
}
}
return;
@@ -4672,7 +4672,7 @@ async function migrateRunnerdStateRootForExecution(input: {
// Unlike the full-scope target above, this legacy name can legitimately
// belong to another scope. Leave it in place for its owner and fail the
// attempted migration visibly.
- throw new Error("runner_state_identity_mismatch");
+ throw new Error("runner_state_identity_mismatch: legacy_session_scope_mismatch");
}
let verifiedPriorRunId: string | undefined;
if (!durableIdentityMatchesExecution(identity, input.execution)) {
@@ -4695,7 +4695,7 @@ async function migrateRunnerdStateRootForExecution(input: {
// untouched because the legacy name may still belong to them.
quarantineRunnerdStateRoot(legacy, "identity_indeterminate");
}
- throw new Error("runner_state_identity_mismatch");
+ throw new Error(`runner_state_identity_mismatch: legacy_prior_owner_${verification}`);
}
verifiedPriorRunId = identity.runId;
}
@@ -4745,7 +4745,7 @@ async function recoverQuiescentRunnerdState(input: {
) {
// Do not roll back to an older valid checkpoint when a newer quarantined
// root contains unconfirmed work, even if the newer root is unreadable.
- throw new Error("runner_state_identity_mismatch");
+ throw new Error("runner_state_identity_mismatch: quarantine_candidates_ambiguous");
}
const verified: Array<{
root: string;
@@ -4932,7 +4932,7 @@ async function recoverQuiescentRunnerdState(input: {
) {
// Known provider history is not permission to start a replacement when
// recovery cannot prove a unique, settled owner.
- throw new Error("runner_state_identity_mismatch");
+ throw new Error("runner_state_identity_mismatch: quarantine_owner_unverified");
}
return;
}
@@ -4951,14 +4951,14 @@ async function recoverQuiescentRunnerdState(input: {
"recovery_state_too_large",
).toString("utf8") !== expected
) {
- throw new Error("runner_state_identity_mismatch");
+ throw new Error("runner_state_identity_mismatch: recovery_evidence_changed");
}
}
if (
!localProcessDefinitelyGone(candidate.processPid) ||
!localProcessDefinitelyGone(candidate.processGroupId, true)
) {
- throw new Error("runner_state_identity_mismatch");
+ throw new Error("runner_state_identity_mismatch: recovery_process_not_gone");
}
if (candidate.root !== input.scoped) {
if (existsSync(input.scoped)) {
@@ -5673,12 +5673,12 @@ export function buildNativeHarnessBackupManifest(input: {
completedAt?: string;
}): NativeHarnessBackupManifest {
if (!providerSessionIdentityIsPresent(input.providerSessionIdentity)) {
- throw new Error("runner_harness_state_mismatch");
+ throw new Error("runner_harness_state_mismatch: backup_provider_identity_missing");
}
const profile = resolveNativeHarnessPersistenceProfile(input.execution);
const directories = profile.directories.map((directory) => {
const path = resolve(input.backupRoot, directory.name);
- if (!existsSync(path)) throw new Error("runner_harness_state_mismatch");
+ if (!existsSync(path)) throw new Error("runner_harness_state_mismatch: backup_directory_missing");
return { name: directory.name, ...digestBackupDirectory(path) };
});
return {
@@ -9364,14 +9364,22 @@ export function readRemoteProviderPackManifest(
readFileSync(resolve(packRoot, "provider-pack.json"), "utf8"),
) as RemoteProviderPackManifest;
} catch (error) {
+ // The terminal run report keeps the outer message, not the cause chain.
+ // Keep a bounded reason there; raw filesystem errors include private paths.
+ const code = (error as NodeJS.ErrnoException | null)?.code;
+ const reason = error instanceof SyntaxError ? "invalid_json"
+ : code === "ENOENT" ? "missing"
+ : code === "EACCES" || code === "EPERM" ? "permission_denied"
+ : code === "EISDIR" || code === "ENOTDIR" ? "invalid_path_type"
+ : "io_error";
throw new Error(
- "runner_remote_provider_artifact_incompatible: provider-pack.json is unreadable",
+ `runner_remote_provider_artifact_incompatible: provider-pack.json is unreadable (${reason})`,
{ cause: error },
);
}
const payload = manifest?.payload;
if (
- manifest.schema !== REMOTE_PROVIDER_PACK_SCHEMA ||
+ manifest?.schema !== REMOTE_PROVIDER_PACK_SCHEMA ||
!payload ||
canonicalJson(payload.pins) !== canonicalJson(REMOTE_PROVIDER_PACK_PINS) ||
canonicalJson(payload.acpxProfileDigests) !==
@@ -11543,7 +11551,7 @@ async function createRunnerdBackendWithinSessionClaim(
async () => {
for (const directory of persistenceProfile.directories) {
const targetPath = remotePersistencePath(directory);
- if (!targetPath) throw new Error("runner_harness_state_mismatch");
+ if (!targetPath) throw new Error("runner_harness_state_mismatch: restore_target_unavailable");
await stageRemoteRunnerDirectory({
target: remoteTarget,
runner: remoteCommandRunner,
@@ -11571,7 +11579,7 @@ async function createRunnerdBackendWithinSessionClaim(
canonicalJson(restored.providerSessionIdentity) !==
canonicalJson(backup.manifest.providerSessionIdentity)
) {
- throw new Error("runner_harness_state_mismatch");
+ throw new Error("runner_harness_state_mismatch: restored_provider_identity_changed");
}
// A deliberately non-reusable environment receives a fresh provider lease
// for every turn. Stamp that new lease as soon as the verified host backup
@@ -11586,7 +11594,7 @@ async function createRunnerdBackendWithinSessionClaim(
for (const directory of persistenceProfile.directories) {
if (directory.location !== "filesystem") continue;
const targetPath = remotePersistencePath(directory);
- if (!targetPath) throw new Error("runner_harness_state_mismatch");
+ if (!targetPath) throw new Error("runner_harness_state_mismatch: bootstrap_target_unavailable");
const escapedTarget = targetPath.replaceAll("'", "'\\''");
const created = await remoteCommandRunner.execute({
command: "sh",
@@ -11766,7 +11774,7 @@ async function createRunnerdBackendWithinSessionClaim(
// A continuation that has a durable backup but no recorded reusable
// lease was not provider-confirmed lost. Never silently create a new
// provider session from that ambiguous state.
- throw new Error("runner_harness_state_mismatch");
+ throw new Error("runner_harness_state_mismatch: backup_without_reusable_lease");
}
}
} else if (remoteTarget && remoteCommandRunner) {
@@ -11950,7 +11958,7 @@ async function createRunnerdBackendWithinSessionClaim(
!verified.runnerState ||
!verified.providerSessionIdentity
) {
- throw new Error("runner_harness_state_mismatch");
+ throw new Error("runner_harness_state_mismatch: checkpoint_identity_incomplete");
}
const providerSessionIdentity = verified.providerSessionIdentity;
@@ -11965,7 +11973,7 @@ async function createRunnerdBackendWithinSessionClaim(
for (const directory of persistenceProfile.directories) {
const sourcePath = remotePersistencePath(directory);
if (!sourcePath)
- throw new Error("runner_harness_state_mismatch");
+ throw new Error("runner_harness_state_mismatch: checkpoint_source_unavailable");
const targetPath = resolve(pendingRoot, directory.name);
await syncRemoteRunnerDirectoryOut({
runner: remoteCommandRunner,
@@ -11975,7 +11983,7 @@ async function createRunnerdBackendWithinSessionClaim(
excludeEntries: directory.excludeEntries,
});
if (!existsSync(targetPath)) {
- throw new Error("runner_harness_state_mismatch");
+ throw new Error("runner_harness_state_mismatch: checkpoint_directory_missing");
}
}
const manifest = buildNativeHarnessBackupManifest({
From 90119181e766fd9722450729e058eaee4e3c5e19 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Mon, 28 Sep 2026 18:08:57 -0700
Subject: [PATCH 6/8] test: control Telegram subscription retry timing (#14501)
## Thinking Path
> - Paperclip manages AI agents and their work.
> - Telegram delivery recovers subscription changes after a restart.
> - The recovery test leaves a failed action on the real one-second
retry timer.
> - A slow runner can cross that deadline before the test checks that no
retry occurred.
> - This pull request holds the fixture deadline until the explicit
restart transition.
> - The test still checks that recovery uses fresh provider options.
## Linked Issues or Issue Description
**What happened?**
The Telegram subscription recovery test expected one `setWebhook`
request but saw two. A 1.5-second delay after the first failed request
reproduces the failure.
**Expected behavior**
The test controls when the failed request becomes eligible for retry.
Host speed does not change its result.
**Steps to reproduce**
Run the test named `retries an unknown subscription mutation after
restart` with a 1.5-second delay after the first failed-action
assertion. The old fixture retries too early. The updated fixture passes
with the same delay.
Related: #13952 fixes a separate Telegram fixture cleanup problem. This
change addresses retry timing.
## What Changed
- Set the stored retry deadline to 2099 before the pre-restart
assertions.
- Keep the existing explicit epoch deadline after restart and all
provider request assertions.
- Report the current test phase only when this test fails, to diagnose
an observed intermittent CI timeout.
- Leave production retry code unchanged. Temporary delay and per-step
console tracing are not included.
## Verification
- Delayed regression: failed before the change with two requests instead
of one; passed after the change.
- Focused Telegram durable private draft Stop group: 34 tests passed.
- `pnpm -r typecheck`: passed.
- `pnpm build`: passed.
- Full local chat shard: 355 tests passed twice.
- `pnpm test:run`: general-server phase completed with 13,861 passed, 86
skipped, and 14 failures in unchanged macOS skills-cache and Git
long-path tests. The same failures reproduce on unmodified base code.
The command stops at that phase, so no full local pass is claimed.
- CI exposed a separate 15-second timeout. A diagnostic run passed all
355 shard tests, with the affected test completing in under one second.
Its cause remains unproven. Normal step logging is removed; a
failure-only phase report remains for a recurrence. The final commit
also passes the 355-test chat shard, and Greptile rates it 5/5. All 52
final-commit checks pass, with two intentional skips. There are no
unresolved review comments or merge conflicts.
## Risks
Low risk. This changes only the fixture deadline. It does not disable a
test, extend a timeout, or change production retry behavior. Existing
assertions still verify the failed action, the pending state, restart
recovery, and fresh provider options. The intermittent CI timeout is not
claimed fixed; phase diagnostics narrow the next occurrence without
changing the timeout. No documentation change is needed for a test
fixture correction.
## Model Used
OpenAI GPT-6 (Codex), with reasoning, terminal tools, and code
execution. The context window size is 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 references)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run focused tests locally and they pass; full-suite status
is recorded above
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes, or
explained why none is needed
- [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
---
.../chat-channels.integration.test.ts | 22 ++++++++++++++++++-
1 file changed, 21 insertions(+), 1 deletion(-)
diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts
index d04154ee97..39d9e150fe 100644
--- a/server/src/__tests__/chat-channels.integration.test.ts
+++ b/server/src/__tests__/chat-channels.integration.test.ts
@@ -69063,7 +69063,11 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
},
);
- it("retries an unknown subscription mutation after restart using freshly observed options, not a recovered confirmation flag", async () => {
+ it("retries an unknown subscription mutation after restart using freshly observed options, not a recovered confirmation flag", async ({ onTestFailed }) => {
+ let phase = "create fixture";
+ onTestFailed(() => {
+ console.error(`Telegram subscription recovery failed during: ${phase}`);
+ });
const lane = await draftFixture();
try {
await db
@@ -69080,6 +69084,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
throw new Error("Synthetic unknown subscription response");
return undefined;
});
+ phase = "first subscription attempt";
await lane.processSubscriptionAttempt();
const [action] = await db
.select()
@@ -69095,17 +69100,28 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
status: "failed",
result: { retryable: true, providerConfirmed: false },
});
+ // Keep the retry pending until the explicit post-restart transition below.
+ // A busy runner can otherwise exhaust the real one-second backoff here.
+ await db
+ .update(chatActions)
+ .set({
+ result: { ...action!.result, retryAt: "2099-01-01T00:00:00.000Z" },
+ })
+ .where(eq(chatActions.id, action!.id));
+ phase = "ordinary publication before restart";
expect((await lane.send("unknown-subscription"))?.state).toBe(
"published",
);
expect(lane.requests).toHaveLength(1);
expect(lane.requests[0]!.method.endsWith("Draft")).toBe(false);
+ phase = "pending recovery before restart";
await lane.context.service.processPendingDeliveries();
expect(
lane.maintenanceRequests.filter(
({ method }) => method === "setWebhook",
),
).toHaveLength(1);
+ phase = "restart";
await lane.restart();
lane.setSubscriptionInfo({
allowed_updates: ["message", "chat_member"],
@@ -69121,10 +69137,12 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
},
})
.where(eq(chatActions.id, action!.id));
+ phase = "concurrent recovery after restart";
await Promise.all([
lane.context.service.processPendingDeliveries(),
lane.context.service.processPendingDeliveries(),
]);
+ phase = "verify recovered subscription";
const mutations = lane.maintenanceRequests.filter(
({ method }) => method === "setWebhook",
);
@@ -69154,9 +69172,11 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
200,
);
});
+ phase = "publication after subscription recovery";
expect((await lane.send("repaired-after-unknown"))?.state).toBe(
"cancelled",
);
+ phase = "close fixture";
} finally {
await lane.close();
}
From ea371b9684ee8e5c657e21b41f1c51f086956ab5 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Mon, 28 Sep 2026 18:09:20 -0700
Subject: [PATCH 7/8] fix: retain project defaults in partial workspace
overrides (#14502)
## Thinking Path
> - Paperclip manages AI agents and their work.
> - Project workspace policies define how isolated worktrees are set up.
> - Tasks can override a branch without providing every setup field.
> - The resolver currently replaces the entire project strategy with
that partial override.
> - Losing an explicit setup command can run the repository fallback
script and block the task.
> - This pull request keeps enabled project defaults when the task uses
the same strategy type.
## Linked Issues or Issue Description
**What happened?**
A project uses `git_worktree` with `provisionCommand: "true"`. A task
overrides only `baseRef`. The resolver drops the command. Worktree
creation then invokes `scripts/provision-worktree.sh`, which can fail
because its required setup is absent.
**Expected behavior**
A branch override keeps the project's provision, runtime provision, and
teardown commands unless the task explicitly overrides them. A different
strategy type must not inherit those commands.
**Steps to reproduce**
Configure the project with an enabled `git_worktree` strategy and
`provisionCommand: "true"`. Give the task an isolated workspace with a
`git_worktree` strategy and a different `baseRef`. Add a failing
repository fallback provisioner. Before this change, worktree creation
invokes that script. After this change, it uses the project's explicit
command and succeeds.
Related: #4968 concerns agent strategy and working-directory fallback.
#13903 concerns gated API fields and reusable-workspace updates. #11091
concerns provision hooks on workspace reuse. None fixes partial task
overrides discarding project defaults.
## What Changed
- Merge a partial task strategy over the enabled project's strategy only
when their types match.
- Preserve explicit null values when parsing nullable strategy fields,
so they can clear project values.
- Keep explicit empty-string overrides and agent fallback behavior.
- Exclude disabled project strategies and avoid an inherited branch
template when a task pins an existing branch.
- Add policy regression coverage and a real Git worktree test with a
failing fallback script.
- Document inheritance, explicit clearing, and no-op provisioning in the
development guide.
## Verification
- Policy regression: eight failures before the fix; all 41 policy tests
pass after it.
- Real worktree regression: passes and creates a worktree using the
task's base branch without invoking the failing fallback provisioner.
- `pnpm -r typecheck`: passed.
- `pnpm build`: passed.
- `pnpm test:run`: general-server phase completed with 13,873 passed, 86
skipped, and 14 failures in unchanged macOS skills-cache and Git
long-path tests. The same failures reproduce on unmodified base code.
The command stops at that phase, so no full local pass is claimed.
- CI initially failed the existing Telegram subscription recovery test
on a 15-second timeout. The separate fix and investigation are in
#14501. A serialized job also lost its runner; GitHub reported lost
communication, and that job was rerun without source changes. All 52
final-commit checks pass, with two intentional skips. Greptile is 5/5,
with no unresolved comments or merge conflicts. The chat shard passed on
one unchanged rerun. The timeout cause remains unproven; #14501 adds
phase diagnostics for a recurrence.
## Risks
Tasks that specify a partial strategy now retain the project's omitted
fields, including setup and teardown hooks. This is the intended
behavior change. Inheritance requires an enabled project policy and
matching strategy types. Explicit task values still win. Null and empty
commands restore existing runtime defaults; they do not guarantee that
no script runs. Use `"true"` for an explicit no-op provision command. No
migration, live configuration change, or task replay is included.
## Model Used
OpenAI GPT-6 (Codex), with reasoning, terminal tools, and code
execution. The context window size is 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 references)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run focused tests locally and they pass; full-suite status
is recorded 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 | 2 +
.../execution-workspace-policy.test.ts | 101 ++++++++++++++++++
.../src/__tests__/workspace-runtime.test.ts | 51 +++++++++
.../services/execution-workspace-policy.ts | 25 +++--
4 files changed, 170 insertions(+), 9 deletions(-)
diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md
index 317bdbbefe..0bca5513e5 100644
--- a/doc/DEVELOPING.md
+++ b/doc/DEVELOPING.md
@@ -992,6 +992,8 @@ eval "$(npx paperclipai worktree env)"
For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants.
+An issue's partial `workspaceStrategy` inherits omitted fields from the enabled project's strategy when both use the same type. For example, an issue can override `baseRef` without losing the project's provision, runtime provision, or teardown commands. An explicit value, including `null` or an empty string, replaces the project value. Clearing a command restores the runtime's usual default behavior; use `provisionCommand: "true"` for an explicit no-op. A different strategy type or a disabled project policy does not supply these defaults. An issue's `existingBranch` pin also excludes the project's `branchTemplate`.
+
An issue can pin its isolated worktree to an exact pre-existing branch instead of a template-derived one — the contract PR-preparation tasks use. Set the issue's `executionWorkspaceSettings` to `{ "mode": "isolated_workspace", "workspaceStrategy": { "type": "git_worktree", "existingBranch": "" } }`. The validator requires isolated mode plus a `git_worktree` strategy and rejects `branchTemplate` alongside `existingBranch`. At dispatch the runtime attaches (never creates, renames, fast-forwards, or resets) that branch: it reuses a registered worktree that already has the branch checked out (including legacy `.worktrees/` paths), otherwise it attaches the branch under the managed worktree parent. A missing branch, an occupied worktree path on another branch, or a non-worktree strategy fails closed with a `workspace_validation_failed` error instead of falling back to the shared checkout or a derived branch, and an inherited `reuse_existing` workspace binding on a different branch is ignored in favor of realizing the pinned branch.
Heavier setup that is only needed by a managed runtime service can use `workspaceStrategy.runtimeProvisionCommand`. Paperclip runs this command lazily before spawning the first service in a start batch, serializes concurrent provisioning for the same workspace, and records the attempt as `workspace_runtime_provision`. The command receives the same workspace environment as `provisionCommand` and should be idempotent because later service-start batches invoke it again.
diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts
index 07f6179731..e5cf70cf01 100644
--- a/server/src/__tests__/execution-workspace-policy.test.ts
+++ b/server/src/__tests__/execution-workspace-policy.test.ts
@@ -300,6 +300,107 @@ describe("execution workspace policy helpers", () => {
});
});
+ describe("partial issue workspace strategies", () => {
+ const projectStrategy = {
+ type: "git_worktree" as const,
+ baseRef: "origin/main",
+ branchTemplate: "{{issue.identifier}}-{{slug}}",
+ worktreeParentDir: ".paperclip/worktrees",
+ provisionCommand: "true",
+ runtimeProvisionCommand: "npm run setup:runtime",
+ teardownCommand: "npm run teardown",
+ };
+
+ function resolveStrategy(
+ strategy: Record,
+ enabled = true,
+ ) {
+ return buildExecutionWorkspaceAdapterConfig({
+ agentConfig: { workspaceStrategy: { type: "git_worktree", provisionCommand: "agent-setup" } },
+ projectPolicy: parseProjectExecutionWorkspacePolicy({
+ enabled,
+ defaultMode: "isolated_workspace",
+ workspaceStrategy: projectStrategy,
+ }),
+ issueSettings: parseIssueExecutionWorkspaceSettings({
+ mode: "isolated_workspace",
+ workspaceStrategy: strategy,
+ }),
+ mode: "isolated_workspace",
+ legacyUseProjectWorkspace: null,
+ }).workspaceStrategy;
+ }
+
+ it("retains project hooks when an issue changes only its base branch", () => {
+ expect(resolveStrategy({ type: "git_worktree", baseRef: "origin/release" })).toEqual({
+ ...projectStrategy,
+ baseRef: "origin/release",
+ });
+ });
+
+ it.each(["npm run issue-setup", "", null])("honors an explicit provisioning override of %j", (provisionCommand) => {
+ expect(resolveStrategy({ type: "git_worktree", provisionCommand })).toEqual({
+ ...projectStrategy,
+ provisionCommand,
+ });
+ });
+
+ it("preserves explicit null clears through persisted JSON parsing", () => {
+ const strategy = {
+ type: "git_worktree",
+ baseRef: null,
+ branchTemplate: null,
+ worktreeParentDir: null,
+ provisionCommand: null,
+ runtimeProvisionCommand: null,
+ teardownCommand: null,
+ };
+ expect(resolveStrategy(strategy)).toEqual(strategy);
+ });
+
+ it.each(["cloud_sandbox", "adapter_managed", "project_primary"])("does not carry project hooks into %s", (type) => {
+ expect(resolveStrategy({ type })).toEqual({ type });
+ });
+
+ it("does not inherit a disabled project strategy", () => {
+ expect(resolveStrategy({ type: "git_worktree", baseRef: "origin/release" }, false)).toEqual({
+ type: "git_worktree",
+ baseRef: "origin/release",
+ });
+ expect(resolveStrategy({}, false)).toEqual({
+ type: "git_worktree",
+ provisionCommand: "agent-setup",
+ });
+ });
+
+ it("keeps project hooks for an exact branch pin without inheriting a branch template", () => {
+ const resolved = resolveStrategy({ type: "git_worktree", existingBranch: "fix/existing" });
+ expect(resolved).toEqual({
+ ...projectStrategy,
+ branchTemplate: undefined,
+ existingBranch: "fix/existing",
+ });
+ expect(issueExecutionWorkspaceSettingsSchema.safeParse({
+ mode: "isolated_workspace",
+ workspaceStrategy: resolved,
+ }).success).toBe(true);
+ });
+
+ it("does not mutate the project or issue strategy", () => {
+ const issueStrategy = { type: "git_worktree" as const, baseRef: "origin/release" };
+ const result = buildExecutionWorkspaceAdapterConfig({
+ agentConfig: {},
+ projectPolicy: { enabled: true, workspaceStrategy: Object.freeze({ ...projectStrategy }) },
+ issueSettings: { workspaceStrategy: Object.freeze(issueStrategy) },
+ mode: "isolated_workspace",
+ legacyUseProjectWorkspace: null,
+ });
+ expect(result.workspaceStrategy).not.toBe(issueStrategy);
+ expect(issueStrategy).toEqual({ type: "git_worktree", baseRef: "origin/release" });
+ expect(projectStrategy.baseRef).toBe("origin/main");
+ });
+ });
+
it("preserves project authorization policy for trust-preset resolution", () => {
expect(parseProjectExecutionWorkspacePolicy({
enabled: true,
diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts
index ececaebf9e..fd0caefa8e 100644
--- a/server/src/__tests__/workspace-runtime.test.ts
+++ b/server/src/__tests__/workspace-runtime.test.ts
@@ -24,6 +24,11 @@ import {
workspaceRuntimeServices,
} from "@paperclipai/db";
import { eq } from "drizzle-orm";
+import {
+ buildExecutionWorkspaceAdapterConfig,
+ parseIssueExecutionWorkspaceSettings,
+ parseProjectExecutionWorkspacePolicy,
+} from "../services/execution-workspace-policy.ts";
import {
buildWorkspaceRuntimeDesiredStatePatch,
cleanupExecutionWorkspaceArtifacts,
@@ -869,6 +874,52 @@ describe("realizeExecutionWorkspace", () => {
expect(second.branchName).toBe(first.branchName);
});
+ it("retains the project provision command when an issue overrides its base branch", async () => {
+ const repoRoot = await createTempRepo();
+ await fs.mkdir(path.join(repoRoot, "scripts"));
+ await fs.writeFile(
+ path.join(repoRoot, "scripts", "provision-worktree.sh"),
+ "#!/usr/bin/env bash\necho 'Unexpected repository provision fallback' >&2\nexit 1\n",
+ );
+ await runGit(repoRoot, ["add", "scripts/provision-worktree.sh"]);
+ await runGit(repoRoot, ["commit", "-m", "Add fallback provisioner"]);
+ await runGit(repoRoot, ["branch", "release"]);
+ const config = buildExecutionWorkspaceAdapterConfig({
+ agentConfig: {},
+ projectPolicy: parseProjectExecutionWorkspacePolicy({
+ enabled: true,
+ defaultMode: "isolated_workspace",
+ workspaceStrategy: { type: "git_worktree", baseRef: "main", provisionCommand: "true" },
+ }),
+ issueSettings: parseIssueExecutionWorkspaceSettings({
+ mode: "isolated_workspace",
+ workspaceStrategy: { type: "git_worktree", baseRef: "release" },
+ }),
+ mode: "isolated_workspace",
+ legacyUseProjectWorkspace: null,
+ });
+ try {
+ const workspace = await realizeExecutionWorkspace({
+ base: {
+ baseCwd: repoRoot,
+ source: "project_primary",
+ projectId: "project-1",
+ workspaceId: "workspace-1",
+ repoUrl: null,
+ repoRef: "HEAD",
+ },
+ config,
+ issue: { id: "issue-1", identifier: "TEST-1", title: "Keep project setup" },
+ agent: { id: "agent-1", name: "Test agent", companyId: "company-1" },
+ });
+ expect(workspace.created).toBe(true);
+ expect(workspace.baseRefSha).toBe(await readGit(repoRoot, ["rev-parse", "release"]));
+ await expect(fs.stat(path.join(workspace.cwd, ".git"))).resolves.toBeTruthy();
+ } finally {
+ await fs.rm(repoRoot, { 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 });
diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts
index b430de18cc..dd36825a2a 100644
--- a/server/src/services/execution-workspace-policy.ts
+++ b/server/src/services/execution-workspace-policy.ts
@@ -38,17 +38,17 @@ function parseExecutionWorkspaceStrategy(raw: unknown): ExecutionWorkspaceStrate
}
return {
type,
- ...(typeof parsed.baseRef === "string" ? { baseRef: parsed.baseRef } : {}),
- ...(typeof parsed.branchTemplate === "string" ? { branchTemplate: parsed.branchTemplate } : {}),
+ ...(typeof parsed.baseRef === "string" || parsed.baseRef === null ? { baseRef: parsed.baseRef } : {}),
+ ...(typeof parsed.branchTemplate === "string" || parsed.branchTemplate === null ? { branchTemplate: parsed.branchTemplate } : {}),
...(typeof parsed.existingBranch === "string" && parsed.existingBranch.trim().length > 0
? { existingBranch: parsed.existingBranch.trim() }
: {}),
- ...(typeof parsed.worktreeParentDir === "string" ? { worktreeParentDir: parsed.worktreeParentDir } : {}),
- ...(typeof parsed.provisionCommand === "string" ? { provisionCommand: parsed.provisionCommand } : {}),
- ...(typeof parsed.runtimeProvisionCommand === "string"
+ ...(typeof parsed.worktreeParentDir === "string" || parsed.worktreeParentDir === null ? { worktreeParentDir: parsed.worktreeParentDir } : {}),
+ ...(typeof parsed.provisionCommand === "string" || parsed.provisionCommand === null ? { provisionCommand: parsed.provisionCommand } : {}),
+ ...(typeof parsed.runtimeProvisionCommand === "string" || parsed.runtimeProvisionCommand === null
? { runtimeProvisionCommand: parsed.runtimeProvisionCommand }
: {}),
- ...(typeof parsed.teardownCommand === "string" ? { teardownCommand: parsed.teardownCommand } : {}),
+ ...(typeof parsed.teardownCommand === "string" || parsed.teardownCommand === null ? { teardownCommand: parsed.teardownCommand } : {}),
};
}
@@ -418,11 +418,18 @@ export function buildExecutionWorkspaceAdapterConfig(input: {
if (hasWorkspaceControl) {
if (input.mode === "isolated_workspace") {
- const strategy =
- input.issueSettings?.workspaceStrategy ??
- input.projectPolicy?.workspaceStrategy ??
+ const projectStrategy = projectHasPolicy ? input.projectPolicy?.workspaceStrategy : undefined;
+ const issueStrategy = input.issueSettings?.workspaceStrategy;
+ // An issue that changes its branch still needs the project's setup hooks.
+ // Do not carry those defaults into a different execution strategy.
+ const strategy = issueStrategy && projectStrategy?.type === issueStrategy.type
+ ? { ...projectStrategy, ...issueStrategy }
+ : issueStrategy ?? projectStrategy ??
parseExecutionWorkspaceStrategy(nextConfig.workspaceStrategy) ??
({ type: "git_worktree" } satisfies ExecutionWorkspaceStrategy);
+ if (issueStrategy?.existingBranch && issueStrategy.branchTemplate === undefined && strategy !== issueStrategy) {
+ delete strategy.branchTemplate;
+ }
nextConfig.workspaceStrategy = strategy as unknown as Record;
} else {
delete nextConfig.workspaceStrategy;
From 53aad90b9e83dc147707797bf224bec12600b171 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Mon, 28 Sep 2026 19:15:33 -0700
Subject: [PATCH 8/8] fix: retry sandbox ACP input delivery after gateway
failures (#14485)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip coordinates agent work through execution adapters.
> - Sandbox ACP sessions send ordered input through a remote file queue.
> - A temporary provider 502 currently closes the session during an
input upload.
> - A lost response can occur after the sandbox has consumed the
message, so a blind retry can duplicate input.
> - This pull request retries gateway failures with the same sequence
and drops consumed sequences at the receiver.
> - The session can continue through a brief provider failure without
repeating a tool call.
## Linked Issues or Issue Description
**What happened?**
A sandbox ACP run can fail with `ACP agent disconnected during request
(connection_close, exit=null, signal=null)` when a provider input upload
returns HTTP 502. The bridge destroys its local socket on the first
failure and can discard the diagnostic before the proxy reads it.
**Expected behavior**
A temporary gateway failure should get a bounded retry. A lost response
after successful delivery must not duplicate input or reorder later
messages. Permanent failures must still close the session.
**Steps to reproduce**
1. Run the real sandbox process bridge with an echo child and a local
test runner.
2. Inject a provider 502 before preparation, after a chunk upload, or
after final publication and consumption.
3. Send the next input message. Before this change, the connection
closes instead of delivering it.
Searched open and closed PRs for `ACP disconnect`, `bridge retry`, and
`502 sandbox`. Related work: #13287 covers shutdown after bridge loss;
#13793 covers large launch envelopes. This change covers ordered input
delivery within a running legacy ACP session.
## What Changed
- Retry input uploads up to three times for recognized Daytona and
Cloudflare HTTP 502, 503, and 504 diagnostics, with 250 ms and 500 ms
delays.
- Give each upload separate temporary paths and discard already-consumed
input sequences, including late publication from an earlier attempt.
Clean failed attempts in the background without removing a published
message or another attempt’s files. Cleanup cannot delay retries or
shutdown.
- Keep later input behind the retry. Stop queued input on permanent
failure and flush a fixed diagnostic before closing the socket. Neither
failure-diagnostic persistence nor shutdown-warning persistence can
block teardown.
- Add real-process regression tests for lost responses, late
publication, retry exhaustion, immediate permanent failure, and
diagnostic redaction.
- Give accepted run-log file appends up to three seconds to drain before
finalization computes the size, hash, and durable copy. Close the run
handle to later appends. This waits only for file writes, independently
of later DB progress or live-event persistence. If writes remain
stalled, return null size/hash metadata and skip the final durable copy
so the run can settle. Late writes cannot restart mirroring.
- Preserve legacy comment attribution when final log size is unknown by
reading existing entries within the unchanged 2 MB scan limit. Storage
errors or a three-second read deadline return the evidence already read
instead of failing the comment listing; pagination stops at the
deadline. The deadline requests cancellation of the underlying local
stream or S3 HEAD, GET, and response stream. A separate response timeout
returns partial evidence even when filesystem I/O delays cancellation;
late reads cannot append evidence or start another page. Each listing
retains its existing batches of eight reads, without a shared admission
cap that skips readable logs under contention.
- Document the retry and log-finalization boundaries in the development
guide.
## Verification
- Final commit `347daa564b`: [Linux
CI](https://github.com/paperclipai/paperclip/actions/runs/36506995168/attempts/2)
passed. Greptile Apex review 13 scored this commit 5/5 with no new
findings; all 12 review threads are resolved.
- The final CI run initially hit a Cursor test timeout and four Discord
credential-lock contention failures. All five cases passed in isolation.
The two failed shards and their aggregate gate passed on retry without a
code change. Those intermittent failures are not claimed fixed by this
PR.
- `pnpm --filter @paperclipai/adapter-utils typecheck` passed.
- `pnpm exec vitest run
packages/adapter-utils/src/execution-target-stdin-race.test.ts
packages/adapter-utils/src/execution-target-sandbox.test.ts
packages/adapter-utils/src/sandbox-callback-bridge.test.ts`: 262 tests
passed on the final implementation, including 21 new regressions. The
original three fault-injection cases failed before the fix.
- The regressions cover failed and indefinitely stalled cleanup,
Cloudflare gateway responses and retry exhaustion, permanent errors that
must not retry, and teardown while failure logging remains indefinitely
stalled. Seven Apex regression cases failed before the review fixes.
Adapter-utils typecheck and build passed again after the final review
change.
- `pnpm exec vitest run server/src/services/run-log-store.test.ts
server/src/services/run-log-store-cancellation.test.ts`: all 25 tests
passed, including four new regressions that failed before the
finalization fix. They cover delayed and failed appends, late-write
admission, agreement between the local bytes/summary/durable copy, and a
stalled append that exhausts the three-second budget. The timeout case
verifies unknown metadata, no final upload, and no mirror restart after
late completion. New cancellation tests use the real AWS SDK against a
local HTTP server. They verify that stalled HEAD, GET, and response-body
connections close on abort and that a subsequent read succeeds. Local
range and already-aborted read cases also pass.
- `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t
'readIssueCommentRunLogText|deriveIssueCommentRunLogAttribution'`: 14
targeted tests passed. The null-size reader case, both storage-error
cases, the stalled-read case, and the cancellation/concurrent-listing
cases failed before their fixes. The new regressions verify that
timed-out reads are cancelled, subsequent listings recover, and two
concurrent listings both retain their attribution markers. A read that
ignores cancellation still returns partial evidence at three seconds and
cannot resume pagination when it finishes; this regression failed before
the response-timeout fix.
- `pnpm --filter @paperclipai/server typecheck` and `pnpm --filter
@paperclipai/server build` passed after the response-timeout change.
- Full `pnpm -r typecheck` and `pnpm build` passed earlier in this PR;
the affected packages were rechecked after review fixes.
- Full local `pnpm test:run` failed in the general-server group: 511
files passed, 40 failed, and 158 were skipped. Failures include embedded
PostgreSQL initialization, read-only cache directory renames, a macOS
long-path fixture, and a workspace exposure assertion. The PostgreSQL,
cache-permission, and long-path failures also reproduce with both
changed implementation files restored to baseline commit `24c58e479a`.
The exposure suite passes in isolation both on baseline and the fixed
branch (28 passed, 3 skipped). CI runs the full suite on Linux. Later
local test groups were not reached.
- An earlier CI run hit the Telegram retry-timing failure fixed upstream
in #14501. The branch includes that master fix. The selected recovery
test passed against a fresh, migrated PostgreSQL 16 database. The
embedded PostgreSQL runner is unavailable on this Mac; the isolated
database was stopped and removed afterward.
- No live agent turn was replayed. The tests use local child processes
and injected provider failures.
## Risks
Retries are restricted to recognized Daytona SDK and Cloudflare bridge
gateway-error messages, which survive plugin RPC serialization. Other
errors fail immediately. Temporary upload paths are now unique for all
command-managed queue writes. Receiver sequence checks prevent duplicate
input; retries do not restart an agent turn. Cleanup and failure logging
are nonblocking and best effort; session teardown remains the final
cleanup boundary. Log finalization now drains accepted local file writes
for at most three seconds and ignores later appends on the closed run
handle. A timeout leaves final size/hash unknown and skips the final
durable upload; an existing partial mirror may remain available, but it
is not claimed as a verified final snapshot. It does not wait for later
DB progress or live-event persistence. Optional attribution keeps
partial evidence when a read fails or times out. Cancellation closes S3
requests and response streams. Local filesystem I/O may finish after the
caller deadline, but a late read cannot change the returned evidence or
continue pagination. Later listings can retry after storage recovers.
There is no schema, authentication, or permission change. Revert this
commit to restore the previous behavior.
## Model Used
OpenAI GPT-6 through Codex, with reasoning, repository inspection, code
editing, and local test 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
#` 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 and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally; targeted tests pass and full-suite
limitations are documented 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 | 29 +++
.../src/execution-target-sandbox.test.ts | 2 +-
.../src/execution-target-stdin-race.test.ts | 245 +++++++++++++++++-
.../adapter-utils/src/execution-target.ts | 72 +++--
.../src/sandbox-callback-bridge.ts | 45 +++-
server/src/__tests__/issues-service.test.ts | 153 ++++++++++-
server/src/services/heartbeat.ts | 8 +-
server/src/services/issues.ts | 121 +++++----
.../run-log-store-cancellation.test.ts | 72 +++++
server/src/services/run-log-store.test.ts | 121 +++++++--
server/src/services/run-log-store.ts | 115 +++++---
server/src/storage/s3-provider.ts | 8 +-
server/src/storage/types.ts | 2 +
13 files changed, 851 insertions(+), 142 deletions(-)
create mode 100644 server/src/services/run-log-store-cancellation.test.ts
diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md
index 0bca5513e5..c830de4bcb 100644
--- a/doc/DEVELOPING.md
+++ b/doc/DEVELOPING.md
@@ -1109,6 +1109,35 @@ agent workspace. The host `HOME` itself, a directory that contains it, a
filesystem root, a `CODEX_HOME` overlap, or a canonical path outside the
assigned workspace is rejected before provider startup.
+### Sandbox ACP input delivery
+
+The legacy sandbox process bridge retries recognized Daytona and Cloudflare
+HTTP 502, 503, and 504 failures while writing an input message, with at most
+three attempts and a short backoff.
+Retries keep the message sequence and use separate temporary upload files.
+The remote wrapper discards already-consumed sequences, so a lost provider
+response cannot send the same input bytes twice. Messages remain ordered.
+This does not restart an agent turn or replay a tool call. Authentication and
+shell errors fail immediately; exhausted input delivery closes the bridge and
+records a fixed diagnostic without logging the input payload. Persisting that
+failure diagnostic does not block bridge teardown.
+Run-log finalization closes its write handle and waits for accepted file
+appends before computing the size, hash, and durable copy. Writes submitted
+after finalization starts are ignored; later progress persistence is not part
+of that file-write barrier. If accepted writes remain stalled after three
+seconds, finalization returns unknown size/hash metadata and skips the final
+durable copy so the run can reach a terminal state. A late write cannot restart
+mirroring or produce a claimed verified snapshot.
+Readers still attempt bounded reads when size is unknown. Legacy comment
+attribution retains its existing 2 MB scan limit and allows three seconds per
+log. Storage errors or timeouts preserve any evidence already read and leave
+the comments available without additional derived attribution.
+The read deadline requests cancellation of local file streams, S3 HEAD and GET
+requests, and S3 response streams. The listing stops waiting at the deadline
+even if filesystem I/O delays cancellation. Late results cannot add evidence
+or start another page. Each listing retains its existing batches of eight reads;
+concurrent listings do not skip healthy logs because another listing is busy.
+
### Preinstalled remote runner runtime
For fast sandbox startup, bake `paperclip-runnerd` and the latest stable agent
diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts
index 36695e85c1..bf600fdd74 100644
--- a/packages/adapter-utils/src/execution-target-sandbox.test.ts
+++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts
@@ -609,7 +609,7 @@ describe("sandbox adapter execution targets", () => {
target: {
kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir,
runner: { execute: async (input) => {
- if (input.args?.[1]?.includes("command.b64.paperclip-upload.b64") && input.args[1].includes(">>")) {
+ if (/command\.b64\.[^/]+\.paperclip-upload\.b64/.test(input.args?.[1] ?? "") && input.args![1].includes(">>")) {
throw new Error("Upload interrupted");
}
return delegate.execute(input);
diff --git a/packages/adapter-utils/src/execution-target-stdin-race.test.ts b/packages/adapter-utils/src/execution-target-stdin-race.test.ts
index 9341358c38..a87deb0e89 100644
--- a/packages/adapter-utils/src/execution-target-stdin-race.test.ts
+++ b/packages/adapter-utils/src/execution-target-stdin-race.test.ts
@@ -122,10 +122,10 @@ describe("stdin file race (parent PAP-4037)", () => {
return new Promise((resolve) => setTimeout(resolve, ms));
}
- async function waitFor(check: () => boolean, timeoutMs = 4_000): Promise {
+ async function waitFor(check: () => boolean | Promise, timeoutMs = 4_000): Promise {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
- if (check()) return;
+ if (await check()) return;
await delay(20);
}
throw new Error("Timed out waiting for condition.");
@@ -492,8 +492,247 @@ describe("stdin file race (parent PAP-4037)", () => {
}
});
+ it.each([
+ ...["prepare", "append", "finalize", "late-finalize"].map((stage) =>
+ [stage, "Request failed with status code 502"] as const),
+ ...[502, 503, 504].map((status) =>
+ ["finalize", `Cloudflare sandbox bridge request failed with HTTP ${status}.`] as const),
+ ])(
+ "recovers a transient %s failure (%s) without repeating or reordering stdin",
+ async (stage, failure) => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-stdin-retry-"));
+ cleanupDirs.push(rootDir);
+ const childPath = path.join(rootDir, "echo-child.mjs");
+ await writeFile(childPath, "process.stdin.on('data', (c) => process.stdout.write(c));\n", "utf8");
+ const first = "first-" + "x".repeat(70_000);
+ let delivered = "";
+ let injected = false;
+ let lateFinalize: (() => Promise) | undefined;
+ const local = createLocalSandboxRunner();
+ const runner = {
+ execute: async (input: Parameters[0]) => {
+ const script = input.args?.[1] ?? "";
+ const matches = script.includes("/stdin/000000000001.json") && (
+ stage === "prepare" ? script.includes("mkdir -p") :
+ stage === "append" ? script.startsWith("printf") : script.startsWith("base64 -d")
+ );
+ if (matches && !injected) {
+ injected = true;
+ if (stage === "late-finalize") lateFinalize = () => local.execute(input);
+ else if (stage !== "prepare") await local.execute(input);
+ // The provider can lose the response after the receiver consumed
+ // the file. A retry must not repeat those bytes on the ACP stream.
+ if (stage === "finalize") await waitFor(() => delivered === first, 8_000);
+ throw new Error(failure);
+ }
+ return local.execute(input);
+ },
+ };
+ const bridge = await startAdapterExecutionTargetProcessSessionBridge({
+ runId: "run-stdin-retry",
+ target: { kind: "remote", transport: "sandbox", remoteCwd: rootDir, runner },
+ runtimeRootDir: path.join(rootDir, "runtime"),
+ adapterKey: "acpx", command: process.execPath, args: [childPath], cwd: rootDir, env: {},
+ });
+ let peer: net.Socket | undefined;
+ try {
+ const source = await readFile(bridge!.agentCommand, "utf8");
+ const port = Number(/port: (\d+)/.exec(source)![1]);
+ const token = JSON.parse(/const token = (".*?");/.exec(source)![1]) as string;
+ peer = net.createConnection({ host: "127.0.0.1", port });
+ peer.on("error", () => {});
+ peer.setEncoding("utf8");
+ let buffer = "";
+ peer.on("data", (chunk) => {
+ buffer += chunk;
+ const lines = buffer.split("\n");
+ buffer = lines.pop()!;
+ for (const line of lines) {
+ const frame = JSON.parse(line) as DeliveredFrame;
+ delivered += collectDelivered([frame]);
+ }
+ });
+ await new Promise((resolve) => peer!.once("connect", resolve));
+ for (const text of [first, "-second"])
+ peer.write(JSON.stringify({ token, type: "stdin", data: Buffer.from(text).toString("base64") }) + "\n");
+ await waitFor(() => delivered.endsWith("-second"), 10_000);
+ expect(injected).toBe(true);
+ expect(delivered).toBe(first + "-second");
+ if (lateFinalize) {
+ // A provider can return 502 while its original finalize still runs.
+ // Cleanup may invalidate its private upload, but it cannot touch
+ // the retry's data or repeat input after newer messages arrived.
+ await lateFinalize();
+ peer.write(JSON.stringify({ token, type: "stdin", data: Buffer.from("-third").toString("base64") }) + "\n");
+ await waitFor(() => delivered.endsWith("-third"), 8_000);
+ expect(delivered).toBe(first + "-second-third");
+ }
+ await waitFor(async () => {
+ const files = await readdir(path.join(rootDir, "runtime", "process-sessions"), { recursive: true });
+ return files.every((file) => !file.endsWith(".paperclip-upload.b64") && !file.endsWith(".paperclip-upload.decoded"));
+ });
+ } finally {
+ peer?.destroy();
+ await bridge?.stop();
+ }
+ },
+ 20_000,
+ );
+
+ it.each([
+ ["Request failed with status code 502", 3],
+ ["Request failed with status code 503", 3],
+ ["Request failed with status code 504", 3],
+ ["Cloudflare sandbox bridge request failed with HTTP 502.", 3],
+ ["Cloudflare sandbox bridge request failed with HTTP 503.", 3],
+ ["Cloudflare sandbox bridge request failed with HTTP 504.", 3],
+ ["Request failed with status code 403", 1],
+ ["Cloudflare sandbox bridge request failed with HTTP 403.", 1],
+ ["Remote command failed: Request failed with status code 502", 1],
+ ["Cloudflare sandbox bridge request failed with HTTP 502. sensitive-input", 1],
+ ["Remote command failed: sensitive-input", 1],
+ ] as const)("bounds input failure %s to %i attempts and stops later writes", async (failure, expectedAttempts) => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-stdin-failed-"));
+ cleanupDirs.push(rootDir);
+ let attempts = 0;
+ let laterWrite = false;
+ let stderr = "";
+ const runner = createLocalSandboxRunner(async (script) => {
+ if (!script.startsWith("mkdir -p")) return;
+ if (script.includes("/stdin/000000000002.json")) laterWrite = true;
+ if (script.includes("/stdin/000000000001.json")) {
+ attempts += 1;
+ throw new Error(failure);
+ }
+ });
+ const bridge = await startAdapterExecutionTargetProcessSessionBridge({
+ runId: "run-stdin-failed",
+ target: { kind: "remote", transport: "sandbox", remoteCwd: rootDir, runner },
+ runtimeRootDir: path.join(rootDir, "runtime"),
+ adapterKey: "acpx", command: "cat", args: [], cwd: rootDir, env: {},
+ onLog: async (stream, chunk) => { if (stream === "stderr") stderr += chunk; },
+ });
+ let peer: net.Socket | undefined;
+ try {
+ const source = await readFile(bridge!.agentCommand, "utf8");
+ const port = Number(/port: (\d+)/.exec(source)![1]);
+ const token = JSON.parse(/const token = (".*?");/.exec(source)![1]) as string;
+ peer = net.createConnection({ host: "127.0.0.1", port });
+ peer.setEncoding("utf8");
+ peer.on("error", () => {});
+ let output = "";
+ peer.on("data", (chunk) => { output += chunk; });
+ const closed = new Promise((resolve) => peer!.on("close", () => resolve()));
+ await new Promise((resolve) => peer!.once("connect", resolve));
+ for (const text of ["first", "second"])
+ peer.write(JSON.stringify({ token, type: "stdin", data: Buffer.from(text).toString("base64") }) + "\n");
+ await closed;
+ expect(attempts).toBe(expectedAttempts);
+ expect(laterWrite).toBe(false);
+ expect(JSON.parse(output)).toEqual({ type: "error", message: "ACP process session input delivery failed." });
+ expect(stderr).toContain("ACP process session input delivery failed.");
+ expect(stderr).not.toContain(failure);
+ } finally {
+ peer?.destroy();
+ await bridge?.stop();
+ }
+ }, 15_000);
+
+ it("stops after exhausted input retries even when failure logging stalls", async () => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-stdin-log-stall-"));
+ cleanupDirs.push(rootDir);
+ let attempts = 0;
+ let loggingStarted = false;
+ let releaseLog!: () => void;
+ const stalledLog = new Promise((resolve) => { releaseLog = resolve; });
+ const runner = createLocalSandboxRunner(async (script) => {
+ if (script.startsWith("mkdir -p") && script.includes("/stdin/000000000001.json")) {
+ attempts += 1;
+ throw new Error("Request failed with status code 502");
+ }
+ });
+ const bridge = await startAdapterExecutionTargetProcessSessionBridge({
+ runId: "run-stdin-log-stall",
+ target: { kind: "remote", transport: "sandbox", remoteCwd: rootDir, runner },
+ runtimeRootDir: path.join(rootDir, "runtime"),
+ adapterKey: "acpx", command: "cat", args: [], cwd: rootDir, env: {},
+ onLog: async (stream) => {
+ if (stream === "stderr") {
+ loggingStarted = true;
+ await stalledLog;
+ }
+ },
+ });
+ let peer: net.Socket | undefined;
+ let stop: Promise | undefined;
+ try {
+ const source = await readFile(bridge!.agentCommand, "utf8");
+ const port = Number(/port: (\d+)/.exec(source)![1]);
+ const token = JSON.parse(/const token = (".*?");/.exec(source)![1]) as string;
+ peer = net.createConnection({ host: "127.0.0.1", port });
+ peer.setEncoding("utf8");
+ peer.on("error", () => {});
+ let output = "";
+ peer.on("data", (chunk) => { output += chunk; });
+ const closed = new Promise((resolve) => peer!.once("close", resolve));
+ await new Promise((resolve) => peer!.once("connect", resolve));
+ peer.write(JSON.stringify({ token, type: "stdin", data: Buffer.from("input").toString("base64") }) + "\n");
+ await closed;
+ expect(attempts).toBe(3);
+ expect(loggingStarted).toBe(true);
+ expect(JSON.parse(output)).toEqual({ type: "error", message: "ACP process session input delivery failed." });
+ let stopped = false;
+ stop = bridge!.stop().then(() => { stopped = true; });
+ // Teardown has a three-second acknowledgement budget. It must finish
+ // while the run-log promise remains unresolved, including local cleanup.
+ await waitFor(() => stopped, 6_000);
+ await expect(lstat(bridge!.agentCommand)).rejects.toMatchObject({ code: "ENOENT" });
+ } finally {
+ releaseLog();
+ peer?.destroy();
+ await (stop ?? bridge?.stop());
+ }
+ }, 15_000);
+
// ---- Host atomic-write tests ------------------------------------------
+ it.each(["fails", "stalls"])("preserves the upload failure when best-effort cleanup %s", async (cleanupMode) => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-upload-cleanup-"));
+ cleanupDirs.push(rootDir);
+ const local = createLocalSandboxRunner();
+ const uploadFailure = new Error("Request failed with status code 502");
+ let cleanupAttempted = false;
+ let rejectCleanup!: (error: Error) => void;
+ const stalledCleanup = new Promise((_resolve, reject) => { rejectCleanup = reject; });
+ // Observe the test-owned promise even in the immediate-failure case.
+ void stalledCleanup.catch(() => {});
+ const client = createCommandManagedSandboxCallbackBridgeQueueClient({
+ remoteCwd: rootDir,
+ runner: {
+ execute: async (input) => {
+ const script = input.args?.[1] ?? "";
+ if (script.startsWith("rm -f")) {
+ cleanupAttempted = true;
+ if (cleanupMode === "stalls") return stalledCleanup;
+ throw new Error("Request failed with status code 403");
+ }
+ const result = await local.execute(input);
+ if (script.startsWith("printf")) throw uploadFailure;
+ return result;
+ },
+ },
+ });
+ try {
+ await expect(Promise.race([
+ client.writeTextFile(path.join(rootDir, "message.json"), "test input"),
+ delay(1_000).then(() => { throw new Error("Upload waited for stalled cleanup"); }),
+ ])).rejects.toBe(uploadFailure);
+ expect(cleanupAttempted).toBe(true);
+ } finally {
+ rejectCleanup(new Error("Cleanup unavailable"));
+ }
+ });
+
// A runner that executes each bridge shell script on the local filesystem,
// so the test exercises the real command-managed `writeTextFile` script.
function createLocalShellRunner(scripts: string[]) {
@@ -572,7 +811,7 @@ describe("stdin file race (parent PAP-4037)", () => {
expect(finalizeScript).toBeDefined();
expect(finalizeScript).toContain(`mv `);
expect(finalizeScript).not.toContain(`> '${jsonPath}'`);
- expect(finalizeScript).toContain(`> '${jsonPath}.paperclip-upload.decoded'`);
+ expect(finalizeScript).toMatch(/> '[^']+\.paperclip-upload\.decoded'/);
});
it("never exposes a partial .json file under a concurrent reader (command-managed host write)", async () => {
diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts
index 54b055bac6..ca816ca25c 100644
--- a/packages/adapter-utils/src/execution-target.ts
+++ b/packages/adapter-utils/src/execution-target.ts
@@ -1985,6 +1985,11 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
const target = input.target;
const onLog = input.onLog ?? (async () => {});
+ // Failure diagnostics are best effort: stalled or failed run-log persistence
+ // must not prevent sending shutdown or removing the bridge's session files.
+ const logFailureWithoutWaiting = (message: string) => {
+ void Promise.resolve().then(() => onLog("stderr", message)).catch(() => undefined);
+ };
const runner = requireSandboxRunner(target);
// Run one unit of run-time work under its named wrapper span when a span
// runner is injected. Without a runner, run the work under the current run
@@ -2135,6 +2140,28 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
// a big earlier chunk, so the wrapper reads the stdin bytes out of order and
// corrupts a large prompt on the stdin path.
let stdinWriteChain: Promise = Promise.resolve();
+ let stdinDeliveryFailed = false;
+ const writeStdinFile = async (filePath: string, body: string) => {
+ // Retry the same sequence, never the ACP request or the tool itself. Each
+ // upload uses private temporary paths, and the wrapper drops sequences it
+ // already consumed when a provider loses the final rename's response.
+ for (let attempt = 1; ; attempt += 1) {
+ try {
+ await client.writeTextFile(filePath, body);
+ return;
+ } catch (error) {
+ // Plugin RPC preserves provider messages but not HTTP error classes.
+ // Match the Daytona SDK and Cloudflare bridge's gateway diagnostics
+ // exactly; shell failures and auth errors must still fail immediately.
+ const gatewayFailure = error instanceof Error && (
+ /^Request failed with status code (502|503|504)$/.test(error.message) ||
+ /^Cloudflare sandbox bridge request failed with HTTP (502|503|504)\.$/.test(error.message)
+ );
+ if (!gatewayFailure || attempt >= 3) throw error;
+ await new Promise((resolve) => setTimeout(resolve, attempt * 250));
+ }
+ }
+ };
let pollTimer: NodeJS.Timeout | null = null;
const pendingRemoteEvents: Array<{
type?: string;
@@ -2266,19 +2293,24 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
// Chain this write after the previous one, so the atomic rename for
// file N finishes before the write for file N+1 starts. Keep the
// per-message `sandbox.agentSession.sendInput` span inside the chain.
- const write = stdinWriteChain.then(() =>
- runRuntimeWork(AGENT_SESSION_SEND_INPUT_SPAN, () =>
- client.writeTextFile(filePath, jsonLine(stdinPayload)),
- ),
- );
- // The next message chains after this write on success or failure, so a
- // failed write never blocks the chain. This mirrors the wrapper
- // `writeChain` pattern for its event files.
- stdinWriteChain = write.then(() => undefined, () => undefined);
- // Keep the failure behavior: send one error line, then destroy the socket.
- write.catch((error) => {
- nextSocket.write(jsonLine({ type: "error", message: error instanceof Error ? error.message : String(error) }));
- nextSocket.destroy();
+ stdinWriteChain = stdinWriteChain.then(async () => {
+ if (stdinDeliveryFailed) return;
+ try {
+ await runRuntimeWork(AGENT_SESSION_SEND_INPUT_SPAN, () =>
+ writeStdinFile(filePath, jsonLine(stdinPayload)),
+ );
+ } catch {
+ stdinDeliveryFailed = true;
+ stopping = true;
+ const message = "ACP process session input delivery failed.";
+ // Flush the diagnostic before closing; destroy() can discard it
+ // and leave only ACP's generic connection_close error. Do not
+ // expose provider error text, which may contain a command payload.
+ nextSocket.end(jsonLine({ type: "error", message }));
+ // stop() awaits this input chain before sending shutdown. Run-log
+ // persistence must not hold teardown open when it stalls or fails.
+ logFailureWithoutWaiting(`[paperclip] ${message}\n`);
+ }
});
}
}
@@ -2549,10 +2581,9 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
]);
stopReadingForShutdownAck = true;
if (!acknowledgedInTime) {
- await onLog(
- "stderr",
+ logFailureWithoutWaiting(
`[paperclip] ACP process session wrapper did not acknowledge shutdown within ${DEFAULT_PROCESS_SESSION_SHUTDOWN_WAIT_MS}ms; removing the session directory anyway.\n`,
- ).catch(() => undefined);
+ );
}
// Unconditional: this removal runs whether or not the wrapper
// acknowledged, and whether or not any event (real or forged) arrived
@@ -2983,6 +3014,14 @@ async function pollStdin() {
for (const name of entries) {
if (shuttingDown) break;
const entrySeq = Number.parseInt(name, 10);
+ const file = path.posix.join(stdinDir, name);
+ // A successful publication can be retried after its provider response
+ // was lost, even after we consumed it. Never send those bytes twice or
+ // move the expected sequence backwards. This also handles late uploads.
+ if (Number.isFinite(entrySeq) && entrySeq < stdinExpectedSeq) {
+ await fs.rm(file, { force: true }).catch(() => undefined);
+ continue;
+ }
// Hold the send order when an earlier file has not appeared. Do not consume
// this later file: wait for the missing file on a later cycle, bounded by
// the retry budget. After the budget, fail loud and advance past the gap,
@@ -3001,7 +3040,6 @@ async function pollStdin() {
stdinGapRetries = 0;
stdinExpectedSeq = entrySeq;
}
- const file = path.posix.join(stdinDir, name);
let message;
try {
// Hardening (I3): open with O_NOFOLLOW where the platform defines it,
diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts
index d9145c9c25..40776a3da5 100644
--- a/packages/adapter-utils/src/sandbox-callback-bridge.ts
+++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts
@@ -701,23 +701,40 @@ export function createCommandManagedSandboxCallbackBridgeQueueClient(input: {
// then moves the complete decoded content onto the final `.json` path.
// A direct `> remotePath` redirect truncates the final path before the
// decode writes it, so a reader can see an empty or partial file.
- const tempPath = `${remotePath}.paperclip-upload.b64`;
- const decodedPath = `${remotePath}.paperclip-upload.decoded`;
- await runChecked(
- `prepare upload ${remotePath}`,
- `mkdir -p ${shellQuote(remoteDir)} && rm -f ${shellQuote(tempPath)} ${shellQuote(decodedPath)} && : > ${shellQuote(tempPath)}`,
- );
- const base64Body = toBuffer(Buffer.from(body, "utf8")).toString("base64");
- for (const chunk of base64Chunks(base64Body)) {
+ // A failed provider response does not prove the remote command stopped.
+ // Keep concurrent or retried uploads from truncating each other's bytes.
+ const uploadPath = `${remotePath}.${randomUUID()}.paperclip-upload`;
+ const tempPath = `${uploadPath}.b64`;
+ const decodedPath = `${uploadPath}.decoded`;
+ try {
await runChecked(
- `append upload chunk ${remotePath}`,
- `printf '%s' ${shellQuote(chunk)} >> ${shellQuote(tempPath)}`,
+ `prepare upload ${remotePath}`,
+ `mkdir -p ${shellQuote(remoteDir)} && rm -f ${shellQuote(tempPath)} ${shellQuote(decodedPath)} && : > ${shellQuote(tempPath)}`,
);
+ const base64Body = toBuffer(Buffer.from(body, "utf8")).toString("base64");
+ for (const chunk of base64Chunks(base64Body)) {
+ await runChecked(
+ `append upload chunk ${remotePath}`,
+ `printf '%s' ${shellQuote(chunk)} >> ${shellQuote(tempPath)}`,
+ );
+ }
+ await runChecked(
+ `finalize upload ${remotePath}`,
+ `base64 -d < ${shellQuote(tempPath)} > ${shellQuote(decodedPath)} && mv ${shellQuote(decodedPath)} ${shellQuote(remotePath)} && rm -f ${shellQuote(tempPath)}`,
+ );
+ } catch (error) {
+ // Abandon only this attempt's intermediates, never the published file
+ // or another attempt. A late finalize may fail or finish publishing;
+ // either is safe for a sequence-aware caller. Preserve the original
+ // failure even when the provider is still unavailable for cleanup.
+ // Cleanup must not put another provider timeout on the retry/shutdown
+ // path. Its unique paths stay safe to remove after this call returns.
+ void runChecked(
+ `clean failed upload ${remotePath}`,
+ `rm -f ${shellQuote(tempPath)} ${shellQuote(decodedPath)}`,
+ ).catch(() => undefined);
+ throw error;
}
- await runChecked(
- `finalize upload ${remotePath}`,
- `base64 -d < ${shellQuote(tempPath)} > ${shellQuote(decodedPath)} && mv ${shellQuote(decodedPath)} ${shellQuote(remotePath)} && rm -f ${shellQuote(tempPath)}`,
- );
},
writeResponseFile: async (responsePath, body, options = {}) => {
const responseDir = path.posix.dirname(responsePath);
diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts
index 9309731a6d..a3fa946571 100644
--- a/server/src/__tests__/issues-service.test.ts
+++ b/server/src/__tests__/issues-service.test.ts
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { asc, eq } from "drizzle-orm";
-import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { sql } from "drizzle-orm";
import {
activityLog,
@@ -41,7 +41,9 @@ import {
deriveIssueCommentRunLogAttribution,
ISSUE_LIST_MAX_LIMIT,
issueService,
+ readIssueCommentRunLogText,
} from "../services/issues.ts";
+import { getRunLogStore } from "../services/run-log-store.js";
import {
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
@@ -149,6 +151,155 @@ describeEmbeddedPostgres("issueService run attachment artifacts", () => {
}, 20_000);
});
+describe("readIssueCommentRunLogText", () => {
+ it("cancels timed-out storage reads so later listings can recover", async () => {
+ let active = 0;
+ const cleanups: Array<() => void> = [];
+ const read = vi.spyOn(getRunLogStore(), "read").mockImplementation((_handle, options) =>
+ new Promise((_resolve, reject) => {
+ active += 1;
+ let settled = false;
+ const abort = () => {
+ if (settled) return;
+ settled = true;
+ active -= 1;
+ reject(new DOMException("Read aborted", "AbortError"));
+ };
+ cleanups.push(abort);
+ options?.signal?.addEventListener("abort", abort, { once: true });
+ }),
+ );
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+ const run = { runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes: null };
+ const firstBatch = Array.from({ length: 8 }, () => readIssueCommentRunLogText(run));
+ try {
+ await vi.advanceTimersByTimeAsync(3_000);
+ expect(await Promise.all(firstBatch)).toEqual(Array(8).fill(""));
+ expect(active).toBe(0);
+ read.mockResolvedValueOnce({ content: "storage recovered" });
+ await expect(readIssueCommentRunLogText(run)).resolves.toBe("storage recovered");
+ expect(read).toHaveBeenCalledTimes(9);
+ } finally {
+ for (const cleanup of cleanups) cleanup();
+ await Promise.allSettled(firstBatch);
+ await vi.advanceTimersByTimeAsync(0);
+ vi.useRealTimers();
+ read.mockRestore();
+ }
+ });
+
+ it("keeps readable attribution evidence for concurrent listings", async () => {
+ let release!: () => void;
+ const gate = new Promise((resolve) => { release = resolve; });
+ const read = vi.spyOn(getRunLogStore(), "read").mockImplementation(async () => {
+ await gate;
+ return { content: "comment id: legacy-comment" };
+ });
+ const run = { runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes: null };
+ const listings = Array.from({ length: 2 }, () =>
+ Promise.all(Array.from({ length: 8 }, () => readIssueCommentRunLogText(run))),
+ );
+ try {
+ release();
+ for (const listing of listings) {
+ expect(await listing).toEqual(Array(8).fill("comment id: legacy-comment"));
+ }
+ expect(read).toHaveBeenCalledTimes(16);
+ } finally {
+ release();
+ await Promise.allSettled(listings);
+ read.mockRestore();
+ }
+ });
+
+ it.each([null, 128])("keeps partial attribution evidence when storage fails with logBytes=%s", async (logBytes) => {
+ const read = vi.spyOn(getRunLogStore(), "read").mockRejectedValue(new Error("Storage gateway unavailable"));
+ const run = { runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes };
+ try {
+ await expect(readIssueCommentRunLogText(run)).resolves.toBe("");
+ read.mockResolvedValueOnce({ content: "earlier evidence", nextOffset: 16 });
+ await expect(readIssueCommentRunLogText(run)).resolves.toBe("earlier evidence");
+ } finally {
+ read.mockRestore();
+ }
+ });
+
+ it("bounds reads that ignore cancellation and stops late pagination after the deadline", async () => {
+ let release!: (value: { content: string; nextOffset: number }) => void;
+ const stalled = new Promise<{ content: string; nextOffset: number }>((resolve) => {
+ release = resolve;
+ });
+ const read = vi.spyOn(getRunLogStore(), "read")
+ .mockResolvedValueOnce({ content: "earlier evidence", nextOffset: 16 })
+ .mockReturnValueOnce(stalled);
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+ let result: string | undefined;
+ const pending = readIssueCommentRunLogText({
+ runId: "run", logStore: "local_file", logRef: "test/run.ndjson", logBytes: null,
+ }).then((value) => { result = value; });
+ try {
+ await vi.advanceTimersByTimeAsync(3_000);
+ expect(result).toBe("earlier evidence");
+ expect(read.mock.calls[1]?.[1]?.signal?.aborted).toBe(true);
+ release({ content: "too late", nextOffset: 24 });
+ await pending;
+ await vi.advanceTimersByTimeAsync(0);
+ expect(read).toHaveBeenCalledTimes(2);
+ expect(result).toBe("earlier evidence");
+ } finally {
+ release({ content: "", nextOffset: 0 });
+ await pending.catch(() => {});
+ vi.useRealTimers();
+ read.mockRestore();
+ }
+ });
+
+ it.each([null, 128, 0])("reads existing attribution markers with logBytes=%s", async (logBytes) => {
+ const commentId = randomUUID();
+ const runId = randomUUID();
+ const agentId = randomUUID();
+ const read = vi.spyOn(getRunLogStore(), "read")
+ .mockResolvedValueOnce({ content: "comment id: ", nextOffset: 12 })
+ .mockResolvedValueOnce({ content: commentId + "\n" });
+ try {
+ const logContent = await readIssueCommentRunLogText({
+ runId, logStore: "local_file", logRef: "test/run.ndjson", logBytes,
+ });
+ const derived = deriveIssueCommentRunLogAttribution(
+ [{
+ id: commentId,
+ authorAgentId: null,
+ authorUserId: "local-board",
+ createdByRunId: null,
+ createdAt: new Date("2020-01-01T00:00:01Z"),
+ }],
+ [{
+ runId,
+ agentId,
+ createdAt: new Date("2020-01-01T00:00:00Z"),
+ startedAt: new Date("2020-01-01T00:00:00Z"),
+ finishedAt: new Date("2020-01-01T00:00:02Z"),
+ logContent,
+ }],
+ );
+ if (logBytes === 0) {
+ expect(read).not.toHaveBeenCalled();
+ expect(derived.size).toBe(0);
+ } else {
+ expect(read).toHaveBeenCalledTimes(2);
+ expect(read.mock.calls[1]?.[1]?.offset).toBe(12);
+ expect(derived.get(commentId)).toEqual({
+ derivedAuthorAgentId: agentId,
+ derivedCreatedByRunId: runId,
+ derivedAuthorSource: "run_log_comment_post",
+ });
+ }
+ } finally {
+ read.mockRestore();
+ }
+ });
+});
+
describe("deriveIssueCommentRunLogAttribution", () => {
it("recovers agent attribution from run logs that printed the posted comment id", () => {
const commentId = randomUUID();
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index d722d3ea45..e923d178d0 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -24931,8 +24931,8 @@ export function heartbeatService(
: null;
let logSummary: {
- bytes: number;
- sha256?: string;
+ bytes: number | null;
+ sha256?: string | null;
compressed: boolean;
} | null = null;
if (handle) {
@@ -25650,8 +25650,8 @@ export function heartbeatService(
logger.error({ err, runId }, "heartbeat execution failed");
let logSummary: {
- bytes: number;
- sha256?: string;
+ bytes: number | null;
+ sha256?: string | null;
compressed: boolean;
} | null = null;
if (handle) {
diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts
index 9feb50394a..ceb13379f7 100644
--- a/server/src/services/issues.ts
+++ b/server/src/services/issues.ts
@@ -231,6 +231,7 @@ const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES = 2_000_000;
const ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES = 256_000;
const ISSUE_COMMENT_RUN_LOG_DERIVATION_END_SLACK_MS = 60_000;
const ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_PARALLEL_READS = 8;
+const ISSUE_COMMENT_RUN_LOG_DERIVATION_TIMEOUT_MS = 3_000;
export const ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_DAYS = 7;
const ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_MS =
ISSUE_CREATE_IDEMPOTENCY_KEY_RETENTION_DAYS * 24 * 60 * 60 * 1000;
@@ -6542,6 +6543,76 @@ async function countBlockedInboxIssues(
}, 0);
}
+export async function readIssueCommentRunLogText(run: {
+ runId?: string | null;
+ logStore: string | null;
+ logRef: string | null;
+ logBytes: number | null;
+}) {
+ if (run.logStore !== "local_file" || !run.logRef) return "";
+ // A timed-out finalization leaves size unknown even when earlier entries
+ // exist. Read those logs within the same byte budget as a known-size log.
+ if (run.logBytes !== null && (!Number.isFinite(run.logBytes) || run.logBytes <= 0)) return "";
+
+ const logRef = run.logRef;
+ const store = getRunLogStore();
+ let offset = 0;
+ let content = "";
+ let nextOffset: number | undefined = 0;
+ const controller = new AbortController();
+ let readTimer: NodeJS.Timeout | undefined;
+
+ const readChunks = async () => {
+ while (nextOffset !== undefined) {
+ controller.signal.throwIfAborted();
+ const remainingBytes =
+ ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES -
+ Buffer.byteLength(content, "utf8");
+ if (remainingBytes <= 0) break;
+ const chunk = await store.read(
+ { store: "local_file", logRef },
+ {
+ offset,
+ limitBytes: Math.min(ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES, remainingBytes),
+ signal: controller.signal,
+ },
+ );
+ controller.signal.throwIfAborted();
+ content += chunk.content;
+ nextOffset = chunk.nextOffset;
+ offset = chunk.nextOffset ?? 0;
+ }
+ };
+
+ try {
+ await Promise.race([
+ readChunks(),
+ new Promise((_resolve, reject) => {
+ readTimer = setTimeout(() => {
+ const reason = new DOMException("Attribution log read timed out", "TimeoutError");
+ // Cancellation closes storage work where supported, but filesystem
+ // I/O can delay stream destruction. Keep the response deadline too.
+ reject(reason);
+ controller.abort(reason);
+ }, ISSUE_COMMENT_RUN_LOG_DERIVATION_TIMEOUT_MS);
+ readTimer.unref?.();
+ }),
+ ]);
+ } catch (err) {
+ // Attribution enriches already-authorized comments. Missing, failed, or
+ // stalled storage must not prevent listing them; keep any evidence read.
+ // Do not log raw provider errors, which can contain credentialed URLs.
+ logger.warn(
+ { runId: run.runId ?? undefined, logRef, status: err instanceof HttpError ? err.status : undefined },
+ "could not read heartbeat run log while deriving optional issue comment metadata",
+ );
+ } finally {
+ clearTimeout(readTimer);
+ }
+
+ return content;
+}
+
export function issueService(db: Db) {
const instanceSettings = instanceSettingsService(db);
const treeControlSvc = issueTreeControlService(db);
@@ -6760,54 +6831,6 @@ export function issueService(db: Db) {
};
}
- async function readRunLogText(run: {
- runId?: string | null;
- logStore: string | null;
- logRef: string | null;
- logBytes: number | null;
- }) {
- if (run.logStore !== "local_file" || !run.logRef) return "";
- const logBytes = Number(run.logBytes ?? 0);
- if (!Number.isFinite(logBytes) || logBytes <= 0) return "";
-
- const store = getRunLogStore();
- let offset = 0;
- let content = "";
- let nextOffset: number | undefined = 0;
-
- try {
- while (nextOffset !== undefined) {
- const remainingBytes =
- ISSUE_COMMENT_RUN_LOG_DERIVATION_MAX_LOG_BYTES -
- Buffer.byteLength(content, "utf8");
- if (remainingBytes <= 0) break;
- const chunk = await store.read(
- { store: "local_file", logRef: run.logRef },
- {
- offset,
- limitBytes: Math.min(
- ISSUE_COMMENT_RUN_LOG_DERIVATION_CHUNK_BYTES,
- remainingBytes,
- ),
- },
- );
- content += chunk.content;
- nextOffset = chunk.nextOffset;
- offset = chunk.nextOffset ?? 0;
- }
- } catch (err) {
- if (err instanceof HttpError && err.status === 404) {
- logger.warn(
- { err, runId: run.runId ?? undefined, logRef: run.logRef },
- "missing heartbeat run log while deriving issue comment metadata",
- );
- return content;
- }
- throw err;
- }
-
- return content;
- }
// Persist a resolved attribution so subsequent reads stop re-scanning run
// logs (and old "Board" threads stay fixed durably). Best-effort: a write
@@ -7027,7 +7050,7 @@ export function issueService(db: Db) {
);
await Promise.all(
batch.map(async (run) => {
- logByRunId.set(run.runId, await readRunLogText(run));
+ logByRunId.set(run.runId, await readIssueCommentRunLogText(run));
}),
);
}
diff --git a/server/src/services/run-log-store-cancellation.test.ts b/server/src/services/run-log-store-cancellation.test.ts
new file mode 100644
index 0000000000..36d405d132
--- /dev/null
+++ b/server/src/services/run-log-store-cancellation.test.ts
@@ -0,0 +1,72 @@
+import { createServer } from "node:http";
+import { promises as fs } from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createDurableRunLogStore } from "./run-log-store.js";
+import { createS3StorageProvider } from "../storage/s3-provider.js";
+
+afterEach(() => vi.unstubAllEnvs());
+
+describe("run-log read cancellation", () => {
+ it.each(["head", "get", "body"])("closes a stalled S3 %s connection and permits a subsequent read", async (stage) => {
+ // Exercise the real SDK against an on-host server. No provider credentials
+ // or external network are used by this cancellation regression.
+ vi.stubEnv("AWS_ACCESS_KEY_ID", "test-access-key");
+ vi.stubEnv("AWS_SECRET_ACCESS_KEY", "test-secret-key");
+ vi.stubEnv("AWS_SESSION_TOKEN", "");
+ const basePath = await fs.mkdtemp(path.join(os.tmpdir(), "run-log-abort-"));
+ let recover = false;
+ let closed = false;
+ let started!: () => void;
+ const stalled = new Promise((resolve) => { started = resolve; });
+ const server = createServer((request, response) => {
+ const shouldStall = !recover && (stage === "head" ? request.method === "HEAD" : request.method === "GET");
+ if (shouldStall) {
+ response.on("close", () => { closed = true; });
+ if (stage === "body") {
+ response.writeHead(200, { "Content-Length": "4" });
+ response.write("d");
+ }
+ started();
+ return;
+ }
+ response.writeHead(200, { "Content-Length": "4" });
+ response.end(request.method === "HEAD" ? undefined : "data");
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("Test server did not bind");
+ const provider = createS3StorageProvider({
+ bucket: "test-bucket", region: "us-east-1", forcePathStyle: true,
+ endpoint: `http://127.0.0.1:${address.port}`,
+ });
+ const store = createDurableRunLogStore({ basePath, s3: { provider } });
+ const handle = { store: "local_file" as const, logRef: "missing.ndjson" };
+ const controller = new AbortController();
+ const read = store.read(handle, { signal: controller.signal });
+ void read.catch(() => {});
+ try {
+ await stalled;
+ controller.abort();
+ await expect(read).rejects.toMatchObject({ name: "AbortError" });
+ await vi.waitFor(() => expect(closed).toBe(true));
+ recover = true;
+ expect(await store.read(handle)).toEqual({ content: "data", nextOffset: undefined });
+ } finally {
+ controller.abort();
+ server.closeAllConnections();
+ await new Promise((resolve) => server.close(() => resolve()));
+ await fs.rm(basePath, { recursive: true, force: true });
+ }
+ }, 10_000);
+
+ it("rejects an already-cancelled local read before opening a file", async () => {
+ const store = createDurableRunLogStore({ basePath: os.tmpdir() });
+ const controller = new AbortController();
+ controller.abort();
+ await expect(store.read({ store: "local_file", logRef: "unused.ndjson" }, {
+ signal: controller.signal,
+ })).rejects.toMatchObject({ name: "AbortError" });
+ });
+});
diff --git a/server/src/services/run-log-store.test.ts b/server/src/services/run-log-store.test.ts
index ab12ae17b3..ccadb78c30 100644
--- a/server/src/services/run-log-store.test.ts
+++ b/server/src/services/run-log-store.test.ts
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { promises as fs } from "node:fs";
import path from "node:path";
import os from "node:os";
+import { createHash } from "node:crypto";
import { Readable } from "node:stream";
import { createDurableRunLogStore } from "./run-log-store.js";
import type { StorageProvider } from "../storage/types.js";
@@ -100,6 +101,102 @@ describe("createDurableRunLogStore", () => {
expect(objects.get(key)!.toString("utf8")).toContain("line-B");
});
+ it.each(["finishes", "fails"])("waits for an accepted file append that %s before finalizing", async (outcome) => {
+ const { provider, objects } = createMemoryProvider();
+ const store = createDurableRunLogStore({ basePath: baseDir, s3: { provider } });
+ const handle = await store.begin(begin);
+ let release!: () => void;
+ const gate = new Promise((resolve) => { release = resolve; });
+ const appendFile = fs.appendFile.bind(fs);
+ const spy = vi.spyOn(fs, "appendFile").mockImplementationOnce(async (...args) => {
+ await gate;
+ if (outcome === "fails") throw new Error("disk unavailable");
+ await appendFile(...args);
+ });
+ const append = store.append(handle, { stream: "stderr", chunk: "late diagnostic", ts: "t1" });
+ // Observe the deliberate rejection independently of finalization.
+ void append.catch(() => {});
+ let finalized = false;
+ const finalize = store.finalize(handle).then((summary) => {
+ finalized = true;
+ return summary;
+ });
+ try {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ expect(finalized).toBe(false);
+ release();
+ if (outcome === "fails") await expect(append).rejects.toThrow("disk unavailable");
+ else await append;
+ const summary = await finalize;
+ const local = await fs.readFile(path.join(baseDir, handle.logRef));
+ expect(summary.bytes).toBe(local.length);
+ expect(summary.sha256).toBe(createHash("sha256").update(local).digest("hex"));
+ expect(objects.get(handle.logRef)).toEqual(local);
+ expect(local.toString()).toBe(outcome === "fails" ? "" : JSON.stringify({
+ ts: "t1", stream: "stderr", chunk: "late diagnostic",
+ }) + "\n");
+ } finally {
+ release();
+ await append.catch(() => {});
+ await finalize;
+ spy.mockRestore();
+ }
+ });
+
+ it("ignores appends once finalization starts so the durable snapshot stays immutable", async () => {
+ const { provider, objects } = createMemoryProvider();
+ const store = createDurableRunLogStore({ basePath: baseDir, s3: { provider } });
+ const handle = await store.begin(begin);
+ await store.append(handle, { stream: "stdout", chunk: "accepted", ts: "t1" });
+ const finalize = store.finalize(handle);
+ expect(await store.append(handle, { stream: "stderr", chunk: "too late", ts: "t2" })).toBe(0);
+ const summary = await finalize;
+ expect(await store.append(handle, { stream: "stderr", chunk: "also too late", ts: "t3" })).toBe(0);
+ const local = await fs.readFile(path.join(baseDir, handle.logRef));
+ expect(local.toString()).not.toContain("too late");
+ expect(summary.bytes).toBe(local.length);
+ expect(summary.sha256).toBe(createHash("sha256").update(local).digest("hex"));
+ expect(objects.get(handle.logRef)).toEqual(local);
+ });
+
+ it("leaves final metadata unknown when an append stalls and never mirrors its late completion", async () => {
+ const { provider, calls } = createMemoryProvider();
+ const store = createDurableRunLogStore({ basePath: baseDir, s3: { provider, inflightMirrorMs: 10_000 } });
+ const handle = await store.begin(begin);
+ let release!: () => void;
+ const gate = new Promise((resolve) => { release = resolve; });
+ const appendFile = fs.appendFile.bind(fs);
+ const spy = vi.spyOn(fs, "appendFile").mockImplementationOnce(async (...args) => {
+ await gate;
+ await appendFile(...args);
+ });
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+ const append = store.append(handle, { stream: "stderr", chunk: "stalled diagnostic", ts: "t1" });
+ let summary: Awaited> | undefined;
+ const finalize = store.finalize(handle).then((result) => { summary = result; });
+ try {
+ await vi.advanceTimersByTimeAsync(3_000);
+ expect(summary).toEqual({ bytes: null, sha256: null, compressed: false });
+ expect(warn).toHaveBeenCalled();
+ expect(calls.put).toBe(0);
+ release();
+ await append;
+ await vi.advanceTimersByTimeAsync(20_000);
+ await store.flushInflightMirrors!();
+ expect(calls.put).toBe(0);
+ expect(await store.finalize(handle)).toEqual(summary);
+ expect(await store.append(handle, { stream: "stderr", chunk: "too late", ts: "t2" })).toBe(0);
+ } finally {
+ release();
+ await append;
+ await finalize;
+ vi.useRealTimers();
+ spy.mockRestore();
+ warn.mockRestore();
+ }
+ });
+
it("falls back to S3 when the local file is gone (the pod-roll case that caused 'Run log not found')", async () => {
const { provider } = createMemoryProvider();
const store = createDurableRunLogStore({ basePath: baseDir, s3: { provider, keyPrefix: "run-logs" } });
@@ -163,25 +260,15 @@ describe("createDurableRunLogStore", () => {
expect(caughtUp.nextOffset).toBeUndefined();
});
- it("falls back to S3 when the local file vanishes between stat() and open (TOCTOU race)", async () => {
- const { provider } = createMemoryProvider();
- const store = createDurableRunLogStore({ basePath: baseDir, s3: { provider, keyPrefix: "run-logs" } });
+ it("reads local pages without waiting for a separate metadata request", async () => {
+ const store = createDurableRunLogStore({ basePath: baseDir });
const handle = await store.begin(begin);
- await store.append(handle, { stream: "stdout", chunk: "raced-line", ts: "t1" });
- await store.finalize(handle);
- // Delete the local file DURING stat(), i.e. after it reports the file
- // present but before createReadStream opens it -> the open hits ENOENT.
- const realStat = fs.stat.bind(fs);
- const statSpy = vi.spyOn(fs, "stat").mockImplementation(async (target, ...rest) => {
- const result = await realStat(target as Parameters[0], ...(rest as []));
- if (String(target).endsWith(".ndjson")) {
- await fs.rm(target as string, { force: true });
- }
- return result;
- });
+ await fs.writeFile(path.join(baseDir, handle.logRef), "0123456789");
+ const statSpy = vi.spyOn(fs, "stat").mockRejectedValue(new Error("Metadata unavailable"));
try {
- const res = await store.read(handle);
- expect(res.content).toContain("raced-line");
+ expect(await store.read(handle, { offset: 2, limitBytes: 4 })).toEqual({ content: "2345", nextOffset: 6 });
+ expect(await store.read(handle, { offset: 6, limitBytes: 4 })).toEqual({ content: "6789", nextOffset: undefined });
+ expect(await store.read(handle, { offset: 20, limitBytes: 4 })).toEqual({ content: "", nextOffset: undefined });
} finally {
statSpy.mockRestore();
}
diff --git a/server/src/services/run-log-store.ts b/server/src/services/run-log-store.ts
index f07ede9abd..7e7b4597ea 100644
--- a/server/src/services/run-log-store.ts
+++ b/server/src/services/run-log-store.ts
@@ -1,6 +1,7 @@
import { createReadStream, promises as fs } from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
+import { addAbortSignal } from "node:stream";
import { notFound } from "../errors.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
import { createS3StorageProvider } from "../storage/s3-provider.js";
@@ -16,6 +17,7 @@ export interface RunLogHandle {
export interface RunLogReadOptions {
offset?: number;
limitBytes?: number;
+ signal?: AbortSignal;
}
export interface RunLogReadResult {
@@ -24,8 +26,10 @@ export interface RunLogReadResult {
}
export interface RunLogFinalizeSummary {
- bytes: number;
- sha256?: string;
+ // Null means a stalled write prevented a verified final snapshot. Callers
+ // can still settle the run without recording a false byte count or hash.
+ bytes: number | null;
+ sha256?: string | null;
compressed: boolean;
}
@@ -100,6 +104,13 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru
const s3 = options.s3;
const s3Prefix = normalizeKeyPrefix(s3?.keyPrefix);
const inflightMirrorMs = s3?.inflightMirrorMs && s3.inflightMirrorMs > 0 ? s3.inflightMirrorMs : 0;
+ // A run owns the write handle returned by begin(). Diagnostics can be
+ // dispatched without awaiting the rest of onLog (DB progress/live events),
+ // but finalize must include every file append already accepted on that handle.
+ // Weak collections let completed handles disappear with their owning runs.
+ const pendingAppends = new WeakMap>>();
+ const closingHandles = new WeakSet();
+ const abandonedHandles = new WeakSet();
function s3Key(logRef: string): string {
return s3Prefix ? `${s3Prefix}/${logRef}` : logRef;
@@ -159,6 +170,7 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru
}
function scheduleInflightMirror(logRef: string, entry: InflightMirrorEntry): void {
+ if (inflightMirrors.get(logRef) !== entry) return;
if (entry.timer || entry.upload) return;
const delay = Math.max(0, inflightMirrorMs - (Date.now() - entry.lastMirrorAt));
entry.timer = setTimeout(() => {
@@ -205,33 +217,28 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru
filePath: string,
offset: number,
limitBytes: number,
+ signal?: AbortSignal,
): Promise {
- const stat = await fs.stat(filePath).catch(() => null);
- if (!stat) return null;
- const start = Math.max(0, Math.min(offset, stat.size));
- // No lower clamp to `start`: when the reader is fully caught up
- // (offset === size) that clamp made end === start and produced a
- // 1-byte-past-EOF range instead of an empty read.
- const end = Math.min(start + limitBytes - 1, stat.size - 1);
- if (start > end) return { content: "", nextOffset: start < stat.size ? start : undefined };
-
+ signal?.throwIfAborted();
+ const start = Math.max(0, offset);
+ // Read one extra byte to discover whether another page exists. A single
+ // abortable stream avoids an uncancellable stat before opening the file.
const chunks: Buffer[] = [];
try {
- await new Promise((resolve, reject) => {
- const stream = createReadStream(filePath, { start, end });
- stream.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
- stream.on("error", reject);
- stream.on("end", () => resolve());
- });
+ const stream = createReadStream(filePath, { start, end: start + limitBytes, signal });
+ for await (const chunk of stream) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
} catch (err) {
- // File deleted between stat() and open (pod-roll cleanup racing a read):
- // treat as missing so the caller falls through to the S3 mirror instead
- // of surfacing the very "Run log not found" this store exists to prevent.
+ signal?.throwIfAborted();
+ // A missing file, including deletion before open, falls back to S3.
if ((err as NodeJS.ErrnoException | null)?.code === "ENOENT") return null;
throw err;
}
- const content = Buffer.concat(chunks).toString("utf8");
- const nextOffset = end + 1 < stat.size ? end + 1 : undefined;
+ signal?.throwIfAborted();
+ const bytes = Buffer.concat(chunks);
+ const content = bytes.subarray(0, limitBytes).toString("utf8");
+ const nextOffset = bytes.length > limitBytes ? start + limitBytes : undefined;
return { content, nextOffset };
}
@@ -239,10 +246,13 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru
logRef: string,
offset: number,
limitBytes: number,
+ signal?: AbortSignal,
): Promise {
+ signal?.throwIfAborted();
if (!s3) throw notFound("Run log not found");
const key = s3Key(logRef);
- const head = await s3.provider.headObject({ objectKey: key });
+ const head = await s3.provider.headObject({ objectKey: key, signal });
+ signal?.throwIfAborted();
if (!head.exists) throw notFound("Run log not found");
const total = head.contentLength ?? 0;
const start = Math.max(0, Math.min(offset, total));
@@ -253,13 +263,15 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru
const end = Math.min(start + limitBytes - 1, total - 1);
if (total === 0 || start > end) return { content: "", nextOffset: start < total ? start : undefined };
- const result = await s3.provider.getObject({ objectKey: key, range: { start, end } });
+ const result = await s3.provider.getObject({ objectKey: key, range: { start, end }, signal });
+ // Destroy a body that stalls after headers arrive, including a body
+ // returned just after the caller cancelled the request.
+ if (signal) addAbortSignal(signal, result.stream);
const chunks: Buffer[] = [];
- await new Promise((resolve, reject) => {
- result.stream.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
- result.stream.on("error", reject);
- result.stream.on("end", () => resolve());
- });
+ for await (const chunk of result.stream) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+ signal?.throwIfAborted();
const content = Buffer.concat(chunks).toString("utf8");
const nextOffset = end + 1 < total ? end + 1 : undefined;
return { content, nextOffset };
@@ -289,7 +301,7 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru
},
async append(handle, event) {
- if (handle.store !== "local_file") return 0;
+ if (handle.store !== "local_file" || closingHandles.has(handle)) return 0;
const absPath = resolveWithin(basePath, handle.logRef);
const line = JSON.stringify({
ts: event.ts,
@@ -301,13 +313,47 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru
...(typeof event.seq === "number" && Number.isFinite(event.seq) ? { seq: event.seq } : {}),
});
const persisted = `${line}\n`;
- await fs.appendFile(absPath, persisted, "utf8");
- noteInflightAppend(handle.logRef);
+ let pending = pendingAppends.get(handle);
+ if (!pending) {
+ pending = new Set();
+ pendingAppends.set(handle, pending);
+ }
+ const write = fs.appendFile(absPath, persisted, "utf8").then(() => {
+ if (!closingHandles.has(handle)) noteInflightAppend(handle.logRef);
+ });
+ pending.add(write);
+ try {
+ await write;
+ } finally {
+ pending.delete(write);
+ }
return Buffer.byteLength(persisted, "utf8");
},
async finalize(handle) {
if (handle.store !== "local_file") return { bytes: 0, compressed: false };
+ if (abandonedHandles.has(handle)) return { bytes: null, sha256: null, compressed: false };
+ // Close admission before the first await. Drain file writes only, not
+ // heartbeat's later DB/live-event persistence, then freeze one consistent
+ // byte count, hash, and durable copy. Late diagnostics cannot mutate it.
+ closingHandles.add(handle);
+ let drainTimer: NodeJS.Timeout | undefined;
+ const drained = await Promise.race([
+ Promise.allSettled(pendingAppends.get(handle) ?? []).then(() => true),
+ new Promise((resolve) => {
+ drainTimer = setTimeout(() => resolve(false), 3_000);
+ drainTimer.unref?.();
+ }),
+ ]).finally(() => clearTimeout(drainTimer));
+ if (!drained) {
+ // An in-flight fs append cannot be cancelled safely. Do not hash or
+ // mirror a file it may still change, and never re-arm mirroring when
+ // that write eventually finishes. Terminal run status can still settle.
+ abandonedHandles.add(handle);
+ void retireInflightMirror(handle.logRef).catch(() => undefined);
+ console.warn("[run-log-store] Pending log writes did not settle within 3000ms; final log size and hash are unknown.");
+ return { bytes: null, sha256: null, compressed: false };
+ }
await retireInflightMirror(handle.logRef);
const absPath = resolveWithin(basePath, handle.logRef);
const stat = await fs.stat(absPath).catch(() => null);
@@ -344,14 +390,15 @@ export function createDurableRunLogStore(options: DurableRunLogStoreOptions): Ru
},
async read(handle, opts) {
+ opts?.signal?.throwIfAborted();
if (handle.store !== "local_file") throw notFound("Run log not found");
const absPath = resolveWithin(basePath, handle.logRef);
const offset = opts?.offset ?? 0;
const limitBytes = opts?.limitBytes ?? 256_000;
- const local = await readLocalRange(absPath, offset, limitBytes);
+ const local = await readLocalRange(absPath, offset, limitBytes, opts?.signal);
if (local) return local;
// Local file gone (pod rolled) -> serve from the S3 mirror if configured.
- return readS3Range(handle.logRef, offset, limitBytes);
+ return readS3Range(handle.logRef, offset, limitBytes, opts?.signal);
},
async flushInflightMirrors() {
diff --git a/server/src/storage/s3-provider.ts b/server/src/storage/s3-provider.ts
index 517ccf23d9..4939bb9b25 100644
--- a/server/src/storage/s3-provider.ts
+++ b/server/src/storage/s3-provider.ts
@@ -6,7 +6,7 @@ import {
PutObjectCommand,
} from "@aws-sdk/client-s3";
import { putS3Multipart } from "./s3-multipart.js";
-import { Readable } from "node:stream";
+import { addAbortSignal, Readable } from "node:stream";
import type { StorageProvider, GetObjectResult, HeadObjectResult } from "./types.js";
import { notFound, unprocessable } from "../errors.js";
@@ -106,10 +106,13 @@ export function createS3StorageProvider(config: S3ProviderConfig): StorageProvid
Key: key,
Range: input.range ? `bytes=${input.range.start}-${input.range.end}` : undefined,
}),
+ { abortSignal: input.signal },
);
+ const stream = await toReadableStream(output.Body);
+ if (input.signal) addAbortSignal(input.signal, stream);
return {
- stream: await toReadableStream(output.Body),
+ stream,
contentType: output.ContentType,
contentLength: output.ContentLength,
etag: output.ETag,
@@ -130,6 +133,7 @@ export function createS3StorageProvider(config: S3ProviderConfig): StorageProvid
Bucket: bucket,
Key: key,
}),
+ { abortSignal: input.signal },
);
return {
diff --git a/server/src/storage/types.ts b/server/src/storage/types.ts
index 15289f77a5..30f0845b5b 100644
--- a/server/src/storage/types.ts
+++ b/server/src/storage/types.ts
@@ -12,6 +12,8 @@ export interface PutObjectInput {
export interface GetObjectInput {
objectKey: string;
+ // S3 reads cancel pending requests and their response streams.
+ signal?: AbortSignal;
range?: {
start: number;
end: number;