Keep optional goal probes from disconnecting retained PRP v1 runners
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
a266720d97
commit
3ee06e212e
|
|
@ -139,6 +139,12 @@ the new scoped-folder or repository-checkpoint durability guarantee for old task
|
|||
New tasks enter the scoped lifecycle below. Automatic migration of an old task's
|
||||
working tree into scoped folders is not performed.
|
||||
|
||||
Retained PRP v1 runners continue ordinary native turns without session goals.
|
||||
The host checks the authenticated protocol version before probing or changing
|
||||
a goal, so an optional v2 request cannot disconnect an older runner or block
|
||||
its final suspension and checkpoint. Unauthenticated connections do not provide
|
||||
capability evidence.
|
||||
|
||||
Version-1 reusable leases obtain their missing task and responsible-user identity
|
||||
from company-scoped host run records. Reuse still requires matching agent, task,
|
||||
user, environment, workspace, provider, and configuration fingerprint. Missing or
|
||||
|
|
|
|||
|
|
@ -961,6 +961,44 @@ it.each([
|
|||
},
|
||||
);
|
||||
|
||||
it.each([1, 2])("only journals session goals after negotiating PRP v2 (version %s)", async (version) => {
|
||||
const root = mkdtempSync(resolve(tmpdir(), "runner-goal-version-test-"));
|
||||
const core = new DurablePrpControlPlane({
|
||||
stateDirectory: root,
|
||||
identity,
|
||||
expectedRunnerVersion,
|
||||
expectedRunnerDigest,
|
||||
});
|
||||
let client: AuthenticatedClient | null = null;
|
||||
try {
|
||||
await core.start();
|
||||
expect(core.negotiatedProtocolVersion).toBeNull();
|
||||
expect(() => core.queueCommand("session.goal.get")).toThrow("authenticated PRP v2");
|
||||
expect(core.store.state.commands).toHaveLength(0);
|
||||
client = await authenticate(core, core.issueBootstrapTicket(), identity,
|
||||
expectedRunnerDigest, undefined, false, version);
|
||||
expect(client).not.toBeNull();
|
||||
expect(core.negotiatedProtocolVersion).toBe(version);
|
||||
for (const type of ["session.goal.get", "session.goal.set", "session.goal.clear"]) {
|
||||
if (version === 1) {
|
||||
expect(() => core.queueCommand(type)).toThrow("authenticated PRP v2");
|
||||
expect(core.store.state.commands).toHaveLength(0);
|
||||
} else {
|
||||
expect(core.queueCommand(type).schema).toBe("paperclip.prp.command.v2");
|
||||
}
|
||||
}
|
||||
// Unsupported probes must not consume journal slots ahead of suspension.
|
||||
expect(core.queueCommand("runner.suspend")).toMatchObject({
|
||||
schema: "paperclip.prp.command.v1",
|
||||
controllerSeq: version === 1 ? 1 : 4,
|
||||
});
|
||||
} finally {
|
||||
client?.socket.destroy();
|
||||
await core.stop();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function secureAad(
|
||||
client: AuthenticatedClient,
|
||||
direction: "client_to_core" | "core_to_client",
|
||||
|
|
|
|||
|
|
@ -1831,6 +1831,16 @@ export class DurablePrpControlPlane {
|
|||
return ticket;
|
||||
}
|
||||
|
||||
get negotiatedProtocolVersion(): number | null {
|
||||
const versions = [...this.#connections]
|
||||
.filter(
|
||||
(connection) => connection.secureChannel !== null && !connection.replayOnly,
|
||||
)
|
||||
.map((connection) => connection.lease?.protocolVersion)
|
||||
.filter((version): version is number => version !== undefined);
|
||||
return versions.length > 0 ? Math.min(...versions) : null;
|
||||
}
|
||||
|
||||
queueCommand(
|
||||
type: string,
|
||||
payload: Record<string, unknown> = {},
|
||||
|
|
@ -1838,6 +1848,14 @@ export class DurablePrpControlPlane {
|
|||
deliverImmediately = false,
|
||||
): DurableRecoveryCoreCommand {
|
||||
this.#store.assertWritable();
|
||||
// A goal probe is optional. Never journal a v2 command for an older
|
||||
// retained runner: it cannot reject that schema and disconnects instead,
|
||||
// leaving the command ahead of the final suspension/checkpoint.
|
||||
if (
|
||||
type.startsWith("session.goal.") && this.negotiatedProtocolVersion !== 2
|
||||
) {
|
||||
throw new Error("Session goals require an authenticated PRP v2 runner.");
|
||||
}
|
||||
const transition = this.#store.state.warmTransition;
|
||||
if (transition && transition.phase !== "activated") {
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -30,7 +30,11 @@ import type {
|
|||
CodexTraceInterpretation,
|
||||
CodexTransportProcessInfo,
|
||||
} from "../drivers/codex/app-server-transport.js";
|
||||
import { createSanitizedCodexEnvironment } from "../drivers/codex/app-server-transport.js";
|
||||
import {
|
||||
CODEX_METHOD_NOT_FOUND,
|
||||
CodexRpcError,
|
||||
createSanitizedCodexEnvironment,
|
||||
} from "../drivers/codex/app-server-transport.js";
|
||||
import {
|
||||
codexSemanticToolSpecs,
|
||||
createIsolatedCodexAppServerArgs,
|
||||
|
|
@ -3594,6 +3598,15 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
|||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
["thread/goal/get", "thread/goal/set", "thread/goal/clear"].includes(method)
|
||||
&& this.#core?.negotiatedProtocolVersion === 1
|
||||
) {
|
||||
throw new CodexRpcError(
|
||||
"Session goals are unavailable on this PRP v1 runner.",
|
||||
CODEX_METHOD_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
if (method === "thread/goal/get") {
|
||||
const result = await this.#commandResult("session.goal.get", params);
|
||||
return {
|
||||
|
|
|
|||
Loading…
Reference in New Issue