use std::sync::Arc;
use std::time::Duration;
use hotl_engine::{spawn_session, EngineConfig, EngineEvent, Outcome, SessionDeps};
use hotl_platform::SystemClock;
use hotl_provider::ScriptedProvider;
use hotl_store::{Masker, SessionLog, WriteFault};
use hotl_tools::{rules::Rules, Registry};
fn deps(dir: &std::path::Path, log: SessionLog, config: EngineConfig) -> SessionDeps {
SessionDeps {
provider: Arc::new(ScriptedProvider::new(vec![ScriptedProvider::text_reply(
"ok",
)])),
registry: Arc::new(Registry::builtin()),
rules: Arc::new(Rules::default()),
sandbox_enforced: false,
clock: Arc::new(SystemClock),
log,
system: "sys".into(),
cwd: dir.to_path_buf(),
snapshots: None,
hooks: None,
initial_items: Vec::new(),
initial_todos: Vec::new(),
config,
}
}
struct SlowTool;
impl hotl_tools::Tool for SlowTool {
fn name(&self) -> &'static str {
"dawdle"
}
fn description(&self) -> &str {
"waits, then answers"
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object"})
}
fn permission(&self, _: &serde_json::Value) -> hotl_tools::Permission {
hotl_tools::Permission::None
}
fn read_only(&self) -> bool {
true
}
fn run<'a>(
&'a self,
_input: serde_json::Value,
_cancel: tokio_util::sync::CancellationToken,
) -> futures_util::future::BoxFuture<'a, hotl_tools::ToolOutcome> {
Box::pin(async {
tokio::time::sleep(Duration::from_millis(300)).await;
hotl_tools::ToolOutcome::ok("waited")
})
}
}
async fn next_turn_done(handle: &mut hotl_engine::SessionHandle) -> Outcome {
loop {
let ev = tokio::time::timeout(Duration::from_secs(30), handle.events.recv())
.await
.expect("event timeout")
.expect("event channel closed");
if let EngineEvent::TurnDone { outcome, .. } = ev {
return outcome;
}
}
}
#[tokio::test]
async fn disk_full_seals_the_session_with_a_clean_error_and_no_torn_entries() {
let dir = tempfile::tempdir().expect("tempdir");
let config = EngineConfig::default();
let log = SessionLog::create(dir.path(), &config.model, None, Masker::empty(), 0).expect("log");
let log_path = log.path().to_path_buf();
log.inject_fault(WriteFault::TearThenFail);
let mut handle = spawn_session(deps(dir.path(), log, config));
handle.prompt("go".into()).await;
let outcome = next_turn_done(&mut handle).await;
match outcome {
Outcome::Error { message } => {
assert!(
message.contains("sealed"),
"user-facing error must say so: {message}"
)
}
other => panic!("a sealed log must end the turn with an error, got {other:?}"),
}
let content = std::fs::read_to_string(&log_path).expect("read log");
for line in content.lines() {
serde_json::from_str::<hotl_types::Entry>(line).expect("whole line");
}
let replayed = hotl_store::replay(&log_path).expect("a sealed log still replays");
assert!(replayed.warnings.is_empty(), "{:?}", replayed.warnings);
handle.finish(Duration::from_millis(200)).await;
}
#[tokio::test]
async fn a_writer_death_before_fsync_never_leaves_the_projection_ahead_of_the_log() {
let dir = tempfile::tempdir().expect("tempdir");
let config = EngineConfig::default();
let log = SessionLog::create(dir.path(), &config.model, None, Masker::empty(), 0).expect("log");
let log_path = log.path().to_path_buf();
log.inject_fault(WriteFault::DropAckBeforeFsync);
let mut handle = spawn_session(deps(dir.path(), log, config));
handle.prompt("go".into()).await;
match next_turn_done(&mut handle).await {
Outcome::Error { message } => assert!(message.contains("sealed"), "{message}"),
other => panic!("an unacked commit must not report success, got {other:?}"),
}
let replayed = hotl_store::replay(&log_path).expect("replay");
assert!(replayed.warnings.is_empty(), "{:?}", replayed.warnings);
handle.finish(Duration::from_millis(200)).await;
}
#[tokio::test]
async fn a_writer_death_before_fsync_never_resolves_a_pipelined_ticket() {
let dir = tempfile::tempdir().expect("tempdir");
let config = EngineConfig::default();
let log = SessionLog::create(dir.path(), &config.model, None, Masker::empty(), 0).expect("log");
let log_path = log.path().to_path_buf();
let mut registry = Registry::builtin();
registry.register(Box::new(SlowTool));
let provider = Arc::new(ScriptedProvider::new(vec![
ScriptedProvider::tool_call("t1", "dawdle", serde_json::json!({})),
ScriptedProvider::text_reply("never reached"),
]));
let injector = log.fault_injector();
let mut deps = deps(dir.path(), log, config);
deps.registry = Arc::new(registry);
deps.provider = provider.clone();
let mut handle = spawn_session(deps);
handle.prompt("go".into()).await;
loop {
let ev = tokio::time::timeout(Duration::from_secs(30), handle.events.recv())
.await
.expect("event timeout")
.expect("event channel closed");
if let EngineEvent::ToolStart { .. } = ev {
injector(WriteFault::DropAckBeforeFsync);
break;
}
}
match next_turn_done(&mut handle).await {
Outcome::Error { message } => assert!(message.contains("sealed"), "{message}"),
other => panic!("an unresolved ticket must not report success, got {other:?}"),
}
assert_eq!(
provider.requests().len(),
2,
"one sample, plus the optimistic dispatch the sealed ticket then cancelled"
);
let replayed = hotl_store::replay(&log_path).expect("replay");
assert!(replayed.warnings.is_empty(), "{:?}", replayed.warnings);
let lines: Vec<String> = std::fs::read_to_string(&log_path)
.expect("read log")
.lines()
.map(String::from)
.collect();
assert!(
lines.last().expect("a log").contains("tool_results"),
"the last line is the un-acked commit, with nothing after it: {lines:#?}"
);
handle.finish(Duration::from_millis(200)).await;
}