Files
PaperClipAI/server/src/services/issue-continuation-summary.ts
T
DottaandPaperclip 3b4b270650 fix(adapters): preserve ACP terminal failure diagnostics (#14573)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The shared ACP adapter engine records agent failures for operators.
> - ACP providers can report a failure category, title, and detailed
cause.
> - Our patch kept only the category in the saved error, so an operator
could not diagnose a failure when tracing was off.
> - This pull request preserves redacted provider diagnostics in the run
error, transcript, and structured run result.
> - Operators can now inspect the provider message and any supplied
request ID or stack trace after the run ends.

## Linked Issues or Issue Description

Refs #13889 (the diagnostic gap; this PR does not update the bundled
Claude version).
Refs #14484 (related model-refusal classification; this PR retains
diagnostics for all terminal failure categories).

**What happened?**
An ACP turn failed with only `ACP agent reported a terminal service
failure.` The provider's title and details were available in memory but
absent from the saved error and transcript.

**Expected behavior**
The run retains useful provider diagnostics even when raw tracing is
disabled. Credentials remain redacted. A size limit must report
truncation instead of silently removing the cause.

**Steps to reproduce**
1. Run an ACP agent that returns an error-severity typed session
failure.
2. Include an HTTP error, request ID, and stack text in its title and
details.
3. Inspect the failed run with tracing disabled. Before this change,
only the category survives.

## What Changed

- Both pinned ACPX patches pass complete error text to the in-memory
callback, so redaction happens before truncation.
- The shared engine retains the sanitized category, title, and details
in `resultJson.terminalSessionFailure` and includes the text in the run
error and error transcript.
- Diagnostics redact configured environment values even under arbitrary
names, unknown launch-environment values, connection URL passwords, run
credentials, and common credential syntax. Known boolean settings remain
readable, while credential values are redacted even when embedded in
other text. Diagnostics remove control characters and invalid Unicode.
- Title and detail limits keep escaped transcript JSON below the
server's chunk limit. Truncated fields include an omission count. The
safe run-result projection preserves a byte-bounded diagnostic preview
when the result exceeds its byte budget, with an explicit pointer to the
full adapter-bounded run error and transcript.
- The existing UI and CLI display the error. Diagnostics do not become
assistant output. Issue continuation summaries and session-compaction
prompts receive only the generic category, preventing provider text from
becoming handoff instructions. Existing quota classification, warnings,
timeout precedence, and control-channel failure precedence remain in
place.
- Regression tests cover real ACP child processes with both pinned
versions in one-shot and persistent modes, credential redaction, request
IDs after the old 4 KiB cutoff, transcript parsing, storage bounds, and
database retrieval of oversized multibyte diagnostics.

## Verification

- Full CI on `20ad4f5f1f66c46d2c260e6ad0339cbea607b4cf`: **54 passed, 2
intentionally skipped, no pending or failing checks**. Includes
typechecking, build, all Vitest shards, Runner checks, browser E2E, and
the canary packaging/public-install dry run.
- Greptile: **5/5** on this commit. Superagent security scan passes. All
review threads are resolved.
- Local verification passed: shared ACP engine suite (395 tests); real
Claude ACP child-process and diagnostic regressions across both pinned
runtimes and both execution modes; run retrieval and model-handoff
regressions (59 tests); ACPX patch packaging (16 tests); full typecheck
and build. Affected package typechecks and focused tests were rerun
after review fixes.
- The broad local `pnpm test:run` was stopped after review edits made
its cached imports stale. Fresh targeted runs pass, including both
affected server suites. Cold-build import failures were also rerun after
dependency builds: chat integration (1,063 tests) and tool access (351
tests) pass. The final commit's complete CI matrix is green.

## Risks

- Provider diagnostic text is untrusted. This change retains more of it
in company-scoped run records. Redaction and size bounds apply before
persistence.
- Diagnostics are limited to fields the provider supplies. Old runs
cannot recover discarded error text.
- No schema migration, recovery-policy change, or new Telemetry or
OpenTelemetry export.

## Model Used

- OpenAI GPT-6 through Codex, with reasoning, repository inspection,
code editing, and test execution. The exact serving model ID and
context-window size are not exposed in this session.

## 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>
2026-09-29 10:18:30 -05:00

292 lines
10 KiB
TypeScript

