Skip to main content

hotl_engine/
actor.rs

1//! The session actor: sole committer, projection owner, turn scheduler.
2
3use std::collections::VecDeque;
4use std::path::PathBuf;
5use std::sync::atomic::{AtomicU8, Ordering};
6use std::sync::{Arc, Mutex};
7
8use futures_util::StreamExt;
9use hotl_context::compaction;
10use hotl_platform::Clock;
11use hotl_provider::{Provider, SamplingRequest, StreamEvent};
12use hotl_store::SessionLog;
13use hotl_tools::{
14    rules::{PermissionMode, Rules},
15    Registry,
16};
17use hotl_types::{assistant_text, EntryPayload, Item, SyntheticReason, Todo, TokenUsage};
18use tokio::sync::mpsc;
19use tokio_util::sync::CancellationToken;
20
21use crate::{turn, EngineConfig, EngineEvent, Outcome, SessionCmd, SessionDeps, TurnEnd};
22
23/// Verbatim tail kept through a compaction, as a share of the window.
24pub(crate) const TAIL_RATIO: f64 = 0.3;
25const SUMMARIZE_ATTEMPTS: u32 = 2;
26const SUMMARIZE_MAX_TOKENS: u32 = 2_000;
27/// Compactions without an intervening completed sample before giving up —
28/// prevents a fold-the-digest spiral when the tail alone overflows.
29/// INVARIANT: a fold with progress behind it never draws down this cap.
30/// Enforced by `three_folds_with_progress_do_not_exhaust_the_streak`.
31const MAX_COMPACT_STREAK: u32 = 2;
32/// Wall-clock bound on the inline compaction summarize. The actor's command
33/// loop is blocked for its duration (T3-4), so it is bounded even though the
34/// provider call has its own retries: a degraded floor digest is a handled
35/// outcome, an unresponsive session is not. Sized as one full retry budget
36/// ([`SUMMARIZE_ATTEMPTS`] attempts under the provider's own per-request
37/// timeout) so the outer net never cuts a legitimate retry short.
38/// INVARIANT: the actor's command loop stalls for at most this long on a fold.
39/// Enforced by `a_hung_inline_summarize_degrades_instead_of_wedging`.
40const COMPACT_SUMMARIZE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
41/// Queued prompts before new ones coalesce into the last entry (T3-7).
42const QUEUE_MAX: usize = 64;
43/// Bytes of held steering (or coalesced prompt) text before folding truncates
44/// with a marker.
45const HELD_BYTES_MAX: usize = 64 * 1024;
46/// In-band disclosure that a fold dropped text. Stripped before each new fold
47/// so repeated folding never stacks markers.
48const FOLD_MARK: &str = "\n[… later text truncated]";
49
50/// One forwarded unit — a single entry, or a whole causal `Group` — waiting
51/// on the writer's ack (commit-protocol.md §Pipelined commits, "The actor's
52/// bookkeeping").
53struct PendingAck {
54    ack: tokio::sync::oneshot::Receiver<std::io::Result<hotl_store::Ack>>,
55    /// The projection items this unit carries, in commit order. Applied when
56    /// the ack lands, in FIFO order — never on forward. A `Group` applies
57    /// whole or not at all, which is what one ack for one writer message
58    /// gives.
59    items: Vec<Item>,
60    /// Id of this unit's **last** entry — the durable leaf once applied
61    /// (§Ordering authority), and what its ticket bears.
62    id: String,
63    /// That entry's `seq`: the epoch the published head reaches when this
64    /// unit is applied.
65    seq: u64,
66    /// The proposer's declaration of whether its sample had closed. Not
67    /// state and not a decision input — the held-steer release's assertion
68    /// reads it once, at settle, and it dies with this entry.
69    stage: crate::SampleStage,
70    /// Resolved when this unit is the last of its proposal: one ticket per
71    /// proposal. `None` for interior entries and for every actor-originated
72    /// append.
73    ticket: Option<tokio::sync::oneshot::Sender<Result<crate::CommitAck, crate::CommitFailed>>>,
74}
75
76/// The projection head the actor publishes (commit-protocol.md §Read
77/// invariant, amendment 2): epoch-fenced, published **only post-ack**, and
78/// read by turn tasks through a `watch::Receiver` they cannot write. The
79/// actor is the sole publisher — the `watch::Sender` never leaves [`Head`],
80/// which only [`run`] owns.
81pub struct ProjectionHead {
82    /// The durable projection: exactly the items the log carries.
83    items: Arc<Vec<Item>>,
84    /// The `todo_write` checklist — ephemeral session context that is never
85    /// canon and never an entry in `items`. It rides the head rather than
86    /// being stitched into it, so the published value stays *the durable
87    /// projection*; the stitch happens per read, in [`Self::snapshot`], the
88    /// same "ephemeral, request-only" shape the MOIM turn-context block has.
89    /// A todo change is itself a durable `Todos` entry, so it moves
90    /// `leaf`/`epoch` like any other commit.
91    todos: Arc<Vec<Todo>>,
92    /// Id of the newest entry applied — the **durable leaf** (§Ordering
93    /// authority), and what an optimistic dispatch's `expected_leaf` is
94    /// compared against. `None` before anything is applied.
95    leaf: Option<String>,
96    /// `seq` of that entry: the session-global commit order, which is what
97    /// makes `wait_for(|p| p.epoch >= my_ack_seq)` well-formed.
98    epoch: u64,
99}
100
101impl ProjectionHead {
102    /// The durable leaf: id of the newest entry applied.
103    pub fn leaf(&self) -> Option<&str> {
104        self.leaf.as_deref()
105    }
106
107    /// `seq` of that entry — the session-global commit order.
108    pub fn epoch(&self) -> u64 {
109        self.epoch
110    }
111
112    /// The snapshot a turn task samples against, as **two channels**: the
113    /// durable projection the head already holds, and the ephemeral suffix
114    /// regenerated per read. The reminder is never spliced into `items` — not
115    /// even into a copy of it — so it can never be committed, replayed,
116    /// double-counted, or (the point of the split) chosen as a cache
117    /// breakpoint by a serializer that only sees a flat list.
118    ///
119    /// Both fields hand back an `Arc` the head or the process already holds
120    /// whenever it can: the common no-todos read allocates nothing at all,
121    /// which is what keeps "Vec with clone at grant" the cost model §Read
122    /// invariant priced.
123    pub fn snapshot(&self) -> Snapshot {
124        Snapshot {
125            durable: Arc::clone(&self.items),
126            tail: match hotl_tools::todo::render_reminder(&self.todos) {
127                Some(reminder) => Arc::new(vec![reminder]),
128                None => empty_tail(),
129            },
130        }
131    }
132}
133
134/// One read of the projection head, split by durability.
135///
136/// The split is the invariant: `durable` is byte-stable between supersede
137/// events (append-only projection), `tail` is regenerated every read. Every
138/// consumer that must not see ephemeral content — the fork seed, the cache
139/// breakpoint chooser — reads `durable` and structurally cannot reach `tail`.
140#[derive(Clone, Debug)]
141pub struct Snapshot {
142    /// The durable projection — byte-stable between supersede events.
143    pub durable: Arc<Vec<Item>>,
144    /// Ephemeral per-sample suffix (todo reminder today), regenerated per
145    /// read, never committed, rendered after every cache marker.
146    pub tail: Arc<Vec<Item>>,
147}
148
149/// The one shared empty ephemeral tail. A `OnceLock` rather than
150/// `Arc::new(Vec::new())` per call so that a session with no todos — the
151/// common case, and the one on the sample-boundary hot path — allocates
152/// nothing to say "nothing ephemeral here".
153pub(crate) fn empty_tail() -> Arc<Vec<Item>> {
154    static EMPTY: std::sync::OnceLock<Arc<Vec<Item>>> = std::sync::OnceLock::new();
155    Arc::clone(EMPTY.get_or_init(|| Arc::new(Vec::new())))
156}
157
158/// The head channel, created before the actor so a `SessionHandle` can hand
159/// out readers immediately (see [`crate::SessionHandle::head`]). The
160/// `Sender` goes to the actor and never leaves it.
161pub(crate) fn head_channel() -> (
162    tokio::sync::watch::Sender<Arc<ProjectionHead>>,
163    tokio::sync::watch::Receiver<Arc<ProjectionHead>>,
164) {
165    tokio::sync::watch::channel(Arc::new(ProjectionHead {
166        items: Arc::new(Vec::new()),
167        todos: Arc::new(Vec::new()),
168        leaf: None,
169        epoch: 0,
170    }))
171}
172
173/// The actor's side of the published head: the live projection plus the one
174/// `watch::Sender` in the process. Every projection advance goes through
175/// here, which is what makes "publishes only post-ack, in ack order" a
176/// property of one type rather than of every call site.
177struct Head {
178    tx: tokio::sync::watch::Sender<Arc<ProjectionHead>>,
179    items: Arc<Vec<Item>>,
180    todos: Arc<Vec<Todo>>,
181    leaf: Option<String>,
182    epoch: u64,
183}
184
185impl Head {
186    /// Seed the head with the session's starting projection and publish it,
187    /// so the very first read (a resumed session's `Continue`, or `fork`)
188    /// never sees the empty placeholder [`head_channel`] created.
189    fn new(
190        tx: tokio::sync::watch::Sender<Arc<ProjectionHead>>,
191        items: Vec<Item>,
192        todos: Vec<Todo>,
193    ) -> Self {
194        let mut head = Self {
195            tx,
196            items: Arc::new(items),
197            todos: Arc::new(todos),
198            leaf: None,
199            epoch: 0,
200        };
201        head.publish();
202        head
203    }
204
205    fn items(&self) -> &Arc<Vec<Item>> {
206        &self.items
207    }
208
209    fn todos(&self) -> &Arc<Vec<Todo>> {
210        &self.todos
211    }
212
213    /// Apply one acked item. Deliberately does NOT publish: a commit and the
214    /// steer released behind it must reach the head as one visible step (see
215    /// `release_steers`), or a turn's refresh could observe the commit
216    /// without the steer that overtook it.
217    fn apply(&mut self, item: Item) {
218        Arc::make_mut(&mut self.items).push(item);
219    }
220
221    /// Record the durable leaf and epoch an acked unit advanced the head to.
222    fn advance(&mut self, leaf: String, seq: u64) {
223        self.leaf = Some(leaf);
224        self.epoch = seq;
225    }
226
227    fn set_todos(&mut self, todos: Vec<Todo>) {
228        self.todos = Arc::new(todos);
229    }
230
231    /// Re-point the projection after a fold. The published head moves without
232    /// an epoch bump: the compaction entry's own commit already advanced
233    /// `leaf`/`epoch`, and this is that entry taking effect. Unobservable
234    /// mid-turn by construction — the actor terminates a turn *before*
235    /// committing a compaction (§Read invariant).
236    fn repoint(&mut self, items: Vec<Item>) {
237        self.items = Arc::new(items);
238        self.publish();
239    }
240
241    /// The one publish site. `watch::Sender::send` never fails here: the
242    /// actor holds a receiver for the whole session inside `SharedDeps`.
243    fn publish(&mut self) {
244        let _ = self.tx.send(Arc::new(ProjectionHead {
245            items: Arc::clone(&self.items),
246            todos: Arc::clone(&self.todos),
247            leaf: self.leaf.clone(),
248            epoch: self.epoch,
249        }));
250    }
251}
252
253/// Why a held steer may land right now — and, for the one case that needs
254/// it, the *proof*. There is no third variant, and between them they are the
255/// whole of the protection the `ProposePrepared` handler's old `sampling`
256/// flip gave: a steer is only ever appended at a boundary the actor itself
257/// just created, so it can never precede an assistant item that could not
258/// have seen it (72a6f1b).
259#[derive(Clone, Copy, Debug)]
260enum Boundary {
261    /// A commit this turn made just landed and was applied to the head.
262    /// `stage` is the proposer's own declaration that its sample had closed
263    /// — the mid-sample half of the old assert's protection, checked rather
264    /// than assumed (see [`crate::SampleStage`]).
265    CommitSettled { stage: crate::SampleStage },
266    /// The turn is over; nothing will answer an open batch now, so no sample
267    /// can be in flight by construction.
268    TurnEnded,
269}
270
271impl Boundary {
272    /// Whether this really is a boundary a held steer may land at.
273    fn is_between_samples(self) -> bool {
274        match self {
275            Self::CommitSettled { stage } => stage == crate::SampleStage::AtBoundary,
276            Self::TurnEnded => true,
277        }
278    }
279}
280
281/// How a drain settles the tickets it resolves.
282#[derive(Clone, Copy)]
283enum Resolution {
284    /// The ordinary path: the ticket reports its byte offset.
285    Ack,
286    /// The conflict table's Abort arm: the projection still advances over
287    /// every entry the drain lands (the bytes are canon), but the turn's
288    /// claim on them is discarded (commit-protocol.md §conflict table, step
289    /// 3 then step 4).
290    Abort,
291}
292
293/// The actor's mirror of the writer's queue (commit-protocol.md §Pipelined
294/// commits). **Bookkeeping, not semantic state** (§Tripwire re-check): it
295/// holds only entries already minted and already forwarded, it is fully
296/// derivable from what the actor has sent, and no rule consults it as a
297/// decision input — the conflict table reads the queued leaf, which is its
298/// tail (and lives in `SessionLog::last_id`, not here).
299///
300/// Depth is bounded by the turn-side `ACK_WINDOW`: a turn may hold at most
301/// that many unresolved tickets before it must wait on the oldest, and every
302/// actor-originated append drains this first.
303#[derive(Default)]
304struct Pipeline {
305    fifo: VecDeque<PendingAck>,
306    /// Global commit order across the session (§Ordering authority),
307    /// assigned at validation so a ticket carries it eagerly.
308    ///
309    /// In memory only. The shipped entry envelope has no `seq` field and
310    /// golden byte-stability is defined over the parent chain exclusively,
311    /// so writing one would be a wire-format change this revision does not
312    /// make — and §Ordering authority already forbids a projector from
313    /// consulting `seq` at all ("audit and debugging only").
314    seq: u64,
315}
316
317impl Pipeline {
318    fn is_empty(&self) -> bool {
319        self.fifo.is_empty()
320    }
321
322    /// Assign the next commit order, at the mint that is about to happen.
323    ///
324    /// **Every** commit the actor makes calls this — `Sync` proposals,
325    /// `Pipelined` proposals and actor-originated inline appends alike —
326    /// because §Ordering authority defines `seq` as the global commit order
327    /// across the whole session, not a count of the pipelined subset. A
328    /// counter that skipped the other paths would also make S2c's watch
329    /// predicate (`epoch >= my_ack_seq`, where `epoch` is the seq of the
330    /// newest entry applied to the published head) compare two different
331    /// numberings.
332    ///
333    /// Assigned at validation, before the write, so a ticket carries it
334    /// eagerly — which means a mint the writer then refuses burns its
335    /// number. That is harmless: the head simply never stops on it, and
336    /// `seq` is monotonic either way.
337    ///
338    /// Within a proposal it is assigned per entry in proposal order, so the
339    /// run is contiguous and `seq` order still equals disk order.
340    fn next_seq(&mut self) -> u64 {
341        self.seq += 1;
342        self.seq
343    }
344
345    /// Resolve every outstanding ack, in order — the barrier the actor runs
346    /// before any inline append and the conflict table's step (3).
347    ///
348    /// INVARIANT: the projection advances strictly in ack order, and only on
349    /// an ack. Enforced by `the_pipeline_advances_the_projection_in_fifo_order`
350    /// and, end to end, by
351    /// `a_writer_death_before_fsync_never_resolves_a_pipelined_ticket`.
352    async fn drain(&mut self, head: &mut Head, resolution: Resolution) {
353        while !self.is_empty() {
354            // The borrow ends here, so the pop below is legal — and dropping
355            // this future mid-await loses nothing: the receiver stays in the
356            // FIFO for the next drain.
357            let acked = {
358                let entry = self.fifo.front_mut().expect("just checked non-empty");
359                await_ack(&mut entry.ack).await
360            };
361            let entry = self.fifo.pop_front().expect("just checked non-empty");
362            let settled = apply_ack(entry, acked, head, resolution);
363            head.publish();
364            settled.resolve();
365        }
366    }
367}
368
369/// What woke the actor's select loop.
370enum Woke {
371    Ack(std::io::Result<hotl_store::Ack>),
372    Cmd(Option<SessionCmd>),
373}
374
375/// The oldest pending ack, or a future that never resolves when there is
376/// none — the loop's ack arm is only live while the FIFO is non-empty.
377async fn next_ack(front: &mut Option<PendingAck>) -> std::io::Result<hotl_store::Ack> {
378    match front {
379        Some(entry) => await_ack(&mut entry.ack).await,
380        None => std::future::pending().await,
381    }
382}
383
384/// The writer's answer for one forwarded entry; a dropped sender means the
385/// writer died before it could ack (the SIGKILL case), which is never an ack.
386async fn await_ack(
387    ack: &mut tokio::sync::oneshot::Receiver<std::io::Result<hotl_store::Ack>>,
388) -> std::io::Result<hotl_store::Ack> {
389    match ack.await {
390        Ok(result) => result,
391        Err(_) => Err(std::io::Error::other(
392            "the log writer stopped before the entry was committed",
393        )),
394    }
395}
396
397/// A ticket that has been decided but not yet told. Held between the *apply*
398/// and the *resolve* halves of commit-protocol.md §Read invariant's
399/// "apply → publish → resolve" so the publish can sit between them — and so
400/// a steer released at the same boundary joins the same published head.
401#[must_use = "a decided ticket must be resolved, or its proposer waits forever"]
402struct Settled {
403    ticket: Option<tokio::sync::oneshot::Sender<Result<crate::CommitAck, crate::CommitFailed>>>,
404    resolved: Result<crate::CommitAck, crate::CommitFailed>,
405}
406
407impl Settled {
408    fn resolve(self) {
409        if let Some(ticket) = self.ticket {
410            let _ = ticket.send(self.resolved);
411        }
412    }
413}
414
415/// The *apply* half: fold one acked unit (entry or whole `Group`) into the
416/// head. The caller publishes, then resolves — in that order, which is what
417/// makes the turn's `epoch >= my_ack_seq` predicate a gate rather than a
418/// race.
419fn apply_ack(
420    entry: PendingAck,
421    acked: std::io::Result<hotl_store::Ack>,
422    head: &mut Head,
423    resolution: Resolution,
424) -> Settled {
425    let resolved = match acked {
426        Ok(ack) => {
427            for item in entry.items {
428                head.apply(item);
429            }
430            head.advance(entry.id, entry.seq);
431            match resolution {
432                Resolution::Ack => Ok(crate::CommitAck { offset: ack.offset }),
433                Resolution::Abort => Err(crate::CommitFailed::Aborted),
434            }
435        }
436        // Nothing was acked, so the projection must not advance: a crash may
437        // leave the log ahead of the projection, never the reverse.
438        Err(_) => Err(crate::CommitFailed::LogSealed),
439    };
440    Settled {
441        ticket: entry.ticket,
442        resolved,
443    }
444}
445
446/// Dependencies shared with turn tasks. The log is *not* here: only the actor
447/// loop writes it, so it lives as a local in [`run`].
448pub(crate) struct SharedDeps {
449    pub provider: Arc<dyn Provider>,
450    pub registry: Arc<Registry>,
451    pub rules: Arc<Rules>,
452    /// The session's *current effective* permission mode — separate from
453    /// `rules.mode()` (the startup default) so `SetMode` can flip it without
454    /// reallocating `Rules` (task 4: mode moves, `Rules` stays a plain
455    /// cheap-to-share value). Seeded from `rules.mode()` at session start.
456    mode: AtomicU8,
457    pub sandbox_enforced: bool,
458    pub clock: Arc<dyn Clock>,
459    pub system: Arc<str>,
460    pub cwd: PathBuf,
461    pub config: EngineConfig,
462    pub snapshots: Option<Arc<dyn crate::Snapshotter>>,
463    pub hooks: Option<Arc<dyn crate::hooks::Hooks>>,
464    /// §S1 HookRouter gate (Task 5): the union of event kinds `hooks`
465    /// actually wants dispatched, read by every `hook_gate!` call site as
466    /// one atomic load — never a fresh `Hooks::event_mask` dyn call — to
467    /// decide whether to build ANY per-event work. `NONE` when `hooks` is
468    /// `None`. Prefers `hooks.mask_handle()` — the SAME cell the impl
469    /// narrows on eviction, so a mid-session narrowing is visible here
470    /// immediately (reviewer finding: a one-time snapshot copy would go
471    /// stale the moment the impl's own state changed). Falls back to a
472    /// fresh `Arc` seeded from a single `event_mask()` call when the impl
473    /// has no live handle to offer (the trait's default `None`) — the same
474    /// "compute once at session start" shape `mode` uses for `rules.mode()`.
475    hook_mask: Arc<AtomicU8>,
476    /// The session-scoped `notify` drain (Finding 1 fix) — shared with
477    /// whatever built this session's `SessionHandle`, so the CLI's exit-time
478    /// drain call reaches the exact same detached `Notification` hook tasks
479    /// this actor (and any `question_sink`) spawns.
480    pub notifications: crate::hooks::NotificationDrain,
481    /// The same masker the log's inline path uses (commit-protocol.md
482    /// §Proposal payloads: "there is no second masking policy — only a
483    /// second, cheap, caller") — cloned from `SessionLog`'s own `Arc<Masker>`
484    /// at construction, so proposal build in the turn task masks under
485    /// exactly the rules the actor would have used.
486    masker: Arc<hotl_store::Masker>,
487    /// Monotonic masking-rules epoch (commit-protocol.md §Proposal
488    /// payloads' `rules_epoch` guard). Today masking rules never change
489    /// mid-session, so this is constant for the life of a `SharedDeps` — the
490    /// guard is implemented anyway, ahead of whatever eventually bumps it.
491    rules_epoch: std::sync::atomic::AtomicU32,
492    /// The read side of the published head (commit-protocol.md §Read
493    /// invariant): a turn's sample-boundary refresh. Only a `Receiver` is
494    /// shared — the `Sender` lives in [`run`]'s [`Head`], so the actor stays
495    /// the sole publisher by construction rather than by convention.
496    head_rx: tokio::sync::watch::Receiver<Arc<ProjectionHead>>,
497}
498
499/// `PermissionMode` has no natural discriminant to lean on across an atomic
500/// (and shouldn't grow one just for this) — a tiny, exhaustively-matched
501/// codec keeps the two in lockstep instead.
502fn mode_to_u8(mode: PermissionMode) -> u8 {
503    match mode {
504        PermissionMode::Ask => 0,
505        PermissionMode::Auto => 1,
506        PermissionMode::Plan => 2,
507        PermissionMode::DontAsk => 3,
508    }
509}
510
511fn u8_to_mode(v: u8) -> PermissionMode {
512    match v {
513        1 => PermissionMode::Auto,
514        2 => PermissionMode::Plan,
515        3 => PermissionMode::DontAsk,
516        _ => PermissionMode::Ask,
517    }
518}
519
520impl SharedDeps {
521    fn new(
522        deps: SessionDeps,
523        notifications: crate::hooks::NotificationDrain,
524        head_rx: tokio::sync::watch::Receiver<Arc<ProjectionHead>>,
525    ) -> (Self, SessionLog) {
526        let mode = AtomicU8::new(mode_to_u8(deps.rules.mode()));
527        let hook_mask = deps
528            .hooks
529            .as_ref()
530            .and_then(|h| h.mask_handle())
531            .unwrap_or_else(|| {
532                Arc::new(AtomicU8::new(
533                    deps.hooks
534                        .as_ref()
535                        .map_or(crate::hooks::EventMask::NONE, |h| h.event_mask())
536                        .bits(),
537                ))
538            });
539        // Cloned before `deps.log` moves out below — cheap (an `Arc` bump),
540        // and it's how a turn-side `prepare_payload` call ends up masking
541        // under the exact same rules the log's own inline path uses.
542        let masker = deps.log.masker_handle();
543        let shared = Self {
544            provider: deps.provider,
545            registry: deps.registry,
546            rules: deps.rules,
547            mode,
548            sandbox_enforced: deps.sandbox_enforced,
549            clock: deps.clock,
550            system: deps.system.into(),
551            cwd: deps.cwd,
552            config: deps.config,
553            snapshots: deps.snapshots,
554            hooks: deps.hooks,
555            hook_mask,
556            notifications,
557            masker,
558            rules_epoch: std::sync::atomic::AtomicU32::new(0),
559            head_rx,
560        };
561        (shared, deps.log)
562    }
563
564    /// A turn's read side of the published head — see the `head_rx` field
565    /// doc. Cloned per turn task; `watch::Receiver` clones observe the same
566    /// value, so this grants no second source of truth.
567    pub(crate) fn head(&self) -> tokio::sync::watch::Receiver<Arc<ProjectionHead>> {
568        self.head_rx.clone()
569    }
570
571    /// The live §S1 mask [`crate::hooks::hook_gate!`] branches on — see the
572    /// `hook_mask` field doc.
573    pub(crate) fn hook_mask(&self) -> crate::hooks::EventMask {
574        crate::hooks::mask_of(&self.hook_mask)
575    }
576
577    /// The mode `evaluate` should gate against right now — not necessarily
578    /// `rules.mode()`, which is only ever the startup default.
579    pub(crate) fn effective_mode(&self) -> PermissionMode {
580        u8_to_mode(self.mode.load(Ordering::Relaxed))
581    }
582
583    /// Runtime mode-mutation entry point (`SessionCmd::SetMode`, reachable
584    /// via ACP `session/set_mode` and the TUI `/mode` command). Routes
585    /// through [`hotl_tools::rules::enforced_mode`] — the same coercion
586    /// `Rules::with_mode` applies at startup — so a `security-enforced`
587    /// build can't be flipped to `Auto` mid-session by a client request.
588    /// Returns the mode actually stored (post-coercion) so the caller logs
589    /// the durable `ModeSet` entry with what really took effect, not the
590    /// raw request.
591    fn set_mode(&self, mode: PermissionMode) -> PermissionMode {
592        let mode = hotl_tools::rules::enforced_mode(mode);
593        self.mode.store(mode_to_u8(mode), Ordering::Relaxed);
594        mode
595    }
596
597    /// Commit one entry: forward it to the writer at the `Durable` tier and
598    /// await the ack ("Writer fsyncs, acks with the byte offset" —
599    /// commit-protocol.md §Durability ordering). `false` = the log is sealed,
600    /// and the caller must NOT advance the projection.
601    ///
602    /// INVARIANT: the projection only ever advances after this returns `true`,
603    /// so a crash can leave the log ahead of the projection but never the
604    /// reverse. Enforced by
605    /// `a_writer_death_before_fsync_never_leaves_the_projection_ahead_of_the_log`.
606    ///
607    /// The failure surfaces to the user via the turn outcome, not stderr.
608    ///
609    /// The pipeline is drained first, always: this entry's ack sits *behind*
610    /// everything already forwarded, so projecting it before those would
611    /// invert the ack order the whole design rests on.
612    ///
613    /// The payload is taken **by value** and its item (if any) applied here,
614    /// so the head advances — and publishes — in exactly one place per
615    /// commit path. A caller that appended and then pushed for itself could
616    /// leave the published head one entry behind the projection it names.
617    async fn append(
618        &self,
619        log: &mut SessionLog,
620        pipeline: &mut Pipeline,
621        head: &mut Head,
622        payload: EntryPayload,
623    ) -> bool {
624        pipeline.drain(head, Resolution::Ack).await;
625        let seq = pipeline.next_seq();
626        if log
627            .append_acked(&payload, self.clock.now_ms())
628            .await
629            .is_err()
630        {
631            return false;
632        }
633        if let EntryPayload::Item { item } = payload {
634            head.apply(item);
635        }
636        // `append_acked` only advances the chain on a committed entry, so
637        // this is the id of the line that just landed.
638        if let Some(id) = log.last_id() {
639            head.advance(id.to_string(), seq);
640        }
641        head.publish();
642        true
643    }
644
645    /// The masker turn-side proposal build masks under
646    /// ([`crate::turn`]'s `prepare_entry`) — see the `masker` field doc.
647    pub(crate) fn masker(&self) -> &Arc<hotl_store::Masker> {
648        &self.masker
649    }
650
651    /// The current masking-rules epoch — see the `rules_epoch` field doc.
652    pub(crate) fn rules_epoch(&self) -> u32 {
653        self.rules_epoch.load(Ordering::Relaxed)
654    }
655
656    /// Commit one already-prepared entry: no serialization, no masking here
657    /// (commit-protocol.md §Proposal payloads) — splice and forward only.
658    /// Mirrors `append`'s durable-tier/await-ack shape.
659    async fn append_prepared(
660        &self,
661        log: &mut SessionLog,
662        prepared: hotl_store::PreparedPayload,
663    ) -> bool {
664        log.append_prepared(prepared, self.clock.now_ms())
665            .await
666            .is_ok()
667    }
668
669    /// The `Pipelined` half: mint, splice, forward, and hand the ack channel
670    /// to the actor's FIFO — no await (commit-protocol.md §Durability
671    /// ordering, step 3 answering before step 5).
672    fn forward_prepared(
673        &self,
674        log: &mut SessionLog,
675        prepared: hotl_store::PreparedPayload,
676    ) -> std::io::Result<hotl_store::Forwarded> {
677        log.forward_prepared(prepared, self.clock.now_ms())
678    }
679
680    /// The `Group` half of the same (commit-protocol.md §Causal groups): one
681    /// writer message, one `sync_data`, one ack bearing the group's last
682    /// entry id.
683    fn forward_group(
684        &self,
685        log: &mut SessionLog,
686        group: Vec<hotl_store::PreparedPayload>,
687    ) -> std::io::Result<hotl_store::Forwarded> {
688        log.forward_group(group, self.clock.now_ms())
689    }
690}
691
692pub(crate) async fn run(
693    mut deps: SessionDeps,
694    mut cmd_rx: mpsc::Receiver<SessionCmd>,
695    cmd_tx: mpsc::WeakSender<SessionCmd>,
696    events: mpsc::Sender<EngineEvent>,
697    current_turn: Arc<Mutex<CancellationToken>>,
698    notifications: crate::hooks::NotificationDrain,
699    head_tx: tokio::sync::watch::Sender<Arc<ProjectionHead>>,
700) {
701    // Resumed history is repaired on the way in: a log written by a build that
702    // let a steer land mid-batch would otherwise fail every request forever.
703    //
704    // The `todo_write` checklist (M4/tier-1 gap #3) is actor-owned, ephemeral
705    // session context: it never lives in the durable projection — it is
706    // stitched onto a *read* of the head only (`ProjectionHead::snapshot`),
707    // the same "ephemeral, request-only" shape as the MOIM turn-context
708    // block. A resumed session seeds it from its replayed `Todos` entry
709    // (`SessionDeps::initial_todos`); a fresh session starts empty.
710    let head_rx = head_tx.subscribe();
711    let mut head = Head::new(
712        head_tx,
713        pair_tool_results(std::mem::take(&mut deps.initial_items)),
714        std::mem::take(&mut deps.initial_todos),
715    );
716    let mut running = false;
717    let mut queue: VecDeque<(String, Option<SyntheticReason>)> = VecDeque::new();
718    // Steers that arrived while a turn was live (or a tool batch open),
719    // waiting for a boundary before they can be appended.
720    let mut held_steers: Vec<String> = Vec::new();
721    let (shared, mut log) = SharedDeps::new(deps, notifications, head_rx);
722    let shared = Arc::new(shared);
723    // Usage carried across compaction respawns within one logical turn.
724    let mut carry_usage = TokenUsage::default();
725    let mut compact_streak: u32 = 0;
726    let mut pipeline = Pipeline::default();
727
728    loop {
729        // The oldest pending ack is held out of the FIFO for the duration of
730        // the select, so the ack future borrows a local rather than the
731        // pipeline every command handler also needs. It goes straight back
732        // when a command wins the race — before any handler runs, so a
733        // handler's own drain still sees a whole FIFO.
734        let mut front = pipeline.fifo.pop_front();
735        // Acks are polled first: the projection is what every command reads,
736        // and the FIFO is bounded, so this can never starve the mailbox.
737        let woke = tokio::select! {
738            biased;
739            acked = next_ack(&mut front) => Woke::Ack(acked),
740            cmd = cmd_rx.recv() => Woke::Cmd(cmd),
741        };
742        let cmd = match woke {
743            Woke::Ack(acked) => {
744                let entry = front.expect("the ack arm only runs with a front entry");
745                let stage = entry.stage;
746                let settled = apply_ack(entry, acked, &mut head, Resolution::Ack);
747                // A commit this turn made just landed: that is the boundary a
748                // held steer was waiting for. It is appended BEFORE the
749                // publish below, so a turn's refresh sees the commit and the
750                // steer as one step — which is what turns a steer at the
751                // boundary into a clean adoption miss instead of a race
752                // (commit-protocol.md §Causal groups, the adoption rule).
753                release_steers(
754                    &shared,
755                    &mut log,
756                    &mut head,
757                    &mut pipeline,
758                    &mut held_steers,
759                    Boundary::CommitSettled { stage },
760                )
761                .await;
762                head.publish();
763                settled.resolve();
764                continue;
765            }
766            Woke::Cmd(cmd) => {
767                if let Some(entry) = front {
768                    pipeline.fifo.push_front(entry);
769                }
770                match cmd {
771                    Some(cmd) => cmd,
772                    None => break,
773                }
774            }
775        };
776        match cmd {
777            SessionCmd::Prompt(text) => {
778                running = admit_prompt(
779                    &shared,
780                    &mut log,
781                    &mut head,
782                    &mut pipeline,
783                    &mut queue,
784                    running,
785                    text,
786                    None,
787                    &cmd_tx,
788                    &events,
789                    &current_turn,
790                )
791                .await;
792            }
793            SessionCmd::PromptTagged { text, synthetic } => {
794                running = admit_prompt(
795                    &shared,
796                    &mut log,
797                    &mut head,
798                    &mut pipeline,
799                    &mut queue,
800                    running,
801                    text,
802                    Some(synthetic),
803                    &cmd_tx,
804                    &events,
805                    &current_turn,
806                )
807                .await;
808            }
809            SessionCmd::Continue => {
810                if !running && crate::needs_continuation(head.items()) {
811                    spawn_turn(&shared, &cmd_tx, &events, &current_turn);
812                    running = true;
813                }
814            }
815            SessionCmd::Steer(text) => {
816                admit_steer(
817                    &shared,
818                    &mut log,
819                    &mut head,
820                    &mut pipeline,
821                    &mut held_steers,
822                    running,
823                    text,
824                )
825                .await
826            }
827            SessionCmd::Rename(name) => {
828                let _ = shared
829                    .append(
830                        &mut log,
831                        &mut pipeline,
832                        &mut head,
833                        EntryPayload::Rename { name },
834                    )
835                    .await;
836            }
837            SessionCmd::SetMode(mode) => {
838                // Effective immediately: the atomic, not a rebuilt `Rules`,
839                // is what `evaluate` reads. The durable entry is what lets
840                // `hotl resume` restore it, exactly like `Rename`/name — it
841                // records the post-coercion mode, so a security-enforced
842                // build's log never claims `auto` while it actually ran
843                // `ask`.
844                let mode = shared.set_mode(mode);
845                let _ = shared
846                    .append(
847                        &mut log,
848                        &mut pipeline,
849                        &mut head,
850                        EntryPayload::ModeSet {
851                            mode: mode.as_str().into(),
852                        },
853                    )
854                    .await;
855            }
856            SessionCmd::SetTodos(new_todos) => {
857                head.set_todos(new_todos);
858                let items = (**head.todos()).clone();
859                let _ = shared
860                    .append(
861                        &mut log,
862                        &mut pipeline,
863                        &mut head,
864                        EntryPayload::Todos {
865                            items: items.clone(),
866                        },
867                    )
868                    .await;
869                let _ = events.send(EngineEvent::TodosChanged { items }).await;
870            }
871            SessionCmd::Propose { entries, reply } => {
872                let committed = commit(&shared, &mut log, &mut head, &mut pipeline, entries).await;
873                let _ = reply.send(committed);
874            }
875            SessionCmd::ProposePrepared {
876                proposal,
877                stage,
878                mode,
879                reply,
880            } => {
881                let result = commit_prepared(
882                    &shared,
883                    &mut log,
884                    &mut head,
885                    &mut pipeline,
886                    proposal,
887                    mode,
888                    stage,
889                )
890                .await;
891                // `Sync` applies inline, so the boundary a held steer waits
892                // for is *here*; `Pipelined` reaches it at the ack, in the
893                // loop's FIFO arm above. A stale-epoch reject is not a
894                // boundary at all — nothing was committed and the turn is
895                // about to resend the SAME entries, so releasing here would
896                // land the steer BEFORE the retried assistant blocks it was
897                // held to avoid preceding (72a6f1b).
898                if mode == crate::AckMode::Sync
899                    && !matches!(result, crate::ProposeReply::StaleEpoch)
900                {
901                    release_steers(
902                        &shared,
903                        &mut log,
904                        &mut head,
905                        &mut pipeline,
906                        &mut held_steers,
907                        Boundary::CommitSettled { stage },
908                    )
909                    .await;
910                }
911                let _ = reply.send(result);
912            }
913            SessionCmd::WriteBlob {
914                tool_use_id,
915                content,
916                reply,
917            } => {
918                let result = match log.write_blob_acked(&tool_use_id, &content).await {
919                    Ok(path) => Ok(path.display().to_string()),
920                    Err(_) => Err(content), // hand the content back — never lose it
921                };
922                let _ = reply.send(result);
923            }
924            SessionCmd::TurnFinished { end, usage } => {
925                // The turn is over, so nothing will answer an open batch now.
926                // Close it, then let held steers land before a queued prompt
927                // starts the next turn behind them.
928                // A turn that died mid-sample must not strand its steers.
929                close_open_batch(&shared, &mut log, &mut head, &mut pipeline).await;
930                release_steers(
931                    &shared,
932                    &mut log,
933                    &mut head,
934                    &mut pipeline,
935                    &mut held_steers,
936                    Boundary::TurnEnded,
937                )
938                .await;
939                on_turn_finished(
940                    TurnFinishedCtx {
941                        shared: &shared,
942                        log: &mut log,
943                        head: &mut head,
944                        pipeline: &mut pipeline,
945                        queue: &mut queue,
946                        running: &mut running,
947                        carry_usage: &mut carry_usage,
948                        compact_streak: &mut compact_streak,
949                        cmd_tx: &cmd_tx,
950                        events: &events,
951                        current_turn: &current_turn,
952                    },
953                    end,
954                    usage,
955                )
956                .await;
957            }
958            SessionCmd::BumpRulesEpoch => {
959                shared.rules_epoch.fetch_add(1, Ordering::Relaxed);
960            }
961        }
962    }
963    // SessionEnd (Finding 1 fix): AWAITED, not fire-and-forget — the command
964    // channel closed (every `SessionHandle`/turn task dropped its sender), so
965    // this actor is shutting down for good and nothing needs it responsive
966    // any more. Blocking here (bounded by `call_session_end`'s own timeout)
967    // is what actually guarantees the hook runs to completion: this task is
968    // itself the one `SessionHandle::finish` awaits before the one-shot
969    // CLI's `block_on` drops its runtime, so a detached spawn here would
970    // just move the same race somewhere else.
971    // §S1 HookRouter gate: a masked-off (or hook-less) session skips the
972    // call (and its timeout registration) entirely.
973    crate::hooks::hook_gate!(
974        shared.hooks,
975        shared.hook_mask(),
976        crate::hooks::EventMask::SESSION_END,
977        |hooks| {
978            crate::hooks::call_session_end(hooks).await;
979        },
980        else {}
981    );
982}
983
984/// The mutable session state `on_turn_finished` threads back into the loop.
985struct TurnFinishedCtx<'a> {
986    shared: &'a Arc<SharedDeps>,
987    log: &'a mut SessionLog,
988    head: &'a mut Head,
989    pipeline: &'a mut Pipeline,
990    queue: &'a mut VecDeque<(String, Option<SyntheticReason>)>,
991    running: &'a mut bool,
992    carry_usage: &'a mut TokenUsage,
993    compact_streak: &'a mut u32,
994    cmd_tx: &'a mpsc::WeakSender<SessionCmd>,
995    events: &'a mpsc::Sender<EngineEvent>,
996    current_turn: &'a Arc<Mutex<CancellationToken>>,
997}
998
999/// A turn ended: either report it (and promote the queue) or, on a compaction
1000/// request, fold and respawn the continuation.
1001async fn on_turn_finished(ctx: TurnFinishedCtx<'_>, end: TurnEnd, mut usage: TokenUsage) {
1002    let outcome = match end {
1003        TurnEnd::Outcome(outcome) => Some(outcome),
1004        TurnEnd::Compact { spec, cont } => {
1005            *ctx.carry_usage += usage;
1006            usage = TokenUsage::default();
1007            try_compact(
1008                ctx.shared,
1009                ctx.log,
1010                ctx.head,
1011                ctx.pipeline,
1012                ctx.compact_streak,
1013                spec,
1014                cont,
1015                ctx.cmd_tx,
1016                ctx.events,
1017                ctx.current_turn,
1018            )
1019            .await
1020        }
1021    };
1022    if let Some(outcome) = outcome {
1023        *ctx.compact_streak = 0;
1024        let mut total = usage;
1025        total += std::mem::take(ctx.carry_usage);
1026        *ctx.running = end_turn(
1027            ctx.shared,
1028            ctx.log,
1029            ctx.head,
1030            ctx.pipeline,
1031            ctx.queue,
1032            outcome,
1033            total,
1034            ctx.cmd_tx,
1035            ctx.events,
1036            ctx.current_turn,
1037        )
1038        .await;
1039    }
1040}
1041
1042/// One compaction attempt on behalf of a turn that hit the threshold: fold,
1043/// announce, respawn the continuation. `Some(outcome)` means compaction can't
1044/// proceed (streak cap, nothing to fold, sealed log) and the turn ends.
1045#[allow(clippy::too_many_arguments)]
1046async fn try_compact(
1047    shared: &Arc<SharedDeps>,
1048    log: &mut SessionLog,
1049    head: &mut Head,
1050    pipeline: &mut Pipeline,
1051    compact_streak: &mut u32,
1052    spec: Option<crate::SpecDigest>,
1053    cont: Box<crate::TurnContinuation>,
1054    cmd_tx: &mpsc::WeakSender<SessionCmd>,
1055    events: &mpsc::Sender<EngineEvent>,
1056    current_turn: &Arc<Mutex<CancellationToken>>,
1057) -> Option<Outcome> {
1058    // INVARIANT: the streak counts folds with no intervening completed sample
1059    // — a long, productive turn folds as often as it needs to, and only a
1060    // fold-the-digest spiral (no progress between folds) trips the cap.
1061    // Enforced by `three_folds_with_progress_do_not_exhaust_the_streak`.
1062    if cont.samples_since_compact > 0 {
1063        *compact_streak = 0;
1064    }
1065    *compact_streak += 1;
1066    // The token interrupt() cancels right now belongs to the turn that just
1067    // ended with `Compact`. Honor it through the whole compaction window —
1068    // race the inline summarize against it, and hand the *same* token to the
1069    // continuation — so an interrupt anywhere in the window ends the logical
1070    // turn instead of being silently swallowed.
1071    let cancel = current_turn
1072        .lock()
1073        .unwrap_or_else(std::sync::PoisonError::into_inner)
1074        .clone();
1075    let compacted = if *compact_streak > MAX_COMPACT_STREAK {
1076        Err("context window exhausted — compaction can no longer make room".into())
1077    } else {
1078        tokio::select! {
1079            biased;
1080            _ = cancel.cancelled() => return Some(Outcome::Cancelled),
1081            compacted = compact(shared, log, head, pipeline, spec) => compacted,
1082        }
1083    };
1084    match compacted {
1085        Ok(degraded) => {
1086            let _ = events.send(EngineEvent::Compacted { degraded }).await;
1087            if cancel.is_cancelled() {
1088                return Some(Outcome::Cancelled);
1089            }
1090            respawn_turn(shared, cmd_tx, events, cancel, *cont);
1091            None // still running: same logical turn continues
1092        }
1093        Err(message) => Some(Outcome::Error { message }),
1094    }
1095}
1096
1097/// Annotate + report a finished turn, then promote the next queued prompt.
1098/// Returns whether a turn is (still) running.
1099#[allow(clippy::too_many_arguments)]
1100async fn end_turn(
1101    shared: &Arc<SharedDeps>,
1102    log: &mut SessionLog,
1103    head: &mut Head,
1104    pipeline: &mut Pipeline,
1105    queue: &mut VecDeque<(String, Option<SyntheticReason>)>,
1106    outcome: Outcome,
1107    usage: TokenUsage,
1108    cmd_tx: &mpsc::WeakSender<SessionCmd>,
1109    events: &mpsc::Sender<EngineEvent>,
1110    current_turn: &Arc<Mutex<CancellationToken>>,
1111) -> bool {
1112    annotate(shared, log, head, pipeline, &outcome).await;
1113    // Notification: the turn completed — fire-and-forget, computed before
1114    // `outcome` moves into the event below. §S1 HookRouter gate: masked-off
1115    // (or hook-less) skips even the `outcome_detail` computation.
1116    crate::hooks::hook_gate!(
1117        shared.hooks,
1118        shared.hook_mask(),
1119        crate::hooks::EventMask::NOTIFICATION,
1120        |hooks| {
1121            crate::hooks::notify(
1122                hooks,
1123                &shared.notifications,
1124                crate::hooks::NotificationKind::Done,
1125                outcome_detail(&outcome),
1126            );
1127        },
1128        else {}
1129    );
1130    let _ = events.send(EngineEvent::TurnDone { outcome, usage }).await;
1131    match queue.pop_front() {
1132        Some((next, synthetic)) => {
1133            start_turn(
1134                shared,
1135                log,
1136                head,
1137                pipeline,
1138                next,
1139                synthetic,
1140                cmd_tx,
1141                events,
1142                current_turn,
1143            )
1144            .await
1145        }
1146        None => {
1147            // Notification: nothing queued behind it — the session goes
1148            // idle awaiting the next prompt.
1149            crate::hooks::hook_gate!(
1150                shared.hooks,
1151                shared.hook_mask(),
1152                crate::hooks::EventMask::NOTIFICATION,
1153                |hooks| {
1154                    crate::hooks::notify(
1155                        hooks,
1156                        &shared.notifications,
1157                        crate::hooks::NotificationKind::Idle,
1158                        "awaiting a prompt",
1159                    );
1160                },
1161                else {}
1162            );
1163            false
1164        }
1165    }
1166}
1167
1168/// A short human-readable rendering of an outcome for `Notification` hooks
1169/// (a `hotl watch`/desktop consumer, not a protocol payload — free-form text
1170/// is fine).
1171fn outcome_detail(outcome: &Outcome) -> String {
1172    match outcome {
1173        Outcome::Done { text } => text.clone(),
1174        other => format!("{other:?}"),
1175    }
1176}
1177
1178/// Append `text` to `dst`, keeping the *head* and disclosing any truncation
1179/// in-band. Dropping a user's words silently is worse than telling the model
1180/// some were dropped, and an unbounded buffer is worse than both.
1181/// INVARIANT: neither the prompt queue nor the held-steer buffer grows without
1182/// bound. Enforced by `folding_bounds_the_buffer_and_discloses_the_truncation`.
1183fn fold_into(dst: &mut String, text: &str, max_bytes: usize) {
1184    if dst.ends_with(FOLD_MARK) {
1185        dst.truncate(dst.len() - FOLD_MARK.len());
1186    }
1187    if !dst.is_empty() {
1188        dst.push_str("\n\n");
1189    }
1190    dst.push_str(text);
1191    if dst.len() > max_bytes {
1192        let mut end = max_bytes;
1193        while !dst.is_char_boundary(end) {
1194            end -= 1;
1195        }
1196        dst.truncate(end);
1197        dst.push_str(FOLD_MARK);
1198    }
1199}
1200
1201/// Whether the projection is mid-batch: it ends on an assistant turn whose
1202/// tool calls have no results yet. Both APIs require those results to be the
1203/// very next message, so nothing else may be appended in this window.
1204fn awaiting_tool_results(items: &[Item]) -> bool {
1205    matches!(
1206        items.last(),
1207        Some(Item::Assistant { blocks }) if !hotl_types::assistant_tool_uses(blocks).is_empty()
1208    )
1209}
1210
1211/// Durable admission on arrival; projection advances only after the append
1212/// (commit-protocol §durability). Linear-log M1 records the steer as a
1213/// tagged user item; the `steer_admission` entry kind arrives with M3b's tree.
1214///
1215/// Steering mid-batch is the normal case — the human reacts while a tool runs
1216/// — and that is precisely the window where appending would strand the batch's
1217/// results away from the calls they answer. Such a steer is held instead and
1218/// released once the results land. The model sees it at the same moment either
1219/// way: the next sample happens after the batch closes.
1220///
1221/// `running` is the same hold one step earlier, and one step wider: a steer
1222/// that arrives while a request is in flight would otherwise commit *ahead*
1223/// of the assistant item that request is about to produce. A live turn is
1224/// always either mid-sample or mid-batch, so holding for its whole life
1225/// costs nothing — [`release_steers`] runs at every boundary the actor
1226/// creates, which is where the steer would have landed anyway.
1227/// INVARIANT: a steer never precedes an assistant item that could not have seen
1228/// it. Enforced by `a_mid_stream_steer_commits_after_the_reply_it_did_not_see`
1229/// and `a_steer_inside_the_boundary_group_lands_after_the_assistant_item`.
1230async fn admit_steer(
1231    shared: &SharedDeps,
1232    log: &mut SessionLog,
1233    head: &mut Head,
1234    pipeline: &mut Pipeline,
1235    held: &mut Vec<String>,
1236    running: bool,
1237    text: String,
1238) {
1239    if running || awaiting_tool_results(head.items()) {
1240        // Past the byte cap, coalesce into the last held steer rather than grow
1241        // without bound — every entry is committed at once on release.
1242        let total: usize = held.iter().map(String::len).sum();
1243        match held.last_mut() {
1244            Some(last) if total >= HELD_BYTES_MAX => fold_into(last, &text, HELD_BYTES_MAX),
1245            _ => held.push(text),
1246        }
1247        return;
1248    }
1249    append_steer(shared, log, head, pipeline, text).await;
1250}
1251
1252async fn append_steer(
1253    shared: &SharedDeps,
1254    log: &mut SessionLog,
1255    head: &mut Head,
1256    pipeline: &mut Pipeline,
1257    text: String,
1258) {
1259    // The other half of the held-steer rule, as a structural guard: a steer
1260    // that split a tool batch would strand the results from the calls they
1261    // answer, and every API rejects that history outright.
1262    debug_assert!(
1263        !awaiting_tool_results(head.items()),
1264        "a steer must never land while a tool batch is open"
1265    );
1266    shared
1267        .append(
1268            log,
1269            pipeline,
1270            head,
1271            EntryPayload::Item {
1272                item: Item::User {
1273                    text,
1274                    synthetic: Some(SyntheticReason::Steer),
1275                },
1276            },
1277        )
1278        .await;
1279}
1280
1281/// Append the steers that were waiting on a sample or a batch, oldest first,
1282/// once the reply has landed and the pairing is closed.
1283///
1284/// `at` carries the two halves of the held-steer rule's proof, and both are
1285/// checked here rather than argued in a comment:
1286///
1287/// - **mid-sample** — [`Boundary::is_between_samples`]: the commit that
1288///   created this boundary closed its sample, so the model's reply is
1289///   already durable. Assert-only, because no site can violate it today; it
1290///   is aimed squarely at §Commit granularity's intra-sample `BlockEnd`
1291///   pipelining, which would otherwise reintroduce 72a6f1b silently.
1292/// - **mid-batch** — `awaiting_tool_results`: a steer that split an open
1293///   tool batch would strand the results from the calls they answer.
1294async fn release_steers(
1295    shared: &SharedDeps,
1296    log: &mut SessionLog,
1297    head: &mut Head,
1298    pipeline: &mut Pipeline,
1299    held: &mut Vec<String>,
1300    at: Boundary,
1301) {
1302    debug_assert!(
1303        at.is_between_samples(),
1304        "a held steer may only land between samples: releasing behind an in-sample \
1305         commit would put it ahead of the assistant item the model is still \
1306         producing — the inversion 72a6f1b fixed ({at:?})"
1307    );
1308    if held.is_empty() || awaiting_tool_results(head.items()) {
1309        return;
1310    }
1311    for text in std::mem::take(held) {
1312        append_steer(shared, log, head, pipeline, text).await;
1313    }
1314}
1315
1316/// Answer a batch nothing will answer any more. A turn that dies before it can
1317/// report leaves calls hanging; the next request would be rejected for the
1318/// missing results, so the protocol gets completed here instead.
1319async fn close_open_batch(
1320    shared: &SharedDeps,
1321    log: &mut SessionLog,
1322    head: &mut Head,
1323    pipeline: &mut Pipeline,
1324) {
1325    let Some(Item::Assistant { blocks }) = head.items().last() else {
1326        return;
1327    };
1328    let uses = hotl_types::assistant_tool_uses(blocks);
1329    if uses.is_empty() {
1330        return;
1331    }
1332    let payload = EntryPayload::Item {
1333        item: Item::ToolResults {
1334            results: uses
1335                .iter()
1336                .map(|tu| hotl_types::ToolResultItem {
1337                    tool_use_id: tu.id.clone(),
1338                    content: "Not executed (the turn ended first).".into(),
1339                    is_error: true,
1340                })
1341                .collect(),
1342        },
1343    };
1344    shared.append(log, pipeline, head, payload).await;
1345}
1346
1347/// Restore tool_use/tool_result adjacency in history written before steers
1348/// were held. Items that landed in the gap move to just after the results
1349/// they interrupted — the order the model would have seen anyway, since the
1350/// gap only ever opened while a batch was still running. Nothing is dropped.
1351pub(crate) fn pair_tool_results(items: Vec<Item>) -> Vec<Item> {
1352    let mut out: Vec<Item> = Vec::with_capacity(items.len());
1353    // Items pulled out of an open batch, waiting to go back in behind it.
1354    let mut stranded: Vec<Item> = Vec::new();
1355    for item in items {
1356        if !awaiting_tool_results(&out) && stranded.is_empty() {
1357            out.push(item);
1358            continue;
1359        }
1360        match item {
1361            Item::ToolResults { .. } => {
1362                out.push(item);
1363                out.append(&mut stranded);
1364            }
1365            // Another assistant turn means no results were ever coming; the
1366            // gap was not an open batch, so leave the order as it was found.
1367            Item::Assistant { .. } => {
1368                out.append(&mut stranded);
1369                out.push(item);
1370            }
1371            _ => stranded.push(item),
1372        }
1373    }
1374    out.append(&mut stranded);
1375    out
1376}
1377
1378/// Commit a proposal: append each entry durably, then project it.
1379async fn commit(
1380    shared: &SharedDeps,
1381    log: &mut SessionLog,
1382    head: &mut Head,
1383    pipeline: &mut Pipeline,
1384    entries: Vec<EntryPayload>,
1385) -> bool {
1386    for payload in entries {
1387        if !shared.append(log, pipeline, head, payload).await {
1388            return false;
1389        }
1390    }
1391    true
1392}
1393
1394/// Commit a proposal of already-prepared entries (commit-protocol.md
1395/// §Proposal payloads): no serialization, no masking here — see `commit`
1396/// above for the actor-built-entries twin this mirrors. The rules-epoch
1397/// guard is checked once, for the whole batch, before anything is
1398/// appended: every entry in one turn-task proposal is built from one
1399/// `rules_epoch` reading (`crate::turn::Turn::propose`), so a mixed batch
1400/// would itself be a bug upstream, not something to partially commit around.
1401async fn commit_prepared(
1402    shared: &SharedDeps,
1403    log: &mut SessionLog,
1404    head: &mut Head,
1405    pipeline: &mut Pipeline,
1406    proposal: crate::EntryProposal,
1407    mode: crate::AckMode,
1408    stage: crate::SampleStage,
1409) -> crate::ProposeReply {
1410    let current_epoch = shared.rules_epoch();
1411    // "Predates" (commit-protocol.md §Proposal payloads), not merely
1412    // "differs": epoch only ever advances, and the actor is its sole owner,
1413    // so a proposal can never legitimately carry an epoch newer than the
1414    // actor's own — but an equal-or-newer stamp is never rejected either,
1415    // matching the spec's literal wording rather than a stricter `!=`.
1416    if proposal
1417        .entries()
1418        .iter()
1419        .any(|e| e.payload().rules_epoch() < current_epoch)
1420    {
1421        return crate::ProposeReply::StaleEpoch;
1422    }
1423    if proposal.is_empty() {
1424        return crate::ProposeReply::Committed;
1425    }
1426    match mode {
1427        // The pre-revision path, entry at a time. It is the arm the
1428        // revision's own counter assertions are measured against ("the same
1429        // session driven through `Sync` and `Pipelined` modes produces the
1430        // same normalized transcript", and the `Completed` boundary's 2→1
1431        // fsync), so it deliberately does NOT collapse a group into one
1432        // writer message — that collapse is exactly what is being measured.
1433        crate::AckMode::Sync => {
1434            // This proposal's acks sit behind everything already forwarded,
1435            // so those have to land (and project) first.
1436            pipeline.drain(head, Resolution::Ack).await;
1437            for entry in into_entries(proposal) {
1438                let (payload, item) = entry.into_parts();
1439                let seq = pipeline.next_seq();
1440                if !shared.append_prepared(log, payload).await {
1441                    return crate::ProposeReply::Sealed;
1442                }
1443                if let Some(item) = item {
1444                    head.apply(item);
1445                }
1446                if let Some(id) = log.last_id() {
1447                    head.advance(id.to_string(), seq);
1448                }
1449                head.publish();
1450            }
1451            crate::ProposeReply::Committed
1452        }
1453        // Validate → mint → assign seq → forward → answer, with no await in
1454        // between (commit-protocol.md §Durability ordering: `Pipelined`
1455        // splits step 5). One ticket per proposal, bearing the last entry's
1456        // id and seq.
1457        crate::AckMode::Pipelined => match proposal {
1458            crate::EntryProposal::Single(entry) => {
1459                let (payload, item) = entry.into_parts();
1460                let seq = pipeline.next_seq();
1461                match shared.forward_prepared(log, payload) {
1462                    Ok(forwarded) => crate::ProposeReply::Ticket(push_pending(
1463                        pipeline,
1464                        forwarded,
1465                        item.into_iter().collect(),
1466                        seq,
1467                        stage,
1468                    )),
1469                    // Sealed before the entry was even minted. Anything
1470                    // already forwarded is canon and still lands; the turn
1471                    // learns the log is gone from this reply.
1472                    Err(_) => crate::ProposeReply::Sealed,
1473                }
1474            }
1475            // One causal event: one writer message, one `sync_data`, one
1476            // ack, one ticket (commit-protocol.md §Causal groups). `seq` is
1477            // assigned per entry in group order, so the run stays contiguous
1478            // and `seq` order still equals disk order; the ticket bears the
1479            // last one, which is the epoch the head reaches when the group
1480            // is applied.
1481            crate::EntryProposal::Group(entries) => {
1482                let mut payloads = Vec::with_capacity(entries.len());
1483                let mut items = Vec::with_capacity(entries.len());
1484                let mut seq = 0;
1485                for entry in entries {
1486                    let (payload, item) = entry.into_parts();
1487                    seq = pipeline.next_seq();
1488                    payloads.push(payload);
1489                    items.extend(item);
1490                }
1491                match shared.forward_group(log, payloads) {
1492                    Ok(forwarded) => crate::ProposeReply::Ticket(push_pending(
1493                        pipeline, forwarded, items, seq, stage,
1494                    )),
1495                    Err(_) => crate::ProposeReply::Sealed,
1496                }
1497            }
1498        },
1499    }
1500}
1501
1502/// Consume a proposal into its entries — the `Sync` arm's shared tail, which
1503/// treats both shapes identically (see that arm's comment).
1504fn into_entries(proposal: crate::EntryProposal) -> Vec<crate::PreparedEntry> {
1505    match proposal {
1506        crate::EntryProposal::Single(entry) => vec![entry],
1507        crate::EntryProposal::Group(entries) => entries,
1508    }
1509}
1510
1511/// Record one forwarded unit in the actor's FIFO and mint its ticket.
1512fn push_pending(
1513    pipeline: &mut Pipeline,
1514    forwarded: hotl_store::Forwarded,
1515    items: Vec<Item>,
1516    seq: u64,
1517    stage: crate::SampleStage,
1518) -> crate::CommitTicket {
1519    let (tx, rx) = tokio::sync::oneshot::channel();
1520    let ticket = crate::CommitTicket {
1521        id: forwarded.id.clone(),
1522        seq,
1523        ack: rx,
1524    };
1525    pipeline.fifo.push_back(PendingAck {
1526        ack: forwarded.ack,
1527        items,
1528        id: forwarded.id,
1529        seq,
1530        stage,
1531        ticket: Some(tx),
1532    });
1533    ticket
1534}
1535
1536/// Non-Done outcomes leave a durable annotation in the log.
1537async fn annotate(
1538    shared: &SharedDeps,
1539    log: &mut SessionLog,
1540    head: &mut Head,
1541    pipeline: &mut Pipeline,
1542    outcome: &Outcome,
1543) {
1544    let reason = match outcome {
1545        Outcome::Cancelled => Some("user interrupt".to_string()),
1546        Outcome::TurnLimit => Some(format!("max_turns ({}) reached", shared.config.max_turns)),
1547        Outcome::DoomLoop { pattern } => Some(format!("doom loop: {pattern}")),
1548        Outcome::ToolFailureBudget { tool } => Some(format!("tool failure budget: {tool}")),
1549        Outcome::Error { message } => Some(format!("error: {message}")),
1550        Outcome::Done { .. } | Outcome::Refused => None,
1551    };
1552    if let Some(reason) = reason {
1553        shared
1554            .append(log, pipeline, head, EntryPayload::Cancelled { reason })
1555            .await;
1556    }
1557}
1558
1559/// Compact the projection (M2): fold `[prefix..kept_from)` into a typed
1560/// digest via the fast model, floor to a placeholder if summarize fails, and
1561/// re-point the projection with an appended `compaction` entry — the log
1562/// keeps everything. A digest the turn speculatively precomputed folds
1563/// instantly; otherwise the summarize runs inline in the actor (no turn is
1564/// in flight, and admission blocking during that call is the serialization
1565/// working as designed).
1566async fn compact(
1567    shared: &SharedDeps,
1568    log: &mut SessionLog,
1569    head: &mut Head,
1570    pipeline: &mut Pipeline,
1571    spec: Option<crate::SpecDigest>,
1572) -> Result<bool, String> {
1573    // Drain-before-BUILD-and-mint (commit-protocol.md §conflict table, the
1574    // Abort arm's steps 3→5). Both halves are load-bearing and fail
1575    // differently: minting after the drain keeps the fold chained onto the
1576    // drained leaf, and *building* after it is what makes the digest's
1577    // content and visibility set cover every entry the drain landed — a fold
1578    // computed against the pre-drain projection would leave a pipeline of
1579    // assistant blocks visible below a fold that never saw them. Every
1580    // `items` read below therefore happens after this line, and the tickets
1581    // resolve `Aborted`: the bytes are canon, the turn's claim on them is
1582    // not.
1583    pipeline.drain(head, Resolution::Abort).await;
1584    // Speculative hit: the digest was planned against this same projection
1585    // lineage (it only appends between folds), so its indices still name the
1586    // same items. Reset mode folds a wider span than the speculation covered,
1587    // so it never uses one; the turn doesn't speculate in reset mode.
1588    if !shared.config.compaction_reset {
1589        if let Some(spec) = spec {
1590            if spec.prefix_end < spec.kept_from && spec.kept_from <= head.items().len() {
1591                let digest = vec![compaction::digest_item(&spec.text)];
1592                let payload = EntryPayload::Compaction {
1593                    digest: digest.clone(),
1594                    prefix_end: spec.prefix_end,
1595                    kept_from: spec.kept_from,
1596                    degraded: false,
1597                };
1598                if !shared.append(log, pipeline, head, payload).await {
1599                    return Err("session log is sealed".into());
1600                }
1601                let plan = compaction::Plan {
1602                    prefix_end: spec.prefix_end,
1603                    kept_from: spec.kept_from,
1604                };
1605                head.repoint(compaction::apply(head.items(), &plan, &digest));
1606                return Ok(false);
1607            }
1608        }
1609    }
1610    let tail_budget = (shared.config.context_window as f64 * TAIL_RATIO) as u64;
1611    let Some(plan) = compaction::plan(head.items(), tail_budget) else {
1612        return Err("context window exhausted — nothing left to compact".into());
1613    };
1614    // Reset mode (#9): fold *everything* after the preserved prefix into the
1615    // digest and keep no verbatim tail — the continuation is a fresh slate.
1616    // In-place mode (default): fold [prefix..kept_from) and keep the tail.
1617    let plan = if shared.config.compaction_reset {
1618        compaction::Plan {
1619            prefix_end: plan.prefix_end,
1620            kept_from: head.items().len(),
1621        }
1622    } else {
1623        plan
1624    };
1625    let snapshot = Arc::clone(head.items());
1626    let folded = &snapshot[plan.prefix_end..plan.kept_from];
1627    // Timeout or two failed attempts: the floor digest keeps the session moving
1628    // rather than ending the turn on housekeeping.
1629    let (digest, degraded) =
1630        match summarize_bounded(summarize(shared, folded), COMPACT_SUMMARIZE_TIMEOUT).await {
1631            Some(text) => (vec![compaction::digest_item(&text)], false),
1632            None => (vec![compaction::floor_digest()], true),
1633        };
1634    let payload = EntryPayload::Compaction {
1635        digest: digest.clone(),
1636        prefix_end: plan.prefix_end,
1637        kept_from: plan.kept_from,
1638        degraded,
1639    };
1640    if !shared.append(log, pipeline, head, payload).await {
1641        return Err("session log is sealed".into());
1642    }
1643    head.repoint(compaction::apply(head.items(), &plan, &digest));
1644    Ok(degraded)
1645}
1646
1647/// The inline fold's summarize under a wall-clock bound. `None` on either a
1648/// failed summarize or an exceeded bound — both degrade to the floor digest,
1649/// which is why one return type covers them. Split out from [`compact`] so the
1650/// bound is testable without a session behind it.
1651async fn summarize_bounded(
1652    fut: impl std::future::Future<Output = Option<String>>,
1653    bound: std::time::Duration,
1654) -> Option<String> {
1655    tokio::time::timeout(bound, fut).await.ok().flatten()
1656}
1657
1658pub(crate) async fn summarize(shared: &SharedDeps, folded: &[Item]) -> Option<String> {
1659    let model = shared
1660        .config
1661        .fast_model
1662        .clone()
1663        .unwrap_or_else(|| shared.config.model.clone());
1664    let request = SamplingRequest {
1665        model,
1666        max_tokens: SUMMARIZE_MAX_TOKENS,
1667        system: compaction::SUMMARIZE_SYSTEM.into(),
1668        items: Arc::new(vec![Item::User {
1669            text: compaction::summarize_prompt(folded),
1670            synthetic: None,
1671        }]),
1672        // A summarize is a one-shot call against a prompt that is different
1673        // every time: nothing to cache, and nothing ephemeral to append.
1674        ephemeral_tail: empty_tail(),
1675        tools: Vec::new().into(),
1676        thinking: false,
1677        cache: hotl_provider::CachePolicy::Off,
1678        turn_context: None,
1679    };
1680    for _ in 0..SUMMARIZE_ATTEMPTS {
1681        let mut stream = shared.provider.stream(request.clone());
1682        let mut text: Option<String> = None;
1683        while let Some(event) = stream.next().await {
1684            match event {
1685                Ok(StreamEvent::Completed { blocks, .. }) => text = Some(assistant_text(&blocks)),
1686                Ok(_) => {}
1687                Err(_) => {
1688                    text = None;
1689                    // Finish the stream rather than abandoning it
1690                    // mid-flight: an error event is terminal for the
1691                    // provider's own generator, so this is one more poll,
1692                    // and it is what marks the attempt as really consumed
1693                    // rather than left in flight.
1694                    crate::turn::drain_to_end(&mut stream).await;
1695                    break;
1696                }
1697            }
1698        }
1699        if let Some(t) = text.filter(|t| !t.trim().is_empty()) {
1700            return Some(t);
1701        }
1702    }
1703    None
1704}
1705
1706/// Start a turn now, or queue the prompt if one is running (one-at-a-time
1707/// promotion). Carries an optional provenance tag (T2).
1708#[allow(clippy::too_many_arguments)]
1709async fn admit_prompt(
1710    shared: &Arc<SharedDeps>,
1711    log: &mut SessionLog,
1712    head: &mut Head,
1713    pipeline: &mut Pipeline,
1714    queue: &mut VecDeque<(String, Option<SyntheticReason>)>,
1715    running: bool,
1716    text: String,
1717    synthetic: Option<SyntheticReason>,
1718    cmd_tx: &mpsc::WeakSender<SessionCmd>,
1719    events: &mpsc::Sender<EngineEvent>,
1720    current_turn: &Arc<Mutex<CancellationToken>>,
1721) -> bool {
1722    if running {
1723        let full = queue.len() >= QUEUE_MAX;
1724        match queue.back_mut() {
1725            // Past the cap the prompt is folded into the last pending entry
1726            // rather than pushed: memory is bounded and nothing vanishes
1727            // without the model being told. It *was* absorbed, so the event is
1728            // still `PromptQueued`. A folded prompt inherits the tag of the
1729            // entry it joins — only reachable past QUEUE_MAX pending prompts,
1730            // where provenance has already stopped being per-prompt.
1731            Some(last) if full => fold_into(&mut last.0, &text, HELD_BYTES_MAX),
1732            _ => queue.push_back((text, synthetic)),
1733        }
1734        let _ = events.send(EngineEvent::PromptQueued).await;
1735        return true;
1736    }
1737    start_turn(
1738        shared,
1739        log,
1740        head,
1741        pipeline,
1742        text,
1743        synthetic,
1744        cmd_tx,
1745        events,
1746        current_turn,
1747    )
1748    .await
1749}
1750
1751#[allow(clippy::too_many_arguments)]
1752async fn start_turn(
1753    shared: &Arc<SharedDeps>,
1754    log: &mut SessionLog,
1755    head: &mut Head,
1756    pipeline: &mut Pipeline,
1757    text: String,
1758    synthetic: Option<SyntheticReason>,
1759    cmd_tx: &mpsc::WeakSender<SessionCmd>,
1760    events: &mpsc::Sender<EngineEvent>,
1761    current_turn: &Arc<Mutex<CancellationToken>>,
1762) -> bool {
1763    // Captured before `text` moves into the committed item — `UserPromptSubmit`
1764    // hooks (tier-1 gap #7) see the prompt exactly as submitted.
1765    let prompt_for_hooks = text.clone();
1766    let payload = EntryPayload::Item {
1767        item: Item::User { text, synthetic },
1768    };
1769    if !shared.append(log, pipeline, head, payload).await {
1770        let _ = events
1771            .send(EngineEvent::TurnDone {
1772                outcome: Outcome::Error {
1773                    message: "session log is sealed".into(),
1774                },
1775                usage: TokenUsage::default(),
1776            })
1777            .await;
1778        return false;
1779    }
1780    // UserPromptSubmit: a hook's `additionalContext` becomes one tagged
1781    // `SystemReminder` user item committed right after the prompt it answers
1782    // — never a system-prompt edit (prefix-cache stability), the one
1783    // reminder chokepoint every injection site shares. Best-effort: a sealed
1784    // log here doesn't fail the turn (the prompt itself already landed).
1785    // §S1 HookRouter gate: a masked-off (or hook-less) session skips the
1786    // call (and its timeout registration) entirely.
1787    crate::hooks::hook_gate!(
1788        shared.hooks,
1789        shared.hook_mask(),
1790        crate::hooks::EventMask::USER_PROMPT,
1791        |hooks| {
1792            if let Some(context) = crate::hooks::call_user_prompt(hooks, &prompt_for_hooks).await {
1793                let reminder = EntryPayload::Item {
1794                    item: Item::User {
1795                        text: format!("<system-reminder>{context}</system-reminder>"),
1796                        synthetic: Some(SyntheticReason::SystemReminder),
1797                    },
1798                };
1799                shared.append(log, pipeline, head, reminder).await;
1800            }
1801        },
1802        else {}
1803    );
1804    spawn_turn(shared, cmd_tx, events, current_turn);
1805    true
1806}
1807
1808/// Spawn a fresh turn task against the current projection, installing a new
1809/// interrupt token for it.
1810fn spawn_turn(
1811    shared: &Arc<SharedDeps>,
1812    cmd_tx: &mpsc::WeakSender<SessionCmd>,
1813    events: &mpsc::Sender<EngineEvent>,
1814    current_turn: &Arc<Mutex<CancellationToken>>,
1815) {
1816    let token = CancellationToken::new();
1817    *current_turn
1818        .lock()
1819        .unwrap_or_else(std::sync::PoisonError::into_inner) = token.clone();
1820    // A fresh prompt is a fresh turn: no counters carry in.
1821    respawn_turn(
1822        shared,
1823        cmd_tx,
1824        events,
1825        token,
1826        crate::TurnContinuation::default(),
1827    );
1828}
1829
1830/// Spawn a turn task under an existing token, seeded with `cont`. Compaction
1831/// respawns use this directly (no new user item, same logical turn — the
1832/// interrupt token carries over so a cancel during the fold still lands, and
1833/// `cont` carries the per-turn safety counters so `max_turns`, the doom
1834/// detector and the failure budget bound the *whole* turn).
1835/// INVARIANT: a compaction respawn continues the same logical turn, counters
1836/// included. Enforced by `max_turns_is_enforced_across_a_compaction`.
1837fn respawn_turn(
1838    shared: &Arc<SharedDeps>,
1839    cmd_tx: &mpsc::WeakSender<SessionCmd>,
1840    events: &mpsc::Sender<EngineEvent>,
1841    token: CancellationToken,
1842    cont: crate::TurnContinuation,
1843) {
1844    // The turn task holds a strong sender for its lifetime; a failed upgrade
1845    // means the handle is gone and there is nobody left to run for.
1846    let Some(cmd_tx) = cmd_tx.upgrade() else {
1847        return;
1848    };
1849    let supervisor_tx = cmd_tx.clone();
1850    let handle = tokio::spawn(turn::run(
1851        shared.clone(),
1852        cmd_tx,
1853        events.clone(),
1854        token,
1855        cont,
1856    ));
1857    // INVARIANT: exactly one `TurnFinished` per spawned turn, panic included —
1858    // `running` is cleared and the prompt queue drains on every exit path.
1859    // Enforced by `a_panicking_turn_reports_an_error_and_the_session_keeps_working`.
1860    // The supervisor's strong sender drops the moment the turn task ends, so it
1861    // never keeps the command channel (or the actor) alive on its own.
1862    tokio::spawn(async move {
1863        if handle.await.is_err() {
1864            let _ = supervisor_tx
1865                .send(SessionCmd::TurnFinished {
1866                    end: TurnEnd::Outcome(Outcome::Error {
1867                        message: "the turn ended unexpectedly (internal error). \
1868                                  The session is intact — retry, or rephrase the request."
1869                            .into(),
1870                    }),
1871                    usage: TokenUsage::default(),
1872                })
1873                .await;
1874        }
1875    });
1876}
1877
1878#[cfg(test)]
1879mod tests {
1880    use super::COMPACT_SUMMARIZE_TIMEOUT;
1881    use super::{
1882        awaiting_tool_results, commit_prepared, compact, fold_into, pair_tool_results,
1883        release_steers, summarize_bounded, Pipeline, Resolution, SharedDeps,
1884    };
1885    use hotl_store::SessionLog;
1886    use hotl_types::{EntryPayload, Item, SyntheticReason, ToolResultItem};
1887    use serde_json::json;
1888    use std::sync::atomic::Ordering;
1889    use std::sync::Arc;
1890
1891    /// T3-4. The paused clock is legal here: this drives `summarize_bounded`
1892    /// alone, with no actor and therefore no writer-thread ack for the clock to
1893    /// auto-advance past (0011's standing constraint). `compact` calls exactly
1894    /// this function, so the bound under test is the shipped one.
1895    #[tokio::test(start_paused = true)]
1896    async fn a_hung_inline_summarize_degrades_instead_of_wedging() {
1897        let hung = summarize_bounded(std::future::pending(), COMPACT_SUMMARIZE_TIMEOUT).await;
1898        assert!(
1899            hung.is_none(),
1900            "a hung summarize must degrade to the floor digest, not stall the command loop"
1901        );
1902        let answered = summarize_bounded(
1903            std::future::ready(Some("DIGEST".to_string())),
1904            COMPACT_SUMMARIZE_TIMEOUT,
1905        )
1906        .await;
1907        assert_eq!(
1908            answered.as_deref(),
1909            Some("DIGEST"),
1910            "a summarize that answers inside the bound must still be used"
1911        );
1912    }
1913
1914    fn user(text: &str) -> Item {
1915        Item::User {
1916            text: text.into(),
1917            synthetic: Some(SyntheticReason::Steer),
1918        }
1919    }
1920
1921    fn calls(id: &str) -> Item {
1922        Item::Assistant {
1923            blocks: vec![json!({"type": "tool_use", "id": id, "name": "read", "input": {}})],
1924        }
1925    }
1926
1927    fn says(text: &str) -> Item {
1928        Item::Assistant {
1929            blocks: vec![json!({"type": "text", "text": text})],
1930        }
1931    }
1932
1933    fn answers(id: &str) -> Item {
1934        Item::ToolResults {
1935            results: vec![ToolResultItem {
1936                tool_use_id: id.into(),
1937                content: "ok".into(),
1938                is_error: false,
1939            }],
1940        }
1941    }
1942
1943    /// T3-7: dropping a user's words silently is worse than telling the model
1944    /// some were dropped, and an unbounded buffer is worse than both.
1945    #[test]
1946    fn folding_bounds_the_buffer_and_discloses_the_truncation() {
1947        let mut dst = String::from("first");
1948        fold_into(&mut dst, &"x".repeat(10_000), 128);
1949        assert!(
1950            dst.len() <= 128 + 64,
1951            "fold must bound the entry, got {}",
1952            dst.len()
1953        );
1954        assert!(
1955            dst.starts_with("first"),
1956            "the oldest text is kept, not clobbered"
1957        );
1958        assert!(
1959            dst.contains("truncated"),
1960            "truncation must be disclosed in-band"
1961        );
1962        // Idempotent under repeated folding — 1000 folds stay bounded, and the
1963        // marker never stacks.
1964        for _ in 0..1_000 {
1965            fold_into(&mut dst, "more", 128);
1966        }
1967        assert!(dst.len() <= 128 + 64, "got {}", dst.len());
1968        assert!(dst.starts_with("first"));
1969    }
1970
1971    #[test]
1972    fn folding_under_the_cap_keeps_every_word() {
1973        let mut dst = String::from("first");
1974        fold_into(&mut dst, "second", 1_024);
1975        assert!(dst.contains("first") && dst.contains("second"));
1976        assert!(!dst.contains("truncated"), "nothing was dropped: {dst}");
1977    }
1978
1979    #[test]
1980    fn only_unanswered_tool_calls_hold_the_batch_open() {
1981        assert!(awaiting_tool_results(&[calls("t1")]));
1982        assert!(!awaiting_tool_results(&[says("hello")]));
1983        assert!(!awaiting_tool_results(&[calls("t1"), answers("t1")]));
1984        assert!(!awaiting_tool_results(&[]));
1985    }
1986
1987    #[test]
1988    fn a_stranded_steer_moves_behind_the_results_it_interrupted() {
1989        let repaired = pair_tool_results(vec![calls("t1"), user("wait"), answers("t1")]);
1990        assert_eq!(repaired, vec![calls("t1"), answers("t1"), user("wait")]);
1991    }
1992
1993    #[test]
1994    fn several_stranded_items_keep_their_order() {
1995        let repaired = pair_tool_results(vec![
1996            calls("t1"),
1997            user("one"),
1998            user("two"),
1999            answers("t1"),
2000            says("done"),
2001        ]);
2002        assert_eq!(
2003            repaired,
2004            vec![
2005                calls("t1"),
2006                answers("t1"),
2007                user("one"),
2008                user("two"),
2009                says("done"),
2010            ]
2011        );
2012    }
2013
2014    #[test]
2015    fn already_paired_history_is_left_alone() {
2016        let good = vec![
2017            user("start"),
2018            calls("t1"),
2019            answers("t1"),
2020            user("next"),
2021            says("done"),
2022        ];
2023        assert_eq!(pair_tool_results(good.clone()), good);
2024    }
2025
2026    #[test]
2027    fn a_gap_with_no_results_coming_is_not_reordered() {
2028        // Nothing answered t1, so there is no batch to move anything behind —
2029        // reordering here would only invent a new history.
2030        let orphaned = vec![calls("t1"), user("never answered"), says("moved on")];
2031        assert_eq!(pair_tool_results(orphaned.clone()), orphaned);
2032    }
2033
2034    #[test]
2035    fn a_trailing_gap_survives_repair() {
2036        let trailing = vec![calls("t1"), user("last word")];
2037        assert_eq!(pair_tool_results(trailing.clone()), trailing);
2038    }
2039
2040    // --- Task 8 (S2a PreparedPayload) --------------------------------
2041
2042    fn test_deps(dir: &std::path::Path, log: hotl_store::SessionLog) -> crate::SessionDeps {
2043        crate::SessionDeps {
2044            provider: Arc::new(hotl_provider::ScriptedProvider::new(vec![])),
2045            registry: Arc::new(hotl_tools::Registry::builtin()),
2046            rules: Arc::new(hotl_tools::rules::Rules::default()),
2047            sandbox_enforced: false,
2048            clock: Arc::new(hotl_platform::SystemClock),
2049            log,
2050            system: "sys".into(),
2051            cwd: dir.to_path_buf(),
2052            snapshots: None,
2053            hooks: None,
2054            initial_items: Vec::new(),
2055            initial_todos: Vec::new(),
2056            config: crate::EngineConfig::default(),
2057        }
2058    }
2059
2060    /// A `SharedDeps` + its log + a live `Head`. `commit_prepared`/`compact`
2061    /// take the head, so the published projection is exercised by every test
2062    /// below rather than simulated.
2063    fn test_shared(dir: &std::path::Path) -> (Arc<SharedDeps>, SessionLog, super::Head) {
2064        let log = SessionLog::create(dir, "m", None, hotl_store::Masker::empty(), 0).expect("log");
2065        let (head_tx, head_rx) = super::head_channel();
2066        let head = super::Head::new(head_tx, Vec::new(), Vec::new());
2067        let (shared, log) = SharedDeps::new(
2068            test_deps(dir, log),
2069            crate::hooks::NotificationDrain::new(),
2070            head_rx,
2071        );
2072        (Arc::new(shared), log, head)
2073    }
2074
2075    /// commit-protocol.md §Proposal payloads' rules_epoch guard: "the actor
2076    /// rejects a payload whose epoch predates the current masking rules" —
2077    /// tested directly against `commit_prepared`, the actor's real commit
2078    /// path for prepared entries, not just a hand-extracted predicate.
2079    #[tokio::test]
2080    async fn commit_prepared_rejects_an_entry_whose_epoch_predates_current_and_commits_nothing() {
2081        let dir = tempfile::tempdir().unwrap();
2082        let (shared, mut log, mut head) = test_shared(dir.path());
2083        let before = std::fs::read_to_string(log.path()).unwrap();
2084
2085        // A genuinely OLDER epoch, not merely a different one: bump the
2086        // actor's real epoch, then stamp the entry with what used to be
2087        // current — exactly the shape a real reject-then-retry produces
2088        // (commit-protocol.md §Proposal payloads: "rejects a payload whose
2089        // epoch PREDATES the current masking rules").
2090        let old_epoch = shared.rules_epoch();
2091        shared.rules_epoch.fetch_add(1, Ordering::Relaxed);
2092        assert!(shared.rules_epoch() > old_epoch);
2093
2094        let payload = hotl_types::EntryPayload::Usage {
2095            usage: hotl_types::TokenUsage::default(),
2096        };
2097        let prepared =
2098            hotl_store::prepare_payload(&payload, &hotl_store::Masker::empty(), old_epoch)
2099                .expect("prepare");
2100        let entries = vec![crate::PreparedEntry::new(prepared, None)];
2101
2102        let result = commit_prepared(
2103            &shared,
2104            &mut log,
2105            &mut head,
2106            &mut Pipeline::default(),
2107            crate::EntryProposal::of(entries),
2108            crate::AckMode::Sync,
2109            crate::SampleStage::AtBoundary,
2110        )
2111        .await;
2112        assert!(matches!(result, crate::ProposeReply::StaleEpoch));
2113        assert!(
2114            head.items().is_empty(),
2115            "a stale proposal must not touch the projection"
2116        );
2117        let after = std::fs::read_to_string(log.path()).unwrap();
2118        assert_eq!(before, after, "a stale proposal must not reach the log");
2119    }
2120
2121    /// The check is "predates", not "differs from": an entry stamped with an
2122    /// epoch that is not older than current — including one newer than the
2123    /// actor has ever advanced to, which can't happen in production since
2124    /// the actor is the epoch's sole owner, but pins the direction the guard
2125    /// actually checks — is accepted, not rejected.
2126    #[tokio::test]
2127    async fn commit_prepared_accepts_an_entry_whose_epoch_is_not_older_than_current() {
2128        let dir = tempfile::tempdir().unwrap();
2129        let (shared, mut log, mut head) = test_shared(dir.path());
2130
2131        let newer_epoch = shared.rules_epoch() + 1;
2132        let payload = hotl_types::EntryPayload::Usage {
2133            usage: hotl_types::TokenUsage::default(),
2134        };
2135        let prepared =
2136            hotl_store::prepare_payload(&payload, &hotl_store::Masker::empty(), newer_epoch)
2137                .expect("prepare");
2138        let entries = vec![crate::PreparedEntry::new(prepared, None)];
2139
2140        let result = commit_prepared(
2141            &shared,
2142            &mut log,
2143            &mut head,
2144            &mut Pipeline::default(),
2145            crate::EntryProposal::of(entries),
2146            crate::AckMode::Sync,
2147            crate::SampleStage::AtBoundary,
2148        )
2149        .await;
2150        assert!(matches!(result, crate::ProposeReply::Committed));
2151    }
2152
2153    // --- Task 9 (S2b pipelined commits) ------------------------------
2154
2155    fn prepared(shared: &SharedDeps, item: Item) -> crate::PreparedEntry {
2156        let payload = EntryPayload::Item { item: item.clone() };
2157        let prepared = hotl_store::prepare_payload(
2158            &payload,
2159            &hotl_store::Masker::empty(),
2160            shared.rules_epoch(),
2161        )
2162        .expect("prepare");
2163        crate::PreparedEntry::new(prepared, Some(item))
2164    }
2165
2166    /// commit-protocol.md §Durability ordering: `Pipelined` splits step 5.
2167    /// The actor answers with a ticket the moment it forwards, and the
2168    /// projection does NOT advance until the writer acks.
2169    #[tokio::test]
2170    async fn a_pipelined_proposal_answers_with_a_ticket_before_the_projection_moves() {
2171        let dir = tempfile::tempdir().unwrap();
2172        let (shared, mut log, mut head) = test_shared(dir.path());
2173        let mut pipeline = Pipeline::default();
2174
2175        let reply = commit_prepared(
2176            &shared,
2177            &mut log,
2178            &mut head,
2179            &mut pipeline,
2180            crate::EntryProposal::of(vec![prepared(&shared, user("hi"))]),
2181            crate::AckMode::Pipelined,
2182            crate::SampleStage::AtBoundary,
2183        )
2184        .await;
2185        let crate::ProposeReply::Ticket(ticket) = reply else {
2186            panic!("Pipelined must answer with a ticket, got {reply:?}")
2187        };
2188        assert_eq!(ticket.seq, 1, "seq is assigned at validation, eagerly");
2189        assert!(!ticket.id.is_empty(), "so is the ulid");
2190        assert!(
2191            head.items().is_empty(),
2192            "the projection advances only on ack, never on forward"
2193        );
2194
2195        pipeline.drain(&mut head, Resolution::Ack).await;
2196        assert_eq!(head.items().len(), 1, "…and it advances when the ack lands");
2197        let ack = ticket
2198            .ack
2199            .await
2200            .expect("the actor resolves the ticket")
2201            .expect("committed");
2202        assert!(ack.offset > 0, "the ticket carries the byte offset");
2203    }
2204
2205    /// "acks arrive in order (one writer, one FIFO channel) and the
2206    /// projection advances in that order" — three proposals in, three items
2207    /// out, same order, with strictly increasing seq and offsets.
2208    #[tokio::test]
2209    async fn the_pipeline_advances_the_projection_in_fifo_order() {
2210        let dir = tempfile::tempdir().unwrap();
2211        let (shared, mut log, mut head) = test_shared(dir.path());
2212        let mut pipeline = Pipeline::default();
2213
2214        let mut tickets = Vec::new();
2215        for text in ["one", "two", "three"] {
2216            let reply = commit_prepared(
2217                &shared,
2218                &mut log,
2219                &mut head,
2220                &mut pipeline,
2221                crate::EntryProposal::of(vec![prepared(&shared, user(text))]),
2222                crate::AckMode::Pipelined,
2223                crate::SampleStage::AtBoundary,
2224            )
2225            .await;
2226            let crate::ProposeReply::Ticket(ticket) = reply else {
2227                panic!("expected a ticket")
2228            };
2229            tickets.push(ticket);
2230        }
2231        assert!(head.items().is_empty());
2232
2233        pipeline.drain(&mut head, Resolution::Ack).await;
2234        assert_eq!(
2235            head.items().as_slice(),
2236            [user("one"), user("two"), user("three")].as_slice()
2237        );
2238        let mut last = 0;
2239        for (i, ticket) in tickets.into_iter().enumerate() {
2240            assert_eq!(ticket.seq, i as u64 + 1);
2241            let ack = ticket.ack.await.expect("resolved").expect("committed");
2242            assert!(ack.offset > last, "offsets follow disk order");
2243            last = ack.offset;
2244        }
2245    }
2246
2247    /// The pipelined half of matrix case 4's invariant, at the seam that
2248    /// decides it: an ack that never comes leaves the projection exactly
2249    /// where it was, and the ticket says `LogSealed` rather than naming an
2250    /// offset for bytes nobody synced.
2251    #[tokio::test]
2252    async fn an_unacked_pipelined_entry_never_advances_the_projection() {
2253        let dir = tempfile::tempdir().unwrap();
2254        let (shared, mut log, mut head) = test_shared(dir.path());
2255        let mut pipeline = Pipeline::default();
2256        log.inject_fault(hotl_store::WriteFault::DropAckBeforeFsync);
2257
2258        let reply = commit_prepared(
2259            &shared,
2260            &mut log,
2261            &mut head,
2262            &mut pipeline,
2263            crate::EntryProposal::of(vec![prepared(&shared, user("doomed"))]),
2264            crate::AckMode::Pipelined,
2265            crate::SampleStage::AtBoundary,
2266        )
2267        .await;
2268        let crate::ProposeReply::Ticket(ticket) = reply else {
2269            panic!("expected a ticket")
2270        };
2271
2272        pipeline.drain(&mut head, Resolution::Ack).await;
2273        assert!(
2274            head.items().is_empty(),
2275            "a crash may leave the log ahead of the projection, never the reverse"
2276        );
2277        assert_eq!(
2278            ticket.ack.await.expect("resolved"),
2279            Err(crate::CommitFailed::LogSealed)
2280        );
2281    }
2282
2283    /// §Ordering authority: `seq` is "global commit order across the whole
2284    /// session", not a count of the pipelined subset. Every commit the actor
2285    /// makes advances it — a `Sync` proposal and an actor-originated inline
2286    /// append included — or the watch predicate S2c is built on
2287    /// (`epoch >= my_ack_seq`, where `epoch` is the seq of the newest entry
2288    /// applied to the published head) is comparing two different counters.
2289    #[tokio::test]
2290    async fn seq_is_the_session_wide_commit_order_not_the_pipelined_subset() {
2291        let dir = tempfile::tempdir().unwrap();
2292        let (shared, mut log, mut head) = test_shared(dir.path());
2293        let mut pipeline = Pipeline::default();
2294
2295        // A Sync proposal…
2296        commit_prepared(
2297            &shared,
2298            &mut log,
2299            &mut head,
2300            &mut pipeline,
2301            crate::EntryProposal::of(vec![prepared(&shared, user("sync"))]),
2302            crate::AckMode::Sync,
2303            crate::SampleStage::AtBoundary,
2304        )
2305        .await;
2306        // …and an actor-originated inline append.
2307        assert!(
2308            shared
2309                .append(
2310                    &mut log,
2311                    &mut pipeline,
2312                    &mut head,
2313                    EntryPayload::Rename {
2314                        name: "inline".into()
2315                    },
2316                )
2317                .await
2318        );
2319
2320        let reply = commit_prepared(
2321            &shared,
2322            &mut log,
2323            &mut head,
2324            &mut pipeline,
2325            crate::EntryProposal::of(vec![prepared(&shared, user("pipelined"))]),
2326            crate::AckMode::Pipelined,
2327            crate::SampleStage::AtBoundary,
2328        )
2329        .await;
2330        let crate::ProposeReply::Ticket(ticket) = reply else {
2331            panic!("expected a ticket")
2332        };
2333        assert_eq!(
2334            ticket.seq, 3,
2335            "the third commit of the session carries seq 3, not seq 1"
2336        );
2337    }
2338
2339    /// commit-protocol.md test matrix case 7 + the conflict table's Abort
2340    /// arm, steps (3)→(5): the fold drains the FIFO first, so it *builds*
2341    /// against a projection that already contains every entry the drain
2342    /// landed, and only then mints — chaining the compaction entry onto the
2343    /// drained leaf. The spec's digest is deliberately one the pre-drain
2344    /// projection could not have produced (`kept_from` past its length), so
2345    /// a fold that built before the drain would silently skip it.
2346    #[tokio::test]
2347    async fn a_compaction_drains_the_pipeline_before_it_builds_and_mints() {
2348        let dir = tempfile::tempdir().unwrap();
2349        let (shared, mut log, mut head) = test_shared(dir.path());
2350        let mut pipeline = Pipeline::default();
2351
2352        let mut tickets = Vec::new();
2353        for text in ["first", "second"] {
2354            let reply = commit_prepared(
2355                &shared,
2356                &mut log,
2357                &mut head,
2358                &mut pipeline,
2359                crate::EntryProposal::of(vec![prepared(&shared, user(text))]),
2360                crate::AckMode::Pipelined,
2361                crate::SampleStage::AtBoundary,
2362            )
2363            .await;
2364            let crate::ProposeReply::Ticket(ticket) = reply else {
2365                panic!("expected a ticket")
2366            };
2367            tickets.push(ticket);
2368        }
2369        assert!(
2370            head.items().is_empty(),
2371            "two entries forwarded, none projected yet"
2372        );
2373
2374        let spec = crate::SpecDigest {
2375            prefix_end: 0,
2376            kept_from: 2,
2377            text: "folded".into(),
2378        };
2379        let degraded = compact(&shared, &mut log, &mut head, &mut pipeline, Some(spec))
2380            .await
2381            .expect("the fold must see the drained projection");
2382        assert!(!degraded);
2383
2384        for ticket in tickets {
2385            assert_eq!(
2386                ticket.ack.await.expect("resolved"),
2387                Err(crate::CommitFailed::Aborted),
2388                "an aborted turn loses its claim on the log, never the bytes"
2389            );
2390        }
2391
2392        // The bytes are canon and the aborting entry chains onto them: the
2393        // compaction entry's parent is the last entry the drain landed.
2394        let entries: Vec<hotl_types::Entry> = std::fs::read_to_string(log.path())
2395            .unwrap()
2396            .lines()
2397            .map(|l| serde_json::from_str(l).expect("entry"))
2398            .collect();
2399        let last = entries.len() - 1;
2400        assert!(
2401            matches!(entries[last].payload, EntryPayload::Compaction { .. }),
2402            "the fold is minted last: {:?}",
2403            entries[last].payload
2404        );
2405        assert_eq!(
2406            entries[last].parent_id.as_deref(),
2407            Some(entries[last - 1].id.as_str()),
2408            "the aborting entry chains onto the drained leaf"
2409        );
2410    }
2411
2412    // --- Task 10 (S2c held-steer boundary proof) ---------------------
2413
2414    /// The mid-sample half of the held-steer rule, as a *checked* argument.
2415    ///
2416    /// `release_steers` rests on the fact that a turn commits nothing
2417    /// between granting itself a snapshot and the `Completed` group that
2418    /// closes the sample — so every ack the actor settles is genuinely
2419    /// between samples. No shipped site can violate that, which is exactly
2420    /// why it needs an assertion rather than a comment: the spec's own
2421    /// intra-sample `BlockEnd` pipelining (§Commit granularity) would create
2422    /// the first violator, and a steer released behind one lands ahead of
2423    /// the assistant item the model is still producing — 72a6f1b, silently
2424    /// back.
2425    ///
2426    /// This drives the guard with the declaration such a site would have to
2427    /// make.
2428    #[tokio::test]
2429    #[should_panic(expected = "a held steer may only land between samples")]
2430    async fn an_in_sample_commit_is_not_a_boundary_a_held_steer_may_land_at() {
2431        let dir = tempfile::tempdir().unwrap();
2432        let (shared, mut log, mut head) = test_shared(dir.path());
2433        let mut held = vec!["hold me".to_string()];
2434        release_steers(
2435            &shared,
2436            &mut log,
2437            &mut head,
2438            &mut Pipeline::default(),
2439            &mut held,
2440            super::Boundary::CommitSettled {
2441                stage: crate::SampleStage::InSample,
2442            },
2443        )
2444        .await;
2445    }
2446
2447    /// …and the boundary every shipped site actually declares does release.
2448    #[tokio::test]
2449    async fn a_commit_that_closed_its_sample_releases_the_steer_it_held() {
2450        let dir = tempfile::tempdir().unwrap();
2451        let (shared, mut log, mut head) = test_shared(dir.path());
2452        let mut held = vec!["hold me".to_string()];
2453        release_steers(
2454            &shared,
2455            &mut log,
2456            &mut head,
2457            &mut Pipeline::default(),
2458            &mut held,
2459            super::Boundary::CommitSettled {
2460                stage: crate::SampleStage::AtBoundary,
2461            },
2462        )
2463        .await;
2464        assert!(held.is_empty(), "the steer must have landed");
2465        assert_eq!(head.items().len(), 1, "…and reached the projection");
2466    }
2467
2468    #[tokio::test]
2469    async fn commit_prepared_commits_a_fresh_entry_and_updates_the_projection() {
2470        let dir = tempfile::tempdir().unwrap();
2471        let (shared, mut log, mut head) = test_shared(dir.path());
2472
2473        let epoch = shared.rules_epoch();
2474        let payload = EntryPayload::Item { item: user("hi") };
2475        let prepared = hotl_store::prepare_payload(&payload, &hotl_store::Masker::empty(), epoch)
2476            .expect("prepare");
2477        let entries = vec![crate::PreparedEntry::new(prepared, Some(user("hi")))];
2478
2479        let result = commit_prepared(
2480            &shared,
2481            &mut log,
2482            &mut head,
2483            &mut Pipeline::default(),
2484            crate::EntryProposal::of(entries),
2485            crate::AckMode::Sync,
2486            crate::SampleStage::AtBoundary,
2487        )
2488        .await;
2489        assert!(matches!(result, crate::ProposeReply::Committed));
2490        assert_eq!(
2491            head.items().len(),
2492            1,
2493            "a fresh proposal must reach the projection"
2494        );
2495
2496        let replayed = hotl_store::replay(log.path()).expect("replay");
2497        assert_eq!(
2498            replayed.items.len(),
2499            1,
2500            "and the disk, chained after the header"
2501        );
2502    }
2503}