diff --git a/doc/plans/2026-10-12-claude-runner-default.md b/doc/plans/2026-10-12-claude-runner-default.md index 5adec643dd0..73e270a10d5 100644 --- a/doc/plans/2026-10-12-claude-runner-default.md +++ b/doc/plans/2026-10-12-claude-runner-default.md @@ -674,3 +674,85 @@ assertions remain unchanged. The full fake-transport suite passes 72 with one existing skip; runner noemit and maps pass. Fresh exact-head runner CI remains required after this test-only correction. No package campaign, paid provider call, local native build, Docker build or upstream prerequisite edit. + +## Guided creation with refreshed settings — 2026-10-12 + +Review 4253594222 on behavior head `4440df2e44e346080308e40ec3287c94c7f776a2` +identified stale cached environment selection during a background instance-settings +read. The mounted reproduction actually calls setup against cached SSH/Legacy +before the pending current settings select local/native, rather than merely +observing a loading label. Guided Claude/Codex setup now requires current completed +environment, settings, execution policy, experimental policy and matching Auto +runner discovery reads before testing or hiring. Existing connection controls stay +mounted inside a disabled fieldset while refreshing, preserving an API-key draft. +Both the cached SSH-to-local case and same-environment discovery refresh pass; +all 65 guided setup controls, UI noemit, token gates and structural maps pass. +Logs: `/private/tmp/claude-guided-current-settings-before.log`, +`/private/tmp/claude-guided-current-settings-draft-controls.log`, and +`/private/tmp/claude-guided-current-settings-final-{types,tokens,maps}.log`. +This repair is prepared locally while the source owner integrates the common +transport correction; published behavior head remains the literal 4440 revision. +No paid turns, local native builds or Docker builds were performed. + +Platform head `173a73ec95386d7b9eac50d87699a16ec27da3b3` failed a credential +fixture during its two-port reservation (EADDRINUSE before the production staging +call). The existing bounded three-port fixture reservation helper is extended to +hold two candidates, retrying fixture preparation only and never a production +staging call. A foreign-collision control verifies that foreign ownership survives, +the auth sentinel stays unchanged, and preparation stops within eight attempts. +All 42 credential controls and runner noemit pass locally after supplying the +worktree's declared tsx test loader. Logs: +`/private/tmp/claude-quorum-fixture-complete-controls.log` and +`/private/tmp/claude-quorum-fixture-final-types.log`. This test-only correction +is prepared in the protocol worktree and has not been published during the parent +integration hold. The independent serialized issue-route timeout remains under +phase diagnosis; a subsequent full mocked suite passes 36 controls but does not +waive the cloud failure or prove its latency cause. Final source composition, +required CI and the provider-free cloud packaging campaign remain pending. + +## Serialized route fixture initialization — 2026-10-12 + +The failing inherited-pause cloud case timed out at its 15000 ms outer test bound. +Temporary local phase instrumentation measured cold route imports at 9741 ms +(and 11920 ms in a separate diagnostic attempt), followed by a POST 409 response +in 11 ms and PATCH 409 in 4 ms. The subsequent full mocked suite passed 36 cases; +these measurements do not establish the exact unrecorded cloud latency. Route +module loading now runs in beforeEach after the same per-case module reset, +unmocking and mock initialization. It uses the existing 30000 ms setup-hook bound; +the 15000 ms request/test bound, application construction, policy assertions and +mutation/wakeup checks are unchanged. All 36 treatment controls pass. Temporary +phase instrumentation was removed. Evidence: +`/private/tmp/claude-pause-route-phases.txt`, +`/private/tmp/claude-pause-route-targeted-phases.log`, and +`/private/tmp/claude-pause-route-hook-controls.log`. Exact-head cloud verification +is still required; no timeout is waived and no provider or native build was run. + +## Mobile retained-preview inspection — 2026-10-12 + +The in-app browser's viewport override did not apply, including to a fresh tab, +so it supplies no mobile acceptance evidence. The existing Product E2E +`openConnectionBrowser` helper then opened a private temporary Chromium profile +against the retained preview. At an actual 390×844 viewport, light and dark +runner menus fit within the document's 390 px scroll width. Enter opens the +Runner listbox with native/Legacy choices; Escape preserves the saved native +choice. Screenshots fast-forward CSS color transitions. Mutation requests were +blocked by the test browser (none attempted); no provider prompts or setup probes +were sent. This is a bounded UI inspection, not a catalog workflow grade. + +Evidence remains tied to compiled source +`557c3064a30779e86e1ae312eab3d1d42c3a8b8d` with the recorded controller +overlays, and must be repeated against final installed packages. The older +preview's Linux Legacy default label is not evidence for the later platform Auto +change. Report, light screenshot and dark screenshot are attached to the completed +isolated x64 task as work products, with independently downloaded SHA-256 matches. +The task stays done and the agent stays idle. The Product E2E helper's local source +is `42e5d9bae436254bba6747e7d26be0cce13b3d0c`. +Report attachment: `e6206748-a785-4c02-9bbf-f4db8a3b22f9`, work product +`e7f6a023-e754-424b-bdc5-eb4087a79172`; light attachment +`7c324aa1-d1c0-45cd-b314-c3106cac302d`, work product +`e81b6b06-e64d-4ed5-aca7-c936a5235c81`; dark attachment +`2815f32b-86d5-48ed-a0f4-7e35554e54ca`, work product +`6fb9753e-19e0-496f-bf0a-f2de4d331895`. +Local receipts: `/private/tmp/claude-responsive-preview-evidence.json`, +`/private/tmp/claude-responsive-evidence-upload.json`, and +`/private/tmp/claude-responsive-evidence-independent-check.json`. diff --git a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts index 8a18b200122..c13ffcb1596 100644 --- a/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/codex-credentials.test.ts @@ -245,13 +245,30 @@ describe("managed Codex credentials", () => { } }); + it("prepares two occupied candidates without disturbing a foreign collision", async () => { + const foreign = await silentPrimaryQuorumFixture(); + const nextFixture = vi.fn().mockResolvedValueOnce(foreign.fixture).mockImplementation(credentialFixture); + let prepared: Awaited> | undefined; + try { + prepared = await silentPrimaryQuorumFixture(nextFixture, "quorum"); + expect(nextFixture.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(nextFixture.mock.calls.length).toBeLessThanOrEqual(8); + expect(prepared.fixture.home).not.toBe(foreign.fixture.home); + await expect(listenSilently(credentialLeasePorts(foreign.fixture.home)[0])).rejects.toMatchObject({ code: "EADDRINUSE" }); + const destination = join(prepared.fixture.home, "auth.json"); + await writeFile(destination, '{"sentinel":true}', { mode: 0o600 }); + await expect(stageManagedCodexCredential({ agentHomeDirectory: prepared.fixture.home, environment: { PAPERCLIP_ACPX_CODEX_AUTH_JSON_SECRET: '{"owner":"contender"}' } })).rejects.toThrow("already has an active lease"); + await expect(readFile(destination, "utf8")).resolves.toBe('{"sentinel":true}'); + } finally { + await prepared?.occupied.close(); + await foreign.occupied.close(); + } + }); + it("fails before auth mutation when two quorum candidates are occupied", async () => { - const fixture = await credentialFixture(); + const { fixture, occupied } = await silentPrimaryQuorumFixture(credentialFixture, "quorum"); const destination = join(fixture.home, "auth.json"); await writeFile(destination, '{"sentinel":true}', { mode: 0o600 }); - const ports = credentialLeasePorts(await realpath(fixture.home)); - const first = await listenSilently(ports[0]); - const second = await listenSilently(ports[1]); try { await expect( stageManagedCodexCredential({ @@ -265,7 +282,7 @@ describe("managed Codex credentials", () => { '{"sentinel":true}', ); } finally { - await Promise.all([first.close(), second.close()]); + await occupied.close(); } }); @@ -1654,7 +1671,7 @@ async function credentialFixture(): Promise<{ root: string; home: string }> { async function silentPrimaryQuorumFixture( nextFixture = credentialFixture, - occupiedIndex: 0 | 1 = 0, + occupiedIndex: 0 | 1 | "quorum" = 0, ) { for (let attempt = 0; attempt < 8; attempt += 1) { const fixture = await nextFixture(); @@ -1674,22 +1691,30 @@ async function silentPrimaryQuorumFixture( continue; throw error; } - // Only fixture preparation may retry. Release the two free candidates + const occupiedIndices = occupiedIndex === "quorum" ? [0, 1] : [occupiedIndex]; + const occupied = { + close: async () => { + const closed = await Promise.allSettled(owned.filter((_, index) => occupiedIndices.includes(index)).map(listener => listener.close())); + const failure = closed.find(result => result.status === "rejected"); + if (failure?.status === "rejected") throw failure.reason; + }, + }; + // Only fixture preparation may retry. Release the unselected candidates // immediately before the caller stages once. A foreign bind racing this // handoff remains a visible production-call failure, never a hidden retry. const released = await Promise.allSettled( owned - .filter((_, index) => index !== occupiedIndex) + .filter((_, index) => !occupiedIndices.includes(index)) .map((listener) => listener.close()), ); const releaseFailure = released.find( (result) => result.status === "rejected", ); if (releaseFailure?.status === "rejected") { - await owned[occupiedIndex]!.close(); + await occupied.close(); throw releaseFailure.reason; } - return { fixture, occupied: owned[occupiedIndex]! }; + return { fixture, occupied }; } throw new Error("Silent-primary credential fixture reservation exhausted."); } 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 6332a402749..f9cd2051838 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.test.ts @@ -396,6 +396,9 @@ it("publishes spawned runner ownership before waiting for provider startup", asy }); const opening = bundle.transport.request("thread/start", { cwd: tmpdir(), dynamicTools: codexSemanticToolSpecs() }); try { + // Same-tick recovery must not mutate an already admitted fresh startup. + await expect(bundle.transport.request("thread/read", { threadId: "rejected-duplicate" })) + .rejects.toThrow("PRP provider thread is already started"); await vi.waitFor(() => expect(onSpawn).toHaveBeenCalledOnce()); expect(onSpawn).toHaveBeenCalledWith({ pid: expect.any(Number), processGroupId: expect.any(Number), startedAt: expect.any(String) }); expect(bundle.transport.processInfo?.().pid).toBeGreaterThan(0); @@ -403,13 +406,17 @@ it("publishes spawned runner ownership before waiting for provider startup", asy releaseOwnership(); await opening; expect(activate).toHaveBeenCalledOnce(); + const turnStarted = expectTurnStarted(bundle.transport.notifications()[Symbol.asyncIterator]()); + void turnStarted.catch(() => undefined); + await bundle.transport.request("turn/start", { input: [{ type: "text", text: "Verify fixture turn delivery." }] }); + await turnStarted; } finally { releaseOwnership(); await opening.catch(() => undefined); await bundle.transport.close(); await rm(stateDirectory, { recursive: true, force: true }); } -}); +}, 30_000); it("launches runnerd with its production durable outbox limits", () => { expect(runnerdLaunchProfileInternals.maxOutboxBytes).toBe(16 * 1024 * 1024); @@ -2177,6 +2184,115 @@ it.each(["installed", "explicit"] as const)("records the selected %s Codex comma } }); +it("does not invoke deferred startup preparation after immediate close", async () => { + const prepare = vi.spyOn(runtimeMaterializer, "prepareIsolatedCodexHome").mockResolvedValue(undefined); + const registration = vi.fn(async () => ({ connectUrl: "ws://127.0.0.1:1", release: () => undefined })); + const launch = vi.fn(() => { throw new Error("unexpected launch"); }); + const { transport } = createCapabilityRunnerdCodexTransport({ + runnerBinary: resolve(import.meta.dirname, "../../package.json"), codexCommand: "/unused-fixture-command", sourceCodexHome: "", + controlPlaneRegistration: registration, runnerProcessLauncher: launch, + }); + const opening = transport.request("thread/start", { cwd: tmpdir(), dynamicTools: [] }); + void opening.catch(() => undefined); + try { + await transport.close(); + await expect(opening).rejects.toThrow(/closed/); + expect(prepare).not.toHaveBeenCalled(); + expect(registration).not.toHaveBeenCalled(); + expect(launch).not.toHaveBeenCalled(); + } finally { + await opening.catch(() => undefined); + await transport.close().catch(() => undefined); + prepare.mockRestore(); + } +}); + +it("owns blocked Codex home preparation through driver abort without later registration or launch", async () => { + const root = await mkdtemp(join(tmpdir(), "runnerd-blocked-home-preparation-")); + const runnerBinary = join(root, "runnerd"); + const command = join(root, "codex"); + await writeFile(runnerBinary, "unexecuted fixture artifact", { mode: 0o700 }); + await writeFile(command, "#!/bin/sh\nexit 99\n", { mode: 0o700 }); + let releasePreparation!: () => void; + const preparationReleased = new Promise(resolve => { releasePreparation = resolve; }); + let markPreparationStarted!: () => void; + const preparationStarted = new Promise(resolve => { markPreparationStarted = resolve; }); + let closeSettled = false; + let preparationContinuedAfterClose = false; + const prepare = vi.spyOn(runtimeMaterializer, "prepareIsolatedCodexHome").mockImplementation(async input => { + markPreparationStarted(); + await preparationReleased; + preparationContinuedAfterClose = closeSettled; + // A synthetic, credential-free write makes the owned preparation visible. + await mkdir(input.codexHome, { mode: 0o700 }); + await writeFile(join(input.codexHome, "preparation-marker"), "fixture preparation drained\n", { mode: 0o600 }); + }); + const registration = vi.fn(async () => ({ + connection: { mode: "connect" as const, connectUrl: "ws://127.0.0.1:1/fixture-never-connected" }, + release: () => undefined, + })); + const launch = vi.fn(() => { throw new Error("fixture_stop_before_runner_launch"); }); + const { transport } = createCapabilityRunnerdCodexTransport({ + provider: "codex", runnerBinary, codexCommand: command, stateDirectory: root, + sourceCodexHome: "", environment: {}, controlPlaneRegistration: registration, runnerProcessLauncher: launch, + }); + const request = transport.request.bind(transport); + let bootstrapOperation: Promise> | undefined; + const requestSpy = vi.spyOn(transport, "request").mockImplementation((method, params) => { + const operation = request(method, params); + if (method === "thread/start") bootstrapOperation = operation; + return operation; + }); + const closeTransport = transport.close.bind(transport); + let closeOperation: Promise | undefined; + const closeSpy = vi.spyOn(transport, "close").mockImplementation(reason => { + closeOperation ??= closeTransport(reason).then(() => { closeSettled = true; }); + return closeOperation; + }); + const controller = new AbortController(); + const cancelled = new Error("fixture Codex bootstrap cancelled"); + const driver = new CodexAppServerDriver({ + taskEnvelope: { + schema: "paperclip.skillless_task.v1", objective: "Credential-free bootstrap ownership fixture.", + completionContract: { revision: "blocked-preparation-v1", criteria: [] }, + constraints: [], expectedResultSchema: "paperclip.run_result.v1", + }, + conversationMode: "direct", environment: {}, transportFactory: () => transport, + }); + let openingSettled = false; + const opening = driver.openSession({ runId: "fixture-blocked-home", normalizedSessionId: "fixture-blocked-home", workingDirectory: root, signal: controller.signal }) + .then(value => { openingSettled = true; return { value }; }, error => { openingSettled = true; return { error }; }); + try { + await preparationStarted; + // A rejected duplicate must not replace the first admitted startup owner. + await expect(transport.request("thread/start", { cwd: root, dynamicTools: [] })) + .rejects.toThrow("PRP provider thread is already started"); + controller.abort(cancelled); + await new Promise(resolve => setImmediate(resolve)); + expect.soft(closeOperation).toBeDefined(); + expect.soft(closeSettled).toBe(false); + expect.soft(openingSettled).toBe(false); + releasePreparation(); + const outcome = await opening; + await closeOperation; + await bootstrapOperation?.catch(() => undefined); + expect.soft(preparationContinuedAfterClose).toBe(false); + expect.soft(registration.mock.calls.length).toBe(0); + expect.soft(launch.mock.calls.length).toBe(0); + expect.soft(outcome).toHaveProperty("error"); + expect.soft(outcome).toHaveProperty("error", cancelled); + } finally { + releasePreparation(); + await opening; + await bootstrapOperation?.catch(() => undefined); + await transport.close().catch(() => undefined); + requestSpy.mockRestore(); + closeSpy.mockRestore(); + prepare.mockRestore(); + await rm(root, { recursive: true, force: true }); + } +}); + it("rejects remote Codex without a guest executable before resolving controller dependencies", async () => { const root = await mkdtemp(join(tmpdir(), "paperclip-runner-remote-codex-command-")); const runnerBinary = join(root, "runnerd"); @@ -8009,6 +8125,41 @@ it("keeps an adopted runner alive when the detached controller finalizer closes" it("adopts an opening session without a checkpoint instead of bootstrapping a duplicate provider", () => verifyLiveRunnerAdoption(false, false, false, false, true), 30_000); +it("cancels startup readiness before releasing the route that settles its ready callback", async () => { + const root = await mkdtemp(join(tmpdir(), "runnerd-ready-release-cycle-")); + let releaseReady!: () => void; + const readyReleased = new Promise(resolve => { releaseReady = resolve; }); + let markReadyStarted!: () => void; + const readyStarted = new Promise(resolve => { markReadyStarted = resolve; }); + const release = vi.fn(async () => { releaseReady(); }); + let core!: DurablePrpControlPlane; + const bundle = createCapabilityRunnerdCodexTransport({ + runnerBinary: defaultCapabilityRunnerdBinary(), codexCommand: fakeCodex, + codexArgs: fakeCodexArgs(root), stateDirectory: root, sourceCodexHome: "", environment: {}, closeGraceMs: 2_000, + controlPlaneRegistration: async authority => { + core = authority; + await authority.start(); + return { ready: () => { markReadyStarted(); return readyReleased; }, release }; + }, + }); + const opening = bundle.transport.request("thread/start", { cwd: root, dynamicTools: [] }); + void opening.catch(() => undefined); + try { + await readyStarted; + await vi.waitFor(() => expect(core.store.state.commands.find(command => command.type === "session.open")?.status).toBe("completed")); + await expect(bundle.transport.close("cancel pending ingress authentication")).resolves.toBeUndefined(); + await expect(opening).rejects.toThrow(/closed/); + expect(release).toHaveBeenCalledOnce(); + expect(bundle.evidence().runnerExited).toBe(true); + expect(bundle.evidence().diagnostics.join("\n")).not.toContain("startup cleanup is unconfirmed"); + } finally { + releaseReady(); + await opening.catch(() => undefined); + await bundle.transport.close().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } +}, 30_000); + it("surfaces a runner exit while provider-ingress readiness is still pending", async () => { const neverReady = new Promise(() => undefined); const bundle = createCapabilityRunnerdCodexTransport({ diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 4d0b8f36268..471686d4d43 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3601,6 +3601,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { #authorizedTools: Record | null = null; #runAttachTemplate: Record | null = null; #closed = false; + readonly #closedSignal: Promise; + #resolveClosedSignal!: () => void; #closePromise: Promise | null = null; #startupOperation: Promise | null = null; #startupDrainTimedOut = false; @@ -3641,6 +3643,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.#failureSignal = new Promise((_resolve, reject) => { this.#rejectFailureSignal = reject; }); + this.#closedSignal = new Promise(resolve => { + this.#resolveClosedSignal = resolve; + }); // Failure is also observed by request/notification paths. Register an // internal handler so a process exit after the owner has closed the // session cannot become an unhandled process-level rejection. @@ -3717,7 +3722,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.#trackStartup(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 @@ -3816,8 +3821,10 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { } if (method === "thread/read") { if (this.#core === null) { - this.#recoveryTurnBindingPending = true; - await this.#trackStartup(this.#resume()); + await this.#trackStartup(() => { + this.#recoveryTurnBindingPending = true; + return this.#resume(); + }); } // Ask the authenticated runner for its live provider snapshot rather // than reading its filesystem. This both supports remote process owners @@ -4431,6 +4438,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { if (this.#closed) return; this.#controllerDetachedForRestart = true; this.#closed = true; + this.#resolveClosedSignal(); this.#turnStartAdmission?.resolve(false); if (this.#pump !== null) clearInterval(this.#pump); this.#pump = null; @@ -4457,7 +4465,16 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { ); } - #trackStartup(operation: Promise): Promise { + #trackStartup(start: () => Promise): Promise { + // Admit one owner before invoking startup. A duplicate request's rejection + // must never replace the preparation that close is responsible for joining. + if (this.#startupOperation !== null) { + return Promise.reject(new Error("PRP provider thread is already started")); + } + const operation = Promise.resolve().then(() => { + this.#assertStartupOpen(); + return start(); + }); this.#startupOperation = operation; return operation; } @@ -4468,6 +4485,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { async #closeOnce(): Promise { this.#closed = true; + this.#resolveClosedSignal(); const { preparationDeadline, closeDeadline } = runnerCloseDeadlines(Date.now(), runnerCloseGraceMs(this.options)); let startupConfirmed = true; if (this.#startupOperation !== null) { @@ -5598,6 +5616,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { >; let oldTransitionRegistration: RecoveryRegistration | null = null; let newTransitionRegistration: RecoveryRegistration | null = null; + this.#assertStartupOpen(); if (warmRecovery && this.options.controlPlaneRegistration) { try { oldTransitionRegistration = await this.options.controlPlaneRegistration( @@ -5616,6 +5635,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { "native_runner_warm_transition_registered_endpoint_mismatch", ); } + this.#assertStartupOpen(); newTransitionRegistration = await this.options.controlPlaneRegistration( core, warmRecovery.proof.receipt.newIdentity, @@ -5691,6 +5711,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { await releaseMaterializedNativeRuntimeSkills( resolve(localCodexHome, "skills"), ); + this.#assertStartupOpen(); await prepareIsolatedCodexHome({ context: sourceRuntimeContext, codexHome: localCodexHome, @@ -5769,10 +5790,13 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.#watchRunner(handle); await this.#publishSpawnedProcess(handle); } + this.#assertStartupOpen(); if (oldTransitionRegistration && newTransitionRegistration) { await oldTransitionRegistration.activate?.(); + this.#assertStartupOpen(); await newTransitionRegistration.activate?.(); } else await registration?.activate?.(); + this.#assertStartupOpen(); if (registration?.failure) { void registration.failure.catch((error: unknown) => { this.#failTransport( @@ -5781,6 +5805,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { }); } if (!adoptedRunner) await this.#awaitRegistrationReady(registration?.ready); + this.#assertStartupOpen(); if (adoptedRunner) { this.#evidence.runnerPid = adoptedRunner.pid; this.#evidence.runnerProcessGroupId = adoptedRunner.processGroupId; @@ -5797,6 +5822,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { !recoveryIdentityMatches(core.store.state.identity, desiredIdentity) || core.store.state.warmTransition !== undefined ) { + this.#assertStartupOpen(); if (Date.now() >= deadline || (await this.#runnerHasExited())) { throw new Error("native_runner_warm_transition_recovery_pending"); } @@ -5811,11 +5837,13 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.#deferredTurnStartEvents = []; if (oldTransitionRegistration && newTransitionRegistration) { await oldTransitionRegistration.release(); + this.#assertStartupOpen(); this.#controlPlaneRelease = newTransitionRegistration.release; this.#controlPlaneCheckpoint = newTransitionRegistration.checkpoint ?? null; } } + this.#assertStartupOpen(); this.#pump = setInterval(() => this.#pumpEventsSafely(), 5); if (adoptedRunner) { await this.#awaitAdoptedRunnerConnection( @@ -6855,8 +6883,11 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { await awaitAdoptedRunnerAuthentication({ activeConnectionCount: () => core.activeRunnerConnectionCount(), isAlive: () => adoptedRunner.isAlive(), - throwIfFailed: () => this.#throwIfFailed(), - failure: this.#failureSignal, + throwIfFailed: () => { this.#assertStartupOpen(); this.#throwIfFailed(); }, + failure: Promise.race([ + this.#failureSignal, + this.#closedSignal.then(() => { throw new Error("runnerd transport closed during provider startup"); }), + ]), ready, timeoutMs: this.options.runnerReconnectGraceMs ?? 30_000, }); @@ -6865,6 +6896,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { this.#failTransport(failure); throw failure; } + this.#assertStartupOpen(); this.#adoptedRunnerAuthenticated = true; this.#diagnostic( `adopted runner ${adoptedRunner.pid} authenticated to its durable PRP authority`, @@ -7069,11 +7101,18 @@ class DurablePrpCodexTransport implements CodexAppServerTransport { async #awaitRegistrationReady( ready: (() => Promise) | undefined, ): Promise { + this.#assertStartupOpen(); if (ready === undefined) { this.#throwIfFailed(); return; } - await Promise.race([ready(), this.#failureSignal]); + await Promise.race([ + ready(), this.#failureSignal, + // Readiness is a notification wait, not launch-material preparation. + // Its route owner is released by close; joining it first would cycle. + this.#closedSignal.then(() => { this.#assertStartupOpen(); }), + ]); + this.#assertStartupOpen(); this.#throwIfFailed(); } diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index 217b07f0692..5bf63139673 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -205,12 +205,17 @@ function registerModuleMocks() { })); } -async function createApp(transaction: (callback: (tx: Record) => Promise) => Promise = - async (callback) => callback({})) { - const [{ errorHandler }, { issueRoutes }] = await Promise.all([ +function loadRouteFixture() { + return Promise.all([ vi.importActual("../middleware/index.js"), vi.importActual("../routes/issues.js"), ]); +} +let routeFixture: Awaited>; + +async function createApp(transaction: (callback: (tx: Record) => Promise) => Promise = + async (callback) => callback({})) { + const [{ errorHandler }, { issueRoutes }] = routeFixture; const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -253,7 +258,7 @@ function makeIssue(overrides: Record = {}) { } describe("issue update comment wakeups", () => { - beforeEach(() => { + beforeEach(async () => { vi.resetModules(); vi.doUnmock("../routes/issues.js"); vi.doUnmock("../routes/authz.js"); @@ -271,6 +276,9 @@ describe("issue update comment wakeups", () => { mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); mockIssueService.getCurrentScheduledRetry.mockResolvedValue(null); mockIssueService.listReviewAttention.mockResolvedValue(new Map()); + // A fresh route graph is fixture preparation, not request execution. Keep + // per-case module resets while charging the cold transform to hookTimeout. + routeFixture = await loadRouteFixture(); }); it.each(["post", "patch"] as const)("rejects %s board messages under an inherited pause before any mutation", async (method) => { diff --git a/ui/src/components/new-agent/NewAgentSetup.tsx b/ui/src/components/new-agent/NewAgentSetup.tsx index 56ae564fb24..d6b3618eca4 100644 --- a/ui/src/components/new-agent/NewAgentSetup.tsx +++ b/ui/src/components/new-agent/NewAgentSetup.tsx @@ -340,7 +340,7 @@ function Setup({ !adapter.disabled && !getAdapterDisplay(adapterType).comingSoon, ) && (requestAdapterType !== "paperclip_runner" || adapterType === "paperclip_runner" || adapterInventory.data?.some(adapter => adapter.type === "paperclip_runner" && adapter.loaded && !adapter.disabled)); - const ready = Boolean( + const connectionSettingsReady = Boolean( available && (!["codex_local", "claude_local"].includes(adapterType) || runner !== "auto" || Boolean(adapters.data)) && !environmentError && @@ -358,6 +358,14 @@ function Setup({ !caps.error && (!(managedOnly || forced.forced) || environmentId), ); + const needsCurrentSetup = ["codex_local", "claude_local"].includes(adapterType); + const ready = connectionSettingsReady && (!needsCurrentSetup || ( + envs.isSuccess && !envs.isFetching + && settings.isSuccess && !settings.isFetching + && experimental.isSuccess && !experimental.isFetching + && general.isSuccess && !general.isFetching + && (runner !== "auto" || (adapters.isSuccess && !adapters.isFetching)) + )); const busy = testState === "running" || saving; function buildConfig( @@ -762,7 +770,7 @@ function Setup({ center /> - {ready ? :

