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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-08 22:18:42 -05:00
parent 777d891c25
commit 73c07ae140
4 changed files with 144 additions and 2 deletions

View File

@ -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.

View File

@ -543,6 +543,7 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
"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!({

View File

@ -3035,6 +3035,22 @@ impl CodexCommandExecutor {
state.reconcile_active_provider_turn(Some(provider_turn_id));
}
let normalized = normalize_provider_notification(state, &method, &params)?;
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()?;

View File

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