From be5709a25c56782d7df98cf9b8fa41aff8d68f2d Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 5 Nov 2026 12:32:59 -0800 Subject: [PATCH] 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 --- packages/paperclip-runner/docs/durable-recovery.md | 7 +++++++ .../runner/crates/runner-core/src/provider_backend.rs | 7 +++++-- .../crates/runner-core/tests/native_provider_backend.rs | 8 +++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/paperclip-runner/docs/durable-recovery.md b/packages/paperclip-runner/docs/durable-recovery.md index a899fdfa42f..10e6f86282f 100644 --- a/packages/paperclip-runner/docs/durable-recovery.md +++ b/packages/paperclip-runner/docs/durable-recovery.md @@ -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. diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs index 9de9869c27a..bbcdcadde9e 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs @@ -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(()) diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index b5a0a5c3d39..8439dd9642f 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -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