import { and, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { documents, issueDocuments, issues } from "@paperclipai/db";
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, type SourceTrustMetadata } from "@paperclipai/shared";
import { documentService } from "./documents.js";
import { summarizeRunErrorForModel } from "./heartbeat-run-summary.js";
export { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY };
export const ISSUE_CONTINUATION_SUMMARY_TITLE = "Continuation Summary";
export const ISSUE_CONTINUATION_SUMMARY_MAX_BODY_CHARS = 8_000;
const SUMMARY_SECTION_MAX_CHARS = 1_200;
const PATH_CANDIDATE_RE = /(?:^|[\s`"'(])((?:server|ui|packages|doc|scripts|\.github)\/[A-Za-z0-9._/-]+)/g;
const WAITING_FOR_REVIEW_OR_APPROVAL_RE =
/\bwait(?:ing)? for\b.{0,160}\b(?:review(?:er)?(?: feedback)?|approval|board|human|user|operator)\b/i;
type IssueSummaryInput = {
id: string;
identifier: string | null;
title: string;
description: string | null;
status: string;
priority: string;
};
type RunSummaryInput = {
id: string;
status: string;
error: string | null;
errorCode?: string | null;
resultJson?: Record<string, unknown> | null;
stdoutExcerpt?: string | null;
stderrExcerpt?: string | null;
finishedAt?: Date | null;
};
type AgentSummaryInput = {
id: string;
name: string;
adapterType: string | null;
};
export type IssueContinuationSummaryDocument = {
key: typeof ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY;
title: string | null;
body: string;
latestRevisionId: string | null;
latestRevisionNumber: number;
sourceTrust: SourceTrustMetadata | null;
updatedAt: Date;
};
function truncateText(value: string, maxChars: number) {
const trimmed = value.trim();
if (trimmed.length <= maxChars) return trimmed;
return `${trimmed.slice(0, Math.max(0, maxChars - 20)).trimEnd()}\n[truncated]`;
}
function asNonEmptyString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function readResultSummary(resultJson: Record<string, unknown> | null | undefined) {
if (!resultJson || typeof resultJson !== "object" || Array.isArray(resultJson)) return null;
return (
asNonEmptyString(resultJson.summary) ??
asNonEmptyString(resultJson.result) ??
asNonEmptyString(resultJson.message) ??
asNonEmptyString(resultJson.error) ??
null
);
}
function extractMarkdownSection(markdown: string | null | undefined, heading: string) {
if (!markdown) return null;
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`^##\\s+${escaped}\\s*$([\\s\\S]*?)(?=^##\\s+|(?![\\s\\S]))`, "im");
const match = re.exec(markdown);
const section = match?.[1]?.trim();
return section ? truncateText(section, SUMMARY_SECTION_MAX_CHARS) : null;
}
function extractPathCandidates(...texts: Array<string | null | undefined>) {
const seen = new Set<string>();
for (const text of texts) {
if (!text) continue;
for (const match of text.matchAll(PATH_CANDIDATE_RE)) {
const path = match[1]?.replace(/[),.;:]+$/, "");
if (path) seen.add(path);
if (seen.size >= 12) break;
}
if (seen.size >= 12) break;
}
return [...seen];
}
function inferMode(issue: IssueSummaryInput, run: RunSummaryInput) {
if (issue.status === "done" || issue.status === "in_review") return "review";
if (run.status === "failed" || run.status === "timed_out" || run.status === "cancelled" || run.status === "interrupted") return "implementation";
if (issue.status === "backlog" || issue.status === "todo") return "plan";
return "implementation";
}
function inferNextAction(issue: IssueSummaryInput, run: RunSummaryInput, previousNextAction: string | null) {
if (issue.status === "done") return "Review the completed issue output and close any remaining follow-up comments.";
if (issue.status === "in_review") return "Wait for reviewer feedback or approval before continuing executor work.";
if (run.status === "failed" || run.status === "timed_out") {
return "Inspect the failed run, fix the cause, and resume from the most recent concrete action above.";
}
if (run.status === "cancelled") return "Confirm the cancellation reason before starting another run.";
return previousNextAction ?? "Resume implementation from the acceptance criteria, latest comments, and this summary.";
}
function bulletList(items: string[], empty: string) {
if (items.length === 0) return `- ${empty}`;
return items.map((item) => `- ${item}`).join("\n");
}
function extractPreviousNextAction(previousBody: string | null | undefined) {
const section = extractMarkdownSection(previousBody, "Next Action");
if (!section) return null;
return section
.split(/\r?\n/)
.map((line) => line.replace(/^[-*]\s+/, "").trim())
.find(Boolean) ?? null;
}
export function extractContinuationSummaryNextAction(body: string | null | undefined) {
return extractPreviousNextAction(body);
}
export function continuationSummaryParksExecutor(body: string | null | undefined) {
const nextAction = extractContinuationSummaryNextAction(body);
if (!nextAction) return false;
return WAITING_FOR_REVIEW_OR_APPROVAL_RE.test(nextAction);
}
export function buildContinuationSummaryMarkdown(input: {
issue: IssueSummaryInput;
run: RunSummaryInput;
agent: AgentSummaryInput;
previousSummaryBody?: string | null;
}) {
const { issue, run, agent } = input;
const resultSummary = readResultSummary(run.resultJson);
const terminalFailure = run.resultJson?.terminalSessionFailure;
const terminalFailureCategory = terminalFailure && typeof terminalFailure === "object" && !Array.isArray(terminalFailure)
? ((terminalFailure as Record<string, unknown>).category ?? "unknown")
: null;
const modelError = summarizeRunErrorForModel(run.error, terminalFailureCategory);
const recentActions = [
`Run \`${run.id}\` finished with status \`${run.status}\`${run.finishedAt ? ` at ${run.finishedAt.toISOString()}` : ""}.`,
resultSummary ? truncateText(resultSummary, SUMMARY_SECTION_MAX_CHARS) : "No adapter-provided result summary was captured for this run.",
];
if (modelError) {
recentActions.push(`Latest run error${run.errorCode ? ` (${run.errorCode})` : ""}: ${truncateText(modelError, 500)}`);
}
const paths = extractPathCandidates(resultSummary, run.stdoutExcerpt, run.stderrExcerpt, input.previousSummaryBody);
const objective = extractMarkdownSection(issue.description, "Objective") ?? issue.description?.trim() ?? "No objective captured.";
const acceptanceCriteria = extractMarkdownSection(issue.description, "Acceptance Criteria") ?? "No explicit acceptance criteria captured.";
const mode = inferMode(issue, run);
const nextAction = inferNextAction(issue, run, extractPreviousNextAction(input.previousSummaryBody));
const body = [
"# Continuation Summary",
"",
`- Issue: ${issue.identifier ?? issue.id} — ${issue.title}`,
`- Status: ${issue.status}`,
`- Priority: ${issue.priority}`,
`- Current mode: ${mode}`,
`- Last updated by run: ${run.id}`,
`- Agent: ${agent.name} (${agent.adapterType ?? "unknown"})`,
"",
"## Objective",
"",
truncateText(objective, SUMMARY_SECTION_MAX_CHARS),
"",
"## Acceptance Criteria",
"",
acceptanceCriteria,
"",
"## Recent Concrete Actions",
"",
bulletList(recentActions, "No recent actions captured."),
"",
"## Files / Routes Touched",
"",
bulletList(paths.map((path) => `\`${path}\``), "No file or route paths were detected in the captured run summary."),
"",
"## Commands Run",
"",
bulletList(
[
`Heartbeat run \`${run.id}\` invoked adapter \`${agent.adapterType ?? "unknown"}\`.`,
"Detailed shell/tool commands remain in the run log and transcript.",
],
"No command metadata captured.",
),
"",
"## Blockers / Decisions",
"",
bulletList(
run.error
? [`Latest run ended with \`${run.status}\`; inspect the error before continuing.`]
: ["No new blocker was recorded by the latest run."],
"No blockers or decisions captured.",
),
"",
"## Next Action",
"",
`- ${nextAction}`,
].join("\n");
return truncateText(body, ISSUE_CONTINUATION_SUMMARY_MAX_BODY_CHARS);
}
export async function getIssueContinuationSummaryDocument(
db: Db,
issueId: string,
): Promise<IssueContinuationSummaryDocument | null> {
const row = await db
.select({
key: issueDocuments.key,
title: documents.title,
body: documents.latestBody,
latestRevisionId: documents.latestRevisionId,
latestRevisionNumber: documents.latestRevisionNumber,
sourceTrust: documents.sourceTrust,
updatedAt: documents.updatedAt,
})
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(eq(issueDocuments.issueId, issueId), eq(issueDocuments.key, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY)))
.then((rows) => rows[0] ?? null);
if (!row) return null;
return {
key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
title: row.title,
body: row.body,
latestRevisionId: row.latestRevisionId,
latestRevisionNumber: row.latestRevisionNumber,
sourceTrust: row.sourceTrust ?? null,
updatedAt: row.updatedAt,
};
}
export async function refreshIssueContinuationSummary(input: {
db: Db;
issueId: string;
run: RunSummaryInput;
agent: AgentSummaryInput;
}) {
const { db, issueId, run, agent } = input;
const [issue, existing] = await Promise.all([
db
.select({
id: issues.id,
conversationAgentId: issues.conversationAgentId,
identifier: issues.identifier,
title: issues.title,
description: issues.description,
status: issues.status,
priority: issues.priority,
})
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0] ?? null),
getIssueContinuationSummaryDocument(db, issueId),
]);
if (!issue || issue.conversationAgentId) return null;
const body = buildContinuationSummaryMarkdown({
issue,
run,
agent,
previousSummaryBody: existing?.body ?? null,
});
const result = await documentService(db).upsertIssueDocument({
issueId,
key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
title: ISSUE_CONTINUATION_SUMMARY_TITLE,
format: "markdown",
body,
baseRevisionId: existing?.latestRevisionId ?? null,
changeSummary: `Refresh continuation summary after run ${run.id}`,
createdByAgentId: agent.id,
createdByRunId: run.id,
});
return result.document;
}