kranz_engine/backend_mock.rs
1//! Mock agent backend — scripted sessions so the whole engine tests without
2//! model calls (plan §8: "the seam").
3//!
4//! [`MockBackend`] holds a FIFO queue of [`MockScript`]s; each
5//! [`AgentBackend::start`] call consumes the front script and returns a
6//! [`MockSession`] that replays the scripted [`AgentEvent`]s. The backend
7//! records every [`SessionSpec`] it was started with and every user message
8//! injected into its sessions, so tests can assert on exactly what the engine
9//! asked for.
10//!
11//! Blocking semantics mirror the real CLI backend: a single-shot session's
12//! stream closes after its scripted events; a streaming session with an empty
13//! queue parks in [`AgentSession::next_event`] until `send_user_message`
14//! supplies the next scripted batch or `abort` closes the stream. Because the
15//! `AgentSession` trait takes `&mut self`, a parked `next_event` future must
16//! be cancelled (e.g. via `tokio::time::timeout` or `select!`) before the
17//! caller can send or abort — the same discipline a real child-process stream
18//! requires.
19
20use crate::backend::{AgentBackend, AgentEvent, AgentSession, SessionExit, SessionSpec};
21use crate::error::{EngineError, Result};
22use crate::types::TokenUsage;
23use serde_json::json;
24use std::collections::VecDeque;
25use std::sync::{Arc, Mutex};
26use tokio::sync::{watch, Notify};
27
28// ---------------------------------------------------------------------------
29// Script
30// ---------------------------------------------------------------------------
31
32/// One scripted agent session.
33#[derive(Debug, Clone)]
34pub struct MockScript {
35 /// Yielded in order by `next_event`.
36 pub events: Vec<AgentEvent>,
37 /// Each `send_user_message` appends the next batch to the pending queue
38 /// (streaming sessions).
39 pub on_message: VecDeque<Vec<AgentEvent>>,
40 /// Exit reported once the stream closes (ignored when aborted, and never
41 /// reached by streaming sessions, which only end via `abort`).
42 pub exit: SessionExit,
43 /// When false, `send_user_message` errors (single-shot session).
44 pub streaming: bool,
45 /// Session id override; defaults to `spec.session_id`.
46 pub session_id: Option<String>,
47 /// Rendezvous: withhold this script's FINAL scripted event (for a
48 /// single-shot run, its `Result`) until at least this many sessions have
49 /// been started backend-wide. Makes wall-clock-overlap tests
50 /// deterministic: a held session provably cannot finish before its peers
51 /// start, so peak-concurrency assertions are exact — and if the engine
52 /// ever dispatches sequentially, the rendezvous deadlocks into the
53 /// test's timeout instead of flaky-passing. Meaningful for single-shot
54 /// scripts (streaming sessions end via `abort`, not a final event).
55 pub hold_result_until_started: Option<usize>,
56 /// Files to write into the session's working directory (`spec.cwd`) when
57 /// the session starts — the seam that lets a mock session leave a dirty
58 /// tree for the engine's §4.4 checkpoint to commit. Each entry is a path
59 /// relative to the session cwd and its contents; parent directories are
60 /// created as needed. Empty by default (byte-for-byte unchanged
61 /// behaviour).
62 pub writes: Vec<(String, String)>,
63 /// Commit messages to record in the session's working directory
64 /// (`spec.cwd`) when the session starts, applied AFTER `writes`: each
65 /// becomes `git add -A && git commit -m <message>` — the seam that lets a
66 /// scripted validator MOVE HEAD, as a real `git commit`-capable validator
67 /// could (validator-immutability-proof tests). Empty by default.
68 pub commits: Vec<String>,
69 /// Paths to REMOVE from the session's working directory (`spec.cwd`) when
70 /// the session starts, applied AFTER `writes` and `commits` — the seam
71 /// that lets a scripted worker sabotage its own worktree metadata (e.g.
72 /// delete its `.git`), so the engine's checkpoint finds an uninspectable
73 /// worktree exactly as a hostile worker could leave one (12th-pass
74 /// review, candidate-inspection-failure tests). Empty by default.
75 pub removes: Vec<String>,
76 /// CONNECTs this session issues through the egress proxy named by
77 /// `spec.env[HTTPS_PROXY]` at start (3.3b): the seam that lets a scripted
78 /// `fs+net` session be REFUSED a destination so the run's outcome carries
79 /// `denied_egress`, exactly as a real sandboxed agent's blocked connection
80 /// would. Each `(host, port)` is attempted in order before the session's
81 /// events replay. Empty by default (no proxy traffic).
82 pub proxy_connects: Vec<(String, u16)>,
83}
84
85impl Default for MockScript {
86 fn default() -> Self {
87 MockScript {
88 events: Vec::new(),
89 on_message: VecDeque::new(),
90 exit: SessionExit::Completed,
91 streaming: false,
92 session_id: None,
93 hold_result_until_started: None,
94 writes: Vec::new(),
95 commits: Vec::new(),
96 removes: Vec::new(),
97 proxy_connects: Vec::new(),
98 }
99 }
100}
101
102impl MockScript {
103 /// A completed single-shot run: init → text → successful result carrying
104 /// `final_text`, with the standard mock usage/cost.
105 pub fn single_shot(final_text: &str) -> Self {
106 MockScript {
107 events: vec![
108 mock_init("mock-session"),
109 mock_text(final_text),
110 mock_result_text(final_text),
111 ],
112 ..Default::default()
113 }
114 }
115
116 /// Like [`single_shot`](Self::single_shot) but the text/result carry the
117 /// serialized JSON — for worker-report / validator-report runs where the
118 /// engine parses the result text.
119 pub fn single_shot_json(value: &serde_json::Value) -> Self {
120 let text = value.to_string();
121 MockScript {
122 events: vec![
123 mock_init("mock-session"),
124 mock_text(&text),
125 mock_result_json(value),
126 ],
127 ..Default::default()
128 }
129 }
130
131 /// A streaming-input session that yields `initial` and then waits for
132 /// injected user messages. Chain [`responding`](Self::responding) to
133 /// script the per-message batches.
134 pub fn streaming(initial: Vec<AgentEvent>) -> Self {
135 MockScript {
136 events: initial,
137 streaming: true,
138 ..Default::default()
139 }
140 }
141
142 /// Script the batches released by successive `send_user_message` calls.
143 pub fn responding(mut self, batches: Vec<Vec<AgentEvent>>) -> Self {
144 self.on_message = batches.into();
145 self
146 }
147
148 /// Override the exit reported when the stream closes.
149 pub fn with_exit(mut self, exit: SessionExit) -> Self {
150 self.exit = exit;
151 self
152 }
153
154 /// Override the session id reported by [`AgentSession::session_id`]
155 /// (defaults to `spec.session_id`).
156 pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
157 self.session_id = Some(session_id.into());
158 self
159 }
160
161 /// Rendezvous (see [`MockScript::hold_result_until_started`]): withhold
162 /// the final scripted event until `n` sessions have started.
163 pub fn rendezvous(mut self, n: usize) -> Self {
164 self.hold_result_until_started = Some(n);
165 self
166 }
167
168 /// Write `contents` to `path` (relative to the session's working
169 /// directory) when the session starts — lets a scripted worker session
170 /// leave a dirty tree for the engine's §4.4 checkpoint to commit.
171 pub fn writes_file(mut self, path: impl Into<String>, contents: impl Into<String>) -> Self {
172 self.writes.push((path.into(), contents.into()));
173 self
174 }
175
176 /// `git add -A && git commit -m <message>` in the session's working
177 /// directory when the session starts (applied after any `writes_file`)
178 /// — lets a scripted validator move HEAD inside its "read-only" session,
179 /// as a real Bash-capable validator could (validator-immutability-proof
180 /// tests).
181 pub fn commits_all(mut self, message: impl Into<String>) -> Self {
182 self.commits.push(message.into());
183 self
184 }
185
186 /// Remove `path` (relative to the session's working directory) when the
187 /// session starts, applied after any `writes_file`/`commits_all` — lets
188 /// a scripted worker sabotage its own worktree (e.g. delete its `.git`)
189 /// so the engine's checkpoint faces an uninspectable worktree
190 /// (12th-pass review, candidate-inspection-failure tests).
191 pub fn removes_path(mut self, path: impl Into<String>) -> Self {
192 self.removes.push(path.into());
193 self
194 }
195
196 /// Attempt `CONNECT host:port` through the session's egress proxy (the
197 /// `HTTPS_PROXY` env the runner wires for an `fs+net` session) when the
198 /// session starts — the stand-in for a real sandboxed agent's blocked
199 /// connection, so the run's outcome carries the denial the grant flow
200 /// parks on. Errors the start (loud, never vacuous) when the spec carries
201 /// no proxy env.
202 pub fn connects_via_proxy(mut self, host: impl Into<String>, port: u16) -> Self {
203 self.proxy_connects.push((host.into(), port));
204 self
205 }
206}
207
208// ---------------------------------------------------------------------------
209// Event helpers — the standard shapes scripts are built from
210// ---------------------------------------------------------------------------
211
212/// The token usage attached to every mock result event.
213fn mock_usage() -> TokenUsage {
214 TokenUsage {
215 input: 1000,
216 output: 200,
217 cache_read: 0,
218 cache_write: 0,
219 }
220}
221
222/// System init event (first message of every session).
223pub fn mock_init(session_id: &str) -> AgentEvent {
224 AgentEvent::Init {
225 session_id: session_id.to_string(),
226 model: "mock-model".to_string(),
227 raw: json!({
228 "mock": true,
229 "type": "system",
230 "subtype": "init",
231 "session_id": session_id,
232 "model": "mock-model",
233 }),
234 }
235}
236
237/// Assistant text output.
238pub fn mock_text(text: &str) -> AgentEvent {
239 AgentEvent::Text {
240 text: text.to_string(),
241 raw: json!({
242 "mock": true,
243 "type": "assistant",
244 "message": { "content": [{ "type": "text", "text": text }] },
245 }),
246 }
247}
248
249/// Assistant tool request.
250pub fn mock_tool_use(tool: &str, summary: &str) -> AgentEvent {
251 AgentEvent::ToolUse {
252 tool: tool.to_string(),
253 summary: summary.to_string(),
254 raw: json!({
255 "mock": true,
256 "type": "assistant",
257 "message": {
258 "content": [{ "type": "tool_use", "name": tool, "input": { "summary": summary } }],
259 },
260 }),
261 }
262}
263
264/// Successful tool result returned to the model.
265pub fn mock_tool_result(tool: &str, summary: &str) -> AgentEvent {
266 AgentEvent::ToolResult {
267 tool: Some(tool.to_string()),
268 denied: false,
269 summary: summary.to_string(),
270 raw: json!({
271 "mock": true,
272 "type": "user",
273 "tool": tool,
274 "content": summary,
275 "is_error": false,
276 }),
277 }
278}
279
280/// Tool call blocked by permission rules (guardrail hit, §4.7).
281pub fn mock_denied(tool: &str, summary: &str) -> AgentEvent {
282 AgentEvent::ToolResult {
283 tool: Some(tool.to_string()),
284 denied: true,
285 summary: summary.to_string(),
286 raw: json!({
287 "mock": true,
288 "type": "user",
289 "tool": tool,
290 "content": summary,
291 "is_error": true,
292 "denied": true,
293 }),
294 }
295}
296
297/// Successful terminal result with `text`, standard mock usage and cost 0.01.
298pub fn mock_result_text(text: &str) -> AgentEvent {
299 AgentEvent::Result {
300 text: text.to_string(),
301 is_error: false,
302 usage: mock_usage(),
303 cost_usd: Some(0.01),
304 num_turns: Some(1),
305 raw: json!({
306 "mock": true,
307 "type": "result",
308 "subtype": "success",
309 "is_error": false,
310 "result": text,
311 "total_cost_usd": 0.01,
312 "num_turns": 1,
313 "usage": {
314 "input_tokens": 1000,
315 "output_tokens": 200,
316 "cache_read_input_tokens": 0,
317 "cache_creation_input_tokens": 0,
318 },
319 }),
320 }
321}
322
323/// Terminal error result: same shape as [`mock_result_text`] but `is_error`
324/// is set, so `pump_turn` treats the turn as failed (best-effort capture-turn
325/// abort scripting).
326pub fn mock_result_error(text: &str) -> AgentEvent {
327 match mock_result_text(text) {
328 AgentEvent::Result {
329 usage,
330 cost_usd,
331 num_turns,
332 ..
333 } => AgentEvent::Result {
334 text: text.to_string(),
335 is_error: true,
336 usage,
337 cost_usd,
338 num_turns,
339 raw: json!({
340 "mock": true,
341 "type": "result",
342 "subtype": "error",
343 "is_error": true,
344 "result": text,
345 "total_cost_usd": 0.01,
346 "num_turns": 1,
347 "usage": {
348 "input_tokens": 1000,
349 "output_tokens": 200,
350 "cache_read_input_tokens": 0,
351 "cache_creation_input_tokens": 0,
352 },
353 }),
354 },
355 _ => unreachable!("mock_result_text always builds a Result event"),
356 }
357}
358
359/// Like [`mock_result_text`] but the result text is the serialized JSON value
360/// (structured-output runs).
361pub fn mock_result_json(value: &serde_json::Value) -> AgentEvent {
362 let text = value.to_string();
363 match mock_result_text(&text) {
364 AgentEvent::Result {
365 is_error,
366 usage,
367 cost_usd,
368 num_turns,
369 ..
370 } => AgentEvent::Result {
371 text,
372 is_error,
373 usage,
374 cost_usd,
375 num_turns,
376 raw: json!({
377 "mock": true,
378 "type": "result",
379 "subtype": "success",
380 "is_error": false,
381 "result": value.to_string(),
382 "structured_output": value,
383 "total_cost_usd": 0.01,
384 "num_turns": 1,
385 "usage": {
386 "input_tokens": 1000,
387 "output_tokens": 200,
388 "cache_read_input_tokens": 0,
389 "cache_creation_input_tokens": 0,
390 },
391 }),
392 },
393 _ => unreachable!("mock_result_text always builds a Result event"),
394 }
395}
396
397// ---------------------------------------------------------------------------
398// Backend
399// ---------------------------------------------------------------------------
400
401/// Scripted [`AgentBackend`]: `start()` pops scripts FIFO and records every
402/// spec it saw. Injected user messages are recorded per session, aligned with
403/// start order.
404pub struct MockBackend {
405 scripts: Mutex<VecDeque<MockScript>>,
406 started_specs: Mutex<Vec<SessionSpec>>,
407 /// Shared with sessions: `injected[i]` are the messages injected into the
408 /// i-th started session.
409 injected: Arc<Mutex<Vec<Vec<String>>>>,
410 /// Count of sessions started, observable by parked rendezvous sessions
411 /// (see [`MockScript::hold_result_until_started`]).
412 started_count: watch::Sender<usize>,
413}
414
415impl Default for MockBackend {
416 fn default() -> Self {
417 MockBackend {
418 scripts: Mutex::new(VecDeque::new()),
419 started_specs: Mutex::new(Vec::new()),
420 injected: Arc::new(Mutex::new(Vec::new())),
421 started_count: watch::channel(0).0,
422 }
423 }
424}
425
426impl MockBackend {
427 pub fn new() -> Self {
428 Self::default()
429 }
430
431 /// Backend pre-loaded with scripts (consumed FIFO by `start()`).
432 pub fn with_scripts(scripts: Vec<MockScript>) -> Self {
433 MockBackend {
434 scripts: Mutex::new(scripts.into()),
435 ..Default::default()
436 }
437 }
438
439 /// Queue another script at the back.
440 pub fn push_script(&self, script: MockScript) {
441 self.scripts
442 .lock()
443 .expect("mock scripts lock")
444 .push_back(script);
445 }
446
447 /// Clones of every spec passed to `start()`, in start order.
448 pub fn started_specs(&self) -> Vec<SessionSpec> {
449 self.started_specs.lock().expect("mock specs lock").clone()
450 }
451
452 /// User messages injected per session, aligned with start order.
453 pub fn injected_messages(&self) -> Vec<Vec<String>> {
454 self.injected.lock().expect("mock injected lock").clone()
455 }
456}
457
458#[async_trait::async_trait]
459impl AgentBackend for MockBackend {
460 async fn start(&self, spec: SessionSpec) -> Result<Box<dyn AgentSession>> {
461 let script = self
462 .scripts
463 .lock()
464 .expect("mock scripts lock")
465 .pop_front()
466 .ok_or_else(|| EngineError::Backend("mock: no script queued".to_string()))?;
467
468 let slot = {
469 let mut injected = self.injected.lock().expect("mock injected lock");
470 injected.push(Vec::new());
471 injected.len() - 1
472 };
473
474 for (rel_path, contents) in &script.writes {
475 let target = spec.cwd.join(rel_path);
476 if let Some(parent) = target.parent() {
477 std::fs::create_dir_all(parent).map_err(|e| {
478 EngineError::Backend(format!(
479 "mock: failed to create parent dirs for {}: {e}",
480 target.display()
481 ))
482 })?;
483 }
484 std::fs::write(&target, contents).map_err(|e| {
485 EngineError::Backend(format!("mock: failed to write {}: {e}", target.display()))
486 })?;
487 }
488
489 // Head-move seam (see [`MockScript::commits`]): record each scripted
490 // commit in the session cwd AFTER the scripted writes, so a validator
491 // script can turn a dirty tree into a moved HEAD inside its session.
492 if !script.commits.is_empty() {
493 let repo = crate::git_ops::GitRepo::open(&spec.cwd)?;
494 repo.ensure_identity()?;
495 for message in &script.commits {
496 repo.add_all_and_commit(message)?;
497 }
498 }
499
500 // Sabotage seam (see [`MockScript::removes`]): applied AFTER writes
501 // and commits, so a scripted worker can leave a deliverable AND an
502 // uninspectable tree — the order a real hostile worker's actions
503 // would produce.
504 for rel_path in &script.removes {
505 let target = spec.cwd.join(rel_path);
506 let metadata = std::fs::symlink_metadata(&target).map_err(|e| {
507 EngineError::Backend(format!(
508 "mock: failed to stat {} for scripted removal: {e}",
509 target.display()
510 ))
511 })?;
512 if metadata.is_dir() {
513 std::fs::remove_dir_all(&target).map_err(|e| {
514 EngineError::Backend(format!(
515 "mock: failed to remove {}: {e}",
516 target.display()
517 ))
518 })?;
519 } else {
520 std::fs::remove_file(&target).map_err(|e| {
521 EngineError::Backend(format!(
522 "mock: failed to remove {}: {e}",
523 target.display()
524 ))
525 })?;
526 }
527 }
528
529 // Egress-denial seam (see [`MockScript::proxy_connects`]): issue each
530 // scripted CONNECT through the proxy the runner wired into the spec
531 // BEFORE the session's events replay, so the proxy records the denial
532 // before the run's outcome is built. A scripted connect with no proxy
533 // env is a misconfigured test — fail loudly rather than vacuously
534 // produce zero denials.
535 for (host, port) in &script.proxy_connects {
536 let url = spec
537 .env
538 .get(crate::egress_proxy::HTTPS_PROXY_ENV)
539 .ok_or_else(|| {
540 EngineError::Backend(format!(
541 "mock: scripted CONNECT to {host}:{port} but the session spec carries no HTTPS_PROXY env"
542 ))
543 })?;
544 let addr = url.strip_prefix("http://").ok_or_else(|| {
545 EngineError::Backend(format!("mock: HTTPS_PROXY {url:?} is not http://host:port"))
546 })?;
547 let mut stream = tokio::net::TcpStream::connect(addr).await.map_err(|e| {
548 EngineError::Backend(format!(
549 "mock: failed to reach the egress proxy at {addr}: {e}"
550 ))
551 })?;
552 tokio::io::AsyncWriteExt::write_all(
553 &mut stream,
554 format!("CONNECT {host}:{port} HTTP/1.1\r\n\r\n").as_bytes(),
555 )
556 .await
557 .map_err(|e| {
558 EngineError::Backend(format!("mock: CONNECT {host}:{port} write failed: {e}"))
559 })?;
560 // Read the response head to its CRLF terminator: the proxy records
561 // a denial BEFORE answering 403, so awaiting the head guarantees
562 // the record exists once start() returns. (Read to the terminator,
563 // not EOF: an ALLOWED connect gets a 200 and the tunnel stays open.)
564 let mut head: Vec<u8> = Vec::new();
565 let mut byte = [0u8; 1];
566 while !head.ends_with(b"\r\n\r\n") && head.len() < 8192 {
567 let n = tokio::io::AsyncReadExt::read(&mut stream, &mut byte)
568 .await
569 .map_err(|e| {
570 EngineError::Backend(format!(
571 "mock: CONNECT {host}:{port} read failed: {e}"
572 ))
573 })?;
574 if n == 0 {
575 break;
576 }
577 head.push(byte[0]);
578 }
579 }
580
581 let session_id = script
582 .session_id
583 .clone()
584 .unwrap_or_else(|| spec.session_id.clone());
585 self.started_specs
586 .lock()
587 .expect("mock specs lock")
588 .push(spec.clone());
589 self.started_count.send_modify(|count| *count += 1);
590
591 Ok(Box::new(MockSession {
592 spec,
593 session_id,
594 streaming: script.streaming,
595 pending: script.events.into(),
596 on_message: script.on_message,
597 script_exit: script.exit,
598 exit: None,
599 notify: Notify::new(),
600 injected: Arc::clone(&self.injected),
601 slot,
602 hold_result_until_started: script.hold_result_until_started,
603 started_count: self.started_count.subscribe(),
604 }))
605 }
606}
607
608// ---------------------------------------------------------------------------
609// Session
610// ---------------------------------------------------------------------------
611
612/// A scripted session handed out by [`MockBackend::start`].
613pub struct MockSession {
614 /// Clone of the spec this session was started with (also recorded on the
615 /// backend via [`MockBackend::started_specs`]).
616 pub spec: SessionSpec,
617 session_id: String,
618 streaming: bool,
619 pending: VecDeque<AgentEvent>,
620 on_message: VecDeque<Vec<AgentEvent>>,
621 script_exit: SessionExit,
622 /// Set exactly when the stream closes (None returned, or abort).
623 exit: Option<SessionExit>,
624 /// Wakes a parked streaming `next_event` after send/abort. `notify_one`
625 /// stores a permit, so a wake issued between a state check and the await
626 /// is never lost.
627 notify: Notify,
628 injected: Arc<Mutex<Vec<Vec<String>>>>,
629 slot: usize,
630 /// Rendezvous (see [`MockScript::hold_result_until_started`]): while
631 /// `Some(n)` and only the final event remains, `next_event` parks until
632 /// `started_count` reaches `n`, then clears itself.
633 hold_result_until_started: Option<usize>,
634 started_count: watch::Receiver<usize>,
635}
636
637#[async_trait::async_trait]
638impl AgentSession for MockSession {
639 fn session_id(&self) -> String {
640 self.session_id.clone()
641 }
642
643 async fn next_event(&mut self) -> Result<Option<AgentEvent>> {
644 // Yield once per call so cooperative schedulers interleave sessions
645 // realistically instead of draining one script synchronously.
646 tokio::task::yield_now().await;
647 loop {
648 if self.exit.is_some() {
649 // Closed (aborted or already finished). Abort drops any
650 // still-pending events, matching process-kill semantics.
651 return Ok(None);
652 }
653 // Rendezvous: the FINAL scripted event is withheld until enough
654 // sessions have started (deterministic wall-clock overlap).
655 if let Some(n) = self.hold_result_until_started {
656 if self.pending.len() == 1 {
657 while *self.started_count.borrow() < n {
658 if self.started_count.changed().await.is_err() {
659 return Err(EngineError::Backend(format!(
660 "mock: rendezvous({n}) abandoned — backend dropped with only \
661 {} session(s) started",
662 *self.started_count.borrow()
663 )));
664 }
665 }
666 self.hold_result_until_started = None;
667 }
668 }
669 if let Some(event) = self.pending.pop_front() {
670 return Ok(Some(event));
671 }
672 if !self.streaming {
673 self.exit = Some(self.script_exit.clone());
674 return Ok(None);
675 }
676 // Streaming with an empty queue: park until send_user_message
677 // pushes the next batch or abort() closes the stream. The state
678 // is re-checked after every wakeup, so spurious wakes are safe.
679 self.notify.notified().await;
680 }
681 }
682
683 async fn send_user_message(&mut self, text: &str) -> Result<()> {
684 if !self.streaming {
685 return Err(EngineError::Backend(
686 "mock: send_user_message on non-streaming session".to_string(),
687 ));
688 }
689 if self.exit.is_some() {
690 return Err(EngineError::Backend(
691 "mock: send_user_message on closed session".to_string(),
692 ));
693 }
694 self.injected.lock().expect("mock injected lock")[self.slot].push(text.to_string());
695 if let Some(batch) = self.on_message.pop_front() {
696 self.pending.extend(batch);
697 }
698 self.notify.notify_one();
699 Ok(())
700 }
701
702 async fn abort(&mut self) -> Result<()> {
703 if self.exit.is_none() {
704 self.exit = Some(SessionExit::Aborted);
705 }
706 self.notify.notify_one();
707 Ok(())
708 }
709
710 fn exit_status(&self) -> Option<SessionExit> {
711 self.exit.clone()
712 }
713}