"""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, build_space_path, GENERAL_SPACE
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]}
def _inject_org_ssh_key(command: str, org_id: str) -> str:
"""给 ssh/scp 命令注入机构独立密钥(每机构一把,防单点泄露)。
部署工程师(deploy_test/deploy_prod)SSH 到目标机时,默认会走 ~/.ssh/id_rsa
(个人密钥),绕过机构密钥机制。这里在 ssh/scp 命令前注入
`-i ~/.ssh/org_keys/{org_id}/id_ed25519`,让部署用机构密钥连目标机。
规则:
- 命令含 ssh/scp 关键字才处理(避免影响 git 等其他命令)
- 已显式指定 -i 的不重复注入
- 机构密钥文件不存在则不注入(回退默认密钥,保持原行为)
"""
if not command or not org_id:
return command
# 仅对 ssh/scp 命令注入(宽松匹配:命令以 ssh/scp 开头,或含 ' ssh ' 等)
stripped = command.lstrip()
if not (stripped.startswith("ssh ") or stripped.startswith("ssh\t") or
stripped.startswith("scp ") or stripped.startswith("scp\t")):
return command
if re.search(r'(^|\s)-i\s+\S+', command):
return command # 已显式指定密钥,不覆盖
from .work_env import ensure_org_key
try:
info = ensure_org_key(org_id)
key_path = info.get("key_path", "")
except Exception:
return command
if not key_path or not os.path.exists(key_path):
return command
# 在 ssh/scp 动词后注入 -i 密钥(保留原有前导空白)
return command[:len(command) - len(command.lstrip())] + \
_inject_prefix(stripped, f"-i {key_path} ")
def _inject_prefix(cmd: str, prefix: str) -> str:
"""在 ssh/scp 命令动词后插入 prefix(密钥参数)。"""
parts = cmd.split(None, 1)
if not parts:
return cmd
verb = parts[0] # ssh 或 scp
rest = parts[1] if len(parts) > 1 else ""
return f"{verb} {prefix}{rest}"
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仓库到机构工作空间(应用→apps/、模块→modules/)","params":{"repo_url":"仓库URL","repo_name":"仓库目录名(可选,默认从URL推断,_app后缀→apps/)","branch":"分支(可选,默认main)"}},
{"name":"git_status","description":"查看git仓库状态","params":{"repo_dir":"仓库子目录(可选,默认 apps/modules 下第一个)"}},
{"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\":\"modules/{模块}/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 产出文件到 projects/{项目}/、apps/、modules/ 下(路径基准是上方「工作空间」= 机构工作空间)
3. 用 run_shell 验证(编译/测试)
4. 用 deliver 提交最终交付件(git 提交由 PM 审核通过后系统统一执行,无需你提交)
## 输出格式(每次只输出一个JSON对象)
调工具:
{"action":"tool_call","tool":"工具名","params":{}}
提交交付件:
{"action":"deliver","deliverable_type":"code_files","summary":"概述","result":"文档内容","files":[{"path":"modules/{模块}/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?, dep_policy?, parent_id?};key 供同批任务间 depends_on 引用,depends_on 是前序 key 或任务ID 数组,空=并行/非空=串行;dep_policy 是可选的启动策略:{"mode":"all"}(默认,前置全部结束才启动)/{"mode":"any"}(任一前置结束即启动)/{"mode":"at_least","n":k}(至少 k 个前置结束即启动))
- list_tasks(role, state) — 列出项目现有任务(派发前先查,避免重复)
- cancel_task(task_id) — 取消任务(重做/作废前必须先取消旧任务,避免两个相同任务并存)
- update_task_deps(task_id, add_depends_on, dep_policy?) — 给已存在任务补前置依赖(系统发现编排缺口时通知你,核实后用此工具修正;add_depends_on 是要追加的任务ID数组,dep_policy 可选、同上)
## 审核流程
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),回退点之后的关联任务将全部作废。
- ⚠️ **外部输入缺失禁止回退上游**:部署主机/SSH账号/密码/部署路径/域名这类信息是**外部输入**,requirement/design/develop 三个阶段的 agent 根本产不出来(它们只能诚实标注「待明确」并列入 env/*.json 的 pending 清单,这是正确履职、不是缺陷)。这类阻塞由系统在部署任务的进入条件处自动冒泡人工任务给 owner 补齐,**你不要因此 review_rollback 到 requirement/design** —— 那会作废下游全部成果(含 owner 已人工确认的设计),而重做一遍后环境信息依然不会有,形成死循环(2026-08-25 hrs7 实际发生过)。正确处置:如实记录阻塞原因、raise_problem 冒泡,任务等 owner 补齐后自动继续。
- 回退前先自问:**这个缺陷是上游 agent「能做对却做错了」,还是「本来就产不出」?** 前者才回退;后者一律冒泡等人补。"""
QC_SYSTEM_PROMPT = """你是质量控制工程师(QC)。对交付件做合规检查和质量检查,不合规直接退回重做。
## 检查维度
1. 项目规范检查:产出是否按 SDLC 仓库标准路径/命名/格式产出——先用 load_skill 加载 project-directory-spec 拿到权威目录结构与路径,再据此检查(交付文件在机构工作空间的 projects/{项目}/docs/、apps/、modules/ 下,不要凭记忆找旧 repos/ 目录)
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, task_id='', dep_policy=None):
"""依赖门控:按启动策略求值(默认 all=全部终态;见 _eval_deps_policy)。
返回 (satisfied: bool, reason: str)。reason 非空说明未满足的原因,供逃逸阀冒泡用。
⚠️ 多依赖正确性(2026-08-25 修复,原实现有 4 个漏洞会导致「依赖未完成就开始」):
D1 原实现查「不在终态的依赖」,依赖 ID 不存在时查出 0 行 → 误判为已满足而放行。
现改为:先查回实际存在的依赖 id 集合,**逐个比对**——缺失的依赖视为未满足。
D3 PM 可能编造 20 位 ID(_pm_create_tasks 对 len>=20 的引用不校验存在性),
配合 D1 会让门控完全失效。现在缺失即阻塞,编造 ID 不再能绕过。
D4 依赖处于 cancelled/failed 等永不可达终态时,原实现让依赖方永久 submitted(死锁
且无人知晓)。现在识别为 dead 依赖并在 reason 里标注,由调用方冒泡人工处理。
自依赖:depends_on 含自身 id 会永久阻塞,识别为 dead 依赖。
"""
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).strip() for x in d if x and str(x).strip()]
except (json.JSONDecodeError, TypeError, ValueError):
# 解析失败不能当「无依赖」放行——那是把编排约束静默丢掉
return False, f"depends_on 解析失败(原始值:{str(depends_on_raw)[:60]}),保守阻塞"
deps = list(dict.fromkeys(deps)) # 去重,保持顺序
if not deps:
return True, ''
if task_id and task_id in deps:
return False, f"依赖自身({task_id[:8]}),永久阻塞,需人工修正 depends_on"
ids = ",".join(["'" + x.replace("'", "''") + "'" for x in deps])
recs = await sor.sqlExe(
"SELECT id, state, title FROM pipeline_tasks WHERE id IN (" + ids + ")", {})
await sor.sqlExe("COMMIT", {})
state_of = {}
title_of = {}
for r in (recs or []):
_id = getattr(r, 'id', '')
state_of[_id] = getattr(r, 'state', '')
title_of[_id] = getattr(r, 'title', '') or ''
satisfied, reason, _kind = _eval_deps_policy(deps, state_of, dep_policy, title_of)
return satisfied, reason
# ── 依赖启动策略(2026-08-27 新增)──────────────────────────────────────────
# 每个任务可带 params.dep_policy 声明前置任务的启动策略(通用数据模型,
# 代码不编码任何「某角色必须依赖某角色」的业务规则,依赖编排决策权归 PM/LLM):
# {"mode": "all"} 前置全部结束才启动(默认,等同历史行为)
# {"mode": "any"} 任一前置结束即启动
# {"mode": "at_least", "n": k} 至少 k 个前置结束即启动
# 不变量(任何策略下都成立,代码守门):
# · 依赖任务不存在 → 阻塞(可能是编造 ID,D1/D3)
# · 依赖处于 cancelled/failed → 永不可达,阻塞并冒泡(D4)
# · 自依赖 / depends_on 解析失败 → 阻塞
DEP_DONE = ('completed', 'approved')
DEP_DEAD = ('cancelled', 'failed')
def _parse_dep_policy(policy):
"""解析 params.dep_policy → (mode, n)。缺省/非法 → ('all', 0)(保守兜底)。"""
if isinstance(policy, str):
try:
policy = json.loads(policy)
except (json.JSONDecodeError, TypeError, ValueError):
policy = None
if not isinstance(policy, dict):
return 'all', 0
mode = str(policy.get('mode') or 'all').strip().lower()
if mode not in ('all', 'any', 'at_least'):
return 'all', 0
n = 0
if mode == 'at_least':
try:
n = int(policy.get('n') or 0)
except (TypeError, ValueError):
n = 0
if n < 1:
return 'all', 0 # 非法 n → 保守退回 all
return mode, n
def _eval_deps_policy(deps, state_of, policy=None, title_of=None):
"""纯函数:对依赖列表按启动策略求值(不访问 DB,两处调用方共用,语义单一来源)。
Args:
deps: 依赖任务 id 列表(已去重)
state_of: {dep_id: state} 状态映射;id 不在映射中 = 任务不存在
policy: params.dep_policy(dict 或 JSON 字符串,可为空=默认 all)
title_of: 可选 {dep_id: title},用于生成可读 reason
Returns:
(satisfied: bool, reason: str, kind: str)
kind ∈ ('', 'dead', 'wait'):
'' 已满足(或无依赖)
'dead' 永不可达(不存在/已作废/失败)→ 调用方应冒泡人工
'wait' 仅等待中 → 保持等待,不冒泡
"""
title_of = title_of or {}
if not deps:
return True, '', ''
mode, n = _parse_dep_policy(policy)
# at_least 越界保护:n > 依赖总数时该策略永不可达(死等),
# 保守钳到依赖总数——宁可提前放行也不能制造永久阻塞。
if mode == 'at_least' and n > len(deps):
n = len(deps)
missing, dead, pending, done = [], [], [], []
for d in deps:
st = state_of.get(d)
if st is None:
missing.append(d) # D1/D3:依赖不存在 → 阻塞(不再放行)
elif st in DEP_DONE:
done.append(d)
elif st in DEP_DEAD:
dead.append(f"{title_of.get(d) or d[:8]}({st})")
else:
pending.append(f"{title_of.get(d) or d[:8]}({st})")
if missing or dead:
parts = []
if missing:
parts.append("依赖任务不存在:" + "、".join(x[:8] for x in missing))
if dead:
parts.append("依赖已作废/失败,永不可达:" + "、".join(dead))
return False, ";".join(parts) + "(需人工修正依赖或重建被依赖任务)", 'dead'
if mode == 'any':
if done:
return True, '', ''
return False, "等待任一前置完成(启动策略=any):" + "、".join(pending), 'wait'
if mode == 'at_least':
if len(done) >= n:
return True, '', ''
return False, (f"等待至少 {n} 个前置完成(启动策略=at_least {n},"
f"已完成 {len(done)}):" + "、".join(pending)), 'wait'
# mode == 'all'(默认)
if pending:
return False, "等待依赖完成:" + "、".join(pending), 'wait'
return True, '', ''
PLACEHOLDER_MARKS = ('待明确', '待确认', '待补充', 'TODO', 'todo', 'xxx', 'XXX', '占位', '<', '未知')
async def _check_deploy_env_ready(sor, project_id, role):
"""部署类任务的进入条件:env/<环境>.json 的真实环境信息是否齐备。
⚠️ 设计定调(用户 2026-08-26 明确纠正,很重要):
**环境信息只影响部署任务,不该阻断需求和开发。**
理由:主机/SSH账号/密码/部署路径是**外部输入**,requirement agent 根本产不出来。
若把「env 必须填全」做成 requirement 的结束条件,agent 就会卡在它无法解决的事情上
无限重试 —— 正是 2026-08-25 hrs7 的死循环成因(deploy_test 因 env 占位符失败 → PM
判「根因在 requirement」→ 回退整链并作废 owner 已确认的设计 → 重做需求后 env 依然
是占位符 → 再次失败)。把门禁前移会把这个死循环制度化。
正确处置:
· requirement/design/develop 一律不检查 env 完整性,占位符可正常通过;
requirement 只需诚实标注(hrs7 的 requirement agent 做对了:填了能确定的
port/dbname,未知项标「待明确」并列入 pending 清单)。
· 只有 deploy_test / deploy_prod 进入前检查;不齐备 → 不进 running,
冒泡人工任务给能提供信息的人(owner/运维),任务置 waiting。
· PM **不得**因外部输入缺失而回退上游阶段 —— 那不是任何 agent 阶段的产出缺陷。
返回 (ready: bool, missing: [字段名], env_file: str)
"""
if role not in ('agent.deploy_test', 'agent.deploy_prod'):
return True, [], ''
env_name = 'test' if role == 'agent.deploy_test' else 'prod'
pdir = await _get_project_dir(sor, project_id)
if not pdir:
return True, [], '' # 取不到项目目录不阻断(宁放过不误杀)
import os as _os
env_file = _os.path.join(pdir, 'env', f'{env_name}.json')
if not _os.path.isfile(env_file):
return False, [f'env/{env_name}.json 文件不存在'], env_file
try:
with open(env_file, encoding='utf-8') as f:
cfg = json.load(f)
except Exception as e:
return False, [f'env/{env_name}.json 解析失败:{e}'], env_file
missing = []
def _bad(v):
if v is None:
return False # null 合法(如 key_file=null 表示用密码登录)
if isinstance(v, str):
s = v.strip()
if not s:
return True
return any(m in s for m in PLACEHOLDER_MARKS)
return False
# 部署真正必需的字段(其余如 domain/status 缺失不阻断部署)
REQUIRED = (('ssh', 'host'), ('ssh', 'user'), ('deploy', 'path'),
('db', 'host'), ('db', 'user'), ('db', 'dbname'))
for path in REQUIRED:
cur = cfg
for k in path:
cur = cur.get(k) if isinstance(cur, dict) else None
if cur is None or _bad(cur):
missing.append('.'.join(path))
# SSH 凭据:password 与 key_file 二者其一即可(key_file=null 表示用密码登录)
_ssh = cfg.get('ssh') or {}
_pwd_ok = not _bad(_ssh.get('password')) and bool(str(_ssh.get('password') or '').strip())
_key = _ssh.get('key_file')
_key_ok = bool(str(_key).strip()) and not _bad(_key) if isinstance(_key, str) else False
if not (_pwd_ok or _key_ok):
missing.append('ssh.password 或 ssh.key_file(二者其一)')
# requirement 自己列的 pending 清单也纳入(它最清楚哪些没定)
for p in (cfg.get('pending') or []):
if isinstance(p, str) and p.strip() and p.strip() not in missing:
_k = p.strip()
if any(_k.startswith(x) for x in ('ssh.', 'deploy.', 'db.')):
if _k not in missing:
missing.append(_k)
return (not missing), missing, env_file
async def _bubble_deploy_env_missing(sor, project_id, task_id, task_title, missing, env_file):
"""部署环境信息缺失 → 走统一问题通道冒泡给项目 owner(去重)。
统一冒泡机制(2026-08-27 定调:缺外部信息的问题一律走问题通道,不再按类型各写补丁):
① raise_problem 建问题记录、任务置 waiting(清 claimed_by);
first_handler_agentid=owner → owner 的「我的待办」里出现一条待答问题
(list_my_human_todos 已 union pipeline_agent_questions,按 agentid 命中)。
② owner 在待办里回答(待办详情已支持 question 渲染 + 回答框)→
question_answer → resolve_problem → 任务自动 waiting→submitted。
③ poller 下轮重新派发 → 本函数调用方重跑 env 检查:补齐 → 认领,
_build_qna_section 把 owner 的回答注入 agent prompt(信息真正交给 LLM);
仍未补齐 → 旧问题已 answered、去重不拦 → 冒新一条。诚实循环,不静默死等。
"""
from .communication import raise_problem
# 去重只看系统冒泡的(from_role='system.orchestrator')——need_info 是通用类型,
# 角色 agent 缺信息也会用 need_info 提问,不加此条件会误拦。
exists = await sor.sqlExe(
"SELECT id FROM pipeline_agent_questions WHERE tenant_id=${pid}$ AND task_id=${tid}$ "
"AND problem_type='need_info' AND from_role='system.orchestrator' AND status='pending' LIMIT 1",
{"pid": project_id, "tid": task_id})
await sor.sqlExe("COMMIT", {})
if exists:
return
_own = await sor.sqlExe("SELECT created_by FROM sd_projects WHERE id=${pid}$", {"pid": project_id})
await sor.sqlExe("COMMIT", {})
owner_id = (getattr(_own[0], 'created_by', '') if _own else '') or 'user-01'
question = (
f"部署任务「{task_title or task_id[:8]}」无法开始:真实部署环境信息尚未提供。"
"这属于外部输入(agent 无法自行产出),因此不回退需求/设计/开发阶段,只等这里补齐。\n\n"
f"配置文件:{env_file}\n\n"
"缺失字段:\n" + "\n".join(f" · {m}" for m in missing) +
"\n\n请补齐以上字段后提交回答(回答内容会随任务上下文交给执行 agent,"
"任务将自动恢复执行、重新校验环境信息)。"
)
await raise_problem(
"need_info", question, "system.orchestrator",
tenant_id=project_id, task_id=task_id,
first_handler_role="owner.superuser", first_handler_agentid=owner_id,
context={"env_file": env_file, "missing": missing},
suspend_task=True,
)
logger.warning(f"部署环境信息缺失冒泡(问题通道): task={task_id} missing={missing} owner={owner_id}")
async def _bubble_blocked_dependency(sor, project_id, task_id, task_title, reason):
"""逃逸阀:任务依赖「永不可达」时冒泡人工任务,避免静默死锁。
设计原则(本次编排整改的通用不变量):
**任何自动门禁都必须配一个「卡住 → 冒泡给谁」的出口。**
没有出口的门禁 = 死锁制造机(对照:failed_poller 自动 pause 项目却无人通知,
导致 hrs7 的 payroll/recruitment 卡了近 1 小时才被偶然发现)。
去重:poller 每 10 秒扫一轮,同一任务不能每轮冒一个待办 —— 按 task_id 查已存在的
pending 依赖阻塞任务,有则跳过。
"""
from .human_task_capability import create_human_task
exists = await sor.sqlExe(
"SELECT id FROM pipeline_human_tasks WHERE project_id=${pid}$ AND task_id=${tid}$ "
"AND task_type='dependency_blocked' AND status='pending' LIMIT 1",
{"pid": project_id, "tid": task_id})
await sor.sqlExe("COMMIT", {})
if exists:
return
_own = await sor.sqlExe("SELECT created_by FROM sd_projects WHERE id=${pid}$", {"pid": project_id})
await sor.sqlExe("COMMIT", {})
owner_id = (getattr(_own[0], 'created_by', '') if _own else '') or 'user-01'
await create_human_task(
project_id,
f"依赖阻塞:{task_title or task_id[:8]}",
f"任务「{task_title}」的依赖永远无法满足,已停止等待,需人工处理。\n\n"
f"原因:{reason}\n\n"
f"处理方式:修正该任务的 depends_on,或重建被依赖的任务,或取消本任务。",
task_type='dependency_blocked',
assignee_id=owner_id,
created_by='system.orchestrator',
task_id=task_id,
)
logger.warning(f"依赖阻塞冒泡: task={task_id} title={task_title} reason={reason}")
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
blocked = [] # [(task_id, title, reason)] 依赖未满足的任务,供逃逸阀冒泡
for rec in recs:
_tid = getattr(rec, 'id', '')
# 启动策略:任务可在 params.dep_policy 声明 all/any/at_least(缺省 all)
try:
_tp = json.loads(getattr(rec, 'params', '{}') or '{}')
except (json.JSONDecodeError, TypeError):
_tp = {}
ok_dep, why = await _task_deps_satisfied(
sor, getattr(rec, 'depends_on', '') or '', task_id=_tid,
dep_policy=_tp.get('dep_policy'))
if not ok_dep:
blocked.append((_tid, getattr(rec, 'title', '') or '', why))
continue
# 进入条件(部署类):真实部署环境信息必须齐备。
# 环境信息属外部输入,只卡部署、不卡需求/设计/开发(用户 2026-08-26 定调)。
_rrole = getattr(rec, 'role', '') or role
env_ok, env_missing, env_file = await _check_deploy_env_ready(sor, tenant_id, _rrole)
if not env_ok:
_rtitle = getattr(rec, 'title', '') or ''
try:
await _bubble_deploy_env_missing(
sor, tenant_id, _tid, _rtitle, env_missing, env_file)
# 置 waiting:让「卡住」在界面上可见,不在 submitted 里静默打转
await sor.sqlExe(
"UPDATE pipeline_tasks SET state='waiting', updated_at=NOW() "
"WHERE id=${i}$ AND state='submitted'", {"i": _tid})
await sor.sqlExe("COMMIT", {})
except Exception as e:
logger.warning(f"_bubble_deploy_env_missing failed task={_tid}: {e}")
continue
task = rec
break
# 逃逸阀:依赖「永不可达」(不存在/已作废/自依赖)的任务会永久 submitted 且无人知晓,
# 与「自动 pause 无出口」是同一类活性缺陷。这里冒泡人工任务,让 owner 能看到并修正。
for _tid, _ti, _why in blocked:
if '永不可达' in _why or '不存在' in _why or '依赖自身' in _why or '解析失败' in _why:
try:
await _bubble_blocked_dependency(sor, tenant_id, _tid, _ti, _why)
except Exception as e:
logger.warning(f"_bubble_blocked_dependency failed task={_tid}: {e}")
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)
async def _get_space_dir(sor, project_id):
"""返回产线工作空间(机构工作空间层){space}/——角色 agent 工具路径基准。
新规下角色在 projects/{项目}/docs/、apps/、modules/ 下读写,
这些相对路径的基准是 {space}/。用 build_space_path(org_id + pipeline_id)构造,
不依赖 workspace_dir 路径结构(workspace_dir 迁到 {space}/projects/{项目名} 后 dirname 会错)。
"""
recs = await sor.sqlExe(
"SELECT org_id, pipeline_id FROM sd_projects WHERE id=${pid}$ LIMIT 1",
{"pid": project_id})
if not recs:
return _resolve_workspace('')
org_id = getattr(recs[0], 'org_id', '0') or '0'
space = getattr(recs[0], 'pipeline_id', '') or GENERAL_SPACE
return build_space_path(WORKSPACE_BASE, org_id, space)
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(space_dir):
"""扫描机构工作空间 modules/*/skill/SKILL.md 和 apps/*/skill/SKILL.md,收集模块技能。
新结构:模块本地仓库在机构工作空间 modules/,应用在 apps/。模块仓库自带
skill/SKILL.md(架构/数据模型/挂载函数/坑位),不进 skill_loader 静态树,
这里运行时扫描,作为「项目模块」scope 注入技能目录 + 支持 load_skill 加载全文。
返回 [{name, description, body, path, repo}]。body 是剥离 frontmatter 后的正文。
"""
modules = []
skill_files = []
for sub in ('modules', 'apps'):
base = os.path.join(space_dir, sub)
if os.path.isdir(base):
for entry in sorted(os.listdir(base)):
sf = os.path.join(base, entry, 'skill', 'SKILL.md')
if os.path.isfile(sf):
skill_files.append((sf, entry))
for skill_file, entry in skill_files:
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):
"""查项目目录名(英文 slug = directory_name 字段,查不到回退 name)。查不到返回空串。
新结构:项目目录名用英文 slug([a-zA-Z0-9_]+),显示名(name,可中文)独立。
"""
try:
r = await sor.sqlExe(
"SELECT directory_name, name FROM sd_projects WHERE id=${pid}$ LIMIT 1",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
if r:
dn = getattr(r[0], 'directory_name', '') or ''
return dn or (getattr(r[0], 'name', '') or '')
except Exception:
pass
return ''
async def _get_project_dir(sor, project_id):
"""返回项目目录 {space}/projects/{项目名}/(项目过程仓库 + docs/ + env/ + deliverables/ 所在)。
新结构:项目目录从 {space}/{项目名} 迁到 {space}/projects/{项目名}/,与 apps/、modules/ 平级。
"""
space_dir = await _get_space_dir(sor, project_id)
if not space_dir:
return ''
project_name = await _get_project_name(sor, project_id)
if not project_name:
return ''
return os.path.join(space_dir, 'projects', project_name)
async def _ensure_project_repo(project_dir):
"""确保项目目录(项目过程仓库)存在并 git init(docs/ + env/ + spec.json + deliverables/ 放这里)。
新结构:项目目录 projects/{项目名}/ 本身就是项目过程仓库(不再是 repos/{项目名}_pc/)。
幂等:已存在则跳过。项目过程仓库是本地仓库,无远程,只 commit 不 push。"""
os.makedirs(project_dir, exist_ok=True)
if os.path.isdir(os.path.join(project_dir, '.git')):
return {'rc': 0, 'message': '项目过程仓库已存在'}
async with _git_lock(project_dir):
if os.path.isdir(os.path.join(project_dir, '.git')):
return {'rc': 0, 'message': '项目过程仓库已存在'}
r = await _run_shell('git init', project_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(project_dir)
readme = os.path.join(project_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: 项目过程仓库"', project_dir, 15)
return {'rc': r2['rc'], 'message': f"init 项目过程仓库 {'成功' if r2['rc'] == 0 else '失败'}"}
async def _commit_repos_after_approve(space_dir, project_name='', title=''):
"""审核通过后统一提交:项目过程仓库 projects/{项目名}/(阶段/QC/PM 文档)+ apps/* + modules/* 仓库(代码)。
不在 agent 每次产出时提交,减少 git 并发锁与远端 push 频率。"""
msg = f"approve: {title[:80]}" if title else "approve: 审核通过"
results = []
roots = []
if project_name:
roots.append(('projects', os.path.join(space_dir, 'projects', project_name)))
roots.append(('apps', os.path.join(space_dir, 'apps')))
roots.append(('modules', os.path.join(space_dir, 'modules')))
for kind, base in roots:
if not os.path.isdir(base):
continue
if kind == 'projects':
if os.path.isdir(os.path.join(base, '.git')):
r = await _git_commit_push(base, msg)
results.append(f"{os.path.basename(base)}: {r.get('message', '')}")
logger.info(f"commit-after-approve projects: rc={r.get('rc', -1)} {r.get('message', '')[:100]}")
continue
for name in sorted(os.listdir(base)):
rp = os.path.join(base, name)
if os.path.isdir(os.path.join(rp, '.git')):
r = await _git_commit_push(rp, msg)
results.append(f"{kind}/{name}: {r.get('message', '')}")
logger.info(f"commit-after-approve {kind}/{name}: rc={r.get('rc', -1)} {r.get('message', '')[:100]}")
return {"rc": 0, "message": "; ".join(results) if results else "无仓库可提交"}
async def _setup_repos(sor, space_dir, project_id):
"""clone 项目关联仓库到机构工作空间:应用→apps/、模块→modules/;项目目录→projects/{项目名}/。"""
project_dir = await _get_project_dir(sor, project_id)
await _ensure_project_repo(project_dir)
repos = await _get_project_repos(sor, project_id)
results = []
for repo in repos:
name = repo['name'] or ''
# 应用仓库:名字以 _app 结尾 → apps/{去掉_app}/;否则模块仓库 → modules/{name}/
if name.endswith('_app'):
target = os.path.join(space_dir, 'apps', name[:-4])
else:
target = os.path.join(space_dir, 'modules', name)
r = await _git_clone(repo['url'], target, repo['branch'])
results.append({'repo': name, **r})
return results
async def _get_repo_state(space_dir, project_name=''):
"""获取仓库当前状态(供PM审核时查看):项目过程仓库 projects/{项目名}/ + apps/* + modules/*。"""
lines = []
roots = []
if project_name:
roots.append(('projects', os.path.join(space_dir, 'projects', project_name)))
roots.append(('apps', os.path.join(space_dir, 'apps')))
roots.append(('modules', os.path.join(space_dir, 'modules')))
for kind, base in roots:
if not os.path.isdir(base):
continue
if kind == 'projects':
if os.path.isdir(os.path.join(base, '.git')):
r = await _run_shell('git log --oneline -3', base, 5)
lines.append(f"\n[{os.path.basename(base)}]")
lines.append(r.get('stdout', '') or '(空仓库)')
continue
for name in sorted(os.listdir(base)):
rp = os.path.join(base, 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[{kind}/{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 _check_orchestration_gaps(sor, project_id, task, next_role='', next_task_id=''):
"""编排完备性校验:代码只**查漏**,不替 PM 决策。
设计立场(2026-08-25 定调):流转决策权归 LLM(PM 懂业务语义,知道模块怎么拆、依赖怎么连),
代码负责补 LLM 的固有短板——**不穷尽**。今天的实证:PM 给 3 个模块任务里的 2 个正确设了
depends_on,却漏了应用脚手架那一个,导致脚手架先跑完、触发 deploy_test 提前启动。
LLM 能做依赖判断(已证明),但会漏(也已证明)→ 代码查漏,把结构化事实回给 PM。
返回 [告警字符串],空 = 无问题。不阻断流程(阻断权仍在人和 PM),只暴露事实。
检查项:
G1 依赖自引用 / 成环 → 必然死锁(我在方案走查阶段就发现自己的设计有这个洞)
G2 应用级 develop(脚手架)无 depends_on,但同迭代存在模块级 develop 未完成
→ 脚手架会先跑完,打破「应用级 develop approved = 编码完成」这个 deploy_test 门控前提
G3 design 产出的模块清单(spec.json generated_modules)有模块没有对应 develop 任务
→ PM 漏派,模块不会被开发却照样部署
返回结构化缺口列表 [{kind, fingerprint, text, task_id, fix_ids}]:
fingerprint 用于通知去重;task_id/fix_ids 让 PM 能直接调 update_task_deps 修正,
不需要再反查任务 ID(G2 元景故障的教训:告警只进日志没人看,事实必须送到 PM 手上)。
"""
gaps = []
_iter = _task_iteration_name(task)
# 取同迭代全部活跃/终态任务(含 depends_on),一次查完供三项检查复用
recs = await sor.sqlExe(
"SELECT id, title, role, state, depends_on, params FROM pipeline_tasks "
"WHERE tenant_id=${pid}$ AND state NOT IN ('cancelled') ORDER BY created_at",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
tasks = []
for r in (recs or []):
try:
p = json.loads(getattr(r, 'params', '{}') or '{}')
except (json.JSONDecodeError, TypeError):
p = {}
if _iter and (p.get('iteration_id') or '') != _iter:
continue
deps = []
try:
_d = json.loads(getattr(r, 'depends_on', '') or '[]')
deps = [str(x) for x in _d if x] if isinstance(_d, list) else []
except (json.JSONDecodeError, TypeError):
deps = []
tasks.append({'id': getattr(r, 'id', ''), 'title': getattr(r, 'title', '') or '',
'role': getattr(r, 'role', '') or '', 'state': getattr(r, 'state', '') or '',
'deps': deps, 'params': p})
# G1 自引用 / 成环(DFS 找回边)
dep_map = {t['id']: t['deps'] for t in tasks}
title_of = {t['id']: t['title'] for t in tasks}
for tid, ds in dep_map.items():
if tid in ds:
gaps.append({'kind': 'G1', 'fingerprint': f"G1:{tid}", 'task_id': tid, 'fix_ids': [],
'text': f"G1 任务「{title_of.get(tid, tid[:8])}」({tid}) 依赖自身,必然死锁"})
WHITE, GRAY, BLACK = 0, 1, 2
color = {k: WHITE for k in dep_map}
_cyc_seen = set()
def _dfs(n, path):
color[n] = GRAY
for m in dep_map.get(n, []):
if m not in color:
continue
if color[m] == GRAY:
cyc = path[path.index(m):] if m in path else [m]
fp = "G1:cycle:" + ",".join(sorted(cyc))
if fp in _cyc_seen:
continue
_cyc_seen.add(fp)
gaps.append({'kind': 'G1', 'fingerprint': fp, 'task_id': (cyc[0] if cyc else ''),
'fix_ids': [],
'text': "G1 依赖成环,必然死锁:" +
" → ".join(title_of.get(x, x[:8]) for x in cyc + [m])})
elif color[m] == WHITE:
_dfs(m, path + [n])
color[n] = BLACK
for n in list(dep_map.keys()):
if color.get(n) == WHITE:
_dfs(n, [n])
# G2 应用级 develop 缺依赖(本次故障的直接成因)
ACTIVE = ('submitted', 'running', 'review', 'qc_review', 'waiting')
module_devs = [t for t in tasks
if t['role'] == 'agent.develop' and not t['params'].get('previous_role')]
app_devs = [t for t in tasks
if t['role'] == 'agent.develop' and t['params'].get('previous_role')]
unfinished_mods = [t for t in module_devs if t['state'] in ACTIVE]
for a in app_devs:
if a['state'] not in ACTIVE and a['state'] != 'approved':
continue
miss_tasks = [m for m in unfinished_mods if m['id'] not in a['deps']]
if miss_tasks:
miss_ids = [m['id'] for m in miss_tasks]
gaps.append({
'kind': 'G2', 'fingerprint': "G2:" + a['id'] + ":" + ",".join(sorted(miss_ids)),
'task_id': a['id'], 'fix_ids': miss_ids,
'text': (f"G2 应用级 develop「{a['title']}」({a['id']}) 未依赖以下未完成的模块任务:"
+ "、".join(f"{m['title']}({m['id']})" for m in miss_tasks)
+ " → 脚手架可能先于模块完成,使 deploy_test 在编码未完成时启动"
+ "(修法:update_task_deps(task_id=\"" + a['id']
+ "\", add_depends_on=" + json.dumps(miss_ids) + "))")})
# G3 设计模块清单 vs 实际派发的 develop 任务(漏派检测)
try:
wdir = await _get_project_dir(sor, project_id)
if wdir:
import os as _os
import glob as _glob
for spec_path in _glob.glob(_os.path.join(wdir, '*_spec.json')):
with open(spec_path, encoding='utf-8') as f:
spec = json.load(f)
mods = spec.get('generated_modules') or []
if not isinstance(mods, list):
continue
for m in mods:
if not m:
continue
hit = any(str(m).lower() in (t['title'] or '').lower()
for t in tasks if t['role'] == 'agent.develop')
if not hit:
gaps.append({
'kind': 'G3', 'fingerprint': f"G3:{m}", 'task_id': '', 'fix_ids': [],
'text': (f"G3 设计清单里的模块「{m}」没有对应的 develop 任务"
f"({_os.path.basename(spec_path)} generated_modules)→ PM 漏派,"
f"该模块不会被开发却会进入部署(修法:create_tasks 补派该模块)")})
except Exception as e:
logger.debug(f"_check_orchestration_gaps G3 跳过: {e}")
return gaps
def _gap_texts(gaps):
"""结构化缺口 → 纯文本列表(日志用)。"""
return [g['text'] for g in (gaps or []) if isinstance(g, dict) and g.get('text')]
async def _save_gap_notices(sor, project_id, gaps, source_task_id=''):
"""把编排查漏结果持久化为 PM 通知(pending),供 PM 下一回合注入上下文。
去重:(tenant_id, fingerprint) 逻辑唯一——同一缺口重复检出只留一条。
实现用 SELECT 先查再 UPDATE/INSERT,不依赖 unique 索引(create_tables.py
的幂等建表会跳过 CREATE INDEX 语句,索引可能不存在)。
已存在(含已 delivered)→ 重置回 pending:缺口未修复再次检出时,
PM 必须再看到(若用 INSERT IGNORE 会被挡住,缺口就永远静默了)。
"""
from appPublic.uniqueID import getID
for g in (gaps or []):
if not isinstance(g, dict) or not g.get('fingerprint'):
continue
try:
_ex = await sor.sqlExe(
"SELECT id FROM pipeline_pm_notices WHERE tenant_id=${pid}$ AND fingerprint=${fp}$",
{"pid": project_id, "fp": g['fingerprint']})
_tid = g.get('task_id') or source_task_id or ''
if _ex:
await sor.sqlExe(
"UPDATE pipeline_pm_notices SET status='pending', gap_text=${t}$, "
"task_id=${tid}$, gap_kind=${k}$, updated_at=NOW() WHERE id=${id}$",
{"t": g.get('text') or '', "tid": _tid, "k": g.get('kind') or '',
"id": getattr(_ex[0], 'id', '')})
else:
await sor.sqlExe(
"INSERT INTO pipeline_pm_notices "
"(id, tenant_id, task_id, fingerprint, gap_kind, gap_text, status, created_at, updated_at) "
"VALUES (${id}$, ${pid}$, ${tid}$, ${fp}$, ${k}$, ${t}$, 'pending', NOW(), NOW())",
{"id": getID(), "pid": project_id, "tid": _tid,
"fp": g['fingerprint'], "k": g.get('kind') or '', "t": g.get('text') or ''})
await sor.sqlExe("COMMIT", {})
except Exception as e:
logger.debug(f"_save_gap_notices 跳过 {g.get('fingerprint')}: {e}")
async def _load_pm_notices(sor, project_id):
"""取本项目全部 pending 编排通知并标记 delivered(每个缺口只进一次 PM 上下文,避免重复噪声)。
返回拼接好的文本块;无通知返回 ''。
"""
recs = await sor.sqlExe(
"SELECT id, gap_text FROM pipeline_pm_notices "
"WHERE tenant_id=${pid}$ AND status='pending' ORDER BY created_at ASC",
{"pid": project_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return ''
ids = [getattr(r, 'id', '') for r in recs]
ph = ",".join("${i%d}$" % n for n in range(len(ids)))
kw = {("i%d" % n): v for n, v in enumerate(ids)}
await sor.sqlExe(
"UPDATE pipeline_pm_notices SET status='delivered', updated_at=NOW() "
"WHERE id IN (" + ph + ")", kw)
await sor.sqlExe("COMMIT", {})
lines = ["⚠️ 编排完备性检查发现以下缺口(代码查漏,修正决策权在你):"]
for r in recs:
lines.append("· " + (getattr(r, 'gap_text', '') or ''))
lines.append("请核实后用 update_task_deps / create_tasks / cancel_task 修正;"
"若缺口是误报(如任务已另行安排),忽略即可。")
return "\n".join(lines)
async def _create_next_task(sor, project_id, task, next_role, pm_comment='',
next_title='', next_desc=''):
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 = {}
# 标题:不再继承上一任务 title + 追加「({next_role}阶段)」——那是 title 继承 bug 的根源,
# 导致 design 任务名变成「需求规格说明书(agent.design阶段)」跟需求名混同,回退重做后更叠加成
# 「...(回退重做)(agent.develop阶段)(agent.deploy_test阶段)」。优先用 PM 在 review_approve
# 里给出的 next_task_title(LLM 懂语义,流转决策权归它),缺失时按 RoleSpec.task_title 兜底。
stage = next_role
try:
pid = await _resolve_pipeline_id(project_id)
from pipeline_core import get_role_spec
spec = get_role_spec(pid, next_role)
if spec and getattr(spec, 'task_title', ''):
stage = spec.task_title
except Exception:
pass
pname = ''
try:
_p = await sor.sqlExe("SELECT name FROM sd_projects WHERE id=${pid}$", {"pid": project_id})
await sor.sqlExe("COMMIT", {})
pname = getattr(_p[0], 'name', '') if _p else ''
except Exception:
pname = ''
new_title = (next_title or '').strip() or (f"{pname} {stage}" if pname else stage)
new_params = {**params, 'previous_role': _normalize_role(getattr(task, 'role', '')),
'previous_task_id': getattr(task, 'id', ''), 'pm_comment': pm_comment}
# ⚠️ description 不能继承(2026-08-25 修复):原实现 new_params = {**params} 把上游任务的
# description 整个带下来,导致 design 派生的「应用脚手架 develop」拿到的是**需求任务的描述**
# ("产出需求规格说明书与需求评审记录"),于是它交付了文档、4 分钟就 approved —— 一行代码没写
# 却走完 develop 阶段,进而打破「应用级 develop approved = 编码已完成」这个 deploy_test 门控
# 前提,造成 deploy_test 在编码未完成时提前启动。
# 正确来源优先级:PM 的 next_task_description(LLM 按阶段语义写)> 阶段兜底描述。
_desc = (next_desc or '').strip()
if not _desc:
_desc = (f"承接上一阶段「{title}」的产出,执行 {next_role} 阶段工作:{stage}。"
f"先 load_skill 加载 role 技能,按其中职责与规范执行,产出物按规范落盘。")
if pm_comment:
_desc += f"\n\n上一阶段 PM 审核意见:{pm_comment}"
new_params['description'] = _desc
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)
# 幂等(2026-08-25 新增):并发场景下多个任务几乎同时 approved,每个都会走到这里尝试
# 创建下一阶段任务 → 同一迭代同一角色出现多个重复任务(历史上已出现「同一迭代两个 design
# 任务」)。这里先查同迭代同角色的活跃任务,存在则复用、不重复创建。
# 注:这是机制层不变量,不能靠 LLM 自觉(LLM 看不到并发)。
_iter = new_params.get('iteration_id') or ''
_dup = await sor.sqlExe(
"SELECT id, title, params FROM pipeline_tasks WHERE tenant_id=${pid}$ AND role=${r}$ "
"AND state IN ('submitted','running','review','qc_review','waiting') "
"ORDER BY created_at DESC LIMIT 20",
{"pid": project_id, "r": next_role})
await sor.sqlExe("COMMIT", {})
# 单例阶段:需求/设计每迭代只有一个(不像 develop 按模块并行多任务)。
# 系统派生时若同迭代已有活跃任务——无论来源(含 PM create_tasks 派发、无 previous_role)——
# 都复用,不重复创建。元景故障:PM 派的真设计任务(无 previous_role)不在旧幂等匹配范围,
# 导致自动派生的错位设计任务与它并存、各自 approved 各触发一次设计确认。
_single_stage = next_role in ('agent.requirement', 'agent.design')
for _d in (_dup or []):
try:
_dp = json.loads(getattr(_d, 'params', '{}') or '{}')
except (json.JSONDecodeError, TypeError):
_dp = {}
if (_dp.get('iteration_id') or '') != _iter:
continue
# 同迭代 + 同角色 + 同为系统派生(有 previous_role)→ 认定为同一阶段任务,幂等复用
if _dp.get('previous_role') or _single_stage:
_did = getattr(_d, 'id', '')
logger.info(f"_create_next_task 幂等命中:{next_role} 同迭代已有活跃任务 {_did},跳过创建")
return _did, getattr(_d, 'title', '') or new_title
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:...
m = re.search(r']*>(.*?)', raw, re.DOTALL)
if m:
tool = m.group(1).strip()
body = m.group(2)
params = {}
for pm in re.finditer(r']*>(.*?)', 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(space_dir, repo_dir):
"""解析仓库目录:空→apps/modules 下第一个 git 仓库;'apps/xxx'/'modules/xxx'→直接;'xxx'→apps/xxx 或 modules/xxx。"""
if repo_dir:
if repo_dir.startswith('apps/') or repo_dir.startswith('apps\\') \
or repo_dir.startswith('modules/') or repo_dir.startswith('modules\\'):
return os.path.join(space_dir, repo_dir)
# 无前缀:先试 apps/,再试 modules/
for sub in ('apps', 'modules'):
cand = os.path.join(space_dir, sub, repo_dir)
if os.path.isdir(cand):
return cand
return os.path.join(space_dir, 'modules', repo_dir)
for sub in ('apps', 'modules'):
base = os.path.join(space_dir, sub)
if os.path.isdir(base):
dirs = [d for d in os.listdir(base)
if os.path.isdir(os.path.join(base, d, '.git'))]
if dirs:
return os.path.join(base, dirs[0])
return space_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):
# 注意:workspace_dir 形参实际接收的是 space_dir(机构工作空间层 {space}/),
# 角色相对路径 projects/{项目}/、apps/、modules/ 都以它为基准。
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: 需要命令'
# 部署角色(deploy_test/deploy_prod)SSH 到目标机时,注入机构独立密钥
# (每机构一把,防单点泄露;否则默认走 ~/.ssh/id_rsa 个人密钥)。
_who = (ctx or {}).get('who', '') or ''
_org = (ctx or {}).get('org_id', '') or ''
if _who in ('agent.deploy_test', 'agent.deploy_prod') and _org:
cmd = _inject_org_ssh_key(cmd, _org)
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', '')
if name.endswith('_app'):
target = os.path.join(workspace_dir, 'apps', name[:-4])
else:
target = os.path.join(workspace_dir, 'modules', 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(space_dir, written_files, 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, space_dir) if f.startswith(space_dir) else f
lines.append("- " + rel)
# 扫描 apps/ + modules/ + projects/ 下的 git 仓库工作区变更
for sub in ('apps', 'modules', 'projects'):
base = os.path.join(space_dir, sub)
if not os.path.isdir(base):
continue
entries = ([(p, p) for p in sorted(os.listdir(base))] if sub == 'projects'
else [(f"{sub}/{n}", n) for n in sorted(os.listdir(base))])
for label, name in entries:
rp = os.path.join(base, name)
if os.path.isdir(os.path.join(rp, '.git')):
try:
r = await _run_shell('git status --short', rp, 10)
out = (r.get('stdout') or '').strip()
if out:
lines.append(f"git 仓库 [{label}] 工作区变更:")
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]}")
# 项目模块技能:机构工作空间 modules/*/skill + apps/*/skill/SKILL.md(模块怎么用——架构/数据模型/挂载函数/坑位),
# 运行时扫描注入,让项目角色知道引用了哪些模块、每个模块怎么挂载(load_xxx 入口/库名/坑)。
try:
ws = await _get_space_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:
# 项目模块技能(机构工作空间 modules/*/skill + apps/*/skill)不在 skill_loader 静态树里,运行时补查。
# 全文层对齐 skill_loader.to_prompt_block:剥离 frontmatter 只返回正文(body)。
try:
ws = await _get_space_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)
# 机构工作空间层({space}/):角色工具路径基准,能访问 projects/、apps/、modules/
space_dir = await _get_space_dir(sor, project_id)
try:
await sor.sqlExe("COMMIT", {})
except Exception:
pass
# 确保项目关联仓库已 clone 到 apps/modules + 项目目录 git init(幂等)——否则源码写不进 git 仓库
try:
clone_results = await _setup_repos(sor, space_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)
# 项目目录名(英文 slug,用于 projects/{项目}/ 路径;产出路径以 project-directory-spec 为准)
project_name = await _get_project_name(sor, project_id)
project_dir = os.path.join(space_dir, 'projects', project_name) if project_name else space_dir
# 能力工具上下文(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,
"org_id": org_id or '0',
}
system = (AGENT_SYSTEM_PROMPT
.replace('__ROLE__', role)
.replace('__TITLE__', title)
.replace('__QNA__', qna_section)
.replace('__WORKSPACE__', space_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, space_dir, capability_ctx)
if tool == "write_file" and params.get("path"):
written_files.append(os.path.join(space_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, space_dir, capability_ctx)
if tool == 'write_file' and params.get('path'):
written_files.append(os.path.join(space_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(space_dir, written_files, 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(space_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(project_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
# 启动策略(可选):PM 可为任务声明前置任务的启动方式
# {"mode": "all"} / {"mode": "any"} / {"mode": "at_least", "n": k}
# 非法值会被 _parse_dep_policy 保守兜底为 all,不会破坏编排。
_dp = t.get('dep_policy')
if isinstance(_dp, dict) and _dp.get('mode'):
tparams['dep_policy'] = _dp
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 终态,子任务无需再显式依赖父。
#
# ⚠️ 依赖完整性(2026-08-25 修复 D2/D3):原实现对未知引用**只警告、静默丢弃**,
# 对 len>=20 的引用**不校验存在性**。两者都会让任务以「更少的依赖」落库 →
# 依赖门控失效 → 依赖未完成就开跑(正是「多依赖时有依赖没完成就已经开始」的成因)。
# 现在:未解析成功的引用一律阻塞该任务(state='waiting' + 冒泡),绝不静默降级为并行。
dep_errors = [] # [(tid, title, [坏引用])]
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 = [deps] if deps.strip() else []
resolved = []
bad = []
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 d == tid:
bad.append(f"{d}(依赖自身)")
elif len(d) >= 20:
# D3:不能只看长度就当有效 ID —— 必须确认该任务真实存在
_ex = await sor.sqlExe(
"SELECT id FROM pipeline_tasks WHERE id=${i}$", {"i": d})
if _ex:
resolved.append(d)
else:
bad.append(f"{d[:12]}(任务不存在)")
else:
bad.append(f"{d}(未知 key)")
if bad:
# 依赖解析不完整 → 任务不进入可认领状态,冒泡人工修正,避免提前开跑
dep_errors.append((tid, title, bad))
await sor.sqlExe(
"UPDATE pipeline_tasks SET state='waiting', depends_on=${deps}$ WHERE id=${tid}$",
{"deps": json.dumps(resolved, ensure_ascii=False) if resolved else None, "tid": tid})
warnings.append(f"「{title}」依赖无法解析({'、'.join(bad)})→ 已置 waiting 待人工修正")
elif 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", {})
# 编排查漏:派发后立即跑 G1/G2/G3,缺口当场返回给 PM(同回合即可修正)+
# 持久化通知兜底(本回合未修,下一回合仍能看到)。元景故障教训:只打日志没人看。
gap_note = ''
try:
_anchor = None
for _, _tid0, _, _ in created:
_ar = await sor.sqlExe("SELECT id, params FROM pipeline_tasks WHERE id=${i}$", {"i": _tid0})
if _ar:
_anchor = _ar[0]
break
_gaps = await _check_orchestration_gaps(sor, project_id, _anchor) if _anchor else []
if _gaps:
await _save_gap_notices(sor, project_id, _gaps, source_task_id=parent_task_id or '')
logger.warning(f"编排完备性告警(派发后) project={project_id}: {_gap_texts(_gaps)}")
gap_note = ("\n⚠️ 编排完备性检查发现缺口:\n" + "\n".join("· " + g['text'] for g in _gaps)
+ "\n请核实后立即用 update_task_deps/create_tasks 修正。")
except Exception as _ge:
logger.warning(f"派发后编排查漏失败: {_ge}")
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]) + gap_note
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_update_task_deps(sor, project_id, params):
"""PM 给已存在任务补前置依赖(G2/G3 查漏的修正出口)。
代码只发现缺口并把事实交给 PM(流转决策权归 LLM),PM 核实后用本原语修正。
params: {task_id, add_depends_on: [任务ID...], dep_policy?: {mode, n?}}
"""
task_id = (params.get('task_id') or params.get('id') or '').strip()
add = params.get('add_depends_on') or []
if isinstance(add, str):
try:
add = json.loads(add)
except (json.JSONDecodeError, ValueError):
add = []
add = [str(x).strip() for x in (add or []) if str(x).strip()]
if not task_id:
return 'FAIL: 需要 task_id'
if not add:
return 'FAIL: 需要 add_depends_on(要追加的任务ID数组)'
recs = await sor.sqlExe(
"SELECT id, title, state, depends_on, params 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: 任务不存在或不属于当前项目'
rec = recs[0]
title = getattr(rec, 'title', '')
state = getattr(rec, 'state', '')
if state in ('completed', 'cancelled', 'failed', 'approved'):
return f'FAIL: 任务「{title}」已是终态 {state},改依赖无意义'
# 校验追加的依赖真实存在(防编造 ID,与 _pm_create_tasks 的 D3 校验同标准)
resolved, bad = [], []
for d in add:
_ex = await sor.sqlExe("SELECT id FROM pipeline_tasks WHERE id=${i}$", {"i": d})
if _ex:
resolved.append(d)
else:
bad.append(d[:12])
if bad:
return 'FAIL: 以下任务ID不存在:' + '、'.join(bad)
# 合并现有 depends_on(去重、防自依赖)
try:
cur = json.loads(getattr(rec, 'depends_on', '') or '[]')
cur = [str(x) for x in cur if x] if isinstance(cur, list) else []
except (json.JSONDecodeError, TypeError):
cur = []
if task_id in resolved:
return 'FAIL: 不能依赖自身'
merged = cur + [d for d in resolved if d not in cur]
# 可选更新启动策略
try:
tparams = json.loads(getattr(rec, 'params', '{}') or '{}')
except (json.JSONDecodeError, TypeError):
tparams = {}
_dp = params.get('dep_policy')
if isinstance(_dp, dict) and _dp.get('mode'):
tparams['dep_policy'] = _dp
await sor.sqlExe(
"UPDATE pipeline_tasks SET depends_on=${deps}$, params=${p}$, updated_at=NOW() WHERE id=${tid}$",
{"deps": json.dumps(merged, ensure_ascii=False),
"p": json.dumps(tparams, ensure_ascii=False), "tid": task_id})
await sor.sqlExe("COMMIT", {})
from .audit import record_audit
await record_audit(project_id, 'pipeline_tasks', task_id, 'update_deps',
who='agent.pm',
detail=f"depends_on += {resolved}", sor=sor)
return (f'OK: 任务「{title}」depends_on 已更新为 {merged}'
+ (f',启动策略={_dp}' if isinstance(_dp, dict) and _dp.get('mode') else ''))
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)
space_dir = await _get_space_dir(sor, project_id)
project_name = await _get_project_name(sor, project_id)
project_dir = os.path.join(space_dir, 'projects', project_name) if project_name else space_dir
try:
await sor.sqlExe("COMMIT", {})
except Exception:
pass
repos = await _get_project_repos(sor, project_id)
if repos:
await _setup_repos(sor, space_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(space_dir, project_name)
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__', space_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}"})
# 编排缺口通知注入:代码查漏发现的 G1/G2/G3 缺口(此前只进日志没人看),
# 这里随任务上下文交给 PM——PM 可在本回合核实后用 update_task_deps 等修正。
try:
_notices = await _load_pm_notices(sor, project_id)
if _notices:
msgs.append({"role": "user", "content": _notices})
except Exception as _ne:
logger.warning(f"_load_pm_notices failed: {_ne}")
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 in ('update_task_deps', 'update_deps'):
result = await _pm_update_task_deps(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, space_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(project_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(space_dir, project_name, 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"}
# 人工确认节点:requirement/design 审核通过后,不直接派发下一角色任务,
# 而是发「需求/设计确认」人工任务给项目 owner。owner 确认通过 → confirm_stage_gate
# 继续派发下一角色;不确定 → 退回原角色重做(带修改意见)。
if task_role in ('agent.requirement', 'agent.design'):
confirm_type = 'requirement_confirmation' if task_role == 'agent.requirement' else 'design_confirmation'
confirm_label = '需求' if task_role == 'agent.requirement' else '设计'
# 确认去重(2026-08-27 修复):同迭代已确认过(status=done)且本任务非回退重做
# → 不再重复发确认任务,直接走确认后派发。元景故障:错位的重复 design 任务各被
# approved 一次,owner 连续收到两条「设计确认」待办。回退重做(有 rollback_from)
# 产出的是新交付件,必须重新确认,不走去重。
iter_id = ''
if task_iter:
_it2 = await sor.sqlExe(
"SELECT id FROM sd_iterations WHERE project_id=${pid}$ AND iteration_name=${n}$",
{"pid": project_id, "n": task_iter})
iter_id = getattr(_it2[0], 'id', '') if _it2 else ''
is_redo = bool(_tp.get('rollback_from'))
if iter_id and not is_redo:
_done_conf = await sor.sqlExe(
"SELECT id FROM pipeline_human_tasks WHERE iteration_id=${iid}$ "
"AND task_type=${ct}$ AND status='done' LIMIT 1",
{"iid": iter_id, "ct": confirm_type})
await sor.sqlExe("COMMIT", {})
if _done_conf:
logger.info(
f"确认去重:{confirm_type} 本迭代已确认过(非回退重做),"
f"跳过重复确认,直接派发 {next_role}: task={task_id}")
next_tid, next_title = await _create_next_task(
sor, project_id, task, next_role, comment,
next_title=decision.get('next_title', '') or '',
next_desc=decision.get('next_desc', '') or '')
return {"status": "approved", "task_id": task_id,
"next_task_id": next_tid, "next_role": next_role,
"comment": comment, "confirm_skipped": "already_confirmed"}
_own = await sor.sqlExe(
"SELECT created_by FROM sd_projects WHERE id=${pid}$", {"pid": project_id})
await sor.sqlExe("COMMIT", {})
owner_id = getattr(_own[0], 'created_by', '') if _own else ''
if not owner_id:
owner_id = 'user-01' # 历史项目 NULL 回填 admin
from .human_task_capability import create_human_task
ok, hid = await create_human_task(
project_id,
f"{confirm_label}确认:{title}",
f"请项目 owner 确认「{confirm_label}」是否满足要求。\n\n"
f"· 确认通过 → 继续后续任务({next_role})。\n"
f"· 不确定 → 请填写修改意见,将退回 {task_role} 重做。\n\n"
f"原任务:{title}\nPM 审核意见:{comment or '通过'}",
task_type=confirm_type,
assignee_id=owner_id,
iteration_id=iter_id,
created_by='agent.pm',
task_id=task_id,
)
logger.info(f"人工确认节点:{task_role} approved → 创建 {confirm_type} "
f"给 owner={owner_id}, human_task={'OK' if ok else 'FAIL:' + str(hid)}")
return {"status": "approved", "task_id": task_id, "next_role": next_role,
"human_confirm": hid if ok else "", "comment": comment}
# 把 PM 给出的 next_task_title / next_task_description 传下去(原实现只传 comment,
# PM 的这两个字段被静默丢弃 → 派生任务只能继承上游 title/description,正是
# 「design 任务名跟需求任务名一样」和「应用级 develop 拿到需求描述」的根源)。
# 流转决策权归 LLM:PM 写了就用 PM 的;没写才用 RoleSpec 阶段模板兜底。
next_tid, next_title = await _create_next_task(
sor, project_id, task, next_role, comment,
next_title=decision.get('next_title', '') or '',
next_desc=decision.get('next_desc', '') or '')
# 完备性校验(代码只查漏、不替 PM 决策):缺口事实持久化为通知,
# 注入 PM 下一回合上下文(元景故障教训:只打日志没人看,事实必须送到 PM 手上)。
try:
_gaps = await _check_orchestration_gaps(sor, project_id, task, next_role, next_tid)
if _gaps:
logger.warning(f"编排完备性告警 task={next_tid}: {_gap_texts(_gaps)}")
await _save_gap_notices(sor, project_id, _gaps, source_task_id=next_tid)
except Exception as _e:
logger.warning(f"_check_orchestration_gaps failed: {_e}")
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(project_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 "")
# 判断是否 human_task_qc(人类任务 QC,检查对象是 pipeline_human_tasks 而非交付件)
task_kind = ""
human_task_id = ""
try:
_tp = json.loads(getattr(task, "params", "") or "{}")
if isinstance(_tp, dict):
task_kind = _tp.get("task_kind", "") or ""
human_task_id = _tp.get("human_task_id", "") or ""
except Exception:
pass
# 机构 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)
space_dir = await _get_space_dir(sor, project_id)
try:
await sor.sqlExe("COMMIT", {})
except Exception:
pass
if task_kind == 'human_task_qc' and human_task_id:
# 人类任务 QC:读 pipeline_human_tasks 的处理结果作为检查对象
ht_recs = await sor.sqlExe(
"SELECT title, description, result_data FROM pipeline_human_tasks WHERE id=${hid}$",
{"hid": human_task_id})
await sor.sqlExe("COMMIT", {})
if not ht_recs:
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": "人类任务不存在"}
ht_title = getattr(ht_recs[0], 'title', '') or ''
ht_desc = getattr(ht_recs[0], 'description', '') or ''
ht_result = getattr(ht_recs[0], 'result_data', '') or ''
deliverable_content = f"人类任务标题:{ht_title}\n任务描述:{ht_desc}\n\n处理结果:\n{ht_result}"
deliverable_type = "human_task"
title = ht_title or title
if not ht_result.strip():
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": "人类任务无处理结果"}
else:
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__', space_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, space_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', ''))
if task_kind == 'human_task_qc' and human_task_id:
from .human_task_capability import qc_human_task
await qc_human_task(human_task_id, True, decision.get('comment', ''), operator_id='agent.qc')
logger.info(f"qc_review_run approve human_task: {human_task_id} qc passed")
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 task_kind == 'human_task_qc' and human_task_id:
# 人类任务 QC 不通过:标记退回重做(qc_status=rejected + status=pending),不冒泡给 agent 角色
from .human_task_capability import qc_human_task
await qc_human_task(human_task_id, False, comment, operator_id='agent.qc')
logger.info(f"qc_review_run reject human_task: {human_task_id} 退回重做")
return {"status": "rejected", "task_id": task_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)