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 ¬ifications,
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 #[should_panic(expected = "item's presence must match payload.kind()")]
959 fn prepared_entry_new_rejects_a_mismatched_item_and_kind() {
960 let masker = hotl_store::Masker::empty();
961 let payload = hotl_store::prepare_payload(
962 &EntryPayload::Usage {
963 usage: TokenUsage::default(),
964 },
965 &masker,
966 0,
967 )
968 .unwrap();
969 // `item` is `Some`, but `payload` was built from `Usage`, not `Item`.
970 let _ = PreparedEntry::new(
971 payload,
972 Some(Item::User {
973 text: "x".into(),
974 synthetic: None,
975 }),
976 );
977 }
978
979 #[test]
980 fn prepared_entry_new_accepts_a_matching_item_and_kind() {
981 let masker = hotl_store::Masker::empty();
982 let item = Item::User {
983 text: "x".into(),
984 synthetic: None,
985 };
986 let payload =
987 hotl_store::prepare_payload(&EntryPayload::Item { item: item.clone() }, &masker, 0)
988 .unwrap();
989 let entry = PreparedEntry::new(payload, Some(item));
990 assert!(entry.item().is_some());
991 }
992}