Merge pull request #5517 from nesquena/release/stage-5504
Release — deleted-session ghost fix (#5504, closes #5498)
This commit is contained in:
10 files changed
+757
-10
No files matched your search
@@ -14,6 +14,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A deleted conversation no longer comes back as a read-only "ghost" after a restart.** Deleting a WebUI session removed its file but left a recoverable row in the agent's state database, so after a server restart and page reload the deleted conversation could reappear as a read-only "Agent" session still showing its old transcript. Deleted WebUI sessions are now recorded in a durable tombstone that the sidebar projection, startup backup-recovery, audit, and session-claim paths all honor, so a deleted session stays gone (the backend keeps returning the 404 the client relies on to clear the stale route). Genuine crash recovery is unaffected — a session whose sidecar is lost without a delete is still restored — imported external-channel (messaging) sessions are exempt and keep their transcript, and a deleted id is automatically un-tombstoned if the same session is later re-created or re-imported. Thanks @rodboev. (#5504, #5498)
|
||||
- **A drag that ends over the sidebar no longer switches conversations by accident.** Releasing the mouse (a stray `pointerup`) over a session row after a drag that began elsewhere in the main content area could switch you into that conversation unintentionally. Session switching now ignores a `pointerup` that isn't a genuine click on the row. Thanks @webtecnica. (#5487, #5462)
|
||||
- **Extension gallery downloads no longer hang on networks with broken IPv6.** Gallery installs and registry fetches followed the OS address order (IPv6-first); on a network advertising dead IPv6 routes, every install stalled for the full TCP timeout before falling back to IPv4. Downloads now try IPv4 first (via a non-global, thread-safe per-connection override that preserves the existing redirect allowlist and timeouts), so installs are fast again on such networks. Thanks @rumotoshino. (#5458)
|
||||
- **Switching profiles no longer leaves you unable to upload in a fresh chat, and cross-profile sidebar rows open correctly.** When you switched profiles with an empty current chat, the browser could keep the previous profile's session id — so a later file upload posted a stale id and failed with "Session not found" until a hard reload. The empty chat is now replaced during the switch when it belonged to a different profile, so uploads work immediately. Additionally, clicking a session that belongs to another profile now switches to its owning profile before loading it (instead of 404ing). Thanks @ruizanthony. (#5460)
|
||||
|
||||
+119
-3
@@ -160,6 +160,7 @@ _SESSION_INDEX_REBUILD_THREAD_TARGET: tuple[Path, Path] | None = None
|
||||
# WebUI sidebar polling path is single-process) but must wrap the WHOLE
|
||||
# load-modify-write/unlink sequence in both helpers.
|
||||
_WEBUI_ZERO_MESSAGE_ORPHAN_TOMBSTONE_LOCK = threading.Lock()
|
||||
_WEBUI_DELETED_SESSION_TOMBSTONE_LOCK = threading.Lock()
|
||||
|
||||
# Path-safety contract for session IDs. Accept alphanumerics, underscore, and
|
||||
# hyphen so API/gateway-issued ids (``api-*``, ``reachy-voice-*``) round-trip
|
||||
@@ -513,6 +514,8 @@ def prune_session_from_index(session_id: str) -> None:
|
||||
|
||||
WEBUI_ZERO_MESSAGE_ORPHAN_TOMBSTONE_CAP = 500
|
||||
WEBUI_ZERO_MESSAGE_ORPHAN_TOMBSTONE_VERSION = 1
|
||||
WEBUI_DELETED_SESSION_TOMBSTONE_CAP = 1000
|
||||
WEBUI_DELETED_SESSION_TOMBSTONE_VERSION = 1
|
||||
|
||||
|
||||
def _webui_zero_message_orphan_tombstone_file() -> "Path":
|
||||
@@ -676,6 +679,98 @@ def _clear_webui_zero_message_orphan_tombstone(sid: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _webui_deleted_session_tombstone_file() -> "Path":
|
||||
return SESSION_DIR / "_deleted_webui_sessions.json"
|
||||
|
||||
|
||||
def _load_webui_deleted_session_tombstone() -> frozenset[str]:
|
||||
p = _webui_deleted_session_tombstone_file()
|
||||
if not p.exists():
|
||||
return frozenset()
|
||||
try:
|
||||
raw = json.loads(p.read_text(encoding='utf-8'))
|
||||
except Exception:
|
||||
logger.debug("Failed to load webui deleted-session tombstone", exc_info=True)
|
||||
return frozenset()
|
||||
if not isinstance(raw, dict):
|
||||
return frozenset()
|
||||
try:
|
||||
if int(raw.get("version", 0)) != WEBUI_DELETED_SESSION_TOMBSTONE_VERSION:
|
||||
return frozenset()
|
||||
except (TypeError, ValueError):
|
||||
return frozenset()
|
||||
ids = raw.get("ids", [])
|
||||
if not isinstance(ids, list):
|
||||
return frozenset()
|
||||
return frozenset(
|
||||
str(sid).strip() for sid in ids if str(sid or "").strip()
|
||||
)
|
||||
|
||||
|
||||
def _save_webui_deleted_session_tombstone(ids) -> None:
|
||||
try:
|
||||
sorted_ids = sorted(set(
|
||||
str(sid).strip() for sid in (ids or []) if str(sid or "").strip()
|
||||
))
|
||||
except TypeError:
|
||||
return
|
||||
if len(sorted_ids) > WEBUI_DELETED_SESSION_TOMBSTONE_CAP:
|
||||
sorted_ids = sorted_ids[-WEBUI_DELETED_SESSION_TOMBSTONE_CAP:]
|
||||
payload = {
|
||||
"version": WEBUI_DELETED_SESSION_TOMBSTONE_VERSION,
|
||||
"ids": sorted_ids,
|
||||
}
|
||||
p = _webui_deleted_session_tombstone_file()
|
||||
_tmp = None
|
||||
try:
|
||||
SESSION_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_tmp = p.with_suffix(
|
||||
f'.tmp.{os.getpid()}.{threading.current_thread().ident}'
|
||||
)
|
||||
with open(_tmp, 'w', encoding='utf-8') as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(_tmp, p)
|
||||
except Exception:
|
||||
logger.debug("Failed to save webui deleted-session tombstone", exc_info=True)
|
||||
if _tmp is not None:
|
||||
try:
|
||||
_tmp.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _record_webui_deleted_session_tombstone(sid: str) -> None:
|
||||
sid = str(sid or "").strip()
|
||||
if not sid:
|
||||
return
|
||||
with _WEBUI_DELETED_SESSION_TOMBSTONE_LOCK:
|
||||
current = set(_load_webui_deleted_session_tombstone())
|
||||
if sid in current:
|
||||
return
|
||||
current.add(sid)
|
||||
_save_webui_deleted_session_tombstone(current)
|
||||
|
||||
|
||||
def _clear_webui_deleted_session_tombstone(sid: str) -> None:
|
||||
sid = str(sid or "").strip()
|
||||
if not sid:
|
||||
return
|
||||
with _WEBUI_DELETED_SESSION_TOMBSTONE_LOCK:
|
||||
current = set(_load_webui_deleted_session_tombstone())
|
||||
if sid not in current:
|
||||
return
|
||||
current.discard(sid)
|
||||
if current:
|
||||
_save_webui_deleted_session_tombstone(current)
|
||||
return
|
||||
try:
|
||||
_webui_deleted_session_tombstone_file().unlink(missing_ok=True)
|
||||
except Exception:
|
||||
logger.debug("Failed to remove empty webui deleted-session tombstone", exc_info=True)
|
||||
|
||||
|
||||
def _active_stream_ids():
|
||||
with STREAMS_LOCK:
|
||||
active_ids = set(STREAMS.keys())
|
||||
@@ -1190,9 +1285,10 @@ class Session:
|
||||
if self.messages:
|
||||
try:
|
||||
_clear_webui_zero_message_orphan_tombstone(self.session_id)
|
||||
_clear_webui_deleted_session_tombstone(self.session_id)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to clear webui zero-message orphan tombstone for %s",
|
||||
"Failed to clear webui tombstone for %s",
|
||||
self.session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -3253,9 +3349,10 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
|
||||
# must never block new-session creation.
|
||||
try:
|
||||
_clear_webui_zero_message_orphan_tombstone(s.session_id)
|
||||
_clear_webui_deleted_session_tombstone(s.session_id)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to clear webui zero-message orphan tombstone for %s",
|
||||
"Failed to clear webui tombstone for %s",
|
||||
s.session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -4741,9 +4838,10 @@ def import_cli_session(
|
||||
# an import.
|
||||
try:
|
||||
_clear_webui_zero_message_orphan_tombstone(s.session_id)
|
||||
_clear_webui_deleted_session_tombstone(s.session_id)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to clear webui zero-message orphan tombstone for %s",
|
||||
"Failed to clear webui tombstone for %s",
|
||||
s.session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -5658,6 +5756,16 @@ def _load_cli_sessions_uncached(
|
||||
return None
|
||||
|
||||
profile_value = _cli_profile or 'default'
|
||||
# A deleted WebUI session is tombstoned (see _record_webui_deleted_session_tombstone)
|
||||
# so recovery/audit/claim treat it as gone. The sidebar's own state.db projection
|
||||
# must honor the same tombstone, or a deleted WebUI session reappears here as an
|
||||
# "Agent" ghost the moment non-WebUI sessions are shown (#5498, second path). Only
|
||||
# suppress genuine WebUI rows with no live sidecar — a re-created/re-imported sid
|
||||
# (live {sid}.json) always beats a stale tombstone.
|
||||
try:
|
||||
_deleted_webui_tombstone = _load_webui_deleted_session_tombstone()
|
||||
except Exception:
|
||||
_deleted_webui_tombstone = frozenset()
|
||||
for row in read_importable_agent_session_rows(
|
||||
db_path,
|
||||
limit=visible_session_limit if visible_session_limit is not None else (
|
||||
@@ -5676,6 +5784,14 @@ def _load_cli_sessions_uncached(
|
||||
profile = profile_value # CLI DB has no profile column; use active profile
|
||||
|
||||
_source = row['source'] or 'cli'
|
||||
# Honor the deleted-WebUI tombstone: a WebUI row the user deleted must
|
||||
# not resurface in this projection (the #5498 ghost). Live sidecar wins.
|
||||
if (
|
||||
_source == 'webui'
|
||||
and sid in _deleted_webui_tombstone
|
||||
and not (SESSION_DIR / f"{sid}.json").exists()
|
||||
):
|
||||
continue
|
||||
_source_meta = normalize_agent_session_source(_source)
|
||||
_title = row['title']
|
||||
if not _title and _source == 'cron':
|
||||
|
||||
+37
-2
@@ -7146,6 +7146,13 @@ def _session_index_marks_was_webui(sid: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _session_deleted_tombstone_marks_was_webui(sid: str) -> bool:
|
||||
try:
|
||||
return sid in _load_webui_deleted_session_tombstone()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _state_db_session_source(sid: str) -> str:
|
||||
"""Return the lowercased ``sessions.source`` for ``sid`` from state.db.
|
||||
|
||||
@@ -7380,13 +7387,29 @@ def _claim_or_synthesize_cli_session(sid: str, cli_meta: dict = None):
|
||||
|
||||
if not is_safe_session_id(sid):
|
||||
return None, "invalid_sid"
|
||||
if _session_index_marks_was_webui(sid) and not _is_subagent_child_session_id(sid):
|
||||
if (
|
||||
(
|
||||
_session_index_marks_was_webui(sid)
|
||||
or (
|
||||
_session_deleted_tombstone_marks_was_webui(sid)
|
||||
and _state_db_session_source(sid) in ("", "webui", "fork")
|
||||
)
|
||||
)
|
||||
and not _is_subagent_child_session_id(sid)
|
||||
):
|
||||
# A delegated subagent child (source='subagent' in state.db) can be
|
||||
# registered in the WebUI index as a webui/fork/blank-source row (it
|
||||
# shares the parent's lineage) yet have no WebUI sidecar of its own.
|
||||
# Those must recover their transcript from state.db below rather than
|
||||
# 404 as a genuinely-deleted WebUI session (#5307). Every other
|
||||
# index-marked-WebUI id keeps the #2782 self-heal 404 contract.
|
||||
#
|
||||
# The durable delete tombstone only 404s a row that is WebUI-owned
|
||||
# (source webui/fork, or blank = a stale URL with no surviving row).
|
||||
# A foreign-source row (messaging/cli/tui/desktop) that happens to
|
||||
# carry a tombstone — e.g. a WebUI delete of an imported session whose
|
||||
# state.db row the external writer later re-created — must still
|
||||
# materialize its transcript, never be self-healed to a 404 (#5504).
|
||||
return None, "was_webui"
|
||||
if cli_meta is None:
|
||||
cli_meta = _lookup_cli_session_metadata(sid) or {}
|
||||
@@ -8584,6 +8607,8 @@ from api.models import (
|
||||
_load_webui_zero_message_orphan_tombstone,
|
||||
_record_webui_zero_message_orphan_tombstone,
|
||||
_clear_webui_zero_message_orphan_tombstone,
|
||||
_load_webui_deleted_session_tombstone,
|
||||
_record_webui_deleted_session_tombstone,
|
||||
ensure_cron_project,
|
||||
_profile_has_user_projects,
|
||||
is_cron_session,
|
||||
@@ -13593,15 +13618,25 @@ def handle_post(handler, parsed) -> bool:
|
||||
p.relative_to(SESSION_DIR.resolve())
|
||||
except Exception:
|
||||
return bad(handler, "Invalid session_id", 400)
|
||||
sidecar_deleted = False
|
||||
try:
|
||||
p.unlink(missing_ok=True)
|
||||
p.with_suffix('.json.bak').unlink(missing_ok=True)
|
||||
except Exception:
|
||||
logger.debug("Failed to unlink session file %s", p)
|
||||
sidecar_deleted = not p.exists()
|
||||
try:
|
||||
p.with_suffix('.json.bak').unlink(missing_ok=True)
|
||||
except Exception:
|
||||
logger.debug("Failed to unlink session backup file %s", p.with_suffix('.json.bak'))
|
||||
try:
|
||||
prune_session_from_index(sid)
|
||||
except Exception:
|
||||
logger.debug("Failed to prune deleted session from index: %s", sid, exc_info=True)
|
||||
if sidecar_deleted and not is_messaging_session:
|
||||
try:
|
||||
_record_webui_deleted_session_tombstone(sid)
|
||||
except Exception:
|
||||
logger.debug("Failed to tombstone deleted WebUI session %s", sid, exc_info=True)
|
||||
try:
|
||||
from api.upload import _session_attachment_dir
|
||||
|
||||
|
||||
+150
-1
@@ -330,6 +330,18 @@ def _orphaned_backup_live_paths(
|
||||
if _msg_count(bak_path) < 0:
|
||||
continue
|
||||
session_id = live_path.stem
|
||||
# A WebUI session the user deleted must not be resurrected from its
|
||||
# surviving .bak on the next boot (#5498). Use the DURABLE tombstone
|
||||
# only — not the _index.json heuristic — so a genuine crash that loses
|
||||
# the sidecar while its index entry survives is still restored (the
|
||||
# crash-recovery case this helper exists for).
|
||||
if _durable_tombstone_marks_deleted_webui_session(session_dir, session_id):
|
||||
logger.info(
|
||||
"recover_all_sessions_on_startup: skipped orphan backup %s; "
|
||||
"session is tombstoned as a deleted WebUI session",
|
||||
bak_path.name,
|
||||
)
|
||||
continue
|
||||
if not _state_db_has_session(session_id, state_db_path):
|
||||
logger.info(
|
||||
"recover_all_sessions_on_startup: skipped orphan backup %s; "
|
||||
@@ -383,6 +395,17 @@ def _read_state_db_missing_sidecar_rows(
|
||||
sid = str(data.get('id') or '').strip()
|
||||
if not sid or (session_dir / f"{sid}.json").exists():
|
||||
continue
|
||||
# Only the DURABLE delete tombstone suppresses state.db sidecar
|
||||
# repair. The _index.json heuristic must NOT gate this path: a
|
||||
# genuine crash that loses the sidecar while its index entry
|
||||
# survives (no durable tombstone) is exactly the case this repair
|
||||
# exists for, and on master it materialized the sidecar. Using
|
||||
# _marks_deleted_webui_session() here (which ORs in the index
|
||||
# heuristic) wrongly classified that crash as a delete and
|
||||
# stopped recovery (#5504 Codex/Opus finding).
|
||||
tombstoned = _durable_tombstone_marks_deleted_webui_session(session_dir, sid)
|
||||
if tombstoned and not include_empty:
|
||||
continue
|
||||
message_rows: list[dict] = []
|
||||
if {'session_id', 'role', 'content'}.issubset(message_cols):
|
||||
order = "timestamp, id" if 'timestamp' in message_cols and 'id' in message_cols else "rowid"
|
||||
@@ -402,6 +425,8 @@ def _read_state_db_missing_sidecar_rows(
|
||||
continue
|
||||
data['messages'] = message_rows
|
||||
data['_state_db_empty_messages'] = not message_rows
|
||||
if tombstoned:
|
||||
data['_state_db_deleted_webui_tombstone'] = True
|
||||
rows.append(data)
|
||||
return rows
|
||||
except Exception as exc:
|
||||
@@ -565,6 +590,79 @@ def _read_index_session_ids(index_path: Path) -> set[str]:
|
||||
return ids
|
||||
|
||||
|
||||
def _index_marks_deleted_webui_session(session_dir: Path, sid: str) -> bool:
|
||||
"""Return True when _index.json has a WebUI-like entry whose sidecar is missing.
|
||||
|
||||
This is a delete-route heuristic for cases where index pruning and durable
|
||||
tombstone recording both failed; it can also match other sidecar-loss modes.
|
||||
"""
|
||||
if not sid or (session_dir / f"{sid}.json").exists():
|
||||
return False
|
||||
index_path = session_dir / '_index.json'
|
||||
if not index_path.exists():
|
||||
return False
|
||||
try:
|
||||
data = json.loads(index_path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return False
|
||||
if not isinstance(data, list):
|
||||
return False
|
||||
for entry in data:
|
||||
if not isinstance(entry, dict) or entry.get('session_id') != sid:
|
||||
continue
|
||||
srcs = [
|
||||
str(entry.get('source_tag') or '').strip().lower(),
|
||||
str(entry.get('raw_source') or '').strip().lower(),
|
||||
str(entry.get('session_source') or '').strip().lower(),
|
||||
]
|
||||
explicit = [src for src in srcs if src]
|
||||
if any(src in ('webui', 'fork') for src in explicit):
|
||||
return True
|
||||
if explicit:
|
||||
return False
|
||||
is_cli = entry.get('is_cli_session') is True
|
||||
is_read_only = bool(entry.get('read_only') or entry.get('is_read_only'))
|
||||
return not (is_cli or is_read_only)
|
||||
return False
|
||||
|
||||
|
||||
def _durable_tombstone_marks_deleted_webui_session(session_dir: Path, sid: str) -> bool:
|
||||
"""Return True when the durable WebUI delete tombstone contains sid."""
|
||||
if not sid or (session_dir / f"{sid}.json").exists():
|
||||
return False
|
||||
try:
|
||||
from api import models as _models
|
||||
|
||||
if Path(_models.SESSION_DIR).resolve() == session_dir.resolve():
|
||||
return sid in _models._load_webui_deleted_session_tombstone()
|
||||
except Exception:
|
||||
pass
|
||||
tombstone_path = session_dir / '_deleted_webui_sessions.json'
|
||||
try:
|
||||
raw = json.loads(tombstone_path.read_text(encoding='utf-8'))
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return False
|
||||
if not isinstance(raw, dict):
|
||||
return False
|
||||
try:
|
||||
version = int(raw.get('version', 0))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if version != 1:
|
||||
return False
|
||||
ids = raw.get('ids')
|
||||
if not isinstance(ids, list):
|
||||
return False
|
||||
return sid in {str(value).strip() for value in ids if str(value or '').strip()}
|
||||
|
||||
|
||||
def _marks_deleted_webui_session(session_dir: Path, sid: str) -> bool:
|
||||
return (
|
||||
_index_marks_deleted_webui_session(session_dir, sid)
|
||||
or _durable_tombstone_marks_deleted_webui_session(session_dir, sid)
|
||||
)
|
||||
|
||||
|
||||
def audit_session_recovery(session_dir: Path, state_db_path: Path | None = None) -> dict:
|
||||
"""Read-only audit of session recovery state.
|
||||
|
||||
@@ -583,6 +681,16 @@ def audit_session_recovery(session_dir: Path, state_db_path: Path | None = None)
|
||||
items: list[dict] = []
|
||||
live_paths = sorted(p for p in session_dir.glob('*.json') if not p.name.startswith('_'))
|
||||
live_ids = {p.stem for p in live_paths}
|
||||
state_db_missing_rows = _read_state_db_missing_sidecar_rows(
|
||||
session_dir,
|
||||
state_db_path,
|
||||
include_empty=True,
|
||||
)
|
||||
state_db_deleted_webui_ids = {
|
||||
str(row.get('id') or '')
|
||||
for row in state_db_missing_rows
|
||||
if row.get('_state_db_deleted_webui_tombstone')
|
||||
}
|
||||
|
||||
for live_path in live_paths:
|
||||
status = inspect_session_recovery_status(live_path)
|
||||
@@ -596,6 +704,12 @@ def audit_session_recovery(session_dir: Path, state_db_path: Path | None = None)
|
||||
status.get('bak_messages', -1),
|
||||
))
|
||||
|
||||
# Track sids already classified as deleted-webui-tombstone from the orphan
|
||||
# .bak branch below, so the later state_db_missing_rows loop doesn't emit a
|
||||
# duplicate audit item for the same sid (both a surviving .bak and a state.db
|
||||
# row can exist for one deleted session). (#5504)
|
||||
_bak_tombstoned_ids: set[str] = set()
|
||||
|
||||
for bak_path in sorted(session_dir.glob('*.json.bak')):
|
||||
live_path = bak_path.with_suffix('')
|
||||
if live_path.exists() or live_path.name.startswith('_'):
|
||||
@@ -606,6 +720,21 @@ def audit_session_recovery(session_dir: Path, state_db_path: Path | None = None)
|
||||
items.append(_new_audit_item(
|
||||
session_id, "malformed_orphan_backup", "unsafe_to_repair", "manual_review", -1, bak_messages
|
||||
))
|
||||
elif _durable_tombstone_marks_deleted_webui_session(session_dir, session_id):
|
||||
# The user deleted this WebUI session; its surviving .bak must NOT be
|
||||
# reported as repairable. DURABLE tombstone only — the _index.json
|
||||
# heuristic must not suppress a genuine crash whose index survived
|
||||
# (that case is legitimately repairable). Matches the startup-recovery
|
||||
# skip + the state.db recovery path (#5504 Codex/Opus finding).
|
||||
_bak_tombstoned_ids.add(session_id)
|
||||
items.append(_new_audit_item(
|
||||
session_id,
|
||||
"state_db_deleted_webui_tombstone",
|
||||
"unsafe_to_repair",
|
||||
"deleted_session_skipped",
|
||||
-1,
|
||||
bak_messages,
|
||||
))
|
||||
elif _state_db_has_session(session_id, state_db_path):
|
||||
items.append(_new_audit_item(
|
||||
session_id, "orphan_backup", "repairable", "restore_from_bak", -1, bak_messages
|
||||
@@ -624,6 +753,11 @@ def audit_session_recovery(session_dir: Path, state_db_path: Path | None = None)
|
||||
if index_path.exists():
|
||||
index_ids = _read_index_session_ids(index_path)
|
||||
for session_id in sorted(index_ids - live_ids):
|
||||
if (
|
||||
session_id in state_db_deleted_webui_ids
|
||||
or _durable_tombstone_marks_deleted_webui_session(session_dir, session_id)
|
||||
):
|
||||
continue
|
||||
items.append(_new_audit_item(
|
||||
session_id, "index_missing_file", "repairable", "rebuild_index"
|
||||
))
|
||||
@@ -633,8 +767,23 @@ def audit_session_recovery(session_dir: Path, state_db_path: Path | None = None)
|
||||
_msg_count(session_dir / f"{session_id}.json"), -1,
|
||||
))
|
||||
|
||||
for row in _read_state_db_missing_sidecar_rows(session_dir, state_db_path, include_empty=True):
|
||||
for row in state_db_missing_rows:
|
||||
sid = str(row.get('id') or '')
|
||||
if sid in _bak_tombstoned_ids:
|
||||
# Already emitted a deleted-webui-tombstone audit item from the
|
||||
# orphan .bak branch above (surviving .bak + state.db row both exist
|
||||
# for this one deleted session) — don't double-count. (#5504)
|
||||
continue
|
||||
if row.get('_state_db_deleted_webui_tombstone'):
|
||||
items.append(_new_audit_item(
|
||||
sid,
|
||||
"state_db_deleted_webui_tombstone",
|
||||
"unsafe_to_repair",
|
||||
"deleted_session_skipped",
|
||||
-1,
|
||||
-1,
|
||||
))
|
||||
continue
|
||||
if row.get('_state_db_empty_messages'):
|
||||
items.append(_new_audit_item(
|
||||
sid,
|
||||
|
||||
@@ -162,10 +162,12 @@ def test_was_webui_gate_excludes_subagent_children():
|
||||
m = re.search(r"\n(?:def |class )", src[start + 1:])
|
||||
block = src[start:(start + 1 + m.start()) if m else len(src)]
|
||||
assert "_session_index_marks_was_webui(sid)" in block
|
||||
assert re.search(
|
||||
r"_session_index_marks_was_webui\(sid\)\s+and\s+not\s+_is_subagent_child_session_id\(sid\)",
|
||||
block,
|
||||
), "was_webui and not subagent-child must gate the same 404 return (#5307)"
|
||||
assert "_session_deleted_tombstone_marks_was_webui(sid)" in block
|
||||
guard_start = block.index("if (")
|
||||
guard = block[guard_start:block.index("return None, \"was_webui\"", guard_start)]
|
||||
assert "_is_subagent_child_session_id(sid)" in guard, (
|
||||
"all was_webui predicates must share the subagent-child exclusion (#5307)"
|
||||
)
|
||||
|
||||
|
||||
def test_sessions_js_open_handlers_keep_isexternalsession_contract():
|
||||
|
||||
@@ -374,6 +374,30 @@ def test_helper_returns_was_webui_for_deleted_webui_session(
|
||||
assert reason == "was_webui"
|
||||
|
||||
|
||||
def test_helper_returns_was_webui_for_durable_deleted_tombstone_without_index(
|
||||
routes_module, isolated_state_db
|
||||
):
|
||||
"""A full WebUI delete tombstone must keep the 404 self-heal contract even
|
||||
after /api/session/delete prunes _index.json before state.db cleanup fails.
|
||||
"""
|
||||
import api.models as _models
|
||||
|
||||
sid = "webui-deleted-db-survives"
|
||||
_make_state_db(
|
||||
isolated_state_db["db"],
|
||||
sid,
|
||||
message_count=2,
|
||||
title="Deleted WebUI",
|
||||
source="webui",
|
||||
)
|
||||
_models._record_webui_deleted_session_tombstone(sid)
|
||||
|
||||
sess, reason = routes_module._claim_or_synthesize_cli_session(sid)
|
||||
|
||||
assert sess is None
|
||||
assert reason == "was_webui"
|
||||
|
||||
|
||||
def test_helper_keeps_cli_orphan_with_blank_source(
|
||||
routes_module, tmp_path, monkeypatch, isolated_state_db
|
||||
):
|
||||
|
||||
@@ -1922,6 +1922,16 @@ def test_delete_imported_messaging_session_preserves_agent_memory(cleanup_test_s
|
||||
(sid,),
|
||||
).fetchone()[0]
|
||||
assert remaining == 2
|
||||
|
||||
# A messaging-session delete deliberately preserves the state.db
|
||||
# transcript (delete_cli_session is skipped), so it must NOT be
|
||||
# recorded in the deleted-WebUI tombstone — otherwise
|
||||
# _claim_or_synthesize_cli_session() would treat the still-live
|
||||
# channel session as was-webui and self-heal it to a 404 on reopen.
|
||||
import api.models as _m
|
||||
assert sid not in _m._load_webui_deleted_session_tombstone(), (
|
||||
"messaging session must not be tombstoned as a deleted WebUI session"
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, sid)
|
||||
@@ -1930,6 +1940,43 @@ def test_delete_imported_messaging_session_preserves_agent_memory(cleanup_test_s
|
||||
pass
|
||||
|
||||
|
||||
def test_deleted_webui_session_stays_out_of_sidebar_projection(cleanup_test_sessions):
|
||||
"""A tombstoned deleted WebUI session must not resurface via the state.db projection.
|
||||
|
||||
Regression for #5498 (second path): even when non-WebUI sessions are shown,
|
||||
_load_cli_sessions_uncached() projects source='webui' state.db rows into the
|
||||
sidebar. Without honoring the deleted-WebUI tombstone, a deleted session
|
||||
reappears as an 'Agent' ghost. The projection must skip a tombstoned
|
||||
source='webui' row that has no live sidecar.
|
||||
"""
|
||||
import api.models as _m
|
||||
|
||||
conn = _ensure_state_db()
|
||||
sid = 'webui_deleted_ghost_projection_001'
|
||||
cleanup_test_sessions.append(sid)
|
||||
try:
|
||||
_insert_gateway_session(conn, session_id=sid, source='webui', title='Deleted WebUI Ghost')
|
||||
_m._record_webui_deleted_session_tombstone(sid)
|
||||
# Tombstone should win: ensure no live sidecar exists for this sid.
|
||||
assert not (_m.SESSION_DIR / f'{sid}.json').exists()
|
||||
|
||||
post('/api/settings', {'show_cli_sessions': True})
|
||||
data, status = get('/api/sessions')
|
||||
assert status == 200
|
||||
ids = {s['session_id'] for s in data.get('sessions', [])}
|
||||
assert sid not in ids, (
|
||||
"deleted (tombstoned) WebUI session must not resurface in the sidebar projection"
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
_m._clear_webui_deleted_session_tombstone(sid)
|
||||
_remove_test_sessions(conn, sid)
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
post('/api/settings', {'show_cli_sessions': False})
|
||||
|
||||
|
||||
def test_imported_cron_sessions_hidden_from_sidebar_by_default(cleanup_test_sessions):
|
||||
"""Cron sessions already imported into the WebUI store should stay hidden from the sidebar."""
|
||||
from api.models import Session
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import api.models as models
|
||||
@@ -48,6 +50,45 @@ def _worktree_session(tmp_path, session_id):
|
||||
return s, worktree
|
||||
|
||||
|
||||
def _make_state_db(path, sid, *, source="telegram"):
|
||||
conn = sqlite3.connect(str(path))
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT,
|
||||
model TEXT,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
started_at REAL,
|
||||
title TEXT,
|
||||
cwd TEXT
|
||||
);
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
role TEXT,
|
||||
content TEXT,
|
||||
timestamp REAL
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, source, model, message_count, started_at, title, cwd) "
|
||||
"VALUES (?, ?, 'MiniMax-M3', 2, 1781024055.0, 'Telegram chat', ?)",
|
||||
(sid, source, str(path.parent)),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, 'user', 'hi', 1781024055.0)",
|
||||
(sid,),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, 'assistant', 'hello', 1781024056.0)",
|
||||
(sid,),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_delete_worktree_session_reports_retained_worktree_without_cleanup(tmp_path, monkeypatch):
|
||||
session_dir = _isolate_session_store(tmp_path, monkeypatch)
|
||||
session, worktree = _worktree_session(tmp_path, "wtdelete1")
|
||||
@@ -67,6 +108,77 @@ def test_delete_worktree_session_reports_retained_worktree_without_cleanup(tmp_p
|
||||
assert worktree.exists(), "session delete must not remove the git worktree directory"
|
||||
|
||||
|
||||
def test_delete_session_records_tombstone_when_state_db_delete_fails(tmp_path, monkeypatch):
|
||||
session_dir = _isolate_session_store(tmp_path, monkeypatch)
|
||||
sid = "dbfaildelete1"
|
||||
session = Session(
|
||||
session_id=sid,
|
||||
title="Delete failure",
|
||||
messages=[{"role": "user", "content": "keep deleted"}],
|
||||
)
|
||||
session.save()
|
||||
(session_dir / f"{sid}.json.bak").write_text("backup", encoding="utf-8")
|
||||
captured = _capture_post(monkeypatch, {"session_id": sid})
|
||||
monkeypatch.setattr(routes, "_lookup_cli_session_metadata", lambda value: {})
|
||||
monkeypatch.setattr(routes, "_is_messaging_session_id", lambda value: False)
|
||||
|
||||
def fail_delete(value):
|
||||
raise RuntimeError("state.db locked")
|
||||
|
||||
real_unlink = Path.unlink
|
||||
|
||||
def fail_backup_unlink(path, *args, **kwargs):
|
||||
if path.name == f"{sid}.json.bak":
|
||||
raise PermissionError("backup locked")
|
||||
return real_unlink(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(models, "delete_cli_session", fail_delete)
|
||||
monkeypatch.setattr(Path, "unlink", fail_backup_unlink)
|
||||
|
||||
assert routes.handle_post(object(), SimpleNamespace(path="/api/session/delete")) is True
|
||||
|
||||
assert captured["status"] == 200
|
||||
assert captured["payload"]["ok"] is True
|
||||
assert not (session_dir / f"{sid}.json").exists()
|
||||
assert sid in models._load_webui_deleted_session_tombstone()
|
||||
|
||||
|
||||
def test_delete_messaging_session_reopens_read_only_without_deleted_webui_tombstone(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
session_dir = _isolate_session_store(tmp_path, monkeypatch)
|
||||
sid = "telegramdelete1"
|
||||
state_db = tmp_path / "state.db"
|
||||
_make_state_db(state_db, sid)
|
||||
monkeypatch.setattr(models, "_active_state_db_path", lambda: state_db)
|
||||
session = Session(session_id=sid, title="Telegram chat")
|
||||
session.save()
|
||||
captured = _capture_post(monkeypatch, {"session_id": sid})
|
||||
cli_meta = {
|
||||
"session_id": sid,
|
||||
"source_tag": "telegram",
|
||||
"raw_source": "telegram",
|
||||
"session_source": "messaging",
|
||||
}
|
||||
monkeypatch.setattr(routes, "_lookup_cli_session_metadata", lambda value: cli_meta)
|
||||
monkeypatch.setattr(routes, "_is_messaging_session_id", lambda value: True)
|
||||
delete_calls = []
|
||||
monkeypatch.setattr(models, "delete_cli_session", lambda value: delete_calls.append(value))
|
||||
|
||||
assert routes.handle_post(object(), SimpleNamespace(path="/api/session/delete")) is True
|
||||
sess, reason = routes._claim_or_synthesize_cli_session(sid)
|
||||
|
||||
assert captured["status"] == 200
|
||||
assert captured["payload"]["ok"] is True
|
||||
assert not (session_dir / f"{sid}.json").exists()
|
||||
assert sid not in models._load_webui_deleted_session_tombstone()
|
||||
assert delete_calls == []
|
||||
assert reason == "not_claimable"
|
||||
assert sess is not None
|
||||
assert sess.read_only is True
|
||||
assert sess.session_source == "messaging"
|
||||
|
||||
|
||||
def test_archive_worktree_session_reports_retained_worktree_without_cleanup(tmp_path, monkeypatch):
|
||||
_isolate_session_store(tmp_path, monkeypatch)
|
||||
session, worktree = _worktree_session(tmp_path, "wtarchive1")
|
||||
|
||||
@@ -270,6 +270,34 @@ def test_recover_all_sessions_on_startup_restores_orphan_bak(temp_session_dir):
|
||||
assert len(restored["messages"]) == 293
|
||||
|
||||
|
||||
def test_recover_all_sessions_on_startup_skips_tombstoned_orphan_bak(temp_session_dir):
|
||||
"""A deleted WebUI session must NOT be resurrected from its surviving .bak.
|
||||
|
||||
Regression for #5498 / #5504: the double-failure delete (backup unlink +
|
||||
state.db delete both fail) leaves a surviving <sid>.json.bak plus the
|
||||
durable deleted-WebUI tombstone. Startup recovery must honor the tombstone
|
||||
and NOT recreate the sidecar (otherwise the deleted transcript reappears in
|
||||
the sidebar on the next boot — the literal ghost the fix exists to kill).
|
||||
"""
|
||||
import api.models as _m
|
||||
|
||||
sid = _make_session_on_disk(temp_session_dir, n_msgs=42)
|
||||
live_path = temp_session_dir / f"{sid}.json"
|
||||
bak_path = temp_session_dir / f"{sid}.json.bak"
|
||||
bak_path.write_text(live_path.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
live_path.unlink()
|
||||
# Simulate the delete route's durable tombstone for this deleted sid.
|
||||
_m._record_webui_deleted_session_tombstone(sid)
|
||||
try:
|
||||
from api.session_recovery import recover_all_sessions_on_startup
|
||||
result = recover_all_sessions_on_startup(temp_session_dir)
|
||||
|
||||
assert result["restored"] == 0, "tombstoned orphan .bak must not be restored"
|
||||
assert not live_path.exists(), "deleted session sidecar must not be recreated on boot"
|
||||
finally:
|
||||
_m._clear_webui_deleted_session_tombstone(sid)
|
||||
|
||||
|
||||
def test_recover_all_sessions_on_startup_rebuilds_missing_index_without_restores(temp_session_dir, monkeypatch):
|
||||
"""Startup recovery must rebuild a missing index even when no .bak restore runs."""
|
||||
import api.models as _m
|
||||
|
||||
@@ -26,6 +26,10 @@ def _make_state_db(path, *, sid="state_only_001", source="webui", messages=2):
|
||||
return sid
|
||||
|
||||
|
||||
def _write_index(path, entries):
|
||||
(path / "_index.json").write_text(json.dumps(entries), encoding="utf-8")
|
||||
|
||||
|
||||
def test_recover_missing_sidecars_from_state_db_materializes_webui_row(tmp_path):
|
||||
sid = _make_state_db(tmp_path / "state.db")
|
||||
|
||||
@@ -44,6 +48,200 @@ def test_recover_missing_sidecars_from_state_db_materializes_webui_row(tmp_path)
|
||||
assert [m["content"] for m in data["messages"]] == ["message 1", "message 2"]
|
||||
|
||||
|
||||
def test_recover_missing_sidecars_from_state_db_skips_deleted_webui_tombstone(tmp_path, monkeypatch):
|
||||
import api.models as _m
|
||||
monkeypatch.setattr(_m, "SESSION_DIR", tmp_path)
|
||||
sid = _make_state_db(tmp_path / "state.db", sid="deleted_webui_001")
|
||||
_write_index(tmp_path, [
|
||||
{
|
||||
"session_id": sid,
|
||||
"source_tag": "webui",
|
||||
"raw_source": "webui",
|
||||
"session_source": "webui",
|
||||
}
|
||||
])
|
||||
# A genuine delete records the DURABLE tombstone; only that suppresses repair.
|
||||
_m._record_webui_deleted_session_tombstone(sid)
|
||||
try:
|
||||
result = recover_missing_sidecars_from_state_db(tmp_path, tmp_path / "state.db")
|
||||
assert result["materialized"] == 0
|
||||
assert not (tmp_path / f"{sid}.json").exists()
|
||||
finally:
|
||||
_m._clear_webui_deleted_session_tombstone(sid)
|
||||
|
||||
|
||||
def test_recover_missing_sidecars_index_only_no_tombstone_is_repairable(tmp_path, monkeypatch):
|
||||
"""A crash that loses the sidecar (index intact, NO durable tombstone) must
|
||||
still be recovered from state.db — the index heuristic alone must not
|
||||
suppress repair (#5504 Codex/Opus finding; matches origin/master behavior)."""
|
||||
import api.models as _m
|
||||
monkeypatch.setattr(_m, "SESSION_DIR", tmp_path)
|
||||
sid = _make_state_db(tmp_path / "state.db", sid="crashed_webui_001")
|
||||
_write_index(tmp_path, [
|
||||
{
|
||||
"session_id": sid,
|
||||
"source_tag": "webui",
|
||||
"raw_source": "webui",
|
||||
"session_source": "webui",
|
||||
}
|
||||
])
|
||||
# No durable tombstone recorded (this is a crash, not a delete).
|
||||
result = recover_missing_sidecars_from_state_db(tmp_path, tmp_path / "state.db")
|
||||
assert result["materialized"] == 1
|
||||
assert (tmp_path / f"{sid}.json").exists()
|
||||
|
||||
|
||||
def test_audit_reports_deleted_webui_tombstone_is_unsafe(tmp_path, monkeypatch):
|
||||
import api.models as _m
|
||||
monkeypatch.setattr(_m, "SESSION_DIR", tmp_path)
|
||||
sid = _make_state_db(tmp_path / "state.db", sid="deleted_webui_001")
|
||||
_write_index(tmp_path, [
|
||||
{
|
||||
"session_id": sid,
|
||||
"source_tag": "webui",
|
||||
"raw_source": "webui",
|
||||
"session_source": "webui",
|
||||
}
|
||||
])
|
||||
_m._record_webui_deleted_session_tombstone(sid)
|
||||
try:
|
||||
report = audit_session_recovery(tmp_path, state_db_path=tmp_path / "state.db")
|
||||
|
||||
assert any(
|
||||
item["session_id"] == sid
|
||||
and item["kind"] == "state_db_deleted_webui_tombstone"
|
||||
and item["category"] == "unsafe_to_repair"
|
||||
and item["recommendation"] == "deleted_session_skipped"
|
||||
for item in report["items"]
|
||||
)
|
||||
assert not any(
|
||||
item["session_id"] == sid and item["kind"] == "state_db_missing_sidecar"
|
||||
for item in report["items"]
|
||||
)
|
||||
assert not any(
|
||||
item["session_id"] == sid and item["category"] == "repairable"
|
||||
for item in report["items"]
|
||||
)
|
||||
finally:
|
||||
_m._clear_webui_deleted_session_tombstone(sid)
|
||||
|
||||
|
||||
def test_audit_no_double_count_when_bak_and_state_db_row_both_survive(tmp_path, monkeypatch):
|
||||
"""A deleted session with BOTH a surviving .json.bak AND a state.db row must
|
||||
yield EXACTLY ONE deleted-webui-tombstone audit item, not two (#5504 SILENT
|
||||
finding — the orphan-.bak branch and the state.db missing-sidecar loop both
|
||||
used to emit it)."""
|
||||
import api.models as _m
|
||||
import json as _json
|
||||
monkeypatch.setattr(_m, "SESSION_DIR", tmp_path)
|
||||
sid = _make_state_db(tmp_path / "state.db", sid="deleted_both_001")
|
||||
# Plant a surviving orphan .bak (no live sidecar) for the same sid.
|
||||
(tmp_path / f"{sid}.json.bak").write_text(
|
||||
_json.dumps({"session_id": sid, "messages": [{"role": "user", "content": "x"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_m._record_webui_deleted_session_tombstone(sid)
|
||||
try:
|
||||
report = audit_session_recovery(tmp_path, state_db_path=tmp_path / "state.db")
|
||||
tombstone_items = [
|
||||
item for item in report["items"]
|
||||
if item["session_id"] == sid
|
||||
and item["kind"] == "state_db_deleted_webui_tombstone"
|
||||
]
|
||||
assert len(tombstone_items) == 1, (
|
||||
f"expected exactly one tombstone audit item, got {len(tombstone_items)}"
|
||||
)
|
||||
finally:
|
||||
_m._clear_webui_deleted_session_tombstone(sid)
|
||||
|
||||
|
||||
def test_recover_missing_sidecars_skips_durable_delete_tombstone_without_index(tmp_path, monkeypatch):
|
||||
import api.models as _m
|
||||
|
||||
sid = _make_state_db(tmp_path / "state.db", sid="durable_deleted_001")
|
||||
monkeypatch.setattr(_m, "SESSION_DIR", tmp_path)
|
||||
_m._record_webui_deleted_session_tombstone(sid)
|
||||
|
||||
result = recover_missing_sidecars_from_state_db(tmp_path, tmp_path / "state.db")
|
||||
report = audit_session_recovery(tmp_path, state_db_path=tmp_path / "state.db")
|
||||
|
||||
assert result["materialized"] == 0
|
||||
assert not (tmp_path / f"{sid}.json").exists()
|
||||
assert any(
|
||||
item["session_id"] == sid
|
||||
and item["kind"] == "state_db_deleted_webui_tombstone"
|
||||
and item["category"] == "unsafe_to_repair"
|
||||
and item["recommendation"] == "deleted_session_skipped"
|
||||
for item in report["items"]
|
||||
)
|
||||
assert not any(
|
||||
item["session_id"] == sid and item["category"] == "repairable"
|
||||
for item in report["items"]
|
||||
)
|
||||
|
||||
|
||||
def test_audit_skips_index_missing_file_when_durable_delete_tombstone_survives(tmp_path, monkeypatch):
|
||||
import api.models as _m
|
||||
|
||||
sid = "durable_deleted_index_001"
|
||||
_write_index(tmp_path, [
|
||||
{
|
||||
"session_id": sid,
|
||||
"source_tag": "webui",
|
||||
"raw_source": "webui",
|
||||
"session_source": "webui",
|
||||
}
|
||||
])
|
||||
monkeypatch.setattr(_m, "SESSION_DIR", tmp_path)
|
||||
_m._record_webui_deleted_session_tombstone(sid)
|
||||
|
||||
report = audit_session_recovery(tmp_path)
|
||||
|
||||
assert not any(
|
||||
item["session_id"] == sid and item["kind"] == "index_missing_file"
|
||||
for item in report["items"]
|
||||
)
|
||||
assert not any(
|
||||
item["session_id"] == sid and item["category"] == "repairable"
|
||||
for item in report["items"]
|
||||
)
|
||||
|
||||
|
||||
def test_audit_skips_orphan_backup_when_durable_delete_tombstone_survives(tmp_path, monkeypatch):
|
||||
import api.models as _m
|
||||
|
||||
sid = _make_state_db(tmp_path / "state.db", sid="durable_deleted_backup_001")
|
||||
(tmp_path / f"{sid}.json.bak").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"session_id": sid,
|
||||
"messages": [{"role": "user", "content": "deleted"}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(_m, "SESSION_DIR", tmp_path)
|
||||
_m._record_webui_deleted_session_tombstone(sid)
|
||||
|
||||
report = audit_session_recovery(tmp_path, state_db_path=tmp_path / "state.db")
|
||||
|
||||
assert any(
|
||||
item["session_id"] == sid
|
||||
and item["kind"] == "state_db_deleted_webui_tombstone"
|
||||
and item["category"] == "unsafe_to_repair"
|
||||
and item["recommendation"] == "deleted_session_skipped"
|
||||
for item in report["items"]
|
||||
)
|
||||
assert not any(
|
||||
item["session_id"] == sid and item["kind"] == "orphan_backup"
|
||||
for item in report["items"]
|
||||
)
|
||||
assert not any(
|
||||
item["session_id"] == sid and item["category"] == "repairable"
|
||||
for item in report["items"]
|
||||
)
|
||||
|
||||
|
||||
def test_recover_missing_sidecars_from_state_db_skips_existing_sidecar(tmp_path):
|
||||
sid = _make_state_db(tmp_path / "state.db")
|
||||
existing = tmp_path / f"{sid}.json"
|
||||
@@ -92,6 +290,41 @@ def test_empty_state_db_webui_row_is_unsafe_not_materialized(tmp_path):
|
||||
assert not (tmp_path / f"{sid}.json").exists()
|
||||
|
||||
|
||||
def test_recover_missing_sidecars_from_state_db_ignores_subagent_row(tmp_path):
|
||||
sid = _make_state_db(tmp_path / "state.db", sid="subagent_001", source="subagent")
|
||||
|
||||
result = recover_missing_sidecars_from_state_db(tmp_path, tmp_path / "state.db")
|
||||
|
||||
assert result["materialized"] == 0
|
||||
assert not (tmp_path / f"{sid}.json").exists()
|
||||
|
||||
report = audit_session_recovery(tmp_path, state_db_path=tmp_path / "state.db")
|
||||
assert not any(item["session_id"] == sid for item in report["items"])
|
||||
|
||||
|
||||
def test_recover_missing_sidecars_from_state_db_ignores_read_only_index_row(tmp_path):
|
||||
sid = _make_state_db(tmp_path / "state.db", sid="read_only_index_001")
|
||||
_write_index(tmp_path, [
|
||||
{
|
||||
"session_id": sid,
|
||||
"source_tag": "",
|
||||
"raw_source": "",
|
||||
"session_source": "",
|
||||
"read_only": True,
|
||||
}
|
||||
])
|
||||
|
||||
result = recover_missing_sidecars_from_state_db(tmp_path, tmp_path / "state.db")
|
||||
|
||||
assert result["materialized"] == 1
|
||||
sidecar = tmp_path / f"{sid}.json"
|
||||
assert sidecar.exists()
|
||||
data = json.loads(sidecar.read_text(encoding="utf-8"))
|
||||
assert data["session_id"] == sid
|
||||
assert data["source_tag"] == "webui"
|
||||
assert data["session_source"] == "webui"
|
||||
|
||||
|
||||
def test_materialized_sidecar_round_trips_through_session_load(tmp_path, monkeypatch):
|
||||
"""Schema parity guard: a materialized sidecar must be readable by Session.load
|
||||
and the resulting Session must have the same messages we put in state.db.
|
||||
|
||||
Reference in new issue
Block a user