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:
parent
777d891c25
commit
73c07ae140
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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()?;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue