From 23909077adcbaf028bc59892d06b72d4482e8528 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:04:19 -0600 Subject: [PATCH] fix(retry): re-deliver the failed run's wake when the board retries it (#20970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - When a run fails, the board shows a Retry button. The Retry starts a new run with the wake reason `task_retry`. > - A failed run can serve a wake that happens once, for example a scheduled task monitor check (`issue_monitor_due`). The monitor is consumed when it fires. > - The Retry run did not tell the agent which wake it retries. The agent saw only `task_retry` and the earlier interruption. > - In the restart-chaos harness, the agent followed its brief ("act only on a board comment or a scheduled monitor check"), decided that the Retry was not the monitor check, and changed nothing. The run succeeded, but the task stayed `in_progress` with no run, no wake and no monitor. The person clicked a Retry that worked mechanically but lost the work it was for. > - This pull request records the retried run's own wake reason on the Retry's continuation trigger (`trigger.retriedReason`) and shows it in the wake summary. > - The benefit is that a Retry re-delivers the same wake that failed. Dotta's rule: if a Retry button shows up, it still needs to work. ## Linked Issues or Issue Description **What happened?** Restart-chaos wave `w-20261111T230537-s6402` (Claude, Daytona warm, volume-free server mode, restart fault K1), task t03 (workload W2). The phase-2 monitor wake started run `60d821af`. That run failed with `native_session_interrupted`. The board Retry was accepted (HTTP 202). Retry run `a5a3958e` started 2 s later and succeeded. Its context had `wakeReason: task_retry` and `executionContinuation.trigger = { reason: "task_retry", sourceRunId: "60d821af…" }`, with no reference to `issue_monitor_due`. The agent's summary: "This wake was a task_retry resuming the previously interrupted run, not a new board comment or scheduled monitor check, so per instructions I changed nothing." The issue stayed `in_progress` with `executionRunId = null`, monitor status `triggered` and no `nextCheckAt`. The harness graded `R3:retry_did_not_complete` and U1 `no_owner_no_action 182s`. **Expected behavior** A Retry of a failed run re-delivers that run's wake. A Retry of a failed monitor check is handled as that monitor check. **Steps to reproduce** 1. Give an agent a task that acts only on a scheduled monitor check. 2. Let the monitor fire, and make the monitor run fail (restart fault K1 during the run). 3. Click Retry on the task. 4. Before this change, the Retry run's prompt says only `reason: task_retry`, and the agent does not do the monitor work. **Additional context** The same signature (`R3:retry_did_not_complete:W2C:K0`, Codex, monitor rewake run `bac90594` failed `native_session_interrupted`, Retry run `cfb24146` succeeded without the phase-2 marker) is in wave `w-20261111T192658-s5201` task t06. That case was attributed to the response-wake loop that #20935 fixed. Its shape matches this cause: the Retry run served the monitor wake but did not do the monitor work. That wave kept no database, so I could not read its run context directly. Refs #20935 ## What Changed - `buildExecutionContinuation` adds `trigger.retriedReason` when the current wake is a Retry (`task_retry` or `retry_failed_run`). The value is the source run's own wake reason. A Retry of a Retry inherits the first non-retry reason from the source run's saved continuation. - `ExecutionContinuationEnvelope.trigger` gets the optional `retriedReason` field. - `renderPaperclipWakePrompt` adds `- retried wake: (this Retry re-delivers the wake the failed run did not finish; handle it as that wake)` to the wake summary for fresh and resumed sessions. - New test in `server/src/services/execution-continuation.test.ts` on embedded Postgres: a failed `issue_monitor_due` run, a Retry of it, a Retry of that Retry, and a non-retry wake. ## Verification - Red on master: `cd server && npx vitest run src/services/execution-continuation.test.ts -t "carries the failed run"` failed with `trigger` = `{ reason: "task_retry", sourceRunId }` and no `retriedReason`. - Green: the same file passes (45 tests). - `tsc --noEmit` passes for `packages/shared`, `packages/adapter-utils` and `server`. - `pnpm verify` exits 2 (a shared type changed), so required CI supplies broad coverage. - Chaos: the existing cell W2 × K1 (Claude, Daytona warm) reproduces this when the fault lands in the monitor rewake run. W2C (Codex) shows the same shape. I did not rerun a wave for this PR. ## Risks - Low risk. The change adds one optional field and one prompt line. It changes no admission, lock, status or authority path. - The field is computed only for Retry wakes. Other wakes are unchanged. - Runs saved by an older version have no `retriedReason`. A Retry built from them reads the source run's `wakeReason`, which older runs already saved. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (shell, file edits, local test execution). ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- packages/adapter-utils/src/server-utils.ts | 3 ++ .../src/types/execution-continuation.ts | 1 + .../services/execution-continuation.test.ts | 30 +++++++++++++++++++ server/src/services/execution-continuation.ts | 11 ++++++- .../runner-e2e/source-sdk-public-inputs.json | 2 +- 5 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 3e24155fb87..8538a710687 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -2477,6 +2477,9 @@ function renderPaperclipWakePromptBody( : []; const wakeSummaryLines = [ `- reason: ${normalized.reason ?? "unknown"}`, + ...(normalized.executionContinuation?.trigger?.retriedReason + ? [`- retried wake: ${normalized.executionContinuation.trigger.retriedReason} (this Retry re-delivers the wake the failed run did not finish; handle it as that wake)`] + : []), `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, ...(hasWakeCommentBatch ? [ diff --git a/packages/shared/src/types/execution-continuation.ts b/packages/shared/src/types/execution-continuation.ts index 545998e33ab..5afe62afd22 100644 --- a/packages/shared/src/types/execution-continuation.ts +++ b/packages/shared/src/types/execution-continuation.ts @@ -7,6 +7,7 @@ export interface ExecutionContinuationEnvelope { reason: string; interactionId: string | null; sourceRunId: string | null; + retriedReason?: string; }; originCommentIds: string[]; objective: string; diff --git a/server/src/services/execution-continuation.test.ts b/server/src/services/execution-continuation.test.ts index 1c81337f4ce..24e108e18ac 100644 --- a/server/src/services/execution-continuation.test.ts +++ b/server/src/services/execution-continuation.test.ts @@ -366,6 +366,36 @@ const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTes } }); + it("carries the failed run's wake reason into a Retry so a monitor check is not lost", async () => { + const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + const retryRunId = randomUUID(); + await db.update(heartbeatRuns).set({ status: "failed", errorCode: "native_session_interrupted", + contextSnapshot: { issueId, wakeReason: "issue_monitor_due", monitorNotes: "Phase 2 check" } }) + .where(eq(heartbeatRuns.id, runId)); + try { + const envelope = await buildExecutionContinuation({ db, companyId, issueId, agentId, + context: { retryOfRunId: runId, wakeReason: "task_retry" }, summary: null, exposeLowTrustRaw: false }); + expect(envelope.trigger).toMatchObject({ reason: "task_retry", sourceRunId: runId, retriedReason: "issue_monitor_due" }); + await db.insert(heartbeatRuns).values({ id: retryRunId, companyId, agentId, status: "failed", + contextSnapshot: { issueId, wakeReason: "task_retry", executionContinuation: envelope } }); + const chained = await buildExecutionContinuation({ db, companyId, issueId, agentId, + context: { retryOfRunId: retryRunId, wakeReason: "task_retry" }, summary: null, exposeLowTrustRaw: false }); + expect(chained.trigger).toMatchObject({ sourceRunId: retryRunId, retriedReason: "issue_monitor_due" }); + for (const resumedSession of [false, true]) { + const prompt = renderPaperclipWakePrompt({ reason: "task_retry", executionContinuation: envelope }, { resumedSession }); + expect(prompt).toContain("- reason: task_retry"); + expect(prompt).toContain("- retried wake: issue_monitor_due"); + } + const fresh = await buildExecutionContinuation({ db, companyId, issueId, agentId, + context: { wakeReason: "issue_assigned" }, summary: null, exposeLowTrustRaw: false }); + expect(fresh.trigger).not.toHaveProperty("retriedReason"); + } finally { + await db.delete(heartbeatRuns).where(eq(heartbeatRuns.id, retryRunId)); + await db.update(heartbeatRuns).set({ status: "failed", errorCode: null, contextSnapshot: source.contextSnapshot }) + .where(eq(heartbeatRuns.id, runId)); + } + }); + it("keeps Local CLI run-authored comments as history without promoting them to human direction", async () => { const id = randomUUID(); await db.insert(issueComments).values({ id, companyId, issueId, authorType: "user", diff --git a/server/src/services/execution-continuation.ts b/server/src/services/execution-continuation.ts index 29a44f2e316..5e2dc7aa6c8 100644 --- a/server/src/services/execution-continuation.ts +++ b/server/src/services/execution-continuation.ts @@ -20,6 +20,8 @@ import { isCompletedOnboardingHandoffWake } from "./chat-completion-delivery.js" import { githubRecoveryReceipts } from "./chat-channels/github/recovery-receipts.js"; import { readTaskRetryIntent } from "./task-retry.js"; +const retryWakeReasons = new Set(["task_retry", "retry_failed_run"]); + const savedRecordOperations = new Set([ "PUT /api/issues/{id}/documents/{key}", "POST /api/issues/{id}/comments", @@ -534,6 +536,12 @@ export async function buildExecutionContinuation(input: { (hasConversationContinuationPolicy(lastTerminal.result) || lastTerminal.status === "interrupted" || lastTerminal.errorCode === "process_lost") ? lastTerminal.id : undefined); + const triggerReason = string(input.context.wakeReason) ?? "task_execution"; + const sourceContext = object(sourceRun?.context); + const retriedCandidate = retryWakeReasons.has(triggerReason) && sourceRun + ? string(object(object(sourceContext.executionContinuation).trigger).retriedReason) ?? string(sourceContext.wakeReason) + : null; + const retriedReason = retriedCandidate && !retryWakeReasons.has(retriedCandidate) ? retriedCandidate : null; return { ...(interruptedRunId ? { interruptedRunId } : {}), ...(resumeDelta ? { resumeDelta } : {}), @@ -547,9 +555,10 @@ export async function buildExecutionContinuation(input: { companyId, issueId, trigger: { - reason: string(input.context.wakeReason) ?? "task_execution", + reason: triggerReason, interactionId: triggerInteraction?.id ?? null, sourceRunId, + ...(retriedReason ? { retriedReason } : {}), }, originCommentIds, objective: latestRequest?.body ?? issue.description ?? issue.title, diff --git a/tests/runner-e2e/source-sdk-public-inputs.json b/tests/runner-e2e/source-sdk-public-inputs.json index 28b9b17be8c..b438afbecc3 100644 --- a/tests/runner-e2e/source-sdk-public-inputs.json +++ b/tests/runner-e2e/source-sdk-public-inputs.json @@ -387,7 +387,7 @@ "packages/shared/src/types/document-annotation.ts": "7769a42f1ccd707a2259774e7fe6aa536b5d183f", "packages/shared/src/types/email.ts": "556ff6a0dcefa01eb704d6d00d3b191de81ff4dc", "packages/shared/src/types/environment.ts": "a380b9ce9d2d1763011e9adaa58615a3ef1e56c5", - "packages/shared/src/types/execution-continuation.ts": "545998e33ab18d38268558d1a367f4aa12c2d5d3", + "packages/shared/src/types/execution-continuation.ts": "5afe62afd227efa03ff847d8f73291c14707ab5b", "packages/shared/src/types/execution-projection.ts": "e72e641602b296f6d3e16d07700629df3039a7b1", "packages/shared/src/types/external-object.ts": "7a225e0f5a6777d449019a30530c858b753eb4c1", "packages/shared/src/types/feedback.ts": "c5eb31545c7f67c04ad9afd9db45a6b4774bcb35",