feat: select rewind points directly from Console history

This commit is contained in:
2026-10-11 14:50:49 +09:00
parent 04bdf1da23
commit 5ac3b1e482
27 changed files with 694 additions and 545 deletions
+21 -39
View File
@@ -349,13 +349,11 @@ pub enum Method {
Compact {
command: WorkerCommandEnvelope,
},
/// Ask the Worker to list valid rewind targets from its authoritative session log.
ListRewindTargets,
/// Truncate the current session back to the selected rewind target and
/// return the selected user input to the client composer.
/// Rewind to just before a user input identified by its public Session entry ID.
/// Validate the target against the current segment at execution time.
/// Later appended history is discarded too; tool side effects are not undone.
RewindTo {
target: RewindTargetId,
expected_head_entries: usize,
entry_id: String,
},
Shutdown {
command: WorkerCommandEnvelope,
@@ -1421,12 +1419,6 @@ pub enum Event {
context: Option<CompletionContext>,
entries: Vec<CompletionEntry>,
},
/// Reply to `Method::ListRewindTargets`. Clients should only open a picker
/// in response to their own pending request; the event may be broadcast.
RewindTargets {
head_entries: usize,
targets: Vec<RewindTarget>,
},
/// A rewind has truncated the authoritative session. `session` is the
/// retained canonical snapshot clients should use to reseed display state.
RewindApplied {
@@ -1569,33 +1561,6 @@ pub struct CompletionEntry {
pub invocation: Option<FeatureInvocationDescriptor>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct RewindTargetId {
#[cfg_attr(feature = "typescript", ts(type = "string"))]
#[cfg_attr(feature = "json-schema", schemars(with = "String"))]
pub segment_id: uuid::Uuid,
pub user_input_entry_index: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct RewindTarget {
pub id: RewindTargetId,
pub expected_head_entries: usize,
pub truncate_entries: usize,
pub turn_index: usize,
pub timestamp_ms: Option<u64>,
pub preview: String,
pub eligible: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub warning: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
@@ -1998,6 +1963,23 @@ pub enum Permission {
mod tests {
use super::*;
#[test]
fn rewind_uses_public_entry_id_without_candidate_or_head_token() {
let method = Method::RewindTo {
entry_id: "user-entry".into(),
};
let wire = serde_json::json!({"method": "rewind_to", "params": {"entry_id": "user-entry"}});
assert_eq!(serde_json::to_value(&method).unwrap(), wire);
assert!(
matches!(serde_json::from_value::<Method>(wire).unwrap(), Method::RewindTo { entry_id } if entry_id == "user-entry")
);
assert!(
serde_json::from_value::<Method>(serde_json::json!({"method": "list_rewind_targets"}))
.is_err()
);
assert!(serde_json::from_value::<Method>(serde_json::json!({"method": "rewind_to", "params": {"target": {"segment_id": uuid::Uuid::nil(), "user_input_entry_index": 1}, "expected_head_entries": 3}})).is_err());
}
#[test]
fn scope_rule_defaults_to_resolved_symlink_policy() {
let rule: ScopeRule = serde_json::from_value(serde_json::json!({
+8 -10
View File
@@ -15,14 +15,14 @@ use crate::{
InvocationClientAdapter, InvocationCompletion, InvocationValue, InvokeKind, MemoryWorkerEvent,
Method, PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef,
PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission, ReasoningConfig,
RewindSummary, RewindTarget, RewindTargetId, RunFailureKind, RunResult, RunResumeSource,
RunYieldReason, ScopeRule, Segment, SessionContentPart, SessionConversationTurn,
SessionEntryProvenance, SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole,
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult,
UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement,
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
RewindSummary, RunFailureKind, RunResult, RunResumeSource, RunYieldReason, ScopeRule, Segment,
SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition,
SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef,
WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
WorkerStateSnapshot, WorkerStatus,
subscription::{
EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES,
MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS,
@@ -111,8 +111,6 @@ pub fn generated_protocol_types() -> String {
push_decl::<UploadedFileRef>(&cfg, &mut output);
push_decl::<ScopeRule>(&cfg, &mut output);
push_decl::<CompletionEntry>(&cfg, &mut output);
push_decl::<RewindTargetId>(&cfg, &mut output);
push_decl::<RewindTarget>(&cfg, &mut output);
push_decl::<RewindSummary>(&cfg, &mut output);
push_decl::<InFlightBlock>(&cfg, &mut output);
push_decl::<InFlightCompaction>(&cfg, &mut output);
-1
View File
@@ -401,7 +401,6 @@ pub enum WorkerInputKind {
UserIfIdle,
Notify,
Compact,
ListRewindTargets,
RegisterPeer,
}
+44 -59
View File
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
use protocol::{
AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, InFlightBlock,
InFlightSnapshot, InFlightToolCallState, InternalWorkerRef, InternalWorkerSnapshot, Method,
RewindTarget, RunResult, Segment, WorkerCommandEnvelope, WorkerStateSnapshot, WorkerStatus,
RunResult, Segment, WorkerCommandEnvelope, WorkerStateSnapshot, WorkerStatus,
};
use crate::block::{
@@ -90,10 +90,15 @@ pub struct RewindPickerScroll {
pub tail_top_offset: usize,
}
#[derive(Debug, Clone)]
pub struct RewindEntry {
pub entry_id: String,
pub preview: String,
}
#[derive(Debug, Clone)]
pub struct RewindPickerState {
pub head_entries: usize,
pub targets: Vec<RewindTarget>,
pub targets: Vec<RewindEntry>,
pub selected: usize,
pub scroll: RewindPickerScroll,
/// True after Enter submitted an authoritative `RewindTo` and before the
@@ -104,10 +109,9 @@ pub struct RewindPickerState {
}
impl RewindPickerState {
pub fn new(head_entries: usize, targets: Vec<RewindTarget>) -> Self {
let selected = targets.iter().position(|t| t.eligible).unwrap_or(0);
pub fn new(targets: Vec<RewindEntry>) -> Self {
let selected = 0;
Self {
head_entries,
targets,
selected,
scroll: RewindPickerScroll::default(),
@@ -115,7 +119,7 @@ impl RewindPickerState {
}
}
pub fn selected_target(&self) -> Option<&RewindTarget> {
pub fn selected_target(&self) -> Option<&RewindEntry> {
self.targets.get(self.selected)
}
}
@@ -334,7 +338,7 @@ pub struct App {
completion_target: String,
/// Dedicated main-view rewind picker state.
pub rewind_picker: Option<RewindPickerState>,
rewind_request_pending: bool,
rewind_entries: Vec<RewindEntry>,
/// After a successful rewind restore, ignore any queued live-update events
/// until the authoritative Worker status/snapshot catches up. This prevents
/// old stream tail events that were already in transit from re-polluting the
@@ -419,7 +423,7 @@ impl App {
pending_feature_edit: None,
completion_target: protocol::new_submission_request_id(),
rewind_picker: None,
rewind_request_pending: false,
rewind_entries: Vec::new(),
rewind_refresh_fence: false,
greeting: None,
task_store: TaskStore::new(),
@@ -1529,7 +1533,19 @@ impl App {
Event::PendingSubmissionsChanged { pending } => {
self.pending_submissions = pending;
}
Event::UserMessage { segments, .. } => {
Event::UserMessage { segments, entry_id } => {
if let Some(entry_id) = entry_id {
if !self
.rewind_entries
.iter()
.any(|entry| entry.entry_id == entry_id)
{
self.rewind_entries.push(RewindEntry {
entry_id,
preview: Segment::flatten_to_text(&segments),
});
}
}
self.turn_index += 1;
self.blocks.push(Block::TurnHeader {
turn: self.turn_index,
@@ -2038,15 +2054,6 @@ impl App {
state.selected = 0;
}
}
Event::RewindTargets {
head_entries,
targets,
} => {
if self.rewind_request_pending {
self.rewind_request_pending = false;
self.rewind_picker = Some(RewindPickerState::new(head_entries, targets));
}
}
Event::RewindApplied {
session,
input,
@@ -2467,14 +2474,14 @@ impl App {
return None;
}
self.completion = None;
self.rewind_picker = None;
self.rewind_request_pending = true;
Some(Method::ListRewindTargets)
self.rewind_picker = Some(RewindPickerState::new(
self.rewind_entries.iter().rev().cloned().collect(),
));
None
}
pub fn close_rewind_picker(&mut self) {
self.rewind_picker = None;
self.rewind_request_pending = false;
}
pub fn cancel_rewind_picker(&mut self) {
@@ -2539,17 +2546,8 @@ impl App {
);
return None;
}
let (target_id, expected_head_entries) = match picker.selected_target() {
Some(target) if target.eligible => (target.id.clone(), target.expected_head_entries),
Some(target) => {
self.push_command_diagnostic(
target
.disabled_reason
.clone()
.unwrap_or_else(|| "rewind target is disabled".into()),
);
return None;
}
let entry_id = match picker.selected_target() {
Some(target) => target.entry_id.clone(),
None => {
self.push_command_diagnostic("no rewind target is available");
return None;
@@ -2558,10 +2556,7 @@ impl App {
if let Some(picker) = self.rewind_picker.as_mut() {
picker.applying = true;
}
Some(Method::RewindTo {
target: target_id,
expected_head_entries,
})
Some(Method::RewindTo { entry_id })
}
fn command_environment(&self) -> CommandEnvironment {
@@ -2597,10 +2592,8 @@ impl App {
command: self.next_command_envelope(),
});
}
if let Some(Method::ListRewindTargets) = method.as_ref() {
self.completion = None;
self.rewind_picker = None;
self.rewind_request_pending = true;
if result.open_rewind_picker {
return self.request_rewind_picker();
}
method
}
@@ -2913,10 +2906,15 @@ impl App {
}
self.assistant_streaming = false;
self.rewind_entries.clear();
for entry in &session.entries {
use protocol::{SessionContentPart, SessionMessageRole, SessionSnapshotEntryData};
match &entry.data {
SessionSnapshotEntryData::UserInput { segments } => {
self.rewind_entries.push(RewindEntry {
entry_id: entry.entry_id.clone(),
preview: Segment::flatten_to_text(segments),
});
self.turn_index += 1;
self.blocks.push(Block::TurnHeader {
turn: self.turn_index,
@@ -3364,23 +3362,10 @@ mod rewind_refresh_tests {
fn app_with_rewind_picker() -> App {
let mut app = App::new("test".into());
app.connected = true;
app.rewind_picker = Some(RewindPickerState::new(
5,
vec![RewindTarget {
id: protocol::RewindTargetId {
segment_id: uuid::Uuid::nil(),
user_input_entry_index: 1,
},
expected_head_entries: 5,
truncate_entries: 2,
turn_index: 1,
timestamp_ms: None,
preview: "rewind target".into(),
eligible: true,
disabled_reason: None,
warning: None,
}],
));
app.rewind_picker = Some(RewindPickerState::new(vec![RewindEntry {
entry_id: "user-1".into(),
preview: "test input".into(),
}]));
app
}
+11 -3
View File
@@ -52,6 +52,7 @@ pub struct CommandEnvironment {
#[derive(Debug, Clone)]
pub struct CommandExecution {
pub method: Option<Method>,
pub open_rewind_picker: bool,
pub diagnostics: Vec<CommandDiagnostic>,
pub exit_command_mode: bool,
pub clear_input: bool,
@@ -60,6 +61,7 @@ pub struct CommandExecution {
impl CommandExecution {
pub fn diagnostic(message: impl Into<String>) -> Self {
Self {
open_rewind_picker: false,
method: None,
diagnostics: vec![CommandDiagnostic::new(message)],
exit_command_mode: false,
@@ -69,6 +71,7 @@ impl CommandExecution {
pub fn notice(message: impl Into<String>) -> Self {
Self {
open_rewind_picker: false,
method: None,
diagnostics: vec![CommandDiagnostic::new(message)],
exit_command_mode: true,
@@ -409,6 +412,7 @@ fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.environment;
let _ = invocation.args.raw();
CommandExecution {
open_rewind_picker: false,
method: Some(Method::Compact {
command: protocol::WorkerCommandEnvelope::new(0),
}),
@@ -423,7 +427,8 @@ fn rewind_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.environment;
let _ = invocation.args.raw();
CommandExecution {
method: Some(Method::ListRewindTargets),
open_rewind_picker: true,
method: None,
diagnostics: vec![CommandDiagnostic::new("rewind picker requested")],
exit_command_mode: true,
clear_input: true,
@@ -435,6 +440,7 @@ fn peer_command(invocation: CommandInvocation<'_>) -> CommandExecution {
let _ = invocation.environment;
let name = invocation.args.argv()[0].clone();
CommandExecution {
open_rewind_picker: false,
method: Some(Method::RegisterPeer { name: name.clone() }),
diagnostics: vec![CommandDiagnostic::new(format!(
"peer metadata registration requested with `{name}`"
@@ -521,7 +527,8 @@ mod tests {
let registry = CommandRegistry::builtins();
for command in ["rewind", "rollback"] {
let result = registry.dispatch(command, &env());
assert!(matches!(result.method, Some(Method::ListRewindTargets)));
assert!(result.method.is_none());
assert!(result.open_rewind_picker);
assert!(result.exit_command_mode);
assert!(result.clear_input);
assert!(result.diagnostics[0].message.contains("rewind picker"));
@@ -549,7 +556,8 @@ mod tests {
let mut paused = env();
paused.paused = true;
let result = registry.dispatch("rewind", &paused);
assert!(matches!(result.method, Some(Method::ListRewindTargets)));
assert!(result.method.is_none());
assert!(result.open_rewind_picker);
}
#[test]
+31 -69
View File
@@ -18,7 +18,7 @@ use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use crossterm::{Command, execute};
use protocol::{Event, Method, Segment, UploadedFileRef, WorkerStatus};
#[cfg(feature = "e2e-test")]
use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId};
use protocol::{Greeting, RewindSummary};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use standalone::{StandaloneHost, StandaloneLaunchConfig};
@@ -758,10 +758,10 @@ async fn run_e2e_rewind_fixture(
});
let (_reader, mut term_rx) = TerminalEventReader::spawn()?;
let target_id = RewindTargetId {
segment_id: uuid::Uuid::from_u128(1),
user_input_entry_index: 1,
};
app.handle_worker_event(Event::UserMessage {
entry_id: Some("rewind-fixture-input".into()),
segments: vec![Segment::text("candidate rewind target")],
});
let mut rewind_submit_count = 0usize;
let mut pending_apply: Option<std::time::Instant> = None;
let apply_delay = Duration::from_millis(400);
@@ -803,45 +803,25 @@ async fn run_e2e_rewind_fixture(
.as_ref()
.map(|picker| picker.applying)
.unwrap_or(false);
if let Some(method) = handle_key(&mut app, key) {
let was_open = app.rewind_picker.is_some();
let method = handle_key(&mut app, key);
if !was_open && app.rewind_picker.is_some() {
crate::e2e_observer::emit(
"single_worker",
"rewind_picker_opened",
serde_json::json!({"targets": 1, "selected_preview": "candidate rewind target"}),
);
}
if let Some(method) = method {
match method {
Method::ListRewindTargets => {
app.handle_worker_event(Event::RewindTargets {
head_entries: 3,
targets: vec![RewindTarget {
id: target_id.clone(),
expected_head_entries: 3,
truncate_entries: 1,
turn_index: 1,
timestamp_ms: Some(1),
preview: "candidate rewind target".to_string(),
eligible: true,
disabled_reason: None,
warning: None,
}],
});
crate::e2e_observer::emit(
"single_worker",
"rewind_picker_opened",
serde_json::json!({
"targets": 1,
"selected_preview": "candidate rewind target",
}),
);
}
Method::RewindTo {
target,
expected_head_entries,
} => {
Method::RewindTo { entry_id } => {
rewind_submit_count += 1;
pending_apply = Some(std::time::Instant::now());
crate::e2e_observer::emit(
"single_worker",
"rewind_submit_sent",
serde_json::json!({
"segment_id": target.segment_id.to_string(),
"user_input_entry_index": target.user_input_entry_index,
"expected_head_entries": expected_head_entries,
"entry_id": entry_id,
"submit_count": rewind_submit_count,
}),
);
@@ -1725,10 +1705,7 @@ mod tests {
use super::*;
use crate::text_selection::{HistoryViewport, SelectionRow};
use async_trait::async_trait;
use protocol::{
Event, RewindTarget, RewindTargetId, Segment, UploadedFileAvailability, UploadedFileRef,
WorkerStatus,
};
use protocol::{Event, Segment, UploadedFileAvailability, UploadedFileRef, WorkerStatus};
#[test]
fn standalone_console_starts_with_in_process_connection_ready() {
@@ -3119,14 +3096,17 @@ mod tests {
&mut app,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
);
assert!(matches!(idle, Some(Method::ListRewindTargets)));
assert!(idle.is_none());
assert!(app.rewind_picker.is_some());
app.close_rewind_picker();
app.set_worker_status(WorkerStatus::Paused);
let paused = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
);
assert!(matches!(paused, Some(Method::ListRewindTargets)));
assert!(paused.is_none());
assert!(app.rewind_picker.is_some());
}
#[test]
@@ -3148,21 +3128,13 @@ mod tests {
fn rewind_picker_close_returns_to_history_view() {
let mut app = App::new("agent".to_string());
app.connected = true;
app.handle_worker_event(Event::RewindTargets {
head_entries: 1,
targets: vec![],
});
assert!(app.rewind_picker.is_none());
let method = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL),
);
assert!(matches!(method, Some(Method::ListRewindTargets)));
app.handle_worker_event(Event::RewindTargets {
head_entries: 1,
targets: vec![],
});
assert!(method.is_none());
assert!(app.rewind_picker.is_some());
let method = handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
@@ -3244,13 +3216,13 @@ mod tests {
#[test]
fn rewind_apply_rejects_non_empty_composer_and_paused_status() {
let mut app = App::new("agent".to_string());
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
app.rewind_picker = Some(crate::app::RewindPickerState::new(vec![rewind_target()]));
type_keys(&mut app, "draft");
assert!(app.submit_rewind_picker().is_none());
assert!(has_alert(&app, "composer is not empty"));
let mut app = App::new("agent".to_string());
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
app.rewind_picker = Some(crate::app::RewindPickerState::new(vec![rewind_target()]));
app.set_worker_status(WorkerStatus::Paused);
assert!(app.submit_rewind_picker().is_none());
assert!(has_alert(
@@ -3265,7 +3237,7 @@ mod tests {
app.scroll.top_offset = 3;
app.scroll.turn_starts = vec![0, 5, 9];
app.scroll.total_lines = 42;
app.rewind_picker = Some(crate::app::RewindPickerState::new(1, vec![rewind_target()]));
app.rewind_picker = Some(crate::app::RewindPickerState::new(vec![rewind_target()]));
let original_top_offset = app.scroll.top_offset;
let original_turn_starts = app.scroll.turn_starts.clone();
let original_total_lines = app.scroll.total_lines;
@@ -3282,20 +3254,10 @@ mod tests {
assert_eq!(app.scroll.total_lines, original_total_lines);
}
fn rewind_target() -> RewindTarget {
RewindTarget {
id: RewindTargetId {
segment_id: uuid::Uuid::nil(),
user_input_entry_index: 0,
},
expected_head_entries: 1,
truncate_entries: 0,
turn_index: 1,
timestamp_ms: Some(1),
preview: "retry this".into(),
eligible: true,
disabled_reason: None,
warning: None,
fn rewind_target() -> crate::app::RewindEntry {
crate::app::RewindEntry {
entry_id: "user-1".into(),
preview: "test input".into(),
}
}
+2 -27
View File
@@ -728,7 +728,7 @@ fn draw_rewind_picker(
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!(" head={} ", picker.head_entries)),
Span::raw(" "),
];
header.extend(action_spans);
logical.push(Line::from(header));
@@ -751,38 +751,13 @@ fn draw_rewind_picker(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
} else if target.eligible {
Style::default()
} else {
Style::default().fg(Color::DarkGray)
Style::default()
};
let ts = target
.timestamp_ms
.map(|ts| format!("{}", ts))
.unwrap_or_else(|| "-".into());
logical.push(Line::from(vec![
Span::styled(marker.to_owned(), base_style),
Span::styled(
format!(
" turn {} idx {} ts {} ",
target.turn_index, target.id.user_input_entry_index, ts
),
base_style,
),
Span::styled(target.preview.clone(), base_style),
]));
if let Some(warning) = target.warning.as_ref() {
logical.push(Line::from(Span::styled(
format!(" warning: {warning}"),
Style::default().fg(Color::Yellow),
)));
}
if let Some(reason) = target.disabled_reason.as_ref() {
logical.push(Line::from(Span::styled(
format!(" disabled: {reason}"),
Style::default().fg(Color::Red),
)));
}
}
}
+1 -2
View File
@@ -12,13 +12,12 @@ pub enum WorkerInputKind {
UserIfIdle,
Notify,
Compact,
ListRewindTargets,
RegisterPeer,
}
impl WorkerInputKind {
pub fn is_empty_content_allowed(&self) -> bool {
matches!(self, Self::Compact | Self::ListRewindTargets)
matches!(self, Self::Compact)
}
}
+10 -10
View File
@@ -6752,16 +6752,16 @@ fn input_protocol_event(input: &WorkerInput) -> Option<protocol::Event> {
// Submit is projected only after the Worker commits UserInput. Queued
// payloads must never become model- or client-visible history early.
WorkerInputKind::User | WorkerInputKind::UserIfIdle | WorkerInputKind::Notify => None,
WorkerInputKind::Compact
| WorkerInputKind::ListRewindTargets
| WorkerInputKind::RegisterPeer => Some(protocol::Event::SystemItem {
entry_id: None,
item: serde_json::json!({
"kind": "embedded_worker_command_input",
"command": input.kind,
"content": input.content.clone(),
}),
}),
WorkerInputKind::Compact | WorkerInputKind::RegisterPeer => {
Some(protocol::Event::SystemItem {
entry_id: None,
item: serde_json::json!({
"kind": "embedded_worker_command_input",
"command": input.kind,
"content": input.content.clone(),
}),
})
}
}
}
+1 -2
View File
@@ -4255,7 +4255,6 @@ where
unreachable!("Notify input is dispatched before ordinary input mapping")
}
WorkerInputKind::Compact => unreachable!("compact input is dispatched above"),
WorkerInputKind::ListRewindTargets => (Method::ListRewindTargets, None),
WorkerInputKind::RegisterPeer => (
Method::RegisterPeer {
name: input.content.trim().to_string(),
@@ -7884,7 +7883,7 @@ mod tests {
));
let old_send = backend.run_on_adapter_runtime(async move {
old_handle
.send(Method::ListRewindTargets)
.send(Method::ListWorkers)
.await
.map_err(|error| error.to_string())
});
+11 -55
View File
@@ -32,8 +32,8 @@ use protocol::{
AlertLevel, AlertSource, CommandEvent as ProtocolCommandEvent,
CommandSnapshot as ProtocolCommandSnapshot, CommandStatus as ProtocolCommandStatus,
CommandStream as ProtocolCommandStream, CommandStreamSlice as ProtocolCommandStreamSlice,
ErrorCode, Event, Method, RewindTargetId, RunResult, TurnResult, UploadedFileRef,
WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
ErrorCode, Event, Method, RunResult, TurnResult, UploadedFileRef, WorkerBusyState,
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
WorkerCommandKind, WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStatus,
};
use workdir::{
@@ -2971,32 +2971,9 @@ async fn controller_loop<C, St>(
}
}
Method::ListRewindTargets => match shared_state.catalog_status() {
WorkerStatus::Idle | WorkerStatus::Paused => {
emit_rewind_targets(&worker, &working_event_tx)
}
WorkerStatus::Running | WorkerStatus::Stopped => {
let _ = working_event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn; rewind can only run while idle or paused"
.into(),
});
}
},
Method::RewindTo {
target,
expected_head_entries,
} => match shared_state.catalog_status() {
Method::RewindTo { entry_id } => match shared_state.catalog_status() {
WorkerStatus::Idle => {
if apply_rewind(
&mut worker,
&working_event_tx,
target,
expected_head_entries,
)
.await
{
if apply_rewind(&mut worker, &working_event_tx, entry_id).await {
worker.clear_in_flight_events();
publish_context_usage(
&shared_state,
@@ -3018,8 +2995,9 @@ async fn controller_loop<C, St>(
WorkerStatus::Running | WorkerStatus::Stopped => {
let _ = working_event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn; rewind can only run while idle or paused"
.into(),
message:
"Worker is already executing a turn; rewind can only run while idle"
.into(),
});
}
},
@@ -3668,10 +3646,10 @@ where
);
}
}
Some(Method::ListRewindTargets | Method::RewindTo { .. }) => {
Some(Method::RewindTo { .. }) => {
let _ = working_event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn; rewind/compact can only run while idle or paused"
message: "Worker is already executing a turn; rewind/compact can only run while idle"
.into(),
});
}
@@ -3749,38 +3727,16 @@ where
}
}
fn emit_rewind_targets<C, St>(worker: &Worker<C, St>, working_event_tx: &broadcast::Sender<Event>)
where
C: LlmClient + 'static,
St: Store,
{
match worker.list_rewind_targets() {
Ok((head_entries, targets)) => {
let _ = working_event_tx.send(Event::RewindTargets {
head_entries,
targets,
});
}
Err(err) => {
let _ = working_event_tx.send(Event::Error {
code: ErrorCode::Internal,
message: err.to_string(),
});
}
}
}
async fn apply_rewind<C, St>(
worker: &mut Worker<C, St>,
working_event_tx: &broadcast::Sender<Event>,
target: RewindTargetId,
expected_head_entries: usize,
entry_id: String,
) -> bool
where
C: LlmClient + 'static,
St: Store,
{
match worker.rewind_to(target, expected_head_entries).await {
match worker.rewind_to(entry_id).await {
Ok(applied) => {
let session =
session_store::public_snapshot::project_current_session_snapshot(&applied.entries);
+179 -140
View File
@@ -531,8 +531,8 @@ use crate::skill::{SkillActivationResponse, SkillClientError};
use async_trait::async_trait;
use protocol::{
AlertLevel, AlertSource, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
CompactionTrigger, ErrorCode, Event, InFlightCompaction, RewindSummary, RewindTarget,
RewindTargetId, RunFailureKind, RunResumeSource, RunYieldReason, Segment,
CompactionTrigger, ErrorCode, Event, InFlightCompaction, RewindSummary, RunFailureKind,
RunResumeSource, RunYieldReason, Segment,
};
use tokio::net::UnixStream;
use tokio::sync::broadcast;
@@ -3714,22 +3714,15 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
&self.store
}
/// List user-submitted turns in newest-first order for the manual rewind picker.
pub fn list_rewind_targets(&self) -> Result<(usize, Vec<RewindTarget>), RewindError> {
/// Rewind the current segment to just before the specified public user input.
pub async fn rewind_to(&mut self, entry_id: String) -> Result<RewindAppliedState, RewindError> {
// Reject invalid targets before invoking rewrite hooks or quiescing tasks.
// Resolve again under the append lock after the asynchronous barrier.
let loc = self.segment_state.location();
let entries = self.store.read_all(loc.session_id, loc.segment_id)?;
Ok((
entries.len(),
build_rewind_targets(loc.segment_id, &entries),
))
}
/// Truncate the current segment to just before a previously listed user input.
pub async fn rewind_to(
&mut self,
target: RewindTargetId,
expected_head_entries: usize,
) -> Result<RewindAppliedState, RewindError> {
resolve_rewind_input(
&self.store.read_all(loc.session_id, loc.segment_id)?,
&entry_id,
)?;
let _rewrite_guard = self
.prepare_session_rewrite(SessionRewriteKind::Rewind)
.await
@@ -3742,29 +3735,9 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
.lock()
.expect("segment append lock poisoned");
let loc = self.segment_state.location();
if target.segment_id != loc.segment_id {
return Err(RewindError::Invalid(
"rewind target belongs to a different segment".into(),
));
}
let entries = self.store.read_all(loc.session_id, loc.segment_id)?;
if entries.len() != expected_head_entries {
return Err(RewindError::Invalid(format!(
"session head changed since picker opened (expected {expected_head_entries}, current {})",
entries.len()
)));
}
let input = match entries.get(target.user_input_entry_index) {
Some(LogEntry::AnnotatedUserInput { segments, .. }) => segments.clone(),
_ => {
return Err(RewindError::Invalid(
"rewind target is no longer a user message".into(),
));
}
};
let truncate_entries = rewind_truncate_entries(&entries, target.user_input_entry_index);
let (entry_index, input) = resolve_rewind_input(&entries, &entry_id)?;
let truncate_entries = rewind_truncate_entries(&entries, entry_index);
let retained = entries[..truncate_entries].to_vec();
let tool_side_effect_warning = suffix_has_tool_side_effects(&entries[truncate_entries..]);
let state = segment_log::collect_state(&retained);
@@ -8994,37 +8967,21 @@ pub struct RewindAppliedState {
pub summary: RewindSummary,
}
fn build_rewind_targets(segment_id: uuid::Uuid, entries: &[LogEntry]) -> Vec<RewindTarget> {
let head_entries = entries.len();
let mut turn_index = 0usize;
let mut targets = Vec::new();
for (entry_index, entry) in entries.iter().enumerate() {
let (segments, ts) = match entry {
LogEntry::AnnotatedUserInput { segments, ts, .. } => (segments, ts),
_ => continue,
};
turn_index += 1;
let truncate_entries = rewind_truncate_entries(entries, entry_index);
let tool_warning = suffix_has_tool_side_effects(&entries[truncate_entries..]);
targets.push(RewindTarget {
id: RewindTargetId {
segment_id,
user_input_entry_index: entry_index,
},
expected_head_entries: head_entries,
truncate_entries,
turn_index,
timestamp_ms: Some(*ts),
preview: preview_segments(segments),
eligible: true,
disabled_reason: None,
warning: tool_warning.then(|| {
"history suffix will be discarded; tool side effects are not undone".into()
}),
});
}
targets.reverse();
targets
fn resolve_rewind_input(
entries: &[LogEntry],
entry_id: &str,
) -> Result<(usize, Vec<Segment>), RewindError> {
// Resolve only actual user submissions in this segment, never seeded
// ancestor history or a display position inferred from matching text.
entries.iter().enumerate().find_map(|(index, entry)| {
let LogEntry::AnnotatedUserInput { history, segments, .. } = entry else { return None; };
let user = history.iter().find(|entry| matches!(&entry.item,
session_store::LoggedItem::Message { role: session_store::LoggedRole::User, .. }
))?;
(user.metadata.entry_id.0 == entry_id).then(|| (index, segments.clone()))
}).ok_or_else(|| RewindError::Invalid(
"rewind entry is not a user input in the current segment; it may have been removed or belong to another history".into()
))
}
fn rewind_truncate_entries(entries: &[LogEntry], user_input_entry_index: usize) -> usize {
@@ -9052,6 +9009,7 @@ fn logged_item_is_tool_call(item: &session_store::LoggedItem) -> bool {
matches!(item, session_store::LoggedItem::ToolCall { .. })
}
#[cfg(test)]
fn preview_segments(segments: &[Segment]) -> String {
let mut preview = String::new();
for segment in segments {
@@ -12305,48 +12263,139 @@ mod build_summary_prompt_tests {
);
}
fn rewind_test_inputs<C: LlmClient + 'static, St: Store>(
worker: &Worker<C, St>,
) -> Vec<(String, usize)> {
let loc = worker.segment_state.location();
let entries = worker
.store
.read_all(loc.session_id, loc.segment_id)
.unwrap();
let public = session_store::public_snapshot::project_current_session_snapshot(&entries);
public
.entries
.iter()
.filter_map(|entry| {
if !matches!(
entry.data,
protocol::SessionSnapshotEntryData::UserInput { .. }
) {
return None;
}
let index = entries.iter().position(|log| {
matches!(log, LogEntry::AnnotatedUserInput { history, .. }
if history.iter().any(|h| h.metadata.entry_id.0 == entry.entry_id))
})?;
Some((
entry.entry_id.clone(),
rewind_truncate_entries(&entries, index),
))
})
.rev()
.collect()
}
#[tokio::test]
async fn rewind_target_listing_is_newest_first_and_warns_on_tool_suffix() {
let (_dir, worker) = rewind_test_worker().await;
append_user_turn(&worker, 10, "first message");
append_user_turn(&worker, 20, "second message");
async fn rewind_resolves_duplicate_text_by_public_entry_identity() {
let (_dir, mut worker) = rewind_test_worker().await;
append_user_turn(&worker, 10, "same input");
append_user_turn(&worker, 20, "same input");
let inputs = rewind_test_inputs(&worker);
assert_ne!(inputs[0].0, inputs[1].0);
worker.rewind_to(inputs[0].0.clone()).await.unwrap();
assert_eq!(rewind_test_inputs(&worker), vec![inputs[1].clone()]);
}
#[tokio::test]
async fn rewind_rejects_unknown_foreign_and_non_user_ids_without_changing_log() {
let (_dir, mut worker) = rewind_test_worker().await;
let (_other_dir, other) = rewind_test_worker().await;
append_user_turn(&worker, 10, "same input");
append_user_turn(&other, 10, "same input");
let assistant =
crate::session_history::test_logged_history_entry(session_store::LoggedItem::Message {
role: session_store::LoggedRole::Assistant,
content: vec![session_store::LoggedContentPart::Text {
text: "answer".into(),
}],
});
let assistant_id = assistant.metadata.entry_id.0.clone();
append_test_entry(
&worker,
LogEntry::AnnotatedToolResult {
ts: 30,
entry: crate::session_history::test_logged_history_entry(
session_store::LoggedItem::ToolResult {
call_id: "call-1".into(),
summary: "wrote a file".into(),
content: None,
attachments: Vec::new(),
disposition: Default::default(),
is_error: false,
},
),
LogEntry::AnnotatedAssistantItem {
ts: 12,
entry: assistant,
},
);
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
let loc = worker.segment_state.location();
let before = worker
.store
.read_all(loc.session_id, loc.segment_id)
.unwrap();
for id in [
String::new(),
"missing-entry".into(),
rewind_test_inputs(&other)[0].0.clone(),
assistant_id,
] {
assert!(
worker
.rewind_to(id)
.await
.unwrap_err()
.to_string()
.contains("not a user input")
);
assert_eq!(
serde_json::to_value(
worker
.store
.read_all(loc.session_id, loc.segment_id)
.unwrap()
)
.unwrap(),
serde_json::to_value(&before).unwrap()
);
}
}
assert_eq!(
head_entries,
worker
.store
.read_all(loc.session_id, loc.segment_id)
.unwrap()
.len()
#[tokio::test]
async fn rewind_rejects_user_input_from_seeded_ancestor_history() {
let (_dir, mut worker) = rewind_test_worker().await;
let input = crate::session_history::test_logged_history_entry(
session_store::LoggedItem::from(agen::Item::user_message("ancestor input")),
);
assert_eq!(targets.len(), 2);
assert_eq!(targets[0].preview, "second message");
assert_eq!(targets[1].preview, "first message");
let id = input.metadata.entry_id.0.clone();
let loc = worker.segment_state.location();
let mut entries = worker
.store
.read_all(loc.session_id, loc.segment_id)
.unwrap();
let LogEntry::AnnotatedSegmentStart { history, .. } = &mut entries[0] else {
panic!("segment start");
};
history.push(input);
worker
.store
.create_segment(loc.session_id, loc.segment_id, &entries)
.unwrap();
assert!(
targets[0]
.warning
.as_ref()
.unwrap()
.contains("tool side effects")
worker
.rewind_to(id)
.await
.unwrap_err()
.to_string()
.contains("current segment")
);
assert_eq!(
serde_json::to_value(
worker
.store
.read_all(loc.session_id, loc.segment_id)
.unwrap()
)
.unwrap(),
serde_json::to_value(entries).unwrap()
);
}
@@ -12371,11 +12420,11 @@ mod build_summary_prompt_tests {
),
},
);
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
let expected_truncate_entries = targets[0].truncate_entries;
let target = targets[0].id.clone();
let targets = rewind_test_inputs(&worker);
let expected_truncate_entries = targets[0].1;
let target = targets[0].0.clone();
let applied = worker.rewind_to(target, head_entries).await.unwrap();
let applied = worker.rewind_to(target).await.unwrap();
assert_eq!(preview_segments(&applied.input), "second message");
assert_eq!(
@@ -12405,10 +12454,10 @@ mod build_summary_prompt_tests {
.pending_submission_handle()
.accept_notification("notification-1".into(), "keep me".into())
.unwrap();
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
let targets = rewind_test_inputs(&worker);
worker
.rewind_to(targets.last().unwrap().id.clone(), head_entries)
.rewind_to(targets.last().unwrap().0.clone())
.await
.unwrap();
@@ -12465,16 +12514,11 @@ mod build_summary_prompt_tests {
},
);
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
let targets = rewind_test_inputs(&worker);
assert_eq!(targets.len(), 2);
assert_eq!(targets[0].preview, "second message");
assert!(targets[0].truncate_entries > 0);
assert!(targets[0].warning.is_some());
assert!(targets[0].1 > 0);
let applied = worker
.rewind_to(targets[0].id.clone(), head_entries)
.await
.unwrap();
let applied = worker.rewind_to(targets[0].0.clone()).await.unwrap();
assert_eq!(applied.summary.truncated_to_entries, 5);
assert!(matches!(
applied.entries.first(),
@@ -12539,19 +12583,20 @@ mod build_summary_prompt_tests {
}
#[tokio::test]
async fn rewind_apply_rejects_stale_head() {
async fn rewind_by_entry_id_includes_history_appended_after_selection() {
let (_dir, mut worker) = rewind_test_worker().await;
append_user_turn(&worker, 10, "first message");
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
let targets = rewind_test_inputs(&worker);
append_user_turn(&worker, 20, "newer message");
let err = worker
.rewind_to(targets[0].id.clone(), head_entries)
.await
.unwrap_err()
.to_string();
assert!(err.contains("session head changed"));
let applied = worker.rewind_to(targets[0].0.clone()).await.unwrap();
assert_eq!(preview_segments(&applied.input), "first message");
assert!(rewind_test_inputs(&worker).is_empty());
let err = worker.rewind_to(targets[0].0.clone()).await.unwrap_err();
assert!(
err.to_string()
.contains("not a user input in the current segment")
);
}
#[tokio::test]
@@ -15421,7 +15466,7 @@ permission = "write"
assert!(pending.pending_notifications.is_empty());
assert!(pending.activating_notifications.is_empty());
}
let (head, targets) = original.list_rewind_targets().unwrap();
let targets = rewind_test_inputs(&original);
let target = targets.last().unwrap();
let old = store
.read_all(original.session_id(), original.segment_id())
@@ -15431,7 +15476,7 @@ permission = "write"
store
.invocation_failure
.store(replacement_fault, Ordering::SeqCst);
let applied = original.rewind_to(target.id.clone(), head).await;
let applied = original.rewind_to(target.0.clone()).await;
let disk = store
.read_all(original.session_id(), original.segment_id())
.unwrap();
@@ -15456,15 +15501,9 @@ permission = "write"
replacement_fault
);
let applied = applied.unwrap();
assert_eq!(
applied.summary.truncated_to_entries,
target.truncate_entries
);
assert_eq!(
applied.summary.discarded_entries,
head - target.truncate_entries
);
assert_eq!(applied.entries.len(), target.truncate_entries);
assert_eq!(applied.summary.truncated_to_entries, target.1);
assert_eq!(applied.summary.discarded_entries, old.len() - target.1);
assert_eq!(applied.entries.len(), target.1);
assert_eq!(original.segment_state.entries_written(), disk.len());
assert_eq!(
serde_json::to_value(original.sink.subscribe_with_snapshot().0)
@@ -15474,10 +15513,10 @@ permission = "write"
} else {
assert!(applied.is_err());
}
assert_eq!(disk.len(), target.truncate_entries + 1);
assert_eq!(disk.len(), target.1 + 1);
assert_eq!(
serde_json::to_value(&disk[..target.truncate_entries]).unwrap(),
serde_json::to_value(&old[..target.truncate_entries]).unwrap()
serde_json::to_value(&disk[..target.1]).unwrap(),
serde_json::to_value(&old[..target.1]).unwrap()
);
let LogEntry::Extension {
domain, payload, ..
+1 -1
View File
@@ -2730,7 +2730,7 @@ async fn shutdown_closes_method_admission_before_terminal_confirmation() {
loop {
tokio::select! {
biased;
result = handle.send(Method::ListRewindTargets) => {
result = handle.send(Method::ListWorkers) => {
if result.is_err() {
break;
}
-4
View File
@@ -705,7 +705,6 @@ pub enum WorkerInputKind {
UserIfIdle,
Notify,
Compact,
ListRewindTargets,
RegisterPeer,
}
@@ -3271,7 +3270,6 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle,
WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify,
WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact,
WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets,
WorkerInputKind::RegisterPeer => EmbeddedWorkerInputKind::RegisterPeer,
},
content: request.content,
@@ -5310,7 +5308,6 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
WorkerInputKind::UserIfIdle => EmbeddedWorkerInputKind::UserIfIdle,
WorkerInputKind::Notify => EmbeddedWorkerInputKind::Notify,
WorkerInputKind::Compact => EmbeddedWorkerInputKind::Compact,
WorkerInputKind::ListRewindTargets => EmbeddedWorkerInputKind::ListRewindTargets,
WorkerInputKind::RegisterPeer => EmbeddedWorkerInputKind::RegisterPeer,
},
content: request.content,
@@ -6748,7 +6745,6 @@ mod tests {
EmbeddedWorkerInputKind::UserIfIdle,
EmbeddedWorkerInputKind::Notify,
EmbeddedWorkerInputKind::Compact,
EmbeddedWorkerInputKind::ListRewindTargets,
EmbeddedWorkerInputKind::RegisterPeer,
] {
let input = EmbeddedWorkerInput {
-2
View File
@@ -6930,7 +6930,6 @@ fn worker_input_request_from_api(
"user" => WorkerInputKind::User,
"notify" => WorkerInputKind::Notify,
"compact" => WorkerInputKind::Compact,
"list_rewind_targets" => WorkerInputKind::ListRewindTargets,
"register_peer" => WorkerInputKind::RegisterPeer,
value => {
return Err(Error::InvalidInput(format!(
@@ -29168,7 +29167,6 @@ pub(crate) fn authorize_browser_worker_method(
| protocol::Method::Cancel { .. }
| protocol::Method::Pause { .. }
| protocol::Method::Compact { .. }
| protocol::Method::ListRewindTargets
| protocol::Method::RewindTo { .. }
| protocol::Method::Shutdown { .. }
| protocol::Method::ListCompletions { .. }