From 73c07ae1405416b4957c4df2becd2c361d03afdd Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 8 Sep 2026 22:18:42 -0500 Subject: [PATCH] fix(runner): retain OpenCode completion authority through interruption Bind validated OpenCode result notifications to the active provider process and turn, matching semantic tool responses. Reproduce the live shutdown failure and verify exact durable authority after interruption. Co-Authored-By: Paperclip --- doc/sandbox-work-folders.md | 6 ++ .../src/bin/fake-codex-app-server.rs | 23 +++- .../runner-core/src/provider_backend.rs | 16 +++ .../tests/native_provider_backend.rs | 101 +++++++++++++++++- 4 files changed, 144 insertions(+), 2 deletions(-) diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index 3eedba2e4a..5edac3c9e2 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -334,3 +334,9 @@ the service before testing an app redeployment; the platform default is zero. This is a deployment prerequisite, not a fleet-default promotion. When upgrading from a release without the idle-session drain, park warm native sessions and verify their completed harness checkpoints before stopping the old app. + +Native OpenCode binds each validated completion result to the current provider +process and turn before the controller can interrupt it. This matches the +semantic-tool response path. A shutdown interruption must preserve that exact +completed-turn authority so the session can be suspended and checkpointed. +Invalid results and conflicting identities still fail validation. diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs index aff3a58115..9054d23f71 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/bin/fake-codex-app-server.rs @@ -543,6 +543,7 @@ fn run() -> Result<(), Box> { let expected_canonical_task_context_file = argument(&args, "--expected-canonical-task-context-file"); let emit_tool_call = args.iter().any(|value| value == "--emit-tool-call"); + let emit_opencode_result = args.iter().any(|value| value == "--emit-opencode-result"); let replay_completed_tool_call = args .iter() .any(|value| value == "--replay-completed-tool-call"); @@ -1091,7 +1092,27 @@ fn run() -> Result<(), Box> { "method": "turn/started", "params": {"turn": {"id": provider_turn_id}} }))?; - if fail_after_second_turn_start && turn_start_count == 2 { + if emit_opencode_result { + send(json!({ + "method": "paperclip/runResult", + "params": { + "threadId": state.thread_id, + "turnId": provider_turn_id, + "result": { + "schema": "paperclip.run_result.v1", + "reportedWorkDisposition": "done", + "summary": "Finished before controller interruption.", + "completionClaim": { + "contractRevision": "revision-1", + "objectiveSatisfied": true, + "criteria": [{"criterionId": "criterion-1", "status": "satisfied", "evidenceRefs": []}], + "remainingWork": [] + }, + "evidence": [], "verification": [], "attentionRequests": [], "artifacts": [] + } + } + }))?; + } else if fail_after_second_turn_start && turn_start_count == 2 { return Err("configured failure after second turn start".into()); } else if fail_turn_immediately { send(json!({ 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 77f194c377..3928054b45 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 @@ -3035,6 +3035,22 @@ impl CodexCommandExecutor { state.reconcile_active_provider_turn(Some(provider_turn_id)); } let normalized = normalize_provider_notification(state, &method, ¶ms)?; + if method == "paperclip/runResult" { + // The OpenCode proxy publishes its validated semantic + // result as a notification rather than a correlated + // tool response. Bind the same exact active process and + // turn authority used by deliver_tool_result before a + // controller interruption can settle that provider turn. + self.provider + .as_mut() + .expect("provider remains present after result validation") + .mark_active_turn_result_authoritative() + .map_err(|error| { + DurableRunnerError::invalid(format!( + "failed to bind OpenCode result to its active provider turn: {error}" + )) + })?; + } let normalized_event_count = normalized.len(); if terminal_event_type.is_some() { state.settle_active_provider_turn_identity()?; 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 9eb1496eca..6a9fb349f8 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 @@ -84,6 +84,10 @@ fn qualified_artifact(path: PathBuf) -> QualifiedLaunchArtifact { } fn opencode_config(state_dir: &Path) -> DurableRunnerConfig { + opencode_config_with_switches(state_dir, "") +} + +fn opencode_config_with_switches(state_dir: &Path, switches: &str) -> DurableRunnerConfig { let command = state_dir.join("qualified-opencode-proxy-command"); let proxy_script = state_dir.join("qualified-opencode-proxy-script"); let executable = state_dir.join("qualified-opencode-executable"); @@ -95,7 +99,7 @@ fn opencode_config(state_dir: &Path) -> DurableRunnerConfig { fs::write( &proxy_script, format!( - "#!/bin/sh\nexec '{}' --state-file '{}' --call-log '{}' --require-completion-contract\n", + "#!/bin/sh\nexec '{}' --state-file '{}' --call-log '{}' --require-completion-contract {switches}\n", env!("CARGO_BIN_EXE_fake-codex-app-server"), state_dir.join("fake-opencode-state.json").display(), state_dir.join("fake-opencode-calls.log").display(), @@ -541,6 +545,101 @@ fn executes_opencode_through_the_local_facade_without_codex_event_labels() { fs::remove_dir_all(directory).unwrap(); } +#[test] +fn opencode_result_survives_controller_interruption_and_durable_close() { + let directory = temporary_directory("opencode-result-interruption"); + let config = opencode_config_with_switches(&directory, "--emit-opencode-result"); + let mut executor = NativeProviderCommandExecutor::with_runner_config(&directory, &config); + executor + .execute(&command( + 1, + "run.prepare", + opencode_prepare_payload(&directory), + )) + .unwrap(); + executor + .execute(&command(2, "session.open", json!({}))) + .unwrap(); + executor + .execute(&command( + 3, + "turn.start", + json!({"text": "Complete, then await interruption."}), + )) + .unwrap(); + let mut observed = Vec::new(); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while std::time::Instant::now() < deadline { + let events = executor.poll_events().unwrap(); + let count = events.len(); + observed.extend(events); + executor.acknowledge_events(count).unwrap(); + if observed + .iter() + .any(|event| event.event_type == "run.result.proposed") + { + break; + } + std::thread::sleep(Duration::from_millis(1)); + } + assert!(observed + .iter() + .any(|event| event.event_type == "run.result.proposed")); + let read_state = || -> Value { + serde_json::from_slice(&fs::read(directory.join("codex-provider-state.json")).unwrap()) + .unwrap() + }; + let active = read_state(); + assert!(active["activeProviderTurnId"].is_string()); + executor + .execute(&command(4, "turn.interrupt", json!({}))) + .unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while std::time::Instant::now() < deadline { + let events = executor + .poll_events() + .expect("interruption retains the validated result's exact process and turn authority"); + let count = events.len(); + observed.extend(events); + executor.acknowledge_events(count).unwrap(); + if observed + .iter() + .any(|event| event.event_type == "run.terminal") + { + break; + } + std::thread::sleep(Duration::from_millis(1)); + } + assert_eq!( + observed + .iter() + .filter(|event| event.event_type == "run.result.proposed") + .count(), + 1 + ); + let terminal = observed + .iter() + .find(|event| event.event_type == "run.terminal") + .expect("accepted result remains terminal"); + assert_eq!(terminal.payload["reportedWorkDisposition"], "done"); + let settled = read_state(); + assert_eq!(settled["completedTurnAuthoritative"], true); + assert_eq!( + settled["completedProviderTurnId"], + active["activeProviderTurnId"] + ); + assert_eq!( + settled["completedTurnProcessGeneration"], + active["providerProcessGeneration"] + ); + assert!(settled["activeProviderTurnId"].is_null()); + executor + .execute(&command(5, "session.close", json!({}))) + .expect("durably close after interrupted completion"); + executor.shutdown().unwrap(); + fs::remove_dir_all(directory).unwrap(); +} + #[test] fn replacement_shutdown_restores_the_persisted_provider_before_cleanup() { let directory = temporary_directory("opencode-replacement-shutdown");