update: エントリの単数化のフォローアップ

This commit is contained in:
2026-05-14 19:42:23 +09:00
parent 988495cfea
commit 35fec78519
12 changed files with 44 additions and 342 deletions
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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.
//!
+14 -3
View File
@@ -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() {
+1 -1
View File
@@ -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
View File
@@ -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.
//
+1 -195
View File
@@ -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::*;
+4 -4
View File
@@ -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,
},
+1 -2
View File
@@ -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 }
+3 -2
View File
@@ -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
+10
View File
@@ -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, .. } => {