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");