diff --git a/server/src/modules/wake-queue/adapters/promoted-retry-scope.test.ts b/server/src/modules/wake-queue/adapters/promoted-retry-scope.test.ts new file mode 100644 index 00000000000..f695fd05839 --- /dev/null +++ b/server/src/modules/wake-queue/adapters/promoted-retry-scope.test.ts @@ -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>; + let db: ReturnType; + + 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); + }); +}); diff --git a/server/src/modules/wake-queue/domain/context.test.ts b/server/src/modules/wake-queue/domain/context.test.ts index d4fe457f716..9dc44b8c099 100644 --- a/server/src/modules/wake-queue/domain/context.test.ts +++ b/server/src/modules/wake-queue/domain/context.test.ts @@ -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" }); + }); }); diff --git a/server/src/modules/wake-queue/domain/context.ts b/server/src/modules/wake-queue/domain/context.ts index 4da2346fa63..a4f0df74e52 100644 --- a/server/src/modules/wake-queue/domain/context.ts +++ b/server/src/modules/wake-queue/domain/context.ts @@ -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; }