import {
ESCALATION_POLL_MS,
TOTAL_CALL_BUDGET_MS,
buildMergeIdempotencyKey,
buildResult,
classifyBlockedPreflight,
isAlreadyMerged,
readHeadSha,
readMergeSha,
remainingMs,
type MergeMethod,
type MergePrResult,
} from './merge-shared.ts'
export type WorkflowCreateInput = {
workflowName?: string
exportName: string
packageId?: string
idempotencyKey: string
params?: Record<string, unknown>
}
export type WorkflowCreateResult = {
ok?: true
id: string
status?: string
workflow_name?: string
}
export type WorkflowRunSnapshot = {
id: string
status: string | null
idempotency_key?: string
last_error?: string | null
}
export type MergeWorkflowsClient = {
create: (input: WorkflowCreateInput) => Promise<WorkflowCreateResult>
}
export type EscalateAfterTimeoutDeps = {
workflows: MergeWorkflowsClient | null | undefined
listWorkflowRuns: (limit?: number) => Promise<Array<WorkflowRunSnapshot>>
fetchPullRequest: () => Promise<Record<string, unknown>>
sleep: (ms: number) => Promise<void>
now?: () => number
packageId?: string
totalBudgetMs?: number
pollIntervalMs?: number
}
export type EscalateAfterTimeoutInput = {
owner: string
repo: string
prNumber: number
repository: string
mergeMethod: MergeMethod
account: string
commitTitle?: string
commitMessage?: string
sha?: string
pr: Record<string, unknown>
startedAt: number
preflightMs: number
mergeMs: number | null
recheckMs: number
timeoutMs: number
message: string
}
/**
* After a direct merge abort (and an immediate re-check that found the PR still
* open), dispatch a durable workflow and poll until the merge lands, the
* workflow finishes, or the remaining call budget is exhausted.
*
* Idempotent: the workflow key is derived from owner/repo/prNumber/headSha, so
* repeated escalations for the same head reuse one live workflow.
*/
export async function escalateAfterTimeout(
deps: EscalateAfterTimeoutDeps,
input: EscalateAfterTimeoutInput,
): Promise<MergePrResult> {
const now = deps.now ?? Date.now
const totalBudgetMs = deps.totalBudgetMs ?? TOTAL_CALL_BUDGET_MS
const pollIntervalMs = deps.pollIntervalMs ?? ESCALATION_POLL_MS
const escalationStartedAt = now()
const finish = (
partial: Omit<
Parameters<typeof buildResult>[0],
| 'startedAt'
| 'preflightMs'
| 'mergeMs'
| 'recheckMs'
| 'escalationMs'
| 'timeoutMs'
| 'totalBudgetMs'
| 'now'
| 'repository'
| 'prNumber'
| 'mergeMethod'
> & {
pr?: Record<string, unknown>
workflowId?: string
workflowIdempotencyKey?: string
},
): MergePrResult =>
buildResult({
merged: partial.merged,
sha: partial.sha,
message: partial.message,
repository: input.repository,
prNumber: input.prNumber,
mergeMethod: input.mergeMethod,
status: partial.status,
note: partial.note,
hint: partial.hint,
workflowId: partial.workflowId,
workflowIdempotencyKey: partial.workflowIdempotencyKey,
pr: partial.pr ?? input.pr,
startedAt: input.startedAt,
preflightMs: input.preflightMs,
mergeMs: input.mergeMs,
recheckMs: input.recheckMs,
escalationMs: now() - escalationStartedAt,
timeoutMs: input.timeoutMs,
totalBudgetMs,
now,
})
if (!deps.workflows) {
return finish({
merged: false,
sha: null,
message: input.message,
status: 'timed_out_unconfirmed',
hint:
'Durable workflows are unavailable in this runtime. Verify with pr/get-info before retrying. Retrying is safe because merge is idempotent.',
})
}
const headSha = readHeadSha(input.pr)
if (!headSha) {
return finish({
merged: false,
sha: null,
message: input.message,
status: 'timed_out_unconfirmed',
hint:
'Could not determine head SHA for durable merge dedupe. Verify with pr/get-info before retrying.',
})
}
const idempotencyKey = buildMergeIdempotencyKey({
owner: input.owner,
repo: input.repo,
prNumber: input.prNumber,
headSha,
})
let workflow: WorkflowCreateResult
try {
workflow = await deps.workflows.create({
workflowName: 'github-pr-merge',
exportName: './pr/merge-durable',
packageId: deps.packageId ?? 'github',
idempotencyKey,
params: {
owner: input.owner,
repo: input.repo,
prNumber: input.prNumber,
mergeMethod: input.mergeMethod,
account: input.account,
commitTitle: input.commitTitle,
commitMessage: input.commitMessage,
sha: input.sha,
},
})
} catch (error) {
const detail =
error instanceof Error && error.message
? error.message
: 'workflows.create failed'
return finish({
merged: false,
sha: null,
message: `${input.message} Durable escalation failed: ${detail}`,
status: 'timed_out_unconfirmed',
hint:
'Verify with pr/get-info before retrying. Retrying is safe because merge is idempotent.',
workflowIdempotencyKey: idempotencyKey,
})
}
const workflowId = workflow.id
while (remainingMs(input.startedAt, totalBudgetMs, now) > pollIntervalMs) {
await deps.sleep(
Math.min(
pollIntervalMs,
remainingMs(input.startedAt, totalBudgetMs, now),
),
)
const pr = await deps.fetchPullRequest()
if (isAlreadyMerged(pr)) {
return finish({
merged: true,
sha: readMergeSha(pr),
message: input.message,
status: 'merged_via_workflow',
note: 'merge completed via durable workflow after the direct attempt timed out',
workflowId,
workflowIdempotencyKey: idempotencyKey,
pr,
})
}
const blocked = classifyBlockedPreflight(pr)
if (blocked) {
return finish({
merged: false,
sha: null,
message: blocked.message,
status: blocked.status,
hint: blocked.hint,
workflowId,
workflowIdempotencyKey: idempotencyKey,
pr,
})
}
const runs = await deps.listWorkflowRuns(50)
const run =
runs.find((candidate) => candidate.id === workflowId) ??
runs.find((candidate) => candidate.idempotency_key === idempotencyKey)
if (!run) continue
if (run.status === 'complete') {
const fresh = await deps.fetchPullRequest()
if (isAlreadyMerged(fresh)) {
return finish({
merged: true,
sha: readMergeSha(fresh),
message: input.message,
status: 'merged_via_workflow',
note: 'merge completed via durable workflow after the direct attempt timed out',
workflowId,
workflowIdempotencyKey: idempotencyKey,
pr: fresh,
})
}
const blockedAfterComplete = classifyBlockedPreflight(fresh)
if (blockedAfterComplete) {
return finish({
merged: false,
sha: null,
message: blockedAfterComplete.message,
status: blockedAfterComplete.status,
hint: blockedAfterComplete.hint,
workflowId,
workflowIdempotencyKey: idempotencyKey,
pr: fresh,
})
}
return finish({
merged: false,
sha: null,
message:
'Durable merge workflow completed without merging the pull request.',
status: 'merge_workflow_failed',
hint: 'Inspect the PR with pr/get-info. Retrying pr/merge is safe if the PR is still mergeable.',
workflowId,
workflowIdempotencyKey: idempotencyKey,
pr: fresh,
})
}
if (run.status === 'errored' || run.status === 'terminated') {
const fresh = await deps.fetchPullRequest()
if (isAlreadyMerged(fresh)) {
return finish({
merged: true,
sha: readMergeSha(fresh),
message: input.message,
status: 'merged_via_workflow',
note: 'merge completed via durable workflow after the direct attempt timed out',
workflowId,
workflowIdempotencyKey: idempotencyKey,
pr: fresh,
})
}
const detail = run.last_error?.trim() || `workflow ${run.status}`
return finish({
merged: false,
sha: null,
message: `Durable merge workflow ${run.status}: ${detail}`,
status: 'merge_workflow_failed',
hint: 'Inspect the PR with pr/get-info. Retrying pr/merge is safe if the PR is still mergeable.',
workflowId,
workflowIdempotencyKey: idempotencyKey,
pr: fresh,
})
}
}
return finish({
merged: false,
sha: null,
message:
'Merge request timed out; a durable workflow will finish the merge.',
status: 'merge_dispatched',
note: 'you do not need to retry — the durable merge workflow will complete or fail on its own',
workflowId,
workflowIdempotencyKey: idempotencyKey,
})
}