Merge branch 'codex/work-folders-remote-recovery-refresh' into codex/work-folders-session-compat-refresh
* codex/work-folders-remote-recovery-refresh: fix(runner): preserve v2 event metadata and reject nonretryable campaign failures fix(runner): preserve exact Claude model selectors during ACP admission test(runner): serve goal reads while holding passive completion notices fix(ci): validate shared Cloud image contexts throughout the stack
This commit is contained in:
commit
b48689cb00
|
|
@ -1619,56 +1619,71 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
}))?;
|
||||
}
|
||||
if emit_post_completion_passive_statuses {
|
||||
if let Some(gate) = post_completion_notification_gate.as_ref() {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while !gate.is_file() {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(
|
||||
"post-completion notification gate timed out".into()
|
||||
);
|
||||
let gate = post_completion_notification_gate.clone();
|
||||
let thread_id = state.thread_id.clone();
|
||||
let tail_turn_id = provider_turn_id.clone();
|
||||
// Goal reconciliation reads must remain responsive while
|
||||
// the test holds back the post-terminal passive notices.
|
||||
thread::spawn(move || {
|
||||
let result = (|| -> io::Result<()> {
|
||||
if let Some(gate) = gate.as_ref() {
|
||||
let deadline =
|
||||
std::time::Instant::now() + Duration::from_secs(5);
|
||||
while !gate.is_file() {
|
||||
if std::time::Instant::now() >= deadline {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"post-completion notification gate timed out",
|
||||
));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(1));
|
||||
for notification in [
|
||||
json!({
|
||||
"method": "deprecationNotice",
|
||||
"params": {"summary": "A provider setting is deprecated", "details": null}
|
||||
}),
|
||||
json!({
|
||||
"method": "remoteControl/status/changed",
|
||||
"params": {"status": "disabled", "environmentId": null}
|
||||
}),
|
||||
json!({
|
||||
"method": "mcpServer/startupStatus/updated",
|
||||
"params": {"name": "codex_apps", "status": "ready", "error": null}
|
||||
}),
|
||||
json!({
|
||||
"method": "account/rateLimits/updated",
|
||||
"params": {"rateLimits": {}}
|
||||
}),
|
||||
json!({
|
||||
"method": "rawResponseItem/completed",
|
||||
"params": {"threadId": thread_id, "turnId": tail_turn_id, "item": {"id": "raw-tail", "type": "reasoning"}}
|
||||
}),
|
||||
json!({
|
||||
"method": "rawResponse/completed",
|
||||
"params": {"threadId": thread_id, "turnId": tail_turn_id, "response": {"id": "response-tail"}}
|
||||
}),
|
||||
json!({
|
||||
"method": "thread/goal/updated",
|
||||
"params": {"threadId": thread_id, "goal": "finish the turn"}
|
||||
}),
|
||||
json!({
|
||||
"method": "thread/goal/cleared",
|
||||
"params": {"threadId": thread_id}
|
||||
}),
|
||||
] {
|
||||
send(notification)?;
|
||||
}
|
||||
if let Some(gate) = gate.as_ref() {
|
||||
fs::write(gate.with_extension("emitted"), b"emitted")?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
eprintln!("post-completion passive tail failed: {error}");
|
||||
}
|
||||
}
|
||||
for notification in [
|
||||
json!({
|
||||
"method": "deprecationNotice",
|
||||
"params": {"summary": "A provider setting is deprecated", "details": null}
|
||||
}),
|
||||
json!({
|
||||
"method": "remoteControl/status/changed",
|
||||
"params": {"status": "disabled", "environmentId": null}
|
||||
}),
|
||||
json!({
|
||||
"method": "mcpServer/startupStatus/updated",
|
||||
"params": {"name": "codex_apps", "status": "ready", "error": null}
|
||||
}),
|
||||
json!({
|
||||
"method": "account/rateLimits/updated",
|
||||
"params": {"rateLimits": {}}
|
||||
}),
|
||||
json!({
|
||||
"method": "rawResponseItem/completed",
|
||||
"params": {"threadId": state.thread_id, "turnId": provider_turn_id, "item": {"id": "raw-tail", "type": "reasoning"}}
|
||||
}),
|
||||
json!({
|
||||
"method": "rawResponse/completed",
|
||||
"params": {"threadId": state.thread_id, "turnId": provider_turn_id, "response": {"id": "response-tail"}}
|
||||
}),
|
||||
json!({
|
||||
"method": "thread/goal/updated",
|
||||
"params": {"threadId": state.thread_id, "goal": "finish the turn"}
|
||||
}),
|
||||
json!({
|
||||
"method": "thread/goal/cleared",
|
||||
"params": {"threadId": state.thread_id}
|
||||
}),
|
||||
] {
|
||||
send(notification)?;
|
||||
}
|
||||
if let Some(gate) = post_completion_notification_gate.as_ref() {
|
||||
fs::write(gate.with_extension("emitted"), b"emitted")?;
|
||||
}
|
||||
});
|
||||
}
|
||||
if emit_post_completion_foreign_turn {
|
||||
let gate = post_completion_notification_gate.clone();
|
||||
|
|
|
|||
|
|
@ -101,7 +101,10 @@ describe("Codex ACPX runtime adapter", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each([["claude" as const, "claude-sonnet-5", "claude-sonnet-5"]])(
|
||||
it.each([
|
||||
["claude" as const, "claude-sonnet-5", "claude-sonnet-5"],
|
||||
["claude" as const, "custom-claude-model", "custom-claude-model"],
|
||||
])(
|
||||
"opens the qualified %s session through the verified lease",
|
||||
async (agent, model, providerModel) => {
|
||||
const runtime = fakeRuntime();
|
||||
|
|
@ -109,7 +112,7 @@ describe("Codex ACPX runtime adapter", () => {
|
|||
const options = openOptions(command);
|
||||
let runtimeOptions: AcpRuntimeOptions | undefined;
|
||||
options.profile = resolveQualifiedAcpxProfile(agent, model);
|
||||
options.launchEnvironment = { PATH: "/verified/bin" };
|
||||
options.launchEnvironment = { PATH: "/verified/bin", ANTHROPIC_CUSTOM_MODEL_OPTION: "stale-model" };
|
||||
|
||||
await openCodexAcpxRuntime(options, {
|
||||
createRegistry: ({ overrides }) => {
|
||||
|
|
@ -128,6 +131,7 @@ describe("Codex ACPX runtime adapter", () => {
|
|||
expect(runtimeOptions?.spawnEnvironment?.()).toEqual({
|
||||
PATH: "/verified/bin",
|
||||
PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1",
|
||||
ANTHROPIC_CUSTOM_MODEL_OPTION: providerModel,
|
||||
});
|
||||
expect(runtime.ensureSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
|
|
|||
|
|
@ -317,7 +317,14 @@ export async function openQualifiedAcpxRuntime(
|
|||
spawnEnvironment: () => ({
|
||||
...definedEnvironment(options.launchEnvironment),
|
||||
...(options.profile.agent === "claude"
|
||||
? { PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1" }
|
||||
? {
|
||||
PAPERCLIP_ACPX_ISOLATED_CONTEXT: "1",
|
||||
// Claude ACP otherwise maps concrete IDs back to rolling picker
|
||||
// aliases (e.g. claude-sonnet-5 -> sonnet). Advertise the exact
|
||||
// host-requested ID so model verification stays exact on resume
|
||||
// and before the first billable prompt.
|
||||
ANTHROPIC_CUSTOM_MODEL_OPTION: options.profile.reportedModelId,
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
spawnCwd: options.cwd,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
PRP_V1_EVENT_TYPES,
|
||||
PRP_V2_EVENT_TYPES,
|
||||
REDACTED_EVENT_VALUE,
|
||||
redactAgentAdapterConfig,
|
||||
redactEventPayload,
|
||||
|
|
@ -23,6 +24,36 @@ describe("redaction", () => {
|
|||
expect([...PRP_V1_EVENT_TYPES]).toEqual(schema.properties.eventType.enum);
|
||||
});
|
||||
|
||||
it("keeps the discriminator allowlist in exact PRP v2 schema parity", () => {
|
||||
const schema = JSON.parse(readFileSync(new URL(
|
||||
"../../../packages/paperclip-runner/protocol/schemas/event-v2.schema.json",
|
||||
import.meta.url,
|
||||
), "utf8")) as { properties: { eventType: { enum: string[] } } };
|
||||
expect([...PRP_V2_EVENT_TYPES].sort()).toEqual(schema.properties.eventType.enum.sort());
|
||||
});
|
||||
|
||||
it("preserves v2 goal envelopes while redacting secrets and mismatched versions", () => {
|
||||
for (const eventType of PRP_V2_EVENT_TYPES) {
|
||||
const envelope = {
|
||||
schema: "paperclip.prp.event.v2", schemaVersion: 2, eventType,
|
||||
payload: { apiKey: "secret", opaque: "aaa.bbb.ccc" },
|
||||
};
|
||||
expect(sanitizeRecord(envelope)).toEqual({
|
||||
...envelope,
|
||||
payload: { apiKey: REDACTED_EVENT_VALUE, opaque: REDACTED_EVENT_VALUE },
|
||||
});
|
||||
}
|
||||
for (const envelope of [
|
||||
{ schema: "paperclip.prp.event.v1", schemaVersion: 1, eventType: "session.goal.snapshot" },
|
||||
{ schema: "paperclip.prp.event.v2", schemaVersion: 1, eventType: "session.goal.snapshot" },
|
||||
{ schema: "paperclip.prp.event.v1", schemaVersion: 2, eventType: "session.goal.snapshot" },
|
||||
{ schema: "paperclip.prp.event.v2", schemaVersion: 2, eventType: "aaa.bbb.ccc" },
|
||||
{ schema: "paperclip.prp.event.v3", schemaVersion: 3, eventType: "session.goal.snapshot" },
|
||||
]) {
|
||||
expect(sanitizeRecord(envelope).eventType).toBe(REDACTED_EVENT_VALUE);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves every discriminator in the cross-language replay stream", () => {
|
||||
const fixture = JSON.parse(
|
||||
readFileSync(
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ export const PAPERCLIP_PUBLIC_SCHEMA_IDS = new Set([
|
|||
"paperclip.prp.command.v1",
|
||||
"paperclip.prp.contract_manifest.v1",
|
||||
"paperclip.prp.event.v1",
|
||||
"paperclip.prp.event.v2",
|
||||
"paperclip.prp.fixture.v1",
|
||||
"paperclip.prp.identity.v1",
|
||||
"paperclip.prp.semantic_tool.v1",
|
||||
|
|
@ -194,7 +195,7 @@ export const PAPERCLIP_PUBLIC_SCHEMA_IDS = new Set([
|
|||
// Keep this closed catalog aligned with PRP v1's event.schema.json. These
|
||||
// values are public protocol discriminators, but their dotted shape overlaps
|
||||
// the deliberately broad JWT heuristic. They are exempt only in the
|
||||
// discriminator field of a PRP v1 event envelope; the same string anywhere
|
||||
// discriminator field of a supported PRP event envelope; the same string anywhere
|
||||
// else remains subject to redaction.
|
||||
export const PRP_V1_EVENT_TYPES = new Set([
|
||||
"runner.connected",
|
||||
|
|
@ -303,6 +304,17 @@ export const PRP_V1_EVENT_TYPES = new Set([
|
|||
"issue.status.decision.superseded",
|
||||
"run.terminal",
|
||||
]);
|
||||
// V2 removes backpressure and semantic reconciliation, and adds goal events.
|
||||
// Exact parity with both protocol schemas is enforced by redaction.test.ts.
|
||||
export const PRP_V2_EVENT_TYPES = new Set([
|
||||
...[...PRP_V1_EVENT_TYPES].filter(
|
||||
(type) => type !== "runner.backpressure" && type !== "semantic_tool.reconciled",
|
||||
),
|
||||
"session.capabilities.updated",
|
||||
"session.goal.snapshot",
|
||||
"session.goal.updated",
|
||||
"session.goal.cleared",
|
||||
]);
|
||||
const NATIVE_RUN_SPAN_SCHEMA = "paperclip.run-performance-span.v1";
|
||||
const NATIVE_RUN_SPAN_FIELDS = ["span", "parentSpan"] as const;
|
||||
const NATIVE_RUN_SPAN_NAMES = new Set([
|
||||
|
|
@ -843,10 +855,13 @@ function isKnownPrpEventDiscriminator(
|
|||
): value is string {
|
||||
return (
|
||||
key === "eventType" &&
|
||||
container.schema === "paperclip.prp.event.v1" &&
|
||||
container.schemaVersion === 1 &&
|
||||
typeof value === "string" &&
|
||||
PRP_V1_EVENT_TYPES.has(value)
|
||||
((container.schema === "paperclip.prp.event.v1" &&
|
||||
container.schemaVersion === 1 &&
|
||||
PRP_V1_EVENT_TYPES.has(value)) ||
|
||||
(container.schema === "paperclip.prp.event.v2" &&
|
||||
container.schemaVersion === 2 &&
|
||||
PRP_V2_EVENT_TYPES.has(value)))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { FailureClass } from "./types.js";
|
|||
const TRANSIENT =
|
||||
/(?:\b429\b|\b5\d\d\b|rate.?limit|ECONN(?:RESET|REFUSED)|socket hang up|network (?:error|interruption|timeout)|service unavailable|(?:provider|server|bootstrap|browser|webserver|health|daytona|sandbox|ingress|preview|connection|harness).*(?:temporar|timed? out|timeout|closed|failed|unavailable|interrupt|reset|refused|create|start|connect)|(?:timed? out|timeout).*(?:provider|server|bootstrap|browser|webserver|health|daytona|sandbox|ingress|preview|connection|harness))/i;
|
||||
const PERMANENT =
|
||||
/(?:missing (?:credential|fixture secret)|invalid.*(?:credential|api key)|unauthorized|forbidden|qualification|model.*(?:unsupported|incompatible)|artifact.*incompatible|runner_remote_.*(?:incompatible|unavailable)|immutable image digest)/i;
|
||||
/(?:retryable=false|effective_model_mismatch|missing (?:credential|fixture secret)|invalid.*(?:credential|api key)|unauthorized|forbidden|qualification|model.*(?:unsupported|incompatible)|artifact.*incompatible|runner_remote_.*(?:incompatible|unavailable)|immutable image digest)/i;
|
||||
const CANDIDATE =
|
||||
/(?:matcher|expected.*observed|marker|issue status|run status|runtime mode|wrong output|missing output)/i;
|
||||
|
||||
|
|
|
|||
|
|
@ -277,3 +277,13 @@ export function numberedPlanStepCount(body: string | null | undefined) {
|
|||
);
|
||||
}).length;
|
||||
}
|
||||
|
||||
/** Validate the persisted protocol version without discarding v1 upgrade history. */
|
||||
export function hasConsistentPrpEventVersion(
|
||||
envelope: Record<string, unknown>,
|
||||
protocolSchemaVersion: unknown,
|
||||
): boolean {
|
||||
return (envelope.schemaVersion === 1 || envelope.schemaVersion === 2)
|
||||
&& envelope.schema === `paperclip.prp.event.v${envelope.schemaVersion}`
|
||||
&& protocolSchemaVersion === envelope.schemaVersion;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { setupLiveFixtures, type LiveFixtureValues } from "./live-fixtures.js";
|
|||
import { evaluateMatcher, type MatcherResult } from "./matchers.js";
|
||||
import {
|
||||
acceptedPlanSessionResetFailures,
|
||||
hasConsistentPrpEventVersion,
|
||||
hasTerminalMalformedPlanConfirmation,
|
||||
isControlPlaneGovernedResponseWait,
|
||||
isNonExecutingReviewFenceRun,
|
||||
|
|
@ -471,12 +472,8 @@ function nativeRunEventIntegrityFailures(
|
|||
}
|
||||
const envelope = record(event.payload?.prpEvent);
|
||||
if (Object.keys(envelope).length === 0) continue;
|
||||
if (
|
||||
envelope.schema !== "paperclip.prp.event.v1" ||
|
||||
envelope.schemaVersion !== 1 ||
|
||||
event.protocolSchemaVersion !== 1
|
||||
) {
|
||||
failures.push(`run ${run.id} exposed a malformed PRP v1 envelope`);
|
||||
if (!hasConsistentPrpEventVersion(envelope, event.protocolSchemaVersion)) {
|
||||
failures.push(`run ${run.id} exposed a malformed PRP envelope`);
|
||||
}
|
||||
if (envelope.runId !== run.id) {
|
||||
failures.push(
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
} from "./ports.js";
|
||||
import {
|
||||
acceptedPlanSessionResetFailures,
|
||||
hasConsistentPrpEventVersion,
|
||||
hasTerminalMalformedPlanConfirmation,
|
||||
isControlPlaneGovernedResponseWait,
|
||||
isNonExecutingReviewFenceRun,
|
||||
|
|
@ -737,6 +738,14 @@ describe("runner E2E failure policy", () => {
|
|||
});
|
||||
|
||||
describe("runner E2E server isolation", () => {
|
||||
it("does not retry a provider rejection explicitly marked nonretryable", () => {
|
||||
const failure = classifyFailure(new Error(
|
||||
"failed to start ACPX provider: ACPX sidecar command session.open was rejected (retryable=false, classification=effective_model_mismatch)",
|
||||
));
|
||||
expect(failure).toBe("permanent_infrastructure");
|
||||
expect(shouldRetryFailure(failure)).toBe(false);
|
||||
});
|
||||
|
||||
it("shares restart control files beneath the isolated temporary root", () => {
|
||||
expect(runnerE2EServerControlPaths("/tmp/cell")).toEqual({
|
||||
controlDirectory: path.join("/tmp/cell", "control"),
|
||||
|
|
@ -1159,3 +1168,21 @@ describe("runner E2E macOS shared-memory cleanup", () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("persisted native event versions", () => {
|
||||
it.each([1, 2])("accepts matching PRP v%i events", (version) => {
|
||||
expect(hasConsistentPrpEventVersion({
|
||||
schema: `paperclip.prp.event.v${version}`, schemaVersion: version,
|
||||
}, version)).toBe(true);
|
||||
});
|
||||
it.each([
|
||||
["paperclip.prp.event.v1", 2, 2],
|
||||
["paperclip.prp.event.v2", 1, 1],
|
||||
["paperclip.prp.event.v2", 2, 1],
|
||||
["paperclip.prp.event.v3", 3, 3],
|
||||
["***REDACTED***", 2, 2],
|
||||
["paperclip.prp.event.v2", "2", 2],
|
||||
])("rejects inconsistent version metadata %s/%s/%s", (schema, version, outer) => {
|
||||
expect(hasConsistentPrpEventVersion({schema, schemaVersion: version}, outer)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue