Skip to content
Merged
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
57 changes: 33 additions & 24 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -4632,30 +4632,39 @@ async def _handle_branch_command(self, event: MessageEvent) -> str:
logger.error("Failed to create branch session: %s", e)
return t("gateway.branch.create_failed", error=e)

# Copy conversation history to the new session
for msg in history:
try:
await self._session_db.append_message(
session_id=new_session_id,
role=msg.get("role", "user"),
content=msg.get("content"),
tool_name=msg.get("tool_name") or msg.get("name"),
tool_calls=msg.get("tool_calls"),
tool_call_id=msg.get("tool_call_id"),
finish_reason=msg.get("finish_reason"),
reasoning=msg.get("reasoning"),
reasoning_content=msg.get("reasoning_content"),
reasoning_details=msg.get("reasoning_details"),
codex_reasoning_items=msg.get("codex_reasoning_items"),
codex_message_items=msg.get("codex_message_items"),
# Keep the api_content sidecar so the branch's first turn
# replays the parent's exact wire bytes (warm provider
# prompt cache) instead of a full cold prefill.
api_content=extract_api_content_sidecar(msg),
timestamp=msg.get("timestamp"),
)
except Exception:
pass # Best-effort copy
# Copy conversation history to the new session in bounded-chunk
# transactions (see #23254): one txn per row was the removed
# write-amplification pattern, and a history can be hundreds of rows.
# Best-effort like the old loop — a failed copy still yields a
# usable (partial) branch.
try:
await self._session_db.append_messages_batch(
new_session_id,
[
{
"role": msg.get("role", "user"),
"content": msg.get("content"),
"tool_name": msg.get("tool_name") or msg.get("name"),
"tool_calls": msg.get("tool_calls"),
"tool_call_id": msg.get("tool_call_id"),
"finish_reason": msg.get("finish_reason"),
"reasoning": msg.get("reasoning"),
"reasoning_content": msg.get("reasoning_content"),
"reasoning_details": msg.get("reasoning_details"),
"codex_reasoning_items": msg.get("codex_reasoning_items"),
"codex_message_items": msg.get("codex_message_items"),
# Keep the api_content sidecar so the branch's first turn
# replays the parent's exact wire bytes (warm provider
# prompt cache) instead of a full cold prefill.
"api_content": extract_api_content_sidecar(msg),
"timestamp": msg.get("timestamp"),
}
for msg in history
],
chunk_rows=500,
)
except Exception:
pass # Best-effort copy

# Set title
try:
Expand Down
45 changes: 26 additions & 19 deletions hermes_cli/cli_commands_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1171,25 +1171,32 @@ def _handle_branch_command(self, cmd_original: str) -> None:
_cprint(f" Failed to create branch session: {e}")
return

# Copy conversation history to the new session
for msg in self.conversation_history:
try:
self._session_db.append_message(
session_id=new_session_id,
role=msg.get("role", "user"),
content=msg.get("content"),
tool_name=msg.get("tool_name") or msg.get("name"),
tool_calls=msg.get("tool_calls"),
tool_call_id=msg.get("tool_call_id"),
reasoning=msg.get("reasoning"),
# Keep the api_content sidecar so the branch's first turn
# replays the parent's exact wire bytes (warm provider
# prompt cache) instead of a full cold prefill.
api_content=extract_api_content_sidecar(msg),
timestamp=msg.get("timestamp"),
)
except Exception:
pass # Best-effort copy
# Copy conversation history to the new session in bounded-chunk
# transactions (see #23254) instead of one txn per row. Best-effort
# like the old loop — a failed copy still yields a usable branch.
try:
self._session_db.append_messages_batch(
new_session_id,
[
{
"role": msg.get("role", "user"),
"content": msg.get("content"),
"tool_name": msg.get("tool_name") or msg.get("name"),
"tool_calls": msg.get("tool_calls"),
"tool_call_id": msg.get("tool_call_id"),
"reasoning": msg.get("reasoning"),
# Keep the api_content sidecar so the branch's first turn
# replays the parent's exact wire bytes (warm provider
# prompt cache) instead of a full cold prefill.
"api_content": extract_api_content_sidecar(msg),
"timestamp": msg.get("timestamp"),
}
for msg in self.conversation_history
],
chunk_rows=500,
)
except Exception:
pass # Best-effort copy

# Set title on the branch
try:
Expand Down
131 changes: 109 additions & 22 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -5883,6 +5883,39 @@ def _encode_display_metadata(display_metadata: Any) -> Optional[str]:
)
return None

