Defer persistence of rollout file (#11028)

- Defer rollout persistence for fresh threads (`InitialHistory::New`):
keep rollout events in memory and only materialize rollout file + state
DB row on first `EventMsg::UserMessage`.
- Keep precomputed rollout path available before materialization.
- Change `thread/start` to build thread response from live config
snapshot and optional precomputed path.
- Improve pre-materialization behavior in app-server/TUI: clearer
invalid-request errors for file-backed ops and a friendlier `/fork` “not
ready yet” UX.
- Update tests to match deferred semantics across
start/read/archive/unarchive/fork/resume/review flows.
- Improved resilience of user_shell test, which should be unrelated to
this change but must be affected by timing changes

For Reviewers:
* The primary change is in recorder.rs
* Most of the other changes were to fix up broken assumptions in
existing tests

Testing:
* Manually tested CLI
* Exercised app server paths by manually running IDE Extension with
rebuilt CLI binary
* Only user-visible change is that `/fork` in TUI generates visible
error if used prior to first turn
This commit is contained in:
Eric Traut
2026-02-07 23:05:03 -08:00
committed by GitHub
parent 6d08298f4e
commit b3de6c7f2b
19 changed files with 983 additions and 195 deletions

View File

@@ -1445,46 +1445,57 @@ impl App {
self.chat_widget
.add_plain_history_lines(vec!["/fork".magenta().into()]);
if let Some(path) = self.chat_widget.rollout_path() {
match self
.server
.fork_thread(usize::MAX, self.config.clone(), path.clone())
.await
{
Ok(forked) => {
self.shutdown_current_thread().await;
let init = self.chatwidget_init_for_forked_or_resumed_thread(
tui,
self.config.clone(),
);
self.chat_widget = ChatWidget::new_from_existing(
init,
forked.thread,
forked.session_configured,
);
self.reset_thread_event_state();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> =
vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
let spans = vec![
"To continue this session, run ".into(),
command.cyan(),
];
lines.push(spans.into());
// Fresh threads expose a precomputed path, but the file is
// materialized lazily on first user message.
if path.exists() {
match self
.server
.fork_thread(usize::MAX, self.config.clone(), path.clone())
.await
{
Ok(forked) => {
self.shutdown_current_thread().await;
let init = self.chatwidget_init_for_forked_or_resumed_thread(
tui,
self.config.clone(),
);
self.chat_widget = ChatWidget::new_from_existing(
init,
forked.thread,
forked.session_configured,
);
self.reset_thread_event_state();
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> =
vec![summary.usage_line.clone().into()];
if let Some(command) = summary.resume_command {
let spans = vec![
"To continue this session, run ".into(),
command.cyan(),
];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
}
self.chat_widget.add_plain_history_lines(lines);
}
Err(err) => {
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to fork current session from {path_display}: {err}"
));
}
}
Err(err) => {
let path_display = path.display();
self.chat_widget.add_error_message(format!(
"Failed to fork current session from {path_display}: {err}"
));
}
} else {
self.chat_widget.add_error_message(
"A thread must contain at least one turn before it can be forked."
.to_string(),
);
}
} else {
self.chat_widget
.add_error_message("Current session is not ready to fork yet.".to_string());
self.chat_widget.add_error_message(
"A thread must contain at least one turn before it can be forked."
.to_string(),
);
}
tui.frame_requester().schedule_frame();

View File

@@ -6845,6 +6845,12 @@ impl ChatWidget {
pub(crate) fn thread_name(&self) -> Option<String> {
self.thread_name.clone()
}
/// Returns the current thread's precomputed rollout path.
///
/// For fresh non-ephemeral threads this path may exist before the file is
/// materialized; rollout persistence is deferred until the first user
/// message is recorded.
pub(crate) fn rollout_path(&self) -> Option<PathBuf> {
self.current_rollout_path.clone()
}