Persist logical run yield and resume transitions

This commit is contained in:
2026-10-01 16:44:11 +09:00
parent f5d8c94713
commit 0ec2b3ad86
19 changed files with 1047 additions and 75 deletions
+2 -1
View File
@@ -107,7 +107,8 @@ pub use segment::{
SegmentStartState, append_entry, append_system_item, classify_logged_history_entry,
create_compacted_segment, create_segment, create_segment_with_ids, ensure_head_or_fork, fork,
fork_at, restore, restore_by_segment, save_config_changed, save_delta, save_extension,
save_run_completed, save_run_errored, save_turn_end, save_usage, save_user_input,
save_run_completed, save_run_errored, save_run_resumed, save_run_yielded, save_turn_end,
save_usage, save_user_input,
};
pub use segment_log::{LogEntry, RestoredState, SegmentOrigin, SessionExtension, collect_state};
pub use store::{Store, StoreError};
+189 -18
View File
@@ -985,6 +985,8 @@ fn project_session_snapshot_for_segment(
) -> SessionSnapshot {
let mut session_key = session_id;
let mut entries = Vec::new();
let mut total_turn_count = 0usize;
let mut active_run_turn_count = None;
for (log_index, record) in log.iter().enumerate() {
match record {
@@ -996,6 +998,8 @@ fn project_session_snapshot_for_segment(
} => {
session_key = *session_id;
entries.clear();
total_turn_count = 0;
active_run_turn_count = None;
extend_history(&mut entries, history, None, *ts);
}
LogEntry::InputSegmentsCheckpoint { user_segments, .. } => {
@@ -1035,24 +1039,108 @@ fn project_session_snapshot_for_segment(
provenance(&entry.metadata.origin),
derivation_ids(entry),
)),
LogEntry::RunErrored { ts, message, .. } => entries.push(legacy_entry(
&session_key,
segment_id.as_ref(),
log_index,
0,
*ts,
SessionSnapshotEntryData::RunError {
message: message.clone(),
},
)),
// Run checkpoints, configuration, usage, and extension state are
// controller/storage authority rather than committed conversation.
LogEntry::Invoke { .. }
| LogEntry::TurnEnd { .. }
| LogEntry::RunCompleted { .. }
| LogEntry::ActiveRunCheckpoint { .. }
| LogEntry::PausedTurnAbandoned { .. }
| LogEntry::ConfigChanged { .. }
LogEntry::RunYielded {
ts,
reason,
active_run_turn_count: count,
} => {
active_run_turn_count = Some(*count);
entries.push(legacy_entry(
&session_key,
segment_id.as_ref(),
log_index,
0,
*ts,
SessionSnapshotEntryData::RunYielded {
reason: *reason,
active_run_turn_count: *count,
},
));
}
LogEntry::RunResumed {
ts,
source,
active_run_turn_count: count,
} => {
active_run_turn_count = Some(*count);
entries.push(legacy_entry(
&session_key,
segment_id.as_ref(),
log_index,
0,
*ts,
SessionSnapshotEntryData::RunResumed {
source: *source,
active_run_turn_count: *count,
},
));
}
LogEntry::RunErrored {
ts,
message,
failure,
..
} => {
active_run_turn_count = None;
entries.push(legacy_entry(
&session_key,
segment_id.as_ref(),
log_index,
0,
*ts,
SessionSnapshotEntryData::RunError {
message: message.clone(),
failure: *failure,
},
));
}
LogEntry::Invoke { .. } => active_run_turn_count = Some(0),
LogEntry::TurnEnd { turn_count, .. } => {
if let Some(active) = &mut active_run_turn_count {
*active += turn_count.saturating_sub(total_turn_count);
}
total_turn_count = *turn_count;
}
LogEntry::RunCompleted {
ts,
interrupted,
result,
active_run_turn_count: persisted_count,
} => {
if *interrupted && matches!(result, agen::EngineResult::Yielded) {
let count = (*persisted_count)
.or(active_run_turn_count)
.unwrap_or_default();
active_run_turn_count = Some(count);
entries.push(legacy_entry(
&session_key,
segment_id.as_ref(),
log_index,
0,
*ts,
SessionSnapshotEntryData::RunYielded {
reason: protocol::RunYieldReason::Compaction,
active_run_turn_count: count,
},
));
} else if *interrupted && matches!(result, agen::EngineResult::Paused) {
active_run_turn_count = (*persisted_count).or(active_run_turn_count);
} else {
active_run_turn_count = None;
}
}
LogEntry::ActiveRunCheckpoint {
active_turn_count,
total_turn_count: persisted_total,
..
} => {
active_run_turn_count = Some(*active_turn_count);
total_turn_count = *persisted_total;
}
LogEntry::PausedTurnAbandoned { .. } => active_run_turn_count = None,
// Configuration, usage, and extension state are controller/storage
// authority rather than committed conversation.
LogEntry::ConfigChanged { .. }
| LogEntry::LlmUsage { .. }
| LogEntry::Extension { .. } => {}
}
@@ -2678,6 +2766,89 @@ mod tests {
);
}
#[test]
fn legacy_run_completed_yield_projects_as_nonterminal_run_yielded() {
let session_id = crate::new_session_id();
let legacy: LogEntry = serde_json::from_value(serde_json::json!({
"kind": "run_completed",
"ts": 30,
"interrupted": true,
"result": "yielded"
}))
.unwrap();
let snapshot = project_session_snapshot(
session_id,
&[
LogEntry::Invoke {
ts: 10,
trigger: protocol::InvokeKind::UserSend,
},
LogEntry::TurnEnd {
ts: 20,
turn_count: 4,
},
legacy,
],
);
assert_eq!(snapshot.entries.len(), 1);
assert!(matches!(
snapshot.entries[0].data,
SessionSnapshotEntryData::RunYielded {
reason: protocol::RunYieldReason::Compaction,
active_run_turn_count: 4,
}
));
}
#[test]
fn run_transitions_project_with_timestamps_and_typed_causes() {
let session_id = crate::new_session_id();
let log = vec![
LogEntry::RunYielded {
ts: 10,
reason: protocol::RunYieldReason::Compaction,
active_run_turn_count: 2,
},
LogEntry::RunResumed {
ts: 20,
source: protocol::RunResumeSource::Compaction,
active_run_turn_count: 2,
},
LogEntry::RunErrored {
ts: 30,
interrupted: false,
message: "mid-run compaction failed: unavailable".into(),
failure: Some(protocol::RunFailureKind::Compaction),
},
];
let snapshot = project_session_snapshot(session_id, &log);
assert_eq!(snapshot.entries.len(), 3);
assert_eq!(snapshot.entries[0].timestamp, 10);
assert!(matches!(
snapshot.entries[0].data,
SessionSnapshotEntryData::RunYielded {
reason: protocol::RunYieldReason::Compaction,
active_run_turn_count: 2,
}
));
assert!(matches!(
snapshot.entries[1].data,
SessionSnapshotEntryData::RunResumed {
source: protocol::RunResumeSource::Compaction,
active_run_turn_count: 2,
}
));
assert!(matches!(
snapshot.entries[2].data,
SessionSnapshotEntryData::RunError {
failure: Some(protocol::RunFailureKind::Compaction),
..
}
));
}
#[test]
fn retained_snapshot_read_projects_active_segment_without_rewriting_files() {
let root = tempfile::tempdir().unwrap();
+43 -1
View File
@@ -10,7 +10,7 @@ use crate::{LoggedHistoryEntry, LoggedSystemHistoryEntry, SegmentId, SessionId};
use agen::EngineResult;
use agen::llm_client::RequestConfig;
use agen::llm_client::types::Item;
use protocol::Segment;
use protocol::{RunFailureKind, RunResumeSource, RunYieldReason, Segment};
/// State snapshot for creating a SegmentStart entry.
pub struct SegmentStartState<'a> {
@@ -332,6 +332,47 @@ pub fn save_run_completed(
)
}
/// Log a non-terminal yield of one logical Run. New writers must use this
/// instead of encoding `EngineResult::Yielded` in `RunCompleted`.
pub fn save_run_yielded(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
reason: RunYieldReason,
active_run_turn_count: usize,
) -> Result<(), StoreError> {
append_entry(
store,
session_id,
segment_id,
LogEntry::RunYielded {
ts: segment_log::now_millis(),
reason,
active_run_turn_count,
},
)
}
/// Log the boundary immediately before Engine resumes one logical Run.
pub fn save_run_resumed(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
source: RunResumeSource,
active_run_turn_count: usize,
) -> Result<(), StoreError> {
append_entry(
store,
session_id,
segment_id,
LogEntry::RunResumed {
ts: segment_log::now_millis(),
source,
active_run_turn_count,
},
)
}
/// Log a `RunErrored` entry — `run()` / `resume()` returned `Err(EngineError)`.
///
/// `EngineError` is not `Serialize`, so the caller passes a lossy
@@ -351,6 +392,7 @@ pub fn save_run_errored(
ts: segment_log::now_millis(),
interrupted,
message,
failure: Some(RunFailureKind::Engine),
},
)
}
+99 -3
View File
@@ -11,7 +11,7 @@
use agen::llm_client::types::{Item, RequestConfig};
use agen::{EngineResult, UsageRecord};
use protocol::{InvokeKind, Segment};
use protocol::{InvokeKind, RunFailureKind, RunResumeSource, RunYieldReason, Segment};
use serde::{Deserialize, Serialize};
use crate::history::{LoggedHistoryEntry, LoggedSystemHistoryEntry};
@@ -126,12 +126,32 @@ pub enum LogEntry {
ts: u64,
interrupted: bool,
result: EngineResult,
/// AgentTurns consumed by a paused/yielded logical run. Terminal
/// outcomes persist `None`.
/// AgentTurns consumed by a paused legacy logical run. Terminal
/// outcomes persist `None`. Legacy logs may also contain Yielded here;
/// new yields use `RunYielded` below.
#[serde(default, skip_serializing_if = "Option::is_none")]
active_run_turn_count: Option<usize>,
},
/// The current logical Run returned control so compaction can prepare a new
/// Segment. This is an intermediate transition, not a successful terminal
/// result. Ordering plus Session/Segment lineage correlate it with the
/// preceding Invoke and a later RunResumed or terminal record.
RunYielded {
ts: u64,
reason: RunYieldReason,
active_run_turn_count: usize,
},
/// Resume preparation has completed and Engine execution is about to begin.
/// A committed record is required before control is handed back to Engine.
/// It deliberately does not assert that a subsequent model call occurred.
RunResumed {
ts: u64,
source: RunResumeSource,
active_run_turn_count: usize,
},
/// `run()` / `resume()` が `EngineError` で終了した。
/// `EngineError` は `Serialize` 不可なので `message` のみ lossy 保持する。
/// Audit-only metadata: replay は `interrupted` のみ反映する。
@@ -139,6 +159,10 @@ pub enum LogEntry {
ts: u64,
interrupted: bool,
message: String,
/// Older records omit this field. New records distinguish failures in
/// Engine execution from failure to continue after a compaction yield.
#[serde(default, skip_serializing_if = "Option::is_none")]
failure: Option<RunFailureKind>,
},
/// Restores an active logical-run budget at a segment boundary, notably
@@ -351,6 +375,20 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.active_run_turn_count = None;
}
}
LogEntry::RunYielded {
active_run_turn_count,
..
}
| LogEntry::RunResumed {
active_run_turn_count,
..
} => {
// Both are resumable boundaries of the same logical Run. A log
// ending at RunResumed proves only that the resume boundary was
// committed, not that Engine produced later output.
state.last_run_interrupted = true;
state.active_run_turn_count = Some(*active_run_turn_count);
}
LogEntry::RunErrored { interrupted, .. } => {
state.last_run_interrupted = *interrupted;
state.active_run_turn_count = None;
@@ -818,6 +856,64 @@ mod tests {
assert!(state.last_run_interrupted);
}
#[test]
fn replay_new_yield_and_resume_keep_one_logical_run_budget() {
let state = collect_state(&[
LogEntry::Invoke {
ts: 100,
trigger: InvokeKind::UserSend,
},
LogEntry::TurnEnd {
ts: 200,
turn_count: 2,
},
LogEntry::RunYielded {
ts: 300,
reason: RunYieldReason::Compaction,
active_run_turn_count: 2,
},
LogEntry::ActiveRunCheckpoint {
ts: 400,
active_turn_count: 2,
total_turn_count: 2,
},
LogEntry::RunResumed {
ts: 500,
source: RunResumeSource::Compaction,
active_run_turn_count: 2,
},
]);
assert!(state.last_run_interrupted);
assert_eq!(state.turn_count, 2);
assert_eq!(state.active_run_turn_count, Some(2));
}
#[test]
fn legacy_yielded_run_completed_remains_resumable_but_is_not_terminal_success() {
let entry: LogEntry = serde_json::from_value(serde_json::json!({
"kind": "run_completed",
"ts": 300,
"interrupted": true,
"result": "yielded"
}))
.expect("legacy yielded run-completed entry");
let state = collect_state(&[
LogEntry::Invoke {
ts: 100,
trigger: InvokeKind::UserSend,
},
LogEntry::TurnEnd {
ts: 200,
turn_count: 3,
},
entry,
]);
assert!(state.last_run_interrupted);
assert_eq!(state.active_run_turn_count, Some(3));
}
#[test]
fn legacy_interrupted_run_derives_budget_from_invoke_and_turn_end() {
let entry: LogEntry = serde_json::from_value(serde_json::json!({
+16 -5
View File
@@ -179,14 +179,13 @@ async fn run_and_persist(
session_store::save_turn_end(store, session_id, segment_id, worker.turn_count()).unwrap();
match &result {
agen::EngineRunExit::Finished
| agen::EngineRunExit::Paused
| agen::EngineRunExit::Yielded => {
agen::EngineRunExit::Finished | agen::EngineRunExit::Paused => {
let (legacy_result, interrupted) = match &result {
agen::EngineRunExit::Finished => (agen::EngineResult::Finished, false),
agen::EngineRunExit::Paused => (agen::EngineResult::Paused, true),
agen::EngineRunExit::Yielded => (agen::EngineResult::Yielded, true),
agen::EngineRunExit::Interrupted(_) => unreachable!(),
agen::EngineRunExit::Yielded | agen::EngineRunExit::Interrupted(_) => {
unreachable!()
}
};
session_store::save_run_completed(
store,
@@ -198,6 +197,18 @@ async fn run_and_persist(
)
.unwrap();
}
agen::EngineRunExit::Yielded => {
session_store::save_run_yielded(
store,
session_id,
segment_id,
protocol::RunYieldReason::Compaction,
worker
.active_run_turn_count()
.expect("yielded test run retains its turn budget"),
)
.unwrap();
}
agen::EngineRunExit::Interrupted(agen::RunInterruptionReason::LimitReached) => {
session_store::save_run_completed(
store,