Skip to main content

objectiveai_cli/db/logs/
writer.rs

1//! `LogWriter<C>` — postgres log writer fronted by an mpsc sender.
2//!
3//! Architecturally:
4//!
5//! - The constructor (`write_agent_completion` etc.) spawns a tokio
6//!   task that owns the [`LogWriterState`] and the
7//!   `mpsc::UnboundedReceiver<C>`. It returns the [`LogWriter`]
8//!   handle plus a [`tokio::sync::oneshot::Receiver<String>`] that
9//!   fires the first time the writer learns the stream's primary
10//!   `response_id`.
11//! - [`LogWriter::write`] is **synchronous** — it just hands the
12//!   chunk to the listener task via `UnboundedSender::send`. The
13//!   caller's chunk-yield hot path stays off the DB write critical
14//!   path.
15//! - The listener task `.push()`-folds every chunk into one
16//!   stream-wide accumulator and runs the persistence logic
17//!   ([`LogWriterState::apply_chunk`]) against that cumulative
18//!   aggregate — not a per-batch slice. Draining the queue per loop
19//!   iteration only collapses how often the pass runs. Folding is
20//!   correct because each tier's chunk is a cumulative roll-up of
21//!   state — `push` folds the later chunk's deltas into the earlier
22//!   one's accumulators (`AgentCompletionChunk::push` /
23//!   `VectorCompletionChunk::push` / `FunctionExecutionChunk::push`) —
24//!   so a row whose fields stream across several wire chunks (a tool
25//!   call's id/name/arguments, streamed content text) is always
26//!   persisted from its complete body, never a partial fragment.
27//! - [`LogWriter::finalize`] consumes the writer by value, drops the
28//!   sender, and `.await`s the JoinHandle. By the time it returns,
29//!   both invariants hold: the channel is empty (sender dropped →
30//!   `recv()` returned `None` only after every queued chunk was
31//!   consumed) and the task's future has fully completed (no
32//!   in-flight row-bucket joins or blob writes).
33//!
34//! Persistence pass (run per drain batch against the cumulative
35//! accumulator):
36//!
37//! 1. **First pass**: capture the accumulator's `response_id`, INSERT
38//!    the request blob (no `agent_instance_hierarchy` on the blob —
39//!    that linkage lives in `objectiveai.messages`).
40//! 2. **Every pass**: walk `chunk_rows(acc)` over the cumulative
41//!    aggregate, gate each yielded [`RowValue`] through the shadow
42//!    (Skip path is pure-memory — unchanged rows cost nothing), bucket
43//!    the survivors by `agent_instance_hierarchy`. For every agent the
44//!    writer hasn't seen yet in this stream's lifetime, prepend a
45//!    `objectiveai.messages` row that registers the request blob in
46//!    that agent's history.
47//! 3. **Per-bucket execution**: rows within one agent's bucket fire
48//!    sequentially (so the per-agent ORDER BY `"index"` matches the
49//!    iterator's order). All buckets fire concurrently via
50//!    `try_join_all`.
51//!
52//! The response blob is written separately, exactly once, by
53//! `listener_loop` after the last chunk — from the same cumulative
54//! accumulator, so blob and rows can never disagree.
55
56use std::collections::{HashMap, HashSet};
57use std::future::Future;
58use std::marker::PhantomData;
59use std::sync::Arc;
60use std::pin::Pin;
61
62use objectiveai_sdk::agent::completions::request::AgentCompletionCreateParams;
63use objectiveai_sdk::agent::completions::response::streaming::{
64    AgentCompletionChunk, AgentCompletionIds,
65};
66use objectiveai_sdk::functions::executions::request::FunctionExecutionCreateParams;
67use objectiveai_sdk::functions::executions::response::streaming::FunctionExecutionChunk;
68use objectiveai_sdk::vector::completions::request::VectorCompletionCreateParams;
69use objectiveai_sdk::vector::completions::response::streaming::VectorCompletionChunk;
70use serde::Serialize;
71use tokio::sync::{Mutex, mpsc, oneshot, watch};
72use tokio::task::JoinHandle;
73
74use crate::db::Pool;
75
76use super::row::{RowValue, WriterItem, WriterItems};
77use super::rows::{
78    agent_completion_chunk_rows, function_execution_chunk_rows,
79    vector_completion_chunk_rows,
80};
81use super::shadow::{Shadow, WriteOp};
82use super::write::{
83    Tier, insert_request_blob, insert_request_messages_row, insert_response_blob,
84    update_agent_token_usage, write_value,
85};
86
87pub trait WriterChunk {
88    fn primary_id(&self) -> &str;
89    /// The spawned agent's AIH for the agent tier — where the writer
90    /// derives request_message rows + the agent_ref from the REQUEST
91    /// (the response chunk no longer carries them). `None` for the
92    /// vector/function tiers, which surface these through the chunk
93    /// stream instead.
94    fn agent_tier_aih(&self) -> Option<&str> {
95        None
96    }
97}
98
99impl WriterChunk for AgentCompletionChunk {
100    fn primary_id(&self) -> &str {
101        self.id.as_str()
102    }
103    fn agent_tier_aih(&self) -> Option<&str> {
104        Some(self.agent_instance_hierarchy.as_str())
105    }
106}
107impl WriterChunk for VectorCompletionChunk {
108    fn primary_id(&self) -> &str {
109        self.id.as_str()
110    }
111}
112impl WriterChunk for FunctionExecutionChunk {
113    fn primary_id(&self) -> &str {
114        self.id.as_str()
115    }
116}
117
118/// Chunks that can surface `(AIH, definition source)` pairs for the
119/// `objectiveai.agent_refs` registry: every nested agent completion
120/// carrying `agent_inline` (each completion's FIRST chunk). Per the
121/// registry's rule, `agent_remote` present → the remote wins;
122/// otherwise the inline spec itself.
123pub trait ChunkAgentRefs {
124    fn collect_agent_refs(
125        &self,
126        out: &mut Vec<(String, crate::db::agent_refs::AgentRefValue)>,
127    );
128}
129
130impl ChunkAgentRefs for AgentCompletionChunk {
131    fn collect_agent_refs(
132        &self,
133        _out: &mut Vec<(String, crate::db::agent_refs::AgentRefValue)>,
134    ) {
135        // The inner agent chunk no longer carries `agent_inline` (it
136        // moved to the vector wrapper). Agent-tier refs are derived
137        // from the REQUEST by the writer, not from the response chunk;
138        // this impl is a no-op (used only when the chunk appears as a
139        // standalone agent completion or a function reasoning summary).
140    }
141}
142
143impl ChunkAgentRefs for VectorCompletionChunk {
144    fn collect_agent_refs(
145        &self,
146        out: &mut Vec<(String, crate::db::agent_refs::AgentRefValue)>,
147    ) {
148        // `agent_inline` now lives on the per-agent vector wrapper, not
149        // the inner agent chunk. Pair it with the inner chunk's
150        // AIH / remote.
151        for completion in &self.completions {
152            if let Some(inline) = &completion.agent_inline {
153                let value = match &completion.inner.agent_remote {
154                    Some(remote) => {
155                        crate::db::agent_refs::AgentRefValue::remote(remote)
156                    }
157                    None => crate::db::agent_refs::AgentRefValue::inline(inline),
158                };
159                if let Some(value) = value {
160                    out.push((
161                        completion.inner.agent_instance_hierarchy.clone(),
162                        value,
163                    ));
164                }
165            }
166        }
167    }
168}
169
170impl ChunkAgentRefs for FunctionExecutionChunk {
171    fn collect_agent_refs(
172        &self,
173        out: &mut Vec<(String, crate::db::agent_refs::AgentRefValue)>,
174    ) {
175        use objectiveai_sdk::functions::executions::response::streaming::TaskChunk;
176        for task in &self.tasks {
177            match task {
178                TaskChunk::FunctionExecution(wrapper) => {
179                    wrapper.inner.collect_agent_refs(out);
180                }
181                TaskChunk::VectorCompletion(wrapper) => {
182                    wrapper.inner.collect_agent_refs(out);
183                }
184            }
185        }
186        if let Some(reasoning) = &self.reasoning {
187            reasoning.inner.collect_agent_refs(out);
188        }
189    }
190}
191
192/// CLI-side wrapper exposing the SDK's intrinsic `push(&mut self,
193/// other: &Self)` method via a uniform trait. Each impl simply
194/// delegates to the chunk type's inherent method — the SDK already
195/// guarantees `push` is a correct accumulator for the tier's
196/// cumulative-state semantics.
197pub trait ChunkPush {
198    fn push(&mut self, other: &Self);
199}
200
201impl ChunkPush for AgentCompletionChunk {
202    fn push(&mut self, other: &Self) {
203        AgentCompletionChunk::push(self, other);
204    }
205}
206impl ChunkPush for VectorCompletionChunk {
207    fn push(&mut self, other: &Self) {
208        VectorCompletionChunk::push(self, other);
209    }
210}
211impl ChunkPush for FunctionExecutionChunk {
212    fn push(&mut self, other: &Self) {
213        FunctionExecutionChunk::push(self, other);
214    }
215}
216
217/// Background-task-fronted log writer.
218///
219/// Construction (via `write_agent_completion` etc.) spawns a tokio
220/// task that owns the per-stream [`LogWriterState`]. The handle here
221/// is a thin sender + JoinHandle pair.
222pub struct LogWriter<C> {
223    tx: mpsc::UnboundedSender<C>,
224    handle: JoinHandle<Result<(), crate::error::Error>>,
225    /// Toggled `false` → `true` by the listener task once it has
226    /// completed a single successful `apply_chunk`. Powers
227    /// [`LogWriter::written_once`] (sync peek) and
228    /// [`LogWriter::wait_written_once`] (async wait).
229    written_rx: watch::Receiver<bool>,
230    /// Mid-stream failure handoff slot shared with the listener —
231    /// see [`finalize_with_stream_error`](Self::finalize_with_stream_error).
232    stream_error: Arc<Mutex<Option<serde_json::Value>>>,
233    _chunk: PhantomData<fn() -> C>,
234}
235
236impl<C> LogWriter<C> {
237    /// Hand off one chunk to the listener task. Returns
238    /// `Err(Error::Instance(_))` only when the listener has already
239    /// exited — typically because an earlier DB write failed. The
240    /// caller should treat that error the same way it would treat an
241    /// upstream stream error: stop reading, surface upward.
242    pub fn write(&self, chunk: C) -> Result<(), crate::error::Error> {
243        self.tx
244            .send(chunk)
245            .map_err(|_| crate::error::Error::Instance(
246                "log writer task has exited (earlier write failed)".to_string(),
247            ))
248    }
249
250    /// Sync peek: has the listener completed at least one successful
251    /// `apply_chunk` batch? Flips `false → true` exactly once,
252    /// immediately after the first batch's write completes and
253    /// before the listener parks on the next `recv`.
254    pub fn written_once(&self) -> bool {
255        *self.written_rx.borrow()
256    }
257
258    /// Async wait that resolves once the listener has completed its
259    /// first successful `apply_chunk` batch. Returns immediately if
260    /// that already happened. Errors only if the listener task
261    /// exited before its first successful write (DB error on the
262    /// very first batch).
263    pub async fn wait_written_once(&self) -> Result<(), crate::error::Error> {
264        let mut rx = self.written_rx.clone();
265        rx.wait_for(|b| *b)
266            .await
267            .map(|_| ())
268            .map_err(|_| crate::error::Error::Instance(
269                "log writer task exited before completing its first write".to_string(),
270            ))
271    }
272
273    /// Consume the writer. Drops the sender (signaling EOF to the
274    /// listener) and awaits the task. Returns only once:
275    ///
276    /// - the channel is empty: `recv()` returns `None` only after the
277    ///   listener has drained every queued chunk, AND
278    /// - no work is in flight: the task's future has fully completed,
279    ///   so no row-bucket joins or blob writes remain pending.
280    ///
281    /// Surfaces the first DB error the task encountered, if any.
282    pub async fn finalize(self) -> Result<(), crate::error::Error> {
283        let LogWriter { tx, handle, .. } = self;
284        drop(tx);
285        match handle.await {
286            Ok(inner) => inner,
287            Err(e) => Err(crate::error::Error::Instance(
288                format!("log writer task: {e}"),
289            )),
290        }
291    }
292
293    /// [`finalize`](Self::finalize), carrying the caller's mid-stream
294    /// failure. When `error` is `Some`, the listener — AFTER draining
295    /// every queued chunk (so error rows land last, never out of
296    /// order) — logs it as an `error` row for every nested agent
297    /// completion that has NOT finished (no `usage` yet) and does not
298    /// carry its own in-band error. Completions that finished cleanly
299    /// get nothing: they started and ended with no errors.
300    pub async fn finalize_with_stream_error(
301        self,
302        error: Option<serde_json::Value>,
303    ) -> Result<(), crate::error::Error> {
304        if let Some(value) = error {
305            *self.stream_error.lock().await = Some(value);
306        }
307        self.finalize().await
308    }
309}
310
311/// All the per-stream state the listener task owns. Was previously
312/// inlined onto `LogWriter`; now lives entirely inside the spawned
313/// task so the handle stays send-and-clone-cheap.
314struct LogWriterState<C> {
315    pool: Pool,
316    tier: Tier,
317    request_body: serde_json::Value,
318    /// Agent tier only: the request's typed messages, unpacked into
319    /// request_message content rows once on the first chunk. `None`
320    /// for the vector/function tiers (their request_message rows ride
321    /// the chunk stream). Immutable — written once, not shadow-gated.
322    request_messages: Option<Vec<objectiveai_sdk::agent::completions::message::Message>>,
323    /// Agent tier only: the agent-ref derived from the REQUEST's
324    /// `agent` field, upserted once on the first chunk. Taken (moved)
325    /// then. `None` for the other tiers (their refs come from the
326    /// response via `ChunkAgentRefs`).
327    request_agent_ref: Option<crate::db::agent_refs::AgentRefValue>,
328    /// AIH of the caller who issued the request that spawned this
329    /// writer (pulled from `ctx.config.agent_instance_hierarchy` at
330    /// `spawn_writer` time). Written into the request blob row at
331    /// `insert_request_blob` time. Constant for the writer's
332    /// lifetime — one request = one sender.
333    sender_agent_instance_hierarchy: String,
334    /// Walks a chunk into [`WriterItem`]s — content rows AND, at each
335    /// nested agent completion with usage, a per-AIH `total_tokens`
336    /// snapshot. One traversal covers both.
337    items_fn: for<'a> fn(&'a C) -> WriterItems<'a>,
338    /// Last `total_tokens` written per AIH — dedups redundant
339    /// overwrites across the writer's repeated passes over the
340    /// cumulative accumulator.
341    last_usage: HashMap<String, u64>,
342    primary_id: Option<String>,
343    /// Per-streaming-content-row shadow. Skip path is allocation-free.
344    shadow: Shadow,
345    /// Every `agent_instance_hierarchy` we've observed in this
346    /// stream's lifetime. The first time an agent appears in the row
347    /// iterator we insert a `objectiveai.messages` row registering the
348    /// request blob in that agent's history; subsequent ticks see
349    /// the agent already-marked and skip the registration.
350    seen_agents: HashSet<String>,
351    /// Live-conversation tee: every row the shadow admits
352    /// (`Insert`/`Update`) is also shipped, full-value, to the resident
353    /// daemon for `/agents/instances/{*aih}` fan-out. Best-effort and
354    /// non-blocking — see [`super::tee`]. `None` = no tee (e.g. hand
355    /// -built writers).
356    tee: Option<super::tee::ConversationTee>,
357    /// The tee's stateful row→typed-event mapper (head memory).
358    frame_mapper: super::tee::FrameMapper,
359    /// `(aih, response_id)` pairs whose in-band completion error has
360    /// already been persisted — the cumulative accumulator re-yields
361    /// the error item every tick once set. Also consulted by the
362    /// post-drain mid-stream failure sweep.
363    logged_errors: HashSet<(String, String)>,
364    /// Per-tier walker over the folded chunk's nested agent
365    /// completions — the mid-stream failure sweep's enumeration.
366    statuses_fn: for<'a> fn(&'a C) -> super::rows::CompletionStatuses<'a>,
367    /// The chunk's own TOP-LEVEL in-band error (a function execution
368    /// failing on the wire without a transport error). `None`-returning
369    /// for the agent tier — spawn's `note_error` owns that tier's
370    /// stream errors.
371    chunk_error_fn: for<'a> fn(&'a C) -> Option<&'a objectiveai_sdk::error::ResponseError>,
372    /// Mid-stream failure handoff: the caller's stream error, set by
373    /// [`LogWriter::finalize_with_stream_error`] BEFORE the EOF signal,
374    /// consumed by the listener's post-drain sweep.
375    stream_error: Arc<Mutex<Option<serde_json::Value>>>,
376    _chunk: PhantomData<fn() -> C>,
377}
378
379impl<C> LogWriterState<C> {
380    fn new(
381        pool: Pool,
382        tier: Tier,
383        request_body: serde_json::Value,
384        sender_agent_instance_hierarchy: String,
385        items_fn: for<'a> fn(&'a C) -> WriterItems<'a>,
386        request_messages: Option<
387            Vec<objectiveai_sdk::agent::completions::message::Message>,
388        >,
389        request_agent_ref: Option<crate::db::agent_refs::AgentRefValue>,
390        tee: Option<super::tee::ConversationTee>,
391        statuses_fn: for<'a> fn(&'a C) -> super::rows::CompletionStatuses<'a>,
392        chunk_error_fn: for<'a> fn(&'a C) -> Option<&'a objectiveai_sdk::error::ResponseError>,
393        stream_error: Arc<Mutex<Option<serde_json::Value>>>,
394    ) -> Self {
395        Self {
396            pool,
397            tier,
398            request_body,
399            request_messages,
400            request_agent_ref,
401            sender_agent_instance_hierarchy,
402            items_fn,
403            last_usage: HashMap::new(),
404            primary_id: None,
405            shadow: Shadow::new(),
406            seen_agents: HashSet::new(),
407            tee,
408            frame_mapper: super::tee::FrameMapper::default(),
409            logged_errors: HashSet::new(),
410            statuses_fn,
411            chunk_error_fn,
412            stream_error,
413            _chunk: PhantomData,
414        }
415    }
416
417    /// Agent tier: write the request's messages as request_message
418    /// content rows, once, on the first chunk — before any response
419    /// row so they read first. Immutable, so a direct one-time INSERT
420    /// per row (no shadow). `response_id`/`aih` are the spawned agent
421    /// completion's own id + AIH.
422    async fn write_agent_request_messages(
423        &mut self,
424        response_id: &str,
425        aih: &str,
426    ) -> Result<(), crate::error::Error> {
427        let Some(messages) = &self.request_messages else {
428            return Ok(());
429        };
430        let ts = now_secs() as i64;
431        for row in super::rows::request_message_rows(response_id, aih, messages) {
432            // These rows bypass the shadow (immutable, written once),
433            // so they must tee here or live subscribers never see the
434            // spawned agent's opening messages.
435            if let Some(tee) = &self.tee {
436                if let Some(frame) = self.frame_mapper.map(&row, ts) {
437                    tee.send(frame);
438                }
439            }
440            write_value(&self.pool, WriteOp::Insert, &row, ts).await?;
441        }
442        Ok(())
443    }
444
445    /// Persist the cumulative aggregate's streaming-content rows
446    /// (`listener_loop` hands in the stream-wide accumulator, not a
447    /// per-batch slice, so every row is seen with its complete body).
448    /// The response_id and request blob are established by
449    /// `listener_loop` before the first call; the response blob is
450    /// written by `listener_loop` after the last chunk. This method
451    /// only touches the per-row content tables (gated through the
452    /// shadow, so re-walking the unchanged majority is free) and the
453    /// per-agent `objectiveai.messages` bookkeeping.
454    async fn apply_chunk(&mut self, chunk: &C) -> Result<(), crate::error::Error>
455    where
456        C: Send + Sync,
457    {
458        let response_id = self
459            .primary_id
460            .clone()
461            .expect("primary_id set by listener_loop before apply_chunk");
462        let created_at_seed = now_secs() as i64;
463
464        // One traversal of the chunk. Content rows are gated via the
465        // shadow and bucketed by agent_instance_hierarchy (Vec inside
466        // the HashMap preserves iterator order so per-bucket sequential
467        // awaits match the walk's ordering). Usage items (per nested
468        // agent completion carrying a non-`None` usage) overwrite the
469        // per-AIH `total_tokens` snapshot inline — `last_usage` dedups
470        // so the repeated passes over the cumulative accumulator only
471        // write when the value changes.
472        let mut buckets: HashMap<&str, Vec<(WriteOp, RowValue<'_>)>> = HashMap::new();
473        for item in (self.items_fn)(chunk) {
474            match item {
475                WriterItem::Row(value) => {
476                    let key = value.agent_instance_hierarchy();
477                    match self.shadow.record(&value) {
478                        WriteOp::Skip => {}
479                        op => {
480                            // Live-conversation tee: ship the admitted
481                            // row (full current value) BEFORE its SQL
482                            // runs — sequential walk order, never gated
483                            // on DB latency. Best-effort, non-blocking.
484                            if let Some(tee) = &self.tee {
485                                if let Some(frame) =
486                                    self.frame_mapper.map(&value, created_at_seed)
487                                {
488                                    tee.send(frame);
489                                }
490                            }
491                            buckets.entry(key).or_default().push((op, value));
492                        }
493                    }
494                }
495                WriterItem::Usage { agent_instance_hierarchy, total_tokens } => {
496                    if self.last_usage.get(agent_instance_hierarchy) != Some(&total_tokens) {
497                        update_agent_token_usage(
498                            &self.pool,
499                            agent_instance_hierarchy,
500                            total_tokens as i64,
501                        )
502                        .await?;
503                        self.last_usage
504                            .insert(agent_instance_hierarchy.to_string(), total_tokens);
505                    }
506                }
507                WriterItem::Error { agent_instance_hierarchy, response_id: rid, error } => {
508                    let logged_key =
509                        (agent_instance_hierarchy.to_string(), rid.to_string());
510                    if !self.logged_errors.contains(&logged_key) {
511                        // Persist first, then tee — same order as the
512                        // spawn-path `note_error`. Covers EVERY tier:
513                        // nested agent completions inside vector /
514                        // function executions flow through the same
515                        // walker.
516                        let value = serde_json::to_value(error)
517                            .unwrap_or_else(|_| error.to_string().into());
518                        super::errors::insert_error(
519                            &self.pool,
520                            agent_instance_hierarchy,
521                            Some(rid),
522                            &value,
523                            created_at_seed,
524                        )
525                        .await?;
526                        if let Some(tee) = &self.tee {
527                            tee.send(super::tee::error_frame(
528                                agent_instance_hierarchy.to_string(),
529                                Some(rid.to_string()),
530                                value,
531                                created_at_seed,
532                            ));
533                        }
534                        self.logged_errors.insert(logged_key);
535                    }
536                }
537            }
538        }
539
540        // Build the per-agent bucket futures. Each future runs its
541        // rows sequentially (order matters within one agent's
542        // history); different agents run concurrently via
543        // `try_join_all`. The seen_agents mutation happens
544        // synchronously inside the map closure — by the time the
545        // futures actually run, every bucket already knows whether it
546        // owes a request-messages row.
547        let pool = &self.pool;
548        let tier = self.tier;
549        let resp_id = response_id.as_str();
550        let seen_agents = &mut self.seen_agents;
551        let bucket_futures: Vec<
552            Pin<Box<dyn Future<Output = Result<(), crate::error::Error>> + Send + '_>>,
553        > = buckets
554            .into_iter()
555            .map(|(hier, items)| {
556                let needs_request_row = !seen_agents.contains(hier);
557                if needs_request_row {
558                    seen_agents.insert(hier.to_string());
559                }
560                Box::pin(async move {
561                    if needs_request_row {
562                        insert_request_messages_row(
563                            pool,
564                            tier,
565                            resp_id,
566                            hier,
567                            created_at_seed,
568                        )
569                        .await?;
570                    }
571                    for (op, value) in &items {
572                        write_value(pool, *op, value, created_at_seed).await?;
573                    }
574                    Ok::<(), crate::error::Error>(())
575                })
576                    as Pin<
577                        Box<
578                            dyn Future<Output = Result<(), crate::error::Error>>
579                                + Send
580                                + '_,
581                        >,
582                    >
583            })
584            .collect();
585
586        futures::future::try_join_all(bucket_futures).await?;
587
588        Ok(())
589    }
590
591    /// Write the request blob exactly once, when `listener_loop` first
592    /// learns the response_id (before any content row references it).
593    /// The request blob carries no agent_instance_hierarchy — that
594    /// linkage lives in `objectiveai.messages` (written per-agent in
595    /// `apply_chunk`).
596    async fn write_request_blob(
597        &self,
598        response_id: &str,
599    ) -> Result<(), crate::error::Error> {
600        let created_at_seed = now_secs() as i64;
601        insert_request_blob(
602            &self.pool,
603            self.tier,
604            response_id,
605            &self.request_body,
606            &self.sender_agent_instance_hierarchy,
607            created_at_seed,
608        )
609        .await?;
610        Ok(())
611    }
612
613    /// Write the complete response blob exactly once, from the
614    /// cumulative aggregate of every chunk in the stream — built by
615    /// `listener_loop` and handed in after the last chunk (finalize).
616    /// A single INSERT: the blob is never a partial snapshot, so a
617    /// chunk's tool-calls can't be lost to a per-batch overwrite.
618    async fn write_response_blob(
619        &self,
620        chunk: &C,
621    ) -> Result<(), crate::error::Error>
622    where
623        C: Serialize,
624    {
625        let Some(response_id) = self.primary_id.as_deref() else {
626            return Ok(());
627        };
628        let created_at_seed = now_secs() as i64;
629        insert_response_blob(
630            &self.pool,
631            self.tier,
632            response_id,
633            chunk,
634            created_at_seed,
635        )
636        .await?;
637        Ok(())
638    }
639}
640
641/// Listener loop. One iteration:
642///
643/// 1. Block on `rx.recv()` for the first chunk of a batch.
644/// 2. Drain any other chunks queued behind it via `try_recv`,
645///    `.push()`-aggregating them into the first.
646/// 3. Apply the aggregated chunk to the state.
647/// 4. If this was the first successful batch, flip `written_tx` to
648///    `true` (powers `LogWriter::wait_written_once`).
649/// 5. If `primary_id` just became known, fire the ready oneshot.
650///
651/// On `recv() = None` (sender dropped via `finalize`), the loop
652/// exits cleanly. On any DB error from `apply_chunk`, the loop
653/// exits with `Err`; subsequent sender sends fail with `SendError`,
654/// which `LogWriter::write` maps to a stable `Error::Instance`.
655async fn listener_loop<C>(
656    mut rx: mpsc::UnboundedReceiver<C>,
657    mut state: LogWriterState<C>,
658    ready_tx: oneshot::Sender<String>,
659    written_tx: watch::Sender<bool>,
660) -> Result<(), crate::error::Error>
661where
662    C: WriterChunk
663        + AgentCompletionIds
664        + ChunkAgentRefs
665        + ChunkPush
666        + Clone
667        + Serialize
668        + Send
669        + Sync,
670{
671    let mut ready_tx = Some(ready_tx);
672    let mut written_fired = false;
673    // Cumulative aggregate of every chunk across the whole stream. Each
674    // iteration's `agg` is only a partial slice (the wire is per-message
675    // deltas); folding every batch in here builds the complete response
676    // that is written as the response blob exactly once, after the last
677    // chunk. Without this the blob would be overwritten with whatever
678    // partial batch arrived last, dropping earlier tool-calls.
679    let mut accumulated: Option<C> = None;
680    while let Some(first) = rx.recv().await {
681        // Fold `first` into the stream-wide aggregate, then drain any
682        // chunks queued behind it into the same aggregate. `accumulated`
683        // is the cumulative roll-up of every chunk seen so far — NOT a
684        // per-batch slice. Draining the queue only collapses how OFTEN
685        // the persistence pass runs; what it persists from is always
686        // the full accumulator.
687        // Definition sources ride the RAW chunks (agent_inline is
688        // first-chunk-only), so scan each incoming chunk before it
689        // dissolves into the aggregate.
690        let mut agent_refs: Vec<(String, crate::db::agent_refs::AgentRefValue)> =
691            Vec::new();
692        first.collect_agent_refs(&mut agent_refs);
693        if let Some(acc) = accumulated.as_mut() {
694            acc.push(&first);
695        } else {
696            accumulated = Some(first.clone());
697        }
698        while let Ok(next) = rx.try_recv() {
699            next.collect_agent_refs(&mut agent_refs);
700            if let Some(acc) = accumulated.as_mut() {
701                acc.push(&next);
702            }
703        }
704        let acc = accumulated
705            .as_ref()
706            .expect("accumulated is Some: set or pushed above");
707
708        // On the very first chunk: learn the response_id and write the
709        // request blob once, before any content row references it.
710        if state.primary_id.is_none() {
711            let response_id = acc.primary_id().to_string();
712            state.write_request_blob(&response_id).await?;
713            // Agent tier: unpack the REQUEST into request_message rows
714            // (written first) and register the agent_ref from the
715            // request's agent field — never the response chunk.
716            if let Some(aih) = acc.agent_tier_aih() {
717                state.write_agent_request_messages(&response_id, aih).await?;
718                if let Some(value) = state.request_agent_ref.take() {
719                    crate::db::agent_refs::upsert(&state.pool, aih, value)
720                        .await?;
721                }
722            }
723            state.primary_id = Some(response_id);
724        }
725
726        // Persist rows from the cumulative aggregate, never a per-batch
727        // slice. A tool call's id/name/arguments — and streamed content
728        // text — arrive as deltas spread across multiple wire chunks;
729        // under load those deltas land in different drain batches. Row
730        // generation (`rows.rs`) drops any tool call missing id/name/
731        // args, so a per-batch slice that lacked the id/name delta would
732        // omit the row entirely (and would overwrite content text with
733        // the latest fragment rather than the full run). Walking the
734        // full accumulator each pass emits every row from its COMPLETE
735        // body; the shadow makes the repeated walk cheap — unchanged
736        // rows Skip with zero writes, only genuinely-changed bodies hit
737        // the DB.
738        state.apply_chunk(acc).await?;
739
740        // Blind agent_refs upserts for every scanned definition
741        // source — last write wins by design.
742        for (hier, value) in agent_refs {
743            crate::db::agent_refs::upsert(&state.pool, &hier, value).await?;
744        }
745
746        // First successful apply: flip the watch true exactly once.
747        // Subsequent batches don't touch it (the value is already
748        // true; no point waking waiters again).
749        if !written_fired {
750            let _ = written_tx.send(true);
751            written_fired = true;
752        }
753        // Fire the oneshot the first time primary_id becomes known
754        // (set above on the first chunk).
755        if let Some(tx) = ready_tx.take() {
756            match state.primary_id.as_deref() {
757                Some(id) => {
758                    let _ = tx.send(id.to_string());
759                }
760                None => {
761                    ready_tx = Some(tx);
762                }
763            }
764        }
765    }
766    // EOF (sender dropped via finalize): every queued chunk is drained.
767    if let Some(acc) = accumulated {
768        // Mid-stream failure sweep: the caller's stream error (set via
769        // `finalize_with_stream_error` before EOF) — or the execution's
770        // own TOP-LEVEL in-band error — is logged for every nested
771        // agent completion that neither finished (no usage yet) nor
772        // carries its own in-band error (already persisted). Runs
773        // strictly post-drain, so error rows land AFTER every
774        // conversation row.
775        let sweep_error = state.stream_error.lock().await.take().or_else(|| {
776            (state.chunk_error_fn)(&acc).map(|e| {
777                serde_json::to_value(e).unwrap_or_else(|_| e.to_string().into())
778            })
779        });
780        if let Some(value) = sweep_error {
781            let ts = now_secs() as i64;
782            for status in (state.statuses_fn)(&acc) {
783                if status.finished || status.errored {
784                    continue;
785                }
786                let key = (
787                    status.agent_instance_hierarchy.to_string(),
788                    status.response_id.to_string(),
789                );
790                if state.logged_errors.contains(&key) {
791                    continue;
792                }
793                // Persist first, then tee — same order as everywhere.
794                super::errors::insert_error(
795                    &state.pool,
796                    status.agent_instance_hierarchy,
797                    Some(status.response_id),
798                    &value,
799                    ts,
800                )
801                .await?;
802                if let Some(tee) = &state.tee {
803                    tee.send(super::tee::error_frame(
804                        key.0.clone(),
805                        Some(key.1.clone()),
806                        value.clone(),
807                        ts,
808                    ));
809                }
810                state.logged_errors.insert(key);
811            }
812        }
813        // Write the complete response blob exactly once from the
814        // cumulative aggregate. (Both skipped when no chunk ever
815        // arrived — `accumulated` still `None`.)
816        state.write_response_blob(&acc).await?;
817    }
818    Ok(())
819}
820
821fn now_secs() -> u64 {
822    std::time::SystemTime::now()
823        .duration_since(std::time::UNIX_EPOCH)
824        .map(|d| d.as_secs())
825        .unwrap_or(0)
826}
827
828fn spawn_writer<C>(
829    pool: Pool,
830    tier: Tier,
831    request_body: serde_json::Value,
832    sender_agent_instance_hierarchy: String,
833    items_fn: for<'a> fn(&'a C) -> WriterItems<'a>,
834    request_messages: Option<
835        Vec<objectiveai_sdk::agent::completions::message::Message>,
836    >,
837    request_agent_ref: Option<crate::db::agent_refs::AgentRefValue>,
838    tee: Option<super::tee::ConversationTee>,
839    statuses_fn: for<'a> fn(&'a C) -> super::rows::CompletionStatuses<'a>,
840    chunk_error_fn: for<'a> fn(&'a C) -> Option<&'a objectiveai_sdk::error::ResponseError>,
841) -> (LogWriter<C>, oneshot::Receiver<String>)
842where
843    C: WriterChunk
844        + AgentCompletionIds
845        + ChunkAgentRefs
846        + ChunkPush
847        + Clone
848        + Serialize
849        + Send
850        + Sync
851        + 'static,
852{
853    let (tx, rx) = mpsc::unbounded_channel();
854    let (ready_tx, ready_rx) = oneshot::channel();
855    let (written_tx, written_rx) = watch::channel(false);
856    let stream_error: Arc<Mutex<Option<serde_json::Value>>> =
857        Arc::new(Mutex::new(None));
858    let state = LogWriterState::new(
859        pool,
860        tier,
861        request_body,
862        sender_agent_instance_hierarchy,
863        items_fn,
864        request_messages,
865        request_agent_ref,
866        tee,
867        statuses_fn,
868        chunk_error_fn,
869        stream_error.clone(),
870    );
871    let handle = tokio::spawn(listener_loop(rx, state, ready_tx, written_tx));
872    (
873        LogWriter {
874            tx,
875            handle,
876            written_rx,
877            stream_error,
878            _chunk: PhantomData,
879        },
880        ready_rx,
881    )
882}
883
884pub fn write_agent_completion(
885    pool: &Pool,
886    params: &AgentCompletionCreateParams,
887    sender_agent_instance_hierarchy: String,
888    tee: Option<super::tee::ConversationTee>,
889) -> Result<
890    (LogWriter<AgentCompletionChunk>, oneshot::Receiver<String>),
891    crate::error::Error,
892> {
893    let body = serde_json::to_value(params)?;
894    // Agent tier derives request_message rows + the agent_ref from the
895    // REQUEST (not the response chunk). Capture both at construction.
896    let request_messages = Some(params.messages.clone());
897    let request_agent_ref = match &params.agent {
898        objectiveai_sdk::agent::InlineAgentBaseWithFallbacksOrRemoteCommitOptional::AgentBase(base) => {
899            crate::db::agent_refs::AgentRefValue::inline(base)
900        }
901        objectiveai_sdk::agent::InlineAgentBaseWithFallbacksOrRemoteCommitOptional::Remote(remote) => {
902            crate::db::agent_refs::AgentRefValue::remote(remote)
903        }
904    };
905    Ok(spawn_writer(
906        pool.clone(),
907        Tier::Agent,
908        body,
909        sender_agent_instance_hierarchy,
910        agent_completion_chunk_rows,
911        request_messages,
912        request_agent_ref,
913        tee,
914        super::rows::agent_completion_statuses,
915        // Agent tier: spawn's `note_error` owns stream errors; the
916        // chunk's own in-band error is logged via `WriterItem::Error`.
917        |_| None,
918    ))
919}
920
921pub fn write_vector_completion(
922    pool: &Pool,
923    params: &VectorCompletionCreateParams,
924    sender_agent_instance_hierarchy: String,
925    tee: Option<super::tee::ConversationTee>,
926) -> Result<
927    (LogWriter<VectorCompletionChunk>, oneshot::Receiver<String>),
928    crate::error::Error,
929> {
930    let body = serde_json::to_value(params)?;
931    Ok(spawn_writer(
932        pool.clone(),
933        Tier::Vector,
934        body,
935        sender_agent_instance_hierarchy,
936        vector_completion_chunk_rows,
937        None,
938        None,
939        tee,
940        super::rows::vector_completion_statuses,
941        // VectorCompletionChunk carries no top-level error field.
942        |_| None,
943    ))
944}
945
946pub fn write_function_execution(
947    pool: &Pool,
948    params: &FunctionExecutionCreateParams,
949    sender_agent_instance_hierarchy: String,
950    tee: Option<super::tee::ConversationTee>,
951) -> Result<
952    (LogWriter<FunctionExecutionChunk>, oneshot::Receiver<String>),
953    crate::error::Error,
954> {
955    let body = serde_json::to_value(params)?;
956    Ok(spawn_writer(
957        pool.clone(),
958        Tier::Function,
959        body,
960        sender_agent_instance_hierarchy,
961        function_execution_chunk_rows,
962        None,
963        None,
964        tee,
965        super::rows::function_execution_statuses,
966        |chunk| chunk.error.as_ref(),
967    ))
968}