3615 lines
192 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Agent loop — 角色agent循环认领执行任务v3.4.0)。
v3.4.0 新增:
- 所有agent具备文件读写 + git pull/push 能力
- agent 产出实际代码文件到项目仓库,而非仅文档
- PM agent 负责 clone 项目关联仓库、初始化工作区
- develop agent 产出可直接运行的源码文件
- 每个agent完成后自动 git commit + push
任务链requirement → design → develop → test → deploy
"""
import asyncio
import hashlib
import json
import os
import re
import subprocess
import logging
import time
from contextlib import asynccontextmanager
logger = logging.getLogger("pipeline.agent_loop")
from .workspace import WORKSPACE_BASE, 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',
# deploy 歧义归测试环境部署(与 RoleSpec aliases 对齐;生产用 deploy_prod/release
'deploy': 'agent.deploy_test', 'deploy_test': 'agent.deploy_test',
'qc': 'agent.qc', 'quality': 'agent.qc', 'quality_control': 'agent.qc', '质量控制': 'agent.qc',
'operation': 'agent.ops', 'operations': 'agent.ops', 'maintenance': 'agent.ops', '运维': 'agent.ops',
'pm': 'agent.pm', 'project_manager': 'agent.pm', '项目经理': 'agent.pm', 'manager': 'agent.pm',
}
TASK_REVIEW = 'review'
TASK_APPROVED = 'approved'
# 项目过程仓库名repos/ 下阶段文档、QC 审计文档、项目管理文档放这里;应用仓库/模块仓库各自独立。
# 实际仓库名 = {项目名}_pc见 project-directory-spec 规范),此常量仅为查不到项目名时的回退默认值。
PROJECT_REPO_NAME = 'project'
# 允许的 shell 工作目录前缀(安全限制)
_ALLOWED_WORKDIRS = [
os.path.expanduser('~/pipeline_ws'),
'/d/pipeline/workspaces',
'/tmp/pipeline_workspaces',
'/tmp/pipeline_ws',
]
def _normalize_role(role):
"""角色规范化:别名映射 + 补 agent. 前缀(人角色 {orgtype}.{role} 保留原样)。"""
r = (role or '').strip().lower()
r = ROLE_ALIASES.get(r, r)
if r and '.' not in r:
r = f"agent.{r}"
return r
async def _resolve_pipeline_id(project_id):
"""从项目解析 pipeline_id能力包 key为空时 fallback 到默认产线。"""
pid = ""
try:
db = _get_db()
async with db.sqlorContext("pipeline") as sor:
recs = await sor.sqlExe(
"SELECT pipeline_id FROM sd_projects WHERE id=${pid}$", {"pid": project_id})
if recs:
pid = getattr(recs[0], "pipeline_id", "") or ""
except Exception:
pass
if not pid:
try:
from pipeline_core import DEFAULT_ABILITY_ID
pid = DEFAULT_ABILITY_ID
except ImportError:
pass
return pid
async def _resolve_role(project_id, role):
"""从能力包解析角色定义,返回 (normalized_role, role_specific_prompt, next_role)。"""
pid = await _resolve_pipeline_id(project_id)
try:
from pipeline_core import get_role_spec
spec = get_role_spec(pid, role)
if spec:
return spec.name, spec.system_prompt, spec.next_role
except Exception:
pass
norm = _normalize_role(role)
return norm, "", ""
async def _resolve_llm_context(sor, project_id, role, model_name=None):
"""解析角色 agent 的 LLM 上下文:返回 (model_name, org_id)。
model_name 解析链:
1. 显式传入的 model_name
2. 项目-角色-模型sd_project_role_models设置界面配的
3. 用户当前模型(项目创建者 created_by → pipeline_agent_settings.default_llm_id → llm.name缺省
4. RoleSpec.model_name角色专属模型
5. 产线 default_modelpipelines.default_model
6. 全局默认 "deepseek-v4-pro"
org_id从 sd_projects.org_id 读,传给 llm_bridge 做多租户隔离
llm 表查询只取「本机构 + 系统级 org_id='0'」的模型)。
"""
org_id = ""
pipeline_id = ""
default_model = ""
try:
recs = await sor.sqlExe(
"SELECT org_id, pipeline_id, default_model FROM sd_projects WHERE id=${pid}$", {"pid": project_id})
if recs:
org_id = getattr(recs[0], "org_id", "") or ""
pipeline_id = getattr(recs[0], "pipeline_id", "") or ""
default_model = getattr(recs[0], "default_model", "") or ""
except Exception:
pass
# 2. 项目-角色-模型(设置界面配的,最高优先)
if not model_name and project_id:
try:
recs = await sor.sqlExe(
"SELECT model_name FROM sd_project_role_models WHERE project_id=${pid}$ AND role=${r}$",
{"pid": project_id, "r": role})
if recs and getattr(recs[0], "model_name", ""):
model_name = getattr(recs[0], "model_name", "")
except Exception:
pass
# 3. 项目缺省模型创建项目时记录的「当前会话模型」session 级缺省)
if not model_name and default_model:
model_name = default_model
if not model_name:
# 4. RoleSpec.model_name角色专属模型
try:
from pipeline_core import get_role_spec
spec = get_role_spec(pipeline_id or await _resolve_pipeline_id(project_id), role)
if spec and spec.model_name:
model_name = spec.model_name
except Exception:
pass
if not model_name and pipeline_id:
# 5. 产线 default_model
try:
recs = await sor.sqlExe(
"SELECT default_model FROM pipelines WHERE id=${pid}$", {"pid": pipeline_id})
if recs:
model_name = getattr(recs[0], "default_model", "") or ""
except Exception:
pass
if not model_name:
# 6. 全局默认
model_name = "deepseek-v4-pro"
return model_name, org_id
async def _check_org_llm(sor, org_id):
"""检测机构是否配置了 LLM 模型(严格本机构隔离,不含系统级兜底)。
返回 (missing: bool, available_names: list)。org_id 为空或 '0'(系统级)时不过滤,
视为已配置(系统级模型对超管可用)。机构没配 llm 时角色/pm/qc 应冒泡问题暂停。
"""
if not org_id or org_id == '0':
return False, []
try:
recs = await sor.sqlExe(
"SELECT name FROM llm WHERE org_id=${org}$ AND status='active'", {"org": org_id})
names = [getattr(r, "name", "") or "" for r in (recs or [])]
return (len(names) == 0), names
except Exception:
return False, []
def _get_db():
from sqlor.dbpools import DBPools
db = DBPools()
if not db.databases:
from appPublic.jsonConfig import getConfig
config = getConfig()
if config.databases:
db.databases = config.databases
return db
def _resolve_workspace(workspace_dir):
if not workspace_dir:
workspace_dir = os.path.join(WORKSPACE_BASE, 'default')
try:
parent = os.path.dirname(workspace_dir)
if parent and not os.access(parent, os.W_OK):
proj_name = os.path.basename(workspace_dir)
workspace_dir = os.path.join(WORKSPACE_BASE, proj_name)
except Exception:
workspace_dir = os.path.join(WORKSPACE_BASE, os.path.basename(workspace_dir) or "default")
os.makedirs(workspace_dir, exist_ok=True)
return workspace_dir
# ── Agent 工具函数 ──
_allowed_workdirs_cache = None
_allowed_workdirs_cache_time = 0
async def _get_allowed_workdirs():
"""动态读 workspace_base 参数params 表合并到允许目录列表60s 缓存)。"""
global _allowed_workdirs_cache, _allowed_workdirs_cache_time
import time as _t
now = _t.time()
if _allowed_workdirs_cache is not None and (now - _allowed_workdirs_cache_time) < 60:
return _allowed_workdirs_cache
allowed = list(_ALLOWED_WORKDIRS)
try:
db = _get_db()
async with db.sqlorContext("pipeline") as sor:
from .workspace import get_workspace_base
base = await get_workspace_base(sor)
if base and base not in allowed:
allowed.append(base)
except Exception:
pass
_allowed_workdirs_cache = allowed
_allowed_workdirs_cache_time = now
return allowed
async def _is_safe_workdir_async(workdir):
"""检查目录是否在允许范围内(含 params 表动态 workspace_base"""
wd = os.path.abspath(workdir)
allowed = await _get_allowed_workdirs()
for a in allowed:
awd = os.path.abspath(os.path.expanduser(a))
if wd.startswith(awd):
return True
return False
# ── bwrap 沙箱执行agent 命令隔离) ──
# run_shell/run_command 的出口统一走 bwrap
# - user/pid/ipc/uts namespace 隔离网络保留git push / ssh 部署需要)
# - 系统目录 + /d/pipeline、/d/doit平台代码/配置/其他机构工作区)只读
# - 当前机构工作空间目录可写(叠在只读挂载之上 → 跨机构写隔离)
# - /tmp 为会话私有 tmpfs
# 无 bwrap → 降级为原有目录隔离(返回 sandbox: False
_BWRAP_CACHE = None
def _find_bwrap():
"""定位 bwrap 二进制(缓存结果,找不到返回 None"""
global _BWRAP_CACHE
if _BWRAP_CACHE is not None:
return _BWRAP_CACHE or None
import shutil as _sh
cand = _sh.which("bwrap")
if not cand:
for p in (
os.environ.get("BWRAP_PATH", ""),
"/d/pipeline/bin/bwrap",
"/d/pipeline/pipeline-app/bin/bwrap",
"/d/doit/bin/bwrap",
"/d/doit/pipeline-app/bin/bwrap",
):
if p and os.path.exists(p) and os.access(p, os.X_OK):
cand = p
break
_BWRAP_CACHE = cand or ""
return cand
async def _sandbox_writable_root(cwd: str) -> str:
"""计算沙箱可写挂载根:
- cwd 在 workspace_base含 params 表动态值)下 → 取机构级子目录(机构隔离)
- 其他允许目录 → cwd 本身
"""
bases = []
try:
db = _get_db()
async with db.sqlorContext("pipeline") as sor:
from .workspace import get_workspace_base
b = await get_workspace_base(sor)
if b:
bases.append(os.path.abspath(b))
except Exception:
pass
bases.append(os.path.abspath(WORKSPACE_BASE))
seen = set()
for base in bases:
bp = base.rstrip("/")
if bp in seen:
continue
seen.add(bp)
if cwd == bp or cwd.startswith(bp + "/"):
rel = cwd[len(bp):].lstrip("/")
seg = rel.split("/")[0] if rel else ""
if seg:
root = os.path.join(bp, seg)
if os.path.isdir(root):
return root
return cwd
return cwd
def _build_agent_bwrap_cmd(bwrap: str, cwd: str, writable_root: str, command: str) -> list:
"""构建 agent 命令的 bwrap 参数(列表传参,防注入)。"""
parts = [
bwrap,
"--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts",
"--die-with-parent",
"--ro-bind", "/usr", "/usr",
"--ro-bind", "/bin", "/bin",
"--ro-bind", "/sbin", "/sbin",
"--ro-bind", "/lib", "/lib",
]
if os.path.exists("/lib64"):
parts += ["--ro-bind", "/lib64", "/lib64"]
parts += [
"--ro-bind", "/etc", "/etc",
"--ro-bind", "/sys", "/sys",
"--proc", "/proc",
"--dev", "/dev",
"--tmpfs", "/tmp",
]
# DNSsystemd-resolved 系统 /etc/resolv.conf 是软链 → /run/resolvconf/resolv.conf
# 沙箱内软链断 → 把真实文件所在目录只读挂进去(普通文件已被 /etc 挂载覆盖)。
try:
rc = os.path.realpath("/etc/resolv.conf")
if os.path.isfile(rc) and os.path.dirname(rc) != "/etc":
parts += ["--ro-bind", os.path.dirname(rc), os.path.dirname(rc)]
except Exception:
pass
# 平台目录整体只读(代码/配置/密钥/其他机构工作区可读不可写)
for ro_dir in ("/d/pipeline", "/d/doit"):
if os.path.isdir(ro_dir):
parts += ["--ro-bind", ro_dir, ro_dir]
# 当前机构工作目录可写(叠在只读挂载之上;/tmp 下的工作目录叠在 tmpfs 之上)
if writable_root and os.path.isdir(writable_root):
parts += ["--bind", writable_root, writable_root]
parts += ["--chdir", cwd, "--", "bash", "-c", command]
return parts
async def _run_shell(command, workdir, timeout=120):
"""安全执行 shell 命令(优先 bwrap 沙箱)。返回 {"rc","stdout","stderr","sandbox"}"""
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} 不在允许范围", "sandbox": False}
if not os.path.isdir(cwd):
return {"rc": -1, "stdout": "", "stderr": f"目录不存在: {cwd}", "sandbox": False}
bwrap = _find_bwrap()
try:
if bwrap:
writable_root = await _sandbox_writable_root(cwd)
cmd = _build_agent_bwrap_cmd(bwrap, cwd, writable_root, command)
# bwrap 用列表直接执行(非 shell 拼接);命令本身仍由沙箱内 bash -c 解释
proc = await asyncio.create_subprocess_exec(
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
else:
# 降级:无 bwrap保持原有目录隔离弱隔离标记 sandbox: False
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", "sandbox": bool(bwrap)}
return {"rc": proc.returncode or 0,
"stdout": stdout.decode('utf-8', 'replace')[-8000:],
"stderr": stderr.decode('utf-8', 'replace')[-4000:],
"sandbox": bool(bwrap)}
except Exception as e:
return {"rc": -1, "stdout": "", "stderr": str(e)[:500], "sandbox": bool(bwrap)}
def _inject_org_ssh_key(command: str, org_id: str) -> str:
"""给 ssh/scp 命令注入机构独立密钥(每机构一把,防单点泄露)。
部署工程师deploy_test/deploy_prodSSH 到目标机时,默认会走 ~/.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 + pushdevelop 用它提交模块仓库本地提交作为产出证据;无远程只 commit 不 push非 git 目录自动 init","params":{"message":"提交信息","repo_dir":"仓库子目录(可选)"}},
{"name":"ask_question","description":"向用户提问(缺少信息时使用)。需要用户提供文件或结构化信息时用 form 声明动态表单,用户可在待办里直接上传/填写完成","params":{"question":"问题","form":"可选,JSON:{\"fields\":[{\"name\":\"字段名\",\"type\":\"file|text|textarea\",\"label\":\"提示\",\"target\":\"文件目标相对路径(如 env/test.json,目录以/结尾保留原文件名)\",\"required\":true}]}"}},
{"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。
## QC 门禁质量权威2026-08-31 起)
- 需求/设计/开发__ROLE__ 为 requirement/design/develop 时)的交付件到达你之前,已经过 QC 契合度门禁:逐项核对、契合度>9.5 才放行,退回意见精确到核对项。
- **这三个阶段你不再复审内容质量**,禁止输出 review_reject 做内容否决(质量判定权在 QC你的职责收敛为编排确认交付件存在 → 需要拆分的先 create_tasks 派发design 按模块清单派 develop 任务)→ review_approve 推进。
- 例外(逃逸阀):发现系统性缺陷(如设计根本性错误影响下游全部成果)可用 review_rollback 回退rollback 会在后续阶段暴露问题时使用,不是常规手段。
- deploy_test/test/deploy_prod 阶段仍由你做完整验收(下述审核标准照旧)。
## 任务分解与编排(先评估,再拆解)
- 派发前先评估:当前任务是否「过于复杂」(涉及多个模块/应用、多个独立交付单元、工作量超单 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内容质量由 QC 门禁把关(契合度>9.5 才到达你),你只确认交付件存在 + 需要拆分的先 create_tasks 派发 → review_approve
- design内容质量由 QC 门禁把关,你按 modules/*.md 的模块清单用 create_tasks 派 develop 任务 → review_approve
- developQC 把关内容你只确认代码文件实际产出list_files 存在性检查)→ review_approve
- deploy_test测试环境部署配置完整、服务可访问
- test测试覆盖充分、发现问题记录完整
- deploy_prod生产部署配置完整、可一键部署、有回滚方案
## 回退规则test 阶段)
- 测试提交 Bug 后,零散 Bug → 走 Bug 闭环report_bug→fix_bug→verify_bug不打断任务链。
- 系统性缺陷Bug 多/严重、设计有缺陷、需求理解错、部署有问题)→ review_rollbackrollback_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. 先按对应审查技能load_skill 加载)构建**核对项清单**——每项必须机械可判定(文件在不在/格式对不对/需求点有没有被响应),不做主观打分。
2. 逐项判定:每项只输出 过/不过 + 证据(文件路径/位置/缺失说明)。
3. 算分:**契合度 = 10 × 通过项数 / 总项数**,保留两位小数。
4. 决策:契合度 > 9.5 → review_approve否则 review_reject退回意见=未过项清单,编号+位置+问题+改法)。
5. 审查技能标注的硬门禁项(需求覆盖、应用脚手架禁项等)任一不过 → 无论总分多少一律 review_reject。
## 检查维度
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 加载被查角色对应的审查技能agent.requirement→review-requirement、agent.design→review-design、agent.develop→review-develop、agent.test→review-test、agent.deploy_*→review-deploy按其核对项清单执行不要凭记忆检查。
## 工具
- load_skill(name, file_path?) — 按需加载技能全文或 references/scripts/templates 子文件(需要具体规范/路径/格式时用)
- list_features() — 查功能清单(审查需求/设计时用于功能落库与需求覆盖核对)
- 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":"契合度 9.75(通过 39/总 40……摘要……","score":9.75,"passed":39,"total":40}
不合规退回:{"action":"review_reject","comment":"契合度 8.50(通过 17/总 20未达 9.5","score":8.5,"passed":17,"total":20,"questions":"[#1] 位置:…… 问题:…… 改法:……(逐条列出未过项)"}
## 检查流程
1. load_skill 加载对应审查技能,构建核对项清单
2. 逐项机械判定(用 list_features/read_file/list_files/run_shell 核实真实性,不轻信交付说明)
3. 算契合度 = 10 × 通过项/总项
4. > 9.5 且无硬门禁未过项 → 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 个前置结束即启动
# 不变量(任何策略下都成立,代码守门):
# · 依赖任务不存在 → 阻塞(可能是编造 IDD1/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_policydict 或 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'
# env_file 是绝对路径({pdir}/env/{env}.jsontarget 取项目内相对路径
_rel = 'env/'
_m = re.search(r'/env/([^/]+)$', env_file or '')
if _m:
_rel = 'env/' + _m.group(1)
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**请直接在下方上传该环境配置文件**(含完整部署信息的 JSON"
"提交后任务自动恢复执行、重新校验环境信息)。"
)
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,
form_schema={"fields": [{
"name": "env_file", "type": "file",
"label": f"环境配置文件({_rel})——需含以下字段:" + "".join(missing),
"target": _rel, "required": True, "accept": ".json",
}]},
)
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 ''
if ws:
return _resolve_workspace(ws)
# 存量项目无 workspace_dir兜底解析项目根目录与上传落盘同一函数
# 保证「上传放的位置 = agent 读的位置」)
from .workspace import get_project_dir_by_id
_pdir, _ = await get_project_dir_by_id(sor, project_id)
if _pdir:
os.makedirs(_pdir, exist_ok=True)
return _pdir
return _resolve_workspace(ws)
async def _get_space_dir(sor, project_id):
"""返回产线工作空间(机构工作空间层){space}/——角色 agent 工具路径基准。
新规下角色在 projects/{项目}/docs/、apps/、modules/ 下读写,
这些相对路径的基准是 {space}/。用 build_space_pathorg_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 initdocs/ + 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 定调):流转决策权归 LLMPM 懂业务语义,知道模块怎么拆、依赖怎么连),
代码负责补 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 修正,
不需要再反查任务 IDG2 元景故障的教训:告警只进日志没人看,事实必须送到 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_titleLLM 懂语义,流转决策权归它),缺失时按 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_descriptionLLM 按阶段语义写)> 阶段兜底描述。
_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=Truedesign 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=Trueapprove 后被
# 「模块级 develop → 跳过 deploy_test」误判任务链断在 approved。与 _create_next_task 的 pop 对齐。
new_params.pop('pm_assigned', None)
if prev_task:
new_params['previous_task_id'] = getattr(prev_task, 'id', '')
new_params['previous_role'] = _normalize_role(getattr(prev_task, 'role', ''))
else:
new_params.pop('previous_task_id', None)
new_params.pop('previous_role', None)
new_tid = getID()
new_title = f"{getattr(target_task, 'title', '') or '任务'}(回退重做)"
await sor.C('pipeline_tasks', {
'id': new_tid, 'tenant_id': project_id, 'pipeline_id': 'role_task',
'owner_id': 'pm', 'title': new_title,
'params': json.dumps(new_params, ensure_ascii=False),
'role': rollback_role, 'state': 'submitted', 'claimed_by': None,
})
await sor.sqlExe("COMMIT", {})
logger.info(f"_rollback_task_chain: task={task_id} -> rollback {rollback_role}, "
f"cancelled={len(cancelled)} 任务, new_task={new_tid}")
return {"status": "rollback", "task_id": task_id, "rollback_role": rollback_role,
"cancelled": cancelled, "new_task_id": new_tid, "comment": comment}
# ── Agent 工具执行 ──
def _parse_form_schema(v):
"""解析 ask_question 的 form 参数 → 规整的 form_schema非法/空 → None
LLM 可能传 JSON 字符串或 dict字段只保留白名单键防注入。
返回 {"fields":[{name,type,label,target,required,accept},...]} 或 None。
"""
if not v:
return None
try:
schema = json.loads(v) if isinstance(v, str) else v
fields = schema.get('fields') if isinstance(schema, dict) else None
if not isinstance(fields, list) or not fields:
return None
out = []
for f in fields[:10]: # 最多 10 个字段
if not isinstance(f, dict):
continue
name = str(f.get('name') or '').strip()
ftype = str(f.get('type') or 'text').strip().lower()
if not name or ftype not in ('file', 'text', 'textarea'):
continue
target = str(f.get('target') or '').strip()
if target:
# target 只允许相对路径字符,禁绝对路径与 .. 穿越
if target.startswith('/') or '..' in target.split('/'):
target = ''
out.append({
'name': name[:40],
'type': ftype,
'label': str(f.get('label') or name)[:120],
'target': target[:200],
'required': bool(f.get('required')),
'accept': str(f.get('accept') or '')[:60],
})
return {"fields": out} if out else None
except Exception:
return None
def _parse_agent_action(raw):
raw = (raw or "").strip()
if raw.startswith("```"):
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
# deepseek v4 原生格式(全角竖线):<DSMLinvoke name="...">…<DSMLparameter name="...">value</DSMLparameter></DSMLinvoke>
# 与旧版 <invoke> 同款解析:长上下文多轮时 deepseek 会自发输出该格式,不解析则全部判「未识别」→ 5 轮耗尽。
# ⚠️ 竖线数量会变:实测出现过单竖线 <DSMLinvoke> 和双竖线 <DSMLinvoke>,用 + 兼容任意数量。
m = re.search(r'<+DSML+invoke\s+name="([^"]+)"[^>]*>(.*?)</+DSML+invoke>', raw, re.DOTALL)
if m:
tool = m.group(1).strip()
body = m.group(2)
params = {}
for pm in re.finditer(r'<+DSML+parameter\s+name="([^"]+)"[^>]*>(.*?)</+DSML+parameter>', body, re.DOTALL):
val = pm.group(2).strip()
try:
val = json.loads(val)
except (json.JSONDecodeError, ValueError):
pass
params[pm.group(1)] = val
if tool in ('deliver', 'deliver_result', 'submit', 'finish'):
return {"action": "deliver", **params}
if tool in ('ask', 'ask_question', 'ask_user'):
return {"action": "ask", "question": params.get("question", "")}
return {"action": "tool_call", "tool": tool, "params": params}
# deepseek 原生 function calling 输出 XML<tool_calls><invoke name="..."><parameter name="..." string="true">...</parameter></invoke></tool_calls>
m = re.search(r'<invoke\s+name="([^"]+)"[^>]*>(.*?)</invoke>', raw, re.DOTALL)
if m:
tool = m.group(1).strip()
body = m.group(2)
params = {}
for pm in re.finditer(r'<parameter\s+name="([^"]+)"[^>]*>(.*?)</parameter>', body, re.DOTALL):
val = pm.group(2).strip()
try:
val = json.loads(val)
except (json.JSONDecodeError, ValueError):
pass
params[pm.group(1)] = val
if tool in ('deliver', 'deliver_result', 'submit', 'finish'):
return {"action": "deliver", **params}
if tool in ('ask', 'ask_question', 'ask_user'):
return {"action": "ask", "question": params.get("question", "")}
return {"action": "tool_call", "tool": tool, "params": params}
try:
d = json.loads(raw)
if isinstance(d, dict) and 'action' in d:
return d
except (json.JSONDecodeError, ValueError):
pass
# 叙述文本里嵌的 ```json 代码块deepseek 常先说"我来加载技能"再贴 JSON tool_call
# 上面 startswith("```") 只剥离开头的围栏,处理不了带叙述前缀的;这里全文找第一个可解析的动作 JSON。
for fm in re.finditer(r'```(?:json)?\s*\n(.*?)\n?\s*```', raw, re.DOTALL):
try:
d = json.loads(fm.group(1).strip())
except (json.JSONDecodeError, ValueError):
continue
if isinstance(d, dict) and 'action' in d:
return d
# 兼容 {"tool":..,"params":..} 无 action 字段的写法
if isinstance(d, dict) and 'tool' in d:
return {"action": "tool_call", "tool": d.get("tool", ""), "params": d.get("params") or {}}
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_prodSSH 到目标机时,注入机构独立密钥
# (每机构一把,防单点泄露;否则默认走 ~/.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=rolefrontmatter 声明 `capability: feature_capability`(角色需要哪些能力),
产线状态机规范scope=pipelinefrontmatter 声明 `capability + tools`(能力含哪些工具),
这里匹配两者返回要注入的工具定义列表name/description/params/required
"""
try:
pid = await _resolve_pipeline_id(project_id)
from pipeline_core.skill_loader import get_skill_loader
from pipeline_core.skill_pack import get_skills_base
from .capability_tools import resolve_capability_tools
skills_dir = get_skills_base()
loader = get_skill_loader(skills_dir)
merged = loader.get_merged(pipeline_id=pid, role=role,
project_id=project_id, org_id=org_id or '0')
if not merged:
return []
role_caps = set()
for skill in merged.values():
if getattr(skill, 'scope', '') == 'role':
cap = getattr(skill, 'capability', '') or ''
for c in str(cap).split(','):
c = c.strip()
if c:
role_caps.add(c)
return resolve_capability_tools(role_caps, merged)
except Exception as e:
logger.warning(f"collect capability tools failed: {e}")
return []
async def _load_skill_by_name(sor, project_id, role, org_id, name, file_path=None):
"""按需加载技能全文或子文件(分层导入第二层,对应 load_skill 工具)。
- 不带 file_path返回技能 SKILL.md 全文 + 关联文件清单references/scripts/templates/assets
- 带 file_path返回 skill 目录下对应子文件内容(仅限 references/scripts/templates/assets
"""
role = _normalize_role(role) # 归一化:裸名 'qc'/'pm' → 'agent.qc'/'agent.pm',否则加载不到角色技能目录
name = (name or '').strip()
if not name:
return 'FAIL: 需要技能名称'
try:
pid = await _resolve_pipeline_id(project_id)
from pipeline_core.skill_loader import get_skill_loader
from pipeline_core.skill_pack import get_skills_base
skills_dir = get_skills_base()
loader = get_skill_loader(skills_dir)
merged = loader.get_merged(pipeline_id=pid, role=role,
project_id=project_id, org_id=org_id or '0')
from pipeline_core.skill_loader import resolve_skill, normalize_skill_name, format_skill_names
skill = resolve_skill(merged, name)
if skill is None:
# 项目模块技能(机构工作空间 modules/*/skill + apps/*/skill不在 skill_loader 静态树里,运行时补查。
# 全文层对齐 skill_loader.to_prompt_block剥离 frontmatter 只返回正文body
cleaned = normalize_skill_name(name)
try:
ws = await _get_space_dir(sor, project_id)
for m in _collect_module_skills(ws):
if m['name'] in (name, cleaned) or m['repo'] in (name, cleaned):
return f"## [项目模块] {m['name']}\n{m['description']}\n\n{m['body']}"
except Exception:
pass
names = format_skill_names(merged)
return (f"FAIL: 技能 '{name}' 不存在。注意name 只传技能裸名"
f"(如 project-directory-spec不要带 [产线] 前缀或描述文字。可用技能: {names}")
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':
# PM 常规任务走 pm_review_runpm_pollerstate='review')。
# 例外项目复盘任务task_kind=retrospective是 submitted/role_task 队列,
# 由 agent_poller 派发到这里。复盘执行器自认领CAS 防双执行);
# 无复盘任务时返回 idle与旧行为一致PM 提示语仅保留给常规路径)。
return await retrospective_run(project_id, agent_id=agent_id, model_name=model_name)
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 os.path.join(space_dir, 'projects', project_id)
# 能力工具上下文project_id/iteration_id/who/agent_id 自动注入LLM 不可见)
_iter = None
try:
from .iteration_capability import get_current_iteration
_iter = await get_current_iteration(sor, project_id)
except Exception:
_iter = None
capability_ctx = {
"project_id": project_id,
"iteration_id": _iter.get('id', '') if _iter else '',
"who": role,
"agent_id": agent_id,
"task_id": task_id,
"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
ask_form = None # ask_question 附带的动态表单声明form_schema
written_files = [] # 本任务执行期间 write_file 实际写入的文件(无 deliver 时的产出兜底)
# ── Tool Loop原生 function calling──
for turn in range(30):
# 心跳:标记任务仍在执行,供 stale 回收判断(进程崩溃后任务不再被 touch 即被回收)。
# 必须 COMMIT 让心跳对其他连接可见,否则 poller 看不到心跳会误判为僵尸。
await sor.sqlExe(
"UPDATE pipeline_tasks SET updated_at=NOW() WHERE id=${tid}$ AND state='running'",
{"tid": task_id})
await sor.sqlExe("COMMIT", {})
# 取消检测:任务被 cancel_task 标记 cancelled 后立即中止,避免与重派的新任务重复执行
_st = await sor.sqlExe("SELECT state FROM pipeline_tasks WHERE id=${tid}$", {"tid": task_id})
await sor.sqlExe("COMMIT", {})
if _st and getattr(_st[0], 'state', '') == 'cancelled':
logger.info(f"task cancelled mid-run: {task_id}")
return {"status": "cancelled", "task_id": task_id}
# 强制产出:第 6 轮起注入,打断「了解现状/反复验证」探索死循环,强制转向 write_file + deliver
if turn >= _FORCE_PRODUCE_TURN:
msgs.append({"role": "user", "content": _FORCE_PRODUCE_HINT})
try:
resp = await asyncio.wait_for(
llm_call_msgs_native(msgs, tools=tools_schema, model=model_name, temperature=0.4, org_id=org_id),
timeout=_LLM_HARD_TIMEOUT)
except Exception as e:
err_msg = f"{type(e).__name__}: {str(e)[:400]}"
from .task_capability import mark_failed
await mark_failed(task_id, project_id, who=role, agent_id=agent_id, error=err_msg)
logger.error(f"role_agent_run llm failed: task={task_id} err={e}")
return {"status": "failed", "task_id": task_id, "error": str(e)[:200]}
native_calls = (resp.get("tool_calls") or []) if isinstance(resp, dict) else []
if native_calls:
# 回填 assistant含 tool_callsOpenAI 原生格式)
msgs.append({"role": "assistant", "content": resp.get("content") or None, "tool_calls": native_calls})
for tc in native_calls:
fn = tc.get("function", {}) if isinstance(tc, dict) else {}
tool = fn.get("name", "")
try:
params = json.loads(fn.get("arguments") or "{}")
except Exception:
params = {}
if tool == "deliver":
deliverable = {"action": "deliver", **params}
break
if tool == "ask_question":
ask_question = params.get("question", "")
ask_form = _parse_form_schema(params.get("form"))
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', '')
ask_form = _parse_form_schema(act.get('form'))
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},
form_schema=ask_form)
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默认 = 本次审核的里程碑任务),记录父子关系供任务树分层;
- keyLLM 给子任务起的短标识,供同批任务间 depends_on 相互引用;
- depends_on依赖的 key 或任务ID 数组(空 = 并行;非空 = 串行等待依赖完成后才可认领)。
兼容单任务形态params 直接含 {title, role, description}。
"""
from appPublic.uniqueID import getID
tasks = params.get('tasks') or []
if isinstance(tasks, str):
try:
tasks = json.loads(tasks)
except (json.JSONDecodeError, ValueError):
return 'FAIL: tasks 必须是 JSON 数组'
if not tasks:
if params.get('title'):
tasks = [{'title': params.get('title'), 'role': params.get('role'), 'description': params.get('description')}]
else:
return 'FAIL: 需要 tasks 数组或 title'
if not isinstance(tasks, list):
return 'FAIL: tasks 必须是数组'
# 默认父任务:本次审核的里程碑任务(子任务挂它名下,任务树据此分层)
default_parent = (params.get('parent_id') or parent_task_id or '').strip()
# 当前迭代status='in_progress' 的唯一迭代),作为任务默认归属
iteration_name = ''
from .iteration_capability import get_current_iteration
cur = await get_current_iteration(sor, project_id)
if cur:
iteration_name = cur.get('iteration_name', '') or ''
created = [] # [(task_dict, tid, title, role)]
key_map = {} # key → 真实任务ID同批 depends_on 用 key 互引,第二遍解析)
cancelled = []
warnings = []
for t in tasks:
if not isinstance(t, dict):
continue
title = (t.get('title') or '').strip()
if not title:
continue
role = _normalize_role(t.get('role') or 'agent.develop')
desc = (t.get('description') or '').strip()
key = (t.get('key') or '').strip()
parent = (t.get('parent_id') or '').strip() or default_parent
# 重做保护:同项目存在同标题活跃任务(submitted/running/review/qc_review)时,
# 先自动取消旧任务再创建新任务,避免两个相同任务并存(重做必须先终止旧任务)。
dup = await sor.sqlExe(
"SELECT id FROM pipeline_tasks WHERE tenant_id=${pid}$ AND title=${title}$ "
"AND state IN ('submitted','running','review','qc_review')",
{"pid": project_id, "title": title})
for d in (dup or []):
did = getattr(d, 'id', '')
await sor.sqlExe(
"UPDATE pipeline_tasks SET state='cancelled', claimed_by=NULL, updated_at=NOW() WHERE id=${did}$",
{"did": did})
cancelled.append(did)
tparams = {"description": desc, "pm_assigned": True}
# 任务来源标记PM 派发的任务按 title/description 判断——
# 含「修复 Bug」= bug_fixdevelop 必须走 fix_bug 状态机);否则 = new_dev模块开发等
_title_desc = f"{title} {desc}"
if '修复 Bug' in _title_desc or '修复Bug' in _title_desc or '修复bug' in _title_desc:
tparams['task_kind'] = 'bug_fix'
else:
tparams['task_kind'] = 'new_dev'
if iteration_name:
tparams["iteration_id"] = iteration_name
# 启动策略可选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_onkey 或 任务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_taskCAS + 审计),正在执行中的 agent 会在下一轮心跳
检测到 cancelled 并自行中止(见 role_agent_run 的取消检测)。
"""
task_id = (params.get('task_id') or params.get('id') or '').strip()
if not task_id:
return 'FAIL: 需要 task_id'
recs = await sor.sqlExe(
"SELECT id, title, state FROM pipeline_tasks WHERE id=${tid}$ AND tenant_id=${pid}$",
{"tid": task_id, "pid": project_id})
await sor.sqlExe("COMMIT", {})
if not recs:
return 'FAIL: 任务不存在或不属于当前项目'
title = getattr(recs[0], 'title', '')
state = getattr(recs[0], 'state', '')
if state in ('completed', 'cancelled', 'failed', 'approved'):
return f'任务「{title}」已是终态 {state},无需取消'
from .task_capability import cancel_task
ok, msg = await cancel_task(task_id, project_id, who="agent.pm")
if not ok:
return f'FAIL: {msg}'
return f'OK: 已取消任务「{title}」({task_id}),正在执行的 agent 将在下一轮心跳中止'
async def _pm_update_task_deps(sor, project_id, params):
"""PM 给已存在任务补前置依赖G2/G3 查漏的修正出口)。
代码只发现缺口并把事实交给 PM流转决策权归 LLMPM 核实后用本原语修正。
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 "")
# 豁免兜底2026-09-03产线自带质量门禁的任务如投标产线
# params.task_kind=bid_* / skip_generic_qcPM 不复审内容,直接放行——
# 质量判定归产线流转引擎(章节评审打分 + 解析产出契合度审核)。
_skip_gqc = False
try:
_tpr = json.loads(getattr(task, "params", "") or "{}")
if isinstance(_tpr, dict):
_skip_gqc = bool(_tpr.get("skip_generic_qc")) or \
str(_tpr.get("task_kind", "") or "").startswith("bid_")
except Exception:
pass
if _skip_gqc:
# 先提交认领_claim_task 的 UPDATE 在本连接未提交),
# 否则 approve_task 在另一连接更新同一行会等自己的锁超时1205
try:
await sor.sqlExe("COMMIT", {})
except Exception:
pass
from .task_capability import approve_task
await approve_task(task_id, project_id, who="agent.pm", agent_id=agent_id,
comment="产线自带质量门禁PM 豁免内容复审")
logger.info(f"pm_review_run exempt: task={task_id} -> approved (pipeline-owned QC)")
return {"status": "exempted", "task_id": task_id}
# 机构 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 os.path.join(space_dir, 'projects', project_id)
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'
# 防御2026-08-31 QC 门禁改造):需求/设计/开发三阶段的质量判定权在 QC契合度>9.5 放行),
# PM 不再复审内容。LLM 偶发无视 prompt 输出 review_reject 时,代码兜底修正为 approved 推进——
# 否则 PM 否决与 QC 放行互相打架,交付件在 review↔submitted 间震荡,任务链停摆。
# 系统性缺陷仍允许走 review_rollback逃逸阀这里只拦内容否决型 rejected。
if status == 'rejected' and task_role in ('agent.requirement', 'agent.design', 'agent.develop'):
logger.warning(f"pm_review_run {task_role} 交付件已过 QC 门禁PM 内容否决无效,修正为 approved: task={task_id}")
comment = f"QC 门禁已通过PM 内容否决被系统修正){comment}"
status = 'approved'
decision['status'] = 'approved'
decision['comment'] = comment
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 "迭代已结束,链终止"}
# 部署以应用为单位:模块级 developPM 按模块派发params 含 pm_assignedapproved 后
# 不创建 deploy_test——模块不独立部署deploy_test 是应用级,由应用脚手架 develop 触发。
# 否则每个模块都会生成一个 deploy_test导致「部署以模块为单位」多端口问题的部署侧根源
_tp = {}
try:
_tp = json.loads(getattr(task, 'params', '{}') or '{}')
except Exception:
_tp = {}
# 模块级 develop = PM 用 create_tasks 直接派发previous_role 为空);应用级 develop =
# 系统派生/回退重做previous_role=agent.design。用 previous_role 区分而非 pm_assigned——
# pm_assigned 会被 design 任务污染design 也是 PM create_tasks 派发、带 pm_assigned=True
# 派生的应用级 develop 曾因此误判为「模块级」跳过 deploy_test
if task_role == 'agent.develop' and not _tp.get('previous_role'):
logger.info(f"模块级 develop approved跳过 deploy_test部署以应用为单位: {task_id}")
return {"status": "approved", "task_id": task_id, "comment": comment,
"next_role": "", "skip_next": "module_not_deployed_independently"}
# 需求/设计人工确认节点已移除2026-08-31 QC 门禁改造):
# 需求分析/设计评审不再设人工审核QC 门禁qc_review 阶段,契合度>9.5 放行)
# 是这两个阶段的质量权威QC 通过后 PM 只做编排派发,直接 _create_next_task
# 创建下一角色任务(原 requirement_confirmation/design_confirmation 人工确认删除)。
# 把 PM 给出的 next_task_title / next_task_description 传下去(原实现只传 comment
# PM 的这两个字段被静默丢弃 → 派生任务只能继承上游 title/description正是
# 「design 任务名跟需求任务名一样」和「应用级 develop 拿到需求描述」的根源)。
# 流转决策权归 LLMPM 写了就用 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:
# 项目完结(无下一阶段)→ 后置创建项目复盘任务(非阻塞,失败不影响完结)
await _spawn_retrospective(sor, project_id, source_task_id=task_id)
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)
# 项目完结review_complete 路径)→ 后置创建项目复盘任务(幂等,非阻塞)
await _spawn_retrospective(sor, project_id, source_task_id=task_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 = ""
skip_generic_qc = False
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 ""
skip_generic_qc = bool(_tp.get("skip_generic_qc"))
except Exception:
pass
# 豁免兜底2026-09-03声明 skip_generic_qc 的产线任务自带质量门禁
# (如投标产线的章节评审 + 契合度审核),通用门禁不接管;存量卡在此状态
# 的任务直接放行,避免被当「交付件合规检查」反复拒回烧轮次。
# task_kind=bid_* 兜底覆盖改造前创建的存量任务params 里没有豁免标记)。
if skip_generic_qc or task_kind.startswith("bid_"):
# 先提交认领_claim_task 的 UPDATE 在本连接未提交),
# 否则 qc_exempt_task 在另一连接更新同一行会等自己的锁超时1205
try:
await sor.sqlExe("COMMIT", {})
except Exception:
pass
from .task_capability import qc_exempt_task
await qc_exempt_task(task_id, project_id, who="agent.qc", agent_id=agent_id,
comment="产线自带质量门禁豁免通用QCskip_generic_qc/bid_*")
logger.info(f"qc_review_run exempt: task={task_id} -> approved "
f"(skip_generic_qc={skip_generic_qc} task_kind={task_kind})")
return {"status": "exempted", "task_id": task_id}
# 机构 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}"})
# 能力工具上下文list_features 需要 project_idQC 审查需求/设计时核对功能落库)
capability_ctx = {
"project_id": project_id, "iteration_id": "", "who": "agent.qc",
"agent_id": agent_id, "task_id": task_id, "org_id": org_id or '0',
}
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, capability_ctx)
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}
# ── 项目复盘2026-08-31 新增:项目完结后 PM 回顾 → 技能提议)──
RETRO_SYSTEM_PROMPT = """你是项目经理PM正在执行「项目复盘」任务。项目已执行完毕你负责回顾执行过程把遇到的问题和处理方法沉淀为技能提议让同类问题在下个项目被预防。
## 工作环境
工作空间__WORKSPACE__
项目目录__PROJECT_DIR__
## 技能
__ROLE_SKILLS__
先 load_skill 加载 role 技能(含「项目复盘」章节的流程与纪律),再执行。
## 工具
- load_skill(name) — 加载技能全文(复盘流程在 role 技能里)
- project_retrospective_data() — 取本项目全部问题素材(冒泡/退回重做/编排缺口/Bug 四类,含解决方法)。素材已附在本轮输入中,此工具供你重新拉取。
- propose_skill(name, description, content) — 提交技能提议content 为 SKILL.md 草稿,头部标 <!-- target: ... -->,四段:触发条件/问题现象/根因/处理方法)
- write_file(path, content) — 写复盘报告
- read_file(path) / list_files(path) — 读文件/列目录
## 流程
1. 通读下方问题素材,逐条判定可复用性(跨项目会再发生=可复用;本项目特有的业务偏差=一次性)。
2. 可复用问题:每条(同目标同要点合并)调 propose_skill 提交;目标技能按问题发生角色定位。
3. 一次性问题:不提提议,只在报告中写明理由。
4. write_file 写复盘报告到 projects/__PROJECT_NAME__/docs/02-retrospective/retrospective.md五段结构统计/问题清单/提议清单/未提议问题及理由/结论,对照 sdlc-repo-standard 技能)。
5. 输出 deliver 提交。
## 输出格式每次一个JSON
调用工具:{"action":"tool_call","tool":"propose_skill","params":{"name":"...","description":"...","content":"..."}}
提交:{"action":"deliver","summary":"复盘完成N 条提议","result":"复盘摘要"}"""
async def _spawn_retrospective(sor, project_id, source_task_id=''):
"""项目完结后创建项目复盘任务(幂等:每项目一个未取消的复盘任务)。
复盘是后置非阻塞任务:失败不影响项目 completed 状态,只走 failed 冒泡。
"""
try:
dup = await sor.sqlExe(
"SELECT id FROM pipeline_tasks WHERE tenant_id=${pid}$ AND role='agent.pm' "
"AND params LIKE ${pat}$ AND state<>'cancelled' LIMIT 1",
{"pid": project_id, "pat": '%"task_kind": "retrospective"%'})
await sor.sqlExe("COMMIT", {})
if dup:
return ''
from appPublic.uniqueID import getID
tid = getID()
params = {
"task_kind": "retrospective",
"source_task_id": source_task_id or '',
"description": ("项目执行完毕,对执行过程做复盘:收集本项目遇到的问题和处理方法,"
"判定可复用性,可复用问题提交技能提议(进技能管理建议列表),"
"产出复盘报告落盘(格式见 sdlc-repo-standard 复盘章节)。"),
}
await sor.C('pipeline_tasks', {
'id': tid, 'tenant_id': project_id, 'pipeline_id': 'role_task',
'owner_id': 'pm', 'title': '项目复盘',
'params': json.dumps(params, ensure_ascii=False),
'role': 'agent.pm', 'state': 'submitted', 'claimed_by': None,
})
await sor.sqlExe("COMMIT", {})
logger.info(f"retrospective spawned: task={tid} project={project_id}")
return tid
except Exception as e:
logger.warning(f"_spawn_retrospective failed: project={project_id} err={e}")
return ''
async def retrospective_run(project_id, agent_id=None, model_name=None):
"""PM 项目复盘执行器:认领复盘任务 → 收集问题素材 → LLM 判定可复用性并提交技能提议
→ 写复盘报告 → 任务 completed。失败走 mark_failedfailed poller 冒泡),不影响项目状态。
"""
from appPublic.uniqueID import getID
db = _get_db()
async with db.sqlorContext("pipeline") as sor:
model_name, org_id = await _resolve_llm_context(sor, project_id, 'pm', model_name)
# 认领CASclaimed_by IS NULL 防双执行)
recs = await sor.sqlExe(
"SELECT id, title, params FROM pipeline_tasks "
"WHERE tenant_id=${pid}$ AND state='submitted' AND role='agent.pm' "
"AND params LIKE ${pat}$ AND (claimed_by IS NULL OR claimed_by='') "
"ORDER BY created_at ASC LIMIT 1",
{"pid": project_id, "pat": '%"task_kind": "retrospective"%'})
await sor.sqlExe("COMMIT", {})
if not recs:
return {"status": "idle", "message": "没有待复盘任务"}
task_id = getattr(recs[0], 'id', '')
claim_token = agent_id or getID()
await sor.sqlExe(
"UPDATE pipeline_tasks SET state='running', claimed_by=${cb}$, updated_at=NOW() "
"WHERE id=${tid}$ AND state='submitted' AND (claimed_by IS NULL OR claimed_by='')",
{"cb": claim_token, "tid": task_id})
chk = await sor.sqlExe("SELECT state, claimed_by FROM pipeline_tasks WHERE id=${tid}$", {"tid": task_id})
await sor.sqlExe("COMMIT", {})
if not chk or getattr(chk[0], 'state', '') != 'running' or getattr(chk[0], 'claimed_by', '') != claim_token:
return {"status": "idle", "message": "复盘任务认领竞争失败"}
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 os.path.join(space_dir, 'projects', project_id)
await sor.sqlExe("COMMIT", {})
# 问题素材代码统一收集PM 不重复实现查询)
from .retrospective_capability import project_retrospective_data
materials = await project_retrospective_data(project_id)
role_skills = await _build_role_skills_block(sor, project_id, _normalize_role('pm'), org_id)
retro_system = RETRO_SYSTEM_PROMPT\
.replace('__WORKSPACE__', space_dir)\
.replace('__PROJECT_DIR__', project_dir)\
.replace('__PROJECT_NAME__', project_name or '')\
.replace('__ROLE_SKILLS__', role_skills)
msgs = [{"role": "system", "content": retro_system}]
msgs.append({"role": "user", "content": f"本项目的问题素材JSON\n\n{materials}"})
capability_ctx = {
"project_id": project_id, "iteration_id": "", "who": "agent.pm",
"agent_id": agent_id, "task_id": task_id, "org_id": org_id or '0',
}
from .llm_bridge import llm_call_msgs
deliverable = None
proposals_made = 0
for turn in range(10):
await sor.sqlExe(
"UPDATE pipeline_tasks SET updated_at=NOW() WHERE id=${tid}$ AND state='running'",
{"tid": task_id})
await sor.sqlExe("COMMIT", {})
if turn >= 7:
msgs.append({"role": "user", "content":
"已执行足够轮次。现在必须立即收尾write_file 写复盘报告(若未写),然后输出 deliver 提交,禁止再调其它工具。"})
try:
raw = await asyncio.wait_for(
llm_call_msgs(msgs, model=model_name, temperature=0.3, 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="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') == 'deliver':
deliverable = act
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, 'pm', 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 == 'propose_skill' and str(result).startswith('OK'):
proposals_made += 1
msgs.append({"role": "assistant", "content": raw})
msgs.append({"role": "user", "content": f"工具 {tool} 结果:\n{result}"})
else:
deliverable = {"result": raw}
break
if not deliverable:
from .task_capability import mark_failed
await mark_failed(task_id, project_id, who="agent.pm", agent_id=agent_id,
error="复盘任务轮次耗尽未产出交付件")
return {"status": "failed", "task_id": task_id, "error": "轮次耗尽未交付"}
# 复盘报告落盘:规范路径 + deliverables/pm/
# 报告优先取 LLM write_file 已写的完整版工具轮写盘deliver 摘要仅作兜底——
# 否则完整报告被摘要覆盖、五段结构丢失。
result_text = deliverable.get("result") or deliverable.get("summary") or ""
# 输出文件必须在项目空间内:项目名缺失时用 project_id 兜底目录名(不再落 projects/ 根)
report_rel = os.path.join('projects', project_name or project_id, 'docs', '02-retrospective', 'retrospective.md')
report_abs = os.path.join(space_dir, report_rel)
try:
if os.path.isfile(report_abs):
with open(report_abs, encoding='utf-8') as f:
on_disk = f.read()
if len(on_disk.strip()) >= len(result_text.strip()):
result_text = on_disk # write_file 版更完整,以盘上为准
else:
os.makedirs(os.path.dirname(report_abs), exist_ok=True)
with open(report_abs, 'w', encoding='utf-8') as f:
f.write(result_text or '')
except Exception as e:
logger.warning(f"retrospective report write failed: {e}")
from appPublic.uniqueID import getID as _gid
did = _gid()
await sor.C("pipeline_deliverables", {
"id": did, "project_id": project_id, "task_id": task_id,
"deliverable_type": "retrospective", "title": "项目复盘报告",
"content": result_text[:60000],
"file_path": report_abs,
"quality_score": 100, "review_status": "approved", "created_by": agent_id or "pm",
})
await sor.sqlExe("COMMIT", {})
# 复盘任务直接 completed后置任务不走 QC/PM 审核环;失败另有 mark_failed 冒泡)
from .task_capability import set_task_state
await set_task_state(task_id, project_id, 'running', 'completed',
who="agent.pm", agent_id=agent_id,
detail=f"复盘完成,提交技能提议 {proposals_made}")
logger.info(f"retrospective_run completed: task={task_id} proposals={proposals_made}")
return {"status": "completed", "task_id": task_id, "proposals": proposals_made}
# ── 失败任务处理第二层PM/cockpit 判定重跑最多3次超限报故障给用户──
_RETRY_HINTS = (
"timeout", "timed out", "connection", "connect", "network", "unreachable",
"rate limit", "429", "500", "502", "503", "504", "reset", "broken pipe",
"eof", "temporar", "busy", "overloaded",
)
def _classify_failure(last_error: str) -> str:
"""按失败原因分类retry(瞬时,可重跑) / fault(永久,报故障)。
空错误通常是 asyncio.TimeoutErrorstr 为空),视为瞬时可重跑。
"""
err = (last_error or "").strip().lower()
if not err:
return "retry"
for h in _RETRY_HINTS:
if h in err:
return "retry"
return "fault"
async def handle_failed_task(task_id: str, project_id: str) -> dict:
"""处理一个失败任务:判定重跑或报故障。由 failed poller 周期调用。
- retry_count < 最大重复数(task_max_retry默认3) 且失败原因判定为瞬时 → 重跑(retry_count+1, 回 submitted)
- retry_count >= 最大重复数 或判定为永久错误 → 暂停任务链(pause_project) + 报故障(建问题通知用户,任务置 waiting)
"""
db = _get_db()
async with db.sqlorContext("pipeline") as sor:
recs = await sor.sqlExe(
"SELECT id, title, role, retry_count, last_error FROM pipeline_tasks "
"WHERE id=${tid}$ AND state='failed'",
{"tid": task_id})
if not recs:
# 释放 SELECT 的元数据锁,防止连接回池后阻塞 DDL
await sor.sqlExe("COMMIT", {})
return {"status": "idle", "task_id": task_id}
# paused 项目零消耗2026-09-02暂停 = 完全停摆,失败任务不判定不重跑,
# 直接跳过(本函数后续有 LLM 决策调用paused 项目每轮白烧 tokens
_p = await sor.sqlExe(
"SELECT status FROM sd_projects WHERE id=${pid}$", {"pid": project_id})
await sor.sqlExe("COMMIT", {})
if _p and getattr(_p[0], "status", "") == "paused":
return {"status": "skipped_paused", "task_id": task_id}
t = recs[0]
title = getattr(t, "title", "") or ""
role = getattr(t, "role", "") or ""
try:
retry_count = int(getattr(t, "retry_count", 0) or 0)
except (TypeError, ValueError):
retry_count = 0
last_error = getattr(t, "last_error", "") or ""
# 最大重复数appbase params 表 task_max_retry默认 3超限暂停任务链 + 抛故障给人工
from .workspace import get_max_task_retry
max_retry = await get_max_task_retry(sor)
# 释放 SELECT 的元数据锁,避免决策/建问题期间长时间持有 MDL
await sor.sqlExe("COMMIT", {})
# 已重试 max_retry 次仍未成功 → 报故障
if retry_count >= max_retry:
decision = "fault"
else:
decision = _classify_failure(last_error)
if decision == "retry":
from .task_capability import retry_task
ok, _ = await retry_task(task_id, project_id, who="agent.pm")
logger.info("failed task retry: task=%s role=%s attempt=%d ok=%s", task_id, role, retry_count + 1, ok)
return {"status": "retry", "task_id": task_id, "attempt": retry_count + 1}
# 报故障:① 暂停任务链pause_projectPM 不再推进)② 建问题通知用户,任务置 waiting 等人工介入
try:
from .project_capability import pause_project
pok, pmsg = await pause_project(project_id, who="agent.pm")
logger.info("failed task pause project: task=%s pause=%s msg=%s", task_id, pok, pmsg)
except Exception as e:
logger.warning("failed task pause_project error: %s", e)
from .communication import raise_problem
reporter = "agent.main_agent" if role == "agent.pm" else "agent.pm"
qid = await raise_problem(
"fault_report",
f"任务「{title}」重复 {retry_count} 次已达上限({max_retry}),任务链已暂停,请人工介入处理。失败原因:{last_error or '未知'}",
reporter, tenant_id=project_id, task_id=task_id,
first_handler_role="agent.main_agent",
context={"fault": True, "last_error": last_error, "retry_count": retry_count,
"max_retry": max_retry, "chain_paused": True})
logger.info("failed task fault: task=%s role=%s qid=%s", task_id, role, qid)
return {"status": "fault", "task_id": task_id, "question_id": qid, "chain_paused": True}
async def role_agent_loop(project_id, role, agent_id=None, model_name=None, max_iterations=10):
results = []
for _ in range(max_iterations):
r = await role_agent_run(project_id, role, agent_id, model_name)
results.append(r)
if r["status"] in ("idle", "need_info", "failed"):
break
await asyncio.sleep(1)
return results
async def pm_review_loop(project_id, agent_id=None, model_name=None, max_iterations=20):
results = []
for _ in range(max_iterations):
r = await pm_review_run(project_id, agent_id, model_name)
results.append(r)
if r["status"] in ("idle", "failed"):
break
await asyncio.sleep(1)
return results
# 向后兼容
async def run_agent_loop(project_id, role_name, model_name=None):
role = _normalize_role(role_name or "develop")
if role == 'pm':
return await pm_review_run(project_id, model_name=model_name)
return await role_agent_run(project_id, role_name or "develop", model_name=model_name)
async def agent_loop(project_id, role_name, model_name=None, max_iterations=10):
role = _normalize_role(role_name or "develop")
if role == 'pm':
return await pm_review_loop(project_id, model_name=model_name, max_iterations=max_iterations)
return await role_agent_loop(project_id, role_name or "develop", model_name=model_name, max_iterations=max_iterations)