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

@ -2,8 +2,9 @@ import { randomUUID } from "node:crypto";
import { Router, type Request, type Response } from "express";
import multer from "multer";
import { z } from "zod";
import { and, desc, eq, inArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { issueExecutionDecisions } from "@paperclipai/db";
import { activityLog, issueExecutionDecisions } from "@paperclipai/db";
import {
addIssueCommentSchema,
acceptIssueThreadInteractionSchema,
@ -32,6 +33,7 @@ import {
isClosedIsolatedExecutionWorkspace,
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
type ExecutionWorkspace,
type SuccessfulRunHandoffState,
} from "@paperclipai/shared";
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
import { getTelemetryClient } from "../telemetry.js";
@ -78,6 +80,7 @@ import { executionWorkspaceService as executionWorkspaceServiceDirect } from "..
import { feedbackService } from "../services/feedback.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { environmentService } from "../services/environments.js";
import { redactSensitiveText } from "../redaction.js";
import {
applyIssueExecutionPolicyTransition,
normalizeIssueExecutionPolicy,
@ -113,6 +116,103 @@ type ExecutionStageWakeContext = {
lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"];
allowedActions: string[];
};
type SuccessfulRunHandoffActivityRow = {
entityId: string;
action: string;
agentId: string | null;
runId: string | null;
details: Record<string, unknown> | null;
createdAt: Date;
};
const SUCCESSFUL_RUN_HANDOFF_ACTIONS = [
"issue.successful_run_handoff_required",
"issue.successful_run_handoff_resolved",
"issue.successful_run_handoff_escalated",
] as const;
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function successfulRunHandoffStateFromActivity(row: {
action: string;
agentId: string | null;
runId: string | null;
details: Record<string, unknown> | null;
createdAt: Date;
}): SuccessfulRunHandoffState | null {
const details = row.details ?? {};
const state =
row.action === "issue.successful_run_handoff_required"
? "required"
: row.action === "issue.successful_run_handoff_resolved"
? "resolved"
: row.action === "issue.successful_run_handoff_escalated"
? "escalated"
: null;
if (!state) return null;
const detectedProgressSummary =
readNonEmptyString(details.detectedProgressSummary)
?? readNonEmptyString(details.detected_progress_summary)
?? null;
return {
state,
required: state === "required",
sourceRunId:
readNonEmptyString(details.sourceRunId)
?? readNonEmptyString(details.source_run_id)
?? readNonEmptyString(details.resumeFromRunId)
?? row.runId
?? null,
correctiveRunId:
readNonEmptyString(details.correctiveRunId)
?? readNonEmptyString(details.corrective_run_id)
?? (state !== "required" ? row.runId : null),
assigneeAgentId:
readNonEmptyString(details.assigneeAgentId)
?? readNonEmptyString(details.agentId)
?? row.agentId
?? null,
detectedProgressSummary: detectedProgressSummary
? redactSensitiveText(detectedProgressSummary)
: null,
createdAt: row.createdAt,
};
}
async function listSuccessfulRunHandoffStates(
db: Db,
companyId: string,
issueIds: string[],
): Promise<Map<string, SuccessfulRunHandoffState>> {
if (issueIds.length === 0) return new Map();
const result = await db.execute(sql`
SELECT DISTINCT ON (${activityLog.entityId})
${activityLog.entityId} AS "entityId",
${activityLog.action} AS "action",
${activityLog.agentId} AS "agentId",
${activityLog.runId} AS "runId",
${activityLog.details} AS "details",
${activityLog.createdAt} AS "createdAt"
FROM ${activityLog}
WHERE ${activityLog.companyId} = ${companyId}
AND ${activityLog.entityType} = 'issue'
AND ${activityLog.entityId} IN (${sql.join(issueIds.map((id) => sql`${id}`), sql`, `)})
AND ${activityLog.action} IN (${sql.join(SUCCESSFUL_RUN_HANDOFF_ACTIONS.map((action) => sql`${action}`), sql`, `)})
ORDER BY ${activityLog.entityId}, ${activityLog.createdAt} DESC, ${activityLog.id} DESC
`);
const rows = Array.from(result as Iterable<SuccessfulRunHandoffActivityRow>);
const states = new Map<string, SuccessfulRunHandoffState>();
for (const row of rows) {
const state = successfulRunHandoffStateFromActivity(row);
if (state) states.set(row.entityId, state);
}
return states;
}
function executionPrincipalsEqual(
left: ParsedExecutionState["currentParticipant"] | null,
@ -1033,7 +1133,15 @@ export function issueRoutes(
limit,
offset,
});
res.json(result);
const handoffStates = await listSuccessfulRunHandoffStates(
db,
companyId,
result.map((issue) => issue.id),
);
res.json(result.map((issue) => ({
...issue,
successfulRunHandoff: handoffStates.get(issue.id) ?? null,
})));
});
router.get("/companies/:companyId/labels", async (req, res) => {
@ -1221,6 +1329,7 @@ export function issueRoutes(
blockerAttention,
productivityReview,
referenceSummary,
successfulRunHandoffStates,
] = await Promise.all([
resolveIssueProjectAndGoal(issue),
svc.getAncestors(issue.id),
@ -1230,6 +1339,7 @@ export function issueRoutes(
svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null),
svc.listProductivityReviews(issue.companyId, [issue.id]).then((map) => map.get(issue.id) ?? null),
issueReferencesSvc.listIssueReferenceSummary(issue.id),
listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id]),
]);
const mentionedProjects = mentionedProjectIds.length > 0
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
@ -1244,6 +1354,7 @@ export function issueRoutes(
ancestors,
...(blockerAttention ? { blockerAttention } : {}),
productivityReview,
successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null,
blockedBy: relations.blockedBy,
blocks: relations.blocks,
relatedWork: referenceSummary,
@ -3688,6 +3799,10 @@ export function issueRoutes(
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
runId: actor.runId,
}, {
authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"),
presentation: req.body.presentation ?? null,
metadata: req.body.metadata ?? null,
});
await issueReferencesSvc.syncComment(comment.id);
const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(currentIssue.id);