mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
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:
1 parent
9ae3d8db3d
commit
ffe5e9e2a8
5 files changed
+71
-19
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
Reference in new issue
Block a user