Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,5 @@ src/backend/half.db
src/backend/repos/

# Optional local-only operator notes
/AGENTS.md
/LOCAL.md
30 changes: 14 additions & 16 deletions docs/prd/issue-code-review-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
## 2. 目标

1. 在流程模板中新增“Issue 编码与双 Agent 评审循环”模板。
2. 支持用户在应用模板时填写 issue URL、目标分支策略、评审提示词等必要输入
2. 支持用户在应用模板时填写 issue URL、评审提示词、测试命令、最大评审轮次等必要输入
3. 由编码 Agent 从用户给定的 issue URL 拉取需求,完成编码、测试、推送到项目仓库新分支。
4. 由两个评审 Agent 并行从项目仓库新分支拉取代码,执行评审并把评审结果提交到 HALF 协作仓库。
5. 流程固定使用 5 个 Task,Task 作为角色槽位复用;实际轮次和业务状态记录在 HALF 协作仓库的 `flow-state.json` 和轮次产物文件中。
Expand Down Expand Up @@ -51,17 +51,16 @@

## 5. 模板输入

应用模板时必须填写以下输入
应用模板时支持以下输入

| 字段 | 必填 | 说明 |
|---|---|---|
| `issue_url` | 是 | 待实现 issue 的 URL。Agent 需要从该 URL 获取需求内容。 |
| `base_branch` | 是 | 新功能分支的基准分支,例如 `main` 或 `develop`。 |
| `work_branch_name` | 否 | 项目仓库中的工作分支名。为空时由编码 Agent 根据 issue 编号和时间生成。 |
| `review_prompt` | 是 | 两个评审 Agent 使用的评审提示词或评审维度。 |
| `test_command` | 否 | 建议执行的测试命令。为空时编码 Agent 根据项目约定自行判断。 |
| `max_review_rounds` | 是 | 最大评审循环次数,默认 3。达到上限仍未通过时进入人工处理。 |
| `pr_target_branch` | 否 | PR 目标分支。为空时默认等于 `base_branch`。 |

当前版本不提供分支输入项:项目代码仓库固定以 `main` 作为基准分支,工作分支名由编码 Agent 根据 issue 编号和时间自动生成,PR 目标分支固定为 `main`。

项目必须配置:

Expand All @@ -77,7 +76,7 @@
2. 用户进入 Plan 页,选择“使用模板生成流程”。
3. 用户选择“Issue 编码与双 Agent 评审循环”模板。
4. 用户完成三个角色槽位映射。
5. 用户填写 `issue_url`、`base_branch`、`review_prompt`、`max_review_rounds` 等模板输入
5. 用户填写 `issue_url`、`review_prompt`、`max_review_rounds`,并可选填写 `test_command`
6. HALF 生成任务流程并进入任务执行页。
7. 项目负责人派发 `TASK-001`,由编码 Agent 拉取 issue、理解需求、生成执行计划,并初始化 `flow-state.json`。
8. `TASK-001` 完成后,前端根据 `flow-state.json` 解锁 `TASK-002`。
Expand Down Expand Up @@ -464,7 +463,7 @@ TASK-005 决策

1. 开始前同步 HALF 协作仓库和项目代码仓库。
2. 从 `issue_url` 获取 issue 内容,并在产物中记录 issue 摘要。
3. 从 `base_branch` 创建或更新 `work_branch`
3. 固定以项目代码仓库 `main` 分支作为基准分支,并由 Agent 根据 issue 编号和时间自动生成工作分支名
4. 完成代码修改和必要测试。
5. 执行 `test_command`;若为空,则根据项目约定选择合理测试命令。
6. 只有测试通过且代码已经 push 到项目仓库后,才允许写入本轮 `branch.json` 并把 `flow-state.json` 更新为 `awaiting_review`。
Expand Down Expand Up @@ -496,7 +495,7 @@ TASK-005 决策
1. 开始前读取 `flow-state.json`;如果 `TASK-005` 不是 `unlocked`,必须停止并说明原因。
2. 只读取 `TASK-003` / `TASK-004` 当前轮次目录下的两份 `review.json`;若任一评审文件缺失、非法或锚点不匹配,必须停止并说明原因。
3. 若任一评审不同意合并,写入 `decision.json` / `decision.md`,更新 `flow-state.json` 为 `needs_fix`,解锁 `TASK-002`,冻结 `TASK-003` / `TASK-004` / `TASK-005`。
4. 若两个评审都同意合并,先把 `TASK-002` 标记为 `approved`,再提交 PR,写入 `pr.json` / `pr.md`,最后把流程标记为 `completed`。
4. 若两个评审都同意合并,先把 `TASK-002` 标记为 `approved`,再以 `main` 作为目标分支提交 PR,写入 `pr.json` / `pr.md`,最后把流程标记为 `completed`。
5. 若达到 `max_review_rounds` 且仍未通过,写入人工处理报告,将流程标记为 `needs_attention`。

