merge: compose canonical repair and current Claude setup guards

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* codex/claude-runner-default:
  docs: record retained-preview mobile runner evidence
  fix(runner): retain startup ownership through cancellation
  test(server): initialize issue routes within the fixture hook
  test(runner): reserve the occupied credential quorum fixture
  fix(ui): wait for current guided runner settings
This commit is contained in:
DottaandPaperclip committed 2026-10-12 20:09:22 -05:00
commit 8ec42ffd1c
7 files changed
+393 -25

No files matched your search

@@ -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`.
@@ -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<ReturnType<typeof silentPrimaryQuorumFixture>> | 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.");
}
@@ -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<void>(resolve => { releasePreparation = resolve; });
let markPreparationStarted!: () => void;
const preparationStarted = new Promise<void>(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<Record<string, unknown>> | 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<void> | 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<void>(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<void>(resolve => { releaseReady = resolve; });
let markReadyStarted!: () => void;
const readyStarted = new Promise<void>(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<void>(() => undefined);
const bundle = createCapabilityRunnerdCodexTransport({
@@ -3601,6 +3601,8 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
#authorizedTools: Record<string, unknown> | null = null;
#runAttachTemplate: Record<string, unknown> | null = null;
#closed = false;
readonly #closedSignal: Promise<void>;
#resolveClosedSignal!: () => void;
#closePromise: Promise<void> | null = null;
#startupOperation: Promise<unknown> | null = null;
#startupDrainTimedOut = false;
@@ -3641,6 +3643,9 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
this.#failureSignal = new Promise<never>((_resolve, reject) => {
this.#rejectFailureSignal = reject;
});
this.#closedSignal = new Promise<void>(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<T>(operation: Promise<T>): Promise<T> {
#trackStartup<T>(start: () => Promise<T>): Promise<T> {
// 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<void> {
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<void>) | undefined,
): Promise<void> {
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();
}
@@ -205,12 +205,17 @@ function registerModuleMocks() {
}));
}
async function createApp(transaction: (callback: (tx: Record<string, never>) => Promise<unknown>) => Promise<unknown> =
async (callback) => callback({})) {
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
function loadRouteFixture() {
return Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
]);
}
let routeFixture: Awaited<ReturnType<typeof loadRouteFixture>>;
async function createApp(transaction: (callback: (tx: Record<string, never>) => Promise<unknown>) => Promise<unknown> =
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<string, unknown> = {}) {
}
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) => {
+11 -3
View File
@@ -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
/>
</div>
{ready ? <AgentProviderConnection
{connectionSettingsReady ? <fieldset disabled={!ready} className="contents"><AgentProviderConnection
key={environmentId ?? "local"}
companyId={companyId}
adapterType={connectionAdapter}
@@ -814,7 +822,7 @@ function Setup({
setRuntimeAiBinding(undefined);
setScreen("runtime");
}}
/> : <p role="status" className="text-sm text-muted-foreground">Loading connection settings…</p>}
/></fieldset> : <p role="status" className="text-sm text-muted-foreground">Loading connection settings…</p>}
{["codex_local", "claude_local"].includes(adapterType) && <details className="mt-5 space-y-3">
<summary className="cursor-pointer text-sm text-muted-foreground">Advanced</summary>
<AgentRunnerSelect value={runner} disabled={busy}
+55
View File
@@ -200,6 +200,61 @@ afterEach(async () => {
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<object>(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<object[]>(resolve => { finish = resolve; }));
let refresh!: Promise<void>;
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"