Merge remote-tracking branch 'origin/master' into HEAD
# Conflicts: # CHANGELOG.md # tests/test_workspace_git.py
This commit is contained in:
192
api/models.py
192
api/models.py
@@ -369,12 +369,35 @@ def _message_timestamp(message):
|
||||
return None
|
||||
|
||||
|
||||
def _is_empty_partial_activity_message(message):
|
||||
"""Return True for cancelled/recovered activity rows with no reply text."""
|
||||
if not isinstance(message, dict):
|
||||
return False
|
||||
if message.get('role') != 'assistant' or not message.get('_partial'):
|
||||
return False
|
||||
content = message.get('content', '')
|
||||
if isinstance(content, str):
|
||||
return not content.strip()
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
if part.get('type') == 'text' and str(part.get('text') or part.get('content') or '').strip():
|
||||
return False
|
||||
continue
|
||||
if str(part or '').strip():
|
||||
return False
|
||||
return True
|
||||
return not str(content or '').strip()
|
||||
|
||||
|
||||
def _last_message_timestamp(messages):
|
||||
if not isinstance(messages, list):
|
||||
return None
|
||||
for message in reversed(messages):
|
||||
if isinstance(message, dict) and message.get('role') == 'tool':
|
||||
continue
|
||||
if _is_empty_partial_activity_message(message):
|
||||
continue
|
||||
ts = _message_timestamp(message)
|
||||
if ts:
|
||||
return ts
|
||||
@@ -1177,6 +1200,19 @@ def _run_journal_has_visible_output(session, stream_id: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _run_journal_terminal_state(session, stream_id: str | None) -> str | None:
|
||||
if not stream_id:
|
||||
return None
|
||||
try:
|
||||
from api.run_journal import latest_run_summary
|
||||
summary = latest_run_summary(session.session_id, stream_id)
|
||||
except Exception:
|
||||
return None
|
||||
if not summary.get('terminal'):
|
||||
return None
|
||||
return str(summary.get('terminal_state') or '') or None
|
||||
|
||||
|
||||
def _journal_is_still_arriving(session, stream_id: str | None) -> bool:
|
||||
"""Return True for journals that may become visible on a later read.
|
||||
|
||||
@@ -1691,13 +1727,35 @@ def _apply_core_sync_or_error_marker(
|
||||
_pending_text = " ".join(str(session.pending_user_message or "").split())
|
||||
_already_checkpointed = False
|
||||
if _pending_text and session.messages:
|
||||
_last_msg = session.messages[-1]
|
||||
if isinstance(_last_msg, dict) and _last_msg.get('role') == 'user':
|
||||
_last_text = " ".join(str(_last_msg.get('content') or "").split())
|
||||
_already_checkpointed = _last_text == _pending_text
|
||||
for _last_msg in reversed(session.messages):
|
||||
if isinstance(_last_msg, dict) and _last_msg.get('role') == 'user':
|
||||
_last_text = " ".join(str(_last_msg.get('content') or "").split())
|
||||
_already_checkpointed = _last_text == _pending_text
|
||||
break
|
||||
_recovered_ts = int(time.time())
|
||||
if isinstance(session.pending_started_at, (int, float)) and session.pending_started_at > 0:
|
||||
_recovered_ts = int(session.pending_started_at)
|
||||
_stream_id = stream_id_for_recheck or session.active_stream_id
|
||||
_pending_started_at = session.pending_started_at
|
||||
if _run_journal_terminal_state(session, _stream_id) == 'completed':
|
||||
if not _already_checkpointed:
|
||||
_append_recovered_pending_turn(session, timestamp=_recovered_ts)
|
||||
_append_journaled_partial_output(
|
||||
session,
|
||||
_stream_id,
|
||||
dedupe_existing=True,
|
||||
)
|
||||
session.active_stream_id = None
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = []
|
||||
session.pending_started_at = None
|
||||
session.save(touch_updated_at=touch_updated_at)
|
||||
logger.info(
|
||||
"Session %s: cleared stale pending state for completed stream %s without error marker",
|
||||
sid,
|
||||
_stream_id,
|
||||
)
|
||||
return True
|
||||
if not _already_checkpointed:
|
||||
_append_recovered_pending_turn(session, timestamp=_recovered_ts)
|
||||
else:
|
||||
@@ -1711,10 +1769,8 @@ def _apply_core_sync_or_error_marker(
|
||||
_append_recovered_turn_to_context(session, recovered)
|
||||
recovered_output = _append_journaled_partial_output(
|
||||
session,
|
||||
stream_id_for_recheck or session.active_stream_id,
|
||||
_stream_id,
|
||||
)
|
||||
_stream_id = stream_id_for_recheck or session.active_stream_id
|
||||
_pending_started_at = session.pending_started_at
|
||||
session.active_stream_id = None
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = []
|
||||
@@ -1852,6 +1908,72 @@ def _apply_core_sync_or_error_marker(
|
||||
_REPAIR_STALE_PENDING_GRACE_SECONDS = 30
|
||||
|
||||
|
||||
def _has_compression_continuation(session) -> bool:
|
||||
"""Return True when ``session`` is an archived compression parent.
|
||||
|
||||
Context compression rotates the live WebUI session id: the old sidecar is
|
||||
preserved for lineage while the new child owns the running/completed turn.
|
||||
Stale-pending repair must not append an interruption marker to that old
|
||||
parent just because its stream bookkeeping disappeared after the rotation.
|
||||
"""
|
||||
sid = getattr(session, 'session_id', None)
|
||||
if not sid:
|
||||
return False
|
||||
|
||||
def _row_is_continuation(row) -> bool:
|
||||
if not isinstance(row, dict):
|
||||
return False
|
||||
child_sid = row.get('session_id')
|
||||
if not child_sid or child_sid == sid:
|
||||
return False
|
||||
if row.get('parent_session_id') != sid:
|
||||
return False
|
||||
# Any child row is enough evidence that this pending state belongs to a
|
||||
# compression lineage, not a dead standalone turn. The child may itself
|
||||
# temporarily carry a bad pre_compression_snapshot flag from older code;
|
||||
# do not filter it out here or the guard misses the exact regression.
|
||||
return True
|
||||
|
||||
try:
|
||||
with LOCK:
|
||||
for child in SESSIONS.values():
|
||||
if getattr(child, 'session_id', None) == sid:
|
||||
continue
|
||||
if getattr(child, 'parent_session_id', None) == sid:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
if SESSION_INDEX_FILE.exists():
|
||||
entries = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
|
||||
if isinstance(entries, list) and any(_row_is_continuation(e) for e in entries):
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug("Failed to inspect session index for compression continuation", exc_info=True)
|
||||
|
||||
# Index rows can lag behind rapid compression/save races. Fall back to a
|
||||
# shallow JSON metadata scan; session files write parent_session_id before
|
||||
# the messages array, so this avoids loading multi-MB transcripts.
|
||||
try:
|
||||
needle = f'"parent_session_id": "{sid}"'
|
||||
for path in SESSION_DIR.glob('*.json'):
|
||||
if path.name.startswith('_') or path.stem == sid:
|
||||
continue
|
||||
try:
|
||||
head = path.read_text(encoding='utf-8', errors='ignore')[:4096]
|
||||
except TypeError:
|
||||
head = path.read_text(encoding='utf-8')[:4096]
|
||||
except OSError:
|
||||
continue
|
||||
if needle in head:
|
||||
return True
|
||||
except Exception:
|
||||
logger.debug("Failed to scan session files for compression continuation", exc_info=True)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _repair_stale_pending(session) -> bool:
|
||||
"""Recover a sidecar stuck with messages=[] and stale pending state.
|
||||
|
||||
@@ -1874,6 +1996,18 @@ def _repair_stale_pending(session) -> bool:
|
||||
or not _seen_stream_id
|
||||
or _seen_stream_id in _active_stream_ids()):
|
||||
return False
|
||||
if getattr(session, 'pre_compression_snapshot', False):
|
||||
logger.debug(
|
||||
"_repair_stale_pending: skipping pre-compression snapshot %s",
|
||||
getattr(session, 'session_id', '?'),
|
||||
)
|
||||
return False
|
||||
if _has_compression_continuation(session):
|
||||
logger.debug(
|
||||
"_repair_stale_pending: skipping compression parent %s with continuation",
|
||||
getattr(session, 'session_id', '?'),
|
||||
)
|
||||
return False
|
||||
|
||||
# Grace-period guard: bail if the turn is too fresh to be a real crash.
|
||||
# Falsy pending_started_at (None, 0, missing) means "old enough" — preserve
|
||||
@@ -2230,6 +2364,38 @@ def _is_intentionally_background_sidebar_session(session: dict) -> bool:
|
||||
return source == 'cron' or sid.startswith('cron_')
|
||||
|
||||
|
||||
def _include_project_hidden_background_sidebar_sessions(
|
||||
candidates: list[dict],
|
||||
visible: list[dict],
|
||||
) -> list[dict]:
|
||||
"""Keep project-assigned background sessions addressable by project chips.
|
||||
|
||||
Cron sessions stay hidden from the default sidebar, but if they have a
|
||||
project assignment they must still be present in the client cache so the
|
||||
dedicated project chip can reveal them (#3019).
|
||||
"""
|
||||
visible_ids = {
|
||||
str(session.get('session_id'))
|
||||
for session in visible
|
||||
if session.get('session_id')
|
||||
}
|
||||
out = list(visible)
|
||||
for session in candidates:
|
||||
sid = str(session.get('session_id') or '')
|
||||
if not sid or sid in visible_ids:
|
||||
continue
|
||||
if not _is_intentionally_background_sidebar_session(session):
|
||||
continue
|
||||
if not session.get('project_id'):
|
||||
continue
|
||||
if _sidebar_message_count(session) <= 0:
|
||||
continue
|
||||
row = dict(session)
|
||||
row['default_hidden'] = True
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
def _preserve_messageful_sidebar_discoverability(
|
||||
candidates: list[dict],
|
||||
visible: list[dict],
|
||||
@@ -2505,8 +2671,10 @@ def all_sessions(diag=None):
|
||||
and not s.get('worktree_path')
|
||||
)]
|
||||
result = _prefer_fuller_snapshots_for_sidebar(result)
|
||||
visible_result = [s for s in result if not _hide_from_default_sidebar(s)]
|
||||
result = _preserve_messageful_sidebar_discoverability(result, visible_result)
|
||||
sidebar_candidates = result
|
||||
visible_result = [s for s in sidebar_candidates if not _hide_from_default_sidebar(s)]
|
||||
result = _preserve_messageful_sidebar_discoverability(sidebar_candidates, visible_result)
|
||||
result = _include_project_hidden_background_sidebar_sessions(sidebar_candidates, result)
|
||||
_strip_sidebar_internal_flags(result)
|
||||
# Backfill: sessions created before Sprint 22 have no profile tag.
|
||||
# Attribute them to 'default' so the client profile filter works correctly.
|
||||
@@ -2544,8 +2712,10 @@ def all_sessions(diag=None):
|
||||
and not getattr(s, 'worktree_path', None)
|
||||
)]
|
||||
result = _prefer_fuller_snapshots_for_sidebar(result)
|
||||
visible_result = [s for s in result if not _hide_from_default_sidebar(s)]
|
||||
result = _preserve_messageful_sidebar_discoverability(result, visible_result)
|
||||
sidebar_candidates = result
|
||||
visible_result = [s for s in sidebar_candidates if not _hide_from_default_sidebar(s)]
|
||||
result = _preserve_messageful_sidebar_discoverability(sidebar_candidates, visible_result)
|
||||
result = _include_project_hidden_background_sidebar_sessions(sidebar_candidates, result)
|
||||
_strip_sidebar_internal_flags(result)
|
||||
for s in result:
|
||||
if not s.get('profile'):
|
||||
|
||||
Reference in New Issue
Block a user