def _check_transcript_write_guards(
self, conn, session_id: str, compression_lock_holder: Optional[str]
) -> None:
"""Transcript-append admission checks, run INSIDE the write txn.

Shared by :meth:`append_message` and :meth:`append_messages_batch` so
the two writers can never diverge on these correctness invariants
(this guard has already needed targeted fixes — see the #74478
patience note below).
"""
active_lock = conn.execute(
"SELECT holder FROM compression_locks "
"WHERE session_id = ? AND expires_at > ?",
(session_id, time.time()),
).fetchone()
if (
active_lock is not None
and active_lock["holder"] != compression_lock_holder
):
raise SessionCompressionInProgressError(
f"Session {session_id!r} is being compressed by another writer"
)
session = conn.execute(
"SELECT ended_at, end_reason FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if (
session is not None
and session["ended_at"] is not None
and session["end_reason"] == "compression"
):
raise CompressionSessionClosedError(session_id)

@staticmethod
def _decode_display_metadata(raw: Any) -> Optional[Dict[str, Any]]:
"""Decode a ``display_metadata`` column into the dict every reader expects.
Expand Down Expand Up @@ -5995,28 +6028,9 @@ def append_message(
num_tool_calls = len(tool_calls) if isinstance(tool_calls, list) else 1

def _do(conn):
active_lock = conn.execute(
"SELECT holder FROM compression_locks "
"WHERE session_id = ? AND expires_at > ?",
(session_id, time.time()),
).fetchone()
if (
active_lock is not None
and active_lock["holder"] != compression_lock_holder
):
raise SessionCompressionInProgressError(
f"Session {session_id!r} is being compressed by another writer"
)
session = conn.execute(
"SELECT ended_at, end_reason FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
if (
session is not None
and session["ended_at"] is not None
and session["end_reason"] == "compression"
):
raise CompressionSessionClosedError(session_id)
self._check_transcript_write_guards(
conn, session_id, compression_lock_holder
)
cursor = conn.execute(
"""INSERT INTO messages (session_id, role, content, tool_call_id,
tool_calls, tool_name, effect_disposition, timestamp, token_count, finish_reason,
Expand Down Expand Up @@ -6072,6 +6086,79 @@ def _do(conn):
_do, patience_s=self._TRANSCRIPT_WRITE_PATIENCE_S
)

def append_messages_batch(
self,
session_id: str,
messages: List[Dict[str, Any]],
compression_lock_holder: Optional[str] = None,
chunk_rows: Optional[int] = None,
) -> int:
"""Append multiple messages atomically in ONE write transaction.

``messages`` is a list of dicts in the same shape
:meth:`_insert_message_rows` already consumes for replace/compact/
import (role, content, tool_name, tool_calls, tool_call_id,
finish_reason, reasoning*, codex_*, timestamp, api_content,
display_kind, display_metadata, ...). Reusing that helper keeps ONE
row-serialization path for every multi-row writer.

A turn-boundary flush writes the whole turn (user + assistant + tool
rows, typically 3-8 messages) as one BEGIN IMMEDIATE / commit pair
instead of one transaction (and, off WAL, one fsync) per row.

Atomicity contract: all rows land or none do (the caller re-flushes
unstamped messages on the next attempt). The same admission guards
as :meth:`append_message` run once for the batch — same session,
same instant.

``chunk_rows`` bounds the transaction size for LARGE copies (branch
seeds can be thousands of rows; measured: 10k rows ≈ 2.4s inside one
BEGIN IMMEDIATE because the FTS triggers run per row, which would
monopolize the write lock and starve concurrent writers). When set,
the batch commits in chunks of at most that many rows — same
recovery semantics as the old per-row loops (a mid-copy failure
leaves a partial seed), just with bounded lock holds. A turn flush
never needs it. Returns the inserted row count.
"""
if not messages:
return 0

if chunk_rows is not None and len(messages) > chunk_rows:
inserted_total = 0
for start in range(0, len(messages), chunk_rows):
inserted_total += self.append_messages_batch(
session_id,
messages[start:start + chunk_rows],
compression_lock_holder=compression_lock_holder,
)
return inserted_total

def _do(conn):
self._check_transcript_write_guards(
conn, session_id, compression_lock_holder
)
inserted, tool_calls_total = self._insert_message_rows(
conn, session_id, messages
)
# One aggregated counter update for the whole batch.
if tool_calls_total > 0:
conn.execute(
"""UPDATE sessions SET message_count = message_count + ?,
tool_call_count = tool_call_count + ? WHERE id = ?""",
(inserted, tool_calls_total, session_id),
)
else:
conn.execute(
"UPDATE sessions SET message_count = message_count + ? WHERE id = ?",
(inserted, session_id),
)
return inserted

# Same criticality as append_message: this IS the turn's transcript.
return self._execute_write(
_do, patience_s=self._TRANSCRIPT_WRITE_PATIENCE_S
)

