Merge PR #7578 into stage/7578-save-count
This commit is contained in:
commit
350e30ab26
4 files changed
+553
-8
No files matched your search
+114
-7
@@ -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).
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user