fix: retain execution evidence for Retry and saved input (#15033)

## Thinking Path

> - Paperclip lets people steer and recover AI-agent conversations.
> - Recovery eligibility depends on retained cancellation receipts.
> - Run presentation intentionally omits result JSON on SQL_ASCII
databases and reduces oversized output.
> - Retry and the saved-input sweep mistakenly used that presentation
read for admission.
> - The banner could offer Retry while the endpoint rejected the same
stopped run.
> - Read the narrow execution-evidence fields for internal admission and
keep normal presentation unchanged.

## Linked Issues or Issue Description

Refs #15024 and #15015. Searched existing recovery and redaction PRs; no
duplicate fix found.

**What happened?**

On a SQL_ASCII instance, a verified pre-dispatch review-wait
cancellation offers Retry in the recovery notice. Clicking it returns an
eligibility conflict, and a saved user message remains deferred. The
notice reads the retained database receipt, but the endpoint and sweep
read a presentation projection where `resultJson` is null.

**Expected behavior**

Retry and saved user input use the recorded execution evidence and
ordinary admission gates, independent of presentation redaction. Public
run reads retain their existing encoding and output-size protections.

**Steps to reproduce**

1. Record a cancelled, unclaimed review-wait continuation and its
recovery hold.
2. Use the SQL_ASCII presentation projection, where run result JSON is
omitted.
3. Click Retry or save a new user message and let the recovery sweep
inspect it.
4. Verify a fresh turn starts once, with no replay of consumed input.

**Paperclip version or commit**

Reproduced on `9ae3d8db3`.

**Deployment mode**

Authenticated private self-hosted server with a SQL_ASCII database.

## What Changed

- Add an explicit internal read of cancellation, startup, review-wait,
tool-inventory, and Stop evidence; omit provider diagnostics.
- Use that read in the Retry route, wakeup validation, and saved-input
continuation checks.
- Preserve the distinction between absent result JSON and an
unrecognized stored result.
- Cover the reproduced SQL_ASCII Retry and saved-input failures,
retained public redaction, native Stop behavior, and excluded provider
output.
- Document the presentation and admission distinction.

## Verification

- Red: four selected assertions fail before the fix, including the
SQL_ASCII eligibility conflict and saved input remaining deferred.
- Targeted green regressions and existing native Stop cases pass.
- Workspace `pnpm -r typecheck` and `pnpm build` pass.
- All 626 affected recovery, continuation, and route tests pass,
including 22 focused admission and native Stop cases. Current-head CI
has 54 passing gates and 2 skipped optional Storybook checks. Greptile
reviewed `9b94af91e295a4e8007dfc6bff6a8d532d945e36` at 5/5 with no
findings or open review threads. No complete local monolithic pass is
claimed; the complete suite runs in sharded CI.

## Risks

Admission still checks recorded process and controller ownership,
provider events, cleanup, company scope, user authority, pending
decisions, and task holds. The evidence projection must retain every
field used by these eligibility predicates; existing native Stop cases
guard against dropping its acknowledgement receipt. No schema,
dependency, UI, or public response change.

## Model Used

OpenAI Codex, an agent based on GPT-6. The exact runtime model ID and
context window are not exposed in this session. Used reasoning,
repository tools, code execution, and browser inspection.

## 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:
DottaandPaperclip authored and GitHub committed 2026-10-03 01:38:57 -05:00
1 parent 9ae3d8db3d
commit ffe5e9e2a8
5 files changed
+71 -19

No files matched your search

+2
View File
@@ -668,6 +668,8 @@ The same bounded rule applies when the previous heartbeat reported waiting on a
A continuation that the staleness gate cancelled with `issue_continuation_waiting_on_review` is a *deliberate park*, not a disappeared execution path. The latest run reported that the issue is waiting for review/approval (for example, an umbrella issue whose work was just decomposed into sub-tasks). Treating that park as a stranded run would retry it, then escalate it to `blocked` with a recovery action and an operator-facing failure notice — even though nothing failed and there is nothing for a human to do.
Execution admission reads a narrow server-owned cancellation-evidence projection. Ordinary run presentation can redact `resultJson` for database encoding or output size; that presentation projection must not decide Retry eligibility or saved-input recovery. The admission projection excludes provider diagnostics and preserves whether the stored result is absent.
Recovery rule for a parked-for-review continuation:
- if the issue has a real waiting target — open (non-terminal) sub-tasks or existing unresolved blockers — Paperclip converts the deliberate wait into a first-class dependency wait: it sets the issue `blocked` by those issues, keeps the original assignee, and posts a plain-language comment explaining that the task will resume automatically when its dependencies finish. The issue then self-resumes through the normal `issue_blockers_resolved` path; no recovery action or escalation owner is involved
@@ -1163,7 +1163,7 @@ describe("agent live run routes", () => {
expect(res.status, JSON.stringify(res.body)).toBe(202);
expect(res.body).toEqual(receipt);
expect(mockHeartbeatService.getRun).toHaveBeenCalledWith(
failedChatRunId,
failedChatRunId, { includeExecutionEvidence: true },
);
expect(
mockChatRunRetries.prepareFailedChatRunRetry,
+1 -1
View File
@@ -5998,7 +5998,7 @@ export function agentRoutes(
"An exact failed-run retry cannot override its execution context.",
);
}
const failedRun = await heartbeat.getRun(req.body.failedRunId);
const failedRun = await heartbeat.getRun(req.body.failedRunId, { includeExecutionEvidence: true });
if (
!failedRun ||
failedRun.companyId !== agent.companyId ||
@@ -623,13 +623,37 @@ const support = await getEmbeddedPostgresTestSupport();
return f;
}
it.each(["message", "retry"])("recovers a pre-dispatch review wait through an explicit %s", async kind => {
it("keeps provider diagnostics out of admission evidence and preserves absent results", async () => {
const f = await seedCancelledReviewWait();
const heartbeat = heartbeatService(db);
await db.update(heartbeatRuns).set({ resultJson: {
stopReason: "issue_continuation_waiting_on_review", timeoutSource: "stale_queued_run_gate",
summary: "private provider output", providerPayload: "large provider output".repeat(10000),
} }).where(eq(heartbeatRuns.id, f.sourceRunId));
const projected = await heartbeat.getRun(f.sourceRunId, { includeExecutionEvidence: true });
expect(projected?.resultJson).toMatchObject({ stopReason: "issue_continuation_waiting_on_review", timeoutSource: "stale_queued_run_gate" });
expect(projected?.resultJson).not.toHaveProperty("summary");
expect(projected?.resultJson).not.toHaveProperty("providerPayload");
await db.update(heartbeatRuns).set({ resultJson: null }).where(eq(heartbeatRuns.id, f.sourceRunId));
expect((await heartbeat.getRun(f.sourceRunId, { includeExecutionEvidence: true }))?.resultJson).toBeNull();
await db.update(heartbeatRuns).set({ resultJson: { unrecognizedReceipt: true } }).where(eq(heartbeatRuns.id, f.sourceRunId));
expect((await heartbeat.getRun(f.sourceRunId, { includeExecutionEvidence: true }))?.resultJson).not.toBeNull();
});
it.each([false, true].flatMap(ascii => ["message", "retry"].map(kind => ({ ascii, kind }))))(
"recovers a pre-dispatch review wait through an explicit $kind (SQL_ASCII: $ascii)", async ({ ascii, kind }) => {
const f = await seedCancelledReviewWait();
const heartbeat = heartbeatService(db);
if (ascii) {
const encoding = vi.spyOn(db, "execute").mockResolvedValueOnce([{ server_encoding: "SQL_ASCII" }] as never);
try { expect((await heartbeat.getRun(f.sourceRunId))?.resultJson).toBeNull(); }
finally { encoding.mockRestore(); }
}
const notice = await getExecutionBlocker(db, f.companyId, f.issueId);
expect(notice).toMatchObject({ canRetry: true, runError: "Waiting for review; this continuation never started." });
expect(notice?.nextAction).not.toContain("Inspect the run before sending a new message");
await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, status: "running" });
const successor = await heartbeatService(db).wakeup(f.agentId, { source: kind === "retry" ? "on_demand" : "automation", triggerDetail: "manual",
const successor = await heartbeat.wakeup(f.agentId, { source: kind === "retry" ? "on_demand" : "automation", triggerDetail: "manual",
reason: kind === "retry" ? "retry_failed_run" : "issue_commented",
...(kind === "retry" ? { failedRunId: f.sourceRunId } : {}), requestedByActorType: "user", requestedByActorId: "board",
payload: { issueId: f.issueId, ...(kind === "message" ? { commentId: f.commentId } : {}) },
@@ -641,13 +665,19 @@ const support = await getEmbeddedPostgresTestSupport();
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId)))[0].status).toBe("cancelled");
});
it("reconsiders saved input after a pre-dispatch review wait exactly once", async () => {
it.each([false, true])("reconsiders saved input after a pre-dispatch review wait exactly once (SQL_ASCII: %s)", async ascii => {
const f = await seedCancelledReviewWait();
const heartbeat = heartbeatService(db);
if (ascii) {
const encoding = vi.spyOn(db, "execute").mockResolvedValueOnce([{ server_encoding: "SQL_ASCII" }] as never);
try { expect((await heartbeat.getRun(f.sourceRunId))?.resultJson).toBeNull(); }
finally { encoding.mockRestore(); }
}
await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: f.agentId, status: "running" });
const leaseId = randomUUID();
await db.insert(environmentLeases).values({ id: leaseId, companyId: f.companyId,
heartbeatRunId: f.sourceRunId, provider: "local", status: "pending_cleanup", cleanupStatus: "failed" });
await heartbeatService(db).wakeup(f.agentId, { source: "automation", reason: "issue_commented",
await heartbeat.wakeup(f.agentId, { source: "automation", reason: "issue_commented",
requestedByActorType: "user", requestedByActorId: "board", payload: { issueId: f.issueId, commentId: f.commentId },
contextSnapshot: { issueId: f.issueId, wakeCommentId: f.commentId } });
const [waiting] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, f.companyId));
@@ -655,7 +685,7 @@ const support = await getEmbeddedPostgresTestSupport();
await db.update(environmentLeases).set({ status: "released", releasedAt: new Date(), cleanupStatus: "succeeded" })
.where(eq(environmentLeases.id, leaseId));
await db.update(agentWakeupRequests).set({ updatedAt: new Date(0) }).where(eq(agentWakeupRequests.id, waiting.id));
await Promise.all([heartbeatService(db).resumeExecutionWaitComments(), heartbeatService(db).resumeExecutionWaitComments()]);
await Promise.all([heartbeat.resumeExecutionWaitComments(), heartbeat.resumeExecutionWaitComments()]);
const successors = await db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, f.companyId), eq(heartbeatRuns.status, "queued")));
expect(successors).toHaveLength(1);
expect(successors[0].contextSnapshot).toMatchObject({ wakeCommentIds: [f.commentId],
+32 -12
View File
@@ -3558,6 +3558,27 @@ const heartbeatRunSafeResultJsonColumn = sql<Record<string, unknown> | null>`
end
`.as("resultJson");
// Execution admission needs retained server receipts even when presentation
// projection omits resultJson (SQL_ASCII or oversized provider output). Select
// only the evidence used by eligibility; never retrieve provider diagnostics.
const heartbeatRunExecutionEvidenceColumn = sql<Record<string, unknown> | null>`
case when ${heartbeatRuns.resultJson} is null then null else jsonb_build_object(
'startupCancellation', ${heartbeatRuns.resultJson} -> 'startupCancellation',
'startupPreparationSettledAt', ${heartbeatRuns.resultJson} -> 'startupPreparationSettledAt',
'stopReason', ${heartbeatRuns.resultJson} -> 'stopReason',
'timeoutSource', ${heartbeatRuns.resultJson} -> 'timeoutSource',
'workspaceRestoreFailure', ${heartbeatRuns.resultJson} -> 'workspaceRestoreFailure',
'executionCancellation', ${heartbeatRuns.resultJson} -> 'executionCancellation',
'nativeCancellation', ${heartbeatRuns.resultJson} -> 'nativeCancellation',
'cancelledByActorType', ${heartbeatRuns.resultJson} -> 'cancelledByActorType',
'cancelledByUserId', ${heartbeatRuns.resultJson} -> 'cancelledByUserId',
'conversationContinuation', ${heartbeatRuns.resultJson} -> 'conversationContinuation',
'cancellation', ${heartbeatRuns.resultJson} -> 'cancellation',
'acpToolInventoryComplete', ${heartbeatRuns.resultJson} -> 'acpToolInventoryComplete',
'acpPendingToolCount', ${heartbeatRuns.resultJson} -> 'acpPendingToolCount'
) end
`.as("resultJson");
const heartbeatRunSafeColumns = {
...getTableColumns(heartbeatRuns),
processGroupId: heartbeatRunProcessGroupIdColumn,
@@ -10384,7 +10405,7 @@ export function heartbeatService(
!(await remoteExecutionHasStopped(db, run.companyId, run.id))) return;
const issueId = run.nativeIssueId ?? (typeof run.contextSnapshot?.issueId === "string" ? run.contextSnapshot.issueId : null);
if (!issueId) return;
const currentRun = run.runtimeMode === "native" ? await getRun(run.id) : null;
const currentRun = run.runtimeMode === "native" ? await getRun(run.id, { includeExecutionEvidence: true }) : null;
const [coordinator] = currentRun ? await db.select({ phase: nativeRunFinalizations.phase,
leaseOwner: nativeRunFinalizations.leaseOwner }).from(nativeRunFinalizations).where(and(
eq(nativeRunFinalizations.companyId, run.companyId), eq(nativeRunFinalizations.runId, run.id),
@@ -10399,7 +10420,7 @@ export function heartbeatService(
await acknowledgedNativeStopExecutionHasStopped(db, currentRun) &&
!(await getExecutionBlocker(db, run.companyId, issueId));
const legacyContinuation = run.runtimeMode === "legacy" &&
hasConversationContinuationPolicy((await getRun(run.id))?.resultJson) &&
hasConversationContinuationPolicy((await getRun(run.id, { includeExecutionEvidence: true }))?.resultJson) &&
!(await getExecutionBlocker(db, run.companyId, issueId));
if (run.runtimeMode !== "native" && run.runtimeMode !== "legacy") return;
const pending = await db.select().from(agentWakeupRequests).where(and(
@@ -10630,7 +10651,7 @@ export function heartbeatService(
const blocker = await getExecutionBlocker(db, wake.companyId, issueId);
const sourceId = blocker?.runId;
if (!sourceId || !isUuidLike(sourceId)) continue;
const run = await getRun(sourceId);
const run = await getRun(sourceId, { includeExecutionEvidence: true });
if (!run || run.companyId !== wake.companyId || run.agentId !== wake.agentId) continue;
if (canContinueCancelledRun(run)) {
await resumeSavedLegacyComments(wake.companyId, wake.id).catch(err => {
@@ -10731,18 +10752,17 @@ export function heartbeatService(
async function getRun(
runId: string,
opts?: { unsafeFullResultJson?: boolean },
opts?: { unsafeFullResultJson?: boolean; includeExecutionEvidence?: boolean },
) {
const safeForLegacyEncoding =
!opts?.unsafeFullResultJson && (await hasUnsafeTextProjectionDatabase());
const columns = opts?.unsafeFullResultJson
? getTableColumns(heartbeatRuns)
: safeForLegacyEncoding ? heartbeatRunSqlAsciiSafeColumns : heartbeatRunSafeColumns;
return db
.select(
opts?.unsafeFullResultJson
? getTableColumns(heartbeatRuns)
: safeForLegacyEncoding
? heartbeatRunSqlAsciiSafeColumns
: heartbeatRunSafeColumns,
)
.select(opts?.includeExecutionEvidence
? { ...columns, resultJson: heartbeatRunExecutionEvidenceColumn }
: columns)
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => rows[0] ?? null);
@@ -26905,7 +26925,7 @@ export function heartbeatService(
}
if (opts.failedRunId) {
const failed = await getRun(opts.failedRunId);
const failed = await getRun(opts.failedRunId, { includeExecutionEvidence: true });
if (opts.requestedByActorType !== "user" || !opts.requestedByActorId ||
reason !== "retry_failed_run" || source !== "on_demand" || triggerDetail !== "manual" ||
!failed || failed.companyId !== agent.companyId || failed.agentId !== agentId ||