diff --git a/doc/plans/2026-10-12-claude-runner-default.md b/doc/plans/2026-10-12-claude-runner-default.md index fc95b3a075f..7f3d189bc64 100644 --- a/doc/plans/2026-10-12-claude-runner-default.md +++ b/doc/plans/2026-10-12-claude-runner-default.md @@ -555,3 +555,39 @@ The retained installed preview has been inspected in light/dark mode with the original theme restored and agent Save disabled. The browser's requested mobile override did not affect its actual 1150-pixel viewport, so that attempt is not mobile proof and its override was reset. Final responsive qualification remains. + +## Warm metadata, dependency recovery and owned startup — 2026-10-12 + +Three warm-cache regressions reproduced configuration construction while required +metadata was unresolved. Onboarding now waits for current environment, settings +and policy reads as well as matching runner discovery, including refetch over +cached successful data. Each prerequisite failure is visible and Retry setup +refetches the required reads without testing or hiring early. This also closes +fresh review 4253377849. The neutral-role step fixture's empty discovery response +reproduced the exact cloud failure; it now supplies valid Legacy discovery while +retaining role/name assertions. All 193 wizard/step/guided-setup/builder controls, +UI noemit, maps and token gates pass. Exploratory guided-connection changes were +removed; its existing connection flow remains intact. + +Cloud platform job 115058187599 exposed ENOTEMPTY during late-bootstrap cleanup. +A deterministic held-writer test proves transport close returned before owned +credential preparation settled. A separate production-probe test proves the +helper called cleanup-confirmed during that unconfirmed preparation. These are +actual ownership defects, not a waived filesystem retry. Protocol repair +`463ebb62661d81ef1fbd79ae0a7b43ece68830f8` tracks startup, joins it within the +existing close budget and blocks later activation/launch. A drain timeout retains +state; late registration retires only its owned route. Primary setup timeout and +unconfirmed cleanup both remain in the failure. Six no-provider controls pass, +including writer drain, bounded retention, late release/error and the actual +probe callback fence. Runner noemit, declaration generation and maps pass; no +provider process, local Rust or Docker build was used. Historical Pi/Copilot +identities are unchanged by this transport-only correction. + +The repair is in this chat's protocol worktree, not the frozen upstream #16028. +Its placement must be reconciled with the source owner's normal merge sequence +before either default rollout merges. Automatic approval review rejected cross- +chat status messages because it did not accept the earlier approval retrieved +from the source chat as current-chat authorization; the direct coordination +question remains pending. Public PR/evidence updates continue within this task. +Fresh exact-head cloud runner/native execution is required, including the failing +late-bootstrap case. No package campaign or new paid task has been dispatched. diff --git a/ui/src/components/OnboardingWizard.step.test.tsx b/ui/src/components/OnboardingWizard.step.test.tsx index bc90ada6132..54b53bda774 100644 --- a/ui/src/components/OnboardingWizard.step.test.tsx +++ b/ui/src/components/OnboardingWizard.step.test.tsx @@ -178,7 +178,10 @@ describe("OnboardingWizard — which step it lands on", () => { dialogState.onboardingOpen = false; dialogState.onboardingOptions = {}; dialogState.onboardingRouteDismissed = false; - mockAdaptersApi.list.mockResolvedValue([]); + // The scoped discovery contract must resolve before the hire seam runs. + mockAdaptersApi.list.mockResolvedValue(["claude_local", "codex_local"].map(type => ({ + type, loaded: true, disabled: false, defaultRunner: "legacy", supportedRunners: ["legacy"], + }))); mockGoalsApi.list.mockResolvedValue([]); mockAgentsApi.adapterModels.mockResolvedValue([]); // The hire step lists the company's agents first so it can adopt one that diff --git a/ui/src/components/OnboardingWizard.test.tsx b/ui/src/components/OnboardingWizard.test.tsx index 7871c7a5470..b6112c59617 100644 --- a/ui/src/components/OnboardingWizard.test.tsx +++ b/ui/src/components/OnboardingWizard.test.tsx @@ -3170,6 +3170,57 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", ( } finally { await act(async () => root.unmount()); } }); + it.each(["environments", "settings", "policy"] as const)("does not use warm Claude discovery before %s resolves", async pending => { + let finish!: (value: any) => void; + const delayed = new Promise(resolve => { finish = resolve; }); + if (pending === "environments") mockEnvironmentsApi.list.mockReturnValue(delayed); + else if (pending === "settings") mockInstanceSettingsApi.get.mockReturnValue(delayed); + else mockInstanceSettingsApi.getExperimental.mockReturnValue(delayed); + mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!)); + const { root, queryClient } = await openStep4({ adapterType: "claude_local" }); + try { + await act(async () => { + // A warm value remains successful while its current fetch is pending. + if (pending === "environments") queryClient.setQueryData(queryKeys.environments.list("company-new"), [LOCAL_ENVIRONMENT]); + else if (pending === "settings") queryClient.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: null }); + else queryClient.setQueryData(queryKeys.instance.experimentalSettings, { enableManagedSandboxOnly: false }); + for (const environmentId of [null, LOCAL_ENVIRONMENT.id]) queryClient.setQueryData(queryKeys.adapters.availability("company-new", environmentId), [{ type: "claude_local", defaultRunner: "paperclip" }]); + queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }] }); + }); + await pickSource(/Claude/); + await settle(); + expect(mockAdapterBuild.buildAdapterConfig).not.toHaveBeenCalled(); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + await act(async () => finish(pending === "environments" ? [LOCAL_ENVIRONMENT] : pending === "settings" ? { defaultEnvironmentId: null } : { enableManagedSandboxOnly: false })); + await settle(); + expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); + const hired = mockAgentsApi.hire.mock.calls[0] as unknown as [string, { adapterConfig: Record }]; + expect(hired[1].adapterConfig.maxTurnsPerRun).toBe(1000); + } finally { await act(async () => root.unmount()); } + }); + + it.each(["environments", "settings", "policy"] as const)("reports failed Claude %s resolution and retries without early verification", async failed => { + if (failed === "environments") mockEnvironmentsApi.list.mockRejectedValue(new Error("Unavailable")); + else if (failed === "settings") mockInstanceSettingsApi.get.mockRejectedValue(new Error("Unavailable")); + else mockInstanceSettingsApi.getExperimental.mockRejectedValue(new Error("Unavailable")); + const { root, queryClient } = await openStep4({ adapterType: "claude_local" }); + try { + await act(async () => queryClient.setQueryData(["ai-connections", "company-new"], { currentUserId: "user-1", connections: [{ id: "saved", grantId: "grant", companyId: "company-new", provider: "anthropic", method: "subscription", ownership: "personal", ownerUserId: "user-1", status: "connected", isDefault: true }] })); + await pickSource(/Claude/); + await settle(); + expect(document.body.textContent).toContain("Could not load execution environment settings"); + expect(mockAgentsApi.testEnvironment).not.toHaveBeenCalled(); + expect(mockAgentsApi.hire).not.toHaveBeenCalled(); + mockEnvironmentsApi.list.mockResolvedValue([LOCAL_ENVIRONMENT]); + mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: null }); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableManagedSandboxOnly: false }); + await click("Retry setup"); + expect(mockAgentsApi.testEnvironment).toHaveBeenCalledTimes(1); + expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1); + } finally { await act(async () => root.unmount()); } + }); + it("does not hire from obsolete Claude discovery after the target changes during verification", async () => { mockAdapterBuild.buildAdapterConfig.mockImplementation(values => buildClaudeLocalConfig(values!)); mockAdaptersApi.list.mockImplementation(async ({ environmentId }: { environmentId: string | null }) => [{ type: "claude_local", defaultRunner: environmentId === "new-local-default" ? "paperclip" : "legacy" }]); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 1400f20fe34..10d9b7bcbf4 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -947,14 +947,14 @@ function OnboardingWizardInner({ // managed-sandbox-only redirect (see AgentConfigForm.tsx:618-640). A render // must not throw, so a resolver error yields no login environment rather // than an error boundary. - const { data: loginEnvironmentList = [], isSuccess: loginEnvironmentsReady } = useQuery({ + const { data: loginEnvironmentList = [], isSuccess: loginEnvironmentsReady, isFetching: loginEnvironmentsFetching, isError: loginEnvironmentsFailed, refetch: refetchLoginEnvironments } = useQuery({ queryKey: createdCompanyId ? queryKeys.environments.list(createdCompanyId) : ["environments", "none"], queryFn: () => environmentsApi.list(createdCompanyId!), enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4, }); - const { data: instanceSettingsForLogin, isSuccess: loginSettingsReady } = useQuery({ + const { data: instanceSettingsForLogin, isSuccess: loginSettingsReady, isFetching: loginSettingsFetching, isError: loginSettingsFailed, refetch: refetchLoginSettings } = useQuery({ queryKey: queryKeys.instance.settings, queryFn: () => instanceSettingsApi.get(), enabled: effectiveOnboardingOpen && step === 4, @@ -962,7 +962,7 @@ function OnboardingWizardInner({ // Wanted across the whole arc, not just the connect step. The progress strip // reads it too — see `enteredFromCloud` — and a value fetched only on step 4 // would let the strip change length as the customer walked through it. - const { data: experimentalSettingsForLogin, isSuccess: loginPolicyReady } = useQuery({ + const { data: experimentalSettingsForLogin, isSuccess: loginPolicyReady, isFetching: loginPolicyFetching, isError: loginPolicyFailed, refetch: refetchLoginPolicy } = useQuery({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), enabled: effectiveOnboardingOpen && step >= 3 && step <= 5, @@ -985,23 +985,35 @@ function OnboardingWizardInner({ loginEnvironmentList, experimentalSettingsForLogin?.enableManagedSandboxOnly, ]); + const loginEnvironmentSettingsReady = loginEnvironmentsReady && loginSettingsReady && loginPolicyReady + && !loginEnvironmentsFetching && !loginSettingsFetching && !loginPolicyFetching; const runnerDiscovery = useQuery({ queryKey: queryKeys.adapters.availability(createdCompanyId, resolvedLoginEnvironmentId), queryFn: () => adaptersApi.list({ companyId: createdCompanyId!, environmentId: resolvedLoginEnvironmentId }), enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4 - && loginEnvironmentsReady && loginSettingsReady && loginPolicyReady, + && loginEnvironmentSettingsReady, }); const needsRunnerDiscovery = ["codex_local", "claude_local"].includes(adapterType) && runner === "auto"; const resolvedRunner = runner === "auto" ? runnerDiscovery.data?.find(item => item.type === adapterType)?.defaultRunner : runner; const runnerDiscoveryReady = !needsRunnerDiscovery - || (runnerDiscovery.isSuccess && !runnerDiscovery.isFetching && resolvedRunner !== undefined); - const runnerDiscoveryError = needsRunnerDiscovery && runnerDiscovery.isError - ? "Could not load runner availability. Retry setup." + || (loginEnvironmentSettingsReady + && runnerDiscovery.isSuccess && !runnerDiscovery.isFetching && resolvedRunner !== undefined); + const runnerDiscoveryError = needsRunnerDiscovery && (loginEnvironmentsFailed || loginSettingsFailed || loginPolicyFailed) + ? "Could not load execution environment settings. Retry setup." + : needsRunnerDiscovery && runnerDiscovery.isError + ? "Could not load runner availability. Retry setup." : needsRunnerDiscovery && runnerDiscovery.isSuccess && resolvedRunner === undefined ? "Runner availability is missing for this harness. Retry setup." : null; + async function retryRunnerAvailability() { + if (!loginEnvironmentSettingsReady) { + await Promise.all([refetchLoginEnvironments(), refetchLoginSettings(), refetchLoginPolicy()]); + } else { + await runnerDiscovery.refetch(); + } + } const visibleAdapterEnvError = adapterEnvError ?? runnerDiscoveryError; const runnerSelectionKey = JSON.stringify([createdCompanyId, resolvedLoginEnvironmentId, adapterType, runner, resolvedRunner]); const runnerSelectionRef = useRef(runnerSelectionKey); @@ -2966,7 +2978,7 @@ function OnboardingWizardInner({ )} {runnerDiscoveryError && ( - )}