---
Expand Down Expand Up @@ -588,7 +587,7 @@ GET /api/projects/:id/flow-state
### 12.1 MVP 必需能力

1. 允许新增一个 `agent_count = 3` 的流程模板。
2. 模板支持声明 `issue_url`、`base_branch`、`review_prompt`、`test_command`、`max_review_rounds`、`pr_target_branch` 等 `required_inputs`。
2. 模板支持声明 `issue_url`、`review_prompt`、`test_command`、`max_review_rounds` 等 `required_inputs`;当前版本不声明分支相关输入
3. 任务 prompt 中能注入模板输入。
4. 模板固定生成 5 个 Task,并在任务描述中明确每个 Task 的角色槽位语义。
5. 后端能读取 `<collaboration_dir>/flow-state.json` 并提供给前端。
Expand Down Expand Up @@ -641,10 +640,10 @@ GET /api/projects/:id/flow-state

1. 用户可以在流程模板列表中看到“Issue 编码与双 Agent 评审循环”模板。
2. 模板要求 3 个 Agent,并能完成 `agent-1`、`agent-2`、`agent-3` 的角色映射。
3. 用户应用模板时必须填写 `issue_url`、`base_branch`、`review_prompt` 和 `max_review_rounds`。
3. 用户应用模板时必须填写 `issue_url`、`review_prompt` 和 `max_review_rounds`,可选填写 `test_command`。
4. 应用模板后固定生成 `TASK-001` 到 `TASK-005` 五个 Task。
5. `TASK-001` prompt 中包含 issue URL,并要求生成计划和初始化 `flow-state.json`。
6. `TASK-002` prompt 中包含项目代码仓库、基准分支、工作分支策略、测试要求和 `flow-state.json` 更新规则。
6. `TASK-002` prompt 中包含项目代码仓库、固定 `main` 基准分支、自动工作分支策略、测试要求和 `flow-state.json` 更新规则。
7. `TASK-002` 完成编码后,前端能显示 `TASK-002 = waiting_review`,并解锁两个评审任务。
8. 两个评审任务 prompt 中包含同一工作分支、同一 commit、同一评审提示词,并要求输出 `approve_merge`。
9. 两个评审结果都提交后,后端能派生 `TASK-005 = unlocked`,前端能显示该状态,并冻结 `TASK-003` / `TASK-004`。
Expand All @@ -660,8 +659,7 @@ GET /api/projects/:id/flow-state

## 16. 待确认问题

1. `max_review_rounds` 的默认值是否固定为 3。
2. PR 创建方式是否只通过编码 Agent 在其环境中执行,还是未来由 HALF 提供 Git 平台集成。
3. 评审意见是否需要在 HALF 前端做结构化展示,还是 MVP 仅展示产物文件路径。
4. 是否需要为不同项目预置多套 `review_prompt` 模板。
5. `flow-state.json` 是否需要支持人工修正操作,以及该操作是否仅限管理员。
1. PR 创建方式是否只通过编码 Agent 在其环境中执行,还是未来由 HALF 提供 Git 平台集成。
2. 评审意见是否需要在 HALF 前端做结构化展示,还是 MVP 仅展示产物文件路径。
3. 是否需要为不同项目预置多套 `review_prompt` 模板。
4. `flow-state.json` 是否需要支持人工修正操作,以及该操作是否仅限管理员。
2 changes: 2 additions & 0 deletions src/backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

logger = logging.getLogger("half.config")

DEFAULT_MAX_REVIEW_ROUNDS = 3


