Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@

# Generated browser bundle.
/crates/web/dist/

/tmp/
26 changes: 24 additions & 2 deletions crates/core/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ pub struct SessionMeta {
/// legacy files (defaults to "not archived").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archived_at: Option<u64>,
/// Manually settled (unix secs), independently of archive state.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub settled_at: Option<u64>,
/// Dedicated-worktree mode metadata, when the session runs in its own git
/// worktree instead of the project checkout. Absent = local checkout.
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -145,6 +148,7 @@ impl SessionMeta {
project_id: None,
model,
archived_at: None,
settled_at: None,
worktree: None,
approval_mode: ApprovalMode::default(),
resume_cursor: None,
Expand Down Expand Up @@ -218,7 +222,8 @@ pub fn auto_archive_candidates(
}
let found = children.get(session_id).is_some_and(|descendants| {
descendants.iter().any(|child| {
exempt.working.contains(&child.id)
child.settled_at.is_some()
|| exempt.working.contains(&child.id)
|| exempt.unread.contains(&child.id)
|| exempt.active.contains(&child.id)
|| has_exempt_descendant(&child.id, children, exempt, visiting)
Expand Down Expand Up @@ -264,7 +269,8 @@ pub fn auto_archive_candidates(
if !state.visited.insert(session.id.clone()) || state.archived.contains(&session.id) {
continue;
}
let directly_exempt = exempt.working.contains(&session.id)
let directly_exempt = session.settled_at.is_some()
|| exempt.working.contains(&session.id)
|| exempt.unread.contains(&session.id)
|| exempt.active.contains(&session.id)
|| parent_id.is_some_and(|parent| exempt.working.contains(parent));
Expand Down Expand Up @@ -648,6 +654,22 @@ mod tests {
assert_eq!(serde_json::from_value::<SessionMeta>(json).unwrap(), meta);
}

#[test]
fn auto_archive_keeps_settled_threads_and_ancestors_of_settled_children() {
let mut settled = archive_session("settled", 1, Some("parent"));
settled.settled_at = Some(2);
let sessions = [
archive_session("newest", 1000, None),
archive_session("parent", 2, None),
settled,
archive_session("old", 1, None),
];
assert_eq!(
candidates(&sessions, 10000, 100, 1, &AutoArchiveExemptions::default()),
HashSet::from(["old".into()])
);
}

#[test]
fn auto_archive_requires_idle_and_beyond_keep_window() {
let day = 86_400;
Expand Down
8 changes: 8 additions & 0 deletions crates/protocol/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,12 @@ pub enum Command {
PatchSettings {
patch: SettingsPatch,
},
SettleSession {
session_id: String,
},
MakeSessionActive {
session_id: String,
},
ArchiveSession {
session_id: String,
},
Expand Down Expand Up @@ -405,6 +411,8 @@ impl Command {
| Self::RemoveTerminalContext { session_id, .. }
| Self::AddReviewComment { session_id, .. }
| Self::RemoveReviewComment { session_id, .. }
| Self::SettleSession { session_id, .. }
| Self::MakeSessionActive { session_id, .. }
| Self::ArchiveSession { session_id, .. }
| Self::UnarchiveSession { session_id, .. }
| Self::RenameSession { session_id, .. }
Expand Down
10 changes: 9 additions & 1 deletion crates/runtime/src/app/command_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,18 @@ impl AppState {
let Some(session_id) = command.session_id() else {
return Ok(());
};
if matches!(command, Command::SettleSession { .. }) && self.settle_family_busy(session_id) {
return Err(error(
"thread_busy",
"Wait for this thread and its children to finish before settling.",
));
}
// Index mutations operate on stored sessions, without requiring a live provider.
if matches!(
command,
Command::ArchiveSession { .. }
Command::SettleSession { .. }
| Command::MakeSessionActive { .. }
| Command::ArchiveSession { .. }
| Command::UnarchiveSession { .. }
| Command::RenameSession { .. }
| Command::DeleteSession { .. }
Expand Down
9 changes: 8 additions & 1 deletion crates/runtime/src/app/orchestrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,7 @@ impl AppState {
child.draft = false;
child.push_queued(brief, Vec::new());
self.residents.parked.insert(id.clone(), child);
self.reactivate_session(&id, cx);
self.ensure_session_started(&id, cx);
Ok(id)
}
Expand Down Expand Up @@ -633,6 +634,7 @@ impl AppState {
if archived {
self.unarchive_session(&thread_id, cx);
}
self.reactivate_session(&thread_id, cx);
// A live turn accepts the message right away — same routing as
// parent callbacks. Queueing a mid-turn correction until the
// turn ends would deliver it after the work it was meant to
Expand Down Expand Up @@ -1080,7 +1082,11 @@ impl AppState {
);
state.callback_last_turn.insert(child_id.clone(), turn);
state.deliver_orchestrate_callback_to_parent(&parent_id, text, cx);
if auto_archive {
if auto_archive
&& state
.find_meta(&child_id)
.is_some_and(|meta| meta.settled_at.is_none())
{
state.archive_session_ids(&[child_id], now_secs(), cx);
}
});
Expand Down Expand Up @@ -1172,6 +1178,7 @@ impl AppState {
text: String,
cx: &mut HostCx,
) {
self.reactivate_session(parent_id, cx);
let can_steer = self
.resident(parent_id)
.is_some_and(|parent| parent.turn_in_flight && parent.can_steer());
Expand Down
3 changes: 3 additions & 0 deletions crates/runtime/src/app/send.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ impl AppState {
let should_start = matches!(active.runtime, Runtime::Idle)
&& !(active.draft
&& matches!(active.draft_workspace, WorkspaceMode::NewWorktree { .. }));
self.reactivate_session(target_id, cx);
if should_start {
// Starting now keeps the in-memory session parkable/resident across
// navigation. Eligibility prevents the future turn from being sent
Expand Down Expand Up @@ -235,6 +236,7 @@ impl AppState {
// The first send on a draft materializes it into a real (persisted)
// session so the sidebar row appears; the provider then starts below.
self.commit_draft(target_id, cx);
self.reactivate_session(target_id, cx);

let Some(active) = self.resident_mut(target_id) else {
return;
Expand Down Expand Up @@ -530,6 +532,7 @@ impl AppState {
attachments: &[Attachment],
cx: &mut HostCx,
) -> String {
self.reactivate_session(session_id, cx);
let request_id = format!(
"local-steer-{}",
cx.delivery_key
Expand Down
69 changes: 69 additions & 0 deletions crates/runtime/src/app/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,75 @@ impl AppState {
(Some(marker.reopen_settings), session_id)
}

pub(super) fn settle_family_busy(&self, session_id: &str) -> bool {
descendant_session_ids(&self.sessions, session_id)
.iter()
.any(|id| {
self.resident(id).is_some_and(|session| {
session.has_work()
|| session.timeline.turn_running
|| !session.timeline.pending_approvals.is_empty()
|| session.timeline.pending_user_input.is_some()
})
})
}

/// Settle a whole descendant group without shutting down its resources.
pub fn settle_session(&mut self, session_id: &str, cx: &mut HostCx) {
if self.settle_family_busy(session_id) {
return;
}
let timestamp = now_secs();
for id in descendant_session_ids(&self.sessions, session_id) {
if let Some(mut meta) = self.find_meta(&id)
&& meta.settled_at.is_none()
{
meta.settled_at = Some(timestamp);
self.persist_settled_meta(meta, cx);
}
}
}

/// Restore the matching settle cascade, then expose all of its ancestors.
pub fn make_session_active(&mut self, session_id: &str, cx: &mut HostCx) {
if let Some(timestamp) = self.find_meta(session_id).and_then(|meta| meta.settled_at) {
for id in descendant_session_ids(&self.sessions, session_id) {
if let Some(mut meta) = self.find_meta(&id)
&& meta.settled_at == Some(timestamp)
{
meta.settled_at = None;
self.persist_settled_meta(meta, cx);
}
}
}
self.reactivate_session(session_id, cx);
}

/// Accepted input exposes this thread and its ancestors, leaving siblings settled.
pub(super) fn reactivate_session(&mut self, session_id: &str, cx: &mut HostCx) {
let mut next = Some(session_id.to_string());
let mut visited = HashSet::new();
while let Some(id) = next.take() {
if !visited.insert(id.clone()) {
break;
}
let Some(mut meta) = self.find_meta(&id) else {
break;
};
next = meta.parent_session_id.clone();
if meta.settled_at.take().is_some() {
self.persist_settled_meta(meta, cx);
}
}
}

fn persist_settled_meta(&mut self, meta: SessionMeta, cx: &mut HostCx) {
if let Some(session) = self.resident_mut(&meta.id) {
session.meta.settled_at = meta.settled_at;
}
self.persist_meta(&meta, cx);
}

/// Archive a thread (reversible; it vanishes from the sidebar). Blocked while
/// its turn is running (returns without changing anything so the caller's
/// tooltip stands). The active thread is closed back to the empty state.
Expand Down
Loading
Loading