Merge PR #7578 into stage/7578-save-count

This commit is contained in:
nesquena-hermes committed 2026-09-23 18:23:31 +00:00
commit 350e30ab26
4 files changed
+553 -8

No files matched your search

+114 -7
View File
@@ -1107,6 +1107,14 @@ _METADATA_PREFIX_MAX_BYTES = 1024 * 1024
#: even when the stop key sits at 2 KB.
_METADATA_PREFIX_FIRST_STAGE_BYTES = 64 * 1024
# Marks a sidecar as written by the CURRENT writer contract, where the
# persisted `message_count` equals len(messages) by construction (both keys
# land in the same atomic write). save()'s bounded-prefix shrink check trusts
# a persisted count ONLY when this marker sits next to it in the file; see
# _prefix_message_count(). Bump the value whenever the writer contract changes
# meaning, so counts from an older contract fall back to the full parse.
_MESSAGE_COUNT_MARKER = 1
def _read_metadata_json_prefix(path, max_prefix_bytes=_METADATA_PREFIX_MAX_BYTES):
"""Read only the metadata portion before the large arrays.
@@ -1505,6 +1513,16 @@ class Session:
# legacy-format reader that stops at a scene key still finds the count.
# The full anchor_activity_scenes bodies serialize AFTER messages.
meta['message_count'] = len(self.messages or [])
# _mc_v marks this file as written by the current writer contract,
# where `message_count` equals len(messages) by construction and both
# keys land in the same atomic write. save()'s shrink guard takes the
# bounded-prefix shortcut ONLY for marked files; an unmarked count
# (a legacy pre-#5854 sidecar, a sidecar materialized by an older
# recovery writer, any foreign writer) gets the full parse, so a stale
# count from outside this contract can never read a real shrink as a
# growth and skip the #1558 backup. One save re-marks the file, so the
# fast path still covers steady state.
meta['_mc_v'] = _MESSAGE_COUNT_MARKER
meta['anchor_scene_index'] = _anchor_scene_index_from_records(self.anchor_activity_scenes)
# Keep the in-memory fingerprint aligned with what we just persisted, so a
# later metadata-only reload of THIS object (or any fingerprint reader)
@@ -1517,7 +1535,7 @@ class Session:
meta['anchor_activity_scenes'] = self.anchor_activity_scenes if isinstance(self.anchor_activity_scenes, dict) else {}
# Fields not in METADATA_FIELDS (e.g. last_usage) go at the end. Exclude
# the keys we placed explicitly above so they aren't emitted twice.
_placed = {'message_count', 'anchor_scene_index', 'messages', 'tool_calls', 'anchor_activity_scenes'}
_placed = {'message_count', '_mc_v', 'anchor_scene_index', 'messages', 'tool_calls', 'anchor_activity_scenes'}
extra = {k: v for k, v in self.__dict__.items()
if k not in METADATA_FIELDS and k not in _placed
and not k.startswith('_')}
@@ -1537,12 +1555,46 @@ class Session:
# their .bak get restored automatically.
try:
if self.path.exists():
existing_text = self.path.read_text(encoding='utf-8')
try:
existing = json.loads(existing_text)
existing_msg_count = len(existing.get('messages') or [])
except (json.JSONDecodeError, ValueError):
existing_msg_count = -1 # corrupt → always back up
# The on-disk count, without reading the body.
#
# The decision below is a function of ONE integer -- how many
# messages the file on disk holds -- and save() already writes
# that integer into the metadata prefix, before `messages`, as
# `message_count` (see METADATA_FIELDS above; load_metadata_only
# and the sidebar freshness check read it the same way). So read
# THAT through a bounded 64 KiB prefix instead of the whole file.
# Measured before this: a 203,439,398-byte sidecar cost 20,377 ms
# (17,453 in read_text, 2,924 in json.loads) to yield one integer,
# on EVERY save -- including the grow-saves that never back
# anything up. The prefix read is O(64 KiB), and because the count
# is part of the bytes on disk it travels with any rewrite of them.
#
# An in-memory "I wrote this, stat says nothing changed" cache is
# NOT sufficient here, and was removed after review: (inode, size,
# mtime_ns) is not a content identity. A same-length in-place
# rewrite inside one mtime tick keeps all three fields -- ext4
# stamps mtime from a coarse clock, so two writes in the same tick
# share one mtime_ns -- and a stale cached count then reads a real
# shrink as a growth and skips the #1558 backup. The prefix count
# cannot be fooled that way.
#
# Every unknown falls through to the full read + parse below: a
# legacy (pre-#5854) sidecar whose count is not in the prefix, a
# count written without the current writer's _mc_v marker (an
# older writer's count can be stale relative to the messages
# array next to it), a corrupt or truncated prefix, a file with
# no top-level `messages` key at all, or metadata alone that
# overflows the budget.
# Fail-open is the contract -- never "assume no shrink".
existing_text = None
existing_msg_count = _prefix_message_count(self.path)
if existing_msg_count is None:
existing_text = self.path.read_text(encoding='utf-8')
try:
existing = json.loads(existing_text)
existing_msg_count = len(existing.get('messages') or [])
except (json.JSONDecodeError, ValueError):
existing_msg_count = -1 # corrupt → always back up
incoming_msg_count = len(self.messages or [])
if (
existing_msg_count > 0
@@ -1560,6 +1612,10 @@ class Session:
return
if existing_msg_count > incoming_msg_count:
bak_path = self.path.with_suffix('.json.bak')
if existing_text is None:
# The .bak body is the one thing that needs the full text,
# and a shrink is the one time it is needed.
existing_text = self.path.read_text(encoding='utf-8')
# SHOULD-FIX #2 (Opus): atomic write via tmp+replace,
# mirroring the main save() pattern below. Prevents a
# torn .bak from a crash mid-write or a concurrent
@@ -4239,6 +4295,57 @@ def _sidecar_stat_signature(path):
int(st.st_size), int(getattr(st, 'st_ctime_ns', int(st.st_ctime * 1_000_000_000))))
_MESSAGE_COUNT_MARKER = 1
def _prefix_message_count(path):
"""The sidecar's own persisted ``message_count``, from a bounded prefix.
The #1558 shrink check in save() needs exactly one integer -- how many
messages the file on disk holds -- and the writer puts that integer in the
metadata prefix, before ``messages``, precisely so readers can have it
without parsing the body (see METADATA_FIELDS). Reading the prefix costs
O(64 KiB) against O(file size) for ``read_text`` + ``json.loads`` (measured
2026-09-15: 20,377 ms for one integer on a 203,439,398-byte sidecar).
Content-derived on purpose. A stat identity cannot stand in for it: an
in-place rewrite of the same length inside one mtime tick keeps inode, size
*and* mtime_ns, so a cached count would read a real shrink as a growth and
skip the backup. The count is part of the bytes, so any rewrite of them
rewrites it too.
Gated on the writer marker (``_mc_v``, review 2026-09-22): a count is only
trusted when the SAME write that produced the messages array also vouched
for the count. Anything unmarked -- a legacy pre-#5854 sidecar, a sidecar
materialized by an older recovery writer whose denormalized count could be
stale against its rows, any foreign writer -- returns None and the caller
falls back to the full read + parse. After one save by the current writer
the file carries the marker and the fast path resumes.
Returns None -- meaning "the caller must fall back to the full read +
parse" -- for every shape that does not carry a usable MARKED count: an
unmarked or wrong-marker file, a legacy sidecar whose count is not in the
prefix, a file with no top-level ``messages`` key at all, a corrupt or
truncated prefix, an unreadable path, or metadata alone that overflows the
budget.
"""
try:
raw = _read_metadata_json_prefix(path)
except (OSError, ValueError):
return None
if not raw:
return None
try:
parsed = json.loads(raw)
except (json.JSONDecodeError, ValueError):
return None
if not isinstance(parsed, dict):
return None
if parsed.get('_mc_v') != _MESSAGE_COUNT_MARKER:
return None
return _parse_nonnegative_int(parsed.get('message_count'))
def _legacy_sidecar_facts_get(sid):
"""Return cached authoritative facts for a LEGACY sidecar, or None (#5854).
+8 -1
View File
@@ -601,7 +601,14 @@ def _state_db_row_to_sidecar(row: dict) -> dict:
'session_id': row.get('id'),
'title': row.get('title') or 'Recovered WebUI Session',
'workspace': workspace_value if isinstance(workspace_value, str) else '',
'message_count': row.get('message_count') if isinstance(row.get('message_count'), int) else len(messages),
# state.db is canonical for the recovered message rows. Its denormalized
# sessions.message_count is absent from older schemas (read above as 0)
# and can be stale after recovery, so never copy it into a sidecar whose
# `messages` array we just materialized. Session.save() uses this field
# to decide whether an overwrite shrinks the transcript; it must match
# the payload exactly or the #1558 backup / empty-snapshot guards can be
# bypassed.
'message_count': len(messages if isinstance(messages, list) else []),
'worktree_path': row.get('worktree_path') or None,
'worktree_branch': row.get('worktree_branch') or None,
'worktree_repo_root': row.get('worktree_repo_root') or None,