runtime: don't record an unmodified temp as the --resume target

On launch an empty temp project is created but, by design
(ADR-0015), auto-deleted on quit while still empty. The
unconditional `write_last_project` at startup recorded that
temp's path anyway, so a later `--resume` resolved to a
since-deleted directory and printed a confusing
"recorded project … no longer exists".

All three resume-pointer writes are now gated on
`!project.is_unmodified_temp()`: the startup write, the
on-switch write (a `new`-command switch to a fresh temp no
longer records it), and a new on-quit write. The quit write is
where a launch-temp the user *filled with content* finally
gets remembered — startup skipped it while it was still empty.
An unmodified empty temp is deleted, never recorded; the two
dispositions are mutually exclusive.

The "no previous project" friendly error the user asked for
already exists (`project.resume_no_previous`, wired in the
resume resolution) — verified, no change needed. The gate
predicate `is_unmodified_temp` is covered by existing
integration tests. 1131 passing, clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-19 10:27:01 +00:00
parent f239ca5ff4
commit 3a40ae27e7
+43 -19
View File
@@ -174,11 +174,18 @@ pub async fn run(args: Args) -> Result<()> {
); );
} }
// Record the just-opened project as the new resume target. // Record the just-opened project as the `--resume` target
// Write failures here are non-fatal: --resume on the next // but only if it is worth resuming. A freshly-created empty
// launch will report the missing/stale state, which is the // temp project is NOT recorded: it is auto-deleted on quit
// safer default than refusing to launch. // while still empty (ADR-0015), and a `last_project`
if let Err(e) = write_last_project(&data_root, project.path()) { // pointing at a since-deleted directory makes the next
// `--resume` fail confusingly. A named project, or a temp
// that already carries content, is recorded. A temp that
// *gains* content this session is recorded on quit instead.
// Write failures are non-fatal.
if !project.is_unmodified_temp()
&& let Err(e) = write_last_project(&data_root, project.path())
{
warn!(error = %e, "could not update last_project"); warn!(error = %e, "could not update last_project");
} }
let db_path = project.db_path(); let db_path = project.db_path();
@@ -482,18 +489,29 @@ async fn run_loop(
let _ = tokio::time::timeout(SHUTDOWN_GRACE, reader_handle).await; let _ = tokio::time::timeout(SHUTDOWN_GRACE, reader_handle).await;
// Auto-delete the active project on quit if it's an // Decide the active project's fate on quit, before dropping
// unmodified temp — same rule as on project switch (see // it. An unmodified empty temp is auto-deleted — same rule
// perform_switch). Captures the path first, drops the // as on project switch (see perform_switch). Anything else —
// project (releasing the lock), then asks // a named project, or a temp that *gained* content this
// safely_delete_temp_project to verify the directory // session — is recorded as the `--resume` target: this is
// before removing it. // the point at which a launch-temp the user filled with
let cleanup_on_quit: Option<std::path::PathBuf> = session // content finally gets remembered (the `run()` startup
.project // write skipped it while it was still empty). The two are
.as_ref() // mutually exclusive (one needs an unmodified temp, the
// other anything else).
let project_at_quit = session.project.as_ref();
let cleanup_on_quit: Option<std::path::PathBuf> = project_at_quit
.and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf())); .and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf()));
let resume_target_on_quit: Option<std::path::PathBuf> = project_at_quit
.filter(|p| !p.is_unmodified_temp())
.map(|p| p.path().to_path_buf());
let _ = session.database.take(); let _ = session.database.take();
let _ = session.project.take(); let _ = session.project.take();
if let Some(target) = resume_target_on_quit
&& let Err(e) = write_last_project(&session.data_root, &target)
{
tracing::warn!(error = %e, "could not update last_project on quit");
}
if let Some(stale) = cleanup_on_quit { if let Some(stale) = cleanup_on_quit {
match safely_delete_temp_project(&stale, &session.data_root) { match safely_delete_temp_project(&stale, &session.data_root) {
Ok(()) => tracing::info!( Ok(()) => tracing::info!(
@@ -754,6 +772,9 @@ async fn perform_switch(
let display_name = new_project.display_name().to_string(); let display_name = new_project.display_name().to_string();
let is_temp = matches!(new_project.kind(), ProjectKind::Temp); let is_temp = matches!(new_project.kind(), ProjectKind::Temp);
// Worth recording as the resume target? A switch to a fresh
// empty temp (`new`) is not — see the gate in `run()`.
let new_worth_recording = !new_project.is_unmodified_temp();
session.project = Some(new_project); session.project = Some(new_project);
session.database = Some(new_database); session.database = Some(new_database);
@@ -764,11 +785,14 @@ async fn perform_switch(
// Persistence handle for this single line. // Persistence handle for this single line.
let _ = Persistence::new(new_path.clone()).append_history(&source); let _ = Persistence::new(new_path.clone()).append_history(&source);
// Update the resume pointer so the next `--resume` // Update the resume pointer so the next `--resume` launch
// launch reopens the project we just switched to. Write // reopens the project we just switched to — unless it is a
// failures are non-fatal — see the same rationale at // fresh empty temp (a `new` command), which must not be
// `run()` startup. // recorded (see the gate in `run()`). Write failures are
if let Err(e) = write_last_project(&session.data_root, &new_path) { // non-fatal.
if new_worth_recording
&& let Err(e) = write_last_project(&session.data_root, &new_path)
{
tracing::warn!(error = %e, "could not update last_project after switch"); tracing::warn!(error = %e, "could not update last_project after switch");
} }