mcp-supervisor-workbench 0.1.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.
- mcp_supervisor_workbench/__init__.py +36 -0
- mcp_supervisor_workbench/__main__.py +5 -0
- mcp_supervisor_workbench/agent_registry.py +599 -0
- mcp_supervisor_workbench/config.py +116 -0
- mcp_supervisor_workbench/conversation_store.py +657 -0
- mcp_supervisor_workbench/error_middleware.py +80 -0
- mcp_supervisor_workbench/image_store.py +289 -0
- mcp_supervisor_workbench/log_batcher.py +111 -0
- mcp_supervisor_workbench/message_delivery.py +104 -0
- mcp_supervisor_workbench/message_queue.py +136 -0
- mcp_supervisor_workbench/pid_manager.py +106 -0
- mcp_supervisor_workbench/project_queue.py +679 -0
- mcp_supervisor_workbench/registry.py +150 -0
- mcp_supervisor_workbench/routes/__init__.py +40 -0
- mcp_supervisor_workbench/routes/agent_routes.py +437 -0
- mcp_supervisor_workbench/routes/image_routes.py +149 -0
- mcp_supervisor_workbench/routes/log_routes.py +414 -0
- mcp_supervisor_workbench/routes/message_routes.py +337 -0
- mcp_supervisor_workbench/routes/project_routes.py +281 -0
- mcp_supervisor_workbench/routes/queue_routes.py +333 -0
- mcp_supervisor_workbench/routes/session_routes.py +300 -0
- mcp_supervisor_workbench/routes/skill_distillation_routes.py +303 -0
- mcp_supervisor_workbench/routes/supervisor_routes.py +713 -0
- mcp_supervisor_workbench/routes/system_routes.py +60 -0
- mcp_supervisor_workbench/routes/ws_routes.py +443 -0
- mcp_supervisor_workbench/server.py +891 -0
- mcp_supervisor_workbench/skill_distillation/__init__.py +72 -0
- mcp_supervisor_workbench/skill_distillation/draft_store.py +257 -0
- mcp_supervisor_workbench/skill_distillation/manager.py +1113 -0
- mcp_supervisor_workbench/skill_distillation/models.py +237 -0
- mcp_supervisor_workbench/skill_distillation/output_parser.py +377 -0
- mcp_supervisor_workbench/skill_distillation/prompt_builder.py +434 -0
- mcp_supervisor_workbench/skill_distillation/runner.py +359 -0
- mcp_supervisor_workbench/skill_distillation/skill_publisher.py +153 -0
- mcp_supervisor_workbench/static/assets/index-B4ZenE_B.css +1 -0
- mcp_supervisor_workbench/static/assets/index-CKZ3WxH_.js +26 -0
- mcp_supervisor_workbench/static/favicon.svg +1 -0
- mcp_supervisor_workbench/static/icons.svg +24 -0
- mcp_supervisor_workbench/static/index.html +37 -0
- mcp_supervisor_workbench/supervisor/__init__.py +9 -0
- mcp_supervisor_workbench/supervisor/agent_backend.py +80 -0
- mcp_supervisor_workbench/supervisor/backend_registry.py +66 -0
- mcp_supervisor_workbench/supervisor/backends/__init__.py +1 -0
- mcp_supervisor_workbench/supervisor/backends/noop_backend.py +40 -0
- mcp_supervisor_workbench/supervisor/backends/qoder_backend.py +157 -0
- mcp_supervisor_workbench/supervisor/models.py +75 -0
- mcp_supervisor_workbench/supervisor/reply_generator.py +635 -0
- mcp_supervisor_workbench/supervisor/session_manager.py +139 -0
- mcp_supervisor_workbench/supervisor/skill_editor.py +184 -0
- mcp_supervisor_workbench/supervisor/skill_manager.py +323 -0
- mcp_supervisor_workbench/supervisor/subsystem.py +139 -0
- mcp_supervisor_workbench/supervisor/supervisor_config.py +169 -0
- mcp_supervisor_workbench/supervisor/task_manager.py +555 -0
- mcp_supervisor_workbench/supervisor/worker_agent.py +367 -0
- mcp_supervisor_workbench/unread_tracker.py +176 -0
- mcp_supervisor_workbench/worker_conversation.py +214 -0
- mcp_supervisor_workbench/ws_manager.py +326 -0
- mcp_supervisor_workbench-0.1.0.dist-info/METADATA +10 -0
- mcp_supervisor_workbench-0.1.0.dist-info/RECORD +61 -0
- mcp_supervisor_workbench-0.1.0.dist-info/WHEEL +4 -0
- mcp_supervisor_workbench-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP AI Supervisor Workbench — 中央控制台
|
|
3
|
+
|
|
4
|
+
独立进程,提供所有 MCP Agent 的统一管理界面。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .config import WorkbenchConfig
|
|
8
|
+
from .error_middleware import error_response
|
|
9
|
+
from . import pid_manager
|
|
10
|
+
from .server import AppState, WorkbenchServer, create_app
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main() -> None:
|
|
14
|
+
"""CLI entry point for the workbench server."""
|
|
15
|
+
import argparse
|
|
16
|
+
import logging
|
|
17
|
+
|
|
18
|
+
logging.basicConfig(
|
|
19
|
+
level=logging.INFO,
|
|
20
|
+
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
parser = argparse.ArgumentParser(description="MCP AI Supervisor Workbench")
|
|
24
|
+
parser.add_argument("--host", default="127.0.0.1", help="Host to bind (default: 127.0.0.1)")
|
|
25
|
+
parser.add_argument("--port", type=int, default=None, help="Port (default: 9766 or MCP_WORKBENCH_PORT)")
|
|
26
|
+
args = parser.parse_args()
|
|
27
|
+
|
|
28
|
+
server = WorkbenchServer(host=args.host, port=args.port)
|
|
29
|
+
print(f"\n Workbench: http://{server.host}:{server.port}")
|
|
30
|
+
server.run()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"AppState", "WorkbenchConfig", "WorkbenchServer",
|
|
35
|
+
"create_app", "error_response", "main",
|
|
36
|
+
]
|
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentRegistry — Workbench 核心模块:Agent 生命周期管理
|
|
3
|
+
|
|
4
|
+
管理所有已注册 Agent 的注册、心跳、状态变更和超时清理。
|
|
5
|
+
使用双索引(agent_id + project_directory)提供 O(1) 查询。
|
|
6
|
+
|
|
7
|
+
迁移自 registry.py,主要变更:
|
|
8
|
+
- threading.Lock → asyncio.Lock
|
|
9
|
+
- 新增 _by_project 双索引
|
|
10
|
+
- AgentStatus 枚举取代裸字符串
|
|
11
|
+
- 路径标准化
|
|
12
|
+
- 广播回调机制
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import enum
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import time
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Callable, Coroutine
|
|
24
|
+
|
|
25
|
+
from pydantic import BaseModel, Field
|
|
26
|
+
|
|
27
|
+
from mcp_supervisor_core.log_writer import get_logger as _get_struct_logger
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# Workbench 进程的结构化日志
|
|
32
|
+
_reg_logger = _get_struct_logger("workbench")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AgentStatus(str, enum.Enum):
|
|
36
|
+
IDLE = "idle"
|
|
37
|
+
WAITING = "waiting"
|
|
38
|
+
ACTIVE = "active"
|
|
39
|
+
OFFLINE = "offline"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class AgentInfo:
|
|
44
|
+
agent_id: str
|
|
45
|
+
project_directory: str
|
|
46
|
+
web_url: str
|
|
47
|
+
port: int = 0
|
|
48
|
+
pid: int = 0
|
|
49
|
+
mode: str = "manual"
|
|
50
|
+
status: AgentStatus = AgentStatus.IDLE
|
|
51
|
+
registered_at: float = field(default_factory=time.time)
|
|
52
|
+
last_heartbeat: float = field(default_factory=time.time)
|
|
53
|
+
last_message_at: float | None = None
|
|
54
|
+
session_id: str | None = None
|
|
55
|
+
session_summary: str = ""
|
|
56
|
+
original_task: str | None = None
|
|
57
|
+
cursor_window_title: str = ""
|
|
58
|
+
vscode_pid: str = ""
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict[str, Any]:
|
|
61
|
+
return {
|
|
62
|
+
"agent_id": self.agent_id,
|
|
63
|
+
"project_directory": self.project_directory,
|
|
64
|
+
"display_name": os.path.basename(self.project_directory),
|
|
65
|
+
"web_url": self.web_url,
|
|
66
|
+
"port": self.port,
|
|
67
|
+
"pid": self.pid,
|
|
68
|
+
"mode": self.mode,
|
|
69
|
+
"status": self.status.value,
|
|
70
|
+
"registered_at": self.registered_at,
|
|
71
|
+
"last_heartbeat": self.last_heartbeat,
|
|
72
|
+
"last_message_at": self.last_message_at,
|
|
73
|
+
"session_id": self.session_id,
|
|
74
|
+
"session_summary": self.session_summary,
|
|
75
|
+
"original_task": self.original_task,
|
|
76
|
+
"cursor_window_title": self.cursor_window_title,
|
|
77
|
+
"vscode_pid": self.vscode_pid,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class AgentRegisterRequest(BaseModel):
|
|
82
|
+
"""Pydantic model for agent registration API input validation."""
|
|
83
|
+
agent_id: str = Field(..., description="Agent UUID")
|
|
84
|
+
project_directory: str = Field(..., description="Absolute path to project")
|
|
85
|
+
web_url: str = Field("", description="Agent Web Server URL (optional, can be set later via heartbeat)")
|
|
86
|
+
port: int = Field(0, description="Agent Web Server port")
|
|
87
|
+
pid: int = Field(0, description="MCP process PID")
|
|
88
|
+
mode: str = Field("manual", description="semi or manual")
|
|
89
|
+
status: str = Field("idle", description="Initial agent status")
|
|
90
|
+
session_id: str | None = Field(None, description="Current session ID")
|
|
91
|
+
session_summary: str = Field("", description="Current session summary")
|
|
92
|
+
original_task: str | None = Field(None, description="Current task description")
|
|
93
|
+
cursor_window_title: str = Field("", description="Cursor window title")
|
|
94
|
+
vscode_pid: str = Field("", description="VSCode process PID")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
BroadcastCallback = Callable[[dict[str, Any]], Coroutine[Any, Any, None]]
|
|
98
|
+
AgentWaitingCallback = Callable[[AgentInfo], Coroutine[Any, Any, None]]
|
|
99
|
+
|
|
100
|
+
HEARTBEAT_TIMEOUT = 90 # Must match WorkbenchConfig.heartbeat_timeout
|
|
101
|
+
STALE_RETENTION = 86400 # 24 hours: keep offline agents for display
|
|
102
|
+
CHECK_INTERVAL = 30
|
|
103
|
+
# active 状态下超过该时长无消息更新,则自动降为 idle。
|
|
104
|
+
#
|
|
105
|
+
# 背景:当前 MCP client 只在 Qoder 主动调用时上报 status;Qoder 完成一轮工作后
|
|
106
|
+
# 不会主动回报 idle,导致 UI 上“AI 已收到消息,正在工作中”永久卡住。
|
|
107
|
+
# 内存中添加时长兜底,将长时间 active 且无消息的 agent 主动降为 idle。
|
|
108
|
+
# waiting 状态 = agent 真的在 interactive_feedback 等用户回复,不属于“伪忙”,不处理。
|
|
109
|
+
ACTIVE_IDLE_TIMEOUT = 60
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
from mcp_supervisor_core.paths import normalize_path as _normalize_path
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class AgentRegistry:
|
|
116
|
+
"""Async-safe in-memory registry with dual indexing (agent_id + project_directory)."""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
heartbeat_timeout: float = HEARTBEAT_TIMEOUT,
|
|
121
|
+
stale_retention: float = STALE_RETENTION,
|
|
122
|
+
check_interval: float = CHECK_INTERVAL,
|
|
123
|
+
active_idle_timeout: float = ACTIVE_IDLE_TIMEOUT,
|
|
124
|
+
) -> None:
|
|
125
|
+
self._agents: dict[str, AgentInfo] = {}
|
|
126
|
+
self._by_project: dict[str, str] = {} # normalized_path → agent_id
|
|
127
|
+
self._replaced_agents: dict[str, float] = {} # agent_id → replaced_at (黑名单)
|
|
128
|
+
self._lock = asyncio.Lock()
|
|
129
|
+
|
|
130
|
+
self._heartbeat_timeout = heartbeat_timeout
|
|
131
|
+
self._stale_retention = stale_retention
|
|
132
|
+
self._check_interval = check_interval
|
|
133
|
+
self._active_idle_timeout = active_idle_timeout
|
|
134
|
+
|
|
135
|
+
self._broadcast_callback: BroadcastCallback | None = None
|
|
136
|
+
self._on_agent_waiting: AgentWaitingCallback | None = None
|
|
137
|
+
self._check_task: asyncio.Task[None] | None = None
|
|
138
|
+
|
|
139
|
+
# ── Lifecycle ────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
def start(self) -> None:
|
|
142
|
+
"""Start background heartbeat check loop. Call after event loop is running."""
|
|
143
|
+
if self._check_task is None or self._check_task.done():
|
|
144
|
+
self._check_task = asyncio.create_task(self._heartbeat_check_loop())
|
|
145
|
+
|
|
146
|
+
def stop(self) -> None:
|
|
147
|
+
"""Cancel background tasks."""
|
|
148
|
+
if self._check_task and not self._check_task.done():
|
|
149
|
+
self._check_task.cancel()
|
|
150
|
+
self._check_task = None
|
|
151
|
+
|
|
152
|
+
# ── Callback Registration ────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
def set_broadcast_callback(self, callback: BroadcastCallback) -> None:
|
|
155
|
+
self._broadcast_callback = callback
|
|
156
|
+
|
|
157
|
+
def set_on_agent_waiting(self, callback: AgentWaitingCallback) -> None:
|
|
158
|
+
self._on_agent_waiting = callback
|
|
159
|
+
|
|
160
|
+
# ── Core CRUD ────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
async def register_from_request(self, request: AgentRegisterRequest) -> AgentInfo:
|
|
163
|
+
"""Register from a validated Pydantic request model (used by API routes)."""
|
|
164
|
+
return await self.register(
|
|
165
|
+
agent_id=request.agent_id,
|
|
166
|
+
project_directory=request.project_directory,
|
|
167
|
+
web_url=request.web_url,
|
|
168
|
+
port=request.port,
|
|
169
|
+
pid=request.pid,
|
|
170
|
+
mode=request.mode,
|
|
171
|
+
status=request.status,
|
|
172
|
+
session_id=request.session_id,
|
|
173
|
+
session_summary=request.session_summary,
|
|
174
|
+
original_task=request.original_task,
|
|
175
|
+
cursor_window_title=request.cursor_window_title,
|
|
176
|
+
vscode_pid=request.vscode_pid,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
async def register(
|
|
180
|
+
self,
|
|
181
|
+
agent_id: str,
|
|
182
|
+
project_directory: str,
|
|
183
|
+
web_url: str,
|
|
184
|
+
*,
|
|
185
|
+
port: int = 0,
|
|
186
|
+
pid: int = 0,
|
|
187
|
+
mode: str = "manual",
|
|
188
|
+
status: str = "idle",
|
|
189
|
+
session_id: str | None = None,
|
|
190
|
+
session_summary: str = "",
|
|
191
|
+
original_task: str | None = None,
|
|
192
|
+
cursor_window_title: str = "",
|
|
193
|
+
vscode_pid: str = "",
|
|
194
|
+
) -> AgentInfo:
|
|
195
|
+
"""Register a new Agent or replace existing one for the same project.
|
|
196
|
+
|
|
197
|
+
被替换的 agent_id 会加入黑名单,防止旧进程心跳 404 后
|
|
198
|
+
通过 re-register 抢占新进程的注册。
|
|
199
|
+
"""
|
|
200
|
+
norm_path = _normalize_path(project_directory)
|
|
201
|
+
|
|
202
|
+
async with self._lock:
|
|
203
|
+
# 检查是否在黑名单中(最近被替换的旧 agent_id)
|
|
204
|
+
if agent_id in self._replaced_agents:
|
|
205
|
+
replaced_at = self._replaced_agents[agent_id]
|
|
206
|
+
if time.time() - replaced_at < self._heartbeat_timeout * 2:
|
|
207
|
+
_reg_logger.info(
|
|
208
|
+
f"rejected={agent_id[:8]} project={os.path.basename(norm_path)} "
|
|
209
|
+
f"reason=recently_replaced",
|
|
210
|
+
"[REG_REJECT] 拒绝已被替换的旧 Agent 重新注册",
|
|
211
|
+
)
|
|
212
|
+
return AgentInfo(
|
|
213
|
+
agent_id=agent_id, project_directory=norm_path,
|
|
214
|
+
web_url=web_url, status=AgentStatus.OFFLINE,
|
|
215
|
+
)
|
|
216
|
+
else:
|
|
217
|
+
del self._replaced_agents[agent_id]
|
|
218
|
+
|
|
219
|
+
old_agent_id = self._by_project.get(norm_path)
|
|
220
|
+
if old_agent_id and old_agent_id != agent_id:
|
|
221
|
+
old_info = self._agents.get(old_agent_id)
|
|
222
|
+
if old_info:
|
|
223
|
+
try:
|
|
224
|
+
new_status = AgentStatus(status)
|
|
225
|
+
except ValueError:
|
|
226
|
+
new_status = AgentStatus.IDLE
|
|
227
|
+
|
|
228
|
+
if (new_status == AgentStatus.OFFLINE
|
|
229
|
+
and old_info.status in (AgentStatus.ACTIVE, AgentStatus.WAITING, AgentStatus.IDLE)):
|
|
230
|
+
_reg_logger.info(
|
|
231
|
+
f"rejected_agent={agent_id[:8]} existing_agent={old_agent_id[:8]} "
|
|
232
|
+
f"project={os.path.basename(norm_path)} "
|
|
233
|
+
f"new_status=offline existing_status={old_info.status.value} "
|
|
234
|
+
f"new_web_url={web_url} existing_web_url={old_info.web_url}",
|
|
235
|
+
"[REG_REJECT_OFFLINE_OVERRIDE] 拒绝 offline 注册覆盖在线 Agent",
|
|
236
|
+
)
|
|
237
|
+
return old_info
|
|
238
|
+
|
|
239
|
+
self._agents.pop(old_agent_id, None)
|
|
240
|
+
self._replaced_agents[old_agent_id] = time.time()
|
|
241
|
+
logger.info(
|
|
242
|
+
"Replacing agent %s with %s for project %s",
|
|
243
|
+
old_agent_id, agent_id, norm_path,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if agent_id in self._agents:
|
|
247
|
+
old_proj = _normalize_path(self._agents[agent_id].project_directory)
|
|
248
|
+
self._by_project.pop(old_proj, None)
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
initial_status = AgentStatus(status)
|
|
252
|
+
except ValueError:
|
|
253
|
+
initial_status = AgentStatus.IDLE
|
|
254
|
+
|
|
255
|
+
now = time.time()
|
|
256
|
+
info = AgentInfo(
|
|
257
|
+
agent_id=agent_id,
|
|
258
|
+
project_directory=norm_path,
|
|
259
|
+
web_url=web_url,
|
|
260
|
+
port=port,
|
|
261
|
+
pid=pid,
|
|
262
|
+
mode=mode,
|
|
263
|
+
status=initial_status,
|
|
264
|
+
registered_at=now,
|
|
265
|
+
last_heartbeat=now,
|
|
266
|
+
session_id=session_id,
|
|
267
|
+
session_summary=session_summary,
|
|
268
|
+
original_task=original_task,
|
|
269
|
+
cursor_window_title=cursor_window_title,
|
|
270
|
+
vscode_pid=vscode_pid,
|
|
271
|
+
)
|
|
272
|
+
self._agents[agent_id] = info
|
|
273
|
+
self._by_project[norm_path] = agent_id
|
|
274
|
+
|
|
275
|
+
_reg_logger.info(
|
|
276
|
+
f"agent_id={agent_id[:8]} project={os.path.basename(norm_path)} pid={pid} port={port}",
|
|
277
|
+
"[REG_REGISTER] Agent 注册",
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
await self._broadcast(
|
|
281
|
+
{
|
|
282
|
+
"type": "project_online",
|
|
283
|
+
"project": info.to_dict(),
|
|
284
|
+
}
|
|
285
|
+
)
|
|
286
|
+
await self._broadcast(
|
|
287
|
+
{
|
|
288
|
+
"type": "agent_registered",
|
|
289
|
+
"agent_id": agent_id,
|
|
290
|
+
"project_directory": norm_path,
|
|
291
|
+
"status": initial_status.value,
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if initial_status == AgentStatus.WAITING and self._on_agent_waiting:
|
|
296
|
+
try:
|
|
297
|
+
await self._on_agent_waiting(info)
|
|
298
|
+
except Exception:
|
|
299
|
+
logger.exception("on_agent_waiting callback failed during register")
|
|
300
|
+
|
|
301
|
+
return info
|
|
302
|
+
|
|
303
|
+
async def unregister(self, agent_id: str) -> bool:
|
|
304
|
+
"""Remove an agent from the registry. Returns True if found."""
|
|
305
|
+
async with self._lock:
|
|
306
|
+
info = self._agents.pop(agent_id, None)
|
|
307
|
+
if info is None:
|
|
308
|
+
return False
|
|
309
|
+
norm = _normalize_path(info.project_directory)
|
|
310
|
+
if self._by_project.get(norm) == agent_id:
|
|
311
|
+
del self._by_project[norm]
|
|
312
|
+
|
|
313
|
+
# [REG_DEREGISTER] 记录 Agent 注销
|
|
314
|
+
_reg_logger.info(
|
|
315
|
+
f"agent_id={agent_id[:8]} project={os.path.basename(info.project_directory)}",
|
|
316
|
+
"[REG_DEREGISTER] Agent 注销",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# 广播 project_offline(React 前端使用)
|
|
320
|
+
await self._broadcast(
|
|
321
|
+
{
|
|
322
|
+
"type": "project_offline",
|
|
323
|
+
"project_directory": info.project_directory,
|
|
324
|
+
"last_message_at": info.last_message_at or 0,
|
|
325
|
+
}
|
|
326
|
+
)
|
|
327
|
+
# 兼容旧版 HTML 前端
|
|
328
|
+
await self._broadcast(
|
|
329
|
+
{
|
|
330
|
+
"type": "agent_deregistered",
|
|
331
|
+
"agent_id": agent_id,
|
|
332
|
+
"project_directory": info.project_directory,
|
|
333
|
+
}
|
|
334
|
+
)
|
|
335
|
+
return True
|
|
336
|
+
|
|
337
|
+
async def heartbeat(self, agent_id: str) -> bool:
|
|
338
|
+
"""Update heartbeat timestamp. Returns False if agent not found."""
|
|
339
|
+
async with self._lock:
|
|
340
|
+
info = self._agents.get(agent_id)
|
|
341
|
+
if info is None:
|
|
342
|
+
return False
|
|
343
|
+
info.last_heartbeat = time.time()
|
|
344
|
+
if info.status == AgentStatus.OFFLINE:
|
|
345
|
+
info.status = AgentStatus.IDLE
|
|
346
|
+
# [REG_HEARTBEAT] 记录 Agent 心跳
|
|
347
|
+
_reg_logger.debug(
|
|
348
|
+
f"agent_id={agent_id[:8]} project={os.path.basename(info.project_directory)} "
|
|
349
|
+
f"status={info.status.value}",
|
|
350
|
+
"[REG_HEARTBEAT] Agent 心跳",
|
|
351
|
+
)
|
|
352
|
+
return True
|
|
353
|
+
|
|
354
|
+
async def update_status(
|
|
355
|
+
self,
|
|
356
|
+
agent_id: str,
|
|
357
|
+
status: str | AgentStatus,
|
|
358
|
+
**kwargs: Any,
|
|
359
|
+
) -> bool:
|
|
360
|
+
"""Update agent status with optional metadata. Returns False if not found."""
|
|
361
|
+
if isinstance(status, str):
|
|
362
|
+
try:
|
|
363
|
+
new_status = AgentStatus(status)
|
|
364
|
+
except ValueError:
|
|
365
|
+
return False
|
|
366
|
+
else:
|
|
367
|
+
new_status = status
|
|
368
|
+
|
|
369
|
+
async with self._lock:
|
|
370
|
+
info = self._agents.get(agent_id)
|
|
371
|
+
if info is None:
|
|
372
|
+
return False
|
|
373
|
+
previous = info.status
|
|
374
|
+
info.status = new_status
|
|
375
|
+
if "session_id" in kwargs:
|
|
376
|
+
info.session_id = kwargs["session_id"]
|
|
377
|
+
if "session_summary" in kwargs:
|
|
378
|
+
info.session_summary = kwargs["session_summary"]
|
|
379
|
+
if "original_task" in kwargs:
|
|
380
|
+
info.original_task = kwargs["original_task"]
|
|
381
|
+
|
|
382
|
+
await self._broadcast(
|
|
383
|
+
{
|
|
384
|
+
"type": "project_status_changed",
|
|
385
|
+
"agent_id": agent_id,
|
|
386
|
+
"project_directory": info.project_directory,
|
|
387
|
+
"status": new_status.value,
|
|
388
|
+
"previous_status": previous.value,
|
|
389
|
+
"session_id": info.session_id,
|
|
390
|
+
"session_summary": info.session_summary or "",
|
|
391
|
+
}
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
if new_status == AgentStatus.WAITING and self._on_agent_waiting:
|
|
395
|
+
if previous != AgentStatus.WAITING:
|
|
396
|
+
_reg_logger.info(
|
|
397
|
+
f"agent_id={agent_id[:8]} project={os.path.basename(info.project_directory)} "
|
|
398
|
+
f"previous={previous.value} → waiting",
|
|
399
|
+
"[REG_STATUS_WAITING] Agent 状态变为 waiting,触发队列投递回调",
|
|
400
|
+
)
|
|
401
|
+
try:
|
|
402
|
+
await self._on_agent_waiting(info)
|
|
403
|
+
except Exception:
|
|
404
|
+
logger.exception("on_agent_waiting callback failed")
|
|
405
|
+
|
|
406
|
+
return True
|
|
407
|
+
|
|
408
|
+
def update_last_message(self, agent_id: str) -> bool:
|
|
409
|
+
"""Update last_message_at timestamp (called synchronously from routes)."""
|
|
410
|
+
info = self._agents.get(agent_id)
|
|
411
|
+
if info is None:
|
|
412
|
+
return False
|
|
413
|
+
info.last_message_at = time.time()
|
|
414
|
+
return True
|
|
415
|
+
|
|
416
|
+
# ── Query ────────────────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
def get_agent(self, agent_id: str) -> AgentInfo | None:
|
|
419
|
+
return self._agents.get(agent_id)
|
|
420
|
+
|
|
421
|
+
def get_agent_by_project(self, project_directory: str) -> AgentInfo | None:
|
|
422
|
+
norm = _normalize_path(project_directory)
|
|
423
|
+
agent_id = self._by_project.get(norm)
|
|
424
|
+
if agent_id is None:
|
|
425
|
+
return None
|
|
426
|
+
return self._agents.get(agent_id)
|
|
427
|
+
|
|
428
|
+
def get_all_agents(self) -> list[AgentInfo]:
|
|
429
|
+
return list(self._agents.values())
|
|
430
|
+
|
|
431
|
+
def get_online_agents(self) -> list[AgentInfo]:
|
|
432
|
+
return [a for a in self._agents.values() if a.status != AgentStatus.OFFLINE]
|
|
433
|
+
|
|
434
|
+
def get_waiting_agents(self) -> list[AgentInfo]:
|
|
435
|
+
return [a for a in self._agents.values() if a.status == AgentStatus.WAITING]
|
|
436
|
+
|
|
437
|
+
def get_deliverable_agents(self) -> list[AgentInfo]:
|
|
438
|
+
"""Return agents that can receive queued messages (waiting or idle)."""
|
|
439
|
+
return [a for a in self._agents.values()
|
|
440
|
+
if a.status in (AgentStatus.WAITING, AgentStatus.IDLE)]
|
|
441
|
+
|
|
442
|
+
def agent_count(self) -> int:
|
|
443
|
+
return len(self._agents)
|
|
444
|
+
|
|
445
|
+
def online_count(self) -> int:
|
|
446
|
+
return sum(1 for a in self._agents.values() if a.status != AgentStatus.OFFLINE)
|
|
447
|
+
|
|
448
|
+
# ── Heartbeat Check ──────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
async def check_heartbeats(self) -> list[str]:
|
|
451
|
+
"""Check all agents for heartbeat timeout. Returns list of timed-out agent_ids."""
|
|
452
|
+
now = time.time()
|
|
453
|
+
timed_out: list[str] = []
|
|
454
|
+
|
|
455
|
+
async with self._lock:
|
|
456
|
+
for agent_id, info in self._agents.items():
|
|
457
|
+
if info.status == AgentStatus.OFFLINE:
|
|
458
|
+
continue
|
|
459
|
+
if now - info.last_heartbeat > self._heartbeat_timeout:
|
|
460
|
+
prev = info.status
|
|
461
|
+
info.status = AgentStatus.OFFLINE
|
|
462
|
+
timed_out.append(agent_id)
|
|
463
|
+
# [REG_HEARTBEAT_TIMEOUT] Agent 心跳超时
|
|
464
|
+
_hb_age = int(now - info.last_heartbeat)
|
|
465
|
+
_reg_logger.warn(
|
|
466
|
+
f"agent_id={agent_id[:8]} project={os.path.basename(info.project_directory)} "
|
|
467
|
+
f"last_heartbeat_age_seconds={_hb_age}",
|
|
468
|
+
"[REG_HEARTBEAT_TIMEOUT] Agent 心跳超时",
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
for agent_id in timed_out:
|
|
472
|
+
info = self._agents.get(agent_id)
|
|
473
|
+
if info:
|
|
474
|
+
await self._broadcast(
|
|
475
|
+
{
|
|
476
|
+
"type": "project_status_changed",
|
|
477
|
+
"agent_id": agent_id,
|
|
478
|
+
"project_directory": info.project_directory,
|
|
479
|
+
"status": AgentStatus.OFFLINE.value,
|
|
480
|
+
"previous_status": "timeout",
|
|
481
|
+
}
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
return timed_out
|
|
485
|
+
|
|
486
|
+
async def check_stale_active(self) -> list[str]:
|
|
487
|
+
"""自动把长时间 active 但无消息更新的 agent 降为 idle。
|
|
488
|
+
|
|
489
|
+
背景:MCP client 仅在 Qoder 主动调用工具时上报 status,Qoder 自己自然完成一轮工作
|
|
490
|
+
后不会回报 idle,导致 project.status 永久卡住 active。本 sweep 在心跳循环里
|
|
491
|
+
同时跑(每 CHECK_INTERVAL),把 status=ACTIVE 且 “last_message_at 距今 > ACTIVE_IDLE_TIMEOUT”
|
|
492
|
+
的 agent 主动降为 IDLE 并广播,保证 UI 上“AI 工作中”能到时自动消失。
|
|
493
|
+
|
|
494
|
+
仅处理 ACTIVE:
|
|
495
|
+
- WAITING 是真等用户(MCP interactive_feedback 还阻塞着),不能降
|
|
496
|
+
- OFFLINE 已由 check_heartbeats 处理
|
|
497
|
+
- IDLE 本身就是目标状态
|
|
498
|
+
Returns list of demoted agent_ids.
|
|
499
|
+
"""
|
|
500
|
+
now = time.time()
|
|
501
|
+
demoted: list[str] = []
|
|
502
|
+
|
|
503
|
+
async with self._lock:
|
|
504
|
+
for agent_id, info in self._agents.items():
|
|
505
|
+
if info.status != AgentStatus.ACTIVE:
|
|
506
|
+
continue
|
|
507
|
+
# 无 last_message_at 时回退到 registered_at,避免刚注册就被降
|
|
508
|
+
reference_time = info.last_message_at or info.registered_at
|
|
509
|
+
age = now - reference_time
|
|
510
|
+
if age <= self._active_idle_timeout:
|
|
511
|
+
continue
|
|
512
|
+
info.status = AgentStatus.IDLE
|
|
513
|
+
demoted.append(agent_id)
|
|
514
|
+
_reg_logger.info(
|
|
515
|
+
f"agent_id={agent_id[:8]} project={os.path.basename(info.project_directory)} "
|
|
516
|
+
f"idle_age_seconds={int(age)}",
|
|
517
|
+
"[REG_ACTIVE_IDLE_SWEEP] Agent 长时 active 无消息更新,自动降为 idle",
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
for agent_id in demoted:
|
|
521
|
+
info = self._agents.get(agent_id)
|
|
522
|
+
if info:
|
|
523
|
+
await self._broadcast(
|
|
524
|
+
{
|
|
525
|
+
"type": "project_status_changed",
|
|
526
|
+
"agent_id": agent_id,
|
|
527
|
+
"project_directory": info.project_directory,
|
|
528
|
+
"status": AgentStatus.IDLE.value,
|
|
529
|
+
"previous_status": AgentStatus.ACTIVE.value,
|
|
530
|
+
"session_id": info.session_id,
|
|
531
|
+
"session_summary": info.session_summary or "",
|
|
532
|
+
}
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
return demoted
|
|
536
|
+
|
|
537
|
+
async def cleanup_stale_agents(self) -> list[str]:
|
|
538
|
+
"""Remove agents that have been offline longer than stale_retention.
|
|
539
|
+
|
|
540
|
+
Broadcasts project_offline for each removed agent so the frontend
|
|
541
|
+
drops them from the project list.
|
|
542
|
+
"""
|
|
543
|
+
cutoff = time.time() - self._stale_retention
|
|
544
|
+
removed_infos: list[AgentInfo] = []
|
|
545
|
+
|
|
546
|
+
async with self._lock:
|
|
547
|
+
to_remove = [
|
|
548
|
+
aid for aid, info in self._agents.items()
|
|
549
|
+
if info.status == AgentStatus.OFFLINE
|
|
550
|
+
and info.last_heartbeat < cutoff
|
|
551
|
+
]
|
|
552
|
+
for aid in to_remove:
|
|
553
|
+
info = self._agents.pop(aid)
|
|
554
|
+
norm = _normalize_path(info.project_directory)
|
|
555
|
+
if self._by_project.get(norm) == aid:
|
|
556
|
+
del self._by_project[norm]
|
|
557
|
+
removed_infos.append(info)
|
|
558
|
+
|
|
559
|
+
# 同时清理过期的黑名单条目
|
|
560
|
+
blacklist_cutoff = time.time() - self._heartbeat_timeout * 2
|
|
561
|
+
expired_blacklist = [
|
|
562
|
+
aid for aid, ts in self._replaced_agents.items()
|
|
563
|
+
if ts < blacklist_cutoff
|
|
564
|
+
]
|
|
565
|
+
for aid in expired_blacklist:
|
|
566
|
+
del self._replaced_agents[aid]
|
|
567
|
+
|
|
568
|
+
for info in removed_infos:
|
|
569
|
+
_reg_logger.info(
|
|
570
|
+
f"agent_id={info.agent_id[:8]} project={os.path.basename(info.project_directory)} "
|
|
571
|
+
f"offline_hours={int((time.time() - info.last_heartbeat) / 3600)}",
|
|
572
|
+
"[REG_STALE_REMOVED] 清理过期离线 Agent",
|
|
573
|
+
)
|
|
574
|
+
await self._broadcast({
|
|
575
|
+
"type": "project_offline",
|
|
576
|
+
"project_directory": info.project_directory,
|
|
577
|
+
"last_message_at": info.last_message_at or 0,
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
return [info.agent_id for info in removed_infos]
|
|
581
|
+
|
|
582
|
+
# ── Internal ─────────────────────────────────────────────────
|
|
583
|
+
|
|
584
|
+
async def _broadcast(self, event: dict[str, Any]) -> None:
|
|
585
|
+
if self._broadcast_callback:
|
|
586
|
+
try:
|
|
587
|
+
await self._broadcast_callback(event)
|
|
588
|
+
except Exception:
|
|
589
|
+
logger.exception("Broadcast callback failed for %s", event.get("type"))
|
|
590
|
+
|
|
591
|
+
async def _heartbeat_check_loop(self) -> None:
|
|
592
|
+
try:
|
|
593
|
+
while True:
|
|
594
|
+
await asyncio.sleep(self._check_interval)
|
|
595
|
+
await self.check_heartbeats()
|
|
596
|
+
await self.cleanup_stale_agents()
|
|
597
|
+
await self.check_stale_active()
|
|
598
|
+
except asyncio.CancelledError:
|
|
599
|
+
pass
|