From 8a9813f08605abefdea0369c0ce2d77d64a31026 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 31 Mar 2026 02:51:43 -0400 Subject: [PATCH 1/9] Issue inventory, compact context docs, AI fixes Add an Issue Inventory service and IPC handlers for PR convergence and pipeline settings; wire the service into app startup. Improve context doc generation: compact oversized AI-generated docs, stricter JSON/output validation, require a selected model for manual runs, handle/stale in-progress runs, and adjust generation status reconciliation. Fix AI/tooling behavior: use no tools for initial_context tasks, include planning only where appropriate, and add Claude TodoWrite handling (emit todo_update events and normalize items). UI/navigation and IPC tweaks: add labels to CTO navigation suggestions and make URL building safer, extend CSP image sources, and exempt context generation IPC from the global timeout. Misc: improve child-process timeout/close handling, minor type/import updates, and update/add tests accordingly. --- apps/desktop/src/main/main.ts | 9 +- .../services/ai/aiIntegrationService.test.ts | 15 +- .../main/services/ai/aiIntegrationService.ts | 2 +- .../ai/tools/ctoOperatorTools.test.ts | 32 +- .../services/ai/tools/ctoOperatorTools.ts | 9 +- .../services/automations/automationService.ts | 8 +- .../services/chat/agentChatService.test.ts | 98 ++ .../main/services/chat/agentChatService.ts | 55 + .../context/contextDocBuilder.test.ts | 121 ++ .../services/context/contextDocBuilder.ts | 92 +- .../context/contextDocService.test.ts | 145 ++ .../services/context/contextDocService.ts | 117 +- .../src/main/services/ipc/registerIpc.ts | 62 +- .../src/main/services/lanes/laneService.ts | 52 +- .../missions/missionPreflightService.test.ts | 23 +- .../missions/missionPreflightService.ts | 40 +- .../main/services/missions/missionService.ts | 895 ++++++++-- .../src/main/services/missions/phaseEngine.ts | 4 +- .../aiOrchestratorService.test.ts | 579 +++---- .../orchestrator/aiOrchestratorService.ts | 1063 ++++-------- .../services/orchestrator/coordinatorAgent.ts | 14 +- .../services/orchestrator/coordinatorTools.ts | 10 +- .../services/orchestrator/missionLifecycle.ts | 9 + .../services/orchestrator/missionStateDoc.ts | 3 +- .../orchestrator/orchestrationRuntime.test.ts | 3 +- .../orchestrator/orchestratorContext.ts | 23 +- .../orchestrator/orchestratorSmoke.test.ts | 437 ++++- .../services/orchestrator/promptInspector.ts | 2 +- .../services/prs/issueInventoryService.ts | 447 +++++ .../src/main/services/prs/prIssueResolver.ts | 170 +- .../src/main/services/prs/prService.ts | 102 ++ .../src/main/services/state/kvDb.sync.test.ts | 8 +- apps/desktop/src/main/services/state/kvDb.ts | 101 +- .../services/state/onConflictAudit.test.ts | 5 + apps/desktop/src/preload/global.d.ts | 15 + apps/desktop/src/preload/preload.ts | 26 + .../renderer/components/files/FilesPage.tsx | 14 +- .../components/graph/WorkspaceGraphPage.tsx | 7 +- .../components/lanes/laneUtils.test.ts | 47 +- .../renderer/components/lanes/laneUtils.ts | 14 +- .../components/missions/AgentChannels.tsx | 147 +- .../missions/CreateMissionDialog.tsx | 272 +-- .../components/missions/MissionChatV2.tsx | 163 +- .../missions/MissionTabContainer.tsx | 35 +- .../components/missions/MissionsPage.tsx | 34 +- .../components/missions/PhaseCardEditor.tsx | 24 +- .../renderer/components/missions/PlanTab.tsx | 272 ++- .../missions/missionControlViewModel.ts | 7 +- .../missions/missionLaunchPolicies.test.ts | 30 + .../missions/missionPhaseDefaults.ts | 4 +- .../onboarding/ProjectSetupPage.tsx | 5 + .../src/renderer/components/prs/PRsPage.tsx | 7 +- .../PrDetailPane.issueResolver.test.tsx | 11 +- .../components/prs/detail/PrDetailPane.tsx | 410 ++++- .../prs/shared/PrConvergencePanel.tsx | 1507 +++++++++++++++++ .../prs/shared/PrIssueResolverModal.tsx | 4 +- .../prs/shared/PrPipelineSettings.tsx | 535 ++++++ .../components/prs/tabs/GitHubTab.test.tsx | 6 +- .../components/prs/tabs/GitHubTab.tsx | 5 +- .../components/settings/ContextSection.tsx | 16 +- apps/desktop/src/renderer/lib/platform.ts | 9 +- apps/desktop/src/shared/ipc.ts | 11 + apps/desktop/src/shared/types/lanes.ts | 5 + apps/desktop/src/shared/types/missions.ts | 35 +- apps/desktop/src/shared/types/orchestrator.ts | 3 +- apps/desktop/src/shared/types/prs.ts | 78 + apps/ios/ADE/Resources/DatabaseBootstrap.sql | 109 +- 67 files changed, 6849 insertions(+), 1773 deletions(-) create mode 100644 apps/desktop/src/main/services/prs/issueInventoryService.ts create mode 100644 apps/desktop/src/renderer/components/missions/missionLaunchPolicies.test.ts create mode 100644 apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.tsx create mode 100644 apps/desktop/src/renderer/components/prs/shared/PrPipelineSettings.tsx diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index d7f544316..7d03d24d9 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -34,6 +34,7 @@ import { createGithubService } from "./services/github/githubService"; 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 { detectDefaultBaseRef, resolveRepoRoot, toProjectInfo, upsertProjectRow } from "./services/projects/projectService"; import { createAdeProjectService } from "./services/projects/adeProjectService"; import { createConfigReloadService } from "./services/projects/configReloadService"; @@ -220,7 +221,7 @@ async function createWindow(logger?: Logger): Promise { ? "'self' http://localhost:* http://127.0.0.1:*" : "'self' file: app:"; const cspWsSources = isDevMode ? " ws://localhost:* ws://127.0.0.1:*" : ""; - const cspImageSources = `${cspSources} https://avatars.githubusercontent.com https://*.githubusercontent.com https://github.githubassets.com https://opengraph.githubassets.com https://github.com https://vercel.com https://*.vercel.com`; + const cspImageSources = `${cspSources} https://avatars.githubusercontent.com https://*.githubusercontent.com https://github.githubassets.com https://opengraph.githubassets.com https://github.com https://vercel.com https://*.vercel.com https://img.shields.io`; const cspPolicy = [ `default-src ${cspSources}`, `base-uri 'self'`, @@ -991,11 +992,12 @@ app.whenReady().then(async () => { if (hotPrIds.size > 0) { prServiceRef?.markHotRefresh(Array.from(hotPrIds)); } - aiOrchestratorServiceRef?.onQueueLandingStateChanged?.(state); } }); queueLandingService.init(); + const issueInventoryService = createIssueInventoryService({ db }); + const fileService = createFileService({ laneService, onLaneWorktreeMutation: ({ laneId, reason }) => { @@ -1499,6 +1501,7 @@ app.whenReady().then(async () => { if (event.reason === "ready_to_start" && event.missionId) { void aiOrchestratorServiceRef?.startMissionRun({ missionId: event.missionId, + queueClaimToken: event.claimToken ?? null, }).catch((error) => { logger.warn("missions.queue_autostart_failed", { missionId: event.missionId, @@ -2301,6 +2304,7 @@ app.whenReady().then(async () => { prPollingService, computerUseArtifactBrokerService, queueLandingService, + issueInventoryService, jobEngine, automationService, automationPlannerService, @@ -2393,6 +2397,7 @@ app.whenReady().then(async () => { prService: null, prPollingService: null, queueLandingService: null, + issueInventoryService: null, jobEngine: null, automationService: null, automationPlannerService: null, diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts index c4a5aaf51..2344f3d75 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.test.ts @@ -312,7 +312,7 @@ describe("aiIntegrationService", () => { it("uses planning tools for read-only orchestrator tasks and none for other read-only tasks", async () => { const { service } = makeService({ - aiConfig: { features: { orchestrator: true, terminal_summaries: true } }, + aiConfig: { features: { orchestrator: true, terminal_summaries: true, initial_context: true } }, }); await service.executeTask({ @@ -333,11 +333,22 @@ describe("aiIntegrationService", () => { permissionMode: "read-only", }); - expect(mockState.executeUnified).toHaveBeenCalledTimes(2); + await service.executeTask({ + feature: "initial_context", + taskType: "initial_context", + prompt: "Generate bootstrap docs", + cwd: "/tmp", + model: "anthropic/claude-sonnet-4-6", + permissionMode: "read-only", + }); + + expect(mockState.executeUnified).toHaveBeenCalledTimes(3); const orchestratorCall = mockState.executeUnified.mock.calls[0]?.[0] as Record; const summaryCall = mockState.executeUnified.mock.calls[1]?.[0] as Record; + const initialContextCall = mockState.executeUnified.mock.calls[2]?.[0] as Record; expect(orchestratorCall.tools).toBe("planning"); expect(summaryCall.tools).toBe("none"); + expect(initialContextCall.tools).toBe("none"); }); it("forwards memory context and compaction identifiers to the unified executor when provided", async () => { diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index d12d4b656..b61baec5e 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -221,7 +221,7 @@ function resolveUnifiedToolMode(args: { return "planning"; } if (args.taskType === "initial_context") { - return "planning"; + return "none"; } if (args.feature === "orchestrator" && args.permissionMode === "read-only") { return "planning"; diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts index 706ae47dc..c3ec81ca5 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.test.ts @@ -223,8 +223,20 @@ describe("createCtoOperatorTools", () => { expect(result).toMatchObject({ success: true, sessionId: "chat-1", - navigation: { surface: "work", laneId: "lane-1", sessionId: "chat-1", href: "/work?laneId=lane-1&sessionId=chat-1" }, - navigationSuggestions: [{ surface: "work", laneId: "lane-1", sessionId: "chat-1", href: "/work?laneId=lane-1&sessionId=chat-1" }], + navigation: { + surface: "work", + laneId: "lane-1", + sessionId: "chat-1", + href: "/work?laneId=lane-1&sessionId=chat-1", + label: "Open in Work", + }, + navigationSuggestions: [{ + surface: "work", + laneId: "lane-1", + sessionId: "chat-1", + href: "/work?laneId=lane-1&sessionId=chat-1", + label: "Open in Work", + }], requestedTitle: "Backend follow-up", }); }); @@ -423,11 +435,23 @@ describe("createCtoOperatorTools", () => { expect(createdLane).toMatchObject({ success: true, - navigation: { surface: "lanes", laneId: "lane-2", href: "/lanes?laneId=lane-2" }, + navigation: { + surface: "lanes", + laneId: "lane-2", + sessionId: null, + href: "/lanes?laneId=lane-2", + label: "Open lane", + }, }); expect(startedMission).toMatchObject({ success: true, - navigation: { surface: "missions", laneId: "lane-2", missionId: "mission-7", href: "/missions?missionId=mission-7&laneId=lane-2" }, + navigation: { + surface: "missions", + laneId: "lane-2", + missionId: "mission-7", + href: "/missions?missionId=mission-7&laneId=lane-2", + label: "Open mission", + }, }); }); diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 8fabd13c2..2001d9ba5 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -170,10 +170,11 @@ function buildNavigationSuggestion(args: { const search = new URLSearchParams(); if (laneId) search.set("laneId", laneId); if (sessionId) search.set("sessionId", sessionId); + const query = search.toString(); return { surface: "work", label: "Open in Work", - href: `/work${search.size ? `?${search.toString()}` : ""}`, + href: `/work${query ? `?${query}` : ""}`, laneId, sessionId, }; @@ -182,10 +183,11 @@ function buildNavigationSuggestion(args: { const search = new URLSearchParams(); if (missionId) search.set("missionId", missionId); if (laneId) search.set("laneId", laneId); + const query = search.toString(); return { surface: "missions", label: "Open mission", - href: `/missions${search.size ? `?${search.toString()}` : ""}`, + href: `/missions${query ? `?${query}` : ""}`, laneId, missionId, }; @@ -202,10 +204,11 @@ function buildNavigationSuggestion(args: { const search = new URLSearchParams(); if (laneId) search.set("laneId", laneId); if (sessionId) search.set("sessionId", sessionId); + const query = search.toString(); return { surface: "lanes", label: "Open lane", - href: `/lanes${search.size ? `?${search.toString()}` : ""}`, + href: `/lanes${query ? `?${query}` : ""}`, laneId, sessionId, }; diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index 38c99df4f..a91f7d1bf 100644 --- a/apps/desktop/src/main/services/automations/automationService.ts +++ b/apps/desktop/src/main/services/automations/automationService.ts @@ -1372,19 +1372,25 @@ export function createAutomationService({ child.stderr?.on("data", (chunk) => onChunk(chunk, "stderr")); const timeoutMs = Math.max(1000, args.timeoutMs); const exitCode = await new Promise((resolve, reject) => { + let settled = false; const timer = setTimeout(() => { try { child.kill("SIGKILL"); } catch { // ignore } + settled = true; reject(new Error(`Command timed out after ${timeoutMs}ms`)); }, timeoutMs); child.on("error", (error) => { + if (settled) return; + settled = true; clearTimeout(timer); reject(error); }); - child.on("exit", (code) => { + child.on("close", (code) => { + if (settled) return; + settled = true; clearTimeout(timer); resolve(code); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 92efaef49..9850b97ac 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -2530,6 +2530,104 @@ describe("createAgentChatService", () => { expect(setPermissionMode).toHaveBeenCalledWith("plan"); expect(setPermissionMode.mock.invocationCallOrder[0]).toBeLessThan(send.mock.invocationCallOrder[1]); }); + + it("emits todo_update events for Claude TodoWrite tool uses", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-1", + slash_commands: [], + }; + return; + } + + yield { + type: "assistant", + message: { + content: [{ + type: "tool_use", + id: "todo-call-1", + name: "TodoWrite", + input: { + todos: [ + { + content: "Inspect Claude task rendering", + activeForm: "Inspecting Claude task rendering", + status: "completed", + }, + { + content: "Render ADE task list UI", + activeForm: "Rendering ADE task list UI", + status: "in_progress", + }, + ], + }, + }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-1", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Track the current task list.", + }); + + const todoEvent = events.find((event) => event.event.type === "todo_update"); + expect(todoEvent).toBeTruthy(); + expect(todoEvent?.event).toMatchObject({ + type: "todo_update", + items: [ + { + id: "todo-0", + description: "Inspect Claude task rendering", + status: "completed", + }, + { + id: "todo-1", + description: "Render ADE task list UI", + status: "in_progress", + }, + ], + }); + + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + type: "tool_call", + tool: "TodoWrite", + itemId: "todo-call-1", + }), + }), + ])); + }); }); // -------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ab781a773..b0581a65c 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -1047,6 +1047,42 @@ function readProviderParentItemId(value: unknown): string | undefined { return undefined; } +function normalizeClaudeTodoItems( + value: unknown, +): Extract["items"] | null { + if (!value || typeof value !== "object") return null; + const todos = (value as { todos?: unknown }).todos; + if (!Array.isArray(todos) || todos.length === 0) return null; + + const items: Extract["items"] = todos.flatMap((todo, index) => { + if (!todo || typeof todo !== "object") return []; + const record = todo as Record; + const description = [ + record.content, + record.activeForm, + record.description, + record.text, + ].find((candidate): candidate is string => typeof candidate === "string" && candidate.trim().length > 0)?.trim(); + if (!description) return []; + + const rawStatus = typeof record.status === "string" ? record.status : ""; + const status: Extract["items"][number]["status"] = rawStatus === "completed" + ? "completed" + : rawStatus === "in_progress" || rawStatus === "inProgress" + ? "in_progress" + : "pending"; + + const explicitId = typeof record.id === "string" && record.id.trim().length > 0 ? record.id.trim() : null; + return [{ + id: explicitId ?? `todo-${index}`, + description, + status, + }]; + }); + + return items.length ? items : null; +} + function buildStreamingUserContent( args: { baseText: string; @@ -4473,6 +4509,7 @@ export function createAgentChatService(args: { const emittedSyntheticItemIds = new Set(); const toolInputJsonByContentIndex = new Map(); const toolUseMetaByContentIndex = new Map(); + const emittedClaudeTodoIds = new Set(); const markFirstStreamEvent = (kind: string): void => { if (firstStreamEventLogged) return; firstStreamEventLogged = true; @@ -4827,6 +4864,15 @@ export function createAgentChatService(args: { itemId, turnId, }); + const todoItems = toolName === "TodoWrite" ? normalizeClaudeTodoItems(block.input ?? {}) : null; + if (todoItems && !emittedClaudeTodoIds.has(itemId)) { + emittedClaudeTodoIds.add(itemId); + emitChatEvent(managed, { + type: "todo_update", + items: todoItems, + turnId, + }); + } // Synthesize a tool_result for the proof observer since the // Claude V2 SDK never surfaces tool results in the stream. const syntheticResult = maybeSyntheticToolResult(toolName, block.input ?? {}, itemId, turnId); @@ -4942,6 +4988,15 @@ export function createAgentChatService(args: { itemId, turnId, }); + const todoItems = toolName === "TodoWrite" ? normalizeClaudeTodoItems(block.input ?? {}) : null; + if (todoItems && !emittedClaudeTodoIds.has(itemId)) { + emittedClaudeTodoIds.add(itemId); + emitChatEvent(managed, { + type: "todo_update", + items: todoItems, + turnId, + }); + } if (typeof contentIndex === "number") { const initial = block.input != null && typeof block.input === "object" && Object.keys(block.input as object).length diff --git a/apps/desktop/src/main/services/context/contextDocBuilder.test.ts b/apps/desktop/src/main/services/context/contextDocBuilder.test.ts index 7c47c18a6..ec21cbaa9 100644 --- a/apps/desktop/src/main/services/context/contextDocBuilder.test.ts +++ b/apps/desktop/src/main/services/context/contextDocBuilder.test.ts @@ -252,6 +252,127 @@ describe("contextDocBuilder", () => { expect(fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "ARCHITECTURE.ade.md"), "utf8")).toBe(firstArch); }); + it("compacts oversized docs instead of falling back", async () => { + const fixture = await createFixture(); + cleanupRoots.push(fixture.projectRoot); + cleanupDbs.push(fixture.db); + + const oversizedArchitecture = [ + buildValidArchitectureDoc("ADE routes trusted repo mutation through Electron main-process services."), + "", + "## Extra detail", + "architecture ".repeat(1200), + ].join("\n"); + + const result = await runContextDocGeneration({ + db: fixture.db, + logger: createLogger() as any, + projectRoot: fixture.projectRoot, + projectId: "project-1", + packsDir: fixture.packsDir, + laneService: {} as any, + projectConfigService: {} as any, + aiIntegrationService: createAiIntegrationService(JSON.stringify({ + prd: buildValidPrdDoc("ADE gives operators a durable control plane for coding agents."), + architecture: oversizedArchitecture, + })) as any, + }, { + provider: "unified", + }); + + expect(result.degraded).toBe(false); + expect(result.docResults).toEqual([ + expect.objectContaining({ id: "prd_ade", health: "ready", source: "ai" }), + expect.objectContaining({ id: "architecture_ade", health: "ready", source: "ai" }), + ]); + + const prdBody = fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "PRD.ade.md"), "utf8"); + const archBody = fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "ARCHITECTURE.ade.md"), "utf8"); + expect(prdBody).toContain("durable control plane for coding agents"); + expect(archBody).toContain("trusted repo mutation through Electron main-process services"); + expect(archBody.length).toBeLessThanOrEqual(8_000); + expect(result.warnings.some((warning) => warning.code === "generator_invalid_architecture")).toBe(false); + }); + + it("keeps a valid doc when only its sibling is structurally invalid", async () => { + const fixture = await createFixture(); + cleanupRoots.push(fixture.projectRoot); + cleanupDbs.push(fixture.db); + + const invalidArchitecture = [ + "# ARCHITECTURE.ade", + "", + "## System shape", + "ADE routes trusted repo mutation through Electron main-process services.", + "", + "## Core services", + "- Main process services own git, files, and process execution.", + "", + "## Data and state", + "- Project state lives under `.ade/`.", + "", + "## Integration points", + "- Renderer talks to trusted services over typed IPC.", + "", + ].join("\n"); + + const result = await runContextDocGeneration({ + db: fixture.db, + logger: createLogger() as any, + projectRoot: fixture.projectRoot, + projectId: "project-1", + packsDir: fixture.packsDir, + laneService: {} as any, + projectConfigService: {} as any, + aiIntegrationService: createAiIntegrationService(JSON.stringify({ + prd: buildValidPrdDoc("ADE gives operators a durable control plane for coding agents."), + architecture: invalidArchitecture, + })) as any, + }, { + provider: "unified", + }); + + expect(result.degraded).toBe(true); + expect(result.docResults).toEqual([ + expect.objectContaining({ id: "prd_ade", health: "ready", source: "ai" }), + expect.objectContaining({ id: "architecture_ade", health: "fallback", source: "deterministic" }), + ]); + expect(result.warnings.some((warning) => warning.code === "generator_invalid_architecture")).toBe(true); + expect(result.warnings.some((warning) => warning.code === "generator_fallback_architecture")).toBe(true); + expect(result.warnings.some((warning) => warning.code === "generator_fallback_prd")).toBe(false); + + const prdBody = fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "PRD.ade.md"), "utf8"); + const archBody = fs.readFileSync(path.join(fixture.projectRoot, ".ade", "context", "ARCHITECTURE.ade.md"), "utf8"); + expect(prdBody).toContain("durable control plane for coding agents"); + expect(archBody).toContain("Auto-generated from curated docs and code digests."); + }); + + it("records an explicit warning when the model returns narration instead of JSON", async () => { + const fixture = await createFixture(); + cleanupRoots.push(fixture.projectRoot); + cleanupDbs.push(fixture.db); + + const result = await runContextDocGeneration({ + db: fixture.db, + logger: createLogger() as any, + projectRoot: fixture.projectRoot, + projectId: "project-1", + packsDir: fixture.packsDir, + laneService: {} as any, + projectConfigService: {} as any, + aiIntegrationService: createAiIntegrationService("Reading the key source docs and recent code changes to produce accurate bootstrap cards.") as any, + }, { + provider: "unified", + }); + + expect(result.degraded).toBe(true); + expect(result.warnings.some((warning) => warning.code === "generator_unstructured_output")).toBe(true); + expect(result.docResults).toEqual([ + expect.objectContaining({ id: "prd_ade", health: "fallback", source: "deterministic" }), + expect.objectContaining({ id: "architecture_ade", health: "fallback", source: "deterministic" }), + ]); + }); + it("rejects overlapping docs and falls back to compact deterministic cards", async () => { const fixture = await createFixture(); cleanupRoots.push(fixture.projectRoot); diff --git a/apps/desktop/src/main/services/context/contextDocBuilder.ts b/apps/desktop/src/main/services/context/contextDocBuilder.ts index 31bc0156c..9c49d8993 100644 --- a/apps/desktop/src/main/services/context/contextDocBuilder.ts +++ b/apps/desktop/src/main/services/context/contextDocBuilder.ts @@ -413,6 +413,51 @@ function validateContextDoc(id: ContextDocId, content: string): { valid: boolean return { valid: reasons.length === 0, reasons }; } +function compactGeneratedContextDoc(id: ContextDocId, content: string): string { + const normalized = content.trim(); + if (normalized.length <= CONTEXT_DOC_MAX_CHARS) return normalized; + + const spec = docSpecFor(id); + if (!normalized.startsWith(spec.title)) return normalized; + + const headingOffsets = spec.requiredHeadings.map((heading) => normalized.indexOf(heading)); + if (headingOffsets.some((offset) => offset < 0)) return normalized; + for (let index = 1; index < headingOffsets.length; index += 1) { + if (headingOffsets[index] <= headingOffsets[index - 1]) return normalized; + } + + const sectionBodies = spec.requiredHeadings.map((heading, index) => { + const sectionStart = normalized.indexOf(heading); + const bodyStart = sectionStart + heading.length; + const nextHeading = index + 1 < spec.requiredHeadings.length + ? normalized.indexOf(spec.requiredHeadings[index + 1], bodyStart) + : normalized.length; + return normalized.slice(bodyStart, nextHeading).trim(); + }); + + const scaffoldLines = [ + spec.title, + "", + ...spec.requiredHeadings.flatMap((heading) => [heading, ""]), + ]; + const scaffoldLength = scaffoldLines.join("\n").length; + const reservedEllipsisBudget = spec.requiredHeadings.length * 20; + const bodyBudget = Math.max( + 120 * spec.requiredHeadings.length, + CONTEXT_DOC_MAX_CHARS - scaffoldLength - reservedEllipsisBudget, + ); + const perSectionBudget = Math.max(120, Math.floor(bodyBudget / spec.requiredHeadings.length)); + + const compactedLines: string[] = [spec.title, ""]; + for (let index = 0; index < spec.requiredHeadings.length; index += 1) { + compactedLines.push(spec.requiredHeadings[index]); + compactedLines.push(clipText(sectionBodies[index], perSectionBudget)); + compactedLines.push(""); + } + + return compactedLines.join("\n").trim(); +} + function computeContextDocHealth(args: { id: ContextDocId; exists: boolean; @@ -601,6 +646,8 @@ function buildGenerationPrompt(args: { "", "Output rules:", "- Return JSON only: {\"prd\":\"...\",\"architecture\":\"...\"}.", + "- The first character of your response must be `{` and the last character must be `}`.", + "- Do not include narration, thinking text, Markdown fences, or any text before/after the JSON object.", `- Each doc must stay under ${CONTEXT_DOC_MAX_CHARS} characters.`, "- Use these exact headings and no changelog language.", "", @@ -906,7 +953,6 @@ export async function runContextDocGeneration( ? args.reasoningEffort.trim() : null; const providerHint = provider === "codex" || provider === "claude" ? provider : undefined; - const generatedAt = nowIso(); const warnings: ContextGenerateDocsResult["warnings"] = []; const lastRunRaw = deps.db.getJson(CONTEXT_DOC_LAST_RUN_KEY); const lastGeneratedAt = typeof lastRunRaw?.generatedAt === "string" ? lastRunRaw.generatedAt : null; @@ -935,7 +981,6 @@ export async function runContextDocGeneration( cwd: deps.projectRoot, ...(providerHint ? { provider: providerHint } : {}), prompt, - timeoutMs: 120_000, ...(modelId ? { model: modelId } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), jsonSchema: { @@ -955,8 +1000,18 @@ export async function runContextDocGeneration( : parseStructuredOutput(aiResult.text); const structured = isRecord(structuredCandidate) ? structuredCandidate : null; if (structured) { - generatedPrd = asString(structured.prd).trim(); - generatedArch = asString(structured.architecture).trim(); + generatedPrd = compactGeneratedContextDoc("prd_ade", asString(structured.prd)); + generatedArch = compactGeneratedContextDoc("architecture_ade", asString(structured.architecture)); + } else if (aiResult.text.trim()) { + warnings.push({ + code: "generator_unstructured_output", + message: "Model returned text instead of the required JSON object for context docs.", + }); + } else { + warnings.push({ + code: "generator_empty_output", + message: "Model returned empty output for context docs.", + }); } } catch (error) { warnings.push({ @@ -1028,13 +1083,14 @@ export async function runContextDocGeneration( health: ContextDocHealth; }; - const allowAi = prdValidation.valid && archValidation.valid && overlapScore < 0.72; + const rejectBothForOverlap = overlapScore >= 0.72; function resolveDocStrategy( generated: string, existingFile: { body: string }, existingHealth: ContextDocHealth, deterministicContent: string, + allowAi: boolean, ): ResolvedDoc { if (allowAi) { return { content: generated, source: "ai", preserveExisting: false, health: "ready" }; @@ -1046,8 +1102,20 @@ export async function runContextDocGeneration( } const resolvedDocs: Record = { - prd_ade: resolveDocStrategy(generatedPrd, existingPrdFile, existingPrdBaseHealth, deterministicPrd), - architecture_ade: resolveDocStrategy(generatedArch, existingArchFile, existingArchBaseHealth, deterministicArch), + prd_ade: resolveDocStrategy( + generatedPrd, + existingPrdFile, + existingPrdBaseHealth, + deterministicPrd, + prdValidation.valid && !rejectBothForOverlap, + ), + architecture_ade: resolveDocStrategy( + generatedArch, + existingArchFile, + existingArchBaseHealth, + deterministicArch, + archValidation.valid && !rejectBothForOverlap, + ), }; const FALLBACK_WARNINGS: Record> = { @@ -1062,11 +1130,10 @@ export async function runContextDocGeneration( ai: { prd_ade: null, architecture_ade: null }, }; - if (!allowAi) { - for (const [id, doc] of Object.entries(resolvedDocs) as Array<[ContextDocId, ResolvedDoc]>) { - const warning = FALLBACK_WARNINGS[doc.source]?.[id]; - if (warning) warnings.push(warning); - } + for (const [id, doc] of Object.entries(resolvedDocs) as Array<[ContextDocId, ResolvedDoc]>) { + if (doc.source === "ai") continue; + const warning = FALLBACK_WARNINGS[doc.source]?.[id]; + if (warning) warnings.push(warning); } const persistResolvedDoc = (id: ContextDocId) => { @@ -1113,6 +1180,7 @@ export async function runContextDocGeneration( }); } + const generatedAt = nowIso(); deps.db.setJson(CONTEXT_DOC_LAST_RUN_KEY, { generatedAt, provider, diff --git a/apps/desktop/src/main/services/context/contextDocService.test.ts b/apps/desktop/src/main/services/context/contextDocService.test.ts index 0e460b5de..15d37e622 100644 --- a/apps/desktop/src/main/services/context/contextDocService.test.ts +++ b/apps/desktop/src/main/services/context/contextDocService.test.ts @@ -156,6 +156,7 @@ describe("contextDocService", () => { await service.generateDocs({ provider: "claude", + modelId: "anthropic/claude-sonnet-4-6", events: { onSessionEnd: true }, }); db.setJson("context:docs:lastRun.v1", { @@ -176,6 +177,7 @@ describe("contextDocService", () => { await service.generateDocs({ provider: "unified", + modelId: "openai/gpt-5.4-codex", events: { onPrCreate: true }, }); @@ -218,6 +220,7 @@ describe("contextDocService", () => { await service.generateDocs({ provider: "unified", + modelId: "openai/gpt-5.4-codex", events: { onPrCreate: true }, }); @@ -321,6 +324,121 @@ describe("contextDocService", () => { }); }); + it("rejects manual generation when no model is selected", async () => { + const { service } = await createFixture(); + + await expect(service.generateDocs({ + provider: "unified", + events: { onPrCreate: true }, + })).rejects.toThrow("Select a model before generating context docs."); + + expect(runContextDocGeneration).not.toHaveBeenCalled(); + }); + + it("skips auto-refresh when no model is configured", async () => { + const { service } = await createFixture(); + + await service.savePrefs({ + provider: "unified", + modelId: null, + reasoningEffort: null, + events: { onPrCreate: true }, + }); + + const refreshed = await service.maybeAutoRefreshDocs({ + event: "pr_create", + reason: "pr_opened", + }); + + expect(refreshed).toBeNull(); + expect(runContextDocGeneration).not.toHaveBeenCalled(); + expect(service.getStatus().generation.state).toBe("idle"); + }); + + it("clears stale finished timestamps when a new generation starts", async () => { + const { db, service } = await createFixture(); + const deferred = createDeferred>>(); + + await service.generateDocs({ + provider: "unified", + modelId: "openai/gpt-5.4-codex", + events: { onPrLand: true }, + }); + + vi.mocked(runContextDocGeneration).mockReturnValueOnce(deferred.promise as ReturnType); + db.setJson("context:docs:lastRun.v1", { + generatedAt: "2026-03-05T11:30:00.000Z", + }); + + const refreshPromise = service.maybeAutoRefreshDocs({ + event: "pr_land", + reason: "prs_land:456", + }); + + const duringRun = service.getStatus().generation; + expect(["pending", "running"]).toContain(duringRun.state); + expect(duringRun.finishedAt).toBeNull(); + + deferred.resolve({ + provider: "unified", + generatedAt: "2026-03-05T12:01:00.000Z", + prdPath: "/tmp/PRD.ade.md", + architecturePath: "/tmp/ARCHITECTURE.ade.md", + usedFallbackPath: false, + degraded: false, + docResults: [ + { id: "prd_ade", health: "ready", source: "ai", sizeBytes: 512 }, + { id: "architecture_ade", health: "ready", source: "ai", sizeBytes: 640 }, + ], + warnings: [], + outputPreview: "generated", + }); + + await refreshPromise; + }); + + it("does not mark an active long-running generation as stale", async () => { + const { service } = await createFixture(); + const deferred = createDeferred>>(); + vi.mocked(runContextDocGeneration).mockReturnValueOnce(deferred.promise as ReturnType); + + const generatePromise = service.generateDocs({ + provider: "unified", + modelId: "openai/gpt-5.4-codex", + }); + + vi.advanceTimersByTime(6 * 60_000); + + expect(service.getStatus().generation).toMatchObject({ + state: "running", + provider: "unified", + modelId: "openai/gpt-5.4-codex", + }); + + deferred.resolve({ + provider: "unified", + generatedAt: "2026-03-05T12:06:00.000Z", + prdPath: "/tmp/PRD.ade.md", + architecturePath: "/tmp/ARCHITECTURE.ade.md", + usedFallbackPath: false, + degraded: false, + docResults: [ + { id: "prd_ade", health: "ready", source: "ai", sizeBytes: 512 }, + { id: "architecture_ade", health: "ready", source: "ai", sizeBytes: 640 }, + ], + warnings: [], + outputPreview: "generated", + }); + + await expect(generatePromise).resolves.toMatchObject({ + generatedAt: "2026-03-05T12:06:00.000Z", + }); + expect(service.getStatus().generation).toMatchObject({ + state: "succeeded", + finishedAt: "2026-03-05T12:06:00.000Z", + }); + }); + it("emits status updates when generation state changes", async () => { const onStatusChanged = vi.fn(); const { service } = await createFixture({ onStatusChanged }); @@ -374,4 +492,31 @@ describe("contextDocService", () => { finishedAt: "2026-03-05T09:30:00.000Z", }); }); + + it("repairs stale in-progress generation records", async () => { + const { db, service } = await createFixture(); + + db.setJson("context:docs:generationStatus.v1", { + state: "running", + requestedAt: "2026-03-05T11:40:00.000Z", + startedAt: "2026-03-05T11:40:00.000Z", + finishedAt: "2026-03-05T11:30:00.000Z", + error: null, + source: "auto", + event: "pr_create", + reason: "stale_run", + provider: "unified", + modelId: null, + reasoningEffort: null, + }); + + expect(service.getStatus().generation).toMatchObject({ + state: "failed", + source: "auto", + event: "pr_create", + reason: "stale_run", + provider: "unified", + }); + expect(service.getStatus().generation.error).toContain("did not finish"); + }); }); diff --git a/apps/desktop/src/main/services/context/contextDocService.ts b/apps/desktop/src/main/services/context/contextDocService.ts index b0af15efa..1c5336104 100644 --- a/apps/desktop/src/main/services/context/contextDocService.ts +++ b/apps/desktop/src/main/services/context/contextDocService.ts @@ -49,6 +49,7 @@ type GenerationRunMeta = { const CONTEXT_DOC_PREFS_KEY = "context:docs:preferences.v1"; const CONTEXT_DOC_LAST_RUN_KEY = "context:docs:lastRun.v1"; const CONTEXT_DOC_GENERATION_STATUS_KEY = "context:docs:generationStatus.v1"; +const STALE_GENERATION_TIMEOUT_MS = 5 * 60_000; /** Minimum interval between auto-refresh runs (per event name). */ const AUTO_REFRESH_MIN_INTERVAL_MS: Record = { @@ -121,6 +122,16 @@ function normalizeGenerationSource(value: unknown): ContextDocGenerationSource | return null; } +function pickDefined(value: T | undefined, fallback: T): T { + return value !== undefined ? value : fallback; +} + +function parseIsoMs(value: string | null | undefined): number | null { + if (!value) return null; + const ts = Date.parse(value); + return Number.isFinite(ts) ? ts : null; +} + export function createContextDocService(args: { db: AdeDb; logger: Logger; @@ -155,9 +166,11 @@ export function createContextDocService(args: { aiIntegrationService, }; + let activeGeneration: Promise | null = null; + const buildStatusSnapshot = (): ContextStatus => ({ ...readContextStatusImpl({ db, projectId, projectRoot, packsDir }), - generation: readGenerationStatus(), + generation: reconcileGenerationStatus(), }); const emitStatusChanged = (): void => { @@ -247,6 +260,55 @@ export function createContextDocService(args: { }; }; + const normalizeStaleGenerationStatus = ( + status: ContextDocGenerationStatus, + ): { status: ContextDocGenerationStatus; changed: boolean } => { + if (status.state !== "pending" && status.state !== "running") { + return { status, changed: false }; + } + + const startedAtMs = parseIsoMs(status.startedAt); + const requestedAtMs = parseIsoMs(status.requestedAt); + const baselineMs = startedAtMs ?? requestedAtMs; + if (baselineMs == null) { + return { + status: { + ...status, + state: "failed", + finishedAt: nowIso(), + error: "Context doc generation state was left in progress without timestamps. ADE reset it.", + }, + changed: true, + }; + } + + if (Date.now() - baselineMs <= STALE_GENERATION_TIMEOUT_MS) { + return { status, changed: false }; + } + + return { + status: { + ...status, + state: "failed", + finishedAt: nowIso(), + error: "Previous context doc generation did not finish. ADE reset the stale in-progress state.", + }, + changed: true, + }; + }; + + const reconcileGenerationStatus = (): ContextDocGenerationStatus => { + const current = readGenerationStatus(); + if (activeGeneration && (current.state === "pending" || current.state === "running")) { + return current; + } + const normalized = normalizeStaleGenerationStatus(current); + if (normalized.changed) { + db.setJson(CONTEXT_DOC_GENERATION_STATUS_KEY, normalized.status); + } + return normalized.status; + }; + const writeGenerationStatus = (next: ContextStatus["generation"]): void => { db.setJson(CONTEXT_DOC_GENERATION_STATUS_KEY, next); emitStatusChanged(); @@ -264,21 +326,19 @@ export function createContextDocService(args: { const previous = args.previous ?? readGenerationStatus(); return { state: args.state, - requestedAt: args.requestedAt ?? args.meta?.requestedAt ?? previous.requestedAt ?? null, - startedAt: args.startedAt ?? previous.startedAt ?? null, - finishedAt: args.finishedAt ?? previous.finishedAt ?? null, - error: args.error ?? null, - source: args.meta?.source ?? previous.source ?? null, - event: args.meta?.event ?? previous.event ?? null, - reason: args.meta?.reason ?? previous.reason ?? null, - provider: args.meta?.provider ?? previous.provider ?? null, - modelId: args.meta?.modelId ?? previous.modelId ?? null, - reasoningEffort: args.meta?.reasoningEffort ?? previous.reasoningEffort ?? null, + requestedAt: pickDefined(args.requestedAt, pickDefined(args.meta?.requestedAt, previous.requestedAt ?? null)), + startedAt: pickDefined(args.startedAt, previous.startedAt ?? null), + finishedAt: pickDefined(args.finishedAt, previous.finishedAt ?? null), + error: pickDefined(args.error, null), + source: pickDefined(args.meta?.source, previous.source ?? null), + event: pickDefined(args.meta?.event, previous.event ?? null), + reason: pickDefined(args.meta?.reason, previous.reason ?? null), + provider: pickDefined(args.meta?.provider, previous.provider ?? null), + modelId: pickDefined(args.meta?.modelId, previous.modelId ?? null), + reasoningEffort: pickDefined(args.meta?.reasoningEffort, previous.reasoningEffort ?? null), }; }; - let activeGeneration: Promise | null = null; - const generateDocsInternal = async ( docArgs: ContextGenerateDocsArgs, meta: GenerationRunMeta, @@ -365,13 +425,19 @@ export function createContextDocService(args: { }; const generateDocs = async (docArgs: ContextGenerateDocsArgs): Promise => - await generateDocsInternal(docArgs, { - source: "manual", - reason: "manual_generate", - provider: normalizeContextProvider(docArgs.provider), - modelId: toOptionalString(docArgs.modelId), - reasoningEffort: toOptionalString(docArgs.reasoningEffort), - }); + await (() => { + const modelId = toOptionalString(docArgs.modelId); + if (!modelId) { + throw new Error("Select a model before generating context docs."); + } + return generateDocsInternal(docArgs, { + source: "manual", + reason: "manual_generate", + provider: normalizeContextProvider(docArgs.provider), + modelId, + reasoningEffort: toOptionalString(docArgs.reasoningEffort), + }); + })(); /** * Resolves which events are enabled, merging project config with stored prefs. @@ -441,6 +507,15 @@ export function createContextDocService(args: { return null; } + if (!prefs.modelId) { + settlePendingWithoutRun(); + logger.debug("context_docs.auto_refresh_skipped_missing_model", { + event, + reason: docArgs.reason ?? null, + }); + return null; + } + if (!activeGeneration) { writeGenerationStatus( buildGenerationStatus({ @@ -499,6 +574,8 @@ export function createContextDocService(args: { } }; + reconcileGenerationStatus(); + return { getDocMeta() { return readContextDocMetaImpl(projectRoot); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 1b213a3f9..b9465f515 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -127,6 +127,10 @@ import type { PrIssueResolutionPromptPreviewResult, PrIssueResolutionStartArgs, PrIssueResolutionStartResult, + IssueInventoryItem, + IssueInventorySnapshot, + ConvergenceStatus, + PipelineSettings, RebaseResolutionStartArgs, RebaseResolutionStartResult, LinkPrToLaneArgs, @@ -495,6 +499,7 @@ import type { createGithubService } from "../github/githubService"; 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 { createAgentChatService } from "../chat/agentChatService"; import type { createComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; import { @@ -588,6 +593,7 @@ export type AppContext = { prService: ReturnType; prPollingService: ReturnType; queueLandingService: ReturnType; + issueInventoryService: ReturnType; jobEngine: ReturnType; automationService: ReturnType; automationPlannerService: ReturnType; @@ -1430,17 +1436,21 @@ export function registerIpc({ })(), args: summarizeIpcValue(args), }); - const IPC_TIMEOUT_MS = 30_000; + const IPC_TIMEOUT_MS = channel === IPC.contextGenerateDocs ? null : 30_000; try { - const result = await Promise.race([ - listener(event, ...args), - new Promise((_, reject) => - setTimeout( - () => reject(new Error(`IPC handler for '${channel}' timed out after ${IPC_TIMEOUT_MS}ms (callId=${callId})`)), - IPC_TIMEOUT_MS - ) - ), - ]); + const result = await ( + IPC_TIMEOUT_MS == null + ? listener(event, ...args) + : Promise.race([ + listener(event, ...args), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`IPC handler for '${channel}' timed out after ${IPC_TIMEOUT_MS}ms (callId=${callId})`)), + IPC_TIMEOUT_MS + ) + ), + ]) + ); logger.info("ipc.invoke.done", { callId, channel, @@ -5026,6 +5036,38 @@ export function registerIpc({ ipcMain.handle(IPC.prsRerunChecks, (_e, args) => getCtx().prService.rerunChecks(args)); ipcMain.handle(IPC.prsAiReviewSummary, (_e, args) => getCtx().prService.aiReviewSummary(args)); + // Issue Inventory (PR convergence loop) + ipcMain.handle(IPC.prsIssueInventorySync, async (_e, args: { prId: string }): Promise => { + const ctx = getCtx(); + const [checks, reviewThreads, comments] = await Promise.all([ + ctx.prService.getChecks(args.prId), + ctx.prService.getReviewThreads(args.prId), + ctx.prService.getComments(args.prId).catch(() => []), + ]); + return ctx.issueInventoryService.syncFromPrData(args.prId, checks, reviewThreads, comments); + }); + ipcMain.handle(IPC.prsIssueInventoryGet, (_e, args: { prId: string }): IssueInventorySnapshot => + getCtx().issueInventoryService.getInventory(args.prId)); + ipcMain.handle(IPC.prsIssueInventoryGetNew, (_e, args: { prId: string }): IssueInventoryItem[] => + getCtx().issueInventoryService.getNewItems(args.prId)); + ipcMain.handle(IPC.prsIssueInventoryMarkFixed, (_e, args: { prId: string; itemIds: string[] }): void => + getCtx().issueInventoryService.markFixed(args.prId, args.itemIds)); + ipcMain.handle(IPC.prsIssueInventoryMarkDismissed, (_e, args: { prId: string; itemIds: string[]; reason: string }): void => + getCtx().issueInventoryService.markDismissed(args.prId, args.itemIds, args.reason)); + ipcMain.handle(IPC.prsIssueInventoryMarkEscalated, (_e, args: { prId: string; itemIds: string[] }): void => + getCtx().issueInventoryService.markEscalated(args.prId, args.itemIds)); + ipcMain.handle(IPC.prsIssueInventoryGetConvergence, (_e, args: { prId: string }): ConvergenceStatus => + getCtx().issueInventoryService.getConvergenceStatus(args.prId)); + ipcMain.handle(IPC.prsIssueInventoryReset, (_e, args: { prId: string }): void => + getCtx().issueInventoryService.resetInventory(args.prId)); + + 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 => + getCtx().issueInventoryService.savePipelineSettings(args.prId, args.settings)); + ipcMain.handle(IPC.prsPipelineSettingsDelete, (_e, args: { prId: string }): void => + getCtx().issueInventoryService.deletePipelineSettings(args.prId)); + ipcMain.handle(IPC.rebaseScanNeeds, async () => getCtx().conflictService.scanRebaseNeeds()); ipcMain.handle(IPC.rebaseGetNeed, async (_event, arg) => getCtx().conflictService.getRebaseNeed(arg.laneId)); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index f242be835..1cd4fa241 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -15,6 +15,7 @@ import type { CreateLaneFromUnstagedArgs, DeleteLaneArgs, LaneIcon, + MissionLaneRole, LaneStateSnapshotSummary, LaneStatus, LaneSummary, @@ -53,6 +54,8 @@ type LaneRow = { icon: string | null; tags_json: string | null; folder: string | null; + mission_id: string | null; + lane_role: MissionLaneRole | null; created_at: string; archived_at: string | null; status: string; @@ -162,6 +165,8 @@ function toLaneSummary(args: { icon: parseLaneIcon(row.icon), tags: parseLaneTags(row.tags_json), folder: row.folder, + missionId: row.mission_id, + laneRole: row.lane_role, createdAt: row.created_at, archivedAt: row.archived_at }; @@ -905,6 +910,8 @@ export function createLaneService({ startPoint: string; parentLaneId: string | null; folder?: string; + missionId?: string | null; + laneRole?: MissionLaneRole | null; }): Promise => { const laneId = randomUUID(); const now = new Date().toISOString(); @@ -922,11 +929,24 @@ export function createLaneService({ ` insert into lanes( id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, - attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, folder, status, created_at, archived_at + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, folder, mission_id, lane_role, status, created_at, archived_at ) - values(?, ?, ?, ?, 'worktree', ?, ?, ?, null, 0, ?, null, null, null, ?, 'active', ?, null) + values(?, ?, ?, ?, 'worktree', ?, ?, ?, null, 0, ?, null, null, null, ?, ?, ?, 'active', ?, null) `, - [laneId, projectId, args.name, args.description ?? null, args.baseRef, branchRef, worktreePath, args.parentLaneId, args.folder ?? null, now] + [ + laneId, + projectId, + args.name, + args.description ?? null, + args.baseRef, + branchRef, + worktreePath, + args.parentLaneId, + args.folder ?? null, + args.missionId ?? null, + args.laneRole ?? null, + now + ] ); invalidateLaneListCache(); @@ -1141,10 +1161,34 @@ export function createLaneService({ baseRef: parent.branch_ref, startPoint: parentHeadSha, parentLaneId: parent.id, - folder: args.folder + folder: args.folder, + missionId: args.missionId ?? null, + laneRole: args.laneRole ?? null }); }, + setMissionOwnership(args: { + laneId: string; + missionId?: string | null; + laneRole?: MissionLaneRole | null; + }): void { + const laneId = args.laneId.trim(); + if (!laneId.length) throw new Error("laneId is required."); + const existing = getLaneRow(laneId); + if (!existing) throw new Error(`Lane not found: ${laneId}`); + db.run( + ` + update lanes + set mission_id = ?, + lane_role = ? + where id = ? + and project_id = ? + `, + [args.missionId?.trim() || null, args.laneRole ?? null, laneId, projectId] + ); + invalidateLaneListCache(); + }, + async createFromUnstaged(args: CreateLaneFromUnstagedArgs): Promise { const sourceLaneId = args.sourceLaneId.trim(); const name = args.name.trim(); diff --git a/apps/desktop/src/main/services/missions/missionPreflightService.test.ts b/apps/desktop/src/main/services/missions/missionPreflightService.test.ts index 98e5ee069..26994799e 100644 --- a/apps/desktop/src/main/services/missions/missionPreflightService.test.ts +++ b/apps/desktop/src/main/services/missions/missionPreflightService.test.ts @@ -339,7 +339,7 @@ describe("missionPreflightService", () => { expect(result.canLaunch).toBe(true); }); - it("blocks launch when queue auto-resolve is enabled without a compatible CLI resolver model", async () => { + it("summarizes result-lane closeout for new missions without requiring PR automation", async () => { const profiles = createProfiles(); const service = createMissionPreflightService({ logger: createLogger(), @@ -383,7 +383,7 @@ describe("missionPreflightService", () => { const result = await service.runPreflight({ launch: { - prompt: "Land the queue automatically after implementation.", + prompt: "Land the consolidated implementation in one lane.", phaseProfileId: profiles[0]!.id, phaseOverride: profiles[0]!.phases.map((phase) => ({ ...phase, @@ -399,21 +399,16 @@ describe("missionPreflightService", () => { modelId: "google/gemini-2.5-flash", }, }, - executionPolicy: { - prStrategy: { - kind: "queue", - targetBranch: "main", - autoLand: true, - autoResolveConflicts: true, - ciGating: true, - mergeMethod: "squash", - }, - }, } as any, }); - expect(result.canLaunch).toBe(false); - expect(result.checklist.find((item) => item.id === "capabilities")?.severity).toBe("fail"); + expect(result.canLaunch).toBe(true); + expect(result.checklist.find((item) => item.id === "capabilities")?.severity).toBe("pass"); + expect( + result.approvalSummary?.conflictAssumptions.some((detail) => + detail.includes("result lane") && detail.includes("will not auto-open a PR"), + ), + ).toBe(true); }); it("surfaces computer-use readiness when an external backend satisfies required proof", async () => { diff --git a/apps/desktop/src/main/services/missions/missionPreflightService.ts b/apps/desktop/src/main/services/missions/missionPreflightService.ts index 3f21f9b8c..35dab7970 100644 --- a/apps/desktop/src/main/services/missions/missionPreflightService.ts +++ b/apps/desktop/src/main/services/missions/missionPreflightService.ts @@ -367,35 +367,7 @@ export function createMissionPreflightService(args: { else capabilityWarnings.push(message); } } - - const selectedPrStrategy = launch.executionPolicy?.prStrategy; const activeLanes = await laneService.list({ includeArchived: false }).catch(() => []); - const needsCliConflictResolver = - ( - selectedPrStrategy?.kind === "integration" - && (selectedPrStrategy.prDepth ?? "resolve-conflicts") !== "propose-only" - ) - || ( - selectedPrStrategy?.kind === "queue" - && selectedPrStrategy.autoLand === true - && selectedPrStrategy.autoResolveConflicts === true - ); - if (needsCliConflictResolver) { - const hasCliResolverModel = [ - ...selected.phases.map((phase) => phase.model.modelId), - orchestratorModelId ?? "", - selectedPrStrategy?.kind === "queue" ? selectedPrStrategy.conflictResolverModel ?? "" : "", - ] - .map((modelId) => getModelById(modelId) ?? resolveModelAlias(modelId)) - .some((d) => d?.isCliWrapped && (d.family === "anthropic" || d.family === "openai")); - if (!hasCliResolverModel) { - capabilityIssues.push( - selectedPrStrategy?.kind === "queue" - ? "Queue auto-land is configured to resolve conflicts automatically, but no compatible Claude/Codex CLI resolver model is configured on the mission." - : "Integration finalization is configured to resolve conflicts automatically, but no compatible Claude/Codex CLI resolver model is configured on the mission." - ); - } - } checklist.push( requiredComputerUseKinds.length === 0 @@ -439,8 +411,8 @@ export function createMissionPreflightService(args: { id: "capabilities", severity: "pass", title: "Capability contracts", - summary: "Configured evidence and finalization contracts have matching runtime capabilities.", - details: ["No blocking capability gaps detected in the selected phase or finalization contract."], + summary: "Configured evidence contracts have matching runtime capabilities.", + details: ["No blocking capability gaps detected in the selected phase configuration."], }) : toChecklistItem({ id: "capabilities", @@ -450,7 +422,7 @@ export function createMissionPreflightService(args: { ? "One or more required capabilities are missing for the selected mission contract." : "Some optional capability-backed evidence may require fallback or operator review.", details: [...capabilityIssues, ...capabilityWarnings], - fixHint: "Switch to models that support the required evidence/finalization flow, or relax the phase/finalization contract before launch.", + fixHint: "Switch to models that support the required evidence flow, or relax the phase contract before launch.", }), ); @@ -767,11 +739,7 @@ export function createMissionPreflightService(args: { worktreeItem.severity === "warning" ? "Parallel fan-out may be reduced because available worktrees are below the ideal concurrency target." : "ADE can provision or reuse enough active lanes for the expected worker fan-out.", - selectedPrStrategy?.kind === "queue" - ? "Queue finalization will respect the configured auto-land settings and surface blocked queue steps for operator follow-up." - : selectedPrStrategy?.kind === "integration" - ? "Integration finalization will open or land PRs using the configured PR depth and conflict policy." - : "Mission will complete without a special PR/queue finalization contract.", + "Mission closeout always ends with a result lane that contains the consolidated changes. ADE will not auto-open a PR during mission finalization.", ], knownBlockers: checklist .filter((item) => item.severity === "fail") diff --git a/apps/desktop/src/main/services/missions/missionService.ts b/apps/desktop/src/main/services/missions/missionService.ts index 7b208759a..8c4c7e612 100644 --- a/apps/desktop/src/main/services/missions/missionService.ts +++ b/apps/desktop/src/main/services/missions/missionService.ts @@ -16,6 +16,7 @@ import type { DeletePhaseItemArgs, ExportPhaseItemsArgs, ExportPhaseItemsResult, + GetMissionEventsArgs, ImportPhaseProfileArgs, ImportPhaseItemsArgs, ListPhaseItemsArgs, @@ -31,7 +32,9 @@ import type { MissionArtifact, MissionArtifactType, MissionDetail, + MissionDetailWarning, MissionEvent, + MissionEventsPage, MissionExecutionMode, MissionIntervention, MissionInterventionResolutionKind, @@ -120,12 +123,17 @@ type MissionRow = { prompt: string; lane_id: string | null; lane_name: string | null; + mission_lane_id: string | null; + mission_lane_name: string | null; + result_lane_id: string | null; + result_lane_name: string | null; status: string; priority: string; execution_mode: string; target_machine_id: string | null; outcome_summary: string | null; last_error: string | null; + metadata_json?: string | null; artifact_count: number; open_interventions: number; total_steps: number; @@ -134,6 +142,9 @@ type MissionRow = { updated_at: string; started_at: string | null; completed_at: string | null; + queue_claim_token?: string | null; + queue_claimed_at?: string | null; + archived_at?: string | null; }; type MissionStepRow = { @@ -240,6 +251,10 @@ type CreateMissionInternalArgs = CreateMissionArgs & { plannerPlan?: PlannerPlan | null; }; +const MISSION_EVENTS_PAGE_LIMIT = 200; +const MISSION_QUEUED_CLAIM_STALE_MS = 2 * 60 * 1000; +const MISSION_START_MANUAL_TOKEN = "__manual__"; + function safeParseRecord(raw: string | null): Record | null { const parsed = safeJsonParse(raw, null); return isRecord(parsed) ? parsed : null; @@ -250,6 +265,59 @@ function safeParseArray(raw: string | null): unknown[] { return Array.isArray(parsed) ? parsed : []; } +function quoteIdentifier(value: string): string { + return `"${value.replace(/"/g, "\"\"")}"`; +} + +function parseMissionCursor(cursor: string | null | undefined): { createdAt: string; id: string } | null { + if (typeof cursor !== "string") return null; + const trimmed = cursor.trim(); + if (!trimmed.length) return null; + const separator = trimmed.indexOf("::"); + if (separator <= 0 || separator === trimmed.length - 2) return null; + return { + createdAt: trimmed.slice(0, separator), + id: trimmed.slice(separator + 2), + }; +} + +function encodeMissionCursor(event: MissionEvent | MissionEventRow): string { + return `${"createdAt" in event ? event.createdAt : event.created_at}::${event.id}`; +} + +function parseRecordWithWarning( + raw: string | null, + warning: Omit +): { value: Record | null; warning: MissionDetailWarning | null } { + if (typeof raw !== "string" || raw.trim().length === 0) { + return { value: null, warning: null }; + } + try { + const parsed = JSON.parse(raw); + if (isRecord(parsed)) { + return { value: parsed, warning: null }; + } + return { + value: null, + warning: { + ...warning, + code: "invalid_json", + message: "Stored JSON did not decode to an object.", + } + }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { + value: null, + warning: { + ...warning, + code: "invalid_json", + message: `Stored JSON could not be parsed: ${detail}`, + } + }; + } +} + function coerceNumber(value: unknown): number | undefined { if (value == null) return undefined; const n = Number(value); @@ -430,11 +498,14 @@ function normalizePhaseCards(phases: PhaseCard[]): PhaseCard[] { const planningPhase = phaseKey === "planning"; const testingOrValidation = phaseKey === "testing" || phaseKey === "validation"; const askQuestionsEnabled = planningPhase && phase.askQuestions.enabled !== false; + const rawMaxQuestions = phase.askQuestions.maxQuestions; const askQuestions: PhaseCard["askQuestions"] = { ...phase.askQuestions, enabled: askQuestionsEnabled, maxQuestions: askQuestionsEnabled - ? Math.max(1, Math.min(10, Number(phase.askQuestions.maxQuestions ?? 5) || 5)) + ? rawMaxQuestions == null + ? null + : Math.max(1, Math.min(10, Number(rawMaxQuestions) || 5)) : undefined, }; const validationGate: PhaseCard["validationGate"] = planningPhase || phaseKey === "development" @@ -599,12 +670,6 @@ function coerceNullableString(value: unknown): string | null { return trimmed.length ? trimmed : null; } -function truncateForMetadata(value: string | null, maxChars = 120_000): string | null { - if (!value) return null; - if (value.length <= maxChars) return value; - return `${value.slice(0, maxChars)}\n...`; -} - function normalizeMissionComputerUse(value: unknown) { return normalizeComputerUsePolicy(value, createDefaultComputerUsePolicy()); } @@ -618,6 +683,10 @@ function toMissionSummary(row: MissionRow): MissionSummary { prompt: row.prompt, laneId: row.lane_id, laneName: row.lane_name, + missionLaneId: row.mission_lane_id ?? null, + missionLaneName: row.mission_lane_name ?? null, + resultLaneId: row.result_lane_id ?? null, + resultLaneName: row.result_lane_name ?? null, status: normalizeMissionStatus(row.status), priority: normalizeMissionPriority(row.priority), executionMode: normalizeExecutionMode(row.execution_mode), @@ -653,34 +722,6 @@ function toMissionStep(row: MissionStepRow): MissionStep { }; } -function toMissionEvent(row: MissionEventRow): MissionEvent { - return { - id: row.id, - missionId: row.mission_id, - eventType: row.event_type, - actor: row.actor, - summary: row.summary, - payload: safeParseRecord(row.payload_json), - createdAt: row.created_at - }; -} - -function toMissionArtifact(row: MissionArtifactRow): MissionArtifact { - return { - id: row.id, - missionId: row.mission_id, - artifactType: normalizeArtifactType(row.artifact_type), - title: row.title, - description: row.description, - uri: row.uri, - laneId: row.lane_id, - createdBy: row.created_by, - createdAt: row.created_at, - updatedAt: row.updated_at, - metadata: safeParseRecord(row.metadata_json) - }; -} - function toMissionIntervention(row: MissionInterventionRow): MissionIntervention { const resolutionKindRaw = typeof row.resolution_kind === "string" ? row.resolution_kind.trim() : ""; const resolutionKind: MissionInterventionResolutionKind | null = @@ -749,18 +790,120 @@ export function createMissionService({ const ensureMissionSchemaCompatibility = () => { const missionColumns = db.all<{ name: string }>("pragma table_info(missions)"); - const hasArchivedAt = missionColumns.some((column) => column.name === "archived_at"); - if (!hasArchivedAt) { - db.run("alter table missions add column archived_at text"); - } + const laneColumns = db.all<{ name: string }>("pragma table_info(lanes)"); + const missionColumnNames = new Set(missionColumns.map((column) => column.name)); + const laneColumnNames = new Set(laneColumns.map((column) => column.name)); + if (!missionColumnNames.has("archived_at")) db.run("alter table missions add column archived_at text"); + if (!missionColumnNames.has("mission_lane_id")) db.run("alter table missions add column mission_lane_id text"); + if (!missionColumnNames.has("result_lane_id")) db.run("alter table missions add column result_lane_id text"); + if (!missionColumnNames.has("queue_claim_token")) db.run("alter table missions add column queue_claim_token text"); + if (!missionColumnNames.has("queue_claimed_at")) db.run("alter table missions add column queue_claimed_at text"); + if (!laneColumnNames.has("mission_id")) db.run("alter table lanes add column mission_id text"); + if (!laneColumnNames.has("lane_role")) db.run("alter table lanes add column lane_role text"); + db.run("create index if not exists idx_missions_project_mission_lane on missions(project_id, mission_lane_id)"); + db.run("create index if not exists idx_missions_project_result_lane on missions(project_id, result_lane_id)"); + db.run("drop index if exists idx_missions_queue_claim_token"); + db.run("create index if not exists idx_missions_project_queue_claim on missions(project_id, queue_claim_token)"); + db.run("create index if not exists idx_lanes_project_mission on lanes(project_id, mission_id)"); + db.run("create index if not exists idx_lanes_project_role on lanes(project_id, lane_role)"); }; ensureMissionSchemaCompatibility(); + const rebuildTable = (tableName: string, createSql: string) => { + const columns = db.all<{ name: string }>(`pragma table_info('${tableName.replace(/'/g, "''")}')`); + if (!columns.length) return; + const indexes = db.all<{ sql: string | null }>( + ` + select sql + from sqlite_master + where type = 'index' + and tbl_name = ? + and sql is not null + order by name asc + `, + [tableName] + ); + const backupName = `__ade_mission_fk_${tableName}`; + const columnsSql = columns.map((column) => quoteIdentifier(column.name)).join(", "); + db.run("pragma foreign_keys = off"); + db.run("begin immediate"); + try { + db.run(`alter table ${quoteIdentifier(tableName)} rename to ${quoteIdentifier(backupName)}`); + db.run(createSql); + db.run( + `insert into ${quoteIdentifier(tableName)} (${columnsSql}) select ${columnsSql} from ${quoteIdentifier(backupName)}` + ); + for (const index of indexes) { + if (typeof index.sql === "string" && index.sql.trim().length > 0) { + db.run(index.sql); + } + } + db.run(`drop table ${quoteIdentifier(backupName)}`); + db.run("commit"); + } catch (error) { + try { + db.run("rollback"); + } catch { + // Ignore rollback failures after a failed rebuild. + } + throw error; + } finally { + db.run("pragma foreign_keys = on"); + } + }; + + const ensureMissionCascadeForeignKeys = () => { + const missionOwnedTables = [ + "mission_steps", + "mission_events", + "mission_artifacts", + "mission_interventions", + "mission_phase_overrides", + "orchestrator_runs", + "mission_step_handoffs", + "orchestrator_chat_threads", + "orchestrator_chat_messages", + "orchestrator_worker_digests", + "orchestrator_artifacts", + "orchestrator_context_checkpoints", + "orchestrator_worker_checkpoints", + "orchestrator_lane_decisions", + "orchestrator_ai_decisions", + "mission_metrics_config", + "orchestrator_metrics_samples", + "orchestrator_team_members", + ]; + for (const tableName of missionOwnedTables) { + const foreignKeys = db.all<{ from: string; on_delete: string }>( + `pragma foreign_key_list('${tableName.replace(/'/g, "''")}')` + ); + const missionForeignKey = foreignKeys.find((row) => row.from === "mission_id"); + if (!missionForeignKey || String(missionForeignKey.on_delete ?? "").toUpperCase() === "CASCADE") { + continue; + } + const createRow = db.get<{ sql: string | null }>( + "select sql from sqlite_master where type = 'table' and name = ? limit 1", + [tableName] + ); + const createSql = createRow?.sql?.replace( + /foreign key\s*\(\s*mission_id\s*\)\s*references\s+missions\s*\(\s*id\s*\)/i, + "foreign key(mission_id) references missions(id) on delete cascade" + ); + if (!createSql) continue; + rebuildTable(tableName, createSql); + } + }; + + ensureMissionCascadeForeignKeys(); + // Late-bound reference to the service object for use in internal helpers. // Assigned after the return object is created. Uses a minimal interface // to avoid circular type dependency. - let serviceRef: { processQueue(): string[] } | null = null; + let serviceRef: { + processQueue(): string[]; + isLaneClaimed(laneId: string, excludeMissionId?: string): MissionLaneClaimCheckResult; + } | null = null; const emit = (payload: Omit) => { try { @@ -785,19 +928,228 @@ export function createMissionService({ } }; + const getMissionLaunchLaneConflict = (laneId: string, excludeMissionId?: string): string | null => { + const lane = db.get<{ + id: string; + status: string; + archived_at: string | null; + mission_id: string | null; + lane_role: string | null; + rebase_in_progress: number | null; + }>( + ` + select + l.id, + l.status, + l.archived_at, + l.mission_id, + l.lane_role, + coalesce(s.rebase_in_progress, 0) as rebase_in_progress + from lanes l + left join lane_state_snapshots s on s.lane_id = l.id + where l.id = ? + and l.project_id = ? + limit 1 + `, + [laneId, projectId] + ); + if (!lane?.id) return `Lane not found: ${laneId}`; + if (lane.archived_at || lane.status === "archived") return "Lane is archived."; + if (Number(lane.rebase_in_progress ?? 0) === 1) return "Lane is currently rebasing."; + if (lane.mission_id && lane.mission_id !== excludeMissionId) { + return "Lane is already owned by another mission."; + } + const existingResultMission = db.get<{ id: string }>( + ` + select id + from missions + where project_id = ? + and result_lane_id = ? + and archived_at is null + and (? is null or id != ?) + limit 1 + `, + [projectId, laneId, excludeMissionId ?? null, excludeMissionId ?? null] + ); + if (existingResultMission?.id) { + return "Lane is already assigned as another mission's result lane."; + } + return null; + }; + + const assertMissionLaunchLaneAvailable = (laneId: string | null | undefined, excludeMissionId?: string) => { + if (!laneId) return; + const conflict = getMissionLaunchLaneConflict(laneId, excludeMissionId); + if (conflict) { + throw new Error(conflict); + } + }; + + const claimQueuedMissionStart = (missionId: string): string | null => { + const staleBefore = new Date(Date.now() - MISSION_QUEUED_CLAIM_STALE_MS).toISOString(); + db.run("begin immediate"); + try { + const row = db.get<{ + status: string; + lane_id: string | null; + queue_claim_token: string | null; + queue_claimed_at: string | null; + metadata_json: string | null; + }>( + ` + select status, lane_id, queue_claim_token, queue_claimed_at, metadata_json + from missions + where id = ? + and project_id = ? + and archived_at is null + limit 1 + `, + [missionId, projectId] + ); + if (!row || normalizeMissionStatus(row.status) !== "queued") { + db.run("commit"); + return null; + } + const launchMetadata = parseRecordWithWarning(row.metadata_json, { + source: "mission", + field: "metadata_json", + recordId: missionId, + }).value; + const launch = launchMetadata && isRecord(launchMetadata.launch) ? launchMetadata.launch : null; + if (launch && launch.autostart === false) { + db.run("commit"); + return null; + } + const existingClaimIsFresh = + typeof row.queue_claim_token === "string" + && row.queue_claim_token.trim().length > 0 + && typeof row.queue_claimed_at === "string" + && row.queue_claimed_at >= staleBefore; + if (existingClaimIsFresh) { + db.run("commit"); + return null; + } + if (activeConcurrencyConfig.laneExclusivity && row.lane_id) { + const laneConflict = getMissionLaunchLaneConflict(row.lane_id, missionId); + if (laneConflict || serviceRef?.isLaneClaimed(row.lane_id, missionId).claimed) { + db.run("commit"); + return null; + } + } + const token = randomUUID(); + const claimedAt = nowIso(); + db.run( + ` + update missions + set queue_claim_token = ?, + queue_claimed_at = ?, + updated_at = ? + where id = ? + and project_id = ? + `, + [token, claimedAt, claimedAt, missionId, projectId] + ); + db.run("commit"); + return token; + } catch (error) { + try { + db.run("rollback"); + } catch { + // Ignore rollback failures after failed queue claim attempts. + } + throw error; + } + }; + + const clearMissionQueueClaim = (missionId: string, claimToken?: string | null): void => { + const normalizedToken = typeof claimToken === "string" ? claimToken.trim() : ""; + const params: Array = [nowIso(), missionId, projectId]; + let tokenClause = ""; + if (normalizedToken.length) { + tokenClause = "and queue_claim_token = ?"; + params.push(normalizedToken); + } + db.run( + ` + update missions + set queue_claim_token = null, + queue_claimed_at = null, + updated_at = ? + where id = ? + and project_id = ? + ${tokenClause} + `, + params + ); + }; + + const beginMissionStart = (missionId: string, claimToken?: string | null): boolean => { + const startedAt = nowIso(); + const normalizedToken = typeof claimToken === "string" ? claimToken.trim() : ""; + if (normalizedToken.length && normalizedToken !== MISSION_START_MANUAL_TOKEN) { + db.run( + ` + update missions + set status = 'planning', + queue_claim_token = null, + queue_claimed_at = null, + started_at = coalesce(started_at, ?), + updated_at = ? + where id = ? + and project_id = ? + and status = 'queued' + and archived_at is null + and queue_claim_token = ? + `, + [startedAt, startedAt, missionId, projectId, normalizedToken] + ); + } else { + db.run( + ` + update missions + set status = 'planning', + started_at = coalesce(started_at, ?), + updated_at = ? + where id = ? + and project_id = ? + and status = 'queued' + and archived_at is null + and queue_claim_token is null + `, + [startedAt, startedAt, missionId, projectId] + ); + } + const row = db.get<{ status: string; queue_claim_token: string | null }>( + ` + select status, queue_claim_token + from missions + where id = ? + and project_id = ? + limit 1 + `, + [missionId, projectId] + ); + return normalizeMissionStatus(row?.status ?? "") === "planning" && row?.queue_claim_token == null; + }; + const baseMissionSelect = ` select m.id as id, m.title as title, m.prompt as prompt, m.lane_id as lane_id, - l.name as lane_name, + launch_lane.name as lane_name, + m.mission_lane_id as mission_lane_id, + mission_lane.name as mission_lane_name, + m.result_lane_id as result_lane_id, + result_lane.name as result_lane_name, m.status as status, m.priority as priority, m.execution_mode as execution_mode, m.target_machine_id as target_machine_id, m.outcome_summary as outcome_summary, m.last_error as last_error, + m.metadata_json as metadata_json, ( select count(*) from mission_artifacts ma @@ -821,9 +1173,14 @@ export function createMissionService({ m.created_at as created_at, m.updated_at as updated_at, m.started_at as started_at, - m.completed_at as completed_at + m.completed_at as completed_at, + m.archived_at as archived_at, + m.queue_claim_token as queue_claim_token, + m.queue_claimed_at as queue_claimed_at from missions m - left join lanes l on l.id = m.lane_id + left join lanes launch_lane on launch_lane.id = m.lane_id + left join lanes mission_lane on mission_lane.id = m.mission_lane_id + left join lanes result_lane on result_lane.id = m.result_lane_id where m.project_id = ? `; @@ -1753,12 +2110,86 @@ export function createMissionService({ return rows.map(toMissionSummary); }, + listEvents(args: GetMissionEventsArgs): MissionEventsPage { + const missionId = String(args.missionId ?? "").trim(); + if (!missionId.length) { + throw new Error("missionId is required."); + } + if (!getMissionRow(missionId)) { + throw new Error(`Mission not found: ${missionId}`); + } + const requestedLimit = Number.isFinite(args.limit) + ? Math.max(1, Math.min(500, Math.floor(args.limit ?? MISSION_EVENTS_PAGE_LIMIT))) + : MISSION_EVENTS_PAGE_LIMIT; + const fetchLimit = requestedLimit + 1; + const before = parseMissionCursor(args.before ?? null); + const params: Array = [projectId, missionId]; + let beforeClause = ""; + if (before) { + beforeClause = ` + and ( + created_at < ? + or (created_at = ? and id < ?) + ) + `; + params.push(before.createdAt, before.createdAt, before.id); + } + params.push(fetchLimit); + const rows = db.all( + ` + select + id, + mission_id, + event_type, + actor, + summary, + payload_json, + created_at + from mission_events + where project_id = ? + and mission_id = ? + ${beforeClause} + order by created_at desc, id desc + limit ? + `, + params + ); + const hasMore = rows.length > requestedLimit; + const pageRows = hasMore ? rows.slice(0, requestedLimit) : rows; + const warnings: MissionDetailWarning[] = []; + const events = pageRows.map((row) => { + const parsedPayload = parseRecordWithWarning(row.payload_json, { + source: "event", + field: "payload_json", + recordId: row.id, + }); + if (parsedPayload.warning) warnings.push(parsedPayload.warning); + return { + id: row.id, + missionId: row.mission_id, + eventType: row.event_type, + actor: row.actor, + summary: row.summary, + payload: parsedPayload.value, + createdAt: row.created_at, + } satisfies MissionEvent; + }); + return { + missionId, + events, + nextCursor: hasMore && pageRows.length > 0 ? encodeMissionCursor(pageRows[pageRows.length - 1]) : null, + hasMore, + warnings, + }; + }, + get(missionId: string): MissionDetail | null { const id = missionId.trim(); if (!id.length) return null; const row = getMissionRow(id); if (!row) return null; + const warnings: MissionDetailWarning[] = []; const steps = db .all( @@ -1784,28 +2215,41 @@ export function createMissionService({ `, [projectId, id] ) - .map(toMissionStep); + .map((stepRow) => { + const metadata = parseRecordWithWarning(stepRow.metadata_json, { + source: "step", + field: "metadata_json", + recordId: stepRow.id, + }); + if (metadata.warning) warnings.push(metadata.warning); + return { + id: stepRow.id, + missionId: stepRow.mission_id, + index: Number(stepRow.step_index ?? 0), + title: stepRow.title, + detail: stepRow.detail, + kind: stepRow.kind, + laneId: stepRow.lane_id, + status: normalizeStepStatus(stepRow.status), + createdAt: stepRow.created_at, + updatedAt: stepRow.updated_at, + startedAt: stepRow.started_at, + completedAt: stepRow.completed_at, + metadata: metadata.value, + } satisfies MissionStep; + }); - const events = db - .all( - ` - select - id, - mission_id, - event_type, - actor, - summary, - payload_json, - created_at - from mission_events - where project_id = ? - and mission_id = ? - order by created_at desc - limit 500 - `, - [projectId, id] - ) - .map(toMissionEvent); + const eventPage = this.listEvents({ missionId: id, limit: MISSION_EVENTS_PAGE_LIMIT }); + warnings.push(...eventPage.warnings); + if (eventPage.hasMore) { + warnings.push({ + code: "truncated_events", + source: "event", + field: "events", + recordId: id, + message: `Showing the newest ${MISSION_EVENTS_PAGE_LIMIT} mission events. Load more to inspect older history.`, + }); + } const artifacts = db .all( @@ -1829,7 +2273,27 @@ export function createMissionService({ `, [projectId, id] ) - .map(toMissionArtifact); + .map((artifactRow) => { + const metadata = parseRecordWithWarning(artifactRow.metadata_json, { + source: "artifact", + field: "metadata_json", + recordId: artifactRow.id, + }); + if (metadata.warning) warnings.push(metadata.warning); + return { + id: artifactRow.id, + missionId: artifactRow.mission_id, + artifactType: normalizeArtifactType(artifactRow.artifact_type), + title: artifactRow.title, + description: artifactRow.description, + uri: artifactRow.uri, + laneId: artifactRow.lane_id, + createdBy: artifactRow.created_by, + createdAt: artifactRow.created_at, + updatedAt: artifactRow.updated_at, + metadata: metadata.value, + } satisfies MissionArtifact; + }); const interventions = db .all( @@ -1858,22 +2322,59 @@ export function createMissionService({ `, [projectId, id] ) - .map(toMissionIntervention); + .map((interventionRow) => { + const metadata = parseRecordWithWarning(interventionRow.metadata_json, { + source: "intervention", + field: "metadata_json", + recordId: interventionRow.id, + }); + if (metadata.warning) warnings.push(metadata.warning); + const resolutionKindRaw = typeof interventionRow.resolution_kind === "string" + ? interventionRow.resolution_kind.trim() + : ""; + return { + id: interventionRow.id, + missionId: interventionRow.mission_id, + interventionType: normalizeInterventionType(interventionRow.intervention_type), + status: normalizeInterventionStatus(interventionRow.status), + resolutionKind: isValidResolutionKind(resolutionKindRaw) ? resolutionKindRaw : null, + title: interventionRow.title, + body: interventionRow.body, + requestedAction: interventionRow.requested_action, + resolutionNote: interventionRow.resolution_note, + laneId: interventionRow.lane_id, + createdAt: interventionRow.created_at, + updatedAt: interventionRow.updated_at, + resolvedAt: interventionRow.resolved_at, + metadata: metadata.value, + } satisfies MissionIntervention; + }); - const metadata = safeParseRecord( + const metadata = parseRecordWithWarning( db.get<{ metadata_json: string | null }>( `select metadata_json from missions where id = ? and project_id = ? limit 1`, [id, projectId] - )?.metadata_json ?? null + )?.metadata_json ?? null, + { + source: "mission", + field: "metadata_json", + recordId: id, + } ); - const launchMetadata = isRecord(metadata?.launch) ? metadata.launch : null; + if (metadata.warning) warnings.push(metadata.warning); + const launchMetadata = isRecord(metadata.value?.launch) ? metadata.value.launch : null; + const plannerPlan = isRecord(metadata.value?.plannerPlan) + ? (metadata.value.plannerPlan as PlannerPlan) + : null; return { ...toMissionSummary(row), steps, - events, + events: eventPage.events, artifacts, interventions, + warnings, + plannerPlan, phaseConfiguration: resolveMissionPhaseConfiguration(id), computerUse: launchMetadata ? normalizeMissionComputerUse(launchMetadata.computerUse) : null, }; @@ -2276,9 +2777,65 @@ export function createMissionService({ getDashboard(): MissionDashboardSnapshot { ensurePhaseStorageSeeded(); const activeMissions = this.list({ status: "active", limit: 50 }); + const activeMissionIds = activeMissions.map((mission) => mission.id); + const phaseGroupsByMission = new Map>(); + const activeWorkerCounts = new Map(); + + if (activeMissionIds.length > 0) { + const placeholders = activeMissionIds.map(() => "?").join(", "); + const stepRows = db.all( + ` + select + id, + mission_id, + step_index, + title, + detail, + kind, + lane_id, + status, + created_at, + updated_at, + started_at, + completed_at, + metadata_json + from mission_steps + where project_id = ? + and mission_id in (${placeholders}) + order by mission_id asc, step_index asc + `, + [projectId, ...activeMissionIds] + ); + const stepsByMission = new Map(); + for (const stepRow of stepRows) { + const bucket = stepsByMission.get(stepRow.mission_id) ?? []; + bucket.push(toMissionStep(stepRow)); + stepsByMission.set(stepRow.mission_id, bucket); + } + for (const missionId of activeMissionIds) { + phaseGroupsByMission.set(missionId, groupMissionStepsByPhase(stepsByMission.get(missionId) ?? [])); + } + + const workerRows = db.all<{ mission_id: string; count: number }>( + ` + select r.mission_id as mission_id, count(distinct oa.id) as count + from orchestrator_attempts oa + inner join orchestrator_runs r on r.id = oa.run_id + where oa.project_id = ? + and r.project_id = ? + and r.mission_id in (${placeholders}) + and oa.status = 'running' + group by r.mission_id + `, + [projectId, projectId, ...activeMissionIds] + ); + for (const row of workerRows) { + activeWorkerCounts.set(row.mission_id, Number(row.count ?? 0)); + } + } + const active = activeMissions.map((mission) => { - const detail = this.get(mission.id); - const phaseGroups = groupMissionStepsByPhase(detail?.steps ?? []); + const phaseGroups = phaseGroupsByMission.get(mission.id) ?? []; const currentPhase = phaseGroups.find((group) => group.completed < group.total) ?? phaseGroups[phaseGroups.length - 1] ?? null; const phaseProgress = currentPhase ? { @@ -2287,22 +2844,7 @@ export function createMissionService({ pct: currentPhase.total > 0 ? Math.round((currentPhase.completed / currentPhase.total) * 100) : 0 } : { completed: 0, total: 0, pct: 0 }; - - const activeWorkers = db.get<{ count: number }>( - ` - select count(distinct oa.id) as count - from orchestrator_attempts oa - where oa.project_id = ? - and oa.status = 'running' - and oa.run_id in ( - select id - from orchestrator_runs - where project_id = ? - and mission_id = ? - ) - `, - [projectId, projectId, mission.id] - )?.count ?? 0; + const activeWorkers = activeWorkerCounts.get(mission.id) ?? 0; const startedAtMs = mission.startedAt ? Date.parse(mission.startedAt) : NaN; const elapsedMs = Number.isFinite(startedAtMs) ? Math.max(0, Date.now() - startedAtMs) : 0; @@ -2422,6 +2964,7 @@ export function createMissionService({ const title = deriveMissionTitle(prompt, args.title); const laneId = coerceNullableString(args.laneId); assertLaneExists(laneId); + assertMissionLaunchLaneAvailable(laneId); const priority = args.priority ?? "normal"; const executionMode = args.executionMode ?? "local"; const targetMachineId = coerceNullableString(args.targetMachineId); @@ -2455,13 +2998,22 @@ export function createMissionService({ const executionPolicyArg = args.executionPolicy && typeof args.executionPolicy === "object" ? (args.executionPolicy as Partial) : null; + const storedExecutionPolicy = executionPolicyArg + ? { + ...executionPolicyArg, + prStrategy: undefined, + finalizationPolicyKind: "result_lane" as const, + } + : { + finalizationPolicyKind: "result_lane" as const, + }; // Build mission-level settings from new args fields const missionLevelSettings: import("../../../shared/types").MissionLevelSettings = { ...(args.recoveryLoop ? { recoveryLoop: args.recoveryLoop } : {}), - ...(executionPolicyArg?.prStrategy ? { prStrategy: executionPolicyArg.prStrategy } : {}), ...(executionPolicyArg?.integrationPr ? { integrationPr: executionPolicyArg.integrationPr } : {}), ...(executionPolicyArg?.teamRuntime ? { teamRuntime: executionPolicyArg.teamRuntime } : {}), + finalizationPolicyKind: "result_lane", }; ensurePhaseStorageSeeded(); @@ -2521,7 +3073,7 @@ export function createMissionService({ const createdAt = nowIso(); const missionMetadata = { source: "manual", - version: 2, + version: 3, launch: { autostart, runMode: launchMode, @@ -2536,8 +3088,12 @@ export function createMissionService({ phaseProfileId: selectedProfile?.id ?? null, hasPhaseOverride: hasExplicitOverride }, - ...(executionPolicyArg ? { executionPolicy: executionPolicyArg } : {}), + executionPolicy: storedExecutionPolicy, missionLevelSettings, + finalization: { + kind: "result_lane", + resultLanePolicy: "single_visible_lane", + }, phaseConfiguration: { profileId: selectedProfile?.id ?? null, phaseKeys: selectedPhases.map((phase) => phase.phaseKey), @@ -2748,6 +3304,7 @@ export function createMissionService({ const nextLaneId = args.laneId !== undefined ? coerceNullableString(args.laneId) : existing.lane_id; assertLaneExists(nextLaneId); + assertMissionLaunchLaneAvailable(nextLaneId, missionId); const nextPrompt = args.prompt !== undefined ? normalizePrompt(args.prompt) : existing.prompt; if (!nextPrompt.length) throw new Error("Mission prompt cannot be empty."); @@ -2881,6 +3438,58 @@ export function createMissionService({ db.flushNow(); }, + beginStart(missionId: string, claimToken?: string | null): boolean { + const id = missionId.trim(); + if (!id.length) throw new Error("Mission id is required."); + if (!getMissionRow(id)) throw new Error(`Mission not found: ${id}`); + return beginMissionStart(id, claimToken ?? null); + }, + + clearQueueClaim(missionId: string, claimToken?: string | null): void { + const id = missionId.trim(); + if (!id.length) throw new Error("Mission id is required."); + if (!getMissionRow(id)) throw new Error(`Mission not found: ${id}`); + clearMissionQueueClaim(id, claimToken ?? null); + }, + + setMissionLane(args: { missionId: string; laneId: string | null }): void { + const missionId = args.missionId.trim(); + if (!missionId.length) throw new Error("missionId is required."); + if (!getMissionRow(missionId)) throw new Error(`Mission not found: ${missionId}`); + const laneId = coerceNullableString(args.laneId); + assertLaneExists(laneId); + db.run( + ` + update missions + set mission_lane_id = ?, + updated_at = ? + where id = ? + and project_id = ? + `, + [laneId, nowIso(), missionId, projectId] + ); + emit({ missionId, reason: "mission-lane-updated" }); + }, + + setResultLane(args: { missionId: string; laneId: string | null }): void { + const missionId = args.missionId.trim(); + if (!missionId.length) throw new Error("missionId is required."); + if (!getMissionRow(missionId)) throw new Error(`Mission not found: ${missionId}`); + const laneId = coerceNullableString(args.laneId); + assertLaneExists(laneId); + db.run( + ` + update missions + set result_lane_id = ?, + updated_at = ? + where id = ? + and project_id = ? + `, + [laneId, nowIso(), missionId, projectId] + ); + emit({ missionId, reason: "result-lane-updated" }); + }, + archive(args: ArchiveMissionArgs): void { const missionId = args.missionId.trim(); if (!missionId.length) throw new Error("missionId is required."); @@ -3688,55 +4297,119 @@ export function createMissionService({ isLaneClaimed(laneId: string, excludeMissionId?: string): MissionLaneClaimCheckResult { if (!activeConcurrencyConfig.laneExclusivity) return { claimed: false }; if (!laneId) return { claimed: false }; + const laneConflict = getMissionLaunchLaneConflict(laneId, excludeMissionId); + if (laneConflict) { + const laneOwner = db.get<{ mission_id: string | null }>( + `select mission_id from lanes where id = ? and project_id = ? limit 1`, + [laneId, projectId] + ); + const resultOwner = db.get<{ id: string }>( + ` + select id + from missions + where project_id = ? + and result_lane_id = ? + and archived_at is null + and (? is null or id != ?) + limit 1 + `, + [projectId, laneId, excludeMissionId ?? null, excludeMissionId ?? null] + ); + return { + claimed: true, + byMissionId: laneOwner?.mission_id ?? resultOwner?.id, + reason: laneConflict, + }; + } const activeMissions = this.list({ status: "active" }) .filter(m => ACTIVE_MISSION_STATUSES.has(m.status) && m.id !== excludeMissionId); for (const mission of activeMissions) { - if (mission.laneId === laneId) return { claimed: true, byMissionId: mission.id }; + if ( + mission.laneId === laneId + || mission.missionLaneId === laneId + || mission.resultLaneId === laneId + ) { + return { claimed: true, byMissionId: mission.id, reason: "Lane is already assigned to another active mission." }; + } const detail = this.get(mission.id); if (detail) { const hasRunningStepOnLane = detail.steps.some( s => s.laneId === laneId && s.status === "running" ); - if (hasRunningStepOnLane) return { claimed: true, byMissionId: mission.id }; + if (hasRunningStepOnLane) { + return { claimed: true, byMissionId: mission.id, reason: "Lane has an active mission step running on it." }; + } } } return { claimed: false }; }, + consumeQueueClaim(missionId: string, claimToken?: string | null): boolean { + const id = missionId.trim(); + if (!id.length) throw new Error("Mission id is required."); + if (!getMissionRow(id)) throw new Error(`Mission not found: ${id}`); + const before = db.get<{ queue_claim_token: string | null }>( + `select queue_claim_token from missions where id = ? and project_id = ? limit 1`, + [id, projectId] + ); + clearMissionQueueClaim(id, claimToken ?? null); + const after = db.get<{ queue_claim_token: string | null }>( + `select queue_claim_token from missions where id = ? and project_id = ? limit 1`, + [id, projectId] + ); + return before?.queue_claim_token !== after?.queue_claim_token; + }, + processQueue(): string[] { const started: string[] = []; - const queuedMissions = this.list({}) - .filter(m => m.status === "queued") - .sort((a, b) => - (PRIORITY_ORDER[a.priority] ?? 2) - (PRIORITY_ORDER[b.priority] ?? 2) - || new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - ); - for (const mission of queuedMissions) { - const detail = this.get(mission.id); - const metadata = detail - ? safeParseRecord( - db.get<{ metadata_json: string | null }>( - "select metadata_json from missions where id = ? and project_id = ? limit 1", - [mission.id, projectId] - )?.metadata_json ?? null - ) - : null; + const queuedMissions = db.all( + `${baseMissionSelect} + and m.archived_at is null + and m.status = 'queued' + order by + case m.priority + when 'urgent' then 0 + when 'high' then 1 + when 'normal' then 2 + else 3 + end, + m.created_at asc`, + [projectId] + ); + for (const row of queuedMissions) { + const mission = toMissionSummary(row); + const metadata = safeParseRecord(row.metadata_json ?? null); const launch = metadata && isRecord(metadata.launch) ? metadata.launch : null; if (launch && launch.autostart === false) continue; + const currentClaimToken = typeof row.queue_claim_token === "string" ? row.queue_claim_token.trim() : ""; + const currentClaimedAt = typeof row.queue_claimed_at === "string" ? row.queue_claimed_at.trim() : ""; + if (currentClaimToken.length > 0) { + const claimedAtMs = Date.parse(currentClaimedAt); + if (Number.isFinite(claimedAtMs) && Date.now() - claimedAtMs < MISSION_QUEUED_CLAIM_STALE_MS) { + continue; + } + clearMissionQueueClaim(mission.id, currentClaimToken); + } const check = this.canStartMission(mission.id); if (!check.allowed) break; if (activeConcurrencyConfig.laneExclusivity && mission.laneId) { + const laneConflict = getMissionLaunchLaneConflict(mission.laneId, mission.id); + if (laneConflict) continue; const laneClaim = this.isLaneClaimed(mission.laneId, mission.id); if (laneClaim.claimed) continue; } + const claimToken = claimQueuedMissionStart(mission.id); + if (!claimToken) { + continue; + } recordEvent({ missionId: mission.id, eventType: "mission_ready_to_start", actor: "system", summary: "Mission eligible to start after concurrency slot opened.", - payload: { queuePosition: 1 } + payload: { queuePosition: 1, claimToken } }); - emit({ missionId: mission.id, reason: "ready_to_start" }); + emit({ missionId: mission.id, reason: "ready_to_start", claimToken }); started.push(mission.id); } return started; diff --git a/apps/desktop/src/main/services/missions/phaseEngine.ts b/apps/desktop/src/main/services/missions/phaseEngine.ts index af87130a8..ed9b7deb7 100644 --- a/apps/desktop/src/main/services/missions/phaseEngine.ts +++ b/apps/desktop/src/main/services/missions/phaseEngine.ts @@ -53,13 +53,13 @@ export function createBuiltInPhaseCards(at: string = nowIso()): PhaseCard[] { }, askQuestions: { enabled: true, - maxQuestions: 5, + maxQuestions: null, }, validationGate: { tier: "none", required: false, }, - requiresApproval: false, + requiresApproval: true, isBuiltIn: true, isCustom: false, position: 0, diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index 586804a1d..873cace81 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -828,6 +828,31 @@ function clearRunPhaseConfig( db.run(`update orchestrator_runs set metadata_json = ? where id = ?`, [JSON.stringify(metadata), runId]); } +function readRunMissionLaneId( + db: Awaited>, + runId: string, +): string | null { + const row = db.get<{ metadata_json: string | null }>( + `select metadata_json from orchestrator_runs where id = ? limit 1`, + [runId] + ); + const metadata = row?.metadata_json ? JSON.parse(row.metadata_json) : {}; + const direct = typeof metadata?.missionLaneId === "string" ? metadata.missionLaneId.trim() : ""; + if (direct.length > 0) return direct; + const coordinatorLaneId = + metadata?.coordinator && typeof metadata.coordinator === "object" && !Array.isArray(metadata.coordinator) + && typeof metadata.coordinator.missionLaneId === "string" + ? metadata.coordinator.missionLaneId.trim() + : ""; + if (coordinatorLaneId.length > 0) return coordinatorLaneId; + const teamRuntimeLaneId = + metadata?.teamRuntime && typeof metadata.teamRuntime === "object" && !Array.isArray(metadata.teamRuntime) + && typeof metadata.teamRuntime.missionLaneId === "string" + ? metadata.teamRuntime.missionLaneId.trim() + : ""; + return teamRuntimeLaneId.length > 0 ? teamRuntimeLaneId : null; +} + async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { const started = Date.now(); while (!predicate()) { @@ -868,6 +893,7 @@ function markRunStepValidationPassed( async function createFixture(args: { aiIntegrationService?: any; laneService?: any; + prService?: any; agentChatService?: any; missionMemoryLifecycleService?: any; orchestratorConfig?: Record; @@ -937,10 +963,60 @@ async function createFixture(args: { const missionService = createMissionService({ db, projectId }); let defaultLaneCounter = 0; const defaultLaneService = { - list: vi.fn(async () => [ - { id: laneId, laneType: "primary" } - ]), - createChild: vi.fn(async ({ name }: { name: string }) => { + list: vi.fn(async ({ includeArchived }: { includeArchived?: boolean } = {}) => db.all<{ + id: string; + project_id: string; + name: string; + lane_type: string | null; + base_ref: string | null; + branch_ref: string | null; + worktree_path: string | null; + attached_root_path: string | null; + status: string | null; + archived_at: string | null; + mission_id: string | null; + lane_role: string | null; + }>( + ` + select + id, + project_id, + name, + lane_type, + base_ref, + branch_ref, + worktree_path, + attached_root_path, + status, + archived_at, + mission_id, + lane_role + from lanes + where project_id = ? + and (? = 1 or archived_at is null) + order by created_at asc, id asc + `, + [projectId, includeArchived ? 1 : 0] + ).map((row) => ({ + id: row.id, + projectId: row.project_id, + name: row.name, + laneType: row.lane_type === "primary" ? "primary" : "worktree", + baseRef: row.base_ref, + branchRef: row.branch_ref, + worktreePath: row.worktree_path, + attachedRootPath: row.attached_root_path, + status: row.archived_at ? "archived" : row.status === "archived" ? "archived" : "active", + missionId: row.mission_id, + laneRole: row.lane_role, + archivedAt: row.archived_at, + }))), + createChild: vi.fn(async ({ name, description, missionId, laneRole }: { + name: string; + description?: string | null; + missionId?: string | null; + laneRole?: string | null; + }) => { defaultLaneCounter += 1; const childId = `mission-lane-${defaultLaneCounter}`; const childNow = new Date().toISOString(); @@ -948,13 +1024,13 @@ async function createFixture(args: { `insert into lanes( id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, attached_root_path, is_edit_protected, parent_lane_id, - color, icon, tags_json, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + color, icon, tags_json, folder, mission_id, lane_role, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ childId, projectId, name, - null, + description ?? null, "worktree", "main", `feature/${childId}`, @@ -965,16 +1041,113 @@ async function createFixture(args: { null, null, null, + null, + missionId ?? null, + laneRole ?? null, "active", childNow, null, ] ); - return { id: childId, name }; + return { + id: childId, + name, + laneType: "worktree", + branchRef: `feature/${childId}`, + worktreePath: projectRoot, + missionId: missionId ?? null, + laneRole: laneRole ?? null, + }; + }), + archive: vi.fn(async ({ laneId: targetLaneId }: { laneId: string }) => { + db.run( + `update lanes set status = 'archived', archived_at = ? where id = ? and project_id = ?`, + [new Date().toISOString(), targetLaneId, projectId] + ); + }), + setMissionOwnership: vi.fn(async ({ + laneId: targetLaneId, + missionId, + laneRole, + }: { + laneId: string; + missionId: string | null; + laneRole?: string | null; + }) => { + db.run( + `update lanes set mission_id = ?, lane_role = ? where id = ? and project_id = ?`, + [missionId, laneRole ?? null, targetLaneId, projectId] + ); + }), + getLaneWorktreePath: vi.fn((targetLaneId: string) => { + const row = db.get<{ worktree_path: string | null }>( + `select worktree_path from lanes where id = ? and project_id = ? limit 1`, + [targetLaneId, projectId] + ); + return row?.worktree_path ?? projectRoot; }), - archive: vi.fn(async () => undefined), }; const laneService = args.laneService ?? defaultLaneService; + let defaultIntegrationLaneCounter = 0; + const defaultPrService = { + createIntegrationLane: vi.fn(async ({ + sourceLaneIds, + integrationLaneName, + missionId, + laneRole, + }: { + sourceLaneIds: string[]; + integrationLaneName: string; + missionId?: string | null; + laneRole?: string | null; + }) => { + defaultIntegrationLaneCounter += 1; + const integrationLaneId = `result-lane-${defaultIntegrationLaneCounter}`; + const createdAt = new Date().toISOString(); + db.run( + `insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, + worktree_path, attached_root_path, is_edit_protected, parent_lane_id, + color, icon, tags_json, folder, mission_id, lane_role, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + integrationLaneId, + projectId, + integrationLaneName, + null, + "worktree", + "main", + `integration/${integrationLaneId}`, + projectRoot, + null, + 0, + laneId, + null, + null, + null, + null, + missionId ?? null, + laneRole ?? "result", + "active", + createdAt, + null, + ] + ); + return { + integrationLane: { + id: integrationLaneId, + name: integrationLaneName, + laneType: "worktree", + branchRef: `integration/${integrationLaneId}`, + worktreePath: projectRoot, + missionId: missionId ?? null, + laneRole: laneRole ?? "result", + }, + mergeResults: sourceLaneIds.map((sourceLaneId) => ({ laneId: sourceLaneId, success: true })), + }; + }), + }; + const prService = args.prService ?? defaultPrService; const aiIntegrationService = "aiIntegrationService" in args ? args.aiIntegrationService : createMockAiIntegrationService(); const projectConfigService = { get: () => ({ @@ -1086,6 +1259,7 @@ async function createFixture(args: { laneService, projectConfigService, aiIntegrationService, + prService, missionMemoryLifecycleService: args.missionMemoryLifecycleService ?? null, projectRoot, hookCommandRunner: args.hookCommandRunner @@ -1099,6 +1273,7 @@ async function createFixture(args: { missionService, orchestratorService, laneService, + prService, projectConfigService, aiIntegrationService, aiOrchestratorService, @@ -4730,17 +4905,7 @@ describe("aiOrchestratorService", () => { }); it("allocates distinct mission lanes when starting multiple runs for the same mission", async () => { - let laneCounter = 0; - const laneService = { - list: vi.fn(async () => [ - { id: "lane-1", laneType: "primary" } - ]), - createChild: vi.fn(async () => { - laneCounter += 1; - return { id: `mission-lane-${laneCounter}`, name: `Mission Lane ${laneCounter}` }; - }), - }; - const fixture = await createFixture({ laneService }); + const fixture = await createFixture(); try { const mission = fixture.missionService.create({ prompt: "Run mission with restart-safe lane reuse.", @@ -4755,12 +4920,7 @@ describe("aiOrchestratorService", () => { expect(firstStart.started).toBeTruthy(); const firstRunId = firstStart.started!.run.id; - const metadataRow = fixture.db.get<{ metadata_json: string | null }>( - `select metadata_json from orchestrator_runs where id = ? limit 1`, - [firstRunId] - ); - const metadata = metadataRow?.metadata_json ? JSON.parse(metadataRow.metadata_json) : {}; - expect(metadata.missionLaneId).toBe("mission-lane-1"); + expect(readRunMissionLaneId(fixture.db, firstRunId)).toBe("mission-lane-1"); const secondStart = await fixture.aiOrchestratorService.startMissionRun({ missionId: mission.id, @@ -4771,17 +4931,12 @@ describe("aiOrchestratorService", () => { const secondRunId = secondStart.started?.run.id; expect(secondRunId).toBeTruthy(); expect(secondRunId).not.toBe(firstRunId); - expect(laneService.createChild).toHaveBeenCalledTimes(2); + expect(fixture.laneService.createChild).toHaveBeenCalledTimes(2); if (!secondRunId) { throw new Error("Expected second run id"); } - const secondMetadataRow = fixture.db.get<{ metadata_json: string | null }>( - `select metadata_json from orchestrator_runs where id = ? limit 1`, - [secondRunId] - ); - const secondMetadata = secondMetadataRow?.metadata_json ? JSON.parse(secondMetadataRow.metadata_json) : {}; - expect(secondMetadata.missionLaneId).toBe("mission-lane-2"); + expect(readRunMissionLaneId(fixture.db, secondRunId)).toBe("mission-lane-2"); } finally { fixture.dispose(); } @@ -6586,38 +6741,58 @@ describe("aiOrchestratorService", () => { } }); - it("completes multi-lane run and calls createIntegrationPr via prService", async () => { - const prServiceMock = { - createIntegrationPr: vi.fn().mockResolvedValue({ - groupId: "group-1", - integrationLaneId: "int-lane-1", - pr: { - id: "pr-1", - laneId: "int-lane-1", - projectId: "proj-1", - repoOwner: "test", - repoName: "repo", - githubPrNumber: 42, - githubUrl: "https://github.com/test/repo/pull/42", - githubNodeId: null, - title: "[ADE] Integration: Test Mission", - state: "draft", - baseBranch: "main", - headBranch: "integration/test", - checksStatus: "none", - reviewStatus: "none", - additions: 0, - deletions: 0, - lastSyncedAt: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - }, - mergeResults: [] - }) - } as any; - + it("completes multi-lane run and assembles a result lane via prService", async () => { const fixture = await createFixture(); try { + const prServiceMock = { + createIntegrationLane: vi.fn().mockImplementation(async () => { + const laneNow = new Date().toISOString(); + fixture.db.run( + `insert or ignore into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, + worktree_path, attached_root_path, is_edit_protected, parent_lane_id, + color, icon, tags_json, folder, mission_id, lane_role, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + "result-lane-1", + fixture.projectId, + "mission/build-feature-with-two-workers-result", + "Synthetic result lane for aiOrchestratorService.test.ts", + "worktree", + "main", + "integration/result-lane-1", + fixture.projectRoot, + null, + 0, + fixture.laneId, + null, + null, + null, + null, + mission.id, + "result", + "active", + laneNow, + null, + ], + ); + return { + integrationLane: { + id: "result-lane-1", + name: "mission/build-feature-with-two-workers-result", + laneType: "worktree", + branchRef: "integration/result-lane-1", + worktreePath: fixture.projectRoot, + missionId: mission.id, + laneRole: "result", + }, + mergeResults: [ + { laneId: "lane-a", success: true }, + { laneId: "lane-b", success: true }, + ], + }; + }), + } as any; const defaultProfile = fixture.missionService.listPhaseProfiles().find((profile) => profile.isDefault); if (!defaultProfile) throw new Error("Expected default phase profile"); // Create a mission with steps on different lanes @@ -6636,7 +6811,7 @@ describe("aiOrchestratorService", () => { ] }); - // Set integration PR policy on mission metadata + // Preserve a legacy metadata payload to verify closeout ignores it. const existingMeta = JSON.parse( fixture.db.get<{ metadata_json: string | null }>( `select metadata_json from missions where id = ? limit 1`, @@ -6769,11 +6944,12 @@ describe("aiOrchestratorService", () => { const missionAfterSync = fixture.missionService.get(mission.id); expect(missionAfterSync?.status).toBe("completed"); - // The prService.createIntegrationPr should have been called - expect(prServiceMock.createIntegrationPr).toHaveBeenCalled(); - const prArgs = prServiceMock.createIntegrationPr.mock.calls[0]![0]; - expect(prArgs.title).toContain("[ADE] Integration:"); - expect(prArgs.draft).toBe(true); + expect(prServiceMock.createIntegrationLane).toHaveBeenCalledWith(expect.objectContaining({ + sourceLaneIds: expect.arrayContaining(["lane-a", "lane-b"]), + missionId: mission.id, + laneRole: "result", + })); + expect(missionAfterSync?.resultLaneId).toBe("result-lane-1"); aiOrchestratorWithPr.dispose(); } finally { @@ -6781,9 +6957,9 @@ describe("aiOrchestratorService", () => { } }); - it("handles PR creation failure gracefully without crashing mission sync", async () => { + it("fails mission closeout when result-lane assembly throws", async () => { const prServiceMock = { - createIntegrationPr: vi.fn().mockRejectedValue(new Error("GitHub API rate limit exceeded")) + createIntegrationLane: vi.fn().mockRejectedValue(new Error("GitHub API rate limit exceeded")) } as any; const fixture = await createFixture(); @@ -6797,7 +6973,7 @@ describe("aiOrchestratorService", () => { ] }); - // Set integration PR policy on mission metadata + // Preserve a legacy metadata payload to verify closeout ignores it. const existingMeta2 = JSON.parse( fixture.db.get<{ metadata_json: string | null }>( `select metadata_json from missions where id = ? limit 1`, @@ -6895,7 +7071,7 @@ describe("aiOrchestratorService", () => { await aiOrchestratorWithPr.syncMissionFromRun(runId, "run_completed", { nextMissionStatus: "completed" }); - // PR finalization failure should block successful mission completion + // Result-lane assembly failure should block mission completion. const refreshed = fixture.missionService.get(mission.id); expect(refreshed?.status).toBe("failed"); @@ -6905,66 +7081,29 @@ describe("aiOrchestratorService", () => { } }); - it("honors integration closing strategy for single-lane runs", async () => { + it("reuses the sole run lane as the result lane for single-lane runs", async () => { const prServiceMock = { - createIntegrationPr: vi.fn().mockResolvedValue({ - groupId: "group-single", - integrationLaneId: "int-lane-single", - pr: { - id: "pr-single", - laneId: "int-lane-single", - projectId: "proj-1", - repoOwner: "test", - repoName: "repo", - githubPrNumber: 7, - githubUrl: "https://github.com/test/repo/pull/7", - githubNodeId: null, - title: "[ADE] Integration: Single lane mission", - state: "draft", - baseBranch: "main", - headBranch: "integration/single", - checksStatus: "none", - reviewStatus: "none", - additions: 0, - deletions: 0, - lastSyncedAt: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - }, - mergeResults: [] - }) + createIntegrationLane: vi.fn(), } as any; const fixture = await createFixture(); try { + const defaultProfile = fixture.missionService.listPhaseProfiles().find((profile) => profile.isDefault); + if (!defaultProfile) throw new Error("Expected default phase profile"); const mission = fixture.missionService.create({ prompt: "Single lane integration PR mission.", laneId: fixture.laneId, + phaseProfileId: defaultProfile.id, + phaseOverride: defaultProfile.phases.map((phase, index) => ({ + ...phase, + position: index, + validationGate: { ...phase.validationGate, tier: "none", required: false, criteria: undefined, evidenceRequirements: undefined }, + })), plannedSteps: [ { index: 0, title: "Worker task", detail: "Task", kind: "implementation", metadata: { stepType: "implementation" } } ] }); - const existingMeta = JSON.parse( - fixture.db.get<{ metadata_json: string | null }>( - `select metadata_json from missions where id = ? limit 1`, - [mission.id] - )?.metadata_json ?? "{}" - ); - existingMeta.executionPolicy = { - ...existingMeta.executionPolicy, - prStrategy: { kind: "integration", targetBranch: "main", draft: true }, - integrationPr: { enabled: true, draft: true, autoResolveConflicts: false } - }; - existingMeta.missionLevelSettings = { - ...(existingMeta.missionLevelSettings ?? {}), - prStrategy: { kind: "integration", targetBranch: "main", draft: true }, - }; - fixture.db.run( - `update missions set metadata_json = ? where id = ?`, - [JSON.stringify(existingMeta), mission.id] - ); - const aiOrchestratorWithPr = createAiOrchestratorService({ db: fixture.db, logger: createLogger(), @@ -6994,6 +7133,7 @@ describe("aiOrchestratorService", () => { `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, [new Date().toISOString(), started.run.id], ); + fixture.missionService.update({ missionId: mission.id, status: "in_progress" }); const runId = started.run.id; fixture.orchestratorService.tick({ runId }); @@ -7026,38 +7166,40 @@ describe("aiOrchestratorService", () => { expect(finalizeResult.finalized).toBe(true); await aiOrchestratorWithPr.syncMissionFromRun(runId, "run_completed", { nextMissionStatus: "completed" }); - expect(prServiceMock.createIntegrationPr).toHaveBeenCalled(); - const prArgs = prServiceMock.createIntegrationPr.mock.calls[0]![0]; - expect(Array.isArray(prArgs.sourceLaneIds)).toBe(true); - expect(prArgs.sourceLaneIds).toContain(fixture.laneId); + const missionAfterSync = fixture.missionService.get(mission.id); + expect(missionAfterSync?.status).toBe("completed"); + expect(missionAfterSync?.resultLaneId).toBe(fixture.laneId); + expect(prServiceMock.createIntegrationLane).not.toHaveBeenCalled(); aiOrchestratorWithPr.dispose(); } finally { fixture.dispose(); } }); - it("does not create integration PR for single-lane run", async () => { + it("does not assemble an integration lane for a single-lane run", async () => { const prServiceMock = { - createIntegrationPr: vi.fn().mockResolvedValue({ - groupId: "group-1", - integrationLaneId: "int-lane-1", - pr: { githubPrNumber: 42, githubUrl: "https://github.com/test/repo/pull/42" }, - mergeResults: [] - }) + createIntegrationLane: vi.fn() } as any; const fixture = await createFixture(); try { + const defaultProfile = fixture.missionService.listPhaseProfiles().find((profile) => profile.isDefault); + if (!defaultProfile) throw new Error("Expected default phase profile"); const mission = fixture.missionService.create({ prompt: "Build feature.", laneId: fixture.laneId, + phaseProfileId: defaultProfile.id, + phaseOverride: defaultProfile.phases.map((phase, index) => ({ + ...phase, + position: index, + validationGate: { ...phase.validationGate, tier: "none", required: false, criteria: undefined, evidenceRequirements: undefined }, + })), plannedSteps: [ { index: 0, title: "Step 1", detail: "A", kind: "implementation", metadata: { stepType: "implementation" } }, { index: 1, title: "Step 2", detail: "B", kind: "test", metadata: { stepType: "test" } } ] }); - // Set integration PR policy on mission metadata const existingMeta3 = JSON.parse( fixture.db.get<{ metadata_json: string | null }>( `select metadata_json from missions where id = ? limit 1`, @@ -7096,6 +7238,7 @@ describe("aiOrchestratorService", () => { `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, [new Date().toISOString(), started.run.id], ); + fixture.missionService.update({ missionId: mission.id, status: "in_progress" }); const runId = started.run.id; // Complete all steps sequentially (single lane — no lane_id changes) @@ -7120,10 +7263,14 @@ describe("aiOrchestratorService", () => { fixture.orchestratorService.tick({ runId }); } + const finalizeResult = aiOrchestratorWithPr.finalizeRun({ runId }); + expect(finalizeResult.finalized).toBe(true); await aiOrchestratorWithPr.syncMissionFromRun(runId, "run_completed", { nextMissionStatus: "completed" }); - // Single-lane run should NOT trigger integration PR - expect(prServiceMock.createIntegrationPr).not.toHaveBeenCalled(); + const missionAfterSync = fixture.missionService.get(mission.id); + expect(missionAfterSync?.status).toBe("completed"); + expect(missionAfterSync?.resultLaneId).toBe(fixture.laneId); + expect(prServiceMock.createIntegrationLane).not.toHaveBeenCalled(); aiOrchestratorWithPr.dispose(); } finally { @@ -7131,170 +7278,6 @@ describe("aiOrchestratorService", () => { } }); - it("keeps mission incomplete while queue auto-land finalization is still running", async () => { - const prServiceMock = { - createQueuePrs: vi.fn().mockResolvedValue({ - groupId: "queue-group-1", - prs: [ - { id: "pr-1", githubUrl: "https://github.com/test/repo/pull/101" }, - { id: "pr-2", githubUrl: "https://github.com/test/repo/pull/102" }, - ], - errors: [], - }), - } as any; - const queueLandingServiceMock = { - startQueue: vi.fn().mockResolvedValue({ - queueId: "queue-1", - groupId: "queue-group-1", - groupName: "Queue Group 1", - targetBranch: "main", - state: "landing", - entries: [], - currentPosition: 0, - activePrId: "pr-1", - activeResolverRunId: null, - lastError: null, - waitReason: null, - config: { - method: "squash", - archiveLane: false, - autoResolve: true, - ciGating: true, - resolverProvider: "claude", - resolverModel: "anthropic/claude-sonnet-4-6", - reasoningEffort: "medium", - permissionMode: "guarded_edit", - confidenceThreshold: null, - originSurface: "mission", - originMissionId: null, - originRunId: null, - originLabel: null, - }, - startedAt: new Date().toISOString(), - completedAt: null, - updatedAt: new Date().toISOString(), - }), - } as any; - - const fixture = await createFixture(); - try { - const mission = fixture.missionService.create({ - prompt: "Queue mission.", - laneId: fixture.laneId, - plannedSteps: [ - { index: 0, title: "Worker task", detail: "Task", kind: "implementation", metadata: { stepType: "implementation" } }, - ], - }); - - const existingMeta = JSON.parse( - fixture.db.get<{ metadata_json: string | null }>( - `select metadata_json from missions where id = ? limit 1`, - [mission.id], - )?.metadata_json ?? "{}", - ); - existingMeta.executionPolicy = { - ...existingMeta.executionPolicy, - prStrategy: { - kind: "queue", - targetBranch: "main", - autoLand: true, - autoResolveConflicts: true, - ciGating: true, - mergeMethod: "squash", - }, - }; - existingMeta.missionLevelSettings = { - ...(existingMeta.missionLevelSettings ?? {}), - prStrategy: { - kind: "queue", - targetBranch: "main", - autoLand: true, - autoResolveConflicts: true, - ciGating: true, - mergeMethod: "squash", - }, - }; - fixture.db.run( - `update missions set metadata_json = ? where id = ?`, - [JSON.stringify(existingMeta), mission.id], - ); - - const service = createAiOrchestratorService({ - db: fixture.db, - logger: createLogger(), - missionService: fixture.missionService, - orchestratorService: fixture.orchestratorService, - laneService: fixture.laneService, - projectConfigService: fixture.projectConfigService, - aiIntegrationService: fixture.aiIntegrationService, - prService: prServiceMock, - queueLandingService: queueLandingServiceMock, - projectRoot: fixture.projectRoot, - }); - - const started = fixture.orchestratorService.startRun({ - missionId: mission.id, - steps: [ - { - stepKey: "worker-task", - title: "Worker task", - stepIndex: 0, - dependencyStepKeys: [], - executorKind: "manual", - metadata: { stepType: "implementation", instructions: "Do the work" }, - }, - ], - }); - fixture.db.run( - `update orchestrator_runs set status = 'active', updated_at = ? where id = ?`, - [new Date().toISOString(), started.run.id], - ); - const runId = started.run.id; - - fixture.orchestratorService.tick({ runId }); - const graph = fixture.orchestratorService.getRunGraph({ runId }); - const readyStep = graph.steps.find((entry) => entry.status === "ready") ?? graph.steps[0]; - if (!readyStep) throw new Error("Expected mission step"); - const attempt = await fixture.orchestratorService.startAttempt({ - runId, - stepId: readyStep.id, - ownerId: "test-owner", - executorKind: "manual", - }); - await fixture.orchestratorService.completeAttempt({ - attemptId: attempt.id, - status: "succeeded", - result: { - schema: "ade.orchestratorAttempt.v1", - success: true, - summary: "Done", - outputs: null, - warnings: [], - sessionId: null, - trackedSession: false, - }, - }); - - fixture.orchestratorService.tick({ runId }); - service.finalizeRun({ runId }); - await service.syncMissionFromRun(runId, "run_completed", { nextMissionStatus: "completed" }); - - expect(prServiceMock.createQueuePrs).toHaveBeenCalled(); - expect(queueLandingServiceMock.startQueue).toHaveBeenCalledWith(expect.objectContaining({ - groupId: "queue-group-1", - autoResolve: true, - originSurface: "mission", - originMissionId: mission.id, - originRunId: runId, - })); - expect(fixture.missionService.get(mission.id)?.status).not.toBe("completed"); - - service.dispose(); - } finally { - fixture.dispose(); - } - }); - it("watchdog detects stalled attempt with no session output and emits warning event", async () => { const fixture = await createFixture({ aiIntegrationService: createStagnationRecoveryAiIntegrationService() diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index d943c056a..f8c6dae73 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts @@ -58,8 +58,6 @@ import type { GetAggregatedUsageArgs, RecoveryLoopState, IntegrationPrPolicy, - QueueLandingState, - PrStrategy, OrchestratorRunStatus, OrchestratorTeamMember, OrchestratorTeamRuntimeState, @@ -72,6 +70,7 @@ import type { MissionCloseoutRequirement, MissionCloseoutRequirementKey, MissionFinalizationPolicy, + MissionFinalizationPolicyKind, MissionFinalizationState, MissionCoordinatorAvailability, MissionStatePendingIntervention, @@ -105,7 +104,6 @@ import { } from "./orchestratorConstants"; import { resolveAdeLayout } from "../../../shared/adeLayout"; import { modelConfigToServiceModel } from "../../../shared/modelProfiles"; -import { getModelById } from "../../../shared/modelRegistry"; import { isWorkerBootstrapNoiseLine } from "../../../shared/workerRuntimeNoise"; import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; @@ -163,7 +161,6 @@ type CoordinatorLifecycleState = // ── Module imports (extracted from this file) ──────────────────── import type { - PendingIntegrationContext, MissionRunStartArgs, MissionRunStartResult, OrchestratorHookCommandRunner, @@ -790,8 +787,6 @@ export function createAiOrchestratorService(args: { projectConfigService, aiIntegrationService, prService, - conflictService, - queueLandingService, missionBudgetService, humanWorkDigestService, missionMemoryLifecycleService, @@ -817,7 +812,6 @@ export function createAiOrchestratorService(args: { const workerDeliveryInterventionCooldowns = new Map(); const runTeamManifests = new Map(); const runRecoveryLoopStates = new Map(); - const pendingIntegrations = new Map(); const pendingCoordinatorEvals = new Map(); const milestoneReadyNotificationSignatures = new Map(); const runWatchdogTimers = new Map>(); @@ -886,7 +880,6 @@ export function createAiOrchestratorService(args: { aiTimeoutBudgetRunLocks: new Set(), aiRetryDecisionLocks: new Set(), coordinatorSessions: new Map(), - pendingIntegrations, coordinatorThinkingLoops: new Map(), pendingCoordinatorEvals, coordinatorAgents, @@ -901,7 +894,6 @@ export function createAiOrchestratorService(args: { const purgeRunMaps = (runId: string): void => { runTeamManifests.delete(runId); runRecoveryLoopStates.delete(runId); - pendingIntegrations.delete(runId); teamRuntimeStates.delete(runId); coordinatorLifecycleStates.delete(runId); for (const key of milestoneReadyNotificationSignatures.keys()) { @@ -2514,57 +2506,16 @@ Check all worker statuses and continue managing the mission from here. Read work reasoningEffort: null, } as const; - const resolveMissionFinalizationPolicy = (strategy: PrStrategy | null | undefined): MissionFinalizationPolicy => { - if (!strategy || strategy.kind === "manual") { - return { - ...NULL_FINALIZATION_FIELDS, - kind: "manual", - description: "Manual PR handling. Execution completion satisfies the mission contract." - }; - } - if (strategy.kind === "integration") { - return { - ...NULL_FINALIZATION_FIELDS, - kind: "integration", - targetBranch: strategy.targetBranch ?? null, - draft: strategy.draft ?? true, - prDepth: strategy.prDepth ?? "resolve-conflicts", - description: "Create a single integration PR as part of mission completion." - }; - } - if (strategy.kind === "per-lane") { - return { - ...NULL_FINALIZATION_FIELDS, - kind: "per-lane", - targetBranch: strategy.targetBranch ?? null, - draft: strategy.draft ?? true, - prDepth: strategy.prDepth ?? null, - description: "Create one PR per lane before the mission is considered complete." - }; - } - const autoLand = strategy.autoLand ?? false; - const autoResolveConflicts = strategy.autoResolveConflicts ?? false; - return { - kind: "queue", - targetBranch: strategy.targetBranch ?? null, - draft: strategy.draft ?? true, - prDepth: strategy.prDepth ?? null, - autoRebase: strategy.autoRebase ?? true, - ciGating: strategy.ciGating ?? false, - autoLand, - autoResolveConflicts, - archiveLaneOnLand: strategy.archiveLaneOnLand ?? false, - mergeMethod: strategy.mergeMethod ?? "squash", - conflictResolverModel: strategy.conflictResolverModel ?? null, - reasoningEffort: strategy.reasoningEffort ?? null, - description: autoLand - ? autoResolveConflicts - ? "Create queue PRs, auto-resolve merge conflicts, and land the queue before the mission is considered complete." - : "Create queue PRs and land the queue before the mission is considered complete." - : "Create queue PRs before the mission is considered complete." - }; + const RESULT_LANE_FINALIZATION_POLICY: MissionFinalizationPolicy = { + ...NULL_FINALIZATION_FIELDS, + kind: "result_lane", + description: "Assemble a single result lane for the mission and stop before PR creation." }; + const resolveMissionFinalizationPolicyForMission = ( + _missionId: string, + ): MissionFinalizationPolicy => RESULT_LANE_FINALIZATION_POLICY; + const EVIDENCE_TO_CLOSEOUT_KEYS = new Set([ "planning_document", "research_summary", "changed_files_summary", "test_report", "review_summary", "risk_notes", "final_outcome_summary", @@ -2715,7 +2666,7 @@ Check all worker statuses and continue managing the mission from here. Read work source: validationRequired ? "runtime" : "waiver", }); - if (args.policy.kind !== "manual" && args.policy.kind !== "disabled") { + if (args.policy.kind !== "manual" && args.policy.kind !== "disabled" && args.policy.kind !== "result_lane") { const reviewRequired = args.policy.prDepth === "open-and-comment"; const proposalOnly = args.policy.prDepth === "propose-only"; pushRequirement({ @@ -2817,6 +2768,7 @@ Check all worker statuses and continue managing the mission from here. Read work detail: state.detail ?? previous?.detail ?? null, resolverJobId: state.resolverJobId ?? previous?.resolverJobId ?? null, integrationLaneId: state.integrationLaneId ?? previous?.integrationLaneId ?? null, + resultLaneId: state.resultLaneId ?? previous?.resultLaneId ?? null, queueGroupId: state.queueGroupId ?? previous?.queueGroupId ?? null, queueId: state.queueId ?? previous?.queueId ?? null, activePrId: state.activePrId ?? previous?.activePrId ?? null, @@ -2892,135 +2844,6 @@ Check all worker statuses and continue managing the mission from here. Read work updateMissionStateDoc(runId, { coordinatorAvailability: availability }, options); }; - const onQueueLandingStateChanged = async (queueState: QueueLandingState): Promise => { - const runId = queueState.config.originRunId ?? null; - const missionId = queueState.config.originMissionId ?? (runId ? getMissionIdForRun(runId) : null); - if (!runId || !missionId) return; - - const mission = missionService.get(missionId); - if (!mission) return; - - let graph: OrchestratorRunGraph; - try { - graph = orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); - } catch { - return; - } - - const prUrls = queueState.entries - .map((entry) => entry.githubUrl ?? null) - .filter((value): value is string => Boolean(value)); - - let status: MissionFinalizationState["status"] = "landing_queue"; - let blocked = false; - let blockedReason: string | null = null; - let mergeReadiness: string | null = null; - let contractSatisfied = false; - let summary = "Queue finalization is still running."; - let detail = queueState.lastError ?? null; - - if (queueState.state === "completed") { - status = "completed"; - contractSatisfied = true; - mergeReadiness = "queue_landed"; - summary = "Execution and queue landing completed."; - detail = `Queue ${queueState.groupName ?? queueState.groupId} landed ${queueState.entries.filter((entry) => entry.state === "landed").length} PR(s).`; - } else if (queueState.state === "landing") { - if (queueState.activeResolverRunId) { - status = "resolving_queue_conflicts"; - summary = "Queue landing is resolving merge conflicts before continuing."; - } else { - status = "landing_queue"; - summary = "Queue landing is progressing through queued PRs."; - } - } else if (queueState.state === "paused") { - if (queueState.waitReason === "ci") { - status = "waiting_for_green"; - mergeReadiness = "waiting_for_green"; - summary = "Queue landing is waiting for CI before it can continue."; - } else if (queueState.waitReason === "review") { - status = "awaiting_operator_review"; - mergeReadiness = "operator_review_required"; - summary = "Queue landing is waiting for operator review before it can continue."; - } else if (queueState.waitReason === "manual") { - status = "finalizing"; - blocked = true; - blockedReason = queueState.lastError ?? "Queue finalization is paused and needs operator intervention."; - summary = "Queue finalization is paused pending operator intervention."; - } else { - status = "finalization_failed"; - blocked = true; - blockedReason = queueState.lastError ?? "Queue finalization failed."; - summary = "Queue finalization failed."; - } - } else if (queueState.state === "cancelled") { - status = "finalization_failed"; - blocked = true; - blockedReason = queueState.lastError ?? "Queue finalization was cancelled."; - summary = "Queue finalization was cancelled."; - } - - const finalization = await updateMissionFinalizationState(runId, { - policy: resolveMissionFinalizationPolicy((resolveActivePhaseSettings(missionId).settings.prStrategy ?? { kind: "manual" }) as PrStrategy), - status, - executionComplete: true, - contractSatisfied, - blocked, - blockedReason, - summary, - detail, - resolverJobId: queueState.activeResolverRunId, - queueGroupId: queueState.groupId, - queueId: queueState.queueId, - activePrId: queueState.activePrId, - waitReason: queueState.waitReason, - prUrls, - mergeReadiness, - completedAt: contractSatisfied ? queueState.completedAt : null, - warnings: [], - }, { graph }); - - if (finalization) { - await updateMissionCompletionFromStateDoc({ - runId, - graph, - mission, - finalization, - }); - } - - // Notify the coordinator agent when queue landing reaches a terminal state - if (queueState.state === "completed" || queueState.state === "cancelled") { - try { - orchestratorService.appendRuntimeEvent({ - runId, - eventType: "finalization_queue_landed", - payload: { - queueState: queueState.state, - contractSatisfied, - summary, - detail, - queueGroupId: queueState.groupId, - prUrls, - }, - }); - } catch (eventError) { - logger.debug("ai_orchestrator.finalization_queue_landed_event_failed", { - runId, - error: eventError instanceof Error ? eventError.message : String(eventError), - }); - } - - const coordAgent = coordinatorAgents.get(runId); - if (coordAgent?.isAlive) { - const eventMessage = queueState.state === "completed" - ? `[finalization.queue_landed] Queue landing completed successfully. ${detail ?? ""} Call check_finalization_status for full details before deciding next steps.` - : `[finalization.queue_landed] Queue landing was cancelled. ${detail ?? ""} Call check_finalization_status to review the current state.`; - coordAgent.injectMessage(eventMessage); - } - } - }; - const resolveMissionStateStepPhase = (step: OrchestratorStep): string => { const stepMeta = isRecord(step.metadata) ? step.metadata : {}; const phaseName = typeof stepMeta.phaseName === "string" ? stepMeta.phaseName.trim() : ""; @@ -3203,7 +3026,7 @@ Check all worker statuses and continue managing the mission from here. Read work */ const createLaneFromBase = async ( name: string, - opts: { description?: string; folder?: string; missionId: string }, + opts: { description?: string; folder?: string; missionId: string; laneRole?: "mission_root" | "worker" | "integration" | "result" | null }, ): Promise<{ laneId: string; name: string } | null> => { if (!laneService) return null; const baseLaneId = await resolveMissionBaseLaneId(opts.missionId); @@ -3216,7 +3039,18 @@ Check all worker statuses and continue managing the mission from here. Read work name, description: opts.description, folder: opts.folder, + missionId: opts.missionId, + laneRole: opts.laneRole ?? "worker", }); + db.run( + ` + update lanes + set mission_id = coalesce(mission_id, ?), + lane_role = coalesce(lane_role, ?) + where id = ? + `, + [opts.missionId, opts.laneRole ?? "worker", child.id], + ); logger.info("ai_orchestrator.lane_created", { missionId: opts.missionId, laneId: child.id, @@ -3234,8 +3068,11 @@ Check all worker statuses and continue managing the mission from here. Read work const laneName = missionTitle.trim().length > 0 ? missionTitle.trim() : `Mission ${missionId.slice(0, 6)}`; const result = await createLaneFromBase( laneName, - { description: `Mission lane for ${missionTitle}`, folder: `Mission: ${missionTitle}`, missionId }, + { description: `Mission lane for ${missionTitle}`, folder: `Mission: ${missionTitle}`, missionId, laneRole: "mission_root" }, ); + if (result?.laneId) { + missionService.setMissionLane({ missionId, laneId: result.laneId }); + } return result?.laneId ?? null; } catch (error) { logger.warn("ai_orchestrator.mission_lane_creation_failed", { @@ -3301,19 +3138,209 @@ Check all worker statuses and continue managing the mission from here. Read work missionTitle: string; createIfMissing?: boolean; }): Promise => { + const persistMissionLaneOwnership = async (laneId: string, laneRole: "mission_root" | "worker" | "integration" | "result"): Promise => { + if (laneService?.setMissionOwnership) { + await laneService.setMissionOwnership({ + laneId, + missionId: args.missionId, + laneRole, + }); + return; + } + db.run( + ` + update lanes + set mission_id = ?, + lane_role = ? + where id = ? + `, + [args.missionId, laneRole, laneId], + ); + }; const persistedLaneId = resolvePersistedMissionLaneIdForRun(args.runId); if (persistedLaneId) { persistMissionLaneIdForRun(args.runId, persistedLaneId); + missionService.setMissionLane({ missionId: args.missionId, laneId: persistedLaneId }); + await persistMissionLaneOwnership(persistedLaneId, "mission_root"); return persistedLaneId; } if (args.createIfMissing === false) return null; const createdLaneId = await createMissionLane(args.missionId, args.missionTitle); if (createdLaneId) { persistMissionLaneIdForRun(args.runId, createdLaneId); + await persistMissionLaneOwnership(createdLaneId, "mission_root"); } return createdLaneId; }; + const archiveMissionIntermediateLanes = async (missionId: string, resultLaneId: string): Promise => { + const missionOwned = db.all<{ id: string }>( + ` + select id + from lanes + where mission_id = ? + and archived_at is null + and id <> ? + `, + [missionId, resultLaneId], + ); + for (const lane of missionOwned) { + try { + if (typeof laneService.archive === "function") { + await laneService.archive({ laneId: lane.id }); + } else { + db.run( + ` + update lanes + set status = 'archived', + archived_at = coalesce(archived_at, ?) + where id = ? + `, + [nowIso(), lane.id], + ); + } + } catch (error) { + logger.warn("ai_orchestrator.archive_intermediate_mission_lane_failed", { + missionId, + laneId: lane.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + }; + + const finalizeMissionToResultLane = async (args: { + mission: MissionDetail; + runId: string; + missionBaseBranch: string; + }): Promise<{ resultLaneId: string; summary: string; detail: string; warnings: string[] }> => { + const missionLaneId = resolvePersistedMissionLaneIdForRun(args.runId) ?? toOptionalString(args.mission.missionLaneId); + const fallbackGraph = orchestratorService.getRunGraph({ runId: args.runId, timelineLimit: 0 }); + const activeMissionLanes = db.all<{ id: string }>( + ` + select id + from lanes + where mission_id = ? + and archived_at is null + and coalesce(lane_role, '') <> 'result' + `, + [args.mission.id], + ); + const stepLaneIds = [...new Set( + fallbackGraph.steps + .map((step) => toOptionalString(step.laneId)) + .filter((laneId): laneId is string => laneId != null) + )]; + let sourceLaneIds = [...new Set([ + ...activeMissionLanes.map((lane) => lane.id), + ...stepLaneIds, + ...(missionLaneId ? [missionLaneId] : []), + ].filter(Boolean))] as string[]; + if (sourceLaneIds.length === 0) { + sourceLaneIds = [...new Set([ + ...(missionLaneId ? [missionLaneId] : []), + ...stepLaneIds, + ])]; + } + if (sourceLaneIds.length === 0) { + const missionBaseLaneId = toOptionalString(args.mission.laneId); + if (missionBaseLaneId) { + sourceLaneIds = [missionBaseLaneId]; + } + } + if (sourceLaneIds.length === 0) { + throw new Error("No mission-owned lanes were available to assemble a result lane."); + } + + if (sourceLaneIds.length === 1) { + const resultLaneId = sourceLaneIds[0]!; + missionService.setResultLane({ missionId: args.mission.id, laneId: resultLaneId }); + if (laneService?.setMissionOwnership) { + await laneService.setMissionOwnership({ + laneId: resultLaneId, + missionId: args.mission.id, + laneRole: "result", + }); + } else { + db.run( + ` + update lanes + set mission_id = ?, + lane_role = 'result' + where id = ? + `, + [args.mission.id, resultLaneId], + ); + } + await archiveMissionIntermediateLanes(args.mission.id, resultLaneId); + return { + resultLaneId, + summary: "Execution completed. The mission result lane is ready.", + detail: `Result lane ${resultLaneId} now contains the mission output.`, + warnings: [], + }; + } + + if (!prService) { + throw new Error("Mission produced multiple lanes, but result-lane assembly is unavailable."); + } + + const slug = args.mission.title + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || `mission-${args.mission.id.slice(0, 8)}`; + const assembly = await prService.createIntegrationLane({ + sourceLaneIds, + integrationLaneName: `mission/${slug}-result`, + baseBranch: args.missionBaseBranch, + description: `Result lane for mission "${args.mission.title}"`, + missionId: args.mission.id, + laneRole: "result", + }); + + const resultLaneId = assembly.integrationLane.id; + missionService.setResultLane({ missionId: args.mission.id, laneId: resultLaneId }); + if (laneService?.setMissionOwnership) { + await laneService.setMissionOwnership({ + laneId: resultLaneId, + missionId: args.mission.id, + laneRole: "result", + }); + } else { + db.run( + ` + update lanes + set mission_id = ?, + lane_role = 'result' + where id = ? + `, + [args.mission.id, resultLaneId], + ); + } + + const failedMerges = assembly.mergeResults.filter((entry) => !entry.success); + if (failedMerges.length > 0) { + const detail = failedMerges + .map((entry) => `${entry.laneId}: ${entry.error ?? "merge failed"}`) + .join("\n"); + return { + resultLaneId, + summary: "Result lane assembly is blocked by merge conflicts.", + detail, + warnings: failedMerges.map((entry) => `${entry.laneId}: ${entry.error ?? "merge failed"}`), + }; + } + + await archiveMissionIntermediateLanes(args.mission.id, resultLaneId); + return { + resultLaneId, + summary: "Execution completed. The mission result lane is ready.", + detail: `Result lane ${assembly.integrationLane.name} contains the merged mission output.`, + warnings: [], + }; + }; + const recordRuntimeEvent = (args: { runId: string; stepId?: string | null; @@ -4953,8 +4980,6 @@ Check all worker statuses and continue managing the mission from here. Read work retrospectiveGenerated: Boolean(retrospective) }); - bestEffortCascadeCleanup(missionId, runId, "run_finalize"); - return finalized; }; @@ -6428,587 +6453,96 @@ Check all worker statuses and continue managing the mission from here. Read work const refreshed = missionService.get(mission.id) ?? mission; const nextMissionStatus = options?.nextMissionStatus ?? deriveMissionStatusFromRun(graph, refreshed); if (nextMissionStatus === "completed") { - // ── Post-resolution finalization ───────────────────────────────────── - // If we previously spawned conflict resolution worker steps, this second - // completion pass means the workers finished. Finalize the integration. - const pendingCtx = pendingIntegrations.get(runId); - let skipNormalPrCreation = false; - - if (pendingCtx && prService) { - pendingIntegrations.delete(runId); - skipNormalPrCreation = true; - try { - // Check if all conflict resolution steps succeeded - const conflictSteps = graph.steps.filter((s) => - pendingCtx.conflictStepKeys.includes(s.stepKey) - ); - const allResolved = conflictSteps.every((s) => s.status === "succeeded"); - const failedSteps = conflictSteps.filter((s) => s.status !== "succeeded"); - - if (!allResolved) { - const failedNames = failedSteps.map((s) => s.title || s.stepKey); - emitOrchestratorMessage( - pendingCtx.missionId, - `Conflict resolution partially failed. ${failedNames.length} worker(s) did not succeed: ${failedNames.join(", ")}. ` + - `The integration lane is available for manual resolution.` - ); - logger.warn("ai_orchestrator.conflict_workers_partial_failure", { - missionId: pendingCtx.missionId, - runId, - totalWorkers: conflictSteps.length, - failed: failedSteps.length - }); - } else { - // Verify all conflicts are truly resolved - const verifyResults: Array<{ laneId: string; clean: boolean }> = []; - for (const cStep of conflictSteps) { - const meta = cStep.metadata as Record | null; - const sourceLaneId = meta?.sourceLaneId as string | undefined; - if (sourceLaneId) { - const recheck = await prService.recheckIntegrationStep({ - proposalId: pendingCtx.proposalId, - laneId: sourceLaneId - }); - verifyResults.push({ laneId: sourceLaneId, clean: recheck.resolution === "resolved" || recheck.allResolved }); - } - } - - const allClean = verifyResults.every((r) => r.clean); - - if (!allClean) { - emitOrchestratorMessage( - pendingCtx.missionId, - `Some conflicts remain after worker resolution. Manual intervention may be needed.` - ); - logger.warn("ai_orchestrator.post_resolution_verify_failed", { - missionId: pendingCtx.missionId, - runId, - results: verifyResults - }); - } else { - // All clean — commit integration PR - emitOrchestratorMessage(pendingCtx.missionId, `All conflicts resolved. Creating integration PR...`); - const commitResult = await prService.commitIntegration({ - proposalId: pendingCtx.proposalId, - integrationLaneName: pendingCtx.integrationLaneName, - title: `[ADE] Integration: ${pendingCtx.missionTitle}`, - body: `Automated integration PR for mission "${pendingCtx.missionTitle}".\n\n` + - `Lanes: ${pendingCtx.laneIdArray.join(", ")}\n` + - `Conflicts auto-resolved by AI workers.`, - draft: pendingCtx.isDraft - }); - - emitOrchestratorMessage( - pendingCtx.missionId, - `Integration PR #${commitResult.pr.githubPrNumber} created (after worker resolution): ${commitResult.pr.githubUrl}` - ); - logger.info("ai_orchestrator.integration_pr_created_after_workers", { - missionId: pendingCtx.missionId, - runId, - prNumber: commitResult.pr.githubPrNumber, - url: commitResult.pr.githubUrl - }); - - // ── open-and-comment: spawn a review worker ── - if (pendingCtx.prDepth === "open-and-comment") { - try { - const reviewStepKey = "pr-review-comment"; - const maxIdx = graph.steps.reduce((max, s) => Math.max(max, s.stepIndex), -1); - - const reviewSteps = orchestratorService.addPostCompletionSteps({ - runId, - steps: [{ - stepKey: reviewStepKey, - title: "Review and comment on integration PR", - stepIndex: maxIdx + 1, - laneId: pendingCtx.integrationLaneId, - dependencyStepKeys: pendingCtx.conflictStepKeys, - retryLimit: 1, - metadata: { - stepType: "pr-review", - prUrl: commitResult.pr.githubUrl, - prNumber: commitResult.pr.githubPrNumber, - instructions: "Review the PR diff, write a comprehensive summary comment covering: " + - "what changed, potential risks, test coverage, and deployment considerations. " + - "Use `gh pr comment` to add the review. Do NOT merge or approve — only comment." - } - }] - }); - - // Store updated context for the review step completion - pendingIntegrations.set(runId, { - ...pendingCtx, - reviewStepKey, - conflictStepKeys: [] // No more conflict steps to track - }); - - emitOrchestratorMessage( - pendingCtx.missionId, - `PR created. Spawning review worker to add summary comment...` - ); - logger.info("ai_orchestrator.pr_review_worker_spawned", { - missionId: pendingCtx.missionId, - runId, - reviewStepId: reviewSteps[0]?.id - }); - - // Run reopened by addPostCompletionSteps — don't transition to completed - return; - } catch (reviewError) { - // Review worker spawn failed — not critical, PR is already created - logger.warn("ai_orchestrator.pr_review_worker_failed", { - missionId: pendingCtx.missionId, - runId, - error: reviewError instanceof Error ? reviewError.message : String(reviewError) - }); - emitOrchestratorMessage( - pendingCtx.missionId, - `PR created successfully but review comment worker could not be spawned: ${reviewError instanceof Error ? reviewError.message : String(reviewError)}` - ); - } - } - } - } - } catch (finalizationError) { - logger.warn("ai_orchestrator.post_resolution_finalization_failed", { - missionId: pendingCtx.missionId, - runId, - error: finalizationError instanceof Error ? finalizationError.message : String(finalizationError) - }); - emitOrchestratorMessage( - pendingCtx.missionId, - `Post-resolution finalization failed: ${finalizationError instanceof Error ? finalizationError.message : String(finalizationError)}. ` + - `The integration lane is available for manual resolution.` - ); - } - } - - // ── PR Creation at Run End (strategy-driven) ────────────────────────── - // Skip if we already handled integration via the post-resolution path above. - // The mission transition to "completed" is deferred until AFTER PR creation - // because the conflict pipeline may spawn workers that reopen the run. let workersSpawned = false; - const { settings: runPhaseSettings } = resolveActivePhaseSettings(mission.id); - const integrationPrPolicy = runPhaseSettings.integrationPr ?? DEFAULT_INTEGRATION_PR_POLICY; - const laneIdArrayBase = [...new Set(graph.steps.map((s) => s.laneId).filter(Boolean))] as string[]; - const missionLaneId = resolvePersistedMissionLaneIdForRun(runId) ?? toOptionalString(mission.laneId); - if (laneIdArrayBase.length === 0 && missionLaneId) laneIdArrayBase.push(missionLaneId); - const prStrategy: PrStrategy = - runPhaseSettings.prStrategy - ?? { kind: "manual" }; - const finalizationPolicy = resolveMissionFinalizationPolicy(prStrategy); + const finalizationPolicy = resolveMissionFinalizationPolicyForMission(mission.id); const missionBaseBranch = await resolveMissionBaseBranch(mission.id); - if (skipNormalPrCreation) { - // Already handled via post-resolution path - } else try { + try { await updateMissionFinalizationState(runId, { policy: finalizationPolicy, - status: prStrategy.kind === "manual" ? "completed" : "finalizing", + status: "finalizing", executionComplete: true, - contractSatisfied: prStrategy.kind === "manual", + contractSatisfied: false, blocked: false, - summary: prStrategy.kind === "manual" - ? "Execution completed. PR handling is manual for this mission." - : "Execution completed. ADE is now running the selected finalization contract.", + summary: "Execution completed. ADE is assembling the mission result lane.", detail: finalizationPolicy.description, warnings: [], - completedAt: prStrategy.kind === "manual" ? nowIso() : null, + completedAt: null, + }, { graph }); + + await updateMissionFinalizationState(runId, { + policy: finalizationPolicy, + status: "finalizing", + executionComplete: true, + contractSatisfied: false, + blocked: false, + summary: "Execution finished. ADE is assembling the mission result lane.", + detail: `Base branch: ${missionBaseBranch ?? "main"}`, + warnings: [], }, { graph }); - if (prStrategy.kind === "manual") { - logger.debug("ai_orchestrator.pr_strategy_manual", { missionId: mission.id, runId }); - } else if (prStrategy.kind === "integration" && prService) { + const result = await finalizeMissionToResultLane({ + mission, + runId, + missionBaseBranch: missionBaseBranch ?? "main", + }); + + if (result.warnings.length > 0) { await updateMissionFinalizationState(runId, { policy: finalizationPolicy, - status: "creating_pr", + status: "finalizing", executionComplete: true, contractSatisfied: false, + blocked: true, + blockedReason: result.detail, + resultLaneId: result.resultLaneId, + summary: result.summary, + detail: result.detail, + warnings: result.warnings, + }, { graph }); + missionService.addIntervention({ + missionId: mission.id, + interventionType: "conflict", + title: "Result lane merge conflict", + body: `ADE assembled a partial result lane but could not finish merging all mission lanes.\n\n${result.detail}`, + requestedAction: "Resolve the conflicts in the result lane, then continue from that lane or rerun finalization.", + laneId: result.resultLaneId, + metadata: { + resultLaneId: result.resultLaneId, + mergeWarnings: result.warnings, + }, + }); + emitOrchestratorMessage( + mission.id, + `Result lane blocked by merge conflicts: ${result.resultLaneId}` + ); + } else { + emitOrchestratorMessage( + mission.id, + `Result lane ready: ${result.resultLaneId}` + ); + await updateMissionFinalizationState(runId, { + policy: finalizationPolicy, + status: "completed", + executionComplete: true, + contractSatisfied: true, blocked: false, - summary: "Execution finished. Creating integration PR before mission completion.", - detail: `Base branch: ${prStrategy.targetBranch ?? missionBaseBranch ?? "main"}`, - warnings: [], + resultLaneId: result.resultLaneId, + summary: result.summary, + detail: result.detail, + warnings: result.warnings, + completedAt: nowIso(), }, { graph }); - try { - const laneIdArray = laneIdArrayBase; - const integrationLaneName = `integration/${mission.id.slice(0, 8)}`; - const baseBranch = prStrategy.targetBranch ?? missionBaseBranch ?? "main"; - const isDraft = prStrategy.draft ?? integrationPrPolicy.draft ?? true; - - const prResult = await prService.createIntegrationPr({ - sourceLaneIds: laneIdArray, - integrationLaneName, - baseBranch, - title: `[ADE] Integration: ${mission.title}`, - body: `Automated integration PR for mission "${mission.title}".\n\nLanes: ${laneIdArray.join(", ")}`, - draft: isDraft - }); - - emitOrchestratorMessage( - mission.id, - `Integration PR #${prResult.pr.githubPrNumber} created: ${prResult.pr.githubUrl}` - ); - logger.info("ai_orchestrator.integration_pr_created", { - missionId: mission.id, - runId, - prNumber: prResult.pr.githubPrNumber, - url: prResult.pr.githubUrl - }); - await updateMissionFinalizationState(runId, { - policy: finalizationPolicy, - status: "completed", - executionComplete: true, - contractSatisfied: true, - blocked: false, - prUrls: [prResult.pr.githubUrl], - mergeReadiness: "operator_review_required", - summary: "Execution and integration PR creation completed.", - detail: `Integration PR ${prResult.pr.githubUrl} created.`, - warnings: [], - completedAt: nowIso(), - }, { graph }); - } catch (prError) { - const prDepth = prStrategy.prDepth - ?? integrationPrPolicy.prDepth - ?? "resolve-conflicts"; - const laneIdArray = laneIdArrayBase; - const baseBranch = prStrategy.targetBranch ?? missionBaseBranch ?? "main"; - const isDraft = prStrategy.draft ?? integrationPrPolicy.draft ?? true; - const integrationLaneName = `integration/${mission.id.slice(0, 8)}`; - - if (prDepth === "propose-only" || !conflictService || !laneService) { - throw prError; - } - - const targetLane = (await laneService.list({ includeArchived: false })).find((lane) => { - const branchRef = normalizeBranchName(lane.branchRef); - const laneBaseRef = normalizeBranchName(lane.baseRef); - const desired = normalizeBranchName(baseBranch); - return lane.id === baseBranch || branchRef === desired || laneBaseRef === desired; - }); - if (!targetLane) { - throw new Error(`No lane is available for base branch "${baseBranch}".`); - } - - const resolverModelId = integrationPrPolicy.conflictResolverModel ?? null; - const resolverDescriptor = resolverModelId ? getModelById(resolverModelId) : null; - const resolverProvider = resolverDescriptor?.family === "openai" ? "codex" : "claude"; - - await updateMissionFinalizationState(runId, { - policy: resolveMissionFinalizationPolicy(prStrategy), - status: "resolving_integration_conflicts", - executionComplete: true, - contractSatisfied: false, - blocked: false, - summary: "Execution finished. ADE is resolving integration conflicts before closeout can complete.", - detail: prError instanceof Error ? prError.message : String(prError), - warnings: [], - }, { graph }); - emitOrchestratorMessage( - mission.id, - `Integration PR creation hit conflicts. Launching shared resolver on integration lane "${integrationLaneName}" before finalization can complete.` - ); - - const resolverRun = await conflictService.runExternalResolver({ - provider: resolverProvider, - targetLaneId: targetLane.id, - sourceLaneIds: laneIdArray, - integrationLaneName, - }); - if (resolverRun.status !== "completed" || !resolverRun.integrationLaneId) { - throw new Error(resolverRun.error ?? "Shared conflict resolver did not complete successfully."); - } - - await updateMissionFinalizationState(runId, { - policy: resolveMissionFinalizationPolicy(prStrategy), - status: "creating_pr", - executionComplete: true, - contractSatisfied: false, - blocked: false, - resolverJobId: resolverRun.runId, - integrationLaneId: resolverRun.integrationLaneId, - detail: resolverRun.summary ?? "Conflict resolution completed. Creating integration PR.", - warnings: resolverRun.warnings, - }, { graph }); - - const resolvedPr = await prService.createFromLane({ - laneId: resolverRun.integrationLaneId, - title: `[ADE] Integration: ${mission.title}`, - body: `Automated integration PR for mission "${mission.title}".\n\nLanes: ${laneIdArray.join(", ")}\n\nResolved via the shared ADE conflict resolver job ${resolverRun.runId}.`, - draft: isDraft, - baseBranch, - }); - - if (prDepth === "open-and-comment") { - await updateMissionFinalizationState(runId, { - policy: resolveMissionFinalizationPolicy(prStrategy), - status: "posting_review_comment", - executionComplete: true, - contractSatisfied: false, - blocked: false, - resolverJobId: resolverRun.runId, - integrationLaneId: resolverRun.integrationLaneId, - prUrls: [resolvedPr.githubUrl], - }, { graph }); - await prService.addComment({ - prId: resolvedPr.id, - body: [ - `ADE finalization summary for mission "${mission.title}":`, - "", - `- Integration conflicts were resolved by shared resolver job \`${resolverRun.runId}\`.`, - `- Changed files: ${resolverRun.changedFiles.length > 0 ? resolverRun.changedFiles.join(", ") : "not reported"}.`, - `- Review this PR before landing. This mission is complete, but follow-up work should start as a continuation.`, - ].join("\n"), - }); - } - - emitOrchestratorMessage( - mission.id, - `Integration PR #${resolvedPr.githubPrNumber} created after shared conflict resolution: ${resolvedPr.githubUrl}` - ); - logger.info("ai_orchestrator.integration_pr_created_via_shared_resolver", { - missionId: mission.id, - runId, - resolverRunId: resolverRun.runId, - prNumber: resolvedPr.githubPrNumber, - url: resolvedPr.githubUrl - }); - await updateMissionFinalizationState(runId, { - policy: resolveMissionFinalizationPolicy(prStrategy), - status: "completed", - executionComplete: true, - contractSatisfied: true, - blocked: false, - resolverJobId: resolverRun.runId, - integrationLaneId: resolverRun.integrationLaneId, - prUrls: [resolvedPr.githubUrl], - reviewStatus: prDepth === "open-and-comment" ? "comment_posted" : null, - mergeReadiness: "operator_review_required", - summary: "Execution and PR finalization completed.", - detail: `Integration PR ${resolvedPr.githubUrl} created via shared resolver job ${resolverRun.runId}.`, - warnings: resolverRun.warnings, - completedAt: nowIso(), - }, { graph }); - } - } else if (prStrategy.kind === "per-lane" && prService) { - try { - const laneIdArray = laneIdArrayBase; - const baseBranch = prStrategy.targetBranch ?? missionBaseBranch ?? "main"; - const isDraft = prStrategy.draft ?? true; - const createdPrUrls: string[] = []; - const laneFailures: string[] = []; - - for (const laneId of laneIdArray) { - try { - const prResult = await prService.createIntegrationPr({ - sourceLaneIds: [laneId], - integrationLaneName: laneId, - baseBranch, - title: `[ADE] Lane ${laneId}: ${mission.title}`, - body: `Per-lane PR for lane "${laneId}" of mission "${mission.title}".`, - draft: isDraft - }); - emitOrchestratorMessage( - mission.id, - `Per-lane PR #${prResult.pr.githubPrNumber} for ${laneId}: ${prResult.pr.githubUrl}` - ); - logger.info("ai_orchestrator.per_lane_pr_created", { - missionId: mission.id, - runId, - laneId, - prNumber: prResult.pr.githubPrNumber, - url: prResult.pr.githubUrl - }); - createdPrUrls.push(prResult.pr.githubUrl); - } catch (lanePrError) { - logger.warn("ai_orchestrator.per_lane_pr_failed", { - missionId: mission.id, - runId, - laneId, - error: lanePrError instanceof Error ? lanePrError.message : String(lanePrError) - }); - emitOrchestratorMessage( - mission.id, - `Per-lane PR for ${laneId} failed: ${lanePrError instanceof Error ? lanePrError.message : String(lanePrError)}` - ); - laneFailures.push(`${laneId}: ${lanePrError instanceof Error ? lanePrError.message : String(lanePrError)}`); - } - } - if (laneFailures.length > 0) { - await updateMissionFinalizationState(runId, { - policy: finalizationPolicy, - status: "finalization_failed", - executionComplete: true, - contractSatisfied: false, - blocked: true, - blockedReason: laneFailures.join("; "), - prUrls: createdPrUrls, - summary: "Per-lane PR finalization did not complete successfully.", - detail: laneFailures.join("\n"), - warnings: [], - }, { graph }); - } else { - await updateMissionFinalizationState(runId, { - policy: finalizationPolicy, - status: "completed", - executionComplete: true, - contractSatisfied: true, - blocked: false, - prUrls: createdPrUrls, - mergeReadiness: "operator_review_required", - summary: "Execution and per-lane PR creation completed.", - detail: `${createdPrUrls.length} PR(s) created.`, - warnings: [], - completedAt: nowIso(), - }, { graph }); - } - } catch (perLaneError) { - logger.warn("ai_orchestrator.per_lane_pr_batch_failed", { - missionId: mission.id, - runId, - error: perLaneError instanceof Error ? perLaneError.message : String(perLaneError) - }); - await updateMissionFinalizationState(runId, { - policy: finalizationPolicy, - status: "finalization_failed", - executionComplete: true, - contractSatisfied: false, - blocked: true, - blockedReason: perLaneError instanceof Error ? perLaneError.message : String(perLaneError), - summary: "Per-lane PR finalization failed.", - detail: perLaneError instanceof Error ? perLaneError.message : String(perLaneError), - warnings: [], - }, { graph }); - } - } else if (prStrategy.kind === "queue" && prService) { - try { - const laneIdArray = laneIdArrayBase; - const targetBranch = prStrategy.targetBranch ?? missionBaseBranch ?? "main"; - const autoLandQueue = prStrategy.autoLand ?? false; - const autoResolveQueueConflicts = prStrategy.autoResolveConflicts ?? false; - const queueMergeMethod = prStrategy.mergeMethod ?? "squash"; - const resolverModelId = prStrategy.conflictResolverModel ?? integrationPrPolicy.conflictResolverModel ?? null; - const resolverDescriptor = resolverModelId ? getModelById(resolverModelId) : null; - const resolverProvider = resolverDescriptor?.family === "anthropic" ? "claude" : resolverModelId ? "codex" : null; - - const queueResult = await prService.createQueuePrs({ - laneIds: laneIdArray, - targetBranch, - draft: prStrategy.draft ?? true, - autoRebase: prStrategy.autoRebase ?? true, - ciGating: prStrategy.ciGating ?? false - }); - - for (const pr of queueResult.prs) { - emitOrchestratorMessage( - mission.id, - `Queue PR #${pr.githubPrNumber} created: ${pr.githubUrl}` - ); - } - for (const err of queueResult.errors) { - emitOrchestratorMessage( - mission.id, - `Queue PR for ${err.laneId} failed: ${err.error}` - ); - } - logger.info("ai_orchestrator.queue_prs_created", { - missionId: mission.id, - runId, - groupId: queueResult.groupId, - prCount: queueResult.prs.length, - errorCount: queueResult.errors.length - }); - if (queueResult.errors.length > 0) { - await updateMissionFinalizationState(runId, { - policy: finalizationPolicy, - status: "finalization_failed", - executionComplete: true, - contractSatisfied: false, - blocked: true, - blockedReason: queueResult.errors.map((entry) => `${entry.laneId}: ${entry.error}`).join("; "), - queueGroupId: queueResult.groupId, - prUrls: queueResult.prs.map((entry) => entry.githubUrl), - summary: "Queue PR finalization failed for one or more lanes.", - detail: queueResult.errors.map((entry) => `${entry.laneId}: ${entry.error}`).join("\n"), - warnings: [], - }, { graph }); - } else if (autoLandQueue && queueLandingService) { - await updateMissionFinalizationState(runId, { - policy: finalizationPolicy, - status: "landing_queue", - executionComplete: true, - contractSatisfied: false, - blocked: false, - queueGroupId: queueResult.groupId, - prUrls: queueResult.prs.map((entry) => entry.githubUrl), - mergeReadiness: prStrategy.ciGating ? "waiting_for_green" : "queue_landing", - summary: "Execution finished. Queue landing has started and must complete before the mission can close out.", - detail: `Queue group ${queueResult.groupId} created with ${queueResult.prs.length} PR(s).`, - warnings: [], - }, { graph }); - await queueLandingService.startQueue({ - groupId: queueResult.groupId, - method: queueMergeMethod, - archiveLane: prStrategy.archiveLaneOnLand ?? false, - autoResolve: autoResolveQueueConflicts, - ciGating: prStrategy.ciGating ?? false, - resolverProvider, - resolverModel: resolverModelId, - reasoningEffort: prStrategy.reasoningEffort ?? null, - permissionMode: prStrategy.permissionMode ?? "guarded_edit", - originSurface: "mission", - originMissionId: mission.id, - originRunId: runId, - originLabel: mission.title, - }); - } else { - await updateMissionFinalizationState(runId, { - policy: finalizationPolicy, - status: "completed", - executionComplete: true, - contractSatisfied: true, - blocked: false, - queueGroupId: queueResult.groupId, - prUrls: queueResult.prs.map((entry) => entry.githubUrl), - mergeReadiness: prStrategy.ciGating ? "waiting_for_green" : "operator_review_required", - summary: "Execution and queue PR creation completed.", - detail: `Queue group ${queueResult.groupId} created with ${queueResult.prs.length} PR(s). Queue landing remains operator-driven.`, - warnings: [], - completedAt: nowIso(), - }, { graph }); - } - } catch (queueError) { - logger.warn("ai_orchestrator.queue_pr_creation_failed", { - missionId: mission.id, - runId, - error: queueError instanceof Error ? queueError.message : String(queueError) - }); - emitOrchestratorMessage( - mission.id, - `Queue PR creation failed: ${queueError instanceof Error ? queueError.message : String(queueError)}` - ); - await updateMissionFinalizationState(runId, { - policy: finalizationPolicy, - status: "finalization_failed", - executionComplete: true, - contractSatisfied: false, - blocked: true, - blockedReason: queueError instanceof Error ? queueError.message : String(queueError), - summary: "Queue PR finalization failed.", - detail: queueError instanceof Error ? queueError.message : String(queueError), - warnings: [], - }, { graph }); - } } - } catch (prStrategyError) { - const finalizationErrorMessage = prStrategyError instanceof Error ? prStrategyError.message : String(prStrategyError); - logger.debug("ai_orchestrator.pr_strategy_trigger_failed", { + } catch (finalizationError) { + const finalizationErrorMessage = finalizationError instanceof Error ? finalizationError.message : String(finalizationError); + logger.debug("ai_orchestrator.result_lane_finalization_failed", { runId, missionId: mission.id, error: finalizationErrorMessage }); await updateMissionFinalizationState(runId, { - policy: resolveMissionFinalizationPolicy(resolveActivePhaseSettings(mission.id).settings.prStrategy ?? { kind: "manual" }), + policy: finalizationPolicy, status: "finalization_failed", executionComplete: true, contractSatisfied: false, @@ -7059,11 +6593,11 @@ Check all worker statuses and continue managing the mission from here. Read work : stateDocAfterFinalization.finalization.detail, completedAt: unmetRequirements.length > 0 ? null : stateDocAfterFinalization.finalization.completedAt, }, { graph }); - stateDocAfterFinalization = projectRoot + stateDocAfterFinalization = projectRoot ? await readMissionStateDocument({ projectRoot, runId }).catch(() => null) : stateDocAfterFinalization; } - const finalizationSatisfied = stateDocAfterFinalization?.finalization?.contractSatisfied ?? (prStrategy.kind === "manual"); + const finalizationSatisfied = stateDocAfterFinalization?.finalization?.contractSatisfied ?? false; const finalizationFailed = stateDocAfterFinalization?.finalization?.status === "finalization_failed"; if (finalizationFailed) { transitionMissionStatus(mission.id, "failed", { @@ -7157,6 +6691,23 @@ Check all worker statuses and continue managing the mission from here. Read work if (!missionId.length) throw new Error("missionId is required."); const initialMission = missionService.get(missionId); if (!initialMission) throw new Error(`Mission not found: ${missionId}`); + const queueClaimToken = toOptionalString(args.queueClaimToken); + if (initialMission.status === "queued") { + const claimed = missionService.beginStart(missionId, queueClaimToken ?? null); + if (!claimed) { + logger.debug("ai_orchestrator.mission_start_claim_skipped", { + missionId, + queueClaimToken, + reason: "queued_claim_not_acquired", + }); + return { + started: null, + mission: missionService.get(missionId), + }; + } + } else if (queueClaimToken) { + missionService.clearQueueClaim(missionId, queueClaimToken); + } const missionGoal = initialMission.prompt || initialMission.title; @@ -7406,6 +6957,7 @@ Check all worker statuses and continue managing the mission from here. Read work startupStage = "run_activate"; const activatedRun = orchestratorService.activateRun(startedRunId); + persistMissionLaneIdForRun(startedRunId, missionLaneId); transitionMissionStatus(missionId, "in_progress"); if (initialPhase?.phaseKey.trim().toLowerCase() !== "planning") { emitOrchestratorMessage( @@ -7487,8 +7039,9 @@ Check all worker statuses and continue managing the mission from here. Read work if (typeof launch.laneStrategy === "string") userRules.laneStrategy = launch.laneStrategy; if (typeof launch.customInstructions === "string") userRules.customInstructions = launch.customInstructions; if (args.defaultExecutorKind) userRules.providerPreference = args.defaultExecutorKind; + userRules.closeoutContract = "Promote or assemble one result lane at mission closeout. Never create a PR automatically."; - // Read phase-based settings and pass budget/recovery/PR/model fields directly into userRules + // Read phase-based settings and pass budget/recovery/model fields directly into userRules const { settings: phaseSettings } = resolveActivePhaseSettings(missionId); const coordinatorModelConfig = resolveOrchestratorModelConfig(missionId, "coordinator"); if (coordinatorModelConfig) userRules.coordinatorModel = modelConfigToServiceModel(coordinatorModelConfig); @@ -7496,7 +7049,6 @@ Check all worker statuses and continue managing the mission from here. Read work userRules.recoveryEnabled = phaseSettings.recoveryLoop.enabled; userRules.recoveryMaxIterations = phaseSettings.recoveryLoop.maxIterations; } - if (phaseSettings.prStrategy?.kind) userRules.prStrategy = phaseSettings.prStrategy.kind; // Pass budget limits if configured const budgetConfig = isRecord(launch.budgetConfig) ? launch.budgetConfig : null; if (budgetConfig) { @@ -9341,7 +8893,7 @@ Check all worker statuses and continue managing the mission from here. Read work ? buildMissionCloseoutRequirements({ mission, graph, - policy: resolveMissionFinalizationPolicy(resolveActivePhaseSettings(mission.id).settings.prStrategy ?? { kind: "manual" }), + policy: resolveMissionFinalizationPolicyForMission(mission.id), finalization: stateDoc?.finalization ?? null, stateDoc, }) @@ -9955,7 +9507,6 @@ Check all worker statuses and continue managing the mission from here. Read work startMissionRun, cancelRunGracefully, cleanupTeamResources, - onQueueLandingStateChanged, onOrchestratorRuntimeEvent(event: OrchestratorRuntimeEvent) { if (disposed) return; diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts index 1dd48bfe4..57e40463e 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts @@ -75,7 +75,7 @@ export type CoordinatorUserRules = { laneStrategy?: string; customInstructions?: string; coordinatorModel?: string; - prStrategy?: string; + closeoutContract?: string; budgetLimitUsd?: number; budgetLimitTokens?: number; recoveryEnabled?: boolean; @@ -1817,7 +1817,7 @@ export class CoordinatorAgent { if (rules.laneStrategy) ruleLines.push(`- Lane strategy: ${rules.laneStrategy}`); if (rules.customInstructions) ruleLines.push(`- Custom instructions: ${rules.customInstructions}`); if (rules.coordinatorModel) ruleLines.push(`- Coordinator model: ${rules.coordinatorModel} (your model — user selected this, do not change)`); - if (rules.prStrategy) ruleLines.push(`- PR strategy: ${rules.prStrategy} (${rules.prStrategy === "manual" ? "user will create PRs manually" : rules.prStrategy === "per-lane" ? "create a PR per lane" : "create an integration PR"})`); + if (rules.closeoutContract) ruleLines.push(`- Closeout contract: ${rules.closeoutContract}`); if (rules.budgetLimitUsd != null) ruleLines.push(`- Budget limit: $${rules.budgetLimitUsd.toFixed(2)} USD (HARD LIMIT — do not exceed)`); if (rules.budgetLimitTokens != null) ruleLines.push(`- Token budget limit: ${rules.budgetLimitTokens.toLocaleString()} tokens (HARD LIMIT)`); if (rules.recoveryEnabled != null) ruleLines.push(`- Recovery loops: ${rules.recoveryEnabled ? `enabled (max ${rules.recoveryMaxIterations ?? 3} iterations)` : "disabled — do not retry failed quality gates"}`); @@ -1841,7 +1841,7 @@ export class CoordinatorAgent { if (p.validationGate.tier !== "none") parts.push(` Validation: ${p.validationGate.tier.replace("-", " ")} ${p.validationGate.required ? "(required)" : "(optional)"}`); if (p.askQuestions.enabled) { parts.push( - ` Ask Questions: enabled (must ask at least one clarification or confirmation question before finalizing this phase, max ${Math.max(1, Math.min(10, Number(p.askQuestions.maxQuestions ?? 5) || 5))} questions)` + ` Ask Questions: enabled (must ask at least one clarification or confirmation question before finalizing this phase${p.askQuestions.maxQuestions == null ? ", unlimited follow-up rounds allowed" : `, max ${Math.max(1, Math.min(10, Number(p.askQuestions.maxQuestions ?? 5) || 5))} questions`})` ); } else { parts.push(" Ask Questions: disabled"); @@ -1853,7 +1853,7 @@ export class CoordinatorAgent { }); phasesSection = `\n## Mission Phases (execute in order)\nThese phases define WHAT work happens. You decide HOW — how many workers, what prompts, what approach.\nQuestion rules per phase govern the ACTIVE PHASE OWNER for that phase: - If Ask Questions is enabled, the worker actively executing that phase may open blocking clarification questions with ask_user when needed. -- Additional ask_user rounds are allowed up to the phase max question limit. Avoid trivial or low-value questions. +- Additional ask_user rounds are allowed up to the phase max question limit, or without a cap when the planning phase is explicitly configured for unlimited clarifications. Avoid trivial or low-value questions. - If Ask Questions is disabled, do not ask questions in that phase; proceed with reasonable assumptions. - ask_user is transport/UI plumbing, not ownership. Coordinator should not ask planning, development, or validation questions on behalf of a worker unless there is no responsible phase worker yet and the mission cannot even be framed. - When using ask_user, bundle ALL related questions into a single call. The tool accepts an array of structured questions with optional multiple-choice options, context, default assumptions, and impact descriptions.\n${phaseLines.join("\n")}`; @@ -1990,12 +1990,12 @@ You are autonomous WITHIN user-configured settings. This means: **You FOLLOW (user constraints — never override):** - Which execution phases are enabled (development, testing, validation, code review) — skip disabled phases, run enabled ones - Model selection — use the configured coordinator and worker models -- PR strategy — create PRs according to the user's chosen strategy +- Closeout contract — finish with a single result lane that contains the consolidated mission changes - Budget limits — hard caps on cost/tokens are guardrails, not suggestions - Model selection — use available model IDs as configured - Thinking budgets / reasoning effort — respect per-model settings -If the user disabled testing, do NOT spawn test workers. If the user set a specific worker model, use THAT model. If the user chose manual PR strategy, do NOT create PRs automatically. You decide HOW to accomplish the mission — the user decides WHAT constraints you operate under. +If the user disabled testing, do NOT spawn test workers. If the user set a specific worker model, use THAT model. Do not open or land PRs during mission closeout. You decide HOW to accomplish the mission — the user decides WHAT constraints you operate under. ## Scope Awareness — Right-Size Your Approach @@ -2024,7 +2024,7 @@ Match your approach to the mission's actual complexity: - Use when tasks might touch overlapping files - Use for large, isolated workstreams that benefit from clean git history - Each lane is a fresh git worktree branching from the base — cheap to create -- Lanes merge back via the configured PR strategy +- Lanes merge back into a single mission result lane during closeout Do NOT overcomplicate simple tasks. A one-file bug fix does not need 3 workers, 5 milestones, and a validation gate. If planning is enabled and the task is tiny, default to one planning worker, one implementation worker on the mission lane, and one validator only if validation is enabled or the change is genuinely risky. Read the code, understand the scope, and scale your approach accordingly. The overhead of coordination should never exceed the cost of the work itself. diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts b/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts index f8f098c48..6289dc582 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorTools.ts @@ -1727,7 +1727,7 @@ export function createCoordinatorToolSet(deps: { phase: PhaseCard | null; phaseKey: string; enabled: boolean; - maxQuestions: number; + maxQuestions: number | null; isPlanning: boolean; } { const phases = resolveMissionPhases(g); @@ -1739,7 +1739,9 @@ export function createCoordinatorToolSet(deps: { phase: current ?? null, phaseKey: current?.phaseKey ?? current?.name ?? "", enabled: current?.askQuestions.enabled === true, - maxQuestions: Math.max(1, Math.min(10, Number(current?.askQuestions.maxQuestions ?? 5) || 5)), + maxQuestions: inPlanning && current?.askQuestions.maxQuestions == null + ? null + : Math.max(1, Math.min(10, Number(current?.askQuestions.maxQuestions ?? 5) || 5)), isPlanning: inPlanning, }; } @@ -1748,7 +1750,7 @@ export function createCoordinatorToolSet(deps: { phase: PhaseCard | null; phaseKey: string; enabled: boolean; - maxQuestions: number; + maxQuestions: number | null; } { const policy = resolveCurrentPhaseQuestionPolicy(g); return { @@ -5956,7 +5958,7 @@ Format: Lead with the concrete rule or fact, then brief context for WHY. One act // VAL-PLAN-006: Multi-round deliberation — when phase has canLoop=true, // bypass the maxQuestions ceiling to allow unbounded ask_user + re-plan cycles. const phaseCanLoop = policy.phase?.orderingConstraints?.canLoop === true; - if (!phaseCanLoop && priorInterventions.length >= policy.maxQuestions) { + if (!phaseCanLoop && policy.maxQuestions != null && priorInterventions.length >= policy.maxQuestions) { return { ok: false as const, error: `This Planning phase already reached its Ask Questions limit (${policy.maxQuestions}). Continue with the best grounded assumptions you can.`, diff --git a/apps/desktop/src/main/services/orchestrator/missionLifecycle.ts b/apps/desktop/src/main/services/orchestrator/missionLifecycle.ts index 413bbfb59..817ef404e 100644 --- a/apps/desktop/src/main/services/orchestrator/missionLifecycle.ts +++ b/apps/desktop/src/main/services/orchestrator/missionLifecycle.ts @@ -43,6 +43,7 @@ import type { MissionLevelSettings, PhaseCard, } from "../../../shared/types"; +import { TERMINAL_MISSION_STATUSES } from "../../../shared/types"; import { resolveExecutionPolicy, DEFAULT_EXECUTION_POLICY } from "./executionPolicy"; import { getMissionMetadata, getMissionIdForRun } from "./chatMessageService"; import { readDocPaths } from "./stepPolicyResolver"; @@ -438,6 +439,14 @@ export function transitionMissionStatus( const mission = ctx.missionService.get(missionId); if (!mission) return; if (mission.status === next && args?.outcomeSummary == null && args?.lastError == null) return; + if (TERMINAL_MISSION_STATUSES.has(mission.status) && !TERMINAL_MISSION_STATUSES.has(next)) { + ctx.logger.debug("ai_orchestrator.mission_status_regression_skipped", { + missionId, + from: mission.status, + to: next, + }); + return; + } // VAL-STATE-001: Before transitioning to intervention_required, pause all // active runs for this mission so we never have an active run while the diff --git a/apps/desktop/src/main/services/orchestrator/missionStateDoc.ts b/apps/desktop/src/main/services/orchestrator/missionStateDoc.ts index 8af7371c0..09372dc9c 100644 --- a/apps/desktop/src/main/services/orchestrator/missionStateDoc.ts +++ b/apps/desktop/src/main/services/orchestrator/missionStateDoc.ts @@ -199,7 +199,7 @@ function normalizeFinalizationPolicy(value: unknown): MissionFinalizationPolicy const raw = isRecord(value) ? value : null; if (!raw) return null; const kind = - raw.kind === "disabled" || raw.kind === "manual" || raw.kind === "integration" || raw.kind === "per-lane" || raw.kind === "queue" + raw.kind === "disabled" || raw.kind === "manual" || raw.kind === "integration" || raw.kind === "per-lane" || raw.kind === "queue" || raw.kind === "result_lane" ? raw.kind : null; if (!kind) return null; @@ -255,6 +255,7 @@ function normalizeFinalizationState(value: unknown): MissionFinalizationState | detail: nullableString(raw.detail), resolverJobId: nullableString(raw.resolverJobId), integrationLaneId: nullableString(raw.integrationLaneId), + resultLaneId: nullableString(raw.resultLaneId), queueGroupId: nullableString(raw.queueGroupId), queueId: nullableString(raw.queueId), activePrId: nullableString(raw.activePrId), diff --git a/apps/desktop/src/main/services/orchestrator/orchestrationRuntime.test.ts b/apps/desktop/src/main/services/orchestrator/orchestrationRuntime.test.ts index 32f76084a..20ef2e7da 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestrationRuntime.test.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestrationRuntime.test.ts @@ -704,7 +704,8 @@ describe("mandatory planning enforcement", () => { const builtIn = createBuiltInPhaseCards(); const planningCard = builtIn.find((c: any) => c.phaseKey === "planning"); expect(planningCard).toBeDefined(); - expect(planningCard!.requiresApproval).toBe(false); + expect(planningCard!.requiresApproval).toBe(true); + expect(planningCard!.askQuestions.maxQuestions).toBeNull(); expect(planningCard!.orderingConstraints.mustBeFirst).toBe(true); }); }); diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts b/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts index a14e3017c..a842a79c6 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts @@ -10,7 +10,6 @@ import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; import { nowIso } from "../shared/utils"; import type { GetModelCapabilitiesResult } from "../../../shared/types"; -import { getModelById } from "../../../shared/modelRegistry"; import type { MissionDetail, MissionExecutionPolicy, @@ -53,9 +52,6 @@ import type { RecoveryDiagnosisTier, RecoveryDiagnosis, OrchestratorContextView, - IntegrationPrPolicy, - PrDepth, - PrStrategy, OrchestratorArtifactKind, ModelConfig, MissionModelConfig, @@ -113,9 +109,6 @@ export type { RecoveryDiagnosisTier, RecoveryDiagnosis, OrchestratorContextView, - IntegrationPrPolicy, - PrDepth, - PrStrategy, OrchestratorArtifactKind, ModelConfig, MissionModelConfig, @@ -128,6 +121,7 @@ export type { export type MissionRunStartArgs = { missionId: string; + queueClaimToken?: string | null; runMode?: "autopilot" | "manual"; autopilotOwnerId?: string; defaultExecutorKind?: OrchestratorExecutorKind; @@ -283,20 +277,6 @@ export type CoordinatorSessionEntry = { pendingInit: Promise | null; }; -export type PendingIntegrationContext = { - proposalId: string; - missionId: string; - integrationLaneName: string; - integrationLaneId: string; - baseBranch: string; - isDraft: boolean; - prDepth: PrDepth; - conflictStepKeys: string[]; - reviewStepKey: string | null; - laneIdArray: string[]; - missionTitle: string; -}; - export type ParallelMissionStepDescriptor = { id: string; index: number; @@ -423,7 +403,6 @@ export type OrchestratorContext = { aiTimeoutBudgetRunLocks: Set; aiRetryDecisionLocks: Set; coordinatorSessions: Map; - pendingIntegrations: Map; coordinatorThinkingLoops: Map; pendingCoordinatorEvals: Map; diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorSmoke.test.ts b/apps/desktop/src/main/services/orchestrator/orchestratorSmoke.test.ts index 93dc6df38..55d9df6be 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorSmoke.test.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorSmoke.test.ts @@ -255,8 +255,9 @@ async function createSmokeFixture() { ); const missionService = createMissionService({ db, projectId }); + let resultLaneCounter = 0; const laneService = { - list: async () => db.all<{ + list: async ({ includeArchived }: { includeArchived?: boolean } = {}) => db.all<{ id: string; project_id: string; name: string; @@ -266,6 +267,9 @@ async function createSmokeFixture() { worktree_path: string | null; attached_root_path: string | null; status: string | null; + archived_at: string | null; + mission_id: string | null; + lane_role: string | null; }>( ` select @@ -277,13 +281,16 @@ async function createSmokeFixture() { branch_ref, worktree_path, attached_root_path, - status + status, + archived_at, + mission_id, + lane_role from lanes where project_id = ? - and archived_at is null + and (? = 1 or archived_at is null) order by created_at asc, id asc `, - [projectId] + [projectId, includeArchived ? 1 : 0] ).map((row) => ({ id: row.id, projectId: row.project_id, @@ -293,9 +300,19 @@ async function createSmokeFixture() { branchRef: row.branch_ref, worktreePath: row.worktree_path, attachedRootPath: row.attached_root_path, - status: row.status === "archived" ? "archived" : "active", + status: row.archived_at ? "archived" : row.status === "archived" ? "archived" : "active", + missionId: row.mission_id, + laneRole: row.lane_role, + archivedAt: row.archived_at, })), - createChild: async (args: { parentLaneId: string; name: string; description?: string; folder?: string }) => { + createChild: async (args: { + parentLaneId: string; + name: string; + description?: string; + folder?: string; + missionId?: string | null; + laneRole?: string | null; + }) => { const childId = `lane-${Math.random().toString(36).slice(2, 10)}`; const childBranch = `mission/${childId}`; db.run( @@ -315,10 +332,13 @@ async function createSmokeFixture() { color, icon, tags_json, + folder, + mission_id, + lane_role, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ childId, @@ -335,6 +355,9 @@ async function createSmokeFixture() { null, null, JSON.stringify(args.folder ? [args.folder] : []), + args.folder ?? null, + args.missionId ?? null, + args.laneRole ?? null, "active", now, null, @@ -345,8 +368,114 @@ async function createSmokeFixture() { name: args.name, branchRef: childBranch, laneType: "worktree", + worktreePath: projectRoot, + missionId: args.missionId ?? null, + laneRole: args.laneRole ?? null, }; - } + }, + archive: async ({ laneId: targetLaneId }: { laneId: string }) => { + db.run( + `update lanes set status = 'archived', archived_at = ? where id = ? and project_id = ?`, + [new Date().toISOString(), targetLaneId, projectId] + ); + }, + setMissionOwnership: async ({ + laneId: targetLaneId, + missionId, + laneRole, + }: { + laneId: string; + missionId: string | null; + laneRole?: string | null; + }) => { + db.run( + `update lanes set mission_id = ?, lane_role = ? where id = ? and project_id = ?`, + [missionId, laneRole ?? null, targetLaneId, projectId] + ); + }, + getLaneWorktreePath: (targetLaneId: string) => { + const row = db.get<{ worktree_path: string | null }>( + `select worktree_path from lanes where id = ? and project_id = ? limit 1`, + [targetLaneId, projectId] + ); + return row?.worktree_path ?? projectRoot; + }, + } as any; + const prService = { + createIntegrationLane: vi.fn(async ({ + sourceLaneIds, + integrationLaneName, + missionId, + laneRole, + }: { + sourceLaneIds: string[]; + integrationLaneName: string; + missionId?: string | null; + laneRole?: string | null; + }) => { + resultLaneCounter += 1; + const resultLaneId = `result-lane-${resultLaneCounter}`; + db.run( + ` + insert into lanes( + id, + project_id, + name, + description, + lane_type, + base_ref, + branch_ref, + worktree_path, + attached_root_path, + is_edit_protected, + parent_lane_id, + color, + icon, + tags_json, + folder, + mission_id, + lane_role, + status, + created_at, + archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + resultLaneId, + projectId, + integrationLaneName, + "Result lane for smoke mission", + "worktree", + "main", + `integration/${resultLaneId}`, + projectRoot, + null, + 0, + laneId, + null, + null, + null, + null, + missionId ?? null, + laneRole ?? "result", + "active", + new Date().toISOString(), + null, + ] + ); + return { + integrationLane: { + id: resultLaneId, + name: integrationLaneName, + laneType: "worktree", + branchRef: `integration/${resultLaneId}`, + worktreePath: projectRoot, + missionId: missionId ?? null, + laneRole: laneRole ?? "result", + }, + mergeResults: sourceLaneIds.map((sourceLaneId) => ({ laneId: sourceLaneId, success: true })), + }; + }), } as any; const projectConfigService = { get: () => ({ @@ -369,10 +498,11 @@ async function createSmokeFixture() { logger: createLogger(), missionService, orchestratorService, - aiIntegrationService: createMockAiIntegrationService(), - laneService, - projectConfigService, - projectRoot + aiIntegrationService: createMockAiIntegrationService(), + laneService, + projectConfigService, + prService, + projectRoot }); return { @@ -781,8 +911,67 @@ describe("orchestrator smoke", () => { } as any; let laneCounter = 0; + let resultLaneCounter = 0; const laneService = { - createChild: vi.fn().mockImplementation(async ({ parentLaneId, name }: { parentLaneId: string; name: string }) => { + list: vi.fn(async ({ includeArchived }: { includeArchived?: boolean } = {}) => db.all<{ + id: string; + project_id: string; + name: string; + lane_type: string | null; + base_ref: string | null; + branch_ref: string | null; + worktree_path: string | null; + attached_root_path: string | null; + status: string | null; + archived_at: string | null; + mission_id: string | null; + lane_role: string | null; + }>( + ` + select + id, + project_id, + name, + lane_type, + base_ref, + branch_ref, + worktree_path, + attached_root_path, + status, + archived_at, + mission_id, + lane_role + from lanes + where project_id = ? + and (? = 1 or archived_at is null) + order by created_at asc, id asc + `, + [projectId, includeArchived ? 1 : 0] + ).map((row) => ({ + id: row.id, + projectId: row.project_id, + name: row.name, + laneType: row.lane_type === "primary" ? "primary" : "worktree", + baseRef: row.base_ref, + branchRef: row.branch_ref, + worktreePath: row.worktree_path, + attachedRootPath: row.attached_root_path, + status: row.archived_at ? "archived" : row.status === "archived" ? "archived" : "active", + missionId: row.mission_id, + laneRole: row.lane_role, + archivedAt: row.archived_at, + }))), + createChild: vi.fn().mockImplementation(async ({ + parentLaneId, + name, + missionId, + laneRole, + }: { + parentLaneId: string; + name: string; + missionId?: string | null; + laneRole?: string | null; + }) => { laneCounter += 1; const id = `lane-child-${laneCounter}`; db.run( @@ -802,10 +991,13 @@ describe("orchestrator smoke", () => { color, icon, tags_json, + folder, + mission_id, + lane_role, status, created_at, archived_at - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ id, @@ -822,13 +1014,127 @@ describe("orchestrator smoke", () => { null, null, null, + null, + missionId ?? null, + laneRole ?? null, "active", new Date().toISOString(), null ] ); - return { id, name }; - }) + return { + id, + name, + laneType: "worktree", + branchRef: `feature/${id}`, + worktreePath: projectRoot, + missionId: missionId ?? null, + laneRole: laneRole ?? null, + }; + }), + archive: vi.fn(async ({ laneId: targetLaneId }: { laneId: string }) => { + db.run( + `update lanes set status = 'archived', archived_at = ? where id = ? and project_id = ?`, + [new Date().toISOString(), targetLaneId, projectId] + ); + }), + setMissionOwnership: vi.fn(async ({ + laneId: targetLaneId, + missionId, + laneRole, + }: { + laneId: string; + missionId: string | null; + laneRole?: string | null; + }) => { + db.run( + `update lanes set mission_id = ?, lane_role = ? where id = ? and project_id = ?`, + [missionId, laneRole ?? null, targetLaneId, projectId] + ); + }), + getLaneWorktreePath: vi.fn((targetLaneId: string) => { + const row = db.get<{ worktree_path: string | null }>( + `select worktree_path from lanes where id = ? and project_id = ? limit 1`, + [targetLaneId, projectId] + ); + return row?.worktree_path ?? projectRoot; + }), + } as any; + const prService = { + createIntegrationLane: vi.fn(async ({ + sourceLaneIds, + integrationLaneName, + missionId, + laneRole, + }: { + sourceLaneIds: string[]; + integrationLaneName: string; + missionId?: string | null; + laneRole?: string | null; + }) => { + resultLaneCounter += 1; + const resultLaneId = `complex-result-${resultLaneCounter}`; + db.run( + ` + insert into lanes( + id, + project_id, + name, + description, + lane_type, + base_ref, + branch_ref, + worktree_path, + attached_root_path, + is_edit_protected, + parent_lane_id, + color, + icon, + tags_json, + folder, + mission_id, + lane_role, + status, + created_at, + archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + resultLaneId, + projectId, + integrationLaneName, + "Complex smoke result lane", + "worktree", + "main", + `integration/${resultLaneId}`, + projectRoot, + null, + 0, + laneId, + null, + null, + null, + null, + missionId ?? null, + laneRole ?? "result", + "active", + new Date().toISOString(), + null, + ] + ); + return { + integrationLane: { + id: resultLaneId, + name: integrationLaneName, + laneType: "worktree", + branchRef: `integration/${resultLaneId}`, + worktreePath: projectRoot, + missionId: missionId ?? null, + laneRole: laneRole ?? "result", + }, + mergeResults: sourceLaneIds.map((sourceLaneId) => ({ laneId: sourceLaneId, success: true })), + }; + }), } as any; const orchestratorService = createOrchestratorService({ @@ -845,6 +1151,7 @@ describe("orchestrator smoke", () => { laneService, projectConfigService, aiIntegrationService, + prService, projectRoot }); @@ -860,6 +1167,7 @@ describe("orchestrator smoke", () => { prompt: complexPrompt, laneId }); + setMissionPlanningMode(db, mission.id, "off"); orchestratorService.registerExecutorAdapter({ kind: "codex", @@ -915,6 +1223,20 @@ describe("orchestrator smoke", () => { }); if (!launch.started) throw new Error("Expected complex mission run to start"); const runId = launch.started.run.id; + const initialRunRow = db.get<{ metadata_json: string | null }>( + `select metadata_json from orchestrator_runs where id = ? limit 1`, + [runId] + ); + const initialRunMetadata = initialRunRow?.metadata_json + ? (JSON.parse(initialRunRow.metadata_json) as Record) + : {}; + delete initialRunMetadata.phaseConfiguration; + delete initialRunMetadata.phaseOverride; + delete initialRunMetadata.phaseRuntime; + db.run( + `update orchestrator_runs set metadata_json = ?, updated_at = ? where id = ?`, + [JSON.stringify(initialRunMetadata), new Date().toISOString(), runId] + ); // In the AI-first flow, startMissionRun creates an empty run. // Simulate the coordinator creating child lanes and adding steps. @@ -947,7 +1269,13 @@ describe("orchestrator smoke", () => { dependencyStepKeys: [], executorKind: "unified", laneId, - metadata: { instructions: "Implement GET /api/health", modelId: workerModelId, taskType: "implementation" } + metadata: { + instructions: "Implement GET /api/health", + modelId: workerModelId, + taskType: "implementation", + phaseKey: "development", + phaseName: "Development", + } }, { stepKey: "runtime-watchdog-hardening", @@ -956,7 +1284,13 @@ describe("orchestrator smoke", () => { dependencyStepKeys: [], executorKind: "unified", laneId: childLane1.id, - metadata: { instructions: "Improve stall detection", modelId: workerModelId, taskType: "implementation" } + metadata: { + instructions: "Improve stall detection", + modelId: workerModelId, + taskType: "implementation", + phaseKey: "development", + phaseName: "Development", + } }, { stepKey: "ui-telemetry-panel", @@ -965,7 +1299,13 @@ describe("orchestrator smoke", () => { dependencyStepKeys: [], executorKind: "unified", laneId: childLane2.id, - metadata: { instructions: "Expose telemetry in UI", modelId: workerModelId, taskType: "implementation" } + metadata: { + instructions: "Expose telemetry in UI", + modelId: workerModelId, + taskType: "implementation", + phaseKey: "development", + phaseName: "Development", + } }, { stepKey: "integration-contract-check", @@ -974,7 +1314,13 @@ describe("orchestrator smoke", () => { dependencyStepKeys: ["api-health-route", "runtime-watchdog-hardening", "ui-telemetry-panel"], executorKind: "unified", laneId, - metadata: { instructions: "Validate interface compatibility", modelId: workerModelId, taskType: "integration" } + metadata: { + instructions: "Validate interface compatibility", + modelId: workerModelId, + taskType: "integration", + phaseKey: "development", + phaseName: "Development", + } }, { stepKey: "docs-and-readme", @@ -983,7 +1329,13 @@ describe("orchestrator smoke", () => { dependencyStepKeys: ["integration-contract-check"], executorKind: "unified", laneId, - metadata: { instructions: "Document changes", modelId: workerModelId, taskType: "implementation" } + metadata: { + instructions: "Document changes", + modelId: workerModelId, + taskType: "implementation", + phaseKey: "development", + phaseName: "Development", + } }, { stepKey: "test-matrix", @@ -992,7 +1344,13 @@ describe("orchestrator smoke", () => { dependencyStepKeys: ["integration-contract-check"], executorKind: "unified", laneId, - metadata: { instructions: "Run tests", modelId: workerModelId, taskType: "test" } + metadata: { + instructions: "Run tests", + modelId: workerModelId, + taskType: "test", + phaseKey: "testing", + phaseName: "Testing", + } }, { stepKey: "rollback-and-risk-check", @@ -1001,7 +1359,13 @@ describe("orchestrator smoke", () => { dependencyStepKeys: ["integration-contract-check"], executorKind: "unified", laneId, - metadata: { instructions: "Verify rollback path", modelId: workerModelId, taskType: "validation" } + metadata: { + instructions: "Verify rollback path", + modelId: workerModelId, + taskType: "validation", + phaseKey: "validation", + phaseName: "Validation", + } }, { stepKey: "final-review-gate", @@ -1010,7 +1374,13 @@ describe("orchestrator smoke", () => { dependencyStepKeys: ["docs-and-readme", "test-matrix", "rollback-and-risk-check"], executorKind: "unified", laneId, - metadata: { instructions: "Final review", modelId: workerModelId, taskType: "milestone" } + metadata: { + instructions: "Final review", + modelId: workerModelId, + taskType: "milestone", + phaseKey: "validation", + phaseName: "Validation", + } } ] }); @@ -1044,6 +1414,25 @@ describe("orchestrator smoke", () => { let terminalReached = false; for (let i = 0; i < 120; i += 1) { const graph = orchestratorService.getRunGraph({ runId, timelineLimit: 0 }); + for (const step of graph.steps) { + const meta = step.metadata && typeof step.metadata === "object" && !Array.isArray(step.metadata) + ? (step.metadata as Record) + : {}; + if (step.stepKey !== "planner-launch-tracker" && meta.plannerLaunchTracker !== true) continue; + if (step.status !== "pending" && step.status !== "ready" && step.status !== "running") continue; + const skippedAt = new Date().toISOString(); + db.run( + ` + update orchestrator_steps + set status = 'skipped', + started_at = coalesce(started_at, ?), + completed_at = ?, + updated_at = ? + where id = ? + `, + [skippedAt, skippedAt, skippedAt, step.id] + ); + } const status = graph.run.status; if (status === "succeeded" || status === "failed" || status === "canceled") { terminalReached = true; diff --git a/apps/desktop/src/main/services/orchestrator/promptInspector.ts b/apps/desktop/src/main/services/orchestrator/promptInspector.ts index 5b4f53607..5c427084c 100644 --- a/apps/desktop/src/main/services/orchestrator/promptInspector.ts +++ b/apps/desktop/src/main/services/orchestrator/promptInspector.ts @@ -453,7 +453,7 @@ function buildCoordinatorRulesSection(rules: CoordinatorUserRules | undefined): if (rules.laneStrategy) ruleLines.push(`- Lane strategy: ${rules.laneStrategy}`); if (rules.customInstructions) ruleLines.push(`- Custom instructions: ${rules.customInstructions}`); if (rules.coordinatorModel) ruleLines.push(`- Coordinator model: ${rules.coordinatorModel} (user selected)`); - if (rules.prStrategy) ruleLines.push(`- PR strategy: ${rules.prStrategy}`); + if (rules.closeoutContract) ruleLines.push(`- Closeout contract: ${rules.closeoutContract}`); if (rules.budgetLimitUsd != null) ruleLines.push(`- Budget limit: $${rules.budgetLimitUsd.toFixed(2)} USD`); if (rules.budgetLimitTokens != null) ruleLines.push(`- Token budget limit: ${rules.budgetLimitTokens.toLocaleString()} tokens`); if (rules.recoveryEnabled != null) ruleLines.push(`- Recovery loops: ${rules.recoveryEnabled ? `enabled (max ${rules.recoveryMaxIterations ?? 3} iterations)` : "disabled"}`); diff --git a/apps/desktop/src/main/services/prs/issueInventoryService.ts b/apps/desktop/src/main/services/prs/issueInventoryService.ts new file mode 100644 index 000000000..3eb94992a --- /dev/null +++ b/apps/desktop/src/main/services/prs/issueInventoryService.ts @@ -0,0 +1,447 @@ +import { randomUUID } from "node:crypto"; +import type { AdeDb } from "../state/kvDb"; +import type { + ConvergenceRoundStat, + ConvergenceStatus, + IssueInventoryItem, + IssueInventorySnapshot, + IssueInventoryState, + IssueSource, + PipelineSettings, + PrCheck, + PrComment, + PrReviewThread, +} from "../../../shared/types"; +import { DEFAULT_PIPELINE_SETTINGS } from "../../../shared/types"; +import { nowIso } from "../shared/utils"; + +// --------------------------------------------------------------------------- +// Source detection — maps GitHub comment authors to known review bot sources +// --------------------------------------------------------------------------- + +const SOURCE_PATTERNS: Array<{ pattern: RegExp; source: IssueSource }> = [ + { pattern: /^coderabbitai(\[bot\])?$/i, source: "coderabbit" }, + { pattern: /^chatgpt-codex-connector(\[bot\])?$/i, source: "codex" }, + { pattern: /^codex(\[bot\])?$/i, source: "codex" }, + { pattern: /^copilot(\[bot\])?$/i, source: "copilot" }, + { pattern: /^github-copilot(\[bot\])?$/i, source: "copilot" }, + { pattern: /^ade-review(\[bot\])?$/i, source: "ade" }, +]; + +function detectSource(author: string | null | undefined): IssueSource { + const name = (author ?? "").trim(); + if (!name) return "unknown"; + for (const { pattern, source } of SOURCE_PATTERNS) { + if (pattern.test(name)) return source; + } + return "human"; +} + +// --------------------------------------------------------------------------- +// Severity extraction — reuses the same pattern as prIssueResolver.ts +// --------------------------------------------------------------------------- + +function extractSeverity(value: string): "critical" | "major" | "minor" | null { + // Match explicit severity words + const wordMatch = value.match(/\b(Critical|Major|Minor)\b/i); + if (wordMatch?.[1]) return wordMatch[1].toLowerCase() as "critical" | "major" | "minor"; + // Match Codex P1/P2/P3 priority labels + if (/\bP1\b/.test(value)) return "critical"; + if (/\bP2\b/.test(value)) return "major"; + if (/\bP3\b/.test(value)) return "minor"; + // Match emoji severity indicators (CodeRabbit uses 🔴 🟠 etc.) + if (/🔴/.test(value)) return "critical"; + if (/🟠|⚠️/.test(value)) return "major"; + if (/🟡/.test(value)) return "minor"; + // Match "[severity]" bracket patterns + const bracketMatch = value.match(/\[(critical|major|minor|bug|error|warning|nitpick|nit)\]/i); + if (bracketMatch?.[1]) { + const label = bracketMatch[1].toLowerCase(); + if (label === "bug" || label === "error" || label === "critical") return "critical"; + if (label === "warning" || label === "major") return "major"; + return "minor"; + } + return null; +} + +// --------------------------------------------------------------------------- +// Headline extraction — compact summary for display +// --------------------------------------------------------------------------- + +function stripEmojiNoise(value: string): string { + return value + .replace(/[⚠️🔴🟠🟡🟢🔵⭐🐰🤖💡📝🚨✅❌⬆️🧹🛑✨💥🧪]/g, "") + .replace(/Potential issue\s*\|?\s*/gi, "") + .replace(/\*\*(Critical|Major|Minor|Bug|Suggestion|Nitpick|Nit)\*\*\s*[:|]?\s*/gi, "") + .replace(/^\s*[:|]\s*/, "") + .trim(); +} + +function extractHeadline(body: string | null | undefined, fallback: string): string { + const raw = (body ?? "").trim(); + if (!raw) return fallback; + // Try to extract a bold title like **Some Title** + const boldMatch = raw.match(/\*\*([^*]+)\*\*/); + if (boldMatch?.[1]) { + const title = stripEmojiNoise(boldMatch[1].trim()); + if (title.length > 0 && title.length <= 120) return title; + } + // Fall back to first line, stripped of markdown noise and emoji + const firstLine = stripEmojiNoise( + raw.split(/\r?\n/)[0] + .replace(/[#*>`_~]/g, "") + .trim(), + ); + if (firstLine.length > 0) { + return firstLine.length > 120 ? `${firstLine.slice(0, 117)}...` : firstLine; + } + return fallback; +} + +// --------------------------------------------------------------------------- +// DB row shape +// --------------------------------------------------------------------------- + +type InventoryRow = { + id: string; + pr_id: string; + source: string; + type: string; + external_id: string; + state: string; + round: number; + file_path: string | null; + line: number | null; + severity: string | null; + headline: string; + body: string | null; + author: string | null; + url: string | null; + dismiss_reason: string | null; + agent_session_id: string | null; + created_at: string; + updated_at: string; +}; + +function rowToItem(row: InventoryRow): IssueInventoryItem { + return { + id: row.id, + prId: row.pr_id, + source: row.source as IssueSource, + type: row.type as IssueInventoryItem["type"], + externalId: row.external_id, + state: row.state as IssueInventoryState, + round: row.round, + filePath: row.file_path, + line: row.line, + severity: row.severity as IssueInventoryItem["severity"], + headline: row.headline, + body: row.body, + author: row.author, + url: row.url, + dismissReason: row.dismiss_reason, + agentSessionId: row.agent_session_id, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +// --------------------------------------------------------------------------- +// Convergence helpers +// --------------------------------------------------------------------------- + +const DEFAULT_MAX_ROUNDS = 5; + +function computeConvergenceStatus(items: IssueInventoryItem[]): ConvergenceStatus { + const totalNew = items.filter((i) => i.state === "new").length; + const totalFixed = items.filter((i) => i.state === "fixed").length; + const totalDismissed = items.filter((i) => i.state === "dismissed").length; + const totalEscalated = items.filter((i) => i.state === "escalated").length; + const totalSentToAgent = items.filter((i) => i.state === "sent_to_agent").length; + + // Determine current round from max round value across all items + const currentRound = items.reduce((max, item) => Math.max(max, item.round), 0); + + // Build per-round stats + const roundMap = new Map(); + for (const item of items) { + if (item.round === 0) continue; + const stat = roundMap.get(item.round) ?? { round: item.round, newCount: 0, fixedCount: 0, dismissedCount: 0 }; + if (item.state === "sent_to_agent" || item.state === "new") stat.newCount++; + if (item.state === "fixed") stat.fixedCount++; + if (item.state === "dismissed") stat.dismissedCount++; + roundMap.set(item.round, stat); + } + const issuesPerRound = Array.from(roundMap.values()).sort((a, b) => a.round - b.round); + + // Determine if we're converging — progress was made in the last round + const lastRoundStat = issuesPerRound[issuesPerRound.length - 1]; + const isConverging = lastRoundStat ? (lastRoundStat.fixedCount + lastRoundStat.dismissedCount) > 0 : false; + + const canAutoAdvance = totalNew > 0 && currentRound < DEFAULT_MAX_ROUNDS; + + return { + currentRound, + maxRounds: DEFAULT_MAX_ROUNDS, + issuesPerRound, + totalNew, + totalFixed, + totalDismissed, + totalEscalated, + totalSentToAgent, + isConverging, + canAutoAdvance, + }; +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +export function createIssueInventoryService(deps: { db: AdeDb }) { + const { db } = deps; + + function getAllRows(prId: string): InventoryRow[] { + return db.all( + "select * from pr_issue_inventory where pr_id = ? order by created_at asc", + [prId], + ); + } + + function getItemsByState(prId: string, state: IssueInventoryState): IssueInventoryItem[] { + return db.all( + "select * from pr_issue_inventory where pr_id = ? and state = ? order by created_at asc", + [prId, state], + ).map(rowToItem); + } + + function upsertItem( + prId: string, + externalId: string, + data: { + source: IssueSource; + type: IssueInventoryItem["type"]; + filePath: string | null; + line: number | null; + severity: IssueInventoryItem["severity"]; + headline: string; + body: string | null; + author: string | null; + url: string | null; + }, + ): void { + const now = nowIso(); + const existing = db.get( + "select * from pr_issue_inventory where pr_id = ? and external_id = ?", + [prId, externalId], + ); + if (existing) { + // Update mutable fields but keep state + db.run( + `update pr_issue_inventory + set headline = ?, body = ?, severity = ?, file_path = ?, line = ?, + author = ?, url = ?, source = ?, updated_at = ? + where id = ?`, + [data.headline, data.body, data.severity, data.filePath, data.line, + data.author, data.url, data.source, now, existing.id], + ); + } else { + db.run( + `insert into pr_issue_inventory + (id, pr_id, source, type, external_id, state, round, file_path, line, + severity, headline, body, author, url, dismiss_reason, agent_session_id, + created_at, updated_at) + values (?, ?, ?, ?, ?, 'new', 0, ?, ?, ?, ?, ?, ?, ?, null, null, ?, ?)`, + [randomUUID(), prId, data.source, data.type, externalId, + data.filePath, data.line, data.severity, data.headline, data.body, + data.author, data.url, now, now], + ); + } + } + + function buildSnapshot(prId: string): IssueInventorySnapshot { + const items = getAllRows(prId).map(rowToItem); + return { + prId, + items, + convergence: computeConvergenceStatus(items), + }; + } + + return { + syncFromPrData( + prId: string, + checks: PrCheck[], + reviewThreads: PrReviewThread[], + comments: PrComment[], + ): IssueInventorySnapshot { + // Sync failing checks + for (const check of checks) { + if (check.conclusion !== "failure") continue; + upsertItem(prId, `check:${check.name}`, { + source: "unknown", + type: "check_failure", + filePath: null, + line: null, + severity: "major", + headline: `CI check "${check.name}" failing`, + body: check.detailsUrl ? `Details: ${check.detailsUrl}` : null, + author: null, + url: check.detailsUrl, + }); + } + + // Sync unresolved, non-outdated review threads + for (const thread of reviewThreads) { + if (thread.isResolved || thread.isOutdated) continue; + const firstComment = thread.comments[0] ?? null; + const author = firstComment?.author ?? null; + const body = firstComment?.body ?? null; + upsertItem(prId, `thread:${thread.id}`, { + source: detectSource(author), + type: "review_thread", + filePath: thread.path, + line: thread.line, + severity: extractSeverity(body ?? ""), + headline: extractHeadline(body, `Review thread at ${thread.path ?? "unknown"}`), + body, + author, + url: thread.url ?? firstComment?.url ?? null, + }); + } + + // Sync issue comments (top-level PR comments, not review-thread comments) + // Filter out noisy bot comments that aren't actionable + const NOISY_AUTHORS = new Set(["vercel", "vercel[bot]", "mintlify", "mintlify[bot]"]); + const NOISY_PATTERNS = [ + /\[vc\]:/i, + /mintlify-preview/i, + /this is an auto-generated comment/i, + /pre-merge checks/i, + /thanks for using \[coderabbit\]/i, + / huge summary", + source: "issue", + })], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + expect(insertCalls.length).toBe(0); + }); + + it("skips comments with source !== issue", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [], + [makeComment({ + id: "ic-review", + source: "review", + body: "This is a review comment.", + })], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + expect(insertCalls.length).toBe(0); + }); + + it("updates existing items instead of duplicating on re-sync", () => { + const db = makeMockDb(); + // Simulate existing row found for the external_id + db.get.mockReturnValue(makeFakeRow({ id: "existing-item-1", external_id: "check:ci / lint" })); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [makeCheck({ name: "ci / lint", conclusion: "failure" })], + [], + [], + ); + + // Should have called UPDATE not INSERT + const updateCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("update pr_issue_inventory"), + ); + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + expect(updateCalls.length).toBe(1); + expect(insertCalls.length).toBe(0); + }); + + it("returns a valid snapshot with convergence status", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + // After sync, buildSnapshot calls db.all — return some items + db.all.mockReturnValue([ + makeFakeRow({ id: "i-1", state: "new", round: 0 }), + makeFakeRow({ id: "i-2", state: "fixed", round: 1 }), + ]); + + const service = createIssueInventoryService({ db }); + const snapshot = service.syncFromPrData(PR_ID, [], [], []); + + expect(snapshot.prId).toBe(PR_ID); + expect(snapshot.items.length).toBe(2); + expect(snapshot.convergence.totalNew).toBe(1); + expect(snapshot.convergence.totalFixed).toBe(1); + }); + }); + + // --------------------------------------------------------------------------- + // Tests — getInventory / getNewItems + // --------------------------------------------------------------------------- + + describe("getInventory", () => { + it("returns snapshot for the given prId", () => { + const db = makeMockDb(); + db.all.mockReturnValue([ + makeFakeRow({ id: "i-1", state: "new", round: 0 }), + makeFakeRow({ id: "i-2", state: "sent_to_agent", round: 1 }), + ]); + + const service = createIssueInventoryService({ db }); + const snapshot = service.getInventory(PR_ID); + + expect(snapshot.prId).toBe(PR_ID); + expect(snapshot.items.length).toBe(2); + expect(snapshot.convergence).toBeDefined(); + expect(snapshot.convergence.totalNew).toBe(1); + expect(snapshot.convergence.totalSentToAgent).toBe(1); + }); + + it("returns empty snapshot when no items exist", () => { + const db = makeMockDb(); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + const snapshot = service.getInventory(PR_ID); + + expect(snapshot.items).toEqual([]); + expect(snapshot.convergence.currentRound).toBe(0); + expect(snapshot.convergence.totalNew).toBe(0); + expect(snapshot.convergence.canAutoAdvance).toBe(false); + }); + }); + + describe("getNewItems", () => { + it("returns only items in 'new' state", () => { + const db = makeMockDb(); + db.all.mockReturnValue([ + makeFakeRow({ id: "i-1", state: "new" }), + makeFakeRow({ id: "i-2", state: "new" }), + ]); + + const service = createIssueInventoryService({ db }); + const items = service.getNewItems(PR_ID); + + expect(items.length).toBe(2); + expect(items[0].state).toBe("new"); + // Verify the DB query was called with 'new' state filter + expect(db.all).toHaveBeenCalledWith( + expect.stringContaining("state = ?"), + [PR_ID, "new"], + ); + }); + }); + + // --------------------------------------------------------------------------- + // Tests — state transition methods + // --------------------------------------------------------------------------- + + describe("markSentToAgent", () => { + it("updates items to sent_to_agent state with round and session", () => { + const db = makeMockDb(); + const service = createIssueInventoryService({ db }); + + service.markSentToAgent(PR_ID, ["item-1", "item-2"], "session-99", 2); + + expect(db.run).toHaveBeenCalledTimes(2); + for (const call of db.run.mock.calls) { + const sql = call[0] as string; + const params = call[1] as unknown[]; + expect(sql).toContain("state = 'sent_to_agent'"); + expect(params[0]).toBe(2); // round + expect(params[1]).toBe("session-99"); // sessionId + expect(params[4]).toBe(PR_ID); // prId + } + }); + + it("handles empty itemIds array gracefully", () => { + const db = makeMockDb(); + const service = createIssueInventoryService({ db }); + + service.markSentToAgent(PR_ID, [], "session-99", 1); + expect(db.run).not.toHaveBeenCalled(); + }); + }); + + describe("markFixed", () => { + it("updates items to fixed state", () => { + const db = makeMockDb(); + const service = createIssueInventoryService({ db }); + + service.markFixed(PR_ID, ["item-1"]); + + expect(db.run).toHaveBeenCalledTimes(1); + const sql = db.run.mock.calls[0][0] as string; + expect(sql).toContain("state = 'fixed'"); + const params = db.run.mock.calls[0][1] as unknown[]; + expect(params[1]).toBe("item-1"); // id + expect(params[2]).toBe(PR_ID); // prId + }); + }); + + describe("markDismissed", () => { + it("updates items to dismissed state with reason", () => { + const db = makeMockDb(); + const service = createIssueInventoryService({ db }); + + service.markDismissed(PR_ID, ["item-1"], "Not applicable to this PR"); + + expect(db.run).toHaveBeenCalledTimes(1); + const sql = db.run.mock.calls[0][0] as string; + expect(sql).toContain("state = 'dismissed'"); + const params = db.run.mock.calls[0][1] as unknown[]; + expect(params[0]).toBe("Not applicable to this PR"); // reason + expect(params[2]).toBe("item-1"); // id + expect(params[3]).toBe(PR_ID); // prId + }); + }); + + describe("markEscalated", () => { + it("updates items to escalated state", () => { + const db = makeMockDb(); + const service = createIssueInventoryService({ db }); + + service.markEscalated(PR_ID, ["item-1", "item-2"]); + + expect(db.run).toHaveBeenCalledTimes(2); + const sql = db.run.mock.calls[0][0] as string; + expect(sql).toContain("state = 'escalated'"); + }); + }); + + // --------------------------------------------------------------------------- + // Tests — convergence computation (tested indirectly through getInventory) + // --------------------------------------------------------------------------- + + describe("convergence computation", () => { + it("computes currentRound as max round across all items", () => { + const db = makeMockDb(); + db.all.mockReturnValue([ + makeFakeRow({ id: "i-1", state: "sent_to_agent", round: 1 }), + makeFakeRow({ id: "i-2", state: "sent_to_agent", round: 3 }), + makeFakeRow({ id: "i-3", state: "new", round: 0 }), + ]); + + const service = createIssueInventoryService({ db }); + const snapshot = service.getInventory(PR_ID); + + expect(snapshot.convergence.currentRound).toBe(3); + }); + + it("builds per-round stats correctly", () => { + const db = makeMockDb(); + db.all.mockReturnValue([ + makeFakeRow({ id: "i-1", state: "fixed", round: 1 }), + makeFakeRow({ id: "i-2", state: "fixed", round: 1 }), + makeFakeRow({ id: "i-3", state: "sent_to_agent", round: 2 }), + makeFakeRow({ id: "i-4", state: "dismissed", round: 2 }), + ]); + + const service = createIssueInventoryService({ db }); + const snapshot = service.getInventory(PR_ID); + + expect(snapshot.convergence.issuesPerRound.length).toBe(2); + const round1 = snapshot.convergence.issuesPerRound.find((r) => r.round === 1); + expect(round1).toBeDefined(); + expect(round1!.fixedCount).toBe(2); + const round2 = snapshot.convergence.issuesPerRound.find((r) => r.round === 2); + expect(round2).toBeDefined(); + expect(round2!.newCount).toBe(1); // sent_to_agent counts as new + expect(round2!.dismissedCount).toBe(1); + }); + + it("isConverging = true when last round has fixes or dismissals", () => { + const db = makeMockDb(); + db.all.mockReturnValue([ + makeFakeRow({ id: "i-1", state: "fixed", round: 1 }), + makeFakeRow({ id: "i-2", state: "new", round: 0 }), + ]); + + const service = createIssueInventoryService({ db }); + const snapshot = service.getInventory(PR_ID); + + expect(snapshot.convergence.isConverging).toBe(true); + }); + + it("isConverging = false when last round has no fixes or dismissals", () => { + const db = makeMockDb(); + db.all.mockReturnValue([ + makeFakeRow({ id: "i-1", state: "sent_to_agent", round: 1 }), + makeFakeRow({ id: "i-2", state: "new", round: 0 }), + ]); + + const service = createIssueInventoryService({ db }); + const snapshot = service.getInventory(PR_ID); + + expect(snapshot.convergence.isConverging).toBe(false); + }); + + it("canAutoAdvance = true when there are new items and round < max", () => { + const db = makeMockDb(); + db.all.mockReturnValue([ + makeFakeRow({ id: "i-1", state: "new", round: 0 }), + makeFakeRow({ id: "i-2", state: "fixed", round: 1 }), + ]); + + const service = createIssueInventoryService({ db }); + const snapshot = service.getInventory(PR_ID); + + expect(snapshot.convergence.canAutoAdvance).toBe(true); + }); + + it("canAutoAdvance = false when no new items remain", () => { + const db = makeMockDb(); + db.all.mockReturnValue([ + makeFakeRow({ id: "i-1", state: "fixed", round: 1 }), + makeFakeRow({ id: "i-2", state: "dismissed", round: 1 }), + ]); + + const service = createIssueInventoryService({ db }); + const snapshot = service.getInventory(PR_ID); + + expect(snapshot.convergence.canAutoAdvance).toBe(false); + }); + + it("canAutoAdvance = false when at max rounds", () => { + const db = makeMockDb(); + db.all.mockReturnValue([ + makeFakeRow({ id: "i-1", state: "new", round: 0 }), + makeFakeRow({ id: "i-2", state: "sent_to_agent", round: 5 }), + ]); + + const service = createIssueInventoryService({ db }); + const snapshot = service.getInventory(PR_ID); + + // currentRound = 5 which equals DEFAULT_MAX_ROUNDS + expect(snapshot.convergence.canAutoAdvance).toBe(false); + }); + + it("skips round 0 items from per-round stats", () => { + const db = makeMockDb(); + db.all.mockReturnValue([ + makeFakeRow({ id: "i-1", state: "new", round: 0 }), + makeFakeRow({ id: "i-2", state: "new", round: 0 }), + ]); + + const service = createIssueInventoryService({ db }); + const snapshot = service.getInventory(PR_ID); + + expect(snapshot.convergence.issuesPerRound.length).toBe(0); + }); + + it("returns maxRounds = 5 (default)", () => { + const db = makeMockDb(); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + const snapshot = service.getInventory(PR_ID); + + expect(snapshot.convergence.maxRounds).toBe(5); + }); + }); + + // --------------------------------------------------------------------------- + // Tests — getConvergenceStatus + // --------------------------------------------------------------------------- + + describe("getConvergenceStatus", () => { + it("returns convergence status directly", () => { + const db = makeMockDb(); + db.all.mockReturnValue([ + makeFakeRow({ id: "i-1", state: "new", round: 0 }), + makeFakeRow({ id: "i-2", state: "escalated", round: 2 }), + ]); + + const service = createIssueInventoryService({ db }); + const status = service.getConvergenceStatus(PR_ID); + + expect(status.totalNew).toBe(1); + expect(status.totalEscalated).toBe(1); + expect(status.currentRound).toBe(2); + }); + }); + + // --------------------------------------------------------------------------- + // Tests — resetInventory + // --------------------------------------------------------------------------- + + describe("resetInventory", () => { + it("deletes all items for the prId", () => { + const db = makeMockDb(); + const service = createIssueInventoryService({ db }); + + service.resetInventory(PR_ID); + + expect(db.run).toHaveBeenCalledWith( + "delete from pr_issue_inventory where pr_id = ?", + [PR_ID], + ); + }); + }); + + // --------------------------------------------------------------------------- + // Tests — pipeline settings + // --------------------------------------------------------------------------- + + describe("getPipelineSettings", () => { + it("returns defaults when no row exists", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + + const service = createIssueInventoryService({ db }); + const settings = service.getPipelineSettings(PR_ID); + + expect(settings).toEqual({ + autoMerge: false, + mergeMethod: "repo_default", + maxRounds: 5, + onRebaseNeeded: "pause", + }); + }); + + it("maps DB row to PipelineSettings", () => { + const db = makeMockDb(); + db.get.mockReturnValue({ + auto_merge: 1, + merge_method: "squash", + max_rounds: 3, + on_rebase_needed: "auto_rebase", + }); + + const service = createIssueInventoryService({ db }); + const settings = service.getPipelineSettings(PR_ID); + + expect(settings).toEqual({ + autoMerge: true, + mergeMethod: "squash", + maxRounds: 3, + onRebaseNeeded: "auto_rebase", + }); + }); + }); + + describe("savePipelineSettings", () => { + it("upserts merged settings into DB", () => { + const db = makeMockDb(); + // getPipelineSettings is called internally — no existing row + db.get.mockReturnValue(null); + + const service = createIssueInventoryService({ db }); + service.savePipelineSettings(PR_ID, { autoMerge: true, maxRounds: 3 }); + + expect(db.run).toHaveBeenCalledTimes(1); + const sql = db.run.mock.calls[0][0] as string; + expect(sql).toContain("insert into pr_pipeline_settings"); + expect(sql).toContain("on conflict(pr_id) do update"); + const params = db.run.mock.calls[0][1] as unknown[]; + expect(params[0]).toBe(PR_ID); // prId + expect(params[1]).toBe(1); // auto_merge = true -> 1 + expect(params[2]).toBe("repo_default"); // mergeMethod (default since not overridden) + expect(params[3]).toBe(3); // maxRounds (overridden) + expect(params[4]).toBe("pause"); // onRebaseNeeded (default) + }); + + it("merges partial settings with existing ones", () => { + const db = makeMockDb(); + // Simulate existing settings in DB + db.get.mockReturnValue({ + auto_merge: 0, + merge_method: "squash", + max_rounds: 5, + on_rebase_needed: "auto_rebase", + }); + + const service = createIssueInventoryService({ db }); + service.savePipelineSettings(PR_ID, { autoMerge: true }); + + const params = db.run.mock.calls[0][1] as unknown[]; + expect(params[1]).toBe(1); // auto_merge overridden to true + expect(params[2]).toBe("squash"); // preserved from existing + expect(params[3]).toBe(5); // preserved from existing + expect(params[4]).toBe("auto_rebase"); // preserved from existing + }); + }); + + describe("deletePipelineSettings", () => { + it("deletes pipeline settings for the prId", () => { + const db = makeMockDb(); + const service = createIssueInventoryService({ db }); + + service.deletePipelineSettings(PR_ID); + + expect(db.run).toHaveBeenCalledWith( + "delete from pr_pipeline_settings where pr_id = ?", + [PR_ID], + ); + }); + }); + + // --------------------------------------------------------------------------- + // Tests — stripEmojiNoise / extractHeadline edge cases (via syncFromPrData) + // --------------------------------------------------------------------------- + + describe("headline extraction edge cases", () => { + it("strips emoji noise from bold titles", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + comments: [{ + id: "c-1", + author: "reviewer", + authorAvatarUrl: null, + body: "**⚠️ Fix the race condition** This is important.", + url: null, + createdAt: null, + updatedAt: null, + }], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + const headline = insertCalls[0][1][8] as string; + // Should have stripped "⚠️" emoji but kept the useful text + expect(headline).not.toContain("⚠️"); + expect(headline).toContain("Fix the race condition"); + }); + + it("uses fallback headline when body is empty", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + path: "src/utils.ts", + comments: [{ + id: "c-1", + author: "reviewer", + authorAvatarUrl: null, + body: "", + url: null, + createdAt: null, + updatedAt: null, + }], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + const headline = insertCalls[0][1][8] as string; + expect(headline).toContain("Review thread at src/utils.ts"); + }); + + it("truncates very long first-line headlines to 120 chars", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const longLine = "A".repeat(200); + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + comments: [{ + id: "c-1", + author: "reviewer", + authorAvatarUrl: null, + body: longLine, + url: null, + createdAt: null, + updatedAt: null, + }], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + const headline = insertCalls[0][1][8] as string; + expect(headline.length).toBeLessThanOrEqual(120); + expect(headline).toContain("..."); + }); + + it("handles null body in thread comment gracefully", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + path: "src/main.ts", + comments: [{ + id: "c-1", + author: "reviewer", + authorAvatarUrl: null, + body: null as any, + url: null, + createdAt: null, + updatedAt: null, + }], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + expect(insertCalls.length).toBe(1); + const headline = insertCalls[0][1][8] as string; + expect(headline).toBe("Review thread at src/main.ts"); + }); + + it("handles thread with no comments gracefully", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + path: "src/foo.ts", + comments: [], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + expect(insertCalls.length).toBe(1); + // author null, headline should use fallback + const args = insertCalls[0][1] as unknown[]; + expect(args[10]).toBeNull(); // author + }); + + it("detects ade-review bot source", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + comments: [{ + id: "c-1", + author: "ade-review[bot]", + authorAvatarUrl: null, + body: "Clean up imports.", + url: null, + createdAt: null, + updatedAt: null, + }], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + const args = insertCalls[0][1] as unknown[]; + expect(args[2]).toBe("ade"); // source + }); + + it("returns unknown source for null/empty author", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + comments: [{ + id: "c-1", + author: "", + authorAvatarUrl: null, + body: "Fix something.", + url: null, + createdAt: null, + updatedAt: null, + }], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + const args = insertCalls[0][1] as unknown[]; + expect(args[2]).toBe("unknown"); // source for empty author + }); + + it("returns null severity when no severity markers found", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + comments: [{ + id: "c-1", + author: "reviewer", + authorAvatarUrl: null, + body: "Consider refactoring this function.", + url: null, + createdAt: null, + updatedAt: null, + }], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + const args = insertCalls[0][1] as unknown[]; + expect(args[7]).toBeNull(); // severity + }); + + it("extracts P1 as critical severity", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + comments: [{ + id: "c-1", + author: "reviewer", + authorAvatarUrl: null, + body: "P1 Security issue here.", + url: null, + createdAt: null, + updatedAt: null, + }], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + const args = insertCalls[0][1] as unknown[]; + expect(args[7]).toBe("critical"); + }); + + it("extracts P3 as minor severity", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + comments: [{ + id: "c-1", + author: "reviewer", + authorAvatarUrl: null, + body: "P3 Cosmetic: extra whitespace.", + url: null, + createdAt: null, + updatedAt: null, + }], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + const args = insertCalls[0][1] as unknown[]; + expect(args[7]).toBe("minor"); + }); + + it("extracts [nit] bracket as minor severity", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + comments: [{ + id: "c-1", + author: "reviewer", + authorAvatarUrl: null, + body: "[nit] Variable name could be clearer.", + url: null, + createdAt: null, + updatedAt: null, + }], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + const args = insertCalls[0][1] as unknown[]; + expect(args[7]).toBe("minor"); + }); + + it("extracts [error] bracket as critical severity", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + comments: [{ + id: "c-1", + author: "reviewer", + authorAvatarUrl: null, + body: "[error] Unhandled exception possible here.", + url: null, + createdAt: null, + updatedAt: null, + }], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + const args = insertCalls[0][1] as unknown[]; + expect(args[7]).toBe("critical"); + }); + + it("extracts 🟠 emoji as major severity", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + comments: [{ + id: "c-1", + author: "reviewer", + authorAvatarUrl: null, + body: "🟠 Performance issue detected.", + url: null, + createdAt: null, + updatedAt: null, + }], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + const args = insertCalls[0][1] as unknown[]; + expect(args[7]).toBe("major"); + }); + + it("extracts 🟡 emoji as minor severity", () => { + const db = makeMockDb(); + db.get.mockReturnValue(null); + db.all.mockReturnValue([]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + comments: [{ + id: "c-1", + author: "reviewer", + authorAvatarUrl: null, + body: "🟡 Minor style issue.", + url: null, + createdAt: null, + updatedAt: null, + }], + })], + [], + ); + + const insertCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), + ); + const args = insertCalls[0][1] as unknown[]; + expect(args[7]).toBe("minor"); + }); + }); +}); diff --git a/apps/desktop/src/main/services/prs/issueInventoryService.ts b/apps/desktop/src/main/services/prs/issueInventoryService.ts index 3eb94992a..ee78e7d5d 100644 --- a/apps/desktop/src/main/services/prs/issueInventoryService.ts +++ b/apps/desktop/src/main/services/prs/issueInventoryService.ts @@ -152,32 +152,48 @@ function rowToItem(row: InventoryRow): IssueInventoryItem { const DEFAULT_MAX_ROUNDS = 5; -function computeConvergenceStatus(items: IssueInventoryItem[]): ConvergenceStatus { - const totalNew = items.filter((i) => i.state === "new").length; - const totalFixed = items.filter((i) => i.state === "fixed").length; - const totalDismissed = items.filter((i) => i.state === "dismissed").length; - const totalEscalated = items.filter((i) => i.state === "escalated").length; - const totalSentToAgent = items.filter((i) => i.state === "sent_to_agent").length; +const NOISY_AUTHORS = new Set(["vercel", "vercel[bot]", "mintlify", "mintlify[bot]"]); +const NOISY_COMMENT_PATTERNS = [ + /\[vc\]:/i, + /mintlify-preview/i, + /this is an auto-generated comment/i, + /pre-merge checks/i, + /thanks for using \[coderabbit\]/i, + //i, -]; - -function isNoisyIssueComment(comment: PrComment): boolean { - const author = comment.author.trim().toLowerCase(); - const body = (comment.body ?? "").trim(); - if (!body) return true; - if (NOISY_BOT_AUTHORS.has(author)) return true; - return NOISY_BODY_PATTERNS.some((pattern) => pattern.test(body)); -} - function formatChecksSummary(checks: PrCheck[], actionRuns: PrActionRun[]): string { const failingChecks = checks.filter((check) => check.conclusion === "failure"); if (failingChecks.length === 0) return "- No actionable failing checks."; @@ -215,11 +213,38 @@ function formatReviewThreadsSummary(reviewThreads: PrReviewThread[]): string { }).join("\n"); } -function formatIssueCommentsSummary(issueComments: PrComment[]): string { - const advisory = issueComments - .filter((comment) => comment.source === "issue") - .filter((comment) => !isNoisyIssueComment(comment)) +function normalizeCommentBodyForPrompt(value: string | null | undefined, max = 700): string { + const normalized = stripMarkupNoise(value ?? ""); + if (!normalized) return "(no comment body)"; + return truncateText(normalized, max); +} + +function formatReviewThreadsDetailed(reviewThreads: PrReviewThread[]): string { + const actionableThreads = reviewThreads.filter((thread) => !thread.isResolved && !thread.isOutdated); + if (actionableThreads.length === 0) return "- No actionable unresolved review threads."; + + return actionableThreads.map((thread, index) => { + const location = thread.path + ? `${thread.path}${thread.line != null ? `:${thread.line}` : ""}` + : "unknown location"; + const comments = thread.comments.length > 0 + ? thread.comments.map((comment, commentIndex) => { + const author = comment.author?.trim() || "unknown"; + return ` ${commentIndex + 1}. ${author}\n ${normalizeCommentBodyForPrompt(comment.body, 800)}${comment.url ? `\n Comment URL: ${comment.url}` : ""}`; + }).join("\n") + : " 1. (no comments returned)"; + return `${index + 1}. Thread ${thread.id} at ${location}${thread.url ? `\n Thread URL: ${thread.url}` : ""}\n${comments}`; + }).join("\n"); +} + +function getAdvisoryIssueComments(issueComments: PrComment[]): PrComment[] { + return issueComments + .filter((comment) => comment.source === "issue" && !isNoisyIssueComment(comment)) .slice(0, 5); +} + +function formatIssueCommentsSummary(issueComments: PrComment[]): string { + const advisory = getAdvisoryIssueComments(issueComments); if (advisory.length === 0) return "- No advisory top-level issue comments."; return advisory .map((comment, index) => { @@ -229,6 +254,17 @@ function formatIssueCommentsSummary(issueComments: PrComment[]): string { .join("\n"); } +function formatIssueCommentsDetailed(issueComments: PrComment[]): string { + const advisory = getAdvisoryIssueComments(issueComments); + if (advisory.length === 0) return "- No advisory top-level issue comments."; + return advisory + .map((comment, index) => { + const author = comment.author?.trim() || "unknown"; + return `${index + 1}. ${author}${comment.url ? ` — ${comment.url}` : ""}\n ${normalizeCommentBodyForPrompt(comment.body, 700)}`; + }) + .join("\n"); +} + function formatChangedFilesSummary(files: PrFile[]): string { if (files.length === 0) return "- No changed files reported."; return files @@ -287,6 +323,54 @@ function buildSelectedScopeDescription(scope: PrIssueResolutionScope): string { return "checks"; } +function defaultPrIssueResolutionRuntimeCapabilities(): PrIssueResolutionRuntimeCapabilities { + return { + runtimeLabel: "Unified/API chat with ADE workflow tools", + toolSurface: "workflow_tools", + refreshInventoryTool: "prRefreshIssueInventory", + getReviewCommentsTool: "prGetReviewComments", + rerunChecksTool: "prRerunFailedChecks", + replyThreadTool: "prReplyToReviewThread", + resolveThreadTool: "prResolveReviewThread", + executionMode: null, + }; +} + +function resolvePrIssueResolutionRuntimeCapabilities(modelId: string | null | undefined): PrIssueResolutionRuntimeCapabilities { + const descriptor = modelId ? getModelById(modelId) : null; + if (!descriptor || !descriptor.isCliWrapped) { + return defaultPrIssueResolutionRuntimeCapabilities(); + } + + const MCP_TOOLS = { + refreshInventoryTool: "pr_refresh_issue_inventory", + getReviewCommentsTool: "pr_get_review_comments", + rerunChecksTool: "pr_rerun_failed_checks", + replyThreadTool: "pr_reply_to_review_thread", + resolveThreadTool: "pr_resolve_review_thread", + } as const; + + if (descriptor.family === "openai") { + return { + ...MCP_TOOLS, + runtimeLabel: "Codex chat via ADE MCP", + toolSurface: "ade_mcp", + executionMode: "parallel", + }; + } + + if (descriptor.family === "anthropic") { + return { + ...MCP_TOOLS, + runtimeLabel: "Claude SDK chat via ADE MCP", + toolSurface: "ade_mcp", + executionMode: "subagents", + }; + } + + return defaultPrIssueResolutionRuntimeCapabilities(); +} + export function buildPrIssueResolutionPrompt(args: IssueResolutionPromptArgs): string { const actionableThreads = args.reviewThreads.filter((thread) => !thread.isResolved && !thread.isOutdated); const availability = getPrIssueResolutionAvailability(args.checks, args.reviewThreads); @@ -301,11 +385,15 @@ export function buildPrIssueResolutionPrompt(args: IssueResolutionPromptArgs): s const roundNumber = args.round ?? null; const previouslyHandled = args.previouslyHandled ?? null; const useInventory = args.inventoryItems != null && args.inventoryItems.length > 0; + const runtimeCapabilities = args.runtimeCapabilities ?? defaultPrIssueResolutionRuntimeCapabilities(); + const detailedIssueContext = args.detailedIssueContext === true; const promptSections = [ "You are resolving issues on an existing GitHub pull request inside ADE.", "Address every valid issue in the selected scope without asking the user to enumerate them again.", - "The issue references below are intentionally compact summaries. Use the linked GitHub thread/check URLs or refresh the issue inventory when you need full detail.", + detailedIssueContext + ? "The issue sections below include detailed comment bodies fetched from ADE at launch time. Refresh the issue inventory if PR state may have changed since launch." + : "The issue references below are intentionally compact summaries. Use the linked GitHub thread/check URLs or refresh the issue inventory when you need full detail.", "", "PR context", `- ADE PR id (for ADE tools): ${args.pr.id}`, @@ -314,6 +402,7 @@ export function buildPrIssueResolutionPrompt(args: IssueResolutionPromptArgs): s `- Base -> head: ${args.pr.baseBranch} -> ${args.pr.headBranch}`, `- Lane: ${args.lane.name}`, `- Worktree: ${args.lane.worktreePath}`, + `- Runtime: ${runtimeCapabilities.runtimeLabel}`, `- Selected scope: ${scopeLabel}`, ]; if (roundNumber != null) { @@ -344,16 +433,29 @@ export function buildPrIssueResolutionPrompt(args: IssueResolutionPromptArgs): s formatInventoryItemsSummary(args.inventoryItems!), ); } else { + const threadsHeading = detailedIssueContext + ? "Current unresolved review threads (detailed context)" + : "Current unresolved review threads (summaries + references)"; + const threadsBody = detailedIssueContext + ? formatReviewThreadsDetailed(args.reviewThreads) + : formatReviewThreadsSummary(args.reviewThreads); + const commentsHeading = detailedIssueContext + ? "Advisory top-level issue comments (detailed)" + : "Advisory top-level issue comments (filtered)"; + const commentsBody = detailedIssueContext + ? formatIssueCommentsDetailed(args.issueComments) + : formatIssueCommentsSummary(args.issueComments); + promptSections.push( "", "Current failing checks", formatChecksSummary(args.checks, args.actionRuns), "", - "Current unresolved review threads (summaries + references)", - formatReviewThreadsSummary(args.reviewThreads), + threadsHeading, + threadsBody, "", - "Advisory top-level issue comments (filtered)", - formatIssueCommentsSummary(args.issueComments), + commentsHeading, + commentsBody, ); } @@ -396,12 +498,35 @@ export function buildPrIssueResolutionPrompt(args: IssueResolutionPromptArgs): s "", "Requirements", "- Fix all valid issues in the selected scope, not just the first one.", - "- Start by refreshing the PR issue inventory if ADE tools are available, especially if CI or review state may have changed.", + ); + + if (runtimeCapabilities.toolSurface === "prompt_only") { + promptSections.push( + "- No live ADE PR tools are available in this session. Use the detailed issue context in this prompt plus the linked GitHub thread/check URLs.", + "- If you need fresher PR state than this prompt provides, fetch it manually before making changes.", + ); + } else { + const surfaceLabel = runtimeCapabilities.toolSurface === "ade_mcp" ? "ADE MCP tool" : "ADE workflow tool"; + const toolList = [ + runtimeCapabilities.refreshInventoryTool, + runtimeCapabilities.getReviewCommentsTool, + runtimeCapabilities.rerunChecksTool, + runtimeCapabilities.replyThreadTool, + runtimeCapabilities.resolveThreadTool, + ].filter(Boolean).map((t) => `\`${t}\``).join(", "); + promptSections.push( + `- Start by refreshing the PR issue inventory with ${surfaceLabel} \`${runtimeCapabilities.refreshInventoryTool}\`, especially if CI or review state may have changed.`, + `- Use ${surfaceLabel}s instead of assuming GitHub CLI access. Relevant tools include ${toolList}.`, + ); + } + + promptSections.push( "- Verify review comments before changing code. Some comments may be stale, incorrect, or already addressed.", "- If you work on review comments, reply on the review thread when useful and resolve the thread only after the fix is truly in place or the thread is clearly outdated/invalid.", - "- If you are running inside ADE, use ADE-backed PR tools instead of assuming gh auth. The relevant tools include prRefreshIssueInventory, prRerunFailedChecks, prReplyToReviewThread, and prResolveReviewThread.", "- If you are running outside ADE, use the linked GitHub thread/check URLs together with your local git and CI tooling.", - "- Use parallel agents when they will materially speed up independent fixes.", + runtimeCapabilities.executionMode + ? "- Use parallel agents or subagents when they will materially speed up independent fixes." + : "- Stay focused unless the runtime clearly supports parallel delegation for this session.", "- After each set of changes, run the smallest relevant local validation first.", "- Before you push, rerun the complete failing test files or suites locally, not just the specific failing test names. Test runners and sharded CI can hide additional failures behind the first error in a file.", "- Treat newly added or heavily modified test files as likely regression hotspots, even if CI only surfaced a different failure first.", @@ -426,6 +551,9 @@ export function buildPrIssueResolutionPrompt(args: IssueResolutionPromptArgs): s async function preparePrIssueResolutionPrompt( deps: PrIssueResolutionLaunchDeps, args: PrIssueResolutionStartArgs | PrIssueResolutionPromptPreviewArgs, + options: { + detailLevel?: "preview" | "launch"; + } = {}, ): Promise { const pr = deps.prService.listAll().find((entry) => entry.id === args.prId) ?? null; if (!pr) throw new Error(`PR not found: ${args.prId}`); @@ -464,6 +592,7 @@ async function preparePrIssueResolutionPrompt( let previouslyHandled: PreviouslyHandledSummary | null = null; let roundNumber: number | null = null; let inventoryNewItems: IssueInventoryItem[] | undefined; + const runtimeCapabilities = resolvePrIssueResolutionRuntimeCapabilities(args.modelId); if (inventoryService) { // Sync the inventory with fresh GitHub data @@ -506,12 +635,15 @@ async function preparePrIssueResolutionPrompt( round: roundNumber, previouslyHandled, inventoryItems: inventoryNewItems ?? null, + runtimeCapabilities, + detailedIssueContext: options.detailLevel === "launch", }), title: roundNumber != null && roundNumber > 1 ? `Resolve PR #${pr.githubPrNumber} issues (round ${roundNumber})` : `Resolve PR #${pr.githubPrNumber} issues`, inventoryNewItems, roundNumber: roundNumber ?? undefined, + runtimeCapabilities, }; } @@ -519,7 +651,7 @@ export async function previewPrIssueResolutionPrompt( deps: PrIssueResolutionLaunchDeps, args: PrIssueResolutionPromptPreviewArgs, ): Promise { - const prepared = await preparePrIssueResolutionPrompt(deps, args); + const prepared = await preparePrIssueResolutionPrompt(deps, args, { detailLevel: "preview" }); return { title: prepared.title, prompt: prepared.prompt, @@ -534,7 +666,7 @@ export async function launchPrIssueResolutionChat( if (!descriptor) { throw new Error(`Unknown model '${args.modelId}'.`); } - const prepared = await preparePrIssueResolutionPrompt(deps, args); + const prepared = await preparePrIssueResolutionPrompt(deps, args, { detailLevel: "launch" }); const reasoningEffort = args.reasoning?.trim() || undefined; const session = await deps.agentChatService.createSession({ @@ -555,6 +687,7 @@ export async function launchPrIssueResolutionChat( text: prepared.prompt, displayText: prepared.title, ...(reasoningEffort ? { reasoningEffort } : {}), + ...(prepared.runtimeCapabilities.executionMode ? { executionMode: prepared.runtimeCapabilities.executionMode } : {}), }); // Mark inventory items as sent to agent for this round diff --git a/apps/desktop/src/main/services/prs/prRebaseResolver.test.ts b/apps/desktop/src/main/services/prs/prRebaseResolver.test.ts new file mode 100644 index 000000000..d9c7b53e7 --- /dev/null +++ b/apps/desktop/src/main/services/prs/prRebaseResolver.test.ts @@ -0,0 +1,318 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, describe, expect, it, vi } from "vitest"; +import type { LaneSummary, RebaseNeed } from "../../../shared/types"; +import { launchRebaseResolutionChat } from "./prRebaseResolver"; + +vi.mock("./resolverUtils", () => ({ + mapPermissionMode: (mode: string | undefined) => { + if (mode === "full_edit") return "full-auto"; + if (mode === "read_only") return "plan"; + return "edit"; + }, + readRecentCommits: vi.fn(async (_worktreePath: string, _count?: number, ref?: string) => { + if (ref && ref.startsWith("origin/")) { + return [ + { sha: "aaa1111222233334444555566667777aaaabbbb", subject: "Upstream fix for auth" }, + { sha: "bbb2222333344445555666677778888bbbbcccc", subject: "Bump dependencies" }, + ]; + } + if (ref && !ref.startsWith("origin/") && ref !== "HEAD") { + return [{ sha: "local111222233334444555566667777aaaabbbb", subject: "Local base commit" }]; + } + return [ + { sha: "ccc3333444455556666777788889999ccccdddd", subject: "Add feature X" }, + { sha: "ddd4444555566667777888899990000ddddeee0", subject: "Fix tests for X" }, + ]; + }), +})); + +const createdTempDirs: string[] = []; +afterAll(() => { + for (const dir of createdTempDirs) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } + } +}); + +function makeLane(overrides: Partial = {}): LaneSummary { + const worktreePath = overrides.worktreePath ?? fs.mkdtempSync(path.join(os.tmpdir(), "ade-rebase-test-")); + if (!overrides.worktreePath) createdTempDirs.push(worktreePath); + return { + id: "lane-rebase-1", + name: "feature/rebase-target", + description: "Lane for rebase testing.", + laneType: "worktree", + baseRef: "main", + branchRef: "feature/rebase-target", + worktreePath, + attachedRootPath: null, + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 3, behind: 5, remoteBehind: -1, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + folder: null, + createdAt: "2026-03-25T10:00:00.000Z", + archivedAt: null, + ...overrides, + }; +} + +function makeRebaseNeed(overrides: Partial = {}): RebaseNeed { + return { + laneId: "lane-rebase-1", + laneName: "feature/rebase-target", + kind: "lane_base", + baseBranch: "main", + behindBy: 5, + conflictPredicted: true, + conflictingFiles: ["src/auth.ts", "src/config.ts"], + prId: null, + groupContext: null, + dismissedAt: null, + deferredUntil: null, + ...overrides, + }; +} + +function makeDeps(overrides: { rebaseNeed?: RebaseNeed | null } = {}) { + const lane = makeLane(); + const createSession = vi.fn(async () => ({ id: "session-rebase-1" })); + const sendMessage = vi.fn(async (_arg: any) => undefined); + const updateMeta = vi.fn(); + const getRebaseNeed = vi.fn(async () => overrides.rebaseNeed !== undefined ? overrides.rebaseNeed : makeRebaseNeed()); + + const deps = { + laneService: { + list: vi.fn(async () => [lane]), + getLaneBaseAndBranch: vi.fn(() => ({ + baseRef: "main", + branchRef: "feature/rebase-target", + worktreePath: lane.worktreePath, + laneType: "worktree", + })), + }, + agentChatService: { createSession, sendMessage }, + sessionService: { updateMeta }, + conflictService: { getRebaseNeed }, + }; + + return { lane, deps, createSession, sendMessage, updateMeta, getRebaseNeed }; +} + +describe("launchRebaseResolutionChat", () => { + it("creates a chat session with the correct parameters and sends the composed prompt", async () => { + const { lane, deps, createSession, sendMessage, updateMeta } = makeDeps(); + + const result = await launchRebaseResolutionChat(deps as any, { + laneId: lane.id, + modelId: "anthropic/claude-sonnet-4-6", + reasoning: "high", + permissionMode: "guarded_edit", + }); + + expect(createSession).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: lane.id, + provider: "unified", + modelId: "anthropic/claude-sonnet-4-6", + surface: "work", + sessionProfile: "workflow", + permissionMode: "edit", + reasoningEffort: "high", + }), + ); + expect(updateMeta).toHaveBeenCalledWith({ + sessionId: "session-rebase-1", + title: "Rebase feature/rebase-target onto main", + }); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-rebase-1", + displayText: "Rebase feature/rebase-target onto main", + reasoningEffort: "high", + }), + ); + expect(result).toEqual({ + sessionId: "session-rebase-1", + laneId: lane.id, + href: `/work?laneId=${encodeURIComponent(lane.id)}&sessionId=session-rebase-1`, + }); + }); + + it("includes conflict info, base commits, and lane commits in the prompt", async () => { + const { deps, sendMessage, lane } = makeDeps(); + + await launchRebaseResolutionChat(deps as any, { + laneId: lane.id, + modelId: "anthropic/claude-sonnet-4-6", + }); + + const sentText = sendMessage.mock.calls[0][0].text as string; + expect(sentText).toContain("resolving a rebase conflict"); + expect(sentText).toContain("Lane name: feature/rebase-target"); + expect(sentText).toContain("Base branch: main"); + expect(sentText).toContain("Behind by: 5 commits"); + expect(sentText).toContain("Conflict predicted: YES"); + expect(sentText).toContain("src/auth.ts"); + expect(sentText).toContain("src/config.ts"); + expect(sentText).toContain("Upstream fix for auth"); + expect(sentText).toContain("Add feature X"); + }); + + it("defaults forcePushAfterRebase to true", async () => { + const { deps, sendMessage, lane } = makeDeps(); + + await launchRebaseResolutionChat(deps as any, { + laneId: lane.id, + modelId: "anthropic/claude-sonnet-4-6", + }); + + const sentText = sendMessage.mock.calls[0][0].text as string; + expect(sentText).toContain("force push the rewritten branch"); + }); + + it("omits force push instruction when forcePushAfterRebase is false", async () => { + const { deps, sendMessage, lane } = makeDeps(); + + await launchRebaseResolutionChat(deps as any, { + laneId: lane.id, + modelId: "anthropic/claude-sonnet-4-6", + forcePushAfterRebase: false, + }); + + const sentText = sendMessage.mock.calls[0][0].text as string; + expect(sentText).toContain("Do NOT push"); + expect(sentText).not.toContain("force push the rewritten branch"); + }); + + it("maps full_edit permission mode to full-auto", async () => { + const { deps, createSession, lane } = makeDeps(); + + await launchRebaseResolutionChat(deps as any, { + laneId: lane.id, + modelId: "anthropic/claude-sonnet-4-6", + permissionMode: "full_edit", + }); + + expect(createSession).toHaveBeenCalledWith( + expect.objectContaining({ permissionMode: "full-auto" }), + ); + }); + + it("maps read_only permission mode to plan", async () => { + const { deps, createSession, lane } = makeDeps(); + + await launchRebaseResolutionChat(deps as any, { + laneId: lane.id, + modelId: "anthropic/claude-sonnet-4-6", + permissionMode: "read_only", + }); + + expect(createSession).toHaveBeenCalledWith( + expect.objectContaining({ permissionMode: "plan" }), + ); + }); + + it("omits reasoningEffort when reasoning is not provided", async () => { + const { deps, createSession, sendMessage, lane } = makeDeps(); + + await launchRebaseResolutionChat(deps as any, { + laneId: lane.id, + modelId: "anthropic/claude-sonnet-4-6", + }); + + const sessionArgs = (createSession.mock.calls as any[][])[0]?.[0]; + expect(sessionArgs).not.toHaveProperty("reasoningEffort"); + const messageArgs = (sendMessage.mock.calls as any[][])[0]?.[0]; + expect(messageArgs).not.toHaveProperty("reasoningEffort"); + }); + + it("throws when model is unknown", async () => { + const { deps, lane } = makeDeps(); + + await expect( + launchRebaseResolutionChat(deps as any, { + laneId: lane.id, + modelId: "unknown/model-xyz", + }), + ).rejects.toThrow("Unknown model"); + }); + + it("throws when lane is not found", async () => { + const { deps } = makeDeps(); + + await expect( + launchRebaseResolutionChat(deps as any, { + laneId: "nonexistent-lane", + modelId: "anthropic/claude-sonnet-4-6", + }), + ).rejects.toThrow("Lane not found"); + }); + + it("throws when lane worktree is missing on disk", async () => { + const lane = makeLane({ worktreePath: path.join(os.tmpdir(), "nonexistent-ade-worktree-path") }); + const deps = { + laneService: { + list: vi.fn(async () => [lane]), + getLaneBaseAndBranch: vi.fn(), + }, + agentChatService: { createSession: vi.fn(), sendMessage: vi.fn() }, + sessionService: { updateMeta: vi.fn() }, + conflictService: { getRebaseNeed: vi.fn() }, + }; + + await expect( + launchRebaseResolutionChat(deps as any, { + laneId: lane.id, + modelId: "anthropic/claude-sonnet-4-6", + }), + ).rejects.toThrow("Lane worktree is missing on disk"); + }); + + it("throws when no rebase need is found", async () => { + const { deps, lane } = makeDeps({ rebaseNeed: null }); + + await expect( + launchRebaseResolutionChat(deps as any, { + laneId: lane.id, + modelId: "anthropic/claude-sonnet-4-6", + }), + ).rejects.toThrow("No rebase need found"); + }); + + it("handles singular commit count in prompt text", async () => { + const { deps, sendMessage, lane } = makeDeps({ + rebaseNeed: makeRebaseNeed({ behindBy: 1 }), + }); + + await launchRebaseResolutionChat(deps as any, { + laneId: lane.id, + modelId: "anthropic/claude-sonnet-4-6", + }); + + const sentText = sendMessage.mock.calls[0][0].text as string; + expect(sentText).toContain("Behind by: 1 commit"); + expect(sentText).not.toContain("1 commits"); + }); + + it("handles no conflict predicted", async () => { + const { deps, sendMessage, lane } = makeDeps({ + rebaseNeed: makeRebaseNeed({ conflictPredicted: false, conflictingFiles: [] }), + }); + + await launchRebaseResolutionChat(deps as any, { + laneId: lane.id, + modelId: "anthropic/claude-sonnet-4-6", + }); + + const sentText = sendMessage.mock.calls[0][0].text as string; + expect(sentText).toContain("Conflict predicted: NO"); + expect(sentText).not.toContain("Files modified in both branches"); + }); +}); diff --git a/apps/desktop/src/main/services/prs/prRebaseResolver.ts b/apps/desktop/src/main/services/prs/prRebaseResolver.ts index 66cd886fc..c386e2443 100644 --- a/apps/desktop/src/main/services/prs/prRebaseResolver.ts +++ b/apps/desktop/src/main/services/prs/prRebaseResolver.ts @@ -119,11 +119,12 @@ export async function launchRebaseResolutionChat( } // Get recent commits on both the lane and the parent - const parentLane = lane.parentLaneId ? lanes.find((l) => l.id === lane.parentLaneId) ?? null : null; - const [laneCommits, baseCommits] = await Promise.all([ + const [laneCommits, remoteBaseCommits, localBaseCommits] = await Promise.all([ readRecentCommits(lane.worktreePath, 8), - parentLane?.worktreePath ? readRecentCommits(parentLane.worktreePath, rebaseNeed.behindBy) : Promise.resolve([]), + readRecentCommits(lane.worktreePath, rebaseNeed.behindBy, `origin/${rebaseNeed.baseBranch}`), + readRecentCommits(lane.worktreePath, rebaseNeed.behindBy, rebaseNeed.baseBranch), ]); + const baseCommits = remoteBaseCommits.length > 0 ? remoteBaseCommits : localBaseCommits; const prompt = buildRebaseResolutionPrompt({ lane, diff --git a/apps/desktop/src/main/services/prs/prService.mergeContext.test.ts b/apps/desktop/src/main/services/prs/prService.mergeContext.test.ts index ba97e2e8b..08e3b0955 100644 --- a/apps/desktop/src/main/services/prs/prService.mergeContext.test.ts +++ b/apps/desktop/src/main/services/prs/prService.mergeContext.test.ts @@ -234,4 +234,50 @@ describe("prService.getMergeContext", () => { integrationLaneId: null, }); }); + + it("does not infer a target lane from baseRef-only matches", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-pr-merge-context-base-ref-only-")); + const db = await openKvDb(path.join(root, ".ade.db"), createLogger()); + const projectId = "proj-merge-context-base-ref-only"; + + const sourceLane = makeLane("lane-auth", "feature/auth", "refs/heads/feature/auth"); + const siblingLane = makeLane("lane-other", "feature/other", "refs/heads/feature/other", { + baseRef: "refs/heads/main", + }); + + await seedProject(db, projectId, root); + await seedLane(db, projectId, sourceLane); + await seedLane(db, projectId, siblingLane); + await seedPr(db, { + prId: "pr-normal", + projectId, + laneId: sourceLane.id, + baseBranch: "main", + headBranch: "feature/auth", + title: "Normal PR", + }); + + const service = createPrService({ + db, + logger: createLogger() as any, + projectId, + projectRoot: root, + laneService: { + list: async () => [sourceLane, siblingLane], + } as any, + operationService: {} as any, + githubService: { apiRequest: async () => ({ data: {} }) } as any, + aiIntegrationService: undefined, + projectConfigService: {} as any, + conflictService: undefined, + openExternal: async () => {}, + }); + + await expect(service.getMergeContext("pr-normal")).resolves.toMatchObject({ + prId: "pr-normal", + sourceLaneIds: ["lane-auth"], + targetLaneId: null, + integrationLaneId: null, + }); + }); }); diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index 282afc46e..1404ec97e 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -322,6 +322,48 @@ describe("prService.createFromLane", () => { expect(result.baseBranch).toBe("main"); }); + it("uses the lane baseRef when legacy primary parent metadata disagrees with the current primary branch", async () => { + const ghService = makeGithubService({ + apiRequest: vi.fn().mockRejectedValue(new Error("stop after payload capture")), + }); + const laneService = makeLaneService([ + makeFakeLane({ + parentLaneId: "lane-primary", + baseRef: "refs/heads/main", + }), + makeFakeLane({ + id: "lane-primary", + name: "Primary", + laneType: "primary", + baseRef: "refs/heads/release/2026", + branchRef: "refs/heads/release/2026", + parentLaneId: null, + }), + ]); + + const { service } = buildService({ githubService: ghService, laneService }); + + await expect( + service.createFromLane({ + laneId: LANE_ID, + title: "My PR", + body: "description", + draft: false, + allowDirtyWorktree: true, + }), + ).rejects.toThrow('Failed to create pull request for "my-feature" → "main": stop after payload capture'); + + expect(ghService.apiRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + body: expect.objectContaining({ + head: "my-feature", + base: "main", + }), + }), + ); + }); + it("throws when GitHub returns an invalid PR number", async () => { const ghService = makeGithubService({ apiRequest: vi.fn().mockResolvedValue({ diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 7bef7a630..69ef3876e 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -103,6 +103,7 @@ import { buildIntegrationPreflight } from "./integrationPlanning"; import { hasMergeConflictMarkers, parseGitStatusPorcelain } from "./integrationValidation"; import { fetchRemoteTrackingBranch } from "../shared/queueRebase"; import { asNumber, asString, getErrorMessage, normalizeBranchName, nowIso, resolvePathWithinRoot } from "../shared/utils"; +import { branchNameFromLaneRef, resolveStableLaneBaseBranch } from "../../../shared/laneBaseResolution"; type PullRequestRow = { id: string; @@ -180,9 +181,7 @@ type PrGroupMemberLookupRow = { }; function branchNameFromRef(ref: string): string { - const trimmed = ref.trim(); - if (trimmed.startsWith("refs/heads/")) return trimmed.slice("refs/heads/".length); - return trimmed; + return branchNameFromLaneRef(ref); } function normalizeGroupMemberRole(raw: string): PrGroupMemberRole { @@ -796,6 +795,7 @@ export function createPrService({ } const successorBaseBranch = branchNameFromRef(successorParent.branchRef); + const successorParentLaneId = successorParent.laneType === "primary" ? null : successorParent.id; const updatedLaneIds: string[] = []; const failedLaneIds: string[] = []; @@ -818,7 +818,7 @@ export function createPrService({ }); const refreshedChild = { ...(allLanesById.get(child.id) ?? child), - parentLaneId: successorParent.id, + parentLaneId: successorParentLaneId, baseRef: successorParent.branchRef, }; allLanesById.set(child.id, refreshedChild); @@ -838,7 +838,7 @@ export function createPrService({ failedLaneIds.push(child.id); await recordAttentionStatusSafely({ laneId: child.id, - parentLaneId: successorParent.id, + parentLaneId: successorParentLaneId, parentHeadSha: null, state: "rebaseFailed", conflictCount: 0, @@ -849,7 +849,7 @@ export function createPrService({ } const recorded = await recordAttentionStatusSafely({ laneId: child.id, - parentLaneId: successorParent.id, + parentLaneId: successorParentLaneId, parentHeadSha: null, state: "autoRebased", conflictCount: 0, @@ -1941,8 +1941,12 @@ export function createPrService({ const headBranch = branchNameFromRef(lane.branchRef); const parentLane = lane.parentLaneId ? allLanes.find((entry) => entry.id === lane.parentLaneId) ?? null : null; const primaryLane = allLanes.find((entry) => entry.laneType === "primary") ?? null; - const inferredBaseRef = parentLane?.branchRef ?? lane.baseRef ?? primaryLane?.branchRef ?? "main"; - const baseBranch = (args.baseBranch ?? branchNameFromRef(inferredBaseRef)).trim(); + const defaultBaseBranch = resolveStableLaneBaseBranch({ + lane, + parent: parentLane, + primaryBranchRef: primaryLane?.branchRef ?? null, + }); + const baseBranch = (args.baseBranch ?? defaultBaseBranch).trim(); // Push the branch to remote before creating the PR const upstreamCheck = await runGit( @@ -2726,9 +2730,7 @@ export function createPrService({ const normalized = normalizeBranchName(rawBranch); if (!normalized) return null; const byBranch = lanes.find((lane) => normalizeBranchName(lane.branchRef) === normalized); - if (byBranch) return byBranch.id; - const byBase = lanes.find((lane) => normalizeBranchName(lane.baseRef) === normalized); - return byBase?.id ?? null; + return byBranch?.id ?? null; }; const fallbackTargetLaneId = findLaneIdByBranch(row.base_branch); diff --git a/apps/desktop/src/main/services/prs/queueLandingService.ts b/apps/desktop/src/main/services/prs/queueLandingService.ts index a169853a8..36c2861eb 100644 --- a/apps/desktop/src/main/services/prs/queueLandingService.ts +++ b/apps/desktop/src/main/services/prs/queueLandingService.ts @@ -19,6 +19,7 @@ import type { Logger } from "../logging/logger"; import type { createConflictService } from "../conflicts/conflictService"; import type { createLaneService } from "../lanes/laneService"; import { runGit, runGitOrThrow } from "../git/git"; +import { branchNameFromLaneRef } from "../../../shared/laneBaseResolution"; import { getErrorMessage, normalizeBranchName, nowIso } from "../shared/utils"; type QueueLandingRow = { @@ -307,9 +308,8 @@ export function createQueueLandingService({ if (!normalizedTarget) return null; const lanes = await laneService.list({ includeArchived: false }); const match = lanes.find((lane) => { - const branch = normalizeBranchName(lane.branchRef); - const base = normalizeBranchName(lane.baseRef); - return lane.id === normalizedTarget || branch === normalizedTarget || base === normalizedTarget; + const branch = normalizeBranchName(branchNameFromLaneRef(lane.branchRef)); + return lane.id === normalizedTarget || branch === normalizedTarget; }); return match?.id ?? null; }; diff --git a/apps/desktop/src/main/services/prs/resolverUtils.test.ts b/apps/desktop/src/main/services/prs/resolverUtils.test.ts new file mode 100644 index 000000000..be679d8fc --- /dev/null +++ b/apps/desktop/src/main/services/prs/resolverUtils.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from "vitest"; +import { mapPermissionMode, readRecentCommits } from "./resolverUtils"; + +vi.mock("../git/git", () => ({ + runGit: vi.fn(), +})); + +import { runGit } from "../git/git"; + +const mockRunGit = vi.mocked(runGit); + +describe("mapPermissionMode", () => { + it("maps full_edit to full-auto", () => { + expect(mapPermissionMode("full_edit")).toBe("full-auto"); + }); + + it("maps read_only to plan", () => { + expect(mapPermissionMode("read_only")).toBe("plan"); + }); + + it("maps guarded_edit to edit", () => { + expect(mapPermissionMode("guarded_edit")).toBe("edit"); + }); + + it("maps undefined to edit", () => { + expect(mapPermissionMode(undefined)).toBe("edit"); + }); + + it("maps an unrecognized value to edit", () => { + expect(mapPermissionMode("some_other_value" as any)).toBe("edit"); + }); +}); + +describe("readRecentCommits", () => { + it("parses git log output into sha/subject pairs", async () => { + mockRunGit.mockResolvedValueOnce({ + exitCode: 0, + stdout: "abc123def456\tAdd feature X\nbbb222ccc333\tFix tests\n", + stderr: "", + } as any); + + const commits = await readRecentCommits("/tmp/worktree", 8); + + expect(mockRunGit).toHaveBeenCalledWith( + ["log", "--format=%H%x09%s", "-n", "8", "HEAD"], + { cwd: "/tmp/worktree", timeoutMs: 10_000 }, + ); + expect(commits).toEqual([ + { sha: "abc123def456", subject: "Add feature X" }, + { sha: "bbb222ccc333", subject: "Fix tests" }, + ]); + }); + + it("defaults to 8 commits and HEAD ref", async () => { + mockRunGit.mockResolvedValueOnce({ + exitCode: 0, + stdout: "aaa111bbb222\tFirst commit\n", + stderr: "", + } as any); + + await readRecentCommits("/tmp/worktree"); + + expect(mockRunGit).toHaveBeenCalledWith( + ["log", "--format=%H%x09%s", "-n", "8", "HEAD"], + expect.objectContaining({ cwd: "/tmp/worktree" }), + ); + }); + + it("uses a custom ref when provided", async () => { + mockRunGit.mockResolvedValueOnce({ + exitCode: 0, + stdout: "aaa111bbb222\tRemote commit\n", + stderr: "", + } as any); + + await readRecentCommits("/tmp/worktree", 5, "origin/main"); + + expect(mockRunGit).toHaveBeenCalledWith( + ["log", "--format=%H%x09%s", "-n", "5", "origin/main"], + expect.objectContaining({ cwd: "/tmp/worktree" }), + ); + }); + + it("returns empty array when git exits with non-zero", async () => { + mockRunGit.mockResolvedValueOnce({ + exitCode: 128, + stdout: "", + stderr: "fatal: bad default revision 'HEAD'", + } as any); + + const commits = await readRecentCommits("/tmp/worktree"); + + expect(commits).toEqual([]); + }); + + it("filters out empty lines and entries with no sha or subject", async () => { + mockRunGit.mockResolvedValueOnce({ + exitCode: 0, + stdout: "abc123\tGood commit\n\n \n\t\n", + stderr: "", + } as any); + + const commits = await readRecentCommits("/tmp/worktree"); + + expect(commits).toEqual([{ sha: "abc123", subject: "Good commit" }]); + }); + + it("handles tab characters in the commit subject", async () => { + mockRunGit.mockResolvedValueOnce({ + exitCode: 0, + stdout: "abc123\tSubject\twith\ttabs\n", + stderr: "", + } as any); + + const commits = await readRecentCommits("/tmp/worktree"); + + expect(commits).toEqual([{ sha: "abc123", subject: "Subject\twith\ttabs" }]); + }); +}); diff --git a/apps/desktop/src/main/services/prs/resolverUtils.ts b/apps/desktop/src/main/services/prs/resolverUtils.ts index ab63facba..625778a19 100644 --- a/apps/desktop/src/main/services/prs/resolverUtils.ts +++ b/apps/desktop/src/main/services/prs/resolverUtils.ts @@ -1,6 +1,31 @@ -import type { AgentChatPermissionMode, AiPermissionMode } from "../../../shared/types"; +import type { AgentChatPermissionMode, AiPermissionMode, PrComment } from "../../../shared/types"; import { runGit } from "../git/git"; +// --------------------------------------------------------------------------- +// Noisy comment detection — shared by issue inventory and issue resolver +// --------------------------------------------------------------------------- + +const NOISY_BOT_AUTHORS = new Set(["vercel", "vercel[bot]", "mintlify", "mintlify[bot]"]); + +const NOISY_BODY_PATTERNS = [ + /\[vc\]:/i, + /mintlify-preview/i, + /this is an auto-generated comment/i, + /pre-merge checks/i, + /thanks for using \[coderabbit\]/i, + /