fix: recover partial session log writes
This commit is contained in:
@@ -112,13 +112,14 @@ impl WorkerInterceptor {
|
||||
/// `Item::system_message`s reach the worker via
|
||||
/// `ContinueWith` / `pending_history_appends`, so on-disk order
|
||||
/// matches worker-history order.
|
||||
fn commit_system_items(&self, items: &[SystemItem]) {
|
||||
fn commit_system_items(&self, items: &[SystemItem]) -> Result<(), session_store::StoreError> {
|
||||
let Some(writer) = self.log_writer.as_ref() else {
|
||||
return;
|
||||
return Ok(());
|
||||
};
|
||||
for item in items {
|
||||
writer.commit_system_item(item.clone());
|
||||
writer.commit_system_item(item.clone())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_turn_index(&self) -> usize {
|
||||
@@ -194,15 +195,17 @@ impl Interceptor for WorkerInterceptor {
|
||||
// `Item::system_message`s, so on-disk order matches
|
||||
// worker-history order.
|
||||
let items: Vec<Item> = extras.iter().map(SystemItem::to_history_item).collect();
|
||||
self.commit_system_items(&extras);
|
||||
PromptAction::ContinueWith(items)
|
||||
match self.commit_system_items(&extras) {
|
||||
Ok(()) => PromptAction::ContinueWith(items),
|
||||
Err(error) => PromptAction::Cancel(format!("session persistence failed: {error}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn pending_history_appends(&self) -> Vec<Item> {
|
||||
async fn pending_history_appends(&self) -> Result<Vec<Item>, String> {
|
||||
let drained = self.pending_notifies.drain();
|
||||
if drained.is_empty() {
|
||||
return Vec::new();
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut system_items: Vec<SystemItem> = Vec::with_capacity(drained.len());
|
||||
@@ -231,8 +234,9 @@ impl Interceptor for WorkerInterceptor {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.commit_system_items(&system_items);
|
||||
items
|
||||
self.commit_system_items(&system_items)
|
||||
.map_err(|error| format!("session persistence failed: {error}"))?;
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
async fn pre_llm_request(&self, context: &mut Vec<Item>) -> PreRequestAction {
|
||||
@@ -278,7 +282,9 @@ impl Interceptor for WorkerInterceptor {
|
||||
let current_tokens = self.estimated_tokens(effective_context.as_ref());
|
||||
|
||||
if self.request_threshold_exceeded(current_tokens, effective_context.as_ref()) {
|
||||
self.commit_system_items(&system_items);
|
||||
if let Err(error) = self.commit_system_items(&system_items) {
|
||||
return PreRequestAction::Cancel(format!("session persistence failed: {error}"));
|
||||
}
|
||||
return if appended_items.is_empty() {
|
||||
PreRequestAction::Yield
|
||||
} else {
|
||||
@@ -292,8 +298,10 @@ impl Interceptor for WorkerInterceptor {
|
||||
if system_items.is_empty() {
|
||||
return PreRequestAction::Continue;
|
||||
}
|
||||
self.commit_system_items(&system_items);
|
||||
PreRequestAction::ContinueWith(appended_items)
|
||||
match self.commit_system_items(&system_items) {
|
||||
Ok(()) => PreRequestAction::ContinueWith(appended_items),
|
||||
Err(error) => PreRequestAction::Cancel(format!("session persistence failed: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn pre_tool_call(&self, info: &mut ToolCallInfo) -> PreToolAction {
|
||||
@@ -451,13 +459,17 @@ mod tests {
|
||||
}
|
||||
|
||||
impl SystemItemCommitter for RecordingSystemItemCommitter {
|
||||
fn commit_log_entry(&self, entry: session_store::LogEntry) {
|
||||
fn commit_log_entry(
|
||||
&self,
|
||||
entry: session_store::LogEntry,
|
||||
) -> Result<(), session_store::StoreError> {
|
||||
if let session_store::LogEntry::SystemItem { item, .. } = entry {
|
||||
self.committed
|
||||
.lock()
|
||||
.expect("committed system-item list poisoned")
|
||||
.push(item);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1034,7 +1046,7 @@ mod tests {
|
||||
None,
|
||||
);
|
||||
|
||||
let items = interceptor.pending_history_appends().await;
|
||||
let items = interceptor.pending_history_appends().await.unwrap();
|
||||
assert_eq!(items.len(), 2);
|
||||
let first = items[0].as_text().unwrap_or_default();
|
||||
let second = items[1].as_text().unwrap_or_default();
|
||||
@@ -1048,7 +1060,7 @@ mod tests {
|
||||
);
|
||||
|
||||
// Empty buffer → empty Vec (no synthesised items).
|
||||
let again = interceptor.pending_history_appends().await;
|
||||
let again = interceptor.pending_history_appends().await.unwrap();
|
||||
assert!(again.is_empty());
|
||||
}
|
||||
|
||||
|
||||
+74
-21
@@ -568,9 +568,8 @@ where
|
||||
St: Store + Clone,
|
||||
{
|
||||
/// Append `entry` to the log: disk write → counter bump → in-memory
|
||||
/// mirror push → broadcast. The kernel orders concurrent `O_APPEND`
|
||||
/// writes for `< PIPE_BUF` lines, so no user-space serialization is
|
||||
/// needed across appenders.
|
||||
/// mirror push → broadcast. The Store owns physical write ordering and
|
||||
/// partial-write recovery; publication happens only after it returns Ok.
|
||||
pub fn append_entry(&self, entry: LogEntry) -> Result<(), StoreError> {
|
||||
let loc = self.state.location();
|
||||
self.store.append(loc.session_id, loc.segment_id, &entry)?;
|
||||
@@ -602,13 +601,13 @@ where
|
||||
/// interceptor commit `SystemItem`s without being generic over the
|
||||
/// concrete `Store` type.
|
||||
pub trait SystemItemCommitter: Send + Sync {
|
||||
fn commit_log_entry(&self, entry: LogEntry);
|
||||
fn commit_log_entry(&self, entry: LogEntry) -> Result<(), StoreError>;
|
||||
|
||||
fn commit_system_item(&self, item: SystemItem) {
|
||||
fn commit_system_item(&self, item: SystemItem) -> Result<(), StoreError> {
|
||||
self.commit_log_entry(LogEntry::SystemItem {
|
||||
ts: segment_log::now_millis(),
|
||||
item,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,10 +615,8 @@ impl<St> SystemItemCommitter for LogWriterHandle<St>
|
||||
where
|
||||
St: Store + Clone + Send + Sync + 'static,
|
||||
{
|
||||
fn commit_log_entry(&self, entry: LogEntry) {
|
||||
if let Err(err) = self.append_entry(entry) {
|
||||
warn!(error = %err, "session log entry commit failed; dropping");
|
||||
}
|
||||
fn commit_log_entry(&self, entry: LogEntry) -> Result<(), StoreError> {
|
||||
self.append_entry(entry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -914,7 +911,7 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Worker<C, St>
|
||||
let writer = self.log_writer_handle();
|
||||
self.engine_mut().on_history_append(move |item| {
|
||||
if item.is_user_message() {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(
|
||||
item,
|
||||
@@ -923,12 +920,12 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Worker<C, St>
|
||||
..
|
||||
}
|
||||
) {
|
||||
return;
|
||||
return Ok(());
|
||||
}
|
||||
let entry = session_store::classify_history_item(item, segment_log::now_millis());
|
||||
if let Err(err) = writer.append_entry(entry) {
|
||||
warn!(error = %err, "history append commit failed; dropping");
|
||||
}
|
||||
writer
|
||||
.append_entry(entry)
|
||||
.map_err(|error| error.to_string())
|
||||
});
|
||||
if self.manifest.session.record_event_trace {
|
||||
let writer = self.log_writer_handle();
|
||||
@@ -1206,7 +1203,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
},
|
||||
})?;
|
||||
self.engine_mut()
|
||||
.append_history(std::iter::once(llm_engine::Item::system_message(body)));
|
||||
.append_history(std::iter::once(llm_engine::Item::system_message(body)))?;
|
||||
Ok(activation)
|
||||
}
|
||||
|
||||
@@ -1251,9 +1248,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
}
|
||||
|
||||
/// Append `entry` to the session log AND publish it through the
|
||||
/// broadcast sink. No user-space serialization is needed across
|
||||
/// concurrent appenders — the kernel orders `O_APPEND` writes for
|
||||
/// lines smaller than `PIPE_BUF`.
|
||||
/// broadcast sink. The Store is the commit boundary: a failed write is
|
||||
/// never counted or published.
|
||||
pub(crate) fn commit_entry(&self, entry: LogEntry) -> Result<(), StoreError> {
|
||||
let loc = self.segment_state.location();
|
||||
self.store.append(loc.session_id, loc.segment_id, &entry)?;
|
||||
@@ -2083,7 +2079,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
&tool_result_summary,
|
||||
);
|
||||
if !closures.is_empty() {
|
||||
self.engine_mut().append_history(closures);
|
||||
self.engine_mut().append_history(closures)?;
|
||||
}
|
||||
self.commit_entry(LogEntry::SystemItem {
|
||||
ts: segment_log::now_millis(),
|
||||
@@ -2094,7 +2090,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
self.engine_mut()
|
||||
.append_history(std::iter::once(llm_engine::Item::system_message(
|
||||
system_note,
|
||||
)));
|
||||
)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2164,6 +2160,12 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
),
|
||||
"run_for_notification expects a non-UserSend InvokeKind; got {kind:?}"
|
||||
);
|
||||
// This is a fresh Invoke, not an explicit resume of the interrupted
|
||||
// turn. Close any dangling tool calls before an auto-run notification
|
||||
// can enter `Engine::resume` and execute them again after a crash.
|
||||
if self.engine.as_ref().unwrap().last_run_interrupted() {
|
||||
self.apply_interrupt_prep()?;
|
||||
}
|
||||
self.prepare_for_run().await?;
|
||||
|
||||
// IDLE → active marker for the buffered notification / worker-event
|
||||
@@ -5914,6 +5916,57 @@ mod build_summary_prompt_tests {
|
||||
assert_eq!(interrupt_system_count, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notification_run_closes_interrupted_tool_call_before_engine_resume() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let manifest = minimal_manifest();
|
||||
let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap();
|
||||
let cwd = dir.path().join("workspace");
|
||||
std::fs::create_dir_all(&cwd).unwrap();
|
||||
let scope = Scope::writable(&cwd).unwrap();
|
||||
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
|
||||
let mut worker = Worker::new(
|
||||
manifest,
|
||||
Engine::new(NoopClient),
|
||||
store,
|
||||
WorkerWorkspaceContext::local_filesystem(None),
|
||||
authority,
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
worker.ensure_segment_head().unwrap();
|
||||
worker.wire_history_persistence();
|
||||
let dangling_call = Item::tool_call("call-1", "SideEffect", "{}");
|
||||
worker
|
||||
.commit_entry(LogEntry::AssistantItem {
|
||||
ts: segment_log::now_millis(),
|
||||
item: dangling_call.clone().into(),
|
||||
})
|
||||
.unwrap();
|
||||
worker.engine_mut().set_history(vec![dangling_call]);
|
||||
worker.engine_mut().set_last_run_interrupted(true);
|
||||
|
||||
worker
|
||||
.run_for_notification(protocol::InvokeKind::Notify)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let history = worker.engine().history();
|
||||
assert!(matches!(
|
||||
history.get(1),
|
||||
Some(Item::ToolResult { call_id, .. }) if call_id == "call-1"
|
||||
));
|
||||
assert!(matches!(
|
||||
history.get(2),
|
||||
Some(Item::Message {
|
||||
role: Role::System,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ResidentInjectionGates {
|
||||
summary: bool,
|
||||
|
||||
Reference in New Issue
Block a user