update: エントリの単数化のフォローアップ
This commit is contained in:
@@ -616,7 +616,7 @@ async fn controller_loop<C, St>(
|
||||
Method::Notify { message } => {
|
||||
// Client-side live echo is delivered as `Event::SystemItem`
|
||||
// once the interceptor commits the corresponding
|
||||
// `LogEntry::SystemItems` entry — drained out of the
|
||||
// `LogEntry::SystemItem` entry — drained out of the
|
||||
// notify buffer + broadcast through the sink. No
|
||||
// separate echo here.
|
||||
pod.push_notify(message);
|
||||
@@ -674,8 +674,8 @@ async fn controller_loop<C, St>(
|
||||
// Live echo travels through the SystemItem lane: once
|
||||
// the interceptor drains the notify buffer, the
|
||||
// typed `SystemItem::PodEvent` lands as a
|
||||
// `LogEntry::SystemItems` entry and the sink fans it
|
||||
// out to clients as `Event::SystemItem`.
|
||||
// `LogEntry::SystemItem` entry and the sink forwards it
|
||||
// to clients as `Event::SystemItem`.
|
||||
//
|
||||
// (1) system side effects — idempotent and tolerant of
|
||||
// out-of-order delivery (e.g. `TurnEnded` arriving
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
//! `PodInterceptor::pending_history_appends`, which the Worker calls
|
||||
//! at the head of each turn loop iteration. The drain renders each
|
||||
//! pending entry into a typed `SystemItem` (with the `notify_wrapper`
|
||||
//! prompt applied), commits a `LogEntry::SystemItems` through the
|
||||
//! session-log sink, and returns the corresponding
|
||||
//! prompt applied), commits a `LogEntry::SystemItem` per entry through
|
||||
//! the session-log sink, and returns the corresponding
|
||||
//! `Item::system_message`s for the worker to append to its
|
||||
//! persistent history.
|
||||
//!
|
||||
|
||||
@@ -115,9 +115,20 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
|
||||
.expect("SystemItem is Serialize");
|
||||
Some(Event::SystemItem { item: value })
|
||||
}
|
||||
// Defensive: should never reach here per
|
||||
// `SessionLogSink::is_live_relevant`.
|
||||
_ => None,
|
||||
other => {
|
||||
// `SessionLogSink::is_live_relevant` keeps
|
||||
// non-live-relevant variants off the
|
||||
// broadcast lane; reaching here means the
|
||||
// two are out of sync and we silently
|
||||
// dropped a wire event. Log so a future
|
||||
// regression surfaces instead of vanishing.
|
||||
tracing::error!(
|
||||
entry_kind = ?std::mem::discriminant(&other),
|
||||
"session-log broadcast emitted a non-live-relevant entry; \
|
||||
sink filter and IPC dispatch are out of sync"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(event) = outbound {
|
||||
if writer.write(&event).await.is_err() {
|
||||
|
||||
@@ -31,5 +31,5 @@ pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTem
|
||||
pub use protocol::{ErrorCode, Event, Method, PodStatus, TurnResult};
|
||||
pub use provider::{ProviderError, build_client};
|
||||
pub use runtime::dir::RuntimeDir;
|
||||
pub use session_log_sink::{SessionLogSink, SessionLogWriter};
|
||||
pub use session_log_sink::SessionLogSink;
|
||||
pub use shared_state::PodSharedState;
|
||||
|
||||
+5
-26
@@ -55,22 +55,13 @@ pub struct SessionHead {
|
||||
/// All three fields are `Clone` (the latter two as `Arc` clones, the
|
||||
/// store per its `Clone` impl) so the handle itself is a flat triple of
|
||||
/// cheap copies.
|
||||
pub struct LogWriterHandle<St> {
|
||||
#[derive(Clone)]
|
||||
pub struct LogWriterHandle<St: Clone> {
|
||||
pub store: St,
|
||||
pub session_head: Arc<SyncMutex<SessionHead>>,
|
||||
pub sink: SessionLogSink,
|
||||
}
|
||||
|
||||
impl<St: Clone> Clone for LogWriterHandle<St> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
store: self.store.clone(),
|
||||
session_head: self.session_head.clone(),
|
||||
sink: self.sink.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<St> LogWriterHandle<St>
|
||||
where
|
||||
St: Store + Clone,
|
||||
@@ -1125,7 +1116,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
self.ensure_interceptor_installed();
|
||||
self.ensure_system_prompt_materialized()?;
|
||||
self.cleanup_finished_memory_task();
|
||||
self.ensure_session_head().await?;
|
||||
self.ensure_session_head()?;
|
||||
if self.should_pre_run_compact() {
|
||||
self.join_memory_task().await;
|
||||
}
|
||||
@@ -1520,7 +1511,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
/// `ensure_system_prompt_materialized` has just rendered. Subsequent
|
||||
/// calls fall through to `ensure_head_or_fork`, which auto-forks when
|
||||
/// another writer has advanced the store head behind our back.
|
||||
async fn ensure_session_head(&mut self) -> Result<(), PodError> {
|
||||
fn ensure_session_head(&mut self) -> Result<(), PodError> {
|
||||
let w = self.worker.as_ref().unwrap();
|
||||
let prev_session_id;
|
||||
let initial_state = {
|
||||
@@ -1545,7 +1536,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
let store_head = self
|
||||
.store
|
||||
.read_head_hash(prev_session_id)
|
||||
|
||||
.map_err(PodError::from)?;
|
||||
let mut head = self.session_head.lock();
|
||||
if store_head == head.head_hash {
|
||||
@@ -1571,7 +1561,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
};
|
||||
self.store
|
||||
.create_session(fork_id, &[hashed])
|
||||
|
||||
.map_err(PodError::from)?;
|
||||
head.session_id = fork_id;
|
||||
head.head_hash = Some(hash);
|
||||
@@ -1715,22 +1704,12 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
history_before: usize,
|
||||
result: &Result<WorkerResult, WorkerError>,
|
||||
) -> Result<(), StoreError> {
|
||||
// Per-item commits for AssistantItems / ToolResults /
|
||||
// HookInjectedItems already landed mid-turn through the
|
||||
// controller-spawned drain task, fed by
|
||||
// `Worker::on_history_append`. Drain the queue here so every
|
||||
// in-flight item has actually been committed before the
|
||||
// trailing `TurnEnd` entry. When no drain is wired (low-level
|
||||
// tests / direct `Pod::new` usage) we fall back to a synchronous
|
||||
// pass that replicates the legacy `save_delta` classification —
|
||||
// those code paths don't fire `on_history_append`, so the items
|
||||
// would otherwise be lost.
|
||||
// Per-item commits for AssistantItem / ToolResult / SystemItem
|
||||
// entries are expected to have landed synchronously: the
|
||||
// worker `on_history_append` callback (wired by the controller
|
||||
// via `wire_history_persistence`) commits each appended item
|
||||
// directly through the writer, and the interceptor commits
|
||||
// SystemItems up-front in `on_prompt_submit` /
|
||||
// SystemItem entries up-front in `on_prompt_submit` /
|
||||
// `pending_history_appends` before returning the matching
|
||||
// `Item::system_message`s.
|
||||
//
|
||||
|
||||
@@ -24,10 +24,7 @@
|
||||
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
|
||||
use parking_lot::{Mutex, MutexGuard};
|
||||
use session_store::{
|
||||
EntryHash, HashedEntry, LogEntry, SessionId, SessionStartState, Store, StoreError, session_log,
|
||||
};
|
||||
use session_store::LogEntry;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Broadcast capacity for the live receiver. Slow subscribers that
|
||||
@@ -194,197 +191,6 @@ impl Default for SessionLogSink {
|
||||
}
|
||||
}
|
||||
|
||||
/// Active session head for the Pod's persistent log: session id +
|
||||
/// last-committed entry hash. Bundled with the store + sink in a
|
||||
/// `SessionLogWriter` so the worker callback / interceptor can share
|
||||
/// one cheap `Clone` handle for direct sync appends.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionHeadState {
|
||||
pub session_id: SessionId,
|
||||
pub head_hash: Option<EntryHash>,
|
||||
}
|
||||
|
||||
/// Pod-side session-log writer.
|
||||
///
|
||||
/// Bundles the (1) persistent store, (2) the in-memory session-head
|
||||
/// state (id + hash), and (3) the broadcast sink. `append_entry`
|
||||
/// chains the hash on disk, advances the head, then publishes the
|
||||
/// entry through the sink — under a single sync mutex so two writers
|
||||
/// cannot interleave the chain.
|
||||
///
|
||||
/// All append paths run synchronously: a local-fs `<1 KiB` JSONL line
|
||||
/// completes well below a millisecond, and going through an async
|
||||
/// `tokio::fs` ferry would re-introduce the `LogCommand` / drain task
|
||||
/// we removed. `parking_lot::Mutex` is safe to hold across the disk
|
||||
/// write since the lock is never crossed by an `.await`.
|
||||
///
|
||||
/// `Clone` is a cheap `Arc` clone. The Pod keeps one writer for its
|
||||
/// inline commits (UserInput, TurnEnd, Usage, RunCompleted/Errored,
|
||||
/// scope snapshots, metrics) and hands clones to every other commit
|
||||
/// site (worker callback, interceptor).
|
||||
pub struct SessionLogWriter<St> {
|
||||
inner: Arc<WriterInner<St>>,
|
||||
}
|
||||
|
||||
struct WriterInner<St> {
|
||||
store: St,
|
||||
head: Mutex<SessionHeadState>,
|
||||
sink: SessionLogSink,
|
||||
}
|
||||
|
||||
impl<St> Clone for SessionLogWriter<St> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: self.inner.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<St> SessionLogWriter<St>
|
||||
where
|
||||
St: Store + Clone,
|
||||
{
|
||||
/// Create a writer for a fresh Pod with no entries on disk yet.
|
||||
/// `head_hash` is `None` until the first `append_entry` (typically
|
||||
/// the deferred `SessionStart` written by `ensure_session_head`).
|
||||
pub fn new(store: St, session_id: SessionId) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(WriterInner {
|
||||
store,
|
||||
head: Mutex::new(SessionHeadState {
|
||||
session_id,
|
||||
head_hash: None,
|
||||
}),
|
||||
sink: SessionLogSink::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a writer seeded with a session already on disk. The
|
||||
/// mirror is populated with `mirror` (typically loaded via
|
||||
/// `Store::read_all`), and `head_hash` should be the hash of the
|
||||
/// last entry.
|
||||
pub fn restored(
|
||||
store: St,
|
||||
session_id: SessionId,
|
||||
head_hash: Option<EntryHash>,
|
||||
mirror: Vec<LogEntry>,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(WriterInner {
|
||||
store,
|
||||
head: Mutex::new(SessionHeadState {
|
||||
session_id,
|
||||
head_hash,
|
||||
}),
|
||||
sink: SessionLogSink::with_initial(mirror),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `entry` to the log: disk write → in-memory mirror push →
|
||||
/// broadcast — atomic w.r.t. `subscribe_with_snapshot` callers.
|
||||
pub fn append_entry(&self, entry: LogEntry) -> Result<EntryHash, StoreError> {
|
||||
let mut head = self.inner.head.lock();
|
||||
let hash = session_store::append_entry_with_hash(
|
||||
&self.inner.store,
|
||||
head.session_id,
|
||||
&mut head.head_hash,
|
||||
entry.clone(),
|
||||
)?;
|
||||
self.inner.sink.publish(entry);
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
/// Atomically swap to a new compacted session.
|
||||
///
|
||||
/// Creates the new session on disk with `initial` as its
|
||||
/// `SessionStart`, advances the head, and resets the sink mirror
|
||||
/// to `[initial]` while broadcasting the entry. Existing
|
||||
/// subscribers observe the swap as a freshly broadcast
|
||||
/// `SessionStart` (with `compacted_from` set), which is their
|
||||
/// signal to reset their derived view.
|
||||
pub fn swap_session(
|
||||
&self,
|
||||
new_session_id: SessionId,
|
||||
initial: LogEntry,
|
||||
) -> Result<EntryHash, StoreError> {
|
||||
let hash = session_log::compute_hash(None, &initial);
|
||||
let hashed = HashedEntry {
|
||||
hash: hash.clone(),
|
||||
prev_hash: None,
|
||||
entry: initial.clone(),
|
||||
};
|
||||
self.inner.store.create_session(new_session_id, &[hashed])?;
|
||||
let mut head = self.inner.head.lock();
|
||||
head.session_id = new_session_id;
|
||||
head.head_hash = Some(hash.clone());
|
||||
self.inner.sink.reset_with_initial(initial);
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
/// If the store's head no longer matches our cached head, mint a
|
||||
/// fresh session that forks from the current state and switch to
|
||||
/// it. Returns `true` when a fork happened.
|
||||
pub fn ensure_head_or_fork(&self, state: SessionStartState<'_>) -> Result<bool, StoreError> {
|
||||
let mut head = self.inner.head.lock();
|
||||
let store_head = self.inner.store.read_head_hash(head.session_id)?;
|
||||
if store_head == head.head_hash {
|
||||
return Ok(false);
|
||||
}
|
||||
let fork_id = session_store::new_session_id();
|
||||
let entry = LogEntry::SessionStart {
|
||||
ts: session_log::now_millis(),
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
config: state.config.clone(),
|
||||
history: session_store::to_logged(state.history),
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
let hash = session_log::compute_hash(None, &entry);
|
||||
let hashed = HashedEntry {
|
||||
hash: hash.clone(),
|
||||
prev_hash: None,
|
||||
entry: entry.clone(),
|
||||
};
|
||||
self.inner.store.create_session(fork_id, &[hashed])?;
|
||||
head.session_id = fork_id;
|
||||
head.head_hash = Some(hash);
|
||||
self.inner.sink.reset_with_initial(entry);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Cloneable handle to the broadcast sink. Used by the IPC layer
|
||||
/// for `subscribe_with_snapshot` and by tests that just want the
|
||||
/// non-write side.
|
||||
pub fn sink(&self) -> SessionLogSink {
|
||||
self.inner.sink.clone()
|
||||
}
|
||||
|
||||
/// Underlying store handle. Direct access is preserved for callers
|
||||
/// that read state (`read_all`, `read_head_hash`) without going
|
||||
/// through the writer's hash chain.
|
||||
pub fn store(&self) -> &St {
|
||||
&self.inner.store
|
||||
}
|
||||
|
||||
/// Cheap snapshot of the current session id.
|
||||
pub fn current_session_id(&self) -> SessionId {
|
||||
self.inner.head.lock().session_id
|
||||
}
|
||||
|
||||
/// Cheap snapshot of the current head hash.
|
||||
pub fn current_head_hash(&self) -> Option<EntryHash> {
|
||||
self.inner.head.lock().head_hash.clone()
|
||||
}
|
||||
|
||||
/// Direct lock on the head. Used by paths that need to coordinate
|
||||
/// custom writes with the hash chain.
|
||||
pub fn lock_head(&self) -> MutexGuard<'_, SessionHeadState> {
|
||||
self.inner.head.lock()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -224,10 +224,10 @@ pub enum Event {
|
||||
/// of parsing free-text prefixes like `[Notification] …` or
|
||||
/// `[File: …]`.
|
||||
///
|
||||
/// Fired per-item, even when the underlying
|
||||
/// `LogEntry::SystemItems` entry batched several together — the
|
||||
/// IPC layer fans the batch out at broadcast time so subscribers
|
||||
/// observe one event per item.
|
||||
/// One event per `LogEntry::SystemItem` commit. Disk-side and
|
||||
/// wire-side are 1:1 (singular variant); legacy `SystemItems`
|
||||
/// entries from older sessions are read-only and never emitted on
|
||||
/// this lane.
|
||||
SystemItem {
|
||||
item: serde_json::Value,
|
||||
},
|
||||
|
||||
@@ -7,10 +7,8 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
llm-worker = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "io-util"] }
|
||||
uuid = { workspace = true, features = ["v7", "serde"] }
|
||||
thiserror = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
@@ -19,6 +17,7 @@ protocol = { workspace = true }
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
tempfile = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
//! `kind` instead of parsing text prefixes like `[Notification] …` or
|
||||
//! `[File: …]`.
|
||||
//!
|
||||
//! Persisted as the payload of [`crate::LogEntry::SystemItems`], and
|
||||
//! broadcast live as the payload of `Event::SystemItem` on the wire.
|
||||
//! Persisted as the payload of [`crate::LogEntry::SystemItem`] (one
|
||||
//! entry per item), and broadcast live as the payload of
|
||||
//! `Event::SystemItem` on the wire.
|
||||
//!
|
||||
//! For LLM context replay, each `SystemItem` reduces to an
|
||||
//! `Item::system_message(...)` whose body matches the legacy free-text
|
||||
|
||||
@@ -967,6 +967,16 @@ impl App {
|
||||
self.blocks.push(Block::UserMessage { segments });
|
||||
}
|
||||
}
|
||||
session_store::LogEntry::AssistantItem { item, .. }
|
||||
| session_store::LogEntry::ToolResult { item, .. } => {
|
||||
let it: llm_worker::Item = item.into();
|
||||
let item_value = serde_json::to_value(&it).expect("Item is Serialize");
|
||||
self.push_history_item(&item_value);
|
||||
}
|
||||
session_store::LogEntry::SystemItem { item, .. } => {
|
||||
let value = serde_json::to_value(&item).expect("SystemItem is Serialize");
|
||||
self.apply_system_item(&value);
|
||||
}
|
||||
session_store::LogEntry::AssistantItems { items, .. }
|
||||
| session_store::LogEntry::ToolResults { items, .. }
|
||||
| session_store::LogEntry::HookInjectedItems { items, .. } => {
|
||||
|
||||
Reference in New Issue
Block a user