fix(wake-queue): keep promoted Retry runs scoped to their issue (#18876)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The wake queue starts the next run for a task when the task's
current run releases its execution lock.
> - A board Retry saves a receipt with only `{ issueId, taskRetry }`.
This receipt has no saved wake context.
> - When the release drain promotes that receipt, the promoted context
gets `taskKey` from the payload, but it does not get `issueId` or
`taskId`.
> - The new run takes the task's execution lock, but the database saves
it as a company-scoped run with no issue.
> - Low-trust workspace preparation then has no issue reference. The run
fails before provider work with "Low-trust execution issue is outside
the active trust boundary."
> - This pull request restores the issue scope of a payload-only receipt
during promotion.
> - The benefit is that a Retry on a low-trust task runs in its task
boundary and does not fail during setup.

## Linked Issues or Issue Description

**What happened?**
A user clicked Retry on a low-trust task while a run was busy. When the
busy run ended, the release drain promoted the saved Retry receipt. The
promoted run had `issue_id = NULL`, `scope_kind = company`, and a
context snapshot with only `taskKey`, `wakeReason: task_retry`,
`wakeSource`, and `wakeTriggerDetail`. Workspace preparation failed with
`low_trust_boundary_mismatch` (HTTP 422) in the setup phase.

**Expected behavior**
A promoted run for a task must keep that task's issue scope. This is
true also when the deferred receipt has no saved wake context. Workspace
preparation must then evaluate the task's own trust boundary.

**Steps to reproduce**
1. Use a task with a low-trust execution policy whose assignee is an
agent.
2. While a run on that task is busy, click Retry on the task.
3. Let the busy run end. The release drain promotes the deferred Retry
receipt.
4. The promoted run is company-scoped with no `issueId`. It fails in
setup with `low_trust_boundary_mismatch`.

**Paperclip version or commit**
`master` at 51d1d4ce07.

**Agent adapter(s) involved**
Not adapter-specific (core bug).

## What Changed

- `enrichPromotedWakeContext`
(`server/src/modules/wake-queue/domain/context.ts`) now restores
`issueId` and `taskId` from the payload when the deferred context does
not have them. This is the same rule that the original wake enrichment
uses. The function does not overwrite a value that the queued context
already has.
- I added a domain test for the payload-only receipt and for the
no-overwrite rule.
- I added a Postgres adapter test
(`adapters/promoted-retry-scope.test.ts`). It runs the real release
drain with a Retry receipt that has no saved wake context. It checks
that the promoted run is issue-scoped and holds the task's execution
lock. The database trigger `set_heartbeat_run_scope_kind` derives
`issue_id` and `scope_kind` from the restored context `issueId`.

## Verification

- Red before the fix: both new tests fail on unchanged `master`. The
adapter test shows the incident shape: `issueId: null`, `scopeKind:
"company"`, and `taskKey` equal to the issue UUID.
- Green after the fix:
- `pnpm vitest run server/src/modules/wake-queue/` passes (10 files, 289
tests).
- The related suites pass (11 files, 468 tests): `heartbeat/queue`,
`heartbeat/run-control`, `heartbeat/run-lifecycle`,
`deferred-execution-receipt-work`, `execution-lock-orphan-cleanup`,
`heartbeat-comment-wake-batching`, `issue-stale-execution-lock-routes`,
`chat-failed-run-retry-boundary`, `task-retry`,
`execution-control-work`, and `workspace-recovery`.
- Server typecheck (`node server/scripts/typecheck-layers.mjs`) passes.
- I did not run a live low-trust Retry on a deployed stack. The adapter
test uses the real release drain, the real database trigger, and the
persisted receipt shape that `requestTaskRetry` writes.

## Risks

- Low risk. The change only adds `issueId` and `taskId` when they are
missing and the payload has them. Deferred wakes that already have a
queued context do not change.
- The release drain still promotes a board Retry receipt directly. It
does not go through `resumeTaskRetry`. This PR does not change that
path. A promoted Retry therefore does not get `forceFreshSession` or
`taskRetryRequestId`. This behavior existed before this PR. It is a
possible follow-up, not part of this fix.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), used through Claude
Code with tool use (code search, shell, and 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 <noreply@anthropic.com>
This commit is contained in:
Devin FoleyandClaude Opus 5.5 authored and GitHub committed 2026-11-03 16:57:44 -08:00
1 parent 985b175e83
commit ad5cd785b1
3 files changed
+94 -3

No files matched your search

@@ -0,0 +1,60 @@
import { agentWakeupRequests, agents, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db";
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../../__tests__/helpers/embedded-postgres.js";
import { createReleaseIssueExecution } from "../application/use-cases.js";
import { createPostgresWakeQueueAdapter } from "./postgres.js";
const support = await getEmbeddedPostgresTestSupport();
// A board Retry receipt stores only `{ issueId, taskRetry }`, with no saved
// wake context. Its promoted run takes the issue's execution lock, so it must
// stay issue-scoped for workspace and trust-boundary preparation.
describe.skipIf(!support.supported)("promoted Retry receipt scope", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase("wake-promoted-retry-scope-");
db = createDb(database.connectionString);
}, 60_000);
afterAll(async () => {
await db?.$client.end();
await database?.cleanup();
});
it("keeps a promoted Retry receipt without saved wake context scoped to its issue", async () => {
const [company] = await db.insert(companies).values({ name: "Retry scope fixture", issuePrefix: "RSF",
defaultResponsibleUserId: "responsible-user" }).returning();
const [agent] = await db.insert(agents).values({ companyId: company.id, name: "Retry agent", adapterType: "process",
runtimeConfig: { heartbeat: { wakeOnDemand: true, maxConcurrentRuns: 1 } } }).returning();
const [issue] = await db.insert(issues).values({ companyId: company.id, title: "Retry scope fixture issue",
status: "in_review", priority: "medium", assigneeAgentId: agent.id }).returning();
const [finished] = await db.insert(heartbeatRuns).values({ companyId: company.id, agentId: agent.id,
invocationSource: "on_demand", status: "succeeded", contextSnapshot: { issueId: issue.id } }).returning();
await db.update(issues).set({ executionRunId: finished.id }).where(eq(issues.id, issue.id));
const [wake] = await db.insert(agentWakeupRequests).values({
companyId: company.id, agentId: agent.id, source: "on_demand", triggerDetail: "manual", reason: "task_retry",
status: "deferred_issue_execution", requestedByActorType: "user", requestedByActorId: "responsible-user",
payload: { issueId: issue.id,
taskRetry: { schema: "paperclip.task-retry.v1", sourceRunId: finished.id, actorId: "responsible-user" } },
}).returning();
const release = createReleaseIssueExecution({
issueLock: createPostgresWakeQueueAdapter(db, {
resolveResponsibleUserId: async () => "responsible-user",
getRoutineEnv: async () => ({ routineId: null, env: null, responsibleUserId: null }),
resolveSessionBeforeForWakeup: async () => null,
}),
recovery: { escalateStrandedAssignedIssue: async () => undefined, escalateStrandedRecoveryIssueInPlace: async () => undefined },
});
const result = await release({ companyId: company.id, runId: finished.id, now: new Date(), suppressImmediateRecovery: true });
expect(result.outcome.kind).toBe("promoted");
const [promoted] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.wakeupRequestId, wake.id));
expect(promoted).toMatchObject({ scopeKind: "issue", issueId: issue.id,
contextSnapshot: { issueId: issue.id, taskId: issue.id, taskKey: issue.id, wakeReason: "task_retry" } });
expect((await db.select().from(issues).where(eq(issues.id, issue.id)))[0].executionRunId).toBe(promoted.id);
});
});
@@ -121,4 +121,26 @@ describe("enrichPromotedWakeContext", () => {
expect(result.contextSnapshot.acceptedPlanWakeRouting).toEqual({ targetAgentId: "agent-1" });
expect(result.contextSnapshot.workspaceRefreshReason).toBe("accepted_plan_confirmation");
});
it("restores the issue scope of a payload-only receipt without overwriting a queued context", () => {
const payloadOnly = enrichPromotedWakeContext({
contextSnapshot: {},
reason: "task_retry",
source: "on_demand",
triggerDetail: "manual",
payload: { issueId: "issue-1", taskRetry: { schema: "paperclip.task-retry.v1" } },
});
expect(payloadOnly.contextSnapshot).toMatchObject({
issueId: "issue-1", taskId: "issue-1", taskKey: "issue-1", wakeReason: "task_retry",
});
const queued = enrichPromotedWakeContext({
contextSnapshot: { issueId: "issue-1", taskId: "task-1", taskKey: "TASK-1" },
reason: "issue_commented",
source: "automation",
triggerDetail: null,
payload: { issueId: "issue-2" },
});
expect(queued.contextSnapshot).toMatchObject({ issueId: "issue-1", taskId: "task-1", taskKey: "TASK-1" });
});
});
@@ -115,15 +115,16 @@ export type EnrichPromotedWakeContextResult = {
/**
* Fills the promoted run's context snapshot with the fields the original
* wake enrichment always fills, without overwriting a field the deferred
* wake already carried. The deferred wake's own context snapshot was
* already enriched once when it was first queued, so most calls only
* normalize the interaction-continuation keys.
* wake already carried. A deferred wake queued through the wake queue was
* already enriched once, so most calls only normalize the
* interaction-continuation keys.
*/
export function enrichPromotedWakeContext(
input: EnrichPromotedWakeContextInput,
): EnrichPromotedWakeContextResult {
const contextSnapshot = { ...input.contextSnapshot };
const { reason, source, triggerDetail, payload } = input;
const issueIdFromPayload = readNonEmptyString(payload?.["issueId"]) ?? readNonEmptyString(payload?.["taskId"]);
const commentIdFromPayload = readNonEmptyString(payload?.["commentId"]);
const taskKey = deriveTaskKey(contextSnapshot, payload);
const wakeCommentIds = mergeWakeCommentIds(contextSnapshot, commentIdFromPayload);
@@ -131,6 +132,14 @@ export function enrichPromotedWakeContext(
if (!readNonEmptyString(contextSnapshot["wakeReason"]) && reason) {
contextSnapshot.wakeReason = reason;
}
// Some durable receipts, such as a board Retry, store only the payload and
// never had a queued context snapshot. Keep their issue scope.
if (!readNonEmptyString(contextSnapshot["issueId"]) && issueIdFromPayload) {
contextSnapshot.issueId = issueIdFromPayload;
}
if (!readNonEmptyString(contextSnapshot["taskId"]) && issueIdFromPayload) {
contextSnapshot.taskId = issueIdFromPayload;
}
if (!readNonEmptyString(contextSnapshot["taskKey"]) && taskKey) {
contextSnapshot.taskKey = taskKey;
}