模块技能作为「项目模块」scope 注入时,目录层只放名字+描述, 全文层(load_skill)剥离 frontmatter 只返回正文,与 skill_loader. to_prompt_block 行为一致。
2202 lines
114 KiB
Python
2202 lines
114 KiB
Python
"""Agent loop — 角色agent循环认领执行任务(v3.4.0)。
|
||
|
||
v3.4.0 新增:
|
||
- 所有agent具备文件读写 + git pull/push 能力
|
||
- agent 产出实际代码文件到项目仓库,而非仅文档
|
||
- PM agent 负责 clone 项目关联仓库、初始化工作区
|
||
- develop agent 产出可直接运行的源码文件
|
||
- 每个agent完成后自动 git commit + push
|
||
|
||
任务链:requirement → design → develop → test → deploy
|
||
"""
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import logging
|
||
import time
|
||
from contextlib import asynccontextmanager
|
||
|
||
logger = logging.getLogger("pipeline.agent_loop")
|
||
|
||
from .workspace import WORKSPACE_BASE
|
||
|
||
ROLE_ALIASES = {
|
||
'developer': 'agent.develop', 'dev': 'agent.develop', 'development': 'agent.develop', 'coding': 'agent.develop',
|
||
'requirement': 'agent.requirement', 'requirements': 'agent.requirement', 'requirement_analysis': 'agent.requirement',
|
||
'designer': 'agent.design', 'ui': 'agent.design', 'ux': 'agent.design',
|
||
'testing': 'agent.test', 'qa': 'agent.test', 'tester': 'agent.test',
|
||
'deployment': 'agent.deploy_prod', 'release': 'agent.deploy_prod', 'production': 'agent.deploy_prod',
|
||
'staging': 'agent.deploy_test', 'deploy_staging': 'agent.deploy_test',
|
||
'qc': 'agent.qc', 'quality': 'agent.qc', 'quality_control': 'agent.qc', '质量控制': 'agent.qc',
|
||
'operation': 'agent.ops', 'operations': 'agent.ops', 'maintenance': 'agent.ops', '运维': 'agent.ops',
|
||
'pm': 'agent.pm', 'project_manager': 'agent.pm', '项目经理': 'agent.pm', 'manager': 'agent.pm',
|
||
}
|
||
|
||
TASK_REVIEW = 'review'
|
||
TASK_APPROVED = 'approved'
|
||
|
||
# 项目过程仓库名(repos/ 下):阶段文档、QC 审计文档、项目管理文档放这里;应用仓库/模块仓库各自独立。
|
||
# 实际仓库名 = {项目名}_pc(见 project-directory-spec 规范),此常量仅为查不到项目名时的回退默认值。
|
||
PROJECT_REPO_NAME = 'project'
|
||
|
||
# 允许的 shell 工作目录前缀(安全限制)
|
||
_ALLOWED_WORKDIRS = [
|
||
os.path.expanduser('~/pipeline_ws'),
|
||
'/d/pipeline/workspaces',
|
||
'/tmp/pipeline_workspaces',
|
||
'/tmp/pipeline_ws',
|
||
]
|
||
|
||
|
||
def _normalize_role(role):
|
||
"""角色规范化:别名映射 + 补 agent. 前缀(人角色 {orgtype}.{role} 保留原样)。"""
|
||
r = (role or '').strip().lower()
|
||
r = ROLE_ALIASES.get(r, r)
|
||
if r and '.' not in r:
|
||
r = f"agent.{r}"
|
||
return r
|
||
|
||
|
||
async def _resolve_pipeline_id(project_id):
|
||
"""从项目解析 pipeline_id(能力包 key),为空时 fallback 到默认产线。"""
|
||
pid = ""
|
||
try:
|
||
db = _get_db()
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
recs = await sor.sqlExe(
|
||
"SELECT pipeline_id FROM sd_projects WHERE id=${pid}$", {"pid": project_id})
|
||
if recs:
|
||
pid = getattr(recs[0], "pipeline_id", "") or ""
|
||
except Exception:
|
||
pass
|
||
if not pid:
|
||
try:
|
||
from pipeline_core import DEFAULT_ABILITY_ID
|
||
pid = DEFAULT_ABILITY_ID
|
||
except ImportError:
|
||
pass
|
||
return pid
|
||
|
||
|
||
async def _resolve_role(project_id, role):
|
||
"""从能力包解析角色定义,返回 (normalized_role, role_specific_prompt, next_role)。"""
|
||
pid = await _resolve_pipeline_id(project_id)
|
||
try:
|
||
from pipeline_core import get_role_spec
|
||
spec = get_role_spec(pid, role)
|
||
if spec:
|
||
return spec.name, spec.system_prompt, spec.next_role
|
||
except Exception:
|
||
pass
|
||
norm = _normalize_role(role)
|
||
return norm, "", ""
|
||
|
||
|
||
async def _resolve_llm_context(sor, project_id, role, model_name=None):
|
||
"""解析角色 agent 的 LLM 上下文:返回 (model_name, org_id)。
|
||
|
||
model_name 解析链:
|
||
1. 显式传入的 model_name
|
||
2. 项目-角色-模型(sd_project_role_models,设置界面配的)
|
||
3. 用户当前模型(项目创建者 created_by → pipeline_agent_settings.default_llm_id → llm.name,缺省)
|
||
4. RoleSpec.model_name(角色专属模型)
|
||
5. 产线 default_model(pipelines.default_model)
|
||
6. 全局默认 "deepseek-v4-pro"
|
||
|
||
org_id:从 sd_projects.org_id 读,传给 llm_bridge 做多租户隔离
|
||
(llm 表查询只取「本机构 + 系统级 org_id='0'」的模型)。
|
||
"""
|
||
org_id = ""
|
||
pipeline_id = ""
|
||
default_model = ""
|
||
try:
|
||
recs = await sor.sqlExe(
|
||
"SELECT org_id, pipeline_id, default_model FROM sd_projects WHERE id=${pid}$", {"pid": project_id})
|
||
if recs:
|
||
org_id = getattr(recs[0], "org_id", "") or ""
|
||
pipeline_id = getattr(recs[0], "pipeline_id", "") or ""
|
||
default_model = getattr(recs[0], "default_model", "") or ""
|
||
except Exception:
|
||
pass
|
||
|
||
# 2. 项目-角色-模型(设置界面配的,最高优先)
|
||
if not model_name and project_id:
|
||
try:
|
||
recs = await sor.sqlExe(
|
||
"SELECT model_name FROM sd_project_role_models WHERE project_id=${pid}$ AND role=${r}$",
|
||
{"pid": project_id, "r": role})
|
||
if recs and getattr(recs[0], "model_name", ""):
|
||
model_name = getattr(recs[0], "model_name", "")
|
||
except Exception:
|
||
pass
|
||
|
||
# 3. 项目缺省模型(创建项目时记录的「当前会话模型」,session 级缺省)
|
||
if not model_name and default_model:
|
||
model_name = default_model
|
||
|
||
if not model_name:
|
||
# 4. RoleSpec.model_name(角色专属模型)
|
||
try:
|
||
from pipeline_core import get_role_spec
|
||
spec = get_role_spec(pipeline_id or await _resolve_pipeline_id(project_id), role)
|
||
if spec and spec.model_name:
|
||
model_name = spec.model_name
|
||
except Exception:
|
||
pass
|
||
if not model_name and pipeline_id:
|
||
# 5. 产线 default_model
|
||
try:
|
||
recs = await sor.sqlExe(
|
||
"SELECT default_model FROM pipelines WHERE id=${pid}$", {"pid": pipeline_id})
|
||
if recs:
|
||
model_name = getattr(recs[0], "default_model", "") or ""
|
||
except Exception:
|
||
pass
|
||
if not model_name:
|
||
# 6. 全局默认
|
||
model_name = "deepseek-v4-pro"
|
||
return model_name, org_id
|
||
|
||
|
||
async def _check_org_llm(sor, org_id):
|
||
"""检测机构是否配置了 LLM 模型(严格本机构隔离,不含系统级兜底)。
|
||
|
||
返回 (missing: bool, available_names: list)。org_id 为空或 '0'(系统级)时不过滤,
|
||
视为已配置(系统级模型对超管可用)。机构没配 llm 时角色/pm/qc 应冒泡问题暂停。
|
||
"""
|
||
if not org_id or org_id == '0':
|
||
return False, []
|
||
try:
|
||
recs = await sor.sqlExe(
|
||
"SELECT name FROM llm WHERE org_id=${org}$ AND status='active'", {"org": org_id})
|
||
names = [getattr(r, "name", "") or "" for r in (recs or [])]
|
||
return (len(names) == 0), names
|
||
except Exception:
|
||
return False, []
|
||
|
||
|
||
def _get_db():
|
||
from sqlor.dbpools import DBPools
|
||
db = DBPools()
|
||
if not db.databases:
|
||
from appPublic.jsonConfig import getConfig
|
||
config = getConfig()
|
||
if config.databases:
|
||
db.databases = config.databases
|
||
return db
|
||
|
||
|
||
def _resolve_workspace(workspace_dir):
|
||
if not workspace_dir:
|
||
workspace_dir = os.path.join(WORKSPACE_BASE, 'default')
|
||
try:
|
||
parent = os.path.dirname(workspace_dir)
|
||
if parent and not os.access(parent, os.W_OK):
|
||
proj_name = os.path.basename(workspace_dir)
|
||
workspace_dir = os.path.join(WORKSPACE_BASE, proj_name)
|
||
except Exception:
|
||
workspace_dir = os.path.join(WORKSPACE_BASE, os.path.basename(workspace_dir) or "default")
|
||
os.makedirs(workspace_dir, exist_ok=True)
|
||
return workspace_dir
|
||
|
||
|
||
# ── Agent 工具函数 ──
|
||
|
||
_allowed_workdirs_cache = None
|
||
_allowed_workdirs_cache_time = 0
|
||
|
||
|
||
async def _get_allowed_workdirs():
|
||
"""动态读 workspace_base 参数(params 表),合并到允许目录列表(60s 缓存)。"""
|
||
global _allowed_workdirs_cache, _allowed_workdirs_cache_time
|
||
import time as _t
|
||
now = _t.time()
|
||
if _allowed_workdirs_cache is not None and (now - _allowed_workdirs_cache_time) < 60:
|
||
return _allowed_workdirs_cache
|
||
allowed = list(_ALLOWED_WORKDIRS)
|
||
try:
|
||
db = _get_db()
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
from .workspace import get_workspace_base
|
||
base = await get_workspace_base(sor)
|
||
if base and base not in allowed:
|
||
allowed.append(base)
|
||
except Exception:
|
||
pass
|
||
_allowed_workdirs_cache = allowed
|
||
_allowed_workdirs_cache_time = now
|
||
return allowed
|
||
|
||
|
||
async def _is_safe_workdir_async(workdir):
|
||
"""检查目录是否在允许范围内(含 params 表动态 workspace_base)。"""
|
||
wd = os.path.abspath(workdir)
|
||
allowed = await _get_allowed_workdirs()
|
||
for a in allowed:
|
||
awd = os.path.abspath(os.path.expanduser(a))
|
||
if wd.startswith(awd):
|
||
return True
|
||
return False
|
||
|
||
|
||
async def _run_shell(command, workdir, timeout=120):
|
||
"""安全执行 shell 命令。返回 {"rc": int, "stdout": str, "stderr": str}"""
|
||
cwd = os.path.abspath(workdir) if workdir else WORKSPACE_BASE
|
||
if not await _is_safe_workdir_async(cwd):
|
||
return {"rc": -1, "stdout": "", "stderr": f"安全限制:目录 {cwd} 不在允许范围"}
|
||
if not os.path.isdir(cwd):
|
||
return {"rc": -1, "stdout": "", "stderr": f"目录不存在: {cwd}"}
|
||
try:
|
||
proc = await asyncio.create_subprocess_shell(
|
||
command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||
cwd=cwd, executable='/bin/bash')
|
||
try:
|
||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||
except asyncio.TimeoutError:
|
||
proc.kill()
|
||
await proc.wait()
|
||
return {"rc": -1, "stdout": "", "stderr": f"命令超时({timeout}s)"}
|
||
return {"rc": proc.returncode or 0,
|
||
"stdout": stdout.decode('utf-8', 'replace')[-8000:],
|
||
"stderr": stderr.decode('utf-8', 'replace')[-4000:]}
|
||
except Exception as e:
|
||
return {"rc": -1, "stdout": "", "stderr": str(e)[:500]}
|
||
|
||
|
||
async def _write_code_file(filepath, content):
|
||
"""写代码文件,自动创建父目录。"""
|
||
try:
|
||
d = os.path.dirname(filepath)
|
||
if d:
|
||
os.makedirs(d, exist_ok=True)
|
||
with open(filepath, 'w', encoding='utf-8') as f:
|
||
f.write(content or '')
|
||
return True, filepath
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
|
||
_GIT_LOCK_TTL = 180 # 秒:覆盖 git 单次操作最坏时长(clone 120s);过期可被原子接管
|
||
|
||
|
||
def _git_lock_key(repo_dir):
|
||
"""同 repo 的 git 操作共用一把锁。lock_key = sha256(repo_abs_path)。"""
|
||
return hashlib.sha256(os.path.abspath(repo_dir).encode('utf-8')).hexdigest()
|
||
|
||
|
||
@asynccontextmanager
|
||
async def _git_lock(repo_dir, timeout=90):
|
||
"""git 操作串行锁(DB 版,跨主机多 worker 生效):只锁 git 那几秒,任务其它部分完全并行。
|
||
|
||
用 pipeline_git_locks 表 + ON DUPLICATE KEY UPDATE + expires_at TTL:
|
||
- 原子抢占:INSERT ... ON DUPLICATE KEY UPDATE,仅当 expires_at 过期才接管他人锁
|
||
- 崩溃自动释放:expires_at 过期后下一个申请者原子接管
|
||
- 释放:DELETE ... WHERE token 匹配(避免误删他人锁)
|
||
"""
|
||
from appPublic.uniqueID import getID
|
||
|
||
key = _git_lock_key(repo_dir)
|
||
token = getID()
|
||
deadline = time.time() + timeout
|
||
acquired = False
|
||
db = _get_db()
|
||
try:
|
||
while not acquired:
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
await sor.sqlExe(
|
||
"INSERT INTO pipeline_git_locks (lock_key, token, expires_at) "
|
||
"VALUES (${k}$, ${t}$, DATE_ADD(NOW(), INTERVAL " + str(_GIT_LOCK_TTL) + " SECOND)) "
|
||
"ON DUPLICATE KEY UPDATE "
|
||
"token = IF(expires_at < NOW(), VALUES(token), token), "
|
||
"expires_at = IF(expires_at < NOW(), DATE_ADD(NOW(), INTERVAL " + str(_GIT_LOCK_TTL) + " SECOND), expires_at)",
|
||
{"k": key, "t": token})
|
||
r = await sor.sqlExe(
|
||
"SELECT token FROM pipeline_git_locks WHERE lock_key=${k}$ AND token=${t}$",
|
||
{"k": key, "t": token})
|
||
if r:
|
||
acquired = True
|
||
if not acquired:
|
||
if time.time() > deadline:
|
||
raise TimeoutError(f'git lock timeout after {timeout}s: {repo_dir}')
|
||
await asyncio.sleep(0.3)
|
||
yield
|
||
finally:
|
||
if acquired:
|
||
try:
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
await sor.sqlExe(
|
||
"DELETE FROM pipeline_git_locks WHERE lock_key=${k}$ AND token=${t}$",
|
||
{"k": key, "t": token})
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
async def _git_setup(workdir):
|
||
"""确保 git 用户已配置。"""
|
||
r = await _run_shell('git config user.email', workdir, 5)
|
||
if 'opencomputing' not in r.get('stdout', '') and '@' not in r.get('stdout', ''):
|
||
await _run_shell('git config user.email "pipeline@opencomputing.cn"', workdir, 5)
|
||
await _run_shell('git config user.name "Pipeline Agent"', workdir, 5)
|
||
|
||
|
||
async def _git_commit_push(workdir, commit_message, branch='main'):
|
||
"""git add → commit → push(无 remote 的本地仓库仅 commit)。非 git 目录自动 git init。"""
|
||
async with _git_lock(workdir):
|
||
await _git_setup(workdir)
|
||
# 非 git 目录自动 git init(模块仓库无真实远端,本地 git init)
|
||
if not os.path.isdir(os.path.join(workdir, '.git')):
|
||
r0 = await _run_shell('git init', workdir, 10)
|
||
if r0['rc'] != 0:
|
||
return {"rc": r0['rc'], "message": f"git init 失败: {r0['stderr'][:200]}"}
|
||
r1 = await _run_shell('git add -A', workdir, 10)
|
||
r2 = await _run_shell(f'git diff --cached --stat', workdir, 10)
|
||
if not r2.get('stdout', '').strip():
|
||
return {"rc": 0, "message": "没有变更需要提交"}
|
||
r3 = await _run_shell(f'git commit -m "{commit_message}"', workdir, 15)
|
||
if r3['rc'] != 0:
|
||
return {"rc": r3['rc'], "message": f"commit 失败: {r3['stderr'][:200]}"}
|
||
# 无 remote 的本地仓库(项目过程仓库)只 commit 不 push
|
||
r4 = await _run_shell('git remote get-url origin', workdir, 5)
|
||
if r4['rc'] != 0:
|
||
return {"rc": 0, "message": "commit 成功(本地仓库,无远程)"}
|
||
r5 = await _run_shell(f'git push origin {branch}', workdir, 30)
|
||
return {"rc": r5['rc'], "message": f"push {'成功' if r5['rc']==0 else '失败'}: {r5['stderr'][:200]}"}
|
||
|
||
|
||
async def _git_clone(repo_url, target_dir, branch='main'):
|
||
"""克隆仓库到目标目录。已存在则 pull。"""
|
||
async with _git_lock(target_dir):
|
||
if os.path.isdir(os.path.join(target_dir, '.git')):
|
||
r = await _run_shell(f'git checkout {branch} && git pull origin {branch}', target_dir, 30)
|
||
return {"rc": r['rc'], "message": f"已存在,pull: {r['stdout'][:200]}"}
|
||
parent = os.path.dirname(target_dir)
|
||
os.makedirs(parent, exist_ok=True)
|
||
r = await _run_shell(f'git clone -b {branch} {repo_url} {target_dir}', parent, 120)
|
||
return {"rc": r['rc'], "message": f"clone: {r['stdout'][:200] if r['rc']==0 else r['stderr'][:200]}"}
|
||
|
||
|
||
# ── Prompts ──
|
||
|
||
AGENT_TOOLS = [
|
||
{"name":"read_file","description":"读取工作空间中的文件","params":{"path":"相对路径"}},
|
||
{"name":"load_skill","description":"按需加载技能全文或子文件——需要具体规范/目录结构/路径/格式/流程时先加载对应技能(如 project-directory-spec 项目目录规范),不要凭记忆瞎写。只给 name 加载 SKILL.md 全文,给 file_path 加载 references/scripts/templates 下的子文件","params":{"name":"技能名","file_path":"子文件相对路径(可选,如 references/api.md)"}},
|
||
{"name":"write_file","description":"写入文件(自动创建父目录)","params":{"path":"相对路径","content":"文件内容"}},
|
||
{"name":"list_files","description":"列出目录内容","params":{"path":"相对路径(可选,默认工作空间根)"}},
|
||
{"name":"run_shell","description":"在工作空间中执行shell命令","params":{"command":"命令"}},
|
||
{"name":"git_clone","description":"克隆git仓库到工作空间repos/下","params":{"repo_url":"仓库URL","repo_name":"仓库目录名(可选,默认从URL推断)","branch":"分支(可选,默认main)"}},
|
||
{"name":"git_status","description":"查看git仓库状态","params":{"repo_dir":"仓库子目录(可选,默认repos下第一个)"}},
|
||
{"name":"git_commit_push","description":"git add + commit + push(develop 用它提交模块仓库本地提交作为产出证据;无远程只 commit 不 push,非 git 目录自动 init)","params":{"message":"提交信息","repo_dir":"仓库子目录(可选)"}},
|
||
{"name":"ask_question","description":"向用户提问(缺少信息时使用)","params":{"question":"问题"}},
|
||
{"name":"deliver","description":"提交最终交付件(代码/文档文件已用write_file写好时调用)","params":{"deliverable_type":"交付件类型(如code_files/design_doc)","summary":"概述","result":"交付件正文","files":"JSON数组[{\"path\":\"repos/仓库/src/x.py\",\"content\":\"代码内容\"}](可选)"}},
|
||
]
|
||
|
||
AGENT_SYSTEM_PROMPT = """你是软件开发产线中的「__ROLE__」角色Agent。
|
||
|
||
## 任务
|
||
__TITLE__
|
||
__QNA__
|
||
|
||
## 工作环境
|
||
工作空间:__WORKSPACE__
|
||
产出要求:__ROLE_SPECIFIC__
|
||
|
||
__ROLE_SKILLS__
|
||
|
||
## 工具
|
||
你可以使用以下工具完成工作:
|
||
__TOOLS__
|
||
|
||
## 技能使用
|
||
上方「可用技能」是目录层(只有名字+描述)。遇到需要具体规范、目录结构、产出路径、文件格式、流程约束的任务,先用 load_skill 加载对应技能全文(如 project-directory-spec 项目目录规范),不要凭记忆瞎写路径或格式。
|
||
|
||
## 工作流
|
||
1. 先用 read_file/list_files 了解现有代码
|
||
2. 用 write_file 产出代码文件到 repos/ 下
|
||
3. 用 run_shell 验证(编译/测试)
|
||
4. 用 deliver 提交最终交付件(git 提交由 PM 审核通过后系统统一执行,无需你提交)
|
||
|
||
## 输出格式(每次只输出一个JSON对象)
|
||
调工具:
|
||
{"action":"tool_call","tool":"工具名","params":{}}
|
||
|
||
提交交付件:
|
||
{"action":"deliver","deliverable_type":"code_files","summary":"概述","result":"文档内容","files":[{"path":"repos/仓库/src/file.py","content":"代码"}]}
|
||
|
||
提问:
|
||
{"action":"ask","question":"问题"}
|
||
|
||
注意:每次只输出一个JSON!收到工具结果后再决定下一步。"""
|
||
|
||
PM_SYSTEM_PROMPT = """你是项目经理(PM)。你的职责是项目计划、任务分配、任务验收。
|
||
|
||
## 项目推进铁律(最高优先级)
|
||
- 默认必须推进项目执行:审核通过后自动创建后续任务、自动分解派发、推动项目往前走,**无需用户或会话 agent 的指令**。
|
||
- **不必询问**用户或会话 agent「是否继续 / 是否推进」——自动推进是默认行为。
|
||
- 自动推进无需指令;**暂停推进才需要指令**。仅当用户明确指令「暂停 / 停止推进」时,项目才会被置为 paused 状态(由会话 agent 调 pause_project),此时你才停止推进。
|
||
- 你审核时若项目已处于 paused 状态,系统会直接跳过、不派发给你;恢复推进(resume_project)后自动继续。
|
||
|
||
## 三大职责
|
||
1. 项目计划:基于需求/设计交付件,规划后续工作——先评估任务复杂度,复杂任务自动分解为可执行的子任务。
|
||
2. 任务分配:用 create_tasks 工具把子任务派发给对应角色 agent,并记录父子关系与依赖关系(能并行的并行、不能并行的串行)。
|
||
3. 任务验收:检查交付件 + 实际产出文件,决定 review_approve / review_reject / review_complete。
|
||
|
||
## 任务分解与编排(先评估,再拆解)
|
||
- 派发前先评估:当前任务是否「过于复杂」(涉及多个模块/应用、多个独立交付单元、工作量超单 agent 一次产出)。简单任务直接派发单个任务,不必强行拆分。
|
||
- **按设计师划分好的模块派发**:develop 任务以 design 阶段设计师在 modules/<模块名>.md 里划分好的模块为单元——一个模块 = 一个 develop 任务,PM 不要自己重新拆模块。派发时按设计师定义的模块间依赖关系编排:无依赖的模块并行(不填 depends_on),有依赖的模块串行(depends_on 指向其依赖的模块任务)。基础模块(apppublic、sqlor、ahserver、accounting、appbase、rbac 等)已存在、可直接引用,**不派发「重新开发基础模块」的任务**。
|
||
- **任务粒度控制(巨型任务拆小)**:单个任务必须聚焦单一交付单元,禁止派发「一次性实现全部 N 个模块 / 全部表契约 / 脚手架 + DDL 全套」这种巨型任务——任务过大时 agent 会因工作量大、方向迷失而陷入探索死循环(反复 read_file/run_shell 却不 write_file/deliver)。模块粒度由 designer 在 design 阶段控制(拆到每个模块单 agent 能一次产出);PM 派发时若发现某模块仍过大,退回 design 补拆,不硬塞一个巨型任务。
|
||
- 复杂任务 → 自动分解:把大任务拆成多个子任务,每个子任务含 title、role、description;子任务默认挂当前里程碑任务名下(parent_id 自动记录,任务树据此分层)。
|
||
- 自动编排(能并行并行、不能并行串行):
|
||
- 无依赖的子任务 → 并行(不填 depends_on,系统同时认领执行)。
|
||
- 有先后依赖的子任务 → 用 key 标记 + depends_on 引用前序 key(如「契约定义」key=contract 完成后,「hr-system 开发」depends_on=[contract])。依赖完成前子任务不会被认领,自动串行。
|
||
- 父子关系:系统自动把子任务的 parent_id 记为当前审核的里程碑任务,任务树(/task)里显示为父任务下的层级子任务,无需你手动记 parent_id。
|
||
|
||
## 任务链不能断(关键规则)
|
||
- 审核通过里程碑任务(requirement / design,产出含多个应用/模块,或评审意见指明后续要落地开发)后,必须先规划并用 create_tasks 创建后续开发任务,再 review_approve。
|
||
- 系统在 approve 后会自动补一个线性链的下一角色任务作为兜底;里程碑的并发拆分由你显式创建,不要等、不要断链。
|
||
- **review_complete 只在「无下一阶段」时使用**——即最后一个角色 deploy_prod 验收通过后。requirement/design/develop/deploy_test/test 各阶段后面都还有下一角色,验收通过一律用 review_approve(系统自动创建下一角色任务),严禁用 review_complete 收尾,否则任务链断裂、下一阶段任务不会自动创建。
|
||
|
||
## 项目信息
|
||
工作目录:__WORKSPACE__
|
||
关联仓库:__REPOS__
|
||
|
||
## 待审核
|
||
标题:__TITLE__
|
||
角色:__ROLE__
|
||
|
||
## 仓库状态
|
||
__REPO_STATE__
|
||
|
||
## 技能
|
||
__ROLE_SKILLS__
|
||
遇到需要具体规范、目录结构、路径、格式、流程的任务,先用 load_skill 加载对应技能全文(如 project-directory-spec 项目目录规范),不要凭记忆瞎写。
|
||
|
||
## 工具
|
||
- load_skill(name, file_path?) — 按需加载技能全文或 references/scripts/templates 子文件(需要具体规范/路径/格式时用)
|
||
- read_file(path) — 读工作空间文件
|
||
- list_files(path) — 列目录
|
||
- git_status() — 查看git状态
|
||
- run_shell(command) — 执行命令(编译/测试验证)
|
||
- create_tasks(tasks) — 批量创建并派发后续任务(tasks 是 JSON 数组,每项 {title, role, description, key?, depends_on?, parent_id?};key 供同批任务间 depends_on 引用,depends_on 是前序 key 或任务ID 数组,空=并行/非空=串行)
|
||
- list_tasks(role, state) — 列出项目现有任务(派发前先查,避免重复)
|
||
- cancel_task(task_id) — 取消任务(重做/作废前必须先取消旧任务,避免两个相同任务并存)
|
||
|
||
## 审核流程
|
||
1. 先用工具检查代码/交付件是否实际产出(git_status / list_files / read_file)
|
||
2. 如需编译验证,用 run_shell
|
||
3. 若审核将通过、且本任务产出需要拆成多个后续任务,先 list_tasks 查重,再 create_tasks 派发;发现需重做的旧任务仍在活跃,先 cancel_task 取消,再 create_tasks
|
||
4. 最后给出决策
|
||
|
||
## 输出格式(每次一个JSON)
|
||
查看文件:{"action":"tool_call","tool":"read_file","params":{"path":"相对路径"}}
|
||
查看git:{"action":"tool_call","tool":"git_status","params":{}}
|
||
查现有任务:{"action":"tool_call","tool":"list_tasks","params":{"role":"develop","state":"submitted"}}
|
||
取消旧任务:{"action":"tool_call","tool":"cancel_task","params":{"task_id":"旧任务ID"}}
|
||
派发后续任务:{"action":"tool_call","tool":"create_tasks","params":{"tasks":[{"title":"契约定义","role":"design","key":"contract","description":"定义数据模型/API/目录规范"},{"title":"hr-system 开发","role":"develop","key":"hrsys","depends_on":["contract"],"description":"实现 hr-system 基础能力"},{"title":"hr-org 开发","role":"develop","key":"hrorg","depends_on":["contract"],"description":"实现 hr-org 基础能力"}]}}
|
||
批准:{"action":"review_approve","comment":"审核意见","next_task_title":"下阶段标题","next_task_description":"描述"}
|
||
驳回:{"action":"review_reject","comment":"原因","questions":"修改要求"}
|
||
完成:{"action":"review_complete","comment":"总结"}(仅 deploy_prod 最后阶段验收通过时使用)
|
||
回退:{"action":"review_rollback","rollback_role":"develop","comment":"回退原因"}
|
||
|
||
审核标准:
|
||
- requirement:需求是否清晰完整可量化(含部署环境需求是否明确)
|
||
- design:方案合理、覆盖需求、技术可行
|
||
- develop:必须用 list_files/read_file 检查代码文件是否实际产出(git 提交由审核通过后系统统一执行)
|
||
- deploy_test:测试环境部署配置完整、服务可访问
|
||
- test:测试覆盖充分、发现问题记录完整
|
||
- deploy_prod:生产部署配置完整、可一键部署、有回滚方案
|
||
|
||
## 回退规则(test 阶段)
|
||
- 测试提交 Bug 后,零散 Bug → 走 Bug 闭环(report_bug→fix_bug→verify_bug),不打断任务链。
|
||
- 系统性缺陷(Bug 多/严重、设计有缺陷、需求理解错、部署有问题)→ review_rollback,rollback_role 指定回退目标阶段(develop/design/requirement/deploy_test),回退点之后的关联任务将全部作废。"""
|
||
|
||
|
||
QC_SYSTEM_PROMPT = """你是质量控制工程师(QC)。对交付件做合规检查和质量检查,不合规直接退回重做。
|
||
|
||
## 检查维度
|
||
1. 项目规范检查:产出是否按 SDLC 仓库标准路径/命名/格式产出——先用 load_skill 加载 project-directory-spec 拿到权威目录结构与路径,再据此检查(交付文件都在 repos/ 下,不要凭记忆在工作空间根找 docs/、apps/ 等目录)
|
||
2. 项目过程规范:是否遵循各阶段流程规范(develop 是否实际产出代码文件、test 是否覆盖充分、deploy 配置是否完整、requirement 是否明确部署环境需求)
|
||
3. 产出质量:内容是否完整、可量化、可验收、无重大缺陷、无空泛套话
|
||
|
||
## 待检查
|
||
标题:__TITLE__
|
||
角色:__ROLE__
|
||
工作目录:__WORKSPACE__
|
||
|
||
## 技能
|
||
__ROLE_SKILLS__
|
||
遇到需要具体规范、目录结构、路径、格式、流程的任务,先用 load_skill 加载对应技能全文,不要凭记忆瞎写。
|
||
|
||
## 工具
|
||
- load_skill(name, file_path?) — 按需加载技能全文或 references/scripts/templates 子文件(需要具体规范/路径/格式时用)
|
||
- read_file(path) — 读工作空间文件
|
||
- list_files(path) — 列目录
|
||
- git_status() — 查看git状态
|
||
- run_shell(command) — 执行命令(编译/测试验证)
|
||
|
||
## 输出格式(每次一个JSON)
|
||
查看文件:{"action":"tool_call","tool":"read_file","params":{"path":"相对路径"}}
|
||
检查通过:{"action":"review_approve","comment":"合规,质量达标"}
|
||
不合规退回:{"action":"review_reject","comment":"退回原因","questions":"具体问题清单,逐条列出"}
|
||
|
||
## 检查流程
|
||
1. 读交付件内容,检查是否按 SDLC 规范产出(路径/命名/格式)
|
||
2. 检查过程合规(文件实际产出、目录结构、必填项)
|
||
3. 判断产出质量是否达标(完整、可量化、可验收)
|
||
4. 合规则 review_approve;不合规则 review_reject 并逐条列出问题清单"""
|
||
|
||
|
||
# ── Agent 核心逻辑 ──
|
||
|
||
async def _task_deps_satisfied(sor, depends_on_raw):
|
||
"""依赖门控:depends_on 是 JSON 数组(依赖任务ID列表),全部处于终态(completed/approved)
|
||
才可认领(返回 True)。空/无依赖 → 并行,立即满足。任一依赖未完成 → 串行等待(False)。
|
||
|
||
PM 分解任务时用 depends_on 表达「不能并行的串行依赖」;agent 认领前必须先过这道门,
|
||
否则串行依赖会被 poller 提前认领、破坏编排顺序。
|
||
"""
|
||
deps = []
|
||
if depends_on_raw:
|
||
try:
|
||
d = json.loads(depends_on_raw) if isinstance(depends_on_raw, str) else depends_on_raw
|
||
if isinstance(d, list):
|
||
deps = [str(x) for x in d if x]
|
||
except (json.JSONDecodeError, TypeError, ValueError):
|
||
deps = []
|
||
if not deps:
|
||
return True
|
||
# 任一依赖不在终态 → 未满足。终态 = completed/approved(approved 视为已验收、可驱动下游)。
|
||
ids = ",".join(["'" + x.replace("'", "''") + "'" for x in deps])
|
||
recs = await sor.sqlExe(
|
||
"SELECT id FROM pipeline_tasks WHERE id IN (" + ids + ") "
|
||
"AND state NOT IN ('completed','approved')", {})
|
||
await sor.sqlExe("COMMIT", {})
|
||
return not recs
|
||
|
||
|
||
async def _claim_task(sor, tenant_id, role, state='submitted', match_role=True, set_state='running'):
|
||
role = _normalize_role(role)
|
||
where_role = "AND (role=${role}$ OR role='')" if match_role else ""
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, title, params, pipeline_id, tenant_id, role, depends_on FROM pipeline_tasks "
|
||
"WHERE tenant_id=${tid}$ AND state=${state}$ " + where_role + " "
|
||
"AND NOT EXISTS (SELECT 1 FROM pipeline_task_steps s WHERE s.task_id=pipeline_tasks.id) "
|
||
"ORDER BY created_at ASC LIMIT 50",
|
||
{"tid": tenant_id, "state": state, "role": role})
|
||
if not recs:
|
||
return None
|
||
# 依赖门控:跳过依赖未完成的任务,认领第一个依赖已满足的任务。
|
||
# (并行任务不被前面的串行任务阻塞;被依赖阻塞的任务保持 submitted 等依赖完成后下一轮认领。)
|
||
task = None
|
||
for rec in recs:
|
||
if await _task_deps_satisfied(sor, getattr(rec, 'depends_on', '') or ''):
|
||
task = rec
|
||
break
|
||
if task is None:
|
||
return None
|
||
task_id = task.id
|
||
from appPublic.uniqueID import getID
|
||
claim_token = getID()
|
||
# claimed_by IS NULL 保证原子认领(并发 poller / start_agents 不会双重认领);
|
||
# updated_at=NOW() 作为心跳,供 stale 回收判断。
|
||
# 同项目并发写冲突由 git 级串行锁解决(见 _git_lock),不在认领层做项目级串行,
|
||
# 否则会把整个任务时长(LLM+写文件)都锁死,牺牲项目内并行度。
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_tasks SET state=${setstate}$, claimed_by=${cb}$, updated_at=NOW() "
|
||
"WHERE id=${tid}$ AND state=${state}$ AND claimed_by IS NULL",
|
||
{"setstate": set_state, "cb": claim_token, "tid": task_id, "state": state})
|
||
check = await sor.sqlExe(
|
||
"SELECT id FROM pipeline_tasks WHERE id=${tid}$ AND state=${setstate}$ AND claimed_by=${cb}$",
|
||
{"tid": task_id, "setstate": set_state, "cb": claim_token})
|
||
if not check:
|
||
logger.info(f"claim lost race: task={task_id}")
|
||
return None
|
||
return task
|
||
|
||
|
||
async def _get_workspace_dir(sor, project_id):
|
||
recs = await sor.sqlExe("SELECT workspace_dir FROM sd_projects WHERE id=${pid}$", {"pid": project_id})
|
||
ws = getattr(recs[0], 'workspace_dir', '') if recs else ''
|
||
return _resolve_workspace(ws)
|
||
|
||
|
||
def _parse_skill_frontmatter(content):
|
||
"""解析技能 SKILL.md 的 frontmatter(--- 之间),返回 dict。无 frontmatter 返回 {}。"""
|
||
content = (content or "").strip()
|
||
if not content.startswith("---"):
|
||
return {}
|
||
end = content.find("---", 3)
|
||
if end == -1:
|
||
return {}
|
||
fm = {}
|
||
for line in content[3:end].strip().split("\n"):
|
||
line = line.strip()
|
||
if ":" in line:
|
||
k, _, v = line.partition(":")
|
||
k = k.strip()
|
||
v = v.strip().strip('"').strip("'")
|
||
if v.startswith("[") and v.endswith("]"):
|
||
v = [x.strip().strip('"').strip("'") for x in v[1:-1].split(",") if x.strip()]
|
||
fm[k] = v
|
||
return fm
|
||
|
||
|
||
def _collect_module_skills(workspace_dir):
|
||
"""扫描项目 workspace 的 repos/*/skill/SKILL.md,收集模块技能(模块怎么用)。
|
||
|
||
模块仓库自带 skill/SKILL.md(架构/数据模型/挂载函数/坑位),但这些不进 skill_loader
|
||
的静态文件树,导致项目角色不知道模块怎么用。这里运行时扫描,作为「项目模块」scope
|
||
注入技能目录 + 支持 load_skill 加载全文。返回 [{name, description, body, path}]。
|
||
其中 body 是剥离 frontmatter 后的正文(对齐 skill_loader.to_prompt_block 的分层导入规范:
|
||
目录层只放名字+描述,全文层剥离 frontmatter 只返回正文)。
|
||
"""
|
||
modules = []
|
||
repos_dir = os.path.join(workspace_dir, 'repos')
|
||
if not os.path.isdir(repos_dir):
|
||
return modules
|
||
for entry in sorted(os.listdir(repos_dir)):
|
||
skill_file = os.path.join(repos_dir, entry, 'skill', 'SKILL.md')
|
||
if not os.path.isfile(skill_file):
|
||
continue
|
||
try:
|
||
with open(skill_file, 'r', encoding='utf-8') as f:
|
||
content = f.read()
|
||
except Exception:
|
||
continue
|
||
fm = _parse_skill_frontmatter(content)
|
||
name = (fm.get('name') or entry).strip()
|
||
description = (fm.get('description') or '').strip()
|
||
# 剥离 frontmatter,只保留正文(对齐 skill_loader.to_prompt_block 行为)
|
||
body = content.strip()
|
||
if body.startswith("---"):
|
||
end = body.find("---", 3)
|
||
if end != -1:
|
||
body = body[end + 3:].strip()
|
||
if not description:
|
||
for line in body.split("\n"):
|
||
line = line.strip()
|
||
if line and not line.startswith("#") and not line.startswith("---"):
|
||
description = line[:200]
|
||
break
|
||
modules.append({
|
||
"name": name,
|
||
"description": description,
|
||
"body": body,
|
||
"path": skill_file,
|
||
"repo": entry,
|
||
})
|
||
return modules
|
||
|
||
|
||
async def _build_qna_section(sor, task_id, role, agent_id=None):
|
||
from .communication import get_task_qa
|
||
qa = await get_task_qa(task_id, role=role, agentid=agent_id)
|
||
qna = qa.get('answered', []) or [] if isinstance(qa, dict) else []
|
||
# pending = 当前该「本角色/本 agent」处理的退回意见,重新认领时必须先逐条响应再产出。
|
||
pending = qa.get('pending', []) or [] if isinstance(qa, dict) else []
|
||
if not qna and not pending:
|
||
return ""
|
||
lines = []
|
||
if pending:
|
||
lines.append("⚠️ 待处理问题(审核/PM 退回意见,你必须先逐条响应这些再产出交付件):")
|
||
for p in pending:
|
||
lines.append(f"- [{p.get('from_role', '') or '审核'}] {p.get('question', '')}")
|
||
if qna:
|
||
lines.append("历史问答:")
|
||
for item in qna:
|
||
src = "业主" if item.get('answer_source') == "owner.superuser" else "主agent"
|
||
lines.append(f"问:{item.get('question','')}")
|
||
lines.append(f"答({src}):{item.get('answer','')}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
async def _get_deliverable_content(sor, task_id):
|
||
recs = await sor.sqlExe(
|
||
"SELECT content, deliverable_type FROM pipeline_deliverables "
|
||
"WHERE task_id=${tid}$ ORDER BY created_at DESC LIMIT 1", {"tid": task_id})
|
||
if recs:
|
||
return getattr(recs[0], 'content', '') or '', getattr(recs[0], 'deliverable_type', '') or ''
|
||
return '', ''
|
||
|
||
|
||
async def _get_project_repos(sor, project_id):
|
||
"""获取项目关联的所有仓库。"""
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, repo_name, repo_url, default_branch, local_path FROM sd_project_repos "
|
||
"WHERE project_id=${pid}$", {"pid": project_id})
|
||
return [{'id': getattr(r, 'id', ''), 'name': getattr(r, 'repo_name', ''),
|
||
'url': getattr(r, 'repo_url', ''), 'branch': getattr(r, 'default_branch', 'main'),
|
||
'local_path': getattr(r, 'local_path', '') or ''} for r in (recs or [])]
|
||
|
||
|
||
async def _get_project_name(sor, project_id):
|
||
"""查项目名(用于命名项目过程仓库 {项目名}_pc)。查不到返回空串。"""
|
||
try:
|
||
r = await sor.sqlExe(
|
||
"SELECT name FROM sd_projects WHERE id=${pid}$ LIMIT 1",
|
||
{"pid": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if r:
|
||
return getattr(r[0], 'name', '') or ''
|
||
except Exception:
|
||
pass
|
||
return ''
|
||
|
||
|
||
async def _ensure_project_repo(workspace_dir, project_name=''):
|
||
"""确保项目过程仓库存在并 git init(阶段文档/QC审计/项目管理文档放这里)。
|
||
仓库名 = {项目名}_pc(见 project-directory-spec 规范),查不到项目名时回退 PROJECT_REPO_NAME。
|
||
幂等:已存在则跳过。项目过程仓库是本地仓库,无远程,只 commit 不 push。"""
|
||
repos_dir = os.path.join(workspace_dir, 'repos')
|
||
os.makedirs(repos_dir, exist_ok=True)
|
||
repo_name = f"{project_name}_pc" if project_name else PROJECT_REPO_NAME
|
||
repo_dir = os.path.join(repos_dir, repo_name)
|
||
os.makedirs(repo_dir, exist_ok=True)
|
||
if os.path.isdir(os.path.join(repo_dir, '.git')):
|
||
return {'rc': 0, 'message': '项目过程仓库已存在'}
|
||
async with _git_lock(repo_dir):
|
||
if os.path.isdir(os.path.join(repo_dir, '.git')):
|
||
return {'rc': 0, 'message': '项目过程仓库已存在'}
|
||
r = await _run_shell('git init', repo_dir, 10)
|
||
if r['rc'] != 0:
|
||
return {'rc': r['rc'], 'message': f"git init 失败: {r['stderr'][:200]}"}
|
||
# init 之后才能配置仓库级 user(否则 git config 报 not a git repository)
|
||
await _git_setup(repo_dir)
|
||
readme = os.path.join(repo_dir, 'README.md')
|
||
if not os.path.isfile(readme):
|
||
try:
|
||
with open(readme, 'w', encoding='utf-8') as f:
|
||
f.write('# 项目过程仓库\n\n阶段文档 / QC 审计文档 / 项目管理文档。\n')
|
||
except Exception:
|
||
pass
|
||
r2 = await _run_shell('git add -A && git commit -m "init: 项目过程仓库"', repo_dir, 15)
|
||
return {'rc': r2['rc'], 'message': f"init 项目过程仓库 {'成功' if r2['rc'] == 0 else '失败'}"}
|
||
|
||
|
||
async def _commit_repos_after_approve(workspace_dir, title=''):
|
||
"""审核通过后统一提交:项目过程仓库 repos/{项目名}_pc/(阶段/QC/PM 文档)+ repos/ 下应用/模块仓库(代码)。
|
||
不在 agent 每次产出时提交,减少 git 并发锁与远端 push 频率。"""
|
||
msg = f"approve: {title[:80]}" if title else "approve: 审核通过"
|
||
repos_dir = os.path.join(workspace_dir, 'repos')
|
||
results = []
|
||
if os.path.isdir(repos_dir):
|
||
for name in sorted(os.listdir(repos_dir)):
|
||
rp = os.path.join(repos_dir, name)
|
||
if os.path.isdir(os.path.join(rp, '.git')):
|
||
r = await _git_commit_push(rp, msg)
|
||
results.append(f"{name}: {r.get('message', '')}")
|
||
logger.info(f"commit-after-approve {name}: rc={r.get('rc', -1)} {r.get('message', '')[:100]}")
|
||
return {"rc": 0, "message": "; ".join(results) if results else "无仓库可提交"}
|
||
|
||
|
||
async def _setup_repos(sor, workspace_dir, project_id):
|
||
"""PM:clone 所有项目关联仓库到 workspace/repos/。"""
|
||
# 先确保项目过程仓库存在(阶段文档/QC审计/PM文档放这里),仓库名 = {项目名}_pc
|
||
project_name = await _get_project_name(sor, project_id)
|
||
await _ensure_project_repo(workspace_dir, project_name)
|
||
repos = await _get_project_repos(sor, project_id)
|
||
results = []
|
||
repos_dir = os.path.join(workspace_dir, 'repos')
|
||
os.makedirs(repos_dir, exist_ok=True)
|
||
for repo in repos:
|
||
target = os.path.join(repos_dir, repo['name'])
|
||
r = await _git_clone(repo['url'], target, repo['branch'])
|
||
results.append({'repo': repo['name'], **r})
|
||
return results
|
||
|
||
|
||
async def _get_repo_state(workspace_dir):
|
||
"""获取仓库当前状态(供PM审核时查看)。"""
|
||
repos_dir = os.path.join(workspace_dir, 'repos')
|
||
if not os.path.isdir(repos_dir):
|
||
return "暂无仓库"
|
||
lines = []
|
||
for name in sorted(os.listdir(repos_dir)):
|
||
rp = os.path.join(repos_dir, name)
|
||
if os.path.isdir(rp) and os.path.isdir(os.path.join(rp, '.git')):
|
||
r = await _run_shell('git log --oneline -3', rp, 5)
|
||
lines.append(f"\n[{name}]")
|
||
lines.append(r.get('stdout', '') or '(空仓库)')
|
||
return "\n".join(lines) if lines else "仓库无提交记录"
|
||
|
||
|
||
async def _get_next_role(current_role, project_id=""):
|
||
"""任务链下一角色:从能力包取。"""
|
||
if project_id:
|
||
try:
|
||
pid = await _resolve_pipeline_id(project_id)
|
||
from pipeline_core import get_role_spec
|
||
spec = get_role_spec(pid, current_role)
|
||
if spec:
|
||
return spec.next_role # 可能是 ""(终结)
|
||
except Exception:
|
||
pass
|
||
return ""
|
||
|
||
|
||
async def _create_next_task(sor, project_id, task, next_role, pm_comment=''):
|
||
from appPublic.uniqueID import getID
|
||
title = getattr(task, 'title', '') or ''
|
||
params_str = getattr(task, 'params', '{}') or '{}'
|
||
try:
|
||
params = json.loads(params_str) if isinstance(params_str, str) else params_str
|
||
except (json.JSONDecodeError, TypeError):
|
||
params = {}
|
||
new_title = f"{title}({next_role}阶段)"
|
||
new_params = {**params, 'previous_role': _normalize_role(getattr(task, 'role', '')),
|
||
'previous_task_id': getattr(task, 'id', ''), 'pm_comment': pm_comment}
|
||
# 任务来源标记:系统自动派生(design→develop→deploy_test→test)都是「新开发」链,
|
||
# 不含 bug 修复语义。develop 角色据此判断是否要走 fix_bug 状态机。
|
||
new_params['task_kind'] = 'new_dev'
|
||
# 清除 pm_assigned:它是「PM 按模块清单派发」的标记,只作用于当前任务;
|
||
# 下一角色任务是系统自动创建(非 PM 派发),若继承会污染——例如 PM 派发的 design 任务
|
||
# 带 pm_assigned=True,design approved 后自动创建的 develop 任务继承了它,被 1785 行的
|
||
# deploy_test 触发判断误判为「模块级 develop」,导致应用脚手架 develop approved 后跳过 deploy_test。
|
||
new_params.pop('pm_assigned', None)
|
||
new_task_id = getID()
|
||
await sor.C('pipeline_tasks', {
|
||
'id': new_task_id, 'tenant_id': project_id, 'pipeline_id': 'role_task',
|
||
'owner_id': 'pm', 'title': new_title,
|
||
'params': json.dumps(new_params, ensure_ascii=False),
|
||
'role': next_role, 'state': 'submitted', 'claimed_by': None,
|
||
})
|
||
logger.info(f"_create_next_task: {next_role} task={new_task_id}")
|
||
return new_task_id, new_title
|
||
|
||
|
||
def _task_iteration_name(task):
|
||
"""提取任务的迭代名(params.iteration_id,存的是迭代名称非 id)。"""
|
||
params_str = getattr(task, 'params', '{}') or '{}'
|
||
try:
|
||
params = json.loads(params_str) if isinstance(params_str, str) else params_str
|
||
return (params.get('iteration_id') or '').strip()
|
||
except (json.JSONDecodeError, TypeError):
|
||
return ''
|
||
|
||
|
||
async def _rollback_task_chain(sor, project_id, task_id, rollback_role, comment):
|
||
"""回退:追溯任务链(previous_task_id),作废回退目标及之后的任务,创建回退目标的新任务。
|
||
|
||
回退点之后的关联任务全部作废(state=cancelled),回退目标阶段重新做。
|
||
"""
|
||
from appPublic.uniqueID import getID
|
||
# 追溯任务链(previous_task_id 往前),得到 [最早 ... 当前]
|
||
chain = []
|
||
cur_id = task_id
|
||
visited = set()
|
||
while cur_id and cur_id not in visited:
|
||
visited.add(cur_id)
|
||
recs = await sor.R('pipeline_tasks', {'id': cur_id})
|
||
if not recs:
|
||
break
|
||
t = recs[0]
|
||
chain.append(t)
|
||
try:
|
||
p = json.loads(getattr(t, 'params', '{}') or '{}')
|
||
except (json.JSONDecodeError, TypeError):
|
||
p = {}
|
||
cur_id = p.get('previous_task_id') or ''
|
||
chain.reverse()
|
||
|
||
# 定位回退目标在链上的位置
|
||
target_idx = -1
|
||
for i, t in enumerate(chain):
|
||
if _normalize_role(getattr(t, 'role', '')) == rollback_role:
|
||
target_idx = i
|
||
break
|
||
if target_idx < 0:
|
||
return {"status": "error", "task_id": task_id,
|
||
"error": f"任务链上找不到回退目标角色 {rollback_role},可用:"
|
||
+ "、".join(_normalize_role(getattr(t, 'role', '')) for t in chain)}
|
||
|
||
target_task = chain[target_idx]
|
||
prev_task = chain[target_idx - 1] if target_idx > 0 else None
|
||
|
||
# 作废回退目标及之后的任务(含回退目标本身)
|
||
cancelled = []
|
||
for t in chain[target_idx:]:
|
||
tid = getattr(t, 'id', '')
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_tasks SET state='cancelled', claimed_by=NULL, updated_at=NOW() WHERE id=${tid}$",
|
||
{"tid": tid})
|
||
cancelled.append(tid)
|
||
|
||
# 创建回退目标的新任务(继承回退目标任务 params,记录回退信息,previous 指向前一任务)
|
||
try:
|
||
tp = json.loads(getattr(target_task, 'params', '{}') or '{}')
|
||
except (json.JSONDecodeError, TypeError):
|
||
tp = {}
|
||
new_params = {**tp, 'rollback_from': task_id, 'rollback_comment': comment}
|
||
# 任务来源标记:回退重做 = 修复导致上游失败的缺陷(质检重做),develop 据此必须走 bug 状态机。
|
||
new_params['task_kind'] = 'rework'
|
||
# 清 pm_assigned:回退重做任务是系统重建的任务(非 PM 按模块清单派发),继承 target_task 的
|
||
# pm_assigned 会污染——若 target 是应用级 develop(历史脏数据带 pm_assigned=True),approve 后被
|
||
# 「模块级 develop → 跳过 deploy_test」误判,任务链断在 approved。与 _create_next_task 的 pop 对齐。
|
||
new_params.pop('pm_assigned', None)
|
||
if prev_task:
|
||
new_params['previous_task_id'] = getattr(prev_task, 'id', '')
|
||
new_params['previous_role'] = _normalize_role(getattr(prev_task, 'role', ''))
|
||
else:
|
||
new_params.pop('previous_task_id', None)
|
||
new_params.pop('previous_role', None)
|
||
|
||
new_tid = getID()
|
||
new_title = f"{getattr(target_task, 'title', '') or '任务'}(回退重做)"
|
||
await sor.C('pipeline_tasks', {
|
||
'id': new_tid, 'tenant_id': project_id, 'pipeline_id': 'role_task',
|
||
'owner_id': 'pm', 'title': new_title,
|
||
'params': json.dumps(new_params, ensure_ascii=False),
|
||
'role': rollback_role, 'state': 'submitted', 'claimed_by': None,
|
||
})
|
||
await sor.sqlExe("COMMIT", {})
|
||
logger.info(f"_rollback_task_chain: task={task_id} -> rollback {rollback_role}, "
|
||
f"cancelled={len(cancelled)} 任务, new_task={new_tid}")
|
||
return {"status": "rollback", "task_id": task_id, "rollback_role": rollback_role,
|
||
"cancelled": cancelled, "new_task_id": new_tid, "comment": comment}
|
||
|
||
|
||
# ── Agent 工具执行 ──
|
||
|
||
def _parse_agent_action(raw):
|
||
raw = (raw or "").strip()
|
||
if raw.startswith("```"):
|
||
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
|
||
|
||
# deepseek 原生 function calling 输出 XML:<tool_calls><invoke name="..."><parameter name="..." string="true">...</parameter></invoke></tool_calls>
|
||
m = re.search(r'<invoke\s+name="([^"]+)"[^>]*>(.*?)</invoke>', raw, re.DOTALL)
|
||
if m:
|
||
tool = m.group(1).strip()
|
||
body = m.group(2)
|
||
params = {}
|
||
for pm in re.finditer(r'<parameter\s+name="([^"]+)"[^>]*>(.*?)</parameter>', body, re.DOTALL):
|
||
val = pm.group(2).strip()
|
||
try:
|
||
val = json.loads(val)
|
||
except (json.JSONDecodeError, ValueError):
|
||
pass
|
||
params[pm.group(1)] = val
|
||
if tool in ('deliver', 'deliver_result', 'submit', 'finish'):
|
||
return {"action": "deliver", **params}
|
||
if tool in ('ask', 'ask_question', 'ask_user'):
|
||
return {"action": "ask", "question": params.get("question", "")}
|
||
return {"action": "tool_call", "tool": tool, "params": params}
|
||
|
||
try:
|
||
d = json.loads(raw)
|
||
if isinstance(d, dict) and 'action' in d:
|
||
return d
|
||
except (json.JSONDecodeError, ValueError):
|
||
pass
|
||
return {"action": "deliver", "result": raw}
|
||
|
||
|
||
def _resolve_repo_target(workspace_dir, repo_dir):
|
||
"""解析仓库目录:空→repos/下第一个git仓库;'repos/xxx'→直接;'xxx'→repos/xxx。"""
|
||
repos_dir = os.path.join(workspace_dir, 'repos')
|
||
if repo_dir:
|
||
if repo_dir.startswith('repos/') or repo_dir.startswith('repos\\'):
|
||
return os.path.join(workspace_dir, repo_dir)
|
||
return os.path.join(repos_dir, repo_dir)
|
||
if os.path.isdir(repos_dir):
|
||
dirs = [d for d in os.listdir(repos_dir)
|
||
if os.path.isdir(os.path.join(repos_dir, d, '.git'))]
|
||
if dirs:
|
||
return os.path.join(repos_dir, dirs[0])
|
||
return workspace_dir
|
||
|
||
|
||
def _agent_tools_to_openai_schema(agent_tools):
|
||
"""把 v1 AGENT_TOOLS({name,description,params})转成 OpenAI function-calling schema。"""
|
||
return [
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": t["name"],
|
||
"description": t["description"],
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
k: {"type": "string", "description": v}
|
||
for k, v in (t.get("params") or {}).items()
|
||
},
|
||
"required": list((t.get("required")) or (t.get("params") or {}).keys()),
|
||
},
|
||
},
|
||
}
|
||
for t in agent_tools
|
||
]
|
||
|
||
|
||
async def _exec_agent_tool(tool, params, workspace_dir, ctx=None):
|
||
p = params or {}
|
||
try:
|
||
if tool == 'read_file':
|
||
path = p.get('path', '')
|
||
if not path: return 'FAIL: 需要文件路径'
|
||
full = os.path.join(workspace_dir, path)
|
||
if not await _is_safe_workdir_async(full): return 'FAIL: 路径不在允许范围'
|
||
if not os.path.isfile(full): return f'FAIL: 文件不存在 {path}'
|
||
with open(full, encoding='utf-8') as f:
|
||
return f.read()[:12000]
|
||
elif tool == 'write_file':
|
||
path = p.get('path', '')
|
||
content = p.get('content', '')
|
||
if not path: return 'FAIL: 需要文件路径'
|
||
full = os.path.join(workspace_dir, path)
|
||
if not await _is_safe_workdir_async(full): return 'FAIL: 路径不在允许范围'
|
||
os.makedirs(os.path.dirname(full), exist_ok=True)
|
||
with open(full, 'w', encoding='utf-8') as f:
|
||
f.write(content)
|
||
return f'OK: 已写入 {path} ({len(content)} 字符)'
|
||
elif tool == 'list_files':
|
||
path = p.get('path', '') or '.'
|
||
full = os.path.join(workspace_dir, path)
|
||
if not await _is_safe_workdir_async(full): return 'FAIL: 路径不在允许范围'
|
||
if not os.path.isdir(full): return f'FAIL: 目录不存在 {path}'
|
||
items = os.listdir(full)[:50]
|
||
lines = []
|
||
for name in sorted(items):
|
||
fp = os.path.join(full, name)
|
||
t = 'DIR' if os.path.isdir(fp) else 'FILE'
|
||
size = os.path.getsize(fp) if os.path.isfile(fp) else 0
|
||
lines.append(f"[{t}] {name} ({size}B)")
|
||
return '\n'.join(lines) if lines else '(空目录)'
|
||
elif tool == 'run_shell':
|
||
cmd = p.get('command', '')
|
||
if not cmd: return 'FAIL: 需要命令'
|
||
r = await _run_shell(cmd, workspace_dir, timeout=120)
|
||
return f"rc={r['rc']}\nSTDOUT:\n{r['stdout'][:2000]}\nSTDERR:\n{r['stderr'][:1000]}"
|
||
elif tool == 'git_clone':
|
||
url = p.get('repo_url', '')
|
||
if not url: return 'FAIL: 需要仓库URL'
|
||
name = p.get('repo_name', '') or url.rstrip('/').split('/')[-1].replace('.git', '')
|
||
target = os.path.join(workspace_dir, 'repos', name)
|
||
r = await _git_clone(url, target, p.get('branch', 'main'))
|
||
return f"rc={r['rc']} {r['message']}"
|
||
elif tool == 'git_status':
|
||
target = _resolve_repo_target(workspace_dir, p.get('repo_dir', ''))
|
||
r = await _run_shell('git status --short', target, 10)
|
||
r2 = await _run_shell('git log --oneline -3', target, 10)
|
||
return f"Status:\n{r['stdout'][:1000] or '(clean)'}\nRecent:\n{r2['stdout'][:500]}"
|
||
elif tool == 'git_commit_push':
|
||
msg = p.get('message', '') or 'agent update'
|
||
target = _resolve_repo_target(workspace_dir, p.get('repo_dir', ''))
|
||
r = await _git_commit_push(target, msg)
|
||
return f"rc={r['rc']} {r['message']}"
|
||
# 能力工具(propose_feature/create_case/report_bug 等,按角色 capability 注入)
|
||
from .capability_tools import exec_capability_tool, TOOL_SCHEMAS
|
||
if tool in TOOL_SCHEMAS:
|
||
return await exec_capability_tool(tool, p, ctx or {})
|
||
return f'未实现: {tool}'
|
||
except Exception as e:
|
||
return f'ERROR: {str(e)[:300]}'
|
||
|
||
|
||
# ── 角色 Agent ──
|
||
|
||
# 强制产出轮数:前 5 轮允许探索,第 6 轮起 auto-inject 强制产出。
|
||
# 治「探索死循环」——重做场景 + 复杂 workspace(已有大量文件/git 历史)时 develop 的 LLM 会迷失方向,
|
||
# 30 轮全耗在 list_files/read_file/run_shell 反复「了解现状 + 验证已有内容」,从不 write_file/deliver。
|
||
_FORCE_PRODUCE_TURN = 5
|
||
|
||
# LLM 调用硬超时(外层 asyncio.wait_for 兜底)。llm_bridge 内部 300s×3 重试最坏 15 分钟,
|
||
# 期间 role_agent_run 心跳不更新(心跳在调用前 touch),观察上像任务挂起/僵尸。
|
||
# 外层硬上限:① 防 aiohttp total 超时因 DNS 等场景失效导致的真正无限挂起 ② 给 llm_bridge 至少 2 轮完整重试机会
|
||
# (420s 只够 ~1.4 轮,网关瞬时挂起时重试被提前掐断,可自愈的瞬时故障也 TimeoutError 超限暂停任务链)。
|
||
_LLM_HARD_TIMEOUT = 600
|
||
|
||
_FORCE_PRODUCE_HINT = (
|
||
"⚠️ 你已经探索了足够多轮(已超过 5 轮)。现在必须立即产出并交付:\n"
|
||
"1. 用 write_file 写出实际交付文件(代码/文档/契约/DDL),不要再 read_file / list_files / run_shell / git_status 等探索或检查类工具。\n"
|
||
"2. 信息不确定就用你的专业判断给出合理结果,先产出再迭代。\n"
|
||
"3. 写完后立即调用 deliver 提交交付件,禁止再调用任何探索类工具。"
|
||
)
|
||
|
||
|
||
async def _build_fallback_deliverable(workspace_dir, written_files, repos_dir, role):
|
||
"""循环结束未 deliver 时,检测 write_file/git 实际产出,构造真实交付件给 QC(而非占位符「Agent未产出交付件」)。"""
|
||
lines = []
|
||
if written_files:
|
||
lines.append("本任务执行期间 write_file 实际写入的文件:")
|
||
for f in sorted(set(written_files)):
|
||
rel = os.path.relpath(f, workspace_dir) if f.startswith(workspace_dir) else f
|
||
lines.append("- " + rel)
|
||
if os.path.isdir(repos_dir):
|
||
for repo_name in sorted(os.listdir(repos_dir)):
|
||
repo_path = os.path.join(repos_dir, repo_name)
|
||
if os.path.isdir(os.path.join(repo_path, '.git')):
|
||
try:
|
||
r = await _run_shell('git status --short', repo_path, 10)
|
||
out = (r.get('stdout') or '').strip()
|
||
if out:
|
||
lines.append(f"git 仓库 [{repo_name}] 工作区变更:")
|
||
lines.append(out[:2000])
|
||
except Exception:
|
||
pass
|
||
if not lines:
|
||
lines.append("(本轮未检测到 write_file 或 git 变更——agent 确实未产出)")
|
||
return {
|
||
"result": "\n".join(lines),
|
||
"summary": "Agent 循环结束未调用 deliver,以下为实际产出检测结果(供 QC 判断是否合格)",
|
||
"deliverable_type": f"{role}_fallback",
|
||
}
|
||
|
||
|
||
async def _build_role_skills_block(sor, project_id, role, org_id=""):
|
||
"""为角色 agent 构建技能目录块(分层导入第一层)。
|
||
|
||
所有技能(不分 scope)统一注入目录层(名字+描述),按优先级降序排列。
|
||
角色需要具体规范时用 load_skill 工具按需加载全文(分层导入第二层)。
|
||
优先级(同名覆盖,高→低):角色 > 项目 > 产线 > 机构 > 通用(SCOPE_PRIORITY 已定义)。
|
||
返回注入 system prompt 的文本,无技能或失败时返回空串。
|
||
"""
|
||
try:
|
||
pid = await _resolve_pipeline_id(project_id)
|
||
from pipeline_core.skill_loader import get_skill_loader, SCOPE_PRIORITY, SCOPE_TAG
|
||
from pipeline_core.skill_pack import get_skills_base
|
||
skills_dir = get_skills_base()
|
||
loader = get_skill_loader(skills_dir)
|
||
merged = loader.get_merged(pipeline_id=pid, role=role,
|
||
project_id=project_id, org_id=org_id or '0')
|
||
skills = sorted(merged.values(),
|
||
key=lambda s: (-int(getattr(s, 'essential', False)),
|
||
-SCOPE_PRIORITY.get(getattr(s, 'scope', ''), 0)))
|
||
lines = ["## 可用技能(目录,按需用 load_skill 加载全文;优先级 角色>项目>产线>组织>通用)"]
|
||
for s in skills[:60]:
|
||
tag = SCOPE_TAG.get(getattr(s, 'scope', ''), getattr(s, 'scope', ''))
|
||
lines.append(f"- [{tag}] {s.name}: {(s.description or '')[:100]}")
|
||
# 项目模块技能:workspace repos/*/skill/SKILL.md(模块怎么用——架构/数据模型/挂载函数/坑位),
|
||
# 运行时扫描注入,让项目角色知道引用了哪些模块、每个模块怎么挂载(load_xxx 入口/库名/坑)。
|
||
try:
|
||
ws = await _get_workspace_dir(sor, project_id)
|
||
module_skills = _collect_module_skills(ws)
|
||
if module_skills:
|
||
lines.append("\n## 项目模块技能(本项目引用的业务模块,load_skill 加载全文了解模块怎么用)")
|
||
for m in module_skills:
|
||
lines.append(f"- [项目模块] {m['name']}: {(m['description'] or '')[:100]}")
|
||
except Exception as e:
|
||
logger.warning(f"module skills collect failed: {e}")
|
||
return "\n".join(lines)
|
||
except Exception as e:
|
||
logger.warning(f"role skills load failed: {e}")
|
||
return ""
|
||
|
||
|
||
async def _collect_capability_tools(sor, project_id, role, org_id=""):
|
||
"""收集角色声明的 capability 对应的能力工具。
|
||
|
||
角色技能(scope=role)frontmatter 声明 `capability: feature_capability`(角色需要哪些能力),
|
||
产线状态机规范(scope=pipeline)frontmatter 声明 `capability + tools`(能力含哪些工具),
|
||
这里匹配两者,返回要注入的工具定义列表(name/description/params/required)。
|
||
"""
|
||
try:
|
||
pid = await _resolve_pipeline_id(project_id)
|
||
from pipeline_core.skill_loader import get_skill_loader
|
||
from pipeline_core.skill_pack import get_skills_base
|
||
from .capability_tools import resolve_capability_tools
|
||
skills_dir = get_skills_base()
|
||
loader = get_skill_loader(skills_dir)
|
||
merged = loader.get_merged(pipeline_id=pid, role=role,
|
||
project_id=project_id, org_id=org_id or '0')
|
||
if not merged:
|
||
return []
|
||
role_caps = set()
|
||
for skill in merged.values():
|
||
if getattr(skill, 'scope', '') == 'role':
|
||
cap = getattr(skill, 'capability', '') or ''
|
||
for c in str(cap).split(','):
|
||
c = c.strip()
|
||
if c:
|
||
role_caps.add(c)
|
||
return resolve_capability_tools(role_caps, merged)
|
||
except Exception as e:
|
||
logger.warning(f"collect capability tools failed: {e}")
|
||
return []
|
||
|
||
|
||
async def _load_skill_by_name(sor, project_id, role, org_id, name, file_path=None):
|
||
"""按需加载技能全文或子文件(分层导入第二层,对应 load_skill 工具)。
|
||
|
||
- 不带 file_path:返回技能 SKILL.md 全文 + 关联文件清单(references/scripts/templates/assets)。
|
||
- 带 file_path:返回 skill 目录下对应子文件内容(仅限 references/scripts/templates/assets)。
|
||
"""
|
||
role = _normalize_role(role) # 归一化:裸名 'qc'/'pm' → 'agent.qc'/'agent.pm',否则加载不到角色技能目录
|
||
name = (name or '').strip()
|
||
if not name:
|
||
return 'FAIL: 需要技能名称'
|
||
try:
|
||
pid = await _resolve_pipeline_id(project_id)
|
||
from pipeline_core.skill_loader import get_skill_loader
|
||
from pipeline_core.skill_pack import get_skills_base
|
||
skills_dir = get_skills_base()
|
||
loader = get_skill_loader(skills_dir)
|
||
merged = loader.get_merged(pipeline_id=pid, role=role,
|
||
project_id=project_id, org_id=org_id or '0')
|
||
if name not in merged:
|
||
# 项目模块技能(workspace repos/*/skill/SKILL.md)不在 skill_loader 静态树里,运行时补查。
|
||
# 全文层对齐 skill_loader.to_prompt_block:剥离 frontmatter 只返回正文(body)。
|
||
try:
|
||
ws = await _get_workspace_dir(sor, project_id)
|
||
for m in _collect_module_skills(ws):
|
||
if m['name'] == name or m['repo'] == name:
|
||
return f"## [项目模块] {m['name']}\n{m['description']}\n\n{m['body']}"
|
||
except Exception:
|
||
pass
|
||
names = ", ".join(sorted(merged.keys())) or "(无可用技能)"
|
||
return f"FAIL: 技能 '{name}' 不存在。可用技能: {names}"
|
||
skill = merged[name]
|
||
if file_path:
|
||
return skill.read_linked_file(file_path)
|
||
body = skill.to_prompt_block()
|
||
linked = skill.list_linked_files()
|
||
if linked:
|
||
body += "\n\n## 关联文件(可用 load_skill(name, file_path) 按需加载)\n" + "\n".join(f"- {f}" for f in linked)
|
||
return body
|
||
except Exception as e:
|
||
return f'ERROR: {str(e)[:300]}'
|
||
|
||
|
||
async def role_agent_run(project_id, role, agent_id=None, model_name=None):
|
||
role, _role_specific, _ = await _resolve_role(project_id, role)
|
||
if role == 'pm':
|
||
return {"status": "idle", "message": "PM请使用 pm_review_run"}
|
||
|
||
db = _get_db()
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
# 解析 LLM 上下文(model 一致性 + org_id 多租户隔离)
|
||
model_name, org_id = await _resolve_llm_context(sor, project_id, role, model_name)
|
||
task = await _claim_task(sor, project_id, role)
|
||
if not task:
|
||
return {"status": "idle", "message": "没有待办任务"}
|
||
|
||
task_id = task.id
|
||
title = getattr(task, "title", "") or ""
|
||
params_str = getattr(task, "params", "{}") or "{}"
|
||
# 机构 llm 检测:机构没配 llm → 冒泡问题暂停(LLM 都调不了,不能硬跑)
|
||
llm_missing, _names = await _check_org_llm(sor, org_id)
|
||
if llm_missing:
|
||
from .communication import raise_problem
|
||
qid = await raise_problem(
|
||
"need_info",
|
||
f"机构(org_id={org_id})未配置 LLM 模型,角色 agent 无法调用模型。"
|
||
f"请在「模型管理」为该机构配置可用模型后重试。",
|
||
role, tenant_id=project_id, task_id=task_id,
|
||
first_handler_role="agent.main_agent", suspend_task=True,
|
||
)
|
||
logger.warning(f"role_agent_run: org llm missing, task={task_id} org={org_id}")
|
||
return {"status": "need_info", "task_id": task_id, "question_id": qid}
|
||
workspace_dir = await _get_workspace_dir(sor, project_id)
|
||
|
||
try:
|
||
await sor.sqlExe("COMMIT", {})
|
||
except Exception:
|
||
pass
|
||
|
||
repos_dir = os.path.join(workspace_dir, 'repos')
|
||
os.makedirs(repos_dir, exist_ok=True)
|
||
|
||
# 确保项目关联仓库已 clone(幂等:已存在则 pull)——否则源码写不进 git 仓库
|
||
try:
|
||
clone_results = await _setup_repos(sor, workspace_dir, project_id)
|
||
logger.info(f"role_agent_run setup_repos: {clone_results}")
|
||
except Exception as e:
|
||
logger.warning(f"role_agent_run setup_repos failed: {e}")
|
||
|
||
role_specific = _role_specific
|
||
qna_section = await _build_qna_section(sor, task_id, role, agent_id)
|
||
# 能力工具:角色技能 capability 声明 → 产线状态机规范 tools 声明 → 注入对应工具
|
||
capability_tools = await _collect_capability_tools(sor, project_id, role, org_id)
|
||
all_tools = AGENT_TOOLS + capability_tools
|
||
tools_text = json.dumps(all_tools, ensure_ascii=False)
|
||
|
||
# 注入角色技能(角色专属技能全量 + 其余 scope 目录层,优先级 角色>项目>产线>组织>通用)
|
||
role_skills = await _build_role_skills_block(sor, project_id, role, org_id)
|
||
# 项目名(用于 prompt 里 {项目名}_pc 占位符替换,角色产出路径以 project-directory-spec 为准)
|
||
project_name = await _get_project_name(sor, project_id)
|
||
# 能力工具上下文(project_id/iteration_id/who/agent_id 自动注入,LLM 不可见)
|
||
_iter = None
|
||
try:
|
||
from .iteration_capability import get_current_iteration
|
||
_iter = await get_current_iteration(sor, project_id)
|
||
except Exception:
|
||
_iter = None
|
||
capability_ctx = {
|
||
"project_id": project_id,
|
||
"iteration_id": _iter.get('id', '') if _iter else '',
|
||
"who": role,
|
||
"agent_id": agent_id,
|
||
"task_id": task_id,
|
||
}
|
||
|
||
system = (AGENT_SYSTEM_PROMPT
|
||
.replace('__ROLE__', role)
|
||
.replace('__TITLE__', title)
|
||
.replace('__QNA__', qna_section)
|
||
.replace('__WORKSPACE__', workspace_dir)
|
||
.replace('__PROJECT_NAME__', project_name)
|
||
.replace('__ROLE_SPECIFIC__', role_specific)
|
||
.replace('__ROLE_SKILLS__', role_skills)
|
||
.replace('__TOOLS__', tools_text))
|
||
|
||
msgs = [{"role": "system", "content": system}]
|
||
msgs.append({"role": "user", "content": f"执行任务:{title}\n参数:{params_str}"})
|
||
|
||
from .llm_bridge import llm_call_msgs, llm_call_msgs_native
|
||
tools_schema = _agent_tools_to_openai_schema(all_tools)
|
||
|
||
deliverable = None
|
||
ask_question = None
|
||
written_files = [] # 本任务执行期间 write_file 实际写入的文件(无 deliver 时的产出兜底)
|
||
|
||
# ── Tool Loop(原生 function calling)──
|
||
for turn in range(30):
|
||
# 心跳:标记任务仍在执行,供 stale 回收判断(进程崩溃后任务不再被 touch 即被回收)。
|
||
# 必须 COMMIT 让心跳对其他连接可见,否则 poller 看不到心跳会误判为僵尸。
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_tasks SET updated_at=NOW() WHERE id=${tid}$ AND state='running'",
|
||
{"tid": task_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
# 取消检测:任务被 cancel_task 标记 cancelled 后立即中止,避免与重派的新任务重复执行
|
||
_st = await sor.sqlExe("SELECT state FROM pipeline_tasks WHERE id=${tid}$", {"tid": task_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if _st and getattr(_st[0], 'state', '') == 'cancelled':
|
||
logger.info(f"task cancelled mid-run: {task_id}")
|
||
return {"status": "cancelled", "task_id": task_id}
|
||
# 强制产出:第 6 轮起注入,打断「了解现状/反复验证」探索死循环,强制转向 write_file + deliver
|
||
if turn >= _FORCE_PRODUCE_TURN:
|
||
msgs.append({"role": "user", "content": _FORCE_PRODUCE_HINT})
|
||
try:
|
||
resp = await asyncio.wait_for(
|
||
llm_call_msgs_native(msgs, tools=tools_schema, model=model_name, temperature=0.4, org_id=org_id),
|
||
timeout=_LLM_HARD_TIMEOUT)
|
||
except Exception as e:
|
||
err_msg = f"{type(e).__name__}: {str(e)[:400]}"
|
||
from .task_capability import mark_failed
|
||
await mark_failed(task_id, project_id, who=role, agent_id=agent_id, error=err_msg)
|
||
logger.error(f"role_agent_run llm failed: task={task_id} err={e}")
|
||
return {"status": "failed", "task_id": task_id, "error": str(e)[:200]}
|
||
|
||
native_calls = (resp.get("tool_calls") or []) if isinstance(resp, dict) else []
|
||
|
||
if native_calls:
|
||
# 回填 assistant(含 tool_calls,OpenAI 原生格式)
|
||
msgs.append({"role": "assistant", "content": resp.get("content") or None, "tool_calls": native_calls})
|
||
for tc in native_calls:
|
||
fn = tc.get("function", {}) if isinstance(tc, dict) else {}
|
||
tool = fn.get("name", "")
|
||
try:
|
||
params = json.loads(fn.get("arguments") or "{}")
|
||
except Exception:
|
||
params = {}
|
||
|
||
if tool == "deliver":
|
||
deliverable = {"action": "deliver", **params}
|
||
break
|
||
if tool == "ask_question":
|
||
ask_question = params.get("question", "")
|
||
break
|
||
|
||
if tool == "load_skill":
|
||
result = await _load_skill_by_name(sor, project_id, role, org_id, params.get("name", ""), params.get("file_path") or None)
|
||
else:
|
||
result = await _exec_agent_tool(tool, params, workspace_dir, capability_ctx)
|
||
if tool == "write_file" and params.get("path"):
|
||
written_files.append(os.path.join(workspace_dir, params["path"]))
|
||
msgs.append({"role": "tool", "tool_call_id": tc.get("id", ""), "content": str(result)})
|
||
logger.info(f"role_agent tool_call: {tool} -> {str(result)[:100]}")
|
||
if deliverable or ask_question:
|
||
break
|
||
continue
|
||
|
||
# 无 tool_calls → 文本兜底(deliver/ask 的 JSON)
|
||
raw = resp.get("content", "") if isinstance(resp, dict) else str(resp)
|
||
act = _parse_agent_action(raw)
|
||
if act.get('action') == 'deliver':
|
||
deliverable = act
|
||
break
|
||
elif act.get('action') == 'ask':
|
||
ask_question = act.get('question', '')
|
||
break
|
||
elif act.get('action') == 'tool_call':
|
||
tool = act.get('tool', '')
|
||
params = act.get('params', {})
|
||
if tool == 'load_skill':
|
||
result = await _load_skill_by_name(sor, project_id, role, org_id, params.get('name', ''), params.get('file_path') or None)
|
||
else:
|
||
result = await _exec_agent_tool(tool, params, workspace_dir, capability_ctx)
|
||
if tool == 'write_file' and params.get('path'):
|
||
written_files.append(os.path.join(workspace_dir, params['path']))
|
||
msgs.append({"role": "assistant", "content": raw})
|
||
msgs.append({"role": "user", "content": f"工具 {tool} 结果:\n{result}"})
|
||
logger.info(f"role_agent tool_call(text): {tool} -> {str(result)[:100]}")
|
||
else:
|
||
deliverable = {"result": raw}
|
||
break
|
||
|
||
if ask_question:
|
||
from .communication import raise_problem
|
||
# need_info:角色缺信息向上提问,首处理方=主 agent
|
||
qid = await raise_problem("need_info", ask_question, role,
|
||
from_agentid=agent_id,
|
||
tenant_id=project_id, task_id=task_id,
|
||
first_handler_role="agent.main_agent",
|
||
context={"title": title})
|
||
return {"status": "need_info", "task_id": task_id, "question_id": qid, "question": ask_question}
|
||
|
||
if not deliverable:
|
||
# 兜底:循环结束未 deliver,检测 write_file/git 实际产出构造真实交付件(而非占位符)
|
||
deliverable = await _build_fallback_deliverable(workspace_dir, written_files, repos_dir, role)
|
||
|
||
# ── 处理产出 ──
|
||
from appPublic.uniqueID import getID
|
||
did = getID()
|
||
result_text = deliverable.get("result") or ""
|
||
deliverable_type = deliverable.get("deliverable_type") or role
|
||
summary = deliverable.get("summary", "")
|
||
|
||
# 写入代码文件
|
||
files_written = []
|
||
code_files = deliverable.get("files") or []
|
||
if isinstance(code_files, str):
|
||
try:
|
||
code_files = json.loads(code_files)
|
||
except Exception:
|
||
code_files = []
|
||
if isinstance(code_files, list):
|
||
for f in code_files:
|
||
if isinstance(f, dict) and f.get("path") and f.get("content"):
|
||
abs_path = os.path.join(workspace_dir, f["path"])
|
||
ok, msg = await _write_code_file(abs_path, f["content"])
|
||
if ok:
|
||
files_written.append(abs_path)
|
||
logger.info(f"code file written: {abs_path}")
|
||
else:
|
||
logger.error(f"code file failed: {abs_path} err={msg}")
|
||
|
||
# 写交付件文档
|
||
file_path = ''
|
||
if result_text:
|
||
deliverable_dir = os.path.join(workspace_dir, 'deliverables', role)
|
||
os.makedirs(deliverable_dir, exist_ok=True)
|
||
safe_type = (deliverable_type or 'deliverable').replace('/', '_')
|
||
file_path = os.path.join(deliverable_dir, f"{task_id}_{safe_type}.md")
|
||
try:
|
||
with open(file_path, 'w', encoding='utf-8') as f:
|
||
f.write(result_text or '')
|
||
except Exception as e:
|
||
logger.error(f"deliverable write failed: {file_path} err={e}")
|
||
file_path = ''
|
||
|
||
await sor.C("pipeline_deliverables", {
|
||
"id": did, "project_id": project_id, "task_id": task_id,
|
||
"deliverable_type": deliverable_type, "title": title,
|
||
"content": result_text, "file_path": file_path,
|
||
"quality_score": 80, "review_status": "pending",
|
||
"created_by": agent_id or role,
|
||
})
|
||
|
||
# Git 提交时机:不在 agent 每次产出时提交,改为 PM 审核通过后统一提交(减少并发锁、避免每次生成都 push 远端)
|
||
git_result = {"rc": 0, "message": "延迟提交:审核通过后统一 git 提交"}
|
||
|
||
from .task_capability import submit_task
|
||
ok, _ = await submit_task(task_id, project_id, who=role, agent_id=agent_id)
|
||
logger.info(f"role_agent_run completed: task={task_id} role={role} "
|
||
f"deliverable={did} files={len(files_written)} git={git_result.get('rc',-1)} submit={ok}")
|
||
return {"status": "completed", "task_id": task_id, "deliverable_id": did,
|
||
"files_written": len(files_written), "git_result": git_result}
|
||
|
||
|
||
# ── PM 审核 ──
|
||
|
||
async def _pm_create_tasks(sor, project_id, params, parent_task_id=None):
|
||
"""PM 派发后续任务(项目计划 / 任务分配):批量创建任务并指定角色。
|
||
|
||
params.tasks: JSON 数组 [{title, role, description, key, depends_on, parent_id}],
|
||
一次派发多个子任务,支持:
|
||
- parent_id:父任务ID(默认 = 本次审核的里程碑任务),记录父子关系供任务树分层;
|
||
- key:LLM 给子任务起的短标识,供同批任务间 depends_on 相互引用;
|
||
- depends_on:依赖的 key 或任务ID 数组(空 = 并行;非空 = 串行等待依赖完成后才可认领)。
|
||
兼容单任务形态:params 直接含 {title, role, description}。
|
||
"""
|
||
from appPublic.uniqueID import getID
|
||
tasks = params.get('tasks') or []
|
||
if isinstance(tasks, str):
|
||
try:
|
||
tasks = json.loads(tasks)
|
||
except (json.JSONDecodeError, ValueError):
|
||
return 'FAIL: tasks 必须是 JSON 数组'
|
||
if not tasks:
|
||
if params.get('title'):
|
||
tasks = [{'title': params.get('title'), 'role': params.get('role'), 'description': params.get('description')}]
|
||
else:
|
||
return 'FAIL: 需要 tasks 数组或 title'
|
||
if not isinstance(tasks, list):
|
||
return 'FAIL: tasks 必须是数组'
|
||
|
||
# 默认父任务:本次审核的里程碑任务(子任务挂它名下,任务树据此分层)
|
||
default_parent = (params.get('parent_id') or parent_task_id or '').strip()
|
||
|
||
# 当前迭代(status='in_progress' 的唯一迭代),作为任务默认归属
|
||
iteration_name = ''
|
||
from .iteration_capability import get_current_iteration
|
||
cur = await get_current_iteration(sor, project_id)
|
||
if cur:
|
||
iteration_name = cur.get('iteration_name', '') or ''
|
||
|
||
created = [] # [(task_dict, tid, title, role)]
|
||
key_map = {} # key → 真实任务ID(同批 depends_on 用 key 互引,第二遍解析)
|
||
cancelled = []
|
||
warnings = []
|
||
for t in tasks:
|
||
if not isinstance(t, dict):
|
||
continue
|
||
title = (t.get('title') or '').strip()
|
||
if not title:
|
||
continue
|
||
role = _normalize_role(t.get('role') or 'agent.develop')
|
||
desc = (t.get('description') or '').strip()
|
||
key = (t.get('key') or '').strip()
|
||
parent = (t.get('parent_id') or '').strip() or default_parent
|
||
|
||
# 重做保护:同项目存在同标题活跃任务(submitted/running/review/qc_review)时,
|
||
# 先自动取消旧任务再创建新任务,避免两个相同任务并存(重做必须先终止旧任务)。
|
||
dup = await sor.sqlExe(
|
||
"SELECT id FROM pipeline_tasks WHERE tenant_id=${pid}$ AND title=${title}$ "
|
||
"AND state IN ('submitted','running','review','qc_review')",
|
||
{"pid": project_id, "title": title})
|
||
for d in (dup or []):
|
||
did = getattr(d, 'id', '')
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_tasks SET state='cancelled', claimed_by=NULL, updated_at=NOW() WHERE id=${did}$",
|
||
{"did": did})
|
||
cancelled.append(did)
|
||
|
||
tparams = {"description": desc, "pm_assigned": True}
|
||
# 任务来源标记:PM 派发的任务按 title/description 判断——
|
||
# 含「修复 Bug」= bug_fix(develop 必须走 fix_bug 状态机);否则 = new_dev(模块开发等)。
|
||
_title_desc = f"{title} {desc}"
|
||
if '修复 Bug' in _title_desc or '修复Bug' in _title_desc or '修复bug' in _title_desc:
|
||
tparams['task_kind'] = 'bug_fix'
|
||
else:
|
||
tparams['task_kind'] = 'new_dev'
|
||
if iteration_name:
|
||
tparams["iteration_id"] = iteration_name
|
||
tid = getID()
|
||
await sor.C('pipeline_tasks', {
|
||
'id': tid, 'tenant_id': project_id, 'pipeline_id': 'role_task',
|
||
'owner_id': 'pm', 'title': title, 'state': 'submitted',
|
||
'role': role, 'params': json.dumps(tparams, ensure_ascii=False),
|
||
'parent_id': parent or None,
|
||
})
|
||
if key:
|
||
key_map[key] = tid
|
||
created.append((t, tid, title, role))
|
||
|
||
# 第二遍:解析 depends_on(key 或 任务ID)→ 真实任务ID,回填 depends_on 列。
|
||
# 串行依赖 = depends_on 非空(依赖完成才认领);并行 = 空(不填)。
|
||
# 父任务本身在 PM approve 后即为 approved 终态,子任务无需再显式依赖父。
|
||
for t, tid, title, role in created:
|
||
deps = t.get('depends_on') or []
|
||
if isinstance(deps, str):
|
||
try:
|
||
deps = json.loads(deps)
|
||
except (json.JSONDecodeError, ValueError):
|
||
deps = []
|
||
resolved = []
|
||
for d in (deps or []):
|
||
d = (str(d) or '').strip()
|
||
if not d:
|
||
continue
|
||
if d in key_map:
|
||
resolved.append(key_map[d]) # 同批 key 引用
|
||
elif len(d) >= 20:
|
||
resolved.append(d) # 已有任务ID(跨批/父任务引用)
|
||
else:
|
||
warnings.append(f"「{title}」的 depends_on 未知引用「{d}」已忽略")
|
||
if resolved:
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_tasks SET depends_on=${deps}$ WHERE id=${tid}$",
|
||
{"deps": json.dumps(resolved, ensure_ascii=False), "tid": tid})
|
||
|
||
if not created:
|
||
return 'FAIL: 没有可创建的任务(缺少 title)'
|
||
# C 之后立即 COMMIT,释放行锁,供 agent_poller 下一轮可见认领
|
||
await sor.sqlExe("COMMIT", {})
|
||
note = f"(已自动取消 {len(cancelled)} 个同标题活跃任务)" if cancelled else ""
|
||
warn_note = f"({len(warnings)} 个未知依赖引用已忽略)" if warnings else ""
|
||
return f"OK: 已派发 {len(created)} 个任务" + note + warn_note + ":" + \
|
||
";".join([f"{title}({role})" for _, _, title, role in created])
|
||
|
||
|
||
async def _pm_list_tasks(sor, project_id, params):
|
||
"""PM 查看项目现有任务(派发前查重)。"""
|
||
role = (params.get('role') or '').strip()
|
||
state = (params.get('state') or '').strip()
|
||
sql = "SELECT id, title, role, state FROM pipeline_tasks WHERE tenant_id=${pid}$"
|
||
p = {"pid": project_id}
|
||
if role:
|
||
sql += " AND role=${role}$"
|
||
p["role"] = _normalize_role(role)
|
||
if state:
|
||
sql += " AND state=${state}$"
|
||
p["state"] = state
|
||
sql += " ORDER BY created_at ASC LIMIT 40"
|
||
recs = await sor.sqlExe(sql, p)
|
||
# 纯 SELECT 后 COMMIT,避免持有 MDL 锁阻塞后续 DDL
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return "暂无任务"
|
||
icons = {"submitted": "⏳", "running": "🔄", "review": "👀", "qc_review": "🔍",
|
||
"approved": "✅", "completed": "✔️", "failed": "❌", "waiting": "⏸️"}
|
||
lines = ["| 状态 | 任务 | 角色 |", "|------|------|------|"]
|
||
for r in recs:
|
||
st = getattr(r, 'state', '')
|
||
lines.append(f"| {icons.get(st, st)} | {getattr(r, 'title', '')} | {getattr(r, 'role', '')} |")
|
||
return "\n".join(lines)
|
||
|
||
|
||
async def _pm_cancel_task(sor, project_id, params):
|
||
"""PM 取消任务(重做/作废场景必须先取消旧任务,避免两个相同任务并存)。
|
||
|
||
委托 task_capability.cancel_task(CAS + 审计),正在执行中的 agent 会在下一轮心跳
|
||
检测到 cancelled 并自行中止(见 role_agent_run 的取消检测)。
|
||
"""
|
||
task_id = (params.get('task_id') or params.get('id') or '').strip()
|
||
if not task_id:
|
||
return 'FAIL: 需要 task_id'
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, title, state FROM pipeline_tasks WHERE id=${tid}$ AND tenant_id=${pid}$",
|
||
{"tid": task_id, "pid": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return 'FAIL: 任务不存在或不属于当前项目'
|
||
title = getattr(recs[0], 'title', '')
|
||
state = getattr(recs[0], 'state', '')
|
||
if state in ('completed', 'cancelled', 'failed', 'approved'):
|
||
return f'任务「{title}」已是终态 {state},无需取消'
|
||
from .task_capability import cancel_task
|
||
ok, msg = await cancel_task(task_id, project_id, who="agent.pm")
|
||
if not ok:
|
||
return f'FAIL: {msg}'
|
||
return f'OK: 已取消任务「{title}」({task_id}),正在执行的 agent 将在下一轮心跳中止'
|
||
|
||
|
||
async def pm_review_run(project_id, agent_id=None, model_name=None):
|
||
db = _get_db()
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
# PM 审核用产线 default_model(无角色专属模型),org_id 多租户隔离
|
||
model_name, org_id = await _resolve_llm_context(sor, project_id, 'pm', model_name)
|
||
# 暂停门控:项目 paused 时 PM 不推进(不认领审核任务)。默认必须推进,暂停才需要指令。
|
||
_precs = await sor.sqlExe(
|
||
"SELECT status FROM sd_projects WHERE id=${pid}$", {"pid": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if _precs and getattr(_precs[0], 'status', '') == 'paused':
|
||
return {"status": "idle", "message": "项目已暂停推进"}
|
||
task = await _claim_task(sor, project_id, '', state=TASK_REVIEW, match_role=False, set_state='review')
|
||
if not task:
|
||
return {"status": "idle", "message": "没有待审核任务"}
|
||
|
||
task_id = task.id
|
||
title = getattr(task, "title", "") or ""
|
||
task_role = _normalize_role(getattr(task, "role", "") or "")
|
||
# 机构 llm 检测:机构没配 llm → 冒泡问题暂停
|
||
llm_missing, _names = await _check_org_llm(sor, org_id)
|
||
if llm_missing:
|
||
from .communication import raise_problem
|
||
qid = await raise_problem(
|
||
"need_info",
|
||
f"机构(org_id={org_id})未配置 LLM 模型,PM 审核无法调用模型。"
|
||
f"请在「模型管理」为该机构配置可用模型后重试。",
|
||
"agent.pm", tenant_id=project_id, task_id=task_id,
|
||
first_handler_role="agent.main_agent", suspend_task=True,
|
||
)
|
||
logger.warning(f"pm_review_run: org llm missing, task={task_id} org={org_id}")
|
||
return {"status": "need_info", "task_id": task_id, "question_id": qid}
|
||
workspace_dir = await _get_workspace_dir(sor, project_id)
|
||
|
||
try:
|
||
await sor.sqlExe("COMMIT", {})
|
||
except Exception:
|
||
pass
|
||
|
||
repos = await _get_project_repos(sor, project_id)
|
||
if repos:
|
||
await _setup_repos(sor, workspace_dir, project_id)
|
||
|
||
deliverable_content, deliverable_type = await _get_deliverable_content(sor, task_id)
|
||
if not deliverable_content:
|
||
from .task_capability import reject_task
|
||
await reject_task(task_id, project_id, who="agent.pm", agent_id=agent_id, comment="没有交付件")
|
||
return {"status": "rejected", "task_id": task_id, "reason": "没有交付件"}
|
||
|
||
repo_state = await _get_repo_state(workspace_dir)
|
||
repos_str = ", ".join([r['name'] for r in repos]) if repos else "无"
|
||
content_preview = deliverable_content[:6000]
|
||
role_skills = await _build_role_skills_block(sor, project_id, _normalize_role('pm'), org_id)
|
||
|
||
pm_system = PM_SYSTEM_PROMPT.replace('__TITLE__', title)\
|
||
.replace('__ROLE__', task_role)\
|
||
.replace('__WORKSPACE__', workspace_dir)\
|
||
.replace('__REPOS__', repos_str)\
|
||
.replace('__REPO_STATE__', repo_state)\
|
||
.replace('__ROLE_SKILLS__', role_skills)
|
||
|
||
msgs = [{"role": "system", "content": pm_system}]
|
||
msgs.append({"role": "user", "content": f"请审核以下交付件(类型:{deliverable_type}):\n\n{content_preview}"})
|
||
|
||
from .llm_bridge import llm_call_msgs
|
||
|
||
decision = None
|
||
for turn in range(5):
|
||
# 心跳:PM 审核期间持续标记,进程崩溃后由 stale 回收重置;COMMIT 使其对其他连接可见
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_tasks SET updated_at=NOW() WHERE id=${tid}$ AND state='review'",
|
||
{"tid": task_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
# 取消检测:任务被 cancel_task 标记 cancelled 后立即中止审核
|
||
_st = await sor.sqlExe("SELECT state FROM pipeline_tasks WHERE id=${tid}$", {"tid": task_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if _st and getattr(_st[0], 'state', '') == 'cancelled':
|
||
logger.info(f"pm review cancelled mid-run: {task_id}")
|
||
return {"status": "cancelled", "task_id": task_id}
|
||
# auto-inject 兜底:最后两轮强制要求给出 review_* 决策,禁止再 tool_call,
|
||
# 否则 deepseek 会一直读文件/git_status 耗尽 5 轮 → 审核超时 → 打回重跑 → 死循环。
|
||
# 例外:允许 create_tasks(里程碑拆后续任务的「项目计划/任务分配」职责),派发后必须立即决策。
|
||
if turn >= 3:
|
||
msgs.append({"role": "user", "content":
|
||
"已检查足够信息。现在必须立即收尾:"
|
||
"若本任务审批通过后需拆分为多个后续任务,现在用 create_tasks 一次性派发;"
|
||
"随后必须立即输出 review_approve / review_reject / review_complete / review_rollback 之一,"
|
||
"禁止再调用其它工具。"})
|
||
try:
|
||
raw = await llm_call_msgs(msgs, model=model_name, temperature=0.3, org_id=org_id)
|
||
except Exception as e:
|
||
err_msg = f"{type(e).__name__}: {str(e)[:400]}"
|
||
from .task_capability import mark_failed
|
||
await mark_failed(task_id, project_id, who="agent.pm", agent_id=agent_id, error=err_msg)
|
||
return {"status": "failed", "task_id": task_id, "error": str(e)[:200]}
|
||
|
||
act = _parse_agent_action(raw)
|
||
|
||
if act.get('action') == 'review_approve':
|
||
decision = {'status': 'approved', 'comment': act.get('comment', ''), 'next_title': act.get('next_task_title', ''), 'next_desc': act.get('next_task_description', '')}
|
||
break
|
||
elif act.get('action') == 'review_reject':
|
||
decision = {'status': 'rejected', 'comment': act.get('comment', ''), 'questions': act.get('questions', '')}
|
||
break
|
||
elif act.get('action') == 'review_complete':
|
||
decision = {'status': 'completed', 'comment': act.get('comment', '')}
|
||
break
|
||
elif act.get('action') == 'review_rollback':
|
||
decision = {'status': 'rollback', 'rollback_role': act.get('rollback_role', ''), 'comment': act.get('comment', '')}
|
||
break
|
||
elif act.get('action') == 'tool_call':
|
||
tool = act.get('tool', '')
|
||
params = act.get('params', {})
|
||
if tool in ('create_tasks', 'create_task'):
|
||
result = await _pm_create_tasks(sor, project_id, params, task_id)
|
||
elif tool == 'list_tasks':
|
||
result = await _pm_list_tasks(sor, project_id, params)
|
||
elif tool in ('cancel_task', 'cancel'):
|
||
result = await _pm_cancel_task(sor, project_id, params)
|
||
elif tool == 'load_skill':
|
||
result = await _load_skill_by_name(sor, project_id, 'pm', org_id, params.get('name', ''), params.get('file_path') or None)
|
||
else:
|
||
if turn >= 3:
|
||
# 硬约束:最后两轮拒绝执行探索类工具(read_file/git_status/list_files/run_shell 等),
|
||
# 强制 PM 立即输出决策——否则 deepseek 无视软提示持续检查、5 轮耗尽 → 审核超时。
|
||
result = (f"已到最后收尾阶段(第 {turn + 1}/5 轮),拒绝执行探索类工具 {tool}。"
|
||
f"请立即输出 review_approve / review_reject / review_complete / review_rollback 之一,不要再调用工具。")
|
||
else:
|
||
result = await _exec_agent_tool(tool, params, workspace_dir)
|
||
msgs.append({"role": "assistant", "content": raw})
|
||
msgs.append({"role": "user", "content": f"工具 {tool} 结果:\n{result}"})
|
||
else:
|
||
# 未识别的 PM 输出(deliver/纯文本/格式错误)绝不能静默当作 review_complete——那会把任务
|
||
# 置 completed 且不创建下一角色任务,任务链就此断掉(正是「需求验收通过但设计任务不自动创建」的根因)。
|
||
# 正确做法:追加纠错提示继续下一轮,让 PM 重新给出正确决策;轮次耗尽由下方「审核超时」兜底为 rejected。
|
||
logger.warning(f"pm_review_run 未识别 PM 输出 action={act.get('action', '?')}, turn={turn}: {raw[:200]}")
|
||
msgs.append({"role": "assistant", "content": raw})
|
||
msgs.append({"role": "user", "content":
|
||
"你的输出无法解析。请严格按格式输出单个 JSON:"
|
||
"review_approve / review_reject / review_complete / review_rollback 之一,不要输出其它内容。"})
|
||
continue
|
||
|
||
if not decision:
|
||
# 超时智能处理:PM 超时前可能已 create_tasks 派发后续子任务(parent_id 指向本任务),
|
||
# 说明 PM 实质已认可本任务(approve 行为)。此时默认 approved 而非 rejected——
|
||
# 否则 design 被无意义的「审核超时」驳回 → 重做写占位文件 → QC 死循环(2026-08 实测)。
|
||
child_recs = await sor.sqlExe(
|
||
"SELECT COUNT(*) as c FROM pipeline_tasks WHERE parent_id=${tid}$", {"tid": task_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
child_cnt = getattr(child_recs[0], 'c', 0) if child_recs else 0
|
||
if child_cnt > 0:
|
||
decision = {'status': 'approved', 'comment': '审核超时,但 PM 已派发后续任务(create_tasks),视为认可通过'}
|
||
else:
|
||
decision = {'status': 'rejected', 'comment': '审核超时'}
|
||
|
||
status = decision['status']
|
||
comment = decision.get('comment', '')
|
||
|
||
# 防御:非终结角色被 review_complete 收尾 → 修正为 approved(避免断链)。
|
||
# 只有无下一角色(deploy_prod)的 review_complete 才是合法终结。否则 requirement/design/develop/
|
||
# deploy_test/test 被误收尾,下一阶段任务不会自动创建,项目卡在「无任务运行」却非「已结束」的假终态。
|
||
if status == 'completed':
|
||
_nr = await _get_next_role(task_role, project_id)
|
||
if _nr:
|
||
logger.warning(f"pm_review_run 非终结角色 {task_role} 收到 review_complete,修正为 approved 继续任务链 → {_nr}")
|
||
status = 'approved'
|
||
decision['status'] = 'approved'
|
||
|
||
if status == 'approved':
|
||
from appPublic.uniqueID import getID
|
||
pm_did = getID()
|
||
await sor.C("pipeline_deliverables", {
|
||
"id": pm_did, "project_id": project_id, "task_id": task_id,
|
||
"deliverable_type": "pm_review", "title": f"PM审核:{title}",
|
||
"content": json.dumps(decision, ensure_ascii=False),
|
||
"file_path": os.path.join(workspace_dir, 'deliverables', 'pm', f"{task_id}_review.md"),
|
||
"quality_score": 100, "review_status": "approved", "created_by": agent_id or "pm",
|
||
})
|
||
await sor.sqlExe("UPDATE pipeline_deliverables SET review_status='approved', review_comment=${cm}$ WHERE task_id=${tid}$", {"cm": comment, "tid": task_id})
|
||
# 审核通过:该任务 review_reject 类的 pending 退回意见已由角色 agent 响应 → 解决,避免永久堆积。
|
||
# 只答结 review_reject(归属角色 agent 的),不碰 need_info(归属主 agent)等其它 pending。
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_agent_questions SET status='answered', answer=${a}$, "
|
||
"answer_source='role_agent', answered_by=${role}$ "
|
||
"WHERE task_id=${tid}$ AND status='pending' "
|
||
"AND (problem_type='review_reject' "
|
||
" OR ((problem_type IS NULL OR problem_type='') AND from_role IN ('pm','cockpit')))",
|
||
{"a": "角色已响应,PM审核通过", "role": task_role or "role_agent", "tid": task_id})
|
||
from .task_capability import approve_task
|
||
await approve_task(task_id, project_id, who="agent.pm", agent_id=agent_id, comment=comment)
|
||
# 审核通过后统一 git 提交(项目过程仓库 + 应用/模块仓库),不在每次生成时提交以减少并发锁
|
||
_git_after = await _commit_repos_after_approve(workspace_dir, title)
|
||
logger.info(f"commit-after-approve: task={task_id} {_git_after.get('message', '')}")
|
||
next_role = await _get_next_role(task_role, project_id)
|
||
if next_role:
|
||
# 迭代边界:仅当任务归属的迭代已显式结束(completed/cancelled)才终止链。
|
||
# 若该迭代仍 planning/in_progress/active,链继续在该迭代内创建下一角色任务——
|
||
# 新建其它迭代(如二期)不应阻断一期仍在跑的链(否则「需求验收通过但设计任务不自动创建」)。
|
||
task_iter = _task_iteration_name(task)
|
||
if task_iter:
|
||
_it = await sor.sqlExe(
|
||
"SELECT status FROM sd_iterations WHERE project_id=${pid}$ AND iteration_name=${name}$",
|
||
{"pid": project_id, "name": task_iter})
|
||
_it_status = getattr(_it[0], 'status', '') if _it else ''
|
||
if _it_status in ('completed', 'cancelled'):
|
||
logger.info(f"迭代边界:任务 {task_id} 归属迭代「{task_iter}」已 {_it_status},链终止")
|
||
return {"status": "completed", "task_id": task_id, "comment": comment or "迭代已结束,链终止"}
|
||
# 部署以应用为单位:模块级 develop(PM 按模块派发,params 含 pm_assigned)approved 后
|
||
# 不创建 deploy_test——模块不独立部署,deploy_test 是应用级,由应用脚手架 develop 触发。
|
||
# 否则每个模块都会生成一个 deploy_test,导致「部署以模块为单位」(多端口问题的部署侧根源)。
|
||
_tp = {}
|
||
try:
|
||
_tp = json.loads(getattr(task, 'params', '{}') or '{}')
|
||
except Exception:
|
||
_tp = {}
|
||
# 模块级 develop = PM 用 create_tasks 直接派发(previous_role 为空);应用级 develop =
|
||
# 系统派生/回退重做(previous_role=agent.design)。用 previous_role 区分而非 pm_assigned——
|
||
# pm_assigned 会被 design 任务污染(design 也是 PM create_tasks 派发、带 pm_assigned=True,
|
||
# 派生的应用级 develop 曾因此误判为「模块级」跳过 deploy_test)。
|
||
if task_role == 'agent.develop' and not _tp.get('previous_role'):
|
||
logger.info(f"模块级 develop approved,跳过 deploy_test(部署以应用为单位): {task_id}")
|
||
return {"status": "approved", "task_id": task_id, "comment": comment,
|
||
"next_role": "", "skip_next": "module_not_deployed_independently"}
|
||
next_tid, next_title = await _create_next_task(sor, project_id, task, next_role, comment)
|
||
return {"status": "approved", "task_id": task_id, "next_task_id": next_tid, "next_role": next_role, "comment": comment}
|
||
else:
|
||
return {"status": "completed", "task_id": task_id, "comment": comment or "项目完成"}
|
||
|
||
elif status == 'rejected':
|
||
from .communication import raise_problem
|
||
rejection_q = decision.get("questions") or comment or "交付件不满足要求"
|
||
# 审核退回:首处理方=被退角色(该角色任意 agent 重新认领响应)。
|
||
# suspend_task=False —— 任务回 submitted(重新认领)而非 waiting(挂起等回答)。
|
||
await raise_problem("review_reject", rejection_q, "agent.pm",
|
||
tenant_id=project_id, task_id=task_id,
|
||
first_handler_role=task_role,
|
||
context={"pm_comment": comment, "deliverable_type": deliverable_type},
|
||
suspend_task=False)
|
||
from .task_capability import reject_task
|
||
await reject_task(task_id, project_id, who="agent.pm", agent_id=agent_id, comment=comment)
|
||
return {"status": "rejected", "task_id": task_id, "comment": comment, "question": rejection_q}
|
||
|
||
elif status == 'rollback':
|
||
rollback_role = _normalize_role((decision.get('rollback_role') or '').strip())
|
||
if not rollback_role:
|
||
# 未指定回退目标 → 视为驳回重审
|
||
from .task_capability import reject_task
|
||
await reject_task(task_id, project_id, who="agent.pm", agent_id=agent_id,
|
||
comment=(comment or '回退目标未指定'))
|
||
return {"status": "rejected", "task_id": task_id,
|
||
"comment": "回退目标未指定,已驳回重审"}
|
||
# 记 PM 审核交付件(回退决策)
|
||
from appPublic.uniqueID import getID as _getID
|
||
pm_did = _getID()
|
||
await sor.C("pipeline_deliverables", {
|
||
"id": pm_did, "project_id": project_id, "task_id": task_id,
|
||
"deliverable_type": "pm_review", "title": f"PM审核(回退):{title}",
|
||
"content": json.dumps(decision, ensure_ascii=False),
|
||
"file_path": os.path.join(workspace_dir, 'deliverables', 'pm', f"{task_id}_rollback.md"),
|
||
"quality_score": 0, "review_status": "rejected", "created_by": agent_id or "pm",
|
||
})
|
||
# 回退:作废回退点及之后的任务,创建回退目标的新任务
|
||
return await _rollback_task_chain(sor, project_id, task_id, rollback_role, comment)
|
||
|
||
else:
|
||
# review_complete(项目完成/无下一阶段):同步把交付件标记 approved,
|
||
# 避免任务 state=completed 但交付件 review_status 仍 pending 的状态不一致。
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_deliverables SET review_status='approved', review_comment=${cm}$ "
|
||
"WHERE task_id=${tid}$ AND review_status='pending'",
|
||
{"cm": comment, "tid": task_id})
|
||
from .task_capability import complete_task
|
||
await complete_task(task_id, project_id, who="agent.pm", agent_id=agent_id)
|
||
return {"status": "completed", "task_id": task_id, "comment": comment or "项目完成"}
|
||
|
||
|
||
async def qc_review_run(project_id, agent_id=None, model_name=None):
|
||
"""QC 质量门禁:认领 qc_review 状态任务,做合规+质量检查,通过转 review,不合规退回重做。"""
|
||
from .task_capability import S_QC_REVIEW
|
||
db = _get_db()
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
model_name, org_id = await _resolve_llm_context(sor, project_id, 'qc', model_name)
|
||
task = await _claim_task(sor, project_id, '', state=S_QC_REVIEW, match_role=False, set_state='qc_review')
|
||
if not task:
|
||
return {"status": "idle", "message": "没有待 QC 检查的任务"}
|
||
|
||
task_id = task.id
|
||
title = getattr(task, "title", "") or ""
|
||
task_role = _normalize_role(getattr(task, "role", "") or "")
|
||
# 机构 llm 检测:机构没配 llm → 冒泡问题暂停
|
||
llm_missing, _names = await _check_org_llm(sor, org_id)
|
||
if llm_missing:
|
||
from .communication import raise_problem
|
||
qid = await raise_problem(
|
||
"need_info",
|
||
f"机构(org_id={org_id})未配置 LLM 模型,QC 检查无法调用模型。"
|
||
f"请在「模型管理」为该机构配置可用模型后重试。",
|
||
"agent.qc", tenant_id=project_id, task_id=task_id,
|
||
first_handler_role="agent.main_agent", suspend_task=True,
|
||
)
|
||
logger.warning(f"qc_review_run: org llm missing, task={task_id} org={org_id}")
|
||
return {"status": "need_info", "task_id": task_id, "question_id": qid}
|
||
workspace_dir = await _get_workspace_dir(sor, project_id)
|
||
|
||
try:
|
||
await sor.sqlExe("COMMIT", {})
|
||
except Exception:
|
||
pass
|
||
|
||
deliverable_content, deliverable_type = await _get_deliverable_content(sor, task_id)
|
||
if not deliverable_content:
|
||
from .task_capability import qc_reject_task
|
||
await qc_reject_task(task_id, project_id, who="agent.qc", agent_id=agent_id, comment="没有交付件")
|
||
return {"status": "rejected", "task_id": task_id, "reason": "没有交付件"}
|
||
|
||
content_preview = deliverable_content[:6000]
|
||
role_skills = await _build_role_skills_block(sor, project_id, _normalize_role('qc'), org_id)
|
||
|
||
qc_system = QC_SYSTEM_PROMPT.replace('__TITLE__', title)\
|
||
.replace('__ROLE__', task_role)\
|
||
.replace('__WORKSPACE__', workspace_dir)\
|
||
.replace('__ROLE_SKILLS__', role_skills)
|
||
|
||
msgs = [{"role": "system", "content": qc_system}]
|
||
msgs.append({"role": "user", "content": f"请检查以下交付件(类型:{deliverable_type}):\n\n{content_preview}"})
|
||
|
||
from .llm_bridge import llm_call_msgs
|
||
|
||
decision = None
|
||
for turn in range(4):
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_tasks SET updated_at=NOW() WHERE id=${tid}$ AND state='qc_review'",
|
||
{"tid": task_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if turn >= 2:
|
||
msgs.append({"role": "user", "content":
|
||
"已检查足够信息。现在必须立即输出 review_approve 或 review_reject,禁止再调用其它工具。"})
|
||
try:
|
||
raw = await llm_call_msgs(msgs, model=model_name, temperature=0.2, org_id=org_id)
|
||
except Exception as e:
|
||
err_msg = f"{type(e).__name__}: {str(e)[:400]}"
|
||
from .task_capability import mark_failed
|
||
await mark_failed(task_id, project_id, who="agent.qc", agent_id=agent_id, error=err_msg)
|
||
return {"status": "failed", "task_id": task_id, "error": str(e)[:200]}
|
||
|
||
act = _parse_agent_action(raw)
|
||
|
||
if act.get('action') == 'review_approve':
|
||
decision = {'status': 'approved', 'comment': act.get('comment', '')}
|
||
break
|
||
elif act.get('action') == 'review_reject':
|
||
decision = {'status': 'rejected', 'comment': act.get('comment', ''), 'questions': act.get('questions', '')}
|
||
break
|
||
elif act.get('action') == 'tool_call':
|
||
tool = act.get('tool', '')
|
||
params = act.get('params', {})
|
||
if tool == 'load_skill':
|
||
result = await _load_skill_by_name(sor, project_id, 'qc', org_id, params.get('name', ''), params.get('file_path') or None)
|
||
else:
|
||
result = await _exec_agent_tool(tool, params, workspace_dir)
|
||
msgs.append({"role": "assistant", "content": raw})
|
||
msgs.append({"role": "user", "content": f"工具 {tool} 结果:\n{result}"})
|
||
else:
|
||
# 无明确决策 → 默认通过(避免 QC 卡死阻塞流程)
|
||
decision = {'status': 'approved', 'comment': raw[:200]}
|
||
break
|
||
|
||
if not decision:
|
||
decision = {'status': 'approved', 'comment': 'QC 检查超时,默认通过'}
|
||
|
||
if decision['status'] == 'approved':
|
||
from .task_capability import qc_approve_task
|
||
await qc_approve_task(task_id, project_id, who="agent.qc", agent_id=agent_id, comment=decision.get('comment', ''))
|
||
logger.info(f"qc_review_run approve: task={task_id} -> review")
|
||
return {"status": "approved", "task_id": task_id, "comment": decision.get('comment', '')}
|
||
else:
|
||
comment = decision.get('comment', '')
|
||
from .task_capability import qc_reject_task
|
||
ok, msg = await qc_reject_task(task_id, project_id, who="agent.qc", agent_id=agent_id, comment=comment)
|
||
if not ok and "failed" in msg:
|
||
# 重复退回达上限 → 已转 failed,不再建 qc_reject 问题(failed poller 会报 fault_report 给人工)
|
||
logger.info(f"qc_review_run reject->failed: task={task_id} {msg}")
|
||
return {"status": "failed", "task_id": task_id, "comment": msg}
|
||
from .communication import raise_problem
|
||
rejection_q = decision.get("questions") or comment or "交付件不合规"
|
||
await raise_problem("qc_reject", rejection_q, "agent.qc",
|
||
tenant_id=project_id, task_id=task_id,
|
||
first_handler_role=task_role,
|
||
context={"qc_comment": comment, "deliverable_type": deliverable_type},
|
||
suspend_task=False)
|
||
logger.info(f"qc_review_run reject: task={task_id} -> submitted")
|
||
return {"status": "rejected", "task_id": task_id, "comment": comment, "question": rejection_q}
|
||
|
||
|
||
# ── 失败任务处理(第二层:PM/cockpit 判定重跑,最多3次,超限报故障给用户)──
|
||
|
||
_RETRY_HINTS = (
|
||
"timeout", "timed out", "connection", "connect", "network", "unreachable",
|
||
"rate limit", "429", "500", "502", "503", "504", "reset", "broken pipe",
|
||
"eof", "temporar", "busy", "overloaded",
|
||
)
|
||
|
||
|
||
def _classify_failure(last_error: str) -> str:
|
||
"""按失败原因分类:retry(瞬时,可重跑) / fault(永久,报故障)。
|
||
|
||
空错误通常是 asyncio.TimeoutError(str 为空),视为瞬时可重跑。
|
||
"""
|
||
err = (last_error or "").strip().lower()
|
||
if not err:
|
||
return "retry"
|
||
for h in _RETRY_HINTS:
|
||
if h in err:
|
||
return "retry"
|
||
return "fault"
|
||
|
||
|
||
async def handle_failed_task(task_id: str, project_id: str) -> dict:
|
||
"""处理一个失败任务:判定重跑或报故障。由 failed poller 周期调用。
|
||
|
||
- retry_count < 最大重复数(task_max_retry,默认3) 且失败原因判定为瞬时 → 重跑(retry_count+1, 回 submitted)
|
||
- retry_count >= 最大重复数 或判定为永久错误 → 暂停任务链(pause_project) + 报故障(建问题通知用户,任务置 waiting)
|
||
"""
|
||
db = _get_db()
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, title, role, retry_count, last_error FROM pipeline_tasks "
|
||
"WHERE id=${tid}$ AND state='failed'",
|
||
{"tid": task_id})
|
||
if not recs:
|
||
# 释放 SELECT 的元数据锁,防止连接回池后阻塞 DDL
|
||
await sor.sqlExe("COMMIT", {})
|
||
return {"status": "idle", "task_id": task_id}
|
||
t = recs[0]
|
||
title = getattr(t, "title", "") or ""
|
||
role = getattr(t, "role", "") or ""
|
||
try:
|
||
retry_count = int(getattr(t, "retry_count", 0) or 0)
|
||
except (TypeError, ValueError):
|
||
retry_count = 0
|
||
last_error = getattr(t, "last_error", "") or ""
|
||
|
||
# 最大重复数:appbase params 表 task_max_retry(默认 3),超限暂停任务链 + 抛故障给人工
|
||
from .workspace import get_max_task_retry
|
||
max_retry = await get_max_task_retry(sor)
|
||
|
||
# 释放 SELECT 的元数据锁,避免决策/建问题期间长时间持有 MDL
|
||
await sor.sqlExe("COMMIT", {})
|
||
|
||
# 已重试 max_retry 次仍未成功 → 报故障
|
||
if retry_count >= max_retry:
|
||
decision = "fault"
|
||
else:
|
||
decision = _classify_failure(last_error)
|
||
|
||
if decision == "retry":
|
||
from .task_capability import retry_task
|
||
ok, _ = await retry_task(task_id, project_id, who="agent.pm")
|
||
logger.info("failed task retry: task=%s role=%s attempt=%d ok=%s", task_id, role, retry_count + 1, ok)
|
||
return {"status": "retry", "task_id": task_id, "attempt": retry_count + 1}
|
||
|
||
# 报故障:① 暂停任务链(pause_project,PM 不再推进)② 建问题通知用户,任务置 waiting 等人工介入
|
||
try:
|
||
from .project_capability import pause_project
|
||
pok, pmsg = await pause_project(project_id, who="agent.pm")
|
||
logger.info("failed task pause project: task=%s pause=%s msg=%s", task_id, pok, pmsg)
|
||
except Exception as e:
|
||
logger.warning("failed task pause_project error: %s", e)
|
||
from .communication import raise_problem
|
||
reporter = "agent.main_agent" if role == "agent.pm" else "agent.pm"
|
||
qid = await raise_problem(
|
||
"fault_report",
|
||
f"任务「{title}」重复 {retry_count} 次已达上限({max_retry}),任务链已暂停,请人工介入处理。失败原因:{last_error or '未知'}",
|
||
reporter, tenant_id=project_id, task_id=task_id,
|
||
first_handler_role="agent.main_agent",
|
||
context={"fault": True, "last_error": last_error, "retry_count": retry_count,
|
||
"max_retry": max_retry, "chain_paused": True})
|
||
logger.info("failed task fault: task=%s role=%s qid=%s", task_id, role, qid)
|
||
return {"status": "fault", "task_id": task_id, "question_id": qid, "chain_paused": True}
|
||
|
||
|
||
async def role_agent_loop(project_id, role, agent_id=None, model_name=None, max_iterations=10):
|
||
results = []
|
||
for _ in range(max_iterations):
|
||
r = await role_agent_run(project_id, role, agent_id, model_name)
|
||
results.append(r)
|
||
if r["status"] in ("idle", "need_info", "failed"):
|
||
break
|
||
await asyncio.sleep(1)
|
||
return results
|
||
|
||
|
||
async def pm_review_loop(project_id, agent_id=None, model_name=None, max_iterations=20):
|
||
results = []
|
||
for _ in range(max_iterations):
|
||
r = await pm_review_run(project_id, agent_id, model_name)
|
||
results.append(r)
|
||
if r["status"] in ("idle", "failed"):
|
||
break
|
||
await asyncio.sleep(1)
|
||
return results
|
||
|
||
|
||
# 向后兼容
|
||
async def run_agent_loop(project_id, role_name, model_name=None):
|
||
role = _normalize_role(role_name or "develop")
|
||
if role == 'pm':
|
||
return await pm_review_run(project_id, model_name=model_name)
|
||
return await role_agent_run(project_id, role_name or "develop", model_name=model_name)
|
||
|
||
|
||
async def agent_loop(project_id, role_name, model_name=None, max_iterations=10):
|
||
role = _normalize_role(role_name or "develop")
|
||
if role == 'pm':
|
||
return await pm_review_loop(project_id, model_name=model_name, max_iterations=max_iterations)
|
||
return await role_agent_loop(project_id, role_name or "develop", model_name=model_name, max_iterations=max_iterations)
|