_DEFAULT_INSECURE_SECRETS = {
"example-insecure-secret-placeholder",
Expand Down
5 changes: 4 additions & 1 deletion src/backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from dotenv import load_dotenv
load_dotenv()

from config import settings, validate_security_config
from config import DEFAULT_MAX_REVIEW_ROUNDS, settings, validate_security_config
from database import engine, SessionLocal, Base
from models import Agent, User, AgentTypeConfig, ModelDefinition, AgentTypeModelMap, Project, ProjectPlan, Task, GlobalSetting, ProcessTemplate
from auth import hash_password
Expand All @@ -28,6 +28,7 @@
from services.polling_service import polling_loop
from services.prompt_settings import DEFAULT_PLAN_CO_LOCATION_GUIDANCE, PLAN_CO_LOCATION_GUIDANCE_KEY
from services.demo_seed import DEMO_AGENT_TYPE_CATALOG, DEMO_MODEL_CAPABILITIES, seed_demo_project
from services.issue_review_loop import ensure_issue_review_loop_template

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("half")
Expand Down Expand Up @@ -122,6 +123,7 @@ def ensure_schema_updates():
"polling_start_delay_minutes": "INTEGER",
"polling_start_delay_seconds": "INTEGER",
"task_timeout_minutes": "INTEGER",
"default_max_review_rounds": f"INTEGER DEFAULT {DEFAULT_MAX_REVIEW_ROUNDS}",
"planning_mode": "TEXT DEFAULT 'balanced'",
"template_inputs_json": "TEXT DEFAULT '{}'",
},
Expand Down Expand Up @@ -407,6 +409,7 @@ def init_db():
db.refresh(admin)
repair_legacy_agent_owners(db, admin)
repair_legacy_project_owners(db, admin)
ensure_issue_review_loop_template(db, admin)
if settings.DEMO_SEED_ENABLED:
if seed_demo_project(db, admin):
logger.info("Demo project seed loaded")
Expand Down
2 changes: 2 additions & 0 deletions src/backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from sqlalchemy import (
Column, Integer, Text, Boolean, DateTime, ForeignKey, UniqueConstraint,
)
from config import DEFAULT_MAX_REVIEW_ROUNDS
from database import Base


Expand Down Expand Up @@ -81,6 +82,7 @@ class Project(Base):
polling_start_delay_minutes = Column(Integer, nullable=True) # NULL means use global default
polling_start_delay_seconds = Column(Integer, nullable=True) # NULL means use global default
task_timeout_minutes = Column(Integer, nullable=True)
default_max_review_rounds = Column(Integer, nullable=False, default=DEFAULT_MAX_REVIEW_ROUNDS)
planning_mode = Column(Text, default="balanced")
template_inputs_json = Column(Text, default="{}")
created_by = Column(Integer, ForeignKey("users.id"))
Expand Down
22 changes: 22 additions & 0 deletions src/backend/routers/process_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@

from access import get_owned_project, load_usable_agents
from auth import get_current_user
from config import DEFAULT_MAX_REVIEW_ROUNDS
from database import get_db
from models import Agent, ProcessTemplate, ProjectPlan, User
from routers.plans import finalize_plan_record
from schemas import UtcDatetimeModel
from services.path_service import ExpectedOutputPathError, normalize_expected_output_path
from services.project_agents import agent_ids_from_assignments_json
from services.issue_review_loop import DEFAULT_REVIEW_PROMPT, FLOW_TYPE

router = APIRouter(prefix="/api/process-templates", tags=["process_templates"])

Expand Down Expand Up @@ -257,6 +259,7 @@ def validate_required_inputs(value: object | None) -> list[dict[str, object]]:
"label": label,
"required": required,
"sensitive": sensitive,
**({"default_value": str(item.get("default_value"))} if item.get("default_value") is not None else {}),
})
return normalized

