Refresh verified provider launch leases after cold model selection
This commit is contained in:
parent
6cd4126108
commit
6dc0efd6db
|
|
@ -13,6 +13,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
import type { VerifiedAcpxCommandLease } from "./installation-integrity.js";
|
||||
import { openCodexAcpxRuntime } from "./codex-runtime-adapter.js";
|
||||
import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js";
|
||||
import { resolveQualifiedAcpxProfile } from "./qualified-profiles.js";
|
||||
import type { AcpxRuntimePortOpenOptions } from "./runtime-host.js";
|
||||
|
||||
|
|
@ -1367,6 +1368,60 @@ describe("Codex ACPX runtime adapter", () => {
|
|||
await port.close({ reason: "test complete" });
|
||||
});
|
||||
|
||||
it("uses a fresh single-use command after a cold model control consumes its launch", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const freshCommand = () => {
|
||||
const command = fakeCommand();
|
||||
vi.mocked(command.spawn).mockReturnValueOnce(fakeChild()).mockImplementation(() => {
|
||||
throw new Error("Verified ACPX command lease is closed");
|
||||
});
|
||||
return command;
|
||||
};
|
||||
const first = freshCommand();
|
||||
const second = freshCommand();
|
||||
const openCommand = vi.fn(async () => second);
|
||||
const owner = createAcpxCommandLeaseOwner(first, openCommand);
|
||||
let runtimeOptions: AcpRuntimeOptions;
|
||||
vi.mocked(runtime.setConfigOption!).mockImplementation(async () => {
|
||||
runtimeOptions.spawnAgent!({ command: "ignored", args: [], options: {} });
|
||||
});
|
||||
vi.mocked(runtime.startTurn).mockImplementation(() => {
|
||||
runtimeOptions.spawnAgent!({ command: "ignored", args: [], options: {} });
|
||||
return {
|
||||
requestId: "cold-turn",
|
||||
promptStarted: Promise.resolve(),
|
||||
events: { async *[Symbol.asyncIterator]() {} },
|
||||
result: Promise.resolve({ status: "completed" }),
|
||||
cancel: vi.fn(),
|
||||
closeStream: vi.fn(),
|
||||
};
|
||||
});
|
||||
const port = await openCodexAcpxRuntime(
|
||||
{
|
||||
...openOptions(owner.command),
|
||||
refreshConsumedCommand: owner.refreshConsumedCommand,
|
||||
},
|
||||
{
|
||||
createRegistry: () => registry(),
|
||||
createStore: () => store(),
|
||||
awaitProviderOwnership: providerOwnershipEstablished,
|
||||
awaitProviderExit: providerOwnershipEstablished,
|
||||
createRuntime: (options) => {
|
||||
runtimeOptions = options;
|
||||
return runtime;
|
||||
},
|
||||
},
|
||||
);
|
||||
await port.setModel!("gpt-5.6-sol");
|
||||
expect(openCommand).toHaveBeenCalledOnce();
|
||||
const turn = port.startTurn({ text: "Resume", requestId: "cold-turn" });
|
||||
await expect(turn.result).resolves.toMatchObject({ status: "completed" });
|
||||
expect(first.spawn).toHaveBeenCalledOnce();
|
||||
expect(second.spawn).toHaveBeenCalledOnce();
|
||||
await port.close({ reason: "test complete" });
|
||||
await owner.command.close();
|
||||
});
|
||||
|
||||
it("admits a verified provider that starts with the first recovered turn", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const child = fakeChild();
|
||||
|
|
|
|||
|
|
@ -265,6 +265,7 @@ export async function openQualifiedAcpxRuntime(
|
|||
update.goal === null ? null : structuredClone(update.goal),
|
||||
);
|
||||
};
|
||||
const commandLaunches = { count: 0, refreshConsumedCommand: options.refreshConsumedCommand };
|
||||
const runtimeOptions: GoalAwareAcpRuntimeOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionStore,
|
||||
|
|
@ -327,6 +328,7 @@ export async function openQualifiedAcpxRuntime(
|
|||
// handshake cannot create a provider process after authority is gone.
|
||||
options.signal?.throwIfAborted();
|
||||
options.assertWorkspaceHeld?.();
|
||||
commandLaunches.count += 1;
|
||||
return children.add(
|
||||
options.command.spawn(input.args, input.options, {
|
||||
credentialFenceFds,
|
||||
|
|
@ -431,6 +433,7 @@ export async function openQualifiedAcpxRuntime(
|
|||
children,
|
||||
runtimeCloseTimeoutMs,
|
||||
goalState,
|
||||
commandLaunches,
|
||||
);
|
||||
} catch (error) {
|
||||
const cleanupReason = "ACPX runtime identity validation failed";
|
||||
|
|
@ -857,6 +860,7 @@ function runtimePort(
|
|||
children: SpawnedChildSet,
|
||||
runtimeCloseTimeoutMs: number,
|
||||
goalState: AcpxRuntimeGoalState,
|
||||
commandLaunches: { count: number; refreshConsumedCommand?: () => Promise<void> },
|
||||
): AcpxRuntimePort {
|
||||
type RuntimeCloseAttempt = {
|
||||
readonly outcome: Promise<unknown | null>;
|
||||
|
|
@ -1161,6 +1165,7 @@ function runtimePort(
|
|||
// only for this control call, and verify ownership before return.
|
||||
const finishOwnershipAdmission =
|
||||
children.beginLifetimeOwnershipAdmission();
|
||||
const spawnsBeforeControl = commandLaunches.count;
|
||||
try {
|
||||
await runtime.setConfigOption?.({
|
||||
handle,
|
||||
|
|
@ -1170,6 +1175,11 @@ function runtimePort(
|
|||
} finally {
|
||||
await finishOwnershipAdmission();
|
||||
}
|
||||
// Cold ACP config calls open and close a temporary connection.
|
||||
// A later prompt needs a newly verified single-use launch snapshot.
|
||||
if (commandLaunches.count > spawnsBeforeControl) {
|
||||
await commandLaunches.refreshConsumedCommand?.();
|
||||
}
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import type { ChildProcess } from "node:child_process";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js";
|
||||
import type { VerifiedAcpxCommandLease } from "./installation-integrity.js";
|
||||
|
||||
function lease() {
|
||||
let consumed = false;
|
||||
return {
|
||||
spawn: vi.fn(() => {
|
||||
if (consumed) throw new Error("single-use command already consumed");
|
||||
consumed = true;
|
||||
return {} as ChildProcess;
|
||||
}),
|
||||
close: vi.fn(async () => {
|
||||
consumed = true;
|
||||
}),
|
||||
} satisfies VerifiedAcpxCommandLease;
|
||||
}
|
||||
|
||||
describe("ACPX verified command lease owner", () => {
|
||||
it("refreshes only consumed snapshots and preserves single-use spawn enforcement", async () => {
|
||||
const first = lease();
|
||||
const second = lease();
|
||||
const open = vi.fn(async () => second);
|
||||
const owner = createAcpxCommandLeaseOwner(first, open);
|
||||
await owner.refreshConsumedCommand();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
owner.command.spawn();
|
||||
expect(() => owner.command.spawn()).toThrow("already consumed");
|
||||
await Promise.all([owner.refreshConsumedCommand(), owner.refreshConsumedCommand()]);
|
||||
expect(open).toHaveBeenCalledOnce();
|
||||
owner.command.spawn();
|
||||
expect(second.spawn).toHaveBeenCalledOnce();
|
||||
expect(() => owner.command.spawn()).toThrow("already consumed");
|
||||
await owner.command.close();
|
||||
expect(first.close).toHaveBeenCalledOnce();
|
||||
expect(second.close).toHaveBeenCalledOnce();
|
||||
expect(() => owner.command.spawn()).toThrow("closing");
|
||||
await expect(owner.refreshConsumedCommand()).rejects.toThrow("closing");
|
||||
});
|
||||
|
||||
it("retains a replacement acquired during shutdown and retries its failed cleanup", async () => {
|
||||
const first = lease();
|
||||
const replacement = lease();
|
||||
replacement.close.mockRejectedValueOnce(new Error("close failed"));
|
||||
let acquired!: (value: VerifiedAcpxCommandLease) => void;
|
||||
const owner = createAcpxCommandLeaseOwner(
|
||||
first,
|
||||
() => new Promise((resolve) => {
|
||||
acquired = resolve;
|
||||
}),
|
||||
);
|
||||
owner.command.spawn();
|
||||
const refresh = owner.refreshConsumedCommand();
|
||||
const rejectedRefresh = expect(refresh).rejects.toThrow("closed during refresh");
|
||||
await Promise.resolve();
|
||||
const close = owner.command.close();
|
||||
const rejectedClose = expect(close).rejects.toThrow("leases did not close");
|
||||
acquired(replacement);
|
||||
await rejectedRefresh;
|
||||
await rejectedClose;
|
||||
expect(replacement.spawn).not.toHaveBeenCalled();
|
||||
await owner.command.close();
|
||||
expect(replacement.close).toHaveBeenCalledTimes(2);
|
||||
expect(first.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("fails closed when fresh command verification fails", async () => {
|
||||
const initial = lease();
|
||||
const owner = createAcpxCommandLeaseOwner(initial, async () => {
|
||||
throw new Error("installation changed");
|
||||
});
|
||||
owner.command.spawn();
|
||||
await expect(owner.refreshConsumedCommand()).rejects.toThrow("installation changed");
|
||||
expect(() => owner.command.spawn()).toThrow("already consumed");
|
||||
await owner.command.close();
|
||||
expect(initial.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import type { VerifiedAcpxCommandLease } from "./installation-integrity.js";
|
||||
|
||||
/** Keep each launch single-use while owning replacements for transient ACP controls. */
|
||||
export function createAcpxCommandLeaseOwner(
|
||||
initial: VerifiedAcpxCommandLease,
|
||||
openCommand: () => Promise<VerifiedAcpxCommandLease>,
|
||||
) {
|
||||
const leases = new Set([initial]);
|
||||
let current = initial;
|
||||
let consumed = false;
|
||||
let closing = false;
|
||||
let refresh: Promise<void> | null = null;
|
||||
const command: VerifiedAcpxCommandLease = {
|
||||
spawn(...args) {
|
||||
if (closing) throw new Error("Verified ACPX command owner is closing");
|
||||
consumed = true;
|
||||
return current.spawn(...args);
|
||||
},
|
||||
async close() {
|
||||
closing = true;
|
||||
// Late acquisitions remain owned. Retry every lease whose close fails.
|
||||
await refresh?.catch(() => undefined);
|
||||
const failures: unknown[] = [];
|
||||
for (const lease of leases) {
|
||||
try {
|
||||
await lease.close();
|
||||
leases.delete(lease);
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
if (failures.length) throw new AggregateError(failures, "ACPX command leases did not close");
|
||||
},
|
||||
};
|
||||
return {
|
||||
command,
|
||||
async refreshConsumedCommand(): Promise<void> {
|
||||
if (closing) throw new Error("Verified ACPX command owner is closing");
|
||||
if (!consumed) return;
|
||||
if (!refresh) {
|
||||
refresh = Promise.resolve()
|
||||
.then(openCommand)
|
||||
.then((replacement) => {
|
||||
leases.add(replacement);
|
||||
if (closing) throw new Error("Verified ACPX command owner closed during refresh");
|
||||
current = replacement;
|
||||
consumed = false;
|
||||
});
|
||||
}
|
||||
const pending = refresh;
|
||||
try {
|
||||
await pending;
|
||||
} finally {
|
||||
if (refresh === pending) refresh = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
type VerifiedAcpxCommandLease,
|
||||
type VerifiedAcpxInstallation,
|
||||
} from "./installation-integrity.js";
|
||||
import { createAcpxCommandLeaseOwner } from "./command-lease-owner.js";
|
||||
import {
|
||||
requireVerifiedAcpxModel,
|
||||
type AcpxModelStatus,
|
||||
|
|
@ -117,6 +118,8 @@ export interface AcpxRuntimePort {
|
|||
|
||||
export interface AcpxRuntimePortOpenOptions {
|
||||
command: VerifiedAcpxCommandLease;
|
||||
/** Replace a consumed launch snapshot after an ephemeral control session. */
|
||||
refreshConsumedCommand?: () => Promise<void>;
|
||||
profile: QualifiedAcpxProfile;
|
||||
cwd: string;
|
||||
stateDirectory: string;
|
||||
|
|
@ -401,6 +404,11 @@ export class AcpxRuntimeHost {
|
|||
reportFailure: (failure) =>
|
||||
dependencies.reportRetainedCleanupFailure(failure),
|
||||
});
|
||||
const commandOwner = createAcpxCommandLeaseOwner(
|
||||
command,
|
||||
() => installation.openCommand(),
|
||||
);
|
||||
command = commandOwner.command;
|
||||
toolBridge = options.semanticTools
|
||||
? await acquireAbortableAdmissionResource({
|
||||
signal: options.signal,
|
||||
|
|
@ -421,6 +429,7 @@ export class AcpxRuntimeHost {
|
|||
options.assertWorkspaceHeld?.();
|
||||
return dependencies.openRuntime({
|
||||
command: command!,
|
||||
refreshConsumedCommand: commandOwner.refreshConsumedCommand,
|
||||
profile,
|
||||
cwd: binding.workspacePath,
|
||||
stateDirectory: sandbox.stateDirectory,
|
||||
|
|
|
|||
Loading…
Reference in New Issue