From 477634c74b28bfe497caec8c3b46f94487586b80 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Mon, 14 Sep 2026 20:34:12 +0800 Subject: [PATCH 1/2] feat(delivery): bounded GitHub PR delivery with preflight and reconcile-before-retry (#115) --- package.json | 2 +- src/durable-operations.ts | 3 +- src/github-pr-delivery.test.ts | 463 +++++++++++++++++++++++ src/github-pr-delivery.ts | 645 +++++++++++++++++++++++++++++++++ 4 files changed, 1111 insertions(+), 2 deletions(-) create mode 100644 src/github-pr-delivery.test.ts create mode 100644 src/github-pr-delivery.ts diff --git a/package.json b/package.json index e39e93252..5b40554aa 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "test:node-pty-permissions": "node --import tsx --test src/node-pty-postinstall.test.ts", "test:physical-host-registry": "tsx src/physical-host-registry.test.ts", "start": "node dist/cli.js serve", - "test": "npm run test:node-pty-permissions && tsx src/development-boundaries.test.ts && npm run test:physical-host-registry && npm run test:carrier-binding && tsx src/coordination-reader-loader.test.ts && tsx src/control-plane-ownership.test.ts && tsx src/control-plane-handoff.test.ts && tsx src/control-plane-continuation.test.ts && tsx src/deployment-convergence.test.ts && tsx src/capability-generation-convergence.test.ts && tsx src/control-plane-convergence.test.ts && tsx src/current-completion-matrix.test.ts && tsx src/control-plane-consumer.test.ts && tsx src/local-agent-cline-catalog.test.ts && tsx src/chat-swarm-contract.test.ts && tsx src/chat-swarm-store.test.ts && tsx src/chat-swarm-runtime-owner.test.ts && tsx src/chat-swarm-runtime-tools.test.ts && tsx src/local-agent-opencode-mcp-catalog.test.ts && tsx src/chat-swarm-coordinator.test.ts && tsx src/chat-swarm-peer-runtime.test.ts && tsx src/chat-swarm-tools.test.ts && tsx src/chat-swarm-lifecycle.test.ts && tsx src/chat-swarm-carrier.test.ts && tsx src/chat-swarm-continuation-domain.test.ts && tsx src/chat-swarm-continuation-store.test.ts && tsx src/chat-swarm-peer-admission.test.ts && tsx src/chat-swarm-task-ledger.test.ts && tsx src/git-worktrees.test.ts && tsx src/execution-protocol.test.ts && tsx src/durable-operations-ci.test.ts && tsx src/git-candidate.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions-ci.test.ts && tsx src/codex-goal-sessions-ci.test.ts && tsx src/mcp-sessions.test.ts && tsx src/cutover-state.test.ts && tsx src/cutover-state-recovery-guard.test.ts && tsx src/cutover-build-ready.test.ts && tsx src/cutover-orchestration.test.ts && tsx src/mcp-cutover.test.ts && tsx src/cutover-restart.test.ts && tsx src/cutover-http.test.ts && tsx src/cutover-recovery.test.ts && tsx src/cutover-binding-repair.test.ts && tsx src/capability-manifest.test.ts && tsx src/server-shutdown.test.ts && tsx src/codex-runtime.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-opencode-catalog.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-profile-source.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-toolchains.test.ts && tsx src/local-agent-idle-policy.test.ts && tsx src/local-agent-capacity.test.ts && tsx src/local-agent-execution-contract.test.ts && tsx src/local-agent-continuation.test.ts && tsx src/provider-scratch.test.ts && tsx src/git-integration-ci.test.ts && tsx src/repository-intelligence.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/conversation-isolation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server-ci.test.ts && tsx src/oauth-store.test.ts && tsx src/cli-ci.test.ts && tsx src/oauth-json-and-child-reap.test.ts && tsx src/local-agent-errors.test.ts && tsx src/provider-output-redaction.test.ts && tsx src/host-activation.test.ts && tsx src/host-operation-policy.test.ts && tsx src/host-operations.test.ts && tsx src/host-operations-http.test.ts", + "test": "npm run test:node-pty-permissions && tsx src/development-boundaries.test.ts && npm run test:physical-host-registry && npm run test:carrier-binding && tsx src/coordination-reader-loader.test.ts && tsx src/control-plane-ownership.test.ts && tsx src/control-plane-handoff.test.ts && tsx src/control-plane-continuation.test.ts && tsx src/deployment-convergence.test.ts && tsx src/capability-generation-convergence.test.ts && tsx src/control-plane-convergence.test.ts && tsx src/current-completion-matrix.test.ts && tsx src/control-plane-consumer.test.ts && tsx src/local-agent-cline-catalog.test.ts && tsx src/chat-swarm-contract.test.ts && tsx src/chat-swarm-store.test.ts && tsx src/chat-swarm-runtime-owner.test.ts && tsx src/chat-swarm-runtime-tools.test.ts && tsx src/local-agent-opencode-mcp-catalog.test.ts && tsx src/chat-swarm-coordinator.test.ts && tsx src/chat-swarm-peer-runtime.test.ts && tsx src/chat-swarm-tools.test.ts && tsx src/chat-swarm-lifecycle.test.ts && tsx src/chat-swarm-carrier.test.ts && tsx src/chat-swarm-continuation-domain.test.ts && tsx src/chat-swarm-continuation-store.test.ts && tsx src/chat-swarm-peer-admission.test.ts && tsx src/chat-swarm-task-ledger.test.ts && tsx src/git-worktrees.test.ts && tsx src/execution-protocol.test.ts && tsx src/durable-operations-ci.test.ts && tsx src/git-candidate.test.ts && tsx src/config.test.ts && tsx src/onboarding.test.ts && tsx src/cli-workspace.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions-ci.test.ts && tsx src/codex-goal-sessions-ci.test.ts && tsx src/mcp-sessions.test.ts && tsx src/cutover-state.test.ts && tsx src/cutover-state-recovery-guard.test.ts && tsx src/cutover-build-ready.test.ts && tsx src/cutover-orchestration.test.ts && tsx src/mcp-cutover.test.ts && tsx src/cutover-restart.test.ts && tsx src/cutover-http.test.ts && tsx src/cutover-recovery.test.ts && tsx src/cutover-binding-repair.test.ts && tsx src/capability-manifest.test.ts && tsx src/server-shutdown.test.ts && tsx src/codex-runtime.test.ts && tsx src/local-agent-config.test.ts && tsx src/local-agent-catalog.test.ts && tsx src/local-agent-presentation.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-daemon-lifecycle.test.ts && tsx src/local-agent-daemon-protocol.test.ts && tsx src/local-agent-daemon.test.ts && tsx src/local-agent-codex.test.ts && tsx src/local-agent-opencode.test.ts && tsx src/local-agent-opencode-catalog.test.ts && tsx src/local-agent-acp.test.ts && tsx src/local-agent-grok.test.ts && tsx src/local-agent-pi-sandbox.test.ts && tsx src/local-agent-pi.test.ts && tsx src/local-agent-claude.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-profile-source.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-toolchains.test.ts && tsx src/local-agent-idle-policy.test.ts && tsx src/local-agent-capacity.test.ts && tsx src/local-agent-execution-contract.test.ts && tsx src/local-agent-continuation.test.ts && tsx src/provider-scratch.test.ts && tsx src/git-integration-ci.test.ts && tsx src/repository-intelligence.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/conversation-isolation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server-ci.test.ts && tsx src/oauth-store.test.ts && tsx src/cli-ci.test.ts && tsx src/oauth-json-and-child-reap.test.ts && tsx src/local-agent-errors.test.ts && tsx src/provider-output-redaction.test.ts && tsx src/host-activation.test.ts && tsx src/host-operation-policy.test.ts && tsx src/host-operations.test.ts && tsx src/host-operations-http.test.ts && tsx src/github-pr-delivery.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit", "test:carrier-binding": "tsx src/carrier-binding.test.ts && tsx src/carrier-binding-http.test.ts", "test:local-agent-sessions": "node --import tsx --test src/local-agent-sessions.test.ts" diff --git a/src/durable-operations.ts b/src/durable-operations.ts index 04037302e..97dedaa32 100644 --- a/src/durable-operations.ts +++ b/src/durable-operations.ts @@ -27,7 +27,8 @@ export type DurableOperationKind = | "nexus_gateway_recover" | "cutover_start" | "host_operation" - | "chat_swarm_reconciliation"; + | "chat_swarm_reconciliation" + | "github_pr_delivery"; export type DurableOperationStatus = "started" | "succeeded" | "failed" | "outcome_unknown"; export type DependencySyncRecipe = "npm_ci" | "pnpm_frozen" | "uv_frozen"; diff --git a/src/github-pr-delivery.test.ts b/src/github-pr-delivery.test.ts new file mode 100644 index 000000000..bca8e26a5 --- /dev/null +++ b/src/github-pr-delivery.test.ts @@ -0,0 +1,463 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { DurableOperationStore } from "./durable-operations.js"; +import { + GitHubPrDeliveryError, + GitHubPrEffectError, + preflightPrDelivery, + reconcilePrDeliveryEffect, + runPrDeliveryEffect, + type GitHubPrDeliveryRequest, + type GitHubPrEffectAdapter, + type GitHubPrRef, +} from "./github-pr-delivery.js"; + +const BASE_SHA = "a".repeat(40); +const HEAD_SHA = "b".repeat(40); +const OTHER_SHA = "c".repeat(40); + +function baseRequest(overrides: Partial = {}): GitHubPrDeliveryRequest { + return { + repository: "James3014/devspace", + baseBranch: "main", + expectedBaseSha: BASE_SHA, + candidateBranch: "codex/issue-115-probe", + expectedCandidateHeadSha: HEAD_SHA, + title: "probe delivery", + body: "probe body", + issueNumber: 115, + attemptKey: "probe-attempt-1", + localRepoRoot: "/tmp/pr-delivery-scope", + ...overrides, + }; +} + +interface FakeCounts { + repoReads: number; + branchReads: number; + lists: number; + prReads: number; + creates: number; +} + +class FakeGitHub implements GitHubPrEffectAdapter { + readonly counts: FakeCounts = { repoReads: 0, branchReads: 0, lists: 0, prReads: 0, creates: 0 }; + repoError?: GitHubPrEffectError; + repoForbidden = false; + heads: Record = { main: BASE_SHA, "codex/issue-115-probe": HEAD_SHA }; + headError?: GitHubPrEffectError; + prs: GitHubPrRef[] = []; + listError?: GitHubPrEffectError; + createMode: "ok" | "lost-ack" | "refused" = "ok"; + createError = new GitHubPrEffectError("EFFECT_FAILED", false, false, "create refused"); + readError?: GitHubPrEffectError; + writeCapability: "unknown" | "unavailable" = "unknown"; + private nextNumber = 100; + + async readRepository(): Promise<{ name: string }> { + this.counts.repoReads += 1; + if (this.repoError) throw this.repoError; + if (this.repoForbidden) { + throw new GitHubPrEffectError("EFFECT_FORBIDDEN", false, false, "repository not authorized"); + } + return { name: "James3014/devspace" }; + } + + async readBranchHead(branch: string): Promise<{ sha: string } | { missing: true }> { + this.counts.branchReads += 1; + if (this.headError) throw this.headError; + const head = this.heads[branch]; + if (head === undefined || head === "missing") return { missing: true }; + return { sha: head }; + } + + async listOpenPrs(input: { headBranch: string; baseBranch: string }): Promise { + this.counts.lists += 1; + if (this.listError) throw this.listError; + return this.prs.filter( + (pr) => pr.state === "open" && pr.headBranch === input.headBranch && pr.baseBranch === input.baseBranch, + ); + } + + async readPr(prNumber: number): Promise { + this.counts.prReads += 1; + if (this.readError) throw this.readError; + const found = this.prs.find((pr) => pr.number === prNumber); + if (!found) throw new GitHubPrEffectError("EFFECT_NOT_FOUND", false, false, "PR not found"); + return found; + } + + async createPr(input: { title: string; body: string; headBranch: string; baseBranch: string; issueNumber?: number }): Promise { + this.counts.creates += 1; + if (this.createMode === "refused") throw this.createError; + const ref: GitHubPrRef = { + repository: "James3014/devspace", + number: this.nextNumber++, + url: `https://github.com/James3014/devspace/pull/${this.nextNumber - 1}`, + headSha: HEAD_SHA, + baseSha: BASE_SHA, + baseBranch: input.baseBranch, + headBranch: input.headBranch, + title: input.title, + ...(input.issueNumber === undefined ? {} : { issueNumber: input.issueNumber }), + state: "open", + }; + this.prs.push(ref); + if (this.createMode === "lost-ack") { + throw new GitHubPrEffectError("EFFECT_TRANSPORT_LOST", false, true, "response lost after remote create"); + } + return ref; + } + + describeWriteCapability(): "unknown" | "unavailable" { + return this.writeCapability; + } +} + +function setupStore() { + const stateDir = mkdtempSync(join(tmpdir(), "devspace-pr-delivery-test-")); + const store = new DurableOperationStore(stateDir); + const clean = () => { + try { + store.close(); + } catch {} + try { + rmSync(stateDir, { recursive: true, force: true }); + } catch {} + }; + return { store, stateDir, clean }; +} + +function authError() { + return new GitHubPrEffectError("EFFECT_AUTH_UNAVAILABLE", false, false, "no credential"); +} + +function networkError() { + return new GitHubPrEffectError("EFFECT_NETWORK_UNAVAILABLE", false, false, "unreachable"); +} + +// ─── G0 preflight ──────────────────────────────────────────────────────────── + +test("preflight reports READY without proving write and without creating", async () => { + const fake = new FakeGitHub(); + const preflight = await preflightPrDelivery(fake, baseRequest()); + assert.equal(preflight.status, "GITHUB_PR_READY"); + assert.equal(preflight.writeProven, false); + assert.equal(preflight.writeState, "UNPROVEN"); + assert.equal(preflight.observedBaseSha, BASE_SHA); + assert.equal(preflight.observedCandidateHeadSha, HEAD_SHA); + assert.equal(fake.counts.creates, 0); +}); + +test("preflight distinguishes auth, network, and authorization failures", async () => { + const authProbe = new FakeGitHub(); + authProbe.repoError = authError(); + assert.equal((await preflightPrDelivery(authProbe, baseRequest())).status, "GITHUB_AUTH_UNAVAILABLE"); + + const networkProbe = new FakeGitHub(); + networkProbe.repoError = networkError(); + assert.equal((await preflightPrDelivery(networkProbe, baseRequest())).status, "GITHUB_NETWORK_UNAVAILABLE"); + + const forbiddenProbe = new FakeGitHub(); + forbiddenProbe.repoForbidden = true; + assert.equal((await preflightPrDelivery(forbiddenProbe, baseRequest())).status, "REPOSITORY_NOT_AUTHORIZED"); +}); + +test("preflight rejects missing branches and drifted identity without writes", async () => { + const missingProbe = new FakeGitHub(); + missingProbe.heads = { main: BASE_SHA }; + assert.equal((await preflightPrDelivery(missingProbe, baseRequest())).status, "CANDIDATE_IDENTITY_INVALID"); + + const driftedProbe = new FakeGitHub(); + driftedProbe.heads = { main: BASE_SHA, "codex/issue-115-probe": OTHER_SHA }; + const drifted = await preflightPrDelivery(driftedProbe, baseRequest()); + assert.equal(drifted.status, "CANDIDATE_IDENTITY_INVALID"); + assert.equal(driftedProbe.counts.creates, 0); +}); + +test("preflight reports unavailable write capability and unavailable reads distinctly", async () => { + const writeProbe = new FakeGitHub(); + writeProbe.writeCapability = "unavailable"; + assert.equal((await preflightPrDelivery(writeProbe, baseRequest())).status, "PR_WRITE_UNAVAILABLE"); + + const readProbe = new FakeGitHub(); + readProbe.listError = new GitHubPrEffectError("EFFECT_FAILED", false, false, "listing exploded"); + assert.equal((await preflightPrDelivery(readProbe, baseRequest())).status, "PR_READ_UNAVAILABLE"); +}); + +// ─── G1/G3 delivery ────────────────────────────────────────────────────────── + +test("delivery creates exactly one PR with exact identity receipt", async () => { + const { store, clean } = setupStore(); + try { + const fake = new FakeGitHub(); + const result = await runPrDeliveryEffect(store, fake, baseRequest()); + assert.equal(result.outcome, "COMPLETED"); + assert.equal(fake.counts.creates, 1); + assert.equal(result.pr?.headSha, HEAD_SHA); + assert.equal(result.pr?.baseSha, BASE_SHA); + assert.equal(result.operation.status, "succeeded"); + } finally { + clean(); + } +}); + +test("delivery refuses on base and candidate drift with zero creates", async () => { + for (const heads of [ + { main: OTHER_SHA, "codex/issue-115-probe": HEAD_SHA }, + { main: BASE_SHA, "codex/issue-115-probe": OTHER_SHA }, + ]) { + const { store, clean } = setupStore(); + try { + const fake = new FakeGitHub(); + fake.heads = heads; + const result = await runPrDeliveryEffect(store, fake, baseRequest({ attemptKey: `drift-${heads.main}` })); + assert.equal(result.outcome, "FAILED"); + assert.equal(fake.counts.creates, 0); + } finally { + clean(); + } + } +}); + +test("lost acknowledgement reconciles to the same PR without a second create", async () => { + const { store, clean } = setupStore(); + try { + const fake = new FakeGitHub(); + fake.createMode = "lost-ack"; + const attempted = await runPrDeliveryEffect(store, fake, baseRequest()); + assert.equal(attempted.outcome, "OUTCOME_UNKNOWN"); + assert.equal(fake.counts.creates, 1); + const reconciled = await reconcilePrDeliveryEffect(store, fake, "/tmp/pr-delivery-scope", "probe-attempt-1"); + assert.equal(reconciled.outcome, "COMPLETED"); + assert.equal(reconciled.pr?.headSha, HEAD_SHA); + assert.equal(fake.counts.creates, 1); + } finally { + clean(); + } +}); + +test("identical replay resolves to the same operation with one create", async () => { + const { store, clean } = setupStore(); + try { + const fake = new FakeGitHub(); + const first = await runPrDeliveryEffect(store, fake, baseRequest()); + const second = await runPrDeliveryEffect(store, fake, baseRequest()); + assert.equal(first.outcome, "COMPLETED"); + assert.equal(second.outcome, "COMPLETED"); + assert.equal(first.operation.operationId, second.operation.operationId); + assert.equal(second.pr?.number, first.pr?.number); + assert.equal(fake.counts.creates, 1); + } finally { + clean(); + } +}); + +test("conflicting replay under the same attemptKey fails closed", async () => { + const variants: Partial[] = [ + { expectedCandidateHeadSha: OTHER_SHA }, + { expectedBaseSha: OTHER_SHA }, + { title: "different title" }, + { body: "different body" }, + { issueNumber: 999 }, + ]; + for (const [index, variant] of variants.entries()) { + const { store, clean } = setupStore(); + try { + const fake = new FakeGitHub(); + await runPrDeliveryEffect(store, fake, baseRequest()); + await assert.rejects( + runPrDeliveryEffect(store, fake, baseRequest({ ...variant, attemptKey: "probe-attempt-1" })), + (error: unknown) => { + assert.equal((error as { code?: string }).code, "OPERATION_REPLAY_CONFLICT"); + return true; + }, + ); + assert.equal(fake.counts.creates, 1, `variant ${index} must not create again`); + } finally { + clean(); + } + } +}); + +test("unrelated and wrong-head PRs are never adopted", async () => { + const { store, clean } = setupStore(); + try { + const fake = new FakeGitHub(); + fake.prs.push({ + repository: "James3014/devspace", + number: 41, + url: "https://github.com/James3014/devspace/pull/41", + headSha: OTHER_SHA, + baseSha: BASE_SHA, + baseBranch: "main", + headBranch: "codex/issue-115-probe", + title: "unrelated same-issue PR", + issueNumber: 115, + state: "open", + }); + const result = await runPrDeliveryEffect(store, fake, baseRequest()); + assert.equal(result.outcome, "COMPLETED"); + assert.notEqual(result.pr?.number, 41); + assert.equal(result.pr?.headSha, HEAD_SHA); + assert.equal(fake.counts.creates, 1); + } finally { + clean(); + } +}); + +test("restart reopens the same operation and reconciles it", async () => { + const stateDir = mkdtempSync(join(tmpdir(), "devspace-pr-delivery-restart-")); + const firstStore = new DurableOperationStore(stateDir); + try { + const fake = new FakeGitHub(); + fake.createMode = "lost-ack"; + const attempted = await runPrDeliveryEffect(firstStore, fake, baseRequest()); + assert.equal(attempted.outcome, "OUTCOME_UNKNOWN"); + } finally { + try { + firstStore.close(); + } catch {} + } + const reopened = new DurableOperationStore(stateDir); + try { + const fake = new FakeGitHub(); + fake.prs.push({ + repository: "James3014/devspace", + number: 77, + url: "https://github.com/James3014/devspace/pull/77", + headSha: HEAD_SHA, + baseSha: BASE_SHA, + baseBranch: "main", + headBranch: "codex/issue-115-probe", + title: "probe delivery", + issueNumber: 115, + state: "open", + }); + const reconciled = await reconcilePrDeliveryEffect(reopened, fake, "/tmp/pr-delivery-scope", "probe-attempt-1"); + assert.equal(reconciled.outcome, "COMPLETED"); + assert.equal(reconciled.pr?.number, 77); + assert.equal(fake.counts.creates, 0); + } finally { + try { + reopened.close(); + } catch {} + try { + rmSync(stateDir, { recursive: true, force: true }); + } catch {} + } +}); + +test("interrupted started operation reconciles after markInterruptedUnknown", async () => { + const stateDir = mkdtempSync(join(tmpdir(), "devspace-pr-delivery-crash-")); + const firstStore = new DurableOperationStore(stateDir); + try { + // A crash between durable creation and finish leaves a started record, + // exactly as a killed controller would. + firstStore.createOrReplay({ + operationId: "op_crash_probe", + attemptKey: "crash-attempt-1", + requestHash: "probe-hash", + kind: "github_pr_delivery", + authorityMode: "OWNER_DIRECT", + scopeRoot: "/tmp/pr-delivery-scope", + request: { + schema: "devspace.github_pr_delivery_request.v1", + repository: "james3014/devspace", + baseBranch: "main", + expectedBaseSha: BASE_SHA, + candidateBranch: "codex/issue-115-probe", + expectedCandidateHeadSha: HEAD_SHA, + title: "probe delivery", + body: "probe body", + issueNumber: 115, + }, + }); + assert.equal(firstStore.markInterruptedUnknown(), 1); + } finally { + try { + firstStore.close(); + } catch {} + } + const reopened = new DurableOperationStore(stateDir); + try { + const crashed = reopened.getByAttempt("/tmp/pr-delivery-scope", "crash-attempt-1"); + assert.equal(crashed?.status, "outcome_unknown"); + assert.equal(crashed?.errorCode, "RECONCILIATION_REQUIRED"); + const fake = new FakeGitHub(); + fake.prs.push({ + repository: "James3014/devspace", + number: 78, + url: "https://github.com/James3014/devspace/pull/78", + headSha: HEAD_SHA, + baseSha: BASE_SHA, + baseBranch: "main", + headBranch: "codex/issue-115-probe", + title: "probe delivery", + issueNumber: 115, + state: "open", + }); + const reconciled = await reconcilePrDeliveryEffect(reopened, fake, "/tmp/pr-delivery-scope", "crash-attempt-1"); + assert.equal(reconciled.outcome, "COMPLETED"); + assert.equal(reconciled.pr?.number, 78); + assert.equal(fake.counts.creates, 0); + } finally { + try { + reopened.close(); + } catch {} + try { + rmSync(stateDir, { recursive: true, force: true }); + } catch {} + } +}); + +test("concurrent delivery attempts create at most one PR", async () => { + const { store, clean } = setupStore(); + try { + const fake = new FakeGitHub(); + const [first, second] = await Promise.all([ + runPrDeliveryEffect(store, fake, baseRequest()), + runPrDeliveryEffect(store, fake, baseRequest()), + ]); + assert.equal(fake.counts.creates, 1); + assert.equal(fake.prs.length, 1); + assert.equal(first.operation.operationId, second.operation.operationId); + assert.ok(["COMPLETED", "OUTCOME_UNKNOWN"].includes(first.outcome)); + assert.ok(["COMPLETED", "OUTCOME_UNKNOWN"].includes(second.outcome)); + assert.ok(first.outcome === "COMPLETED" || second.outcome === "COMPLETED"); + } finally { + clean(); + } +}); + +test("delivery surface grants no merge authority", async () => { + const { store, clean } = setupStore(); + try { + const fake = new FakeGitHub(); + const result = await runPrDeliveryEffect(store, fake, baseRequest()); + assert.equal(result.outcome, "COMPLETED"); + const serialized = JSON.stringify(result); + assert.ok(!/merge/i.test(serialized.replace(/MERGE_READY/g, "")), "no merge state may appear in delivery evidence"); + assert.ok(!("merge" in (result as unknown as Record)), "no merge surface on the result"); + } finally { + clean(); + } +}); + +test("invalid request identity fails closed before any effect", async () => { + const { store, clean } = setupStore(); + try { + const fake = new FakeGitHub(); + await assert.rejects( + runPrDeliveryEffect(store, fake, baseRequest({ expectedCandidateHeadSha: "not-a-sha" })), + (error: unknown) => error instanceof GitHubPrDeliveryError, + ); + assert.equal(fake.counts.creates, 0); + assert.equal(fake.counts.branchReads, 0); + } finally { + clean(); + } +}); diff --git a/src/github-pr-delivery.ts b/src/github-pr-delivery.ts new file mode 100644 index 000000000..0e26c014f --- /dev/null +++ b/src/github-pr-delivery.ts @@ -0,0 +1,645 @@ +/** + * Bounded GitHub PR delivery primitive (#115 G0-G3 core milestone). + * + * This module turns an already accepted, already pushed Candidate into exactly + * one recoverable PR-delivery effect. It is a composable primitive, not a + * workflow engine: it knows nothing about Candidate acceptance, worker + * selection, controller authority, CI acceptance, merge readiness, merge, + * release, or deployment. + * + * Effect boundary: all GitHub contact flows through the injected + * {@link GitHubPrEffectAdapter}. The adapter owns authentication privately; + * credentials must never appear in arguments, results, logs, or receipts. + * Tests must inject a fake adapter; this module never creates a real PR. + * + * Durability: every delivery attempt is bound to one durable operation in + * {@link DurableOperationStore} (kind `github_pr_delivery`) before the first + * possible write. Timeout / disconnect / 502 / lost acknowledgement never + * implies the remote effect did not happen: ambiguous outcomes persist as + * `OUTCOME_UNKNOWN` and must be reconciled against exact remote PR state + * before any retry. Only a proven `CONFIRMED_NO_EFFECT` permits safe retry, + * and retries reuse the same operation identity, never a new attempt. + */ + +import { + DurableOperationStore, + durableOperationId, + hashDurableRequest, + type DurableOperationRecord, +} from "./durable-operations.js"; + +export const GITHUB_PR_DELIVERY_KIND = "github_pr_delivery" as const; +export const GITHUB_PR_DELIVERY_REQUEST_SCHEMA = "devspace.github_pr_delivery_request.v1" as const; +export const GITHUB_PR_DELIVERY_HOST = "github.com" as const; + +const ATTEMPT_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/; +const SHA_PATTERN = /^[0-9a-f]{40}$/i; +const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const BRANCH_PATTERN = /^[^\s~^:?*[]+$/; + +export interface GitHubPrDeliveryRequest { + /** Canonical `owner/repo` identity on github.com. */ + repository: string; + baseBranch: string; + /** Exact expected base SHA (40 hex chars). */ + expectedBaseSha: string; + candidateBranch: string; + /** Exact expected candidate head SHA (40 hex chars). */ + expectedCandidateHeadSha: string; + title: string; + body: string; + issueNumber?: number; + /** Caller-chosen idempotency key, bound to one logical delivery effect. */ + attemptKey: string; + /** + * Absolute local clone path that scopes the durable operation. Attempt + * identity is per-checkout; cross-checkout duplicates are prevented by the + * remote exact-physical-match check, never by title or Issue similarity. + */ + localRepoRoot: string; + workspaceId?: string; +} + +export interface NormalizedPrDeliveryRequest { + repository: string; + baseBranch: string; + expectedBaseSha: string; + candidateBranch: string; + expectedCandidateHeadSha: string; + title: string; + body: string; + issueNumber?: number; + attemptKey: string; + localRepoRoot: string; + workspaceId?: string; +} + +export interface GitHubPrRef { + repository: string; + number: number; + url: string; + headSha: string; + baseSha: string; + baseBranch: string; + headBranch: string; + title: string; + issueNumber?: number; + state: "open" | "closed" | "merged"; +} + +export type GitHubPrPreflightStatus = + | "GITHUB_PR_READY" + | "GITHUB_AUTH_UNAVAILABLE" + | "GITHUB_NETWORK_UNAVAILABLE" + | "PR_WRITE_UNAVAILABLE" + | "PR_READ_UNAVAILABLE" + | "REPOSITORY_NOT_AUTHORIZED" + | "CANDIDATE_IDENTITY_INVALID"; + +export interface GitHubPrDeliveryPreflight { + status: GitHubPrPreflightStatus; + /** Read success never proves write capability; always false here. */ + writeProven: false; + writeState: "UNPROVEN"; + repository: string; + baseBranch: string; + candidateBranch: string; + observedBaseSha?: string; + observedCandidateHeadSha?: string; + matchedExistingPr?: GitHubPrRef; + detail: string; +} + +export type GitHubPrDeliveryOutcome = "COMPLETED" | "FAILED" | "OUTCOME_UNKNOWN"; +export type GitHubPrReconcileOutcome = "COMPLETED" | "CONFIRMED_NO_EFFECT" | "OUTCOME_UNKNOWN" | "FAILED"; + +export interface GitHubPrDeliveryResult { + outcome: GitHubPrDeliveryOutcome; + operation: DurableOperationRecord; + pr?: GitHubPrRef; +} + +export interface GitHubPrReconcileResult { + outcome: GitHubPrReconcileOutcome; + operation: DurableOperationRecord; + pr?: GitHubPrRef; +} + +export type GitHubPrEffectCode = + | "EFFECT_AUTH_UNAVAILABLE" + | "EFFECT_NETWORK_UNAVAILABLE" + | "EFFECT_FORBIDDEN" + | "EFFECT_NOT_FOUND" + | "EFFECT_TRANSPORT_LOST" + | "EFFECT_FAILED"; + +/** + * Host-side transport failure. `effectPossiblyApplied` distinguishes a + * definitive failure (safe to reason about, retry only when proven safe) + * from a lost acknowledgement (must reconcile before any retry). + */ +export class GitHubPrEffectError extends Error { + constructor( + readonly code: GitHubPrEffectCode, + readonly retryable: boolean, + readonly effectPossiblyApplied: boolean, + message: string, + ) { + super(message); + this.name = "GitHubPrEffectError"; + } +} + +export type GitHubPrDeliveryCode = + | "CANDIDATE_IDENTITY_INVALID" + | "REMOTE_IDENTITY_DRIFT" + | "RECONCILIATION_REQUIRED" + | "DELIVERY_OUTCOME_UNKNOWN" + | "OPERATION_NOT_FOUND"; + +export class GitHubPrDeliveryError extends Error { + constructor( + readonly code: GitHubPrDeliveryCode, + message: string, + readonly retryable = false, + readonly operation?: DurableOperationRecord, + ) { + super(message); + this.name = "GitHubPrDeliveryError"; + } +} + +/** + * Bounded host-side GitHub effect boundary. Implementations own credentials + * privately and must never surface them in arguments, results, or errors. + * `describeWriteCapability` is advisory only: `unknown` is the honest + * default because write capability cannot be proven without mutation. + */ +export interface GitHubPrEffectAdapter { + readRepository(): Promise<{ name: string }>; + readBranchHead(branch: string): Promise<{ sha: string } | { missing: true }>; + listOpenPrs(input: { headBranch: string; baseBranch: string }): Promise; + readPr(prNumber: number): Promise; + createPr(input: { + title: string; + body: string; + headBranch: string; + baseBranch: string; + issueNumber?: number; + }): Promise; + describeWriteCapability?(): "unknown" | "unavailable"; +} + +function normalizeRequest(request: GitHubPrDeliveryRequest): NormalizedPrDeliveryRequest { + const fail = (detail: string): never => { + throw new GitHubPrDeliveryError("CANDIDATE_IDENTITY_INVALID", `Invalid PR delivery request: ${detail}.`); + }; + if (!request || typeof request !== "object") fail("request must be an object"); + const repository = request.repository?.trim() ?? ""; + if (!REPOSITORY_PATTERN.test(repository)) fail("repository must be canonical 'owner/repo' on github.com"); + const baseBranch = request.baseBranch?.trim() ?? ""; + const candidateBranch = request.candidateBranch?.trim() ?? ""; + if (!baseBranch || !BRANCH_PATTERN.test(baseBranch)) fail("baseBranch is invalid"); + if (!candidateBranch || !BRANCH_PATTERN.test(candidateBranch)) fail("candidateBranch is invalid"); + if (baseBranch === candidateBranch) fail("base and candidate branches must differ"); + const expectedBaseSha = request.expectedBaseSha?.trim().toLowerCase() ?? ""; + const expectedCandidateHeadSha = request.expectedCandidateHeadSha?.trim().toLowerCase() ?? ""; + if (!SHA_PATTERN.test(expectedBaseSha)) fail("expectedBaseSha must be an exact 40-character SHA"); + if (!SHA_PATTERN.test(expectedCandidateHeadSha)) fail("expectedCandidateHeadSha must be an exact 40-character SHA"); + const title = request.title?.trim() ?? ""; + if (!title) fail("title must be non-empty"); + const body = request.body ?? ""; + if (typeof body !== "string") fail("body must be a string"); + const issueNumber = request.issueNumber; + if (issueNumber !== undefined && (!Number.isInteger(issueNumber) || issueNumber <= 0)) { + fail("issueNumber must be a positive integer when present"); + } + const attemptKey = request.attemptKey ?? ""; + if (!ATTEMPT_KEY_PATTERN.test(attemptKey)) fail("attemptKey must be 1-128 characters of letters, numbers, '.', '_', ':', '/', '-', or '-' prefixed alphanumerically"); + const localRepoRoot = request.localRepoRoot?.trim() ?? ""; + if (!localRepoRoot) fail("localRepoRoot must be a non-empty absolute clone path"); + return { + repository: repository.toLowerCase(), + baseBranch, + expectedBaseSha, + candidateBranch, + expectedCandidateHeadSha, + title, + body, + ...(issueNumber === undefined ? {} : { issueNumber }), + attemptKey, + localRepoRoot, + ...(request.workspaceId === undefined ? {} : { workspaceId: request.workspaceId }), + }; +} + +/** Every field that changes the logical delivery effect participates. */ +function materialRequestHash(request: NormalizedPrDeliveryRequest): string { + return hashDurableRequest({ + repository: request.repository, + baseBranch: request.baseBranch, + expectedBaseSha: request.expectedBaseSha, + candidateBranch: request.candidateBranch, + expectedCandidateHeadSha: request.expectedCandidateHeadSha, + title: request.title, + body: request.body, + issueNumber: request.issueNumber ?? null, + }); +} + +function isEffectError(error: unknown): error is GitHubPrEffectError { + return error instanceof GitHubPrEffectError; +} + +/** Physical Candidate identity: exact SHAs plus exact branch binding. Never title or Issue similarity. */ +function isExactPrMatch(ref: GitHubPrRef, request: NormalizedPrDeliveryRequest): boolean { + return ( + ref.state === "open" && + ref.repository.toLowerCase() === request.repository && + ref.headBranch === request.candidateBranch && + ref.baseBranch === request.baseBranch && + ref.headSha.toLowerCase() === request.expectedCandidateHeadSha && + ref.baseSha.toLowerCase() === request.expectedBaseSha + ); +} + +function receiptToRef(receipt: Record | undefined): GitHubPrRef | undefined { + if (!receipt || typeof receipt !== "object") return undefined; + const record = receipt as Record; + if ( + typeof record.prNumber !== "number" || + typeof record.url !== "string" || + typeof record.headSha !== "string" || + typeof record.baseSha !== "string" || + typeof record.baseBranch !== "string" || + typeof record.headBranch !== "string" || + typeof record.title !== "string" || + typeof record.repository !== "string" + ) { + return undefined; + } + return { + repository: record.repository, + number: record.prNumber, + url: record.url, + headSha: record.headSha, + baseSha: record.baseSha, + baseBranch: record.baseBranch, + headBranch: record.headBranch, + title: record.title, + ...(typeof record.issueNumber === "number" ? { issueNumber: record.issueNumber } : {}), + state: "open", + }; +} + +function prReceipt(request: NormalizedPrDeliveryRequest, ref: GitHubPrRef, matchedExisting: boolean): Record { + return { + schema: GITHUB_PR_DELIVERY_REQUEST_SCHEMA, + repository: request.repository, + prNumber: ref.number, + url: ref.url, + headSha: ref.headSha, + baseSha: ref.baseSha, + baseBranch: ref.baseBranch, + headBranch: ref.headBranch, + title: ref.title, + ...(ref.issueNumber === undefined && request.issueNumber === undefined + ? {} + : { issueNumber: ref.issueNumber ?? request.issueNumber ?? null }), + ...(request.issueNumber === undefined ? {} : { requestedIssueNumber: request.issueNumber }), + matchedExisting, + }; +} + +/** + * Effect-free delivery preflight. Reads only; never calls `createPr`. + * A successful read surface never proves write capability. + */ +export async function preflightPrDelivery( + adapter: GitHubPrEffectAdapter, + request: GitHubPrDeliveryRequest, +): Promise { + const normalized = normalizeRequest(request); + const base = (status: GitHubPrPreflightStatus, detail: string): GitHubPrDeliveryPreflight => ({ + status, + writeProven: false, + writeState: "UNPROVEN", + repository: normalized.repository, + baseBranch: normalized.baseBranch, + candidateBranch: normalized.candidateBranch, + detail, + }); + try { + await adapter.readRepository(); + } catch (error) { + if (isEffectError(error)) { + if (error.code === "EFFECT_AUTH_UNAVAILABLE") return base("GITHUB_AUTH_UNAVAILABLE", "GitHub credentials are unavailable for this delivery path."); + if (error.code === "EFFECT_NETWORK_UNAVAILABLE") return base("GITHUB_NETWORK_UNAVAILABLE", "GitHub is unreachable from this delivery path."); + return base("REPOSITORY_NOT_AUTHORIZED", "Repository is not authorized for this delivery path."); + } + return base("GITHUB_NETWORK_UNAVAILABLE", "Repository probe failed without attributable transport evidence."); + } + let observedBaseSha: string | undefined; + let observedCandidateHeadSha: string | undefined; + for ( + const [branch, expected, label] of [ + [normalized.baseBranch, normalized.expectedBaseSha, "base"], + [normalized.candidateBranch, normalized.expectedCandidateHeadSha, "candidate"], + ] as const + ) { + let head: { sha: string } | { missing: true }; + try { + head = await adapter.readBranchHead(branch); + } catch (error) { + if (isEffectError(error) && error.code === "EFFECT_NETWORK_UNAVAILABLE") { + return base("GITHUB_NETWORK_UNAVAILABLE", `Branch read failed for ${label} branch.`); + } + if (isEffectError(error) && error.code === "EFFECT_AUTH_UNAVAILABLE") { + return base("GITHUB_AUTH_UNAVAILABLE", `Branch read failed for ${label} branch.`); + } + return base("CANDIDATE_IDENTITY_INVALID", `Branch read failed for ${label} branch without attributable evidence.`); + } + if ("missing" in head) { + return base("CANDIDATE_IDENTITY_INVALID", `Exact ${label} branch '${branch}' is missing on the remote.`); + } + if (head.sha.toLowerCase() !== expected) { + return base( + "CANDIDATE_IDENTITY_INVALID", + `Remote ${label} branch '${branch}' already drifted from the expected Candidate identity.`, + ); + } + if (label === "base") observedBaseSha = head.sha; + else observedCandidateHeadSha = head.sha; + } + if (adapter.describeWriteCapability && adapter.describeWriteCapability() === "unavailable") { + return { + ...base("PR_WRITE_UNAVAILABLE", "Write capability is affirmatively unavailable on this delivery path."), + observedBaseSha, + observedCandidateHeadSha, + }; + } + let matchedExistingPr: GitHubPrRef | undefined; + try { + const open = await adapter.listOpenPrs({ headBranch: normalized.candidateBranch, baseBranch: normalized.baseBranch }); + matchedExistingPr = open.find((ref) => isExactPrMatch(ref, normalized)); + } catch (error) { + if (isEffectError(error) && error.code === "EFFECT_NETWORK_UNAVAILABLE") { + return base("GITHUB_NETWORK_UNAVAILABLE", "PR listing failed while checking for an existing exact PR."); + } + if (isEffectError(error) && error.code === "EFFECT_AUTH_UNAVAILABLE") { + return base("GITHUB_AUTH_UNAVAILABLE", "PR listing failed while checking for an existing exact PR."); + } + return base("PR_READ_UNAVAILABLE", "PR listing failed without attributable transport evidence."); + } + return { + ...base( + "GITHUB_PR_READY", + matchedExistingPr + ? "Delivery lane reads succeed; an exact open PR already exists for this Candidate." + : "Delivery lane reads succeed; write capability remains unproven without mutation.", + ), + observedBaseSha, + observedCandidateHeadSha, + ...(matchedExistingPr === undefined ? {} : { matchedExistingPr }), + }; +} + +function failRecord( + store: DurableOperationStore, + operationId: string, + errorCode: string, + errorMessage: string, + retrySafe: boolean, +): DurableOperationRecord { + return store.finish(operationId, { status: "failed", retrySafe, errorCode, errorMessage }); +} + +/** + * Run one bounded PR-delivery effect. Creates the durable operation before + * the first possible write, revalidates exact remote identity immediately + * before writing, and refuses on any drift with zero create calls. + */ +export async function runPrDeliveryEffect( + store: DurableOperationStore, + adapter: GitHubPrEffectAdapter, + request: GitHubPrDeliveryRequest, +): Promise { + const normalized = normalizeRequest(request); + const scopeRoot = normalized.localRepoRoot; + const requestHash = materialRequestHash(normalized); + const operationId = durableOperationId(GITHUB_PR_DELIVERY_KIND, scopeRoot, normalized.attemptKey); + const { record: existing, created } = store.createOrReplay({ + operationId, + attemptKey: normalized.attemptKey, + requestHash, + kind: GITHUB_PR_DELIVERY_KIND, + authorityMode: "OWNER_DIRECT", + scopeRoot, + ...(normalized.workspaceId === undefined ? {} : { workspaceId: normalized.workspaceId }), + request: { + schema: GITHUB_PR_DELIVERY_REQUEST_SCHEMA, + repository: normalized.repository, + baseBranch: normalized.baseBranch, + expectedBaseSha: normalized.expectedBaseSha, + candidateBranch: normalized.candidateBranch, + expectedCandidateHeadSha: normalized.expectedCandidateHeadSha, + title: normalized.title, + body: normalized.body, + ...(normalized.issueNumber === undefined ? {} : { issueNumber: normalized.issueNumber }), + }, + }); + if (!created) { + if (existing.status === "succeeded") { + return { outcome: "COMPLETED", operation: existing, pr: receiptToRef(existing.receipt) }; + } + if (existing.status === "failed") { + if (!existing.retrySafe) return { outcome: "FAILED", operation: existing }; + // Proven CONFIRMED_NO_EFFECT: same operation may safely act again. + } else { + // outcome_unknown / started: caller must reconcile explicitly first. + return { outcome: "OUTCOME_UNKNOWN", operation: existing }; + } + } + + const refuse = (errorCode: string, errorMessage: string, retrySafe: boolean): GitHubPrDeliveryResult => ({ + outcome: "FAILED", + operation: failRecord(store, operationId, errorCode, errorMessage, retrySafe), + }); + + // Fresh remote revalidation immediately before any possible write. + for ( + const [branch, expected, label] of [ + [normalized.baseBranch, normalized.expectedBaseSha, "base"], + [normalized.candidateBranch, normalized.expectedCandidateHeadSha, "candidate"], + ] as const + ) { + let head: { sha: string } | { missing: true }; + try { + head = await adapter.readBranchHead(branch); + } catch (error) { + return refuse( + "PRE_WRITE_TRANSPORT", + `Remote ${label} branch could not be revalidated before write; refusing.`, + true, + ); + } + if ("missing" in head) { + return refuse("REMOTE_IDENTITY_DRIFT", `Remote ${label} branch '${branch}' is missing; refusing before write.`, false); + } + if (head.sha.toLowerCase() !== expected) { + return refuse( + "REMOTE_IDENTITY_DRIFT", + `Remote ${label} branch '${branch}' drifted from the expected Candidate identity; refusing before write.`, + false, + ); + } + } + + // Exact physical match first: never create a duplicate for the same Candidate. + let listed: GitHubPrRef[]; + try { + listed = await adapter.listOpenPrs({ headBranch: normalized.candidateBranch, baseBranch: normalized.baseBranch }); + } catch (error) { + return refuse("PRE_WRITE_TRANSPORT", "Existing-PR check failed before write; refusing.", true); + } + const matched = listed.find((ref) => isExactPrMatch(ref, normalized)); + if (matched) { + const operation = store.finish(operationId, { + status: "succeeded", + retrySafe: false, + receipt: prReceipt(normalized, matched, true), + }); + return { outcome: "COMPLETED", operation, pr: matched }; + } + + let created_ref: GitHubPrRef; + try { + created_ref = await adapter.createPr({ + title: normalized.title, + body: normalized.body, + headBranch: normalized.candidateBranch, + baseBranch: normalized.baseBranch, + ...(normalized.issueNumber === undefined ? {} : { issueNumber: normalized.issueNumber }), + }); + } catch (error) { + if (isEffectError(error) && error.effectPossiblyApplied) { + const operation = store.finish(operationId, { + status: "outcome_unknown", + retrySafe: false, + errorCode: "RECONCILIATION_REQUIRED", + errorMessage: "PR create acknowledgement was lost; the remote effect may have applied. Reconcile before any retry.", + }); + return { outcome: "OUTCOME_UNKNOWN", operation }; + } + const message = isEffectError(error) ? `PR create failed: ${error.code}.` : "PR create failed without attributable evidence."; + return refuse("CREATE_FAILED", message, !isEffectError(error) || !error.effectPossiblyApplied); + } + + // Exact readback of what was created; a mismatch is ambiguity, not success. + let readback: GitHubPrRef; + try { + readback = await adapter.readPr(created_ref.number); + } catch (error) { + const operation = store.finish(operationId, { + status: "outcome_unknown", + retrySafe: false, + errorCode: "RECONCILIATION_REQUIRED", + errorMessage: "PR was created but exact readback failed; reconcile before any retry.", + receipt: prReceipt(normalized, created_ref, false), + }); + return { outcome: "OUTCOME_UNKNOWN", operation, pr: created_ref }; + } + if ( + readback.headSha.toLowerCase() !== normalized.expectedCandidateHeadSha || + readback.baseSha.toLowerCase() !== normalized.expectedBaseSha + ) { + const operation = store.finish(operationId, { + status: "outcome_unknown", + retrySafe: false, + errorCode: "RECONCILIATION_REQUIRED", + errorMessage: "Created PR readback does not match the accepted Candidate identity; reconcile before any retry.", + receipt: prReceipt(normalized, readback, false), + }); + return { outcome: "OUTCOME_UNKNOWN", operation, pr: readback }; + } + const operation = store.finish(operationId, { + status: "succeeded", + retrySafe: false, + receipt: prReceipt(normalized, readback, false), + }); + return { outcome: "COMPLETED", operation, pr: readback }; +} + +/** + * Reconcile one logical delivery operation against exact remote PR state. + * This path never creates a PR. Only a proven CONFIRMED_NO_EFFECT permits + * a later safe retry through {@link runPrDeliveryEffect} on the same attempt. + */ +export async function reconcilePrDeliveryEffect( + store: DurableOperationStore, + adapter: GitHubPrEffectAdapter, + scopeRoot: string, + attemptKey: string, +): Promise { + const existing = store.getByAttempt(scopeRoot, attemptKey); + if (!existing || existing.kind !== GITHUB_PR_DELIVERY_KIND) { + throw new GitHubPrDeliveryError("OPERATION_NOT_FOUND", `No PR delivery operation is bound to attempt '${attemptKey}'.`); + } + if (existing.status === "succeeded") { + return { outcome: "COMPLETED", operation: existing, pr: receiptToRef(existing.receipt) }; + } + if (existing.status === "failed" && !existing.retrySafe) { + return { outcome: "FAILED", operation: existing }; + } + let listed: GitHubPrRef[]; + try { + const request = existing.request as unknown as { + repository?: string; + baseBranch?: string; + expectedBaseSha?: string; + candidateBranch?: string; + expectedCandidateHeadSha?: string; + title?: string; + body?: string; + issueNumber?: number; + }; + const normalized = normalizeRequest({ + repository: typeof request.repository === "string" ? request.repository : "", + baseBranch: typeof request.baseBranch === "string" ? request.baseBranch : "", + expectedBaseSha: typeof request.expectedBaseSha === "string" ? request.expectedBaseSha : "", + candidateBranch: typeof request.candidateBranch === "string" ? request.candidateBranch : "", + expectedCandidateHeadSha: typeof request.expectedCandidateHeadSha === "string" ? request.expectedCandidateHeadSha : "", + title: typeof request.title === "string" && request.title.trim() ? request.title : "reconcile", + body: typeof request.body === "string" ? request.body : "", + ...(typeof request.issueNumber === "number" ? { issueNumber: request.issueNumber } : {}), + attemptKey: existing.attemptKey, + localRepoRoot: existing.scopeRoot, + }); + listed = await adapter.listOpenPrs({ headBranch: normalized.candidateBranch, baseBranch: normalized.baseBranch }); + const matched = listed.find((ref) => isExactPrMatch(ref, normalized)); + if (matched) { + const operation = store.finish(existing.operationId, { + status: "succeeded", + retrySafe: false, + receipt: prReceipt(normalized, matched, true), + }); + return { outcome: "COMPLETED", operation, pr: matched }; + } + const operation = store.finish(existing.operationId, { + status: "failed", + retrySafe: true, + errorCode: "CONFIRMED_NO_EFFECT", + errorMessage: "Reconciliation proved no exact PR exists for this delivery identity; safe retry is permitted on the same attempt.", + }); + return { outcome: "CONFIRMED_NO_EFFECT", operation }; + } catch (error) { + if (error instanceof GitHubPrDeliveryError) throw error; + const operation = store.finish(existing.operationId, { + status: "outcome_unknown", + retrySafe: false, + errorCode: "RECONCILIATION_REQUIRED", + errorMessage: "Reconciliation could not prove remote PR state; the effect remains unknown.", + }); + return { outcome: "OUTCOME_UNKNOWN", operation }; + } +} From e4a9897803d647e4ac299973f54995b5cb9433b6 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Tue, 15 Sep 2026 07:55:22 +0800 Subject: [PATCH 2/2] fix(delivery): bind exact repository identity and serialize same-Candidate creates (#115) --- src/github-pr-delivery.test.ts | 199 +++++++++++++++++++++++++++++++- src/github-pr-delivery.ts | 200 +++++++++++++++++++++++++-------- 2 files changed, 350 insertions(+), 49 deletions(-) diff --git a/src/github-pr-delivery.test.ts b/src/github-pr-delivery.test.ts index bca8e26a5..40b472e3d 100644 --- a/src/github-pr-delivery.test.ts +++ b/src/github-pr-delivery.test.ts @@ -45,15 +45,19 @@ interface FakeCounts { class FakeGitHub implements GitHubPrEffectAdapter { readonly counts: FakeCounts = { repoReads: 0, branchReads: 0, lists: 0, prReads: 0, creates: 0 }; + repoName = "James3014/devspace"; repoError?: GitHubPrEffectError; repoForbidden = false; heads: Record = { main: BASE_SHA, "codex/issue-115-probe": HEAD_SHA }; headError?: GitHubPrEffectError; prs: GitHubPrRef[] = []; listError?: GitHubPrEffectError; - createMode: "ok" | "lost-ack" | "refused" = "ok"; + listGate?: () => Promise; + createMode: "ok" | "lost-ack" | "refused" | "conflict" = "ok"; createError = new GitHubPrEffectError("EFFECT_FAILED", false, false, "create refused"); + conflictRef?: GitHubPrRef; readError?: GitHubPrEffectError; + readPrOverride?: GitHubPrRef; writeCapability: "unknown" | "unavailable" = "unknown"; private nextNumber = 100; @@ -63,7 +67,7 @@ class FakeGitHub implements GitHubPrEffectAdapter { if (this.repoForbidden) { throw new GitHubPrEffectError("EFFECT_FORBIDDEN", false, false, "repository not authorized"); } - return { name: "James3014/devspace" }; + return { name: this.repoName }; } async readBranchHead(branch: string): Promise<{ sha: string } | { missing: true }> { @@ -76,6 +80,7 @@ class FakeGitHub implements GitHubPrEffectAdapter { async listOpenPrs(input: { headBranch: string; baseBranch: string }): Promise { this.counts.lists += 1; + if (this.listGate) await this.listGate(); if (this.listError) throw this.listError; return this.prs.filter( (pr) => pr.state === "open" && pr.headBranch === input.headBranch && pr.baseBranch === input.baseBranch, @@ -85,6 +90,7 @@ class FakeGitHub implements GitHubPrEffectAdapter { async readPr(prNumber: number): Promise { this.counts.prReads += 1; if (this.readError) throw this.readError; + if (this.readPrOverride && this.readPrOverride.number === prNumber) return this.readPrOverride; const found = this.prs.find((pr) => pr.number === prNumber); if (!found) throw new GitHubPrEffectError("EFFECT_NOT_FOUND", false, false, "PR not found"); return found; @@ -93,8 +99,15 @@ class FakeGitHub implements GitHubPrEffectAdapter { async createPr(input: { title: string; body: string; headBranch: string; baseBranch: string; issueNumber?: number }): Promise { this.counts.creates += 1; if (this.createMode === "refused") throw this.createError; + if (this.createMode === "conflict") { + // Models the remote atomic conflict seam: the exact PR already exists, + // so the remote refuses the duplicate instead of persisting one. + const existing = this.conflictRef ?? this.prs.find((pr) => pr.state === "open"); + if (!existing) throw new GitHubPrEffectError("EFFECT_FAILED", false, false, "conflict without referent"); + throw new GitHubPrEffectError("EFFECT_CONFLICT_EXISTING", false, false, "exact PR already exists", existing); + } const ref: GitHubPrRef = { - repository: "James3014/devspace", + repository: this.repoName, number: this.nextNumber++, url: `https://github.com/James3014/devspace/pull/${this.nextNumber - 1}`, headSha: HEAD_SHA, @@ -461,3 +474,183 @@ test("invalid request identity fails closed before any effect", async () => { clean(); } }); + +// ─── Repair A: exact repository binding ────────────────────────────────────── + +test("wrong connected repository fails preflight without writes", async () => { + const fake = new FakeGitHub(); + fake.repoName = "OtherOrg/other-repo"; + const preflight = await preflightPrDelivery(fake, baseRequest()); + assert.equal(preflight.status, "REPOSITORY_NOT_AUTHORIZED"); + assert.equal(fake.counts.creates, 0); +}); + +test("repository drift between preflight and delivery refuses with zero creates", async () => { + const { store, clean } = setupStore(); + try { + const fake = new FakeGitHub(); + const preflight = await preflightPrDelivery(fake, baseRequest()); + assert.equal(preflight.status, "GITHUB_PR_READY"); + fake.repoName = "OtherOrg/other-repo"; + const result = await runPrDeliveryEffect(store, fake, baseRequest()); + assert.equal(result.outcome, "FAILED"); + assert.equal(result.operation.errorCode, "REMOTE_IDENTITY_DRIFT"); + assert.equal(fake.counts.creates, 0); + } finally { + clean(); + } +}); + +test("readback with same SHAs but wrong repository is not COMPLETED", async () => { + const { store, clean } = setupStore(); + try { + const fake = new FakeGitHub(); + // The first created PR always takes number 100 on a fresh fake; observe + // it through a readback that carries identical SHAs for a foreign repo. + fake.readPrOverride = { + repository: "OtherOrg/other-repo", + number: 100, + url: "https://github.com/OtherOrg/other-repo/pull/200", + headSha: HEAD_SHA, + baseSha: BASE_SHA, + baseBranch: "main", + headBranch: "codex/issue-115-probe", + title: "probe delivery", + state: "open", + }; + const result = await runPrDeliveryEffect(store, fake, baseRequest({ attemptKey: "readback-repo-1" })); + assert.equal(result.outcome, "OUTCOME_UNKNOWN"); + assert.equal(result.operation.status, "outcome_unknown"); + assert.equal(fake.counts.creates, 1); + } finally { + clean(); + } +}); + +test("readback with same SHAs but wrong branches is not COMPLETED", async () => { + const { store, clean } = setupStore(); + try { + const fake = new FakeGitHub(); + const created: GitHubPrRef = { + repository: "James3014/devspace", + number: 201, + url: "https://github.com/James3014/devspace/pull/201", + headSha: HEAD_SHA, + baseSha: BASE_SHA, + baseBranch: "main", + headBranch: "codex/issue-115-probe", + title: "probe delivery", + state: "open", + }; + fake.createMode = "ok"; + // Force the readback path to observe a wrong-branch ref by overriding it. + const realCreate = fake.createPr.bind(fake); + fake.createPr = async (input) => { + const ref = await realCreate(input); + fake.readPrOverride = { ...ref, headBranch: "codex/wrong-branch" }; + return ref; + }; + const result = await runPrDeliveryEffect(store, fake, baseRequest({ attemptKey: "readback-branch-1" })); + assert.equal(result.outcome, "OUTCOME_UNKNOWN"); + assert.equal(result.operation.status, "outcome_unknown"); + } finally { + clean(); + } +}); + +test("closed exact-SHA PR is not adopted as the delivered PR", async () => { + const { store, clean } = setupStore(); + try { + const fake = new FakeGitHub(); + fake.prs.push({ + repository: "James3014/devspace", + number: 42, + url: "https://github.com/James3014/devspace/pull/42", + headSha: HEAD_SHA, + baseSha: BASE_SHA, + baseBranch: "main", + headBranch: "codex/issue-115-probe", + title: "probe delivery", + issueNumber: 115, + state: "closed", + }); + const result = await runPrDeliveryEffect(store, fake, baseRequest()); + assert.equal(result.outcome, "COMPLETED"); + assert.notEqual(result.pr?.number, 42); + assert.equal(result.pr?.state, "open"); + assert.equal(result.pr?.headSha, HEAD_SHA); + assert.equal(fake.counts.creates, 1); + } finally { + clean(); + } +}); + +// ─── Repair B: cross-operation concurrent duplicate prevention ─────────────── + +test("forced check-then-check race still persists exactly one remote PR", async () => { + const { store, clean } = setupStore(); + try { + const fake = new FakeGitHub(); + let arrivals = 0; + let release!: () => void; + const released = new Promise((resolve) => { + release = resolve; + }); + // Force both attempts through their initial exact-match checks with none + // visible; disarm before releasing so lock-phase rechecks observe truth. + fake.listGate = async () => { + arrivals += 1; + if (arrivals <= 2) await released; + }; + const attemptA = runPrDeliveryEffect(store, fake, baseRequest({ attemptKey: "race-attempt-A" })); + const attemptB = runPrDeliveryEffect(store, fake, baseRequest({ attemptKey: "race-attempt-B" })); + while (arrivals < 2) await new Promise((resolve) => setTimeout(resolve, 5)); + fake.listGate = undefined; + release(); + const [first, second] = await Promise.all([attemptA, attemptB]); + assert.equal(first.outcome, "COMPLETED"); + assert.equal(second.outcome, "COMPLETED"); + assert.equal(fake.counts.creates, 1); + assert.equal(fake.prs.length, 1); + const openExact = fake.prs.filter((pr) => pr.state === "open" && pr.headSha === HEAD_SHA && pr.baseSha === BASE_SHA); + assert.equal(openExact.length, 1); + assert.equal(first.pr?.number, second.pr?.number); + assert.notEqual(first.operation.operationId, second.operation.operationId); + } finally { + clean(); + } +}); + +test("remote atomic conflict resolves the loser to the same PR", async () => { + const { store, clean } = setupStore(); + try { + const fake = new FakeGitHub(); + const winner: GitHubPrRef = { + repository: "James3014/devspace", + number: 300, + url: "https://github.com/James3014/devspace/pull/300", + headSha: HEAD_SHA, + baseSha: BASE_SHA, + baseBranch: "main", + headBranch: "codex/issue-115-probe", + title: "probe delivery", + issueNumber: 115, + state: "open", + }; + // Stale replica reads hide the winner; the authoritative create refuses + // the duplicate exactly as GitHub answers 422 for an existing head PR. + fake.prs.push(winner); + fake.listOpenPrs = async () => { + fake.counts.lists += 1; + return []; + }; + fake.createMode = "conflict"; + fake.conflictRef = winner; + const result = await runPrDeliveryEffect(store, fake, baseRequest({ attemptKey: "conflict-loser-1" })); + assert.equal(result.outcome, "COMPLETED"); + assert.equal(result.pr?.number, 300); + assert.equal(fake.prs.length, 1); + } finally { + clean(); + } +}); diff --git a/src/github-pr-delivery.ts b/src/github-pr-delivery.ts index 0e26c014f..6b0a68e12 100644 --- a/src/github-pr-delivery.ts +++ b/src/github-pr-delivery.ts @@ -131,12 +131,16 @@ export type GitHubPrEffectCode = | "EFFECT_FORBIDDEN" | "EFFECT_NOT_FOUND" | "EFFECT_TRANSPORT_LOST" + | "EFFECT_CONFLICT_EXISTING" | "EFFECT_FAILED"; /** * Host-side transport failure. `effectPossiblyApplied` distinguishes a * definitive failure (safe to reason about, retry only when proven safe) * from a lost acknowledgement (must reconcile before any retry). + * `EFFECT_CONFLICT_EXISTING` is definitive remote state, not ambiguity: + * the remote refused a duplicate create because the exact PR already + * exists (GitHub answers such creates with 422). It carries that PR. */ export class GitHubPrEffectError extends Error { constructor( @@ -144,6 +148,7 @@ export class GitHubPrEffectError extends Error { readonly retryable: boolean, readonly effectPossiblyApplied: boolean, message: string, + readonly existingPr?: GitHubPrRef, ) { super(message); this.name = "GitHubPrEffectError"; @@ -251,6 +256,47 @@ function isEffectError(error: unknown): error is GitHubPrEffectError { return error instanceof GitHubPrEffectError; } +function isSameRepository(observed: string, expected: string): boolean { + return observed.trim().toLowerCase() === expected.trim().toLowerCase(); +} + +/** Physical Candidate identity key for in-process delivery serialization. */ +function candidateLockKey(request: NormalizedPrDeliveryRequest): string { + return [ + request.repository, + request.baseBranch, + request.expectedBaseSha, + request.candidateBranch, + request.expectedCandidateHeadSha, + ].join("\0"); +} + +const candidateLocks = new Map>(); + +/** + * Serialize the recheck-then-create window per physical Candidate within + * this process. The initial fast-path check stays outside the lock; the + * atomic commit (re-list, adopt-or-create) runs inside, so two concurrent + * same-Candidate attempts can never both pass an empty check into create. + * Cross-process races fall back to the remote atomic conflict seam + * (`EFFECT_CONFLICT_EXISTING`). Entries are removed on release. + */ +async function withCandidateLock(key: string, fn: () => Promise): Promise { + const prior = candidateLocks.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + candidateLocks.set(key, current); + await prior; + try { + return await fn(); + } finally { + release(); + if (candidateLocks.get(key) === current) candidateLocks.delete(key); + } +} + /** Physical Candidate identity: exact SHAs plus exact branch binding. Never title or Issue similarity. */ function isExactPrMatch(ref: GitHubPrRef, request: NormalizedPrDeliveryRequest): boolean { return ( @@ -329,8 +375,9 @@ export async function preflightPrDelivery( candidateBranch: normalized.candidateBranch, detail, }); + let connectedRepository: string; try { - await adapter.readRepository(); + connectedRepository = (await adapter.readRepository()).name; } catch (error) { if (isEffectError(error)) { if (error.code === "EFFECT_AUTH_UNAVAILABLE") return base("GITHUB_AUTH_UNAVAILABLE", "GitHub credentials are unavailable for this delivery path."); @@ -339,6 +386,12 @@ export async function preflightPrDelivery( } return base("GITHUB_NETWORK_UNAVAILABLE", "Repository probe failed without attributable transport evidence."); } + if (!isSameRepository(connectedRepository, normalized.repository)) { + return base( + "REPOSITORY_NOT_AUTHORIZED", + "Connected repository does not match the requested delivery repository.", + ); + } let observedBaseSha: string | undefined; let observedCandidateHeadSha: string | undefined; for ( @@ -466,6 +519,25 @@ export async function runPrDeliveryEffect( operation: failRecord(store, operationId, errorCode, errorMessage, retrySafe), }); + // Physical repository binding immediately before any possible write: + // the connected repository must be the requested one, not merely reachable. + try { + const connected = await adapter.readRepository(); + if (!isSameRepository(connected.name, normalized.repository)) { + return refuse( + "REMOTE_IDENTITY_DRIFT", + "Connected repository does not match the requested delivery repository; refusing before write.", + false, + ); + } + } catch (error) { + return refuse( + "PRE_WRITE_TRANSPORT", + "Repository binding could not be revalidated before write; refusing.", + true, + ); + } + // Fresh remote revalidation immediately before any possible write. for ( const [branch, expected, label] of [ @@ -512,62 +584,98 @@ export async function runPrDeliveryEffect( return { outcome: "COMPLETED", operation, pr: matched }; } - let created_ref: GitHubPrRef; - try { - created_ref = await adapter.createPr({ - title: normalized.title, - body: normalized.body, - headBranch: normalized.candidateBranch, - baseBranch: normalized.baseBranch, - ...(normalized.issueNumber === undefined ? {} : { issueNumber: normalized.issueNumber }), - }); - } catch (error) { - if (isEffectError(error) && error.effectPossiblyApplied) { + // Atomic commit window, serialized per physical Candidate identity: re-list + // under the lock, adopt an exact match without creating, and only then + // create. Concurrent same-Candidate attempts therefore converge on one + // remote PR instead of racing two check-before-create windows. Cross-process + // races fall back to the remote atomic conflict seam below. + return withCandidateLock(candidateLockKey(normalized), async (): Promise => { + let relisted: GitHubPrRef[]; + try { + relisted = await adapter.listOpenPrs({ headBranch: normalized.candidateBranch, baseBranch: normalized.baseBranch }); + } catch (error) { + return refuse("PRE_WRITE_TRANSPORT", "Existing-PR recheck failed before write; refusing.", true); + } + const rematched = relisted.find((ref) => isExactPrMatch(ref, normalized)); + if (rematched) { + const operation = store.finish(operationId, { + status: "succeeded", + retrySafe: false, + receipt: prReceipt(normalized, rematched, true), + }); + return { outcome: "COMPLETED", operation, pr: rematched }; + } + + let created_ref: GitHubPrRef; + try { + created_ref = await adapter.createPr({ + title: normalized.title, + body: normalized.body, + headBranch: normalized.candidateBranch, + baseBranch: normalized.baseBranch, + ...(normalized.issueNumber === undefined ? {} : { issueNumber: normalized.issueNumber }), + }); + } catch (error) { + if ( + isEffectError(error) && + error.code === "EFFECT_CONFLICT_EXISTING" && + error.existingPr !== undefined && + isExactPrMatch(error.existingPr, normalized) + ) { + // Definitive remote state, not ambiguity: the atomic create refused + // because the exact PR already exists. Adopt it without creating. + const operation = store.finish(operationId, { + status: "succeeded", + retrySafe: false, + receipt: prReceipt(normalized, error.existingPr, true), + }); + return { outcome: "COMPLETED", operation, pr: error.existingPr }; + } + if (isEffectError(error) && error.effectPossiblyApplied) { + const operation = store.finish(operationId, { + status: "outcome_unknown", + retrySafe: false, + errorCode: "RECONCILIATION_REQUIRED", + errorMessage: "PR create acknowledgement was lost; the remote effect may have applied. Reconcile before any retry.", + }); + return { outcome: "OUTCOME_UNKNOWN", operation }; + } + const message = isEffectError(error) ? `PR create failed: ${error.code}.` : "PR create failed without attributable evidence."; + return refuse("CREATE_FAILED", message, !isEffectError(error) || !error.effectPossiblyApplied); + } + + // Exact readback of the full physical Candidate identity; a mismatch on + // repository, branches, or SHAs is ambiguity, not success. + let readback: GitHubPrRef; + try { + readback = await adapter.readPr(created_ref.number); + } catch (error) { const operation = store.finish(operationId, { status: "outcome_unknown", retrySafe: false, errorCode: "RECONCILIATION_REQUIRED", - errorMessage: "PR create acknowledgement was lost; the remote effect may have applied. Reconcile before any retry.", + errorMessage: "PR was created but exact readback failed; reconcile before any retry.", + receipt: prReceipt(normalized, created_ref, false), }); - return { outcome: "OUTCOME_UNKNOWN", operation }; + return { outcome: "OUTCOME_UNKNOWN", operation, pr: created_ref }; + } + if (!isExactPrMatch(readback, normalized)) { + const operation = store.finish(operationId, { + status: "outcome_unknown", + retrySafe: false, + errorCode: "RECONCILIATION_REQUIRED", + errorMessage: "Created PR readback does not match the accepted Candidate identity; reconcile before any retry.", + receipt: prReceipt(normalized, readback, false), + }); + return { outcome: "OUTCOME_UNKNOWN", operation, pr: readback }; } - const message = isEffectError(error) ? `PR create failed: ${error.code}.` : "PR create failed without attributable evidence."; - return refuse("CREATE_FAILED", message, !isEffectError(error) || !error.effectPossiblyApplied); - } - - // Exact readback of what was created; a mismatch is ambiguity, not success. - let readback: GitHubPrRef; - try { - readback = await adapter.readPr(created_ref.number); - } catch (error) { - const operation = store.finish(operationId, { - status: "outcome_unknown", - retrySafe: false, - errorCode: "RECONCILIATION_REQUIRED", - errorMessage: "PR was created but exact readback failed; reconcile before any retry.", - receipt: prReceipt(normalized, created_ref, false), - }); - return { outcome: "OUTCOME_UNKNOWN", operation, pr: created_ref }; - } - if ( - readback.headSha.toLowerCase() !== normalized.expectedCandidateHeadSha || - readback.baseSha.toLowerCase() !== normalized.expectedBaseSha - ) { const operation = store.finish(operationId, { - status: "outcome_unknown", + status: "succeeded", retrySafe: false, - errorCode: "RECONCILIATION_REQUIRED", - errorMessage: "Created PR readback does not match the accepted Candidate identity; reconcile before any retry.", receipt: prReceipt(normalized, readback, false), }); - return { outcome: "OUTCOME_UNKNOWN", operation, pr: readback }; - } - const operation = store.finish(operationId, { - status: "succeeded", - retrySafe: false, - receipt: prReceipt(normalized, readback, false), + return { outcome: "COMPLETED", operation, pr: readback }; }); - return { outcome: "COMPLETED", operation, pr: readback }; } /**