amberfork_attrib/driver.rs
1//! The re-execution driver: run the patched cassette once and judge the result.
2//!
3//! Where [`patch_cassette`](crate::patch_cassette) builds the counterfactual and
4//! [`classify_recovery`](crate::oracle::classify_recovery) judges a re-run, this stage is the I/O
5//! edge between them: stand up a [`ReplayServer`] over the patched cassette, drive the agent
6//! against its loopback URL, read the re-executed tape back, normalize it, and hand it to the
7//! oracle. It is the one place in counterfactual attribution that reaches the network — and only
8//! past the patch, where the re-run has branched off the tape.
9//!
10//! ## The agent is a seam, so the test stays offline
11//!
12//! Re-driving the agent is abstracted behind [`AgentDriver`]. Production spawns the recorded
13//! `-- <cmd>` subprocess with its base-URL env var pointed at the listener — exactly how
14//! `amberfork record` drove it the first time (that impl lands with the CLI). The test substitutes
15//! an in-process agent that speaks HTTP to the same listener, so `cargo test` exercises the real
16//! server, normalize, and oracle without a provider or a subprocess. This mirrors how
17//! [`Upstream`]/`ScriptedUpstream` already keep the replay crate's tests offline.
18
19use amberfork_align::DiffParams;
20use amberfork_model::Run;
21use amberfork_record::normalize;
22use amberfork_replay::{ReplayError, ReplayServer, Upstream};
23use std::fmt;
24use std::future::Future;
25
26use crate::oracle::{RunVerdict, classify_recovery};
27
28/// Drives the agent under test once, pointed at a base URL, so a re-execution can capture what it
29/// does.
30///
31/// The counterfactual analog of [`Upstream`]: a native async trait (return-position `impl Future`,
32/// not the `async-trait` crate), so the driver is chosen at compile time — an in-process stub in
33/// tests, a spawned subprocess in production — with no `dyn` and no per-run allocation.
34pub trait AgentDriver: Send + Sync {
35 /// Drive the agent once against `base_url`, returning when it has finished.
36 ///
37 /// The agent's own exit code is deliberately *not* a failure here: a run that exits non-zero
38 /// is often exactly the run being re-executed, and its trajectory is what matters, not its
39 /// verdict. [`AgentError`] is reserved for not being able to run the agent at all.
40 ///
41 /// # Errors
42 ///
43 /// Returns [`AgentError`] when the agent cannot be launched (for the subprocess driver, the
44 /// command could not be spawned).
45 fn drive(&self, base_url: &str) -> impl Future<Output = Result<(), AgentError>> + Send;
46}
47
48/// Why the agent could not be driven.
49///
50/// `#[non_exhaustive]`: the subprocess driver may add variants (a spawn-vs-signal distinction)
51/// without breaking callers.
52#[derive(Debug)]
53#[non_exhaustive]
54pub enum AgentError {
55 /// The agent command could not be launched — in practice, the binary is missing or not
56 /// executable.
57 Spawn(std::io::Error),
58}
59
60impl fmt::Display for AgentError {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 match self {
63 Self::Spawn(source) => write!(f, "cannot run the agent: {source}"),
64 }
65 }
66}
67
68impl std::error::Error for AgentError {
69 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
70 match self {
71 Self::Spawn(source) => Some(source),
72 }
73 }
74}
75
76/// Why a re-execution could not be carried out at all.
77///
78/// Distinct from a [`RunVerdict`] of `Inconclusive`: that is a re-run that *ran* but produced no
79/// usable evidence. `ReexecError` is the experiment failing to start — the listener would not bind,
80/// or the agent could not be launched. Both abort a `verify`; an inconclusive run does not.
81#[derive(Debug)]
82#[non_exhaustive]
83pub enum ReexecError {
84 /// The loopback replay listener could not be stood up.
85 Bind(ReplayError),
86 /// The agent could not be driven.
87 Agent(AgentError),
88}
89
90impl fmt::Display for ReexecError {
91 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92 match self {
93 Self::Bind(source) => write!(f, "cannot start the replay listener: {source}"),
94 Self::Agent(source) => write!(f, "{source}"),
95 }
96 }
97}
98
99impl std::error::Error for ReexecError {
100 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
101 match self {
102 Self::Bind(source) => Some(source),
103 Self::Agent(source) => Some(source),
104 }
105 }
106}
107
108/// Re-execute the patched cassette once and return whether the run recovered.
109///
110/// Stands up a [`ReplayServer`] over `patched`, drives `driver` against its loopback URL, then
111/// normalizes the re-executed tape and asks the oracle whether the fork at `origin` is gone. The
112/// re-run answers recorded turns from the tape and relays through `upstream` once it branches off
113/// — so the patched step is served the good run's response, and everything after runs live.
114///
115/// `Ok(verdict)` is the experiment's result, `Inconclusive` included; `Err` means the experiment
116/// could not run (see [`ReexecError`]).
117///
118/// # Errors
119///
120/// [`ReexecError::Bind`] if the listener cannot bind; [`ReexecError::Agent`] if the agent cannot be
121/// launched.
122pub(crate) async fn reexecute_once<U, D>(
123 patched: amberfork_record::Cassette,
124 good: &Run,
125 origin: usize,
126 driver: &D,
127 upstream: U,
128 reexecuted_id: &str,
129 params: &DiffParams,
130) -> Result<RunVerdict, ReexecError>
131where
132 U: Upstream + 'static,
133 D: AgentDriver,
134{
135 let server = ReplayServer::bind(patched, upstream, reexecuted_id)
136 .await
137 .map_err(ReexecError::Bind)?;
138 // A completed agent run — any exit code — leaves its tape; only a failure to launch it aborts.
139 driver
140 .drive(&server.base_url())
141 .await
142 .map_err(ReexecError::Agent)?;
143 // The re-executed tape is itself a cassette (recorded turns + relayed misses, in order), so it
144 // normalizes into a `Run` through the very same path a first-party recording does.
145 let reexecuted = normalize(&server.reexecuted().await);
146 Ok(classify_recovery(good, &reexecuted, origin, params))
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use crate::testkit::{
153 B2, G0, G1, G2, ScriptedAgent, body_of, cassette, good_cassette, response,
154 };
155 use amberfork_record::Cassette;
156 use amberfork_replay::ScriptedUpstream;
157
158 fn good_run() -> Run {
159 normalize(&good_cassette())
160 }
161
162 /// The patched bad cassette: prefix matches good, turn 1 (the fork) serves the good response,
163 /// turn 2 still holds the bad answer for the bad request. Origin (the patched step) is 1 — this
164 /// is what `patch_cassette` produces from the good/bad pair, built directly here so the driver
165 /// test does not depend on the patch builder.
166 fn patched_cassette() -> Cassette {
167 cassette("bad", &[("q0", G0), ("q1", G1), ("q2_bad", B2)])
168 }
169
170 #[tokio::test]
171 async fn a_rerun_that_follows_the_good_path_is_recovered() {
172 // The scripted agent, having been served the good turn-1 response, now asks the good
173 // turn-2 question — which the bad cassette never recorded, so it cache-misses and the
174 // upstream serves the good turn-2 answer. The re-executed outputs are [G0, G1, G2], which
175 // aligns against good with no fork.
176 let verdict = reexecute_once(
177 patched_cassette(),
178 &good_run(),
179 1,
180 &ScriptedAgent {
181 requests: vec![body_of("q0"), body_of("q1"), body_of("q2_good")],
182 },
183 ScriptedUpstream::new([response(G2)]),
184 "reexec",
185 &DiffParams::default(),
186 )
187 .await
188 .expect("the experiment runs");
189
190 assert_eq!(verdict, RunVerdict::Recovered);
191 }
192
193 #[tokio::test]
194 async fn a_rerun_that_still_diverges_is_not_recovered() {
195 // Even served the good turn-1 response, this agent asks the bad turn-2 question, which the
196 // patched cassette still answers with the bad response. The re-executed outputs are
197 // [G0, G1, B2]; B2 is disjoint from good's G2, so the fork survives at the tail.
198 let verdict = reexecute_once(
199 patched_cassette(),
200 &good_run(),
201 1,
202 &ScriptedAgent {
203 requests: vec![body_of("q0"), body_of("q1"), body_of("q2_bad")],
204 },
205 ScriptedUpstream::new([]), // every turn hits the tape; no relay expected
206 "reexec",
207 &DiffParams::default(),
208 )
209 .await
210 .expect("the experiment runs");
211
212 assert_eq!(verdict, RunVerdict::NotRecovered);
213 }
214}