fix(multi-agent): MultiAgentState 全局单例,根治 terminate 后状态分裂复活

- MultiAgentState 从 manager 实例属性改为进程级全局注册表 GLOBAL_MULTI_AGENT_STATES
  (多 manager 并存时各 from_snapshot 出独立副本,terminate 只标记一份,轮询落到陈旧副本显示空闲)
- _load_state 恢复后用任务记录校准实例终态(磁盘快照可能被陈旧副本洗回 idle)
- 显示名后缀固定用角色内编号(role_seq 每次创建必递增),与内部 agent_id 命名空间分离;
  存量 _None 后缀按对话×角色×创建时间自愈迁移
- tools_execution 同步含 edited_files 工具埋点(write/edit/delete/rename)
This commit is contained in:
JOJO 2026-07-22 00:53:05 +08:00
parent 171833e4d1
commit 77a271fa23
5 changed files with 239 additions and 38 deletions

View File

@ -466,7 +466,9 @@ AI 执行以下流程时,每一步都要向用户说明在做什么:
- 切换会话不清理 `_running_tasks``_sub_agent_instances`。`SubAgentManager` 的全局 tasks 字典按 `task_id` 保持,多智能体状态由 `conversation_id``get_multi_agent_state` 中查。 - 切换会话不清理 `_running_tasks``_sub_agent_instances`。`SubAgentManager` 的全局 tasks 字典按 `task_id` 保持,多智能体状态由 `conversation_id``get_multi_agent_state` 中查。
- 子智能体对话存在 `~/.astrion/astrion/host/host/data/sub_agents/`。重启后走 `manager.restore_sub_agent` 恢复实例引用。 - 子智能体对话存在 `~/.astrion/astrion/host/host/data/sub_agents/`。重启后走 `manager.restore_sub_agent` 恢复实例引用。
- `MultiAgentState` 实例在 Manager 上常驻dict by `conversation_id`);进程重启走 `from_snapshot` 恢复。 - **`MultiAgentState` 是进程级全局单例**2026-07 重构):存放在 `modules/multi_agent/state.py``GLOBAL_MULTI_AGENT_STATES`key=`conversation_id`),所有 `SubAgentManager` 实例共享;`manager.multi_agent_states` 只是该全局 dict 的引用。此前它是 manager 实例属性,对话级 terminal 缓存重建会产生多个 manager各自的 `_load_state` 都从磁盘快照 `from_snapshot` 出一份独立副本,导致 terminate 只标记其中一份、前端轮询落到其他副本显示陈旧 idle。`get_or_create` / `drop` / `_load_state` restore 均通过 `GLOBAL_MULTI_AGENT_STATES_LOCK`RLock互斥。
- **进程重启后的状态校准**2026-07 新增):`_load_state` 恢复 ma 快照后,会用任务记录(持久真相)校准实例终态——任务记录是 terminated/终态而快照里还是 idle 的,一律校准为终态;同处还有存量 `_None` 后缀显示名的自愈迁移(按「对话×角色×创建时间」重编号)。
- **显示名编号语义**:显示名后缀(如 `Full-Stack Engineer_1`)是**角色内编号**`next_agent_id_for_role`,每次创建必递增),与内部 `agent_id`LLM 可手动指定,如 10001是两套独立命名空间创建路径 `tools_execution.py` 中不得混用。
--- ---

View File

