Keep optional goal probes from disconnecting retained PRP v1 runners

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-11 15:05:06 -05:00
parent a266720d97
commit 3ee06e212e
4 changed files with 76 additions and 1 deletions

View File

@ -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

View File

@ -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",

View File

@ -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 (

View File

@ -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 {