fix(runner): admit recovered ACPX provider ownership
This commit is contained in:
parent
65c204f060
commit
93f82b6b2e
|
|
@ -1304,6 +1304,61 @@ describe("Codex ACPX runtime adapter", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("admits a verified provider that starts with the first recovered turn", async () => {
|
||||||
|
const runtime = fakeRuntime();
|
||||||
|
const child = fakeChild();
|
||||||
|
const command = fakeCommand();
|
||||||
|
vi.mocked(command.spawn).mockReturnValue(child);
|
||||||
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
||||||
|
let resolvePromptStarted: (() => void) | undefined;
|
||||||
|
const promptStarted = new Promise<void>((resolve) => {
|
||||||
|
resolvePromptStarted = resolve;
|
||||||
|
});
|
||||||
|
const rawTurn = {
|
||||||
|
requestId: "turn-recovered",
|
||||||
|
promptStarted,
|
||||||
|
events: { async *[Symbol.asyncIterator]() {} },
|
||||||
|
result: new Promise<never>(() => undefined),
|
||||||
|
cancel: vi.fn(),
|
||||||
|
closeStream: vi.fn(),
|
||||||
|
};
|
||||||
|
vi.mocked(runtime.startTurn).mockImplementation(() => {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
runtimeOptions?.spawnAgent?.({
|
||||||
|
command: "ignored",
|
||||||
|
args: ["--stdio"],
|
||||||
|
options: {},
|
||||||
|
});
|
||||||
|
resolvePromptStarted?.();
|
||||||
|
});
|
||||||
|
return rawTurn;
|
||||||
|
});
|
||||||
|
const port = await openCodexAcpxRuntime(openOptions(command), {
|
||||||
|
createRegistry: () => registry(),
|
||||||
|
createStore: () => store(),
|
||||||
|
awaitProviderOwnership: providerOwnershipEstablished,
|
||||||
|
awaitProviderExit: providerOwnershipEstablished,
|
||||||
|
createRuntime: (options) => {
|
||||||
|
runtimeOptions = options;
|
||||||
|
return runtime;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const turn = port.startTurn({
|
||||||
|
text: "Resume the task.",
|
||||||
|
requestId: "turn-recovered",
|
||||||
|
});
|
||||||
|
await expect(turn.promptStarted).resolves.toBeUndefined();
|
||||||
|
expect(command.spawn).toHaveBeenCalledTimes(1);
|
||||||
|
expect(() =>
|
||||||
|
runtimeOptions?.spawnAgent?.({
|
||||||
|
command: "ignored",
|
||||||
|
args: ["--stdio"],
|
||||||
|
options: {},
|
||||||
|
}),
|
||||||
|
).toThrow("provider spawned after ownership admission was sealed");
|
||||||
|
});
|
||||||
|
|
||||||
it("projects only ephemeral MCP bindings and applies fail-closed permissions", async () => {
|
it("projects only ephemeral MCP bindings and applies fail-closed permissions", async () => {
|
||||||
const runtime = fakeRuntime();
|
const runtime = fakeRuntime();
|
||||||
let runtimeOptions: AcpRuntimeOptions | undefined;
|
let runtimeOptions: AcpRuntimeOptions | undefined;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import type {
|
||||||
AcpxRuntimePort,
|
AcpxRuntimePort,
|
||||||
AcpxRuntimePortIdentity,
|
AcpxRuntimePortIdentity,
|
||||||
AcpxRuntimePortOpenOptions,
|
AcpxRuntimePortOpenOptions,
|
||||||
|
AcpxRuntimeTurn,
|
||||||
} from "./runtime-host.js";
|
} from "./runtime-host.js";
|
||||||
import {
|
import {
|
||||||
assertVerifiedAcpxProviderPlatform,
|
assertVerifiedAcpxProviderPlatform,
|
||||||
|
|
@ -1045,20 +1046,71 @@ function runtimePort(
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
startTurn(input) {
|
startTurn(input) {
|
||||||
return runtime.startTurn({
|
const finishOwnershipAdmission =
|
||||||
handle,
|
children.beginLifetimeOwnershipAdmission();
|
||||||
text: input.text,
|
let turn: AcpxRuntimeTurn;
|
||||||
mode: "prompt",
|
try {
|
||||||
requestId: input.requestId,
|
turn = runtime.startTurn({
|
||||||
...(input.signal ? { signal: input.signal } : {}),
|
handle,
|
||||||
...(input.onElicitation ? { onElicitation: input.onElicitation } : {}),
|
text: input.text,
|
||||||
});
|
mode: "prompt",
|
||||||
|
requestId: input.requestId,
|
||||||
|
...(input.signal ? { signal: input.signal } : {}),
|
||||||
|
...(input.onElicitation
|
||||||
|
? { onElicitation: input.onElicitation }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
void finishOwnershipAdmission().catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return turnWithVerifiedLifetimeOwnership(turn, finishOwnershipAdmission);
|
||||||
},
|
},
|
||||||
close: closeRuntime,
|
close: closeRuntime,
|
||||||
};
|
};
|
||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function turnWithVerifiedLifetimeOwnership(
|
||||||
|
turn: AcpxRuntimeTurn,
|
||||||
|
finishOwnershipAdmission: () => Promise<void>,
|
||||||
|
): AcpxRuntimeTurn {
|
||||||
|
// A persisted ACPX session can be loaded without starting an agent process.
|
||||||
|
// Keep the narrowly scoped turn admission open until either the provider has
|
||||||
|
// accepted the prompt or the turn has already terminalized. The synchronous
|
||||||
|
// stable-empty seal in SpawnedChildSet then rejects every later spawn.
|
||||||
|
const reachedAdmissionBoundary = Promise.race([
|
||||||
|
turn.promptStarted.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
),
|
||||||
|
turn.result.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const ownershipVerified = reachedAdmissionBoundary.then(() =>
|
||||||
|
finishOwnershipAdmission(),
|
||||||
|
);
|
||||||
|
void ownershipVerified.catch(() => undefined);
|
||||||
|
return {
|
||||||
|
requestId: turn.requestId,
|
||||||
|
promptStarted: ownershipVerified.then(() => turn.promptStarted),
|
||||||
|
events: eventsAfterLifetimeOwnership(turn.events, ownershipVerified),
|
||||||
|
result: ownershipVerified.then(() => turn.result),
|
||||||
|
cancel: (input) => turn.cancel(input),
|
||||||
|
closeStream: (input) => turn.closeStream(input),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* eventsAfterLifetimeOwnership<T>(
|
||||||
|
events: AsyncIterable<T>,
|
||||||
|
ownershipVerified: Promise<void>,
|
||||||
|
): AsyncIterable<T> {
|
||||||
|
await ownershipVerified;
|
||||||
|
yield* events;
|
||||||
|
}
|
||||||
|
|
||||||
async function persistedRuntimeStatus(
|
async function persistedRuntimeStatus(
|
||||||
sessionStore: AcpSessionStore,
|
sessionStore: AcpSessionStore,
|
||||||
handle: AcpRuntimeHandle,
|
handle: AcpRuntimeHandle,
|
||||||
|
|
@ -1304,19 +1356,40 @@ class SpawnedChildSet {
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyLifetimeOwnership(): Promise<void> {
|
async verifyLifetimeOwnership(): Promise<void> {
|
||||||
for (;;) {
|
try {
|
||||||
const ownership = this.#lifetimeOwnership.splice(0);
|
for (;;) {
|
||||||
if (ownership.length === 0) {
|
const ownership = this.#lifetimeOwnership.splice(0);
|
||||||
// This check and seal are synchronous. Any spawn added while an
|
if (ownership.length === 0) {
|
||||||
// earlier batch was pending is observed by the next loop iteration;
|
// This check and seal are synchronous. Any spawn added while an
|
||||||
// no later provider can race admission after the stable-empty point.
|
// earlier batch was pending is observed by the next loop iteration;
|
||||||
this.#lifetimeOwnershipSealed = true;
|
// no later provider can race admission after the stable-empty point.
|
||||||
return;
|
this.#lifetimeOwnershipSealed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.all(ownership);
|
||||||
}
|
}
|
||||||
await Promise.all(ownership);
|
} catch (error) {
|
||||||
|
this.#lifetimeOwnershipSealed = true;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
beginLifetimeOwnershipAdmission(): () => Promise<void> {
|
||||||
|
if (this.#sealed) {
|
||||||
|
throw new Error("ACPX provider ownership admission is closed");
|
||||||
|
}
|
||||||
|
if (!this.#lifetimeOwnershipSealed) {
|
||||||
|
throw new Error("ACPX provider ownership admission is already active");
|
||||||
|
}
|
||||||
|
this.#lifetimeOwnershipSealed = false;
|
||||||
|
let finished = false;
|
||||||
|
return async () => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
await this.verifyLifetimeOwnership();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
#track(child: ChildProcess, providerExit: ProviderExitObservation): void {
|
#track(child: ChildProcess, providerExit: ProviderExitObservation): void {
|
||||||
this.#children.add(child);
|
this.#children.add(child);
|
||||||
const onError = (error: unknown) => this.#errors.add(error);
|
const onError = (error: unknown) => this.#errors.add(error);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue