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

@ -16,6 +16,7 @@ import type {
CompanyPortabilityImportResult,
CompanyPortabilityInclude,
CompanyPortabilityManifest,
CompanyPortabilityIssueCommentManifestEntry,
CompanyPortabilityPreview,
CompanyPortabilityPreviewAgentPlan,
CompanyPortabilityPreviewResult,
@ -42,6 +43,9 @@ import {
ROUTINE_TRIGGER_SIGNING_MODES,
deriveProjectUrlKey,
envConfigSchema,
issueCommentAuthorTypeSchema,
issueCommentMetadataSchema,
issueCommentPresentationSchema,
normalizeAgentUrlKey,
} from "@paperclipai/shared";
import {
@ -644,6 +648,96 @@ function asInteger(value: unknown): number | null {
return typeof value === "number" && Number.isInteger(value) ? value : null;
}
function hasOwn(record: Record<string, unknown>, key: string) {
return Object.prototype.hasOwnProperty.call(record, key);
}
function readStringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const entries = value.filter((entry): entry is string => typeof entry === "string");
return entries.length === value.length ? entries : null;
}
function derivePortableCommentAuthorType(value: Record<string, unknown>) {
const explicit = issueCommentAuthorTypeSchema.safeParse(value.authorType);
if (explicit.success) return explicit.data;
return asString(value.authorAgentSlug) ? "agent" : asString(value.authorUserId) ? "user" : "system";
}
function readPortableIssueComments(
value: unknown,
warnings: string[],
sourceLabel: string,
): CompanyPortabilityIssueCommentManifestEntry[] {
if (value === undefined || value === null) return [];
if (!Array.isArray(value)) {
warnings.push(`${sourceLabel} comments were ignored because they are not an array.`);
return [];
}
const comments: CompanyPortabilityIssueCommentManifestEntry[] = [];
for (const [index, entry] of value.entries()) {
if (!isPlainRecord(entry)) {
warnings.push(`${sourceLabel} comment ${index + 1} was ignored because it is not an object.`);
continue;
}
const body = asString(entry.body);
if (!body) {
warnings.push(`${sourceLabel} comment ${index + 1} was ignored because it has no body.`);
continue;
}
const presentation = entry.presentation == null ? null : issueCommentPresentationSchema.safeParse(entry.presentation);
if (presentation && !presentation.success) {
warnings.push(`${sourceLabel} comment ${index + 1} has invalid presentation metadata and was ignored.`);
continue;
}
const metadata = entry.metadata == null ? null : issueCommentMetadataSchema.safeParse(entry.metadata);
if (metadata && !metadata.success) {
warnings.push(`${sourceLabel} comment ${index + 1} has invalid hidden metadata and was ignored.`);
continue;
}
const createdAt = asString(entry.createdAt);
comments.push({
body,
authorType: derivePortableCommentAuthorType(entry),
authorAgentSlug: asString(entry.authorAgentSlug),
authorUserId: asString(entry.authorUserId),
presentation: presentation ? presentation.data : null,
metadata: metadata ? metadata.data : null,
createdAt: createdAt && Number.isNaN(Date.parse(createdAt)) ? null : createdAt,
});
}
return comments;
}
function appendCodexImportArg(adapterConfig: Record<string, unknown>, arg: string) {
const extraArgs = readStringArray(adapterConfig.extraArgs);
if (extraArgs) {
if (!extraArgs.includes(arg)) adapterConfig.extraArgs = [...extraArgs, arg];
return;
}
const legacyArgs = readStringArray(adapterConfig.args);
if (legacyArgs && legacyArgs.length > 0) {
if (!legacyArgs.includes(arg)) adapterConfig.args = [...legacyArgs, arg];
return;
}
if (legacyArgs?.includes(arg)) return;
adapterConfig.extraArgs = [arg];
}
function applyImportAdapterRunDefaults(
adapterType: string,
adapterConfig: Record<string, unknown>,
) {
const next = { ...adapterConfig };
if (adapterType === "codex_local") {
appendCodexImportArg(next, "--skip-git-repo-check");
}
return next;
}
function normalizeRoutineTriggerExtension(value: unknown): CompanyPortabilityIssueRoutineTriggerManifestEntry | null {
if (!isPlainRecord(value)) return null;
const kind = asString(value.kind);
@ -2685,6 +2779,7 @@ function buildManifestFromPackageFiles(
assigneeAdapterOverrides: isPlainRecord(extension.assigneeAdapterOverrides)
? extension.assigneeAdapterOverrides
: null,
comments: readPortableIssueComments(extension.comments, warnings, `Task ${slug}`),
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
});
if (frontmatter.kind && frontmatter.kind !== "task") {
@ -2804,7 +2899,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
if (mode === "agent_safe" && IMPORT_FORBIDDEN_ADAPTER_TYPES.has(effectiveAdapterType)) {
throw forbidden(`Adapter type "${effectiveAdapterType}" is not allowed in safe imports`);
}
const nextAdapterConfig = writePaperclipSkillSyncPreference({ ...adapterConfig }, desiredSkills);
const nextAdapterConfig = writePaperclipSkillSyncPreference(
applyImportAdapterRunDefaults(effectiveAdapterType, adapterConfig),
desiredSkills,
);
delete nextAdapterConfig.promptTemplate;
delete nextAdapterConfig.bootstrapPromptTemplate;
delete nextAdapterConfig.instructionsFilePath;
@ -3380,6 +3478,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
});
}
}
const comments = await issuesSvc.listComments(issue.id, { order: "asc" });
files[taskPath] = buildMarkdown(
{
name: issue.title,
@ -3397,6 +3496,20 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
projectWorkspaceKey: projectWorkspaceKey ?? undefined,
executionWorkspaceSettings: issue.executionWorkspaceSettings ?? undefined,
assigneeAdapterOverrides: issue.assigneeAdapterOverrides ?? undefined,
comments: comments.length > 0
? comments.map((comment) => ({
body: comment.body,
authorType: comment.authorType,
authorAgentSlug: comment.authorAgentId ? (idToSlug.get(comment.authorAgentId) ?? null) : null,
// Portable bundles preserve author kind, but not raw board user ids.
authorUserId: null,
presentation: comment.presentation,
metadata: comment.metadata,
createdAt: comment.createdAt instanceof Date
? comment.createdAt.toISOString()
: new Date(comment.createdAt).toISOString(),
}))
: undefined,
});
paperclipTasksOut[taskSlug] = isPlainRecord(extension) ? extension : {};
}
@ -4496,7 +4609,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
warnings.push(`Task ${manifestIssue.slug} was downgraded to todo because its assignee could not be imported as assignable work.`);
issueStatus = "todo";
}
await issues.create(targetCompany.id, {
const createdIssue = await issues.create(targetCompany.id, {
projectId,
projectWorkspaceId,
title: manifestIssue.title,
@ -4511,6 +4624,30 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
executionWorkspaceSettings: manifestIssue.executionWorkspaceSettings,
labelIds: manifestIssue.labelIds ?? [],
});
for (const comment of manifestIssue.comments ?? []) {
const authorAgentId = comment.authorType === "agent" && comment.authorAgentSlug
? importedSlugToAgentId.get(comment.authorAgentSlug)
?? existingSlugToAgentId.get(comment.authorAgentSlug)
?? null
: null;
if (comment.authorType === "agent" && comment.authorAgentSlug && !authorAgentId) {
warnings.push(`Comment on task ${manifestIssue.slug} was imported as a system comment because author agent ${comment.authorAgentSlug} was not imported.`);
}
const authorType = authorAgentId
? "agent"
: comment.authorType === "user"
? "user"
: "system";
await issues.addComment(createdIssue.id, comment.body, {
agentId: authorAgentId ?? undefined,
userId: authorType === "user" ? actorUserId ?? undefined : undefined,
}, {
authorType,
presentation: comment.presentation,
metadata: comment.metadata,
createdAt: comment.createdAt,
});
}
}
}