Expand Down Expand Up @@ -500,6 +503,25 @@ def apply_template(
for task in applied_data["tasks"]:
task["assignee"] = slot_to_slug[task["assignee"]]

if applied_data.get("flow_type") == FLOW_TYPE:
try:
template_inputs = json.loads(project.template_inputs_json or "{}")
except json.JSONDecodeError:
template_inputs = {}
if not isinstance(template_inputs, dict):
template_inputs = {}
template_inputs_changed = False
if not str(template_inputs.get("max_review_rounds") or "").strip():
template_inputs["max_review_rounds"] = str(
getattr(project, "default_max_review_rounds", None) or DEFAULT_MAX_REVIEW_ROUNDS
)
template_inputs_changed = True
if not str(template_inputs.get("review_prompt") or "").strip():
template_inputs["review_prompt"] = DEFAULT_REVIEW_PROMPT
template_inputs_changed = True
if template_inputs_changed:
project.template_inputs_json = json.dumps(template_inputs, ensure_ascii=False)

now = datetime.now(timezone.utc)
db.query(ProjectPlan).filter(
ProjectPlan.project_id == project.id,
Expand Down
23 changes: 23 additions & 0 deletions src/backend/routers/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from models import Agent, Project, ProjectPlan, Task, TaskEvent, User
from auth import get_current_user
from schemas import UtcDatetimeModel
from config import DEFAULT_MAX_REVIEW_ROUNDS
from services.polling_config_service import get_global_polling_settings
from services.git_service import validate_git_url
from services.project_agents import (
Expand All @@ -20,6 +21,7 @@
serialize_agent_assignments,
)
from services.agents import derive_agent_status
from services.issue_review_loop import get_issue_review_flow_state

router = APIRouter(prefix="/api/projects", tags=["projects"])

Expand Down Expand Up @@ -66,6 +68,7 @@ class ProjectCreate(BaseModel):
polling_start_delay_minutes: Optional[int] = None # None = use global default
polling_start_delay_seconds: Optional[int] = None # None = use global default
task_timeout_minutes: Optional[int] = None
default_max_review_rounds: Optional[int] = DEFAULT_MAX_REVIEW_ROUNDS
planning_mode: str = DEFAULT_PLANNING_MODE
template_inputs: Optional[object] = None

Expand All @@ -84,6 +87,7 @@ class ProjectUpdate(BaseModel):
polling_start_delay_minutes: Optional[int] = None
polling_start_delay_seconds: Optional[int] = None
task_timeout_minutes: Optional[int] = None
default_max_review_rounds: Optional[int] = None
planning_mode: Optional[str] = None
template_inputs: Optional[object] = None

Expand All @@ -105,6 +109,7 @@ class ProjectResponse(UtcDatetimeModel):
polling_start_delay_minutes: Optional[int]
polling_start_delay_seconds: Optional[int]
task_timeout_minutes: Optional[int]
default_max_review_rounds: int
planning_mode: str
template_inputs: dict[str, str]
agent_assignments: list[AgentAssignment]
Expand Down Expand Up @@ -187,6 +192,7 @@ def _build_project_response(db: Session, project: Project, next_step: Optional[s
'polling_start_delay_minutes': project.polling_start_delay_minutes,
'polling_start_delay_seconds': project.polling_start_delay_seconds,
'task_timeout_minutes': project.task_timeout_minutes,
'default_max_review_rounds': getattr(project, 'default_max_review_rounds', None) or DEFAULT_MAX_REVIEW_ROUNDS,
'planning_mode': _normalize_planning_mode(getattr(project, 'planning_mode', None)),
'template_inputs': _parse_template_inputs_json(getattr(project, 'template_inputs_json', None)),
'inactive_agent_ids': _inactive_project_agent_ids(db, project),
Expand Down Expand Up @@ -384,6 +390,14 @@ def _validate_polling_params(
raise HTTPException(status_code=400, detail="task_timeout_minutes must be 1-120 minutes")


def _validate_default_max_review_rounds(value: Optional[int]) -> int:
if value is None:
return DEFAULT_MAX_REVIEW_ROUNDS
if value < 1 or value > 20:
raise HTTPException(status_code=400, detail="default_max_review_rounds must be 1-20")
return value


def _resolve_polling_snapshot(
db: Session,
interval_min: Optional[int],
Expand Down Expand Up @@ -453,6 +467,7 @@ def create_project(body: ProjectCreate, db: Session = Depends(get_db), user: Use
polling_start_delay_minutes=polling_snapshot["polling_start_delay_minutes"],
polling_start_delay_seconds=polling_snapshot["polling_start_delay_seconds"],
task_timeout_minutes=polling_snapshot["task_timeout_minutes"],
default_max_review_rounds=_validate_default_max_review_rounds(body.default_max_review_rounds),
planning_mode=_normalize_planning_mode(body.planning_mode),
template_inputs_json=json.dumps(_normalize_template_inputs(body.template_inputs), ensure_ascii=False),
)
Expand All @@ -477,6 +492,12 @@ def get_project(project_id: int, db: Session = Depends(get_db), user: User = Dep
return _build_project_response(db, project, next_step=next_step, task_summary=task_summary)


@router.get('/{project_id}/flow-state')
def get_project_flow_state(project_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
project = get_owned_project(db, project_id, user)
return get_issue_review_flow_state(db, project)


@router.put('/{project_id}', response_model=ProjectResponse)
def update_project(project_id: int, body: ProjectUpdate, db: Session = Depends(get_db), user: User = Depends(get_current_user)):
project = get_owned_project(db, project_id, user)
Expand Down Expand Up @@ -521,6 +542,8 @@ def update_project(project_id: int, body: ProjectUpdate, db: Session = Depends(g
update_data['task_timeout_minutes'] = get_global_polling_settings(db)["task_timeout_minutes"]
if 'planning_mode' in update_data:
update_data['planning_mode'] = _normalize_planning_mode(update_data['planning_mode'])
if 'default_max_review_rounds' in update_data:
update_data['default_max_review_rounds'] = _validate_default_max_review_rounds(update_data['default_max_review_rounds'])
if 'template_inputs' in update_data:
update_data['template_inputs_json'] = json.dumps(
_normalize_template_inputs(update_data.pop('template_inputs')),
Expand Down
Loading
Loading