Skip to main content

majordomus_cli/session/
episode.rs

1//! The aggregate: what an episode is, and what it is not.
2//!
3//! An **episode** is a provider's conversation boundary. It begins when a client attaches
4//! and ends when it detaches, whether or not anybody declared a task, and whether or not
5//! the last task anybody declared was finished a week ago (ADR 0052).
6//!
7//! A **task** is a unit of intended work that a person opens and closes. It is *related* to
8//! an episode and it is not part of one. The relation is `Option<TaskId>` and nothing more:
9//! no outcome, no profile, no scope, no obligations. Those are the task aggregate's, and an
10//! episode that carried a copy of them would be a second account of a record that already
11//! exists — the shape of every defect this subsystem has produced.
12//!
13//! The separation is not stylistic. On 2026-09-05 task `t-20260905034523-a9f1` was marked
14//! `handed_over` and never replaced, and for six days this repository wrote no checkpoint
15//! and no handover while `doctor` and `watch` reported health. Three code paths each asked
16//! a task whether an episode's artefact should be written, and each refusal was correct
17//! against its own contract. In this model there is no such question to ask: nothing on
18//! [`Episode`] can gate a transition on a task, because a transition is
19//! [`super::EpisodeState::may_move_to`] and it takes no task.
20//!
21//! ```
22//! use majordomus_cli::session::{Episode, EpisodeId, EpisodeState, TaskId};
23//! use std::path::Path;
24//!
25//! let id = EpisodeId::parse("s-20260910205542-e2a6").expect("an episode id");
26//! let episode = Episode::opening(id, Path::new("/r"))
27//!     .opened_at("2026-09-10T20:55:42Z", "master", "9f3a2a0", 1_789_073_742)
28//!     .with_task(TaskId::parse("t-20260905034523-a9f1").expect("the task from the audit"));
29//!
30//! // the task is `handed_over` in the incident, and the episode still closes
31//! assert!(episode.may_move_to(EpisodeState::Closed));
32//! assert!(Episode::opening(episode.id.clone(), Path::new("/r")).task.is_none());
33//! ```
34
35use schemars::JsonSchema;
36use serde::{Deserialize, Serialize};
37
38use super::identity::{CheckoutId, EpisodeId, ProviderSessionId, RepositoryId, TaskId, WorkerId};
39use super::state::EpisodeState;
40
41/// One execution episode: a provider's conversation boundary, with the identities that say
42/// whose and where it is, and at most a relation to a task.
43///
44/// ```
45/// use majordomus_cli::session::{Episode, EpisodeId, EpisodeState, ProviderSessionId, TaskId};
46/// use std::path::Path;
47///
48/// let id = EpisodeId::parse("s-20260910205542-e2a6").unwrap();
49/// let e = Episode::opening(id, Path::new("/r"))
50///     .with_provider("claude-code", ProviderSessionId::new("01Bv2gJsf"));
51///
52/// assert_eq!(e.state, EpisodeState::Opening);
53/// assert!(e.task.is_none(), "an episode without a task is ordinary");
54///
55/// // a task is a relation the episode carries, never a thing that gates it
56/// let e = e.with_task(TaskId::parse("t-20260905034523-a9f1").unwrap());
57/// assert!(e.task.is_some());
58/// assert!(e.state.may_move_to(EpisodeState::Open), "whatever the task says");
59/// ```
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
61pub struct Episode {
62    /// The episode's own identity, and the only session identity there is.
63    pub id: EpisodeId,
64    /// Where it is in its life.
65    pub state: EpisodeState,
66    /// The repository, as this executable computes it. `None` where git cannot be asked.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub repository: Option<RepositoryId>,
69    /// The checkout it was opened in. Two worktrees of one repository differ here, and
70    /// that difference is the first tier of every record resolution in the tool.
71    pub checkout: CheckoutId,
72    /// The provider that opened it, when a provider did. Only something running inside a
73    /// provider's own hook can name it, which is what makes it a fact rather than a claim.
74    #[serde(default, skip_serializing_if = "String::is_empty")]
75    pub provider: String,
76    /// The provider's own name for its conversation: an **external correlation id**, used
77    /// to find this episode again and never to identify it. `None` for an episode opened
78    /// by hand, which is the one episode no provider session owns.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub provider_session: Option<ProviderSessionId>,
81    /// The worker, when one was supplied. Never inferred.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub worker: Option<WorkerId>,
84    /// The task this episode relates to, when there is one. The whole of the relation.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub task: Option<TaskId>,
87    /// When it opened, RFC 3339 in UTC. Empty for an episode that is still `Opening`.
88    #[serde(default, skip_serializing_if = "String::is_empty")]
89    pub started_at: String,
90    /// The branch it opened on.
91    #[serde(default, skip_serializing_if = "String::is_empty")]
92    pub branch: String,
93    /// The commit it opened at.
94    #[serde(default, skip_serializing_if = "String::is_empty")]
95    pub start_head: String,
96    /// The newest sign of life: the later of `started_at` and the newest ledger line this
97    /// episode stamped, as Unix seconds. `None` when neither can be read as a timestamp,
98    /// which is the one case [`Episode::stranded_after`] refuses to judge.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub last_sign_of_life: Option<i64>,
101}
102
103impl Episode {
104    /// A newly minted episode, before anything durable has been written. It exists as a
105    /// state so that a crash between minting an id and writing the open record is something
106    /// the model can name rather than an episode that never happened.
107    ///
108    /// ```
109    /// use majordomus_cli::session::{Episode, EpisodeId, EpisodeState};
110    /// use std::path::Path;
111    ///
112    /// let id = EpisodeId::parse("s-20260910205542-e2a6").expect("an episode id");
113    /// let e = Episode::opening(id, Path::new("/r"));
114    /// assert_eq!(e.state, EpisodeState::Opening);
115    /// assert!(e.started_at.is_empty(), "nothing durable has been written yet");
116    /// ```
117    pub fn opening(id: EpisodeId, root: &std::path::Path) -> Self {
118        Episode {
119            id,
120            state: EpisodeState::Opening,
121            repository: RepositoryId::of(root),
122            checkout: CheckoutId::of(root),
123            provider: String::new(),
124            provider_session: None,
125            worker: None,
126            task: None,
127            started_at: String::new(),
128            branch: String::new(),
129            start_head: String::new(),
130            last_sign_of_life: None,
131        }
132    }
133
134    /// Name the provider and its own session identity — the external correlation id a
135    /// hook passes back to find this episode again, and never the episode's own name.
136    ///
137    /// ```
138    /// use majordomus_cli::session::{Episode, EpisodeId, ProviderSessionId};
139    /// use std::path::Path;
140    ///
141    /// let id = EpisodeId::parse("s-20260910205542-e2a6").expect("an episode id");
142    /// let e = Episode::opening(id, Path::new("/r"))
143    ///     .with_provider("claude-code", ProviderSessionId::new("01Bv2gJsf"));
144    /// assert_eq!(e.provider, "claude-code");
145    /// assert_ne!(e.provider_session.as_ref().map(|p| p.as_str()), Some(e.id.as_str()));
146    /// ```
147    pub fn with_provider(
148        mut self,
149        provider: impl Into<String>,
150        session: ProviderSessionId,
151    ) -> Self {
152        self.provider = provider.into();
153        self.provider_session = Some(session);
154        self
155    }
156
157    /// Relate the episode to a task. The whole of the relation: an episode carries a task's
158    /// identity and nothing else of it, so nothing here can gate a transition on one.
159    ///
160    /// ```
161    /// use majordomus_cli::session::{Episode, EpisodeId, EpisodeState, TaskId};
162    /// use std::path::Path;
163    ///
164    /// let id = EpisodeId::parse("s-20260910205542-e2a6").expect("an episode id");
165    /// let e = Episode::opening(id, Path::new("/r"))
166    ///     .with_task(TaskId::parse("t-20260905034523-a9f1").expect("a task id"));
167    /// assert!(e.task.is_some());
168    /// assert!(e.state.may_move_to(EpisodeState::Open), "whatever the task says");
169    /// ```
170    pub fn with_task(mut self, task: TaskId) -> Self {
171        self.task = Some(task);
172        self
173    }
174
175    /// Record the worker, when one was supplied. Absent stays absent: an inferred worker
176    /// is indistinguishable from a recorded one the moment it is written down.
177    ///
178    /// ```
179    /// use majordomus_cli::session::{Episode, EpisodeId, WorkerId};
180    /// use std::path::Path;
181    ///
182    /// let id = EpisodeId::parse("s-20260910205542-e2a6").expect("an episode id");
183    /// let e = Episode::opening(id, Path::new("/r")).with_worker(WorkerId::supplied(""));
184    /// assert!(e.worker.is_none());
185    /// ```
186    pub fn with_worker(mut self, worker: Option<WorkerId>) -> Self {
187        self.worker = worker;
188        self
189    }
190
191    /// Record where and when it opened, and move it to [`EpisodeState::Open`].
192    ///
193    /// ```
194    /// use majordomus_cli::session::{Episode, EpisodeId, EpisodeState};
195    /// use std::path::Path;
196    ///
197    /// let e = Episode::opening(EpisodeId::parse("s-20260910205542-e2a6").unwrap(), Path::new("/r"))
198    ///     .opened_at("2026-09-10T20:55:42Z", "master", "9f3a2a0", 1_789_073_742);
199    /// assert_eq!(e.state, EpisodeState::Open);
200    /// assert_eq!(e.last_sign_of_life, Some(1_789_073_742));
201    /// ```
202    pub fn opened_at(
203        mut self,
204        started_at: impl Into<String>,
205        branch: impl Into<String>,
206        head: impl Into<String>,
207        at: i64,
208    ) -> Self {
209        debug_assert!(self.state.may_move_to(EpisodeState::Open));
210        self.started_at = started_at.into();
211        self.branch = branch.into();
212        self.start_head = head.into();
213        self.last_sign_of_life = Some(at);
214        self.state = EpisodeState::Open;
215        self
216    }
217
218    /// Is this episode's last sign of life older than `threshold_seconds` at `now`?
219    ///
220    /// The one predicate the state machine needs from the time dimension, and deliberately
221    /// the only one. The vocabulary of age — `fresh | aging | stale | unknown | invalid`
222    /// and `Thresholds::judge` — belongs to `continuity` (ADR 0052) and is not restated
223    /// here; this domain consumes seconds and answers a boolean.
224    ///
225    /// `None` means *unjudgeable*, not *fine*. An episode whose evidence cannot be read as
226    /// a timestamp is skipped and counted by a recovery sweep, never swept: on 2026-09-10 a
227    /// first draft of the shell sweep would have deleted a staging directory belonging to a
228    /// `scripts/derive` that was running, because "the run that made it did not finish" is
229    /// a claim and not a measurement until something reads the clock.
230    ///
231    /// ```
232    /// use majordomus_cli::session::{Episode, EpisodeId};
233    /// use std::path::Path;
234    ///
235    /// let id = EpisodeId::parse("s-20260910205542-e2a6").unwrap();
236    /// let live = Episode::opening(id.clone(), Path::new("/r"))
237    ///     .opened_at("2026-09-10T20:55:42Z", "master", "9f3a2a0", 1_000);
238    /// assert_eq!(live.stranded_after(1_600, 3_600), Some(false), "ten minutes is not stranded");
239    /// assert_eq!(live.stranded_after(5_000, 3_600), Some(true), "an hour later it is");
240    ///
241    /// let unjudgeable = Episode::opening(id, Path::new("/r"));
242    /// assert_eq!(unjudgeable.stranded_after(5_000, 3_600), None, "no evidence is not `fine`");
243    /// ```
244    pub fn stranded_after(&self, now: i64, threshold_seconds: i64) -> Option<bool> {
245        let last = self.last_sign_of_life?;
246        Some(now.saturating_sub(last) > threshold_seconds)
247    }
248
249    /// The transition this episode may take to reach `target`, or `None` when the machine
250    /// forbids the move. The store asks this before it writes; a caller that wants to know
251    /// without writing asks the same question and gets the same answer.
252    ///
253    /// ```
254    /// use majordomus_cli::session::{Episode, EpisodeId, EpisodeState};
255    /// use std::path::Path;
256    ///
257    /// let mut e = Episode::opening(EpisodeId::parse("s-20260910205542-e2a6").unwrap(), Path::new("/r"))
258    ///     .opened_at("2026-09-10T20:55:42Z", "master", "9f3a2a0", 1_000);
259    /// assert!(e.may_move_to(EpisodeState::Closed));
260    /// e.state = EpisodeState::Closed;
261    /// assert!(!e.may_move_to(EpisodeState::Closed), "closing twice is not a move");
262    /// ```
263    pub fn may_move_to(&self, target: EpisodeState) -> bool {
264        self.state.may_move_to(target)
265    }
266}
267
268impl crate::order::Ordered for Episode {
269    fn order_key(&self) -> crate::order::OrderKey<'_> {
270        crate::order::OrderKey {
271            group: Some(self.state.as_str()),
272            rank: 0,
273            label: self.id.as_str(),
274            identity: self.id.as_str(),
275        }
276    }
277}
278
279/// How an episode ended, as the record states it.
280///
281/// ```
282/// use majordomus_cli::session::Outcome;
283/// // the one thing about an ended session that changes what somebody does next
284/// assert_eq!(Outcome::Interrupted.as_str(), "interrupted");
285/// assert_ne!(Outcome::Closed, Outcome::Interrupted);
286/// ```
287///
288/// Two values, both self-reported and neither verified — the record says so. `closed` is a
289/// worker ending an episode deliberately; `interrupted` tells the next reader that the
290/// episode was cut short and its records may be incomplete, which is the one thing about an
291/// ended session that changes what somebody does next. A recovery sweep always writes
292/// `interrupted`, because an episode nobody ended is by definition one nobody finished.
293#[derive(
294    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
295)]
296#[serde(rename_all = "snake_case")]
297pub enum Outcome {
298    /// A worker ended the episode.
299    Closed,
300    /// The episode was cut short: a crash, a killed process, or a recovery sweep.
301    Interrupted,
302}
303
304impl Outcome {
305    /// The word as serialised, which is the word the record carries and the word a
306    /// resuming worker reads to learn whether the episode was finished or cut short.
307    ///
308    /// ```
309    /// use majordomus_cli::session::Outcome;
310    /// assert_eq!(Outcome::Closed.as_str(), "closed");
311    /// assert_eq!(Outcome::Interrupted.as_str(), "interrupted");
312    /// ```
313    pub fn as_str(self) -> &'static str {
314        match self {
315            Outcome::Closed => "closed",
316            Outcome::Interrupted => "interrupted",
317        }
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use std::path::Path;
325
326    fn episode() -> Episode {
327        Episode::opening(
328            EpisodeId::parse("s-20260910205542-e2a6").expect("an id"),
329            Path::new("/r"),
330        )
331        .opened_at("2026-09-10T20:55:42Z", "master", "9f3a2a0", 1_000)
332    }
333
334    #[test]
335    fn a_tasks_outcome_is_not_reachable_from_an_episodes_transition() {
336        // The structural assertion of ADR 0052. `Episode` carries at most a TaskId: there
337        // is no outcome on it, so no expression of the form `if outcome != active` can be
338        // written against an episode without adding a field this test would fail on.
339        let e = episode().with_task(TaskId::parse("t-20260905034523-a9f1").expect("a task"));
340        assert!(e.may_move_to(EpisodeState::Closed));
341        let json = serde_json::to_value(&e).expect("serialisable");
342        let obj = json.as_object().expect("an object");
343        assert_eq!(
344            obj.get("task").and_then(|v| v.as_str()),
345            Some("t-20260905034523-a9f1")
346        );
347        for forbidden in ["outcome", "profile", "scope", "requires", "task_outcome"] {
348            assert!(
349                !obj.contains_key(forbidden),
350                "an episode carries `{forbidden}`; that is the task aggregate's"
351            );
352        }
353    }
354
355    #[test]
356    fn an_episode_with_no_task_is_ordinary_and_still_closes() {
357        let e = episode();
358        assert!(e.task.is_none());
359        assert!(e.may_move_to(EpisodeState::Closed));
360    }
361
362    #[test]
363    fn an_unjudgeable_episode_is_never_stranded_and_never_fine() {
364        let e = Episode::opening(
365            EpisodeId::parse("s-20260910205542-e2a6").expect("an id"),
366            Path::new("/r"),
367        );
368        assert_eq!(e.stranded_after(i64::MAX / 2, 1), None);
369    }
370
371    #[test]
372    fn the_threshold_is_exclusive_at_its_own_boundary() {
373        let e = episode();
374        assert_eq!(e.stranded_after(1_000 + 3_600, 3_600), Some(false));
375        assert_eq!(e.stranded_after(1_001 + 3_600, 3_600), Some(true));
376    }
377}