specrails-desktop 2.55.0 → 2.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/dist/args.js +80 -0
- package/cli/dist/desktop-client.js +125 -0
- package/cli/dist/desktop-control.js +339 -0
- package/cli/dist/format.js +45 -0
- package/cli/dist/help.js +68 -0
- package/cli/dist/output.js +47 -0
- package/cli/dist/run-command.js +340 -0
- package/cli/dist/specrails-desktop.js +41 -1107
- package/cli/dist/status.js +111 -0
- package/client/dist/assets/{ActivityFeedPage-ILXMZXcb.js → ActivityFeedPage-DdMNmVBk.js} +1 -1
- package/client/dist/assets/{AgentBrowserCapture-UcsupCy7.js → AgentBrowserCapture-CeerUf--.js} +1 -1
- package/client/dist/assets/{AgentModeAnalyticsPane-CQuxYh8B.js → AgentModeAnalyticsPane-BjGb-8rh.js} +2 -2
- package/client/dist/assets/{AgentModeCodePane-zPWbW7ei.js → AgentModeCodePane-8XRkQEo_.js} +2 -2
- package/client/dist/assets/{AgentModeJobsPane-CInByIdl.js → AgentModeJobsPane-BN3dgJOA.js} +1 -1
- package/client/dist/assets/{AgentsPage-BWvgxaBv.js → AgentsPage-DfHWgP_x.js} +1 -1
- package/client/dist/assets/{AnalyticsPage-DP3jakv-.js → AnalyticsPage-CyfhYiBn.js} +1 -1
- package/client/dist/assets/{CodePage-BKXiwE5a.js → CodePage-C2Nf-Q5W.js} +1 -1
- package/client/dist/assets/{DesktopAnalyticsPage-DryJOaMz.js → DesktopAnalyticsPage-tc0p4jJW.js} +1 -1
- package/client/dist/assets/{DocsDialog-BsCG9ksG.js → DocsDialog-Br3bczCp.js} +1 -1
- package/client/dist/assets/{DocsPage-Cc-GeKYl.js → DocsPage-BcDr2cBm.js} +1 -1
- package/client/dist/assets/{ExportDropdown-ClahJdDn.js → ExportDropdown-BVGKEgkd.js} +1 -1
- package/client/dist/assets/{InteractiveJobComposer-DfLF6N0f.js → InteractiveJobComposer-DFPpdg2D.js} +1 -1
- package/client/dist/assets/{JobDetailModal-DHeur9YK.js → JobDetailModal-BdOv-l-M.js} +1 -1
- package/client/dist/assets/{JobDetailPage-CraDKzeG.js → JobDetailPage-vBbNeRuz.js} +1 -1
- package/client/dist/assets/{JobsPage-CcbuSwj7.js → JobsPage-CJCtPhIz.js} +1 -1
- package/client/dist/assets/{LoopBuilderPage-BKtNp494.js → LoopBuilderPage-BemvUOxy.js} +1 -1
- package/client/dist/assets/{LoopPreviewModal-DbZd9isy.js → LoopPreviewModal-C6hNAeYN.js} +1 -1
- package/client/dist/assets/{LoopsPage-DEg6sIkG.js → LoopsPage-BZr0quvh.js} +1 -1
- package/client/dist/assets/{MinimizedChatsContext-BGtEjWsc.js → MinimizedChatsContext-D3QzgV0B.js} +1 -1
- package/client/dist/assets/{PluginsPage-BVqSsPzL.js → PluginsPage-DOpTV75p.js} +1 -1
- package/client/dist/assets/{ProjectSettingsDialog-CAjLgJAZ.js → ProjectSettingsDialog-Dkwrn0z1.js} +1 -1
- package/client/dist/assets/{RepositoryDeliveries-DrcrxLe0.js → RepositoryDeliveries-Du2UCf1a.js} +1 -1
- package/client/dist/assets/{RepositoryScopeSelector-CAGY3xW6.js → RepositoryScopeSelector-hPGZNRBm.js} +1 -1
- package/client/dist/assets/ReviewPacketPage-DcOq-UTA.js +4 -0
- package/client/dist/assets/{TemplatePreviewModal-Bqs-9fhn.js → TemplatePreviewModal-Cejcquah.js} +1 -1
- package/client/dist/assets/{TicketDetailModalContext-C0jQ5yOk.js → TicketDetailModalContext-DAK-NoK6.js} +1 -1
- package/client/dist/assets/{Trans-DtWlCJN9.js → Trans-DEbgkuAY.js} +1 -1
- package/client/dist/assets/{dashboard-CTfEtkqX.js → dashboard-B9uWNGO2.js} +1 -1
- package/client/dist/assets/{dashboard-DMklepmz.js → dashboard-BIwmO9wc.js} +1 -1
- package/client/dist/assets/{dashboard-DBAP2_SQ.js → dashboard-BV4vOZkR.js} +1 -1
- package/client/dist/assets/{dashboard-D40HP2ks.js → dashboard-Bf6MPVsM.js} +1 -1
- package/client/dist/assets/{dashboard-Csf1yGSN.js → dashboard-C8klWVvZ.js} +1 -1
- package/client/dist/assets/{dashboard-C29Qwa-L.js → dashboard-D1ORhPF0.js} +1 -1
- package/client/dist/assets/{dashboard-Cr4D8oMn.js → dashboard-DSubvs9x.js} +1 -1
- package/client/dist/assets/{dashboard-CSJzf5OT.js → dashboard-px_vlRx_.js} +1 -1
- package/client/dist/assets/{formatDistanceToNow-DYmvOA3R.js → formatDistanceToNow-Cc2u5FwQ.js} +1 -1
- package/client/dist/assets/{getTimezoneOffsetInMilliseconds-BAhW5E6V.js → getTimezoneOffsetInMilliseconds-BHm2U-w1.js} +1 -1
- package/client/dist/assets/index-C51x61Bz.css +2 -0
- package/client/dist/assets/{index-BfAZg5DG.js → index-Cxb288Rj.js} +29 -29
- package/client/dist/assets/{jira-api-B27Za5FP.js → jira-api-DrGxiD9p.js} +1 -1
- package/client/dist/assets/packet-8nn9RsU7.js +1 -0
- package/client/dist/assets/packet-B0H15elv.js +1 -0
- package/client/dist/assets/packet-BQvBgrwh.js +1 -0
- package/client/dist/assets/packet-BR7mO0-a.js +1 -0
- package/client/dist/assets/packet-BgcQ6q_K.js +1 -0
- package/client/dist/assets/packet-D2s92Jnn.js +1 -0
- package/client/dist/assets/packet-YbtcD1HS.js +1 -0
- package/client/dist/assets/packet-c0Qj_9eX.js +1 -0
- package/client/dist/assets/{project-repositories-DixIZHTA.js → project-repositories-9Wzs0coQ.js} +1 -1
- package/client/dist/assets/{spending-BGmZBX5B.js → spending-T5GM7wpn.js} +1 -1
- package/client/dist/assets/{useDesktop-CPB4cnwI.js → useDesktop-QogiN0zR.js} +1 -1
- package/client/dist/assets/{useRuntimeRuns-DEQ9HdAM.js → useRuntimeRuns-Dqmz9fS9.js} +1 -1
- package/client/dist/assets/{useSharedWebSocket-DcPRKR8R.js → useSharedWebSocket-Dz-HI6De.js} +2 -2
- package/client/dist/index.html +17 -17
- package/docs/codex.md +5 -5
- package/docs/customizing.md +1 -1
- package/docs/gemini-cli-provider-study.md +14 -14
- package/docs/gemini-core-support-evaluation.md +1 -1
- package/docs/gemini.md +5 -5
- package/docs/internals/README.md +6 -2
- package/docs/internals/adding-a-provider.md +22 -15
- package/docs/internals/agent-runtime-framework-evaluation.md +8 -8
- package/docs/internals/api-reference.md +2 -2
- package/docs/internals/architecture.md +9 -9
- package/docs/internals/browser-capture-performance.md +3 -3
- package/docs/internals/code-explorer-story.md +2 -2
- package/docs/internals/companion-rails-as-loops-contract.md +2 -2
- package/docs/internals/core-runtime-updates.md +5 -0
- package/docs/internals/embedded-browser-native-webview-evaluation.md +5 -5
- package/docs/internals/gemini-mcp-registration.md +2 -2
- package/docs/internals/global-artifacts-alignment-contract.md +1 -1
- package/docs/internals/global-artifacts-relocation-evaluation.md +1 -1
- package/docs/internals/interactive-jobs.md +8 -8
- package/docs/internals/legacy-implementation-notes.md +722 -0
- package/docs/internals/loop-step-log-explorer.md +8 -8
- package/docs/internals/mission-rail-cards.md +14 -14
- package/docs/internals/modular-architecture.md +107 -0
- package/docs/internals/profiles.md +1 -1
- package/docs/internals/programmatic-agent-runtime.md +66 -0
- package/docs/internals/project-builder.md +18 -18
- package/docs/internals/review-packet.md +8 -8
- package/docs/internals/safe-pr-review-flow.md +11 -11
- package/docs/internals/source-architecture.md +131 -0
- package/docs/internals/source-map.md +1356 -0
- package/docs/internals/spec-addenda.md +65 -9
- package/docs/internals/switch-to-agent-mode-design.md +11 -11
- package/docs/tracking-cost.md +1 -1
- package/package.json +5 -2
- package/server/dist/agent-mcp-config.js +1 -1
- package/server/dist/attachment-manager.js +1 -1
- package/server/dist/build-dirs.js +2 -2
- package/server/dist/config.js +0 -3
- package/server/dist/core-completion.js +1 -1
- package/server/dist/core-execution.js +1 -1
- package/server/dist/db/activity.js +39 -0
- package/server/dist/db/connection.js +41 -0
- package/server/dist/db/conversations.js +48 -0
- package/server/dist/db/jobs.js +251 -0
- package/server/dist/db/migrations.js +1521 -0
- package/server/dist/db/proposals.js +50 -0
- package/server/dist/db/settings.js +15 -0
- package/server/dist/db/stats.js +61 -0
- package/server/dist/db/telemetry.js +56 -0
- package/server/dist/db/templates.js +37 -0
- package/server/dist/db/types.js +2 -0
- package/server/dist/db.js +66 -2224
- package/server/dist/desktop-db.js +3 -3
- package/server/dist/desktop-router.js +8 -8
- package/server/dist/git-diagnostics.js +1 -1
- package/server/dist/index.js +11 -11
- package/server/dist/integration-branch.js +3 -14
- package/server/dist/jira/jira-issue-fields.js +0 -1
- package/server/dist/jira/jira-materializer.js +1 -1
- package/server/dist/jira/jira-sync-manager.js +1 -1
- package/server/dist/mcp/guide.js +16 -2
- package/server/dist/mcp/mcp-admin-router.js +1 -1
- package/server/dist/mcp/mcp-server.js +1 -1
- package/server/dist/mcp/tools/catalog.js +2 -0
- package/server/dist/mcp/tools/jobs.js +23 -5
- package/server/dist/mcp/tools/rails.js +2 -2
- package/server/dist/mcp/tools/recovery.js +35 -0
- package/server/dist/mcp/tools/specs.js +2 -2
- package/server/dist/mcp/tools/support.js +4 -1
- package/server/dist/mcp/tools/types.js +3 -3
- package/server/dist/mobile/mobile-gateway.js +1 -1
- package/server/dist/mobile/mobile-missions.js +1 -1
- package/server/dist/{desktop-analytics.js → modules/accounting/runtime/desktop-analytics.js} +2 -2
- package/server/dist/{pricing.js → modules/accounting/runtime/pricing.js} +10 -3
- package/server/dist/{result-event.js → modules/accounting/runtime/result-event.js} +3 -3
- package/server/dist/{spending.js → modules/accounting/runtime/spending.js} +0 -3
- package/server/dist/{telemetry-compactor.js → modules/accounting/runtime/telemetry-compactor.js} +1 -1
- package/server/dist/{telemetry-receiver.js → modules/accounting/runtime/telemetry-receiver.js} +2 -2
- package/server/dist/{agent-runtime-bridge.js → modules/agent-runtime/runtime/agent-runtime-bridge.js} +2 -2
- package/server/dist/{agent-runtime-controls-router.js → modules/agent-runtime/runtime/agent-runtime-controls-router.js} +16 -0
- package/server/dist/{agent-runtime-controls.js → modules/agent-runtime/runtime/agent-runtime-controls.js} +100 -8
- package/server/dist/{agent-runtime-effective-config.js → modules/agent-runtime/runtime/agent-runtime-effective-config.js} +1 -1
- package/server/dist/{agent-runtime-loader.js → modules/agent-runtime/runtime/agent-runtime-loader.js} +3 -3
- package/server/dist/{agent-runtime-paths.js → modules/agent-runtime/runtime/agent-runtime-paths.js} +1 -1
- package/server/dist/modules/agent-runtime/runtime/agent-runtime-recovery.js +53 -0
- package/server/dist/{agent-runtime-settings-router.js → modules/agent-runtime/runtime/agent-runtime-settings-router.js} +2 -2
- package/server/dist/{agent-runtime-settings.js → modules/agent-runtime/runtime/agent-runtime-settings.js} +3 -3
- package/server/dist/{agent-runtime-settlement.js → modules/agent-runtime/runtime/agent-runtime-settlement.js} +6 -6
- package/server/dist/{agent-defaults.js → modules/agents/runtime/agent-defaults.js} +3 -3
- package/server/dist/{agent-generator.js → modules/agents/runtime/agent-generator.js} +7 -7
- package/server/dist/{agent-refine-manager.js → modules/agents/runtime/agent-refine-manager.js} +5 -5
- package/server/dist/{agent-store.js → modules/agents/runtime/agent-store.js} +1 -1
- package/server/dist/{profile-manager.js → modules/agents/runtime/profile-manager.js} +2 -2
- package/server/dist/{profiles-router.js → modules/agents/runtime/profiles-router.js} +5 -5
- package/server/dist/{browser-capture-manager.js → modules/browser/runtime/browser-capture-manager.js} +2 -4
- package/server/dist/{browser-playwright.js → modules/browser/runtime/browser-playwright.js} +1 -1
- package/server/dist/{blueprint-chat-manager.js → modules/builder/runtime/blueprint-chat-manager.js} +7 -7
- package/server/dist/{blueprint-commit.js → modules/builder/runtime/blueprint-commit.js} +8 -8
- package/server/dist/{blueprint-draft-parser.js → modules/builder/runtime/blueprint-draft-parser.js} +1 -1
- package/server/dist/{blueprint-generation.js → modules/builder/runtime/blueprint-generation.js} +1 -1
- package/server/dist/{blueprint-operator-prompt.js → modules/builder/runtime/blueprint-operator-prompt.js} +1 -1
- package/server/dist/{blueprint-router.js → modules/builder/runtime/blueprint-router.js} +3 -3
- package/server/dist/{blueprint-spec-quality.js → modules/builder/runtime/blueprint-spec-quality.js} +1 -1
- package/server/dist/{milestone-chain.js → modules/builder/runtime/milestone-chain.js} +1 -1
- package/server/dist/{milestone-progress.js → modules/builder/runtime/milestone-progress.js} +6 -6
- package/server/dist/{code-activity.js → modules/code/runtime/code-activity.js} +1 -1
- package/server/dist/{code-explorer-router.js → modules/code/runtime/code-explorer-router.js} +5 -5
- package/server/dist/{file-provenance.js → modules/code/runtime/file-provenance.js} +1 -1
- package/server/dist/{file-story-manager.js → modules/code/runtime/file-story-manager.js} +2 -2
- package/server/dist/{file-story.js → modules/code/runtime/file-story.js} +2 -2
- package/server/dist/{file-summary-generator.js → modules/code/runtime/file-summary-generator.js} +3 -3
- package/server/dist/{file-summary-manager.js → modules/code/runtime/file-summary-manager.js} +3 -3
- package/server/dist/{project-code-discovery.js → modules/code/runtime/project-code-discovery.js} +2 -2
- package/server/dist/modules/conversations/domain/draft-stream.js +91 -0
- package/server/dist/modules/conversations/domain/recovery-context.js +101 -0
- package/server/dist/modules/conversations/index.js +10 -0
- package/server/dist/{chat-manager.js → modules/conversations/runtime/chat-manager.js} +32 -216
- package/server/dist/{context-budget.js → modules/conversations/runtime/context-budget.js} +1 -1
- package/server/dist/{explore-cwd-manager.js → modules/conversations/runtime/explore-cwd-manager.js} +1 -1
- package/server/dist/{explore-smash.js → modules/conversations/runtime/explore-smash.js} +1 -1
- package/server/dist/{explore-stdin-session.js → modules/conversations/runtime/explore-stdin-session.js} +1 -1
- package/server/dist/modules/delivery/adapters/decisions/chains.js +138 -0
- package/server/dist/modules/delivery/adapters/decisions/contracts.js +2 -0
- package/server/dist/modules/delivery/adapters/decisions/create.js +234 -0
- package/server/dist/modules/delivery/adapters/decisions/discard.js +251 -0
- package/server/dist/modules/delivery/adapters/decisions/dispatch.js +107 -0
- package/server/dist/modules/delivery/adapters/decisions/evidence.js +205 -0
- package/server/dist/modules/delivery/adapters/decisions/merge-local.js +338 -0
- package/server/dist/modules/delivery/adapters/decisions/metadata.js +109 -0
- package/server/dist/modules/delivery/adapters/decisions/observations.js +260 -0
- package/server/dist/modules/delivery/adapters/decisions/ownership.js +83 -0
- package/server/dist/modules/delivery/adapters/decisions/publish.js +242 -0
- package/server/dist/modules/delivery/adapters/decisions/recovery.js +425 -0
- package/server/dist/modules/delivery/adapters/decisions/retry.js +112 -0
- package/server/dist/modules/delivery/adapters/decisions/transitions.js +94 -0
- package/server/dist/modules/delivery/domain/decision-policy.js +50 -0
- package/server/dist/modules/delivery/domain/state.js +2 -0
- package/server/dist/modules/delivery/index.js +7 -0
- package/server/dist/{active-pr-continuation.js → modules/delivery/runtime/active-pr-continuation.js} +2 -2
- package/server/dist/{delivery-evidence.js → modules/delivery/runtime/delivery-evidence.js} +3 -3
- package/server/dist/{multi-repo-bases.js → modules/delivery/runtime/multi-repo-bases.js} +1 -1
- package/server/dist/{multi-repo-checkout.js → modules/delivery/runtime/multi-repo-checkout.js} +4 -4
- package/server/dist/{multi-repo-delivery.js → modules/delivery/runtime/multi-repo-delivery.js} +4 -4
- package/server/dist/{multi-repo-execution.js → modules/delivery/runtime/multi-repo-execution.js} +8 -8
- package/server/dist/{pr-body.js → modules/delivery/runtime/pr-body.js} +1 -1
- package/server/dist/{pr-follow-up-scope.js → modules/delivery/runtime/pr-follow-up-scope.js} +2 -2
- package/server/dist/{pr-follow-up.js → modules/delivery/runtime/pr-follow-up.js} +1 -1
- package/server/dist/{pr-naming.js → modules/delivery/runtime/pr-naming.js} +1 -1
- package/server/dist/{pr-publisher.js → modules/delivery/runtime/pr-publisher.js} +2 -2
- package/server/dist/{rail-isolated-launch.js → modules/delivery/runtime/rail-isolated-launch.js} +29 -27
- package/server/dist/{rail-launch-parser.js → modules/delivery/runtime/rail-launch-parser.js} +2 -2
- package/server/dist/{rail-merge-orchestrator.js → modules/delivery/runtime/rail-merge-orchestrator.js} +5 -5
- package/server/dist/modules/delivery/runtime/rail-pr-decision.js +12 -0
- package/server/dist/{rail-pr-delivery.js → modules/delivery/runtime/rail-pr-delivery.js} +1 -1
- package/server/dist/{rail-pr-store.js → modules/delivery/runtime/rail-pr-store.js} +2 -2
- package/server/dist/{rail-pr-ticket-effects.js → modules/delivery/runtime/rail-pr-ticket-effects.js} +3 -3
- package/server/dist/{rail-worktree-release.js → modules/delivery/runtime/rail-worktree-release.js} +4 -4
- package/server/dist/{rails-router.js → modules/delivery/runtime/rails-router.js} +66 -67
- package/server/dist/{review-packet.js → modules/delivery/runtime/review-packet.js} +3 -3
- package/server/dist/modules/execution/adapters/budget-storage.js +21 -0
- package/server/dist/modules/execution/adapters/usage-reader.js +38 -0
- package/server/dist/modules/execution/application/enforce-budget.js +14 -0
- package/server/dist/modules/execution/application/record-job-invocations.js +64 -0
- package/server/dist/modules/execution/application/recover-job-usage.js +200 -0
- package/server/dist/modules/execution/domain/job-accounting.js +2 -0
- package/server/dist/modules/execution/domain/scheduling.js +24 -0
- package/server/dist/modules/execution/domain/usage.js +21 -0
- package/server/dist/modules/execution/index.js +14 -0
- package/server/dist/modules/execution/ports.js +2 -0
- package/server/dist/{accept-ladder.js → modules/execution/runtime/accept-ladder.js} +1 -1
- package/server/dist/{interactive-job-session.js → modules/execution/runtime/interactive-job-session.js} +11 -11
- package/server/dist/{job-phase-breakdown.js → modules/execution/runtime/job-phase-breakdown.js} +1 -1
- package/server/dist/{queue-manager.js → modules/execution/runtime/queue-manager.js} +74 -491
- package/server/dist/{revision-seed.js → modules/execution/runtime/revision-seed.js} +1 -1
- package/server/dist/{spawn-lifecycle.js → modules/execution/runtime/spawn-lifecycle.js} +3 -3
- package/server/dist/{stuck-run-detector.js → modules/execution/runtime/stuck-run-detector.js} +1 -1
- package/server/dist/{loop-command-catalog.js → modules/loops/runtime/loop-command-catalog.js} +1 -1
- package/server/dist/{loop-executors.js → modules/loops/runtime/loop-executors.js} +17 -17
- package/server/dist/{loop-factory.js → modules/loops/runtime/loop-factory.js} +2 -21
- package/server/dist/{loop-graph.js → modules/loops/runtime/loop-graph.js} +25 -0
- package/server/dist/{loop-role-engines.js → modules/loops/runtime/loop-role-engines.js} +4 -4
- package/server/dist/{loop-run-manager.js → modules/loops/runtime/loop-run-manager.js} +101 -26
- package/server/dist/{loop-runs-store.js → modules/loops/runtime/loop-runs-store.js} +1 -1
- package/server/dist/{loop-shell-invocation.js → modules/loops/runtime/loop-shell-invocation.js} +2 -2
- package/server/dist/{loop-step-idle.js → modules/loops/runtime/loop-step-idle.js} +1 -1
- package/server/dist/{loop-templates.js → modules/loops/runtime/loop-templates.js} +24 -29
- package/server/dist/{loops-router.js → modules/loops/runtime/loops-router.js} +2 -2
- package/server/dist/{agent-chat-manager.js → modules/missions/runtime/agent-chat-manager.js} +19 -19
- package/server/dist/{agent-chat-router.js → modules/missions/runtime/agent-chat-router.js} +6 -6
- package/server/dist/{agent-context-resolver.js → modules/missions/runtime/agent-context-resolver.js} +8 -8
- package/server/dist/{agent-failure-briefing.js → modules/missions/runtime/agent-failure-briefing.js} +4 -2
- package/server/dist/{agent-fence-promotion.js → modules/missions/runtime/agent-fence-promotion.js} +1 -1
- package/server/dist/{agent-input-store.js → modules/missions/runtime/agent-input-store.js} +1 -1
- package/server/dist/{agent-operator-prompt.js → modules/missions/runtime/agent-operator-prompt.js} +75 -12
- package/server/dist/{agent-spec-framing.js → modules/missions/runtime/agent-spec-framing.js} +4 -4
- package/server/dist/{agent-steering.js → modules/missions/runtime/agent-steering.js} +1 -1
- package/server/dist/{mission-run-notify.js → modules/missions/runtime/mission-run-notify.js} +5 -2
- package/server/dist/modules/project-settings/adapters/http.js +24 -0
- package/server/dist/modules/project-settings/adapters/sqlite.js +120 -0
- package/server/dist/modules/project-settings/application.js +11 -0
- package/server/dist/modules/project-settings/domain.js +95 -0
- package/server/dist/modules/project-settings/index.js +11 -0
- package/server/dist/modules/project-settings/ports.js +2 -0
- package/server/dist/{contract-refine-runner.js → modules/specs/runtime/contract-refine-runner.js} +12 -12
- package/server/dist/{proposal-manager.js → modules/specs/runtime/proposal-manager.js} +8 -8
- package/server/dist/{smash-runner.js → modules/specs/runtime/smash-runner.js} +26 -58
- package/server/dist/{spec-addenda-core.js → modules/specs/runtime/spec-addenda-core.js} +2 -2
- package/server/dist/{spec-addenda.js → modules/specs/runtime/spec-addenda.js} +3 -3
- package/server/dist/{spec-launcher-manager.js → modules/specs/runtime/spec-launcher-manager.js} +8 -8
- package/server/dist/{spec-models.js → modules/specs/runtime/spec-models.js} +1 -1
- package/server/dist/{ticket-watcher.js → modules/specs/runtime/ticket-watcher.js} +1 -1
- package/server/dist/{background-process-control.js → modules/terminals/runtime/background-process-control.js} +1 -1
- package/server/dist/{background-process-service.js → modules/terminals/runtime/background-process-service.js} +3 -3
- package/server/dist/{background-process-store.js → modules/terminals/runtime/background-process-store.js} +1 -1
- package/server/dist/{background-windows-bootstrap.js → modules/terminals/runtime/background-windows-bootstrap.js} +3 -3
- package/server/dist/{terminal-manager.js → modules/terminals/runtime/terminal-manager.js} +3 -3
- package/server/dist/project-git.js +1 -1
- package/server/dist/project-profile-support.js +32 -0
- package/server/dist/project-registry.js +34 -40
- package/server/dist/project-router-background-processes.js +1 -1
- package/server/dist/project-router-chat.js +6 -6
- package/server/dist/project-router-git.js +1 -1
- package/server/dist/project-router-helpers.js +2 -2
- package/server/dist/project-router-jobs.js +14 -12
- package/server/dist/project-router-loop-runs.js +12 -12
- package/server/dist/project-router-settings.js +15 -75
- package/server/dist/project-router-setup.js +2 -2
- package/server/dist/project-router-spending.js +4 -4
- package/server/dist/project-router-terminals.js +4 -4
- package/server/dist/project-router-tickets.js +14 -14
- package/server/dist/project-router.js +13 -13
- package/server/dist/providers/claude-adapter.js +3 -2
- package/server/dist/providers/codex-adapter.js +3 -3
- package/server/dist/providers/gemini-adapter.js +1 -1
- package/server/dist/providers/telemetry-env.js +55 -0
- package/server/dist/runtime-role-prompts-router.js +2 -2
- package/server/dist/setup-manager.js +2 -48
- package/server/dist/shared/git-branch-name.js +16 -0
- package/server/dist/transient-children.js +3 -3
- package/server/dist/util/distribute-int.js +33 -0
- package/client/dist/assets/ReviewPacketPage-D-UjTef3.js +0 -4
- package/client/dist/assets/index-BAtqmAZR.css +0 -2
- package/client/dist/assets/packet-CjS_478C.js +0 -1
- package/client/dist/assets/packet-D69dKsHN.js +0 -1
- package/client/dist/assets/packet-D9rU6Oxo.js +0 -1
- package/client/dist/assets/packet-DMDFoamg.js +0 -1
- package/client/dist/assets/packet-Dd2RMs1h.js +0 -1
- package/client/dist/assets/packet-DjC9OibC.js +0 -1
- package/client/dist/assets/packet-DwPtJoDz.js +0 -1
- package/client/dist/assets/packet-ceahDg8_.js +0 -1
- package/server/dist/rail-pr-decision.js +0 -2377
- package/server/dist/ticket-broadcast.js +0 -47
- /package/server/dist/{ai-invocations.js → modules/accounting/runtime/ai-invocations.js} +0 -0
- /package/server/dist/{codex-otel-bridge.js → modules/accounting/runtime/codex-otel-bridge.js} +0 -0
- /package/server/dist/{metrics.js → modules/accounting/runtime/metrics.js} +0 -0
- /package/server/dist/{telemetry-export.js → modules/accounting/runtime/telemetry-export.js} +0 -0
- /package/server/dist/{agent-runtime-accounting.js → modules/agent-runtime/runtime/agent-runtime-accounting.js} +0 -0
- /package/server/dist/{agent-runtime-events.js → modules/agent-runtime/runtime/agent-runtime-events.js} +0 -0
- /package/server/dist/{agent-runtime-history.js → modules/agent-runtime/runtime/agent-runtime-history.js} +0 -0
- /package/server/dist/{agent-runtime-metrics.js → modules/agent-runtime/runtime/agent-runtime-metrics.js} +0 -0
- /package/server/dist/{agent-runtime-package.js → modules/agent-runtime/runtime/agent-runtime-package.js} +0 -0
- /package/server/dist/{agent-runtime-repositories.js → modules/agent-runtime/runtime/agent-runtime-repositories.js} +0 -0
- /package/server/dist/{agent-runtime-verification-suggestions.js → modules/agent-runtime/runtime/agent-runtime-verification-suggestions.js} +0 -0
- /package/server/dist/{agent-refine-db.js → modules/agents/runtime/agent-refine-db.js} +0 -0
- /package/server/dist/{browser-capture-types.js → modules/browser/runtime/browser-capture-types.js} +0 -0
- /package/server/dist/{browser-context-pool.js → modules/browser/runtime/browser-context-pool.js} +0 -0
- /package/server/dist/{browser-network.js → modules/browser/runtime/browser-network.js} +0 -0
- /package/server/dist/{browser-viewport.js → modules/browser/runtime/browser-viewport.js} +0 -0
- /package/server/dist/{blueprint-render.js → modules/builder/runtime/blueprint-render.js} +0 -0
- /package/server/dist/{blueprint-spec-fixtures.js → modules/builder/runtime/blueprint-spec-fixtures.js} +0 -0
- /package/server/dist/{blueprint-store.js → modules/builder/runtime/blueprint-store.js} +0 -0
- /package/server/dist/{blueprint-types.js → modules/builder/runtime/blueprint-types.js} +0 -0
- /package/server/dist/{builder-cwd-manager.js → modules/builder/runtime/builder-cwd-manager.js} +0 -0
- /package/server/dist/{milestone-chain-store.js → modules/builder/runtime/milestone-chain-store.js} +0 -0
- /package/server/dist/{changes-reader.js → modules/code/runtime/changes-reader.js} +0 -0
- /package/server/dist/{context-scope.js → modules/conversations/runtime/context-scope.js} +0 -0
- /package/server/dist/{explore-contract-refine.js → modules/conversations/runtime/explore-contract-refine.js} +0 -0
- /package/server/dist/{explore-draft-title.js → modules/conversations/runtime/explore-draft-title.js} +0 -0
- /package/server/dist/{merge-manager.js → modules/delivery/runtime/merge-manager.js} +0 -0
- /package/server/dist/{multi-repo-execution-store.js → modules/delivery/runtime/multi-repo-execution-store.js} +0 -0
- /package/server/dist/{pr-lifecycle.js → modules/delivery/runtime/pr-lifecycle.js} +0 -0
- /package/server/dist/{rail-isolation.js → modules/delivery/runtime/rail-isolation.js} +0 -0
- /package/server/dist/{rail-pr-recovery-git.js → modules/delivery/runtime/rail-pr-recovery-git.js} +0 -0
- /package/server/dist/{rail-worktrees-store.js → modules/delivery/runtime/rail-worktrees-store.js} +0 -0
- /package/server/dist/{rails-store.js → modules/delivery/runtime/rails-store.js} +0 -0
- /package/server/dist/{job-listing.js → modules/execution/runtime/job-listing.js} +0 -0
- /package/server/dist/{job-spawn-idempotency.js → modules/execution/runtime/job-spawn-idempotency.js} +0 -0
- /package/server/dist/{run-duration-stats.js → modules/execution/runtime/run-duration-stats.js} +0 -0
- /package/server/dist/{verification-sentinel.js → modules/execution/runtime/verification-sentinel.js} +0 -0
- /package/server/dist/{loop-constants.js → modules/loops/runtime/loop-constants.js} +0 -0
- /package/server/dist/{loop-decider.js → modules/loops/runtime/loop-decider.js} +0 -0
- /package/server/dist/{loop-effect.js → modules/loops/runtime/loop-effect.js} +0 -0
- /package/server/dist/{loop-preview.js → modules/loops/runtime/loop-preview.js} +0 -0
- /package/server/dist/{loop-templates-ported.js → modules/loops/runtime/loop-templates-ported.js} +0 -0
- /package/server/dist/{loops-store.js → modules/loops/runtime/loops-store.js} +0 -0
- /package/server/dist/{agent-chat-registry.js → modules/missions/runtime/agent-chat-registry.js} +0 -0
- /package/server/dist/{agent-cwd-manager.js → modules/missions/runtime/agent-cwd-manager.js} +0 -0
- /package/server/dist/{agent-tier.js → modules/missions/runtime/agent-tier.js} +0 -0
- /package/server/dist/{spec-contract-prompt.js → modules/specs/runtime/spec-contract-prompt.js} +0 -0
- /package/server/dist/{spec-draft-parser.js → modules/specs/runtime/spec-draft-parser.js} +0 -0
- /package/server/dist/{ticket-store.js → modules/specs/runtime/ticket-store.js} +0 -0
- /package/server/dist/{terminal-marks-store.js → modules/terminals/runtime/terminal-marks-store.js} +0 -0
- /package/server/dist/{terminal-osc-parser.js → modules/terminals/runtime/terminal-osc-parser.js} +0 -0
- /package/server/dist/{terminal-settings.js → modules/terminals/runtime/terminal-settings.js} +0 -0
|
@@ -1,2377 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
-
var ownKeys = function(o) {
|
|
20
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
-
var ar = [];
|
|
22
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
-
return ar;
|
|
24
|
-
};
|
|
25
|
-
return ownKeys(o);
|
|
26
|
-
};
|
|
27
|
-
return function (mod) {
|
|
28
|
-
if (mod && mod.__esModule) return mod;
|
|
29
|
-
var result = {};
|
|
30
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
-
__setModuleDefault(result, mod);
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
34
|
-
})();
|
|
35
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.PR_DECISION_ACTIONS = void 0;
|
|
37
|
-
exports.isPrDecisionAction = isPrDecisionAction;
|
|
38
|
-
exports.actionAllowed = actionAllowed;
|
|
39
|
-
exports.executePrDecision = executePrDecision;
|
|
40
|
-
exports.sweepMergedChainAncestors = sweepMergedChainAncestors;
|
|
41
|
-
/**
|
|
42
|
-
* Execute a user's ask-first PR decision (safe-pr-review-flow) against its
|
|
43
|
-
* authoritative `rail_pr_deliveries` row: the actions both decision
|
|
44
|
-
* surfaces (the dashboard rail row and the launching agent-chat card) POST to
|
|
45
|
-
* `/rails/pr-decision`.
|
|
46
|
-
*
|
|
47
|
-
* create-pr → deferred deliverRailAsPr, reconstructed from the row's durable
|
|
48
|
-
* columns (nothing survives in memory after build-settle). The
|
|
49
|
-
* PR title/body are composed HERE (canonical OSS style — see
|
|
50
|
-
* pr-naming/pr-body) from the ticket store + jira_links + the
|
|
51
|
-
* branch diffs, all failure-tolerant.
|
|
52
|
-
* publish → `gh pr ready` opens the draft for team review.
|
|
53
|
-
* discard → close the PR, sweep the launch's worktrees + branches, revert
|
|
54
|
-
* the on_review tickets to todo.
|
|
55
|
-
* poll-merge → `gh pr view` — MERGED promotes the tickets to done.
|
|
56
|
-
* merge-local→ the REMOTE-LESS acceptance path: merge the delivered branches
|
|
57
|
-
* into the integration branch after isolated assembly. Compatible
|
|
58
|
-
* local changes survive; another current branch stays untouched.
|
|
59
|
-
* Without it, a repo with no GitHub remote wedges
|
|
60
|
-
* forever in retry(local-only)/discard — no way to ACCEPT work.
|
|
61
|
-
*
|
|
62
|
-
* Every mutation rides a lease-owned compare-and-set (a raced concurrent
|
|
63
|
-
* decision loses with a 409 before effects) and re-broadcasts the durable
|
|
64
|
-
* `rail.pr_state` snapshot so both surfaces converge; agent-chat-originated
|
|
65
|
-
* rows also update their inline decision card in place. Deps are injected
|
|
66
|
-
* (db/git/exec/broadcast/jira/agent-chat) so the whole matrix is unit-testable
|
|
67
|
-
* without git/gh/net — the route in rails-router validates and delegates here.
|
|
68
|
-
*/
|
|
69
|
-
const path = __importStar(require("path"));
|
|
70
|
-
const fs = __importStar(require("fs"));
|
|
71
|
-
const artifact_registry_1 = require("./artifact-registry");
|
|
72
|
-
const worktree_manager_1 = require("./worktree-manager");
|
|
73
|
-
const pr_publisher_1 = require("./pr-publisher");
|
|
74
|
-
const rail_pr_delivery_1 = require("./rail-pr-delivery");
|
|
75
|
-
const rail_worktree_release_1 = require("./rail-worktree-release");
|
|
76
|
-
const pr_naming_1 = require("./pr-naming");
|
|
77
|
-
const pr_body_1 = require("./pr-body");
|
|
78
|
-
const ai_invocations_1 = require("./ai-invocations");
|
|
79
|
-
const jira_db_1 = require("./jira/jira-db");
|
|
80
|
-
const rail_pr_store_1 = require("./rail-pr-store");
|
|
81
|
-
const rail_worktrees_store_1 = require("./rail-worktrees-store");
|
|
82
|
-
const ticket_store_1 = require("./ticket-store");
|
|
83
|
-
const workspace_resolution_1 = require("./workspace-resolution");
|
|
84
|
-
const agent_chat_registry_1 = require("./agent-chat-registry");
|
|
85
|
-
const ids_1 = require("./ids");
|
|
86
|
-
const repo_lock_1 = require("./repo-lock");
|
|
87
|
-
const integration_branch_1 = require("./integration-branch");
|
|
88
|
-
const rail_pr_ticket_effects_1 = require("./rail-pr-ticket-effects");
|
|
89
|
-
const pr_lifecycle_1 = require("./pr-lifecycle");
|
|
90
|
-
const rail_pr_recovery_git_1 = require("./rail-pr-recovery-git");
|
|
91
|
-
const milestone_chain_store_1 = require("./milestone-chain-store");
|
|
92
|
-
const rail_pr_store_2 = require("./rail-pr-store");
|
|
93
|
-
exports.PR_DECISION_ACTIONS = ['create-pr', 'publish', 'discard', 'dismiss', 'poll-merge', 'reopen', 'merge-local', 'acknowledge-no-changes', 'recover-and-retry'];
|
|
94
|
-
function safetyArchiveRecorder(deps, row) {
|
|
95
|
-
return (archive) => {
|
|
96
|
-
if (!(0, rail_pr_store_1.appendPrDeliverySafetyArchive)(deps.db, row.id, archive)) {
|
|
97
|
-
throw new Error(`delivery ${row.id} disappeared while recording safety archive ${archive}`);
|
|
98
|
-
}
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
function isPrDecisionAction(v) {
|
|
102
|
-
return typeof v === 'string' && exports.PR_DECISION_ACTIONS.includes(v);
|
|
103
|
-
}
|
|
104
|
-
// Action handlers keep using one finalization helper, but the operation lease
|
|
105
|
-
// must be absent from every emitted snapshot. Calls made while a lease is held
|
|
106
|
-
// are deferred until executePrDecision's finally releases that exact token.
|
|
107
|
-
const deferredFinalizations = new Set();
|
|
108
|
-
function staleDecision(current) {
|
|
109
|
-
return { status: 409, body: { error: 'stale_decision', current } };
|
|
110
|
-
}
|
|
111
|
-
function illegalAction(current) {
|
|
112
|
-
return { status: 409, body: { error: 'stale_decision', current, reason: 'illegal_action' } };
|
|
113
|
-
}
|
|
114
|
-
function ghFailed(r) {
|
|
115
|
-
const detail = (r.stderr.trim() || r.stdout.trim()).split('\n')[0] || `exit ${r.code}`;
|
|
116
|
-
return { status: 502, body: { error: 'gh_failed', detail } };
|
|
117
|
-
}
|
|
118
|
-
/**
|
|
119
|
-
* The D3 state machine's action legality. `create-pr` is also the RETRY path:
|
|
120
|
-
* from a retryable pr_failed, or from a pr_draft whose delivery degraded before
|
|
121
|
-
* a PR existed (pushed/local-only → pr_url null). Publish/poll require a real
|
|
122
|
-
* PR URL — a degraded draft only offers retry or discard.
|
|
123
|
-
*/
|
|
124
|
-
function actionAllowed(action, row) {
|
|
125
|
-
switch (action) {
|
|
126
|
-
case 'create-pr':
|
|
127
|
-
return row.decision === 'on_review' ||
|
|
128
|
-
(row.decision === 'pr_failed' && (row.delivery_outcome === 'retryable_failure' || row.delivery_outcome === 'unknown')) ||
|
|
129
|
-
(row.decision === 'pr_draft' && row.pr_url === null);
|
|
130
|
-
case 'publish':
|
|
131
|
-
return row.decision === 'pr_draft' && row.pr_url !== null;
|
|
132
|
-
case 'discard':
|
|
133
|
-
return row.decision === 'on_review' || row.decision === 'pr_draft' ||
|
|
134
|
-
row.decision === 'pr_ready' || row.decision === 'pr_closed' || row.decision === 'no_changes' ||
|
|
135
|
-
row.decision === 'implementation_failed' ||
|
|
136
|
-
row.decision === 'pr_failed';
|
|
137
|
-
case 'dismiss':
|
|
138
|
-
return row.is_continuation === 1 && row.decision !== 'building' &&
|
|
139
|
-
row.decision !== 'merged' && row.decision !== 'discarded' && row.decision !== 'superseded';
|
|
140
|
-
case 'poll-merge':
|
|
141
|
-
return (row.decision === 'pr_draft' || row.decision === 'pr_ready' || row.decision === 'pr_closed') && row.pr_url !== null;
|
|
142
|
-
case 'reopen':
|
|
143
|
-
return row.decision === 'pr_closed' && row.pr_url !== null;
|
|
144
|
-
case 'merge-local':
|
|
145
|
-
// Remote-less acceptance ONLY: once a real PR exists (pr_url), GitHub is
|
|
146
|
-
// the merge authority — merging under an open PR would leave it dangling.
|
|
147
|
-
return row.pr_url === null &&
|
|
148
|
-
(row.decision === 'on_review' || row.decision === 'pr_failed' || row.decision === 'pr_draft');
|
|
149
|
-
case 'acknowledge-no-changes':
|
|
150
|
-
return row.decision === 'no_changes' && row.is_continuation !== 1;
|
|
151
|
-
case 'recover-and-retry':
|
|
152
|
-
return row.decision === 'pr_failed' && row.delivery_outcome === 'blocked' &&
|
|
153
|
-
(row.status_code === 'settlement_interrupted' || row.status_code === 'recovery_unavailable') &&
|
|
154
|
-
row.is_continuation === 1 &&
|
|
155
|
-
(row.implementation_outcome === 'succeeded' || row.implementation_outcome === 'partially_succeeded') &&
|
|
156
|
-
row.pr_url !== null && row.branch !== null;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
async function executePrDecision(deps, input) {
|
|
160
|
-
const row = (0, rail_pr_store_1.getPrDelivery)(deps.db, input.prDeliveryId);
|
|
161
|
-
if (!row)
|
|
162
|
-
return { status: 404, body: { error: 'Unknown prDeliveryId' } };
|
|
163
|
-
if (row.parent_delivery_id && deps.repositoryChildOf !== row.parent_delivery_id)
|
|
164
|
-
return { status: 409, body: { error: 'repository_delivery_requires_parent', parentDeliveryId: row.parent_delivery_id } };
|
|
165
|
-
// A manifest alone does not make a repository GROUP: a launch that failed
|
|
166
|
-
// before allocating a single repository delivery persists the parent with
|
|
167
|
-
// its manifest and zero children (observed: `git worktree add` refused
|
|
168
|
-
// because the PR branch was checked out in the main clone). Routing that
|
|
169
|
-
// parent into the group path 404'd on "no repositories" — which the UI read
|
|
170
|
-
// as "already resolved" — and left the rail pinned at pending_decision.
|
|
171
|
-
if (!row.parent_delivery_id && row.execution_manifest && (0, rail_pr_store_1.hasRepositoryDeliveries)(deps.db, row.id)) {
|
|
172
|
-
const { executeRepositoryGroupDecision } = await Promise.resolve().then(() => __importStar(require('./multi-repo-delivery')));
|
|
173
|
-
return executeRepositoryGroupDecision(deps, input);
|
|
174
|
-
}
|
|
175
|
-
// Compare-and-set pre-check: the caller decided against a snapshot — if the
|
|
176
|
-
// row moved on (the other surface answered first), it must reconcile, not act.
|
|
177
|
-
if (row.decision !== input.expectedDecision)
|
|
178
|
-
return staleDecision(row.decision);
|
|
179
|
-
if (!actionAllowed(input.action, row))
|
|
180
|
-
return illegalAction(row.decision);
|
|
181
|
-
// Claim before any git/GitHub/cleanup/ticket effect. The visible decision is
|
|
182
|
-
// unchanged while the winner works, but a second surface cannot start.
|
|
183
|
-
const operationToken = (0, ids_1.newId)();
|
|
184
|
-
if (!(0, rail_pr_store_1.claimPrDeliveryOperation)(deps.db, row.id, row.decision, input.action, operationToken)) {
|
|
185
|
-
const current = (0, rail_pr_store_1.getPrDelivery)(deps.db, row.id);
|
|
186
|
-
if (!current || current.decision !== input.expectedDecision) {
|
|
187
|
-
return staleDecision(current?.decision ?? row.decision);
|
|
188
|
-
}
|
|
189
|
-
return {
|
|
190
|
-
status: 409,
|
|
191
|
-
body: { error: 'operation_in_progress', current: current.decision, operation: current.operation },
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
const claimedRow = (0, rail_pr_store_1.getPrDelivery)(deps.db, row.id);
|
|
195
|
-
if (!claimedRow || claimedRow.operation_token !== operationToken) {
|
|
196
|
-
return { status: 409, body: { error: 'operation_in_progress', current: claimedRow?.decision ?? row.decision } };
|
|
197
|
-
}
|
|
198
|
-
try {
|
|
199
|
-
switch (input.action) {
|
|
200
|
-
case 'create-pr': return await runCreatePr(deps, claimedRow);
|
|
201
|
-
case 'publish': return await runPublish(deps, claimedRow);
|
|
202
|
-
case 'discard': return await runDiscard(deps, claimedRow);
|
|
203
|
-
case 'dismiss': return await runDismiss(deps, claimedRow);
|
|
204
|
-
case 'poll-merge': return await runPollMerge(deps, claimedRow);
|
|
205
|
-
case 'reopen': return await runReopen(deps, claimedRow);
|
|
206
|
-
case 'merge-local': return await runMergeLocal(deps, claimedRow);
|
|
207
|
-
case 'acknowledge-no-changes': return await runAcknowledgeNoChanges(deps, claimedRow);
|
|
208
|
-
case 'recover-and-retry': return await runRecoverAndRetry(deps, claimedRow);
|
|
209
|
-
}
|
|
210
|
-
return illegalAction(row.decision);
|
|
211
|
-
}
|
|
212
|
-
finally {
|
|
213
|
-
try {
|
|
214
|
-
(0, rail_pr_store_1.releasePrDeliveryOperation)(deps.db, row.id, operationToken);
|
|
215
|
-
}
|
|
216
|
-
catch { /* durable state remains authoritative */ }
|
|
217
|
-
if (deferredFinalizations.delete(row.id))
|
|
218
|
-
finalizeTransition(deps, row.id);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
/**
|
|
222
|
-
* Atomic CAS transition; a `false` return means a concurrent mutation raced us
|
|
223
|
-
* between the pre-check and here — surface the same 409 with the fresh state.
|
|
224
|
-
*/
|
|
225
|
-
function casTransition(deps, row, next, patch = {}) {
|
|
226
|
-
if (row.operation_token && (0, rail_pr_store_1.transitionClaimedDecision)(deps.db, row.id, row.decision, next, row.operation_token, patch))
|
|
227
|
-
return null;
|
|
228
|
-
const current = (0, rail_pr_store_1.getPrDelivery)(deps.db, row.id);
|
|
229
|
-
return staleDecision(current?.decision ?? row.decision);
|
|
230
|
-
}
|
|
231
|
-
function casTransitionWithTicketEffect(deps, row, next, patch, effect) {
|
|
232
|
-
if (deps.repositoryChildOf)
|
|
233
|
-
return casTransition(deps, row, next, patch);
|
|
234
|
-
if (row.operation_token &&
|
|
235
|
-
(0, rail_pr_ticket_effects_1.transitionClaimedDecisionWithTicketEffect)(deps.db, row.id, row.decision, next, row.operation_token, patch, effect))
|
|
236
|
-
return null;
|
|
237
|
-
const current = (0, rail_pr_store_1.getPrDelivery)(deps.db, row.id);
|
|
238
|
-
return staleDecision(current?.decision ?? row.decision);
|
|
239
|
-
}
|
|
240
|
-
function applyTerminalTicketEffect(deps, row, terminalDecision, cleanupWarnings) {
|
|
241
|
-
if (deps.repositoryChildOf)
|
|
242
|
-
return;
|
|
243
|
-
const result = (0, rail_pr_ticket_effects_1.applyRailPrTicketEffect)(deps, row.id);
|
|
244
|
-
if (result.ok)
|
|
245
|
-
return;
|
|
246
|
-
cleanupWarnings.push(`ticket status update pending: ${result.error ?? 'unknown ticket-store error'}`);
|
|
247
|
-
if (!row.operation_token)
|
|
248
|
-
return;
|
|
249
|
-
(0, rail_pr_store_1.transitionClaimedDecision)(deps.db, row.id, terminalDecision, terminalDecision, row.operation_token, {
|
|
250
|
-
statusCode: 'cleanup_incomplete',
|
|
251
|
-
cleanupWarnings,
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
/**
|
|
255
|
-
* Post-transition fan-out: re-broadcast the durable rail.pr_state snapshot
|
|
256
|
-
* (both surfaces converge on it) and, for agent-chat-originated launches,
|
|
257
|
-
* update the persisted inline card in place.
|
|
258
|
-
*/
|
|
259
|
-
function finalizeTransition(deps, id) {
|
|
260
|
-
const row = (0, rail_pr_store_1.getPrDelivery)(deps.db, id);
|
|
261
|
-
if (!row)
|
|
262
|
-
return undefined;
|
|
263
|
-
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
264
|
-
if (row.operation_token) {
|
|
265
|
-
deferredFinalizations.add(id);
|
|
266
|
-
return snap;
|
|
267
|
-
}
|
|
268
|
-
if (deps.repositoryChildOf)
|
|
269
|
-
return snap;
|
|
270
|
-
deps.broadcast((0, rail_pr_store_1.toRailPrStateMessage)(deps.project.id, snap));
|
|
271
|
-
if (row.origin_conversation_id) {
|
|
272
|
-
const agent = (deps.agentChat ?? agent_chat_registry_1.getAgentChatManager)();
|
|
273
|
-
// Shared mapper (rail-pr-store.toPrDecisionCardEnvelope) — the SAME shape
|
|
274
|
-
// rail-isolated-launch's origin-card sync posts, so the two card-writing
|
|
275
|
-
// sites (incl. the runIds chip data) can never drift.
|
|
276
|
-
agent?.updatePrDecisionCard(row.origin_conversation_id, (0, rail_pr_store_1.toPrDecisionCardEnvelope)(deps.project.id, snap));
|
|
277
|
-
}
|
|
278
|
-
return snap;
|
|
279
|
-
}
|
|
280
|
-
function parsePrNumber(prUrl) {
|
|
281
|
-
const m = /\/pull\/(\d+)/.exec(prUrl);
|
|
282
|
-
return m ? parseInt(m[1], 10) : null;
|
|
283
|
-
}
|
|
284
|
-
function batchWorktreeRoot(slug) {
|
|
285
|
-
// Mirrors rail-isolated-launch's worktreesRoot — derivable, never stored.
|
|
286
|
-
return path.join((0, artifact_registry_1.resolveHome)(), '.specrails', 'projects', slug, 'worktrees');
|
|
287
|
-
}
|
|
288
|
-
/** A legacy record's `succeeded` bit meant delivery eligibility. New records
|
|
289
|
-
* state that eligibility directly; blocked/no-change units are never swept as
|
|
290
|
-
* a side effect of delivering another unit in the same batch. */
|
|
291
|
-
function unitWasDelivered(unit) {
|
|
292
|
-
return unit.deliveryOutcome === undefined ? unit.succeeded : unit.deliveryOutcome === 'ready';
|
|
293
|
-
}
|
|
294
|
-
/** Branch deletion is ownership-based, never name-derived. Legacy rows without
|
|
295
|
-
* ownership evidence degrade to preservation. A multi-unit assembled head is
|
|
296
|
-
* owned when it is distinct from every recorded unit branch. */
|
|
297
|
-
function ownedDeliveryBranches(row, snap, cleanupWarnings) {
|
|
298
|
-
const unknownOwnership = row.is_continuation !== 1
|
|
299
|
-
? snap.branches.filter((unit) => unitWasDelivered(unit) && unit.branchOwnership === undefined)
|
|
300
|
-
: [];
|
|
301
|
-
if (cleanupWarnings && unknownOwnership.length > 0) {
|
|
302
|
-
cleanupWarnings.push(`branch cleanup preserved ${unknownOwnership.length} legacy ${unknownOwnership.length === 1 ? 'branch' : 'branches'} because ownership was not recorded`);
|
|
303
|
-
}
|
|
304
|
-
const owned = new Set(snap.branches
|
|
305
|
-
.filter((unit) => unit.branchOwnership === 'created' && unitWasDelivered(unit))
|
|
306
|
-
.map((unit) => unit.branch));
|
|
307
|
-
if (row.is_continuation !== 1 && snap.branches.length > 1 && row.branch &&
|
|
308
|
-
!snap.branches.some((unit) => unit.branch === row.branch))
|
|
309
|
-
owned.add(row.branch);
|
|
310
|
-
return owned;
|
|
311
|
-
}
|
|
312
|
-
function immutableHeadForOwnedBranch(row, snap, branch) {
|
|
313
|
-
if (branch === row.branch && row.delivery_sha && COMMIT_SHA_RE.test(row.delivery_sha)) {
|
|
314
|
-
return row.delivery_sha.toLowerCase();
|
|
315
|
-
}
|
|
316
|
-
const unitHeads = new Set(snap.branches
|
|
317
|
-
.filter((unit) => unitWasDelivered(unit) && unit.branch === branch && unit.finalSha && COMMIT_SHA_RE.test(unit.finalSha))
|
|
318
|
-
.map((unit) => unit.finalSha.toLowerCase()));
|
|
319
|
-
return unitHeads.size === 1 ? [...unitHeads][0] : null;
|
|
320
|
-
}
|
|
321
|
-
async function deleteOwnedBranchesIfUnchanged(deps, row, snap, cleanupWarnings, protectedBranches = new Set()) {
|
|
322
|
-
for (const branch of ownedDeliveryBranches(row, snap, cleanupWarnings)) {
|
|
323
|
-
if (!branch || branch === row.base_branch)
|
|
324
|
-
continue;
|
|
325
|
-
// A failed release leaves the linked worktree mounted for inspection. Do
|
|
326
|
-
// not even attempt to delete its checked-out branch: preserving the files
|
|
327
|
-
// while erasing their durable ref would make the recovery story depend on
|
|
328
|
-
// Git's incidental checked-out-branch rejection.
|
|
329
|
-
if (protectedBranches.has(branch)) {
|
|
330
|
-
cleanupWarnings.push(`branch ${branch}: retained with its worktree for inspection`);
|
|
331
|
-
continue;
|
|
332
|
-
}
|
|
333
|
-
try {
|
|
334
|
-
const expectedHead = immutableHeadForOwnedBranch(row, snap, branch);
|
|
335
|
-
if (!expectedHead) {
|
|
336
|
-
cleanupWarnings.push(`branch ${branch}: retained because no unambiguous immutable delivered HEAD was recorded`);
|
|
337
|
-
continue;
|
|
338
|
-
}
|
|
339
|
-
const observed = await deps.git.run(['rev-parse', '--verify', `refs/heads/${branch}`], deps.project.path);
|
|
340
|
-
const observedHead = observed.code === 0 ? observed.stdout.trim().toLowerCase() : '';
|
|
341
|
-
if (!COMMIT_SHA_RE.test(observedHead) || observedHead !== expectedHead) {
|
|
342
|
-
cleanupWarnings.push(`branch ${branch}: retained because its current tip no longer matches delivered HEAD ${expectedHead.slice(0, 12)}`);
|
|
343
|
-
continue;
|
|
344
|
-
}
|
|
345
|
-
const deleted = await deps.git.run(['branch', '-D', branch], deps.project.path);
|
|
346
|
-
if (deleted.code !== 0) {
|
|
347
|
-
cleanupWarnings.push(`branch ${branch}: ${(deleted.stderr.trim() || deleted.stdout.trim()).split('\n')[0] || `exit ${deleted.code}`}`);
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
catch (err) {
|
|
351
|
-
cleanupWarnings.push(`branch ${branch}: ${err instanceof Error ? err.message : String(err)}`);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
function releasableWorktreeIds(deps, snap) {
|
|
356
|
-
const preservedBranches = new Set(snap.branches.filter((unit) => !unitWasDelivered(unit)).map((unit) => unit.branch));
|
|
357
|
-
return snap.worktreeIds.filter((id) => {
|
|
358
|
-
const wt = (0, rail_worktrees_store_1.getRailWorktree)(deps.db, id);
|
|
359
|
-
return !wt || !preservedBranches.has(wt.branch);
|
|
360
|
-
});
|
|
361
|
-
}
|
|
362
|
-
function mergedEvidenceDetail(observation) {
|
|
363
|
-
return observation.includesExpectedSha === null
|
|
364
|
-
? 'GitHub did not return enough commit evidence to prove the previous PR included the verified implementation commit'
|
|
365
|
-
: 'the previous PR merged without the verified implementation commit';
|
|
366
|
-
}
|
|
367
|
-
async function rerouteFromStalePr(deps, row, detail, exactWasPushed) {
|
|
368
|
-
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
369
|
-
const cleanupWarnings = await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
370
|
-
db: deps.db, git: deps.git, repoDir: deps.project.path,
|
|
371
|
-
worktreeIds: releasableWorktreeIds(deps, snap),
|
|
372
|
-
expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
|
|
373
|
-
overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
|
|
374
|
-
settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
|
|
375
|
-
onSafetyArchive: safetyArchiveRecorder(deps, row),
|
|
376
|
-
});
|
|
377
|
-
const partial = row.implementation_outcome === 'partially_succeeded';
|
|
378
|
-
const preserved = row.delivery_sha ? 'the preserved exact commit' : 'the preserved recorded branch';
|
|
379
|
-
const conflict = casTransition(deps, row, 'on_review', {
|
|
380
|
-
prUrl: null,
|
|
381
|
-
prNumber: null,
|
|
382
|
-
prState: exactWasPushed ? 'pushed' : 'local-only',
|
|
383
|
-
deliveryOutcome: partial ? 'partial' : 'ready',
|
|
384
|
-
statusCode: cleanupWarnings.length > 0 ? 'cleanup_incomplete' : partial ? 'partial_success' : 'ready_for_review',
|
|
385
|
-
statusDetail: `${detail}; create a new draft PR from ${preserved}`,
|
|
386
|
-
cleanupWarnings,
|
|
387
|
-
// The replacement PR belongs to this generation. Per-unit branch ownership
|
|
388
|
-
// remains unchanged so a borrowed/pre-existing ref is still never deleted.
|
|
389
|
-
isContinuation: false,
|
|
390
|
-
});
|
|
391
|
-
if (conflict)
|
|
392
|
-
return conflict;
|
|
393
|
-
const after = finalizeTransition(deps, row.id);
|
|
394
|
-
return {
|
|
395
|
-
status: 200,
|
|
396
|
-
body: {
|
|
397
|
-
ok: true,
|
|
398
|
-
decision: 'on_review',
|
|
399
|
-
prUrl: null,
|
|
400
|
-
prState: after?.prState ?? (exactWasPushed ? 'pushed' : 'local-only'),
|
|
401
|
-
rerouted: true,
|
|
402
|
-
detail,
|
|
403
|
-
},
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
async function settleObservedExistingPr(deps, row, observation, pushed) {
|
|
407
|
-
const next = observation.state === 'MERGED' || observation.isDraft === false ? 'pr_ready' : 'pr_draft';
|
|
408
|
-
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
409
|
-
const cleanupWarnings = await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
410
|
-
db: deps.db, git: deps.git, repoDir: deps.project.path,
|
|
411
|
-
worktreeIds: releasableWorktreeIds(deps, snap),
|
|
412
|
-
expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
|
|
413
|
-
overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
|
|
414
|
-
settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
|
|
415
|
-
onSafetyArchive: safetyArchiveRecorder(deps, row),
|
|
416
|
-
});
|
|
417
|
-
const conflict = casTransition(deps, row, next, {
|
|
418
|
-
prState: 'pr-created',
|
|
419
|
-
deliveryOutcome: 'delivered',
|
|
420
|
-
statusCode: cleanupWarnings.length > 0 ? 'cleanup_incomplete' : next === 'pr_ready' ? 'pr_ready' : 'existing_pr_updated',
|
|
421
|
-
statusDetail: null,
|
|
422
|
-
cleanupWarnings,
|
|
423
|
-
});
|
|
424
|
-
if (conflict)
|
|
425
|
-
return conflict;
|
|
426
|
-
await releaseDeliveredRecoveryLineage(deps, row, row.delivery_sha);
|
|
427
|
-
const after = finalizeTransition(deps, row.id);
|
|
428
|
-
return {
|
|
429
|
-
status: 200,
|
|
430
|
-
body: {
|
|
431
|
-
ok: true,
|
|
432
|
-
decision: next,
|
|
433
|
-
prUrl: after?.prUrl ?? row.pr_url,
|
|
434
|
-
prState: 'pr-created',
|
|
435
|
-
deliveryVerified: true,
|
|
436
|
-
verifiedSha: row.delivery_sha,
|
|
437
|
-
remoteHeadSha: observation.headRefOid,
|
|
438
|
-
pushed,
|
|
439
|
-
},
|
|
440
|
-
};
|
|
441
|
-
}
|
|
442
|
-
async function settleObservedClosedPr(deps, row, observation) {
|
|
443
|
-
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
444
|
-
const cleanupWarnings = await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
445
|
-
db: deps.db, git: deps.git, repoDir: deps.project.path,
|
|
446
|
-
worktreeIds: releasableWorktreeIds(deps, snap),
|
|
447
|
-
expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
|
|
448
|
-
overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
|
|
449
|
-
settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
|
|
450
|
-
onSafetyArchive: safetyArchiveRecorder(deps, row),
|
|
451
|
-
});
|
|
452
|
-
const conflict = casTransition(deps, row, 'pr_closed', {
|
|
453
|
-
prState: 'pr-created',
|
|
454
|
-
deliveryOutcome: 'delivered',
|
|
455
|
-
statusCode: cleanupWarnings.length > 0 ? 'cleanup_incomplete' : 'pr_closed',
|
|
456
|
-
statusDetail: null,
|
|
457
|
-
cleanupWarnings,
|
|
458
|
-
});
|
|
459
|
-
if (conflict)
|
|
460
|
-
return conflict;
|
|
461
|
-
await releaseDeliveredRecoveryLineage(deps, row, row.delivery_sha);
|
|
462
|
-
const after = finalizeTransition(deps, row.id);
|
|
463
|
-
return {
|
|
464
|
-
status: 200,
|
|
465
|
-
body: {
|
|
466
|
-
ok: true,
|
|
467
|
-
decision: 'pr_closed',
|
|
468
|
-
closed: true,
|
|
469
|
-
merged: false,
|
|
470
|
-
prUrl: after?.prUrl ?? row.pr_url,
|
|
471
|
-
prState: 'pr-created',
|
|
472
|
-
deliveryVerified: true,
|
|
473
|
-
verifiedSha: row.delivery_sha,
|
|
474
|
-
remoteHeadSha: observation.headRefOid,
|
|
475
|
-
pushed: false,
|
|
476
|
-
},
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
function persistRetryablePrObservationFailure(deps, row, detail) {
|
|
480
|
-
const conflict = casTransition(deps, row, 'pr_failed', {
|
|
481
|
-
deliveryOutcome: 'retryable_failure',
|
|
482
|
-
statusCode: 'push_failed',
|
|
483
|
-
statusDetail: detail,
|
|
484
|
-
});
|
|
485
|
-
if (conflict)
|
|
486
|
-
return conflict;
|
|
487
|
-
finalizeTransition(deps, row.id);
|
|
488
|
-
return { status: 200, body: { ok: true, decision: 'pr_failed', prUrl: row.pr_url, detail } };
|
|
489
|
-
}
|
|
490
|
-
function persistMovedPrHead(deps, row, detail) {
|
|
491
|
-
const conflict = casTransition(deps, row, 'pr_failed', {
|
|
492
|
-
deliveryOutcome: 'blocked',
|
|
493
|
-
statusCode: 'branch_verification_failed',
|
|
494
|
-
statusDetail: detail,
|
|
495
|
-
});
|
|
496
|
-
if (conflict)
|
|
497
|
-
return conflict;
|
|
498
|
-
finalizeTransition(deps, row.id);
|
|
499
|
-
return { status: 200, body: { ok: true, decision: 'pr_failed', prUrl: row.pr_url, detail } };
|
|
500
|
-
}
|
|
501
|
-
function persistRecoveryBlocked(deps, row, detail, deliverySha) {
|
|
502
|
-
const conflict = casTransition(deps, row, 'pr_failed', {
|
|
503
|
-
deliveryOutcome: 'blocked',
|
|
504
|
-
statusCode: 'recovery_unavailable',
|
|
505
|
-
statusDetail: detail,
|
|
506
|
-
...(deliverySha !== undefined ? { deliverySha } : {}),
|
|
507
|
-
isContinuation: true,
|
|
508
|
-
});
|
|
509
|
-
if (conflict)
|
|
510
|
-
return conflict;
|
|
511
|
-
finalizeTransition(deps, row.id);
|
|
512
|
-
return {
|
|
513
|
-
status: 200,
|
|
514
|
-
body: {
|
|
515
|
-
ok: true,
|
|
516
|
-
decision: 'pr_failed',
|
|
517
|
-
prUrl: row.pr_url,
|
|
518
|
-
recoveryUnavailable: true,
|
|
519
|
-
detail,
|
|
520
|
-
},
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
|
-
function persistProtectedRecoveryRetry(deps, row, snap, matchingUnitIndexes, runId, candidateSha, detail) {
|
|
524
|
-
const recoveredIndexes = new Set(matchingUnitIndexes);
|
|
525
|
-
const recoveredBranches = snap.branches.map((unit, index) => recoveredIndexes.has(index)
|
|
526
|
-
? {
|
|
527
|
-
...unit,
|
|
528
|
-
runId,
|
|
529
|
-
branch: row.branch,
|
|
530
|
-
succeeded: true,
|
|
531
|
-
implementationOutcome: 'succeeded',
|
|
532
|
-
deliveryOutcome: 'ready',
|
|
533
|
-
finalSha: candidateSha,
|
|
534
|
-
changed: unit.changed ?? (unit.initialSha ? unit.initialSha.toLowerCase() !== candidateSha.toLowerCase() : true),
|
|
535
|
-
failureCode: null,
|
|
536
|
-
branchOwnership: 'borrowed-pr',
|
|
537
|
-
}
|
|
538
|
-
: unit);
|
|
539
|
-
const conflict = casTransition(deps, row, 'pr_failed', {
|
|
540
|
-
branches: recoveredBranches,
|
|
541
|
-
deliverySha: candidateSha,
|
|
542
|
-
deliveryOutcome: 'retryable_failure',
|
|
543
|
-
statusCode: 'settlement_interrupted',
|
|
544
|
-
statusDetail: detail,
|
|
545
|
-
isContinuation: true,
|
|
546
|
-
});
|
|
547
|
-
if (conflict)
|
|
548
|
-
return conflict;
|
|
549
|
-
finalizeTransition(deps, row.id);
|
|
550
|
-
return {
|
|
551
|
-
status: 200,
|
|
552
|
-
body: {
|
|
553
|
-
ok: true,
|
|
554
|
-
decision: 'pr_failed',
|
|
555
|
-
prUrl: row.pr_url,
|
|
556
|
-
recovered: true,
|
|
557
|
-
detail,
|
|
558
|
-
},
|
|
559
|
-
};
|
|
560
|
-
}
|
|
561
|
-
async function exactRefSha(deps, cwd, ref) {
|
|
562
|
-
try {
|
|
563
|
-
const result = await deps.git.run(['rev-parse', '--verify', ref], cwd);
|
|
564
|
-
const sha = result.code === 0 ? result.stdout.trim() : '';
|
|
565
|
-
return COMMIT_SHA_RE.test(sha) ? sha : null;
|
|
566
|
-
}
|
|
567
|
-
catch {
|
|
568
|
-
return null;
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
async function isFastForwardCandidate(deps, baselineSha, candidateSha) {
|
|
572
|
-
if (baselineSha === candidateSha)
|
|
573
|
-
return true;
|
|
574
|
-
try {
|
|
575
|
-
return (await deps.git.run(['merge-base', '--is-ancestor', baselineSha, candidateSha], deps.project.path)).code === 0;
|
|
576
|
-
}
|
|
577
|
-
catch {
|
|
578
|
-
return false;
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
async function releaseDeliveredRecoveryLineage(deps, row, deliveredSha) {
|
|
582
|
-
if (!deliveredSha || !COMMIT_SHA_RE.test(deliveredSha))
|
|
583
|
-
return;
|
|
584
|
-
const currentProtection = await (0, rail_pr_recovery_git_1.inspectRecoveryCommitProtection)(deps.git, deps.project.path, row.id);
|
|
585
|
-
if (currentProtection.kind === 'present' &&
|
|
586
|
-
currentProtection.sha.toLowerCase() === deliveredSha.toLowerCase()) {
|
|
587
|
-
await (0, rail_pr_recovery_git_1.releaseRecoveryCommit)(deps.git, deps.project.path, row.id, deliveredSha);
|
|
588
|
-
}
|
|
589
|
-
const visited = new Set([row.id]);
|
|
590
|
-
let predecessorId = row.supersedes_delivery_id;
|
|
591
|
-
for (let depth = 0; predecessorId && depth < 32 && !visited.has(predecessorId); depth++) {
|
|
592
|
-
visited.add(predecessorId);
|
|
593
|
-
const predecessor = (0, rail_pr_store_1.getPrDelivery)(deps.db, predecessorId);
|
|
594
|
-
if (!predecessor)
|
|
595
|
-
break;
|
|
596
|
-
if (predecessor.decision !== 'discarded' && predecessor.decision !== 'superseded')
|
|
597
|
-
break;
|
|
598
|
-
const protection = await (0, rail_pr_recovery_git_1.inspectRecoveryCommitProtection)(deps.git, deps.project.path, predecessor.id);
|
|
599
|
-
if (protection.kind === 'present' && await isFastForwardCandidate(deps, protection.sha, deliveredSha)) {
|
|
600
|
-
await (0, rail_pr_recovery_git_1.releaseRecoveryCommit)(deps.git, deps.project.path, predecessor.id, protection.sha);
|
|
601
|
-
}
|
|
602
|
-
predecessorId = predecessor.supersedes_delivery_id;
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
function parseRegisteredGitWorktrees(stdout) {
|
|
606
|
-
const worktrees = [];
|
|
607
|
-
let current = null;
|
|
608
|
-
const finish = () => {
|
|
609
|
-
if (current)
|
|
610
|
-
worktrees.push(current);
|
|
611
|
-
current = null;
|
|
612
|
-
};
|
|
613
|
-
for (const field of stdout.split('\0')) {
|
|
614
|
-
if (field === '') {
|
|
615
|
-
finish();
|
|
616
|
-
continue;
|
|
617
|
-
}
|
|
618
|
-
if (field.startsWith('worktree ')) {
|
|
619
|
-
finish();
|
|
620
|
-
current = { worktreePath: field.slice('worktree '.length), head: null, branch: null };
|
|
621
|
-
continue;
|
|
622
|
-
}
|
|
623
|
-
if (!current)
|
|
624
|
-
continue;
|
|
625
|
-
if (field.startsWith('HEAD '))
|
|
626
|
-
current.head = field.slice('HEAD '.length);
|
|
627
|
-
if (field.startsWith('branch '))
|
|
628
|
-
current.branch = field.slice('branch '.length);
|
|
629
|
-
}
|
|
630
|
-
finish();
|
|
631
|
-
return worktrees;
|
|
632
|
-
}
|
|
633
|
-
/** Authenticate a ledger path as a live, non-symlink worktree registered by
|
|
634
|
-
* this exact repository. The main checkout and ordinary directories fail
|
|
635
|
-
* closed. Callers repeat this immediately before staging to close the useful
|
|
636
|
-
* path-replacement window between inspection and mutation. */
|
|
637
|
-
async function authenticateRecoveryWorktree(deps, ledgerPath, expectedBranch, expectedHead) {
|
|
638
|
-
if (!path.isAbsolute(ledgerPath)) {
|
|
639
|
-
return { ok: false, detail: 'the recorded worktree path is not absolute' };
|
|
640
|
-
}
|
|
641
|
-
let realPath;
|
|
642
|
-
let projectRealPath;
|
|
643
|
-
try {
|
|
644
|
-
const ledgerStat = fs.lstatSync(ledgerPath);
|
|
645
|
-
if (ledgerStat.isSymbolicLink()) {
|
|
646
|
-
return { ok: false, detail: 'the recorded worktree path is a symbolic link' };
|
|
647
|
-
}
|
|
648
|
-
if (!ledgerStat.isDirectory()) {
|
|
649
|
-
return { ok: false, detail: 'the recorded worktree path is not a directory' };
|
|
650
|
-
}
|
|
651
|
-
realPath = fs.realpathSync(ledgerPath);
|
|
652
|
-
projectRealPath = fs.realpathSync(deps.project.path);
|
|
653
|
-
if (!fs.statSync(realPath).isDirectory()) {
|
|
654
|
-
return { ok: false, detail: 'the canonical worktree path is not a directory' };
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
catch {
|
|
658
|
-
return { ok: false, detail: 'the recorded worktree path could not be resolved safely' };
|
|
659
|
-
}
|
|
660
|
-
if (realPath === projectRealPath) {
|
|
661
|
-
return { ok: false, detail: 'the recorded worktree path resolves to the main project checkout' };
|
|
662
|
-
}
|
|
663
|
-
let listed;
|
|
664
|
-
try {
|
|
665
|
-
listed = await deps.git.run(['worktree', 'list', '--porcelain', '-z'], deps.project.path);
|
|
666
|
-
}
|
|
667
|
-
catch {
|
|
668
|
-
return { ok: false, detail: 'Git worktree registration could not be verified' };
|
|
669
|
-
}
|
|
670
|
-
if (listed.code !== 0) {
|
|
671
|
-
return { ok: false, detail: 'Git worktree registration could not be verified' };
|
|
672
|
-
}
|
|
673
|
-
const matches = parseRegisteredGitWorktrees(listed.stdout).filter((registered) => {
|
|
674
|
-
try {
|
|
675
|
-
return fs.realpathSync(registered.worktreePath) === realPath;
|
|
676
|
-
}
|
|
677
|
-
catch {
|
|
678
|
-
return false;
|
|
679
|
-
}
|
|
680
|
-
});
|
|
681
|
-
if (matches.length !== 1) {
|
|
682
|
-
return { ok: false, detail: 'the recorded path is not uniquely registered as a Git worktree of this project' };
|
|
683
|
-
}
|
|
684
|
-
const registered = matches[0];
|
|
685
|
-
if (registered.branch !== `refs/heads/${expectedBranch}`) {
|
|
686
|
-
return { ok: false, detail: 'the registered Git worktree is not on the recorded delivery branch' };
|
|
687
|
-
}
|
|
688
|
-
if (!registered.head || !COMMIT_SHA_RE.test(registered.head)) {
|
|
689
|
-
return { ok: false, detail: 'the registered Git worktree has no verifiable commit identity' };
|
|
690
|
-
}
|
|
691
|
-
if (expectedHead && registered.head.toLowerCase() !== expectedHead.toLowerCase()) {
|
|
692
|
-
return { ok: false, detail: 'the registered Git worktree changed commits before staging' };
|
|
693
|
-
}
|
|
694
|
-
return { ok: true, realPath, head: registered.head };
|
|
695
|
-
}
|
|
696
|
-
/**
|
|
697
|
-
* Explicit recovery for a legacy successful continuation whose automatic
|
|
698
|
-
* causal scan could not freeze a commit. Unlike Checkout, this operates only
|
|
699
|
-
* on the delivery-owned isolated worktree/branch and never changes the user's
|
|
700
|
-
* main checkout. The user's confirmation authorizes adopting already-committed
|
|
701
|
-
* progress on that exact branch, but only as a non-force fast-forward of the
|
|
702
|
-
* live PR head.
|
|
703
|
-
*/
|
|
704
|
-
async function runRecoverAndRetry(deps, row) {
|
|
705
|
-
if (!row.pr_url || !row.branch || !row.operation_token)
|
|
706
|
-
return illegalAction(row.decision);
|
|
707
|
-
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
708
|
-
const runIds = [...new Set(snap.runIds.filter((runId) => typeof runId === 'string' && runId.length > 0))];
|
|
709
|
-
if (runIds.length !== 1) {
|
|
710
|
-
return persistRecoveryBlocked(deps, row, `Commit & retry push requires exactly one recorded implementation run; found ${runIds.length}. No local evidence was changed.`);
|
|
711
|
-
}
|
|
712
|
-
const runId = runIds[0];
|
|
713
|
-
const matchingUnitIndexes = snap.branches
|
|
714
|
-
.map((branch, index) => branch.branch === row.branch && branch.runId === runId ? index : -1)
|
|
715
|
-
.filter((index) => index >= 0);
|
|
716
|
-
if (matchingUnitIndexes.length === 0) {
|
|
717
|
-
return persistRecoveryBlocked(deps, row, 'Commit & retry push could not prove an exact recorded unit for this run and PR branch. No local evidence was changed.');
|
|
718
|
-
}
|
|
719
|
-
const matchingUnits = matchingUnitIndexes.map((index) => snap.branches[index]);
|
|
720
|
-
const recordedFinalValues = matchingUnits.map((unit) => unit.finalSha);
|
|
721
|
-
const hasAnyRecordedFinal = recordedFinalValues.some((sha) => sha != null);
|
|
722
|
-
const recordedFinalShas = [...new Set(recordedFinalValues
|
|
723
|
-
.filter((sha) => typeof sha === 'string' && COMMIT_SHA_RE.test(sha))
|
|
724
|
-
.map((sha) => sha.toLowerCase()))];
|
|
725
|
-
const recordedFinalEvidenceUnsafe = hasAnyRecordedFinal && (recordedFinalValues.some((sha) => typeof sha !== 'string' || !COMMIT_SHA_RE.test(sha)) ||
|
|
726
|
-
recordedFinalShas.length !== 1);
|
|
727
|
-
// Durable final evidence is one sufficient ownership proof, not a veto over
|
|
728
|
-
// the independent exact run-marker proof. Incomplete/disagreeing migrated
|
|
729
|
-
// unit fields are ignored here; a unique causal commit may still recover the
|
|
730
|
-
// result without adopting any of those disputed SHAs.
|
|
731
|
-
const recordedFinalSha = recordedFinalEvidenceUnsafe ? null : recordedFinalShas[0] ?? null;
|
|
732
|
-
let discovery = null;
|
|
733
|
-
const discoverCandidate = async () => {
|
|
734
|
-
if (!discovery) {
|
|
735
|
-
discovery = await (0, rail_pr_recovery_git_1.discoverRunMarkedCommit)(deps.git, deps.project.path, runId);
|
|
736
|
-
}
|
|
737
|
-
return discovery;
|
|
738
|
-
};
|
|
739
|
-
const ownedWorktrees = snap.worktreeIds
|
|
740
|
-
.map((worktreeId) => (0, rail_worktrees_store_1.getRailWorktree)(deps.db, worktreeId))
|
|
741
|
-
.filter((worktree) => Boolean(worktree && worktree.branch === row.branch && worktree.run_id === runId));
|
|
742
|
-
const presentWorktrees = ownedWorktrees.filter((worktree) => {
|
|
743
|
-
try {
|
|
744
|
-
fs.lstatSync(worktree.worktree_path);
|
|
745
|
-
return true;
|
|
746
|
-
}
|
|
747
|
-
catch {
|
|
748
|
-
return false;
|
|
749
|
-
}
|
|
750
|
-
});
|
|
751
|
-
const worktree = presentWorktrees[0] ?? null;
|
|
752
|
-
let preprotectedCandidateSha = null;
|
|
753
|
-
let preprotectedCandidateSource = null;
|
|
754
|
-
// Discover and pin an exact object before any network dependency, even when
|
|
755
|
-
// a ledger path still exists. A recreated/unsafe/dirty path must not hide an
|
|
756
|
-
// older run-owned orphan or leave it exposed to Git GC.
|
|
757
|
-
{
|
|
758
|
-
if (recordedFinalSha && await commitObjectExists(deps, recordedFinalSha)) {
|
|
759
|
-
preprotectedCandidateSha = recordedFinalSha;
|
|
760
|
-
preprotectedCandidateSource = 'recorded-final';
|
|
761
|
-
}
|
|
762
|
-
else {
|
|
763
|
-
const found = await discoverCandidate();
|
|
764
|
-
if (found.kind === 'ambiguous') {
|
|
765
|
-
return persistRecoveryBlocked(deps, row, `Git found ${found.count} different run-marked commits. No ambiguous object, branch, or worktree was changed.`);
|
|
766
|
-
}
|
|
767
|
-
if (found.kind === 'scan_failed') {
|
|
768
|
-
return persistRecoveryBlocked(deps, row, `${found.detail}. No branch, worktree, or object was changed.`);
|
|
769
|
-
}
|
|
770
|
-
if (found.kind === 'unique') {
|
|
771
|
-
preprotectedCandidateSha = found.sha;
|
|
772
|
-
preprotectedCandidateSource = 'run-marker';
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
if (preprotectedCandidateSha) {
|
|
776
|
-
const protection = await (0, rail_pr_recovery_git_1.protectRecoveryCommit)(deps.git, deps.project.path, row.id, preprotectedCandidateSha);
|
|
777
|
-
if (!protection.ok) {
|
|
778
|
-
return persistRecoveryBlocked(deps, row, `${protection.detail}. The candidate object and every user-visible branch were left unchanged.`);
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
if (presentWorktrees.length > 1) {
|
|
783
|
-
return persistRecoveryBlocked(deps, row, 'Multiple live worktrees claim this run and branch, so Specrails refused to choose one. Every local result remains intact.', preprotectedCandidateSha);
|
|
784
|
-
}
|
|
785
|
-
const observed = await (0, pr_lifecycle_1.observePrLifecycle)(deps.exec, deps.project.path, row.pr_url, preprotectedCandidateSha);
|
|
786
|
-
if (!observed.ok) {
|
|
787
|
-
if (preprotectedCandidateSha) {
|
|
788
|
-
if (worktree) {
|
|
789
|
-
return persistRecoveryBlocked(deps, row, `Protected an exact run-owned commit, but a live worktree also exists and the PR could not be observed: ${observed.detail}. Both local results were preserved and neither was selected or pushed.`, preprotectedCandidateSha);
|
|
790
|
-
}
|
|
791
|
-
const durablyProvenNoChange = row.implementation_outcome === 'succeeded' &&
|
|
792
|
-
matchingUnitIndexes.length === snap.branches.length &&
|
|
793
|
-
matchingUnits.every((unit) => (unit.changed === false &&
|
|
794
|
-
unit.initialSha?.toLowerCase() === preprotectedCandidateSha.toLowerCase() &&
|
|
795
|
-
unit.finalSha?.toLowerCase() === preprotectedCandidateSha.toLowerCase()));
|
|
796
|
-
const detail = `Recovered and protected the exact run-owned commit, but the recorded PR could not be observed: ${observed.detail}; retry will revalidate before any push.`;
|
|
797
|
-
if (durablyProvenNoChange) {
|
|
798
|
-
return persistRecoveryBlocked(deps, row, detail, preprotectedCandidateSha);
|
|
799
|
-
}
|
|
800
|
-
return persistProtectedRecoveryRetry(deps, row, snap, matchingUnitIndexes, runId, preprotectedCandidateSha, detail);
|
|
801
|
-
}
|
|
802
|
-
return persistRecoveryBlocked(deps, row, `The existing PR could not be verified before local recovery: ${observed.detail}. No local evidence was changed.`);
|
|
803
|
-
}
|
|
804
|
-
if (!(0, pr_lifecycle_1.isExactOpenPr)(observed, row.branch, row.base_branch) || !observed.headRefOid) {
|
|
805
|
-
if (preprotectedCandidateSha) {
|
|
806
|
-
if (worktree) {
|
|
807
|
-
return persistRecoveryBlocked(deps, row, 'A protected run-owned commit and a live worktree both exist while the recorded PR lifecycle changed. Specrails preserved both and refused to choose or push either automatically.', preprotectedCandidateSha);
|
|
808
|
-
}
|
|
809
|
-
const frozen = persistProtectedRecoveryRetry(deps, row, snap, matchingUnitIndexes, runId, preprotectedCandidateSha, 'Protected the exact run-owned commit; revalidating the changed PR lifecycle before any push.');
|
|
810
|
-
if (frozen.status !== 200 || frozen.body.decision !== 'pr_failed')
|
|
811
|
-
return frozen;
|
|
812
|
-
const recoveredRow = (0, rail_pr_store_1.getPrDelivery)(deps.db, row.id);
|
|
813
|
-
if (!recoveredRow || recoveredRow.operation_token !== row.operation_token) {
|
|
814
|
-
return staleDecision(recoveredRow?.decision ?? row.decision);
|
|
815
|
-
}
|
|
816
|
-
return retryExistingPrFollowupPush(deps, recoveredRow);
|
|
817
|
-
}
|
|
818
|
-
return persistRecoveryBlocked(deps, row, 'The recorded PR is no longer an exact open head/base recovery target. No local evidence was changed.');
|
|
819
|
-
}
|
|
820
|
-
const baselineSha = observed.headRefOid;
|
|
821
|
-
// Ensure the live remote baseline object is available for the ancestry proof
|
|
822
|
-
// without advancing any local branch or touching the main checkout.
|
|
823
|
-
if (!(await commitObjectExists(deps, baselineSha))) {
|
|
824
|
-
try {
|
|
825
|
-
await deps.git.run(['fetch', 'origin', `refs/heads/${row.branch}`], deps.project.path);
|
|
826
|
-
}
|
|
827
|
-
catch { /* the exact object check below remains authoritative */ }
|
|
828
|
-
}
|
|
829
|
-
if (!(await commitObjectExists(deps, baselineSha))) {
|
|
830
|
-
return persistRecoveryBlocked(deps, row, 'The live PR head object is unavailable locally, so fast-forward recovery cannot be proven. No local evidence was changed.', preprotectedCandidateSha);
|
|
831
|
-
}
|
|
832
|
-
let candidateSha = preprotectedCandidateSha;
|
|
833
|
-
let candidateOwned = preprotectedCandidateSha !== null;
|
|
834
|
-
let candidateSource = preprotectedCandidateSource ?? 'baseline';
|
|
835
|
-
let committed = false;
|
|
836
|
-
if (worktree) {
|
|
837
|
-
const authenticated = await authenticateRecoveryWorktree(deps, worktree.worktree_path, row.branch);
|
|
838
|
-
if (!authenticated.ok) {
|
|
839
|
-
return persistRecoveryBlocked(deps, row, `The preserved path at ${worktree.worktree_path} could not be authenticated safely: ${authenticated.detail}. Nothing was staged, pushed, or removed.`, preprotectedCandidateSha);
|
|
840
|
-
}
|
|
841
|
-
const authenticatedPath = authenticated.realPath;
|
|
842
|
-
const [actualBranch, worktreeHead, branchHead] = await Promise.all([
|
|
843
|
-
deps.git.run(['rev-parse', '--abbrev-ref', 'HEAD'], authenticatedPath).catch(() => ({ code: 1, stdout: '', stderr: '' })),
|
|
844
|
-
exactRefSha(deps, authenticatedPath, 'HEAD'),
|
|
845
|
-
exactRefSha(deps, deps.project.path, `refs/heads/${row.branch}`),
|
|
846
|
-
]);
|
|
847
|
-
if (actualBranch.code !== 0 || actualBranch.stdout.trim() !== row.branch ||
|
|
848
|
-
!worktreeHead || !branchHead || worktreeHead !== branchHead ||
|
|
849
|
-
authenticated.head.toLowerCase() !== worktreeHead.toLowerCase()) {
|
|
850
|
-
return persistRecoveryBlocked(deps, row, `The preserved worktree at ${worktree.worktree_path} is not on the exact recorded branch/HEAD. It was left untouched.`, preprotectedCandidateSha);
|
|
851
|
-
}
|
|
852
|
-
if (preprotectedCandidateSha &&
|
|
853
|
-
worktreeHead.toLowerCase() !== preprotectedCandidateSha.toLowerCase()) {
|
|
854
|
-
return persistRecoveryBlocked(deps, row, `A protected run-owned commit and a different live worktree result both exist. Specrails preserved both and refused to choose or bundle them automatically. Inspect ${worktree.worktree_path} before retrying or discarding either result.`, preprotectedCandidateSha);
|
|
855
|
-
}
|
|
856
|
-
if (!(await isFastForwardCandidate(deps, baselineSha, worktreeHead))) {
|
|
857
|
-
return persistRecoveryBlocked(deps, row, `The preserved branch diverges from the live PR head. The worktree at ${worktree.worktree_path} was left untouched.`, preprotectedCandidateSha);
|
|
858
|
-
}
|
|
859
|
-
let skipWorktreeCommit = false;
|
|
860
|
-
if (worktreeHead === baselineSha) {
|
|
861
|
-
candidateOwned = true;
|
|
862
|
-
candidateSource = 'baseline';
|
|
863
|
-
}
|
|
864
|
-
else if (recordedFinalSha && worktreeHead.toLowerCase() === recordedFinalSha) {
|
|
865
|
-
candidateOwned = true;
|
|
866
|
-
candidateSource = 'recorded-final';
|
|
867
|
-
}
|
|
868
|
-
else {
|
|
869
|
-
const found = await discoverCandidate();
|
|
870
|
-
if (found.kind === 'unique' && found.sha === worktreeHead.toLowerCase()) {
|
|
871
|
-
candidateOwned = true;
|
|
872
|
-
candidateSource = 'run-marker';
|
|
873
|
-
}
|
|
874
|
-
else if (found.kind === 'unique') {
|
|
875
|
-
// A later/unrelated checked-out tip must not be bundled into the
|
|
876
|
-
// recovered result. Preserve it untouched and use only the exact
|
|
877
|
-
// run-owned object discovered independently.
|
|
878
|
-
candidateSha = found.sha;
|
|
879
|
-
candidateOwned = true;
|
|
880
|
-
candidateSource = 'run-marker';
|
|
881
|
-
skipWorktreeCommit = true;
|
|
882
|
-
}
|
|
883
|
-
else {
|
|
884
|
-
const reason = found.kind === 'ambiguous'
|
|
885
|
-
? `Git found ${found.count} run-marked commits, so no unique worktree baseline could be chosen.`
|
|
886
|
-
: found.kind === 'scan_failed'
|
|
887
|
-
? `${found.detail}.`
|
|
888
|
-
: 'The preserved worktree branch advanced beyond every delivery-owned commit.';
|
|
889
|
-
return persistRecoveryBlocked(deps, row, `${reason} The worktree at ${worktree.worktree_path} was left untouched.`);
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
if (!skipWorktreeCommit) {
|
|
893
|
-
deps.assertAdmission?.();
|
|
894
|
-
const reauthenticated = await authenticateRecoveryWorktree(deps, worktree.worktree_path, row.branch, worktreeHead);
|
|
895
|
-
if (!reauthenticated.ok || reauthenticated.realPath !== authenticatedPath) {
|
|
896
|
-
const detail = reauthenticated.ok
|
|
897
|
-
? 'the canonical worktree path changed before staging'
|
|
898
|
-
: reauthenticated.detail;
|
|
899
|
-
return persistRecoveryBlocked(deps, row, `The preserved path at ${worktree.worktree_path} failed final authentication: ${detail}. Nothing was staged, pushed, or removed.`);
|
|
900
|
-
}
|
|
901
|
-
deps.assertAdmission?.();
|
|
902
|
-
const overlayExcludes = [...new Set(matchingUnits.flatMap((unit) => unit.overlayExcludes ?? []))];
|
|
903
|
-
const commit = await (0, worktree_manager_1.commitWorktreeAndVerify)(deps.git, reauthenticated.realPath, `specrails: recovered follow-up (run ${runId})`, overlayExcludes);
|
|
904
|
-
if (!commit.clean) {
|
|
905
|
-
return persistRecoveryBlocked(deps, row, `The preserved worktree could not be committed safely: ${commit.error ?? (commit.dirty.join(', ') || 'deliverable changes remain')}. It was left intact at ${worktree.worktree_path}.`);
|
|
906
|
-
}
|
|
907
|
-
committed = commit.committed;
|
|
908
|
-
const [afterBranch, afterHead, afterBranchHead] = await Promise.all([
|
|
909
|
-
deps.git.run(['rev-parse', '--abbrev-ref', 'HEAD'], reauthenticated.realPath).catch(() => ({ code: 1, stdout: '', stderr: '' })),
|
|
910
|
-
exactRefSha(deps, reauthenticated.realPath, 'HEAD'),
|
|
911
|
-
exactRefSha(deps, deps.project.path, `refs/heads/${row.branch}`),
|
|
912
|
-
]);
|
|
913
|
-
if (afterBranch.code !== 0 || afterBranch.stdout.trim() !== row.branch ||
|
|
914
|
-
!afterHead || afterHead !== afterBranchHead) {
|
|
915
|
-
return persistRecoveryBlocked(deps, row, `The recovery commit exists, but final branch verification failed. It remains preserved at ${worktree.worktree_path}.`);
|
|
916
|
-
}
|
|
917
|
-
candidateSha = afterHead;
|
|
918
|
-
if (committed) {
|
|
919
|
-
candidateOwned = true;
|
|
920
|
-
candidateSource = 'worktree';
|
|
921
|
-
}
|
|
922
|
-
if (candidateSource === 'baseline' && recordedFinalSha && afterHead.toLowerCase() === recordedFinalSha) {
|
|
923
|
-
candidateSource = 'recorded-final';
|
|
924
|
-
}
|
|
925
|
-
candidateOwned = candidateOwned || committed || candidateSource !== 'baseline' || afterHead === baselineSha;
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
else {
|
|
929
|
-
candidateSha ??= await exactRefSha(deps, deps.project.path, `refs/heads/${row.branch}`);
|
|
930
|
-
}
|
|
931
|
-
// A consistent durable final SHA outranks a mutable branch name. This also
|
|
932
|
-
// lets another checkout recover an object whose branch was later reset.
|
|
933
|
-
if (!committed && recordedFinalSha &&
|
|
934
|
-
await commitObjectExists(deps, recordedFinalSha) &&
|
|
935
|
-
(!candidateOwned || candidateSha === baselineSha)) {
|
|
936
|
-
candidateSha = recordedFinalSha;
|
|
937
|
-
candidateOwned = true;
|
|
938
|
-
candidateSource = 'recorded-final';
|
|
939
|
-
}
|
|
940
|
-
// Before claiming absence, inspect the same complete causal surface as
|
|
941
|
-
// startup. A unique run-marked object may survive after both the worktree and
|
|
942
|
-
// visible branch were removed.
|
|
943
|
-
const baselineHasDurableFinalProof = candidateSha === baselineSha && recordedFinalSha === baselineSha;
|
|
944
|
-
if (!candidateSha || (candidateSha === baselineSha && !baselineHasDurableFinalProof) || !candidateOwned) {
|
|
945
|
-
const found = await discoverCandidate();
|
|
946
|
-
if (found.kind === 'ambiguous') {
|
|
947
|
-
return persistRecoveryBlocked(deps, row, `Git found ${found.count} different run-marked commits. No ambiguous object, branch, or worktree was changed.`);
|
|
948
|
-
}
|
|
949
|
-
if (found.kind === 'scan_failed') {
|
|
950
|
-
return persistRecoveryBlocked(deps, row, `${found.detail}. No branch, worktree, or object was changed.`);
|
|
951
|
-
}
|
|
952
|
-
if (found.kind === 'unique') {
|
|
953
|
-
candidateSha = found.sha;
|
|
954
|
-
candidateOwned = true;
|
|
955
|
-
candidateSource = 'run-marker';
|
|
956
|
-
}
|
|
957
|
-
else if (candidateSha && candidateSha !== baselineSha && !candidateOwned) {
|
|
958
|
-
return persistRecoveryBlocked(deps, row, 'The recorded local branch advanced beyond the run-owned result. The later branch tip was preserved and was not committed or pushed.');
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
if (!candidateSha || !(await commitObjectExists(deps, candidateSha))) {
|
|
962
|
-
return persistRecoveryBlocked(deps, row, 'No delivery-owned commit is available in this clone’s refs, reflogs, worktrees, or unreachable objects. The result may still exist on the computer where the run executed; nothing here was changed or removed.');
|
|
963
|
-
}
|
|
964
|
-
if (candidateSha === baselineSha) {
|
|
965
|
-
const provesNoChanges = row.implementation_outcome === 'succeeded' &&
|
|
966
|
-
matchingUnitIndexes.length === snap.branches.length &&
|
|
967
|
-
matchingUnits.every((unit) => (unit.changed === false &&
|
|
968
|
-
unit.initialSha?.toLowerCase() === baselineSha.toLowerCase() &&
|
|
969
|
-
unit.finalSha?.toLowerCase() === baselineSha.toLowerCase()));
|
|
970
|
-
if (provesNoChanges) {
|
|
971
|
-
const noChangeIndexes = new Set(matchingUnitIndexes);
|
|
972
|
-
const noChangeBranches = snap.branches.map((unit, index) => noChangeIndexes.has(index)
|
|
973
|
-
? {
|
|
974
|
-
...unit,
|
|
975
|
-
succeeded: true,
|
|
976
|
-
implementationOutcome: 'succeeded',
|
|
977
|
-
deliveryOutcome: 'no_changes',
|
|
978
|
-
initialSha: baselineSha,
|
|
979
|
-
finalSha: baselineSha,
|
|
980
|
-
changed: false,
|
|
981
|
-
failureCode: null,
|
|
982
|
-
}
|
|
983
|
-
: unit);
|
|
984
|
-
const conflict = casTransition(deps, row, 'no_changes', {
|
|
985
|
-
branches: noChangeBranches,
|
|
986
|
-
deliverySha: baselineSha,
|
|
987
|
-
deliveryOutcome: 'no_changes',
|
|
988
|
-
statusCode: 'no_changes',
|
|
989
|
-
statusDetail: null,
|
|
990
|
-
isContinuation: true,
|
|
991
|
-
});
|
|
992
|
-
if (conflict)
|
|
993
|
-
return conflict;
|
|
994
|
-
await releaseDeliveredRecoveryLineage(deps, row, baselineSha);
|
|
995
|
-
finalizeTransition(deps, row.id);
|
|
996
|
-
return {
|
|
997
|
-
status: 200,
|
|
998
|
-
body: {
|
|
999
|
-
ok: true,
|
|
1000
|
-
decision: 'no_changes',
|
|
1001
|
-
prUrl: row.pr_url,
|
|
1002
|
-
noChanges: true,
|
|
1003
|
-
deliveryVerified: true,
|
|
1004
|
-
verifiedSha: baselineSha,
|
|
1005
|
-
pushed: false,
|
|
1006
|
-
},
|
|
1007
|
-
};
|
|
1008
|
-
}
|
|
1009
|
-
const alreadyDelivered = recordedFinalSha === baselineSha || candidateSource === 'run-marker';
|
|
1010
|
-
if (!alreadyDelivered) {
|
|
1011
|
-
return persistRecoveryBlocked(deps, row, 'This clone contains only the current PR head and no additional run-owned result. The original execution computer may still retain its worktree or orphan commit; nothing here was changed or removed.');
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
else {
|
|
1015
|
-
const protection = preprotectedCandidateSha &&
|
|
1016
|
-
preprotectedCandidateSha.toLowerCase() !== candidateSha.toLowerCase()
|
|
1017
|
-
? await (0, rail_pr_recovery_git_1.advanceRecoveryCommitProtection)(deps.git, deps.project.path, row.id, preprotectedCandidateSha, candidateSha)
|
|
1018
|
-
: await (0, rail_pr_recovery_git_1.protectRecoveryCommit)(deps.git, deps.project.path, row.id, candidateSha);
|
|
1019
|
-
if (!protection.ok) {
|
|
1020
|
-
return persistRecoveryBlocked(deps, row, `${protection.detail}. The candidate object and every user-visible branch were left unchanged.`, preprotectedCandidateSha);
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
if (!(await isFastForwardCandidate(deps, baselineSha, candidateSha))) {
|
|
1024
|
-
return persistRecoveryBlocked(deps, row, 'The exact run-owned recovery commit is not a fast-forward of the live PR head. It remains protected locally and nothing was pushed or removed.', candidateSha);
|
|
1025
|
-
}
|
|
1026
|
-
const recoveredIndexes = new Set(matchingUnitIndexes);
|
|
1027
|
-
const recoveredBranches = snap.branches.map((unit, index) => recoveredIndexes.has(index)
|
|
1028
|
-
? {
|
|
1029
|
-
...unit,
|
|
1030
|
-
runId,
|
|
1031
|
-
branch: row.branch,
|
|
1032
|
-
succeeded: true,
|
|
1033
|
-
implementationOutcome: 'succeeded',
|
|
1034
|
-
deliveryOutcome: 'ready',
|
|
1035
|
-
// Equality with the live PR head can mean the exact run commit was
|
|
1036
|
-
// already delivered; it is not no-change evidence. Preserve a durable
|
|
1037
|
-
// initial SHA when available and infer the observed baseline only for
|
|
1038
|
-
// a still-missing descendant. The proven no-change branch returned
|
|
1039
|
-
// above before reaching this mapping.
|
|
1040
|
-
initialSha: unit.initialSha ?? (candidateSha !== baselineSha ? baselineSha : null),
|
|
1041
|
-
finalSha: candidateSha,
|
|
1042
|
-
changed: true,
|
|
1043
|
-
failureCode: null,
|
|
1044
|
-
branchOwnership: 'borrowed-pr',
|
|
1045
|
-
...(worktree ? { worktreePath: worktree.worktree_path } : {}),
|
|
1046
|
-
}
|
|
1047
|
-
: unit);
|
|
1048
|
-
// Freeze before the network mutation. A crash after this write leaves a
|
|
1049
|
-
// normal immutable Retry push card; a crash before it leaves the run-marked
|
|
1050
|
-
// commit discoverable by startup recovery.
|
|
1051
|
-
if (!(0, rail_pr_store_1.transitionClaimedDecision)(deps.db, row.id, row.decision, 'pr_failed', row.operation_token, {
|
|
1052
|
-
branches: recoveredBranches,
|
|
1053
|
-
deliverySha: candidateSha,
|
|
1054
|
-
deliveryOutcome: 'retryable_failure',
|
|
1055
|
-
statusCode: 'settlement_interrupted',
|
|
1056
|
-
statusDetail: candidateSource === 'worktree'
|
|
1057
|
-
? `Committed the preserved worktree as ${candidateSha.slice(0, 8)}; validating the exact PR before push.`
|
|
1058
|
-
: candidateSource === 'run-marker'
|
|
1059
|
-
? `Protected the unique run-owned commit ${candidateSha.slice(0, 8)}; validating the exact PR before push.`
|
|
1060
|
-
: `Recovered the durable final commit ${candidateSha.slice(0, 8)}; validating the exact PR before push.`,
|
|
1061
|
-
isContinuation: true,
|
|
1062
|
-
})) {
|
|
1063
|
-
const current = (0, rail_pr_store_1.getPrDelivery)(deps.db, row.id);
|
|
1064
|
-
return staleDecision(current?.decision ?? row.decision);
|
|
1065
|
-
}
|
|
1066
|
-
if (worktree)
|
|
1067
|
-
(0, rail_worktrees_store_1.updateRailWorktreeState)(deps.db, worktree.id, 'built');
|
|
1068
|
-
const recoveredRow = (0, rail_pr_store_1.getPrDelivery)(deps.db, row.id);
|
|
1069
|
-
if (!recoveredRow || recoveredRow.operation_token !== row.operation_token) {
|
|
1070
|
-
return staleDecision(recoveredRow?.decision ?? row.decision);
|
|
1071
|
-
}
|
|
1072
|
-
deps.assertAdmission?.();
|
|
1073
|
-
return retryExistingPrFollowupPush(deps, recoveredRow);
|
|
1074
|
-
}
|
|
1075
|
-
async function retryExistingPrFollowupPush(deps, row) {
|
|
1076
|
-
if (!row.branch || !row.pr_url)
|
|
1077
|
-
return { status: 502, body: { error: 'push_failed', detail: 'missing PR branch/url' } };
|
|
1078
|
-
if (!row.delivery_sha) {
|
|
1079
|
-
const detail = 'missing verified delivery SHA; refusing to resolve a mutable branch for retry';
|
|
1080
|
-
const conflict = casTransition(deps, row, 'pr_failed', {
|
|
1081
|
-
deliveryOutcome: 'blocked',
|
|
1082
|
-
statusCode: 'branch_verification_failed',
|
|
1083
|
-
statusDetail: detail,
|
|
1084
|
-
});
|
|
1085
|
-
if (conflict)
|
|
1086
|
-
return conflict;
|
|
1087
|
-
finalizeTransition(deps, row.id);
|
|
1088
|
-
return { status: 409, body: { error: 'missing_verified_sha', decision: 'pr_failed', prUrl: row.pr_url, detail } };
|
|
1089
|
-
}
|
|
1090
|
-
const beforePush = await (0, pr_lifecycle_1.observePrLifecycle)(deps.exec, deps.project.path, row.pr_url, row.delivery_sha);
|
|
1091
|
-
if (!beforePush.ok) {
|
|
1092
|
-
return persistRetryablePrObservationFailure(deps, row, `could not confirm the existing PR is open before retry: ${beforePush.detail}`);
|
|
1093
|
-
}
|
|
1094
|
-
if (!(0, pr_lifecycle_1.isExactOpenPr)(beforePush, row.branch, row.base_branch)) {
|
|
1095
|
-
const identityMatches = (0, pr_lifecycle_1.matchesRecordedPrIdentity)(beforePush, row.branch, row.base_branch);
|
|
1096
|
-
if (beforePush.state === 'MERGED' && identityMatches && beforePush.includesExpectedSha === true) {
|
|
1097
|
-
return settleMergedPr(deps, row);
|
|
1098
|
-
}
|
|
1099
|
-
if (beforePush.state === 'MERGED') {
|
|
1100
|
-
return rerouteFromStalePr(deps, row, identityMatches ? mergedEvidenceDetail(beforePush) : 'the previous PR merged after its recorded head/base identity changed', false);
|
|
1101
|
-
}
|
|
1102
|
-
if (beforePush.state === 'CLOSED') {
|
|
1103
|
-
if (identityMatches && beforePush.includesExpectedSha === true) {
|
|
1104
|
-
return settleObservedClosedPr(deps, row, beforePush);
|
|
1105
|
-
}
|
|
1106
|
-
return rerouteFromStalePr(deps, row, 'the previous PR closed before this implementation was delivered', false);
|
|
1107
|
-
}
|
|
1108
|
-
return rerouteFromStalePr(deps, row, 'the existing open PR no longer matches its recorded head/base identity', false);
|
|
1109
|
-
}
|
|
1110
|
-
if (beforePush.includesExpectedSha === true) {
|
|
1111
|
-
return settleObservedExistingPr(deps, row, beforePush, false);
|
|
1112
|
-
}
|
|
1113
|
-
if (!beforePush.headRefOid) {
|
|
1114
|
-
return persistRetryablePrObservationFailure(deps, row, 'the open PR did not expose a verifiable head commit; Retry push will revalidate before mutation');
|
|
1115
|
-
}
|
|
1116
|
-
if (!(await commitObjectExists(deps, beforePush.headRefOid))) {
|
|
1117
|
-
try {
|
|
1118
|
-
await deps.git.run(['fetch', 'origin', `refs/heads/${row.branch}`], deps.project.path);
|
|
1119
|
-
}
|
|
1120
|
-
catch { /* the immutable object check below remains authoritative */ }
|
|
1121
|
-
}
|
|
1122
|
-
if (!(await commitObjectExists(deps, beforePush.headRefOid))) {
|
|
1123
|
-
return persistRetryablePrObservationFailure(deps, row, 'the live PR head object is unavailable locally; Retry push will revalidate before mutation');
|
|
1124
|
-
}
|
|
1125
|
-
if (!(await isFastForwardCandidate(deps, beforePush.headRefOid, row.delivery_sha))) {
|
|
1126
|
-
const detail = 'the exact delivery commit is not a fast-forward of the live PR head; it was not pushed';
|
|
1127
|
-
return row.is_continuation === 1
|
|
1128
|
-
? persistRecoveryBlocked(deps, row, `${detail} and remains protected`, row.delivery_sha)
|
|
1129
|
-
: persistMovedPrHead(deps, row, detail);
|
|
1130
|
-
}
|
|
1131
|
-
const remote = await (0, pr_lifecycle_1.verifyPushRemoteForPr)(deps.exec, deps.project.path, row.pr_url);
|
|
1132
|
-
if (!remote.ok) {
|
|
1133
|
-
return persistRetryablePrObservationFailure(deps, row, `refusing to push until the PR repository and origin are proven identical: ${remote.detail}`);
|
|
1134
|
-
}
|
|
1135
|
-
const pushed = await (0, pr_publisher_1.pushBranch)(deps.exec, {
|
|
1136
|
-
repoDir: deps.project.path,
|
|
1137
|
-
branch: row.branch,
|
|
1138
|
-
baseBranch: row.base_branch,
|
|
1139
|
-
remote: remote.pushTarget,
|
|
1140
|
-
sourceSha: row.delivery_sha,
|
|
1141
|
-
});
|
|
1142
|
-
if (pushed.state === 'local-only') {
|
|
1143
|
-
const conflict = casTransition(deps, row, 'pr_failed', {
|
|
1144
|
-
prState: 'local-only',
|
|
1145
|
-
deliveryOutcome: 'retryable_failure',
|
|
1146
|
-
statusCode: 'push_failed',
|
|
1147
|
-
statusDetail: pushed.reason,
|
|
1148
|
-
});
|
|
1149
|
-
if (conflict)
|
|
1150
|
-
return conflict;
|
|
1151
|
-
finalizeTransition(deps, row.id);
|
|
1152
|
-
return { status: 200, body: { ok: true, decision: 'pr_failed', prUrl: row.pr_url, detail: pushed.reason } };
|
|
1153
|
-
}
|
|
1154
|
-
const afterPush = await (0, pr_lifecycle_1.observePrLifecycle)(deps.exec, deps.project.path, row.pr_url, row.delivery_sha);
|
|
1155
|
-
if (!afterPush.ok) {
|
|
1156
|
-
return persistRetryablePrObservationFailure(deps, row, `exact commit was pushed, but the PR lifecycle could not be confirmed: ${afterPush.detail}`);
|
|
1157
|
-
}
|
|
1158
|
-
if ((0, pr_lifecycle_1.isExactOpenPr)(afterPush, row.branch, row.base_branch)) {
|
|
1159
|
-
if (afterPush.includesExpectedSha !== true) {
|
|
1160
|
-
return persistRetryablePrObservationFailure(deps, row, 'the exact push completed, but the PR does not yet expose the verified commit as its head; Retry push remains safe and uses the preserved SHA');
|
|
1161
|
-
}
|
|
1162
|
-
return settleObservedExistingPr(deps, row, afterPush, true);
|
|
1163
|
-
}
|
|
1164
|
-
const identityMatches = (0, pr_lifecycle_1.matchesRecordedPrIdentity)(afterPush, row.branch, row.base_branch);
|
|
1165
|
-
if (afterPush.state === 'MERGED' && identityMatches && afterPush.includesExpectedSha === true) {
|
|
1166
|
-
return settleMergedPr(deps, row);
|
|
1167
|
-
}
|
|
1168
|
-
if (afterPush.state === 'MERGED') {
|
|
1169
|
-
return rerouteFromStalePr(deps, row, identityMatches ? mergedEvidenceDetail(afterPush) : 'the previous PR merged after its recorded head/base identity changed', true);
|
|
1170
|
-
}
|
|
1171
|
-
if (afterPush.state === 'CLOSED') {
|
|
1172
|
-
if (identityMatches && afterPush.includesExpectedSha === true) {
|
|
1173
|
-
return settleObservedClosedPr(deps, row, afterPush);
|
|
1174
|
-
}
|
|
1175
|
-
return rerouteFromStalePr(deps, row, 'the previous PR closed before this implementation was delivered', true);
|
|
1176
|
-
}
|
|
1177
|
-
return rerouteFromStalePr(deps, row, 'the existing open PR no longer matches its recorded head/base identity after the exact push', true);
|
|
1178
|
-
}
|
|
1179
|
-
/**
|
|
1180
|
-
* Load the PR's ticket data (title/labels/description) from the ticket store,
|
|
1181
|
-
* with the Jira key resolved PER TICKET: the authoritative `jira_links` row
|
|
1182
|
-
* prevails over the ticket's `jira_key` field (JIRA ALWAYS PREVAILS), no HTTP.
|
|
1183
|
-
* Tolerant of a missing/corrupt store or link table — degrades to bare local
|
|
1184
|
-
* ids, never throws, never blocks PR creation.
|
|
1185
|
-
*/
|
|
1186
|
-
function loadPrTicketData(deps, ticketIds) {
|
|
1187
|
-
let tickets = {};
|
|
1188
|
-
try {
|
|
1189
|
-
tickets = (0, ticket_store_1.readStore)(resolveTicketFile(deps)).tickets;
|
|
1190
|
-
}
|
|
1191
|
-
catch {
|
|
1192
|
-
/* tolerated — the PR falls back to bare ticket refs */
|
|
1193
|
-
}
|
|
1194
|
-
return ticketIds.map((id) => {
|
|
1195
|
-
const t = tickets[String(id)];
|
|
1196
|
-
let jiraKey = t?.jira_key ?? null;
|
|
1197
|
-
try {
|
|
1198
|
-
const link = (0, jira_db_1.getLinkByLocalId)(deps.db, id);
|
|
1199
|
-
if (link && !link.tombstoned && link.jiraKey)
|
|
1200
|
-
jiraKey = link.jiraKey;
|
|
1201
|
-
}
|
|
1202
|
-
catch {
|
|
1203
|
-
/* tolerated — fall back to the ticket field */
|
|
1204
|
-
}
|
|
1205
|
-
return {
|
|
1206
|
-
ticketId: id,
|
|
1207
|
-
title: t?.title ?? null,
|
|
1208
|
-
labels: t?.labels ?? null,
|
|
1209
|
-
description: t?.description ?? null,
|
|
1210
|
-
jiraKey,
|
|
1211
|
-
};
|
|
1212
|
-
});
|
|
1213
|
-
}
|
|
1214
|
-
/** True when the branch exists as a local ref in the base repo. */
|
|
1215
|
-
async function branchExists(deps, branch) {
|
|
1216
|
-
const r = await deps.git.run(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], deps.project.path);
|
|
1217
|
-
return r.code === 0;
|
|
1218
|
-
}
|
|
1219
|
-
const COMMIT_SHA_RE = /^[0-9a-f]{40,64}$/i;
|
|
1220
|
-
async function commitObjectExists(deps, sha) {
|
|
1221
|
-
if (!COMMIT_SHA_RE.test(sha))
|
|
1222
|
-
return false;
|
|
1223
|
-
try {
|
|
1224
|
-
return (await deps.git.run(['cat-file', '-e', `${sha}^{commit}`], deps.project.path)).code === 0;
|
|
1225
|
-
}
|
|
1226
|
-
catch {
|
|
1227
|
-
return false;
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
/** Resolve a legacy mutable ref once into an immutable object. New settlement
|
|
1231
|
-
* rows already carry finalSha and never take this compatibility path. */
|
|
1232
|
-
async function captureBranchSha(deps, branch) {
|
|
1233
|
-
try {
|
|
1234
|
-
const result = await deps.git.run(['rev-parse', '--verify', `refs/heads/${branch}`], deps.project.path);
|
|
1235
|
-
const sha = result.code === 0 ? result.stdout.trim() : '';
|
|
1236
|
-
return COMMIT_SHA_RE.test(sha) ? sha : null;
|
|
1237
|
-
}
|
|
1238
|
-
catch {
|
|
1239
|
-
return null;
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
async function runCreatePr(deps, row) {
|
|
1243
|
-
if (row.decision === 'pr_failed' && row.pr_url && row.branch) {
|
|
1244
|
-
return retryExistingPrFollowupPush(deps, row);
|
|
1245
|
-
}
|
|
1246
|
-
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
1247
|
-
// Resolve every deliverable unit to an immutable object. New rows carry the
|
|
1248
|
-
// verified final SHA from settlement; legacy rows may capture their recorded
|
|
1249
|
-
// ref once. Never substitute another historical branch merely because it
|
|
1250
|
-
// belongs to the same ticket.
|
|
1251
|
-
let effectiveBranches = snap.branches;
|
|
1252
|
-
let branchEvidenceCaptured = false;
|
|
1253
|
-
const singleDeliverable = snap.branches.filter((unit) => unit.succeeded).length === 1;
|
|
1254
|
-
const frozenSingleSha = singleDeliverable && row.delivery_sha && COMMIT_SHA_RE.test(row.delivery_sha) &&
|
|
1255
|
-
await commitObjectExists(deps, row.delivery_sha)
|
|
1256
|
-
? row.delivery_sha
|
|
1257
|
-
: null;
|
|
1258
|
-
for (let index = 0; index < snap.branches.length; index++) {
|
|
1259
|
-
const unit = snap.branches[index];
|
|
1260
|
-
if (!unit.succeeded)
|
|
1261
|
-
continue;
|
|
1262
|
-
// A detached/stale single-unit delivery already has authoritative immutable
|
|
1263
|
-
// evidence. Prefer it over both legacy unit gaps and a branch ref that may
|
|
1264
|
-
// have advanced while the card waited for the user's decision.
|
|
1265
|
-
let sourceSha = frozenSingleSha ?? (unit.finalSha && COMMIT_SHA_RE.test(unit.finalSha) ? unit.finalSha : null);
|
|
1266
|
-
if (sourceSha) {
|
|
1267
|
-
if (!(await commitObjectExists(deps, sourceSha)))
|
|
1268
|
-
sourceSha = null;
|
|
1269
|
-
}
|
|
1270
|
-
else {
|
|
1271
|
-
sourceSha = await captureBranchSha(deps, unit.branch);
|
|
1272
|
-
if (sourceSha && !(await commitObjectExists(deps, sourceSha)))
|
|
1273
|
-
sourceSha = null;
|
|
1274
|
-
}
|
|
1275
|
-
if (!sourceSha) {
|
|
1276
|
-
const detail = `the verified commit for branch '${unit.branch}' (ticket #${unit.ticketId}) is unavailable; refusing to deliver a mutable or unrelated ref`;
|
|
1277
|
-
const conflict = casTransition(deps, row, 'pr_failed', {
|
|
1278
|
-
deliveryOutcome: 'blocked', statusCode: 'branch_verification_failed', statusDetail: detail,
|
|
1279
|
-
});
|
|
1280
|
-
if (conflict)
|
|
1281
|
-
return conflict;
|
|
1282
|
-
finalizeTransition(deps, row.id);
|
|
1283
|
-
return { status: 200, body: { ok: true, decision: 'pr_failed', detail } };
|
|
1284
|
-
}
|
|
1285
|
-
if (unit.finalSha !== sourceSha)
|
|
1286
|
-
branchEvidenceCaptured = true;
|
|
1287
|
-
effectiveBranches = effectiveBranches.map((branch, branchIndex) => branchIndex === index
|
|
1288
|
-
? { ...branch, finalSha: sourceSha }
|
|
1289
|
-
: branch);
|
|
1290
|
-
}
|
|
1291
|
-
// Succeeded COVERED tickets for the PR title/body: a single unit covers every
|
|
1292
|
-
// launch ticket (scope='all'), per-ticket units cover exactly their own.
|
|
1293
|
-
const succeededUnits = effectiveBranches.filter((b) => b.succeeded);
|
|
1294
|
-
const succeededTicketIds = effectiveBranches.length === 1 && succeededUnits.length === 1
|
|
1295
|
-
? snap.ticketIds
|
|
1296
|
-
: succeededUnits.map((b) => b.ticketId);
|
|
1297
|
-
const ticketData = loadPrTicketData(deps, succeededTicketIds);
|
|
1298
|
-
// The batch name is derived ONLY for multi-unit deliveries — the single-ticket
|
|
1299
|
-
// path delivers straight from its unit branch and must never even compute one.
|
|
1300
|
-
const batchPreferred = succeededUnits.length > 1 ? (0, pr_naming_1.batchBranchNameFor)(ticketData) : null;
|
|
1301
|
-
const unitBranches = new Set(effectiveBranches.map((b) => b.branch));
|
|
1302
|
-
// Per-ticket covering branch for the body's honest Tests digest: per-ticket
|
|
1303
|
-
// units map 1:1; a single 'all'-scope unit covers every launch ticket.
|
|
1304
|
-
const branchFor = (ticketId) => {
|
|
1305
|
-
const unit = succeededUnits.find((b) => b.ticketId === ticketId);
|
|
1306
|
-
if (unit)
|
|
1307
|
-
return unit.branch;
|
|
1308
|
-
return succeededUnits.length === 1 ? succeededUnits[0].branch : null;
|
|
1309
|
-
};
|
|
1310
|
-
// Diffstats are best-effort: a git failure degrades the body (section
|
|
1311
|
-
// omitted / "diff unavailable" note), never blocks the PR.
|
|
1312
|
-
let changes = null;
|
|
1313
|
-
try {
|
|
1314
|
-
changes = await (0, pr_body_1.collectBranchChanges)(deps.git, deps.project.path, row.base_branch, succeededUnits.map((b) => b.branch));
|
|
1315
|
-
}
|
|
1316
|
-
catch {
|
|
1317
|
-
changes = null;
|
|
1318
|
-
}
|
|
1319
|
-
// Cost footer is best-effort: a lookup failure omits the line, never blocks.
|
|
1320
|
-
let costUsd = null;
|
|
1321
|
-
try {
|
|
1322
|
-
costUsd = (0, ai_invocations_1.sumInvocationCostForRuns)(deps.db, snap.runIds);
|
|
1323
|
-
}
|
|
1324
|
-
catch {
|
|
1325
|
-
costUsd = null;
|
|
1326
|
-
}
|
|
1327
|
-
const title = (0, pr_naming_1.buildPrTitle)(ticketData, { loopName: row.loop_name });
|
|
1328
|
-
const body = (0, pr_body_1.buildCanonicalPrBody)({
|
|
1329
|
-
loopName: row.loop_name,
|
|
1330
|
-
baseBranch: row.base_branch,
|
|
1331
|
-
tickets: ticketData.map((t) => ({ ...t, branch: branchFor(t.ticketId) })),
|
|
1332
|
-
changes,
|
|
1333
|
-
costUsd,
|
|
1334
|
-
});
|
|
1335
|
-
let next;
|
|
1336
|
-
// Underlying git/gh failure detail for a degraded or failed delivery —
|
|
1337
|
-
// relayed on the response and persisted as bounded secondary diagnostics.
|
|
1338
|
-
let detail = null;
|
|
1339
|
-
const patch = {};
|
|
1340
|
-
if (branchEvidenceCaptured)
|
|
1341
|
-
patch.branches = effectiveBranches;
|
|
1342
|
-
try {
|
|
1343
|
-
// A degraded multi-unit attempt already owns an assembled batch branch.
|
|
1344
|
-
// Retry that exact head so exact head/base PR discovery remains idempotent;
|
|
1345
|
-
// never `branch -D` a merely name-matching ref, which could destroy a user's
|
|
1346
|
-
// unrelated or subsequently edited local branch.
|
|
1347
|
-
let degradedBatchSha = null;
|
|
1348
|
-
if (batchPreferred && row.branch && row.pr_url === null &&
|
|
1349
|
-
(row.pr_state === 'pushed' || row.pr_state === 'local-only') &&
|
|
1350
|
-
row.branch !== row.base_branch && !unitBranches.has(row.branch)) {
|
|
1351
|
-
if (row.delivery_sha && await commitObjectExists(deps, row.delivery_sha)) {
|
|
1352
|
-
degradedBatchSha = row.delivery_sha;
|
|
1353
|
-
}
|
|
1354
|
-
else {
|
|
1355
|
-
degradedBatchSha = await captureBranchSha(deps, row.branch);
|
|
1356
|
-
}
|
|
1357
|
-
}
|
|
1358
|
-
const result = degradedBatchSha
|
|
1359
|
-
? {
|
|
1360
|
-
state: 'delivered',
|
|
1361
|
-
branch: row.branch,
|
|
1362
|
-
pr: await (0, pr_publisher_1.publishDraftPr)(deps.exec, {
|
|
1363
|
-
repoDir: deps.project.path,
|
|
1364
|
-
branch: row.branch,
|
|
1365
|
-
baseBranch: row.base_branch,
|
|
1366
|
-
title,
|
|
1367
|
-
body,
|
|
1368
|
-
sourceSha: degradedBatchSha,
|
|
1369
|
-
}),
|
|
1370
|
-
ticketIds: succeededTicketIds,
|
|
1371
|
-
}
|
|
1372
|
-
: await (0, rail_pr_delivery_1.deliverRailAsPr)(deps.git, deps.exec, {
|
|
1373
|
-
baseRepo: deps.project.path,
|
|
1374
|
-
integrationBranch: row.base_branch,
|
|
1375
|
-
railKey: row.rail_key,
|
|
1376
|
-
batchBranch: batchPreferred ?? undefined,
|
|
1377
|
-
batchWorktreeRoot: batchWorktreeRoot(deps.project.slug),
|
|
1378
|
-
branches: effectiveBranches.map((unit) => ({
|
|
1379
|
-
...unit,
|
|
1380
|
-
sourceSha: unit.finalSha ?? undefined,
|
|
1381
|
-
})),
|
|
1382
|
-
title,
|
|
1383
|
-
body,
|
|
1384
|
-
});
|
|
1385
|
-
if (result.state === 'delivered') {
|
|
1386
|
-
next = result.pr.state === 'pr-created' && result.pr.isDraft === false ? 'pr_ready' : 'pr_draft';
|
|
1387
|
-
patch.branch = result.branch;
|
|
1388
|
-
patch.prState = result.pr.state;
|
|
1389
|
-
const resultSha = degradedBatchSha
|
|
1390
|
-
?? (succeededUnits.length === 1 ? succeededUnits[0].finalSha ?? null : await captureBranchSha(deps, result.branch));
|
|
1391
|
-
if (resultSha && await commitObjectExists(deps, resultSha))
|
|
1392
|
-
patch.deliverySha = resultSha;
|
|
1393
|
-
if (result.pr.state === 'pr-created' && result.pr.prUrl) {
|
|
1394
|
-
patch.prUrl = result.pr.prUrl;
|
|
1395
|
-
patch.prNumber = parsePrNumber(result.pr.prUrl);
|
|
1396
|
-
patch.deliveryOutcome = 'delivered';
|
|
1397
|
-
patch.statusCode = next === 'pr_ready' ? 'pr_ready' : 'pr_draft_ready';
|
|
1398
|
-
}
|
|
1399
|
-
else {
|
|
1400
|
-
// Degraded (pushed / local-only): no PR exists — only retry or discard.
|
|
1401
|
-
patch.prUrl = null;
|
|
1402
|
-
patch.prNumber = null;
|
|
1403
|
-
detail = result.pr.reason ?? null;
|
|
1404
|
-
patch.deliveryOutcome = 'retryable_failure';
|
|
1405
|
-
patch.statusCode = result.pr.state === 'local-only' ? 'push_failed' : 'delivery_failed';
|
|
1406
|
-
}
|
|
1407
|
-
}
|
|
1408
|
-
else {
|
|
1409
|
-
// 'assembly-failed' (or an unexpected 'no-op' — settle guarantees ≥1
|
|
1410
|
-
// succeeded unit, but a wedged row must not 500) → retryable failure.
|
|
1411
|
-
next = 'pr_failed';
|
|
1412
|
-
detail = result.reason;
|
|
1413
|
-
const retryable = !result.reason.startsWith('merge-conflict:') &&
|
|
1414
|
-
!result.reason.startsWith('batch-branch-collision:') &&
|
|
1415
|
-
result.reason !== 'missing-batch-branch' && result.state === 'assembly-failed';
|
|
1416
|
-
patch.deliveryOutcome = retryable ? 'retryable_failure' : 'blocked';
|
|
1417
|
-
patch.statusCode = 'delivery_failed';
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
catch (err) {
|
|
1421
|
-
// publishDraftPr can propagate GitGuardrailError — a guardrail violation is
|
|
1422
|
-
// a retryable delivery failure, never a crash.
|
|
1423
|
-
console.error('[rail-pr-decision] create-pr delivery failed:', err);
|
|
1424
|
-
next = 'pr_failed';
|
|
1425
|
-
detail = err instanceof Error ? err.message : String(err);
|
|
1426
|
-
patch.deliveryOutcome = 'blocked';
|
|
1427
|
-
patch.statusCode = 'delivery_failed';
|
|
1428
|
-
}
|
|
1429
|
-
if (detail)
|
|
1430
|
-
patch.statusDetail = detail;
|
|
1431
|
-
if ((next === 'pr_draft' || next === 'pr_ready') && patch.prUrl) {
|
|
1432
|
-
patch.cleanupWarnings = await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
1433
|
-
db: deps.db, git: deps.git, repoDir: deps.project.path, worktreeIds: releasableWorktreeIds(deps, snap),
|
|
1434
|
-
expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(effectiveBranches),
|
|
1435
|
-
overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(effectiveBranches),
|
|
1436
|
-
settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(effectiveBranches),
|
|
1437
|
-
onSafetyArchive: safetyArchiveRecorder(deps, row),
|
|
1438
|
-
});
|
|
1439
|
-
if (patch.cleanupWarnings.length > 0)
|
|
1440
|
-
patch.statusCode = 'cleanup_incomplete';
|
|
1441
|
-
}
|
|
1442
|
-
const conflict = casTransition(deps, row, next, patch);
|
|
1443
|
-
if (conflict)
|
|
1444
|
-
return conflict;
|
|
1445
|
-
if ((next === 'pr_draft' || next === 'pr_ready') && patch.prUrl) {
|
|
1446
|
-
await releaseDeliveredRecoveryLineage(deps, row, patch.deliverySha ?? row.delivery_sha);
|
|
1447
|
-
}
|
|
1448
|
-
const after = finalizeTransition(deps, row.id);
|
|
1449
|
-
// Tickets stay on_review — a draft PR is still awaiting the engineer's merge.
|
|
1450
|
-
return {
|
|
1451
|
-
status: 200,
|
|
1452
|
-
body: {
|
|
1453
|
-
ok: true,
|
|
1454
|
-
decision: next,
|
|
1455
|
-
prUrl: after?.prUrl ?? null,
|
|
1456
|
-
prState: after?.prState ?? row.pr_state,
|
|
1457
|
-
...(detail ? { detail } : {}),
|
|
1458
|
-
},
|
|
1459
|
-
};
|
|
1460
|
-
}
|
|
1461
|
-
async function runPublish(deps, row) {
|
|
1462
|
-
if (!row.delivery_sha || !row.branch) {
|
|
1463
|
-
return persistMovedPrHead(deps, row, 'the draft PR has no immutable delivery SHA/head identity; refusing to mutate unverifiable review state');
|
|
1464
|
-
}
|
|
1465
|
-
const beforePublish = await (0, pr_lifecycle_1.observePrLifecycle)(deps.exec, deps.project.path, row.pr_url, row.delivery_sha);
|
|
1466
|
-
if (!beforePublish.ok) {
|
|
1467
|
-
return { status: 502, body: { error: 'gh_failed', detail: beforePublish.detail } };
|
|
1468
|
-
}
|
|
1469
|
-
if (!(0, pr_lifecycle_1.matchesRecordedPrIdentity)(beforePublish, row.branch, row.base_branch)) {
|
|
1470
|
-
return rerouteFromStalePr(deps, row, `the recorded PR is ${beforePublish.state.toLowerCase()} but no longer matches its original head/base identity`, beforePublish.includesExpectedSha === true);
|
|
1471
|
-
}
|
|
1472
|
-
if (beforePublish.state === 'MERGED') {
|
|
1473
|
-
return beforePublish.includesExpectedSha === true
|
|
1474
|
-
? settleMergedPr(deps, row)
|
|
1475
|
-
: rerouteFromStalePr(deps, row, mergedEvidenceDetail(beforePublish), false);
|
|
1476
|
-
}
|
|
1477
|
-
if (beforePublish.state === 'CLOSED') {
|
|
1478
|
-
return beforePublish.includesExpectedSha === true
|
|
1479
|
-
? settleObservedClosedPr(deps, row, beforePublish)
|
|
1480
|
-
: rerouteFromStalePr(deps, row, 'the draft PR closed without the verified implementation commit', false);
|
|
1481
|
-
}
|
|
1482
|
-
if (beforePublish.includesExpectedSha !== true) {
|
|
1483
|
-
return persistRetryablePrObservationFailure(deps, row, 'the draft PR no longer exposes the verified implementation commit; Retry push is available and will use the preserved exact SHA');
|
|
1484
|
-
}
|
|
1485
|
-
if (beforePublish.isDraft === false) {
|
|
1486
|
-
return settleObservedExistingPr(deps, row, beforePublish, false);
|
|
1487
|
-
}
|
|
1488
|
-
let r;
|
|
1489
|
-
try {
|
|
1490
|
-
r = await deps.exec.run('gh', ['pr', 'ready', row.pr_url], deps.project.path);
|
|
1491
|
-
}
|
|
1492
|
-
catch (err) {
|
|
1493
|
-
r = { code: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) };
|
|
1494
|
-
}
|
|
1495
|
-
// `gh pr ready` is externally mutating and may return an ambiguous error.
|
|
1496
|
-
// Re-observe exact remote truth in every case before changing the ledger.
|
|
1497
|
-
const afterPublish = await (0, pr_lifecycle_1.observePrLifecycle)(deps.exec, deps.project.path, row.pr_url, row.delivery_sha);
|
|
1498
|
-
if (!afterPublish.ok) {
|
|
1499
|
-
if (r.code !== 0)
|
|
1500
|
-
return ghFailed(r);
|
|
1501
|
-
return { status: 502, body: { error: 'gh_failed', detail: `PR readiness could not be verified: ${afterPublish.detail}` } };
|
|
1502
|
-
}
|
|
1503
|
-
if (!(0, pr_lifecycle_1.matchesRecordedPrIdentity)(afterPublish, row.branch, row.base_branch)) {
|
|
1504
|
-
return rerouteFromStalePr(deps, row, `the PR identity changed while publishing it for review (${afterPublish.state.toLowerCase()})`, afterPublish.includesExpectedSha === true);
|
|
1505
|
-
}
|
|
1506
|
-
if (afterPublish.state === 'MERGED') {
|
|
1507
|
-
return afterPublish.includesExpectedSha === true
|
|
1508
|
-
? settleMergedPr(deps, row)
|
|
1509
|
-
: rerouteFromStalePr(deps, row, mergedEvidenceDetail(afterPublish), false);
|
|
1510
|
-
}
|
|
1511
|
-
if (afterPublish.state === 'CLOSED') {
|
|
1512
|
-
return afterPublish.includesExpectedSha === true
|
|
1513
|
-
? settleObservedClosedPr(deps, row, afterPublish)
|
|
1514
|
-
: rerouteFromStalePr(deps, row, 'the PR closed without the verified implementation commit while being published', false);
|
|
1515
|
-
}
|
|
1516
|
-
if (afterPublish.includesExpectedSha !== true) {
|
|
1517
|
-
return persistRetryablePrObservationFailure(deps, row, 'the PR no longer exposes the verified implementation commit after publishing; Retry push is available');
|
|
1518
|
-
}
|
|
1519
|
-
if (afterPublish.isDraft) {
|
|
1520
|
-
if (r.code !== 0)
|
|
1521
|
-
return ghFailed(r);
|
|
1522
|
-
return { status: 502, body: { error: 'gh_failed', detail: 'PR remained a draft after publishing' } };
|
|
1523
|
-
}
|
|
1524
|
-
return settleObservedExistingPr(deps, row, afterPublish, false);
|
|
1525
|
-
}
|
|
1526
|
-
async function runDiscard(deps, row, opts = { removeNeedsReview: true }) {
|
|
1527
|
-
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
1528
|
-
const implementationFailed = row.decision === 'implementation_failed';
|
|
1529
|
-
// PR lifecycle ownership and head-ref ownership are separate. Startup repairs
|
|
1530
|
-
// historical continuation rows before admission opens; only that durable bit
|
|
1531
|
-
// borrows the PR/ticket lifecycle. Per-unit ownership below independently
|
|
1532
|
-
// prevents deletion of borrowed/pre-existing head refs for fresh replacements.
|
|
1533
|
-
const preserveExternalReview = row.is_continuation === 1;
|
|
1534
|
-
// A preparation failure never ran: no worktree, no branch, no commit, no PR.
|
|
1535
|
-
// Discarding it only closes the attempt and frees the rail — the spec keeps
|
|
1536
|
-
// the status it had (typically on_review from a still-open earlier PR), the
|
|
1537
|
-
// backlog/Jira are not told about a delivery that never existed, and no
|
|
1538
|
-
// resource cleanup can be owed. Distinct from implementation_failed, which
|
|
1539
|
-
// did run and did move the ticket.
|
|
1540
|
-
const preparationFailure = row.decision === 'pr_failed' &&
|
|
1541
|
-
row.implementation_outcome === 'failed' &&
|
|
1542
|
-
!row.pr_url && !row.delivery_sha && !preserveExternalReview &&
|
|
1543
|
-
snap.branches.length === 0 && snap.worktreeIds.length === 0;
|
|
1544
|
-
const cleanupWarnings = [];
|
|
1545
|
-
// A continuation borrows an existing PR/head. Discarding its local iteration
|
|
1546
|
-
// may remove Specrails' worktree, but never closes or deletes borrowed review
|
|
1547
|
-
// state. A fresh PR is closed without GitHub's unleased --delete-branch: its
|
|
1548
|
-
// remote head may have advanced since the card was rendered.
|
|
1549
|
-
if (row.pr_url && !implementationFailed && !preserveExternalReview) {
|
|
1550
|
-
try {
|
|
1551
|
-
const r = await deps.exec.run('gh', ['pr', 'close', row.pr_url], deps.project.path);
|
|
1552
|
-
if (r.code !== 0) {
|
|
1553
|
-
const detail = (r.stderr.trim() || r.stdout.trim()).split('\n')[0] || `exit ${r.code}`;
|
|
1554
|
-
cleanupWarnings.push(`PR close ${row.pr_url}: ${detail}`);
|
|
1555
|
-
console.warn(`[rail-pr-decision] gh pr close failed (continuing discard): ${detail}`);
|
|
1556
|
-
}
|
|
1557
|
-
}
|
|
1558
|
-
catch (err) {
|
|
1559
|
-
cleanupWarnings.push(`PR close ${row.pr_url}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1560
|
-
console.warn('[rail-pr-decision] gh pr close threw (continuing discard):', err);
|
|
1561
|
-
}
|
|
1562
|
-
}
|
|
1563
|
-
// 2. Release only worktrees whose live tracked/untracked/ignored state and
|
|
1564
|
-
// exact HEAD/ref still match durable settlement evidence. Explicit
|
|
1565
|
-
// discard is not permission to contradict the confirmation copy by
|
|
1566
|
-
// force-removing subsequently changed local work. Authenticated overlays
|
|
1567
|
-
// take the same lossless quarantine path as automatic settlement.
|
|
1568
|
-
const deleteOwnedBranches = !implementationFailed && !preserveExternalReview;
|
|
1569
|
-
const skippedNeedsReview = opts.removeNeedsReview === true
|
|
1570
|
-
? []
|
|
1571
|
-
: snap.worktreeIds
|
|
1572
|
-
.map((wtId) => (0, rail_worktrees_store_1.getRailWorktree)(deps.db, wtId))
|
|
1573
|
-
.filter((wt) => wt?.merge_state === 'needs-review');
|
|
1574
|
-
for (const worktree of skippedNeedsReview) {
|
|
1575
|
-
cleanupWarnings.push(`worktree ${worktree.worktree_path}: preserved for inspection because it already requires review`);
|
|
1576
|
-
}
|
|
1577
|
-
const skippedIds = new Set(skippedNeedsReview.map((wt) => wt.id));
|
|
1578
|
-
const worktreeIds = snap.worktreeIds.filter((wtId) => !skippedIds.has(wtId));
|
|
1579
|
-
cleanupWarnings.push(...await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
1580
|
-
db: deps.db,
|
|
1581
|
-
git: deps.git,
|
|
1582
|
-
repoDir: deps.project.path,
|
|
1583
|
-
worktreeIds,
|
|
1584
|
-
state: 'failed',
|
|
1585
|
-
expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
|
|
1586
|
-
overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
|
|
1587
|
-
settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
|
|
1588
|
-
onSafetyArchive: safetyArchiveRecorder(deps, row),
|
|
1589
|
-
}));
|
|
1590
|
-
const retainedWorktreeBranches = new Set(snap.worktreeIds
|
|
1591
|
-
.map((wtId) => (0, rail_worktrees_store_1.getRailWorktree)(deps.db, wtId))
|
|
1592
|
-
.filter((wt) => Boolean(wt && (wt.merge_state === 'needs-review' || fs.existsSync(wt.worktree_path))))
|
|
1593
|
-
.map((wt) => wt.branch));
|
|
1594
|
-
// 3. Delete only branches this delivery durably records as created, and only
|
|
1595
|
-
// while their live tips still equal the immutable delivered commits.
|
|
1596
|
-
// Later user/collaborator commits are retained with a cleanup warning.
|
|
1597
|
-
if (deleteOwnedBranches) {
|
|
1598
|
-
await deleteOwnedBranchesIfUnchanged(deps, row, snap, cleanupWarnings, retainedWorktreeBranches);
|
|
1599
|
-
}
|
|
1600
|
-
// Explicit Discard local result is also the only authority (besides proven
|
|
1601
|
-
// delivery) to release a pinned orphan. A crash can occur after the atomic
|
|
1602
|
-
// ref creation but before delivery_sha is written, so recover that ownership
|
|
1603
|
-
// only from this delivery's exact durable final SHA or run-marker evidence.
|
|
1604
|
-
if (opts.removeNeedsReview === true) {
|
|
1605
|
-
let recoverySha = row.delivery_sha && COMMIT_SHA_RE.test(row.delivery_sha)
|
|
1606
|
-
? row.delivery_sha.toLowerCase()
|
|
1607
|
-
: null;
|
|
1608
|
-
if (!recoverySha && row.is_continuation === 1 &&
|
|
1609
|
-
(row.status_code === 'settlement_interrupted' || row.status_code === 'recovery_unavailable')) {
|
|
1610
|
-
const protection = await (0, rail_pr_recovery_git_1.inspectRecoveryCommitProtection)(deps.git, deps.project.path, row.id);
|
|
1611
|
-
if (protection.kind === 'present') {
|
|
1612
|
-
const durableFinalShas = [...new Set(snap.branches
|
|
1613
|
-
.map((unit) => unit.finalSha)
|
|
1614
|
-
.filter((sha) => typeof sha === 'string' && COMMIT_SHA_RE.test(sha))
|
|
1615
|
-
.map((sha) => sha.toLowerCase()))];
|
|
1616
|
-
const runIds = [...new Set(snap.runIds.filter(Boolean))];
|
|
1617
|
-
const exactDurableFinal = durableFinalShas.length === 1 && durableFinalShas[0] === protection.sha;
|
|
1618
|
-
const exactRunMarker = runIds.length === 1 && await (0, rail_pr_recovery_git_1.commitCarriesRunMarker)(deps.git, deps.project.path, protection.sha, runIds[0]);
|
|
1619
|
-
if (exactDurableFinal || exactRunMarker) {
|
|
1620
|
-
recoverySha = protection.sha;
|
|
1621
|
-
}
|
|
1622
|
-
else {
|
|
1623
|
-
cleanupWarnings.push(`recovery ref ${protection.ref}: preserved because its commit could not be proven to belong to this delivery`);
|
|
1624
|
-
}
|
|
1625
|
-
}
|
|
1626
|
-
else if (protection.kind === 'unreadable') {
|
|
1627
|
-
cleanupWarnings.push(`recovery ref ${protection.ref}: preserved because its exact commit could not be read safely`);
|
|
1628
|
-
}
|
|
1629
|
-
}
|
|
1630
|
-
if (recoverySha && !await (0, rail_pr_recovery_git_1.releaseRecoveryCommit)(deps.git, deps.project.path, row.id, recoverySha)) {
|
|
1631
|
-
cleanupWarnings.push('the delivery recovery ref changed or could not be removed; it was preserved for inspection');
|
|
1632
|
-
}
|
|
1633
|
-
}
|
|
1634
|
-
const terminalPatch = {
|
|
1635
|
-
deliveryOutcome: 'not_started',
|
|
1636
|
-
statusCode: cleanupWarnings.length > 0 ? 'cleanup_incomplete' : 'discarded',
|
|
1637
|
-
cleanupWarnings,
|
|
1638
|
-
};
|
|
1639
|
-
const conflict = preserveExternalReview || preparationFailure
|
|
1640
|
-
? casTransition(deps, row, 'discarded', terminalPatch)
|
|
1641
|
-
: casTransitionWithTicketEffect(deps, row, 'discarded', terminalPatch, {
|
|
1642
|
-
deliveryId: row.id,
|
|
1643
|
-
ticketIds: snap.ticketIds,
|
|
1644
|
-
targetStatus: 'todo',
|
|
1645
|
-
// The dashboard labels discard-from-no_changes as Refine. Jira must
|
|
1646
|
-
// mirror that truthful backlog return and never apply discardStatus.
|
|
1647
|
-
jiraAction: row.decision === 'no_changes' ? 'refine' : 'discard',
|
|
1648
|
-
prUrl: null,
|
|
1649
|
-
});
|
|
1650
|
-
if (conflict)
|
|
1651
|
-
return conflict;
|
|
1652
|
-
pauseChainsOnDiscard(deps, row);
|
|
1653
|
-
if (!preserveExternalReview && !preparationFailure)
|
|
1654
|
-
applyTerminalTicketEffect(deps, row, 'discarded', cleanupWarnings);
|
|
1655
|
-
finalizeTransition(deps, row.id);
|
|
1656
|
-
return {
|
|
1657
|
-
status: 200,
|
|
1658
|
-
body: {
|
|
1659
|
-
ok: true,
|
|
1660
|
-
decision: 'discarded',
|
|
1661
|
-
...(cleanupWarnings.length > 0 ? { cleanupWarnings } : {}),
|
|
1662
|
-
...(preparationFailure ? { preparationFailure: true } : {}),
|
|
1663
|
-
...(preserveExternalReview ? { preservedBorrowedReview: true, preservedExternalReview: true } : {}),
|
|
1664
|
-
},
|
|
1665
|
-
};
|
|
1666
|
-
}
|
|
1667
|
-
async function runDismiss(deps, row) {
|
|
1668
|
-
// Legality restricts dismiss to a clean/retryable continuation. Reuse the
|
|
1669
|
-
// ownership-safe discard path; its external PR, head and tickets are borrowed.
|
|
1670
|
-
return runDiscard(deps, row, { removeNeedsReview: false });
|
|
1671
|
-
}
|
|
1672
|
-
async function runAcknowledgeNoChanges(deps, row) {
|
|
1673
|
-
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
1674
|
-
const cleanupWarnings = await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
1675
|
-
db: deps.db,
|
|
1676
|
-
git: deps.git,
|
|
1677
|
-
repoDir: deps.project.path,
|
|
1678
|
-
worktreeIds: snap.worktreeIds,
|
|
1679
|
-
expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
|
|
1680
|
-
overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
|
|
1681
|
-
settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
|
|
1682
|
-
onSafetyArchive: safetyArchiveRecorder(deps, row),
|
|
1683
|
-
});
|
|
1684
|
-
// A fresh no-change branch is disposable only while Git still proves that it
|
|
1685
|
-
// has no commits ahead of the integration base. If the user added work after
|
|
1686
|
-
// settlement, preserve it and disclose the incomplete cleanup.
|
|
1687
|
-
for (const branch of ownedDeliveryBranches(row, snap, cleanupWarnings)) {
|
|
1688
|
-
if (!branch || branch === row.base_branch)
|
|
1689
|
-
continue;
|
|
1690
|
-
try {
|
|
1691
|
-
const ahead = await deps.git.run(['rev-list', '--count', `${row.base_branch}..${branch}`], deps.project.path);
|
|
1692
|
-
if (ahead.code !== 0 || Number.parseInt(ahead.stdout.trim(), 10) !== 0) {
|
|
1693
|
-
cleanupWarnings.push(`branch ${branch}: retained because no-change cleanup could not prove it has zero commits ahead of ${row.base_branch}`);
|
|
1694
|
-
continue;
|
|
1695
|
-
}
|
|
1696
|
-
const deleted = await deps.git.run(['branch', '-d', branch], deps.project.path);
|
|
1697
|
-
if (deleted.code !== 0) {
|
|
1698
|
-
cleanupWarnings.push(`branch ${branch}: ${(deleted.stderr.trim() || deleted.stdout.trim()).split('\n')[0] || `exit ${deleted.code}`}`);
|
|
1699
|
-
}
|
|
1700
|
-
}
|
|
1701
|
-
catch (err) {
|
|
1702
|
-
cleanupWarnings.push(`branch ${branch}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1703
|
-
}
|
|
1704
|
-
}
|
|
1705
|
-
const conflict = casTransitionWithTicketEffect(deps, row, 'completed', {
|
|
1706
|
-
deliveryOutcome: 'no_changes',
|
|
1707
|
-
statusCode: cleanupWarnings.length > 0 ? 'cleanup_incomplete' : 'no_changes',
|
|
1708
|
-
cleanupWarnings,
|
|
1709
|
-
}, {
|
|
1710
|
-
deliveryId: row.id,
|
|
1711
|
-
ticketIds: snap.ticketIds,
|
|
1712
|
-
targetStatus: 'done',
|
|
1713
|
-
jiraAction: 'completed',
|
|
1714
|
-
prUrl: null,
|
|
1715
|
-
});
|
|
1716
|
-
if (conflict)
|
|
1717
|
-
return conflict;
|
|
1718
|
-
applyTerminalTicketEffect(deps, row, 'completed', cleanupWarnings);
|
|
1719
|
-
finalizeTransition(deps, row.id);
|
|
1720
|
-
return {
|
|
1721
|
-
status: 200,
|
|
1722
|
-
body: {
|
|
1723
|
-
ok: true,
|
|
1724
|
-
decision: 'completed',
|
|
1725
|
-
noChanges: true,
|
|
1726
|
-
...(cleanupWarnings.length > 0 ? { cleanupWarnings } : {}),
|
|
1727
|
-
},
|
|
1728
|
-
};
|
|
1729
|
-
}
|
|
1730
|
-
async function settleMergedPr(deps, row) {
|
|
1731
|
-
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
1732
|
-
const cleanupWarnings = await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
1733
|
-
db: deps.db, git: deps.git, repoDir: deps.project.path,
|
|
1734
|
-
worktreeIds: releasableWorktreeIds(deps, snap), state: 'merged',
|
|
1735
|
-
expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
|
|
1736
|
-
overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
|
|
1737
|
-
settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
|
|
1738
|
-
onSafetyArchive: safetyArchiveRecorder(deps, row),
|
|
1739
|
-
});
|
|
1740
|
-
await deleteOwnedBranchesIfUnchanged(deps, row, snap, cleanupWarnings);
|
|
1741
|
-
const conflict = casTransitionWithTicketEffect(deps, row, 'merged', {
|
|
1742
|
-
deliveryOutcome: 'delivered',
|
|
1743
|
-
statusCode: cleanupWarnings.length > 0 ? 'cleanup_incomplete' : 'merged',
|
|
1744
|
-
statusDetail: null,
|
|
1745
|
-
cleanupWarnings,
|
|
1746
|
-
}, {
|
|
1747
|
-
deliveryId: row.id,
|
|
1748
|
-
ticketIds: snap.ticketIds,
|
|
1749
|
-
targetStatus: 'done',
|
|
1750
|
-
jiraAction: 'merged',
|
|
1751
|
-
prUrl: row.pr_url,
|
|
1752
|
-
});
|
|
1753
|
-
if (conflict)
|
|
1754
|
-
return conflict;
|
|
1755
|
-
await releaseDeliveredRecoveryLineage(deps, row, row.delivery_sha);
|
|
1756
|
-
applyTerminalTicketEffect(deps, row, 'merged', cleanupWarnings);
|
|
1757
|
-
finalizeTransition(deps, row.id);
|
|
1758
|
-
await sweepMergedChainAncestors(deps, row);
|
|
1759
|
-
return {
|
|
1760
|
-
status: 200,
|
|
1761
|
-
body: {
|
|
1762
|
-
ok: true,
|
|
1763
|
-
decision: 'merged',
|
|
1764
|
-
merged: true,
|
|
1765
|
-
prUrl: row.pr_url,
|
|
1766
|
-
...(row.delivery_sha ? {
|
|
1767
|
-
deliveryVerified: true,
|
|
1768
|
-
verifiedSha: row.delivery_sha,
|
|
1769
|
-
pushed: false,
|
|
1770
|
-
} : {}),
|
|
1771
|
-
},
|
|
1772
|
-
};
|
|
1773
|
-
}
|
|
1774
|
-
async function runPollMerge(deps, row) {
|
|
1775
|
-
const observed = await (0, pr_lifecycle_1.observePrLifecycle)(deps.exec, deps.project.path, row.pr_url, row.delivery_sha);
|
|
1776
|
-
if (!observed.ok)
|
|
1777
|
-
return { status: 502, body: { error: 'gh_failed', detail: observed.detail } };
|
|
1778
|
-
const { state, isDraft } = observed;
|
|
1779
|
-
if (!row.branch) {
|
|
1780
|
-
return persistMovedPrHead(deps, row, `the ${state.toLowerCase()} PR has no recorded delivery branch; refusing to infer recoverable work`);
|
|
1781
|
-
}
|
|
1782
|
-
if (!row.delivery_sha) {
|
|
1783
|
-
// A legacy OPEN row cannot be retried without an immutable object. Once the
|
|
1784
|
-
// attached PR is terminal, however, keeping that stale URL would strand the
|
|
1785
|
-
// only recorded branch behind Discard. Detach it so Create PR can perform
|
|
1786
|
-
// the existing one-time legacy ref capture, without claiming any delivery.
|
|
1787
|
-
if (state === 'CLOSED' || state === 'MERGED') {
|
|
1788
|
-
return rerouteFromStalePr(deps, row, `the previous PR is ${state.toLowerCase()} and this legacy delivery has no persisted verified SHA`, false);
|
|
1789
|
-
}
|
|
1790
|
-
return persistMovedPrHead(deps, row, 'the open PR has no persisted verified delivery SHA; refusing to infer delivery from a mutable branch');
|
|
1791
|
-
}
|
|
1792
|
-
if (!(0, pr_lifecycle_1.matchesRecordedPrIdentity)(observed, row.branch, row.base_branch)) {
|
|
1793
|
-
return rerouteFromStalePr(deps, row, `the recorded PR is ${state.toLowerCase()} but no longer matches its original head/base identity`, observed.includesExpectedSha === true);
|
|
1794
|
-
}
|
|
1795
|
-
if (state === 'CLOSED') {
|
|
1796
|
-
if (observed.includesExpectedSha !== true) {
|
|
1797
|
-
return rerouteFromStalePr(deps, row, 'the previous PR closed without the verified implementation commit', false);
|
|
1798
|
-
}
|
|
1799
|
-
return settleObservedClosedPr(deps, row, observed);
|
|
1800
|
-
}
|
|
1801
|
-
if (state === 'OPEN') {
|
|
1802
|
-
if (observed.includesExpectedSha !== true) {
|
|
1803
|
-
const detail = 'the open PR no longer exposes the verified implementation commit; Retry push is available and will use the preserved exact SHA';
|
|
1804
|
-
const result = persistRetryablePrObservationFailure(deps, row, detail);
|
|
1805
|
-
return {
|
|
1806
|
-
...result,
|
|
1807
|
-
body: {
|
|
1808
|
-
...result.body,
|
|
1809
|
-
deliveryVerified: false,
|
|
1810
|
-
verifiedSha: row.delivery_sha,
|
|
1811
|
-
remoteHeadSha: observed.headRefOid,
|
|
1812
|
-
},
|
|
1813
|
-
};
|
|
1814
|
-
}
|
|
1815
|
-
// A successful reopen can be observed even if the explicit action lost its
|
|
1816
|
-
// follow-up `gh view` response. Polling heals pr_closed from remote truth.
|
|
1817
|
-
if (row.decision === 'pr_closed') {
|
|
1818
|
-
const next = isDraft ? 'pr_draft' : 'pr_ready';
|
|
1819
|
-
const conflict = casTransition(deps, row, next, {
|
|
1820
|
-
deliveryOutcome: 'delivered', statusCode: isDraft ? 'pr_draft_ready' : 'pr_ready',
|
|
1821
|
-
});
|
|
1822
|
-
if (conflict)
|
|
1823
|
-
return conflict;
|
|
1824
|
-
finalizeTransition(deps, row.id);
|
|
1825
|
-
return {
|
|
1826
|
-
status: 200,
|
|
1827
|
-
body: {
|
|
1828
|
-
ok: true,
|
|
1829
|
-
decision: next,
|
|
1830
|
-
merged: false,
|
|
1831
|
-
reopened: true,
|
|
1832
|
-
prUrl: row.pr_url,
|
|
1833
|
-
deliveryVerified: true,
|
|
1834
|
-
verifiedSha: row.delivery_sha,
|
|
1835
|
-
remoteHeadSha: observed.headRefOid,
|
|
1836
|
-
pushed: false,
|
|
1837
|
-
},
|
|
1838
|
-
};
|
|
1839
|
-
}
|
|
1840
|
-
return {
|
|
1841
|
-
status: 200,
|
|
1842
|
-
body: {
|
|
1843
|
-
ok: true,
|
|
1844
|
-
decision: row.decision,
|
|
1845
|
-
merged: false,
|
|
1846
|
-
deliveryVerified: true,
|
|
1847
|
-
verifiedSha: row.delivery_sha,
|
|
1848
|
-
remoteHeadSha: observed.headRefOid,
|
|
1849
|
-
pushed: false,
|
|
1850
|
-
},
|
|
1851
|
-
};
|
|
1852
|
-
}
|
|
1853
|
-
if (state !== 'MERGED') {
|
|
1854
|
-
return { status: 502, body: { error: 'gh_failed', detail: `unexpected PR state: ${state}` } };
|
|
1855
|
-
}
|
|
1856
|
-
// A PR can merge just before a continuation push; GitHub may then accept a
|
|
1857
|
-
// later update to the head branch even though that object was never part of
|
|
1858
|
-
// the merge. Never move tickets to Done from state=MERGED alone.
|
|
1859
|
-
if (observed.includesExpectedSha !== true) {
|
|
1860
|
-
return rerouteFromStalePr(deps, row, mergedEvidenceDetail(observed), false);
|
|
1861
|
-
}
|
|
1862
|
-
return settleMergedPr(deps, row);
|
|
1863
|
-
}
|
|
1864
|
-
async function runReopen(deps, row) {
|
|
1865
|
-
if (!row.delivery_sha || !row.branch) {
|
|
1866
|
-
return rerouteFromStalePr(deps, row, 'the closed PR cannot be reopened safely because its immutable delivery SHA/head identity is unavailable', false);
|
|
1867
|
-
}
|
|
1868
|
-
const beforeReopen = await (0, pr_lifecycle_1.observePrLifecycle)(deps.exec, deps.project.path, row.pr_url, row.delivery_sha);
|
|
1869
|
-
if (!beforeReopen.ok) {
|
|
1870
|
-
return { status: 502, body: { error: 'gh_failed', detail: beforeReopen.detail } };
|
|
1871
|
-
}
|
|
1872
|
-
if (!(0, pr_lifecycle_1.matchesRecordedPrIdentity)(beforeReopen, row.branch, row.base_branch) ||
|
|
1873
|
-
beforeReopen.includesExpectedSha !== true) {
|
|
1874
|
-
return rerouteFromStalePr(deps, row, 'the closed PR no longer proves the recorded head/base and verified implementation commit', false);
|
|
1875
|
-
}
|
|
1876
|
-
if (beforeReopen.state === 'MERGED')
|
|
1877
|
-
return settleMergedPr(deps, row);
|
|
1878
|
-
if (beforeReopen.state === 'OPEN') {
|
|
1879
|
-
const next = beforeReopen.isDraft ? 'pr_draft' : 'pr_ready';
|
|
1880
|
-
const conflict = casTransition(deps, row, next, {
|
|
1881
|
-
deliveryOutcome: 'delivered', statusCode: beforeReopen.isDraft ? 'pr_draft_ready' : 'pr_ready',
|
|
1882
|
-
statusDetail: null,
|
|
1883
|
-
});
|
|
1884
|
-
if (conflict)
|
|
1885
|
-
return conflict;
|
|
1886
|
-
finalizeTransition(deps, row.id);
|
|
1887
|
-
return {
|
|
1888
|
-
status: 200,
|
|
1889
|
-
body: {
|
|
1890
|
-
ok: true, decision: next, reopened: true, prUrl: row.pr_url,
|
|
1891
|
-
deliveryVerified: true, verifiedSha: row.delivery_sha,
|
|
1892
|
-
remoteHeadSha: beforeReopen.headRefOid, pushed: false,
|
|
1893
|
-
},
|
|
1894
|
-
};
|
|
1895
|
-
}
|
|
1896
|
-
let reopened;
|
|
1897
|
-
try {
|
|
1898
|
-
reopened = await deps.exec.run('gh', ['pr', 'reopen', row.pr_url], deps.project.path);
|
|
1899
|
-
}
|
|
1900
|
-
catch (err) {
|
|
1901
|
-
reopened = { code: 1, stdout: '', stderr: err instanceof Error ? err.message : String(err) };
|
|
1902
|
-
}
|
|
1903
|
-
const observed = await (0, pr_lifecycle_1.observePrLifecycle)(deps.exec, deps.project.path, row.pr_url, row.delivery_sha);
|
|
1904
|
-
if (!observed.ok) {
|
|
1905
|
-
if (reopened.code !== 0)
|
|
1906
|
-
return ghFailed(reopened);
|
|
1907
|
-
return { status: 502, body: { error: 'gh_failed', detail: observed.detail } };
|
|
1908
|
-
}
|
|
1909
|
-
const identityMatches = (0, pr_lifecycle_1.matchesRecordedPrIdentity)(observed, row.branch, row.base_branch);
|
|
1910
|
-
if (observed.state === 'MERGED') {
|
|
1911
|
-
if (identityMatches && observed.includesExpectedSha === true)
|
|
1912
|
-
return settleMergedPr(deps, row);
|
|
1913
|
-
return rerouteFromStalePr(deps, row, identityMatches ? mergedEvidenceDetail(observed) : 'the PR identity changed while it was being reopened', false);
|
|
1914
|
-
}
|
|
1915
|
-
if (observed.state === 'CLOSED') {
|
|
1916
|
-
if (!identityMatches || observed.includesExpectedSha !== true) {
|
|
1917
|
-
return rerouteFromStalePr(deps, row, 'the PR remained closed without proving the recorded head/base and verified implementation commit', false);
|
|
1918
|
-
}
|
|
1919
|
-
if (reopened.code !== 0)
|
|
1920
|
-
return ghFailed(reopened);
|
|
1921
|
-
return { status: 502, body: { error: 'gh_failed', detail: 'PR remained closed after reopen' } };
|
|
1922
|
-
}
|
|
1923
|
-
if (!identityMatches || observed.includesExpectedSha !== true) {
|
|
1924
|
-
return rerouteFromStalePr(deps, row, 'the reopened PR no longer proves the recorded head/base and verified implementation commit', true);
|
|
1925
|
-
}
|
|
1926
|
-
const next = observed.isDraft ? 'pr_draft' : 'pr_ready';
|
|
1927
|
-
const conflict = casTransition(deps, row, next, {
|
|
1928
|
-
deliveryOutcome: 'delivered', statusCode: observed.isDraft ? 'pr_draft_ready' : 'pr_ready',
|
|
1929
|
-
statusDetail: null,
|
|
1930
|
-
});
|
|
1931
|
-
if (conflict)
|
|
1932
|
-
return conflict;
|
|
1933
|
-
finalizeTransition(deps, row.id);
|
|
1934
|
-
return {
|
|
1935
|
-
status: 200,
|
|
1936
|
-
body: {
|
|
1937
|
-
ok: true, decision: next, reopened: true, prUrl: row.pr_url,
|
|
1938
|
-
deliveryVerified: true, verifiedSha: row.delivery_sha,
|
|
1939
|
-
remoteHeadSha: observed.headRefOid, pushed: false,
|
|
1940
|
-
},
|
|
1941
|
-
};
|
|
1942
|
-
}
|
|
1943
|
-
async function runMergeLocal(deps, row) {
|
|
1944
|
-
return (0, repo_lock_1.withRepoLock)(deps.project.path, () => runMergeLocalLocked(deps, row));
|
|
1945
|
-
}
|
|
1946
|
-
async function runMergeLocalLocked(deps, row) {
|
|
1947
|
-
// A stacked milestone delivery targets the chain integration branch, not
|
|
1948
|
-
// the previous feature branch recorded as its PR base. Freeze that target
|
|
1949
|
-
// for the whole locked operation and every pre/post-advance check.
|
|
1950
|
-
const integrationTarget = mergeLocalTargetBranch(deps, row);
|
|
1951
|
-
deps.assertAdmission?.();
|
|
1952
|
-
if (!(0, integration_branch_1.isValidBranchName)(integrationTarget) || integrationTarget === 'HEAD' || integrationTarget.startsWith('refs/')) {
|
|
1953
|
-
return {
|
|
1954
|
-
status: 409,
|
|
1955
|
-
body: { error: 'merge_local_blocked', reason: 'unresolved_head', base: integrationTarget,
|
|
1956
|
-
detail: 'Configure a local integration branch before accepting this delivery.' },
|
|
1957
|
-
};
|
|
1958
|
-
}
|
|
1959
|
-
const head = await deps.git.run(['symbolic-ref', '--quiet', 'HEAD'], deps.project.path);
|
|
1960
|
-
if (head.code !== 0 && head.code !== 1) {
|
|
1961
|
-
return { status: 409, body: { error: 'merge_local_blocked', reason: 'unresolved_head', base: integrationTarget } };
|
|
1962
|
-
}
|
|
1963
|
-
if (head.stdout.trim() === `refs/heads/${integrationTarget}`)
|
|
1964
|
-
return runMergeLocalIntoCheckout(deps, row, deps.project.path, integrationTarget);
|
|
1965
|
-
// Claim the designated base in an ordinary linked checkout. Git refuses if
|
|
1966
|
-
// another worktree already holds it; never update-ref a checked-out branch
|
|
1967
|
-
// behind its owner's back or switch/stash the user's current work.
|
|
1968
|
-
const safeId = row.id.replace(/[^A-Za-z0-9-]/g, '').slice(0, 64) || 'delivery';
|
|
1969
|
-
const target = path.join(deps.assemblyRoot ?? batchWorktreeRoot(deps.project.slug), `local-base-${safeId}-${(0, ids_1.newId)()}`);
|
|
1970
|
-
const added = await deps.git.run(['worktree', 'add', target, integrationTarget], deps.project.path);
|
|
1971
|
-
if (added.code !== 0) {
|
|
1972
|
-
return {
|
|
1973
|
-
status: 409,
|
|
1974
|
-
body: {
|
|
1975
|
-
error: 'merge_local_blocked', reason: 'integration_branch_busy', base: integrationTarget,
|
|
1976
|
-
detail: added.stderr.trim() || added.stdout.trim() || 'The integration branch could not be opened safely.',
|
|
1977
|
-
},
|
|
1978
|
-
};
|
|
1979
|
-
}
|
|
1980
|
-
let result;
|
|
1981
|
-
try {
|
|
1982
|
-
result = await runMergeLocalIntoCheckout(deps, row, target, integrationTarget);
|
|
1983
|
-
return result;
|
|
1984
|
-
}
|
|
1985
|
-
finally {
|
|
1986
|
-
// Even failed assembly must release the temporary base checkout. Non-force
|
|
1987
|
-
// removal preserves files if an external process wrote there during merge.
|
|
1988
|
-
let warning = null;
|
|
1989
|
-
try {
|
|
1990
|
-
const removed = await deps.git.run(['worktree', 'remove', target], deps.project.path);
|
|
1991
|
-
if (removed.code !== 0)
|
|
1992
|
-
warning = `integration worktree ${target}: ${removed.stderr.trim() || removed.stdout.trim() || `exit ${removed.code}`}`;
|
|
1993
|
-
}
|
|
1994
|
-
catch (err) {
|
|
1995
|
-
warning = `integration worktree ${target}: ${err instanceof Error ? err.message : String(err)}`;
|
|
1996
|
-
}
|
|
1997
|
-
if (warning) {
|
|
1998
|
-
safetyArchiveRecorder(deps, row)(target);
|
|
1999
|
-
const current = (0, rail_pr_store_1.getPrDelivery)(deps.db, row.id);
|
|
2000
|
-
if (current?.operation_token === row.operation_token) {
|
|
2001
|
-
const warnings = [...(0, rail_pr_store_1.toPrDeliverySnapshot)(current).cleanupWarnings, warning];
|
|
2002
|
-
casTransition(deps, current, current.decision, { cleanupWarnings: warnings, statusCode: 'cleanup_incomplete' });
|
|
2003
|
-
finalizeTransition(deps, row.id);
|
|
2004
|
-
if (result)
|
|
2005
|
-
result.body.cleanupWarnings = warnings;
|
|
2006
|
-
}
|
|
2007
|
-
}
|
|
2008
|
-
}
|
|
2009
|
-
}
|
|
2010
|
-
async function runMergeLocalIntoCheckout(deps, row, targetRepo, integrationTarget) {
|
|
2011
|
-
const snap = (0, rail_pr_store_1.toPrDeliverySnapshot)(row);
|
|
2012
|
-
const cleanupWarnings = [];
|
|
2013
|
-
// Snapshot branch/HEAD before assembly. Local edits are allowed: the final
|
|
2014
|
-
// non-forced fast-forward leaves unrelated work intact and Git atomically
|
|
2015
|
-
// refuses any overwrite, including ignored files.
|
|
2016
|
-
const head = await deps.git.run(['symbolic-ref', '--quiet', 'HEAD'], targetRepo);
|
|
2017
|
-
const currentBranch = head.code === 0 ? head.stdout.trim().replace(/^refs\/heads\//, '') : '';
|
|
2018
|
-
if (currentBranch !== integrationTarget) {
|
|
2019
|
-
return {
|
|
2020
|
-
status: 409,
|
|
2021
|
-
body: { error: 'merge_local_blocked', reason: 'wrong_branch', current: currentBranch || null, base: integrationTarget },
|
|
2022
|
-
};
|
|
2023
|
-
}
|
|
2024
|
-
const status = await deps.git.run(['status', '--porcelain'], targetRepo);
|
|
2025
|
-
if (status.code !== 0) {
|
|
2026
|
-
return { status: 409, body: { error: 'merge_local_blocked', reason: 'status_unavailable', base: integrationTarget } };
|
|
2027
|
-
}
|
|
2028
|
-
const startingHead = await deps.git.run(['rev-parse', '--verify', 'HEAD'], targetRepo);
|
|
2029
|
-
const startingSha = startingHead.code === 0 ? startingHead.stdout.trim() : '';
|
|
2030
|
-
if (!/^[0-9a-f]{40,64}$/i.test(startingSha)) {
|
|
2031
|
-
return { status: 409, body: { error: 'merge_local_blocked', reason: 'unresolved_head', base: integrationTarget } };
|
|
2032
|
-
}
|
|
2033
|
-
// What to merge: immutable object ids only. The assembled head carries every
|
|
2034
|
-
// unit already; without one, merge each settled unit's exact final SHA.
|
|
2035
|
-
let toMerge;
|
|
2036
|
-
let deliveryHeadSha = null;
|
|
2037
|
-
if (row.delivery_sha) {
|
|
2038
|
-
if (!COMMIT_SHA_RE.test(row.delivery_sha) || !(await commitObjectExists(deps, row.delivery_sha))) {
|
|
2039
|
-
return { status: 502, body: { error: 'merge_failed', detail: 'The verified delivery commit is unavailable; no branch fallback was merged.' } };
|
|
2040
|
-
}
|
|
2041
|
-
deliveryHeadSha = row.delivery_sha;
|
|
2042
|
-
}
|
|
2043
|
-
if (deliveryHeadSha) {
|
|
2044
|
-
toMerge = [deliveryHeadSha];
|
|
2045
|
-
}
|
|
2046
|
-
else {
|
|
2047
|
-
toMerge = [];
|
|
2048
|
-
for (const unit of snap.branches.filter((b) => b.succeeded)) {
|
|
2049
|
-
let sourceSha = unit.finalSha && COMMIT_SHA_RE.test(unit.finalSha) ? unit.finalSha : null;
|
|
2050
|
-
if (sourceSha) {
|
|
2051
|
-
if (!(await commitObjectExists(deps, sourceSha)))
|
|
2052
|
-
sourceSha = null;
|
|
2053
|
-
}
|
|
2054
|
-
if (!sourceSha) {
|
|
2055
|
-
return {
|
|
2056
|
-
status: 502,
|
|
2057
|
-
body: { error: 'merge_failed', detail: `verified commit for '${unit.branch}' (ticket #${unit.ticketId}) is unavailable` },
|
|
2058
|
-
};
|
|
2059
|
-
}
|
|
2060
|
-
toMerge.push(sourceSha);
|
|
2061
|
-
}
|
|
2062
|
-
}
|
|
2063
|
-
if (toMerge.length === 0) {
|
|
2064
|
-
return { status: 502, body: { error: 'merge_failed', detail: 'no succeeded branch to merge' } };
|
|
2065
|
-
}
|
|
2066
|
-
// Assemble every merge away from the user's checkout. A conflict can only
|
|
2067
|
-
// dirty this disposable detached worktree, never the integration branch.
|
|
2068
|
-
const safeId = row.id.replace(/[^A-Za-z0-9-]/g, '').slice(0, 64) || 'delivery';
|
|
2069
|
-
const assemblyPath = path.join(deps.assemblyRoot ?? batchWorktreeRoot(deps.project.slug), `local-merge-${safeId}-${(0, ids_1.newId)()}`);
|
|
2070
|
-
const added = await deps.git.run(['worktree', 'add', '--detach', assemblyPath, startingSha], deps.project.path);
|
|
2071
|
-
if (added.code !== 0) {
|
|
2072
|
-
const detail = (added.stderr.trim() || added.stdout.trim()).split('\n')[0] || `exit ${added.code}`;
|
|
2073
|
-
return { status: 502, body: { error: 'merge_failed', detail: `creating isolated assembly: ${detail}` } };
|
|
2074
|
-
}
|
|
2075
|
-
let assemblyRemovalAttempted = false;
|
|
2076
|
-
const removeAssembly = async () => {
|
|
2077
|
-
if (assemblyRemovalAttempted)
|
|
2078
|
-
return;
|
|
2079
|
-
assemblyRemovalAttempted = true;
|
|
2080
|
-
try {
|
|
2081
|
-
const removed = await deps.git.run(['worktree', 'remove', assemblyPath], deps.project.path);
|
|
2082
|
-
if (removed.code !== 0) {
|
|
2083
|
-
cleanupWarnings.push(`assembly worktree ${assemblyPath}: ${(removed.stderr.trim() || removed.stdout.trim()).split('\n')[0] || `exit ${removed.code}`}`);
|
|
2084
|
-
safetyArchiveRecorder(deps, row)(assemblyPath);
|
|
2085
|
-
}
|
|
2086
|
-
}
|
|
2087
|
-
catch (err) {
|
|
2088
|
-
cleanupWarnings.push(`assembly worktree ${assemblyPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2089
|
-
safetyArchiveRecorder(deps, row)(assemblyPath);
|
|
2090
|
-
}
|
|
2091
|
-
};
|
|
2092
|
-
const persistAssemblyFailure = (detail) => {
|
|
2093
|
-
const conflict = casTransition(deps, row, row.decision, {
|
|
2094
|
-
deliveryOutcome: 'blocked', statusCode: 'delivery_failed', statusDetail: detail, cleanupWarnings,
|
|
2095
|
-
});
|
|
2096
|
-
if (conflict)
|
|
2097
|
-
return conflict;
|
|
2098
|
-
finalizeTransition(deps, row.id);
|
|
2099
|
-
return null;
|
|
2100
|
-
};
|
|
2101
|
-
try {
|
|
2102
|
-
for (const branch of toMerge) {
|
|
2103
|
-
const r = await deps.git.run(['merge', '--no-ff', '--no-edit', branch], assemblyPath);
|
|
2104
|
-
if (r.code !== 0) {
|
|
2105
|
-
try {
|
|
2106
|
-
const aborted = await deps.git.run(['merge', '--abort'], assemblyPath);
|
|
2107
|
-
if (aborted.code !== 0)
|
|
2108
|
-
cleanupWarnings.push(`assembly abort ${branch}: exit ${aborted.code}`);
|
|
2109
|
-
}
|
|
2110
|
-
catch (err) {
|
|
2111
|
-
cleanupWarnings.push(`assembly abort ${branch}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2112
|
-
}
|
|
2113
|
-
await removeAssembly();
|
|
2114
|
-
const detail = (r.stderr.trim() || r.stdout.trim()).split('\n')[0] || `exit ${r.code}`;
|
|
2115
|
-
const conflict = persistAssemblyFailure(`merging '${branch}': ${detail}`);
|
|
2116
|
-
if (conflict)
|
|
2117
|
-
return conflict;
|
|
2118
|
-
return { status: 502, body: { error: 'merge_failed', detail: `merging '${branch}': ${detail}` } };
|
|
2119
|
-
}
|
|
2120
|
-
}
|
|
2121
|
-
const assembled = await deps.git.run(['rev-parse', '--verify', 'HEAD'], assemblyPath);
|
|
2122
|
-
const assembledSha = assembled.code === 0 ? assembled.stdout.trim() : '';
|
|
2123
|
-
if (!/^[0-9a-f]{40,64}$/i.test(assembledSha)) {
|
|
2124
|
-
await removeAssembly();
|
|
2125
|
-
const detail = 'isolated assembly produced no verifiable HEAD';
|
|
2126
|
-
const conflict = persistAssemblyFailure(detail);
|
|
2127
|
-
if (conflict)
|
|
2128
|
-
return conflict;
|
|
2129
|
-
return { status: 502, body: { error: 'merge_failed', detail } };
|
|
2130
|
-
}
|
|
2131
|
-
const [finalBranch, finalStatus, finalHead] = await Promise.all([
|
|
2132
|
-
deps.git.run(['symbolic-ref', '--quiet', 'HEAD'], targetRepo),
|
|
2133
|
-
deps.git.run(['status', '--porcelain'], targetRepo),
|
|
2134
|
-
deps.git.run(['rev-parse', '--verify', 'HEAD'], targetRepo),
|
|
2135
|
-
]);
|
|
2136
|
-
const changedReason = finalBranch.code !== 0 || finalBranch.stdout.trim() !== `refs/heads/${integrationTarget}`
|
|
2137
|
-
? 'wrong_branch'
|
|
2138
|
-
: finalStatus.code !== 0
|
|
2139
|
-
? 'status_unavailable'
|
|
2140
|
-
: finalHead.code !== 0 || finalHead.stdout.trim() !== startingSha
|
|
2141
|
-
? 'head_changed'
|
|
2142
|
-
: null;
|
|
2143
|
-
if (changedReason) {
|
|
2144
|
-
await removeAssembly();
|
|
2145
|
-
if (cleanupWarnings.length > 0) {
|
|
2146
|
-
const conflict = casTransition(deps, row, row.decision, { cleanupWarnings });
|
|
2147
|
-
if (conflict)
|
|
2148
|
-
return conflict;
|
|
2149
|
-
finalizeTransition(deps, row.id);
|
|
2150
|
-
}
|
|
2151
|
-
return { status: 409, body: { error: 'merge_local_blocked', reason: changedReason, base: integrationTarget } };
|
|
2152
|
-
}
|
|
2153
|
-
const advanced = await deps.git.run(['merge', '--ff-only', '--no-overwrite-ignore', assembledSha], targetRepo);
|
|
2154
|
-
if (advanced.code !== 0) {
|
|
2155
|
-
await removeAssembly();
|
|
2156
|
-
if (/would be overwritten|local changes|untracked working tree/i.test(`${advanced.stderr}\n${advanced.stdout}`)) {
|
|
2157
|
-
return {
|
|
2158
|
-
status: 409,
|
|
2159
|
-
body: {
|
|
2160
|
-
error: 'merge_local_blocked', reason: 'dirty', base: integrationTarget,
|
|
2161
|
-
detail: (advanced.stderr.trim() || advanced.stdout.trim()).slice(0, 1200),
|
|
2162
|
-
},
|
|
2163
|
-
};
|
|
2164
|
-
}
|
|
2165
|
-
const detail = (advanced.stderr.trim() || advanced.stdout.trim()).split('\n')[0] || `exit ${advanced.code}`;
|
|
2166
|
-
const conflict = persistAssemblyFailure(`advancing '${integrationTarget}': ${detail}`);
|
|
2167
|
-
if (conflict)
|
|
2168
|
-
return conflict;
|
|
2169
|
-
return { status: 502, body: { error: 'merge_failed', detail: `advancing '${integrationTarget}': ${detail}` } };
|
|
2170
|
-
}
|
|
2171
|
-
const [acceptedBranch, acceptedHead, acceptedBase] = await Promise.all([
|
|
2172
|
-
deps.git.run(['symbolic-ref', '--quiet', 'HEAD'], targetRepo),
|
|
2173
|
-
deps.git.run(['rev-parse', '--verify', 'HEAD'], targetRepo),
|
|
2174
|
-
deps.git.run(['rev-parse', '--verify', `refs/heads/${integrationTarget}`], deps.project.path),
|
|
2175
|
-
]);
|
|
2176
|
-
if (acceptedBranch.code !== 0 || acceptedBranch.stdout.trim() !== `refs/heads/${integrationTarget}` ||
|
|
2177
|
-
acceptedHead.code !== 0 || acceptedHead.stdout.trim().toLowerCase() !== assembledSha.toLowerCase() ||
|
|
2178
|
-
acceptedBase.code !== 0 || acceptedBase.stdout.trim().toLowerCase() !== assembledSha.toLowerCase()) {
|
|
2179
|
-
// An external checkout/ref edit can race the final preflight. A successful
|
|
2180
|
-
// Git exit is insufficient evidence that the authorized base was updated.
|
|
2181
|
-
// Preserve the assembled object and all source worktrees/branches for a
|
|
2182
|
-
// retry; never roll back the external edit or mark tickets done.
|
|
2183
|
-
assemblyRemovalAttempted = true;
|
|
2184
|
-
safetyArchiveRecorder(deps, row)(assemblyPath);
|
|
2185
|
-
const detail = `The integration checkout changed during the final advance. The assembled result is preserved at ${assemblyPath}.`;
|
|
2186
|
-
cleanupWarnings.push(detail);
|
|
2187
|
-
const conflict = casTransition(deps, row, row.decision, { cleanupWarnings, statusCode: 'delivery_failed', statusDetail: detail });
|
|
2188
|
-
if (conflict)
|
|
2189
|
-
return conflict;
|
|
2190
|
-
finalizeTransition(deps, row.id);
|
|
2191
|
-
return { status: 409, body: { error: 'merge_local_blocked', reason: 'head_changed', base: integrationTarget, detail } };
|
|
2192
|
-
}
|
|
2193
|
-
await removeAssembly();
|
|
2194
|
-
// Post-advance sweep: the work now lives on
|
|
2195
|
-
// the integration branch, so the launch's worktrees + branches are spent.
|
|
2196
|
-
cleanupWarnings.push(...await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
2197
|
-
db: deps.db,
|
|
2198
|
-
git: deps.git,
|
|
2199
|
-
repoDir: deps.project.path,
|
|
2200
|
-
worktreeIds: releasableWorktreeIds(deps, snap),
|
|
2201
|
-
state: 'merged',
|
|
2202
|
-
expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
|
|
2203
|
-
overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
|
|
2204
|
-
settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
|
|
2205
|
-
onSafetyArchive: safetyArchiveRecorder(deps, row),
|
|
2206
|
-
}));
|
|
2207
|
-
await deleteOwnedBranchesIfUnchanged(deps, row, snap, cleanupWarnings);
|
|
2208
|
-
const conflict = casTransitionWithTicketEffect(deps, row, 'merged', {
|
|
2209
|
-
deliveryOutcome: 'delivered',
|
|
2210
|
-
statusCode: cleanupWarnings.length > 0 ? 'cleanup_incomplete' : 'merged',
|
|
2211
|
-
cleanupWarnings,
|
|
2212
|
-
}, {
|
|
2213
|
-
deliveryId: row.id,
|
|
2214
|
-
ticketIds: snap.ticketIds,
|
|
2215
|
-
targetStatus: 'done',
|
|
2216
|
-
jiraAction: 'merged',
|
|
2217
|
-
prUrl: null,
|
|
2218
|
-
});
|
|
2219
|
-
if (conflict)
|
|
2220
|
-
return conflict;
|
|
2221
|
-
applyTerminalTicketEffect(deps, row, 'merged', cleanupWarnings);
|
|
2222
|
-
finalizeTransition(deps, row.id);
|
|
2223
|
-
await sweepMergedChainAncestors(deps, row);
|
|
2224
|
-
return { status: 200, body: { ok: true, decision: 'merged', merged: true, local: true } };
|
|
2225
|
-
}
|
|
2226
|
-
finally {
|
|
2227
|
-
// Runner exceptions (spawn failures/timeouts) must release the temporary
|
|
2228
|
-
// assembly as well. A failed non-force removal leaves a recorded archive.
|
|
2229
|
-
await removeAssembly();
|
|
2230
|
-
}
|
|
2231
|
-
}
|
|
2232
|
-
function resolveTicketFile(deps) {
|
|
2233
|
-
if (deps.ticketFile)
|
|
2234
|
-
return deps.ticketFile;
|
|
2235
|
-
const exec = (0, workspace_resolution_1.resolveProjectExecution)({ slug: deps.project.slug, path: deps.project.path });
|
|
2236
|
-
return exec.relocated ? exec.ticketsPath : (0, ticket_store_1.resolveTicketStoragePath)(deps.project.path);
|
|
2237
|
-
}
|
|
2238
|
-
// ─── Milestone chain hooks (premium-milestone-progress D4) ───────────────────
|
|
2239
|
-
/** Decisions a stacked sibling can sit at while its head is already merged. */
|
|
2240
|
-
const SWEEPABLE_DECISIONS = new Set(['on_review', 'pr_draft', 'pr_ready', 'pr_closed']);
|
|
2241
|
-
/**
|
|
2242
|
-
* Merging a STACKED delivery also lands every earlier chunk it was built on.
|
|
2243
|
-
* Walk the chain(s) this delivery belongs to and settle each still-undecided
|
|
2244
|
-
* sibling whose delivered head commit is PROVABLY an ancestor of the chain's
|
|
2245
|
-
* integration branch (`git merge-base --is-ancestor`) as `merged` — the same
|
|
2246
|
-
* CAS + ticket effect (`done`) + Jira hook a direct merge runs. Chain-local by
|
|
2247
|
-
* design; siblings whose head is not an ancestor are left untouched. Returns
|
|
2248
|
-
* the swept delivery ids. Never throws.
|
|
2249
|
-
*/
|
|
2250
|
-
async function sweepMergedChainAncestors(deps, mergedRow) {
|
|
2251
|
-
const swept = [];
|
|
2252
|
-
if (deps.repositoryChildOf)
|
|
2253
|
-
return swept;
|
|
2254
|
-
let chains;
|
|
2255
|
-
try {
|
|
2256
|
-
chains = (0, milestone_chain_store_1.listChainsTouchingDelivery)(deps.db, mergedRow.id);
|
|
2257
|
-
}
|
|
2258
|
-
catch {
|
|
2259
|
-
return swept;
|
|
2260
|
-
}
|
|
2261
|
-
for (const chain of chains) {
|
|
2262
|
-
const integration = chain.integration_branch;
|
|
2263
|
-
if (!integration)
|
|
2264
|
-
continue;
|
|
2265
|
-
for (const entry of (0, milestone_chain_store_1.parseLaunched)(chain)) {
|
|
2266
|
-
if (!entry.deliveryId || entry.deliveryId === mergedRow.id || swept.includes(entry.deliveryId))
|
|
2267
|
-
continue;
|
|
2268
|
-
const other = (0, rail_pr_store_2.getPrDelivery)(deps.db, entry.deliveryId);
|
|
2269
|
-
if (!other || (0, rail_pr_store_2.isTerminalPrDecision)(other.decision) || !SWEEPABLE_DECISIONS.has(other.decision))
|
|
2270
|
-
continue;
|
|
2271
|
-
const snap = (0, rail_pr_store_2.toPrDeliverySnapshot)(other);
|
|
2272
|
-
// An assembled delivery SHA proves the whole chunk. Without that head,
|
|
2273
|
-
// every unit needs its own immutable proof; accepting just the first
|
|
2274
|
-
// ancestor could mark unmerged tickets done and delete their branches.
|
|
2275
|
-
const requiredShas = snap.deliverySha ? [snap.deliverySha] : snap.units.map((unit) => unit.finalSha);
|
|
2276
|
-
const validShas = requiredShas.filter((sha) => typeof sha === 'string' && COMMIT_SHA_RE.test(sha));
|
|
2277
|
-
if (requiredShas.length === 0 || validShas.length !== requiredShas.length)
|
|
2278
|
-
continue;
|
|
2279
|
-
let allAncestors = true;
|
|
2280
|
-
try {
|
|
2281
|
-
for (const sha of new Set(validShas)) {
|
|
2282
|
-
const r = await deps.git.run(['merge-base', '--is-ancestor', sha, integration], deps.project.path);
|
|
2283
|
-
if (r.code !== 0) {
|
|
2284
|
-
allAncestors = false;
|
|
2285
|
-
break;
|
|
2286
|
-
}
|
|
2287
|
-
}
|
|
2288
|
-
}
|
|
2289
|
-
catch {
|
|
2290
|
-
allAncestors = false;
|
|
2291
|
-
}
|
|
2292
|
-
if (!allAncestors)
|
|
2293
|
-
continue;
|
|
2294
|
-
const token = (0, ids_1.newId)();
|
|
2295
|
-
if (!(0, rail_pr_store_2.claimPrDeliveryOperation)(deps.db, other.id, other.decision, 'poll-merge', token))
|
|
2296
|
-
continue;
|
|
2297
|
-
let released = false;
|
|
2298
|
-
try {
|
|
2299
|
-
const claimed = { ...other, operation_token: token };
|
|
2300
|
-
const cleanupWarnings = await (0, rail_worktree_release_1.releaseRailWorktrees)({
|
|
2301
|
-
db: deps.db, git: deps.git, repoDir: deps.project.path,
|
|
2302
|
-
worktreeIds: releasableWorktreeIds(deps, snap), state: 'merged',
|
|
2303
|
-
expectedHeadByBranch: (0, rail_worktree_release_1.durableBranchHeads)(snap.branches),
|
|
2304
|
-
overlayEvidenceByBranch: (0, rail_worktree_release_1.durableOverlayCleanupEvidence)(snap.branches),
|
|
2305
|
-
settlementIgnoredByBranch: (0, rail_worktree_release_1.durableSettlementIgnoredPaths)(snap.branches),
|
|
2306
|
-
onSafetyArchive: safetyArchiveRecorder(deps, claimed),
|
|
2307
|
-
});
|
|
2308
|
-
await deleteOwnedBranchesIfUnchanged(deps, claimed, snap, cleanupWarnings);
|
|
2309
|
-
const conflict = casTransitionWithTicketEffect(deps, claimed, 'merged', {
|
|
2310
|
-
deliveryOutcome: 'delivered',
|
|
2311
|
-
statusCode: cleanupWarnings.length > 0 ? 'cleanup_incomplete' : 'merged',
|
|
2312
|
-
statusDetail: `merged as part of ${mergedRow.rail_key}`,
|
|
2313
|
-
cleanupWarnings,
|
|
2314
|
-
}, {
|
|
2315
|
-
deliveryId: claimed.id,
|
|
2316
|
-
ticketIds: snap.ticketIds,
|
|
2317
|
-
targetStatus: 'done',
|
|
2318
|
-
jiraAction: 'merged',
|
|
2319
|
-
prUrl: claimed.pr_url,
|
|
2320
|
-
});
|
|
2321
|
-
if (conflict)
|
|
2322
|
-
continue;
|
|
2323
|
-
applyTerminalTicketEffect(deps, claimed, 'merged', cleanupWarnings);
|
|
2324
|
-
// Release BEFORE finalizing: a row still holding a lease defers its
|
|
2325
|
-
// broadcast to the lease owner's finally, which here would never come.
|
|
2326
|
-
(0, rail_pr_store_2.releasePrDeliveryOperation)(deps.db, other.id, token);
|
|
2327
|
-
released = true;
|
|
2328
|
-
finalizeTransition(deps, other.id);
|
|
2329
|
-
swept.push(other.id);
|
|
2330
|
-
}
|
|
2331
|
-
catch (err) {
|
|
2332
|
-
console.error(`[milestone-chain] ancestor sweep failed for ${other.id}:`, err);
|
|
2333
|
-
}
|
|
2334
|
-
finally {
|
|
2335
|
-
if (!released)
|
|
2336
|
-
(0, rail_pr_store_2.releasePrDeliveryOperation)(deps.db, other.id, token);
|
|
2337
|
-
}
|
|
2338
|
-
}
|
|
2339
|
-
}
|
|
2340
|
-
return swept;
|
|
2341
|
-
}
|
|
2342
|
-
/** A discarded delivery that a chain's later chunks build on pauses those
|
|
2343
|
-
* chains (`head_discarded`, head rewound to the previous chunk's branch). */
|
|
2344
|
-
function pauseChainsOnDiscard(deps, row) {
|
|
2345
|
-
if (deps.repositoryChildOf)
|
|
2346
|
-
return;
|
|
2347
|
-
try {
|
|
2348
|
-
const branchOf = (id) => {
|
|
2349
|
-
const other = (0, rail_pr_store_2.getPrDelivery)(deps.db, id);
|
|
2350
|
-
if (!other)
|
|
2351
|
-
return null;
|
|
2352
|
-
const s = (0, rail_pr_store_2.toPrDeliverySnapshot)(other);
|
|
2353
|
-
return s.branch ?? s.units.find((u) => u.succeeded && u.branch)?.branch ?? null;
|
|
2354
|
-
};
|
|
2355
|
-
const paused = (0, milestone_chain_store_1.pauseChainsForDiscardedHead)(deps.db, row.id, branchOf);
|
|
2356
|
-
for (const chain of paused) {
|
|
2357
|
-
deps.broadcast({ type: 'milestone.chain_changed', projectId: deps.project.id, chain: (0, milestone_chain_store_1.toChainSnapshot)(chain), timestamp: new Date().toISOString() });
|
|
2358
|
-
}
|
|
2359
|
-
}
|
|
2360
|
-
catch (err) {
|
|
2361
|
-
console.error('[milestone-chain] discard hook failed:', err);
|
|
2362
|
-
}
|
|
2363
|
-
}
|
|
2364
|
-
/** Local-integration target: a chain-launched (stacked) delivery integrates
|
|
2365
|
-
* into the chain's integration branch, never into its feature base. */
|
|
2366
|
-
function mergeLocalTargetBranch(deps, row) {
|
|
2367
|
-
if (deps.repositoryChildOf)
|
|
2368
|
-
return deps.repositoryIntegrationBranch ?? row.base_branch;
|
|
2369
|
-
try {
|
|
2370
|
-
for (const chain of (0, milestone_chain_store_1.listChainsTouchingDelivery)(deps.db, row.id)) {
|
|
2371
|
-
if (chain.integration_branch)
|
|
2372
|
-
return chain.integration_branch;
|
|
2373
|
-
}
|
|
2374
|
-
}
|
|
2375
|
-
catch { /* fall through */ }
|
|
2376
|
-
return row.base_branch;
|
|
2377
|
-
}
|