LastNoob commited on
Commit
df85ebc
·
1 Parent(s): abd3dad

Optimized code in hot paths with z-ai/glm5

Browse files
api/app.py CHANGED
@@ -145,6 +145,11 @@ async def lifespan(app: FastAPI):
145
  yield
146
 
147
  # Cleanup
 
 
 
 
 
148
  logger.info("Shutdown requested, cleaning up...")
149
  if messaging_platform:
150
  await _best_effort("messaging_platform.stop", messaging_platform.stop())
 
145
  yield
146
 
147
  # Cleanup
148
+ if message_handler and hasattr(message_handler, "session_store"):
149
+ try:
150
+ message_handler.session_store.flush_pending_save()
151
+ except Exception as e:
152
+ logger.warning(f"Session store flush on shutdown: {e}")
153
  logger.info("Shutdown requested, cleaning up...")
154
  if messaging_platform:
155
  await _best_effort("messaging_platform.stop", messaging_platform.stop())
api/optimization_handlers.py CHANGED
@@ -126,9 +126,10 @@ def try_filepath_mock(
126
  )
127
 
128
 
 
129
  OPTIMIZATION_HANDLERS = [
130
- try_prefix_detection,
131
  try_quota_mock,
 
132
  try_title_skip,
133
  try_suggestion_skip,
134
  try_filepath_mock,
 
126
  )
127
 
128
 
129
+ # Cheapest/most common optimizations first for faster short-circuit.
130
  OPTIMIZATION_HANDLERS = [
 
131
  try_quota_mock,
132
+ try_prefix_detection,
133
  try_title_skip,
134
  try_suggestion_skip,
135
  try_filepath_mock,
api/request_utils.py CHANGED
@@ -5,7 +5,7 @@ Contains token counting for API requests.
5
 
6
  import json
7
  import logging
8
- from typing import List, Optional, Union
9
 
10
  import tiktoken
11
 
@@ -15,6 +15,13 @@ ENCODER = tiktoken.get_encoding("cl100k_base")
15
  __all__ = ["get_token_count"]
16
 
17
 
 
 
 
 
 
 
 
18
  def get_token_count(
19
  messages: List,
20
  system: Optional[Union[str, List]] = None,
@@ -32,13 +39,9 @@ def get_token_count(
32
  total_tokens += len(ENCODER.encode(system))
33
  elif isinstance(system, list):
34
  for block in system:
35
- text = (
36
- getattr(block, "text", None)
37
- if hasattr(block, "text")
38
- else (block.get("text", "") if isinstance(block, dict) else "")
39
- )
40
  if text:
41
- total_tokens += len(ENCODER.encode(text))
42
  total_tokens += 4 # System block formatting overhead
43
 
44
  for msg in messages:
@@ -46,38 +49,24 @@ def get_token_count(
46
  total_tokens += len(ENCODER.encode(msg.content))
47
  elif isinstance(msg.content, list):
48
  for block in msg.content:
49
- b_type = getattr(block, "type", None) or (
50
- block.get("type") if isinstance(block, dict) else None
51
- )
52
 
53
  if b_type == "text":
54
- text = getattr(block, "text", "") or (
55
- block.get("text", "") if isinstance(block, dict) else ""
56
- )
57
- total_tokens += len(ENCODER.encode(text))
58
  elif b_type == "thinking":
59
- thinking = getattr(block, "thinking", "") or (
60
- block.get("thinking", "") if isinstance(block, dict) else ""
61
- )
62
- total_tokens += len(ENCODER.encode(thinking))
63
  elif b_type == "tool_use":
64
- name = getattr(block, "name", "") or (
65
- block.get("name", "") if isinstance(block, dict) else ""
66
- )
67
- inp = getattr(block, "input", {}) or (
68
- block.get("input", {}) if isinstance(block, dict) else {}
69
- )
70
- block_id = getattr(block, "id", "") or (
71
- block.get("id", "") if isinstance(block, dict) else ""
72
- )
73
- total_tokens += len(ENCODER.encode(name))
74
  total_tokens += len(ENCODER.encode(json.dumps(inp)))
75
  total_tokens += len(ENCODER.encode(str(block_id)))
76
  total_tokens += 15
77
  elif b_type == "image":
78
- source = getattr(block, "source", None) or (
79
- block.get("source", {}) if isinstance(block, dict) else {}
80
- )
81
  if isinstance(source, dict):
82
  data = source.get("data") or source.get("base64") or ""
83
  if data:
@@ -87,12 +76,8 @@ def get_token_count(
87
  else:
88
  total_tokens += 765
89
  elif b_type == "tool_result":
90
- content = getattr(block, "content", "") or (
91
- block.get("content", "") if isinstance(block, dict) else ""
92
- )
93
- tool_use_id = getattr(block, "tool_use_id", "") or (
94
- block.get("tool_use_id", "") if isinstance(block, dict) else ""
95
- )
96
  if isinstance(content, str):
97
  total_tokens += len(ENCODER.encode(content))
98
  else:
@@ -102,7 +87,7 @@ def get_token_count(
102
  else:
103
  try:
104
  total_tokens += len(ENCODER.encode(json.dumps(block)))
105
- except (TypeError, ValueError):
106
  total_tokens += len(ENCODER.encode(str(block)))
107
 
108
  if tools:
 
5
 
6
  import json
7
  import logging
8
+ from typing import Any, List, Optional, Union
9
 
10
  import tiktoken
11
 
 
15
  __all__ = ["get_token_count"]
16
 
17
 
18
+ def _get_block_attr(block: object, key: str, default: Any = "") -> Any:
19
+ """Get attribute from block (object or dict)."""
20
+ if isinstance(block, dict):
21
+ return block.get(key, default) # type: ignore[no-matching-overload]
22
+ return getattr(block, key, default)
23
+
24
+
25
  def get_token_count(
26
  messages: List,
27
  system: Optional[Union[str, List]] = None,
 
39
  total_tokens += len(ENCODER.encode(system))
40
  elif isinstance(system, list):
41
  for block in system:
42
+ text = _get_block_attr(block, "text", "")
 
 
 
 
43
  if text:
44
+ total_tokens += len(ENCODER.encode(str(text)))
45
  total_tokens += 4 # System block formatting overhead
46
 
47
  for msg in messages:
 
49
  total_tokens += len(ENCODER.encode(msg.content))
50
  elif isinstance(msg.content, list):
51
  for block in msg.content:
52
+ b_type = _get_block_attr(block, "type") or None
 
 
53
 
54
  if b_type == "text":
55
+ text = _get_block_attr(block, "text", "")
56
+ total_tokens += len(ENCODER.encode(str(text)))
 
 
57
  elif b_type == "thinking":
58
+ thinking = _get_block_attr(block, "thinking", "")
59
+ total_tokens += len(ENCODER.encode(str(thinking)))
 
 
60
  elif b_type == "tool_use":
61
+ name = _get_block_attr(block, "name", "")
62
+ inp = _get_block_attr(block, "input", {})
63
+ block_id = _get_block_attr(block, "id", "")
64
+ total_tokens += len(ENCODER.encode(str(name)))
 
 
 
 
 
 
65
  total_tokens += len(ENCODER.encode(json.dumps(inp)))
66
  total_tokens += len(ENCODER.encode(str(block_id)))
67
  total_tokens += 15
68
  elif b_type == "image":
69
+ source = _get_block_attr(block, "source")
 
 
70
  if isinstance(source, dict):
71
  data = source.get("data") or source.get("base64") or ""
72
  if data:
 
76
  else:
77
  total_tokens += 765
78
  elif b_type == "tool_result":
79
+ content = _get_block_attr(block, "content", "")
80
+ tool_use_id = _get_block_attr(block, "tool_use_id", "")
 
 
 
 
81
  if isinstance(content, str):
82
  total_tokens += len(ENCODER.encode(content))
83
  else:
 
87
  else:
88
  try:
89
  total_tokens += len(ENCODER.encode(json.dumps(block)))
90
+ except TypeError, ValueError:
91
  total_tokens += len(ENCODER.encode(str(block)))
92
 
93
  if tools:
messaging/handler.py CHANGED
@@ -32,41 +32,45 @@ logger = logging.getLogger(__name__)
32
  # Status message prefixes used to filter our own messages (ignore echo)
33
  STATUS_MESSAGE_PREFIXES = ("⏳", "💭", "🔧", "✅", "❌", "🚀", "🤖", "📋", "📊", "🔄")
34
 
35
- # Event types that update the transcript
36
- TRANSCRIPT_EVENT_TYPES = (
37
- "thinking_start",
38
- "thinking_delta",
39
- "thinking_chunk",
40
- "thinking_stop",
41
- "text_start",
42
- "text_delta",
43
- "text_chunk",
44
- "text_stop",
45
- "tool_use_start",
46
- "tool_use_delta",
47
- "tool_use_stop",
48
- "tool_use",
49
- "tool_result",
50
- "block_stop",
51
- "error",
 
 
52
  )
53
 
54
- # Event types -> (emoji, label) for status updates
55
  _EVENT_STATUS_MAP = {
56
- ("thinking_start", "thinking_delta", "thinking_chunk"): (
57
- "🧠",
58
- "Claude is thinking...",
59
- ),
60
- ("text_start", "text_delta", "text_chunk"): ("🧠", "Claude is working..."),
61
- ("tool_result",): ("", "Executing tools..."),
 
62
  }
63
 
64
 
65
  def _get_status_for_event(ptype: str, parsed: dict) -> Optional[str]:
66
  """Return status string for event type, or None if no status update needed."""
67
- for types, (emoji, label) in _EVENT_STATUS_MAP.items():
68
- if ptype in types:
69
- return format_status(emoji, label)
 
70
  if ptype in ("tool_use_start", "tool_use_delta", "tool_use"):
71
  if parsed.get("name") == "Task":
72
  return format_status("🤖", "Subagent working...")
@@ -636,6 +640,7 @@ class ClaudeMessageHandler:
636
 
637
  def _update_cancelled_nodes_ui(self, nodes: List[MessageNode]) -> None:
638
  """Update status messages and persist tree state for cancelled nodes."""
 
639
  for node in nodes:
640
  self.platform.fire_and_forget(
641
  self.platform.queue_edit_message(
@@ -647,7 +652,9 @@ class ClaudeMessageHandler:
647
  )
648
  tree = self.tree_queue.get_tree_for_node(node.node_id)
649
  if tree:
650
- self.session_store.save_tree(tree.root_id, tree.to_dict())
 
 
651
 
652
  async def _handle_stop_command(self, incoming: IncomingMessage) -> None:
653
  """Handle /stop command from messaging platform."""
 
32
  # Status message prefixes used to filter our own messages (ignore echo)
33
  STATUS_MESSAGE_PREFIXES = ("⏳", "💭", "🔧", "✅", "❌", "🚀", "🤖", "📋", "📊", "🔄")
34
 
35
+ # Event types that update the transcript (frozenset for O(1) membership)
36
+ TRANSCRIPT_EVENT_TYPES = frozenset(
37
+ {
38
+ "thinking_start",
39
+ "thinking_delta",
40
+ "thinking_chunk",
41
+ "thinking_stop",
42
+ "text_start",
43
+ "text_delta",
44
+ "text_chunk",
45
+ "text_stop",
46
+ "tool_use_start",
47
+ "tool_use_delta",
48
+ "tool_use_stop",
49
+ "tool_use",
50
+ "tool_result",
51
+ "block_stop",
52
+ "error",
53
+ }
54
  )
55
 
56
+ # Event type -> (emoji, label) for status updates (O(1) lookup)
57
  _EVENT_STATUS_MAP = {
58
+ "thinking_start": ("🧠", "Claude is thinking..."),
59
+ "thinking_delta": ("🧠", "Claude is thinking..."),
60
+ "thinking_chunk": ("🧠", "Claude is thinking..."),
61
+ "text_start": ("🧠", "Claude is working..."),
62
+ "text_delta": ("🧠", "Claude is working..."),
63
+ "text_chunk": ("🧠", "Claude is working..."),
64
+ "tool_result": ("⏳", "Executing tools..."),
65
  }
66
 
67
 
68
  def _get_status_for_event(ptype: str, parsed: dict) -> Optional[str]:
69
  """Return status string for event type, or None if no status update needed."""
70
+ entry = _EVENT_STATUS_MAP.get(ptype)
71
+ if entry is not None:
72
+ emoji, label = entry
73
+ return format_status(emoji, label)
74
  if ptype in ("tool_use_start", "tool_use_delta", "tool_use"):
75
  if parsed.get("name") == "Task":
76
  return format_status("🤖", "Subagent working...")
 
640
 
641
  def _update_cancelled_nodes_ui(self, nodes: List[MessageNode]) -> None:
642
  """Update status messages and persist tree state for cancelled nodes."""
643
+ trees_to_save: dict[str, MessageTree] = {}
644
  for node in nodes:
645
  self.platform.fire_and_forget(
646
  self.platform.queue_edit_message(
 
652
  )
653
  tree = self.tree_queue.get_tree_for_node(node.node_id)
654
  if tree:
655
+ trees_to_save[tree.root_id] = tree
656
+ for root_id, tree in trees_to_save.items():
657
+ self.session_store.save_tree(root_id, tree.to_dict())
658
 
659
  async def _handle_stop_command(self, incoming: IncomingMessage) -> None:
660
  """Handle /stop command from messaging platform."""
messaging/limiter.py CHANGED
@@ -79,8 +79,6 @@ class MessagingRateLimiter:
79
  _lock = asyncio.Lock()
80
 
81
  def __new__(cls, *args, **kwargs):
82
- if not cls._instance:
83
- pass
84
  return super(MessagingRateLimiter, cls).__new__(cls)
85
 
86
  @classmethod
 
79
  _lock = asyncio.Lock()
80
 
81
  def __new__(cls, *args, **kwargs):
 
 
82
  return super(MessagingRateLimiter, cls).__new__(cls)
83
 
84
  @classmethod
messaging/session.py CHANGED
@@ -50,6 +50,9 @@ class SessionStore:
50
  # Key: "{platform}:{chat_id}" -> list of records
51
  self._message_log: Dict[str, List[Dict[str, Any]]] = {}
52
  self._message_log_ids: Dict[str, set[str]] = {}
 
 
 
53
  self._load()
54
 
55
  def _make_key(self, platform: str, chat_id: str, msg_id: str) -> str:
@@ -130,7 +133,7 @@ class SessionStore:
130
  logger.error(f"Failed to load sessions: {e}")
131
 
132
  def _save(self) -> None:
133
- """Persist sessions and trees to disk."""
134
  try:
135
  data = {
136
  "sessions": {
@@ -145,6 +148,41 @@ class SessionStore:
145
  except Exception as e:
146
  logger.error(f"Failed to save sessions: {e}")
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  def record_message_id(
149
  self,
150
  platform: str,
@@ -192,7 +230,7 @@ class SessionStore:
192
  except Exception:
193
  pass
194
 
195
- self._save()
196
 
197
  def get_message_ids_for_chat(self, platform: str, chat_id: str) -> List[str]:
198
  """Get all recorded message IDs for a chat (in insertion order)."""
@@ -214,7 +252,7 @@ class SessionStore:
214
  self._node_to_tree.clear()
215
  self._message_log.clear()
216
  self._message_log_ids.clear()
217
- self._save()
218
 
219
  # ==================== Tree Methods ====================
220
 
@@ -233,7 +271,7 @@ class SessionStore:
233
  for node_id in tree_data.get("nodes", {}).keys():
234
  self._node_to_tree[node_id] = root_id
235
 
236
- self._save()
237
  logger.debug(f"Saved tree {root_id}")
238
 
239
  def get_tree(self, root_id: str) -> Optional[dict]:
@@ -250,7 +288,7 @@ class SessionStore:
250
  """Register a node ID to a tree root."""
251
  with self._lock:
252
  self._node_to_tree[node_id] = root_id
253
- self._save()
254
 
255
  def get_all_trees(self) -> Dict[str, dict]:
256
  """Get all stored trees (public accessor)."""
@@ -269,7 +307,7 @@ class SessionStore:
269
  with self._lock:
270
  self._trees = trees
271
  self._node_to_tree = node_to_tree
272
- self._save()
273
 
274
  def cleanup_old_trees(self, max_age_days: int = 30) -> int:
275
  """Remove trees older than max_age_days."""
@@ -299,7 +337,7 @@ class SessionStore:
299
  removed += 1
300
 
301
  if removed:
302
- self._save()
303
  logger.info(f"Cleaned up {removed} old trees")
304
 
305
  return removed
 
50
  # Key: "{platform}:{chat_id}" -> list of records
51
  self._message_log: Dict[str, List[Dict[str, Any]]] = {}
52
  self._message_log_ids: Dict[str, set[str]] = {}
53
+ self._dirty = False
54
+ self._save_timer: Optional[threading.Timer] = None
55
+ self._save_debounce_secs = 0.5
56
  self._load()
57
 
58
  def _make_key(self, platform: str, chat_id: str, msg_id: str) -> str:
 
133
  logger.error(f"Failed to load sessions: {e}")
134
 
135
  def _save(self) -> None:
136
+ """Persist sessions and trees to disk. Caller must hold self._lock."""
137
  try:
138
  data = {
139
  "sessions": {
 
148
  except Exception as e:
149
  logger.error(f"Failed to save sessions: {e}")
150
 
151
+ def _schedule_save(self) -> None:
152
+ """Schedule a debounced save. Caller must hold self._lock."""
153
+ self._dirty = True
154
+ if self._save_timer is not None:
155
+ self._save_timer.cancel()
156
+ self._save_timer = None
157
+ self._save_timer = threading.Timer(
158
+ self._save_debounce_secs, self._save_from_timer
159
+ )
160
+ self._save_timer.daemon = True
161
+ self._save_timer.start()
162
+
163
+ def _save_from_timer(self) -> None:
164
+ """Timer callback: save if dirty. Runs in timer thread."""
165
+ with self._lock:
166
+ if not self._dirty:
167
+ self._save_timer = None
168
+ return
169
+ self._save()
170
+ self._dirty = False
171
+ self._save_timer = None
172
+
173
+ def _flush_save(self) -> None:
174
+ """Immediate save, cancel any pending debounced save. Caller must hold self._lock."""
175
+ if self._save_timer is not None:
176
+ self._save_timer.cancel()
177
+ self._save_timer = None
178
+ self._dirty = False
179
+ self._save()
180
+
181
+ def flush_pending_save(self) -> None:
182
+ """Flush any pending debounced save. Call on shutdown to avoid losing data."""
183
+ with self._lock:
184
+ self._flush_save()
185
+
186
  def record_message_id(
187
  self,
188
  platform: str,
 
230
  except Exception:
231
  pass
232
 
233
+ self._schedule_save()
234
 
235
  def get_message_ids_for_chat(self, platform: str, chat_id: str) -> List[str]:
236
  """Get all recorded message IDs for a chat (in insertion order)."""
 
252
  self._node_to_tree.clear()
253
  self._message_log.clear()
254
  self._message_log_ids.clear()
255
+ self._flush_save()
256
 
257
  # ==================== Tree Methods ====================
258
 
 
271
  for node_id in tree_data.get("nodes", {}).keys():
272
  self._node_to_tree[node_id] = root_id
273
 
274
+ self._schedule_save()
275
  logger.debug(f"Saved tree {root_id}")
276
 
277
  def get_tree(self, root_id: str) -> Optional[dict]:
 
288
  """Register a node ID to a tree root."""
289
  with self._lock:
290
  self._node_to_tree[node_id] = root_id
291
+ self._schedule_save()
292
 
293
  def get_all_trees(self) -> Dict[str, dict]:
294
  """Get all stored trees (public accessor)."""
 
307
  with self._lock:
308
  self._trees = trees
309
  self._node_to_tree = node_to_tree
310
+ self._schedule_save()
311
 
312
  def cleanup_old_trees(self, max_age_days: int = 30) -> int:
313
  """Remove trees older than max_age_days."""
 
337
  removed += 1
338
 
339
  if removed:
340
+ self._schedule_save()
341
  logger.info(f"Cleaned up {removed} old trees")
342
 
343
  return removed
messaging/transcript.py CHANGED
@@ -11,8 +11,9 @@ from __future__ import annotations
11
  import json
12
  import logging
13
  import os
 
14
  from dataclasses import dataclass, field
15
- from typing import Any, Callable, Dict, List, Optional
16
 
17
 
18
  logger = logging.getLogger(__name__)
@@ -299,6 +300,18 @@ class TranscriptBuffer:
299
  if not self._subagent_stack:
300
  return
301
  if tool_id:
 
 
 
 
 
 
 
 
 
 
 
 
302
  # Pop to the matching id (defensive against non-LIFO emissions).
303
  try:
304
  idx = (
@@ -545,7 +558,7 @@ class TranscriptBuffer:
545
  status_text = f"\n\n{status}" if status else ""
546
  prefix_marker = ctx.escape_text("... (truncated)\n")
547
 
548
- def _join(parts: List[str], add_marker: bool) -> str:
549
  body = "\n".join(parts)
550
  if add_marker and body:
551
  body = prefix_marker + body
@@ -557,13 +570,14 @@ class TranscriptBuffer:
557
  return candidate
558
 
559
  # Drop oldest segments until under limit (keep the tail).
560
- parts = list(rendered)
 
561
  dropped = False
562
  while parts:
563
  candidate = _join(parts, add_marker=True)
564
  if len(candidate) <= limit_chars:
565
  return candidate
566
- parts.pop(0)
567
  dropped = True
568
 
569
  # Nothing fits; return status only with marker if possible.
 
11
  import json
12
  import logging
13
  import os
14
+ from collections import deque
15
  from dataclasses import dataclass, field
16
+ from typing import Any, Callable, Dict, Iterable, List, Optional
17
 
18
 
19
  logger = logging.getLogger(__name__)
 
300
  if not self._subagent_stack:
301
  return
302
  if tool_id:
303
+ # O(1) common case: LIFO - top of stack matches.
304
+ if self._subagent_stack[-1] == tool_id:
305
+ self._subagent_stack.pop()
306
+ if self._subagent_segments:
307
+ self._subagent_segments.pop()
308
+ if self._debug_subagent_stack:
309
+ logger.debug(
310
+ "SUBAGENT_STACK: pop id=%r depth=%d (LIFO)",
311
+ tool_id,
312
+ len(self._subagent_stack),
313
+ )
314
+ return
315
  # Pop to the matching id (defensive against non-LIFO emissions).
316
  try:
317
  idx = (
 
558
  status_text = f"\n\n{status}" if status else ""
559
  prefix_marker = ctx.escape_text("... (truncated)\n")
560
 
561
+ def _join(parts: Iterable[str], add_marker: bool) -> str:
562
  body = "\n".join(parts)
563
  if add_marker and body:
564
  body = prefix_marker + body
 
570
  return candidate
571
 
572
  # Drop oldest segments until under limit (keep the tail).
573
+ # Use deque for O(1) popleft; list.pop(0) would be O(n) per iteration.
574
+ parts: deque[str] = deque(rendered)
575
  dropped = False
576
  while parts:
577
  candidate = _join(parts, add_marker=True)
578
  if len(candidate) <= limit_chars:
579
  return candidate
580
+ parts.popleft()
581
  dropped = True
582
 
583
  # Nothing fits; return status only with marker if possible.
messaging/tree_data.py CHANGED
@@ -284,6 +284,9 @@ class MessageTree:
284
 
285
  Caller must hold the tree lock (e.g. via with_lock).
286
  Returns True if node was removed, False if not in queue.
 
 
 
287
  """
288
  queue_deque: deque = self._queue._queue # type: ignore[attr-defined]
289
  if node_id not in queue_deque:
 
284
 
285
  Caller must hold the tree lock (e.g. via with_lock).
286
  Returns True if node was removed, False if not in queue.
287
+
288
+ Note: asyncio.Queue has no built-in remove; we filter via the internal
289
+ deque. O(n) in queue size; acceptable for typical tree queue sizes.
290
  """
291
  queue_deque: deque = self._queue._queue # type: ignore[attr-defined]
292
  if node_id not in queue_deque:
messaging/tree_queue.py CHANGED
@@ -254,13 +254,14 @@ class TreeQueueManager:
254
  # 2. Drain queue and mark nodes as cancelled
255
  queue_nodes = tree.drain_queue_and_mark_cancelled()
256
  cancelled_nodes.extend(queue_nodes)
 
257
 
258
  # 3. Cleanup: Mark ANY other PENDING or IN_PROGRESS nodes as ERROR
259
  cleanup_count = 0
260
  for node in tree.all_nodes():
261
  if (
262
  node.state in (MessageState.PENDING, MessageState.IN_PROGRESS)
263
- and node not in cancelled_nodes
264
  ):
265
  node.state = MessageState.ERROR
266
  node.error_message = "Stale task cleaned up"
 
254
  # 2. Drain queue and mark nodes as cancelled
255
  queue_nodes = tree.drain_queue_and_mark_cancelled()
256
  cancelled_nodes.extend(queue_nodes)
257
+ cancelled_ids = {n.node_id for n in cancelled_nodes}
258
 
259
  # 3. Cleanup: Mark ANY other PENDING or IN_PROGRESS nodes as ERROR
260
  cleanup_count = 0
261
  for node in tree.all_nodes():
262
  if (
263
  node.state in (MessageState.PENDING, MessageState.IN_PROGRESS)
264
+ and node.node_id not in cancelled_ids
265
  ):
266
  node.state = MessageState.ERROR
267
  node.error_message = "Stale task cleaned up"
tests/test_restart_reply_restore.py CHANGED
@@ -29,6 +29,7 @@ async def test_reply_to_old_status_message_after_restore_routes_to_parent(
29
  handler1.tree_queue.register_node("status_A", tree.root_id)
30
  store.register_node("status_A", tree.root_id)
31
  store.save_tree(tree.root_id, tree.to_dict())
 
32
 
33
  # "Restart": new store instance loads from disk, and we restore TreeQueueManager.
34
  store2 = SessionStore(storage_path=str(store_path))
@@ -81,6 +82,7 @@ async def test_reply_to_old_status_message_without_mapping_creates_new_conversat
81
  )
82
  # Intentionally do NOT register "status_A" mapping.
83
  store.save_tree(tree.root_id, tree.to_dict())
 
84
 
85
  store2 = SessionStore(storage_path=str(store_path))
86
  handler2 = ClaudeMessageHandler(mock_platform, mock_cli_manager, store2)
 
29
  handler1.tree_queue.register_node("status_A", tree.root_id)
30
  store.register_node("status_A", tree.root_id)
31
  store.save_tree(tree.root_id, tree.to_dict())
32
+ store.flush_pending_save()
33
 
34
  # "Restart": new store instance loads from disk, and we restore TreeQueueManager.
35
  store2 = SessionStore(storage_path=str(store_path))
 
82
  )
83
  # Intentionally do NOT register "status_A" mapping.
84
  store.save_tree(tree.root_id, tree.to_dict())
85
+ store.flush_pending_save()
86
 
87
  store2 = SessionStore(storage_path=str(store_path))
88
  handler2 = ClaudeMessageHandler(mock_platform, mock_cli_manager, store2)
tests/test_session_store_edge_cases.py CHANGED
@@ -152,6 +152,7 @@ class TestSessionStoreClearAll:
152
  ids = store.get_message_ids_for_chat("telegram", "c1")
153
  assert ids == ["1", "2"]
154
 
 
155
  store2 = SessionStore(storage_path=path)
156
  assert store2.get_message_ids_for_chat("telegram", "c1") == ["1", "2"]
157
 
 
152
  ids = store.get_message_ids_for_chat("telegram", "c1")
153
  assert ids == ["1", "2"]
154
 
155
+ store.flush_pending_save()
156
  store2 = SessionStore(storage_path=path)
157
  assert store2.get_message_ids_for_chat("telegram", "c1") == ["1", "2"]
158
 
tests/test_transcript.py CHANGED
@@ -123,6 +123,20 @@ def test_transcript_truncates_by_dropping_oldest_segments():
123
  assert escape_md_v2("segment_0") not in out
124
 
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  def test_transcript_reused_index_closes_previous_open_block():
127
  t = TranscriptBuffer()
128
  # Open a text block at index 0, but never close it.
 
123
  assert escape_md_v2("segment_0") not in out
124
 
125
 
126
+ def test_transcript_render_many_segments_completes_quickly():
127
+ """Render with 200+ segments exercises O(n) truncation (deque popleft)."""
128
+ t = TranscriptBuffer()
129
+ for i in range(200):
130
+ t.apply({"type": "text_start", "index": i})
131
+ t.apply({"type": "text_delta", "index": i, "text": f"seg_{i} " + ("y" * 80)})
132
+ t.apply({"type": "block_stop", "index": i})
133
+
134
+ out = t.render(_ctx(), limit_chars=500, status="ok")
135
+ assert escape_md_v2("... (truncated)") in out
136
+ assert "199" in out # last segment (MarkdownV2 escapes underscores)
137
+ assert "seg_0 " not in out # oldest segment dropped
138
+
139
+
140
  def test_transcript_reused_index_closes_previous_open_block():
141
  t = TranscriptBuffer()
142
  # Open a text block at index 0, but never close it.