""" 公司项目后台执行器 — 白盒控制室的执行解耦层 把公司级流式执行从「请求协程内跑完」解耦为「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()