crabcode 0.1.6__py3-none-win_amd64.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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
"""Team message bus — event-driven message passing with backpressure.
|
|
2
|
+
|
|
3
|
+
Provides O(1) JSONL append for message writes, session injection for
|
|
4
|
+
delivery, and auto-wake for idle recipients. No polling required.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Iterator
|
|
16
|
+
|
|
17
|
+
from crabcode_core.file_lock import file_lock
|
|
18
|
+
from crabcode_core.logging_utils import get_logger
|
|
19
|
+
from crabcode_core.path_validation import validate_path_component
|
|
20
|
+
from crabcode_core.team.models import (
|
|
21
|
+
TeamConfig,
|
|
22
|
+
TeamMessage,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
_MAX_MESSAGE_SIZE_BYTES = 10_000 # 10KB default
|
|
28
|
+
|
|
29
|
+
@contextmanager
|
|
30
|
+
def _team_file_lock(
|
|
31
|
+
storage_root: Path,
|
|
32
|
+
team_name: str,
|
|
33
|
+
*,
|
|
34
|
+
shared: bool = False,
|
|
35
|
+
) -> Iterator[None]:
|
|
36
|
+
"""Coordinate all disk operations for one team.
|
|
37
|
+
|
|
38
|
+
The sidecar lives beside the team directory, so deleting the directory
|
|
39
|
+
cannot remove the lock inode while another writer is waiting. The naming
|
|
40
|
+
matches ``InboxStorage`` so the two storage APIs also serialize when they
|
|
41
|
+
point at the same root.
|
|
42
|
+
"""
|
|
43
|
+
storage_root.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
lock_path = storage_root / f".{team_name}.team.lock"
|
|
45
|
+
with file_lock(lock_path, exclusive=not shared):
|
|
46
|
+
yield
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TeamMessageBus:
|
|
50
|
+
"""In-process message bus for a single team.
|
|
51
|
+
|
|
52
|
+
Each team gets its own TeamMessageBus instance. Messages are:
|
|
53
|
+
1. Written to the recipient's inbox (JSONL, O(1) append)
|
|
54
|
+
2. Injected into the recipient's asyncio.Queue for immediate delivery
|
|
55
|
+
3. Auto-wake triggers restart the recipient's prompt loop if idle
|
|
56
|
+
|
|
57
|
+
Backpressure: each agent has a bounded asyncio.Queue. When full,
|
|
58
|
+
the oldest unread message is dropped and a warning is logged.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
team_name: str,
|
|
64
|
+
config: TeamConfig,
|
|
65
|
+
*,
|
|
66
|
+
inject_fn: Any | None = None,
|
|
67
|
+
wake_fn: Any | None = None,
|
|
68
|
+
storage_root: Path | None = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
self._validate_component(team_name, "team name")
|
|
71
|
+
self._team_name = team_name
|
|
72
|
+
self._config = config
|
|
73
|
+
self._max_queue_size = int(config.backpressure_queue_size)
|
|
74
|
+
self._max_msg_size = int(config.max_message_size_bytes or _MAX_MESSAGE_SIZE_BYTES)
|
|
75
|
+
if self._max_queue_size <= 0:
|
|
76
|
+
raise ValueError("backpressure_queue_size must be greater than zero")
|
|
77
|
+
if self._max_msg_size <= 0:
|
|
78
|
+
raise ValueError("max_message_size_bytes must be greater than zero")
|
|
79
|
+
self._inject_fn = inject_fn # async (agent_id, from_agent, text) -> None
|
|
80
|
+
self._wake_fn = wake_fn # async (agent_id, from_agent) -> None
|
|
81
|
+
|
|
82
|
+
# Per-agent queues: agent_id -> asyncio.Queue[TeamMessage]
|
|
83
|
+
self._queues: dict[str, asyncio.Queue[TeamMessage]] = {}
|
|
84
|
+
# Per-agent inbox for persistence (agent_id -> list of all messages)
|
|
85
|
+
self._inboxes: dict[str, list[TeamMessage]] = {}
|
|
86
|
+
# Track which agents are registered
|
|
87
|
+
self._registered: set[str] = set()
|
|
88
|
+
# Registration-scoped wake events let receive() abandon a queue that
|
|
89
|
+
# was invalidated by unregister_agent() instead of waiting forever on
|
|
90
|
+
# an orphaned queue object.
|
|
91
|
+
self._registration_events: dict[str, asyncio.Event] = {}
|
|
92
|
+
# Inbox storage root on disk
|
|
93
|
+
self._storage_root = storage_root
|
|
94
|
+
# A per-recipient lock serializes append and read-state rewrites. Without
|
|
95
|
+
# this, an async mark_read rewrite can race a send append and lose data.
|
|
96
|
+
self._inbox_locks: dict[str, asyncio.Lock] = {}
|
|
97
|
+
# Disk operations run in an executor and may outlive a registration
|
|
98
|
+
# change. Epochs prevent a stale operation from repopulating the
|
|
99
|
+
# in-memory state of a later registration for the same agent id.
|
|
100
|
+
self._registration_epochs: dict[str, int] = {}
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _validate_component(value: str, label: str) -> None:
|
|
104
|
+
"""Reject path separators and traversal components used in inbox paths."""
|
|
105
|
+
validate_path_component(value, label)
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
# Registration
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def register_agent(self, agent_id: str) -> None:
|
|
112
|
+
"""Register an agent to receive messages."""
|
|
113
|
+
self._validate_component(agent_id, "agent id")
|
|
114
|
+
if agent_id in self._registered:
|
|
115
|
+
return
|
|
116
|
+
self._registered.add(agent_id)
|
|
117
|
+
self._registration_epochs[agent_id] = (
|
|
118
|
+
self._registration_epochs.get(agent_id, 0) + 1
|
|
119
|
+
)
|
|
120
|
+
self._registration_events[agent_id] = asyncio.Event()
|
|
121
|
+
self._queues[agent_id] = asyncio.Queue(maxsize=self._max_queue_size)
|
|
122
|
+
# Preserve the lock across unregister/re-register cycles. An
|
|
123
|
+
# in-flight send or mark_read may still own the previous lock object.
|
|
124
|
+
self._inbox_locks.setdefault(agent_id, asyncio.Lock())
|
|
125
|
+
# Recover durable messages before exposing the recipient. Previously a
|
|
126
|
+
# newly-created bus silently reset the in-memory inbox and made JSONL
|
|
127
|
+
# persistence useless after a restart.
|
|
128
|
+
messages = self.load_inbox_from_disk(agent_id)
|
|
129
|
+
self._inboxes[agent_id] = messages
|
|
130
|
+
# Queue only unread messages, retaining the full inbox as the source of
|
|
131
|
+
# truth. If more messages exist than the bounded queue can hold, keep
|
|
132
|
+
# the newest entries in the live queue; older entries remain readable.
|
|
133
|
+
unread = [message for message in messages if not message.read]
|
|
134
|
+
for message in unread[-self._max_queue_size :]:
|
|
135
|
+
try:
|
|
136
|
+
self._queues[agent_id].put_nowait(message.model_copy(deep=True))
|
|
137
|
+
except asyncio.QueueFull:
|
|
138
|
+
break
|
|
139
|
+
|
|
140
|
+
def unregister_agent(self, agent_id: str) -> None:
|
|
141
|
+
"""Unregister an agent. Drops its queue and inbox."""
|
|
142
|
+
self._registration_epochs[agent_id] = (
|
|
143
|
+
self._registration_epochs.get(agent_id, 0) + 1
|
|
144
|
+
)
|
|
145
|
+
event = self._registration_events.pop(agent_id, None)
|
|
146
|
+
if event is not None:
|
|
147
|
+
event.set()
|
|
148
|
+
self._registered.discard(agent_id)
|
|
149
|
+
self._queues.pop(agent_id, None)
|
|
150
|
+
self._inboxes.pop(agent_id, None)
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
# Sending
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
async def send(
|
|
157
|
+
self,
|
|
158
|
+
*,
|
|
159
|
+
from_agent: str,
|
|
160
|
+
to_agent: str,
|
|
161
|
+
text: str,
|
|
162
|
+
msg_type: str = "text",
|
|
163
|
+
expected_epoch: int | None = None,
|
|
164
|
+
) -> TeamMessage | None:
|
|
165
|
+
"""Send a message from one agent to another.
|
|
166
|
+
|
|
167
|
+
Steps:
|
|
168
|
+
1. Validate message size
|
|
169
|
+
2. Create TeamMessage
|
|
170
|
+
3. Append to recipient's inbox (source of truth)
|
|
171
|
+
4. Persist to JSONL on disk
|
|
172
|
+
5. Push into recipient's asyncio.Queue (with backpressure)
|
|
173
|
+
6. Inject into recipient's session (delivery mechanism)
|
|
174
|
+
7. Auto-wake idle recipient
|
|
175
|
+
"""
|
|
176
|
+
if not isinstance(text, str):
|
|
177
|
+
logger.warning("Non-text message rejected for %s in team %s", to_agent, self._team_name)
|
|
178
|
+
return None
|
|
179
|
+
if to_agent not in self._registered:
|
|
180
|
+
logger.warning("Message to unregistered agent %s in team %s", to_agent, self._team_name)
|
|
181
|
+
return None
|
|
182
|
+
# Capture the registration generation before waiting on the recipient
|
|
183
|
+
# lock. An unregister/re-register cycle must fence this operation;
|
|
184
|
+
# otherwise a send that started for the old runtime can be delivered to
|
|
185
|
+
# a replacement using the same agent id after it acquires the lock.
|
|
186
|
+
initial_epoch = self._registration_epochs.get(to_agent)
|
|
187
|
+
if initial_epoch is None:
|
|
188
|
+
return None
|
|
189
|
+
if expected_epoch is not None and initial_epoch != expected_epoch:
|
|
190
|
+
logger.warning(
|
|
191
|
+
"Recipient %s registration changed before delivery in team %s",
|
|
192
|
+
to_agent,
|
|
193
|
+
self._team_name,
|
|
194
|
+
)
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
if len(text.encode("utf-8")) > self._max_msg_size:
|
|
198
|
+
logger.warning(
|
|
199
|
+
"Message from %s to %s exceeds %d bytes, truncating",
|
|
200
|
+
from_agent, to_agent, self._max_msg_size,
|
|
201
|
+
)
|
|
202
|
+
# Ignore a partial UTF-8 sequence at the byte boundary so the
|
|
203
|
+
# resulting payload never exceeds the configured byte limit.
|
|
204
|
+
text = text.encode("utf-8")[:self._max_msg_size].decode("utf-8", errors="ignore")
|
|
205
|
+
if not text:
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
msg = TeamMessage(
|
|
209
|
+
from_agent=from_agent,
|
|
210
|
+
to_agent=to_agent,
|
|
211
|
+
text=text,
|
|
212
|
+
msg_type=msg_type,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# 1. Append to inbox and persist under one recipient lock. Re-check
|
|
216
|
+
# registration after waiting: shutdown/removal may have happened while
|
|
217
|
+
# another send was in progress.
|
|
218
|
+
lock = self._inbox_locks.setdefault(to_agent, asyncio.Lock())
|
|
219
|
+
async with lock:
|
|
220
|
+
if (
|
|
221
|
+
to_agent not in self._registered
|
|
222
|
+
or self._registration_epochs.get(to_agent) != initial_epoch
|
|
223
|
+
):
|
|
224
|
+
logger.warning("Recipient %s was unregistered before delivery", to_agent)
|
|
225
|
+
return None
|
|
226
|
+
registration_epoch = initial_epoch
|
|
227
|
+
if not await self._persist_message(to_agent, msg):
|
|
228
|
+
# Persistence is the commit point for a send. Do not expose a
|
|
229
|
+
# message through memory, injection, or wake-up when its durable
|
|
230
|
+
# append failed.
|
|
231
|
+
return None
|
|
232
|
+
if (
|
|
233
|
+
to_agent not in self._registered
|
|
234
|
+
or self._registration_epochs.get(to_agent, 0) != registration_epoch
|
|
235
|
+
):
|
|
236
|
+
# The recipient can be removed while the executor is writing
|
|
237
|
+
# the durable line. Keep it for a future registration without
|
|
238
|
+
# recreating the old in-memory inbox or injecting into a stale
|
|
239
|
+
# session callback.
|
|
240
|
+
if to_agent in self._registered:
|
|
241
|
+
self._refresh_registered_inbox(to_agent)
|
|
242
|
+
return None
|
|
243
|
+
self._inboxes.setdefault(to_agent, []).append(msg)
|
|
244
|
+
|
|
245
|
+
# 3. Push into queue (with backpressure)
|
|
246
|
+
queue = self._queues.get(to_agent)
|
|
247
|
+
if queue is not None and (
|
|
248
|
+
to_agent in self._registered
|
|
249
|
+
and self._registration_epochs.get(to_agent, 0) == registration_epoch
|
|
250
|
+
):
|
|
251
|
+
if queue.full():
|
|
252
|
+
# Drop oldest unread message
|
|
253
|
+
try:
|
|
254
|
+
dropped = queue.get_nowait()
|
|
255
|
+
logger.warning(
|
|
256
|
+
"Backpressure: dropped message %s from %s to %s in team %s",
|
|
257
|
+
dropped.id, dropped.from_agent, to_agent, self._team_name,
|
|
258
|
+
)
|
|
259
|
+
except asyncio.QueueEmpty:
|
|
260
|
+
pass
|
|
261
|
+
queue.put_nowait(msg.model_copy(deep=True))
|
|
262
|
+
|
|
263
|
+
# 4. Inject into session (delivery)
|
|
264
|
+
if self._inject_fn and (
|
|
265
|
+
to_agent in self._registered
|
|
266
|
+
and self._registration_epochs.get(to_agent, 0) == registration_epoch
|
|
267
|
+
):
|
|
268
|
+
try:
|
|
269
|
+
await self._inject_fn(to_agent, from_agent, text)
|
|
270
|
+
except Exception:
|
|
271
|
+
logger.warning("Failed to inject message into session for %s", to_agent, exc_info=True)
|
|
272
|
+
|
|
273
|
+
# 5. Auto-wake idle recipient
|
|
274
|
+
if self._wake_fn and (
|
|
275
|
+
to_agent in self._registered
|
|
276
|
+
and self._registration_epochs.get(to_agent, 0) == registration_epoch
|
|
277
|
+
):
|
|
278
|
+
try:
|
|
279
|
+
await self._wake_fn(to_agent, from_agent)
|
|
280
|
+
except Exception:
|
|
281
|
+
logger.warning("Failed to auto-wake %s", to_agent, exc_info=True)
|
|
282
|
+
|
|
283
|
+
return msg
|
|
284
|
+
|
|
285
|
+
def registration_epoch(self, agent_id: str) -> int | None:
|
|
286
|
+
"""Return the current registration generation for *agent_id*.
|
|
287
|
+
|
|
288
|
+
The generation is an internal lifecycle fence used by TeamManager to
|
|
289
|
+
prevent an authorization made for one runtime from being delivered to
|
|
290
|
+
a replacement that reuses the same agent id. ``None`` means the agent
|
|
291
|
+
is not currently registered.
|
|
292
|
+
"""
|
|
293
|
+
if agent_id not in self._registered:
|
|
294
|
+
return None
|
|
295
|
+
return self._registration_epochs.get(agent_id)
|
|
296
|
+
|
|
297
|
+
async def broadcast(
|
|
298
|
+
self,
|
|
299
|
+
*,
|
|
300
|
+
from_agent: str,
|
|
301
|
+
text: str,
|
|
302
|
+
msg_type: str = "text",
|
|
303
|
+
) -> list[TeamMessage]:
|
|
304
|
+
"""Broadcast a message to all registered agents except the sender."""
|
|
305
|
+
messages: list[TeamMessage] = []
|
|
306
|
+
for agent_id in list(self._registered):
|
|
307
|
+
if agent_id == from_agent:
|
|
308
|
+
continue
|
|
309
|
+
msg = await self.send(
|
|
310
|
+
from_agent=from_agent,
|
|
311
|
+
to_agent=agent_id,
|
|
312
|
+
text=text,
|
|
313
|
+
msg_type=msg_type,
|
|
314
|
+
)
|
|
315
|
+
if msg is not None:
|
|
316
|
+
messages.append(msg)
|
|
317
|
+
return messages
|
|
318
|
+
|
|
319
|
+
# ------------------------------------------------------------------
|
|
320
|
+
# Reading
|
|
321
|
+
# ------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
def get_unread(self, agent_id: str) -> list[TeamMessage]:
|
|
324
|
+
"""Get all unread messages for an agent from its inbox."""
|
|
325
|
+
if agent_id in self._registered and self._storage_root is not None:
|
|
326
|
+
# Another bus/process may have appended since this instance was
|
|
327
|
+
# registered. Keep query APIs from serving a permanently stale
|
|
328
|
+
# in-memory snapshot; real-time receive() remains queue-based.
|
|
329
|
+
self._refresh_registered_inbox(agent_id)
|
|
330
|
+
inbox = self._inboxes.get(agent_id, [])
|
|
331
|
+
# Do not expose mutable in-memory records: callers changing ``read``
|
|
332
|
+
# directly would bypass receipt generation and durable persistence.
|
|
333
|
+
return [msg.model_copy(deep=True) for msg in inbox if not msg.read]
|
|
334
|
+
|
|
335
|
+
def get_all(self, agent_id: str) -> list[TeamMessage]:
|
|
336
|
+
"""Get all messages for an agent (read and unread)."""
|
|
337
|
+
if agent_id in self._registered and self._storage_root is not None:
|
|
338
|
+
self._refresh_registered_inbox(agent_id)
|
|
339
|
+
return [msg.model_copy(deep=True) for msg in self._inboxes.get(agent_id, [])]
|
|
340
|
+
|
|
341
|
+
async def mark_read(self, agent_id: str, message_ids: list[str] | None = None) -> int:
|
|
342
|
+
"""Mark messages as read. If message_ids is None, mark all as read.
|
|
343
|
+
|
|
344
|
+
Returns the number of messages marked read.
|
|
345
|
+
Also sends delivery receipts back to the senders.
|
|
346
|
+
"""
|
|
347
|
+
if agent_id not in self._registered:
|
|
348
|
+
return 0
|
|
349
|
+
# As with send(), fence a read acknowledgement that was started by a
|
|
350
|
+
# previous registration if it waits behind an in-flight rewrite.
|
|
351
|
+
initial_epoch = self._registration_epochs.get(agent_id)
|
|
352
|
+
if initial_epoch is None:
|
|
353
|
+
return 0
|
|
354
|
+
count = 0
|
|
355
|
+
# Group by sender for batched delivery receipts
|
|
356
|
+
# Keep the sender registration epoch with each receipt batch. A
|
|
357
|
+
# sender id can be reused while this method is awaiting the inbox
|
|
358
|
+
# rewrite; an acknowledgement for the old runtime must not be sent to
|
|
359
|
+
# the replacement runtime.
|
|
360
|
+
receipt_by_sender: dict[tuple[str, int], list[str]] = {}
|
|
361
|
+
|
|
362
|
+
# Mutate and persist under the same recipient lock used by send().
|
|
363
|
+
lock = self._inbox_locks.setdefault(agent_id, asyncio.Lock())
|
|
364
|
+
async with lock:
|
|
365
|
+
# Re-check after waiting on the lock. unregister_agent() can run
|
|
366
|
+
# synchronously while another coroutine is persisting a message.
|
|
367
|
+
if (
|
|
368
|
+
agent_id not in self._registered
|
|
369
|
+
or self._registration_epochs.get(agent_id) != initial_epoch
|
|
370
|
+
):
|
|
371
|
+
return 0
|
|
372
|
+
registration_epoch = initial_epoch
|
|
373
|
+
live_refs_by_id = {
|
|
374
|
+
message.id: message
|
|
375
|
+
for message in self._inboxes.get(agent_id, [])
|
|
376
|
+
}
|
|
377
|
+
if self._storage_root is not None:
|
|
378
|
+
# Another bus/process may have committed messages after this
|
|
379
|
+
# registration loaded its in-memory snapshot. Refresh at the
|
|
380
|
+
# acknowledgement's linearization point so ``mark all`` (and
|
|
381
|
+
# explicit ids) cannot silently leave already-durable messages
|
|
382
|
+
# unread. A later concurrent append remains correctly outside
|
|
383
|
+
# this acknowledgement and is merged by _write_content().
|
|
384
|
+
self._refresh_registered_inbox(agent_id)
|
|
385
|
+
staged_inbox = [
|
|
386
|
+
message.model_copy(deep=True)
|
|
387
|
+
for message in self._inboxes.get(agent_id, [])
|
|
388
|
+
]
|
|
389
|
+
for msg in staged_inbox:
|
|
390
|
+
if msg.read:
|
|
391
|
+
continue
|
|
392
|
+
if message_ids is not None and msg.id not in message_ids:
|
|
393
|
+
continue
|
|
394
|
+
msg.read = True
|
|
395
|
+
count += 1
|
|
396
|
+
# A receipt is itself a message. Generating a receipt for it
|
|
397
|
+
# would make two agents acknowledge acknowledgements forever.
|
|
398
|
+
sender_epoch = self._registration_epochs.get(msg.from_agent)
|
|
399
|
+
if (
|
|
400
|
+
msg.from_agent
|
|
401
|
+
and sender_epoch is not None
|
|
402
|
+
and msg.msg_type != "delivery_receipt"
|
|
403
|
+
):
|
|
404
|
+
receipt_by_sender.setdefault(
|
|
405
|
+
(msg.from_agent, sender_epoch),
|
|
406
|
+
[],
|
|
407
|
+
).append(msg.id)
|
|
408
|
+
persisted_inbox = await self._persist_inbox(
|
|
409
|
+
agent_id,
|
|
410
|
+
inbox=staged_inbox,
|
|
411
|
+
)
|
|
412
|
+
if persisted_inbox is None:
|
|
413
|
+
# Keep the original in-memory read state and suppress receipts
|
|
414
|
+
# when the durable acknowledgement could not be committed.
|
|
415
|
+
return 0
|
|
416
|
+
if (
|
|
417
|
+
agent_id in self._registered
|
|
418
|
+
and self._registration_epochs.get(agent_id) == registration_epoch
|
|
419
|
+
):
|
|
420
|
+
# Preserve references returned by send(): historically callers
|
|
421
|
+
# observe their TeamMessage instance transition to read after a
|
|
422
|
+
# successful acknowledgement. Apply that mutation only after
|
|
423
|
+
# persistence succeeds, while also adopting externally merged
|
|
424
|
+
# messages from the committed snapshot.
|
|
425
|
+
existing_by_id = {
|
|
426
|
+
message.id: message
|
|
427
|
+
for message in self._inboxes.get(agent_id, [])
|
|
428
|
+
}
|
|
429
|
+
# Keep objects returned by send() live across the disk refresh
|
|
430
|
+
# above; callers historically observe their ``read`` field
|
|
431
|
+
# transition after a successful acknowledgement.
|
|
432
|
+
existing_by_id.update(live_refs_by_id)
|
|
433
|
+
committed_inbox: list[TeamMessage] = []
|
|
434
|
+
for persisted in persisted_inbox:
|
|
435
|
+
existing = existing_by_id.get(persisted.id)
|
|
436
|
+
if existing is None:
|
|
437
|
+
committed_inbox.append(persisted)
|
|
438
|
+
continue
|
|
439
|
+
existing.read = persisted.read
|
|
440
|
+
committed_inbox.append(existing)
|
|
441
|
+
self._inboxes[agent_id] = committed_inbox
|
|
442
|
+
|
|
443
|
+
# The recipient may have been unregistered while the executor was
|
|
444
|
+
# rewriting its file. Do not emit receipts from a stale registration.
|
|
445
|
+
if (
|
|
446
|
+
agent_id not in self._registered
|
|
447
|
+
or self._registration_epochs.get(agent_id, 0) != registration_epoch
|
|
448
|
+
):
|
|
449
|
+
if agent_id in self._registered:
|
|
450
|
+
self._refresh_registered_inbox(agent_id)
|
|
451
|
+
return count
|
|
452
|
+
|
|
453
|
+
# Send delivery receipts
|
|
454
|
+
for (sender_id, sender_epoch), read_ids in receipt_by_sender.items():
|
|
455
|
+
if (
|
|
456
|
+
sender_id not in self._registered
|
|
457
|
+
or self._registration_epochs.get(sender_id) != sender_epoch
|
|
458
|
+
):
|
|
459
|
+
continue
|
|
460
|
+
receipt_text = f"Messages read by {agent_id}: {', '.join(read_ids[:10])}"
|
|
461
|
+
if len(read_ids) > 10:
|
|
462
|
+
receipt_text += f" ... and {len(read_ids) - 10} more"
|
|
463
|
+
try:
|
|
464
|
+
await self.send(
|
|
465
|
+
from_agent=agent_id,
|
|
466
|
+
to_agent=sender_id,
|
|
467
|
+
text=receipt_text,
|
|
468
|
+
msg_type="delivery_receipt",
|
|
469
|
+
expected_epoch=sender_epoch,
|
|
470
|
+
)
|
|
471
|
+
except Exception:
|
|
472
|
+
logger.warning("Failed to send delivery receipt to %s", sender_id, exc_info=True)
|
|
473
|
+
|
|
474
|
+
return count
|
|
475
|
+
|
|
476
|
+
# ------------------------------------------------------------------
|
|
477
|
+
# Queue-based receive (for async consumers)
|
|
478
|
+
# ------------------------------------------------------------------
|
|
479
|
+
|
|
480
|
+
async def receive(self, agent_id: str, timeout: float | None = None) -> TeamMessage | None:
|
|
481
|
+
"""Wait for the next message for an agent from its queue."""
|
|
482
|
+
queue = self._queues.get(agent_id)
|
|
483
|
+
registration_event = self._registration_events.get(agent_id)
|
|
484
|
+
if queue is None or registration_event is None:
|
|
485
|
+
return None
|
|
486
|
+
queue_task = asyncio.create_task(queue.get())
|
|
487
|
+
invalidated_task = asyncio.create_task(registration_event.wait())
|
|
488
|
+
try:
|
|
489
|
+
done, pending = await asyncio.wait(
|
|
490
|
+
{queue_task, invalidated_task},
|
|
491
|
+
timeout=timeout,
|
|
492
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
493
|
+
)
|
|
494
|
+
for task in pending:
|
|
495
|
+
task.cancel()
|
|
496
|
+
if pending:
|
|
497
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
498
|
+
if not done or invalidated_task in done:
|
|
499
|
+
return None
|
|
500
|
+
if queue_task not in done:
|
|
501
|
+
return None
|
|
502
|
+
return queue_task.result().model_copy(deep=True)
|
|
503
|
+
except asyncio.CancelledError:
|
|
504
|
+
for task in (queue_task, invalidated_task):
|
|
505
|
+
if not task.done():
|
|
506
|
+
task.cancel()
|
|
507
|
+
await asyncio.gather(queue_task, invalidated_task, return_exceptions=True)
|
|
508
|
+
raise
|
|
509
|
+
|
|
510
|
+
def try_receive(self, agent_id: str) -> TeamMessage | None:
|
|
511
|
+
"""Non-blocking receive. Returns None if no message is available."""
|
|
512
|
+
queue = self._queues.get(agent_id)
|
|
513
|
+
if queue is None:
|
|
514
|
+
return None
|
|
515
|
+
try:
|
|
516
|
+
return queue.get_nowait().model_copy(deep=True)
|
|
517
|
+
except asyncio.QueueEmpty:
|
|
518
|
+
return None
|
|
519
|
+
|
|
520
|
+
# ------------------------------------------------------------------
|
|
521
|
+
# Persistence
|
|
522
|
+
# ------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
def _inbox_path(self, agent_id: str) -> Path | None:
|
|
525
|
+
"""Return the JSONL inbox path for an agent, or None if no storage root."""
|
|
526
|
+
if self._storage_root is None:
|
|
527
|
+
return None
|
|
528
|
+
self._validate_component(agent_id, "agent id")
|
|
529
|
+
return self._storage_root / self._team_name / f"{agent_id}.jsonl"
|
|
530
|
+
|
|
531
|
+
async def _persist_message(self, agent_id: str, msg: TeamMessage) -> bool:
|
|
532
|
+
"""Append a single message to the agent's JSONL inbox file (O(1))."""
|
|
533
|
+
path = self._inbox_path(agent_id)
|
|
534
|
+
if path is None:
|
|
535
|
+
return True
|
|
536
|
+
try:
|
|
537
|
+
line = msg.model_dump_json() + "\n"
|
|
538
|
+
# Use append mode for O(1) writes
|
|
539
|
+
loop = asyncio.get_event_loop()
|
|
540
|
+
operation = loop.run_in_executor(None, self._append_line, path, line)
|
|
541
|
+
try:
|
|
542
|
+
await asyncio.shield(operation)
|
|
543
|
+
except asyncio.CancelledError:
|
|
544
|
+
# Executor threads cannot be cancelled safely. Drain the
|
|
545
|
+
# write before propagating cancellation so shutdown cannot
|
|
546
|
+
# delete an inbox while an append is still in flight.
|
|
547
|
+
await asyncio.shield(operation)
|
|
548
|
+
raise
|
|
549
|
+
except Exception:
|
|
550
|
+
logger.warning("Failed to persist message for %s", agent_id, exc_info=True)
|
|
551
|
+
return False
|
|
552
|
+
return True
|
|
553
|
+
|
|
554
|
+
@staticmethod
|
|
555
|
+
def _append_line(path: Path, line: str) -> None:
|
|
556
|
+
# ``path`` is <storage_root>/<team>/<agent>.jsonl. Acquire the team
|
|
557
|
+
# lock before creating the directory; otherwise delete_team_inboxes()
|
|
558
|
+
# could remove it and this late append would recreate stale state.
|
|
559
|
+
storage_root = path.parent.parent
|
|
560
|
+
team_name = path.parent.name
|
|
561
|
+
with _team_file_lock(storage_root, team_name):
|
|
562
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
563
|
+
lock_path = path.with_name(f".{path.name}.lock")
|
|
564
|
+
with file_lock(lock_path):
|
|
565
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
566
|
+
f.write(line)
|
|
567
|
+
f.flush()
|
|
568
|
+
os.fsync(f.fileno())
|
|
569
|
+
|
|
570
|
+
async def _persist_inbox(
|
|
571
|
+
self,
|
|
572
|
+
agent_id: str,
|
|
573
|
+
*,
|
|
574
|
+
inbox: list[TeamMessage] | None = None,
|
|
575
|
+
) -> list[TeamMessage] | None:
|
|
576
|
+
"""Persist and return the committed inbox, or ``None`` on failure."""
|
|
577
|
+
desired = inbox if inbox is not None else self._inboxes.get(agent_id, [])
|
|
578
|
+
path = self._inbox_path(agent_id)
|
|
579
|
+
if path is None:
|
|
580
|
+
return [message.model_copy(deep=True) for message in desired]
|
|
581
|
+
try:
|
|
582
|
+
lines = [msg.model_dump_json() + "\n" for msg in desired]
|
|
583
|
+
content = "".join(lines)
|
|
584
|
+
loop = asyncio.get_event_loop()
|
|
585
|
+
operation = loop.run_in_executor(None, self._write_content, path, content)
|
|
586
|
+
try:
|
|
587
|
+
committed = await asyncio.shield(operation)
|
|
588
|
+
except asyncio.CancelledError:
|
|
589
|
+
committed = await asyncio.shield(operation)
|
|
590
|
+
raise
|
|
591
|
+
return self._parse_inbox_content(agent_id, committed)
|
|
592
|
+
except Exception:
|
|
593
|
+
logger.warning("Failed to persist inbox for %s", agent_id, exc_info=True)
|
|
594
|
+
return None
|
|
595
|
+
|
|
596
|
+
@staticmethod
|
|
597
|
+
def _write_content(path: Path, content: str) -> str:
|
|
598
|
+
storage_root = path.parent.parent
|
|
599
|
+
team_name = path.parent.name
|
|
600
|
+
with _team_file_lock(storage_root, team_name):
|
|
601
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
602
|
+
lock_path = path.with_name(f".{path.name}.lock")
|
|
603
|
+
with file_lock(lock_path):
|
|
604
|
+
# Merge lines appended by another bus/process since this
|
|
605
|
+
# instance loaded its inbox. A plain rewrite would otherwise
|
|
606
|
+
# erase those messages even though append and rewrite each
|
|
607
|
+
# held a file lock.
|
|
608
|
+
current = (
|
|
609
|
+
path.read_text(encoding="utf-8")
|
|
610
|
+
if path.exists()
|
|
611
|
+
else ""
|
|
612
|
+
)
|
|
613
|
+
merged = TeamMessageBus._merge_jsonl(current, content)
|
|
614
|
+
|
|
615
|
+
# Replace atomically so a crash or concurrent reader never
|
|
616
|
+
# observes a half-written JSONL file.
|
|
617
|
+
tmp_path: str | None = None
|
|
618
|
+
try:
|
|
619
|
+
with tempfile.NamedTemporaryFile(
|
|
620
|
+
mode="w",
|
|
621
|
+
encoding="utf-8",
|
|
622
|
+
dir=path.parent,
|
|
623
|
+
prefix=f".{path.name}.",
|
|
624
|
+
suffix=".tmp",
|
|
625
|
+
delete=False,
|
|
626
|
+
) as f:
|
|
627
|
+
tmp_path = f.name
|
|
628
|
+
f.write(merged)
|
|
629
|
+
f.flush()
|
|
630
|
+
os.fsync(f.fileno())
|
|
631
|
+
os.replace(tmp_path, path)
|
|
632
|
+
tmp_path = None
|
|
633
|
+
finally:
|
|
634
|
+
if tmp_path is not None:
|
|
635
|
+
try:
|
|
636
|
+
os.unlink(tmp_path)
|
|
637
|
+
except OSError:
|
|
638
|
+
pass
|
|
639
|
+
return merged
|
|
640
|
+
|
|
641
|
+
@staticmethod
|
|
642
|
+
def _merge_jsonl(current: str, desired: str) -> str:
|
|
643
|
+
"""Merge two inbox snapshots by message id while preserving order."""
|
|
644
|
+
desired_by_id: dict[str, str] = {}
|
|
645
|
+
desired_unkeyed: list[str] = []
|
|
646
|
+
for raw in desired.splitlines():
|
|
647
|
+
if not raw.strip():
|
|
648
|
+
continue
|
|
649
|
+
try:
|
|
650
|
+
message_id = str(json.loads(raw).get("id", ""))
|
|
651
|
+
except Exception:
|
|
652
|
+
message_id = ""
|
|
653
|
+
if message_id:
|
|
654
|
+
desired_by_id[message_id] = raw
|
|
655
|
+
else:
|
|
656
|
+
desired_unkeyed.append(raw)
|
|
657
|
+
|
|
658
|
+
merged: list[str] = []
|
|
659
|
+
for raw in current.splitlines():
|
|
660
|
+
if not raw.strip():
|
|
661
|
+
continue
|
|
662
|
+
try:
|
|
663
|
+
message_id = str(json.loads(raw).get("id", ""))
|
|
664
|
+
except Exception:
|
|
665
|
+
message_id = ""
|
|
666
|
+
if message_id and message_id in desired_by_id:
|
|
667
|
+
merged.append(
|
|
668
|
+
TeamMessageBus._merge_json_line(
|
|
669
|
+
raw,
|
|
670
|
+
desired_by_id.pop(message_id),
|
|
671
|
+
)
|
|
672
|
+
)
|
|
673
|
+
else:
|
|
674
|
+
merged.append(raw)
|
|
675
|
+
merged.extend(desired_by_id.values())
|
|
676
|
+
merged.extend(desired_unkeyed)
|
|
677
|
+
return "".join(f"{line}\n" for line in merged)
|
|
678
|
+
|
|
679
|
+
@staticmethod
|
|
680
|
+
def _merge_json_line(current: str, desired: str) -> str:
|
|
681
|
+
"""Merge a line while keeping the monotonic read acknowledgement."""
|
|
682
|
+
try:
|
|
683
|
+
current_obj = json.loads(current)
|
|
684
|
+
desired_obj = json.loads(desired)
|
|
685
|
+
if current_obj.get("read"):
|
|
686
|
+
desired_obj["read"] = True
|
|
687
|
+
return json.dumps(desired_obj, ensure_ascii=False, separators=(",", ":"))
|
|
688
|
+
except Exception:
|
|
689
|
+
return desired
|
|
690
|
+
|
|
691
|
+
def load_inbox_from_disk(self, agent_id: str) -> list[TeamMessage]:
|
|
692
|
+
"""Load messages from an agent's JSONL inbox file on disk."""
|
|
693
|
+
self._validate_component(agent_id, "agent id")
|
|
694
|
+
path = self._inbox_path(agent_id)
|
|
695
|
+
if path is None or not path.exists():
|
|
696
|
+
self._inboxes[agent_id] = []
|
|
697
|
+
return []
|
|
698
|
+
messages: list[TeamMessage] = []
|
|
699
|
+
try:
|
|
700
|
+
# Read under the same sidecar lock used by append/rewrite. Without
|
|
701
|
+
# this, a second process can observe a partially appended JSON line,
|
|
702
|
+
# skip it as invalid, and later rewrite it away permanently.
|
|
703
|
+
content = self._read_content(path)
|
|
704
|
+
messages = self._parse_inbox_content(agent_id, content)
|
|
705
|
+
except OSError:
|
|
706
|
+
logger.warning("Failed to read inbox for %s", agent_id, exc_info=True)
|
|
707
|
+
self._inboxes[agent_id] = messages
|
|
708
|
+
return messages
|
|
709
|
+
|
|
710
|
+
@staticmethod
|
|
711
|
+
def _parse_inbox_content(agent_id: str, content: str) -> list[TeamMessage]:
|
|
712
|
+
"""Parse a persisted inbox while tolerating isolated corrupt lines."""
|
|
713
|
+
messages: list[TeamMessage] = []
|
|
714
|
+
for line in content.splitlines():
|
|
715
|
+
line = line.strip()
|
|
716
|
+
if not line:
|
|
717
|
+
continue
|
|
718
|
+
try:
|
|
719
|
+
messages.append(TeamMessage.model_validate_json(line))
|
|
720
|
+
except Exception:
|
|
721
|
+
logger.debug(
|
|
722
|
+
"Skipping invalid inbox line for %s",
|
|
723
|
+
agent_id,
|
|
724
|
+
exc_info=True,
|
|
725
|
+
)
|
|
726
|
+
return messages
|
|
727
|
+
|
|
728
|
+
def _refresh_registered_inbox(self, agent_id: str) -> None:
|
|
729
|
+
"""Merge durable messages into a replacement registration.
|
|
730
|
+
|
|
731
|
+
A stale append/rewrite may finish after ``register_agent`` has loaded
|
|
732
|
+
the file. Refresh synchronously at that boundary and enqueue any new
|
|
733
|
+
unread records, while preserving read acknowledgements already held in
|
|
734
|
+
memory. This never invokes the old session's injection callback.
|
|
735
|
+
"""
|
|
736
|
+
if agent_id not in self._registered:
|
|
737
|
+
return
|
|
738
|
+
queue = self._queues.get(agent_id)
|
|
739
|
+
queued_before_refresh = {
|
|
740
|
+
getattr(message, "id", "")
|
|
741
|
+
for message in list(getattr(queue, "_queue", ()))
|
|
742
|
+
} if queue is not None else set()
|
|
743
|
+
existing = [
|
|
744
|
+
message.model_copy(deep=True)
|
|
745
|
+
for message in self._inboxes.get(agent_id, [])
|
|
746
|
+
]
|
|
747
|
+
disk = self.load_inbox_from_disk(agent_id)
|
|
748
|
+
merged_by_id: dict[str, TeamMessage] = {}
|
|
749
|
+
order: list[str] = []
|
|
750
|
+
for message in [*existing, *disk]:
|
|
751
|
+
if message.id not in merged_by_id:
|
|
752
|
+
merged_by_id[message.id] = message.model_copy(deep=True)
|
|
753
|
+
order.append(message.id)
|
|
754
|
+
continue
|
|
755
|
+
current = merged_by_id[message.id]
|
|
756
|
+
merged_by_id[message.id] = message.model_copy(deep=True)
|
|
757
|
+
merged_by_id[message.id].read = current.read or message.read
|
|
758
|
+
merged = [merged_by_id[message_id] for message_id in order]
|
|
759
|
+
self._inboxes[agent_id] = merged
|
|
760
|
+
|
|
761
|
+
if queue is None:
|
|
762
|
+
return
|
|
763
|
+
queued_ids = {
|
|
764
|
+
getattr(message, "id", "")
|
|
765
|
+
for message in list(getattr(queue, "_queue", ()))
|
|
766
|
+
} | queued_before_refresh
|
|
767
|
+
for message in merged:
|
|
768
|
+
if message.read or message.id in queued_ids:
|
|
769
|
+
continue
|
|
770
|
+
if queue.full():
|
|
771
|
+
break
|
|
772
|
+
queue.put_nowait(message.model_copy(deep=True))
|
|
773
|
+
queued_ids.add(message.id)
|
|
774
|
+
|
|
775
|
+
@staticmethod
|
|
776
|
+
def _read_content(path: Path) -> str:
|
|
777
|
+
"""Read a JSONL file while sharing its process/POSIX lock."""
|
|
778
|
+
storage_root = path.parent.parent
|
|
779
|
+
team_name = path.parent.name
|
|
780
|
+
with _team_file_lock(storage_root, team_name, shared=True):
|
|
781
|
+
lock_path = path.with_name(f".{path.name}.lock")
|
|
782
|
+
with file_lock(lock_path, exclusive=False):
|
|
783
|
+
return path.read_text(encoding="utf-8")
|
|
784
|
+
|
|
785
|
+
# ------------------------------------------------------------------
|
|
786
|
+
# Cleanup
|
|
787
|
+
# ------------------------------------------------------------------
|
|
788
|
+
|
|
789
|
+
async def delete_team_inboxes(self) -> None:
|
|
790
|
+
"""Delete all inbox files for this team."""
|
|
791
|
+
if self._storage_root is None:
|
|
792
|
+
return
|
|
793
|
+
team_dir = self._storage_root / self._team_name
|
|
794
|
+
loop = asyncio.get_event_loop()
|
|
795
|
+
operation = loop.run_in_executor(None, self._remove_dir, team_dir)
|
|
796
|
+
try:
|
|
797
|
+
await asyncio.shield(operation)
|
|
798
|
+
except asyncio.CancelledError:
|
|
799
|
+
# rmtree runs in a thread and cannot be cancelled safely. Drain
|
|
800
|
+
# it before propagating cancellation so a caller cannot reuse the
|
|
801
|
+
# team directory while deletion is still in progress.
|
|
802
|
+
await asyncio.shield(operation)
|
|
803
|
+
raise
|
|
804
|
+
|
|
805
|
+
def _remove_dir(self, path: Path) -> None:
|
|
806
|
+
import shutil
|
|
807
|
+
with _team_file_lock(self._storage_root or path.parent, self._team_name):
|
|
808
|
+
if path.exists():
|
|
809
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
810
|
+
|
|
811
|
+
@property
|
|
812
|
+
def team_name(self) -> str:
|
|
813
|
+
return self._team_name
|
|
814
|
+
|
|
815
|
+
@property
|
|
816
|
+
def config(self) -> TeamConfig:
|
|
817
|
+
return self._config
|
|
818
|
+
|
|
819
|
+
@property
|
|
820
|
+
def registered_agents(self) -> set[str]:
|
|
821
|
+
return set(self._registered)
|