From 6091ae1516571c3cf8f439ed0a22f80a62c2db8d 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 21:54:11 +0200 Subject: [PATCH 1/2] fix: keep candidate-owned conflicts off feature heads --- src/idle-reconciliation.ts | 28 +++++ test/run-orchestrator-reconciliation.test.ts | 106 +++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/src/idle-reconciliation.ts b/src/idle-reconciliation.ts index b729b53a..ee756d23 100644 --- a/src/idle-reconciliation.ts +++ b/src/idle-reconciliation.ts @@ -43,6 +43,7 @@ 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 { readIntegrationCandidateState } from "./integration-candidate-state.ts"; const BLOCKED_DEPENDENCY_REFRESH_SUCCESS_BACKOFF_MS = 60_000; const BLOCKED_DEPENDENCY_REFRESH_FAILURE_BACKOFF_MS = 5 * 60_000; @@ -953,6 +954,33 @@ export class IdleIssueReconciler { return; } if (issue.delegatedToPatchRelay && reactiveIntent?.runType === "queue_repair" && mergeConflictDetected) { + const candidate = pr.headRefOid && project.github?.repoFullName + ? await readIntegrationCandidateState({ + repoFullName: project.github.repoFullName, + baseBranch: project.github.baseBranch ?? "main", + prNumber, + approvedHeadSha: pr.headRefOid, + requiredChecks: getGateCheckNames(project), + }) + : undefined; + // A published candidate ref is Merge Steward's ownership signal. A + // DIRTY feature PR is expected while that separate workspace is being + // repaired or validated; rewriting the approved PR head here would + // invalidate both its review and the exact candidate. Only the proven + // absence of a candidate permits the legacy branch-repair path. API + // uncertainty fails closed to preserve the frozen feature artifact. + if (candidate?.kind !== "absent") { + this.logger.info( + { + issueKey: issue.issueKey, + prNumber: issue.prNumber, + candidateKind: candidate?.kind ?? "unknown", + candidateSha: candidate?.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/test/run-orchestrator-reconciliation.test.ts b/test/run-orchestrator-reconciliation.test.ts index fa4f2a2d..3e027768 100644 --- a/test/run-orchestrator-reconciliation.test.ts +++ b/test/run-orchestrator-reconciliation.test.ts @@ -869,6 +869,108 @@ exit 1 } }); +test("reconcileIdleIssues keeps an approved DIRTY head frozen while its integration candidate exists", async () => { + const baseDir = mkdtempSync(path.join(tmpdir(), "patchrelay-reconcile-dirty-candidate-owned-")); + 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" ] && [[ "$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 +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-owned", + issueKey: "USE-CO", + branchName: "feat-candidate-owned", + 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-owned"); + assertIssuePhase(issue, "awaiting_queue"); + assert.equal(issue?.prHeadSha, "sha-frozen"); + assert.equal(issue?.lastGitHubFailureSource, undefined); + assert.deepEqual(enqueueCalls, []); + } finally { + process.env.PATH = oldPath; + rmSync(baseDir, { recursive: true, force: true }); + } +}); + +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; @@ -1747,6 +1849,10 @@ test("reconcileIdleIssues dispatches queue repair for approved DIRTY PRs without 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 '404 Not Found' >&2 + exit 1 +fi if [ "$1" = "pr" ] && [ "$2" = "view" ]; then printf '{"state":"OPEN","reviewDecision":"APPROVED","mergeable":"CONFLICTING","mergeStateStatus":"DIRTY","headRefOid":"sha-13b2","labels":[]}' exit 0 From ebc27f86cb5ec4088354d023b6582a3a5733d5f2 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 22:00:24 +0200 Subject: [PATCH 2/2] refactor: extract conflict repair ownership policy --- src/conflict-repair-ownership.ts | 48 ++++++++++++++++++++++ src/idle-reconciliation.ts | 28 ++++--------- test/conflict-repair-ownership.test.ts | 57 ++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 src/conflict-repair-ownership.ts create mode 100644 test/conflict-repair-ownership.test.ts diff --git a/src/conflict-repair-ownership.ts b/src/conflict-repair-ownership.ts new file mode 100644 index 00000000..930bfa50 --- /dev/null +++ b/src/conflict-repair-ownership.ts @@ -0,0 +1,48 @@ +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 ee756d23..437147c3 100644 --- a/src/idle-reconciliation.ts +++ b/src/idle-reconciliation.ts @@ -43,7 +43,7 @@ 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 { readIntegrationCandidateState } from "./integration-candidate-state.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; @@ -954,28 +954,18 @@ export class IdleIssueReconciler { return; } if (issue.delegatedToPatchRelay && reactiveIntent?.runType === "queue_repair" && mergeConflictDetected) { - const candidate = pr.headRefOid && project.github?.repoFullName - ? await readIntegrationCandidateState({ - repoFullName: project.github.repoFullName, - baseBranch: project.github.baseBranch ?? "main", - prNumber, - approvedHeadSha: pr.headRefOid, - requiredChecks: getGateCheckNames(project), - }) - : undefined; - // A published candidate ref is Merge Steward's ownership signal. A - // DIRTY feature PR is expected while that separate workspace is being - // repaired or validated; rewriting the approved PR head here would - // invalidate both its review and the exact candidate. Only the proven - // absence of a candidate permits the legacy branch-repair path. API - // uncertainty fails closed to preserve the frozen feature artifact. - if (candidate?.kind !== "absent") { + 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: candidate?.kind ?? "unknown", - candidateSha: candidate?.candidateSha, + candidateKind: ownership.candidateKind, + candidateSha: ownership.candidateSha, }, "Reconciliation: integration candidate owns the dirty PR; feature head remains frozen", ); diff --git a/test/conflict-repair-ownership.test.ts b/test/conflict-repair-ownership.test.ts new file mode 100644 index 00000000..68f2baca --- /dev/null +++ b/test/conflict-repair-ownership.test.ts @@ -0,0 +1,57 @@ +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", + }); +});