Skip to content
Open
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
156 changes: 156 additions & 0 deletions scripts/test_cli_save_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""Owned, offline tmux regression for explicit rename persistence failures.

No model request is needed. An owned loopback HTTP/proxy trap records unexpected
requests; startup model refresh is disabled and update caches are seeded fresh.
"""
import argparse
import hashlib
import http.server
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import threading
import time


def run_case(binary, blocked, private_diagnostics):
requests = []

class Trap(http.server.BaseHTTPRequestHandler):
def reject(self):
requests.append((self.command, self.path))
self.send_error(503)
do_GET = reject
do_POST = reject
do_CONNECT = reject
def log_message(self, *_):
pass

server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Trap)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
with tempfile.TemporaryDirectory(prefix="coven-save-ui-") as temp:
root = Path(temp)
home, project = root / "home", root / "project"
engine = home / "engine"
sessions = engine / "sessions"
sessions.mkdir(parents=True)
project.mkdir()
empty_path = root / "bin"
empty_path.mkdir()
(engine / "settings.json").write_text(json.dumps({"hasCompletedOnboarding": True}))
for cache in [home / "Library/Caches", home / ".cache"]:
directory = cache / "coven-code"
directory.mkdir(parents=True)
(directory / "update_check.txt").write_text("")
session_id = "11111111-1111-4111-8111-111111111111"
path = sessions / (session_id + ".json")
original = json.dumps({
"id": session_id, "created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z", "model": "claude-sonnet-4-6",
"title": "original-title", "working_dir": str(project),
"messages": [{"role": "user", "content": "history-fixture-loaded"}],
}).encode()
path.write_bytes(original)
proxy = f"http://127.0.0.1:{server.server_port}"
env = {
"HOME": str(home), "USERPROFILE": str(home),
"COVEN_CODE_HOME": str(engine), "COVEN_HOME": str(home / "coven"),
"COVEN_CAVE_HOME": str(home / "cave"),
"XDG_CACHE_HOME": str(home / ".cache"), "PATH": str(empty_path),
"SHELL": "/bin/sh", "TERM": "xterm-256color",
"SAVE_STDERR": str(root / "stderr"),
"ANTHROPIC_API_KEY": "owned-fixture-no-credential",
"COVEN_CODE_DISABLE_MODELS_FETCH": "1",
"HTTP_PROXY": proxy, "HTTPS_PROXY": proxy, "ALL_PROXY": proxy,
"http_proxy": proxy, "https_proxy": proxy, "all_proxy": proxy,
"NO_PROXY": "127.0.0.1", "no_proxy": "127.0.0.1",
}
tmux = [shutil.which("tmux"), "-f", "/dev/null", "-L", f"save-{os.getpid()}-{int(blocked)}"]
def call(*args):
return subprocess.run(tmux + list(args), check=True, capture_output=True, text=True, timeout=10).stdout
def capture():
return call("capture-pane", "-t", "fixture", "-p")
def wait_for(needle):
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
pane = capture()
if needle in pane:
return pane
time.sleep(0.1)
raise AssertionError(f"missing {needle!r}:\n{pane}")
try:
launch = [str(binary), "--resume", session_id, "--api-base", proxy]
if not private_diagnostics:
launch = ["/bin/sh", "-c", 'exec "$@" 2>"$SAVE_STDERR"', "fixture", *launch]
call("new-session", "-d", "-s", "fixture", "-x", "140", "-y", "45",
"-c", str(project), "/usr/bin/env", "-i",
*[f"{k}={v}" for k, v in env.items()], *launch)
wait_for("history-fixture-loaded")
if blocked:
path.rename(root / "original.json")
path.mkdir()
(path / "sentinel").write_bytes(b"untouched")
call("send-keys", "-t", "fixture", "-l", "/session rename fixture-title")
call("send-keys", "-t", "fixture", "Enter")
pane = wait_for("Session not saved; retry before exit." if blocked else 'Session renamed to "fixture-title".')
if blocked:
assert 'Session renamed to "fixture-title".' not in pane, pane
assert "Session renamed in memory; not saved." in pane, pane
assert (root / "original.json").read_bytes() == original
assert (path / "sentinel").read_bytes() == b"untouched"
assert sorted(p.name for p in path.iterdir()) == ["sentinel"]
if private_diagnostics:
logs = "".join(p.read_text() for p in (engine / "logs").glob("session-*.log"))
else:
logs = (root / "stderr").read_text()
assert "Failed to persist session" in logs and session_id in logs and "rename" in logs, logs
assert "Is a directory" in logs, logs
assert "fixture-title" not in logs and "history-fixture-loaded" not in logs, logs
(path / "sentinel").unlink()
path.rmdir()
call("send-keys", "-t", "fixture", "-l", "/session rename retry-title")
call("send-keys", "-t", "fixture", "Enter")
pane = wait_for('Session renamed to "retry-title".')
assert "Session not saved" not in pane, pane
saved = json.loads(path.read_text())
assert saved["title"] == "retry-title"
assert saved["messages"][0]["content"] == "history-fixture-loaded"
else:
saved = json.loads(path.read_text())
assert saved["title"] == "fixture-title"
assert saved["messages"][0]["content"] == "history-fixture-loaded"
assert "Session not saved" not in pane
assert requests == [], requests
print(f"PASS {'blocked' if blocked else 'writable'} rename; no HTTP requests")
finally:
subprocess.run(tmux + ["kill-server"], capture_output=True, timeout=10)
server.shutdown()
server.server_close()
thread.join(timeout=5)
assert requests == [], requests


def main():
parser = argparse.ArgumentParser()
parser.add_argument("binary", type=Path)
parser.add_argument("--private-diagnostics", action="store_true")
args = parser.parse_args()
binary = args.binary.resolve()
assert binary.is_file() and shutil.which("tmux")
print(f"Binary SHA256 {hashlib.sha256(binary.read_bytes()).hexdigest()}")
failures = []
for blocked in [False, True]:
try:
run_case(binary, blocked, args.private_diagnostics)
except Exception as error:
failures.append(f"blocked={blocked}: {error}")
assert not failures, "\n".join(failures)


if __name__ == "__main__":
main()
17 changes: 10 additions & 7 deletions src-rust/crates/cli/src/goal_dispatch_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ fn isolated(name: &str) -> bool {
.env("COVEN_CODE_GOAL_DISPATCH_TEST", &name)
.env("COVEN_CODE_HOME", &home)
.env("COVEN_HOME", dir.path().join("coven"))
.env("COVEN_CAVE_HOME", dir.path().join("cave"))
.env("CLAUDE_CONFIG_DIR", dir.path().join("claude"))
.env("COVEN_CODE_CLAUDE_BIN", dir.path().join("absent-claude"))
.env("COVEN_DAEMON_SOCKET", dir.path().join("absent.sock"))
Expand Down Expand Up @@ -333,7 +334,8 @@ fn goal_dispatch_rate_limited_retry_does_not_adopt_replacement() {
serde_json::to_string(&messages).unwrap(),
serde_json::to_string(&before).unwrap()
);
persist_interactive_session(&session, Some(&owner)).await;
persist_interactive_session(&session, Some(&owner), &mut app.notifications)
.await;
assert_eq!(
serde_json::to_string(
&claurst_core::history::load_session(&session.id)
Expand Down Expand Up @@ -411,7 +413,7 @@ fn goal_dispatch_revoked_unattempted_uuid_is_absent_from_persistence() {
),
"idle frames must not rewrite an already rolled-back session"
);
persist_interactive_session(&session, Some(&owner)).await;
persist_interactive_session(&session, Some(&owner), &mut app.notifications).await;
assert_eq!(
store.try_get_goal(&session.id).unwrap().unwrap(),
replacement
Expand Down Expand Up @@ -758,7 +760,7 @@ fn goal_dispatch_preparation_failure_then_revoke_rolls_back_json_and_index() {
assert!(!turn.request_attempted());
messages = history.lock().await.clone();
session.messages = messages.clone();
persist_interactive_session(&session, Some(&owner)).await;
persist_interactive_session(&session, Some(&owner), &mut app.notifications).await;
let index = claurst_core::SqliteSessionStore::open(
&Settings::config_dir().join("sessions.db"),
)
Expand Down Expand Up @@ -796,7 +798,8 @@ fn goal_dispatch_preparation_failure_then_revoke_rolls_back_json_and_index() {
assert!(turn.request_attempted());
assert_eq!(requests.load(std::sync::atomic::Ordering::SeqCst), 1);
session.messages = history.lock().await.clone();
persist_interactive_session(&session, Some(&owner)).await;
persist_interactive_session(&session, Some(&owner), &mut app.notifications)
.await;
assert_eq!(
index
.list_sessions()
Expand Down Expand Up @@ -833,7 +836,7 @@ fn goal_dispatch_preparation_failure_then_revoke_rolls_back_json_and_index() {
&mut command_messages
));
*history.lock().await = messages.clone();
persist_interactive_session(&session, Some(&owner)).await;
persist_interactive_session(&session, Some(&owner), &mut app.notifications).await;
let loaded = claurst_core::history::load_session(&session.id)
.await
.unwrap();
Expand Down Expand Up @@ -991,7 +994,7 @@ fn goal_dispatch_repeated_partial_failures_keep_manual_and_synthetic_baselines()
assert!(goal.time_used_secs >= 3);
}
session.messages = messages;
persist_interactive_session(&session, Some(&owner)).await;
persist_interactive_session(&session, Some(&owner), &mut app.notifications).await;
if synthetic {
let index = claurst_core::SqliteSessionStore::open(
&Settings::config_dir().join("sessions.db"),
Expand Down Expand Up @@ -1310,7 +1313,7 @@ fn goal_dispatch_abandoned_inflight_outcome_does_not_rearm_recovery() {
assert!(app.rate_limit_recovery.auto_retry_deadline.is_none());
messages = history.lock().await.clone();
session.messages = messages.clone();
persist_interactive_session(&session, owner.as_ref()).await;
persist_interactive_session(&session, owner.as_ref(), &mut app.notifications).await;
settle_abandoned_turn(
&mut owner,
false,
Expand Down
67 changes: 57 additions & 10 deletions src-rust/crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2350,7 +2350,7 @@ async fn settle_abandoned_turn(
session,
command_messages,
) {
persist_interactive_session(session, owner.as_ref()).await;
persist_interactive_session(session, owner.as_ref(), &mut app.notifications).await;
}
// Keep authority until the cancelled task has drained its partial output.
if !query_running {
Expand Down Expand Up @@ -2401,9 +2401,38 @@ impl InteractiveQuery {
}
}

const SESSION_SAVE_WARNING: &str = "Session not saved; retry before exit.";

async fn save_session_with_warning(
session: &claurst_core::history::ConversationSession,
notifications: &mut claurst_tui::notifications::NotificationQueue,
operation: &str,
) -> bool {
use claurst_tui::notifications::NotificationKind;
match claurst_core::history::save_session(session).await {
Ok(()) => {
notifications.notifications.retain(|notification| {
notification.kind != NotificationKind::Warning
|| notification.message != SESSION_SAVE_WARNING
});
true
}
Err(error) => {
tracing::warn!(session_id = %session.id, operation, %error, "Failed to persist session");
notifications.push(
NotificationKind::Warning,
SESSION_SAVE_WARNING.to_owned(),
None,
);
false
}
}
}

async fn persist_interactive_session(
session: &claurst_core::history::ConversationSession,
owner: Option<&InteractiveTurn>,
notifications: &mut claurst_tui::notifications::NotificationQueue,
) {
// A recoverable preparation/auth failure can finish without an attempt.
// Delay indexing its synthetic UUID until attempted; a later stale retry
Expand All @@ -2417,7 +2446,7 @@ async fn persist_interactive_session(
})
.and_then(|owner| owner.synthetic_uuid.as_deref());
// JSON is primary storage; index only the finalized transcript.
let _ = claurst_core::history::save_session(session).await;
save_session_with_warning(session, notifications, "transcript update").await;

// Also index into SQLite for /search support
{
Expand Down Expand Up @@ -3366,10 +3395,13 @@ async fn run_interactive(
session.title = Some(title.clone());
session.updated_at = chrono::Utc::now();
cmd_ctx.session_title = session.title.clone();
let _ = claurst_core::history::save_session(&session).await;
let saved = save_session_with_warning(&session, &mut app.notifications, "rename").await;
claurst_tui::update_terminal_title(Some(&title));
app.status_message =
Some(format!("Session renamed to \"{}\".", title));
app.status_message = Some(if saved {
format!("Session renamed to \"{}\".", title)
} else {
"Session renamed in memory; not saved.".to_owned()
});
}
Some(CommandResult::RefreshProviderState) => {
if app.is_streaming || current_query.is_some() {
Expand Down Expand Up @@ -3679,7 +3711,7 @@ async fn run_interactive(

if rollback_unattempted_turn(goal_turn.as_ref(), true, &mut messages,
&mut app, &mut session, &mut cmd_ctx.messages) {
persist_interactive_session(&session, goal_turn.as_ref()).await;
persist_interactive_session(&session, goal_turn.as_ref(), &mut app.notifications).await;
}
app.rate_limit_recovery.dismiss();
app.rate_limit_recovery.take_abandoned();
Expand Down Expand Up @@ -4352,7 +4384,12 @@ async fn run_interactive(
// Persist the session URL into the saved session record.
session.remote_session_url = Some(session_url.clone());
session.updated_at = chrono::Utc::now();
let _ = claurst_core::history::save_session(&session).await;
save_session_with_warning(
&session,
&mut app.notifications,
"remote connection",
)
.await;

// Wire the BridgeSessionInfo relay so live tool/text events reach
// the web UI via /api/bridge/sessions. This runs alongside
Expand Down Expand Up @@ -4453,7 +4490,12 @@ async fn run_interactive(
session.updated_at = chrono::Utc::now();
cmd_ctx.session_title = Some(title.clone());
app.session_title = Some(title);
let _ = claurst_core::history::save_session(&session).await;
save_session_with_warning(
&session,
&mut app.notifications,
"remote rename",
)
.await;
}
Ok(TuiBridgeEvent::Error(msg)) => {
app.bridge_state = BridgeConnectionState::Failed {
Expand Down Expand Up @@ -4519,7 +4561,8 @@ async fn run_interactive(
&mut session,
&mut cmd_ctx.messages,
) {
persist_interactive_session(&session, goal_turn.as_ref()).await;
persist_interactive_session(&session, goal_turn.as_ref(), &mut app.notifications)
.await;
}
app.rate_limit_recovery.dismiss();
app.rate_limit_recovery.take_abandoned();
Expand Down Expand Up @@ -5024,7 +5067,8 @@ async fn run_interactive(
&mut cmd_ctx.messages,
);
*msgs_arc.lock().await = messages.clone();
persist_interactive_session(&session, goal_turn.as_ref()).await;
persist_interactive_session(&session, goal_turn.as_ref(), &mut app.notifications)
.await;

// --- Goal continuation ---
// After every completed turn check if there is an active goal.
Expand Down Expand Up @@ -6845,3 +6889,6 @@ mod tests {

#[cfg(test)]
mod goal_dispatch_tests;

#[cfg(test)]
mod session_save_tests;
Loading
Loading