diff --git a/doc/plans/2026-10-12-claude-runner-default.md b/doc/plans/2026-10-12-claude-runner-default.md index 1f63413b1eb..56c442477f1 100644 --- a/doc/plans/2026-10-12-claude-runner-default.md +++ b/doc/plans/2026-10-12-claude-runner-default.md @@ -615,3 +615,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/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index 91185423f85..3fa7be6cbd0 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -151,3 +151,11 @@ retain existing execution on unrelated edits and use recorded run configuration for active/recovered runs. Explicit runner changes use revisions and session invalidation. Verify saved configuration, current versus historical run identity, restart and follow-up without granting session-reset authority to an agent. + +Scoped source-only evidence: the +[native transport](../../packages/paperclip-runner/src/live/runnerd-codex-transport.ts) +joins owned startup before certifying cleanup. Unconfirmed preparation or +registration retains recovery state; late registration may retire only its own +route and cannot launch a provider after close. This does not grant an agent +account-setup, cleanup-override or session-reset authority. Verify the no-provider +ownership controls independently from installed/live provider acceptance. diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index 49eb99f5566..6077504d78e 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -84,3 +84,11 @@ different observations, both required in the evidence. - The run log is instance database data; it is not the opt-out first-party Telemetry system or operator-configured OpenTelemetry tracing. - Authentication/model discovery does not establish tool, image, steering, or resumption capability parity. - Use [recovery](./recovery.md) for interrupted runs and [execution environments](./execution-environments.md) for local/SSH/sandbox differences. + +Native setup retirement must join owned startup before cleanup can be confirmed. +[Transport ownership controls](../../packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts) +hold credential preparation or registration without launching a provider, then +verify bounded close, no later launch, and retention of unconfirmed state. A +primary setup timeout remains a failure even when teardown also fails. These +controls cover the lifecycle boundary; final packaged provider execution is +separate evidence. diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts index 5ff210ad59b..6332a402749 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -69,6 +69,7 @@ import { codexSemanticToolSpecs, } from "../drivers/codex/codex-app-server-driver.js"; import { releaseMaterializedNativeRuntimeSkills } from "../drivers/runtime-context-materializer.js"; +import * as runtimeMaterializer from "../drivers/runtime-context-materializer.js"; import { RUNNERD_CANONICAL_ITEM } from "../drivers/codex/codex-driver-values.js"; import { @@ -2811,6 +2812,150 @@ function fakeCodexArgs(stateDirectory: string, ...args: string[]): string[] { ]; } +it("joins owned credential preparation before close returns and never launches after close", async () => { + const root = await mkdtemp(join(tmpdir(), "runnerd-owned-startup-")); + const artifact = join(root, "unexecuted-runner-artifact"); + await writeFile(artifact, "fixture artifact bytes; never executed"); + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let entered!: () => void; + const started = new Promise(resolve => { entered = resolve; }); + const preparation = vi.spyOn(runtimeMaterializer, "prepareIsolatedCodexHome").mockImplementation(async ({ codexHome }) => { + entered(); + await gate; + await mkdir(codexHome, { recursive: true }); + await writeFile(join(codexHome, "owned-write.txt"), "completed owned preparation"); + }); + const registration = vi.fn(async () => ({ connectUrl: "ws://127.0.0.1:1", release: async () => {} })); + const launch = vi.fn(() => { throw new Error("unexecuted late launch"); }); + const bundle = createCapabilityRunnerdCodexTransport({ stateDirectory: root, runnerBinary: artifact, codexCommand: artifact, provider: "codex", closeGraceMs: 1000, + controlPlaneRegistration: registration, runnerProcessLauncher: launch }); + let opening: Promise | undefined; + let closing: Promise | undefined; + try { + opening = bundle.transport.request("thread/start", { cwd: root, model: "gpt-test", dynamicTools: [] }); + void opening.catch(() => {}); + await started; + let closeReturned = false; + closing = bundle.transport.close("owned preparation test"); + void closing.then(() => { closeReturned = true; }, () => {}); + await new Promise(resolve => setTimeout(resolve, 25)); + expect(closeReturned).toBe(false); + release(); + await expect(opening).rejects.toThrow(/closed/); + await closing; + expect(await readFile(join(root, "codex-home", "owned-write.txt"), "utf8")).toBe("completed owned preparation"); + expect(registration).not.toHaveBeenCalled(); + expect(launch).not.toHaveBeenCalled(); + } finally { + release(); + await Promise.allSettled([opening, closing]); + preparation.mockRestore(); + await rm(root, { recursive: true, force: true }); + } +}); + +it("retains owned state when credential preparation outlives bounded close", async () => { + const root = await mkdtemp(join(tmpdir(), "runnerd-retained-startup-")); + const artifact = join(root, "unexecuted-artifact"); + await writeFile(artifact, "never executed"); + let release!: () => void, entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + const preparation = vi.spyOn(runtimeMaterializer, "prepareIsolatedCodexHome").mockImplementation(async () => { entered(); await gate; }); + const registration = vi.fn(async () => ({ connectUrl: "ws://127.0.0.1:1", release: async () => {} })); + const launch = vi.fn(() => { throw new Error("unexpected launch"); }); + const bundle = createCapabilityRunnerdCodexTransport({ stateDirectory: root, runnerBinary: artifact, codexCommand: artifact, provider: "codex", closeGraceMs: 100, + controlPlaneRegistration: registration, runnerProcessLauncher: launch }); + const opening = bundle.transport.request("thread/start", { cwd: root, model: "gpt-test", dynamicTools: [] }); + void opening.catch(() => {}); + try { + await started; + await expect(bundle.transport.close("bounded preparation")).rejects.toThrow("startup cleanup is unconfirmed"); + expect((await stat(root)).isDirectory()).toBe(true); + release(); + await expect(opening).rejects.toThrow(/closed/); + expect(registration).not.toHaveBeenCalled(); + expect(launch).not.toHaveBeenCalled(); + } finally { + release(); await opening.catch(() => {}); preparation.mockRestore(); + await rm(root, { recursive: true, force: true }); + } +}); + +it.each(["settled", "late", "late-release-error"] as const)("retires owned startup registration without post-close launch (%s)", async mode => { + const root = await mkdtemp(join(tmpdir(), "runnerd-startup-registration-")); + const artifact = join(root, "unexecuted-artifact"); + await writeFile(artifact, "never executed"); + let release!: () => void, entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const registered = new Promise(resolve => { entered = resolve; }); + const preparation = vi.spyOn(runtimeMaterializer, "prepareIsolatedCodexHome").mockResolvedValue(undefined); + const retire = vi.fn(async () => { if (mode === "late-release-error") throw new Error("owned registration release failed"); }); + const registration = vi.fn(async () => { entered(); await gate; return { connectUrl: "ws://127.0.0.1:1", release: retire }; }); + const launch = vi.fn(() => { throw new Error("unexpected launch"); }); + const bundle = createCapabilityRunnerdCodexTransport({ stateDirectory: root, runnerBinary: artifact, codexCommand: artifact, provider: "codex", closeGraceMs: mode === "settled" ? 1000 : 100, + controlPlaneRegistration: registration, runnerProcessLauncher: launch }); + const opening = bundle.transport.request("thread/start", { cwd: root, model: "gpt-test", dynamicTools: [] }); + void opening.catch(() => {}); + let closing: Promise | undefined; + try { + await registered; + let closeReturned = false; + closing = bundle.transport.close("owned registration"); + void closing.then(() => { closeReturned = true; }, () => {}); + if (mode === "settled") { + await new Promise(resolve => setTimeout(resolve, 25)); + expect(closeReturned).toBe(false); + } else { + await expect(closing).rejects.toThrow("startup cleanup is unconfirmed"); + expect((await stat(root)).isDirectory()).toBe(true); + } + release(); + await expect(opening).rejects.toThrow(mode === "late-release-error" ? "owned registration release failed" : /closed/); + if (mode === "settled") await closing; + expect(retire).toHaveBeenCalledTimes(1); + expect(launch).not.toHaveBeenCalled(); + } finally { + release(); await Promise.allSettled([opening, closing]); preparation.mockRestore(); + await rm(root, { recursive: true, force: true }); + } +}); + +it("does not certify probe cleanup while owned credential preparation is unconfirmed", async () => { + const root = await mkdtemp(join(tmpdir(), "runnerd-probe-owned-preparation-")); + const artifact = join(root, "unexecuted-artifact"); + await writeFile(artifact, "never executed"); + let release!: () => void, entered!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + const started = new Promise(resolve => { entered = resolve; }); + const preparation = vi.spyOn(runtimeMaterializer, "prepareIsolatedCodexHome").mockImplementation(async () => { entered(); await gate; }); + const cleanup = vi.fn(async () => {}); + const launch = vi.fn(() => { throw new Error("unexpected launch"); }); + const probe = probeNativeRunnerEnvironment({ runtimeDirectory: root, provider: "codex", model: "gpt-test", timeoutMs: 100, + environment: { OPENAI_API_KEY: "fixture" }, onCleanupConfirmed: cleanup, + transportOptions: { runnerBinary: artifact, codexCommand: artifact, runnerProcessLauncher: launch, + controlPlaneRegistration: async () => ({ connectUrl: "ws://127.0.0.1:1", release: async () => {} }) }, + }); + const result = probe.catch(error => error); + try { + await started; + const error = await result; + expect(cleanup).not.toHaveBeenCalled(); + expect(error).toBeInstanceOf(AggregateError); + expect(error.message).toContain("hello probe timed out"); + expect(error.message).toContain("startup cleanup is unconfirmed"); + expect((await stat(root)).isDirectory()).toBe(true); + release(); + await new Promise(resolve => setTimeout(resolve, 25)); + expect(launch).not.toHaveBeenCalled(); + } finally { + release(); await result; preparation.mockRestore(); + await new Promise(resolve => setTimeout(resolve, 25)); + await rm(root, { recursive: true, force: true }); + } +}, 15_000); + it.each(["complete", "api-key", "default-model", "mismatched-model", "model-refresh-error", "model-cleanup-error", "missing-model", "missing-runtime", "missing-completion", "late-bootstrap", "remote-complete", "remote-mismatched-model", "late-remote-preparation", "late-remote-release-error"] as const)("native Codex setup requires its daemon/app-server hello (%s)", async mode => { const root = await mkdtemp(join(tmpdir(), "runnerd-native-setup-")); const runtimeDirectory = join(root, "native-state"); diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index c7eaf5d6f94..4d0b8f36268 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3602,6 +3602,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { #runAttachTemplate: Record | null = null; #closed = false; #closePromise: Promise | null = null; + #startupOperation: Promise | null = null; + #startupDrainTimedOut = false; #controllerDetachedForRestart = false; #failure: Error | null = null; readonly #failureSignal: Promise; @@ -3715,7 +3717,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { throw new Error("native_runner_warm_transition_completion_pending"); } if (method === "initialize") return { user: {} }; - if (method === "thread/start") return this.#start(params); + if (method === "thread/start") return this.#trackStartup(this.#start(params)); if (method === "collaborationMode/list") { // runnerd negotiates the real Codex preset or the provider-proxy-owned // planning contract during session.open. This transport-level mask @@ -3815,7 +3817,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { if (method === "thread/read") { if (this.#core === null) { this.#recoveryTurnBindingPending = true; - await this.#resume(); + await this.#trackStartup(this.#resume()); } // Ask the authenticated runner for its live provider snapshot rather // than reading its filesystem. This both supports remote process owners @@ -4455,8 +4457,33 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { ); } + #trackStartup(operation: Promise): Promise { + this.#startupOperation = operation; + return operation; + } + + #assertStartupOpen(): void { + if (this.#closed) throw new Error("runnerd transport closed during provider startup"); + } + async #closeOnce(): Promise { this.#closed = true; + const { preparationDeadline, closeDeadline } = runnerCloseDeadlines(Date.now(), runnerCloseGraceMs(this.options)); + let startupConfirmed = true; + if (this.#startupOperation !== null) { + let timer: ReturnType | undefined; + try { + await Promise.race([ + this.#startupOperation.catch(() => undefined), + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error("Native startup drain timed out")), Math.max(0, preparationDeadline - Date.now())); + }), + ]); + } catch { + startupConfirmed = false; + this.#startupDrainTimedOut = true; + } finally { clearTimeout(timer); } + } this.#turnStartAdmission?.resolve(false); const adoptedRunner = this.options.adoptExistingRunner; // `settled` is a durable-state assertion, not merely the absence of a @@ -4481,10 +4508,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { // A terminal provider frame can become visible one control loop before // its durable provider suffix is ACKed. Drain it before suspension so a // fresh run authority never inherits the prior run's pending events. - const { preparationDeadline, closeDeadline } = runnerCloseDeadlines( - Date.now(), - runnerCloseGraceMs(this.options), - ); + if (!(await this.#runnerHasExited())) { // Let an already-admitted tool result reach its original provider // before turn.stop can retire that tool-call identity. This shares the @@ -4611,7 +4635,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { // an incomplete or identity-conflicting state remains fail-closed. const finalProviderState = this.#providerDrainState(); const runnerSettled = - runnerSuspended && + startupConfirmed && runnerSuspended && providerDrained && this.#core?.semanticToolResultsSettled() === true && (finalProviderState === null || @@ -4663,6 +4687,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { } throw new NativeSessionCloseUnrecoverableError(settlement); } + if (!startupConfirmed) { + throw new Error("Native runner startup cleanup is unconfirmed; recovery state retained."); + } if (this.#ownsRoot && !adoptedRunner) { rmSync(this.#root, { recursive: true, force: true }); } @@ -4756,6 +4783,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { nativeMcp: nativeMcpLaunchBinding(this.options.environment), }); } + this.#assertStartupOpen(); const opencodeProxyPath = this.options.opencodeProxyPath ?? (provider === "opencode" && !this.options.runnerFilesystemRoot @@ -5056,6 +5084,12 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.#controlPlaneCheckpoint = registration.checkpoint ?? null; this.#controlPlaneRelease = registration.release; } + if (this.#closed && this.#startupDrainTimedOut) { + const release = this.#controlPlaneRelease; + this.#controlPlaneRelease = null; + await release?.(); + } + this.#assertStartupOpen(); const admissionDeadline = this.#coldAdmissionDeadline(); const handle = spawnRunner({ connection: registration?.connection ?? { @@ -5098,7 +5132,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.#handle = handle; this.#watchRunner(handle); await this.#publishSpawnedProcess(handle); + this.#assertStartupOpen(); await registration?.activate?.(); + this.#assertStartupOpen(); if (registration?.failure) { void registration.failure.catch((error: unknown) => { this.#failTransport( @@ -5107,6 +5143,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { }); } await this.#awaitRegistrationReady(registration?.ready); + this.#assertStartupOpen(); this.#evidence.runnerPid = handle.child.pid ?? null; this.#evidence.runnerProcessGroupId = handle.processGroupId ?? null; this.#publish(); @@ -5633,6 +5670,12 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { } : registration.release; } + if (this.#closed && this.#startupDrainTimedOut) { + const release = this.#controlPlaneRelease; + this.#controlPlaneRelease = null; + await release?.(); + } + this.#assertStartupOpen(); // Route and immutable endpoint admission precedes every launch-material // write. A refused transition leaves its retained home/context untouched. if (runtimeContext !== null) { @@ -5664,6 +5707,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { if (warmRecovery) { await this.options.authorizeWarmTransitionRecovery?.("before_bootstrap"); } + this.#assertStartupOpen(); const bootstrapTicket = adoptedRunner ? null : warmRecovery @@ -5679,6 +5723,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { await this.options.authorizeWarmTransitionRecovery?.("before_spawn"); } // A replacement executor restores its cold provider before acknowledging recovery. + this.#assertStartupOpen(); const admissionDeadline = this.#coldAdmissionDeadline(adoptedRunner === undefined); const handle = adoptedRunner ? null 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 && ( - )}