blob: 4fd59b1416bb9997e88317df5e87eedfd001d4cf [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use super::support::{context, invocation};
use futures_util::future::BoxFuture;
use maka_agent_engine::{ExecutorInput, RunError};
use maka_agent_facts::event::Fact;
use maka_agent_facts::event::Invocation;
use maka_agent_facts::event::InvocationOutcome;
use maka_agent_facts::execution::ToolMode;
use maka_agent_facts::executor::Output;
use maka_agent_ports::executor::Admitted;
use maka_agent_ports::executor::Backend;
use maka_agent_ports::executor::Context;
use maka_agent_ports::executor::Error;
use maka_agent_ports::executor::Execution;
use maka_agent_ports::executor::Outcome;
use maka_agent_ports::executor::OutputSink;
use maka_agent_ports::executor::Provider;
use maka_agent_ports::executor::Request;
use maka_agent_ports::executor::Settlement;
use maka_event_log::EventLog;
use std::{
sync::{Arc, Mutex},
time::Duration,
};
use tokio::sync::Semaphore;
use tokio_util::sync::CancellationToken;
struct NativeBackend(Arc<dyn Provider>);
impl Backend for NativeBackend {
fn admit_execution(&self, request: Request) -> Result<Admitted, Error> {
Ok(Admitted {
identity: maka_agent_facts::executor::Binding {
executor_id: "example".to_owned().try_into().unwrap(),
package_id: "example".into(),
entry_id: "example".into(),
activation: "native-test".into(),
},
execution: Box::new(NativeExecution(self.0.clone(), request)),
})
}
}
struct NativeExecution(Arc<dyn Provider>, Request);
impl Execution for NativeExecution {
fn execute(
self: Box<Self>,
output: Arc<dyn OutputSink>,
cancellation: CancellationToken,
) -> BoxFuture<'static, Settlement> {
Box::pin(async move {
let result = self
.0
.execute(
self.1,
Context {
cancellation,
output,
call: None,
},
)
.await;
Settlement::new(result, self.0)
})
}
}
struct Adapter {
answer: String,
started: Arc<Semaphore>,
retained: Arc<Mutex<Option<Context>>>,
}
impl Provider for Adapter {
fn execute(
&self,
request: Request,
context: Context,
) -> BoxFuture<'static, Result<Outcome, Error>> {
let started = self.started.clone();
let retained = self.retained.clone();
let answer = self.answer.clone();
Box::pin(async move {
assert_eq!(request.settings.model.as_deref(), Some("executor-model"));
assert_eq!(
request.settings.thinking_level,
Some(maka_agent_facts::execution::ThinkingLevel::Max)
);
context
.output
.emit(Output::ThinkingDelta {
text: "thought".into(),
})
.await?;
context
.output
.emit(Output::OutputDelta {
text: "partial".into(),
})
.await?;
*retained.lock().unwrap() = Some(context.clone());
started.add_permits(1);
if request.content.text == "wait" {
context.cancellation.cancelled().await;
}
Ok(Outcome::Completed { text: answer })
})
}
}
#[tokio::test]
async fn external_backend_uses_canonical_admission_settlement_and_never_enters_model_loop() {
let temp = tempfile::tempdir().unwrap();
let log = Arc::new(
EventLog::open(&temp.path().join("executor.sqlite"))
.await
.unwrap(),
);
let engine = context::engine(log.clone());
let started = Arc::new(Semaphore::new(0));
let retained = Arc::new(Mutex::new(None));
let answer = format!(
"{}COMPLETE_EXECUTOR_FINAL",
"External answer 原文\n".repeat(60_000)
);
let read_bytes = answer.len() + 1024 * 1024;
let adapter = Arc::new(Adapter {
answer: answer.clone(),
started: started.clone(),
retained: retained.clone(),
});
for id in ["complete", "wait"] {
let mut configuration = invocation::configuration(ToolMode::Direct);
configuration.model = None;
let input = ExecutorInput {
request: Request {
language: configuration.language,
invocation: Invocation {
session_id: "session".into(),
turn_id: id.into(),
run_id: id.into(),
invocation_id: id.into(),
},
conversation_key: "session".into(),
settings: maka_agent_facts::executor::Settings {
model: Some("executor-model".into()),
thinking_level: Some(maka_agent_facts::execution::ThinkingLevel::Max),
},
content: id.into(),
cwd: configuration.cwd.clone(),
instructions: None,
},
binding: NativeBackend(adapter.clone()),
configuration,
request_fingerprint: Some(id.into()),
source_messages: vec![],
};
let running = engine
.start_executor(input, CancellationToken::new())
.await
.unwrap();
assert!(running.handoff().is_none());
started.acquire().await.unwrap().forget();
if id == "wait" {
let call = running.cancellation();
call.cancel();
}
let result = running.wait().await;
if id == "complete" {
result.unwrap();
} else {
assert!(matches!(result, Err(RunError::Cancelled)));
}
let old = retained.lock().unwrap().take().unwrap();
assert!(matches!(
old.output
.emit(Output::OutputDelta {
text: "late".into()
})
.await,
Err(Error::Cancelled)
));
}
engine.drain().await;
let prefix = log.prefix(100, read_bytes).await.unwrap();
for row in &prefix.events {
if let Fact::ExecutorStarted { settings, .. } = &row.event.fact {
assert_eq!(settings.model.as_deref(), Some("executor-model"));
assert_eq!(
settings.thinking_level,
Some(maka_agent_facts::execution::ThinkingLevel::Max)
);
}
}
assert_eq!(
prefix
.events
.iter()
.filter(|row| matches!(row.event.fact, Fact::ExecutorCompleted { .. }))
.count(),
1
);
let completed_text = prefix
.events
.iter()
.find_map(|row| match &row.event.fact {
Fact::ExecutorCompleted { text } if row.event.invocation.turn_id == "complete" => {
Some(text)
}
_ => None,
})
.expect("completed external result must be persisted before releasing settlement");
assert!(
completed_text == &answer,
"completed external result lost original content"
);
assert!(
prefix
.events
.iter()
.any(|row| row.event.invocation.turn_id == "wait"
&& matches!(
row.event.fact,
Fact::InvocationEnded {
outcome: InvocationOutcome::Cancelled { .. }
}
))
);
assert!(!prefix.events.iter().any(|row| matches!(
row.event.fact,
Fact::ModelRequested { .. } | Fact::ToolDispatched { .. }
)));
let stream = log
.session_stream_events("session", 0, prefix.high_water, 64, read_bytes)
.await
.unwrap();
assert!(stream.events.iter().any(|event| event.invocation.turn_id == "wait"
&& matches!(&event.fact, maka_event_log::observation::StreamFact::InvocationEnded { interrupted, .. } if interrupted.len() == 1)));
// A crash after the external dispatch cannot be downgraded to "never ran".
// Recovery seals it without invoking the adapter or claiming cleanup.
let crashed = Invocation {
turn_id: "crashed".into(),
run_id: "crashed".into(),
invocation_id: "crashed".into(),
session_id: "session".into(),
};
for stored in prefix
.events
.iter()
.filter(|row| row.event.invocation.invocation_id == "complete")
{
if matches!(
stored.event.fact,
Fact::InvocationOpened { .. }
| Fact::ExecutorStarted { .. }
| Fact::ExecutorObserved { .. }
) {
log.append(
&maka_agent_facts::event::EventWrite::plain(
maka_agent_facts::event::RuntimeEvent::new(
crashed.clone(),
stored.event.fact.clone(),
),
)
.unwrap(),
)
.await
.unwrap();
}
}
assert!(
log.invocation_recovery(&crashed, 32, 1024 * 1024)
.await
.unwrap()
.unfinished_executor
);
assert_eq!(
maka_event_log::recovery::recover_invocations(&log)
.await
.unwrap(),
1
);
assert_eq!(
maka_event_log::recovery::recover_invocations(&log)
.await
.unwrap(),
0
);
let recovered = log.prefix(100, read_bytes).await.unwrap();
assert!(recovered.project_invocation("crashed").unfinished_executor);
assert!(recovered.events.iter().any(|row| row.event.invocation == crashed
&& matches!(&row.event.fact, Fact::InvocationEnded { outcome: InvocationOutcome::Failed { class, .. } } if class == "outcome_unknown")));
assert_eq!(
started.available_permits(),
0,
"recovery must not call the adapter"
);
log.shutdown().await.unwrap();
}
struct CleanupFault {
log: Arc<EventLog>,
sql_failure: bool,
}
impl Provider for CleanupFault {
fn execute(&self, _: Request, _: Context) -> BoxFuture<'static, Result<Outcome, Error>> {
let log = self.log.clone();
let sql_failure = self.sql_failure;
Box::pin(async move {
if sql_failure {
log.shutdown().await.unwrap();
}
Err(Error::CleanupUnconfirmed)
})
}
}
async fn unconfirmed_executor(sql_failure: bool) {
let temp = tempfile::tempdir().unwrap();
let log = Arc::new(
EventLog::open(&temp.path().join("executor.sqlite"))
.await
.unwrap(),
);
let engine = context::engine(log.clone());
let adapter = Arc::new(CleanupFault {
log: log.clone(),
sql_failure,
});
let mut configuration = invocation::configuration(ToolMode::Direct);
configuration.model = None;
let running = engine
.start_executor(
ExecutorInput {
request: Request {
language: configuration.language,
invocation: Invocation {
session_id: "session".into(),
turn_id: "turn".into(),
run_id: "run".into(),
invocation_id: "invocation".into(),
},
conversation_key: "session".into(),
settings: Default::default(),
content: "question".into(),
cwd: configuration.cwd.clone(),
instructions: None,
},
binding: NativeBackend(adapter),
configuration,
request_fingerprint: Some("original cleanup request".into()),
source_messages: vec![],
},
CancellationToken::new(),
)
.await
.unwrap();
let completion = running.completion().await;
assert!(completion.result.is_err());
assert!(
completion.cleanup.is_err(),
"terminal SQL failure cannot clear original executor cleanup failure"
);
engine.drain().await;
}
#[tokio::test]
async fn executor_cleanup_failure_is_independent_of_ordinary_terminal_verdict() {
tokio::time::timeout(Duration::from_secs(10), unconfirmed_executor(false))
.await
.unwrap();
}
#[tokio::test]
async fn executor_cleanup_failure_survives_terminal_sql_failure() {
tokio::time::timeout(Duration::from_secs(10), unconfirmed_executor(true))
.await
.unwrap();
}