ymq 32cd8c2e8a fix: 模块技能全文加载剥离 frontmatter,对齐分层导入规范
模块技能作为「项目模块」scope 注入时,目录层只放名字+描述,
全文层(load_skill)剥离 frontmatter 只返回正文,与 skill_loader.
to_prompt_block 行为一致。
2026-08-23 21:24:47 +08:00

2202 lines
114 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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_modelpipelines.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 + pushdevelop 用它提交模块仓库本地提交作为产出证据;无远程只 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_rollbackrollback_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/approvedapproved 视为已验收、可驱动下游)。
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):
"""PMclone 所有项目关联仓库到 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=Truedesign 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=Trueapprove 后被
# 「模块级 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=rolefrontmatter 声明 `capability: feature_capability`(角色需要哪些能力),
产线状态机规范scope=pipelinefrontmatter 声明 `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_callsOpenAI 原生格式)
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默认 = 本次审核的里程碑任务),记录父子关系供任务树分层;
- keyLLM 给子任务起的短标识,供同批任务间 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_fixdevelop 必须走 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_onkey 或 任务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_taskCAS + 审计),正在执行中的 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 "迭代已结束,链终止"}
# 部署以应用为单位:模块级 developPM 按模块派发params 含 pm_assignedapproved 后
# 不创建 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.TimeoutErrorstr 为空),视为瞬时可重跑。
"""
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_projectPM 不再推进)② 建问题通知用户,任务置 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)