fix(runner): decode native inputs saved under ACPX 0.13.1 (#21062)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip Runner defines the native execution input that every
native run saves and every later run reads back
> - The ACPX upgrade to 0.22.0 (#20770) changed the qualified
`acpxVersion` in each ACPX profile
> - The input parser accepts only the current `acpxVersion`, so it
refuses every input that was saved under ACPX 0.13.1
> - Run preparation reads the previous run's saved input for the same
task, so each ACPX agent with an older task session fails before its
provider starts
> - This pull request lets the parser decode inputs saved under ACPX
0.13.1 and normalizes them to the current qualified profile
> - The benefit is that saved ACPX agents and tasks work again after the
upgrade, with no manual edit

## Linked Issues or Issue Description

Refs #20770

**What happened?**
Every run of an ACPX agent (Claude through ACPX) failed about 20 seconds
after it started. The run error was `input.provider.profile does not
match the qualified ACPX v1 profile` with the failure cause
`native_execution_input_invalid`. The run log showed no runner output,
only the preparation messages. Five runs on five different tasks failed
this way in 40 minutes. The previous successful run on one of those
tasks saved this provider profile:
`{"agent":"claude","acpxVersion":"0.13.1","agentProfileVersion":1,...}`.
New runs saved `acpxVersion` `0.22.0`. Run preparation reads the
previous run's saved input (for example, the instruction working copy
step parses the input of the task session's last run). The parser
refused `0.13.1` and the error stopped the run.

**Expected behavior**
A run on a task whose earlier run was saved under ACPX 0.13.1 prepares
and starts normally. The ACPX upgrade carries saved sessions forward.
The session identity already pins `0.13.1` for this reason
(`recovery-identity.ts`). The saved input must decode too.

**Steps to reproduce**
1. Run an ACPX agent on a task with Paperclip before #20770, so the run
saves `provider.profile.acpxVersion: "0.13.1"`.
2. Deploy a build that includes #20770.
3. Wake the agent on the same task.
4. See the run fail in preparation with `input.provider.profile does not
match the qualified ACPX v1 profile`.
5. The new unit test reproduces this with the exact saved profile shape.

**Paperclip version or commit**
master at 424e2c6488. The failure is on a deployed build at 3c058c37e6,
which includes #20770.

**Deployment mode**
Hosted cloud (staging), Daytona remote workspace, `paperclip_runner`
adapter.

**Agent adapter(s) involved**
`paperclip_runner` with provider `acpx`, ACPX agent `claude`. All ACPX
agents with inputs saved before the upgrade have the same failure.

## What Changed

- `profile-compatibility.ts`: add `isDecodableAcpxVersion`. It accepts
the current qualified ACPX version and `0.13.1`, the only ACPX version
that was qualified before.
- `native-execution.ts`: the ACPX profile check uses
`isDecodableAcpxVersion` instead of a strict equality with the current
version. The parser continues to write the current qualified version
into the parsed profile, so all later checks and digests see the current
profile.
- `native-execution.test.ts`: a regression test with the exact saved
Claude profile shape. It confirms that the saved input decodes, that the
result is the current profile, that the parse is idempotent, and that
other versions (`0.13.0`, `0.21.9`, `0.23.0`, empty, null, number) are
still refused.

## Verification

- Red: the new test failed on master with `input.provider.profile does
not match the qualified ACPX v1 profile`, the same error as the failed
runs.
- Green: `npx vitest run src/contracts/native-execution.test.ts` in
`packages/paperclip-runner`: 115 passed.
- `npx vitest run` on the ACPX `profile-compatibility`,
`cursor-cloud-candidate-profiles`, `kimi-installation`,
`pi-installation`, `copilot-installation` and `recovery-identity` tests:
140 passed, 1 skipped.
- Server: `native-execution-input.test.ts` (42 passed) and
`heartbeat/instruction-working-copy.test.ts` (14 passed).
- `tsc --noEmit` for `packages/paperclip-runner`: clean.
- `pnpm verify --explain` selects the full suite because the runner
package changed. CI supplies that coverage.

## Risks

- Low risk. The change only widens decoding from one ACPX version to two
named versions. Unknown versions are still refused.
- A decoded 0.13.1 input becomes the current qualified profile. This
matches the upgrade design: the current ACPX loads sessions that 0.13.1
wrote, and the session identity already uses 0.13.1 for all versions.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use
(shell, browser for read-only staging API reads) and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-12 03:35:32 -06:00
1 parent a858016082
commit b7693e2076
3 files changed
+41 -3

No files matched your search

@@ -1,6 +1,7 @@
import { nativeTaskConstraints } from "../backends/runtime-context.js";
import { createHash } from "node:crypto";
import { QUALIFIED_ACPX_PROFILES } from "../drivers/acpx/qualified-profiles.js";
import { QUALIFIED_ACPX_VERSION } from "../drivers/acpx/generated-profiles.js";
import { describe, expect, it } from "vitest";
import { buildNativeModelEnvelope, parseNativeExecutionInput, NATIVE_EXECUTION_INPUT_SCHEMA, NATIVE_EXECUTION_INPUT_SCHEMA_V6, NATIVE_EXECUTION_INPUT_SCHEMA_V7, NATIVE_EXECUTION_INPUT_SCHEMA_V9, NATIVE_EXECUTION_INPUT_SCHEMA_V10, type NativeExecutionInputV1 } from "./native-execution.js";
@@ -450,6 +451,37 @@ describe("NativeExecutionInputV1", () => {
expect(parseNativeExecutionInput(parsed)).toEqual(parsed);
});
it("decodes a Claude input saved under ACPX 0.13.1 as the current qualified profile", () => {
const savedProfile = {
agent: "claude",
driverKind: "acpx_runtime",
acpxVersion: "0.13.1",
commandDigest: "sha256:9d73d1f0f121fb96cc8badb28c22d5bff02d8582eb2e40360a81c189e1b9422a",
protocolVersion: 1,
agentServerPackage: "@agentclientprotocol/claude-agent-acp",
agentServerVersion: "0.73.0",
agentProfileVersion: 1,
agentRuntimePackage: "@anthropic-ai/claude-agent-sdk",
agentRuntimeVersion: "0.3.286",
};
const saved = {
...input,
session: { ...input.session, driverKind: "acpx_runtime" },
provider: {
kind: "acpx", agent: "claude", model: "claude-opus-5-5",
permissionPolicy: "interactive", profile: savedProfile,
},
};
const parsed = parseNativeExecutionInput(saved);
expect(parsed.provider).toEqual({ ...saved.provider, profile: { ...savedProfile, acpxVersion: QUALIFIED_ACPX_VERSION } });
expect(parseNativeExecutionInput(parsed)).toEqual(parsed);
for (const acpxVersion of ["0.13.0", "0.21.9", "0.23.0", "", null, 13]) {
expect(() => parseNativeExecutionInput({
...saved, provider: { ...saved.provider, profile: { ...savedProfile, acpxVersion } },
})).toThrow("qualified ACPX v1 profile");
}
});
it.each([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40] as const)("decodes closed Copilot profile history at version %s without admitting a future version", (agentProfileVersion) => {
const { permissionPolicy, qualificationStatus: _qualificationStatus, ...snapshot } = QUALIFIED_ACPX_PROFILES.copilot;
const provider = {
@@ -4,7 +4,7 @@ import { resolveGrokEffort } from "../drivers/acpx/grok-effort.js";
import { resolveClaudeEffort } from "../drivers/acpx/claude-effort.js";
import type { DotBindingSnapshot, MuseBindingSnapshot, ExternalBotProviderKind } from "./external-provider.js";
import { QUALIFIED_ACPX_VERSION, QUALIFIED_ACPX_PROFILE_DATA } from "../drivers/acpx/generated-profiles.js";
import { isSupportedAcpxProfileVersion, type AcpxProfileVersion } from "../drivers/acpx/profile-compatibility.js";
import { isDecodableAcpxVersion, isSupportedAcpxProfileVersion, type AcpxProfileVersion } from "../drivers/acpx/profile-compatibility.js";
import { isProviderMode } from "./provider-mode.js";
import { createHash } from "node:crypto";
import type { PrpStructuredRunResult, PrpTerminalState } from "../protocol/replay-contract.js";
@@ -754,7 +754,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
if (
profile.driverKind !== "acpx_runtime"
|| profile.protocolVersion !== 1
|| profile.acpxVersion !== QUALIFIED_ACPX_VERSION
|| !isDecodableAcpxVersion(profile.acpxVersion)
|| profile.agent !== provider.agent
|| !isSupportedAcpxProfileVersion(provider.agent, profile.agentProfileVersion)
) {
@@ -1,4 +1,4 @@
import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js";
import { QUALIFIED_ACPX_PROFILE_DATA, QUALIFIED_ACPX_VERSION } from "./generated-profiles.js";
/** Decodable profile revisions, not permission to launch them.
* Exact current profile/package/digest admission is checked separately.
@@ -15,3 +15,9 @@ export function isSupportedAcpxProfileVersion(agent: string, value: unknown): va
const latest = Math.max(current, Object.hasOwn(RETAINED_THROUGH, agent) ? RETAINED_THROUGH[agent]! : 0);
return (value as number) >= 1 && (value as number) <= latest;
}
const DECODABLE_ACPX_VERSIONS: ReadonlySet<unknown> = new Set(["0.13.1", QUALIFIED_ACPX_VERSION]);
export function isDecodableAcpxVersion(value: unknown): boolean {
return DECODABLE_ACPX_VERSIONS.has(value);
}