Merge branch 'codex/claude-runner-default' into codex/claude-runner-platforms

* codex/claude-runner-default:
  fix(claude): require current setup metadata and recover failed reads
  fix(runner): drain owned startup before certifying cleanup
This commit is contained in:
Dotta committed 2026-10-12 19:17:42 -05:00
commit 66925e79c9
8 files changed
+324 -16

No files matched your search

@@ -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.
+8
View File
@@ -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.
+8
View File
@@ -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.
@@ -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<void>(resolve => { release = resolve; });
let entered!: () => void;
const started = new Promise<void>(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<unknown> | undefined;
let closing: Promise<void> | 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<void>(resolve => { release = resolve; });
const started = new Promise<void>(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<void>(resolve => { release = resolve; });
const registered = new Promise<void>(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<void> | 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<void>(resolve => { release = resolve; });
const started = new Promise<void>(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");
@@ -3602,6 +3602,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
#runAttachTemplate: Record<string, unknown> | null = null;
#closed = false;
#closePromise: Promise<void> | null = null;
#startupOperation: Promise<unknown> | null = null;
#startupDrainTimedOut = false;
#controllerDetachedForRestart = false;
#failure: Error | null = null;
readonly #failureSignal: Promise<never>;
@@ -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<T>(operation: Promise<T>): Promise<T> {
this.#startupOperation = operation;
return operation;
}
#assertStartupOpen(): void {
if (this.#closed) throw new Error("runnerd transport closed during provider startup");
}
async #closeOnce(): Promise<void> {
this.#closed = true;
const { preparationDeadline, closeDeadline } = runnerCloseDeadlines(Date.now(), runnerCloseGraceMs(this.options));
let startupConfirmed = true;
if (this.#startupOperation !== null) {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
this.#startupOperation.catch(() => undefined),
new Promise<never>((_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
@@ -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
@@ -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<any>(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<string, unknown> }];
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" }]);
+20 -8
View File
@@ -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 && (
<Button type="button" variant="outline" disabled={runnerDiscovery.isFetching} onClick={() => void runnerDiscovery.refetch()}>
<Button type="button" variant="outline" disabled={runnerDiscovery.isFetching || loginEnvironmentsFetching || loginSettingsFetching || loginPolicyFetching} onClick={() => void retryRunnerAvailability()}>
Retry setup
</Button>
)}