Skip to content
Draft
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion tauri/src/services/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ fn flush_batch(conn: &Mutex<Connection>, 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(..) {
Expand Down
115 changes: 99 additions & 16 deletions tauri/src/services/session_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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),
);
}
}
10 changes: 7 additions & 3 deletions ui/components/CentralPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 */
}
Expand Down
48 changes: 48 additions & 0 deletions ui/lib/journal-merge.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 12 additions & 0 deletions ui/lib/journal-merge.ts
Original file line number Diff line number Diff line change
@@ -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<number, JournalEntry>();
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);
}
Loading