@ -638,6 +638,103 @@ class MainTerminalToolsExecutionMixin:
sorted(visited), sorted(visited),
) )
# ---------------- 快捷窗口:本次对话编辑/创建文件记录 ----------------
def _get_conversation_manager_for_edited_files(self, conversation_id: str):
cm = getattr(self, "context_manager", None)
if cm is None:
return None
router = getattr(cm, "_get_conversation_manager_for_id", None)
if callable(router):
try:
return router(conversation_id)
except Exception:
pass
return getattr(cm, "conversation_manager", None)
def _mutate_edited_files(self, mutator) -> None:
"""读取-修改-写回当前对话 metadata.edited_files并实时广播给前端。"""
try:
cm = getattr(self, "context_manager", None)
if cm is None:
return
conv_id = getattr(self, "_bound_conversation_id", None) or getattr(cm, "current_conversation_id", None)
if not conv_id:
return
manager = self._get_conversation_manager_for_edited_files(conv_id)
if manager is None:
return
data = manager.load_conversation(conv_id)
if not data:
return
metadata = data.get("metadata", {}) or {}
raw = metadata.get("edited_files")
entries = [dict(item) for item in raw if isinstance(item, dict)] if isinstance(raw, list) else []
changed = mutator(entries)
if not changed:
return
if not manager.update_conversation_metadata(conv_id, {"edited_files": entries}):
return
callback = getattr(cm, "_web_terminal_callback", None)
if callable(callback):
try:
callback("edited_files_updated", {
"conversation_id": conv_id,
"edited_files": entries,
})
except Exception:
pass
except Exception as exc:
print(f"⚠️ 更新对话编辑文件记录失败: {exc}")
def _record_edited_file(self, path: Any, op: str) -> None:
"""记录本次对话中编辑/创建过的文件(相对路径,同 path 去重更新)。"""
rel_path = str(path or "").strip().replace("\\", "/")
if not rel_path:
return
def _upsert(entries: List[Dict[str, Any]]) -> bool:
now = datetime.now().isoformat()
for item in entries:
if item.get("path") == rel_path:
item["op"] = op
item["ts"] = now
return True
entries.append({"path": rel_path, "op": op, "ts": now})
return True
self._mutate_edited_files(_upsert)
def _remove_edited_file(self, path: Any) -> None:
"""文件被删除后从记录中移除。"""
rel_path = str(path or "").strip().replace("\\", "/")
if not rel_path:
return
def _remove(entries: List[Dict[str, Any]]) -> bool:
before = len(entries)
entries[:] = [item for item in entries if item.get("path") != rel_path]
return len(entries) != before
self._mutate_edited_files(_remove)
def _rename_edited_file(self, old_path: Any, new_path: Any) -> None:
"""文件重命名后同步更新记录中的路径。"""
old_rel = str(old_path or "").strip().replace("\\", "/")
new_rel = str(new_path or "").strip().replace("\\", "/")
if not old_rel or not new_rel or old_rel == new_rel:
return
def _rename(entries: List[Dict[str, Any]]) -> bool:
for item in entries:
if item.get("path") == old_rel:
item["path"] = new_rel
item["ts"] = datetime.now().isoformat()
return True
return False
self._mutate_edited_files(_rename)
def _track_shallow_versioning(self, file_path: Any) -> None: def _track_shallow_versioning(self, file_path: Any) -> None:
"""Track a file edit for shallow versioning (conversation-scoped undo).""" """Track a file edit for shallow versioning (conversation-scoped undo)."""
conversation_id = getattr(self.context_manager, "current_conversation_id", None) conversation_id = getattr(self.context_manager, "current_conversation_id", None)
@ -1297,6 +1394,8 @@ class MainTerminalToolsExecutionMixin:
# 如果删除成功,同时删除备注 # 如果删除成功,同时删除备注
if result.get("success") and result.get("action") == "deleted": if result.get("success") and result.get("action") == "deleted":
deleted_path = result.get("path") deleted_path = result.get("path")
# 快捷窗口:从编辑文件记录中移除
self._remove_edited_file(deleted_path)
# 删除备注 # 删除备注
if deleted_path in self.context_manager.file_annotations: if deleted_path in self.context_manager.file_annotations:
del self.context_manager.file_annotations[deleted_path] del self.context_manager.file_annotations[deleted_path]
@ -1313,6 +1412,8 @@ class MainTerminalToolsExecutionMixin:
if result.get("success") and result.get("action") == "renamed": if result.get("success") and result.get("action") == "renamed":
old_path = result.get("old_path") old_path = result.get("old_path")
new_path = result.get("new_path") new_path = result.get("new_path")
# 快捷窗口:同步编辑文件记录中的路径
self._rename_edited_file(old_path, new_path)
# 更新备注 # 更新备注
if old_path in self.context_manager.file_annotations: if old_path in self.context_manager.file_annotations:
annotation = self.context_manager.file_annotations[old_path] annotation = self.context_manager.file_annotations[old_path]
@ -1339,6 +1440,8 @@ class MainTerminalToolsExecutionMixin:
# write_file 成功后,该文件视作当前会话已“接触”, # write_file 成功后,该文件视作当前会话已“接触”,
# 允许后续继续 write/edit 而不再触发先读拦截。 # 允许后续继续 write/edit 而不再触发先读拦截。
self._mark_file_as_read_visited(result.get("path") or path) self._mark_file_as_read_visited(result.get("path") or path)
# 快捷窗口:记录本次对话写入过的文件
self._record_edited_file(result.get("path") or path, "write")
elif tool_name == "edit_file": elif tool_name == "edit_file":
read_guard_error = self._check_read_before_edit_prerequisite(tool_name, arguments) read_guard_error = self._check_read_before_edit_prerequisite(tool_name, arguments)
@ -1356,6 +1459,8 @@ class MainTerminalToolsExecutionMixin:
result = self.file_manager.replace_many_in_file(path, replacements) result = self.file_manager.replace_many_in_file(path, replacements)
if isinstance(result, dict) and result.get("success"): if isinstance(result, dict) and result.get("success"):
self._mark_file_as_read_visited(result.get("path") or path) self._mark_file_as_read_visited(result.get("path") or path)
# 快捷窗口:记录本次对话编辑过的文件
self._record_edited_file(result.get("path") or path, "edit")
elif tool_name == "create_folder": elif tool_name == "create_folder":
result = self.file_manager.create_folder(arguments["path"]) result = self.file_manager.create_folder(arguments["path"])
@ -1798,12 +1903,16 @@ class MainTerminalToolsExecutionMixin:
else: else:
conv_id = self.context_manager.current_conversation_id conv_id = self.context_manager.current_conversation_id
multi_agent_state = self.sub_agent_manager.get_or_create_multi_agent_state(conv_id) multi_agent_state = self.sub_agent_manager.get_or_create_multi_agent_state(conv_id)
# 分配 agent_id如果未传入则自动递增 # 角色内编号:每次创建都递增,作为显示名后缀
# (如 Full-Stack Engineer_1。它与内部 agent_id 是两套
# 独立命名空间——agent_id 可被 LLM 手动指定(如 10001
# 显示名后缀永远用角色内编号。
role_seq = multi_agent_state.next_agent_id_for_role(role_id)
agent_id = arguments.get("agent_id") agent_id = arguments.get("agent_id")
if not agent_id: if not agent_id:
agent_id = multi_agent_state.next_agent_id_for_role(role_id) agent_id = role_seq
# 构造显示名 # 构造显示名
display_name = role.display_name(int(agent_id)) display_name = role.display_name(int(role_seq))
# 构造多智能体版系统提示词(含动态上下文注入) # 构造多智能体版系统提示词(含动态上下文注入)
workspace_path = str(getattr(self, "project_path", "")) workspace_path = str(getattr(self, "project_path", ""))
data_dir = str(getattr(self, "data_dir", "")) data_dir = str(getattr(self, "data_dir", ""))

