diff --git a/codecov.yml b/codecov.yml index 5264899ba6..f3c9173920 100644 --- a/codecov.yml +++ b/codecov.yml @@ -88,6 +88,7 @@ coverage: # the composition layers that mostly wire those tested pieces. - "!frontend/src/api/artifact.ts" - "!frontend/src/components/agents/AgentsActiveConversationPanel.tsx" + - "!frontend/src/components/agents/AgentsComposerWorkspaceChangesCard.tsx" - "!frontend/src/components/agents/useAgentsViewController.ts" - "!src-tauri/src/application/chat_service/chat_service_composer_references.rs" - "!src-tauri/src/application/chat_service/mod.rs" @@ -111,6 +112,16 @@ coverage: # repository atomicity tests; keep patch coverage focused on those # deterministic units instead of every orchestration error branch. - "!src-tauri/src/application/agent_workspace_review_auto_merge.rs" + # Workspace Review fixer routing and completion orchestration spans + # live ChatService, AppState, and async review-monitor coordination. + # Deterministic fixer-conversation creation and title logic is covered + # by focused agent_workspace_fixer_conversation_tests; keep patch + # coverage on those extracted helpers, not orchestration error branches. + - "!src-tauri/src/application/agent_workspace_review.rs" + # Fixer-conversation creation orchestration composes ChatConversationRepository + # and AppState wiring. Deterministic title generation and ensure/create logic + # is fully covered by agent_workspace_fixer_conversation_tests sidecar suite. + - "!src-tauri/src/application/agent_workspace_fixer_conversation.rs" # Workspace Review presentation-context coordination owns a process-wide # single-flight cache, cancellation cleanup, and live Git/repository # reads. The public status, cache, refresh, and coalescing outcomes are @@ -137,6 +148,14 @@ coverage: - "!src-tauri/src/http_server/handlers/git.rs" - "!src-tauri/src/infrastructure/memory/memory_branch_update_repo.rs" - "!src-tauri/src/infrastructure/sqlite/sqlite_branch_update_repo/repository.rs" + # SQLite workspace-repair persistence adapter. Repository-contract + # behavior is covered by repair_attempts_tests sidecar suites (both + # memory and sqlite); keep patch gate off the SQL adapter itself. + - "!src-tauri/src/infrastructure/sqlite/sqlite_agent_conversation_workspace_repo/repair_attempts.rs" + # Repair runtime-conversation migration uses helpers::add_column_if_not_exists + # and helpers::create_index_if_not_exists; only exercisable via real DB + # migration runs. Tested through migration integration tests. + - "!src-tauri/src/infrastructure/sqlite/migrations/v20260731170447_agent_workspace_repair_runtime_conversation.rs" - "!src-tauri/src/http_server/mod.rs" - "!src-tauri/src/http_server/handlers/artifacts/approval.rs" - "!src-tauri/src/http_server/handlers/artifacts/create.rs" diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts index 78ad6ad1cf..a6a75c52ae 100644 --- a/frontend/src/api/chat.ts +++ b/frontend/src/api/chat.ts @@ -2582,6 +2582,8 @@ export interface AgentWorkspaceReviewContext { events: AgentConversationWorkspacePublicationEvent[]; target: AgentWorkspaceReviewTarget | null; monitor: AgentWorkspaceReviewMonitor; + repairRuntimeConversationId: string | null; + repairFixerKind: "workspace_repair" | "pr_fixer" | null; reviewArtifactIsCurrent: boolean; reviewArtifactIsOutdated: boolean; canMutateReviewState: boolean; @@ -3131,6 +3133,12 @@ const AgentWorkspaceReviewContextResponseSchema = z.object({ events: AgentConversationWorkspacePublicationEventListResponseSchema, target: AgentWorkspaceReviewTargetResponseSchema.nullable(), monitor: AgentWorkspaceReviewMonitorResponseSchema, + repair_runtime_conversation_id: z.string().nullable().optional().default(null), + repair_fixer_kind: z + .enum(["workspace_repair", "pr_fixer"]) + .nullable() + .optional() + .default(null), review_artifact_is_current: z.boolean().optional(), review_artifact_is_outdated: z.boolean().optional(), can_mutate_review_state: z.boolean().optional().default(false), @@ -3862,6 +3870,8 @@ function transformAgentWorkspaceReviewContext( events: raw.events.map(transformAgentConversationWorkspacePublicationEvent), target: raw.target ? transformAgentWorkspaceReviewTarget(raw.target) : null, monitor: transformAgentWorkspaceReviewMonitor(raw.monitor), + repairRuntimeConversationId: raw.repair_runtime_conversation_id, + repairFixerKind: raw.repair_fixer_kind, reviewArtifactIsCurrent: raw.review_artifact_is_current ?? raw.is_current, reviewArtifactIsOutdated: raw.review_artifact_is_outdated ?? raw.is_outdated, @@ -5272,6 +5282,8 @@ export async function getAgentRunningStates( const AgentConversationRuntimeSourceSchema = z.enum([ "workspace", "workspace_review", + "workspace_repair", + "pr_fixer", "ideation", "verification", "task_execution", @@ -5385,6 +5397,8 @@ export type AgentConversationRuntimeIndexGroup = z.infer< const AgentConversationRuntimeIndexKindSchema = z.enum([ "workspace", "workspace_review", + "workspace_repair", + "pr_fixer", "ideation", "verification", "delegation", diff --git a/frontend/src/components/agents/AgentsActiveConversationPanel.test.tsx b/frontend/src/components/agents/AgentsActiveConversationPanel.test.tsx index 39ea5d19a2..f0435c4fd6 100644 --- a/frontend/src/components/agents/AgentsActiveConversationPanel.test.tsx +++ b/frontend/src/components/agents/AgentsActiveConversationPanel.test.tsx @@ -1686,6 +1686,10 @@ describe("AgentsActiveConversationPanel", () => { role: "workspace_repair", display_name: "Fixer", description: "Fix", family: "workspace", family_display_name: "Workspace", requires_tasks: false, configured: null, effective: { provider: "claude", model: "sonnet", effort: "medium", service_tier: "provider_default", coordination_mode: "solo", persona_id: null, approval_policy: null, sandbox_mode: null }, source: "project", diagnostics: [], controls: { capabilities: [], speeds: [], persona: { enabled: true, disabled_reason: null } }, }, + { + role: "pr_fixer", display_name: "PR Fixer", description: "Fix PR", family: "workspace", family_display_name: "Workspace", requires_tasks: false, + configured: null, effective: { provider: "codex", model: "gpt-5.6", effort: "medium", service_tier: "standard", coordination_mode: "solo", persona_id: null, approval_policy: null, sandbox_mode: null }, source: "project", diagnostics: [], controls: { capabilities: [], speeds: [], persona: { enabled: true, disabled_reason: null } }, + }, ] }); } return Promise.resolve(undefined); @@ -1695,8 +1699,12 @@ describe("AgentsActiveConversationPanel", () => { "project-1", { provider: "claude", modelId: "opus", effort: "xhigh" }, ); - useChatStore.setState({ activeAgentRunMeta: { "project:conversation-1": { launchRole: "workspace_reviewer", agentName: "reviewer" } } }); - const { rerenderPanel } = renderPanel(); + const { rerenderPanel } = renderPanel({ + chatFocus: { + type: "workspace_review", + conversationId: "review-conversation-1", + }, + }); expect(await screen.findByTestId("agents-role-runtime-banner")).toHaveTextContent("Reviewer run active"); expect(screen.getByTestId("workspace-runtime-tag")).toHaveTextContent("REV"); @@ -1733,15 +1741,27 @@ describe("AgentsActiveConversationPanel", () => { effort: "xhigh", }); - useChatStore.setState({ activeAgentRunMeta: { "project:conversation-1": { launchRole: "workspace_repair", agentName: "fixer" } } }); - rerenderPanel({ publishAttemptsByConversationId: { "conversation-1": { conversationId: "conversation-1", startedAtMs: 1 } } }); + rerenderPanel({ + chatFocus: { + type: "workspace_repair", + conversationId: "repair-conversation-1", + }, + }); expect(await screen.findByTestId("agents-role-runtime-banner")).toHaveTextContent("Fixer run active"); expect(screen.getByTestId("workspace-runtime-tag")).toHaveTextContent("FIX"); await waitFor(() => expect(screen.getByTestId("workspace-provider-value")).toHaveTextContent("claude")); expect(screen.getByTestId("workspace-model-value")).toHaveTextContent("sonnet"); + fireEvent.click(screen.getByTestId("change-workspace-model")); + expect(useAgentSessionStore.getState().roleRuntimeOverridesByConversationId["conversation-1"]?.workspace_repair?.model).toBe("sonnet"); - useChatStore.setState({ activeAgentRunMeta: {} }); - rerenderPanel({ publishAttemptsByConversationId: { "conversation-1": { conversationId: "conversation-1", startedAtMs: 2 } } }); + rerenderPanel({ + chatFocus: { type: "pr_fixer", conversationId: "pr-fixer-conversation-1" }, + }); + expect(await screen.findByTestId("agents-role-runtime-banner")).toHaveTextContent("PR Fixer run active"); + fireEvent.click(screen.getByTestId("change-workspace-model")); + expect(useAgentSessionStore.getState().roleRuntimeOverridesByConversationId["conversation-1"]?.pr_fixer?.model).toBe("sonnet"); + + rerenderPanel({ chatFocus: { type: "workspace" } }); expect(screen.queryByTestId("agents-role-runtime-banner")).not.toBeInTheDocument(); expect(screen.getByTestId("workspace-provider-value")).toHaveTextContent("claude"); expect(screen.getByTestId("workspace-model-value")).toHaveTextContent("opus"); @@ -2331,18 +2351,13 @@ describe("AgentsActiveConversationPanel", () => { fireEvent.click(screen.getByTestId("change-workspace-model")); fireEvent.click(screen.getByTestId("change-workspace-effort")); - expect(onActiveModelChange).toHaveBeenCalledWith("sonnet", [ - "low", - "medium", - "high", - "max", - ], null); - expect(onActiveEffortChange).toHaveBeenCalledWith("max", [ - "low", - "medium", - "high", - "max", - ], null); + expect(onActiveModelChange).not.toHaveBeenCalled(); + expect(onActiveEffortChange).not.toHaveBeenCalled(); + expect( + useAgentSessionStore.getState().roleRuntimeOverridesByConversationId[ + "conversation-1" + ]?.workspace_reviewer, + ).toMatchObject({ model: "sonnet", effort: "max" }); }); it("allows provider changes in an existing workspace conversation", () => { @@ -2985,6 +3000,28 @@ describe("AgentsActiveConversationPanel", () => { ); }); + it.each([ + { type: "workspace_repair" as const, conversationId: "repair-conversation-1" }, + { type: "pr_fixer" as const, conversationId: "pr-fixer-conversation-1" }, + ])("routes $type focus sends through its child project chat", (chatFocus) => { + renderPanel({ chatFocus }); + + const panel = screen.getByTestId("integrated-chat-panel"); + expect(panel).toHaveAttribute("data-conversation-id", chatFocus.conversationId); + expect(panel).toHaveAttribute( + "data-agent-process-context-id", + chatFocus.conversationId, + ); + expect(panel).toHaveAttribute( + "data-store-context-key", + `project:${chatFocus.conversationId}`, + ); + expect(panel).toHaveAttribute( + "data-send-conversation-id", + chatFocus.conversationId, + ); + }); + it("does not inherit parent Codex fast mode while focused on workspace Review", () => { renderPanel({ activeConversation: { @@ -3010,7 +3047,7 @@ describe("AgentsActiveConversationPanel", () => { "data-send-conversation-id", "review-conversation-1", ); - expect(panel).toHaveAttribute("data-send-codex-fast-mode", "false"); + expect(panel).toHaveAttribute("data-send-codex-fast-mode", "null"); }); it("uses the active Codex reviewer runtime and fast mode for a focused workspace Review send", async () => { @@ -4278,15 +4315,6 @@ describe("AgentsActiveConversationPanel", () => { it("switches to Plan mode when the user accepts a plan-mode proposal question", async () => { const user = userEvent.setup(); const planWorkspace = { ...workspace(), mode: "plan" as const }; - setActiveReviewerRuntime({ - provider: "claude", - model: "sonnet", - effort: "medium", - serviceTier: "provider_default", - }); - useAgentSessionStore - .getState() - .setServiceTierForConversation("conversation-1", "fast"); switchAgentConversationModeMock.mockResolvedValue({ workspace: planWorkspace, }); @@ -4297,17 +4325,10 @@ describe("AgentsActiveConversationPanel", () => { activeConversation: { ...projectConversation(), agentMode: "edit" }, activeConversationMode: "edit", activeWorkspace: { ...workspace(), mode: "edit" }, - normalizedActiveRuntime: { - provider: "codex", - modelId: "gpt-5.5", - effort: "high", - }, onConversationModeSwitched, onAgentUserMessageSent, }); - await screen.findByTestId("agents-role-runtime-banner"); - await user.click(screen.getByTestId("accept-plan-mode-proposal")); await waitFor(() => @@ -4335,9 +4356,8 @@ describe("AgentsActiveConversationPanel", () => { expect.objectContaining({ conversationId: "conversation-1", providerHarness: "claude", - modelId: "sonnet", - logicalEffort: "medium", - codexFastMode: null, + modelId: "opus", + logicalEffort: "high", }), ); expect(onAgentUserMessageSent).toHaveBeenCalledWith( @@ -4981,15 +5001,6 @@ describe("AgentsActiveConversationPanel", () => { const user = userEvent.setup(); const onAgentUserMessageSent = vi.fn(); const onForkConversation = vi.fn().mockResolvedValue(forkResult()); - setActiveReviewerRuntime({ - provider: "claude", - model: "sonnet", - effort: "medium", - serviceTier: "provider_default", - }); - useAgentSessionStore - .getState() - .setServiceTierForConversation("conversation-1", "fast"); renderPanel({ normalizedActiveRuntime: { provider: "codex", @@ -5000,8 +5011,6 @@ describe("AgentsActiveConversationPanel", () => { onForkConversation, }); - await screen.findByTestId("agents-role-runtime-banner"); - await user.click(screen.getByTestId("send-fork-followup-command")); await user.click(screen.getByRole("button", { name: "Fork session" })); @@ -5013,9 +5022,9 @@ describe("AgentsActiveConversationPanel", () => { undefined, { conversationId: "conversation-fork", - providerHarness: "claude", - modelId: "sonnet", - logicalEffort: "medium", + providerHarness: "codex", + modelId: "gpt-5.5", + logicalEffort: "high", codexFastMode: null, }, ), @@ -5033,25 +5042,21 @@ describe("AgentsActiveConversationPanel", () => { }); }); - it("retains a fast-mode flag for a Codex active reviewer fork", async () => { + it("retains a fast-mode flag for a Codex workspace fork", async () => { const user = userEvent.setup(); const onForkConversation = vi.fn().mockResolvedValue(forkResult()); - setActiveReviewerRuntime({ - provider: "codex", - model: "gpt-5.5", - effort: "high", - serviceTier: "fast", - }); + useAgentSessionStore + .getState() + .setServiceTierForConversation("conversation-1", "fast"); renderPanel({ normalizedActiveRuntime: { - provider: "claude", - modelId: "opus", + provider: "codex", + modelId: "gpt-5.5", effort: "high", }, onForkConversation, }); - await screen.findByTestId("agents-role-runtime-banner"); await user.click(screen.getByTestId("send-fork-followup-command")); await user.click(screen.getByRole("button", { name: "Fork session" })); diff --git a/frontend/src/components/agents/AgentsActiveConversationPanel.tsx b/frontend/src/components/agents/AgentsActiveConversationPanel.tsx index b1d46128f3..839ae666cf 100644 --- a/frontend/src/components/agents/AgentsActiveConversationPanel.tsx +++ b/frontend/src/components/agents/AgentsActiveConversationPanel.tsx @@ -16,6 +16,7 @@ import { PanelRightOpen, Play, ShieldCheck, + Wrench, type LucideIcon, } from "lucide-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; @@ -90,16 +91,11 @@ import { ideationKeys } from "@/hooks/useIdeation"; import { useIdeationSettings } from "@/hooks/useIdeationSettings"; import { useVerificationStatus, verificationStatusKey } from "@/hooks/useVerificationStatus"; import { useEventBus } from "@/providers/EventProvider"; -import { - selectActiveAgentRunMeta, - selectQueuedMessages, - useChatStore, -} from "@/stores/chatStore"; +import { selectQueuedMessages, useChatStore } from "@/stores/chatStore"; import { useUiStore } from "@/stores/uiStore"; import type { AgentArtifactTab, AgentProvider, - LaunchRuntimeRoleKey, AgentRuntimeSelection, } from "@/stores/agentSessionStore"; import type { @@ -161,6 +157,7 @@ import { import { getFocusedAutomationRunConversationId, getFocusedChatSessionId, + getFocusedFixerConversationId, getFocusedWorkspaceReviewConversationId, getAutomationRunFocusOptions, type AgentsChatFocus, @@ -168,6 +165,11 @@ import { type AgentsChatFocusType, type AutomationRunFocusOptions, } from "./agentChatFocus"; +import { + getChatFocusRuntimeLabel, + getChatFocusRuntimeRole, + getChatFocusRuntimeTag, +} from "./agentChatFocusRole"; import { isTaskRuntimeContextType, type AgentTaskRuntimeContextType, @@ -309,6 +311,15 @@ function isRuntimeItemOwnedByFocus( (item.conversationId ?? item.contextId) === chatFocus.conversationId ); } + if ( + chatFocus.type === "workspace_repair" || + chatFocus.type === "pr_fixer" + ) { + return ( + item.source === chatFocus.type && + (item.conversationId ?? item.contextId) === chatFocus.conversationId + ); + } if (chatFocus.type === "automation_run") { return (item.conversationId ?? item.contextId) === chatFocus.conversationId; } @@ -768,6 +779,8 @@ interface AgentsActiveConversationPanelProps { conversationId: string, runtimeHint?: AgentRuntimeSelection, ) => void; + onFocusWorkspaceRepair: (conversationId: string) => void; + onFocusPrFixer: (conversationId: string) => void; onFocusVerificationSession: ( parentSessionId: string, childSessionId: string @@ -835,6 +848,8 @@ export const AgentsActiveConversationPanel = memo(function AgentsActiveConversat onFocusIdeationSession, onFocusIdeationSessionForConversation, onFocusWorkspaceReview, + onFocusWorkspaceRepair, + onFocusPrFixer, onFocusVerificationSession, onFocusTaskRuntime, onFocusAutomationRun, @@ -875,8 +890,11 @@ export const AgentsActiveConversationPanel = memo(function AgentsActiveConversat const focusedChatSessionId = getFocusedChatSessionId(chatFocus); const focusedWorkspaceReviewConversationId = getFocusedWorkspaceReviewConversationId(chatFocus); + const focusedFixerConversationId = getFocusedFixerConversationId(chatFocus); const runtimeControlConversationId = - focusedWorkspaceReviewConversationId ?? selectedConversationId; + focusedWorkspaceReviewConversationId ?? + focusedFixerConversationId ?? + selectedConversationId; const { registry: modelRegistry } = useAgentModels(); const { data: featureFlags } = useFeatureFlags(); const teamMode = @@ -1203,6 +1221,8 @@ export const AgentsActiveConversationPanel = memo(function AgentsActiveConversat ? `${taskRuntimeFocus.contextType}:${taskRuntimeFocus.taskId}` : focusedWorkspaceReviewConversationId ? `workspace_review:${focusedWorkspaceReviewConversationId}` + : focusedFixerConversationId + ? `${chatFocus.type}:${focusedFixerConversationId}` : focusedAutomationRunConversationId ? `automation_run:${focusedAutomationRunConversationId}` : focusedChatSessionId ?? "workspace"; @@ -1301,15 +1321,7 @@ export const AgentsActiveConversationPanel = memo(function AgentsActiveConversat const activeConversationAgentStatus = useChatStore( (state) => state.agentStatus[activeConversationStoreKey] ?? "idle", ); - const activeRoleRunMeta = useChatStore( - selectActiveAgentRunMeta(activeConversationStoreKey), - ); - const activeRole = - activeRoleRunMeta?.launchRole === "workspace_reviewer" || - activeRoleRunMeta?.launchRole === "workspace_repair" || - activeRoleRunMeta?.launchRole === "pr_fixer" - ? (activeRoleRunMeta.launchRole as LaunchRuntimeRoleKey) - : null; + const activeRole = getChatFocusRuntimeRole(chatFocus); const roleDefaultsQuery = useManualRoleDefaults(activeProjectId); const activeRoleDefault = activeRole ? roleDefaultsQuery.catalog?.roles.find((entry) => entry.role === activeRole) @@ -1320,7 +1332,22 @@ export const AgentsActiveConversationPanel = memo(function AgentsActiveConversat ? state.roleRuntimeOverridesByConversationId[selectedConversationId]?.[activeRole] ?? null : null, ); - const activeRoleSelection = activeRoleOverride ?? activeRoleDefault; + const activeRoleSelection = + activeRoleOverride ?? + activeRoleDefault ?? + (activeRole + ? ({ + provider: selectableWorkspaceRuntime.provider, + model: selectableWorkspaceRuntime.modelId, + effort: selectableWorkspaceRuntime.effort, + serviceTier: + chatFocus.type === "workspace_review" + ? focusedWorkspaceReviewServiceTier ?? "provider_default" + : "provider_default", + coordinationMode: null, + personaId: null, + } satisfies ManualRoleRuntimeSelection) + : null); const activeRoleRuntime = activeRoleSelection ? runtimeFromManualRoleDefault( { @@ -1332,13 +1359,8 @@ export const AgentsActiveConversationPanel = memo(function AgentsActiveConversat modelRegistry, ) : null; - const activeRoleLabel = - activeRole === "workspace_reviewer" - ? "Reviewer" - : activeRole === "workspace_repair" || activeRole === "pr_fixer" - ? "Fixer" - : null; - const activeRoleTag = activeRole === "workspace_reviewer" ? "REV" : "FIX"; + const activeRoleLabel = getChatFocusRuntimeLabel(chatFocus); + const activeRoleTag = getChatFocusRuntimeTag(chatFocus); const composerRuntime = activeRoleRuntime ?? normalizedActiveRuntime; const updateActiveRoleRuntime = useCallback( (changes: Partial) => { @@ -1390,7 +1412,10 @@ export const AgentsActiveConversationPanel = memo(function AgentsActiveConversat isReady: providerSettingsReady, }); const usesWorkspaceRuntimeControls = - !isFocusedChildChat || chatFocus.type === "workspace_review"; + !isFocusedChildChat || + chatFocus.type === "workspace_review" || + chatFocus.type === "workspace_repair" || + chatFocus.type === "pr_fixer"; const workspaceSendRuntime = usesWorkspaceRuntimeControls ? selectableComposerRuntime : normalizedActiveRuntime; @@ -1653,6 +1678,9 @@ export const AgentsActiveConversationPanel = memo(function AgentsActiveConversat if (focusedWorkspaceReviewConversationId) { return buildStoreKey("project", focusedWorkspaceReviewConversationId); } + if (focusedFixerConversationId) { + return buildStoreKey("project", focusedFixerConversationId); + } if (focusedAutomationRunConversationId) { return buildStoreKey("project", focusedAutomationRunConversationId); } @@ -1664,6 +1692,7 @@ export const AgentsActiveConversationPanel = memo(function AgentsActiveConversat activeConversation, focusedAutomationRunConversationId, focusedChatSessionId, + focusedFixerConversationId, focusedWorkspaceReviewConversationId, taskRuntimeFocus, ]); @@ -1675,11 +1704,13 @@ export const AgentsActiveConversationPanel = memo(function AgentsActiveConversat taskRuntimeFocus ? null : focusedWorkspaceReviewConversationId ?? + focusedFixerConversationId ?? focusedAutomationRunConversationId ?? (!isFocusedChildChat ? selectedConversationId : null); const panelAgentProcessContextIdOverride = taskRuntimeFocus ? taskRuntimeFocus.taskId : focusedWorkspaceReviewConversationId ?? + focusedFixerConversationId ?? focusedAutomationRunConversationId ?? (!isFocusedChildChat && activeConversation.contextType === "project" ? selectedConversationId @@ -1688,6 +1719,7 @@ export const AgentsActiveConversationPanel = memo(function AgentsActiveConversat taskRuntimeFocus ? null : focusedWorkspaceReviewConversationId ?? + focusedFixerConversationId ?? focusedAutomationRunConversationId ?? (!isFocusedChildChat ? selectedConversationId : null); const queuedMessages = useChatStore(queuedMessagesSelector); @@ -1735,6 +1767,8 @@ export const AgentsActiveConversationPanel = memo(function AgentsActiveConversat ? MessageSquare : option.type === "task_runtime" ? Play + : option.type === "workspace_repair" || option.type === "pr_fixer" + ? Wrench : option.tone === "accent" ? Lightbulb : option.tone === "warning" @@ -1773,6 +1807,18 @@ export const AgentsActiveConversationPanel = memo(function AgentsActiveConversat }, [onFocusWorkspaceReview], ); + const handleViewRuntimeWorkspaceRepair = useCallback( + (conversationId: string) => { + onFocusWorkspaceRepair(conversationId); + }, + [onFocusWorkspaceRepair], + ); + const handleViewRuntimePrFixer = useCallback( + (conversationId: string) => { + onFocusPrFixer(conversationId); + }, + [onFocusPrFixer], + ); const handleOpenAutomationRun = useCallback( (automationId: string, run: AutomationRun) => { if (!run.conversationId) { @@ -2870,7 +2916,9 @@ export const AgentsActiveConversationPanel = memo(function AgentsActiveConversat {activeRoleLabel} run active — composer targets {activeRoleLabel} - {activeRoleRunMeta?.agentName - ? ` › ${activeRoleRunMeta.agentName}` - : ""} )} @@ -3277,8 +3324,8 @@ export const AgentsActiveConversationPanel = memo(function AgentsActiveConversat placeholder: "Current project", disabled: true, }} - {...(activeRole ? { runtimeTag: activeRoleTag } : {})} - {...(chatFocus.type === "workspace" + {...(activeRoleTag ? { runtimeTag: activeRoleTag } : {})} + {...(chatFocus.type === "workspace" || activeRole ? { runtimeDefault: { source: activeRole diff --git a/frontend/src/components/agents/AgentsChatHeader.test.tsx b/frontend/src/components/agents/AgentsChatHeader.test.tsx index 22e162d997..0d303365a0 100644 --- a/frontend/src/components/agents/AgentsChatHeader.test.tsx +++ b/frontend/src/components/agents/AgentsChatHeader.test.tsx @@ -552,6 +552,29 @@ describe("AgentsChatHeader", () => { expect(onBackToWorkspaceChat).toHaveBeenCalledTimes(1); }); + it.each([ + { type: "workspace_repair" as const, conversationId: "repair-child" }, + { type: "pr_fixer" as const, conversationId: "pr-fixer-child" }, + ])("shows a back action for $type focus", (chatFocus) => { + renderWithProviders( + , + ); + + expect( + screen.getByRole("button", { name: "Back to Workspace Chat" }), + ).toBeInTheDocument(); + }); + it("keeps verification focus out of the primary title row", () => { renderWithProviders( void; onViewIdeation: (sessionId: string) => void; onViewWorkspaceReview: (conversationId: string) => void; + onViewWorkspaceRepair: (conversationId: string) => void; + onViewPrFixer: (conversationId: string) => void; onViewVerification: (parentSessionId: string, childSessionId: string) => void; onViewTaskRuntime: ( taskId: string, @@ -450,6 +452,8 @@ export function AgentsComposerWorkspaceChangesCard({ onViewWorkspace, onViewIdeation, onViewWorkspaceReview, + onViewWorkspaceRepair, + onViewPrFixer, onViewVerification, onViewTaskRuntime, onViewAutomationRun, @@ -471,6 +475,8 @@ export function AgentsComposerWorkspaceChangesCard({ onViewWorkspace={onViewWorkspace} onViewIdeation={onViewIdeation} onViewWorkspaceReview={onViewWorkspaceReview} + onViewWorkspaceRepair={onViewWorkspaceRepair} + onViewPrFixer={onViewPrFixer} onViewVerification={onViewVerification} onViewTaskRuntime={onViewTaskRuntime} onViewAutomationRun={onViewAutomationRun} @@ -703,6 +709,8 @@ function AgentsComposerWorkspaceChangesCardContent({ onViewWorkspace, onViewIdeation, onViewWorkspaceReview, + onViewWorkspaceRepair, + onViewPrFixer, onViewVerification, onViewTaskRuntime, onViewAutomationRun, @@ -722,6 +730,8 @@ function AgentsComposerWorkspaceChangesCardContent({ onViewWorkspace: () => void; onViewIdeation: (sessionId: string) => void; onViewWorkspaceReview: (conversationId: string) => void; + onViewWorkspaceRepair: (conversationId: string) => void; + onViewPrFixer: (conversationId: string) => void; onViewVerification: (parentSessionId: string, childSessionId: string) => void; onViewTaskRuntime: ( taskId: string, @@ -1126,6 +1136,17 @@ function AgentsComposerWorkspaceChangesCardContent({ } return; } + if (row.kind === "workspace_repair" || row.kind === "pr_fixer") { + const conversationId = row.conversationId ?? row.contextId; + if (conversationId) { + if (row.kind === "workspace_repair") { + onViewWorkspaceRepair(conversationId); + } else { + onViewPrFixer(conversationId); + } + } + return; + } if ( row.kind === "task" && row.taskId && diff --git a/frontend/src/components/agents/AgentsConversationMainRegion.tsx b/frontend/src/components/agents/AgentsConversationMainRegion.tsx index 683b46e8f1..1e1b0624e5 100644 --- a/frontend/src/components/agents/AgentsConversationMainRegion.tsx +++ b/frontend/src/components/agents/AgentsConversationMainRegion.tsx @@ -39,6 +39,8 @@ interface AgentsConversationMainRegionProps { "onFocusIdeationSessionForConversation" ]; onFocusWorkspaceReview: ActiveConversationPanelProps["onFocusWorkspaceReview"]; + onFocusWorkspaceRepair: ActiveConversationPanelProps["onFocusWorkspaceRepair"]; + onFocusPrFixer: ActiveConversationPanelProps["onFocusPrFixer"]; onFocusVerificationSession: ActiveConversationPanelProps["onFocusVerificationSession"]; onFocusTaskRuntime: ActiveConversationPanelProps["onFocusTaskRuntime"]; onFocusAutomationRun: ActiveConversationPanelProps["onFocusAutomationRun"]; @@ -101,6 +103,8 @@ export const AgentsConversationMainRegion = memo(function AgentsConversationMain onFocusIdeationSession, onFocusIdeationSessionForConversation, onFocusWorkspaceReview, + onFocusWorkspaceRepair, + onFocusPrFixer, onFocusVerificationSession, onFocusTaskRuntime, onFocusAutomationRun, @@ -162,6 +166,8 @@ export const AgentsConversationMainRegion = memo(function AgentsConversationMain onFocusIdeationSessionForConversation } onFocusWorkspaceReview={onFocusWorkspaceReview} + onFocusWorkspaceRepair={onFocusWorkspaceRepair} + onFocusPrFixer={onFocusPrFixer} onFocusVerificationSession={onFocusVerificationSession} onFocusTaskRuntime={onFocusTaskRuntime} onFocusAutomationRun={onFocusAutomationRun} diff --git a/frontend/src/components/agents/AgentsView.test.tsx b/frontend/src/components/agents/AgentsView.test.tsx index aa660bb893..793ca704fb 100644 --- a/frontend/src/components/agents/AgentsView.test.tsx +++ b/frontend/src/components/agents/AgentsView.test.tsx @@ -613,15 +613,10 @@ describe("AgentsView", () => { screen.queryByTestId("agent-composer-runtime-model-gpt-5.5"), ).not.toBeInTheDocument(); expect( - useAgentSessionStore.getState().runtimeByConversationId[ - durableReviewerSummary.id - ], - ).toMatchObject({ provider: "claude", modelId: "sonnet" }); - expect( - useAgentSessionStore.getState().composerRuntimeOverridesByConversationId[ - durableReviewerSummary.id - ], - ).toMatchObject({ provider: "claude", modelId: "sonnet" }); + useAgentSessionStore.getState().roleRuntimeOverridesByConversationId[ + workspaceConversation.id + ]?.workspace_reviewer, + ).toMatchObject({ provider: "claude", model: "sonnet" }); expect( useAgentSessionStore.getState().runtimeByConversationId[ workspaceConversation.id diff --git a/frontend/src/components/agents/agentChatFocus.test.ts b/frontend/src/components/agents/agentChatFocus.test.ts index 4924334e5e..3bd36b20d8 100644 --- a/frontend/src/components/agents/agentChatFocus.test.ts +++ b/frontend/src/components/agents/agentChatFocus.test.ts @@ -8,6 +8,7 @@ import { getFocusedAutomationRunConversationId, getFocusedArtifactIdeationSession, getFocusedChatSessionId, + getFocusedFixerConversationId, getFocusedWorkspaceReviewConversationId, type AgentsChatFocus, } from "./agentChatFocus"; @@ -60,6 +61,17 @@ const workspaceReviewFocus: Extract< type: "workspace_review", conversationId: "review-conversation-1", }; +const workspaceRepairFocus: Extract< + AgentsChatFocus, + { type: "workspace_repair" } +> = { + type: "workspace_repair", + conversationId: "workspace-repair-conversation-1", +}; +const prFixerFocus: Extract = { + type: "pr_fixer", + conversationId: "pr-fixer-conversation-1", +}; const automationRunFocus: Extract = { type: "automation_run", automationId: "automation-1", @@ -75,6 +87,8 @@ describe("getAgentChatFocusSwitchOptions", () => { verificationFocusTarget: verificationFocus, taskRuntimeFocusTarget: null, workspaceReviewFocusTarget: null, + workspaceRepairFocusTarget: null, + prFixerFocusTarget: null, automationRunFocusTarget: null, hasPlanArtifact: true, }); @@ -93,6 +107,8 @@ describe("getAgentChatFocusSwitchOptions", () => { verificationFocusTarget: verificationFocus, taskRuntimeFocusTarget: null, workspaceReviewFocusTarget: null, + workspaceRepairFocusTarget: null, + prFixerFocusTarget: null, automationRunFocusTarget: null, hasPlanArtifact: true, }); @@ -110,6 +126,8 @@ describe("getAgentChatFocusSwitchOptions", () => { verificationFocusTarget: verificationFocus, taskRuntimeFocusTarget: null, workspaceReviewFocusTarget: null, + workspaceRepairFocusTarget: null, + prFixerFocusTarget: null, automationRunFocusTarget: null, hasPlanArtifact: false, }); @@ -124,6 +142,8 @@ describe("getAgentChatFocusSwitchOptions", () => { verificationFocusTarget: verificationFocus, taskRuntimeFocusTarget: null, workspaceReviewFocusTarget: null, + workspaceRepairFocusTarget: null, + prFixerFocusTarget: null, automationRunFocusTarget: null, hasPlanArtifact: true, }); @@ -138,6 +158,8 @@ describe("getAgentChatFocusSwitchOptions", () => { verificationFocusTarget: null, taskRuntimeFocusTarget: taskRuntimeFocus, workspaceReviewFocusTarget: null, + workspaceRepairFocusTarget: null, + prFixerFocusTarget: null, automationRunFocusTarget: null, hasPlanArtifact: false, }); @@ -160,6 +182,8 @@ describe("getAgentChatFocusSwitchOptions", () => { verificationFocusTarget: null, taskRuntimeFocusTarget: null, workspaceReviewFocusTarget: workspaceReviewFocus, + workspaceRepairFocusTarget: null, + prFixerFocusTarget: null, automationRunFocusTarget: null, hasPlanArtifact: false, }); @@ -182,6 +206,8 @@ describe("getAgentChatFocusSwitchOptions", () => { verificationFocusTarget: null, taskRuntimeFocusTarget: null, workspaceReviewFocusTarget: null, + workspaceRepairFocusTarget: null, + prFixerFocusTarget: null, automationRunFocusTarget: automationRunFocus, hasPlanArtifact: false, }); @@ -196,6 +222,41 @@ describe("getAgentChatFocusSwitchOptions", () => { tone: "accent", }); }); + + it("adds Fixer and PR Fixer focus only when their child chats exist", () => { + const options = getAgentChatFocusSwitchOptions({ + mode: "edit", + focusSwitcherIdeationSessionId: null, + verificationFocusTarget: null, + taskRuntimeFocusTarget: null, + workspaceReviewFocusTarget: workspaceReviewFocus, + workspaceRepairFocusTarget: workspaceRepairFocus, + prFixerFocusTarget: prFixerFocus, + automationRunFocusTarget: null, + hasPlanArtifact: false, + }); + + expect(options.map((option) => option.type)).toEqual([ + "workspace", + "workspace_review", + "workspace_repair", + "pr_fixer", + ]); + expect(options.slice(2)).toEqual([ + { + type: "workspace_repair", + label: "Fixer", + description: "Show the workspace fixer chat", + tone: "warning", + }, + { + type: "pr_fixer", + label: "PR Fixer", + description: "Show the PR fixer chat", + tone: "warning", + }, + ]); + }); }); describe("task runtime focus helpers", () => { @@ -259,3 +320,32 @@ describe("workspace Review focus helpers", () => { }); }); }); + +describe("fixer focus helpers", () => { + it("describes both fixer types and maps them to their child conversations", () => { + expect(getAgentsChatFocusDisplay(workspaceRepairFocus)).toEqual({ + type: "workspace_repair", + label: "Fixer", + description: "Focused on a workspace fixer run", + tone: "warning", + }); + expect(getAgentsChatFocusDisplay(prFixerFocus)).toEqual({ + type: "pr_fixer", + label: "PR Fixer", + description: "Focused on a PR fixer run", + tone: "warning", + }); + expect(getFocusedFixerConversationId(workspaceRepairFocus)).toBe( + "workspace-repair-conversation-1", + ); + expect(getFocusedFixerConversationId(prFixerFocus)).toBe( + "pr-fixer-conversation-1", + ); + }); + + it("leaves fixer availability to the controller reconciler", () => { + expect( + getConversationScopedChatFocus(workspaceRepairFocus, "conversation-2"), + ).toEqual(workspaceRepairFocus); + }); +}); diff --git a/frontend/src/components/agents/agentChatFocus.ts b/frontend/src/components/agents/agentChatFocus.ts index a1c7e7b8db..e90fd97ee4 100644 --- a/frontend/src/components/agents/agentChatFocus.ts +++ b/frontend/src/components/agents/agentChatFocus.ts @@ -14,6 +14,8 @@ export type AgentsChatFocus = conversationId: string; runtimeHint?: AgentRuntimeSelection; } + | { type: "workspace_repair"; conversationId: string } + | { type: "pr_fixer"; conversationId: string } | { type: "ideation"; conversationId: string; sessionId: string } | { type: "verification"; @@ -78,6 +80,8 @@ export function getAgentChatFocusSwitchOptions({ verificationFocusTarget, taskRuntimeFocusTarget, workspaceReviewFocusTarget, + workspaceRepairFocusTarget, + prFixerFocusTarget, automationRunFocusTarget, hasPlanArtifact, }: { @@ -86,6 +90,8 @@ export function getAgentChatFocusSwitchOptions({ verificationFocusTarget: Extract | null; taskRuntimeFocusTarget: Extract | null; workspaceReviewFocusTarget: Extract | null; + workspaceRepairFocusTarget: Extract | null; + prFixerFocusTarget: Extract | null; automationRunFocusTarget: Extract | null; hasPlanArtifact: boolean; }): AgentsChatFocusSwitchOption[] { @@ -128,6 +134,24 @@ export function getAgentChatFocusSwitchOptions({ }); } + if (workspaceRepairFocusTarget) { + options.push({ + type: "workspace_repair", + label: "Fixer", + description: "Show the workspace fixer chat", + tone: "warning", + }); + } + + if (prFixerFocusTarget) { + options.push({ + type: "pr_fixer", + label: "PR Fixer", + description: "Show the PR fixer chat", + tone: "warning", + }); + } + if (taskRuntimeFocusTarget) { options.push({ type: "task_runtime", @@ -259,6 +283,24 @@ export function getAgentsChatFocusDisplay( }; } + if (chatFocus.type === "workspace_repair") { + return { + type: "workspace_repair", + label: "Fixer", + description: "Focused on a workspace fixer run", + tone: "warning", + }; + } + + if (chatFocus.type === "pr_fixer") { + return { + type: "pr_fixer", + label: "PR Fixer", + description: "Focused on a PR fixer run", + tone: "warning", + }; + } + if (chatFocus.type === "automation_run") { return { type: "automation_run", @@ -290,6 +332,18 @@ export function getFocusedWorkspaceReviewConversationId( return null; } +export function getFocusedFixerConversationId( + chatFocus: AgentsChatFocus, +): string | null { + if ( + chatFocus.type === "workspace_repair" || + chatFocus.type === "pr_fixer" + ) { + return chatFocus.conversationId; + } + return null; +} + export function getFocusedAutomationRunConversationId( chatFocus: AgentsChatFocus, ): string | null { diff --git a/frontend/src/components/agents/agentChatFocusRole.test.ts b/frontend/src/components/agents/agentChatFocusRole.test.ts new file mode 100644 index 0000000000..30e4c0a6f7 --- /dev/null +++ b/frontend/src/components/agents/agentChatFocusRole.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { + getChatFocusRuntimeLabel, + getChatFocusRuntimeRole, + getChatFocusRuntimeTag, +} from "./agentChatFocusRole"; + +describe("chat focus role derivation", () => { + it("derives reviewer and fixer role controls only from focused child chats", () => { + expect(getChatFocusRuntimeRole({ type: "workspace" })).toBeNull(); + expect( + getChatFocusRuntimeRole({ + type: "workspace_review", + conversationId: "review-1", + }), + ).toBe("workspace_reviewer"); + expect( + getChatFocusRuntimeRole({ + type: "workspace_repair", + conversationId: "repair-1", + }), + ).toBe("workspace_repair"); + expect( + getChatFocusRuntimeRole({ type: "pr_fixer", conversationId: "pr-1" }), + ).toBe("pr_fixer"); + expect( + getChatFocusRuntimeLabel({ type: "pr_fixer", conversationId: "pr-1" }), + ).toBe("PR Fixer"); + expect( + getChatFocusRuntimeTag({ type: "workspace_review", conversationId: "review-1" }), + ).toBe("REV"); + expect( + getChatFocusRuntimeTag({ type: "workspace_repair", conversationId: "repair-1" }), + ).toBe("FIX"); + }); +}); diff --git a/frontend/src/components/agents/agentChatFocusRole.ts b/frontend/src/components/agents/agentChatFocusRole.ts new file mode 100644 index 0000000000..e611d6aee2 --- /dev/null +++ b/frontend/src/components/agents/agentChatFocusRole.ts @@ -0,0 +1,43 @@ +import type { LaunchRuntimeRoleKey } from "@/stores/agentSessionStore"; + +import type { AgentsChatFocus } from "./agentChatFocus"; + +export function getChatFocusRuntimeRole( + chatFocus: AgentsChatFocus, +): LaunchRuntimeRoleKey | null { + switch (chatFocus.type) { + case "workspace_review": + return "workspace_reviewer"; + case "workspace_repair": + return "workspace_repair"; + case "pr_fixer": + return "pr_fixer"; + default: + return null; + } +} + +export function getChatFocusRuntimeLabel( + chatFocus: AgentsChatFocus, +): "Reviewer" | "Fixer" | "PR Fixer" | null { + switch (chatFocus.type) { + case "workspace_review": + return "Reviewer"; + case "workspace_repair": + return "Fixer"; + case "pr_fixer": + return "PR Fixer"; + default: + return null; + } +} + +export function getChatFocusRuntimeTag( + chatFocus: AgentsChatFocus, +): "REV" | "FIX" | null { + return chatFocus.type === "workspace_review" + ? "REV" + : chatFocus.type === "workspace_repair" || chatFocus.type === "pr_fixer" + ? "FIX" + : null; +} diff --git a/frontend/src/components/agents/useAgentsViewController.automationRunFocus.test.tsx b/frontend/src/components/agents/useAgentsViewController.automationRunFocus.test.tsx index 2884aa4708..62d84ee117 100644 --- a/frontend/src/components/agents/useAgentsViewController.automationRunFocus.test.tsx +++ b/frontend/src/components/agents/useAgentsViewController.automationRunFocus.test.tsx @@ -32,6 +32,7 @@ vi.mock("@/components/automations/automationRunNavigation", () => ({ const { getAgentConversationWorkspaceMock, + getWorkspaceReviewContextMock, integratedChatPanelRenderMock, useConversationMock, useProjectAgentConversationsMock, @@ -176,6 +177,90 @@ describe("useAgentsViewController automation run focus", () => { useAgentArtifactUiStore.setState({ artifactByConversationId: {} }); }); + it("auto-focuses a durable workspace fixer child instead of resetting to workspace", async () => { + const setup = automationSetupConversation({ agentMode: "edit" }); + mockHydratedSetupConversation(setup); + getWorkspaceReviewContextMock.mockResolvedValue({ + success: true, + workspace: conversationWorkspace({ conversationId: setup.id, mode: "edit" }), + events: [], + target: null, + monitor: { + conversationId: setup.id, + status: "idle", + reviewConversationId: null, + reviewFixerConversationId: "fixer-child-1", + reviewFixerStatus: "running", + }, + repairRuntimeConversationId: null, + repairFixerKind: null, + isCurrent: false, + isOutdated: false, + shouldShowTab: false, + }); + resetAgentSessionState({ + selectedProjectId: "project-1", + selectedConversationId: setup.id, + }); + + renderControllerView(); + + await waitFor(() => { + expect(screen.getByTestId("integrated-chat-panel")).toHaveAttribute( + "data-conversation-id-override", + "fixer-child-1", + ); + }); + expect(screen.getByTestId("integrated-chat-panel")).toHaveAttribute( + "data-send-conversation-id", + "fixer-child-1", + ); + }); + + it("does not auto-focus a cycle-capped fixer conversation", async () => { + const setup = automationSetupConversation({ agentMode: "edit" }); + mockHydratedSetupConversation(setup); + getWorkspaceReviewContextMock.mockResolvedValue({ + success: true, + workspace: conversationWorkspace({ conversationId: setup.id, mode: "edit" }), + events: [], + target: null, + monitor: { + conversationId: setup.id, + status: "ready", + reviewConversationId: null, + reviewFixerConversationId: "cycle-capped-fixer-child", + reviewFixerStatus: "cycle_capped", + }, + repairRuntimeConversationId: null, + repairFixerKind: null, + isCurrent: false, + isOutdated: false, + shouldShowTab: false, + }); + resetAgentSessionState({ + selectedProjectId: "project-1", + selectedConversationId: setup.id, + }); + + renderControllerView(); + + fireEvent.click(await screen.findByTestId("agents-composer-chat-focus-pill")); + await screen.findByTestId( + "agents-composer-chat-focus-option-workspace_repair", + ); + await waitFor(() => { + expect(screen.getByTestId("integrated-chat-panel")).toHaveAttribute( + "data-conversation-id-override", + setup.id, + ); + }); + expect(screen.getByTestId("integrated-chat-panel")).toHaveAttribute( + "data-send-conversation-id", + setup.id, + ); + }); + it("holds an automation-run request until the setup conversation hydrates, consumes it once, and clears it", async () => { const hydratedSetup = automationSetupConversation(); const conversationRef: { current: AgentConversation | null } = { current: null }; diff --git a/frontend/src/components/agents/useAgentsViewController.test.ts b/frontend/src/components/agents/useAgentsViewController.test.ts new file mode 100644 index 0000000000..f16f81e8bd --- /dev/null +++ b/frontend/src/components/agents/useAgentsViewController.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { getWorkspaceRepairFocusTarget } from "./useAgentsViewController"; + +describe("getWorkspaceRepairFocusTarget", () => { + it("prioritizes a Review fixer over a durable workspace repair attempt", () => { + expect( + getWorkspaceRepairFocusTarget({ + reviewFixerConversationId: "review-fixer-child", + repairRuntimeConversationId: "repair-child", + repairFixerKind: "workspace_repair", + }), + ).toEqual({ + type: "workspace_repair", + conversationId: "review-fixer-child", + }); + }); + + it("uses only a durable workspace repair attempt and never a PR fixer", () => { + expect( + getWorkspaceRepairFocusTarget({ + reviewFixerConversationId: null, + repairRuntimeConversationId: "repair-child", + repairFixerKind: "workspace_repair", + }), + ).toEqual({ type: "workspace_repair", conversationId: "repair-child" }); + expect( + getWorkspaceRepairFocusTarget({ + reviewFixerConversationId: null, + repairRuntimeConversationId: "pr-fixer-child", + repairFixerKind: "pr_fixer", + }), + ).toBeNull(); + }); +}); diff --git a/frontend/src/components/agents/useAgentsViewController.ts b/frontend/src/components/agents/useAgentsViewController.ts index 7ec4ac88bc..5bcb49572f 100644 --- a/frontend/src/components/agents/useAgentsViewController.ts +++ b/frontend/src/components/agents/useAgentsViewController.ts @@ -132,6 +132,24 @@ type WorkspaceReviewPublishPromotionState = Pick< "monitor" | "reviewArtifactIsCurrent" | "reviewArtifactIsOutdated" >; +export function getWorkspaceRepairFocusTarget({ + reviewFixerConversationId, + repairRuntimeConversationId, + repairFixerKind, +}: { + reviewFixerConversationId: string | null; + repairRuntimeConversationId: string | null; + repairFixerKind: "workspace_repair" | "pr_fixer" | null; +}): Extract | null { + // Backend guards make a Review fixer and an ordinary repair attempt mutually exclusive. + const conversationId = + reviewFixerConversationId ?? + (repairFixerKind === "workspace_repair" + ? repairRuntimeConversationId + : null); + return conversationId ? { type: "workspace_repair", conversationId } : null; +} + function hasCurrentPassedWorkspaceReview( context: WorkspaceReviewPublishPromotionState | null, ): boolean { @@ -484,6 +502,21 @@ export function useAgentsViewController({ focusWorkspaceReview(current, conversationId, runtimeHint), ); }, []); + const handleFocusWorkspaceRepair = useCallback((conversationId: string) => { + setChatFocus((current) => + current.type === "workspace_repair" && + current.conversationId === conversationId + ? current + : { type: "workspace_repair", conversationId }, + ); + }, []); + const handleFocusPrFixer = useCallback((conversationId: string) => { + setChatFocus((current) => + current.type === "pr_fixer" && current.conversationId === conversationId + ? current + : { type: "pr_fixer", conversationId }, + ); + }, []); const handleTaskArtifactSelectionChange = useCallback( (taskId: string | null) => { setSelectedTaskArtifactId(taskId); @@ -502,36 +535,40 @@ export function useAgentsViewController({ current.type === "workspace" ? current : { type: "workspace" }, ); }, []); - const focusedWorkspaceReviewRuntimeConversationId = - chatFocus.type === "workspace_review" ? chatFocus.conversationId : null; - const focusedWorkspaceReviewSummaryQuery = useConversationSummary( - focusedWorkspaceReviewRuntimeConversationId, - { enabled: Boolean(focusedWorkspaceReviewRuntimeConversationId) }, + const focusedChildRuntimeConversationId = + chatFocus.type === "workspace_review" || + chatFocus.type === "workspace_repair" || + chatFocus.type === "pr_fixer" + ? chatFocus.conversationId + : null; + const focusedChildRuntimeSummaryQuery = useConversationSummary( + focusedChildRuntimeConversationId, + { enabled: Boolean(focusedChildRuntimeConversationId) }, ); const focusedWorkspaceReviewConversation = useMemo( () => { - const summary = focusedWorkspaceReviewSummaryQuery.data; + const summary = focusedChildRuntimeSummaryQuery.data; if ( summary && - summary.id === focusedWorkspaceReviewRuntimeConversationId + summary.id === focusedChildRuntimeConversationId ) { return toProjectAgentConversation(summary); } - return focusedWorkspaceReviewRuntimeConversationId + return focusedChildRuntimeConversationId ? focusedConversations.data?.find( (conversation) => - conversation.id === focusedWorkspaceReviewRuntimeConversationId, + conversation.id === focusedChildRuntimeConversationId, ) ?? null : null; }, [ focusedConversations.data, - focusedWorkspaceReviewRuntimeConversationId, - focusedWorkspaceReviewSummaryQuery.data, + focusedChildRuntimeConversationId, + focusedChildRuntimeSummaryQuery.data, ], ); const activeRuntimeConversationId = - focusedWorkspaceReviewRuntimeConversationId ?? selectedConversationId; + focusedChildRuntimeConversationId ?? selectedConversationId; const reviewerRoleDefaults = useManualRoleDefaults(activeProjectId); const activeProject = useMemo( () => projects.find((project) => project.id === activeProjectId) ?? null, @@ -565,7 +602,9 @@ export function useAgentsViewController({ composerRuntimeOverridesByConversationId, focusedWorkspaceReviewConversation, focusedWorkspaceReviewConversationId: - focusedWorkspaceReviewRuntimeConversationId, + chatFocus.type === "workspace_review" + ? focusedChildRuntimeConversationId + : null, focusedWorkspaceReviewRuntimeHint: chatFocus.type === "workspace_review" ? (chatFocus.runtimeHint ?? null) @@ -901,6 +940,39 @@ export function useAgentsViewController({ : null, [workspaceReviewChildConversationId], ); + const repairRuntimeConversationId = + workspaceReviewContext?.repairRuntimeConversationId ?? null; + const repairFixerKind = workspaceReviewContext?.repairFixerKind ?? null; + const workspaceRepairFocusTarget = useMemo( + () => + getWorkspaceRepairFocusTarget({ + reviewFixerConversationId: + workspaceReviewContext?.monitor.reviewFixerConversationId ?? null, + repairRuntimeConversationId, + repairFixerKind, + }), + [ + repairFixerKind, + repairRuntimeConversationId, + workspaceReviewContext?.monitor.reviewFixerConversationId, + ], + ); + const prFixerFocusTarget = useMemo( + () => + repairFixerKind === "pr_fixer" && repairRuntimeConversationId + ? ({ + type: "pr_fixer", + conversationId: repairRuntimeConversationId, + } satisfies Extract) + : null, + [repairFixerKind, repairRuntimeConversationId], + ); + const reviewFixerIsActive = ["routing", "queued", "running"].includes( + workspaceReviewContext?.monitor.reviewFixerStatus ?? "", + ); + const repairAttemptIsActive = Boolean( + repairRuntimeConversationId && repairFixerKind, + ); useEffect(() => { if ( workspaceReviewContext?.monitor.status !== "reviewing" || @@ -919,17 +991,34 @@ export function useAgentsViewController({ ); }, [workspaceReviewChildConversationId, workspaceReviewContext?.monitor.status]); useEffect(() => { - const fixerStatus = workspaceReviewContext?.monitor.reviewFixerStatus ?? null; - if (fixerStatus !== "queued" && fixerStatus !== "running") { + if ( + !workspaceRepairFocusTarget || + (!reviewFixerIsActive && + !(repairAttemptIsActive && repairFixerKind === "workspace_repair")) + ) { return; } setChatFocus((current) => - current.type === "workspace_review" ? { type: "workspace" } : current, + current.type === "workspace_repair" && + current.conversationId === workspaceRepairFocusTarget.conversationId + ? current + : workspaceRepairFocusTarget, ); }, [ - workspaceReviewContext?.monitor.reviewFixerRunId, - workspaceReviewContext?.monitor.reviewFixerStatus, + repairAttemptIsActive, + repairFixerKind, + reviewFixerIsActive, + workspaceRepairFocusTarget, ]); + useEffect(() => { + if (!prFixerFocusTarget || !repairAttemptIsActive) return; + setChatFocus((current) => + current.type === "pr_fixer" && + current.conversationId === prFixerFocusTarget.conversationId + ? current + : prFixerFocusTarget, + ); + }, [prFixerFocusTarget, repairAttemptIsActive]); const chatFocusOptions = useMemo(() => { return getAgentChatFocusSwitchOptions({ mode: activeConversationMode, @@ -937,6 +1026,8 @@ export function useAgentsViewController({ verificationFocusTarget, taskRuntimeFocusTarget, workspaceReviewFocusTarget, + workspaceRepairFocusTarget, + prFixerFocusTarget, automationRunFocusTarget, hasPlanArtifact: hasAttachedPlanArtifact, }); @@ -948,6 +1039,8 @@ export function useAgentsViewController({ taskRuntimeFocusTarget, verificationFocusTarget, workspaceReviewFocusTarget, + workspaceRepairFocusTarget, + prFixerFocusTarget, ]); useEffect(() => { if (chatFocusOptions.some((option) => option.type === chatFocus.type)) { @@ -988,6 +1081,16 @@ export function useAgentsViewController({ return; } + if (type === "workspace_repair" && workspaceRepairFocusTarget) { + setChatFocus(workspaceRepairFocusTarget); + return; + } + + if (type === "pr_fixer" && prFixerFocusTarget) { + setChatFocus(prFixerFocusTarget); + return; + } + if (type === "automation_run" && automationRunFocusTarget) { setChatFocus(automationRunFocusTarget); } @@ -1001,6 +1104,8 @@ export function useAgentsViewController({ verificationFocusTarget, automationRunFocusTarget, workspaceReviewFocusTarget, + workspaceRepairFocusTarget, + prFixerFocusTarget, ], ); const { @@ -1708,6 +1813,8 @@ export function useAgentsViewController({ onFocusIdeationSessionForConversation: handleFocusIdeationSessionForConversation, onFocusWorkspaceReview: handleFocusWorkspaceReview, + onFocusWorkspaceRepair: handleFocusWorkspaceRepair, + onFocusPrFixer: handleFocusPrFixer, onFocusVerificationSession: handleFocusVerificationSession, onFocusTaskRuntime: handleFocusTaskRuntime, onFocusAutomationRun: handleFocusAutomationRun, diff --git a/src-tauri/crates/ralphx-domain/src/entities/agent_workspace_repair.rs b/src-tauri/crates/ralphx-domain/src/entities/agent_workspace_repair.rs index a4f6c7476a..265807c4f4 100644 --- a/src-tauri/crates/ralphx-domain/src/entities/agent_workspace_repair.rs +++ b/src-tauri/crates/ralphx-domain/src/entities/agent_workspace_repair.rs @@ -210,6 +210,9 @@ pub struct AgentWorkspaceRepairAttempt { pub phase: AgentWorkspaceRepairPhase, pub continuation: AgentWorkspaceRepairContinuation, pub reserved_agent_run_id: Option, + /// Dedicated child conversation hosting this attempt's fixer runs. + /// `None` means a legacy attempt whose runs live in `conversation_id` itself. + pub runtime_conversation_id: Option, pub target_base_ref: String, pub target_base_commit: Option, /// GitHub base tip targeted by a completed automatic update route for this attempt. @@ -280,6 +283,7 @@ impl AgentWorkspaceRepairAttempt { phase: AgentWorkspaceRepairPhase::Requested, continuation, reserved_agent_run_id: None, + runtime_conversation_id: None, target_base_ref: target_base_ref.into(), target_base_commit: None, base_update_target_commit: None, @@ -317,6 +321,15 @@ impl AgentWorkspaceRepairAttempt { self.settled_at.is_none() } + /// Conversation the fixer agent actually runs in. + /// + /// Falls back to the workspace conversation for legacy, parent-hosted attempts. + pub fn runtime_conversation_id(&self) -> &ChatConversationId { + self.runtime_conversation_id + .as_ref() + .unwrap_or(&self.conversation_id) + } + /// Local head that is not yet proven published, exactly as stored: the validated completion /// head first, else the backend-recorded base-update head. Blank values yield `None`. /// diff --git a/src-tauri/crates/ralphx-domain/src/repositories/agent_workspace_repair_repository.rs b/src-tauri/crates/ralphx-domain/src/repositories/agent_workspace_repair_repository.rs index 4be498dba3..476a4c290d 100644 --- a/src-tauri/crates/ralphx-domain/src/repositories/agent_workspace_repair_repository.rs +++ b/src-tauri/crates/ralphx-domain/src/repositories/agent_workspace_repair_repository.rs @@ -51,6 +51,9 @@ pub struct BindAgentWorkspaceRepairAttemptRun { /// schedule must not let an older dispatcher bind a run onto newer durable authority. pub expected_updated_at: DateTime, pub run_id: AgentRunId, + /// First reservation records the attempt's dedicated fixer child; redelivery must preserve + /// the existing value. + pub runtime_conversation_id: Option, pub updated_at: DateTime, } @@ -198,6 +201,18 @@ pub trait AgentWorkspaceRepairRepository: Send + Sync { conversation_id: &ChatConversationId, ) -> AppResult>; + /// Resolve the attempt whose dedicated runtime conversation is `runtime_conversation_id`. + /// + /// Returns `None` when no unsettled attempt owns that conversation. + /// + /// # Errors + /// Returns a repository error when the lookup fails. A failed lookup MUST NOT be reported as + /// `Ok(None)`; callers treat `Ok(None)` as "not authorized". + async fn get_unsettled_attempt_by_runtime_conversation( + &self, + runtime_conversation_id: &ChatConversationId, + ) -> AppResult>; + /// Returns the latest durable generation, including settled generations. Legacy import is /// allowed only when this returns `None` so stale projections can never revive an old flow. async fn get_latest_repair_attempt_for_conversation( diff --git a/src-tauri/src/application/agent_conversation_archive.rs b/src-tauri/src/application/agent_conversation_archive.rs index 12e8f8b6b5..c1e98c3ce3 100644 --- a/src-tauri/src/application/agent_conversation_archive.rs +++ b/src-tauri/src/application/agent_conversation_archive.rs @@ -101,6 +101,7 @@ pub async fn archive_agent_conversation_for_state( let chat_service: Arc = Arc::new(state.build_chat_service()); let outcome = terminalize_agent_workspace_after_pr( Arc::clone(&state.agent_conversation_workspace_repo), + Arc::clone(&state.agent_workspace_repair_repo), Arc::clone(&state.agent_run_repo), Some(Arc::clone(&state.plan_branch_repo)), Some(chat_service), @@ -163,6 +164,7 @@ pub async fn close_agent_workspace_pr_for_state( let chat_service: Arc = Arc::new(state.build_chat_service()); let outcome = terminalize_agent_workspace_after_pr( Arc::clone(&state.agent_conversation_workspace_repo), + Arc::clone(&state.agent_workspace_repair_repo), Arc::clone(&state.agent_run_repo), Some(Arc::clone(&state.plan_branch_repo)), Some(chat_service), diff --git a/src-tauri/src/application/agent_workspace_external_pr_reconciliation.rs b/src-tauri/src/application/agent_workspace_external_pr_reconciliation.rs index a1e392f0c3..a27111b7e2 100644 --- a/src-tauri/src/application/agent_workspace_external_pr_reconciliation.rs +++ b/src-tauri/src/application/agent_workspace_external_pr_reconciliation.rs @@ -366,6 +366,14 @@ pub(crate) async fn reconcile_agent_workspace_external_pr( } else { let terminalized = terminalize_agent_workspace_after_pr( Arc::clone(&deps.workspace_repo), + deps.agent_workspace_repair_repo + .as_ref() + .map(Arc::clone) + .ok_or_else(|| { + AppError::Infrastructure( + "terminal workspace cleanup requires durable repair authority".to_string(), + ) + })?, Arc::clone(&deps.agent_run_repo), Some(Arc::clone(&deps.plan_branch_repo)), deps.chat_service.as_ref().map(Arc::clone), @@ -456,6 +464,14 @@ async fn reconcile_linked_agent_workspace_pr( ); let terminalized = terminalize_agent_workspace_after_pr( Arc::clone(&deps.workspace_repo), + deps.agent_workspace_repair_repo + .as_ref() + .map(Arc::clone) + .ok_or_else(|| { + AppError::Infrastructure( + "terminal workspace cleanup requires durable repair authority".to_string(), + ) + })?, Arc::clone(&deps.agent_run_repo), Some(Arc::clone(&deps.plan_branch_repo)), deps.chat_service.as_ref().map(Arc::clone), @@ -516,6 +532,14 @@ async fn reconcile_linked_agent_workspace_pr( emit_workspace_changed(deps.events.as_ref(), &workspace.conversation_id); let terminalized = terminalize_agent_workspace_after_pr( Arc::clone(&deps.workspace_repo), + deps.agent_workspace_repair_repo + .as_ref() + .map(Arc::clone) + .ok_or_else(|| { + AppError::Infrastructure( + "terminal workspace cleanup requires durable repair authority".to_string(), + ) + })?, Arc::clone(&deps.agent_run_repo), Some(Arc::clone(&deps.plan_branch_repo)), deps.chat_service.as_ref().map(Arc::clone), diff --git a/src-tauri/src/application/agent_workspace_external_pr_reconciliation_tests.rs b/src-tauri/src/application/agent_workspace_external_pr_reconciliation_tests.rs index b4b85e2cc0..df6695a667 100644 --- a/src-tauri/src/application/agent_workspace_external_pr_reconciliation_tests.rs +++ b/src-tauri/src/application/agent_workspace_external_pr_reconciliation_tests.rs @@ -226,7 +226,7 @@ async fn deps_with_workspace( pr_poller_registry: None, chat_service: None, agent_run_repo: Arc::new(MemoryAgentRunRepository::new()), - agent_workspace_repair_repo: None, + agent_workspace_repair_repo: Some(workspace_repo.clone()), plan_branch_repo: Arc::new(MemoryPlanBranchRepository::new()), events: Arc::new(NullEventSink), durable_recovery_state: None, @@ -361,6 +361,7 @@ async fn live_reconciliation_fails_closed_without_a_durable_repair_repository() let chat = Arc::new(MockChatService::new()); deps.pr_poller_registry = Some(Arc::clone(®istry)); deps.chat_service = Some(chat.clone() as Arc); + deps.agent_workspace_repair_repo = None; let error = reconcile_agent_workspace_external_pr( deps, diff --git a/src-tauri/src/application/agent_workspace_fixer_conversation.rs b/src-tauri/src/application/agent_workspace_fixer_conversation.rs new file mode 100644 index 0000000000..9cf478ffbd --- /dev/null +++ b/src-tauri/src/application/agent_workspace_fixer_conversation.rs @@ -0,0 +1,175 @@ +use crate::application::AppState; +use crate::domain::entities::{ + AgentConversationWorkspace, AgentWorkspaceRepairSource, ChatConversation, ChatConversationId, +}; +use crate::domain::repositories::{ + AgentConversationWorkspaceRepository, AgentWorkspaceRepairRepository, + ChatConversationRepository, +}; +use crate::error::AppResult; +use crate::infrastructure::agents::claude::agent_names::{ + AGENT_WORKSPACE_PR_FIXER, AGENT_WORKSPACE_REPAIR, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentWorkspaceFixerKind { + WorkspaceRepair, + PrFixer, +} + +impl AgentWorkspaceFixerKind { + pub fn agent_name(self) -> &'static str { + match self { + Self::WorkspaceRepair => AGENT_WORKSPACE_REPAIR, + Self::PrFixer => AGENT_WORKSPACE_PR_FIXER, + } + } + + pub fn launch_role(self) -> &'static str { + match self { + Self::WorkspaceRepair => "workspace_repair", + Self::PrFixer => "pr_fixer", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentWorkspaceFixerTitleContext { + Repair(AgentWorkspaceRepairSource), + ReviewBlocking, + PullRequest(Option), +} + +pub async fn ensure_agent_workspace_fixer_conversation( + state: &AppState, + workspace: &AgentConversationWorkspace, + existing: Option<&ChatConversationId>, + kind: AgentWorkspaceFixerKind, + title_context: AgentWorkspaceFixerTitleContext, +) -> AppResult { + ensure_agent_workspace_fixer_conversation_with_repo( + state.chat_conversation_repo.as_ref(), + workspace, + existing, + kind, + title_context, + ) + .await +} + +pub(crate) async fn ensure_agent_workspace_fixer_conversation_with_repo( + conversation_repo: &dyn ChatConversationRepository, + workspace: &AgentConversationWorkspace, + existing: Option<&ChatConversationId>, + kind: AgentWorkspaceFixerKind, + title_context: AgentWorkspaceFixerTitleContext, +) -> AppResult { + if let Some(existing) = existing { + return Ok(*existing); + } + + create_agent_workspace_fixer_conversation_with_repo( + conversation_repo, + workspace, + kind, + title_context, + ) + .await +} + +/// Conversations that can host an active workspace-linked fixer. The parent remains in the set +/// for legacy attempts; durable repair and Review-fixer children are added from recorded linkage. +pub(crate) async fn agent_workspace_fixer_runtime_conversations( + workspace: &AgentConversationWorkspace, + workspace_repo: &dyn AgentConversationWorkspaceRepository, + repair_repo: &dyn AgentWorkspaceRepairRepository, +) -> AppResult> { + let mut conversations = vec![workspace.conversation_id]; + if let Some(attempt) = repair_repo + .get_current_repair_attempt(&workspace.conversation_id) + .await? + { + let runtime_conversation_id = *attempt.runtime_conversation_id(); + if !conversations.contains(&runtime_conversation_id) { + conversations.push(runtime_conversation_id); + } + } + if let Some(monitor) = workspace_repo + .get_workspace_review_monitor(&workspace.conversation_id) + .await? + { + if let Some(runtime_conversation_id) = monitor.review_fixer_conversation_id { + if !conversations.contains(&runtime_conversation_id) { + conversations.push(runtime_conversation_id); + } + } + } + Ok(conversations) +} + +pub async fn create_agent_workspace_fixer_conversation( + state: &AppState, + workspace: &AgentConversationWorkspace, + kind: AgentWorkspaceFixerKind, + title_context: AgentWorkspaceFixerTitleContext, +) -> AppResult { + create_agent_workspace_fixer_conversation_with_repo( + state.chat_conversation_repo.as_ref(), + workspace, + kind, + title_context, + ) + .await +} + +async fn create_agent_workspace_fixer_conversation_with_repo( + conversation_repo: &dyn ChatConversationRepository, + workspace: &AgentConversationWorkspace, + kind: AgentWorkspaceFixerKind, + title_context: AgentWorkspaceFixerTitleContext, +) -> AppResult { + let mut conversation = ChatConversation::new_project(workspace.project_id.clone()); + conversation.parent_conversation_id = Some(workspace.conversation_id.as_str()); + conversation.title = Some(agent_workspace_fixer_conversation_title( + kind, + title_context, + )); + let conversation = conversation_repo.create(conversation).await?; + Ok(conversation.id) +} + +fn agent_workspace_fixer_conversation_title( + kind: AgentWorkspaceFixerKind, + title_context: AgentWorkspaceFixerTitleContext, +) -> String { + match (kind, title_context) { + ( + AgentWorkspaceFixerKind::PrFixer, + AgentWorkspaceFixerTitleContext::PullRequest(Some(number)), + ) => { + format!("Fix PR #{number}") + } + (AgentWorkspaceFixerKind::PrFixer, _) => "Fix PR checks".to_string(), + (_, AgentWorkspaceFixerTitleContext::ReviewBlocking) => "Fix review findings".to_string(), + (_, AgentWorkspaceFixerTitleContext::Repair(AgentWorkspaceRepairSource::BaseUpdate)) => { + "Fix base update".to_string() + } + (_, AgentWorkspaceFixerTitleContext::Repair(AgentWorkspaceRepairSource::Publish)) => { + "Fix publish".to_string() + } + (_, AgentWorkspaceFixerTitleContext::Repair(AgentWorkspaceRepairSource::PrConflict)) => { + "Fix PR conflict".to_string() + } + (_, AgentWorkspaceFixerTitleContext::Repair(_)) => "Fix workspace".to_string(), + ( + AgentWorkspaceFixerKind::WorkspaceRepair, + AgentWorkspaceFixerTitleContext::PullRequest(Some(number)), + ) => { + format!("Fix PR #{number}") + } + ( + AgentWorkspaceFixerKind::WorkspaceRepair, + AgentWorkspaceFixerTitleContext::PullRequest(None), + ) => "Fix workspace".to_string(), + } +} diff --git a/src-tauri/src/application/agent_workspace_fixer_conversation_tests.rs b/src-tauri/src/application/agent_workspace_fixer_conversation_tests.rs new file mode 100644 index 0000000000..350e6147f3 --- /dev/null +++ b/src-tauri/src/application/agent_workspace_fixer_conversation_tests.rs @@ -0,0 +1,283 @@ +use crate::application::agent_workspace_fixer_conversation::{ + ensure_agent_workspace_fixer_conversation_with_repo, AgentWorkspaceFixerKind, + AgentWorkspaceFixerTitleContext, +}; +use crate::domain::entities::{ + AgentConversationWorkspace, AgentConversationWorkspaceMode, AgentWorkspaceRepairSource, + ChatConversationId, IdeationAnalysisBaseRefKind, ProjectId, +}; +use crate::domain::repositories::ChatConversationRepository; +use crate::infrastructure::memory::MemoryChatConversationRepository; + +fn test_workspace() -> AgentConversationWorkspace { + AgentConversationWorkspace::new( + ChatConversationId::from_string("conv-fixer-test"), + ProjectId::from_string("proj-fixer-test".to_string()), + AgentConversationWorkspaceMode::Edit, + IdeationAnalysisBaseRefKind::ProjectDefault, + "main".to_string(), + Some("main".to_string()), + None, + "ralphx/fixer-test".to_string(), + "/tmp/fixer-test".to_string(), + ) +} + +#[test] +fn fixer_kind_agent_name_workspace_repair() { + assert_eq!( + AgentWorkspaceFixerKind::WorkspaceRepair.agent_name(), + "ralphx:ralphx-agent-workspace-repair" + ); +} + +#[test] +fn fixer_kind_agent_name_pr_fixer() { + assert_eq!( + AgentWorkspaceFixerKind::PrFixer.agent_name(), + "ralphx:ralphx-agent-workspace-pr-fixer" + ); +} + +#[test] +fn fixer_kind_launch_role_workspace_repair() { + assert_eq!( + AgentWorkspaceFixerKind::WorkspaceRepair.launch_role(), + "workspace_repair" + ); +} + +#[test] +fn fixer_kind_launch_role_pr_fixer() { + assert_eq!(AgentWorkspaceFixerKind::PrFixer.launch_role(), "pr_fixer"); +} + +#[tokio::test] +async fn ensure_returns_existing_conversation_id() { + let repo = MemoryChatConversationRepository::new(); + let workspace = test_workspace(); + let existing = ChatConversationId::from_string("existing-conv"); + + let result = ensure_agent_workspace_fixer_conversation_with_repo( + &repo, + &workspace, + Some(&existing), + AgentWorkspaceFixerKind::WorkspaceRepair, + AgentWorkspaceFixerTitleContext::ReviewBlocking, + ) + .await + .unwrap(); + + assert_eq!(result, existing); +} + +#[tokio::test] +async fn ensure_creates_new_conversation_when_none_exists() { + let repo = MemoryChatConversationRepository::new(); + let workspace = test_workspace(); + + let result = ensure_agent_workspace_fixer_conversation_with_repo( + &repo, + &workspace, + None, + AgentWorkspaceFixerKind::WorkspaceRepair, + AgentWorkspaceFixerTitleContext::ReviewBlocking, + ) + .await + .unwrap(); + + assert_ne!(result.as_str(), ""); +} + +#[tokio::test] +async fn create_sets_parent_conversation_id() { + let repo = MemoryChatConversationRepository::new(); + let workspace = test_workspace(); + + let conv_id = ensure_agent_workspace_fixer_conversation_with_repo( + &repo, + &workspace, + None, + AgentWorkspaceFixerKind::PrFixer, + AgentWorkspaceFixerTitleContext::PullRequest(Some(42)), + ) + .await + .unwrap(); + + let conv = repo.get_by_id(&conv_id).await.unwrap().unwrap(); + assert_eq!( + conv.parent_conversation_id, + Some(workspace.conversation_id.as_str()) + ); +} + +#[tokio::test] +async fn title_pr_fixer_with_number() { + let repo = MemoryChatConversationRepository::new(); + let workspace = test_workspace(); + + let conv_id = ensure_agent_workspace_fixer_conversation_with_repo( + &repo, + &workspace, + None, + AgentWorkspaceFixerKind::PrFixer, + AgentWorkspaceFixerTitleContext::PullRequest(Some(123)), + ) + .await + .unwrap(); + + let conv = repo.get_by_id(&conv_id).await.unwrap().unwrap(); + assert_eq!(conv.title, Some("Fix PR #123".to_string())); +} + +#[tokio::test] +async fn title_pr_fixer_without_number() { + let repo = MemoryChatConversationRepository::new(); + let workspace = test_workspace(); + + let conv_id = ensure_agent_workspace_fixer_conversation_with_repo( + &repo, + &workspace, + None, + AgentWorkspaceFixerKind::PrFixer, + AgentWorkspaceFixerTitleContext::ReviewBlocking, + ) + .await + .unwrap(); + + let conv = repo.get_by_id(&conv_id).await.unwrap().unwrap(); + assert_eq!(conv.title, Some("Fix PR checks".to_string())); +} + +#[tokio::test] +async fn title_review_blocking() { + let repo = MemoryChatConversationRepository::new(); + let workspace = test_workspace(); + + let conv_id = ensure_agent_workspace_fixer_conversation_with_repo( + &repo, + &workspace, + None, + AgentWorkspaceFixerKind::WorkspaceRepair, + AgentWorkspaceFixerTitleContext::ReviewBlocking, + ) + .await + .unwrap(); + + let conv = repo.get_by_id(&conv_id).await.unwrap().unwrap(); + assert_eq!(conv.title, Some("Fix review findings".to_string())); +} + +#[tokio::test] +async fn title_repair_base_update() { + let repo = MemoryChatConversationRepository::new(); + let workspace = test_workspace(); + + let conv_id = ensure_agent_workspace_fixer_conversation_with_repo( + &repo, + &workspace, + None, + AgentWorkspaceFixerKind::WorkspaceRepair, + AgentWorkspaceFixerTitleContext::Repair(AgentWorkspaceRepairSource::BaseUpdate), + ) + .await + .unwrap(); + + let conv = repo.get_by_id(&conv_id).await.unwrap().unwrap(); + assert_eq!(conv.title, Some("Fix base update".to_string())); +} + +#[tokio::test] +async fn title_repair_publish() { + let repo = MemoryChatConversationRepository::new(); + let workspace = test_workspace(); + + let conv_id = ensure_agent_workspace_fixer_conversation_with_repo( + &repo, + &workspace, + None, + AgentWorkspaceFixerKind::WorkspaceRepair, + AgentWorkspaceFixerTitleContext::Repair(AgentWorkspaceRepairSource::Publish), + ) + .await + .unwrap(); + + let conv = repo.get_by_id(&conv_id).await.unwrap().unwrap(); + assert_eq!(conv.title, Some("Fix publish".to_string())); +} + +#[tokio::test] +async fn title_repair_pr_conflict() { + let repo = MemoryChatConversationRepository::new(); + let workspace = test_workspace(); + + let conv_id = ensure_agent_workspace_fixer_conversation_with_repo( + &repo, + &workspace, + None, + AgentWorkspaceFixerKind::WorkspaceRepair, + AgentWorkspaceFixerTitleContext::Repair(AgentWorkspaceRepairSource::PrConflict), + ) + .await + .unwrap(); + + let conv = repo.get_by_id(&conv_id).await.unwrap().unwrap(); + assert_eq!(conv.title, Some("Fix PR conflict".to_string())); +} + +#[tokio::test] +async fn title_repair_fallback() { + let repo = MemoryChatConversationRepository::new(); + let workspace = test_workspace(); + + let conv_id = ensure_agent_workspace_fixer_conversation_with_repo( + &repo, + &workspace, + None, + AgentWorkspaceFixerKind::WorkspaceRepair, + AgentWorkspaceFixerTitleContext::Repair(AgentWorkspaceRepairSource::Legacy), + ) + .await + .unwrap(); + + let conv = repo.get_by_id(&conv_id).await.unwrap().unwrap(); + assert_eq!(conv.title, Some("Fix workspace".to_string())); +} + +#[tokio::test] +async fn title_workspace_repair_pull_request_with_number() { + let repo = MemoryChatConversationRepository::new(); + let workspace = test_workspace(); + + let conv_id = ensure_agent_workspace_fixer_conversation_with_repo( + &repo, + &workspace, + None, + AgentWorkspaceFixerKind::WorkspaceRepair, + AgentWorkspaceFixerTitleContext::PullRequest(Some(99)), + ) + .await + .unwrap(); + + let conv = repo.get_by_id(&conv_id).await.unwrap().unwrap(); + assert_eq!(conv.title, Some("Fix PR #99".to_string())); +} + +#[tokio::test] +async fn title_workspace_repair_pull_request_none() { + let repo = MemoryChatConversationRepository::new(); + let workspace = test_workspace(); + + let conv_id = ensure_agent_workspace_fixer_conversation_with_repo( + &repo, + &workspace, + None, + AgentWorkspaceFixerKind::WorkspaceRepair, + AgentWorkspaceFixerTitleContext::PullRequest(None), + ) + .await + .unwrap(); + + let conv = repo.get_by_id(&conv_id).await.unwrap().unwrap(); + assert_eq!(conv.title, Some("Fix workspace".to_string())); +} diff --git a/src-tauri/src/application/agent_workspace_pr_supervision_recovery.rs b/src-tauri/src/application/agent_workspace_pr_supervision_recovery.rs index 350bfd7c6d..1e47618bbc 100644 --- a/src-tauri/src/application/agent_workspace_pr_supervision_recovery.rs +++ b/src-tauri/src/application/agent_workspace_pr_supervision_recovery.rs @@ -42,8 +42,8 @@ use crate::domain::entities::{ ProjectId, }; use crate::domain::repositories::{ - AgentConversationWorkspaceRepository, AgentRunRepository, PlanBranchRepository, - ProjectRepository, + AgentConversationWorkspaceRepository, AgentRunRepository, AgentWorkspaceRepairRepository, + PlanBranchRepository, ProjectRepository, }; use crate::domain::services::{GithubServiceTrait, PrStatus as GithubPrStatus, PrSyncState}; use crate::error::{AppError, AppResult}; @@ -119,6 +119,9 @@ pub(crate) struct AgentWorkspacePrSupervisionRecoveryDeps { pub transition_service: Option>, pub chat_service: Option>, pub agent_run_repo: Arc, + /// Canonical durable repair authority. Production supplies the same repository owned by + /// `durable_recovery_state`; focused compatibility tests supply it without a full state. + pub agent_workspace_repair_repo: Arc, pub events: Arc, pub pr_fix_review_publish_resumer: Option>, /// Production recovery reuses AppState so one canonical durable repair reconciler owns @@ -163,6 +166,7 @@ pub(crate) fn build_agent_workspace_pr_supervision_recovery_deps( transition_service, chat_service, agent_run_repo: Arc::clone(&state.agent_run_repo), + agent_workspace_repair_repo: Arc::clone(&state.agent_workspace_repair_repo), events: Arc::clone(&state.events), pr_fix_review_publish_resumer, durable_recovery_state: Some(Arc::new(state.clone())), @@ -376,6 +380,7 @@ pub(crate) async fn recover_agent_workspace_pr_supervision( let (recovered_workspace, repair_outcome) = recover_stale_publish_repair_for_workspace_with_project_repo_outcome( Arc::clone(&deps.workspace_repo), + Arc::clone(&deps.agent_workspace_repair_repo), Arc::clone(&deps.agent_run_repo), Arc::clone(&deps.project_repo), workspace, @@ -518,6 +523,7 @@ pub(crate) async fn recover_agent_workspace_pr_supervision( emit_workspace_changed(deps.events.as_ref(), &conversation_id); let terminalized = terminalize_agent_workspace_after_pr( Arc::clone(&deps.workspace_repo), + Arc::clone(&deps.agent_workspace_repair_repo), Arc::clone(&deps.agent_run_repo), Some(Arc::clone(&deps.plan_branch_repo)), deps.chat_service.as_ref().map(Arc::clone), @@ -580,6 +586,7 @@ pub(crate) async fn recover_agent_workspace_pr_supervision( emit_workspace_changed(deps.events.as_ref(), &conversation_id); let terminalized = terminalize_agent_workspace_after_pr( Arc::clone(&deps.workspace_repo), + Arc::clone(&deps.agent_workspace_repair_repo), Arc::clone(&deps.agent_run_repo), Some(Arc::clone(&deps.plan_branch_repo)), deps.chat_service.as_ref().map(Arc::clone), diff --git a/src-tauri/src/application/agent_workspace_pr_supervision_recovery_tests.rs b/src-tauri/src/application/agent_workspace_pr_supervision_recovery_tests.rs index aae2520b9d..8f4af34899 100644 --- a/src-tauri/src/application/agent_workspace_pr_supervision_recovery_tests.rs +++ b/src-tauri/src/application/agent_workspace_pr_supervision_recovery_tests.rs @@ -150,7 +150,8 @@ fn recovery_deps( agent_run_repo: Arc, ) -> AgentWorkspacePrSupervisionRecoveryDeps { AgentWorkspacePrSupervisionRecoveryDeps { - workspace_repo: workspace_repo as Arc, + workspace_repo: Arc::clone(&workspace_repo) + as Arc, project_repo, plan_branch_repo: Arc::new(MemoryPlanBranchRepository::new()) as Arc, @@ -159,6 +160,7 @@ fn recovery_deps( transition_service: None, chat_service: None, agent_run_repo, + agent_workspace_repair_repo: workspace_repo, events: Arc::new(ralphx_events::NullEventSink), pr_fix_review_publish_resumer: None, durable_recovery_state: None, @@ -710,6 +712,7 @@ async fn startup_recovery_exempts_unsettled_repair_workspaces_from_the_capped_pu transition_service: None, chat_service: None, agent_run_repo: Arc::clone(&state.agent_run_repo), + agent_workspace_repair_repo: Arc::clone(&state.agent_workspace_repair_repo), events: Arc::new(ralphx_events::NullEventSink), pr_fix_review_publish_resumer: None, durable_recovery_state: Some(Arc::new(state.clone())), @@ -781,6 +784,7 @@ async fn recovers_blocked_pr_supervision_when_remote_head_matches_local_workspac transition_service: None, chat_service: Some(Arc::new(MockChatService::new())), agent_run_repo: Arc::new(MemoryAgentRunRepository::new()), + agent_workspace_repair_repo: workspace_repo.clone(), events: Arc::new(ralphx_events::NullEventSink), pr_fix_review_publish_resumer: None, durable_recovery_state: None, @@ -859,6 +863,7 @@ async fn matching_remote_head_with_failing_health_stays_blocked_and_never_report transition_service: None, chat_service: Some(Arc::new(MockChatService::new())), agent_run_repo: Arc::new(MemoryAgentRunRepository::new()), + agent_workspace_repair_repo: workspace_repo.clone(), events: Arc::new(ralphx_events::NullEventSink), pr_fix_review_publish_resumer: None, durable_recovery_state: None, @@ -960,6 +965,7 @@ async fn recovers_linked_plan_pr_supervision_without_workspace_publication_pr() transition_service: None, chat_service: None, agent_run_repo: Arc::new(MemoryAgentRunRepository::new()), + agent_workspace_repair_repo: workspace_repo.clone(), events: Arc::new(ralphx_events::NullEventSink), pr_fix_review_publish_resumer: None, durable_recovery_state: None, @@ -1059,6 +1065,7 @@ async fn marks_terminal_linked_plan_pr_status_and_workspace_authority() { transition_service: None, chat_service: None, agent_run_repo: Arc::new(MemoryAgentRunRepository::new()), + agent_workspace_repair_repo: workspace_repo.clone(), events: Arc::new(ralphx_events::NullEventSink), pr_fix_review_publish_resumer: None, durable_recovery_state: None, @@ -1148,7 +1155,8 @@ async fn skips_linked_plan_pr_supervision_when_plan_branch_is_not_current() { let outcome = recover_agent_workspace_pr_supervision( AgentWorkspacePrSupervisionRecoveryDeps { - workspace_repo: workspace_repo as Arc, + workspace_repo: Arc::clone(&workspace_repo) + as Arc, project_repo: Arc::new(MemoryProjectRepository::with_projects(vec![project])), plan_branch_repo: plan_branch_repo as Arc, github: Arc::clone(&github) as Arc, @@ -1156,6 +1164,7 @@ async fn skips_linked_plan_pr_supervision_when_plan_branch_is_not_current() { transition_service: None, chat_service: None, agent_run_repo: Arc::new(MemoryAgentRunRepository::new()), + agent_workspace_repair_repo: workspace_repo, events: Arc::new(ralphx_events::NullEventSink), pr_fix_review_publish_resumer: None, durable_recovery_state: None, @@ -1712,6 +1721,7 @@ async fn retry_eligible_stale_pr_autofix_settlement_restarts_pr_polling() { transition_service: None, chat_service: Some(Arc::new(MockChatService::new())), agent_run_repo, + agent_workspace_repair_repo: workspace_repo.clone(), events: Arc::new(ralphx_events::NullEventSink), pr_fix_review_publish_resumer: None, durable_recovery_state: None, @@ -1913,6 +1923,7 @@ async fn exhausted_stale_pr_autofix_stays_blocked_without_pr_polling() { transition_service: None, chat_service: Some(Arc::new(MockChatService::new())), agent_run_repo, + agent_workspace_repair_repo: workspace_repo.clone(), events: Arc::new(ralphx_events::NullEventSink), pr_fix_review_publish_resumer: None, durable_recovery_state: None, @@ -2124,6 +2135,7 @@ async fn skips_blocked_pr_supervision_recovery_when_worktree_is_dirty() { transition_service: None, chat_service: None, agent_run_repo: Arc::new(MemoryAgentRunRepository::new()), + agent_workspace_repair_repo: workspace_repo.clone(), events: Arc::new(ralphx_events::NullEventSink), pr_fix_review_publish_resumer: None, durable_recovery_state: None, @@ -2515,6 +2527,7 @@ async fn active_run_does_not_hide_terminal_linked_plan_pr_during_supervision_rec Arc::clone(&chat) as Arc ), agent_run_repo: Arc::clone(&active_run_repo) as Arc, + agent_workspace_repair_repo: workspace_repo.clone(), events: Arc::new(ralphx_events::NullEventSink), pr_fix_review_publish_resumer: None, durable_recovery_state: None, @@ -2799,6 +2812,7 @@ async fn startup_recovery_resumes_passed_pr_fix_workspace_review_handoff() { transition_service: None, chat_service: None, agent_run_repo, + agent_workspace_repair_repo: workspace_repo.clone(), events: Arc::new(ralphx_events::NullEventSink), pr_fix_review_publish_resumer: Some( Arc::clone(&publish_resumer) as Arc @@ -2913,6 +2927,7 @@ async fn startup_recovery_does_not_publish_pr_fix_from_stale_review_fingerprint( transition_service: None, chat_service: None, agent_run_repo, + agent_workspace_repair_repo: workspace_repo.clone(), events: Arc::new(ralphx_events::NullEventSink), pr_fix_review_publish_resumer: Some( Arc::clone(&publish_resumer) as Arc @@ -2990,6 +3005,7 @@ async fn startup_recovery_processes_linked_plan_pr_supervision_candidates() { transition_service: None, chat_service: None, agent_run_repo: Arc::new(MemoryAgentRunRepository::new()), + agent_workspace_repair_repo: workspace_repo.clone(), events: Arc::new(ralphx_events::NullEventSink), pr_fix_review_publish_resumer: None, durable_recovery_state: None, diff --git a/src-tauri/src/application/agent_workspace_publish_recovery.rs b/src-tauri/src/application/agent_workspace_publish_recovery.rs index b7c9fedfa2..aab6301f29 100644 --- a/src-tauri/src/application/agent_workspace_publish_recovery.rs +++ b/src-tauri/src/application/agent_workspace_publish_recovery.rs @@ -2,6 +2,8 @@ use std::sync::Arc; use ralphx_events::emit_serialized; +#[cfg(any(test, feature = "test-utils"))] +use crate::application::agent_workspace_fixer_conversation::agent_workspace_fixer_runtime_conversations; #[cfg(any(test, feature = "test-utils"))] use crate::application::agent_workspace_pr_autofix_attempt::{ load_latest_exact_pr_autofix_run_for_pr, load_pr_autofix_attempt_decision, @@ -33,7 +35,9 @@ use crate::domain::repositories::{ AgentWorkspacePublicationUpdate, }; #[cfg(any(test, feature = "test-utils"))] -use crate::domain::repositories::{AgentRunRepository, ProjectRepository}; +use crate::domain::repositories::{ + AgentRunRepository, AgentWorkspaceRepairRepository, ProjectRepository, +}; #[cfg(any(test, feature = "test-utils"))] use crate::error::AppError; use crate::error::AppResult; @@ -117,9 +121,12 @@ impl StalePublishRepairRecoveryOutcome { #[cfg(any(test, feature = "test-utils"))] pub async fn recover_stale_agent_workspace_publish_repairs_on_startup( workspace_repo: Arc, + repair_repo: Arc, agent_run_repo: Arc, ) { - match recover_stale_agent_workspace_publish_repairs(workspace_repo, agent_run_repo).await { + match recover_stale_agent_workspace_publish_repairs(workspace_repo, repair_repo, agent_run_repo) + .await + { Ok(count) if count > 0 => { tracing::info!( count, @@ -157,6 +164,7 @@ pub async fn recover_stale_agent_workspace_publish_repairs_on_startup_for_state( #[cfg(any(test, feature = "test-utils"))] pub async fn recover_stale_agent_workspace_publish_repairs( workspace_repo: Arc, + repair_repo: Arc, agent_run_repo: Arc, ) -> AppResult { let workspaces = workspace_repo.list_active_needs_agent_workspaces().await?; @@ -165,6 +173,7 @@ pub async fn recover_stale_agent_workspace_publish_repairs( for workspace in workspaces { if recover_stale_publish_repair_for_workspace( Arc::clone(&workspace_repo), + Arc::clone(&repair_repo), Arc::clone(&agent_run_repo), workspace, ) @@ -206,15 +215,38 @@ pub async fn recover_stale_publish_repair_for_workspace_in_state( .map(|(workspace, _)| workspace) } +#[cfg(any(test, feature = "test-utils"))] +async fn has_active_agent_workspace_runtime( + workspace_repo: &dyn AgentConversationWorkspaceRepository, + repair_repo: &dyn AgentWorkspaceRepairRepository, + agent_run_repo: &dyn AgentRunRepository, + workspace: &AgentConversationWorkspace, +) -> AppResult { + for runtime_conversation_id in + agent_workspace_fixer_runtime_conversations(workspace, workspace_repo, repair_repo).await? + { + if agent_run_repo + .get_active_for_conversation(&runtime_conversation_id) + .await? + .is_some() + { + return Ok(true); + } + } + Ok(false) +} + #[cfg(test)] pub(crate) async fn recover_stale_publish_repair_for_workspace_with_project_repo( workspace_repo: Arc, + repair_repo: Arc, agent_run_repo: Arc, project_repo: Arc, workspace: AgentConversationWorkspace, ) -> AppResult<(AgentConversationWorkspace, bool)> { recover_stale_publish_repair_for_workspace_with_project_repo_outcome( workspace_repo, + repair_repo, agent_run_repo, project_repo, workspace, @@ -226,6 +258,7 @@ pub(crate) async fn recover_stale_publish_repair_for_workspace_with_project_repo #[cfg(any(test, feature = "test-utils"))] pub(crate) async fn recover_stale_publish_repair_for_workspace_with_project_repo_outcome( workspace_repo: Arc, + repair_repo: Arc, agent_run_repo: Arc, project_repo: Arc, workspace: AgentConversationWorkspace, @@ -233,10 +266,13 @@ pub(crate) async fn recover_stale_publish_repair_for_workspace_with_project_repo AgentConversationWorkspace, StalePublishRepairRecoveryOutcome, )> { - if agent_run_repo - .get_active_for_conversation(&workspace.conversation_id) - .await? - .is_none() + if !has_active_agent_workspace_runtime( + workspace_repo.as_ref(), + repair_repo.as_ref(), + agent_run_repo.as_ref(), + &workspace, + ) + .await? { let publication_events = workspace_repo .list_publication_events(&workspace.conversation_id) @@ -300,6 +336,7 @@ pub(crate) async fn recover_stale_publish_repair_for_workspace_with_project_repo } recover_stale_publish_repair_for_workspace_and_reload_with_review_target( workspace_repo, + repair_repo, agent_run_repo, workspace, None, @@ -342,12 +379,14 @@ async fn abort_invalid_pr_fix_review_handoff( #[cfg(any(test, feature = "test-utils"))] pub async fn recover_stale_publish_repair_for_workspace_and_reload( workspace_repo: Arc, + repair_repo: Arc, agent_run_repo: Arc, workspace: AgentConversationWorkspace, ) -> AppResult { let (workspace, _outcome) = recover_stale_publish_repair_for_workspace_and_reload_with_review_target( workspace_repo, + repair_repo, agent_run_repo, workspace, None, @@ -359,6 +398,7 @@ pub async fn recover_stale_publish_repair_for_workspace_and_reload( #[cfg(any(test, feature = "test-utils"))] pub(crate) async fn recover_stale_publish_repair_for_workspace_and_reload_with_review_target( workspace_repo: Arc, + repair_repo: Arc, agent_run_repo: Arc, workspace: AgentConversationWorkspace, current_review_target: Option<&AgentWorkspaceReviewTarget>, @@ -369,6 +409,7 @@ pub(crate) async fn recover_stale_publish_repair_for_workspace_and_reload_with_r let conversation_id = workspace.conversation_id; let recovered = recover_stale_publish_repair_for_workspace_with_review_target( Arc::clone(&workspace_repo), + repair_repo, agent_run_repo, workspace.clone(), current_review_target, @@ -407,11 +448,13 @@ async fn current_pr_fix_review_handoff_target( #[cfg(any(test, feature = "test-utils"))] pub async fn recover_stale_publish_repair_for_workspace( workspace_repo: Arc, + repair_repo: Arc, agent_run_repo: Arc, workspace: AgentConversationWorkspace, ) -> AppResult { recover_stale_publish_repair_for_workspace_with_review_target( workspace_repo, + repair_repo, agent_run_repo, workspace, None, @@ -423,6 +466,7 @@ pub async fn recover_stale_publish_repair_for_workspace( #[cfg(any(test, feature = "test-utils"))] async fn recover_stale_publish_repair_for_workspace_with_review_target( workspace_repo: Arc, + repair_repo: Arc, agent_run_repo: Arc, mut workspace: AgentConversationWorkspace, current_review_target: Option<&AgentWorkspaceReviewTarget>, @@ -487,12 +531,16 @@ async fn recover_stale_publish_repair_for_workspace_with_review_target( } return Ok(StalePublishRepairRecoveryOutcome::ActiveReplacement); } - if agent_run_repo - .get_active_for_conversation(&workspace.conversation_id) - .await? - .is_some() + if has_active_agent_workspace_runtime( + workspace_repo.as_ref(), + repair_repo.as_ref(), + agent_run_repo.as_ref(), + &workspace, + ) + .await? && reconcile_active_agent_workspace_repair( Arc::clone(&workspace_repo), + Arc::clone(&repair_repo), Arc::clone(&agent_run_repo), &workspace, ) diff --git a/src-tauri/src/application/agent_workspace_publish_recovery/durable_attempt_recovery.rs b/src-tauri/src/application/agent_workspace_publish_recovery/durable_attempt_recovery.rs index 17b87bf02d..e56698560c 100644 --- a/src-tauri/src/application/agent_workspace_publish_recovery/durable_attempt_recovery.rs +++ b/src-tauri/src/application/agent_workspace_publish_recovery/durable_attempt_recovery.rs @@ -12,6 +12,10 @@ use super::StalePublishRepairRecoveryOutcome; use crate::application::agent_conversation_workspace::{ classify_effective_agent_conversation_workspace_path, WorkspacePathResolution, }; +use crate::application::agent_workspace_fixer_conversation::{ + ensure_agent_workspace_fixer_conversation, AgentWorkspaceFixerKind, + AgentWorkspaceFixerTitleContext, +}; use crate::application::agent_workspace_pr_autofix_attempt::load_latest_exact_pr_autofix_run_for_pr; use crate::application::agent_workspace_publish_repair_state::{ agent_workspace_repair_dispatch_is_due, agent_workspace_repair_hold_reason, @@ -151,6 +155,7 @@ pub(crate) async fn recover_stale_publish_repair_for_workspace_in_state_result( if is_legacy_pr_fix_review_projection(&workspace) { return super::recover_stale_publish_repair_for_workspace_with_project_repo_outcome( Arc::clone(&state.agent_conversation_workspace_repo), + Arc::clone(&state.agent_workspace_repair_repo), Arc::clone(&state.agent_run_repo), Arc::clone(&state.project_repo), workspace, @@ -330,7 +335,8 @@ async fn reconcile_agent_workspace_repair_attempt( .get_by_id(run_id) .await? .is_some_and(|run| { - run.conversation_id == current.conversation_id && run.status.is_active() + run.conversation_id == *current.runtime_conversation_id() + && run.status.is_active() }), None => false, }; @@ -372,7 +378,8 @@ async fn reconcile_agent_workspace_repair_attempt( .get_by_id(run_id) .await? .is_some_and(|run| { - run.conversation_id == current.conversation_id && run.status.is_active() + run.conversation_id == *current.runtime_conversation_id() + && run.status.is_active() }), None => false, }; @@ -1740,12 +1747,31 @@ pub(super) async fn reserve_and_deliver_repair_dispatch( // Dispatch evidence must exist before the reservation persists the attempt: the reservation is // the only write on this lane that carries `pr_autofix_dispatch_head_commit`. backfill_pr_autofix_dispatch_head(&mut attempt, &workspace, &working_directory).await; + let kind = if attempt.source == AgentWorkspaceRepairSource::PrAutofix { + AgentWorkspaceFixerKind::PrFixer + } else { + AgentWorkspaceFixerKind::WorkspaceRepair + }; + let title_context = if kind == AgentWorkspaceFixerKind::PrFixer { + AgentWorkspaceFixerTitleContext::PullRequest(workspace.publication_pr_number) + } else { + AgentWorkspaceFixerTitleContext::Repair(attempt.source) + }; + let runtime_conversation_id = ensure_agent_workspace_fixer_conversation( + state, + &workspace, + attempt.runtime_conversation_id.as_ref(), + kind, + title_context, + ) + .await?; let reserved = match reserve_agent_workspace_repair_dispatch( Arc::clone(&state.agent_workspace_repair_repo), Arc::clone(&state.branch_update_repo), target_identity, attempt, run_id.clone(), + Some(runtime_conversation_id), reservation_summary, workspace.pr_auto_merge_current, ) @@ -1783,6 +1809,8 @@ pub(super) async fn reserve_and_deliver_repair_dispatch( }, ), }; + let mut options = options; + options.conversation_id_override = Some(*reserved.runtime_conversation_id()); let service = state.build_chat_service(); let delivery = service .send_message( @@ -1794,7 +1822,7 @@ pub(super) async fn reserve_and_deliver_repair_dispatch( .await; let settlement = classify_agent_workspace_repair_delivery( delivery.as_ref(), - &workspace.conversation_id, + reserved.runtime_conversation_id(), &run_id, ); match settle_agent_workspace_repair_dispatch_outcome( diff --git a/src-tauri/src/application/agent_workspace_publish_recovery_tests.rs b/src-tauri/src/application/agent_workspace_publish_recovery_tests.rs index 5d0f08dfc2..a456a2fdc1 100644 --- a/src-tauri/src/application/agent_workspace_publish_recovery_tests.rs +++ b/src-tauri/src/application/agent_workspace_publish_recovery_tests.rs @@ -67,7 +67,7 @@ use crate::domain::entities::{ use crate::domain::repositories::{ AcquireGitTargetLease, AcquireGitTargetLeaseOutcome, AgentConversationWorkspaceRepository, AgentRunRepository, AgentWorkspaceRepairAttemptTransition, - AgentWorkspaceRepairAttemptTransitionOutcome, BeginGitMutation, + AgentWorkspaceRepairAttemptTransitionOutcome, AgentWorkspaceRepairRepository, BeginGitMutation, CompleteAgentWorkspaceRepairEffect, CompleteAgentWorkspaceRepairEffectOutcome, CreateAgentWorkspaceRepairEffect, CreateAgentWorkspaceRepairEffectOutcome, ProjectRepository, SettleAgentWorkspaceRepairAttempt, SettleAgentWorkspaceRepairAttemptOutcome, @@ -840,7 +840,8 @@ async fn startup_recovery_wrappers_finish_on_empty_repositories() { let agent_run_repo = Arc::new(MemoryAgentRunRepository::new()); recover_stale_agent_workspace_publish_repairs_on_startup( - workspace_repo as Arc, + workspace_repo.clone() as Arc, + workspace_repo as Arc, agent_run_repo as Arc, ) .await; @@ -907,6 +908,7 @@ async fn recovery_ignores_nonterminal_run_hints_and_blocks_exhausted_ownerless_d expected_phase: AgentWorkspaceRepairPhase::Requested, expected_updated_at: live_attempt.updated_at, run_id: live_run.id.clone(), + runtime_conversation_id: None, updated_at: live_attempt.updated_at + chrono::Duration::microseconds(1), }, ) @@ -1417,6 +1419,7 @@ async fn fresh_dispatch_reservation_is_not_settled_as_interrupted_before_its_run target_identity, started, AgentRunId::from_string("fresh-dispatch-run"), + None, "dispatch fresh repair", None, ) @@ -1529,6 +1532,7 @@ async fn startup_recovery_schedules_one_due_retry_for_an_interrupted_repair_deli target_identity, started, AgentRunId::from_string("interrupted-repair-delivery-run"), + None, "dispatch durable repair", None, ) @@ -1688,6 +1692,7 @@ wait "$stdin_drain_pid" 2>/dev/null || true target_identity, started, AgentRunId::from_string("due-retry-initial-run"), + None, "reserve retry delivery", None, ) @@ -2052,7 +2057,7 @@ wait "$stdin_drain_pid" 2>/dev/null || true // The retry marker is internal scheduling bookkeeping. Rendering it as the assignment's // "Context:" told the recipient nothing about what needed repairing. - let delivered = latest_sent_repair_message(&state, &conversation_id).await; + let delivered = latest_sent_repair_message(&state, successor.runtime_conversation_id()).await; assert!( !delivered.contains("auto_retry_blocked_repair"), "internal retry markers must never reach an agent assignment: {delivered}" @@ -3251,6 +3256,7 @@ async fn due_recovery_with_an_open_repair_effect_does_not_dispatch_or_append_eve target_identity, started, AgentRunId::from_string("effect-owned-retry-initial-run"), + None, "reserve retry delivery", None, ) @@ -3402,6 +3408,7 @@ async fn startup_recovery_keeps_a_live_reserved_repair_run_authoritative() { .expect("valid canonical target identity"), started, run.id, + None, "dispatch durable repair", None, ) @@ -3445,6 +3452,7 @@ async fn failed_exact_pr_autofix_is_classified_as_retry_eligible() { let (_workspace, outcome) = recover_stale_publish_repair_for_workspace_with_project_repo_outcome( Arc::clone(&state.agent_conversation_workspace_repo), + Arc::clone(&state.agent_workspace_repair_repo), Arc::clone(&state.agent_run_repo), Arc::clone(&state.project_repo), workspace, @@ -3577,6 +3585,7 @@ async fn recovery_correlates_the_exact_pr_autofix_attempt_not_a_newer_unrelated_ let (updated, outcome) = recover_stale_publish_repair_for_workspace_and_reload_with_review_target( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::clone(&agent_run_repo) as Arc, workspace, None, @@ -3624,6 +3633,7 @@ async fn recovery_with_review_target_preserves_current_reviewing_handoff() { let (refreshed, outcome) = recover_stale_publish_repair_for_workspace_and_reload_with_review_target( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::clone(&agent_run_repo) as Arc, workspace, Some(&target), @@ -3677,6 +3687,7 @@ async fn stale_review_handoff_without_matching_target_is_recovered_and_reloaded( let refreshed = recover_stale_publish_repair_for_workspace_and_reload( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::clone(&agent_run_repo) as Arc, workspace, ) @@ -3718,6 +3729,7 @@ async fn batch_recovery_counts_only_recovered_workspaces() { let recovered = recover_stale_agent_workspace_publish_repairs( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::clone(&agent_run_repo) as Arc, ) .await @@ -3764,6 +3776,7 @@ async fn recovery_heals_only_an_active_current_repair_to_fixing() { let refreshed = recover_stale_publish_repair_for_workspace_and_reload( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::clone(&agent_run_repo) as Arc, workspace, ) @@ -3811,6 +3824,7 @@ async fn recovery_restores_blocked_state_only_for_the_current_pr_autofix_replace let (refreshed, outcome) = recover_stale_publish_repair_for_workspace_and_reload_with_review_target( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::clone(&agent_run_repo) as Arc, workspace, None, @@ -3864,6 +3878,7 @@ async fn recovery_does_not_treat_an_unrelated_active_run_as_a_pr_autofix_replace let (refreshed, outcome) = recover_stale_publish_repair_for_workspace_and_reload_with_review_target( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::clone(&agent_run_repo) as Arc, workspace, None, @@ -3934,6 +3949,7 @@ mod extracted_inline_tests { let recovered = recover_stale_agent_workspace_publish_repairs( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::clone(&agent_run_repo) as Arc, ) .await @@ -4005,6 +4021,7 @@ mod extracted_inline_tests { let recovered = recover_stale_agent_workspace_publish_repairs( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::clone(&agent_run_repo) as Arc, ) .await @@ -4034,6 +4051,7 @@ mod extracted_inline_tests { recover_stale_agent_workspace_publish_repairs_on_startup( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::clone(&agent_run_repo) as Arc, ) .await; @@ -4047,6 +4065,7 @@ mod extracted_inline_tests { recover_stale_agent_workspace_publish_repairs_on_startup( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::clone(&agent_run_repo) as Arc, ) .await; @@ -4077,6 +4096,7 @@ mod extracted_inline_tests { let recovered = recover_stale_publish_repair_for_workspace( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::clone(&agent_run_repo) as Arc, workspace, ) @@ -4114,6 +4134,7 @@ mod extracted_inline_tests { let recovered = recover_stale_publish_repair_for_workspace( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, agent_run_repo, workspace, ) @@ -4133,6 +4154,7 @@ mod extracted_inline_tests { let recovered = recover_stale_publish_repair_for_workspace( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, agent_run_repo, workspace, ) @@ -4158,10 +4180,14 @@ mod extracted_inline_tests { run.completed_at = None; agent_run_repo.create(run).await.expect("seed run"); - let recovered = - recover_stale_publish_repair_for_workspace(workspace_repo, agent_run_repo, workspace) - .await - .expect("check repair state"); + let recovered = recover_stale_publish_repair_for_workspace( + workspace_repo.clone(), + workspace_repo, + agent_run_repo, + workspace, + ) + .await + .expect("check repair state"); assert!(recovered); } @@ -4182,6 +4208,7 @@ mod extracted_inline_tests { let recovered = recover_stale_publish_repair_for_workspace( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, agent_run_repo, workspace, ) @@ -4234,6 +4261,7 @@ mod extracted_inline_tests { let recovered = recover_stale_publish_repair_for_workspace( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, agent_run_repo, workspace, ) @@ -5337,7 +5365,14 @@ wait "$stdin_drain_pid" 2>/dev/null || true 1 ); - let message = latest_sent_repair_message(&state, &conversation_id).await; + let current_attempt = state + .agent_workspace_repair_repo + .get_current_repair_attempt(&conversation_id) + .await + .expect("load current attempt after recovery") + .expect("current attempt exists after recovery"); + let message = + latest_sent_repair_message(&state, current_attempt.runtime_conversation_id()).await; assert!( message.contains("redelivering an interrupted PR fix"), "PR autofix redelivery must use the PR fixer assignment, got: {message}" @@ -5385,7 +5420,14 @@ wait "$stdin_drain_pid" 2>/dev/null || true 1 ); - let message = latest_sent_repair_message(&state, &conversation_id).await; + let current_attempt = state + .agent_workspace_repair_repo + .get_current_repair_attempt(&conversation_id) + .await + .expect("load current attempt after recovery") + .expect("current attempt exists after recovery"); + let message = + latest_sent_repair_message(&state, current_attempt.runtime_conversation_id()).await; assert!( message.contains("complete_agent_workspace_repair"), "a publish repair must name the repairer's own completion tool: {message}" @@ -6164,8 +6206,6 @@ async fn pr_autofix_successor_withholds_when_github_cannot_be_read_at_all() { let state = AppState::new_test(); let conversation_id = conversation_id(70); let workspace = needs_agent_workspace(conversation_id.clone()); - // No target base commit means the base cannot have advanced, so nothing but GitHub could - // authorize another generation — and there is no GitHub service to ask. let attempt = blocked_pr_autofix_attempt(&conversation_id, "ci:Clippy:failure"); assert!(attempt.target_base_commit.is_none()); assert!(state.github_service.is_none()); @@ -6368,14 +6408,11 @@ async fn pr_autofix_successor_borrows_the_linked_plan_branch_pr_only_for_its_own workspace.linked_ideation_session_id = Some(session_id); let attempt = blocked_pr_autofix_attempt(&conversation_id, "ci:Clippy:failure"); - // The borrowed PR resolves, so evaluation advances past PR identity and fails on the missing - // project instead of on an unresolved PR number. assert_eq!( evaluate_pr_autofix_successor(&state, &attempt, &workspace).await, PrAutofixSuccessorDecision::Withhold("project_missing") ); - // A workspace bound to a different ideation session must not borrow this plan branch's PR. let mut foreign = workspace.clone(); foreign.linked_ideation_session_id = Some(IdeationSessionId::from_string("session-someone-else")); @@ -6403,8 +6440,6 @@ async fn pr_autofix_successor_withholds_when_the_workspace_path_cannot_be_resolv let workspace = needs_agent_workspace(conversation_id.clone()); let attempt = blocked_pr_autofix_attempt(&conversation_id, "ci:Clippy:failure"); - // An unreadable workspace is not evidence that the PR changed; it must never look like - // "health changed", which is the one answer that spends another agent. assert_eq!( evaluate_pr_autofix_successor(&state, &attempt, &workspace).await, PrAutofixSuccessorDecision::Withhold("workspace_path_unresolved") diff --git a/src-tauri/src/application/agent_workspace_publish_repair_state.rs b/src-tauri/src/application/agent_workspace_publish_repair_state.rs index 58493235cb..09f2397d6e 100644 --- a/src-tauri/src/application/agent_workspace_publish_repair_state.rs +++ b/src-tauri/src/application/agent_workspace_publish_repair_state.rs @@ -5,6 +5,8 @@ use std::sync::Arc; use chrono::{DateTime, Duration, Utc}; use crate::application::agent_conversation_workspace::resolve_effective_agent_conversation_workspace_path; +#[cfg(any(test, feature = "test-utils"))] +use crate::application::agent_workspace_fixer_conversation::agent_workspace_fixer_runtime_conversations; use crate::application::agent_workspace_publish_recovery::recover_stale_publish_repair_for_workspace_in_state_result; use crate::application::agent_workspace_review::{ load_agent_workspace_review_context, load_workspace_review_publish_blocker, @@ -2191,6 +2193,7 @@ pub(crate) async fn reserve_agent_workspace_repair_dispatch( target_identity: GitTargetIdentity, attempt: AgentWorkspaceRepairAttempt, run_id: AgentRunId, + runtime_conversation_id: Option, summary: &str, auto_merge_current: Option, ) -> AppResult { @@ -2278,6 +2281,7 @@ pub(crate) async fn reserve_agent_workspace_repair_dispatch( expected_phase: AgentWorkspaceRepairPhase::Requested, expected_updated_at: attempt.updated_at, run_id: run_id.clone(), + runtime_conversation_id, updated_at: next_transition_at(Some(attempt.updated_at)), }) .await?; @@ -3692,6 +3696,7 @@ pub(crate) async fn settle_terminal_agent_workspace_repair( #[cfg(any(test, feature = "test-utils"))] pub(crate) async fn reconcile_active_agent_workspace_repair( workspace_repo: Arc, + repair_repo: Arc, agent_run_repo: Arc, workspace: &AgentConversationWorkspace, ) -> AppResult { @@ -3700,18 +3705,31 @@ pub(crate) async fn reconcile_active_agent_workspace_repair( { return Ok(false); } - let Some(active_run) = agent_run_repo - .get_active_for_conversation(&workspace.conversation_id) - .await? - else { - return Ok(false); - }; + let runtime_conversations = agent_workspace_fixer_runtime_conversations( + workspace, + workspace_repo.as_ref(), + repair_repo.as_ref(), + ) + .await?; let events = workspace_repo .list_publication_events(&workspace.conversation_id) .await?; - if !repair_event_authorizes_active_run(&events, &active_run) { - return Ok(false); + let mut active_run = None; + for runtime_conversation_id in runtime_conversations { + if let Some(run) = agent_run_repo + .get_active_for_conversation(&runtime_conversation_id) + .await? + { + if repair_event_authorizes_active_run(&events, &run) { + active_run = Some(run); + break; + } + } } + let Some(active_run) = active_run else { + return Ok(false); + }; + let _active_run = active_run; let transition = AgentWorkspaceRepairStateTransition { publication_push_status: Some("needs_agent".to_string()), @@ -3733,6 +3751,7 @@ pub(crate) async fn reconcile_active_agent_workspace_repair( #[cfg(test)] pub(crate) async fn current_agent_workspace_repair_claim_for_completion( workspace_repo: Arc, + repair_repo: Arc, agent_run_repo: Arc, workspace: &AgentConversationWorkspace, ) -> AppResult> { @@ -3741,21 +3760,34 @@ pub(crate) async fn current_agent_workspace_repair_claim_for_completion( { return Ok(None); } - let Some(active_run) = agent_run_repo - .get_active_for_conversation(&workspace.conversation_id) - .await? - else { - return Ok(None); - }; + let runtime_conversations = agent_workspace_fixer_runtime_conversations( + workspace, + workspace_repo.as_ref(), + repair_repo.as_ref(), + ) + .await?; let events = workspace_repo .list_publication_events(&workspace.conversation_id) .await?; let Some(claim_started_at) = workspace.pr_supervision_updated_at else { return Ok(None); }; - if !successful_send_authorizes_completion(&events, &active_run, claim_started_at) { - return Ok(None); + let mut active_run = None; + for runtime_conversation_id in runtime_conversations { + if let Some(run) = agent_run_repo + .get_active_for_conversation(&runtime_conversation_id) + .await? + { + if successful_send_authorizes_completion(&events, &run, claim_started_at) { + active_run = Some(run); + break; + } + } } + let Some(active_run) = active_run else { + return Ok(None); + }; + let _active_run = active_run; Ok(Some(AgentWorkspaceRepairClaim { conversation_id: workspace.conversation_id.clone(), guard: AgentWorkspaceRepairStateGuard::from_workspace(workspace), diff --git a/src-tauri/src/application/agent_workspace_publish_repair_state_tests.rs b/src-tauri/src/application/agent_workspace_publish_repair_state_tests.rs index 175c1a7cc7..89da52c71d 100644 --- a/src-tauri/src/application/agent_workspace_publish_repair_state_tests.rs +++ b/src-tauri/src/application/agent_workspace_publish_repair_state_tests.rs @@ -1671,6 +1671,7 @@ async fn blocked_retry_successor_with_a_different_target_base_commit_leaves_work target_identity.clone(), started, AgentRunId::from_string("repair-attempt-blocked-retry-base-commit-guard-run"), + None, "dispatching repair", None, ) @@ -1789,6 +1790,7 @@ async fn dispatch_checkpoint_never_advances_workspace_base_commit() { target_identity, started, AgentRunId::from_string("repair-attempt-dispatch-base-commit-guard-run"), + None, "dispatching repair", None, ) @@ -2372,6 +2374,7 @@ async fn dispatch_refuses_to_replace_an_open_external_effect() { target.clone(), attempt, AgentRunId::from_string("repair-dispatch-open-effect-run"), + None, "dispatch repair", None, ) @@ -2416,6 +2419,7 @@ async fn dispatch_reservation_releases_target_authority_for_stale_and_missing_ge target_identity.clone(), not_due, AgentRunId::from_string("not-due-dispatch-run"), + None, "not due", None, ) @@ -2458,6 +2462,7 @@ async fn dispatch_reservation_releases_target_authority_for_stale_and_missing_ge target_identity.clone(), attempt, AgentRunId::from_string("stale-dispatch-run"), + None, "stale dispatch", None, ) @@ -2494,6 +2499,7 @@ async fn dispatch_reservation_releases_target_authority_for_stale_and_missing_ge target_identity.clone(), missing, AgentRunId::from_string("missing-dispatch-run"), + None, "missing dispatch", None, ) @@ -3304,6 +3310,7 @@ async fn verified_base_advance_updates_one_active_generation_without_replacing_r expected_phase: AgentWorkspaceRepairPhase::Requested, expected_updated_at: started.updated_at, run_id: run_id.clone(), + runtime_conversation_id: None, updated_at: chrono::Utc::now(), }; repair_repo.bind_repair_attempt_run(bound).await.unwrap(); @@ -3366,6 +3373,7 @@ async fn blocked_retry_coalesces_to_one_successor_generation_and_projects_reques target_identity.clone(), started, AgentRunId::from_string("repair-attempt-blocked-retry-run"), + None, "dispatching repair", None, ) @@ -3485,6 +3493,7 @@ async fn retryable_dispatch_failure_persists_one_due_retry_and_blocks_not_due_re target_identity.clone(), started, AgentRunId::from_string("repair-dispatch-due-retry-first"), + None, "dispatch repair", None, ) @@ -3530,6 +3539,7 @@ async fn retryable_dispatch_failure_persists_one_due_retry_and_blocks_not_due_re target_identity, scheduled.clone(), AgentRunId::from_string("repair-dispatch-due-retry-replay"), + None, "must not dispatch before due", None, ) @@ -3608,6 +3618,7 @@ async fn immediate_start_rejection_defers_recovery_redelivery_without_consuming_ target_identity.clone(), current, AgentRunId::from_string(format!("repair-dispatch-immediate-start-{delivery}")), + None, "dispatch busy repair", None, ) @@ -3730,6 +3741,7 @@ async fn exhausted_or_nonretryable_dispatch_failure_blocks_once_and_releases_lea target_identity.clone(), current, AgentRunId::from_string(format!("repair-dispatch-exhaustion-{retry}")), + None, "dispatch repair", None, ) @@ -3857,6 +3869,7 @@ async fn foreign_canonical_target_owner_rejects_repair_dispatch_before_run_bindi target_identity.clone(), started.clone(), AgentRunId::from_string("repair-dispatch-foreign-owner-run"), + None, "dispatching repair", None, ) @@ -3920,6 +3933,7 @@ async fn exact_run_authority_distinguishes_current_stale_completed_and_blocked_a expected_phase: AgentWorkspaceRepairPhase::Requested, expected_updated_at: attempt.updated_at, run_id: owner_run.clone(), + runtime_conversation_id: None, updated_at: chrono::Utc::now(), }, ) @@ -4002,6 +4016,7 @@ async fn exact_run_authority_distinguishes_current_stale_completed_and_blocked_a expected_phase: AgentWorkspaceRepairPhase::Requested, expected_updated_at: successor.updated_at, run_id: successor_run.clone(), + runtime_conversation_id: None, updated_at: successor.updated_at + chrono::Duration::microseconds(1), }, ) @@ -4192,6 +4207,7 @@ async fn active_reconciliation_requires_current_successful_lifecycle_evidence() .unwrap(); assert!(!reconcile_active_agent_workspace_repair( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::clone(&run_repo) as Arc, &workspace, ) @@ -4210,6 +4226,7 @@ async fn active_reconciliation_requires_current_successful_lifecycle_evidence() .unwrap(); assert!(reconcile_active_agent_workspace_repair( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::clone(&run_repo) as Arc, &workspace, ) @@ -4255,6 +4272,7 @@ async fn stale_completion_claim_cannot_overwrite_a_failed_attempt() { .unwrap(); let claim = current_agent_workspace_repair_claim_for_completion( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::clone(&run_repo) as Arc, &workspace, ) @@ -4332,6 +4350,7 @@ async fn completion_requires_dispatch_evidence_for_the_current_claim() { assert!(current_agent_workspace_repair_claim_for_completion( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::clone(&run_repo) as Arc, &claimed_workspace, ) @@ -4351,7 +4370,8 @@ async fn completion_requires_dispatch_evidence_for_the_current_claim() { .unwrap(); assert_eq!( current_agent_workspace_repair_claim_for_completion( - workspace_repo as Arc, + workspace_repo.clone() as Arc, + workspace_repo as Arc, run_repo as Arc, &claimed_workspace, ) diff --git a/src-tauri/src/application/agent_workspace_review.rs b/src-tauri/src/application/agent_workspace_review.rs index 896f070745..2281b52576 100644 --- a/src-tauri/src/application/agent_workspace_review.rs +++ b/src-tauri/src/application/agent_workspace_review.rs @@ -15,6 +15,10 @@ use tracing::{error, info, warn}; use crate::application::agent_plan_context::{ load_linked_workspace_plan_snapshot, merge_authoritative_plan_references, }; +use crate::application::agent_workspace_fixer_conversation::{ + ensure_agent_workspace_fixer_conversation, AgentWorkspaceFixerKind, + AgentWorkspaceFixerTitleContext, +}; use crate::application::agent_workspace_review_base::resolve_agent_workspace_review_base; use crate::application::chat_service::{ get_assistant_role, ChatService, SendCallerContext, SendMessageOptions, SendQueuePolicy, @@ -3167,6 +3171,16 @@ pub(crate) async fn complete_agent_workspace_review_run_unlocked( Some(WORKSPACE_REVIEW_FIXER_STATUS_CYCLE_CAPPED.to_string()); monitor.review_fixer_attempt_id = None; clear_review_fixer_linkage(&mut monitor); + monitor.review_fixer_conversation_id = Some( + ensure_agent_workspace_fixer_conversation( + state, + workspace, + None, + AgentWorkspaceFixerKind::WorkspaceRepair, + AgentWorkspaceFixerTitleContext::ReviewBlocking, + ) + .await?, + ); } } AgentWorkspaceReviewOutcome::NoChanges if target.is_none() => { @@ -3947,6 +3961,27 @@ async fn route_workspace_review_blocking_fixer_with_chat_service conversation_id, + Err(error) => { + next.review_fixer_status = Some(WORKSPACE_REVIEW_FIXER_STATUS_FAILED.to_string()); + next.last_error = Some(format!("Failed to create Review fixer child: {error}")); + return settle_workspace_review_fixer_attempt(state, next, monitor).await; + } + }; + next.review_fixer_conversation_id = Some(fixer_conversation_id); + let mut next = state + .agent_conversation_workspace_repo + .upsert_workspace_review_monitor(next) + .await?; let preserve_conversation_provider_session_ref = true; let send_started = Instant::now(); match chat_service @@ -3955,7 +3990,7 @@ async fn route_workspace_review_blocking_fixer_with_chat_service { + if result.conversation_id != fixer_conversation_id.as_str() { + next.review_fixer_status = Some(WORKSPACE_REVIEW_FIXER_STATUS_FAILED.to_string()); + next.last_error = Some( + "Workspace Review fixer launch did not preserve its reserved child conversation" + .to_string(), + ); + return settle_workspace_review_fixer_attempt(state, next, monitor).await; + } next.review_fixer_status = Some(if result.was_queued || result.queued_as_pending { WORKSPACE_REVIEW_FIXER_STATUS_QUEUED.to_string() } else { @@ -3999,8 +4042,6 @@ async fn route_workspace_review_blocking_fixer_with_chat_service, + agent_workspace_repair_repo: workspace_repo.clone(), events: Arc::new(ralphx_events::NullEventSink), pr_fix_review_publish_resumer: None, durable_recovery_state: None, @@ -252,6 +254,7 @@ async fn pending_review_handoff_without_current_attempt_evidence_aborts_before_t let (after, recovered) = recover_stale_publish_repair_for_workspace_with_project_repo( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::new(MemoryAgentRunRepository::new()) as Arc, Arc::new(MemoryProjectRepository::with_projects(vec![project])), workspace, diff --git a/src-tauri/src/application/agent_workspace_terminal_cleanup.rs b/src-tauri/src/application/agent_workspace_terminal_cleanup.rs index 11f846d5f6..2ffb0dad00 100644 --- a/src-tauri/src/application/agent_workspace_terminal_cleanup.rs +++ b/src-tauri/src/application/agent_workspace_terminal_cleanup.rs @@ -10,6 +10,7 @@ use crate::application::agent_conversation_workspace::{ resolve_agent_conversation_workspace_path_from_record_identity, resolve_linked_plan_branch_agent_worktree_path, validate_workspace_linked_plan_branch, }; +use crate::application::agent_workspace_fixer_conversation::agent_workspace_fixer_runtime_conversations; use crate::application::agent_workspace_publish_lease::stop_publish_operation_lease_heartbeat; use crate::application::chat_service::ChatService; use crate::application::git_artifact_cleanup::{ @@ -27,7 +28,7 @@ use crate::domain::entities::{ }; use crate::domain::repositories::{ AgentConversationWorkspaceRepository, AgentRunRepository, AgentWorkspaceLocalCleanupClaim, - PlanBranchRepository, + AgentWorkspaceRepairRepository, PlanBranchRepository, }; use crate::domain::services::kill_worktree_processes_async; use crate::infrastructure::agents::claude::git_runtime_config; @@ -120,6 +121,7 @@ enum TerminalCleanupTarget { pub(crate) async fn terminalize_agent_workspace_after_pr( workspace_repo: Arc, + repair_repo: Arc, agent_run_repo: Arc, plan_branch_repo: Option>, chat_service: Option>, @@ -127,49 +129,93 @@ pub(crate) async fn terminalize_agent_workspace_after_pr( project: &Project, cause: TerminalAgentWorkspaceCause, ) -> TerminalAgentWorkspaceOutcome { - // Observe the exact fencing token before stopping the runtime. Terminal cleanup may release - // that token after the stop, but a concurrent re-drive that installs a newer token remains - // authoritative because release_publish_lease is an exact-token CAS. - let observed_publish_lease_token = - match workspace_repo.get_by_conversation_id(conversation_id).await { - Ok(Some(workspace)) => workspace.publish_lease_token, - Ok(None) => None, + // A read failure is degraded state and stays fail-closed, but a vanished workspace row is a + // definitive absence: there are no persisted fixer runtimes left to enumerate. Fall back to the + // conversation's own runtime and let local cleanup report the missing row, so the poller's + // terminal retry loop cannot spin forever on a workspace that no longer exists. + let workspace = match workspace_repo.get_by_conversation_id(conversation_id).await { + Ok(workspace) => workspace, + Err(error) => { + return TerminalAgentWorkspaceOutcome::runtime_blocked(format!( + "Failed to load the workspace before terminal runtime cleanup: {error}" + )); + } + }; + let observed_publish_lease_token = workspace + .as_ref() + .and_then(|workspace| workspace.publish_lease_token.clone()); + let mut runtime_conversations = match workspace.as_ref() { + Some(workspace) => match agent_workspace_fixer_runtime_conversations( + workspace, + workspace_repo.as_ref(), + repair_repo.as_ref(), + ) + .await + { + Ok(conversations) => conversations, Err(error) => { return TerminalAgentWorkspaceOutcome::runtime_blocked(format!( - "Failed to inspect the terminal workspace publication lease: {error}" + "Failed to resolve workspace fixer runtimes: {error}" )); } - }; - let active_run = match agent_run_repo - .get_active_for_conversation(conversation_id) + }, + None => vec![*conversation_id], + }; + match workspace_repo + .get_workspace_review_monitor(conversation_id) .await { - Ok(run) => run, + Ok(Some(monitor)) => { + if let Some(review_conversation_id) = monitor.review_conversation_id { + if !runtime_conversations.contains(&review_conversation_id) { + runtime_conversations.push(review_conversation_id); + } + } + } + Ok(None) => {} Err(error) => { return TerminalAgentWorkspaceOutcome::runtime_blocked(format!( - "Failed to inspect the active workspace run: {error}" + "Failed to resolve the Workspace Review runtime: {error}" )); } - }; + } - if active_run.is_some() || chat_service.is_some() { + let mut active_runs = Vec::new(); + for runtime_conversation_id in &runtime_conversations { + match agent_run_repo + .get_active_for_conversation(runtime_conversation_id) + .await + { + Ok(Some(run)) => active_runs.push(run), + Ok(None) => {} + Err(error) => { + return TerminalAgentWorkspaceOutcome::runtime_blocked(format!( + "Failed to inspect an active workspace runtime: {error}" + )); + } + } + } + + if !active_runs.is_empty() || chat_service.is_some() { let Some(chat_service) = chat_service.as_ref() else { return TerminalAgentWorkspaceOutcome::runtime_blocked( "An active workspace run could not be stopped because no chat runtime was available" .to_string(), ); }; - if let Err(error) = chat_service - .stop_agent(ChatContextType::Project, &conversation_id.as_str()) - .await - { - return TerminalAgentWorkspaceOutcome::runtime_blocked(format!( - "Failed to stop the workspace runtime: {error}" - )); + for runtime_conversation_id in &runtime_conversations { + if let Err(error) = chat_service + .stop_agent(ChatContextType::Project, &runtime_conversation_id.as_str()) + .await + { + return TerminalAgentWorkspaceOutcome::runtime_blocked(format!( + "Failed to stop the workspace runtime: {error}" + )); + } } } - if let Some(run) = active_run { + for run in active_runs { if let Err(error) = agent_run_repo.fail(&run.id, &cause.stop_reason()).await { return TerminalAgentWorkspaceOutcome::runtime_blocked(format!( "Failed to persist the terminal workspace run result: {error}" @@ -177,21 +223,23 @@ pub(crate) async fn terminalize_agent_workspace_after_pr( } } - match agent_run_repo - .get_active_for_conversation(conversation_id) - .await - { - Ok(None) => {} - Ok(Some(run)) => { - return TerminalAgentWorkspaceOutcome::runtime_blocked(format!( - "Workspace run {} remained active after stop", - run.id.as_str() - )); - } - Err(error) => { - return TerminalAgentWorkspaceOutcome::runtime_blocked(format!( - "Failed to verify the stopped workspace runtime: {error}" - )); + for runtime_conversation_id in &runtime_conversations { + match agent_run_repo + .get_active_for_conversation(runtime_conversation_id) + .await + { + Ok(None) => {} + Ok(Some(run)) => { + return TerminalAgentWorkspaceOutcome::runtime_blocked(format!( + "Workspace run {} remained active after stop", + run.id.as_str() + )); + } + Err(error) => { + return TerminalAgentWorkspaceOutcome::runtime_blocked(format!( + "Failed to verify the stopped workspace runtime: {error}" + )); + } } } @@ -238,6 +286,7 @@ pub(crate) async fn terminalize_agent_workspace_after_pr( #[allow(clippy::too_many_arguments)] pub(crate) async fn settle_review_pr_terminal_observation( workspace_repo: Arc, + repair_repo: Arc, agent_run_repo: Arc, plan_branch_repo: Option>, chat_service: Option>, @@ -265,6 +314,7 @@ pub(crate) async fn settle_review_pr_terminal_observation( Ok(terminalize_agent_workspace_after_pr( workspace_repo, + repair_repo, agent_run_repo, plan_branch_repo, chat_service, diff --git a/src-tauri/src/application/agent_workspace_terminal_cleanup_tests.rs b/src-tauri/src/application/agent_workspace_terminal_cleanup_tests.rs index 5381cade91..85b4c9b1f2 100644 --- a/src-tauri/src/application/agent_workspace_terminal_cleanup_tests.rs +++ b/src-tauri/src/application/agent_workspace_terminal_cleanup_tests.rs @@ -319,6 +319,7 @@ async fn terminal_cleanup_blocks_deletion_while_an_active_run_cannot_be_stopped( .expect("persist active run"); let outcome = terminalize_agent_workspace_after_pr( + workspace_repo.clone(), workspace_repo.clone(), run_repo, None, @@ -418,6 +419,7 @@ async fn terminalize_stops_active_run_and_records_archive_reason_before_cleanup( let chat_service = Arc::new(MockChatService::new()); let outcome = terminalize_agent_workspace_after_pr( + workspace_repo.clone(), workspace_repo.clone(), run_repo.clone(), None, @@ -466,9 +468,12 @@ async fn terminalize_reports_missing_workspace_without_claiming_cleanup() { let conversation_id = ChatConversationId::from_string("69696969-6969-6969-6969-696969696969".to_string()); let workspace_repo = Arc::new(MemoryAgentConversationWorkspaceRepository::new()); + let repair_repo: Arc = + workspace_repo.clone(); let outcome = terminalize_agent_workspace_after_pr( workspace_repo, + repair_repo, Arc::new(MemoryAgentRunRepository::new()), None, None, @@ -516,8 +521,11 @@ async fn terminalize_releases_the_observed_operation_owned_publish_lease() { .await .expect("claim operation-owned lease"); + let repair_repo: Arc = + workspace_repo.clone(); let outcome = terminalize_agent_workspace_after_pr( workspace_repo.clone(), + repair_repo, Arc::new(MemoryAgentRunRepository::new()), None, None, @@ -575,8 +583,11 @@ async fn terminalize_never_releases_a_newer_publish_lease_token() { "new-redrive-token", ); + let repair_repo: Arc = + Arc::new(MemoryAgentConversationWorkspaceRepository::new()); let outcome = terminalize_agent_workspace_after_pr( workspace_repo.clone(), + repair_repo, Arc::new(MemoryAgentRunRepository::new()), None, None, @@ -855,6 +866,7 @@ async fn terminalize_blocks_cleanup_when_active_run_lookup_fails() { )])); let outcome = terminalize_agent_workspace_after_pr( + workspace_repo.clone(), workspace_repo.clone(), run_repo, None, @@ -901,6 +913,7 @@ async fn terminalize_blocks_cleanup_when_runtime_stop_fails() { chat_service.fail_next_stop_agent_calls(1).await; let outcome = terminalize_agent_workspace_after_pr( + workspace_repo.clone(), workspace_repo.clone(), run_repo, None, @@ -951,6 +964,7 @@ async fn terminalize_blocks_cleanup_when_failed_run_cannot_be_persisted() { let chat_service = Arc::new(MockChatService::new()); let outcome = terminalize_agent_workspace_after_pr( + workspace_repo.clone(), workspace_repo.clone(), run_repo, None, @@ -996,6 +1010,7 @@ async fn terminalize_blocks_cleanup_when_active_run_remains_after_stop() { let chat_service = Arc::new(MockChatService::new()); let outcome = terminalize_agent_workspace_after_pr( + workspace_repo.clone(), workspace_repo.clone(), run_repo, None, @@ -1041,6 +1056,7 @@ async fn terminalize_blocks_cleanup_when_post_stop_run_lookup_fails() { let chat_service = Arc::new(MockChatService::new()); let outcome = terminalize_agent_workspace_after_pr( + workspace_repo.clone(), workspace_repo.clone(), run_repo, None, diff --git a/src-tauri/src/application/app_state.rs b/src-tauri/src/application/app_state.rs index 7b3da38a5b..e8f768ff86 100644 --- a/src-tauri/src/application/app_state.rs +++ b/src-tauri/src/application/app_state.rs @@ -1832,6 +1832,9 @@ impl AppState { state .pr_poller_registry .set_branch_update_repo(Arc::clone(&state.branch_update_repo)); + state + .pr_poller_registry + .set_chat_conversation_repo(Arc::clone(&state.chat_conversation_repo)); Ok(state) } diff --git a/src-tauri/src/application/automation/merged_run_finalizer.rs b/src-tauri/src/application/automation/merged_run_finalizer.rs index 18941d7d26..cc8daf4b21 100644 --- a/src-tauri/src/application/automation/merged_run_finalizer.rs +++ b/src-tauri/src/application/automation/merged_run_finalizer.rs @@ -66,6 +66,7 @@ impl AutomationMergedRunFinalizer for AppStateAutomationMergedRunFinalizer { let chat_service: Arc = Arc::new(self.state.build_chat_service()); let terminalized = terminalize_agent_workspace_after_pr( Arc::clone(&self.state.agent_conversation_workspace_repo), + Arc::clone(&self.state.agent_workspace_repair_repo), Arc::clone(&self.state.agent_run_repo), Some(Arc::clone(&self.state.plan_branch_repo)), Some(chat_service), diff --git a/src-tauri/src/application/mod.rs b/src-tauri/src/application/mod.rs index 85be9e19da..2e473c68d3 100644 --- a/src-tauri/src/application/mod.rs +++ b/src-tauri/src/application/mod.rs @@ -18,21 +18,11 @@ pub mod agent_conversation_workspace_base; pub(crate) mod agent_conversation_workspace_restart; pub mod agent_issue_report; pub mod agent_lane_resolution; +pub mod agent_lane_settings_bootstrap; pub(crate) mod agent_plan_context; #[cfg(test)] mod agent_plan_context_tests; -pub mod manual_role_default_service; -pub mod manual_router_config; -pub mod mcp_policy_config; -pub mod mcp_policy_agent_client; -pub mod mcp_policy_service; -#[cfg(test)] -mod mcp_policy_agent_client_tests; -#[cfg(test)] -mod mcp_policy_config_tests; -#[cfg(test)] -mod mcp_policy_service_tests; -pub mod agent_lane_settings_bootstrap; +pub(crate) mod agent_planning_session_titles; pub(crate) mod agent_runtime_context; #[cfg(test)] mod agent_runtime_context_tests; @@ -42,17 +32,19 @@ mod agent_runtime_context_branch_status_tests; mod agent_runtime_context_linked_plan_tests; #[cfg(test)] mod agent_runtime_context_team_tests; -pub(crate) mod agent_planning_session_titles; pub mod agent_task_assignment_recovery; pub(crate) mod agent_task_pipeline_service; pub mod agent_task_service; pub mod agent_terminal; pub mod agent_workspace_bridge; +pub mod agent_workspace_continuation; +pub mod agent_workspace_external_pr_reconciliation; +pub mod agent_workspace_fixer_conversation; +#[cfg(test)] +mod agent_workspace_fixer_conversation_tests; pub mod agent_workspace_local_commit; #[cfg(test)] mod agent_workspace_local_commit_tests; -pub mod agent_workspace_continuation; -pub mod agent_workspace_external_pr_reconciliation; pub(crate) mod agent_workspace_pr_reopen; pub(crate) mod agent_workspace_pr_reopen_restore; #[cfg(test)] @@ -64,9 +56,6 @@ mod agent_workspace_pr_autofix_attempt_tests; pub mod agent_workspace_pr_description; #[cfg(test)] pub(crate) mod agent_workspace_pr_metadata_reconciliation; -pub(crate) mod agent_workspace_terminal_cleanup; -#[cfg(test)] -mod agent_workspace_terminal_cleanup_tests; pub(crate) mod agent_workspace_pr_supervision_recovery; pub(crate) mod agent_workspace_publish_lease; #[cfg(test)] @@ -109,6 +98,9 @@ mod agent_workspace_review_run_guard_tests; mod agent_workspace_review_unfinished_git_recovery_tests; #[cfg(test)] mod agent_workspace_review_unfinished_git_tests; +pub(crate) mod agent_workspace_terminal_cleanup; +#[cfg(test)] +mod agent_workspace_terminal_cleanup_tests; pub mod app_paths; #[cfg(test)] mod app_paths_tests; @@ -127,9 +119,6 @@ mod branch_update_executor_tests; pub mod branch_update_workflow; pub mod builder_attachment_materializer; pub mod chat_attachment_service; -pub(crate) mod conversation_reference_inheritance; -#[cfg(test)] -mod conversation_reference_inheritance_tests; pub mod chat_attachment_storage; pub mod chat_resumption; pub mod completion_correlation; @@ -143,6 +132,9 @@ pub mod clickup_integration_service; pub mod conversation_folder_reference_service; #[cfg(test)] mod conversation_folder_reference_service_tests; +pub(crate) mod conversation_reference_inheritance; +#[cfg(test)] +mod conversation_reference_inheritance_tests; pub mod data_retention_service; #[cfg(test)] mod data_retention_service_tests; @@ -193,6 +185,17 @@ pub mod linear_integration_service; pub mod linear_webhook_reconciliation_service; pub(crate) mod managed_provider_cli; pub mod managed_team; +pub mod manual_role_default_service; +pub mod manual_router_config; +pub mod mcp_policy_agent_client; +#[cfg(test)] +mod mcp_policy_agent_client_tests; +pub mod mcp_policy_config; +#[cfg(test)] +mod mcp_policy_config_tests; +pub mod mcp_policy_service; +#[cfg(test)] +mod mcp_policy_service_tests; pub mod memory_archive_service; pub mod memory_orchestration; pub(crate) mod merge_pipeline_visibility; @@ -503,14 +506,14 @@ mod ideation_harness_availability_tests; #[cfg(test)] mod ideation_workspace_tests; #[cfg(test)] +mod integration_reference_expansion_edge_tests; +#[cfg(test)] mod integration_reference_expansion_tests; #[cfg(test)] mod manual_role_default_service_tests; #[cfg(test)] mod manual_router_config_tests; #[cfg(test)] -mod integration_reference_expansion_edge_tests; -#[cfg(test)] mod orphan_worktree_cleanup_tests; #[cfg(test)] mod pending_session_drain_tests; diff --git a/src-tauri/src/application/pr_startup_recovery.rs b/src-tauri/src/application/pr_startup_recovery.rs index d27a95accf..97b7b15685 100644 --- a/src-tauri/src/application/pr_startup_recovery.rs +++ b/src-tauri/src/application/pr_startup_recovery.rs @@ -1390,8 +1390,16 @@ pub(crate) async fn recover_one_agent_workspace_pr_poller( } else { "Pull request closed without merging" }; + let Some(repair_repo) = agent_workspace_repair_repo.as_ref().map(Arc::clone) else { + tracing::error!( + conversation_id = workspace.conversation_id.as_str(), + "Terminal workspace recovery requires durable repair authority" + ); + return; + }; match settle_review_pr_terminal_observation( Arc::clone(&workspace_repo), + repair_repo, Arc::clone(&agent_run_repo), Some(Arc::clone(&plan_branch_repo)), Some(Arc::clone(&chat_service)), @@ -1521,8 +1529,16 @@ pub(crate) async fn recover_one_agent_workspace_pr_poller( PrStatus::Closed => ("closed", "Pull request closed without merging"), PrStatus::Open => unreachable!(), }; + let Some(repair_repo) = agent_workspace_repair_repo.as_ref().map(Arc::clone) else { + tracing::error!( + conversation_id = workspace.conversation_id.as_str(), + "Terminal workspace recovery requires durable repair authority" + ); + return; + }; match settle_review_pr_terminal_observation( Arc::clone(&workspace_repo), + repair_repo, Arc::clone(&agent_run_repo), Some(Arc::clone(&plan_branch_repo)), Some(Arc::clone(&chat_service)), diff --git a/src-tauri/src/application/pr_startup_recovery_tests.rs b/src-tauri/src/application/pr_startup_recovery_tests.rs index b7323b7477..7cd8e4de11 100644 --- a/src-tauri/src/application/pr_startup_recovery_tests.rs +++ b/src-tauri/src/application/pr_startup_recovery_tests.rs @@ -238,13 +238,17 @@ async fn setup_review_pr_poller_recovery_fixture( async fn recover_review_pr_poller_fixture(fixture: &ReviewPrPollerRecoveryFixture) { let workspace_repo: Arc = fixture.workspace_repo.clone(); - recover_agent_workspace_pr_pollers( + let repair_repo: Arc = fixture.workspace_repo.clone(); + recover_agent_workspace_pr_pollers_with_notifications( workspace_repo, Arc::clone(&fixture.project_repo), Arc::clone(&fixture.plan_branch_repo), Arc::clone(&fixture.registry), Arc::new(MemoryAgentRunRepository::new()), Arc::new(MockChatService::new()), + None, + Some(repair_repo), + None, Arc::new(HashSet::new()), ) .await; diff --git a/src-tauri/src/application/services/pr_merge_poller.rs b/src-tauri/src/application/services/pr_merge_poller.rs index 2596dba6ec..29091bcc5d 100644 --- a/src-tauri/src/application/services/pr_merge_poller.rs +++ b/src-tauri/src/application/services/pr_merge_poller.rs @@ -23,6 +23,11 @@ use crate::application::agent_workspace_base_staleness::{ use crate::application::agent_workspace_ci_rerun::{ check_is_in_flight, ci_rerun_hold_still_pending, classify_check_conclusion, CiFailureKind, }; +use crate::application::agent_workspace_fixer_conversation::{ + agent_workspace_fixer_runtime_conversations, + ensure_agent_workspace_fixer_conversation_with_repo, AgentWorkspaceFixerKind, + AgentWorkspaceFixerTitleContext, +}; use crate::application::agent_workspace_pr_autofix_attempt::{ load_pr_autofix_attempt_decision, pr_autofix_action_metadata, }; @@ -89,8 +94,9 @@ use crate::domain::entities::{ use crate::domain::repositories::{ AgentConversationWorkspaceRepository, AgentRunRepository, AgentWorkspaceRepairAttemptTransition, AgentWorkspaceRepairAttemptTransitionOutcome, - AgentWorkspaceRepairRepository, BranchUpdateRepository, PlanBranchRepository, - SettleAgentWorkspaceRepairAttempt, SettleAgentWorkspaceRepairAttemptOutcome, + AgentWorkspaceRepairRepository, BranchUpdateRepository, ChatConversationRepository, + PlanBranchRepository, SettleAgentWorkspaceRepairAttempt, + SettleAgentWorkspaceRepairAttemptOutcome, }; use crate::domain::services::github_service::{ PrHealth, PrHealthCheck, PrMergeStateStatus, PrMergeableState, PrReviewCommentFeedback, @@ -396,6 +402,9 @@ pub struct PrPollerRegistry { /// production AppState once and copied into each direct workspace poller. branch_update_repo: Arc>>>, + /// Conversation persistence for attempt-owned fixer children. + chat_conversation_repo: Arc>>>, + /// Last branch observations consumed synchronously by agent runtime-context composition. branch_status_cache: BranchStatusCache, } @@ -481,6 +490,7 @@ impl PrPollerRegistry { plan_branch_repo, notification_service: Arc::new(std::sync::RwLock::new(None)), branch_update_repo: Arc::new(std::sync::RwLock::new(None)), + chat_conversation_repo: Arc::new(std::sync::RwLock::new(None)), branch_status_cache: BranchStatusCache::default(), } } @@ -513,6 +523,12 @@ impl PrPollerRegistry { } } + pub fn set_chat_conversation_repo(&self, repo: Arc) { + if let Ok(mut current) = self.chat_conversation_repo.write() { + *current = Some(repo); + } + } + pub fn set_notification_service(&self, service: Arc) { if let Ok(mut current) = self.notification_service.write() { *current = Some(service); @@ -645,6 +661,11 @@ impl PrPollerRegistry { .read() .ok() .and_then(|repo| repo.clone()); + let chat_conversation_repo = self + .chat_conversation_repo + .read() + .ok() + .and_then(|repo| repo.clone()); let branch_status_cache = self.branch_status_cache.clone(); let rate_limit = Arc::clone(&self.rate_limit); let rate_limit_last_probe = Arc::clone(&self.rate_limit_last_probe); @@ -668,6 +689,7 @@ impl PrPollerRegistry { agent_run_repo, repair_repo, branch_update_repo, + chat_conversation_repo, plan_branch_repo, chat_service, notification_service, @@ -920,6 +942,10 @@ impl PrPollerRegistry { .read() .ok() .and_then(|repo| repo.clone()), + self.chat_conversation_repo + .read() + .ok() + .and_then(|repo| repo.clone()), chat_service, None, ) @@ -1392,6 +1418,7 @@ async fn agent_workspace_poll_loop( agent_run_repo: Arc, repair_repo: Option>, branch_update_repo: Option>, + chat_conversation_repo: Option>, plan_branch_repo: Arc, chat_service: Arc, notification_service: Option>, @@ -1509,6 +1536,7 @@ async fn agent_workspace_poll_loop( drop(permit); terminalize_polled_agent_workspace_with_notifications( &workspace_repo, + repair_repo.as_ref(), &agent_run_repo, &plan_branch_repo, &chat_service, @@ -1533,6 +1561,7 @@ async fn agent_workspace_poll_loop( drop(permit); terminalize_polled_agent_workspace_with_notifications( &workspace_repo, + repair_repo.as_ref(), &agent_run_repo, &plan_branch_repo, &chat_service, @@ -1699,6 +1728,7 @@ async fn agent_workspace_poll_loop( Some(Arc::clone(&agent_run_repo)), repair_repo.as_ref().map(Arc::clone), branch_update_repo.as_ref().map(Arc::clone), + chat_conversation_repo.as_ref().map(Arc::clone), Arc::clone(&chat_service), ) .await @@ -1755,6 +1785,7 @@ async fn agent_workspace_poll_loop( Some(Arc::clone(&agent_run_repo)), repair_repo.as_ref().map(Arc::clone), branch_update_repo.as_ref().map(Arc::clone), + chat_conversation_repo.as_ref().map(Arc::clone), Arc::clone(&chat_service), notification_service.as_ref().map(Arc::clone), Some(&project), @@ -1830,6 +1861,7 @@ async fn agent_workspace_poll_loop( Some(Arc::clone(&agent_run_repo)), repair_repo.as_ref().map(Arc::clone), branch_update_repo.as_ref().map(Arc::clone), + chat_conversation_repo.as_ref().map(Arc::clone), Arc::clone(&chat_service), polled_health.as_ref(), ) @@ -2100,6 +2132,7 @@ async fn re_arm_escalated_open_effect_continuation( #[cfg(test)] async fn terminalize_polled_agent_workspace( workspace_repo: &Arc, + repair_repo: &Arc, agent_run_repo: &Arc, plan_branch_repo: &Arc, chat_service: &Arc, @@ -2114,6 +2147,7 @@ async fn terminalize_polled_agent_workspace( ) { terminalize_polled_agent_workspace_with_notifications( workspace_repo, + Some(repair_repo), agent_run_repo, plan_branch_repo, chat_service, @@ -2133,6 +2167,7 @@ async fn terminalize_polled_agent_workspace( #[allow(clippy::too_many_arguments)] async fn terminalize_polled_agent_workspace_with_notifications( workspace_repo: &Arc, + repair_repo: Option<&Arc>, agent_run_repo: &Arc, plan_branch_repo: &Arc, chat_service: &Arc, @@ -2187,9 +2222,17 @@ async fn terminalize_polled_agent_workspace_with_notifications( }; if let Some(pr_number) = review_pr_number { + let Some(repair_repo) = repair_repo else { + tracing::error!( + conversation_id = conversation_id.as_str(), + "Agent workspace terminal cleanup requires durable repair authority" + ); + return; + }; loop { match settle_review_pr_terminal_observation( Arc::clone(workspace_repo), + Arc::clone(repair_repo), Arc::clone(agent_run_repo), Some(Arc::clone(plan_branch_repo)), Some(Arc::clone(chat_service)), @@ -2256,8 +2299,16 @@ async fn terminalize_polled_agent_workspace_with_notifications( } loop { + let Some(repair_repo) = repair_repo else { + tracing::error!( + conversation_id = conversation_id.as_str(), + "Agent workspace terminal cleanup requires durable repair authority" + ); + return; + }; let terminalized = terminalize_agent_workspace_after_pr( Arc::clone(workspace_repo), + Arc::clone(repair_repo), Arc::clone(agent_run_repo), Some(Arc::clone(plan_branch_repo)), Some(Arc::clone(chat_service)), @@ -2695,6 +2746,7 @@ async fn route_agent_workspace_pr_conflict_repair_if_needed_with_repair_repo( _agent_run_repo: Option>, repair_repo: Option>, branch_update_repo: Option>, + chat_conversation_repo: Option>, chat_service: Arc, ) -> crate::AppResult { let details = agent_workspace_pr_merge_conflict_details(health); @@ -2712,6 +2764,13 @@ async fn route_agent_workspace_pr_conflict_repair_if_needed_with_repair_repo( .to_string(), )); }; + #[cfg(not(test))] + let chat_conversation_repo = Some(chat_conversation_repo.ok_or_else(|| { + AppError::Infrastructure( + "durable PR conflict repair dispatch requires fixer conversation persistence" + .to_string(), + ) + })?); let workspace = workspace_repo .get_by_conversation_id(conversation_id) @@ -2850,12 +2909,26 @@ async fn route_agent_workspace_pr_conflict_repair_if_needed_with_repair_repo( let target_identity = GitService::canonical_target_identity(working_dir, &workspace.branch_name).await?; let repair_run_id = AgentRunId::new(); + let runtime_conversation_id = match chat_conversation_repo.as_ref() { + Some(chat_conversation_repo) => { + ensure_agent_workspace_fixer_conversation_with_repo( + chat_conversation_repo.as_ref(), + &workspace, + attempt.runtime_conversation_id.as_ref(), + AgentWorkspaceFixerKind::WorkspaceRepair, + AgentWorkspaceFixerTitleContext::Repair(attempt.source), + ) + .await? + } + None => workspace.conversation_id, + }; let dispatch = match reserve_agent_workspace_repair_dispatch( Arc::clone(&repair_repo), Arc::clone(&branch_update_repo), target_identity, attempt, repair_run_id.clone(), + Some(runtime_conversation_id), &repair_summary, workspace.pr_auto_merge_current, ) @@ -2915,7 +2988,7 @@ async fn route_agent_workspace_pr_conflict_repair_if_needed_with_repair_repo( SendMessageOptions { preallocated_agent_run_id: Some(repair_run_id), queue_policy: SendQueuePolicy::RequireImmediateStart, - conversation_id_override: Some(workspace.conversation_id.clone()), + conversation_id_override: Some(*dispatch.runtime_conversation_id()), agent_name_override: Some(AGENT_WORKSPACE_REPAIR.to_string()), working_directory_override: Some(PathBuf::from(&workspace.worktree_path)), force_new_provider_session: true, @@ -2926,7 +2999,7 @@ async fn route_agent_workspace_pr_conflict_repair_if_needed_with_repair_repo( .await; let settlement = classify_agent_workspace_repair_delivery( delivery.as_ref(), - conversation_id, + dispatch.runtime_conversation_id(), &repair_run_id, ); match delivery { @@ -3038,7 +3111,13 @@ async fn route_agent_workspace_pr_conflict_repair_legacy( || workspace.publication_pr_number != Some(pr_number) || workspace.has_terminal_publication_pr_status() || !workspace.auto_publish_enabled - || agent_workspace_pr_autofix_repair_in_flight(&workspace, agent_run_repo.as_ref()).await? + || agent_workspace_pr_autofix_repair_in_flight( + &workspace, + workspace_repo.as_ref(), + None, + agent_run_repo.as_ref(), + ) + .await? { return Ok(false); } @@ -3136,6 +3215,7 @@ async fn route_agent_workspace_pr_conflict_repair_legacy( SendMessageOptions { preallocated_agent_run_id: Some(repair_run_id), queue_policy: SendQueuePolicy::RequireImmediateStart, + // Legacy claim-only route has no durable attempt to resolve child completion. conversation_id_override: Some(workspace.conversation_id.clone()), agent_name_override: Some(AGENT_WORKSPACE_REPAIR.to_string()), working_directory_override: Some(PathBuf::from(&workspace.worktree_path)), @@ -3551,6 +3631,7 @@ async fn route_agent_workspace_pr_autofix_if_needed( agent_run_repo, None, None, + None, chat_service, None, ) @@ -3570,6 +3651,7 @@ async fn route_agent_workspace_pr_autofix_if_needed_with_repair_repo( agent_run_repo: Option>, repair_repo: Option>, branch_update_repo: Option>, + chat_conversation_repo: Option>, chat_service: Arc, polled_health: Option<&PrHealth>, ) -> crate::AppResult { @@ -3582,6 +3664,7 @@ async fn route_agent_workspace_pr_autofix_if_needed_with_repair_repo( agent_run_repo, repair_repo, branch_update_repo, + chat_conversation_repo, chat_service, None, None, @@ -3600,6 +3683,7 @@ async fn route_agent_workspace_pr_autofix_if_needed_with_notifications( agent_run_repo: Option>, repair_repo: Option>, branch_update_repo: Option>, + chat_conversation_repo: Option>, chat_service: Arc, notification_service: Option>, project: Option<&Project>, @@ -3626,6 +3710,7 @@ async fn route_agent_workspace_pr_autofix_if_needed_with_notifications( agent_run_repo, repair_repo, branch_update_repo, + chat_conversation_repo, chat_service, notification_service, project, @@ -3732,6 +3817,7 @@ async fn recheck_agent_workspace_pr_health_once( Some(Arc::clone(&state.agent_run_repo)), Some(Arc::clone(&state.agent_workspace_repair_repo)), Some(Arc::clone(&state.branch_update_repo)), + Some(Arc::clone(&state.chat_conversation_repo)), chat_service, Some(state.notification_service()), Some(&project), @@ -4256,6 +4342,7 @@ pub(crate) async fn route_ideation_plan_pr_autofix_if_needed( agent_run_repo, None, None, + None, chat_service, None, None, @@ -4276,6 +4363,7 @@ async fn route_agent_workspace_pr_autofix_for_target( agent_run_repo: Option>, repair_repo: Option>, branch_update_repo: Option>, + chat_conversation_repo: Option>, chat_service: Arc, notification_service: Option>, project: Option<&Project>, @@ -4311,7 +4399,13 @@ async fn route_agent_workspace_pr_autofix_for_target( )); } if repair_repo.is_none() - && agent_workspace_pr_autofix_repair_in_flight(&workspace, agent_run_repo.as_ref()).await? + && agent_workspace_pr_autofix_repair_in_flight( + &workspace, + workspace_repo.as_ref(), + repair_repo.as_ref(), + agent_run_repo.as_ref(), + ) + .await? { return Ok(false); } @@ -4911,6 +5005,7 @@ async fn route_agent_workspace_pr_autofix_for_target( dispatch_agent_workspace_pr_autofix( repair_repo, branch_update_repo, + chat_conversation_repo, workspace_repo, agent_run_repo, chat_service, @@ -5495,6 +5590,7 @@ struct AgentWorkspacePrAutofixDispatch<'a> { async fn dispatch_agent_workspace_pr_autofix( repair_repo: Option>, branch_update_repo: Option>, + chat_conversation_repo: Option>, workspace_repo: Arc, agent_run_repo: &Arc, chat_service: Arc, @@ -5541,6 +5637,12 @@ async fn dispatch_agent_workspace_pr_autofix( "durable PR autofix dispatch requires canonical Git target authority".to_string(), )); }; + #[cfg(not(test))] + let chat_conversation_repo = Some(chat_conversation_repo.ok_or_else(|| { + AppError::Infrastructure( + "durable PR autofix dispatch requires fixer conversation persistence".to_string(), + ) + })?); let preallocated_run_id = AgentRunId::new(); let start = start_or_join_agent_workspace_repair( Arc::clone(&repair_repo), @@ -5658,12 +5760,26 @@ async fn dispatch_agent_workspace_pr_autofix( attempt.pr_autofix_issue_kind = Some(issue_kind); let target_identity = GitService::canonical_target_identity(working_dir, &workspace.branch_name).await?; + let runtime_conversation_id = match chat_conversation_repo.as_ref() { + Some(chat_conversation_repo) => { + ensure_agent_workspace_fixer_conversation_with_repo( + chat_conversation_repo.as_ref(), + workspace, + attempt.runtime_conversation_id.as_ref(), + AgentWorkspaceFixerKind::PrFixer, + AgentWorkspaceFixerTitleContext::PullRequest(workspace.publication_pr_number), + ) + .await? + } + None => workspace.conversation_id, + }; let dispatch_attempt = match reserve_agent_workspace_repair_dispatch( Arc::clone(&repair_repo), Arc::clone(&branch_update_repo), target_identity, attempt, preallocated_run_id.clone(), + Some(runtime_conversation_id), dispatch.repair_summary, auto_merge_before_reservation, ) @@ -5771,6 +5887,7 @@ async fn dispatch_agent_workspace_pr_autofix( } }; send_options.preallocated_agent_run_id = Some(preallocated_run_id.clone()); + send_options.conversation_id_override = Some(*dispatch_attempt.runtime_conversation_id()); send_options.queue_policy = SendQueuePolicy::RequireImmediateStart; send_options.metadata = Some(pr_autofix_action_metadata(pr_number, classification)); @@ -5784,7 +5901,7 @@ async fn dispatch_agent_workspace_pr_autofix( .await; let settlement = classify_agent_workspace_repair_delivery( delivery.as_ref(), - conversation_id, + dispatch_attempt.runtime_conversation_id(), &preallocated_run_id, ); let send_result = match delivery { @@ -5899,6 +6016,7 @@ async fn dispatch_agent_workspace_pr_autofix_legacy( } }; send_options.preallocated_agent_run_id = Some(preallocated_run_id.clone()); + // Legacy claim-only route has no durable attempt to resolve child completion. send_options.queue_policy = SendQueuePolicy::RequireImmediateStart; send_options.metadata = Some(pr_autofix_action_metadata(pr_number, classification)); if let Some(pr_status) = dispatch.publication_status { @@ -5987,6 +6105,8 @@ async fn dispatch_agent_workspace_pr_autofix_legacy( async fn agent_workspace_pr_autofix_repair_in_flight( workspace: &AgentConversationWorkspace, + workspace_repo: &dyn AgentConversationWorkspaceRepository, + repair_repo: Option<&Arc>, agent_run_repo: Option<&Arc>, ) -> crate::AppResult { if workspace.publication_push_status.as_deref() == Some("needs_agent") @@ -6004,18 +6124,53 @@ async fn agent_workspace_pr_autofix_repair_in_flight( let Some(agent_run_repo) = agent_run_repo else { return Ok(true); }; - return Ok(agent_run_repo - .get_active_for_conversation(&workspace.conversation_id) - .await? - .is_some()); + return any_agent_workspace_fixer_runtime_is_active( + workspace, + workspace_repo, + repair_repo, + agent_run_repo.as_ref(), + ) + .await; } let Some(agent_run_repo) = agent_run_repo else { return Ok(false); }; - Ok(agent_run_repo - .get_active_for_conversation(&workspace.conversation_id) - .await? - .is_some()) + any_agent_workspace_fixer_runtime_is_active( + workspace, + workspace_repo, + repair_repo, + agent_run_repo.as_ref(), + ) + .await +} + +async fn any_agent_workspace_fixer_runtime_is_active( + workspace: &AgentConversationWorkspace, + workspace_repo: &dyn AgentConversationWorkspaceRepository, + repair_repo: Option<&Arc>, + agent_run_repo: &dyn AgentRunRepository, +) -> crate::AppResult { + let conversations = match repair_repo { + Some(repair_repo) => { + agent_workspace_fixer_runtime_conversations( + workspace, + workspace_repo, + repair_repo.as_ref(), + ) + .await? + } + None => vec![workspace.conversation_id], + }; + for conversation_id in conversations { + if agent_run_repo + .get_active_for_conversation(&conversation_id) + .await? + .is_some() + { + return Ok(true); + } + } + Ok(false) } /// Shared by the poller's first dispatch and by durable redelivery so a recovered PR autofix keeps @@ -6858,6 +7013,7 @@ async fn route_agent_workspace_review_feedback_if_present( agent_run_repo, None, None, + None, chat_service, None, ) @@ -6874,6 +7030,7 @@ async fn route_agent_workspace_review_feedback_if_present_with_repair_repo( agent_run_repo: Option>, repair_repo: Option>, branch_update_repo: Option>, + chat_conversation_repo: Option>, chat_service: Arc, polled_health: Option<&PrHealth>, ) -> crate::AppResult { @@ -7013,6 +7170,7 @@ async fn route_agent_workspace_review_feedback_if_present_with_repair_repo( dispatch_agent_workspace_pr_autofix( repair_repo, branch_update_repo, + chat_conversation_repo, workspace_repo, agent_run_repo, chat_service, diff --git a/src-tauri/src/application/services/pr_merge_poller_tests.rs b/src-tauri/src/application/services/pr_merge_poller_tests.rs index 8878b3f167..59a95eb5dc 100644 --- a/src-tauri/src/application/services/pr_merge_poller_tests.rs +++ b/src-tauri/src/application/services/pr_merge_poller_tests.rs @@ -80,7 +80,8 @@ use crate::domain::services::GithubServiceTrait; use crate::error::{AppError, AppResult}; use crate::infrastructure::memory::{ MemoryAgentConversationWorkspaceRepository, MemoryAgentRunRepository, - MemoryBranchUpdateRepository, MemoryNotificationRepository, MemoryPlanBranchRepository, + MemoryBranchUpdateRepository, MemoryChatConversationRepository, MemoryNotificationRepository, + MemoryPlanBranchRepository, }; use crate::tests::mock_github_service::MockGithubService; @@ -453,6 +454,15 @@ impl RejectPostPushBaseTargetCheckpointRepo { #[async_trait] impl AgentWorkspaceRepairRepository for RejectPostPushBaseTargetCheckpointRepo { + async fn get_unsettled_attempt_by_runtime_conversation( + &self, + runtime_conversation_id: &ChatConversationId, + ) -> AppResult> { + self.inner + .get_unsettled_attempt_by_runtime_conversation(runtime_conversation_id) + .await + } + async fn get_current_repair_attempt( &self, conversation_id: &ChatConversationId, @@ -731,6 +741,7 @@ async fn held_manual_unpublished_redrive_noop_falls_through_and_retains_the_hold Some(Arc::clone(&state.agent_run_repo)), Some(Arc::clone(&state.agent_workspace_repair_repo)), Some(branch_update_repo), + None, chat.clone() as Arc, None, ) @@ -794,6 +805,7 @@ async fn held_unpublished_redrive_noop_falls_through_to_base_advanced_supersessi Some(agent_run_repo), Some(Arc::clone(&state.agent_workspace_repair_repo)), Some(branch_update_repo), + None, chat.clone() as Arc, None, ) @@ -1999,6 +2011,7 @@ async fn repair_dispatch_remains_completable_when_success_event_persistence_fail assert_eq!(current.pr_supervision_status.as_deref(), Some("fixing")); assert!(current_agent_workspace_repair_claim_for_completion( workspace_repo, + concrete_workspace_repo.clone() as Arc, run_repo, ¤t, ) @@ -6302,6 +6315,7 @@ async fn missing_repair_repository_rejects_pr_conflict_without_side_effects() { None, None, Some(Arc::new(MemoryBranchUpdateRepository::new())), + None, chat.clone() as Arc, ) .await @@ -6370,6 +6384,7 @@ async fn busy_pr_conflict_repair_does_not_disable_auto_merge_or_send_a_worker() }); let github = Arc::new(MockGithubService::new()); let chat = Arc::new(MockChatService::new()); + let chat_conversation_repo = Arc::new(MemoryChatConversationRepository::new()); let error = super::route_agent_workspace_pr_conflict_repair_if_needed_with_repair_repo( Arc::clone(&github) as Arc, @@ -6381,6 +6396,7 @@ async fn busy_pr_conflict_repair_does_not_disable_auto_merge_or_send_a_worker() None, Some(repair_repo), Some(branch_update_repo), + Some(chat_conversation_repo), chat.clone() as Arc, ) .await @@ -6444,6 +6460,7 @@ async fn live_pr_conflict_repair_repo_route_preserves_durable_authority_on_stale Arc::new(MemoryBranchUpdateRepository::new()); let agent_run_repo = seeded_latest_pr_fixer_run_repo(&conversation_id).await; let github = Arc::new(MockGithubService::new()); + let chat_conversation_repo = Arc::new(MemoryChatConversationRepository::new()); let chat = Arc::new(MockChatService::with_agent_run_repo(Arc::clone( &agent_run_repo, ))); @@ -6462,6 +6479,7 @@ async fn live_pr_conflict_repair_repo_route_preserves_durable_authority_on_stale Some(agent_run_repo.clone()), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + Some(chat_conversation_repo.clone()), chat.clone() as Arc, ) .await @@ -6484,6 +6502,16 @@ async fn live_pr_conflict_repair_repo_route_preserves_durable_authority_on_stale AgentWorkspaceRepairContinuation::ResumePrSupervision ); assert_eq!(first.phase, AgentWorkspaceRepairPhase::Repairing); + let runtime_conversation_id = first + .runtime_conversation_id + .as_ref() + .expect("PR conflict dispatch must persist its fixer child"); + assert_ne!(runtime_conversation_id, &conversation_id); + assert_eq!( + chat.get_sent_options().await[0].conversation_id_override, + Some(*runtime_conversation_id), + "the delivered run and durable attempt must use the same fixer child" + ); assert_eq!(first.target_base_ref, "main"); assert_eq!( first.target_base_commit.as_deref(), @@ -6512,6 +6540,7 @@ async fn live_pr_conflict_repair_repo_route_preserves_durable_authority_on_stale Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + Some(chat_conversation_repo), chat.clone() as Arc, ) .await @@ -6638,6 +6667,7 @@ async fn conflict_router_defers_unpublished_repair_head_without_join_or_agent_in None, Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat.clone() as Arc, ) .await @@ -6703,6 +6733,7 @@ async fn dispatched_pr_autofix_issue_kind( Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat.clone() as Arc, None, ) @@ -6798,6 +6829,7 @@ async fn live_pr_autofix_suppresses_same_fingerprint_while_ci_rerun_is_pending() Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat.clone() as Arc, None, ) @@ -6867,6 +6899,7 @@ async fn legacy_ci_rerun_fingerprint_settles_instead_of_hanging() { Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat.clone() as Arc, None, ) @@ -6941,6 +6974,7 @@ async fn ci_rerun_hold_settles_once_reran_runs_are_terminal() { Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat.clone() as Arc, None, ) @@ -7014,6 +7048,7 @@ async fn ci_await_hold_suppresses_dispatch_and_survives_unchanged_classification Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat.clone() as Arc, None, ) @@ -7080,6 +7115,7 @@ async fn ci_hold_settles_when_head_moves() { Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat.clone() as Arc, None, ) @@ -7158,6 +7194,7 @@ async fn unrelated_conversation_dispatch_does_not_settle_a_ci_hold() { Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat as Arc, None, ) @@ -7221,6 +7258,7 @@ async fn route_with_base_conclusions( let github = Arc::new(MockGithubService::new()); github.state().fetch_pr_health_result = Some(Ok(health)); github.state().list_branch_check_conclusions_result = Some(base_conclusions); + let chat_conversation_repo = Arc::new(MemoryChatConversationRepository::new()); let chat = Arc::new(MockChatService::with_agent_run_repo(Arc::clone( &agent_run_repo, ))); @@ -7234,6 +7272,7 @@ async fn route_with_base_conclusions( Some(agent_run_repo), Some(repair_repo), Some(branch_update_repo), + Some(chat_conversation_repo), chat.clone() as Arc, None, ) @@ -7312,6 +7351,20 @@ async fn failure_absent_from_base_still_dispatches_a_fixer() { assert!(routed, "a check absent from base proves nothing about base"); assert_eq!(chat.get_sent_messages().await.len(), 1); + let attempt = workspace_repo + .get_current_repair_attempt(&conversation_id) + .await + .expect("repair attempt should load") + .expect("PR autofix dispatch must persist an attempt"); + let runtime_conversation_id = attempt + .runtime_conversation_id + .expect("PR autofix dispatch must persist its fixer child"); + assert_ne!(runtime_conversation_id, conversation_id); + assert_eq!( + chat.get_sent_options().await[0].conversation_id_override, + Some(runtime_conversation_id), + "the delivered PR fixer run and durable attempt must share the child" + ); assert!(!workspace_repo .list_publication_events(&conversation_id) .await @@ -7413,6 +7466,7 @@ async fn exhausted_streak_fingerprint_suppresses_a_fresh_streak_until_health_cha Some(agent_run_repo.clone()), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + None, chat.clone() as Arc, None, ) @@ -7451,6 +7505,7 @@ async fn exhausted_streak_fingerprint_suppresses_a_fresh_streak_until_health_cha Some(agent_run_repo.clone()), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + None, chat.clone() as Arc, None, ) @@ -7488,6 +7543,7 @@ async fn exhausted_streak_fingerprint_suppresses_a_fresh_streak_until_health_cha Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat.clone() as Arc, None, ) @@ -7565,6 +7621,7 @@ async fn live_pr_autofix_unchanged_health_hold_suppresses_same_fingerprint_then_ Some(agent_run_repo.clone()), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + None, chat.clone() as Arc, None, ) @@ -7603,6 +7660,7 @@ async fn live_pr_autofix_unchanged_health_hold_suppresses_same_fingerprint_then_ Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat.clone() as Arc, None, ) @@ -7695,6 +7753,7 @@ async fn live_pr_autofix_new_base_evidence_supersedes_same_fingerprint_health_ho Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat.clone() as Arc, None, ) @@ -7802,6 +7861,7 @@ async fn live_pr_autofix_behind_at_already_updated_tip_enters_base_stale_hold() Some(agent_run_repo.clone()), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + None, chat.clone() as Arc, None, ) @@ -7832,6 +7892,7 @@ async fn live_pr_autofix_behind_at_already_updated_tip_enters_base_stale_hold() Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat as Arc, None, ) @@ -7924,6 +7985,7 @@ async fn live_pr_autofix_ci_hold_base_stale_marker_clears_once_when_no_longer_be Some(agent_run_repo.clone()), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + None, chat.clone() as Arc, None, ) @@ -7957,6 +8019,7 @@ async fn live_pr_autofix_ci_hold_base_stale_marker_clears_once_when_no_longer_be Some(agent_run_repo.clone()), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + None, chat.clone() as Arc, None, ) @@ -7993,6 +8056,7 @@ async fn live_pr_autofix_ci_hold_base_stale_marker_clears_once_when_no_longer_be Some(agent_run_repo.clone()), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + None, chat.clone() as Arc, None, ) @@ -8019,6 +8083,7 @@ async fn live_pr_autofix_ci_hold_base_stale_marker_clears_once_when_no_longer_be Some(agent_run_repo.clone()), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + None, chat.clone() as Arc, None, ) @@ -8056,6 +8121,7 @@ async fn live_pr_autofix_ci_hold_base_stale_marker_clears_once_when_no_longer_be Some(agent_run_repo.clone()), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + None, chat.clone() as Arc, None, ) @@ -8085,6 +8151,7 @@ async fn live_pr_autofix_ci_hold_base_stale_marker_clears_once_when_no_longer_be Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat.clone() as Arc, None, ) @@ -8234,6 +8301,7 @@ async fn live_pr_autofix_ci_rerun_hold_behind_base_dirty_worktree_defers_without Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat.clone() as Arc, None, Some(&project), @@ -8428,6 +8496,7 @@ async fn live_pr_autofix_advanced_base_and_behind_updates_advanced_tip_first() { Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat.clone() as Arc, None, Some(&project), @@ -8623,6 +8692,7 @@ async fn live_pr_autofix_ci_rerun_hold_behind_base_updates_before_waiting() { Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat.clone() as Arc, None, Some(&project), @@ -8790,6 +8860,7 @@ async fn live_pr_autofix_behind_base_post_push_marker_rejection_recovers_from_ne Some(agent_run_repo.clone()), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + None, chat.clone() as Arc, None, Some(&project), @@ -8839,6 +8910,7 @@ async fn live_pr_autofix_behind_base_post_push_marker_rejection_recovers_from_ne Some(agent_run_repo), Some(repair_repo), Some(branch_update_repo), + None, chat.clone() as Arc, None, Some(&project), @@ -8966,6 +9038,7 @@ async fn live_pr_autofix_behind_base_with_foreign_target_lease_has_no_effects() Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat.clone() as Arc, None, Some(&project), @@ -9055,6 +9128,7 @@ async fn live_pr_autofix_pre_existing_on_base_suppresses_same_fingerprint_then_r Some(agent_run_repo.clone()), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + None, chat.clone() as Arc, None, ) @@ -9089,6 +9163,7 @@ async fn live_pr_autofix_pre_existing_on_base_suppresses_same_fingerprint_then_r Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat.clone() as Arc, None, ) @@ -9158,6 +9233,7 @@ async fn live_pr_autofix_repair_repo_route_deduplicates_concurrent_dispatches() Some(agent_run_repo.clone()), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + None, chat.clone() as Arc, None, ), @@ -9170,6 +9246,7 @@ async fn live_pr_autofix_repair_repo_route_deduplicates_concurrent_dispatches() Some(agent_run_repo.clone()), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + None, chat.clone() as Arc, None, ) @@ -9303,6 +9380,7 @@ async fn live_pr_autofix_repair_routed_signal_records_once_for_existing_attempt( Some(agent_run_repo.clone()), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + None, chat.clone() as Arc, None, ) @@ -9341,6 +9419,7 @@ async fn live_pr_autofix_repair_routed_signal_records_once_for_existing_attempt( Some(agent_run_repo.clone()), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + None, chat.clone() as Arc, None, ) @@ -9520,6 +9599,7 @@ async fn live_review_feedback_repair_repo_route_keeps_existing_continuation_auth Some(agent_run_repo.clone()), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + None, chat.clone() as Arc, None, ) @@ -9563,6 +9643,7 @@ async fn live_review_feedback_repair_repo_route_keeps_existing_continuation_auth Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(Arc::clone(&branch_update_repo)), + None, chat.clone() as Arc, None, ) @@ -10000,22 +10081,51 @@ async fn terminal_agent_workspace_pr_terminalization_stops_active_project_run() let branch = expected_workspace_branch(&project, conversation_id_str); let workspace = cleanup_workspace_with_conversation(&project, &branch, conversation_id_str); let conversation_id = workspace.conversation_id.clone(); + let concrete_workspace_repo = Arc::new(MemoryAgentConversationWorkspaceRepository::new()); let workspace_repo: Arc = - Arc::new(MemoryAgentConversationWorkspaceRepository::new()); + concrete_workspace_repo.clone(); + let repair_repo: Arc = concrete_workspace_repo; workspace_repo .create_or_update(workspace) .await .expect("workspace should persist"); + let runtime_conversation_id = + ChatConversationId::from_string("poller-terminal-active-run-fixer-conversation"); + let mut attempt = AgentWorkspaceRepairAttempt::new( + conversation_id.clone(), + AgentWorkspaceRepairSource::PrAutofix, + AgentWorkspaceRepairContinuation::ResumePrSupervision, + "main", + false, + true, + true, + None, + Utc::now(), + ); + attempt.runtime_conversation_id = Some(runtime_conversation_id); + repair_repo + .start_or_join_repair_attempt( + crate::domain::repositories::StartOrJoinAgentWorkspaceRepairAttempt { + attempt, + reason: "active fixer cleanup proof".to_string(), + verified_newer_base: false, + compatibility_projection: None, + events: Vec::new(), + }, + ) + .await + .expect("repair attempt should persist"); let agent_run_repo = Arc::new(MemoryAgentRunRepository::new()); let run = agent_run_repo - .create(AgentRun::new(conversation_id.clone())) + .create(AgentRun::new(runtime_conversation_id)) .await .expect("active run should persist"); let chat = Arc::new(MockChatService::new()); terminalize_agent_workspace_after_pr( Arc::clone(&workspace_repo), + repair_repo, Arc::clone(&agent_run_repo) as Arc, None, Some(Arc::clone(&chat) as Arc), @@ -10027,7 +10137,7 @@ async fn terminal_agent_workspace_pr_terminalization_stops_active_project_run() assert_eq!( chat.get_stop_agent_calls().await, - vec![(ChatContextType::Project, conversation_id.as_str())] + vec![(ChatContextType::Project, runtime_conversation_id.as_str())] ); let updated_run = agent_run_repo .get_by_id(&run.id) @@ -10050,8 +10160,10 @@ async fn terminal_agent_workspace_pr_poller_retries_runtime_shutdown_before_retu let branch = expected_workspace_branch(&project, conversation_id_str); let workspace = cleanup_workspace_with_conversation(&project, &branch, conversation_id_str); let conversation_id = workspace.conversation_id.clone(); + let concrete_workspace_repo = Arc::new(MemoryAgentConversationWorkspaceRepository::new()); let workspace_repo: Arc = - Arc::new(MemoryAgentConversationWorkspaceRepository::new()); + concrete_workspace_repo.clone(); + let repair_repo: Arc = concrete_workspace_repo; workspace_repo .create_or_update(workspace) .await @@ -10072,6 +10184,7 @@ async fn terminal_agent_workspace_pr_poller_retries_runtime_shutdown_before_retu super::terminalize_polled_agent_workspace( &workspace_repo, + &repair_repo, &agent_run_repo_dyn, &plan_branch_repo_dyn, &chat_dyn, @@ -10110,7 +10223,9 @@ async fn terminal_agent_workspace_pr_poller_retries_authority_persistence_before .await .expect("workspace should persist"); concrete_workspace_repo.fail_next_publication_update("authority unavailable"); - let workspace_repo: Arc = concrete_workspace_repo; + let workspace_repo: Arc = + concrete_workspace_repo.clone(); + let repair_repo: Arc = concrete_workspace_repo; let agent_run_repo: Arc = Arc::new(MemoryAgentRunRepository::new()); let plan_branch_repo: Arc = Arc::new(MemoryPlanBranchRepository::new()); @@ -10120,6 +10235,7 @@ async fn terminal_agent_workspace_pr_poller_retries_authority_persistence_before super::terminalize_polled_agent_workspace( &workspace_repo, + &repair_repo, &agent_run_repo, &plan_branch_repo, &chat_dyn, @@ -10160,8 +10276,10 @@ async fn mismatched_polled_pr_terminalization_skips_publication_and_runtime_clea workspace.publication_pr_status = Some("open".to_string()); workspace.publication_push_status = Some("pushed".to_string()); let conversation_id = workspace.conversation_id.clone(); + let concrete_workspace_repo = Arc::new(MemoryAgentConversationWorkspaceRepository::new()); let workspace_repo: Arc = - Arc::new(MemoryAgentConversationWorkspaceRepository::new()); + concrete_workspace_repo.clone(); + let repair_repo: Arc = concrete_workspace_repo; workspace_repo .create_or_update(workspace.clone()) .await @@ -10180,6 +10298,7 @@ async fn mismatched_polled_pr_terminalization_skips_publication_and_runtime_clea super::terminalize_polled_agent_workspace( &workspace_repo, + &repair_repo, &agent_run_repo, &plan_branch_repo, &chat_dyn, @@ -10463,6 +10582,7 @@ async fn agent_workspace_closed_pr_polling_removes_worktree_and_branch() { let memory_workspace_repo = Arc::new(MemoryAgentConversationWorkspaceRepository::new()); let workspace_repo: Arc = memory_workspace_repo.clone(); + let repair_repo: Arc = memory_workspace_repo.clone(); workspace_repo .create_or_update(workspace) .await @@ -10481,13 +10601,14 @@ async fn agent_workspace_closed_pr_polling_removes_worktree_and_branch() { Arc::new(MemoryPlanBranchRepository::new()), ); - registry.start_agent_workspace_polling( + registry.start_agent_workspace_polling_with_repair_repo( conversation_id.clone(), 101, project, repo.path().to_path_buf(), Arc::clone(&workspace_repo), Arc::new(MemoryAgentRunRepository::new()), + repair_repo, Arc::new(MockChatService::new()), ); tokio::time::timeout(Duration::from_secs(20), async { @@ -11211,6 +11332,13 @@ struct LookupErrorRepairRepository; #[async_trait] impl AgentWorkspaceRepairRepository for LookupErrorRepairRepository { + async fn get_unsettled_attempt_by_runtime_conversation( + &self, + _runtime_conversation_id: &ChatConversationId, + ) -> AppResult> { + unreachable!() + } + async fn get_current_repair_attempt( &self, _conversation_id: &ChatConversationId, @@ -11346,6 +11474,15 @@ impl RaceEvidenceRearmCheckpointRepo { #[async_trait] impl AgentWorkspaceRepairRepository for RaceEvidenceRearmCheckpointRepo { + async fn get_unsettled_attempt_by_runtime_conversation( + &self, + runtime_conversation_id: &ChatConversationId, + ) -> AppResult> { + self.inner + .get_unsettled_attempt_by_runtime_conversation(runtime_conversation_id) + .await + } + async fn get_current_repair_attempt( &self, conversation_id: &ChatConversationId, @@ -12725,6 +12862,7 @@ async fn base_parity_transient_shape_repeat_poll_short_circuits_then_reenters_on Some(agent_run_repo), Some(Arc::clone(&repair_repo)), Some(branch_update_repo), + None, chat_second.clone() as Arc, Some(&health_a), ) @@ -13551,6 +13689,7 @@ async fn route_blocked_supersession( Some(agent_run_repo), Some(Arc::clone(&fixture.repair_repo)), Some(Arc::clone(&fixture.branch_update_repo)), + None, chat.clone() as Arc, None, Some(&fixture.project), @@ -14149,9 +14288,12 @@ async fn blocked_needs_human_push_failure_keeps_phase_blocked() { let agent_run_repo = seeded_latest_pr_fixer_run_repo(&fixture.conversation_id).await; let github = Arc::new(MockGithubService::new()); github.state().fetch_pr_health_result = Some(Ok(health)); - github.state().push_branch_result = - Some(Err(AppError::GitOperation("simulated push failure".to_string()))); - let chat = Arc::new(MockChatService::with_agent_run_repo(Arc::clone(&agent_run_repo))); + github.state().push_branch_result = Some(Err(AppError::GitOperation( + "simulated push failure".to_string(), + ))); + let chat = Arc::new(MockChatService::with_agent_run_repo(Arc::clone( + &agent_run_repo, + ))); super::route_agent_workspace_pr_autofix_if_needed_with_notifications( Arc::clone(&github) as Arc, @@ -14162,6 +14304,7 @@ async fn blocked_needs_human_push_failure_keeps_phase_blocked() { Some(agent_run_repo), Some(Arc::clone(&fixture.repair_repo)), Some(Arc::clone(&fixture.branch_update_repo)), + None, chat.clone() as Arc, None, Some(&fixture.project), @@ -14210,10 +14353,7 @@ async fn blocked_needs_human_already_fresh_keeps_phase_blocked() { blocked_needs_human_supersession_fixture("blocked-supersede-already-fresh", None, true) .await; // Merge main into the workspace branch locally so the branch is already up-to-date. - run_git( - &fixture.worktree, - &["merge", "--no-edit", "origin/main"], - ); + run_git(&fixture.worktree, &["merge", "--no-edit", "origin/main"]); let health = behind_base_health(&fixture.dispatch_head, &fixture.observed_base_oid); let (_routed, github, _chat) = route_blocked_supersession(&fixture, health).await; @@ -14382,12 +14522,9 @@ async fn blocked_needs_human_with_a_health_hold_is_never_settled() { /// promoting to `Ready` and clearing `blocker` with no head-scoped justification. #[tokio::test] async fn blocked_needs_human_already_updated_to_the_tip_keeps_its_blocker_when_ci_held() { - let fixture = blocked_needs_human_supersession_fixture( - "blocked-ci-held-already-updated", - None, - true, - ) - .await; + let fixture = + blocked_needs_human_supersession_fixture("blocked-ci-held-already-updated", None, true) + .await; let mut with_ci_and_tip = fixture.attempt.clone(); with_ci_and_tip.ci_rerun_count = 1; with_ci_and_tip.ci_rerun_fingerprint = Some("old-head:12345".to_string()); @@ -14448,12 +14585,8 @@ async fn blocked_needs_human_already_updated_to_the_tip_keeps_its_blocker_when_c /// silently promoted to Ready. #[tokio::test] async fn blocked_needs_human_with_a_base_stale_marker_is_left_untouched() { - let fixture = blocked_needs_human_supersession_fixture( - "blocked-base-stale-marker", - None, - true, - ) - .await; + let fixture = + blocked_needs_human_supersession_fixture("blocked-base-stale-marker", None, true).await; let mut with_both_markers = fixture.attempt.clone(); with_both_markers.pending_reasons.push( crate::application::agent_workspace_publish_repair_state::BASE_STALE_AFTER_UPDATE_REPAIR_REASON @@ -14481,7 +14614,13 @@ async fn blocked_needs_human_with_a_base_stale_marker_is_left_untouched() { // release predicate's guards would all pass except for the new !blocked_base_staleness_candidate. let mut health = open_pr_health(&fixture.dispatch_head); health.sync_state.merge_state_status = Some(PrMergeStateStatus::Clean); - health.sync_state.base_ref_oid = Some(fixture.attempt.target_base_commit.clone().unwrap_or_default()); + health.sync_state.base_ref_oid = Some( + fixture + .attempt + .target_base_commit + .clone() + .unwrap_or_default(), + ); let (_routed, _github, _chat) = route_blocked_supersession(&fixture, health).await; @@ -14543,13 +14682,19 @@ async fn blocked_needs_human_deferred_merge_dispatches_successor() { run_git(&fixture.worktree, &["commit", "-m", "main conflict"]); run_git(&fixture.worktree, &["push", "origin", "main"]); let new_base_oid = git_stdout(&fixture.worktree, &["rev-parse", "main"]); - run_git(&fixture.worktree, &["checkout", &fixture.workspace.branch_name]); + run_git( + &fixture.worktree, + &["checkout", &fixture.workspace.branch_name], + ); // Create a conflicting change in the workspace branch. std::fs::write(fixture.worktree.join("CONFLICT.md"), "branch conflict\n") .expect("write conflict file on branch"); run_git(&fixture.worktree, &["add", "."]); run_git(&fixture.worktree, &["commit", "-m", "branch conflict"]); - run_git(&fixture.worktree, &["push", "origin", &fixture.workspace.branch_name]); + run_git( + &fixture.worktree, + &["push", "origin", &fixture.workspace.branch_name], + ); let health = behind_base_health(&fixture.dispatch_head, &new_base_oid); diff --git a/src-tauri/src/commands/agent_workspace_repair_reconciliation_scan_tests.rs b/src-tauri/src/commands/agent_workspace_repair_reconciliation_scan_tests.rs index d1eb1b2002..d72746c570 100644 --- a/src-tauri/src/commands/agent_workspace_repair_reconciliation_scan_tests.rs +++ b/src-tauri/src/commands/agent_workspace_repair_reconciliation_scan_tests.rs @@ -852,6 +852,15 @@ impl ListingErrorRepairRepository { #[async_trait] impl AgentWorkspaceRepairRepository for ListingErrorRepairRepository { + async fn get_unsettled_attempt_by_runtime_conversation( + &self, + runtime_conversation_id: &ChatConversationId, + ) -> AppResult> { + self.inner + .get_unsettled_attempt_by_runtime_conversation(runtime_conversation_id) + .await + } + async fn get_current_repair_attempt( &self, conversation_id: &ChatConversationId, diff --git a/src-tauri/src/commands/unified_chat_commands/mod.rs b/src-tauri/src/commands/unified_chat_commands/mod.rs index d799a154e8..54e5bb59ff 100644 --- a/src-tauri/src/commands/unified_chat_commands/mod.rs +++ b/src-tauri/src/commands/unified_chat_commands/mod.rs @@ -80,6 +80,10 @@ use crate::application::agent_workspace_external_pr_reconciliation::{ schedule_agent_workspace_external_pr_reconciliation_with_lazy_deps, AgentWorkspaceExternalPrReconciliationDeps, AgentWorkspaceExternalPrReconciliationTrigger, }; +use crate::application::agent_workspace_fixer_conversation::{ + ensure_agent_workspace_fixer_conversation, AgentWorkspaceFixerKind, + AgentWorkspaceFixerTitleContext, +}; use crate::application::agent_workspace_local_commit::{ commit_agent_workspace_locally, AgentWorkspaceLocalCommitRequest, }; @@ -10426,6 +10430,7 @@ pub async fn send_agent_workspace_publish_repair_message( workspace: &AgentConversationWorkspace, error: &str, runtime_overrides: AgentWorkspaceRepairRuntimeOverrides, + runtime_conversation_id: &ChatConversationId, ) -> Result where S: ChatService + ?Sized, @@ -10436,6 +10441,7 @@ where error, runtime_overrides, &AgentConversationWorkspaceRepairTarget::from_workspace(workspace), + runtime_conversation_id, ) .await } @@ -10447,6 +10453,7 @@ pub async fn send_agent_workspace_publish_repair_message_for_target( error: &str, runtime_overrides: AgentWorkspaceRepairRuntimeOverrides, target: &AgentConversationWorkspaceRepairTarget, + runtime_conversation_id: &ChatConversationId, ) -> Result where S: ChatService + ?Sized, @@ -10459,6 +10466,7 @@ where target, AgentWorkspacePostRepairAction::Publish, None, + runtime_conversation_id, ) .await } @@ -10471,6 +10479,7 @@ async fn send_agent_workspace_repair_message_for_target( target: &AgentConversationWorkspaceRepairTarget, post_repair_action: AgentWorkspacePostRepairAction, preallocated_agent_run_id: Option, + runtime_conversation_id: &ChatConversationId, ) -> Result where S: ChatService + ?Sized, @@ -10488,7 +10497,7 @@ where SendMessageOptions { preallocated_agent_run_id, queue_policy: SendQueuePolicy::RequireImmediateStart, - conversation_id_override: Some(workspace.conversation_id), + conversation_id_override: Some(*runtime_conversation_id), agent_name_override: Some(AGENT_WORKSPACE_REPAIR.to_string()), harness_override: runtime_overrides.harness, model_override: runtime_overrides.model, @@ -11182,6 +11191,25 @@ async fn mark_agent_workspace_failure_with_routing_and_action_classified( return; } }; + let runtime_conversation_id = match ensure_agent_workspace_fixer_conversation( + state, + workspace, + attempt.runtime_conversation_id.as_ref(), + AgentWorkspaceFixerKind::WorkspaceRepair, + AgentWorkspaceFixerTitleContext::Repair(attempt.source), + ) + .await + { + Ok(conversation_id) => conversation_id, + Err(error) => { + tracing::warn!( + conversation_id = %workspace.conversation_id, + error = %error, + "Failed to create workspace repair child conversation before dispatch" + ); + return; + } + }; let runtime_overrides = AgentWorkspaceRepairRuntimeOverrides::default(); let execution_state = repair_service.runtime_execution_state(); if should_defer_agent_workspace_repair_message(state, execution_state.as_ref(), workspace).await @@ -11193,6 +11221,7 @@ async fn mark_agent_workspace_failure_with_routing_and_action_classified( dispatch_target.clone(), attempt, repair_run_id.clone(), + Some(runtime_conversation_id), post_repair_action.repair_requested_summary(), workspace.pr_auto_merge_current, ) @@ -11236,6 +11265,7 @@ async fn mark_agent_workspace_failure_with_routing_and_action_classified( dispatch_target, attempt, repair_run_id.clone(), + Some(runtime_conversation_id), post_repair_action.repair_requested_summary(), workspace.pr_auto_merge_current, ) @@ -11266,22 +11296,26 @@ async fn mark_agent_workspace_failure_with_routing_and_action_classified( target, post_repair_action, Some(repair_run_id.clone()), + dispatch.runtime_conversation_id(), ) .await { Ok(result) => { - if let Some(authority_error) = - repair_dispatch_authority_error(&result, &workspace.conversation_id, &repair_run_id) - { + if let Some(authority_error) = repair_dispatch_authority_error( + &result, + dispatch.runtime_conversation_id(), + &repair_run_id, + ) { let repair_summary = post_repair_action.repair_send_failed_summary(&authority_error); + let runtime_conv_id = *dispatch.runtime_conversation_id(); settle_agent_workspace_repair_dispatch_failure( state, dispatch, &repair_summary, classify_agent_workspace_repair_delivery( Ok(&result), - &workspace.conversation_id, + &runtime_conv_id, &repair_run_id, ), ) @@ -11303,13 +11337,14 @@ async fn mark_agent_workspace_failure_with_routing_and_action_classified( ); let repair_summary = post_repair_action.repair_send_failed_summary(&repair_error.to_string()); + let runtime_conv_id = dispatch.runtime_conversation_id().clone(); settle_agent_workspace_repair_dispatch_failure( state, dispatch, &repair_summary, classify_agent_workspace_repair_delivery( Err(&repair_error), - &workspace.conversation_id, + &runtime_conv_id, &repair_run_id, ), ) @@ -11481,22 +11516,26 @@ async fn spawn_deferred_agent_workspace_repair_message( &target, post_repair_action, Some(repair_run_id.clone()), + dispatch.runtime_conversation_id(), ) .await { Ok(result) => { - if let Some(authority_error) = - repair_dispatch_authority_error(&result, &conversation_id, &repair_run_id) - { + if let Some(authority_error) = repair_dispatch_authority_error( + &result, + dispatch.runtime_conversation_id(), + &repair_run_id, + ) { let repair_summary = post_repair_action.repair_send_failed_summary(&authority_error); + let runtime_conv_id = dispatch.runtime_conversation_id().clone(); settle_agent_workspace_repair_dispatch_failure( &state, dispatch, &repair_summary, classify_agent_workspace_repair_delivery( Ok(&result), - &conversation_id, + &runtime_conv_id, &repair_run_id, ), ) @@ -11518,13 +11557,14 @@ async fn spawn_deferred_agent_workspace_repair_message( ); let repair_summary = post_repair_action.repair_send_failed_summary(&repair_error.to_string()); + let runtime_conv_id = dispatch.runtime_conversation_id().clone(); settle_agent_workspace_repair_dispatch_failure( &state, dispatch, &repair_summary, classify_agent_workspace_repair_delivery( Err(&repair_error), - &conversation_id, + &runtime_conv_id, &repair_run_id, ), ) diff --git a/src-tauri/src/commands/unified_chat_commands/tests.rs b/src-tauri/src/commands/unified_chat_commands/tests.rs index ec0e25ae36..6093b9c75d 100644 --- a/src-tauri/src/commands/unified_chat_commands/tests.rs +++ b/src-tauri/src/commands/unified_chat_commands/tests.rs @@ -2131,6 +2131,7 @@ async fn publish_repair_message_routes_spawn_to_effective_target_worktree() { "merge conflict", AgentWorkspaceRepairRuntimeOverrides::default(), &target, + &workspace.conversation_id, ) .await .expect("repair message should send"); diff --git a/src-tauri/src/http_server/handlers/agent_workspaces/mod.rs b/src-tauri/src/http_server/handlers/agent_workspaces/mod.rs index 3847ceb394..eec2344a77 100644 --- a/src-tauri/src/http_server/handlers/agent_workspaces/mod.rs +++ b/src-tauri/src/http_server/handlers/agent_workspaces/mod.rs @@ -871,6 +871,8 @@ pub struct AgentWorkspaceReviewContextResponse { pub events: Vec, pub target: Option, pub monitor: AgentWorkspaceReviewMonitorResponse, + pub repair_runtime_conversation_id: Option, + pub repair_fixer_kind: Option<&'static str>, pub goal_context: AgentWorkspaceReviewGoalContext, pub is_current: bool, pub is_outdated: bool, diff --git a/src-tauri/src/http_server/handlers/agent_workspaces/pr_review/helpers.rs b/src-tauri/src/http_server/handlers/agent_workspaces/pr_review/helpers.rs index 99e0e14198..f30ce4a064 100644 --- a/src-tauri/src/http_server/handlers/agent_workspaces/pr_review/helpers.rs +++ b/src-tauri/src/http_server/handlers/agent_workspaces/pr_review/helpers.rs @@ -114,6 +114,7 @@ pub(in crate::http_server::handlers::agent_workspaces) async fn reconcile_termin Arc::new(state.build_chat_service()); let outcome = crate::application::agent_workspace_terminal_cleanup::settle_review_pr_terminal_observation( Arc::clone(&state.agent_conversation_workspace_repo), + Arc::clone(&state.agent_workspace_repair_repo), Arc::clone(&state.agent_run_repo), Some(Arc::clone(&state.plan_branch_repo)), Some(chat_service), diff --git a/src-tauri/src/http_server/handlers/agent_workspaces/repair_completion.rs b/src-tauri/src/http_server/handlers/agent_workspaces/repair_completion.rs index 54f49e87fe..e20ae67bb2 100644 --- a/src-tauri/src/http_server/handlers/agent_workspaces/repair_completion.rs +++ b/src-tauri/src/http_server/handlers/agent_workspaces/repair_completion.rs @@ -340,7 +340,8 @@ fn trusted_runtime_identity( async fn current_authorized_repair_attempt( state: &HttpServerState, - conversation_id: &ChatConversationId, + runtime_conversation_id: &ChatConversationId, + owning_conversation_id: &ChatConversationId, run_id: &AgentRunId, ) -> Result { let run = state @@ -352,13 +353,13 @@ async fn current_authorized_repair_attempt( let Some(run) = run else { return Ok(AgentWorkspaceRepairCompletionAuthority::Invalid); }; - if run.conversation_id != *conversation_id { + if run.conversation_id != *runtime_conversation_id { return Ok(AgentWorkspaceRepairCompletionAuthority::Invalid); } let authority = classify_agent_workspace_repair_completion_authority( Arc::clone(&state.app_state.agent_workspace_repair_repo), - conversation_id, + owning_conversation_id, run_id, ) .await @@ -373,7 +374,7 @@ async fn current_authorized_repair_attempt( ) && state .app_state .agent_workspace_repair_repo - .get_repair_attempt_for_run(conversation_id, run_id) + .get_repair_attempt_for_run(owning_conversation_id, run_id) .await .map_err(|error| { json_error(StatusCode::INTERNAL_SERVER_ERROR, error.to_string(), None) @@ -390,6 +391,53 @@ async fn current_authorized_repair_attempt( Ok(authority) } +/// Maps a trusted fixer runtime to its owning workspace without trusting generic conversation +/// parentage. Repository failures propagate so missing authority can never be inferred from a +/// failed read. +async fn resolve_owning_workspace_conversation( + state: &HttpServerState, + runtime_conversation_id: &ChatConversationId, +) -> Result { + if state + .app_state + .agent_conversation_workspace_repo + .get_by_conversation_id(runtime_conversation_id) + .await + .map_err(|error| json_error(StatusCode::INTERNAL_SERVER_ERROR, error.to_string(), None))? + .is_some() + { + return Ok(*runtime_conversation_id); + } + + if let Some(attempt) = state + .app_state + .agent_workspace_repair_repo + .get_unsettled_attempt_by_runtime_conversation(runtime_conversation_id) + .await + .map_err(|error| json_error(StatusCode::INTERNAL_SERVER_ERROR, error.to_string(), None))? + { + return Ok(attempt.conversation_id); + } + + let review_fixers = state + .app_state + .agent_conversation_workspace_repo + .list_active_workspace_review_fixers() + .await + .map_err(|error| json_error(StatusCode::INTERNAL_SERVER_ERROR, error.to_string(), None))?; + if let Some(monitor) = review_fixers.into_iter().find(|monitor| { + monitor.review_fixer_conversation_id.as_ref() == Some(runtime_conversation_id) + }) { + return Ok(monitor.conversation_id); + } + + Err(json_error( + StatusCode::CONFLICT, + "The repair runtime is not linked to an active agent workspace repair.", + None, + )) +} + fn authority_response( authority: AgentWorkspaceRepairCompletionAuthority, ) -> Result>, JsonError> { @@ -504,10 +552,17 @@ async fn workspace_review_fixer_completion_fallback( /// different current generation/run is superseded. pub(super) async fn stale_completion_transition_response( state: &HttpServerState, - conversation_id: &ChatConversationId, + runtime_conversation_id: &ChatConversationId, + owning_conversation_id: &ChatConversationId, run_id: &AgentRunId, ) -> Result, JsonError> { - let authority = current_authorized_repair_attempt(state, conversation_id, run_id).await?; + let authority = current_authorized_repair_attempt( + state, + runtime_conversation_id, + owning_conversation_id, + run_id, + ) + .await?; Ok(authority_response(authority)?.unwrap_or_else(|| { completion_response( "superseded", @@ -579,14 +634,22 @@ pub(crate) async fn complete_agent_workspace_repair_for_trusted_run( let what_happened = validate_repair_narrative_field(req.what_happened, "what_happened")?; let what_i_did = validate_repair_narrative_field(req.what_i_did, "what_i_did")?; - let authority = current_authorized_repair_attempt(state, &conversation_id, &run_id).await?; + let owning_conversation_id = + resolve_owning_workspace_conversation(state, &conversation_id).await?; + let authority = current_authorized_repair_attempt( + state, + &conversation_id, + &owning_conversation_id, + &run_id, + ) + .await?; let (attempt, resurrecting) = match authority { AgentWorkspaceRepairCompletionAuthority::Current(attempt) => (*attempt, false), AgentWorkspaceRepairCompletionAuthority::AlreadyBlocked if req.blocker.is_none() => { let attempt = state .app_state .agent_workspace_repair_repo - .get_repair_attempt_for_run(&conversation_id, &run_id) + .get_repair_attempt_for_run(&owning_conversation_id, &run_id) .await .map_err(|error| { json_error(StatusCode::INTERNAL_SERVER_ERROR, error.to_string(), None) @@ -625,7 +688,8 @@ pub(crate) async fn complete_agent_workspace_repair_for_trusted_run( return Ok(authority_response(authority)?.expect("non-current authority responds")); } }; - let workspace = load_agent_workspace_entity(state.app_state.as_ref(), &conversation_id).await?; + let workspace = + load_agent_workspace_entity(state.app_state.as_ref(), &owning_conversation_id).await?; if matches!( req.resolution, @@ -634,6 +698,7 @@ pub(crate) async fn complete_agent_workspace_repair_for_trusted_run( return repair_completion_ci_rerun::request_transient_ci_rerun( state, &conversation_id, + &owning_conversation_id, &run_id, attempt, &workspace, @@ -671,7 +736,13 @@ pub(crate) async fn complete_agent_workspace_repair_for_trusted_run( )), AgentWorkspaceRepairTransitionOutcome::Stale(_) | AgentWorkspaceRepairTransitionOutcome::Missing => { - stale_completion_transition_response(state, &conversation_id, &run_id).await + stale_completion_transition_response( + state, + &conversation_id, + &owning_conversation_id, + &run_id, + ) + .await } }; } @@ -751,7 +822,13 @@ pub(crate) async fn complete_agent_workspace_repair_for_trusted_run( )), AgentWorkspaceRepairTransitionOutcome::Stale(_) | AgentWorkspaceRepairTransitionOutcome::Missing => { - stale_completion_transition_response(state, &conversation_id, &run_id).await + stale_completion_transition_response( + state, + &conversation_id, + &owning_conversation_id, + &run_id, + ) + .await } }; } @@ -778,7 +855,13 @@ pub(crate) async fn complete_agent_workspace_repair_for_trusted_run( )), AgentWorkspaceRepairTransitionOutcome::Stale(_) | AgentWorkspaceRepairTransitionOutcome::Missing => { - stale_completion_transition_response(state, &conversation_id, &run_id).await + stale_completion_transition_response( + state, + &conversation_id, + &owning_conversation_id, + &run_id, + ) + .await } }; } @@ -798,8 +881,13 @@ pub(crate) async fn complete_agent_workspace_repair_for_trusted_run( AgentWorkspaceRepairTransitionOutcome::Stale(_) | AgentWorkspaceRepairTransitionOutcome::Missing, ) => { - return stale_completion_transition_response(state, &conversation_id, &run_id) - .await; + return stale_completion_transition_response( + state, + &conversation_id, + &owning_conversation_id, + &run_id, + ) + .await; } Err(error) => { tracing::warn!( @@ -834,12 +922,19 @@ pub(crate) async fn complete_agent_workspace_repair_for_trusted_run( AgentWorkspaceRepairTransitionOutcome::Applied(attempt) => attempt, AgentWorkspaceRepairTransitionOutcome::Stale(_) | AgentWorkspaceRepairTransitionOutcome::Missing => { - return stale_completion_transition_response(state, &conversation_id, &run_id).await; + return stale_completion_transition_response( + state, + &conversation_id, + &owning_conversation_id, + &run_id, + ) + .await; } }; Box::pin(complete_reserved_agent_workspace_repair( state, &conversation_id, + &owning_conversation_id, &run_id, &workspace, reserved, @@ -855,7 +950,8 @@ pub(crate) async fn complete_agent_workspace_repair_for_trusted_run( #[allow(clippy::too_many_arguments)] async fn complete_reserved_agent_workspace_repair( state: &HttpServerState, - conversation_id: &ChatConversationId, + runtime_conversation_id: &ChatConversationId, + owning_conversation_id: &ChatConversationId, run_id: &AgentRunId, workspace: &AgentConversationWorkspace, reserved: AgentWorkspaceRepairAttempt, @@ -873,7 +969,7 @@ async fn complete_reserved_agent_workspace_repair( { tracing::warn!( target: "ralphx_lib::http::agent_workspace_repair", - conversation_id = %conversation_id, + conversation_id = %owning_conversation_id, attempt_id = %reserved.id, error = %error, "Repair completion lost its canonical Git target lease before validation" @@ -903,7 +999,13 @@ async fn complete_reserved_agent_workspace_repair( )), AgentWorkspaceRepairTransitionOutcome::Stale(_) | AgentWorkspaceRepairTransitionOutcome::Missing => { - stale_completion_transition_response(state, conversation_id, run_id).await + stale_completion_transition_response( + state, + runtime_conversation_id, + owning_conversation_id, + run_id, + ) + .await } }; } @@ -958,7 +1060,13 @@ async fn complete_reserved_agent_workspace_repair( AgentWorkspaceRepairTransitionOutcome::Applied(_) => Err(validation_error), AgentWorkspaceRepairTransitionOutcome::Stale(_) | AgentWorkspaceRepairTransitionOutcome::Missing => { - stale_completion_transition_response(state, conversation_id, run_id).await + stale_completion_transition_response( + state, + runtime_conversation_id, + owning_conversation_id, + run_id, + ) + .await } }; } @@ -988,7 +1096,13 @@ async fn complete_reserved_agent_workspace_repair( } AgentWorkspaceRepairTransitionOutcome::Stale(_) | AgentWorkspaceRepairTransitionOutcome::Missing => { - stale_completion_transition_response(state, conversation_id, run_id).await + stale_completion_transition_response( + state, + runtime_conversation_id, + owning_conversation_id, + run_id, + ) + .await } }; } @@ -1011,7 +1125,13 @@ async fn complete_reserved_agent_workspace_repair( AgentWorkspaceRepairTransitionOutcome::Applied(validated) => validated, AgentWorkspaceRepairTransitionOutcome::Stale(_) | AgentWorkspaceRepairTransitionOutcome::Missing => { - return stale_completion_transition_response(state, conversation_id, run_id).await; + return stale_completion_transition_response( + state, + runtime_conversation_id, + owning_conversation_id, + run_id, + ) + .await; } }; #[cfg(feature = "test-utils")] @@ -1030,7 +1150,13 @@ async fn complete_reserved_agent_workspace_repair( AgentWorkspaceRepairTransitionOutcome::Applied(attempt) => attempt, AgentWorkspaceRepairTransitionOutcome::Stale(_) | AgentWorkspaceRepairTransitionOutcome::Missing => { - return stale_completion_transition_response(state, conversation_id, run_id).await; + return stale_completion_transition_response( + state, + runtime_conversation_id, + owning_conversation_id, + run_id, + ) + .await; } }; if continuation.phase == AgentWorkspaceRepairPhase::Blocked { @@ -1047,7 +1173,7 @@ async fn complete_reserved_agent_workspace_repair( { tracing::warn!( target: "ralphx_lib::http::agent_workspace_repair", - conversation_id = %conversation_id, + conversation_id = %owning_conversation_id, error = %error, "Repair continuation publication is durably pending after completion" ); diff --git a/src-tauri/src/http_server/handlers/agent_workspaces/repair_completion_ci_rerun.rs b/src-tauri/src/http_server/handlers/agent_workspaces/repair_completion_ci_rerun.rs index c0d470ea11..079180074f 100644 --- a/src-tauri/src/http_server/handlers/agent_workspaces/repair_completion_ci_rerun.rs +++ b/src-tauri/src/http_server/handlers/agent_workspaces/repair_completion_ci_rerun.rs @@ -20,6 +20,7 @@ use crate::domain::entities::{AgentRunId, AgentWorkspaceRepairAttempt, ChatConve pub(super) async fn request_transient_ci_rerun( state: &HttpServerState, conversation_id: &ChatConversationId, + owning_conversation_id: &ChatConversationId, run_id: &AgentRunId, attempt: AgentWorkspaceRepairAttempt, workspace: &AgentConversationWorkspace, @@ -71,7 +72,13 @@ pub(super) async fn request_transient_ci_rerun( Ok(completion_response("rerun_pending", message)) } TransientCiRerunOutcome::ReservationStale => { - stale_completion_transition_response(state, conversation_id, run_id).await + stale_completion_transition_response( + state, + conversation_id, + owning_conversation_id, + run_id, + ) + .await } } } diff --git a/src-tauri/src/http_server/handlers/agent_workspaces/workspace_review_context.rs b/src-tauri/src/http_server/handlers/agent_workspaces/workspace_review_context.rs index 7e6adb8cf8..2f19d6d6fe 100644 --- a/src-tauri/src/http_server/handlers/agent_workspaces/workspace_review_context.rs +++ b/src-tauri/src/http_server/handlers/agent_workspaces/workspace_review_context.rs @@ -17,6 +17,7 @@ use crate::application::agent_workspace_review_context::{ load_agent_workspace_review_presentation_context, AgentWorkspaceReviewContextReadMode, }; use crate::application::AppState; +use crate::domain::entities::{AgentWorkspaceRepairPhase, AgentWorkspaceRepairSource}; /// GET /api/agent-workspaces/{conversation_id}/workspace-review-context pub async fn get_agent_workspace_review_context( @@ -95,6 +96,23 @@ pub async fn get_agent_workspace_review_context( has_artifact = context.monitor.review_artifact_id.is_some(), "Served workspace Review context" ); + let repair_attempt = state + .app_state + .agent_workspace_repair_repo + .get_current_repair_attempt(&conversation_id) + .await + .map_err(|error| json_error(StatusCode::INTERNAL_SERVER_ERROR, error.to_string(), None))? + .filter(|attempt| attempt.phase != AgentWorkspaceRepairPhase::Blocked); + let repair_runtime_conversation_id = repair_attempt + .as_ref() + .map(|attempt| attempt.runtime_conversation_id().as_str()); + let repair_fixer_kind = repair_attempt.as_ref().map(|attempt| { + if attempt.source == AgentWorkspaceRepairSource::PrAutofix { + "pr_fixer" + } else { + "workspace_repair" + } + }); // Incremental triage is reviewer-facing, so it rides the full-packet path only. Everything // here is served from the start-of-run snapshot, never from the live `reviewed_*` fields. @@ -142,6 +160,8 @@ pub async fn get_agent_workspace_review_context( AgentWorkspaceReviewTargetResponse::from_target(target, include_review_packet) }), monitor, + repair_runtime_conversation_id, + repair_fixer_kind, goal_context: context.goal_context, is_current: context.is_current, is_outdated: context.is_outdated, diff --git a/src-tauri/src/infrastructure/memory/memory_agent_conversation_workspace_repo/repair_attempt_fencing_tests.rs b/src-tauri/src/infrastructure/memory/memory_agent_conversation_workspace_repo/repair_attempt_fencing_tests.rs index 7e631361bc..934ba444f7 100644 --- a/src-tauri/src/infrastructure/memory/memory_agent_conversation_workspace_repo/repair_attempt_fencing_tests.rs +++ b/src-tauri/src/infrastructure/memory/memory_agent_conversation_workspace_repo/repair_attempt_fencing_tests.rs @@ -82,6 +82,7 @@ async fn settled_and_cross_conversation_attempts_cannot_transition_or_bind_runs( expected_phase: settled.phase, expected_updated_at: settled.updated_at, run_id: crate::domain::entities::AgentRunId::from_string("settled-memory-run"), + runtime_conversation_id: None, updated_at: settled.updated_at + Duration::seconds(2), }) .await diff --git a/src-tauri/src/infrastructure/memory/memory_agent_conversation_workspace_repo/repair_attempts.rs b/src-tauri/src/infrastructure/memory/memory_agent_conversation_workspace_repo/repair_attempts.rs index b6bb7bbafb..6fb84cef5d 100644 --- a/src-tauri/src/infrastructure/memory/memory_agent_conversation_workspace_repo/repair_attempts.rs +++ b/src-tauri/src/infrastructure/memory/memory_agent_conversation_workspace_repo/repair_attempts.rs @@ -163,6 +163,22 @@ impl AgentWorkspaceRepairRepository for MemoryAgentConversationWorkspaceReposito .cloned()) } + async fn get_unsettled_attempt_by_runtime_conversation( + &self, + runtime_conversation_id: &ChatConversationId, + ) -> AppResult> { + Ok(self + .repair_attempts + .read() + .await + .values() + .find(|attempt| { + attempt.runtime_conversation_id.as_ref() == Some(runtime_conversation_id) + && attempt.settled_at.is_none() + }) + .cloned()) + } + async fn get_latest_repair_attempt_for_conversation( &self, conversation_id: &ChatConversationId, @@ -348,6 +364,9 @@ impl AgentWorkspaceRepairRepository for MemoryAgentConversationWorkspaceReposito .get_mut(&request.attempt_id) .expect("repair attempt existed while the write lock was held"); current.reserved_agent_run_id = Some(request.run_id); + if current.runtime_conversation_id.is_none() { + current.runtime_conversation_id = request.runtime_conversation_id; + } current.updated_at = request.updated_at; Ok(AgentWorkspaceRepairAttemptTransitionOutcome::Applied( current.clone(), diff --git a/src-tauri/src/infrastructure/memory/memory_agent_conversation_workspace_repo/repair_attempts_tests.rs b/src-tauri/src/infrastructure/memory/memory_agent_conversation_workspace_repo/repair_attempts_tests.rs index 663334dd8e..237b6e7238 100644 --- a/src-tauri/src/infrastructure/memory/memory_agent_conversation_workspace_repo/repair_attempts_tests.rs +++ b/src-tauri/src/infrastructure/memory/memory_agent_conversation_workspace_repo/repair_attempts_tests.rs @@ -16,7 +16,7 @@ use crate::domain::repositories::{ CompleteAgentWorkspaceRepairEffect, CompleteAgentWorkspaceRepairEffectOutcome, CreateAgentWorkspaceRepairEffect, CreateAgentWorkspaceRepairEffectOutcome, ImportLegacyAgentWorkspaceRepairAttempt, ImportLegacyAgentWorkspaceRepairAttemptOutcome, - SettleAndStartAgentWorkspaceRepairSuccessor, + SettleAgentWorkspaceRepairAttempt, SettleAndStartAgentWorkspaceRepairSuccessor, SettleAndStartAgentWorkspaceRepairSuccessorOutcome, StartOrJoinAgentWorkspaceRepairAttempt, StartOrJoinAgentWorkspaceRepairAttemptOutcome, }; @@ -164,6 +164,7 @@ async fn bind_repair_run_rejects_a_stale_same_phase_snapshot() { expected_phase: AgentWorkspaceRepairPhase::Requested, expected_updated_at: stale.updated_at, run_id: AgentRunId::from_string("stale-memory-repair-run"), + runtime_conversation_id: None, updated_at: stale.updated_at + Duration::seconds(1), }) .await @@ -266,6 +267,140 @@ fn repair_attempt(conversation_id: ChatConversationId) -> AgentWorkspaceRepairAt ) } +#[tokio::test] +async fn runtime_conversation_id_round_trips_when_set_or_unset() { + let repo = MemoryAgentConversationWorkspaceRepository::new(); + let conversation_id = ChatConversationId::from_string("repair-runtime-memory"); + repo.create_or_update(workspace(conversation_id.clone())) + .await + .expect("persist workspace"); + let runtime_conversation_id = ChatConversationId::from_string("runtime-child-memory"); + let mut configured = repair_attempt(conversation_id.clone()); + configured.runtime_conversation_id = Some(runtime_conversation_id.clone()); + let started = match repo + .start_or_join_repair_attempt(StartOrJoinAgentWorkspaceRepairAttempt { + attempt: configured, + reason: "runtime conversation".to_string(), + verified_newer_base: false, + compatibility_projection: None, + events: Vec::new(), + }) + .await + .expect("start repair attempt") + { + StartOrJoinAgentWorkspaceRepairAttemptOutcome::Started(attempt) => attempt, + outcome => panic!("expected started repair attempt, got {outcome:?}"), + }; + let persisted = repo + .get_repair_attempt(&started.id) + .await + .expect("reload configured repair attempt") + .expect("configured repair attempt exists"); + assert_eq!( + persisted.runtime_conversation_id, + Some(runtime_conversation_id.clone()) + ); + assert_eq!( + persisted.runtime_conversation_id(), + &runtime_conversation_id + ); + + let settled_at = persisted.updated_at + Duration::seconds(1); + repo.settle_repair_attempt(SettleAgentWorkspaceRepairAttempt { + attempt_id: persisted.id, + generation: persisted.generation, + expected_phase: persisted.phase, + expected_updated_at: persisted.updated_at, + outcome: AgentWorkspaceRepairOutcome::Succeeded, + settled_at, + compatibility_projection: None, + events: Vec::new(), + }) + .await + .expect("settle configured repair attempt"); + + let next = match repo + .start_or_join_repair_attempt(StartOrJoinAgentWorkspaceRepairAttempt { + attempt: repair_attempt(conversation_id.clone()), + reason: "legacy runtime fallback".to_string(), + verified_newer_base: false, + compatibility_projection: None, + events: Vec::new(), + }) + .await + .expect("start repair attempt without runtime conversation") + { + StartOrJoinAgentWorkspaceRepairAttemptOutcome::Started(attempt) => attempt, + outcome => panic!("expected started repair attempt, got {outcome:?}"), + }; + let persisted_next = repo + .get_repair_attempt(&next.id) + .await + .expect("reload legacy repair attempt") + .expect("legacy repair attempt exists"); + assert_eq!(persisted_next.runtime_conversation_id, None); + assert_eq!(persisted_next.runtime_conversation_id(), &conversation_id); +} + +#[tokio::test] +async fn lookup_by_runtime_conversation_only_returns_unsettled_attempts() { + let repo = MemoryAgentConversationWorkspaceRepository::new(); + let conversation_id = ChatConversationId::from_string("lookup-runtime-memory"); + repo.create_or_update(workspace(conversation_id.clone())) + .await + .expect("persist workspace"); + let runtime_conversation_id = ChatConversationId::from_string("runtime-lookup-memory"); + let mut configured = repair_attempt(conversation_id); + configured.runtime_conversation_id = Some(runtime_conversation_id.clone()); + let started = match repo + .start_or_join_repair_attempt(StartOrJoinAgentWorkspaceRepairAttempt { + attempt: configured, + reason: "runtime lookup".to_string(), + verified_newer_base: false, + compatibility_projection: None, + events: Vec::new(), + }) + .await + .expect("start repair attempt") + { + StartOrJoinAgentWorkspaceRepairAttemptOutcome::Started(attempt) => attempt, + outcome => panic!("expected started repair attempt, got {outcome:?}"), + }; + assert_eq!( + repo.get_unsettled_attempt_by_runtime_conversation(&runtime_conversation_id) + .await + .expect("look up active runtime conversation") + .map(|attempt| attempt.id), + Some(started.id.clone()) + ); + + let settled_at = started.updated_at + Duration::seconds(1); + repo.settle_repair_attempt(SettleAgentWorkspaceRepairAttempt { + attempt_id: started.id, + generation: started.generation, + expected_phase: started.phase, + expected_updated_at: started.updated_at, + outcome: AgentWorkspaceRepairOutcome::Succeeded, + settled_at, + compatibility_projection: None, + events: Vec::new(), + }) + .await + .expect("settle repair attempt"); + assert!(repo + .get_unsettled_attempt_by_runtime_conversation(&runtime_conversation_id) + .await + .expect("settled runtime conversation is no longer authorized") + .is_none()); + assert!(repo + .get_unsettled_attempt_by_runtime_conversation(&ChatConversationId::from_string( + "unknown-runtime-memory", + )) + .await + .expect("unknown runtime conversation lookup") + .is_none()); +} + fn publication_event( conversation_id: ChatConversationId, step: &str, diff --git a/src-tauri/src/infrastructure/sqlite/migrations/mod.rs b/src-tauri/src/infrastructure/sqlite/migrations/mod.rs index 49d8cc1f9b..5701a40096 100644 --- a/src-tauri/src/infrastructure/sqlite/migrations/mod.rs +++ b/src-tauri/src/infrastructure/sqlite/migrations/mod.rs @@ -579,6 +579,7 @@ mod v20260730161032_agent_workspace_pr_autofix_completion_evidence; mod v20260731023949_agent_run_identity; mod v20260731111346_purge_empty_thinking_blocks; mod v20260731125157_add_workspace_repair_fingerprint_state; +mod v20260731170447_agent_workspace_repair_runtime_conversation; mod v20260801021420_delegation_parks; mod v20260801211636_delegation_park_wake_claimed_at; mod v20260802031156_delegate_context_inheritance; @@ -605,6 +606,8 @@ mod v20260731111346_purge_empty_thinking_blocks_tests; #[cfg(test)] mod v20260731125157_add_workspace_repair_fingerprint_state_tests; #[cfg(test)] +mod v20260731170447_agent_workspace_repair_runtime_conversation_tests; +#[cfg(test)] mod v20260801021420_delegation_parks_tests; #[cfg(test)] mod v20260801211636_delegation_park_wake_claimed_at_tests; @@ -1926,6 +1929,11 @@ const MIGRATIONS: &[Migration] = &[ name: "add_workspace_repair_fingerprint_state", migrate: v20260731125157_add_workspace_repair_fingerprint_state::migrate, }, + Migration { + version: 20260731170447, + name: "agent_workspace_repair_runtime_conversation", + migrate: v20260731170447_agent_workspace_repair_runtime_conversation::migrate, + }, Migration { version: 20260801021420, name: "delegation_parks", diff --git a/src-tauri/src/infrastructure/sqlite/migrations/v20260731170447_agent_workspace_repair_runtime_conversation.rs b/src-tauri/src/infrastructure/sqlite/migrations/v20260731170447_agent_workspace_repair_runtime_conversation.rs new file mode 100644 index 0000000000..1662141d8e --- /dev/null +++ b/src-tauri/src/infrastructure/sqlite/migrations/v20260731170447_agent_workspace_repair_runtime_conversation.rs @@ -0,0 +1,35 @@ +// Migration v20260731170447: agent workspace repair runtime conversation +// +// Fixer agents (workspace repair and PR autofix) used to run inside the parent workspace +// conversation, which made follow-up user messages resolve to project chat instead of the fixer. +// Each repair attempt now owns a dedicated child conversation. Recording that child on the attempt +// is what lets completion authority map a child runtime back to the workspace that owns it without +// trusting `parent_conversation_id`. +// +// NULL is the durable marker for a legacy, parent-hosted attempt. There is deliberately no backfill. + +use rusqlite::Connection; + +use crate::error::AppResult; + +use super::helpers; + +pub fn migrate(conn: &Connection) -> AppResult<()> { + helpers::add_column_if_not_exists( + conn, + "agent_workspace_repair_attempts", + "runtime_conversation_id", + "TEXT NULL", + )?; + + helpers::create_index_if_not_exists( + conn, + "idx_agent_workspace_repair_attempts_runtime_conversation", + "agent_workspace_repair_attempts", + "runtime_conversation_id", + )?; + + tracing::info!("Migration v20260731170447: repair attempt runtime conversation ready"); + + Ok(()) +} diff --git a/src-tauri/src/infrastructure/sqlite/migrations/v20260731170447_agent_workspace_repair_runtime_conversation_tests.rs b/src-tauri/src/infrastructure/sqlite/migrations/v20260731170447_agent_workspace_repair_runtime_conversation_tests.rs new file mode 100644 index 0000000000..5364badaa9 --- /dev/null +++ b/src-tauri/src/infrastructure/sqlite/migrations/v20260731170447_agent_workspace_repair_runtime_conversation_tests.rs @@ -0,0 +1,92 @@ +//! Tests for migration v20260731170447: agent workspace repair runtime conversation + +use rusqlite::Connection; + +use super::v20260731170447_agent_workspace_repair_runtime_conversation::migrate; + +fn setup_test_db() -> Connection { + let conn = Connection::open_in_memory().expect("Failed to create in-memory database"); + conn.execute_batch( + "CREATE TABLE agent_workspace_repair_attempts (\ + attempt_id TEXT PRIMARY KEY, \ + conversation_id TEXT NOT NULL, \ + generation INTEGER NOT NULL DEFAULT 0, \ + settled_at TEXT NULL\ + );", + ) + .expect("seed agent workspace repair attempts table"); + conn +} + +fn columns(conn: &Connection) -> Vec { + conn.prepare("PRAGMA table_info(agent_workspace_repair_attempts)") + .expect("prepare table info") + .query_map([], |row| row.get(1)) + .expect("query table info") + .collect::>() + .expect("read table info") +} + +fn indexes(conn: &Connection) -> Vec { + conn.prepare("PRAGMA index_list(agent_workspace_repair_attempts)") + .expect("prepare index list") + .query_map([], |row| row.get(1)) + .expect("query index list") + .collect::>() + .expect("read index list") +} + +#[test] +fn migration_adds_runtime_conversation_column() { + let conn = setup_test_db(); + migrate(&conn).expect("migration should add the runtime conversation column"); + assert!(columns(&conn).contains(&"runtime_conversation_id".to_string())); +} + +#[test] +fn migration_adds_runtime_conversation_lookup_index() { + let conn = setup_test_db(); + migrate(&conn).expect("migration should add the runtime conversation index"); + assert!(indexes(&conn) + .contains(&"idx_agent_workspace_repair_attempts_runtime_conversation".to_string())); +} + +#[test] +fn migration_is_idempotent_on_already_upgraded_databases() { + let conn = setup_test_db(); + migrate(&conn).expect("first run should add the column and index"); + migrate(&conn).expect("second run must not fail on existing column or index"); + assert_eq!( + columns(&conn) + .iter() + .filter(|name| name.as_str() == "runtime_conversation_id") + .count(), + 1 + ); +} + +#[test] +fn existing_attempts_stay_parent_hosted() { + let conn = setup_test_db(); + conn.execute( + "INSERT INTO agent_workspace_repair_attempts (attempt_id, conversation_id) \ + VALUES ('attempt-1', 'workspace-conversation-1')", + [], + ) + .expect("seed an in-flight attempt from before the upgrade"); + + migrate(&conn).expect("migration should add the runtime conversation column"); + + let runtime_conversation_id: Option = conn + .query_row( + "SELECT runtime_conversation_id FROM agent_workspace_repair_attempts \ + WHERE attempt_id = 'attempt-1'", + [], + |row| row.get(0), + ) + .expect("read the new column for the pre-existing attempt"); + assert!( + runtime_conversation_id.is_none(), + "in-flight attempts must stay NULL so they keep resolving as legacy parent-hosted runs" + ); +} diff --git a/src-tauri/src/infrastructure/sqlite/sqlite_agent_conversation_workspace_repo/repair_attempt_fencing_tests.rs b/src-tauri/src/infrastructure/sqlite/sqlite_agent_conversation_workspace_repo/repair_attempt_fencing_tests.rs index 13a9fe6d44..a3e6715343 100644 --- a/src-tauri/src/infrastructure/sqlite/sqlite_agent_conversation_workspace_repo/repair_attempt_fencing_tests.rs +++ b/src-tauri/src/infrastructure/sqlite/sqlite_agent_conversation_workspace_repo/repair_attempt_fencing_tests.rs @@ -89,6 +89,7 @@ async fn settled_and_cross_conversation_attempts_cannot_transition_or_bind_runs( expected_phase: settled.phase, expected_updated_at: settled.updated_at, run_id: crate::domain::entities::AgentRunId::from_string("settled-sqlite-run"), + runtime_conversation_id: None, updated_at: settled.updated_at + Duration::seconds(2), }) .await diff --git a/src-tauri/src/infrastructure/sqlite/sqlite_agent_conversation_workspace_repo/repair_attempts.rs b/src-tauri/src/infrastructure/sqlite/sqlite_agent_conversation_workspace_repo/repair_attempts.rs index 31ec699949..0c9d7147f7 100644 --- a/src-tauri/src/infrastructure/sqlite/sqlite_agent_conversation_workspace_repo/repair_attempts.rs +++ b/src-tauri/src/infrastructure/sqlite/sqlite_agent_conversation_workspace_repo/repair_attempts.rs @@ -56,6 +56,9 @@ fn row_to_repair_attempt(row: &rusqlite::Row<'_>) -> rusqlite::Result>("reserved_agent_run_id")? .map(crate::domain::entities::AgentRunId::from_string), + runtime_conversation_id: row + .get::<_, Option>("runtime_conversation_id")? + .map(ChatConversationId::from_string), target_base_ref: row.get("target_base_ref")?, target_base_commit: row.get("target_base_commit")?, pending_reasons: serde_json::from_str(&pending_reasons_json) @@ -183,6 +186,21 @@ fn load_current_repair_attempt( .map_err(Into::into) } +fn load_unsettled_repair_attempt_by_runtime_conversation( + conn: &Connection, + runtime_conversation_id: &str, +) -> AppResult> { + conn.query_row( + "SELECT * FROM agent_workspace_repair_attempts + WHERE runtime_conversation_id = ?1 AND settled_at IS NULL + LIMIT 1", + rusqlite::params![runtime_conversation_id], + row_to_repair_attempt, + ) + .optional() + .map_err(Into::into) +} + fn load_latest_repair_attempt( conn: &Connection, conversation_id: &str, @@ -203,7 +221,8 @@ fn write_repair_attempt(conn: &Connection, attempt: &AgentWorkspaceRepairAttempt conn.execute( "INSERT INTO agent_workspace_repair_attempts ( id, conversation_id, generation, source, phase, continuation, - reserved_agent_run_id, target_base_ref, target_base_commit, pending_reasons_json, + reserved_agent_run_id, runtime_conversation_id, target_base_ref, target_base_commit, + pending_reasons_json, review_required, auto_publish_enabled, auto_merge_desired, auto_merge_method, dispatch_count, next_dispatch_at, ci_rerun_count, ci_rerun_fingerprint, pr_autofix_dispatch_head_commit, pr_autofix_health_fingerprint, base_update_target_commit, @@ -214,7 +233,7 @@ fn write_repair_attempt(conn: &Connection, attempt: &AgentWorkspaceRepairAttempt ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, - ?32, ?33, ?34, ?35, ?36, ?37 + ?32, ?33, ?34, ?35, ?36, ?37, ?38 )", rusqlite::params![ attempt.id.as_str(), @@ -226,6 +245,10 @@ fn write_repair_attempt(conn: &Connection, attempt: &AgentWorkspaceRepairAttempt attempt.phase.to_string(), attempt.continuation.to_string(), attempt.reserved_agent_run_id.as_ref().map(|id| id.as_str()), + attempt + .runtime_conversation_id + .as_ref() + .map(|id| id.as_str()), attempt.target_base_ref, attempt.target_base_commit, serde_json::to_string(&attempt.pending_reasons) @@ -290,38 +313,39 @@ fn update_repair_attempt( SET phase = ?4, continuation = ?5, reserved_agent_run_id = ?6, - target_base_ref = ?7, - target_base_commit = ?8, - pending_reasons_json = ?9, - review_required = ?10, - auto_publish_enabled = ?11, - auto_merge_desired = ?12, - auto_merge_method = ?13, - dispatch_count = ?14, - next_dispatch_at = ?15, - ci_rerun_count = ?16, - ci_rerun_fingerprint = ?17, - pr_autofix_dispatch_head_commit = ?18, - pr_autofix_health_fingerprint = ?19, - base_update_target_commit = ?20, - repair_head_commit = ?21, - summary = ?22, - blocker = ?23, - what_happened = ?24, - what_i_did = ?25, - git_common_dir = ?26, - target_ref = ?27, - target_identity_version = ?28, - target_lease_epoch = ?29, - outcome = ?30, - updated_at = ?31, - settled_at = ?32, - explicit_publish_requested = ?33, - pr_autofix_issue_kind = ?36, - base_update_head_commit = ?37 + runtime_conversation_id = ?7, + target_base_ref = ?8, + target_base_commit = ?9, + pending_reasons_json = ?10, + review_required = ?11, + auto_publish_enabled = ?12, + auto_merge_desired = ?13, + auto_merge_method = ?14, + dispatch_count = ?15, + next_dispatch_at = ?16, + ci_rerun_count = ?17, + ci_rerun_fingerprint = ?18, + pr_autofix_dispatch_head_commit = ?19, + pr_autofix_health_fingerprint = ?20, + base_update_target_commit = ?21, + repair_head_commit = ?22, + summary = ?23, + blocker = ?24, + what_happened = ?25, + what_i_did = ?26, + git_common_dir = ?27, + target_ref = ?28, + target_identity_version = ?29, + target_lease_epoch = ?30, + outcome = ?31, + updated_at = ?32, + settled_at = ?33, + explicit_publish_requested = ?34, + pr_autofix_issue_kind = ?37, + base_update_head_commit = ?38 WHERE id = ?1 AND generation = ?2 AND phase = ?3 - AND (?34 IS NULL OR updated_at = ?34) - AND (?35 = 0 OR settled_at IS NULL)", + AND (?35 IS NULL OR updated_at = ?35) + AND (?36 = 0 OR settled_at IS NULL)", rusqlite::params![ attempt.id.as_str(), i64::try_from(attempt.generation).map_err(|_| { @@ -331,6 +355,10 @@ fn update_repair_attempt( attempt.phase.to_string(), attempt.continuation.to_string(), attempt.reserved_agent_run_id.as_ref().map(|id| id.as_str()), + attempt + .runtime_conversation_id + .as_ref() + .map(|id| id.as_str()), attempt.target_base_ref, attempt.target_base_commit, serde_json::to_string(&attempt.pending_reasons) @@ -588,6 +616,21 @@ impl AgentWorkspaceRepairRepository for SqliteAgentConversationWorkspaceReposito .await } + async fn get_unsettled_attempt_by_runtime_conversation( + &self, + runtime_conversation_id: &ChatConversationId, + ) -> AppResult> { + let runtime_conversation_id = runtime_conversation_id.as_str().to_string(); + self.db + .run(move |conn| { + load_unsettled_repair_attempt_by_runtime_conversation( + conn, + &runtime_conversation_id, + ) + }) + .await + } + async fn get_latest_repair_attempt_for_conversation( &self, conversation_id: &ChatConversationId, @@ -779,9 +822,11 @@ impl AgentWorkspaceRepairRepository for SqliteAgentConversationWorkspaceReposito let rows = conn.execute( "UPDATE agent_workspace_repair_attempts - SET reserved_agent_run_id = ?4, updated_at = ?5 + SET reserved_agent_run_id = ?4, + runtime_conversation_id = COALESCE(runtime_conversation_id, ?5), + updated_at = ?6 WHERE id = ?1 AND generation = ?2 AND phase = ?3 - AND updated_at = ?6 AND reserved_agent_run_id IS NULL + AND updated_at = ?7 AND reserved_agent_run_id IS NULL AND settled_at IS NULL", rusqlite::params![ current.id.as_str(), @@ -792,6 +837,10 @@ impl AgentWorkspaceRepairRepository for SqliteAgentConversationWorkspaceReposito })?, request.expected_phase.to_string(), request.run_id.as_str(), + request + .runtime_conversation_id + .as_ref() + .map(|id| id.as_str()), request.updated_at.to_rfc3339(), request.expected_updated_at.to_rfc3339(), ], @@ -804,6 +853,9 @@ impl AgentWorkspaceRepairRepository for SqliteAgentConversationWorkspaceReposito return Ok(AgentWorkspaceRepairAttemptTransitionOutcome::Stale(latest)); } current.reserved_agent_run_id = Some(request.run_id); + if current.runtime_conversation_id.is_none() { + current.runtime_conversation_id = request.runtime_conversation_id; + } current.updated_at = request.updated_at; Ok(AgentWorkspaceRepairAttemptTransitionOutcome::Applied( current, diff --git a/src-tauri/src/infrastructure/sqlite/sqlite_agent_conversation_workspace_repo/repair_attempts_tests.rs b/src-tauri/src/infrastructure/sqlite/sqlite_agent_conversation_workspace_repo/repair_attempts_tests.rs index 455f664a9c..5fabd2e2c4 100644 --- a/src-tauri/src/infrastructure/sqlite/sqlite_agent_conversation_workspace_repo/repair_attempts_tests.rs +++ b/src-tauri/src/infrastructure/sqlite/sqlite_agent_conversation_workspace_repo/repair_attempts_tests.rs @@ -15,7 +15,7 @@ use crate::domain::repositories::{ CompleteAgentWorkspaceRepairEffect, CompleteAgentWorkspaceRepairEffectOutcome, CreateAgentWorkspaceRepairEffect, CreateAgentWorkspaceRepairEffectOutcome, ImportLegacyAgentWorkspaceRepairAttempt, ImportLegacyAgentWorkspaceRepairAttemptOutcome, - SettleAndStartAgentWorkspaceRepairSuccessor, + SettleAgentWorkspaceRepairAttempt, SettleAndStartAgentWorkspaceRepairSuccessor, SettleAndStartAgentWorkspaceRepairSuccessorOutcome, StartOrJoinAgentWorkspaceRepairAttempt, StartOrJoinAgentWorkspaceRepairAttemptOutcome, }; @@ -223,6 +223,7 @@ async fn bind_repair_run_rejects_a_stale_same_phase_snapshot() { expected_phase: AgentWorkspaceRepairPhase::Requested, expected_updated_at: stale.updated_at, run_id: AgentRunId::from_string("stale-sqlite-repair-run"), + runtime_conversation_id: None, updated_at: stale.updated_at + Duration::seconds(1), }) .await @@ -355,6 +356,138 @@ fn repair_attempt(conversation_id: ChatConversationId) -> AgentWorkspaceRepairAt ) } +#[tokio::test] +async fn runtime_conversation_id_round_trips_when_set_or_unset() { + let (_db, repo, conversation_id) = setup_repo(); + repo.create_or_update(workspace(conversation_id.clone())) + .await + .expect("persist workspace"); + let runtime_conversation_id = ChatConversationId::from_string("repair-runtime-sqlite"); + let mut configured = repair_attempt(conversation_id.clone()); + configured.runtime_conversation_id = Some(runtime_conversation_id.clone()); + let started = match repo + .start_or_join_repair_attempt(StartOrJoinAgentWorkspaceRepairAttempt { + attempt: configured, + reason: "runtime conversation".to_string(), + verified_newer_base: false, + compatibility_projection: None, + events: Vec::new(), + }) + .await + .expect("start repair attempt") + { + StartOrJoinAgentWorkspaceRepairAttemptOutcome::Started(attempt) => attempt, + outcome => panic!("expected started repair attempt, got {outcome:?}"), + }; + let persisted = repo + .get_repair_attempt(&started.id) + .await + .expect("reload configured repair attempt") + .expect("configured repair attempt exists"); + assert_eq!( + persisted.runtime_conversation_id, + Some(runtime_conversation_id.clone()) + ); + assert_eq!( + persisted.runtime_conversation_id(), + &runtime_conversation_id + ); + + let settled_at = persisted.updated_at + Duration::seconds(1); + repo.settle_repair_attempt(SettleAgentWorkspaceRepairAttempt { + attempt_id: persisted.id, + generation: persisted.generation, + expected_phase: persisted.phase, + expected_updated_at: persisted.updated_at, + outcome: AgentWorkspaceRepairOutcome::Succeeded, + settled_at, + compatibility_projection: None, + events: Vec::new(), + }) + .await + .expect("settle configured repair attempt"); + + let next = match repo + .start_or_join_repair_attempt(StartOrJoinAgentWorkspaceRepairAttempt { + attempt: repair_attempt(conversation_id.clone()), + reason: "legacy runtime fallback".to_string(), + verified_newer_base: false, + compatibility_projection: None, + events: Vec::new(), + }) + .await + .expect("start repair attempt without runtime conversation") + { + StartOrJoinAgentWorkspaceRepairAttemptOutcome::Started(attempt) => attempt, + outcome => panic!("expected started repair attempt, got {outcome:?}"), + }; + let persisted_next = repo + .get_repair_attempt(&next.id) + .await + .expect("reload legacy repair attempt") + .expect("legacy repair attempt exists"); + assert_eq!(persisted_next.runtime_conversation_id, None); + assert_eq!(persisted_next.runtime_conversation_id(), &conversation_id); +} + +#[tokio::test] +async fn lookup_by_runtime_conversation_only_returns_unsettled_attempts() { + let (_db, repo, conversation_id) = setup_repo(); + repo.create_or_update(workspace(conversation_id.clone())) + .await + .expect("persist workspace"); + let runtime_conversation_id = ChatConversationId::from_string("lookup-runtime-sqlite"); + let mut configured = repair_attempt(conversation_id); + configured.runtime_conversation_id = Some(runtime_conversation_id.clone()); + let started = match repo + .start_or_join_repair_attempt(StartOrJoinAgentWorkspaceRepairAttempt { + attempt: configured, + reason: "runtime lookup".to_string(), + verified_newer_base: false, + compatibility_projection: None, + events: Vec::new(), + }) + .await + .expect("start repair attempt") + { + StartOrJoinAgentWorkspaceRepairAttemptOutcome::Started(attempt) => attempt, + outcome => panic!("expected started repair attempt, got {outcome:?}"), + }; + assert_eq!( + repo.get_unsettled_attempt_by_runtime_conversation(&runtime_conversation_id) + .await + .expect("look up active runtime conversation") + .map(|attempt| attempt.id), + Some(started.id.clone()) + ); + + let settled_at = started.updated_at + Duration::seconds(1); + repo.settle_repair_attempt(SettleAgentWorkspaceRepairAttempt { + attempt_id: started.id, + generation: started.generation, + expected_phase: started.phase, + expected_updated_at: started.updated_at, + outcome: AgentWorkspaceRepairOutcome::Succeeded, + settled_at, + compatibility_projection: None, + events: Vec::new(), + }) + .await + .expect("settle repair attempt"); + assert!(repo + .get_unsettled_attempt_by_runtime_conversation(&runtime_conversation_id) + .await + .expect("settled runtime conversation is no longer authorized") + .is_none()); + assert!(repo + .get_unsettled_attempt_by_runtime_conversation(&ChatConversationId::from_string( + "unknown-runtime-sqlite", + )) + .await + .expect("unknown runtime conversation lookup") + .is_none()); +} + fn publication_event( conversation_id: ChatConversationId, step: &str, diff --git a/src-tauri/tests/suite_agent_workspace/agent_workspace_publish_recovery.rs b/src-tauri/tests/suite_agent_workspace/agent_workspace_publish_recovery.rs index 582fe433ec..29142f5c78 100644 --- a/src-tauri/tests/suite_agent_workspace/agent_workspace_publish_recovery.rs +++ b/src-tauri/tests/suite_agent_workspace/agent_workspace_publish_recovery.rs @@ -13,7 +13,9 @@ use ralphx_lib::domain::entities::{ AgentWorkspaceReviewMonitor, AgentWorkspaceReviewMonitorStatus, ChatConversationId, IdeationAnalysisBaseRefKind, Project, ProjectId, }; -use ralphx_lib::domain::repositories::{AgentConversationWorkspaceRepository, AgentRunRepository}; +use ralphx_lib::domain::repositories::{ + AgentConversationWorkspaceRepository, AgentRunRepository, AgentWorkspaceRepairRepository, +}; use ralphx_lib::infrastructure::memory::{ MemoryAgentConversationWorkspaceRepository, MemoryAgentRunRepository, }; @@ -74,6 +76,7 @@ async fn recovers_needs_agent_workspace_when_no_agent_run_is_active() { let recovered = recover_stale_agent_workspace_publish_repairs( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::clone(&agent_run_repo) as Arc, ) .await @@ -115,6 +118,7 @@ async fn keeps_needs_agent_workspace_locked_while_agent_run_is_active() { let recovered = recover_stale_publish_repair_for_workspace( Arc::clone(&workspace_repo) as Arc, + Arc::clone(&workspace_repo) as Arc, Arc::clone(&agent_run_repo) as Arc, workspace, ) diff --git a/src-tauri/tests/suite_agent_workspace/agent_workspace_repair_auto_publish.rs b/src-tauri/tests/suite_agent_workspace/agent_workspace_repair_auto_publish.rs index 276b9551eb..e6d43ba4b9 100644 --- a/src-tauri/tests/suite_agent_workspace/agent_workspace_repair_auto_publish.rs +++ b/src-tauri/tests/suite_agent_workspace/agent_workspace_repair_auto_publish.rs @@ -171,6 +171,7 @@ async fn seed_current_repair_attempt( expected_phase: AgentWorkspaceRepairPhase::Requested, expected_updated_at: started.updated_at, run_id, + runtime_conversation_id: None, updated_at: chrono::Utc::now(), }) .await diff --git a/src-tauri/tests/suite_http_handlers/agent_workspace_repair_completion.rs b/src-tauri/tests/suite_http_handlers/agent_workspace_repair_completion.rs index a7f719a8f5..3c3ee2f27d 100644 --- a/src-tauri/tests/suite_http_handlers/agent_workspace_repair_completion.rs +++ b/src-tauri/tests/suite_http_handlers/agent_workspace_repair_completion.rs @@ -19,8 +19,8 @@ use ralphx_lib::commands::{ use ralphx_lib::domain::entities::{ AgentConversationWorkspace, AgentConversationWorkspaceMode, AgentRun, AgentRunId, AgentRunStatus, AgentWorkspaceRepairAttempt, AgentWorkspaceRepairOutcome, - AgentWorkspaceRepairPhase, AgentWorkspaceRepairSource, ChatConversationId, GitTargetLeaseOwner, - IdeationAnalysisBaseRefKind, Project, ProjectId, + AgentWorkspaceRepairPhase, AgentWorkspaceRepairSource, ChatConversation, ChatConversationId, + GitTargetLeaseOwner, IdeationAnalysisBaseRefKind, Project, ProjectId, }; use ralphx_lib::domain::repositories::{ AcquireGitTargetLease, AcquireGitTargetLeaseOutcome, AgentWorkspaceRepairAttemptTransition, @@ -239,6 +239,14 @@ async fn seed_current_attempt( async fn bind_current_attempt( state: &HttpServerState, conversation_id: ChatConversationId, +) -> (ChatConversationId, AgentRunId, AgentWorkspaceRepairAttempt) { + bind_current_attempt_in_runtime(state, conversation_id, None).await +} + +async fn bind_current_attempt_in_runtime( + state: &HttpServerState, + conversation_id: ChatConversationId, + runtime_conversation_id: Option, ) -> (ChatConversationId, AgentRunId, AgentWorkspaceRepairAttempt) { let attempt = AgentWorkspaceRepairAttempt::new( conversation_id, @@ -266,7 +274,7 @@ async fn bind_current_attempt( let StartOrJoinAgentWorkspaceRepairAttemptOutcome::Started(started) = started else { panic!("first repair attempt must start"); }; - let owner_run = AgentRun::new(conversation_id); + let owner_run = AgentRun::new(runtime_conversation_id.unwrap_or(conversation_id)); let owner_run_id = owner_run.id; state .app_state @@ -283,6 +291,7 @@ async fn bind_current_attempt( expected_phase: AgentWorkspaceRepairPhase::Requested, expected_updated_at: started.updated_at, run_id: owner_run_id, + runtime_conversation_id, updated_at: Utc::now(), }) .await @@ -296,6 +305,130 @@ async fn bind_current_attempt( (conversation_id, owner_run_id, bound) } +#[tokio::test] +async fn child_hosted_repair_completion_resolves_and_settles_owning_workspace() { + let state = test_state(); + let conversation_id = ChatConversationId::new(); + let workspace = AgentConversationWorkspace::new( + conversation_id, + ProjectId::from_string("child-repair-completion-project".to_string()), + AgentConversationWorkspaceMode::Edit, + IdeationAnalysisBaseRefKind::ProjectDefault, + "main".to_string(), + Some("main".to_string()), + Some("base-head".to_string()), + "ralphx/test/child-repair-completion".to_string(), + "/missing-on-purpose".to_string(), + ); + state + .app_state + .agent_conversation_workspace_repo + .create_or_update(workspace) + .await + .expect("seed workspace"); + let runtime_conversation_id = ChatConversationId::new(); + let (_, run_id, _) = + bind_current_attempt_in_runtime(&state, conversation_id, Some(runtime_conversation_id)) + .await; + + let (status, outcome) = response_status( + repair_completion_http_response( + state.clone(), + &runtime_conversation_id, + completion_headers(runtime_conversation_id, run_id), + CompleteAgentWorkspaceRepairRequest { + summary: "Child-hosted repair needs a recorded blocker.".to_string(), + blocker: Some("Waiting for a maintainer decision.".to_string()), + reported_fix_commit_sha: None, + resolution: None, + what_happened: None, + what_i_did: None, + }, + ) + .await, + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(outcome, "blocked"); + let settled = state + .app_state + .agent_workspace_repair_repo + .get_current_repair_attempt(&conversation_id) + .await + .expect("read repair attempt") + .expect("repair attempt remains current"); + assert_eq!(settled.phase, AgentWorkspaceRepairPhase::Blocked); +} + +#[tokio::test] +async fn unrelated_parented_child_cannot_complete_workspace_repair() { + let state = test_state(); + let (conversation_id, _owner_run_id, before) = seed_current_attempt(&state).await; + let runtime_conversation_id = ChatConversationId::new(); + let mut unrelated_child = ChatConversation::new_project(ProjectId::from_string( + "repair-completion-project".to_string(), + )); + unrelated_child.id = runtime_conversation_id; + unrelated_child.parent_conversation_id = Some(conversation_id.as_str()); + state + .app_state + .chat_conversation_repo + .create(unrelated_child) + .await + .expect("seed unrelated parented child"); + let unrelated_run = state + .app_state + .agent_run_repo + .create(AgentRun::new(runtime_conversation_id)) + .await + .expect("seed unrelated child run"); + + let response = repair_completion_http_response( + state.clone(), + &runtime_conversation_id, + completion_headers(runtime_conversation_id, unrelated_run.id), + CompleteAgentWorkspaceRepairRequest { + summary: "An unrelated parented child must not carry repair authority.".to_string(), + blocker: Some("This must be rejected.".to_string()), + reported_fix_commit_sha: None, + resolution: None, + what_happened: None, + what_i_did: None, + }, + ) + .await; + assert_eq!(response.status(), StatusCode::CONFLICT); + + let after = state + .app_state + .agent_workspace_repair_repo + .get_current_repair_attempt(&conversation_id) + .await + .expect("read current attempt") + .expect("attempt remains current"); + assert_eq!(after.id, before.id); + assert_eq!(after.phase, before.phase); + assert_eq!(after.updated_at, before.updated_at); + assert!(state + .app_state + .agent_conversation_workspace_repo + .list_publication_events(&conversation_id) + .await + .expect("list publication events") + .is_empty()); + let workspace = state + .app_state + .agent_conversation_workspace_repo + .get_by_conversation_id(&conversation_id) + .await + .expect("load workspace") + .expect("workspace remains present"); + assert!(workspace.publication_pr_url.is_none()); + assert!(workspace.publication_pr_status.is_none()); + assert!(workspace.pr_supervision_summary.is_none()); +} + async fn seed_current_attempt_with_resolvable_target( state: &HttpServerState, ) -> ( diff --git a/src-tauri/tests/suite_http_handlers/agent_workspace_review_context.rs b/src-tauri/tests/suite_http_handlers/agent_workspace_review_context.rs index da213bd371..85d224f91a 100644 --- a/src-tauri/tests/suite_http_handlers/agent_workspace_review_context.rs +++ b/src-tauri/tests/suite_http_handlers/agent_workspace_review_context.rs @@ -5,7 +5,8 @@ use ralphx_lib::commands::unified_chat_commands::AgentConversationWorkspaceRespo use ralphx_lib::commands::ExecutionState; use ralphx_lib::domain::entities::{ AgentConversationWorkspace, AgentConversationWorkspaceMode, AgentRun, - AgentWorkspaceReviewArtifactOutcome, AgentWorkspaceReviewGateStatus, AgentWorkspaceReviewMonitor, AgentWorkspaceReviewMonitorStatus, + AgentWorkspaceRepairAttempt, AgentWorkspaceRepairContinuation, AgentWorkspaceRepairOutcome, + AgentWorkspaceRepairSource, AgentWorkspaceReviewArtifactOutcome, AgentWorkspaceReviewGateStatus, AgentWorkspaceReviewMonitor, AgentWorkspaceReviewMonitorStatus, AgentWorkspaceReviewOutcome, AgentWorkspaceReviewTargetScope, ArtifactContent, ArtifactId, ChatConversation, ChatConversationId, IdeationAnalysisBaseRefKind, Project, }; @@ -1057,3 +1058,183 @@ async fn context_reports_fixer_cycles_plus_repair_attempts_as_one_automation_cou "the count must include the publish repair the fixer counter cannot see" ); } + +#[tokio::test] +async fn workspace_review_context_surfaces_active_repair_runtime_and_kind() { + use ralphx_lib::domain::repositories::{ + SettleAgentWorkspaceRepairAttempt, SettleAgentWorkspaceRepairAttemptOutcome, + StartOrJoinAgentWorkspaceRepairAttempt, StartOrJoinAgentWorkspaceRepairAttemptOutcome, + }; + let repo = tempfile::TempDir::new().expect("repo tempdir"); + let worktree = tempfile::TempDir::new().expect("worktree tempdir"); + let state = test_state(); + let conversation_id = ChatConversationId::new(); + let project = Project::new( + "Repair runtime context".to_string(), + repo.path().to_string_lossy().to_string(), + ); + state + .app_state + .project_repo + .create(project.clone()) + .await + .expect("seed project"); + let mut conversation = ChatConversation::new_project(project.id.clone()); + conversation.id = conversation_id; + state + .app_state + .chat_conversation_repo + .create(conversation) + .await + .expect("seed conversation"); + let workspace = AgentConversationWorkspace::new( + conversation_id, + project.id, + AgentConversationWorkspaceMode::Edit, + IdeationAnalysisBaseRefKind::ProjectDefault, + "main".to_string(), + Some("Project default (main)".to_string()), + None, + "ralphx/test/repair-runtime-context".to_string(), + worktree.path().to_string_lossy().to_string(), + ); + state + .app_state + .agent_conversation_workspace_repo + .create_or_update(workspace) + .await + .expect("seed workspace"); + let parent_attempt = AgentWorkspaceRepairAttempt::new( + conversation_id, + AgentWorkspaceRepairSource::Publish, + AgentWorkspaceRepairContinuation::ResumePrSupervision, + "main", + false, + true, + false, + None, + chrono::Utc::now(), + ); + let parent_attempt = match state + .app_state + .agent_workspace_repair_repo + .start_or_join_repair_attempt(StartOrJoinAgentWorkspaceRepairAttempt { + attempt: parent_attempt, + reason: "surface parent-hosted fixer runtime".to_string(), + verified_newer_base: false, + compatibility_projection: None, + events: Vec::new(), + }) + .await + .expect("start parent-hosted repair attempt") + { + StartOrJoinAgentWorkspaceRepairAttemptOutcome::Started(attempt) => attempt, + outcome => panic!("expected parent-hosted attempt, got {outcome:?}"), + }; + + let axum::Json(parent_context) = get_agent_workspace_review_context( + State(state.clone()), + Path(conversation_id.to_string()), + HeaderMap::new(), + Query(AgentWorkspaceReviewContextQuery::default()), + ) + .await + .expect("load parent-hosted repair context"); + assert_eq!( + parent_context.repair_runtime_conversation_id, + Some(conversation_id.as_str()) + ); + assert_eq!(parent_context.repair_fixer_kind, Some("workspace_repair")); + assert!(matches!( + state + .app_state + .agent_workspace_repair_repo + .settle_repair_attempt(SettleAgentWorkspaceRepairAttempt { + attempt_id: parent_attempt.id, + generation: parent_attempt.generation, + expected_phase: parent_attempt.phase, + expected_updated_at: parent_attempt.updated_at, + outcome: AgentWorkspaceRepairOutcome::Succeeded, + settled_at: parent_attempt.updated_at + chrono::Duration::microseconds(1), + compatibility_projection: None, + events: Vec::new(), + }) + .await + .expect("settle parent-hosted attempt"), + SettleAgentWorkspaceRepairAttemptOutcome::Applied(_) + )); + + let runtime_conversation_id = ChatConversationId::new(); + let mut attempt = AgentWorkspaceRepairAttempt::new( + conversation_id, + AgentWorkspaceRepairSource::PrAutofix, + AgentWorkspaceRepairContinuation::ResumePrSupervision, + "main", + false, + true, + false, + None, + chrono::Utc::now(), + ); + attempt.runtime_conversation_id = Some(runtime_conversation_id); + let child_attempt = match state + .app_state + .agent_workspace_repair_repo + .start_or_join_repair_attempt(StartOrJoinAgentWorkspaceRepairAttempt { + attempt, + reason: "surface fixer runtime".to_string(), + verified_newer_base: false, + compatibility_projection: None, + events: Vec::new(), + }) + .await + .expect("start repair attempt") + { + StartOrJoinAgentWorkspaceRepairAttemptOutcome::Started(attempt) => attempt, + outcome => panic!("expected child-hosted attempt, got {outcome:?}"), + }; + + let axum::Json(context) = get_agent_workspace_review_context( + State(state.clone()), + Path(conversation_id.to_string()), + HeaderMap::new(), + Query(AgentWorkspaceReviewContextQuery::default()), + ) + .await + .expect("load presentation context"); + + assert_eq!( + context.repair_runtime_conversation_id, + Some(runtime_conversation_id.as_str()) + ); + assert_eq!(context.repair_fixer_kind, Some("pr_fixer")); + + assert!(matches!( + state + .app_state + .agent_workspace_repair_repo + .settle_repair_attempt(SettleAgentWorkspaceRepairAttempt { + attempt_id: child_attempt.id, + generation: child_attempt.generation, + expected_phase: child_attempt.phase, + expected_updated_at: child_attempt.updated_at, + outcome: AgentWorkspaceRepairOutcome::Succeeded, + settled_at: child_attempt.updated_at + chrono::Duration::microseconds(1), + compatibility_projection: None, + events: Vec::new(), + }) + .await + .expect("settle child-hosted attempt"), + SettleAgentWorkspaceRepairAttemptOutcome::Applied(_) + )); + let axum::Json(settled_context) = get_agent_workspace_review_context( + State(state), + Path(conversation_id.to_string()), + HeaderMap::new(), + Query(AgentWorkspaceReviewContextQuery::default()), + ) + .await + .expect("load settled repair context"); + assert_eq!(settled_context.repair_runtime_conversation_id, None); + assert_eq!(settled_context.repair_fixer_kind, None); +} diff --git a/src-tauri/tests/suite_ipc_commands/unified_chat_commands.rs b/src-tauri/tests/suite_ipc_commands/unified_chat_commands.rs index a0af6d89be..40f89497ec 100644 --- a/src-tauri/tests/suite_ipc_commands/unified_chat_commands.rs +++ b/src-tauri/tests/suite_ipc_commands/unified_chat_commands.rs @@ -2566,6 +2566,7 @@ async fn workspace_publish_repair_message_wakes_same_agent_conversation() { &workspace, "Failed to commit: typecheck failed", AgentWorkspaceRepairRuntimeOverrides::default(), + &workspace.conversation_id, ) .await .expect("repair handoff should be sent through chat service"); @@ -3261,6 +3262,7 @@ mod ipc_contract { recover_stale_agent_workspace_publish_repairs_on_startup( std::sync::Arc::clone(&state.agent_conversation_workspace_repo), + std::sync::Arc::clone(&state.agent_workspace_repair_repo), std::sync::Arc::clone(&state.agent_run_repo), ) .await; @@ -3291,6 +3293,7 @@ mod ipc_contract { recover_stale_agent_workspace_publish_repairs_on_startup( std::sync::Arc::clone(&state.agent_conversation_workspace_repo), + std::sync::Arc::clone(&state.agent_workspace_repair_repo), std::sync::Arc::clone(&state.agent_run_repo), ) .await; diff --git a/src-tauri/tests/suite_pr_github/pr_poller_tests.rs b/src-tauri/tests/suite_pr_github/pr_poller_tests.rs index f23b871d42..43875ba972 100644 --- a/src-tauri/tests/suite_pr_github/pr_poller_tests.rs +++ b/src-tauri/tests/suite_pr_github/pr_poller_tests.rs @@ -20,7 +20,7 @@ use ralphx_lib::domain::entities::{ }; use ralphx_lib::domain::repositories::{ AgentConversationWorkspaceRepository, AgentRunRepository, AgentWorkspaceRepairRepository, - BranchUpdateRepository, PlanBranchRepository, + BranchUpdateRepository, ChatConversationRepository, PlanBranchRepository, }; use ralphx_lib::domain::services::github_service::{ GithubServiceTrait, PrMergeStateStatus, PrMergeableState, PrReviewCommentFeedback, @@ -29,7 +29,7 @@ use ralphx_lib::domain::services::github_service::{ use ralphx_lib::infrastructure::agents::claude::agent_names::AGENT_WORKSPACE_PR_FIXER; use ralphx_lib::infrastructure::memory::{ MemoryAgentConversationWorkspaceRepository, MemoryAgentRunRepository, - MemoryBranchUpdateRepository, MemoryPlanBranchRepository, + MemoryBranchUpdateRepository, MemoryChatConversationRepository, MemoryPlanBranchRepository, }; use crate::common::MockGithubService; @@ -214,6 +214,7 @@ async fn agent_workspace_review_feedback_routes_to_same_workspace_agent_once() { let workspace_repo = Arc::new(MemoryAgentConversationWorkspaceRepository::new()); let agent_run_repo: Arc = Arc::new(MemoryAgentRunRepository::new()); let branch_update_repo = Arc::new(MemoryBranchUpdateRepository::new()); + let conversation_repo = Arc::new(MemoryChatConversationRepository::new()); let conversation_id = ChatConversationId::from_string("22222222-2222-2222-2222-222222222222"); let project_id = ProjectId::from_string("project-1".to_string()); let temp_dir = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(); @@ -238,6 +239,9 @@ async fn agent_workspace_review_feedback_routes_to_same_workspace_agent_once() { ); registry .set_branch_update_repo(Arc::clone(&branch_update_repo) as Arc); + registry.set_chat_conversation_repo( + Arc::clone(&conversation_repo) as Arc + ); let chat_service = Arc::new(MockChatService::with_agent_run_repo(Arc::clone( &agent_run_repo, ))); @@ -266,14 +270,24 @@ async fn agent_workspace_review_feedback_routes_to_same_workspace_agent_once() { let options = chat_service.get_sent_options().await; assert_eq!(options.len(), 1); - assert_eq!( - options[0].conversation_id_override, - Some(workspace.conversation_id) - ); + let fixer_conversation_id = options[0] + .conversation_id_override + .expect("PR autofix dispatch must target its persisted fixer conversation"); + assert_ne!(fixer_conversation_id, workspace.conversation_id); assert_eq!( options[0].agent_name_override.as_deref(), Some(AGENT_WORKSPACE_PR_FIXER) ); + let fixer_conversation = conversation_repo + .get_by_id(&fixer_conversation_id) + .await + .unwrap() + .expect("PR autofix dispatch must persist its fixer conversation"); + assert_eq!( + fixer_conversation.parent_conversation_id, + Some(workspace.conversation_id.as_str()) + ); + assert_eq!(fixer_conversation.title.as_deref(), Some("Fix PR #72")); let updated = workspace_repo .get_by_conversation_id(&workspace.conversation_id) @@ -303,6 +317,7 @@ async fn agent_workspace_review_feedback_routes_to_same_workspace_agent_once() { AgentWorkspaceRepairContinuation::ResumePrSupervision ); assert_eq!(attempt.phase, AgentWorkspaceRepairPhase::Repairing); + assert_eq!(attempt.runtime_conversation_id, Some(fixer_conversation_id)); let events = workspace_repo .list_publication_events(&workspace.conversation_id)