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}