From f792ef406d0e0f24af95a2e4887b272024673ee4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 12:06:30 +0000 Subject: [PATCH 1/2] fix: restore session lifecycle and chat history reliability fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Merge DB history with live journal by seq when switching sessions - Reject send_message while spawning_sessions is active - Skip Completed status in reader_loop when user stopped the session - Retry flush_batch buffer on BEGIN failure instead of discarding rows Co-authored-by: José Fernando --- CHANGELOG.md | 3 + tauri/src/services/database.rs | 1 - tauri/src/services/session_manager.rs | 115 ++++++++++++++++++++++---- ui/components/CentralPanel.svelte | 10 ++- ui/lib/journal-merge.test.ts | 48 +++++++++++ ui/lib/journal-merge.ts | 12 +++ 6 files changed, 169 insertions(+), 20 deletions(-) create mode 100644 ui/lib/journal-merge.test.ts create mode 100644 ui/lib/journal-merge.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d9c2f30..0ade6a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ## June 2026 +### 06/29 · Fix — Session lifecycle and chat history reliability +Switching between sessions no longer drops messages that arrived while history was loading. Follow-up messages sent while the agent is still starting are rejected with a clear error instead of appearing in chat without being delivered. Stopping a session no longer flips back to "completed" when the process exits. Session output is no longer lost when the database is briefly busy. + ### 06/09 · Adjustment — Chats only, for now The "+" tab menu's Terminal and Git overview options are temporarily turned off and shown greyed out while they are being reworked. Chat sessions are unaffected. diff --git a/tauri/src/services/database.rs b/tauri/src/services/database.rs index bf48d0a..f6aec6e 100644 --- a/tauri/src/services/database.rs +++ b/tauri/src/services/database.rs @@ -21,7 +21,6 @@ fn flush_batch(conn: &Mutex, buf: &mut Vec<(SessionId, String)>) { let conn = conn.lock().unwrap_or_else(|e| e.into_inner()); if let Err(e) = conn.execute_batch("BEGIN") { eprintln!("[orbit] flush_batch: BEGIN failed: {e}"); - buf.clear(); return; } for (session_id, data) in buf.drain(..) { diff --git a/tauri/src/services/session_manager.rs b/tauri/src/services/session_manager.rs index d8520f9..564a648 100644 --- a/tauri/src/services/session_manager.rs +++ b/tauri/src/services/session_manager.rs @@ -932,26 +932,30 @@ impl SessionManager { } } + let finalize_completed = session_status_allows_completion(&db, session_id); { let mut m = manager.write().unwrap_or_else(|e| e.into_inner()); - if let Some(a) = m.active.get_mut(&session_id) { - a.session.status = crate::models::SessionStatus::Completed; - a.session.attention = Some(crate::models::AttentionState { - requires_attention: true, - reason: Some(crate::models::AttentionReason::Completed), - since: Some(chrono::Utc::now().to_rfc3339()), - }); - } - if let Some(state) = m.journal_states.get_mut(&session_id) { - state.status = AgentStatus::Idle; - state.attention = crate::models::AttentionState { - requires_attention: true, - reason: Some(crate::models::AttentionReason::Completed), - since: Some(chrono::Utc::now().to_rfc3339()), - }; + if finalize_completed { + if let Some(a) = m.active.get_mut(&session_id) { + a.session.status = crate::models::SessionStatus::Completed; + a.session.attention = Some(crate::models::AttentionState { + requires_attention: true, + reason: Some(crate::models::AttentionReason::Completed), + since: Some(chrono::Utc::now().to_rfc3339()), + }); + } + if let Some(state) = m.journal_states.get_mut(&session_id) { + state.status = AgentStatus::Idle; + state.attention = crate::models::AttentionState { + requires_attention: true, + reason: Some(crate::models::AttentionReason::Completed), + since: Some(chrono::Utc::now().to_rfc3339()), + }; + } + let _ = + db.update_session_status(session_id, crate::models::SessionStatus::Completed); } m.spawning_sessions.remove(&session_id); - let _ = db.update_session_status(session_id, crate::models::SessionStatus::Completed); } let _ = app.emit( @@ -1002,6 +1006,16 @@ impl SessionManager { } } + { + let m = manager.read().unwrap_or_else(|e| e.into_inner()); + if m.spawning_sessions.contains(&session_id) { + return Err( + "Session is still processing the previous message. Wait for it to finish." + .to_string(), + ); + } + } + // Push the user's follow-up message as a journal entry so it appears in the chat { let mut m = manager.write().unwrap_or_else(|e| e.into_inner()); @@ -1416,6 +1430,14 @@ fn ascii_ci_contains(haystack: &str, needle: &str) -> bool { h.windows(n.len()).any(|w| w.eq_ignore_ascii_case(n)) } +/// True when reader_loop may mark the session completed (user stop must win). +fn session_status_allows_completion(db: &DatabaseService, session_id: SessionId) -> bool { + db.get_session(session_id) + .ok() + .flatten() + .is_none_or(|s| s.status != crate::models::SessionStatus::Stopped) +} + /// Check if a JSON line from Claude's stdout indicates a rate limit error. /// /// Parses the JSON and requires: @@ -2020,4 +2042,65 @@ mod tests { expected_id.as_str(), ); } + + #[test] + fn should_not_finalize_stopped_session_as_completed() { + let mut t = TestCase::new("should_not_finalize_stopped_session_as_completed"); + t.phase("Seed"); + let db = make_db(); + let sid = db + .create_session(None, None, "/tmp", "ignore", None, None, None, None) + .expect("session"); + db.update_session_status(sid, crate::models::SessionStatus::Stopped) + .expect("stop"); + t.phase("Assert"); + t.ok( + "stopped session must not be marked completed", + !session_status_allows_completion(&db, sid), + ); + } + + #[test] + fn should_finalize_running_session_as_completed() { + let mut t = TestCase::new("should_finalize_running_session_as_completed"); + t.phase("Seed"); + let db = make_db(); + let sid = db + .create_session(None, None, "/tmp", "ignore", None, None, None, None) + .expect("session"); + t.phase("Assert"); + t.ok( + "running session may be marked completed", + session_status_allows_completion(&db, sid), + ); + } + + #[test] + fn should_reject_send_message_while_session_is_spawning() { + let mut t = TestCase::new("should_reject_send_message_while_session_is_spawning"); + t.phase("Seed"); + let mgr = make_manager(); + let sid = mgr + .write() + .unwrap() + .init_session( + "/tmp/proj", + None, + "ignore", + None, + false, + None, + None, + None, + None, + ) + .expect("init") + .id; + mgr.write().unwrap().spawning_sessions.insert(sid); + t.phase("Assert"); + t.ok( + "spawning guard blocks follow-up", + mgr.read().unwrap().spawning_sessions.contains(&sid), + ); + } } diff --git a/ui/components/CentralPanel.svelte b/ui/components/CentralPanel.svelte index 6cb7dde..8747f8e 100644 --- a/ui/components/CentralPanel.svelte +++ b/ui/components/CentralPanel.svelte @@ -3,6 +3,7 @@ import { journal, pendingMessages } from '../lib/stores/journal'; import { backends as backendsStore } from '../lib/stores/providers'; import { getSessionJournal } from '../lib/tauri/sessions'; + import { mergeJournalBySeq } from '../lib/journal-merge'; import { invoke } from '../lib/tauri/invoke'; import { updateSessionState, sessions } from '../lib/stores/sessions'; import { statusColor, statusLabel, modelShortName } from '../lib/status'; @@ -30,9 +31,12 @@ async function loadHistory(id: number) { try { const entries = await getSessionJournal(id); - if (entries.length > 0) { - journal.update((m) => new Map(m).set(id, entries)); - } + if (entries.length === 0) return; + journal.update((m) => { + const existing = m.get(id) ?? []; + const merged = mergeJournalBySeq(existing, entries); + return new Map(m).set(id, merged); + }); } catch (_e) { /* no-op */ } diff --git a/ui/lib/journal-merge.test.ts b/ui/lib/journal-merge.test.ts new file mode 100644 index 0000000..3f6ca82 --- /dev/null +++ b/ui/lib/journal-merge.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { mergeJournalBySeq } from './journal-merge'; +import type { JournalEntry } from './types'; + +function entry(seq: number, text: string): JournalEntry { + return { + sessionId: '1', + timestamp: '2026-01-01T00:00:00Z', + entryType: 'assistant', + text, + thinking: null, + thinkingDuration: null, + tool: null, + toolInput: null, + output: null, + exitCode: null, + linesChanged: null, + seq, + epoch: '', + }; +} + +describe('mergeJournalBySeq', () => { + it('returns db entries when live feed is empty', () => { + const db = [entry(1, 'from db')]; + expect(mergeJournalBySeq([], db)).toEqual(db); + }); + + it('keeps live entries when db is empty', () => { + const live = [entry(1, 'live')]; + expect(mergeJournalBySeq(live, [])).toEqual(live); + }); + + it('prefers live over db for the same seq', () => { + const live = [entry(1, 'live'), entry(2, 'only live')]; + const db = [entry(1, 'stale db')]; + const merged = mergeJournalBySeq(live, db); + expect(merged).toHaveLength(2); + expect(merged[0].text).toBe('live'); + expect(merged[1].text).toBe('only live'); + }); + + it('includes higher-seq live entries missing from db snapshot', () => { + const live = [entry(1, 'a'), entry(2, 'b'), entry(3, 'c')]; + const db = [entry(1, 'a')]; + expect(mergeJournalBySeq(live, db)).toHaveLength(3); + }); +}); diff --git a/ui/lib/journal-merge.ts b/ui/lib/journal-merge.ts new file mode 100644 index 0000000..c38b692 --- /dev/null +++ b/ui/lib/journal-merge.ts @@ -0,0 +1,12 @@ +import type { JournalEntry } from './types'; + +/** Merge DB history with live feed entries; live wins on seq conflicts. */ +export function mergeJournalBySeq(live: JournalEntry[], db: JournalEntry[]): JournalEntry[] { + if (live.length === 0) return db; + if (db.length === 0) return live; + + const bySeq = new Map(); + for (const entry of db) bySeq.set(entry.seq, entry); + for (const entry of live) bySeq.set(entry.seq, entry); + return [...bySeq.values()].sort((a, b) => a.seq - b.seq); +} From af39e61802942a1ee13e93f8205cb6469019262a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 29 Jun 2026 12:07:14 +0000 Subject: [PATCH 2/2] chore: sync package-lock.json version to 0.6.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: José Fernando --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index d7bb1f8..91fcfac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "orbit", - "version": "0.6.0", + "version": "0.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "orbit", - "version": "0.6.0", + "version": "0.6.1", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.10.3",