View File

@ -20,6 +20,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import re import re
import threading
import uuid import uuid
from asyncio import AbstractEventLoop from asyncio import AbstractEventLoop
from dataclasses import dataclass, field from dataclasses import dataclass, field
@ -624,4 +625,20 @@ class MultiAgentState:
self.pending_question_loops.clear() self.pending_question_loops.clear()
self.pending_answers.clear() self.pending_answers.clear()
self.agent_blocking_question.clear() self.agent_blocking_question.clear()
self.role_counters.clear() self.role_counters.clear()
# ----------------------------------------------------------------------
# 进程级全局注册表(所有 SubAgentManager 共享)
# ----------------------------------------------------------------------
# MultiAgentState 是会话级运行态,本质上不属于任何单个 SubAgentManager。
# 历史上它存放在 manager.multi_agent_statesmanager 实例属性)里,而对话级
# terminal 缓存重建会产生多个 manager每个 manager 的 _load_state 都从磁盘快照
# from_snapshot 恢复一份副本,导致同一对话的 MultiAgentState 在内存中同时存在
# N 份terminate 只标记了其中一份,其余副本仍是陈旧 idle前端轮询落到哪份
# 就看到哪份的状态)。现在全进程共享同一份注册表,任何 manager 读写的都是同一
# 对象,从根上消除多副本分裂。
GLOBAL_MULTI_AGENT_STATES: Dict[str, "MultiAgentState"] = {}
# get-or-create / drop / _load_state restore 的 check-then-act 需要互斥;
# 用 RLock 防止与调用方已有锁重入死锁。
GLOBAL_MULTI_AGENT_STATES_LOCK = threading.RLock()

View File

