diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 6c8cd7011..f120834b1 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -62,8 +62,6 @@ ade git user-identity --lane lane-id --text ade prs create --lane lane-id --base main --title "Fix checkout flow" ade prs list-open --text ade prs path-to-merge --pr pr-id --model gpt-5.5 --max-rounds 3 --no-auto-merge -ade prs path-to-merge --pr pr-id --model gpt-5.5 --conflict-strategy auto --force-finalize conditional -ade prs pipeline pr-id save --conflict-strategy rebase --no-early-merge-on-green ade run defs --text ade run start web --lane lane-id ade shell start --lane lane-id -- npm test @@ -83,25 +81,6 @@ ade cursor cloud me Use typed commands first. They validate common arguments and provide stable JSON fields or readable text summaries. Use `ade help ` for exact flags, `ade actions list --text` to discover the full service-backed action catalog, and `ade actions run ` only when there is no typed command for the workflow yet. -The `prs path-to-merge` and `prs pipeline save` commands persist a partial `PipelineSettings` patch via `issue_inventory.savePipelineSettings` before launching the resolver. The Path to Merge orchestrator reads these from saved settings, so the same flags work either way: - -| Flag | PipelineSettings field | Values | -| --- | --- | --- | -| `--max-rounds ` (alias `--rounds`) | `maxRounds` | positive integer | -| `--auto-merge` / `--no-auto-merge` | `autoMerge` | boolean | -| `--merge-method ` | `mergeMethod` | `repo_default` \| `merge` \| `squash` \| `rebase` | -| `--conflict-strategy ` | `conflictStrategy` | `pause` \| `rebase` \| `merge` \| `auto` | -| `--force-finalize ` | `forceFinalizeMode` | `off` \| `conditional` \| `unconditional` | -| `--force-finalize-require-no-ci` / `--force-finalize-allow-ci` | `forceFinalizeRequireNoCiFailures` | boolean | -| `--early-merge-on-green` / `--no-early-merge-on-green` | `earlyMergeOnGreen` | boolean | - -To set fields without a dedicated flag (for example `autoAgentSettings`), call the action directly: - -```bash -ade actions run issue_inventory.savePipelineSettings --args-list-json \ - '["pr-1",{"autoAgentSettings":{"provider":"claude","model":"sonnet","reasoningEffort":"high","permissionMode":"guarded_edit","confidenceThreshold":0.7}}]' -``` - Output modes are explicit: ```bash diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 0e424d6b6..a8ce434a6 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -233,72 +233,6 @@ describe("ADE CLI", () => { }); }); - it("forwards new pipeline-settings flags through Path to Merge", () => { - const plan = buildCliPlan([ - "prs", - "path-to-merge", - "pr-2", - "--model", - "gpt-5.4", - "--conflict-strategy", - "auto", - "--force-finalize", - "conditional", - "--no-early-merge-on-green", - ]); - expect(plan.kind).toBe("execute"); - if (plan.kind !== "execute") return; - expect(plan.steps).toHaveLength(2); - expect(plan.steps[0]?.params).toEqual({ - name: "run_ade_action", - arguments: { - domain: "issue_inventory", - action: "savePipelineSettings", - argsList: [ - "pr-2", - { - conflictStrategy: "auto", - forceFinalizeMode: "conditional", - earlyMergeOnGreen: false, - }, - ], - }, - }); - expect(plan.steps[1]?.params).toEqual({ - name: "pr_start_issue_resolution", - arguments: { - prId: "pr-2", - scope: "both", - modelId: "gpt-5.4", - }, - }); - }); - - it("rejects invalid pipeline-settings enum values", () => { - expect(() => - buildCliPlan([ - "prs", - "path-to-merge", - "pr-3", - "--model", - "gpt-5.4", - "--conflict-strategy", - "wat", - ]), - ).toThrow(/--conflict-strategy must be one of/); - expect(() => - buildCliPlan([ - "prs", - "path-to-merge", - "pr-3", - "--model", - "gpt-5.4", - "--force-finalize", - "always", - ]), - ).toThrow(/--force-finalize must be one of/); - }); - it("validates required arguments before service execution", () => { expect(() => buildCliPlan(["lanes", "create"])).toThrow(/name is required/); expect(() => buildCliPlan(["lanes", "child", "--name", "child"])).toThrow(/parent lane is required/); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index f1017b738..01021ecfb 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -725,9 +725,6 @@ const HELP_BY_COMMAND: Record = { $ ade prs comments --text Show unresolved review work $ ade prs inventory Refresh ADE issue inventory $ ade prs path-to-merge --model --max-rounds 3 --no-auto-merge - $ ade prs path-to-merge --model --conflict-strategy auto --force-finalize conditional - $ ade prs path-to-merge --model --no-early-merge-on-green - $ ade prs pipeline save --conflict-strategy rebase --early-merge-on-green $ ade prs resolve-thread --thread Resolve a review thread $ ade prs labels set ready-to-merge Replace labels $ ade prs reviewers request alice bob Request reviewers @@ -1337,72 +1334,6 @@ function maybePut(target: JsonObject, key: string, value: unknown): void { } } -/** - * Parse the PR pipeline-settings flags shared by `prs path-to-merge` and - * `prs pipeline` subcommands. Returns a partial `PipelineSettings` patch - * suitable for `issue_inventory.savePipelineSettings`. Only fields the user - * explicitly passed are included so the patch never clobbers other settings. - * - * The orchestrator reads these from saved settings (not StartPathToMergeArgs), - * so the path-to-merge command must save them before launching the loop. - */ -function readPipelineSettingsPatch(args: string[]): JsonObject { - const patch: JsonObject = {}; - - const maxRounds = readIntOption(args, ["--max-rounds", "--rounds"]); - if (maxRounds != null) patch.maxRounds = maxRounds; - - const autoMerge = readFlag(args, ["--auto-merge"]); - const noAutoMerge = readFlag(args, ["--no-auto-merge"]); - if (autoMerge || noAutoMerge) patch.autoMerge = autoMerge && !noAutoMerge; - - const mergeMethod = readValue(args, ["--merge-method"]); - if (mergeMethod) patch.mergeMethod = mergeMethod; - - const conflictStrategy = readValue(args, ["--conflict-strategy"]); - if (conflictStrategy) { - if ( - conflictStrategy !== "pause" - && conflictStrategy !== "rebase" - && conflictStrategy !== "merge" - && conflictStrategy !== "auto" - ) { - throw new CliUsageError( - "--conflict-strategy must be one of pause, rebase, merge, or auto.", - ); - } - patch.conflictStrategy = conflictStrategy; - } - - const forceFinalize = readValue(args, ["--force-finalize"]); - if (forceFinalize) { - if ( - forceFinalize !== "off" - && forceFinalize !== "conditional" - && forceFinalize !== "unconditional" - ) { - throw new CliUsageError( - "--force-finalize must be one of off, conditional, or unconditional.", - ); - } - patch.forceFinalizeMode = forceFinalize; - } - - const requireNoCi = readFlag(args, ["--force-finalize-require-no-ci"]); - const allowCi = readFlag(args, ["--force-finalize-allow-ci"]); - if (requireNoCi || allowCi) { - patch.forceFinalizeRequireNoCiFailures = requireNoCi && !allowCi; - } - - const earlyMergeOn = readFlag(args, ["--early-merge-on-green"]); - const earlyMergeOff = readFlag(args, ["--no-early-merge-on-green"]); - if (earlyMergeOn || earlyMergeOff) { - patch.earlyMergeOnGreen = earlyMergeOn && !earlyMergeOff; - } - - return patch; -} - function parseCliArgs(argv: string[]): ParsedCli { const command: string[] = []; const options: GlobalOptions = { @@ -1976,16 +1907,19 @@ function buildPrPlan(args: string[]): CliPlan { maybePut(input, "reasoning", readValue(args, ["--reasoning"])); maybePut(input, "permissionMode", readValue(args, ["--permission-mode", "--permissions"])); maybePut(input, "additionalInstructions", readValue(args, ["--instructions", "--additional-instructions"])); - // Path to Merge orchestrator reads conflictStrategy / forceFinalizeMode / - // earlyMergeOnGreen / autoMerge / maxRounds / mergeMethod from saved - // PipelineSettings, not from the launch args. Persist any user-supplied - // overrides before the resolver step so the loop picks them up. - const pipelinePatch = readPipelineSettingsPatch(args); + const maxRounds = readIntOption(args, ["--max-rounds", "--rounds"]); + const autoMerge = readFlag(args, ["--auto-merge"]); + const noAutoMerge = readFlag(args, ["--no-auto-merge"]); + const mergeMethod = readValue(args, ["--merge-method"]); const steps: InvocationStep[] = []; - if (Object.keys(pipelinePatch).length > 0) { + if (maxRounds != null || autoMerge || noAutoMerge || mergeMethod) { steps.push(actionArgsListStep("pipelineSettings", "issue_inventory", "savePipelineSettings", [ id, - pipelinePatch, + { + ...(maxRounds != null ? { maxRounds } : {}), + ...(autoMerge || noAutoMerge ? { autoMerge: autoMerge && !noAutoMerge } : {}), + ...(mergeMethod ? { mergeMethod } : {}), + }, ])); } steps.push(actionCallStep("result", mode === "preview" ? "pr_preview_issue_resolution_prompt" : "pr_start_issue_resolution", collectGenericObjectArgs(args, input))); @@ -1997,7 +1931,12 @@ function buildPrPlan(args: string[]): CliPlan { const id = requireValue(prId ?? firstPositional(args), "prId"); if (mode === "get") return { kind: "execute", label: "PR pipeline", steps: [actionArgsListStep("result", "issue_inventory", "getPipelineSettings", [id])] }; if (mode === "delete") return { kind: "execute", label: "PR pipeline delete", steps: [actionArgsListStep("result", "issue_inventory", "deletePipelineSettings", [id])] }; - const settings = collectGenericObjectArgs(args, readPipelineSettingsPatch(args)); + const maxRounds = readIntOption(args, ["--max-rounds", "--rounds"]); + const mergeMethod = readValue(args, ["--merge-method"]); + const settings = collectGenericObjectArgs(args, { + ...(maxRounds != null ? { maxRounds } : {}), + ...(mergeMethod ? { mergeMethod } : {}), + }); return { kind: "execute", label: "PR pipeline save", steps: [actionArgsListStep("result", "issue_inventory", "savePipelineSettings", [id, settings])] }; } diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 313669abe..9fb9e8a94 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -44,7 +44,6 @@ import { createPrService } from "./services/prs/prService"; import { createPrPollingService } from "./services/prs/prPollingService"; import { createQueueLandingService } from "./services/prs/queueLandingService"; import { createIssueInventoryService } from "./services/prs/issueInventoryService"; -import { createPathToMergeOrchestrator } from "./services/prs/pathToMergeOrchestrator"; import { createPrSummaryService } from "./services/prs/prSummaryService"; import { detectDefaultBaseRef, @@ -2425,27 +2424,6 @@ app.whenReady().then(async () => { // Wire agentChatService into prService for integration resolution prService.setAgentChatService(agentChatService); - const pathToMergeOrchestrator = createPathToMergeOrchestrator({ - logger, - prService, - laneService, - agentChatService, - sessionService, - issueInventoryService, - conflictService, - defaultModelId: null, - defaultReasoningEffort: null, - }); - setImmediate(() => { - try { - pathToMergeOrchestrator.resumeFromPersistedState(); - } catch (err) { - logger.warn("path_to_merge.resume_failed", { - error: err instanceof Error ? err.message : String(err), - }); - } - }); - const gitService = createGitOperationsService({ laneService, operationService, @@ -2947,7 +2925,6 @@ app.whenReady().then(async () => { conflictService, prService, issueInventoryService, - pathToMergeOrchestrator, queueLandingService, sessionService, ptyService, @@ -3786,7 +3763,6 @@ app.whenReady().then(async () => { appControlService, queueLandingService, issueInventoryService, - pathToMergeOrchestrator, prSummaryService, reviewService, jobEngine, @@ -3898,7 +3874,6 @@ app.whenReady().then(async () => { prPollingService: null, queueLandingService: null, issueInventoryService: null, - pathToMergeOrchestrator: null, prSummaryService: null, reviewService: null, jobEngine: null, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 5d477a6c6..280fdc052 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -587,7 +587,6 @@ import type { createPrService } from "../prs/prService"; import type { createPrPollingService } from "../prs/prPollingService"; import type { createQueueLandingService } from "../prs/queueLandingService"; import type { createIssueInventoryService } from "../prs/issueInventoryService"; -import type { PathToMergeOrchestrator } from "../prs/pathToMergeOrchestrator"; import type { createPrSummaryService } from "../prs/prSummaryService"; import type { createReviewService } from "../review/reviewService"; import type { createAgentChatService } from "../chat/agentChatService"; @@ -692,7 +691,6 @@ export type AppContext = { prPollingService: ReturnType; queueLandingService: ReturnType; issueInventoryService: ReturnType; - pathToMergeOrchestrator?: PathToMergeOrchestrator | null; prSummaryService: ReturnType; reviewService: ReturnType; jobEngine: ReturnType; @@ -7276,11 +7274,6 @@ export function registerIpc({ return await ctx.prService.landStack(arg); }); - ipcMain.handle(IPC.prsRetargetBase, async (_event, arg: { prId: string; baseBranch: string }): Promise => { - const ctx = getCtx(); - return await ctx.prService.retargetBase(arg.prId, arg.baseBranch); - }); - ipcMain.handle(IPC.prsOpenInGitHub, async (_event, arg: { prId: string }): Promise => { const ctx = getCtx(); return await ctx.prService.openInGitHub(arg.prId); @@ -7876,49 +7869,6 @@ export function registerIpc({ ipcMain.handle(IPC.prsConvergenceStateDelete, (_e, args: { prId: string }): void => getCtx().issueInventoryService.resetConvergenceRuntime(args.prId)); - ipcMain.handle( - IPC.prsPathToMergeStart, - async (_e, args: { - prId: string; - modelId?: string | null; - reasoning?: string | null; - scope?: "checks" | "comments" | "both"; - additionalInstructions?: string | null; - }) => { - const orchestrator = getCtx().pathToMergeOrchestrator; - if (!orchestrator) { - throw new Error("Path to Merge orchestrator is not available in this build."); - } - const prId = typeof args?.prId === "string" ? args.prId.trim() : ""; - if (!prId) throw new Error("prId is required"); - return await orchestrator.startPathToMerge({ - prId, - modelId: typeof args?.modelId === "string" ? args.modelId : null, - reasoning: typeof args?.reasoning === "string" ? args.reasoning : null, - scope: args?.scope === "checks" || args?.scope === "comments" || args?.scope === "both" - ? args.scope - : undefined, - additionalInstructions: typeof args?.additionalInstructions === "string" ? args.additionalInstructions : null, - }); - }, - ); - - ipcMain.handle( - IPC.prsPathToMergeStop, - async (_e, args: { prId: string; reason?: string | null }) => { - const orchestrator = getCtx().pathToMergeOrchestrator; - if (!orchestrator) { - throw new Error("Path to Merge orchestrator is not available in this build."); - } - const prId = typeof args?.prId === "string" ? args.prId.trim() : ""; - if (!prId) throw new Error("prId is required"); - return await orchestrator.stopPathToMerge({ - prId, - reason: typeof args?.reason === "string" ? args.reason : null, - }); - }, - ); - ipcMain.handle(IPC.prsPipelineSettingsGet, (_e, args: { prId: string }): PipelineSettings => getCtx().issueInventoryService.getPipelineSettings(args.prId)); ipcMain.handle(IPC.prsPipelineSettingsSave, (_e, args: { prId: string; settings: Partial }): void => diff --git a/apps/desktop/src/main/services/prs/issueInventoryService.ts b/apps/desktop/src/main/services/prs/issueInventoryService.ts index a3f949bd8..748a9275d 100644 --- a/apps/desktop/src/main/services/prs/issueInventoryService.ts +++ b/apps/desktop/src/main/services/prs/issueInventoryService.ts @@ -1,13 +1,9 @@ import { randomUUID } from "node:crypto"; import type { AdeDb } from "../state/kvDb"; import type { - AutoConflictAgentProvider, - AutoConflictAgentSettings, - ConflictStrategy, ConvergenceRoundStat, ConvergenceStatus, ConvergenceRuntimeState, - ForceFinalizeMode, IssueInventoryItem, IssueInventorySnapshot, IssueInventoryState, @@ -17,17 +13,7 @@ import type { PrComment, PrReviewThread, } from "../../../shared/types"; -import { - DEFAULT_AUTO_CONFLICT_AGENT_SETTINGS, - DEFAULT_CONVERGENCE_RUNTIME_STATE, - DEFAULT_PIPELINE_SETTINGS, - conflictStrategyFromLegacyRebasePolicy, - legacyRebasePolicyFromConflictStrategy, -} from "../../../shared/types"; - -const CONFLICT_STRATEGY_VALUES = new Set(["pause", "rebase", "merge", "auto"]); -const FORCE_FINALIZE_MODE_VALUES = new Set(["off", "unconditional", "conditional"]); -const AUTO_AGENT_PROVIDER_VALUES = new Set(["claude", "codex"]); +import { DEFAULT_CONVERGENCE_RUNTIME_STATE, DEFAULT_PIPELINE_SETTINGS } from "../../../shared/types"; import { isNoisyIssueComment, looksLikeResolutionAck } from "./resolverUtils"; import { nowIso } from "../shared/utils"; @@ -463,60 +449,13 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { merge_method: string; max_rounds: number; on_rebase_needed: string; - conflict_strategy: string | null; - force_finalize_mode: string | null; - force_finalize_require_no_ci_failures: number | null; - early_merge_on_green: number | null; - auto_agent_provider: string | null; - auto_agent_model: string | null; - auto_agent_reasoning_effort: string | null; - auto_agent_permission_mode: string | null; - auto_agent_confidence_threshold: number | null; - }>( - `select auto_merge, merge_method, max_rounds, on_rebase_needed, - conflict_strategy, force_finalize_mode, - force_finalize_require_no_ci_failures, early_merge_on_green, - auto_agent_provider, auto_agent_model, auto_agent_reasoning_effort, - auto_agent_permission_mode, auto_agent_confidence_threshold - from pr_pipeline_settings where pr_id = ?`, - [prId], - ); - if (!row) return { ...DEFAULT_PIPELINE_SETTINGS, autoAgentSettings: { ...DEFAULT_AUTO_CONFLICT_AGENT_SETTINGS } }; - - const onRebaseNeeded = row.on_rebase_needed as PipelineSettings["onRebaseNeeded"]; - const rawConflictStrategy = row.conflict_strategy; - const conflictStrategy: ConflictStrategy = rawConflictStrategy && CONFLICT_STRATEGY_VALUES.has(rawConflictStrategy as ConflictStrategy) - ? (rawConflictStrategy as ConflictStrategy) - : conflictStrategyFromLegacyRebasePolicy(onRebaseNeeded); - - const rawForceFinalize = row.force_finalize_mode; - const forceFinalizeMode: ForceFinalizeMode = rawForceFinalize && FORCE_FINALIZE_MODE_VALUES.has(rawForceFinalize as ForceFinalizeMode) - ? (rawForceFinalize as ForceFinalizeMode) - : DEFAULT_PIPELINE_SETTINGS.forceFinalizeMode; - - const rawProvider = row.auto_agent_provider; - const provider: AutoConflictAgentProvider | null = rawProvider && AUTO_AGENT_PROVIDER_VALUES.has(rawProvider as AutoConflictAgentProvider) - ? (rawProvider as AutoConflictAgentProvider) - : null; - - const autoAgentSettings: AutoConflictAgentSettings = { - provider, - model: row.auto_agent_model, - reasoningEffort: row.auto_agent_reasoning_effort, - permissionMode: row.auto_agent_permission_mode as AutoConflictAgentSettings["permissionMode"], - confidenceThreshold: row.auto_agent_confidence_threshold, - }; - + }>("select auto_merge, merge_method, max_rounds, on_rebase_needed from pr_pipeline_settings where pr_id = ?", [prId]); + if (!row) return { ...DEFAULT_PIPELINE_SETTINGS }; return { autoMerge: row.auto_merge === 1, mergeMethod: row.merge_method as PipelineSettings["mergeMethod"], maxRounds: row.max_rounds, - onRebaseNeeded, - conflictStrategy, - autoAgentSettings, - forceFinalizeMode, - forceFinalizeRequireNoCiFailures: (row.force_finalize_require_no_ci_failures ?? 1) === 1, - earlyMergeOnGreen: (row.early_merge_on_green ?? 1) === 1, + onRebaseNeeded: row.on_rebase_needed as PipelineSettings["onRebaseNeeded"], }; } @@ -983,37 +922,6 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { db.run("delete from pr_convergence_state where pr_id = ?", [prId]); }, - /** - * Persist the original {@link StartPathToMergeArgs} alongside the - * convergence state so the orchestrator can rehydrate them after a desktop - * restart. The blob is opaque JSON — typed at the call site. - */ - savePathToMergeArgs(prId: string, args: Record | null): void { - // Make sure the row exists so the alter-table column has somewhere to land. - readConvergenceRuntime(prId); - saveConvergenceRuntimeState(prId, {}); - const payload = args == null ? null : JSON.stringify(args); - db.run( - "update pr_convergence_state set ptm_args_json = ? where pr_id = ?", - [payload, prId], - ); - }, - - getPathToMergeArgs(prId: string): Record | null { - const row = db.get<{ ptm_args_json: string | null }>( - "select ptm_args_json from pr_convergence_state where pr_id = ?", - [prId], - ); - const raw = row?.ptm_args_json; - if (!raw) return null; - try { - const parsed = JSON.parse(raw); - return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : null; - } catch { - return null; - } - }, - // ----- Pipeline settings (auto-converge / auto-merge) ----- getPipelineSettings(prId: string): PipelineSettings { @@ -1022,60 +930,18 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { savePipelineSettings(prId: string, settings: Partial): void { const current = readPipelineSettings(prId); - const merged: PipelineSettings = { - ...current, - ...settings, - autoAgentSettings: settings.autoAgentSettings - ? { ...current.autoAgentSettings, ...settings.autoAgentSettings } - : current.autoAgentSettings, - }; - // Keep the legacy `onRebaseNeeded` column in sync with the authoritative - // `conflictStrategy` so older readers (and the iOS sync layer) see a - // coherent value. - merged.onRebaseNeeded = legacyRebasePolicyFromConflictStrategy(merged.conflictStrategy); + const merged = { ...current, ...settings }; const now = nowIso(); db.run( - `insert into pr_pipeline_settings ( - pr_id, auto_merge, merge_method, max_rounds, on_rebase_needed, - conflict_strategy, force_finalize_mode, - force_finalize_require_no_ci_failures, early_merge_on_green, - auto_agent_provider, auto_agent_model, auto_agent_reasoning_effort, - auto_agent_permission_mode, auto_agent_confidence_threshold, - updated_at - ) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `insert into pr_pipeline_settings (pr_id, auto_merge, merge_method, max_rounds, on_rebase_needed, updated_at) + values (?, ?, ?, ?, ?, ?) on conflict(pr_id) do update set auto_merge = excluded.auto_merge, merge_method = excluded.merge_method, max_rounds = excluded.max_rounds, on_rebase_needed = excluded.on_rebase_needed, - conflict_strategy = excluded.conflict_strategy, - force_finalize_mode = excluded.force_finalize_mode, - force_finalize_require_no_ci_failures = excluded.force_finalize_require_no_ci_failures, - early_merge_on_green = excluded.early_merge_on_green, - auto_agent_provider = excluded.auto_agent_provider, - auto_agent_model = excluded.auto_agent_model, - auto_agent_reasoning_effort = excluded.auto_agent_reasoning_effort, - auto_agent_permission_mode = excluded.auto_agent_permission_mode, - auto_agent_confidence_threshold = excluded.auto_agent_confidence_threshold, updated_at = excluded.updated_at`, - [ - prId, - merged.autoMerge ? 1 : 0, - merged.mergeMethod, - merged.maxRounds, - merged.onRebaseNeeded, - merged.conflictStrategy, - merged.forceFinalizeMode, - merged.forceFinalizeRequireNoCiFailures ? 1 : 0, - merged.earlyMergeOnGreen ? 1 : 0, - merged.autoAgentSettings.provider, - merged.autoAgentSettings.model, - merged.autoAgentSettings.reasoningEffort, - merged.autoAgentSettings.permissionMode, - merged.autoAgentSettings.confidenceThreshold, - now, - ], + [prId, merged.autoMerge ? 1 : 0, merged.mergeMethod, merged.maxRounds, merged.onRebaseNeeded, now], ); }, diff --git a/apps/desktop/src/main/services/prs/pathToMergeOrchestrator.test.ts b/apps/desktop/src/main/services/prs/pathToMergeOrchestrator.test.ts deleted file mode 100644 index 3d1439864..000000000 --- a/apps/desktop/src/main/services/prs/pathToMergeOrchestrator.test.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - PHASE_DELAY_SECONDS, - createPathToMergeOrchestrator, - type PathToMergeDeps, -} from "./pathToMergeOrchestrator"; -import type { - ConvergenceRuntimeState, - PrSummary, -} from "../../../shared/types"; -import { DEFAULT_CONVERGENCE_RUNTIME_STATE, DEFAULT_PIPELINE_SETTINGS } from "../../../shared/types/prs"; - -// --------------------------------------------------------------------------- -// Test scaffolding — fake all deps. The orchestrator's public surface is just -// startPathToMerge / stopPathToMerge / resumeFromPersistedState / dispose, so -// almost every dep can be a no-op for these tests. Iterations are kicked off -// via setImmediate from startPathToMerge, but with prService.listAll() returning -// `[]` they abort at the "PR missing" branch before touching any other dep. -// --------------------------------------------------------------------------- - -function buildRuntime(prId: string, overrides: Partial = {}): ConvergenceRuntimeState { - return { - prId, - ...DEFAULT_CONVERGENCE_RUNTIME_STATE, - ...overrides, - }; -} - -function buildPrSummary(overrides: Partial = {}): PrSummary { - return { - id: "pr-1", - laneId: "lane-1", - githubPrNumber: 1, - state: "open", - title: "Test PR", - headBranch: "feature/x", - baseBranch: "main", - headSha: null, - htmlUrl: null, - body: null, - bodyMarkdown: null, - bodyAt: null, - createdAt: "2026-05-01T00:00:00.000Z", - updatedAt: "2026-05-01T00:00:00.000Z", - closedAt: null, - mergedAt: null, - mergedBy: null, - isDraft: false, - mergeable: null, - mergeableState: null, - mergeStateStatus: null, - behindBaseBy: 0, - aheadOfBaseBy: 0, - checksStatus: "passing", - reviewStatus: "approved", - requestedReviewers: [], - reviewers: [], - assignees: [], - labels: [], - additions: 0, - deletions: 0, - changedFiles: 0, - autoMergeEnabled: false, - autoMergeMethod: null, - autoMergeBy: null, - ...overrides, - } as PrSummary; -} - -function buildDeps(initial?: { - runtimeByPrId?: Map; - ptmArgsByPrId?: Map | null>; - prs?: PrSummary[]; -}): { - deps: PathToMergeDeps; - runtimeByPrId: Map; - ptmArgsByPrId: Map | null>; - interrupt: ReturnType; -} { - const runtimeByPrId = initial?.runtimeByPrId ?? new Map(); - const ptmArgsByPrId = initial?.ptmArgsByPrId ?? new Map | null>(); - const prs = initial?.prs ?? []; - - const interrupt = vi.fn(async (_args: { sessionId: string }) => undefined); - - const logger = { - debug: () => {}, - info: () => {}, - warn: () => {}, - error: () => {}, - } as unknown as PathToMergeDeps["logger"]; - - const deps: PathToMergeDeps = { - logger, - prService: { - listAll: () => prs, - // Iterations call refresh/getStatus/getChecks/land/runPostMergeCleanup, - // but with `prs = []` the iteration aborts before reaching them. - refresh: async () => undefined, - getStatus: async () => ({ behindBaseBy: 0 }), - getChecks: async () => [], - land: async () => ({ success: false, error: "test" }), - runPostMergeCleanup: async () => undefined, - } as unknown as PathToMergeDeps["prService"], - laneService: { - list: async () => [], - getLaneBaseAndBranch: () => ({ worktreePath: "/tmp/lane", baseRef: "main", branchRef: "feature/x" }), - } as unknown as PathToMergeDeps["laneService"], - agentChatService: { - createSession: async () => ({ id: "sess" }), - sendMessage: async () => undefined, - previewSessionToolNames: async () => [], - interrupt, - getSessionSummary: async () => null, - } as unknown as PathToMergeDeps["agentChatService"], - sessionService: { - updateMeta: async () => undefined, - } as unknown as PathToMergeDeps["sessionService"], - issueInventoryService: { - saveConvergenceRuntime: (prId: string, patch: Partial) => { - const prev = runtimeByPrId.get(prId) ?? buildRuntime(prId); - const next: ConvergenceRuntimeState = { ...prev, ...patch, prId }; - runtimeByPrId.set(prId, next); - return next; - }, - getConvergenceRuntime: (prId: string) => runtimeByPrId.get(prId) ?? buildRuntime(prId), - savePathToMergeArgs: (prId: string, args: Record | null) => { - ptmArgsByPrId.set(prId, args); - }, - getPathToMergeArgs: (prId: string) => ptmArgsByPrId.get(prId) ?? null, - getPipelineSettings: () => ({ ...DEFAULT_PIPELINE_SETTINGS }), - getConvergenceStatus: () => ({ - currentRound: 0, - maxRounds: 5, - issuesPerRound: [], - totalNew: 0, - totalFixed: 0, - totalDismissed: 0, - totalEscalated: 0, - totalSentToAgent: 0, - isConverging: false, - canAutoAdvance: false, - }), - } as unknown as PathToMergeDeps["issueInventoryService"], - conflictService: { - runExternalResolver: async () => ({ status: "completed" }), - } as unknown as PathToMergeDeps["conflictService"], - defaultModelId: null, - defaultReasoningEffort: null, - }; - - return { deps, runtimeByPrId, ptmArgsByPrId, interrupt }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("PHASE_DELAY_SECONDS", () => { - it("matches the shipLane.md §5.3 phase-delay contract exactly", () => { - // These values are part of the public contract — the orchestrator JSDoc - // pins them to 270/720/1800 from shipLane.md. Drift here changes operator - // expectations and must be a deliberate doc-update commit. - expect(PHASE_DELAY_SECONDS.justPushed).toBe(270); - expect(PHASE_DELAY_SECONDS.warming).toBe(720); - expect(PHASE_DELAY_SECONDS.waitingOnReview).toBe(1800); - }); -}); - -describe("createPathToMergeOrchestrator.startPathToMerge", () => { - it("rejects an empty prId so callers can't accidentally arm a global loop", async () => { - const { deps } = buildDeps(); - const orchestrator = createPathToMergeOrchestrator(deps); - try { - await expect(orchestrator.startPathToMerge({ prId: "" })).rejects.toThrow(/prId is required/); - await expect(orchestrator.startPathToMerge({ prId: " " })).rejects.toThrow(/prId is required/); - } finally { - orchestrator.dispose(); - } - }); - - it("persists the launching runtime state and original run args before scheduling", async () => { - const { deps, runtimeByPrId, ptmArgsByPrId } = buildDeps(); - const orchestrator = createPathToMergeOrchestrator(deps); - try { - const result = await orchestrator.startPathToMerge({ - prId: "pr-1", - modelId: "claude-opus", - reasoning: "high", - scope: "checks", - additionalInstructions: "be thorough", - }); - - expect(result.scheduled).toBe(true); - expect(result.prId).toBe("pr-1"); - expect(result.runtime.autoConvergeEnabled).toBe(true); - expect(result.runtime.status).toBe("launching"); - expect(result.runtime.pollerStatus).toBe("scheduled"); - - const persisted = runtimeByPrId.get("pr-1"); - expect(persisted?.autoConvergeEnabled).toBe(true); - expect(persisted?.status).toBe("launching"); - expect(persisted?.lastStartedAt).toBeTruthy(); - - // Run args are persisted so a desktop restart can resume with the same - // model/reasoning/scope rather than pausing on "No modelId available". - expect(ptmArgsByPrId.get("pr-1")).toEqual({ - modelId: "claude-opus", - reasoning: "high", - scope: "checks", - additionalInstructions: "be thorough", - }); - } finally { - orchestrator.dispose(); - } - }); -}); - -describe("createPathToMergeOrchestrator.stopPathToMerge", () => { - it("rejects an empty prId so a typo can't accidentally clear unrelated state", async () => { - const { deps } = buildDeps(); - const orchestrator = createPathToMergeOrchestrator(deps); - try { - await expect(orchestrator.stopPathToMerge({ prId: "" })).rejects.toThrow(/prId is required/); - await expect(orchestrator.stopPathToMerge({ prId: " \t" })).rejects.toThrow(/prId is required/); - } finally { - orchestrator.dispose(); - } - }); - - it("interrupts the active fix-agent session and records the stop reason", async () => { - const runtimeByPrId = new Map([ - ["pr-1", buildRuntime("pr-1", { - autoConvergeEnabled: true, - status: "running", - pollerStatus: "polling", - activeSessionId: "sess-active", - })], - ]); - const ptmArgsByPrId = new Map | null>([ - ["pr-1", { modelId: "claude-opus", reasoning: null, scope: "both", additionalInstructions: null }], - ]); - const { deps, interrupt } = buildDeps({ runtimeByPrId, ptmArgsByPrId }); - const orchestrator = createPathToMergeOrchestrator(deps); - try { - const result = await orchestrator.stopPathToMerge({ prId: "pr-1", reason: "operator paused" }); - - expect(result.stopped).toBe(true); - expect(interrupt).toHaveBeenCalledTimes(1); - expect(interrupt).toHaveBeenCalledWith({ sessionId: "sess-active" }); - - const persisted = runtimeByPrId.get("pr-1"); - expect(persisted?.autoConvergeEnabled).toBe(false); - expect(persisted?.status).toBe("stopped"); - expect(persisted?.activeSessionId).toBeNull(); - expect(persisted?.pauseReason).toBe("operator paused"); - expect(persisted?.lastStoppedAt).toBeTruthy(); - - // Persisted run-args must be cleared so a future resume doesn't think - // this PR is still in flight. - expect(ptmArgsByPrId.get("pr-1")).toBeNull(); - } finally { - orchestrator.dispose(); - } - }); - - it("does not call interrupt when no fix-agent session is recorded", async () => { - const runtimeByPrId = new Map([ - ["pr-1", buildRuntime("pr-1", { - autoConvergeEnabled: true, - status: "paused", - pollerStatus: "paused", - activeSessionId: null, - })], - ]); - const { deps, interrupt } = buildDeps({ runtimeByPrId }); - const orchestrator = createPathToMergeOrchestrator(deps); - try { - const result = await orchestrator.stopPathToMerge({ prId: "pr-1" }); - - expect(result.stopped).toBe(true); - expect(interrupt).not.toHaveBeenCalled(); - expect(runtimeByPrId.get("pr-1")?.status).toBe("stopped"); - } finally { - orchestrator.dispose(); - } - }); -}); - -describe("createPathToMergeOrchestrator.resumeFromPersistedState", () => { - it("only rearms PRs whose runtime is still flagged as live", async () => { - const runtimeByPrId = new Map([ - // Live — should be rearmed. - ["pr-live", buildRuntime("pr-live", { - autoConvergeEnabled: true, - status: "running", - pollerStatus: "polling", - })], - // Already merged — should be skipped. - ["pr-merged", buildRuntime("pr-merged", { - autoConvergeEnabled: true, - status: "merged", - pollerStatus: "polling", - })], - // Operator-stopped — should be skipped. - ["pr-stopped", buildRuntime("pr-stopped", { - autoConvergeEnabled: true, - status: "stopped", - pollerStatus: "polling", - })], - // Disabled — should be skipped. - ["pr-disabled", buildRuntime("pr-disabled", { - autoConvergeEnabled: false, - status: "running", - pollerStatus: "polling", - })], - // Poller already stopped — should be skipped. - ["pr-poller-stopped", buildRuntime("pr-poller-stopped", { - autoConvergeEnabled: true, - status: "running", - pollerStatus: "stopped", - })], - ]); - const ptmArgsByPrId = new Map | null>(); - const prs = [ - buildPrSummary({ id: "pr-live" }), - buildPrSummary({ id: "pr-merged" }), - buildPrSummary({ id: "pr-stopped" }), - buildPrSummary({ id: "pr-disabled" }), - buildPrSummary({ id: "pr-poller-stopped" }), - ]; - const { deps, interrupt } = buildDeps({ runtimeByPrId, ptmArgsByPrId, prs }); - const orchestrator = createPathToMergeOrchestrator(deps); - try { - // resumeFromPersistedState walks every PR, filters by runtime, and - // arms a `warming` timer for each survivor. We don't let the timer - // fire (dispose tears it down), but the resume call itself must: - // - never throw on terminal/disabled rows; - // - never call interrupt (no session kill is implied by resume); - // - leave dispose able to clean up cleanly. - expect(() => orchestrator.resumeFromPersistedState()).not.toThrow(); - expect(interrupt).not.toHaveBeenCalled(); - // Stopping a non-live PR must not call interrupt either — the - // pre-resume row had no activeSessionId, and resume doesn't synthesize - // one for filtered-out PRs. - await orchestrator.stopPathToMerge({ prId: "pr-merged" }); - await orchestrator.stopPathToMerge({ prId: "pr-disabled" }); - expect(interrupt).not.toHaveBeenCalled(); - } finally { - orchestrator.dispose(); - } - }); - - it("rehydrates persisted run args back onto the resumed PR", async () => { - const runtimeByPrId = new Map([ - ["pr-1", buildRuntime("pr-1", { - autoConvergeEnabled: true, - status: "running", - pollerStatus: "polling", - })], - ]); - const ptmArgsByPrId = new Map | null>([ - ["pr-1", { modelId: "claude-opus", reasoning: "high", scope: "checks", additionalInstructions: "x" }], - ]); - const { deps } = buildDeps({ - runtimeByPrId, - ptmArgsByPrId, - prs: [buildPrSummary({ id: "pr-1" })], - }); - const orchestrator = createPathToMergeOrchestrator(deps); - try { - // Should not throw, regardless of whether the timer fires. - orchestrator.resumeFromPersistedState(); - - // Stop the resumed PR — its in-process runArgs were rehydrated, so - // stopPathToMerge clears the state cleanly. - const result = await orchestrator.stopPathToMerge({ prId: "pr-1" }); - expect(result.stopped).toBe(true); - expect(runtimeByPrId.get("pr-1")?.status).toBe("stopped"); - } finally { - orchestrator.dispose(); - } - }); -}); - -describe("createPathToMergeOrchestrator.dispose", () => { - it("is idempotent and prevents subsequent schedule calls from arming timers", () => { - const { deps } = buildDeps(); - const orchestrator = createPathToMergeOrchestrator(deps); - orchestrator.dispose(); - // Calling dispose twice and resumeFromPersistedState after dispose must - // not throw (the orchestrator marks itself disposed and short-circuits). - expect(() => orchestrator.dispose()).not.toThrow(); - expect(() => orchestrator.resumeFromPersistedState()).not.toThrow(); - }); -}); diff --git a/apps/desktop/src/main/services/prs/pathToMergeOrchestrator.ts b/apps/desktop/src/main/services/prs/pathToMergeOrchestrator.ts deleted file mode 100644 index 77908547d..000000000 --- a/apps/desktop/src/main/services/prs/pathToMergeOrchestrator.ts +++ /dev/null @@ -1,1052 +0,0 @@ -/** - * Path to Merge orchestrator. - * - * Native TypeScript port of the `/shipLane` Claude skill state machine - * (`.claude/commands/shipLane.md`). Drives a PR through CI + review until - * merged, using: - * - phase-aware setTimeout wake-ups (270 / 720 / 1800 seconds; see - * {@link PHASE_DELAY_SECONDS}); - * - a combined CI + review terminal-state gate before pushing fixes; - * - a 4-option conflict strategy (`pause | rebase | merge | auto`); - * - a hard cap on iterations with optional bonus force-finalize iteration; - * - an early-merge-on-green short circuit; - * - a merge ladder that tries the configured method, then `--admin`, then - * `--auto`. - * - * Persistence happens through the existing `pr_convergence_state` table via - * `issueInventoryService` — no schema changes. The orchestrator runs ON TOP - * of the existing manual entry points (`startPullRequestConvergenceRound`) - * and dispatches each fix iteration via `launchPrIssueResolutionChat`. - * - * Simplifications vs `/shipLane`: - * - The shipLane reference relies on Claude Code's TeamCreate primitive to - * run a poll-agent + fix-agent + rebase-agent in parallel. ADE has no - * equivalent, so each iteration here dispatches a single fix agent - * through the standard `launchPrIssueResolutionChat` pipeline; that - * agent internally decides whether to fix CI, review comments, or both. - * - We do not implement the Phase 0 `automate-agent`/`finalize-agent` — - * this orchestrator assumes the PR already exists; PR creation is the - * caller's responsibility. - */ - -import { spawn } from "node:child_process"; -import type { Logger } from "../logging/logger"; -import { runGit } from "../git/git"; -import { launchPrIssueResolutionChat } from "./prIssueResolver"; -import { nowIso, getErrorMessage } from "../shared/utils"; -import type { createIssueInventoryService } from "./issueInventoryService"; -import type { createPrService } from "./prService"; -import type { createLaneService } from "../lanes/laneService"; -import type { createAgentChatService } from "../chat/agentChatService"; -import type { createSessionService } from "../sessions/sessionService"; -import type { createConflictService } from "../conflicts/conflictService"; -import type { - AutoConflictAgentSettings, - ConflictStrategy, - ConvergenceRuntimeState, - ForceFinalizeMode, - MergeMethod, - PipelineSettings, - PrCheck, - PrIssueResolutionScope, - PrSummary, -} from "../../../shared/types"; - -// --------------------------------------------------------------------------- -// Phase delays (shipLane.md lines 89-91, exact values). -// --------------------------------------------------------------------------- - -/** - * Wake-up delays per phase, in seconds. - * - * Mirrors `/shipLane` playbook §5.3: - * - `justPushed` (270s, ~4.5 min): we just pushed a fix commit; wait for - * CI to start churning before re-polling. - * - `warming` (720s, ~12 min): one of CI / review is still pending, - * reschedule and re-evaluate the terminal-state gate. - * - `waitingOnReview` (1800s, 30 min): everything else is settled, we are - * parked waiting on a human/bot reviewer signal. - */ -export const PHASE_DELAY_SECONDS = { - justPushed: 270, - warming: 720, - waitingOnReview: 1800, -} as const; - -type PhaseDelayKind = keyof typeof PHASE_DELAY_SECONDS; - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -export type PathToMergeDeps = { - logger: Logger; - prService: ReturnType; - laneService: ReturnType; - agentChatService: Pick< - ReturnType, - "createSession" | "sendMessage" | "previewSessionToolNames" | "interrupt" | "getSessionSummary" - >; - sessionService: Pick, "updateMeta">; - issueInventoryService: ReturnType; - conflictService: Pick, "runExternalResolver">; - defaultModelId: string | null; - defaultReasoningEffort: string | null; -}; - -export type StartPathToMergeArgs = { - prId: string; - /** Optional override for the agent model used for fix dispatches. */ - modelId?: string | null; - /** Optional override for reasoning effort passed to the fix agent. */ - reasoning?: string | null; - /** Scope passed to `launchPrIssueResolutionChat` per iteration. */ - scope?: PrIssueResolutionScope; - /** Optional extra instructions appended to each iteration prompt. */ - additionalInstructions?: string | null; -}; - -export type StartPathToMergeResult = { - prId: string; - scheduled: boolean; - runtime: ConvergenceRuntimeState; -}; - -export type StopPathToMergeResult = { - prId: string; - stopped: boolean; - runtime: ConvergenceRuntimeState | null; -}; - -export type PathToMergeOrchestrator = { - startPathToMerge: (args: StartPathToMergeArgs) => Promise; - stopPathToMerge: (args: { prId: string; reason?: string | null }) => Promise; - /** - * Resume any persisted runtime states that were mid-flight when the - * desktop app last shut down. Idempotent — relies on `pollerStatus` and - * `autoConvergeEnabled` to decide whether to rearm a timer. - */ - resumeFromPersistedState: () => void; - /** Stop all in-flight timers without mutating persisted state. */ - dispose: () => void; -}; - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -const CHECKS_TERMINAL_STATUSES = new Set(["passing", "failing", "none"]); -const REVIEWS_TERMINAL_STATUSES = new Set(["approved", "changes_requested", "none"]); - -/** - * Combined CI + review terminal gate (shipLane.md line 206). - * Both signals must be in a terminal state before we are allowed to push - * another round of fixes — pushing on a partial signal causes thrashing. - */ -function isTerminalForFixPush(pr: PrSummary): { terminal: boolean; pendingSignal: "checks" | "review" | null } { - const checksTerminal = CHECKS_TERMINAL_STATUSES.has(pr.checksStatus); - const reviewTerminal = REVIEWS_TERMINAL_STATUSES.has(pr.reviewStatus); - if (!checksTerminal) return { terminal: false, pendingSignal: "checks" }; - if (!reviewTerminal) return { terminal: false, pendingSignal: "review" }; - return { terminal: true, pendingSignal: null }; -} - -/** - * Convergence row + resolved current settings + current PR snapshot. - * Bundling them avoids redundant DB reads in the iteration body. - */ -type IterationContext = { - pr: PrSummary; - pipelineSettings: PipelineSettings; - runtime: ConvergenceRuntimeState; -}; - -function resolveMergeMethod(settings: PipelineSettings): MergeMethod { - // `repo_default` has no in-process source-of-truth for the repo's default - // merge method — the GitHub merge REST API requires an explicit - // merge_method, so we fall back to `squash` (matches shipLane's default). - if (settings.mergeMethod === "repo_default") return "squash"; - return settings.mergeMethod; -} - -// --------------------------------------------------------------------------- -// `gh` shell wrapper (used for the `--admin` and `--auto` rungs of the -// merge ladder; the first rung uses the existing `prService.land()` REST -// call, which is plenty for the happy path). -// --------------------------------------------------------------------------- - -type GhRunResult = { exitCode: number; stdout: string; stderr: string }; - -async function runGh(args: string[], opts: { cwd: string; timeoutMs?: number }): Promise { - const timeoutMs = opts.timeoutMs ?? 60_000; - return await new Promise((resolve) => { - let stdout = ""; - let stderr = ""; - let settled = false; - let timer: NodeJS.Timeout | null = null; - - const child = spawn("gh", args, { - cwd: opts.cwd, - env: process.env, - stdio: ["ignore", "pipe", "pipe"], - }); - - const finish = (code: number) => { - if (settled) return; - settled = true; - if (timer) clearTimeout(timer); - try { child.kill("SIGKILL"); } catch { /* noop */ } - resolve({ exitCode: code, stdout, stderr }); - }; - - timer = setTimeout(() => finish(124), timeoutMs); - child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); }); - child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); }); - child.on("error", () => finish(1)); - child.on("close", (code) => finish(code ?? 1)); - }); -} - -// --------------------------------------------------------------------------- -// Factory -// --------------------------------------------------------------------------- - -export function createPathToMergeOrchestrator(deps: PathToMergeDeps): PathToMergeOrchestrator { - const { logger, issueInventoryService, prService, laneService, agentChatService, sessionService, conflictService } = deps; - - /** - * Map prId → in-flight `setTimeout` handle. Used so we can cancel a - * pending wake-up when {@link stopPathToMerge} is called or when an - * iteration triggers an early reschedule. - */ - const timersByPrId = new Map(); - /** - * Map prId → boolean indicating an iteration is currently executing. - * Prevents double-scheduling when external callers also poke the loop. - */ - const iterationInFlight = new Map(); - /** Per-PR state we don't want to round-trip through the DB. */ - const inProcessState = new Map(); - - let disposed = false; - - // ------------------------------------------------------------------------- - // Scheduling primitive (mirrors prPollingService's setTimeout pattern). - // ------------------------------------------------------------------------- - - function clearTimer(prId: string): void { - const handle = timersByPrId.get(prId); - if (handle) { - clearTimeout(handle); - timersByPrId.delete(prId); - } - } - - function schedule(prId: string, kind: PhaseDelayKind): void { - if (disposed) return; - clearTimer(prId); - const seconds = PHASE_DELAY_SECONDS[kind]; - logger.info("ptm.schedule", { prId, kind, seconds }); - const handle = setTimeout(() => { - timersByPrId.delete(prId); - void runIteration(prId).catch((err) => { - logger.error("ptm.iteration_unhandled", { prId, error: getErrorMessage(err) }); - }); - }, seconds * 1000); - timersByPrId.set(prId, handle); - } - - // ------------------------------------------------------------------------- - // Public entry points - // ------------------------------------------------------------------------- - - async function startPathToMerge(args: StartPathToMergeArgs): Promise { - const prId = args.prId.trim(); - if (!prId) throw new Error("prId is required"); - - inProcessState.set(prId, { forceFinalizeUsed: false, runArgs: args }); - - const runtime = issueInventoryService.saveConvergenceRuntime(prId, { - autoConvergeEnabled: true, - status: "launching", - pollerStatus: "scheduled", - pauseReason: null, - errorMessage: null, - lastStartedAt: nowIso(), - }); - - // Persist run args so a desktop restart can resume with the same modelId - // and reasoning instead of pausing on "No modelId available". - issueInventoryService.savePathToMergeArgs(prId, { - modelId: args.modelId ?? null, - reasoning: args.reasoning ?? null, - scope: args.scope ?? "both", - additionalInstructions: args.additionalInstructions ?? null, - }); - - // Kick the loop immediately rather than waiting for a phase delay — - // operators expect "Start" to do something visible right away. - clearTimer(prId); - setImmediate(() => { - void runIteration(prId).catch((err) => { - logger.error("ptm.start_unhandled", { prId, error: getErrorMessage(err) }); - }); - }); - - return { prId, scheduled: true, runtime }; - } - - async function stopPathToMerge(args: { prId: string; reason?: string | null }): Promise { - const prId = args.prId.trim(); - if (!prId) throw new Error("prId is required"); - - clearTimer(prId); - inProcessState.delete(prId); - - const current = issueInventoryService.getConvergenceRuntime(prId); - const activeSessionId = current.activeSessionId; - if (activeSessionId) { - try { - await agentChatService.interrupt({ sessionId: activeSessionId }); - } catch (err) { - logger.warn("ptm.interrupt_failed", { prId, sessionId: activeSessionId, error: getErrorMessage(err) }); - } - } - - const runtime = issueInventoryService.saveConvergenceRuntime(prId, { - autoConvergeEnabled: false, - status: "stopped", - pollerStatus: "stopped", - activeSessionId: null, - pauseReason: args.reason?.trim() || null, - errorMessage: null, - lastStoppedAt: nowIso(), - }); - issueInventoryService.savePathToMergeArgs(prId, null); - - return { prId, stopped: true, runtime }; - } - - function resumeFromPersistedState(): void { - if (disposed) return; - // Find every PR whose convergence is still flagged as live and rearm the - // scheduled wake-up. We rely on `autoConvergeEnabled === true` and - // `pollerStatus !== 'stopped'` to identify candidates. - const allPrs = prService.listAll(); - for (const pr of allPrs) { - const runtime = issueInventoryService.getConvergenceRuntime(pr.id); - if (!runtime.autoConvergeEnabled) continue; - if (runtime.pollerStatus === "stopped") continue; - if (runtime.status === "merged" || runtime.status === "stopped" || runtime.status === "cancelled") continue; - // Rehydrate the original startPathToMerge args (modelId, reasoning, - // scope, additionalInstructions) from the side-channel column. - const persistedArgs = issueInventoryService.getPathToMergeArgs(pr.id); - const runArgs: StartPathToMergeArgs = { - prId: pr.id, - modelId: typeof persistedArgs?.modelId === "string" ? persistedArgs.modelId : null, - reasoning: typeof persistedArgs?.reasoning === "string" ? persistedArgs.reasoning : null, - scope: (persistedArgs?.scope as PrIssueResolutionScope | undefined) ?? "both", - additionalInstructions: typeof persistedArgs?.additionalInstructions === "string" - ? persistedArgs.additionalInstructions - : null, - }; - // forceFinalizeUsed is reconstructed from `pauseReason === 'force-finalize'`, - // which Step 4 writes BEFORE launching the bonus iteration — that signal is - // reliable across restarts (the heuristic `currentRound > maxRounds` is - // not, because currentRound doesn't bump when no `new` items exist). - inProcessState.set(pr.id, { - forceFinalizeUsed: runtime.pauseReason === "force-finalize", - runArgs, - }); - schedule(pr.id, "warming"); - } - } - - function dispose(): void { - disposed = true; - for (const handle of timersByPrId.values()) { - clearTimeout(handle); - } - timersByPrId.clear(); - iterationInFlight.clear(); - inProcessState.clear(); - } - - // ------------------------------------------------------------------------- - // Helpers - // ------------------------------------------------------------------------- - - function loadIterationContext(prId: string): IterationContext | null { - const pr = prService.listAll().find((entry) => entry.id === prId) ?? null; - if (!pr) return null; - const pipelineSettings = issueInventoryService.getPipelineSettings(prId); - const runtime = issueInventoryService.getConvergenceRuntime(prId); - return { pr, pipelineSettings, runtime }; - } - - function pauseLoop(prId: string, reason: string, errorMessage?: string | null): ConvergenceRuntimeState { - clearTimer(prId); - return issueInventoryService.saveConvergenceRuntime(prId, { - status: "paused", - pollerStatus: "paused", - pauseReason: reason, - errorMessage: errorMessage ?? null, - lastPausedAt: nowIso(), - autoConvergeEnabled: false, - }); - } - - function failLoop(prId: string, errorMessage: string): ConvergenceRuntimeState { - clearTimer(prId); - return issueInventoryService.saveConvergenceRuntime(prId, { - status: "failed", - pollerStatus: "stopped", - errorMessage, - autoConvergeEnabled: false, - }); - } - - // ------------------------------------------------------------------------- - // Conflict strategy dispatcher - // ------------------------------------------------------------------------- - - type ConflictKind = "base_advance" | "merge_time"; - - type ApplyConflictStrategyResult = - | { kind: "ok" } - | { kind: "paused"; reason: string } - | { kind: "failed"; error: string }; - - /** - * Single helper called from two sites: - * - at the top of each iteration as a "base advance" sync (`base_advance`) - * - when the merge ladder fails on a conflict (`merge_time`) - * - * The behavior switches on `pipelineSettings.conflictStrategy`: - * - `pause` → mark the loop paused, exit - * - `rebase` → `git rebase origin/` then force-push --force-with-lease - * - `merge` → `git merge origin/` then push the merge commit - * - `auto` → invoke `conflictService.runExternalResolver` with the - * settings from `pipelineSettings.autoAgentSettings`. - */ - async function applyConflictStrategy( - ctx: IterationContext, - kind: ConflictKind, - ): Promise { - const { pr, pipelineSettings } = ctx; - const strategy: ConflictStrategy = pipelineSettings.conflictStrategy; - let cwd: string; - try { - cwd = laneService.getLaneBaseAndBranch(pr.laneId).worktreePath; - } catch (err) { - // Lane row was deleted out from under us (operator action, archive, etc.). - // Surface a clean pause instead of letting the throw silently kill the loop. - return { kind: "paused", reason: `Lane ${pr.laneId} unavailable: ${getErrorMessage(err)}` }; - } - const baseRef = `origin/${pr.baseBranch}`; - const branch = pr.headBranch; - - logger.info("ptm.conflict_strategy_apply", { prId: pr.id, strategy, kind }); - - if (strategy === "pause") { - return { kind: "paused", reason: `Conflict (${kind}): paused per pipeline settings.` }; - } - - // Always make sure origin is up to date before attempting the strategy. - const fetchRes = await runGit(["fetch", "origin", pr.baseBranch], { cwd, timeoutMs: 60_000 }); - if (fetchRes.exitCode !== 0) { - return { kind: "failed", error: `git fetch origin ${pr.baseBranch} failed: ${fetchRes.stderr.trim() || fetchRes.stdout.trim()}` }; - } - - if (strategy === "rebase") { - const rebaseRes = await runGit(["rebase", baseRef], { cwd, timeoutMs: 180_000 }); - if (rebaseRes.exitCode !== 0) { - // Best-effort cleanup so the worktree is not left in a half-rebased state. - await runGit(["rebase", "--abort"], { cwd, timeoutMs: 30_000 }).catch(() => {}); - return { kind: "failed", error: `git rebase ${baseRef} failed: ${rebaseRes.stderr.trim() || rebaseRes.stdout.trim()}` }; - } - const pushRes = await runGit(["push", "--force-with-lease", "origin", `HEAD:${branch}`], { cwd, timeoutMs: 120_000 }); - if (pushRes.exitCode !== 0) { - return { kind: "failed", error: `force-push after rebase failed: ${pushRes.stderr.trim() || pushRes.stdout.trim()}` }; - } - return { kind: "ok" }; - } - - if (strategy === "merge") { - const mergeRes = await runGit( - ["merge", "--no-edit", baseRef], - { cwd, timeoutMs: 180_000 }, - ); - if (mergeRes.exitCode !== 0) { - await runGit(["merge", "--abort"], { cwd, timeoutMs: 30_000 }).catch(() => {}); - return { kind: "failed", error: `git merge ${baseRef} failed: ${mergeRes.stderr.trim() || mergeRes.stdout.trim()}` }; - } - const pushRes = await runGit(["push", "origin", `HEAD:${branch}`], { cwd, timeoutMs: 120_000 }); - if (pushRes.exitCode !== 0) { - return { kind: "failed", error: `push after merge failed: ${pushRes.stderr.trim() || pushRes.stdout.trim()}` }; - } - return { kind: "ok" }; - } - - // strategy === "auto" — let the conflict resolver agent decide. The - // agent reads the worktree, picks rebase vs merge, and resolves any - // marker-style conflicts itself. - const autoSettings: AutoConflictAgentSettings = pipelineSettings.autoAgentSettings; - const provider = autoSettings.provider; - if (!provider) { - return { kind: "failed", error: "Conflict strategy is 'auto' but no autoAgentSettings.provider is configured." }; - } - - try { - const result = await conflictService.runExternalResolver({ - provider, - targetLaneId: pr.laneId, - sourceLaneIds: [pr.laneId], - cwdLaneId: pr.laneId, - model: autoSettings.model, - reasoningEffort: autoSettings.reasoningEffort, - permissionMode: autoSettings.permissionMode, - // No "automation"/"path-to-merge" surface yet — closest existing - // value is "rebase", which the resolver treats as a worktree-local - // base-sync invocation (which is exactly what the PtM loop is doing - // here). - originSurface: "rebase", - originLabel: `path-to-merge:${kind}:pr=${pr.githubPrNumber}`, - }); - if (result.status === "completed") { - return { kind: "ok" }; - } - return { kind: "failed", error: result.error ?? `Auto resolver finished with status ${result.status}.` }; - } catch (err) { - return { kind: "failed", error: `Auto resolver threw: ${getErrorMessage(err)}` }; - } - } - - // ------------------------------------------------------------------------- - // Merge ladder (shipLane.md lines 194-195) - // ------------------------------------------------------------------------- - - type MergeLadderResult = - | { kind: "merged"; via: "rest" | "admin" } - /** `gh pr merge --auto` armed GitHub auto-merge — the PR is NOT yet landed. */ - | { kind: "auto_armed" } - | { kind: "conflict"; error: string } - | { kind: "blocked"; error: string } - | { kind: "failed"; error: string }; - - /** - * 1. Try the configured merge method via existing `prService.land()` (REST). - * 2. On policy block, retry with `gh pr merge --admin`. - * 3. On further failure, fall back to `gh pr merge --auto` (when the - * pipeline's auto-merge toggle is on). - * - * CRITICAL: never pass `--delete-branch` to gh (shipLane.md line 212 — - * would conflict with the project-root worktree on `main`). For rungs 2/3 - * we run `prService.runPostMergeCleanup` after success so branch deletion, - * child-lane rebase advance, and cache refresh all still happen. - */ - async function runMergeLadder(ctx: IterationContext): Promise { - const { pr, pipelineSettings } = ctx; - const method = resolveMergeMethod(pipelineSettings); - - logger.info("ptm.merge_ladder_start", { prId: pr.id, method }); - - // Rung 1: REST merge via existing service (handles post-merge cleanup, - // child-lane rebase, branch deletion via gh api -X DELETE). - const restResult = await prService.land({ prId: pr.id, method, archiveLane: false }); - if (restResult.success) { - return { kind: "merged", via: "rest" }; - } - const restErr = restResult.error ?? "unknown REST merge error"; - logger.warn("ptm.merge_ladder_rest_failed", { prId: pr.id, error: restErr }); - - // Conflict detection — short-circuit out so the caller can run the - // conflict strategy and retry on the next iteration. - if (/conflict|409/i.test(restErr)) { - return { kind: "conflict", error: restErr }; - } - - // Look up the lane worktree for `gh` calls (gh resolves the PR from cwd). - let laneWorktreePath: string; - try { - laneWorktreePath = laneService.getLaneBaseAndBranch(pr.laneId).worktreePath; - } catch (err) { - return { kind: "failed", error: `Lane lookup failed for merge ladder: ${getErrorMessage(err)}` }; - } - const ghMethodFlag = `--${method}`; - const prNumberArg = String(pr.githubPrNumber); - - // Rung 2: gh pr merge --admin (overrides branch protection if the - // operator has admin rights). - const adminRes = await runGh( - ["pr", "merge", prNumberArg, ghMethodFlag, "--admin"], - { cwd: laneWorktreePath, timeoutMs: 90_000 }, - ); - if (adminRes.exitCode === 0) { - logger.info("ptm.merge_ladder_admin_succeeded", { prId: pr.id }); - // gh CLI doesn't run our cleanup pipeline — invoke it explicitly so the - // remote branch is deleted, child lanes advance, group memberships are - // cleared, and caches refresh. Any cleanup error is logged inside the - // helper and never masks the successful merge. - try { - await prService.runPostMergeCleanup({ prId: pr.id, mergeCommitSha: null, archiveLane: false }); - } catch (err) { - logger.warn("ptm.merge_ladder_admin_cleanup_failed", { prId: pr.id, error: getErrorMessage(err) }); - } - return { kind: "merged", via: "admin" }; - } - logger.warn("ptm.merge_ladder_admin_failed", { prId: pr.id, stderr: adminRes.stderr.trim() }); - - // Rung 3: gh pr merge --auto (queue the merge for when checks/policy - // gates clear). This is a "park & wait" outcome, not an immediate land. - // Only attempt it when the user explicitly opted into auto-merge. - if (!pipelineSettings.autoMerge) { - return { - kind: "blocked", - error: `Merge ladder exhausted (REST: ${restErr}; admin: ${adminRes.stderr.trim() || adminRes.stdout.trim() || "exit " + adminRes.exitCode}). auto-merge skipped because pipelineSettings.autoMerge is false.`, - }; - } - const autoRes = await runGh( - ["pr", "merge", prNumberArg, ghMethodFlag, "--auto"], - { cwd: laneWorktreePath, timeoutMs: 60_000 }, - ); - if (autoRes.exitCode === 0) { - logger.info("ptm.merge_ladder_auto_armed", { prId: pr.id }); - // DO NOT run post-merge cleanup here — the PR has not actually merged - // yet. The caller parks the loop and re-polls pr.state until GitHub - // either lands the auto-merge (then cleanup runs on observation) or it - // times out/fails (then we surface that to the user). - return { kind: "auto_armed" }; - } - - return { - kind: "blocked", - error: `Merge ladder exhausted (REST: ${restErr}; admin: ${adminRes.stderr.trim() || adminRes.stdout.trim() || "exit " + adminRes.exitCode}; auto: ${autoRes.stderr.trim() || autoRes.stdout.trim() || "exit " + autoRes.exitCode})`, - }; - } - - // ------------------------------------------------------------------------- - // Force-finalize predicate (shipLane.md lines 183-198) - // ------------------------------------------------------------------------- - - type ForceFinalizeDecision = - | { kind: "skip"; reason: string } - | { kind: "run"; ignoreReview: boolean; ignoreCi: boolean }; - - function decideForceFinalize( - ctx: IterationContext, - checks: PrCheck[], - ): ForceFinalizeDecision { - const mode: ForceFinalizeMode = ctx.pipelineSettings.forceFinalizeMode; - if (mode === "off") { - return { kind: "skip", reason: "force-finalize disabled (off)" }; - } - if (mode === "conditional") { - const requireNoCi = ctx.pipelineSettings.forceFinalizeRequireNoCiFailures; - const ciFailing = ctx.pr.checksStatus === "failing" - || checks.some((c) => c.conclusion === "failure"); - if (requireNoCi && ciFailing) { - return { kind: "skip", reason: "conditional force-finalize blocked (CI failing)" }; - } - } - // mode === "unconditional" or conditional-passed: - // - skip review-comment fixes (shipLane line 189) - // - only run CI fixes (line 190); if CI is already green, skip dispatch - // and go straight to merge ladder - return { kind: "run", ignoreReview: true, ignoreCi: false }; - } - - // ------------------------------------------------------------------------- - // The iteration body - // ------------------------------------------------------------------------- - - async function runIteration(prId: string): Promise { - if (disposed) return; - if (iterationInFlight.get(prId)) { - logger.debug("ptm.iteration_skipped_in_flight", { prId }); - return; - } - iterationInFlight.set(prId, true); - try { - await runIterationInner(prId); - } finally { - iterationInFlight.set(prId, false); - } - } - - async function runIterationInner(prId: string): Promise { - const ctx = loadIterationContext(prId); - if (!ctx) { - logger.warn("ptm.iteration_aborted_pr_missing", { prId }); - return; - } - if (!ctx.runtime.autoConvergeEnabled) { - logger.info("ptm.iteration_aborted_disabled", { prId }); - clearTimer(prId); - return; - } - if (ctx.pr.state === "merged") { - issueInventoryService.saveConvergenceRuntime(prId, { - status: "merged", - pollerStatus: "stopped", - autoConvergeEnabled: false, - errorMessage: null, - pauseReason: null, - }); - clearTimer(prId); - return; - } - if (ctx.pr.state === "closed") { - pauseLoop(prId, "PR is closed."); - return; - } - - issueInventoryService.saveConvergenceRuntime(prId, { - status: "running", - pollerStatus: "polling", - lastPolledAt: nowIso(), - }); - - // Refresh remote state up-front so terminal-gate decisions use the - // latest snapshot rather than whatever was cached. - try { - await prService.refresh({ prId }); - } catch (err) { - logger.warn("ptm.refresh_failed", { prId, error: getErrorMessage(err) }); - } - - // Reload after refresh. - const refreshed = loadIterationContext(prId); - if (!refreshed) { - pauseLoop(prId, "PR vanished after refresh."); - return; - } - const fresh = refreshed; - - // Re-check merged state in case a previously armed `gh pr merge --auto` - // landed between iterations. If so, complete the cleanup now and exit. - if (fresh.pr.state === "merged") { - try { - await prService.runPostMergeCleanup({ prId, mergeCommitSha: null, archiveLane: false }); - } catch (err) { - logger.warn("ptm.post_merge_cleanup_on_observation_failed", { prId, error: getErrorMessage(err) }); - } - issueInventoryService.saveConvergenceRuntime(prId, { - status: "merged", - pollerStatus: "stopped", - autoConvergeEnabled: false, - errorMessage: null, - pauseReason: null, - }); - clearTimer(prId); - return; - } - - // Look up `behindBaseBy` once via the status snapshot; skip the base-sync - // round-trip when the PR is already at base — otherwise every wake fires - // a redundant `git fetch` + `git push --force-with-lease` that no-ops on - // the remote but pollutes the audit log and wastes round-trips. - let behindBaseBy = 0; - try { - const status = await prService.getStatus(prId); - behindBaseBy = status.behindBaseBy ?? 0; - } catch (err) { - logger.debug("ptm.status_fetch_failed", { prId, error: getErrorMessage(err) }); - // Fall through; treat as "unknown" → safest is to run the base sync. - behindBaseBy = 1; - } - - // ---- Step 1: base-advance conflict check (only when actually behind) ---- - if (behindBaseBy > 0) { - const baseSync = await applyConflictStrategy(fresh, "base_advance"); - if (baseSync.kind === "paused") { - pauseLoop(prId, baseSync.reason); - return; - } - if (baseSync.kind === "failed") { - pauseLoop(prId, "Base sync failed.", baseSync.error); - return; - } - } - - // Helper: persist a "converged but not auto-merging" parked state. Used - // when `pipelineSettings.autoMerge === false` — the loop gets the PR into - // a green/clean state but stops short of landing it. - const parkConverged = (reason: string): void => { - issueInventoryService.saveConvergenceRuntime(prId, { - status: "converged", - pollerStatus: "waiting_for_comments", - pauseReason: reason, - errorMessage: null, - }); - schedule(prId, "waitingOnReview"); - }; - - // Helper: persist a "merge armed via gh --auto" parked state. The PR has - // not actually landed; GitHub will land it when its required gates clear. - // We re-poll `pr.state` until that transition is observed. - const parkAutoArmed = (): void => { - issueInventoryService.saveConvergenceRuntime(prId, { - status: "converged", - pollerStatus: "waiting_for_checks", - pauseReason: "auto-merge armed via gh CLI; waiting for GitHub to land.", - errorMessage: null, - }); - schedule(prId, "waitingOnReview"); - }; - - // ---- Step 2: early merge on green (shipLane intent) ---- - if (fresh.pipelineSettings.earlyMergeOnGreen) { - const reviewClean = fresh.pr.reviewStatus !== "changes_requested" && fresh.pr.reviewStatus !== "requested"; - if (fresh.pr.checksStatus === "passing" && reviewClean) { - if (!fresh.pipelineSettings.autoMerge) { - // User has explicitly opted out of auto-merge: stop short of landing. - parkConverged("Converged but pipelineSettings.autoMerge is false; click merge when ready."); - return; - } - const ladder = await runMergeLadder(fresh); - if (ladder.kind === "merged") { - issueInventoryService.saveConvergenceRuntime(prId, { - status: "merged", - pollerStatus: "stopped", - autoConvergeEnabled: false, - errorMessage: null, - pauseReason: null, - }); - clearTimer(prId); - return; - } - if (ladder.kind === "auto_armed") { - parkAutoArmed(); - return; - } - if (ladder.kind === "conflict") { - // Apply merge-time conflict strategy and retry on next wake. - const conflictRes = await applyConflictStrategy(fresh, "merge_time"); - if (conflictRes.kind === "paused") { - pauseLoop(prId, conflictRes.reason); - return; - } - if (conflictRes.kind === "failed") { - pauseLoop(prId, "Merge-time conflict resolution failed.", conflictRes.error); - return; - } - schedule(prId, "justPushed"); - return; - } - if (ladder.kind === "blocked") { - // Fall through to fix-dispatch — early merge isn't possible right now, - // probably a missing required reviewer or status check we can't see. - logger.info("ptm.early_merge_blocked_falling_through", { prId, error: ladder.error }); - } else if (ladder.kind === "failed") { - pauseLoop(prId, "Early merge ladder failed.", ladder.error); - return; - } - } - } - - // ---- Step 3: terminal-gate check before dispatching fixes ---- - const gate = isTerminalForFixPush(fresh.pr); - if (!gate.terminal) { - logger.info("ptm.terminal_gate_pending", { prId, pendingSignal: gate.pendingSignal }); - issueInventoryService.saveConvergenceRuntime(prId, { - pollerStatus: "waiting_for_checks", - currentRound: fresh.runtime.currentRound, - }); - schedule(prId, "warming"); - return; - } - - // ---- Step 4: hard cap + force-finalize logic ---- - const maxRounds = fresh.pipelineSettings.maxRounds; - const completedRounds = fresh.runtime.currentRound; - const inProc = inProcessState.get(prId) ?? { forceFinalizeUsed: false, runArgs: { prId, scope: "both" as PrIssueResolutionScope } }; - - let isForceFinalizeIteration = false; - if (completedRounds >= maxRounds) { - if (inProc.forceFinalizeUsed) { - // Bonus iteration already consumed; nothing left to try. - pauseLoop(prId, "Hard cap reached (force-finalize already attempted)."); - return; - } - // Need to fetch checks for the conditional predicate. - let checks: PrCheck[] = []; - try { - checks = await prService.getChecks(prId); - } catch (err) { - logger.warn("ptm.force_finalize_checks_fetch_failed", { prId, error: getErrorMessage(err) }); - } - const decision = decideForceFinalize(fresh, checks); - if (decision.kind === "skip") { - pauseLoop(prId, `Hard cap reached: ${decision.reason}`); - return; - } - isForceFinalizeIteration = true; - issueInventoryService.saveConvergenceRuntime(prId, { - pauseReason: "force-finalize", - }); - } - - // ---- Step 5: dispatch fix agent (or skip if force-finalize + green) ---- - const ciIsGreen = fresh.pr.checksStatus === "passing"; - const shouldSkipFixDispatch = isForceFinalizeIteration && ciIsGreen; - - if (!shouldSkipFixDispatch) { - // Busy-guard: if a fix agent is still running from a previous iteration, - // do NOT dispatch another. Two agents racing on the same worktree - // corrupts pushes and orphans the first session's reconciliation row. - const priorSessionId = fresh.runtime.activeSessionId?.trim() ?? null; - if (priorSessionId) { - let priorActive = false; - try { - const summary = await agentChatService.getSessionSummary(priorSessionId); - // Defensively treat absent summary as "alive" so we never double- - // dispatch on a transient lookup error. `active` = a turn is in - // flight; `idle` + awaitingInput = session waiting on a user reply. - if (summary == null) { - priorActive = true; - } else if (summary.status === "active") { - priorActive = true; - } else if (summary.status === "idle" && summary.awaitingInput === true) { - priorActive = true; - } - } catch (err) { - logger.warn("ptm.session_alive_check_failed", { prId, sessionId: priorSessionId, error: getErrorMessage(err) }); - priorActive = true; - } - if (priorActive) { - logger.info("ptm.fix_agent_still_running_skipping_dispatch", { prId, sessionId: priorSessionId }); - schedule(prId, "warming"); - return; - } - } - - let scope: PrIssueResolutionScope; - if (isForceFinalizeIteration) { - // shipLane line 189: ignore review, only run CI fixes. - scope = "checks"; - } else { - scope = inProc.runArgs.scope ?? "both"; - } - - const modelId = inProc.runArgs.modelId?.trim() || deps.defaultModelId?.trim() || null; - if (!modelId) { - pauseLoop(prId, "No modelId available to dispatch fix agent."); - return; - } - - try { - const launch = await launchPrIssueResolutionChat( - { - prService, - laneService: { - list: laneService.list, - getLaneBaseAndBranch: laneService.getLaneBaseAndBranch, - }, - agentChatService: { - createSession: agentChatService.createSession, - sendMessage: agentChatService.sendMessage, - previewSessionToolNames: agentChatService.previewSessionToolNames, - }, - sessionService, - issueInventoryService, - }, - { - prId, - scope, - modelId, - reasoning: inProc.runArgs.reasoning ?? deps.defaultReasoningEffort ?? null, - additionalInstructions: inProc.runArgs.additionalInstructions ?? null, - }, - ); - - // NOTE: forceFinalizeUsed is intentionally NOT set here. The bonus - // iteration is consumed by the merge-ladder attempt in Step 6, not by - // a fix dispatch — otherwise force-finalize would degrade to "one - // extra fix iteration, then pause" and never actually retry the - // merge once the agent's fix turned CI green. - inProcessState.set(prId, inProc); - - const status = issueInventoryService.getConvergenceStatus(prId); - issueInventoryService.saveConvergenceRuntime(prId, { - status: "running", - pollerStatus: "waiting_for_comments", - currentRound: status.currentRound, - activeSessionId: launch.sessionId, - activeLaneId: launch.laneId, - activeHref: launch.href, - pauseReason: isForceFinalizeIteration ? "force-finalize" : null, - errorMessage: null, - }); - - // Wait for the agent to finish + push before re-evaluating. - schedule(prId, "justPushed"); - return; - } catch (err) { - pauseLoop(prId, "Fix-agent dispatch failed.", getErrorMessage(err)); - return; - } - } - - // ---- Step 6: force-finalize green-path → straight to merge ladder ---- - if (isForceFinalizeIteration) { - if (!fresh.pipelineSettings.autoMerge) { - // User opted into force-finalize but not auto-merge: park instead of land. - inProc.forceFinalizeUsed = true; - inProcessState.set(prId, inProc); - parkConverged("Force-finalize complete with green CI; pipelineSettings.autoMerge is false, click merge when ready."); - return; - } - - // Consume the bonus only when we actually attempt the merge ladder. - inProc.forceFinalizeUsed = true; - inProcessState.set(prId, inProc); - - const ladder = await runMergeLadder(fresh); - if (ladder.kind === "merged") { - issueInventoryService.saveConvergenceRuntime(prId, { - status: "merged", - pollerStatus: "stopped", - autoConvergeEnabled: false, - errorMessage: null, - pauseReason: "force-finalize", - }); - clearTimer(prId); - return; - } - if (ladder.kind === "auto_armed") { - parkAutoArmed(); - return; - } - if (ladder.kind === "conflict") { - const conflictRes = await applyConflictStrategy(fresh, "merge_time"); - if (conflictRes.kind === "paused") { - pauseLoop(prId, conflictRes.reason); - return; - } - if (conflictRes.kind === "failed") { - pauseLoop(prId, "Force-finalize merge-time conflict resolution failed.", conflictRes.error); - return; - } - schedule(prId, "justPushed"); - return; - } - if (ladder.kind === "blocked") { - failLoop(prId, `Force-finalize merge ladder blocked: ${ladder.error}`); - return; - } - failLoop(prId, `Force-finalize merge ladder failed: ${ladder.error}`); - return; - } - - // Fallback: park waiting on review. - schedule(prId, "waitingOnReview"); - } - - return { - startPathToMerge, - stopPathToMerge, - resumeFromPersistedState, - dispose, - }; -} diff --git a/apps/desktop/src/main/services/prs/prIssueResolution.test.ts b/apps/desktop/src/main/services/prs/prIssueResolution.test.ts index f441381fe..189d8ccdf 100644 --- a/apps/desktop/src/main/services/prs/prIssueResolution.test.ts +++ b/apps/desktop/src/main/services/prs/prIssueResolution.test.ts @@ -1438,17 +1438,6 @@ describe("issueInventoryService", () => { mergeMethod: "repo_default", maxRounds: 5, onRebaseNeeded: "pause", - conflictStrategy: "pause", - autoAgentSettings: { - provider: null, - model: null, - reasoningEffort: null, - permissionMode: null, - confidenceThreshold: null, - }, - forceFinalizeMode: "off", - forceFinalizeRequireNoCiFailures: true, - earlyMergeOnGreen: true, }); }); @@ -1459,15 +1448,6 @@ describe("issueInventoryService", () => { merge_method: "squash", max_rounds: 3, on_rebase_needed: "auto_rebase", - conflict_strategy: "rebase", - force_finalize_mode: "conditional", - force_finalize_require_no_ci_failures: 1, - early_merge_on_green: 0, - auto_agent_provider: "claude", - auto_agent_model: "anthropic/claude-3-5-sonnet", - auto_agent_reasoning_effort: "medium", - auto_agent_permission_mode: "guarded_edit", - auto_agent_confidence_threshold: 0.75, }); const service = createIssueInventoryService({ db }); @@ -1478,17 +1458,6 @@ describe("issueInventoryService", () => { mergeMethod: "squash", maxRounds: 3, onRebaseNeeded: "auto_rebase", - conflictStrategy: "rebase", - autoAgentSettings: { - provider: "claude", - model: "anthropic/claude-3-5-sonnet", - reasoningEffort: "medium", - permissionMode: "guarded_edit", - confidenceThreshold: 0.75, - }, - forceFinalizeMode: "conditional", - forceFinalizeRequireNoCiFailures: true, - earlyMergeOnGreen: false, }); }); }); diff --git a/apps/desktop/src/main/services/prs/prMergeQueue.test.ts b/apps/desktop/src/main/services/prs/prMergeQueue.test.ts index e7b469272..ba6c1db6b 100644 --- a/apps/desktop/src/main/services/prs/prMergeQueue.test.ts +++ b/apps/desktop/src/main/services/prs/prMergeQueue.test.ts @@ -198,7 +198,6 @@ describe("queueLandingService", () => { mergeConflicts: false, behindBaseBy: 0, }), - retargetBase: async () => {}, }, laneService: { list: async () => [{ id: "lane-main", branchRef: "main", baseRef: "main" }], @@ -324,7 +323,6 @@ describe("queueLandingService", () => { mergeConflicts: false, behindBaseBy: 0, }), - retargetBase: async () => {}, }, laneService: { list: async () => [{ id: "lane-main", branchRef: "main", baseRef: "main" }], @@ -410,7 +408,6 @@ describe("queueLandingService", () => { mergeConflicts: false, behindBaseBy: 0, }), - retargetBase: async () => {}, }, laneService: { list: async () => [{ id: "lane-main", branchRef: "main", baseRef: "main" }], @@ -454,7 +451,6 @@ describe("queueLandingService", () => { mergeConflicts: false, behindBaseBy: 0, }), - retargetBase: async () => {}, }, laneService: { list: async () => [{ id: "lane-main", branchRef: "main", baseRef: "main" }], @@ -536,7 +532,6 @@ describe("queueLandingService", () => { mergeConflicts: false, behindBaseBy: 0, }), - retargetBase: async () => {}, }, laneService: { list: async () => [{ id: "lane-main", branchRef: "main", baseRef: "main" }], diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 2847622a0..71ef1da3b 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -2850,217 +2850,173 @@ export function createPrService({ return result; }; - /** - * Post-merge bookkeeping that runs after a successful merge — regardless of - * whether the merge happened via REST (`land`) or the gh CLI rungs of the - * Path-to-Merge merge ladder. - * - * Idempotent: every step (group cleanup, branch deletion, lane archive, - * cache refresh) is wrapped in try/catch and tolerant of being called twice - * for the same PR. - */ - const runPostMergeCleanup = async (args: { - prId: string; - mergeCommitSha: string | null; - archiveLane: boolean; - operationId?: string | null; - }): Promise<{ - branchDeleted: boolean; - laneArchived: boolean; - childAutoRebaseBlockedCleanup: boolean; - }> => { + const land = async (args: LandPrArgs): Promise => { const row = getRow(args.prId); - if (!row) { - logger.warn("prs.post_merge_cleanup_pr_missing", { prId: args.prId }); - return { branchDeleted: false, laneArchived: false, childAutoRebaseBlockedCleanup: false }; - } + if (!row) throw new Error(`PR not found: ${args.prId}`); const repo: GitHubRepoRef = { owner: row.repo_owner, name: row.repo_name }; - const headBranch = row.head_branch; - let branchDeleted = false; - let laneArchived = false; - let childAutoRebaseBlockedCleanup = false; - try { - try { - db.run("delete from pr_group_members where pr_id = ?", [row.id]); - } catch (groupErr) { - logger.warn("prs.group_membership_cleanup_failed", { prId: row.id, error: getErrorMessage(groupErr) }); + const op = operationService.start({ + laneId: row.lane_id, + kind: "pr_land", + metadata: { + prId: row.id, + prNumber: Number(row.github_pr_number), + method: args.method } + }); - await fetchRemoteTrackingBranch({ - projectRoot, - targetBranch: row.base_branch, - }).catch((error) => { - logger.warn("prs.fetch_base_branch_failed", { - prId: row.id, - baseBranch: row.base_branch, - error: getErrorMessage(error), - }); + try { + const merge = await githubService.apiRequest({ + method: "PUT", + path: `/repos/${repo.owner}/${repo.name}/pulls/${Number(row.github_pr_number)}/merge`, + body: { + merge_method: args.method + } }); + + const mergeCommitSha = asString(merge.data?.sha) || null; + + // --- Post-merge cleanup: failures here must not mask a successful merge --- + const headBranch = row.head_branch; + let branchDeleted = false; + let laneArchived = false; + let childAutoRebaseBlockedCleanup = false; + try { - laneService.invalidateCache?.(); - } catch (cacheError) { - logger.warn("prs.lane_cache_invalidation_failed", { - prId: row.id, - error: getErrorMessage(cacheError), - }); - } + // Remove PR from any group membership before archiving (lane archive blocks if still in a group) + try { + db.run("delete from pr_group_members where pr_id = ?", [row.id]); + } catch (groupErr) { + logger.warn("prs.group_membership_cleanup_failed", { prId: row.id, error: getErrorMessage(groupErr) }); + } - const childAdvanceResult = await advanceChildLanesAfterLand({ - landedLaneId: row.lane_id, - landedLaneName: row.title?.trim() || row.head_branch, - }).catch((error) => { - logger.warn("prs.child_auto_rebase_failed", { - prId: row.id, - laneId: row.lane_id, - error: getErrorMessage(error), + await fetchRemoteTrackingBranch({ + projectRoot, + targetBranch: row.base_branch, + }).catch((error) => { + logger.warn("prs.fetch_base_branch_failed", { + prId: row.id, + baseBranch: row.base_branch, + error: getErrorMessage(error), + }); }); - return { - updatedLaneIds: [], - failedLaneIds: [], - blockCleanup: true, - }; - }); - childAutoRebaseBlockedCleanup = childAdvanceResult.blockCleanup; - - if (!childAutoRebaseBlockedCleanup) { try { - await githubService.apiRequest({ - method: "DELETE", - path: `/repos/${repo.owner}/${repo.name}/git/refs/heads/${headBranch}` + laneService.invalidateCache?.(); + } catch (cacheError) { + logger.warn("prs.lane_cache_invalidation_failed", { + prId: row.id, + error: getErrorMessage(cacheError), }); - branchDeleted = true; - } catch (error) { - // 404-tolerant: if the branch was already deleted (e.g. by gh pr - // merge --delete-branch on a previous attempt), treat as success. - const msg = getErrorMessage(error); - if (msg.includes("404") || msg.toLowerCase().includes("not found")) { - branchDeleted = true; - } else { - logger.warn("prs.delete_branch_failed", { prId: row.id, headBranch, error: msg }); - } } - if (args.archiveLane) { + const childAdvanceResult = await advanceChildLanesAfterLand({ + landedLaneId: row.lane_id, + landedLaneName: row.title?.trim() || row.head_branch, + }).catch((error) => { + logger.warn("prs.child_auto_rebase_failed", { + prId: row.id, + laneId: row.lane_id, + error: getErrorMessage(error), + }); + return { + updatedLaneIds: [], + failedLaneIds: [], + blockCleanup: true, + }; + }); + childAutoRebaseBlockedCleanup = childAdvanceResult.blockCleanup; + + if (!childAutoRebaseBlockedCleanup) { try { - await laneService.archive({ laneId: row.lane_id }); - laneArchived = true; - } catch (archiveErr) { - logger.warn("prs.lane_archive_failed", { prId: row.id, laneId: row.lane_id, error: getErrorMessage(archiveErr) }); + await githubService.apiRequest({ + method: "DELETE", + path: `/repos/${repo.owner}/${repo.name}/git/refs/heads/${headBranch}` + }); + branchDeleted = true; + } catch (error) { + logger.warn("prs.delete_branch_failed", { prId: row.id, headBranch, error: getErrorMessage(error) }); } - } - } else { - logger.warn("prs.post_merge_cleanup_blocked", { - prId: row.id, - laneId: row.lane_id, - failedLaneIds: childAdvanceResult.failedLaneIds, - }); - } - if (args.operationId) { - try { - operationService.finish({ - operationId: args.operationId, - status: "succeeded", - metadataPatch: { - mergeCommitSha: args.mergeCommitSha, - branchDeleted, - laneArchived, - childAutoRebaseBlockedCleanup, - autoRebasedChildLaneIds: childAdvanceResult.updatedLaneIds, - failedAutoRebaseChildLaneIds: childAdvanceResult.failedLaneIds, + if (args.archiveLane) { + try { + await laneService.archive({ laneId: row.lane_id }); + laneArchived = true; + } catch (archiveErr) { + logger.warn("prs.lane_archive_failed", { prId: row.id, laneId: row.lane_id, error: getErrorMessage(archiveErr) }); } + } + } else { + logger.warn("prs.post_merge_cleanup_blocked", { + prId: row.id, + laneId: row.lane_id, + failedLaneIds: childAdvanceResult.failedLaneIds, }); - } catch { /* already finished -- ignore */ } - } + } - markHotRefresh([row.id]); - await refreshOne(row.id).catch(() => {}); - await conflictService?.scanRebaseNeeds().catch((error) => { - logger.warn("prs.refresh_rebase_needs_failed", { - prId: row.id, - error: getErrorMessage(error), + operationService.finish({ + operationId: op.operationId, + status: "succeeded", + metadataPatch: { + mergeCommitSha, + branchDeleted, + laneArchived, + childAutoRebaseBlockedCleanup, + autoRebasedChildLaneIds: childAdvanceResult.updatedLaneIds, + failedAutoRebaseChildLaneIds: childAdvanceResult.failedLaneIds, + } }); - }); - await rebaseSuggestionService?.refresh().catch((error) => { - logger.warn("prs.refresh_rebase_suggestions_failed", { - prId: row.id, - error: getErrorMessage(error), + + markHotRefresh([row.id]); + await refreshOne(row.id).catch(() => {}); + await conflictService?.scanRebaseNeeds().catch((error) => { + logger.warn("prs.refresh_rebase_needs_failed", { + prId: row.id, + error: getErrorMessage(error), + }); }); - }); - await autoRebaseService?.refreshActiveRebaseNeeds("merge_completed").catch((error) => { - logger.warn("prs.refresh_auto_rebase_failed", { + await rebaseSuggestionService?.refresh().catch((error) => { + logger.warn("prs.refresh_rebase_suggestions_failed", { + prId: row.id, + error: getErrorMessage(error), + }); + }); + await autoRebaseService?.refreshActiveRebaseNeeds("merge_completed").catch((error) => { + logger.warn("prs.refresh_auto_rebase_failed", { + prId: row.id, + error: getErrorMessage(error), + }); + }); + } catch (cleanupError) { + // The merge itself succeeded -- cleanup failure must not mask that. + const cleanupMsg = getErrorMessage(cleanupError); + logger.error("prs.post_merge_cleanup_failed", { prId: row.id, - error: getErrorMessage(error), + mergeCommitSha, + error: cleanupMsg, }); - }); - } catch (cleanupError) { - const cleanupMsg = getErrorMessage(cleanupError); - logger.error("prs.post_merge_cleanup_failed", { - prId: row.id, - mergeCommitSha: args.mergeCommitSha, - error: cleanupMsg, - }); - if (args.operationId) { + // Best-effort: mark the operation as succeeded even if cleanup threw try { operationService.finish({ - operationId: args.operationId, + operationId: op.operationId, status: "succeeded", metadataPatch: { - mergeCommitSha: args.mergeCommitSha, + mergeCommitSha, branchDeleted, laneArchived, childAutoRebaseBlockedCleanup, cleanupError: cleanupMsg, } }); - } catch { /* already finished -- ignore */ } + } catch { /* already finished or double-finish -- ignore */ } } - } - - return { branchDeleted, laneArchived, childAutoRebaseBlockedCleanup }; - }; - - const land = async (args: LandPrArgs): Promise => { - const row = getRow(args.prId); - if (!row) throw new Error(`PR not found: ${args.prId}`); - const repo: GitHubRepoRef = { owner: row.repo_owner, name: row.repo_name }; - - const op = operationService.start({ - laneId: row.lane_id, - kind: "pr_land", - metadata: { - prId: row.id, - prNumber: Number(row.github_pr_number), - method: args.method - } - }); - - try { - const merge = await githubService.apiRequest({ - method: "PUT", - path: `/repos/${repo.owner}/${repo.name}/pulls/${Number(row.github_pr_number)}/merge`, - body: { - merge_method: args.method - } - }); - - const mergeCommitSha = asString(merge.data?.sha) || null; - - const cleanup = await runPostMergeCleanup({ - prId: row.id, - mergeCommitSha, - archiveLane: Boolean(args.archiveLane), - operationId: op.operationId, - }); return { prId: row.id, prNumber: Number(row.github_pr_number), success: true, mergeCommitSha, - branchDeleted: cleanup.branchDeleted, - laneArchived: cleanup.laneArchived, + branchDeleted, + laneArchived, error: null }; } catch (error) { @@ -3161,36 +3117,15 @@ export function createPrService({ [groupId, projectId, args.queueName ?? null, args.autoRebase ? 1 : 0, args.ciGating ? 1 : 0, args.targetBranch, now] ); - // Graphite-style chain bases: PR_0 targets args.targetBranch; PR_N targets - // PR_(N-1)'s branch so each PR's GitHub diff shows only its own changes. - // We honor the caller-provided lane order. If a pair isn't actually parent->child - // we still chain them (the queue is a single review unit) but warn since the - // resulting diffs won't be clean per-PR. - let previousBranch: string | null = null; - let previousLaneId: string | null = null; + // Queue PRs all target the same branch (no chaining) for (let i = 0; i < args.laneIds.length; i++) { const laneId = args.laneIds[i]!; const lane = laneMap.get(laneId); if (!lane) { errors.push({ laneId, error: `Lane not found: ${laneId}` }); - // Reset chain pointers so the next PR doesn't silently chain across the - // missing lane and end up with a polluted diff against the wrong base. - previousBranch = null; - previousLaneId = null; continue; } - const baseBranch = previousBranch ?? args.targetBranch; - if (previousLaneId && lane.parentLaneId !== previousLaneId) { - logger.warn("prs.queue_chain_unrelated_lanes", { - groupId, - position: i, - laneId, - previousLaneId, - baseBranch - }); - } - const title = args.titles?.[laneId] ?? lane.name; try { const pr = await createFromLane({ @@ -3198,7 +3133,7 @@ export function createPrService({ title, body: "", draft: Boolean(args.draft), - baseBranch, + baseBranch: args.targetBranch, allowDirtyWorktree: true }); prs.push(pr); @@ -3208,15 +3143,8 @@ export function createPrService({ `insert into pr_group_members(id, group_id, pr_id, lane_id, position, role) values (?, ?, ?, ?, ?, 'source')`, [memberId, groupId, pr.id, laneId, i] ); - - previousBranch = branchNameFromRef(lane.branchRef); - previousLaneId = laneId; } catch (error) { errors.push({ laneId, error: error instanceof Error ? error.message : String(error) }); - // Same reasoning as the missing-lane branch: don't chain the next PR - // onto a lane whose PR creation just failed. - previousBranch = null; - previousLaneId = null; continue; } } @@ -5454,38 +5382,10 @@ export function createPrService({ return await land(args); }, - /** - * Run only the post-merge bookkeeping (branch deletion, child-lane rebase - * advance, group cleanup, cache refresh). Used by the Path-to-Merge merge - * ladder when a `gh pr merge --admin` rung lands the PR outside of `land`. - */ - async runPostMergeCleanup(args: { prId: string; mergeCommitSha?: string | null; archiveLane?: boolean }): Promise<{ - branchDeleted: boolean; - laneArchived: boolean; - childAutoRebaseBlockedCleanup: boolean; - }> { - return await runPostMergeCleanup({ - prId: args.prId, - mergeCommitSha: args.mergeCommitSha ?? null, - archiveLane: Boolean(args.archiveLane), - operationId: null, - }); - }, - async landStack(args: LandStackArgs): Promise { return await landStack(args); }, - /** - * Retarget a PR's GitHub base branch. Used by the stacked-queue land loop - * after a parent PR merges, to drop the next PR's base from the parent's - * branch to the queue's target branch (typically `main`) so it can land - * cleanly on top. - */ - async retargetBase(prId: string, baseBranch: string): Promise { - await retargetBase(prId, baseBranch); - }, - async openInGitHub(prId: string): Promise { const row = getRow(prId); if (!row) throw new Error(`PR not found: ${prId}`); diff --git a/apps/desktop/src/main/services/prs/queueLandingService.ts b/apps/desktop/src/main/services/prs/queueLandingService.ts index 77614e9fc..41ead7722 100644 --- a/apps/desktop/src/main/services/prs/queueLandingService.ts +++ b/apps/desktop/src/main/services/prs/queueLandingService.ts @@ -14,8 +14,6 @@ import type { ResumeQueueAutomationArgs, StartQueueAutomationArgs, } from "../../../shared/types"; -import { DEFAULT_PIPELINE_SETTINGS, pipelineFromLegacyQueueConfig } from "../../../shared/types"; -import type { PipelineSettings } from "../../../shared/types"; import type { AdeDb } from "../state/kvDb"; import type { Logger } from "../logging/logger"; import type { createConflictService } from "../conflicts/conflictService"; @@ -54,25 +52,17 @@ type QueueGroupRow = { const DEFAULT_QUEUE_CONFIG: QueueAutomationConfig = { method: "squash", archiveLane: false, - ciGating: true, - pipeline: { - ...DEFAULT_PIPELINE_SETTINGS, - autoAgentSettings: { - ...DEFAULT_PIPELINE_SETTINGS.autoAgentSettings, - permissionMode: "guarded_edit", - }, - }, - originSurface: "manual", - originMissionId: null, - originRunId: null, - originLabel: null, - // Legacy mirrors (retained for back-compat with iOS sync, RPC, older UI). autoResolve: false, + ciGating: true, resolverProvider: null, resolverModel: null, reasoningEffort: null, permissionMode: "guarded_edit", confidenceThreshold: null, + originSurface: "manual", + originMissionId: null, + originRunId: null, + originLabel: null, }; function parseEntries(raw: string): QueueLandingEntry[] { @@ -142,7 +132,6 @@ export function createQueueLandingService({ }>; listGroupPrs: (groupId: string) => Promise; getStatus: (prId: string) => Promise; - retargetBase: (prId: string, baseBranch: string) => Promise; }; laneService: Pick, "list" | "getLaneBaseAndBranch">; conflictService?: Pick, "runExternalResolver"> | null; @@ -289,49 +278,12 @@ export function createQueueLandingService({ emitQueueState(state.groupId, state.state, state.currentPosition); }; - const mergePipelineSettings = ( - base: PipelineSettings, - override: PipelineSettings | undefined, - ): PipelineSettings => { - if (!override) return base; - return { - ...base, - ...override, - autoAgentSettings: { - ...base.autoAgentSettings, - ...override.autoAgentSettings, - }, - }; - }; - const resolveQueueConfig = ( args: StartQueueAutomationArgs | ResumeQueueAutomationArgs, existing?: QueueLandingState | null, group?: QueueGroupRow | null, ): QueueAutomationConfig => { const prior = existing?.config ?? DEFAULT_QUEUE_CONFIG; - // Pipeline resolution: explicit args win over prior over default. When no - // args.pipeline is given but the caller passed only the legacy mirror - // fields (autoResolve / resolverProvider / etc.), synthesize a pipeline - // from those so the new fields stay in sync with the legacy ones. - const hasLegacyAutoResolveArgs = - args.autoResolve !== undefined - || args.resolverProvider !== undefined - || args.resolverModel !== undefined - || args.reasoningEffort !== undefined - || args.permissionMode !== undefined - || args.confidenceThreshold !== undefined; - const argsPipeline: PipelineSettings | undefined = - args.pipeline ?? (hasLegacyAutoResolveArgs ? pipelineFromLegacyQueueConfig({ - autoResolve: args.autoResolve, - resolverProvider: args.resolverProvider, - resolverModel: args.resolverModel, - reasoningEffort: args.reasoningEffort, - permissionMode: args.permissionMode, - confidenceThreshold: args.confidenceThreshold, - }) : undefined); - const basePipeline = mergePipelineSettings(DEFAULT_QUEUE_CONFIG.pipeline, prior.pipeline); - const pipeline = mergePipelineSettings(basePipeline, argsPipeline); return { ...DEFAULT_QUEUE_CONFIG, ...prior, @@ -339,7 +291,6 @@ export function createQueueLandingService({ archiveLane: args.archiveLane ?? prior.archiveLane ?? false, autoResolve: args.autoResolve ?? prior.autoResolve ?? false, ciGating: args.ciGating ?? prior.ciGating ?? Boolean(group?.ci_gating), - pipeline, resolverProvider: args.resolverProvider ?? prior.resolverProvider ?? null, resolverModel: args.resolverModel ?? prior.resolverModel ?? null, reasoningEffort: args.reasoningEffort ?? prior.reasoningEffort ?? null, @@ -453,42 +404,6 @@ export function createQueueLandingService({ emitQueueStep(state.groupId, entry.prId, "landed", index); }; - /** - * After a stacked PR lands, the next PR in the queue still has its base - * pointing at the just-merged PR's branch (that's how we created it in - * `createQueuePrs`). Drop its base onto the queue's `targetBranch` so the - * next land is a clean main-merge instead of trying to merge a deleted ref. - * - * Idempotent: if the base is already `targetBranch`, GitHub's API treats it - * as a no-op. Failures here don't fail the queue — the next iteration's - * land attempt will surface any base mismatch as a real merge error. - */ - const retargetNextEntryIfNeeded = async ( - state: QueueLandingState, - landedIndex: number, - ): Promise => { - const targetBranch = state.targetBranch; - if (!targetBranch) return; - const next = state.entries[landedIndex + 1]; - if (!next) return; - if (next.state === "landed" || next.state === "skipped") return; - try { - await prService.retargetBase(next.prId, targetBranch); - logger.debug("queue_landing.retargeted_next_base", { - queueId: state.queueId, - nextPrId: next.prId, - baseBranch: targetBranch, - }); - } catch (error) { - logger.warn("queue_landing.retarget_next_base_failed", { - queueId: state.queueId, - nextPrId: next.prId, - baseBranch: targetBranch, - error: getErrorMessage(error), - }); - } - }; - const pauseWithReason = ( state: QueueLandingState, entry: QueueLandingEntry, @@ -747,7 +662,6 @@ export function createQueueLandingService({ } if (!guardTransition(entry, "landed", { queueId })) return; markEntryLanded(state, entry, index, retried.mergeCommitSha); - await retargetNextEntryIfNeeded(state, index); continue; } @@ -765,7 +679,6 @@ export function createQueueLandingService({ if (!guardTransition(entry, "landed", { queueId })) return; markEntryLanded(state, entry, index, landResult.mergeCommitSha); - await retargetNextEntryIfNeeded(state, index); } catch (error) { const message = getErrorMessage(error); failEntry(state, entry, "manual", message); diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 97f471238..159734321 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -1446,32 +1446,6 @@ function migrate(db: MigrationDb) { try { db.run("alter table queue_landing_state add column wait_reason text"); } catch {} try { db.run("alter table queue_landing_state add column updated_at text"); } catch {} - // One-shot wipe of legacy queue_landing_state on upgrade to the stacked-PR - // queue overhaul. The new queue creates PRs with chain bases (PR_N's base = - // previous lane's branch) instead of all-into-main, so any in-flight queue - // from the old code path would be misinterpreted by the new landing loop. - // Wiping rather than migrating is a deliberate choice — the user accepts - // losing in-flight queues in exchange for not maintaining a translation - // layer for every legacy field shape. - const QUEUE_OVERHAUL_WIPE_MARKER = "queue_landing_state.wiped_for_stacked_overhaul.v1"; - try { - const row = db.get<{ value: string }>( - "select value from kv where key = ?", - [QUEUE_OVERHAUL_WIPE_MARKER], - ); - if (!row) { - db.run("delete from queue_landing_state"); - db.run( - "insert into kv (key, value) values (?, ?) on conflict(key) do update set value = excluded.value", - [QUEUE_OVERHAUL_WIPE_MARKER, new Date().toISOString()], - ); - } - } catch { - // Table may not exist on a brand-new DB; initialization will create both - // tables and the next startup will record the marker. Skipping the wipe - // on a fresh DB is correct (nothing to wipe). - } - // Rebase dismiss/defer persistence db.run(` create table if not exists rebase_dismissed ( @@ -3355,10 +3329,7 @@ function migrate(db: MigrationDb) { try { db.run("alter table pr_issue_inventory add column thread_latest_comment_source text"); } catch {} db.run("create index if not exists idx_inventory_pr_state on pr_issue_inventory(pr_id, state)"); - // PR pipeline settings: per-PR auto-converge / auto-merge configuration. - // Newer fields (conflict_strategy, force_finalize_*, early_merge_on_green, - // auto_agent_*) are added via try-catch ALTER below so existing DBs upgrade - // in place. The legacy `on_rebase_needed` column is retained for back-compat. + // PR pipeline settings: per-PR auto-converge / auto-merge configuration db.run(` create table if not exists pr_pipeline_settings ( pr_id text primary key, @@ -3370,15 +3341,6 @@ function migrate(db: MigrationDb) { foreign key(pr_id) references pull_requests(id) on delete cascade ) `); - try { db.run("alter table pr_pipeline_settings add column conflict_strategy text not null default 'pause'"); } catch {} - try { db.run("alter table pr_pipeline_settings add column force_finalize_mode text not null default 'off'"); } catch {} - try { db.run("alter table pr_pipeline_settings add column force_finalize_require_no_ci_failures integer not null default 1"); } catch {} - try { db.run("alter table pr_pipeline_settings add column early_merge_on_green integer not null default 1"); } catch {} - try { db.run("alter table pr_pipeline_settings add column auto_agent_provider text"); } catch {} - try { db.run("alter table pr_pipeline_settings add column auto_agent_model text"); } catch {} - try { db.run("alter table pr_pipeline_settings add column auto_agent_reasoning_effort text"); } catch {} - try { db.run("alter table pr_pipeline_settings add column auto_agent_permission_mode text"); } catch {} - try { db.run("alter table pr_pipeline_settings add column auto_agent_confidence_threshold real"); } catch {} db.run(` create table if not exists pr_convergence_state ( @@ -3401,10 +3363,6 @@ function migrate(db: MigrationDb) { foreign key(pr_id) references pull_requests(id) on delete cascade ) `); - // PtM-specific run args (modelId, reasoning, scope, additionalInstructions) - // serialized as JSON. Persisted so resumeFromPersistedState can re-dispatch - // the fix agent after a desktop restart instead of pausing on missing modelId. - try { db.run("alter table pr_convergence_state add column ptm_args_json text"); } catch {} } function loadCrsqlite(db: DatabaseSyncType, extensionPath: string): void { diff --git a/apps/desktop/src/main/services/sync/syncHostService.ts b/apps/desktop/src/main/services/sync/syncHostService.ts index 4717c2486..0ad97bad0 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.ts @@ -74,7 +74,6 @@ import type { createRebaseSuggestionService } from "../lanes/rebaseSuggestionSer import type { createProcessService } from "../processes/processService"; import type { createPtyService } from "../pty/ptyService"; import type { createIssueInventoryService } from "../prs/issueInventoryService"; -import type { PathToMergeOrchestrator } from "../prs/pathToMergeOrchestrator"; import type { createPrService } from "../prs/prService"; import type { createQueueLandingService } from "../prs/queueLandingService"; import type { createSessionService } from "../sessions/sessionService"; @@ -286,8 +285,6 @@ type SyncHostServiceArgs = { conflictService?: ReturnType; prService: ReturnType; issueInventoryService?: ReturnType | null; - /** Optional Path-to-Merge orchestrator (forwarded to remote command service). */ - pathToMergeOrchestrator?: PathToMergeOrchestrator | null; queueLandingService?: ReturnType | null; sessionService: ReturnType; ptyService: ReturnType; @@ -531,7 +528,6 @@ export function createSyncHostService(args: SyncHostServiceArgs) { getLinearIssueTracker: args.getLinearIssueTracker, getLinearSyncService: args.getLinearSyncService, issueInventoryService: args.issueInventoryService, - pathToMergeOrchestrator: args.pathToMergeOrchestrator, queueLandingService: args.queueLandingService, projectConfigService: args.projectConfigService, processService: args.processService, diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index aae73e19a..c1d3fbe1a 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -143,8 +143,6 @@ const IOS_REMOTE_COMMAND_ACTIONS = [ "prs.pipelineSettings.get", "prs.pipelineSettings.save", "prs.pipelineSettings.delete", - "prs.pathToMerge.start", - "prs.pathToMerge.stop", ] satisfies SyncRemoteCommandAction[]; const IOS_FILE_REQUEST_ACTIONS = [ diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index c7714735f..b71170c38 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -131,7 +131,6 @@ import type { createProcessService } from "../processes/processService"; import type { Logger } from "../logging/logger"; import type { createPrService } from "../prs/prService"; import type { createIssueInventoryService } from "../prs/issueInventoryService"; -import type { PathToMergeOrchestrator } from "../prs/pathToMergeOrchestrator"; import type { createQueueLandingService } from "../prs/queueLandingService"; import type { createPtyService } from "../pty/ptyService"; import type { createSessionService } from "../sessions/sessionService"; @@ -140,13 +139,6 @@ type SyncRemoteCommandServiceArgs = { laneService: ReturnType; prService: ReturnType; issueInventoryService?: ReturnType | null; - /** - * Optional Path-to-Merge orchestrator. When present, iOS callers can start - * and stop the convergence loop via the `prs.pathToMerge.start` / - * `prs.pathToMerge.stop` sync commands. Optional so older builds (without - * the orchestrator wired) keep compiling and degrade gracefully on iOS. - */ - pathToMergeOrchestrator?: PathToMergeOrchestrator | null; queueLandingService?: ReturnType | null; ptyService: ReturnType; sessionService: ReturnType; @@ -2239,36 +2231,6 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg args.issueInventoryService.deletePipelineSettings(parseIssueInventoryPrArgs(payload, "prs.pipelineSettings.delete").prId); return { ok: true }; }); - register("prs.pathToMerge.start", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.pathToMergeOrchestrator) { - throw new Error("Path to Merge orchestrator is not available in this build."); - } - const { prId } = parseIssueInventoryPrArgs(payload, "prs.pathToMerge.start"); - const modelId = typeof payload?.modelId === "string" ? payload.modelId : null; - const reasoning = typeof payload?.reasoning === "string" ? payload.reasoning : null; - const additionalInstructions = typeof payload?.additionalInstructions === "string" - ? payload.additionalInstructions - : null; - const rawScope = payload?.scope; - const scope = rawScope === "checks" || rawScope === "comments" || rawScope === "both" - ? rawScope - : undefined; - return args.pathToMergeOrchestrator.startPathToMerge({ - prId, - modelId, - reasoning, - scope, - additionalInstructions, - }); - }); - register("prs.pathToMerge.stop", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.pathToMergeOrchestrator) { - throw new Error("Path to Merge orchestrator is not available in this build."); - } - const { prId } = parseIssueInventoryPrArgs(payload, "prs.pathToMerge.stop"); - const reason = typeof payload?.reason === "string" ? payload.reason : null; - return args.pathToMergeOrchestrator.stopPathToMerge({ prId, reason }); - }); register("prs.getMobileSnapshot", { viewerAllowed: true }, async () => args.prService.getMobileSnapshot()); return { diff --git a/apps/desktop/src/main/services/sync/syncService.ts b/apps/desktop/src/main/services/sync/syncService.ts index 0c7e54d4c..87451de1c 100644 --- a/apps/desktop/src/main/services/sync/syncService.ts +++ b/apps/desktop/src/main/services/sync/syncService.ts @@ -43,7 +43,6 @@ import type { createRebaseSuggestionService } from "../lanes/rebaseSuggestionSer import type { createMissionService } from "../missions/missionService"; import type { createProcessService } from "../processes/processService"; import type { createIssueInventoryService } from "../prs/issueInventoryService"; -import type { PathToMergeOrchestrator } from "../prs/pathToMergeOrchestrator"; import type { createPrService } from "../prs/prService"; import type { createQueueLandingService } from "../prs/queueLandingService"; import type { createPtyService } from "../pty/ptyService"; @@ -75,11 +74,6 @@ type SyncServiceArgs = { conflictService?: ReturnType; prService: ReturnType; issueInventoryService?: ReturnType | null; - /** - * Optional Path-to-Merge orchestrator forwarded to the embedded sync host so - * iOS callers can drive the convergence loop via remote commands. - */ - pathToMergeOrchestrator?: PathToMergeOrchestrator | null; queueLandingService?: ReturnType | null; sessionService: ReturnType; ptyService: ReturnType; @@ -529,7 +523,6 @@ export function createSyncService(args: SyncServiceArgs) { conflictService: args.conflictService, prService: args.prService, issueInventoryService: args.issueInventoryService, - pathToMergeOrchestrator: args.pathToMergeOrchestrator, queueLandingService: args.queueLandingService, sessionService: args.sessionService, ptyService: args.ptyService, diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 94839535b..45ee3aa62 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -1589,7 +1589,6 @@ declare global { ) => Promise<{ title: string; body: string }>; land: (args: LandPrArgs) => Promise; landStack: (args: LandStackArgs) => Promise; - retargetBase: (args: { prId: string; baseBranch: string }) => Promise; openInGitHub: (prId: string) => Promise; createQueue: ( args: CreateQueuePrsArgs, @@ -1720,17 +1719,6 @@ declare global { state: PrConvergenceStatePatch, ) => Promise; convergenceStateDelete: (prId: string) => Promise; - pathToMergeStart: (args: { - prId: string; - modelId?: string | null; - reasoning?: string | null; - scope?: "checks" | "comments" | "both"; - additionalInstructions?: string | null; - }) => Promise<{ prId: string; scheduled: boolean; runtime: PrConvergenceState }>; - pathToMergeStop: (args: { - prId: string; - reason?: string | null; - }) => Promise<{ prId: string; stopped: boolean; runtime: PrConvergenceState | null }>; pipelineSettingsGet: (prId: string) => Promise; pipelineSettingsSave: ( prId: string, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 48d3b5f0e..a3b7d758e 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -2887,8 +2887,6 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.prsLand, args), landStack: async (args: LandStackArgs): Promise => ipcRenderer.invoke(IPC.prsLandStack, args), - retargetBase: async (args: { prId: string; baseBranch: string }): Promise => - ipcRenderer.invoke(IPC.prsRetargetBase, args), openInGitHub: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsOpenInGitHub, { prId }), createQueue: (args: CreateQueuePrsArgs): Promise => @@ -3083,19 +3081,6 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.prsConvergenceStateSave, { prId, state }), convergenceStateDelete: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsConvergenceStateDelete, { prId }), - pathToMergeStart: async (args: { - prId: string; - modelId?: string | null; - reasoning?: string | null; - scope?: "checks" | "comments" | "both"; - additionalInstructions?: string | null; - }): Promise<{ prId: string; scheduled: boolean; runtime: PrConvergenceState }> => - ipcRenderer.invoke(IPC.prsPathToMergeStart, args), - pathToMergeStop: async (args: { - prId: string; - reason?: string | null; - }): Promise<{ prId: string; stopped: boolean; runtime: PrConvergenceState | null }> => - ipcRenderer.invoke(IPC.prsPathToMergeStop, args), pipelineSettingsGet: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsPipelineSettingsGet, { prId }), pipelineSettingsSave: async ( diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index d285a1d96..8d9b5041a 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -29,7 +29,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { getDefaultModelDescriptor } from "../shared/modelRegistry"; -import { DEFAULT_PIPELINE_SETTINGS } from "../shared/types"; const noop = () => () => {}; const resolved = @@ -4434,7 +4433,6 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }), land: resolvedArg({ success: true, prNumber: 142, sha: "abc123" }), landStack: resolvedArg([]), - retargetBase: resolvedArg(undefined), openInGitHub: resolvedArg(undefined), createQueue: resolvedArg({}), createIntegration: resolvedArg({}), @@ -4576,44 +4574,6 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { convergenceStateDelete: async (prId: string) => { delete MOCK_CONVERGENCE_RUNTIME[prId]; }, - pathToMergeStart: async (args: { prId: string }) => { - const runtime = - MOCK_CONVERGENCE_RUNTIME[args.prId] ?? - createDefaultConvergenceRuntime(args.prId); - runtime.autoConvergeEnabled = true; - runtime.status = "running"; - runtime.pollerStatus = "scheduled"; - runtime.pauseReason = null; - runtime.errorMessage = null; - runtime.lastStartedAt = new Date().toISOString(); - MOCK_CONVERGENCE_RUNTIME[args.prId] = runtime; - return { prId: args.prId, scheduled: true, runtime: { ...runtime } }; - }, - pathToMergeStop: async (args: { prId: string; reason?: string | null }) => { - const runtime = MOCK_CONVERGENCE_RUNTIME[args.prId] ?? null; - if (runtime) { - runtime.autoConvergeEnabled = false; - runtime.status = "stopped"; - runtime.pollerStatus = "stopped"; - runtime.pauseReason = args.reason ?? null; - runtime.lastStoppedAt = new Date().toISOString(); - } - return { - prId: args.prId, - stopped: true, - runtime: runtime ? { ...runtime } : null, - }; - }, - pipelineSettingsGet: async (_prId: string) => DEFAULT_PIPELINE_SETTINGS, - pipelineSettingsSave: async ( - _prId: string, - _settings: Record, - ) => { - // No-op in browser mock — settings persistence is server-side. - }, - pipelineSettingsDelete: async (_prId: string) => { - // No-op in browser mock. - }, rebaseResolutionStart: async () => ({ sessionId: "mock-rebase-session", laneId: "lane-dashboard", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index f1a289d5f..968c954d6 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -211,29 +211,6 @@ function pendingHeaderLabel(kind: PendingInputRequest["kind"], questionCount: nu return "Input needed"; } -function getComposerInputLockMessage(pendingInput: PendingInputRequest | null | undefined): string | null { - if (!pendingInput) return null; - if (pendingInput.kind === "question" || pendingInput.kind === "structured_question") { - return "Answer the question card above, or decline it."; - } - return "Resolve the pending request above before sending another message."; -} - -function getAttachBlockedReason(args: { - composerInputLocked: boolean; - composerInputLockMessage: string | null; - parallelChatMode: boolean; - attachmentCount: number; -}): string | null { - if (args.composerInputLocked) { - return args.composerInputLockMessage ?? "Resolve the pending request before adding attachments."; - } - if (args.parallelChatMode && args.attachmentCount >= PARALLEL_CHAT_MAX_ATTACHMENTS) { - return `Maximum ${PARALLEL_CHAT_MAX_ATTACHMENTS} attachments for parallel launch`; - } - return null; -} - function iosSourceResolutionLabel(resolution: string): string { switch (resolution) { case "ade-inspector": @@ -792,14 +769,17 @@ export function AgentChatComposer({ const clipboardImagePasteFallbackAttachedRef = useRef(false); const useRichComposer = iosElementContextItems.length > 0 || appControlContextItems.length > 0 || builtInBrowserContextItems.length > 0; const composerInputLocked = Boolean(pendingInput?.blocking); - const composerInputLockMessage = getComposerInputLockMessage(pendingInput); + const composerInputLockMessage = pendingInput?.kind === "question" || pendingInput?.kind === "structured_question" + ? "Answer the question card above, or decline it." + : pendingInput + ? "Resolve the pending request above before sending another message." + : null; const canAttach = !composerInputLocked && (!parallelChatMode || attachments.length < PARALLEL_CHAT_MAX_ATTACHMENTS); - const attachBlockedReason = getAttachBlockedReason({ - composerInputLocked, - composerInputLockMessage, - parallelChatMode, - attachmentCount: attachments.length, - }); + const attachBlockedReason = composerInputLocked + ? composerInputLockMessage ?? "Resolve the pending request before adding attachments." + : parallelChatMode && attachments.length >= PARALLEL_CHAT_MAX_ATTACHMENTS + ? `Maximum ${PARALLEL_CHAT_MAX_ATTACHMENTS} attachments for parallel launch` + : null; const resizeTextarea = useCallback(() => { if (useRichComposer) return; diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx index 95f8e3a54..9c73fc37d 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx @@ -387,17 +387,6 @@ function renderPane(args: { mergeMethod: "repo_default", maxRounds: 5, onRebaseNeeded: "pause", - conflictStrategy: "pause", - autoAgentSettings: { - provider: null, - model: null, - reasoningEffort: null, - permissionMode: null, - confidenceThreshold: null, - }, - forceFinalizeMode: "off", - forceFinalizeRequireNoCiFailures: true, - earlyMergeOnGreen: true, }), getChecks, getStatus, diff --git a/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.test.tsx b/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.test.tsx index b1d9685b8..21830e0bf 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.test.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.test.tsx @@ -66,17 +66,6 @@ const defaultPipelineSettings: PipelineSettings = { mergeMethod: "repo_default", maxRounds: 5, onRebaseNeeded: "pause", - conflictStrategy: "pause", - autoAgentSettings: { - provider: null, - model: null, - reasoningEffort: null, - permissionMode: null, - confidenceThreshold: null, - }, - forceFinalizeMode: "off", - forceFinalizeRequireNoCiFailures: true, - earlyMergeOnGreen: true, }; function renderPanel(overrides: Partial = {}) { diff --git a/apps/desktop/src/renderer/components/prs/shared/PrPipelineSettings.tsx b/apps/desktop/src/renderer/components/prs/shared/PrPipelineSettings.tsx index 1cadf6a85..544b3b51c 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrPipelineSettings.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrPipelineSettings.tsx @@ -1,17 +1,12 @@ import React from "react"; import type { AiPermissionMode, - AutoConflictAgentProvider, - AutoConflictAgentSettings, - ConflictResolverPermissionMode, - ConflictStrategy, - ForceFinalizeMode, PipelineMergeMethod, PipelineSettings, + RebasePolicy, } from "../../../../shared/types"; import { COLORS, MONO_FONT, SANS_FONT } from "../../lanes/laneDesignTokens"; import { PrResolverLaunchControls } from "./PrResolverLaunchControls"; -import { SmartTooltip, type SmartTooltipContent } from "../../ui/SmartTooltip"; // --------------------------------------------------------------------------- // Types @@ -41,137 +36,15 @@ const MERGE_METHOD_OPTIONS: Array<{ value: PipelineMergeMethod; label: string }> { value: "rebase", label: "Rebase and merge" }, ]; -type ConflictStrategyOption = { - value: ConflictStrategy; - label: string; - tooltip: SmartTooltipContent; -}; - -const CONFLICT_STRATEGY_OPTIONS: ConflictStrategyOption[] = [ - { - value: "pause", - label: "Pause and wait for me", - tooltip: { - label: "Pause on conflict", - description: - "When the base branch advances or a merge conflict appears, stop the loop and surface it so you can resolve it by hand.", - }, - }, - { - value: "rebase", - label: "Rebase the PR onto the new base", - tooltip: { - label: "Auto-rebase", - description: - "Run git rebase onto the latest base and force-push (with --force-with-lease). Fast and clean — but rewrites history.", - }, - }, - { - value: "merge", - label: "Merge the new base into the PR branch", - tooltip: { - label: "Auto-merge base", - description: - "Merge the latest base commit into the PR branch. Preserves history and avoids force-push, but adds a merge commit.", - }, - }, - { - value: "auto", - label: "Let an agent decide and resolve", - tooltip: { - label: "Agent-resolved conflicts", - description: - "An agent picks rebase vs merge and resolves any conflict markers itself. Configure the agent in the section below.", - }, - }, -]; - -type ForceFinalizeOption = { - value: ForceFinalizeMode; - label: string; - tooltip: SmartTooltipContent; -}; - -const FORCE_FINALIZE_OPTIONS: ForceFinalizeOption[] = [ - { - value: "off", - label: "Don't force-merge", - tooltip: { - label: "Force-finalize off", - description: - "If iterations run out without converging, stop and leave the PR alone. Safest — you'll come back and finish by hand.", - }, - }, - { - value: "unconditional", - label: "Force-merge once iterations are exhausted", - tooltip: { - label: "Always force-finalize", - description: - "After the last iteration, run one more pass that lands the merge no matter what.", - warning: "Bypasses unresolved review comments and ignores most checks.", - }, - }, - { - value: "conditional", - label: "Force-merge only if conditions hold", - tooltip: { - label: "Conditional force-finalize", - description: - "Run the bonus merge pass only when guardrails below pass — e.g. no CI checks are currently failing.", - }, - }, -]; - -const AUTO_AGENT_PROVIDER_OPTIONS: Array<{ value: string; label: string }> = [ - { value: "__inherit", label: "Inherit default" }, - { value: "claude", label: "Claude" }, - { value: "codex", label: "Codex" }, -]; - -const AUTO_AGENT_REASONING_OPTIONS: Array<{ value: string; label: string }> = [ - { value: "__unspecified", label: "Unspecified" }, - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, -]; - -const AUTO_AGENT_PERMISSION_OPTIONS: Array<{ - value: ConflictResolverPermissionMode; - label: string; - tooltip: SmartTooltipContent; -}> = [ - { - value: "read_only", - label: "Read only", - tooltip: { - label: "Read-only resolver", - description: - "The agent can plan but not write to disk. Use when you want to inspect what it would do without touching files.", - }, - }, - { - value: "guarded_edit", - label: "Guarded edit", - tooltip: { - label: "Guarded edits", - description: - "The agent edits files but is restricted from destructive shell commands. Sensible default for most repos.", - }, - }, - { - value: "full_edit", - label: "Full edit", - tooltip: { - label: "Full edit access", - description: - "Unrestricted edits and shell access. Fastest at resolving messy conflicts; most risky if the agent misjudges.", - }, - }, +const REBASE_OPTIONS: Array<{ value: RebasePolicy; label: string }> = [ + { value: "pause", label: "Pause convergence" }, + { value: "auto_rebase", label: "Auto-rebase (conflicts pause)" }, ]; const ACCENT_GREEN = "#22C55E"; -const WARNING_AMBER = "#F59E0B"; +const TRACK_BG = "rgba(255,255,255,0.08)"; +const THUMB_SIZE = 14; +const TRACK_HEIGHT = 4; // --------------------------------------------------------------------------- // Keyframes @@ -189,6 +62,27 @@ function ensureKeyframes() { from { background-position: 0% 50%; } to { background-position: 100% 50%; } } + /* range thumb styling */ + input[type="range"].pipeline-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: ${THUMB_SIZE}px; + height: ${THUMB_SIZE}px; + border-radius: 999px; + background: #FAFAFA; + border: 2px solid ${COLORS.accent}; + box-shadow: 0 0 6px color-mix(in srgb, var(--color-accent) 40%, transparent); + cursor: pointer; + margin-top: -${(THUMB_SIZE - TRACK_HEIGHT) / 2}px; + transition: box-shadow 0.15s ease; + } + input[type="range"].pipeline-range::-webkit-slider-thumb:hover { + box-shadow: 0 0 10px color-mix(in srgb, var(--color-accent) 70%, transparent); + } + input[type="range"].pipeline-range::-webkit-slider-runnable-track { + height: ${TRACK_HEIGHT}px; + border-radius: 999px; + } /* select arrow override */ select.pipeline-select { -webkit-appearance: none; @@ -202,10 +96,6 @@ function ensureKeyframes() { outline: none; border-color: color-mix(in srgb, var(--color-accent) 60%, transparent); } - input.pipeline-number::-webkit-inner-spin-button, - input.pipeline-number::-webkit-outer-spin-button { - opacity: 1; - } `; document.head.appendChild(style); } @@ -221,15 +111,15 @@ function SectionDivider({ label }: { label: string }) { display: "flex", alignItems: "center", gap: 8, - marginTop: 14, - marginBottom: 8, + marginTop: 10, + marginBottom: 6, }} > - {children} - - ); -} - -function FieldLabel({ - text, - tooltip, - disabled, -}: { - text: string; - tooltip: SmartTooltipContent; - disabled?: boolean; -}) { - return ( - - - {text} - - - ); -} - function ToggleSwitch({ checked, onChange, disabled, - ariaLabel, }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean; - ariaLabel?: string; }) { return ( - - - - {/* ── Body ── */} -
- {/* PR list */} -
-
- Stack ({members.length} PR{members.length === 1 ? "" : "s"}) -
-
- {members.map((member, idx) => { - const status = statuses[member.prId] ?? { kind: "pending" }; - const badge = statusBadgeColor(status); - const isActive = idx === activeIndex && isRunning; - const wasHalted = idx === haltedIndex; - let rowBackground = "transparent"; - let rowBorderColor = "transparent"; - if (isActive) { - rowBackground = "color-mix(in srgb, var(--color-info) 8%, transparent)"; - rowBorderColor = "color-mix(in srgb, var(--color-info) 32%, transparent)"; - } else if (wasHalted) { - rowBackground = "color-mix(in srgb, var(--color-error) 8%, transparent)"; - rowBorderColor = "color-mix(in srgb, var(--color-error) 32%, transparent)"; - } - return ( -
- - {String(idx + 1).padStart(2, "0")} - - {member.laneColor ? ( - - ) : null} - - {member.laneName} - - - {member.prNumber != null ? `#${member.prNumber}` : "no PR"} - -
- {status.kind === "running" && status.round > 0 ? ( - - round {status.round}/{pipelineSettings.maxRounds} - - ) : null} - {status.kind === "saving" || - status.kind === "retargeting" || - status.kind === "starting" || - (status.kind === "running" && status.runtimeStatus !== "paused") ? ( - - ) : null} - {status.kind === "merged" ? ( - - ) : null} - {status.kind === "failed" ? ( - - ) : null} - - {badge.label} - -
- ); - })} -
-
- - {/* Pipeline settings — reuse the per-PR editor for the stack-wide config. */} -
-
- Stack-wide pipeline settings -
-
- These settings apply to every PR in the queue. Each PR runs Path to Merge sequentially; once one merges, the next PR's base is dropped to{" "} - {queueTargetBranch} before its run begins. -
- -
- - {/* Halted error surface */} - {haltedError ? ( -
-
- Stopped on {members[haltedIndex]?.laneName ?? "this PR"} -
- {haltedError} -
- The remaining PRs were not started. Open the PR's Path to Merge panel to inspect or retry. -
-
- ) : null} - - {sequence.kind === "complete" ? ( -
- - All PRs merged. The queue is clean. -
- ) : null} -
- - {/* ── Sticky footer ── */} -
- - - - - - -
- - - ); -} diff --git a/apps/desktop/src/renderer/components/prs/tabs/QueueTab.tsx b/apps/desktop/src/renderer/components/prs/tabs/QueueTab.tsx index 68a13683f..20839de25 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/QueueTab.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/QueueTab.tsx @@ -7,13 +7,11 @@ import { CircleNotch, GitBranch, GithubLogo, - MagicWand, Sparkle, Trash, Warning, } from "@phosphor-icons/react"; import type { - ConvergenceRuntimeState, LandResult, LaneSummary, MergeMethod, @@ -28,7 +26,6 @@ import type { import { getModelById } from "../../../../shared/modelRegistry"; import { EmptyState } from "../../ui/EmptyState"; import { PaneTilingLayout, type PaneConfig } from "../../ui/PaneTilingLayout"; -import { SmartTooltip } from "../../ui/SmartTooltip"; import { usePrs } from "../state/PrsContext"; import { LaneAccentDot } from "../../lanes/LaneAccentDot"; import { PR_TAB_TILING_TREE } from "../shared/tilingConstants"; @@ -41,10 +38,6 @@ import { findQueueMemberSelection, getQueueWorkflowBucket, } from "./queueWorkflowModel"; -import { - QueueAutomateMergingModal, - type QueueAutomateMergingMember, -} from "./QueueAutomateMergingModal"; type QueueMember = { prId: string; @@ -107,15 +100,6 @@ function pad2(value: number): string { return String(value).padStart(2, "0"); } -function describeConvergenceWaitState(conv: ConvergenceRuntimeState): string { - if (conv.pollerStatus === "waiting_for_checks") return "waiting for CI"; - if (conv.pollerStatus === "waiting_for_comments") return "waiting for review"; - if (conv.pauseReason) return `paused: ${conv.pauseReason}`; - if (conv.status === "polling") return "polling"; - if (conv.status === "paused") return "paused"; - return "running"; -} - function queueGroupLabel(group: QueueGroup): string { return group.name?.trim() || `Queue ${group.groupId.slice(0, 8)}`; } @@ -405,8 +389,6 @@ export function QueueTab({ const [showAiRebaseControls, setShowAiRebaseControls] = React.useState(false); const [rebaseSummary, setRebaseSummary] = React.useState(null); const [queueActionBusy, setQueueActionBusy] = React.useState<"resume" | "pause" | "cancel" | null>(null); - const [automateModalOpen, setAutomateModalOpen] = React.useState(false); - const [convergenceByPrId, setConvergenceByPrId] = React.useState>({}); React.useEffect(() => { setArchiveOnLand(Boolean(selectedGroup?.landingState?.config.archiveLane)); @@ -451,49 +433,6 @@ export function QueueTab({ return new Map(entries.map((entry) => [entry.prId, entry] as const)); }, [selectedGroup?.landingState?.entries]); - // Poll the per-PR convergence runtime so the queue list can show "Iteration N/Max", - // pause reasons, and the force-finalize bonus iteration badge inline. Only members - // of the currently-selected queue are polled to avoid unnecessary IPC chatter. - const memberPrIdsKey = React.useMemo( - () => (selectedGroup?.members ?? []).map((m) => m.prId).sort().join("|"), - [selectedGroup?.members], - ); - React.useEffect(() => { - // memberPrIdsKey is a sorted-joined string and is the only identity-stable - // signal for the member set; depending on `selectedGroup?.members` would - // re-arm this effect on every queue-state poll because the parent rebuilds - // the array reference each time. The effect already reads the current - // members through the closure, so re-running on identity churn buys - // nothing. - const memberIds = memberPrIdsKey ? memberPrIdsKey.split("|") : []; - if (memberIds.length === 0) { - setConvergenceByPrId({}); - return; - } - let cancelled = false; - const fetchAll = async () => { - const next: Record = {}; - await Promise.all( - memberIds.map(async (prId) => { - try { - next[prId] = await window.ade.prs.convergenceStateGet(prId); - } catch { - next[prId] = null; - } - }), - ); - if (!cancelled) setConvergenceByPrId(next); - }; - void fetchAll(); - const interval = setInterval(() => { - if (!cancelled) void fetchAll(); - }, 5000); - return () => { - cancelled = true; - clearInterval(interval); - }; - }, [memberPrIdsKey]); - const visibleRebaseNeeds = React.useMemo( () => rebaseNeeds.filter((need) => need.kind === "lane_base" && need.behindBy > 0), [rebaseNeeds], @@ -842,19 +781,7 @@ export function QueueTab({ >
- -
- {queueGroupLabel(selectedGroup)} -
-
+
{queueGroupLabel(selectedGroup)}
target {selectedGroup.targetBranch ?? "main"} · {selectedGroup.members.length} lanes · {queueStats.landed} landed / {queueStats.pending} pending / {queueStats.failed} blocked
@@ -939,71 +866,9 @@ export function QueueTab({ {laneColorById.get(member.laneId) ? : null} {member.laneName} - - - - - + {marker ? : null} {rebaseNeed ? : null} - {(() => { - const conv = convergenceByPrId[member.prId] ?? null; - if (!conv || !conv.autoConvergeEnabled) return null; - const runtimeStatus = conv.status; - const isActive = - runtimeStatus === "launching" || - runtimeStatus === "running" || - runtimeStatus === "polling" || - runtimeStatus === "paused" || - runtimeStatus === "converged"; - if (!isActive) return null; - const round = conv.currentRound; - const isForceFinalize = conv.pauseReason === "force-finalize"; - if (isForceFinalize) { - return ( - - - - - - ); - } - const waitLabel = describeConvergenceWaitState(conv); - const label = round > 0 - ? `PtM iter ${pad2(round)} · ${waitLabel}` - : `PtM · ${waitLabel}`; - return ( - - - - - - ); - })()}
{member.pr?.githubPrNumber != null ? `#${member.pr.githubPrNumber}` : "No PR"} · {member.pr?.title ?? "PR metadata unavailable"} @@ -1321,35 +1186,6 @@ export function QueueTab({ {queueActionBusy === "cancel" ? "Canceling..." : "Cancel queue"} ) : null} - - -
@@ -1753,51 +1589,12 @@ export function QueueTab({ }, }; - // Members eligible for the Automate Merging modal: every member that hasn't - // already landed/skipped, in queue order. Skipping landed entries keeps the - // sequence focused on what's left to ship. - const automateMembers: QueueAutomateMergingMember[] = React.useMemo(() => { - if (!selectedGroup) return []; - return selectedGroup.members - .filter((member) => { - const entry = queueEntryByPrId.get(member.prId) ?? null; - if (entry && (entry.state === "landed" || entry.state === "skipped")) return false; - if (member.pr?.state === "merged" || member.pr?.state === "closed") return false; - return true; - }) - .map((member) => ({ - prId: member.prId, - prNumber: member.pr?.githubPrNumber ?? null, - laneId: member.laneId, - laneName: member.laneName, - laneColor: laneColorById.get(member.laneId) ?? null, - position: member.position, - })); - }, [laneColorById, queueEntryByPrId, selectedGroup]); - return ( - <> - - {selectedGroup ? ( - setResolverReasoningLevel(value || "medium")} - onPermissionModeChange={(mode) => setResolverPermissionMode(mode)} - onClose={() => setAutomateModalOpen(false)} - /> - ) : null} - + ); } diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 02544091a..91c995d13 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -449,7 +449,6 @@ export const IPC = { prsDelete: "ade.prs.delete", prsLand: "ade.prs.land", prsLandStack: "ade.prs.landStack", - prsRetargetBase: "ade.prs.retargetBase", prsDraftDescription: "ade.prs.draftDescription", prsOpenInGitHub: "ade.prs.openInGitHub", prsCreateIntegration: "ade.prs.createIntegration", @@ -517,8 +516,6 @@ export const IPC = { prsConvergenceStateGet: "ade.prs.convergenceState.get", prsConvergenceStateSave: "ade.prs.convergenceState.save", prsConvergenceStateDelete: "ade.prs.convergenceState.delete", - prsPathToMergeStart: "ade.prs.pathToMerge.start", - prsPathToMergeStop: "ade.prs.pathToMerge.stop", prsPipelineSettingsGet: "ade.prs.pipelineSettings.get", prsPipelineSettingsSave: "ade.prs.pipelineSettings.save", prsPipelineSettingsDelete: "ade.prs.pipelineSettings.delete", diff --git a/apps/desktop/src/shared/types/prs.ts b/apps/desktop/src/shared/types/prs.ts index 9ca9b8580..bac0f6e3c 100644 --- a/apps/desktop/src/shared/types/prs.ts +++ b/apps/desktop/src/shared/types/prs.ts @@ -770,123 +770,56 @@ export type QueueWaitReason = | "manual" | "canceled"; -/** - * Stack-wide config that the new Queue applies to every PR in the stack via - * the convergence engine. - * - * The legacy fields (`autoResolve`, `resolverProvider`, `resolverModel`, - * `reasoningEffort`, `permissionMode`, `confidenceThreshold`) are retained as - * deprecated mirrors so existing call sites (iOS sync, RPC layer, older UI) - * keep compiling while consumers migrate. The new authoritative source is the - * embedded {@link PipelineSettings} on `pipeline`. A queue with - * `pipeline.conflictStrategy === "auto"` reproduces the legacy auto-resolve - * behavior — plus the full PtM loop on each PR. - */ export type QueueAutomationConfig = { method: MergeMethod; archiveLane: boolean; - /** Whether to pause the stack land when CI is failing or reviews are pending. */ - ciGating: boolean; - /** Stack-wide PtM/convergence settings applied to every queued PR. */ - pipeline: PipelineSettings; - /** Origin surface (telemetry/attribution) for the auto-resolver agent. */ - originSurface: ConflictResolverOriginSurface; - originMissionId: string | null; - originRunId: string | null; - originLabel: string | null; - /** @deprecated Mirrors `pipeline.conflictStrategy === "auto"`. */ autoResolve: boolean; - /** @deprecated Mirrors `pipeline.autoAgentSettings.provider`. */ + ciGating: boolean; resolverProvider: ExternalConflictResolverProvider | null; - /** @deprecated Mirrors `pipeline.autoAgentSettings.model`. */ resolverModel: string | null; - /** @deprecated Mirrors `pipeline.autoAgentSettings.reasoningEffort`. */ reasoningEffort: string | null; - /** @deprecated Mirrors `pipeline.autoAgentSettings.permissionMode`. */ permissionMode: ConflictResolverPermissionMode | null; - /** @deprecated Mirrors `pipeline.autoAgentSettings.confidenceThreshold`. */ confidenceThreshold: number | null; + originSurface: ConflictResolverOriginSurface; + originMissionId: string | null; + originRunId: string | null; + originLabel: string | null; }; export type StartQueueAutomationArgs = { groupId: string; method: MergeMethod; archiveLane?: boolean; - ciGating?: boolean; - pipeline?: PipelineSettings; - originSurface?: ConflictResolverOriginSurface; - originMissionId?: string | null; - originRunId?: string | null; - originLabel?: string | null; - /** @deprecated Use `pipeline.conflictStrategy = "auto"` instead. */ autoResolve?: boolean; - /** @deprecated Use `pipeline.autoAgentSettings.provider`. */ + ciGating?: boolean; resolverProvider?: ExternalConflictResolverProvider | null; - /** @deprecated Use `pipeline.autoAgentSettings.model`. */ resolverModel?: string | null; - /** @deprecated Use `pipeline.autoAgentSettings.reasoningEffort`. */ reasoningEffort?: string | null; - /** @deprecated Use `pipeline.autoAgentSettings.permissionMode`. */ permissionMode?: ConflictResolverPermissionMode | null; - /** @deprecated Use `pipeline.autoAgentSettings.confidenceThreshold`. */ confidenceThreshold?: number | null; + originSurface?: ConflictResolverOriginSurface; + originMissionId?: string | null; + originRunId?: string | null; + originLabel?: string | null; }; export type ResumeQueueAutomationArgs = { queueId: string; method?: MergeMethod; archiveLane?: boolean; - ciGating?: boolean; - pipeline?: PipelineSettings; - originSurface?: ConflictResolverOriginSurface; - originMissionId?: string | null; - originRunId?: string | null; - originLabel?: string | null; - /** @deprecated Use `pipeline.conflictStrategy = "auto"` instead. */ autoResolve?: boolean; - /** @deprecated Use `pipeline.autoAgentSettings.provider`. */ + ciGating?: boolean; resolverProvider?: ExternalConflictResolverProvider | null; - /** @deprecated Use `pipeline.autoAgentSettings.model`. */ resolverModel?: string | null; - /** @deprecated Use `pipeline.autoAgentSettings.reasoningEffort`. */ reasoningEffort?: string | null; - /** @deprecated Use `pipeline.autoAgentSettings.permissionMode`. */ permissionMode?: ConflictResolverPermissionMode | null; - /** @deprecated Use `pipeline.autoAgentSettings.confidenceThreshold`. */ confidenceThreshold?: number | null; + originSurface?: ConflictResolverOriginSurface; + originMissionId?: string | null; + originRunId?: string | null; + originLabel?: string | null; }; -/** - * Builds a {@link PipelineSettings} from the legacy auto-resolve fields on - * a {@link QueueAutomationConfig} (or its Args variants). Used by the runtime - * when an older caller hasn't supplied an explicit `pipeline`. - */ -export function pipelineFromLegacyQueueConfig( - legacy: Partial<{ - autoResolve: boolean | null | undefined; - resolverProvider: ExternalConflictResolverProvider | null | undefined; - resolverModel: string | null | undefined; - reasoningEffort: string | null | undefined; - permissionMode: ConflictResolverPermissionMode | null | undefined; - confidenceThreshold: number | null | undefined; - }>, -): PipelineSettings { - const isAuto = legacy.autoResolve === true; - const provider = legacy.resolverProvider ?? null; - const agentProvider: AutoConflictAgentProvider | null = provider === "claude" || provider === "codex" ? provider : null; - return { - ...DEFAULT_PIPELINE_SETTINGS, - conflictStrategy: isAuto ? "auto" : "pause", - autoAgentSettings: { - provider: agentProvider, - model: legacy.resolverModel ?? null, - reasoningEffort: legacy.reasoningEffort ?? null, - permissionMode: legacy.permissionMode ?? null, - confidenceThreshold: legacy.confidenceThreshold ?? null, - }, - }; -} - export type PauseQueueAutomationArgs = { queueId: string; }; @@ -899,11 +832,8 @@ export type LandQueueNextArgs = { groupId: string; method: MergeMethod; archiveLane?: boolean; - /** - * Optional one-shot pipeline override for the single PR being landed. When - * omitted, the queue's stack-wide pipeline config is used. - */ - pipeline?: PipelineSettings; + autoResolve?: boolean; + confidenceThreshold?: number; }; export type ReorderQueuePrsArgs = { @@ -1186,93 +1116,13 @@ export type AiReviewSummary = { /** Merge method for the auto-merge pipeline — extends MergeMethod with repo_default. */ export type PipelineMergeMethod = MergeMethod | "repo_default"; -/** - * Legacy two-option rebase policy. Retained for back-compat reads from older - * `pr_pipeline_settings` rows; new writes use {@link ConflictStrategy}. - */ export type RebasePolicy = "pause" | "auto_rebase"; -/** - * What the convergence loop does when the PR's base branch advances or a merge - * conflict surfaces. - * - * - `pause` — stop the loop and surface the conflict to the operator - * - `rebase` — `git rebase origin/`, force-push with `--force-with-lease` - * - `merge` — merge `origin/` into the PR branch, push the merge commit - * - `auto` — let the conflict-resolver agent decide rebase vs merge based on - * context, then resolve any resulting conflict markers itself - */ -export type ConflictStrategy = "pause" | "rebase" | "merge" | "auto"; - -/** - * Behavior of the bonus iteration that runs after `hardCapIterations` normal - * iterations have failed to land the PR. Mirrors `/shipLane`'s force-finalize. - * - * - `off` — never force-merge; if hard cap hits, fail the convergence - * - `unconditional` — always force-merge once cap is hit, ignoring review/CI - * - `conditional` — force-merge only if extra predicates pass (see - * {@link PipelineSettings.forceFinalizeRequireNoCiFailures}) - */ -export type ForceFinalizeMode = "off" | "unconditional" | "conditional"; - -/** - * Provider used by the {@link ConflictStrategy} `auto` agent and (legacy) by - * the queue's standalone `autoResolve` flow. Mirrors - * {@link ExternalConflictResolverProvider} but lives on PipelineSettings so - * each PR's convergence can target its own provider. - */ -export type AutoConflictAgentProvider = "claude" | "codex"; - -export type AutoConflictAgentSettings = { - provider: AutoConflictAgentProvider | null; - /** Fully-qualified model id (e.g. `anthropic/claude-3-5-sonnet`). `null` = provider default. */ - model: string | null; - /** Reasoning token budget hint (provider-specific string). */ - reasoningEffort: string | null; - /** Permission mode the resolver chat runs under. */ - permissionMode: ConflictResolverPermissionMode | null; - /** Minimum confidence (0–1) the resolver must report before its fix is accepted. `null` = accept all. */ - confidenceThreshold: number | null; -}; - -export const DEFAULT_AUTO_CONFLICT_AGENT_SETTINGS: AutoConflictAgentSettings = { - provider: null, - model: null, - reasoningEffort: null, - permissionMode: null, - confidenceThreshold: null, -}; - export type PipelineSettings = { - /** When true, PtM merges the PR as soon as it converges (or hits the early-green gate). */ autoMerge: boolean; mergeMethod: PipelineMergeMethod; - /** - * Hard cap on normal iterations before the loop either gives up or runs the - * force-finalize bonus iteration (per {@link forceFinalizeMode}). Same as the - * legacy `maxRounds` semantically — kept under that name for back-compat. - */ maxRounds: number; - /** @deprecated Read-only mirror of the legacy two-option rebase policy. New code reads `conflictStrategy`. */ onRebaseNeeded: RebasePolicy; - /** Strategy for both base-advance sync (between iterations) and merge-time conflicts. */ - conflictStrategy: ConflictStrategy; - /** Tunables used when {@link conflictStrategy} is `auto`. */ - autoAgentSettings: AutoConflictAgentSettings; - /** Whether the loop runs a bonus force-finalize iteration after the hard cap. */ - forceFinalizeMode: ForceFinalizeMode; - /** - * When {@link forceFinalizeMode} is `conditional`, the bonus iteration only - * fires if no required CI checks are currently failing. Other future - * predicates can be added alongside this flag. - */ - forceFinalizeRequireNoCiFailures: boolean; - /** - * If true (default), every iteration first checks whether checks are green - * and reviews are clean — if so, the merge ladder runs immediately instead - * of dispatching another fix round. - */ - earlyMergeOnGreen: boolean; }; export const DEFAULT_PIPELINE_SETTINGS: PipelineSettings = { @@ -1280,31 +1130,8 @@ export const DEFAULT_PIPELINE_SETTINGS: PipelineSettings = { mergeMethod: "repo_default", maxRounds: 5, onRebaseNeeded: "pause", - conflictStrategy: "pause", - autoAgentSettings: { ...DEFAULT_AUTO_CONFLICT_AGENT_SETTINGS }, - forceFinalizeMode: "off", - forceFinalizeRequireNoCiFailures: true, - earlyMergeOnGreen: true, }; -/** - * Maps the legacy {@link RebasePolicy} (`pause` | `auto_rebase`) to the new - * 4-option {@link ConflictStrategy}. Used when reading older settings rows. - */ -export function conflictStrategyFromLegacyRebasePolicy(policy: RebasePolicy): ConflictStrategy { - return policy === "auto_rebase" ? "rebase" : "pause"; -} - -/** - * Inverse of {@link conflictStrategyFromLegacyRebasePolicy}: lossy projection - * of the new strategy onto the legacy two-option field, so old code paths that - * still read `onRebaseNeeded` keep working. `merge` and `auto` both project to - * `auto_rebase` because they imply automatic conflict handling. - */ -export function legacyRebasePolicyFromConflictStrategy(strategy: ConflictStrategy): RebasePolicy { - return strategy === "pause" ? "pause" : "auto_rebase"; -} - // -------------------------------- // PR Convergence Runtime State // -------------------------------- diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 8159824dd..96d02386e 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -678,8 +678,6 @@ export type SyncRemoteCommandAction = | "prs.pipelineSettings.get" | "prs.pipelineSettings.save" | "prs.pipelineSettings.delete" - | "prs.pathToMerge.start" - | "prs.pathToMerge.stop" | "prs.getMobileSnapshot"; export type SyncRemoteCommandPolicy = { diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 027c15e0b..1812ff6b6 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -3060,76 +3060,11 @@ struct PrWorkflowCard: Codable, Identifiable, Equatable { } } -/// Tunables used by the conflict-resolver agent when -/// ``PipelineSettings.conflictStrategy`` is `auto`. Mirrors desktop's -/// `AutoConflictAgentSettings`. All fields are optional so older payloads -/// that don't carry them still decode cleanly. -struct AutoConflictAgentSettings: Codable, Equatable { - var provider: String? - var model: String? - var reasoningEffort: String? - var permissionMode: String? - var confidenceThreshold: Double? -} - -/// Stack-wide pipeline settings for a PR's path-to-merge convergence loop. -/// -/// The five fields originally exposed on iOS (`autoMerge`, `mergeMethod`, -/// `maxRounds`, `onRebaseNeeded`) are still required for back-compat with -/// older sync payloads. The newer fields added by the Path-to-Merge feature -/// (`conflictStrategy`, `autoAgentSettings`, `forceFinalizeMode`, -/// `forceFinalizeRequireNoCiFailures`, `earlyMergeOnGreen`) are optional — -/// when missing, a synthesized value is filled in from `onRebaseNeeded` so -/// existing read paths keep working. struct PipelineSettings: Codable, Equatable { var autoMerge: Bool var mergeMethod: String var maxRounds: Int var onRebaseNeeded: String - var conflictStrategy: String? - var autoAgentSettings: AutoConflictAgentSettings? - var forceFinalizeMode: String? - var forceFinalizeRequireNoCiFailures: Bool? - var earlyMergeOnGreen: Bool? - - init( - autoMerge: Bool, - mergeMethod: String, - maxRounds: Int, - onRebaseNeeded: String, - conflictStrategy: String? = nil, - autoAgentSettings: AutoConflictAgentSettings? = nil, - forceFinalizeMode: String? = nil, - forceFinalizeRequireNoCiFailures: Bool? = nil, - earlyMergeOnGreen: Bool? = nil - ) { - self.autoMerge = autoMerge - self.mergeMethod = mergeMethod - self.maxRounds = maxRounds - self.onRebaseNeeded = onRebaseNeeded - self.conflictStrategy = conflictStrategy - self.autoAgentSettings = autoAgentSettings - self.forceFinalizeMode = forceFinalizeMode - self.forceFinalizeRequireNoCiFailures = forceFinalizeRequireNoCiFailures - self.earlyMergeOnGreen = earlyMergeOnGreen - } -} - -/// Result envelope returned by `prs.pathToMerge.start`. `runtime` mirrors the -/// updated convergence runtime row that the host just wrote, so callers can -/// refresh local UI without an extra round-trip. -struct StartPathToMergeResult: Codable, Equatable { - var prId: String - var scheduled: Bool - var runtime: ConvergenceRuntimeState -} - -/// Result envelope returned by `prs.pathToMerge.stop`. `runtime` may be `nil` -/// when no runtime row existed for the PR (already-stopped no-op). -struct StopPathToMergeResult: Codable, Equatable { - var prId: String - var stopped: Bool - var runtime: ConvergenceRuntimeState? } struct ConvergenceRoundStat: Codable, Identifiable, Equatable { diff --git a/apps/ios/ADE/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index 7e0373320..ac576aea6 100644 --- a/apps/ios/ADE/Resources/DatabaseBootstrap.sql +++ b/apps/ios/ADE/Resources/DatabaseBootstrap.sql @@ -708,10 +708,6 @@ alter table queue_landing_state add column wait_reason text; alter table queue_landing_state add column updated_at text; -delete from queue_landing_state; - -insert into kv (key, value) values (?, ?) on conflict(key) do update set value = excluded.value; - create table if not exists rebase_dismissed ( lane_id text not null, project_id text not null, @@ -2585,24 +2581,6 @@ create table if not exists pr_pipeline_settings ( foreign key(pr_id) references pull_requests(id) on delete cascade ); -alter table pr_pipeline_settings add column conflict_strategy text not null default 'pause'; - -alter table pr_pipeline_settings add column force_finalize_mode text not null default 'off'; - -alter table pr_pipeline_settings add column force_finalize_require_no_ci_failures integer not null default 1; - -alter table pr_pipeline_settings add column early_merge_on_green integer not null default 1; - -alter table pr_pipeline_settings add column auto_agent_provider text; - -alter table pr_pipeline_settings add column auto_agent_model text; - -alter table pr_pipeline_settings add column auto_agent_reasoning_effort text; - -alter table pr_pipeline_settings add column auto_agent_permission_mode text; - -alter table pr_pipeline_settings add column auto_agent_confidence_threshold real; - create table if not exists pr_convergence_state ( pr_id text primary key, auto_converge_enabled integer not null default 0, @@ -2622,5 +2600,3 @@ create table if not exists pr_convergence_state ( updated_at text not null, foreign key(pr_id) references pull_requests(id) on delete cascade ); - -alter table pr_convergence_state add column ptm_args_json text; diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 2cde03b3a..b7e6d140e 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -3968,57 +3968,6 @@ final class SyncService: ObservableObject { _ = try await sendCommand(action: "prs.pipelineSettings.delete", args: ["prId": prId]) } - /// Start the desktop's Path-to-Merge convergence loop for ``prId``. The host - /// schedules the first iteration (or a noop refresh if it's already - /// running) and returns the persisted runtime row, which the caller can use - /// to refresh its local convergence panel without a follow-up fetch. - /// - /// - Parameters: - /// - prId: PR identifier from the desktop catalog. - /// - modelId: optional model id forwarded to the fix agent. `nil` keeps - /// the host's default. - /// - reasoning: optional reasoning-effort hint for providers that accept - /// one (e.g. Anthropic's "extended", OpenAI's "high"). - /// - scope: which input the iteration's fix agent should consider — - /// `"checks"`, `"comments"`, or `"both"`. `nil` defers to the host - /// default. - /// - additionalInstructions: free-form text appended to each iteration - /// prompt. - @discardableResult - func startPathToMerge( - prId: String, - modelId: String? = nil, - reasoning: String? = nil, - scope: String? = nil, - additionalInstructions: String? = nil - ) async throws -> StartPathToMergeResult { - var payload: [String: Any] = ["prId": prId] - if let modelId, !modelId.isEmpty { payload["modelId"] = modelId } - if let reasoning, !reasoning.isEmpty { payload["reasoning"] = reasoning } - if let scope, !scope.isEmpty { payload["scope"] = scope } - if let additionalInstructions, !additionalInstructions.isEmpty { - payload["additionalInstructions"] = additionalInstructions - } - return try await sendDecodableCommand( - action: "prs.pathToMerge.start", - args: payload, - as: StartPathToMergeResult.self - ) - } - - /// Stop a running Path-to-Merge loop for ``prId``. ``reason`` is recorded on - /// the runtime row so the convergence panel can surface why the loop ended. - @discardableResult - func stopPathToMerge(prId: String, reason: String? = nil) async throws -> StopPathToMergeResult { - var payload: [String: Any] = ["prId": prId] - if let reason, !reason.isEmpty { payload["reason"] = reason } - return try await sendDecodableCommand( - action: "prs.pathToMerge.stop", - args: payload, - as: StopPathToMergeResult.self - ) - } - @discardableResult func createQueuePrs( laneIds: [String], diff --git a/apps/ios/ADE/Views/PRs/PrDetailOverviewTab.swift b/apps/ios/ADE/Views/PRs/PrDetailOverviewTab.swift index f8d2d9819..421440ecc 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailOverviewTab.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailOverviewTab.swift @@ -556,11 +556,6 @@ struct PrPathToMergeTab: View { let onCopyPrompt: () -> Void let onLaunchAiResolver: () -> Void let onStopAiResolver: () -> Void - /// True while a `prs.pathToMerge.start` or `prs.pathToMerge.stop` round-trip - /// is in flight. Prevents double-taps on the convergence toggle. - let isPathToMergeBusy: Bool - let onStartPathToMerge: () -> Void - let onStopPathToMerge: () -> Void @State private var pipelineExpanded = false @@ -590,10 +585,7 @@ struct PrPathToMergeTab: View { onSetPipelineRebasePolicy: @escaping (String) -> Void, onCopyPrompt: @escaping () -> Void = {}, onLaunchAiResolver: @escaping () -> Void = {}, - onStopAiResolver: @escaping () -> Void = {}, - isPathToMergeBusy: Bool = false, - onStartPathToMerge: @escaping () -> Void = {}, - onStopPathToMerge: @escaping () -> Void = {} + onStopAiResolver: @escaping () -> Void = {} ) { self.pr = pr self.snapshot = snapshot @@ -621,9 +613,6 @@ struct PrPathToMergeTab: View { self.onCopyPrompt = onCopyPrompt self.onLaunchAiResolver = onLaunchAiResolver self.onStopAiResolver = onStopAiResolver - self.isPathToMergeBusy = isPathToMergeBusy - self.onStartPathToMerge = onStartPathToMerge - self.onStopPathToMerge = onStopPathToMerge } private var unresolvedThreadCount: Int { @@ -700,127 +689,53 @@ struct PrPathToMergeTab: View { // MARK: - Mode strip (Manual / Auto-Converge) - /// Interactive mode toggle paired with a Start/Stop control. The two pills - /// are now real buttons — tapping `Auto-Converge` issues `prs.pathToMerge.start`, - /// tapping `Manual` issues `prs.pathToMerge.stop`. Round + status pill appear - /// when in auto mode, mirroring desktop's PtM panel. - /// - /// Accessibility: - /// - Each pill carries a descriptive `accessibilityLabel` and an - /// `accessibilityValue` of "selected" / "not selected". - /// - The hit target is `44 × 44` pt minimum via `.contentShape` + frame. - /// - When `!isLive` or `isPathToMergeBusy`, the strip dims and taps are - /// ignored; the second row surfaces a contextual reason. + /// Read-only mirror of the desktop's mode toggle. The host doesn't expose a + /// mobile setter for `auto_converge_enabled`, so we surface state without + /// trying to write it. Round + status pill appear when in auto mode, exactly + /// like desktop. @ViewBuilder private var modeStrip: some View { let convergence = issueInventory?.convergence let runtimeStatus = issueInventory?.runtime.status.replacingOccurrences(of: "_", with: " ") ?? "idle" - let canToggle = isLive && !isPathToMergeBusy - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - HStack(spacing: 0) { - modePillButton( - label: "Manual", - isActive: !isAutoConverge, - accessibilityHint: "Stop path-to-merge convergence and switch to manual mode." - ) { - guard canToggle, isAutoConverge else { return } - ADEHaptics.light() - onStopPathToMerge() - } - modePillButton( - label: "Auto-Converge", - isActive: isAutoConverge, - accessibilityHint: "Start path-to-merge convergence on this pull request." - ) { - guard canToggle, !isAutoConverge else { return } - ADEHaptics.success() - onStartPathToMerge() - } - } - .padding(2) - .background( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(Color.white.opacity(0.04)) - ) - .overlay( - RoundedRectangle(cornerRadius: 9, style: .continuous) - .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5) - ) - .opacity(canToggle ? 1 : 0.55) - - Spacer(minLength: 6) - - if isPathToMergeBusy { - ProgressView().controlSize(.mini).tint(ADEColor.textSecondary) - } else if isAutoConverge, let convergence { - Text("Round \(convergence.currentRound)/\(convergence.maxRounds)") - .font(.system(size: 10.5, weight: .semibold, design: .monospaced)) - .foregroundStyle(ADEColor.textSecondary) - .accessibilityLabel("Convergence round \(convergence.currentRound) of \(convergence.maxRounds).") - ConvergenceStatusPill(status: runtimeStatus) - } + HStack(spacing: 8) { + HStack(spacing: 0) { + modePill(label: "Manual", isActive: !isAutoConverge) + modePill(label: "Auto-Converge", isActive: isAutoConverge) } + .padding(2) + .background( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(Color.white.opacity(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5) + ) - if let footnote = autoConvergeFootnote { - Text(footnote) - .font(.footnote) - .foregroundStyle(ADEColor.textMuted) - .fixedSize(horizontal: false, vertical: true) - .accessibilityLabel(footnote) - } - } - } + Spacer(minLength: 6) - /// Optional one-line explanation rendered below the mode strip. Surfaces - /// pause/error reasons or the offline-disabled state so users understand - /// why a tap had no effect. - private var autoConvergeFootnote: String? { - if !isLive { - return "Reconnect to control Path to Merge." - } - if isPathToMergeBusy { - return isAutoConverge ? "Stopping convergence…" : "Starting convergence…" - } - let runtime = issueInventory?.runtime - if let reason = runtime?.pauseReason, !reason.isEmpty { - return "Paused: \(reason)" - } - if let err = runtime?.errorMessage, !err.isEmpty { - return err + if isAutoConverge, let convergence { + Text("Round \(convergence.currentRound)/\(convergence.maxRounds)") + .font(.system(size: 10.5, weight: .semibold, design: .monospaced)) + .foregroundStyle(ADEColor.textSecondary) + ConvergenceStatusPill(status: runtimeStatus) + } } - return nil } - /// Tap-target wrapper for the mode toggle pills. Plain Button keeps the - /// custom shape but supplies VoiceOver semantics + 44pt hit area. - private func modePillButton( - label: String, - isActive: Bool, - accessibilityHint: String, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - Text(label) - .font(.system(size: 10.5, weight: isActive ? .bold : .medium)) - .tracking(0.4) - .foregroundStyle(isActive ? Color(red: 0x10/255, green: 0x0D/255, blue: 0x14/255) : ADEColor.textSecondary) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .frame(minHeight: 32) - .background { - if isActive { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(ADEColor.tintPRs) - } + private func modePill(label: String, isActive: Bool) -> some View { + Text(label) + .font(.system(size: 10.5, weight: isActive ? .bold : .medium)) + .tracking(0.4) + .foregroundStyle(isActive ? Color(red: 0x10/255, green: 0x0D/255, blue: 0x14/255) : ADEColor.textSecondary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background { + if isActive { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(ADEColor.tintPRs) } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .frame(minWidth: 44, minHeight: 44, alignment: .center) - .accessibilityLabel("\(label) mode") - .accessibilityValue(isActive ? "selected" : "not selected") - .accessibilityHint(accessibilityHint) + } } // MARK: - Counter strip (NEW / FIXED / DISMISSED / ESCALATED) diff --git a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift index 4ab5c1eb4..916ae1337 100644 --- a/apps/ios/ADE/Views/PRs/PrDetailScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrDetailScreen.swift @@ -35,9 +35,6 @@ struct PrDetailView: View { @State private var aiResolution: AiResolutionState? @State private var isAiResolverBusy: Bool = false @State private var aiResolverSheetPresented: Bool = false - /// True while a `prs.pathToMerge.start` or `prs.pathToMerge.stop` round-trip - /// is in flight. Used to disable the convergence toggle and show a spinner. - @State private var isPathToMergeBusy: Bool = false private var prsStatus: SyncDomainStatus { syncService.status(for: .prs) @@ -320,10 +317,7 @@ struct PrDetailView: View { onSetPipelineRebasePolicy: setPipelineRebasePolicy, onCopyPrompt: copyConvergencePrompt, onLaunchAiResolver: { aiResolverSheetPresented = true }, - onStopAiResolver: stopAiResolver, - isPathToMergeBusy: isPathToMergeBusy, - onStartPathToMerge: startPathToMerge, - onStopPathToMerge: stopPathToMerge + onStopAiResolver: stopAiResolver ) .prListRow() case .files: @@ -1023,57 +1017,6 @@ struct PrDetailView: View { } } - /// Kick the desktop's Path-to-Merge convergence loop on this PR. The host - /// returns the updated runtime row, which we splice into the local issue - /// inventory snapshot so the convergence panel reflects the new state - /// without waiting for the next sync push. - private func startPathToMerge() { - guard !isPathToMergeBusy else { return } - Task { @MainActor in - isPathToMergeBusy = true - defer { isPathToMergeBusy = false } - do { - let result = try await syncService.startPathToMerge(prId: prId) - applyConvergenceRuntime(result.runtime) - actionMessage = "Path to Merge started." - } catch { - errorMessage = error.localizedDescription - } - } - } - - /// Stop the convergence loop. Mirrors {@link startPathToMerge} but updates - /// the runtime to the host-returned snapshot (when present) so the UI - /// reflects the canonical post-stop state, including any pause reason. - private func stopPathToMerge() { - guard !isPathToMergeBusy else { return } - Task { @MainActor in - isPathToMergeBusy = true - defer { isPathToMergeBusy = false } - do { - let result = try await syncService.stopPathToMerge(prId: prId, reason: "Stopped from iOS.") - if let runtime = result.runtime { - applyConvergenceRuntime(runtime) - } - actionMessage = "Path to Merge stopped." - } catch { - errorMessage = error.localizedDescription - } - } - } - - /// Replace the runtime row inside `issueInventory` (if any) with `next`. - /// Used by the start/stop handlers so the mode toggle flips immediately. - private func applyConvergenceRuntime(_ next: ConvergenceRuntimeState) { - guard let current = issueInventory else { return } - issueInventory = IssueInventorySnapshot( - prId: current.prId, - items: current.items, - convergence: current.convergence, - runtime: next - ) - } - private func refreshAiSummary() { Task { @MainActor in guard canRunPrActions else { return } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 383d857d8..7d4959a98 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -364,10 +364,7 @@ ade.files.* # file tree, read, write, search, watch ade.pty.* # PTY spawn/write/kill, data/exit events ade.git.* # stage/commit/push/sync/revert/cherry-pick/stash ade.github.* # PR list, review, merge, checks -ade.prs.* # stacked PR queue, integration, issue inventory, - # Path-to-Merge orchestrator (ade.prs.pathToMerge.start / - # ade.prs.pathToMerge.stop) and ade.prs.retargetBase used - # by the queue Automate Merging modal +ade.prs.* # stacked PR queue, integration, issue inventory ade.conflicts.* # risk matrix, simulation, proposals ade.memory.* # memory CRUD, search, health, embeddings ade.missions.* / ade.orchestrator.* diff --git a/docs/features/pull-requests/README.md b/docs/features/pull-requests/README.md index 853f53e57..db4e7508a 100644 --- a/docs/features/pull-requests/README.md +++ b/docs/features/pull-requests/README.md @@ -11,7 +11,6 @@ This folder documents: - [`stacking.md`](./stacking.md) — stacked PR chains, rebase ordering, queue-aware rebase targeting. - [`queue.md`](./queue.md) — PR merge queue model and landing state machine. - [`conflict-simulation.md`](./conflict-simulation.md) — how ADE predicts PR merge conflicts before the user hits Merge. -- [`path-to-merge.md`](./path-to-merge.md) — the Path-to-Merge orchestrator: phase delays, terminal-state gate, conflict strategy switch, force-finalize, merge ladder, and Queue Automate Merging. ## Source file map @@ -25,10 +24,9 @@ Main-process services (`apps/desktop/src/main/services/prs/`): | `prPollingService.ts` | 60 s polling loop, fingerprint-based change detection, notification emission. Writes `last_polled_at` per PR so callers can run delta polls on the next tick | | `prSummaryService.ts` | AI PR summary generator; caches `PrAiSummary` per `(prId, headSha)` in `pull_request_ai_summaries` so pushes invalidate the cache | | `queueLandingService.ts` | Merge queue state machine (`ALLOWED_TRANSITIONS`), landing loop, auto-resolve on conflicts | -| `pathToMergeOrchestrator.ts` | Path-to-Merge orchestrator: phase-aware setTimeout wake-ups, combined CI + review terminal-state gate, 4-option conflict strategy, force-finalize bonus iteration, REST → `gh --admin` → `gh --auto` merge ladder, persistent resume across restarts. Native port of the `/shipLane` Claude skill. See [`path-to-merge.md`](./path-to-merge.md). | | `integrationPlanning.ts` | `buildIntegrationPreflight` — validates source lanes for an integration proposal | | `integrationValidation.ts` | `parseGitStatusPorcelain`, `hasMergeConflictMarkers` — shared helpers for integration flows | -| `issueInventoryService.ts` | Typed issue inventory, per-round convergence status, participant classification, thread re-open logic. `IssueInventoryItem` carries `type` (`review_thread | check_failure | issue_comment`), `externalId`, `body`, `author`, and `threadCommentCount` / `threadLatestCommentAuthor` / `threadLatestCommentAt` so `PrConvergencePanel` can render expandable rows with the full comment context inline. Also persists pipeline settings (incl. the new `conflictStrategy`, `forceFinalizeMode`, `earlyMergeOnGreen`, `autoAgentSettings`) and the orchestrator's serialized run args (`pr_convergence_state.ptm_args_json`) via `savePathToMergeArgs` / `getPathToMergeArgs`. | +| `issueInventoryService.ts` | Typed issue inventory, per-round convergence status, participant classification, thread re-open logic. `IssueInventoryItem` carries `type` (`review_thread | check_failure | issue_comment`), `externalId`, `body`, `author`, and `threadCommentCount` / `threadLatestCommentAuthor` / `threadLatestCommentAt` so `PrConvergencePanel` can render expandable rows with the full comment context inline. | | `prIssueResolver.ts` | Builds issue-resolution prompts for the agent, launches chat session | | `prRebaseResolver.ts` | Builds rebase-resolution prompts, launches chat session | | `resolverUtils.ts` | Shared permission-mode mapping, recent commit reading, comment noise filter, and the `looksLikeResolutionAck` heuristic that flags resolved-looking replies on unresolved review threads | @@ -43,13 +41,12 @@ Renderer components (`apps/desktop/src/renderer/components/prs/`): | `CreatePrModal.tsx` | Draft/queue/integration PR creation with lane warnings, branch name validation | | `tabs/NormalTab.tsx` | Normal PR list | | `tabs/GitHubTab.tsx` | Unified repo + external PR browser with label filters, CI badges, review indicators | -| `tabs/QueueTab.tsx` | Merge queue UI. Hosts the "Automate Merging" entry point that opens `QueueAutomateMergingModal` with the queue's eligible members (everything that has not landed yet). | -| `tabs/QueueAutomateMergingModal.tsx` | Stack-wide automation modal: edits one `PipelineSettings` config that applies to every queue member, then sequentially saves settings, calls `ade.prs.retargetBase` for non-leading members so each PR's base points at the queue's tracking branch, starts Path-to-Merge via `ade.prs.pathToMerge.start`, and polls `convergenceStateGet` every 4 s until the runtime status is terminal. Halts the sequence on the first `failed | cancelled | stopped`. Closing mid-sequence stops dispatching new starts but leaves already-launched orchestrators running. | +| `tabs/QueueTab.tsx` | Merge queue UI | | `tabs/IntegrationTab.tsx` | Integration (merge-plan) proposals and execution, including merge-into-lane selection, apply-and-resimulate, and adopted-lane cleanup messaging | | `tabs/RebaseTab.tsx` | Lane rebase needs (base + queue + PR target) and attention items | | `tabs/WorkflowsTab.tsx` | Container for queue/integration/rebase sub-tabs | | `tabs/queueWorkflowModel.ts` | Pure model for queue tab rendering (active/history bucketing, guidance computation) | -| `detail/PrDetailPane.tsx` | Selected PR detail pane: status, checks, reviews, comments, merge readiness, bypass, Path-to-Merge convergence sub-tab (labelled "Path to Merge" in the tab list), resolver modals. Switches the Overview tab between the legacy grid and the Timeline+Rails layout based on `prsTimelineRailsEnabled`. Persists the selected sub-tab (`overview | convergence | files | checks | activity`) per PR in `localStorage` under `ade:prs:detailTabs:v1`, mirrored through the `detailTab` URL param so deep links restore the last-used tab | +| `detail/PrDetailPane.tsx` | Selected PR detail pane: status, checks, reviews, comments, merge readiness, bypass, convergence, resolver modals. Switches the Overview tab between the legacy grid and the Timeline+Rails layout based on `prsTimelineRailsEnabled`. Persists the selected sub-tab (`overview | convergence | files | checks | activity`) per PR in `localStorage` under `ade:prs:detailTabs:v1`, mirrored through the `detailTab` URL param so deep links restore the last-used tab | | `detail/PrDetailTimelineRails.tsx` | Timeline+Rails overview: merges timeline events, commit rail (seeded from both `PrActivityEvent.commit_push` entries and the `getCommits` snapshot), status rail, deployment cards, AI summary, and command-palette navigation (`g c` / `g t` / `g f` and `[` / `]`) | | `shared/PrTimeline.tsx` | Timeline column: synthesises `PrTimelineEvent`s from detail data, handles per-PR filters (`PrTimelineFilters`), renders grouped events | | `shared/PrCommitRail.tsx`, `shared/PrStatusRail.tsx` | Right-hand rails on the timeline view: commit list, checks/reviews summary, deployment chips | @@ -57,10 +54,10 @@ Renderer components (`apps/desktop/src/renderer/components/prs/`): | `shared/PrAiSummaryCard.tsx` | AI summary card above the timeline; dismissible per PR (state in `PrsContext.dismissedAiSummaries`), with a "Regenerate" action wired to `prSummaryService.regenerateSummary` | | `shared/PrReviewThreadCard.tsx`, `shared/PrBotReviewCard.tsx` | Rich thread cards for the timeline (bot-review collapse, reply box, resolve/react actions) | | `shared/PrDeploymentCard.tsx` | Deployment row used in the status rail and on the timeline | -| `shared/PrConvergencePanel.tsx` | Path-to-Merge slide-over panel with issue inventory, agent session embed, pipeline settings. Status copy uses "Path to Merge" verbatim (e.g. "Agent working on Path to Merge…", "Ready to launch another Path to Merge run"). Each issue row is expandable (caret toggles full comment body, author, and thread comment count); a "show ignored" toggle un-hides previously dismissed items. The dismiss button is labelled "Ignore comment" so users understand it removes the item from the round without resolving the thread. The waiting-state copy hides the round number when the panel runs in non-round-aware contexts (`showRoundLabels = false`). Terminal PRs render a frozen state with historical comments shown for reference only. | +| `shared/PrConvergencePanel.tsx` | Auto-converge slide-over panel with issue inventory, agent session embed, pipeline settings. Each issue row is expandable (caret toggles full comment body, author, and thread comment count); a "show ignored" toggle un-hides previously dismissed items. The dismiss button is labelled "Ignore comment" so users understand it removes the item from the round without resolving the thread. The waiting-state copy hides the round number when the panel runs in non-round-aware contexts (`showRoundLabels = false`). | | `shared/PrIssueResolverModal.tsx` | Launch issue resolution (checks/comments/both scopes) | | `shared/PrAiResolverPanel.tsx` | AI resolver launch controls in Rebase/Integration flows, including additional-instructions passthrough | -| `shared/PrPipelineSettings.tsx` | Per-PR pipeline settings editor used inside `PrConvergencePanel` and the queue Automate Merging modal. Surfaces the 4-option `conflictStrategy` selector, the `auto`-only `autoAgentSettings` group (provider / model / reasoning / permission mode / confidence threshold), the `forceFinalizeMode` selector with the conditional sub-toggle, the `earlyMergeOnGreen` switch, `autoMerge`, `mergeMethod`, and `maxRounds`. Renders a force-push warning when `conflictStrategy` is `rebase` or `auto`. | +| `shared/PrPipelineSettings.tsx` | Auto-converge pipeline settings per PR | | `shared/PrLaneCleanupBanner.tsx` | Post-merge cleanup banner on the PR detail. Also renders a dedicated "PR branch cleanup" variant when the PR is linked to the primary lane but its head branch differs — the primary lane is never deleted, but the user can still delete the local and/or remote PR branch after confirming `delete ` | | `shared/IntegrationPrContextPanel.tsx` | Integration PR context panel | | `shared/prVisuals.tsx` | CI running indicator, check/review badges, dot colors, activity derivation | @@ -132,9 +129,6 @@ Selected channels exposed through `preload.ts`: - `ade.prs.issueResolutionStart`, `ade.prs.issueResolutionPreview` - `ade.prs.rebaseResolutionStart` - `ade.prs.convergenceStateGet`, `ade.prs.convergenceStateSave`, `ade.prs.convergenceStateDelete` -- `ade.prs.pathToMerge.start`, `ade.prs.pathToMerge.stop` — drive the Path-to-Merge orchestrator (see [`path-to-merge.md`](./path-to-merge.md)) -- `ade.prs.retargetBase` — re-point a PR's base branch (used by Queue Automate Merging when stacking the chain bases before PtM picks them up) -- `ade.prs.pipelineSettingsGet`, `ade.prs.pipelineSettingsSave`, `ade.prs.pipelineSettingsDelete` - `ade.prs.getGitHubSnapshot` — merged repo + external PR snapshot - `ade.prs.simulateIntegration`, `ade.prs.createIntegrationLaneForProposal`, `ade.prs.commitIntegration`, `ade.prs.cleanupIntegrationWorkflow` @@ -398,31 +392,13 @@ type ConvergenceRuntimeState = { }; ``` -`PipelineSettings` (per PR) drives both the manual auto-converge panel -and the Path-to-Merge orchestrator: - -| Field | Purpose | -|-------|---------| -| `autoMerge` | When true, PtM lands the PR after convergence (or via the early-green gate). | -| `mergeMethod` | `repo_default | merge | squash | rebase`. PtM falls back to `squash` when the value is `repo_default` because GitHub's REST merge API requires an explicit method. | -| `maxRounds` | Hard cap on normal iterations before the loop either gives up or runs the force-finalize bonus iteration. Default `5`. | -| `conflictStrategy` | `pause | rebase | merge | auto`. Drives both base-advance sync between iterations and merge-time conflict handling. See [`path-to-merge.md`](./path-to-merge.md). | -| `autoAgentSettings` | Provider / model / reasoning / permission mode / confidence threshold used when `conflictStrategy === "auto"` — also reused by the queue's standalone auto-resolve flow. | -| `forceFinalizeMode` | `off | unconditional | conditional`. Controls the bonus iteration that runs after `maxRounds` is exhausted. | -| `forceFinalizeRequireNoCiFailures` | When `forceFinalizeMode === "conditional"`, the bonus iteration only fires if no required CI checks are failing. | -| `earlyMergeOnGreen` | Default `true`. Each iteration first checks whether checks are green and reviews are clean — if so, the merge ladder runs immediately instead of dispatching another fix round. | -| `onRebaseNeeded` | Legacy two-option projection (`pause | auto_rebase`) of `conflictStrategy`. Kept for back-compat reads; new code reads `conflictStrategy`. | - -The orchestrator persists per-PR start args (`modelId`, `reasoning`, -`scope`, `additionalInstructions`) in -`pr_convergence_state.ptm_args_json` so a desktop restart can rehydrate -the loop instead of pausing on missing model overrides. - -The manual auto-converge poller (still used when PtM is not active) -waits for CI to finish and comments to stabilize (2 consecutive polls -with same count) before starting the next round. Auto-merge -additionally requires a non-empty check list: if GitHub returns zero -checks for the PR, the poller pauses with +`PipelineSettings` (per PR): `autoMerge`, `mergeMethod`, `maxRounds`, +`onRebaseNeeded` (`pause | auto_rebase`). Default `maxRounds = 5`. + +The auto-converge poller waits for CI to finish and comments to +stabilize (2 consecutive polls with same count) before starting the +next round. Auto-merge additionally requires a non-empty check list: if +GitHub returns zero checks for the PR, the poller pauses with `Auto-merge paused because GitHub returned no check data for this PR.` instead of merging on vacuously-true "all checks passed". diff --git a/docs/features/pull-requests/path-to-merge.md b/docs/features/pull-requests/path-to-merge.md deleted file mode 100644 index c1b50aefd..000000000 --- a/docs/features/pull-requests/path-to-merge.md +++ /dev/null @@ -1,309 +0,0 @@ -# Path to Merge orchestrator - -Path to Merge (PtM) drives a PR through CI, review, and merge in one -self-pacing loop instead of forcing the operator to babysit each round. -It is a native TypeScript port of the `/shipLane` Claude skill state -machine — `apps/.claude/commands/shipLane.md` is the source of truth for -the phase delays, terminal-state gate, conflict-strategy switch, and -force-finalize semantics; this implementation mirrors them in-process so -the Electron host can run several PtM loops in parallel without spawning -agents per phase. - -Source: `apps/desktop/src/main/services/prs/pathToMergeOrchestrator.ts`. - -## Wiring and lifecycle - -`createPathToMergeOrchestrator(deps)` is built once during main-process -boot in `main.ts` alongside the rest of the PR services. Right after -construction, `setImmediate(() => resumeFromPersistedState())` rearms any -loops that were live when the desktop last shut down. The orchestrator is -exposed to renderer code through two IPCs (registered in -`services/ipc/registerIpc.ts` and bridged via `preload.ts`): - -| Channel | Purpose | -|---------|---------| -| `ade.prs.pathToMerge.start` | Start (or restart) PtM for a PR. Persists the run args, flips convergence runtime to `launching`/`scheduled`, and kicks the loop via `setImmediate` so the user sees activity right away. | -| `ade.prs.pathToMerge.stop` | Cancel any pending wake-up, interrupt the active fix-agent session if one is running, and persist `status: stopped` / `pollerStatus: stopped` / `autoConvergeEnabled: false`. | - -Both channels reject if the orchestrator is not present in the active -`AppContext` (the only such build is the headless test harness). - -The orchestrator stores three pieces of state: - -- **Persisted, in `pr_convergence_state`** — `ConvergenceRuntimeState` - (`status`, `pollerStatus`, `currentRound`, `activeSessionId`, - `pauseReason`, `errorMessage`, timestamps). Same row the manual - convergence panel reads. -- **Persisted, in `pr_convergence_state.ptm_args_json`** — the - `StartPathToMergeArgs` (`modelId`, `reasoning`, `scope`, - `additionalInstructions`). Persisted so a desktop restart can rehydrate - the original launch options instead of pausing on "No modelId - available". Cleared by `stopPathToMerge`. -- **In-process only** — - - `timersByPrId: Map` so wake-ups can be - cancelled deterministically on stop or reschedule. - - `iterationInFlight: Map` to guard against external - pokes during an iteration. - - `inProcessState: Map`. The - bonus-iteration consumed flag is reconstructed across restarts from - `pauseReason === "force-finalize"` because `currentRound > maxRounds` - is unreliable (the round counter does not advance when an iteration - finds no new inventory items). - -## Phase delays - -`PHASE_DELAY_SECONDS` mirrors `/shipLane` §5.3 exactly: - -| Kind | Seconds | When | -|------|---------|------| -| `justPushed` | 270 (~4.5 min) | A fix commit was just pushed; let CI start churning before re-evaluating. | -| `warming` | 720 (~12 min) | One of CI / review is still pending; reschedule and re-check the terminal-state gate. | -| `waitingOnReview` | 1800 (30 min) | Everything else is settled, parked waiting on a human / bot reviewer signal. | - -Each `schedule(prId, kind)` call clears any prior timer for the same PR -and stores a fresh `setTimeout` handle in `timersByPrId`. - -## Iteration body - -`runIteration(prId)` is reentry-safe via `iterationInFlight`. The loop -is: - -1. **Reload context** — refresh PR state via `prService.refresh`, - reload the row + pipeline settings + runtime. Early-exit on - `merged` (terminal) or `closed` (paused) states. -2. **Base-advance conflict check** — if `behindBaseBy > 0` (read - once via `prService.getStatus`), run - `applyConflictStrategy(ctx, "base_advance")`. The strategy switch - is below; failure pauses the loop, success continues. -3. **Early merge on green** — when - `pipelineSettings.earlyMergeOnGreen` (default `true`) and checks - are passing and review is clean, run the merge ladder. If - `pipelineSettings.autoMerge` is false, park as `converged` instead - of landing. -4. **Terminal-state gate** — `isTerminalForFixPush(pr)` requires - *both* checks (`passing | failing | none`) and review - (`approved | changes_requested | none`) to be terminal before - pushing more fixes. Pushing on a partial signal causes review-bot - thrash, so non-terminal gates schedule `warming` and exit. -5. **Hard cap and force-finalize** — when - `runtime.currentRound >= maxRounds`, consult - `pipelineSettings.forceFinalizeMode`: - - `off` → pause with "Hard cap reached". - - `unconditional` → run a bonus iteration that ignores review. - - `conditional` → run a bonus iteration only if - `forceFinalizeRequireNoCiFailures` is satisfied (no failing - checks). - - The bonus iteration is consumed by the merge-ladder attempt in step - 7, not by a fix dispatch — otherwise force-finalize would degrade - to "one extra fix iteration, then pause" and never actually retry - the merge once the agent's fix turned CI green. -6. **Fix-agent dispatch** — unless force-finalize already has CI green, - call `launchPrIssueResolutionChat` with the persisted scope (or - `"checks"` during force-finalize) and the resolved - `modelId` / `reasoning`. Before dispatching, the loop verifies the - prior session is no longer active via - `agentChatService.getSessionSummary` — two fix agents racing on the - same worktree corrupts pushes. After dispatch, schedule - `justPushed`. -7. **Force-finalize merge ladder** — when force-finalize fired with CI - already green, skip the dispatch and run the merge ladder - immediately. `forceFinalizeUsed` is set to `true` so the next hard - cap check pauses with "force-finalize already attempted". - -A merged observation (e.g. `gh pr merge --auto` landed between -iterations) is handled at the top of the iteration body: `runPostMergeCleanup` -runs and the runtime flips to `merged` / `stopped`. - -## Conflict strategy - -`applyConflictStrategy(ctx, kind)` runs at two sites: at the top of each -iteration as a base-advance sync (`base_advance`) and when the merge -ladder reports a conflict (`merge_time`). The behavior switches on -`pipelineSettings.conflictStrategy`: - -| Strategy | Behavior | -|----------|----------| -| `pause` | Mark the loop paused with `Conflict (kind): paused per pipeline settings.` | -| `rebase` | `git fetch origin ` then `git rebase origin/` then `git push --force-with-lease origin HEAD:`. Aborts the rebase on failure so the worktree is not left half-rebased. | -| `merge` | `git merge --no-edit origin/` then `git push origin HEAD:`. Aborts on conflict. | -| `auto` | `conflictService.runExternalResolver` with the configured `autoAgentSettings` (provider / model / reasoningEffort / permissionMode). The resolver agent reads the worktree, picks rebase vs merge, and resolves any marker-style conflicts itself. | - -The legacy `pipelineSettings.onRebaseNeeded` (`pause | auto_rebase`) is -projected to/from the 4-option strategy via -`conflictStrategyFromLegacyRebasePolicy` / -`legacyRebasePolicyFromConflictStrategy` so older settings rows continue -to work and queue auto-resolve callers that still read the legacy field -keep functioning. - -## Merge ladder - -`runMergeLadder(ctx)` is the rung sequence used by both early-merge and -force-finalize paths: - -1. **REST** — `prService.land({ prId, method, archiveLane: false })` - with the merge method resolved by `resolveMergeMethod` (defaults to - `squash` when settings are `repo_default`, since GitHub's REST - merge API requires an explicit method). On success the existing - post-merge cleanup pipeline runs inside `prService.land`. -2. **`gh pr merge --admin`** — when the REST call fails for a - non-conflict reason (typically a branch-protection block the - operator has admin override for). On success the orchestrator - explicitly invokes `prService.runPostMergeCleanup` because gh CLI - does not run the cleanup pipeline itself. -3. **`gh pr merge --auto`** — only attempted when - `pipelineSettings.autoMerge` is true. This **arms** GitHub - auto-merge; the PR has not actually landed. The orchestrator parks - the loop with `pollerStatus: waiting_for_checks` and re-polls - `pr.state` on each subsequent wake; cleanup runs when the merge - observation lands. - -Conflict detection on the REST rung is substring-based on the error -message (`/conflict|409/i`); a conflict short-circuits the ladder so -the caller can run the merge-time conflict strategy and retry. - -`gh pr merge` is invoked **without** `--delete-branch`. Per shipLane -line 212, `--delete-branch` would conflict with the project-root -worktree on `main`; branch deletion is delegated to -`prService.runPostMergeCleanup` so it goes through the same path as the -REST flow. - -## Conflict resolver provider - -The `auto` strategy uses `conflictService.runExternalResolver` with -`originSurface: "rebase"` and -`originLabel: "path-to-merge::pr="`. There -is no dedicated `path-to-merge` origin yet; `rebase` is the closest -existing surface (worktree-local base sync), which is exactly what the -PtM loop is asking the resolver to do. - -`pipelineSettings.autoAgentSettings.provider` must be set when -`conflictStrategy === "auto"`; the loop pauses with a clear error -otherwise. - -## Persistence and resume - -`resumeFromPersistedState()` runs on boot from `main.ts`. It iterates -every PR via `prService.listAll()` and rearms a `warming`-phase wake-up -for any whose convergence runtime is still flagged as live -(`autoConvergeEnabled === true`, -`pollerStatus !== "stopped"`, -`status !∈ {merged, stopped, cancelled}`). The warming delay is chosen -intentionally — it is long enough that any push-driven CI churn from -before the restart has settled, so the first iteration after resume -sees a stable state. - -The `pr_convergence_state.ptm_args_json` column (added in the same -migration as the other PtM-aware pipeline columns) carries the -`StartPathToMergeArgs` JSON. Resume rehydrates `modelId`, `reasoning`, -`scope`, and `additionalInstructions` from this column; absence of the -column or an unparseable payload falls back to `scope: "both"` and -null model overrides (the dispatch then uses `defaultModelId` from -deps). - -## Pipeline settings columns - -`pr_pipeline_settings` gained eight columns to back the new behavior -(see `apps/desktop/src/main/services/state/kvDb.ts`): - -| Column | Type | Default | Maps to | -|--------|------|---------|---------| -| `conflict_strategy` | text | `'pause'` | `PipelineSettings.conflictStrategy` | -| `force_finalize_mode` | text | `'off'` | `PipelineSettings.forceFinalizeMode` | -| `force_finalize_require_no_ci_failures` | int | `1` | `PipelineSettings.forceFinalizeRequireNoCiFailures` | -| `early_merge_on_green` | int | `1` | `PipelineSettings.earlyMergeOnGreen` | -| `auto_agent_provider` | text | null | `autoAgentSettings.provider` | -| `auto_agent_model` | text | null | `autoAgentSettings.model` | -| `auto_agent_reasoning_effort` | text | null | `autoAgentSettings.reasoningEffort` | -| `auto_agent_permission_mode` | text | null | `autoAgentSettings.permissionMode` | -| `auto_agent_confidence_threshold` | real | null | `autoAgentSettings.confidenceThreshold` | - -All eight are added via `try { db.run("alter table … add column …") } catch {}` -so existing DBs upgrade in place; the legacy `on_rebase_needed` column -stays for back-compat reads. - -`DEFAULT_PIPELINE_SETTINGS` (in `apps/desktop/src/shared/types/prs.ts`) -is the single source of truth for new-row defaults. - -## Renderer surface - -Two main consumers drive the orchestrator from the UI: - -- `renderer/components/prs/shared/PrPipelineSettings.tsx` — per-PR - pipeline settings editor used inside `PrConvergencePanel`. Surfaces - the 4-option `conflictStrategy` selector, the `auto`-only - `autoAgentSettings` group (provider / model / reasoning / - permission mode / confidence threshold), the `forceFinalizeMode` - selector with the conditional sub-toggle, and the - `earlyMergeOnGreen` switch. When `conflictStrategy` is `rebase` or - `auto`, the panel renders a force-push-warning chip. -- `renderer/components/prs/shared/PrConvergencePanel.tsx` — the - Path-to-Merge panel itself. Status copy uses "Path to Merge" verbatim - ("Agent working on Path to Merge…", "Ready to launch another Path to - Merge run", "Path to Merge is frozen for terminal PRs"). The - convergence sub-tab on `PrDetailPane` is labelled "Path to Merge" so - the surface name matches the orchestrator. -- `renderer/components/prs/tabs/QueueAutomateMergingModal.tsx` — the - Queue tab's "Automate Merging" entry point. The modal applies one - `PipelineSettings` config to every queue member, then for each member - in queue order: saves settings, retargets the base branch to the - queue's tracking branch (skipping position 0 — the first PR keeps its - original base), starts PtM via `pathToMergeStart`, and polls - `convergenceStateGet` every 4 s until the runtime status reaches a - terminal value (`merged` is success; - `failed | cancelled | stopped` halts the sequence). Closing the modal - mid-sequence stops dispatching new starts but leaves already-launched - orchestrators running. - -The IPC bridge for the modal also adds `ade.prs.retargetBase` (PR id + -base branch), which is what re-points each non-leading queue PR at the -chain base before PtM picks it up. - -## Differences from `/shipLane` - -`/shipLane` relies on Claude Code's `TeamCreate` primitive to run a -poll-agent + fix-agent + rebase-agent in parallel. ADE has no -equivalent, so each iteration here dispatches a **single fix agent** -through `launchPrIssueResolutionChat`; that agent decides internally -whether to fix CI, review comments, or both. The shipLane Phase 0 -`automate-agent` / `finalize-agent` are also unimplemented — the -orchestrator assumes the PR already exists and PR creation is the -caller's responsibility (the Queue Automate Merging modal handles this -for stack flows, since each queue member is already a PR). - -Wake-up delays, the combined CI + review terminal gate (line 206), the -4-option conflict strategy switch, the merge ladder rung sequence -(REST → admin → auto), and the force-finalize predicate (lines -183–198) all match `/shipLane` exactly. - -## Gotchas - -- **Never pass `--delete-branch` to gh.** Branch deletion goes through - `prService.runPostMergeCleanup` so the post-merge cleanup pipeline - (child-lane rebase, group-membership cleanup, cache invalidation, - rebase-needs scan) actually runs. shipLane line 212 documents the - same constraint. -- **Don't set `forceFinalizeUsed` on fix dispatch.** It is consumed by - the merge-ladder attempt in step 7. Setting it on dispatch degrades - force-finalize to "one extra fix iteration, then pause" instead of - letting the fix run and retrying the ladder once CI flips green. -- **Terminal-state gate must consider *both* signals.** Pushing on a - partial signal (e.g. CI passing but review still pending) makes - review bots churn on every commit; the orchestrator will explicitly - schedule `warming` and exit instead of dispatching a fix. -- **Conflict detection on the REST rung is substring-based** (`conflict` - or `409`). Changing the wording GitHub returns silently disables the - conflict short-circuit and the loop will treat conflicts as generic - failures. -- **`forceFinalizeUsed` rehydration uses `pauseReason`.** Step 5 writes - `pauseReason: "force-finalize"` *before* dispatching the bonus - iteration. `currentRound > maxRounds` would not survive restarts - reliably because the round counter does not advance when no new - inventory items are produced. -- **`auto` conflict strategy needs an explicit provider.** A null - `autoAgentSettings.provider` pauses the loop with a clear error — - ship a UI guard before saving the strategy if surface design changes. -- **Resume uses the `warming` delay deliberately.** After a desktop - restart, kicking the loop on `justPushed` would retry while CI - state from a stale push is still propagating; `warming` lets the - state stabilise before the first post-resume iteration.