def set_latest_matching_message_display_kind(
self, session_id: str, *, role: str, content: str, display_kind: str,
display_metadata: Optional[Dict[str, Any]] = None,
Expand Down
55 changes: 37 additions & 18 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2096,6 +2096,10 @@ def _flush_messages_to_session_db_unlocked(
):
_scan_start += 1

# Collect this flush's new rows and write them in ONE transaction
# at the end of the scan (see append_messages_batch).
_batch_rows: List[Dict[str, Any]] = []
_batch_msgs: List[Dict] = []
for _msg_idx in range(_scan_start, len(messages)):
msg = messages[_msg_idx]
if not isinstance(msg, dict):
Expand Down Expand Up @@ -2214,33 +2218,48 @@ def _flush_messages_to_session_db_unlocked(
]
elif isinstance(msg.get("tool_calls"), list):
tool_calls_data = msg["tool_calls"]
self._session_db.append_message(
session_id=self.session_id,
role=role,
content=content,
tool_name=msg.get("tool_name"),
tool_calls=tool_calls_data,
tool_call_id=msg.get("tool_call_id"),
finish_reason=msg.get("finish_reason"),
reasoning=msg.get("reasoning") if role == "assistant" else None,
reasoning_content=msg.get("reasoning_content") if role == "assistant" else None,
reasoning_details=msg.get("reasoning_details") if role == "assistant" else None,
codex_reasoning_items=msg.get("codex_reasoning_items") if role == "assistant" else None,
codex_message_items=msg.get("codex_message_items") if role == "assistant" else None,
timestamp=_row_timestamp,
api_content=_row_api_content,
display_kind=(
_batch_rows.append({
"role": role,
"content": content,
"tool_name": msg.get("tool_name"),
"tool_calls": tool_calls_data,
"tool_call_id": msg.get("tool_call_id"),
"finish_reason": msg.get("finish_reason"),
# Reasoning/codex fields are role-gated (assistant-only)
# inside _insert_message_rows — pass through untouched.
"reasoning": msg.get("reasoning"),
"reasoning_content": msg.get("reasoning_content"),
"reasoning_details": msg.get("reasoning_details"),
"codex_reasoning_items": msg.get("codex_reasoning_items"),
"codex_message_items": msg.get("codex_message_items"),
"timestamp": _row_timestamp,
"api_content": _row_api_content,
"display_kind": (
"hidden"
if msg.get(COMPRESSED_SUMMARY_METADATA_KEY)
and not msg.get("_compressed_summary_has_user_turn")
else msg.get("display_kind")
),
display_metadata=msg.get("display_metadata"),
"display_metadata": msg.get("display_metadata"),
})
_batch_msgs.append(msg)
# One transaction for the whole turn's new rows (typically 3-8
# messages): one BEGIN IMMEDIATE / commit — and, off WAL, one
# fsync — instead of one per row. All-or-nothing pairs exactly
# with the marker stamping below: on failure NO rows landed and
# NO markers were stamped, so the next flush re-scans and
# re-writes the whole tail (same recovery contract as before,
# minus the partial-prefix case that could double-pay counters).
if _batch_rows:
self._session_db.append_messages_batch(
session_id=self.session_id,
messages=_batch_rows,
compression_lock_holder=getattr(
self, "_active_compression_lock_holder", None
),
)
msg[_DB_PERSISTED_MARKER] = True
for _written in _batch_msgs:
_written[_DB_PERSISTED_MARKER] = True
# The intrinsic markers are now the sole source of truth. Reset the
# one-shot seed so no id() outlives this flush to alias a message
# allocated next turn at a recycled address.
Expand Down
6 changes: 6 additions & 0 deletions tests/agent/test_cursor_optimizations_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ def __init__(self):
self.rows = []
def append_message(self, **kw):
self.rows.append({k: copy.deepcopy(v) for k, v in kw.items()})
def append_messages_batch(self, session_id, messages, **kw):
for m in messages:
row = {k: copy.deepcopy(v) for k, v in m.items()}
row["session_id"] = session_id
self.rows.append(row)
return list(range(1, len(messages) + 1))

def make_agent(bounded):
a = ra.AIAgent.__new__(ra.AIAgent)
Expand Down
5 changes: 3 additions & 2 deletions tests/agent/test_verification_stop_caching.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@ def test_db_flush_drops_only_nudge_keeps_candidate(tmp_path, monkeypatch):
agent._flush_messages_to_session_db(messages, conversation_history=[])

persisted = [
kwargs.get("content")
for _args, kwargs in agent._session_db.append_message.call_args_list
msg.get("content")
for _args, kwargs in agent._session_db.append_messages_batch.call_args_list
for msg in kwargs["messages"]
]
assert "hi" in persisted
assert "verified and clean" in persisted
Expand Down
Loading
Loading