@ -29,6 +29,10 @@ from modules.sub_agent.state import SubAgentStateMixin
from modules.sub_agent.stats import SubAgentStatsMixin from modules.sub_agent.stats import SubAgentStatsMixin
from modules.sub_agent.creation import SubAgentCreationMixin from modules.sub_agent.creation import SubAgentCreationMixin
from modules.multi_agent.debug_logger import ma_debug from modules.multi_agent.debug_logger import ma_debug
from modules.multi_agent.state import (
GLOBAL_MULTI_AGENT_STATES,
GLOBAL_MULTI_AGENT_STATES_LOCK,
)
from server.utils_common import debug_log from server.utils_common import debug_log
if TYPE_CHECKING: if TYPE_CHECKING:
@ -63,9 +67,11 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
self.container_session: Optional["ContainerHandle"] = container_session self.container_session: Optional["ContainerHandle"] = container_session
self.host_execution_mode: str = "sandbox" self.host_execution_mode: str = "sandbox"
self.terminal: Optional["WebTerminal"] = None self.terminal: Optional["WebTerminal"] = None
# 多智能体模式:为每个启用 multi_agent_mode 的会话维护一个 MultiAgentState # 多智能体模式MultiAgentState 是会话级运行态,全进程共享一份注册表
# (见 modules/multi_agent/state.py 中 GLOBAL_MULTI_AGENT_STATES 的注释),
# 避免多 manager 并存时同一对话被 from_snapshot 复制出多份独立副本。
# key = conversation_id, value = MultiAgentState # key = conversation_id, value = MultiAgentState
self.multi_agent_states: Dict[str, Any] = {} self.multi_agent_states: Dict[str, Any] = GLOBAL_MULTI_AGENT_STATES
self.base_dir.mkdir(parents=True, exist_ok=True) self.base_dir.mkdir(parents=True, exist_ok=True)
self.state_file.parent.mkdir(parents=True, exist_ok=True) self.state_file.parent.mkdir(parents=True, exist_ok=True)
@ -1026,24 +1032,25 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
def get_or_create_multi_agent_state(self, conversation_id: str): def get_or_create_multi_agent_state(self, conversation_id: str):
"""获取或为该会话创建 MultiAgentState。""" """获取或为该会话创建 MultiAgentState。"""
from modules.multi_agent.state import MultiAgentState from modules.multi_agent.state import MultiAgentState
state = self.multi_agent_states.get(conversation_id) with GLOBAL_MULTI_AGENT_STATES_LOCK:
if state: state = self.multi_agent_states.get(conversation_id)
if state:
ma_debug(
"manager_get_or_create_ma_state_reuse",
conversation_id=conversation_id,
state_id=id(state),
manager_id=id(self),
)
return state
state = MultiAgentState(conversation_id=conversation_id)
self.multi_agent_states[conversation_id] = state
ma_debug( ma_debug(
"manager_get_or_create_ma_state_reuse", "manager_get_or_create_ma_state_create",
conversation_id=conversation_id, conversation_id=conversation_id,
state_id=id(state), state_id=id(state),
manager_id=id(self), manager_id=id(self),
) )
return state return state
state = MultiAgentState(conversation_id=conversation_id)
self.multi_agent_states[conversation_id] = state
ma_debug(
"manager_get_or_create_ma_state_create",
conversation_id=conversation_id,
state_id=id(state),
manager_id=id(self),
)
return state
def get_multi_agent_state(self, conversation_id: str): def get_multi_agent_state(self, conversation_id: str):
"""获取该会话的多智能体状态。""" """获取该会话的多智能体状态。"""
@ -1059,7 +1066,8 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi
def drop_multi_agent_state(self, conversation_id: str) -> None: def drop_multi_agent_state(self, conversation_id: str) -> None:
"""删除会话状态(会话结束时调用)。""" """删除会话状态(会话结束时调用)。"""
self.multi_agent_states.pop(conversation_id, None) with GLOBAL_MULTI_AGENT_STATES_LOCK:
self.multi_agent_states.pop(conversation_id, None)
def reconcile_task_states(self, conversation_id: Optional[str] = None) -> int: def reconcile_task_states(self, conversation_id: Optional[str] = None) -> int:
"""修正运行态任务状态。 """修正运行态任务状态。

View File

