feat: virtual company M2 control + tier-3/4 capabilities + per-dept session fix

- M2-a/b/c: stop/pause/resume/skip dept, plan/rework approval gates,
  budget & timeout circuit breakers, per-dept cancel and on-demand rework
- tier-3: batched event persistence (_EventBuffer), project/dept
  concurrency semaphores, company_ws event streaming
- tier-4: cross-dept deliverable handoff, CEO memory feedback loop,
  per-dept model override, one-click Markdown report export (RFC5987)
- fix: per-department independent SQLAlchemy session in parallel waves
  (shared Session race across asyncio tasks in company_orchestrator)
- fix: rename process_open_fds gauge to app_process_open_fds
  (collides with prometheus_client ProcessCollector on Linux)
- fix: dept role resolution ladder, real token usage & cost tracking,
  deliverable file capture (mtime scan of dept workspace)
- tests: test_company_orchestrator.py 22 cases with mocked LLM
- frontend: CompanyControlRoom, companyExecution store, EChart,
  dept model config dialog, ws proxy, build/typecheck decoupling
- docs: virtual company design/usage docs, M2 control plan,
  deepseek4pro handover docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 18:16:39 +08:00
parent eef48ce831
commit 495eafcd78
39 changed files with 5733 additions and 574 deletions

View File

