From e516815d8f14c1754977cdb0ebe0869beb37e000 Mon Sep 17 00:00:00 2001 From: Rinalds Uzkalns Date: Sat, 24 Oct 2026 23:43:35 +0200 Subject: [PATCH] fix(jobs): stop follow-ups waiting behind an operator hold on a closed PR A follow-up held for an operator (integry/mcptest#447: worktree_not_on_branch) blocked every later follow-up for its pull request. The blocked job only checked whether the PR was closed after the predecessor gate, so after the merge it kept waiting - before f5e35100f by queueing a replacement task every minute (103 tasks in two hours), after it by backing off forever - and the hold itself was never settled. - gatePredecessorHandoff reads the PR state when a predecessor blocks it. A merged or closed PR ends the job as cancelled_pr_closed without returning its comments to the pending list, then releases the hold. An open PR or an unreadable state keeps the existing wait. - releaseClosedPullRequestHandoffs, run with the periodic handoff recovery, releases operator holds whose PR has since closed (each PR read at most every 10 minutes) and drops the comments still queued for it. - Both use the operator release, forced: the agent must be proven gone and the PR lease taken, and retained files and commits stay in the worktree. The release records released_pr_merged / released_pr_closed. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/jobs/closedPullRequestHandoffs.ts | 160 +++++++++++++++++++++ src/jobs/followupHandoffOperator.ts | 5 +- src/jobs/prCommentHandoffIntegration.ts | 34 +++++ src/jobs/processPullRequestCommentJob.ts | 11 +- src/worker.ts | 4 +- test/followupHandoffReviewBlockers.test.ts | 103 +++++++++++++ test/prPublicationJob.test.ts | 2 + test/prPublicationRecoveryJob.test.ts | 1 + 8 files changed, 315 insertions(+), 5 deletions(-) create mode 100644 src/jobs/closedPullRequestHandoffs.ts diff --git a/src/jobs/closedPullRequestHandoffs.ts b/src/jobs/closedPullRequestHandoffs.ts new file mode 100644 index 000000000..9a4d4ea31 --- /dev/null +++ b/src/jobs/closedPullRequestHandoffs.ts @@ -0,0 +1,160 @@ +import { getPendingPrCommentsKey } from '@propr/core'; +import { resolveRecoveryDeps, type FollowupHandoffRecoveryDeps } from './followupHandoffDeps.js'; +import { releaseFollowupHandoffRecord } from './followupHandoffOperator.js'; +import { recoverInterruptedFollowupHandoffs } from './followupHandoffRecovery.js'; +import { splitHandoffRepository, type HandoffOctokit } from './followupHandoffResumeTypes.js'; +import { + HANDOFF_AWAITING_OPERATOR, loadFollowupHandoff, loadFollowupHandoffs, parseHandoffJobData, + type FollowupHandoffKey, type FollowupHandoffRecord, +} from './followupHandoffStore.js'; + +/** + * Follow-ups held for an operator on a pull request that has since been merged + * or closed. Nothing can be published there any more, so the hold would block + * every later follow-up for the pull request forever: each one waited behind + * it, and before waiting in place each queued a replacement every minute. + */ + +export type PullRequestClosure = 'merged' | 'closed'; + +/** How the pull request was closed, or null while it is open. */ +export async function readPullRequestClosure( + octokit: Pick, + owner: string, + repo: string, + pullRequestNumber: number, +): Promise { + const { data } = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { + owner, repo, pull_number: pullRequestNumber, + }); + const pull = data as { state?: string; merged?: boolean | null; merged_at?: string | null }; + if (pull.state !== 'closed') return null; + return pull.merged || pull.merged_at ? 'merged' : 'closed'; +} + +interface DeletingRedis { del?(key: string): Promise } + +/** + * Releases one operator-held follow-up of a closed pull request and drops the + * comments still queued for it. The release is the operator release, forced: + * it still requires the agent proven gone and the PR lease, and it only + * detaches the worktree HEAD, so retained files and commits stay for the + * normal worktree retention cleanup. + */ +export async function releaseClosedPullRequestHandoff( + record: FollowupHandoffRecord, + closure: PullRequestClosure, + deps: FollowupHandoffRecoveryDeps = {}, +): Promise<{ released: boolean; reason?: string }> { + const key: FollowupHandoffKey = { repository: record.repository, pull_request: record.pull_request }; + const result = await releaseFollowupHandoffRecord(key, { + force: true, reason: closure === 'merged' ? 'released_pr_merged' : 'released_pr_closed', + }, deps); + if (result.released && deps.redis) { + const jobData = parseHandoffJobData(record); + const { owner, repo } = splitHandoffRepository(record.repository); + await (deps.redis as DeletingRedis).del?.( + getPendingPrCommentsKey(jobData.repoOwner ?? owner, jobData.repoName ?? repo, record.pull_request), + ); + } + return result; +} + +/** Releases the pull request's follow-up hold, if one is held for an operator; never throws. */ +export async function releaseHeldHandoffOfClosedPullRequest( + ref: { repoOwner: string; repoName: string; pullRequestNumber: number }, + closure: PullRequestClosure, + deps: FollowupHandoffRecoveryDeps = {}, +): Promise<{ released: boolean; reason?: string }> { + const resolved = await resolveRecoveryDeps(deps); + try { + const record = await loadFollowupHandoff(resolved.store, { + repository: `${ref.repoOwner}/${ref.repoName}`.toLowerCase(), pull_request: ref.pullRequestNumber, + }); + if (record?.status !== HANDOFF_AWAITING_OPERATOR) return { released: false, reason: 'not_held' }; + return await releaseClosedPullRequestHandoff(record, closure, sharedDeps(deps, resolved)); + } catch (error) { + resolved.log.warn({ ...ref, error: (error as Error).message }, 'Could not release the follow-up hold of a closed pull request'); + return { released: false, reason: 'error' }; + } finally { + if (resolved.ownRedis) await resolved.redis.quit?.(); + } +} + +function sharedDeps( + deps: FollowupHandoffRecoveryDeps, + resolved: Awaited>, +): FollowupHandoffRecoveryDeps { + return { + ...deps, store: resolved.store, redis: resolved.redis, log: resolved.log, + getOctokit: resolved.getOctokit, git: resolved.git, inspectLiveness: resolved.inspectLiveness, + }; +} + +export interface ClosedPullRequestHandoffSummary { + inspected: number; + released: number; + refused: number; + errors: number; +} + +/** Each held follow-up's pull request is read from GitHub at most this often. */ +export const CLOSED_PULL_REQUEST_CHECK_INTERVAL_MS = 10 * 60_000; +const lastChecked = new Map(); + +/** + * One pass over the operator-held follow-ups: those whose pull request was + * merged or closed are released. A refused release (the agent still running, + * unverifiable liveness, the lease held by a live writer) is retried after + * the check interval. + */ +export async function releaseClosedPullRequestHandoffs( + deps: FollowupHandoffRecoveryDeps & { now?: () => number } = {}, +): Promise { + const now = deps.now ?? Date.now; + const resolved = await resolveRecoveryDeps(deps); + const summary: ClosedPullRequestHandoffSummary = { inspected: 0, released: 0, refused: 0, errors: 0 }; + try { + const held = await loadFollowupHandoffs(resolved.store, { status: HANDOFF_AWAITING_OPERATOR }); + const current = new Set(); + for (const record of held) { + const checkKey = `${record.repository}#${record.pull_request}#${record.incarnation}`; + current.add(checkKey); + const checkedAt = lastChecked.get(checkKey); + if (checkedAt !== undefined && now() - checkedAt < CLOSED_PULL_REQUEST_CHECK_INTERVAL_MS) continue; + lastChecked.set(checkKey, now()); + summary.inspected += 1; + try { + const { owner, repo } = splitHandoffRepository(record.repository); + const closure = await readPullRequestClosure(await resolved.getOctokit(), owner, repo, record.pull_request); + if (!closure) continue; + const result = await releaseClosedPullRequestHandoff(record, closure, sharedDeps(deps, resolved)); + if (result.released) { + summary.released += 1; + resolved.log.info({ repository: record.repository, pullRequest: record.pull_request, taskId: record.task_id, closure }, + 'Released the follow-up hold of a closed pull request'); + } else { + summary.refused += 1; + resolved.log.warn({ repository: record.repository, pullRequest: record.pull_request, reason: result.reason }, + 'Could not yet release the follow-up hold of a closed pull request'); + } + } catch (error) { + summary.errors += 1; + resolved.log.warn({ repository: record.repository, pullRequest: record.pull_request, error: (error as Error).message }, + 'Failed to check a held follow-up against its pull request'); + } + } + for (const key of lastChecked.keys()) if (!current.has(key)) lastChecked.delete(key); + return summary; + } finally { + if (resolved.ownRedis) await resolved.redis.quit?.(); + } +} + +/** The worker's periodic handoff pass: interrupted publications, then holds on closed pull requests. */ +export async function recoverFollowupHandoffsAndClosedHolds(): Promise> { + return { + ...await recoverInterruptedFollowupHandoffs(), + closedPullRequestHolds: await releaseClosedPullRequestHandoffs(), + }; +} diff --git a/src/jobs/followupHandoffOperator.ts b/src/jobs/followupHandoffOperator.ts index 1384a3118..a569e2cc1 100644 --- a/src/jobs/followupHandoffOperator.ts +++ b/src/jobs/followupHandoffOperator.ts @@ -210,7 +210,7 @@ async function confirmUnconfirmedRetryOwnership( */ export async function releaseFollowupHandoffRecord( key: FollowupHandoffKey, - options: { force?: boolean }, + options: { force?: boolean; /** Recorded status reason; defaults to an operator release. */ reason?: string }, deps: FollowupHandoffRecoveryDeps = {}, ): Promise<{ released: boolean; reason?: string }> { const resolved = await resolveRecoveryDeps(deps); @@ -253,7 +253,8 @@ export async function releaseFollowupHandoffRecord( const refusal = await branchReleaseRefusal(resolved.git, fresh); if (refusal) return { released: false, reason: refusal }; const advanced = await advanceFollowupHandoff(resolved.store, fresh, { - status: HANDOFF_RELEASED, status_reason: options.force ? 'released_by_operator_force' : 'released_by_operator', + status: HANDOFF_RELEASED, + status_reason: options.reason ?? (options.force ? 'released_by_operator_force' : 'released_by_operator'), }); return { released: Boolean(advanced), reason: advanced ? undefined : 'superseded' }; }); diff --git a/src/jobs/prCommentHandoffIntegration.ts b/src/jobs/prCommentHandoffIntegration.ts index e948e5082..98bfa02ba 100644 --- a/src/jobs/prCommentHandoffIntegration.ts +++ b/src/jobs/prCommentHandoffIntegration.ts @@ -11,6 +11,7 @@ import { import { adoptPredecessorFollowupHandoff, settleFollowupHandoffAfterFailure } from './followupHandoffRecovery.js'; import { delayPRCommentJobInPlace, predecessorWaitDelay, schedulePRCommentRecovery } from './prCommentCollisionRecovery.js'; import { releasePRProcessingLock, startPRProcessingLockHeartbeat } from './prProcessingLock.js'; +import { releaseHeldHandoffOfClosedPullRequest, type PullRequestClosure } from './closedPullRequestHandoffs.js'; import type { ProcessingState } from './prPublicationRecovery.js'; import type { PullRequestPublication } from './prPublication.js'; @@ -277,6 +278,14 @@ export interface PredecessorHandoffGateParams { lockKey: string; lockToken: string; correlatedLogger: Logger; + /** + * How the pull request was closed, or null while it is open. Read only when + * a predecessor blocks this writer: a merged or closed pull request ends the + * wait instead of waiting forever behind a hold nobody can publish. + */ + pullRequestClosure?: () => Promise; + /** Releases the closed pull request's operator-held follow-up; injectable for tests. */ + releaseClosedHold?: typeof releaseHeldHandoffOfClosedPullRequest; } /** Removes the given comment IDs from a captured set in place, returning how many were removed. */ @@ -380,6 +389,8 @@ export async function gatePredecessorHandoff(params: PredecessorHandoffGateParam if (skipped) await releasePRProcessingLock(redisClient, params.lockKey, params.lockToken); return skipped; } + const closed = await endWaitOnClosedPullRequest(params, blocked); + if (closed) return closed; // Wait in place as the same job and task, backing off: an operator hold // can stand for days, and a fresh replacement every minute left thousands // of cancelled tasks behind a single unanswered follow-up. @@ -399,6 +410,29 @@ export async function gatePredecessorHandoff(params: PredecessorHandoffGateParam return { status: 'rescheduled', reason: blocked, replacementTaskId }; } +/** + * A blocked writer on a merged or closed pull request stops instead of + * waiting: nothing can be published there, and the hold it waits behind would + * never be settled. Its captured comments are not returned to the pending + * list, exactly as for a job that finds the pull request closed later on, and + * the hold itself is released once this job has let go of the PR lease. + */ +async function endWaitOnClosedPullRequest(params: PredecessorHandoffGateParams, blocked: string): Promise { + const { ref, redisClient, correlatedLogger } = params; + let closure: PullRequestClosure | null = null; + try { + closure = await params.pullRequestClosure?.() ?? null; + } catch (error) { + correlatedLogger.warn({ error: (error as Error).message }, 'Could not read the pull request state while blocked by a predecessor; waiting'); + } + if (!closure) return undefined; + await releasePRProcessingLock(redisClient, params.lockKey, params.lockToken); + const release = await (params.releaseClosedHold ?? releaseHeldHandoffOfClosedPullRequest)(ref, closure, { redis: redisClient as never }); + correlatedLogger.info({ pullRequestNumber: ref.pullRequestNumber, closure, blocked, holdReleased: release.released, releaseReason: release.reason }, + 'Pull request is closed; ending the follow-up instead of waiting behind its predecessor'); + return { status: 'cancelled', reason: 'cancelled_pr_closed', pullRequestNumber: ref.pullRequestNumber }; +} + export interface HandoffErrorSettlementParams { ref: HandoffRef; taskId: string; diff --git a/src/jobs/processPullRequestCommentJob.ts b/src/jobs/processPullRequestCommentJob.ts index 01e359eb6..dbdbd91fa 100644 --- a/src/jobs/processPullRequestCommentJob.ts +++ b/src/jobs/processPullRequestCommentJob.ts @@ -54,6 +54,8 @@ import { findPRContinuation, type Contribution } from './prContinuation.js'; import { suspendObsoleteValidationForImplementation } from './followupCiSuspension.js'; import { stoppedReviewRecap } from './notificationRecap.js'; import { resumeAutomaticPRCommentRetry } from './prCommentAutomaticRetry.js'; +import { readPullRequestClosure } from './closedPullRequestHandoffs.js'; +import type { HandoffOctokit } from './followupHandoffResumeTypes.js'; const redisClient = new Redis({ host: process.env.REDIS_HOST || '127.0.0.1', @@ -414,8 +416,15 @@ async function processAdmittedPRCommentJob(job: Job): Promise readPullRequestClosure(octokit as unknown as HandoffOctokit, repoOwner, repoName, pullRequestNumber), }); - if (predecessorGate) return predecessorGate; + if (predecessorGate) { + if (predecessorGate.reason === 'cancelled_pr_closed') { + await createPRCommentTaskStateIfMissing({ job, taskId, stateManager, preexistingState, modelName, correlatedLogger }); + await stateManager.markTaskCancelled(taskId, 'system', { reason: formatTaskTerminalReason('cancelled_pr_closed'), terminalReason: 'cancelled_pr_closed' }); + } + return predecessorGate; + } const executionController = new AbortController(); const stopLockHeartbeat = startPRProcessingLockHeartbeat({ diff --git a/src/worker.ts b/src/worker.ts index 05f837a06..a04fe43fd 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -40,7 +40,7 @@ import { ultrafixFailedStepFinalizerOptions } from './jobs/ultrafixFailedStepFin import { getTaskReplacementService } from './taskReplacement/index.js'; import { recoverNonterminalGoals } from './goalRecovery.js'; import { reconcileFollowupCiSuspensions } from './jobs/followupCiSuspension.js'; -import { recoverInterruptedFollowupHandoffs } from './jobs/followupHandoffRecovery.js'; +import { recoverFollowupHandoffsAndClosedHolds } from './jobs/closedPullRequestHandoffs.js'; import { cancelClosedPullRequestValidation } from './jobs/closedPullRequestCiCancellation.js'; import { prepareAgentRegistryAtStartup, processAgentImagePreparationJob, startAgentImageWatchdog } from './workerAgentPreparation.js'; @@ -403,7 +403,7 @@ async function startWorker(options: WorkerOptions = {}): Promise closedPullRequests: await cancelClosedPullRequestValidation(), epicQueues: await reconcileEpicExecutionQueues(), }), - recoverFollowupHandoffs: () => recoverInterruptedFollowupHandoffs(), + recoverFollowupHandoffs: () => recoverFollowupHandoffsAndClosedHolds(), }); const usageTipsRunner = await startUsageTipsSelectionRunner(); diff --git a/test/followupHandoffReviewBlockers.test.ts b/test/followupHandoffReviewBlockers.test.ts index 3905ecc5c..a1cd89688 100644 --- a/test/followupHandoffReviewBlockers.test.ts +++ b/test/followupHandoffReviewBlockers.test.ts @@ -1423,3 +1423,106 @@ test('F27: without delivered completion evidence recovery still publishes once o assert.deepEqual(mutations, ['PATCH']); assert.match(h.octokit.comments[0].body, /Recovered and published follow-up changes/); }); + +// --- A follow-up held for an operator on a merged or closed pull request must +// not block every later follow-up forever (integry/mcptest#447: a /fix queued +// a replacement every minute for two hours, and kept going after the merge). + +const { releaseClosedPullRequestHandoffs, readPullRequestClosure } = await import('../src/jobs/closedPullRequestHandoffs.js'); + +function waitingJob(taskId: string) { + const job = { + id: taskId, name: 'processPullRequestComment', token: 'worker-token', + data: { repoOwner: 'acme', repoName: 'widgets', pullRequestNumber: 7, prProcessingLockToken: `${taskId}-token` } as Record, + updateData: mock.fn(async (next: Record) => { job.data = next; }), + moveToDelayed: mock.fn(async (_timestamp: number, _token: string) => {}), + }; + return job; +} + +test('a follow-up blocked by an operator hold on a merged pull request ends instead of waiting', async () => { + const h = await createHarness(); + const predecessor = await seedHandoff(h, { taskId: 'task-1' }); + await advanceFollowupHandoff({ database }, predecessor, { status: HANDOFF_AWAITING_OPERATOR, status_reason: 'worktree_not_on_branch' }); + const job = waitingJob('task-2'); + const releases: Array<{ ref: unknown; closure: string }> = []; + const queuedBefore = queuedRecoveryJobs.length; + + const result = await gatePredecessorHandoff({ + ...gateParams(h, 'task-2'), job: job as never, + pullRequestClosure: async () => 'merged', + releaseClosedHold: async (ref, closure) => { releases.push({ ref, closure }); return { released: true }; }, + }); + + assert.deepEqual(result, { status: 'cancelled', reason: 'cancelled_pr_closed', pullRequestNumber: 7 }); + assert.equal(job.moveToDelayed.mock.calls.length, 0, 'the job does not wait'); + assert.equal(queuedRecoveryJobs.length, queuedBefore, 'no replacement job is queued'); + assert.equal(h.redis.store.has(LOCK), false, 'the PR lease is released before the hold is'); + assert.deepEqual(releases, [{ ref: { repoOwner: 'acme', repoName: 'widgets', pullRequestNumber: 7 }, closure: 'merged' }]); +}); + +for (const [label, closure] of [ + ['an open pull request', async () => null], + ['an unreadable pull request state', async () => { throw new Error('GitHub unavailable'); }], +] as const) { + test(`a follow-up blocked by an operator hold keeps waiting for ${label}`, async () => { + const h = await createHarness(); + const predecessor = await seedHandoff(h, { taskId: 'task-1' }); + await advanceFollowupHandoff({ database }, predecessor, { status: HANDOFF_AWAITING_OPERATOR, status_reason: 'cancelled_by_user' }); + const job = waitingJob('task-2'); + + await assert.rejects(gatePredecessorHandoff({ + ...gateParams(h, 'task-2'), job: job as never, pullRequestClosure: closure, + releaseClosedHold: async () => assert.fail('an open pull request keeps its hold'), + }), (error: unknown) => error instanceof DelayedError); + assert.equal(job.moveToDelayed.mock.calls.length, 1); + assert.equal((await loadFollowupHandoff({ database }, KEY))?.status, HANDOFF_AWAITING_OPERATOR); + }); +} + +test('readPullRequestClosure tells merged, closed and open pull requests apart', async () => { + const reply = (data: Record) => ({ request: async () => ({ data }) }); + assert.equal(await readPullRequestClosure(reply({ state: 'closed', merged: true }), 'acme', 'widgets', 7), 'merged'); + assert.equal(await readPullRequestClosure(reply({ state: 'closed', merged: false, merged_at: null }), 'acme', 'widgets', 7), 'closed'); + assert.equal(await readPullRequestClosure(reply({ state: 'open' }), 'acme', 'widgets', 7), null); +}); + +test('the periodic pass releases a hold on a merged pull request, keeps retained work, and drops its queued comments', async () => { + const h = await createHarness(); + const predecessor = await seedHandoff(h, { taskId: 'task-1' }); + await agentEdit(h.fx); + await advanceFollowupHandoff({ database }, predecessor, { status: HANDOFF_AWAITING_OPERATOR, status_reason: 'worktree_not_on_branch' }); + const deleted: string[] = []; + const redis = Object.assign(h.redis, { del: async (key: string) => { deleted.push(key); return 1; } }); + let now = 1_000_000; + + // Open: nothing changes, and the pull request is not read again within the interval. + const open = await releaseClosedPullRequestHandoffs({ ...h.deps, redis, now: () => now }); + assert.deepEqual(open, { inspected: 1, released: 0, refused: 0, errors: 0 }); + h.octokit.pull = { ...h.octokit.pull, state: 'closed', merged: true } as never; + const throttled = await releaseClosedPullRequestHandoffs({ ...h.deps, redis, now: () => now }); + assert.equal(throttled.inspected, 0); + + now += 10 * 60_000; + const merged = await releaseClosedPullRequestHandoffs({ ...h.deps, redis, now: () => now }); + assert.deepEqual(merged, { inspected: 1, released: 1, refused: 0, errors: 0 }); + const released = await loadFollowupHandoff({ database }, KEY); + assert.equal(released?.status, HANDOFF_RELEASED); + assert.equal(released?.status_reason, 'released_pr_merged'); + assert.ok(existsSync(path.join(h.fx.wt, 'feature.txt')), 'retained edits are never deleted'); + assert.deepEqual(deleted, ['pending:acme:widgets:7']); + assert.equal(h.redis.store.has(LOCK), false, 'the operator lease is released'); +}); + +test('the periodic pass leaves a hold whose agent is still running for a later pass', async () => { + const h = await createHarness(); + const predecessor = await seedHandoff(h, { taskId: 'task-1' }); + await advanceFollowupHandoff({ database }, predecessor, { status: HANDOFF_AWAITING_OPERATOR, status_reason: 'cancelled_by_user' }); + h.octokit.pull = { ...h.octokit.pull, state: 'closed', merged: false } as never; + + const summary = await releaseClosedPullRequestHandoffs({ + ...h.deps, inspectLiveness: async () => ({ liveness: 'running' }), now: () => 5_000_000, + }); + assert.deepEqual(summary, { inspected: 1, released: 0, refused: 1, errors: 0 }); + assert.equal((await loadFollowupHandoff({ database }, KEY))?.status, HANDOFF_AWAITING_OPERATOR); +}); diff --git a/test/prPublicationJob.test.ts b/test/prPublicationJob.test.ts index 5fd0fd8ec..e76911120 100644 --- a/test/prPublicationJob.test.ts +++ b/test/prPublicationJob.test.ts @@ -226,6 +226,8 @@ const modules: Record> = { stopAfterHandoffOwnershipLoss: () => ({ status: 'skipped', reason: 'followup_handoff_ownership_lost' }), writerOwnedWorktree: (state: { followupHandoffOwnershipLost?: boolean; worktreeInfo?: unknown }) => state.followupHandoffOwnershipLost ? undefined : state.worktreeInfo, }, + // Closed pull request holds are covered by test/followupHandoffReviewBlockers.test.ts. + closedPullRequestHandoffs: { readPullRequestClosure: async () => null }, prCommentCollisionRecovery: { createPRCommentTaskStateIfMissing: async (params: Parameters[0]) => { if (realReentryHelpers) await realRecovery.createPRCommentTaskStateIfMissing(params); }, evaluatePRCommentPreExecutionRecovery: async (params: Parameters[0]) => realReentryHelpers ? realRecovery.evaluatePRCommentPreExecutionRecovery(params) : {}, diff --git a/test/prPublicationRecoveryJob.test.ts b/test/prPublicationRecoveryJob.test.ts index b65caea42..4adc1f18a 100644 --- a/test/prPublicationRecoveryJob.test.ts +++ b/test/prPublicationRecoveryJob.test.ts @@ -306,6 +306,7 @@ const modules: Record> = { ensurePRProcessingLockToken: async () => 'token', releasePRProcessingLock: async (_redis: unknown, key: string) => { heldLocks.delete(key); }, startPRProcessingLockHeartbeat: () => noOp, }, + closedPullRequestHandoffs: { readPullRequestClosure: async () => null, releaseHeldHandoffOfClosedPullRequest: async () => ({ released: false }) }, prCommentCollisionRecovery: { createPRCommentTaskStateIfMissing: async ({ taskId }: { taskId: string }) => { if (!taskStates.has(taskId)) taskStates.set(taskId, 'processing'); }, evaluatePRCommentPreExecutionRecovery: async () => ({}), handlePRCommentLockContention: async () => ({ status: 'deferred' }), schedulePRCommentRecovery: async () => undefined, delayPRCommentJobInPlace: async () => false, predecessorWaitDelay: () => 60_000 }, prCompletionComment: { buildCompletionComment: async (commit: unknown, comments: unknown, options: unknown, result: unknown) => JSON.stringify({ commit, comments, options, result }), noChangeAnalysisSummary: () => '' }, reviewCommentGatherer: { markReviewFindingsProcessed: noOp },