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

@@ -254,11 +254,44 @@ class AgentRuntime:
# 返回 True 表示预算充足;返回 False 或抛出异常表示超限
self.on_llm_invocation: Optional[Callable[[], Any]] = None
# 真实 token 累计(与 token_budget 开关无关,永远统计本次运行的真实用量)
self._real_usage: Dict[str, int] = {
"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, "llm_calls": 0,
}
def _record_real_usage(self, metrics: Dict[str, Any]) -> None:
"""无条件累计每次 LLM 调用的真实用量(来自 API usage)。"""
p = int(metrics.get("prompt_tokens") or 0)
c = int(metrics.get("completion_tokens") or 0)
t = int(metrics.get("total_tokens") or 0) or (p + c)
self._real_usage["prompt_tokens"] += p
self._real_usage["completion_tokens"] += c
self._real_usage["total_tokens"] += t
self._real_usage["llm_calls"] += 1
def _real_token_usage_payload(self) -> Dict[str, Any]:
"""构造随终态事件下发的真实用量 dict(形状含 total_tokens,供下游/前端与计费消费)。"""
return {
"prompt_tokens": self._real_usage["prompt_tokens"],
"completion_tokens": self._real_usage["completion_tokens"],
"total_tokens": self._real_usage["total_tokens"],
"llm_calls": self._real_usage["llm_calls"],
"model": self.config.llm.model,
}
def _attach_token_usage(self, result: AgentResult) -> AgentResult:
"""将 TokenBudget 摘要附加到 AgentResult(若启用)。"""
"""将 token 用量摘要附加到 AgentResult。预算启用则用其摘要;否则用真实累计用量。"""
from app.agent_runtime.schemas import TokenUsageInfo
if self._token_budget:
from app.agent_runtime.schemas import TokenUsageInfo
result.token_usage = TokenUsageInfo(**self._token_budget.summary())
else:
ru = self._real_usage
result.token_usage = TokenUsageInfo(
cumulative_total=ru["total_tokens"],
cumulative_prompt=ru["prompt_tokens"],
cumulative_completion=ru["completion_tokens"],
llm_call_count=ru["llm_calls"],
)
return result
def _build_execution_log_kwargs(self, user_input: str, result: AgentResult, latency_ms: int) -> dict:
@@ -391,6 +424,7 @@ class AgentRuntime:
llm_callback_ctx = {"step_type": "think", "tool_name": None}
def _llm_callback(metrics: Dict[str, Any]):
self._record_real_usage(metrics) # 无条件累计真实 token(不受预算开关影响)
# Token 预算追踪 (P2)
if self._token_budget:
prompt_tok = metrics.get("prompt_tokens", 0)
@@ -869,6 +903,7 @@ class AgentRuntime:
llm_callback_ctx = {"step_type": "think", "tool_name": None}
def _llm_callback(metrics: Dict[str, Any]):
self._record_real_usage(metrics) # 无条件累计真实 token(不受预算开关影响)
# Token 预算追踪 (P2)
if self._token_budget:
prompt_tok = metrics.get("prompt_tokens", 0)
@@ -1016,7 +1051,9 @@ class AgentRuntime:
self.context.add_user_message(fix_prompt)
continue # 回到 ReAct 循环,让 LLM 修正
token_usage_final = self._token_budget.summary() if self._token_budget else None
token_usage_final = self._real_token_usage_payload()
if self._token_budget:
token_usage_final = {**self._token_budget.summary(), **token_usage_final}
yield {
"type": "final",
"content": final_text,
@@ -1278,7 +1315,9 @@ class AgentRuntime:
# 提取知识到全局知识池(即便截断,工具调用序列仍有参考价值)
if last_content:
await self._extract_global_knowledge(user_input, last_content, steps)
token_usage_truncated = self._token_budget.summary() if self._token_budget else None
token_usage_truncated = self._real_token_usage_payload()
if self._token_budget:
token_usage_truncated = {**self._token_budget.summary(), **token_usage_truncated}
yield {
"type": "final",
"content": last_content or "已达最大迭代次数,但模型未返回最终回答。",

View File

@@ -13,12 +13,14 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from app.api.auth import get_current_user
from app.core.database import get_db
from app.models.user import User
from app.models.agent import Agent
from app.models.company import Company, CompanyProject
from app.models.company_project_event import CompanyProjectEvent
from app.models.team import Team, TeamMember
from app.models.company_schedule import CompanySchedule
from app.models.company_knowledge import CompanyKnowledge
@@ -63,6 +65,31 @@ class ExecuteRequest(BaseModel):
ceo_plan: Optional[Dict[str, Any]] = Field(
None, description="已确认的 CEO 计划;提供时跳过二次规划,直接据此执行"
)
max_rounds: Optional[int] = Field(3, ge=1, le=10, description="最大迭代轮次")
# ─── M2-b 运行限额(默认全关,回归安全)───
max_cost_yuan: Optional[float] = Field(None, gt=0, description="成本熔断上限(¥),超阈自动停")
max_duration_min: Optional[float] = Field(None, gt=0, description="超时熔断上限(分钟),超阈自动停")
approve_plan: Optional[bool] = Field(False, description="开 CEO 计划人在环审批,批准才派部门")
approve_rework: Optional[bool] = Field(False, description="开返工人在环审批,批准才进入下一轮")
class SkipRequest(BaseModel):
department_name: str = Field(..., min_length=1, description="要跳过的部门名(尚未开始)")
class ApproveRequest(BaseModel):
decision: str = Field(..., description="审批裁决:approved / rejected")
gate: Optional[str] = Field(None, description="门 id(plan/rework),可选校验用")
feedback: Optional[str] = Field(None, description="人在环注入的反馈(返工门用,注入到部门返工 prompt)")
# ─── Helpers ───
def _assert_company_owner(company: Company, current_user: User) -> None:
"""校验公司归属(创建者或平台管理员),否则 403。"""
if company.user_id != current_user.id and getattr(current_user, "role", "") != "admin":
raise HTTPException(status_code=403, detail="无权操作该公司")
# ─── Endpoints ───
@@ -365,6 +392,47 @@ def remove_department(
return {"message": "部门已移除"}
class DepartmentModelRequest(BaseModel):
"""四档-③ 按部门配模型:provider/model 皆空表示清除覆盖,回归成员 Agent 自身配置。"""
provider: Optional[str] = Field(None, max_length=50, description="LLM 提供方,如 deepseek/openai/anthropic")
model: Optional[str] = Field(None, max_length=100, description="模型名,如 deepseek-v4-pro")
@router.put("/{company_id}/departments/{department_id}/model")
def set_department_model(
company_id: str,
department_id: str,
body: DepartmentModelRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""设置/清除某部门的模型覆盖(存 Team.config["model_override"])。
该部门执行时所有成员统一走此模型(成本/质量分层调优);provider/model 皆空则清除。
"""
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
raise HTTPException(status_code=404, detail="公司不存在")
_assert_company_owner(company, current_user)
team = db.query(Team).filter(Team.id == department_id, Team.parent_company_id == company_id).first()
if not team:
raise HTTPException(status_code=404, detail="部门不存在")
provider = (body.provider or "").strip()
model = (body.model or "").strip()
cfg = dict(team.config or {})
if not provider and not model:
cfg.pop("model_override", None) # 清除
override = None
else:
override = {"provider": provider, "model": model}
cfg["model_override"] = override
team.config = cfg
flag_modified(team, "config") # JSON 原地替换需显式标记脏,确保落库
db.commit()
return {"department_id": department_id, "model_override": override}
# ─── 项目执行 ───
@@ -379,6 +447,7 @@ async def plan_company(
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
raise HTTPException(status_code=404, detail="公司不存在")
_assert_company_owner(company, current_user)
orchestrator = CompanyOrchestrator(db, company_id, current_user.id)
try:
return await orchestrator.preview_plan(body.project_description)
@@ -397,6 +466,7 @@ async def execute_company(
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
raise HTTPException(status_code=404, detail="公司不存在")
_assert_company_owner(company, current_user)
orchestrator = CompanyOrchestrator(db, company_id, current_user.id)
try:
result = await orchestrator.execute(body.project_description, ceo_plan=body.ceo_plan)
@@ -416,6 +486,7 @@ async def execute_company_stream(
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
raise HTTPException(status_code=404, detail="公司不存在")
_assert_company_owner(company, current_user)
orchestrator = CompanyOrchestrator(db, company_id, current_user.id)
@@ -429,6 +500,92 @@ async def execute_company_stream(
return StreamingResponse(event_stream(), media_type="text/event-stream")
@router.post("/{company_id}/execute/async")
async def execute_company_async(
company_id: str,
body: ExecuteRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""异步执行公司级项目(白盒控制室):预建项目行 + 后台任务,立即返回 project_id。
执行在 API 进程内的后台 asyncio 任务中进行,事件落库 company_project_events,
前端经 WS `/ws/company-projects/{project_id}` 或 HTTP `/projects/{pid}/events` 回放+实时观测。
执行不随请求/连接断开而中止(可恢复)。
"""
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
raise HTTPException(status_code=404, detail="公司不存在")
_assert_company_owner(company, current_user)
# 预建项目行(queued)——供 WS 立即订阅;真正执行由后台任务复用该行
project = CompanyProject(
company_id=company_id,
name=body.project_description[:200],
description=body.project_description,
status="queued",
)
db.add(project)
db.commit()
db.refresh(project)
project_id = project.id
from app.services import company_runner
limits = {
"max_cost_yuan": body.max_cost_yuan,
"max_duration_min": body.max_duration_min,
"approve_plan": bool(body.approve_plan),
"approve_rework": bool(body.approve_rework),
}
company_runner.spawn(
project_id, company_id, current_user.id, body.project_description,
max_rounds=body.max_rounds or 3, ceo_plan=body.ceo_plan, limits=limits,
)
return {
"project_id": project_id,
"status": "queued",
"ws_url": f"/api/v1/ws/company-projects/{project_id}",
}
@router.get("/{company_id}/projects/{project_id}/events")
def get_project_events(
company_id: str,
project_id: str,
since_seq: int = Query(0, ge=0),
limit: int = Query(1000, ge=1, le=5000),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""按 seq 回放项目事件(WS 不可用时的 HTTP 兜底 / 续传)。"""
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
raise HTTPException(status_code=404, detail="公司不存在")
_assert_company_owner(company, current_user)
project = (
db.query(CompanyProject)
.filter(CompanyProject.id == project_id, CompanyProject.company_id == company_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
rows = (
db.query(CompanyProjectEvent)
.filter(CompanyProjectEvent.project_id == project_id, CompanyProjectEvent.seq > since_seq)
.order_by(CompanyProjectEvent.seq)
.limit(limit)
.all()
)
events = [r.to_envelope() for r in rows]
last_seq = rows[-1].seq if rows else since_seq
return {
"events": events,
"last_seq": last_seq,
"status": project.status,
"finished": project.status in ("completed", "failed", "stopped"),
}
# ─── 统计 ───
@@ -531,10 +688,229 @@ def export_company(
lines.append("")
markdown = "\n".join(lines)
from urllib.parse import quote
safe = (company.name or "company")[:60].replace('"', "").replace("/", "_").replace("\\", "_")
ascii_fallback = safe.encode("ascii", "ignore").decode("ascii").strip() or "company"
fname = f"{safe}_export.md"
ascii_fname = f"{ascii_fallback}_export.md"
disposition = (
f'attachment; filename="{ascii_fname}"; '
f"filename*=UTF-8''{quote(fname)}"
)
return Response(
content=markdown,
media_type="text/markdown; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{company.name}_export.md"'},
headers={"Content-Disposition": disposition},
)
# ─── 四档-④:单项目运行报告导出 ───
_REPORT_MILESTONES = {
"company_start": "项目启动",
"ceo_plan_done": "CEO 规划完成",
"awaiting_approval": "等待人工审批",
"approval_resolved": "审批完成",
"paused": "已暂停",
"resumed": "已继续",
"dept_done": "部门完成",
"dept_skipped": "部门跳过",
"ceo_score_done": "CEO 评审完成",
"rework_start": "返工开始",
"rework_done": "返工完成",
"budget_tripped": "预算熔断",
"timeout_tripped": "超时熔断",
"company_terminated": "项目终止",
"company_done": "项目完成",
}
def _fmt_dur_ms(ms) -> str:
try:
s = int(ms) // 1000
except (TypeError, ValueError):
return "—"
if s < 60:
return f"{s}s"
return f"{s // 60}m{s % 60:02d}s"
def _render_project_report(company, project, events) -> str:
"""把单个项目渲染成 Markdown 运行报告(纯函数,便于隔离测试)。
数据源:project.ceo_plan / review_scores + 事件流(终态事件取汇总、里程碑做时间轴)。
对缺字段一律 .get 兜底,不抛。
"""
ceo_plan = project.ceo_plan if isinstance(project.ceo_plan, dict) else {}
review_scores = project.review_scores if isinstance(project.review_scores, list) else []
# 终态事件汇总(company_done / company_terminated 取最后一个)
terminal = {}
for e in events:
if e.type in ("company_done", "company_terminated"):
terminal = e.payload or {}
total_cost = terminal.get("total_cost_yuan")
total_dur_ms = terminal.get("total_duration_ms")
overall_score = terminal.get("overall_score")
if not overall_score and review_scores:
overall_score = review_scores[0].get("overall_score")
dept_results = terminal.get("departments") or []
L = []
L.append(f"# 运行报告 · {project.name}")
L.append("")
L.append(f"- **公司**: {company.name}")
L.append(f"- **项目描述**: {project.description or project.name}")
L.append(f"- **状态**: {project.status}")
L.append(f"- **创建时间**: {project.created_at.isoformat() if project.created_at else '未知'}")
if project.completed_at:
L.append(f"- **完成时间**: {project.completed_at.isoformat()}")
if getattr(project, "round_count", None):
L.append(f"- **迭代轮次**: {project.round_count}")
L.append("")
L.append("## 概要")
L.append("")
L.append(f"- **综合评分**: {overall_score if overall_score is not None else '—'} / 10")
L.append(f"- **总成本**: ¥{total_cost if total_cost is not None else '—'}")
L.append(f"- **总时长**: {_fmt_dur_ms(total_dur_ms)}")
L.append(f"- **部门数**: {len(dept_results) or len(ceo_plan.get('departments', []))}")
L.append("")
# CEO 规划
analysis = ceo_plan.get("analysis", "")
dept_plans = ceo_plan.get("departments", []) if isinstance(ceo_plan, dict) else []
if analysis or dept_plans:
L.append("## CEO 规划")
L.append("")
if analysis:
L.append(analysis)
L.append("")
for dp in dept_plans:
name = dp.get("department_name") or dp.get("department_type") or "未知部门"
L.append(f"### {name}")
if dp.get("goal"):
L.append(f"- **目标**: {dp['goal']}")
deps = dp.get("dependencies") or []
if deps:
L.append(f"- **依赖**: {', '.join(str(d) for d in deps)}")
dlv = dp.get("deliverables") or []
if dlv:
L.append("- **预期交付物**:")
for d in dlv:
L.append(f" - {d}")
L.append("")
# 部门执行结果
if dept_results:
L.append("## 部门执行结果")
L.append("")
for dr in dept_results:
name = dr.get("department_name", "未知部门")
ok = "✓ 成功" if dr.get("success") else ("✗ 失败" if dr.get("success") is False else "—")
L.append(f"### {name} — {ok}")
meta = []
if dr.get("cost_yuan") is not None:
meta.append(f"成本 ¥{dr.get('cost_yuan')}")
if dr.get("tokens_used") is not None:
meta.append(f"tokens {dr.get('tokens_used')}")
if dr.get("duration_ms") is not None:
meta.append(f"耗时 {_fmt_dur_ms(dr.get('duration_ms'))}")
if meta:
L.append("- " + " · ".join(meta))
result = dr.get("result") if isinstance(dr.get("result"), dict) else {}
deliverable = (result.get("deliverable") or "").strip()
if deliverable:
L.append("- **交付物摘录**:")
L.append("")
L.append("> " + deliverable[:1200].replace("\n", "\n> "))
files = result.get("files") or []
if files:
L.append(f"- **产出文件** ({len(files)}): {', '.join(str(f) for f in files[:20])}")
if dr.get("error"):
L.append(f"- **错误**: {dr.get('error')}")
L.append("")
# CEO 评审打分
if review_scores:
L.append("## CEO 评审打分")
L.append("")
for s in review_scores:
nm = s.get("name", "")
sc = s.get("score", "—")
passed = "✓" if s.get("pass") else "✗"
L.append(f"### {nm}: {sc}/10 {passed}")
if s.get("feedback"):
L.append(str(s["feedback"])[:800])
areas = s.get("improvement_areas") or []
if areas:
L.append("**改进方向**:")
for a in areas:
L.append(f"- {a}")
L.append("")
# 执行时间轴
milestones = [e for e in events if e.type in _REPORT_MILESTONES]
if milestones:
t0 = milestones[0].created_at
L.append("## 执行时间轴")
L.append("")
for e in milestones:
delta = "+00:00"
if t0 and e.created_at:
secs = int((e.created_at - t0).total_seconds())
delta = f"+{secs // 60:02d}:{secs % 60:02d}"
label = _REPORT_MILESTONES.get(e.type, e.type)
payload = e.payload or {}
detail = payload.get("department_name") or payload.get("decision") or payload.get("reason") or ""
L.append(f"- `[{delta}]` {label}" + (f" — {detail}" if detail else ""))
L.append("")
return "\n".join(L)
@router.get("/{company_id}/projects/{project_id}/report")
def export_project_report(
company_id: str,
project_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""导出单个项目的运行报告为 Markdown(含概要/规划/部门结果/评分/时间轴)。"""
from fastapi.responses import Response
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
raise HTTPException(status_code=404, detail="公司不存在")
_assert_company_owner(company, current_user)
project = (
db.query(CompanyProject)
.filter(CompanyProject.id == project_id, CompanyProject.company_id == company_id)
.first()
)
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
events = (
db.query(CompanyProjectEvent)
.filter(CompanyProjectEvent.project_id == project_id)
.order_by(CompanyProjectEvent.seq)
.all()
)
markdown = _render_project_report(company, project, events)
from urllib.parse import quote
safe = (project.name or "project")[:60].replace('"', "").replace("/", "_").replace("\\", "_")
ascii_fallback = safe.encode("ascii", "ignore").decode("ascii").strip() or "project"
fname = f"report_{safe}.md"
ascii_fname = f"report_{ascii_fallback}.md"
disposition = (
f'attachment; filename="{ascii_fname}"; '
f"filename*=UTF-8''{quote(fname)}"
)
return Response(
content=markdown,
media_type="text/markdown; charset=utf-8",
headers={"Content-Disposition": disposition},
)
@@ -820,3 +1196,108 @@ def recover_project(company_id: str, project_id: str, db: Session = Depends(get_
if not result["success"]:
raise HTTPException(status_code=400, detail=result["error"])
return result
# ─── M2-a 运行时控制:停止 / 暂停 / 继续 / 跳过部门 ───
def _load_owned_project(company_id: str, project_id: str, db: Session, current_user: User) -> CompanyProject:
"""校验公司归属并返回项目,否则 404/403。"""
company = db.query(Company).filter(Company.id == company_id).first()
if not company:
raise HTTPException(status_code=404, detail="公司不存在")
_assert_company_owner(company, current_user)
project = db.query(CompanyProject).filter(
CompanyProject.id == project_id, CompanyProject.company_id == company_id
).first()
if not project:
raise HTTPException(status_code=404, detail="项目不存在")
return project
@router.post("/{company_id}/projects/{project_id}/stop")
def stop_project(company_id: str, project_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""停止(硬取消)运行中的项目任务,幂等。取消后 runner 落 stopped。"""
_load_owned_project(company_id, project_id, db, current_user)
from app.services import company_runner
stopped = company_runner.stop(project_id)
return {"stopped": stopped, "status": "stopped" if stopped else "not_running"}
@router.post("/{company_id}/projects/{project_id}/pause")
def pause_project(company_id: str, project_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""暂停:不再开新波次/新轮次(在飞部门跑完才生效)。"""
project = _load_owned_project(company_id, project_id, db, current_user)
from app.services import company_runner
if not company_runner.is_running(project_id):
raise HTTPException(status_code=409, detail="项目未在运行")
company_runner.get_control(project_id).pause_event.clear()
project.status = "paused"
db.commit()
return {"paused": True}
@router.post("/{company_id}/projects/{project_id}/resume")
def resume_project(company_id: str, project_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""继续:解除暂停。"""
project = _load_owned_project(company_id, project_id, db, current_user)
from app.services import company_runner
if not company_runner.is_running(project_id):
raise HTTPException(status_code=409, detail="项目未在运行")
company_runner.get_control(project_id).pause_event.set()
project.status = "in_progress"
db.commit()
return {"resumed": True}
@router.post("/{company_id}/projects/{project_id}/skip")
def skip_department(company_id: str, project_id: str, body: SkipRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""跳过一个尚未开始的部门(就绪选取时拦截,下游依赖照常推进)。"""
_load_owned_project(company_id, project_id, db, current_user)
from app.services import company_runner
if not company_runner.is_running(project_id):
raise HTTPException(status_code=409, detail="项目未在运行")
company_runner.get_control(project_id).skip_depts.add(body.department_name)
return {"skipped": body.department_name}
@router.post("/{company_id}/projects/{project_id}/approve")
def approve_gate(company_id: str, project_id: str, body: ApproveRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""裁决当前待批的审批门(CEO 计划 / 返工)。放行编排器 await。"""
_load_owned_project(company_id, project_id, db, current_user)
from app.services import company_runner
if not company_runner.is_running(project_id):
raise HTTPException(status_code=409, detail="项目未在运行")
ctrl = company_runner.get_control(project_id)
if not ctrl.pending_gate:
raise HTTPException(status_code=409, detail="当前无待审批的门")
decision = "approved" if str(body.decision).strip().lower() in ("approved", "approve", "yes", "ok") else "rejected"
gate = ctrl.pending_gate
ctrl.resolve_gate(decision, (body.feedback or "").strip())
return {"resolved": decision, "gate": gate}
@router.post("/{company_id}/projects/{project_id}/departments/{department_name}/cancel")
async def cancel_department(company_id: str, project_id: str, department_name: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""M2-c:硬取消运行中的单个部门任务(不停整个公司)。下游依赖照常推进。"""
_load_owned_project(company_id, project_id, db, current_user)
from app.services import company_runner
if not company_runner.is_running(project_id):
raise HTTPException(status_code=409, detail="项目未在运行")
cancelled = company_runner.cancel_department(project_id, department_name)
return {"cancelled": cancelled, "department_name": department_name}
@router.post("/{company_id}/projects/{project_id}/departments/{department_name}/rework")
async def rework_department(company_id: str, project_id: str, department_name: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
"""M2-c:项目跑完后只重跑一个部门;新事件续写进同一流,替换其交付物与打分。"""
project = _load_owned_project(company_id, project_id, db, current_user)
from app.services import company_runner
if company_runner.is_running(project_id):
raise HTTPException(status_code=409, detail="项目在运行中,不能返工")
dept_plans = (project.ceo_plan or {}).get("departments", []) if project.ceo_plan else []
names = {dp.get("department_name") for dp in dept_plans} | {dp.get("department_type") for dp in dept_plans}
if department_name not in names:
raise HTTPException(status_code=400, detail="该部门不在项目计划中")
company_runner.spawn_rework(project_id, company_id, current_user.id, department_name)
return {"reworking": department_name}

View File

@@ -0,0 +1,139 @@
"""
公司项目「白盒控制室」WebSocket — 事件回放 + 准实时推送
数据源为 MySQL 表 company_project_events(后台执行器持续落库)。
连接后按 since_seq 回放历史事件,随后轮询该表 seq>last 的新事件推送,
直至项目终态(completed/failed/stopped)。轮询式(非广播)→ 顺序正确、无回放/实时竞态、天然可恢复。
鉴权:JWT via query ?token=,校验用户 → 项目所属公司归属(创建者或平台管理员)。
"""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from app.core.database import SessionLocal
from app.core.security import decode_access_token
from app.models.user import User
from app.models.company import Company, CompanyProject
from app.models.company_project_event import CompanyProjectEvent
from typing import Optional
import json
import asyncio
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
_TERMINAL = ("completed", "failed", "stopped")
_POLL_INTERVAL = 0.8 # 秒
def _validate_company_ws_token(token: Optional[str], project_id: str) -> dict:
"""校验 WS token 并确认项目归属。失败返回 {error, code}。"""
if not token:
return {"error": "缺少认证令牌", "code": 4001}
payload = decode_access_token(token)
if payload is None:
return {"error": "无效的访问令牌", "code": 4001}
user_id = payload.get("sub")
if not user_id:
return {"error": "无效的令牌载荷", "code": 4001}
db = SessionLocal()
try:
user = db.query(User).filter(User.id == user_id).first()
if not user:
return {"error": "用户不存在", "code": 4001}
if user.status == "deleted":
return {"error": "账号已注销", "code": 4003}
project = db.query(CompanyProject).filter(CompanyProject.id == project_id).first()
if not project:
return {"error": "项目不存在", "code": 4004}
company = db.query(Company).filter(Company.id == project.company_id).first()
if not company:
return {"error": "公司不存在", "code": 4004}
# 平台管理员绕过;否则须为公司创建者
if user.role != "admin" and company.user_id != user_id:
return {"error": "无权查看此项目", "code": 4003}
return {"user_id": user_id, "workspace_id": payload.get("ws", "")}
finally:
db.close()
@router.websocket("/api/v1/ws/company-projects/{project_id}")
async def company_project_ws(
websocket: WebSocket,
project_id: str,
token: Optional[str] = Query(None),
since_seq: int = Query(0),
):
"""回放 + 准实时推送公司项目执行事件。"""
auth = _validate_company_ws_token(token, project_id)
if "error" in auth:
await websocket.accept()
await websocket.send_json({"type": "error", "message": auth["error"], "code": auth["code"]})
await websocket.close(code=auth["code"])
return
await websocket.accept()
db = SessionLocal()
last = since_seq
empty_after_terminal = 0
try:
while True:
# 1) 推送 seq>last 的新事件(含首次回放)
rows = (
db.query(CompanyProjectEvent)
.filter(CompanyProjectEvent.project_id == project_id, CompanyProjectEvent.seq > last)
.order_by(CompanyProjectEvent.seq)
.limit(500)
.all()
)
for r in rows:
await websocket.send_json(r.to_envelope())
last = r.seq
# 2) 检查终态(强制刷新 ORM 缓存以读到最新 status)
db.expire_all()
project = db.query(CompanyProject).filter(CompanyProject.id == project_id).first()
if not project:
await websocket.send_json({"type": "error", "message": "项目不存在"})
break
status = project.status
if status in _TERMINAL:
# 终态后需再排空尾部事件(company_done 可能晚于 status 落库)
if not rows:
empty_after_terminal += 1
if empty_after_terminal >= 2:
await websocket.send_json(
{"type": "_stream_end", "status": status, "last_seq": last}
)
break
else:
empty_after_terminal = 0
# 3) 等待客户端 ping 或超时(轮询间隔)
try:
data = await asyncio.wait_for(websocket.receive_text(), timeout=_POLL_INTERVAL)
try:
msg = json.loads(data)
if msg.get("type") == "ping":
await websocket.send_json({"type": "pong"})
except Exception:
pass
except asyncio.TimeoutError:
pass
except WebSocketDisconnect:
break
except WebSocketDisconnect:
pass
except Exception as e:
logger.error("公司项目 WS 错误 [%s]: %s", project_id, e)
try:
await websocket.send_json({"type": "error", "message": str(e)})
except Exception:
pass
finally:
db.close()

View File

@@ -101,7 +101,31 @@ class Settings(BaseSettings):
# 单执行工具实际执行次数上限(LLM function calling 每执行一个工具计 1)
WORKFLOW_MAX_TOOL_CALLS_PER_RUN: int = 500
# ── 虚拟公司:事件批量落库(三档-a)──
# 每积攒 N 条事件即刷库(bulk commit);N=1 等价旧的逐条 commit(回退开关)
COMPANY_EVENT_FLUSH_MAX_EVENTS: int = 25
# 或每 T 秒强制刷库一次(保证 WS 可见延迟 ≤ ~1.5s;WS 轮询 0.8s)
COMPANY_EVENT_FLUSH_INTERVAL_SEC: float = 0.7
# ── 虚拟公司:并发上限(三档-b)──
# 同时运行的公司项目数上限;超出的排队(真实队列),空出名额再启动。调高≈无限
MAX_CONCURRENT_COMPANY_PROJECTS: int = 3
# 排队期间刷新 updated_at 的间隔(防长队被僵尸监管误杀,ZOMBIE_TIMEOUT=900s)
COMPANY_QUEUE_HEARTBEAT_SEC: float = 60.0
# 单次编排每一波并行运行的部门数上限(防超大 CEO 计划一次拉起过多部门)。调高≈无限
MAX_CONCURRENT_DEPARTMENTS: int = 4
# ── 虚拟公司:CEO 规划回喂历史知识(四档-② 学习闭环)──
# 规划前检索本公司历史知识(CompanyKnowledge)按相关性注入 CEO prompt,越跑越懂本公司。关闭即回归旧行为
COMPANY_CEO_MEMORY_ENABLED: bool = True
# 最多注入的历史知识条数(按相关性+category 权重排序取 TopN)
COMPANY_CEO_MEMORY_MAX_ENTRIES: int = 5
# 注入历史知识的总字数上限(防 CEO prompt 膨胀/超 context)
COMPANY_CEO_MEMORY_MAX_CHARS: int = 4000
# 项目终态时自动沉淀知识到 CompanyKnowledge(闭合学习闭环;否则需手动调 generate 接口)
COMPANY_AUTO_EXTRACT_KNOWLEDGE: bool = True
# 外部访问地址(用于飞书通知中的详情链接等)
EXTERNAL_URL: str = ""

View File

@@ -81,6 +81,7 @@ def init_db():
import app.models.billing
import app.models.company_schedule
import app.models.company_knowledge
import app.models.company_project_event
Base.metadata.create_all(bind=engine)
# v1.1.0: 手机验证字段迁移(safe ALTER)

View File

@@ -120,7 +120,7 @@ process_memory_bytes = Gauge(
)
process_open_fds = Gauge(
"process_open_fds", "进程打开的文件描述符数"
"app_process_open_fds", "进程打开的文件描述符数"
)
db_connections_active = Gauge(

View File

@@ -527,7 +527,7 @@ async def startup_event():
logger.error(f"公司定时调度器启动失败: {e}")
# 注册路由
from app.api import auth, workspaces, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_sessions, agent_branches, agent_monitoring, knowledge_base, knowledge_dashboard, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins, agent_market, goals, tasks, system_logs, audit_logs, feedback, agent_swarm, push, voice, fcm, scene_contracts, teams, agent_memory, legal, app_update, analytics, billing, users, api_keys, company_presets, companies
from app.api import auth, workspaces, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_sessions, agent_branches, agent_monitoring, knowledge_base, knowledge_dashboard, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins, agent_market, goals, tasks, system_logs, audit_logs, feedback, agent_swarm, push, voice, fcm, scene_contracts, teams, agent_memory, legal, app_update, analytics, billing, users, api_keys, company_presets, companies, company_ws
app.include_router(auth.router)
app.include_router(workspaces.router)
@@ -584,6 +584,7 @@ app.include_router(users.router)
app.include_router(api_keys.router)
app.include_router(company_presets.router)
app.include_router(companies.router)
app.include_router(company_ws.router)
@app.on_event("shutdown")
async def shutdown_event():

View File

@@ -34,10 +34,11 @@ from app.models.workspace import Workspace, WorkspaceMembership
from app.models.scene_contract import SceneContract
from app.models.team import Team, TeamMember
from app.models.company import Company, CompanyProject
from app.models.company_project_event import CompanyProjectEvent
from app.models.company_schedule import CompanySchedule
from app.models.company_knowledge import CompanyKnowledge
from app.models.chat_message import ChatMessage
from app.models.agent_session import AgentSession
from app.models.billing import BillingPlan, BillingOrder, UserSubscription, UsageRecord
__all__ = ["User", "Workflow", "WorkflowVersion", "Agent", "GlobalKnowledge", "AgentRating", "AgentFavorite", "Execution", "ExecutionLog", "ModelConfig", "DataSource", "WorkflowTemplate", "TemplateRating", "TemplateFavorite", "NodeTemplate", "Role", "Permission", "WorkflowPermission", "AgentPermission", "AlertRule", "AlertLog", "PersistentUserMemory", "AgentLLMLog", "AgentVectorMemory", "AgentLearningPattern", "AgentSchedule", "KnowledgeBase", "Document", "DocumentChunk", "Notification", "UserFeishuOpenId", "NodePlugin", "OrchestrationTemplate", "Goal", "Task", "AgentExecutionLog", "UserBehaviorLog", "KnowledgeEntry", "UserFingerprint", "ShadowComparison", "FeedbackRecord", "AuditLog", "Workspace", "WorkspaceMembership", "SceneContract", "Team", "TeamMember", "Company", "CompanyProject", "CompanySchedule", "CompanyKnowledge", "ChatMessage", "AgentSession", "BillingPlan", "BillingOrder", "UserSubscription", "UsageRecord"]
__all__ = ["User", "Workflow", "WorkflowVersion", "Agent", "GlobalKnowledge", "AgentRating", "AgentFavorite", "Execution", "ExecutionLog", "ModelConfig", "DataSource", "WorkflowTemplate", "TemplateRating", "TemplateFavorite", "NodeTemplate", "Role", "Permission", "WorkflowPermission", "AgentPermission", "AlertRule", "AlertLog", "PersistentUserMemory", "AgentLLMLog", "AgentVectorMemory", "AgentLearningPattern", "AgentSchedule", "KnowledgeBase", "Document", "DocumentChunk", "Notification", "UserFeishuOpenId", "NodePlugin", "OrchestrationTemplate", "Goal", "Task", "AgentExecutionLog", "UserBehaviorLog", "KnowledgeEntry", "UserFingerprint", "ShadowComparison", "FeedbackRecord", "AuditLog", "Workspace", "WorkspaceMembership", "SceneContract", "Team", "TeamMember", "Company", "CompanyProject", "CompanyProjectEvent", "CompanySchedule", "CompanyKnowledge", "ChatMessage", "AgentSession", "BillingPlan", "BillingOrder", "UserSubscription", "UsageRecord"]

View File

@@ -0,0 +1,44 @@
"""
公司级项目执行事件 — 白盒控制室的持久事件流
每条编排事件(CEO 规划 / 部门 ReAct / 打分 / 完成…)落一行,
供控制室按 seq 回放、断线恢复、审计。表由 create_all 自动创建。
"""
from sqlalchemy import Column, String, Integer, DateTime, JSON, ForeignKey, Index, func
from sqlalchemy.dialects.mysql import CHAR
from app.core.database import Base
import uuid
class CompanyProjectEvent(Base):
"""公司项目执行事件(一次编排 = 一串按 seq 递增的事件)。"""
__tablename__ = "company_project_events"
id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="事件ID")
project_id = Column(
CHAR(36),
ForeignKey("company_projects.id", ondelete="CASCADE"),
nullable=False,
comment="所属项目ID",
)
seq = Column(Integer, nullable=False, comment="项目内自增序号,从 1")
type = Column(String(50), nullable=False, comment="事件类型")
payload = Column(JSON, nullable=True, comment="事件负载(原编排事件字段)")
created_at = Column(DateTime, default=func.now(), comment="落库时间")
__table_args__ = (
Index("idx_cpe_project_seq", "project_id", "seq"),
)
def __repr__(self):
return f"<CompanyProjectEvent(project_id={self.project_id}, seq={self.seq}, type={self.type})>"
def to_envelope(self) -> dict:
"""转为前端消费的事件信封。"""
return {
"seq": self.seq,
"pid": self.project_id,
"type": self.type,
"payload": self.payload,
"ts": self.created_at.timestamp() if self.created_at else None,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,483 @@
"""
公司项目后台执行器 — 白盒控制室的执行解耦层
把公司级流式执行从「请求协程内跑完」解耦为「API 进程内的后台 asyncio 任务」:
- 执行不再随 SSE/WS 断开而中止(可恢复)
- 每条编排事件落库 company_project_events(供 WS/HTTP 按 seq 回放)
- 周期性刷新 project.updated_at(心跳),避免僵尸监管误杀长任务
前提:API 为单 uvicorn worker,后台任务与 WS 端点同进程共享 DB/表。
"""
from __future__ import annotations
import asyncio
import logging
import time
from datetime import datetime
from typing import Any, Dict, Optional
from sqlalchemy import func
from app.core.config import settings
from app.core.database import SessionLocal
from app.models.company import CompanyProject
from app.models.company_project_event import CompanyProjectEvent
from app.services.company_orchestrator import CompanyOrchestrator
logger = logging.getLogger(__name__)
# 进程内后台任务句柄:防止 asyncio.create_task 结果被 GC;完成后自动清理
_RUNNING: Dict[str, asyncio.Task] = {}
# 三档-b:全局项目并发信号量(lazy,绑定到运行时事件循环)。单 worker 单 loop 单实例。
_PROJECT_SEM: Optional[asyncio.Semaphore] = None
def _get_project_sem() -> asyncio.Semaphore:
"""取(缺则建)全局项目并发信号量。lazy 构造避免 import 期绑错事件循环。"""
global _PROJECT_SEM
if _PROJECT_SEM is None:
_PROJECT_SEM = asyncio.Semaphore(max(1, int(settings.MAX_CONCURRENT_COMPANY_PROJECTS)))
return _PROJECT_SEM
class ProjectControl:
"""单个运行中项目的进程内控制态(M2-a:暂停/继续/跳过)。
与 `_RUNNING` 同为进程内状态:编排任务与 API handler 同进程,靠同一对象通信。
进程重启则控制态随运行任务一起消失(任务本就死),无持久化需求。
"""
def __init__(self) -> None:
self.pause_event = asyncio.Event()
self.pause_event.set() # set = 运行中;clear = 暂停(编排器在检查点 await)
self.skip_depts: set[str] = set() # 待跳过的(未开始)部门名
# M2-b 人在环审批门:set = 放行/无门;clear = 编排器在门前 await 等人裁决
self.approval_event = asyncio.Event()
self.approval_event.set()
self.approval_decision: str = "" # 最近一次裁决:"approved" / "rejected"
self.approval_feedback: str = "" # 人在环注入的反馈文本(返工门用,供下一轮注入部门 prompt)
self.pending_gate: str = "" # 非空=有门待批,值即门 id:"plan" / "rework"
# M2-c 部门粒度控制:运行中部门 name→task(供单部门硬取消)
self.dept_tasks: Dict[str, asyncio.Task] = {}
self.cancelled_depts: set[str] = set() # 已请求取消的部门名(记录意图)
def open_gate(self, gate: str) -> None:
"""编排器开门等审批:登记门 id、清裁决、阻塞后续 await。"""
self.pending_gate = gate
self.approval_decision = ""
self.approval_feedback = ""
self.approval_event.clear()
def resolve_gate(self, decision: str, feedback: str = "") -> None:
"""API 端裁决:落决定+可选反馈并放行编排器 await。pending_gate 由编排器过门后清。"""
self.approval_decision = decision
self.approval_feedback = feedback or ""
self.approval_event.set()
# 进程内控制注册表,键为 project_id
_CONTROL: Dict[str, ProjectControl] = {}
def get_control(project_id: str) -> ProjectControl:
"""取(缺则建)项目控制态;API 与编排器共用同一对象,幂等。"""
ctrl = _CONTROL.get(project_id)
if ctrl is None:
ctrl = ProjectControl()
_CONTROL[project_id] = ctrl
return ctrl
def clear_control(project_id: str) -> None:
_CONTROL.pop(project_id, None)
def stop(project_id: str) -> bool:
"""请求停止(硬取消)运行中的项目任务。返回是否有任务被取消。
复用 run_company_project 的 CancelledError→stopped 雏形(落 company_terminated)。
"""
task = _RUNNING.get(project_id)
if task is not None and not task.done():
task.cancel()
return True
return False
def cancel_department(project_id: str, name: str) -> bool:
"""M2-c:硬取消运行中的单个部门任务(不停整个公司)。返回是否有任务被取消。
编排器在建部门 task 时把句柄登记进 ctrl.dept_tasks;此处 cancel 触发
`_run_department_stream` 的 CancelledError 分支,仍发 dept_done 让波次推进、
下游依赖照常解锁。
"""
ctrl = get_control(project_id)
task = ctrl.dept_tasks.get(name)
if task is not None and not task.done():
ctrl.cancelled_depts.add(name)
task.cancel()
return True
return False
def _max_seq(db, project_id: str) -> int:
"""当前项目已落库事件的最大 seq(无则 0)。返工据此续写,避免前端按 seq 丢弃。"""
try:
return db.query(func.max(CompanyProjectEvent.seq)).filter(
CompanyProjectEvent.project_id == project_id
).scalar() or 0
except Exception:
return 0
_TERMINAL = {"completed", "failed", "stopped"}
_MAX_STR = 8192 # 单个字符串字段落库上限,防止行爆(如超长 tool_call.input)
def _truncate(obj: Any, limit: int = _MAX_STR):
"""递归截断过长字符串,控制事件行体积。"""
if isinstance(obj, str):
return obj if len(obj) <= limit else obj[:limit] + f"…[truncated {len(obj) - limit} chars]"
if isinstance(obj, dict):
return {k: _truncate(v, limit) for k, v in obj.items()}
if isinstance(obj, list):
return [_truncate(v, limit) for v in obj]
return obj
# 三档-a:终态/关键事件类型——立即 force-flush 落地,保证 WS 不截断 finale
_TERMINAL_EVENT_TYPES = {
"company_done", "company_terminated", "rework_done",
"budget_tripped", "timeout_tripped", "error", "review_parse_failed",
}
class _EventBuffer:
"""按 run 缓冲事件、批量落库(三档-a)。单事件循环单写者,无需锁。
- add() 只入内存;flush() 一次事务 add_all + 单 commit(省远程 MySQL 逐条 RTT)。
- 心跳折叠进 flush(每 hb_interval 秒顺带 bump updated_at)。
- 终态/错误事件走 flush_terminal 立即落地,配合 WS 的 2-空轮询宽限防截断 finale。
- 用 add_all(非 bulk_insert_mappings):模型 id/created_at 是 Python 侧 default,
add_all 构 ORM 实例默认照常触发;SQLAlchemy 1.4+ 仍渲染多行 INSERT。
"""
def __init__(self, project_id: str) -> None:
self.project_id = project_id
self.rows: list[dict] = []
self.last_flush = time.time()
self.last_hb = time.time()
self.max_events = max(1, int(settings.COMPANY_EVENT_FLUSH_MAX_EVENTS))
self.max_interval = float(settings.COMPANY_EVENT_FLUSH_INTERVAL_SEC)
self.hb_interval = 5.0
def add(self, seq: int, evt: Dict[str, Any]) -> None:
self.rows.append({
"project_id": self.project_id,
"seq": seq,
"type": str(evt.get("type", "unknown"))[:50],
"payload": _truncate(evt),
})
def should_flush(self, now: float) -> bool:
return len(self.rows) >= self.max_events or (
bool(self.rows) and (now - self.last_flush) >= self.max_interval
)
def flush(self, db, force: bool = False) -> None:
"""一次事务落盘缓冲事件 + 折叠心跳。失败不阻断执行(沿用旧契约)。"""
now = time.time()
hb_due = force or (now - self.last_hb) >= self.hb_interval
if not self.rows and not hb_due:
return
pending = len(self.rows)
try:
if self.rows:
db.add_all([CompanyProjectEvent(**r) for r in self.rows])
if hb_due:
db.query(CompanyProject).filter(CompanyProject.id == self.project_id).update(
{"updated_at": datetime.now()}
)
db.commit()
self.rows.clear()
self.last_flush = now
if hb_due:
self.last_hb = now
except Exception as e:
db.rollback()
logger.warning("事件批量落库失败 [%s, %d 行]: %s", self.project_id, pending, e)
# 保留 rows 下次重试,但上限 10*N 防持续故障时无限增长(丢最旧)
cap = self.max_events * 10
if len(self.rows) > cap:
dropped = len(self.rows) - cap
self.rows = self.rows[-cap:]
logger.warning("事件缓冲超限,丢弃最旧 %d 行 [%s]", dropped, self.project_id)
def flush_terminal(self, db, seq: int, evt: Dict[str, Any]) -> None:
"""终态/错误事件:入队并立即 force-flush(事件 + status-bump 一次 commit 落地)。"""
self.add(seq, evt)
self.flush(db, force=True)
def _write_queued(project_id: str, seq: int) -> None:
"""排队中:临时 session 写一条 queued 事件 + 刷新心跳后关闭(不占用长连接)。"""
tmp = SessionLocal()
try:
_persist_event(tmp, project_id, seq,
{"type": "queued", "reason": "waiting_slot", "_ts": time.time()})
_heartbeat(tmp, project_id)
finally:
tmp.close()
def _bump_heartbeat(project_id: str) -> None:
"""排队等待中周期性刷新 updated_at,防僵尸监管误杀(临时 session)。"""
tmp = SessionLocal()
try:
_heartbeat(tmp, project_id)
finally:
tmp.close()
def is_running(project_id: str) -> bool:
task = _RUNNING.get(project_id)
return task is not None and not task.done()
def spawn(project_id: str, company_id: str, user_id: str, description: str,
max_rounds: int = 3, ceo_plan: Optional[Dict[str, Any]] = None,
limits: Optional[Dict[str, Any]] = None) -> asyncio.Task:
"""在当前事件循环内派发后台执行任务,并登记句柄防 GC。"""
task = asyncio.create_task(
run_company_project(project_id, company_id, user_id, description, max_rounds, ceo_plan, limits)
)
_RUNNING[project_id] = task
def _on_done(t: asyncio.Task) -> None:
_RUNNING.pop(project_id, None)
clear_control(project_id) # 清理控制态,避免注册表泄漏
task.add_done_callback(_on_done)
return task
async def run_company_project(
project_id: str,
company_id: str,
user_id: str,
description: str,
max_rounds: int = 3,
ceo_plan: Optional[Dict[str, Any]] = None,
limits: Optional[Dict[str, Any]] = None,
) -> None:
"""后台执行一个公司级项目:驱动 execute_stream,批量事件落库 + 心跳;受全局并发上限约束。"""
sem = _get_project_sem()
seq = 0
acquired = False
db_orch = None
db_evt = None
buf: Optional[_EventBuffer] = None
try:
# 三档-b:并发上限 + 真实排队。无空名额时先写 queued 事件、排队期周期心跳。
if sem.locked():
seq += 1
_write_queued(project_id, seq)
while True:
try:
await asyncio.wait_for(sem.acquire(), timeout=settings.COMPANY_QUEUE_HEARTBEAT_SEC)
acquired = True
break
except asyncio.TimeoutError:
_bump_heartbeat(project_id) # 排队久刷新 updated_at 防僵尸误杀
# 名额到手后再开 DB session(排队期零连接,防池死锁)
db_orch = SessionLocal() # 编排器专用(会被内部并行部门协程共享,沿用既有行为)
db_evt = SessionLocal() # 事件落库 + 心跳专用,隔离编排器 session 状态
buf = _EventBuffer(project_id)
orch = CompanyOrchestrator(db_orch, company_id, user_id)
async for evt in orch.execute_stream(
description, max_rounds=max_rounds, ceo_plan=ceo_plan, project_id=project_id,
limits=limits,
):
seq += 1
buf.add(seq, evt)
if evt.get("type") in _TERMINAL_EVENT_TYPES:
buf.flush(db_evt, force=True) # 终态立即落地,防 WS 截断 finale
else:
now = time.time()
if buf.should_flush(now) or (now - buf.last_hb) >= buf.hb_interval:
buf.flush(db_evt)
buf.flush(db_evt, force=True) # 收尾兜底:保 company_done 等 finale 落地
# execute_stream 正常结束时已把 project 状态落 completed/failed(其自身 session)
logger.info("公司后台执行完成 [%s]: %d 事件", project_id, seq)
except asyncio.CancelledError:
if db_evt is None:
db_evt = SessionLocal()
if buf is None:
buf = _EventBuffer(project_id)
buf.flush(db_evt, force=True)
_set_status(db_evt, project_id, "stopped")
buf.flush_terminal(db_evt, seq + 1,
{"type": "company_terminated", "reason": "cancelled", "_ts": time.time()})
logger.info("公司后台执行被取消 [%s]", project_id)
raise
except Exception as e:
logger.error("公司后台执行失败 [%s]: %s", project_id, e, exc_info=True)
if db_evt is None:
db_evt = SessionLocal()
if buf is None:
buf = _EventBuffer(project_id)
buf.flush(db_evt, force=True)
_set_status(db_evt, project_id, "failed")
buf.flush_terminal(db_evt, seq + 1,
{"type": "error", "message": str(e), "phase": "runner", "_ts": time.time()})
finally:
if acquired:
sem.release() # 先放名额,再关 session
try:
if db_orch is not None:
db_orch.close()
finally:
if db_evt is not None:
db_evt.close()
def spawn_rework(project_id: str, company_id: str, user_id: str, dept_name: str) -> asyncio.Task:
"""M2-c:派发单部门返工的后台任务(跑完后重跑一个部门)。镜像 spawn 的注册。"""
task = asyncio.create_task(
run_department_rework(project_id, company_id, user_id, dept_name)
)
_RUNNING[project_id] = task
def _on_done(t: asyncio.Task) -> None:
_RUNNING.pop(project_id, None)
clear_control(project_id)
task.add_done_callback(_on_done)
return task
async def run_department_rework(
project_id: str,
company_id: str,
user_id: str,
dept_name: str,
) -> None:
"""后台重跑单个部门:seq 续写进同一事件流,替换该部门交付物与打分。
结构镜像 run_company_project(并发上限 + 批量落库 + CancelledError/Exception 收尾),
但不走 execute_stream 全流程,只驱动编排器的 rework_single_department。
"""
sem = _get_project_sem()
acquired = False
db_orch = None
db_evt = None
buf: Optional[_EventBuffer] = None
seq = 0
try:
# 三档-b:返工共用同一全局信号量(=一个部门的完整负载,不该绕过上限)
if sem.locked():
tmp = SessionLocal()
try:
seq = _max_seq(tmp, project_id) + 1
_persist_event(tmp, project_id, seq,
{"type": "queued", "reason": "waiting_slot", "_ts": time.time()})
_heartbeat(tmp, project_id)
finally:
tmp.close()
while True:
try:
await asyncio.wait_for(sem.acquire(), timeout=settings.COMPANY_QUEUE_HEARTBEAT_SEC)
acquired = True
break
except asyncio.TimeoutError:
_bump_heartbeat(project_id)
db_orch = SessionLocal()
db_evt = SessionLocal()
buf = _EventBuffer(project_id)
seq = _max_seq(db_evt, project_id) # 从当前最大 seq(含刚写的 queued)续写
_set_status(db_evt, project_id, "in_progress")
seq += 1
buf.add(seq, {"type": "rework_start", "department_name": dept_name, "_ts": time.time()})
buf.flush(db_evt, force=True) # 状态刚翻 in_progress,前端要重连,rework_start 尽快可见
orch = CompanyOrchestrator(db_orch, company_id, user_id)
orch.auto_approve_files = True
async for evt in orch.rework_single_department(project_id, dept_name):
seq += 1
buf.add(seq, evt)
if evt.get("type") in _TERMINAL_EVENT_TYPES:
buf.flush(db_evt, force=True)
else:
now = time.time()
if buf.should_flush(now) or (now - buf.last_hb) >= buf.hb_interval:
buf.flush(db_evt)
buf.flush(db_evt, force=True)
logger.info("单部门返工完成 [%s / %s]", project_id, dept_name)
except asyncio.CancelledError:
if db_evt is None:
db_evt = SessionLocal()
if buf is None:
buf = _EventBuffer(project_id)
buf.flush(db_evt, force=True)
_set_status(db_evt, project_id, "stopped")
buf.flush_terminal(db_evt, seq + 1,
{"type": "company_terminated", "reason": "cancelled", "_ts": time.time()})
logger.info("单部门返工被取消 [%s / %s]", project_id, dept_name)
raise
except Exception as e:
logger.error("单部门返工失败 [%s / %s]: %s", project_id, dept_name, e, exc_info=True)
if db_evt is None:
db_evt = SessionLocal()
if buf is None:
buf = _EventBuffer(project_id)
buf.flush(db_evt, force=True)
_set_status(db_evt, project_id, "failed")
buf.flush_terminal(db_evt, seq + 1,
{"type": "error", "message": str(e), "phase": "rework", "_ts": time.time()})
finally:
if acquired:
sem.release()
try:
if db_orch is not None:
db_orch.close()
finally:
if db_evt is not None:
db_evt.close()
def _persist_event(db, project_id: str, seq: int, evt: Dict[str, Any]) -> None:
"""落一行事件;失败不阻断执行。"""
try:
row = CompanyProjectEvent(
project_id=project_id,
seq=seq,
type=str(evt.get("type", "unknown"))[:50],
payload=_truncate(evt),
)
db.add(row)
db.commit()
except Exception as e:
db.rollback()
logger.warning("事件落库失败 [%s seq=%s]: %s", project_id, seq, e)
def _heartbeat(db, project_id: str) -> None:
try:
db.query(CompanyProject).filter(CompanyProject.id == project_id).update(
{"updated_at": datetime.now()}
)
db.commit()
except Exception:
db.rollback()
def _set_status(db, project_id: str, status: str) -> None:
try:
db.query(CompanyProject).filter(CompanyProject.id == project_id).update(
{"status": status, "updated_at": datetime.now()}
)
db.commit()
except Exception:
db.rollback()

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,