@@ -12,7 +12,9 @@ from __future__ import annotations
import asyncio
import json
import re
import difflib
import logging
import time
from pathlib import Path
from typing import Any, AsyncGenerator, Dict, List, Optional
from datetime import datetime
@@ -39,8 +41,34 @@ from app.services.project_scanner import (
)
def _build_agent_config(agent: Agent, auto_approve_files: bool = True) -> AgentConfig:
"""从 Agent 的 workflow_config 构建 AgentConfig,供 AgentRuntime 使用。"""
def _extract_model_override(team_config: Any) -> Optional[dict]:
"""四档-③:从 Team.config 解析部门级模型覆盖 {provider?, model?}(无则 None)。
约定存于 `config["model_override"] = {"provider": "...", "model": "..."}`;
provider/model 任一为空即视为未设该项,两者皆空 → None(回归各成员 Agent 自身配置)。
"""
if not isinstance(team_config, dict):
return None
mo = team_config.get("model_override")
if not isinstance(mo, dict):
return None
model = str(mo.get("model") or "").strip()
provider = str(mo.get("provider") or "").strip()
if not model and not provider:
return None
return {"model": model or None, "provider": provider or None}
def _build_agent_config(
agent: Agent,
auto_approve_files: bool = True,
model_override: Optional[dict] = None,
) -> AgentConfig:
"""从 Agent 的 workflow_config 构建 AgentConfig,供 AgentRuntime 使用。
model_override(四档-③ 部门级配模型)不为空时覆盖 provider/model,
让同一部门内所有成员统一走部门指定的模型(成本/质量分层调优)。
"""
wf = agent.workflow_config or {}
nodes = wf.get("nodes", [])
llm_node = None
@@ -58,8 +86,16 @@ def _build_agent_config(agent: Agent, auto_approve_files: bool = True) -> AgentC
temperature = float(llm_data.get("temperature", 0.4))
max_iterations = int(llm_data.get("max_iterations", 15))
# 四档-③:部门级模型覆盖(优先于成员 Agent 自身配置)
if model_override:
if model_override.get("model"):
model = model_override["model"]
if model_override.get("provider"):
provider = model_override["provider"]
perm = "acceptEdits" if auto_approve_files else "default"
logger.info("_build_agent_config: agent=%s tools=%d permission_level=%s", agent.name, len(tools), perm)
logger.info("_build_agent_config: agent=%s tools=%d permission_level=%s model=%s%s",
agent.name, len(tools), perm, model, " (dept-override)" if model_override else "")
return AgentConfig(
name=agent.name,
system_prompt=system_prompt,
@@ -232,6 +268,48 @@ def _parse_depends_on(raw: str) -> List[int]:
return []
def _norm_role(s: str) -> str:
"""角色名归一化:小写、去空白、下划线/空格统一为连字符。"""
return (s or "").strip().lower().replace("_", "-").replace(" ", "-")
# LLM 常见叫法 → 预设 slug(归一化后的键)。其余靠归一化+模糊兜底。
_ROLE_ALIASES: Dict[str, str] = {
"backend": "dev-backend",
"back-end": "dev-backend",
"backend-dev": "dev-backend",
"backend-developer": "dev-backend",
"developer": "dev-backend",
"engineer": "dev-backend",
"程序员": "dev-backend",
"后端": "dev-backend",
"后端工程师": "dev-backend",
"frontend": "dev-frontend",
"front-end": "dev-frontend",
"frontend-dev": "dev-frontend",
"frontend-developer": "dev-frontend",
"前端": "dev-frontend",
"前端工程师": "dev-frontend",
"产品经理": "pm",
"product-manager": "pm",
"pm-lead": "pm",
"设计师": "designer",
"ui": "designer",
"ui-designer": "designer",
"测试": "qa",
"测试工程师": "qa",
"qa-engineer": "qa",
"运维": "devops",
"文档": "tech-writer",
"技术文档": "tech-writer",
"tech-writer": "tech-writer",
"techwriter": "tech-writer",
"首席执行官": "ceo",
"技术总监": "cto",
"首席技术官": "cto",
}
def _parse_phases_from_markdown(text: str) -> List[Dict[str, Any]]:
"""从 Markdown 表格中提取 phase 信息。支持格式:
| **phase-1** | `functional_tester` | 核心任务 | 工时 |
@@ -240,10 +318,10 @@ def _parse_phases_from_markdown(text: str) -> List[Dict[str, Any]]:
"""
phases = []
# 匹配表格行: | (可选**的) phase-N (可选**) | role | description | [可选 depends_on] |
# role 可能带反引号 `role` 或直接是文本
# role 可能带反引号 `role` 或直接是文本;支持连字符 slug(如 dev-backend)
table_row = re.compile(
r'^\|\s*\*{0,2}(?:phase[-–]?\s*)?(\d+)\*{0,2}\s*\|'
r'\s*(?:`)?([a-z_]+)(?:`)?\s*\|'
r'\s*(?:`)?([a-z0-9_-]+)(?:`)?\s*\|'
r'\s*([^|]+?)\s*\|'
r'(?:\s*([^|]*?)\s*\|)?',
re.MULTILINE | re.IGNORECASE
@@ -253,16 +331,8 @@ def _parse_phases_from_markdown(text: str) -> List[Dict[str, Any]]:
role = m.group(2).strip().lower()
desc = m.group(3).strip()
depends_raw = (m.group(4) or '').strip() if m.lastindex and m.lastindex >= 4 else ''
# 验证 role 是合法角色名
if role not in ("test_planner", "functional_tester", "ux_reviewer", "edge_explorer",
"architect",
"performance_evaluator", "pm", "designer", "developer", "qa", "devops",
"curriculum_designer", "instructor", "teaching_assistant", "academic_admin",
"fullstack_dev", "fe_ux_engineer", "platform_devops", "qa_engineer", "product_lead",
"doc_architect", "tech_writer", "api_doc_specialist", "translator_reviewer",
"release_manager", "health_assessor", "nutritionist", "exercise_rehab",
"psychologist", "chronic_manager", "triage_specialist", "record_analyst",
"medication_reviewer", "followup_manager", "insurance_coordinator"):
# 接受任何非空 role slug;不存在的角色交由 _resolve_phase_agent 归一化/兜底
if not role:
continue
phases.append({
"phase": phase_num,
@@ -311,6 +381,27 @@ def _scan_directory_files(directory: Path) -> List[str]:
return sorted(files)
def _scan_recent_files(root: Path, since_ts: float) -> List[str]:
"""递归扫描 root,返回本次运行期间(mtime >= since_ts)修改过的文件绝对路径。
用于精确捕获部门本轮真实产物:Agent 有时把产物写到 project_path 以外的
兄弟目录,或用脚本(code_execute)生成文件——这些都不在 file_write 追踪里,
但只要落在部门工作区且是本轮修改的,就应计入交付物。按 mtime 过滤可避免
把历史运行的陈旧文件误纳入。
"""
import os
hits: List[str] = []
if not root.exists():
return hits
for p in root.rglob("*"):
try:
if p.is_file() and p.stat().st_mtime >= since_ts:
hits.append(str(p))
except OSError:
continue
return hits
def _extract_written_files(result: AgentResult) -> List[str]:
"""从 AgentResult.steps 中提取 file_write 成功产出的绝对路径列表。"""
files: List[str] = []
@@ -357,6 +448,11 @@ class TeamOrchestrator:
self.team_id = team_id
self.user_id = user_id
self.auto_approve_files = auto_approve_files
self._model_override: Optional[dict] = None # 四档-③:部门级模型覆盖,execute[_stream] 载入 team 后设置
def _acfg(self, agent: Agent) -> AgentConfig:
"""构建成员 AgentConfig,自动应用部门级模型覆盖(四档-③)。"""
return _build_agent_config(agent, self.auto_approve_files, self._model_override)
def _load_team(self) -> Team:
team = self.db.query(Team).filter(Team.id == self.team_id).first()
@@ -373,12 +469,66 @@ class TeamOrchestrator:
)
def _get_agent_by_role(self, members: List[TeamMember], role: str) -> Optional[Agent]:
"""按角色查找 Agent。"""
"""按角色查找 Agent(严格精确匹配)。规划者角色解析依赖其 None 回退,勿放宽。"""
for m in members:
if m.role == role:
return m.agent
return None
def _resolve_phase_agent(
self, members: List[TeamMember], role: str
) -> tuple[Optional[Agent], str, bool]:
"""健壮地把阶段 role 解析到成员 Agent,避免「部门空转」。
解析阶梯:精确 → 归一化相等 → 复合拆分(a/b、a,b) → alias 表 → 模糊 → 兜底到 Leader。
Returns: (agent, matched_role, is_fallback)。members 为空才返回 (None, role, False)。
"""
if not members:
return None, role, False
# a. 精确
for m in members:
if m.role == role:
return m.agent, m.role, False
norm_to_member = {_norm_role(m.role): m for m in members}
nrole = _norm_role(role)
# b. 归一化相等
if nrole in norm_to_member:
m = norm_to_member[nrole]
return m.agent, m.role, False
# c. 复合拆分("ceo/cto"、"a, b"、"a and b"、"a、b")
tokens = [t.strip().strip("-") for t in re.split(r"[/,&|、]|\band\b", nrole)]
tokens = [t for t in tokens if t]
if len(tokens) > 1:
for tok in tokens:
if tok in norm_to_member:
m = norm_to_member[tok]
return m.agent, m.role, False
alias = _ROLE_ALIASES.get(tok)
if alias and _norm_role(alias) in norm_to_member:
m = norm_to_member[_norm_role(alias)]
return m.agent, m.role, False
# d. alias 表
alias = _ROLE_ALIASES.get(nrole)
if alias and _norm_role(alias) in norm_to_member:
m = norm_to_member[_norm_role(alias)]
return m.agent, m.role, False
# e. 模糊
close = difflib.get_close_matches(nrole, list(norm_to_member.keys()), n=1, cutoff=0.6)
if close:
m = norm_to_member[close[0]]
return m.agent, m.role, False
# f. 兜底到 Leader(无则第一个成员)
lead = next((m for m in sorted(members, key=lambda x: x.position) if m.is_lead), None)
m = lead or sorted(members, key=lambda x: x.position)[0]
return m.agent, m.role, True
def _resolve_planner_role(self, members: List[TeamMember], workflow: str) -> str:
"""根据团队 workflow 类型解析规划者角色。
@@ -491,14 +641,10 @@ class TeamOrchestrator:
self_review = phase.get("self_review", False)
# DB 查询在协程内完成(members 已预加载,此处仅内存迭代)
agent = None
for m in members:
if m.role == role:
agent = m.agent
break
agent, matched_role, role_fallback = self._resolve_phase_agent(members, role)
if not agent:
logger.warning("团队编排 [%s]: 阶段 %s 缺少角色 '%s',跳过",
logger.warning("团队编排 [%s]: 阶段 %s 无可用成员(角色 '%s'),跳过",
self.team_id, phase_num, role)
return {
"phase": phase_num, "name": phase_name, "role": role,
@@ -508,11 +654,16 @@ class TeamOrchestrator:
"files": [],
}
if role_fallback or matched_role != role:
logger.info("团队编排 [%s]: 阶段 %s 角色 '%s' 解析为 '%s'%s",
self.team_id, phase_num, role, matched_role,
"(兜底 Leader)" if role_fallback else "")
logger.info("团队编排 [%s]: 执行阶段 %s — %s (角色: %s, Agent: %s)",
self.team_id, phase_num, phase_name, role, agent.name)
self.team_id, phase_num, phase_name, matched_role, agent.name)
try:
agent_config = _build_agent_config(agent, self.auto_approve_files)
agent_config = self._acfg(agent)
runtime = AgentRuntime(agent_config)
phase_input = self._build_phase_input(
@@ -555,6 +706,7 @@ class TeamOrchestrator:
return {
"phase": phase_num, "name": phase_name, "role": role,
"actual_role": matched_role, "role_fallback": role_fallback,
"agent_name": agent.name,
"output": output_text,
"success": phase_result.success,
@@ -597,15 +749,13 @@ class TeamOrchestrator:
role = phase.get("role", "developer")
phase_name = phase.get("name", f"阶段 {phase_num}")
agent = None
for m in members:
if m.role == role:
agent = m.agent
break
agent, matched_role, role_fallback = self._resolve_phase_agent(members, role)
base_event = {
"phase": phase_num,
"role": role,
"actual_role": matched_role,
"role_fallback": role_fallback,
"name": phase_name,
"agent": agent.name if agent else None,
}
@@ -627,8 +777,13 @@ class TeamOrchestrator:
"files": [],
}
if role_fallback or matched_role != role:
logger.info("团队编排 [%s]: 阶段 %s 角色 '%s' 解析为 '%s'%s",
self.team_id, phase_num, role, matched_role,
"(兜底 Leader)" if role_fallback else "")
try:
agent_config = _build_agent_config(agent, self.auto_approve_files)
agent_config = self._acfg(agent)
runtime = AgentRuntime(agent_config)
phase_input = self._build_phase_input(
@@ -838,7 +993,7 @@ class TeamOrchestrator:
members = self._load_members()
if not members:
raise ValueError("团队没有成员,请先为角色分配 Agent")
self._model_override = _extract_model_override(team.config) # 四档-③ 部门级配模型
# ─── 创建项目目录 ───
workspace_root = _resolve_workspace_root()
safe_name = _safe_project_name(project_description)
@@ -846,6 +1001,7 @@ class TeamOrchestrator:
project_path.mkdir(parents=True, exist_ok=True)
logger.info("团队编排 [%s]: 项目目录 %s", self.team_id, project_path)
run_start_ts = time.time() # 交付物捕获基准:本轮之后修改的文件才算产物
prev_files = set(_scan_directory_files(project_path))
all_files: List[str] = []
@@ -877,7 +1033,7 @@ class TeamOrchestrator:
logger.info("团队编排 [%s]: Architect 开始扫描 %d 个目标路径",
self.team_id, len(target_paths))
try:
arch_config = _build_agent_config(architect_agent, self.auto_approve_files)
arch_config = self._acfg(architect_agent)
arch_runtime = AgentRuntime(arch_config)
# ─── 程序化预扫描源码 ───
pre_scan_result = scan_multiple_dirs(target_paths)
@@ -932,27 +1088,29 @@ class TeamOrchestrator:
# 如果架构师已作为前置步骤执行,从可用角色中移除,避免 PM 再次规划架构师阶段
if enhanced_description != project_description and "architect" in available_roles:
available_roles.remove("architect")
roles_hint = "/".join(available_roles)
roles_hint = ", ".join(available_roles)
first_role = available_roles[0] if available_roles else "developer"
logger.info("团队编排 [%s]: 规划开始 (workflow=%s, planner=%s, role=%s)",
self.team_id, workflow, planner_agent.name, planner_role)
planner_config = _build_agent_config(planner_agent, self.auto_approve_files)
planner_config = self._acfg(planner_agent)
planner_runtime = AgentRuntime(planner_config)
planner_input = (
f"你是一个团队的项目规划者。请分析以下需求,将工作分解为阶段,并分配给你团队的每个成员。\n\n"
f"需求描述:{enhanced_description}\n"
f"输出文件目录:{project_path}\n\n"
f"你的团队有以下角色:{roles_hint}\n"
f"每个阶段必须分配上述角色之一。\n\n"
f"你的团队有以下角色 slug(必须逐字使用,禁止合并/翻译/自造):{roles_hint}\n"
f'每个阶段的 role 字段必须精确等于上述其中一个 slug(例如 "{first_role}");'
f'严禁把多个角色写成 "a/b" 这种复合形式。\n\n'
f"请严格按照以下 JSON 格式输出执行计划(不要输出任何其他内容,只输出 JSON):\n"
f'{{"project_name":"...","analysis":"...","phases":[\n'
f' {{"phase":1,"name":"...","role":"{roles_hint.split("/")[0] if "/" in roles_hint else roles_hint}","description":"...","expected_output":"...","depends_on":[]}},\n'
f' {{"phase":1,"name":"...","role":"{first_role}","description":"...","expected_output":"...","depends_on":[]}},\n'
f' {{"phase":2,"name":"...","role":"...","description":"...","expected_output":"...","depends_on":[]}}\n'
f'],"acceptance_criteria":["..."]}}\n\n'
f"关键要求:\n"
f"1. phases 数组是强制性的——没有它计划无法执行\n"
f"2. 每个 phase 的 role 必须是上述角色之一\n"
f"2. 每个 phase 的 role 必须逐字复制上述角色 slug 之一(区分大小写、含连字符),禁止组合/翻译/自造\n"
f"3. depends_on 列出必须先完成的阶段编号,无依赖则用[]\n"
f"4. 4-7 个阶段最佳\n"
f"5. 用中文输出\n"
@@ -1090,7 +1248,7 @@ class TeamOrchestrator:
async def _run_qa_review(round_label: str = "") -> tuple:
"""运行一次 QA 审查,返回 (qa_review_dict, raw_output)"""
qa_config = _build_agent_config(qa_agent, self.auto_approve_files)
qa_config = self._acfg(qa_agent)
qa_runtime = AgentRuntime(qa_config)
deliverables = "\n\n---\n\n".join(all_outputs)
prefix = f"({round_label})" if round_label else ""
@@ -1238,6 +1396,9 @@ class TeamOrchestrator:
final_scan = set(_scan_directory_files(project_path))
for fp in all_files:
final_scan.add(fp)
# 兜底:捕获 Agent 写到 project_path 以外兄弟目录 / 脚本生成的本轮产物
for fp in _scan_recent_files(project_path.parent, run_start_ts):
final_scan.add(fp)
results["files"] = sorted(final_scan)
results["completed_at"] = datetime.now().isoformat()
return results
@@ -1256,6 +1417,7 @@ class TeamOrchestrator:
try:
team = self._load_team()
members = self._load_members()
self._model_override = _extract_model_override(team.config) # 四档-③ 部门级配模型
except Exception as e:
yield {"type": "error", "content": str(e)}
return
@@ -1269,6 +1431,7 @@ class TeamOrchestrator:
safe_name = _safe_project_name(project_description)
project_path = workspace_root / "team_projects" / self.team_id / safe_name
project_path.mkdir(parents=True, exist_ok=True)
run_start_ts = time.time() # 交付物捕获基准:本轮之后修改的文件才算产物
prev_files = set(_scan_directory_files(project_path))
all_files: List[str] = []
@@ -1296,7 +1459,7 @@ class TeamOrchestrator:
yield {"type": "architect_start", "agent": architect_agent.name,
"paths": [str(p) for p in target_paths]}
try:
arch_config = _build_agent_config(architect_agent, self.auto_approve_files)
arch_config = self._acfg(architect_agent)
arch_runtime = AgentRuntime(arch_config)
# ─── 程序化预扫描源码 ───
pre_scan_result = scan_multiple_dirs(target_paths)
@@ -1353,24 +1516,26 @@ class TeamOrchestrator:
# 如果架构师已作为前置步骤执行,从可用角色中移除,避免 PM 再次规划架构师阶段
if enhanced_description != project_description and "architect" in available_roles:
available_roles.remove("architect")
roles_hint = "/".join(available_roles)
roles_hint = ", ".join(available_roles)
first_role = available_roles[0] if available_roles else "developer"
planner_config = _build_agent_config(planner_agent, self.auto_approve_files)
planner_config = self._acfg(planner_agent)
planner_runtime = AgentRuntime(planner_config)
planner_input = (
f"你是一个团队的项目规划者。请分析以下需求,将工作分解为阶段,并分配给你团队的每个成员。\n\n"
f"需求描述:{enhanced_description}\n"
f"输出文件目录:{project_path}\n\n"
f"你的团队有以下角色:{roles_hint}\n"
f"每个阶段必须分配上述角色之一。\n\n"
f"你的团队有以下角色 slug(必须逐字使用,禁止合并/翻译/自造):{roles_hint}\n"
f'每个阶段的 role 字段必须精确等于上述其中一个 slug(例如 "{first_role}");'
f'严禁把多个角色写成 "a/b" 这种复合形式。\n\n'
f"请严格按照以下 JSON 格式输出执行计划(不要输出任何其他内容,只输出 JSON):\n"
f'{{"project_name":"...","analysis":"...","phases":[\n'
f' {{"phase":1,"name":"...","role":"{roles_hint.split("/")[0] if "/" in roles_hint else roles_hint}","description":"...","expected_output":"...","depends_on":[]}},\n'
f' {{"phase":1,"name":"...","role":"{first_role}","description":"...","expected_output":"...","depends_on":[]}},\n'
f' {{"phase":2,"name":"...","role":"...","description":"...","expected_output":"...","depends_on":[]}}\n'
f'],"acceptance_criteria":["..."]}}\n\n'
f"关键要求:\n"
f"1. phases 数组是强制性的——没有它计划无法执行\n"
f"2. 每个 phase 的 role 必须是上述角色之一\n"
f"2. 每个 phase 的 role 必须逐字复制上述角色 slug 之一(区分大小写、含连字符),禁止组合/翻译/自造\n"
f"3. depends_on 列出必须先完成的阶段编号,无依赖则用[]\n"
f"4. 4-7 个阶段最佳\n"
f"5. 用中文输出\n"
@@ -1552,7 +1717,7 @@ class TeamOrchestrator:
role = phase.get("role", "developer")
phase_name = phase.get("name", f"阶段 {phase_num}")
agent = self._get_agent_by_role(members, role)
agent, matched_role, role_fallback = self._resolve_phase_agent(members, role)
if not agent:
yield {"type": "phase_done", "phase": phase_num, "name": phase_name,
"role": role, "success": False, "error": f"missing_role:{role}"}
@@ -1564,6 +1729,8 @@ class TeamOrchestrator:
"phase": phase_num,
"name": phase_name,
"role": role,
"actual_role": matched_role,
"role_fallback": role_fallback,
"agent": agent.name,
}
@@ -1615,7 +1782,7 @@ class TeamOrchestrator:
async def _run_qa_review_stream(round_label: str = "") -> tuple:
"""运行一次 QA 审查,返回 (qa_review_dict, raw_output, success)"""
qa_config = _build_agent_config(qa_agent, self.auto_approve_files)
qa_config = self._acfg(qa_agent)
qa_runtime = AgentRuntime(qa_config)
deliverables = "\n\n---\n\n".join(all_outputs)
prefix = f"({round_label})" if round_label else ""
@@ -1792,6 +1959,9 @@ class TeamOrchestrator:
final_scan = set(_scan_directory_files(project_path))
for fp in all_files:
final_scan.add(fp)
# 兜底:捕获 Agent 写到 project_path 以外兄弟目录 / 脚本生成的本轮产物
for fp in _scan_recent_files(project_path.parent, run_start_ts):
final_scan.add(fp)
yield {
"type": "final",
"deliverable": final,