feat: Session(Segment 群の grouping)を導入
- SessionId 型を新設、各 SegmentStart に session_id を持たせる - compaction / 内部 fork は同 SessionId を継承、fork() は新 Session を発行 - Store API を (SessionId, SegmentId) ベースに、FsStore layout は <root>/<session_id>/<segment_id>.jsonl に - Store::list_sessions / list_segments(session_id) / lookup_session_of を追加 - restore_by_segment shim を session-store に提供(pod-cli --session で使用) - SegmentState に SegmentLocation (session_id, segment_id) を保持し ArcSwap で更新 - RestoredState に session_id: Option<SessionId> を追加 - Picker は Session 単位に列挙、leaf segment を解決して resume
This commit is contained in:
+23
-2
@@ -5,7 +5,7 @@ use std::process::ExitCode;
|
||||
use clap::Parser;
|
||||
use manifest::{PodManifest, paths};
|
||||
use pod::{Pod, PodController, PodFactory, PromptLoader};
|
||||
use session_store::{FsStore, SegmentId};
|
||||
use session_store::{FsStore, SegmentId, Store};
|
||||
|
||||
const USER_MANIFEST_ENV: &str = "INSOMNIA_USER_MANIFEST";
|
||||
|
||||
@@ -186,7 +186,28 @@ async fn main() -> ExitCode {
|
||||
}
|
||||
}
|
||||
} else if let Some(source_segment_id) = cli.session {
|
||||
match Pod::restore_from_manifest(source_segment_id, manifest, store, loader).await {
|
||||
let source_session_id = match store.lookup_session_of(source_segment_id) {
|
||||
Ok(Some(sid)) => sid,
|
||||
Ok(None) => {
|
||||
eprintln!(
|
||||
"error: --session {source_segment_id}: segment is not registered to any session"
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("error: lookup_session_of failed: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
match Pod::restore_from_manifest(
|
||||
source_session_id,
|
||||
source_segment_id,
|
||||
manifest,
|
||||
store,
|
||||
loader,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
eprintln!("error: failed to restore pod: {e}");
|
||||
|
||||
+94
-48
@@ -9,7 +9,8 @@ use llm_worker::llm_client::client::LlmClient;
|
||||
use llm_worker::state::Mutable;
|
||||
use llm_worker::{ToolOutputLimits, UsageRecord, Worker, WorkerError, WorkerResult};
|
||||
use session_store::{
|
||||
LogEntry, PodScopeSnapshot, SegmentId, Store, StoreError, SystemItem, segment_log, to_logged,
|
||||
LogEntry, PodScopeSnapshot, SegmentId, SessionId, Store, StoreError, SystemItem, segment_log,
|
||||
to_logged,
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
@@ -42,35 +43,54 @@ use protocol::{AlertLevel, AlertSource, Event, Segment};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
/// Lock-free shared session pointer.
|
||||
/// `(SessionId, SegmentId)` pair the Pod is currently writing to.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SegmentLocation {
|
||||
pub session_id: SessionId,
|
||||
pub segment_id: SegmentId,
|
||||
}
|
||||
|
||||
/// Lock-free shared session/segment pointer.
|
||||
///
|
||||
/// Holds the current `(segment_id, entries_written)` pair so that the
|
||||
/// Pod and every `LogWriterHandle` clone see a consistent view through
|
||||
/// `Arc`-shared lock-free reads. `segment_id` is wrapped in `ArcSwap`
|
||||
/// so fork (a rare, run-start-only event) can atomically swap it
|
||||
/// without taking a mutex on the append hot path. `entries_written` is
|
||||
/// an `AtomicUsize` bumped on every successful append; the writer's
|
||||
/// tally is compared against the store's on-disk count to detect
|
||||
/// concurrent writers in `ensure_segment_head`.
|
||||
/// Holds the current `(SessionId, SegmentId)` pair and the append tally
|
||||
/// so that the Pod and every `LogWriterHandle` clone see a consistent
|
||||
/// view through `Arc`-shared lock-free reads. The location is wrapped in
|
||||
/// `ArcSwap` so fork (a rare, run-start-only event) can atomically swap
|
||||
/// session_id + segment_id together without taking a mutex on the
|
||||
/// append hot path. `entries_written` is an `AtomicUsize` bumped on
|
||||
/// every successful append; the writer's tally is compared against the
|
||||
/// store's on-disk count to detect concurrent writers in
|
||||
/// `ensure_segment_head`.
|
||||
pub struct SegmentState {
|
||||
segment_id: ArcSwap<SegmentId>,
|
||||
location: ArcSwap<SegmentLocation>,
|
||||
entries_written: AtomicUsize,
|
||||
}
|
||||
|
||||
impl SegmentState {
|
||||
pub fn new(segment_id: SegmentId, entries_written: usize) -> Arc<Self> {
|
||||
pub fn new(session_id: SessionId, segment_id: SegmentId, entries_written: usize) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
segment_id: ArcSwap::from_pointee(segment_id),
|
||||
location: ArcSwap::from_pointee(SegmentLocation {
|
||||
session_id,
|
||||
segment_id,
|
||||
}),
|
||||
entries_written: AtomicUsize::new(entries_written),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn segment_id(&self) -> SegmentId {
|
||||
**self.segment_id.load()
|
||||
pub fn location(&self) -> SegmentLocation {
|
||||
**self.location.load()
|
||||
}
|
||||
|
||||
pub fn set_segment_id(&self, id: SegmentId) {
|
||||
self.segment_id.store(Arc::new(id));
|
||||
pub fn session_id(&self) -> SessionId {
|
||||
self.location().session_id
|
||||
}
|
||||
|
||||
pub fn segment_id(&self) -> SegmentId {
|
||||
self.location().segment_id
|
||||
}
|
||||
|
||||
pub fn set_location(&self, loc: SegmentLocation) {
|
||||
self.location.store(Arc::new(loc));
|
||||
}
|
||||
|
||||
pub fn entries_written(&self) -> usize {
|
||||
@@ -107,8 +127,8 @@ where
|
||||
/// writes for `< PIPE_BUF` lines, so no user-space serialization is
|
||||
/// needed across appenders.
|
||||
pub fn append_entry(&self, entry: LogEntry) -> Result<(), StoreError> {
|
||||
let segment_id = self.state.segment_id();
|
||||
self.store.append(segment_id, &entry)?;
|
||||
let loc = self.state.location();
|
||||
self.store.append(loc.session_id, loc.segment_id, &entry)?;
|
||||
self.state.increment_entries();
|
||||
self.sink.publish(entry);
|
||||
Ok(())
|
||||
@@ -472,13 +492,14 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
// Segment creation is deferred to `ensure_segment_head` at first
|
||||
// run so a later-installed system-prompt template (see
|
||||
// `set_system_prompt_template`) can be captured by `SegmentStart`.
|
||||
let session_id = session_store::new_session_id();
|
||||
let segment_id = session_store::new_segment_id();
|
||||
let prompts = PromptCatalog::builtins_only()?;
|
||||
let mut pod = Self {
|
||||
manifest,
|
||||
worker: Some(worker),
|
||||
store,
|
||||
segment_state: SegmentState::new(segment_id, 0),
|
||||
segment_state: SegmentState::new(session_id, segment_id, 0),
|
||||
pwd,
|
||||
scope: SharedScope::new(scope),
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
@@ -542,12 +563,18 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
&self.prompts
|
||||
}
|
||||
|
||||
/// The session ID used for persistence. Read lock-free from the
|
||||
/// shared session pointer so fork-time swaps are observed immediately.
|
||||
/// The current segment ID. Read lock-free from the shared session
|
||||
/// pointer so fork-time swaps are observed immediately.
|
||||
pub fn segment_id(&self) -> SegmentId {
|
||||
self.segment_state.segment_id()
|
||||
}
|
||||
|
||||
/// The Session this Pod belongs to. Stable across compaction and
|
||||
/// in-Session fork — only `fork` (a brand-new Session) changes it.
|
||||
pub fn session_id(&self) -> SessionId {
|
||||
self.segment_state.session_id()
|
||||
}
|
||||
|
||||
/// The Pod's manifest.
|
||||
pub fn manifest(&self) -> &PodManifest {
|
||||
&self.manifest
|
||||
@@ -627,8 +654,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
/// concurrent appenders — the kernel orders `O_APPEND` writes for
|
||||
/// lines smaller than `PIPE_BUF`.
|
||||
pub(crate) fn commit_entry(&self, entry: LogEntry) -> Result<(), StoreError> {
|
||||
let segment_id = self.segment_state.segment_id();
|
||||
self.store.append(segment_id, &entry)?;
|
||||
let loc = self.segment_state.location();
|
||||
self.store.append(loc.session_id, loc.segment_id, &entry)?;
|
||||
self.segment_state.increment_entries();
|
||||
self.sink.publish(entry);
|
||||
Ok(())
|
||||
@@ -1618,11 +1645,12 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
/// when another writer has appended behind our back.
|
||||
fn ensure_segment_head(&mut self) -> Result<(), PodError> {
|
||||
let w = self.worker.as_ref().unwrap();
|
||||
let prev_segment_id = self.segment_state.segment_id();
|
||||
let loc = self.segment_state.location();
|
||||
let entries_written = self.segment_state.entries_written();
|
||||
if entries_written == 0 {
|
||||
let initial = LogEntry::SegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
session_id: loc.session_id,
|
||||
system_prompt: w.get_system_prompt().map(String::from),
|
||||
config: w.request_config().clone(),
|
||||
history: to_logged(w.history()),
|
||||
@@ -1636,17 +1664,19 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
// Check store count + auto-fork if it drifted.
|
||||
let store_count = self
|
||||
.store
|
||||
.read_entry_count(prev_segment_id)
|
||||
.read_entry_count(loc.session_id, loc.segment_id)
|
||||
.map_err(PodError::from)?;
|
||||
if store_count == entries_written {
|
||||
return Ok(());
|
||||
}
|
||||
// Fork: mint a fresh session and switch to it. The new
|
||||
// SegmentStart entry replaces the mirror and is broadcast
|
||||
// through the sink so existing subscribers reset their view.
|
||||
let fork_id = session_store::new_segment_id();
|
||||
// Auto-fork within the same Session: mint a fresh Segment and
|
||||
// switch to it. The new SegmentStart entry replaces the mirror
|
||||
// and is broadcast through the sink so existing subscribers
|
||||
// reset their view.
|
||||
let fork_segment_id = session_store::new_segment_id();
|
||||
let entry = LogEntry::SegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
session_id: loc.session_id,
|
||||
system_prompt: w.get_system_prompt().map(String::from),
|
||||
config: w.request_config().clone(),
|
||||
history: to_logged(w.history()),
|
||||
@@ -1654,13 +1684,16 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
compacted_from: None,
|
||||
};
|
||||
self.store
|
||||
.create_segment(fork_id, &[entry.clone()])
|
||||
.create_segment(loc.session_id, fork_segment_id, &[entry.clone()])
|
||||
.map_err(PodError::from)?;
|
||||
self.segment_state.set_segment_id(fork_id);
|
||||
self.segment_state.set_location(SegmentLocation {
|
||||
session_id: loc.session_id,
|
||||
segment_id: fork_segment_id,
|
||||
});
|
||||
self.segment_state.set_entries_written(1);
|
||||
self.sink.reset_with_initial(entry);
|
||||
if self.scope_allocation.is_some() {
|
||||
pod_registry::update_segment(&self.manifest.pod.name, fork_id)?;
|
||||
pod_registry::update_segment(&self.manifest.pod.name, fork_segment_id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2140,27 +2173,34 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
task_snapshot_text.clone(),
|
||||
));
|
||||
|
||||
// Build the SegmentStart entry for the new compacted session,
|
||||
// then atomically rotate to it: create on disk, swap head, reset
|
||||
// the broadcast sink so existing subscribers see the new
|
||||
// `SegmentStart { compacted_from }` and reset their view.
|
||||
// Build the SegmentStart entry for the new compacted segment.
|
||||
// Inherits the source Segment's session_id so the compacted
|
||||
// lineage stays grouped under the same Session. Atomically
|
||||
// rotate: create on disk, swap location, reset the broadcast
|
||||
// sink so existing subscribers see the new `SegmentStart
|
||||
// { compacted_from }` and reset their view.
|
||||
let new_segment_id = session_store::new_segment_id();
|
||||
let old_session_id = self.segment_state.segment_id();
|
||||
let old_loc = self.segment_state.location();
|
||||
let source_turn_count = self.worker.as_ref().unwrap().turn_count();
|
||||
let w = self.worker.as_ref().unwrap();
|
||||
let entry = LogEntry::SegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
session_id: old_loc.session_id,
|
||||
system_prompt: w.get_system_prompt().map(String::from),
|
||||
config: w.request_config().clone(),
|
||||
history: to_logged(&new_history),
|
||||
forked_from: None,
|
||||
compacted_from: Some(session_store::SegmentOrigin {
|
||||
segment_id: old_session_id,
|
||||
segment_id: old_loc.segment_id,
|
||||
at_turn_index: source_turn_count,
|
||||
}),
|
||||
};
|
||||
self.store.create_segment(new_segment_id, &[entry.clone()])?;
|
||||
self.segment_state.set_segment_id(new_segment_id);
|
||||
self.store
|
||||
.create_segment(old_loc.session_id, new_segment_id, &[entry.clone()])?;
|
||||
self.segment_state.set_location(SegmentLocation {
|
||||
session_id: old_loc.session_id,
|
||||
segment_id: new_segment_id,
|
||||
});
|
||||
self.segment_state.set_entries_written(1);
|
||||
let session_start = entry;
|
||||
// Broadcast the SegmentStart through the sink. This atomically
|
||||
@@ -2367,7 +2407,10 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
// Read the session log to get the current entry count. This is
|
||||
// the boundary for the source.range end_entry. Called once per
|
||||
// extract, on a small local file.
|
||||
let entries_now = self.store.read_all(self.segment_id())?.len();
|
||||
let entries_now = self
|
||||
.store
|
||||
.read_all(self.session_id(), self.segment_id())?
|
||||
.len();
|
||||
if entries_now == 0 {
|
||||
return Ok(ExtractDecision::Skipped);
|
||||
}
|
||||
@@ -2738,8 +2781,9 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
// Segment creation is deferred to the first run (see
|
||||
// `ensure_segment_head`) so the SegmentStart entry can capture
|
||||
// the rendered system prompt, not the raw template source. The
|
||||
// segment_id is allocated here so the pod-registry registration
|
||||
// can record it from the start.
|
||||
// session_id + segment_id are allocated here so the pod-registry
|
||||
// registration can record them from the start.
|
||||
let session_id = session_store::new_session_id();
|
||||
let segment_id = session_store::new_segment_id();
|
||||
|
||||
// Register this Pod in the machine-wide pod-registry
|
||||
@@ -2765,7 +2809,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
manifest,
|
||||
worker: Some(worker),
|
||||
store,
|
||||
segment_state: SegmentState::new(segment_id, 0),
|
||||
segment_state: SegmentState::new(session_id, segment_id, 0),
|
||||
pwd: common.pwd,
|
||||
scope: SharedScope::new(common.scope),
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
@@ -2820,6 +2864,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
let mut common = prepare_pod_common(&manifest, &loader, /* parse_template */ true)?;
|
||||
let skill_shadows = std::mem::take(&mut common.skill_shadows);
|
||||
|
||||
let session_id = session_store::new_session_id();
|
||||
let segment_id = session_store::new_segment_id();
|
||||
let scope_allocation = pod_registry::adopt_allocation(
|
||||
manifest.pod.name.clone(),
|
||||
@@ -2835,7 +2880,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
manifest,
|
||||
worker: Some(worker),
|
||||
store,
|
||||
segment_state: SegmentState::new(segment_id, 0),
|
||||
segment_state: SegmentState::new(session_id, segment_id, 0),
|
||||
pwd: common.pwd,
|
||||
scope: SharedScope::new(common.scope),
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
@@ -2892,6 +2937,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
/// session keeps a stable cache prefix even when the manifest's
|
||||
/// instruction template would render differently today.
|
||||
pub async fn restore_from_manifest(
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
manifest: PodManifest,
|
||||
store: St,
|
||||
@@ -2900,7 +2946,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
// Read raw entries once so we can both reconstruct state and
|
||||
// seed the broadcast sink's mirror with the same prefix that
|
||||
// sits on disk.
|
||||
let raw_entries = store.read_all(segment_id)?;
|
||||
let raw_entries = store.read_all(session_id, segment_id)?;
|
||||
let state = session_store::collect_state(&raw_entries);
|
||||
if state.entries_count == 0 {
|
||||
return Err(PodError::SegmentEmpty { segment_id });
|
||||
@@ -2974,7 +3020,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||
manifest,
|
||||
worker: Some(worker),
|
||||
store,
|
||||
segment_state: SegmentState::new(segment_id, state.entries_count),
|
||||
segment_state: SegmentState::new(session_id, segment_id, state.entries_count),
|
||||
pwd: common.pwd,
|
||||
scope: SharedScope::new(common.scope),
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
|
||||
@@ -203,6 +203,7 @@ mod tests {
|
||||
fn session_start() -> LogEntry {
|
||||
LogEntry::SegmentStart {
|
||||
ts: now_millis(),
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![],
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use pod::{Pod, PodError};
|
||||
use session_store::{FsStore, SegmentId, StoreError};
|
||||
use session_store::{FsStore, StoreError};
|
||||
|
||||
const MINIMAL_MANIFEST_TOML: &str = r#"
|
||||
[pod]
|
||||
@@ -32,7 +32,7 @@ permission = "write"
|
||||
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_from_manifest_rejects_unknown_session() {
|
||||
async fn restore_from_manifest_rejects_unknown_segment() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let store_tmp = tempfile::tempdir().unwrap();
|
||||
@@ -42,66 +42,76 @@ async fn restore_from_manifest_rejects_unknown_session() {
|
||||
// A freshly-minted id with no jsonl file at all → store returns
|
||||
// NotFound, which `Pod::restore_from_manifest` surfaces verbatim
|
||||
// as `PodError::Store`.
|
||||
let unknown = session_store::new_segment_id();
|
||||
let unknown_sid = session_store::new_session_id();
|
||||
let unknown_seg = session_store::new_segment_id();
|
||||
let result = Pod::restore_from_manifest(
|
||||
unknown_sid,
|
||||
unknown_seg,
|
||||
manifest,
|
||||
store,
|
||||
pod::PromptLoader::builtins_only(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(PodError::Store(StoreError::NotFound(id))) => assert_eq!(id, unknown_seg),
|
||||
Err(other) => panic!("expected Store(NotFound), got {other:?}"),
|
||||
Ok(_) => panic!("expected unknown segment to fail"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_from_manifest_rejects_empty_segment_log() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let store_tmp = tempfile::tempdir().unwrap();
|
||||
let store = FsStore::new(store_tmp.path()).unwrap();
|
||||
let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
|
||||
|
||||
// Pre-create an empty `<sid>/<segid>.jsonl` so `read_all` succeeds
|
||||
// with no entries. `collect_state` returns `entries_count = 0`,
|
||||
// which `restore_from_manifest` rejects with `SegmentEmpty` *before*
|
||||
// it gets as far as building the LLM client.
|
||||
let sid = session_store::new_session_id();
|
||||
let segid = session_store::new_segment_id();
|
||||
let dir = store_tmp.path().join(sid.to_string());
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(format!("{segid}.jsonl")), b"").unwrap();
|
||||
|
||||
let result =
|
||||
Pod::restore_from_manifest(unknown, manifest, store, pod::PromptLoader::builtins_only())
|
||||
Pod::restore_from_manifest(sid, segid, manifest, store, pod::PromptLoader::builtins_only())
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(PodError::Store(StoreError::NotFound(id))) => assert_eq!(id, unknown),
|
||||
Err(other) => panic!("expected Store(NotFound), got {other:?}"),
|
||||
Ok(_) => panic!("expected unknown session to fail"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_from_manifest_rejects_empty_session_log() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let store_tmp = tempfile::tempdir().unwrap();
|
||||
let store = FsStore::new(store_tmp.path()).unwrap();
|
||||
let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
|
||||
|
||||
// Pre-create an empty `<id>.jsonl` so `read_all` succeeds with no
|
||||
// entries. `collect_state` returns `entries_count = 0`, which
|
||||
// `restore_from_manifest` rejects with `SegmentEmpty` *before* it
|
||||
// gets as far as building the LLM client — so the test does not
|
||||
// need credentials or a runtime sandbox.
|
||||
let id: SegmentId = session_store::new_segment_id();
|
||||
let path = store_tmp.path().join(format!("{id}.jsonl"));
|
||||
std::fs::write(&path, b"").unwrap();
|
||||
|
||||
let result =
|
||||
Pod::restore_from_manifest(id, manifest, store, pod::PromptLoader::builtins_only()).await;
|
||||
|
||||
match result {
|
||||
Err(PodError::SegmentEmpty { segment_id }) => assert_eq!(segment_id, id),
|
||||
Err(PodError::SegmentEmpty { segment_id }) => assert_eq!(segment_id, segid),
|
||||
Err(other) => panic!("expected SegmentEmpty, got {other:?}"),
|
||||
Ok(_) => panic!("expected empty session log to fail"),
|
||||
Ok(_) => panic!("expected empty segment log to fail"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_from_manifest_rejects_session_without_scope_snapshot() {
|
||||
async fn restore_from_manifest_rejects_segment_without_scope_snapshot() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let store_tmp = tempfile::tempdir().unwrap();
|
||||
let store = FsStore::new(store_tmp.path()).unwrap();
|
||||
let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
|
||||
|
||||
let id = session_store::new_segment_id();
|
||||
let sid = session_store::new_session_id();
|
||||
let segid = session_store::new_segment_id();
|
||||
let state = session_store::SegmentStartState {
|
||||
system_prompt: None,
|
||||
config: &Default::default(),
|
||||
history: &[],
|
||||
};
|
||||
session_store::create_segment_with_id(&store, id, state).unwrap();
|
||||
session_store::create_segment_with_ids(&store, sid, segid, state).unwrap();
|
||||
|
||||
let result =
|
||||
Pod::restore_from_manifest(id, manifest, store, pod::PromptLoader::builtins_only()).await;
|
||||
Pod::restore_from_manifest(sid, segid, manifest, store, pod::PromptLoader::builtins_only())
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(PodError::SegmentScopeMissing { segment_id }) => assert_eq!(segment_id, id),
|
||||
Err(PodError::SegmentScopeMissing { segment_id }) => assert_eq!(segment_id, segid),
|
||||
Err(other) => panic!("expected SegmentScopeMissing, got {other:?}"),
|
||||
Ok(_) => panic!("expected missing scope snapshot to fail"),
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ use llm_worker::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEve
|
||||
use llm_worker::llm_client::{ClientError, LlmClient, Request};
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use session_metrics::{DOMAIN, Metric, metrics_from_extensions};
|
||||
use session_store::{FsStore, LogEntry, SegmentId, Store, StoreError, TraceEntry};
|
||||
use session_store::{FsStore, LogEntry, SegmentId, SessionId, Store, StoreError, TraceEntry};
|
||||
|
||||
use pod::{Pod, PodManifest};
|
||||
|
||||
@@ -200,6 +200,7 @@ async fn prune_metrics_emit_skip_then_fire_with_post_request_join() {
|
||||
text_response_with_cache("done", 1234, 50),
|
||||
]);
|
||||
let (mut pod, _store_tmp, _pwd_tmp) = make_pod(manifest_toml(1, 1), client, "big_tool").await;
|
||||
let session_id = pod.session_id();
|
||||
let segment_id = pod.segment_id();
|
||||
// Cloning the store handle to read the session log back after the
|
||||
// runs complete — the Pod retains its own copy.
|
||||
@@ -208,7 +209,7 @@ async fn prune_metrics_emit_skip_then_fire_with_post_request_join() {
|
||||
pod.run_text("first").await.unwrap();
|
||||
pod.run_text("second").await.unwrap();
|
||||
|
||||
let state = session_store::restore(&store, segment_id).unwrap();
|
||||
let state = session_store::restore(&store, session_id, segment_id).unwrap();
|
||||
let metrics = metrics_from_extensions(&state.extensions);
|
||||
|
||||
// Run 1 has 2 LLM iterations (tool loop), each evaluates prune with
|
||||
@@ -288,13 +289,14 @@ async fn prune_metrics_record_below_min_savings_skip() {
|
||||
]);
|
||||
let (mut pod, _store_tmp, _pwd_tmp) =
|
||||
make_pod(manifest_toml(1, u64::MAX), client, "big_tool").await;
|
||||
let session_id = pod.session_id();
|
||||
let segment_id = pod.segment_id();
|
||||
let store = pod.store().clone();
|
||||
|
||||
pod.run_text("first").await.unwrap();
|
||||
pod.run_text("second").await.unwrap();
|
||||
|
||||
let state = session_store::restore(&store, segment_id).unwrap();
|
||||
let state = session_store::restore(&store, session_id, segment_id).unwrap();
|
||||
let metrics = metrics_from_extensions(&state.extensions);
|
||||
let below = metrics
|
||||
.iter()
|
||||
@@ -327,31 +329,60 @@ struct MetricFailingStore {
|
||||
}
|
||||
|
||||
impl Store for MetricFailingStore {
|
||||
fn append(&self, id: SegmentId, entry: &LogEntry) -> Result<(), StoreError> {
|
||||
fn append(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
entry: &LogEntry,
|
||||
) -> Result<(), StoreError> {
|
||||
if let LogEntry::Extension { domain, .. } = entry {
|
||||
if domain == DOMAIN {
|
||||
return Err(StoreError::Io(std::io::Error::other("synthetic failure")));
|
||||
}
|
||||
}
|
||||
self.inner.append(id, entry)
|
||||
self.inner.append(session_id, segment_id, entry)
|
||||
}
|
||||
fn read_all(&self, id: SegmentId) -> Result<Vec<LogEntry>, StoreError> {
|
||||
self.inner.read_all(id)
|
||||
fn read_all(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
) -> Result<Vec<LogEntry>, StoreError> {
|
||||
self.inner.read_all(session_id, segment_id)
|
||||
}
|
||||
fn list_segments(&self) -> Result<Vec<SegmentId>, StoreError> {
|
||||
self.inner.list_segments()
|
||||
fn list_sessions(&self) -> Result<Vec<SessionId>, StoreError> {
|
||||
self.inner.list_sessions()
|
||||
}
|
||||
fn create_segment(&self, id: SegmentId, entries: &[LogEntry]) -> Result<(), StoreError> {
|
||||
self.inner.create_segment(id, entries)
|
||||
fn list_segments(&self, session_id: SessionId) -> Result<Vec<SegmentId>, StoreError> {
|
||||
self.inner.list_segments(session_id)
|
||||
}
|
||||
fn exists(&self, id: SegmentId) -> Result<bool, StoreError> {
|
||||
self.inner.exists(id)
|
||||
fn lookup_session_of(&self, segment_id: SegmentId) -> Result<Option<SessionId>, StoreError> {
|
||||
self.inner.lookup_session_of(segment_id)
|
||||
}
|
||||
fn read_entry_count(&self, id: SegmentId) -> Result<usize, StoreError> {
|
||||
self.inner.read_entry_count(id)
|
||||
fn create_segment(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
entries: &[LogEntry],
|
||||
) -> Result<(), StoreError> {
|
||||
self.inner.create_segment(session_id, segment_id, entries)
|
||||
}
|
||||
fn append_trace(&self, id: SegmentId, entry: &TraceEntry) -> Result<(), StoreError> {
|
||||
self.inner.append_trace(id, entry)
|
||||
fn exists(&self, session_id: SessionId, segment_id: SegmentId) -> Result<bool, StoreError> {
|
||||
self.inner.exists(session_id, segment_id)
|
||||
}
|
||||
fn read_entry_count(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
) -> Result<usize, StoreError> {
|
||||
self.inner.read_entry_count(session_id, segment_id)
|
||||
}
|
||||
fn append_trace(
|
||||
&self,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
entry: &TraceEntry,
|
||||
) -> Result<(), StoreError> {
|
||||
self.inner.append_trace(session_id, segment_id, entry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,12 +417,13 @@ async fn metric_write_failure_emits_warn_alert_and_does_not_abort_run() {
|
||||
let alerter = pod::Alerter::new(tx);
|
||||
pod.attach_alerter(alerter);
|
||||
|
||||
let session_id = pod.session_id();
|
||||
let segment_id = pod.segment_id();
|
||||
// Run completes successfully despite metric failure.
|
||||
pod.run_text("hello").await.unwrap();
|
||||
|
||||
// No metrics ended up in the log (writes were rejected).
|
||||
let state = session_store::restore(&store, segment_id).unwrap();
|
||||
let state = session_store::restore(&store, session_id, segment_id).unwrap();
|
||||
let metrics = metrics_from_extensions(&state.extensions);
|
||||
assert!(metrics.is_empty(), "metrics must drop on write failure");
|
||||
|
||||
@@ -446,10 +478,11 @@ permission = "write"
|
||||
let mut pod = Pod::new(manifest, worker, store.clone(), pwd, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
let session_id = pod.session_id();
|
||||
let segment_id = pod.segment_id();
|
||||
pod.run_text("hello").await.unwrap();
|
||||
|
||||
let state = session_store::restore(&store, segment_id).unwrap();
|
||||
let state = session_store::restore(&store, session_id, segment_id).unwrap();
|
||||
let metrics = metrics_from_extensions(&state.extensions);
|
||||
assert!(
|
||||
metrics.is_empty(),
|
||||
|
||||
@@ -182,7 +182,10 @@ async fn session_start_state_captures_rendered_prompt() {
|
||||
.unwrap();
|
||||
pod.run_text("hi").await.unwrap();
|
||||
|
||||
let entries = pod.store().read_all(pod.segment_id()).unwrap();
|
||||
let entries = pod
|
||||
.store()
|
||||
.read_all(pod.session_id(), pod.segment_id())
|
||||
.unwrap();
|
||||
let first = entries.first().expect("at least one entry");
|
||||
match first {
|
||||
LogEntry::SegmentStart { system_prompt, .. } => {
|
||||
|
||||
Reference in New Issue
Block a user