feat: structure and humanize recovery notices (#11075)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip posts system comments when automatic run recovery cannot continue > - These comments currently mix the main event with recovery identifiers and routing details > - The task chat shell also renders these comments as large raw text blocks > - Operators need a short explanation first and inspectable evidence on demand > - This pull request emits structured recovery notices and renders them as compact humanized rows > - The benefit is a quieter task thread that keeps the full recovery evidence available ## Linked Issues or Issue Description Related prior extraction source: #11070. This pull request replaces only its structured recovery notice slice with a focused branch based on current master. **What existing behavior does this improve?** Paperclip recovery escalations and the experimental task chat system-comment renderer. **Current behavior** Recovery escalation comments put action identifiers, owner details, run details, and failure codes into the visible markdown body. The task chat shell renders the complete system comment as a large text block. **Proposed behavior** The server emits a short system notice with typed metadata sections. The task chat shell classifies known recovery families and renders one compact row. An operator can expand the row to inspect the full body and metadata. **Reason and benefit** The main thread stays readable during repeated recovery activity. Typed links and evidence remain available without exposing raw failure text in the default view. **Breaking changes** The visible recovery comment body is shorter. Recovery action deduplication now reads the structured metadata and still recognizes legacy body markers. No API schema or database migration changes. ## What Changed - Emit stranded recovery escalations with `system_notice` presentation and typed recovery, owner, run, and failure-code metadata. - Share bounded metadata row builders across recovery notice producers and preserve legacy deduplication compatibility. - Humanize known recovery notice families and render compact expandable task-chat rows. - Route system-authored comments ahead of derived agent authorship so recovery notices do not appear as agent bubbles. - Add focused server and UI regression coverage. ## Verification - `pnpm check:token-gates` — 3/3 clean. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm --filter @paperclipai/shared typecheck` — passed. - `pnpm --filter @paperclipai/shared exec vitest run src/validators/issue.test.ts` — 32 tests passed. - `pnpm --filter @paperclipai/server exec vitest run src/services/recovery/stranded-notice.test.ts src/__tests__/issue-recovery-actions.test.ts` — 57 tests passed. - `pnpm --filter @paperclipai/server exec vitest run src/services/recovery/successful-run-handoff.test.ts src/services/recovery/stranded-notice.test.ts` — 39 tests passed. - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/heartbeat-process-recovery.test.ts -t 'escalates an exhausted failed successful-run handoff without using generic continuation recovery first|escalates an exhausted successful handoff run that still leaves no disposition'` — 2 tests passed. - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/heartbeat-process-recovery.test.ts -t 'blocks assigned todo work after the one automatic dispatch recovery was already used'` — passed. - `pnpm --filter @paperclipai/ui exec vitest run src/lib/system-notice-humanizer.test.ts src/components/task-chat/TaskChatSystemNotice.test.tsx src/components/task-chat/task-chat-adapter.test.ts` — 15 tests passed. - Storybook visual baselines were not updated because this chat-shell path has no affected snapshot baseline. Focused rendering tests and token gates cover this change. ## Risks - Consumers that parse recovery action identifiers from comment markdown must move to structured metadata. Server deduplication remains backward compatible with legacy comments. - The humanizer uses stable recovery-family phrases. Unknown notices use a generic truncated first-sentence fallback. - The UI changes only the experimental task chat presentation. The stored comment body and expanded metadata remain available. > 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, reasoning mode, repository tools, shell execution, and GitHub integration. The runtime did not expose a context-window size. ## 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>
This commit is contained in:
parent
4e9a78db58
commit
cc35c3c395
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<typeof heartbeatRuns.$inferSelect, "error" | "errorCode"> | 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<typeof heartbeatRuns.$inferSelect, "status" | "contextSnapshot"> | null,
|
||||
expectedRetryReason:
|
||||
|
|
@ -16379,73 +16361,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
}
|
||||
}
|
||||
|
||||
function buildImmediateExecutionPathRecoveryComment(input: {
|
||||
status: "todo" | "in_progress";
|
||||
latestRun: Pick<typeof heartbeatRuns.$inferSelect, "error" | "errorCode"> | 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<typeof heartbeatRuns.$inferSelect, "error" | "errorCode" | "resultJson">
|
||||
| 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<typeof heartbeatRuns.$inferSelect, "error" | "errorCode"> | 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<typeof heartbeatRuns.$inferSelect, "error" | "errorCode"> | 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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, string> = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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" }),
|
||||
]),
|
||||
|
|
|
|||
|
|
@ -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<typeof agents.$inferSelect, "id" | "companyId" | "status">;
|
||||
type NoticeIssue = Pick<typeof issues.$inferSelect, "id" | "identifier" | "title" | "status">;
|
||||
type NoticeRun = Pick<typeof heartbeatRuns.$inferSelect, "id" | "status">;
|
||||
type NoticeRun = Pick<typeof heartbeatRuns.$inferSelect, "id" | "status" | "agentId">;
|
||||
type NoticeAgent = Pick<typeof agents.$inferSelect, "id" | "name">;
|
||||
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 ||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="divide-y divide-border/50 px-1 py-1">
|
||||
{sections.map((section, sectionIdx) => (
|
||||
<div key={sectionIdx} className="py-1.5 first:pt-2 last:pb-2">
|
||||
{section.title ? (
|
||||
<div className="px-3 pb-1 pt-0.5 text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-caps) text-muted-foreground">
|
||||
{section.title}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
{section.rows.map((row, rowIdx) => (
|
||||
<MetadataRow key={rowIdx} row={row} tone={tokens} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemNotice({
|
||||
tone = "neutral",
|
||||
label,
|
||||
|
|
@ -312,22 +344,7 @@ export function SystemNotice({
|
|||
tokens.divider,
|
||||
)}
|
||||
>
|
||||
<div className="divide-y divide-border/50 px-1 py-1">
|
||||
{metadata!.map((section, sectionIdx) => (
|
||||
<div key={sectionIdx} className="py-1.5 first:pt-2 last:pb-2">
|
||||
{section.title ? (
|
||||
<div className="px-3 pb-1 pt-0.5 text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-caps) text-muted-foreground">
|
||||
{section.title}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
{section.rows.map((row, rowIdx) => (
|
||||
<MetadataRow key={rowIdx} row={row} tone={tokens} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SystemNoticeMetadataSections sections={metadata!} tone={tone} />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="tc-enter-bubble flex justify-center py-1">
|
||||
<p className="max-w-(--pct-85) text-center text-xs text-muted-foreground">{item.text}</p>
|
||||
</div>
|
||||
);
|
||||
// Collapsed humanized one-liner, expandable to the full detail (PAP-443).
|
||||
return <TaskChatSystemNotice item={item} />;
|
||||
}
|
||||
|
||||
const isHuman = item.author === "human";
|
||||
|
|
|
|||
|
|
@ -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<TaskChatMessageItem> = {}) {
|
||||
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(
|
||||
<ThemeProvider>
|
||||
<TaskChatSystemNotice item={item} />
|
||||
</ThemeProvider>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function toggleButton() {
|
||||
return container.querySelector<HTMLButtonElement>(
|
||||
'[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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<SystemNoticeTone, LucideIcon> = {
|
||||
neutral: Info,
|
||||
info: Info,
|
||||
success: CircleCheck,
|
||||
warning: TriangleAlert,
|
||||
danger: OctagonAlert,
|
||||
};
|
||||
|
||||
const TONE_ICON_CLASS: Record<SystemNoticeTone, string> = {
|
||||
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 (
|
||||
<div className="tc-enter-bubble flex flex-col items-center py-1" data-testid="task-chat-system-notice">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={detailsId}
|
||||
className={cn(
|
||||
"flex max-w-(--pct-85) items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground",
|
||||
"hover:bg-muted/50 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
)}
|
||||
>
|
||||
<ToneIcon className={cn("h-3.5 w-3.5 shrink-0", TONE_ICON_CLASS[tone])} aria-hidden />
|
||||
<span className="truncate font-medium">{title}</span>
|
||||
{detail ? <span className="hidden truncate sm:inline">· {detail}</span> : null}
|
||||
{relative ? <span className="shrink-0 text-muted-foreground/70">· {relative}</span> : null}
|
||||
<ChevronDown
|
||||
className={cn("tc-notice-chevron h-3.5 w-3.5 shrink-0 transition-transform", open && "rotate-180")}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
id={detailsId}
|
||||
data-testid="task-chat-system-notice-details"
|
||||
className="mt-1 w-full max-w-(--pct-85) overflow-hidden rounded-lg border border-border bg-muted/25 text-left text-sm dark:bg-muted/15"
|
||||
>
|
||||
<div className="px-3 py-2.5 text-foreground/90">
|
||||
<MarkdownBody softBreaks linkIssueReferences>
|
||||
{item.text}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
{sections.length > 0 ? (
|
||||
<div className="border-t border-border/70 bg-background/50 dark:bg-background/30">
|
||||
<SystemNoticeMetadataSections sections={sections} tone={tone} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue