devcopilot 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. api/__init__.py +17 -0
  2. api/admin_config.py +1303 -0
  3. api/admin_routes.py +287 -0
  4. api/admin_static/admin.css +459 -0
  5. api/admin_static/admin.js +497 -0
  6. api/admin_static/index.html +77 -0
  7. api/admin_urls.py +34 -0
  8. api/app.py +194 -0
  9. api/command_utils.py +164 -0
  10. api/dependencies.py +144 -0
  11. api/detection.py +152 -0
  12. api/gateway_model_ids.py +54 -0
  13. api/model_catalog.py +133 -0
  14. api/model_router.py +125 -0
  15. api/models/__init__.py +45 -0
  16. api/models/anthropic.py +234 -0
  17. api/models/openai_responses.py +28 -0
  18. api/models/responses.py +60 -0
  19. api/optimization_handlers.py +154 -0
  20. api/request_pipeline.py +424 -0
  21. api/routes.py +156 -0
  22. api/runtime.py +334 -0
  23. api/validation_log.py +48 -0
  24. api/web_server_tools.py +22 -0
  25. api/web_tools/__init__.py +17 -0
  26. api/web_tools/constants.py +15 -0
  27. api/web_tools/egress.py +99 -0
  28. api/web_tools/outbound.py +278 -0
  29. api/web_tools/parsers.py +104 -0
  30. api/web_tools/request.py +87 -0
  31. api/web_tools/streaming.py +206 -0
  32. cli/__init__.py +5 -0
  33. cli/claude_env.py +12 -0
  34. cli/entrypoints.py +166 -0
  35. cli/env.example +209 -0
  36. cli/launchers/__init__.py +1 -0
  37. cli/launchers/claude.py +84 -0
  38. cli/launchers/codex.py +204 -0
  39. cli/launchers/codex_model_catalog.py +186 -0
  40. cli/launchers/common.py +93 -0
  41. cli/managed/__init__.py +6 -0
  42. cli/managed/claude.py +215 -0
  43. cli/managed/manager.py +157 -0
  44. cli/managed/session.py +260 -0
  45. cli/process_registry.py +78 -0
  46. config/__init__.py +5 -0
  47. config/constants.py +13 -0
  48. config/logging_config.py +159 -0
  49. config/nim.py +118 -0
  50. config/paths.py +91 -0
  51. config/provider_catalog.py +259 -0
  52. config/provider_ids.py +7 -0
  53. config/settings.py +538 -0
  54. core/__init__.py +1 -0
  55. core/anthropic/__init__.py +46 -0
  56. core/anthropic/content.py +31 -0
  57. core/anthropic/conversion.py +587 -0
  58. core/anthropic/emitted_sse_tracker.py +346 -0
  59. core/anthropic/errors.py +70 -0
  60. core/anthropic/native_messages_request.py +280 -0
  61. core/anthropic/native_sse_block_policy.py +313 -0
  62. core/anthropic/provider_stream_error.py +34 -0
  63. core/anthropic/server_tool_sse.py +14 -0
  64. core/anthropic/sse.py +440 -0
  65. core/anthropic/stream_contracts.py +205 -0
  66. core/anthropic/stream_recovery.py +346 -0
  67. core/anthropic/stream_recovery_session.py +133 -0
  68. core/anthropic/thinking.py +140 -0
  69. core/anthropic/tokens.py +117 -0
  70. core/anthropic/tools.py +212 -0
  71. core/anthropic/utils.py +9 -0
  72. core/openai_responses/__init__.py +5 -0
  73. core/openai_responses/adapter.py +31 -0
  74. core/openai_responses/anthropic_sse.py +59 -0
  75. core/openai_responses/errors.py +22 -0
  76. core/openai_responses/events.py +19 -0
  77. core/openai_responses/ids.py +21 -0
  78. core/openai_responses/input.py +258 -0
  79. core/openai_responses/items.py +37 -0
  80. core/openai_responses/reasoning.py +52 -0
  81. core/openai_responses/stream.py +25 -0
  82. core/openai_responses/stream_state.py +654 -0
  83. core/openai_responses/tools.py +374 -0
  84. core/openai_responses/usage.py +37 -0
  85. core/rate_limit.py +60 -0
  86. core/trace.py +216 -0
  87. devcopilot-0.2.0.dist-info/METADATA +687 -0
  88. devcopilot-0.2.0.dist-info/RECORD +189 -0
  89. devcopilot-0.2.0.dist-info/WHEEL +4 -0
  90. devcopilot-0.2.0.dist-info/entry_points.txt +6 -0
  91. devcopilot-0.2.0.dist-info/licenses/LICENSE +21 -0
  92. messaging/__init__.py +26 -0
  93. messaging/cli_event_constants.py +67 -0
  94. messaging/command_context.py +66 -0
  95. messaging/command_dispatcher.py +37 -0
  96. messaging/commands.py +275 -0
  97. messaging/event_parser.py +181 -0
  98. messaging/limiter.py +300 -0
  99. messaging/models.py +36 -0
  100. messaging/node_event_pipeline.py +127 -0
  101. messaging/node_runner.py +342 -0
  102. messaging/platforms/__init__.py +15 -0
  103. messaging/platforms/base.py +228 -0
  104. messaging/platforms/discord.py +567 -0
  105. messaging/platforms/factory.py +103 -0
  106. messaging/platforms/outbox.py +144 -0
  107. messaging/platforms/telegram.py +688 -0
  108. messaging/platforms/voice_flow.py +295 -0
  109. messaging/rendering/__init__.py +3 -0
  110. messaging/rendering/discord_markdown.py +318 -0
  111. messaging/rendering/markdown_tables.py +49 -0
  112. messaging/rendering/profiles.py +55 -0
  113. messaging/rendering/telegram_markdown.py +327 -0
  114. messaging/safe_diagnostics.py +17 -0
  115. messaging/session.py +334 -0
  116. messaging/transcript.py +581 -0
  117. messaging/transcription.py +164 -0
  118. messaging/trees/__init__.py +15 -0
  119. messaging/trees/data.py +482 -0
  120. messaging/trees/manager.py +433 -0
  121. messaging/trees/processor.py +179 -0
  122. messaging/trees/repository.py +177 -0
  123. messaging/turn_intake.py +235 -0
  124. messaging/ui_updates.py +101 -0
  125. messaging/voice.py +76 -0
  126. messaging/workflow.py +200 -0
  127. providers/__init__.py +31 -0
  128. providers/base.py +152 -0
  129. providers/cerebras/__init__.py +7 -0
  130. providers/cerebras/client.py +31 -0
  131. providers/cerebras/request.py +55 -0
  132. providers/codestral/__init__.py +7 -0
  133. providers/codestral/client.py +34 -0
  134. providers/deepseek/__init__.py +11 -0
  135. providers/deepseek/client.py +51 -0
  136. providers/deepseek/request.py +475 -0
  137. providers/defaults.py +41 -0
  138. providers/error_mapping.py +309 -0
  139. providers/exceptions.py +113 -0
  140. providers/fireworks/__init__.py +5 -0
  141. providers/fireworks/client.py +45 -0
  142. providers/fireworks/request.py +48 -0
  143. providers/gemini/__init__.py +7 -0
  144. providers/gemini/client.py +49 -0
  145. providers/gemini/request.py +199 -0
  146. providers/groq/__init__.py +7 -0
  147. providers/groq/client.py +31 -0
  148. providers/groq/request.py +83 -0
  149. providers/kimi/__init__.py +10 -0
  150. providers/kimi/client.py +53 -0
  151. providers/kimi/request.py +42 -0
  152. providers/llamacpp/__init__.py +3 -0
  153. providers/llamacpp/client.py +16 -0
  154. providers/lmstudio/__init__.py +5 -0
  155. providers/lmstudio/client.py +16 -0
  156. providers/mistral/__init__.py +7 -0
  157. providers/mistral/client.py +31 -0
  158. providers/mistral/request.py +37 -0
  159. providers/model_listing.py +133 -0
  160. providers/nvidia_nim/__init__.py +7 -0
  161. providers/nvidia_nim/client.py +91 -0
  162. providers/nvidia_nim/request.py +430 -0
  163. providers/nvidia_nim/voice.py +95 -0
  164. providers/ollama/__init__.py +7 -0
  165. providers/ollama/client.py +39 -0
  166. providers/open_router/__init__.py +7 -0
  167. providers/open_router/client.py +124 -0
  168. providers/open_router/request.py +42 -0
  169. providers/opencode/__init__.py +11 -0
  170. providers/opencode/client.py +31 -0
  171. providers/opencode/request.py +35 -0
  172. providers/rate_limit.py +300 -0
  173. providers/registry.py +527 -0
  174. providers/transports/__init__.py +1 -0
  175. providers/transports/anthropic_messages/__init__.py +5 -0
  176. providers/transports/anthropic_messages/http.py +118 -0
  177. providers/transports/anthropic_messages/recovery.py +206 -0
  178. providers/transports/anthropic_messages/stream.py +295 -0
  179. providers/transports/anthropic_messages/transport.py +236 -0
  180. providers/transports/openai_chat/__init__.py +5 -0
  181. providers/transports/openai_chat/recovery.py +217 -0
  182. providers/transports/openai_chat/stream.py +384 -0
  183. providers/transports/openai_chat/tool_calls.py +293 -0
  184. providers/transports/openai_chat/transport.py +156 -0
  185. providers/wafer/__init__.py +10 -0
  186. providers/wafer/client.py +50 -0
  187. providers/zai/__init__.py +10 -0
  188. providers/zai/client.py +46 -0
  189. providers/zai/request.py +42 -0
