pub struct Episode {
pub id: EpisodeId,
pub state: EpisodeState,
pub repository: Option<RepositoryId>,
pub checkout: CheckoutId,
pub provider: String,
pub provider_session: Option<ProviderSessionId>,
pub worker: Option<WorkerId>,
pub task: Option<TaskId>,
pub started_at: String,
pub branch: String,
pub start_head: String,
pub last_sign_of_life: Option<i64>,
}Expand description
One execution episode: a provider’s conversation boundary, with the identities that say whose and where it is, and at most a relation to a task.
use majordomus_cli::session::{Episode, EpisodeId, EpisodeState, ProviderSessionId, TaskId};
use std::path::Path;
let id = EpisodeId::parse("s-20260910205542-e2a6").unwrap();
let e = Episode::opening(id, Path::new("/r"))
.with_provider("claude-code", ProviderSessionId::new("01Bv2gJsf"));
assert_eq!(e.state, EpisodeState::Opening);
assert!(e.task.is_none(), "an episode without a task is ordinary");
// a task is a relation the episode carries, never a thing that gates it
let e = e.with_task(TaskId::parse("t-20260905034523-a9f1").unwrap());
assert!(e.task.is_some());
assert!(e.state.may_move_to(EpisodeState::Open), "whatever the task says");Fields§
§id: EpisodeIdThe episode’s own identity, and the only session identity there is.
state: EpisodeStateWhere it is in its life.
repository: Option<RepositoryId>The repository, as this executable computes it. None where git cannot be asked.
checkout: CheckoutIdThe checkout it was opened in. Two worktrees of one repository differ here, and that difference is the first tier of every record resolution in the tool.
provider: StringThe provider that opened it, when a provider did. Only something running inside a provider’s own hook can name it, which is what makes it a fact rather than a claim.
provider_session: Option<ProviderSessionId>The provider’s own name for its conversation: an external correlation id, used
to find this episode again and never to identify it. None for an episode opened
by hand, which is the one episode no provider session owns.
worker: Option<WorkerId>The worker, when one was supplied. Never inferred.
task: Option<TaskId>The task this episode relates to, when there is one. The whole of the relation.
started_at: StringWhen it opened, RFC 3339 in UTC. Empty for an episode that is still Opening.
branch: StringThe branch it opened on.
start_head: StringThe commit it opened at.
last_sign_of_life: Option<i64>The newest sign of life: the later of started_at and the newest ledger line this
episode stamped, as Unix seconds. None when neither can be read as a timestamp,
which is the one case Episode::stranded_after refuses to judge.
Implementations§
Source§impl Episode
impl Episode
Sourcepub fn opening(id: EpisodeId, root: &Path) -> Self
pub fn opening(id: EpisodeId, root: &Path) -> Self
A newly minted episode, before anything durable has been written. It exists as a state so that a crash between minting an id and writing the open record is something the model can name rather than an episode that never happened.
use majordomus_cli::session::{Episode, EpisodeId, EpisodeState};
use std::path::Path;
let id = EpisodeId::parse("s-20260910205542-e2a6").expect("an episode id");
let e = Episode::opening(id, Path::new("/r"));
assert_eq!(e.state, EpisodeState::Opening);
assert!(e.started_at.is_empty(), "nothing durable has been written yet");Sourcepub fn with_provider(
self,
provider: impl Into<String>,
session: ProviderSessionId,
) -> Self
pub fn with_provider( self, provider: impl Into<String>, session: ProviderSessionId, ) -> Self
Name the provider and its own session identity — the external correlation id a hook passes back to find this episode again, and never the episode’s own name.
use majordomus_cli::session::{Episode, EpisodeId, ProviderSessionId};
use std::path::Path;
let id = EpisodeId::parse("s-20260910205542-e2a6").expect("an episode id");
let e = Episode::opening(id, Path::new("/r"))
.with_provider("claude-code", ProviderSessionId::new("01Bv2gJsf"));
assert_eq!(e.provider, "claude-code");
assert_ne!(e.provider_session.as_ref().map(|p| p.as_str()), Some(e.id.as_str()));Sourcepub fn with_task(self, task: TaskId) -> Self
pub fn with_task(self, task: TaskId) -> Self
Relate the episode to a task. The whole of the relation: an episode carries a task’s identity and nothing else of it, so nothing here can gate a transition on one.
use majordomus_cli::session::{Episode, EpisodeId, EpisodeState, TaskId};
use std::path::Path;
let id = EpisodeId::parse("s-20260910205542-e2a6").expect("an episode id");
let e = Episode::opening(id, Path::new("/r"))
.with_task(TaskId::parse("t-20260905034523-a9f1").expect("a task id"));
assert!(e.task.is_some());
assert!(e.state.may_move_to(EpisodeState::Open), "whatever the task says");Sourcepub fn with_worker(self, worker: Option<WorkerId>) -> Self
pub fn with_worker(self, worker: Option<WorkerId>) -> Self
Record the worker, when one was supplied. Absent stays absent: an inferred worker is indistinguishable from a recorded one the moment it is written down.
use majordomus_cli::session::{Episode, EpisodeId, WorkerId};
use std::path::Path;
let id = EpisodeId::parse("s-20260910205542-e2a6").expect("an episode id");
let e = Episode::opening(id, Path::new("/r")).with_worker(WorkerId::supplied(""));
assert!(e.worker.is_none());Sourcepub fn opened_at(
self,
started_at: impl Into<String>,
branch: impl Into<String>,
head: impl Into<String>,
at: i64,
) -> Self
pub fn opened_at( self, started_at: impl Into<String>, branch: impl Into<String>, head: impl Into<String>, at: i64, ) -> Self
Record where and when it opened, and move it to EpisodeState::Open.
use majordomus_cli::session::{Episode, EpisodeId, EpisodeState};
use std::path::Path;
let e = Episode::opening(EpisodeId::parse("s-20260910205542-e2a6").unwrap(), Path::new("/r"))
.opened_at("2026-09-10T20:55:42Z", "master", "9f3a2a0", 1_789_073_742);
assert_eq!(e.state, EpisodeState::Open);
assert_eq!(e.last_sign_of_life, Some(1_789_073_742));Sourcepub fn stranded_after(&self, now: i64, threshold_seconds: i64) -> Option<bool>
pub fn stranded_after(&self, now: i64, threshold_seconds: i64) -> Option<bool>
Is this episode’s last sign of life older than threshold_seconds at now?
The one predicate the state machine needs from the time dimension, and deliberately
the only one. The vocabulary of age — fresh | aging | stale | unknown | invalid
and Thresholds::judge — belongs to continuity (ADR 0052) and is not restated
here; this domain consumes seconds and answers a boolean.
None means unjudgeable, not fine. An episode whose evidence cannot be read as
a timestamp is skipped and counted by a recovery sweep, never swept: on 2026-09-10 a
first draft of the shell sweep would have deleted a staging directory belonging to a
scripts/derive that was running, because “the run that made it did not finish” is
a claim and not a measurement until something reads the clock.
use majordomus_cli::session::{Episode, EpisodeId};
use std::path::Path;
let id = EpisodeId::parse("s-20260910205542-e2a6").unwrap();
let live = Episode::opening(id.clone(), Path::new("/r"))
.opened_at("2026-09-10T20:55:42Z", "master", "9f3a2a0", 1_000);
assert_eq!(live.stranded_after(1_600, 3_600), Some(false), "ten minutes is not stranded");
assert_eq!(live.stranded_after(5_000, 3_600), Some(true), "an hour later it is");
let unjudgeable = Episode::opening(id, Path::new("/r"));
assert_eq!(unjudgeable.stranded_after(5_000, 3_600), None, "no evidence is not `fine`");Sourcepub fn may_move_to(&self, target: EpisodeState) -> bool
pub fn may_move_to(&self, target: EpisodeState) -> bool
The transition this episode may take to reach target, or None when the machine
forbids the move. The store asks this before it writes; a caller that wants to know
without writing asks the same question and gets the same answer.
use majordomus_cli::session::{Episode, EpisodeId, EpisodeState};
use std::path::Path;
let mut e = Episode::opening(EpisodeId::parse("s-20260910205542-e2a6").unwrap(), Path::new("/r"))
.opened_at("2026-09-10T20:55:42Z", "master", "9f3a2a0", 1_000);
assert!(e.may_move_to(EpisodeState::Closed));
e.state = EpisodeState::Closed;
assert!(!e.may_move_to(EpisodeState::Closed), "closing twice is not a move");Trait Implementations§
Source§impl<'de> Deserialize<'de> for Episode
impl<'de> Deserialize<'de> for Episode
Source§fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where
__D: Deserializer<'de>,
fn deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where
__D: Deserializer<'de>,
impl Eq for Episode
Source§impl JsonSchema for Episode
impl JsonSchema for Episode
Source§fn schema_id() -> Cow<'static, str>
fn schema_id() -> Cow<'static, str>
Source§fn json_schema(generator: &mut SchemaGenerator) -> Schema
fn json_schema(generator: &mut SchemaGenerator) -> Schema
Source§fn inline_schema() -> bool
fn inline_schema() -> bool
$ref keyword. Read moreimpl StructuralPartialEq for Episode
Auto Trait Implementations§
impl Freeze for Episode
impl RefUnwindSafe for Episode
impl Send for Episode
impl Sync for Episode
impl Unpin for Episode
impl UnsafeUnpin for Episode
impl UnwindSafe for Episode
Blanket Implementations§
Source§impl<T> BorrowMut<T> for Twhere
T: ?Sized,
impl<T> BorrowMut<T> for Twhere
T: ?Sized,
Source§fn borrow_mut(&mut self) -> &mut T
fn borrow_mut(&mut self) -> &mut T
impl<ST, DT> CastableFrom<ST, Initialized, Initialized> for DT
impl<ST, DT> CastableFrom<ST, Uninit, Uninit> for DT
Source§impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> CloneToUninit for Twhere
T: Clone,
impl<T> DeserializeOwned for Twhere
T: for<'de> Deserialize<'de>,
§impl<Q, K> Equivalent<K> for Q
impl<Q, K> Equivalent<K> for Q
§fn equivalent(&self, key: &K) -> bool
fn equivalent(&self, key: &K) -> bool
key and return true if they are equal.