mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
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:
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) => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user