feat: make recovery updates quieter (#10542)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The recovery subsystem restores work after an agent run stops or
loses state
> - Recovery notices currently use the same visual weight as normal work
comments
> - Recovery agents can also post long narratives that obscure the
useful hand-off
> - The server must identify recovery output because agents cannot set
presentation controls
> - This pull request adds compact recovery notices, structured action
references, and brief recovery prompts
> - The benefit is a quieter issue thread that still keeps recovery
state inspectable

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting: `server/`, `packages/shared`, and
`packages/adapter-utils`.

**Problem or motivation**

Recovery notices and recovery-run comments can dominate an issue thread.
Operators must scan routine recovery narration before they find the work
hand-off.

**Proposed solution**

Give routine recovery output a compact system-notice presentation.
Derive the presentation on the server so agents cannot hide arbitrary
comments. Keep the successful missing-state summary fully visible
because that comment is the recovery deliverable.

**Alternatives considered**

The UI could detect recovery text. That approach is fragile and does not
provide structured action references. Agents could also set presentation
directly, but that would weaken the current board-only security
boundary.

**Roadmap alignment**

This change refines the completed “Self-healing runs & automatic
recovery” and “Enforced Outcomes” roadmap areas. It does not add a
competing roadmap capability.

**Additional context**

The scope covers shared comment validation, server recovery notices,
agent-comment derivation, and recovery prompt text. No database
migration is needed because presentation data already uses JSON.

## What Changed

- Add the `compact` issue-comment presentation density to shared
constants, types, and validation.
- Give recovery escalation, waiting, and in-place notices compact titles
and structured recovery-action metadata.
- Use recovery-action metadata for notice deduplication, with the legacy
text marker as a compatibility fallback.
- Derive compact presentation for comments from recovery-scoped runs
while preserving the board-only presentation boundary.
- Keep successful missing-state recovery summaries fully visible.
- Ask recovery participants to record outcomes in `resolutionNote` and
keep source-issue comments brief.
- Add shared, route, service, and prompt tests for the new behavior and
exceptions.

## Verification

- `pnpm -r typecheck`
- Focused Vitest coverage: 320 tests passed across shared validators,
adapter prompts, issue comments, recovery actions, and heartbeat
recovery.
- Full server phase: 292 files passed, 3,094 tests passed, and 2 tests
skipped.
- Full UI phase: 386 files passed and 3,182 tests passed.
- `pnpm build`
- Known master baseline: `cli/src/__tests__/secrets.test.ts` expects
`pass`, but the current implementation returns `warn` when strict secret
mode is disabled for Postgres. This branch does not change CLI secrets
code.

## Risks

- Low migration risk. The presentation column is JSON and needs no
database migration.
- Recovery-run detection depends on the persisted run context snapshot.
- Structured metadata becomes the primary deduplication key. The
existing body marker remains as a fallback for older comments.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex with `gpt-5.6-sol`. The runtime did not expose the
context-window size. The model used agentic reasoning, repository tools,
code execution, and 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
#` / `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 <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dotta 2026-07-31 09:55:01 -07:00 committed by GitHub
parent 131d476a7e
commit b4a7a12985
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 793 additions and 16 deletions

View File

@ -973,6 +973,48 @@ describe("renderPaperclipWakePrompt", () => {
expect(prompt).toContain("- recovery attempt: 2/3");
expect(prompt).toContain("- next action: Restore the execution path.");
expect(prompt).not.toContain("Execution contract: take concrete action");
if (cause === "successful_run_missing_state") {
expect(prompt).not.toContain("Any comment you post on the source issue must be ≤3 lines");
} else {
expect(prompt).toContain("Record the outcome in the resolve call's `resolutionNote`");
expect(prompt).toContain("Any comment you post on the source issue must be ≤3 lines");
expect(prompt).toContain("No headings, no run-by-run narrative.");
}
});
it("asks process-loss retries to lead with the work instead of narrating recovery", () => {
const prompt = renderPaperclipWakePrompt({
reason: "source_scoped_recovery_action",
issue: { id: "issue-1", identifier: "PAP-14092", title: "Recover work", status: "blocked" },
recovery: {
cause: "process_lost",
failureSummary: "adapter stopped",
originalAssignee: { id: "agent-1", name: "Coder" },
attemptCount: 1,
nextAction: "Restore the execution path.",
},
commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 },
comments: [],
fallbackFetchNeeded: false,
});
expect(prompt).toContain(
"Do not narrate the recovery in your next comment — at most one short sentence; lead with the work.",
);
});
it("asks restored source owners to lead with work instead of narrating recovery", () => {
const prompt = renderPaperclipWakePrompt({
reason: "issue_recovery_action_restored",
issue: { id: "issue-1", identifier: "PAP-14092", title: "Continue work", status: "todo" },
commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 },
comments: [],
fallbackFetchNeeded: false,
});
expect(prompt).toContain(
"Do not narrate the recovery in your next comment — at most one short sentence; lead with the work.",
);
});
it("keeps exactly one execution contract in a composed fresh heartbeat prompt", () => {

View File

@ -1437,7 +1437,7 @@ export function renderPaperclipWakePrompt(
const recoveryInstruction = (() => {
switch (recovery?.cause) {
case "process_lost":
return `Your previous run on this issue was lost (${recovery.failureSummary ?? "no failure summary available"}). Try again — resume from durable progress; don't redo completed steps.`;
return `Your previous run on this issue was lost (${recovery.failureSummary ?? "no failure summary available"}). Try again — resume from durable progress; don't redo completed steps. Do not narrate the recovery in your next comment — at most one short sentence; lead with the work.`;
case "successful_run_missing_state":
case "successful_run_missing_issue_disposition":
return "Your run completed but left no final disposition. Post a comment summarizing the state and set the correct disposition (`done` / `in_review` / `blocked` / `in_progress` with a live path). Do not start new work.";
@ -1480,6 +1480,10 @@ export function renderPaperclipWakePrompt(
? [
"Recovery contract: your job is to RECOVER this task, not to do the work. Do not produce the deliverable yourself.",
`Cause-specific instruction: ${recoveryInstruction}`,
...(recovery?.cause === "successful_run_missing_state" ||
recovery?.cause === "successful_run_missing_issue_disposition"
? []
: ["Record the outcome in the resolve call's `resolutionNote`. Any comment you post on the source issue must be ≤3 lines (cause → what you did → hand-back). No headings, no run-by-run narrative."]),
`Fallback preference order: (1) send back to ${originalAssigneeLabel} with a retry instruction; (2) fix the runtime/adapter/workspace problem, then send it back; (3) reassign to another agent with the right specialty; (4) convert to an explicit manual-review state for the board.`,
"",
]
@ -1511,6 +1515,9 @@ export function renderPaperclipWakePrompt(
: []),
]
: []),
...(normalized.reason === "issue_recovery_action_restored"
? ["- instruction: Do not narrate the recovery in your next comment — at most one short sentence; lead with the work."]
: []),
];
const lines = resumedSession
? [

View File

@ -235,6 +235,9 @@ export type IssueCommentPresentationKind = (typeof ISSUE_COMMENT_PRESENTATION_KI
export const ISSUE_COMMENT_PRESENTATION_TONES = ["neutral", "info", "success", "warning", "danger"] as const;
export type IssueCommentPresentationTone = (typeof ISSUE_COMMENT_PRESENTATION_TONES)[number];
export const ISSUE_COMMENT_PRESENTATION_DENSITIES = ["compact"] as const;
export type IssueCommentPresentationDensity = (typeof ISSUE_COMMENT_PRESENTATION_DENSITIES)[number];
export const ISSUE_COMMENT_METADATA_ROW_TYPES = [
"text",
"code",

View File

@ -191,6 +191,7 @@ export {
ISSUE_COMMENT_METADATA_ROW_TYPES,
ISSUE_COMMENT_PRESENTATION_KINDS,
ISSUE_COMMENT_PRESENTATION_TONES,
ISSUE_COMMENT_PRESENTATION_DENSITIES,
clampIssueRequestDepth,
ISSUE_THREAD_INTERACTION_KINDS,
ISSUE_THREAD_INTERACTION_STATUSES,
@ -375,6 +376,7 @@ export {
type IssueCommentMetadataRowType,
type IssueCommentPresentationKind,
type IssueCommentPresentationTone,
type IssueCommentPresentationDensity,
type IssueThreadInteractionKind,
type IssueThreadInteractionStatus,
type IssueThreadInteractionContinuationPolicy,

View File

@ -3,6 +3,7 @@ import type {
IssueCommentMetadataRowType,
IssueCommentPresentationKind,
IssueCommentPresentationTone,
IssueCommentPresentationDensity,
IssueExecutionMonitorClearReason,
IssueExecutionMonitorKind,
IssueExecutionMonitorRecoveryPolicy,
@ -953,6 +954,7 @@ export interface IssueCommentPresentation {
tone: IssueCommentPresentationTone;
title?: string | null;
detailsDefaultOpen: boolean;
density?: IssueCommentPresentationDensity;
}
export interface IssueThreadInteractionActorFields {

View File

@ -216,6 +216,7 @@ describe("issue validators", () => {
kind: "system_notice",
tone: "warning",
title: "Needs disposition",
density: "compact",
},
metadata: {
version: 1,
@ -234,10 +235,22 @@ describe("issue validators", () => {
});
expect(parsed.presentation?.detailsDefaultOpen).toBe(false);
expect(parsed.presentation?.density).toBe("compact");
expect(parsed.metadata?.sourceRunId).toBe("11111111-1111-4111-8111-111111111111");
expect(parsed.metadata?.sections[0]?.rows).toHaveLength(3);
});
it("rejects unknown issue comment presentation densities", () => {
expect(addIssueCommentSchema.safeParse({
body: "Hidden details",
presentation: {
kind: "system_notice",
tone: "warning",
density: "condensed",
},
}).success).toBe(false);
});
it("rejects arbitrary issue comment metadata", () => {
const parsed = addIssueCommentSchema.safeParse({
body: "Hidden details",

View File

@ -12,6 +12,7 @@ import {
ISSUE_COMMENT_METADATA_ROW_TYPES,
ISSUE_COMMENT_PRESENTATION_KINDS,
ISSUE_COMMENT_PRESENTATION_TONES,
ISSUE_COMMENT_PRESENTATION_DENSITIES,
ISSUE_HARNESS_KINDS,
ISSUE_MONITOR_SCHEDULED_BY,
ISSUE_PRIORITIES,
@ -563,6 +564,7 @@ export const issueCommentPresentationSchema = z.object({
tone: z.enum(ISSUE_COMMENT_PRESENTATION_TONES).default("neutral"),
title: z.string().trim().min(1).max(160).nullable().optional(),
detailsDefaultOpen: z.boolean().optional().default(false),
density: z.enum(ISSUE_COMMENT_PRESENTATION_DENSITIES).optional(),
}).strict();
export type IssueCommentPresentation = z.infer<typeof issueCommentPresentationSchema>;

View File

@ -2039,6 +2039,20 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(comments[0]?.body).toContain("recovery issues do not create nested `stranded_issue_recovery` issues");
expect(comments[0]?.body).toContain("Latest retry failure details were withheld from the issue thread");
expect(comments[0]?.body).not.toContain("sk-test-recovery-secret");
expect(comments[0]?.presentation).toMatchObject({
kind: "system_notice",
tone: "warning",
title: "Recovery: recovery attempt failed — remains blocked",
density: "compact",
});
expect(comments[0]?.metadata).toMatchObject({
version: 1,
sections: [expect.objectContaining({
rows: expect.arrayContaining([
expect.objectContaining({ type: "key_value", label: "Cause", value: "recovery_issue_failed" }),
]),
})],
});
await expect(sourceBlockerIssueIds(companyId, sourceIssueId)).resolves.toEqual([issueId]);
});
@ -3134,6 +3148,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
tone: "danger",
detailsDefaultOpen: false,
});
expect(comments[0]?.presentation).not.toHaveProperty("density");
expect(comments[0]?.metadata).toMatchObject({
version: 1,
sections: expect.arrayContaining([
@ -3287,6 +3302,24 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(comments[0]?.body).not.toContain(`${issuePrefix}-12`);
// Plain language — the raw machine error code never leaks into the thread.
expect(comments[0]?.body).not.toContain("issue_continuation_waiting_on_review");
expect(comments[0]?.presentation).toMatchObject({
kind: "system_notice",
tone: "warning",
title: "Recovery: waiting on dependencies — moved to blocked",
density: "compact",
});
expect(comments[0]?.metadata).toMatchObject({
version: 1,
sections: [expect.objectContaining({
rows: expect.arrayContaining([
expect.objectContaining({
type: "key_value",
label: "Cause",
value: "continuation_waiting_on_review",
}),
]),
})],
});
const activity = await db.select().from(activityLog).where(eq(activityLog.entityId, issueId));
expect(
@ -4849,6 +4882,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
runErrorCode: "process_lost",
runError: "Authorization: Bearer sk-test-recovery-secret",
});
const longRecoveryOwnerName = "R".repeat(161);
await db.update(agents).set({ name: longRecoveryOwnerName }).where(eq(agents.id, agentId));
const heartbeat = heartbeatService(db);
const result = await heartbeat.reconcileStrandedAssignedIssues();
@ -4875,7 +4910,23 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(comments[0]?.body).toContain("retried dispatch");
expect(comments[0]?.body).toContain("Latest retry failure details were withheld from the issue thread");
expect(comments[0]?.body).toContain(`Recovery action: \`${recoveryAction.id}\``);
expect(comments[0]?.body).toContain("Recovery owner: [CodexCoder]");
expect(comments[0]?.body).toContain(`Recovery owner: [${longRecoveryOwnerName}]`);
expect(comments[0]?.presentation).toMatchObject({
kind: "system_notice",
tone: "warning",
title: `${`Recovery: retries exhausted — moved to blocked (owner: ${longRecoveryOwnerName})`.slice(0, 159)}`,
density: "compact",
});
expect(comments[0]?.metadata).toMatchObject({
version: 1,
sections: [expect.objectContaining({
rows: expect.arrayContaining([
expect.objectContaining({ type: "key_value", label: "Recovery action", value: recoveryAction.id }),
expect.objectContaining({ type: "key_value", label: "Cause", value: "process_lost" }),
expect.objectContaining({ type: "agent_link", label: "Recovery owner", name: "R".repeat(160) }),
]),
})],
});
});
it("blocks an already stranded recovery issue without creating a recovery child", async () => {
@ -4953,6 +5004,13 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(comments[0]?.body).toContain("recovery issues do not create nested `stranded_issue_recovery` issues");
expect(comments[0]?.body).toContain(`Recovery issue: [${recoveryIssues[0]?.identifier}]`);
expect(comments[0]?.body).toContain("Next action:");
expect(comments[0]?.presentation).toMatchObject({
kind: "system_notice",
tone: "warning",
title: "Recovery: recovery attempt failed — remains blocked",
density: "compact",
});
expect(comments[0]?.metadata).toMatchObject({ version: 1 });
});
it("assigns open unassigned blockers back to their creator agent", async () => {

View File

@ -1042,6 +1042,87 @@ describe.sequential("issue comment reopen routes", () => {
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it("derives compact presentation for comments from source-scoped recovery runs", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("in_progress"));
mockDbSelectWhere.mockImplementation(() => ({
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{
id: "run-1",
companyId: "company-1",
agentId: "22222222-2222-4222-8222-222222222222",
contextSnapshot: {
wakeReason: "source_scoped_recovery_action",
recoveryCause: "process_lost",
},
}]).then(onFulfilled, onRejected),
}));
const res = await request(await installActor(createApp(), agentActor()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "Recovered the execution path.\nHanded back to the original owner." });
expect(res.status).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"Recovered the execution path.\nHanded back to the original owner.",
{ agentId: "22222222-2222-4222-8222-222222222222", userId: undefined, runId: "run-1" },
expect.objectContaining({
authorType: "agent",
presentation: {
kind: "system_notice",
tone: "info",
title: "Recovered the execution path.",
detailsDefaultOpen: false,
density: "compact",
},
}),
);
});
it("leaves normal agent comments without derived presentation", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("in_progress"));
const res = await request(await installActor(createApp(), agentActor()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "Normal work update." });
expect(res.status).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"Normal work update.",
{ agentId: "22222222-2222-4222-8222-222222222222", userId: undefined, runId: "run-1" },
expect.objectContaining({ presentation: null }),
);
});
it("keeps successful-run missing-state recovery comments fully visible", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("in_progress"));
mockDbSelectWhere.mockImplementation(() => ({
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{
id: "run-1",
companyId: "company-1",
agentId: "22222222-2222-4222-8222-222222222222",
contextSnapshot: {
wakeReason: "source_scoped_recovery_action",
recoveryCause: "successful_run_missing_state",
},
}]).then(onFulfilled, onRejected),
}));
const res = await request(await installActor(createApp(), agentActor()))
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "The run completed; here is the required summary." });
expect(res.status).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
"The run completed; here is the required summary.",
{ agentId: "22222222-2222-4222-8222-222222222222", userId: undefined, runId: "run-1" },
expect.objectContaining({ presentation: null }),
);
});
it("rejects invalid comment metadata before writing a comment", async () => {
const app = await installActor(createApp());
mockIssueService.getById.mockResolvedValue(makeIssue("todo"));

View File

@ -1019,6 +1019,12 @@ describeEmbeddedPostgres("issue recovery actions", () => {
comment: "Workspace failed validation.",
recoveryCause: "workspace_validation_failed",
});
// Prove dedupe uses the structured recovery-action reference rather than
// depending only on the legacy body marker.
await db
.update(issueComments)
.set({ body: "Workspace recovery was already escalated." })
.where(eq(issueComments.issueId, sourceIssue.id));
await recovery.escalateStrandedAssignedIssue({
issue: sourceIssue,
previousStatus: "in_progress",
@ -1061,7 +1067,22 @@ describeEmbeddedPostgres("issue recovery actions", () => {
});
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, sourceIssue.id));
expect(comments.filter((comment) => comment.body.includes(`Recovery action: \`${actionRows[0]?.id}\``))).toHaveLength(1);
expect(comments).toHaveLength(1);
expect(comments[0]?.presentation).toMatchObject({
kind: "system_notice",
tone: "warning",
title: "Recovery: workspace validation failed — moved to blocked (owner: CTO)",
density: "compact",
});
expect(comments[0]?.metadata).toMatchObject({
version: 1,
sections: [expect.objectContaining({
rows: expect.arrayContaining([
expect.objectContaining({ type: "key_value", label: "Recovery action", value: actionRows[0]?.id }),
expect.objectContaining({ type: "key_value", label: "Cause", value: "workspace_validation_failed" }),
]),
})],
});
expect(enqueueWakeup).toHaveBeenCalledTimes(2);
expect(enqueueWakeup).toHaveBeenCalledWith(
expect.any(String),

View File

@ -88,6 +88,7 @@ import {
type IssueWakeDiagnosticWakeRequest,
type IssueWakeDiagnosticsResponse,
type IssueRelationIssueSummary,
type IssueCommentPresentation,
type IssueWatchdogDiscoveryKind,
type ProjectWorkspace,
type SourceTrustMetadata,
@ -4035,6 +4036,53 @@ export function issueRoutes(
return run;
}
function readObject(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
async function deriveRecoveryCommentPresentation(
req: Request,
companyId: string,
body: string,
): Promise<IssueCommentPresentation | null> {
const run = await loadActorRunContext(req, companyId);
if (!run) return null;
const context = readObject(run.contextSnapshot);
const paperclipWake = readObject(context.paperclipWake);
const recovery = readObject(paperclipWake.recovery);
const wakeReason = typeof context.wakeReason === "string"
? context.wakeReason
: typeof paperclipWake.reason === "string"
? paperclipWake.reason
: null;
if (wakeReason !== "source_scoped_recovery_action") return null;
const recoveryCause = typeof context.recoveryCause === "string"
? context.recoveryCause
: typeof recovery.cause === "string"
? recovery.cause
: null;
if (
recoveryCause === "successful_run_missing_state" ||
recoveryCause === "successful_run_missing_issue_disposition"
) {
return null;
}
const firstLine = body.split(/\r?\n/, 1)[0]?.trim() || "Recovery update";
const title = firstLine.length > 160 ? `${firstLine.slice(0, 159)}` : firstLine;
return {
kind: "system_notice",
tone: "info",
title,
detailsDefaultOpen: false,
density: "compact",
};
}
async function assertCheapRecoveryIssueAssigneeProfileAllowed(
req: Request,
res: Response,
@ -9982,6 +10030,8 @@ export function issueRoutes(
}
const actor = getActorInfo(req);
const commentPresentation = req.body.presentation ??
await deriveRecoveryCommentPresentation(req, issue.companyId, req.body.body);
const reopenRequested = req.body.reopen === true;
const resumeRequested = req.body.resume === true;
const interruptRequested = req.body.interrupt === true;
@ -10196,7 +10246,7 @@ export function issueRoutes(
const sourceTrust = await sourceTrustForActorWrite(currentIssue, actor);
const commentOptions = {
authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"),
presentation: req.body.presentation ?? null,
presentation: commentPresentation,
metadata: req.body.metadata ?? null,
sourceTrust,
};
@ -10281,7 +10331,7 @@ export function issueRoutes(
runId: actor.runId,
}, {
authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"),
presentation: req.body.presentation ?? null,
presentation: commentPresentation,
metadata: req.body.metadata ?? null,
sourceTrust: await sourceTrustForActorWrite(currentIssue, actor),
});

View File

@ -5,6 +5,8 @@ import {
MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
PROVIDER_QUOTA_MONITOR_SERVICE_NAME,
type IssueCommentMetadata,
type IssueCommentPresentation,
type IssueGraphLivenessAutoRecoveryPreview,
type IssueGraphLivenessAutoRecoveryPreviewItem,
} from "@paperclipai/shared";
@ -164,6 +166,76 @@ type SuccessfulRunHandoffRecoveryEvidence = {
maxHandoffAttempts: number;
};
function compactRecoveryPresentation(title: string): IssueCommentPresentation {
const normalizedTitle = title.trim();
return {
kind: "system_notice",
tone: "warning",
title: normalizedTitle.length > 160 ? `${normalizedTitle.slice(0, 159)}` : normalizedTitle,
detailsDefaultOpen: false,
density: "compact",
};
}
function recoveryCauseTitle(cause: StrandedRecoveryCause) {
switch (cause) {
case "process_lost":
return "retries exhausted";
case "codex_output_inactivity_monitor":
return "output-inactivity retry exhausted";
case "workspace_validation_failed":
return "workspace validation failed";
case "configuration_incomplete":
return "configuration incomplete";
case "execution_review_participant_recovery":
return "reviewer recovery failed";
case "provider_quota":
return "provider quota unavailable";
case SUCCESSFUL_RUN_MISSING_STATE_REASON:
return "missing disposition recovery failed";
default:
return "execution path recovery failed";
}
}
function recoveryNoticeMetadata(input: {
cause: string;
latestRun: LatestIssueRun;
recoveryActionId?: string | null;
previousStatus: string;
recoveryOwner?: Pick<typeof agents.$inferSelect, "id" | "name"> | null;
}): IssueCommentMetadata {
const rows: IssueCommentMetadata["sections"][number]["rows"] = [
...(input.recoveryActionId
? [{ type: "key_value" as const, label: "Recovery action", value: input.recoveryActionId }]
: []),
{ type: "key_value", label: "Cause", value: input.cause },
{ type: "key_value", label: "Previous status", value: input.previousStatus },
...(input.recoveryOwner
? [{
type: "agent_link" as const,
label: "Recovery owner",
agentId: input.recoveryOwner.id,
name: input.recoveryOwner.name.slice(0, 160),
}]
: [{ type: "key_value" as const, label: "Recovery owner", value: "board" }]),
...(input.latestRun
? [{
type: "run_link" as const,
label: "Latest run",
runId: input.latestRun.id,
title: input.latestRun.status,
}]
: []),
];
return {
version: 1,
sourceRunId: input.latestRun?.id ?? null,
sections: [{ title: "Recovery", rows }],
};
}
function readRecoveryRunErrorFamily(latestRun: LatestIssueRun) {
const result = parseObject(latestRun?.resultJson);
return readNonEmptyString(result.errorFamily);
@ -3028,6 +3100,29 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
prefix,
}),
{},
{
authorType: "system",
presentation: compactRecoveryPresentation("Recovery: recovery attempt failed — remains blocked"),
metadata: {
version: 1,
sourceRunId: input.latestRun?.id ?? null,
sections: [{
title: "Recovery",
rows: [
{ type: "key_value", label: "Cause", value: "recovery_issue_failed" },
{ type: "key_value", label: "Previous status", value: input.previousStatus },
...(input.latestRun
? [{
type: "run_link" as const,
label: "Latest run",
runId: input.latestRun.id,
title: input.latestRun.status,
}]
: []),
],
}],
},
},
);
await logActivity(db, {
@ -3121,7 +3216,25 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
"(It was paused because the latest run reported it was waiting for review/approval; " +
"Paperclip turned that into a normal dependency wait instead of flagging it as stuck.)",
{},
{ authorType: "system" },
{
authorType: "system",
presentation: compactRecoveryPresentation("Recovery: waiting on dependencies — moved to blocked"),
metadata: {
version: 1,
sections: [{
title: "Recovery",
rows: [
{ type: "key_value", label: "Cause", value: "continuation_waiting_on_review" },
{ type: "key_value", label: "Previous status", value: issue.status },
{
type: "key_value",
label: "Blocking issues",
value: blockedByIssueIds.join(", ").slice(0, 2000),
},
],
}],
},
},
);
await logActivity(db, {
companyId: issue.companyId,
@ -3242,8 +3355,8 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
.orderBy(desc(issueComments.createdAt))
.limit(50)
.then((rows) => rows.some((row) =>
(row.body ?? "").includes(escalationCommentMarker) ||
noticeMetadataReferencesRecoveryAction(row.metadata, recoveryAction.id),
noticeMetadataReferencesRecoveryAction(row.metadata, recoveryAction.id) ||
(row.body ?? "").includes(escalationCommentMarker),
));
if (!hasEscalationComment) {
@ -3256,6 +3369,17 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
} else {
await issuesSvc.addComment(input.issue.id, `${input.comment ?? ""}${recoveryLine}`, {}, {
authorType: "system",
presentation: compactRecoveryPresentation(
`Recovery: ${recoveryCauseTitle(recoveryCause)} — moved to blocked ` +
`(owner: ${recoveryOwner?.name ?? "board"})`,
),
metadata: recoveryNoticeMetadata({
cause: recoveryCause,
latestRun: input.latestRun,
recoveryActionId: recoveryAction.id,
previousStatus: input.previousStatus,
recoveryOwner,
}),
});
}
}

View File

@ -145,10 +145,13 @@ import {
SystemNotice,
type SystemNoticeMetadataRow,
type SystemNoticeMetadataSection,
type SystemNoticeProps,
type SystemNoticeTone,
} from "./SystemNotice";
import {
buildSystemNoticeProps,
mapCommentMetadataToSystemNoticeSections,
systemNoticeLabelForTone,
} from "../lib/system-notice-comment";
import type {
IssueCommentMetadata,
@ -2558,6 +2561,82 @@ function StaleDispositionWarningRow({
);
}
// Tone-colored dot for the fully-collapsed compact notice row. Tone is never
// conveyed by color alone — the adjacent title text names the notice.
const COMPACT_TONE_DOT: Record<SystemNoticeTone, string> = {
neutral: "bg-muted-foreground/40",
info: "bg-sky-500 dark:bg-sky-400",
success: "bg-emerald-500 dark:bg-emerald-400",
warning: "bg-amber-500 dark:bg-amber-400",
danger: "bg-red-500 dark:bg-red-400",
};
// A system notice whose presentation opts into `density: "compact"` collapses to
// a single quiet row — tone dot + title (+ author) + timestamp + chevron.
// Expanding reveals the full SystemNotice card (body + details), so no
// information is lost. Generalized from the StaleDispositionWarningRow precedent.
function CompactSystemNoticeRow({
anchorId,
message,
tone,
title,
source,
noticeProps,
defaultOpen = false,
}: {
anchorId?: string;
message: ThreadMessage;
tone: SystemNoticeTone;
title: string;
source?: SystemNoticeProps["source"];
noticeProps: SystemNoticeProps;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
const detailsId = useId();
return (
<div id={anchorId} data-testid="compact-system-notice" className="group">
<div className="flex items-start gap-2.5 py-1.5">
<span className="size-6 shrink-0" aria-hidden />
<div className="min-w-0 flex-1">
<button
type="button"
aria-expanded={open}
aria-controls={detailsId}
className="-mx-1 flex w-full items-center gap-2 rounded-md px-1 py-0.5 text-left transition-colors hover:bg-accent/5"
onClick={() => setOpen((value) => !value)}
>
<span
className={cn("size-1.5 shrink-0 rounded-full", COMPACT_TONE_DOT[tone])}
aria-hidden
/>
<span className="truncate text-sm font-medium text-foreground/80">{title}</span>
{source ? (
<span className="truncate text-(length:--text-micro) text-muted-foreground">
· {source.label}
</span>
) : null}
{/* Trailing meta never shrinks keeps the timestamp on one line so the
collapsed row stays a single quiet line on narrow / mobile widths. */}
<span className="ml-auto flex shrink-0 items-center gap-1.5">
{message.createdAt ? (
<span data-testid="compact-system-notice-time" className="whitespace-nowrap text-(length:--text-micro) text-muted-foreground/50">
{commentDateLabel(message.createdAt)}
</span>
) : null}
<ChevronDown className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground/40 transition-transform group-hover:text-muted-foreground/70", open && "rotate-180")} />
</span>
</button>
<div id={detailsId} hidden={!open} className="py-1">
<SystemNotice {...noticeProps} />
</div>
</div>
</div>
</div>
);
}
function SystemNoticeCommentRow({
message,
anchorId,
@ -2654,6 +2733,24 @@ function SystemNoticeCommentRow({
);
}
// Compact presentation collapses the notice to a single quiet row. Notices
// without `density` (old comments / old data) keep today's full card.
if (presentation?.density === "compact") {
const tone = presentation.tone ?? "neutral";
const title = systemNoticeLabelForTone(tone, presentation.title);
return (
<CompactSystemNoticeRow
anchorId={anchorId}
message={message}
tone={tone}
title={title}
source={source}
noticeProps={props}
defaultOpen={Boolean(presentation.detailsDefaultOpen)}
/>
);
}
return (
<div id={anchorId} className="group">
<div className="py-1">

View File

@ -391,7 +391,9 @@ describe("IssueChatThread system notice routing", () => {
expect(sourceLink?.textContent).toBe("Paperclip");
});
it("keeps agent-authored comments as assistant bubbles even when presentation requests system_notice", () => {
it("routes agent-authored comments to the notice renderer when presentation requests system_notice", () => {
const owner = { id: "agent-1", name: "ClaudeCoder" } as unknown as Agent;
const agentMap = new Map<string, Agent>([[owner.id, owner]]);
const comment: IssueChatComment = {
id: "comment-agent-system",
companyId: "company-1",
@ -399,11 +401,166 @@ describe("IssueChatThread system notice routing", () => {
authorType: "agent",
authorAgentId: "agent-1",
authorUserId: null,
runId: "run-owner",
runAgentId: "agent-1",
body: "Reassigned to ClaudeFixer.",
presentation: {
kind: "system_notice",
tone: "neutral",
title: null,
title: "Recovery reassignment",
detailsDefaultOpen: false,
},
metadata: null,
...baseTimestamps,
};
renderThread([comment], { agentMap });
// No longer an assistant bubble — it collapses like a system notice.
expect(container.querySelector('[data-message-role="assistant"]')).toBeNull();
const row = container.querySelector('[data-message-role="system"]');
expect(row).not.toBeNull();
const status = row?.querySelector('[role="status"]');
expect(status?.getAttribute("aria-label")).toBe("Recovery reassignment");
// The real author's name + run link stay visible on the notice.
const sourceLink = status?.querySelector('a[href^="/agents/"]') as HTMLAnchorElement | null;
expect(sourceLink?.getAttribute("href")).toBe("/agents/agent-1/runs/run-owner");
expect(sourceLink?.textContent).toBe("ClaudeCoder");
});
it("collapses compact system notices to a single quiet row and expands to the full card", () => {
const comment: IssueChatComment = {
id: "comment-compact",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
runId: "run-recovery",
runAgentId: "agent-codex",
body: "Recovery escalated to the CTO after repeated stalls.",
presentation: {
kind: "system_notice",
tone: "warning",
title: "Recovery escalated",
detailsDefaultOpen: false,
density: "compact",
},
metadata: {
version: 1,
sections: [
{
title: "Escalation",
rows: [{ type: "agent_link", label: "Owner", agentId: "agent-cto", name: "CTO" }],
},
],
},
...baseTimestamps,
};
renderThread([comment]);
const row = container.querySelector('[data-testid="compact-system-notice"]');
expect(row).not.toBeNull();
// Collapsed: title + timestamp + chevron visible, but body/card hidden.
expect(row?.textContent).toContain("Recovery escalated");
expect(row?.querySelector('[data-testid="compact-system-notice-time"]')).not.toBeNull();
expect(row?.querySelector(".lucide-chevron-down")).not.toBeNull();
const toggle = row?.querySelector("button[aria-expanded]") as HTMLButtonElement;
expect(toggle.getAttribute("aria-expanded")).toBe("false");
const detailsId = toggle.getAttribute("aria-controls");
const details = detailsId ? container.ownerDocument.getElementById(detailsId) : null;
expect(details).not.toBeNull();
expect(details).toHaveProperty("hidden", true);
// The full SystemNotice card lives inside the collapsed region.
expect(details?.querySelector('[role="status"]')).not.toBeNull();
act(() => {
toggle.click();
});
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect(details).toHaveProperty("hidden", false);
expect(details?.textContent).toContain("Recovery escalated to the CTO");
});
it("renders a compact notice already expanded when detailsDefaultOpen is true", () => {
const comment: IssueChatComment = {
id: "comment-compact-open",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
body: "Recovery escalated to the CTO.",
presentation: {
kind: "system_notice",
tone: "danger",
title: "Recovery escalated",
detailsDefaultOpen: true,
density: "compact",
},
metadata: null,
...baseTimestamps,
};
renderThread([comment]);
const row = container.querySelector('[data-testid="compact-system-notice"]');
expect(row).not.toBeNull();
const toggle = row?.querySelector("button[aria-expanded]") as HTMLButtonElement;
expect(toggle.getAttribute("aria-expanded")).toBe("true");
const detailsId = toggle.getAttribute("aria-controls");
const details = detailsId ? container.ownerDocument.getElementById(detailsId) : null;
expect(details).toHaveProperty("hidden", false);
expect(details?.querySelector('[role="status"]')).not.toBeNull();
expect(details?.textContent).toContain("Recovery escalated to the CTO.");
});
it("keeps the author visible on a compact agent-authored notice row", () => {
const owner = { id: "agent-1", name: "ClaudeCoder" } as unknown as Agent;
const agentMap = new Map<string, Agent>([[owner.id, owner]]);
const comment: IssueChatComment = {
id: "comment-compact-agent",
companyId: "company-1",
issueId: "issue-1",
authorType: "agent",
authorAgentId: "agent-1",
authorUserId: null,
body: "Picked this back up after the wake.",
presentation: {
kind: "system_notice",
tone: "neutral",
title: "Recovery owner update",
detailsDefaultOpen: false,
density: "compact",
},
metadata: null,
...baseTimestamps,
};
renderThread([comment], { agentMap });
const row = container.querySelector('[data-testid="compact-system-notice"]');
expect(row).not.toBeNull();
expect(row?.textContent).toContain("Recovery owner update");
// Author name shows on the collapsed row itself.
expect(row?.querySelector("button[aria-expanded]")?.textContent).toContain("ClaudeCoder");
});
it("keeps notices without density rendering as the full card (graceful fallback)", () => {
const comment: IssueChatComment = {
id: "comment-no-density",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
body: "Recovery completed.",
presentation: {
kind: "system_notice",
tone: "success",
title: "Recovery completed",
detailsDefaultOpen: false,
},
metadata: null,
@ -412,8 +569,11 @@ describe("IssueChatThread system notice routing", () => {
renderThread([comment]);
expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector('[data-message-role="assistant"]')).not.toBeNull();
expect(container.querySelector('[data-testid="compact-system-notice"]')).toBeNull();
const status = container.querySelector('[role="status"]');
expect(status?.getAttribute("aria-label")).toBe("Recovery completed");
// Body is visible immediately on the full card.
expect(status?.textContent).toContain("Recovery completed.");
});
it("folds stale successful-run disposition warnings into the activity log disclosure style", () => {

View File

@ -493,11 +493,19 @@ function createCommentMessage(args: {
}): ThreadMessage {
const { comment, agentMap, currentUserId, userLabelMap, companyId, projectId } = args;
const createdAt = toDate(comment.createdAt);
const isSystemNotice = comment.authorType === "system";
const isSystemAuthor = comment.authorType === "system";
// Presentation can route a comment to the system-notice renderer even when it
// is agent-authored (e.g. a recovery owner's short status update), letting it
// collapse like a system notice while keeping the real author's name/link.
// Comments without a presentation keep today's routing (graceful fallback for
// old data both directions).
const renderAsSystemNotice = isSystemAuthor || comment.presentation?.kind === "system_notice";
const authorAgentId = effectiveCommentAuthorAgentId(comment);
const authorName = authorNameForComment(comment, agentMap, currentUserId, userLabelMap, { isSystemNotice });
const authorName = authorNameForComment(comment, agentMap, currentUserId, userLabelMap, {
isSystemNotice: isSystemAuthor,
});
const custom = {
kind: isSystemNotice ? "system_notice" : "comment",
kind: renderAsSystemNotice ? "system_notice" : "comment",
commentId: comment.id,
anchorId: `comment-${comment.id}`,
authorName,
@ -525,7 +533,7 @@ function createCommentMessage(args: {
};
const contentText = comment.deletedAt ? "" : comment.body;
if (isSystemNotice) {
if (renderAsSystemNotice) {
const message: ThreadSystemMessage = {
id: comment.id,
role: "system",

View File

@ -887,6 +887,109 @@ function IssueThreadNoticeReview() {
);
}
// PAP-15871 — compact recovery notices collapse to a single quiet row and
// expand to the full SystemNotice card. `detailsDefaultOpen` seeds the expanded
// state so both states are visible in a static screenshot.
function compactRecoveryComments(expanded: boolean): IssueChatComment[] {
return [
createComment({
id: `comment-compact-harness-${expanded ? "open" : "closed"}`,
authorType: "system",
authorAgentId: null,
authorUserId: null,
runId: "run-recovery-source",
runAgentId: codexAgent.id,
body: "Recovery escalated this issue to the CTO after three stalled runs.",
presentation: {
kind: "system_notice",
tone: "warning",
title: "Recovery escalated to CTO",
detailsDefaultOpen: expanded,
density: "compact",
},
metadata: {
version: 1,
sourceRunId: "run-recovery-source",
sections: [
{
title: "Escalation",
rows: [
{ type: "agent_link", label: "Owner", agentId: codexAgent.id, name: codexAgent.name },
{ type: "run_link", label: "Last run", runId: "run-recovery-source", title: "process_lost" },
{ type: "key_value", label: "Stalled attempts", value: "3" },
],
},
],
},
createdAt: new Date("2026-04-20T14:10:00.000Z"),
}),
createComment({
id: `comment-compact-agent-${expanded ? "open" : "closed"}`,
authorAgentId: codexAgent.id,
authorUserId: null,
runId: "run-recovery-owner",
runAgentId: codexAgent.id,
body: "Picked this back up after the wake — re-running the failing migration now.",
presentation: {
kind: "system_notice",
tone: "neutral",
title: "Recovery owner update",
detailsDefaultOpen: expanded,
density: "compact",
},
metadata: null,
createdAt: new Date("2026-04-20T14:12:00.000Z"),
}),
];
}
function CompactRecoveryNoticeReview() {
return (
<div className="paperclip-story">
<main className="paperclip-story__inner max-w-4xl space-y-6">
<Section eyebrow="IssueChatThread" title="Compact recovery notices — collapsed">
<div className="rounded-lg border border-border bg-background/70 p-4">
<IssueChatThread
comments={compactRecoveryComments(false)}
timelineEvents={[]}
linkedRuns={[]}
liveRuns={[]}
companyId={companyId}
projectId={projectId}
issueStatus="in_progress"
agentMap={storybookAgentMap}
currentUserId={currentUserId}
userLabelMap={boardUserLabels}
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
showJumpToLatest={false}
/>
</div>
</Section>
<Section eyebrow="IssueChatThread" title="Compact recovery notices — expanded">
<div className="rounded-lg border border-border bg-background/70 p-4">
<IssueChatThread
comments={compactRecoveryComments(true)}
timelineEvents={[]}
linkedRuns={[]}
liveRuns={[]}
companyId={companyId}
projectId={projectId}
issueStatus="in_progress"
agentMap={storybookAgentMap}
currentUserId={currentUserId}
userLabelMap={boardUserLabels}
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
showJumpToLatest={false}
/>
</div>
</Section>
</main>
</div>
);
}
function ChatCommentsStories() {
return (
<div className="paperclip-story">
@ -961,3 +1064,7 @@ export const IssueChatWithTimeline: Story = {
export const IssueThreadNotices: Story = {
render: () => <IssueThreadNoticeReview />,
};
export const CompactRecoveryNotices: Story = {
render: () => <CompactRecoveryNoticeReview />,
};