3615 lines
192 KiB
Python
3615 lines
192 KiB
Python
"""Agent loop — 角色agent循环认领执行任务(v3.4.0)。
|
||
|
||
v3.4.0 新增:
|
||
- 所有agent具备文件读写 + git pull/push 能力
|
||
- agent 产出实际代码文件到项目仓库,而非仅文档
|
||
- PM agent 负责 clone 项目关联仓库、初始化工作区
|
||
- develop agent 产出可直接运行的源码文件
|
||
- 每个agent完成后自动 git commit + push
|
||
|
||
任务链:requirement → design → develop → test → deploy
|
||
"""
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import logging
|
||
import time
|
||
from contextlib import asynccontextmanager
|
||
|
||
logger = logging.getLogger("pipeline.agent_loop")
|
||
|
||
from .workspace import WORKSPACE_BASE, 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_model(pipelines.default_model)
|
||
6. 全局默认 "deepseek-v4-pro"
|
||
|
||
org_id:从 sd_projects.org_id 读,传给 llm_bridge 做多租户隔离
|
||
(llm 表查询只取「本机构 + 系统级 org_id='0'」的模型)。
|
||
"""
|
||
org_id = ""
|
||
pipeline_id = ""
|
||
default_model = ""
|
||
try:
|
||
recs = await sor.sqlExe(
|
||
"SELECT org_id, pipeline_id, default_model FROM sd_projects WHERE id=${pid}$", {"pid": project_id})
|
||
if recs:
|
||
org_id = getattr(recs[0], "org_id", "") or ""
|
||
pipeline_id = getattr(recs[0], "pipeline_id", "") or ""
|
||
default_model = getattr(recs[0], "default_model", "") or ""
|
||
except Exception:
|
||
pass
|
||
|
||
# 2. 项目-角色-模型(设置界面配的,最高优先)
|
||
if not model_name and project_id:
|
||
try:
|
||
recs = await sor.sqlExe(
|
||
"SELECT model_name FROM sd_project_role_models WHERE project_id=${pid}$ AND role=${r}$",
|
||
{"pid": project_id, "r": role})
|
||
if recs and getattr(recs[0], "model_name", ""):
|
||
model_name = getattr(recs[0], "model_name", "")
|
||
except Exception:
|
||
pass
|
||
|
||
# 3. 项目缺省模型(创建项目时记录的「当前会话模型」,session 级缺省)
|
||
if not model_name and default_model:
|
||
model_name = default_model
|
||
|
||
if not model_name:
|
||
# 4. RoleSpec.model_name(角色专属模型)
|
||
try:
|
||
from pipeline_core import get_role_spec
|
||
spec = get_role_spec(pipeline_id or await _resolve_pipeline_id(project_id), role)
|
||
if spec and spec.model_name:
|
||
model_name = spec.model_name
|
||
except Exception:
|
||
pass
|
||
if not model_name and pipeline_id:
|
||
# 5. 产线 default_model
|
||
try:
|
||
recs = await sor.sqlExe(
|
||
"SELECT default_model FROM pipelines WHERE id=${pid}$", {"pid": pipeline_id})
|
||
if recs:
|
||
model_name = getattr(recs[0], "default_model", "") or ""
|
||
except Exception:
|
||
pass
|
||
if not model_name:
|
||
# 6. 全局默认
|
||
model_name = "deepseek-v4-pro"
|
||
return model_name, org_id
|
||
|
||
|
||
async def _check_org_llm(sor, org_id):
|
||
"""检测机构是否配置了 LLM 模型(严格本机构隔离,不含系统级兜底)。
|
||
|
||
返回 (missing: bool, available_names: list)。org_id 为空或 '0'(系统级)时不过滤,
|
||
视为已配置(系统级模型对超管可用)。机构没配 llm 时角色/pm/qc 应冒泡问题暂停。
|
||
"""
|
||
if not org_id or org_id == '0':
|
||
return False, []
|
||
try:
|
||
recs = await sor.sqlExe(
|
||
"SELECT name FROM llm WHERE org_id=${org}$ AND status='active'", {"org": org_id})
|
||
names = [getattr(r, "name", "") or "" for r in (recs or [])]
|
||
return (len(names) == 0), names
|
||
except Exception:
|
||
return False, []
|
||
|
||
|
||
def _get_db():
|
||
from sqlor.dbpools import DBPools
|
||
db = DBPools()
|
||
if not db.databases:
|
||
from appPublic.jsonConfig import getConfig
|
||
config = getConfig()
|
||
if config.databases:
|
||
db.databases = config.databases
|
||
return db
|
||
|
||
|
||
def _resolve_workspace(workspace_dir):
|
||
if not workspace_dir:
|
||
workspace_dir = os.path.join(WORKSPACE_BASE, 'default')
|
||
try:
|
||
parent = os.path.dirname(workspace_dir)
|
||
if parent and not os.access(parent, os.W_OK):
|
||
proj_name = os.path.basename(workspace_dir)
|
||
workspace_dir = os.path.join(WORKSPACE_BASE, proj_name)
|
||
except Exception:
|
||
workspace_dir = os.path.join(WORKSPACE_BASE, os.path.basename(workspace_dir) or "default")
|
||
os.makedirs(workspace_dir, exist_ok=True)
|
||
return workspace_dir
|
||
|
||
|
||
# ── Agent 工具函数 ──
|
||
|
||
_allowed_workdirs_cache = None
|
||
_allowed_workdirs_cache_time = 0
|
||
|
||
|
||
async def _get_allowed_workdirs():
|
||
"""动态读 workspace_base 参数(params 表),合并到允许目录列表(60s 缓存)。"""
|
||
global _allowed_workdirs_cache, _allowed_workdirs_cache_time
|
||
import time as _t
|
||
now = _t.time()
|
||
if _allowed_workdirs_cache is not None and (now - _allowed_workdirs_cache_time) < 60:
|
||
return _allowed_workdirs_cache
|
||
allowed = list(_ALLOWED_WORKDIRS)
|
||
try:
|
||
db = _get_db()
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
from .workspace import get_workspace_base
|
||
base = await get_workspace_base(sor)
|
||
if base and base not in allowed:
|
||
allowed.append(base)
|
||
except Exception:
|
||
pass
|
||
_allowed_workdirs_cache = allowed
|
||
_allowed_workdirs_cache_time = now
|
||
return allowed
|
||
|
||
|
||
async def _is_safe_workdir_async(workdir):
|
||
"""检查目录是否在允许范围内(含 params 表动态 workspace_base)。"""
|
||
wd = os.path.abspath(workdir)
|
||
allowed = await _get_allowed_workdirs()
|
||
for a in allowed:
|
||
awd = os.path.abspath(os.path.expanduser(a))
|
||
if wd.startswith(awd):
|
||
return True
|
||
return False
|
||
|
||
|
||
# ── 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",
|
||
]
|
||
# DNS:systemd-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_prod)SSH 到目标机时,默认会走 ~/.ssh/id_rsa
|
||
(个人密钥),绕过机构密钥机制。这里在 ssh/scp 命令前注入
|
||
`-i ~/.ssh/org_keys/{org_id}/id_ed25519`,让部署用机构密钥连目标机。
|
||
|
||
规则:
|
||
- 命令含 ssh/scp 关键字才处理(避免影响 git 等其他命令)
|
||
- 已显式指定 -i 的不重复注入
|
||
- 机构密钥文件不存在则不注入(回退默认密钥,保持原行为)
|
||
"""
|
||
if not command or not org_id:
|
||
return command
|
||
# 仅对 ssh/scp 命令注入(宽松匹配:命令以 ssh/scp 开头,或含 ' ssh ' 等)
|
||
stripped = command.lstrip()
|
||
if not (stripped.startswith("ssh ") or stripped.startswith("ssh\t") or
|
||
stripped.startswith("scp ") or stripped.startswith("scp\t")):
|
||
return command
|
||
if re.search(r'(^|\s)-i\s+\S+', command):
|
||
return command # 已显式指定密钥,不覆盖
|
||
from .work_env import ensure_org_key
|
||
try:
|
||
info = ensure_org_key(org_id)
|
||
key_path = info.get("key_path", "")
|
||
except Exception:
|
||
return command
|
||
if not key_path or not os.path.exists(key_path):
|
||
return command
|
||
# 在 ssh/scp 动词后注入 -i 密钥(保留原有前导空白)
|
||
return command[:len(command) - len(command.lstrip())] + \
|
||
_inject_prefix(stripped, f"-i {key_path} ")
|
||
|
||
|
||
def _inject_prefix(cmd: str, prefix: str) -> str:
|
||
"""在 ssh/scp 命令动词后插入 prefix(密钥参数)。"""
|
||
parts = cmd.split(None, 1)
|
||
if not parts:
|
||
return cmd
|
||
verb = parts[0] # ssh 或 scp
|
||
rest = parts[1] if len(parts) > 1 else ""
|
||
return f"{verb} {prefix}{rest}"
|
||
|
||
|
||
async def _write_code_file(filepath, content):
|
||
"""写代码文件,自动创建父目录。"""
|
||
try:
|
||
d = os.path.dirname(filepath)
|
||
if d:
|
||
os.makedirs(d, exist_ok=True)
|
||
with open(filepath, 'w', encoding='utf-8') as f:
|
||
f.write(content or '')
|
||
return True, filepath
|
||
except Exception as e:
|
||
return False, str(e)
|
||
|
||
|
||
_GIT_LOCK_TTL = 180 # 秒:覆盖 git 单次操作最坏时长(clone 120s);过期可被原子接管
|
||
|
||
|
||
def _git_lock_key(repo_dir):
|
||
"""同 repo 的 git 操作共用一把锁。lock_key = sha256(repo_abs_path)。"""
|
||
return hashlib.sha256(os.path.abspath(repo_dir).encode('utf-8')).hexdigest()
|
||
|
||
|
||
@asynccontextmanager
|
||
async def _git_lock(repo_dir, timeout=90):
|
||
"""git 操作串行锁(DB 版,跨主机多 worker 生效):只锁 git 那几秒,任务其它部分完全并行。
|
||
|
||
用 pipeline_git_locks 表 + ON DUPLICATE KEY UPDATE + expires_at TTL:
|
||
- 原子抢占:INSERT ... ON DUPLICATE KEY UPDATE,仅当 expires_at 过期才接管他人锁
|
||
- 崩溃自动释放:expires_at 过期后下一个申请者原子接管
|
||
- 释放:DELETE ... WHERE token 匹配(避免误删他人锁)
|
||
"""
|
||
from appPublic.uniqueID import getID
|
||
|
||
key = _git_lock_key(repo_dir)
|
||
token = getID()
|
||
deadline = time.time() + timeout
|
||
acquired = False
|
||
db = _get_db()
|
||
try:
|
||
while not acquired:
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
await sor.sqlExe(
|
||
"INSERT INTO pipeline_git_locks (lock_key, token, expires_at) "
|
||
"VALUES (${k}$, ${t}$, DATE_ADD(NOW(), INTERVAL " + str(_GIT_LOCK_TTL) + " SECOND)) "
|
||
"ON DUPLICATE KEY UPDATE "
|
||
"token = IF(expires_at < NOW(), VALUES(token), token), "
|
||
"expires_at = IF(expires_at < NOW(), DATE_ADD(NOW(), INTERVAL " + str(_GIT_LOCK_TTL) + " SECOND), expires_at)",
|
||
{"k": key, "t": token})
|
||
r = await sor.sqlExe(
|
||
"SELECT token FROM pipeline_git_locks WHERE lock_key=${k}$ AND token=${t}$",
|
||
{"k": key, "t": token})
|
||
if r:
|
||
acquired = True
|
||
if not acquired:
|
||
if time.time() > deadline:
|
||
raise TimeoutError(f'git lock timeout after {timeout}s: {repo_dir}')
|
||
await asyncio.sleep(0.3)
|
||
yield
|
||
finally:
|
||
if acquired:
|
||
try:
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
await sor.sqlExe(
|
||
"DELETE FROM pipeline_git_locks WHERE lock_key=${k}$ AND token=${t}$",
|
||
{"k": key, "t": token})
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
async def _git_setup(workdir):
|
||
"""确保 git 用户已配置。"""
|
||
r = await _run_shell('git config user.email', workdir, 5)
|
||
if 'opencomputing' not in r.get('stdout', '') and '@' not in r.get('stdout', ''):
|
||
await _run_shell('git config user.email "pipeline@opencomputing.cn"', workdir, 5)
|
||
await _run_shell('git config user.name "Pipeline Agent"', workdir, 5)
|
||
|
||
|
||
async def _git_commit_push(workdir, commit_message, branch='main'):
|
||
"""git add → commit → push(无 remote 的本地仓库仅 commit)。非 git 目录自动 git init。"""
|
||
async with _git_lock(workdir):
|
||
await _git_setup(workdir)
|
||
# 非 git 目录自动 git init(模块仓库无真实远端,本地 git init)
|
||
if not os.path.isdir(os.path.join(workdir, '.git')):
|
||
r0 = await _run_shell('git init', workdir, 10)
|
||
if r0['rc'] != 0:
|
||
return {"rc": r0['rc'], "message": f"git init 失败: {r0['stderr'][:200]}"}
|
||
r1 = await _run_shell('git add -A', workdir, 10)
|
||
r2 = await _run_shell(f'git diff --cached --stat', workdir, 10)
|
||
if not r2.get('stdout', '').strip():
|
||
return {"rc": 0, "message": "没有变更需要提交"}
|
||
r3 = await _run_shell(f'git commit -m "{commit_message}"', workdir, 15)
|
||
if r3['rc'] != 0:
|
||
return {"rc": r3['rc'], "message": f"commit 失败: {r3['stderr'][:200]}"}
|
||
# 无 remote 的本地仓库(项目过程仓库)只 commit 不 push
|
||
r4 = await _run_shell('git remote get-url origin', workdir, 5)
|
||
if r4['rc'] != 0:
|
||
return {"rc": 0, "message": "commit 成功(本地仓库,无远程)"}
|
||
r5 = await _run_shell(f'git push origin {branch}', workdir, 30)
|
||
return {"rc": r5['rc'], "message": f"push {'成功' if r5['rc']==0 else '失败'}: {r5['stderr'][:200]}"}
|
||
|
||
|
||
async def _git_clone(repo_url, target_dir, branch='main'):
|
||
"""克隆仓库到目标目录。已存在则 pull。"""
|
||
async with _git_lock(target_dir):
|
||
if os.path.isdir(os.path.join(target_dir, '.git')):
|
||
r = await _run_shell(f'git checkout {branch} && git pull origin {branch}', target_dir, 30)
|
||
return {"rc": r['rc'], "message": f"已存在,pull: {r['stdout'][:200]}"}
|
||
parent = os.path.dirname(target_dir)
|
||
os.makedirs(parent, exist_ok=True)
|
||
r = await _run_shell(f'git clone -b {branch} {repo_url} {target_dir}', parent, 120)
|
||
return {"rc": r['rc'], "message": f"clone: {r['stdout'][:200] if r['rc']==0 else r['stderr'][:200]}"}
|
||
|
||
|
||
# ── Prompts ──
|
||
|
||
AGENT_TOOLS = [
|
||
{"name":"read_file","description":"读取工作空间中的文件","params":{"path":"相对路径"}},
|
||
{"name":"load_skill","description":"按需加载技能全文或子文件——需要具体规范/目录结构/路径/格式/流程时先加载对应技能(如 project-directory-spec 项目目录规范),不要凭记忆瞎写。只给 name 加载 SKILL.md 全文,给 file_path 加载 references/scripts/templates 下的子文件","params":{"name":"技能名","file_path":"子文件相对路径(可选,如 references/api.md)"}},
|
||
{"name":"write_file","description":"写入文件(自动创建父目录)","params":{"path":"相对路径","content":"文件内容"}},
|
||
{"name":"list_files","description":"列出目录内容","params":{"path":"相对路径(可选,默认工作空间根)"}},
|
||
{"name":"run_shell","description":"在工作空间中执行shell命令","params":{"command":"命令"}},
|
||
{"name":"git_clone","description":"克隆git仓库到机构工作空间(应用→apps/、模块→modules/)","params":{"repo_url":"仓库URL","repo_name":"仓库目录名(可选,默认从URL推断,_app后缀→apps/)","branch":"分支(可选,默认main)"}},
|
||
{"name":"git_status","description":"查看git仓库状态","params":{"repo_dir":"仓库子目录(可选,默认 apps/modules 下第一个)"}},
|
||
{"name":"git_commit_push","description":"git add + commit + push(develop 用它提交模块仓库本地提交作为产出证据;无远程只 commit 不 push,非 git 目录自动 init)","params":{"message":"提交信息","repo_dir":"仓库子目录(可选)"}},
|
||
{"name":"ask_question","description":"向用户提问(缺少信息时使用)。需要用户提供文件或结构化信息时用 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
|
||
- develop:QC 把关内容;你只确认代码文件实际产出(list_files 存在性检查)→ review_approve
|
||
- deploy_test:测试环境部署配置完整、服务可访问
|
||
- test:测试覆盖充分、发现问题记录完整
|
||
- deploy_prod:生产部署配置完整、可一键部署、有回滚方案
|
||
|
||
## 回退规则(test 阶段)
|
||
- 测试提交 Bug 后,零散 Bug → 走 Bug 闭环(report_bug→fix_bug→verify_bug),不打断任务链。
|
||
- 系统性缺陷(Bug 多/严重、设计有缺陷、需求理解错、部署有问题)→ review_rollback,rollback_role 指定回退目标阶段(develop/design/requirement/deploy_test),回退点之后的关联任务将全部作废。
|
||
- ⚠️ **外部输入缺失禁止回退上游**:部署主机/SSH账号/密码/部署路径/域名这类信息是**外部输入**,requirement/design/develop 三个阶段的 agent 根本产不出来(它们只能诚实标注「待明确」并列入 env/*.json 的 pending 清单,这是正确履职、不是缺陷)。这类阻塞由系统在部署任务的进入条件处自动冒泡人工任务给 owner 补齐,**你不要因此 review_rollback 到 requirement/design** —— 那会作废下游全部成果(含 owner 已人工确认的设计),而重做一遍后环境信息依然不会有,形成死循环(2026-08-25 hrs7 实际发生过)。正确处置:如实记录阻塞原因、raise_problem 冒泡,任务等 owner 补齐后自动继续。
|
||
- 回退前先自问:**这个缺陷是上游 agent「能做对却做错了」,还是「本来就产不出」?** 前者才回退;后者一律冒泡等人补。"""
|
||
|
||
|
||
QC_SYSTEM_PROMPT = """你是质量控制工程师(QC)。对交付件做合规检查和质量检查,不合规直接退回重做。
|
||
|
||
## 评分协议(强制)
|
||
1. 先按对应审查技能(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 个前置结束即启动
|
||
# 不变量(任何策略下都成立,代码守门):
|
||
# · 依赖任务不存在 → 阻塞(可能是编造 ID,D1/D3)
|
||
# · 依赖处于 cancelled/failed → 永不可达,阻塞并冒泡(D4)
|
||
# · 自依赖 / depends_on 解析失败 → 阻塞
|
||
DEP_DONE = ('completed', 'approved')
|
||
DEP_DEAD = ('cancelled', 'failed')
|
||
|
||
|
||
def _parse_dep_policy(policy):
|
||
"""解析 params.dep_policy → (mode, n)。缺省/非法 → ('all', 0)(保守兜底)。"""
|
||
if isinstance(policy, str):
|
||
try:
|
||
policy = json.loads(policy)
|
||
except (json.JSONDecodeError, TypeError, ValueError):
|
||
policy = None
|
||
if not isinstance(policy, dict):
|
||
return 'all', 0
|
||
mode = str(policy.get('mode') or 'all').strip().lower()
|
||
if mode not in ('all', 'any', 'at_least'):
|
||
return 'all', 0
|
||
n = 0
|
||
if mode == 'at_least':
|
||
try:
|
||
n = int(policy.get('n') or 0)
|
||
except (TypeError, ValueError):
|
||
n = 0
|
||
if n < 1:
|
||
return 'all', 0 # 非法 n → 保守退回 all
|
||
return mode, n
|
||
|
||
|
||
def _eval_deps_policy(deps, state_of, policy=None, title_of=None):
|
||
"""纯函数:对依赖列表按启动策略求值(不访问 DB,两处调用方共用,语义单一来源)。
|
||
|
||
Args:
|
||
deps: 依赖任务 id 列表(已去重)
|
||
state_of: {dep_id: state} 状态映射;id 不在映射中 = 任务不存在
|
||
policy: params.dep_policy(dict 或 JSON 字符串,可为空=默认 all)
|
||
title_of: 可选 {dep_id: title},用于生成可读 reason
|
||
|
||
Returns:
|
||
(satisfied: bool, reason: str, kind: str)
|
||
kind ∈ ('', 'dead', 'wait'):
|
||
'' 已满足(或无依赖)
|
||
'dead' 永不可达(不存在/已作废/失败)→ 调用方应冒泡人工
|
||
'wait' 仅等待中 → 保持等待,不冒泡
|
||
"""
|
||
title_of = title_of or {}
|
||
if not deps:
|
||
return True, '', ''
|
||
|
||
mode, n = _parse_dep_policy(policy)
|
||
|
||
# at_least 越界保护:n > 依赖总数时该策略永不可达(死等),
|
||
# 保守钳到依赖总数——宁可提前放行也不能制造永久阻塞。
|
||
if mode == 'at_least' and n > len(deps):
|
||
n = len(deps)
|
||
|
||
missing, dead, pending, done = [], [], [], []
|
||
for d in deps:
|
||
st = state_of.get(d)
|
||
if st is None:
|
||
missing.append(d) # D1/D3:依赖不存在 → 阻塞(不再放行)
|
||
elif st in DEP_DONE:
|
||
done.append(d)
|
||
elif st in DEP_DEAD:
|
||
dead.append(f"{title_of.get(d) or d[:8]}({st})")
|
||
else:
|
||
pending.append(f"{title_of.get(d) or d[:8]}({st})")
|
||
|
||
if missing or dead:
|
||
parts = []
|
||
if missing:
|
||
parts.append("依赖任务不存在:" + "、".join(x[:8] for x in missing))
|
||
if dead:
|
||
parts.append("依赖已作废/失败,永不可达:" + "、".join(dead))
|
||
return False, ";".join(parts) + "(需人工修正依赖或重建被依赖任务)", 'dead'
|
||
|
||
if mode == 'any':
|
||
if done:
|
||
return True, '', ''
|
||
return False, "等待任一前置完成(启动策略=any):" + "、".join(pending), 'wait'
|
||
if mode == 'at_least':
|
||
if len(done) >= n:
|
||
return True, '', ''
|
||
return False, (f"等待至少 {n} 个前置完成(启动策略=at_least {n},"
|
||
f"已完成 {len(done)}):" + "、".join(pending)), 'wait'
|
||
# mode == 'all'(默认)
|
||
if pending:
|
||
return False, "等待依赖完成:" + "、".join(pending), 'wait'
|
||
return True, '', ''
|
||
|
||
|
||
PLACEHOLDER_MARKS = ('待明确', '待确认', '待补充', 'TODO', 'todo', 'xxx', 'XXX', '占位', '<', '未知')
|
||
|
||
|
||
async def _check_deploy_env_ready(sor, project_id, role):
|
||
"""部署类任务的进入条件:env/<环境>.json 的真实环境信息是否齐备。
|
||
|
||
⚠️ 设计定调(用户 2026-08-26 明确纠正,很重要):
|
||
**环境信息只影响部署任务,不该阻断需求和开发。**
|
||
|
||
理由:主机/SSH账号/密码/部署路径是**外部输入**,requirement agent 根本产不出来。
|
||
若把「env 必须填全」做成 requirement 的结束条件,agent 就会卡在它无法解决的事情上
|
||
无限重试 —— 正是 2026-08-25 hrs7 的死循环成因(deploy_test 因 env 占位符失败 → PM
|
||
判「根因在 requirement」→ 回退整链并作废 owner 已确认的设计 → 重做需求后 env 依然
|
||
是占位符 → 再次失败)。把门禁前移会把这个死循环制度化。
|
||
|
||
正确处置:
|
||
· requirement/design/develop 一律不检查 env 完整性,占位符可正常通过;
|
||
requirement 只需诚实标注(hrs7 的 requirement agent 做对了:填了能确定的
|
||
port/dbname,未知项标「待明确」并列入 pending 清单)。
|
||
· 只有 deploy_test / deploy_prod 进入前检查;不齐备 → 不进 running,
|
||
冒泡人工任务给能提供信息的人(owner/运维),任务置 waiting。
|
||
· PM **不得**因外部输入缺失而回退上游阶段 —— 那不是任何 agent 阶段的产出缺陷。
|
||
|
||
返回 (ready: bool, missing: [字段名], env_file: str)
|
||
"""
|
||
if role not in ('agent.deploy_test', 'agent.deploy_prod'):
|
||
return True, [], ''
|
||
env_name = 'test' if role == 'agent.deploy_test' else 'prod'
|
||
pdir = await _get_project_dir(sor, project_id)
|
||
if not pdir:
|
||
return True, [], '' # 取不到项目目录不阻断(宁放过不误杀)
|
||
import os as _os
|
||
env_file = _os.path.join(pdir, 'env', f'{env_name}.json')
|
||
if not _os.path.isfile(env_file):
|
||
return False, [f'env/{env_name}.json 文件不存在'], env_file
|
||
try:
|
||
with open(env_file, encoding='utf-8') as f:
|
||
cfg = json.load(f)
|
||
except Exception as e:
|
||
return False, [f'env/{env_name}.json 解析失败:{e}'], env_file
|
||
|
||
missing = []
|
||
|
||
def _bad(v):
|
||
if v is None:
|
||
return False # null 合法(如 key_file=null 表示用密码登录)
|
||
if isinstance(v, str):
|
||
s = v.strip()
|
||
if not s:
|
||
return True
|
||
return any(m in s for m in PLACEHOLDER_MARKS)
|
||
return False
|
||
|
||
# 部署真正必需的字段(其余如 domain/status 缺失不阻断部署)
|
||
REQUIRED = (('ssh', 'host'), ('ssh', 'user'), ('deploy', 'path'),
|
||
('db', 'host'), ('db', 'user'), ('db', 'dbname'))
|
||
for path in REQUIRED:
|
||
cur = cfg
|
||
for k in path:
|
||
cur = cur.get(k) if isinstance(cur, dict) else None
|
||
if cur is None or _bad(cur):
|
||
missing.append('.'.join(path))
|
||
# SSH 凭据:password 与 key_file 二者其一即可(key_file=null 表示用密码登录)
|
||
_ssh = cfg.get('ssh') or {}
|
||
_pwd_ok = not _bad(_ssh.get('password')) and bool(str(_ssh.get('password') or '').strip())
|
||
_key = _ssh.get('key_file')
|
||
_key_ok = bool(str(_key).strip()) and not _bad(_key) if isinstance(_key, str) else False
|
||
if not (_pwd_ok or _key_ok):
|
||
missing.append('ssh.password 或 ssh.key_file(二者其一)')
|
||
# requirement 自己列的 pending 清单也纳入(它最清楚哪些没定)
|
||
for p in (cfg.get('pending') or []):
|
||
if isinstance(p, str) and p.strip() and p.strip() not in missing:
|
||
_k = p.strip()
|
||
if any(_k.startswith(x) for x in ('ssh.', 'deploy.', 'db.')):
|
||
if _k not in missing:
|
||
missing.append(_k)
|
||
return (not missing), missing, env_file
|
||
|
||
|
||
async def _bubble_deploy_env_missing(sor, project_id, task_id, task_title, missing, env_file):
|
||
"""部署环境信息缺失 → 走统一问题通道冒泡给项目 owner(去重)。
|
||
|
||
统一冒泡机制(2026-08-27 定调:缺外部信息的问题一律走问题通道,不再按类型各写补丁):
|
||
① raise_problem 建问题记录、任务置 waiting(清 claimed_by);
|
||
first_handler_agentid=owner → owner 的「我的待办」里出现一条待答问题
|
||
(list_my_human_todos 已 union pipeline_agent_questions,按 agentid 命中)。
|
||
② owner 在待办里回答(待办详情已支持 question 渲染 + 回答框)→
|
||
question_answer → resolve_problem → 任务自动 waiting→submitted。
|
||
③ poller 下轮重新派发 → 本函数调用方重跑 env 检查:补齐 → 认领,
|
||
_build_qna_section 把 owner 的回答注入 agent prompt(信息真正交给 LLM);
|
||
仍未补齐 → 旧问题已 answered、去重不拦 → 冒新一条。诚实循环,不静默死等。
|
||
"""
|
||
from .communication import raise_problem
|
||
# 去重只看系统冒泡的(from_role='system.orchestrator')——need_info 是通用类型,
|
||
# 角色 agent 缺信息也会用 need_info 提问,不加此条件会误拦。
|
||
exists = await sor.sqlExe(
|
||
"SELECT id FROM pipeline_agent_questions WHERE tenant_id=${pid}$ AND task_id=${tid}$ "
|
||
"AND problem_type='need_info' AND from_role='system.orchestrator' AND status='pending' LIMIT 1",
|
||
{"pid": project_id, "tid": task_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if exists:
|
||
return
|
||
_own = await sor.sqlExe("SELECT created_by FROM sd_projects WHERE id=${pid}$", {"pid": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
owner_id = (getattr(_own[0], 'created_by', '') if _own else '') or 'user-01'
|
||
# env_file 是绝对路径({pdir}/env/{env}.json),target 取项目内相对路径
|
||
_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_path(org_id + pipeline_id)构造,
|
||
不依赖 workspace_dir 路径结构(workspace_dir 迁到 {space}/projects/{项目名} 后 dirname 会错)。
|
||
"""
|
||
recs = await sor.sqlExe(
|
||
"SELECT org_id, pipeline_id FROM sd_projects WHERE id=${pid}$ LIMIT 1",
|
||
{"pid": project_id})
|
||
if not recs:
|
||
return _resolve_workspace('')
|
||
org_id = getattr(recs[0], 'org_id', '0') or '0'
|
||
space = getattr(recs[0], 'pipeline_id', '') or GENERAL_SPACE
|
||
return build_space_path(WORKSPACE_BASE, org_id, space)
|
||
|
||
|
||
def _parse_skill_frontmatter(content):
|
||
"""解析技能 SKILL.md 的 frontmatter(--- 之间),返回 dict。无 frontmatter 返回 {}。"""
|
||
content = (content or "").strip()
|
||
if not content.startswith("---"):
|
||
return {}
|
||
end = content.find("---", 3)
|
||
if end == -1:
|
||
return {}
|
||
fm = {}
|
||
for line in content[3:end].strip().split("\n"):
|
||
line = line.strip()
|
||
if ":" in line:
|
||
k, _, v = line.partition(":")
|
||
k = k.strip()
|
||
v = v.strip().strip('"').strip("'")
|
||
if v.startswith("[") and v.endswith("]"):
|
||
v = [x.strip().strip('"').strip("'") for x in v[1:-1].split(",") if x.strip()]
|
||
fm[k] = v
|
||
return fm
|
||
|
||
|
||
def _collect_module_skills(space_dir):
|
||
"""扫描机构工作空间 modules/*/skill/SKILL.md 和 apps/*/skill/SKILL.md,收集模块技能。
|
||
|
||
新结构:模块本地仓库在机构工作空间 modules/,应用在 apps/。模块仓库自带
|
||
skill/SKILL.md(架构/数据模型/挂载函数/坑位),不进 skill_loader 静态树,
|
||
这里运行时扫描,作为「项目模块」scope 注入技能目录 + 支持 load_skill 加载全文。
|
||
返回 [{name, description, body, path, repo}]。body 是剥离 frontmatter 后的正文。
|
||
"""
|
||
modules = []
|
||
skill_files = []
|
||
for sub in ('modules', 'apps'):
|
||
base = os.path.join(space_dir, sub)
|
||
if os.path.isdir(base):
|
||
for entry in sorted(os.listdir(base)):
|
||
sf = os.path.join(base, entry, 'skill', 'SKILL.md')
|
||
if os.path.isfile(sf):
|
||
skill_files.append((sf, entry))
|
||
for skill_file, entry in skill_files:
|
||
try:
|
||
with open(skill_file, 'r', encoding='utf-8') as f:
|
||
content = f.read()
|
||
except Exception:
|
||
continue
|
||
fm = _parse_skill_frontmatter(content)
|
||
name = (fm.get('name') or entry).strip()
|
||
description = (fm.get('description') or '').strip()
|
||
# 剥离 frontmatter,只保留正文(对齐 skill_loader.to_prompt_block 行为)
|
||
body = content.strip()
|
||
if body.startswith("---"):
|
||
end = body.find("---", 3)
|
||
if end != -1:
|
||
body = body[end + 3:].strip()
|
||
if not description:
|
||
for line in body.split("\n"):
|
||
line = line.strip()
|
||
if line and not line.startswith("#") and not line.startswith("---"):
|
||
description = line[:200]
|
||
break
|
||
modules.append({
|
||
"name": name,
|
||
"description": description,
|
||
"body": body,
|
||
"path": skill_file,
|
||
"repo": entry,
|
||
})
|
||
return modules
|
||
|
||
|
||
async def _build_qna_section(sor, task_id, role, agent_id=None):
|
||
from .communication import get_task_qa
|
||
qa = await get_task_qa(task_id, role=role, agentid=agent_id)
|
||
qna = qa.get('answered', []) or [] if isinstance(qa, dict) else []
|
||
# pending = 当前该「本角色/本 agent」处理的退回意见,重新认领时必须先逐条响应再产出。
|
||
pending = qa.get('pending', []) or [] if isinstance(qa, dict) else []
|
||
if not qna and not pending:
|
||
return ""
|
||
lines = []
|
||
if pending:
|
||
lines.append("⚠️ 待处理问题(审核/PM 退回意见,你必须先逐条响应这些再产出交付件):")
|
||
for p in pending:
|
||
lines.append(f"- [{p.get('from_role', '') or '审核'}] {p.get('question', '')}")
|
||
if qna:
|
||
lines.append("历史问答:")
|
||
for item in qna:
|
||
src = "业主" if item.get('answer_source') == "owner.superuser" else "主agent"
|
||
lines.append(f"问:{item.get('question','')}")
|
||
lines.append(f"答({src}):{item.get('answer','')}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
async def _get_deliverable_content(sor, task_id):
|
||
recs = await sor.sqlExe(
|
||
"SELECT content, deliverable_type FROM pipeline_deliverables "
|
||
"WHERE task_id=${tid}$ ORDER BY created_at DESC LIMIT 1", {"tid": task_id})
|
||
if recs:
|
||
return getattr(recs[0], 'content', '') or '', getattr(recs[0], 'deliverable_type', '') or ''
|
||
return '', ''
|
||
|
||
|
||
async def _get_project_repos(sor, project_id):
|
||
"""获取项目关联的所有仓库。"""
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, repo_name, repo_url, default_branch, local_path FROM sd_project_repos "
|
||
"WHERE project_id=${pid}$", {"pid": project_id})
|
||
return [{'id': getattr(r, 'id', ''), 'name': getattr(r, 'repo_name', ''),
|
||
'url': getattr(r, 'repo_url', ''), 'branch': getattr(r, 'default_branch', 'main'),
|
||
'local_path': getattr(r, 'local_path', '') or ''} for r in (recs or [])]
|
||
|
||
|
||
async def _get_project_name(sor, project_id):
|
||
"""查项目目录名(英文 slug = directory_name 字段,查不到回退 name)。查不到返回空串。
|
||
|
||
新结构:项目目录名用英文 slug([a-zA-Z0-9_]+),显示名(name,可中文)独立。
|
||
"""
|
||
try:
|
||
r = await sor.sqlExe(
|
||
"SELECT directory_name, name FROM sd_projects WHERE id=${pid}$ LIMIT 1",
|
||
{"pid": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if r:
|
||
dn = getattr(r[0], 'directory_name', '') or ''
|
||
return dn or (getattr(r[0], 'name', '') or '')
|
||
except Exception:
|
||
pass
|
||
return ''
|
||
|
||
|
||
async def _get_project_dir(sor, project_id):
|
||
"""返回项目目录 {space}/projects/{项目名}/(项目过程仓库 + docs/ + env/ + deliverables/ 所在)。
|
||
|
||
新结构:项目目录从 {space}/{项目名} 迁到 {space}/projects/{项目名}/,与 apps/、modules/ 平级。
|
||
"""
|
||
space_dir = await _get_space_dir(sor, project_id)
|
||
if not space_dir:
|
||
return ''
|
||
project_name = await _get_project_name(sor, project_id)
|
||
if not project_name:
|
||
return ''
|
||
return os.path.join(space_dir, 'projects', project_name)
|
||
|
||
|
||
async def _ensure_project_repo(project_dir):
|
||
"""确保项目目录(项目过程仓库)存在并 git init(docs/ + env/ + spec.json + deliverables/ 放这里)。
|
||
|
||
新结构:项目目录 projects/{项目名}/ 本身就是项目过程仓库(不再是 repos/{项目名}_pc/)。
|
||
幂等:已存在则跳过。项目过程仓库是本地仓库,无远程,只 commit 不 push。"""
|
||
os.makedirs(project_dir, exist_ok=True)
|
||
if os.path.isdir(os.path.join(project_dir, '.git')):
|
||
return {'rc': 0, 'message': '项目过程仓库已存在'}
|
||
async with _git_lock(project_dir):
|
||
if os.path.isdir(os.path.join(project_dir, '.git')):
|
||
return {'rc': 0, 'message': '项目过程仓库已存在'}
|
||
r = await _run_shell('git init', project_dir, 10)
|
||
if r['rc'] != 0:
|
||
return {'rc': r['rc'], 'message': f"git init 失败: {r['stderr'][:200]}"}
|
||
# init 之后才能配置仓库级 user(否则 git config 报 not a git repository)
|
||
await _git_setup(project_dir)
|
||
readme = os.path.join(project_dir, 'README.md')
|
||
if not os.path.isfile(readme):
|
||
try:
|
||
with open(readme, 'w', encoding='utf-8') as f:
|
||
f.write('# 项目过程仓库\n\n阶段文档 / QC 审计文档 / 项目管理文档。\n')
|
||
except Exception:
|
||
pass
|
||
r2 = await _run_shell('git add -A && git commit -m "init: 项目过程仓库"', project_dir, 15)
|
||
return {'rc': r2['rc'], 'message': f"init 项目过程仓库 {'成功' if r2['rc'] == 0 else '失败'}"}
|
||
|
||
|
||
async def _commit_repos_after_approve(space_dir, project_name='', title=''):
|
||
"""审核通过后统一提交:项目过程仓库 projects/{项目名}/(阶段/QC/PM 文档)+ apps/* + modules/* 仓库(代码)。
|
||
不在 agent 每次产出时提交,减少 git 并发锁与远端 push 频率。"""
|
||
msg = f"approve: {title[:80]}" if title else "approve: 审核通过"
|
||
results = []
|
||
roots = []
|
||
if project_name:
|
||
roots.append(('projects', os.path.join(space_dir, 'projects', project_name)))
|
||
roots.append(('apps', os.path.join(space_dir, 'apps')))
|
||
roots.append(('modules', os.path.join(space_dir, 'modules')))
|
||
for kind, base in roots:
|
||
if not os.path.isdir(base):
|
||
continue
|
||
if kind == 'projects':
|
||
if os.path.isdir(os.path.join(base, '.git')):
|
||
r = await _git_commit_push(base, msg)
|
||
results.append(f"{os.path.basename(base)}: {r.get('message', '')}")
|
||
logger.info(f"commit-after-approve projects: rc={r.get('rc', -1)} {r.get('message', '')[:100]}")
|
||
continue
|
||
for name in sorted(os.listdir(base)):
|
||
rp = os.path.join(base, name)
|
||
if os.path.isdir(os.path.join(rp, '.git')):
|
||
r = await _git_commit_push(rp, msg)
|
||
results.append(f"{kind}/{name}: {r.get('message', '')}")
|
||
logger.info(f"commit-after-approve {kind}/{name}: rc={r.get('rc', -1)} {r.get('message', '')[:100]}")
|
||
return {"rc": 0, "message": "; ".join(results) if results else "无仓库可提交"}
|
||
|
||
|
||
async def _setup_repos(sor, space_dir, project_id):
|
||
"""clone 项目关联仓库到机构工作空间:应用→apps/、模块→modules/;项目目录→projects/{项目名}/。"""
|
||
project_dir = await _get_project_dir(sor, project_id)
|
||
await _ensure_project_repo(project_dir)
|
||
repos = await _get_project_repos(sor, project_id)
|
||
results = []
|
||
for repo in repos:
|
||
name = repo['name'] or ''
|
||
# 应用仓库:名字以 _app 结尾 → apps/{去掉_app}/;否则模块仓库 → modules/{name}/
|
||
if name.endswith('_app'):
|
||
target = os.path.join(space_dir, 'apps', name[:-4])
|
||
else:
|
||
target = os.path.join(space_dir, 'modules', name)
|
||
r = await _git_clone(repo['url'], target, repo['branch'])
|
||
results.append({'repo': name, **r})
|
||
return results
|
||
|
||
|
||
async def _get_repo_state(space_dir, project_name=''):
|
||
"""获取仓库当前状态(供PM审核时查看):项目过程仓库 projects/{项目名}/ + apps/* + modules/*。"""
|
||
lines = []
|
||
roots = []
|
||
if project_name:
|
||
roots.append(('projects', os.path.join(space_dir, 'projects', project_name)))
|
||
roots.append(('apps', os.path.join(space_dir, 'apps')))
|
||
roots.append(('modules', os.path.join(space_dir, 'modules')))
|
||
for kind, base in roots:
|
||
if not os.path.isdir(base):
|
||
continue
|
||
if kind == 'projects':
|
||
if os.path.isdir(os.path.join(base, '.git')):
|
||
r = await _run_shell('git log --oneline -3', base, 5)
|
||
lines.append(f"\n[{os.path.basename(base)}]")
|
||
lines.append(r.get('stdout', '') or '(空仓库)')
|
||
continue
|
||
for name in sorted(os.listdir(base)):
|
||
rp = os.path.join(base, name)
|
||
if os.path.isdir(rp) and os.path.isdir(os.path.join(rp, '.git')):
|
||
r = await _run_shell('git log --oneline -3', rp, 5)
|
||
lines.append(f"\n[{kind}/{name}]")
|
||
lines.append(r.get('stdout', '') or '(空仓库)')
|
||
return "\n".join(lines) if lines else "仓库无提交记录"
|
||
|
||
|
||
async def _get_next_role(current_role, project_id=""):
|
||
"""任务链下一角色:从能力包取。"""
|
||
if project_id:
|
||
try:
|
||
pid = await _resolve_pipeline_id(project_id)
|
||
from pipeline_core import get_role_spec
|
||
spec = get_role_spec(pid, current_role)
|
||
if spec:
|
||
return spec.next_role # 可能是 ""(终结)
|
||
except Exception:
|
||
pass
|
||
return ""
|
||
|
||
|
||
async def _check_orchestration_gaps(sor, project_id, task, next_role='', next_task_id=''):
|
||
"""编排完备性校验:代码只**查漏**,不替 PM 决策。
|
||
|
||
设计立场(2026-08-25 定调):流转决策权归 LLM(PM 懂业务语义,知道模块怎么拆、依赖怎么连),
|
||
代码负责补 LLM 的固有短板——**不穷尽**。今天的实证:PM 给 3 个模块任务里的 2 个正确设了
|
||
depends_on,却漏了应用脚手架那一个,导致脚手架先跑完、触发 deploy_test 提前启动。
|
||
LLM 能做依赖判断(已证明),但会漏(也已证明)→ 代码查漏,把结构化事实回给 PM。
|
||
|
||
返回 [告警字符串],空 = 无问题。不阻断流程(阻断权仍在人和 PM),只暴露事实。
|
||
|
||
检查项:
|
||
G1 依赖自引用 / 成环 → 必然死锁(我在方案走查阶段就发现自己的设计有这个洞)
|
||
G2 应用级 develop(脚手架)无 depends_on,但同迭代存在模块级 develop 未完成
|
||
→ 脚手架会先跑完,打破「应用级 develop approved = 编码完成」这个 deploy_test 门控前提
|
||
G3 design 产出的模块清单(spec.json generated_modules)有模块没有对应 develop 任务
|
||
→ PM 漏派,模块不会被开发却照样部署
|
||
|
||
返回结构化缺口列表 [{kind, fingerprint, text, task_id, fix_ids}]:
|
||
fingerprint 用于通知去重;task_id/fix_ids 让 PM 能直接调 update_task_deps 修正,
|
||
不需要再反查任务 ID(G2 元景故障的教训:告警只进日志没人看,事实必须送到 PM 手上)。
|
||
"""
|
||
gaps = []
|
||
_iter = _task_iteration_name(task)
|
||
|
||
# 取同迭代全部活跃/终态任务(含 depends_on),一次查完供三项检查复用
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, title, role, state, depends_on, params FROM pipeline_tasks "
|
||
"WHERE tenant_id=${pid}$ AND state NOT IN ('cancelled') ORDER BY created_at",
|
||
{"pid": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
tasks = []
|
||
for r in (recs or []):
|
||
try:
|
||
p = json.loads(getattr(r, 'params', '{}') or '{}')
|
||
except (json.JSONDecodeError, TypeError):
|
||
p = {}
|
||
if _iter and (p.get('iteration_id') or '') != _iter:
|
||
continue
|
||
deps = []
|
||
try:
|
||
_d = json.loads(getattr(r, 'depends_on', '') or '[]')
|
||
deps = [str(x) for x in _d if x] if isinstance(_d, list) else []
|
||
except (json.JSONDecodeError, TypeError):
|
||
deps = []
|
||
tasks.append({'id': getattr(r, 'id', ''), 'title': getattr(r, 'title', '') or '',
|
||
'role': getattr(r, 'role', '') or '', 'state': getattr(r, 'state', '') or '',
|
||
'deps': deps, 'params': p})
|
||
|
||
# G1 自引用 / 成环(DFS 找回边)
|
||
dep_map = {t['id']: t['deps'] for t in tasks}
|
||
title_of = {t['id']: t['title'] for t in tasks}
|
||
for tid, ds in dep_map.items():
|
||
if tid in ds:
|
||
gaps.append({'kind': 'G1', 'fingerprint': f"G1:{tid}", 'task_id': tid, 'fix_ids': [],
|
||
'text': f"G1 任务「{title_of.get(tid, tid[:8])}」({tid}) 依赖自身,必然死锁"})
|
||
WHITE, GRAY, BLACK = 0, 1, 2
|
||
color = {k: WHITE for k in dep_map}
|
||
_cyc_seen = set()
|
||
|
||
def _dfs(n, path):
|
||
color[n] = GRAY
|
||
for m in dep_map.get(n, []):
|
||
if m not in color:
|
||
continue
|
||
if color[m] == GRAY:
|
||
cyc = path[path.index(m):] if m in path else [m]
|
||
fp = "G1:cycle:" + ",".join(sorted(cyc))
|
||
if fp in _cyc_seen:
|
||
continue
|
||
_cyc_seen.add(fp)
|
||
gaps.append({'kind': 'G1', 'fingerprint': fp, 'task_id': (cyc[0] if cyc else ''),
|
||
'fix_ids': [],
|
||
'text': "G1 依赖成环,必然死锁:" +
|
||
" → ".join(title_of.get(x, x[:8]) for x in cyc + [m])})
|
||
elif color[m] == WHITE:
|
||
_dfs(m, path + [n])
|
||
color[n] = BLACK
|
||
|
||
for n in list(dep_map.keys()):
|
||
if color.get(n) == WHITE:
|
||
_dfs(n, [n])
|
||
|
||
# G2 应用级 develop 缺依赖(本次故障的直接成因)
|
||
ACTIVE = ('submitted', 'running', 'review', 'qc_review', 'waiting')
|
||
module_devs = [t for t in tasks
|
||
if t['role'] == 'agent.develop' and not t['params'].get('previous_role')]
|
||
app_devs = [t for t in tasks
|
||
if t['role'] == 'agent.develop' and t['params'].get('previous_role')]
|
||
unfinished_mods = [t for t in module_devs if t['state'] in ACTIVE]
|
||
for a in app_devs:
|
||
if a['state'] not in ACTIVE and a['state'] != 'approved':
|
||
continue
|
||
miss_tasks = [m for m in unfinished_mods if m['id'] not in a['deps']]
|
||
if miss_tasks:
|
||
miss_ids = [m['id'] for m in miss_tasks]
|
||
gaps.append({
|
||
'kind': 'G2', 'fingerprint': "G2:" + a['id'] + ":" + ",".join(sorted(miss_ids)),
|
||
'task_id': a['id'], 'fix_ids': miss_ids,
|
||
'text': (f"G2 应用级 develop「{a['title']}」({a['id']}) 未依赖以下未完成的模块任务:"
|
||
+ "、".join(f"{m['title']}({m['id']})" for m in miss_tasks)
|
||
+ " → 脚手架可能先于模块完成,使 deploy_test 在编码未完成时启动"
|
||
+ "(修法:update_task_deps(task_id=\"" + a['id']
|
||
+ "\", add_depends_on=" + json.dumps(miss_ids) + "))")})
|
||
|
||
# G3 设计模块清单 vs 实际派发的 develop 任务(漏派检测)
|
||
try:
|
||
wdir = await _get_project_dir(sor, project_id)
|
||
if wdir:
|
||
import os as _os
|
||
import glob as _glob
|
||
for spec_path in _glob.glob(_os.path.join(wdir, '*_spec.json')):
|
||
with open(spec_path, encoding='utf-8') as f:
|
||
spec = json.load(f)
|
||
mods = spec.get('generated_modules') or []
|
||
if not isinstance(mods, list):
|
||
continue
|
||
for m in mods:
|
||
if not m:
|
||
continue
|
||
hit = any(str(m).lower() in (t['title'] or '').lower()
|
||
for t in tasks if t['role'] == 'agent.develop')
|
||
if not hit:
|
||
gaps.append({
|
||
'kind': 'G3', 'fingerprint': f"G3:{m}", 'task_id': '', 'fix_ids': [],
|
||
'text': (f"G3 设计清单里的模块「{m}」没有对应的 develop 任务"
|
||
f"({_os.path.basename(spec_path)} generated_modules)→ PM 漏派,"
|
||
f"该模块不会被开发却会进入部署(修法:create_tasks 补派该模块)")})
|
||
except Exception as e:
|
||
logger.debug(f"_check_orchestration_gaps G3 跳过: {e}")
|
||
|
||
return gaps
|
||
|
||
|
||
def _gap_texts(gaps):
|
||
"""结构化缺口 → 纯文本列表(日志用)。"""
|
||
return [g['text'] for g in (gaps or []) if isinstance(g, dict) and g.get('text')]
|
||
|
||
|
||
async def _save_gap_notices(sor, project_id, gaps, source_task_id=''):
|
||
"""把编排查漏结果持久化为 PM 通知(pending),供 PM 下一回合注入上下文。
|
||
|
||
去重:(tenant_id, fingerprint) 逻辑唯一——同一缺口重复检出只留一条。
|
||
实现用 SELECT 先查再 UPDATE/INSERT,不依赖 unique 索引(create_tables.py
|
||
的幂等建表会跳过 CREATE INDEX 语句,索引可能不存在)。
|
||
已存在(含已 delivered)→ 重置回 pending:缺口未修复再次检出时,
|
||
PM 必须再看到(若用 INSERT IGNORE 会被挡住,缺口就永远静默了)。
|
||
"""
|
||
from appPublic.uniqueID import getID
|
||
for g in (gaps or []):
|
||
if not isinstance(g, dict) or not g.get('fingerprint'):
|
||
continue
|
||
try:
|
||
_ex = await sor.sqlExe(
|
||
"SELECT id FROM pipeline_pm_notices WHERE tenant_id=${pid}$ AND fingerprint=${fp}$",
|
||
{"pid": project_id, "fp": g['fingerprint']})
|
||
_tid = g.get('task_id') or source_task_id or ''
|
||
if _ex:
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_pm_notices SET status='pending', gap_text=${t}$, "
|
||
"task_id=${tid}$, gap_kind=${k}$, updated_at=NOW() WHERE id=${id}$",
|
||
{"t": g.get('text') or '', "tid": _tid, "k": g.get('kind') or '',
|
||
"id": getattr(_ex[0], 'id', '')})
|
||
else:
|
||
await sor.sqlExe(
|
||
"INSERT INTO pipeline_pm_notices "
|
||
"(id, tenant_id, task_id, fingerprint, gap_kind, gap_text, status, created_at, updated_at) "
|
||
"VALUES (${id}$, ${pid}$, ${tid}$, ${fp}$, ${k}$, ${t}$, 'pending', NOW(), NOW())",
|
||
{"id": getID(), "pid": project_id, "tid": _tid,
|
||
"fp": g['fingerprint'], "k": g.get('kind') or '', "t": g.get('text') or ''})
|
||
await sor.sqlExe("COMMIT", {})
|
||
except Exception as e:
|
||
logger.debug(f"_save_gap_notices 跳过 {g.get('fingerprint')}: {e}")
|
||
|
||
|
||
async def _load_pm_notices(sor, project_id):
|
||
"""取本项目全部 pending 编排通知并标记 delivered(每个缺口只进一次 PM 上下文,避免重复噪声)。
|
||
|
||
返回拼接好的文本块;无通知返回 ''。
|
||
"""
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, gap_text FROM pipeline_pm_notices "
|
||
"WHERE tenant_id=${pid}$ AND status='pending' ORDER BY created_at ASC",
|
||
{"pid": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return ''
|
||
ids = [getattr(r, 'id', '') for r in recs]
|
||
ph = ",".join("${i%d}$" % n for n in range(len(ids)))
|
||
kw = {("i%d" % n): v for n, v in enumerate(ids)}
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_pm_notices SET status='delivered', updated_at=NOW() "
|
||
"WHERE id IN (" + ph + ")", kw)
|
||
await sor.sqlExe("COMMIT", {})
|
||
lines = ["⚠️ 编排完备性检查发现以下缺口(代码查漏,修正决策权在你):"]
|
||
for r in recs:
|
||
lines.append("· " + (getattr(r, 'gap_text', '') or ''))
|
||
lines.append("请核实后用 update_task_deps / create_tasks / cancel_task 修正;"
|
||
"若缺口是误报(如任务已另行安排),忽略即可。")
|
||
return "\n".join(lines)
|
||
|
||
|
||
async def _create_next_task(sor, project_id, task, next_role, pm_comment='',
|
||
next_title='', next_desc=''):
|
||
from appPublic.uniqueID import getID
|
||
title = getattr(task, 'title', '') or ''
|
||
params_str = getattr(task, 'params', '{}') or '{}'
|
||
try:
|
||
params = json.loads(params_str) if isinstance(params_str, str) else params_str
|
||
except (json.JSONDecodeError, TypeError):
|
||
params = {}
|
||
# 标题:不再继承上一任务 title + 追加「({next_role}阶段)」——那是 title 继承 bug 的根源,
|
||
# 导致 design 任务名变成「需求规格说明书(agent.design阶段)」跟需求名混同,回退重做后更叠加成
|
||
# 「...(回退重做)(agent.develop阶段)(agent.deploy_test阶段)」。优先用 PM 在 review_approve
|
||
# 里给出的 next_task_title(LLM 懂语义,流转决策权归它),缺失时按 RoleSpec.task_title 兜底。
|
||
stage = next_role
|
||
try:
|
||
pid = await _resolve_pipeline_id(project_id)
|
||
from pipeline_core import get_role_spec
|
||
spec = get_role_spec(pid, next_role)
|
||
if spec and getattr(spec, 'task_title', ''):
|
||
stage = spec.task_title
|
||
except Exception:
|
||
pass
|
||
pname = ''
|
||
try:
|
||
_p = await sor.sqlExe("SELECT name FROM sd_projects WHERE id=${pid}$", {"pid": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
pname = getattr(_p[0], 'name', '') if _p else ''
|
||
except Exception:
|
||
pname = ''
|
||
new_title = (next_title or '').strip() or (f"{pname} {stage}" if pname else stage)
|
||
|
||
new_params = {**params, 'previous_role': _normalize_role(getattr(task, 'role', '')),
|
||
'previous_task_id': getattr(task, 'id', ''), 'pm_comment': pm_comment}
|
||
# ⚠️ description 不能继承(2026-08-25 修复):原实现 new_params = {**params} 把上游任务的
|
||
# description 整个带下来,导致 design 派生的「应用脚手架 develop」拿到的是**需求任务的描述**
|
||
# ("产出需求规格说明书与需求评审记录"),于是它交付了文档、4 分钟就 approved —— 一行代码没写
|
||
# 却走完 develop 阶段,进而打破「应用级 develop approved = 编码已完成」这个 deploy_test 门控
|
||
# 前提,造成 deploy_test 在编码未完成时提前启动。
|
||
# 正确来源优先级:PM 的 next_task_description(LLM 按阶段语义写)> 阶段兜底描述。
|
||
_desc = (next_desc or '').strip()
|
||
if not _desc:
|
||
_desc = (f"承接上一阶段「{title}」的产出,执行 {next_role} 阶段工作:{stage}。"
|
||
f"先 load_skill 加载 role 技能,按其中职责与规范执行,产出物按规范落盘。")
|
||
if pm_comment:
|
||
_desc += f"\n\n上一阶段 PM 审核意见:{pm_comment}"
|
||
new_params['description'] = _desc
|
||
new_params['task_kind'] = 'new_dev'
|
||
# 清除 pm_assigned:它是「PM 按模块清单派发」的标记,只作用于当前任务;
|
||
# 下一角色任务是系统自动创建(非 PM 派发),若继承会污染——例如 PM 派发的 design 任务
|
||
# 带 pm_assigned=True,design approved 后自动创建的 develop 任务继承了它,被 1785 行的
|
||
# deploy_test 触发判断误判为「模块级 develop」,导致应用脚手架 develop approved 后跳过 deploy_test。
|
||
new_params.pop('pm_assigned', None)
|
||
|
||
# 幂等(2026-08-25 新增):并发场景下多个任务几乎同时 approved,每个都会走到这里尝试
|
||
# 创建下一阶段任务 → 同一迭代同一角色出现多个重复任务(历史上已出现「同一迭代两个 design
|
||
# 任务」)。这里先查同迭代同角色的活跃任务,存在则复用、不重复创建。
|
||
# 注:这是机制层不变量,不能靠 LLM 自觉(LLM 看不到并发)。
|
||
_iter = new_params.get('iteration_id') or ''
|
||
_dup = await sor.sqlExe(
|
||
"SELECT id, title, params FROM pipeline_tasks WHERE tenant_id=${pid}$ AND role=${r}$ "
|
||
"AND state IN ('submitted','running','review','qc_review','waiting') "
|
||
"ORDER BY created_at DESC LIMIT 20",
|
||
{"pid": project_id, "r": next_role})
|
||
await sor.sqlExe("COMMIT", {})
|
||
# 单例阶段:需求/设计每迭代只有一个(不像 develop 按模块并行多任务)。
|
||
# 系统派生时若同迭代已有活跃任务——无论来源(含 PM create_tasks 派发、无 previous_role)——
|
||
# 都复用,不重复创建。元景故障:PM 派的真设计任务(无 previous_role)不在旧幂等匹配范围,
|
||
# 导致自动派生的错位设计任务与它并存、各自 approved 各触发一次设计确认。
|
||
_single_stage = next_role in ('agent.requirement', 'agent.design')
|
||
for _d in (_dup or []):
|
||
try:
|
||
_dp = json.loads(getattr(_d, 'params', '{}') or '{}')
|
||
except (json.JSONDecodeError, TypeError):
|
||
_dp = {}
|
||
if (_dp.get('iteration_id') or '') != _iter:
|
||
continue
|
||
# 同迭代 + 同角色 + 同为系统派生(有 previous_role)→ 认定为同一阶段任务,幂等复用
|
||
if _dp.get('previous_role') or _single_stage:
|
||
_did = getattr(_d, 'id', '')
|
||
logger.info(f"_create_next_task 幂等命中:{next_role} 同迭代已有活跃任务 {_did},跳过创建")
|
||
return _did, getattr(_d, 'title', '') or new_title
|
||
|
||
new_task_id = getID()
|
||
await sor.C('pipeline_tasks', {
|
||
'id': new_task_id, 'tenant_id': project_id, 'pipeline_id': 'role_task',
|
||
'owner_id': 'pm', 'title': new_title,
|
||
'params': json.dumps(new_params, ensure_ascii=False),
|
||
'role': next_role, 'state': 'submitted', 'claimed_by': None,
|
||
})
|
||
logger.info(f"_create_next_task: {next_role} task={new_task_id}")
|
||
return new_task_id, new_title
|
||
|
||
|
||
def _task_iteration_name(task):
|
||
"""提取任务的迭代名(params.iteration_id,存的是迭代名称非 id)。"""
|
||
params_str = getattr(task, 'params', '{}') or '{}'
|
||
try:
|
||
params = json.loads(params_str) if isinstance(params_str, str) else params_str
|
||
return (params.get('iteration_id') or '').strip()
|
||
except (json.JSONDecodeError, TypeError):
|
||
return ''
|
||
|
||
|
||
async def _rollback_task_chain(sor, project_id, task_id, rollback_role, comment):
|
||
"""回退:追溯任务链(previous_task_id),作废回退目标及之后的任务,创建回退目标的新任务。
|
||
|
||
回退点之后的关联任务全部作废(state=cancelled),回退目标阶段重新做。
|
||
"""
|
||
from appPublic.uniqueID import getID
|
||
# 追溯任务链(previous_task_id 往前),得到 [最早 ... 当前]
|
||
chain = []
|
||
cur_id = task_id
|
||
visited = set()
|
||
while cur_id and cur_id not in visited:
|
||
visited.add(cur_id)
|
||
recs = await sor.R('pipeline_tasks', {'id': cur_id})
|
||
if not recs:
|
||
break
|
||
t = recs[0]
|
||
chain.append(t)
|
||
try:
|
||
p = json.loads(getattr(t, 'params', '{}') or '{}')
|
||
except (json.JSONDecodeError, TypeError):
|
||
p = {}
|
||
cur_id = p.get('previous_task_id') or ''
|
||
chain.reverse()
|
||
|
||
# 定位回退目标在链上的位置
|
||
target_idx = -1
|
||
for i, t in enumerate(chain):
|
||
if _normalize_role(getattr(t, 'role', '')) == rollback_role:
|
||
target_idx = i
|
||
break
|
||
if target_idx < 0:
|
||
return {"status": "error", "task_id": task_id,
|
||
"error": f"任务链上找不到回退目标角色 {rollback_role},可用:"
|
||
+ "、".join(_normalize_role(getattr(t, 'role', '')) for t in chain)}
|
||
|
||
target_task = chain[target_idx]
|
||
prev_task = chain[target_idx - 1] if target_idx > 0 else None
|
||
|
||
# 作废回退目标及之后的任务(含回退目标本身)
|
||
cancelled = []
|
||
for t in chain[target_idx:]:
|
||
tid = getattr(t, 'id', '')
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_tasks SET state='cancelled', claimed_by=NULL, updated_at=NOW() WHERE id=${tid}$",
|
||
{"tid": tid})
|
||
cancelled.append(tid)
|
||
|
||
# 创建回退目标的新任务(继承回退目标任务 params,记录回退信息,previous 指向前一任务)
|
||
try:
|
||
tp = json.loads(getattr(target_task, 'params', '{}') or '{}')
|
||
except (json.JSONDecodeError, TypeError):
|
||
tp = {}
|
||
new_params = {**tp, 'rollback_from': task_id, 'rollback_comment': comment}
|
||
# 任务来源标记:回退重做 = 修复导致上游失败的缺陷(质检重做),develop 据此必须走 bug 状态机。
|
||
new_params['task_kind'] = 'rework'
|
||
# 清 pm_assigned:回退重做任务是系统重建的任务(非 PM 按模块清单派发),继承 target_task 的
|
||
# pm_assigned 会污染——若 target 是应用级 develop(历史脏数据带 pm_assigned=True),approve 后被
|
||
# 「模块级 develop → 跳过 deploy_test」误判,任务链断在 approved。与 _create_next_task 的 pop 对齐。
|
||
new_params.pop('pm_assigned', None)
|
||
if prev_task:
|
||
new_params['previous_task_id'] = getattr(prev_task, 'id', '')
|
||
new_params['previous_role'] = _normalize_role(getattr(prev_task, 'role', ''))
|
||
else:
|
||
new_params.pop('previous_task_id', None)
|
||
new_params.pop('previous_role', None)
|
||
|
||
new_tid = getID()
|
||
new_title = f"{getattr(target_task, 'title', '') or '任务'}(回退重做)"
|
||
await sor.C('pipeline_tasks', {
|
||
'id': new_tid, 'tenant_id': project_id, 'pipeline_id': 'role_task',
|
||
'owner_id': 'pm', 'title': new_title,
|
||
'params': json.dumps(new_params, ensure_ascii=False),
|
||
'role': rollback_role, 'state': 'submitted', 'claimed_by': None,
|
||
})
|
||
await sor.sqlExe("COMMIT", {})
|
||
logger.info(f"_rollback_task_chain: task={task_id} -> rollback {rollback_role}, "
|
||
f"cancelled={len(cancelled)} 任务, new_task={new_tid}")
|
||
return {"status": "rollback", "task_id": task_id, "rollback_role": rollback_role,
|
||
"cancelled": cancelled, "new_task_id": new_tid, "comment": comment}
|
||
|
||
|
||
# ── Agent 工具执行 ──
|
||
|
||
def _parse_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 原生格式(全角竖线):<|DSML||invoke name="...">…<|DSML||parameter name="...">value</|DSML||parameter></|DSML||invoke>
|
||
# 与旧版 <invoke> 同款解析:长上下文多轮时 deepseek 会自发输出该格式,不解析则全部判「未识别」→ 5 轮耗尽。
|
||
# ⚠️ 竖线数量会变:实测出现过单竖线 <|DSML||invoke> 和双竖线 <||DSML||invoke>,用 |+ 兼容任意数量。
|
||
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_prod)SSH 到目标机时,注入机构独立密钥
|
||
# (每机构一把,防单点泄露;否则默认走 ~/.ssh/id_rsa 个人密钥)。
|
||
_who = (ctx or {}).get('who', '') or ''
|
||
_org = (ctx or {}).get('org_id', '') or ''
|
||
if _who in ('agent.deploy_test', 'agent.deploy_prod') and _org:
|
||
cmd = _inject_org_ssh_key(cmd, _org)
|
||
r = await _run_shell(cmd, workspace_dir, timeout=120)
|
||
return f"rc={r['rc']}\nSTDOUT:\n{r['stdout'][:2000]}\nSTDERR:\n{r['stderr'][:1000]}"
|
||
elif tool == 'git_clone':
|
||
url = p.get('repo_url', '')
|
||
if not url: return 'FAIL: 需要仓库URL'
|
||
name = p.get('repo_name', '') or url.rstrip('/').split('/')[-1].replace('.git', '')
|
||
if name.endswith('_app'):
|
||
target = os.path.join(workspace_dir, 'apps', name[:-4])
|
||
else:
|
||
target = os.path.join(workspace_dir, 'modules', name)
|
||
r = await _git_clone(url, target, p.get('branch', 'main'))
|
||
return f"rc={r['rc']} {r['message']}"
|
||
elif tool == 'git_status':
|
||
target = _resolve_repo_target(workspace_dir, p.get('repo_dir', ''))
|
||
r = await _run_shell('git status --short', target, 10)
|
||
r2 = await _run_shell('git log --oneline -3', target, 10)
|
||
return f"Status:\n{r['stdout'][:1000] or '(clean)'}\nRecent:\n{r2['stdout'][:500]}"
|
||
elif tool == 'git_commit_push':
|
||
msg = p.get('message', '') or 'agent update'
|
||
target = _resolve_repo_target(workspace_dir, p.get('repo_dir', ''))
|
||
r = await _git_commit_push(target, msg)
|
||
return f"rc={r['rc']} {r['message']}"
|
||
# 能力工具(propose_feature/create_case/report_bug 等,按角色 capability 注入)
|
||
from .capability_tools import exec_capability_tool, TOOL_SCHEMAS
|
||
if tool in TOOL_SCHEMAS:
|
||
return await exec_capability_tool(tool, p, ctx or {})
|
||
return f'未实现: {tool}'
|
||
except Exception as e:
|
||
return f'ERROR: {str(e)[:300]}'
|
||
|
||
|
||
# ── 角色 Agent ──
|
||
|
||
# 强制产出轮数:前 5 轮允许探索,第 6 轮起 auto-inject 强制产出。
|
||
# 治「探索死循环」——重做场景 + 复杂 workspace(已有大量文件/git 历史)时 develop 的 LLM 会迷失方向,
|
||
# 30 轮全耗在 list_files/read_file/run_shell 反复「了解现状 + 验证已有内容」,从不 write_file/deliver。
|
||
_FORCE_PRODUCE_TURN = 5
|
||
|
||
# LLM 调用硬超时(外层 asyncio.wait_for 兜底)。llm_bridge 内部 300s×3 重试最坏 15 分钟,
|
||
# 期间 role_agent_run 心跳不更新(心跳在调用前 touch),观察上像任务挂起/僵尸。
|
||
# 外层硬上限:① 防 aiohttp total 超时因 DNS 等场景失效导致的真正无限挂起 ② 给 llm_bridge 至少 2 轮完整重试机会
|
||
# (420s 只够 ~1.4 轮,网关瞬时挂起时重试被提前掐断,可自愈的瞬时故障也 TimeoutError 超限暂停任务链)。
|
||
_LLM_HARD_TIMEOUT = 600
|
||
|
||
_FORCE_PRODUCE_HINT = (
|
||
"⚠️ 你已经探索了足够多轮(已超过 5 轮)。现在必须立即产出并交付:\n"
|
||
"1. 用 write_file 写出实际交付文件(代码/文档/契约/DDL),不要再 read_file / list_files / run_shell / git_status 等探索或检查类工具。\n"
|
||
"2. 信息不确定就用你的专业判断给出合理结果,先产出再迭代。\n"
|
||
"3. 写完后立即调用 deliver 提交交付件,禁止再调用任何探索类工具。"
|
||
)
|
||
|
||
|
||
async def _build_fallback_deliverable(space_dir, written_files, role):
|
||
"""循环结束未 deliver 时,检测 write_file/git 实际产出,构造真实交付件给 QC(而非占位符「Agent未产出交付件」)。"""
|
||
lines = []
|
||
if written_files:
|
||
lines.append("本任务执行期间 write_file 实际写入的文件:")
|
||
for f in sorted(set(written_files)):
|
||
rel = os.path.relpath(f, space_dir) if f.startswith(space_dir) else f
|
||
lines.append("- " + rel)
|
||
# 扫描 apps/ + modules/ + projects/ 下的 git 仓库工作区变更
|
||
for sub in ('apps', 'modules', 'projects'):
|
||
base = os.path.join(space_dir, sub)
|
||
if not os.path.isdir(base):
|
||
continue
|
||
entries = ([(p, p) for p in sorted(os.listdir(base))] if sub == 'projects'
|
||
else [(f"{sub}/{n}", n) for n in sorted(os.listdir(base))])
|
||
for label, name in entries:
|
||
rp = os.path.join(base, name)
|
||
if os.path.isdir(os.path.join(rp, '.git')):
|
||
try:
|
||
r = await _run_shell('git status --short', rp, 10)
|
||
out = (r.get('stdout') or '').strip()
|
||
if out:
|
||
lines.append(f"git 仓库 [{label}] 工作区变更:")
|
||
lines.append(out[:2000])
|
||
except Exception:
|
||
pass
|
||
if not lines:
|
||
lines.append("(本轮未检测到 write_file 或 git 变更——agent 确实未产出)")
|
||
return {
|
||
"result": "\n".join(lines),
|
||
"summary": "Agent 循环结束未调用 deliver,以下为实际产出检测结果(供 QC 判断是否合格)",
|
||
"deliverable_type": f"{role}_fallback",
|
||
}
|
||
|
||
|
||
async def _build_role_skills_block(sor, project_id, role, org_id=""):
|
||
"""为角色 agent 构建技能目录块(分层导入第一层)。
|
||
|
||
所有技能(不分 scope)统一注入目录层(名字+描述),按优先级降序排列。
|
||
角色需要具体规范时用 load_skill 工具按需加载全文(分层导入第二层)。
|
||
优先级(同名覆盖,高→低):角色 > 项目 > 产线 > 机构 > 通用(SCOPE_PRIORITY 已定义)。
|
||
返回注入 system prompt 的文本,无技能或失败时返回空串。
|
||
"""
|
||
try:
|
||
pid = await _resolve_pipeline_id(project_id)
|
||
from pipeline_core.skill_loader import get_skill_loader, SCOPE_PRIORITY, SCOPE_TAG
|
||
from pipeline_core.skill_pack import get_skills_base
|
||
skills_dir = get_skills_base()
|
||
loader = get_skill_loader(skills_dir)
|
||
merged = loader.get_merged(pipeline_id=pid, role=role,
|
||
project_id=project_id, org_id=org_id or '0')
|
||
skills = sorted(merged.values(),
|
||
key=lambda s: (-int(getattr(s, 'essential', False)),
|
||
-SCOPE_PRIORITY.get(getattr(s, 'scope', ''), 0)))
|
||
lines = ["## 可用技能(目录,按需用 load_skill 加载全文;优先级 角色>项目>产线>组织>通用)"]
|
||
for s in skills[:60]:
|
||
tag = SCOPE_TAG.get(getattr(s, 'scope', ''), getattr(s, 'scope', ''))
|
||
lines.append(f"- [{tag}] {s.name}: {(s.description or '')[:100]}")
|
||
# 项目模块技能:机构工作空间 modules/*/skill + apps/*/skill/SKILL.md(模块怎么用——架构/数据模型/挂载函数/坑位),
|
||
# 运行时扫描注入,让项目角色知道引用了哪些模块、每个模块怎么挂载(load_xxx 入口/库名/坑)。
|
||
try:
|
||
ws = await _get_space_dir(sor, project_id)
|
||
module_skills = _collect_module_skills(ws)
|
||
if module_skills:
|
||
lines.append("\n## 项目模块技能(本项目引用的业务模块,load_skill 加载全文了解模块怎么用)")
|
||
for m in module_skills:
|
||
lines.append(f"- [项目模块] {m['name']}: {(m['description'] or '')[:100]}")
|
||
except Exception as e:
|
||
logger.warning(f"module skills collect failed: {e}")
|
||
return "\n".join(lines)
|
||
except Exception as e:
|
||
logger.warning(f"role skills load failed: {e}")
|
||
return ""
|
||
|
||
|
||
async def _collect_capability_tools(sor, project_id, role, org_id=""):
|
||
"""收集角色声明的 capability 对应的能力工具。
|
||
|
||
角色技能(scope=role)frontmatter 声明 `capability: feature_capability`(角色需要哪些能力),
|
||
产线状态机规范(scope=pipeline)frontmatter 声明 `capability + tools`(能力含哪些工具),
|
||
这里匹配两者,返回要注入的工具定义列表(name/description/params/required)。
|
||
"""
|
||
try:
|
||
pid = await _resolve_pipeline_id(project_id)
|
||
from pipeline_core.skill_loader import get_skill_loader
|
||
from pipeline_core.skill_pack import get_skills_base
|
||
from .capability_tools import resolve_capability_tools
|
||
skills_dir = get_skills_base()
|
||
loader = get_skill_loader(skills_dir)
|
||
merged = loader.get_merged(pipeline_id=pid, role=role,
|
||
project_id=project_id, org_id=org_id or '0')
|
||
if not merged:
|
||
return []
|
||
role_caps = set()
|
||
for skill in merged.values():
|
||
if getattr(skill, 'scope', '') == 'role':
|
||
cap = getattr(skill, 'capability', '') or ''
|
||
for c in str(cap).split(','):
|
||
c = c.strip()
|
||
if c:
|
||
role_caps.add(c)
|
||
return resolve_capability_tools(role_caps, merged)
|
||
except Exception as e:
|
||
logger.warning(f"collect capability tools failed: {e}")
|
||
return []
|
||
|
||
|
||
async def _load_skill_by_name(sor, project_id, role, org_id, name, file_path=None):
|
||
"""按需加载技能全文或子文件(分层导入第二层,对应 load_skill 工具)。
|
||
|
||
- 不带 file_path:返回技能 SKILL.md 全文 + 关联文件清单(references/scripts/templates/assets)。
|
||
- 带 file_path:返回 skill 目录下对应子文件内容(仅限 references/scripts/templates/assets)。
|
||
"""
|
||
role = _normalize_role(role) # 归一化:裸名 'qc'/'pm' → 'agent.qc'/'agent.pm',否则加载不到角色技能目录
|
||
name = (name or '').strip()
|
||
if not name:
|
||
return 'FAIL: 需要技能名称'
|
||
try:
|
||
pid = await _resolve_pipeline_id(project_id)
|
||
from pipeline_core.skill_loader import get_skill_loader
|
||
from pipeline_core.skill_pack import get_skills_base
|
||
skills_dir = get_skills_base()
|
||
loader = get_skill_loader(skills_dir)
|
||
merged = loader.get_merged(pipeline_id=pid, role=role,
|
||
project_id=project_id, org_id=org_id or '0')
|
||
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_run(pm_poller,state='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_calls,OpenAI 原生格式)
|
||
msgs.append({"role": "assistant", "content": resp.get("content") or None, "tool_calls": native_calls})
|
||
for tc in native_calls:
|
||
fn = tc.get("function", {}) if isinstance(tc, dict) else {}
|
||
tool = fn.get("name", "")
|
||
try:
|
||
params = json.loads(fn.get("arguments") or "{}")
|
||
except Exception:
|
||
params = {}
|
||
|
||
if tool == "deliver":
|
||
deliverable = {"action": "deliver", **params}
|
||
break
|
||
if tool == "ask_question":
|
||
ask_question = params.get("question", "")
|
||
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(默认 = 本次审核的里程碑任务),记录父子关系供任务树分层;
|
||
- key:LLM 给子任务起的短标识,供同批任务间 depends_on 相互引用;
|
||
- depends_on:依赖的 key 或任务ID 数组(空 = 并行;非空 = 串行等待依赖完成后才可认领)。
|
||
兼容单任务形态:params 直接含 {title, role, description}。
|
||
"""
|
||
from appPublic.uniqueID import getID
|
||
tasks = params.get('tasks') or []
|
||
if isinstance(tasks, str):
|
||
try:
|
||
tasks = json.loads(tasks)
|
||
except (json.JSONDecodeError, ValueError):
|
||
return 'FAIL: tasks 必须是 JSON 数组'
|
||
if not tasks:
|
||
if params.get('title'):
|
||
tasks = [{'title': params.get('title'), 'role': params.get('role'), 'description': params.get('description')}]
|
||
else:
|
||
return 'FAIL: 需要 tasks 数组或 title'
|
||
if not isinstance(tasks, list):
|
||
return 'FAIL: tasks 必须是数组'
|
||
|
||
# 默认父任务:本次审核的里程碑任务(子任务挂它名下,任务树据此分层)
|
||
default_parent = (params.get('parent_id') or parent_task_id or '').strip()
|
||
|
||
# 当前迭代(status='in_progress' 的唯一迭代),作为任务默认归属
|
||
iteration_name = ''
|
||
from .iteration_capability import get_current_iteration
|
||
cur = await get_current_iteration(sor, project_id)
|
||
if cur:
|
||
iteration_name = cur.get('iteration_name', '') or ''
|
||
|
||
created = [] # [(task_dict, tid, title, role)]
|
||
key_map = {} # key → 真实任务ID(同批 depends_on 用 key 互引,第二遍解析)
|
||
cancelled = []
|
||
warnings = []
|
||
for t in tasks:
|
||
if not isinstance(t, dict):
|
||
continue
|
||
title = (t.get('title') or '').strip()
|
||
if not title:
|
||
continue
|
||
role = _normalize_role(t.get('role') or 'agent.develop')
|
||
desc = (t.get('description') or '').strip()
|
||
key = (t.get('key') or '').strip()
|
||
parent = (t.get('parent_id') or '').strip() or default_parent
|
||
|
||
# 重做保护:同项目存在同标题活跃任务(submitted/running/review/qc_review)时,
|
||
# 先自动取消旧任务再创建新任务,避免两个相同任务并存(重做必须先终止旧任务)。
|
||
dup = await sor.sqlExe(
|
||
"SELECT id FROM pipeline_tasks WHERE tenant_id=${pid}$ AND title=${title}$ "
|
||
"AND state IN ('submitted','running','review','qc_review')",
|
||
{"pid": project_id, "title": title})
|
||
for d in (dup or []):
|
||
did = getattr(d, 'id', '')
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_tasks SET state='cancelled', claimed_by=NULL, updated_at=NOW() WHERE id=${did}$",
|
||
{"did": did})
|
||
cancelled.append(did)
|
||
|
||
tparams = {"description": desc, "pm_assigned": True}
|
||
# 任务来源标记:PM 派发的任务按 title/description 判断——
|
||
# 含「修复 Bug」= bug_fix(develop 必须走 fix_bug 状态机);否则 = new_dev(模块开发等)。
|
||
_title_desc = f"{title} {desc}"
|
||
if '修复 Bug' in _title_desc or '修复Bug' in _title_desc or '修复bug' in _title_desc:
|
||
tparams['task_kind'] = 'bug_fix'
|
||
else:
|
||
tparams['task_kind'] = 'new_dev'
|
||
if iteration_name:
|
||
tparams["iteration_id"] = iteration_name
|
||
# 启动策略(可选):PM 可为任务声明前置任务的启动方式
|
||
# {"mode": "all"} / {"mode": "any"} / {"mode": "at_least", "n": k}
|
||
# 非法值会被 _parse_dep_policy 保守兜底为 all,不会破坏编排。
|
||
_dp = t.get('dep_policy')
|
||
if isinstance(_dp, dict) and _dp.get('mode'):
|
||
tparams['dep_policy'] = _dp
|
||
tid = getID()
|
||
await sor.C('pipeline_tasks', {
|
||
'id': tid, 'tenant_id': project_id, 'pipeline_id': 'role_task',
|
||
'owner_id': 'pm', 'title': title, 'state': 'submitted',
|
||
'role': role, 'params': json.dumps(tparams, ensure_ascii=False),
|
||
'parent_id': parent or None,
|
||
})
|
||
if key:
|
||
key_map[key] = tid
|
||
created.append((t, tid, title, role))
|
||
|
||
# 第二遍:解析 depends_on(key 或 任务ID)→ 真实任务ID,回填 depends_on 列。
|
||
# 串行依赖 = depends_on 非空(依赖完成才认领);并行 = 空(不填)。
|
||
# 父任务本身在 PM approve 后即为 approved 终态,子任务无需再显式依赖父。
|
||
#
|
||
# ⚠️ 依赖完整性(2026-08-25 修复 D2/D3):原实现对未知引用**只警告、静默丢弃**,
|
||
# 对 len>=20 的引用**不校验存在性**。两者都会让任务以「更少的依赖」落库 →
|
||
# 依赖门控失效 → 依赖未完成就开跑(正是「多依赖时有依赖没完成就已经开始」的成因)。
|
||
# 现在:未解析成功的引用一律阻塞该任务(state='waiting' + 冒泡),绝不静默降级为并行。
|
||
dep_errors = [] # [(tid, title, [坏引用])]
|
||
for t, tid, title, role in created:
|
||
deps = t.get('depends_on') or []
|
||
if isinstance(deps, str):
|
||
try:
|
||
deps = json.loads(deps)
|
||
except (json.JSONDecodeError, ValueError):
|
||
deps = [deps] if deps.strip() else []
|
||
resolved = []
|
||
bad = []
|
||
for d in (deps or []):
|
||
d = (str(d) or '').strip()
|
||
if not d:
|
||
continue
|
||
if d in key_map:
|
||
resolved.append(key_map[d]) # 同批 key 引用
|
||
elif d == tid:
|
||
bad.append(f"{d}(依赖自身)")
|
||
elif len(d) >= 20:
|
||
# D3:不能只看长度就当有效 ID —— 必须确认该任务真实存在
|
||
_ex = await sor.sqlExe(
|
||
"SELECT id FROM pipeline_tasks WHERE id=${i}$", {"i": d})
|
||
if _ex:
|
||
resolved.append(d)
|
||
else:
|
||
bad.append(f"{d[:12]}(任务不存在)")
|
||
else:
|
||
bad.append(f"{d}(未知 key)")
|
||
if bad:
|
||
# 依赖解析不完整 → 任务不进入可认领状态,冒泡人工修正,避免提前开跑
|
||
dep_errors.append((tid, title, bad))
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_tasks SET state='waiting', depends_on=${deps}$ WHERE id=${tid}$",
|
||
{"deps": json.dumps(resolved, ensure_ascii=False) if resolved else None, "tid": tid})
|
||
warnings.append(f"「{title}」依赖无法解析({'、'.join(bad)})→ 已置 waiting 待人工修正")
|
||
elif resolved:
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_tasks SET depends_on=${deps}$ WHERE id=${tid}$",
|
||
{"deps": json.dumps(resolved, ensure_ascii=False), "tid": tid})
|
||
|
||
if not created:
|
||
return 'FAIL: 没有可创建的任务(缺少 title)'
|
||
# C 之后立即 COMMIT,释放行锁,供 agent_poller 下一轮可见认领
|
||
await sor.sqlExe("COMMIT", {})
|
||
# 编排查漏:派发后立即跑 G1/G2/G3,缺口当场返回给 PM(同回合即可修正)+
|
||
# 持久化通知兜底(本回合未修,下一回合仍能看到)。元景故障教训:只打日志没人看。
|
||
gap_note = ''
|
||
try:
|
||
_anchor = None
|
||
for _, _tid0, _, _ in created:
|
||
_ar = await sor.sqlExe("SELECT id, params FROM pipeline_tasks WHERE id=${i}$", {"i": _tid0})
|
||
if _ar:
|
||
_anchor = _ar[0]
|
||
break
|
||
_gaps = await _check_orchestration_gaps(sor, project_id, _anchor) if _anchor else []
|
||
if _gaps:
|
||
await _save_gap_notices(sor, project_id, _gaps, source_task_id=parent_task_id or '')
|
||
logger.warning(f"编排完备性告警(派发后) project={project_id}: {_gap_texts(_gaps)}")
|
||
gap_note = ("\n⚠️ 编排完备性检查发现缺口:\n" + "\n".join("· " + g['text'] for g in _gaps)
|
||
+ "\n请核实后立即用 update_task_deps/create_tasks 修正。")
|
||
except Exception as _ge:
|
||
logger.warning(f"派发后编排查漏失败: {_ge}")
|
||
note = f"(已自动取消 {len(cancelled)} 个同标题活跃任务)" if cancelled else ""
|
||
warn_note = f"({len(warnings)} 个未知依赖引用已忽略)" if warnings else ""
|
||
return f"OK: 已派发 {len(created)} 个任务" + note + warn_note + ":" + \
|
||
";".join([f"{title}({role})" for _, _, title, role in created]) + gap_note
|
||
|
||
|
||
async def _pm_list_tasks(sor, project_id, params):
|
||
"""PM 查看项目现有任务(派发前查重)。"""
|
||
role = (params.get('role') or '').strip()
|
||
state = (params.get('state') or '').strip()
|
||
sql = "SELECT id, title, role, state FROM pipeline_tasks WHERE tenant_id=${pid}$"
|
||
p = {"pid": project_id}
|
||
if role:
|
||
sql += " AND role=${role}$"
|
||
p["role"] = _normalize_role(role)
|
||
if state:
|
||
sql += " AND state=${state}$"
|
||
p["state"] = state
|
||
sql += " ORDER BY created_at ASC LIMIT 40"
|
||
recs = await sor.sqlExe(sql, p)
|
||
# 纯 SELECT 后 COMMIT,避免持有 MDL 锁阻塞后续 DDL
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return "暂无任务"
|
||
icons = {"submitted": "⏳", "running": "🔄", "review": "👀", "qc_review": "🔍",
|
||
"approved": "✅", "completed": "✔️", "failed": "❌", "waiting": "⏸️"}
|
||
lines = ["| 状态 | 任务 | 角色 |", "|------|------|------|"]
|
||
for r in recs:
|
||
st = getattr(r, 'state', '')
|
||
lines.append(f"| {icons.get(st, st)} | {getattr(r, 'title', '')} | {getattr(r, 'role', '')} |")
|
||
return "\n".join(lines)
|
||
|
||
|
||
async def _pm_cancel_task(sor, project_id, params):
|
||
"""PM 取消任务(重做/作废场景必须先取消旧任务,避免两个相同任务并存)。
|
||
|
||
委托 task_capability.cancel_task(CAS + 审计),正在执行中的 agent 会在下一轮心跳
|
||
检测到 cancelled 并自行中止(见 role_agent_run 的取消检测)。
|
||
"""
|
||
task_id = (params.get('task_id') or params.get('id') or '').strip()
|
||
if not task_id:
|
||
return 'FAIL: 需要 task_id'
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, title, state FROM pipeline_tasks WHERE id=${tid}$ AND tenant_id=${pid}$",
|
||
{"tid": task_id, "pid": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return 'FAIL: 任务不存在或不属于当前项目'
|
||
title = getattr(recs[0], 'title', '')
|
||
state = getattr(recs[0], 'state', '')
|
||
if state in ('completed', 'cancelled', 'failed', 'approved'):
|
||
return f'任务「{title}」已是终态 {state},无需取消'
|
||
from .task_capability import cancel_task
|
||
ok, msg = await cancel_task(task_id, project_id, who="agent.pm")
|
||
if not ok:
|
||
return f'FAIL: {msg}'
|
||
return f'OK: 已取消任务「{title}」({task_id}),正在执行的 agent 将在下一轮心跳中止'
|
||
|
||
|
||
async def _pm_update_task_deps(sor, project_id, params):
|
||
"""PM 给已存在任务补前置依赖(G2/G3 查漏的修正出口)。
|
||
|
||
代码只发现缺口并把事实交给 PM(流转决策权归 LLM),PM 核实后用本原语修正。
|
||
params: {task_id, add_depends_on: [任务ID...], dep_policy?: {mode, n?}}
|
||
"""
|
||
task_id = (params.get('task_id') or params.get('id') or '').strip()
|
||
add = params.get('add_depends_on') or []
|
||
if isinstance(add, str):
|
||
try:
|
||
add = json.loads(add)
|
||
except (json.JSONDecodeError, ValueError):
|
||
add = []
|
||
add = [str(x).strip() for x in (add or []) if str(x).strip()]
|
||
if not task_id:
|
||
return 'FAIL: 需要 task_id'
|
||
if not add:
|
||
return 'FAIL: 需要 add_depends_on(要追加的任务ID数组)'
|
||
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, title, state, depends_on, params FROM pipeline_tasks "
|
||
"WHERE id=${tid}$ AND tenant_id=${pid}$",
|
||
{"tid": task_id, "pid": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if not recs:
|
||
return 'FAIL: 任务不存在或不属于当前项目'
|
||
rec = recs[0]
|
||
title = getattr(rec, 'title', '')
|
||
state = getattr(rec, 'state', '')
|
||
if state in ('completed', 'cancelled', 'failed', 'approved'):
|
||
return f'FAIL: 任务「{title}」已是终态 {state},改依赖无意义'
|
||
|
||
# 校验追加的依赖真实存在(防编造 ID,与 _pm_create_tasks 的 D3 校验同标准)
|
||
resolved, bad = [], []
|
||
for d in add:
|
||
_ex = await sor.sqlExe("SELECT id FROM pipeline_tasks WHERE id=${i}$", {"i": d})
|
||
if _ex:
|
||
resolved.append(d)
|
||
else:
|
||
bad.append(d[:12])
|
||
if bad:
|
||
return 'FAIL: 以下任务ID不存在:' + '、'.join(bad)
|
||
|
||
# 合并现有 depends_on(去重、防自依赖)
|
||
try:
|
||
cur = json.loads(getattr(rec, 'depends_on', '') or '[]')
|
||
cur = [str(x) for x in cur if x] if isinstance(cur, list) else []
|
||
except (json.JSONDecodeError, TypeError):
|
||
cur = []
|
||
if task_id in resolved:
|
||
return 'FAIL: 不能依赖自身'
|
||
merged = cur + [d for d in resolved if d not in cur]
|
||
|
||
# 可选更新启动策略
|
||
try:
|
||
tparams = json.loads(getattr(rec, 'params', '{}') or '{}')
|
||
except (json.JSONDecodeError, TypeError):
|
||
tparams = {}
|
||
_dp = params.get('dep_policy')
|
||
if isinstance(_dp, dict) and _dp.get('mode'):
|
||
tparams['dep_policy'] = _dp
|
||
|
||
await sor.sqlExe(
|
||
"UPDATE pipeline_tasks SET depends_on=${deps}$, params=${p}$, updated_at=NOW() WHERE id=${tid}$",
|
||
{"deps": json.dumps(merged, ensure_ascii=False),
|
||
"p": json.dumps(tparams, ensure_ascii=False), "tid": task_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
from .audit import record_audit
|
||
await record_audit(project_id, 'pipeline_tasks', task_id, 'update_deps',
|
||
who='agent.pm',
|
||
detail=f"depends_on += {resolved}", sor=sor)
|
||
return (f'OK: 任务「{title}」depends_on 已更新为 {merged}'
|
||
+ (f',启动策略={_dp}' if isinstance(_dp, dict) and _dp.get('mode') else ''))
|
||
|
||
|
||
async def pm_review_run(project_id, agent_id=None, model_name=None):
|
||
db = _get_db()
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
# PM 审核用产线 default_model(无角色专属模型),org_id 多租户隔离
|
||
model_name, org_id = await _resolve_llm_context(sor, project_id, 'pm', model_name)
|
||
# 暂停门控:项目 paused 时 PM 不推进(不认领审核任务)。默认必须推进,暂停才需要指令。
|
||
_precs = await sor.sqlExe(
|
||
"SELECT status FROM sd_projects WHERE id=${pid}$", {"pid": project_id})
|
||
await sor.sqlExe("COMMIT", {})
|
||
if _precs and getattr(_precs[0], 'status', '') == 'paused':
|
||
return {"status": "idle", "message": "项目已暂停推进"}
|
||
task = await _claim_task(sor, project_id, '', state=TASK_REVIEW, match_role=False, set_state='review')
|
||
if not task:
|
||
return {"status": "idle", "message": "没有待审核任务"}
|
||
|
||
task_id = task.id
|
||
title = getattr(task, "title", "") or ""
|
||
task_role = _normalize_role(getattr(task, "role", "") or "")
|
||
# 豁免兜底(2026-09-03):产线自带质量门禁的任务(如投标产线,
|
||
# params.task_kind=bid_* / skip_generic_qc)PM 不复审内容,直接放行——
|
||
# 质量判定归产线流转引擎(章节评审打分 + 解析产出契合度审核)。
|
||
_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 "迭代已结束,链终止"}
|
||
# 部署以应用为单位:模块级 develop(PM 按模块派发,params 含 pm_assigned)approved 后
|
||
# 不创建 deploy_test——模块不独立部署,deploy_test 是应用级,由应用脚手架 develop 触发。
|
||
# 否则每个模块都会生成一个 deploy_test,导致「部署以模块为单位」(多端口问题的部署侧根源)。
|
||
_tp = {}
|
||
try:
|
||
_tp = json.loads(getattr(task, 'params', '{}') or '{}')
|
||
except Exception:
|
||
_tp = {}
|
||
# 模块级 develop = PM 用 create_tasks 直接派发(previous_role 为空);应用级 develop =
|
||
# 系统派生/回退重做(previous_role=agent.design)。用 previous_role 区分而非 pm_assigned——
|
||
# pm_assigned 会被 design 任务污染(design 也是 PM create_tasks 派发、带 pm_assigned=True,
|
||
# 派生的应用级 develop 曾因此误判为「模块级」跳过 deploy_test)。
|
||
if task_role == 'agent.develop' and not _tp.get('previous_role'):
|
||
logger.info(f"模块级 develop approved,跳过 deploy_test(部署以应用为单位): {task_id}")
|
||
return {"status": "approved", "task_id": task_id, "comment": comment,
|
||
"next_role": "", "skip_next": "module_not_deployed_independently"}
|
||
# 需求/设计人工确认节点已移除(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 拿到需求描述」的根源)。
|
||
# 流转决策权归 LLM:PM 写了就用 PM 的;没写才用 RoleSpec 阶段模板兜底。
|
||
next_tid, next_title = await _create_next_task(
|
||
sor, project_id, task, next_role, comment,
|
||
next_title=decision.get('next_title', '') or '',
|
||
next_desc=decision.get('next_desc', '') or '')
|
||
# 完备性校验(代码只查漏、不替 PM 决策):缺口事实持久化为通知,
|
||
# 注入 PM 下一回合上下文(元景故障教训:只打日志没人看,事实必须送到 PM 手上)。
|
||
try:
|
||
_gaps = await _check_orchestration_gaps(sor, project_id, task, next_role, next_tid)
|
||
if _gaps:
|
||
logger.warning(f"编排完备性告警 task={next_tid}: {_gap_texts(_gaps)}")
|
||
await _save_gap_notices(sor, project_id, _gaps, source_task_id=next_tid)
|
||
except Exception as _e:
|
||
logger.warning(f"_check_orchestration_gaps failed: {_e}")
|
||
return {"status": "approved", "task_id": task_id, "next_task_id": next_tid, "next_role": next_role, "comment": comment}
|
||
else:
|
||
# 项目完结(无下一阶段)→ 后置创建项目复盘任务(非阻塞,失败不影响完结)
|
||
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="产线自带质量门禁,豁免通用QC(skip_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_id;QC 审查需求/设计时核对功能落库)
|
||
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_failed(failed 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)
|
||
# 认领(CAS:claimed_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.TimeoutError(str 为空),视为瞬时可重跑。
|
||
"""
|
||
err = (last_error or "").strip().lower()
|
||
if not err:
|
||
return "retry"
|
||
for h in _RETRY_HINTS:
|
||
if h in err:
|
||
return "retry"
|
||
return "fault"
|
||
|
||
|
||
async def handle_failed_task(task_id: str, project_id: str) -> dict:
|
||
"""处理一个失败任务:判定重跑或报故障。由 failed poller 周期调用。
|
||
|
||
- retry_count < 最大重复数(task_max_retry,默认3) 且失败原因判定为瞬时 → 重跑(retry_count+1, 回 submitted)
|
||
- retry_count >= 最大重复数 或判定为永久错误 → 暂停任务链(pause_project) + 报故障(建问题通知用户,任务置 waiting)
|
||
"""
|
||
db = _get_db()
|
||
async with db.sqlorContext("pipeline") as sor:
|
||
recs = await sor.sqlExe(
|
||
"SELECT id, title, role, retry_count, last_error FROM pipeline_tasks "
|
||
"WHERE id=${tid}$ AND state='failed'",
|
||
{"tid": task_id})
|
||
if not recs:
|
||
# 释放 SELECT 的元数据锁,防止连接回池后阻塞 DDL
|
||
await sor.sqlExe("COMMIT", {})
|
||
return {"status": "idle", "task_id": task_id}
|
||
# 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_project,PM 不再推进)② 建问题通知用户,任务置 waiting 等人工介入
|
||
try:
|
||
from .project_capability import pause_project
|
||
pok, pmsg = await pause_project(project_id, who="agent.pm")
|
||
logger.info("failed task pause project: task=%s pause=%s msg=%s", task_id, pok, pmsg)
|
||
except Exception as e:
|
||
logger.warning("failed task pause_project error: %s", e)
|
||
from .communication import raise_problem
|
||
reporter = "agent.main_agent" if role == "agent.pm" else "agent.pm"
|
||
qid = await raise_problem(
|
||
"fault_report",
|
||
f"任务「{title}」重复 {retry_count} 次已达上限({max_retry}),任务链已暂停,请人工介入处理。失败原因:{last_error or '未知'}",
|
||
reporter, tenant_id=project_id, task_id=task_id,
|
||
first_handler_role="agent.main_agent",
|
||
context={"fault": True, "last_error": last_error, "retry_count": retry_count,
|
||
"max_retry": max_retry, "chain_paused": True})
|
||
logger.info("failed task fault: task=%s role=%s qid=%s", task_id, role, qid)
|
||
return {"status": "fault", "task_id": task_id, "question_id": qid, "chain_paused": True}
|
||
|
||
|
||
async def role_agent_loop(project_id, role, agent_id=None, model_name=None, max_iterations=10):
|
||
results = []
|
||
for _ in range(max_iterations):
|
||
r = await role_agent_run(project_id, role, agent_id, model_name)
|
||
results.append(r)
|
||
if r["status"] in ("idle", "need_info", "failed"):
|
||
break
|
||
await asyncio.sleep(1)
|
||
return results
|
||
|
||
|
||
async def pm_review_loop(project_id, agent_id=None, model_name=None, max_iterations=20):
|
||
results = []
|
||
for _ in range(max_iterations):
|
||
r = await pm_review_run(project_id, agent_id, model_name)
|
||
results.append(r)
|
||
if r["status"] in ("idle", "failed"):
|
||
break
|
||
await asyncio.sleep(1)
|
||
return results
|
||
|
||
|
||
# 向后兼容
|
||
async def run_agent_loop(project_id, role_name, model_name=None):
|
||
role = _normalize_role(role_name or "develop")
|
||
if role == 'pm':
|
||
return await pm_review_run(project_id, model_name=model_name)
|
||
return await role_agent_run(project_id, role_name or "develop", model_name=model_name)
|
||
|
||
|
||
async def agent_loop(project_id, role_name, model_name=None, max_iterations=10):
|
||
role = _normalize_role(role_name or "develop")
|
||
if role == 'pm':
|
||
return await pm_review_loop(project_id, model_name=model_name, max_iterations=max_iterations)
|
||
return await role_agent_loop(project_id, role_name or "develop", model_name=model_name, max_iterations=max_iterations)
|