fix(runner): classify incompatible saved provider profiles (#19334)

Classify deterministic OpenCode and Cursor Cloud saved-profile mismatches with the existing permanent provider-state class. Preserve saved state and stop same-run retries after the runner refuses that state.

Verified 37 native-provider backend tests, durable classified-result replay, the server policy test, rustfmt, workspace typecheck and build. Complete hosted CI passed with Greptile 5/5 and no unresolved threads.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-11-05 12:32:59 -08:00
1 parent ffd9591ab4
commit be5709a25c
3 files changed
+19 -3

No files matched your search

@@ -99,6 +99,13 @@ after a failed resume, it opens a fresh provider session as before. Otherwise th
run fails with `native_provider_state_incompatible` and is not retried. A board
Retry then continues the task in a fresh provider session in the same workspace.
Saved OpenCode and Cursor Cloud provider state binds the digest of its own
qualified launch profile (command, proxy script and, for OpenCode, the
executable, each by path and SHA-256). These providers do not re-bind. A changed
profile fails restore with the same `provider_state_incompatible`
classification and follows the same disposition, so the controller does not
retry the restore or try to reattach a runner that already rejected it.
## Profile identity
Every ACPX provider's `commandDigest` attests only its own upstream release.
@@ -23,6 +23,7 @@ use crate::durable::{
validate_semantic_tool_input, verify_private_directory, Command, CommandExecution,
CommandExecutor, CursorCloudLaunchProfile, DurableRunnerConfig, DurableRunnerError,
EventPriority, OpenCodeLaunchProfile, PolledEvent, TerminalDeliveryReconciliation,
PROVIDER_STATE_INCOMPATIBLE,
};
use crate::provider_bridge::{
authorized_tool_catalog_digest, semantic_value_digest, AuthorizedToolSet, DurableReplayFilter,
@@ -2272,14 +2273,16 @@ impl CodexCommandExecutor {
if state.opencode_launch_profile_digest != expected_launch_profile_digest {
return Err(DurableRunnerError::invalid(
"OpenCode runner launch profile changed across durable recovery",
));
)
.with_classification(PROVIDER_STATE_INCOMPATIBLE));
}
let expected_cursor_cloud_profile_digest =
self.bind_cursor_cloud_launch_profile(&state.config)?;
if state.cursor_cloud_launch_profile_digest != expected_cursor_cloud_profile_digest {
return Err(DurableRunnerError::invalid(
"Cursor Cloud runner launch profile changed across durable recovery",
));
)
.with_classification(PROVIDER_STATE_INCOMPATIBLE));
}
self.state = Some(state);
Ok(())
@@ -7,7 +7,7 @@ use std::os::unix::fs::PermissionsExt;
use paperclip_runner_core::durable::{
AcpxLaunchProfile, Command, CommandExecutor, CursorCloudLaunchProfile, DurableRunnerConfig,
OpenCodeLaunchProfile, QualifiedLaunchArtifact,
OpenCodeLaunchProfile, QualifiedLaunchArtifact, PROVIDER_STATE_INCOMPATIBLE,
};
use paperclip_runner_core::native_provider_backend::NativeProviderCommandExecutor;
use paperclip_runner_core::provider_bridge::{authorized_tool_catalog_digest, AuthorizedTool};
@@ -2220,6 +2220,7 @@ fn cursor_cloud_durable_recovery_rejects_a_changed_proxy_profile_without_rewriti
assert!(error
.to_string()
.contains("Cursor Cloud runner launch profile changed across durable recovery"));
assert_eq!(error.classification(), Some(PROVIDER_STATE_INCOMPATIBLE));
}
assert_eq!(fs::read(&state_path).unwrap(), saved);
fs::remove_dir_all(directory).unwrap();
@@ -2311,6 +2312,7 @@ fn rejects_opencode_launch_profile_drift_across_fresh_recovery() {
assert!(error
.to_string()
.contains("launch profile changed across durable recovery"));
assert_eq!(error.classification(), Some(PROVIDER_STATE_INCOMPATIBLE));
let second_error = recovered
.execute(&command(
3,
@@ -2321,6 +2323,10 @@ fn rejects_opencode_launch_profile_drift_across_fresh_recovery() {
assert!(second_error
.to_string()
.contains("launch profile changed across durable recovery"));
assert_eq!(
second_error.classification(),
Some(PROVIDER_STATE_INCOMPATIBLE)
);
assert_eq!(fs::read(&state_path).unwrap(), state_before_recovery);
let shutdown_error = recovered