Skip to main content

majordomus_cli/
episodes.rs

1//! Episodes: the execution episode of a client that has no provider hooks, drawn by the
2//! MCP connection itself (ADR 0103).
3//!
4//! # The defect this exists for
5//!
6//! Majordomus draws the episode boundary below the model, so that the episode which
7//! mattered — the one that ended in a crash, a compaction, or somebody closing the window —
8//! is recorded whether or not a model remembered to record it. Until this module that was
9//! wired for exactly one provider. Claude Code fires `SessionStart` and `SessionEnd`, a
10//! shim runs `capture session`, and the repository gets an episode. Every other client got
11//! nothing at all, and the repository's claim to draw the boundary below the model was a
12//! claim about one vendor.
13//!
14//! A client with no hooks still has a boundary, and this server is standing on it: the
15//! client speaks `initialize`, makes calls, and goes. That is an episode. The connection is
16//! the event source, the peer board already tracks attach, activity and detach, and the
17//! repository's own `majordomus capture session` already opens and closes episodes for a
18//! provider that names its session. Nothing new needs inventing; the three had to be joined.
19//!
20//! # A peer is not an episode
21//!
22//! They are deliberately separate types and the distinction has cost real time here. A peer
23//! id (`p1`, `p2`, ...) is *this process's* name for *this connection*, handed out in
24//! attachment order and reused by the next server. It is not a worker identity: a session
25//! that reconnects comes back as a different peer, and on 2026-09-09 a session in this
26//! repository announced, was re-established under a new id, and was invisible to eight
27//! others for three hours because everybody had been treating the id as durable.
28//!
29//! So an episode is keyed by an **external identity** the client supplies — its own
30//! conversation id, thread id, whatever it durably calls itself — and the peer holding it is
31//! a field that changes. The invariants:
32//!
33//! * a connection holds **zero or one** episode: `initialize` alone opens nothing, and a
34//!   client that never calls `episodes.attach` is a peer and not a worker with a record;
35//! * a client that reconnects **re-associates** with its own episode by external identity,
36//!   under whatever peer id it now has;
37//! * an episode outlives the connection that opened it, for a bounded grace, because a
38//!   dropped socket is not the end of somebody's work — it is the most ordinary thing that
39//!   happens to one.
40//!
41//! # What is honestly not here
42//!
43//! Raw prompt capture. An MCP server is handed `initialize`, tool calls and notifications;
44//! the person's prompt is never among them, in any version of the protocol. Prompt capture
45//! stays provider-specific, `share/providers.yaml` declares `prompts: none` for the generic
46//! provider with that reasoning written down, and no surface implies otherwise.
47//!
48//! # The lifecycle, end to end
49//!
50//! A client attaches under its own name, its connection drops, it comes back as a different
51//! peer under the same name and gets the same episode, and then it says it is done:
52//!
53//! ```
54//! use majordomus_cli::episodes::{CloseReason, ConnectionEpisodeState, EpisodeBoard};
55//! use majordomus_cli::peers::{PeerBoard, Transport};
56//!
57//! let peers = PeerBoard::new();
58//! let board = EpisodeBoard::new(); // records nothing in a repository
59//! let first = peers.attach(Transport::Http);
60//! let (opened, resumed) = board.attach(&first, "thread-7", "generic");
61//! assert!(!resumed);
62//!
63//! board.detach(&first); // the socket went; the work did not
64//! assert_eq!(board.list()[0].state, ConnectionEpisodeState::Detached);
65//!
66//! let second = peers.attach(Transport::Http); // a new peer id, the same worker
67//! let (again, resumed) = board.attach(&second, "thread-7", "generic");
68//! assert!(resumed);
69//! assert_eq!((again.opened_at.as_str(), again.attachments), (opened.opened_at.as_str(), 2));
70//!
71//! let closed = board.close("thread-7", CloseReason::Detach).unwrap();
72//! assert_eq!(closed.external_id, "thread-7");
73//! assert!(board.list().is_empty());
74//! ```
75
76use std::collections::BTreeMap;
77use std::path::{Path, PathBuf};
78use std::process::Command;
79use std::sync::Mutex;
80use std::time::{Duration, Instant, SystemTime};
81
82use schemars::JsonSchema;
83use serde::{Deserialize, Serialize};
84
85use crate::peers::PeerId;
86
87/// How long an episode whose connection has gone is kept before it is closed.
88///
89/// Longer than the transport's own idle timeout on purpose. `http::mcp::SESSION_IDLE_TIMEOUT`
90/// decides when a *connection* is forgotten — ninety seconds, which is right for a socket —
91/// and a client that is restarted, rebuilt or resumed takes longer than that to come back.
92/// Closing the episode at the same moment as the connection would publish a record saying
93/// the work ended, for work that resumed four minutes later under a new peer id, which is
94/// precisely the fiction this whole subsystem exists to avoid.
95pub const REATTACH_GRACE: Duration = Duration::from_secs(15 * 60);
96
97/// Why an episode was closed. Reported to the repository as the end event's `reason`, where
98/// `share/providers.yaml`'s deliberate-end list decides whether the record says `closed` or
99/// `interrupted` — the same table the provider hooks are read through, so a connection
100/// episode and a hook episode cannot disagree about what "ended deliberately" means.
101///
102/// The serialised word and [`CloseReason::as_str`] are one vocabulary:
103///
104/// ```
105/// use majordomus_cli::episodes::CloseReason;
106/// for reason in [CloseReason::Detach, CloseReason::Shutdown, CloseReason::Expired] {
107///     let json = serde_json::to_string(&reason).unwrap();
108///     assert_eq!(json, format!("\"{}\"", reason.as_str()));
109/// }
110/// ```
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
112#[serde(rename_all = "snake_case")]
113pub enum CloseReason {
114    /// The client said so: `episodes.detach`, or `DELETE /mcp`. A deliberate end.
115    Detach,
116    /// The server is stopping and every episode goes with it. Deliberate: nothing was cut
117    /// short by this, the machine was.
118    Shutdown,
119    /// The connection went and did not come back inside [`REATTACH_GRACE`]. Not deliberate,
120    /// and the record says `interrupted`, because calling a crashed episode complete is the
121    /// worse of the two mistakes.
122    Expired,
123}
124
125impl CloseReason {
126    /// The word sent to the repository's end event, as its `reason`. Only `expired` is
127    /// outside the deliberate-end list, so only it records the episode as interrupted.
128    ///
129    /// ```
130    /// use majordomus_cli::episodes::CloseReason;
131    /// assert_eq!(CloseReason::Detach.as_str(), "detach");
132    /// assert_eq!(CloseReason::Expired.as_str(), "expired");
133    /// ```
134    pub fn as_str(self) -> &'static str {
135        match self {
136            CloseReason::Detach => "detach",
137            CloseReason::Shutdown => "shutdown",
138            CloseReason::Expired => "expired",
139        }
140    }
141}
142
143/// Where an episode stands: held by a live connection, or waiting for its client to come
144/// back. There is no closed state, because a closed episode leaves the board; what survives
145/// it is the repository's own record.
146///
147/// ```
148/// use majordomus_cli::episodes::{ConnectionEpisodeState, EpisodeBoard};
149/// use majordomus_cli::peers::{PeerBoard, Transport};
150/// let peer = PeerBoard::new().attach(Transport::Stdio);
151/// let board = EpisodeBoard::new();
152/// assert_eq!(board.attach(&peer, "w", "generic").0.state, ConnectionEpisodeState::Open);
153/// assert_eq!(board.detach(&peer)[0].state, ConnectionEpisodeState::Detached);
154/// assert_eq!(serde_json::to_string(&ConnectionEpisodeState::Detached).unwrap(), "\"detached\"");
155/// ```
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
157#[serde(rename_all = "snake_case")]
158pub enum ConnectionEpisodeState {
159    /// A connection holds it and the client is speaking.
160    Open,
161    /// The connection has gone; the episode is kept for [`REATTACH_GRACE`] so the client
162    /// can come back to it. This is the state a crash lands in, and the one a reconnect
163    /// recovers from.
164    Detached,
165}
166
167/// One episode: what the client calls itself, which connection holds it now, and what the
168/// repository did about it.
169///
170/// ```
171/// use majordomus_cli::episodes::{ConnectionEpisode, EpisodeBoard};
172/// use majordomus_cli::peers::{PeerBoard, Transport};
173/// let peer = PeerBoard::new().attach(Transport::Http);
174/// let (episode, _) = EpisodeBoard::new().attach(&peer, "conv-42", "generic");
175/// let episode: ConnectionEpisode = episode;
176/// assert_eq!((episode.external_id.as_str(), episode.provider.as_str()), ("conv-42", "generic"));
177/// assert_eq!(episode.peer.as_ref(), Some(&peer));
178/// // never empty: a board with no repository behind it says so
179/// assert!(episode.repository.contains("without a driver"));
180/// ```
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
182pub struct ConnectionEpisode {
183    /// The client's own durable name for this piece of work, and the key everything here is
184    /// addressed by. Never a peer id: see the module documentation.
185    pub external_id: String,
186    /// The provider id this episode is recorded under, as `share/providers.yaml` names one.
187    pub provider: String,
188    /// The connection holding it, when one does. `None` while detached — which is a state
189    /// an episode is *supposed* to reach and come back from, not a fault.
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub peer: Option<PeerId>,
192    /// When it was first opened, RFC 3339, UTC. Unchanged by a re-attach: the episode is
193    /// the work, and the work did not restart because a socket did.
194    pub opened_at: String,
195    /// Seconds since the client last spoke on it, through any connection.
196    pub last_activity_seconds_ago: u64,
197    /// How many times a connection has taken it over, the first attach included.
198    pub attachments: u32,
199    /// Open, or detached and waiting for its client.
200    pub state: ConnectionEpisodeState,
201    /// What the repository's own episode command reported, verbatim — the record it opened,
202    /// or the reason it could not. Never interpreted here, and never empty: a subsystem
203    /// that silently does nothing is the failure mode this field exists to make visible.
204    pub repository: String,
205}
206
207/// The episodes of one server.
208///
209/// In memory, like the peer board, and for a narrower reason: what survives the process is
210/// the repository's own record, written by the same command a provider hook runs. This board
211/// holds only the association between a live connection and that record, which is a fact
212/// about now and stops being true when the process does.
213///
214/// ```
215/// use majordomus_cli::episodes::EpisodeBoard;
216/// use majordomus_cli::peers::{PeerBoard, Transport};
217/// let peers = PeerBoard::new();
218/// let (a, b) = (peers.attach(Transport::Stdio), peers.attach(Transport::Http));
219/// let board = EpisodeBoard::default();
220/// board.attach(&b, "zeta", "generic");
221/// board.attach(&a, "alpha", "generic");
222/// // listed by external identity, never by peer or attachment order
223/// let ids: Vec<String> = board.list().into_iter().map(|e| e.external_id).collect();
224/// assert_eq!(ids, ["alpha", "zeta"]);
225/// assert_eq!(board.close_all().len(), 2);
226/// ```
227pub struct EpisodeBoard {
228    episodes: Mutex<BTreeMap<String, Entry>>,
229    /// How the repository's episode boundary is driven. Injected so that the unit suites can
230    /// drive the board without a repository and the real server drives the real command.
231    driver: Box<dyn EpisodeDriver>,
232}
233
234struct Entry {
235    episode: ConnectionEpisode,
236    last_activity: Instant,
237    detached_since: Option<Instant>,
238}
239
240/// What opens and closes the repository's own episode.
241///
242/// One trait with one real implementation, because the alternative was for this module to
243/// learn how a session record is written — and a second writer of the record the provider
244/// hooks already write is exactly the repeated semantic definition this repository refuses
245/// (ADR 0004). [`ToolDriver`] runs `majordomus capture session`, the same command, with the
246/// same payload shape, through the same adapter resolution.
247///
248/// A driver of one's own is how a board is driven without a repository; what it returns is
249/// what the episode's `repository` field carries:
250///
251/// ```
252/// use majordomus_cli::episodes::{CloseReason, EpisodeBoard, EpisodeDriver};
253/// use majordomus_cli::peers::{PeerBoard, Transport};
254///
255/// struct Echo;
256/// impl EpisodeDriver for Echo {
257///     fn open(&self, provider: &str, id: &str) -> String { format!("opened {provider} {id}") }
258///     fn close(&self, _: &str, id: &str, reason: CloseReason) -> String {
259///         format!("closed {id} ({})", reason.as_str())
260///     }
261/// }
262///
263/// let board = EpisodeBoard::with_driver(Box::new(Echo));
264/// let peer = PeerBoard::new().attach(Transport::Stdio);
265/// assert_eq!(board.attach(&peer, "t1", "generic").0.repository, "opened generic t1");
266/// assert_eq!(board.close("t1", CloseReason::Shutdown).unwrap().repository, "closed t1 (shutdown)");
267/// ```
268pub trait EpisodeDriver: Send + Sync {
269    /// Open the episode for `external_id` under `provider`. Returns what to record in
270    /// [`ConnectionEpisode::repository`] — a description of what happened, successful or not.
271    /// It is called once per episode, on the first attach; a resume does not call it again.
272    ///
273    /// ```
274    /// use majordomus_cli::episodes::{EpisodeDriver, NullDriver};
275    /// let said = NullDriver.open("generic", "t1");
276    /// assert!(said.starts_with("no repository episode"), "a driver never answers blank");
277    /// ```
278    fn open(&self, provider: &str, external_id: &str) -> String;
279    /// Close it, with the reason the record is to carry. Returns what the repository did,
280    /// which replaces the episode's `repository` field on the way out.
281    ///
282    /// ```
283    /// use majordomus_cli::episodes::{CloseReason, EpisodeDriver, NullDriver};
284    /// let said = NullDriver.close("generic", "t1", CloseReason::Expired);
285    /// assert!(said.contains("without a driver"));
286    /// ```
287    fn close(&self, provider: &str, external_id: &str, reason: CloseReason) -> String;
288}
289
290/// The driver that runs the repository's own `majordomus capture session`.
291///
292/// It finds the tool the way a provider hook's shim does and in the same order — the
293/// repository's `bin/majordomus`, an installation under `.majordomus/`, then whatever is on
294/// the path — because a second resolution order would be a second answer to "which tool is
295/// this repository's", and the shims are the ones that have been right about it for months.
296///
297/// When no tool is found it records nothing and says why, in the episode's own field:
298///
299/// ```
300/// use majordomus_cli::episodes::{EpisodeDriver, ToolDriver};
301/// let empty = std::env::temp_dir().join("mj-tooldriver-doctest-no-tool");
302/// std::env::remove_var("PATH"); // nor any majordomus on the path
303/// let said = ToolDriver::new(&empty).open("generic", "t1");
304/// assert!(said.starts_with("no episode was recorded"), "{said}");
305/// ```
306#[derive(Debug, Clone)]
307pub struct ToolDriver {
308    root: PathBuf,
309}
310
311impl ToolDriver {
312    /// A driver for the repository at `root`. Nothing is resolved or run here: the tool is
313    /// looked for on every event, so a `bin/majordomus` built after the server started is the
314    /// one used.
315    ///
316    /// ```
317    /// use majordomus_cli::episodes::ToolDriver;
318    /// let driver = ToolDriver::new("/srv/repo");
319    /// assert!(format!("{driver:?}").contains("/srv/repo"));
320    /// ```
321    pub fn new(root: impl Into<PathBuf>) -> Self {
322        ToolDriver { root: root.into() }
323    }
324
325    /// The shell tool, resolved as `.claude/hooks/majordomus-session-start` resolves it.
326    fn tool(&self) -> Option<PathBuf> {
327        for candidate in ["bin/majordomus", ".majordomus/bin/majordomus"] {
328            let p = self.root.join(candidate);
329            if is_executable(&p) {
330                return Some(p);
331            }
332        }
333        which("majordomus")
334    }
335
336    fn run(&self, provider: &str, event: &str, payload: &str) -> String {
337        self.run_tool(self.tool(), provider, event, payload)
338    }
339
340    /// Run `tool`'s `capture session` with `payload` on stdin, or say that there is no tool.
341    fn run_tool(
342        &self,
343        tool: Option<PathBuf>,
344        provider: &str,
345        event: &str,
346        payload: &str,
347    ) -> String {
348        let Some(tool) = tool else {
349            return format!(
350                "no episode was recorded: neither bin/majordomus nor .majordomus/bin/majordomus is executable under {} and none is on the path",
351                self.root.display()
352            );
353        };
354        // The payload goes in on stdin exactly as a provider's own event does, so the one
355        // reader in lib/capture.sh parses both. Writing it with a pipe rather than an
356        // argument is not decoration: `capture session` reads the episode key out of the
357        // payload, and a key that arrived as an argument would be a second way in.
358        let mut child = match Command::new(&tool)
359            .arg("--repo")
360            .arg(&self.root)
361            .args([
362                "capture",
363                "session",
364                "--provider",
365                provider,
366                "--event",
367                event,
368            ])
369            .current_dir(&self.root)
370            .stdin(std::process::Stdio::piped())
371            .stdout(std::process::Stdio::piped())
372            .stderr(std::process::Stdio::piped())
373            .spawn()
374        {
375            Ok(c) => c,
376            Err(e) => {
377                return format!(
378                    "no episode was recorded: {} did not start: {e}",
379                    tool.display()
380                )
381            }
382        };
383        // stdin was asked for as a pipe, so it is there; it is closed when the write is done,
384        // and a tool that exited without reading it has answered all the same
385        let _ = child.stdin.take().map(|mut stdin| {
386            use std::io::Write;
387            stdin.write_all(payload.as_bytes())
388        });
389        reported(event, &tool, child.wait_with_output())
390    }
391}
392
393/// What a finished `capture session` run says happened: its last line on stderr, or, when it
394/// said nothing or could not be waited for, a sentence that says so and names the tool.
395fn reported(event: &str, tool: &Path, finished: std::io::Result<std::process::Output>) -> String {
396    match finished {
397        // `capture session` writes nothing to stdout by contract and reports what it did
398        // on stderr, because on a start event its stdout would be loaded into a model's
399        // context. Its last line is the one that says what happened.
400        Ok(out) => {
401            let text = String::from_utf8_lossy(&out.stderr);
402            let last = text
403                .lines()
404                .rev()
405                .find(|l| !l.trim().is_empty())
406                .unwrap_or("");
407            if last.is_empty() {
408                format!(
409                    "`capture session --event {event}` exited {} and said nothing",
410                    out.status
411                )
412            } else {
413                last.trim().to_string()
414            }
415        }
416        Err(e) => format!(
417            "no episode was recorded: {} did not finish: {e}",
418            tool.display()
419        ),
420    }
421}
422
423impl EpisodeDriver for ToolDriver {
424    fn open(&self, provider: &str, external_id: &str) -> String {
425        self.run(
426            provider,
427            "start",
428            &format!(
429                "{{\"session_id\":{},\"source\":\"attach\"}}\n",
430                json_string(external_id)
431            ),
432        )
433    }
434
435    fn close(&self, provider: &str, external_id: &str, reason: CloseReason) -> String {
436        self.run(
437            provider,
438            "end",
439            &format!(
440                "{{\"session_id\":{},\"reason\":\"{}\"}}\n",
441                json_string(external_id),
442                reason.as_str()
443            ),
444        )
445    }
446}
447
448/// A driver that records nothing: the board without a repository behind it.
449///
450/// Used by the unit suites and by a server serving a checkout whose tool cannot be found. It
451/// says so in every episode's `repository` field rather than leaving it blank, because an
452/// episode that quietly recorded nothing is indistinguishable, to a reader, from one that
453/// recorded everything.
454///
455/// ```
456/// use majordomus_cli::episodes::{EpisodeBoard, NullDriver};
457/// use majordomus_cli::peers::{PeerBoard, Transport};
458/// let board = EpisodeBoard::with_driver(Box::new(NullDriver));
459/// let peer = PeerBoard::new().attach(Transport::Stdio);
460/// let (episode, _) = board.attach(&peer, "t1", "generic");
461/// assert_eq!(episode.repository, "no repository episode: this board is running without a driver");
462/// ```
463#[derive(Debug, Clone, Default)]
464pub struct NullDriver;
465
466impl EpisodeDriver for NullDriver {
467    fn open(&self, _: &str, _: &str) -> String {
468        "no repository episode: this board is running without a driver".into()
469    }
470    fn close(&self, _: &str, _: &str, _: CloseReason) -> String {
471        "no repository episode: this board is running without a driver".into()
472    }
473}
474
475impl std::fmt::Debug for EpisodeBoard {
476    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
477        f.debug_struct("EpisodeBoard")
478            .field("episodes", &self.list().len())
479            .finish()
480    }
481}
482
483impl Default for EpisodeBoard {
484    fn default() -> Self {
485        Self::new()
486    }
487}
488
489impl EpisodeBoard {
490    /// A board that records nothing in the repository: every episode's `repository` field
491    /// says so. The board the unit suites and a repository-less server use.
492    ///
493    /// ```
494    /// use majordomus_cli::episodes::EpisodeBoard;
495    /// let board = EpisodeBoard::new();
496    /// assert!(board.list().is_empty(), "a new board holds no episode");
497    /// ```
498    pub fn new() -> Self {
499        EpisodeBoard {
500            episodes: Mutex::new(BTreeMap::new()),
501            driver: Box::new(NullDriver),
502        }
503    }
504
505    /// A board that drives the repository at `root` through its own `capture session`, by
506    /// a [`ToolDriver`]. Constructing it runs nothing; the first attach does.
507    ///
508    /// ```
509    /// use majordomus_cli::episodes::EpisodeBoard;
510    /// let board = EpisodeBoard::for_repository(std::env::temp_dir());
511    /// assert!(board.list().is_empty(), "no command ran and no episode exists yet");
512    /// assert_eq!(board.close_all().len(), 0);
513    /// ```
514    pub fn for_repository(root: impl Into<PathBuf>) -> Self {
515        EpisodeBoard {
516            episodes: Mutex::new(BTreeMap::new()),
517            driver: Box::new(ToolDriver::new(root)),
518        }
519    }
520
521    /// A board over an arbitrary driver: the seam a test, or a server with another way of
522    /// recording, plugs into. The driver is asked once per opened and once per closed episode.
523    ///
524    /// ```
525    /// use majordomus_cli::episodes::{CloseReason, EpisodeBoard, EpisodeDriver};
526    /// use majordomus_cli::peers::{PeerBoard, Transport};
527    /// struct Fixed;
528    /// impl EpisodeDriver for Fixed {
529    ///     fn open(&self, _: &str, _: &str) -> String { "opened".into() }
530    ///     fn close(&self, _: &str, _: &str, _: CloseReason) -> String { "closed".into() }
531    /// }
532    /// let board = EpisodeBoard::with_driver(Box::new(Fixed));
533    /// let peer = PeerBoard::new().attach(Transport::Stdio);
534    /// assert_eq!(board.attach(&peer, "t", "generic").0.repository, "opened");
535    /// ```
536    pub fn with_driver(driver: Box<dyn EpisodeDriver>) -> Self {
537        EpisodeBoard {
538            episodes: Mutex::new(BTreeMap::new()),
539            driver,
540        }
541    }
542
543    /// Open an episode for `external_id`, or take an existing one over.
544    ///
545    /// The whole point is in the second half. A client that comes back — after a crash, a
546    /// rebuild, a dropped socket, a client restart — names itself the same way and gets the
547    /// same episode, with a new peer holding it and `attachments` one higher. Nothing is
548    /// re-opened, no second record is written, and the ledger lines on either side of the
549    /// gap belong to one episode, which is what makes the record of it true.
550    ///
551    /// Returns the episode and whether it was resumed rather than opened.
552    ///
553    /// ```
554    /// use majordomus_cli::episodes::EpisodeBoard;
555    /// use majordomus_cli::peers::{PeerBoard, Transport};
556    /// let peers = PeerBoard::new();
557    /// let board = EpisodeBoard::new();
558    /// let (p1, p2) = (peers.attach(Transport::Stdio), peers.attach(Transport::Http));
559    /// assert!(!board.attach(&p1, "work", "generic").1, "first: opened");
560    /// let (episode, resumed) = board.attach(&p2, "work", "generic");
561    /// assert!(resumed, "same identity, another peer: resumed");
562    /// assert_eq!((episode.attachments, episode.peer.as_ref()), (2, Some(&p2)));
563    /// assert_eq!(board.list().len(), 1, "one episode, not two");
564    /// ```
565    pub fn attach(
566        &self,
567        peer: &PeerId,
568        external_id: &str,
569        provider: &str,
570    ) -> (ConnectionEpisode, bool) {
571        let mut episodes = self.lock();
572        // A connection holds zero or one episode. A peer attaching to a second external
573        // identity is not a second worker, it is the same connection changing its mind —
574        // the first is released (and left detached, recoverable) rather than held by a peer
575        // that has stopped speaking for it.
576        for entry in episodes.values_mut().filter(|e| {
577            e.episode.peer.as_ref() == Some(peer) && e.episode.external_id != external_id
578        }) {
579            entry.episode.peer = None;
580            entry.episode.state = ConnectionEpisodeState::Detached;
581            entry.detached_since = Some(Instant::now());
582        }
583
584        if let Some(entry) = episodes.get_mut(external_id) {
585            entry.episode.peer = Some(peer.clone());
586            entry.episode.state = ConnectionEpisodeState::Open;
587            entry.episode.attachments = entry.episode.attachments.saturating_add(1);
588            entry.detached_since = None;
589            entry.last_activity = Instant::now();
590            entry.episode.last_activity_seconds_ago = 0;
591            return (entry.episode.clone(), true);
592        }
593
594        // The driver runs while the map is locked, which is deliberate and not an oversight:
595        // two connections racing to attach under one external identity must produce one
596        // episode, and releasing the lock across the subprocess is how they would produce
597        // two. The cost is one attach at a time per server, which is the correct price.
598        //
599        // It is re-entrant across processes and safe to be. `capture session --event start`
600        // runs `serve ensure`, which probes this very server over HTTP from a child process
601        // — so an attach handled on the stdio thread is answered by the HTTP listener's
602        // thread, and one handled by the HTTP endpoint is answered by another of its
603        // workers. Nothing on that path touches this mutex, so the probe cannot wait on the
604        // attach that started it. `test/cases/200_generic_mcp_episode.sh` exercises both
605        // directions — a stdio client that is the server, and a client bridged to somebody
606        // else's — because an argument about threads is worth exactly as much as the run
607        // that confirms it.
608        let repository = self.driver.open(provider, external_id);
609        let episode = ConnectionEpisode {
610            external_id: external_id.to_string(),
611            provider: provider.to_string(),
612            peer: Some(peer.clone()),
613            opened_at: crate::peers::rfc3339(SystemTime::now()),
614            last_activity_seconds_ago: 0,
615            attachments: 1,
616            state: ConnectionEpisodeState::Open,
617            repository,
618        };
619        episodes.insert(
620            external_id.to_string(),
621            Entry {
622                episode: episode.clone(),
623                last_activity: Instant::now(),
624                detached_since: None,
625            },
626        );
627        (episode, false)
628    }
629
630    /// The client spoke. Every message on a connection holding an episode is a heartbeat for
631    /// it, so a working client never has its episode reaped and no client has to send
632    /// anything it would not otherwise send.
633    ///
634    /// ```
635    /// use std::time::Duration;
636    /// use majordomus_cli::episodes::EpisodeBoard;
637    /// use majordomus_cli::peers::{PeerBoard, Transport};
638    /// let peer = PeerBoard::new().attach(Transport::Http);
639    /// let board = EpisodeBoard::new();
640    /// board.attach(&peer, "busy", "generic");
641    /// board.touch(&peer);
642    /// assert_eq!(board.list()[0].last_activity_seconds_ago, 0);
643    /// assert!(board.reap(Duration::ZERO).is_empty(), "a held episode is never reaped");
644    /// ```
645    pub fn touch(&self, peer: &PeerId) {
646        let mut episodes = self.lock();
647        for entry in episodes.values_mut() {
648            if entry.episode.peer.as_ref() == Some(peer) {
649                entry.last_activity = Instant::now();
650            }
651        }
652    }
653
654    /// The connection went. The episode is **not** closed: it is detached, and the client
655    /// can come back to it by name for [`REATTACH_GRACE`].
656    ///
657    /// This is the difference between a lifecycle and a session counter. Closing here would
658    /// publish a record saying the work ended every time a socket dropped, and the work
659    /// almost never ends when a socket drops.
660    ///
661    /// ```
662    /// use majordomus_cli::episodes::{ConnectionEpisodeState, EpisodeBoard};
663    /// use majordomus_cli::peers::{PeerBoard, Transport};
664    /// let peer = PeerBoard::new().attach(Transport::Http);
665    /// let board = EpisodeBoard::new();
666    /// board.attach(&peer, "kept", "generic");
667    /// let detached = board.detach(&peer);
668    /// assert_eq!(detached.len(), 1);
669    /// assert!(detached[0].peer.is_none());
670    /// assert_eq!(board.list()[0].state, ConnectionEpisodeState::Detached, "still on the board");
671    /// assert!(board.detach(&peer).is_empty(), "nothing is held twice");
672    /// ```
673    pub fn detach(&self, peer: &PeerId) -> Vec<ConnectionEpisode> {
674        let mut episodes = self.lock();
675        let mut out = Vec::new();
676        for entry in episodes.values_mut() {
677            if entry.episode.peer.as_ref() == Some(peer) {
678                entry.episode.peer = None;
679                entry.episode.state = ConnectionEpisodeState::Detached;
680                entry.detached_since = Some(Instant::now());
681                out.push(entry.episode.clone());
682            }
683        }
684        out
685    }
686
687    /// Close one episode deliberately, by name: the client said it was done.
688    ///
689    /// ```
690    /// use majordomus_cli::episodes::{CloseReason, EpisodeBoard};
691    /// use majordomus_cli::peers::{PeerBoard, Transport};
692    /// let peer = PeerBoard::new().attach(Transport::Stdio);
693    /// let board = EpisodeBoard::new();
694    /// board.attach(&peer, "done", "generic");
695    /// let closed = board.close("done", CloseReason::Detach).expect("it was open");
696    /// assert!(closed.peer.is_none());
697    /// assert!(board.close("done", CloseReason::Detach).is_none(), "closed once only");
698    /// assert!(board.of_peer(&peer).is_none());
699    /// ```
700    pub fn close(&self, external_id: &str, reason: CloseReason) -> Option<ConnectionEpisode> {
701        let mut episodes = self.lock();
702        let entry = episodes.remove(external_id)?;
703        let mut episode = entry.episode;
704        episode.repository = self
705            .driver
706            .close(&episode.provider, &episode.external_id, reason);
707        episode.peer = None;
708        episode.state = ConnectionEpisodeState::Detached;
709        Some(episode)
710    }
711
712    /// Close every episode detached longer than `grace`. The reaper, and the answer to the
713    /// client that never comes back. Returns what it closed, each as
714    /// [`CloseReason::Expired`], which the record reads as interrupted.
715    ///
716    /// ```
717    /// use std::time::Duration;
718    /// use majordomus_cli::episodes::EpisodeBoard;
719    /// use majordomus_cli::peers::{PeerBoard, Transport};
720    /// let peer = PeerBoard::new().attach(Transport::Http);
721    /// let board = EpisodeBoard::new();
722    /// board.attach(&peer, "gone", "generic");
723    /// board.detach(&peer);
724    /// assert!(board.reap(Duration::from_secs(3600)).is_empty(), "inside the grace it waits");
725    /// assert_eq!(board.reap(Duration::ZERO).len(), 1);
726    /// assert!(board.list().is_empty());
727    /// ```
728    pub fn reap(&self, grace: Duration) -> Vec<ConnectionEpisode> {
729        let expired: Vec<String> = {
730            let episodes = self.lock();
731            episodes
732                .values()
733                .filter(|e| e.detached_since.is_some_and(|t| t.elapsed() > grace))
734                .map(|e| e.episode.external_id.clone())
735                .collect()
736        };
737        expired
738            .into_iter()
739            .filter_map(|id| self.close(&id, CloseReason::Expired))
740            .collect()
741    }
742
743    /// Close every episode: the server is stopping.
744    ///
745    /// An episode left open by a server that has gone is an episode nothing will ever close,
746    /// and the repository would carry it as open for ever — the failure ADR 0041 names on
747    /// the hook side, reached here by a different road.
748    ///
749    /// ```
750    /// use majordomus_cli::episodes::EpisodeBoard;
751    /// use majordomus_cli::peers::{PeerBoard, Transport};
752    /// let peers = PeerBoard::new();
753    /// let board = EpisodeBoard::new();
754    /// board.attach(&peers.attach(Transport::Stdio), "held", "generic");
755    /// let detached = peers.attach(Transport::Http);
756    /// board.attach(&detached, "waiting", "generic");
757    /// board.detach(&detached);
758    /// assert_eq!(board.close_all().len(), 2, "held and detached alike");
759    /// assert!(board.list().is_empty());
760    /// ```
761    pub fn close_all(&self) -> Vec<ConnectionEpisode> {
762        let ids: Vec<String> = self.lock().keys().cloned().collect();
763        ids.into_iter()
764            .filter_map(|id| self.close(&id, CloseReason::Shutdown))
765            .collect()
766    }
767
768    /// Every episode this server holds, open and detached, in external-identity order, each
769    /// with its idle time measured now rather than when it was last written.
770    ///
771    /// ```
772    /// use majordomus_cli::episodes::{ConnectionEpisodeState, EpisodeBoard};
773    /// use majordomus_cli::peers::{PeerBoard, Transport};
774    /// let peers = PeerBoard::new();
775    /// let board = EpisodeBoard::new();
776    /// let p = peers.attach(Transport::Stdio);
777    /// board.attach(&p, "b", "generic");
778    /// board.detach(&p);
779    /// board.attach(&peers.attach(Transport::Http), "a", "generic");
780    /// let states: Vec<_> = board.list().into_iter().map(|e| (e.external_id, e.state)).collect();
781    /// assert_eq!(states, [("a".to_string(), ConnectionEpisodeState::Open),
782    ///                     ("b".to_string(), ConnectionEpisodeState::Detached)]);
783    /// ```
784    pub fn list(&self) -> Vec<ConnectionEpisode> {
785        let episodes = self.lock();
786        episodes
787            .values()
788            .map(|e| {
789                let mut episode = e.episode.clone();
790                episode.last_activity_seconds_ago = e.last_activity.elapsed().as_secs();
791                episode
792            })
793            .collect()
794    }
795
796    /// The episode a connection holds, if it holds one. A connection that only initialised
797    /// holds none, and neither does one whose episode was detached from it.
798    ///
799    /// ```
800    /// use majordomus_cli::episodes::EpisodeBoard;
801    /// use majordomus_cli::peers::{PeerBoard, Transport};
802    /// let peer = PeerBoard::new().attach(Transport::Http);
803    /// let board = EpisodeBoard::new();
804    /// assert!(board.of_peer(&peer).is_none(), "a peer is not a worker until it attaches");
805    /// board.attach(&peer, "mine", "generic");
806    /// assert_eq!(board.of_peer(&peer).map(|e| e.external_id).as_deref(), Some("mine"));
807    /// ```
808    pub fn of_peer(&self, peer: &PeerId) -> Option<ConnectionEpisode> {
809        self.list()
810            .into_iter()
811            .find(|e| e.peer.as_ref() == Some(peer))
812    }
813
814    fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, Entry>> {
815        self.episodes.lock().unwrap_or_else(|e| e.into_inner())
816    }
817}
818
819fn is_executable(p: &Path) -> bool {
820    #[cfg(unix)]
821    {
822        use std::os::unix::fs::PermissionsExt;
823        std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
824    }
825    #[cfg(not(unix))]
826    {
827        p.is_file()
828    }
829}
830
831fn which(name: &str) -> Option<PathBuf> {
832    std::env::var_os("PATH").and_then(|path| find_in(&path, name))
833}
834
835/// The first executable `name` in the directories of a `PATH`-shaped list.
836fn find_in(path: &std::ffi::OsStr, name: &str) -> Option<PathBuf> {
837    std::env::split_paths(path)
838        .map(|d| d.join(name))
839        .find(|p| is_executable(p))
840}
841
842/// One JSON string, escaped. The payload is this tool's own and the identity in it came from
843/// a client, so it is escaped rather than trusted: a quote in an external identity that
844/// reached a shell's stdin unescaped would be a payload of somebody else's making.
845fn json_string(s: &str) -> String {
846    let mut out = String::with_capacity(s.len() + 2);
847    out.push('"');
848    for c in s.chars() {
849        match c {
850            '"' => out.push_str("\\\""),
851            '\\' => out.push_str("\\\\"),
852            '\n' => out.push_str("\\n"),
853            '\r' => out.push_str("\\r"),
854            '\t' => out.push_str("\\t"),
855            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
856            c => out.push(c),
857        }
858    }
859    out.push('"');
860    out
861}
862
863#[cfg(test)]
864mod tests {
865    use super::*;
866    use std::sync::Mutex as StdMutex;
867
868    #[derive(Default)]
869    struct Recording {
870        calls: StdMutex<Vec<String>>,
871    }
872
873    impl EpisodeDriver for std::sync::Arc<Recording> {
874        fn open(&self, provider: &str, external_id: &str) -> String {
875            self.calls
876                .lock()
877                .unwrap()
878                .push(format!("open {provider} {external_id}"));
879            format!("episode opened for {external_id}")
880        }
881        fn close(&self, provider: &str, external_id: &str, reason: CloseReason) -> String {
882            self.calls.lock().unwrap().push(format!(
883                "close {provider} {external_id} {}",
884                reason.as_str()
885            ));
886            format!("episode closed for {external_id}")
887        }
888    }
889
890    fn board() -> (EpisodeBoard, std::sync::Arc<Recording>) {
891        let rec = std::sync::Arc::new(Recording::default());
892        (EpisodeBoard::with_driver(Box::new(rec.clone())), rec)
893    }
894
895    /// Peer ids come from a real board, because they are handed out in attachment order and
896    /// a test that invented them would be testing a numbering this code never sees.
897    fn peers(n: usize) -> Vec<PeerId> {
898        let board = crate::peers::PeerBoard::new();
899        (0..n)
900            .map(|_| board.attach(crate::peers::Transport::Stdio))
901            .collect()
902    }
903
904    /// THE REGRESSION this module exists for. A client that loses its connection and comes
905    /// back gets *its own* episode, not a second one, and the repository is asked to open an
906    /// episode exactly once.
907    #[test]
908    fn a_reconnect_resumes_the_episode_by_external_identity() {
909        let (board, rec) = board();
910        let ids = peers(2);
911        let first = ids[0].clone();
912        let (opened, resumed) = board.attach(&first, "thread-7", "generic");
913        assert!(!resumed, "the first attach is not a resume");
914        assert_eq!(opened.attachments, 1);
915
916        board.detach(&first);
917        assert_eq!(
918            board.list().len(),
919            1,
920            "a detach must not discard the episode"
921        );
922        assert_eq!(board.list()[0].state, ConnectionEpisodeState::Detached);
923
924        // a new connection: a different peer id, the same worker
925        let second = ids[1].clone();
926        let (again, resumed) = board.attach(&second, "thread-7", "generic");
927        assert!(
928            resumed,
929            "the same external identity must resume, not re-open"
930        );
931        assert_eq!(again.opened_at, opened.opened_at, "it is the same episode");
932        assert_eq!(again.attachments, 2);
933        assert_eq!(again.peer.as_ref(), Some(&second));
934
935        assert_eq!(
936            *rec.calls.lock().unwrap(),
937            vec!["open generic thread-7".to_string()],
938            "the repository episode was opened once, not once per connection"
939        );
940    }
941
942    /// A connection holds zero or one episode, and `initialize` alone holds none.
943    #[test]
944    fn a_connection_holds_at_most_one_episode() {
945        let (board, _) = board();
946        let p = peers(1).remove(0);
947        assert!(
948            board.of_peer(&p).is_none(),
949            "attaching nothing holds nothing"
950        );
951        board.attach(&p, "one", "generic");
952        board.attach(&p, "two", "generic");
953        assert_eq!(board.of_peer(&p).map(|e| e.external_id), Some("two".into()));
954        let one = board
955            .list()
956            .into_iter()
957            .find(|e| e.external_id == "one")
958            .unwrap();
959        assert_eq!(
960            one.state,
961            ConnectionEpisodeState::Detached,
962            "the episode it stopped speaking for is released, and recoverable"
963        );
964    }
965
966    /// The reaper closes what never came back, and only that.
967    #[test]
968    fn the_reaper_closes_only_what_is_past_its_grace() {
969        let (board, rec) = board();
970        let p = peers(1).remove(0);
971        board.attach(&p, "gone", "generic");
972        assert!(
973            board.reap(Duration::from_secs(1)).is_empty(),
974            "an attached episode is never reaped"
975        );
976        board.detach(&p);
977        assert!(
978            board.reap(Duration::from_secs(3600)).is_empty(),
979            "inside the grace it waits"
980        );
981        let closed = board.reap(Duration::ZERO);
982        assert_eq!(closed.len(), 1);
983        assert!(board.list().is_empty());
984        assert_eq!(
985            rec.calls.lock().unwrap().last().map(String::as_str),
986            Some("close generic gone expired"),
987            "a cut-short episode is closed as expired, never as a deliberate end"
988        );
989    }
990
991    /// A stopping server closes what it holds; an episode nothing will ever close is an
992    /// episode the repository carries as open for ever.
993    #[test]
994    fn shutdown_closes_every_episode() {
995        let (board, rec) = board();
996        let ids = peers(2);
997        board.attach(&ids[0], "a", "generic");
998        board.attach(&ids[1], "b", "generic");
999        assert_eq!(board.close_all().len(), 2);
1000        assert!(board.list().is_empty());
1001        let calls = rec.calls.lock().unwrap();
1002        assert!(calls.contains(&"close generic a shutdown".to_string()));
1003        assert!(calls.contains(&"close generic b shutdown".to_string()));
1004    }
1005
1006    #[test]
1007    fn an_external_identity_is_escaped_into_the_payload() {
1008        assert_eq!(json_string("a\"b\\c"), "\"a\\\"b\\\\c\"");
1009        assert_eq!(json_string("line\nbreak"), "\"line\\nbreak\"");
1010    }
1011
1012    /// Every control character is escaped, the named ones by name and the rest by code, so
1013    /// no identity can end the payload's line or smuggle a byte a JSON reader refuses.
1014    #[test]
1015    fn every_control_character_is_escaped() {
1016        assert_eq!(json_string("a\rb\tc"), "\"a\\rb\\tc\"");
1017        assert_eq!(json_string("\u{1}\u{1f}"), "\"\\u0001\\u001f\"");
1018        let parsed: String = serde_json::from_str(&json_string("x\r\t\u{7}y")).unwrap();
1019        assert_eq!(
1020            parsed, "x\r\t\u{7}y",
1021            "what is escaped reads back as it was"
1022        );
1023    }
1024
1025    /// A board built without a driver still keeps the episodes; what it reports for the
1026    /// repository is that there is no driver, never a record that was not made.
1027    #[test]
1028    fn a_board_without_a_driver_keeps_episodes_and_says_none_was_recorded() {
1029        for board in [EpisodeBoard::new(), EpisodeBoard::default()] {
1030            let p = peers(1).remove(0);
1031            let (episode, _) = board.attach(&p, "quiet", "generic");
1032            assert_eq!(
1033                episode.repository,
1034                "no repository episode: this board is running without a driver"
1035            );
1036            assert_eq!(format!("{board:?}"), "EpisodeBoard { episodes: 1 }");
1037            let closed = board.close("quiet", CloseReason::Detach).unwrap();
1038            assert_eq!(closed.repository, episode.repository);
1039            assert_eq!(format!("{board:?}"), "EpisodeBoard { episodes: 0 }");
1040        }
1041    }
1042
1043    /// Activity, a lost connection and a deliberate close each reach only the episode they
1044    /// name; closing one nobody holds is nothing, not an error and not somebody else's.
1045    #[test]
1046    fn touch_detach_and_close_reach_only_their_own_episode() {
1047        let (board, _) = board();
1048        let ids = peers(2);
1049        board.attach(&ids[0], "mine", "generic");
1050        board.attach(&ids[1], "theirs", "generic");
1051        let activity = |id: &str| board.lock().get(id).unwrap().last_activity;
1052        let theirs_before = activity("theirs");
1053        let mine_before = activity("mine");
1054        std::thread::sleep(Duration::from_millis(5));
1055        board.touch(&ids[0]);
1056        assert!(
1057            activity("mine") > mine_before,
1058            "the toucher's episode moved"
1059        );
1060        assert_eq!(activity("theirs"), theirs_before, "the other did not");
1061
1062        let detached = board.detach(&ids[0]);
1063        assert_eq!(detached.len(), 1);
1064        assert_eq!(detached[0].external_id, "mine");
1065        assert_eq!(
1066            board.of_peer(&ids[1]).map(|e| e.state),
1067            Some(ConnectionEpisodeState::Open),
1068            "a detach of one connection leaves the other open"
1069        );
1070
1071        assert!(board
1072            .close("nobody-holds-this", CloseReason::Detach)
1073            .is_none());
1074        assert_eq!(board.list().len(), 2);
1075    }
1076
1077    /// A thread that panicked while holding the board does not take the board with it: the
1078    /// map it guarded is still whole and the next attach is served.
1079    #[test]
1080    fn a_poisoned_board_still_answers() {
1081        let board = std::sync::Arc::new(EpisodeBoard::new());
1082        let held = std::sync::Arc::clone(&board);
1083        let _ = std::thread::spawn(move || {
1084            let _guard = held.episodes.lock().unwrap();
1085            panic!("a handler died holding the board");
1086        })
1087        .join();
1088        assert!(board.episodes.is_poisoned());
1089        let p = peers(1).remove(0);
1090        board.attach(&p, "after", "generic");
1091        assert_eq!(board.list().len(), 1);
1092    }
1093
1094    /// The tool on the path is the first executable of that name, in path order; a file that
1095    /// is there but cannot be run is not a tool.
1096    #[test]
1097    fn the_tool_on_the_path_is_the_first_executable_one() {
1098        use std::os::unix::fs::PermissionsExt;
1099        let dir = tempfile::tempdir().unwrap();
1100        let (plain, runnable) = (dir.path().join("plain"), dir.path().join("runnable"));
1101        for (d, mode) in [(&plain, 0o644), (&runnable, 0o755)] {
1102            std::fs::create_dir_all(d).unwrap();
1103            let tool = d.join("majordomus");
1104            std::fs::write(&tool, "#!/bin/sh\n").unwrap();
1105            std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(mode)).unwrap();
1106        }
1107        let path = std::env::join_paths([&plain, &runnable]).unwrap();
1108        assert_eq!(
1109            find_in(&path, "majordomus"),
1110            Some(runnable.join("majordomus"))
1111        );
1112        assert_eq!(find_in(&path, "absent"), None);
1113        assert_eq!(
1114            find_in(&std::env::join_paths([&plain]).unwrap(), "majordomus"),
1115            None
1116        );
1117    }
1118
1119    /// Write `body` as the repository's own `bin/majordomus`, runnable.
1120    fn tool_at(root: &Path, body: &str) -> PathBuf {
1121        use std::os::unix::fs::PermissionsExt;
1122        let tool = root.join("bin/majordomus");
1123        std::fs::create_dir_all(tool.parent().unwrap()).unwrap();
1124        std::fs::write(&tool, body).unwrap();
1125        std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
1126        tool
1127    }
1128
1129    /// The repository's own tool is asked exactly as a provider hook asks it — the
1130    /// repository, `capture session`, the provider and the event as arguments, the payload
1131    /// on stdin — and what the episode reports is the last thing it said.
1132    #[test]
1133    fn the_repository_tool_is_asked_as_a_hook_asks_it_and_its_last_word_is_the_answer() {
1134        let dir = tempfile::tempdir().unwrap();
1135        let root = dir.path();
1136        tool_at(
1137            root,
1138            "#!/bin/sh\nprintf '%s\\n' \"$@\" > args.txt\ncat > stdin.txt\nprintf 'noise\\n\\n  episode e1 recorded  \\n\\n' >&2\n",
1139        );
1140        let driver = ToolDriver::new(root);
1141        assert_eq!(driver.open("generic", "e1"), "episode e1 recorded");
1142        let args = std::fs::read_to_string(root.join("args.txt")).unwrap();
1143        assert_eq!(
1144            args.lines().collect::<Vec<_>>(),
1145            vec![
1146                "--repo",
1147                root.to_str().unwrap(),
1148                "capture",
1149                "session",
1150                "--provider",
1151                "generic",
1152                "--event",
1153                "start"
1154            ]
1155        );
1156        assert_eq!(
1157            std::fs::read_to_string(root.join("stdin.txt")).unwrap(),
1158            "{\"session_id\":\"e1\",\"source\":\"attach\"}\n"
1159        );
1160        assert_eq!(
1161            driver.close("generic", "e1", CloseReason::Expired),
1162            "episode e1 recorded"
1163        );
1164        assert_eq!(
1165            std::fs::read_to_string(root.join("stdin.txt")).unwrap(),
1166            "{\"session_id\":\"e1\",\"reason\":\"expired\"}\n"
1167        );
1168    }
1169
1170    /// Every way the tool can fail to say what it did is said instead: no tool at all, a
1171    /// tool that cannot start, a tool that said nothing, a tool that could not be waited for.
1172    #[test]
1173    fn every_way_the_tool_fails_to_answer_is_named() {
1174        let dir = tempfile::tempdir().unwrap();
1175        let root = dir.path();
1176        let driver = ToolDriver::new(root);
1177
1178        assert_eq!(
1179            driver.run_tool(None, "generic", "start", "{}"),
1180            format!(
1181                "no episode was recorded: neither bin/majordomus nor .majordomus/bin/majordomus is executable under {} and none is on the path",
1182                root.display()
1183            )
1184        );
1185
1186        tool_at(root, "#!/bin/sh\nexit 3\n");
1187        assert_eq!(
1188            driver.close("generic", "e1", CloseReason::Detach),
1189            "`capture session --event end` exited exit status: 3 and said nothing"
1190        );
1191
1192        // a tool that was found and is gone by the time it is started
1193        let gone = root.join("bin/gone");
1194        let refused = driver.run_tool(Some(gone.clone()), "generic", "start", "{}");
1195        assert!(
1196            refused.starts_with(&format!(
1197                "no episode was recorded: {} did not start: ",
1198                gone.display()
1199            )),
1200            "{refused}"
1201        );
1202
1203        assert_eq!(
1204            reported(
1205                "end",
1206                Path::new("/bin/majordomus"),
1207                Err(std::io::Error::other("the pipe broke"))
1208            ),
1209            "no episode was recorded: /bin/majordomus did not finish: the pipe broke"
1210        );
1211    }
1212}