Add recovery handoff system notices (#5289)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Agent runs can end productively while the source issue still lacks a
durable final disposition.
> - That leaves the control plane unsure whether to resume, escalate, or
close the work.
> - Issue comments also need a presentation contract so system-authored
recovery notices can render as first-class thread messages without
overloading normal comments.
> - This pull request adds successful-run handoff recovery, comment
presentation metadata, and system notice rendering.
> - The benefit is stricter task liveness with clearer operator-facing
recovery state.

## What Changed

- Added successful-run handoff decisions, wake payloads, escalation
behavior, and recovery tests.
- Added issue comment presentation metadata with migration
`0078_white_darwin.sql` and shared/server/company portability support.
- Rendered recovery/system notices in issue chat with dedicated UI
components, fixtures, tests, and storybook/lab coverage.
- Included the current recovery model-profile hint patch so automatic
recovery follow-ups use the cheap profile.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run
server/src/services/recovery/successful-run-handoff.test.ts
ui/src/components/SystemNotice.test.tsx
ui/src/lib/system-notice-comment.test.ts
ui/src/components/IssueChatThreadSystemNotice.test.tsx`

## Risks

- Migration-bearing PR: merge this before any other branch that might
later add a migration.
- The branch touches both recovery services and issue-thread rendering,
so review should pay attention to recovery wake idempotency and comment
metadata compatibility.

## Model Used

- OpenAI GPT-5 Codex via Paperclip `codex_local` adapter, with
shell/git/GitHub CLI tool use.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-05-06 06:05:58 -05:00 committed by GitHub
parent 50db8c01d2
commit 454edfe81e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 21919 additions and 125 deletions

View file

@ -35,9 +35,11 @@ const projectSvc = {
const issueSvc = {
list: vi.fn(),
listComments: vi.fn(),
getById: vi.fn(),
getByIdentifier: vi.fn(),
create: vi.fn(),
addComment: vi.fn(),
};
const routineSvc = {
@ -131,6 +133,14 @@ describe("company portability", () => {
config,
secretKeys: new Set<string>(),
}));
issueSvc.listComments.mockResolvedValue([]);
issueSvc.addComment.mockResolvedValue({
id: "comment-imported",
body: "Imported comment",
authorType: "system",
presentation: null,
metadata: null,
});
companySvc.getById.mockResolvedValue({
id: "company-1",
name: "Paperclip",
@ -2333,6 +2343,103 @@ describe("company portability", () => {
expect(materializedFiles["AGENTS.md"]).not.toContain('name: "ClaudeCoder"');
});
it("does not silently add local adapter permission bypasses on import", async () => {
const portability = companyPortabilityService({} as any);
companySvc.create.mockResolvedValue({
id: "company-imported",
name: "Imported Paperclip",
});
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "agent-created",
name: String(input.name),
adapterType: input.adapterType,
adapterConfig: input.adapterConfig,
}));
const exported = await portability.exportBundle("company-1", {
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
});
agentSvc.list.mockResolvedValue([]);
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
}, "user-1");
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
adapterType: "claude_local",
adapterConfig: expect.not.objectContaining({
dangerouslySkipPermissions: expect.anything(),
}),
}));
await portability.importBundle({
source: {
type: "inline",
rootPath: exported.rootPath,
files: exported.files,
},
include: {
company: true,
agents: true,
projects: false,
issues: false,
},
target: {
mode: "new_company",
newCompanyName: "Imported Paperclip",
},
agents: ["claudecoder"],
collisionStrategy: "rename",
adapterOverrides: {
claudecoder: {
adapterType: "codex_local",
adapterConfig: {
extraArgs: [],
args: ["--legacy-arg"],
},
},
},
}, "user-1");
expect(agentSvc.create).toHaveBeenLastCalledWith("company-imported", expect.objectContaining({
adapterType: "codex_local",
adapterConfig: expect.objectContaining({
extraArgs: ["--skip-git-repo-check"],
args: ["--legacy-arg"],
}),
}));
expect(agentSvc.create).toHaveBeenLastCalledWith("company-imported", expect.objectContaining({
adapterConfig: expect.not.objectContaining({
dangerouslyBypassApprovalsAndSandbox: expect.anything(),
dangerouslyBypassSandbox: expect.anything(),
}),
}));
});
it("preserves issue labelIds through export and import round-trip", async () => {
const portability = companyPortabilityService({} as any);
@ -2399,6 +2506,85 @@ describe("company portability", () => {
);
});
it("preserves issue comment presentation fields through export and import", async () => {
const portability = companyPortabilityService({} as any);
const presentation = { kind: "system_notice", tone: "warning", detailsDefaultOpen: false };
const metadata = {
version: 1,
sections: [{ rows: [{ type: "key_value", label: "Cause", value: "successful_run_missing_state" }] }],
};
projectSvc.list.mockResolvedValue([]);
projectSvc.listWorkspaces.mockResolvedValue([]);
issueSvc.list.mockResolvedValue([
{
id: "issue-1",
identifier: "PAP-1",
title: "Needs disposition",
description: "System notice source",
projectId: null,
projectWorkspaceId: null,
assigneeAgentId: null,
status: "todo",
priority: "high",
labelIds: [],
billingCode: null,
executionWorkspaceSettings: null,
assigneeAdapterOverrides: null,
},
]);
issueSvc.listComments.mockResolvedValue([
{
id: "comment-1",
issueId: "issue-1",
companyId: "company-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
body: "Paperclip needs a disposition before this issue can continue.",
presentation,
metadata,
createdAt: new Date("2026-05-04T12:00:00.000Z"),
updatedAt: new Date("2026-05-04T12:00:00.000Z"),
},
]);
const exported = await portability.exportBundle("company-1", {
include: { company: true, agents: false, projects: false, issues: true },
});
const extension = asTextFile(exported.files[".paperclip.yaml"]);
expect(extension).toContain("comments:");
expect(extension).toContain("system_notice");
expect(extension).toContain("successful_run_missing_state");
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
accessSvc.ensureMembership.mockResolvedValue(undefined);
agentSvc.list.mockResolvedValue([]);
projectSvc.list.mockResolvedValue([]);
issueSvc.create.mockResolvedValue({ id: "issue-imported", title: "Needs disposition" });
await portability.importBundle({
source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
include: { company: true, agents: false, projects: false, issues: true },
target: { mode: "new_company", newCompanyName: "Imported" },
agents: "all",
collisionStrategy: "rename",
}, "user-1");
expect(issueSvc.addComment).toHaveBeenCalledWith(
"issue-imported",
"Paperclip needs a disposition before this issue can continue.",
{ agentId: undefined, userId: undefined },
{
authorType: "system",
presentation,
metadata,
createdAt: "2026-05-04T12:00:00.000Z",
},
);
});
it("strips root AGENTS frontmatter when importing a nested agent entry path", async () => {
const portability = companyPortabilityService({} as any);