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 30294009db8..43705c15932 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 766100aac29..691901b44b0 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 943b2c71982..6d14ad8a045 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 00000000000..aac6e2955eb
--- /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 00000000000..8f7c2a44213
--- /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 00000000000..11df65023e3
--- /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 00000000000..0e7a01136cd
--- /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 00000000000..2ce405e6c39
--- /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 007972cf986..d4a70696294 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 00000000000..b8a5e92fe8b
--- /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 00000000000..1f5f8d70b17
--- /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 00000000000..ec421f5faf8
--- /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 00000000000..d044c9f0094
--- /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 00000000000..b2dfe5a561b
--- /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 a4bd74796bf..371082cbdab 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 b3972ef2d12..ea06c277d49 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 1cdcf7f8074..83670ad3fa4 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 4ebf644936c..b9634405186 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 00000000000..5fe19d676e0
--- /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 c6f748934d1..27d72d43405 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 03/13] 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 e07ba3f82c0..317bdbbefee 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 7b5bef23c88..fb2a8b15eea 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 f5566737bfe..fc3c8b0bae9 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 00000000000..309bd841b88
--- /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 00000000000..15c5f3a8300
--- /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 64e41ae37ca..677875216fb 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 b0b7b896448..700e6f62ddc 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 734be7edf19..4e87c29b20a 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 7d714793dca..852e2b855be 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 04/13] 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 3c82a93a2b7..151581873d3 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 9155a9b9fa7..d6dbbbfc3ff 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 dfc5f5bb896..496b58719d2 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 05/13] 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 fb2a8b15eea..491e8e35c85 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 85d968cbc1c..d04154ee977 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 dd3694dc6ab..a4c67d20b37 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 d533986d90c..b6d73169110 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 7b682232f8d..1e75231a0b5 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 06/13] 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 d04154ee977..39d9e150fe8 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 07/13] 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 317bdbbefee..0bca5513e5c 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 07f61797313..e5cf70cf011 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 ececaebf9e4..fd0caefa8e1 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 b430de18cc2..dd36825a2a6 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 08/13] 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 0bca5513e5c..c830de4bcbd 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 36695e85c1b..bf600fdd74c 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 9341358c38a..a87deb0e89a 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 54b055bac6d..ca816ca25c3 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 d9145c9c256..40776a3da59 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 9309731a6dd..a3fa946571d 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 d722d3ea45f..e923d178d06 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 9feb50394ab..ceb13379f74 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 00000000000..36d405d132b
--- /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 ab12ae17b34..ccadb78c30f 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 f07ede9abdf..7e7b4597eae 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 517ccf23d98..4939bb9b25f 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 15289f77a5d..30f0845b5b7 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;
From d172197117a14b80a1eb2d2835a0e7cce2679656 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Tue, 29 Sep 2026 07:59:11 -0500
Subject: [PATCH 09/13] feat(storage): add plain directory sync with conflict
preflight (#14416)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent runs use workspace transport to restore and collect files.
> - Some directories belong to the agent across tasks.
> - Those directories need plain file transport without task Git state.
> - A concurrent file edit must be detected before a merge changes any
file.
> - This pull request adds optional plain-directory sync and conflict
preflight.
> - Existing task workspace sync keeps its defaults.
## Linked Issues or Issue Description
Refs #14325. This is the transport prerequisite for a replacement of its
instruction revision design with current agent files.
## What Changed
- Add an opt-in plain-directory transport mode to command and sandbox
runtimes.
- Add strict merge preflight for file edits, deletions, and directory
changes.
- Accept identical replay after an interrupted merge. Preserve competing
changes.
- Set the compiled OpenCode test executable to 0755, independent of the
CI host’s file-creation mask. Preserve the original startup error if
cleanup also fails.
## Verification
- At `ced53ae532ce6966cad5a83575d83db61af98126`, all 212 targeted
transport tests passed across workspace restore, remote managed runtime,
SSH fixture, and execution-target sandbox suites. Adapter-utils
typecheck passed.
- Real isolated SSH retry fixture previously passed with
`PAPERCLIP_ENABLE_DARWIN_SSH_ENV_LAB=1`; stale deleted files remain
absent while gitignored binary bytes survive.
- Dependent PR #14420 passed real native local, legacy local, and native
Daytona persistence E2E at `169fab46d5af21caa2269b4c1b29b69c933a6951`,
which includes all production transport changes through `ced53ae53`; the
subsequent two commits only fix the OpenCode test fixture. Nine tasks,
three server restarts, and all cleanup checks passed.
- A hosted OpenCode fixture failed twice at `ced53ae53`. Reproduced the
failure locally and in Linux with `umask 0002`: the compiler created a
group-writable executable, correctly rejected by the qualified launch
boundary. Explicit 0755 permissions fix the test without weakening the
production guard. The focused test and non-root Linux reproduction now
pass under that same mask.
- Before rebase, head `69e97de0475d34aac5d532e559a405eaf015fd2b`
includes the deterministic fixture permission fix and preserves original
bootstrap diagnostics. All production transport code is unchanged since
the 212-test validation. Fresh Greptile review is 5/5 on this exact head
with no unresolved findings. All 54 current-head checks passed, with two
conditional skips. The full CI run completed successfully, including the
previously failing OpenCode runner shard.
- Merge validation on rebased head
`c509d79dd190c5cb00dc65edfde209097ff21465`: all five commits are
patch-identical to the reviewed branch. All 54 checks passed with two
conditional skips, and fresh Greptile review is 5/5 with no findings.
One retry cleared an npm archive 404 and a Cursor fixture timeout.
## Risks
- New behavior is opt-in. Existing task snapshot behavior retains its
defaults.
- Generic strict merge preflight remains opt-in. The dependent
agent-folder feature rebases changed paths before applying them to
provide per-file last-sync-wins; it does not create a conflict-review
queue.
- This change adds no database migration, dependency, or UI.
## Model Used
OpenAI Codex, GPT-6 family. The session does not expose a more specific
model ID or context-window size. Reasoning, code execution, and tool use
assisted this change. Live provider validation used `gpt-5.6-sol`.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
.../src/command-managed-runtime.ts | 4 +
.../adapter-utils/src/execution-target.ts | 5 ++
.../src/remote-managed-runtime.test.ts | 27 +++++++
.../src/remote-managed-runtime.ts | 6 +-
.../src/sandbox-managed-runtime.ts | 14 ++--
.../adapter-utils/src/ssh-fixture.test.ts | 22 ++++++
packages/adapter-utils/src/ssh.ts | 6 +-
.../src/workspace-restore-merge.test.ts | 53 +++++++++++++
.../src/workspace-restore-merge.ts | 78 +++++++++++++++++--
.../src/live/runnerd-codex-transport.test.ts | 18 ++++-
10 files changed, 214 insertions(+), 19 deletions(-)
diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts
index befdccf674d..9abfd933c9f 100644
--- a/packages/adapter-utils/src/command-managed-runtime.ts
+++ b/packages/adapter-utils/src/command-managed-runtime.ts
@@ -544,6 +544,8 @@ export async function prepareCommandManagedRuntime(input: {
workspaceBaseline?: DirectorySnapshot;
workspaceGitSnapshot?: GitWorkspaceSnapshot | null;
workspaceExclude?: string[];
+ /** Plain persistent directories include all files, independent of Git and task cache exclusions. */
+ workspaceFileMode?: "all";
preserveAbsentOnRestore?: string[];
assets?: CommandManagedRuntimeAsset[];
/** Referenced (additional) projects to stage into the sandbox as plain, read-only trees. */
@@ -609,6 +611,7 @@ export async function prepareCommandManagedRuntime(input: {
workspaceBaseline: input.workspaceBaseline,
workspaceGitSnapshot: input.workspaceGitSnapshot,
workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude),
+ workspaceFileMode: input.workspaceFileMode,
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
assets: input.assets,
additionalSources: input.additionalSources,
@@ -652,6 +655,7 @@ export async function prepareCommandManagedRuntime(input: {
workspaceBaseline: input.workspaceBaseline,
workspaceGitSnapshot: input.workspaceGitSnapshot,
workspaceExclude: mergeRuntimeExcludes(input.workspaceExclude),
+ workspaceFileMode: input.workspaceFileMode,
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
assets: input.assets,
additionalSources: input.additionalSources,
diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts
index ca816ca25c3..6b118011d43 100644
--- a/packages/adapter-utils/src/execution-target.ts
+++ b/packages/adapter-utils/src/execution-target.ts
@@ -1437,6 +1437,8 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
workspaceBaseline?: DirectorySnapshot;
workspaceGitSnapshot?: GitWorkspaceSnapshot | null;
workspaceExclude?: string[];
+ /** Plain persistent directories include all files, independent of Git and task cache exclusions. */
+ workspaceFileMode?: "all";
preserveAbsentOnRestore?: string[];
assets?: AdapterManagedRuntimeAsset[];
/** Referenced (additional) projects to stage into the sandbox as plain, read-only trees. */
@@ -1478,6 +1480,8 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
workspaceLocalDir: input.workspaceLocalDir,
workspaceRemoteDir: input.workspaceRemoteDir,
syncWorkspace: input.syncWorkspace,
+ workspaceFileMode: input.workspaceFileMode,
+ workspaceExclude: input.workspaceExclude,
assets: input.assets,
additionalSources: input.additionalSources,
onProgress: input.onProgress,
@@ -1517,6 +1521,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
workspaceBaseline: input.workspaceBaseline,
workspaceGitSnapshot: input.workspaceGitSnapshot,
workspaceExclude: input.workspaceExclude,
+ workspaceFileMode: input.workspaceFileMode,
preserveAbsentOnRestore: input.preserveAbsentOnRestore,
assets: input.assets,
additionalSources: input.additionalSources,
diff --git a/packages/adapter-utils/src/remote-managed-runtime.test.ts b/packages/adapter-utils/src/remote-managed-runtime.test.ts
index 89659bb643e..f5cb64b9d06 100644
--- a/packages/adapter-utils/src/remote-managed-runtime.test.ts
+++ b/packages/adapter-utils/src/remote-managed-runtime.test.ts
@@ -32,6 +32,33 @@ import { setExpensiveWorkspaceGitExecutor } from "./git-workspace-sync.js";
describe("remote managed runtime", () => {
const cleanupDirs: string[] = [];
+ it("stages all files over SSH without Git or cache exclusions and restores the same baseline", async () => {
+ const root = await mkdtemp(path.join(os.tmpdir(), "paperclip-ssh-plain-"));
+ cleanupDirs.push(root);
+ await mkdir(path.join(root, "node_modules"));
+ await writeFile(path.join(root, ".gitignore"), "node_modules/\n");
+ await writeFile(path.join(root, "node_modules", "personal.bin"), Buffer.from([0, 255, 1]));
+ const prepared = await prepareRemoteManagedRuntime({
+ spec: { host: "127.0.0.1", port: 2222, username: "fixture", remoteWorkspacePath: "/app", remoteCwd: "/app",
+ privateKey: "PRIVATE KEY", knownHosts: "KNOWN HOSTS", strictHostKeyChecking: true },
+ runId: "plain", adapterKey: "test", workspaceLocalDir: root,
+ workspaceFileMode: "all", workspaceExclude: ["explicitly-excluded"],
+ });
+ expect(prepareWorkspaceForSshExecution).toHaveBeenCalledWith(expect.objectContaining({
+ localDir: root, remoteDir: prepared.workspaceRemoteDir, workspaceFileMode: "all", workspaceExclude: ["explicitly-excluded"],
+ }));
+ await prepared.restoreWorkspace();
+ expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({
+ restoreGitHistory: false, baselineSnapshot: expect.objectContaining({
+ exclude: [".paperclip-runtime", "explicitly-excluded"],
+ entries: expect.any(Map),
+ }),
+ }));
+ const args = vi.mocked(restoreWorkspaceFromSshExecution).mock.calls[0] as unknown as [{ baselineSnapshot: { entries: Map } }];
+ expect(args[0].baselineSnapshot.entries.has("node_modules/personal.bin")).toBe(true);
+ });
+
+
afterEach(async () => {
vi.clearAllMocks();
while (cleanupDirs.length > 0) {
diff --git a/packages/adapter-utils/src/remote-managed-runtime.ts b/packages/adapter-utils/src/remote-managed-runtime.ts
index ff394dc7fb3..cb47f3caede 100644
--- a/packages/adapter-utils/src/remote-managed-runtime.ts
+++ b/packages/adapter-utils/src/remote-managed-runtime.ts
@@ -113,6 +113,8 @@ export async function prepareRemoteManagedRuntime(input: {
workspaceLocalDir: string;
workspaceRemoteDir?: string;
syncWorkspace?: boolean;
+ workspaceFileMode?: "all";
+ workspaceExclude?: string[];
assets?: RemoteManagedRuntimeAsset[];
/** Referenced (additional) projects to stage as plain, read-only trees. */
additionalSources?: SandboxAdditionalSource[];
@@ -139,13 +141,15 @@ export async function prepareRemoteManagedRuntime(input: {
localDir: input.workspaceLocalDir,
remoteDir: workspaceRemoteDir,
onProgress: input.onProgress,
+ workspaceFileMode: input.workspaceFileMode,
+ workspaceExclude: input.workspaceExclude,
})
: null;
const baselineSnapshot = preparedWorkspace
? await captureDirectorySnapshot(input.workspaceLocalDir, {
exclude: preparedWorkspace.gitBacked
? [...GIT_ARCHIVE_EXCLUDES, ".paperclip-runtime"]
- : [".paperclip-runtime"],
+ : [".paperclip-runtime", ...(input.workspaceFileMode === "all" ? input.workspaceExclude ?? [] : [])],
})
: null;
diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts
index 3f83e61639a..10eeab606e4 100644
--- a/packages/adapter-utils/src/sandbox-managed-runtime.ts
+++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts
@@ -1102,6 +1102,8 @@ export async function prepareSandboxManagedRuntime(input: {
workspaceBaseline?: DirectorySnapshot;
workspaceGitSnapshot?: GitWorkspaceSnapshot | null;
workspaceExclude?: string[];
+ /** Plain persistent directories include all files, independent of Git and task cache exclusions. */
+ workspaceFileMode?: "all";
preserveAbsentOnRestore?: string[];
assets?: SandboxManagedRuntimeAsset[];
/**
@@ -1179,7 +1181,7 @@ export async function prepareSandboxManagedRuntime(input: {
// The git enumeration (`git status --ignored`, the HEAD diffs, `ls-files`).
// It reads git's own bookkeeping to decide what to include/exclude, so it is
// usually fast, but on a large working tree the `--ignored` walk is not free.
- const gitSnapshot = syncWorkspace
+ const gitSnapshot = syncWorkspace && input.workspaceFileMode !== "all"
? input.workspaceGitSnapshot !== undefined
? input.workspaceGitSnapshot
: await runStepSpan("snapshot.git", () =>
@@ -1195,7 +1197,7 @@ export async function prepareSandboxManagedRuntime(input: {
// A selected subfolder has no cloneable Git snapshot, but its parent
// repository's ignore rules still govern which files may leave the host.
// Use the same bounded, path-relative resolver as referenced project trees.
- const directoryIgnore = syncWorkspace && !gitSnapshot
+ const directoryIgnore = syncWorkspace && !gitSnapshot && input.workspaceFileMode !== "all"
? await resolveReferencedSourceIgnore(input.workspaceLocalDir)
: null;
if (directoryIgnore?.kind === "failed") {
@@ -1203,14 +1205,14 @@ export async function prepareSandboxManagedRuntime(input: {
}
const gitIgnoredExcludes = directoryIgnore?.kind === "git" ? directoryIgnore.ignoredPaths : undefined;
const workspaceArchiveExclude = mergeExcludes(
- SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES,
- [...GIT_ARCHIVE_EXCLUDES],
+ input.workspaceFileMode === "all" ? [] : SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES,
+ input.workspaceFileMode === "all" ? [] : [...GIT_ARCHIVE_EXCLUDES],
input.workspaceExclude,
gitIgnoredExcludes,
);
const restoreExclude = mergeExcludes(
- SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES,
- [...GIT_ARCHIVE_EXCLUDES],
+ input.workspaceFileMode === "all" ? [] : SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES,
+ input.workspaceFileMode === "all" ? [] : [...GIT_ARCHIVE_EXCLUDES],
[".paperclip-runtime"],
input.preserveAbsentOnRestore,
input.workspaceExclude,
diff --git a/packages/adapter-utils/src/ssh-fixture.test.ts b/packages/adapter-utils/src/ssh-fixture.test.ts
index c9c7657f564..83c4ad7e6d5 100644
--- a/packages/adapter-utils/src/ssh-fixture.test.ts
+++ b/packages/adapter-utils/src/ssh-fixture.test.ts
@@ -712,6 +712,28 @@ describe("ssh env-lab fixture", () => {
expect(result.stdout).toContain("{\"token\":\"secret\"}");
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
+ it("clears stale files when plain SSH preparation is retried", async () => {
+ const rootDir = await createFixtureRootDir();
+ const localDir = path.join(rootDir, "plain-local");
+ await mkdir(path.join(localDir, "node_modules"), { recursive: true });
+ await git(localDir, ["init"]);
+ await writeFile(path.join(localDir, ".gitignore"), "node_modules/\n");
+ await writeFile(path.join(localDir, "removed.txt"), "remove on retry");
+ const binary = Buffer.from([0, 255, 1]);
+ await writeFile(path.join(localDir, "node_modules", "personal.bin"), binary);
+ const started = await startSshEnvLabFixtureOrSkip(path.join(rootDir, "state.json"), "SSH plain retry");
+ if (!started) return;
+ const config = await buildSshEnvLabFixtureConfig(started);
+ const input = { spec: { ...config, remoteCwd: started.workspaceDir }, localDir,
+ remoteDir: started.workspaceDir, workspaceFileMode: "all" as const };
+ expect(await prepareWorkspaceForSshExecution(input)).toEqual({ gitBacked: false });
+ expect(await readFile(path.join(started.workspaceDir, "node_modules", "personal.bin"))).toEqual(binary);
+ await rm(path.join(localDir, "removed.txt"));
+ await prepareWorkspaceForSshExecution(input);
+ await expect(stat(path.join(started.workspaceDir, "removed.txt"))).rejects.toMatchObject({ code: "ENOENT" });
+ expect(await readFile(path.join(started.workspaceDir, "node_modules", "personal.bin"))).toEqual(binary);
+ }, SSH_FIXTURE_TEST_TIMEOUT_MS);
+
it("round-trips a git workspace through the SSH fixture", async () => {
const rootDir = await createFixtureRootDir();
const statePath = path.join(rootDir, "state.json");
diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts
index 084bb0803d1..89fd456c5d4 100644
--- a/packages/adapter-utils/src/ssh.ts
+++ b/packages/adapter-utils/src/ssh.ts
@@ -1558,9 +1558,11 @@ export async function prepareWorkspaceForSshExecution(input: {
localDir: string;
remoteDir?: string;
onProgress?: RuntimeProgressSink;
+ workspaceFileMode?: "all";
+ workspaceExclude?: string[];
}): Promise<{ gitBacked: boolean }> {
const remoteDir = input.remoteDir ?? input.spec.remoteCwd;
- const gitSnapshot = await readLocalGitWorkspaceSnapshot(input.localDir);
+ const gitSnapshot = input.workspaceFileMode === "all" ? null : await readLocalGitWorkspaceSnapshot(input.localDir);
if (gitSnapshot) {
await importGitWorkspaceToSsh({
@@ -1595,7 +1597,7 @@ export async function prepareWorkspaceForSshExecution(input: {
spec: input.spec,
localDir: input.localDir,
remoteDir,
- exclude: [".paperclip-runtime"],
+ exclude: [".paperclip-runtime", ...(input.workspaceFileMode === "all" ? input.workspaceExclude ?? [] : [])],
onProgress: input.onProgress,
progressLabel: "workspace",
});
diff --git a/packages/adapter-utils/src/workspace-restore-merge.test.ts b/packages/adapter-utils/src/workspace-restore-merge.test.ts
index 7833997dd44..914d99ac751 100644
--- a/packages/adapter-utils/src/workspace-restore-merge.test.ts
+++ b/packages/adapter-utils/src/workspace-restore-merge.test.ts
@@ -605,3 +605,56 @@ describe("workspace restore merge", () => {
});
});
});
+
+describe("conflict-preserving directory restore", () => {
+ it("preflights competing edits before applying any other change and deduplicates replay", async () => {
+ const root = await fsPromises.realpath(await mkdtemp(path.join(os.tmpdir(), "directory-cas-")));
+ const source = path.join(root, "source"), target = path.join(root, "target");
+ try {
+ await mkdir(target);
+ await writeFile(path.join(target, "conflict"), "baseline");
+ const baseline = await captureDirectorySnapshot(target);
+ await fsPromises.cp(target, source, { recursive: true });
+ await writeFile(path.join(source, "conflict"), "incoming");
+ await writeFile(path.join(source, "independent"), "also incoming");
+ await writeFile(path.join(target, "conflict"), "board");
+ await expect(mergeDirectoryWithBaseline({ baseline, sourceDir: source, targetDir: target, conflictPolicy: "reject" })).rejects.toMatchObject({ code: "DIRECTORY_MERGE_CONFLICT", paths: ["conflict"] });
+ await expect(stat(path.join(target, "independent"))).rejects.toMatchObject({ code: "ENOENT" });
+ await writeFile(path.join(target, "conflict"), "baseline");
+ await expect(mergeDirectoryWithBaseline({ baseline, sourceDir: source, targetDir: target, conflictPolicy: "reject", afterApply: async () => { throw new Error("receipt interrupted"); } })).rejects.toThrow("receipt interrupted");
+ await mergeDirectoryWithBaseline({ baseline, sourceDir: source, targetDir: target, conflictPolicy: "reject" });
+ expect(await readFile(path.join(target, "independent"), "utf8")).toBe("also incoming");
+ } finally { await rm(root, { recursive: true, force: true }); }
+ });
+
+ it("preserves a newly added child when another run removes or replaces its parent", async () => {
+ const root = await fsPromises.realpath(await mkdtemp(path.join(os.tmpdir(), "directory-delete-cas-")));
+ const source = path.join(root, "source"), target = path.join(root, "target");
+ try {
+ await mkdir(path.join(target, "folder"), { recursive: true });
+ const baseline = await captureDirectorySnapshot(target);
+ await mkdir(source);
+ await writeFile(path.join(target, "folder", "new"), "concurrent");
+ await expect(mergeDirectoryWithBaseline({ baseline, sourceDir: source, targetDir: target, conflictPolicy: "reject" })).rejects.toMatchObject({ paths: ["folder/new"] });
+ expect(await readFile(path.join(target, "folder", "new"), "utf8")).toBe("concurrent");
+ } finally { await rm(root, { recursive: true, force: true }); }
+ });
+});
+
+
+it("strict preflight preserves excluded descendants when a directory becomes a file", async () => {
+ const root = await fsPromises.realpath(await mkdtemp(path.join(os.tmpdir(), "directory-excluded-cas-")));
+ const target = path.join(root, "target"), source = path.join(root, "source");
+ await mkdir(path.join(target, "folder", "node_modules"), { recursive: true });
+ await writeFile(path.join(target, "folder", "node_modules", "keep"), "excluded contents");
+ const baseline = await captureDirectorySnapshot(target, { exclude: ["*/node_modules"], diskBacked: true });
+ try {
+ await mkdir(source);
+ await writeFile(path.join(source, "folder"), "replacement");
+ await writeFile(path.join(source, "independent"), "must not partially apply");
+ await expect(mergeDirectoryWithBaseline({ baseline, sourceDir: source, targetDir: target, conflictPolicy: "reject" }))
+ .rejects.toMatchObject({ code: "DIRECTORY_MERGE_CONFLICT", paths: expect.arrayContaining(["folder/node_modules/keep"]) });
+ expect(await readFile(path.join(target, "folder", "node_modules", "keep"), "utf8")).toBe("excluded contents");
+ await expect(stat(path.join(target, "independent"))).rejects.toMatchObject({ code: "ENOENT" });
+ } finally { await disposeDirectorySnapshot(baseline); await rm(root, { recursive: true, force: true }); }
+});
diff --git a/packages/adapter-utils/src/workspace-restore-merge.ts b/packages/adapter-utils/src/workspace-restore-merge.ts
index 88e89e8f920..60e3ce539f4 100644
--- a/packages/adapter-utils/src/workspace-restore-merge.ts
+++ b/packages/adapter-utils/src/workspace-restore-merge.ts
@@ -1,4 +1,4 @@
-import { createHash } from "node:crypto";
+import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs";
import { constants as fsConstants, promises as fs } from "node:fs";
import path from "node:path";
@@ -442,16 +442,26 @@ async function copySnapshotEntry(sourceDir: string, targetDir: string, relative:
}
await fs.mkdir(path.dirname(targetPath), { recursive: true });
- await fs.rm(targetPath, { recursive: true, force: true }).catch(() => undefined);
if (entry.kind === "symlink") {
+ await fs.rm(targetPath, { recursive: true, force: true });
await fs.symlink(entry.target, targetPath);
return;
}
+ // An interrupted restore must not leave a truncated current file. Keep the
+ // incoming tree until its owner records success; exact retries deduplicate.
+ const temporary = path.join(path.dirname(targetPath), `.paperclip-merge-${randomUUID()}`);
+ try {
+ await fs.copyFile(sourcePath, temporary, fsConstants.COPYFILE_FICLONE).catch(async () => {
+ await fs.copyFile(sourcePath, temporary);
+ });
+ await fs.chmod(temporary, entry.mode);
+ const file = await fs.open(temporary, "r");
+ try { await file.sync(); } finally { await file.close(); }
+ const existing = await fs.lstat(targetPath).catch(() => null);
+ if (existing?.isDirectory()) await fs.rm(targetPath, { recursive: true, force: true });
+ await fs.rename(temporary, targetPath);
+ } finally { await fs.rm(temporary, { force: true }); }
- await fs.copyFile(sourcePath, targetPath, fsConstants.COPYFILE_FICLONE).catch(async () => {
- await fs.copyFile(sourcePath, targetPath);
- });
- await fs.chmod(targetPath, entry.mode);
}
export async function captureDirectorySnapshot(
@@ -511,10 +521,54 @@ function orderedEntries(snapshot: DirectorySnapshot, reverse = false): Iterable<
return [...snapshot.entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0) * (reverse ? -1 : 1));
}
+export class DirectoryMergeConflict extends Error {
+ readonly code = "DIRECTORY_MERGE_CONFLICT";
+ constructor(readonly paths: string[]) {
+ super("Directory contents changed concurrently");
+ }
+}
+
+/** Preflight the entire delta before writing. Identical replays are safe after
+ * an interrupted apply; unrelated edits are left alone. No history is retained. */
+export function directoryMergeConflicts(baseline: DirectorySnapshot, source: DirectorySnapshot, current: DirectorySnapshot): string[] {
+ const same = (a: SnapshotEntry | undefined, b: SnapshotEntry | undefined) =>
+ (!a && !b) || entriesMatch(a, b);
+ const conflicts = new Set();
+ function* changedPaths() {
+ for (const [name] of baseline.entries) yield name;
+ for (const [name] of source.entries) if (!baseline.entries.has(name)) yield name;
+ }
+ for (const relative of changedPaths()) {
+ const before = baseline.entries.get(relative);
+ const incoming = source.entries.get(relative);
+ const present = current.entries.get(relative);
+ if (same(before, incoming) || same(incoming, present)) continue;
+ if (!same(before, present)) conflicts.add(relative);
+ // A parent removed/replaced by another writer must never be traversed.
+ for (let parent = path.posix.dirname(relative); parent !== "."; parent = path.posix.dirname(parent)) {
+ if (current.entries.get(parent)?.kind !== "dir" &&
+ !same(current.entries.get(parent), baseline.entries.get(parent))) conflicts.add(parent);
+ }
+ }
+ // Stream each current entry once. A replacement must not remove children
+ // omitted from the baseline, including excluded or newly created files.
+ for (const [child, entry] of current.entries) {
+ if (same(entry, baseline.entries.get(child)) || same(entry, source.entries.get(child))) continue;
+ for (let parent = path.posix.dirname(child); parent !== "."; parent = path.posix.dirname(parent)) {
+ if (baseline.entries.get(parent)?.kind === "dir" && source.entries.get(parent)?.kind !== "dir") {
+ conflicts.add(child);
+ break;
+ }
+ }
+ }
+ return [...conflicts].sort();
+}
+
export async function mergeDirectoryWithBaseline(input: {
baseline: DirectorySnapshot;
sourceDir: string;
targetDir: string;
+ conflictPolicy?: "reject";
beforeApply?: () => Promise;
afterApply?: () => Promise;
}): Promise {
@@ -523,8 +577,15 @@ export async function mergeDirectoryWithBaseline(input: {
try {
await withDirectoryMergeLock(input.targetDir, async (canonicalTargetDir) => {
await input.beforeApply?.();
- const current = await captureDirectorySnapshot(canonicalTargetDir, options);
+ // Strict preflight must see excluded children before a directory is
+ // replaced. The merge still applies only the filtered source/baseline.
+ const current = await captureDirectorySnapshot(canonicalTargetDir,
+ input.conflictPolicy === "reject" ? { exclude: [], diskBacked: true } : options);
try {
+ if (input.conflictPolicy === "reject") {
+ const conflicts = directoryMergeConflicts(input.baseline, source, current);
+ if (conflicts.length) throw new DirectoryMergeConflict(conflicts);
+ }
for (const [relative, baselineEntry] of orderedEntries(input.baseline)) {
if (baselineEntry.kind === "dir" || source.entries.has(relative)) continue;
if (!entriesMatch(current.entries.get(relative), baselineEntry)) continue;
@@ -537,7 +598,8 @@ export async function mergeDirectoryWithBaseline(input: {
});
}
for (const [relative, entry] of orderedEntries(source)) {
- if (!entriesMatch(input.baseline.entries.get(relative), entry)) await copySnapshotEntry(input.sourceDir, canonicalTargetDir, relative, entry);
+ if (!entriesMatch(input.baseline.entries.get(relative), entry) &&
+ !(input.conflictPolicy === "reject" && entriesMatch(current.entries.get(relative), entry))) await copySnapshotEntry(input.sourceDir, canonicalTargetDir, relative, entry);
}
await input.afterApply?.();
} finally { await disposeDirectorySnapshot(current); }
diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts
index 691dda544fa..1baae369209 100644
--- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts
+++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts
@@ -1,4 +1,5 @@
import {
+ chmod,
cp,
mkdir,
lstat,
@@ -7273,6 +7274,8 @@ it("preserves prepared input through runnerd and the real OpenCode proxy boundar
execFileSync("cc", ["-x", "c", "-o", executable, "-"], {
input: `#include \n#include \nint main(int argc, char **argv) { char **args = calloc(argc + 2, sizeof(char *)); args[0] = ${JSON.stringify(process.execPath)}; args[1] = ${JSON.stringify(fixture)}; for (int i = 1; i < argc; i++) args[i + 1] = argv[i]; execv(args[0], args); return 127; }`,
});
+ // CI may use umask 0002; qualified executables cannot be group-writable.
+ await chmod(executable, 0o755);
// Use the production bundler without depending on (or mutating) shared dist
// artifacts. The Vitest CI lane builds Rust but does not build TypeScript.
const proxy = join(root, "opencode-app-server-proxy.cjs");
@@ -7316,6 +7319,7 @@ it("preserves prepared input through runnerd and the real OpenCode proxy boundar
task: { prompt: "Keep this request unchanged." },
completionContract: { revision: "prepared-v1", criteria: task.completionContract.criteria },
});
+ let failure: unknown;
try {
session = await driver.openSession({ runId: "prepared-opencode", normalizedSessionId: "prepared-opencode", workingDirectory: root });
await session.startTurn({ message: { role: "user", text: prepared } });
@@ -7326,9 +7330,19 @@ it("preserves prepared input through runnerd and the real OpenCode proxy boundar
expect(sessionRoots).toHaveLength(1);
const requests = (await readFile(join(runtime, sessionRoots[0]!.name, "data/fake-prompt-requests.ndjson"), "utf8")).trim().split("\n").map((line) => JSON.parse(line));
expect(requests.map((request) => request.parts)).toEqual([[{ type: "text", text: prepared }]]);
+ } catch (error) {
+ failure = error;
} finally {
- await session?.close();
- await bundle.transport.close();
+ try {
+ await session?.close();
+ await bundle.transport.close();
+ } catch (error) {
+ // Preserve bootstrap failures when cleanup independently cannot suspend.
+ failure ??= error;
+ }
await rm(root, { recursive: true, force: true });
}
+ if (failure) {
+ throw new Error(`${String(failure)}\n${bundle.evidence().diagnostics.join("\n")}`, { cause: failure });
+ }
}, 30_000);
From 060099f7b831da09904823604f71a715150f51de Mon Sep 17 00:00:00 2001
From: Dotta
Date: Tue, 29 Sep 2026 08:03:38 -0500
Subject: [PATCH 10/13] fix(runner): persist ACP response settlement across
crashes
Record successful response delivery in the durable provider outbox before retiring the pending request. Preserve settlement across restart without replaying provider responses. Disable automatic issue lookups in provider decision documents.
Co-Authored-By: Paperclip
---
.../runner-rich-acp-capabilities.md | 24 +-
.../runner-core/src/acpx_provider_backend.rs | 88 +++++-
.../tests/native_provider_backend.rs | 289 ++++++++++++++++++
ui/src/components/task-chat/QuestionForm.tsx | 2 +-
4 files changed, 378 insertions(+), 25 deletions(-)
diff --git a/doc/architecture/runner-rich-acp-capabilities.md b/doc/architecture/runner-rich-acp-capabilities.md
index 13aa59e6aba..2dc62cddb7e 100644
--- a/doc/architecture/runner-rich-acp-capabilities.md
+++ b/doc/architecture/runner-rich-acp-capabilities.md
@@ -1,9 +1,9 @@
# Rich ACP integration and qualification report
-Updated: 2026-09-28. Base: `c65fc9e3c81c41aafe421aa90a00514b84343285`.
-Mainline integration: `18e8c121d99fee1d4038610531ab57ae3f824fdd`.
-Status: implementation is available as four draft PRs. The shared foundation's
-local checks pass, and provider review and qualification remain in progress.
+Updated: 2026-09-29. Base: `c65fc9e3c81c41aafe421aa90a00514b84343285`.
+Mainline integration: `53aad90b9e83dc147707797bf224bec12600b171`.
+Status: implementation is split into a shared foundation and three provider PRs.
+Provider review and qualification remain separate from foundation acceptance.
All three new profiles remain **pending qualification**. Cursor passed all five
local semantic Product E2E cases. Copilot passed completion, file validation,
plan approval and controller restart; its question case failed the required final
@@ -34,8 +34,9 @@ adapters are outside this change.
The provider branches were implemented in parallel from the foundation. Final
shared registration and packaging conflicts are resolved in dependency order:
foundation → Cursor → Copilot → Pi. They remain four separate worktrees and PR
-review units; the later PR bases include their prerequisite providers. No GitHub
-PR is merged automatically. Source reports on the provider branches are
+review units; the later PR bases include their prerequisite providers. Foundation
+acceptance requires Apex review and CI to pass. Provider PRs remain unmerged
+pending qualification. Source reports on the provider branches are
`doc/architecture/runner-cursor-capabilities.md`,
`doc/architecture/runner-copilot-capabilities.md`, and
`doc/architecture/runner-pi-capabilities.md`. Those reports retain versioned
@@ -93,7 +94,7 @@ deterministic evidence are mapped separately after the comparison.
| Active steering and queued follow-up | Existing active-turn control where a bound method is negotiated; no native Pi queue selector yet | `src/drivers/acpx/turn-controls.test.ts`; provider reports state which wire method is absent or not surfaced |
| Files, diffs and images | Workspace file/artifact cards plus contained provider reference notices; raw provider diffs are still partial | `src/drivers/acpx/profile-extensions.test.ts`; provider field audits; Product `file-edit-validate` uses an independent exact-byte oracle |
| Usage and model identity | Exact configured model, per-provider token/accounting fields, explicit incomplete cost coverage | `src/drivers/acpx/usage-accounting.test.ts`, `src/cli/eval-session-contract.test.ts`, `server/src/services/native-runtime/native-session-executor.test.ts` |
-| Durable input, reconnect and provider death | Pending interaction cards survive controller recovery; unsafe replacement expires unresolved requests | `src/control-plane/durable-prp-control-plane.test.ts`, `runner/crates/runner-core/tests/acpx_provider_resolutions.rs`, `src/live/runnerd-codex-transport.test.ts` |
+| Durable input, reconnect and provider death | Pending interaction cards survive controller recovery; unsafe replacement expires unresolved requests; delivered settlement survives a crash before journaling | `src/control-plane/durable-prp-control-plane.test.ts`, `runner/crates/runner-core/tests/acpx_provider_resolutions.rs`, `runner/crates/runner-core/tests/native_provider_backend.rs`, `src/live/runnerd-codex-transport.test.ts` |
| Session list/fork, generic configuration and commands | No added operator surface; exact owned session recovery and configured model remain available | Provider inventories identify native-only, ACP-exposed, confirmed-absent and unverified methods with follow-ups |
Paths starting with `src/` or `runner/` in this evidence table are relative to
@@ -110,6 +111,11 @@ mismatch is rejected. Retired streams and inactive turns cannot emit new activit
Requests enter durable runtime state before the UI presents them. The response
must match an outstanding request and an offered action or valid typed answer.
The direct driver and sidecar await a receipt for the exact JSON-RPC pipe write before the runtime settles its durable record.
+The successful resolution enters the retained event outbox in the same atomic
+state save that removes the pending request. A restart before journal delivery
+retains that resolution for normal event replay and acknowledgment. A restart
+before this state save instead expires the unresolved request; neither path sends
+the provider response again.
Standard ACP does not acknowledge application of a permission reply; a lost
transport acknowledgment is not proof of exactly-once external effects. A
replacement provider process cannot inherit an old approval promise. A bounded durable ledger expires pending requests after provider loss or unsafe restart, including requests whose creation events were already acknowledged. No tool
@@ -119,7 +125,9 @@ Full plan documents have a bounded 100,000-character description and a 196 KiB
question-set envelope. Oversized plans fail rather than approve an unseen suffix.
Display redaction remains visible. Decision descriptions render image references as
inert text and Mermaid diagrams as source, so reviewing a plan does not fetch
-provider-selected media. The rich event channel has exact canonical
+provider-selected media. Automatic issue-reference linking is disabled for these
+descriptions, so a long provider plan cannot start an issue-detail query for every
+identifier. The rich event channel has exact canonical
schemas and a bounded envelope. It cannot create terminal outcomes, dispatch a
semantic tool, register an artifact, synchronize a durable plan, or supply source
authority. Notices retain useful bounded fields and provenance in expandable UI
diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs
index 3c84813dd42..e725b7524b0 100644
--- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs
+++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs
@@ -585,6 +585,12 @@ impl AcpxDurableState {
"ACPX provider event backlog exceeds its durable limit",
));
}
+ // Check every fallible admission condition before changing the request
+ // ledger. A rejected settlement must leave its pending request intact.
+ let sequence = self.next_event_sequence;
+ let next_sequence = sequence
+ .checked_add(1)
+ .ok_or_else(|| DurableRunnerError::invalid("ACPX event sequence exhausted"))?;
if event.event_type == "runtime_request.created" {
let request = event.payload.get("request").cloned().ok_or_else(|| {
DurableRunnerError::invalid("ACPX runtime request omitted its payload")
@@ -612,10 +618,7 @@ impl AcpxDurableState {
self.pending_runtime_requests.remove(id);
}
}
- let sequence = self.next_event_sequence;
- self.next_event_sequence = sequence
- .checked_add(1)
- .ok_or_else(|| DurableRunnerError::invalid("ACPX event sequence exhausted"))?;
+ self.next_event_sequence = next_sequence;
self.pending_events.push_back(PolledEvent {
executor_event_id: event_id(sequence),
event_type: event.event_type,
@@ -1540,20 +1543,24 @@ impl AcpxCommandExecutor {
.map_err(|error| {
DurableRunnerError::invalid(format!("ACPX runtime response failed: {error}"))
})?;
- if let Some(state) = self.state.as_mut() {
- state.pending_runtime_requests.remove(request_id);
- }
- self.save_state()?;
- Ok(CommandExecution {
- result: json!({"status": "delivered", "requestId": request_id}),
- events: vec![(
- "runtime_request.resolved".to_owned(),
- EventPriority::P0,
- json!({"provider": "acpx", "requestId": request_id, "status": "delivered",
+ // The sidecar ACK proves delivery, not durable controller settlement.
+ // Retain that fact in the same atomic state write that retires the
+ // pending request. A crash before this write expires the old request;
+ // a crash after it replays this event, never the provider response.
+ self.state
+ .as_mut()
+ .expect("ACPX response has an active durable turn")
+ .push(NormalizedProviderEvent {
+ event_type: "runtime_request.resolved".to_owned(),
+ priority: EventPriority::P0,
+ payload: json!({"provider": "acpx", "requestId": request_id, "status": "delivered",
"requestKind": if permission {"permission_approval"} else {"runtime"},
"turnId":self.context.turn_id,"itemId":self.context.item_id,"action":resolution.get("action")}),
- )],
- })
+ })?;
+ self.save_state()?;
+ Ok(CommandExecution::result(
+ json!({"status": "delivered", "requestId": request_id}),
+ ))
}
fn deliver_tool_result(
@@ -2293,6 +2300,55 @@ mod tests {
.is_empty());
}
+ #[test]
+ fn rejected_runtime_settlement_preserves_the_pending_request() {
+ for event_type in [
+ "runtime_request.resolved",
+ "runtime_request.expired",
+ "runtime_request.cancelled",
+ ] {
+ for exhausted_sequence in [false, true] {
+ let operations = Vec::new();
+ let mut state = AcpxDurableState::new(
+ serde_json::from_value(descriptor("codex")).unwrap(),
+ AuthorizedToolSet {
+ schema: TOOL_SET_SCHEMA.into(),
+ schema_version: 1,
+ catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(),
+ operations,
+ },
+ "test".into(),
+ );
+ state
+ .push(NormalizedProviderEvent {
+ event_type: "runtime_request.created".into(),
+ priority: EventPriority::P0,
+ payload: json!({"request":pending_input_request("input-1")}),
+ })
+ .unwrap();
+ if exhausted_sequence {
+ state.next_event_sequence = u64::MAX;
+ } else {
+ state
+ .pending_events
+ .resize(MAX_PENDING_EVENTS, state.pending_events[0].clone());
+ }
+ let before = state.clone();
+ assert!(state
+ .push(NormalizedProviderEvent {
+ event_type: event_type.into(),
+ priority: EventPriority::P0,
+ payload: json!({"requestId":"input-1"}),
+ })
+ .is_err());
+ assert_eq!(
+ state, before,
+ "{event_type} must not retire a request without its event"
+ );
+ }
+ }
+ }
+
#[test]
fn turn_control_rejects_nonstring_mode_before_provider_access() {
let directory = temporary_directory("control-mode");
diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs
index 034727b788c..fa4f188a440 100644
--- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs
+++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs
@@ -239,6 +239,295 @@ fn pi_prepare_payload(directory: &Path, mode: &str) -> Value {
payload
}
+fn pending_acpx_runtime_request(
+ directory: &Path,
+ mode: &str,
+) -> (NativeProviderCommandExecutor, DurableRunnerConfig, String) {
+ let mut config = acpx_config(directory, mode);
+ let mut payload = prepare_payload_with_mode(directory, "codex", mode);
+ if mode.starts_with("permissions-") {
+ // Codex's pinned runner policy does not allow interactive ACP permissions.
+ let digest = "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a";
+ *config
+ .acpx_launch_profile
+ .as_mut()
+ .unwrap()
+ .args
+ .last_mut()
+ .unwrap() = digest.into();
+ let provider = &mut payload["provider"];
+ provider["agent"] = json!("claude");
+ provider["model"] = json!("claude-sonnet-5");
+ provider["agentServerPackage"] = json!("@agentclientprotocol/claude-agent-acp");
+ provider["agentServerVersion"] = json!("0.73.0");
+ provider["agentRuntimePackage"] = json!("@anthropic-ai/claude-agent-sdk");
+ provider["agentRuntimeVersion"] = json!("0.3.280");
+ provider["commandDigest"] = json!(digest);
+ provider["sidecarArgs"][3] = json!(digest);
+ } else {
+ let operations = vec![AuthorizedTool {
+ operation_id: "issues.read".into(),
+ version: 1,
+ description: "Read an issue.".into(),
+ input_schema: json!({"type":"object"}),
+ response_schema: json!({"type":"object"}),
+ }];
+ payload["authorizedTools"]["catalogDigest"] =
+ json!(authorized_tool_catalog_digest(&operations).unwrap());
+ payload["authorizedTools"]["operations"] = json!(operations);
+ }
+ let mut executor = NativeProviderCommandExecutor::with_runner_config(directory, &config);
+ executor
+ .execute(&command(1, "run.prepare", payload))
+ .unwrap();
+ executor
+ .execute(&command(2, "session.open", json!({})))
+ .unwrap();
+ executor
+ .execute(&command(
+ 3,
+ "turn.start",
+ json!({"text":"Ask before continuing", "turnId":"turn-1"}),
+ ))
+ .unwrap();
+ let deadline = std::time::Instant::now() + Duration::from_secs(2);
+ loop {
+ let events = executor.poll_events().unwrap();
+ let request_id = events
+ .iter()
+ .find(|event| event.event_type == "runtime_request.created")
+ .and_then(|event| event.payload.pointer("/request/requestId"))
+ .and_then(Value::as_str)
+ .map(str::to_owned);
+ executor.acknowledge_events(events.len()).unwrap();
+ if let Some(request_id) = request_id {
+ return (executor, config, request_id);
+ }
+ assert!(
+ std::time::Instant::now() < deadline,
+ "request was not presented"
+ );
+ }
+}
+
+fn acpx_runtime_resolution(mode: &str, request_id: &str) -> Value {
+ json!({"requestId":request_id, "turnId":"turn-1", "resolution":
+ if mode.starts_with("permissions-") { json!({"action":"accept"}) }
+ else { json!({"action":"submit", "response":{"schema":"paperclip.question_response.v1",
+ "answers":{"target":{"selectedOptionIds":["first"]}}}}) }
+ })
+}
+
+#[test]
+fn acpx_response_delivery_survives_crash_before_journaling_without_replaying_the_response() {
+ for mode in [
+ "permissions-interactive",
+ "resolutions",
+ "resolutions-projected-id",
+ ] {
+ let directory = temporary_directory(mode);
+ let (mut executor, config, request_id) = pending_acpx_runtime_request(&directory, mode);
+ let resolve = command(
+ 4,
+ "request.resolve",
+ acpx_runtime_resolution(mode, &request_id),
+ );
+ let delivered = executor.execute(&resolve).unwrap();
+ assert_eq!(delivered.result["status"], "delivered");
+ assert!(
+ delivered.events.is_empty(),
+ "settlement must use the durable provider outbox"
+ );
+ let persisted: Value =
+ serde_json::from_slice(&fs::read(directory.join("acpx-provider-state.json")).unwrap())
+ .unwrap();
+ assert!(persisted["pendingRuntimeRequests"]
+ .get(&request_id)
+ .is_none());
+ let retained = executor.retained_events().unwrap();
+ assert_eq!(retained.len(), 1);
+ assert_eq!(retained[0].event_type, "runtime_request.resolved");
+ assert_eq!(retained[0].payload["requestId"], request_id);
+ assert_eq!(
+ persisted["pendingEvents"][0]["executorEventId"],
+ retained[0].executor_event_id
+ );
+ assert!(executor
+ .execute(&command(5, "request.resolve", resolve.payload.clone()))
+ .is_err());
+ assert_eq!(executor.retained_events().unwrap(), retained);
+
+ // Simulate death after the sidecar ACK/state fsync, before the runner
+ // journals this event or acknowledges the provider's retained prefix.
+ drop(executor);
+ let mut recovered = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
+ let events = recovered.poll_events().unwrap();
+ assert_eq!(events[0], retained[0]);
+ assert_eq!(
+ events
+ .iter()
+ .filter(|event| event.event_type.starts_with("runtime_request."))
+ .count(),
+ 1
+ );
+ assert!(recovered
+ .execute(&command(6, "request.resolve", resolve.payload))
+ .is_err());
+ assert_eq!(recovered.poll_events().unwrap(), events);
+ assert_eq!(
+ recovered
+ .execute(&command(7, "session.snapshot", json!({})))
+ .unwrap()
+ .result["status"],
+ "closed"
+ );
+ // Polling is not an ACK; only the journal's ACK retires the event.
+ recovered.acknowledge_events(events.len()).unwrap();
+ assert!(recovered.poll_events().unwrap().is_empty());
+ drop(recovered);
+ let mut recovered_again =
+ NativeProviderCommandExecutor::with_runner_config(&directory, &config);
+ assert!(recovered_again.poll_events().unwrap().is_empty());
+ recovered_again.shutdown().unwrap();
+ fs::remove_dir_all(directory).unwrap();
+ }
+}
+
+#[test]
+fn acpx_delivered_response_is_acknowledged_once_before_session_close() {
+ for mode in ["permissions-interactive", "resolutions"] {
+ let directory = temporary_directory("response-ack");
+ let (mut executor, _, request_id) = pending_acpx_runtime_request(&directory, mode);
+ let response = acpx_runtime_resolution(mode, &request_id);
+ executor
+ .execute(&command(4, "request.resolve", response.clone()))
+ .unwrap();
+ let events = executor.poll_events().unwrap();
+ assert_eq!(events.len(), 1);
+ assert_eq!(events[0].event_type, "runtime_request.resolved");
+ assert_eq!(executor.poll_events().unwrap(), events);
+ executor.acknowledge_events(1).unwrap();
+ assert!(executor
+ .execute(&command(5, "request.resolve", response))
+ .is_err());
+ executor
+ .execute(&command(6, "session.close", json!({})))
+ .unwrap();
+ assert!(
+ executor.poll_events().unwrap().is_empty(),
+ "close must not expire a delivered request"
+ );
+ executor.shutdown().unwrap();
+ fs::remove_dir_all(directory).unwrap();
+ }
+}
+
+#[test]
+fn acpx_uncommitted_delivery_expires_after_failed_state_write_without_replay() {
+ for mode in ["permissions-interactive", "resolutions"] {
+ let directory = temporary_directory("response-save-failure");
+ let (mut executor, config, request_id) = pending_acpx_runtime_request(&directory, mode);
+ let state_path = directory.join("acpx-provider-state.json");
+ let previous_path = directory.join("previous-state.json");
+ let previous = fs::read(&state_path).unwrap();
+ fs::rename(&state_path, &previous_path).unwrap();
+ // Force the atomic state replacement to fail after the real sidecar
+ // has acknowledged delivery, without adding production fault hooks.
+ fs::create_dir(&state_path).unwrap();
+ let response = acpx_runtime_resolution(mode, &request_id);
+ let error = executor
+ .execute(&command(4, "request.resolve", response.clone()))
+ .unwrap_err();
+ assert!(error
+ .to_string()
+ .contains("atomically replace ACPX provider state"));
+ assert_eq!(fs::read(&previous_path).unwrap(), previous);
+ assert_eq!(
+ executor.retained_events().unwrap()[0].event_type,
+ "runtime_request.resolved"
+ );
+ fs::remove_dir(&state_path).unwrap();
+ fs::rename(&previous_path, &state_path).unwrap();
+ drop(executor);
+
+ let mut recovered = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
+ let events = recovered.poll_events().unwrap();
+ let requests: Vec<_> = events
+ .iter()
+ .filter(|event| event.event_type.starts_with("runtime_request."))
+ .collect();
+ assert_eq!(requests.len(), 1);
+ assert_eq!(requests[0].event_type, "runtime_request.expired");
+ assert_eq!(requests[0].payload["requestId"], request_id);
+ assert_eq!(requests[0].payload["replayAllowed"], false);
+ assert!(recovered
+ .execute(&command(5, "request.resolve", response))
+ .is_err());
+ assert_eq!(recovered.poll_events().unwrap(), events);
+ recovered.acknowledge_events(events.len()).unwrap();
+ recovered.shutdown().unwrap();
+ fs::remove_dir_all(directory).unwrap();
+ }
+}
+
+#[test]
+fn acpx_failed_or_cancelled_responses_retain_one_durable_settlement() {
+ for (mode, close) in [
+ ("permissions-wrong-ack", false),
+ ("permissions-interactive", true),
+ ("resolutions", true),
+ ] {
+ let directory = temporary_directory("response-expiry");
+ let (mut executor, config, request_id) = pending_acpx_runtime_request(&directory, mode);
+ if close {
+ executor
+ .execute(&command(4, "session.close", json!({})))
+ .unwrap();
+ } else {
+ let error = executor
+ .execute(&command(
+ 4,
+ "request.resolve",
+ acpx_runtime_resolution(mode, &request_id),
+ ))
+ .unwrap_err();
+ assert!(error
+ .to_string()
+ .contains("did not confirm permission resolution"));
+ }
+ let events = executor.poll_events().unwrap();
+ let requests: Vec<_> = events
+ .iter()
+ .filter(|event| event.event_type.starts_with("runtime_request."))
+ .collect();
+ assert_eq!(requests.len(), 1);
+ assert_eq!(
+ requests[0].event_type,
+ if close {
+ "runtime_request.cancelled"
+ } else {
+ "runtime_request.expired"
+ }
+ );
+ assert_eq!(requests[0].payload["requestId"], request_id);
+ assert_eq!(requests[0].payload["replayAllowed"], false);
+ drop(executor);
+ let mut recovered = NativeProviderCommandExecutor::with_runner_config(&directory, &config);
+ assert_eq!(recovered.poll_events().unwrap(), events);
+ assert!(recovered
+ .execute(&command(
+ 5,
+ "request.resolve",
+ acpx_runtime_resolution(mode, &request_id)
+ ))
+ .is_err());
+ recovered.acknowledge_events(events.len()).unwrap();
+ assert!(recovered.poll_events().unwrap().is_empty());
+ recovered.shutdown().unwrap();
+ fs::remove_dir_all(directory).unwrap();
+ }
+}
+
#[test]
fn preserves_acpx_semantic_disposition_in_the_run_terminal() {
let directory = temporary_directory("acpx-blocked");
diff --git a/ui/src/components/task-chat/QuestionForm.tsx b/ui/src/components/task-chat/QuestionForm.tsx
index 205231ef03c..58b355317c7 100644
--- a/ui/src/components/task-chat/QuestionForm.tsx
+++ b/ui/src/components/task-chat/QuestionForm.tsx
@@ -522,7 +522,7 @@ export function QuestionForm({
tabIndex={0}
className="mb-3 max-h-96 overflow-auto text-sm text-muted-foreground"
>
- {questionSet.description}
+ {questionSet.description}
) : null}
{question.answerMode === "text" ? (
From 2bf24873d9fea3e0045ff019e8108ed184c8687e Mon Sep 17 00:00:00 2001
From: Dotta
Date: Tue, 29 Sep 2026 08:18:03 -0500
Subject: [PATCH 11/13] test(runner): synchronize recovered ACP fixture cleanup
Parallel fake sidecars share lifetime-fence ports. Bound cleanup retries to the exact quorum rejection and assert the persisted uncertainty flag before and after proof. Preserve every crash, settlement, and no-replay assertion.
Co-Authored-By: Paperclip
---
.../tests/native_provider_backend.rs | 49 +++++++++++++++++--
1 file changed, 46 insertions(+), 3 deletions(-)
diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs
index fa4f188a440..e768abaacc8 100644
--- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs
+++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs
@@ -318,6 +318,49 @@ fn acpx_runtime_resolution(mode: &str, request_id: &str) -> Value {
})
}
+fn shutdown_recovered_acpx_fixture(executor: &mut NativeProviderCommandExecutor, directory: &Path) {
+ // The fake sidecar advertises the same three lifetime-fence ports for all
+ // fixtures. Parallel recovered shutdowns hold a quorum through state fsync,
+ // temporarily preventing another fixture from proving cleanup. Retry only
+ // that rejection, using the real production quorum proof on every attempt.
+ // This is fixture synchronization, not a production cleanup workaround.
+ let deadline = std::time::Instant::now() + Duration::from_secs(5);
+ let mut blocked_attempts = 0;
+ loop {
+ match executor.shutdown() {
+ Ok(()) => break,
+ Err(error) => {
+ assert_eq!(
+ error.to_string(),
+ "ACPX original provider lifetime remains active; cleanup is not yet proven",
+ "unexpected recovered fixture shutdown failure"
+ );
+ let persisted: Value = serde_json::from_slice(
+ &fs::read(directory.join("acpx-provider-state.json")).unwrap(),
+ )
+ .unwrap();
+ assert_eq!(persisted["providerExitUnconfirmed"], true);
+ blocked_attempts += 1;
+ if blocked_attempts == 1 {
+ eprintln!("recovered fixture cleanup waiting for the shared lifetime quorum");
+ }
+ assert!(
+ std::time::Instant::now() < deadline,
+ "recovered fixture cleanup never acquired the lifetime quorum"
+ );
+ std::thread::sleep(Duration::from_millis(10));
+ }
+ }
+ }
+ if blocked_attempts > 0 {
+ eprintln!("recovered fixture cleanup proved after {blocked_attempts} quorum rejections");
+ }
+ let persisted: Value =
+ serde_json::from_slice(&fs::read(directory.join("acpx-provider-state.json")).unwrap())
+ .unwrap();
+ assert_eq!(persisted["providerExitUnconfirmed"], false);
+}
+
#[test]
fn acpx_response_delivery_survives_crash_before_journaling_without_replaying_the_response() {
for mode in [
@@ -388,7 +431,7 @@ fn acpx_response_delivery_survives_crash_before_journaling_without_replaying_the
let mut recovered_again =
NativeProviderCommandExecutor::with_runner_config(&directory, &config);
assert!(recovered_again.poll_events().unwrap().is_empty());
- recovered_again.shutdown().unwrap();
+ shutdown_recovered_acpx_fixture(&mut recovered_again, &directory);
fs::remove_dir_all(directory).unwrap();
}
}
@@ -465,7 +508,7 @@ fn acpx_uncommitted_delivery_expires_after_failed_state_write_without_replay() {
.is_err());
assert_eq!(recovered.poll_events().unwrap(), events);
recovered.acknowledge_events(events.len()).unwrap();
- recovered.shutdown().unwrap();
+ shutdown_recovered_acpx_fixture(&mut recovered, &directory);
fs::remove_dir_all(directory).unwrap();
}
}
@@ -523,7 +566,7 @@ fn acpx_failed_or_cancelled_responses_retain_one_durable_settlement() {
.is_err());
recovered.acknowledge_events(events.len()).unwrap();
assert!(recovered.poll_events().unwrap().is_empty());
- recovered.shutdown().unwrap();
+ shutdown_recovered_acpx_fixture(&mut recovered, &directory);
fs::remove_dir_all(directory).unwrap();
}
}
From 3ca196b0a642aa21b8feba1fcd89edb53d1c622e Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Tue, 29 Sep 2026 08:25:56 -0500
Subject: [PATCH 12/13] feat(agents): persist agent files across tasks without
revision history (#14420)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - An agent needs personal files across tasks and sessions.
> - AGENTS.md is one file in that directory. Supporting files need the
same persistence.
> - The Instructions Editor and agent runs must share one current
directory.
> - Concurrent runs should apply only the files they change. The last
sync of the same file wins.
> - This pull request uses existing file transport and removes temporary
copies after sync.
> - Old instruction-only sessions keep their restore contract. New saves
do not create revision history.
## Linked Issues or Issue Description
Refs #14325. This replaces its revision-oriented design with persistent
agent files. Keep #14325 unmerged.
Transport prerequisite #14416 merged first at
`d172197117a14b80a1eb2d2835a0e7cce2679656`. This PR now targets master
and remains below 100 changed files.
Related work: #4513 and #8798 cover instruction tooling. This change
handles run synchronization, cross-task personal files, browser editing,
and old-session restoration.
## What Changed
- Keep one current directory per company and agent. Point AGENT_HOME at
a temporary working copy for each active run. Keep task files and
provider HOME separate.
- Restore text, binary files, and nested folders through workspace
transport. Exclude remote agent files from task Git snapshots with a
self-ignoring file inside the reserved runtime directory; never write
through repository-controlled Git metadata.
- Collect after the provider and child processes have stopped. Keep
resumable conversation state.
- Apply changed and deleted files under the agent lock. The last sync
wins for the same file. Unrelated concurrent changes survive.
- Remove temporary copies after successful sync, rejected sync, and
staging failure. Register ownership before copying so restart recovery
can remove interrupted preparation. Retry transient synchronization up
to three times. Preserve the original remote lease reference until
deletion succeeds; restart cleanup never acquires a replacement sandbox.
Do not create captured directories or a conflict-review queue for new
runs.
- Keep browser editing, stale-draft protection, and streaming binary
downloads. Keep the instruction entry and text editor limited to 1 MiB.
- Keep historical agent-folder sync failures on their affected runs
instead of repeating them above current saved instructions. Preserve
legacy candidate review and current browser-save errors. Avoid duplicate
quota warnings while retaining separate sync failures when they describe
a different problem.
- Require target-scoped caller grants for peer instruction access, while
preserving self edits, responsible-user checks, and protected-change
consent.
- Treat full storage as a nonblocking run warning, never an agent pause
or run-admission failure. Restore already-over-quota saved folders so
ordinary agent cleanup can recover; warn on each run until cleanup. The
run detail view shows the warning.
- Allow 256 MiB per file, 2 GiB per directory, and 100,000 entries. Hash
large files as streams. Check editor-save quotas with metadata instead
of hashing unrelated files.
- Preserve old native inputs, instruction-only copies, paths, digests,
and pending legacy candidates. Adopt old revision heads once. New writes
do not append history rows.
- Add idempotent migration 0287 and verify upgrades from the preview
tables and receipts.
- Add nine interactive stories under **Agents / Persistent files**,
including automatic incoming edits, stale browser drafts, and
storage-limit diagnostics.
## Verification
- Merge candidate: `4f5390107ec6ffd80a76d1d2e85530e66f21d079`, after
merging current master and the landed transport prerequisite.
Integration required no manual conflict resolution; the feature remains
99 changed files. Full workspace typecheck, production build, token
gates, and 715 focused tests passed on this merge candidate. Fresh
Greptile review is 5/5 with no unresolved findings. All 55 checks
passed, with four conditional skips, including the build, typecheck,
browser E2E, and canary dry run. A single retry recovered four jobs
interrupted by runner shutdowns; no source changes were required.
- Historical-warning UI fix: all 6,834 UI tests across 640 files passed,
including regression coverage for three old failures, legacy preserved
edits, and warnings scoped to the affected run. Full workspace
typecheck, production build, Storybook build, and token gates passed.
Browser-verified Storybook playtests passed for Historical Failures
After Successful Save, Storage Limit, and Full Storage Run Warning.
- Review follow-ups at `4e20c9fb2`: all 18 focused tests passed,
including external Git directories, linked worktrees, symlinks,
hardlinks, and distinct I/O failures alongside storage warnings. Server
and UI typechecks, token gates, and the production build passed.
- Storage warning regressions at `0724f3012`: all 33 directory tests and
all five heartbeat-list tests passed, with no skips in their successful
runs. They cover repeated runs while full, an already-over-quota saved
folder, cleanup, warnings retained after unrelated save failures, and
bounded warnings in large result JSON. Server typecheck passed after the
final warning fixes.
- Full workspace typecheck, production build, and token gates passed
during this follow-up. Product E2E harness: 631 tests passed across 52
files; harness typecheck passed. Earlier native session/context and
directory/legacy collection suites passed 537 tests; Runner
unit/transport suites passed 329 tests.
- **Real E2E at `0724f3012` (before this follow-up):** legacy local
Codex and native Daytona Codex each passed six tasks, one server
restart, seven independent assertions, and cleanup verification. Both
prove browser-to-agent edits, agent-to-browser edits, nested/binary
restoration, per-file last-sync-wins, a successful run after an
oversized save rejection, and cleanup clearing the warning.
- Native local Codex also passed the six-task quota flow before the
final warning-retention fixes. That pass began at `918d1ed02` while the
bounded-result warning fix was being edited, so it is not claimed as
exact-final-head evidence. Its final-head rerun failed during embedded
PostgreSQL bootstrap before any provider run: the macOS host had 87,365
of 87,381 SysV semaphores occupied. No unrelated services or kernel
limits were changed.
- The final-source report intentionally records **2/3 cells passed**,
preserving the blocked native-local attempt:
`tests/runner-e2e/results/agent-files-quota-final-20260928-report/`.
Earlier failed attempts and provenance notes remain under
`tests/runner-e2e/results/agent-files-quota-final-20260928-input/` and
the original campaign directories.
- Daytona used immutable image
`ghcr.io/paperclipai/paperclip-daytona-runner@sha256:5643f0d801417cae3581833a1a3bc6715b325e028602738d2652c44cac5dc6bf`
and its exact Linux runner binary. Controller source is `0724f3012`;
image source is recorded separately.
- Legacy-session compatibility and all three ACP Stop/resume browser
regressions passed on the prior validated feature head
`169fab46d5af21caa2269b4c1b29b69c933a6951`. They assert the same
provider session is retained and interrupted writes are not replayed.
Migration upgrade tests also passed earlier.
- Nine interactive stories are under **Agents / Persistent files**,
including **Full Storage Run Warning**. Its playtest and visual browser
inspection passed; the warning states that runs continue and the editor
remains available.
- Prior-head checks on `4e20c9fb2`: 55 passed, two conditional jobs
skipped, no failures or pending checks. All eight browser E2E shards and
their aggregate passed. Fresh Greptile review is 5/5 with no findings;
all review threads are resolved, the security scan passed, and GitHub
reports no merge conflicts.
- The broad local follow-up test run was interrupted after host
semaphore exhaustion affected isolated PostgreSQL instances. It also
encountered the existing macOS long-path fixture failure and two timeout
failures. This is not a claim that the full local suite passed. Logs are
retained; focused storage/warning tests passed.
## Risks
- A later sync can overwrite an earlier edit to the same file, including
a saved browser edit. There is no text merge or retained version. This
is the intended last-sync-wins policy.
- A save that exceeds a storage limit is rejected and its temporary copy
is discarded. The run itself continues normally, and later runs restore
the last saved files with a warning until cleanup. Transient sync
failures get bounded retries. An I/O failure partway through a sync can
leave some files updated; a failed receipt does not claim whole-folder
success.
- Larger folders increase copy time, network traffic, and temporary disk
usage. Active runs still need working copies. Terminal runs do not
accumulate archives. Operators must provision disk for agents and
configured concurrency; these limits are not company-wide quotas.
- A restored old native session remains instruction-only until a fresh
session starts. Its original conflict fence and existing pending
candidates remain compatible.
- Provider processes close at the collection boundary. Conversation
resume remains available, but warm process reuse is lost.
- Backups must include the instance filesystem and database. External
bundles keep their existing behavior until explicitly moved to managed
storage.
## Model Used
OpenAI Codex, GPT-6 family. The session does not expose a more specific
model ID or context-window size. Reasoning, code execution, and browser
tools assisted this change. Real provider E2E uses `gpt-5.6-sol`.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Fry (Paperclip)
---
doc/DATABASE.md | 11 +
doc/SPEC-implementation.md | 17 +
doc/SPEC.md | 12 +
doc/agent-files.md | 177 +++
doc/agent-instruction-revisions.md | 15 +
doc/evals.md | 5 +
.../adapter-utils/src/acpx-engine/execute.ts | 6 +-
.../src/agent-instruction-migration.test.ts | 141 +++
.../src/migrations/0287_serious_tinkerer.sql | 61 +
...{0282_snapshot.json => 0287_snapshot.json} | 1106 ++++++++++++++++-
packages/db/src/migrations/meta/_journal.json | 7 +
.../src/schema/agent_instruction_revisions.ts | 119 ++
packages/db/src/schema/index.ts | 2 +
.../capability/semantic-tool-contracts.json | 2 +-
.../evals/native-execution-seeded.json | 2 +-
.../paperclip-runner/protocol/manifest.json | 2 +-
.../spec/capability/protocol-coverage.json | 78 +-
.../spec/operation-groups/source.json | 8 +-
.../spec/paperclip-agent-operation-groups.md | 18 +-
.../src/backends/codex-native-backend.ts | 1 +
.../src/backends/runtime-context.ts | 5 +
.../src/catalog/reconciliation.test.ts | 14 +-
.../src/contracts/runtime-context.ts | 28 +-
.../src/drivers/codex/app-server-transport.ts | 1 +
.../codex/codex-app-server-driver-impl.ts | 2 +-
.../src/drivers/codex/codex-driver-types.ts | 2 +
.../codex/codex-security-config.test.ts | 19 +
.../drivers/codex/codex-security-config.ts | 5 +
.../runtime-context-materializer.test.ts | 12 +
.../src/eval/workflow-evals.test.ts | 6 +-
.../src/live/runnerd-codex-transport.ts | 4 +
.../src/native-session-runtime.test.ts | 42 +
.../src/native-session-runtime.ts | 4 +
.../src/protocol-actions/core.ts | 9 +
.../get-agent-instruction-history.ts | 99 ++
.../src/protocol-actions/index.ts | 9 +
.../read-agent-instructions.ts | 90 ++
.../restore-agent-instructions.ts | 99 ++
.../update-agent-instructions.ts | 102 ++
.../src/semantic-tools/discovery.ts | 10 +-
.../src/semantic-tools/semantic-tools.test.ts | 2 +-
.../src/semantic-tools/types.ts | 4 +
packages/shared/src/index.ts | 7 +
packages/shared/src/types/agent.ts | 79 ++
packages/shared/src/types/index.ts | 4 +
packages/shared/src/validators/agent.ts | 20 +-
packages/shared/src/validators/index.ts | 2 +
.../agent-directory-working-copies.test.ts | 539 ++++++++
.../agent-instruction-git-exclusion.test.ts | 87 ++
.../agent-instruction-revisions.test.ts | 247 ++++
.../agent-instruction-working-copies.test.ts | 345 +++++
.../agent-instructions-routes.test.ts | 174 ++-
.../agent-instructions-service.test.ts | 58 +-
.../__tests__/built-in-agent-routes.test.ts | 2 +-
server/src/__tests__/built-in-agents.test.ts | 136 +-
server/src/__tests__/heartbeat-list.test.ts | 12 +
.../__tests__/plugin-managed-agents.test.ts | 164 ++-
.../rerun-survival.integration.test.ts | 27 +-
server/src/routes/agents.ts | 158 ++-
server/src/routes/built-in-agents.ts | 2 +-
server/src/routes/openapi.ts | 44 +-
.../agent-directory-working-copies.ts | 258 ++++
server/src/services/agent-file-store.ts | 282 +++++
.../agent-instruction-authorization.ts | 195 +++
.../src/services/agent-instruction-files.ts | 178 +++
.../services/agent-instruction-revisions.ts | 429 +++++++
.../agent-instruction-working-copies.ts | 356 ++++++
server/src/services/agent-instructions.ts | 185 ++-
server/src/services/authorization.ts | 21 +-
server/src/services/built-in-agents.ts | 157 ++-
server/src/services/change-consent-gate.ts | 2 +-
server/src/services/company-portability.ts | 2 +-
server/src/services/heartbeat.ts | 118 ++
...gent-instruction-tools.integration.test.ts | 89 ++
.../agent-instruction-tools.test.ts | 20 +
.../native-runtime/agent-instruction-tools.ts | 76 ++
.../native-session-executor.test.ts | 51 +
.../native-runtime/native-session-executor.ts | 19 +-
.../paperclip-runner-tool-authority.test.ts | 2 +-
.../paperclip-runner-tool-authority.ts | 9 +
.../native-runtime/runner-api-catalog.ts | 4 +
.../native-runtime/runner-api.test.ts | 4 +
.../native-runtime/runtime-context.test.ts | 61 +
.../native-runtime/runtime-context.ts | 32 +-
server/src/services/plugin-managed-agents.ts | 127 +-
tests/runner-e2e/FIXTURES.md | 16 +
tests/runner-e2e/README.md | 32 +
tests/runner-e2e/catalog.test.ts | 6 +-
tests/runner-e2e/catalog.ts | 11 +
.../instruction-persistence.test.ts | 36 +
tests/runner-e2e/instruction-persistence.ts | 232 ++++
tests/runner-e2e/runner.spec.ts | 13 +-
tests/runner-e2e/types.ts | 3 +-
ui/src/api/agents.ts | 25 +-
ui/src/components/InstructionHistory.tsx | 145 +++
ui/src/lib/queryKeys.ts | 2 +
.../pages/AgentDetail.instructions.test.tsx | 158 ++-
ui/src/pages/AgentDetail.tsx | 206 ++-
ui/storybook/stories/agent-files.stories.tsx | 182 +++
99 files changed, 7948 insertions(+), 261 deletions(-)
create mode 100644 doc/agent-files.md
create mode 100644 doc/agent-instruction-revisions.md
create mode 100644 packages/db/src/agent-instruction-migration.test.ts
create mode 100644 packages/db/src/migrations/0287_serious_tinkerer.sql
rename packages/db/src/migrations/meta/{0282_snapshot.json => 0287_snapshot.json} (97%)
create mode 100644 packages/db/src/schema/agent_instruction_revisions.ts
create mode 100644 packages/paperclip-runner/src/protocol-actions/get-agent-instruction-history.ts
create mode 100644 packages/paperclip-runner/src/protocol-actions/read-agent-instructions.ts
create mode 100644 packages/paperclip-runner/src/protocol-actions/restore-agent-instructions.ts
create mode 100644 packages/paperclip-runner/src/protocol-actions/update-agent-instructions.ts
create mode 100644 server/src/__tests__/agent-directory-working-copies.test.ts
create mode 100644 server/src/__tests__/agent-instruction-git-exclusion.test.ts
create mode 100644 server/src/__tests__/agent-instruction-revisions.test.ts
create mode 100644 server/src/__tests__/agent-instruction-working-copies.test.ts
create mode 100644 server/src/services/agent-directory-working-copies.ts
create mode 100644 server/src/services/agent-file-store.ts
create mode 100644 server/src/services/agent-instruction-authorization.ts
create mode 100644 server/src/services/agent-instruction-files.ts
create mode 100644 server/src/services/agent-instruction-revisions.ts
create mode 100644 server/src/services/agent-instruction-working-copies.ts
create mode 100644 server/src/services/native-runtime/agent-instruction-tools.integration.test.ts
create mode 100644 server/src/services/native-runtime/agent-instruction-tools.test.ts
create mode 100644 server/src/services/native-runtime/agent-instruction-tools.ts
create mode 100644 tests/runner-e2e/instruction-persistence.test.ts
create mode 100644 tests/runner-e2e/instruction-persistence.ts
create mode 100644 ui/src/components/InstructionHistory.tsx
create mode 100644 ui/storybook/stories/agent-files.stories.tsx
diff --git a/doc/DATABASE.md b/doc/DATABASE.md
index 151581873d3..3a348cbe52a 100644
--- a/doc/DATABASE.md
+++ b/doc/DATABASE.md
@@ -442,6 +442,17 @@ revokes the previous boot identity with a conditional update. Its own claim also
expires so another sweep can finish cleanup after a restart. Historical rows keep
null ownership fields and follow the previous recovery path.
+## Agent file persistence and legacy revisions
+
+Managed agent files are current filesystem contents, using the same persistent
+instance storage as other workspaces. `agent_instruction_revisions` and
+`agent_instruction_heads` are retained as read-only upgrade input. Their heads
+are adopted once into the managed directory; new saves never append revisions.
+`agent_instruction_working_copies` holds per-run baseline hashes, state, and
+capture receipts. New receipts identify `paperclip.agent-files.v1`; historical
+rows retain the instruction-only format. Completed directory runs discard their
+baseline and private copies. See [Persistent agent files](agent-files.md).
+
## Large API response snapshots
`assets.byte_size` uses PostgreSQL `bigint` so saved responses and byte ranges can
diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md
index 927e7f1a7e5..12e000fea51 100644
--- a/doc/SPEC-implementation.md
+++ b/doc/SPEC-implementation.md
@@ -1725,6 +1725,23 @@ read or change these preferences. The legacy instance general setting is retaine
for API compatibility but no longer controls shortcut behavior in the app;
users opt in individually after the upgrade.
+### Persistent managed agent files (2026-09-28)
+
+The Instructions Editor and agent execution share one current agent-owned
+directory, scoped by company and agent. The configured instruction entry is one
+file in this directory. Registered private copies synchronize supported files
+across tasks and sessions, separately from task workspace persistence. Saves
+require verified provider stop and current authorization, then synchronize changed
+files using per-file last-sync-wins;
+new content is not stored as revision history. Existing deployed revisions and
+saved execution formats remain compatible during adoption. See
+[Persistent agent files](agent-files.md).
+
+Persistent-file storage limits are advisory for execution: a full folder cannot
+pause the agent, fail its run, or prevent later runs. Show a warning on each run
+while storage remains full, restore existing files so the agent can remove them,
+and enforce the limits on saves. Cleanup clears the warning for future runs.
+
### Unsafe native workspace exports
An unsafe workspace link does not fail an accepted native task result. Retry
diff --git a/doc/SPEC.md b/doc/SPEC.md
index 3e5fa410fe6..e69a6f1b8b0 100644
--- a/doc/SPEC.md
+++ b/doc/SPEC.md
@@ -620,6 +620,18 @@ read or change these preferences. The legacy instance general setting is retaine
for API compatibility but no longer controls shortcut behavior in the app;
users opt in individually after the upgrade.
+Managed agents own a persistent file directory across tasks and sessions. The
+Instructions Editor and stopped agent execution synchronize the same current
+files, including AGENTS.md and its supporting files. Task working directories and
+provider home directories remain separate concepts. Concurrent runs synchronize only
+the files they change, with the last sync winning for the same file. Temporary
+copies are cleaned up; this storage does not add a revision-history system. See
+[agent-files.md](agent-files.md) for lifecycle and upgrade compatibility.
+
+Full agent storage produces a run warning without stopping current or future
+work. Storage limits constrain saved file changes, not the agent's ability to run
+and remove files to recover space.
+
### Unsafe native workspace exports
An unsafe workspace link does not fail an accepted native task result. Retry
diff --git a/doc/agent-files.md b/doc/agent-files.md
new file mode 100644
index 00000000000..956db02c029
--- /dev/null
+++ b/doc/agent-files.md
@@ -0,0 +1,177 @@
+# Persistent agent files
+
+Each managed agent has one current directory, scoped by company and agent. The
+Instructions Editor reads and writes this directory. `AGENTS.md` (or the
+configured entry) is one file in it. Agents may create ordinary files and nested
+folders for notes, memory, and other personal working material. Files in the
+task working directory remain task files.
+
+Agents can edit their own managed files under the responsible user’s current
+target permissions. Access to another agent’s files additionally requires the
+caller’s own target-scoped configuration permission; shared company membership
+or a responsible user alone does not grant peer access.
+
+## Layout
+
+The canonical host directory keeps its existing physical location:
+
+```
+/companies//agents//
+ instructions/ current agent files (editor)
+ AGENTS.md
+ notes/
+ any-supported-file
+ file-sync/ controller-only operational state
+ adopted.json
+ runs//live/ isolated writable copy while a run executes
+```
+
+The process starts in its existing task workspace. `AGENT_HOME` points to the
+registered writable agent copy. Adapter `HOME` and `CODEX_HOME` keep their
+existing meanings and are not personal-file storage. Local copies are outside
+the task workspace. Remote providers currently confine file sync to their
+workspace: their independent agent copy therefore lives under the excluded
+`.paperclip-runtime/agent-files///` area. It is not included in task
+workspace sync, Git staging, or task deliverables.
+
+Regular files (including binary bytes) and directories are supported, up to
+100,000 entries (files and folders), 256 MiB per file and 2 GiB total. Symlinks,
+hardlinks, and special
+files are rejected, rather than followed or silently skipped. The instruction
+entry remains valid UTF-8, at most 1 MiB, and cannot be deleted. The editor edits
+text up to 1 MiB and offers downloads for binary or larger files. The reserved
+`.paperclip-runtime` directory and the compatibility-only virtual file
+`promptTemplate.legacy.md` are not user storage. Task cache and Git ignore
+exclusions do not apply to this directory.
+
+These storage limits are separate from the 1 MiB instruction/editor limit. Large
+files are hashed and downloaded as streams; listings bound concurrent reads,
+and only editor-sized text is buffered. Storage counts uncompressed file bytes,
+not allocated disk
+blocks. These are sync validation limits, not live filesystem quotas: an agent
+can write beyond them while running. Storage limits never pause an agent, fail
+a provider run, or block future task admission. A folder at or above a limit
+produces a warning on each run until enough files have been removed or shrunk.
+Existing saved files are restored even when already over quota, so the agent
+can continue working and clean them up with ordinary filesystem tools. Unsafe
+paths and links still fail validation; bypassing a storage quota does not
+bypass those checks.
+
+An API save above a storage limit returns 422 without changing the saved files.
+If a stopped run exceeds a storage limit, none of its agent-folder changes are
+saved. The run shows a nonblocking storage warning and its save receipt reports
+`AGENT_FILES_LIMIT_EXCEEDED` with the specific limit
+and, for an oversized file, its path. The previous saved folder is used on the
+next run. The temporary run copy is discarded, including on a limit failure;
+there is no retained recovery archive or partial-save option. Transient sync
+failures get up to three attempts at the stop boundary before cleanup and an
+explicit failure receipt. Individual file writes are atomic, but an I/O failure
+partway through a sync can leave some files updated; a failed receipt does not
+claim whole-folder success.
+
+Sync failures are diagnostics for the affected run, not errors on the current
+files in the Instructions Editor. Historical failures remain in the run log;
+the run detail also shows warnings from its save receipt. The editor only shows
+preserved instruction-only candidates that may need review, alongside errors
+from the current browser edit. Later successful saves do not erase run history.
+
+Larger folders take longer to hash, copy, and transfer on each run. There is one
+canonical folder plus temporary working copies for currently active runs (and
+remote staging when the transport needs it). No additional captured tree is
+created. Terminal runs remove their private trees and baseline metadata, keeping
+only a small receipt. Restart recovery retries interrupted cleanup without
+removing a running provider's files. These are not aggregate disk quotas; the
+operator still provisions storage for agents and the configured run concurrency.
+
+## Run lifecycle
+
+1. Under the agent lock, restore current files into a private run copy and save
+ a baseline of paths, kinds, modes, and hashes. This is sync metadata, not a
+ revision history.
+2. Stage the copy through the existing workspace transport. Point `AGENT_HOME`
+ and instruction guidance at that registered root.
+3. At the provider's verified checkpoint-and-stop boundary, retrieve the entire
+ directory into the existing working copy before releasing its environment.
+4. Recheck the responsible user's current authorization. Under the same agent
+ lock used by editor writes, apply only files changed or deleted relative to
+ the starting baseline. For a competing edit or deletion of the same file,
+ the last synchronization to acquire the lock wins. Unchanged files do not
+ overwrite another run's changes; newly added unrelated files survive.
+5. Record the outcome and remove temporary copies for successful and failed
+ runs. No per-run file versions, conflict copies, or review queue accumulate.
+ The next run starts with the current directory.
+
+The whole-directory contract closes the provider process to establish a safe
+collection boundary, including child processes. It preserves the provider's
+resumable conversation. Only the loaded instruction entry participates in the
+new runtime instruction digest; adding or editing another file does not change
+that digest. Relative supporting files are read from `AGENT_HOME`, not from the
+read-only prompt snapshot.
+
+The editor supplies the hash of the file it read. A stale browser save returns
+409 and retains the user's unsaved draft. Run synchronization itself uses
+per-file last-sync-wins: a later run can overwrite a saved browser edit to the
+same file. There is no text merge or historical copy to recover the overwritten
+version. Ordinary task files continue using their existing workspace contract.
+
+## Upgrade and recovery
+
+Migration 0287 creates the preview tables idempotently after master’s 0285/0286.
+Existing preview receipts, rows, constraints, and pending captures are retained.
+On first use, while holding
+the agent row lock, import any deployed revision heads into the existing managed
+directory once. A controller-owned marker outside agent files prevents any
+later replay of those heads. Existing revision rows remain readable for recovery;
+new saves never append to them. Old UUID-based clients receive content tokens
+and can still submit their previously recorded revision IDs, which are checked
+against the corresponding bytes before a write.
+
+Working-copy receipts and native runtime inputs record the new file contract.
+A restored native session with no contract field keeps the old instruction-only
+copy shape, prompt digest, paths, and collector. Its writes use the compatibility
+bridge into current files, with the original baseline fence. Existing pending
+legacy candidates remain resolvable. Neither old task workspaces nor arbitrary
+external instruction roots are imported as agent directories.
+
+Stock-agent and plugin resets update their declared files while retaining unrelated
+personal files and formerly configured entries. Automatic stock upgrades first
+record baseline hashes in the existing resource binding, then apply and finalize
+under the agent lock. A failed file write or database commit retries against
+those hashes and already-applied bytes. Removed, unchanged stock files are
+removed; intervening personal edits stop the retry. This pending operation
+metadata is cleared on success and does not retain file revisions.
+
+External bundles retain their existing behavior. Their migration to managed
+storage is an explicit configuration action. Historical task cwd, provider-home,
+checkpoint, and workspace restoration formats are not rewritten.
+
+Backups must include the persistent instance filesystem as well as the database.
+New current-file bytes are not database revision rows. Old instruction-only
+candidates are retained solely for upgrade compatibility.
+
+Crash recovery can collect a stopped working copy without starting a model.
+Missing stop proof or lost remote bytes produce a visible diagnostic, never a
+save receipt. An interrupted apply can replay its changed files with the same
+last-sync-wins rule. Cleanup resumes for terminal runs; no copy is retained as
+an archive after cleanup succeeds.
+
+## Verification
+
+`agent-directory-working-copies.test.ts` exercises nested/binary files, directory
+isolation, last-sync-wins edits and deletions, terminal cleanup, link rejection, old-head
+adoption, and stable prompt digests. The legacy working-copy and native-tool
+suites exercise compatibility. Workspace merge tests exercise preflight and
+interrupted replay.
+
+The explicit Product E2E `instruction-persistence` suite creates a file through
+the browser editor, runs an agent that changes instructions and supporting files,
+checks exact binary bytes via the public download route, restarts the server,
+and asks a fresh task to prove restored contents using an independent nonce. A
+third task edits its entry while the browser saves that same file; the later
+run sync wins while a separate browser-created file survives, with no conflict
+candidate or manual resolution. Three more tasks save a sparse file at its
+256 MiB boundary, exceed that boundary with a nonfatal save rejection, then
+remove it and save a new small file. All tasks must succeed, with warnings
+visible in run details while full and cleared after cleanup.
+Run results, including unavailable credentials, must be reported separately from
+unit or matcher results; a passing matcher does not prove a live run.
diff --git a/doc/agent-instruction-revisions.md b/doc/agent-instruction-revisions.md
new file mode 100644
index 00000000000..d4512d68678
--- /dev/null
+++ b/doc/agent-instruction-revisions.md
@@ -0,0 +1,15 @@
+# Legacy instruction revision compatibility
+
+The revision-based design in the first version of PR #14325 has been superseded
+by [persistent agent files](agent-files.md). Current managed files are authoritative;
+new edits do not create revision or head rows.
+
+Published migrations 0285 and 0286 and existing revision records are retained so
+preview installations and saved sessions can upgrade safely. Old heads are
+adopted once. Legacy collectors and UUID-based APIs compare their baseline bytes
+against current files before writing. Read-only history and restore endpoints
+remain for pre-upgrade records; they are not advertised as new agent capabilities
+or shown as a history feature in the current file editor.
+
+See the current document for directory layout, synchronization, conflicts,
+authorization, recovery, and verification.
diff --git a/doc/evals.md b/doc/evals.md
index cbfd78e78b5..40d298b5dbc 100644
--- a/doc/evals.md
+++ b/doc/evals.md
@@ -276,6 +276,11 @@ results and follow-up coverage are recorded in that suite's guide.
Continuation accounting has an explicit-only eight-cell Product E2E [baseline suite](../tests/runner-e2e/CONTINUATION-ACCOUNTING.md), complementing the deterministic lifecycle inventory.
+The explicit Product E2E `instruction-persistence` suite verifies private file
+edits, nested and binary agent files, stopped-provider directory saves, server restart, and a fresh task's
+downloaded proof on local native/legacy Codex and native Daytona. See the
+[Product E2E runbook](../tests/runner-e2e/README.md).
+
The explicit-only Product E2E `api-response-reading` suite verifies retrieval of
large saved API responses on local and Daytona native Codex runs. See the
[Runner E2E guide](../tests/runner-e2e/README.md#bounded-api-response-reading).
diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts
index 2c4b029e126..7aaef803b9d 100644
--- a/packages/adapter-utils/src/acpx-engine/execute.ts
+++ b/packages/adapter-utils/src/acpx-engine/execute.ts
@@ -2058,7 +2058,11 @@ async function buildRuntime(input: {
paperclipClaudeSettings = await writePaperclipClaudeSettings({
cwd,
stateDir,
- agentHome,
+ // A run's writable AGENT_HOME moves, but the existing permission root
+ // must not invalidate its resumable conversation. Run copies are covered
+ // by the company root already granted in these settings; the process env
+ // still receives this run's actual AGENT_HOME.
+ agentHome: asString(workspaceContext.agentHomeForPermissions, agentHome),
companyId: agent.companyId,
});
skillCommandNotes.push(
diff --git a/packages/db/src/agent-instruction-migration.test.ts b/packages/db/src/agent-instruction-migration.test.ts
new file mode 100644
index 00000000000..6def7b7e884
--- /dev/null
+++ b/packages/db/src/agent-instruction-migration.test.ts
@@ -0,0 +1,141 @@
+import { createHash, randomUUID } from "node:crypto";
+import { readFile } from "node:fs/promises";
+import postgres from "postgres";
+import { describe, expect, it } from "vitest";
+import { applyPendingMigrations, inspectMigrations } from "./client.js";
+import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
+
+// Exact SQL from PR #14325 before master assigned 0285/0286 to API response storage.
+// Exercise real preview tables/receipts, not a second application of the new schema.
+const previewMigrations = [
+ { when: 1790474441205, sql: `CREATE TABLE "agent_instruction_heads" (
+ "company_id" uuid NOT NULL,
+ "agent_id" uuid NOT NULL,
+ "entry_file" text NOT NULL,
+ "revision_id" uuid NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "agent_instruction_heads_identity_uq" UNIQUE("company_id","agent_id","entry_file")
+);
+--> statement-breakpoint
+CREATE TABLE "agent_instruction_revisions" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "company_id" uuid NOT NULL,
+ "agent_id" uuid NOT NULL,
+ "entry_file" text NOT NULL,
+ "content_base64" text NOT NULL,
+ "content_hash" text NOT NULL,
+ "byte_length" integer NOT NULL,
+ "parent_revision_id" uuid,
+ "base_revision_id" uuid,
+ "restored_from_revision_id" uuid,
+ "actor_agent_id" uuid,
+ "actor_user_id" text,
+ "responsible_user_id" text,
+ "source_run_id" uuid,
+ "source" text NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "agent_instruction_revisions_identity_uq" UNIQUE("company_id","agent_id","entry_file","id")
+);
+--> statement-breakpoint
+ALTER TABLE "agent_instruction_heads" ADD CONSTRAINT "agent_instruction_heads_company_id_agent_id_entry_file_revision_id_agent_instruction_revisions_company_id_agent_id_entry_file_id_fk" FOREIGN KEY ("company_id","agent_id","entry_file","revision_id") REFERENCES "public"."agent_instruction_revisions"("company_id","agent_id","entry_file","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "agent_instruction_revisions" ADD CONSTRAINT "agent_instruction_revisions_company_id_agent_id_agents_company_id_id_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "agent_instruction_revisions_history_idx" ON "agent_instruction_revisions" USING btree ("company_id","agent_id","entry_file","created_at","id");` },
+ { when: 1790543348340, sql: `CREATE TABLE "agent_instruction_working_copies" (
+ "run_id" uuid PRIMARY KEY NOT NULL,
+ "company_id" uuid NOT NULL,
+ "agent_id" uuid NOT NULL,
+ "responsible_user_id" text NOT NULL,
+ "entry_file" text NOT NULL,
+ "base_revision_id" uuid,
+ "base_hash" text NOT NULL,
+ "local_root" text NOT NULL,
+ "execution_root" text NOT NULL,
+ "location" text NOT NULL,
+ "state" text DEFAULT 'prepared' NOT NULL,
+ "candidate_base64" text,
+ "candidate_hash" text,
+ "error_code" text,
+ "error_message" text,
+ "receipt" jsonb,
+ "process_stopped_at" timestamp with time zone,
+ "attempts" integer DEFAULT 0 NOT NULL,
+ "next_attempt_at" timestamp with time zone,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "agent_instruction_working_copies" ADD CONSTRAINT "agent_instruction_working_copies_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "agent_instruction_working_copies" ADD CONSTRAINT "agent_instruction_working_copies_company_id_agent_id_agents_company_id_id_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "agent_instruction_copies_pending_idx" ON "agent_instruction_working_copies" USING btree ("state","next_attempt_at");--> statement-breakpoint
+CREATE INDEX "agent_instruction_copies_agent_idx" ON "agent_instruction_working_copies" USING btree ("company_id","agent_id","created_at");` },
+];
+const migration = await readFile(new URL("./migrations/0287_serious_tinkerer.sql", import.meta.url), "utf8");
+const migrationHash = createHash("sha256").update(migration).digest("hex");
+
+const support = await getEmbeddedPostgresTestSupport();
+const describePostgres = support.supported ? describe : describe.skip;
+
+describePostgres("instruction revision migrations", () => {
+ it("keeps applied migration receipts, revisions, and pending copies intact on repeated upgrades", async () => {
+ const database = await startEmbeddedPostgresTestDatabase("instruction-migration-upgrade-");
+ const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
+ try {
+ await sql`DROP TABLE agent_instruction_heads, agent_instruction_working_copies, agent_instruction_revisions`;
+ await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${migrationHash}`;
+ for (const preview of previewMigrations) {
+ for (const statement of preview.sql.split("--> statement-breakpoint")) await sql.unsafe(statement);
+ await sql`INSERT INTO drizzle.__drizzle_migrations (hash, created_at)
+ VALUES (${createHash("sha256").update(preview.sql).digest("hex")}, ${preview.when})`;
+ }
+ const companyId = randomUUID(), otherCompanyId = randomUUID(), agentId = randomUUID();
+ const runId = randomUUID(), revisionId = randomUUID();
+ const bytes = Buffer.from("\uFEFF# Saved\r\nExact bytes ☃\n");
+ const contentBase64 = bytes.toString("base64");
+ const contentHash = createHash("sha256").update(bytes).digest("hex");
+ await sql`INSERT INTO companies (id, name, issue_prefix) VALUES
+ (${companyId}, 'Instructions', 'INS'), (${otherCompanyId}, 'Other', 'OTH')`;
+ await sql`INSERT INTO agents (id, company_id, name) VALUES (${agentId}, ${companyId}, 'Writer')`;
+ await sql`INSERT INTO heartbeat_runs (id, company_id, agent_id) VALUES (${runId}, ${companyId}, ${agentId})`;
+ await sql`INSERT INTO agent_instruction_revisions
+ (id, company_id, agent_id, entry_file, content_base64, content_hash, byte_length, source, source_run_id)
+ VALUES (${revisionId}, ${companyId}, ${agentId}, 'AGENTS.md', ${contentBase64}, ${contentHash}, ${bytes.length}, 'cleanup', ${runId})`;
+ await sql`INSERT INTO agent_instruction_heads (company_id, agent_id, entry_file, revision_id)
+ VALUES (${companyId}, ${agentId}, 'AGENTS.md', ${revisionId})`;
+ await sql`INSERT INTO agent_instruction_working_copies
+ (run_id, company_id, agent_id, responsible_user_id, entry_file, base_revision_id, base_hash, local_root, execution_root, location, state, candidate_base64, candidate_hash)
+ VALUES (${runId}, ${companyId}, ${agentId}, 'editor', 'AGENTS.md', ${revisionId}, ${contentHash}, '/private/copy', '/private/copy', 'local', 'pending_commit', ${contentBase64}, ${contentHash})`;
+ const snapshot = async () => ({
+ journal: await sql`SELECT * FROM drizzle.__drizzle_migrations ORDER BY id`,
+ relations: await sql`SELECT oid::text, relname FROM pg_class WHERE relname IN
+ ('agent_instruction_revisions', 'agent_instruction_heads', 'agent_instruction_working_copies') ORDER BY relname`,
+ constraints: await sql`SELECT oid::text, conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint
+ WHERE conrelid IN ('agent_instruction_revisions'::regclass, 'agent_instruction_heads'::regclass,
+ 'agent_instruction_working_copies'::regclass) ORDER BY conname`,
+ revisions: await sql`SELECT * FROM agent_instruction_revisions`,
+ heads: await sql`SELECT * FROM agent_instruction_heads`,
+ copies: await sql`SELECT * FROM agent_instruction_working_copies`,
+ });
+ const before = await snapshot();
+ expect(before.relations).toHaveLength(3);
+ expect(await inspectMigrations(database.connectionString)).toMatchObject({
+ status: "needsMigrations", pendingMigrations: ["0287_serious_tinkerer.sql"],
+ });
+ await applyPendingMigrations(database.connectionString);
+ const upgraded = await snapshot();
+ expect(upgraded).toEqual({ ...before, journal: expect.arrayContaining(before.journal) });
+ expect(upgraded.journal).toHaveLength(before.journal.length + 1);
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ expect(await inspectMigrations(database.connectionString)).toMatchObject({ status: "upToDate" });
+ await applyPendingMigrations(database.connectionString);
+ expect(await snapshot()).toEqual(upgraded);
+ }
+ await expect(sql`UPDATE agent_instruction_heads SET company_id = ${otherCompanyId} WHERE revision_id = ${revisionId}`)
+ .rejects.toMatchObject({ code: "23503" });
+ await expect(sql`UPDATE agent_instruction_working_copies SET company_id = ${otherCompanyId} WHERE run_id = ${runId}`)
+ .rejects.toMatchObject({ code: "23503" });
+ } finally {
+ await sql.end();
+ await database.cleanup();
+ }
+ }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
+});
diff --git a/packages/db/src/migrations/0287_serious_tinkerer.sql b/packages/db/src/migrations/0287_serious_tinkerer.sql
new file mode 100644
index 00000000000..2810534d4c1
--- /dev/null
+++ b/packages/db/src/migrations/0287_serious_tinkerer.sql
@@ -0,0 +1,61 @@
+-- Retain preview instruction rows and pending captures when upgrading PR #14325.
+CREATE TABLE IF NOT EXISTS "agent_instruction_heads" (
+ "company_id" uuid NOT NULL,
+ "agent_id" uuid NOT NULL,
+ "entry_file" text NOT NULL,
+ "revision_id" uuid NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "agent_instruction_heads_identity_uq" UNIQUE("company_id","agent_id","entry_file")
+);
+--> statement-breakpoint
+CREATE TABLE IF NOT EXISTS "agent_instruction_revisions" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "company_id" uuid NOT NULL,
+ "agent_id" uuid NOT NULL,
+ "entry_file" text NOT NULL,
+ "content_base64" text NOT NULL,
+ "content_hash" text NOT NULL,
+ "byte_length" integer NOT NULL,
+ "parent_revision_id" uuid,
+ "base_revision_id" uuid,
+ "restored_from_revision_id" uuid,
+ "actor_agent_id" uuid,
+ "actor_user_id" text,
+ "responsible_user_id" text,
+ "source_run_id" uuid,
+ "source" text NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "agent_instruction_revisions_identity_uq" UNIQUE("company_id","agent_id","entry_file","id")
+);
+--> statement-breakpoint
+CREATE TABLE IF NOT EXISTS "agent_instruction_working_copies" (
+ "run_id" uuid PRIMARY KEY NOT NULL,
+ "company_id" uuid NOT NULL,
+ "agent_id" uuid NOT NULL,
+ "responsible_user_id" text NOT NULL,
+ "entry_file" text NOT NULL,
+ "base_revision_id" uuid,
+ "base_hash" text NOT NULL,
+ "local_root" text NOT NULL,
+ "execution_root" text NOT NULL,
+ "location" text NOT NULL,
+ "state" text DEFAULT 'prepared' NOT NULL,
+ "candidate_base64" text,
+ "candidate_hash" text,
+ "error_code" text,
+ "error_message" text,
+ "receipt" jsonb,
+ "process_stopped_at" timestamp with time zone,
+ "attempts" integer DEFAULT 0 NOT NULL,
+ "next_attempt_at" timestamp with time zone,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+DO $$ BEGIN ALTER TABLE "agent_instruction_heads" ADD CONSTRAINT "agent_instruction_heads_company_id_agent_id_entry_file_revision_id_agent_instruction_revisions_company_id_agent_id_entry_file_id_fk" FOREIGN KEY ("company_id","agent_id","entry_file","revision_id") REFERENCES "public"."agent_instruction_revisions"("company_id","agent_id","entry_file","id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
+DO $$ BEGIN ALTER TABLE "agent_instruction_revisions" ADD CONSTRAINT "agent_instruction_revisions_company_id_agent_id_agents_company_id_id_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
+DO $$ BEGIN ALTER TABLE "agent_instruction_working_copies" ADD CONSTRAINT "agent_instruction_working_copies_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
+DO $$ BEGIN ALTER TABLE "agent_instruction_working_copies" ADD CONSTRAINT "agent_instruction_working_copies_company_id_agent_id_agents_company_id_id_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN NULL; END $$;--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "agent_instruction_revisions_history_idx" ON "agent_instruction_revisions" USING btree ("company_id","agent_id","entry_file","created_at","id");--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "agent_instruction_copies_pending_idx" ON "agent_instruction_working_copies" USING btree ("state","next_attempt_at");--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "agent_instruction_copies_agent_idx" ON "agent_instruction_working_copies" USING btree ("company_id","agent_id","created_at");
diff --git a/packages/db/src/migrations/meta/0282_snapshot.json b/packages/db/src/migrations/meta/0287_snapshot.json
similarity index 97%
rename from packages/db/src/migrations/meta/0282_snapshot.json
rename to packages/db/src/migrations/meta/0287_snapshot.json
index 5c16a75ee35..258c8e4a6a2 100644
--- a/packages/db/src/migrations/meta/0282_snapshot.json
+++ b/packages/db/src/migrations/meta/0287_snapshot.json
@@ -1,6 +1,6 @@
{
- "id": "23f8fed6-33cf-43ff-b711-8adb151a4bfc",
- "prevId": "8d202a5a-5d6b-44d9-9336-3bb39a8b99bc",
+ "id": "26beb23c-50c3-42f9-8a1c-814d0c8acfbe",
+ "prevId": "33120bf3-9cac-4909-ae94-d514ddc1e9e2",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -829,6 +829,479 @@
"checkConstraints": {},
"isRLSEnabled": false
},
+ "public.agent_instruction_heads": {
+ "name": "agent_instruction_heads",
+ "schema": "",
+ "columns": {
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entry_file": {
+ "name": "entry_file",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revision_id": {
+ "name": "revision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "agent_instruction_heads_company_id_agent_id_entry_file_revision_id_agent_instruction_revisions_company_id_agent_id_entry_file_id_fk": {
+ "name": "agent_instruction_heads_company_id_agent_id_entry_file_revision_id_agent_instruction_revisions_company_id_agent_id_entry_file_id_fk",
+ "tableFrom": "agent_instruction_heads",
+ "tableTo": "agent_instruction_revisions",
+ "columnsFrom": [
+ "company_id",
+ "agent_id",
+ "entry_file",
+ "revision_id"
+ ],
+ "columnsTo": [
+ "company_id",
+ "agent_id",
+ "entry_file",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "agent_instruction_heads_identity_uq": {
+ "name": "agent_instruction_heads_identity_uq",
+ "nullsNotDistinct": false,
+ "columns": [
+ "company_id",
+ "agent_id",
+ "entry_file"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_instruction_revisions": {
+ "name": "agent_instruction_revisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entry_file": {
+ "name": "entry_file",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_base64": {
+ "name": "content_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_hash": {
+ "name": "content_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "byte_length": {
+ "name": "byte_length",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_revision_id": {
+ "name": "parent_revision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "base_revision_id": {
+ "name": "base_revision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "restored_from_revision_id": {
+ "name": "restored_from_revision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_agent_id": {
+ "name": "actor_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "responsible_user_id": {
+ "name": "responsible_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_run_id": {
+ "name": "source_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "agent_instruction_revisions_history_idx": {
+ "name": "agent_instruction_revisions_history_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "entry_file",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_instruction_revisions_company_id_agent_id_agents_company_id_id_fk": {
+ "name": "agent_instruction_revisions_company_id_agent_id_agents_company_id_id_fk",
+ "tableFrom": "agent_instruction_revisions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "company_id",
+ "agent_id"
+ ],
+ "columnsTo": [
+ "company_id",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "agent_instruction_revisions_identity_uq": {
+ "name": "agent_instruction_revisions_identity_uq",
+ "nullsNotDistinct": false,
+ "columns": [
+ "company_id",
+ "agent_id",
+ "entry_file",
+ "id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_instruction_working_copies": {
+ "name": "agent_instruction_working_copies",
+ "schema": "",
+ "columns": {
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "responsible_user_id": {
+ "name": "responsible_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entry_file": {
+ "name": "entry_file",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "base_revision_id": {
+ "name": "base_revision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "base_hash": {
+ "name": "base_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "local_root": {
+ "name": "local_root",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "execution_root": {
+ "name": "execution_root",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'prepared'"
+ },
+ "candidate_base64": {
+ "name": "candidate_base64",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "candidate_hash": {
+ "name": "candidate_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "receipt": {
+ "name": "receipt",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "process_stopped_at": {
+ "name": "process_stopped_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "next_attempt_at": {
+ "name": "next_attempt_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "agent_instruction_copies_pending_idx": {
+ "name": "agent_instruction_copies_pending_idx",
+ "columns": [
+ {
+ "expression": "state",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "next_attempt_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "agent_instruction_copies_agent_idx": {
+ "name": "agent_instruction_copies_agent_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_instruction_working_copies_run_id_heartbeat_runs_id_fk": {
+ "name": "agent_instruction_working_copies_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "agent_instruction_working_copies",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "agent_instruction_working_copies_company_id_agent_id_agents_company_id_id_fk": {
+ "name": "agent_instruction_working_copies_company_id_agent_id_agents_company_id_id_fk",
+ "tableFrom": "agent_instruction_working_copies",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "company_id",
+ "agent_id"
+ ],
+ "columnsTo": [
+ "company_id",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
"public.agent_memberships": {
"name": "agent_memberships",
"schema": "",
@@ -2917,7 +3390,7 @@
},
"byte_size": {
"name": "byte_size",
- "type": "integer",
+ "type": "bigint",
"primaryKey": false,
"notNull": true
},
@@ -3302,6 +3775,13 @@
"primaryKey": false,
"notNull": false
},
+ "keyboard_shortcuts": {
+ "name": "keyboard_shortcuts",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
@@ -8109,6 +8589,519 @@
},
"isRLSEnabled": false
},
+ "public.chat_github_configurations": {
+ "name": "chat_github_configurations",
+ "schema": "",
+ "columns": {
+ "endpoint_id": {
+ "name": "endpoint_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "configuration": {
+ "name": "configuration",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_by_user_id": {
+ "name": "updated_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "chat_github_configurations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": {
+ "name": "chat_github_configurations_company_id_endpoint_id_chat_endpoints_company_id_id_fk",
+ "tableFrom": "chat_github_configurations",
+ "tableTo": "chat_endpoints",
+ "columnsFrom": [
+ "company_id",
+ "endpoint_id"
+ ],
+ "columnsTo": [
+ "company_id",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "chat_github_config_revision_check": {
+ "name": "chat_github_config_revision_check",
+ "value": "\"chat_github_configurations\".\"revision\" > 0"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.chat_github_registrations": {
+ "name": "chat_github_registrations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "endpoint_id": {
+ "name": "endpoint_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state_hash": {
+ "name": "state_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "trusted_origin": {
+ "name": "trusted_origin",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "consumed_at": {
+ "name": "consumed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "chat_github_registration_state_uq": {
+ "name": "chat_github_registration_state_uq",
+ "columns": [
+ {
+ "expression": "state_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "chat_github_registration_endpoint_idx": {
+ "name": "chat_github_registration_endpoint_idx",
+ "columns": [
+ {
+ "expression": "endpoint_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chat_github_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": {
+ "name": "chat_github_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk",
+ "tableFrom": "chat_github_registrations",
+ "tableTo": "chat_endpoints",
+ "columnsFrom": [
+ "company_id",
+ "endpoint_id"
+ ],
+ "columnsTo": [
+ "company_id",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "chat_github_registration_status_check": {
+ "name": "chat_github_registration_status_check",
+ "value": "\"chat_github_registrations\".\"status\" in ('pending', 'exchanging', 'completed', 'failed')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.chat_github_reviews": {
+ "name": "chat_github_reviews",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "endpoint_id": {
+ "name": "endpoint_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "repository_id": {
+ "name": "repository_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "repository": {
+ "name": "repository",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pull_number": {
+ "name": "pull_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "head_sha": {
+ "name": "head_sha",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "delivery_id": {
+ "name": "delivery_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "configuration_revision": {
+ "name": "configuration_revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "policy_snapshot": {
+ "name": "policy_snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event": {
+ "name": "event",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'queued'"
+ },
+ "assessment": {
+ "name": "assessment",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "conclusion": {
+ "name": "conclusion",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "check_id": {
+ "name": "check_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "check_url": {
+ "name": "check_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary_id": {
+ "name": "summary_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary_url": {
+ "name": "summary_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "publication_receipts": {
+ "name": "publication_receipts",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "chat_github_reviews_delivery_uq": {
+ "name": "chat_github_reviews_delivery_uq",
+ "columns": [
+ {
+ "expression": "endpoint_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "delivery_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "chat_github_reviews_pull_idx": {
+ "name": "chat_github_reviews_pull_idx",
+ "columns": [
+ {
+ "expression": "endpoint_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "repository_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "pull_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "chat_github_reviews_task_idx": {
+ "name": "chat_github_reviews_task_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chat_github_reviews_run_id_heartbeat_runs_id_fk": {
+ "name": "chat_github_reviews_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "chat_github_reviews",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "chat_github_reviews_company_id_endpoint_id_chat_endpoints_company_id_id_fk": {
+ "name": "chat_github_reviews_company_id_endpoint_id_chat_endpoints_company_id_id_fk",
+ "tableFrom": "chat_github_reviews",
+ "tableTo": "chat_endpoints",
+ "columnsFrom": [
+ "company_id",
+ "endpoint_id"
+ ],
+ "columnsTo": [
+ "company_id",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "chat_github_reviews_company_id_issue_id_issues_company_id_id_fk": {
+ "name": "chat_github_reviews_company_id_issue_id_issues_company_id_id_fk",
+ "tableFrom": "chat_github_reviews",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "company_id",
+ "issue_id"
+ ],
+ "columnsTo": [
+ "company_id",
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "chat_github_reviews_company_id_uq": {
+ "name": "chat_github_reviews_company_id_uq",
+ "nullsNotDistinct": false,
+ "columns": [
+ "company_id",
+ "id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "chat_github_review_pull_number_check": {
+ "name": "chat_github_review_pull_number_check",
+ "value": "\"chat_github_reviews\".\"pull_number\" > 0"
+ },
+ "chat_github_review_state_check": {
+ "name": "chat_github_review_state_check",
+ "value": "\"chat_github_reviews\".\"state\" in ('queued', 'running', 'completed', 'incomplete', 'error', 'superseded', 'manual_required')"
+ }
+ },
+ "isRLSEnabled": false
+ },
"public.chat_teams_file_transfers": {
"name": "chat_teams_file_transfers",
"schema": "",
@@ -38419,6 +39412,113 @@
"checkConstraints": {},
"isRLSEnabled": false
},
+ "public.runner_api_response_reservations": {
+ "name": "runner_api_response_reservations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "asset_id": {
+ "name": "asset_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reserved_bytes": {
+ "name": "reserved_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "runner_api_response_reservations_company_idx": {
+ "name": "runner_api_response_reservations_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "runner_api_response_reservations_company_id_companies_id_fk": {
+ "name": "runner_api_response_reservations_company_id_companies_id_fk",
+ "tableFrom": "runner_api_response_reservations",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "runner_api_response_reservations_run_id_heartbeat_runs_id_fk": {
+ "name": "runner_api_response_reservations_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "runner_api_response_reservations",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "runner_api_response_reservations_asset_id_assets_id_fk": {
+ "name": "runner_api_response_reservations_asset_id_assets_id_fk",
+ "tableFrom": "runner_api_response_reservations",
+ "tableTo": "assets",
+ "columnsFrom": [
+ "asset_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
"public.secret_access_events": {
"name": "secret_access_events",
"schema": "",
diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json
index 264cefe4465..f1937652643 100644
--- a/packages/db/src/migrations/meta/_journal.json
+++ b/packages/db/src/migrations/meta/_journal.json
@@ -1996,6 +1996,13 @@
"when": 1790607525758,
"tag": "0286_complete_alice",
"breakpoints": true
+ },
+ {
+ "idx": 287,
+ "version": "7",
+ "when": 1790611116286,
+ "tag": "0287_serious_tinkerer",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/src/schema/agent_instruction_revisions.ts b/packages/db/src/schema/agent_instruction_revisions.ts
new file mode 100644
index 00000000000..39db9af7237
--- /dev/null
+++ b/packages/db/src/schema/agent_instruction_revisions.ts
@@ -0,0 +1,119 @@
+import {
+ pgTable,
+ uuid,
+ text,
+ integer,
+ timestamp,
+ index,
+ unique,
+ foreignKey,
+ jsonb,
+} from "drizzle-orm/pg-core";
+import { agents } from "./agents.js";
+import { heartbeatRuns } from "./heartbeat_runs.js";
+
+/** Append-only content. Base64 preserves every UTF-8 byte, including NUL and BOM. */
+export const agentInstructionRevisions = pgTable(
+ "agent_instruction_revisions",
+ {
+ id: uuid("id").primaryKey().defaultRandom(),
+ companyId: uuid("company_id").notNull(),
+ agentId: uuid("agent_id").notNull(),
+ entryFile: text("entry_file").notNull(),
+ contentBase64: text("content_base64").notNull(),
+ contentHash: text("content_hash").notNull(),
+ byteLength: integer("byte_length").notNull(),
+ parentRevisionId: uuid("parent_revision_id"),
+ baseRevisionId: uuid("base_revision_id"),
+ restoredFromRevisionId: uuid("restored_from_revision_id"),
+ actorAgentId: uuid("actor_agent_id"),
+ actorUserId: text("actor_user_id"),
+ responsibleUserId: text("responsible_user_id"),
+ sourceRunId: uuid("source_run_id"),
+ source: text("source").notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ },
+ (t) => ({
+ owner: foreignKey({
+ columns: [t.companyId, t.agentId],
+ foreignColumns: [agents.companyId, agents.id],
+ }).onDelete("cascade"),
+ identity: unique("agent_instruction_revisions_identity_uq").on(
+ t.companyId,
+ t.agentId,
+ t.entryFile,
+ t.id,
+ ),
+ history: index("agent_instruction_revisions_history_idx").on(
+ t.companyId,
+ t.agentId,
+ t.entryFile,
+ t.createdAt,
+ t.id,
+ ),
+ }),
+);
+
+export const agentInstructionHeads = pgTable(
+ "agent_instruction_heads",
+ {
+ companyId: uuid("company_id").notNull(),
+ agentId: uuid("agent_id").notNull(),
+ entryFile: text("entry_file").notNull(),
+ revisionId: uuid("revision_id").notNull(),
+ updatedAt: timestamp("updated_at", { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ },
+ (t) => ({
+ identity: unique("agent_instruction_heads_identity_uq").on(
+ t.companyId,
+ t.agentId,
+ t.entryFile,
+ ),
+ revision: foreignKey({
+ columns: [t.companyId, t.agentId, t.entryFile, t.revisionId],
+ foreignColumns: [
+ agentInstructionRevisions.companyId,
+ agentInstructionRevisions.agentId,
+ agentInstructionRevisions.entryFile,
+ agentInstructionRevisions.id,
+ ],
+ }).onDelete("cascade"),
+ }),
+);
+
+/** A server-owned collection receipt, retained even when canonical CAS rejects an edit. */
+export const agentInstructionWorkingCopies = pgTable(
+ "agent_instruction_working_copies",
+ {
+ runId: uuid("run_id").primaryKey().references(() => heartbeatRuns.id, { onDelete: "cascade" }),
+ companyId: uuid("company_id").notNull(),
+ agentId: uuid("agent_id").notNull(),
+ responsibleUserId: text("responsible_user_id").notNull(),
+ entryFile: text("entry_file").notNull(),
+ baseRevisionId: uuid("base_revision_id"),
+ baseHash: text("base_hash").notNull(),
+ localRoot: text("local_root").notNull(),
+ executionRoot: text("execution_root").notNull(),
+ location: text("location").notNull(),
+ state: text("state").notNull().default("prepared"),
+ candidateBase64: text("candidate_base64"),
+ candidateHash: text("candidate_hash"),
+ errorCode: text("error_code"),
+ errorMessage: text("error_message"),
+ receipt: jsonb("receipt").$type>(),
+ processStoppedAt: timestamp("process_stopped_at", { withTimezone: true }),
+ attempts: integer("attempts").notNull().default(0),
+ nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }),
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
+ },
+ (t) => ({
+ owner: foreignKey({ columns: [t.companyId, t.agentId], foreignColumns: [agents.companyId, agents.id] }).onDelete("cascade"),
+ pending: index("agent_instruction_copies_pending_idx").on(t.state, t.nextAttemptAt),
+ history: index("agent_instruction_copies_agent_idx").on(t.companyId, t.agentId, t.createdAt),
+ }),
+);
diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts
index db17f2aa695..bd007401dbb 100644
--- a/packages/db/src/schema/index.ts
+++ b/packages/db/src/schema/index.ts
@@ -211,3 +211,5 @@ export { aiConnectionDefaults } from "./ai_connection_defaults.js";
export { aiProviderDefaults } from "./ai_provider_defaults.js";
export * from "./email.js";
export { announcementDismissals, announcementPublications } from "./announcement_dismissals.js";
+
+export { agentInstructionRevisions, agentInstructionHeads, agentInstructionWorkingCopies } from "./agent_instruction_revisions.js";
diff --git a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json
index 9ca2c4ba8d1..a42ac9b66bb 100644
--- a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json
+++ b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json
@@ -1 +1 @@
-[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a durable human question or approval card on the current Paperclip task bound to this run; Paperclip renders it and authenticates the response. Use questions with continuationPolicy='wake_assignee' when an answer is needed, including otherwise tool-free chat turns. Supply a stable idempotencyKey and reuse it on retries. For one question at a time, ask only the next unanswered question and wait for its real answer. Never infer answers, answer your own card, or treat clarification as approval. Preserve existing review gates. Call this tool before claiming a question was asked; if creation fails, report the failure. Do not fabricate answer links or Markdown buttons, post duplicate cards, or substitute call_api. Use payload.questions for choices and payload.questionSet for text fields; see the payload schema for formats.","inputSchema":{"additionalProperties":false,"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For interactionKind='questions', use version:1 and questions:[{id,prompt,selectionMode:'single'|'multi',required?,options:[{id,label,description?,freeText?}]}]. Choice questions need at least two distinct meaningful options. For an open-ended text answer, ALSO include questionSet:{schema:'paperclip.question_set.v1',questions:[{id,prompt,answerMode:'text',required?}]} with no options or customAnswer in its text questions. Keep matching IDs/prompts in both arrays; the required compatibility questions entry uses selectionMode:'single' and options:[{id:'describe',label:'Your answer',freeText:true}]. Without questionSet this incorrectly renders as a one-option choice. Never use a lone Other or describe option as the presentation. Option keys are id/label, not value. For confirmation, payload may be {}. Keep IDs stable across retries.","type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"hire_agent","requiredClaims":["delegation:agents:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a persistent native Runner teammate with an identity and persona. The teammate reports to the caller and inherits the caller's native runtime; provider, adapter, environment, and credential settings are selected by Paperclip and are never caller-supplied here. Reuse a suitable teammate from list_agents when possible.","inputSchema":{"additionalProperties":false,"properties":{"capabilities":{"maxLength":2000,"type":["string","null"]},"instructions":{"maxLength":20000,"type":["string","null"]},"name":{"maxLength":200,"minLength":1,"type":"string"},"role":{"enum":["ceo","cto","cmo","cfo","security","engineer","designer","pm","qa","devops","researcher","general"],"type":"string"},"title":{"maxLength":300,"type":["string","null"]}},"required":["name"],"type":"object"},"name":"hire_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"status":{"description":"Initial status. Use backlog to save work without executing it. Defaults to todo (blocked when dependencies are unresolved).","enum":["backlog","todo"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide an approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to an approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"},"responseText":{"additionalProperties":false,"description":"GET only: return a bounded UTF-8 text window inline, including JSON as text, without saving another artifact. Offsets and limits are bytes. Use the returned nextOffsetBytes to continue; null means complete. Prefer reading a saved artifact for a stable snapshot. New live responses have a 1 GiB capture limit and a 4 GiB per-run capture budget. Existing larger assets remain readable in bounded pages. If a live response returns an artifact, continue on its content operation for a stable snapshot.","properties":{"limitBytes":{"default":24576,"maximum":24576,"minimum":4,"type":"integer"},"offsetBytes":{"default":0,"maximum":9007199254740991,"minimum":0,"type":"integer"}},"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","enum":["folder","rocket","code","terminal","database","globe","package","boxes","box","layers","briefcase","compass","target","flame","zap","star","bug","wrench","hammer","lightbulb","sparkles","shield","lock","search","cog","brain","cpu","git-branch","file-code","puzzle","gem","atom","heart","mail","message-square","crown","radar","telescope","hexagon",null],"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Inspect available company projects before selecting a project for new work.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a reusable single-file skill in the company library. Supply a complete SKILL.md whose name and description match the inputs. This saves the skill and shows a card; it does not assign the skill to any agent. Reuse idempotencyKey on retries.","inputSchema":{"additionalProperties":false,"properties":{"description":{"maxLength":2000,"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"description":"Complete SKILL.md including name and description frontmatter and substantive instructions.","maxLength":200000,"minLength":1,"type":"string"},"name":{"description":"Lowercase skill name, matching SKILL.md frontmatter.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"},"slug":{"description":"Optional; must equal name.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"}},"required":["idempotencyKey","name","description","markdown"],"type":"object"},"name":"create_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"reassign_task","requiredClaims":["delegation:tasks:assign"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run’s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"New company agent ID from list_agents.","minLength":1,"type":"string"},"expectedAssigneeActorId":{"description":"Current assigneeAgentId from search_tasks; null means unassigned.","type":["string","null"]},"expectedStatusVersion":{"description":"Current statusVersion from search_tasks.","minimum":0,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Why the task should move and context for the new owner.","maxLength":20000,"minLength":1,"type":"string"},"taskId":{"description":"Existing task ID from search_tasks.","minLength":1,"type":"string"}},"required":["idempotencyKey","taskId","assigneeActorId","expectedAssigneeActorId","expectedStatusVersion","reason"],"type":"object"},"name":"reassign_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}}]
+[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a durable human question or approval card on the current Paperclip task bound to this run; Paperclip renders it and authenticates the response. Use questions with continuationPolicy='wake_assignee' when an answer is needed, including otherwise tool-free chat turns. Supply a stable idempotencyKey and reuse it on retries. For one question at a time, ask only the next unanswered question and wait for its real answer. Never infer answers, answer your own card, or treat clarification as approval. Preserve existing review gates. Call this tool before claiming a question was asked; if creation fails, report the failure. Do not fabricate answer links or Markdown buttons, post duplicate cards, or substitute call_api. Use payload.questions for choices and payload.questionSet for text fields; see the payload schema for formats.","inputSchema":{"additionalProperties":false,"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For interactionKind='questions', use version:1 and questions:[{id,prompt,selectionMode:'single'|'multi',required?,options:[{id,label,description?,freeText?}]}]. Choice questions need at least two distinct meaningful options. For an open-ended text answer, ALSO include questionSet:{schema:'paperclip.question_set.v1',questions:[{id,prompt,answerMode:'text',required?}]} with no options or customAnswer in its text questions. Keep matching IDs/prompts in both arrays; the required compatibility questions entry uses selectionMode:'single' and options:[{id:'describe',label:'Your answer',freeText:true}]. Without questionSet this incorrectly renders as a one-option choice. Never use a lone Other or describe option as the presentation. Option keys are id/label, not value. For confirmation, payload may be {}. Keep IDs stable across retries.","type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"hire_agent","requiredClaims":["delegation:agents:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a persistent native Runner teammate with an identity and persona. The teammate reports to the caller and inherits the caller's native runtime; provider, adapter, environment, and credential settings are selected by Paperclip and are never caller-supplied here. Reuse a suitable teammate from list_agents when possible.","inputSchema":{"additionalProperties":false,"properties":{"capabilities":{"maxLength":2000,"type":["string","null"]},"instructions":{"maxLength":20000,"type":["string","null"]},"name":{"maxLength":200,"minLength":1,"type":"string"},"role":{"enum":["ceo","cto","cmo","cfo","security","engineer","designer","pm","qa","devops","researcher","general"],"type":"string"},"title":{"maxLength":300,"type":["string","null"]}},"required":["name"],"type":"object"},"name":"hire_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution. Set status to backlog when the user wants to save or plan work without starting it; backlog tasks never wake an agent. Omitted status means todo, subject to blockers.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"status":{"description":"Initial status. Use backlog to save work without executing it. Defaults to todo (blocked when dependencies are unresolved).","enum":["backlog","todo"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide an approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to an approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.","inputSchema":{"additionalProperties":false,"properties":{"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision to inspect; also provide its entryFile.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":[],"type":"object"},"name":"read_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"update_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user’s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Revision read before editing; null only when no canonical entry exists. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":["string","null"]},"content":{"description":"Complete UTF-8 instruction content, at most 1 MiB; empty content is valid.","maxLength":1048576,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","content","baseRevisionId"],"type":"object"},"name":"update_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool. For large text responses, read the returned artifact with GET /api/assets/{assetId}/content and responseText; follow nextOffsetBytes until null.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"},"responseText":{"additionalProperties":false,"description":"GET only: return a bounded UTF-8 text window inline, including JSON as text, without saving another artifact. Offsets and limits are bytes. Use the returned nextOffsetBytes to continue; null means complete. Prefer reading a saved artifact for a stable snapshot. New live responses have a 1 GiB capture limit and a 4 GiB per-run capture budget. Existing larger assets remain readable in bounded pages. If a live response returns an artifact, continue on its content operation for a stable snapshot.","properties":{"limitBytes":{"default":24576,"maximum":24576,"minimum":4,"type":"integer"},"offsetBytes":{"default":0,"maximum":9007199254740991,"minimum":0,"type":"integer"}},"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_agent_instruction_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":2048,"minLength":1,"type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"limit":{"maximum":100,"minimum":1,"type":"integer"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile"],"type":"object"},"name":"get_agent_instruction_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"restore_agent_instructions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user’s current target edit permission. History remains intact; conflicts require an explicit resolution.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision read before restoring. Never replace a stale base silently.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"entryFile":{"description":"Configured relative entry filename returned by read_agent_instructions; retain it with the revision.","maxLength":4096,"minLength":1,"type":"string"},"revisionId":{"description":"Historical revision whose exact content should be restored.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"},"targetAgentId":{"description":"Same-company target agent. Omit to use the calling agent.","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","type":"string"}},"required":["entryFile","revisionId","baseRevisionId"],"type":"object"},"name":"restore_agent_instructions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","enum":["folder","rocket","code","terminal","database","globe","package","boxes","box","layers","briefcase","compass","target","flame","zap","star","bug","wrench","hammer","lightbulb","sparkles","shield","lock","search","cog","brain","cpu","git-branch","file-code","puzzle","gem","atom","heart","mail","message-square","crown","radar","telescope","hexagon",null],"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Inspect available company projects before selecting a project for new work.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a reusable single-file skill in the company library. Supply a complete SKILL.md whose name and description match the inputs. This saves the skill and shows a card; it does not assign the skill to any agent. Reuse idempotencyKey on retries.","inputSchema":{"additionalProperties":false,"properties":{"description":{"maxLength":2000,"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"description":"Complete SKILL.md including name and description frontmatter and substantive instructions.","maxLength":200000,"minLength":1,"type":"string"},"name":{"description":"Lowercase skill name, matching SKILL.md frontmatter.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"},"slug":{"description":"Optional; must equal name.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"}},"required":["idempotencyKey","name","description","markdown"],"type":"object"},"name":"create_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"reassign_task","requiredClaims":["delegation:tasks:assign"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run’s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"New company agent ID from list_agents.","minLength":1,"type":"string"},"expectedAssigneeActorId":{"description":"Current assigneeAgentId from search_tasks; null means unassigned.","type":["string","null"]},"expectedStatusVersion":{"description":"Current statusVersion from search_tasks.","minimum":0,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Why the task should move and context for the new owner.","maxLength":20000,"minLength":1,"type":"string"},"taskId":{"description":"Existing task ID from search_tasks.","minLength":1,"type":"string"}},"required":["idempotencyKey","taskId","assigneeActorId","expectedAssigneeActorId","expectedStatusVersion","reason"],"type":"object"},"name":"reassign_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}}]
diff --git a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json
index 74e4b4be817..e535bd9f8f6 100644
--- a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json
+++ b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json
@@ -24,7 +24,7 @@
"prpVersion": 1,
"nativeExecutionVersion": 1,
"catalogVersion": 1,
- "catalogSha256": "sha256:a19534304480dfd3d357cc902e785af05158ce1496f4d324184112732ff94c9c",
+ "catalogSha256": "sha256:61c6ba99926c14285ca6a89a05fe316848b9e5bc38840d43dd19b0990dc482d0",
"driverContractVersion": 1,
"driverKind": "paperclip-deterministic",
"driverVersion": "1.0.0"
diff --git a/packages/paperclip-runner/protocol/manifest.json b/packages/paperclip-runner/protocol/manifest.json
index bd8c300f1d9..ba8d17e452b 100644
--- a/packages/paperclip-runner/protocol/manifest.json
+++ b/packages/paperclip-runner/protocol/manifest.json
@@ -160,7 +160,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
- "sha256": "c6367c7c7e3dd01fed341b7a83b0008c9d5d1efdfa85bc70e1013bc304a008c7",
+ "sha256": "c9c56cc11b420302be2d0044aeee30302e43da74c076e13a31addfbcaacd0458",
"expectation": "accept",
"compatibilityCase": "canonical"
},
diff --git a/packages/paperclip-runner/spec/capability/protocol-coverage.json b/packages/paperclip-runner/spec/capability/protocol-coverage.json
index 8c72856beb7..2371a89f6df 100644
--- a/packages/paperclip-runner/spec/capability/protocol-coverage.json
+++ b/packages/paperclip-runner/spec/capability/protocol-coverage.json
@@ -7,10 +7,86 @@
"src/scenarios/scenario-plan.ts"
],
"counts": {
- "actions": 48,
+ "actions": 52,
"legacyRequirements": 106
},
"actions": [
+ {
+ "id": "read_agent_instructions",
+ "ownership": "always_agent_tool",
+ "surfaces": [
+ "live"
+ ],
+ "legacyAliases": [],
+ "contractCase": "protocol-action:read_agent_instructions",
+ "contractOwner": "src/catalog/protocol-action-contracts.test.ts::read_agent_instructions has a schema-valid canonical example and every declared projection",
+ "legacyBehavioralCases": [],
+ "deterministicCases": [
+ "protocol-action:read_agent_instructions"
+ ],
+ "legacyRequirementCases": [],
+ "deterministicOwners": [
+ "src/catalog/protocol-action-contracts.test.ts::read_agent_instructions has a schema-valid canonical example and every declared projection",
+ "src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
+ ]
+ },
+ {
+ "id": "update_agent_instructions",
+ "ownership": "always_agent_tool",
+ "surfaces": [
+ "live"
+ ],
+ "legacyAliases": [],
+ "contractCase": "protocol-action:update_agent_instructions",
+ "contractOwner": "src/catalog/protocol-action-contracts.test.ts::update_agent_instructions has a schema-valid canonical example and every declared projection",
+ "legacyBehavioralCases": [],
+ "deterministicCases": [
+ "protocol-action:update_agent_instructions"
+ ],
+ "legacyRequirementCases": [],
+ "deterministicOwners": [
+ "src/catalog/protocol-action-contracts.test.ts::update_agent_instructions has a schema-valid canonical example and every declared projection",
+ "src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
+ ]
+ },
+ {
+ "id": "get_agent_instruction_history",
+ "ownership": "always_agent_tool",
+ "surfaces": [
+ "live"
+ ],
+ "legacyAliases": [],
+ "contractCase": "protocol-action:get_agent_instruction_history",
+ "contractOwner": "src/catalog/protocol-action-contracts.test.ts::get_agent_instruction_history has a schema-valid canonical example and every declared projection",
+ "legacyBehavioralCases": [],
+ "deterministicCases": [
+ "protocol-action:get_agent_instruction_history"
+ ],
+ "legacyRequirementCases": [],
+ "deterministicOwners": [
+ "src/catalog/protocol-action-contracts.test.ts::get_agent_instruction_history has a schema-valid canonical example and every declared projection",
+ "src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
+ ]
+ },
+ {
+ "id": "restore_agent_instructions",
+ "ownership": "always_agent_tool",
+ "surfaces": [
+ "live"
+ ],
+ "legacyAliases": [],
+ "contractCase": "protocol-action:restore_agent_instructions",
+ "contractOwner": "src/catalog/protocol-action-contracts.test.ts::restore_agent_instructions has a schema-valid canonical example and every declared projection",
+ "legacyBehavioralCases": [],
+ "deterministicCases": [
+ "protocol-action:restore_agent_instructions"
+ ],
+ "legacyRequirementCases": [],
+ "deterministicOwners": [
+ "src/catalog/protocol-action-contracts.test.ts::restore_agent_instructions has a schema-valid canonical example and every declared projection",
+ "src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
+ ]
+ },
{
"id": "create_skill",
"ownership": "optional_agent_tool",
diff --git a/packages/paperclip-runner/spec/operation-groups/source.json b/packages/paperclip-runner/spec/operation-groups/source.json
index 2d1cecf0f87..6b24551b0e4 100644
--- a/packages/paperclip-runner/spec/operation-groups/source.json
+++ b/packages/paperclip-runner/spec/operation-groups/source.json
@@ -263,13 +263,13 @@
"id": "rf",
"legacyGroup": 13,
"name": "Reference files",
- "owner": "optional domain tools + test-only escape hatch",
- "operationIds": ["list_cases", "upsert_case", "list_routines", "manage_routine", "create_skill", "list_company_skills", "sync_company_skills", "list_secret_metadata", "read_secret_value", "export_company", "administer_company", "generic_api_request", "search_api", "call_api", "create_project"],
+ "owner": "always instruction tools + optional domain tools + test-only escape hatch",
+ "operationIds": ["list_cases", "upsert_case", "list_routines", "manage_routine", "create_skill", "list_company_skills", "sync_company_skills", "list_secret_metadata", "read_secret_value", "export_company", "administer_company", "generic_api_request", "search_api", "call_api", "create_project", "read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions"],
"controlPlaneOperationIds": ["append_audit_record"],
- "realSurface": "project, case, routine, company-skill, secret, portability, and administration services",
+ "realSurface": "agent instruction revisions, project, case, routine, company-skill, secret, portability, and administration services",
"mockStateDomains": ["company", "cases", "routines", "skills", "secrets", "audit", "fault"],
"prpEvidence": "bounded domain projections, redacted broker receipts, company diffs, and audit references",
- "gap": "Project and skill creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage."
+ "gap": "Instruction read/update/history/restore require the live canonical revision service, current responsible-user permissions, and pinned base revisions for writes. Their service, authority, and product E2E tests are separate from the legacy mock scenarios. Project and skill creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage."
},
{
"id": "mh",
diff --git a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md
index b6bfa691674..afadc264d7d 100644
--- a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md
+++ b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md
@@ -6,7 +6,7 @@ Status: canonical explanatory contract for the Paperclip runner V1 surface.
This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.
-The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **48 reconciled semantic operations** (14 always, 34 optional), and **106 scenarios in 16 behavior groups**.
+The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **52 reconciled semantic operations** (18 always, 34 optional), and **106 scenarios in 16 behavior groups**.
## Axis 1: PRP v1 event and command families
@@ -83,9 +83,9 @@ Placement has exactly three outcomes:
| `schedule_blocker_wake` | Dependency-resolution wake scheduling. |
| `reconcile_run` | Work assessment, terminal status arbitration, and recovery reconciliation. |
-### Always-agent operations (14)
+### Always-agent operations (18)
-`answer_status_question`, `block_task`, `finish_task`, `get_task_context`, `get_task_history`, `inspect_operation_result`, `list_document_revisions`, `list_documents`, `read_document`, `register_deliverable`, `report_progress`, `request_human_input`, `request_review`, `write_document`.
+`answer_status_question`, `block_task`, `finish_task`, `get_agent_instruction_history`, `get_task_context`, `get_task_history`, `inspect_operation_result`, `list_document_revisions`, `list_documents`, `read_agent_instructions`, `read_document`, `register_deliverable`, `report_progress`, `request_human_input`, `request_review`, `restore_agent_instructions`, `update_agent_instructions`, `write_document`.
### Optional operations (34) and grant groups (13)
@@ -127,6 +127,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `finish_task` | `always_agent_tool` | none | `standard` `skill_test` | `task_write` | `required` | no | `semantic_command:finish_task` | `scenario` + `live` `live_codex` | `unbound` semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events catalog PRP status: `audit_pending` |
| `generic_api_request` | `optional_agent_tool` | `test:generic_api_request` | `skill_test` | `test_escape_hatch` | `required` | yes | `mock_extension:test.generic_api` | `scenario` + `live` `test_only` | `unbound` test-only; excluded from product PRP evidence catalog PRP status: `audit_pending` |
| `get_agent` | `optional_agent_tool` | `discovery:agents:read` | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | inline/no mapping | `live` `live_codex` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
+| `get_agent_instruction_history` | `always_agent_tool` | none | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | inline/no mapping | `live` `live_codex` | `agentInstructionRevisionService` tool-result item event plus canonical revision receipt for writes; no scenario mock coverage catalog PRP status: `bound` |
| `get_approval` | `optional_agent_tool` | `governance:approvals:read` | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | inline/no mapping | `live` `live_codex` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
| `get_approval_context` | `optional_agent_tool` | `governance:approvals:read` | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | inline/no mapping | `live` `live_codex` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
| `get_task_context` | `always_agent_tool` | none | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `context_read:active_task` | `scenario` + `live` `live_codex` | `PaperclipRunnerToolAuthority active issue/run + accepted plan revision` bound company/assignment query plus exact accepted document revision projection catalog PRP status: `bound` |
@@ -146,6 +147,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `list_routines` | `optional_agent_tool` | `routines:read` | `standard` `skill_test` | `read` | `none` | no | `mock_extension:routines.list` | `scenario` `scenario_mock` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
| `list_secret_metadata` | `optional_agent_tool` | `secrets:metadata:read` | `standard` `skill_test` | `read` | `none` | no | `mock_extension:secrets.metadata` | `scenario` `scenario_mock` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
| `manage_routine` | `optional_agent_tool` | `routines:write` | `standard` `skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario` `scenario_mock` | `unbound` company admin/portability item event plus audit record catalog PRP status: `audit_pending` |
+| `read_agent_instructions` | `always_agent_tool` | none | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | inline/no mapping | `live` `live_codex` | `agentInstructionRevisionService` tool-result item event plus canonical revision receipt for writes; no scenario mock coverage catalog PRP status: `bound` |
| `read_document` | `always_agent_tool` | none | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `snapshot_read:active_task_document` | `scenario` + `live` `live_codex` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
| `read_secret_value` | `optional_agent_tool` | `secrets:values:read` | `standard` `skill_test` | `secret_read` | `none` | yes | `mock_extension:secrets.value` | `scenario` `scenario_mock` | `unbound` redacted tool-result item event; secret value never reaches the wire catalog PRP status: `audit_pending` |
| `reassign_task` | `optional_agent_tool` | `delegation:tasks:assign` | `standard` | `company_write` | `required` | no | `semantic_command:reassign_task` | `scenario` + `live` `live_codex` | `PaperclipRunnerToolAuthority.reassign_task` semantic-operation item event plus company-entity state diff and audit record catalog PRP status: `bound` |
@@ -154,11 +156,13 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `request_approval` | `optional_agent_tool` | `governance:approvals:request` | `standard` `skill_test` | `governance` | `required` | no | `semantic_command:request_approval` | `scenario` + `live` `live_codex` | `unbound` approval lifecycle plus governed-wait continuation and audit events catalog PRP status: `audit_pending` |
| `request_human_input` | `always_agent_tool` | none | `standard` `planning` `ask` `skill_test` | `task_write` | `required` | no | `semantic_command:request_human_input` | `scenario` + `live` `live_codex` | `unbound` semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events catalog PRP status: `audit_pending` |
| `request_review` | `always_agent_tool` | none | `standard` `skill_test` | `task_write` | `required` | no | `semantic_command:request_review` | `scenario` + `live` `live_codex` | `unbound` semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events catalog PRP status: `audit_pending` |
+| `restore_agent_instructions` | `always_agent_tool` | none | `standard` `planning` | `company_write` | `recommended` | no | inline/no mapping | `live` `live_codex` | `agentInstructionRevisionService` tool-result item event plus canonical revision receipt for writes; no scenario mock coverage catalog PRP status: `bound` |
| `schedule_wake` | `optional_agent_tool` | `control_plane:wakes` | `standard` `skill_test` | `task_write` | `required` | no | inline/no mapping | `live` `live_codex` | `unbound` semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events catalog PRP status: `audit_pending` |
| `search_api` | `optional_agent_tool` | `api:discover` | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | inline/no mapping | `live` `live_codex` | `PaperclipRunnerToolAuthority` Authenticated PRP tool input/result and existing HTTP route authorization/activity records. catalog PRP status: `bound` |
| `search_tasks` | `optional_agent_tool` | `discovery:tasks:read` | `standard` `ask` `planning` `skill_test` | `read` | `none` | no | `snapshot_read:company_tasks` | `scenario` + `live` `live_codex` | `unbound` read projection surfaced via a tool-result item event; no control-plane state diff catalog PRP status: `audit_pending` |
| `set_dependencies` | `optional_agent_tool` | `dependencies:write` | `standard` `skill_test` | `company_write` | `required` | no | `semantic_command:set_dependencies` | `scenario` + `live` `live_codex` | `issues.update.blockedByIssueIds` semantic-operation item event plus company-entity state diff and audit record catalog PRP status: `bound` |
| `sync_company_skills` | `optional_agent_tool` | `company_skills:write` | `standard` `skill_test` | `admin` | `required` | no | `mock_extension:company_skills.sync` | `scenario` `scenario_mock` | `unbound` company admin/portability item event plus audit record catalog PRP status: `audit_pending` |
+| `update_agent_instructions` | `always_agent_tool` | none | `standard` `planning` | `company_write` | `recommended` | no | inline/no mapping | `live` `live_codex` | `agentInstructionRevisionService` tool-result item event plus canonical revision receipt for writes; no scenario mock coverage catalog PRP status: `bound` |
| `upsert_case` | `optional_agent_tool` | `cases:write` | `standard` `skill_test` | `company_write` | `required` | no | `mock_extension:cases.upsert` | `scenario` `scenario_mock` | `unbound` semantic-operation item event plus company-entity state diff and audit record catalog PRP status: `audit_pending` |
| `write_document` | `always_agent_tool` | none | `standard` `planning` `skill_test` | `task_write` | `required` | no | `semantic_command:write_document` | `scenario` + `live` `live_codex` | `unbound` semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events catalog PRP status: `audit_pending` |
@@ -182,7 +186,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t
| [`ap` — Approvals](#behavior-group-ap-approvals) | optional governance tools + governed approver | `list_approvals` `get_approval` `get_approval_context` `request_approval` `decide_approval` `comment_on_approval` | `route_wake` `append_audit_record` | company approval, decision, issue-link, comment, and governed-action services | `company` `task` `approvals` `actor` `wake` `audit` `idempotency` | 6 | governed semantic receipts, audit references, and attention/continuation linkage | Production binding and additive governed-action receipts are unbound; board-only authority stays outside grants. |
| [`ar` — Artifacts](#behavior-group-ar-artifacts) | always tools + artifact/work-product services | `register_deliverable` | `append_audit_record` | attachment upload and issue work-product routes | `task` `artifacts` `workProducts` `workspace` `audit` `idempotency` | 4 | artifact/work-product reference and durable inspectability receipt; never binary bytes | Production upload/register composite and additive durable-reference receipt are unbound. |
| [`er` — Errors and critical rules](#behavior-group-er-errors-and-critical-rules) | runner/control plane + optional workspace/wake tools | `get_workspace_runtime` `control_workspace_service` `schedule_wake` `inspect_operation_result` | `release_task` `enforce_budget` `persist_run` `replay_run` `reconcile_run` | workspace runtime, monitor/recovery, budget, run persistence/replay, release, and terminal services | `workspace` `budget` `run` `wake` `audit` `idempotency` `fault` | 9 | runtime/workspace/attention/run lifecycle, typed denials, replay facts, and terminal causality | Budget stop reasons and semantic denial/conflict receipts require additive v1 envelopes; inspect_operation_result remains scenario-only. |
-| [`rf` — Reference files](#behavior-group-rf-reference-files) | optional domain tools + test-only escape hatch | `list_cases` `upsert_case` `list_routines` `manage_routine` `create_skill` `list_company_skills` `sync_company_skills` `list_secret_metadata` `read_secret_value` `export_company` `administer_company` `generic_api_request` `search_api` `call_api` `create_project` | `append_audit_record` | project, case, routine, company-skill, secret, portability, and administration services | `company` `cases` `routines` `skills` `secrets` `audit` `fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | Project and skill creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage. |
+| [`rf` — Reference files](#behavior-group-rf-reference-files) | always instruction tools + optional domain tools + test-only escape hatch | `list_cases` `upsert_case` `list_routines` `manage_routine` `create_skill` `list_company_skills` `sync_company_skills` `list_secret_metadata` `read_secret_value` `export_company` `administer_company` `generic_api_request` `search_api` `call_api` `create_project` `read_agent_instructions` `update_agent_instructions` `get_agent_instruction_history` `restore_agent_instructions` | `append_audit_record` | agent instruction revisions, project, case, routine, company-skill, secret, portability, and administration services | `company` `cases` `routines` `skills` `secrets` `audit` `fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | Instruction read/update/history/restore require the live canonical revision service, current responsible-user permissions, and pinned base revisions for writes. Their service, authority, and product E2E tests are separate from the legacy mock scenarios. Project and skill creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage. |
| [`mh` — Multi-hop](#behavior-group-mh-multi-hop) | composed semantic operations + control-plane continuation | `create_task` `set_dependencies` `request_human_input` `request_approval` `register_deliverable` | `route_wake` `reconcile_run` | delegation, dependency, interaction, approval, artifact, and terminal orchestration services | `task` `blockers` `interactions` `approvals` `artifacts` `wake` `run` `audit` | 4 | correlated operation receipts, state diffs, attention hops, work assessment, status decision, and terminal outcome | No generic transaction tool is allowed; shared mock/real conformance must prove each composed effect. |
| [`rs` — Restraint and no-call](#behavior-group-rs-restraint-and-no-call) | policy/exposure layer | `answer_status_question` `read_secret_value` `generic_api_request` | `enforce_budget` | task-mode, secret-broker, test-scope, pause, and budget policy checks | `actor` `task` `budget` `secrets` `audit` `fault` | 3 | absence of forbidden effects plus typed policy denial/redaction receipts when a call is attempted | Typed redaction/authorization receipts need additive v1 evidence; generic_api_request is never a product fallback. |
| [`wk` — Wake situations](#behavior-group-wk-wake-situations) | control plane + always context/history tools | `get_task_context` `get_task_history` `schedule_wake` | `select_work` `route_wake` | wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services | `wake` `task` `comments` `interactions` `approvals` `blockers` `run` | 8 | attention request routing/resolution plus resumed session/run causality | Production scheduling binding is unbound; control-plane routing remains non-callable. |
@@ -459,10 +463,10 @@ Current responsibility-based paths are normative. Numbered `phase-*` or mileston
### Catalog split and deliberate replacement
- Scenario/eval catalog: **39** operations.
-- Live dispatcher catalog: **36** operations.
-- Shared: **27**; union/canonical authority: **48**.
+- Live dispatcher catalog: **40** operations.
+- Shared: **27**; union/canonical authority: **52**.
- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
-- Live-only: `call_api`, `create_project`, `get_agent`, `get_approval`, `get_approval_context`, `hire_agent`, `list_project_repositories`, `schedule_wake`, `search_api`.
+- Live-only: `call_api`, `create_project`, `get_agent`, `get_agent_instruction_history`, `get_approval`, `get_approval_context`, `hire_agent`, `list_project_repositories`, `read_agent_instructions`, `restore_agent_instructions`, `schedule_wake`, `search_api`, `update_agent_instructions`.
- The generated provider contract contains exactly the live catalog; the canonical union remains the migration authority until all scenario-only operations are either implemented, deferred, or removed by an explicit reconciliation decision.
- `generic_api_request` stays exported only for controlled tests and cannot be cited as real-surface, mock-parity, or PRP product coverage.
diff --git a/packages/paperclip-runner/src/backends/codex-native-backend.ts b/packages/paperclip-runner/src/backends/codex-native-backend.ts
index d334079b787..1cdd0bb6af5 100644
--- a/packages/paperclip-runner/src/backends/codex-native-backend.ts
+++ b/packages/paperclip-runner/src/backends/codex-native-backend.ts
@@ -156,6 +156,7 @@ function createTransportBackedNativeSessionBackend(
? (input.provider.approvalPolicy ?? "never")
: "never",
baseInstructions: nativeSystemInstructions(input),
+ instructionWorkingCopyRoot: "runtimeContext" in input ? input.runtimeContext.instructions.workingCopy?.rootPath : undefined,
includeSkillInstructions: isCodex && "runtimeContext" in input,
skillInputs: isCodex
? nativeTaskSkillInputs(
diff --git a/packages/paperclip-runner/src/backends/runtime-context.ts b/packages/paperclip-runner/src/backends/runtime-context.ts
index 203e42552a4..05f7d177c3c 100644
--- a/packages/paperclip-runner/src/backends/runtime-context.ts
+++ b/packages/paperclip-runner/src/backends/runtime-context.ts
@@ -104,6 +104,11 @@ export function nativeTaskConstraints(input: NativeExecutionInput): string[] {
}
return [
"Use only the assigned skills and provider-native tools.",
+ ...(input.runtimeContext.instructions.workingCopy ? [
+ input.runtimeContext.instructions.workingCopy.kind === "agent_files"
+ ? `For this turn, AGENT_HOME is ${input.runtimeContext.instructions.workingCopy.rootPath}. This replaces any prior turn's agent directory path. It contains your instructions and persistent personal files, separate from the task working directory. Changes save after the provider stops; check the save receipt.`
+ : `For this turn, the editable agent instruction file is ${input.runtimeContext.instructions.workingCopy.rootPath}/${input.runtimeContext.instructions.workingCopy.entryPath}. This replaces any private working-copy path from a previous turn. Ordinary edits save after the provider stops and only with a durable revision receipt. Use the agent instruction tools for immediate saves. Shared instruction assets and repository instructions are not collected.`,
+ ] : []),
"Use Paperclip semantic tools for coordination and finalization.",
"Save requested plans and Paperclip documents directly with write_document. A saved Paperclip document is already a durable deliverable. Do not create a local file, compute file hashes, or call register_deliverable for it unless the user also requests a downloadable file. Cite the saved document in your completion evidence and final response.",
"When the requested result is a file, use register_deliverable before paperclip_finish. Compute its exact byte size and SHA-256, register the workspace-relative file, cite deliverable: from the receipt as completion evidence, and include /api/attachments//content as the download link in your answer. A bare workspace filename is not a delivered result. For repository edits, cite an accessible PR or registered work product. Preserve existing work; do not upload unrelated files. If file publication fails, fix it or report the concrete blocker instead of claiming the file is delivered.",
diff --git a/packages/paperclip-runner/src/catalog/reconciliation.test.ts b/packages/paperclip-runner/src/catalog/reconciliation.test.ts
index 9ab2cdf7c8c..0f69be379c1 100644
--- a/packages/paperclip-runner/src/catalog/reconciliation.test.ts
+++ b/packages/paperclip-runner/src/catalog/reconciliation.test.ts
@@ -20,21 +20,25 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("pins the reconciled op-set relationship between the two catalogs", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.scenarioCount).toBe(39);
- expect(summary.liveCount).toBe(36);
+ expect(summary.liveCount).toBe(40);
expect(summary.sharedCount).toBe(27);
- expect(summary.unionCount).toBe(48);
+ expect(summary.unionCount).toBe(52);
// Any operation added to or removed from either catalog without a
// reconciliation decision changes these exact sets and fails the gate.
expect(summary.liveOnly).toEqual([
"call_api",
"create_project",
"get_agent",
+ "get_agent_instruction_history",
"get_approval",
"get_approval_context",
"hire_agent",
"list_project_repositories",
+ "read_agent_instructions",
+ "restore_agent_instructions",
"schedule_wake",
"search_api",
+ "update_agent_instructions",
]);
expect(summary.scenarioOnly).toEqual([
"administer_company",
@@ -53,7 +57,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
});
it("is the single source both catalogs derive their operation set from", () => {
- expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(48);
+ expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(52);
const canonicalIds = new Set(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId));
// Neither catalog may contain an operation absent from the canonical source.
for (const tool of SCENARIO_CATALOG) expect(canonicalIds.has(tool.operationId)).toBe(true);
@@ -87,7 +91,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
});
it("names placement, claims, task modes, side-effect class, idempotency, redaction, mock mapping, real binding status, and PRP evidence for every operation", () => {
- expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(48);
+ expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(52);
for (const operation of CAPABILITY_CANONICAL_CATALOG) {
expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/);
expect(Array.isArray(operation.requiredClaims)).toBe(true);
@@ -113,7 +117,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("classifies real binding status so generic_api_request is never product coverage", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.byRealBindingStatus).toEqual({
- live_codex: 35,
+ live_codex: 39,
scenario_mock: 12,
test_only: 1,
});
diff --git a/packages/paperclip-runner/src/contracts/runtime-context.ts b/packages/paperclip-runner/src/contracts/runtime-context.ts
index 948cdbc62fa..bbf7e89ab5e 100644
--- a/packages/paperclip-runner/src/contracts/runtime-context.ts
+++ b/packages/paperclip-runner/src/contracts/runtime-context.ts
@@ -15,7 +15,12 @@ export interface NativeRuntimeAssetReference {
export interface NativeRuntimeContextSnapshot {
prompt: { revision: typeof PAPERCLIP_EXECUTION_PROMPT_REVISION; text: typeof PAPERCLIP_EXECUTION_PROMPT; digest: string };
- instructions: { entryPath: string; bundle: NativeRuntimeAssetReference };
+ instructions: {
+ entryPath: string;
+ bundle: NativeRuntimeAssetReference;
+ /** Server-registered writable copy; excluded from the pinned prompt digest. */
+ workingCopy?: { rootPath: string; entryPath: string; kind?: "agent_files" };
+ };
skills: Array<{ key: string; runtimeName: string; versionId: string | null; bundle: NativeRuntimeAssetReference }>;
mcp: { assignmentSetId: string; digest: string; bindingId: string | null };
aggregateDigest: string;
@@ -103,7 +108,10 @@ export function parseNativeRuntimeContext(value: unknown): NativeRuntimeContextS
throw new NativeRuntimeContextError("input.runtimeContext.prompt.digest does not match prompt text");
}
const instructions = object(context.instructions, "input.runtimeContext.instructions");
- exact(instructions, ["entryPath", "bundle"], "input.runtimeContext.instructions");
+ exact(instructions, ["entryPath", "bundle", "workingCopy"], "input.runtimeContext.instructions");
+ const workingCopy = instructions.workingCopy === undefined ? undefined : object(instructions.workingCopy, "input.runtimeContext.instructions.workingCopy");
+ if (workingCopy) exact(workingCopy, ["rootPath", "entryPath", "kind"], "input.runtimeContext.instructions.workingCopy");
+ if (workingCopy?.kind !== undefined && workingCopy.kind !== "agent_files") throw new NativeRuntimeContextError("Unknown agent file contract");
if (!Array.isArray(context.skills)) throw new NativeRuntimeContextError("input.runtimeContext.skills must be an array");
const skills = context.skills.map((value, index) => {
const skill = object(value, `input.runtimeContext.skills[${index}]`);
@@ -122,7 +130,15 @@ export function parseNativeRuntimeContext(value: unknown): NativeRuntimeContextS
exact(mcp, ["assignmentSetId", "digest", "bindingId"], "input.runtimeContext.mcp");
const parsed = {
prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
- instructions: { entryPath: safeRelativePath(instructions.entryPath, "input.runtimeContext.instructions.entryPath"), bundle: parseAsset(instructions.bundle, "input.runtimeContext.instructions.bundle") },
+ instructions: {
+ entryPath: safeRelativePath(instructions.entryPath, "input.runtimeContext.instructions.entryPath"),
+ bundle: parseAsset(instructions.bundle, "input.runtimeContext.instructions.bundle"),
+ ...(workingCopy ? { workingCopy: {
+ ...(workingCopy.kind === "agent_files" ? { kind: "agent_files" as const } : {}),
+ rootPath: text(workingCopy.rootPath, "input.runtimeContext.instructions.workingCopy.rootPath"),
+ entryPath: safeRelativePath(workingCopy.entryPath, "input.runtimeContext.instructions.workingCopy.entryPath"),
+ } } : {}),
+ },
skills,
mcp: {
assignmentSetId: text(mcp.assignmentSetId, "input.runtimeContext.mcp.assignmentSetId"),
@@ -141,6 +157,12 @@ export function composeNativeSystemInstructions(context: NativeRuntimeContextSna
return [
context.prompt.text,
entryContent.trim(),
+ context.instructions.workingCopy?.kind === "agent_files"
+ ? `Your persistent agent directory (AGENT_HOME) is ${context.instructions.workingCopy.rootPath}. Your instruction entry is ${context.instructions.workingCopy.entryPath}, relative to that directory. All supported files and subfolders there are restored across tasks and sessions, and collected after this provider stops. Write task deliverables in the task working directory. Only changed or deleted files synchronize; the last sync wins for the same file. Temporary copies are cleaned up without retaining file history. Check the save receipt before claiming persistence.`
+ : context.instructions.workingCopy
+ ? `Your editable agent instruction file is ${context.instructions.workingCopy.rootPath}/${context.instructions.workingCopy.entryPath}. Edit this registered private copy normally. After this run stops, Paperclip saves changed content as a persistent revision if your responsible user still has permission and the baseline has not changed. Check the run's instruction-save receipt before claiming persistence. Conflicts are preserved for explicit resolution. Repository instruction files, skills, and this run's loaded prompt are separate and are not collected.`
+ : null,
+ // Keep this canonical suffix intact for provider-specific asset remapping.
`Read-only instruction sibling root: ${context.instructions.bundle.rootPath}`,
].filter(Boolean).join("\n\n");
}
diff --git a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts
index 172aec56573..4dedcb67261 100644
--- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts
+++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts
@@ -194,6 +194,7 @@ class BoundedLineDecoder {
}
const SAFE_ENVIRONMENT_KEYS = [
+ "AGENT_HOME",
"ALL_PROXY",
"CODEX_HOME",
"HOME",
diff --git a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts
index 40ef0df0268..0fba00ebad7 100644
--- a/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts
+++ b/packages/paperclip-runner/src/drivers/codex/codex-app-server-driver-impl.ts
@@ -660,7 +660,7 @@ export class CodexAppServerDriver implements HarnessDriver {
this.#options.transportFactory?.(context) ??
new ProcessCodexAppServerTransport({
workingDirectory,
- args: createIsolatedCodexAppServerArgs(this.#options.environment, codexExecutableReadOnlyRoots(this.#options.environment ?? process.env)),
+ args: createIsolatedCodexAppServerArgs(this.#options.environment, codexExecutableReadOnlyRoots(this.#options.environment ?? process.env), this.#options.instructionWorkingCopyRoot),
environment: createSanitizedCodexEnvironment(this.#options.environment),
onDiagnostic: this.#options.onDiagnostic,
processGroup: true,
diff --git a/packages/paperclip-runner/src/drivers/codex/codex-driver-types.ts b/packages/paperclip-runner/src/drivers/codex/codex-driver-types.ts
index 0414b2ccf7c..12e6265b6ee 100644
--- a/packages/paperclip-runner/src/drivers/codex/codex-driver-types.ts
+++ b/packages/paperclip-runner/src/drivers/codex/codex-driver-types.ts
@@ -20,6 +20,8 @@ export interface CodexAppServerDriverOptions {
approvalPolicy?: "never" | "on-request" | "untrusted";
baseInstructions?: string;
includeSkillInstructions?: boolean;
+ /** Private instruction directory registered by the control plane for this run. */
+ instructionWorkingCopyRoot?: string;
/** Explicit selected skills, resolved from this task's assigned runtime assets. */
skillInputs?: readonly import("../../contracts/runtime-context.js").NativeSkillInput[];
conversationMode?: "task" | "direct" | "prepared";
diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts
index f22d328ffb0..acecaf2c838 100644
--- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts
+++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.test.ts
@@ -12,6 +12,25 @@ import {
} from "./codex-security-config.js";
describe("Codex security configuration", () => {
+ it("allows only the registered private instruction directory while keeping shared context read-only", () => {
+ const args = createIsolatedCodexAppServerArgs({ HOME: "/host/home" }, ["/runtime/immutable-context"], "/runtime/instruction-edits/run-1").join("\n");
+ expect(args).toContain('"/runtime/instruction-edits/run-1"="write"');
+ expect(args).toContain('"/runtime/immutable-context"="read"');
+ expect(args).not.toContain('"/runtime"="write"');
+ expect(args).toContain('"/host/home"="none"');
+ expect(createIsolatedCodexAppServerArgs({ PAPERCLIP_INSTRUCTION_ROOT: "/host/home" }).join("\n"))
+ .not.toContain('"/host/home"="write"');
+ });
+
+ it("exposes AGENT_HOME only when it matches the controller-registered writable directory", () => {
+ const root = "/agent-files/run-1";
+ const registered = createIsolatedCodexAppServerArgs({ AGENT_HOME: root, HOME: "/provider" }, [], root).join("\n");
+ expect(registered).toContain('AGENT_HOME="/agent-files/run-1"');
+ expect(registered).toContain('"/provider"="none"');
+ expect(createIsolatedCodexAppServerArgs({ AGENT_HOME: "/arbitrary" }, [], root).join("\n")).not.toContain("AGENT_HOME");
+ expect(createIsolatedCodexAppServerArgs({ AGENT_HOME: root }).join("\n")).not.toContain("AGENT_HOME");
+ });
+
it("makes the installed npm Codex native sandbox executable readable without exposing its parent workspace", () => {
const command = evalProviderTransportOptions("codex").codexCommand!;
const manifest = createRequire(command).resolve(`@openai/codex-${process.platform}-${process.arch}/package.json`);
diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts
index b041ff43634..f6480c12c03 100644
--- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts
+++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts
@@ -169,6 +169,8 @@ function tomlString(value: string): string {
export function createIsolatedCodexAppServerArgs(
source: NodeJS.ProcessEnv = process.env,
readOnlyRoots: string[] = [],
+ /** Server-registered run copy, never an environment/config-supplied root. */
+ instructionWorkingCopyRoot?: string,
): string[] {
const gitRoots = gitFilesystemRoots(source);
readOnlyRoots = [...new Set([...readOnlyRoots, ...codexNetworkReadOnlyRoots(source)])];
@@ -179,6 +181,7 @@ export function createIsolatedCodexAppServerArgs(
// Codex filters the configured `set` values through include_only as well.
// Retain the explicit command PATH/HOME/locale settings, not ambient secrets.
const commandEnvironment = codexCommandEnvironment(source);
+ if (instructionWorkingCopyRoot && source.AGENT_HOME === instructionWorkingCopyRoot) commandEnvironment.AGENT_HOME = instructionWorkingCopyRoot;
const shellEnvironmentKeys = [...new Set([...inheritedGitHubKeys, ...Object.keys(commandEnvironment)])].sort();
if (source.PAPERCLIP_GITHUB_LAUNCHER_DIR) readOnlyRoots = [...readOnlyRoots, source.PAPERCLIP_GITHUB_LAUNCHER_DIR];
const deniedHostRoots = [
@@ -199,6 +202,7 @@ export function createIsolatedCodexAppServerArgs(
...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`),
...gitRoots.read.map((path) => `${tomlString(path)}="read"`),
...gitRoots.write.map((path) => `${tomlString(path)}="write"`),
+ ...(instructionWorkingCopyRoot ? [`${tomlString(resolve(instructionWorkingCopyRoot))}="write"`] : []),
...(source.PAPERCLIP_GITHUB_BROKER_TOKEN && source.GH_CONFIG_DIR
? [`${tomlString(resolve(source.GH_CONFIG_DIR))}="write"`] : []),
`":workspace_roots"={"."="write"}`,
@@ -210,6 +214,7 @@ export function createIsolatedCodexAppServerArgs(
...deniedHostRoots.map((path) => `${tomlString(path)}="none"`),
...readOnlyRoots.map((path) => `${tomlString(resolve(path))}="read"`),
...[...gitRoots.read, ...gitRoots.write].map((path) => `${tomlString(path)}="read"`),
+ ...(instructionWorkingCopyRoot ? [`${tomlString(resolve(instructionWorkingCopyRoot))}="read"`] : []),
...(source.PAPERCLIP_GITHUB_BROKER_TOKEN && source.GH_CONFIG_DIR
? [`${tomlString(resolve(source.GH_CONFIG_DIR))}="write"`] : []),
`":workspace_roots"={"."="read"}`,
diff --git a/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts
index 0898771d666..6f59edee752 100644
--- a/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts
+++ b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts
@@ -20,6 +20,7 @@ import {
PAPERCLIP_EXECUTION_PROMPT_REVISION,
canonicalNativeRuntimeContextDigest,
nativeRuntimePromptDigest,
+ parseNativeRuntimeContext,
type NativeRuntimeContextSnapshot,
} from "../contracts/runtime-context.js";
import { nativeMcpLaunchBinding } from "./native-mcp.js";
@@ -92,6 +93,17 @@ function context(
}
describe("runtime context materialization", () => {
+ it("restores the legacy working-copy contract without changing its digest or adding new fields", () => {
+ const old = context("/skills", "/instructions");
+ old.instructions.workingCopy = { rootPath: "/old-run/copy", entryPath: "AGENTS.md" };
+ old.aggregateDigest = canonicalNativeRuntimeContextDigest(old);
+ expect(parseNativeRuntimeContext(JSON.parse(JSON.stringify(old)))).toEqual(old);
+ expect(parseNativeRuntimeContext(old).instructions.workingCopy).not.toHaveProperty("kind");
+ const next = { ...old, instructions: { ...old.instructions, workingCopy: { ...old.instructions.workingCopy, kind: "agent_files" as const } } };
+ next.aggregateDigest = canonicalNativeRuntimeContextDigest(next);
+ expect(parseNativeRuntimeContext(next).instructions.workingCopy?.kind).toBe("agent_files");
+ });
+
it("validates native MCP launch bindings before they reach Codex", () => {
expect(nativeMcpLaunchBinding({})).toBeNull();
expect(() => nativeMcpLaunchBinding({
diff --git a/packages/paperclip-runner/src/eval/workflow-evals.test.ts b/packages/paperclip-runner/src/eval/workflow-evals.test.ts
index 8deb3154b18..14cbe7199c0 100644
--- a/packages/paperclip-runner/src/eval/workflow-evals.test.ts
+++ b/packages/paperclip-runner/src/eval/workflow-evals.test.ts
@@ -466,13 +466,13 @@ describe("workflow reports and stress traceability", () => {
candidateFailures: 36,
});
expect(report.coverage).toMatchObject({
- canonicalOperations: 48,
+ canonicalOperations: 52,
capabilityCases: 106,
workflows: 12,
stressFindings: 44,
stressExclusions: 1,
});
- expect(report.coverage.operations).toHaveLength(48);
+ expect(report.coverage.operations).toHaveLength(52);
expect(report.coverage.composedWorkflows).toHaveLength(12);
expect(
report.coverage.operations.find(
@@ -480,7 +480,7 @@ describe("workflow reports and stress traceability", () => {
)?.workflowIds.length,
).toBeGreaterThan(0);
expect(renderRunnerWorkflowMarkdown(report)).toContain(
- "48 operations · 106 capability cases · 12 workflows",
+ "52 operations · 106 capability cases · 12 workflows",
);
expect(renderRunnerWorkflowJUnit(report)).toContain(
'tests="36" failures="36" skipped="0"',
diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts
index 1b35308400a..5de9203e641 100644
--- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts
+++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts
@@ -3277,6 +3277,7 @@ export function createRunnerdCodexAppServerArgs(input: {
codexHome: string;
codexCommand?: string;
readOnlyRoots?: string[];
+ instructionWorkingCopyRoot?: string;
}): string[] {
// The filesystem policy denies HOME and CODEX_HOME to keep credentials and
// runner state outside provider reach. Always bind those names to the actual
@@ -3289,6 +3290,7 @@ export function createRunnerdCodexAppServerArgs(input: {
CODEX_HOME: input.codexHome,
},
[...(input.readOnlyRoots ?? []), ...codexExecutableReadOnlyRoots(input.environment ?? {}, input.codexCommand)],
+ input.instructionWorkingCopyRoot,
);
}
@@ -4628,6 +4630,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
environment: this.options.environment,
codexHome,
codexCommand: this.options.codexCommand,
+ instructionWorkingCopyRoot: runtimeContext?.instructions.workingCopy?.rootPath,
readOnlyRoots: [
...trustedRuntimeReadOnlyRoots(
this.options.environment,
@@ -5119,6 +5122,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
environment: this.options.environment,
codexHome,
codexCommand: this.options.codexCommand,
+ instructionWorkingCopyRoot: runtimeContext?.instructions.workingCopy?.rootPath,
readOnlyRoots: [
...trustedRuntimeReadOnlyRoots(this.options.environment),
...(runtimeContext
diff --git a/packages/paperclip-runner/src/native-session-runtime.test.ts b/packages/paperclip-runner/src/native-session-runtime.test.ts
index ab82510cf91..1c02ca05c5b 100644
--- a/packages/paperclip-runner/src/native-session-runtime.test.ts
+++ b/packages/paperclip-runner/src/native-session-runtime.test.ts
@@ -4014,6 +4014,45 @@ describe("executeNativeSession recovery", () => {
},
);
+ it("collects a failed run's private instructions only after its owned provider close joins", async () => {
+ const scopedIdentity = { ...identity, companyId: "instruction-close-company", runId: "instruction-close-run" };
+ let finishClose!: () => void;
+ const closed = new Promise((resolve) => { finishClose = resolve; });
+ const order: string[] = [];
+ const capabilities = { resume: true, typedEvents: true, steering: false, interruption: false, structuredResult: true };
+ const session: NativeSession = {
+ identity: () => scopedIdentity,
+ capabilities: async () => capabilities,
+ async *events() { throw new Error("provider failed after editing instructions"); },
+ startTurn: async () => ({ turnId: "instruction-turn" }),
+ result: async () => null,
+ snapshot: async () => ({ backendKind: "local", sessionId: "instruction-session", identity: scopedIdentity,
+ providerSessionId: "instruction-provider", cursor: null, activeTurnId: null, pendingRuntimeRequests: [], lineage: [] }),
+ close: async () => { order.push("closing"); await closed; order.push("stopped"); },
+ };
+ const backend: NativeSessionBackend = {
+ descriptor: async () => ({ kind: "local", name: "instruction-close-test", version: "1", capabilities }),
+ openSession: async () => session,
+ };
+ const controlPlane: ControlPlanePort = {
+ openRun: async () => {}, checkpointSession: async () => {},
+ appendEvent: async () => ({ cursor: 0, highestContiguousSourceSeq: 0, disposition: "committed" }),
+ replayEvents: async () => ({ events: [], highestContiguousSourceSeq: 0 }), completeRun: async () => {},
+ };
+ const options = {
+ input: { ...input, binding: { ...input.binding, companyId: scopedIdentity.companyId, runId: scopedIdentity.runId } },
+ backend, controlPlane, runnerInstanceId: "instruction-runner", controlPlaneInstanceId: "control",
+ requireSessionCloseBeforeReturn: true,
+ onSessionClosed: async () => { order.push("collected"); },
+ };
+ const execution = executeNativeSession(options);
+ const failed = expect(execution).rejects.toThrow("provider failed after editing instructions");
+ await vi.waitFor(() => expect(order).toEqual(["closing"]));
+ finishClose();
+ await failed;
+ expect(order).toEqual(["closing", "stopped", "collected"]);
+ });
+
it("retires only the terminated remote resource, including two sandboxes for one run", async () => {
const scopedIdentity = { ...identity, companyId: "remote-stop-company", runId: "remote-stop-run" };
const binding = { ...scopedIdentity, remoteCleanupScope: "first-sandbox" };
@@ -4107,6 +4146,7 @@ describe("executeNativeSession recovery", () => {
vi.useFakeTimers();
try {
const closeFailure = new Error("required remote checkpoint close failed");
+ const onSessionClosed = vi.fn(async () => {});
const close = vi.fn(({ reason }: { reason: string }) =>
reason === "native session quarantined cleanup recovery"
? Promise.resolve()
@@ -4191,11 +4231,13 @@ describe("executeNativeSession recovery", () => {
runnerInstanceId: "runner-recovery",
controlPlaneInstanceId: "control-recovery",
requireSessionCloseBeforeReturn: true,
+ onSessionClosed,
}),
).rejects.toThrow(closeFailure);
await vi.advanceTimersByTimeAsync(3_000);
await execution;
expect(close).toHaveBeenCalledTimes(5);
+ expect(onSessionClosed).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
diff --git a/packages/paperclip-runner/src/native-session-runtime.ts b/packages/paperclip-runner/src/native-session-runtime.ts
index 408f3adc9e6..27b5c204c3a 100644
--- a/packages/paperclip-runner/src/native-session-runtime.ts
+++ b/packages/paperclip-runner/src/native-session-runtime.ts
@@ -220,6 +220,9 @@ export interface ExecuteNativeSessionOptions {
* carries required persistence (for example, a remote runner checkpoint).
*/
requireSessionCloseBeforeReturn?: boolean;
+ /** Trusted cleanup observer, called only after the owned close completes.
+ * Requires requireSessionCloseBeforeReturn; never called for a live warm session. */
+ onSessionClosed?: () => Promise;
onCheckpoint?: (
snapshot: PersistedNativeSession,
options?: CheckpointControlPlaneSessionOptions,
@@ -2756,6 +2759,7 @@ export async function executeNativeSession(
// generic retryable transport failure at the control-plane boundary.
throw protocolIntegrityFailure ?? closeError;
}
+ await options.onSessionClosed?.();
}
} else if (shouldClose && !failedCleanupDeferred) {
// A provider that ignores close must not keep execution pending forever.
diff --git a/packages/paperclip-runner/src/protocol-actions/core.ts b/packages/paperclip-runner/src/protocol-actions/core.ts
index ee195815279..8e0a5695dac 100644
--- a/packages/paperclip-runner/src/protocol-actions/core.ts
+++ b/packages/paperclip-runner/src/protocol-actions/core.ts
@@ -1,3 +1,7 @@
+import { readAgentInstructionsAction } from "./read-agent-instructions.js";
+import { updateAgentInstructionsAction } from "./update-agent-instructions.js";
+import { getAgentInstructionHistoryAction } from "./get-agent-instruction-history.js";
+import { restoreAgentInstructionsAction } from "./restore-agent-instructions.js";
import { answerStatusQuestionAction } from "./answer-status-question.js";
import { blockTaskAction } from "./block-task.js";
import { finishTaskAction } from "./finish-task.js";
@@ -16,6 +20,11 @@ import { deepFreezeProtocolAction } from "./freeze.js";
/** Core actions that are always present in an authorized runner projection. */
export const PAPERCLIP_CORE_PROTOCOL_ACTIONS = deepFreezeProtocolAction([
+ readAgentInstructionsAction,
+ updateAgentInstructionsAction,
+ getAgentInstructionHistoryAction,
+ restoreAgentInstructionsAction,
+
answerStatusQuestionAction,
blockTaskAction,
finishTaskAction,
diff --git a/packages/paperclip-runner/src/protocol-actions/get-agent-instruction-history.ts b/packages/paperclip-runner/src/protocol-actions/get-agent-instruction-history.ts
new file mode 100644
index 00000000000..11046457e0b
--- /dev/null
+++ b/packages/paperclip-runner/src/protocol-actions/get-agent-instruction-history.ts
@@ -0,0 +1,99 @@
+/** Canonical definition for `get_agent_instruction_history`. */
+export const getAgentInstructionHistoryAction = {
+ "id": "get_agent_instruction_history",
+ "canonical": {
+ "operationId": "get_agent_instruction_history",
+ "surfaces": [
+ "live"
+ ],
+ "placement": "always_agent_tool",
+ "optionalGroup": null,
+ "requiredClaims": [],
+ "taskModes": [
+ "standard",
+ "ask",
+ "planning",
+ "skill_test"
+ ],
+ "sideEffectClass": "read",
+ "idempotency": "none",
+ "disabledByDefault": false,
+ "realBindingStatus": "live_codex",
+ "realServiceBinding": "agentInstructionRevisionService",
+ "prpEvidence": "tool-result item event plus canonical revision receipt for writes; no scenario mock coverage",
+ "prpBindingStatus": "bound",
+ "legacyAliases": [],
+ "note": "Live canonical instruction service; company scope and current responsible-user authorization are checked at invocation."
+ },
+ "documentation": {
+ "title": "Get agent instruction history",
+ "description": "List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.",
+ "note": null
+ },
+ "examples": {
+ "call": {
+ "operationId": "get_agent_instruction_history",
+ "input": {
+ "entryFile": "AGENTS.md"
+ }
+ },
+ "success": {
+ "ok": true,
+ "operationId": "get_agent_instruction_history",
+ "result": {}
+ }
+ },
+ "live": {
+ "order": 42,
+ "descriptor": {
+ "schema": "paperclip.semantic-tool.v1",
+ "operationId": "get_agent_instruction_history",
+ "version": 1,
+ "title": "Get agent instruction history",
+ "description": "List bounded canonical instruction revision metadata, including actor, source run, and restore origin. Use read_agent_instructions with entryFile and revisionId to inspect exact historical content.",
+ "exposure": "always",
+ "requiredClaims": [],
+ "allowedModes": [
+ "standard",
+ "ask",
+ "planning",
+ "skill_test"
+ ],
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "targetAgentId": {
+ "type": "string",
+ "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
+ "description": "Same-company target agent. Omit to use the calling agent."
+ },
+ "entryFile": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 4096,
+ "description": "Configured relative entry filename returned by read_agent_instructions; retain it with the revision."
+ },
+ "cursor": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 2048
+ },
+ "limit": {
+ "type": "integer",
+ "minimum": 1,
+ "maximum": 100
+ }
+ },
+ "required": [
+ "entryFile"
+ ],
+ "additionalProperties": false
+ },
+ "outputSchema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ },
+ "scenario": null
+} as const;
diff --git a/packages/paperclip-runner/src/protocol-actions/index.ts b/packages/paperclip-runner/src/protocol-actions/index.ts
index 8f07df6695d..4dcfcc66b0e 100644
--- a/packages/paperclip-runner/src/protocol-actions/index.ts
+++ b/packages/paperclip-runner/src/protocol-actions/index.ts
@@ -1,3 +1,7 @@
+import { readAgentInstructionsAction } from "./read-agent-instructions.js";
+import { updateAgentInstructionsAction } from "./update-agent-instructions.js";
+import { getAgentInstructionHistoryAction } from "./get-agent-instruction-history.js";
+import { restoreAgentInstructionsAction } from "./restore-agent-instructions.js";
import { reassignTaskAction } from "./reassign-task.js";
import { createSkillAction } from "./create-skill.js";
import { createProjectAction } from "./create-project.js";
@@ -49,6 +53,11 @@ import { writeDocumentAction } from "./write-document.js";
import { deepFreezeProtocolAction } from "./freeze.js";
export const PAPERCLIP_PROTOCOL_ACTIONS = deepFreezeProtocolAction([
+ readAgentInstructionsAction,
+ updateAgentInstructionsAction,
+ getAgentInstructionHistoryAction,
+ restoreAgentInstructionsAction,
+
createSkillAction,
createProjectAction,
listProjectRepositoriesAction,
diff --git a/packages/paperclip-runner/src/protocol-actions/read-agent-instructions.ts b/packages/paperclip-runner/src/protocol-actions/read-agent-instructions.ts
new file mode 100644
index 00000000000..9a947d1a2cb
--- /dev/null
+++ b/packages/paperclip-runner/src/protocol-actions/read-agent-instructions.ts
@@ -0,0 +1,90 @@
+/** Canonical definition for `read_agent_instructions`. */
+export const readAgentInstructionsAction = {
+ "id": "read_agent_instructions",
+ "canonical": {
+ "operationId": "read_agent_instructions",
+ "surfaces": [
+ "live"
+ ],
+ "placement": "always_agent_tool",
+ "optionalGroup": null,
+ "requiredClaims": [],
+ "taskModes": [
+ "standard",
+ "ask",
+ "planning",
+ "skill_test"
+ ],
+ "sideEffectClass": "read",
+ "idempotency": "none",
+ "disabledByDefault": false,
+ "realBindingStatus": "live_codex",
+ "realServiceBinding": "agentInstructionRevisionService",
+ "prpEvidence": "tool-result item event plus canonical revision receipt for writes; no scenario mock coverage",
+ "prpBindingStatus": "bound",
+ "legacyAliases": [],
+ "note": "Live canonical instruction service; company scope and current responsible-user authorization are checked at invocation."
+ },
+ "documentation": {
+ "title": "Read canonical agent instructions",
+ "description": "Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.",
+ "note": null
+ },
+ "examples": {
+ "call": {
+ "operationId": "read_agent_instructions",
+ "input": {}
+ },
+ "success": {
+ "ok": true,
+ "operationId": "read_agent_instructions",
+ "result": {}
+ }
+ },
+ "live": {
+ "order": 40,
+ "descriptor": {
+ "schema": "paperclip.semantic-tool.v1",
+ "operationId": "read_agent_instructions",
+ "version": 1,
+ "title": "Read canonical agent instructions",
+ "description": "Read the current canonical instruction entry and its revision, or inspect a historical revision by supplying both entryFile and revisionId. Read before updating; do not edit shared instruction caches.",
+ "exposure": "always",
+ "requiredClaims": [],
+ "allowedModes": [
+ "standard",
+ "ask",
+ "planning",
+ "skill_test"
+ ],
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "targetAgentId": {
+ "type": "string",
+ "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
+ "description": "Same-company target agent. Omit to use the calling agent."
+ },
+ "entryFile": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 4096,
+ "description": "Configured relative entry filename returned by read_agent_instructions; retain it with the revision."
+ },
+ "revisionId": {
+ "type": "string",
+ "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
+ "description": "Historical revision to inspect; also provide its entryFile."
+ }
+ },
+ "required": [],
+ "additionalProperties": false
+ },
+ "outputSchema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ },
+ "scenario": null
+} as const;
diff --git a/packages/paperclip-runner/src/protocol-actions/restore-agent-instructions.ts b/packages/paperclip-runner/src/protocol-actions/restore-agent-instructions.ts
new file mode 100644
index 00000000000..464a556f410
--- /dev/null
+++ b/packages/paperclip-runner/src/protocol-actions/restore-agent-instructions.ts
@@ -0,0 +1,99 @@
+/** Canonical definition for `restore_agent_instructions`. */
+export const restoreAgentInstructionsAction = {
+ "id": "restore_agent_instructions",
+ "canonical": {
+ "operationId": "restore_agent_instructions",
+ "surfaces": [
+ "live"
+ ],
+ "placement": "always_agent_tool",
+ "optionalGroup": null,
+ "requiredClaims": [],
+ "taskModes": [
+ "standard",
+ "planning"
+ ],
+ "sideEffectClass": "company_write",
+ "idempotency": "recommended",
+ "disabledByDefault": false,
+ "realBindingStatus": "live_codex",
+ "realServiceBinding": "agentInstructionRevisionService",
+ "prpEvidence": "tool-result item event plus canonical revision receipt for writes; no scenario mock coverage",
+ "prpBindingStatus": "bound",
+ "legacyAliases": [],
+ "note": "Live canonical instruction service; company scope and current responsible-user authorization are checked at invocation."
+ },
+ "documentation": {
+ "title": "Restore canonical agent instructions",
+ "description": "Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user\u2019s current target edit permission. History remains intact; conflicts require an explicit resolution.",
+ "note": null
+ },
+ "examples": {
+ "call": {
+ "operationId": "restore_agent_instructions",
+ "input": {
+ "entryFile": "AGENTS.md",
+ "revisionId": "00000000-0000-4000-8000-000000000001",
+ "baseRevisionId": "00000000-0000-4000-8000-000000000002"
+ }
+ },
+ "success": {
+ "ok": true,
+ "operationId": "restore_agent_instructions",
+ "result": {}
+ }
+ },
+ "live": {
+ "order": 43,
+ "descriptor": {
+ "schema": "paperclip.semantic-tool.v1",
+ "operationId": "restore_agent_instructions",
+ "version": 1,
+ "title": "Restore canonical agent instructions",
+ "description": "Append a historical instruction revision as the current content using the current baseRevisionId. Requires the responsible user\u2019s current target edit permission. History remains intact; conflicts require an explicit resolution.",
+ "exposure": "always",
+ "requiredClaims": [],
+ "allowedModes": [
+ "standard",
+ "planning"
+ ],
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "targetAgentId": {
+ "type": "string",
+ "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
+ "description": "Same-company target agent. Omit to use the calling agent."
+ },
+ "entryFile": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 4096,
+ "description": "Configured relative entry filename returned by read_agent_instructions; retain it with the revision."
+ },
+ "revisionId": {
+ "type": "string",
+ "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
+ "description": "Historical revision whose exact content should be restored."
+ },
+ "baseRevisionId": {
+ "type": "string",
+ "description": "Current revision read before restoring. Never replace a stale base silently.",
+ "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
+ }
+ },
+ "required": [
+ "entryFile",
+ "revisionId",
+ "baseRevisionId"
+ ],
+ "additionalProperties": false
+ },
+ "outputSchema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ },
+ "scenario": null
+} as const;
diff --git a/packages/paperclip-runner/src/protocol-actions/update-agent-instructions.ts b/packages/paperclip-runner/src/protocol-actions/update-agent-instructions.ts
new file mode 100644
index 00000000000..a42356b6ca3
--- /dev/null
+++ b/packages/paperclip-runner/src/protocol-actions/update-agent-instructions.ts
@@ -0,0 +1,102 @@
+/** Canonical definition for `update_agent_instructions`. */
+export const updateAgentInstructionsAction = {
+ "id": "update_agent_instructions",
+ "canonical": {
+ "operationId": "update_agent_instructions",
+ "surfaces": [
+ "live"
+ ],
+ "placement": "always_agent_tool",
+ "optionalGroup": null,
+ "requiredClaims": [],
+ "taskModes": [
+ "standard",
+ "planning"
+ ],
+ "sideEffectClass": "company_write",
+ "idempotency": "recommended",
+ "disabledByDefault": false,
+ "realBindingStatus": "live_codex",
+ "realServiceBinding": "agentInstructionRevisionService",
+ "prpEvidence": "tool-result item event plus canonical revision receipt for writes; no scenario mock coverage",
+ "prpBindingStatus": "bound",
+ "legacyAliases": [],
+ "note": "Live canonical instruction service; company scope and current responsible-user authorization are checked at invocation."
+ },
+ "documentation": {
+ "title": "Update canonical agent instructions",
+ "description": "Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user\u2019s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.",
+ "note": null
+ },
+ "examples": {
+ "call": {
+ "operationId": "update_agent_instructions",
+ "input": {
+ "entryFile": "AGENTS.md",
+ "content": "# Instructions\n",
+ "baseRevisionId": null
+ }
+ },
+ "success": {
+ "ok": true,
+ "operationId": "update_agent_instructions",
+ "result": {}
+ }
+ },
+ "live": {
+ "order": 41,
+ "descriptor": {
+ "schema": "paperclip.semantic-tool.v1",
+ "operationId": "update_agent_instructions",
+ "version": 1,
+ "title": "Update canonical agent instructions",
+ "description": "Commit instruction content with the exact entryFile and baseRevisionId from a prior read. Requires the responsible user\u2019s current target edit permission. On conflict, preserve your candidate and explicitly resolve it; never overwrite the newer head.",
+ "exposure": "always",
+ "requiredClaims": [],
+ "allowedModes": [
+ "standard",
+ "planning"
+ ],
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "targetAgentId": {
+ "type": "string",
+ "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
+ "description": "Same-company target agent. Omit to use the calling agent."
+ },
+ "entryFile": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 4096,
+ "description": "Configured relative entry filename returned by read_agent_instructions; retain it with the revision."
+ },
+ "content": {
+ "type": "string",
+ "maxLength": 1048576,
+ "description": "Complete UTF-8 instruction content, at most 1 MiB; empty content is valid."
+ },
+ "baseRevisionId": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Revision read before editing; null only when no canonical entry exists. Never replace a stale base silently.",
+ "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
+ }
+ },
+ "required": [
+ "entryFile",
+ "content",
+ "baseRevisionId"
+ ],
+ "additionalProperties": false
+ },
+ "outputSchema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ },
+ "scenario": null
+} as const;
diff --git a/packages/paperclip-runner/src/semantic-tools/discovery.ts b/packages/paperclip-runner/src/semantic-tools/discovery.ts
index d1bf2bd4335..7d6c28a0d2f 100644
--- a/packages/paperclip-runner/src/semantic-tools/discovery.ts
+++ b/packages/paperclip-runner/src/semantic-tools/discovery.ts
@@ -27,7 +27,12 @@ const NAMESPACE: Readonly> = Objec
report_progress: "active_task", answer_status_question: "active_task", write_document: "documents",
request_human_input: "documents", register_deliverable: "documents", finish_task: "active_task",
block_task: "active_task", request_review: "active_task", search_tasks: "discovery",
- list_agents: "discovery", get_agent: "discovery", create_skill: "skills", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects",
+ list_agents: "discovery", get_agent: "discovery",
+ read_agent_instructions: "agent_instructions",
+ update_agent_instructions: "agent_instructions",
+ get_agent_instruction_history: "agent_instructions",
+ restore_agent_instructions: "agent_instructions",
+ create_skill: "skills", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects",
set_dependencies: "delegation", reassign_task: "delegation", list_approvals: "governance", get_approval: "governance",
get_approval_context: "governance", request_approval: "governance",
decide_approval: "governance", comment_on_approval: "governance",
@@ -102,6 +107,9 @@ export function discoverCapabilityDefinitions(
const tokens = normalized.split(/[^a-z0-9]+/).filter((token) => token.length > 1);
const permitted = CAPABILITY_SEMANTIC_TOOL_CATALOG
.filter((descriptor) => descriptor.exposure === "optional")
+ // Retain dispatch support for pre-upgrade sessions, but do not advertise
+ // revision history/restore as a capability of current agent directories.
+ .filter((descriptor) => descriptor.operationId !== "get_agent_instruction_history" && descriptor.operationId !== "restore_agent_instructions")
.filter((descriptor) => options.namespace === undefined || capabilityToolNamespace(descriptor.operationId) === options.namespace)
.filter((descriptor) => decideCapabilitySemanticAuthorization(descriptor, context, "exposure").allowed)
.map((descriptor) => ({ descriptor, score: score(descriptor, tokens, normalized) }))
diff --git a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts
index af1344a2984..1e6ebc1bb50 100644
--- a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts
+++ b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts
@@ -94,7 +94,7 @@ describe("Capability semantic catalog and authorization", () => {
it("publishes a stable narrow catalog without credentials or control-plane-owned tools", () => {
const names = CAPABILITY_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId);
expect(new Set(names).size).toBe(names.length);
- expect(names).toHaveLength(36);
+ expect(names).toHaveLength(40);
expect(names).toContain("get_task_context");
expect(names).toContain("finish_task");
expect(names).not.toContain("checkout_task");
diff --git a/packages/paperclip-runner/src/semantic-tools/types.ts b/packages/paperclip-runner/src/semantic-tools/types.ts
index ea9339edf8d..31178d2690a 100644
--- a/packages/paperclip-runner/src/semantic-tools/types.ts
+++ b/packages/paperclip-runner/src/semantic-tools/types.ts
@@ -33,6 +33,10 @@ export type CapabilitySemanticOperationId =
| "request_review"
| "list_agents"
| "get_agent"
+ | "read_agent_instructions"
+ | "update_agent_instructions"
+ | "get_agent_instruction_history"
+ | "restore_agent_instructions"
| "search_tasks"
| "list_approvals"
| "get_approval"
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 04b12167862..9591a8ce372 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -2789,3 +2789,10 @@ export { MEMORY_CONNECTOR_IDS, isMemoryConnectorId, type MemoryConnectorId } fro
export * from "./connection-routing.js";
export { WORKSPACE_RESTORE_FAILURE_CODES, hasWorkspaceRestoreFailure, safeWorkspaceRestorePath, isNativeWorkspaceExportRepairCause } from "./workspace-restore.js";
+
+
+export type { AgentInstructionErrorCode, AgentInstructionErrorDetails, AgentInstructionSource, AgentInstructionRevision, AgentInstructionSnapshot, AgentInstructionCommitReceipt, AgentInstructionHistory, AgentInstructionDiff } from "./types/agent.js";
+export { restoreAgentInstructionSchema } from "./validators/agent.js";
+
+export type { AgentInstructionCandidate } from "./types/agent.js";
+export { resolveAgentInstructionCandidateSchema, type ResolveAgentInstructionCandidate } from "./validators/agent.js";
diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts
index e775c5b9941..443e61f217e 100644
--- a/packages/shared/src/types/agent.ts
+++ b/packages/shared/src/types/agent.ts
@@ -32,6 +32,8 @@ export type AgentInstructionsBundleMode = "managed" | "external";
export interface AgentInstructionsFileSummary {
path: string;
+ contentHash?: string;
+ binary?: boolean;
size: number;
language: string;
markdown: boolean;
@@ -43,11 +45,14 @@ export interface AgentInstructionsFileSummary {
export interface AgentInstructionsFileDetail extends AgentInstructionsFileSummary {
content: string;
+ revision?: AgentInstructionRevision;
+ receipt?: AgentInstructionCommitReceipt;
}
export interface AgentInstructionsBundle {
agentId: string;
companyId: string;
+ persistence?: "agent_files";
mode: AgentInstructionsBundleMode | null;
rootPath: string | null;
managedRootPath: string;
@@ -336,3 +341,77 @@ export type AdapterAuthSignal = "present" | "absent" | "unknown";
export interface AdapterAuthSignalResponse {
status: AdapterAuthSignal;
}
+
+export type AgentInstructionSource = "seed" | "board" | "api" | "tool" | "cleanup" | "restore";
+export interface AgentInstructionRevision {
+ id: string;
+ companyId: string;
+ agentId: string;
+ entryFile: string;
+ contentHash: string;
+ byteLength: number;
+ parentRevisionId: string | null;
+ baseRevisionId: string | null;
+ restoredFromRevisionId: string | null;
+ actorAgentId: string | null;
+ actorUserId: string | null;
+ responsibleUserId: string | null;
+ sourceRunId: string | null;
+ source: AgentInstructionSource;
+ createdAt: string;
+}
+export interface AgentInstructionSnapshot {
+ revision: AgentInstructionRevision;
+ content: string;
+}
+export interface AgentInstructionCommitReceipt extends AgentInstructionSnapshot {
+ changed: boolean;
+ materialization: "current" | "pending";
+}
+export interface AgentInstructionHistory {
+ revisions: AgentInstructionRevision[];
+ nextCursor: string | null;
+}
+export interface AgentInstructionDiff {
+ from: AgentInstructionSnapshot;
+ to: AgentInstructionSnapshot;
+ prefix: string;
+ removed: string;
+ added: string;
+ suffix: string;
+}
+
+export type AgentInstructionErrorCode =
+ | "INSTRUCTION_BASE_REQUIRED"
+ | "INSTRUCTION_REVISION_REQUIRED"
+ | "INSTRUCTION_REVISION_CONFLICT"
+ | "INSTRUCTION_ENTRY_CHANGED"
+ | "INSTRUCTION_MANAGED_BUNDLE_REQUIRED"
+ | "INSTRUCTION_IDENTITY_INVALID"
+ | "INSTRUCTION_CONTENT_INVALID"
+ | "INSTRUCTION_PATH_INVALID"
+ | "RESPONSIBLE_USER_UNAVAILABLE"
+ | "RESPONSIBLE_USER_UNAUTHORIZED";
+export interface AgentInstructionErrorDetails {
+ code: AgentInstructionErrorCode;
+ baseRevisionId?: string | null;
+ currentRevisionId?: string | null;
+ entryFile?: string;
+ reason?: string;
+}
+
+/** Preserved instruction edits; filesystem locations and responsible identity stay server-side. */
+export interface AgentInstructionCandidate {
+ contract?: "agent_files" | "legacy";
+ runId: string;
+ entryFile: string;
+ baseRevisionId: string | null;
+ baseHash: string;
+ state: "conflict" | "pending_collection" | "pending_commit" | "unavailable";
+ candidateHash: string | null;
+ content: string | null;
+ errorCode: string | null;
+ errorMessage: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts
index 179832a3de4..87ace9cd47b 100644
--- a/packages/shared/src/types/index.ts
+++ b/packages/shared/src/types/index.ts
@@ -1069,3 +1069,7 @@ export * from "./chat-channels.js";
export * from "./chat-github.js";
export * from "./email.js";
+
+export type { AgentInstructionErrorCode, AgentInstructionErrorDetails, AgentInstructionSource, AgentInstructionRevision, AgentInstructionSnapshot, AgentInstructionCommitReceipt, AgentInstructionHistory, AgentInstructionDiff } from "./agent.js";
+
+export type { AgentInstructionCandidate } from "./agent.js";
diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts
index e40a7cf6ce7..9c2ec1d0611 100644
--- a/packages/shared/src/validators/agent.ts
+++ b/packages/shared/src/validators/agent.ts
@@ -34,10 +34,18 @@ export const updateAgentInstructionsBundleSchema = z.object({
export type UpdateAgentInstructionsBundle = z.infer;
export const upsertAgentInstructionsFileSchema = z.object({
- path: z.string().trim().min(1),
- content: z.string(),
+ path: z.string().min(1).max(512),
+ content: z.string().max(1024 * 1024),
+ baseRevisionId: z.string().uuid().nullable().optional(),
+ baseHash: z.string().regex(/^[a-f0-9]{64}$/).nullable().optional(),
clearLegacyPromptTemplate: z.boolean().optional().default(false),
-});
+}).strict();
+
+export const restoreAgentInstructionSchema = z.object({
+ path: z.string().min(1).max(512),
+ revisionId: z.string().uuid(),
+ baseRevisionId: z.string().uuid(),
+}).strict();
export type UpsertAgentInstructionsFile = z.infer;
@@ -289,3 +297,9 @@ export const updateAgentPermissionsSchema = z.object({
});
export type UpdateAgentPermissions = z.infer;
+
+export const resolveAgentInstructionCandidateSchema = z.object({
+ baseRevisionId: z.string().uuid().nullable(),
+ content: z.string().max(1024 * 1024),
+}).strict();
+export type ResolveAgentInstructionCandidate = z.infer;
diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts
index a39388a080c..8489488d1c7 100644
--- a/packages/shared/src/validators/index.ts
+++ b/packages/shared/src/validators/index.ts
@@ -985,3 +985,5 @@ export * from "./chat-channels.js";
export * from "./chat-github.js";
export * from "./email.js";
+
+export { restoreAgentInstructionSchema } from "./agent.js";
diff --git a/server/src/__tests__/agent-directory-working-copies.test.ts b/server/src/__tests__/agent-directory-working-copies.test.ts
new file mode 100644
index 00000000000..17d7acdb882
--- /dev/null
+++ b/server/src/__tests__/agent-directory-working-copies.test.ts
@@ -0,0 +1,539 @@
+import fs from "node:fs/promises";
+import { execFile as execFileCallback } from "node:child_process";
+import { promisify } from "node:util";
+import * as executionTargetTools from "@paperclipai/adapter-utils/execution-target";
+import * as ssh from "@paperclipai/adapter-utils/ssh";
+const execFile = promisify(execFileCallback);
+import os from "node:os";
+import path from "node:path";
+import { createHash, randomUUID } from "node:crypto";
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { eq } from "drizzle-orm";
+import { agentFileStore, fileHash, inspectAgentFile, snapshotAgentFiles, MAX_AGENT_FILE_BYTES, MAX_AGENT_DIRECTORY_BYTES, MAX_AGENT_DIRECTORY_ENTRIES } from "../services/agent-file-store.js";
+import { agents, companies, authUsers, companyMemberships, principalPermissionGrants, heartbeatRuns, environmentLeases, environments, agentInstructionWorkingCopies, agentInstructionRevisions, agentInstructionHeads, createDb } from "@paperclipai/db";
+import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
+import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
+import { agentInstructionWorkingCopyService, instructionWorkingCopyGuidance } from "../services/agent-instruction-working-copies.js";
+import { resolveManagedInstructionsRoot } from "../services/agent-instructions.js";
+import { buildNativeRuntimeContext } from "../services/native-runtime/runtime-context.js";
+import type { EnvironmentRuntimeService } from "../services/environment-runtime.js";
+import { remoteTerminationReceipt } from "../services/remote-execution-termination.js";
+
+describe("persistent agent directories", () => {
+ let database: Awaited>;
+ let db: ReturnType;
+ let copies: ReturnType;
+ let revisions: ReturnType;
+ const previousHome = process.env.PAPERCLIP_HOME;
+ let home: string;
+ let companyId: string, agentId: string, userId: string, root: string;
+ const entryFile = "policy/INSTRUCTIONS.txt";
+ const initial = "\uFEFF# Original\r\n☃\n";
+ const target = () => ({ companyId, agentId });
+ const board = () => ({ type: "board" as const, userId, source: "session" as const });
+ async function run() {
+ const runId = randomUUID();
+ await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
+ return (await copies.prepare({ ...target(), runId, cwd: home }))!;
+ }
+ beforeAll(async () => {
+ home = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "instruction-working-copies-")));
+ process.env.PAPERCLIP_HOME = home;
+ database = await startEmbeddedPostgresTestDatabase("instruction-copies-db-");
+ db = createDb(database.connectionString);
+ copies = agentInstructionWorkingCopyService(db);
+ revisions = agentInstructionRevisionService(db);
+ }, 90_000);
+ afterAll(async () => {
+ if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
+ await database?.cleanup();
+ if (home) {
+ const writable = async (dir: string) => {
+ await fs.chmod(dir, 0o700);
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) if (entry.isDirectory()) await writable(path.join(dir, entry.name));
+ };
+ await writable(home);
+ await fs.rm(home, { recursive: true, force: true });
+ }
+ });
+ beforeEach(async () => {
+ companyId = randomUUID(); agentId = randomUUID(); userId = randomUUID();
+ await db.insert(companies).values({ id: companyId, name: "Instruction tests", issuePrefix: randomUUID().slice(0, 8) });
+ await db.insert(authUsers).values({ id: userId, name: "Editor", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
+ root = resolveManagedInstructionsRoot({ ...target(), id: agentId, name: "Target", adapterConfig: {} });
+ await db.insert(agents).values({ id: agentId, companyId, name: "Target", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: entryFile } });
+ await db.insert(companyMemberships).values([
+ { companyId, principalType: "user", principalId: userId, membershipRole: "operator" },
+ { companyId, principalType: "agent", principalId: agentId, membershipRole: "member" },
+ ]);
+ await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId, permissionKey: "agents:configure", scope: { agentIds: [agentId] } });
+ await fs.mkdir(path.dirname(path.join(root, entryFile)), { recursive: true });
+ await fs.writeFile(path.join(root, entryFile), initial);
+ });
+
+ it.each([".paperclip-runtime/state", "notes/.paperclip-runtime/state", "promptTemplate.legacy.md"])("rejects reserved board path %s before mutation", async (reserved) => {
+ await expect(agentFileStore(db).write({ ...target(), path: reserved, bytes: Buffer.from("reserved"), baseHash: null }, board())).rejects.toMatchObject({ status: 422 });
+ await expect(fs.stat(path.join(root, reserved))).rejects.toMatchObject({ code: "ENOENT" });
+ expect(await run()).toBeTruthy();
+ });
+
+ it("round trips nested text, empty directories and binary bytes independently of task files", async () => {
+ const first = await run();
+ expect(first.localRoot.startsWith(path.join(home, ".paperclip-runtime"))).toBe(false);
+ await fs.mkdir(path.join(first.localRoot, "notes", "empty"), { recursive: true });
+ await fs.writeFile(path.join(first.localRoot, "notes", "fact.txt"), "remember me");
+ const bytes = Buffer.from([0, 255, 17, 128, 9]);
+ await fs.writeFile(path.join(first.localRoot, "image.bin"), bytes);
+ await fs.writeFile(path.join(home, "task-only.txt"), "not personal");
+ expect((await copies.collectStopped({ companyId, runId: first.runId }))?.state).toBe("saved");
+ copies = agentInstructionWorkingCopyService(db);
+ const next = await run();
+ expect(await fs.readFile(path.join(next.localRoot, "notes", "fact.txt"), "utf8")).toBe("remember me");
+ expect(await fs.readFile(path.join(next.localRoot, "image.bin"))).toEqual(bytes);
+ expect((await fs.stat(path.join(next.localRoot, "notes", "empty"))).isDirectory()).toBe(true);
+ await expect(fs.stat(path.join(next.localRoot, "task-only.txt"))).rejects.toMatchObject({ code: "ENOENT" });
+ expect(await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, agentId))).toHaveLength(0);
+ });
+
+ async function sparseFile(filename: string, size: number) {
+ const handle = await fs.open(filename, "w");
+ try { await handle.truncate(size); } finally { await handle.close(); }
+ }
+
+ it("saves and restores files beyond the former file and folder limits, with streaming inspection and download", async () => {
+ const first = await run();
+ const size = 66 * 1024 * 1024;
+ await sparseFile(path.join(first.localRoot, "large.bin"), size);
+ expect((await copies.collectStopped({ companyId, runId: first.runId }))?.state).toBe("saved");
+ await copies.release(companyId, first.runId);
+ const next = await run();
+ const restored = await inspectAgentFile(next.localRoot, "large.bin");
+ expect(restored).toMatchObject({ size, bytes: null });
+ const store = agentFileStore(db);
+ const download = (await store.download(companyId, agentId, "large.bin", board()))!;
+ expect(download.size).toBe(size);
+ // The lock is released before consuming the stream. An editor replacement
+ // must not change the already opened download's bytes.
+ await store.write({ ...target(), path: "large.bin", bytes: Buffer.from("replacement"), baseHash: restored!.hash }, board());
+ const hash = createHash("sha256");
+ let downloaded = 0;
+ for await (const chunk of download.stream) { downloaded += chunk.length; hash.update(chunk); }
+ expect(downloaded).toBe(size);
+ expect(hash.digest("hex")).toBe(restored!.hash);
+ expect(await fs.readFile(path.join(root, "large.bin"), "utf8")).toBe("replacement");
+ await store.write({ ...target(), path: "empty.bin", bytes: Buffer.alloc(0), baseHash: null }, board());
+ const empty = (await store.download(companyId, agentId, "empty.bin", board()))!;
+ expect(empty.size).toBe(0);
+ for await (const _chunk of empty.stream) throw new Error("Empty download must have no chunks");
+ }, 30_000);
+
+ it("accepts the exact 256 MiB file boundary without retaining a text buffer", async () => {
+ expect(MAX_AGENT_FILE_BYTES).toBe(256 * 1024 * 1024);
+ await sparseFile(path.join(root, "boundary.bin"), MAX_AGENT_FILE_BYTES);
+ expect(await inspectAgentFile(root, "boundary.bin")).toMatchObject({ size: MAX_AGENT_FILE_BYTES, bytes: null });
+ });
+
+ it("checks quota for a small editor save without reading unrelated file bytes", async () => {
+ const asset = path.join(root, "large.bin");
+ await sparseFile(asset, MAX_AGENT_FILE_BYTES);
+ const open = vi.spyOn(fs, "open");
+ try {
+ await agentFileStore(db).write({ ...target(), path: "note.txt", bytes: Buffer.from("small edit"), baseHash: null }, board());
+ expect(open.mock.calls.some(([filename]) => filename === asset)).toBe(false);
+ expect(await fs.readFile(path.join(root, "note.txt"), "utf8")).toBe("small edit");
+ } finally { open.mockRestore(); }
+ });
+
+ it("reports an oversized run file without a partial save and removes its temporary copy", async () => {
+ const copy = await run();
+ await sparseFile(path.join(copy.localRoot, "too-large.bin"), MAX_AGENT_FILE_BYTES + 1);
+ await fs.writeFile(path.join(copy.localRoot, entryFile), "changed instructions");
+ const failed = await copies.collectStopped({ companyId, runId: copy.runId });
+ expect(failed).toMatchObject({ state: "unavailable", errorCode: "AGENT_FILES_LIMIT_EXCEEDED", candidateHash: null, nextAttemptAt: null, attempts: 1 });
+ expect(failed?.receipt?.storageWarning).toContain("Runs can continue");
+ expect(failed?.errorMessage).toContain('"too-large.bin" exceeds the 256 MiB');
+ expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
+ await expect(fs.stat(path.join(root, "too-large.bin"))).rejects.toMatchObject({ code: "ENOENT" });
+ expect((await copies.reportUnavailable(companyId, copy.runId))?.errorCode).toBe("AGENT_FILES_LIMIT_EXCEEDED");
+ await copies.release(companyId, copy.runId);
+ await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
+ expect((await copies.get(companyId, copy.runId))?.receipt?.baseline).toBeUndefined();
+ const next = await run();
+ expect(await fs.readFile(path.join(next.localRoot, entryFile), "utf8")).toBe(initial);
+ await fs.writeFile(path.join(next.localRoot, "next-task.txt"), "still working");
+ expect((await copies.collectStopped({ companyId, runId: next.runId }))?.state).toBe("saved");
+ expect(await fs.readFile(path.join(root, "next-task.txt"), "utf8")).toBe("still working");
+ });
+
+ it("warns on each run at the file limit and clears the warning after ordinary agent cleanup", async () => {
+ await sparseFile(path.join(root, "full.bin"), MAX_AGENT_FILE_BYTES);
+ const first = await run();
+ expect(first.receipt?.storageWarning).toContain("256 MiB");
+ expect(instructionWorkingCopyGuidance(first)).toContain("Runs can continue");
+ const unchanged = await copies.collectStopped({ companyId, runId: first.runId });
+ expect(unchanged).toMatchObject({ state: "unchanged", errorCode: null });
+ expect(unchanged?.receipt?.storageWarning).toContain("Agent storage is full");
+ copies = agentInstructionWorkingCopyService(db);
+ const second = await run();
+ expect(second.receipt?.storageWarning).toContain("Agent storage is full");
+ await fs.unlink(path.join(second.localRoot, "full.bin"));
+ await fs.writeFile(path.join(second.localRoot, "task-output.txt"), "work continues");
+ expect(await copies.collectStopped({ companyId, runId: second.runId })).toMatchObject({ state: "saved", receipt: { storageWarning: null } });
+ const next = await run();
+ expect(next.receipt?.storageWarning).toBeNull();
+ expect(await fs.readFile(path.join(next.localRoot, "task-output.txt"), "utf8")).toBe("work continues");
+ }, 30_000);
+
+ it("starts successive runs with an already oversized saved file and lets the agent remove it", async () => {
+ await sparseFile(path.join(root, "old-large.bin"), MAX_AGENT_FILE_BYTES + 1);
+ const first = await run();
+ expect(first.state).toBe("prepared");
+ expect(first.receipt?.storageWarning).toContain("256 MiB");
+ expect(await fs.readFile(path.join(first.localRoot, entryFile), "utf8")).toBe(initial);
+ const stopped = await copies.collectStopped({ companyId, runId: first.runId });
+ expect(stopped).toMatchObject({ state: "unavailable", errorCode: "AGENT_FILES_LIMIT_EXCEEDED" });
+ await expect(fs.stat(first.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
+ copies = agentInstructionWorkingCopyService(db);
+ const cleanup = await run();
+ expect(cleanup.state).toBe("prepared");
+ await fs.unlink(path.join(cleanup.localRoot, "old-large.bin"));
+ expect(await copies.collectStopped({ companyId, runId: cleanup.runId })).toMatchObject({ state: "saved", receipt: { storageWarning: null } });
+ expect((await run()).receipt?.storageWarning).toBeNull();
+ }, 30_000);
+
+ it("starts when the existing folder exceeds its total quota and accepts agent cleanup", async () => {
+ for (let index = 0; index < 8; index++) await fs.writeFile(path.join(root, `quota-part-${index}.bin`), "fixture");
+ const lstat = fs.lstat.bind(fs);
+ // Keep this regression small on disk while exercising the real filesystem,
+ // snapshots, database receipts, restore, and merge with 2 GiB of metadata.
+ const sizes = vi.spyOn(fs, "lstat").mockImplementation(async (...args: Parameters) => {
+ const stat = await lstat(...args);
+ if (path.basename(String(args[0])).startsWith("quota-part-")) Object.assign(stat, { size: MAX_AGENT_FILE_BYTES });
+ return stat;
+ });
+ try {
+ const first = await run();
+ expect(first.receipt?.storageWarning).toContain("2 GiB");
+ expect((await copies.collectStopped({ companyId, runId: first.runId }))?.errorCode).toBe("AGENT_FILES_LIMIT_EXCEEDED");
+ const cleanup = await run();
+ for (let index = 0; index < 8; index++) await fs.unlink(path.join(cleanup.localRoot, `quota-part-${index}.bin`));
+ await fs.writeFile(path.join(cleanup.localRoot, "small.txt"), "recovered");
+ expect(await copies.collectStopped({ companyId, runId: cleanup.runId })).toMatchObject({ state: "saved", receipt: { storageWarning: null } });
+ expect((await run()).receipt?.storageWarning).toBeNull();
+ } finally { sizes.mockRestore(); }
+ });
+
+ it("rejects more than 2 GiB in aggregate before hashing or saving any file", async () => {
+ expect(MAX_AGENT_DIRECTORY_BYTES).toBe(2 * 1024 * 1024 * 1024);
+ const copy = await run();
+ for (let index = 0; index < 8; index++) await sparseFile(path.join(copy.localRoot, `part-${index}.bin`), MAX_AGENT_FILE_BYTES);
+ // The eight allowed files exactly fill the quota; the entry is additional.
+ const result = await copies.collectStopped({ companyId, runId: copy.runId });
+ expect(result).toMatchObject({ state: "unavailable", errorCode: "AGENT_FILES_LIMIT_EXCEEDED" });
+ expect(result?.errorMessage).toContain("2 GiB total storage limit");
+ expect(await fs.readdir(root)).toEqual(["policy"]);
+ });
+
+ it("rejects excessive entry count before snapshotting", async () => {
+ const directory = path.join(home, "many-entries");
+ await fs.mkdir(directory);
+ await fs.writeFile(path.join(directory, "empty"), "");
+ const entries = await fs.readdir(directory, { withFileTypes: true });
+ // Exercise the walker bound without creating 100,001 physical files.
+ const readdir = vi.spyOn(fs, "readdir").mockResolvedValue(Array(MAX_AGENT_DIRECTORY_ENTRIES + 1).fill(entries[0]));
+ try {
+ await expect(snapshotAgentFiles(directory)).rejects.toMatchObject({ status: 422, message: expect.stringContaining("100,000-entry limit") });
+ } finally { readdir.mockRestore(); }
+ }, 30_000);
+
+ it("keeps the entry's 1 MiB limit and cleans up failed synchronization", async () => {
+ const copy = await run();
+ await fs.writeFile(path.join(copy.localRoot, entryFile), "a".repeat(1024 * 1024 + 1));
+ const result = await copies.collectStopped({ companyId, runId: copy.runId });
+ expect(result?.state).toBe("unavailable");
+ await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
+ expect(result?.errorMessage).toContain("at most 1 MiB");
+ expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
+ });
+
+ it("merges independent changes and uses the last synchronization for same-file edits", async () => {
+ const a = await run(), b = await run();
+ await fs.writeFile(path.join(a.localRoot, "a.txt"), "A");
+ await fs.writeFile(path.join(b.localRoot, "b.txt"), "B");
+ await Promise.all([a, b].map(copy => copies.collectStopped({ companyId, runId: copy.runId })));
+ expect(await fs.readFile(path.join(root, "a.txt"), "utf8")).toBe("A");
+ expect(await fs.readFile(path.join(root, "b.txt"), "utf8")).toBe("B");
+ const c = await run(), d = await run();
+ await fs.writeFile(path.join(c.localRoot, "a.txt"), "C");
+ await fs.writeFile(path.join(d.localRoot, "a.txt"), "D");
+ await fs.writeFile(path.join(c.localRoot, "b.txt"), "new B");
+ expect((await copies.collectStopped({ companyId, runId: c.runId }))?.state).toBe("saved");
+ expect((await copies.collectStopped({ companyId, runId: d.runId }))?.state).toBe("saved");
+ expect(await fs.readFile(path.join(root, "a.txt"), "utf8")).toBe("D");
+ expect(await fs.readFile(path.join(root, "b.txt"), "utf8")).toBe("new B");
+ expect(await copies.list(companyId, agentId, board())).toEqual([]);
+ for (const copy of [a, b, c, d]) await expect(fs.stat(path.dirname(copy.localRoot))).rejects.toMatchObject({ code: "ENOENT" });
+ expect(await fs.readdir(path.join(path.dirname(root), "file-sync", "runs"))).toEqual([]);
+ });
+
+ it("makes concurrent edit/delete races last-sync-wins and preserves unrelated new files", async () => {
+ await fs.mkdir(path.join(root, "notes"));
+ await fs.writeFile(path.join(root, "notes", "old.txt"), "old");
+ const deleting = await run(), editing = await run();
+ await fs.rm(path.join(deleting.localRoot, "notes"), { recursive: true });
+ await fs.writeFile(path.join(editing.localRoot, "notes", "old.txt"), "edited");
+ await fs.writeFile(path.join(editing.localRoot, "notes", "new.txt"), "unrelated");
+ await copies.collectStopped({ companyId, runId: editing.runId });
+ expect((await copies.collectStopped({ companyId, runId: deleting.runId }))?.state).toBe("saved");
+ await expect(fs.stat(path.join(root, "notes", "old.txt"))).rejects.toMatchObject({ code: "ENOENT" });
+ expect(await fs.readFile(path.join(root, "notes", "new.txt"), "utf8")).toBe("unrelated");
+ const remove = await run(), change = await run();
+ await fs.unlink(path.join(remove.localRoot, "notes", "new.txt"));
+ await fs.writeFile(path.join(change.localRoot, "notes", "new.txt"), "restored by later edit");
+ await copies.collectStopped({ companyId, runId: remove.runId });
+ await copies.collectStopped({ companyId, runId: change.runId });
+ expect(await fs.readFile(path.join(root, "notes", "new.txt"), "utf8")).toBe("restored by later edit");
+ });
+
+ it("handles a concurrently replaced parent and keeps the later file change", async () => {
+ await fs.mkdir(path.join(root, "notes"));
+ await fs.writeFile(path.join(root, "notes", "fact.txt"), "old");
+ const replacing = await run(), editing = await run();
+ await fs.rm(path.join(replacing.localRoot, "notes"), { recursive: true });
+ await fs.writeFile(path.join(replacing.localRoot, "notes"), "now a file");
+ await fs.writeFile(path.join(editing.localRoot, "notes", "fact.txt"), "new fact");
+ await copies.collectStopped({ companyId, runId: replacing.runId });
+ expect(await fs.readFile(path.join(root, "notes"), "utf8")).toBe("now a file");
+ expect((await copies.collectStopped({ companyId, runId: editing.runId }))?.state).toBe("saved");
+ expect(await fs.readFile(path.join(root, "notes", "fact.txt"), "utf8")).toBe("new fact");
+ });
+
+ it("cleans stopped copies after a crash during cleanup without touching active copies", async () => {
+ const finished = await run(), active = await run();
+ await db.update(agentInstructionWorkingCopies).set({ state: "saved", processStoppedAt: new Date() }).where(eq(agentInstructionWorkingCopies.runId, finished.runId));
+ copies = agentInstructionWorkingCopyService(db);
+ await copies.recoverCaptured();
+ await expect(fs.stat(finished.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
+ expect((await fs.stat(active.localRoot)).isDirectory()).toBe(true);
+ });
+
+ it("cleans up a staging failure before any provider starts", async () => {
+ const runId = randomUUID();
+ await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
+ const shell = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 1, signal: null, timedOut: false, stdout: "", stderr: "fixture failure" });
+ try {
+ await expect(copies.prepare({ ...target(), runId, cwd: home, target: { kind: "remote", transport: "ssh", environmentId: randomUUID(), remoteCwd: "/fixture/task",
+ spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd: "/fixture/task" } } })).rejects.toThrow("Could not exclude");
+ const row = (await copies.get(companyId, runId))!;
+ expect(row.state).toBe("unavailable");
+ expect(row.receipt?.baseline).toBeUndefined();
+ await expect(fs.stat(path.dirname(row.localRoot))).rejects.toMatchObject({ code: "ENOENT" });
+ } finally { shell.mockRestore(); }
+ });
+
+ it.each(["succeeded", "failed", "cancelled", "timed_out", "interrupted"])("registers staging ownership before copying and cleans preparation for a %s run after restart", async (status) => {
+ const original = fs.cp.bind(fs);
+ const copy = vi.spyOn(fs, "cp").mockImplementationOnce(async (source, destination, options) => {
+ const rows = await db.select().from(agentInstructionWorkingCopies).where(eq(agentInstructionWorkingCopies.agentId, agentId));
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({ state: "preparing", localRoot: destination });
+ return original(source, destination, options);
+ });
+ const interrupted = await run();
+ copy.mockRestore();
+ const active = await run();
+ for (const row of [interrupted, active]) {
+ await db.update(agentInstructionWorkingCopies).set({ state: "preparing", baseHash: "preparing", receipt: { schema: "paperclip.agent-files.v1" } }).where(eq(agentInstructionWorkingCopies.runId, row.runId));
+ }
+ await db.update(heartbeatRuns).set({ status }).where(eq(heartbeatRuns.id, interrupted.runId));
+ copies = agentInstructionWorkingCopyService(db);
+ await copies.recoverStopped();
+ expect((await copies.get(companyId, interrupted.runId))?.state).toBe("unavailable");
+ await expect(fs.stat(path.dirname(interrupted.localRoot))).rejects.toMatchObject({ code: "ENOENT" });
+ expect((await fs.stat(active.localRoot)).isDirectory()).toBe(true);
+ });
+
+ it("retains remote cleanup across restart and failed retries until the original lease is cleaned", async () => {
+ const copy = await run();
+ const environmentId = randomUUID(), leaseId = randomUUID(), remoteCwd = "/fixture/task";
+ const executionRoot = path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", agentId, copy.runId);
+ await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox" });
+ await db.insert(environmentLeases).values({ id: leaseId, companyId, environmentId, heartbeatRunId: copy.runId, provider: "daytona", providerLeaseId: "original-sandbox" });
+ await db.update(agentInstructionWorkingCopies).set({ state: "saved", processStoppedAt: new Date(), location: `remote:${environmentId}`, executionRoot,
+ receipt: { ...copy.receipt, cleanup: { leaseId, remoteCwd } } }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
+ copies = agentInstructionWorkingCopyService(db);
+ await copies.recoverCaptured();
+ const pending = (await copies.get(companyId, copy.runId))!;
+ expect(pending.receipt).toMatchObject({ cleanupPending: true, cleanup: { leaseId, remoteCwd } });
+ expect(pending.receipt?.baseline).toBeUndefined();
+ await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
+ const execute = vi.fn().mockRejectedValueOnce(new Error("provider temporarily unavailable")).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" });
+ copies = agentInstructionWorkingCopyService(db, { environmentRuntime: { execute } as unknown as EnvironmentRuntimeService });
+ for (const expectedPending of [true, false]) {
+ await db.update(agentInstructionWorkingCopies).set({ nextAttemptAt: null }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
+ await copies.recoverCaptured();
+ expect((await copies.get(companyId, copy.runId))?.receipt?.cleanupPending).toBe(expectedPending);
+ }
+ expect(execute).toHaveBeenCalledTimes(2);
+ expect(execute).toHaveBeenLastCalledWith(expect.objectContaining({ lease: expect.objectContaining({ id: leaseId, providerLeaseId: "original-sandbox" }),
+ command: "rm", args: ["-rf", "--", executionRoot], bypassSession: true }));
+ await copies.recoverCaptured();
+ expect(execute).toHaveBeenCalledTimes(2);
+ await copies.release(companyId, copy.runId);
+ expect((await copies.get(companyId, copy.runId))?.receipt?.cleanupPending).toBe(false);
+ expect(execute).toHaveBeenCalledTimes(2);
+ });
+
+ it("finishes remote cleanup from a destruction receipt after the environment is deleted", async () => {
+ const copy = await run();
+ const environmentId = randomUUID(), leaseId = randomUUID(), remoteCwd = "/fixture/task";
+ const lease = { id: leaseId, companyId, environmentId, heartbeatRunId: copy.runId, provider: "daytona", providerLeaseId: "destroyed-sandbox" };
+ await db.insert(environments).values({ id: environmentId, name: environmentId, driver: "sandbox" });
+ await db.insert(environmentLeases).values({ ...lease, status: "released", releasedAt: new Date(), cleanupStatus: "success",
+ metadata: { remoteExecutionTermination: remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: "destroyed" }) } });
+ await db.update(agentInstructionWorkingCopies).set({ state: "saved", processStoppedAt: new Date(), location: `remote:${environmentId}`,
+ executionRoot: path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", agentId, copy.runId),
+ receipt: { ...copy.receipt, cleanupPending: true, cleanup: { leaseId, remoteCwd } } }).where(eq(agentInstructionWorkingCopies.runId, copy.runId));
+ await db.delete(environments).where(eq(environments.id, environmentId));
+ copies = agentInstructionWorkingCopyService(db);
+ await copies.recoverCaptured();
+ expect((await copies.get(companyId, copy.runId))?.receipt?.cleanupPending).toBe(false);
+ await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
+ });
+
+ it.each([false, true])("rejects symlinks without saving any part of the tree, preserving an existing full-storage warning: %s", async (full) => {
+ if (full) await sparseFile(path.join(root, "full.bin"), MAX_AGENT_FILE_BYTES);
+ const copy = await run();
+ await fs.writeFile(path.join(copy.localRoot, "innocent.txt"), "changed");
+ await fs.symlink(path.join(home, "outside"), path.join(copy.localRoot, "escape"));
+ const result = await copies.collectStopped({ companyId, runId: copy.runId });
+ expect(result?.state).toBe("unavailable");
+ expect(result?.errorCode).toBe("AGENT_FILES_SAVE_FAILED");
+ expect(result?.receipt?.storageWarning).toBe(copy.receipt?.storageWarning);
+ if (full) expect(result?.receipt?.storageWarning).toContain("Agent storage is full");
+ await expect(fs.stat(copy.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
+ await expect(fs.stat(path.join(root, "innocent.txt"))).rejects.toMatchObject({ code: "ENOENT" });
+ }, 30_000);
+
+ it("adopts the last deployed head once and bridges an old collector without appending history", async () => {
+ const id = randomUUID();
+ const content = "last deployed revision";
+ await db.insert(agentInstructionRevisions).values({ id, ...target(), entryFile, contentBase64: Buffer.from(content).toString("base64"), contentHash: fileHash(Buffer.from(content)), byteLength: content.length, source: "board" });
+ await db.insert(agentInstructionHeads).values({ ...target(), entryFile, revisionId: id });
+ const current = await revisions.readCurrent(target(), board());
+ expect(current?.content).toBe(content);
+ await revisions.commit({ ...target(), entryFile, content: "new directory contents", baseRevisionId: id, source: "cleanup" }, board());
+ await revisions.materializeCurrent(target());
+ expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe("new directory contents");
+ await expect(revisions.commit({ ...target(), entryFile, content: "stale legacy run", baseRevisionId: id, source: "cleanup" }, board())).rejects.toMatchObject({ status: 409 });
+ expect(await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, agentId))).toHaveLength(1);
+ });
+
+ it("ordinary file edits leave the loaded instruction digest unchanged", async () => {
+ const make = async () => {
+ const copy = await run();
+ const context = await buildNativeRuntimeContext({ db, agent: { id: agentId, companyId, name: "Target", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: entryFile } },
+ runId: copy.runId, runtimeConfig: {}, runtimeSkillEntries: [], instructionWorkingCopy: { rootPath: copy.executionRoot, entryPath: entryFile, kind: "agent_files" } });
+ return { copy, context };
+ };
+ const before = await make();
+ await fs.writeFile(path.join(before.copy.localRoot, "notes.txt"), "new personal knowledge");
+ await copies.collectStopped({ companyId, runId: before.copy.runId });
+ const after = await make();
+ expect(after.context.aggregateDigest).toBe(before.context.aggregateDigest);
+ expect(after.context.instructions.bundle.fileCount).toBe(1);
+ });
+ it("uses the workspace transport to restore after destruction of the remote filesystem", async () => {
+ const remoteCwd = path.join(home, "remote-task");
+ await fs.mkdir(remoteCwd, { recursive: true });
+ await execFile("git", ["init", remoteCwd]);
+ const runner: import("@paperclipai/adapter-utils/command-managed-runtime").CommandManagedRuntimeRunner = {
+ execute: async input => {
+ const startedAt = new Date().toISOString();
+ const env = { ...process.env, ...input.env };
+ const args = [...(input.args ?? [])];
+ if (input.stdin != null && (args[0] === "-c" || args[0] === "-lc")) {
+ env.PAPERCLIP_TEST_STDIN = input.stdin;
+ args[1] = `printf '%s' "$PAPERCLIP_TEST_STDIN" | (${args[1]})`;
+ }
+ try {
+ const result = await execFile(input.command, args, { cwd: input.cwd, env, timeout: input.timeoutMs, maxBuffer: 32 * 1024 * 1024 });
+ return { exitCode: 0, signal: null, timedOut: false, stdout: result.stdout, stderr: result.stderr, pid: null, startedAt };
+ } catch (error) {
+ const e = error as { code?: number; signal?: NodeJS.Signals; stdout?: string; stderr?: string };
+ return { exitCode: typeof e.code === "number" ? e.code : 1, signal: e.signal ?? null, timedOut: false, stdout: e.stdout ?? "", stderr: e.stderr ?? "", pid: null, startedAt };
+ }
+ },
+ };
+ const executionTarget = { kind: "remote" as const, transport: "sandbox" as const, environmentId: randomUUID(), remoteCwd, runner };
+ const prepare = async () => {
+ const runId = randomUUID();
+ await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
+ return (await copies.prepare({ ...target(), runId, cwd: home, target: executionTarget }))!;
+ };
+ await fs.mkdir(path.join(root, "build"));
+ await fs.writeFile(path.join(root, "build", "personal.txt"), "cache-like names are still agent files");
+ const first = await prepare();
+ expect(await fs.readFile(path.join(first.executionRoot, "build", "personal.txt"), "utf8")).toBe("cache-like names are still agent files");
+ await fs.mkdir(path.join(first.executionRoot, "notes"));
+ await fs.writeFile(path.join(first.executionRoot, "notes", "bytes.bin"), Buffer.from([0, 128, 255]));
+ await fs.mkdir(path.join(first.executionRoot, "node_modules"));
+ await fs.writeFile(path.join(first.executionRoot, "node_modules", "personal.txt"), "retain this too");
+ await fs.writeFile(path.join(remoteCwd, "task-only.txt"), "task");
+ expect((await execFile("git", ["-C", remoteCwd, "status", "--porcelain", "--untracked-files=all"])).stdout).toBe("?? task-only.txt\n");
+ expect((await copies.collectStopped({ companyId, runId: first.runId, target: executionTarget }))?.state).toBe("saved");
+ await copies.release(companyId, first.runId);
+ expect((await copies.get(companyId, first.runId))?.receipt?.baseline).toBeUndefined();
+ await expect(fs.stat(first.localRoot)).rejects.toMatchObject({ code: "ENOENT" });
+ await fs.rm(remoteCwd, { recursive: true });
+ await fs.mkdir(remoteCwd);
+ copies = agentInstructionWorkingCopyService(db);
+ const second = await prepare();
+ expect(await fs.readFile(path.join(second.executionRoot, "notes", "bytes.bin"))).toEqual(Buffer.from([0,128,255]));
+ expect(await fs.readFile(path.join(second.executionRoot, "node_modules", "personal.txt"), "utf8")).toBe("retain this too");
+ await expect(fs.stat(path.join(second.executionRoot, "task-only.txt"))).rejects.toMatchObject({ code: "ENOENT" });
+ await copies.collectStopped({ companyId, runId: second.runId, target: executionTarget });
+ await copies.release(companyId, second.runId);
+ });
+
+ it("stages SSH agent files at the registered root without a nested task workspace", async () => {
+ const remoteCwd = path.join(home, "ssh-task");
+ const exclude = vi.spyOn(executionTargetTools, "runAdapterExecutionTargetShellCommand").mockResolvedValue({ exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "" });
+ const stage = vi.spyOn(ssh, "syncDirectoryToSsh").mockImplementation(async input => {
+ await fs.mkdir(path.dirname(input.remoteDir), { recursive: true });
+ await fs.cp(input.localDir, input.remoteDir, { recursive: true });
+ });
+ const restore = vi.spyOn(ssh, "restoreWorkspaceFromSshExecution").mockImplementation(async input => {
+ await fs.cp(input.remoteDir!, input.localDir, { recursive: true });
+ });
+ try {
+ const runId = randomUUID();
+ await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
+ const target = { kind: "remote" as const, transport: "ssh" as const, environmentId: randomUUID(), remoteCwd,
+ spec: { host: "unused.invalid", port: 22, username: "test", remoteCwd } };
+ const copy = (await copies.prepare({ companyId, agentId, runId, cwd: home, target }))!;
+ expect(stage).toHaveBeenCalledWith(expect.objectContaining({ remoteDir: copy.executionRoot }));
+ expect(await fs.readFile(path.join(copy.executionRoot, entryFile), "utf8")).toBe(initial);
+ await fs.writeFile(path.join(copy.executionRoot, "ssh-note.txt"), "persistent SSH file");
+ expect((await copies.collectStopped({ companyId, runId, target }))?.state).toBe("saved");
+ expect(restore).toHaveBeenCalledWith(expect.objectContaining({ remoteDir: copy.executionRoot, restoreGitHistory: false }));
+ expect(await fs.readFile(path.join(root, "ssh-note.txt"), "utf8")).toBe("persistent SSH file");
+ } finally { stage.mockRestore(); restore.mockRestore(); exclude.mockRestore(); }
+ });
+
+ it("deduplicates concurrent stopped callbacks and discards completed operational snapshots", async () => {
+ const copy = await run();
+ await fs.writeFile(path.join(copy.localRoot, "note.txt"), "one write");
+ const results = await Promise.all([copies.collectStopped({ companyId, runId: copy.runId }), copies.collectStopped({ companyId, runId: copy.runId })]);
+ expect(results.every(result => result?.state === "saved")).toBe(true);
+ // Simulate a crash after save but before release. The restart sweeper must
+ // reclaim operational baselines without touching canonical files.
+ copies = agentInstructionWorkingCopyService(db);
+ await copies.recoverCaptured();
+ const row = await copies.get(companyId, copy.runId);
+ expect(row?.receipt?.schema).toBe("paperclip.agent-files.v1");
+ expect(row?.receipt?.baseline).toBeUndefined();
+ expect(await fs.readFile(path.join(root, "note.txt"), "utf8")).toBe("one write");
+ });
+
+});
diff --git a/server/src/__tests__/agent-instruction-git-exclusion.test.ts b/server/src/__tests__/agent-instruction-git-exclusion.test.ts
new file mode 100644
index 00000000000..73f81d4c92f
--- /dev/null
+++ b/server/src/__tests__/agent-instruction-git-exclusion.test.ts
@@ -0,0 +1,87 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { execFile as execFileCallback } from "node:child_process";
+import { promisify } from "node:util";
+import { afterEach, beforeEach, expect, it } from "vitest";
+import { instructionGitExcludeProgram } from "../services/agent-instruction-files.js";
+
+const execFile = promisify(execFileCallback);
+let root: string;
+beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), "agent-git-exclusion-")); });
+afterEach(async () => { await fs.rm(root, { recursive: true, force: true }); });
+const git = (cwd: string, ...args: string[]) => execFile("git", ["-C", cwd, ...args]);
+const exclude = (cwd: string) => execFile(process.execPath, ["-e", instructionGitExcludeProgram, cwd]);
+
+async function assertPrivateFilesIgnored(cwd: string) {
+ const runtime = path.join(cwd, ".paperclip-runtime");
+ await fs.writeFile(path.join(runtime, "private.md"), "agent instructions");
+ await git(cwd, "add", "-A");
+ expect((await git(cwd, "diff", "--cached", "--name-only")).stdout).not.toContain(".paperclip-runtime");
+ expect((await git(cwd, "check-ignore", "--", ".paperclip-runtime/private.md", ".paperclip-runtime/.gitignore")).stdout.trim().split("\n")).toHaveLength(2);
+}
+
+it("excludes runtime files without changing tracked ignore rules or Git metadata", async () => {
+ await git(root, "init");
+ await fs.writeFile(path.join(root, ".gitignore"), "keep-me\n");
+ const metadata = path.join(root, ".git", "info", "exclude");
+ const before = await fs.readFile(metadata, "utf8");
+ await exclude(root);
+ await exclude(root);
+ await assertPrivateFilesIgnored(root);
+ expect(await fs.readFile(path.join(root, ".gitignore"), "utf8")).toBe("keep-me\n");
+ expect(await fs.readFile(metadata, "utf8")).toBe(before);
+});
+
+it("never writes to an external gitdir selected by task-controlled metadata", async () => {
+ const workspace = path.join(root, "workspace"), outside = path.join(root, "outside.git");
+ await fs.mkdir(workspace);
+ await git(workspace, "init", `--separate-git-dir=${outside}`);
+ const metadata = path.join(outside, "info", "exclude");
+ const before = await fs.readFile(metadata, "utf8");
+ await exclude(workspace);
+ await assertPrivateFilesIgnored(workspace);
+ expect(await fs.readFile(metadata, "utf8")).toBe(before);
+});
+
+it("supports linked worktrees and nested task directories without changing the common gitdir", async () => {
+ const repository = path.join(root, "repository"), worktree = path.join(root, "worktree");
+ await fs.mkdir(repository);
+ await git(repository, "init");
+ await git(repository, "-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "--allow-empty", "-m", "initial");
+ await git(repository, "worktree", "add", "--detach", worktree);
+ const nested = path.join(worktree, "nested");
+ await fs.mkdir(nested);
+ const metadata = path.join(repository, ".git", "info", "exclude");
+ const before = await fs.readFile(metadata, "utf8");
+ await exclude(nested);
+ await assertPrivateFilesIgnored(nested);
+ expect(await fs.readFile(metadata, "utf8")).toBe(before);
+});
+
+it.each(["directory", "file"])("rejects a symlink at the runtime exclusion %s", async (kind) => {
+ const workspace = path.join(root, "workspace"), outside = path.join(root, "outside");
+ await fs.mkdir(workspace);
+ await fs.mkdir(outside);
+ const externalFile = path.join(outside, ".gitignore");
+ await fs.writeFile(externalFile, "untouched\n");
+ const runtime = path.join(workspace, ".paperclip-runtime");
+ if (kind === "directory") await fs.symlink(outside, runtime);
+ else {
+ await fs.mkdir(runtime);
+ await fs.symlink(externalFile, path.join(runtime, ".gitignore"));
+ }
+ await expect(exclude(workspace)).rejects.toThrow("Unsafe runtime exclusion");
+ expect(await fs.readFile(externalFile, "utf8")).toBe("untouched\n");
+});
+
+it("replaces a linked ignore file without writing through its external inode", async () => {
+ const externalFile = path.join(root, "outside-ignore"), workspace = path.join(root, "workspace");
+ const runtime = path.join(workspace, ".paperclip-runtime");
+ await fs.mkdir(runtime, { recursive: true });
+ await fs.writeFile(externalFile, "untouched\n");
+ await fs.link(externalFile, path.join(runtime, ".gitignore"));
+ await exclude(workspace);
+ expect(await fs.readFile(externalFile, "utf8")).toBe("untouched\n");
+ expect(await fs.readFile(path.join(runtime, ".gitignore"), "utf8")).toBe("*\n");
+});
diff --git a/server/src/__tests__/agent-instruction-revisions.test.ts b/server/src/__tests__/agent-instruction-revisions.test.ts
new file mode 100644
index 00000000000..6d2a516cb2c
--- /dev/null
+++ b/server/src/__tests__/agent-instruction-revisions.test.ts
@@ -0,0 +1,247 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { randomUUID } from "node:crypto";
+import { beforeAll, afterAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { and, eq, sql } from "drizzle-orm";
+import { agents, agentApiKeys, companies, authUsers, companyMemberships, principalPermissionGrants, heartbeatRuns,
+ agentInstructionRevisions, agentInstructionHeads, issueThreadInteractions, issues, createDb } from "@paperclipai/db";
+import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
+import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
+import { agentInstructionWorkingCopyService } from "../services/agent-instruction-working-copies.js";
+import { instructionBytes, instructionPath, materializeInstructionBytes, readInstructionBytes } from "../services/agent-instruction-files.js";
+import { agentInstructionsService, resolveManagedInstructionsRoot } from "../services/agent-instructions.js";
+import type { AuthorizationActor } from "../services/authorization.js";
+import { upsertAgentInstructionsFileSchema } from "@paperclipai/shared";
+
+describe("canonical instruction revisions", () => {
+ let database: Awaited>;
+ let db: ReturnType;
+ let service: ReturnType;
+ const previousHome = process.env.PAPERCLIP_HOME;
+ let home: string;
+ let companyId: string, agentId: string, userId: string, actorId: string, runId: string, root: string;
+ let actor: AuthorizationActor;
+ const entryFile = "policy/INSTRUCTIONS.txt";
+ const initial = "\uFEFF# Original\r\n☃\0\n";
+ beforeAll(async () => {
+ home = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "instruction-revisions-")));
+ process.env.PAPERCLIP_HOME = home;
+ database = await startEmbeddedPostgresTestDatabase("instruction-revisions-db-");
+ db = createDb(database.connectionString);
+ service = agentInstructionRevisionService(db);
+ }, 90_000);
+ afterAll(async () => {
+ vi.restoreAllMocks();
+ if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
+ await database?.cleanup();
+ if (home) await fs.rm(home, { recursive: true, force: true });
+ });
+ beforeEach(async () => {
+ companyId = randomUUID(); agentId = randomUUID(); actorId = randomUUID(); userId = randomUUID(); runId = randomUUID();
+ await db.insert(companies).values({ id: companyId, name: "Instruction tests", issuePrefix: randomUUID().slice(0, 8) });
+ await db.insert(authUsers).values({ id: userId, name: "Editor", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
+ root = resolveManagedInstructionsRoot({ id: agentId, companyId, name: "Target", adapterConfig: {} });
+ await db.insert(agents).values([
+ { id: agentId, companyId, name: "Target", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: entryFile } },
+ { id: actorId, companyId, name: "Writer" },
+ ]);
+ await db.insert(companyMemberships).values([
+ { companyId, principalType: "user", principalId: userId, membershipRole: "operator" },
+ { companyId, principalType: "agent", principalId: actorId, membershipRole: "member" },
+ ]);
+ await db.insert(principalPermissionGrants).values([
+ { companyId, principalType: "user", principalId: userId, permissionKey: "agents:configure", scope: { agentIds: [agentId] } },
+ { companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:configure", scope: { agentIds: [agentId] } },
+ ]);
+ await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId: actorId, invocationSource: "on_demand", responsibleUserId: userId });
+ actor = { type: "agent", companyId, agentId: actorId, runId, source: "agent_jwt" };
+ await fs.mkdir(path.dirname(path.join(root, entryFile)), { recursive: true });
+ await fs.writeFile(path.join(root, entryFile), initial);
+ });
+ const target = () => ({ companyId, agentId });
+ async function base() { return (await service.readCurrent(target(), actor))!; }
+ async function save(content: string, baseRevisionId: string | null) {
+ return service.commit({ ...target(), entryFile, content, baseRevisionId, source: "cleanup" }, actor);
+ }
+ it.each(["current", "history", "revision", "diff", "candidates"])("denies peer %s reads without a configuration grant or responsible-user target access", async (operation) => {
+ const first = await base();
+ await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, userId));
+ const read = () => {
+ if (operation === "current") return service.readCurrent(target(), actor);
+ if (operation === "history") return service.history({ ...target(), entryFile }, actor);
+ if (operation === "revision") return service.readRevision({ ...target(), entryFile, revisionId: first.revision.id }, actor);
+ if (operation === "diff") return service.diff({ ...target(), entryFile, fromRevisionId: first.revision.id, toRevisionId: first.revision.id }, actor);
+ return agentInstructionWorkingCopyService(db).list(companyId, agentId, actor);
+ };
+ await expect(read()).rejects.toMatchObject({ status: 403 });
+ await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId, permissionKey: "agents:configure", scope: { agentIds: [actorId] } });
+ await expect(read()).rejects.toMatchObject({ status: 403 });
+ });
+ it.each(["current", "history", "revision", "diff", "candidates", "write"])("requires the caller's own target-scoped grant for peer %s access", async (operation) => {
+ const first = await base();
+ await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, actorId));
+ const access = () => {
+ if (operation === "current") return service.readCurrent(target(), actor);
+ if (operation === "history") return service.history({ ...target(), entryFile }, actor);
+ if (operation === "revision") return service.readRevision({ ...target(), entryFile, revisionId: first.revision.id }, actor);
+ if (operation === "diff") return service.diff({ ...target(), entryFile, fromRevisionId: first.revision.id, toRevisionId: first.revision.id }, actor);
+ if (operation === "write") return save("authorized peer edit", first.revision.id);
+ return agentInstructionWorkingCopyService(db).list(companyId, agentId, actor);
+ };
+ // The responsible user retains permission on the target throughout.
+ await expect(access()).rejects.toMatchObject({ status: 403 });
+ await db.insert(principalPermissionGrants).values({ companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:configure", scope: { agentIds: [actorId] } });
+ await expect(access()).rejects.toMatchObject({ status: 403 });
+ await db.update(principalPermissionGrants).set({ scope: { agentIds: [agentId] } }).where(eq(principalPermissionGrants.principalId, actorId));
+ // New saves have no historical revision row; authorized history lookups
+ // reach the normal not-found response rather than an authorization denial.
+ if (operation === "revision" || operation === "diff") await expect(access()).rejects.toMatchObject({ status: 404 });
+ else await expect(access()).resolves.toBeDefined();
+ });
+ it("allows self and board reads without requiring instruction edit permission", async () => {
+ await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, userId));
+ const selfRoot = resolveManagedInstructionsRoot({ id: actorId, companyId, name: "Writer", adapterConfig: {} });
+ await fs.mkdir(selfRoot, { recursive: true });
+ await fs.writeFile(path.join(selfRoot, "AGENTS.md"), "Self instructions");
+ expect((await service.readCurrent({ companyId, agentId: actorId }, actor))?.content).toBe("Self instructions");
+ expect((await service.readCurrent(target(), { type: "board", userId, source: "session" }))?.content).toBe(initial);
+ });
+ it("reads and replaces current bytes using content tokens without storing revisions", async () => {
+ const first = await base();
+ expect(first.content).toBe(initial);
+ const saved = await save("# Changed\r\n", first.revision.id);
+ expect(saved).toMatchObject({ changed: true, materialization: "current" });
+ expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(saved.content);
+ expect((await save(saved.content, first.revision.id)).changed).toBe(false);
+ expect((await service.history({ ...target(), entryFile }, actor)).revisions).toHaveLength(0);
+ expect(await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, agentId))).toHaveLength(0);
+ });
+ it("requires a matching initial content token and serializes competing saves", async () => {
+ await expect(save("without reading", null)).rejects.toMatchObject({ status: 409 });
+ const first = await base();
+ const results = await Promise.allSettled([save("one", first.revision.id), save("two", first.revision.id)]);
+ expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1);
+ expect(results.find(result => result.status === "rejected")).toMatchObject({ reason: { status: 409 } });
+ });
+ it("does not claim a save when atomic file replacement fails", async () => {
+ const first = await base();
+ const rename = vi.spyOn(fs, "rename").mockRejectedValueOnce(new Error("interrupted replacement"));
+ await expect(save("new content", first.revision.id)).rejects.toThrow("interrupted replacement");
+ rename.mockRestore();
+ expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
+ service = agentInstructionRevisionService(db);
+ await service.materializeCurrent(target());
+ expect((await base()).content).toBe(initial);
+ expect((await save("new content", first.revision.id)).content).toBe("new content");
+ });
+ it("allows authorized peer edits but rejects missing, forged and revoked responsible identity", async () => {
+ const first = await base();
+ await save("peer save", first.revision.id);
+ await expect(service.commit({ ...target(), entryFile, content: "forged", baseRevisionId: first.revision.id, source: "tool" }, { ...actor, onBehalfOfUserId: "forged" })).rejects.toMatchObject({ status: 403 });
+ await db.update(heartbeatRuns).set({ responsibleUserId: null }).where(eq(heartbeatRuns.id, runId));
+ await expect(save("missing", first.revision.id)).rejects.toMatchObject({ status: 403 });
+ await db.update(heartbeatRuns).set({ responsibleUserId: userId }).where(eq(heartbeatRuns.id, runId));
+ await db.update(companyMemberships).set({ status: "suspended" }).where(and(eq(companyMemberships.principalId, userId), eq(companyMemberships.companyId, companyId)));
+ await expect(save("revoked", first.revision.id)).rejects.toMatchObject({ status: 403 });
+ });
+ it("rechecks user target scope and agent containment, even with shadow authorization enabled", async () => {
+ const first = await base();
+ const previousShadow = process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW;
+ process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW = "true";
+ await db.update(principalPermissionGrants).set({ scope: { agentIds: [actorId] } }).where(eq(principalPermissionGrants.principalId, userId));
+ await expect(save("denied", first.revision.id)).rejects.toMatchObject({ status: 403 });
+ await db.update(principalPermissionGrants).set({ scope: null }).where(eq(principalPermissionGrants.principalId, userId));
+ await db.update(agents).set({ permissions: { trustPreset: "low_trust_review" } }).where(eq(agents.id, actorId));
+ await expect(save("restricted", first.revision.id)).rejects.toMatchObject({ status: 403 });
+ if (previousShadow === undefined) delete process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW; else process.env.PAPERCLIP_RESPONSIBLE_USER_AUTHZ_SHADOW = previousShadow;
+ });
+ it("keeps suggest-only protected-change consent and explicit configure scope restrictions", async () => {
+ const first = await base();
+ await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, actorId));
+ await db.insert(principalPermissionGrants).values({ companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:suggest-changes" });
+ await expect(save("needs approval", first.revision.id)).rejects.toMatchObject({ status: 403 });
+ await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, actorId));
+ await db.insert(principalPermissionGrants).values({ companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:configure", scope: { agentIds: [actorId] } });
+ await expect(save("out of scope", first.revision.id)).rejects.toMatchObject({ status: 403 });
+ });
+ it("consumes accepted protected-change approval only with a successful CAS commit", async () => {
+ const first = await base();
+ const sourceRunId = randomUUID(), issueId = randomUUID(), interactionId = randomUUID();
+ await db.insert(heartbeatRuns).values({ id: sourceRunId, companyId, agentId: actorId, status: "succeeded" });
+ await db.insert(issues).values({ id: issueId, companyId, title: "Review instructions" });
+ await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, actorId));
+ await db.insert(principalPermissionGrants).values({ companyId, principalType: "agent", principalId: actorId, permissionKey: "agents:suggest-changes" });
+ await db.insert(issueThreadInteractions).values({ id: interactionId, companyId, issueId, kind: "request_confirmation", status: "accepted", sourceRunId, createdByAgentId: actorId,
+ payload: { version: 1, prompt: "Apply the instruction change?", detailsMarkdown: "```diff\n+approved\n```", target: { type: "custom", key: `agent:${agentId}:instructions`, revisionId: "proposal" } },
+ result: { version: 1, outcome: "accepted" }, resolvedByUserId: userId, resolvedAt: new Date(),
+ });
+ expect((await service.readCurrent(target(), actor))?.revision.id).toBe(first.revision.id);
+ expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0].result).not.toHaveProperty("consumedAt");
+ await expect(save("approved", randomUUID())).rejects.toMatchObject({ status: 409 });
+ expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0].result).not.toHaveProperty("consumedAt");
+ const receipt = await save("approved", first.revision.id);
+ expect(receipt.changed).toBe(true);
+ expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, interactionId)))[0].result).toMatchObject({ consumedByRunId: runId });
+ await expect(save("another unapproved change", receipt.revision.id)).rejects.toMatchObject({ status: 403 });
+ });
+ it("shares head/CAS and attribution between board and agent writers", async () => {
+ const first = await base();
+ const board = { type: "board", userId, source: "session" } as const;
+ const saved = await service.commit({ ...target(), entryFile, content: "board edit", baseRevisionId: first.revision.id, source: "board" }, board);
+ expect(saved.revision).toMatchObject({ actorUserId: userId, responsibleUserId: userId, actorAgentId: null, sourceRunId: null, source: "api" });
+ await expect(save("stale agent edit", first.revision.id)).rejects.toMatchObject({ status: 409 });
+ });
+ it("seeds a nested legacy managed entry and never imports a different AGENTS.md", async () => {
+ await fs.writeFile(path.join(root, "AGENTS.md"), "unrelated");
+ await db.update(agents).set({ adapterConfig: { instructionsFilePath: path.join(root, entryFile) } }).where(eq(agents.id, agentId));
+ const first = await base();
+ expect(first.content).toBe(initial);
+ expect(first.revision.entryFile).toBe(entryFile);
+ await db.update(agents).set({ adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: "missing.md" } }).where(eq(agents.id, agentId));
+ expect(await service.readCurrent(target(), actor)).toBeNull();
+ await expect(save("wrong entry", first.revision.id)).rejects.toMatchObject({ status: 409, details: { code: "INSTRUCTION_ENTRY_CHANGED" } });
+ });
+ it("rejects cross-company targets, foreign revisions, and revoked API keys", async () => {
+ const first = await base();
+ await expect(service.readRevision({ ...target(), entryFile, revisionId: randomUUID() }, actor)).rejects.toMatchObject({ status: 404 });
+ await expect(service.readCurrent({ companyId: randomUUID(), agentId }, actor)).rejects.toMatchObject({ status: 404 });
+ const keyId = randomUUID();
+ await db.insert(agentApiKeys).values({ id: keyId, companyId, agentId: actorId, name: "test", keyHash: randomUUID(), responsibleUserId: userId, revokedAt: new Date() });
+ await expect(service.commit({ ...target(), entryFile, content: "key", baseRevisionId: first.revision.id, source: "api" }, { ...actor, keyId })).rejects.toMatchObject({ status: 403 });
+ await expect(db.insert(agentInstructionHeads).values({ companyId: randomUUID(), agentId, entryFile, revisionId: first.revision.id })).rejects.toThrow();
+ });
+ it("rejects invalid content, symlinks, traversal and external bundles without changing canonical bytes", async () => {
+ const first = await base();
+ expect(() => instructionBytes("\ud800")).toThrow("UTF-8");
+ expect(() => instructionBytes(new Uint8Array([0xff]))).toThrow("UTF-8");
+ expect(() => instructionBytes("a".repeat(1024 * 1024 + 1))).toThrow("1 MiB");
+ for (const value of ["../AGENTS.md", "/etc/passwd", "a/../AGENTS.md", "a\\b", "a//b"]) expect(() => instructionPath(value)).toThrow();
+ await fs.unlink(path.join(root, entryFile));
+ await fs.symlink(path.join(home, "outside"), path.join(root, entryFile));
+ await expect(save("symlink", first.revision.id)).rejects.toMatchObject({ status: 422 });
+ await fs.unlink(path.join(root, entryFile));
+ await fs.rm(path.dirname(path.join(root, entryFile)), { recursive: true });
+ await fs.symlink(home, path.dirname(path.join(root, entryFile)));
+ await expect(readInstructionBytes(root, entryFile)).rejects.toMatchObject({ status: 422 });
+ await db.update(agents).set({ adapterConfig: { instructionsBundleMode: "external", instructionsRootPath: home } }).where(eq(agents.id, agentId));
+ await expect(save("external", first.revision.id)).rejects.toMatchObject({ details: { code: "INSTRUCTION_MANAGED_BUNDLE_REQUIRED" } });
+ expect((await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, agentId)))).toHaveLength(0);
+ });
+ it.skipIf(process.platform !== "darwin")("accepts macOS temporary directory aliases while rejecting links within storage", async () => {
+ const aliasRoot = home.replace(/^\/private\/(var|tmp)\//, "/$1/");
+ expect(aliasRoot).not.toBe(home);
+ await materializeInstructionBytes(aliasRoot, "alias/AGENTS.md", Buffer.from("persisted"));
+ expect(await readInstructionBytes(aliasRoot, "alias/AGENTS.md")).toEqual(Buffer.from("persisted"));
+ await fs.symlink(path.join(home, "alias"), path.join(home, "linked-agent"));
+ await expect(readInstructionBytes(path.join(aliasRoot, "linked-agent"), "AGENTS.md"))
+ .rejects.toMatchObject({ status: 422 });
+ });
+ it("requires the content API for the configured entry and rejects forged fields", async () => {
+ await base();
+ const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
+ await expect(agentInstructionsService(db).writeFile(agent, entryFile, "bypass")).rejects.toMatchObject({ details: { code: "INSTRUCTION_REVISION_REQUIRED" } });
+ await expect(agentInstructionsService(db).deleteFile(agent, entryFile)).rejects.toMatchObject({ status: 422 });
+ expect(upsertAgentInstructionsFileSchema.safeParse({ path: entryFile, content: "bad", responsibleUserId: userId }).success).toBe(false);
+ });
+});
diff --git a/server/src/__tests__/agent-instruction-working-copies.test.ts b/server/src/__tests__/agent-instruction-working-copies.test.ts
new file mode 100644
index 00000000000..1b6c9b2eb3a
--- /dev/null
+++ b/server/src/__tests__/agent-instruction-working-copies.test.ts
@@ -0,0 +1,345 @@
+import fs from "node:fs/promises";
+import { execFile as execFileCallback } from "node:child_process";
+import { promisify } from "node:util";
+import * as gitWorkspaceSync from "@paperclipai/adapter-utils/git-workspace-sync";
+import { DatabaseSync } from "node:sqlite";
+import { captureDirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge";
+const execFile = promisify(execFileCallback);
+import os from "node:os";
+import path from "node:path";
+import { randomUUID } from "node:crypto";
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { eq } from "drizzle-orm";
+import { agents, companies, authUsers, companyMemberships, principalPermissionGrants, heartbeatRuns, agentInstructionWorkingCopies, createDb } from "@paperclipai/db";
+import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
+import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
+import { agentInstructionWorkingCopyService } from "../services/agent-instruction-working-copies.js";
+import { appendHeartbeatRunEvent } from "../services/heartbeat-run-events.js";
+import { resolveManagedInstructionsRoot } from "../services/agent-instructions.js";
+import { buildNativeRuntimeContext } from "../services/native-runtime/runtime-context.js";
+
+describe("registered run instruction copies", () => {
+ let database: Awaited>;
+ let db: ReturnType;
+ let copies: ReturnType;
+ let revisions: ReturnType;
+ const previousHome = process.env.PAPERCLIP_HOME;
+ let home: string;
+ let companyId: string, agentId: string, userId: string, root: string;
+ const entryFile = "policy/INSTRUCTIONS.txt";
+ const initial = "\uFEFF# Original\r\n☃\n";
+ const target = () => ({ companyId, agentId });
+ const board = () => ({ type: "board" as const, userId, source: "session" as const });
+ async function run() {
+ const runId = randomUUID();
+ await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
+ return (await copies.prepare({ legacy: true, ...target(), runId, cwd: home }))!;
+ }
+ beforeAll(async () => {
+ home = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "instruction-working-copies-")));
+ process.env.PAPERCLIP_HOME = home;
+ database = await startEmbeddedPostgresTestDatabase("instruction-copies-db-");
+ db = createDb(database.connectionString);
+ copies = agentInstructionWorkingCopyService(db);
+ revisions = agentInstructionRevisionService(db);
+ }, 90_000);
+ afterAll(async () => {
+ if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
+ await database?.cleanup();
+ if (home) {
+ const writable = async (dir: string) => {
+ await fs.chmod(dir, 0o700);
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) if (entry.isDirectory()) await writable(path.join(dir, entry.name));
+ };
+ await writable(home);
+ await fs.rm(home, { recursive: true, force: true });
+ }
+ });
+ beforeEach(async () => {
+ companyId = randomUUID(); agentId = randomUUID(); userId = randomUUID();
+ await db.insert(companies).values({ id: companyId, name: "Instruction tests", issuePrefix: randomUUID().slice(0, 8) });
+ await db.insert(authUsers).values({ id: userId, name: "Editor", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
+ root = resolveManagedInstructionsRoot({ ...target(), id: agentId, name: "Target", adapterConfig: {} });
+ await db.insert(agents).values({ id: agentId, companyId, name: "Target", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: entryFile } });
+ await db.insert(companyMemberships).values([
+ { companyId, principalType: "user", principalId: userId, membershipRole: "operator" },
+ { companyId, principalType: "agent", principalId: agentId, membershipRole: "member" },
+ ]);
+ await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId, permissionKey: "agents:configure", scope: { agentIds: [agentId] } });
+ await fs.mkdir(path.dirname(path.join(root, entryFile)), { recursive: true });
+ await fs.writeFile(path.join(root, entryFile), initial);
+ });
+
+ it("persists an atomic editor replacement, ignores repository instructions, and gives the next run exact bytes", async () => {
+ const copy = await run();
+ await fs.writeFile(path.join(home, "AGENTS.md"), "Repository text must not become agent instructions");
+ const edited = "\uFEFF# Updated\r\nKeep this persistent. ☃\n";
+ const replacement = path.join(copy.localRoot, "edited.tmp");
+ await fs.writeFile(replacement, edited);
+ await fs.rename(replacement, path.join(copy.localRoot, entryFile));
+ expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
+ const saved = await copies.collectStopped({ companyId, runId: copy.runId });
+ expect(saved).toMatchObject({ state: "saved", candidateHash: expect.any(String), processStoppedAt: expect.any(Date) });
+ expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(edited);
+ copies = agentInstructionWorkingCopyService(db);
+ expect((await copies.collectStopped({ companyId, runId: copy.runId }))?.receipt).toEqual(saved?.receipt);
+ expect((await revisions.history({ ...target(), entryFile }, board())).revisions).toHaveLength(0);
+ const next = await run();
+ expect(await fs.readFile(path.join(next.localRoot, entryFile), "utf8")).toBe(edited);
+ });
+
+ it.each(["failed", "cancelled", "timed_out"])("retains edits from stopped %s runs without restarting execution", async (status) => {
+ const copy = await run();
+ await fs.writeFile(path.join(copy.localRoot, entryFile), status);
+ await db.update(heartbeatRuns).set({ status, finishedAt: new Date() }).where(eq(heartbeatRuns.id, copy.runId));
+ expect((await copies.collectStopped({ companyId, runId: copy.runId }))?.state).toBe("saved");
+ expect((await revisions.readCurrent(target(), board()))?.content).toBe(status);
+ const [unchangedRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, copy.runId));
+ expect(unchangedRun.status).toBe(status);
+ });
+
+ it.each(["saved", "unchanged"])("refreshes a stopped %s copy from the current canonical revision before retry", async (state) => {
+ const copy = await run();
+ if (state === "saved") await fs.writeFile(path.join(copy.localRoot, entryFile), "previous saved turn");
+ expect((await copies.collectStopped({ companyId, runId: copy.runId }))?.state).toBe(state);
+ const before = (await revisions.readCurrent(target(), board()))!;
+ const current = await revisions.commit({ ...target(), entryFile, baseRevisionId: before.revision.id, content: "new board instructions", source: "api" }, board());
+ const retried = (await copies.prepare({ legacy: true, ...target(), runId: copy.runId, cwd: home }))!;
+ expect(retried).toMatchObject({ state: "prepared", baseRevisionId: current.revision.id, baseHash: current.revision.contentHash, processStoppedAt: null });
+ expect(await fs.readFile(path.join(retried.localRoot, entryFile), "utf8")).toBe("new board instructions");
+ await fs.writeFile(path.join(retried.localRoot, entryFile), "retry adds a change");
+ expect((await copies.collectStopped({ companyId, runId: copy.runId }))?.state).toBe("saved");
+ });
+
+ it("never rebases uncollected private bytes or a live warm copy onto a newer head", async () => {
+ const stopped = await run();
+ await copies.collectStopped({ companyId, runId: stopped.runId });
+ await fs.writeFile(path.join(stopped.localRoot, entryFile), "uncollected private edit");
+ const warm = await run();
+ expect(await copies.hasChanges({ companyId, runId: warm.runId })).toBe(false);
+ const prior = (await revisions.readCurrent(target(), board()))!;
+ await revisions.commit({ ...target(), entryFile, baseRevisionId: prior.revision.id, content: "board change", source: "api" }, board());
+ for (const copy of [stopped, warm]) {
+ const retried = (await copies.prepare({ legacy: true, ...target(), runId: copy.runId, cwd: home }))!;
+ expect(retried.baseRevisionId).toBe(copy.baseRevisionId);
+ }
+ expect(await fs.readFile(path.join(stopped.localRoot, entryFile), "utf8")).toBe("uncollected private edit");
+ expect(await fs.readFile(path.join(warm.localRoot, entryFile), "utf8")).toBe(initial);
+ expect((await copies.collectStopped({ companyId, runId: stopped.runId }))?.state).toBe("conflict");
+ expect((await revisions.readCurrent(target(), board()))?.content).toBe("board change");
+ });
+
+ it.each(["before replacement", "after replacement"])("preserves the completed receipt when retry staging fails %s", async (point) => {
+ const copy = await run();
+ await fs.writeFile(path.join(copy.localRoot, entryFile), "completed turn");
+ const saved = (await copies.collectStopped({ companyId, runId: copy.runId }))!;
+ expect(saved.state).toBe("saved");
+ const prior = (await revisions.readCurrent(target(), board()))!;
+ const current = await revisions.commit({ ...target(), entryFile, baseRevisionId: prior.revision.id, content: "new board instructions", source: "api" }, board());
+ const originalMkdir = fs.mkdir.bind(fs);
+ const originalChmod = fs.chmod.bind(fs);
+ const mkdir = vi.spyOn(fs, "mkdir").mockImplementation(async (...args: Parameters) => {
+ if (point === "before replacement" && args[0] === copy.localRoot) throw new Error("retry staging failed");
+ return originalMkdir(...args);
+ });
+ const chmod = vi.spyOn(fs, "chmod").mockImplementation(async (...args) => {
+ if (point === "after replacement" && args[0] === path.join(copy.localRoot, entryFile)) throw new Error("retry staging failed");
+ return originalChmod(...args);
+ });
+ try {
+ await expect(copies.prepare({ legacy: true, ...target(), runId: copy.runId, cwd: home })).rejects.toThrow("retry staging failed");
+ } finally { mkdir.mockRestore(); chmod.mockRestore(); }
+ expect(await copies.get(companyId, copy.runId)).toEqual(saved);
+ copies = agentInstructionWorkingCopyService(db);
+ await copies.recoverStopped();
+ expect(await copies.get(companyId, copy.runId)).toEqual(saved);
+ expect((await revisions.readCurrent(target(), board()))?.revision.id).toBe(current.revision.id);
+ const retried = (await copies.prepare({ legacy: true, ...target(), runId: copy.runId, cwd: home }))!;
+ expect(retried).toMatchObject({ state: "prepared", baseRevisionId: current.revision.id, processStoppedAt: null });
+ expect(await fs.readFile(path.join(retried.localRoot, entryFile), "utf8")).toBe(current.content);
+ });
+
+ it("reads an existing canonical runtime snapshot without seeding or changing instruction bytes", async () => {
+ expect((await revisions.readCommittedForRuntime(target()))?.content).toBe(initial);
+ const copy = await run();
+ await fs.writeFile(path.join(copy.localRoot, entryFile), "saved runtime content");
+ await copies.collectStopped({ companyId, runId: copy.runId });
+ const current = (await revisions.readCurrent(target(), board()))!;
+ await fs.writeFile(path.join(root, entryFile), "current filesystem contents");
+ expect((await revisions.readCommittedForRuntime(target()))?.content).toBe("current filesystem contents");
+ const [agent] = await db.select().from(agents).where(eq(agents.id, agentId));
+ const context = await buildNativeRuntimeContext({ db, agent, runId: copy.runId, runtimeConfig: {}, runtimeSkillEntries: [] });
+ try {
+ expect(context.instructions.entryPath).toBe(entryFile);
+ expect(await fs.readFile(path.join(context.instructions.bundle.rootPath, entryFile), "utf8"))
+ .toBe("current filesystem contents");
+ } finally {
+ // Runtime assets are immutable, so make only this fixture's directories
+ // removable before the ordinary temporary-home cleanup.
+ const makeRemovable = async (directory: string): Promise => {
+ await fs.chmod(directory, 0o700);
+ for (const child of await fs.readdir(directory, { withFileTypes: true })) {
+ if (child.isDirectory()) await makeRemovable(path.join(directory, child.name));
+ }
+ };
+ await makeRemovable(context.instructions.bundle.rootPath);
+ }
+ expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe("current filesystem contents");
+ expect((await revisions.history({ ...target(), entryFile }, board())).revisions).toHaveLength(0);
+ await expect(revisions.readCommittedForRuntime({ companyId: randomUUID(), agentId })).rejects.toThrow("Agent not found");
+ });
+
+ it("preserves a competing stale candidate and requires explicit resolution against the new head", async () => {
+ const first = await run();
+ const second = await run();
+ await fs.writeFile(path.join(first.localRoot, entryFile), "first run");
+ await fs.writeFile(path.join(second.localRoot, entryFile), "second run");
+ await copies.collectStopped({ companyId, runId: first.runId });
+ const conflict = await copies.collectStopped({ companyId, runId: second.runId });
+ expect(conflict).toMatchObject({ state: "conflict", errorCode: "INSTRUCTION_REVISION_CONFLICT" });
+ expect(Buffer.from(conflict!.candidateBase64!, "base64").toString("utf8")).toBe("second run");
+ expect((await revisions.readCurrent(target(), board()))?.content).toBe("first run");
+ copies = agentInstructionWorkingCopyService(db);
+ expect(await copies.list(companyId, agentId, board())).toHaveLength(1);
+ const current = (await revisions.readCurrent(target(), board()))!;
+ await copies.resolve({ ...target(), runId: second.runId, baseRevisionId: current.revision.id, content: "explicitly combined" }, board());
+ expect((await revisions.readCurrent(target(), board()))?.content).toBe("explicitly combined");
+ expect((await copies.collectStopped({ companyId, runId: second.runId }))?.state).toBe("resolved");
+ const resumed = (await copies.prepare({ legacy: true, ...target(), runId: second.runId, cwd: home }))!;
+ const resolved = (await revisions.readCurrent(target(), board()))!;
+ expect(resumed.baseRevisionId).toBe(resolved.revision.id);
+ expect(await fs.readFile(path.join(resumed.localRoot, entryFile), "utf8")).toBe("explicitly combined");
+ });
+
+ it("rechecks responsible-user permission at collection and preserves denied edits", async () => {
+ const copy = await run();
+ await fs.writeFile(path.join(copy.localRoot, entryFile), "candidate after revocation");
+ await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.principalId, userId));
+ const denied = await copies.collectStopped({ companyId, runId: copy.runId });
+ expect(denied).toMatchObject({ state: "conflict", errorCode: "RESPONSIBLE_USER_UNAVAILABLE" });
+ expect(denied?.candidateBase64).not.toBeNull();
+ expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(initial);
+ });
+
+ it("does not import a symlink or confuse a removed entry with an empty file", async () => {
+ const linked = await run();
+ await fs.unlink(path.join(linked.localRoot, entryFile));
+ await fs.symlink(path.join(home, "AGENTS.md"), path.join(linked.localRoot, entryFile));
+ expect((await copies.collectStopped({ companyId, runId: linked.runId }))?.state).toBe("pending_collection");
+ const missing = await run();
+ await fs.unlink(path.join(missing.localRoot, entryFile));
+ expect((await copies.collectStopped({ companyId, runId: missing.runId }))?.state).toBe("unavailable");
+ const empty = await run();
+ await fs.writeFile(path.join(empty.localRoot, entryFile), "");
+ expect((await copies.collectStopped({ companyId, runId: empty.runId }))?.state).toBe("saved");
+ expect((await revisions.readCurrent(target(), board()))?.content).toBe("");
+ });
+
+ it("keeps unchanged warm copies reusable without a content revision", async () => {
+ const copy = await run();
+ expect(await copies.hasChanges({ companyId, runId: copy.runId })).toBe(false);
+ expect((await copies.get(companyId, copy.runId))?.processStoppedAt).toBeNull();
+ expect((await revisions.history({ ...target(), entryFile }, board())).revisions).toHaveLength(0);
+ await fs.writeFile(path.join(copy.localRoot, entryFile), "changed after turn");
+ expect(await copies.hasChanges({ companyId, runId: copy.runId })).toBe(true);
+ expect((await revisions.readCurrent(target(), board()))?.content).toBe(initial);
+ });
+
+ it("advances an explicit save baseline only when the private copy matches that revision", async () => {
+ const copy = await run();
+ const local = path.join(copy.localRoot, entryFile);
+ await fs.writeFile(local, "explicit self-save");
+ const first = await revisions.commit({ ...target(), entryFile, baseRevisionId: copy.baseRevisionId, content: "explicit self-save", source: "tool" }, board());
+ await copies.acknowledgeExplicitSave({ ...target(), runId: copy.runId, entryFile, revisionId: first.revision.id, contentHash: first.revision.contentHash });
+ await fs.writeFile(local, "local edit after explicit save");
+ expect((await copies.collectStopped({ companyId, runId: copy.runId }))?.state).toBe("saved");
+ expect((await revisions.readCurrent(target(), board()))?.content).toBe("local edit after explicit save");
+
+ const competing = await run();
+ await fs.writeFile(path.join(competing.localRoot, entryFile), "older local draft");
+ const separate = await revisions.commit({ ...target(), entryFile, baseRevisionId: competing.baseRevisionId, content: "different explicit content", source: "tool" }, board());
+ await copies.acknowledgeExplicitSave({ ...target(), runId: competing.runId, entryFile, revisionId: separate.revision.id, contentHash: separate.revision.contentHash });
+ expect((await copies.collectStopped({ companyId, runId: competing.runId }))?.state).toBe("conflict");
+ expect((await revisions.readCurrent(target(), board()))?.content).toBe("different explicit content");
+ expect((await copies.list(companyId, agentId, board()))[0]).not.toHaveProperty("localRoot");
+ });
+
+ it("recovers captured bytes after restart and deduplicates concurrent cleanup callbacks", async () => {
+ const copy = await run();
+ await db.update(agentInstructionWorkingCopies).set({ state: "pending_commit", candidateBase64: Buffer.from("captured before restart").toString("base64"), processStoppedAt: new Date() })
+ .where(eq(agentInstructionWorkingCopies.runId, copy.runId));
+ copies = agentInstructionWorkingCopyService(db);
+ await copies.recoverCaptured();
+ expect((await revisions.readCurrent(target(), board()))?.content).toBe("captured before restart");
+ const duplicate = await run();
+ await fs.writeFile(path.join(duplicate.localRoot, entryFile), "one final edit");
+ await Promise.all([copies.collectStopped({ companyId, runId: duplicate.runId }), copies.collectStopped({ companyId, runId: duplicate.runId })]);
+ expect((await copies.get(companyId, duplicate.runId))?.state).toBe("saved");
+ expect((await revisions.history({ ...target(), entryFile }, board())).revisions).toHaveLength(0);
+ });
+ it("requires durable stop evidence before recovering an uncaptured copy", async () => {
+ const copy = await run();
+ await fs.writeFile(path.join(copy.localRoot, entryFile), "edit before controller restart");
+ await db.update(heartbeatRuns).set({ runtimeMode: "native", status: "failed", finishedAt: new Date() }).where(eq(heartbeatRuns.id, copy.runId));
+ copies = agentInstructionWorkingCopyService(db);
+ await copies.recoverStopped();
+ expect((await revisions.readCurrent(target(), board()))?.content).toBe(initial);
+ expect((await copies.list(companyId, agentId, board()))[0]).toMatchObject({ state: "pending_collection", content: null });
+ await appendHeartbeatRunEvent(db, { ...target(), runId: copy.runId, eventType: "native.local_process_stopped", stream: "system" });
+ await copies.recoverStopped();
+ expect((await revisions.readCurrent(target(), board()))?.content).toBe("edit before controller restart");
+ });
+
+ it("never treats a superseded stop receipt or unavailable environment as a save", async () => {
+ const copy = await run();
+ await fs.writeFile(path.join(copy.localRoot, entryFile), "unverified live edit");
+ await db.update(heartbeatRuns).set({ runtimeMode: "native", status: "failed", finishedAt: new Date() }).where(eq(heartbeatRuns.id, copy.runId));
+ await appendHeartbeatRunEvent(db, { ...target(), runId: copy.runId, eventType: "native.local_process_stopped", stream: "system" });
+ await appendHeartbeatRunEvent(db, { ...target(), runId: copy.runId, eventType: "native.process_start_requested", stream: "system" });
+ await copies.recoverStopped();
+ expect((await revisions.readCurrent(target(), board()))?.content).toBe(initial);
+ const lost = await copies.reportUnavailable(companyId, copy.runId);
+ expect(lost).toMatchObject({ state: "unavailable", candidateBase64: null, processStoppedAt: null });
+ expect(lost?.errorMessage).not.toContain(copy.localRoot);
+ });
+
+ it("keeps private run copies out of normal Git staging and workspace snapshots", async () => {
+ const workspace = path.join(home, `workspace-${randomUUID()}`);
+ await fs.mkdir(workspace);
+ await execFile("git", ["init", workspace]);
+ await execFile("git", ["-C", workspace, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--allow-empty", "-m", "fixture"]);
+ const runId = randomUUID();
+ await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, invocationSource: "on_demand", responsibleUserId: userId });
+ const copy = (await copies.prepare({ legacy: true, ...target(), runId, cwd: workspace }))!;
+ expect(copy.localRoot).toBe(path.join(workspace, ".paperclip-runtime", `instruction-edits-${runId}`, "instructions"));
+ await fs.writeFile(path.join(workspace, "deliverable.txt"), "public work");
+ await execFile("git", ["-C", workspace, "add", "."]);
+ const staged = await execFile("git", ["-C", workspace, "diff", "--cached", "--name-only"]);
+ expect(staged.stdout).toBe("deliverable.txt\n");
+ const snapshot = await gitWorkspaceSync.readGitWorkspaceSnapshot(workspace);
+ try {
+ const paths: unknown = snapshot?.overlayPaths;
+ if (Array.isArray(paths)) {
+ expect(paths).toEqual(["deliverable.txt"]);
+ } else {
+ // The streaming Git snapshot stores the same path set in a private
+ // manifest. Inspect its actual records, not only the reported count.
+ expect(paths).toMatchObject({ kind: "path_manifest", version: 1, category: "overlay", count: 1 });
+ const manifest = paths as { filePath: string; category: string };
+ const manifestDb = new DatabaseSync(manifest.filePath, { readOnly: true, allowExtension: false });
+ try {
+ const rows = manifestDb.prepare("SELECT path FROM records WHERE category = ? ORDER BY path").all(manifest.category);
+ expect(rows.map((row) => row.path)).toEqual(["deliverable.txt"]);
+ } finally { manifestDb.close(); }
+ }
+ } finally {
+ if ("disposeGitWorkspaceSnapshot" in gitWorkspaceSync && typeof gitWorkspaceSync.disposeGitWorkspaceSnapshot === "function") {
+ await gitWorkspaceSync.disposeGitWorkspaceSnapshot(snapshot);
+ }
+ }
+ const files = await captureDirectorySnapshot(workspace, { exclude: [".git", ".paperclip-runtime"] });
+ expect([...files.entries].map(([relative]) => relative)).toEqual(["deliverable.txt"]);
+ await expect(copies.prepare({ legacy: true, ...target(), runId, cwd: home })).rejects.toThrow("different run workspace");
+ });
+
+});
diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts
index 0a110b78025..494f89ffb0c 100644
--- a/server/src/__tests__/agent-instructions-routes.test.ts
+++ b/server/src/__tests__/agent-instructions-routes.test.ts
@@ -1,5 +1,6 @@
import express from "express";
import request from "supertest";
+import { Readable } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockAgentService = vi.hoisted(() => ({
@@ -24,6 +25,21 @@ const mockAgentInstructionsService = vi.hoisted(() => ({
materializeManagedBundle: vi.fn(),
}));
+const mockInstructionWorkingCopies = vi.hoisted(() => ({ list: vi.fn(), resolve: vi.fn(), acknowledgeExplicitSave: vi.fn() }));
+vi.mock("../services/agent-instruction-working-copies.js", () => ({ agentInstructionWorkingCopyService: () => mockInstructionWorkingCopies }));
+
+const mockDownloadAgentFile = vi.hoisted(() => vi.fn());
+vi.mock("../services/agent-file-store.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, agentFileStore: (...args: Parameters) => ({ ...actual.agentFileStore(...args), download: mockDownloadAgentFile }) };
+});
+
+const mockInstructionRevisions = vi.hoisted(() => ({ readCurrent: vi.fn(), commit: vi.fn(), restore: vi.fn(), history: vi.fn(), readRevision: vi.fn(), diff: vi.fn(), materializeCurrent: vi.fn() }));
+vi.mock("../services/agent-instruction-revisions.js", () => ({ agentInstructionRevisionService: () => mockInstructionRevisions }));
+
+const mockAuthorizeInstructionRead = vi.hoisted(() => vi.fn());
+vi.mock("../services/agent-instruction-authorization.js", () => ({ authorizeInstructionRead: mockAuthorizeInstructionRead }));
+
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
@@ -202,6 +218,9 @@ describe("agent instructions bundle routes", () => {
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
+ mockAuthorizeInstructionRead.mockImplementation(async (_db, actor) => actor);
+ mockInstructionRevisions.readCurrent.mockResolvedValue(null);
+ mockInstructionRevisions.commit.mockResolvedValue({ revision: { id: "33333333-3333-4333-8333-333333333333", entryFile: "AGENTS.md", byteLength: 18 }, content: "# Updated Agent\n", changed: true, materialization: "current" });
mockBuiltInAgentService.ensureCompanyDefaultAgentGrants.mockResolvedValue(0);
mockSyncInstructionsBundleConfigFromFilePath.mockImplementation((_agent, config) => config);
mockFindServerAdapter.mockImplementation((_type: string) => ({ type: _type }));
@@ -286,6 +305,8 @@ describe("agent instructions bundle routes", () => {
entryFile: "AGENTS.md",
});
expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled();
+ expect(mockAuthorizeInstructionRead).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ type: "board" }),
+ expect.objectContaining({ companyId: "company-1", id: "11111111-1111-4111-8111-111111111111" }));
});
it("requires instance-admin access for every external instruction entry point", async () => {
@@ -331,6 +352,7 @@ describe("agent instructions bundle routes", () => {
expect(mockAgentInstructionsService.updateBundle).not.toHaveBeenCalled();
expect(mockAgentInstructionsService.writeFile).not.toHaveBeenCalled();
expect(mockAgentInstructionsService.deleteFile).not.toHaveBeenCalled();
+ expect(mockAuthorizeInstructionRead).not.toHaveBeenCalled();
});
it("treats a host root mislabeled as managed as external", async () => {
@@ -383,6 +405,7 @@ describe("agent instructions bundle routes", () => {
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled();
+ expect(mockAuthorizeInstructionRead).not.toHaveBeenCalled();
});
it("rejects a company admin that requests a new external instruction root", async () => {
@@ -484,7 +507,8 @@ describe("agent instructions bundle routes", () => {
expect(mockAgentService.create).not.toHaveBeenCalled();
});
- it("denies non-privileged agents from reading peer instructions bundles", async () => {
+ it.each(["", "/file?path=AGENTS.md", "/file?path=private-support.md"])(
+ "denies peer instruction reads before any data dispatch: %s", async (suffix) => {
mockAgentService.getById.mockImplementation(async (id: string) => {
if (id === "agent-reader") {
return {
@@ -496,11 +520,10 @@ describe("agent instructions bundle routes", () => {
}
return makeAgent();
});
- mockAccessService.decide.mockResolvedValue({
- allowed: false,
- reason: "deny_no_grant",
- explanation: "Missing permission: agents:configure or agents:suggest-changes.",
- });
+ // General agent visibility is allowed. It must not authorize instruction
+ // metadata, canonical entry bytes, or supporting files from a peer.
+ const { forbidden } = await vi.importActual("../errors.js");
+ mockAuthorizeInstructionRead.mockRejectedValue(forbidden("Missing permission to read peer instructions"));
const res = await requestApp(
await createApp({
@@ -510,20 +533,18 @@ describe("agent instructions bundle routes", () => {
source: "agent_key",
}),
(baseUrl) => request(baseUrl)
- .get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle"),
+ .get(`/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle${suffix}`),
);
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toContain("Missing permission");
- expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
- action: "agent_config:read",
- resource: {
- type: "agent",
- companyId: "company-1",
- agentId: "11111111-1111-4111-8111-111111111111",
- },
- }));
+ expect(mockAuthorizeInstructionRead).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
+ type: "agent", agentId: "agent-reader",
+ }), { companyId: "company-1", id: "11111111-1111-4111-8111-111111111111" });
expect(mockAgentInstructionsService.getBundle).not.toHaveBeenCalled();
+ expect(mockAgentInstructionsService.readFile).not.toHaveBeenCalled();
+ expect(mockInstructionRevisions.readCurrent).not.toHaveBeenCalled();
+ expect(mockInstructionRevisions.materializeCurrent).not.toHaveBeenCalled();
});
it("allows agents to read their own instructions bundles", async () => {
@@ -540,9 +561,12 @@ describe("agent instructions bundle routes", () => {
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled();
+ expect(mockAuthorizeInstructionRead).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
+ type: "agent", agentId: "11111111-1111-4111-8111-111111111111",
+ }), { companyId: "company-1", id: "11111111-1111-4111-8111-111111111111" });
});
- it("allows agents with suggest grants to read peer instructions bundles", async () => {
+ it.each(["AGENTS.md", "private-support.md"])("allows an authorized peer to read instruction file %s", async (filePath) => {
mockAccessService.decide.mockResolvedValue({
allowed: true,
reason: "allow_explicit_grant",
@@ -570,52 +594,100 @@ describe("agent instructions bundle routes", () => {
}),
(baseUrl) => request(baseUrl)
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file")
- .query({ path: "AGENTS.md" }),
+ .query({ path: filePath }),
);
expect(res.status, JSON.stringify(res.body)).toBe(200);
- expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
- action: "agent_config:read",
- resource: {
- type: "agent",
- companyId: "company-1",
- agentId: "11111111-1111-4111-8111-111111111111",
- },
- }));
+ expect(mockAuthorizeInstructionRead).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
+ type: "agent", agentId: "coach-agent",
+ }), { companyId: "company-1", id: "11111111-1111-4111-8111-111111111111" });
expect(mockAgentInstructionsService.readFile).toHaveBeenCalledWith(
expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
- "AGENTS.md",
+ filePath,
);
});
- it("writes a bundle file and persists compatibility config", async () => {
+ it("commits entry bytes through the canonical service with the authenticated actor", async () => {
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
.put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1")
- .send({
- path: "AGENTS.md",
- content: "# Updated Agent\n",
- clearLegacyPromptTemplate: true,
- }));
-
+ .send({ path: "AGENTS.md", content: "# Updated Agent\n", baseRevisionId: null }));
expect(res.status, JSON.stringify(res.body)).toBe(200);
- expect(mockAgentInstructionsService.writeFile).toHaveBeenCalledWith(
- expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
- "AGENTS.md",
- "# Updated Agent\n",
- { clearLegacyPromptTemplate: true },
- );
- expect(mockAgentService.update).toHaveBeenCalledWith(
- "11111111-1111-4111-8111-111111111111",
- expect.objectContaining({
- adapterConfig: expect.objectContaining({
- instructionsBundleMode: "managed",
- instructionsRootPath: "/tmp/agent-1",
- instructionsEntryFile: "AGENTS.md",
- instructionsFilePath: "/tmp/agent-1/AGENTS.md",
- }),
- }),
- expect.any(Object),
- );
+ expect(mockInstructionRevisions.commit).toHaveBeenCalledWith(expect.objectContaining({
+ agentId: "11111111-1111-4111-8111-111111111111", entryFile: "AGENTS.md", content: "# Updated Agent\n", baseRevisionId: null, source: "board",
+ }), expect.objectContaining({ type: "board" }));
+ expect(mockAgentInstructionsService.writeFile).not.toHaveBeenCalled();
+ expect(res.body.receipt.revision.id).toBe("33333333-3333-4333-8333-333333333333");
+ });
+
+ it("returns committed entry content independently of its disk copy", async () => {
+ mockInstructionRevisions.readCurrent.mockResolvedValue({ revision: { id: "33333333-3333-4333-8333-333333333333", entryFile: "AGENTS.md", byteLength: 9 }, content: "committed" });
+ const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
+ .get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?path=AGENTS.md"));
+ expect(res.status).toBe(200);
+ expect(res.body.content).toBe("committed");
+ expect(res.body.revision.id).toBe("33333333-3333-4333-8333-333333333333");
+ expect(mockAgentInstructionsService.readFile).not.toHaveBeenCalled();
+ });
+
+ it.each([Buffer.from([0, 255, 128, 17]), Buffer.alloc(0)])("streams an authorized binary download without text conversion (%j)", async bytes => {
+ mockAgentService.getById.mockResolvedValue({ ...makeAgent(), adapterConfig: { instructionsBundleMode: "managed" } });
+ mockDownloadAgentFile.mockResolvedValue({ size: bytes.length, stream: Readable.from(bytes.length ? [bytes] : []) });
+ const res = await requestApp(await createApp(), url => request(url)
+ .get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file")
+ .query({ path: "notes/data.bin", download: "true" }));
+ expect(res.status, JSON.stringify(res.body)).toBe(200);
+ expect(res.body).toEqual(bytes);
+ expect(res.headers["content-length"]).toBe(String(bytes.length));
+ expect(res.headers["content-disposition"]).toContain('filename="data.bin"');
+ expect(mockDownloadAgentFile).toHaveBeenCalledWith("company-1", "11111111-1111-4111-8111-111111111111", "notes/data.bin", expect.objectContaining({ type: "board" }));
+ expect(mockAgentInstructionsService.readFile).not.toHaveBeenCalled();
+ });
+
+ it("lists and resolves preserved edits with server scope, actor, and explicit revision", async () => {
+ const app = await createApp();
+ const runId = "55555555-5555-4555-8555-555555555555";
+ const baseRevisionId = "44444444-4444-4444-8444-444444444444";
+ const prefix = "/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/candidates";
+ mockInstructionWorkingCopies.list.mockResolvedValue([{ runId, entryFile: "AGENTS.md", state: "conflict", content: "preserved" }]);
+ mockInstructionWorkingCopies.resolve.mockResolvedValue({ revision: { id: baseRevisionId, entryFile: "AGENTS.md", byteLength: 8 }, content: "resolved", changed: true, materialization: "current" });
+ const listed = await requestApp(app, (url) => request(url).get(prefix));
+ expect(listed.status).toBe(200);
+ expect(listed.body).toEqual([{ runId, entryFile: "AGENTS.md", state: "conflict", content: "preserved" }]);
+ expect(mockInstructionWorkingCopies.list).toHaveBeenCalledWith("company-1", "11111111-1111-4111-8111-111111111111", expect.objectContaining({ type: "board", userId: "local-board" }));
+ const resolved = await requestApp(app, (url) => request(url).post(`${prefix}/${runId}/resolve`).send({ baseRevisionId, content: "resolved" }));
+ expect(resolved.status).toBe(200);
+ expect(resolved.body).toMatchObject({ content: "resolved", receipt: { changed: true } });
+ expect(mockInstructionWorkingCopies.resolve).toHaveBeenCalledWith({ companyId: "company-1", agentId: "11111111-1111-4111-8111-111111111111", runId, baseRevisionId, content: "resolved" }, expect.objectContaining({ type: "board", userId: "local-board" }));
+ for (const body of [{ content: "missing base" }, { baseRevisionId, content: "forged", responsibleUserId: "other" }, { baseRevisionId, content: "forged", entryFile: "other.md" }]) {
+ expect((await requestApp(app, (url) => request(url).post(`${prefix}/${runId}/resolve`).send(body))).status).toBe(400);
+ }
+ expect(mockInstructionWorkingCopies.resolve).toHaveBeenCalledOnce();
+ });
+
+ it("exposes scoped history, diff and restore with the server actor", async () => {
+ const app = await createApp();
+ const revisionId = "33333333-3333-4333-8333-333333333333";
+ const baseRevisionId = "44444444-4444-4444-8444-444444444444";
+ const prefix = "/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle";
+ mockInstructionRevisions.history.mockResolvedValue({ revisions: [], nextCursor: null });
+ mockInstructionRevisions.diff.mockResolvedValue({ removed: "old", added: "new" });
+ mockInstructionRevisions.restore.mockResolvedValue({ revision: { id: revisionId, entryFile: "AGENTS.md", byteLength: 3 }, content: "old", changed: true, materialization: "current" });
+ expect((await requestApp(app, (url) => request(url).get(`${prefix}/history?path=AGENTS.md`))).status).toBe(200);
+ expect((await requestApp(app, (url) => request(url).get(`${prefix}/diff?path=AGENTS.md&from=${revisionId}&to=${baseRevisionId}`))).status).toBe(200);
+ const restored = await requestApp(app, (url) => request(url).post(`${prefix}/restore`).send({ path: "AGENTS.md", revisionId, baseRevisionId }));
+ expect(restored.status).toBe(200);
+ expect(restored.body.receipt.changed).toBe(true);
+ expect(mockInstructionRevisions.restore).toHaveBeenCalledWith({ companyId: "company-1", agentId: "11111111-1111-4111-8111-111111111111", entryFile: "AGENTS.md", revisionId, baseRevisionId }, expect.objectContaining({ type: "board", userId: "local-board" }));
+ });
+
+ it("requires a base revision and rejects client-supplied responsible identity", async () => {
+ const app = await createApp();
+ for (const body of [ { path: "AGENTS.md", content: "stale" }, { path: "AGENTS.md", content: "forged", baseRevisionId: null, responsibleUserId: "forged" } ]) {
+ const res = await requestApp(app, (baseUrl) => request(baseUrl)
+ .put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file").send(body));
+ expect([400, 422]).toContain(res.status);
+ }
+ expect(mockInstructionRevisions.commit).not.toHaveBeenCalled();
});
it("preserves managed instructions config when switching adapters", async () => {
diff --git a/server/src/__tests__/agent-instructions-service.test.ts b/server/src/__tests__/agent-instructions-service.test.ts
index 2dc4719d1a8..7641dcc6778 100644
--- a/server/src/__tests__/agent-instructions-service.test.ts
+++ b/server/src/__tests__/agent-instructions-service.test.ts
@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
-import { afterEach, describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { agentInstructionsService } from "../services/agent-instructions.js";
type TestAgent = {
@@ -12,7 +12,7 @@ type TestAgent = {
};
async function makeTempDir(prefix: string) {
- return fs.mkdtemp(path.join(os.tmpdir(), prefix));
+ return fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), prefix)));
}
function makeAgent(adapterConfig: Record): TestAgent {
@@ -29,6 +29,13 @@ describe("agent instructions service", () => {
const originalPaperclipInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
const cleanupDirs = new Set();
+ beforeEach(async () => {
+ const home = await makeTempDir("agent-instructions-test-home-");
+ cleanupDirs.add(home);
+ process.env.PAPERCLIP_HOME = home;
+ process.env.PAPERCLIP_INSTANCE_ID = "instructions-service-test";
+ });
+
afterEach(async () => {
if (originalPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = originalPaperclipHome;
@@ -41,6 +48,37 @@ describe("agent instructions service", () => {
}));
});
+ it.each(["./AGENTS.md", "notes/../AGENTS.md", "notes\\..\\AGENTS.md", "../invalid"])("reads legacy entry configuration %s without breaking the bundle", async (entry) => {
+ const root = await makeTempDir("legacy-entry-"); cleanupDirs.add(root);
+ await fs.writeFile(path.join(root, "AGENTS.md"), "legacy instructions");
+ const agent = makeAgent({ instructionsBundleMode: "external", instructionsRootPath: root, instructionsEntryFile: entry });
+ const svc = agentInstructionsService();
+ const bundle = await svc.getBundle(agent);
+ expect(bundle.entryFile).toBe("AGENTS.md");
+ expect((await svc.readFile(agent, bundle.entryFile)).content).toBe("legacy instructions");
+ if (entry === "../invalid") expect(bundle.warnings.length).toBeGreaterThan(0);
+ });
+
+ it("lists external bundles containing large binary assets", async () => {
+ const root = await makeTempDir("external-large-assets-"); cleanupDirs.add(root);
+ await fs.writeFile(path.join(root, "AGENTS.md"), "external instructions");
+ const asset = await fs.open(path.join(root, "large.bin"), "w");
+ try { await asset.truncate(17 * 1024 * 1024); } finally { await asset.close(); }
+ const agent = makeAgent({ instructionsBundleMode: "external", instructionsRootPath: root });
+ const svc = agentInstructionsService();
+ const bundle = await svc.getBundle(agent);
+ expect(bundle.files).toContainEqual(expect.objectContaining({ path: "large.bin", binary: true, editable: false }));
+ expect((await svc.readFile(agent, "AGENTS.md")).content).toBe("external instructions");
+ });
+
+ it("rejects reserved paths while initializing an unconfigured managed bundle", async () => {
+ const root = await makeTempDir("unconfigured-reserved-path-"); cleanupDirs.add(root);
+ process.env.PAPERCLIP_HOME = root;
+ const svc = agentInstructionsService();
+ await expect(svc.writeFile(makeAgent({}), ".paperclip-runtime/state", "invalid")).rejects.toMatchObject({ status: 422 });
+ expect(await fs.readdir(root)).toEqual([]);
+ });
+
it("copies the existing bundle into the managed root when switching to managed mode", async () => {
const paperclipHome = await makeTempDir("paperclip-agent-instructions-home-");
const externalRoot = await makeTempDir("paperclip-agent-instructions-external-");
@@ -53,7 +91,7 @@ describe("agent instructions service", () => {
await fs.mkdir(path.join(externalRoot, "docs"), { recursive: true });
await fs.writeFile(path.join(externalRoot, "docs", "TOOLS.md"), "## Tools\n", "utf8");
- const svc = agentInstructionsService();
+ const svc = agentInstructionsService({ select: () => ({ from: () => ({ where: async () => [] }) }) } as never);
const agent = makeAgent({
instructionsBundleMode: "external",
instructionsRootPath: externalRoot,
@@ -250,11 +288,10 @@ describe("agent instructions service", () => {
expect(bundle.mode).toBe("managed");
expect(bundle.rootPath).toBe(managedRoot);
- expect(bundle.entryFile).toBe("AGENTS.md");
+ expect(bundle.entryFile).toBe("docs/MISSING.md");
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
expect(bundle.warnings).toEqual([
`Recovered managed instructions from disk at ${managedRoot}; ignoring stale configured root ${staleRoot}.`,
- "Recovered managed instructions entry file from disk as AGENTS.md; previous entry docs/MISSING.md was missing.",
]);
expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" });
});
@@ -293,8 +330,8 @@ describe("agent instructions service", () => {
expect(result.adapterConfig).toMatchObject({
instructionsBundleMode: "managed",
instructionsRootPath: managedRoot,
- instructionsEntryFile: "AGENTS.md",
- instructionsFilePath: path.join(managedRoot, "AGENTS.md"),
+ instructionsEntryFile: "docs/MISSING.md",
+ instructionsFilePath: path.join(managedRoot, "docs/MISSING.md"),
});
await expect(fs.readFile(path.join(managedRoot, "docs", "TOOLS.md"), "utf8")).resolves.toBe("## Tools\n");
});
@@ -334,8 +371,8 @@ describe("agent instructions service", () => {
expect(result.adapterConfig).toMatchObject({
instructionsBundleMode: "managed",
instructionsRootPath: managedRoot,
- instructionsEntryFile: "AGENTS.md",
- instructionsFilePath: path.join(managedRoot, "AGENTS.md"),
+ instructionsEntryFile: "docs/MISSING.md",
+ instructionsFilePath: path.join(managedRoot, "docs/MISSING.md"),
});
await expect(fs.stat(path.join(managedRoot, "docs", "TOOLS.md"))).rejects.toThrow();
expect(result.bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
@@ -373,11 +410,10 @@ describe("agent instructions service", () => {
expect(bundle.mode).toBe("managed");
expect(bundle.rootPath).toBe(managedRoot);
- expect(bundle.entryFile).toBe("AGENTS.md");
+ expect(bundle.entryFile).toBe("docs/MISSING.md");
expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]);
expect(bundle.warnings).toEqual([
`Recovered managed instructions from disk at ${managedRoot}; ignoring stale configured root ${staleRoot}.`,
- "Recovered managed instructions entry file from disk as AGENTS.md; previous entry docs/MISSING.md was missing.",
]);
expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" });
});
diff --git a/server/src/__tests__/built-in-agent-routes.test.ts b/server/src/__tests__/built-in-agent-routes.test.ts
index acae6704dc3..e51166f2ba4 100644
--- a/server/src/__tests__/built-in-agent-routes.test.ts
+++ b/server/src/__tests__/built-in-agent-routes.test.ts
@@ -412,7 +412,7 @@ describe("built-in agent routes", () => {
.send({});
expect(res.status, JSON.stringify(res.body)).toBe(200);
- expect(mockBuiltInAgentService.reset).toHaveBeenCalledWith(companyId, "briefs", {});
+ expect(mockBuiltInAgentService.reset).toHaveBeenCalledWith(companyId, "briefs", {}, expect.objectContaining({ type: "agent", agentId: "manager-agent" }));
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
companyId,
actorType: "agent",
diff --git a/server/src/__tests__/built-in-agents.test.ts b/server/src/__tests__/built-in-agents.test.ts
index 8f3abf3972d..69de71c730c 100644
--- a/server/src/__tests__/built-in-agents.test.ts
+++ b/server/src/__tests__/built-in-agents.test.ts
@@ -1,3 +1,6 @@
+import fs from "node:fs/promises";
+import { fileHash } from "../services/agent-file-store.js";
+import { stockHash } from "../services/managed-resource-drift.js";
import { randomUUID } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
@@ -27,6 +30,7 @@ import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
+import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
import { agentInstructionsService } from "../services/agent-instructions.ts";
import { agentService } from "../services/agents.ts";
import { approvalService } from "../services/approvals.ts";
@@ -116,6 +120,16 @@ describe("built-in agent asset loading", () => {
describeEmbeddedPostgres("built-in agents", () => {
let db!: ReturnType;
let tempDb: Awaited> | null = null;
+ const instructionOperator = { type: "board", userId: "local-board", source: "local_implicit" } as const;
+ async function writeInstructionEntry(agent: { id: string; companyId: string }, entryFile: string, content: string) {
+ const service = agentInstructionRevisionService(db);
+ const target = { companyId: agent.companyId, agentId: agent.id };
+ const baseline = await service.readCurrent(target, instructionOperator);
+ await service.commit({ ...target, entryFile, content, baseRevisionId: baseline?.revision.id ?? null, source: "board" }, instructionOperator);
+ const refreshed = await agentService(db).getById(agent.id);
+ return { adapterConfig: refreshed!.adapterConfig };
+ }
+
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-built-in-agents-");
@@ -553,7 +567,7 @@ describeEmbeddedPostgres("built-in agents", () => {
capabilities: "Custom purpose",
});
- const reset = await builtIns.reset(companyId, "briefs");
+ const reset = await builtIns.reset(companyId, "briefs", {}, instructionOperator);
expect(reset).toMatchObject({
status: "ready",
@@ -795,13 +809,111 @@ describeEmbeddedPostgres("built-in agents", () => {
expect(approvalRows).toHaveLength(1);
});
+ it("automatically upgrades untouched stock instructions while preserving personal files", async () => {
+ const companyId = await seedCompany({ requireApproval: false });
+ const svc = builtInAgentService(db);
+ const created = await svc.ensure(companyId, "reflection-coach");
+ const agent = created.agent!;
+ const oldFiles = { "AGENTS.md": "# Previous stock instructions\n", "obsolete.txt": "old stock support" };
+ await writeInstructionEntry(agent, "AGENTS.md", oldFiles["AGENTS.md"]);
+ await agentInstructionsService(db).writeFile(agent, "obsolete.txt", oldFiles["obsolete.txt"]);
+ await agentInstructionsService(db).writeFile(agent, "personal.txt", "keep my notes");
+ await db.update(builtInManagedResources).set({ stockHash: stockHash(oldFiles), stockVersion: "previous",
+ defaultsJson: { entryFile: "AGENTS.md", files: Object.keys(oldFiles) },
+ }).where(and(eq(builtInManagedResources.companyId, companyId), eq(builtInManagedResources.resourceKind, "instructions")));
+ const updated = await svc.ensure(companyId, "reflection-coach");
+ expect(updated.resources.find(resource => resource.resourceKind === "instructions")).toMatchObject({ stockStatus: "stock_current" });
+ expect((await agentInstructionsService().readFile(updated.agent!, "AGENTS.md")).content).toContain("You are Reflection Coach");
+ expect((await agentInstructionsService().readFile(updated.agent!, "personal.txt")).content).toBe("keep my notes");
+ await expect(agentInstructionsService().readFile(updated.agent!, "obsolete.txt")).rejects.toMatchObject({ status: 404 });
+ const [event] = await db.select().from(activityLog).where(and(eq(activityLog.companyId, companyId), eq(activityLog.actorId, "built-in-reconcile")));
+ expect(event).toMatchObject({ actorType: "system", action: "agent.files_updated" });
+ });
+
+ it.each(["file", "database"])("retries a stock update after a %s failure without overwriting intervening edits", async (failure) => {
+ const companyId = await seedCompany({ requireApproval: false });
+ const svc = builtInAgentService(db);
+ const created = await svc.ensure(companyId, "reflection-coach");
+ const agent = created.agent!;
+ const oldFiles = { "AGENTS.md": "# Old stock\n", "obsolete.txt": "old supporting file" };
+ await writeInstructionEntry(agent, "AGENTS.md", oldFiles["AGENTS.md"]);
+ await agentInstructionsService(db).writeFile(agent, "obsolete.txt", oldFiles["obsolete.txt"]);
+ await db.update(builtInManagedResources).set({ stockHash: stockHash(oldFiles), stockVersion: "old",
+ defaultsJson: { entryFile: "AGENTS.md", files: Object.keys(oldFiles) },
+ }).where(and(eq(builtInManagedResources.companyId, companyId), eq(builtInManagedResources.resourceKind, "instructions")));
+ const trigger = `stock_failure_${companyId.replaceAll("-", "")}`;
+ const remove = fs.rm.bind(fs);
+ const fault = failure === "file" ? vi.spyOn(fs, "rm").mockImplementation(async (file, options) => {
+ if (String(file).endsWith("/obsolete.txt")) throw new Error("injected stock file failure");
+ return remove(file, options);
+ }) : null;
+ if (failure === "database") {
+ await db.execute(sql.raw(`CREATE FUNCTION ${trigger}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
+ IF NEW.company_id = '${companyId}' AND NEW.resource_kind = 'instructions' AND NEW.stock_hash <> OLD.stock_hash THEN
+ RAISE EXCEPTION 'injected stock binding failure'; END IF; RETURN NEW; END $$`));
+ await db.execute(sql.raw(`CREATE TRIGGER ${trigger} BEFORE UPDATE ON built_in_managed_resources FOR EACH ROW EXECUTE FUNCTION ${trigger}()`));
+ }
+ try { await expect(svc.ensure(companyId, "reflection-coach")).rejects.toThrow(); }
+ finally {
+ fault?.mockRestore();
+ if (failure === "database") {
+ await db.execute(sql.raw(`DROP TRIGGER ${trigger} ON built_in_managed_resources`));
+ await db.execute(sql.raw(`DROP FUNCTION ${trigger}()`));
+ }
+ }
+ const [pending] = await db.select().from(builtInManagedResources).where(and(eq(builtInManagedResources.companyId, companyId), eq(builtInManagedResources.resourceKind, "instructions")));
+ expect(pending.defaultsJson.pendingInstructionsUpdate).toBeTruthy();
+ expect(pending.stockHash).toBe(stockHash(oldFiles));
+ const partial = (await agentInstructionsService().readFile(agent, "AGENTS.md")).content;
+ expect(partial).toContain("You are Reflection Coach");
+ // A real operator edit after the failed attempt must stop the retry.
+ await writeInstructionEntry(agent, "AGENTS.md", "operator edit after failure");
+ await svc.ensure(companyId, "reflection-coach");
+ expect((await agentInstructionsService().readFile(agent, "AGENTS.md")).content).toBe("operator edit after failure");
+ await writeInstructionEntry(agent, "AGENTS.md", partial);
+ const retried = await svc.ensure(companyId, "reflection-coach");
+ expect(retried.resources.find(resource => resource.resourceKind === "instructions")).toMatchObject({ stockStatus: "stock_current" });
+ await expect(agentInstructionsService().readFile(agent, "obsolete.txt")).rejects.toMatchObject({ status: 404 });
+ const [complete] = await db.select().from(builtInManagedResources).where(eq(builtInManagedResources.id, pending.id));
+ expect(complete.defaultsJson.pendingInstructionsUpdate).toBeUndefined();
+ });
+
+ it.each([false, true])("supersedes an interrupted stock version, including rollback=%s", async (rollback) => {
+ const companyId = await seedCompany({ requireApproval: false });
+ const svc = builtInAgentService(db);
+ const created = await svc.ensure(companyId, "reflection-coach");
+ const agent = created.agent!;
+ const currentStock = (await agentInstructionsService().readFile(agent, "AGENTS.md")).content;
+ const oldFiles = { "AGENTS.md": rollback ? currentStock : "# Original stock" };
+ const interruptedFiles = { "AGENTS.md": "# Intermediate stock", "intermediate-only.txt": "intermediate support" };
+ await writeInstructionEntry(agent, "AGENTS.md", interruptedFiles["AGENTS.md"]);
+ await agentInstructionsService(db).writeFile(agent, "intermediate-only.txt", interruptedFiles["intermediate-only.txt"]);
+ await agentInstructionsService(db).writeFile(agent, "personal.txt", "personal notes");
+ await db.update(builtInManagedResources).set({ stockHash: stockHash(oldFiles), stockVersion: "old",
+ defaultsJson: { entryFile: "AGENTS.md", files: Object.keys(oldFiles), pendingInstructionsUpdate: {
+ stockHash: stockHash(interruptedFiles),
+ baseHashes: { "AGENTS.md": fileHash(Buffer.from(oldFiles["AGENTS.md"])), "intermediate-only.txt": null },
+ nextHashes: Object.fromEntries(Object.entries(interruptedFiles).map(([file, text]) => [file, fileHash(Buffer.from(text))])),
+ } },
+ }).where(and(eq(builtInManagedResources.companyId, companyId), eq(builtInManagedResources.resourceKind, "instructions")));
+ await writeInstructionEntry(agent, "AGENTS.md", "intervening operator edit");
+ await svc.ensure(companyId, "reflection-coach");
+ expect((await agentInstructionsService().readFile(agent, "AGENTS.md")).content).toBe("intervening operator edit");
+ await writeInstructionEntry(agent, "AGENTS.md", interruptedFiles["AGENTS.md"]);
+ const updated = await svc.ensure(companyId, "reflection-coach");
+ expect(updated.resources.find(resource => resource.resourceKind === "instructions")).toMatchObject({ stockStatus: "stock_current" });
+ expect((await agentInstructionsService().readFile(agent, "AGENTS.md")).content).toBe(currentStock);
+ await expect(agentInstructionsService().readFile(agent, "intermediate-only.txt")).rejects.toMatchObject({ status: 404 });
+ expect((await agentInstructionsService().readFile(agent, "personal.txt")).content).toBe("personal notes");
+ });
+
it("preserves Reflection Coach instruction drift on reconcile and restores it on reset", async () => {
const companyId = await seedCompany();
const builtIns = builtInAgentService(db);
const created = await builtIns.ensure(companyId, "reflection-coach");
const instructions = agentInstructionsService();
- await instructions.writeFile(created.agent!, "AGENTS.md", "# Custom Reflection Coach\n\nOperator edit.\n");
+ await writeInstructionEntry(created.agent!, "AGENTS.md", "# Custom Reflection Coach\n\nOperator edit.\n");
const reconciled = await builtIns.ensure(companyId, "reflection-coach");
const drift = reconciled.resources.find((resource) => resource.resourceKind === "instructions");
@@ -815,7 +927,7 @@ describeEmbeddedPostgres("built-in agents", () => {
content: "# Custom Reflection Coach\n\nOperator edit.\n",
});
- const reset = await builtIns.reset(companyId, "reflection-coach");
+ const reset = await builtIns.reset(companyId, "reflection-coach", {}, instructionOperator);
expect(reset.resources.find((resource) => resource.resourceKind === "instructions")).toMatchObject({
stockStatus: "stock_current",
resetAvailable: false,
@@ -823,6 +935,12 @@ describeEmbeddedPostgres("built-in agents", () => {
const resetFile = await instructions.readFile(reset.agent!, "AGENTS.md");
expect(resetFile.content).toContain("Reflection Coach");
expect(resetFile.content).not.toContain("Operator edit.");
+ const revisions = agentInstructionRevisionService(db);
+ const target = { companyId, agentId: created.agent!.id, entryFile: "AGENTS.md" };
+ const history = await revisions.history(target, instructionOperator);
+ expect(history.revisions).toHaveLength(0);
+ const current = await revisions.readCurrent(target, instructionOperator);
+ expect(current?.content).toBe(resetFile.content);
});
it("blocks deleting a built-in agent", async () => {
@@ -1270,7 +1388,7 @@ describeEmbeddedPostgres("built-in agents", () => {
const created = await builtIns.ensure(companyId, "summarizer");
const instructions = agentInstructionsService();
- await instructions.writeFile(created.agent!, "AGENTS.md", "# Custom Summarizer\n\nOperator edit.\n");
+ await writeInstructionEntry(created.agent!, "AGENTS.md", "# Custom Summarizer\n\nOperator edit.\n");
const reconciled = await builtIns.ensure(companyId, "summarizer");
expect(reconciled.resources.find((resource) => resource.resourceKind === "instructions")).toMatchObject({
@@ -1279,7 +1397,7 @@ describeEmbeddedPostgres("built-in agents", () => {
changedFiles: ["AGENTS.md"],
});
- const reset = await builtIns.reset(companyId, "summarizer");
+ const reset = await builtIns.reset(companyId, "summarizer", {}, instructionOperator);
expect(reset.resources.find((resource) => resource.resourceKind === "instructions")).toMatchObject({
stockStatus: "stock_current",
resetAvailable: false,
@@ -1419,7 +1537,7 @@ describeEmbeddedPostgres("built-in agents", () => {
const coach = created.agent!;
const instructionsSvc = agentInstructionsService();
const originalInstructions = "# Target Coder\n\nWork from the assigned issue.\n";
- const prepared = await instructionsSvc.writeFile(target, "AGENTS.md", originalInstructions);
+ const prepared = await writeInstructionEntry(target, "AGENTS.md", originalInstructions);
let persistedTarget = (await agentsSvc.update(target.id, { adapterConfig: prepared.adapterConfig }))!;
const interactionsSvc = issueThreadInteractionService(db);
@@ -1431,7 +1549,7 @@ describeEmbeddedPostgres("built-in agents", () => {
if (interaction?.kind !== "request_confirmation" || interaction.status !== "accepted") {
return false;
}
- const written = await instructionsSvc.writeFile(persistedTarget, "AGENTS.md", input.nextInstructions);
+ const written = await writeInstructionEntry(persistedTarget, "AGENTS.md", input.nextInstructions);
persistedTarget = (await agentsSvc.update(persistedTarget.id, { adapterConfig: written.adapterConfig }))!;
return true;
};
@@ -1568,7 +1686,7 @@ describeEmbeddedPostgres("built-in agents", () => {
const agent = created.agent!;
const instructionsSvc = agentInstructionsService();
- await instructionsSvc.writeFile(agent, "AGENTS.md", "# Custom Reflection Coach\n\nDo not overwrite me.\n");
+ await writeInstructionEntry(agent, "AGENTS.md", "# Custom Reflection Coach\n\nDo not overwrite me.\n");
await db
.update(companySkills)
.set({ markdown: "---\nname: reflection-coach\n---\n\n# Custom skill\n" })
@@ -1597,7 +1715,7 @@ describeEmbeddedPostgres("built-in agents", () => {
const reset = await builtInAgentService(db).reset(companyId, "reflection-coach", {
resources: ["instructions", "routine"],
- });
+ }, instructionOperator);
expect(reset.resources.find((resource) => resource.resourceKind === "instructions")).toMatchObject({
stockStatus: "stock_current",
resetAvailable: false,
diff --git a/server/src/__tests__/heartbeat-list.test.ts b/server/src/__tests__/heartbeat-list.test.ts
index 465514840ba..b07c8e4368d 100644
--- a/server/src/__tests__/heartbeat-list.test.ts
+++ b/server/src/__tests__/heartbeat-list.test.ts
@@ -264,6 +264,12 @@ describeEmbeddedPostgres("heartbeat list", () => {
summary: "completed",
stdout: oversizedStdout,
nestedHuge: { payload: oversizedNestedPayload },
+ instructionSave: {
+ state: "unavailable", contract: "agent_files", entryFile: "AGENTS.md",
+ errorCode: "AGENT_FILES_LIMIT_EXCEEDED",
+ storageWarning: "Agent storage is full. Runs can continue.".repeat(50),
+ privateSyncMetadata: oversizedNestedPayload,
+ },
workspaceRestoreFailure: "restore_unsafe_archive",
finalResponseRecorded: true,
executionBeforeRestore: { errorCode: "model_error", exitCode: 2, timedOut: false },
@@ -278,6 +284,11 @@ describeEmbeddedPostgres("heartbeat list", () => {
truncated: true,
truncationReason: "oversized_result_json",
stdoutTruncated: true,
+ instructionSave: {
+ state: "unavailable", contract: "agent_files", entryFile: "AGENTS.md",
+ errorCode: "AGENT_FILES_LIMIT_EXCEEDED",
+ storageWarning: "Agent storage is full. Runs can continue.".repeat(50).slice(0, 1024),
+ },
workspaceRestoreFailure: "restore_unsafe_archive",
finalResponseRecorded: true,
executionBeforeRestore: { errorCode: "model_error", exitCode: 2, timedOut: false },
@@ -285,6 +296,7 @@ describeEmbeddedPostgres("heartbeat list", () => {
expect(typeof result?.stdout).toBe("string");
expect((result?.stdout as string).length).toBeLessThan(oversizedStdout.length);
expect(result).not.toHaveProperty("nestedHuge");
+ expect(result?.instructionSave).not.toHaveProperty("privateSyncMetadata");
});
});
diff --git a/server/src/__tests__/plugin-managed-agents.test.ts b/server/src/__tests__/plugin-managed-agents.test.ts
index 34ba238c554..0b06a551266 100644
--- a/server/src/__tests__/plugin-managed-agents.test.ts
+++ b/server/src/__tests__/plugin-managed-agents.test.ts
@@ -7,6 +7,8 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agentConfigRevisions,
+ agentInstructionRevisions,
+ agentInstructionHeads,
agents,
approvals,
companies,
@@ -23,6 +25,9 @@ import {
} from "./helpers/embedded-postgres.js";
import { buildHostServices } from "../services/plugin-host-services.js";
import { agentService } from "../services/agents.js";
+import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
+import { agentInstructionsService } from "../services/agent-instructions.js";
+import { pluginRegistryService } from "../services/plugin-registry.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -312,7 +317,7 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
it("materializes declared managed agent instructions with local folder paths", async () => {
const previousHome = process.env.PAPERCLIP_HOME;
const previousInstance = process.env.PAPERCLIP_INSTANCE_ID;
- const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-home-"));
+ const tempHome = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-home-")));
const wikiRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-wiki-")));
process.env.PAPERCLIP_HOME = tempHome;
process.env.PAPERCLIP_INSTANCE_ID = "test";
@@ -369,6 +374,39 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
expect(content).toContain("You are the LLM Wiki Maintainer.");
expect(content).toContain(`Wiki root: \`${wikiRoot}\``);
expect(content).toContain(`Wiki schema: \`${path.join(wikiRoot, "AGENTS.md")}\``);
+
+ // A managed plugin reset must preserve the previous entry in canonical
+ // history instead of deleting a shared bundle or bypassing its write guard.
+ const edited = "# User-customized wiki instructions\n";
+ await fs.writeFile(instructionsFilePath as string, edited);
+ const reset = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
+ expect(reset.status).toBe("reset");
+ expect(await fs.readFile(instructionsFilePath as string, "utf8")).toBe(content);
+ const history = await db.select().from(agentInstructionRevisions)
+ .where(eq(agentInstructionRevisions.agentId, created.agentId!));
+ expect(history).toHaveLength(0);
+ expect(await db.select().from(agentInstructionHeads)
+ .where(eq(agentInstructionHeads.agentId, created.agentId!))).toHaveLength(0);
+ const repeated = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
+ expect(repeated.status).toBe("reset");
+ expect(await db.select().from(agentInstructionRevisions)
+ .where(eq(agentInstructionRevisions.agentId, created.agentId!))).toHaveLength(0);
+ const audit = await db.select().from(activityLog).where(eq(activityLog.action, "agent.files_updated"));
+ expect(audit).toHaveLength(1);
+ expect(audit.every((row) => row.actorType === "plugin" && row.actorId === pluginId)).toBe(true);
+ const bundles = agentInstructionsService(db);
+ await bundles.writeFile(repeated.agent!, "CUSTOM.md", "# Alternate configured entry\n");
+ const switched = await bundles.updateBundle(repeated.agent!, { entryFile: "CUSTOM.md" });
+ await agentService(db).update(created.agentId!, { adapterConfig: switched.adapterConfig });
+ const resetEntry = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
+ expect(resetEntry.agent?.adapterConfig.instructionsEntryFile).toBe("AGENTS.md");
+ expect(resetEntry.defaultDrift).toBeNull();
+ expect(await fs.readFile(instructionsFilePath as string, "utf8")).toBe(content);
+ const preserved = await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, created.agentId!));
+ expect(preserved).toHaveLength(0);
+ expect(await fs.readFile(path.join(path.dirname(instructionsFilePath as string), "CUSTOM.md"), "utf8")).toBe("# Alternate configured entry\n");
+
+
} finally {
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousHome;
@@ -379,6 +417,60 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
}
});
+ it("fences plugin instruction reset by exact ownership, current capability and canonical CAS", async () => {
+ const previousHome = process.env.PAPERCLIP_HOME;
+ const tempHome = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "plugin-reset-cas-")));
+ process.env.PAPERCLIP_HOME = tempHome;
+ try {
+ const pluginManifest = manifest();
+ pluginManifest.agents![0]!.instructions = { content: "# Default\n" };
+ const { companyId, pluginId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest });
+ const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
+ const target = { companyId, agentId: created.agentId! };
+ const actor = { type: "plugin" as const, pluginId, pluginKey: pluginManifest.id, agentKey: "wiki-maintainer" };
+ const revisions = agentInstructionRevisionService(db);
+ const baselineRead = await revisions.readForPluginReset(target, actor);
+ const baseline = baselineRead.snapshot!;
+ const input = { ...target, entryFile: "AGENTS.md", baseRevisionId: baseline.revision.id,
+ configuredEntryFile: baselineRead.configuredEntryFile, content: "# Reset\n" };
+ await expect(revisions.commitPluginReset(input, { ...actor, pluginId: randomUUID() })).rejects.toMatchObject({ status: 403 });
+ await expect(revisions.commitPluginReset({ ...input, companyId: randomUUID() }, actor)).rejects.toMatchObject({ status: 404 });
+ await expect(revisions.commitPluginReset(input, { ...actor, agentKey: "foreign-agent" })).rejects.toMatchObject({ status: 403 });
+ await db.update(plugins).set({ manifestJson: { ...pluginManifest, capabilities: [] } }).where(eq(plugins.id, pluginId));
+ await expect(revisions.commitPluginReset(input, actor)).rejects.toMatchObject({ status: 403 });
+ await db.update(plugins).set({ manifestJson: pluginManifest }).where(eq(plugins.id, pluginId));
+ const originalMetadata = created.agent!.metadata;
+ await db.update(agents).set({ metadata: {} }).where(eq(agents.id, target.agentId));
+ await expect(revisions.commitPluginReset(input, actor)).rejects.toMatchObject({ status: 403 });
+ await db.update(agents).set({ metadata: originalMetadata }).where(eq(agents.id, target.agentId));
+ const [binding] = await db.select().from(pluginManagedResources).where(eq(pluginManagedResources.resourceId, target.agentId));
+ await db.update(pluginManagedResources).set({ resourceId: randomUUID() }).where(eq(pluginManagedResources.id, binding.id));
+ await expect(revisions.commitPluginReset(input, actor)).rejects.toMatchObject({ status: 403 });
+ await db.update(pluginManagedResources).set({ resourceId: target.agentId }).where(eq(pluginManagedResources.id, binding.id));
+ await expect(revisions.commitPluginReset({ ...input, entryFile: "OTHER.md" }, actor)).rejects.toMatchObject({ status: 403 });
+ const bundles = agentInstructionsService(db);
+ await bundles.writeFile(created.agent!, "OTHER.md", "# Other entry\n");
+ const switched = await bundles.updateBundle(created.agent!, { entryFile: "OTHER.md" });
+ await agentService(db).update(target.agentId, { adapterConfig: switched.adapterConfig });
+ await expect(revisions.commitPluginReset(input, actor)).rejects.toMatchObject({ status: 409, details: { code: "INSTRUCTION_ENTRY_CHANGED" } });
+ await agentService(db).update(target.agentId, { adapterConfig: created.agent!.adapterConfig });
+ const results = await Promise.allSettled([
+ revisions.commitPluginReset(input, actor),
+ revisions.commitPluginReset({ ...input, content: "# Concurrent reset\n" }, actor),
+ ]);
+ expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
+ const loser = results.find((result) => result.status === "rejected") as PromiseRejectedResult;
+ expect(loser.reason).toMatchObject({ status: 409, details: { code: "INSTRUCTION_REVISION_CONFLICT" } });
+ const winner = results.find((result) => result.status === "fulfilled") as PromiseFulfilledResult>>;
+ expect(await fs.readFile(created.agent!.adapterConfig.instructionsFilePath as string, "utf8")).toBe(winner.value.content);
+ const history = await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, target.agentId));
+ expect(history).toHaveLength(0);
+ } finally {
+ if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
+ await fs.rm(tempHome, { recursive: true, force: true });
+ }
+ });
+
it("repairs a missing binding by relinking a same-company managed agent marker", async () => {
const { companyId, pluginId, pluginManifest, services } = await seedCompanyAndPlugin();
const agentId = randomUUID();
@@ -410,6 +502,76 @@ describeEmbeddedPostgres("plugin-managed agents", () => {
expect(binding?.data).toMatchObject({ agentId });
});
+ it("preserves current-file authority when relinking after a hard uninstall and reinstall", async () => {
+ const previousHome = process.env.PAPERCLIP_HOME;
+ const tempHome = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "plugin-reinstall-reset-")));
+ process.env.PAPERCLIP_HOME = tempHome;
+ try {
+ const pluginManifest = manifest();
+ pluginManifest.agents![0]!.instructions = { content: "# Original stock\n" };
+ const { companyId, pluginId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest });
+ const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
+ const target = { companyId, agentId: created.agentId! };
+ const revisions = agentInstructionRevisionService(db);
+ const original = await revisions.readForPluginReset(target, {
+ type: "plugin", pluginId, pluginKey: pluginManifest.id, agentKey: "wiki-maintainer",
+ });
+ const registry = pluginRegistryService(db);
+ await registry.uninstall(pluginId, true);
+ expect(await db.select().from(pluginManagedResources)).toHaveLength(0);
+ expect(await db.select().from(pluginEntities)).toHaveLength(0);
+ const nextManifest = structuredClone(pluginManifest);
+ nextManifest.agents![0]!.instructions = { content: "# Reinstalled stock\n" };
+ const installed = await registry.install({ packageName: "@paperclipai/plugin-managed-agents-test" }, nextManifest);
+ expect(installed!.id).not.toBe(pluginId);
+ await registry.updateStatus(installed!.id, { status: "ready" });
+ const reinstalledServices = buildHostServices(db, installed!.id, nextManifest.id, createEventBusStub(), undefined, {
+ manifest: nextManifest,
+ });
+ const relinked = await reinstalledServices.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
+ expect(relinked.status).toBe("relinked");
+ expect(relinked.agentId).toBe(created.agentId);
+ const reset = await reinstalledServices.agents.managedReset({ companyId, agentKey: "wiki-maintainer" });
+ expect(reset.agentId).toBe(created.agentId);
+ expect(reset.agent!.metadata).toMatchObject({ paperclipManagedResource: { pluginId: installed!.id }, pluginManagedAgent: { pluginId: installed!.id } });
+ expect(await fs.readFile(reset.agent!.adapterConfig.instructionsFilePath as string, "utf8")).toBe("# Reinstalled stock\n");
+ const history = await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, created.agentId!));
+ expect(history).toHaveLength(0);
+ } finally {
+ if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
+ await fs.rm(tempHome, { recursive: true, force: true });
+ }
+ });
+
+ it.each(["not-ready", "capability-removed", "declaration-removed", "other-plugin-owner"])(
+ "does not relink a managed agent when its authority is %s", async (reason) => {
+ const { companyId, pluginId, pluginManifest, services } = await seedCompanyAndPlugin();
+ const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" });
+ await db.delete(pluginEntities);
+ await db.delete(pluginManagedResources);
+ if (reason === "not-ready") {
+ await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, pluginId));
+ } else if (reason === "capability-removed") {
+ await db.update(plugins).set({ manifestJson: { ...pluginManifest, capabilities: [] } }).where(eq(plugins.id, pluginId));
+ } else if (reason === "declaration-removed") {
+ await db.update(plugins).set({ manifestJson: { ...pluginManifest, agents: [] } }).where(eq(plugins.id, pluginId));
+ } else {
+ const otherId = randomUUID();
+ await db.insert(plugins).values({ id: otherId, pluginKey: "paperclip.other-owner", packageName: "other",
+ version: "0.1.0", apiVersion: 1, categories: [], manifestJson: { ...pluginManifest, id: "paperclip.other-owner" }, status: "ready", installOrder: 2 });
+ await db.update(agents).set({ metadata: { ...created.agent!.metadata,
+ paperclipManagedResource: { pluginId: otherId, pluginKey: pluginManifest.id, resourceKind: "agent", resourceKey: "wiki-maintainer" },
+ } }).where(eq(agents.id, created.agentId!));
+ }
+ const [before] = await db.select().from(agents).where(eq(agents.id, created.agentId!));
+ await expect(services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" })).rejects.toMatchObject({ status: 403 });
+ expect(await db.select().from(pluginEntities)).toHaveLength(0);
+ expect(await db.select().from(pluginManagedResources)).toHaveLength(0);
+ const [after] = await db.select().from(agents).where(eq(agents.id, created.agentId!));
+ expect(after.metadata).toEqual(before.metadata);
+ },
+ );
+
it("respects board approval policy for new managed agents", async () => {
const { companyId, services } = await seedCompanyAndPlugin({ requireApproval: true });
diff --git a/server/src/__tests__/rerun-survival.integration.test.ts b/server/src/__tests__/rerun-survival.integration.test.ts
index 1708febeadf..aa3d5cb48c4 100644
--- a/server/src/__tests__/rerun-survival.integration.test.ts
+++ b/server/src/__tests__/rerun-survival.integration.test.ts
@@ -9,7 +9,7 @@ import {
writeEnvFileAtomicallyIfChanged,
} from "@paperclipai/shared/env-file";
import { resourceStatus, stockHash } from "../services/managed-resource-drift.js";
-import { agentInstructionsService } from "../services/agent-instructions.js";
+import { agentInstructionsService, resolveManagedInstructionsRoot } from "../services/agent-instructions.js";
/**
* Cross-cutting setup/sync rerun survival test.
@@ -23,7 +23,7 @@ import { agentInstructionsService } from "../services/agent-instructions.js";
* - .env -> updateEnvFileContents + writeEnvFileAtomicallyIfChanged
* - managed sandbox env -> resourceStatus/stockHash drift gate (the `shouldWrite`
* classifier the boot reconciler and built-in sync share)
- * - managed instructions -> agentInstructionsService materialize + the drift gate that
+ * - managed instructions -> agentInstructionsService export + the drift gate that
* makes built-in/plugin/portability rebuilds skip operator edits
*
* The DB-backed end-to-end variants of the sandbox-row, skills-assignment, and
@@ -31,7 +31,7 @@ import { agentInstructionsService } from "../services/agent-instructions.js";
* server/src/__tests__/company-skills-service.test.ts (skills add/remove/replace)
* server/src/services/managed-environments.test.ts (sandbox row boot reconcile)
* server/src/__tests__/environment-service.test.ts (sandbox skip + stock update)
- * server/src/__tests__/agent-instructions-service.test.ts (managed instruction drift)
+ * server/src/__tests__/agent-instruction-revisions.test.ts (guarded bundle materialization)
*/
const cleanupDirs = new Set();
@@ -147,7 +147,7 @@ describe("setup/sync rerun survival — cross-cutting", () => {
).toBe("stock_current");
});
- it("keeps operator edits and additions in a managed instructions tree across a re-materialize", async () => {
+ it("keeps operator edits and additions when managed instructions reconciliation detects drift", async () => {
const home = await tmp("pap16587-instr-home-");
process.env.PAPERCLIP_HOME = home;
process.env.PAPERCLIP_INSTANCE_ID = "test-instance";
@@ -156,18 +156,25 @@ describe("setup/sync rerun survival — cross-cutting", () => {
const agent = { id: "agent-1", companyId: "company-1", name: "Agent 1", adapterConfig: {} };
const stockFiles = { "AGENTS.md": "# stock\n", "docs/TOOLS.md": "## stock tools\n" };
- const first = await svc.materializeManagedBundle(agent, stockFiles, { entryFile: "AGENTS.md" });
- const root = first.bundle.managedRootPath!;
+ // Initial files are fixture setup. Bundle materialization now requires a
+ // real database so that it cannot bypass a canonical instruction revision.
+ const root = resolveManagedInstructionsRoot(agent);
+ for (const [name, content] of Object.entries(stockFiles)) {
+ await fs.mkdir(path.dirname(path.join(root, name)), { recursive: true });
+ await fs.writeFile(path.join(root, name), content, "utf8");
+ }
+ const managedAgent = { ...agent, adapterConfig: {
+ instructionsBundleMode: "managed", instructionsRootPath: root,
+ instructionsEntryFile: "AGENTS.md", instructionsFilePath: path.join(root, "AGENTS.md"),
+ } };
// Operator hand-edits a managed file and adds a brand-new operator-only file.
await fs.writeFile(path.join(root, "AGENTS.md"), "# stock\n\noperator note\n", "utf8");
await fs.writeFile(path.join(root, "OPERATOR.md"), "operator-added\n", "utf8");
// The drift gate the built-in/plugin/portability rebuilds share: classify the tree.
- const currentFiles = {
- "AGENTS.md": await fs.readFile(path.join(root, "AGENTS.md"), "utf8"),
- "docs/TOOLS.md": await fs.readFile(path.join(root, "docs", "TOOLS.md"), "utf8"),
- };
+ const currentFiles = (await svc.exportFiles(managedAgent)).files;
+ expect(currentFiles["OPERATOR.md"]).toBe("operator-added\n");
const status = resourceStatus({
resourceId: agent.id,
currentHash: stockHash(currentFiles),
diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts
index 2efcc764571..756d0c7c377 100644
--- a/server/src/routes/agents.ts
+++ b/server/src/routes/agents.ts
@@ -1,3 +1,5 @@
+import { agentFileStore, agentFileTokenFromHash } from "../services/agent-file-store.js";
+import { pipeline } from "node:stream/promises";
import { resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared";
import { listOpenRouterModels } from "../services/openrouter-models.js";
import { prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings } from "../services/ai-connection-runtime.js";
@@ -38,6 +40,8 @@ import {
type AgentSkillSnapshot,
type InstanceSchedulerHeartbeatAgent,
upsertAgentInstructionsFileSchema,
+ restoreAgentInstructionSchema,
+ resolveAgentInstructionCandidateSchema,
updateAgentInstructionsBundleSchema,
updateAgentPermissionsSchema,
updateAgentInstructionsPathSchema,
@@ -63,7 +67,11 @@ import {
import { trackAgentCreated } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
import { inheritNativeRunnerAdapterConfig } from "../services/native-runtime/native-agent-runtime-inheritance.js";
-import { agentInstructionsBundleMode } from "../services/agent-instructions.js";
+import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js";
+import { agentInstructionWorkingCopyService } from "../services/agent-instruction-working-copies.js";
+import { authorizeInstructionRead } from "../services/agent-instruction-authorization.js";
+import { instructionPath } from "../services/agent-instruction-files.js";
+import { agentInstructionsBundleMode, deriveBundleState } from "../services/agent-instructions.js";
import {
agentService,
agentInstructionsService,
@@ -724,7 +732,17 @@ export function agentRoutes(
const recovery = recoveryService(db, { enqueueWakeup: heartbeat.wakeup });
const issueApprovalsSvc = issueApprovalService(db);
const secretsSvc = secretService(db);
- const instructions = agentInstructionsService();
+ const instructions = agentInstructionsService(db);
+ const agentFiles = agentFileStore(db);
+ const instructionRevisions = agentInstructionRevisionService(db);
+ const instructionWorkingCopies = agentInstructionWorkingCopyService(db);
+ function instructionFileDetail(snapshot: import("@paperclipai/shared").AgentInstructionSnapshot,
+ receipt?: import("@paperclipai/shared").AgentInstructionCommitReceipt) {
+ const path = snapshot.revision.entryFile;
+ return { path, content: snapshot.content, contentHash: snapshot.revision.contentHash, size: snapshot.revision.byteLength, revision: snapshot.revision, receipt,
+ language: path.toLowerCase().endsWith(".md") ? "markdown" : "text", markdown: path.toLowerCase().endsWith(".md"),
+ isEntryFile: true, editable: true, deprecated: false, virtual: false };
+ }
const companySkills = companySkillService(db);
const workspaceOperations = workspaceOperationService(db);
const instanceSettings = instanceSettingsService(db);
@@ -5067,8 +5085,24 @@ export function agentRoutes(
const id = req.params.id as string;
const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found");
if (!existing) return;
- await assertCanReadAgent(req, existing);
assertExternalInstructionsAdmin(req, existing);
+ if (agentInstructionsBundleMode(existing) === "external") {
+ await assertCanReadAgent(req, existing);
+ } else {
+ await authorizeInstructionRead(db, req.actor, { companyId: existing.companyId, id: existing.id });
+ }
+ if (agentInstructionsBundleMode(existing) !== "external") {
+ const target = { companyId: existing.companyId, agentId: existing.id };
+ const current = await instructionRevisions.readCurrent(target, req.actor);
+ if (current) {
+ try { await instructionRevisions.materializeCurrent(target); }
+ catch (error) {
+ const bundle = await instructions.getBundle(existing);
+ bundle.warnings.push(`Saved instruction revision needs materialization: ${error instanceof Error ? error.message : String(error)}`);
+ res.json(bundle); return;
+ }
+ }
+ }
res.json(await instructions.getBundle(existing));
});
@@ -5124,8 +5158,12 @@ export function agentRoutes(
const id = req.params.id as string;
const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found");
if (!existing) return;
- await assertCanReadAgent(req, existing);
assertExternalInstructionsAdmin(req, existing);
+ if (agentInstructionsBundleMode(existing) === "external") {
+ await assertCanReadAgent(req, existing);
+ } else {
+ await authorizeInstructionRead(db, req.actor, { companyId: existing.companyId, id: existing.id });
+ }
const relativePath = typeof req.query.path === "string" ? req.query.path : "";
if (!relativePath.trim()) {
@@ -5133,6 +5171,19 @@ export function agentRoutes(
return;
}
+ if (req.query.download === "true" && agentInstructionsBundleMode(existing) === "managed") {
+ const download = await agentFiles.download(existing.companyId, existing.id, relativePath, req.actor);
+ if (download === null) throw notFound("Agent file not found");
+ res.setHeader("Content-Type", "application/octet-stream");
+ res.setHeader("X-Content-Type-Options", "nosniff");
+ res.attachment(relativePath.split("/").at(-1)!);
+ res.setHeader("Content-Length", download.size);
+ await pipeline(download.stream, res); return;
+ }
+ if (agentInstructionsBundleMode(existing) !== "external" && instructionPath(relativePath) === deriveBundleState(existing).entryFile) {
+ const snapshot = await instructionRevisions.readCurrent({ companyId: existing.companyId, agentId: existing.id }, req.actor);
+ if (snapshot) { res.json(instructionFileDetail(snapshot)); return; }
+ }
res.json(await instructions.readFile(existing, relativePath));
});
@@ -5140,9 +5191,42 @@ export function agentRoutes(
const id = req.params.id as string;
const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found");
if (!existing) return;
+ const entryFile = deriveBundleState(existing).entryFile;
+ if (instructionPath(req.body.path) === entryFile) {
+ assertExternalInstructionsAdmin(req, existing);
+ if (req.body.baseHash !== undefined) req.body.baseRevisionId = req.body.baseHash === null ? null : agentFileTokenFromHash(req.body.baseHash);
+ if (req.body.baseRevisionId === undefined) throw unprocessable("Read the entry and supply baseRevisionId (null for a new entry)", { code: "INSTRUCTION_BASE_REQUIRED" });
+ // Clearing legacy prompt configuration remains a protected config change.
+ if (req.body.clearLegacyPromptTemplate) await assertCanManageInstructionsPath(req, existing);
+ const receipt = await instructionRevisions.commit({ companyId: existing.companyId, agentId: existing.id,
+ entryFile, content: req.body.content, baseRevisionId: req.body.baseRevisionId,
+ source: req.actor.type === "board" ? "board" : "api" }, req.actor);
+ if (req.actor.type === "agent" && req.actor.runId) {
+ await instructionWorkingCopies.acknowledgeExplicitSave({ companyId: existing.companyId, agentId: existing.id,
+ runId: req.actor.runId, entryFile: receipt.revision.entryFile, revisionId: receipt.revision.id,
+ contentHash: receipt.revision.contentHash }).catch(() => undefined);
+ }
+ if (req.body.clearLegacyPromptTemplate) {
+ const fresh = await svc.getById(existing.id);
+ if (fresh) {
+ const adapterConfig = { ...asRecord(fresh.adapterConfig) };
+ delete adapterConfig.promptTemplate;
+ delete adapterConfig.bootstrapPromptTemplate;
+ await svc.update(existing.id, { adapterConfig });
+ }
+ }
+ res.json(instructionFileDetail(receipt, receipt));
+ return;
+ }
await assertCanManageInstructionsPath(req, existing);
assertExternalInstructionsAdmin(req, existing);
+ if (agentInstructionsBundleMode(existing) === "managed" && req.body.path !== "promptTemplate.legacy.md") {
+ if (req.body.baseHash === undefined) throw unprocessable("Read the file and supply baseHash (null for a new file)");
+ await agentFiles.write({ companyId: existing.companyId, agentId: existing.id, path: req.body.path,
+ bytes: Buffer.from(req.body.content, "utf8"), baseHash: req.body.baseHash }, req.actor);
+ res.json(await instructions.readFile(existing, req.body.path)); return;
+ }
const actor = getActorInfo(req);
const result = await instructions.writeFile(existing, req.body.path, req.body.content, {
clearLegacyPromptTemplate: req.body.clearLegacyPromptTemplate,
@@ -5184,6 +5268,66 @@ export function agentRoutes(
res.json(result.file);
});
+ router.get("/agents/:id/instructions-bundle/candidates", async (req, res) => {
+ const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found");
+ if (!existing) return;
+ assertExternalInstructionsAdmin(req, existing);
+ res.json(await instructionWorkingCopies.list(existing.companyId, existing.id, req.actor));
+ });
+
+ router.post("/agents/:id/instructions-bundle/candidates/:runId/resolve", validate(resolveAgentInstructionCandidateSchema), async (req, res) => {
+ const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found");
+ if (!existing) return;
+ assertExternalInstructionsAdmin(req, existing);
+ const runId = req.params.runId as string;
+ if (!isUuidLike(runId)) throw unprocessable("Invalid instruction candidate run id");
+ const receipt = await instructionWorkingCopies.resolve({ companyId: existing.companyId, agentId: existing.id,
+ runId, baseRevisionId: req.body.baseRevisionId, content: req.body.content }, req.actor);
+ res.json(instructionFileDetail(receipt, receipt));
+ });
+
+ router.get("/agents/:id/instructions-bundle/history", async (req, res) => {
+ const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found");
+ if (!existing) return;
+ assertExternalInstructionsAdmin(req, existing);
+ const entryFile = typeof req.query.path === "string" ? req.query.path : deriveBundleState(existing).entryFile;
+ const cursor = typeof req.query.cursor === "string" ? req.query.cursor : undefined;
+ if (cursor && !isUuidLike(cursor)) throw unprocessable("Invalid history cursor");
+ res.json(await instructionRevisions.history({ companyId: existing.companyId, agentId: existing.id, entryFile, cursor }, req.actor));
+ });
+ router.get("/agents/:id/instructions-bundle/revision/:revisionId", async (req, res) => {
+ const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found");
+ if (!existing) return;
+ assertExternalInstructionsAdmin(req, existing);
+ const revisionId = req.params.revisionId as string;
+ if (!isUuidLike(revisionId)) throw unprocessable("Invalid instruction revision id");
+ const entryFile = typeof req.query.path === "string" ? req.query.path : deriveBundleState(existing).entryFile;
+ res.json(await instructionRevisions.readRevision({ companyId: existing.companyId, agentId: existing.id, entryFile, revisionId }, req.actor));
+ });
+ router.get("/agents/:id/instructions-bundle/diff", async (req, res) => {
+ const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found");
+ if (!existing) return;
+ assertExternalInstructionsAdmin(req, existing);
+ const fromRevisionId = typeof req.query.from === "string" ? req.query.from : "";
+ const toRevisionId = typeof req.query.to === "string" ? req.query.to : "";
+ if (!isUuidLike(fromRevisionId) || !isUuidLike(toRevisionId)) throw unprocessable("Provide valid from and to revision ids");
+ const entryFile = typeof req.query.path === "string" ? req.query.path : deriveBundleState(existing).entryFile;
+ res.json(await instructionRevisions.diff({ companyId: existing.companyId, agentId: existing.id, entryFile, fromRevisionId, toRevisionId }, req.actor));
+ });
+ router.post("/agents/:id/instructions-bundle/restore", validate(restoreAgentInstructionSchema), async (req, res) => {
+ const existing = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found");
+ if (!existing) return;
+ assertExternalInstructionsAdmin(req, existing);
+ const receipt = await instructionRevisions.restore({ companyId: existing.companyId, agentId: existing.id,
+ entryFile: req.body.path, baseRevisionId: req.body.baseRevisionId, revisionId: req.body.revisionId }, req.actor);
+ if (req.actor.type === "agent" && req.actor.runId) {
+ await instructionWorkingCopies.acknowledgeExplicitSave({ companyId: existing.companyId, agentId: existing.id,
+ runId: req.actor.runId, entryFile: receipt.revision.entryFile, revisionId: receipt.revision.id,
+ contentHash: receipt.revision.contentHash }).catch(() => undefined);
+ }
+ res.json(instructionFileDetail(receipt, receipt));
+ });
+
router.delete("/agents/:id/instructions-bundle/file", async (req, res) => {
const id = req.params.id as string;
const existing = await getAccessibleResource(req, res, svc.getById(id), "Agent not found");
@@ -5197,6 +5341,12 @@ export function agentRoutes(
return;
}
+ if (agentInstructionsBundleMode(existing) === "managed") {
+ const baseHash = typeof req.query.baseHash === "string" && /^[a-f0-9]{64}$/.test(req.query.baseHash) ? req.query.baseHash : null;
+ if (baseHash === null) throw unprocessable("Read the file and supply baseHash before deleting it");
+ await agentFiles.write({ companyId: existing.companyId, agentId: existing.id, path: relativePath, bytes: null, baseHash }, req.actor);
+ res.json(await instructions.getBundle(existing)); return;
+ }
const actor = getActorInfo(req);
const result = await instructions.deleteFile(existing, relativePath);
await logActivity(db, {
diff --git a/server/src/routes/built-in-agents.ts b/server/src/routes/built-in-agents.ts
index 9c16ba6f53b..c881ae9c93f 100644
--- a/server/src/routes/built-in-agents.ts
+++ b/server/src/routes/built-in-agents.ts
@@ -217,7 +217,7 @@ export function builtInAgentRoutes(db: Db) {
const key = req.params.key as string;
await assertBuiltInAgentsEnabled();
await assertCanProvisionBuiltInAgents(req, companyId);
- const state = await svc.reset(companyId, key, req.body);
+ const state = await svc.reset(companyId, key, req.body, req.actor);
await logBuiltInAgentMutation(req, {
companyId,
action: "built_in_agent.reset",
diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts
index 9cd65e6f6d0..38b876197d6 100644
--- a/server/src/routes/openapi.ts
+++ b/server/src/routes/openapi.ts
@@ -27,6 +27,8 @@ import {
updateAgentInstructionsPathSchema,
updateAgentInstructionsBundleSchema,
upsertAgentInstructionsFileSchema,
+ restoreAgentInstructionSchema,
+ resolveAgentInstructionCandidateSchema,
createAgentKeySchema,
builtInAgentEmptyMutationSchema,
builtInAgentProvisionSchema,
@@ -3455,8 +3457,8 @@ registry.registerPath({
method: "get",
path: "/api/agents/{id}/instructions-bundle/file",
tags: ["agents"],
- summary: "Get agent instructions file",
- request: { params: z.object({ id: z.string() }) },
+ summary: "Get agent file content or download its original bytes",
+ request: { params: z.object({ id: z.string() }), query: z.object({ path: z.string(), download: z.enum(["true", "false"]).optional() }) },
responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound },
});
@@ -3472,12 +3474,46 @@ registry.registerPath({
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
});
+for (const operation of [
+ { suffix: "history", summary: "List immutable instruction revisions", query: z.object({ path: z.string().optional(), cursor: z.string().uuid().optional() }) },
+ { suffix: "revision/{revisionId}", summary: "Read exact instruction content at a revision", query: z.object({ path: z.string().optional() }) },
+ { suffix: "diff", summary: "Compare instruction revisions (exact common prefix, removed, added, suffix)", query: z.object({ path: z.string().optional(), from: z.string().uuid(), to: z.string().uuid() }) },
+]) {
+ registry.registerPath({ method: "get", path: `/api/agents/{id}/instructions-bundle/${operation.suffix}`, tags: ["agents"], summary: operation.summary,
+ request: { params: operation.suffix.includes("revisionId") ? z.object({ id: z.string(), revisionId: z.string().uuid() }) : z.object({ id: z.string() }), query: operation.query },
+ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound } });
+}
+registry.registerPath({ method: "post", path: "/api/agents/{id}/instructions-bundle/restore", tags: ["agents"], summary: "Restore a pre-upgrade instruction snapshot into current files with compare-and-swap",
+ request: { params: z.object({ id: z.string() }), body: jsonBody(restoreAgentInstructionSchema) },
+ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict } });
+
+registry.registerPath({
+ method: "get",
+ path: "/api/agents/{id}/instructions-bundle/candidates",
+ tags: ["agents"],
+ summary: "List preserved instruction edits and collection diagnostics",
+ request: { params: z.object({ id: z.string() }) },
+ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
+});
+
+registry.registerPath({
+ method: "post",
+ path: "/api/agents/{id}/instructions-bundle/candidates/{runId}/resolve",
+ tags: ["agents"],
+ summary: "Explicitly save a preserved instruction edit with compare-and-swap",
+ request: {
+ params: z.object({ id: z.string(), runId: z.string().uuid() }),
+ body: jsonBody(resolveAgentInstructionCandidateSchema),
+ },
+ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable },
+});
+
registry.registerPath({
method: "delete",
path: "/api/agents/{id}/instructions-bundle/file",
tags: ["agents"],
- summary: "Delete agent instructions file",
- request: { params: z.object({ id: z.string() }) },
+ summary: "Delete agent file with compare-and-swap for managed storage",
+ request: { params: z.object({ id: z.string() }), query: z.object({ path: z.string(), baseHash: z.string().regex(/^[a-f0-9]{64}$/).optional() }) },
responses: { 200: r.ok(), 401: r.unauthorized },
});
diff --git a/server/src/services/agent-directory-working-copies.ts b/server/src/services/agent-directory-working-copies.ts
new file mode 100644
index 00000000000..ab3dde2e112
--- /dev/null
+++ b/server/src/services/agent-directory-working-copies.ts
@@ -0,0 +1,258 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { and, eq } from "drizzle-orm";
+import { agents, environmentLeases, environments, agentInstructionWorkingCopies as copies, type Db } from "@paperclipai/db";
+import { syncDirectoryToSsh, restoreWorkspaceFromSshExecution } from "@paperclipai/adapter-utils/ssh";
+import { prepareAdapterExecutionTargetRuntime, runAdapterExecutionTargetShellCommand, type AdapterExecutionTarget, type PreparedAdapterExecutionTargetRuntime } from "@paperclipai/adapter-utils/execution-target";
+import { withDirectoryMergeLock, directorySnapshotSha256, parseDirectorySnapshot, serializeDirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge";
+import { AGENT_FILES_CONTRACT, AgentFileLimitError, agentFileStore, agentStorageWarning, inspectAgentDirectory } from "./agent-file-store.js";
+import { agentInstructionsBundleMode, deriveBundleState, resolveManagedInstructionsRoot } from "./agent-instructions.js";
+import { instructionGitExcludeProgram } from "./agent-instruction-files.js";
+import { resolveInstructionActor } from "./agent-instruction-authorization.js";
+import { HttpError, conflict, notFound } from "../errors.js";
+import type { AuthorizationActor } from "./authorization.js";
+import type { EnvironmentRuntimeService } from "./environment-runtime.js";
+import type { Environment, EnvironmentLease } from "@paperclipai/shared";
+import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js";
+
+type Copy = typeof copies.$inferSelect;
+const completed = new Set(["saved", "unchanged", "resolved", "unavailable"]);
+const transports = new Map();
+const key = (row: Pick) => `${row.companyId}:${row.runId}`;
+export function isAgentDirectoryCopy(row: Pick | null): boolean {
+ return row?.receipt?.schema === AGENT_FILES_CONTRACT;
+}
+function baseline(row: Copy) {
+ const snapshot = parseDirectorySnapshot(row.receipt?.baseline);
+ if (!snapshot || directorySnapshotSha256(snapshot) !== row.baseHash) throw new Error("Agent directory baseline is invalid");
+ return snapshot;
+}
+function actor(row: Copy): AuthorizationActor {
+ return { type: "agent", companyId: row.companyId, agentId: row.agentId, runId: row.runId, onBehalfOfUserId: row.responsibleUserId };
+}
+export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string, runId: string) => Promise, patch: (row: Copy, values: Partial) => Promise, environmentRuntime?: EnvironmentRuntimeService) {
+ const store = agentFileStore(db);
+ async function transport(row: Copy, target: AdapterExecutionTarget, recovering: boolean) {
+ if (target.kind !== "remote" || row.location !== `remote:${target.environmentId ?? ""}`) throw new Error("Agent directory environment changed");
+ if (recovering && target.transport === "ssh") throw new Error("The original SSH collector is unavailable");
+ if (target.transport === "ssh") {
+ // Reuse SSH's plain directory transfer without its task-workspace suffix
+ // or Git-history discovery. The registered root is the exact writable root.
+ await syncDirectoryToSsh({ spec: target.spec, localDir: row.localRoot, remoteDir: row.executionRoot, exclude: [".paperclip-runtime"] });
+ return { target, workspaceRemoteDir: row.executionRoot, runtimeRootDir: null,
+ assetDirs: {}, additionalSourceDirs: {}, additionalSourceFailures: [], workspaceSyncSnapshot: null,
+ restoreWorkspace: () => restoreWorkspaceFromSshExecution({ spec: target.spec, localDir: row.localRoot,
+ remoteDir: row.executionRoot, baselineSnapshot: { ...baseline(row), exclude: [".paperclip-runtime"] }, restoreGitHistory: false }) };
+ }
+ return prepareAdapterExecutionTargetRuntime({ target, runId: row.runId, adapterKey: "agent-files",
+ workspaceLocalDir: row.localRoot, workspaceRemoteDir: row.executionRoot,
+ syncWorkspace: true, workspaceInboundMode: recovering ? "adopt_remote" : undefined,
+ workspaceBaseline: baseline(row), workspaceGitSnapshot: null, workspaceFileMode: "all",
+ workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] });
+ }
+ async function prepare(input: { companyId: string; agentId: string; runId: string; target?: AdapterExecutionTarget | null; cwd: string }) {
+ const [agent] = await db.select().from(agents).where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId)));
+ if (!agent) throw notFound("Agent not found");
+ if (agentInstructionsBundleMode(agent) !== "managed") return null;
+ const bound = await resolveInstructionActor(db, { type: "agent", companyId: input.companyId, agentId: input.agentId, runId: input.runId });
+ const root = resolveManagedInstructionsRoot(agent);
+ const localRoot = path.join(path.dirname(root), "file-sync", "runs", input.runId, "live");
+ // Local copies live outside the task cwd. Remote copies use the reserved,
+ // excluded runtime area inside the provider's confined workspace. They are
+ // synchronized independently; provider HOME is never reinterpreted.
+ const executionRoot = input.target?.kind === "remote"
+ ? path.posix.join(input.target.remoteCwd, ".paperclip-runtime", "agent-files", input.agentId, input.runId)
+ : localRoot;
+ const location = input.target?.kind === "remote" ? `remote:${input.target.environmentId ?? ""}` : "local";
+ let row = await get(input.companyId, input.runId);
+ if (row && (row.localRoot !== localRoot || row.executionRoot !== executionRoot || row.agentId !== input.agentId || row.location !== location)) throw conflict("Agent directory belongs to a different execution environment");
+ if (row && !completed.has(row.state) && row.state !== "preparing") {
+ if (input.target?.kind === "remote" && !transports.has(key(row))) transports.set(key(row), await transport(row, input.target, true));
+ return row;
+ }
+ // Register ownership before creating any bytes, so a crash during the copy
+ // leaves a recoverable preparing receipt instead of an orphan directory.
+ const preparing = { entryFile: deriveBundleState(agent).entryFile, baseRevisionId: null, baseHash: "preparing",
+ localRoot, executionRoot, location, state: "preparing", candidateBase64: null, candidateHash: null,
+ receipt: { schema: AGENT_FILES_CONTRACT, ...(input.target?.kind === "remote"
+ ? { cleanup: { leaseId: input.target.leaseId ?? null, remoteCwd: input.target.remoteCwd } } : {}) }, processStoppedAt: null, attempts: 0,
+ nextAttemptAt: null, errorCode: null, errorMessage: null };
+ if (row) row = await patch(row, preparing);
+ else {
+ await db.insert(copies).values({ runId: input.runId, companyId: input.companyId, agentId: input.agentId, responsibleUserId: bound.onBehalfOfUserId!, ...preparing });
+ row = (await get(input.companyId, input.runId))!;
+ }
+ const { snapshot, storageWarning } = await store.locked(input.companyId, input.agentId, bound, false, async (_tx, _agent, canonical) => {
+ // Storage quotas govern saves, never admission to a future run. Restore
+ // existing bytes so the agent can work normally and remove excess files.
+ // Path/link validation remains mandatory even for an over-quota folder.
+ const inspected = await inspectAgentDirectory(canonical, false);
+ // A stopped lifecycle has already accounted for all its bytes. No live
+ // or pending candidate ever enters this replacement path.
+ await fs.rm(localRoot, { recursive: true, force: true });
+ await fs.mkdir(path.dirname(localRoot), { recursive: true, mode: 0o700 });
+ try {
+ await fs.cp(canonical, localRoot, { recursive: true, preserveTimestamps: true });
+ return inspected;
+ } catch (error) {
+ await fs.rm(path.dirname(localRoot), { recursive: true, force: true });
+ throw error;
+ }
+ }).catch(async error => {
+ await fs.rm(path.dirname(localRoot), { recursive: true, force: true });
+ throw error;
+ });
+ const values = { entryFile: deriveBundleState(agent).entryFile, baseRevisionId: null, baseHash: directorySnapshotSha256(snapshot),
+ localRoot, executionRoot, location, state: "preparing", candidateBase64: null, candidateHash: null,
+ receipt: { ...preparing.receipt, baseline: serializeDirectorySnapshot(snapshot), storageWarning },
+ processStoppedAt: null, attempts: 0, nextAttemptAt: null, errorCode: null, errorMessage: null };
+ try {
+ row = await patch(row, values);
+ } catch (error) {
+ await fs.rm(path.dirname(localRoot), { recursive: true, force: true });
+ throw error;
+ }
+ try {
+ if (input.target?.kind === "remote") {
+ const quote = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`;
+ const excluded = await runAdapterExecutionTargetShellCommand(input.runId, input.target,
+ `node -e ${quote(instructionGitExcludeProgram)} ${quote(input.target.remoteCwd)}`,
+ { cwd: input.target.remoteCwd, env: {}, timeoutSec: 15 });
+ if (excluded.exitCode !== 0 || excluded.timedOut) throw new Error("Could not exclude agent files from task Git staging");
+ transports.set(key(row), await transport(row, input.target, false));
+ }
+ return await patch(row, { state: "prepared" });
+ } catch (error) {
+ // Preparation failed before a provider could start using this copy.
+ row = await patch(row, { state: "unavailable", processStoppedAt: new Date(), errorCode: "AGENT_FILES_PREPARE_FAILED",
+ errorMessage: "Agent files could not be staged. The temporary copy was discarded.", nextAttemptAt: null });
+ await release(row, input.target);
+ throw error;
+ }
+ }
+
+ async function retrieve(row: Copy, target?: AdapterExecutionTarget | null) {
+ if (row.location !== "local") {
+ let runtime = transports.get(key(row));
+ if (!runtime) {
+ if (!target) throw new Error("Agent directory transport is unavailable");
+ runtime = await transport(row, target, true);
+ transports.set(key(row), runtime);
+ }
+ await runtime.restoreWorkspace();
+ }
+ return inspectAgentDirectory(row.localRoot);
+ }
+ async function hasChanges(_row: Copy, _target?: AdapterExecutionTarget | null) {
+ // A whole-directory collector needs a quiescent provider, including its
+ // child processes. Checkpoint and close at the turn boundary before reading
+ // either local or remote files. The provider conversation remains resumable;
+ // ordinary file edits do not change the session configuration digest.
+ return true;
+ }
+ async function collectStopped(row: Copy, target?: AdapterExecutionTarget | null) {
+ if (completed.has(row.state)) { await release(row); return (await get(row.companyId, row.runId))!; }
+ row = await patch(row, { processStoppedAt: row.processStoppedAt ?? new Date() });
+ // The stopped working copy is the only temporary tree. Retry transient I/O
+ // at this boundary, then clean it up; there are no preserved run snapshots.
+ let inspected: Awaited> | undefined;
+ for (;;) {
+ row = await patch(row, { attempts: row.attempts + 1 });
+ try {
+ inspected ??= await retrieve(row, target);
+ const { snapshot } = inspected;
+ const candidateHash = directorySnapshotSha256(snapshot);
+ let storageWarning = inspected.storageWarning;
+ if (candidateHash === row.baseHash) {
+ row = await patch(row, { state: "unchanged", errorCode: null, errorMessage: null, nextAttemptAt: null });
+ } else {
+ ({ storageWarning } = await store.apply({ companyId: row.companyId, agentId: row.agentId, sourceDir: row.localRoot, baseline: baseline(row) }, actor(row)));
+ row = await patch(row, { state: "saved", candidateHash, errorCode: null, errorMessage: null, nextAttemptAt: null });
+ }
+ row = await patch(row, { receipt: { ...row.receipt, storageWarning } });
+ break;
+ } catch (error) {
+ const retryable = !(error instanceof HttpError) || error.status >= 500;
+ if (retryable && row.attempts < 3) continue;
+ row = await patch(row, { state: "unavailable", nextAttemptAt: null,
+ receipt: { ...row.receipt, storageWarning: error instanceof AgentFileLimitError ? agentStorageWarning(error.message) : row.receipt?.storageWarning ?? null },
+ errorCode: error instanceof AgentFileLimitError ? "AGENT_FILES_LIMIT_EXCEEDED" : "AGENT_FILES_SAVE_FAILED",
+ errorMessage: error instanceof HttpError && error.status === 422
+ ? `${error.message}. This run's agent-folder changes were not saved; the temporary copy is discarded.`
+ : "Agent-file synchronization failed. No successful save is claimed; the temporary copy is discarded.",
+ });
+ break;
+ }
+ }
+ await release(row);
+ return (await get(row.companyId, row.runId))!;
+ }
+ async function release(row: Copy, target?: AdapterExecutionTarget | null) {
+ // Collection and environment teardown can both release the same copy.
+ // A compact successful cleanup receipt is final, even after restart.
+ if (completed.has(row.state) && row.processStoppedAt && row.receipt?.cleanupPending === false) return;
+ const runtime = transports.get(key(row));
+ transports.delete(key(row));
+ let cleanupPending = false;
+ await runtime?.cleanupWorkspaceSnapshot?.().catch(() => { cleanupPending = true; });
+ const cleanupTarget = runtime?.target ?? target;
+ if (row.processStoppedAt && completed.has(row.state) && cleanupTarget?.kind === "remote") {
+ const expected = path.posix.join(cleanupTarget.remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, row.runId);
+ if (row.executionRoot !== expected) throw new Error("Agent directory cleanup path changed");
+ const quoted = `'${expected.replaceAll("'", `'"'"'`)}'`;
+ const remoteCleanupFailed = await runAdapterExecutionTargetShellCommand(row.runId, cleanupTarget, `rm -rf -- ${quoted}`,
+ { cwd: cleanupTarget.remoteCwd, env: {}, timeoutSec: 15 }).then(result => result.exitCode !== 0 || result.timedOut, () => true);
+ cleanupPending ||= remoteCleanupFailed;
+ } else if (row.processStoppedAt && completed.has(row.state) && row.location !== "local") {
+ // Rebind only the original host-owned lease. Never acquire a replacement
+ // sandbox or persist transport credentials in a working-copy receipt.
+ cleanupPending ||= !await cleanupRemoteAfterRestart(row).catch(() => false);
+ }
+ if (completed.has(row.state) && row.processStoppedAt) {
+ try { await fs.rm(path.dirname(row.localRoot), { recursive: true, force: true }); }
+ catch {
+ // Leave the baseline marker so restart recovery retries failed cleanup.
+ await patch(row, { receipt: { ...row.receipt, cleanupPending: true } });
+ return;
+ }
+ await patch(row, { receipt: { schema: AGENT_FILES_CONTRACT, state: row.state, appliedCandidateHash: row.candidateHash, storageWarning: row.receipt?.storageWarning ?? null, cleanupPending,
+ ...(cleanupPending ? { cleanup: row.receipt?.cleanup } : {}) },
+ nextAttemptAt: cleanupPending ? new Date(Date.now() + 30_000) : null });
+ }
+ }
+ async function cleanupRemoteAfterRestart(row: Copy): Promise {
+ const cleanup = row.receipt?.cleanup as { leaseId?: string; remoteCwd?: string } | undefined;
+ // Older preview receipts did not record the lease ID. Accept only a unique
+ // lease still bound to this run and environment in that compatibility case.
+ const leases = await db.select().from(environmentLeases).where(and(
+ eq(environmentLeases.companyId, row.companyId),
+ cleanup?.leaseId ? eq(environmentLeases.id, cleanup.leaseId) : and(
+ eq(environmentLeases.environmentId, row.location.slice("remote:".length)), eq(environmentLeases.heartbeatRunId, row.runId)),
+ ));
+ if (leases.length !== 1) return false;
+ const lease = leases[0]!;
+ const termination = lease.metadata?.remoteExecutionTermination as { state?: string } | undefined;
+ if (hasRemoteTerminationReceipt(lease) && termination?.state === "destroyed") return true;
+ if (!environmentRuntime || !lease.environmentId || row.location !== `remote:${lease.environmentId}`) return false;
+ const [environment] = await db.select().from(environments).where(eq(environments.id, lease.environmentId));
+ if (!environment) return false;
+ const remoteCwd = cleanup?.remoteCwd ?? lease.metadata?.remoteCwd;
+ if (typeof remoteCwd !== "string" || row.executionRoot !== path.posix.join(remoteCwd, ".paperclip-runtime", "agent-files", row.agentId, row.runId)) return false;
+ const result = await environmentRuntime.execute({ environment: environment as Environment, lease: lease as EnvironmentLease, command: "rm", args: ["-rf", "--", row.executionRoot],
+ cwd: remoteCwd, env: {}, timeoutMs: 15_000, bypassSession: true });
+ return result.exitCode === 0 && !result.timedOut;
+ }
+ async function serial(row: Copy, fn: (current: Copy) => Promise): Promise {
+ // Duplicate stop callbacks and restart recovery must not race while moving
+ // the stopped working copy. This lock is outside the writable tree.
+ return withDirectoryMergeLock(path.resolve(row.localRoot, "../../.."), async () => {
+ const current = await get(row.companyId, row.runId);
+ if (!current) throw notFound("Agent directory copy not found");
+ return fn(current);
+ });
+ }
+ return { prepare, hasChanges,
+ collectStopped: (row: Copy, target?: AdapterExecutionTarget | null) => serial(row, current => collectStopped(current, target)),
+ release: (row: Copy) => serial(row, release),
+ };
+}
diff --git a/server/src/services/agent-file-store.ts b/server/src/services/agent-file-store.ts
new file mode 100644
index 00000000000..868a3560553
--- /dev/null
+++ b/server/src/services/agent-file-store.ts
@@ -0,0 +1,282 @@
+import { createHash } from "node:crypto";
+import fs from "node:fs/promises";
+import { constants } from "node:fs";
+import { Readable } from "node:stream";
+import path from "node:path";
+import { and, eq } from "drizzle-orm";
+import { agents, activityLog, agentInstructionHeads, agentInstructionRevisions, type Db } from "@paperclipai/db";
+import { captureDirectorySnapshot, mergeDirectoryWithBaseline, type DirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge";
+import { HttpError, conflict, notFound, unprocessable } from "../errors.js";
+import { authorizeInstructionCommit, authorizeInstructionRead } from "./agent-instruction-authorization.js";
+import { assertInstructionPathSafe, instructionPath, instructionBytes, materializeInstructionBytes, readInstructionBytes, MAX_INSTRUCTION_BYTES } from "./agent-instruction-files.js";
+import { agentInstructionsBundleMode, deriveBundleState, resolveManagedInstructionsRoot } from "./agent-instructions.js";
+import type { AuthorizationActor } from "./authorization.js";
+
+type Tx = Parameters[0]>[0];
+type Agent = typeof agents.$inferSelect;
+export const AGENT_FILES_CONTRACT = "paperclip.agent-files.v1";
+export const MAX_AGENT_FILE_BYTES = 256 * 1024 * 1024;
+export const MAX_AGENT_DIRECTORY_BYTES = 2 * 1024 * 1024 * 1024;
+export const MAX_AGENT_DIRECTORY_ENTRIES = 100_000;
+export const fileHash = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
+
+export class AgentFileLimitError extends HttpError {
+ constructor(message: string) {
+ super(422, message, { code: "AGENT_FILES_LIMIT_EXCEEDED" });
+ }
+}
+function assertFileSize(size: number, relative: string) {
+ if (size > MAX_AGENT_FILE_BYTES) throw new AgentFileLimitError(`Agent file "${relative}" exceeds the 256 MiB per-file limit`);
+}
+function assertDirectorySize(size: number, count: number) {
+ if (size > MAX_AGENT_DIRECTORY_BYTES) throw new AgentFileLimitError("Agent directory exceeds the 2 GiB total storage limit");
+ if (count > MAX_AGENT_DIRECTORY_ENTRIES) throw new AgentFileLimitError("Agent directory exceeds the 100,000-entry limit (files and folders)");
+}
+export function agentStorageWarning(detail: string) {
+ return `Agent storage is full. ${detail}. Runs can continue; remove or shrink files in AGENT_HOME to free space. Changes exceeding the storage limits will not be saved.`;
+}
+function storageWarning(size: number, count: number, fullFile?: string) {
+ if (size >= MAX_AGENT_DIRECTORY_BYTES) return agentStorageWarning("The agent folder has reached its 2 GiB limit");
+ if (count >= MAX_AGENT_DIRECTORY_ENTRIES) return agentStorageWarning("The agent folder has reached its 100,000-entry limit");
+ if (fullFile) return agentStorageWarning(`Agent file "${fullFile}" has reached its 256 MiB limit`);
+ return null;
+}
+
+export function agentFilePath(value: string): string {
+ const relative = instructionPath(value);
+ if (relative.split("/").includes(".paperclip-runtime") || relative === "promptTemplate.legacy.md") {
+ throw unprocessable(`${relative} is reserved and cannot be used for agent files`);
+ }
+ return relative;
+}
+
+/** Compatibility ETag for clients whose old schema requires a UUID. This is a
+ * content token, not a revision ID: no snapshot or history row is created. */
+export function agentFileToken(bytes: Uint8Array): string {
+ return agentFileTokenFromHash(fileHash(bytes));
+}
+export function agentFileTokenFromHash(h: string): string {
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-8${h.slice(13, 16)}-a${h.slice(17, 20)}-${h.slice(20, 32)}`;
+}
+
+/** Call under the agent row lock. Import deployed revision heads once, then the
+ * directory is authoritative. The adoption marker lives OUTSIDE agent files. */
+export async function adoptAgentFiles(tx: Tx, agent: Agent): Promise {
+ if (agentInstructionsBundleMode(agent) === "external") throw unprocessable("External instructions must be migrated to managed storage first");
+ const root = resolveManagedInstructionsRoot(agent);
+ const markerRoot = path.join(path.dirname(root), "file-sync");
+ const marker = "adopted.json";
+ await assertInstructionPathSafe(root, deriveBundleState(agent).entryFile);
+ await fs.mkdir(root, { recursive: true, mode: 0o700 });
+ const filename = await assertInstructionPathSafe(markerRoot, marker);
+ const adopted = await fs.readFile(filename, "utf8").catch((error: NodeJS.ErrnoException) => {
+ if (error.code === "ENOENT") return null;
+ throw error;
+ });
+ if (adopted !== null) {
+ if (JSON.parse(adopted).schema !== AGENT_FILES_CONTRACT) throw new Error("Unsupported agent file contract");
+ return root;
+ }
+ const legacy = await tx.select({ entryFile: agentInstructionHeads.entryFile, contentBase64: agentInstructionRevisions.contentBase64 })
+ .from(agentInstructionHeads).innerJoin(agentInstructionRevisions, eq(agentInstructionHeads.revisionId, agentInstructionRevisions.id))
+ .where(and(eq(agentInstructionHeads.companyId, agent.companyId), eq(agentInstructionHeads.agentId, agent.id)));
+ for (const entry of legacy) await materializeInstructionBytes(root, entry.entryFile, Buffer.from(entry.contentBase64, "base64"));
+ await materializeInstructionBytes(markerRoot, marker, Buffer.from(JSON.stringify({ schema: AGENT_FILES_CONTRACT })));
+ return root;
+}
+
+async function openAgentFile(root: string, relative: string) {
+ const filename = await assertInstructionPathSafe(root, relative);
+ const handle = await fs.open(filename, constants.O_RDONLY | constants.O_NOFOLLOW).catch((error: NodeJS.ErrnoException) => {
+ if (error.code === "ENOENT") return null;
+ throw error;
+ });
+ if (!handle) return null;
+ try {
+ const stat = await handle.stat();
+ if (!stat.isFile() || stat.nlink !== 1) throw unprocessable("Agent files must be regular files without links");
+ assertFileSize(stat.size, relative);
+ return { handle, size: stat.size };
+ } catch (error) { await handle.close(); throw error; }
+}
+
+/** Hash large files incrementally. Only editor-sized content is retained in RAM. */
+export async function inspectAgentFile(root: string, relative: string, bufferLimit = MAX_INSTRUCTION_BYTES) {
+ const opened = await openAgentFile(root, relative);
+ if (!opened) return null;
+ const hash = createHash("sha256");
+ let chunks: Buffer[] | null = opened.size <= bufferLimit ? [] : null;
+ let size = 0;
+ try {
+ for await (const chunk of opened.handle.createReadStream()) {
+ size += chunk.length;
+ assertFileSize(size, relative);
+ hash.update(chunk);
+ if (size > bufferLimit) chunks = null;
+ chunks?.push(chunk);
+ }
+ return { size, hash: hash.digest("hex"), bytes: chunks ? Buffer.concat(chunks) : null };
+ } finally { await opened.handle.close(); }
+}
+
+export async function readAgentFile(root: string, relative: string): Promise {
+ return (await inspectAgentFile(root, relative, MAX_AGENT_FILE_BYTES))?.bytes ?? null;
+}
+
+/** Validate before staging and again after provider stop; never follow links or
+ * silently skip an unsupported file. Bounds apply to bytes, including binaries. */
+async function scanAgentFiles(root: string, enforceLimits = true) {
+ await assertInstructionPathSafe(root, ".path-check");
+ let size = 0, count = 0;
+ const entries = new Set();
+ let fullFile: string | undefined;
+ async function walk(dir: string) {
+ for (const item of await fs.readdir(path.join(root, dir), { withFileTypes: true })) {
+ const relative = agentFilePath(dir ? `${dir}/${item.name}` : item.name);
+ const stat = await fs.lstat(path.join(root, relative));
+ count++;
+ if (enforceLimits) assertDirectorySize(size, count);
+ entries.add(relative);
+ if (stat.isDirectory()) await walk(relative);
+ else if (stat.isFile() && stat.nlink === 1) {
+ size += stat.size;
+ if (stat.size >= MAX_AGENT_FILE_BYTES) fullFile ??= relative;
+ if (enforceLimits) {
+ assertFileSize(stat.size, relative);
+ assertDirectorySize(size, count);
+ }
+ } else throw unprocessable("Agent directories support regular files and directories, without links or special files");
+ }
+ }
+ await walk("");
+ return { size, entries, storageWarning: storageWarning(size, count, fullFile) };
+}
+
+export async function inspectAgentDirectory(root: string, enforceLimits = true) {
+ const usage = await scanAgentFiles(root, enforceLimits);
+ return { snapshot: await captureDirectorySnapshot(root), storageWarning: usage.storageWarning };
+}
+export async function snapshotAgentFiles(root: string, enforceLimits = true): Promise {
+ return (await inspectAgentDirectory(root, enforceLimits)).snapshot;
+}
+
+export function agentFileStore(db: Db) {
+ async function locked(companyId: string, agentId: string, actor: AuthorizationActor, write: boolean,
+ fn: (tx: Tx, agent: Agent, root: string, bound: AuthorizationActor) => Promise) {
+ return db.transaction(async tx => {
+ const [agent] = await tx.select().from(agents).where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("update");
+ if (!agent) throw notFound("Agent not found");
+ const bound = write ? await authorizeInstructionCommit(tx, actor, agent) : await authorizeInstructionRead(tx, actor, agent);
+ const root = await adoptAgentFiles(tx, agent);
+ return fn(tx, agent, root, bound);
+ });
+ }
+ async function audit(tx: Tx, agent: Agent, actor: AuthorizationActor, details: Record) {
+ await tx.insert(activityLog).values({ companyId: agent.companyId, actorType: actor.type === "board" ? "user" : "agent",
+ actorId: (actor.type === "board" ? actor.userId : actor.agentId)!, agentId: actor.type === "agent" ? actor.agentId : null,
+ runId: actor.runId, responsibleUserId: actor.type === "board" ? actor.userId : actor.onBehalfOfUserId,
+ action: "agent.files_updated", entityType: "agent", entityId: agent.id, details });
+ }
+ return {
+ locked,
+ download: async (companyId: string, agentId: string, relative: string, actor: AuthorizationActor) => {
+ const opened: { file: Awaited> } = { file: null };
+ try {
+ const file = await locked(companyId, agentId, actor, false, async (_tx, _agent, root) => {
+ opened.file = await openAgentFile(root, instructionPath(relative));
+ return opened.file;
+ });
+ // Transfer outside the database lock. The open descriptor pins the file
+ // across concurrent atomic replacements; end bounds concurrent growth.
+ if (!file) return null;
+ if (file.size === 0) { await file.handle.close(); return { size: 0, stream: Readable.from([]) }; }
+ return { size: file.size, stream: file.handle.createReadStream({ end: file.size - 1 }) };
+ } catch (error) { await opened.file?.handle.close(); throw error; }
+ },
+ read: (companyId: string, agentId: string, relative: string, actor: AuthorizationActor) =>
+ locked(companyId, agentId, actor, false, (_tx, _agent, root) => readAgentFile(root, instructionPath(relative))),
+ write: (input: { companyId: string; agentId: string; path: string; bytes: Buffer | null; baseHash: string | null }, actor: AuthorizationActor) =>
+ locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound) => {
+ const relative = agentFilePath(input.path);
+ if (input.bytes) assertFileSize(input.bytes.length, relative);
+ const previous = await inspectAgentFile(root, relative);
+ const currentHash = previous?.hash ?? null;
+ const incomingHash = input.bytes === null ? null : fileHash(input.bytes);
+ if (currentHash === incomingHash) return { contentHash: currentHash, changed: false };
+ if (currentHash !== input.baseHash) throw conflict("This file changed since it was read. Reload before saving.", { code: "AGENT_FILE_CONFLICT", path: relative, currentHash });
+ if (input.bytes === null && relative === deriveBundleState(agent).entryFile) throw unprocessable("The configured instruction entry cannot be deleted");
+ if (input.bytes !== null) {
+ if (relative === deriveBundleState(agent).entryFile) instructionBytes(input.bytes);
+ // A quota check needs metadata only. Do not hash unrelated large
+ // files while holding the editor's row lock for a one-file save.
+ const snapshot = await scanAgentFiles(root);
+ const total = snapshot.size + input.bytes.length - (previous?.size ?? 0);
+ const newEntries = relative.split("/").map((_part, i, parts) => parts.slice(0, i + 1).join("/")).filter(name => !snapshot.entries.has(name)).length;
+ assertDirectorySize(total, snapshot.entries.size + newEntries);
+ }
+ if (input.bytes === null) await fs.unlink(await assertInstructionPathSafe(root, relative));
+ else await materializeInstructionBytes(root, relative, input.bytes);
+ await audit(tx, agent, bound, { path: relative, contentHash: incomingHash });
+ return { contentHash: incomingHash, changed: true };
+ }),
+ apply: (input: { companyId: string; agentId: string; sourceDir: string; baseline: DirectorySnapshot }, actor: AuthorizationActor) =>
+ locked(input.companyId, input.agentId, actor, true, async (tx, agent, root, bound) => {
+ const incoming = await snapshotAgentFiles(input.sourceDir);
+ const entry = await readInstructionBytes(input.sourceDir, deriveBundleState(agent).entryFile);
+ if (entry === null) throw unprocessable("The configured instruction entry cannot be deleted");
+ instructionBytes(entry);
+ // Previously saved/imported bytes must remain readable, including when
+ // over quota. Enforce limits on the incoming and resulting tree so a
+ // run can delete files to recover instead of being locked out forever.
+ const current = await snapshotAgentFiles(root, false);
+ // Rebase only the run's changed paths onto the current tree. This makes
+ // same-file edits/deletions last-sync-wins while untouched files retain
+ // changes from other runs. Ancestors may need recreating after a writer
+ // replaced a directory with a file.
+ const entries = new Map(input.baseline.entries);
+ const changed = [...new Set([...input.baseline.entries, ...incoming.entries].map(([name]) => name))]
+ .filter(name => JSON.stringify(input.baseline.entries.get(name)) !== JSON.stringify(incoming.entries.get(name)));
+ for (const name of changed) {
+ const present = current.entries.get(name);
+ if (present) entries.set(name, present); else entries.delete(name);
+ if (incoming.entries.has(name)) {
+ for (let parent = path.posix.dirname(name); parent !== "."; parent = path.posix.dirname(parent)) {
+ if (current.entries.get(parent)?.kind === "dir") continue;
+ const before = current.entries.get(parent);
+ if (before) entries.set(parent, before); else entries.delete(parent);
+ }
+ }
+ }
+ const applyBaseline = { ...input.baseline, entries };
+ const finalEntries = new Map([...current.entries].map(([name, value]) => [name, { value, root }]));
+ for (const name of changed) if (!incoming.entries.has(name)) finalEntries.delete(name);
+ for (const [name, value] of incoming.entries) {
+ if (JSON.stringify(input.baseline.entries.get(name)) === JSON.stringify(value)) continue;
+ if (value.kind !== "dir") for (const child of finalEntries.keys()) if (child.startsWith(`${name}/`)) finalEntries.delete(child);
+ finalEntries.set(name, { value, root: input.sourceDir });
+ for (let parent = path.posix.dirname(name); parent !== "."; parent = path.posix.dirname(parent)) {
+ if (finalEntries.get(parent)?.value.kind !== "dir") finalEntries.set(parent, { value: { kind: "dir" }, root: input.sourceDir });
+ }
+ }
+ // An unchanged file created by another run can keep a removed folder
+ // alive. Count those surviving parent directories in quota preflight.
+ for (const name of finalEntries.keys()) {
+ for (let parent = path.posix.dirname(name); parent !== "."; parent = path.posix.dirname(parent)) {
+ if (!finalEntries.has(parent)) finalEntries.set(parent, { value: { kind: "dir" }, root });
+ }
+ }
+ let total = 0;
+ let fullFile: string | undefined;
+ for (const [name, item] of finalEntries) if (item.value.kind === "file") {
+ const size = (await fs.stat(path.join(item.root, name))).size;
+ assertFileSize(size, name);
+ if (size >= MAX_AGENT_FILE_BYTES) fullFile ??= name;
+ total += size;
+ }
+ assertDirectorySize(total, finalEntries.size);
+ await mergeDirectoryWithBaseline({ ...input, baseline: applyBaseline, targetDir: root });
+ await audit(tx, agent, bound, { sourceRunId: actor.runId, contract: AGENT_FILES_CONTRACT });
+ return { storageWarning: storageWarning(total, finalEntries.size, fullFile) };
+ }),
+ };
+}
diff --git a/server/src/services/agent-instruction-authorization.ts b/server/src/services/agent-instruction-authorization.ts
new file mode 100644
index 00000000000..fe98d26995e
--- /dev/null
+++ b/server/src/services/agent-instruction-authorization.ts
@@ -0,0 +1,195 @@
+import { and, eq, isNull } from "drizzle-orm";
+import {
+ agentApiKeys,
+ authUsers,
+ companyMemberships,
+ heartbeatRuns,
+ runIdentityContexts,
+ type Db,
+} from "@paperclipai/db";
+import { forbidden } from "../errors.js";
+import {
+ authorizationService,
+ authorizationDeniedDetails,
+ type AuthorizationActor,
+} from "./authorization.js";
+import {
+ agentInstructionsChangeTargetKey,
+ changeConsentGateService,
+} from "./change-consent-gate.js";
+
+type Connection = Db | Parameters[0]>[0];
+
+/** Accept ONLY a server-authenticated actor, never request/tool JSON. Rebind run/key provenance on every call. */
+export async function resolveInstructionActor(
+ db: Connection,
+ actor: AuthorizationActor,
+): Promise {
+ if (actor.type === "board") {
+ if (!actor.userId)
+ throw forbidden("Instruction edits require an authenticated user");
+ // Discard caller membership caches so revoked access is observed.
+ return {
+ type: "board",
+ userId: actor.userId,
+ source: actor.source,
+ ignoreInstanceAdmin: actor.ignoreInstanceAdmin,
+ };
+ }
+ if (actor.type !== "agent" || !actor.agentId || !actor.companyId)
+ throw forbidden("Instruction identity is missing");
+ let responsibleUserId: string | null = null;
+ let keyScope = actor.keyScope;
+ if (actor.keyId) {
+ const [key] = await db
+ .select()
+ .from(agentApiKeys)
+ .where(
+ and(
+ eq(agentApiKeys.id, actor.keyId),
+ eq(agentApiKeys.companyId, actor.companyId),
+ eq(agentApiKeys.agentId, actor.agentId),
+ isNull(agentApiKeys.revokedAt),
+ ),
+ );
+ if (!key)
+ throw forbidden("Instruction API key is missing or revoked", {
+ code: "INSTRUCTION_IDENTITY_INVALID",
+ });
+ responsibleUserId = key.responsibleUserId;
+ keyScope = key.scopeConfig ?? undefined;
+ }
+ if (actor.runId) {
+ const [run] = await db
+ .select()
+ .from(heartbeatRuns)
+ .where(
+ and(
+ eq(heartbeatRuns.id, actor.runId),
+ eq(heartbeatRuns.companyId, actor.companyId),
+ eq(heartbeatRuns.agentId, actor.agentId),
+ ),
+ );
+ if (!run)
+ throw forbidden("Instruction run identity does not match", {
+ code: "INSTRUCTION_IDENTITY_INVALID",
+ });
+ if (actor.keyId && responsibleUserId !== run.responsibleUserId)
+ throw forbidden("Key and run responsible identity do not match", {
+ code: "INSTRUCTION_IDENTITY_INVALID",
+ });
+ responsibleUserId = run.responsibleUserId;
+ if (run.activeIdentityContextId) {
+ const [context] = await db
+ .select()
+ .from(runIdentityContexts)
+ .where(
+ and(
+ eq(runIdentityContexts.id, run.activeIdentityContextId),
+ eq(runIdentityContexts.runId, run.id),
+ eq(runIdentityContexts.companyId, run.companyId),
+ eq(runIdentityContexts.status, "accepted"),
+ ),
+ );
+ if (!context)
+ throw forbidden("Instruction run identity is unavailable", {
+ code: "INSTRUCTION_IDENTITY_INVALID",
+ });
+ responsibleUserId = context.responsibleUserId;
+ }
+ }
+ if (
+ !responsibleUserId ||
+ (actor.onBehalfOfUserId && actor.onBehalfOfUserId !== responsibleUserId)
+ ) {
+ throw forbidden("A current server-bound responsible user is required", {
+ code: "INSTRUCTION_IDENTITY_INVALID",
+ });
+ }
+ const [membership] = await db
+ .select({ userId: authUsers.id })
+ .from(companyMemberships)
+ .innerJoin(authUsers, eq(authUsers.id, companyMemberships.principalId))
+ .where(
+ and(
+ eq(companyMemberships.companyId, actor.companyId),
+ eq(companyMemberships.principalType, "user"),
+ eq(companyMemberships.principalId, responsibleUserId),
+ eq(companyMemberships.status, "active"),
+ ),
+ );
+ if (!membership)
+ throw forbidden("The responsible user is unavailable in this company", {
+ code: "RESPONSIBLE_USER_UNAVAILABLE",
+ });
+ return {
+ type: "agent",
+ agentId: actor.agentId,
+ companyId: actor.companyId,
+ runId: actor.runId,
+ keyId: actor.keyId,
+ keyScope,
+ source: actor.source,
+ onBehalfOfUserId: responsibleUserId,
+ };
+}
+
+/** Instruction content is narrower than general same-company agent visibility. */
+export async function authorizeInstructionRead(
+ db: Connection,
+ actor: AuthorizationActor,
+ target: { companyId: string; id: string },
+) {
+ const bound = await resolveInstructionActor(db, actor);
+ const access = authorizationService(db);
+ const resource = { type: "agent" as const, companyId: target.companyId, agentId: target.id };
+ const peer = bound.type === "agent" && bound.agentId !== target.id;
+ const decision = await access.decide({
+ actor: bound,
+ action: peer ? "agent_config:read" : "agent:read",
+ resource,
+ scope: { targetAgentId: target.id },
+ });
+ if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
+ return bound;
+}
+
+/** Current target edit access + agent containment + protected-change consent, shared by all writers. */
+export async function authorizeInstructionCommit(
+ db: Connection,
+ actor: AuthorizationActor,
+ target: { companyId: string; id: string },
+) {
+ const bound = await resolveInstructionActor(db, actor);
+ const access = authorizationService(db);
+ const input = {
+ actor: bound,
+ action: "agent_instructions:update" as const,
+ resource: {
+ type: "agent" as const,
+ companyId: target.companyId,
+ agentId: target.id,
+ },
+ scope: { targetAgentId: target.id, requiresChangeGrant: true },
+ };
+ let decision = await access.decide(input);
+ if (
+ !decision.allowed &&
+ decision.reason === "deny_missing_consent" &&
+ bound.type === "agent"
+ ) {
+ await changeConsentGateService(db).assertConsented({
+ companyId: target.companyId,
+ actorAgentId: bound.agentId,
+ actorRunId: bound.runId,
+ targetKeys: [agentInstructionsChangeTargetKey(target.id)],
+ });
+ decision = await access.decide({
+ ...input,
+ scope: { ...input.scope, consentedChange: true },
+ });
+ }
+ if (!decision.allowed)
+ throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
+ return bound;
+}
diff --git a/server/src/services/agent-instruction-files.ts b/server/src/services/agent-instruction-files.ts
new file mode 100644
index 00000000000..6f3db874aca
--- /dev/null
+++ b/server/src/services/agent-instruction-files.ts
@@ -0,0 +1,178 @@
+import fs from "node:fs/promises";
+import { constants } from "node:fs";
+import path from "node:path";
+import { randomUUID } from "node:crypto";
+import { unprocessable } from "../errors.js";
+
+export const MAX_INSTRUCTION_BYTES = 1024 * 1024;
+export function instructionPath(value: string): string {
+ if (
+ !value ||
+ value.length > 512 ||
+ value.includes("\0") ||
+ value.includes("\\") ||
+ path.posix.isAbsolute(value) ||
+ /^[a-z]:/i.test(value) ||
+ value.split("/").some((part) => !part || part === "." || part === "..")
+ ) {
+ throw unprocessable("Use a relative instructions path without traversal", {
+ code: "INSTRUCTION_PATH_INVALID",
+ });
+ }
+ return value;
+}
+
+export function instructionBytes(content: string | Uint8Array): Buffer {
+ const byteLength =
+ typeof content === "string"
+ ? Buffer.byteLength(content, "utf8")
+ : content.byteLength;
+ if (byteLength > MAX_INSTRUCTION_BYTES) {
+ throw unprocessable("Instructions exceed the 1 MiB limit", {
+ code: "INSTRUCTION_CONTENT_INVALID",
+ });
+ }
+ const bytes =
+ typeof content === "string"
+ ? Buffer.from(content, "utf8")
+ : Buffer.from(content);
+ try {
+ // Preserve BOM; reject invalid UTF-8 and unpaired JS surrogates instead of replacing bytes.
+ const decoded = new TextDecoder("utf-8", {
+ fatal: true,
+ ignoreBOM: true,
+ }).decode(bytes);
+ if (typeof content === "string" && decoded !== content)
+ throw new Error("invalid Unicode");
+ } catch {
+ throw unprocessable("Instructions must be valid UTF-8", {
+ code: "INSTRUCTION_CONTENT_INVALID",
+ });
+ }
+ return bytes;
+}
+
+export async function assertInstructionPathSafe(
+ root: string,
+ entryFile: string,
+) {
+ instructionPath(entryFile);
+ let absolute = path.resolve(root, entryFile);
+ // macOS exposes its system temporary directories through root-owned aliases.
+ // Resolve only those fixed OS aliases, never a link in operator/agent storage.
+ if (process.platform === "darwin") {
+ const alias = absolute.split(path.sep)[1];
+ if (alias === "var" || alias === "tmp" || alias === "etc") {
+ const systemPath = `/${alias}`;
+ const stat = await fs.lstat(systemPath);
+ if (stat.isSymbolicLink() && stat.uid === 0 && await fs.realpath(systemPath) === `/private/${alias}`) {
+ absolute = `/private${absolute}`;
+ }
+ }
+ }
+ let current = path.parse(absolute).root;
+ const parts = absolute.slice(current.length).split(path.sep);
+ for (const [i, part] of parts.entries()) {
+ current = path.join(current, part);
+ const stat = await fs
+ .lstat(current)
+ .catch((error: NodeJS.ErrnoException) => {
+ if (error.code === "ENOENT") return null;
+ throw error;
+ });
+ if (!stat) continue;
+ if (
+ stat.isSymbolicLink() ||
+ (i < parts.length - 1 ? !stat.isDirectory() : !stat.isFile())
+ ) {
+ throw unprocessable(
+ "Instructions require regular files and directories without symlinks",
+ { code: "INSTRUCTION_PATH_INVALID" },
+ );
+ }
+ }
+ return absolute;
+}
+
+export async function readInstructionBytes(
+ root: string,
+ entryFile: string,
+): Promise {
+ const absolute = await assertInstructionPathSafe(root, entryFile);
+ const file = await fs
+ .open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW)
+ .catch((error: NodeJS.ErrnoException) => {
+ if (error.code === "ENOENT") return null;
+ throw error;
+ });
+ if (!file) return null;
+ try {
+ const stat = await file.stat();
+ if (!stat.isFile() || stat.size > MAX_INSTRUCTION_BYTES) {
+ throw unprocessable(
+ "Instructions must be a regular UTF-8 file of at most 1 MiB",
+ { code: "INSTRUCTION_CONTENT_INVALID" },
+ );
+ }
+ const buffer = Buffer.alloc(MAX_INSTRUCTION_BYTES + 1);
+ let length = 0;
+ while (length < buffer.length) {
+ const result = await file.read(
+ buffer,
+ length,
+ buffer.length - length,
+ length,
+ );
+ if (result.bytesRead === 0) break;
+ length += result.bytesRead;
+ }
+ return instructionBytes(buffer.subarray(0, length));
+ } finally {
+ await file.close();
+ }
+}
+
+/** Call while holding the canonical agent lock. Never writes through a symlink. */
+export async function materializeInstructionBytes(
+ root: string,
+ entryFile: string,
+ bytes: Buffer,
+) {
+ const absolute = await assertInstructionPathSafe(root, entryFile);
+ await fs.mkdir(path.dirname(absolute), { recursive: true });
+ await assertInstructionPathSafe(root, entryFile);
+ const temporary = path.join(
+ path.dirname(absolute),
+ `.instruction-${randomUUID()}.tmp`,
+ );
+ try {
+ const file = await fs.open(temporary, "wx", 0o600);
+ try {
+ await file.writeFile(bytes);
+ await file.sync();
+ } finally {
+ await file.close();
+ }
+ await assertInstructionPathSafe(root, entryFile);
+ await fs.rename(temporary, absolute);
+ } finally {
+ await fs.rm(temporary, { force: true });
+ }
+}
+
+// Keep the reserved runtime directory out of Git using a self-ignoring file
+// inside that directory. Never write to a gitdir or exclude path derived from
+// repository-controlled metadata; legitimate worktrees can keep external gitdirs.
+export const instructionGitExcludeProgram = String.raw`
+const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto');
+const cwd=fs.realpathSync(process.argv[1]);
+const root=path.join(cwd,'.paperclip-runtime'),filename=path.join(root,'.gitignore');
+const stat=(name)=>{try{return fs.lstatSync(name)}catch(e){if(e.code==='ENOENT')return null;throw e}};
+const directory=stat(root);
+if(directory && (directory.isSymbolicLink() || !directory.isDirectory()))throw Error('Unsafe runtime exclusion directory');
+if(!directory)fs.mkdirSync(root,{mode:0o700});
+const existing=stat(filename);
+if(existing && (existing.isSymbolicLink() || !existing.isFile()))throw Error('Unsafe runtime exclusion file');
+const temporary=path.join(root,'.ignore-'+crypto.randomUUID()+'.tmp');
+try{fs.writeFileSync(temporary,'*\n',{flag:'wx',mode:0o600});fs.renameSync(temporary,filename)}finally{fs.rmSync(temporary,{force:true})}
+`;
diff --git a/server/src/services/agent-instruction-revisions.ts b/server/src/services/agent-instruction-revisions.ts
new file mode 100644
index 00000000000..6c07b0d08f8
--- /dev/null
+++ b/server/src/services/agent-instruction-revisions.ts
@@ -0,0 +1,429 @@
+import { createHash } from "node:crypto";
+import { adoptAgentFiles, agentFileToken } from "./agent-file-store.js";
+import { and, desc, eq, getTableColumns, lt, or, sql } from "drizzle-orm";
+import {
+ activityLog,
+ agents,
+ plugins,
+ pluginManagedResources,
+ agentInstructionHeads as heads,
+ agentInstructionRevisions as revisions,
+ type Db,
+} from "@paperclipai/db";
+import type {
+ AgentInstructionCommitReceipt,
+ AgentInstructionDiff,
+ AgentInstructionHistory,
+ AgentInstructionSnapshot,
+ AgentInstructionSource,
+} from "@paperclipai/shared";
+import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
+import {
+ agentInstructionsBundleMode,
+ deriveBundleState,
+ resolveManagedInstructionsRoot,
+} from "./agent-instructions.js";
+import {
+ instructionBytes,
+ instructionPath,
+ assertInstructionPathSafe,
+ readInstructionBytes,
+ materializeInstructionBytes,
+} from "./agent-instruction-files.js";
+import {
+ authorizeInstructionCommit,
+ authorizeInstructionRead,
+} from "./agent-instruction-authorization.js";
+import type { AuthorizationActor } from "./authorization.js";
+
+type Tx = Parameters[0]>[0];
+type Revision = typeof revisions.$inferSelect;
+type PluginResetActor = { type: "plugin"; pluginId: string; pluginKey: string; agentKey: string };
+type RevisionActor = AuthorizationActor | PluginResetActor;
+const { contentBase64: _contentColumn, ...revisionMetadataColumns } =
+ getTableColumns(revisions);
+export type InstructionTarget = { companyId: string; agentId: string };
+export type InstructionCommitInput = InstructionTarget & {
+ entryFile: string;
+ content: string | Uint8Array;
+ baseRevisionId: string | null;
+ source: Exclude;
+};
+function metadata(row: Omit) {
+ const { createdAt, source, ...fields } = row;
+ return {
+ ...fields,
+ source: source as AgentInstructionSource,
+ createdAt: createdAt.toISOString(),
+ };
+}
+function snapshot(row: Revision): AgentInstructionSnapshot {
+ const { contentBase64, ...fields } = row;
+ return {
+ revision: metadata(fields),
+ content: Buffer.from(contentBase64, "base64").toString("utf8"),
+ };
+}
+function owner(target: InstructionTarget, entryFile: string) {
+ return and(
+ eq(revisions.companyId, target.companyId),
+ eq(revisions.agentId, target.agentId),
+ eq(revisions.entryFile, entryFile),
+ );
+}
+function headOwner(target: InstructionTarget, entryFile: string) {
+ return and(
+ eq(heads.companyId, target.companyId),
+ eq(heads.agentId, target.agentId),
+ eq(heads.entryFile, entryFile),
+ );
+}
+
+export function agentInstructionRevisionService(db: Db) {
+ async function lockTarget(
+ tx: Tx,
+ target: InstructionTarget,
+ actor?: AuthorizationActor,
+ ) {
+ if (actor?.type === "agent" && actor.companyId !== target.companyId)
+ throw notFound("Agent not found");
+ const [agent] = await tx
+ .select()
+ .from(agents)
+ .where(
+ and(
+ eq(agents.id, target.agentId),
+ eq(agents.companyId, target.companyId),
+ ),
+ )
+ .for("update");
+ if (!agent) throw notFound("Agent not found");
+ if (agentInstructionsBundleMode(agent) === "external") {
+ throw unprocessable(
+ "External host instructions do not support revision write-back. Ask an instance administrator to migrate this bundle to managed storage.",
+ { code: "INSTRUCTION_MANAGED_BUNDLE_REQUIRED" },
+ );
+ }
+ const state = deriveBundleState(agent);
+ const entryFile = instructionPath(state.entryFile);
+ return { agent, entryFile, root: resolveManagedInstructionsRoot(agent) };
+ }
+ async function authorizeRead(
+ tx: Tx,
+ actor: AuthorizationActor,
+ target: InstructionTarget,
+ ) {
+ return authorizeInstructionRead(tx, actor, { companyId: target.companyId, id: target.agentId });
+ }
+ // Trusted host-only reset authority. No HTTP or agent tool accepts this actor.
+ async function authorizePluginReset(tx: Tx, actor: PluginResetActor, target: InstructionTarget, agent: typeof agents.$inferSelect) {
+ const [plugin] = await tx.select().from(plugins).where(and(eq(plugins.id, actor.pluginId), eq(plugins.pluginKey, actor.pluginKey)));
+ const marker = agent.metadata?.paperclipManagedResource as Record | undefined;
+ const [binding] = await tx.select().from(pluginManagedResources).where(and(
+ eq(pluginManagedResources.pluginId, actor.pluginId), eq(pluginManagedResources.pluginKey, actor.pluginKey),
+ eq(pluginManagedResources.companyId, target.companyId), eq(pluginManagedResources.resourceKind, "agent"),
+ eq(pluginManagedResources.resourceKey, actor.agentKey), eq(pluginManagedResources.resourceId, target.agentId),
+ ));
+ if (!plugin || plugin.status !== "ready" || !plugin.manifestJson.capabilities.includes("agents.managed")
+ || !plugin.manifestJson.agents?.some((declaration) => declaration.agentKey === actor.agentKey)
+ || !binding || marker?.pluginId !== actor.pluginId || marker.pluginKey !== actor.pluginKey
+ || marker.resourceKind !== "agent" || marker.resourceKey !== actor.agentKey) {
+ throw forbidden("Plugin reset is limited to its declared, bound managed agent");
+ }
+ const declared = plugin.manifestJson.agents!.find((declaration) => declaration.agentKey === actor.agentKey)!;
+ return { ...actor, declaredEntryFile: instructionPath(declared.instructions?.entryFile ?? "AGENTS.md") };
+ }
+ async function readForPluginReset(target: InstructionTarget, actor: PluginResetActor) {
+ return db.transaction(async (tx) => {
+ const state = await lockTarget(tx, target);
+ const bound = await authorizePluginReset(tx, actor, target, state.agent);
+ // Preserve the formerly configured entry before an explicit stock reset.
+ await currentFile(tx, target, state, bound);
+ const row = await currentFile(tx, target, { ...state, entryFile: bound.declaredEntryFile }, bound);
+ return { snapshot: row ? snapshot(row) : null, configuredEntryFile: state.entryFile };
+ });
+ }
+ async function head(tx: Tx, target: InstructionTarget, entryFile: string) {
+ const [result] = await tx
+ .select({ revision: revisions })
+ .from(heads)
+ .innerJoin(revisions, eq(revisions.id, heads.revisionId))
+ .where(headOwner(target, entryFile));
+ return result?.revision ?? null;
+ }
+ // The deployed revision tables are read-only upgrade input. Current bytes
+ // live in the agent directory; old UUID clients receive a content ETag.
+ async function currentFile(tx: Tx, target: InstructionTarget, state: Awaited>, actor?: RevisionActor) {
+ await adoptAgentFiles(tx, state.agent);
+ const bytes = await readInstructionBytes(state.root, state.entryFile);
+ if (bytes === null) return null;
+ return currentRow(target, state.entryFile, bytes, actor);
+ }
+ function currentRow(target: InstructionTarget, entryFile: string, bytes: Buffer, actor?: RevisionActor): Revision {
+ return { ...target, id: agentFileToken(bytes), entryFile, contentBase64: bytes.toString("base64"),
+ contentHash: createHash("sha256").update(bytes).digest("hex"), byteLength: bytes.length,
+ parentRevisionId: null, baseRevisionId: null, restoredFromRevisionId: null, source: "api",
+ actorAgentId: actor?.type === "agent" ? actor.agentId ?? null : null,
+ actorUserId: actor?.type === "board" ? actor.userId ?? null : null,
+ responsibleUserId: actor?.type === "board" ? actor.userId ?? null : actor?.type === "agent" ? actor.onBehalfOfUserId ?? null : null,
+ sourceRunId: actor && actor.type !== "plugin" ? actor.runId ?? null : null, createdAt: new Date(0) };
+ }
+ async function getRevision(
+ tx: Tx,
+ target: InstructionTarget,
+ entryFile: string,
+ id: string,
+ ) {
+ const [row] = await tx
+ .select()
+ .from(revisions)
+ .where(and(owner(target, entryFile), eq(revisions.id, id)));
+ if (!row) throw notFound("Instruction revision not found");
+ return row;
+ }
+ async function readCurrent(
+ target: InstructionTarget,
+ actor: AuthorizationActor,
+ ): Promise {
+ return db.transaction(async (tx) => {
+ const state = await lockTarget(tx, target, actor);
+ const bound = await authorizeRead(tx, actor, target);
+ const row = await currentFile(tx, target, state, bound);
+ return row ? snapshot(row) : null;
+ });
+ }
+ /** Trusted orchestration read for the already authorized run target. This
+ * never seeds or writes, and is not exposed through HTTP or model tools. */
+ async function readCommittedForRuntime(target: InstructionTarget): Promise {
+ return db.transaction(async (tx) => {
+ const state = await lockTarget(tx, target);
+ const current = await currentFile(tx, target, state);
+ return current ? snapshot(current) : null;
+ });
+ }
+ /** Rebuild disk from the current committed head under the same lock as commits. Safe after restart. */
+ async function materializeCurrent(target: InstructionTarget): Promise {
+ await db.transaction(async (tx) => {
+ const state = await lockTarget(tx, target);
+ await adoptAgentFiles(tx, state.agent);
+ });
+ }
+ async function commitInternal(
+ input:
+ | InstructionCommitInput
+ | (Omit & {
+ restoreRevisionId: string;
+ }),
+ actor: RevisionActor,
+ configuredEntryFile?: string,
+ ): Promise {
+ instructionPath(input.entryFile);
+ const bytes = "content" in input ? instructionBytes(input.content) : null;
+ const result = await db.transaction(async (tx) => {
+ const state = await lockTarget(tx, input, actor.type === "plugin" ? undefined : actor);
+ const bound = actor.type === "plugin"
+ ? await authorizePluginReset(tx, actor, input, state.agent)
+ : await authorizeInstructionCommit(tx, actor, state.agent);
+ if (bound.type === "plugin") {
+ if (input.entryFile !== bound.declaredEntryFile) throw forbidden("Plugin reset must restore its declared entry file");
+ if (configuredEntryFile !== state.entryFile) throw conflict("Configured instruction entry changed; read the current entry and retry", { code: "INSTRUCTION_ENTRY_CHANGED", entryFile: state.entryFile });
+ state.entryFile = input.entryFile;
+ }
+ if (state.entryFile !== input.entryFile)
+ throw conflict(
+ "Configured instruction entry changed; read the current entry and retry",
+ { code: "INSTRUCTION_ENTRY_CHANGED", entryFile: state.entryFile },
+ );
+ await assertInstructionPathSafe(state.root, state.entryFile);
+ const current = await currentFile(tx, input, state, bound);
+ const restored =
+ "restoreRevisionId" in input
+ ? await getRevision(
+ tx,
+ input,
+ input.entryFile,
+ input.restoreRevisionId,
+ )
+ : null;
+ const candidate = bytes ?? Buffer.from(restored!.contentBase64, "base64");
+ const bindEntry = async () => {
+ if (
+ !state.agent.adapterConfig.instructionsRootPath ||
+ !state.agent.adapterConfig.instructionsBundleMode ||
+ (bound.type === "plugin" && configuredEntryFile !== input.entryFile)
+ ) {
+ await tx
+ .update(agents)
+ .set({
+ adapterConfig: {
+ ...state.agent.adapterConfig,
+ instructionsBundleMode: "managed",
+ instructionsRootPath: state.root,
+ instructionsEntryFile: state.entryFile,
+ instructionsFilePath: `${state.root}/${state.entryFile}`,
+ },
+ updatedAt: new Date(),
+ })
+ .where(eq(agents.id, state.agent.id));
+ }
+ };
+ // An exact replay can safely return the durable receipt even after its base
+ // advanced. Restore also deduplicates identical content, preserving history.
+ if (current && current.contentBase64 === candidate.toString("base64")) {
+ await bindEntry();
+ return { row: current, changed: false };
+ }
+ // A pre-upgrade run can still pin a historical UUID. Compare its bytes,
+ // never let the old head project over a newer directory.
+ const legacyBase = input.baseRevisionId && current?.id !== input.baseRevisionId
+ ? await tx.select().from(revisions).where(and(owner(input, input.entryFile), eq(revisions.id, input.baseRevisionId))).then(rows => rows[0])
+ : null;
+ if ((current?.id ?? null) !== input.baseRevisionId && !(legacyBase && legacyBase.contentHash === current?.contentHash)) {
+ throw conflict(
+ "Instructions changed since the base revision. Read the current entry before saving.",
+ {
+ code: "INSTRUCTION_REVISION_CONFLICT",
+ baseRevisionId: input.baseRevisionId,
+ currentRevisionId: current?.id ?? null,
+ },
+ );
+ }
+ await materializeInstructionBytes(state.root, state.entryFile, candidate);
+ const row = currentRow(input, input.entryFile, candidate, bound);
+ await tx.insert(activityLog).values({ companyId: input.companyId,
+ actorType: bound.type === "plugin" ? "plugin" : bound.type === "board" ? "user" : "agent",
+ actorId: (bound.type === "plugin" ? bound.pluginId : bound.type === "board" ? bound.userId : bound.agentId)!,
+ agentId: bound.type === "agent" ? bound.agentId : null, runId: row.sourceRunId,
+ responsibleUserId: row.responsibleUserId, action: "agent.files_updated", entityType: "agent", entityId: input.agentId,
+ details: { path: input.entryFile, contentHash: row.contentHash, source: restored ? "legacy_restore" : (input as InstructionCommitInput).source } });
+ await bindEntry();
+ return { row, changed: true };
+ });
+ return { ...snapshot(result.row), changed: result.changed, materialization: "current" };
+ }
+
+ async function history(
+ target: InstructionTarget & {
+ entryFile: string;
+ cursor?: string;
+ limit?: number;
+ },
+ actor: AuthorizationActor,
+ ): Promise {
+ const limit = Math.max(1, Math.min(100, target.limit ?? 50));
+ return db.transaction(async (tx) => {
+ const state = await lockTarget(tx, target, actor);
+ const bound = await authorizeRead(tx, actor, target);
+ await currentFile(tx, target, state, bound);
+ instructionPath(target.entryFile);
+ const cursor = target.cursor
+ ? await getRevision(tx, target, target.entryFile, target.cursor)
+ : null;
+ // Keep PostgreSQL timestamp precision; JS Date truncates microseconds and
+ // would skip revisions in the cursor's fractional millisecond.
+ const cursorTime = cursor
+ ? sql`(select created_at from agent_instruction_revisions where id = ${cursor.id})`
+ : null;
+ const rows = await tx
+ .select(revisionMetadataColumns)
+ .from(revisions)
+ .where(
+ and(
+ owner(target, target.entryFile),
+ cursor && cursorTime
+ ? or(
+ lt(revisions.createdAt, cursorTime),
+ and(
+ eq(revisions.createdAt, cursorTime),
+ lt(revisions.id, cursor.id),
+ ),
+ )
+ : undefined,
+ ),
+ )
+ .orderBy(desc(revisions.createdAt), desc(revisions.id))
+ .limit(limit + 1);
+ return {
+ revisions: rows.slice(0, limit).map(metadata),
+ nextCursor: rows.length > limit ? rows[limit - 1]!.id : null,
+ };
+ });
+ }
+ async function readRevision(
+ target: InstructionTarget & { entryFile: string; revisionId: string },
+ actor: AuthorizationActor,
+ ) {
+ return db.transaction(async (tx) => {
+ await lockTarget(tx, target, actor);
+ await authorizeRead(tx, actor, target);
+ return snapshot(
+ await getRevision(
+ tx,
+ target,
+ instructionPath(target.entryFile),
+ target.revisionId,
+ ),
+ );
+ });
+ }
+ async function diff(
+ target: InstructionTarget & {
+ entryFile: string;
+ fromRevisionId: string;
+ toRevisionId: string;
+ },
+ actor: AuthorizationActor,
+ ): Promise {
+ const from = await readRevision(
+ { ...target, revisionId: target.fromRevisionId },
+ actor,
+ );
+ const to = await readRevision(
+ { ...target, revisionId: target.toRevisionId },
+ actor,
+ );
+ // Linear, bounded exact replacement diff. Code points keep surrogate pairs intact.
+ const a = Array.from(from.content),
+ b = Array.from(to.content);
+ let start = 0,
+ end = 0;
+ while (start < a.length && start < b.length && a[start] === b[start])
+ start++;
+ while (
+ end < a.length - start &&
+ end < b.length - start &&
+ a[a.length - end - 1] === b[b.length - end - 1]
+ )
+ end++;
+ return {
+ from,
+ to,
+ prefix: a.slice(0, start).join(""),
+ removed: a.slice(start, a.length - end).join(""),
+ added: b.slice(start, b.length - end).join(""),
+ suffix: end ? a.slice(-end).join("") : "",
+ };
+ }
+ return {
+ readCommittedForRuntime,
+ readCurrent,
+ readForPluginReset,
+ commitPluginReset: (input: Omit & { configuredEntryFile: string }, actor: PluginResetActor) =>
+ commitInternal({ ...input, source: "api" }, actor, input.configuredEntryFile),
+ readRevision,
+ history,
+ diff,
+ materializeCurrent,
+ commit: (input: InstructionCommitInput, actor: AuthorizationActor) =>
+ commitInternal(input, actor),
+ restore: (
+ input: InstructionTarget & {
+ entryFile: string;
+ baseRevisionId: string;
+ revisionId: string;
+ },
+ actor: AuthorizationActor,
+ ) =>
+ commitInternal({ ...input, restoreRevisionId: input.revisionId }, actor),
+ };
+}
diff --git a/server/src/services/agent-instruction-working-copies.ts b/server/src/services/agent-instruction-working-copies.ts
new file mode 100644
index 00000000000..7611dcb614f
--- /dev/null
+++ b/server/src/services/agent-instruction-working-copies.ts
@@ -0,0 +1,356 @@
+import { agentDirectoryWorkingCopyService, isAgentDirectoryCopy } from "./agent-directory-working-copies.js";
+import { createHash } from "node:crypto";
+import { execFile as execFileCallback } from "node:child_process";
+import { promisify } from "node:util";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { and, asc, desc, eq, inArray, lte, or, isNull, isNotNull, sql } from "drizzle-orm";
+import { agents, heartbeatRuns, agentInstructionWorkingCopies as copies, type Db } from "@paperclipai/db";
+import {
+ prepareAdapterExecutionTargetRuntime,
+ runAdapterExecutionTargetShellCommand,
+ type AdapterExecutionTarget,
+} from "@paperclipai/adapter-utils/execution-target";
+import { conflict, notFound } from "../errors.js";
+import { agentInstructionsService, agentInstructionsBundleMode } from "./agent-instructions.js";
+import { agentInstructionRevisionService } from "./agent-instruction-revisions.js";
+import { resolveInstructionActor } from "./agent-instruction-authorization.js";
+import { assertInstructionPathSafe, instructionBytes, instructionPath, materializeInstructionBytes, MAX_INSTRUCTION_BYTES, readInstructionBytes, instructionGitExcludeProgram } from "./agent-instruction-files.js";
+import { hasNativeLocalProcessStop } from "./native-local-process-stop.js";
+import { remoteExecutionHasStopped } from "./remote-execution-termination.js";
+import type { AuthorizationActor } from "./authorization.js";
+import type { EnvironmentRuntimeService } from "./environment-runtime.js";
+
+const execFile = promisify(execFileCallback);
+type Copy = typeof copies.$inferSelect;
+const hash = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
+const quote = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`;
+const completed = new Set(["saved", "unchanged", "resolved"]);
+const MAX_COLLECTION_ATTEMPTS = 3;
+// Only orchestration can register a live target; tools and HTTP callers cannot
+// supply one. These handles are optional: durable captured bytes survive restart.
+const liveTargets = new Map();
+const targetKey = (companyId: string, runId: string) => `${companyId}:${runId}`;
+
+export function instructionWorkingCopyGuidance(copy: Pick) {
+ if (isAgentDirectoryCopy(copy)) return `Your persistent agent directory is ${copy.executionRoot} (AGENT_HOME). Your instruction entry is ${copy.executionRoot}/${copy.entryFile}. Read and write your own files and subfolders there. This directory belongs to this agent across tasks and sessions; task files belong in the task working directory. Paperclip restores this directory before execution and saves changes after the provider stops. Regular files, including binary files, persist; symlinks and special files are unsupported. Check the agent-files save receipt before claiming persistence; Only files you change or delete are synchronized. If another run changes the same file, the last completed synchronization wins. Temporary copies are removed after synchronization; there is no per-run file history. Storage allows 256 MiB per file, 2 GiB total, and 100,000 entries; the instruction entry must remain UTF-8 and at most 1 MiB. Reaching a storage limit never prevents this or future tasks from running. Remove or shrink files to free space; changes that exceed the limits will not be saved.${typeof copy.receipt?.storageWarning === "string" ? `\n\n${copy.receipt.storageWarning}` : ""}`;
+ return `Your editable agent instruction file is ${copy.executionRoot}/${copy.entryFile}. Edit this registered private copy normally. After this run stops, Paperclip saves changed content as a persistent revision if your responsible user still has permission and the baseline has not changed. Check the run's instruction-save receipt before claiming persistence. Use read_agent_instructions, update_agent_instructions, get_agent_instruction_history, and restore_agent_instructions for immediate saves and history. Read first and pin the returned revision. Preserve conflicts; never silently retry against a newer head. Repository instructions, skills, and the loaded prompt are separate and are not collected.`;
+}
+
+/** Remote reads use the registered entry only; never scan for files called AGENTS.md. */
+export function instructionCollectionScript(root: string, entry: string) {
+ instructionPath(entry);
+ return `node -e ${quote([
+ "const fs=require('node:fs'),path=require('node:path')",
+ "const root=process.argv[1],entry=process.argv[2],max=Number(process.argv[3])",
+ "const absolute=path.resolve(root,entry),parts=absolute.split('/').filter(Boolean)",
+ "let current='/'",
+ "for(let i=0;imax)throw Error('INSTRUCTION_CONTENT_INVALID');const bytes=Buffer.alloc(max+1);let n=0;while(nmax)throw Error('INSTRUCTION_CONTENT_INVALID');process.stdout.write(JSON.stringify({contentBase64:bytes.subarray(0,n).toString('base64')}))}finally{fs.closeSync(fd)}",
+ ].join(";"))} ${quote(root)} ${quote(entry)} ${MAX_INSTRUCTION_BYTES}`;
+}
+
+
+export function agentInstructionWorkingCopyService(db: Db, options: { environmentRuntime?: EnvironmentRuntimeService } = {}) {
+ const revisions = agentInstructionRevisionService(db);
+ const scope = (companyId: string, runId: string) => and(eq(copies.companyId, companyId), eq(copies.runId, runId));
+ async function get(companyId: string, runId: string) {
+ const [row] = await db.select().from(copies).where(scope(companyId, runId));
+ return row ?? null;
+ }
+ async function patch(row: Copy, values: Partial) {
+ const [updated] = await db.update(copies).set({ ...values, updatedAt: new Date() }).where(and(
+ scope(row.companyId, row.runId), eq(copies.state, row.state), eq(copies.attempts, row.attempts), eq(copies.baseHash, row.baseHash),
+ )).returning();
+ // A late cleanup must not overwrite an explicit resolution or a newer
+ // baseline. Canonical content has its own independent head CAS.
+ return updated ?? (await get(row.companyId, row.runId))!;
+ }
+ function actorFor(row: Copy): AuthorizationActor {
+ return { type: "agent", companyId: row.companyId, agentId: row.agentId, runId: row.runId, onBehalfOfUserId: row.responsibleUserId };
+ }
+ const directories = agentDirectoryWorkingCopyService(db, get, patch, options.environmentRuntime);
+ async function prepare(input: { companyId: string; agentId: string; runId: string; target?: AdapterExecutionTarget | null; cwd: string; legacy?: boolean }) {
+ const existing = await get(input.companyId, input.runId);
+ if (isAgentDirectoryCopy(existing) || (!existing && !input.legacy)) return directories.prepare(input);
+ let refreshStoppedCopy = false;
+ const workspace = await fs.realpath(input.cwd);
+ const expectedLocalRoot = path.join(workspace, ".paperclip-runtime", `instruction-edits-${input.runId}`, "instructions");
+ if (existing && existing.localRoot !== expectedLocalRoot) throw conflict("The registered instruction copy belongs to a different run workspace");
+ const location = input.target?.kind === "remote" ? `remote:${input.target.environmentId ?? ""}` : "local";
+ if (existing && (existing.agentId !== input.agentId || existing.location !== location)) {
+ throw conflict("The registered instruction copy belongs to a different run environment");
+ }
+ // Preserve in-flight bytes and their CAS baseline. A completed, stopped copy
+ // can start a fresh lifecycle only after its recorded bytes are accounted for.
+ if (existing && existing.state !== "preparing") {
+ if (["conflict", "pending_commit", "unavailable"].includes(existing.state)) {
+ throw conflict("Resolve this run's preserved instruction candidate before editing its working copy again");
+ }
+ if (input.target) liveTargets.set(targetKey(input.companyId, input.runId), input.target);
+ if (completed.has(existing.state) && existing.processStoppedAt) {
+ const privateBytes = await readCandidate(existing, input.target);
+ // Refresh only bytes whose previous lifecycle is complete. A changed
+ // private file must keep its old CAS fence; never silently rebase edits.
+ refreshStoppedCopy = privateBytes === null || hash(privateBytes) === (existing.candidateHash ?? existing.baseHash);
+ if (!refreshStoppedCopy && privateBytes !== null) {
+ // A failed staging attempt can already have replaced the file before
+ // the completed row advances. Exact current canonical bytes are also
+ // accounted for; any other private edit still keeps its old fence.
+ const current = await revisions.readCommittedForRuntime({ companyId: input.companyId, agentId: input.agentId });
+ refreshStoppedCopy = current?.revision.entryFile === existing.entryFile
+ && current.revision.contentHash === hash(privateBytes);
+ }
+ }
+ if (!refreshStoppedCopy) {
+ if (completed.has(existing.state) || existing.state === "unchanged_turn") {
+ // unchanged_turn is a live warm-owner observation, not stop proof.
+ return patch(existing, { state: "prepared", candidateBase64: null, candidateHash: null,
+ receipt: null, processStoppedAt: null, attempts: 0, nextAttemptAt: null });
+ }
+ return existing;
+ }
+ }
+ const [agent] = await db.select().from(agents).where(and(eq(agents.id, input.agentId), eq(agents.companyId, input.companyId)));
+ if (!agent) throw notFound("Agent not found");
+ if (agentInstructionsBundleMode(agent) !== "managed") return null;
+ const bound = await resolveInstructionActor(db, { type: "agent", companyId: input.companyId, agentId: input.agentId, runId: input.runId });
+ const baseline = existing?.baseRevisionId && !refreshStoppedCopy
+ ? await revisions.readRevision({ companyId: input.companyId, agentId: input.agentId, entryFile: existing.entryFile, revisionId: existing.baseRevisionId }, bound).catch(async (error) => {
+ if ((error as { status?: number }).status !== 404) throw error;
+ const current = await revisions.readCurrent({ companyId: input.companyId, agentId: input.agentId }, bound);
+ if (current?.revision.id !== existing.baseRevisionId) throw conflict("The instruction baseline changed during preparation");
+ return current;
+ })
+ : await revisions.readCurrent({ companyId: input.companyId, agentId: input.agentId }, bound);
+ if (!baseline) return null;
+ const exported = await agentInstructionsService().exportFiles(agent, { rejectSymlinks: true });
+ const entryFile = instructionPath(baseline?.revision.entryFile ?? exported.entryFile);
+ const content = instructionBytes(baseline?.content ?? exported.files[entryFile] ?? "");
+ const localRoot = existing?.localRoot ?? expectedLocalRoot;
+ const target = input.target?.kind === "remote" ? input.target : null;
+ const executionRoot = target
+ ? path.posix.join(target.remoteCwd, ".paperclip-runtime", `instruction-edits-${input.runId}`, "instructions")
+ : localRoot;
+ if (!existing) {
+ await db.insert(copies).values({
+ runId: input.runId, companyId: input.companyId, agentId: input.agentId,
+ responsibleUserId: bound.onBehalfOfUserId!, entryFile,
+ baseRevisionId: baseline?.revision.id ?? null, baseHash: hash(content),
+ localRoot, executionRoot, location, state: "preparing",
+ });
+ }
+ const row = (await get(input.companyId, input.runId))!;
+ await assertInstructionPathSafe(localRoot, entryFile);
+ await execFile(process.execPath, ["-e", instructionGitExcludeProgram, workspace], { timeout: 15_000 });
+ await fs.mkdir(localRoot, { recursive: true, mode: 0o700 });
+ for (const [name, text] of Object.entries({ ...exported.files, [entryFile]: content.toString("utf8") })) {
+ const relative = instructionPath(name);
+ const filename = await assertInstructionPathSafe(localRoot, relative);
+ await fs.mkdir(path.dirname(filename), { recursive: true, mode: 0o700 });
+ // Atomic replacement also allows interrupted preparation to restage an
+ // already-read-only sibling without writing through its existing inode.
+ await materializeInstructionBytes(localRoot, relative, instructionBytes(text));
+ await fs.chmod(filename, name === entryFile ? 0o600 : 0o400);
+ }
+ if (target) {
+ const staged = await prepareAdapterExecutionTargetRuntime({
+ target, runId: input.runId, adapterKey: `instruction-edits-${input.runId}`,
+ workspaceLocalDir: input.cwd, syncWorkspace: false,
+ assets: [{ key: "instructions", localDir: localRoot, followSymlinks: false }],
+ });
+ if (staged.assetDirs.instructions !== executionRoot) throw new Error("Instruction copy staging path changed");
+ const excluded = await runAdapterExecutionTargetShellCommand(input.runId, target,
+ `node -e ${quote(instructionGitExcludeProgram)} ${quote(target.remoteCwd)}`, { cwd: target.remoteCwd, env: {}, timeoutSec: 15 });
+ if (excluded.exitCode !== 0 || excluded.timedOut) throw new Error("Could not exclude private instructions from Git staging");
+ }
+ // A refresh does not supersede the completed turn until every local and
+ // remote staging step succeeds. Keep its receipt and stop evidence intact
+ // if preparation throws or the controller stops midway through staging.
+ const prepared = await patch(row, { state: "prepared", ...(refreshStoppedCopy ? {
+ entryFile, baseRevisionId: baseline.revision.id, baseHash: hash(content),
+ candidateBase64: null, candidateHash: null, receipt: null, processStoppedAt: null,
+ attempts: 0, nextAttemptAt: null, errorCode: null, errorMessage: null,
+ } : {}) });
+ if (input.target) liveTargets.set(targetKey(input.companyId, input.runId), input.target);
+ return prepared;
+ }
+
+ async function readCandidate(row: Copy, target?: AdapterExecutionTarget | null) {
+ if (row.location === "local") return readInstructionBytes(row.localRoot, row.entryFile);
+ if (target?.kind !== "remote") throw new Error("The original execution environment is needed to retrieve this instruction copy");
+ if (row.location !== `remote:${target.environmentId ?? ""}`) throw new Error("Instruction copy execution environment changed");
+ const expected = path.posix.join(target.remoteCwd, ".paperclip-runtime", `instruction-edits-${row.runId}`, "instructions");
+ if (expected !== row.executionRoot) throw new Error("Instruction copy execution environment changed");
+ const result = await runAdapterExecutionTargetShellCommand(row.runId, target, instructionCollectionScript(row.executionRoot, row.entryFile), { cwd: target.remoteCwd, env: {}, timeoutSec: 30 });
+ if (result.exitCode !== 0 || result.timedOut) throw new Error("Could not safely retrieve the stopped run's instruction file");
+ const payload = JSON.parse(result.stdout) as { missing?: boolean; contentBase64?: string };
+ if (payload.missing) return null;
+ if (typeof payload.contentBase64 !== "string" || payload.contentBase64.length > Math.ceil(MAX_INSTRUCTION_BYTES / 3) * 4) throw new Error("Instruction response exceeds the collection bound");
+ return instructionBytes(Buffer.from(payload.contentBase64, "base64"));
+ }
+
+ /** A terminal-turn probe is not a save: changed/invalid copies require a
+ * stopped provider before capture. Unchanged warm providers remain reusable. */
+ async function hasChanges(input: { companyId: string; runId: string; target?: AdapterExecutionTarget | null }) {
+ const row = await get(input.companyId, input.runId);
+ if (!row || completed.has(row.state)) return false;
+ if (isAgentDirectoryCopy(row)) return directories.hasChanges(row, input.target);
+ try {
+ const content = await readCandidate(row, input.target);
+ if (content !== null && hash(content) === row.baseHash) {
+ // This is an unchanged turn receipt, not stopped-process collection.
+ // No candidate bytes or new revision are persisted from a live owner.
+ await patch(row, { state: "unchanged_turn", nextAttemptAt: null });
+ return false;
+ }
+ return true;
+ } catch { return true; }
+ }
+
+ async function acknowledgeExplicitSave(input: { companyId: string; agentId: string; runId: string; entryFile: string; revisionId: string; contentHash: string }) {
+ const row = await get(input.companyId, input.runId);
+ if (!row || isAgentDirectoryCopy(row) || row.agentId !== input.agentId || row.entryFile !== input.entryFile || row.state !== "prepared") return;
+ const target = liveTargets.get(targetKey(row.companyId, row.runId));
+ const content = await readCandidate(row, target).catch(() => null);
+ // Never advance a stale local edit past an unrelated explicit update.
+ if (content === null || hash(content) !== input.contentHash) return;
+ await db.update(copies).set({ baseRevisionId: input.revisionId, baseHash: input.contentHash, updatedAt: new Date() })
+ .where(and(scope(row.companyId, row.runId), eq(copies.state, "prepared"), eq(copies.baseHash, row.baseHash)));
+ }
+
+ async function commitCandidate(row: Copy) {
+ if (isAgentDirectoryCopy(row)) return directories.collectStopped(row);
+ if (row.candidateBase64 === null || completed.has(row.state) || row.state === "conflict") return row;
+ try {
+ const receipt = await revisions.commit({
+ companyId: row.companyId, agentId: row.agentId, entryFile: row.entryFile,
+ baseRevisionId: row.baseRevisionId, content: Buffer.from(row.candidateBase64, "base64"), source: "cleanup",
+ }, actorFor(row));
+ return patch(row, { state: "saved", receipt: { ...receipt }, errorCode: null, errorMessage: null, nextAttemptAt: null });
+ } catch (error) {
+ const detail = error as { status?: number; details?: { code?: string }; message?: string };
+ const code = detail.details?.code ?? "INSTRUCTION_SAVE_FAILED";
+ const retryable = !detail.status || detail.status >= 500;
+ const message = detail.status === 403
+ ? "Current permissions do not allow this instruction save. The candidate was preserved."
+ : detail.status === 409
+ ? "Instructions changed after this run started. Review the preserved candidate against the current revision."
+ : "Instruction edits were preserved but not saved. Review the candidate before retrying.";
+ return patch(row, { state: retryable ? "pending_commit" : "conflict", errorCode: code, errorMessage: message, nextAttemptAt: retryable && row.attempts < MAX_COLLECTION_ATTEMPTS ? new Date(Date.now() + 30_000) : null });
+ }
+ }
+
+ /** Caller must have joined the owned provider process before granting this proof. */
+ async function collectStopped(input: { companyId: string; runId: string; target?: AdapterExecutionTarget | null }) {
+ let row = await get(input.companyId, input.runId);
+ if (!row) return row;
+ if (isAgentDirectoryCopy(row)) return directories.collectStopped(row, input.target);
+ if (completed.has(row.state) || row.state === "conflict") return row;
+ row = await patch(row, { processStoppedAt: row.processStoppedAt ?? new Date(), attempts: row.attempts + 1 });
+ if (completed.has(row.state) || row.state === "conflict") return row;
+ if (row.candidateBase64 !== null) return commitCandidate(row);
+ try {
+ const candidate = await readCandidate(row, input.target);
+ if (candidate === null) {
+ return patch(row, { state: "unavailable", errorCode: "INSTRUCTION_FILE_MISSING", errorMessage: "The registered instruction file was removed. Canonical instructions were kept; no edits were saved.", nextAttemptAt: null });
+ }
+ if (hash(candidate) === row.baseHash) return patch(row, { state: "unchanged", nextAttemptAt: null });
+ // Save the candidate durably before attempting authorization/CAS or releasing the environment.
+ row = await patch(row, { state: "pending_commit", candidateBase64: candidate.toString("base64"), candidateHash: hash(candidate), nextAttemptAt: new Date() });
+ return commitCandidate(row);
+ } catch (error) {
+ return patch(row, { state: row.attempts < MAX_COLLECTION_ATTEMPTS ? "pending_collection" : "unavailable", errorCode: "INSTRUCTION_COLLECTION_FAILED", errorMessage: "The registered instruction copy could not be read safely. Canonical instructions were kept; no save receipt exists.", nextAttemptAt: row.attempts < MAX_COLLECTION_ATTEMPTS ? new Date(Date.now() + 30_000) : null });
+ }
+ }
+
+ /** Restarts can retry persisted bytes without touching a live filesystem or launching a model. */
+ async function recoverCaptured() {
+ const pending = await db.select().from(copies).where(and(
+ eq(copies.state, "pending_commit"),
+ or(isNull(copies.nextAttemptAt), lte(copies.nextAttemptAt, new Date())),
+ lte(copies.attempts, MAX_COLLECTION_ATTEMPTS - 1),
+ )).limit(20);
+ for (const row of pending) {
+ const result = await commitCandidate(await patch(row, { attempts: row.attempts + 1 }));
+ if (isAgentDirectoryCopy(result) && completed.has(result.state)) await directories.release(result);
+ }
+ // A crash between a durable save and release must not retain run snapshots
+ // forever. Only discard copies with recorded stop proof and a terminal receipt.
+ const cleanup = await db.select().from(copies).where(and(
+ inArray(copies.state, [...completed, "unavailable"]), isNotNull(copies.processStoppedAt),
+ sql`${copies.receipt}->>'schema' = 'paperclip.agent-files.v1'`,
+ or(sql`${copies.receipt} ? 'baseline'`, sql`${copies.receipt}->>'cleanupPending' = 'true'`),
+ or(isNull(copies.nextAttemptAt), lte(copies.nextAttemptAt, new Date())),
+ )).orderBy(asc(copies.updatedAt)).limit(20);
+ for (const row of cleanup) await directories.release(row);
+ return pending.length;
+ }
+
+ /** Recover only the registered copy after independently recorded process-stop
+ * proof. Terminal run status is never a substitute for that proof. Remote
+ * receipts mean the environment is stopped; do not restart it just to read. */
+ async function recoverStopped() {
+ const pending = await db.select({ copy: copies, runtimeMode: heartbeatRuns.runtimeMode }).from(copies)
+ .innerJoin(heartbeatRuns, and(eq(heartbeatRuns.companyId, copies.companyId), eq(heartbeatRuns.id, copies.runId)))
+ .where(and(or(inArray(copies.state, ["prepared", "pending_collection"]),
+ and(eq(copies.state, "preparing"), sql`${copies.receipt}->>'schema' = 'paperclip.agent-files.v1'`)),
+ inArray(heartbeatRuns.status, ["succeeded", "failed", "cancelled", "timed_out", "interrupted"]),
+ lte(copies.attempts, MAX_COLLECTION_ATTEMPTS - 1))).limit(20);
+ for (const { copy: row, runtimeMode } of pending) {
+ if (row.state === "preparing" && isAgentDirectoryCopy(row)) {
+ // A provider cannot launch until preparation records "prepared". With
+ // the owning run terminal, this is an interrupted staging copy only.
+ await directories.release(await patch(row, { state: "unavailable", processStoppedAt: new Date(),
+ errorCode: "AGENT_FILES_PREPARE_FAILED", errorMessage: "Agent-file preparation was interrupted. The temporary copy was discarded.", nextAttemptAt: null }));
+ } else if (row.location === "local" && (row.processStoppedAt || runtimeMode === "native" &&
+ await hasNativeLocalProcessStop(db, row.companyId, row.runId))) {
+ const result = await collectStopped({ companyId: row.companyId, runId: row.runId });
+ if (result && isAgentDirectoryCopy(result) && completed.has(result.state)) await directories.release(result);
+ } else if (row.location !== "local" && await remoteExecutionHasStopped(db, row.companyId, row.runId)) {
+ const unavailable = await reportUnavailable(row.companyId, row.runId);
+ if (unavailable && isAgentDirectoryCopy(unavailable)) {
+ await directories.release(await patch(unavailable, { processStoppedAt: unavailable.processStoppedAt ?? new Date() }));
+ }
+ } else if (row.state === "prepared") {
+ await patch(row, { state: "pending_collection", errorCode: "INSTRUCTION_STOP_UNCONFIRMED",
+ errorMessage: "The provider's stop has not been confirmed. Instruction collection is pending; no save is claimed.", nextAttemptAt: null });
+ }
+ }
+ return pending.length;
+ }
+
+ async function list(companyId: string, agentId: string, actor: AuthorizationActor) {
+ await revisions.readCurrent({ companyId, agentId }, actor);
+ const rows = await db.select().from(copies).where(and(eq(copies.companyId, companyId), eq(copies.agentId, agentId), inArray(copies.state, ["conflict", "pending_collection", "pending_commit", "unavailable"]))).orderBy(desc(copies.createdAt)).limit(50);
+ return rows.map(row => ({ runId: row.runId, entryFile: row.entryFile, baseRevisionId: row.baseRevisionId,
+ baseHash: row.baseHash, state: row.state as "conflict" | "pending_collection" | "pending_commit" | "unavailable",
+ candidateHash: row.candidateHash, contract: isAgentDirectoryCopy(row) ? "agent_files" : "legacy",
+ content: row.candidateBase64 === null ? null : Buffer.from(row.candidateBase64, "base64").toString("utf8"),
+ errorCode: row.errorCode, errorMessage: row.errorMessage, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString() }));
+ }
+
+ async function resolve(input: { companyId: string; agentId: string; runId: string; baseRevisionId: string | null; content: string }, actor: AuthorizationActor) {
+ const row = await get(input.companyId, input.runId);
+ if (!row || row.agentId !== input.agentId) throw notFound("Instruction candidate not found");
+ if (completed.has(row.state)) throw conflict("This instruction candidate has already been resolved");
+ if (isAgentDirectoryCopy(row)) throw conflict("Agent-file synchronization failures have no preserved candidate");
+ const receipt = await revisions.commit({ companyId: input.companyId, agentId: input.agentId, entryFile: row.entryFile, content: input.content, baseRevisionId: input.baseRevisionId, source: "api" }, actor);
+ await patch(row, { state: "resolved", receipt: { ...receipt }, nextAttemptAt: null });
+ return receipt;
+ }
+
+ /** A controller may report loss before disposal, never fabricate a save or
+ * read a possibly live provider. Already captured candidates remain intact. */
+ async function reportUnavailable(companyId: string, runId: string) {
+ const row = await get(companyId, runId);
+ if (!row || completed.has(row.state) || row.state === "unchanged_turn" || row.candidateBase64 !== null || (isAgentDirectoryCopy(row) && row.candidateHash !== null) || row.state === "conflict") return row;
+ if (isAgentDirectoryCopy(row) && row.state === "unavailable") return row;
+ return patch(row, { state: "unavailable", errorCode: "INSTRUCTION_COLLECTION_UNAVAILABLE",
+ errorMessage: "The registered instruction copy could not be retrieved safely before environment release. No instruction save is claimed.", nextAttemptAt: null });
+ }
+
+ async function release(companyId: string, runId: string) { liveTargets.delete(targetKey(companyId, runId)); const row = await get(companyId, runId); if (row && isAgentDirectoryCopy(row)) await directories.release(row); }
+ return { prepare, get, hasChanges, acknowledgeExplicitSave, collectStopped, recoverCaptured, recoverStopped, list, resolve, reportUnavailable, release };
+}
diff --git a/server/src/services/agent-instructions.ts b/server/src/services/agent-instructions.ts
index 5045143eb70..4f66a27545a 100644
--- a/server/src/services/agent-instructions.ts
+++ b/server/src/services/agent-instructions.ts
@@ -1,5 +1,9 @@
+import { inspectAgentFile, fileHash, agentFilePath, MAX_AGENT_FILE_BYTES } from "./agent-file-store.js";
import fs from "node:fs/promises";
import path from "node:path";
+import { and, eq } from "drizzle-orm";
+import { agentInstructionHeads, agents, type Db } from "@paperclipai/db";
+import { instructionPath, assertInstructionPathSafe, instructionBytes, readInstructionBytes } from "./agent-instruction-files.js";
import { notFound, unprocessable } from "../errors.js";
import { resolveHomeAwarePath, resolvePaperclipInstanceRoot } from "../home-paths.js";
@@ -36,6 +40,8 @@ type AgentLike = {
type AgentInstructionsFileSummary = {
path: string;
+ contentHash?: string;
+ binary?: boolean;
size: number;
language: string;
markdown: boolean;
@@ -53,6 +59,7 @@ type AgentInstructionsFileDetail = AgentInstructionsFileSummary & {
type AgentInstructionsBundle = {
agentId: string;
companyId: string;
+ persistence?: "agent_files";
mode: BundleMode | null;
rootPath: string | null;
managedRootPath: string;
@@ -112,11 +119,7 @@ function isMarkdown(relativePath: string) {
}
function normalizeRelativeFilePath(candidatePath: string): string {
- const normalized = path.posix.normalize(candidatePath.replaceAll("\\", "/")).replace(/^\/+/, "");
- if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) {
- throw unprocessable("Instructions file path must stay within the bundle root");
- }
- return normalized;
+ return instructionPath(candidatePath);
}
function resolvePathWithinRoot(rootPath: string, relativePath: string): string {
@@ -130,7 +133,7 @@ function resolvePathWithinRoot(rootPath: string, relativePath: string): string {
return absolutePath;
}
-function resolveManagedInstructionsRoot(agent: AgentLike): string {
+export function resolveManagedInstructionsRoot(agent: AgentLike): string {
return path.resolve(
resolvePaperclipInstanceRoot(),
"companies",
@@ -153,7 +156,7 @@ function resolveLegacyInstructionsPath(candidatePath: string, config: Record null);
+ return fs.lstat(targetPath).catch(() => null);
}
function shouldIgnoreInstructionsEntry(entry: { name: string; isDirectory(): boolean; isFile(): boolean }) {
@@ -172,14 +175,14 @@ function shouldIgnoreInstructionsEntry(entry: { name: string; isDirectory(): boo
async function listFilesRecursive(
rootPath: string,
- options?: { rejectSymlinks?: boolean },
+ options?: { rejectSymlinks?: boolean; legacyExcludes?: boolean },
): Promise {
const output: string[] = [];
async function walk(currentPath: string, relativeDir: string) {
const entries = await fs.readdir(currentPath, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
- if (shouldIgnoreInstructionsEntry(entry)) continue;
+ if (options?.legacyExcludes && shouldIgnoreInstructionsEntry(entry)) continue;
const absolutePath = path.join(currentPath, entry.name);
const relativePath = normalizeRelativeFilePath(
relativeDir ? path.posix.join(relativeDir, entry.name) : entry.name,
@@ -206,13 +209,21 @@ async function listFilesRecursive(
async function readFileSummary(rootPath: string, relativePath: string, entryFile: string): Promise {
const absolutePath = resolvePathWithinRoot(rootPath, relativePath);
const stat = await fs.stat(absolutePath);
+ // External bundles may contain large assets; listing must not read them.
+ const file = stat.size > MAX_AGENT_FILE_BYTES ? null : await inspectAgentFile(rootPath, relativePath);
+ return summarizeFile(relativePath, entryFile, file?.size ?? stat.size, file?.bytes ?? null, file?.hash);
+}
+
+function summarizeFile(relativePath: string, entryFile: string, size: number, bytes: Buffer | null, hash?: string): AgentInstructionsFileSummary {
+ let binary = bytes === null;
+ try { if (bytes?.includes(0)) binary = true; new TextDecoder("utf-8", { fatal: true }).decode(bytes ?? undefined); } catch { binary = true; }
return {
path: relativePath,
- size: stat.size,
+ size,
language: inferLanguage(relativePath),
markdown: isMarkdown(relativePath),
isEntryFile: relativePath === entryFile,
- editable: true,
+ editable: !binary, binary, contentHash: hash,
deprecated: false,
virtual: false,
};
@@ -231,7 +242,7 @@ async function readLegacyInstructions(agent: AgentLike, config: Record[0]>[0] | undefined = db) {
+ if (!connection) throw unprocessable("Bundle initialization requires the database-backed instructions service");
+ const [head] = await connection.select().from(agentInstructionHeads).where(and(eq(agentInstructionHeads.companyId, agent.companyId),
+ eq(agentInstructionHeads.agentId, agent.id), eq(agentInstructionHeads.entryFile, entryFile)));
+ if (head) throw unprocessable("This entry has revision history. Use the instruction content API with its baseRevisionId to update it.", { code: "INSTRUCTION_REVISION_REQUIRED" });
+ }
async function getBundle(agent: AgentLike): Promise {
const state = await recoverManagedBundleState(agent, deriveBundleState(agent));
if (!state.rootPath) return toBundle(agent, state, []);
+ await assertInstructionPathSafe(state.rootPath, state.entryFile);
const stat = await statIfExists(state.rootPath);
if (!stat?.isDirectory()) {
return toBundle(agent, {
@@ -484,8 +506,12 @@ export function agentInstructionsService() {
warnings: [...state.warnings, `Instructions root does not exist: ${state.rootPath}`],
}, []);
}
- const files = await listFilesRecursive(state.rootPath);
- const summaries = await Promise.all(files.map((relativePath) => readFileSummary(state.rootPath!, relativePath, state.entryFile)));
+ const files = await listFilesRecursive(state.rootPath, { legacyExcludes: state.mode === "external" });
+ const summaries: AgentInstructionsFileSummary[] = [];
+ // Bound open descriptors and text buffers even for a large personal folder.
+ for (let index = 0; index < files.length; index += 8) {
+ summaries.push(...await Promise.all(files.slice(index, index + 8).map(relativePath => readFileSummary(state.rootPath!, relativePath, state.entryFile))));
+ }
return toBundle(agent, state, summaries);
}
@@ -507,24 +533,11 @@ export function agentInstructionsService() {
};
}
if (!state.rootPath) throw notFound("Agent instructions bundle is not configured");
- const absolutePath = resolvePathWithinRoot(state.rootPath, relativePath);
- const [content, stat] = await Promise.all([
- fs.readFile(absolutePath, "utf8").catch(() => null),
- fs.stat(absolutePath).catch(() => null),
- ]);
- if (content === null || !stat?.isFile()) throw notFound("Instructions file not found");
- const normalizedPath = normalizeRelativeFilePath(relativePath);
- return {
- path: normalizedPath,
- size: stat.size,
- language: inferLanguage(normalizedPath),
- markdown: isMarkdown(normalizedPath),
- isEntryFile: normalizedPath === state.entryFile,
- editable: true,
- deprecated: false,
- virtual: false,
- content,
- };
+ await assertInstructionPathSafe(state.rootPath, relativePath);
+ const file = await inspectAgentFile(state.rootPath, relativePath);
+ if (file === null) throw notFound("Instructions file not found");
+ const summary = summarizeFile(relativePath, state.entryFile, file.size, file.bytes, file.hash);
+ return { ...summary, content: summary.binary ? "" : file.bytes!.toString("utf8") };
}
async function ensureWritableBundle(
@@ -551,11 +564,12 @@ export function agentInstructionsService() {
});
await fs.mkdir(managedRoot, { recursive: true });
- const entryPath = resolvePathWithinRoot(managedRoot, entryFile);
+ const entryPath = await assertInstructionPathSafe(managedRoot, entryFile);
const entryStat = await statIfExists(entryPath);
if (!entryStat?.isFile()) {
const legacyInstructions = await readLegacyInstructions(agent, current.config);
if (legacyInstructions.trim().length > 0) {
+ await assertUnversionedEntry(agent, entryFile);
await fs.mkdir(path.dirname(entryPath), { recursive: true });
await fs.writeFile(entryPath, legacyInstructions, "utf8");
}
@@ -595,9 +609,13 @@ export function agentInstructionsService() {
nextRootPath = resolvedRoot;
}
+ await assertInstructionPathSafe(nextRootPath, nextEntryFile);
await fs.mkdir(nextRootPath, { recursive: true });
const existingFiles = await listFilesRecursive(nextRootPath);
+ if (nextMode === "managed" && !existingFiles.includes(nextEntryFile)) {
+ await assertUnversionedEntry(agent, nextEntryFile);
+ }
const exported = await exportFiles(agent);
if (existingFiles.length === 0) {
await writeBundleFiles(nextRootPath, exported.files);
@@ -618,7 +636,7 @@ export function agentInstructionsService() {
return { bundle: nextBundle, adapterConfig: nextConfig };
}
- async function writeFile(
+ async function writeFileUnversioned(
agent: AgentLike,
relativePath: string,
content: string,
@@ -642,7 +660,14 @@ export function agentInstructionsService() {
return { bundle, file, adapterConfig };
}
+ const configured = await recoverManagedBundleState(agent, current);
+ if (normalizeRelativeFilePath(relativePath) === configured.entryFile) {
+ throw unprocessable("Entry edits require the canonical instruction commit service and baseRevisionId", { code: "INSTRUCTION_REVISION_REQUIRED" });
+ }
+ if (configured.mode !== "external") agentFilePath(relativePath);
const prepared = await ensureWritableBundle(agent, options);
+ instructionBytes(content);
+ await assertInstructionPathSafe(prepared.state.rootPath!, relativePath);
const absolutePath = resolvePathWithinRoot(prepared.state.rootPath!, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, content, "utf8");
@@ -654,7 +679,7 @@ export function agentInstructionsService() {
return { bundle, file, adapterConfig: prepared.adapterConfig };
}
- async function deleteFile(agent: AgentLike, relativePath: string): Promise<{
+ async function deleteFileUnversioned(agent: AgentLike, relativePath: string): Promise<{
bundle: AgentInstructionsBundle;
adapterConfig: Record;
}> {
@@ -668,13 +693,35 @@ export function agentInstructionsService() {
if (normalizedPath === state.entryFile) {
throw unprocessable("Cannot delete the bundle entry file");
}
- const absolutePath = resolvePathWithinRoot(state.rootPath, normalizedPath);
+ const absolutePath = await assertInstructionPathSafe(state.rootPath, normalizedPath);
await fs.rm(absolutePath, { force: true });
const adapterConfig = buildPersistedBundleConfig(derived, state);
const bundle = await getBundle({ ...agent, adapterConfig });
return { bundle, adapterConfig };
}
+ // Reload configuration under the commit lock: a previously supporting file
+ // may have become the configured entry since the route loaded its agent.
+ async function withCurrentAgent(agent: AgentLike, relativePath: string, operation: (current: AgentLike) => Promise) {
+ if (!db) return operation(agent);
+ return db.transaction(async (tx) => {
+ const [current] = await tx.select().from(agents)
+ .where(and(eq(agents.id, agent.id), eq(agents.companyId, agent.companyId))).for("update");
+ if (!current) throw notFound("Agent not found");
+ if (relativePath !== LEGACY_PROMPT_TEMPLATE_PATH) {
+ // Historical entries also remain projections if the configured entry changes.
+ await assertUnversionedEntry(current, normalizeRelativeFilePath(relativePath), tx);
+ }
+ return operation(current);
+ });
+ }
+ async function writeFile(...args: Parameters) {
+ return withCurrentAgent(args[0], args[1], (current) => writeFileUnversioned(current, args[1], args[2], args[3]));
+ }
+ async function deleteFile(...args: Parameters) {
+ return withCurrentAgent(args[0], args[1], (current) => deleteFileUnversioned(current, args[1]));
+ }
+
async function exportFiles(agent: AgentLike, options?: { rejectSymlinks?: boolean }): Promise<{
files: Record;
entryFile: string;
@@ -684,7 +731,7 @@ export function agentInstructionsService() {
if (state.rootPath) {
const stat = await statIfExists(state.rootPath);
if (stat?.isDirectory()) {
- const relativePaths = await listFilesRecursive(state.rootPath, options);
+ const relativePaths = await listFilesRecursive(state.rootPath, { ...options, legacyExcludes: true });
const files = Object.fromEntries(await Promise.all(relativePaths.map(async (relativePath) => {
const absolutePath = resolvePathWithinRoot(state.rootPath!, relativePath);
const content = await fs.readFile(absolutePath, "utf8");
@@ -704,7 +751,7 @@ export function agentInstructionsService() {
};
}
- async function materializeManagedBundle(
+ async function materializeManagedBundleUnversioned(
agent: AgentLike,
files: Record,
options?: {
@@ -716,6 +763,14 @@ export function agentInstructionsService() {
const rootPath = resolveManagedInstructionsRoot(agent);
const entryFile = options?.entryFile ? normalizeRelativeFilePath(options.entryFile) : ENTRY_FILE_DEFAULT;
+ for (const [relativePath, content] of Object.entries(files)) {
+ instructionBytes(content);
+ await assertInstructionPathSafe(rootPath, agentFilePath(relativePath));
+ }
+ const previous = await readInstructionBytes(rootPath, entryFile);
+ if (previous && !previous.equals(instructionBytes(files[entryFile] ?? ""))) {
+ throw unprocessable("Existing entry content must be saved through the canonical revision API before replacing a bundle", { code: "INSTRUCTION_REVISION_REQUIRED" });
+ }
if (options?.replaceExisting) {
await fs.rm(rootPath, { recursive: true, force: true });
}
@@ -744,6 +799,24 @@ export function agentInstructionsService() {
return { bundle, adapterConfig };
}
+ async function materializeManagedBundle(
+ ...args: Parameters
+ ) {
+ if (!db) throw unprocessable("Bundle initialization requires the database-backed instructions service");
+ return db.transaction(async (tx) => {
+ const agent = args[0];
+ const [owner] = await tx.select({ id: agents.id }).from(agents).where(and(eq(agents.id, agent.id), eq(agents.companyId, agent.companyId))).for("update");
+ if (!owner) throw notFound("Agent not found");
+ await assertUnversionedEntry(agent, args[2]?.entryFile ? normalizeRelativeFilePath(args[2].entryFile) : ENTRY_FILE_DEFAULT, tx);
+ if (args[2]?.replaceExisting) {
+ const [existingHead] = await tx.select({ id: agentInstructionHeads.revisionId }).from(agentInstructionHeads)
+ .where(and(eq(agentInstructionHeads.companyId, agent.companyId), eq(agentInstructionHeads.agentId, agent.id))).limit(1);
+ if (existingHead) throw unprocessable("Replacing this bundle would remove a versioned entry. Use canonical content commits.", { code: "INSTRUCTION_REVISION_REQUIRED" });
+ }
+ return materializeManagedBundleUnversioned(...args);
+ });
+ }
+
return {
getBundle,
readFile,
diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts
index 70b1d2032cf..ed1719e0280 100644
--- a/server/src/services/authorization.ts
+++ b/server/src/services/authorization.ts
@@ -62,6 +62,7 @@ export type AuthorizationAction =
| PermissionKey
| "agent_config:read"
| "agent_config:update"
+ | "agent_instructions:update"
| "skill_config:update"
| "agent:read"
| "agent:wake"
@@ -148,7 +149,7 @@ function companyIdForResource(resource: AuthorizationResource) {
}
function permissionForAction(action: AuthorizationAction): PermissionKey | null {
- if (action === "agent_config:read" || action === "agent_config:update" || action === "skill_config:update") {
+ if (action === "agent_config:read" || action === "agent_config:update" || action === "agent_instructions:update" || action === "skill_config:update") {
return null;
}
if (
@@ -998,6 +999,7 @@ export function authorizationService(db: Db | DbTransaction) {
input.action === "decision_triage:manage" ||
input.action === "agent_config:read" ||
input.action === "agent_config:update" ||
+ input.action === "agent_instructions:update" ||
input.action === "skill_config:update" ||
input.action === "inbox:manage" ||
input.action === "runtime:manage" ||
@@ -1734,7 +1736,7 @@ export function authorizationService(db: Db | DbTransaction) {
if (input.action === "agent_config:read") {
return decideWithAgentConfigReadGrant("user", input.actor.userId);
}
- if (input.action === "agent_config:update") {
+ if (input.action === "agent_config:update" || input.action === "agent_instructions:update") {
return decideWithProtectedChangeGrants("user", input.actor.userId, {
direct: "agents:configure",
suggest: "agents:suggest-changes",
@@ -2227,6 +2229,19 @@ export function authorizationService(db: Db | DbTransaction) {
return decideWithAgentConfigReadGrant("agent", actorAgentId);
}
+ if (input.action === "agent_instructions:update") {
+ if (!isSimpleAssignableAgentStatus(actorAgent.status) || !input.actor.onBehalfOfUserId) {
+ return deny({ action: input.action, reason: "deny_missing_membership", explanation: "Instruction edits require an active agent and a responsible user." });
+ }
+ // Explicit configure/suggest restrictions still govern content changes.
+ // Only self edits may fall back to the responsible user's target access.
+ const restricted = await decideWithProtectedChangeGrants("agent", actorAgentId, {
+ direct: "agents:configure", suggest: "agents:suggest-changes",
+ });
+ if (restricted.reason !== "deny_no_grant" || input.resource.type !== "agent" || input.resource.agentId !== actorAgentId) return restricted;
+ return allow({ action: input.action, reason: "allow_self", explanation: "Own instruction content edit, subject to the responsible user's target edit access." });
+ }
+
if (input.action === "agent_config:update") {
return decideWithProtectedChangeGrants("agent", actorAgentId, {
direct: "agents:configure",
@@ -2391,7 +2406,7 @@ export function authorizationService(db: Db | DbTransaction) {
responsibleUserId,
}, "responsible-user authorization intersection denied");
- return responsibleUserAuthzShadowMode() ? agentDecision : denied;
+ return input.action !== "agent_instructions:update" && responsibleUserAuthzShadowMode() ? agentDecision : denied;
}
async function decide(input: {
diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts
index 718263668eb..1b5f70308d0 100644
--- a/server/src/services/built-in-agents.ts
+++ b/server/src/services/built-in-agents.ts
@@ -1,3 +1,4 @@
+import fs from "node:fs/promises";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
@@ -5,12 +6,16 @@ import { fileURLToPath } from "node:url";
import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference } from "@paperclipai/adapter-utils/server-utils";
import { and, desc, eq, ne } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
-import { agents, builtInManagedResources, companies, issueThreadInteractions, issues, routines, routineTriggers } from "@paperclipai/db";
+import { activityLog, agents, builtInManagedResources, companies, issueThreadInteractions, issues, routines, routineTriggers } from "@paperclipai/db";
import { syncRoutineVariablesWithTemplate } from "@paperclipai/shared";
import type { Agent, Approval, CompanySkill, PermissionKey, Routine, RoutineTrigger, RoutineVariable } from "@paperclipai/shared";
import { conflict, HttpError, notFound, unprocessable } from "../errors.js";
import { logActivity } from "./activity-log.js";
-import { agentInstructionsService } from "./agent-instructions.js";
+import { adoptAgentFiles, agentFilePath, fileHash, readAgentFile, snapshotAgentFiles } from "./agent-file-store.js";
+import { assertInstructionPathSafe, instructionBytes, materializeInstructionBytes } from "./agent-instruction-files.js";
+import { agentInstructionRevisionService } from "./agent-instruction-revisions.js";
+import type { AuthorizationActor } from "./authorization.js";
+import { agentInstructionsBundleMode, agentInstructionsService } from "./agent-instructions.js";
import { agentService } from "./agents.js";
import { approvalService } from "./approvals.js";
import {
@@ -802,7 +807,7 @@ export function builtInAgentService(db: Db) {
const agentSvc = agentService(db);
const accessSvc = accessService(db);
const approvalSvc = approvalService(db);
- const instructionsSvc = agentInstructionsService();
+ const instructionsSvc = agentInstructionsService(db);
const skillSvc = companySkillService(db);
const routineSvc = routineService(db);
@@ -916,9 +921,9 @@ export function builtInAgentService(db: Db) {
stockVersion: string;
stockHash: string;
defaultsJson: Record;
- }) {
+ }, connection: Db | Parameters[0]>[0] = db) {
const now = new Date();
- return db
+ return connection
.insert(builtInManagedResources)
.values(input)
.onConflictDoUpdate({
@@ -952,10 +957,15 @@ export function builtInAgentService(db: Db) {
return currentFiles;
}
- async function materializeInstructions(agent: Agent, definition: BuiltInAgentDefinition, mode: "reconcile" | "reset") {
+ async function materializeInstructions(agent: Agent, definition: BuiltInAgentDefinition, mode: "reconcile" | "reset", actor?: AuthorizationActor) {
const bundle = definition.bundle!;
const stock = stockHash(bundle.instructions.files);
const binding = await getManagedResourceBinding(agent.companyId, definition.key, "instructions", "AGENTS.md");
+ // Stock operations also read the committed projection after interrupted writes.
+ if (agentInstructionsBundleMode(agent) === "managed") {
+ try { await agentInstructionRevisionService(db).materializeCurrent({ companyId: agent.companyId, agentId: agent.id }); }
+ catch (error) { if (!(error instanceof HttpError && error.status === 404)) throw error; }
+ }
const currentFiles = await currentInstructionFiles(agent, bundle);
const currentHash = Object.values(currentFiles).some((value) => value === null) ? null : stockHash(currentFiles);
const currentState = stockState({
@@ -969,6 +979,100 @@ export function builtInAgentService(db: Db) {
changedFiles: changedFileList(currentFiles, bundle.instructions.files),
});
+ if (!actor && mode === "reconcile" && agentInstructionsBundleMode(agent) === "managed"
+ && binding && (binding.stockHash !== stock || binding.defaultsJson.pendingInstructionsUpdate)) {
+ const bindingWhere = and(eq(builtInManagedResources.companyId, agent.companyId),
+ eq(builtInManagedResources.bundleKey, definition.key), eq(builtInManagedResources.resourceKind, "instructions"),
+ eq(builtInManagedResources.resourceKey, "AGENTS.md"));
+ const incoming = Object.fromEntries(Object.entries(bundle.instructions.files)
+ .map(([file, content]) => [agentFilePath(file), instructionBytes(content)]));
+ // Persist only the pending operation's baseline hashes before touching
+ // files. If a write or DB commit fails, the next reconciliation can accept
+ // already-applied bytes and retry. This is not instruction revision history.
+ const prepared = await db.transaction(async (tx) => {
+ const [current] = await tx.select().from(agents).where(and(eq(agents.companyId, agent.companyId), eq(agents.id, agent.id))).for("update");
+ if (!current) throw notFound("Built-in agent not found");
+ if (agentInstructionsBundleMode(current) !== "managed") return false;
+ const root = await adoptAgentFiles(tx, current);
+ const [latest] = await tx.select().from(builtInManagedResources).where(bindingWhere);
+ if (!latest) return false;
+ const pending = latest.defaultsJson.pendingInstructionsUpdate as {
+ stockHash?: string; baseHashes?: Record; nextHashes?: Record;
+ } | undefined;
+ if (pending?.stockHash === stock) return true;
+ if (!pending && latest.stockHash === stock) return false;
+ const previousPaths = latest.defaultsJson.files;
+ if (!Array.isArray(previousPaths) || !previousPaths.every((file): file is string => typeof file === "string")) return false;
+ const ownedPaths = new Set(previousPaths);
+ if (pending) {
+ // A newer release (or rollback) can supersede an interrupted update.
+ // Recognize only the previous operation's baseline or intended bytes;
+ // never mistake an intervening operator edit for partially applied stock.
+ if (!pending.baseHashes || !pending.nextHashes) return false;
+ for (const [file, baseHash] of Object.entries(pending.baseHashes)) {
+ if (!(file in pending.nextHashes)) return false;
+ const bytes = await readAgentFile(root, agentFilePath(file));
+ const hash = bytes === null ? null : fileHash(bytes);
+ if (hash !== baseHash && hash !== pending.nextHashes[file]) return false;
+ ownedPaths.add(file);
+ }
+ } else {
+ const previousFiles: Record = {};
+ for (const file of previousPaths) {
+ try { previousFiles[file] = (await instructionsSvc.readFile(current, file)).content; }
+ catch { previousFiles[file] = null; }
+ }
+ if (stockHash(previousFiles) !== latest.stockHash) return false;
+ }
+ await snapshotAgentFiles(root);
+ const baseHashes: Record = {};
+ const nextHashes: Record = {};
+ for (const file of new Set([...ownedPaths, ...Object.keys(incoming)])) {
+ const bytes = await readAgentFile(root, agentFilePath(file));
+ // A newly declared stock path must not overwrite a personal file.
+ if (!ownedPaths.has(file) && bytes !== null && !bytes.equals(incoming[file]!)) return false;
+ baseHashes[file] = bytes === null ? null : fileHash(bytes);
+ nextHashes[file] = incoming[file] ? fileHash(incoming[file]) : null;
+ }
+ await tx.update(builtInManagedResources).set({ defaultsJson: { ...latest.defaultsJson,
+ pendingInstructionsUpdate: { stockHash: stock, baseHashes, nextHashes } }, updatedAt: new Date() }).where(bindingWhere);
+ return true;
+ });
+ if (!prepared) return currentState;
+ return db.transaction(async (tx) => {
+ const [current] = await tx.select().from(agents).where(and(eq(agents.companyId, agent.companyId), eq(agents.id, agent.id))).for("update");
+ if (!current) throw notFound("Built-in agent not found");
+ if (agentInstructionsBundleMode(current) !== "managed") return currentState;
+ const root = await adoptAgentFiles(tx, current);
+ const [latest] = await tx.select().from(builtInManagedResources).where(bindingWhere);
+ const pending = latest?.defaultsJson.pendingInstructionsUpdate as { stockHash?: string; baseHashes?: Record } | undefined;
+ if (pending?.stockHash !== stock || !pending.baseHashes) return currentState;
+ await snapshotAgentFiles(root);
+ // Preflight every changed or removed path before mutation; preserve any
+ // operator edit made between preparation, failure, and this retry.
+ for (const [file, baseHash] of Object.entries(pending.baseHashes)) {
+ const bytes = await readAgentFile(root, agentFilePath(file));
+ const hash = bytes === null ? null : fileHash(bytes);
+ const nextHash = incoming[file] ? fileHash(incoming[file]) : null;
+ if (hash !== baseHash && hash !== nextHash) return currentState;
+ }
+ for (const [file, bytes] of Object.entries(incoming)) await materializeInstructionBytes(root, file, bytes);
+ for (const file of Object.keys(pending.baseHashes)) {
+ if (!(file in incoming)) await fs.rm(await assertInstructionPathSafe(root, file), { force: true });
+ }
+ await upsertManagedResourceBinding({ companyId: agent.companyId, bundleKey: definition.key,
+ resourceKind: "instructions", resourceKey: "AGENTS.md", resourceId: agent.id,
+ stockVersion: bundle.stockVersion, stockHash: stock,
+ defaultsJson: { entryFile: bundle.instructions.entryFile, files: Object.keys(bundle.instructions.files) },
+ }, tx);
+ await tx.insert(activityLog).values({ companyId: agent.companyId, actorType: "system", actorId: "built-in-reconcile",
+ action: "agent.files_updated", entityType: "agent", entityId: agent.id,
+ details: { source: "built-in-stock-update", bundleKey: definition.key, stockHash: stock } });
+ return stockState({ resourceKind: "instructions", resourceKey: "AGENTS.md", resourceId: agent.id,
+ stockVersion: bundle.stockVersion, latestStockHash: stock, currentHash: stock, bindingStockHash: stock });
+ });
+ }
+
const shouldWrite =
mode === "reset"
|| currentState.stockStatus === "missing"
@@ -992,13 +1096,36 @@ export function builtInAgentService(db: Db) {
return currentState;
}
- const materialized = await instructionsSvc.materializeManagedBundle(agent, bundle.instructions.files, {
- entryFile: bundle.instructions.entryFile,
- replaceExisting: true,
- clearLegacyPromptTemplate: true,
- });
+ let adapterConfig: Record;
+ if (currentFiles[bundle.instructions.entryFile] !== null && currentFiles[bundle.instructions.entryFile] !== undefined) {
+ if (!actor && mode === "reconcile") return currentState;
+ if (!actor) throw unprocessable("Resetting existing instructions requires an authenticated operator", { code: "INSTRUCTION_IDENTITY_INVALID" });
+ const revisions = agentInstructionRevisionService(db);
+ const target = { companyId: agent.companyId, agentId: agent.id };
+ const baseline = await revisions.readCurrent(target, actor);
+ const receipt = await revisions.commit({ ...target, entryFile: bundle.instructions.entryFile,
+ baseRevisionId: baseline?.revision.id ?? null, content: bundle.instructions.files[bundle.instructions.entryFile] ?? "",
+ source: actor.type === "board" ? "board" : "api" }, actor);
+ if (receipt.materialization === "pending") throw conflict("Instruction revision saved; retry reset to repair its disk copy", { revisionId: receipt.revision.id });
+ const refreshed = await agentSvc.getById(agent.id);
+ if (!refreshed) throw notFound("Built-in agent not found");
+ for (const [file, content] of Object.entries(bundle.instructions.files)) {
+ if (file !== bundle.instructions.entryFile) await instructionsSvc.writeFile(refreshed, file, content);
+ }
+ // Preserve personal files outside this built-in bundle's declared paths.
+ adapterConfig = { ...refreshed.adapterConfig };
+ delete adapterConfig.promptTemplate;
+ delete adapterConfig.bootstrapPromptTemplate;
+ } else {
+ const materialized = await instructionsSvc.materializeManagedBundle(agent, bundle.instructions.files, {
+ entryFile: bundle.instructions.entryFile,
+ replaceExisting: false,
+ clearLegacyPromptTemplate: true,
+ });
+ adapterConfig = materialized.adapterConfig;
+ }
const updated = await agentSvc.update(agent.id, {
- adapterConfig: materialized.adapterConfig,
+ adapterConfig,
}, {
allowBuiltInAgentMetadata: true,
recordRevision: { source: `built-in-bundle:${mode}:instructions` },
@@ -1480,13 +1607,14 @@ export function builtInAgentService(db: Db) {
definition: BuiltInAgentDefinition,
mode: "reconcile" | "reset",
resources?: Array<"instructions" | "skill" | "routine">,
+ actor?: AuthorizationActor,
) {
if (!definition.bundle) return [];
const selected = new Set(resources ?? ["instructions", "skill", "routine"]);
const existingStates = await bundleResourceStates(agent.companyId, definition, agent);
const byKind = new Map(existingStates.map((state) => [state.resourceKind, state]));
const instruction = selected.has("instructions")
- ? await materializeInstructions(agent, definition, mode)
+ ? await materializeInstructions(agent, definition, mode, actor)
: byKind.get("instructions")!;
const refreshedAgent = await agentSvc.getById(agent.id) as Agent | null;
if (!refreshedAgent) throw notFound("Built-in agent not found");
@@ -1847,7 +1975,7 @@ export function builtInAgentService(db: Db) {
return state(definition, updated as Agent);
}
- async function reset(companyId: string, key: string, input: { resources?: Array<"agent" | "instructions" | "skill" | "routine"> } = {}) {
+ async function reset(companyId: string, key: string, input: { resources?: Array<"agent" | "instructions" | "skill" | "routine"> } = {}, actor?: AuthorizationActor) {
const definition = requireBuiltInAgentDefinition(key);
const resetAgentDefaults = !input.resources || input.resources.includes("agent");
const current = resetAgentDefaults
@@ -1862,6 +1990,7 @@ export function builtInAgentService(db: Db) {
definition,
"reset",
input.resources ? selectedBundleResources ?? [] : undefined,
+ actor,
);
return state(definition, await agentSvc.getById(current.agent.id) as Agent, resources);
}
diff --git a/server/src/services/change-consent-gate.ts b/server/src/services/change-consent-gate.ts
index 8523f04bcad..4569a0f8b38 100644
--- a/server/src/services/change-consent-gate.ts
+++ b/server/src/services/change-consent-gate.ts
@@ -112,7 +112,7 @@ function expandTargetKeysForLegacyCompatibility(targetKeys: string[]) {
return [...expanded];
}
-export function changeConsentGateService(db: Db) {
+export function changeConsentGateService(db: Db | Parameters[0]>[0]) {
return {
assertConsented: async (input: {
companyId: string;
diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts
index 7c7501f6316..701894a7610 100644
--- a/server/src/services/company-portability.ts
+++ b/server/src/services/company-portability.ts
@@ -3549,7 +3549,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const companies = companyService(db);
const agents = agentService(db);
const assetRecords = assetService(db);
- const instructions = agentInstructionsService();
+ const instructions = agentInstructionsService(db);
const access = accessService(db);
const projects = projectService(db);
const issues = issueService(db);
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index e923d178d06..60372fa015b 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -1,3 +1,5 @@
+import { isAgentDirectoryCopy } from "./agent-directory-working-copies.js";
+
import type { PaperclipTurnContext } from "@paperclipai/adapter-utils/server-utils";
import { restoreNativeWorkspaceBestEffort } from "./native-runtime/native-workspace-best-effort.js";
import {
@@ -67,6 +69,7 @@ import {
startAdapterExecutionTargetPaperclipBridge,
} from "@paperclipai/adapter-utils/execution-target";
import { agentService } from "./agents.js";
+import { agentInstructionWorkingCopyService, instructionWorkingCopyGuidance } from "./agent-instruction-working-copies.js";
import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils";
import fs from "node:fs/promises";
import path from "node:path";
@@ -3442,6 +3445,15 @@ const heartbeatRunSafeResultJsonColumn = sql | null>`
'error', left(${heartbeatRuns.resultJson} ->> 'error', ${HEARTBEAT_RUN_RESULT_SUMMARY_MAX_CHARS}),
'stdout', left(${heartbeatRuns.resultJson} ->> 'stdout', ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}),
'stderr', left(${heartbeatRuns.resultJson} ->> 'stderr', ${HEARTBEAT_RUN_RESULT_OUTPUT_MAX_CHARS}),
+ 'instructionSave', case when jsonb_typeof(${heartbeatRuns.resultJson} -> 'instructionSave') = 'object'
+ then jsonb_strip_nulls(jsonb_build_object(
+ 'state', left(${heartbeatRuns.resultJson} #>> '{instructionSave,state}', 32),
+ 'contract', left(${heartbeatRuns.resultJson} #>> '{instructionSave,contract}', 32),
+ 'entryFile', left(${heartbeatRuns.resultJson} #>> '{instructionSave,entryFile}', 512),
+ 'errorCode', left(${heartbeatRuns.resultJson} #>> '{instructionSave,errorCode}', 128),
+ 'errorMessage', left(${heartbeatRuns.resultJson} #>> '{instructionSave,errorMessage}', 1024),
+ 'storageWarning', left(${heartbeatRuns.resultJson} #>> '{instructionSave,storageWarning}', 1024)
+ )) end,
'workspaceRestoreFailure', case when ${heartbeatRuns.resultJson} ->> 'workspaceRestoreFailure'
in ('restore_permission_denied', 'restore_lock_timeout', 'restore_unsafe_archive', 'restore_failed')
then ${heartbeatRuns.resultJson} -> 'workspaceRestoreFailure' end,
@@ -9500,6 +9512,7 @@ export function heartbeatService(
environmentRuntimeService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
+ const instructionCopies = agentInstructionWorkingCopyService(db, { environmentRuntime });
const envOrchestrator = environmentRunOrchestrator(db, {
pluginWorkerManager: options.pluginWorkerManager,
environmentRuntime,
@@ -18866,6 +18879,14 @@ export function heartbeatService(
async function reapOrphanedRuns(opts?: { staleThresholdMs?: number }) {
const staleThresholdMs = opts?.staleThresholdMs ?? 0;
const now = new Date();
+ // Recovery never launches a provider or infers stopped ownership from
+ // terminal status. Uncaptured local copies require durable stop evidence.
+ await instructionCopies.recoverStopped().catch(error => {
+ logger.warn({ err: error }, "failed to recover stopped instruction copies");
+ });
+ await instructionCopies.recoverCaptured().catch(error => {
+ logger.warn({ err: error }, "failed to retry captured instruction revisions");
+ });
// Complete persisted native results before generic orphan recovery. The
// reconciler reads the durable workspace barrier and persisted runtime
@@ -22348,6 +22369,29 @@ export function heartbeatService(
await bindIssueToPersistedExecutionWorkspace(persistedExecutionWorkspace);
const workspaceRealization = realizationResult.workspaceRealization;
const executionTarget = realizationResult.executionTarget;
+ let instructionCopy: Awaited> = null;
+ let instructionSave: Record | null = null;
+ const collectStoppedInstructions = async () => {
+ if (!instructionCopy) return;
+ let saved = await instructionCopies.collectStopped({ companyId: agent.companyId, runId: run.id, target: executionTarget });
+ // Capture before disposal. Exhausted bounded collection leaves a durable
+ // explicit loss report, never a claim that missing bytes were saved.
+ while (saved?.state === "pending_collection" && saved.attempts < 3) {
+ saved = await instructionCopies.collectStopped({ companyId: agent.companyId, runId: run.id, target: executionTarget });
+ }
+ if (!saved) return;
+ const receipt = parseObject(saved.receipt);
+ const storageWarning = readNonEmptyString(receipt.storageWarning);
+ instructionSave = { state: saved.state, entryFile: saved.entryFile,
+ ...(isAgentDirectoryCopy(saved) ? { contract: "agent_files", appliedCandidateHash: saved.candidateHash }
+ : { revisionId: parseObject(receipt.revision).id ?? null }), storageWarning, errorCode: saved.errorCode, errorMessage: saved.errorMessage };
+ await appendRunEvent(run, { eventType: "instruction_save", stream: "system",
+ level: !storageWarning && ["saved", "unchanged", "resolved"].includes(saved.state) ? "info" : "warn",
+ message: storageWarning ?? (saved.state === "saved" ? "Agent files saved."
+ : saved.state === "unchanged" ? "Instruction working copy is unchanged."
+ : saved.errorMessage ?? "Instruction edits were not saved. Review the preserved candidate in the agent instruction editor."),
+ payload: instructionSave });
+ };
if (managedAiRuntime && aiBinding) {
try { await assertManagedAiProjectAuth({ ...resolvedConfig, cwd: executionWorkspace.cwd }, aiBinding.provider, executionTarget); }
catch { throw new ConfigurationIncompleteFailure("Project authentication conflicts with this agent’s managed AI connection", { configurationIncomplete: { reason: "ai_connection_incompatible", actionUrl: `/agents/${agent.id}/runtime` } }); }
@@ -23157,6 +23201,62 @@ export function heartbeatService(
target: executionTarget,
workspaceId: persistedExecutionWorkspace?.id ?? null,
});
+ const hasInstructionFilesystem = nativeRuntimeResolution.kind !== "native"
+ ? adapter.supportsInstructionsBundle === true
+ : !["claude_managed_agents_api", "aws_agentcore_harness_api"].includes(nativeRuntimeResolution.profile.backend);
+ if (hasInstructionFilesystem) {
+ try {
+ // Missing contract fields on a restored session mean the deployed
+ // legacy format. New sessions opt into whole-directory persistence.
+ const priorFileRun = taskSessionForRun?.lastRunId
+ ? await db.select({ profile: heartbeatRuns.runnerProfileJson }).from(heartbeatRuns).where(and(
+ eq(heartbeatRuns.id, taskSessionForRun.lastRunId), eq(heartbeatRuns.companyId, agent.companyId), eq(heartbeatRuns.agentId, agent.id))).then(rows => rows[0])
+ : null;
+ const savedFileInput = parseObject(parseObject(run.runnerProfileJson).nativeExecutionInput);
+ const priorFileInput = Object.keys(savedFileInput).length ? savedFileInput : parseObject(parseObject(priorFileRun?.profile).nativeExecutionInput);
+ const priorWorkingCopy = parseObject(parseObject(parseObject(priorFileInput.runtimeContext).instructions).workingCopy);
+ instructionCopy = await instructionCopies.prepare({
+ companyId: agent.companyId, agentId: agent.id, runId: run.id,
+ target: executionTarget, cwd: executionWorkspace.cwd,
+ legacy: Object.keys(priorFileInput).length > 0 && priorWorkingCopy.kind !== "agent_files",
+ });
+ } catch (error) {
+ if ((error as { status?: number }).status !== 403) throw error;
+ // Missing write identity must not break a background run's read-only
+ // prompt. It must also never imply that ordinary file edits will save.
+ await appendRunEvent(run, { eventType: "instruction_save", stream: "system", level: "warn",
+ message: "Persistent instruction editing is unavailable. Use an authenticated user with instruction edit access and a managed instruction bundle.",
+ payload: { state: "unavailable", code: "INSTRUCTION_COPY_UNAVAILABLE" } });
+ const guidance = "No editable agent instruction working copy is registered for this turn. Use authenticated agent file tools for persistent edits; do not edit a private copy named in an earlier turn or claim its changes will persist.";
+ for (const key of ["paperclipTaskMarkdown", "paperclipTaskMarkdownCompact"]) {
+ context[key] = [readNonEmptyString(context[key]), guidance].filter(Boolean).join("\n\n");
+ }
+ }
+ if (instructionCopy) {
+ const storageWarning = readNonEmptyString(instructionCopy.receipt?.storageWarning);
+ if (storageWarning) {
+ instructionSave = { state: "prepared", contract: "agent_files", storageWarning };
+ // This is an advisory on the run, never an agent pause, execution
+ // failure, or scheduling gate. Keep it visible while work runs.
+ await db.update(heartbeatRuns).set({ resultJson: sql`coalesce(${heartbeatRuns.resultJson}, '{}'::jsonb) || ${JSON.stringify({ instructionSave })}::jsonb` }).where(eq(heartbeatRuns.id, run.id));
+ await appendRunEvent(run, { eventType: "instruction_save", stream: "system", level: "warn",
+ message: storageWarning, payload: instructionSave });
+ }
+ runtimeConfig = { ...runtimeConfig, instructionsFilePath: path.join(instructionCopy.localRoot, instructionCopy.entryFile) };
+ if (isAgentDirectoryCopy(instructionCopy)) {
+ const workspace = parseObject(context.paperclipWorkspace);
+ context.paperclipWorkspace = { ...workspace, agentHome: instructionCopy.executionRoot,
+ // Keep the pre-existing permission root stable for ACP session
+ // identity. The per-run copy is already under the company root.
+ agentHomeForPermissions: workspace.agentHome,
+ };
+ }
+ const guidance = instructionWorkingCopyGuidance(instructionCopy);
+ for (const key of ["paperclipTaskMarkdown", "paperclipTaskMarkdownCompact"]) {
+ context[key] = [readNonEmptyString(context[key]), guidance].filter(Boolean).join("\n\n");
+ }
+ }
+ }
let nativeExecution: NativeExecutionInput | null = null;
let nativeRunnerInstanceId: string | null = null;
if (nativeRuntimeResolution.kind === "native") {
@@ -23586,6 +23686,7 @@ export function heartbeatService(
runId: run.id,
runtimeConfig,
runtimeSkillEntries,
+ instructionWorkingCopy: instructionCopy ? { rootPath: instructionCopy.executionRoot, entryPath: instructionCopy.entryFile, ...(isAgentDirectoryCopy(instructionCopy) ? { kind: "agent_files" as const } : {}) } : undefined,
});
const nativeExecutionWithCheckpoint =
buildNativeExecutionWithCheckpoint({
@@ -24293,6 +24394,10 @@ export function heartbeatService(
},
onLog,
onEvent: onAdapterEvent,
+ instructionWorkingCopy: instructionCopy ? {
+ hasChanges: () => instructionCopies.hasChanges({ companyId: agent.companyId, runId: run.id, target: executionTarget }),
+ collectStopped: collectStoppedInstructions,
+ } : undefined,
preparationSpans: nativeRunnerPreparationSpans,
// Bootstrap with executable/home discovery while keeping
// configured provider values and the server-selected
@@ -24306,6 +24411,7 @@ export function heartbeatService(
process.env,
executionWorkspace.cwd,
),
+ ...(instructionCopy && isAgentDirectoryCopy(instructionCopy) ? { AGENT_HOME: instructionCopy.executionRoot } : {}),
...(nativeMcpServer
? {
PAPERCLIP_NATIVE_MCP_NAME: nativeMcpServer.name,
@@ -24486,6 +24592,7 @@ export function heartbeatService(
issueId,
);
},
+ onProviderStopped: collectStoppedInstructions,
onDispatch: markDispatchStarted,
signal: executionControl.controller.signal,
...(executionTarget?.kind === "remote" && executionTarget.transport === "sandbox" ? {
@@ -24534,6 +24641,7 @@ export function heartbeatService(
if (!guardedDispatch.dispatched) return;
adapterResult = await guardedDispatch.resultPromise;
}
+ if (instructionSave) adapterResult.resultJson = { ...adapterResult.resultJson, instructionSave };
adapterResult = applyWorkspaceRestoreFailure(adapterResult);
// A returned result can include a failed restore. Keep the workspace
// barrier closed until required files have been restored.
@@ -24769,6 +24877,7 @@ export function heartbeatService(
"failed to revoke heartbeat-run MCP gateway tokens",
);
}
+ await instructionCopies.release(agent.companyId, run.id);
}
// Reconcile the referenced-project set against the real remote staging outcome. A referenced
// project can pass authorization and clone locally at run prep, then fail to stage into the
@@ -26160,6 +26269,15 @@ export function heartbeatService(
},
);
}
+ // A retained or unverified process stays above this release boundary.
+ // If no stopped-copy capture occurred, preserve an explicit loss report.
+ const uncapturedInstructions = await instructionCopies.reportUnavailable(run.companyId, run.id);
+ if (uncapturedInstructions?.state === "unavailable") {
+ await appendRunEvent(run, { eventType: "instruction_save", stream: "system", level: "warn",
+ message: "Instruction edits could not be recovered before environment release. No instruction save is claimed.",
+ payload: { state: "unavailable", code: uncapturedInstructions.errorCode } });
+ }
+ await instructionCopies.release(run.companyId, run.id);
await releaseEnvironmentLeasesForRun({
runId: run.id,
companyId: run.companyId,
diff --git a/server/src/services/native-runtime/agent-instruction-tools.integration.test.ts b/server/src/services/native-runtime/agent-instruction-tools.integration.test.ts
new file mode 100644
index 00000000000..fe79d00aa8e
--- /dev/null
+++ b/server/src/services/native-runtime/agent-instruction-tools.integration.test.ts
@@ -0,0 +1,89 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { randomUUID } from "node:crypto";
+import { eq } from "drizzle-orm";
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { agents, authUsers, companies, companyMemberships, principalPermissionGrants, heartbeatRuns, issues, agentInstructionRevisions, createDb } from "@paperclipai/db";
+import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
+import { resolveManagedInstructionsRoot } from "../agent-instructions.js";
+import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
+
+describe("canonical instruction tools through native authority", () => {
+ let database: Awaited>;
+ let db: ReturnType;
+ let home: string;
+ const previousHome = process.env.PAPERCLIP_HOME;
+ let companyId: string, agentId: string, targetAgentId: string, userId: string, runId: string, issueId: string, root: string;
+ let authority: PaperclipRunnerToolAuthority;
+ const entryFile = "policy/ENTRY.md";
+ const original = "\uFEFF# Original\r\n\0☃\n";
+ beforeAll(async () => {
+ home = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "instruction-tool-home-")));
+ process.env.PAPERCLIP_HOME = home;
+ database = await startEmbeddedPostgresTestDatabase("instruction-tool-db-");
+ db = createDb(database.connectionString);
+ }, 90_000);
+ afterAll(async () => {
+ if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
+ await database?.cleanup();
+ if (home) await fs.rm(home, { recursive: true, force: true });
+ });
+ beforeEach(async () => {
+ [companyId, agentId, targetAgentId, userId, runId, issueId] = Array.from({ length: 6 }, () => randomUUID());
+ await db.insert(companies).values({ id: companyId, name: "Tool scope", issuePrefix: randomUUID().slice(0, 8) });
+ await db.insert(authUsers).values({ id: userId, name: "Editor", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
+ root = resolveManagedInstructionsRoot({ id: targetAgentId, companyId, name: "Target", adapterConfig: {} });
+ await db.insert(agents).values([
+ { id: agentId, companyId, name: "Caller", status: "active" },
+ { id: targetAgentId, companyId, name: "Target", adapterConfig: { instructionsBundleMode: "managed", instructionsRootPath: root, instructionsEntryFile: entryFile } },
+ ]);
+ await db.insert(companyMemberships).values([
+ { companyId, principalType: "user", principalId: userId, membershipRole: "operator" },
+ { companyId, principalType: "agent", principalId: agentId, membershipRole: "member" },
+ ]);
+ await db.insert(principalPermissionGrants).values([
+ { companyId, principalType: "user", principalId: userId, permissionKey: "agents:configure", scope: { agentIds: [targetAgentId] } },
+ { companyId, principalType: "agent", principalId: agentId, permissionKey: "agents:configure", scope: { agentIds: [targetAgentId] } },
+ ]);
+ await db.insert(issues).values({ id: issueId, companyId, title: "Maintain instructions", status: "in_progress", workMode: "standard", assigneeAgentId: agentId });
+ await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: issueId, invocationSource: "on_demand", responsibleUserId: userId });
+ await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
+ authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
+ await fs.mkdir(path.dirname(path.join(root, entryFile)), { recursive: true });
+ await fs.writeFile(path.join(root, entryFile), original);
+ });
+ const call = (tool: string, args: Record = {}) => authority.execute({ tool, callId: randomUUID(), arguments: { targetAgentId, ...args } }) as Promise;
+ it("bridges old native instruction tools to current files without writing history", async () => {
+ const first = await call("read_agent_instructions");
+ expect(first).toMatchObject({ entryFile, content: original });
+ const changed = await call("update_agent_instructions", { entryFile, content: "changed\r\n", baseRevisionId: first.revision.id });
+ expect(changed.revision).toMatchObject({ responsibleUserId: userId, actorAgentId: agentId, sourceRunId: runId });
+ expect((await call("get_agent_instruction_history", { entryFile })).revisions).toHaveLength(0);
+ expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe("changed\r\n");
+ await expect(call("update_agent_instructions", { entryFile, content: "stale", baseRevisionId: first.revision.id })).rejects.toMatchObject({ status: 409 });
+ expect((await call("read_agent_instructions")).content).toBe("changed\r\n");
+ });
+ it("rejects supplied identity and missing CAS bases before changing content", async () => {
+ const first = await call("read_agent_instructions");
+ for (const identity of ["companyId", "agentId", "runId", "responsibleUserId", "onBehalfOfUserId", "actor"]) {
+ await expect(call("update_agent_instructions", { entryFile, content: "forged", baseRevisionId: first.revision.id, [identity]: randomUUID() })).rejects.toMatchObject({ status: 400 });
+ }
+ await expect(call("update_agent_instructions", { entryFile, content: "missing base" })).rejects.toMatchObject({ status: 400 });
+ expect((await call("read_agent_instructions")).content).toBe(original);
+ expect(await db.select().from(agentInstructionRevisions).where(eq(agentInstructionRevisions.agentId, targetAgentId))).toHaveLength(0);
+ });
+ it("rechecks responsible-user access and rejects a cross-company target", async () => {
+ const first = await call("read_agent_instructions");
+ await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.principalId, userId));
+ await expect(call("update_agent_instructions", { entryFile, content: "revoked", baseRevisionId: first.revision.id })).rejects.toMatchObject({ status: 403 });
+ const foreignCompanyId = randomUUID(), foreignAgentId = randomUUID();
+ await db.insert(companies).values({ id: foreignCompanyId, name: "Foreign", issuePrefix: randomUUID().slice(0, 8) });
+ await db.insert(agents).values({ id: foreignAgentId, companyId: foreignCompanyId, name: "Foreign" });
+ await expect(call("read_agent_instructions", { targetAgentId: foreignAgentId })).rejects.toMatchObject({ status: 404 });
+ await expect(call("update_agent_instructions", { targetAgentId: foreignAgentId, entryFile, content: "foreign", baseRevisionId: null })).rejects.toMatchObject({ status: 404 });
+ await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, runId));
+ await expect(call("read_agent_instructions")).rejects.toThrow("paperclip_runner_tool_binding_not_authorized");
+ expect(await fs.readFile(path.join(root, entryFile), "utf8")).toBe(original);
+ });
+});
diff --git a/server/src/services/native-runtime/agent-instruction-tools.test.ts b/server/src/services/native-runtime/agent-instruction-tools.test.ts
new file mode 100644
index 00000000000..619d05a9f6b
--- /dev/null
+++ b/server/src/services/native-runtime/agent-instruction-tools.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import type { Db } from "@paperclipai/db";
+import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
+
+const binding = { companyId: "00000000-0000-4000-8000-000000000001", agentId: "00000000-0000-4000-8000-000000000002", issueId: "00000000-0000-4000-8000-000000000003", runId: "00000000-0000-4000-8000-000000000004" };
+
+describe("dedicated canonical instruction tools", () => {
+ it("advertises read, CAS update, history, and restore on ordinary native runs", () => {
+ const authority = new PaperclipRunnerToolAuthority({} as Db, binding);
+ const tools = authority.definitions();
+ expect(tools.map((tool) => tool.name)).toEqual(expect.arrayContaining([
+ "read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions",
+ ]));
+ for (const name of ["update_agent_instructions", "restore_agent_instructions"]) {
+ const tool = tools.find((candidate) => candidate.name === name)!;
+ expect(tool.inputSchema).toMatchObject({ required: expect.arrayContaining(["baseRevisionId"]), additionalProperties: false });
+ expect(tool.inputSchema).not.toHaveProperty("properties.responsibleUserId");
+ }
+ });
+});
diff --git a/server/src/services/native-runtime/agent-instruction-tools.ts b/server/src/services/native-runtime/agent-instruction-tools.ts
new file mode 100644
index 00000000000..032e66699fa
--- /dev/null
+++ b/server/src/services/native-runtime/agent-instruction-tools.ts
@@ -0,0 +1,76 @@
+import type { AgentInstructionCommitReceipt } from "@paperclipai/shared";
+import { z } from "zod";
+import type { Db } from "@paperclipai/db";
+import { badRequest, notFound } from "../../errors.js";
+import { agentInstructionRevisionService } from "../agent-instruction-revisions.js";
+import { agentInstructionWorkingCopyService } from "../agent-instruction-working-copies.js";
+import { deriveBundleState } from "../agent-instructions.js";
+import { agentService } from "../agents.js";
+import type { AuthorizationActor } from "../authorization.js";
+
+const target = { targetAgentId: z.string().uuid().optional() };
+const entryFile = z.string().min(1).max(4096);
+const baseRevisionId = z.string().uuid().nullable();
+const schemas = {
+ read_agent_instructions: z.object({ ...target, entryFile: entryFile.optional(), revisionId: z.string().uuid().optional() }).strict()
+ .refine((input) => Boolean(input.entryFile) === Boolean(input.revisionId), "Historical reads require both entryFile and revisionId"),
+ update_agent_instructions: z.object({ ...target, entryFile, content: z.string().max(1024 * 1024), baseRevisionId }).strict(),
+ get_agent_instruction_history: z.object({ ...target, entryFile, cursor: z.string().min(1).max(2048).optional(), limit: z.number().int().min(1).max(100).optional() }).strict(),
+ restore_agent_instructions: z.object({ ...target, entryFile, revisionId: z.string().uuid(), baseRevisionId: z.string().uuid() }).strict(),
+};
+
+export type AgentInstructionToolName = keyof typeof schemas;
+
+/** Called only after the native authority validates its server-bound active run. */
+export async function executeAgentInstructionTool(input: {
+ db: Db;
+ binding: { companyId: string; agentId: string; runId: string };
+ tool: AgentInstructionToolName;
+ arguments: unknown;
+}) {
+ // Never accept an actor, responsible user, company, or source run from tool JSON.
+ const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId: input.binding.companyId, agentId: input.binding.agentId, runId: input.binding.runId };
+ const service = agentInstructionRevisionService(input.db);
+ const parse = (schema: z.ZodType): T => {
+ const result = schema.safeParse(input.arguments);
+ if (!result.success) throw badRequest("Invalid instruction tool arguments", result.error.flatten());
+ return result.data;
+ };
+ const scope = (targetAgentId?: string) => ({ companyId: input.binding.companyId, agentId: targetAgentId ?? input.binding.agentId });
+ const acknowledge = async (receipt: AgentInstructionCommitReceipt, targetAgentId?: string) => {
+ // The commit is already durable. A failed baseline refresh keeps the old CAS
+ // fence, so later cleanup preserves a conflict instead of overwriting it.
+ await agentInstructionWorkingCopyService(input.db).acknowledgeExplicitSave({
+ ...scope(targetAgentId), runId: input.binding.runId, entryFile: receipt.revision.entryFile,
+ revisionId: receipt.revision.id, contentHash: receipt.revision.contentHash,
+ }).catch(() => undefined);
+ return receipt;
+ };
+ switch (input.tool) {
+ case "read_agent_instructions": {
+ const args = parse(schemas.read_agent_instructions);
+ const target = scope(args.targetAgentId);
+ if (args.entryFile && args.revisionId) return service.readRevision({ ...target, entryFile: args.entryFile, revisionId: args.revisionId }, actor);
+ const snapshot = await service.readCurrent(target, actor);
+ if (snapshot) return { ...snapshot, entryFile: snapshot.revision.entryFile };
+ // An unseeded empty entry still needs its configured filename for the first CAS.
+ const agent = await agentService(input.db).getById(target.agentId);
+ if (!agent || agent.companyId !== target.companyId) throw notFound("Agent not found");
+ return { entryFile: deriveBundleState(agent).entryFile, revision: null, content: null };
+ }
+ case "update_agent_instructions": {
+ const args = parse(schemas.update_agent_instructions);
+ const receipt = await service.commit({ ...scope(args.targetAgentId), entryFile: args.entryFile, content: args.content, baseRevisionId: args.baseRevisionId, source: "tool" }, actor);
+ return acknowledge(receipt, args.targetAgentId);
+ }
+ case "get_agent_instruction_history": {
+ const args = parse(schemas.get_agent_instruction_history);
+ return service.history({ ...scope(args.targetAgentId), entryFile: args.entryFile, cursor: args.cursor, limit: args.limit }, actor);
+ }
+ case "restore_agent_instructions": {
+ const args = parse(schemas.restore_agent_instructions);
+ const receipt = await service.restore({ ...scope(args.targetAgentId), entryFile: args.entryFile, revisionId: args.revisionId, baseRevisionId: args.baseRevisionId }, actor);
+ return acknowledge(receipt, args.targetAgentId);
+ }
+ }
+}
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 b6d73169110..30ae4895d85 100644
--- a/server/src/services/native-runtime/native-session-executor.test.ts
+++ b/server/src/services/native-runtime/native-session-executor.test.ts
@@ -5870,6 +5870,57 @@ describe("native session same-turn steering", () => {
});
describe("native warm session supervision", () => {
+ it.each([
+ { changed: false, closeFails: false },
+ { changed: true, closeFails: false },
+ { changed: true, closeFails: true },
+ ])("collects changed instructions only after the owned warm provider stops (changed=$changed, close fails=$closeFails)", async ({ changed, closeFails }) => {
+ const identity = `instruction-close-${changed}-${closeFails}`;
+ let releaseClose!: () => void;
+ const closed = new Promise((resolve) => { releaseClose = resolve; });
+ const close = vi.fn(async () => { await closed; if (closeFails) throw new Error("instruction provider close failed"); });
+ const collectStopped = vi.fn(async () => {});
+ const hasChanges = vi.fn(async () => changed);
+ const warmExecution = { ...execution,
+ binding: { ...execution.binding, runId: identity, executionWorkspaceId: identity },
+ session: { ...execution.session, normalizedSessionId: identity, lifecyclePolicy: { mode: "warm" as const, idleTimeoutMs: 60_000 } },
+ } as NativeExecutionInputV1;
+ state.execute.mockReset().mockImplementationOnce(async (options) => {
+ expect(options.requireSessionCloseBeforeReturn).toBe(true);
+ expect(options.onSessionClosed).toBe(collectStopped);
+ await options.onSession?.({ close });
+ return { result: { summary: "completed" }, terminal: { runTerminalState: "succeeded" },
+ turnId: identity, normalizedSessionId: identity, providerSessionId: identity,
+ driverKind: "test", driverVersion: "1", nativeEventCount: 1, highestContiguousSourceSeq: 1, usage: null };
+ });
+ const running = executePaperclipNativeSession({ db: leaseDb(warmExecution), execution: warmExecution,
+ runnerInstanceId: identity, runnerExecutionTarget: { kind: "remote", transport: "sandbox", environmentId: identity, remoteCwd: `/tmp/${identity}` },
+ instructionWorkingCopy: { hasChanges, collectStopped } });
+ const observed = running.then(() => null, error => error);
+ try {
+ if (changed) {
+ await vi.waitFor(() => expect(close).toHaveBeenCalledOnce());
+ expect(collectStopped).not.toHaveBeenCalled();
+ releaseClose();
+ const error = await observed;
+ if (closeFails) {
+ expect(error?.message).toBe("instruction provider close failed");
+ expect(collectStopped).not.toHaveBeenCalled();
+ } else {
+ expect(error).toBeNull();
+ expect(collectStopped).toHaveBeenCalledOnce();
+ }
+ } else {
+ expect(await observed).toBeNull();
+ expect(close).not.toHaveBeenCalled();
+ expect(collectStopped).not.toHaveBeenCalled();
+ }
+ } finally {
+ releaseClose();
+ await closeWarmNativeSessionsForEnvironment({ environmentId: identity, reason: "test cleanup" });
+ }
+ });
+
describe("warm session identity transitions", () => {
let previousHome: string | undefined;
let isolatedHome: string;
diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts
index 1e75231a0b5..2da691b5e1d 100644
--- a/server/src/services/native-runtime/native-session-executor.ts
+++ b/server/src/services/native-runtime/native-session-executor.ts
@@ -7167,6 +7167,12 @@ export async function executePaperclipNativeSession(input: {
chatAttachmentReadScope?: NativeChatAttachmentReadScope;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise;
onEvent?: (event: AdapterRuntimeEvent) => Promise;
+ /** Only this run's registered private instruction entry.
+ * Probe at terminal; persist only after owned shutdown. */
+ instructionWorkingCopy?: {
+ hasChanges: () => Promise;
+ collectStopped: () => Promise;
+ };
/** Persist task-level continuity before a durable goal can outlive this run. */
onGoalCheckpoint?: (snapshot: PersistedNativeSession) => Promise;
sessionGoalControl?: NativeSessionGoalControl | null;
@@ -8305,7 +8311,8 @@ async function executePaperclipNativeSessionWithinScope(
resumeSessionGoalHeartbeat: input.resumeSessionGoalHeartbeat,
// Every durable runner must finish its bounded suspension before
// the next run verifies and rotates the saved authority.
- requireSessionCloseBeforeReturn: runnerdBackend !== null,
+ requireSessionCloseBeforeReturn: runnerdBackend !== null || input.instructionWorkingCopy !== undefined,
+ onSessionClosed: input.instructionWorkingCopy?.collectStopped,
onCheckpoint: async (snapshot) => {
if (warmSessionId !== null && warmConfigDigest !== null) {
await persistWarmNativeCheckpoint(
@@ -9065,12 +9072,22 @@ async function executePaperclipNativeSessionWithinScope(
// A following run cannot attach until the prior run's durable finalization
// is committed. Provider completion alone is not an authority boundary.
if (warmSessionId !== null && lifecyclePolicy.mode === "warm") {
+ const instructionCopy = input.instructionWorkingCopy;
+ const ownedSession = warmNativeSessions.get(warmSessionId);
+ const collectInstructions = Boolean(instructionCopy && ownedSession?.ownerToken === warmSessionOwnerToken &&
+ await instructionCopy.hasChanges());
+ if (collectInstructions && ownedSession) {
+ // Keep the unchanged warm path intact. A changed private instruction copy
+ // requires the existing checkpoint-and-close boundary before collection.
+ ownedSession.closeOnReleaseReason = "registered instruction edits require stopped-provider collection";
+ }
await releaseWarmNativeSession(
warmSessionId,
warmSessionOwnerToken,
lifecyclePolicy.idleTimeoutMs,
false,
);
+ if (collectInstructions) await instructionCopy!.collectStopped();
}
const adapterResult: AdapterExecutionResult = {
exitCode: native.terminal.runTerminalState === "succeeded" ? 0 : 1,
diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts
index 5b1c58b5ad6..6b5e6f0d33f 100644
--- a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts
+++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts
@@ -99,7 +99,7 @@ describe("PaperclipRunnerToolAuthority", () => {
issueId,
runId,
});
- expect(authority.definitions()).toHaveLength(30);
+ expect(authority.definitions()).toHaveLength(34);
const questions = authority.definitions().find(tool => tool.name === "request_human_input")!;
expect(questions.description).toContain("ask only the next unanswered question");
expect(questions.description).toContain("Never infer answers");
diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts
index 1b46dfe55d4..27fd2f45e65 100644
--- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts
+++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts
@@ -1,3 +1,4 @@
+import { executeAgentInstructionTool } from "./agent-instruction-tools.js";
import { createReadStream } from "node:fs";
import { publicChatTaskUrl } from "../chat-task-url.js";
import type { createAssignedMcpTools } from "./assigned-mcp-tools.js";
@@ -83,6 +84,7 @@ import {
} from "./chat-attachment-read.js";
const IMPLEMENTED_OPERATIONS = new Set([
+ "read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions",
"search_api", "call_api", "hire_agent",
"get_task_context", "get_task_history", "search_tasks", "report_progress",
"request_human_input",
@@ -368,6 +370,13 @@ export class PaperclipRunnerToolAuthority {
throw new Error("paperclip_runner_tool_mode_denied");
}
switch (call.tool) {
+ case "read_agent_instructions":
+ case "update_agent_instructions":
+ case "get_agent_instruction_history":
+ case "restore_agent_instructions":
+ return executeAgentInstructionTool({ db: this.db, binding: {
+ companyId: this.binding.companyId, agentId: this.binding.agentId, runId: this.binding.runId,
+ }, tool: call.tool, arguments: call.arguments });
case "create_skill": {
const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL;
const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId);
diff --git a/server/src/services/native-runtime/runner-api-catalog.ts b/server/src/services/native-runtime/runner-api-catalog.ts
index d8f8fd7d79f..85da534726f 100644
--- a/server/src/services/native-runtime/runner-api-catalog.ts
+++ b/server/src/services/native-runtime/runner-api-catalog.ts
@@ -42,6 +42,10 @@ function words(text: string): string[] {
}
function dedicatedTools(method: string, path: string): string[] {
+ if (/\/agents\/\{[^}]+\}\/instructions-bundle\/file$/.test(path)) return method === "GET" ? ["read_agent_instructions"] : method === "PUT" ? ["update_agent_instructions"] : [];
+ if (/\/agents\/\{[^}]+\}\/instructions-bundle\/history$/.test(path) && method === "GET") return ["get_agent_instruction_history"];
+ if (/\/agents\/\{[^}]+\}\/instructions-bundle\/revision\/\{[^}]+\}$/.test(path) && method === "GET") return ["read_agent_instructions"];
+ if (/\/agents\/\{[^}]+\}\/instructions-bundle\/restore$/.test(path) && method === "POST") return ["restore_agent_instructions"];
if (/\/companies\/\{[^}]+\}\/skills$/.test(path) && method === "POST") return ["create_skill"];
if (/\/companies\/\{[^}]+\}\/agent-hires$/.test(path) && method === "POST") return ["hire_agent"];
if (/\/projects$/.test(path)) return method === "GET" ? ["list_projects"] : method === "POST" ? ["create_project"] : [];
diff --git a/server/src/services/native-runtime/runner-api.test.ts b/server/src/services/native-runtime/runner-api.test.ts
index 5cc31752644..0746456f259 100644
--- a/server/src/services/native-runtime/runner-api.test.ts
+++ b/server/src/services/native-runtime/runner-api.test.ts
@@ -72,6 +72,10 @@ describe("runner API catalog", () => {
expect(runnerApiOperation(createProject).dedicatedTools).toEqual(["create_project"]);
expect(runnerApiOperation(projects).dedicatedTools).toEqual(["list_projects"]);
expect(runnerApiOperation("GET /api/companies/{companyId}/project-repositories").dedicatedTools).toEqual(["list_project_repositories"]);
+ expect(runnerApiOperation("GET /api/agents/{id}/instructions-bundle/file").dedicatedTools).toEqual(["read_agent_instructions"]);
+ expect(runnerApiOperation("PUT /api/agents/{id}/instructions-bundle/file").dedicatedTools).toEqual(["update_agent_instructions"]);
+ expect(runnerApiOperation("GET /api/agents/{id}/instructions-bundle/history").dedicatedTools).toEqual(["get_agent_instruction_history"]);
+ expect(runnerApiOperation("POST /api/agents/{id}/instructions-bundle/restore").dedicatedTools).toEqual(["restore_agent_instructions"]);
expect(runnerApiOperation("POST /api/companies/{companyId}/agent-hires").dedicatedTools).toEqual(["hire_agent"]);
expect(runnerApiOperation("POST /api/companies/{companyId}/agent-hires").dedicatedToolGuidance).toContain("inherits the caller's native runtime");
});
diff --git a/server/src/services/native-runtime/runtime-context.test.ts b/server/src/services/native-runtime/runtime-context.test.ts
index a2903c177ac..f8996f1f7f6 100644
--- a/server/src/services/native-runtime/runtime-context.test.ts
+++ b/server/src/services/native-runtime/runtime-context.test.ts
@@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const serviceMocks = vi.hoisted(() => ({
exportFiles: vi.fn(),
+ readCommittedForRuntime: vi.fn(),
getEffectiveProfilesForAgent: vi.fn(),
githubBotConnectionIdsForRun: vi.fn(),
}));
@@ -20,6 +21,11 @@ vi.mock("../chat-github-tools.js", () => ({
vi.mock("../agent-instructions.js", () => ({
agentInstructionsService: () => ({ exportFiles: serviceMocks.exportFiles }),
+ agentInstructionsBundleMode: (agent: { adapterConfig?: { instructionsBundleMode?: string } }) => agent.adapterConfig?.instructionsBundleMode ?? null,
+}));
+
+vi.mock("../agent-instruction-revisions.js", () => ({
+ agentInstructionRevisionService: () => ({ readCommittedForRuntime: serviceMocks.readCommittedForRuntime }),
}));
vi.mock("../tool-access.js", () => ({
@@ -50,6 +56,7 @@ async function makeTreeWritable(target: string): Promise {
beforeEach(async () => {
vi.clearAllMocks();
+ serviceMocks.readCommittedForRuntime.mockResolvedValue(null);
serviceMocks.githubBotConnectionIdsForRun.mockResolvedValue(new Set());
previousPaperclipHome = process.env.PAPERCLIP_HOME;
previousInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
@@ -90,6 +97,60 @@ afterEach(async () => {
});
describe("buildNativeRuntimeContext", () => {
+ it("uses committed instructions when the disk projection is stale", async () => {
+ serviceMocks.exportFiles.mockResolvedValue({
+ entryFile: "AGENTS.md",
+ files: { "AGENTS.md": "Old disk projection\n", "reference.md": "Sibling reference\n" },
+ });
+ serviceMocks.readCommittedForRuntime.mockResolvedValue({
+ revision: { entryFile: "AGENTS.md" }, content: "Saved canonical instructions\n",
+ });
+ const context = await buildNativeRuntimeContext({
+ db: {} as Db,
+ agent: { id: "agent-1", companyId: "company-1", name: "Reviewer", adapterType: "paperclip_runner", adapterConfig: { instructionsBundleMode: "managed" } },
+ runId: "run-1", runtimeConfig: {}, runtimeSkillEntries: [],
+ });
+ expect(await readFile(path.join(context.instructions.bundle.rootPath, context.instructions.entryPath), "utf8"))
+ .toBe("Saved canonical instructions\n");
+ expect(await readFile(path.join(context.instructions.bundle.rootPath, "reference.md"), "utf8"))
+ .toBe("Sibling reference\n");
+ expect(serviceMocks.readCommittedForRuntime).toHaveBeenCalledWith({ companyId: "company-1", agentId: "agent-1" });
+ });
+
+ it("does not substitute canonical managed instructions into an external bundle", async () => {
+ serviceMocks.exportFiles.mockResolvedValue({ entryFile: "AGENTS.md", files: { "AGENTS.md": "External instructions\n" } });
+ const context = await buildNativeRuntimeContext({
+ db: {} as Db,
+ agent: { id: "agent-1", companyId: "company-1", name: "Reviewer", adapterConfig: { instructionsBundleMode: "external" } },
+ runId: "run-1", runtimeConfig: {}, runtimeSkillEntries: [],
+ });
+ expect(await readFile(path.join(context.instructions.bundle.rootPath, context.instructions.entryPath), "utf8"))
+ .toBe("External instructions\n");
+ expect(serviceMocks.readCommittedForRuntime).not.toHaveBeenCalled();
+ });
+
+ it("keeps the pinned prompt immutable while exposing the registered editable instruction copy", async () => {
+ serviceMocks.exportFiles.mockResolvedValue({
+ entryFile: "instructions/CHARter.md",
+ files: { "instructions/CHARter.md": "Original instructions.\n" },
+ });
+ const workingRoot = path.join(temporaryRoots.at(-1)!, "private-run-copy");
+ await mkdir(path.join(workingRoot, "instructions"), { recursive: true });
+ await writeFile(path.join(workingRoot, "instructions/CHARter.md"), "Original instructions.\n");
+ const context = await buildNativeRuntimeContext({
+ db: {} as Db,
+ agent: { id: "agent-1", companyId: "company-1", name: "Reviewer", adapterType: "paperclip_runner", adapterConfig: {} },
+ runId: "run-1",
+ runtimeConfig: {},
+ runtimeSkillEntries: [],
+ instructionWorkingCopy: { rootPath: workingRoot, entryPath: "instructions/CHARter.md" },
+ });
+ expect(context.instructions.workingCopy).toEqual({ rootPath: workingRoot, entryPath: "instructions/CHARter.md" });
+ await writeFile(path.join(workingRoot, "instructions/CHARter.md"), "Persist this next time.\n");
+ expect(await readFile(path.join(context.instructions.bundle.rootPath, context.instructions.entryPath), "utf8"))
+ .toBe("Original instructions.\n");
+ });
+
it.each(["disabled", "degraded"] as const)(
"omits an unavailable native MCP connection when it is %s without aborting runtime context creation",
async (unavailableState) => {
diff --git a/server/src/services/native-runtime/runtime-context.ts b/server/src/services/native-runtime/runtime-context.ts
index 32bb32426ea..5498f279988 100644
--- a/server/src/services/native-runtime/runtime-context.ts
+++ b/server/src/services/native-runtime/runtime-context.ts
@@ -21,7 +21,8 @@ import {
type NativeRuntimeContextSnapshot,
} from "../../vendor/paperclip-runner/index.js";
import { resolvePaperclipInstanceRoot } from "../../home-paths.js";
-import { agentInstructionsService } from "../agent-instructions.js";
+import { agentInstructionsService, agentInstructionsBundleMode } from "../agent-instructions.js";
+import { agentInstructionRevisionService } from "../agent-instruction-revisions.js";
import { toolAccessService } from "../tool-access.js";
import { filterResolvedGitHubConnectionsForRun } from "../git-credentials.js";
@@ -145,8 +146,24 @@ export async function materializeAsset(files: AssetFile[]): Promise ({ path: safeRelativePath(relativePath, "instruction path"), content: Buffer.from(content, "utf8"), mode: 0o444 }));
@@ -222,12 +239,17 @@ export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pi
return { assignmentSetId: `sha256:${assignmentDigest}`, digest: assignmentDigest, bindingId: assignment.connections.length ? `native-mcp:${input.runId}` : null };
}
-export async function buildNativeRuntimeContext(input: { db: Db; agent: RuntimeAgent; runId: string; runtimeConfig: Record; runtimeSkillEntries: PaperclipSkillEntry[] }): Promise {
+export async function buildNativeRuntimeContext(input: { db: Db; agent: RuntimeAgent; runId: string; runtimeConfig: Record; runtimeSkillEntries: PaperclipSkillEntry[]; instructionWorkingCopy?: { rootPath: string; entryPath: string; kind?: "agent_files" } }): Promise {
const [instructions, skills, mcp] = await Promise.all([
- materializeInstructionBundle(input.agent),
+ materializeInstructionBundle(input.db, input.agent, input.instructionWorkingCopy?.kind === "agent_files"),
materializeSelectedSkills(input.runtimeConfig, input.runtimeSkillEntries, input.agent.adapterType === "paperclip_runner"),
resolveNativeRuntimeMcpSnapshot({ db: input.db, agent: input.agent, runId: input.runId }),
]);
- const snapshot = { prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() }, instructions, skills, mcp };
+ const snapshot = {
+ prompt: { revision: PAPERCLIP_EXECUTION_PROMPT_REVISION, text: PAPERCLIP_EXECUTION_PROMPT, digest: nativeRuntimePromptDigest() },
+ instructions: { ...instructions, ...(input.instructionWorkingCopy ? { workingCopy: input.instructionWorkingCopy } : {}) },
+ skills,
+ mcp,
+ };
return parseNativeRuntimeContext({ ...snapshot, aggregateDigest: canonicalNativeRuntimeContextDigest(snapshot) });
}
diff --git a/server/src/services/plugin-managed-agents.ts b/server/src/services/plugin-managed-agents.ts
index 13473ec8c0a..f86f7a439db 100644
--- a/server/src/services/plugin-managed-agents.ts
+++ b/server/src/services/plugin-managed-agents.ts
@@ -5,6 +5,8 @@ import {
companies,
pluginEntities,
pluginManagedResources,
+ plugins,
+ activityLog,
} from "@paperclipai/db";
import type {
Agent,
@@ -12,11 +14,13 @@ import type {
PluginManagedAgentDeclaration,
PluginManagedAgentResolution,
} from "@paperclipai/shared";
-import { notFound } from "../errors.js";
+import { isUuidLike } from "@paperclipai/shared";
+import { conflict, forbidden, notFound } from "../errors.js";
import { agentService } from "./agents.js";
import { approvalService } from "./approvals.js";
import { logActivity } from "./activity-log.js";
import { agentInstructionsBundleMode, agentInstructionsService } from "./agent-instructions.js";
+import { agentInstructionRevisionService } from "./agent-instruction-revisions.js";
const MANAGED_AGENT_ENTITY_TYPE = "managed_agent";
const DEFAULT_MANAGED_AGENT_ADAPTER_TYPE = "process";
@@ -180,7 +184,7 @@ export function pluginManagedAgentService(
) {
const agentSvc = agentService(db);
const approvalSvc = approvalService(db);
- const instructions = agentInstructionsService();
+ const instructions = agentInstructionsService(db);
function declarationFor(agentKey: string) {
const declaration = options.manifest?.agents?.find((agent) => agent.agentKey === agentKey);
@@ -190,8 +194,8 @@ export function pluginManagedAgentService(
return declaration;
}
- async function getBinding(companyId: string, agentKey: string) {
- return db
+ async function getBinding(companyId: string, agentKey: string, client: Pick = db) {
+ return client
.select()
.from(pluginEntities)
.where(
@@ -210,6 +214,7 @@ export function pluginManagedAgentService(
agentId: string,
extraData: Record = {},
effectiveAdapterType?: string,
+ client: Pick = db,
) {
const adapterType = effectiveAdapterType ?? (await resolveManagedAdapterType(companyId, declaration));
const defaultsJson = {
@@ -227,7 +232,7 @@ export function pluginManagedAgentService(
budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0,
instructions: declaration.instructions ?? null,
};
- const managedResource = await db
+ const managedResource = await client
.select({ id: pluginManagedResources.id })
.from(pluginManagedResources)
.where(and(
@@ -238,12 +243,12 @@ export function pluginManagedAgentService(
))
.then((rows) => rows[0] ?? null);
if (managedResource) {
- await db
+ await client
.update(pluginManagedResources)
.set({ resourceId: agentId, defaultsJson, updatedAt: new Date() })
.where(eq(pluginManagedResources.id, managedResource.id));
} else {
- await db.insert(pluginManagedResources).values({
+ await client.insert(pluginManagedResources).values({
companyId,
pluginId: options.pluginId,
pluginKey: options.pluginKey,
@@ -265,9 +270,9 @@ export function pluginManagedAgentService(
lastReconciledAt: new Date().toISOString(),
...extraData,
};
- const existing = await getBinding(companyId, declaration.agentKey);
+ const existing = await getBinding(companyId, declaration.agentKey, client);
if (existing) {
- return db
+ return client
.update(pluginEntities)
.set({
scopeKind: "company",
@@ -281,7 +286,7 @@ export function pluginManagedAgentService(
.returning()
.then((rows) => rows[0]);
}
- return db
+ return client
.insert(pluginEntities)
.values({
pluginId: options.pluginId,
@@ -305,6 +310,46 @@ export function pluginManagedAgentService(
return rows.find((row) => rowIsManagedAgent(row, options.pluginKey, declaration.agentKey)) ?? null;
}
+ async function relinkManagedAgent(companyId: string, declaration: PluginManagedAgentDeclaration, agentId: string) {
+ const adapterType = await resolveManagedAdapterType(companyId, declaration);
+ return db.transaction(async (tx) => {
+ const [agent] = await tx.select().from(agents).where(and(
+ eq(agents.id, agentId), eq(agents.companyId, companyId), ne(agents.status, "terminated"),
+ )).for("update");
+ if (!agent || !rowIsManagedAgent(agent, options.pluginKey, declaration.agentKey)) {
+ throw conflict("Managed agent ownership changed before relink");
+ }
+ const [plugin] = await tx.select().from(plugins).where(and(
+ eq(plugins.id, options.pluginId), eq(plugins.pluginKey, options.pluginKey),
+ )).for("share");
+ if (!plugin || plugin.status !== "ready" || !plugin.manifestJson.capabilities.includes("agents.managed")
+ || !plugin.manifestJson.agents?.some((entry) => entry.agentKey === declaration.agentKey)) {
+ throw forbidden("Plugin relink is limited to its registered managed agent declaration");
+ }
+ const marker = agent.metadata!.paperclipManagedResource as Record;
+ const previousPluginId = marker.pluginId;
+ if (typeof previousPluginId !== "string" || !isUuidLike(previousPluginId)) throw forbidden("Managed agent has no plugin owner");
+ if (previousPluginId !== options.pluginId) {
+ const [previousPlugin] = await tx.select({ id: plugins.id }).from(plugins).where(eq(plugins.id, previousPluginId));
+ if (previousPlugin) throw forbidden("Managed agent still belongs to another installed plugin");
+ }
+ // Hard uninstall cascades the bindings, but deliberately leaves the agent
+ // and its canonical history. Transfer only its proven stable-key marker to
+ // the replacement installation, atomically with the new scoped bindings.
+ await upsertBinding(companyId, declaration, agentId, {}, adapterType, tx);
+ const [relinked] = await tx.update(agents).set({
+ metadata: managedMetadata(options.pluginId, options.pluginKey, declaration, agent.metadata),
+ updatedAt: new Date(),
+ }).where(and(eq(agents.id, agentId), eq(agents.companyId, companyId))).returning();
+ await tx.insert(activityLog).values({
+ companyId, actorType: "plugin", actorId: options.pluginId,
+ action: "plugin.managed_agent.relinked", entityType: "agent", entityId: agentId,
+ details: { sourcePluginKey: options.pluginKey, managedResourceKey: declaration.agentKey, previousPluginId },
+ });
+ return relinked as Agent;
+ });
+ }
+
async function companyAdapterUsage(companyId: string) {
const rows = await db
.select({ adapterType: agents.adapterType })
@@ -336,23 +381,39 @@ export function pluginManagedAgentService(
const declared = declaredInstructionFiles(declaration, variables);
if (!declared) return agent;
- const materialized = await instructions.materializeManagedBundle(
- agent,
- declared.files,
- {
- entryFile: declared.entryFile,
- replaceExisting: materializeOptions.replaceExisting,
- clearLegacyPromptTemplate: true,
- },
- );
+ let adapterConfig: Record;
+ if (materializeOptions.replaceExisting) {
+ const revisions = agentInstructionRevisionService(db);
+ const target = { companyId, agentId: agent.id };
+ const actor = { type: "plugin" as const, pluginId: options.pluginId, pluginKey: options.pluginKey, agentKey: declaration.agentKey };
+ const baseline = await revisions.readForPluginReset(target, actor);
+ const receipt = await revisions.commitPluginReset({ ...target, entryFile: declared.entryFile,
+ baseRevisionId: baseline.snapshot?.revision.id ?? null, configuredEntryFile: baseline.configuredEntryFile, content: declared.files[declared.entryFile] ?? "" }, actor);
+ if (receipt.materialization === "pending") throw conflict("Instruction revision saved; retry reset to repair its disk copy", { revisionId: receipt.revision.id });
+ const refreshed = await agentSvc.getById(agent.id);
+ if (!refreshed) throw notFound("Managed agent not found");
+ for (const [file, content] of Object.entries(declared.files)) {
+ if (file !== declared.entryFile) await instructions.writeFile(refreshed, file, content);
+ }
+ // A stock reset owns declared instruction paths, not the agent's other
+ // persistent files (including a formerly configured entry).
+ adapterConfig = { ...refreshed.adapterConfig };
+ delete adapterConfig.promptTemplate;
+ delete adapterConfig.bootstrapPromptTemplate;
+ } else {
+ const materialized = await instructions.materializeManagedBundle(agent, declared.files, {
+ entryFile: declared.entryFile, replaceExisting: false, clearLegacyPromptTemplate: true,
+ });
+ adapterConfig = materialized.adapterConfig;
+ }
const updated = await agentSvc.update(agent.id, {
- adapterConfig: materialized.adapterConfig,
+ adapterConfig,
}, {
recordRevision: {
source: `plugin:${optionsForRevisionSource()}:managed-agent-instructions`,
},
});
- return (updated as Agent | null) ?? { ...agent, adapterConfig: materialized.adapterConfig };
+ return (updated as Agent | null) ?? { ...agent, adapterConfig };
}
async function managedInstructionDefaultDrift(
@@ -375,8 +436,7 @@ export function pluginManagedAgentService(
return { entryFile: declared.entryFile, changedFiles: [declared.entryFile] };
}
- const paths = new Set([...Object.keys(declared.files), ...Object.keys(exported.files)]);
- const changedFiles = [...paths]
+ const changedFiles = Object.keys(declared.files)
.filter((filePath) => (exported.files[filePath] ?? null) !== (declared.files[filePath] ?? null))
.sort((left, right) => left.localeCompare(right));
if (exported.entryFile !== declared.entryFile && !changedFiles.includes(declared.entryFile)) {
@@ -433,7 +493,7 @@ export function pluginManagedAgentService(
spentMonthlyCents: 0,
lastHeartbeatAt: null,
}) as Agent;
- created = await materializeDeclaredInstructions(companyId, created, declaration, { replaceExisting: true });
+ created = await materializeDeclaredInstructions(companyId, created, declaration, { replaceExisting: false });
let approvalId: string | null = null;
if (requiresApproval) {
@@ -574,9 +634,7 @@ export function pluginManagedAgentService(
const relinkCandidate = await findRelinkCandidate(companyId, declaration);
if (relinkCandidate) {
- await upsertBinding(companyId, declaration, relinkCandidate.id);
- const relinkedAgent = await agentSvc.getById(relinkCandidate.id) as Agent | null;
- if (!relinkedAgent) throw notFound("Managed agent not found");
+ const relinkedAgent = await relinkManagedAgent(companyId, declaration, relinkCandidate.id);
const agent = await backfillManagedPauseReason(
companyId,
declaration,
@@ -596,8 +654,19 @@ export function pluginManagedAgentService(
? reconciled.agent.metadata
: {};
const adapterType = await resolveManagedAdapterType(companyId, declaration);
+ // Reset content through the canonical CAS path before changing defaults.
+ // A conflict must leave the existing adapter configuration untouched.
+ const withInstructions = await materializeDeclaredInstructions(companyId, reconciled.agent, declaration, { replaceExisting: true });
+ const defaults = declarationPatch(declaration, { adapterType });
+ const adapterConfig = { ...defaults.adapterConfig } as Record;
+ if (declaration.instructions) {
+ for (const key of ["instructionsBundleMode", "instructionsRootPath", "instructionsEntryFile", "instructionsFilePath"]) {
+ if (withInstructions.adapterConfig[key] !== undefined) adapterConfig[key] = withInstructions.adapterConfig[key];
+ }
+ }
const updated = await agentSvc.update(reconciled.agent.id, {
- ...declarationPatch(declaration, { adapterType }),
+ ...defaults,
+ adapterConfig,
metadata: managedMetadata(options.pluginId, options.pluginKey, declaration, currentMetadata),
}, {
recordRevision: {
@@ -605,7 +674,7 @@ export function pluginManagedAgentService(
},
});
if (!updated) throw notFound("Managed agent not found");
- const updatedAgent = await materializeDeclaredInstructions(companyId, updated as Agent, declaration, { replaceExisting: true });
+ const updatedAgent = updated as Agent;
await upsertBinding(companyId, declaration, updatedAgent.id, {}, adapterType);
await logActivity(db, {
companyId,
diff --git a/tests/runner-e2e/FIXTURES.md b/tests/runner-e2e/FIXTURES.md
index 1b1041bd19e..6aaf3b6f19c 100644
--- a/tests/runner-e2e/FIXTURES.md
+++ b/tests/runner-e2e/FIXTURES.md
@@ -233,3 +233,19 @@ first provider turn intentionally omits task disposition, and their second turn
must be an automatic, causally bound repair that records completion. They use
public task comments/status APIs and run-detail evidence; no private runtime
hooks or database mutations are used by the fixture.
+
+## Persistent agent files
+
+The `instruction_persistence` flow uses production managed storage and public file
+APIs. The browser creates a supporting file, then a real agent edits its registered
+AGENT_HOME with ordinary filesystem tools. Independent oracles verify instructions,
+nested text, binary download bytes, and a stopped-run save receipt without new
+revision history. The harness restarts the server and creates a fresh browser task
+without disclosing the saved nonces. Its readback oracle downloads and verifies an
+attachment's bytes and SHA-256, rather than accepting a filename or model claim.
+A third task uploads a ready attachment and waits in an ordinary bounded shell
+command while the board changes the current file through the public API. Stopped
+cleanup must preserve the original candidate as a conflict. The browser reviews
+current and incoming files and applies the run edits against the reviewed current
+directory hash. All three tasks' runs count toward billing and teardown. The suite
+is explicit-only. No private control-plane hooks or direct database writes are used.
diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md
index 29287faf34d..c1f73bb3035 100644
--- a/tests/runner-e2e/README.md
+++ b/tests/runner-e2e/README.md
@@ -191,6 +191,30 @@ duplicating the final response. The second workflow restarts the isolated
Paperclip server while the interaction is waiting, reloads that state, and
then resumes it. The suite has no Daytona cells.
+`instruction-persistence` is an explicit-only three-cell workflow: legacy and
+native Codex locally, plus native Codex on Daytona. Each creates six browser tasks
+for the same agent. The editor first creates a nested supporting file. The first
+run edits its registered AGENT_HOME using ordinary filesystem tools: instructions,
+nested text, editor-created content, and exact binary bytes. The oracle checks the
+current files, a stopped-run save receipt, and absence of newly appended history.
+The first task also publishes a small verification receipt for the normal
+completion contract; the personal files stay in the agent directory.
+After a Paperclip restart, a fresh task must upload a downloaded proof attachment
+containing independent saved nonces absent from its prompt. A third task edits its
+private copy while the browser edits the same current file. The later run sync
+must win for that changed file, preserve an unrelated board-created file, and
+produce no conflict candidate or manual review step. Exact bytes, downloads,
+and receipts are independently checked; model claims alone cannot pass.
+Three further tasks fill a sparse personal file to its 256 MiB limit, exceed
+that limit, and clean it up. Every run must still succeed; the run UI must show
+a warning while full and clear it after cleanup. Rejected bytes must not replace
+the saved file. This adds at most one 256 MiB saved fixture per isolated agent.
+The deadline is twenty minutes per cell, with six expected provider runs;
+normal instance/Daytona cleanup, screenshots, evidence, and billing apply. Run with
+`pnpm test:e2e:runner -- --suite instruction-persistence`. Managed agent directories
+checkpoint and close the provider before collection while retaining conversation
+state. The separate `daytona-warm-continuity` suite covers warm runtime behavior.
+
`daytona-warm-continuity` (**Daytona Warm Continuity**) is exactly two paid
cells: legacy Codex and Runner Codex against one reusable warm Daytona
configuration. Each cell creates a real project with a primary local-path
@@ -424,6 +448,14 @@ content tags are never rebuilt or overwritten by the workflow.
### Match the local controller package to the Daytona image
+When the controller runs on macOS or another platform different from the sandbox,
+set `PAPERCLIP_RUNNER_REMOTE_BINARY_PATH` to a verified Linux amd64
+`paperclip-runnerd`, such as the binary copied from `/usr/local/bin/paperclip-runnerd`
+in the pinned image. The controller must have these exact bytes for its artifact
+identity check. A local macOS runner cannot substitute for the Linux binary,
+even when the sandbox image contains a compatible runner. This also applies to
+native Codex cells, which do not otherwise need the remote provider pack below.
+
Native ACPX (including Claude) and OpenCode Daytona cells also require
`PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH` on the controller. The package and
the image must come from the same verified build. Equal provider version numbers
diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts
index ecfba3a556f..749207e66cc 100644
--- a/tests/runner-e2e/catalog.test.ts
+++ b/tests/runner-e2e/catalog.test.ts
@@ -91,10 +91,10 @@ describe("runner E2E catalog", () => {
expect(localIntegrityTasks).toHaveLength(2);
expect(openRouterBreadthTasks).toHaveLength(3);
expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([
- 16, 16, 2, 8, 46, 23, 47, 20, 52, 28, 18, 6, 6, 4, 48, 16, 10, 2,
+ 3, 16, 16, 2, 8, 46, 23, 47, 20, 52, 28, 18, 6, 6, 4, 48, 16, 10, 2,
]);
- expect(validateRunnerCatalog()).toHaveLength(368);
- expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(368);
+ expect(validateRunnerCatalog()).toHaveLength(371);
+ expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(371);
expect(
runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"),
).toHaveLength(48);
diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts
index 56da5bf4a86..662bc661752 100644
--- a/tests/runner-e2e/catalog.ts
+++ b/tests/runner-e2e/catalog.ts
@@ -1,3 +1,4 @@
+import { instructionPersistenceTask } from "./instruction-persistence.js";
import { apiResponseReadingTask } from "./api-response-reading.js";
import { accountingTasks } from "./accounting-cases.js";
import { continuationTasks } from "./continuation-cases.js";
@@ -985,6 +986,15 @@ const everydayProfiles = [
].map(productionStoryProfile);
export const runnerSuites: readonly RunnerSuiteFixture[] = [
+ {
+ id: "instruction-persistence", label: "Instruction Persistence",
+ description: "Agent-owned text and binary files round trip through the editor, survive a server restart and fresh task, and synchronize concurrent edits per file with last-sync-wins.",
+ groups: [], profiles: codexContinuityProfiles,
+ environments: [localEnvironment, runnerEnvironments.find(environment => environment.id === "daytona")!], tasks: [instructionPersistenceTask],
+ excludedExecutionIds: ["instruction-persistence.legacy-codex.daytona.private-copy-persists"],
+ expectedMatrixSize: 3, manualOnly: true,
+ definitionMetadata: { version: 6, oracle: "current-directory-independent-nonce-binary-last-sync-wins-and-nonblocking-storage-quota", providerTurns: 6, restart: "between-tasks", instructions: "production" },
+ },
{
id: "grok-subscription-qualification", label: "Grok Build Subscription Qualification", manualOnly: true,
description: "Explicit company subscription login across Grok browser workflows in local and Daytona environments.",
@@ -1313,6 +1323,7 @@ export function validateRunnerCatalog(): MatrixExecution[] {
...localIntegrityTasks,
...openRouterBreadthTasks,
daytonaWarmContinuityTask,
+ instructionPersistenceTask,
];
for (const [label, values] of [
["suite", runnerSuites],
diff --git a/tests/runner-e2e/instruction-persistence.test.ts b/tests/runner-e2e/instruction-persistence.test.ts
new file mode 100644
index 00000000000..a0d547d983d
--- /dev/null
+++ b/tests/runner-e2e/instruction-persistence.test.ts
@@ -0,0 +1,36 @@
+import { createHash } from "node:crypto";
+import { describe, expect, it } from "vitest";
+import { gradeInstructionPersistence, instructionPersistenceTask } from "./instruction-persistence.js";
+import { runnerMatrix } from "./catalog.js";
+const expectedContent = "original\nInstruction persistence nonce: fixture\n";
+const valid = () => ({ before: { revision: { id: "before" } },
+ saveEvent: { runId: "first-run", state: "saved" }, fileProof: true,
+ after: { content: expectedContent, contentHash: createHash("sha256").update(expectedContent).digest("hex"), revision: { id: "after", source: "cleanup", sourceRunId: "first-run", contentHash: createHash("sha256").update(expectedContent).digest("hex") } },
+ firstRunId: "first-run", expectedContent,
+ proof: { body: "Instruction persistence nonce: fixture\n", contentVerified: true },
+ expectedProof: "Instruction persistence nonce: fixture\n" });
+describe("instruction persistence independent oracle", () => {
+ it("does not disclose the persisted nonce in the fresh-task marker", () => {
+ const nonce = "only-the-instruction-entry-reveals-this";
+ expect(instructionPersistenceTask.buildVisibleMarker(nonce)).not.toContain(nonce);
+ });
+ it("accepts exact cleanup content and a verified downloaded fresh-task proof", () => {
+ expect(gradeInstructionPersistence(valid()).every(row => row.passed)).toBe(true);
+ });
+ it.each(["missing-save", "wrong-run", "missing-files", "wrong-bytes", "missing-proof", "unverified-proof", "wrong-proof"])("rejects %s evidence", kind => {
+ const value = valid();
+ if (kind === "missing-save") value.saveEvent.state = "unavailable";
+ if (kind === "wrong-run") value.saveEvent.runId = "another-run";
+ if (kind === "missing-files") value.fileProof = false;
+ if (kind === "wrong-bytes") value.after.content = "claimed success";
+ if (kind === "missing-proof") return expect(gradeInstructionPersistence({ ...value, proof: undefined }).some(row => !row.passed)).toBe(true);
+ if (kind === "unverified-proof") value.proof.contentVerified = false;
+ if (kind === "wrong-proof") value.proof.body = "claimed success";
+ expect(gradeInstructionPersistence(value).some(row => !row.passed)).toBe(true);
+ });
+ it("registers exactly three explicit cells without changing scheduled campaigns", () => {
+ const cells = runnerMatrix.filter(row => row.suite.id === "instruction-persistence");
+ expect(cells.map(row => `${row.profile.id}.${row.environment.id}`)).toEqual(["legacy-codex.local", "runner-codex.local", "runner-codex.daytona"]);
+ expect(cells.every(row => row.suite.manualOnly && row.task.expectedRunCount === 6)).toBe(true);
+ });
+});
diff --git a/tests/runner-e2e/instruction-persistence.ts b/tests/runner-e2e/instruction-persistence.ts
new file mode 100644
index 00000000000..6e60b304dd5
--- /dev/null
+++ b/tests/runner-e2e/instruction-persistence.ts
@@ -0,0 +1,232 @@
+import { createHash, randomBytes } from "node:crypto";
+import { expect, type Page } from "@playwright/test";
+import { pollUntil, type RunnerApi } from "./api.js";
+import { captureFirstTaskAttachments } from "./first-task-attachments.js";
+import { collectRunEvents } from "./run-observations.js";
+import { createTaskThroughUi } from "./user-actions.js";
+import type { LiveFixtureValues } from "./live-fixtures.js";
+import type { MatrixExecution, RunnerTaskFixture } from "./types.js";
+
+type Row = Record;
+export const instructionNonceLine = (nonce: string) => `Instruction persistence nonce: ${nonce}\n`;
+export const instructionPersistenceTask: RunnerTaskFixture = {
+ id: "private-copy-persists", label: "Agent directory survives a fresh task",
+ groups: [], workMode: "standard", flow: "instruction_persistence",
+ expectedRunCount: 6, attemptTimeoutMs: { local: 20 * 60_000, daytona: 20 * 60_000 },
+ expectedTerminalState: { issue: "done", run: "succeeded" },
+ buildTitle: nonce => `Persist private instructions ${nonce}`,
+ buildVisibleMarker: () => "INSTRUCTIONS-VERIFIED",
+ buildPrompt: nonce => [
+ "Edit your own registered writable agent instruction entry with ordinary filesystem tools. The runtime guidance gives its exact private path.",
+ "Use Node.js built-in fs for these byte-preserving edits. Apply each append exactly once: inspect the existing suffix before retrying any command, because a warning does not imply that its writes failed.",
+ `Preserve its existing bytes and append exactly this UTF-8 suffix, represented as a JSON string: ${JSON.stringify(`\n${instructionNonceLine(nonce)}`)}`,
+ "Decode the JSON string once and append those bytes. Do not trim or normalize the existing file and do not add another blank line or separator.",
+ `In AGENT_HOME, create notes/retained.txt containing exactly ${JSON.stringify(`Personal file nonce: ${nonce}\n`)}. Create notes/bytes.bin with exactly the bytes [0,255,17,128,9]. Read notes/from-editor.txt and append exactly a newline followed by Edited by agent. and a final newline.`,
+ "Do not use update_agent_instructions, restore_agent_instructions, or an instructions API to save it. Do not edit repository AGENTS.md or the read-only loaded bundle.",
+ "After verifying the edits, upload a small text/plain attachment named agent-file-check.txt containing only 'Private file edits verified'. Use this attachment as your task completion evidence; the personal files themselves stay in AGENT_HOME.",
+ "Reply only Instruction copy edited without printing filesystem paths, then complete this task after the file edit. Paperclip will collect it after the provider stops; do not claim it has already persisted. Do not create further tasks.",
+ ].join("\n"),
+ buildMatchers: () => [], // Independent current file and attachment oracle below.
+};
+
+export function gradeInstructionPersistence(input: { before: Row; after: Row; firstRunId: string; expectedContent: string; proof: Row | undefined; expectedProof: string; saveEvent?: Row; fileProof?: boolean }) {
+ return [
+ { id: "directory-save-receipt", passed: input.saveEvent?.runId === input.firstRunId && input.saveEvent?.state === "saved" },
+ { id: "nested-and-binary-files", passed: input.fileProof === true },
+ { id: "exact-canonical-bytes", passed: input.after.content === input.expectedContent && input.after.contentHash === createHash("sha256").update(input.expectedContent).digest("hex") },
+ { id: "fresh-task-downloaded-proof", passed: input.proof?.contentVerified === true && input.proof.body === input.expectedProof },
+ ].map(check => ({ ...check, detail: check.passed ? `${check.id} verified independently` : `${check.id} missing or incorrect` }));
+}
+
+export async function runInstructionPersistenceFlow(input: {
+ page: Page; api: RunnerApi; fixtures: LiveFixtureValues; execution: MatrixExecution; nonce: string;
+ secrets: readonly string[]; deadlineAt: number;
+ restart(): Promise;
+ observe(issue: Row, runs: Row[]): void;
+ capture(id: string, label: string, file: string): Promise;
+ evidence(name: string, data: unknown): Promise;
+}) {
+ const { page, api, fixtures, execution, nonce } = input;
+ // Fixture names contain the campaign nonce. Use an unrelated value that the
+ // fresh task can obtain only from the saved entry (or forbidden task history).
+ const persistedNonce = randomBytes(16).toString("hex");
+ const filePath = `/api/agents/${fixtures.agent.id}/instructions-bundle/file?path=AGENTS.md`;
+ const before = await api.get(filePath);
+ if (typeof before.content !== "string" || !before.contentHash) throw new Error("Managed instructions must expose a current file hash");
+ const expectedContent = `${before.content}\n${instructionNonceLine(persistedNonce)}`;
+ const instructionsUrl = `/${fixtures.company.issuePrefix}/agents/${fixtures.agent.id}/instructions`;
+ const editorText = `Editor nonce: ${randomBytes(16).toString("hex")}`;
+ await page.goto(instructionsUrl);
+ await page.getByRole("button", { name: "Add agent file", exact: true }).click();
+ await page.getByPlaceholder("TOOLS.md").fill("notes/from-editor.txt");
+ await page.getByRole("button", { name: "Create", exact: true }).click();
+ await page.getByRole("group", { name: "Instruction file view" }).getByRole("button", { name: "edit", exact: true }).click();
+ await page.getByRole("textbox", { name: "Instruction file editor" }).fill(editorText);
+ await page.getByRole("button", { name: "Save changes", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled();
+ await expect(page.getByRole("button", { name: "History", exact: true })).toHaveCount(0);
+ let issue: Row = {};
+ let runs: Row[] = [];
+ async function create(title: string, prompt: string) {
+ await createTaskThroughUi({ page, issuePrefix: fixtures.company.issuePrefix!, agentName: fixtures.agent.name, title, prompt, workMode: "standard", projectName: fixtures.project?.name });
+ const found = await pollUntil({ label: `instruction task ${title}`, deadlineAt: input.deadlineAt,
+ load: async () => (await api.get(`/api/companies/${fixtures.company.id}/issues?limit=100`)).find(row => row.title === title), accept: row => Boolean(row) });
+ if (!found) throw new Error("Browser-created instruction task missing");
+ issue = found;
+ input.observe(issue, runs);
+ await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`);
+ }
+ async function settle(count: number) {
+ await pollUntil({ label: `instruction run ${count} completed`, deadlineAt: input.deadlineAt,
+ load: async () => {
+ issue = await api.get(`/api/issues/${issue.id}`);
+ const listed = await api.get(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`);
+ runs = await Promise.all(listed.map(row => api.get(`/api/heartbeat-runs/${row.id}`)));
+ runs.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
+ input.observe(issue, runs);
+ return { issue, runs };
+ },
+ accept: state => state.issue.status === "done" && state.runs.length === count && state.runs.every(row => row.status === "succeeded"),
+ reject: state => state.runs.some(row => ["failed", "cancelled", "timed_out"].includes(row.status)) ? "Instruction task provider run failed" : state.runs.length > count ? "Instruction task dispatched an extra run" : state.issue.status === "blocked" && state.runs.length === count && state.runs.every(row => row.status === "succeeded") ? `Instruction task reported a terminal blocker: ${JSON.stringify(state.issue.unblockDescriptor ?? {})}` : undefined,
+ });
+ expect(runs.every(row => row.runtimeMode === execution.profile.expectedRuntimeMode)).toBe(true);
+ await page.reload();
+ await expect(page.getByTestId("issue-detail-header").getByRole("button", { name: "Change status (current: Done)", exact: true })).toBeVisible();
+ }
+ await create(execution.task.buildTitle(nonce), execution.task.buildPrompt(persistedNonce));
+ await settle(1);
+ const firstRunId = runs[0]!.id;
+ const after = await pollUntil({ label: "stopped agent directory save", deadlineAt: Math.min(input.deadlineAt, Date.now() + 30_000),
+ load: () => api.get(filePath), accept: row => row.content === expectedContent });
+ const events = await collectRunEvents((afterSeq, limit) => api.get(`/api/heartbeat-runs/${firstRunId}/events?afterSeq=${afterSeq}&limit=${limit}`));
+ const saveEvent = events.find(row => row.eventType === "instruction_save" && row.payload?.state === "saved");
+ expect(saveEvent).toBeTruthy();
+ const readPersonal = (name: string) => api.get(`/api/agents/${fixtures.agent.id}/instructions-bundle/file?path=${encodeURIComponent(name)}`);
+ const note = await readPersonal("notes/retained.txt");
+ const fromEditor = await readPersonal("notes/from-editor.txt");
+ const binaryResponse = await api.request.get(`/api/agents/${fixtures.agent.id}/instructions-bundle/file?path=notes%2Fbytes.bin&download=true`);
+ expect(binaryResponse.ok()).toBe(true);
+ const binary = await binaryResponse.body();
+ const fileProof = note.content === `Personal file nonce: ${persistedNonce}\n` && fromEditor.content === `${editorText}\nEdited by agent.\n` && binary.equals(Buffer.from([0,255,17,128,9]));
+ expect(fileProof).toBe(true);
+ const history = await api.get(`/api/agents/${fixtures.agent.id}/instructions-bundle/history?path=AGENTS.md`);
+ expect(history.revisions).toHaveLength(0);
+ await input.evidence("instruction-first-save.json", { before, after, run: runs[0], events });
+ await input.capture("instruction-edited", "Private instructions saved after provider stop", "instruction-edited.png");
+ // A new server and a new issue cannot pass by retaining model conversation.
+ await input.restart();
+ expect((await api.get(filePath)).content).toBe(expectedContent);
+ await create("Read persisted instructions", [
+ "Read your own loaded agent instruction entry (or its current registered private copy) using ordinary filesystem tools.",
+ "Find the line beginning 'Instruction persistence nonce: '. Copy that entire line plus one final newline into instruction-proof.txt. Then append the exact bytes of notes/retained.txt from AGENT_HOME. Verify notes/bytes.bin contains the bytes [0,255,17,128,9]. Do not infer the value from this task title or other task history. Do not change your instructions.",
+ "Upload instruction-proof.txt as a text/plain task attachment named instruction-proof.txt using the normal artifact workflow. A local file alone is insufficient.",
+ `Reply with exactly ${execution.task.buildVisibleMarker(nonce)} and complete the task.`,
+ ].join("\n"));
+ await settle(2);
+ const attachments = await captureFirstTaskAttachments(api, [{ ...issue, id: String(issue.id) }], input.secrets);
+ const proof = attachments.find(row => row.originalFilename === "instruction-proof.txt" || row.name === "instruction-proof.txt");
+ const final = await api.get(filePath);
+ expect(final.revision.id).toBe(after.revision.id);
+ const checks = gradeInstructionPersistence({ before, after, firstRunId, expectedContent, proof, expectedProof: `${instructionNonceLine(persistedNonce)}Personal file nonce: ${persistedNonce}\n`, saveEvent: { runId: firstRunId, state: saveEvent?.payload?.state }, fileProof });
+ await expect(page.getByTestId("task-chat-agent-bubble").filter({ hasText: execution.task.buildVisibleMarker(nonce) }).last()).toBeVisible();
+ await input.capture("final-state", "Fresh task downloaded the persisted instruction nonce", "final-state.png");
+ expect(checks.filter(check => !check.passed), "Independent instruction persistence checks").toEqual([]);
+
+ checks.push({ id: "editor-round-trip-no-history", passed: true, detail: "A browser-created supporting file was edited by the agent; all current bytes persisted without revision rows" });
+ const restored = final;
+
+ // The provider publishes an ordinary attachment before a bounded command wait.
+ // A board edit during that run is superseded only for the same changed file.
+ const conflictSuffix = `\nLast completed synchronization: ${nonce}\n`;
+ const expectedCandidate = `${restored.content}${conflictSuffix}`;
+ await create("Sync a concurrent instruction edit", [
+ "Use Node.js built-in fs. Apply the append exactly once, checking existing bytes before any retry.",
+ `Append exactly this UTF-8 suffix to your current registered writable instruction entry, represented as a JSON string: ${JSON.stringify(conflictSuffix)}`,
+ "Decode the JSON string once. Preserve all existing bytes. Do not use an instruction revision tool or instructions API.",
+ "After the file edit, upload a text/plain task attachment named instruction-candidate-ready.txt with the text ready. Use the ordinary artifact workflow.",
+ "Then execute the ordinary shell command sleep 45 and wait for it to finish. This gives the board time to edit the canonical instructions concurrently. Do not complete the task before that command finishes.",
+ "After the wait completes, reply Candidate edit ready and complete the task. Do not change the instructions again or claim that they saved.",
+ ].join("\n"));
+ await pollUntil({ label: "provider staged concurrent instruction edit", deadlineAt: input.deadlineAt,
+ load: () => api.get(`/api/issues/${issue.id}/attachments`),
+ accept: rows => rows.some(row => row.originalFilename === "instruction-candidate-ready.txt" || row.name === "instruction-candidate-ready.txt") });
+ const active = await api.get(`/api/issues/${issue.id}/runs`);
+ expect(active.some(row => row.status === "running")).toBe(true);
+ const boardMarker = "Concurrent board instruction edit.";
+ await page.goto(instructionsUrl);
+ await page.getByText("AGENTS.md", { exact: true }).first().click();
+ await page.getByRole("group", { name: "Instruction file view" }).getByRole("button", { name: "edit", exact: true }).click();
+ const entryEditor = page.getByRole("textbox", { name: "editable markdown" });
+ await entryEditor.click();
+ await entryEditor.press("ControlOrMeta+End");
+ await entryEditor.press("Enter");
+ await entryEditor.pressSequentially(boardMarker);
+ await page.getByRole("button", { name: "Save changes", exact: true }).click();
+ await expect(page.getByRole("button", { name: "Save changes", exact: true })).toBeDisabled();
+ const board = await api.get(filePath);
+ expect(board.content).toContain(boardMarker);
+ expect(board.contentHash).not.toBe(restored.contentHash);
+ const unrelatedContent = `Concurrent independent file: ${nonce}`;
+ const unrelated = await api.request.put(`/api/agents/${fixtures.agent.id}/instructions-bundle/file`, {
+ data: { path: "notes/concurrent-editor.txt", content: unrelatedContent, baseHash: null },
+ });
+ expect(unrelated.ok()).toBe(true);
+ await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`);
+ await settle(3);
+ const syncRunId = runs[2]!.id;
+ const resolved = await pollUntil({ label: "last completed sync wins", deadlineAt: Math.min(input.deadlineAt, Date.now() + 30_000),
+ load: () => api.get(filePath), accept: row => row.content === expectedCandidate });
+ const candidates = await api.get(`/api/agents/${fixtures.agent.id}/instructions-bundle/candidates`);
+ expect(candidates.some(row => row.runId === syncRunId)).toBe(false);
+ expect((await readPersonal("notes/concurrent-editor.txt")).content).toBe(unrelatedContent);
+ const syncEvents = await collectRunEvents((afterSeq, limit) => api.get(`/api/heartbeat-runs/${syncRunId}/events?afterSeq=${afterSeq}&limit=${limit}`));
+ expect(syncEvents.some(row => row.eventType === "instruction_save" && row.payload?.state === "saved")).toBe(true);
+ await page.goto(instructionsUrl);
+ await expect(page.getByRole("button", { name: "Review preserved files", exact: true })).toHaveCount(0);
+ checks.push({ id: "per-file-last-sync-wins", passed: true, detail: "The later agent sync replaced the concurrent browser edit to its changed entry, preserved an unrelated new file, and created no conflict candidate" });
+ await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`);
+ await input.capture("last-sync-wins", "Concurrent changes synchronized per file without a conflict-review step", "last-sync-wins.png");
+
+ const quotaTask = (action: string, receipt: string) => [
+ "This is a controlled persistent-storage quota check. Use ordinary Node.js filesystem tools in your registered AGENT_HOME. Do not edit AGENTS.md.",
+ action,
+ `Upload a small text/plain task attachment named ${receipt}.txt containing the observed file size or cleanup result. This attachment is the primary task deliverable.`,
+ "Complete this task normally after uploading the receipt. A persistent-file storage warning is expected and must not prevent completion. Do not perform additional cleanup or change other personal files.",
+ ].join("\n");
+ await create("Reach the agent file storage limit", quotaTask(
+ "Create quota-cache.bin using fs.openSync with flag w, fs.ftruncateSync(fd, 268435456), and fs.closeSync. This is a sparse fixture file, not a download. Verify its size using fs.statSync without reading the large contents.", "quota-full"));
+ await settle(4);
+ const fullRun = runs[3]!;
+ expect(fullRun.resultJson?.instructionSave).toMatchObject({ state: "saved", storageWarning: expect.stringContaining("Agent storage is full") });
+ await page.goto(`/${fixtures.company.issuePrefix}/agents/${fixtures.agent.id}/runs/${fullRun.id}`);
+ await expect(page.getByRole("note").filter({ hasText: "Agent storage warning" })).toContainText("Runs can continue");
+ // Public campaign screenshots are limited to sanitized task routes. Verify
+ // the warning in the real run UI, then capture its completed task outcome.
+ await page.goto(`/${fixtures.company.issuePrefix}/issues/${issue.identifier ?? issue.id}`);
+ await input.capture("storage-warning", "Task succeeded while agent storage reached its limit", "storage-warning.png");
+
+ await create("Keep running while agent storage is full", quotaTask(
+ "Verify quota-cache.bin already exists and its size is exactly 268435456. Grow only this file to 268435457 bytes with fs.truncateSync, then verify the new size. Leave it above the limit for this run's sync check.", "quota-exceeded"));
+ await settle(5);
+ const exceededRun = runs[4]!;
+ expect(exceededRun.resultJson?.instructionSave).toMatchObject({ state: "unavailable", errorCode: "AGENT_FILES_LIMIT_EXCEEDED", storageWarning: expect.stringContaining("Runs can continue") });
+ const fullEvents = await collectRunEvents