@@ -0,0 +1,433 @@
1
+ """Public manager API for tree-based messaging queues."""
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable
5
+
6
+ from loguru import logger
7
+
8
+ from ..models import IncomingMessage
9
+ from .data import MessageNode, MessageState, MessageTree
10
+ from .processor import TreeQueueProcessor
11
+ from .repository import TreeRepository
12
+
13
+
14
+ class TreeQueueManager:
15
+ """
16
+ Manages multiple message trees: index + async processing.
17
+
18
+ Each new conversation creates a new tree.
19
+ Replies to existing messages add nodes to existing trees.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ queue_update_callback: Callable[[MessageTree], Awaitable[None]] | None = None,
25
+ node_started_callback: Callable[[MessageTree, str], Awaitable[None]]
26
+ | None = None,
27
+ _repository: TreeRepository | None = None,
28
+ ) -> None:
29
+ self._repository = _repository or TreeRepository()
30
+ self._processor = TreeQueueProcessor(
31
+ queue_update_callback=queue_update_callback,
32
+ node_started_callback=node_started_callback,
33
+ )
34
+ self._lock = asyncio.Lock()
35
+
36
+ logger.info("TreeQueueManager initialized")
37
+
38
+ async def create_tree(
39
+ self,
40
+ node_id: str,
41
+ incoming: IncomingMessage,
42
+ status_message_id: str,
43
+ ) -> MessageTree:
44
+ """
45
+ Create a new tree with a root node.
46
+
47
+ Args:
48
+ node_id: ID for the root node
49
+ incoming: The incoming message
50
+ status_message_id: Bot's status message ID
51
+
52
+ Returns:
53
+ The created MessageTree
54
+ """
55
+ async with self._lock:
56
+ root_node = MessageNode(
57
+ node_id=node_id,
58
+ incoming=incoming,
59
+ status_message_id=status_message_id,
60
+ state=MessageState.PENDING,
61
+ )
62
+
63
+ tree = MessageTree(root_node)
64
+ self._repository.add_tree(node_id, tree)
65
+
66
+ logger.info(f"Created new tree with root {node_id}")
67
+ return tree
68
+
69
+ async def add_to_tree(
70
+ self,
71
+ parent_node_id: str,
72
+ node_id: str,
73
+ incoming: IncomingMessage,
74
+ status_message_id: str,
75
+ ) -> tuple[MessageTree, MessageNode]:
76
+ """
77
+ Add a reply as a child node to an existing tree.
78
+
79
+ Args:
80
+ parent_node_id: ID of the parent message
81
+ node_id: ID for the new node
82
+ incoming: The incoming reply message
83
+ status_message_id: Bot's status message ID
84
+
85
+ Returns:
86
+ Tuple of (tree, new_node)
87
+ """
88
+ async with self._lock:
89
+ if not self._repository.has_node(parent_node_id):
90
+ raise ValueError(f"Parent node {parent_node_id} not found in any tree")
91
+
92
+ tree = self._repository.get_tree_for_node(parent_node_id)
93
+ if not tree:
94
+ raise ValueError(f"Parent node {parent_node_id} not found in any tree")
95
+
96
+ node = await tree.add_node(
97
+ node_id=node_id,
98
+ incoming=incoming,
99
+ status_message_id=status_message_id,
100
+ parent_id=parent_node_id,
101
+ )
102
+
103
+ async with self._lock:
104
+ self._repository.register_node(node_id, tree.root_id)
105
+
106
+ logger.info(f"Added node {node_id} to tree {tree.root_id}")
107
+ return tree, node
108
+
109
+ def get_tree(self, root_id: str) -> MessageTree | None:
110
+ """Get a tree by its root ID."""
111
+ return self._repository.get_tree(root_id)
112
+
113
+ def get_tree_for_node(self, node_id: str) -> MessageTree | None:
114
+ """Get the tree containing a given node."""
115
+ return self._repository.get_tree_for_node(node_id)
116
+
117
+ def get_node(self, node_id: str) -> MessageNode | None:
118
+ """Get a node from any tree."""
119
+ return self._repository.get_node(node_id)
120
+
121
+ def resolve_parent_node_id(self, msg_id: str) -> str | None:
122
+ """Resolve a message ID to the actual parent node ID."""
123
+ return self._repository.resolve_parent_node_id(msg_id)
124
+
125
+ def is_tree_busy(self, root_id: str) -> bool:
126
+ """Check if a tree is currently processing."""
127
+ return self._repository.is_tree_busy(root_id)
128
+
129
+ def is_node_tree_busy(self, node_id: str) -> bool:
130
+ """Check if the tree containing a node is busy."""
131
+ return self._repository.is_node_tree_busy(node_id)
132
+
133
+ async def enqueue(
134
+ self,
135
+ node_id: str,
136
+ processor: Callable[[str, MessageNode], Awaitable[None]],
137
+ ) -> bool:
138
+ """
139
+ Enqueue a node for processing.
140
+
141
+ If the tree is not busy, processing starts immediately.
142
+ If busy, the message is queued.
143
+
144
+ Args:
145
+ node_id: Node to process
146
+ processor: Async function to process the node
147
+
148
+ Returns:
149
+ True if queued, False if processing immediately
150
+ """
151
+ tree = self._repository.get_tree_for_node(node_id)
152
+ if not tree:
153
+ logger.error(f"No tree found for node {node_id}")
154
+ return False
155
+
156
+ return await self._processor.enqueue_and_start(tree, node_id, processor)
157
+
158
+ def get_queue_size(self, node_id: str) -> int:
159
+ """Get queue size for the tree containing a node."""
160
+ return self._repository.get_queue_size(node_id)
161
+
162
+ def get_pending_children(self, node_id: str) -> list[MessageNode]:
163
+ """Get all pending child nodes (recursively) of a given node."""
164
+ return self._repository.get_pending_children(node_id)
165
+
166
+ async def mark_node_error(
167
+ self,
168
+ node_id: str,
169
+ error_message: str,
170
+ propagate_to_children: bool = True,
171
+ ) -> list[MessageNode]:
172
+ """
173
+ Mark a node as ERROR and optionally propagate to pending children.
174
+
175
+ Args:
176
+ node_id: The node to mark as error
177
+ error_message: Error description
178
+ propagate_to_children: If True, also mark pending children as error
179
+
180
+ Returns:
181
+ List of all nodes marked as error (including children)
182
+ """
183
+ tree = self._repository.get_tree_for_node(node_id)
184
+ if not tree:
185
+ return []
186
+
187
+ affected = []
188
+ node = tree.get_node(node_id)
189
+ if node:
190
+ await tree.update_state(
191
+ node_id, MessageState.ERROR, error_message=error_message
192
+ )
193
+ affected.append(node)
194
+
195
+ if propagate_to_children:
196
+ pending_children = self._repository.get_pending_children(node_id)
197
+ for child in pending_children:
198
+ await tree.update_state(
199
+ child.node_id,
200
+ MessageState.ERROR,
201
+ error_message=f"Parent failed: {error_message}",
202
+ )
203
+ affected.append(child)
204
+
205
+ return affected
206
+
207
+ async def cancel_tree(self, root_id: str) -> list[MessageNode]:
208
+ """
209
+ Cancel all queued and in-progress messages in a tree.
210
+
211
+ Updates node states to ERROR and returns list of affected nodes
212
+ that were actually active or in the current processing queue.
213
+ """
214
+ tree = self._repository.get_tree(root_id)
215
+ if not tree:
216
+ return []
217
+
218
+ cancelled_nodes = []
219
+
220
+ cleanup_count = 0
221
+ async with tree.with_lock():
222
+ if tree.cancel_current_task():
223
+ current_id = tree.current_node_id
224
+ if current_id:
225
+ node = tree.get_node(current_id)
226
+ if node and node.state not in (
227
+ MessageState.COMPLETED,
228
+ MessageState.ERROR,
229
+ ):
230
+ tree.set_node_error_sync(node, "Cancelled by user")
231
+ cancelled_nodes.append(node)
232
+
233
+ queue_nodes = tree.drain_queue_and_mark_cancelled()
234
+ cancelled_nodes.extend(queue_nodes)
235
+ cancelled_ids = {n.node_id for n in cancelled_nodes}
236
+
237
+ for node in tree.all_nodes():
238
+ if (
239
+ node.state in (MessageState.PENDING, MessageState.IN_PROGRESS)
240
+ and node.node_id not in cancelled_ids
241
+ ):
242
+ tree.set_node_error_sync(node, "Stale task cleaned up")
243
+ cleanup_count += 1
244
+
245
+ tree.reset_processing_state()
246
+
247
+ if cancelled_nodes:
248
+ logger.info(
249
+ f"Cancelled {len(cancelled_nodes)} active nodes in tree {root_id}"
250
+ )
251
+ if cleanup_count:
252
+ logger.info(f"Cleaned up {cleanup_count} stale nodes in tree {root_id}")
253
+
254
+ return cancelled_nodes
255
+
256
+ async def cancel_node(self, node_id: str) -> list[MessageNode]:
257
+ """
258
+ Cancel a single node (queued or in-progress) without affecting other nodes.
259
+
260
+ Returns:
261
+ List containing the cancelled node if it was cancellable, else empty list.
262
+ """
263
+ tree = self._repository.get_tree_for_node(node_id)
264
+ if not tree:
265
+ return []
266
+
267
+ async with tree.with_lock():
268
+ node = tree.get_node(node_id)
269
+ if not node:
270
+ return []
271
+
272
+ if node.state in (MessageState.COMPLETED, MessageState.ERROR):
273
+ return []
274
+
275
+ if tree.is_current_node(node_id):
276
+ self._processor.cancel_current(tree)
277
+
278
+ removed_from_queue = False
279
+ try:
280
+ removed_from_queue = tree.remove_from_queue(node_id)
281
+ except Exception:
282
+ logger.debug(
283
+ "Failed to remove node from queue; will rely on state=ERROR"
284
+ )
285
+
286
+ tree.set_node_error_sync(node, "Cancelled by user")
287
+
288
+ if removed_from_queue:
289
+ await self._processor.notify_queue_updated(tree)
290
+
291
+ return [node]
292
+
293
+ async def cancel_all(self) -> list[MessageNode]:
294
+ """Cancel all messages in all trees."""
295
+ async with self._lock:
296
+ root_ids = list(self._repository.tree_ids())
297
+ all_cancelled: list[MessageNode] = []
298
+ for root_id in root_ids:
299
+ all_cancelled.extend(await self.cancel_tree(root_id))
300
+ return all_cancelled
301
+
302
+ def cleanup_stale_nodes(self) -> int:
303
+ """
304
+ Mark any PENDING or IN_PROGRESS nodes in all trees as ERROR.
305
+ Used on startup to reconcile restored state.
306
+ """
307
+ count = 0
308
+ for tree in self._repository.all_trees():
309
+ for node in tree.all_nodes():
310
+ if node.state in (MessageState.PENDING, MessageState.IN_PROGRESS):
311
+ tree.set_node_error_sync(node, "Lost during server restart")
312
+ count += 1
313
+ if count:
314
+ logger.info(f"Cleaned up {count} stale nodes during startup")
315
+ return count
316
+
317
+ def get_tree_count(self) -> int:
318
+ """Get the number of active message trees."""
319
+ return self._repository.tree_count()
320
+
321
+ def set_queue_update_callback(
322
+ self,
323
+ queue_update_callback: Callable[[MessageTree], Awaitable[None]] | None,
324
+ ) -> None:
325
+ """Set callback for queue position updates."""
326
+ self._processor.set_queue_update_callback(queue_update_callback)
327
+
328
+ def set_node_started_callback(
329
+ self,
330
+ node_started_callback: Callable[[MessageTree, str], Awaitable[None]] | None,
331
+ ) -> None:
332
+ """Set callback for when a queued node starts processing."""
333
+ self._processor.set_node_started_callback(node_started_callback)
334
+
335
+ def register_node(self, node_id: str, root_id: str) -> None:
336
+ """Register a node ID to a tree (for external mapping)."""
337
+ self._repository.register_node(node_id, root_id)
338
+
339
+ async def cancel_branch(self, branch_root_id: str) -> list[MessageNode]:
340
+ """
341
+ Cancel all PENDING/IN_PROGRESS nodes in the subtree (branch_root + descendants).
342
+ """
343
+ tree = self._repository.get_tree_for_node(branch_root_id)
344
+ if not tree:
345
+ return []
346
+
347
+ branch_ids = set(tree.get_descendants(branch_root_id))
348
+ cancelled: list[MessageNode] = []
349
+ removed_from_queue = False
350
+
351
+ async with tree.with_lock():
352
+ for nid in branch_ids:
353
+ node = tree.get_node(nid)
354
+ if not node or node.state in (
355
+ MessageState.COMPLETED,
356
+ MessageState.ERROR,
357
+ ):
358
+ continue
359
+
360
+ if tree.is_current_node(nid):
361
+ self._processor.cancel_current(tree)
362
+ tree.set_node_error_sync(node, "Cancelled by user")
363
+ cancelled.append(node)
364
+ else:
365
+ removed_from_queue = (
366
+ tree.remove_from_queue(nid) or removed_from_queue
367
+ )
368
+ tree.set_node_error_sync(node, "Cancelled by user")
369
+ cancelled.append(node)
370
+
371
+ if cancelled:
372
+ logger.info(f"Cancelled {len(cancelled)} nodes in branch {branch_root_id}")
373
+ if removed_from_queue:
374
+ await self._processor.notify_queue_updated(tree)
375
+ return cancelled
376
+
377
+ async def remove_branch(
378
+ self, branch_root_id: str
379
+ ) -> tuple[list[MessageNode], str, bool]:
380
+ """
381
+ Remove a branch (subtree) from the tree.
382
+
383
+ If branch_root is the tree root, removes the entire tree.
384
+
385
+ Returns:
386
+ (removed_nodes, root_id, removed_entire_tree)
387
+ """
388
+ tree = self._repository.get_tree_for_node(branch_root_id)
389
+ if not tree:
390
+ return ([], "", False)
391
+
392
+ root_id = tree.root_id
393
+
394
+ if branch_root_id == root_id:
395
+ cancelled = await self.cancel_tree(root_id)
396
+ removed_tree = self._repository.remove_tree(root_id)
397
+ if removed_tree:
398
+ return (removed_tree.all_nodes(), root_id, True)
399
+ return (cancelled, root_id, True)
400
+
401
+ async with tree.with_lock():
402
+ removed = tree.remove_branch(branch_root_id)
403
+
404
+ self._repository.unregister_node_lookups(removed)
405
+ return (removed, root_id, False)
406
+
407
+ def get_message_ids_for_chat(self, platform: str, chat_id: str) -> set[str]:
408
+ """Get all message IDs for a given platform/chat."""
409
+ return self._repository.get_message_ids_for_chat(platform, chat_id)
410
+
411
+ def to_dict(self) -> dict:
412
+ """Serialize all trees."""
413
+ return self._repository.to_dict()
414
+
415
+ @classmethod
416
+ def from_dict(
417
+ cls,
418
+ data: dict,
419
+ queue_update_callback: Callable[[MessageTree], Awaitable[None]] | None = None,
420
+ node_started_callback: Callable[[MessageTree, str], Awaitable[None]]
421
+ | None = None,
422
+ ) -> TreeQueueManager:
423
+ """Deserialize from dictionary."""
424
+ return cls(
425
+ queue_update_callback=queue_update_callback,
426
+ node_started_callback=node_started_callback,
427
+ _repository=TreeRepository.from_dict(data),
428
+ )
429
+
430
+
431
+ __all__ = [
432
+ "TreeQueueManager",
433
+ ]
@@ -0,0 +1,179 @@
1
+ """Async processing loop for a single messaging tree queue."""
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable
5
+
6
+ from loguru import logger
7
+
8
+ from config.settings import get_settings
9
+ from core.anthropic import get_user_facing_error_message
10
+
11
+ from ..safe_diagnostics import format_exception_for_log
12
+ from .data import MessageNode, MessageState, MessageTree
13
+
14
+
15
+ class TreeQueueProcessor:
16
+ """Per-tree async queue processing owned by TreeQueueManager."""
17
+
18
+ def __init__(
19
+ self,
20
+ queue_update_callback: Callable[[MessageTree], Awaitable[None]] | None = None,
21
+ node_started_callback: Callable[[MessageTree, str], Awaitable[None]]
22
+ | None = None,
23
+ ) -> None:
24
+ self._queue_update_callback = queue_update_callback
25
+ self._node_started_callback = node_started_callback
26
+
27
+ def set_queue_update_callback(
28
+ self,
29
+ queue_update_callback: Callable[[MessageTree], Awaitable[None]] | None,
30
+ ) -> None:
31
+ """Update the callback used to refresh queue positions."""
32
+ self._queue_update_callback = queue_update_callback
33
+
34
+ def set_node_started_callback(
35
+ self,
36
+ node_started_callback: Callable[[MessageTree, str], Awaitable[None]] | None,
37
+ ) -> None:
38
+ """Update the callback used when a queued node starts processing."""
39
+ self._node_started_callback = node_started_callback
40
+
41
+ async def _notify_queue_updated(self, tree: MessageTree) -> None:
42
+ """Invoke queue update callback if set."""
43
+ if not self._queue_update_callback:
44
+ return
45
+ try:
46
+ await self._queue_update_callback(tree)
47
+ except Exception as e:
48
+ d = get_settings().log_messaging_error_details
49
+ logger.warning(
50
+ "Queue update callback failed: {}",
51
+ format_exception_for_log(e, log_full_message=d),
52
+ )
53
+
54
+ async def notify_queue_updated(self, tree: MessageTree) -> None:
55
+ """Invoke the queue update callback after external queue mutations."""
56
+ await self._notify_queue_updated(tree)
57
+
58
+ async def _notify_node_started(self, tree: MessageTree, node_id: str) -> None:
59
+ """Invoke node started callback if set."""
60
+ if not self._node_started_callback:
61
+ return
62
+ try:
63
+ await self._node_started_callback(tree, node_id)
64
+ except Exception as e:
65
+ d = get_settings().log_messaging_error_details
66
+ logger.warning(
67
+ "Node started callback failed: {}",
68
+ format_exception_for_log(e, log_full_message=d),
69
+ )
70
+
71
+ async def process_node(
72
+ self,
73
+ tree: MessageTree,
74
+ node: MessageNode,
75
+ processor: Callable[[str, MessageNode], Awaitable[None]],
76
+ ) -> None:
77
+ """Process a single node and then check the queue."""
78
+ if node.state == MessageState.ERROR:
79
+ logger.info(
80
+ f"Skipping node {node.node_id} as it is already in state {node.state}"
81
+ )
82
+ await self._process_next(tree, processor)
83
+ return
84
+
85
+ try:
86
+ await processor(node.node_id, node)
87
+ except asyncio.CancelledError:
88
+ logger.info(f"Task for node {node.node_id} was cancelled")
89
+ raise
90
+ except Exception as e:
91
+ d = get_settings().log_messaging_error_details
92
+ logger.error(
93
+ "Error processing node {}: {}",
94
+ node.node_id,
95
+ format_exception_for_log(e, log_full_message=d),
96
+ )
97
+ await tree.update_state(
98
+ node.node_id,
99
+ MessageState.ERROR,
100
+ error_message=get_user_facing_error_message(e),
101
+ )
102
+ finally:
103
+ async with tree.with_lock():
104
+ tree.clear_current_node()
105
+ await self._process_next(tree, processor)
106
+
107
+ async def _process_next(
108
+ self,
109
+ tree: MessageTree,
110
+ processor: Callable[[str, MessageNode], Awaitable[None]],
111
+ ) -> None:
112
+ """Process the next message in queue, if any."""
113
+ next_node_id = None
114
+ node: MessageNode | None = None
115
+ discarded_stale_ids = False
116
+ async with tree.with_lock():
117
+ while True:
118
+ next_node_id = await tree.dequeue()
119
+
120
+ if not next_node_id:
121
+ tree.set_processing_state(None, False)
122
+ logger.debug(f"Tree {tree.root_id} queue empty, marking as free")
123
+ break
124
+
125
+ node = tree.get_node(next_node_id)
126
+ if node:
127
+ tree.set_processing_state(next_node_id, True)
128
+ logger.info(f"Processing next queued node {next_node_id}")
129
+ tree.set_current_task(
130
+ asyncio.create_task(self.process_node(tree, node, processor))
131
+ )
132
+ break
133
+
134
+ discarded_stale_ids = True
135
+ logger.debug(
136
+ "Skipping stale queued node {} in tree {}",
137
+ next_node_id,
138
+ tree.root_id,
139
+ )
140
+
141
+ if next_node_id and node:
142
+ await self._notify_node_started(tree, next_node_id)
143
+ await self._notify_queue_updated(tree)
144
+ elif discarded_stale_ids:
145
+ await self._notify_queue_updated(tree)
146
+
147
+ async def enqueue_and_start(
148
+ self,
149
+ tree: MessageTree,
150
+ node_id: str,
151
+ processor: Callable[[str, MessageNode], Awaitable[None]],
152
+ ) -> bool:
153
+ """
154
+ Enqueue a node or start processing immediately.
155
+
156
+ Returns True if queued, False if processing immediately.
157
+ """
158
+ async with tree.with_lock():
159
+ if tree.is_processing:
160
+ tree.put_queue_unlocked(node_id)
161
+ queue_size = tree.get_queue_size()
162
+ logger.info(f"Queued node {node_id}, position {queue_size}")
163
+ return True
164
+
165
+ tree.set_processing_state(node_id, True)
166
+
167
+ node = tree.get_node(node_id)
168
+ if node:
169
+ tree.set_current_task(
170
+ asyncio.create_task(self.process_node(tree, node, processor))
171
+ )
172
+ return False
173
+
174
+ def cancel_current(self, tree: MessageTree) -> bool:
175
+ """Cancel the currently running task in a tree."""
176
+ return tree.cancel_current_task()
177
+
178
+
179
+ __all__ = ["TreeQueueProcessor"]