@ -58,33 +58,97 @@ class SubAgentStateMixin:
# 恢复多智能体运行态(如果状态文件包含) # 恢复多智能体运行态(如果状态文件包含)
try: try:
from modules.multi_agent.state import MultiAgentState from modules.multi_agent.state import (
MultiAgentState,
GLOBAL_MULTI_AGENT_STATES_LOCK,
)
manager = self manager = self
multi_agent_states = getattr(manager, "multi_agent_states", None) multi_agent_states = getattr(manager, "multi_agent_states", None)
if multi_agent_states is not None and isinstance(multi_agent_states, dict): if multi_agent_states is not None and isinstance(multi_agent_states, dict):
loaded_ma_states = data.get("multi_agent_states", {}) loaded_ma_states = data.get("multi_agent_states", {})
for conv_id, snapshot in loaded_ma_states.items(): # multi_agent_states 是全进程共享注册表;加锁防止多个 manager
try: # 并发 _load_state 时对同一 conv 重复 from_snapshot 出多份副本。
if isinstance(snapshot, dict): with GLOBAL_MULTI_AGENT_STATES_LOCK:
# 关键:不要覆盖内存中已存在的 MultiAgentState for conv_id, snapshot in loaded_ma_states.items():
# 否则 SubAgentTask 持有的旧引用上的 pending_master_messages try:
# 会被新的空 state 覆盖,导致子智能体输出丢失。 if isinstance(snapshot, dict):
if conv_id in multi_agent_states: # 关键:不要覆盖内存中已存在的 MultiAgentState
# 否则 SubAgentTask 持有的旧引用上的 pending_master_messages
# 会被新的空 state 覆盖,导致子智能体输出丢失。
if conv_id in multi_agent_states:
ma_debug(
"load_state_skip_existing_ma_state",
conversation_id=conv_id,
existing_state_id=id(multi_agent_states[conv_id]),
)
continue
multi_agent_states[conv_id] = MultiAgentState.from_snapshot(snapshot)
ma_debug( ma_debug(
"load_state_skip_existing_ma_state", "load_state_restore_ma_state",
conversation_id=conv_id, conversation_id=conv_id,
existing_state_id=id(multi_agent_states[conv_id]), state_id=id(multi_agent_states[conv_id]),
) )
continue except Exception as exc:
multi_agent_states[conv_id] = MultiAgentState.from_snapshot(snapshot) logger.warning(f"恢复多智能体状态失败 {conv_id}: {exc}")
# 用任务记录持久真相校准实例终态terminate 只改了发起 manager
# 的内存与磁盘任务记录,而磁盘上的 ma 快照可能是更早的 idle 副本
# (多 manager 并存时代其他 manager 会用陈旧内存快照覆盖落盘)。
# 任务记录的 terminated/终态是吸收态,恢复快照后必须以此校准,
# 否则已终结实例会以 idle 复活显示。
for cal_state in list(multi_agent_states.values()):
for agent in cal_state.list_all():
cal_task = self.tasks.get(agent.task_id)
if not isinstance(cal_task, dict):
continue
cal_status = cal_task.get("status")
if (
cal_status in TERMINAL_STATUSES.union({"terminated"})
and agent.status != cal_status
):
ma_debug( ma_debug(
"load_state_restore_ma_state", "load_state_calibrate_agent_status",
conversation_id=conv_id, conversation_id=getattr(cal_state, "conversation_id", ""),
state_id=id(multi_agent_states[conv_id]), agent_id=agent.agent_id,
before=agent.status,
after=cal_status,
) )
except Exception as exc: cal_state.mark_status(agent.agent_id, cal_status)
logger.warning(f"恢复多智能体状态失败 {conv_id}: {exc}") # 存量脏数据自愈:历史版本曾把内部 agent_id 与角色内编号混用,
# 在编号缺失时生成过 "_None" 后缀显示名(如 Full-Stack Engineer_None
# 这里按「对话 × 角色 × 任务创建时间」重新排序编号修复,幂等:
# 修完后 display_name 不再以 _None 结尾,不会再命中。
none_agents = []
for st in list(multi_agent_states.values()):
for agent in st.list_all():
if str(agent.display_name or "").endswith("_None"):
none_agents.append((st, agent))
if none_agents:
groups = {}
for st, agent in none_agents:
groups.setdefault((id(st), agent.role_id), []).append((st, agent))
for (_sid, _role_id), items in groups.items():
def _created_of(pair):
t = self.tasks.get(pair[1].task_id) or {}
return t.get("created_at") or 0
items.sort(key=_created_of)
seq = 1
for st, agent in items:
base = str(agent.display_name)[: -len("_None")]
if not base:
continue
new_name = f"{base}_{seq}"
seq += 1
ma_debug(
"load_state_fix_none_display_name",
agent_id=agent.agent_id,
before=agent.display_name,
after=new_name,
)
agent.display_name = new_name
task = self.tasks.get(agent.task_id)
if isinstance(task, dict) and str(task.get("display_name") or "").endswith("_None"):
task["display_name"] = new_name
except Exception as exc: except Exception as exc:
logger.warning(f"加载多智能体状态失败: {exc}") logger.warning(f"加载多智能体状态失败: {exc}")
@ -116,7 +180,8 @@ class SubAgentStateMixin:
if multi_agent_states: if multi_agent_states:
payload["multi_agent_states"] = { payload["multi_agent_states"] = {
conv_id: state.to_snapshot() conv_id: state.to_snapshot()
for conv_id, state in multi_agent_states.items() # 全局共享注册表可能被其他线程并发增删,先拷贝再遍历
for conv_id, state in list(multi_agent_states.items())
if isinstance(state, object) and hasattr(state, "to_snapshot") if isinstance(state, object) and hasattr(state, "to_snapshot")
} }
except Exception as exc: except Exception as exc: