Skip to main content

hotl_engine/
lib.rs

1//! L3 — the turn engine, M1: actor + turn tasks (commit-protocol.md).
2//!
3//! One **session actor** per session is the sole committer to the log and the
4//! owner of the projection ([`actor`]); **turn tasks** read actor-granted
5//! snapshots at sample boundaries and *propose* entries ([`turn`]). Steers
6//! admitted mid-turn are woven into the next sample (the conflict table's
7//! rebase row); interrupts travel out-of-band via a shared token; permission
8//! asks are events carrying a oneshot reply.
9
10mod actor;
11pub mod hooks;
12mod ledger;
13mod turn;
14
15use std::path::PathBuf;
16use std::sync::{Arc, Mutex};
17use std::time::Duration;
18
19use hotl_platform::Clock;
20use hotl_provider::{CacheTtl, Provider};
21use hotl_store::SessionLog;
22use hotl_tools::{
23    rules::{PermissionMode, Rules},
24    Registry,
25};
26use hotl_types::{EntryPayload, Item, Todo, TokenUsage};
27use tokio::sync::{mpsc, oneshot};
28use tokio_util::sync::CancellationToken;
29
30/// Re-exported so `hotl_engine::QuestionAnswer` resolves alongside
31/// `EngineEvent::Question` — the type physically lives in hotl-types (shared
32/// with hotl-tools's `QuestionSink`) to avoid a hotl-tools → hotl-engine
33/// dependency cycle; see `question_sink`'s doc comment.
34pub use hotl_types::QuestionAnswer;
35
36/// Re-exported so `hotl_engine::NotificationKind` names the `Notification`
37/// hook's kind without reaching into the `hooks` module — the type is
38/// defined in `hooks.rs` (next to the trait method it parameterizes), not
39/// here, to keep the event vocabulary and its dispatcher together.
40pub use hooks::NotificationKind;
41
42/// Re-exported so `hotl_engine::LedgerSummary` resolves alongside
43/// `EngineEvent::LedgerReport` — the loop-overhead instrument (§S1) lives in
44/// its own module ([`ledger`]) since it is self-contained (no dependency on
45/// the rest of the engine's types) and independently unit-tested.
46pub use ledger::{LedgerSummary, Phase, PhaseDeltaSummary};
47
48/// Re-exported so `hotl_engine::ProjectionHead` names what
49/// [`SessionHandle::head`] hands out — the epoch-fenced published projection
50/// (commit-protocol.md §Read invariant). It lives in [`actor`], next to the
51/// only thing that may publish it.
52pub use actor::ProjectionHead;
53
54/// Re-exported alongside [`ProjectionHead`]: what a read of it yields, split
55/// into the durable projection and the ephemeral per-sample tail. Out-of-crate
56/// readers (`fork`'s history seed) name it to say which half they take.
57pub use actor::Snapshot;
58
59#[derive(Debug, Clone)]
60pub struct EngineConfig {
61    pub model: String,
62    pub max_tokens: u32,
63    /// Model samples one prompt may spend before the turn is cut short with
64    /// [`Outcome::TurnLimit`]. Every tool round-trip costs one, so this is a
65    /// step budget for the agent loop, not a count of conversational turns.
66    ///
67    /// **Negative = unlimited**: the turn runs until the model stops on its
68    /// own, the context fills, a tool budget trips, or the user cancels. That
69    /// is a deliberate opt-in — the bound is what keeps an unattended
70    /// (`Auto`/`DontAsk`) run from looping on the owner's money, so removing
71    /// it should be a choice, never a default.
72    pub max_turns: i64,
73    pub thinking: bool,
74    pub cache_static: bool,
75    /// The lifetime `compose_request` asks explicit-cache breakpoints for
76    /// when `cache_static` is set (`CachePolicy::Static { prefix_ttl }` —
77    /// consumed by the Anthropic serializer's prefix and rolling-anchor
78    /// markers; the latest marker always renders plain regardless). Default
79    /// `FiveMinutes`; long-lived human-supervised surfaces (`hotl tui`,
80    /// `hotl acp`, `hotl bg`/attach) raise it to `OneHour` after `scaffold()`
81    /// returns, and sub-agent children pin it back to `FiveMinutes`
82    /// explicitly in `HotlChildBuilder::spawn_child`.
83    pub cache_ttl: CacheTtl,
84    /// Availability-only fallback models (≤3 total — RELIABILITY.md).
85    pub fallback_models: Vec<String>,
86    /// Consecutive failures of one tool before the turn stops.
87    pub tool_failure_budget: u32,
88    /// Model context window in tokens; compaction triggers at 80% (M2).
89    pub context_window: u64,
90    /// Housekeeping model (compaction summarize); defaults to `model`.
91    pub fast_model: Option<String>,
92    /// Reset-mode compaction (M4/#9): the continuation gets the preserved
93    /// prefix + digest only, no verbatim tail — a fresh slate rather than a
94    /// summarized-then-refilling window. Default false = M2 in-place behavior.
95    pub compaction_reset: bool,
96    /// Include `context_used%` in the MOIM turn-context block (M4/#9).
97    /// Default true = M2 behavior; false to avoid inducing context anxiety.
98    pub show_context_pct: bool,
99    /// Evict a successful tool result larger than this (estimated tokens) to a
100    /// masked blob, leaving a head preview + read pointer (T4). `0` disables.
101    pub evict_threshold_tokens: u64,
102    /// Which [`AckMode`] turn-originated proposals use where the protocol
103    /// allows pipelining (commit-protocol.md §Pipelined commits). Production
104    /// is `Pipelined`; `Sync` exists so a golden scenario can drive the same
105    /// session both ways and compare normalized transcripts — the revision's
106    /// own counter assertion. Same discipline as
107    /// `hotl_store::SessionLog::set_sync_noop`: a runtime seam, hidden from
108    /// the public API, never a cargo feature.
109    #[doc(hidden)]
110    pub ack_mode: AckMode,
111}
112
113impl Default for EngineConfig {
114    fn default() -> Self {
115        Self {
116            model: "claude-opus-4-8".into(),
117            max_tokens: 32_000,
118            // Roomy enough that ordinary agentic work (long edit/test/fix
119            // chains) finishes inside it — the cap is a runaway backstop, not
120            // a work ceiling. Sub-agent call sites set their own, tighter.
121            max_turns: 100,
122            thinking: true,
123            cache_static: true,
124            cache_ttl: CacheTtl::FiveMinutes,
125            fallback_models: Vec::new(),
126            tool_failure_budget: 5,
127            context_window: 200_000,
128            fast_model: None,
129            compaction_reset: false,
130            show_context_pct: true,
131            evict_threshold_tokens: 20_000,
132            ack_mode: AckMode::Pipelined,
133        }
134    }
135}
136
137/// How a turn task ended: with a user-facing outcome, or asking the actor
138/// to compact and respawn a continuation (M2 mid-turn = terminate → compact
139/// → respawn, per commit-protocol).
140#[derive(Debug)]
141pub enum TurnEnd {
142    Outcome(Outcome),
143    /// Compact, folding with the speculative digest when the turn managed to
144    /// precompute one — `None` falls back to the inline summarize. `cont`
145    /// carries the per-turn counters the respawn must not reset (boxed so the
146    /// variant stays small).
147    Compact {
148        spec: Option<SpecDigest>,
149        cont: Box<TurnContinuation>,
150    },
151}
152
153/// The per-turn state a compaction respawn must NOT reset (T2-2). A fold is
154/// "no new user item, same logical turn" — so every counter that bounds that
155/// turn has to cross it. Reconstructing a `Turn` from `Default` here is what let
156/// `max_turns` be defeated by the very scenario it exists for.
157/// INVARIANT: every per-turn safety counter survives a compaction respawn.
158/// Enforced by `max_turns_is_enforced_across_a_compaction` and
159/// `three_folds_with_progress_do_not_exhaust_the_streak`.
160#[derive(Debug, Default)]
161pub struct TurnContinuation {
162    /// Steps already spent against [`EngineConfig::max_turns`].
163    pub(crate) spent: i64,
164    /// Fallback-model position: a continuation does not silently revert to the
165    /// primary model that just failed.
166    pub(crate) model_idx: usize,
167    /// The doom detector's trailing signature window.
168    pub(crate) call_sigs: std::collections::VecDeque<crate::turn::CallSig>,
169    /// Per-tool consecutive failures (the tool-failure budget).
170    pub(crate) consecutive_failures: std::collections::HashMap<String, u32>,
171    /// The shared per-prompt "reminder and continue" budget.
172    pub(crate) turn_extensions: u32,
173    /// Completed samples since the last fold — the compaction streak's
174    /// "intervening completed sample" (T2-3). Read by `actor::try_compact`;
175    /// a fresh continuation restarts the count at zero.
176    pub(crate) samples_since_compact: u32,
177}
178
179/// A compaction digest computed speculatively *during* the turn, overlapping
180/// the summarize call with the turn's own samples. Indices refer to the
181/// projection the digest was planned against; the projection only appends
182/// between folds, so they stay valid until the fold that consumes them.
183#[derive(Debug)]
184pub struct SpecDigest {
185    pub prefix_end: usize,
186    pub kept_from: usize,
187    pub text: String,
188}
189
190/// A human's answer to a permission ask. Widened from a
191/// bare `bool` so a denial can carry the reason to the model as tool-result
192/// feedback — a steer fused with a "no". §2b (M4) extends this with
193/// `AllowEdited`/`Respond`; callers should treat it as non-exhaustive.
194#[derive(Debug, Clone, PartialEq)]
195pub enum AskReply {
196    Allow,
197    Deny {
198        message: Option<String>,
199    },
200    /// The human approved but rewrote the tool input (§2b).
201    AllowEdited {
202        input: serde_json::Value,
203    },
204    /// The human answered *as* the tool — skip execution, use this as the
205    /// tool result (§2b).
206    Respond {
207        content: String,
208    },
209}
210
211#[derive(Debug, Clone, PartialEq)]
212pub enum Outcome {
213    Done { text: String },
214    Cancelled,
215    TurnLimit,
216    Refused,
217    DoomLoop { pattern: String },
218    ToolFailureBudget { tool: String },
219    Error { message: String },
220}
221
222/// Everything the surface renders. `Ask` carries the reply channel — the
223/// surface (or an allow-rule upstream) is the human on the loop.
224pub enum EngineEvent {
225    TextDelta(String),
226    ThinkingDelta(String),
227    ToolStart {
228        name: String,
229        summary: String,
230    },
231    ToolDone {
232        name: String,
233        ok: bool,
234    },
235    ToolDenied {
236        name: String,
237    },
238    ToolAutoAllowed {
239        name: String,
240        rule: String,
241    },
242    Retrying {
243        attempt: u32,
244        reason: String,
245    },
246    FallbackModel {
247        model: String,
248    },
249    PromptQueued,
250    /// Context was compacted (digest + verbatim tail); `degraded` means the
251    /// summarize call failed and the floor placeholder was used.
252    Compacted {
253        degraded: bool,
254    },
255    Ask {
256        summary: String,
257        protected_why: Option<String>,
258        reply: oneshot::Sender<AskReply>,
259    },
260    /// A structured `ask_user` question (tier-1 gap #4) — NOT a permission
261    /// gate: the reply is a plain-text tool result, never an authorization.
262    /// Committed durably (`PendingQuestion`) before this event is sent; a
263    /// dropped `reply` (headless/no-human) resolves to `QuestionAnswer::NoHuman`.
264    Question {
265        id: String,
266        question: hotl_types::Question,
267        reply: oneshot::Sender<hotl_types::QuestionAnswer>,
268    },
269    TurnDone {
270        outcome: Outcome,
271        usage: TokenUsage,
272    },
273    /// The `todo_write` checklist changed (a full-state replace committed).
274    /// Ephemeral-context companion to the durable `Todos` entry: the surface
275    /// (console strip, `hotl watch`) renders progress from this, never from
276    /// parsing model text.
277    TodosChanged {
278        items: Vec<Todo>,
279    },
280    /// Loop-overhead instrument (§S1), flushed once when the turn task ends.
281    /// UI/telemetry only — this NEVER becomes a session-log entry, so it
282    /// cannot perturb golden-transcript normalization.
283    LedgerReport(LedgerSummary),
284}
285
286impl std::fmt::Debug for EngineEvent {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        match self {
289            Self::TextDelta(t) => write!(f, "TextDelta({t:?})"),
290            Self::ThinkingDelta(_) => write!(f, "ThinkingDelta"),
291            Self::ToolStart { name, .. } => write!(f, "ToolStart({name})"),
292            Self::ToolDone { name, ok } => write!(f, "ToolDone({name},{ok})"),
293            Self::ToolDenied { name } => write!(f, "ToolDenied({name})"),
294            Self::ToolAutoAllowed { name, rule } => write!(f, "ToolAutoAllowed({name},{rule})"),
295            Self::Retrying { attempt, .. } => write!(f, "Retrying({attempt})"),
296            Self::FallbackModel { model } => write!(f, "FallbackModel({model})"),
297            Self::PromptQueued => write!(f, "PromptQueued"),
298            Self::Compacted { degraded } => write!(f, "Compacted({degraded})"),
299            Self::Ask { summary, .. } => write!(f, "Ask({summary})"),
300            Self::Question { question, .. } => write!(f, "Question({})", question.header),
301            Self::TurnDone { outcome, .. } => write!(f, "TurnDone({outcome:?})"),
302            Self::TodosChanged { items } => write!(f, "TodosChanged(n={})", items.len()),
303            Self::LedgerReport(s) => write!(f, "LedgerReport(samples={})", s.sample_count),
304        }
305    }
306}
307
308/// One entry a turn task proposes to the actor, already serialized and
309/// masked (commit-protocol.md §Proposal payloads): the actor's disk-write
310/// path ([`hotl_store::SessionLog::append_prepared`]) never touches
311/// `EntryPayload` again. `item` carries the typed value for entries that
312/// also live in the model-visible projection — the actor still needs it to
313/// update `SessionCmd::Snapshot`'s answer, and keeping it here is not a
314/// second serialization: it is the exact value the turn already built in
315/// memory to produce `payload`, never re-parsed from `payload`'s bytes
316/// (that would just move T3-16's per-entry cost back onto the actor rather
317/// than delete it). `None` for entries that never enter the projection
318/// (`Usage`, `PendingAsk`/`AskResolved`).
319///
320/// Fields are private: `payload` and `item` are two independently-built
321/// views of the same logical entry, and nothing about the types alone
322/// guarantees they agree. [`PreparedEntry::new`] is the only constructor —
323/// it debug-asserts that `item`'s presence matches `payload.kind()`, so a
324/// future call site that passes a mismatched pair (wrong variable, copied
325/// from a different entry) fails loudly in tests/dev builds instead of
326/// silently diverging the projection from the log.
327pub struct PreparedEntry {
328    payload: hotl_store::PreparedPayload,
329    item: Option<Item>,
330}
331
332impl PreparedEntry {
333    pub fn new(payload: hotl_store::PreparedPayload, item: Option<Item>) -> Self {
334        debug_assert_eq!(
335            item.is_some(),
336            matches!(payload.kind(), hotl_store::EntryKind::Item),
337            "PreparedEntry::new: item's presence must match payload.kind() == EntryKind::Item"
338        );
339        Self { payload, item }
340    }
341
342    pub fn payload(&self) -> &hotl_store::PreparedPayload {
343        &self.payload
344    }
345
346    pub fn item(&self) -> Option<&Item> {
347        self.item.as_ref()
348    }
349
350    /// Consume the entry: the actor's commit loop needs to move `payload`
351    /// into `SessionLog::append_prepared` and, separately, `item` (if any)
352    /// into the live projection.
353    pub fn into_parts(self) -> (hotl_store::PreparedPayload, Option<Item>) {
354        (self.payload, self.item)
355    }
356}
357
358/// Whether the sample that produced a proposal had **closed** by the time
359/// the proposal was made — declared by the proposer, because only the turn
360/// knows. The actor never stores it as state and never routes on it; it
361/// exists so the held-steer release can *check* the argument it rests on
362/// instead of resting on a comment (commit-protocol.md §Read invariant, and
363/// the 72a6f1b held-steer rule).
364///
365/// The argument, stated: a steer held while a turn is live may land the
366/// moment one of that turn's commits settles, because a turn commits nothing
367/// between granting itself a snapshot and the `Completed` group that closes
368/// the sample — so every ack the actor handles is genuinely between samples.
369/// That is true of every proposal site today and it is exactly what
370/// [`SampleStage::InSample`] exists to catch when it stops being true.
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub enum SampleStage {
373    /// The sample that produced this commit had already closed (or none was
374    /// running). A held steer may land behind it: the model's reply is
375    /// already durable, so nothing can precede an assistant item that could
376    /// not have seen it.
377    AtBoundary,
378    /// The commit lands **while its own sample is still streaming**. No such
379    /// site exists today; §Commit granularity's intra-sample `BlockEnd`
380    /// pipelining would create the first, and on that day a steer released
381    /// behind one would land ahead of the assistant item the model is still
382    /// producing — the exact inversion 72a6f1b fixed. Declaring this is what
383    /// makes that a loud failure rather than a silent regression.
384    InSample,
385}
386
387/// A batch of entries a turn task asks the actor to commit
388/// (commit-protocol.md §Vocabulary), in the two shapes the protocol names.
389/// Both answer with exactly one [`CommitTicket`] in [`AckMode::Pipelined`];
390/// they differ in what reaches the writer.
391pub enum EntryProposal {
392    Single(PreparedEntry),
393    /// Entries that are **one causal event** (§Causal groups): the actor
394    /// chains them parent→child inside the group and sends one writer
395    /// message, which does one `write_all`, one `sync_data` and resolves one
396    /// ticket. The projection applies the whole group or none of it.
397    Group(Vec<PreparedEntry>),
398}
399
400impl EntryProposal {
401    /// The shape `entries` calls for. A turn only ever proposes several
402    /// entries *together* when they are one causal event — the `Completed`
403    /// pair, or a tool-results batch with the subdir instructions that batch
404    /// uncovered — so the multi-entry case is always a `Group`.
405    pub fn of(mut entries: Vec<PreparedEntry>) -> Self {
406        if entries.len() == 1 {
407            Self::Single(entries.pop().expect("just checked len == 1"))
408        } else {
409            Self::Group(entries)
410        }
411    }
412
413    pub(crate) fn entries(&self) -> &[PreparedEntry] {
414        match self {
415            Self::Single(entry) => std::slice::from_ref(entry),
416            Self::Group(entries) => entries,
417        }
418    }
419
420    pub(crate) fn is_empty(&self) -> bool {
421        self.entries().is_empty()
422    }
423}
424
425/// Whether the *proposer* waits for durability (commit-protocol.md
426/// §Vocabulary). Orthogonal to [`hotl_store::AckTier`], which is how durable
427/// the write must be before the writer acks: canon is `Durable` in both
428/// modes, and only who waits changes.
429#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
430pub enum AckMode {
431    /// The proposer awaits the durable ack inline.
432    Sync,
433    /// The actor validates, mints, assigns `seq`, forwards to the writer and
434    /// answers immediately with a [`CommitTicket`]; durability is settled at
435    /// the turn's next barrier (§Pipelined commits).
436    #[default]
437    Pipelined,
438}
439
440/// What the writer acked with, as a proposer sees it — the shipped
441/// `hotl_store::Ack`, renamed at this boundary to match commit-protocol.md
442/// §Vocabulary.
443#[derive(Debug, Clone, Copy, PartialEq, Eq)]
444pub struct CommitAck {
445    pub offset: u64,
446}
447
448/// Why a pipelined commit will never be durable. Exactly two variants
449/// (commit-protocol.md §Vocabulary).
450#[derive(Debug, Clone, Copy, PartialEq, Eq)]
451pub enum CommitFailed {
452    /// The log is read-only for good; nothing further can be recorded.
453    LogSealed,
454    /// A compaction or branch move superseded the turn. The bytes already
455    /// forwarded are canon and will land — what is discarded is the turn's
456    /// claim on them, never the log (§conflict table, Abort).
457    Aborted,
458}
459
460/// Handed back the moment the actor forwards a proposal to the writer in
461/// [`AckMode::Pipelined`] (commit-protocol.md §Vocabulary). `id` and `seq`
462/// are carried **eagerly** — the actor mints the ulid and assigns `seq` at
463/// validation, before the write — so a proposer knows its own identity and
464/// its own commit order without waiting for durability. Only durability
465/// waits, and `ack` is the one place a commit failure is ever reported.
466#[derive(Debug)]
467pub struct CommitTicket {
468    pub id: String,
469    pub seq: u64,
470    pub ack: oneshot::Receiver<Result<CommitAck, CommitFailed>>,
471}
472
473/// What [`SessionCmd::ProposePrepared`] answers with — plain `bool` has no
474/// room for the rules-epoch guard's distinction (commit-protocol.md
475/// §Proposal payloads): a stale epoch means "rebuild under the current
476/// rules and resend", a genuinely different repair than "the log is sealed,
477/// stop trying".
478#[derive(Debug)]
479pub enum ProposeReply {
480    Committed,
481    /// The actor refused the append: the log is sealed.
482    Sealed,
483    /// `PreparedPayload::rules_epoch` predates the actor's current masking
484    /// rules epoch. Nothing in this proposal was committed.
485    StaleEpoch,
486    /// [`AckMode::Pipelined`] only: forwarded to the writer, durability
487    /// outstanding. One ticket per proposal, bearing the proposal's last
488    /// entry's id and seq — the shape a `Group` will keep unchanged (S2c).
489    Ticket(CommitTicket),
490}
491
492// `BumpRulesEpoch` (below) is a real, data-free variant a test sends over
493// the wire, not a non-exhaustiveness marker — clippy's heuristic can't tell
494// those apart for a `#[doc(hidden)]` unit variant in last position.
495#[allow(clippy::manual_non_exhaustive)]
496pub enum SessionCmd {
497    /// A user prompt. Starts a turn, or queues (one-at-a-time promotion).
498    Prompt(String),
499    /// A prompt whose committed item carries a provenance tag (T2: schema
500    /// contract + validation-retry feedback ride in as tagged user items).
501    PromptTagged {
502        text: String,
503        synthetic: hotl_types::SyntheticReason,
504    },
505    /// Continue an interrupted turn (M4/#8): sample against the current
506    /// projection with no new user item — used on resume when the last item
507    /// is a user/tool turn the model never answered. No-op if already running.
508    Continue,
509    /// Mid-turn guidance: admitted durably now, woven into the next sample.
510    Steer(String),
511    /// Set the session's display name (durable: appended to the log).
512    Rename(String),
513    /// Set the session's effective permission mode (durable: appended to the
514    /// log as `ModeSet`; takes effect immediately — no `Rules` reallocation).
515    SetMode(PermissionMode),
516    /// Full-state replace of the `todo_write` checklist (durable: appended
517    /// to the log as `Todos`, last-wins on replay — same shape as
518    /// `Rename`/`SetMode`). The actor is the list's sole owner; the tool
519    /// only ever forwards a validated `Vec<Todo>` here.
520    SetTodos(Vec<Todo>),
521    /// Pre-actor proposal path (durable-ack before reply): the ONLY caller
522    /// left is [`question_sink`]'s `PendingQuestion`/`QuestionResolved`
523    /// entries, built and sent before the actor (and its masker) exist yet
524    /// — see `question_sink`'s doc comment on why it can't reach
525    /// `SharedDeps`. Those entries are always human-sized (a question
526    /// header/prompt/options), so they stay on the actor-serializing inline
527    /// path, the same carve-out commit-protocol.md §Proposal payloads grants
528    /// steer admissions/compaction digests/todo snapshots. A turn-task
529    /// proposal uses [`SessionCmd::ProposePrepared`] instead — see that
530    /// variant's doc comment for why this one can't be reused for that.
531    Propose {
532        entries: Vec<EntryPayload>,
533        reply: oneshot::Sender<bool>,
534    },
535    /// Turn task → actor: commit already-prepared entries
536    /// (commit-protocol.md §Proposal payloads). This is the type-level
537    /// enforcement point requirement 4 of task 8 asks for: a turn-originated
538    /// proposal can only reach the log through this channel, so a future
539    /// call site cannot reintroduce actor-side serialization for these kinds
540    /// — the entries carry pre-serialized, pre-masked `PreparedPayload`
541    /// bytes, never a raw `EntryPayload`.
542    ProposePrepared {
543        proposal: EntryProposal,
544        /// The proposer's declaration of whether its sample had closed —
545        /// see [`SampleStage`]. Read once, by the held-steer release's
546        /// assertion, and dropped.
547        stage: SampleStage,
548        /// Whether the proposer waits for durability (commit-protocol.md
549        /// §Pipelined commits). `Pipelined` answers with a
550        /// [`ProposeReply::Ticket`] the moment the entries are forwarded.
551        mode: AckMode,
552        reply: oneshot::Sender<ProposeReply>,
553    },
554    /// Turn task → actor: write an oversized tool result to a masked blob
555    /// (T4 — the actor owns the log, the turn never touches it directly).
556    /// Replies `Ok(path)` on success; on write failure the content is handed
557    /// back in `Err` so eviction never loses data.
558    WriteBlob {
559        tool_use_id: String,
560        content: String,
561        reply: oneshot::Sender<Result<String, String>>,
562    },
563    /// Turn task → actor: the turn is over (or needs a compaction respawn).
564    TurnFinished { end: TurnEnd, usage: TokenUsage },
565    /// Test-only: bump the actor's masking-rules epoch by one
566    /// (commit-protocol.md §Proposal payloads' `rules_epoch` guard). Nothing
567    /// in production sends this — the epoch is constant today — but an
568    /// integration test needs a way to force a real
569    /// reject-stale→re-mask→retry round trip through the actor's actual
570    /// command loop, not just a hand-called commit function. `#[doc(hidden)]`
571    /// rather than `#[cfg(test)]`: this crate's tests live in a separate
572    /// compilation unit (`hotl-engine/tests/*.rs`) that only sees `pub` API,
573    /// the same reason `hotl_store::SessionLog::inject_fault` is shaped this
574    /// way.
575    #[doc(hidden)]
576    BumpRulesEpoch,
577}
578
579/// Workspace snapshots around mutating tool batches (M3b shadow-git).
580/// Implementations run the actual snapshot off-thread; a slow or absent
581/// snapshotter must never wedge the turn.
582pub trait Snapshotter: Send + Sync {
583    fn snapshot(&self, label: String) -> futures_util::future::BoxFuture<'static, ()>;
584}
585
586pub struct SessionDeps {
587    pub provider: Arc<dyn Provider>,
588    pub registry: Arc<Registry>,
589    pub rules: Arc<Rules>,
590    /// Gates bash allow-rules: true only while the kernel write floor is
591    /// enforced *and* any configured egress restriction is kernel-backed.
592    pub sandbox_enforced: bool,
593    pub clock: Arc<dyn Clock>,
594    pub log: SessionLog,
595    pub system: String,
596    /// Working directory for subdir instruction hints (M2).
597    pub cwd: PathBuf,
598    /// Shadow snapshots (M3b); None = run without undo support.
599    pub snapshots: Option<Arc<dyn Snapshotter>>,
600    /// Extension hooks (M5); None = no hooks.
601    pub hooks: Option<Arc<dyn hooks::Hooks>>,
602    pub initial_items: Vec<Item>,
603    /// The todo checklist a resumed session starts with (the replayed
604    /// session's last durable `Todos` entry — see `hotl_store::Replayed`).
605    /// Empty for a fresh session. Seeds the actor's live `todos`, not
606    /// `initial_items`: it never rode the projection, so it must not
607    /// re-enter through it, and seeding here (vs. a post-spawn `SetTodos`)
608    /// means resume never appends a duplicate `Todos` log entry.
609    pub initial_todos: Vec<Todo>,
610    pub config: EngineConfig,
611}
612
613pub struct SessionHandle {
614    cmd: mpsc::Sender<SessionCmd>,
615    pub events: mpsc::Receiver<EngineEvent>,
616    current_turn: Arc<Mutex<CancellationToken>>,
617    /// The session-scoped `notify` drain (Finding 1 fix) — the same instance
618    /// the actor (and any `question_sink`) tracks detached `Notification`
619    /// hook tasks in.
620    notifications: hooks::NotificationDrain,
621    /// The read side of the actor's published head — see
622    /// [`SessionHandle::head`].
623    head: tokio::sync::watch::Receiver<Arc<ProjectionHead>>,
624    /// The actor task itself. Kept (rather than discarded, as before) so a
625    /// one-shot CLI exit path can wait for the actor to fully shut down —
626    /// including its now-synchronous `SessionEnd` hook call (Finding 1) —
627    /// instead of just dropping the handle and hoping the actor gets another
628    /// scheduler turn before the runtime goes away.
629    actor: tokio::task::JoinHandle<()>,
630}
631
632impl SessionHandle {
633    /// A read-only view of the actor's published projection head
634    /// (commit-protocol.md §Read invariant). Only a `watch::Receiver` is ever
635    /// handed out: the `Sender` never leaves the actor, so this grants a
636    /// reader, never a second publisher. Used by `fork`, which seeds a child
637    /// session from this one's history.
638    pub fn head(&self) -> tokio::sync::watch::Receiver<Arc<ProjectionHead>> {
639        self.head.clone()
640    }
641
642    pub async fn prompt(&self, text: String) {
643        let _ = self.cmd.send(SessionCmd::Prompt(text)).await;
644    }
645    /// A prompt whose committed user item carries a provenance tag (T2).
646    pub async fn prompt_tagged(&self, text: String, synthetic: hotl_types::SyntheticReason) {
647        let _ = self
648            .cmd
649            .send(SessionCmd::PromptTagged { text, synthetic })
650            .await;
651    }
652    pub async fn steer(&self, text: String) {
653        let _ = self.cmd.send(SessionCmd::Steer(text)).await;
654    }
655    /// Name the session durably (a `rename` log entry; last one wins).
656    pub async fn rename(&self, name: String) {
657        let _ = self.cmd.send(SessionCmd::Rename(name)).await;
658    }
659    /// Set the session's effective permission mode durably (a `mode_set` log
660    /// entry; last one wins). Takes effect immediately: the running actor
661    /// flips an atomic, it never reallocates `Rules`.
662    pub async fn set_mode(&self, mode: PermissionMode) {
663        let _ = self.cmd.send(SessionCmd::SetMode(mode)).await;
664    }
665    /// Full-state replace of the todo checklist (a durable `todos` log
666    /// entry). Exposed mainly for tests that pre-seed a list; the real
667    /// entry point is the `todo_write` tool's sink.
668    pub async fn set_todos(&self, items: Vec<Todo>) {
669        let _ = self.cmd.send(SessionCmd::SetTodos(items)).await;
670    }
671    /// Continue an interrupted turn on resume (M4/#8).
672    pub async fn continue_turn(&self) {
673        let _ = self.cmd.send(SessionCmd::Continue).await;
674    }
675    /// Out-of-band interrupt of the in-flight turn (never queued behind data).
676    pub fn interrupt(&self) {
677        // A poisoned lock is fine: the token has no invariants to protect.
678        self.current_turn
679            .lock()
680            .unwrap_or_else(std::sync::PoisonError::into_inner)
681            .cancel();
682    }
683
684    /// Bounded wait for every detached `Notification` hook task still in
685    /// flight (Finding 1's `notify` fix): the one-shot CLI's `block_on` drops
686    /// its `current_thread` runtime the instant its driving future resolves,
687    /// which would otherwise silently kill a hook task mid-subprocess. A
688    /// one-shot exit path should call this (or, more commonly,
689    /// [`SessionHandle::finish`]) before returning; the long-lived
690    /// TUI/interactive path never needs to — its runtime stays alive on its
691    /// own, so an in-flight notification finishes naturally.
692    pub async fn drain_notifications(&self, grace: Duration) {
693        self.notifications.drain(grace).await;
694    }
695
696    /// The one-shot CLI's exit-time helper (Finding 1, both halves):
697    /// consumes the handle, first draining in-flight `Notification` hook
698    /// tasks (bounded by `grace`), then dropping this handle's strong
699    /// command-channel sender and waiting — again bounded by `grace` — for
700    /// the actor to fully shut down, which now runs its `SessionEnd` hook
701    /// synchronously rather than as a detached task racing this same exit.
702    /// Total worst case is `2 * grace`, never unbounded: a hung hook can
703    /// delay the process's exit, but never wedge it.
704    ///
705    /// Call this (not a bare `drop(handle)`) right before a one-shot CLI
706    /// function returns. The long-lived TUI/interactive/`hotl serve` paths
707    /// must NOT call this — their runtime stays alive on its own, so both
708    /// the notification and session-end hooks get to run naturally without
709    /// this explicit wait.
710    pub async fn finish(self, grace: Duration) {
711        self.notifications.drain(grace).await;
712        let SessionHandle { cmd, actor, .. } = self;
713        drop(cmd);
714        let _ = tokio::time::timeout(grace, actor).await;
715    }
716}
717
718/// Whether a projection ends on the model's turn to speak (M4/#8): the last
719/// item is a user prompt or a batch of tool results the model never answered
720/// — i.e. an interrupted turn worth continuing on resume. A projection ending
721/// in an assistant item (or holding only instructions) is complete.
722pub fn needs_continuation(items: &[Item]) -> bool {
723    matches!(
724        items.last(),
725        Some(Item::User { .. } | Item::ToolResults { .. })
726    )
727}
728
729/// A fresh, not-yet-consumed command channel for a session that doesn't
730/// exist yet. Split out from [`spawn_session`] so a caller can build a tool
731/// (`todo_write`) whose sink already holds a live sender to *this* session's
732/// actor before the actor exists — the registry (and the deps built from
733/// it) has to be assembled before `spawn_session` runs, which is otherwise a
734/// chicken-and-egg with a command channel `spawn_session` creates internally.
735pub fn session_channel() -> (mpsc::Sender<SessionCmd>, mpsc::Receiver<SessionCmd>) {
736    mpsc::channel(64)
737}
738
739/// A fresh, not-yet-consumed event channel for a session that doesn't exist
740/// yet — the events-side twin of [`session_channel`]. Split out so a caller
741/// can build a tool (`ask_user`) whose sink already holds a live sender to
742/// *this* session's own events stream before the actor exists, the same
743/// chicken-and-egg [`session_channel`] solves for `SessionCmd`.
744pub fn event_channel() -> (mpsc::Sender<EngineEvent>, mpsc::Receiver<EngineEvent>) {
745    mpsc::channel(256)
746}
747
748pub fn spawn_session(deps: SessionDeps) -> SessionHandle {
749    let (cmd_tx, cmd_rx) = session_channel();
750    spawn_session_with(deps, cmd_tx, cmd_rx)
751}
752
753/// Spawn against a pre-created command channel (see [`session_channel`]);
754/// builds its own event channel.
755pub fn spawn_session_with(
756    deps: SessionDeps,
757    cmd_tx: mpsc::Sender<SessionCmd>,
758    cmd_rx: mpsc::Receiver<SessionCmd>,
759) -> SessionHandle {
760    let (event_tx, event_rx) = event_channel();
761    spawn_session_with_channels(
762        deps,
763        cmd_tx,
764        cmd_rx,
765        event_tx,
766        event_rx,
767        hooks::NotificationDrain::new(),
768    )
769}
770
771/// Spawn against pre-created command *and* event channels (see
772/// [`session_channel`]/[`event_channel`]) — what a caller needs when a
773/// session-scoped tool's sink (`ask_user`) must hold live senders to both
774/// before the actor exists.
775pub fn spawn_session_with_channels(
776    deps: SessionDeps,
777    cmd_tx: mpsc::Sender<SessionCmd>,
778    cmd_rx: mpsc::Receiver<SessionCmd>,
779    event_tx: mpsc::Sender<EngineEvent>,
780    event_rx: mpsc::Receiver<EngineEvent>,
781    notifications: hooks::NotificationDrain,
782) -> SessionHandle {
783    let current_turn = Arc::new(Mutex::new(CancellationToken::new()));
784    // The head's read side is created here rather than inside the actor so
785    // `SessionHandle::head` can hand it out immediately: the actor takes the
786    // `Sender` and never gives it up.
787    let (head_tx, head_rx) = actor::head_channel();
788    // The actor gets only a weak sender: strong senders are the handle and
789    // any in-flight turn task, so dropping the handle lets the command
790    // channel close and the actor task exit instead of leaking.
791    let actor = tokio::spawn(actor::run(
792        deps,
793        cmd_rx,
794        cmd_tx.downgrade(),
795        event_tx,
796        current_turn.clone(),
797        notifications.clone(),
798        head_tx,
799    ));
800    SessionHandle {
801        cmd: cmd_tx,
802        events: event_rx,
803        current_turn,
804        notifications,
805        head: head_rx,
806        actor,
807    }
808}
809
810/// The production [`hotl_tools::ask::QuestionSink`] for `ask_user` (tier-1
811/// gap #4): mirrors `Turn::ask` almost line-for-line, but runs from inside a
812/// tool rather than `Turn` itself, so it reaches the actor through channels
813/// instead of `self.propose`/`self.events` directly. Durably commits
814/// `PendingQuestion` *before* surfacing (so a process that dies mid-question
815/// leaves a dangling record replay can warn about, exactly like
816/// `PendingAsk`), emits [`EngineEvent::Question`] carrying a fresh reply
817/// channel, races the human's reply against the call's own cancellation
818/// token (the same token `Turn::ask` races — an in-flight `ask_user` must
819/// never outlive a turn the user already cancelled), then commits
820/// `QuestionResolved`.
821///
822/// Captures only *weak* senders: this sink ends up owned by the tool
823/// registry, which `SharedDeps` — and so the actor — holds for the whole
824/// session. A strong sender captured here would be exactly the reference
825/// cycle that made an early cut of `TodoWriteTool`'s sink leak the actor
826/// task (`cmd_rx.recv()` never returns `None` while a strong sender lives
827/// inside the very state the actor holds forever); see
828/// `spawn_session_with_todos` for the sibling fix. An upgrade failure (the
829/// handle/actor already gone) resolves to `NoHuman` — there is nobody left
830/// to answer.
831///
832/// `hooks`/`notifications` (Finding 2 fix): this is the dominant "agent
833/// needs input" surface — the exact signal `hotl watch` exists to catch —
834/// but until now only `Turn::ask` (the permission-ask surface) fired
835/// `Notification::Blocked`. The blocker cited when this was first built
836/// (hooks unavailable at registry-build time) doesn't hold: `scaffold()`
837/// loads hooks and completes before `spawn_session_with_todos`/this sink are
838/// built, so the caller always has a `hooks` handle in scope — it just
839/// wasn't threaded through. `notifications` must be the *same* drain the
840/// session's actor was built with (Finding 1) so the CLI's exit-time drain
841/// call also covers a `Blocked` notification fired from here.
842pub fn question_sink(
843    cmd_tx: mpsc::WeakSender<SessionCmd>,
844    events_tx: mpsc::WeakSender<EngineEvent>,
845    hooks_handle: Option<Arc<dyn hooks::Hooks>>,
846    notifications: hooks::NotificationDrain,
847) -> hotl_tools::ask::QuestionSink {
848    // §S1 HookRouter gate: resolved once here (sink-construction time, never
849    // per question) — the same handle-first, snapshot-fallback shape
850    // `SharedDeps::hook_mask` uses, so a live handle's mid-session
851    // narrowing (e.g. a three-strike eviction) is visible to `hook_gate!`
852    // below immediately, not just at the next session.
853    let hook_mask: Arc<std::sync::atomic::AtomicU8> = hooks_handle
854        .as_ref()
855        .and_then(|h| h.mask_handle())
856        .unwrap_or_else(|| {
857            Arc::new(std::sync::atomic::AtomicU8::new(
858                hooks_handle
859                    .as_ref()
860                    .map_or(hooks::EventMask::NONE, |h| h.event_mask())
861                    .bits(),
862            ))
863        });
864    std::sync::Arc::new(move |question, cancel| {
865        let hook_mask = Arc::clone(&hook_mask);
866        let cmd_tx = cmd_tx.clone();
867        let events_tx = events_tx.clone();
868        let hooks_handle = hooks_handle.clone();
869        let notifications = notifications.clone();
870        Box::pin(async move {
871            let id = hotl_types::new_ulid();
872            propose_via(
873                &cmd_tx,
874                vec![EntryPayload::PendingQuestion {
875                    id: id.clone(),
876                    question: question.clone(),
877                }],
878            )
879            .await;
880            // Notification (Finding 2): the agent is blocked on a human at
881            // the ask_user surface, mirroring `Turn::ask` — fire-and-forget,
882            // right before the question actually surfaces.
883            crate::hooks::hook_gate!(
884                hooks_handle,
885                crate::hooks::mask_of(&hook_mask),
886                crate::hooks::EventMask::NOTIFICATION,
887                |h| {
888                    crate::hooks::notify(
889                        h,
890                        &notifications,
891                        crate::hooks::NotificationKind::Blocked,
892                        question.header.clone(),
893                    );
894                },
895                else {}
896            );
897            let answer = match events_tx.upgrade() {
898                None => hotl_types::QuestionAnswer::NoHuman,
899                Some(events) => {
900                    let (reply_tx, reply_rx) = oneshot::channel();
901                    if events
902                        .send(EngineEvent::Question {
903                            id: id.clone(),
904                            question,
905                            reply: reply_tx,
906                        })
907                        .await
908                        .is_err()
909                    {
910                        hotl_types::QuestionAnswer::NoHuman
911                    } else {
912                        tokio::select! {
913                            biased;
914                            _ = cancel.cancelled() => hotl_types::QuestionAnswer::NoHuman,
915                            reply = reply_rx => reply.unwrap_or(hotl_types::QuestionAnswer::NoHuman),
916                        }
917                    }
918                }
919            };
920            propose_via(
921                &cmd_tx,
922                vec![EntryPayload::QuestionResolved {
923                    id,
924                    answer: hotl_tools::ask::format_answer(&answer),
925                }],
926            )
927            .await;
928            answer
929        })
930    })
931}
932
933/// Durable-append helper for [`question_sink`]: best-effort, like
934/// `Turn::propose` — a sealed/gone log never blocks the question itself.
935async fn propose_via(cmd_tx: &mpsc::WeakSender<SessionCmd>, entries: Vec<EntryPayload>) {
936    let Some(tx) = cmd_tx.upgrade() else { return };
937    let (reply_tx, reply_rx) = oneshot::channel();
938    if tx
939        .send(SessionCmd::Propose {
940            entries,
941            reply: reply_tx,
942        })
943        .await
944        .is_ok()
945    {
946        let _ = reply_rx.await;
947    }
948}
949
950#[cfg(test)]
951mod tests {
952    use super::*;
953
954    /// IMPORTANT 3 (task 8 review): `PreparedEntry::new`'s debug_assert
955    /// catches a mismatched pair — the bug class the reviewer flagged
956    /// (bytes and item as two unchecked sources of truth).
957    #[test]
958    #[cfg_attr(
959        not(debug_assertions),
960        ignore = "drives a debug_assert, which release builds compile out"
961    )]
962    #[should_panic(expected = "item's presence must match payload.kind()")]
963    fn prepared_entry_new_rejects_a_mismatched_item_and_kind() {
964        let masker = hotl_store::Masker::empty();
965        let payload = hotl_store::prepare_payload(
966            &EntryPayload::Usage {
967                usage: TokenUsage::default(),
968            },
969            &masker,
970            0,
971        )
972        .unwrap();
973        // `item` is `Some`, but `payload` was built from `Usage`, not `Item`.
974        let _ = PreparedEntry::new(
975            payload,
976            Some(Item::User {
977                text: "x".into(),
978                synthetic: None,
979            }),
980        );
981    }
982
983    #[test]
984    fn prepared_entry_new_accepts_a_matching_item_and_kind() {
985        let masker = hotl_store::Masker::empty();
986        let item = Item::User {
987            text: "x".into(),
988            synthetic: None,
989        };
990        let payload =
991            hotl_store::prepare_payload(&EntryPayload::Item { item: item.clone() }, &masker, 0)
992                .unwrap();
993        let entry = PreparedEntry::new(payload, Some(item));
994        assert!(entry.item().is_some());
995    }
996}