mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 19:50:38 +09:00
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:
parent
50db8c01d2
commit
454edfe81e
70 changed files with 21919 additions and 125 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue