fix(sessions): cascade delegated subagents on parent deletion

This commit is contained in:
pxxD1998 committed 2026-07-14 18:10:03 +08:00
1 parent 3f3fa9b69a
commit 73b6c5d604
9 files changed
+2304 -27

No files matched your search

+592 -13
View File
@@ -6,14 +6,25 @@ import hashlib
import inspect
import json
import logging
import math
import os
import re
import threading
import time
import uuid
from contextlib import closing
from contextlib import closing, contextmanager
from pathlib import Path
try: # pragma: no cover - platform-specific imports.
import fcntl as _fcntl
except ImportError: # pragma: no cover
_fcntl = None
try: # pragma: no cover - platform-specific imports.
import msvcrt as _msvcrt
except ImportError: # pragma: no cover
_msvcrt = None
import api.config as _cfg
from api.compression_anchor import is_context_compression_marker
from api.config import (
@@ -8594,31 +8605,599 @@ def count_conversation_rounds(sid: str, since: float | None = None) -> int:
CONVERSATION_ROUND_THRESHOLD = 10
@contextmanager
def _cleanup_manifest_process_lock(hermes_home):
"""Serialize cleanup across WebUI worker processes for one profile.
Keep the lock file in place permanently: unlinking it while another process
is waiting can split later callers across different inodes and defeat the
lock. POSIX uses ``flock``; native Windows locks the first byte with
``msvcrt.locking``. If neither primitive exists, fail closed rather than
running destructive cleanup without cross-process serialization.
"""
lock_path = Path(hermes_home) / ".session_cleanup.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
with os.fdopen(fd, "r+b", buffering=0) as lock_file:
if _fcntl is not None:
_fcntl.flock(lock_file.fileno(), _fcntl.LOCK_EX)
try:
yield
finally:
_fcntl.flock(lock_file.fileno(), _fcntl.LOCK_UN)
return
if _msvcrt is not None:
if os.fstat(lock_file.fileno()).st_size == 0:
lock_file.write(b"\0")
lock_file.seek(0)
_msvcrt.locking( # type: ignore[attr-defined]
lock_file.fileno(), _msvcrt.LK_LOCK, 1 # type: ignore[attr-defined]
)
try:
yield
finally:
lock_file.seek(0)
_msvcrt.locking( # type: ignore[attr-defined]
lock_file.fileno(), _msvcrt.LK_UNLCK, 1 # type: ignore[attr-defined]
)
return
raise RuntimeError("cross-process session cleanup locking is unavailable")
_cleanup_manifest_locks_guard = threading.Lock()
_cleanup_manifest_locks = {}
def _cleanup_manifest_thread_lock(hermes_home):
"""Return the in-process cleanup lock for one resolved profile home."""
key = os.fspath(Path(hermes_home))
with _cleanup_manifest_locks_guard:
lock = _cleanup_manifest_locks.get(key)
if lock is None:
lock = threading.Lock()
_cleanup_manifest_locks[key] = lock
return lock
def delete_cli_session(sid) -> bool:
"""Delete a CLI session from state.db (messages + session row).
Returns True if deleted, False if not found or error.
"""Delete a CLI session while serializing manifest and DB cleanup."""
try:
from api.profiles import get_active_hermes_home
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
except Exception:
logger.warning("Failed to resolve active profile for session delete", exc_info=True)
return False
try:
with _cleanup_manifest_thread_lock(hermes_home):
with _cleanup_manifest_process_lock(hermes_home):
return _delete_cli_session_locked(sid, hermes_home)
except Exception:
logger.warning("Failed to delete CLI session %s from state.db", sid, exc_info=True)
return False
def _delete_cli_session_locked(sid, hermes_home) -> bool:
"""Delete a CLI session from state.db using Hermes' session semantics.
A scoped transaction implements the Agent invariant while giving branch and
compression evidence precedence over inherited delegate metadata. Current
Hermes Agent's canonical helper does not yet make that precedence guarantee,
so this destructive path fails closed instead of delegating to it.
Returns True when the requested state is absent after cleanup, False on an
operational error.
"""
import os
try:
import sqlite3
except ImportError:
return False
try:
from api.profiles import get_active_hermes_home
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
except Exception:
hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser().resolve()
# Process any leftover cleanup manifests from a previous failed run.
# This runs before the DB-existence check so pending artifact
# removals get another chance even when the current session ID
# is unrelated.
stale_cleanup_complete = _process_stale_cleanup_manifests(hermes_home)
db_path = hermes_home / 'state.db'
if not db_path.exists():
return False
try:
with closing(sqlite3.connect(str(db_path))) as conn:
cur = conn.cursor()
cur.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
cur.execute("DELETE FROM sessions WHERE id = ?", (sid,))
conn.row_factory = sqlite3.Row
# SQLite does not enforce foreign keys by default; enabling
# PRAGMA foreign_keys makes the ON DELETE CASCADE clauses on
# session_model_usage and telegram_dm_topic_bindings fire
# automatically. Compression locks have no FK, so they are
# cleaned explicitly below.
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN IMMEDIATE")
columns = {
row[1] for row in conn.execute("PRAGMA table_info(sessions)").fetchall()
}
if not {"id", "parent_session_id"}.issubset(columns):
return False
selected = ["id", "parent_session_id"]
for column in (
"model_config", "source", "end_reason", "started_at", "ended_at"
):
selected.append(column if column in columns else f"NULL AS {column}")
rows = conn.execute(f"SELECT {', '.join(selected)} FROM sessions").fetchall()
# _delegate_from is authoritative. source=subagent is the legacy
# compatibility signal for rows created before that marker existed,
# but legacy branch/compression continuations must remain intact.
records = []
for row in rows:
model_config = {}
raw_model_config = row["model_config"]
model_config_known = raw_model_config in (None, "")
if not model_config_known:
try:
parsed_model_config = json.loads(raw_model_config)
except (TypeError, ValueError):
parsed_model_config = None
if isinstance(parsed_model_config, dict):
model_config = parsed_model_config
model_config_known = True
records.append(
{
"id": row["id"],
"parent_id": row["parent_session_id"],
"delegate_from": model_config.get("_delegate_from"),
"branched_from": model_config.get("_branched_from"),
"model_config_known": model_config_known,
"source": row["source"],
"end_reason": row["end_reason"],
"started_at": row["started_at"],
"ended_at": row["ended_at"],
}
)
records_by_id = {record["id"]: record for record in records}
def _timestamp_value(value):
"""Return a comparable UTC timestamp, or None when ambiguous."""
if isinstance(value, bool) or value in (None, ""):
return None
if isinstance(value, (int, float)):
numeric = float(value)
return numeric if math.isfinite(numeric) else None
if isinstance(value, datetime.datetime):
parsed = value
elif isinstance(value, str):
raw = value.strip()
if not raw:
return None
try:
numeric = float(raw)
return numeric if math.isfinite(numeric) else None
except ValueError:
try:
parsed = datetime.datetime.fromisoformat(
raw[:-1] + "+00:00" if raw.endswith("Z") else raw
)
except ValueError:
return None
else:
return None
if parsed.tzinfo is None:
return None
try:
numeric = parsed.timestamp()
except (OverflowError, OSError, ValueError):
return None
return numeric if math.isfinite(numeric) else None
def _must_preserve(record, parent):
"""Fail closed when branch/compression evidence is ambiguous."""
if record["branched_from"] is not None:
return True
end_reason = parent.get("end_reason")
if end_reason == "compression":
return True
if end_reason != "branched":
return False
started_at = _timestamp_value(record["started_at"])
parent_ended_at = _timestamp_value(parent.get("ended_at"))
if started_at is None or parent_ended_at is None:
return True
return started_at >= parent_ended_at
def _lineage_parent(record):
"""Return the parent that supplies this row's lineage edge.
``_delegate_from`` is authoritative when present. Falling back
to the physical parent is only valid for legacy rows without
that marker; otherwise a compression continuation whose physical
parent differs from its lineage parent could be deleted.
"""
delegate_from = record["delegate_from"]
if delegate_from is not None:
return records_by_id.get(delegate_from) or {}
return records_by_id.get(record["parent_id"]) or {}
# Once a branch/compression continuation is preserved, its physical
# child tree is outside the delete lineage. Stale inherited delegate
# markers must not let traversal re-enter that retained subtree.
preserved_ids = {
record["id"]
for record in records
if record["id"] != sid
and _must_preserve(record, _lineage_parent(record))
}
while True:
descendants = {
record["id"]
for record in records
if record["id"] != sid
and record["parent_id"] in preserved_ids
}
new_ids = descendants - preserved_ids
if not new_ids:
break
preserved_ids.update(new_ids)
found = {sid}
frontier = {sid}
while frontier:
next_frontier = set()
for record in records:
row_id = record["id"]
if row_id in found or row_id in preserved_ids:
continue
parent_id = record["parent_id"]
delegate_from = record["delegate_from"]
linked_to_frontier = (
delegate_from in frontier or parent_id in frontier
)
if not linked_to_frontier:
continue
parent = _lineage_parent(record)
if _must_preserve(record, parent):
continue
# Explicit _delegate_from is authoritative even when a
# migrated legacy row lacks source='subagent'. Branch and
# compression evidence above still wins and preserves it.
if delegate_from is not None:
if delegate_from in frontier:
next_frontier.add(row_id)
continue
# Only marker-less legacy inference requires the historical
# source tag plus a compatible physical-parent edge.
if record["source"] != "subagent":
continue
if (
parent_id in frontier
and record["model_config_known"]
):
next_frontier.add(row_id)
found.update(next_frontier)
frontier = next_frontier
delegate_ids = sorted(found - {sid})
all_removed_ids = [sid, *delegate_ids]
placeholders = ",".join("?" * len(all_removed_ids))
# Delete delegate children first (messages, then orphan their
# children, then the rows themselves).
for child_id in delegate_ids:
conn.execute("DELETE FROM messages WHERE session_id = ?", (child_id,))
for child_id in delegate_ids:
conn.execute(
"UPDATE sessions SET parent_session_id = NULL WHERE parent_session_id = ?",
(child_id,),
)
for child_id in delegate_ids:
conn.execute("DELETE FROM sessions WHERE id = ?", (child_id,))
# Preserve every remaining child as an independent row, matching
# current SessionDB.delete_session().
conn.execute(
"UPDATE sessions SET parent_session_id = NULL WHERE parent_session_id = ?",
(sid,),
)
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
# Referential-integrity cleanup for session-owned rows that are
# not covered by ON DELETE CASCADE. session_model_usage and
# telegram_dm_topic_bindings have FK CASCADE (enforced by
# PRAGMA foreign_keys = ON above), but compression_locks has no
# foreign key, so stale rows would survive. Gate each table
# on existence so this works on older schemas too.
table_names = {
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
}
if "compression_locks" in table_names:
conn.execute(
f"DELETE FROM compression_locks WHERE session_id IN ({placeholders})",
all_removed_ids,
)
# Belt-and-suspenders: also explicitly clear FK tables in case
# PRAGMA foreign_keys is OFF on an older SQLite build or a
# future schema drops the CASCADE clause.
if "session_model_usage" in table_names:
conn.execute(
f"DELETE FROM session_model_usage WHERE session_id IN ({placeholders})",
all_removed_ids,
)
if "telegram_dm_topic_bindings" in table_names:
conn.execute(
f"DELETE FROM telegram_dm_topic_bindings WHERE session_id IN ({placeholders})",
all_removed_ids,
)
# Persist a cleanup manifest BEFORE the commit so artifact
# removal is idempotent and retryable. Each call uses a unique
# manifest filename (atomic temp-file + rename) so concurrent
# deletes never clobber each other's retry records.
sessions_dir = hermes_home / "sessions"
sessions_dir.mkdir(parents=True, exist_ok=True)
manifest_basename = f".cleanup_manifest_{uuid.uuid4().hex}"
manifest_path = sessions_dir / f"{manifest_basename}.json"
manifest_tmp = sessions_dir / f"{manifest_basename}.tmp"
try:
manifest_tmp.write_text(
json.dumps(sorted(str(i) for i in all_removed_ids)),
encoding="utf-8",
)
manifest_tmp.rename(manifest_path)
except OSError:
logger.warning("Failed to write cleanup manifest", exc_info=True)
try:
manifest_tmp.unlink(missing_ok=True)
except OSError:
logger.warning(
"Failed to remove incomplete cleanup manifest %s",
manifest_tmp,
exc_info=True,
)
# Publishing the retry record is a prerequisite for making the
# DB deletion durable. Without it, committed rows could vanish
# while transcript artifacts remain forever and the UI reports
# a false success.
conn.rollback()
return False
conn.commit()
return cur.rowcount > 0
# Post-commit artifact cleanup. Scan ALL outstanding manifests
# (including the one just written) and retry every pending ID.
# Each manifest entry is re-checked against the DB so a stale
# manifest from a failed commit never unlinks a live session's
# artifacts.
artifact_cleanup_failed = False
def _is_session_alive(conn, sid):
"""True when *sid* still has a row in the sessions table.
On any query failure, assume alive (fail closed) — an
uncertain liveness check must never trigger artifact
deletion on this unrecoverable state.db path.
"""
try:
cursor = conn.execute(
"SELECT 1 FROM sessions WHERE id = ?", (sid,)
)
return cursor.fetchone() is not None
except Exception:
return True
def _clean_artifacts_for_id(sessions_dir, removed_id):
"""Remove on-disk transcript files for one session ID.
Returns True when every artifact is gone (or was absent).
"""
if not is_safe_session_id(removed_id):
return False
ok = True
for suffix in (".json", ".jsonl"):
artifact = sessions_dir / f"{removed_id}{suffix}"
if not artifact.exists():
continue
try:
artifact.unlink(missing_ok=True)
except OSError:
ok = False
logger.warning(
"Failed to remove session artifact %s%s",
removed_id,
suffix,
exc_info=True,
)
try:
for path in list(
sessions_dir.glob(f"request_dump_{removed_id}_*.json")
):
try:
path.unlink(missing_ok=True)
except OSError:
ok = False
logger.warning(
"Failed to remove request dump %s",
path,
exc_info=True,
)
except OSError:
ok = False
logger.warning(
"Failed to enumerate request dumps for %s",
removed_id,
exc_info=True,
)
return ok
for mp in sorted(sessions_dir.glob(".cleanup_manifest_*.json")):
try:
raw = mp.read_text(encoding="utf-8")
pending_ids = json.loads(raw)
except (OSError, ValueError, TypeError):
# The IDs are unknown, so deleting the manifest would lose
# the only retry record for potentially orphaned artifacts.
artifact_cleanup_failed = True
continue
if not isinstance(pending_ids, list) or not all(
isinstance(item, str) for item in pending_ids
):
artifact_cleanup_failed = True
continue
still_pending = []
for removed_id in pending_ids:
# Transaction-outcome guard: never unlink artifacts for
# a session that still exists in the DB. A stale
# manifest from a failed commit must not delete data
# belonging to a live conversation.
if _is_session_alive(conn, removed_id):
still_pending.append(removed_id)
continue
if not _clean_artifacts_for_id(sessions_dir, removed_id):
still_pending.append(removed_id)
if still_pending:
# Atomic rewrite using temp-file + rename.
tmp = mp.with_suffix(".tmp")
try:
tmp.write_text(json.dumps(still_pending), encoding="utf-8")
tmp.rename(mp)
except OSError:
logger.warning(
"Failed to rewrite manifest %s", mp, exc_info=True,
)
artifact_cleanup_failed = True
else:
mp.unlink(missing_ok=True)
return stale_cleanup_complete and not artifact_cleanup_failed
except Exception:
logger.warning("Failed to delete CLI session %s from state.db", sid, exc_info=True)
return False
# ---------------------------------------------------------------------------
# ``delete_cli_session`` nests each profile's cross-process file lock inside
# that profile's in-process lock so stale-manifest read, DB transaction, and
# post-commit cleanup remain one critical section without blocking unrelated
# profiles.
# ---------------------------------------------------------------------------
def _process_stale_cleanup_manifests(hermes_home) -> bool:
"""Process any leftover cleanup manifests outside a DB transaction.
Called at the start of each delete_cli_session run, before the
regular transaction, so a previous run whose DB commit succeeded
but artifact cleanup failed gets another chance — even when the
session ID being deleted this time is unrelated.
Serialized by the caller's per-profile thread and process locks so that the
manifest read → DB transaction → post-commit cleanup triad is
never interleaved across concurrent delete calls. Returns ``True`` only
when every discovered retry record was processed completely.
"""
try:
import sqlite3
except ImportError:
return False
db_path = hermes_home / "state.db"
sessions_dir = hermes_home / "sessions"
if not sessions_dir.exists():
return True
manifests = sorted(sessions_dir.glob(".cleanup_manifest_*.json"))
if not manifests:
return True
# Missing state.db is not proof that a manifested session is dead. It may
# have been temporarily renamed, unmounted, or made inaccessible. Preserve
# every manifest and artifact until a successful query proves absence.
if not db_path.exists():
return False
cleanup_complete = True
for mp in manifests:
try:
raw = mp.read_text(encoding="utf-8")
pending_ids = json.loads(raw)
except (OSError, ValueError, TypeError):
# Preserve malformed/unreadable retry records. Their pending IDs
# are unknowable, so silently deleting them would turn an
# incomplete cleanup into a false success.
cleanup_complete = False
continue
if not isinstance(pending_ids, list) or not all(
isinstance(item, str) for item in pending_ids
):
cleanup_complete = False
continue
pending_ids = list(pending_ids)
if not pending_ids:
mp.unlink(missing_ok=True)
continue
# Require a successful read-only query to prove absence. Opening via a
# read-only URI also prevents SQLite from creating a fresh empty DB if
# state.db disappears between the existence check and connect().
try:
db_uri = f"{db_path.resolve().as_uri()}?mode=ro"
with closing(sqlite3.connect(db_uri, uri=True)) as conn:
cursor = conn.execute(
"SELECT id FROM sessions WHERE id IN ({})".format(
",".join("?" * len(pending_ids))
),
pending_ids,
)
alive = {row[0] for row in cursor.fetchall()}
except Exception:
# Liveness query failed (missing DB, lock, timeout, I/O error).
# Fail closed: preserve the manifest file on disk and skip it this
# round. A later call can retry when DB state is queryable.
cleanup_complete = False
continue
still_pending = []
for removed_id in pending_ids:
if removed_id in alive:
# Session still exists — the previous commit never
# reached the DB, so this manifest entry is stale.
# Drop it silently: never propagate a stale manifest
# into the post-commit cleanup loop where it would
# cause the current call to report a false failure.
continue
if not _clean_pending_artifact(sessions_dir, removed_id):
still_pending.append(removed_id)
if still_pending:
tmp = mp.with_suffix(".tmp")
try:
tmp.write_text(json.dumps(still_pending), encoding="utf-8")
tmp.rename(mp)
except OSError:
cleanup_complete = False
else:
try:
mp.unlink(missing_ok=True)
except OSError:
cleanup_complete = False
return cleanup_complete
def _clean_pending_artifact(sessions_dir, removed_id):
"""Remove on-disk transcript files for one session ID, outside a
DB transaction. Returns True when every artifact is gone (or absent).
"""
if not is_safe_session_id(removed_id):
return False
ok = True
for suffix in (".json", ".jsonl"):
artifact = sessions_dir / f"{removed_id}{suffix}"
if not artifact.exists():
continue
try:
artifact.unlink(missing_ok=True)
except OSError:
ok = False
try:
for path in list(sessions_dir.glob(f"request_dump_{removed_id}_*.json")):
try:
path.unlink(missing_ok=True)
except OSError:
ok = False
except OSError:
ok = False
return ok
+12 -3
View File
@@ -14666,15 +14666,24 @@ def handle_post(handler, parsed) -> bool:
logger.debug("Failed to close workspace terminal for deleted session %s", sid)
# Also delete from CLI state.db for CLI sessions shown in sidebar,
# but never erase external messaging channel memory via WebUI delete.
state_db_cleanup_failed = False
if not is_messaging_session:
try:
from api.models import delete_cli_session
delete_cli_session(sid)
state_db_cleanup_failed = not delete_cli_session(sid)
except Exception:
logger.debug("Failed to delete CLI session %s", sid)
state_db_cleanup_failed = True
logger.warning("Failed to delete CLI session %s", sid, exc_info=True)
_publish_session_list_changed("session_delete", profile=event_profile)
return j(handler, {"ok": True, **worktree_retained})
return j(
handler,
{
"ok": True,
"state_db_cleanup_failed": state_db_cleanup_failed,
**worktree_retained,
},
)
if parsed.path == "/api/session/clear":
try:
+5 -2
View File
@@ -41,7 +41,7 @@ helpers are genuinely shared code.
| `docker_agent_source_volume` | Compose files and Docker docs expose `hermes-agent-src` and `/opt/hermes` to make the agent checkout visible to WebUI. | Remove the WebUI source mount only after startup install and runtime imports have migrated. This needs Docker/compose follow-up work, not a runtime behavior change in this audit PR. |
| `startup_dependency_install` | `api/startup.py` discovers `HERMES_WEBUI_AGENT_DIR` or `$HERMES_HOME/hermes-agent`; `server.py` calls `auto_install_agent_deps()` after import verification fails; `docker_init.bash` installs from the staged agent source. | Replace source-tree pip installs with a packaged hermes-agent WebUI client plus an agent health/version capability contract. Keep `HERMES_WEBUI_AGENT_DIR` during migration as an override/debug path, but it should stop being required in normal multi-container startup. |
| `runtime_auxiliary_model_metadata` | `api/streaming.py`, `api/routes.py`, `api/config.py`, and `api/providers.py` import `agent.auxiliary_client`, `agent.model_metadata`, `agent.models_dev`, `hermes_cli.models`, and `agent.account_usage`. | Existing provider/model WebUI endpoints can keep serving UI data where they already wrap agent helpers. Missing surfaces need hermes-agent endpoints or a client package for auxiliary task config, text auxiliary calls, context length, token estimate, provider catalog, and account usage. |
| `runtime_session_state` | `api/streaming.py`, `api/goals.py`, and `api/state_sync.py` import `hermes_state.SessionDB` directly. | Move cross-container state reads and writes behind hermes-agent session/state endpoints. WebUI-only presentation state can remain local, but agent session storage should not be opened from the WebUI container. |
| `runtime_session_state` | `api/streaming.py`, `api/goals.py`, and `api/state_sync.py` import `hermes_state.SessionDB` directly. `api/models.py` also opens the active profile's canonical `state.db` for scoped session deletion because the current canonical helper does not preserve branch/compression evidence ahead of inherited delegate metadata or expose retryable artifact-cleanup semantics. | Move cross-container state reads and writes, including destructive session deletion, behind hermes-agent session/state endpoints once the agent API provides equivalent lineage precedence, transaction, and retry-manifest guarantees. WebUI-only presentation state can remain local, but agent session storage should not be opened from the WebUI container. |
| `runtime_gateway_provider` | `api/streaming.py` and `api/routes.py` import `hermes_cli.runtime_provider`; adapter helpers such as `agent.anthropic_adapter` are also imported for gateway normalization. | Provider resolution, runtime routing, and gateway invocation should be hermes-agent API calls. WebUI can keep request validation and display formatting, but it should not import runtime provider internals from the agent checkout. |
| `webui_local_or_client_package` | WebUI imports `hermes_cli.auth`, `hermes_cli.config`, `hermes_cli.plugins`, `hermes_cli.profiles`, `hermes_cli.goals`, `agent.skill_utils`, `agent.credential_pool`, and `hermes_constants`. | Pure schemas, constants, and parsing helpers can move into a small versioned client/shared package. Privileged data such as credential pools, auth status, profile mutation, plugin discovery, and goal persistence need hermes-agent endpoints. UI-only formatting can remain in WebUI. |
@@ -67,7 +67,10 @@ These surfaces require an agent-owned endpoint because they read agent state,
perform provider/runtime decisions, or expose privileged data:
- SessionDB/session state read and write operations now using
`hermes_state.SessionDB`.
`hermes_state.SessionDB`, plus the scoped direct-SQL session deletion in
`api/models.py`. The replacement endpoint must preserve branch/compression
precedence and expose retryable post-commit artifact cleanup before that
compatibility path can be removed.
- Runtime provider resolution and gateway normalization now using
`hermes_cli.runtime_provider` and `agent.anthropic_adapter`.
- Auxiliary task execution and configuration now using `agent.auxiliary_client`.
+8 -3
View File
@@ -4142,6 +4142,7 @@ function _renderBatchActionBar(){
return {response,session:sessionsById.get(sid)||null};
}));
const retainedCount=_worktreeResponseCount(results);
const cleanupFailedCount=results.filter(result=>result.response&&result.response.state_db_cleanup_failed).length;
ids.forEach(_clearHandoffStorageForSession);
if(S.session&&ids.includes(S.session.session_id)){
S.session=null;S.messages=[];S.entries=[];localStorage.removeItem('hermes-webui-session');
@@ -4150,7 +4151,9 @@ function _renderBatchActionBar(){
if(remaining.sessions&&remaining.sessions.length){await loadSession(remaining.sessions[0].session_id);}
else{$('msgInner').innerHTML='';$('emptyState').style.display='';}
}
showToast((retainedCount?t('session_deleted_worktree'):t('session_delete'))+' ('+ids.length+')');exitSessionSelectMode();await renderSessionList();
if(cleanupFailedCount) showToast(t('delete_failed')+' ('+cleanupFailedCount+'/'+ids.length+')',0,'error');
else showToast((retainedCount?t('session_deleted_worktree'):t('session_delete'))+' ('+ids.length+')');
exitSessionSelectMode();await renderSessionList();
}catch(e){showToast('Delete failed: '+(e.message||e));}
};bar.appendChild(deleteBtn);
}
@@ -8887,6 +8890,7 @@ async function deleteSession(sid, beforeDelete=null){
return false;
}
const response=deleteResult&&deleteResult.response;
const cleanupFailed=!!(response&&response.state_db_cleanup_failed);
if(typeof _clearPersistedSessionQueue==='function') _clearPersistedSessionQueue(sid);
if(!optimisticRendered){
_pendingSessionReflowPositions=reflowPositions;
@@ -8910,10 +8914,11 @@ async function deleteSession(sid, beforeDelete=null){
if(typeof syncAppTitlebar==='function') syncAppTitlebar();
}
}
showToast(_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree'):t('session_deleted'));
if(cleanupFailed) showToast(t('delete_failed'),0,'error');
else showToast(_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree'):t('session_deleted'));
if(optimisticRendered) void renderSessionList().finally(()=>_optimisticallyRemovedSessionIds.delete(sid));
else await renderSessionList();
return true;
return !cleanupFailed;
}
// ── Project helpers ─────────────────────────────────────────────────────
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -204,7 +204,7 @@ def test_get_cli_session_messages_closes_connection(tmp_path, tracking_sqlite, m
def test_delete_cli_session_closes_connection(tmp_path, tracking_sqlite, monkeypatch):
"""`delete_cli_session` must close its sqlite connection (also keeps explicit commit working)."""
"""The scoped delete transaction must close its sqlite connection."""
db = tmp_path / "state.db"
_make_state_db(db)
@@ -217,9 +217,9 @@ def test_delete_cli_session_closes_connection(tmp_path, tracking_sqlite, monkeyp
deleted = delete_cli_session("s1")
assert deleted is True, "delete_cli_session should report the row was removed"
# Second call: row gone, nothing to delete — connection must still close cleanly.
# Second call is idempotent; its connection must still close cleanly.
deleted_again = delete_cli_session("s1")
assert deleted_again is False
assert deleted_again is True
_assert_all_closed(tracking_sqlite, "delete_cli_session")
# First call commits; second call short-circuits but still opens+closes a connection.
+4 -1
View File
@@ -95,12 +95,13 @@ def test_delete_worktree_session_reports_retained_worktree_without_cleanup(tmp_p
captured = _capture_post(monkeypatch, {"session_id": session.session_id})
monkeypatch.setattr(routes, "_lookup_cli_session_metadata", lambda sid: {})
monkeypatch.setattr(routes, "_is_messaging_session_id", lambda sid: False)
monkeypatch.setattr(models, "delete_cli_session", lambda sid: None)
monkeypatch.setattr(models, "delete_cli_session", lambda sid: True)
assert routes.handle_post(object(), SimpleNamespace(path="/api/session/delete")) is True
assert captured["status"] == 200
assert captured["payload"]["ok"] is True
assert captured["payload"]["state_db_cleanup_failed"] is False
assert captured["payload"]["worktree_retained"] is True
assert captured["payload"]["worktree_path"] == str(worktree.resolve())
assert captured["payload"]["worktree_branch"] == "hermes/wtdelete1"
@@ -139,6 +140,7 @@ def test_delete_session_records_tombstone_when_state_db_delete_fails(tmp_path, m
assert captured["status"] == 200
assert captured["payload"]["ok"] is True
assert captured["payload"]["state_db_cleanup_failed"] is True
assert not (session_dir / f"{sid}.json").exists()
assert sid in models._load_webui_deleted_session_tombstone()
@@ -170,6 +172,7 @@ def test_delete_messaging_session_reopens_read_only_without_deleted_webui_tombst
assert captured["status"] == 200
assert captured["payload"]["ok"] is True
assert captured["payload"]["state_db_cleanup_failed"] is False
assert not (session_dir / f"{sid}.json").exists()
assert sid not in models._load_webui_deleted_session_tombstone()
assert delete_calls == []
+8 -1
View File
@@ -57,8 +57,13 @@ def test_archive_delete_success_copy_prefers_response_worktree_retained():
assert "session.archived?_sessionArchiveToast(response,session):t('session_restored')" in src
assert "_sessionResponseRetainsWorktree(response,session)?t('session_deleted_worktree')" in src
assert "const retainedCount=_worktreeResponseCount(results)" in src
assert "const cleanupFailedCount=results.filter(result=>result.response&&result.response.state_db_cleanup_failed).length;" in src
assert "if(cleanupFailedCount) showToast(t('delete_failed')+' ('+cleanupFailedCount+'/'+ids.length+')',0,'error');" in src
assert "showToast(retainedCount?t('session_archived_worktree'):t('session_archived'))" in src
assert "showToast((retainedCount?t('session_deleted_worktree'):t('session_delete'))" in src
assert "const cleanupFailed=!!(response&&response.state_db_cleanup_failed);" in src
assert "if(cleanupFailed) showToast(t('delete_failed'),0,'error');" in src
assert "return !cleanupFailed;" in src
def test_worktree_archive_delete_api_responses_are_explicit():
@@ -66,7 +71,9 @@ def test_worktree_archive_delete_api_responses_are_explicit():
assert "def _worktree_retained_payload(session)" in src
assert "def _worktree_retained_payload_for_session_id(sid: str)" in src
assert '"worktree_retained": True' in src
assert '{"ok": True, **worktree_retained}' in src
assert '"state_db_cleanup_failed": state_db_cleanup_failed' in src
assert '"ok": True,' in src
assert "**worktree_retained," in src
assert '{"ok": True, "session": s.compact(), **_worktree_retained_payload(s)}' in src
+6 -1
View File
@@ -260,7 +260,12 @@ def test_delete_cli_session_still_opens_writable_connection():
read-only downgrade would make the delete silently no-op."""
import inspect
src = inspect.getsource(models.delete_cli_session)
src = "\n".join(
(
inspect.getsource(models.delete_cli_session),
inspect.getsource(models._delete_cli_session_locked),
)
)
assert "sqlite3.connect(str(db_path))" in src, (
"delete_cli_session must keep its writable connection"
)