Loading connection settings…

} + /> :

Loading connection settings…

} {["codex_local", "claude_local"].includes(adapterType) &&
Advanced { container.remove(); }); describe("New agent setup", () => { + it("waits for the current instance default instead of testing cached SSH discovery", async () => { + let finish!: (value: object) => void; + settings.get.mockReturnValue(new Promise(resolve => { finish = resolve; })); + const environments = [ + { id: "local-1", name: "Local", driver: "local", config: {} }, + { id: "linux-ssh", name: "SSH", driver: "ssh", config: {} }, + ]; + envApi.list.mockResolvedValue(environments); + cache.setQueryData(queryKeys.environments.list("company-1"), environments); + cache.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: "linux-ssh" }); + adapterApi.list.mockImplementation(async (context?: { environmentId?: string | null }) => state.adapters.map((adapter: any) => adapter.type === "claude_local" + ? { ...adapter, supportedRunners: ["paperclip", "legacy"], defaultRunner: context?.environmentId === "local-1" ? "paperclip" : "legacy" } : adapter)); + await render("claude_local"); + await connect("Claude"); + expect(api.testEnvironment).not.toHaveBeenCalled(); + expect(api.hire).not.toHaveBeenCalled(); + await act(async () => finish({ defaultEnvironmentId: "local-1" })); + await settle(); + await connect("Claude"); + await click("Finish setup"); + expect(api.testEnvironment).toHaveBeenCalledTimes(1); + expect(api.testEnvironment.mock.calls[0][2].environmentId).toBe("local-1"); + expect(api.hire.mock.calls[0][1].adapterConfig).not.toHaveProperty("maxTurnsPerRun"); + }); + + it("keeps the API key draft while waiting for refreshed discovery", async () => { + state.adapters = state.adapters.map((adapter: any) => adapter.type === "claude_local" + ? { ...adapter, defaultRunner: "paperclip", supportedRunners: ["paperclip", "legacy"] } : adapter); + await render("claude_local"); + await click("ClaudeAPI key"); + await fill("API key", "example-test-secret"); + const draft = container.querySelector('[aria-label="API key"]') as HTMLInputElement; + let finish!: (value: object[]) => void; + adapterApi.list.mockReturnValue(new Promise(resolve => { finish = resolve; })); + let refresh!: Promise; + await act(async () => { + refresh = cache.invalidateQueries({ queryKey: queryKeys.adapters.availability("company-1", "local-1") }); + }); + await settle(); + expect(container.querySelector('[aria-label="API key"]')).toBe(draft); + expect(draft.value).toBe("example-test-secret"); + await click("Connect"); + expect(managedApi.create).not.toHaveBeenCalled(); + expect(api.testEnvironment).not.toHaveBeenCalled(); + await act(async () => { finish(state.adapters); await refresh; }); + await settle(); + expect(container.querySelector('[aria-label="API key"]')).toBe(draft); + expect(draft.value).toBe("example-test-secret"); + await click("Connect"); + await click("Finish setup"); + expect(api.testEnvironment).toHaveBeenCalledTimes(1); + expect(api.hire).toHaveBeenCalledTimes(1); + expect(api.hire.mock.calls[0][1].adapterConfig).not.toHaveProperty("maxTurnsPerRun"); + }); + it.each(["paperclip", "legacy", undefined] as const)("uses Claude's discovered %s default for Auto setup and hire", async (defaultRunner) => { settings.getExperimental.mockResolvedValue({ enableNativeRunner: false }); state.adapters = (state.adapters as any[]).map(adapter => adapter.type === "claude_local"