--- a/crates/airouter-agentd/src/session_service/driver.rs +++ b/crates/airouter-agentd/src/session_service/driver.rs @@ -1,6 +1,6 @@ use super::*; use airouter_agent_client::journal::Journal; -use airouter_agent_protocol::{Command, task}; +use airouter_agent_protocol::{Command, Phase, Usage, controller, session, task}; use std::time::Duration; impl Service { @@ -37,6 +37,13 @@ } let mut run = prepared.run.clone(); let mut error = None; + if saved.status.cancel_requested && !saved.attempted && run.phase == Phase::Uncertain { + // Reservation can precede service admission. The fsynced cancel + // record and never-attempted marker prove no host was polled, so + // they are sufficient to redeliver this exact zero-effect outcome. + // Attempted jobs must still recover their original host receipts. + run = self.finish_unattempted_cancellation(&prepared).await?; + } if !run.phase.terminal() { let journal = Journal::new(self.store.root())?; match managed_session::reconcile_saved(&self.client, &prepared, &journal).await { @@ -68,6 +75,52 @@ })?; Ok(record.status) } + + async fn finish_unattempted_cancellation( + &self, + prepared: &controller::Prepared, + ) -> Result { + let run = &prepared.run; + let checkpoint = run.session.as_ref().ok_or("session checkpoint missing")?; + if run.failure.as_deref() != Some("cancel_requested") + || !checkpoint.responses.is_empty() + || checkpoint.outcome.is_some() + || run.usage.model_calls != 0 + || run.usage.tool_calls != 0 + || run.usage.input_tokens != 0 + || run.usage.output_tokens != 0 + || run.usage.cost_micro_usd != 0 + || run.usage.cached_input_tokens.unwrap_or(0) != 0 + || run.usage.cache_write_input_tokens.unwrap_or(0) != 0 + { + return Err("unattempted cancellation conflicts with execution evidence".into()); + } + self.client.observe_host(run.id, &controller::Observe { + lease_id: prepared.binding.lease_id, + host: prepared.binding.host.clone(), + expected_revision: run.revision, + operation_id: checkpoint.operation.id, + digest: checkpoint.operation.digest.clone(), + observation: controller::Observation::Session { + observation: session::Observation::Finished { + outcome: session::Outcome { + final_response_id: None, + error: Some("session cancelled before execution".into()), + usage: Usage { + coverage: Some(session::AccountingCoverage { + model_calls: true, tool_calls: true, input_tokens: true, + output_tokens: true, cost: true, + }), + cached_input_tokens: Some(0), + cache_write_input_tokens: Some(0), + ..Usage::default() + }, + }, + }, + }, + }).await + } + pub(super) async fn drive( &self, id: Uuid, --- a/crates/airouter-agentd/tests/session_service/mod.rs +++ b/crates/airouter-agentd/tests/session_service/mod.rs @@ -303,6 +303,7 @@ async fn restart_with_unfinished_claim_preserves_uncertainty_and_never_executes() { let f = Fixture::new().await; let prepared = f.managed_session().await; + wait_for(async || f.client.get(prepared.run.id).await.unwrap().phase == Phase::SessionInFlight).await; let dir = tempfile::Builder::new().permissions(std::fs::Permissions::from_mode(0o700)).tempdir().unwrap(); let journal = Journal::new(dir.path()).unwrap(); journal @@ -327,6 +328,10 @@ reopened.status(request.binding.run_id).unwrap().state, State::ReconciliationRequired ); + let cancelled = reopened.cancel(request.binding.run_id).await.unwrap(); + assert_eq!(cancelled.state, State::ReconciliationRequired); + assert_eq!(cancelled.phase, Phase::Uncertain); + assert!(!cancelled.activity_complete); assert!( reopened .status(request.binding.run_id) @@ -450,6 +455,7 @@ async fn recovered_cancel_before_attempt_has_complete_empty_activity() { let f = Fixture::new().await; let prepared = f.managed_session().await; + wait_for(async || f.client.get(prepared.run.id).await.unwrap().phase == Phase::SessionInFlight).await; let dir = tempfile::Builder::new().permissions(std::fs::Permissions::from_mode(0o700)).tempdir().unwrap(); let request = submit(&prepared,dir.path()); // Durable accepted admission recovered before the first driver poll. @@ -462,6 +468,13 @@ })).unwrap()).unwrap(); std::fs::set_permissions(&path,std::fs::Permissions::from_mode(0o600)).unwrap(); let host=adapter(None); + let service=Service::open(f.client.clone(),dir.path(),host.clone(),options()).unwrap(); + f.unavailable.store(1, Ordering::SeqCst); + service.recover().await.unwrap(); + assert_eq!(service.status(prepared.run.id).unwrap().state, State::ReconciliationRequired); + service.shutdown().await; + drop(service); + f.unavailable.store(0, Ordering::SeqCst); let service=Service::open(f.client.clone(),dir.path(),host.clone(),options()).unwrap(); service.recover().await.unwrap(); wait_for(async || service.status(prepared.run.id).unwrap().state==State::Finished).await; @@ -496,3 +509,63 @@ assert!(observer.events(prepared.run.id,0).await.unwrap().events.is_empty()); service.shutdown().await; server.abort(); let _=server.await; } + +#[tokio::test] +async fn cancellation_after_reservation_before_admission_finishes_without_execution() { + let f = Fixture::new().await; + let prepared = f.managed_session().await; + // Force the ordering that races the controller's periodic reservation. + wait_for(async || f.client.get(prepared.run.id).await.unwrap().phase == Phase::SessionInFlight).await; + let dir = tempfile::Builder::new().permissions(std::fs::Permissions::from_mode(0o700)).tempdir().unwrap(); + let host = adapter(None); + let request = submit(&prepared, dir.path()); + let service = Service::open(f.client.clone(), dir.path(), host.clone(), options()).unwrap(); + let cancelled = service.cancel_submission(request.clone()).await.unwrap(); + assert_eq!(cancelled.phase, Phase::Cancelled); + assert_eq!(cancelled.state, State::Finished); + assert!(cancelled.activity_complete); + let run = f.client.get(prepared.run.id).await.unwrap(); + assert_eq!(run.phase, Phase::Cancelled); + assert!(!run.usage.usage_unknown); + assert!(run.usage.coverage.as_ref().unwrap().complete()); + assert_eq!(run.usage.model_calls, 0); + assert_eq!(run.usage.input_tokens, 0); + assert_eq!(run.usage.output_tokens, 0); + assert_eq!(run.usage.cost_micro_usd, 0); + assert!(service.events(run.id, 0).unwrap().events.is_empty()); + service.shutdown().await; + drop(service); + let reopened = Service::open(f.client.clone(), dir.path(), host.clone(), options()).unwrap(); + reopened.recover().await.unwrap(); + assert_eq!(reopened.submit(request).await.unwrap(), cancelled); + assert_eq!(host.0.calls.load(Ordering::SeqCst), 0); + reopened.shutdown().await; +} + +#[tokio::test] +async fn unattempted_cancel_rejects_contradictory_execution_evidence() { + let f = Fixture::new().await; + let prepared = f.managed_session().await; + wait_for(async || f.client.get(prepared.run.id).await.unwrap().phase == Phase::SessionInFlight).await; + let run = f.client.get(prepared.run.id).await.unwrap(); + let operation = &run.session.as_ref().unwrap().operation; + f.client.observe_host(run.id, &controller::Observe { + lease_id: prepared.binding.lease_id, host: prepared.binding.host.clone(), + expected_revision: run.revision, operation_id: operation.id, digest: operation.digest.clone(), + observation: controller::Observation::Session { + observation: session::Observation::ResponseStarted { id: "existing-effect".into() }, + }, + }).await.unwrap(); + let dir = tempfile::Builder::new().permissions(std::fs::Permissions::from_mode(0o700)).tempdir().unwrap(); + let host = adapter(None); + let service = Service::open(f.client.clone(), dir.path(), host.clone(), options()).unwrap(); + let error = service.cancel_submission(submit(&prepared, dir.path())).await.unwrap_err(); + assert_eq!(error, "unattempted cancellation conflicts with execution evidence"); + let run = f.client.get(run.id).await.unwrap(); + assert_eq!(run.phase, Phase::Uncertain); + assert!(run.usage.usage_unknown); + assert_eq!(run.session.as_ref().unwrap().responses[0].id, "existing-effect"); + assert!(!service.status(run.id).unwrap().activity_complete); + assert_eq!(host.0.calls.load(Ordering::SeqCst), 0); + service.shutdown().await; +} --- a/crates/airouter-agentd/SESSION_SERVICE.md +++ b/crates/airouter-agentd/SESSION_SERVICE.md @@ -33,6 +33,14 @@ share the same lock. This endpoint saves a cancellation without starting a process or reading attachment sources; repeating the original submission returns the cancelled record. Changed bodies and foreign bindings are rejected. + +The controller may already have reserved a session when this cancellation +arrives. A durable cancellation record with `attempted = false` proves that +the session service has never polled the executor. It reports a terminal +cancelled outcome with known zero usage under the original binding, including +after restart or a framework outage. Conflicting response or usage evidence +is rejected. An attempted execution still requires its original cleanup and +activity receipts; cancellation does not turn missing evidence into success. The spool is a private canonical directory outside the model workspace; the service rejects a spool beneath the submitted working directory. A