Persist logical run yield and resume transitions
This commit is contained in:
@@ -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};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user