diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 36c8bc309a..cf79698b3f 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -992,6 +992,7 @@ export interface IssueCommentMetadataAgentLinkRow extends IssueCommentMetadataRo export interface IssueCommentMetadataRunLinkRow extends IssueCommentMetadataRowBase { type: "run_link"; runId: string; + agentId?: string | null; title?: string | null; } diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index 762d4538bf..20161a2094 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -277,7 +277,12 @@ describe("issue validators", () => { rows: [ { type: "key_value", label: "Cause", value: "successful_run_missing_state" }, { type: "issue_link", label: "Source issue", identifier: "PAP-3440" }, - { type: "run_link", label: "Run", runId: "11111111-1111-4111-8111-111111111111" }, + { + type: "run_link", + label: "Run", + runId: "11111111-1111-4111-8111-111111111111", + agentId: "22222222-2222-4222-8222-222222222222", + }, ], }, ], @@ -288,6 +293,10 @@ describe("issue validators", () => { expect(parsed.presentation?.density).toBe("compact"); expect(parsed.metadata?.sourceRunId).toBe("11111111-1111-4111-8111-111111111111"); expect(parsed.metadata?.sections[0]?.rows).toHaveLength(3); + expect(parsed.metadata?.sections[0]?.rows[2]).toMatchObject({ + type: "run_link", + agentId: "22222222-2222-4222-8222-222222222222", + }); }); it("rejects unknown issue comment presentation densities", () => { diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index b67d7e1ae6..557519c213 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -631,6 +631,7 @@ const issueCommentMetadataAgentLinkRowSchema = issueCommentMetadataBaseRowSchema const issueCommentMetadataRunLinkRowSchema = issueCommentMetadataBaseRowSchema.extend({ type: z.literal("run_link"), runId: z.string().uuid(), + agentId: z.string().uuid().nullable().optional(), title: z.string().trim().min(1).max(160).nullable().optional(), }).strict(); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 789c28552d..84ba03a1b5 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -116,6 +116,7 @@ import { SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY, SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY, SUCCESSFUL_RUN_MISSING_STATE_REASON, + noticeMetadataReferencesRecoveryAction, } from "../services/recovery/index.ts"; import { UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON, @@ -130,6 +131,11 @@ if (!embeddedPostgresSupport.supported) { ); } +function commentMetadataRows(comment: { metadata?: unknown } | null | undefined) { + const metadata = comment?.metadata as { sections?: Array<{ rows?: unknown[] }> } | null | undefined; + return (metadata?.sections ?? []).flatMap((section) => section.rows ?? []) as Array>; +} + function spawnAliveProcess() { return spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore", @@ -2489,8 +2495,11 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }); expect(comments).toHaveLength(1); expect(comments[0]?.body).toContain("retried continuation"); - expect(comments[0]?.body).toContain(`Recovery action: \`${recoveryAction.id}\``); - expect(comments[0]?.body).toContain("Recovery owner: [CodexCoder]"); + expect(comments[0]?.presentation).toMatchObject({ kind: "system_notice", tone: "danger" }); + expect(noticeMetadataReferencesRecoveryAction(comments[0]?.metadata, recoveryAction.id)).toBe(true); + expect(commentMetadataRows(comments[0]).some((row) => + row.type === "agent_link" && row.label === "Recovery owner" && row.name === "CodexCoder", + )).toBe(true); }); it("blocks failed recovery work in place during immediate terminal-run cleanup", async () => { @@ -2565,6 +2574,7 @@ 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(JSON.stringify(comments[0]?.metadata)).not.toContain("sk-test-recovery-secret"); expect(comments[0]?.presentation).toMatchObject({ kind: "system_notice", tone: "warning", @@ -2944,7 +2954,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { authorType: "system", body: expect.stringContaining("Agent failed to resume after approval: `adapter_failed` — needs attention"), }); - expect(comments[0]?.body).toContain("Recovery action:"); + expect(commentMetadataRows(comments[0]).some((row) => row.label === "Recovery action")).toBe(true); const interaction = await db .select({ result: issueThreadInteractions.result }) @@ -4697,7 +4707,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); const recoveryComment = comments.find((comment) => comment.body.includes("pending execution-review participant once") && - comment.body.includes(`Recovery action: \`${recoveryAction.id}\``), + noticeMetadataReferencesRecoveryAction(comment.metadata, recoveryAction.id), ); expect(recoveryComment).toBeTruthy(); @@ -5511,25 +5521,18 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); expect(comments).toHaveLength(1); 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: [${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) }), - ]), - })], - }); + expect(comments[0]?.body).not.toContain("sk-test-recovery-secret"); + expect(JSON.stringify(comments[0]?.metadata)).not.toContain("sk-test-recovery-secret"); + const failureSummary = commentMetadataRows(comments[0]).find((row) => + row.type === "key_value" && row.label === "Failure summary" + ); + expect(failureSummary).toMatchObject({ type: "key_value", label: "Failure summary" }); + expect(failureSummary?.type === "key_value" ? failureSummary.value : "").toContain("Authorization"); + expect(comments[0]?.presentation).toMatchObject({ kind: "system_notice", tone: "danger" }); + expect(noticeMetadataReferencesRecoveryAction(comments[0]?.metadata, recoveryAction.id)).toBe(true); + expect(commentMetadataRows(comments[0]).some((row) => + row.type === "agent_link" && row.label === "Recovery owner" && row.name === longRecoveryOwnerName.slice(0, 160), + )).toBe(true); }); it("blocks an already stranded recovery issue without creating a recovery child", async () => { @@ -5933,9 +5936,11 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); expect(comments).toHaveLength(1); expect(comments[0]?.body).toContain("retried continuation"); - 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]?.presentation).toMatchObject({ kind: "system_notice", tone: "danger" }); + expect(noticeMetadataReferencesRecoveryAction(comments[0]?.metadata, recoveryAction.id)).toBe(true); + expect(commentMetadataRows(comments[0]).some((row) => + row.type === "agent_link" && row.label === "Recovery owner" && row.name === "CodexCoder", + )).toBe(true); }); it("redacts error-code-only stranded recovery failures in issue copy", async () => { @@ -5966,8 +5971,15 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); expect(comments).toHaveLength(1); - expect(comments[0]?.body).toContain("Latest retry failure details were withheld from the issue thread"); + // The short structured body carries no failure details; the normalized + // failure code surfaces only as a metadata row. + expect(comments[0]?.body).not.toContain("adapter_exit_code"); expect(comments[0]?.body).not.toContain("- Failure: none recorded"); + expect(commentMetadataRows(comments[0])).toContainEqual({ + type: "key_value", + label: "Failure code", + value: "adapter_exit_code", + }); }); it("keeps retrying transient adapter_failed continuation runs before the cap", async () => { @@ -6060,7 +6072,11 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(comments).toHaveLength(1); expect(comments[0]?.body).toContain("retried continuation"); expect(comments[0]?.body).toContain("3× attempts"); - expect(comments[0]?.body).toContain("Latest cause: `adapter_failed`"); + expect(commentMetadataRows(comments[0])).toContainEqual({ + type: "key_value", + label: "Failure code", + value: "adapter_failed", + }); }); it("does not count mixed-cause continuation failures toward the transient cap", async () => { @@ -6687,8 +6703,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(comments).toHaveLength(1); expect(comments[0]?.body).toContain("automatically retried continuation"); expect(comments[0]?.body).toContain("still has no live execution path"); - expect(comments[0]?.body).toContain(`Recovery action: \`${recoveryAction.id}\``); - expect(comments[0]?.body).toContain("Recovery owner: [CodexCoder]"); + expect(noticeMetadataReferencesRecoveryAction(comments[0]?.metadata, recoveryAction.id)).toBe(true); + expect(commentMetadataRows(comments[0]).some((row) => + row.type === "agent_link" && row.label === "Recovery owner" && row.name === "CodexCoder", + )).toBe(true); }); it("allows one productive-terminal recovery after regular continuation recovery made progress", async () => { diff --git a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts index ed899e0dfc..38394581ea 100644 --- a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts +++ b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts @@ -37,6 +37,7 @@ import { } from "./helpers/embedded-postgres.js"; import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js"; import { heartbeatService } from "../services/heartbeat.ts"; +import { noticeMetadataReferencesRecoveryAction } from "../services/recovery/index.ts"; import { instanceSettingsService } from "../services/instance-settings.ts"; import { WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE, @@ -221,7 +222,7 @@ async function waitForContainmentSideEffects(input: { const hasRecoveryActionComment = recoveryActionId ? comments.some((comment) => comment.issueId === input.sourceIssueId && - comment.body.includes(`Recovery action: \`${recoveryActionId}\``)) + noticeMetadataReferencesRecoveryAction(comment.metadata, recoveryActionId)) : false; if ( source?.status === "blocked" && @@ -685,7 +686,10 @@ async function expectContainedWorkspaceBranchFailure(input: { }), }); - expect(comments.filter((comment) => comment.issueId === input.sourceIssueId && comment.body.includes(`Recovery action: \`${action.id}\``))).toHaveLength(1); + expect(comments.filter((comment) => + comment.issueId === input.sourceIssueId && + noticeMetadataReferencesRecoveryAction(comment.metadata, action.id), + )).toHaveLength(1); expect(comments.filter((comment) => comment.issueId === input.sameWorkspaceSiblingId)).toHaveLength(0); expect(comments.filter((comment) => comment.issueId === input.otherWorkspaceSiblingId)).toHaveLength(0); } @@ -812,14 +816,10 @@ async function expectForwardBranchReconciled(input: { ]), ); if (resolvedRecoveryActionId) { - expect(comments).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - authorType: "system", - body: expect.stringContaining(`Recovery action: \`${resolvedRecoveryActionId}\``), - }), - ]), - ); + expect(comments.some((comment) => + comment.authorType === "system" && + noticeMetadataReferencesRecoveryAction(comment.metadata, resolvedRecoveryActionId), + )).toBe(true); } const activities = await input.db diff --git a/server/src/__tests__/issue-recovery-actions.test.ts b/server/src/__tests__/issue-recovery-actions.test.ts index 236f884c34..f5a2d036dd 100644 --- a/server/src/__tests__/issue-recovery-actions.test.ts +++ b/server/src/__tests__/issue-recovery-actions.test.ts @@ -27,6 +27,7 @@ import { issueRoutes } from "../routes/issues.js"; import { buildPaperclipWakePayload } from "../services/heartbeat.js"; import { issueRecoveryActionService } from "../services/issue-recovery-actions.js"; import { recoveryService } from "../services/recovery/service.js"; +import { noticeMetadataReferencesRecoveryAction } from "../services/recovery/successful-run-handoff.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -1143,21 +1144,14 @@ describeEmbeddedPostgres("issue recovery actions", () => { }); const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, sourceIssue.id)); - expect(comments).toHaveLength(1); - expect(comments[0]?.presentation).toMatchObject({ + const escalationComments = comments.filter((comment) => + noticeMetadataReferencesRecoveryAction(comment.metadata, actionRows[0]!.id), + ); + expect(escalationComments).toHaveLength(1); + expect(escalationComments[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" }), - ]), - })], + tone: "danger", + title: "Workspace validation failed", }); expect(enqueueWakeup).toHaveBeenCalledTimes(2); expect(enqueueWakeup).toHaveBeenCalledWith( @@ -1231,7 +1225,11 @@ describeEmbeddedPostgres("issue recovery actions", () => { const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, sourceIssue.id)); expect(comments).toHaveLength(1); - expect(comments[0]?.body).toContain("Recovery action:"); + // Dedupe for structured notices is metadata-based: the short body no longer + // carries the `Recovery action: \`id\`` marker line. + expect(comments[0]?.body).not.toContain("Recovery action:"); + expect(noticeMetadataReferencesRecoveryAction(comments[0]?.metadata, actionRows[0]!.id)).toBe(true); + expect(comments[0]?.presentation).toMatchObject({ kind: "system_notice", tone: "danger" }); }); it("does not create nested recovery artifacts when issue-backed fallback work itself fails", async () => { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index f9eddcbd5b..5ffbfe143b 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -224,6 +224,12 @@ import { readContinuationAttempt, } from "./recovery/index.js"; import { isAutomaticRecoverySuppressedByPauseHold } from "./recovery/pause-hold-guard.js"; +import { + buildConfigurationIncompleteRecoveryNoticeSeed, + buildExecutionReviewParticipantRecoveryNoticeSeed, + buildImmediateExecutionPathRecoveryNoticeSeed, + buildWorkspaceValidationRecoveryNoticeSeed, +} from "./recovery/stranded-notice.js"; import { recoveryAssigneeAdapterOverrides, withRecoveryModelProfileHint, @@ -3616,30 +3622,6 @@ export function summarizeHeartbeatRunListResultJson(input: { return Object.keys(summary).length > 0 ? summary : null; } -function summarizeRunFailureForIssueComment( - run: Pick | null | undefined, -) { - if (!run) return null; - - const errorCode = readNonEmptyString(run.errorCode)?.trim() ?? null; - const rawError = readNonEmptyString(run.error)?.trim() ?? null; - const apiMessageMatch = rawError?.match(/"message"\s*:\s*"([^"]+)"/); - const firstLine = rawError - ?.split(/\r?\n/) - .map((line) => line.trim()) - .find(Boolean) ?? null; - const summarySource = apiMessageMatch?.[1] ?? firstLine; - const summary = - summarySource && summarySource.length > 240 - ? `${summarySource.slice(0, 237)}...` - : summarySource; - - if (errorCode && summary) return ` Latest retry failure: \`${errorCode}\` - ${summary}.`; - if (errorCode) return ` Latest retry failure: \`${errorCode}\`.`; - if (summary) return ` Latest retry failure: ${summary}.`; - return null; -} - function didAutomaticRecoveryFail( latestRun: Pick | null, expectedRetryReason: @@ -16379,73 +16361,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } } - function buildImmediateExecutionPathRecoveryComment(input: { - status: "todo" | "in_progress"; - latestRun: Pick | null | undefined; - }) { - const failureSummary = summarizeRunFailureForIssueComment(input.latestRun); - if (input.status === "todo") { - return ( - "Paperclip automatically retried dispatch for this assigned `todo` issue during terminal run recovery, " + - `but it still has no live execution path.${failureSummary ?? ""} ` + - "Moving it to `blocked` so it is visible for intervention." - ); - } - - return ( - "Paperclip automatically retried continuation for this assigned `in_progress` issue during terminal run " + - `recovery, but it still has no live execution path.${failureSummary ?? ""} ` + - "Moving it to `blocked` so it is visible for intervention." - ); - } - - function buildWorkspaceValidationRecoveryComment(input: { - latestRun: - | Pick - | null - | undefined; - }) { - const failureSummary = summarizeRunFailureForIssueComment(input.latestRun); - const validationReason = readNonEmptyString( - readWorkspaceValidationPayloadFromRun(input.latestRun).reason, - ); - if (validationReason === "git_worktree_base_materialization_failed") { - return ( - "Paperclip stopped before launching the local adapter because the project workspace checkout could not be prepared " + - `(for example the repository clone failed).${failureSummary ?? ""} ` + - "Moving it to `blocked` with a source-scoped recovery action so the repository URL, clone access, or configured local cwd can be repaired before resuming." - ); - } - return ( - "Paperclip stopped before launching the local adapter because the issue workspace failed validation. " + - `This prevents git-sensitive adapters from running in an unrelated fallback cwd.${failureSummary ?? ""} ` + - "Moving it to `blocked` with a source-scoped recovery action so the workspace link, cwd, or git checkout can be repaired before resuming." - ); - } - - function buildConfigurationIncompleteRecoveryComment(input: { - latestRun: Pick | null | undefined; - }) { - const failureSummary = summarizeRunFailureForIssueComment(input.latestRun); - return ( - "Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " + - `Resolving them as a runtime failure would only produce repeated opaque setup failures.${failureSummary ?? ""} ` + - "Moving it to `blocked` with a source-scoped recovery action so an operator can bind the missing secret(s) before resuming." - ); - } - - function buildExecutionReviewParticipantRecoveryComment(input: { - latestRun: Pick | null | undefined; - }) { - const failureSummary = summarizeRunFailureForIssueComment(input.latestRun); - return ( - "Paperclip retried the pending execution-review participant once, but the review stage still has no completed decision " + - `or live reviewer run.${failureSummary ?? ""} ` + - "Moving it to `blocked` with a source-scoped recovery action so the recovery owner can repair the reviewer runtime, " + - "restore the review stage, or record an intentional manual resolution." - ); - } - async function releaseIssueExecutionAndPromote( run: typeof heartbeatRuns.$inferSelect, options: { suppressImmediateRecovery?: boolean } = {}, @@ -16579,9 +16494,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) kind: "blocked" as const, issue, previousStatus: issue.status, - comment: configurationIncomplete - ? buildConfigurationIncompleteRecoveryComment({ latestRun: run }) - : buildWorkspaceValidationRecoveryComment({ latestRun: run }), + notice: configurationIncomplete + ? buildConfigurationIncompleteRecoveryNoticeSeed() + : buildWorkspaceValidationRecoveryNoticeSeed(), recoveryCause: configurationIncomplete ? CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE : WORKSPACE_VALIDATION_RECOVERY_CAUSE, @@ -16925,7 +16840,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) kind: "blocked" as const, issue, previousStatus: issue.status, - comment: buildExecutionReviewParticipantRecoveryComment({ latestRun: run }), + notice: buildExecutionReviewParticipantRecoveryNoticeSeed(), recoveryCause: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_CAUSE, recoveryOwnerAgentId: currentParticipant.agentId, }; @@ -17046,19 +16961,18 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) if (shouldBlockImmediately) { const workspaceValidationFailure = isWorkspaceValidationFailedRun(run); const configurationIncompleteFailure = isConfigurationIncompleteFailedRun(run); - const comment = workspaceValidationFailure - ? buildWorkspaceValidationRecoveryComment({ latestRun: run }) + const notice = workspaceValidationFailure + ? buildWorkspaceValidationRecoveryNoticeSeed() : configurationIncompleteFailure - ? buildConfigurationIncompleteRecoveryComment({ latestRun: run }) - : buildImmediateExecutionPathRecoveryComment({ + ? buildConfigurationIncompleteRecoveryNoticeSeed() + : buildImmediateExecutionPathRecoveryNoticeSeed({ status: issue.status as "todo" | "in_progress", - latestRun: run, }); return { kind: "blocked" as const, issue, previousStatus: issue.status, - comment, + notice, recoveryCause: workspaceValidationFailure ? WORKSPACE_VALIDATION_RECOVERY_CAUSE : configurationIncompleteFailure @@ -17168,7 +17082,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) issue: promotionResult.issue, previousStatus: promotionResult.previousStatus as "todo" | "in_progress" | "in_review", latestRun: run, - comment: promotionResult.comment, + notice: promotionResult.notice, recoveryCause: promotionResult.recoveryCause === WORKSPACE_VALIDATION_RECOVERY_CAUSE ? WORKSPACE_VALIDATION_RECOVERY_CAUSE diff --git a/server/src/services/recovery/index.ts b/server/src/services/recovery/index.ts index 245aa707e0..6d35159ed3 100644 --- a/server/src/services/recovery/index.ts +++ b/server/src/services/recovery/index.ts @@ -58,8 +58,22 @@ export { findExistingFinishSuccessfulRunHandoffWake, isSuccessfulRunHandoffValidPathSkip, isSuccessfulRunHandoffRequiredNoticeBody, + noticeMetadataReferencesRecoveryAction, } from "./successful-run-handoff.js"; export type { SuccessfulRunHandoffNotice, SuccessfulRunHandoffDecision, } from "./successful-run-handoff.js"; +export { + DEFAULT_STRANDED_RECOVERY_NOTICE_BODY, + buildConfigurationIncompleteRecoveryNoticeSeed, + buildExecutionReviewParticipantRecoveryNoticeSeed, + buildExecutionReviewParticipantUnavailableNoticeSeed, + buildImmediateExecutionPathRecoveryNoticeSeed, + buildStrandedRecoveryEscalationNotice, + buildWorkspaceValidationRecoveryNoticeSeed, +} from "./stranded-notice.js"; +export type { + StrandedRecoveryEscalationNotice, + StrandedRecoveryNoticeSeed, +} from "./stranded-notice.js"; diff --git a/server/src/services/recovery/notice-format.ts b/server/src/services/recovery/notice-format.ts new file mode 100644 index 0000000000..520be52ec9 --- /dev/null +++ b/server/src/services/recovery/notice-format.ts @@ -0,0 +1,68 @@ +import type { IssueCommentMetadata, IssueCommentPresentation } from "@paperclipai/shared"; + +export type NoticeMetadataRow = IssueCommentMetadata["sections"][number]["rows"][number]; +export type NoticeMetadataSection = IssueCommentMetadata["sections"][number]; + +export function metadataText(value: unknown, fallback = "unknown") { + const text = typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim(); + const resolved = text.length > 0 ? text : fallback; + return resolved.length > 2000 ? `${resolved.slice(0, 1997)}...` : resolved; +} + +export function keyValueRow(label: string, value: unknown): NoticeMetadataRow { + return { type: "key_value", label, value: metadataText(value) }; +} + +export function issueLinkRow( + label: string, + issue: { id: string; identifier: string | null; title: string } | null | undefined, +): NoticeMetadataRow { + if (!issue) return keyValueRow(label, "unknown"); + return { + type: "issue_link", + label, + issueId: issue.id, + identifier: issue.identifier, + title: issue.title, + }; +} + +export function runLinkRow( + label: string, + run: { id: string; status: string; agentId?: string | null } | null | undefined, +): NoticeMetadataRow { + if (!run) return keyValueRow(label, "unknown"); + return { + type: "run_link", + label, + runId: run.id, + ...(run.agentId ? { agentId: run.agentId } : {}), + title: run.status, + }; +} + +export function agentLinkRow( + label: string, + agent: { id: string; name: string | null } | null | undefined, +): NoticeMetadataRow { + if (!agent) return keyValueRow(label, "unknown"); + return { + type: "agent_link", + label, + agentId: agent.id, + // Issue-comment metadata constrains link names to 160 characters. + name: agent.name?.slice(0, 160) ?? null, + }; +} + +export function systemNoticePresentation(input: { + tone: IssueCommentPresentation["tone"]; + title: string; +}): IssueCommentPresentation { + return { + kind: "system_notice", + tone: input.tone, + title: input.title, + detailsDefaultOpen: false, + }; +} diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index c0f2a5e032..9cfe392197 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -61,6 +61,12 @@ import { noticeMetadataReferencesRecoveryAction, type SuccessfulRunHandoffNotice, } from "./successful-run-handoff.js"; +import { + buildExecutionReviewParticipantRecoveryNoticeSeed, + buildExecutionReviewParticipantUnavailableNoticeSeed, + buildStrandedRecoveryEscalationNotice, + type StrandedRecoveryNoticeSeed, +} from "./stranded-notice.js"; import { RECOVERY_ORIGIN_KINDS, buildIssueGraphLivenessLeafKey, @@ -312,25 +318,6 @@ function summarizeRunFailureForIssueComment(run: LatestIssueRun) { return null; } -function buildExecutionReviewParticipantRecoveryComment(latestRun: LatestIssueRun) { - const failureSummary = summarizeRunFailureForIssueComment(latestRun); - return ( - "Paperclip retried the pending execution-review participant once, but the review stage still has no completed decision " + - `or live reviewer run.${failureSummary ?? ""} ` + - "Moving it to `blocked` with a source-scoped recovery action so the recovery owner can repair the reviewer runtime, " + - "restore the review stage, or record an intentional manual resolution." - ); -} - -function buildExecutionReviewParticipantUnavailableComment(latestRun: LatestIssueRun) { - const failureSummary = summarizeRunFailureForIssueComment(latestRun); - return ( - "Paperclip cannot continue the pending execution-review participant because the participant is not invokable " + - `and the review stage has no completed decision or live reviewer run.${failureSummary ?? ""} ` + - "Moving it to `blocked` with a source-scoped recovery action so the recovery owner can repair the reviewer runtime, " + - "restore the review stage, or record an intentional manual resolution." - ); -} function didAutomaticRecoveryFail( latestRun: LatestIssueRun, @@ -3315,6 +3302,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) previousStatus: StrandedPreviousStatus; latestRun: LatestIssueRun; comment?: string; + notice?: StrandedRecoveryNoticeSeed | null; recoveryCause?: StrandedRecoveryCause; recoveryOwnerAgentId?: string | null; successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null; @@ -3356,17 +3344,30 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) if (!updated) return null; if (isProviderQuotaWait) return updated; - const prefix = await getCompanyIssuePrefix(input.issue.companyId); const recoveryOwner = recoveryAction.ownerAgentId ? await getAgent(recoveryAction.ownerAgentId) : null; const sourceAssignee = input.issue.assigneeAgentId ? await getAgent(input.issue.assigneeAgentId) : null; let notice: SuccessfulRunHandoffNotice | null = null; if (input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON && input.successfulRunHandoffEvidence) { + const [sourceRun] = input.successfulRunHandoffEvidence.sourceRunId + ? await db + .select({ + id: heartbeatRuns.id, + status: heartbeatRuns.status, + agentId: heartbeatRuns.agentId, + }) + .from(heartbeatRuns) + .where(and( + eq(heartbeatRuns.id, input.successfulRunHandoffEvidence.sourceRunId), + eq(heartbeatRuns.companyId, input.issue.companyId), + )) + .limit(1) + : []; notice = buildSuccessfulRunHandoffExhaustedNotice({ issue: input.issue, - sourceRun: input.successfulRunHandoffEvidence.sourceRunId - ? { id: input.successfulRunHandoffEvidence.sourceRunId, status: "succeeded" } + sourceRun: sourceRun ?? null, + correctiveRun: input.latestRun + ? { id: input.latestRun.id, status: input.latestRun.status, agentId: input.latestRun.agentId } : null, - correctiveRun: input.latestRun ? { id: input.latestRun.id, status: input.latestRun.status } : null, sourceAssignee, recoveryIssue: null, recoveryActionId: recoveryAction.id, @@ -3376,19 +3377,24 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) missingDisposition: input.successfulRunHandoffEvidence.missingDisposition, }); } - const recoveryLine = recoveryAction.ownerAgentId - ? [ - "", - `- Recovery action: \`${recoveryAction.id}\``, - `- Recovery owner: ${agentUiLink(recoveryOwner, prefix)}`, - "- Next action: the recovery owner should either restore a live execution path or record the manual resolution on the source issue.", - ].join("\n") - : [ - "", - `- Recovery action: \`${recoveryAction.id}\``, - "- Recovery owner: board escalation, because Paperclip could not find an invokable manager, creator, or executive owner with budget available.", - "- Next action: a board operator should assign an invokable recovery owner, fix the agent/runtime state, or record an intentional manual resolution.", - ].join("\n"); + const escalationNotice = buildStrandedRecoveryEscalationNotice({ + seed: input.notice, + fallbackBody: input.comment, + recoveryCause, + recoveryActionId: recoveryAction.id, + recoveryOwner: recoveryAction.ownerAgentId && recoveryOwner + ? { id: recoveryOwner.id, name: recoveryOwner.name } + : null, + sourceRun: input.latestRun + ? { + id: input.latestRun.id, + agentId: input.latestRun.agentId, + status: input.latestRun.status, + errorCode: input.latestRun.errorCode, + errorSummary: input.latestRun.error ? redactSensitiveText(input.latestRun.error) : null, + } + : null, + }); const shouldPostEscalationComment = recoveryAction.attemptCount === 1 || @@ -3421,19 +3427,10 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) metadata: notice.metadata, }); } else { - await issuesSvc.addComment(input.issue.id, `${input.comment ?? ""}${recoveryLine}`, {}, { + await issuesSvc.addComment(input.issue.id, escalationNotice.body, {}, { 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, - }), + presentation: escalationNotice.presentation, + metadata: escalationNotice.metadata, }); } } @@ -3907,7 +3904,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) issue, previousStatus: "in_review", latestRun: participantLatestRun, - comment: buildExecutionReviewParticipantUnavailableComment(participantLatestRun), + notice: buildExecutionReviewParticipantUnavailableNoticeSeed(), recoveryCause: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON, recoveryOwnerAgentId: participantAgentId, }); @@ -3974,7 +3971,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) issue, previousStatus: "in_review", latestRun: participantLatestRun, - comment: buildExecutionReviewParticipantUnavailableComment(participantLatestRun), + notice: buildExecutionReviewParticipantUnavailableNoticeSeed(), recoveryCause: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON, recoveryOwnerAgentId: participantAgentId, }); @@ -3992,7 +3989,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) issue, previousStatus: "in_review", latestRun: participantLatestRun, - comment: buildExecutionReviewParticipantRecoveryComment(participantLatestRun), + notice: buildExecutionReviewParticipantRecoveryNoticeSeed(), recoveryCause: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON, recoveryOwnerAgentId: participantAgentId, }); @@ -4069,15 +4066,18 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) } if (didAutomaticRecoveryFail(latestRun, "assignment_recovery")) { - const failureSummary = summarizeRunFailureForIssueComment(latestRun); const updated = await escalateStrandedAssignedIssue({ issue, previousStatus: "todo", latestRun, - comment: - "Paperclip automatically retried dispatch for this assigned `todo` issue after a lost wake/run, " + - `but it still has no live execution path.${failureSummary ?? ""} ` + - "Moving it to `blocked` so it is visible for intervention.", + notice: { + body: + "Paperclip automatically retried dispatch for this assigned `todo` issue after a lost wake/run, " + + "but it still has no live execution path. " + + "Moving it to `blocked` so it is visible for intervention.", + title: "No live execution path", + tone: "danger", + }, }); if (updated) { result.escalated += 1; @@ -4210,15 +4210,18 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) } if (classification.kind === "non_retryable") { - const failureSummary = summarizeRunFailureForIssueComment(latestRun); const updated = await escalateStrandedAssignedIssue({ issue, previousStatus: "in_progress", latestRun, - comment: - "Paperclip detected a non-retryable failure on this issue's continuation run " + - `(\`${classification.errorCode}\`). Skipping automatic retries and moving it to \`blocked\` ` + - `so it is visible for intervention.${failureSummary ?? ""}`, + notice: { + body: + "Paperclip detected a non-retryable failure on this issue's continuation run " + + `(\`${classification.errorCode}\`). Skipping automatic retries and moving it to \`blocked\` ` + + "so it is visible for intervention.", + title: "Continuation failed", + tone: "danger", + }, }); if (updated) { result.escalated += 1; @@ -4237,19 +4240,19 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) classification.errorCode, ); if (consecutive >= classification.maxAttempts) { - const failureSummary = summarizeRunFailureForIssueComment(latestRun); const attemptCopy = consecutive <= 1 ? "" : ` (${consecutive}× attempts)`; - const causeCopy = classification.errorCode - ? ` Latest cause: \`${classification.errorCode}\`.` - : ""; const updated = await escalateStrandedAssignedIssue({ issue, previousStatus: "in_progress", latestRun, - comment: - "Paperclip automatically retried continuation for this assigned `in_progress` issue after its live " + - `execution disappeared, but it still has no live execution path${attemptCopy}.${causeCopy}${failureSummary ?? ""} ` + - "Moving it to `blocked` so it is visible for intervention.", + notice: { + body: + "Paperclip automatically retried continuation for this assigned `in_progress` issue after its live " + + `execution disappeared, but it still has no live execution path${attemptCopy}. ` + + "Moving it to `blocked` so it is visible for intervention.", + title: "No live execution path", + tone: "danger", + }, }); if (updated) { result.escalated += 1; diff --git a/server/src/services/recovery/stranded-notice.test.ts b/server/src/services/recovery/stranded-notice.test.ts new file mode 100644 index 0000000000..3fff23b5fc --- /dev/null +++ b/server/src/services/recovery/stranded-notice.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; +import { noticeMetadataReferencesRecoveryAction } from "./successful-run-handoff.js"; +import { + DEFAULT_STRANDED_RECOVERY_NOTICE_BODY, + buildConfigurationIncompleteRecoveryNoticeSeed, + buildExecutionReviewParticipantRecoveryNoticeSeed, + buildExecutionReviewParticipantUnavailableNoticeSeed, + buildImmediateExecutionPathRecoveryNoticeSeed, + buildStrandedRecoveryEscalationNotice, + buildWorkspaceValidationRecoveryNoticeSeed, +} from "./stranded-notice.js"; + +function allRows(metadata: { sections: Array<{ rows: unknown[] }> }) { + return metadata.sections.flatMap((section) => section.rows) as Array>; +} + +describe("stranded recovery notice seeds", () => { + it.each([ + ["todo dispatch", buildImmediateExecutionPathRecoveryNoticeSeed({ status: "todo" }), "No live execution path"], + ["in_progress continuation", buildImmediateExecutionPathRecoveryNoticeSeed({ status: "in_progress" }), "No live execution path"], + ["workspace validation", buildWorkspaceValidationRecoveryNoticeSeed(), "Workspace validation failed"], + ["configuration incomplete", buildConfigurationIncompleteRecoveryNoticeSeed(), "Configuration incomplete"], + ["review participant recovery", buildExecutionReviewParticipantRecoveryNoticeSeed(), "Review recovery stalled"], + ["review participant unavailable", buildExecutionReviewParticipantUnavailableNoticeSeed(), "Review recovery stalled"], + ])("%s seed has a short body plus title and tone", (_label, seed, expectedTitle) => { + expect(seed.title).toBe(expectedTitle); + expect(seed.tone).toBe("danger"); + expect(seed.body.length).toBeGreaterThan(0); + // Failure details now live in metadata rows, never inline in the body. + expect(seed.body).not.toContain("Latest retry failure"); + expect(seed.body).not.toContain("Recovery action:"); + }); + + it("distinguishes todo dispatch from in_progress continuation copy", () => { + expect(buildImmediateExecutionPathRecoveryNoticeSeed({ status: "todo" }).body).toContain("retried dispatch"); + expect(buildImmediateExecutionPathRecoveryNoticeSeed({ status: "in_progress" }).body).toContain( + "retried continuation", + ); + }); +}); + +describe("buildStrandedRecoveryEscalationNotice", () => { + const actionId = "6a2f8e64-6f5e-4b58-b7fd-111111111111"; + const owner = { id: "9b1c2d3e-4f50-4a61-8b72-222222222222", name: "CTO" }; + const sourceRun = { + id: "0c1d2e3f-4a5b-4c6d-8e7f-333333333333", + agentId: "4d5e6f70-8a9b-4c1d-8e2f-444444444444", + status: "failed", + errorCode: "workspace_validation_failed", + errorSummary: "Expected project worktree but resolved the agent fallback directory.", + }; + + it("emits system_notice presentation and the required metadata rows", () => { + const seed = buildWorkspaceValidationRecoveryNoticeSeed(); + const notice = buildStrandedRecoveryEscalationNotice({ + seed, + recoveryActionId: actionId, + recoveryOwner: owner, + sourceRun, + }); + + expect(notice.body).toBe(seed.body); + expect(notice.presentation).toMatchObject({ + kind: "system_notice", + tone: "danger", + title: "Workspace validation failed", + }); + expect(notice.metadata.version).toBe(1); + expect(notice.metadata.sourceRunId).toBe(sourceRun.id); + + const rows = allRows(notice.metadata); + expect(rows).toContainEqual({ type: "key_value", label: "Recovery action", value: actionId }); + expect(rows).toContainEqual({ type: "agent_link", label: "Recovery owner", agentId: owner.id, name: owner.name }); + expect(rows).toContainEqual({ + type: "run_link", + label: "Source run", + runId: sourceRun.id, + agentId: sourceRun.agentId, + title: "failed", + }); + expect(rows).toContainEqual({ + type: "key_value", + label: "Failure code", + value: "workspace_validation_failed", + }); + expect(rows).toContainEqual({ + type: "key_value", + label: "Failure summary", + value: sourceRun.errorSummary, + }); + expect(rows.some((row) => row.label === "Next action")).toBe(true); + }); + + it("records board escalation when there is no invokable recovery owner", () => { + const notice = buildStrandedRecoveryEscalationNotice({ + seed: buildConfigurationIncompleteRecoveryNoticeSeed(), + recoveryActionId: actionId, + recoveryOwner: null, + sourceRun, + }); + + const ownerRow = allRows(notice.metadata).find((row) => row.label === "Recovery owner"); + expect(ownerRow?.type).toBe("key_value"); + expect(String(ownerRow?.value)).toContain("Board escalation"); + }); + + it("derives title from the recovery cause and body from the plain-comment fallback", () => { + const notice = buildStrandedRecoveryEscalationNotice({ + fallbackBody: "Legacy plain comment body.", + recoveryCause: "configuration_incomplete", + recoveryActionId: actionId, + recoveryOwner: owner, + sourceRun: null, + }); + + expect(notice.body).toBe("Legacy plain comment body."); + expect(notice.presentation.title).toBe("Configuration incomplete"); + expect(notice.metadata.sourceRunId).toBeNull(); + expect(notice.metadata.sections).toHaveLength(1); + }); + + it("falls back to the default body and title when no seed or comment is given", () => { + const notice = buildStrandedRecoveryEscalationNotice({ + recoveryActionId: actionId, + recoveryOwner: null, + sourceRun: null, + }); + + expect(notice.body).toBe(DEFAULT_STRANDED_RECOVERY_NOTICE_BODY); + expect(notice.presentation.title).toBe("Automatic recovery blocked"); + }); + + it("omits the failure code row when the source run has no error code", () => { + const notice = buildStrandedRecoveryEscalationNotice({ + seed: buildImmediateExecutionPathRecoveryNoticeSeed({ status: "in_progress" }), + recoveryActionId: actionId, + recoveryOwner: owner, + sourceRun: { + id: sourceRun.id, + agentId: sourceRun.agentId, + status: "cancelled", + errorCode: null, + errorSummary: null, + }, + }); + + const rows = allRows(notice.metadata); + expect(rows).toContainEqual({ + type: "run_link", + label: "Source run", + runId: sourceRun.id, + agentId: sourceRun.agentId, + title: "cancelled", + }); + expect(rows.some((row) => row.label === "Failure code")).toBe(false); + expect(rows.some((row) => row.label === "Failure summary")).toBe(false); + }); + + it("is matched by the metadata-based escalation dedupe matcher", () => { + const notice = buildStrandedRecoveryEscalationNotice({ + seed: buildImmediateExecutionPathRecoveryNoticeSeed({ status: "todo" }), + recoveryActionId: actionId, + recoveryOwner: owner, + sourceRun, + }); + + // The escalation dedupe in recovery/service.ts no longer relies on the + // `Recovery action: \`id\`` body marker for new comments; the metadata + // matcher must recognize every notice this builder produces. + expect(notice.body).not.toContain(actionId); + expect(noticeMetadataReferencesRecoveryAction(notice.metadata, actionId)).toBe(true); + expect( + noticeMetadataReferencesRecoveryAction(notice.metadata, "1f2e3d4c-5b6a-4798-8899-444444444444"), + ).toBe(false); + }); +}); diff --git a/server/src/services/recovery/stranded-notice.ts b/server/src/services/recovery/stranded-notice.ts new file mode 100644 index 0000000000..7705ba328f --- /dev/null +++ b/server/src/services/recovery/stranded-notice.ts @@ -0,0 +1,158 @@ +import type { IssueCommentMetadata, IssueCommentPresentation } from "@paperclipai/shared"; +import { + agentLinkRow, + keyValueRow, + runLinkRow, + systemNoticePresentation, + type NoticeMetadataRow, + type NoticeMetadataSection, +} from "./notice-format.js"; + +// Short human-readable body plus the presentation header for one recovery +// family. The escalation path merges in the metadata rows only it knows +// (recovery action, owner, source run) via buildStrandedRecoveryEscalationNotice. +export type StrandedRecoveryNoticeSeed = { + body: string; + title: string; + tone: IssueCommentPresentation["tone"]; +}; + +export type StrandedRecoveryEscalationNotice = { + body: string; + presentation: IssueCommentPresentation; + metadata: IssueCommentMetadata; +}; + +export const DEFAULT_STRANDED_RECOVERY_NOTICE_BODY = + "Paperclip could not restore a live execution path for this issue automatically. " + + "Moving it to `blocked` so it is visible for intervention."; + +const DEFAULT_STRANDED_RECOVERY_NOTICE_TITLE = "Automatic recovery blocked"; + +const STRANDED_RECOVERY_NOTICE_TITLES_BY_CAUSE: Record = { + workspace_validation_failed: "Workspace validation failed", + configuration_incomplete: "Configuration incomplete", + execution_review_participant_recovery: "Review recovery stalled", +}; + +export function buildImmediateExecutionPathRecoveryNoticeSeed(input: { + status: "todo" | "in_progress"; +}): StrandedRecoveryNoticeSeed { + const retryDescription = input.status === "todo" + ? "Paperclip automatically retried dispatch for this assigned `todo` issue during terminal run recovery" + : "Paperclip automatically retried continuation for this assigned `in_progress` issue during terminal run recovery"; + return { + body: + `${retryDescription}, but it still has no live execution path. ` + + "Moving it to `blocked` so it is visible for intervention.", + title: "No live execution path", + tone: "danger", + }; +} + +export function buildWorkspaceValidationRecoveryNoticeSeed(): StrandedRecoveryNoticeSeed { + return { + body: + "Paperclip stopped before launching the local adapter because the issue workspace failed validation. " + + "Moving it to `blocked` so the workspace link, cwd, or git checkout can be repaired before resuming.", + title: "Workspace validation failed", + tone: "danger", + }; +} + +export function buildConfigurationIncompleteRecoveryNoticeSeed(): StrandedRecoveryNoticeSeed { + return { + body: + "Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " + + "Moving it to `blocked` so an operator can bind the missing secret(s) before resuming.", + title: "Configuration incomplete", + tone: "danger", + }; +} + +export function buildExecutionReviewParticipantRecoveryNoticeSeed(): StrandedRecoveryNoticeSeed { + return { + body: + "Paperclip retried the pending execution-review participant once, but the review stage still has no " + + "completed decision or live reviewer run. Moving it to `blocked` so the recovery owner can repair the " + + "reviewer runtime, restore the review stage, or record an intentional manual resolution.", + title: "Review recovery stalled", + tone: "danger", + }; +} + +export function buildExecutionReviewParticipantUnavailableNoticeSeed(): StrandedRecoveryNoticeSeed { + return { + body: + "Paperclip cannot continue the pending execution-review participant because the participant is not " + + "invokable and the review stage has no completed decision or live reviewer run. Moving it to `blocked` " + + "so the recovery owner can repair the reviewer runtime, restore the review stage, or record an " + + "intentional manual resolution.", + title: "Review recovery stalled", + tone: "danger", + }; +} + +// Escalation dedupe matches the `Recovery action` key_value row via +// noticeMetadataReferencesRecoveryAction, so this builder must always emit +// that row with the raw action id. +export function buildStrandedRecoveryEscalationNotice(input: { + seed?: StrandedRecoveryNoticeSeed | null; + fallbackBody?: string | null; + recoveryCause?: string | null; + recoveryActionId: string; + recoveryOwner: { id: string; name: string | null } | null | undefined; + sourceRun: { + id: string; + agentId?: string | null; + status: string; + errorCode?: string | null; + errorSummary?: string | null; + } | null | undefined; +}): StrandedRecoveryEscalationNotice { + const fallbackBody = input.fallbackBody?.trim(); + const body = input.seed?.body ?? (fallbackBody || DEFAULT_STRANDED_RECOVERY_NOTICE_BODY); + const title = input.seed?.title ?? + STRANDED_RECOVERY_NOTICE_TITLES_BY_CAUSE[input.recoveryCause ?? ""] ?? + DEFAULT_STRANDED_RECOVERY_NOTICE_TITLE; + + const recoveryRows: NoticeMetadataRow[] = [ + keyValueRow("Recovery action", input.recoveryActionId), + input.recoveryOwner + ? agentLinkRow("Recovery owner", input.recoveryOwner) + : keyValueRow( + "Recovery owner", + "Board escalation - Paperclip could not find an invokable manager, creator, or executive owner with budget available", + ), + keyValueRow( + "Next action", + input.recoveryOwner + ? "The recovery owner should either restore a live execution path or record the manual resolution on the source issue" + : "A board operator should assign an invokable recovery owner, fix the agent/runtime state, or record an intentional manual resolution", + ), + ]; + + const runRows: NoticeMetadataRow[] = []; + if (input.sourceRun) { + runRows.push(runLinkRow("Source run", input.sourceRun)); + const failureCode = input.sourceRun.errorCode?.trim(); + if (failureCode) runRows.push(keyValueRow("Failure code", failureCode)); + const failureSummary = input.sourceRun.errorSummary?.trim(); + if (failureSummary) runRows.push(keyValueRow("Failure summary", failureSummary)); + } + + const sections: NoticeMetadataSection[] = [ + { title: "Recovery", rows: recoveryRows }, + ...(runRows.length > 0 ? [{ title: "Run evidence", rows: runRows }] : []), + ]; + + return { + body, + presentation: systemNoticePresentation({ tone: input.seed?.tone ?? "danger", title }), + metadata: { + version: 1, + sourceRunId: input.sourceRun?.id ?? null, + sections, + }, + }; +} diff --git a/server/src/services/recovery/successful-run-handoff.test.ts b/server/src/services/recovery/successful-run-handoff.test.ts index 46a36c6396..5aa042b041 100644 --- a/server/src/services/recovery/successful-run-handoff.test.ts +++ b/server/src/services/recovery/successful-run-handoff.test.ts @@ -389,6 +389,7 @@ describe("successful run handoff decision", () => { run: { id: "22222222-2222-4222-8222-222222222222", status: "succeeded", + agentId: "33333333-3333-4333-8333-333333333333", } as any, agent: { id: "33333333-3333-4333-8333-333333333333", @@ -417,7 +418,11 @@ describe("successful run handoff decision", () => { expect.objectContaining({ title: "Run evidence", rows: expect.arrayContaining([ - expect.objectContaining({ type: "run_link", runId: "22222222-2222-4222-8222-222222222222" }), + expect.objectContaining({ + type: "run_link", + runId: "22222222-2222-4222-8222-222222222222", + agentId: "33333333-3333-4333-8333-333333333333", + }), expect.objectContaining({ type: "key_value", label: "Normalized cause", value: SUCCESSFUL_RUN_MISSING_STATE_REASON }), expect.objectContaining({ type: "key_value", label: "Detected progress" }), ]), @@ -433,8 +438,16 @@ describe("successful run handoff decision", () => { title: "Finish backend handoff", status: "in_progress", } as any, - sourceRun: { id: "22222222-2222-4222-8222-222222222222", status: "succeeded" } as any, - correctiveRun: { id: "44444444-4444-4444-8444-444444444444", status: "failed" } as any, + sourceRun: { + id: "22222222-2222-4222-8222-222222222222", + status: "succeeded", + agentId: "33333333-3333-4333-8333-333333333333", + } as any, + correctiveRun: { + id: "44444444-4444-4444-8444-444444444444", + status: "failed", + agentId: "66666666-6666-4666-8666-666666666666", + } as any, sourceAssignee: { id: "33333333-3333-4333-8333-333333333333", name: "CodexCoder" } as any, recoveryIssue: { id: "55555555-5555-4555-8555-555555555555", @@ -467,7 +480,16 @@ describe("successful run handoff decision", () => { expect.objectContaining({ title: "Run evidence", rows: expect.arrayContaining([ - expect.objectContaining({ type: "run_link", label: "Source run" }), + expect.objectContaining({ + type: "run_link", + label: "Source run", + agentId: "33333333-3333-4333-8333-333333333333", + }), + expect.objectContaining({ + type: "run_link", + label: "Corrective handoff run", + agentId: "66666666-6666-4666-8666-666666666666", + }), expect.objectContaining({ type: "run_link", label: "Corrective handoff run" }), expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }), ]), diff --git a/server/src/services/recovery/successful-run-handoff.ts b/server/src/services/recovery/successful-run-handoff.ts index d415ccde20..e6a3533586 100644 --- a/server/src/services/recovery/successful-run-handoff.ts +++ b/server/src/services/recovery/successful-run-handoff.ts @@ -3,6 +3,13 @@ import type { Db } from "@paperclipai/db"; import { agentWakeupRequests, agents, heartbeatRuns, issues } from "@paperclipai/db"; import type { IssueCommentMetadata, IssueCommentPresentation, RunLivenessState } from "@paperclipai/shared"; import { withRecoveryModelProfileHint } from "./model-profile-hint.js"; +import { + agentLinkRow, + issueLinkRow, + keyValueRow, + runLinkRow, + systemNoticePresentation, +} from "./notice-format.js"; export const FINISH_SUCCESSFUL_RUN_HANDOFF_REASON = "finish_successful_run_handoff"; export const SUCCESSFUL_RUN_MISSING_STATE_REASON = "successful_run_missing_state"; @@ -57,7 +64,7 @@ type IssueRow = Pick< >; type AgentRow = Pick; type NoticeIssue = Pick; -type NoticeRun = Pick; +type NoticeRun = Pick; type NoticeAgent = Pick; type NullableNoticeAgent = NoticeAgent | null | undefined; type NullableNoticeIssue = NoticeIssue | null | undefined; @@ -115,58 +122,6 @@ export function isSuccessfulRunHandoffValidPathSkip( return decision.kind === "skip" && SUCCESSFUL_RUN_HANDOFF_VALID_PATH_SKIP_REASONS.has(decision.reason); } -function metadataText(value: unknown, fallback = "unknown") { - const text = typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim(); - const resolved = text.length > 0 ? text : fallback; - return resolved.length > 2000 ? `${resolved.slice(0, 1997)}...` : resolved; -} - -function keyValueRow(label: string, value: unknown): IssueCommentMetadata["sections"][number]["rows"][number] { - return { type: "key_value", label, value: metadataText(value) }; -} - -function issueLinkRow( - label: string, - issue: NullableNoticeIssue, -): IssueCommentMetadata["sections"][number]["rows"][number] { - if (!issue) return keyValueRow(label, "unknown"); - return { - type: "issue_link", - label, - issueId: issue.id, - identifier: issue.identifier, - title: issue.title, - }; -} - -function runLinkRow( - label: string, - run: NullableNoticeRun, -): IssueCommentMetadata["sections"][number]["rows"][number] { - if (!run) return keyValueRow(label, "unknown"); - return { type: "run_link", label, runId: run.id, title: run.status }; -} - -function agentLinkRow( - label: string, - agent: NullableNoticeAgent, -): IssueCommentMetadata["sections"][number]["rows"][number] { - if (!agent) return keyValueRow(label, "unknown"); - return { type: "agent_link", label, agentId: agent.id, name: agent.name }; -} - -function systemNoticePresentation(input: { - tone: IssueCommentPresentation["tone"]; - title: string; -}): IssueCommentPresentation { - return { - kind: "system_notice", - tone: input.tone, - title: input.title, - detailsDefaultOpen: false, - }; -} - export function isSuccessfulRunHandoffRequiredNoticeBody(body: string) { const trimmed = body.trim(); return trimmed === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY || diff --git a/ui/src/components/SystemNotice.tsx b/ui/src/components/SystemNotice.tsx index 343c397694..291e380d26 100644 --- a/ui/src/components/SystemNotice.tsx +++ b/ui/src/components/SystemNotice.tsx @@ -206,6 +206,38 @@ function MetadataRow({ row, tone }: { row: SystemNoticeMetadataRow; tone: ToneTo ); } +/** + * The structured metadata rows shared by the boxed SystemNotice details panel + * and the chat shell's expandable system row (TaskChatSystemNotice, PAP-443). + */ +export function SystemNoticeMetadataSections({ + sections, + tone = "neutral", +}: { + sections: SystemNoticeMetadataSection[]; + tone?: SystemNoticeTone; +}) { + const tokens = TONE_TOKENS[tone]; + return ( +
+ {sections.map((section, sectionIdx) => ( +
+ {section.title ? ( +
+ {section.title} +
+ ) : null} +
+ {section.rows.map((row, rowIdx) => ( + + ))} +
+
+ ))} +
+ ); +} + export function SystemNotice({ tone = "neutral", label, @@ -312,22 +344,7 @@ export function SystemNotice({ tokens.divider, )} > -
- {metadata!.map((section, sectionIdx) => ( -
- {section.title ? ( -
- {section.title} -
- ) : null} -
- {section.rows.map((row, rowIdx) => ( - - ))} -
-
- ))} -
+ ) : null} diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index 528fbc985c..89584df89f 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -14,6 +14,7 @@ import { AttachmentTrigger, } from "@/components/ui/attachment"; import { extractAttachmentRefs, fileKindForName } from "./task-chat-attachments"; +import { TaskChatSystemNotice } from "./TaskChatSystemNotice"; import type { TaskChatMessageItem } from "./task-chat-model"; interface TaskChatBubbleProps { @@ -58,11 +59,8 @@ export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubblePr } if (item.author === "system") { - return ( -
-

{item.text}

-
- ); + // Collapsed humanized one-liner, expandable to the full detail (PAP-443). + return ; } const isHuman = item.author === "human"; diff --git a/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx b/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx new file mode 100644 index 0000000000..6d34d7b45e --- /dev/null +++ b/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx @@ -0,0 +1,123 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { ThemeProvider } from "@/context/ThemeContext"; +import { TaskChatSystemNotice } from "./TaskChatSystemNotice"; +import type { TaskChatMessageItem } from "./task-chat-model"; + +describe("TaskChatSystemNotice (PAP-443)", () => { + let container: HTMLDivElement; + let root: Root | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + flushSync(() => root?.unmount()); + root = null; + container.remove(); + }); + + const recoveryBody = + "Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " + + "Latest retry failure: `configuration_incomplete`. Moving it to `blocked` with a source-scoped recovery action."; + + function renderNotice(overrides: Partial = {}) { + const item: TaskChatMessageItem = { + id: "sys-1", + kind: "message", + author: "system", + text: recoveryBody, + createdAtIso: new Date(Date.now() - 5 * 60_000).toISOString(), + ...overrides, + }; + flushSync(() => + root!.render( + + + , + ), + ); + } + + function toggleButton() { + return container.querySelector( + '[data-testid="task-chat-system-notice"] button', + )!; + } + + it("collapses to a humanized one-liner with relative time and hides the raw body", () => { + renderNotice(); + const button = toggleButton(); + expect(button.getAttribute("aria-expanded")).toBe("false"); + expect(button.textContent).toContain("Task paused — a secret/config binding is missing"); + expect(button.textContent).toContain("5m ago"); + expect(container.textContent).not.toContain("source-scoped recovery action"); + expect( + container.querySelector('[data-testid="task-chat-system-notice-details"]'), + ).toBeNull(); + }); + + it("expands on click to the full markdown body and metadata sections", () => { + renderNotice({ + metadata: { + version: 1, + sections: [ + { title: "Failure", rows: [{ type: "code", label: "Code", code: "configuration_incomplete" }] }, + ], + }, + }); + flushSync(() => toggleButton().click()); + + expect(toggleButton().getAttribute("aria-expanded")).toBe("true"); + const details = container.querySelector('[data-testid="task-chat-system-notice-details"]'); + expect(details).not.toBeNull(); + expect(details!.textContent).toContain("required secret/env bindings are missing"); + expect(details!.textContent).toContain("Failure"); + expect(details!.textContent).toContain("configuration_incomplete"); + + // Collapses back — presentation-only fold, nothing lost. + flushSync(() => toggleButton().click()); + expect( + container.querySelector('[data-testid="task-chat-system-notice-details"]'), + ).toBeNull(); + }); + + it("links source-run metadata when the comment carries its run agent", () => { + renderNotice({ + metadata: { + version: 1, + sections: [ + { + title: "Run", + rows: [{ type: "run_link", label: "Source run", runId: "run-1", agentId: "agent-1", title: "failed" }], + }, + ], + }, + }); + flushSync(() => toggleButton().click()); + + expect(container.querySelector('a[href="/agents/agent-1/runs/run-1"]')).not.toBeNull(); + }); + + it("respects presentation.detailsDefaultOpen", () => { + renderNotice({ + presentation: { + kind: "system_notice", + tone: "warning", + title: "Run recovery", + detailsDefaultOpen: true, + }, + }); + expect(toggleButton().getAttribute("aria-expanded")).toBe("true"); + expect(toggleButton().textContent).toContain("Run recovery"); + expect( + container.querySelector('[data-testid="task-chat-system-notice-details"]'), + ).not.toBeNull(); + }); +}); diff --git a/ui/src/components/task-chat/TaskChatSystemNotice.tsx b/ui/src/components/task-chat/TaskChatSystemNotice.tsx new file mode 100644 index 0000000000..da9c40bd1b --- /dev/null +++ b/ui/src/components/task-chat/TaskChatSystemNotice.tsx @@ -0,0 +1,100 @@ +import { useId, useState } from "react"; +import { + ChevronDown, + CircleCheck, + Info, + OctagonAlert, + TriangleAlert, + type LucideIcon, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { MarkdownBody } from "@/components/MarkdownBody"; +import { + SystemNoticeMetadataSections, + type SystemNoticeTone, +} from "@/components/SystemNotice"; +import { humanizeSystemNotice } from "@/lib/system-notice-humanizer"; +import { mapCommentMetadataToSystemNoticeSections } from "@/lib/system-notice-comment"; +import { timeAgo } from "@/lib/timeAgo"; +import type { TaskChatMessageItem } from "./task-chat-model"; + +const TONE_ICON: Record = { + neutral: Info, + info: Info, + success: CircleCheck, + warning: TriangleAlert, + danger: OctagonAlert, +}; + +const TONE_ICON_CLASS: Record = { + neutral: "text-muted-foreground", + info: "text-(--status-task-icon-in_progress)", + success: "text-(--status-task-icon-done)", + warning: "text-(--status-task-icon-todo)", + danger: "text-(--status-task-icon-blocked)", +}; + +/** + * System comment row in the chat shell (PAP-443 / PAP-442 Phase 1): collapsed, + * it reads as a quiet centered one-liner in TaskChatMarker's register — tone + * icon + humanized plain-English title + relative time + chevron — instead of + * a large gray paragraph of raw text. Expanding reveals the full + * markdown-rendered body plus any structured metadata sections; nothing is + * suppressed, only folded. + */ +export function TaskChatSystemNotice({ item }: { item: TaskChatMessageItem }) { + const [open, setOpen] = useState(Boolean(item.presentation?.detailsDefaultOpen)); + const detailsId = useId(); + const { title, tone, detail } = humanizeSystemNotice({ + body: item.text, + presentation: item.presentation, + }); + const sections = mapCommentMetadataToSystemNoticeSections(item.metadata, { + runAgentId: item.runAgentId, + }); + const ToneIcon = TONE_ICON[tone]; + const relative = item.createdAtIso ? timeAgo(item.createdAtIso) : undefined; + + return ( +
+ + {open ? ( +
+
+ + {item.text} + +
+ {sections.length > 0 ? ( +
+ +
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/ui/src/components/task-chat/task-chat-adapter.test.ts b/ui/src/components/task-chat/task-chat-adapter.test.ts index 3af1b5b707..27e0ad3b53 100644 --- a/ui/src/components/task-chat/task-chat-adapter.test.ts +++ b/ui/src/components/task-chat/task-chat-adapter.test.ts @@ -22,4 +22,67 @@ describe("commentsToTaskChatItems", () => { expect(item.author).toBe("agent"); expect(item.interstitial).toBeUndefined(); }); + + it("classifies system comments as system even with a derivable run→agent linkage (PAP-443)", () => { + const comments = [ + { + id: "c-sys", + body: "Paperclip automatically retried dispatch, but it still has no live execution path.", + authorType: "system", + authorAgentId: null, + derivedAuthorAgentId: "agent-1", + createdAt: "2026-08-07T09:00:00.000Z", + } as unknown as IssueChatComment, + ]; + const items = commentsToTaskChatItems(comments); + expect(items).toHaveLength(1); + const item = items[0]; + if (item.kind !== "message") throw new Error("expected message item"); + expect(item.author).toBe("system"); + }); + + it("carries presentation, metadata, and the raw timestamp for system comments", () => { + const presentation = { + kind: "system_notice", + tone: "warning", + title: "Run recovery", + detailsDefaultOpen: true, + }; + const metadata = { + version: 1, + sections: [{ rows: [{ type: "text", label: "Reason", text: "quota" }] }], + }; + const comments = [ + { + id: "c-sys", + body: "Recovery notice.", + authorType: "system", + presentation, + metadata, + runAgentId: "agent-1", + createdAt: new Date("2026-08-07T09:00:00.000Z"), + } as unknown as IssueChatComment, + { + id: "c-agent", + body: "Agent reply.", + authorType: "agent", + authorAgentId: "agent-1", + presentation: null, + metadata, + createdAt: "2026-08-07T09:01:00.000Z", + } as unknown as IssueChatComment, + ]; + const items = commentsToTaskChatItems(comments); + const [sys, agent] = items; + if (sys.kind !== "message" || agent.kind !== "message") throw new Error("expected messages"); + expect(sys.presentation).toEqual(presentation); + expect(sys.metadata).toEqual(metadata); + expect(sys.runAgentId).toBe("agent-1"); + expect(sys.createdAtIso).toBe("2026-08-07T09:00:00.000Z"); + // Non-system authors keep the item lean — no structured notice fields. + expect(agent.presentation).toBeUndefined(); + expect(agent.metadata).toBeUndefined(); + expect(agent.runAgentId).toBeUndefined(); + expect(agent.createdAtIso).toBeUndefined(); + }); }); diff --git a/ui/src/components/task-chat/task-chat-adapter.ts b/ui/src/components/task-chat/task-chat-adapter.ts index 552b5099cc..db3edebbf7 100644 --- a/ui/src/components/task-chat/task-chat-adapter.ts +++ b/ui/src/components/task-chat/task-chat-adapter.ts @@ -34,10 +34,13 @@ function effectiveAgentId(comment: IssueChatComment): string | null { } function authorKind(comment: IssueChatComment): TaskChatAuthorKind { + // System authorship wins over any derivable run→agent linkage (PAP-443): + // recovery notices carry a derivedAuthorAgentId but must not render as + // agent bubbles. + if (comment.authorType === "system") return "system"; if (effectiveAgentId(comment)) return "agent"; if (comment.authorType === "user") return "human"; - if (comment.authorType === "agent") return "agent"; - return "system"; + return "agent"; } /** Shared bubble-footer time format ("2:34 PM") — also used by the description bubble (PAP-375). */ @@ -79,6 +82,12 @@ export function commentsToTaskChatItems( : comment.clientStatus === "pending" ? "pending" : undefined; + const createdAtIso = + comment.createdAt instanceof Date + ? comment.createdAt.toISOString() + : comment.createdAt + ? String(comment.createdAt) + : undefined; items.push({ id: comment.id || comment.clientId || `${comment.createdAt}`, kind: "message", @@ -90,6 +99,12 @@ export function commentsToTaskChatItems( agentIcon, onBehalfOfUserName, modeLabel: kind === "agent" ? ctx.agentModeLabelFor?.(comment) : undefined, + // System notices carry their structured hints through to the render + // layer (PAP-443); other authors keep the item lean. + presentation: kind === "system" ? comment.presentation ?? null : undefined, + metadata: kind === "system" ? comment.metadata ?? null : undefined, + runAgentId: kind === "system" ? comment.runAgentId ?? null : undefined, + createdAtIso: kind === "system" ? createdAtIso : undefined, }); } return items; diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index bbf42b6459..e465c8044c 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -12,6 +12,7 @@ * inventory for the mapping. No timing/motion values live here — those are * CSS motion tokens in ui/src/index.css. */ +import type { IssueCommentMetadata, IssueCommentPresentation } from "@paperclipai/shared"; import type { IssueThreadInteraction } from "@/lib/issue-thread-interactions"; /** Who authored a thread row — the primary legibility signal. */ @@ -100,6 +101,17 @@ export interface TaskChatMessageItem { * Expanding still nests the tool history beneath the bubble. */ attachedTurn?: TaskChatTurnItem; + /** + * Structured system-notice fields (PAP-443), carried only for + * author === "system": the comment's server-authored presentation hints and + * metadata sections drive the collapsed one-line row + expandable detail. + */ + presentation?: IssueCommentPresentation | null; + metadata?: IssueCommentMetadata | null; + /** Agent that owns the source run, used to build run-detail links in metadata rows. */ + runAgentId?: string | null; + /** Raw comment timestamp (ISO) — the collapsed system row shows relative time. */ + createdAtIso?: string; } /** Collapsed chain-of-thought (ACP agent_thought_chunk). */ diff --git a/ui/src/index.css b/ui/src/index.css index 3b4b143346..b68251e0c7 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -581,6 +581,7 @@ .tc-enter-tool { animation: tc-fade-rise var(--motion-tool-enter) var(--motion-ease-out-expo) both; } .tc-enter-status { animation: tc-status-in var(--motion-status-enter) var(--motion-ease-out-expo) both; } .tc-enter-cot-line { animation: tc-fade-rise var(--motion-duration-fast) var(--motion-ease-standard) both; } +.tc-notice-chevron { transition-duration: var(--motion-duration-fast); } .tc-reveal-diff { animation: tc-fade-in var(--motion-diff-reveal) var(--motion-ease-standard) both; } .tc-approval { animation: tc-approval-pulse var(--motion-approval-pulse) var(--motion-ease-standard) infinite; } .tc-cursor { animation: tc-cursor-blink var(--motion-streaming-cursor-blink) step-end infinite; } diff --git a/ui/src/lib/system-notice-comment.ts b/ui/src/lib/system-notice-comment.ts index c06e21e78f..6d3bd46336 100644 --- a/ui/src/lib/system-notice-comment.ts +++ b/ui/src/lib/system-notice-comment.ts @@ -57,7 +57,7 @@ function mapMetadataRow( }; } case "run_link": { - const runAgentId = ctx.runAgentId ?? null; + const runAgentId = row.agentId ?? ctx.runAgentId ?? null; const href = runAgentId ? `/agents/${runAgentId}/runs/${row.runId}` : undefined; return { kind: "run", diff --git a/ui/src/lib/system-notice-humanizer.test.ts b/ui/src/lib/system-notice-humanizer.test.ts new file mode 100644 index 0000000000..88d8787dce --- /dev/null +++ b/ui/src/lib/system-notice-humanizer.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { humanizeSystemNotice } from "./system-notice-humanizer"; + +describe("humanizeSystemNotice", () => { + it("uses presentation title and tone when present", () => { + const result = humanizeSystemNotice({ + body: "Paperclip automatically retried dispatch but it still has no live execution path.", + presentation: { + kind: "system_notice", + tone: "danger", + title: "Run recovery exhausted", + detailsDefaultOpen: false, + }, + }); + expect(result).toEqual({ title: "Run recovery exhausted", tone: "danger" }); + }); + + it("classifies claude_auth_required retry failures", () => { + const result = humanizeSystemNotice({ + body: + "Paperclip automatically retried continuation for this assigned `in_progress` issue during terminal run " + + "recovery, but it still has no live execution path. Latest retry failure: `claude_auth_required` - adapter " + + "handshake failed. Moving it to `blocked` so it is visible for intervention.", + }); + expect(result.title).toBe("Task paused — Claude needs re-authentication"); + expect(result.tone).toBe("warning"); + }); + + it("classifies configuration_incomplete failures", () => { + const result = humanizeSystemNotice({ + body: + "Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " + + "Latest retry failure: `configuration_incomplete`. Moving it to `blocked` with a source-scoped recovery action.", + }); + expect(result.title).toBe("Task paused — a secret/config binding is missing"); + expect(result.tone).toBe("warning"); + }); + + it("classifies missing secret bindings even without a failure code", () => { + const result = humanizeSystemNotice({ + body: + "Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " + + "Moving it to `blocked` so an operator can bind the missing secret(s).", + }); + expect(result.title).toBe("Task paused — a secret/config binding is missing"); + }); + + it("classifies workspace validation failures", () => { + const result = humanizeSystemNotice({ + body: + "Paperclip stopped before launching the local adapter because the issue workspace failed validation. " + + "This prevents git-sensitive adapters from running in an unrelated fallback cwd.", + }); + expect(result.title).toBe("Task paused — workspace problem"); + expect(result.tone).toBe("warning"); + }); + + it("names the recovery owner from the markdown link for generic no-live-execution-path notices", () => { + const result = humanizeSystemNotice({ + body: + "Paperclip automatically retried dispatch for this assigned `todo` issue during terminal run recovery, " + + "but it still has no live execution path. Moving it to `blocked` so it is visible for intervention.\n\n" + + "- Recovery action: `ra-123`\n" + + "- Recovery owner: [Dana the Manager](/PAP/agents/agent-9)\n" + + "- Next action: the recovery owner should restore a live execution path.", + }); + expect(result.title).toBe("Task paused — waiting on Dana the Manager"); + expect(result.tone).toBe("warning"); + }); + + it("still humanizes no-live-execution-path notices without an owner link", () => { + const result = humanizeSystemNotice({ + body: + "Paperclip automatically retried dispatch, but it still has no live execution path. " + + "Moving it to `blocked` so it is visible for intervention.", + }); + expect(result.title).toBe("Task paused — waiting on a recovery owner"); + }); + + it("falls back to System update plus a truncated first sentence", () => { + const longFirstSentence = + "The heartbeat scheduler observed an unusual condition while reconciling this issue's run ledger against the " + + "control plane state and recorded the discrepancy for later review."; + const result = humanizeSystemNotice({ body: `${longFirstSentence} Second sentence here.` }); + expect(result.title).toBe("System update"); + expect(result.tone).toBe("neutral"); + expect(result.detail).toBeDefined(); + expect(result.detail!.length).toBeLessThanOrEqual(80); + expect(result.detail!.endsWith("…")).toBe(true); + }); + + it("keeps a short first sentence untruncated in the fallback detail", () => { + const result = humanizeSystemNotice({ body: "Run rescheduled. More context follows." }); + expect(result).toEqual({ + title: "System update", + tone: "neutral", + detail: "Run rescheduled.", + }); + }); +}); diff --git a/ui/src/lib/system-notice-humanizer.ts b/ui/src/lib/system-notice-humanizer.ts new file mode 100644 index 0000000000..6b2177eada --- /dev/null +++ b/ui/src/lib/system-notice-humanizer.ts @@ -0,0 +1,92 @@ +import type { IssueCommentPresentation } from "@paperclipai/shared"; +import type { SystemNoticeTone } from "../components/SystemNotice"; + +/** + * Pure classifier for system comments in the chat shell (PAP-443 / PAP-442 + * Phase 1): derive a short plain-English one-liner + tone for the collapsed + * row. Server-authored presentation hints win when present; otherwise the + * known recovery families are pattern-matched from the comment body. Nothing + * here suppresses data — the full body stays available behind the expand. + */ +export interface HumanizedSystemNotice { + title: string; + tone: SystemNoticeTone; + /** Optional muted trailing snippet for the generic fallback row. */ + detail?: string; +} + +const FALLBACK_TITLE = "System update"; +const DETAIL_MAX_CHARS = 80; + +/** Failure code the recovery comments embed as "Latest retry failure: `code`". */ +function retryFailureCode(body: string): string | null { + const match = body.match(/Latest retry failure: `([^`]+)`/); + return match ? match[1] : null; +} + +/** Owner display name from a "Recovery owner: [Name](/…/agents/…)" markdown link. */ +function recoveryOwnerName(body: string): string | null { + const match = body.match(/Recovery owner: \[([^\]]+)\]\(/); + const name = match?.[1]?.trim(); + return name && name.length > 0 ? name : null; +} + +function firstSentence(body: string): string | undefined { + const firstLine = body + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0); + if (!firstLine) return undefined; + const sentenceEnd = firstLine.search(/[.!?](\s|$)/); + const sentence = sentenceEnd >= 0 ? firstLine.slice(0, sentenceEnd + 1) : firstLine; + if (sentence.length <= DETAIL_MAX_CHARS) return sentence; + return `${sentence.slice(0, DETAIL_MAX_CHARS - 1).trimEnd()}…`; +} + +export function humanizeSystemNotice(input: { + body: string; + presentation?: IssueCommentPresentation | null; +}): HumanizedSystemNotice { + const presentationTitle = input.presentation?.title?.trim(); + const presentationTone = input.presentation?.tone; + if (presentationTitle) { + return { title: presentationTitle, tone: presentationTone ?? "neutral" }; + } + + const body = input.body ?? ""; + const code = retryFailureCode(body); + + if (code === "claude_auth_required") { + return { + title: "Task paused — Claude needs re-authentication", + tone: presentationTone ?? "warning", + }; + } + if (code === "configuration_incomplete" || body.includes("secret/env bindings are missing")) { + return { + title: "Task paused — a secret/config binding is missing", + tone: presentationTone ?? "warning", + }; + } + if (code === "workspace_validation_failed" || body.includes("workspace failed validation")) { + return { + title: "Task paused — workspace problem", + tone: presentationTone ?? "warning", + }; + } + if (body.includes("no live execution path")) { + const owner = recoveryOwnerName(body); + return { + title: owner + ? `Task paused — waiting on ${owner}` + : "Task paused — waiting on a recovery owner", + tone: presentationTone ?? "warning", + }; + } + + return { + title: FALLBACK_TITLE, + tone: presentationTone ?? "neutral", + detail: firstSentence(body), + }; +}