From 2c624df172878fd33a9b78a5f43d7c994b67a225 Mon Sep 17 00:00:00 2001 From: "krasnoperov-coding-agent[bot]" <322146550+krasnoperov-coding-agent[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:52:26 +0200 Subject: [PATCH 1/3] fix: freeze approved feature heads during integration --- src/conflict-repair-ownership.ts | 48 ------- src/idle-reconciliation.ts | 28 ++-- src/issue-session-events.ts | 7 +- src/manual-issue-actions.ts | 14 +- src/operator-retry-event.ts | 14 ++ src/reactive-workflow-intent.ts | 2 +- src/run-orchestrator.ts | 25 ++-- src/workflow-task-derivation.ts | 8 ++ test/cli.test.ts | 4 +- test/conflict-repair-ownership.test.ts | 57 -------- test/lost-webhook-rederivation.test.ts | 8 +- test/manual-issue-actions.test.ts | 20 ++- test/operator-retry-event.test.ts | 22 +++ test/queue-health-monitor.test.ts | 1 + test/run-orchestrator-reconciliation.test.ts | 141 ++++++++----------- 15 files changed, 167 insertions(+), 232 deletions(-) delete mode 100644 src/conflict-repair-ownership.ts delete mode 100644 test/conflict-repair-ownership.test.ts diff --git a/src/conflict-repair-ownership.ts b/src/conflict-repair-ownership.ts deleted file mode 100644 index 930bfa50..00000000 --- a/src/conflict-repair-ownership.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { getGateCheckNames } from "./idle-reconciliation-helpers.ts"; -import { - readIntegrationCandidateState, - type IntegrationCandidateState, -} from "./integration-candidate-state.ts"; -import type { AppConfig } from "./types.ts"; - -export type ConflictRepairOwnership = - | { owner: "feature_branch"; candidateKind: "absent" } - | { - owner: "integration_candidate"; - candidateKind: Exclude | "unknown"; - candidateSha?: string; - }; - -/** - * Resolve which workflow owns a merge conflict without mutating either - * artifact. A published integration candidate owns the conflict. Uncertain - * GitHub reads fail closed so an approved feature head stays frozen; only an - * explicit missing candidate ref grants legacy feature-branch repair. - */ -export async function resolveConflictRepairOwnership(params: { - project: AppConfig["projects"][number]; - prNumber: number; - approvedHeadSha?: string; - readCandidateState?: typeof readIntegrationCandidateState; -}): Promise { - const repoFullName = params.project.github?.repoFullName; - if (!repoFullName || !params.approvedHeadSha) { - return { owner: "integration_candidate", candidateKind: "unknown" }; - } - - const candidate = await (params.readCandidateState ?? readIntegrationCandidateState)({ - repoFullName, - baseBranch: params.project.github?.baseBranch ?? "main", - prNumber: params.prNumber, - approvedHeadSha: params.approvedHeadSha, - requiredChecks: getGateCheckNames(params.project), - }); - if (candidate?.kind === "absent") { - return { owner: "feature_branch", candidateKind: "absent" }; - } - return { - owner: "integration_candidate", - candidateKind: candidate?.kind ?? "unknown", - ...(candidate && "candidateSha" in candidate ? { candidateSha: candidate.candidateSha } : {}), - }; -} diff --git a/src/idle-reconciliation.ts b/src/idle-reconciliation.ts index 437147c3..2746e625 100644 --- a/src/idle-reconciliation.ts +++ b/src/idle-reconciliation.ts @@ -43,7 +43,6 @@ import type { WorkflowTaskDispatcher } from "./workflow-task-dispatcher.ts"; import { LinearIssueProjectionService } from "./linear-issue-projection.ts"; import type { LinearClientProvider } from "./types.ts"; import { TerminalInboxReconciler } from "./terminal-inbox-reconciler.ts"; -import { resolveConflictRepairOwnership } from "./conflict-repair-ownership.ts"; const BLOCKED_DEPENDENCY_REFRESH_SUCCESS_BACKOFF_MS = 60_000; const BLOCKED_DEPENDENCY_REFRESH_FAILURE_BACKOFF_MS = 5 * 60_000; @@ -953,24 +952,17 @@ export class IdleIssueReconciler { }); return; } + if (issue.delegatedToPatchRelay && reactiveIntent?.runType === "integration_repair" && mergeConflictDetected) { + this.logger.info( + { + issueKey: issue.issueKey, + prNumber: issue.prNumber, + }, + "Reconciliation: dirty approved PR remains frozen; candidate reconciliation owns integration repair", + ); + return; + } if (issue.delegatedToPatchRelay && reactiveIntent?.runType === "queue_repair" && mergeConflictDetected) { - const ownership = await resolveConflictRepairOwnership({ - project, - prNumber, - ...(pr.headRefOid ? { approvedHeadSha: pr.headRefOid } : {}), - }); - if (ownership.owner === "integration_candidate") { - this.logger.info( - { - issueKey: issue.issueKey, - prNumber: issue.prNumber, - candidateKind: ownership.candidateKind, - candidateSha: ownership.candidateSha, - }, - "Reconciliation: integration candidate owns the dirty PR; feature head remains frozen", - ); - return; - } this.logger.info( { issueKey: issue.issueKey, prNumber: issue.prNumber, mergeable: pr.mergeable }, "Reconciliation: PR needs queue repair from fresh GitHub truth", diff --git a/src/issue-session-events.ts b/src/issue-session-events.ts index 732f8137..0e36ec01 100644 --- a/src/issue-session-events.ts +++ b/src/issue-session-events.ts @@ -339,7 +339,12 @@ export function deriveSessionInputPlan( if (!typed) continue; switch (typed.eventType) { case "merge_steward_incident": - runType = typed.payload?.candidateBranch ? "integration_repair" : "queue_repair"; + // Once a PR is approved, every merge-steward incident belongs to the + // integration candidate even while its ref is still materializing. + // Legacy feature-branch queue repair is only valid before approval. + runType = typed.payload?.candidateBranch || issue.prReviewState === "approved" + ? "integration_repair" + : "queue_repair"; workflowReason = "merge_steward_incident"; eventIds = [event.id]; Object.assign(context, typed.payload ?? {}); diff --git a/src/manual-issue-actions.ts b/src/manual-issue-actions.ts index bc7485de..bb0bc358 100644 --- a/src/manual-issue-actions.ts +++ b/src/manual-issue-actions.ts @@ -20,14 +20,7 @@ export function resolveRetryTarget(params: { } if (hasOpenPr(params.prNumber, params.prState) && params.lastGitHubFailureSource === "queue_eviction") { - return { runType: "queue_repair" }; - } - if ( - hasOpenPr(params.prNumber, params.prState) - && params.prReviewState === "approved" - && params.lastRunType === "queue_repair" - ) { - return { runType: "queue_repair" }; + return { runType: params.prReviewState === "approved" ? "integration_repair" : "queue_repair" }; } if ( hasOpenPr(params.prNumber, params.prState) @@ -42,6 +35,9 @@ export function resolveRetryTarget(params: { : "review_fix", }; } + if (hasOpenPr(params.prNumber, params.prState) && params.prReviewState === "approved") { + return { runType: "integration_repair" }; + } if (hasOpenPr(params.prNumber, params.prState)) { return { runType: "implementation" }; } @@ -52,7 +48,7 @@ export function buildManualRetryAttemptReset(runType: RunType): Partial { }), 0, ); - assert.match(queueRetryOut.read(), /Queued stage: queue_repair/); + assert.match(queueRetryOut.read(), /Queued stage: integration_repair/); const queueRepairIssue = db.getIssue("usertold", "issue-queue-repair"); assertIssuePhase(queueRepairIssue, "repairing_queue"); const queueRepairTask = db.issueSessions.peekPendingSessionInputPlanForDiagnostics("usertold", "issue-queue-repair"); - assert.equal(queueRepairTask?.runType, "queue_repair"); + assert.equal(queueRepairTask?.runType, "integration_repair"); db.upsertIssue({ projectId: "usertold", diff --git a/test/conflict-repair-ownership.test.ts b/test/conflict-repair-ownership.test.ts deleted file mode 100644 index 68f2baca..00000000 --- a/test/conflict-repair-ownership.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { - resolveConflictRepairOwnership, - type ConflictRepairOwnership, -} from "../src/conflict-repair-ownership.ts"; -import type { IntegrationCandidateState } from "../src/integration-candidate-state.ts"; -import type { AppConfig } from "../src/types.ts"; - -const project = { - id: "usertold", - github: { - repoFullName: "owner/repo", - baseBranch: "main", - }, - gateChecks: ["verify"], -} as AppConfig["projects"][number]; - -async function resolve(candidate: IntegrationCandidateState | undefined): Promise { - return resolveConflictRepairOwnership({ - project, - prNumber: 113, - approvedHeadSha: "approved-sha", - readCandidateState: async () => candidate, - }); -} - -test("a published candidate owns conflict repair and preserves its identity", async () => { - assert.deepEqual(await resolve({ - kind: "pending", - branch: "merge-steward/main/pr-113", - candidateSha: "candidate-sha", - approvedHeadSha: "approved-sha", - checks: [], - }), { - owner: "integration_candidate", - candidateKind: "pending", - candidateSha: "candidate-sha", - }); -}); - -test("an uncertain candidate read fails closed to integration ownership", async () => { - assert.deepEqual(await resolve(undefined), { - owner: "integration_candidate", - candidateKind: "unknown", - }); -}); - -test("only a proven absent candidate grants feature-branch repair", async () => { - assert.deepEqual(await resolve({ - kind: "absent", - branch: "merge-steward/main/pr-113", - }), { - owner: "feature_branch", - candidateKind: "absent", - }); -}); diff --git a/test/lost-webhook-rederivation.test.ts b/test/lost-webhook-rederivation.test.ts index 0486b033..af243745 100644 --- a/test/lost-webhook-rederivation.test.ts +++ b/test/lost-webhook-rederivation.test.ts @@ -814,7 +814,8 @@ test("lost check_passed: green BRANCH gate does NOT clear queue_eviction provena // The swallowed-repair doctrine: a queue eviction means integration with // main broke; a green branch gate proves nothing about that. Neither the // delivered check_passed nor the poll may clear the queue provenance — - // both worlds must keep it and route the queue repair from it. + // both worlds must keep it, but an approved feature head stays frozen until + // Merge Steward publishes candidate context. const baseDir = mkdtempSync(path.join(tmpdir(), "patchrelay-lost-green-queue-")); const restoreGh = installFakeGh(baseDir, { prView: { @@ -868,7 +869,8 @@ test("lost check_passed: green BRANCH gate does NOT clear queue_eviction provena assert.equal(issue?.lastGitHubFailureSource, "queue_eviction", `${world.name}: queue provenance must survive the green poll`); } - // Pass 2: the surviving provenance routes the queue repair. + // Pass 2: the surviving provenance remains visible without creating a + // feature-branch repair task. await runReconciliationPass(pair.delivered); await runReconciliationPass(pair.lost); @@ -876,7 +878,7 @@ test("lost check_passed: green BRANCH gate does NOT clear queue_eviction provena const b = captureConvergedFacts(pair.lost); assertIssuePhase(b, "repairing_queue"); assert.equal(b.failureSource, "queue_eviction"); - assert.equal(b.runnableTaskRunType, "queue_repair"); + assert.equal(b.runnableTaskRunType, null); assert.deepEqual(b, a, "the preserved queue provenance must drive the same repair in both worlds"); } finally { pair.close(); diff --git a/test/manual-issue-actions.test.ts b/test/manual-issue-actions.test.ts index 1989738e..7f2330be 100644 --- a/test/manual-issue-actions.test.ts +++ b/test/manual-issue-actions.test.ts @@ -2,19 +2,19 @@ import assert from "node:assert/strict"; import test from "node:test"; import { resolveRetryTarget } from "../src/manual-issue-actions.ts"; -test("manual retry treats approved awaiting_queue PRs as queue repair", () => { +test("manual retry keeps approved awaiting_queue PRs in candidate integration", () => { const retryTarget = resolveRetryTarget({ prNumber: 1125, prState: "open", prReviewState: "approved", prCheckStatus: "pending", workflowOutcome: undefined, - lastRunType: "queue_repair", + lastRunType: "implementation", lastGitHubFailureSource: undefined, }); assert.deepEqual(retryTarget, { - runType: "queue_repair", + runType: "integration_repair", }); }); @@ -33,3 +33,17 @@ test("manual retry keeps ordinary open PRs on implementation retry", () => { runType: "implementation", }); }); + +test("manual retry repairs red branch CI before freezing an approved PR", () => { + const retryTarget = resolveRetryTarget({ + prNumber: 13, + prState: "open", + prReviewState: "approved", + prCheckStatus: "failed", + runnableTaskRunType: "ci_repair", + lastRunType: "implementation", + lastGitHubFailureSource: "branch_ci", + }); + + assert.deepEqual(retryTarget, { runType: "ci_repair" }); +}); diff --git a/test/operator-retry-event.test.ts b/test/operator-retry-event.test.ts index c08bd996..500951bd 100644 --- a/test/operator-retry-event.test.ts +++ b/test/operator-retry-event.test.ts @@ -37,6 +37,28 @@ test("buildOperatorRetryEvent preserves queue incident and failure context for q assert.match(String(payload.promptContext), /publish a new head SHA/); }); +test("buildOperatorRetryEvent retries approved integration work without feature-head mutation instructions", () => { + const event = buildOperatorRetryEvent(createIssue({ + linearIssueId: "issue-integration", + lastGitHubFailureSignature: "integration:candidate-123:conflict", + lastGitHubFailureContextJson: JSON.stringify({ + candidateBranch: "merge-steward/main/pr-42", + candidateSha: "candidate-123", + approvedHeadSha: "approved-456", + integrationFailureKind: "conflict", + }), + }), "integration_repair"); + + assert.equal(event.eventType, "merge_steward_incident"); + assert.equal(event.dedupeKey, "operator_retry:integration_repair:issue-integration:candidate-123"); + const payload = JSON.parse(event.eventJson) as Record; + assert.equal(payload.candidateBranch, "merge-steward/main/pr-42"); + assert.equal(payload.approvedHeadSha, "approved-456"); + assert.equal(payload.source, "operator_retry"); + assert.equal("requiresFreshHead" in payload, false); + assert.equal("promptContext" in payload, false); +}); + test("buildOperatorRetryEvent emits settled_red_ci for ci repair", () => { const event = buildOperatorRetryEvent(createIssue({ linearIssueId: "issue-ci", diff --git a/test/queue-health-monitor.test.ts b/test/queue-health-monitor.test.ts index e10f9ee2..4e660f4e 100644 --- a/test/queue-health-monitor.test.ts +++ b/test/queue-health-monitor.test.ts @@ -164,6 +164,7 @@ exit 1`; prNumber: 42, prState: "open", prReviewState: "approved", + prCheckStatus: "success", workflowOutcome: undefined, }); diff --git a/test/run-orchestrator-reconciliation.test.ts b/test/run-orchestrator-reconciliation.test.ts index 3e027768..41fe81cb 100644 --- a/test/run-orchestrator-reconciliation.test.ts +++ b/test/run-orchestrator-reconciliation.test.ts @@ -511,6 +511,50 @@ test("blocked implementation starts after idle dependency refresh recovers", asy } }); +test("a failed run claim does not consume a repair attempt before a Codex turn starts", async () => { + const baseDir = mkdtempSync(path.join(tmpdir(), "patchrelay-pre-turn-attempt-budget-")); + try { + const { db, orchestrator } = createOrchestrator(baseDir); + const issue = db.upsertIssue({ + projectId: "usertold", + linearIssueId: "issue-pre-turn-budget", + issueKey: "USE-PRE-TURN", + title: "Preserve repair budget before launch", + delegatedToPatchRelay: true, + prNumber: 77, + prState: "open", + prHeadSha: "failed-head", + prReviewState: "review_required", + prCheckStatus: "failed", + lastGitHubFailureSource: "branch_ci", + lastGitHubFailureHeadSha: "failed-head", + lastGitHubFailureSignature: "branch_ci:failed-head:verify", + workflowOutcome: undefined, + }); + reconcileWorkflowTasksForIssue(db, issue); + + const runLauncher = (orchestrator as unknown as { + runLauncher: { + prepareLaunchPlan: () => { prompt: string; branchName: string; worktreePath: string }; + claimRun: () => undefined; + }; + }).runLauncher; + runLauncher.prepareLaunchPlan = () => ({ + prompt: "repair CI", + branchName: "use/pre-turn-budget", + worktreePath: path.join(baseDir, "worktrees", "USE-PRE-TURN"), + }); + runLauncher.claimRun = () => undefined; + + await orchestrator.run({ projectId: issue.projectId, issueId: issue.linearIssueId }); + + assert.equal(db.getIssue(issue.projectId, issue.linearIssueId)?.ciRepairAttempts, 0); + assert.equal(db.runs.listRunsForIssue(issue.projectId, issue.linearIssueId).length, 0); + } finally { + rmSync(baseDir, { recursive: true, force: true }); + } +}); + test("idle reconciliation does not re-enqueue issues that already have an active run", async () => { const baseDir = mkdtempSync(path.join(tmpdir(), "patchrelay-reconcile-active-run-orphan-")); try { @@ -869,7 +913,7 @@ exit 1 } }); -test("reconcileIdleIssues keeps an approved DIRTY head frozen while its integration candidate exists", async () => { +test("reconcileIdleIssues keeps an approved DIRTY head frozen for candidate reconciliation", async () => { const baseDir = mkdtempSync(path.join(tmpdir(), "patchrelay-reconcile-dirty-candidate-owned-")); const fakeBin = path.join(baseDir, "bin"); const ghPath = path.join(fakeBin, "gh"); @@ -877,17 +921,6 @@ test("reconcileIdleIssues keeps an approved DIRTY head frozen while its integrat try { mkdirSync(fakeBin, { recursive: true }); writeFileSync(ghPath, `#!/usr/bin/env bash -if [ "$1" = "api" ] && [[ "$2" == repos/owner/repo/git/ref/heads/merge-steward/main/pr-113 ]]; then - printf 'candidate-sha' - exit 0 -fi -if [ "$1" = "api" ] && [[ "$2" == repos/owner/repo/compare/sha-frozen...candidate-sha ]]; then - printf 'ahead' - exit 0 -fi -if [ "$1" = "api" ] && [[ "$2" == repos/owner/repo/commits/candidate-sha/check-runs ]]; then - exit 0 -fi if [ "$1" = "pr" ] && [ "$2" = "view" ]; then printf '{"state":"OPEN","reviewDecision":"APPROVED","mergeable":"CONFLICTING","mergeStateStatus":"DIRTY","headRefOid":"sha-frozen","labels":[],"statusCheckRollup":[]}' exit 0 @@ -924,53 +957,6 @@ exit 1`, "utf8"); } }); -test("reconcileIdleIssues keeps an approved DIRTY head frozen when candidate ownership cannot be read", async () => { - const baseDir = mkdtempSync(path.join(tmpdir(), "patchrelay-reconcile-dirty-candidate-unknown-")); - const fakeBin = path.join(baseDir, "bin"); - const ghPath = path.join(fakeBin, "gh"); - const oldPath = process.env.PATH; - try { - mkdirSync(fakeBin, { recursive: true }); - writeFileSync(ghPath, `#!/usr/bin/env bash -if [ "$1" = "api" ]; then - printf 'temporary GitHub API failure' >&2 - exit 1 -fi -if [ "$1" = "pr" ] && [ "$2" = "view" ]; then - printf '{"state":"OPEN","reviewDecision":"APPROVED","mergeable":"CONFLICTING","mergeStateStatus":"DIRTY","headRefOid":"sha-frozen","labels":[],"statusCheckRollup":[]}' - exit 0 -fi -exit 1`, "utf8"); - chmodSync(ghPath, 0o755); - process.env.PATH = `${fakeBin}:${oldPath ?? ""}`; - - const { db, enqueueCalls, orchestrator } = createOrchestrator(baseDir); - db.upsertIssue({ - projectId: "usertold", - linearIssueId: "issue-candidate-unknown", - issueKey: "USE-CU", - branchName: "feat-candidate-unknown", - prNumber: 113, - prState: "open", - prHeadSha: "sha-frozen", - prReviewState: "approved", - prCheckStatus: "success", - workflowOutcome: undefined, - delegatedToPatchRelay: true, - }); - - await (orchestrator as unknown as { idleReconciler: { reconcile: () => Promise } }).idleReconciler.reconcile(); - - const issue = db.getIssue("usertold", "issue-candidate-unknown"); - assertIssuePhase(issue, "awaiting_queue"); - assert.equal(issue?.prHeadSha, "sha-frozen"); - assert.deepEqual(enqueueCalls, []); - } finally { - process.env.PATH = oldPath; - rmSync(baseDir, { recursive: true, force: true }); - } -}); - test("idle reconciliation recovers escalated PR issues when a newer head is now pending CI", { concurrency: false }, async () => { const baseDir = mkdtempSync(path.join(tmpdir(), "patchrelay-reconcile-terminal-pending-")); const oldPath = process.env.PATH; @@ -1841,7 +1827,7 @@ exit 1`, "utf8"); } }); -test("reconcileIdleIssues dispatches queue repair for approved DIRTY PRs without queue admission", async () => { +test("reconcileIdleIssues freezes approved DIRTY PRs until Merge Steward publishes a candidate", async () => { const baseDir = mkdtempSync(path.join(tmpdir(), "patchrelay-reconcile-approved-dirty-no-queue-")); const fakeBin = path.join(baseDir, "bin"); const ghPath = path.join(fakeBin, "gh"); @@ -1878,9 +1864,9 @@ exit 1`, "utf8"); await (orchestrator as unknown as { idleReconciler: { reconcile: () => Promise } }).idleReconciler.reconcile(); const issue = db.getIssue("usertold", "issue-13b2"); - assertIssuePhase(issue, "repairing_queue"); - assert.equal(new RunTaskPlanner(db).resolveRunTask(issue!)?.runType, "queue_repair"); - assert.deepEqual(enqueueCalls, [{ projectId: "usertold", issueId: "issue-13b2" }]); + assertIssuePhase(issue, "awaiting_queue"); + assert.equal(new RunTaskPlanner(db).resolveRunTask(issue!), undefined); + assert.deepEqual(enqueueCalls, []); } finally { process.env.PATH = oldPath; rmSync(baseDir, { recursive: true, force: true }); @@ -1932,7 +1918,7 @@ exit 1`, "utf8"); } }); -test("reconcileIdleIssues reclassifies stale branch_ci provenance to queue repair when GitHub shows a downstream conflict", async () => { +test("reconcileIdleIssues reclassifies an approved downstream conflict without dispatching feature-branch repair", async () => { const baseDir = mkdtempSync(path.join(tmpdir(), "patchrelay-reconcile-stale-branch-ci-")); const fakeBin = path.join(baseDir, "bin"); const ghPath = path.join(fakeBin, "gh"); @@ -1969,12 +1955,9 @@ exit 1`, "utf8"); await (orchestrator as unknown as { idleReconciler: { reconcile: () => Promise } }).idleReconciler.reconcile(); const issue = db.getIssue("usertold", "issue-13e"); - assertIssuePhase(issue, "repairing_queue"); - assert.equal(db.issueSessions.peekPendingSessionInputPlanForDiagnostics("usertold", "issue-13e")?.runType, "queue_repair"); + assert.equal(db.issueSessions.peekPendingSessionInputPlanForDiagnostics("usertold", "issue-13e")?.runType, "integration_repair"); assert.equal(issue?.lastGitHubFailureSource, "queue_eviction"); - assert.equal(issue?.lastGitHubFailureCheckName, "merge-steward/queue"); - assert.equal(issue?.lastGitHubFailureSignature, "queue_eviction::sha-13e::merge-steward/queue"); - assert.deepEqual(enqueueCalls, [{ projectId: "usertold", issueId: "issue-13e" }]); + assert.deepEqual(enqueueCalls, []); } finally { process.env.PATH = oldPath; rmSync(baseDir, { recursive: true, force: true }); @@ -2335,8 +2318,8 @@ test("reconcileRun reclaims a foreign active-run lease after restart once the ho assert.equal(updatedIssue?.activeRunId, undefined); assert.equal(updatedRun?.status, "failed"); assert.equal(updatedRun?.failureReason, "Codex turn was interrupted"); - assert.equal(db.issueSessions.peekPendingSessionInputPlanForDiagnostics("usertold", "issue-15f")?.runType, "queue_repair"); - assert.deepEqual(enqueueCalls, [{ projectId: "usertold", issueId: "issue-15f" }]); + assert.equal(db.issueSessions.peekPendingSessionInputPlanForDiagnostics("usertold", "issue-15f")?.runType, "integration_repair"); + assert.deepEqual(enqueueCalls, []); assert.equal(session?.leaseId, undefined); } finally { rmSync(baseDir, { recursive: true, force: true }); @@ -2405,11 +2388,11 @@ test("reconcileRun leaves interrupted queue_repair eligible for retry on idle re const updatedRun = db.runs.getRunById(run.id); assertIssuePhase(updatedIssue, "repairing_queue"); assert.equal(updatedIssue?.queueRepairAttempts, 0); - assert.equal(db.issueSessions.peekPendingSessionInputPlanForDiagnostics("usertold", "issue-15q")?.runType, "queue_repair"); + assert.equal(db.issueSessions.peekPendingSessionInputPlanForDiagnostics("usertold", "issue-15q")?.runType, "integration_repair"); assert.equal(updatedIssue?.activeRunId, undefined); assert.equal(updatedRun?.status, "failed"); assert.equal(updatedRun?.failureReason, "Codex turn was interrupted"); - assert.deepEqual(enqueueCalls, [{ projectId: "usertold", issueId: "issue-15q" }]); + assert.deepEqual(enqueueCalls, []); } finally { rmSync(baseDir, { recursive: true, force: true }); } @@ -3456,7 +3439,7 @@ test("reconciliation repairs stale undelegated local state from live Linear befo } }); -test("reconcileIdleIssues prioritizes queue eviction recovery over approved waiting state", async () => { +test("reconcileIdleIssues waits for a candidate instead of replaying an approved queue eviction on the feature branch", async () => { const baseDir = mkdtempSync(path.join(tmpdir(), "patchrelay-reconcile-queue-eviction-priority-")); try { const { db, enqueueCalls, orchestrator } = createOrchestrator(baseDir); @@ -3482,8 +3465,8 @@ test("reconcileIdleIssues prioritizes queue eviction recovery over approved wait const issue = db.getIssue("usertold", "issue-queue-priority"); assertIssuePhase(issue, "repairing_queue"); - assert.equal(db.issueSessions.peekPendingSessionInputPlanForDiagnostics("usertold", "issue-queue-priority")?.runType, "queue_repair"); - assert.deepEqual(enqueueCalls, [{ projectId: "usertold", issueId: "issue-queue-priority" }]); + assert.equal(db.issueSessions.peekPendingSessionInputPlanForDiagnostics("usertold", "issue-queue-priority")?.runType, "integration_repair"); + assert.deepEqual(enqueueCalls, []); } finally { rmSync(baseDir, { recursive: true, force: true }); } @@ -3760,7 +3743,7 @@ test("reconcileIdleIssues leaves awaiting_queue issues idle when they are alread } }); -test("reconcileIdleIssues re-enqueues queue_repair when a fresh steward incident fires after a prior failed attempt", async () => { +test("reconcileIdleIssues leaves a fresh approved steward incident pending until candidate context arrives", async () => { const baseDir = mkdtempSync(path.join(tmpdir(), "patchrelay-reconcile-fresh-incident-")); try { const { db, enqueueCalls, orchestrator } = createOrchestrator(baseDir); @@ -3791,8 +3774,8 @@ test("reconcileIdleIssues re-enqueues queue_repair when a fresh steward incident const issue = db.getIssue("usertold", "issue-fresh-incident"); assertIssuePhase(issue, "repairing_queue"); - assert.equal(db.issueSessions.peekPendingSessionInputPlanForDiagnostics("usertold", "issue-fresh-incident")?.runType, "queue_repair"); - assert.deepEqual(enqueueCalls, [{ projectId: "usertold", issueId: "issue-fresh-incident" }]); + assert.equal(db.issueSessions.peekPendingSessionInputPlanForDiagnostics("usertold", "issue-fresh-incident")?.runType, "integration_repair"); + assert.deepEqual(enqueueCalls, []); } finally { rmSync(baseDir, { recursive: true, force: true }); } From 91a829ac1bb9a940a7ff9d66b51f0430d070bf57 Mon Sep 17 00:00:00 2001 From: "krasnoperov-coding-agent[bot]" <322146550+krasnoperov-coding-agent[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:03:29 +0200 Subject: [PATCH 2/3] fix: account repair attempts atomically at claim --- src/run-attempt-accounting.ts | 60 +++++++++++++++++++++++++++++ src/run-failure-policy.ts | 43 +++------------------ src/run-launcher.ts | 32 ++++++++++----- src/run-orchestrator.ts | 14 ------- src/run-task-planner.ts | 35 ----------------- test/run-attempt-accounting.test.ts | 35 +++++++++++++++++ 6 files changed, 124 insertions(+), 95 deletions(-) create mode 100644 src/run-attempt-accounting.ts create mode 100644 test/run-attempt-accounting.test.ts diff --git a/src/run-attempt-accounting.ts b/src/run-attempt-accounting.ts new file mode 100644 index 00000000..0162ca71 --- /dev/null +++ b/src/run-attempt-accounting.ts @@ -0,0 +1,60 @@ +import type { IssueRecord } from "./db-types.ts"; +import type { UpsertIssueParams } from "./db/issue-store.ts"; +import { isRequestedChangesRunType } from "./reactive-pr-state.ts"; +import type { RunContext } from "./run-context.ts"; +import type { RunType } from "./run-type.ts"; + +export type AttemptAccountingFields = Partial>; + +export function buildAttemptStartFields( + runType: RunType, + issue: Pick, + context?: RunContext, +): AttemptAccountingFields { + const counter = runType === "ci_repair" + ? { ciRepairAttempts: issue.ciRepairAttempts + 1 } + : runType === "integration_repair" || runType === "queue_repair" + ? { queueRepairAttempts: issue.queueRepairAttempts + 1 } + : isRequestedChangesRunType(runType) + ? { reviewFixAttempts: issue.reviewFixAttempts + 1 } + : {}; + const failureSignature = context?.failureSignature; + const provenance = (runType === "ci_repair" || runType === "integration_repair" || runType === "queue_repair") + && failureSignature + ? { + lastAttemptedFailureSignature: failureSignature, + lastAttemptedFailureHeadSha: context?.failureHeadSha ?? null, + lastAttemptedFailureAt: new Date().toISOString(), + } + : {}; + return { ...counter, ...provenance }; +} + +export function buildAttemptRefundFields( + runType: RunType, + issue: Pick, +): AttemptAccountingFields { + const counter = runType === "ci_repair" && issue.ciRepairAttempts > 0 + ? { ciRepairAttempts: issue.ciRepairAttempts - 1 } + : (runType === "integration_repair" || runType === "queue_repair") && issue.queueRepairAttempts > 0 + ? { queueRepairAttempts: issue.queueRepairAttempts - 1 } + : isRequestedChangesRunType(runType) && issue.reviewFixAttempts > 0 + ? { reviewFixAttempts: issue.reviewFixAttempts - 1 } + : {}; + const provenance = runType === "ci_repair" || runType === "integration_repair" || runType === "queue_repair" + ? { + lastAttemptedFailureHeadSha: null, + lastAttemptedFailureSignature: null, + lastAttemptedFailureAt: null, + } + : {}; + return { ...counter, ...provenance }; +} diff --git a/src/run-failure-policy.ts b/src/run-failure-policy.ts index 12b24101..1c70dc94 100644 --- a/src/run-failure-policy.ts +++ b/src/run-failure-policy.ts @@ -22,45 +22,10 @@ import { appendBranchUpkeepObservation } from "./branch-upkeep-signal.ts"; import { reconcileWorkflowTasksForIssue } from "./workflow-task-reconciler.ts"; import { settleRun } from "./run-settlement.ts"; import type { ProjectConfig } from "./workflow-types.ts"; +import { buildAttemptRefundFields } from "./run-attempt-accounting.ts"; const WRITER = "run-failure-policy"; -type AttemptRefundFields = Partial>; - -// Roll back the attempt counter consumed at launch and clear the -// attempted-failure provenance for repair runs, so a run that died without -// evidence about the work (interrupted turn, capacity outage) neither burns -// a budget unit nor blocks the same failure from re-deriving a workflow task. -function buildAttemptRefundFields( - runType: RunType, - issue: Pick, -): AttemptRefundFields | undefined { - const counter = runType === "ci_repair" && issue.ciRepairAttempts > 0 - ? { ciRepairAttempts: issue.ciRepairAttempts - 1 } - : (runType === "integration_repair" || runType === "queue_repair") && issue.queueRepairAttempts > 0 - ? { queueRepairAttempts: issue.queueRepairAttempts - 1 } - : isRequestedChangesRunType(runType) && issue.reviewFixAttempts > 0 - ? { reviewFixAttempts: issue.reviewFixAttempts - 1 } - : undefined; - const provenance = runType === "ci_repair" || runType === "integration_repair" || runType === "queue_repair" - ? { - lastAttemptedFailureHeadSha: null, - lastAttemptedFailureSignature: null, - lastAttemptedFailureAt: null, - } - : undefined; - if (!counter && !provenance) return undefined; - return { ...counter, ...provenance }; -} - // The interrupted-run variant: same refund, committed as a single issue // update so the whole repair commits (and conflict-recomputes) atomically. function buildInterruptedAttemptRepairUpdate( @@ -68,7 +33,7 @@ function buildInterruptedAttemptRepairUpdate( issue: Pick, ): UpsertIssueParams | undefined { const fields = buildAttemptRefundFields(runType, issue); - if (!fields) return undefined; + if (Object.keys(fields).length === 0) return undefined; return { projectId: issue.projectId, linearIssueId: issue.linearIssueId, @@ -129,12 +94,16 @@ export class RunFailurePolicy { failureReason: string; }): void { const { run, issue } = params; + const turnNeverStarted = run.launchPhase !== "turn_started" && run.launchPhase !== "running"; this.withHeldLease(run.projectId, run.linearIssueId, (lease) => settleRun({ db: this.db, run, finish: { status: "failed", failureReason: params.failureReason }, lease, + ...(turnNeverStarted + ? { buildIssueUpdate: (current) => buildAttemptRefundFields(run.runType, current) } + : {}), })); this.recoverOrEscalate({ issue, runType: run.runType, reason: params.reason }); } diff --git a/src/run-launcher.ts b/src/run-launcher.ts index 92491c21..43088eaf 100644 --- a/src/run-launcher.ts +++ b/src/run-launcher.ts @@ -5,6 +5,7 @@ import type { CodexAppServerClient } from "./codex-app-server.ts"; import type { PatchRelayDatabase } from "./db.ts"; import type { IssueRecord, RunRecord } from "./db-types.ts"; import type { RunType } from "./run-type.ts"; +import { buildAttemptRefundFields, buildAttemptStartFields } from "./run-attempt-accounting.ts"; import { resolveFailureOutcome } from "./reactive-pr-state.ts"; import { buildHookEnv, type HookEnv, type HookResult, runProjectHook } from "./hook-runner.ts"; import { buildRunFailureActivity } from "./linear-session-reporting.ts"; @@ -320,8 +321,6 @@ export class RunLauncher { ...(params.authorityEpoch !== undefined ? { authorityEpoch: params.authorityEpoch } : {}), promptText: params.prompt, }); - const failureHeadSha = params.effectiveContext?.failureHeadSha; - const failureSignature = params.effectiveContext?.failureSignature; const claimUpdate = { projectId: params.item.projectId, linearIssueId: params.item.issueId, @@ -334,13 +333,7 @@ export class RunLauncher { workflowOutcomeReason: null, inputRequestKind: null, }), - ...((params.runType === "ci_repair" || params.runType === "integration_repair" || params.runType === "queue_repair") && failureSignature - ? { - lastAttemptedFailureSignature: failureSignature, - lastAttemptedFailureHeadSha: failureHeadSha ?? null, - lastAttemptedFailureAt: new Date().toISOString(), - } - : {}), + ...buildAttemptStartFields(params.runType, fresh, params.effectiveContext), }; const claimCommit = this.db.issueSessions.commitIssueState({ writer: WRITER, @@ -554,13 +547,20 @@ export class RunLauncher { // Issue clear + run-terminal write ride in one transaction; the run // finish is gated on the issue commit so a lost lease skips both. this.db.transaction(() => { + const current = this.db.issues.getIssue(params.project.id, params.issue.linearIssueId); + // A completion notification may have settled this run while the + // start request was rejecting. Never clear or refund a slot that no + // longer belongs to this launch. + if (!current || current.activeRunId !== params.run.id) return; const commit = this.db.issueSessions.commitIssueState({ writer: WRITER, lease: { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId }, + expectedVersion: current.version, update: { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, activeRunId: null, + ...buildAttemptRefundFields(params.runType, current), ...(workflowOutcome ? { workflowOutcome, @@ -568,6 +568,20 @@ export class RunLauncher { } : {}), }, + onConflict: (fresh) => fresh.activeRunId === params.run.id + ? { + projectId: params.project.id, + linearIssueId: params.issue.linearIssueId, + activeRunId: null, + ...buildAttemptRefundFields(params.runType, fresh), + ...(workflowOutcome + ? { + workflowOutcome, + workflowOutcomeReason: `run_launch_failed:${params.runType}`, + } + : {}), + } + : undefined, }); if (commit.outcome !== "applied") return; this.db.runs.finishRun(params.run.id, { diff --git a/src/run-orchestrator.ts b/src/run-orchestrator.ts index 4c0fdac6..3a994eda 100644 --- a/src/run-orchestrator.ts +++ b/src/run-orchestrator.ts @@ -746,20 +746,6 @@ export class RunOrchestrator { lowerCaseFirst, }); - // A repair budget represents an agent attempt, not an enqueue, worktree - // preparation, failed claim, or other pre-turn infrastructure work. - // Consume it only after Codex confirms that the turn actually started. - if (!this.runTaskPlanner.incrementAttemptCounters( - this.db.issues.getIssue(item.projectId, item.issueId) ?? issue, - { projectId: issue.projectId, linearIssueId: issue.linearIssueId, leaseId }, - runType, - isRequestedChangesRunType, - )) { - this.logger.warn({ runId: run.id, issueId: run.linearIssueId }, "Started Codex turn but lost lease before recording its attempt"); - this.releaseIssueSessionLease(run.projectId, run.linearIssueId); - return; - } - this.assertLaunchLease(run, "before recording the active thread"); if (!this.db.issueSessions.updateRunThreadWithLease( { projectId: run.projectId, linearIssueId: run.linearIssueId, leaseId }, diff --git a/src/run-task-planner.ts b/src/run-task-planner.ts index d45ddabd..cc1700cb 100644 --- a/src/run-task-planner.ts +++ b/src/run-task-planner.ts @@ -17,8 +17,6 @@ import type { WorkflowRunIntent } from "./workflow-intent.ts"; import type { ProjectConfig } from "./workflow-types.ts"; import { reconcileWorkflowTasksForIssue } from "./workflow-task-reconciler.ts"; -const WRITER = "run-task-planner"; - export function buildRequestedChangesLoopEscalationReason(attempts: number, configuredLimit: number): string { return `Repeated/systemic requested-changes review loop after ${attempts} repair attempts (configured limit: ${configuredLimit}). Next action: consolidate the accumulated review history and audit the violated invariants, or split an oversized PR before requesting another review.`; } @@ -199,37 +197,4 @@ export class RunTaskPlanner { return undefined; } - incrementAttemptCounters( - issue: IssueRecord, - lease: IssueSessionLease, - runType: RunType, - isRequestedChangesRunType: (runType: RunType) => boolean, - ): boolean { - // The increments are read-modify-write against the issue row (which may - // be stale by the time the launch path gets here); on conflict, recompute - // from the fresh row instead of writing a counter derived from the stale - // read. - const buildIncrement = (record: Pick) => { - if (runType === "ci_repair") { - return { projectId: issue.projectId, linearIssueId: issue.linearIssueId, ciRepairAttempts: record.ciRepairAttempts + 1 }; - } - if (runType === "integration_repair" || runType === "queue_repair") { - return { projectId: issue.projectId, linearIssueId: issue.linearIssueId, queueRepairAttempts: record.queueRepairAttempts + 1 }; - } - if (isRequestedChangesRunType(runType)) { - return { projectId: issue.projectId, linearIssueId: issue.linearIssueId, reviewFixAttempts: record.reviewFixAttempts + 1 }; - } - return undefined; - }; - const update = buildIncrement(issue); - if (!update) return true; - const commit = this.db.issueSessions.commitIssueState({ - writer: WRITER, - lease, - expectedVersion: issue.version, - update, - onConflict: (current) => buildIncrement(current), - }); - return commit.outcome === "applied"; - } } diff --git a/test/run-attempt-accounting.test.ts b/test/run-attempt-accounting.test.ts new file mode 100644 index 00000000..2ed95a43 --- /dev/null +++ b/test/run-attempt-accounting.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildAttemptRefundFields, buildAttemptStartFields } from "../src/run-attempt-accounting.ts"; + +const counters = { + ciRepairAttempts: 2, + queueRepairAttempts: 3, + reviewFixAttempts: 4, +}; + +test("repair attempt start records budget and failure provenance together", () => { + const fields = buildAttemptStartFields("integration_repair", counters, { + failureHeadSha: "candidate-sha", + failureSignature: "integration:candidate-sha:conflict", + }); + + assert.equal(fields.queueRepairAttempts, 4); + assert.equal(fields.lastAttemptedFailureHeadSha, "candidate-sha"); + assert.equal(fields.lastAttemptedFailureSignature, "integration:candidate-sha:conflict"); + assert.ok(fields.lastAttemptedFailureAt); +}); + +test("pre-turn refund restores the budget and clears failure provenance", () => { + assert.deepEqual(buildAttemptRefundFields("ci_repair", counters), { + ciRepairAttempts: 1, + lastAttemptedFailureHeadSha: null, + lastAttemptedFailureSignature: null, + lastAttemptedFailureAt: null, + }); +}); + +test("implementation claims do not participate in repair accounting", () => { + assert.deepEqual(buildAttemptStartFields("implementation", counters), {}); + assert.deepEqual(buildAttemptRefundFields("implementation", counters), {}); +}); From 4c17901a50e48c9894b008271810e0e7b24d3118 Mon Sep 17 00:00:00 2001 From: "krasnoperov-coding-agent[bot]" <322146550+krasnoperov-coding-agent[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:12:02 +0200 Subject: [PATCH 3/3] fix: recover pre-turn claimed runs --- src/run-launcher.ts | 9 +++-- src/run-reconciler.ts | 28 +++++++++++++- test/crash-injection.test.ts | 71 ++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/src/run-launcher.ts b/src/run-launcher.ts index 43088eaf..0b2e9ad5 100644 --- a/src/run-launcher.ts +++ b/src/run-launcher.ts @@ -321,7 +321,7 @@ export class RunLauncher { ...(params.authorityEpoch !== undefined ? { authorityEpoch: params.authorityEpoch } : {}), promptText: params.prompt, }); - const claimUpdate = { + const buildClaimUpdate = (record: IssueRecord) => ({ projectId: params.item.projectId, linearIssueId: params.item.issueId, activeRunId: created.id, @@ -333,14 +333,15 @@ export class RunLauncher { workflowOutcomeReason: null, inputRequestKind: null, }), - ...buildAttemptStartFields(params.runType, fresh, params.effectiveContext), - }; + ...buildAttemptStartFields(params.runType, record, params.effectiveContext), + }); + const claimUpdate = buildClaimUpdate(fresh); const claimCommit = this.db.issueSessions.commitIssueState({ writer: WRITER, expectedVersion: fresh.version, update: claimUpdate, // Never steal a slot another writer claimed concurrently. - onConflict: (current) => (current.activeRunId == null ? claimUpdate : undefined), + onConflict: (current) => (current.activeRunId == null ? buildClaimUpdate(current) : undefined), }); if (claimCommit.outcome !== "applied") return undefined; // Session events are consumed for session-history coherence; the diff --git a/src/run-reconciler.ts b/src/run-reconciler.ts index 903bb12d..9dab818a 100644 --- a/src/run-reconciler.ts +++ b/src/run-reconciler.ts @@ -181,6 +181,33 @@ export class RunReconciler { return; } + const latestTurn = getThreadTurns(thread).at(-1); + const turnNeverStarted = run.launchPhase !== "turn_started" && run.launchPhase !== "running"; + if (!latestTurn && turnNeverStarted) { + if (recoveryLease === "owned") { + this.logger.debug( + { issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, + "Skipping pre-turn recovery for a locally-owned launch", + ); + return; + } + this.logger.warn( + { issueKey: effectiveIssue.issueKey, runId: run.id, runType: run.runType, threadId: run.threadId }, + "Pre-turn zombie detected (thread exists but no turn started)", + ); + this.failurePolicy.settleStrandedRunAndRecover({ + run, + issue: effectiveIssue, + reason: "pre_turn_zombie", + failureReason: "Zombie: thread created but Codex turn never started after restart", + }); + const recoveredIssue = this.db.issues.getIssue(run.projectId, run.linearIssueId) ?? effectiveIssue; + void this.linearSync.emitActivity(recoveredIssue, buildRunFailureActivity(run.runType, "The Codex thread was created, but its turn never started before PatchRelay restarted.")); + void this.linearSync.syncSession(recoveredIssue, { activeRunType: run.runType }); + this.releaseLease(run.projectId, run.linearIssueId); + return; + } + const linear = await this.linearProvider.forProject(run.projectId).catch(() => undefined); if (linear) { const linearIssue = await linear.getIssue(run.linearIssueId).catch(() => undefined); @@ -217,7 +244,6 @@ export class RunReconciler { } } - const latestTurn = getThreadTurns(thread).at(-1); if (latestTurn?.status === "interrupted") { try { await this.failurePolicy.handleInterruptedRun(run, effectiveIssue); diff --git a/test/crash-injection.test.ts b/test/crash-injection.test.ts index 9a542d36..83dbe5bb 100644 --- a/test/crash-injection.test.ts +++ b/test/crash-injection.test.ts @@ -362,6 +362,77 @@ test("launch race: slot claimed but no thread persisted - restart settles the zo } }); +test("pre-turn crash: persisted thread with no turn refunds the repair attempt and recovers", { concurrency: false }, async () => { + const baseDir = mkdtempSync(path.join(tmpdir(), "patchrelay-crash-pre-turn-thread-")); + const restoreGh = installFakeGh(baseDir, { + prView: { + headRefOid: "sha-pre-turn", + state: "OPEN", + reviewDecision: "REVIEW_REQUIRED", + mergeable: "MERGEABLE", + mergeStateStatus: "BLOCKED", + statusCheckRollup: [{ __typename: "CheckRun", name: "verify", status: "COMPLETED", conclusion: "FAILURE" }], + }, + }); + const config = createConfig(baseDir); + try { + seedCrashedProcessState(config, (db) => { + const issue = db.upsertIssue({ + projectId: PROJECT, + linearIssueId: "issue-pre-turn-thread", + issueKey: "USE-CR2B", + branchName: "feat-pre-turn-thread", + prNumber: 42, + prState: "open", + prHeadSha: "sha-pre-turn", + prCheckStatus: "failure", + workflowOutcome: undefined, + delegatedToPatchRelay: true, + ciRepairAttempts: 1, + lastGitHubFailureSource: "branch_ci", + lastGitHubFailureHeadSha: "sha-pre-turn", + lastGitHubFailureSignature: "branch_ci::sha-pre-turn::verify", + lastAttemptedFailureHeadSha: "sha-pre-turn", + lastAttemptedFailureSignature: "branch_ci::sha-pre-turn::verify", + }); + const run = db.runs.createRun({ + issueId: issue.id, + projectId: PROJECT, + linearIssueId: issue.linearIssueId, + runType: "ci_repair", + sourceHeadSha: "sha-pre-turn", + }); + db.runs.updateRunThread(run.id, { threadId: "thread-pre-turn" }); + db.runs.updateLaunchPhase(run.id, "thread_started"); + db.upsertIssue({ projectId: PROJECT, linearIssueId: issue.linearIssueId, activeRunId: run.id }); + seedExpiredForeignLease(db, issue.linearIssueId); + }); + + const { db, orchestrator, enqueueCalls } = startRestartedService(config, { + readThread: async () => ({ id: "thread-pre-turn", turns: [] }) as never, + }); + try { + await orchestrator.reconcileActiveRuns(); + + const issue = db.getIssue(PROJECT, "issue-pre-turn-thread"); + const run = db.runs.getLatestRunForIssue(PROJECT, "issue-pre-turn-thread"); + assert.equal(run?.status, "failed"); + assert.match(run?.failureReason ?? "", /turn never started/); + assert.equal(issue?.activeRunId, undefined); + assert.equal(issue?.ciRepairAttempts, 0); + assert.equal(issue?.lastAttemptedFailureSignature, undefined); + assert.equal(new RunTaskPlanner(db).resolveRunTask(issue!)?.runType, "ci_repair"); + assert.ok(enqueueCalls.some((call) => call.issueId === "issue-pre-turn-thread")); + assertConvergedIssue(db, "issue-pre-turn-thread"); + } finally { + db.close(); + } + } finally { + restoreGh(); + rmSync(baseDir, { recursive: true, force: true }); + } +}); + test("thread persisted but gone after restart: stale foreign lease is reclaimed and the run settles in one pass", async () => { // Multi-step path: the thread id was persisted, but the Codex side lost // the thread across the crash (readThread fails). The dead worker's lease