mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 07:55:47 +01:00
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:
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.
|
||||
@@ -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.
|
||||
@@ -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" }]);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user