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 ¤t_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 ¤t_turn,
806 )
807 .await;
808 }
809 SessionCmd::Continue => {
810 if !running && crate::needs_continuation(head.items()) {
811 spawn_turn(&shared, &cmd_tx, &events, ¤t_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: ¤t_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}