From a4e2d00d424cc27d643ba0cd6ca3e4a0a1987f6e Mon Sep 17 00:00:00 2001 From: YXW Date: Fri, 15 May 2026 12:18:49 +0800 Subject: [PATCH 1/8] docs: add issue code review loop prd --- docs/prd/issue-code-review-loop.md | 667 +++++++++++++++++++++++++++++ 1 file changed, 667 insertions(+) create mode 100644 docs/prd/issue-code-review-loop.md diff --git a/docs/prd/issue-code-review-loop.md b/docs/prd/issue-code-review-loop.md new file mode 100644 index 0000000..f75dbe6 --- /dev/null +++ b/docs/prd/issue-code-review-loop.md @@ -0,0 +1,667 @@ +# PRD:Issue 编码与双 Agent 评审循环模板 + +> **创建日期**:2026-05-15 +> **状态**:Draft +> **适用范围**:HALF 流程模板、任务执行、评审结果归档、协作文件状态机与前端同步 + +--- + +## 1. 背景 + +当前 HALF 已支持通过流程模板快速生成固定 Task DAG,并通过 HALF 协作仓库中的任务产物和 `result.json` 追踪任务完成状态。用户希望新增一种标准流程:输入一个 issue URL 后,由一个编码 Agent 完成实现、测试和推送,再由两个评审 Agent 并行评审。只有两个评审都同意合并时,编码 Agent 才提交 PR;若任一评审不同意合并,编码 Agent 需要读取评审意见,判断哪些意见合理并修改代码,然后进入下一轮评审。 + +该功能的核心价值是把“编码 - 双人评审 - 修改 - 再评审 - PR”的协作闭环沉淀为可复用模板,降低项目负责人手工拆任务、串联评审和判断下一步动作的成本。 + +--- + +## 2. 目标 + +1. 在流程模板中新增“Issue 编码与双 Agent 评审循环”模板。 +2. 支持用户在应用模板时填写 issue URL、目标分支策略、评审提示词等必要输入。 +3. 由编码 Agent 从用户给定的 issue URL 拉取需求,完成编码、测试、推送到项目仓库新分支。 +4. 由两个评审 Agent 并行从项目仓库新分支拉取代码,执行评审并把评审结果提交到 HALF 协作仓库。 +5. 流程固定使用 5 个 Task,Task 作为角色槽位复用;实际轮次和业务状态记录在 HALF 协作仓库的 `flow-state.json` 和轮次产物文件中。 +6. 系统能够根据两份评审结果中的合并结论决定下一步: + - 两个评审都同意合并:编码 Agent 提交 PR。 + - 至少一个评审不同意合并:编码 Agent 拉取两份评审意见,修复合理问题并进入下一轮评审。 +7. 前端能够通过后端读取 `flow-state.json`,同步展示当前轮次、流程阶段、每个 Task 的业务状态,并据此控制派发按钮。 + +--- + +## 3. 非目标 + +1. 不直接接入 GitHub/GitLab 等平台的私有 Agent API。 +2. 不要求 HALF 后端自动调用外部 AI Agent;仍沿用当前“生成 prompt,负责人复制给 Agent”的执行模式。 +3. 不在 MVP 中自动判断代码质量或自动合并 PR。 +4. 不在 MVP 中替代代码托管平台的权限、分支保护、CI 或 PR 审批能力。 +5. 不在 MVP 中要求 HALF 后端 clone 或校验项目代码仓库;项目代码仓库仍由 Agent 在自身执行环境中操作。 + +--- + +## 4. 角色与职责 + +| 角色 | 模板槽位 | 职责 | +|---|---|---| +| 编码 Agent | `agent-1` | 拉取 issue、创建项目分支、编码、编写测试、执行测试、推送代码、根据评审意见修复、最终提交 PR | +| 评审 Agent A | `agent-2` | 拉取项目分支,按评审提示词进行独立评审,提交结构化评审结果 | +| 评审 Agent B | `agent-3` | 拉取项目分支,按评审提示词进行独立评审,提交结构化评审结果 | +| 项目负责人 | 人类用户 | 创建项目、选择模板、映射 Agent、填写输入、派发 prompt、处理异常 | + +--- + +## 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`。 | + +项目必须配置: + +1. HALF 协作仓库地址 `git_repo_url`。 +2. 项目代码仓库地址 `project_repo_url`。若为空,则使用 `git_repo_url` 作为项目代码仓库。 +3. 至少 3 个可用 Agent,并完成 `agent-1`、`agent-2`、`agent-3` 的槽位映射。 + +--- + +## 6. 用户流程 + +1. 用户创建或编辑项目,填写 HALF 协作仓库、项目代码仓库、协作目录,并选择至少 3 个 Agent。 +2. 用户进入 Plan 页,选择“使用模板生成流程”。 +3. 用户选择“Issue 编码与双 Agent 评审循环”模板。 +4. 用户完成三个角色槽位映射。 +5. 用户填写 `issue_url`、`base_branch`、`review_prompt`、`max_review_rounds` 等模板输入。 +6. HALF 生成任务流程并进入任务执行页。 +7. 项目负责人派发 `TASK-001`,由编码 Agent 拉取 issue、理解需求、生成执行计划,并初始化 `flow-state.json`。 +8. `TASK-001` 完成后,前端根据 `flow-state.json` 解锁 `TASK-002`。 +9. 项目负责人派发 `TASK-002`,编码 Agent 完成编码、测试用例、测试执行和项目仓库分支推送。 +10. `TASK-002` 不直接进入“评审通过”,而是把自身业务状态更新为 `waiting_review`,并解锁 `TASK-003` / `TASK-004`。 +11. 项目负责人并行派发 `TASK-003` / `TASK-004`,两个评审 Agent 各自写入本轮评审结果。 +12. 两个评审结果都提交后,后端读取 `flow-state.json` 和两份评审文件,派生出 `TASK-005 = unlocked`;前端据此冻结 `TASK-003` / `TASK-004`,解锁 `TASK-005`。 +13. 项目负责人派发 `TASK-005`,编码 Agent 读取两份评审结果并做决策: + - 均同意合并:`TASK-002` 业务状态变为 `approved`,`TASK-005` 提交 PR 并完成流程。 + - 任一不同意合并:`TASK-002` 重新变为 `unlocked`,`TASK-003` / `TASK-004` / `TASK-005` 暂时冻结,编码 Agent 回到 `TASK-002` 进行下一轮修复。 +14. 每一轮修复、评审、决策都追加写入新的轮次目录,不覆盖旧轮次产物。 +15. 流程在以下任一条件结束: + - 两个评审 Agent 同意合并,编码 Agent 提交 PR 成功。 + - 达到最大评审轮次仍未通过,流程进入 `needs_attention`,等待人工处理。 + - 任一任务超时、产物缺失或结果格式非法,按现有任务异常机制处理。 + +--- + +## 7. 流程状态机 + +### 7.1 状态事实源 + +本流程不通过新增数据库表保存循环状态。流程运行状态以 HALF 协作仓库中的 `/flow-state.json` 作为事实源。后端在轮询或手动刷新时同步协作仓库,并读取该文件返回给前端。 + +评审 Agent 不直接修改 `flow-state.json`,避免两个评审并行写同一个文件导致冲突。推荐写入规则: + +| 写入方 | 允许写入 | +|---|---| +| `TASK-001` | 初始化 `flow-state.json`,写入计划结果 | +| `TASK-002` | 更新轮次、工作分支、commit、测试结果,把自身置为 `waiting_review`,解锁两个评审 | +| `TASK-003` | 只写自己的 `review.json` / `review.md` | +| `TASK-004` | 只写自己的 `review.json` / `review.md` | +| `TASK-005` | 读取两份评审结果,更新决策、解锁修复或提交 PR | + +后端读取流程状态时,不只返回 `flow-state.json` 的原始内容,还需要结合当前轮次下的评审文件派生有效状态。例如:当 `TASK-003/reviews/round-XXX/review.json` 和 `TASK-004/reviews/round-XXX/review.json` 都存在且合法时,即使 `flow-state.json.task_states.TASK-005` 仍是 `frozen`,后端也应在 API 返回中派生 `TASK-005 = unlocked`、`phase = awaiting_decision`。后端 `dispatch` / `redispatch` 校验也应使用派生后的有效状态。 + +多轮评审必须做目录隔离,避免上一轮产物干扰当前轮判断。后端和 `TASK-005` 只能读取 `flow-state.json.current_round` 对应的目录: + +```text +TASK-003/reviews/round-/review.json +TASK-004/reviews/round-/review.json +``` + +不得扫描历史目录来寻找“最新”评审结果。每一轮还必须使用 `round_id`、`current_round`、`work_branch`、`head_commit` 四个锚点校验产物是否属于当前轮次。 + +### 7.2 流程阶段 + +`flow-state.json.phase` 可取以下值: + +| 阶段 | 说明 | +|---|---| +| `planning` | `TASK-001` 正在拉取 issue、理解需求、生成计划 | +| `coding` | `TASK-002` 正在实现 issue 或修复上一轮评审意见 | +| `awaiting_review` | `TASK-002` 已提交代码并等待两个评审 | +| `reviewing` | `TASK-003` / `TASK-004` 至少一个评审仍未完成 | +| `awaiting_decision` | 两个评审都已提交结果,等待 `TASK-005` 决策 | +| `needs_fix` | 决策不通过,等待 `TASK-002` 修复 | +| `approved` | 两个评审都通过,`TASK-002` 已评审通过 | +| `pr_submitting` | `TASK-005` 正在提交 PR | +| `completed` | PR 已提交,流程完成 | +| `needs_attention` | 达到最大轮次或出现不可自动推进的问题 | + +### 7.3 Task 业务状态 + +`flow-state.json.task_states` 用于控制前端展示和派发按钮: + +| 状态 | 说明 | +|---|---| +| `frozen` | 当前不允许派发该 Task | +| `unlocked` | 允许项目负责人复制 prompt 并派发 | +| `running` | 该 Task 已派发,正在执行 | +| `waiting_review` | 仅用于 `TASK-002`,表示代码已提交,等待评审 | +| `waiting_decision` | 等待 `TASK-005` 做评审决策 | +| `needs_fix` | 评审未通过,等待编码 Agent 修复 | +| `approved` | 仅用于 `TASK-002`,表示两个评审都同意合并 | +| `completed` | 该 Task 的流程职责完成 | +| `needs_attention` | 需要人工处理 | + +### 7.4 `flow-state.json` 最小结构 + +```json +{ + "schema_version": 1, + "flow_type": "issue_code_review_loop", + "current_round": 1, + "round_id": "round-001-abc123", + "phase": "awaiting_review", + "work_branch": "issue-123-fix-login", + "head_commit": "abc123", + "max_review_rounds": 3, + "task_states": { + "TASK-001": "completed", + "TASK-002": "waiting_review", + "TASK-003": "unlocked", + "TASK-004": "unlocked", + "TASK-005": "frozen" + }, + "reviews": { + "round": 1, + "TASK-003": { + "status": "pending", + "approve_merge": null, + "review_path": null + }, + "TASK-004": { + "status": "pending", + "approve_merge": null, + "review_path": null + } + }, + "decision": { + "round": 1, + "status": "pending", + "approved": null, + "reason": null, + "decision_path": null + }, + "pr": { + "status": "not_started", + "url": null + }, + "updated_by_task": "TASK-002", + "updated_at": "2026-05-15T00:00:00Z" +} +``` + +### 7.5 合并判断规则 + +1. 只有当两个评审任务都已完成且评审结果均可解析时,才进入合并判断。 +2. 每份评审结果必须包含布尔字段 `approve_merge`。 +3. `TASK-005` 只允许读取当前轮次目录下的两份 `review.json`,不得读取或回退到历史轮次目录。 +4. 每份 `review.json` 必须满足: + - `round_id == flow-state.json.round_id` + - `round == flow-state.json.current_round` + - `work_branch == flow-state.json.work_branch` + - `head_commit == flow-state.json.head_commit` +5. 当且仅当两份评审结果的 `approve_merge` 都为 `true` 时,流程进入提交 PR 阶段。 +6. 只要任一 `approve_merge` 为 `false`,流程进入修复阶段。 +7. 若评审结果缺失、JSON 非法、缺少 `approve_merge` 或任一锚点不匹配,该评审任务不得视为完成,任务进入 `needs_attention`。 +8. 若当前轮次已达到 `max_review_rounds` 且仍未满足合并条件,流程进入 `needs_attention`,提示项目负责人人工处理。 + +--- + +## 8. 任务设计 + +### 8.1 固定 Task 形态 + +本流程不预先展开每一轮评审,也不在运行中动态新增 Task。模板固定生成 5 个 Task,Task 表示角色槽位,真实轮次由协作仓库产物记录。 + +| 任务 | 指派 | 依赖 | 说明 | +|---|---|---|---| +| `TASK-001` | 编码 Agent | 无 | 拉取 issue、理解需求、生成执行计划、初始化 `flow-state.json` | +| `TASK-002` | 编码 Agent | `TASK-001` | 编码、修复、编写测试、执行测试、推送工作分支;可被多轮复用 | +| `TASK-003` | 评审 Agent A | `TASK-002` | 评审槽位 A;每一轮只写自己的评审产物 | +| `TASK-004` | 评审 Agent B | `TASK-002` | 评审槽位 B;每一轮只写自己的评审产物 | +| `TASK-005` | 编码 Agent | `TASK-003`, `TASK-004` | 评审决策与 PR 提交;不通过则解锁 `TASK-002` 进入下一轮 | + +说明: + +1. `TASK-002` 的业务状态不是简单的 `completed`,而是在 `flow-state.json` 中区分 `unlocked`、`waiting_review`、`needs_fix`、`approved`。 +2. `TASK-003` / `TASK-004` 在每轮评审完成后冻结,直到 `TASK-002` 提交下一轮新 commit 后再次解锁。 +3. `TASK-005` 只有在两个评审结果都存在且可解析时解锁。 +4. 该模板的业务解锁不应只依赖数据库中的 `Task.status = completed`。`TASK-002` 在等待评审时还不能算“评审通过”,但 `TASK-003` / `TASK-004` 已经可以派发;因此前端和后端派发校验必须以 `flow-state.json` 及派生状态为准。 +5. `result.json` 仍可作为该角色槽位最终完成的归档哨兵,但每一轮的中间完成状态由 `flow-state.json`、`branch.json`、`review.json`、`decision.json` 表达。 + +### 8.2 业务流转 + +```text +TASK-001 计划 + ↓ +TASK-002 编码 / 修复 + ↓ +TASK-002 waiting_review + ↓ +TASK-003 评审 A + TASK-004 评审 B + ↓ +TASK-005 决策 + ├─ 两个评审都通过 -> TASK-002 approved -> TASK-005 提交 PR -> completed + └─ 任一评审不通过 -> TASK-002 unlocked -> 下一轮修复 +``` + +### 8.3 前端派发控制 + +前端在任务页读取后端返回的流程状态,并按 `task_states` 控制按钮: + +| Task 状态 | 前端行为 | +|---|---| +| `unlocked` | 显示并允许“复制 Prompt 并派发” | +| `frozen` | 禁用派发按钮,显示冻结原因 | +| `waiting_review` | 显示“等待评审”,禁用编码派发 | +| `waiting_decision` | 显示“等待决策”,只允许派发 `TASK-005` | +| `needs_fix` | 显示“需修复”,允许重新派发 `TASK-002` | +| `approved` | 显示“评审通过”,禁用 `TASK-002` 派发 | +| `completed` | 显示完成状态 | + +后端在 `dispatch` / `redispatch` 时也应读取 `flow-state.json` 和当前轮次产物,按派生后的有效状态校验目标 Task 是否允许派发,避免用户绕过前端直接调用接口派发冻结任务。 + +--- + +## 9. 产物契约 + +### 9.1 协作仓库目录结构 + +推荐目录结构: + +```text +/flow-state.json + +/TASK-001/ + issue-summary.md + implementation-plan.md + result.json + +/TASK-002/ + rounds/ + round-001/ + branch.json + implementation.md + test-report.md + round-002/ + branch.json + fix-summary.md + review-response.md + test-report.md + +/TASK-003/ + reviews/ + round-001/ + review.json + review.md + +/TASK-004/ + reviews/ + round-001/ + review.json + review.md + +/TASK-005/ + decisions/ + round-001/ + decision.json + decision.md + pr.json + pr.md +``` + +目录名必须由 `flow-state.json.current_round` 派生,推荐使用三位补零格式 `round-001`、`round-002`。进入新一轮时,编码 Agent 必须创建新的轮次目录,不得覆盖上一轮目录。 + +### 9.2 编码任务产物 + +编码 Agent 必须在 HALF 协作仓库当前任务目录写入: + +| 文件 | 说明 | +|---|---| +| `rounds/round-XXX/implementation.md` 或 `fix-summary.md` | 本轮实现或修复摘要 | +| `rounds/round-XXX/test-report.md` | 本轮测试命令、结果和失败说明 | +| `rounds/round-XXX/branch.json` | 项目仓库分支信息 | +| `result.json` | 可选。仅在 `TASK-002` 被 `TASK-005` 标记为 `approved` 后用于归档该角色槽位最终完成状态 | + +`branch.json` 建议格式: + +```json +{ + "round": 1, + "round_id": "round-001-abc123", + "project_repo_url": "https://github.com/example/project.git", + "base_branch": "main", + "work_branch": "issue-123-fix-login", + "head_commit": "abc123", + "tests": [ + { + "command": "pytest", + "status": "passed", + "summary": "128 passed" + } + ], + "pr_url": null +} +``` + +### 9.3 评审任务产物 + +每个评审 Agent 必须在 HALF 协作仓库当前任务目录写入: + +| 文件 | 说明 | +|---|---| +| `reviews/round-XXX/review.json` | 结构化评审结果 | +| `reviews/round-XXX/review.md` | 面向人类阅读的详细评审意见 | +| `result.json` | 可选。仅在流程最终完成时用于归档评审角色槽位最终完成状态 | + +`review.json` 必须包含: + +```json +{ + "round": 1, + "round_id": "round-001-abc123", + "reviewer": "agent-2", + "work_branch": "issue-123-fix-login", + "head_commit": "abc123", + "approve_merge": false, + "summary": "发现 2 个阻塞问题,需要修改后再合并。", + "findings": [ + { + "severity": "blocking", + "file": "src/example.py", + "line": 42, + "title": "缺少错误分支处理", + "detail": "当接口返回空响应时会抛出未捕获异常。", + "recommendation": "补充空响应处理和对应测试。" + } + ], + "tested": [ + { + "command": "pytest tests/test_example.py", + "status": "passed", + "summary": "3 passed" + } + ] +} +``` + +字段规则: + +1. `round_id`、`round`、`work_branch`、`head_commit` 必须与 `flow-state.json` 当前值一致。 +2. `approve_merge` 为必填布尔值。 +3. `findings[].severity` 可选值为 `blocking`、`major`、`minor`、`nit`。 +4. 只要存在 `blocking` 级别问题,`approve_merge` 必须为 `false`。 +5. `approve_merge = false` 时,`findings` 不得为空。 +6. `file` 和 `line` 应尽量指向具体代码位置;无法定位时可以为空,但必须在 `detail` 中说明原因。 + +### 9.4 决策与 PR 产物 + +`TASK-005` 在每轮决策时必须写入: + +| 文件 | 说明 | +|---|---| +| `decisions/round-XXX/decision.json` | 结构化决策结果 | +| `decisions/round-XXX/decision.md` | 对两份评审意见的汇总与下一步说明 | +| `result.json` | 可选。仅在 PR 已提交或流程进入终止状态时用于归档 `TASK-005` 最终完成状态 | + +当两个评审都同意合并时,编码 Agent 必须提交 PR 并在当前任务目录写入: + +| 文件 | 说明 | +|---|---| +| `pr.json` | PR URL、目标分支、标题、提交 commit | +| `pr.md` | PR 描述正文 | +| `result.json` | 流程最终完成后的归档哨兵 | + +`pr.json` 建议格式: + +```json +{ + "project_repo_url": "https://github.com/example/project.git", + "work_branch": "issue-123-fix-login", + "target_branch": "main", + "pr_url": "https://github.com/example/project/pull/456", + "title": "Fix login error handling", + "head_commit": "def456" +} +``` + +`decision.json` 建议格式: + +```json +{ + "round": 1, + "round_id": "round-001-abc123", + "approved": false, + "review_a_approve_merge": false, + "review_b_approve_merge": true, + "next_action": "fix", + "reason": "评审 A 发现阻塞问题,需要修复后重新评审。", + "next_round": 2 +} +``` + +--- + +## 10. Prompt 要求 + +### 10.1 编码 Agent Prompt + +编码 Agent 的任务 prompt 必须明确要求: + +1. 开始前同步 HALF 协作仓库和项目代码仓库。 +2. 从 `issue_url` 获取 issue 内容,并在产物中记录 issue 摘要。 +3. 从 `base_branch` 创建或更新 `work_branch`。 +4. 完成代码修改和必要测试。 +5. 执行 `test_command`;若为空,则根据项目约定选择合理测试命令。 +6. 只有测试通过且代码已经 push 到项目仓库后,才允许写入本轮 `branch.json` 并把 `flow-state.json` 更新为 `awaiting_review`。 +7. 若处于修复轮次,必须读取两个评审任务的 `review.json` / `review.md`,逐条判断意见是否合理。 +8. 对拒绝采纳的评审意见必须给出具体理由。 +9. 不得在两个评审都同意合并前提交 PR。 +10. 开始执行前必须读取 `flow-state.json`;如果 `TASK-002` 当前不是 `unlocked` 或 `needs_fix`,必须停止并说明原因。 +11. 完成编码或修复后必须更新 `flow-state.json`:设置最新 `current_round`、`round_id`、`work_branch`、`head_commit`,将 `TASK-002` 置为 `waiting_review`,将 `TASK-003` / `TASK-004` 置为 `unlocked`,将 `TASK-005` 置为 `frozen`。 +12. 每一轮必须写入新的 `round-XXX` 目录,不得覆盖或复用上一轮目录。 + +### 10.2 评审 Agent Prompt + +评审 Agent 的任务 prompt 必须明确要求: + +1. 开始前同步 HALF 协作仓库和项目代码仓库。 +2. 从当前轮次的 `TASK-002/rounds/round-XXX/branch.json` 读取 `round_id`、`work_branch` 和 `head_commit`。 +3. 基于用户填写的 `review_prompt` 独立评审。 +4. 必须检查代码正确性、测试覆盖、回归风险、可维护性和需求匹配度。 +5. 必须输出 `review.json`,并明确 `approve_merge`。 +6. 不得依赖另一名评审 Agent 的结论。 +7. 只有评审产物写完后,才允许将本轮评审视为已提交。 +8. 开始执行前必须读取 `flow-state.json`;如果自己的 Task 不是 `unlocked`,必须停止并说明原因。 +9. 评审 Agent 不得修改 `flow-state.json`,只允许写入自己 Task 目录下的本轮评审产物。 + +### 10.3 决策 Agent Prompt + +`TASK-005` 仍由编码 Agent 执行,但 prompt 必须明确它此时承担“评审决策与 PR 提交”职责: + +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`。 +5. 若达到 `max_review_rounds` 且仍未通过,写入人工处理报告,将流程标记为 `needs_attention`。 + +--- + +## 11. 前端同步需求 + +### 11.1 同步链路 + +前端不直接读取或写入 Git 仓库文件。同步链路为: + +```text +Agent 更新协作仓库文件 + ↓ +HALF 后端轮询 / 手动刷新时同步协作仓库 + ↓ +后端读取并解析 /flow-state.json 和当前轮次产物 + ↓ +前端调用 API 获取流程状态 + ↓ +页面更新阶段、轮次、Task 业务状态和按钮可用性 +``` + +### 11.2 API 需求 + +新增或扩展一个读取流程状态的接口: + +```text +GET /api/projects/:id/flow-state +``` + +接口返回 `flow-state.json` 的解析结果、结合当前轮次产物派生后的有效状态,以及文件缺失、JSON 非法、schema 不匹配等错误信息。最小返回字段: + +```json +{ + "exists": true, + "valid": true, + "current_round": 2, + "round_id": "round-002-def456", + "phase": "awaiting_review", + "derived_phase": "awaiting_review", + "work_branch": "issue-123-fix-login", + "head_commit": "def456", + "task_states": { + "TASK-002": "waiting_review", + "TASK-003": "unlocked", + "TASK-004": "unlocked", + "TASK-005": "frozen" + }, + "effective_task_states": { + "TASK-002": "waiting_review", + "TASK-003": "unlocked", + "TASK-004": "unlocked", + "TASK-005": "frozen" + }, + "pr": { + "status": "not_started", + "url": null + } +} +``` + +### 11.3 页面展示 + +任务执行页需要展示: + +1. 当前流程阶段,例如 `awaiting_review`、`needs_fix`、`completed`。 +2. 当前评审轮次。 +3. 当前工作分支和最新 commit。 +4. 两个评审 Agent 的本轮状态和评审结论。 +5. `TASK-002` 的业务状态:待编码、等待评审、需修复、评审通过。 +6. `TASK-005` 的业务状态:等待评审完成、可决策、PR 已提交。 +7. PR URL。 + +### 11.4 派发约束 + +前端和后端都必须按 `task_states` 控制派发: + +1. `unlocked`:允许派发。 +2. `frozen`:禁止派发。 +3. `waiting_review`:禁止派发 `TASK-002`,允许派发已解锁评审。 +4. `needs_fix`:允许派发 `TASK-002`。 +5. `approved`:禁止再次派发 `TASK-002`。 +6. `completed`:禁止再次派发流程内 Task,除非用户显式重新打开流程。 + +--- + +## 12. HALF 产品能力需求 + +### 12.1 MVP 必需能力 + +1. 允许新增一个 `agent_count = 3` 的流程模板。 +2. 模板支持声明 `issue_url`、`base_branch`、`review_prompt`、`test_command`、`max_review_rounds`、`pr_target_branch` 等 `required_inputs`。 +3. 任务 prompt 中能注入模板输入。 +4. 模板固定生成 5 个 Task,并在任务描述中明确每个 Task 的角色槽位语义。 +5. 后端能读取 `/flow-state.json` 并提供给前端。 +6. 前端能展示流程阶段、当前轮次、工作分支、评审结果和 PR URL。 +7. 前端能根据 `task_states` 禁用或启用派发按钮。 +8. 后端能在 `dispatch` / `redispatch` 时校验目标 Task 在派生有效状态中是否允许派发。 +9. 评审任务能通过协作仓库读取当前轮次的 `branch.json`。 +10. 任务结果页能展示每轮编码、评审、决策和 PR 产物路径。 + +### 12.2 推荐增强能力 + +1. 新增结构化产物校验:对 `review.json`、`branch.json`、`pr.json` 做 schema 校验。 +2. 对 `flow-state.json` 做 schema 校验,并在前端展示具体错误。 +3. 在任务页为 `TASK-002`、`TASK-003`、`TASK-004`、`TASK-005` 提供轮次历史视图。 +4. 在 `TASK-005` 决策后自动触发一次项目状态刷新。 +5. 提供人工修正 `flow-state.json` 的管理员工具,用于处理冲突或异常状态。 + +--- + +## 13. 异常处理 + +| 场景 | 期望处理 | +|---|---| +| issue URL 无法访问 | 编码任务进入 `needs_attention`,产物中记录失败原因 | +| 项目仓库无法拉取或推送 | 对应任务进入 `needs_attention`,提示检查权限、网络和分支保护 | +| 测试失败 | 编码 Agent 不得把 `flow-state.json` 推进到 `awaiting_review`,应输出失败日志并等待人工处理或修复 | +| 评审结果 JSON 非法 | 评审任务不应视为完成,进入 `needs_attention` | +| `flow-state.json` 缺失 | 前端显示流程状态未初始化,只允许派发 `TASK-001` | +| `flow-state.json` JSON 非法或 schema 不匹配 | 前端显示状态文件错误,所有流程 Task 派发进入保护性禁用,等待人工修复 | +| 用户尝试派发 frozen Task | 前端禁用;后端 dispatch 校验拒绝并返回当前 Task 有效业务状态 | +| 评审意见冲突 | 编码 Agent 必须逐条回应两份意见;合理意见应修复,不合理意见应说明拒绝理由 | +| 达到最大评审轮次仍未通过 | 流程进入 `needs_attention`,输出人工处理报告 | +| PR 创建失败 | PR 任务进入 `needs_attention`,记录失败原因和可重试步骤 | + +--- + +## 14. 权限与安全 + +1. Agent 对项目代码仓库的 clone、push、PR 创建权限由用户在 Agent 执行环境中配置。 +2. HALF 不保存项目仓库访问令牌。 +3. 协作仓库中的评审意见可能包含代码片段或安全问题描述,应遵循项目已有访问控制。 +4. Prompt 中必须提醒 Agent 不要把密钥、令牌、私有凭据写入协作产物。 +5. 分支名应避免包含空格、控制字符、shell 特殊字符和路径穿越片段。 +6. 评审 Agent 不得修改 `flow-state.json`,避免并行写入造成覆盖或冲突。 +7. 后端读取 `flow-state.json` 时必须复用现有安全路径校验,禁止绝对路径、反斜杠和 `..` 越界路径。 + +--- + +## 15. 验收标准 + +1. 用户可以在流程模板列表中看到“Issue 编码与双 Agent 评审循环”模板。 +2. 模板要求 3 个 Agent,并能完成 `agent-1`、`agent-2`、`agent-3` 的角色映射。 +3. 用户应用模板时必须填写 `issue_url`、`base_branch`、`review_prompt` 和 `max_review_rounds`。 +4. 应用模板后固定生成 `TASK-001` 到 `TASK-005` 五个 Task。 +5. `TASK-001` prompt 中包含 issue URL,并要求生成计划和初始化 `flow-state.json`。 +6. `TASK-002` prompt 中包含项目代码仓库、基准分支、工作分支策略、测试要求和 `flow-state.json` 更新规则。 +7. `TASK-002` 完成编码后,前端能显示 `TASK-002 = waiting_review`,并解锁两个评审任务。 +8. 两个评审任务 prompt 中包含同一工作分支、同一 commit、同一评审提示词,并要求输出 `approve_merge`。 +9. 两个评审结果都提交后,后端能派生 `TASK-005 = unlocked`,前端能显示该状态,并冻结 `TASK-003` / `TASK-004`。 +10. 当任一评审不同意合并时,`TASK-005` 能更新 `flow-state.json`,使 `TASK-002 = unlocked`,并进入下一轮修复。 +11. 当两个评审都同意合并时,`TASK-005` 能将 `TASK-002` 标记为 `approved`,提交 PR,并在 HALF 协作仓库记录 PR URL。 +12. 前端能通过后端接口同步展示 `phase`、`current_round`、`work_branch`、`head_commit`、`task_states` 和 PR URL。 +13. 后端和 `TASK-005` 只读取当前 `round-XXX` 目录下的评审结果,历史轮次评审文件不会影响当前轮判断。 +14. `review.json` 的 `round_id`、`round`、`work_branch`、`head_commit` 与 `flow-state.json` 不一致时,不得解锁 `TASK-005`。 +15. 前端和后端都能阻止派发 `frozen` 状态的 Task。 +16. 达到最大评审轮次仍未通过时,流程进入人工处理状态,并保留完整评审与修复记录。 + +--- + +## 16. 待确认问题 + +1. `max_review_rounds` 的默认值是否固定为 3。 +2. PR 创建方式是否只通过编码 Agent 在其环境中执行,还是未来由 HALF 提供 Git 平台集成。 +3. 评审意见是否需要在 HALF 前端做结构化展示,还是 MVP 仅展示产物文件路径。 +4. 是否需要为不同项目预置多套 `review_prompt` 模板。 +5. `flow-state.json` 是否需要支持人工修正操作,以及该操作是否仅限管理员。 From d9e5e03c7003afc70a797ef6938fe2f58d576aee Mon Sep 17 00:00:00 2001 From: YXW Date: Mon, 18 May 2026 22:52:20 +0800 Subject: [PATCH 2/8] feat: add issue review loop support --- src/backend/main.py | 3 + src/backend/models.py | 1 + src/backend/routers/process_templates.py | 12 + src/backend/routers/projects.py | 22 ++ src/backend/routers/tasks.py | 56 ++- src/backend/services/issue_review_loop.py | 346 ++++++++++++++++++ src/backend/services/polling_service.py | 66 +++- src/backend/services/prompt_service.py | 125 ++++++- src/backend/tests/test_issue_review_loop.py | 197 ++++++++++ src/backend/tests/test_polling_service.py | 144 ++++++++ src/backend/tests/test_prompt_service.py | 99 +++++ src/frontend/src/components/DagView.tsx | 36 +- src/frontend/src/components/StatusBadge.tsx | 12 + .../src/components/TaskDetailPanel.tsx | 35 +- src/frontend/src/pages/PlanPage.tsx | 11 +- src/frontend/src/pages/ProjectNewPage.test.ts | 2 + src/frontend/src/pages/ProjectNewPage.tsx | 23 ++ src/frontend/src/pages/TasksPage.tsx | 37 +- src/frontend/src/types/index.ts | 30 ++ .../src/utils/applyTemplatePlan.test.ts | 39 ++ 20 files changed, 1250 insertions(+), 46 deletions(-) create mode 100644 src/backend/services/issue_review_loop.py create mode 100644 src/backend/tests/test_issue_review_loop.py diff --git a/src/backend/main.py b/src/backend/main.py index a053291..12f5760 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -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") @@ -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": "INTEGER DEFAULT 3", "planning_mode": "TEXT DEFAULT 'balanced'", "template_inputs_json": "TEXT DEFAULT '{}'", }, @@ -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") diff --git a/src/backend/models.py b/src/backend/models.py index 0a1f64f..ddc2308 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -81,6 +81,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=3) planning_mode = Column(Text, default="balanced") template_inputs_json = Column(Text, default="{}") created_by = Column(Integer, ForeignKey("users.id")) diff --git a/src/backend/routers/process_templates.py b/src/backend/routers/process_templates.py index 0ada890..f2f6bdd 100644 --- a/src/backend/routers/process_templates.py +++ b/src/backend/routers/process_templates.py @@ -15,6 +15,7 @@ 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 FLOW_TYPE router = APIRouter(prefix="/api/process-templates", tags=["process_templates"]) @@ -500,6 +501,17 @@ 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 = {} + 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 3) + 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, diff --git a/src/backend/routers/projects.py b/src/backend/routers/projects.py index 81a18cb..e33f99d 100644 --- a/src/backend/routers/projects.py +++ b/src/backend/routers/projects.py @@ -20,6 +20,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"]) @@ -66,6 +67,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] = 3 planning_mode: str = DEFAULT_PLANNING_MODE template_inputs: Optional[object] = None @@ -84,6 +86,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 @@ -105,6 +108,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] @@ -187,6 +191,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 3, '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), @@ -384,6 +389,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 3 + 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], @@ -453,6 +466,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), ) @@ -477,6 +491,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) @@ -521,6 +541,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')), diff --git a/src/backend/routers/tasks.py b/src/backend/routers/tasks.py index d1f8591..091569d 100644 --- a/src/backend/routers/tasks.py +++ b/src/backend/routers/tasks.py @@ -14,6 +14,11 @@ from services.path_service import ExpectedOutputPathError, normalize_expected_output_path from services.prompt_service import generate_task_prompt from services import git_service +from services.issue_review_loop import ( + get_effective_business_state, + is_business_dispatch_allowed, + project_uses_issue_review_loop, +) router = APIRouter(tags=["tasks"]) @@ -61,6 +66,27 @@ class TaskDispatchRequest(BaseModel): ignore_missing_predecessor_outputs: bool = False +def _load_task_project(db: Session, task: Task) -> Project: + project = db.query(Project).filter(Project.id == task.project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + return project + + +def _validate_loop_dispatch_state(db: Session, task: Task, project: Project) -> bool: + if not project_uses_issue_review_loop(db, project): + return False + business_state = get_effective_business_state(db, project, task.task_code) + if not is_business_dispatch_allowed(business_state): + raise HTTPException( + status_code=400, + detail=f"Cannot dispatch task while issue review loop state is: {business_state or 'unknown'}", + ) + if task.status == "abandoned": + raise HTTPException(status_code=400, detail=f"Cannot dispatch abandoned task in issue review loop: {task.task_code}") + return True + + # Project-scoped task list @router.get("/api/projects/{project_id}/tasks", response_model=list[TaskResponse]) def list_project_tasks(project_id: int, db: Session = Depends(get_db), user: User = Depends(get_current_user)): @@ -346,14 +372,17 @@ def dispatch_task( user: User = Depends(get_current_user), ): task = get_owned_task(db, task_id, user) - if task.status not in ("pending", "needs_attention"): + project = _load_task_project(db, task) + is_loop_dispatch = _validate_loop_dispatch_state(db, task, project) + if not is_loop_dispatch and task.status not in ("pending", "needs_attention"): raise HTTPException(status_code=400, detail=f"Cannot dispatch task in status: {task.status}") - _validate_dispatch_predecessors( - db, - task, - ignore_missing_predecessor_outputs=body.ignore_missing_predecessor_outputs, - ) + if not is_loop_dispatch: + _validate_dispatch_predecessors( + db, + task, + ignore_missing_predecessor_outputs=body.ignore_missing_predecessor_outputs, + ) now = datetime.now(timezone.utc) task.status = "running" @@ -438,14 +467,17 @@ def redispatch_task( user: User = Depends(get_current_user), ): task = get_owned_task(db, task_id, user) - if task.status not in ("needs_attention", "running", "abandoned"): + project = _load_task_project(db, task) + is_loop_dispatch = _validate_loop_dispatch_state(db, task, project) + if not is_loop_dispatch and task.status not in ("needs_attention", "running", "abandoned"): raise HTTPException(status_code=400, detail=f"Cannot redispatch task in status: {task.status}") - _validate_dispatch_predecessors( - db, - task, - ignore_missing_predecessor_outputs=body.ignore_missing_predecessor_outputs, - ) + if not is_loop_dispatch: + _validate_dispatch_predecessors( + db, + task, + ignore_missing_predecessor_outputs=body.ignore_missing_predecessor_outputs, + ) now = datetime.now(timezone.utc) prev_status = task.status diff --git a/src/backend/services/issue_review_loop.py b/src/backend/services/issue_review_loop.py new file mode 100644 index 0000000..05e28d5 --- /dev/null +++ b/src/backend/services/issue_review_loop.py @@ -0,0 +1,346 @@ +import json +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy.orm import Session + +from models import ProcessTemplate, Project, ProjectPlan +from services import git_service + + +FLOW_TYPE = "issue_code_review_loop" +TEMPLATE_NAME = "Issue 编码与双 Agent 评审循环" +TASK_CODES = ["TASK-001", "TASK-002", "TASK-003", "TASK-004", "TASK-005"] +BUSINESS_DISPATCHABLE_STATES = {"unlocked", "needs_fix"} + + +def issue_review_loop_template_json() -> dict[str, Any]: + return { + "plan_name": TEMPLATE_NAME, + "description": "输入 issue URL 后,由编码 Agent 编码、两个评审 Agent 并行评审,并按评审结论循环修复或提交 PR。", + "flow_type": FLOW_TYPE, + "agent_roles": [ + { + "slot": "agent-1", + "description": "编码与决策 Agent。负责拉取 issue、实现代码、测试、推送工作分支,根据评审意见修复,并在双评审通过后提交 PR。", + }, + { + "slot": "agent-2", + "description": "评审 Agent A。负责从当前工作分支和 commit 独立评审代码,按结构化格式写入本轮 review.json 与 review.md。", + }, + { + "slot": "agent-3", + "description": "评审 Agent B。负责独立执行第二份代码评审,不依赖评审 A 的结论,只写入自己的本轮评审产物。", + }, + ], + "tasks": [ + { + "task_code": "TASK-001", + "task_name": "拉取 issue 并初始化评审循环状态", + "description": "读取 issue URL,理解需求,生成实现计划,初始化 flow-state.json,解锁编码任务。", + "assignee": "agent-1", + "depends_on": [], + "expected_output": "outputs/TASK-001/result.json", + }, + { + "task_code": "TASK-002", + "task_name": "编码、测试并推送工作分支", + "description": "实现 issue 或修复上一轮合理评审意见,执行测试,推送项目仓库工作分支,并更新 flow-state.json 进入等待评审。", + "assignee": "agent-1", + "depends_on": ["TASK-001"], + "expected_output": "outputs/TASK-002/result.json", + }, + { + "task_code": "TASK-003", + "task_name": "评审 A", + "description": "读取当前轮次 branch.json 和用户评审提示词,对工作分支进行独立评审,仅写入自己的 review.json / review.md。", + "assignee": "agent-2", + "depends_on": ["TASK-002"], + "expected_output": "outputs/TASK-003/result.json", + }, + { + "task_code": "TASK-004", + "task_name": "评审 B", + "description": "读取当前轮次 branch.json 和用户评审提示词,对工作分支进行独立评审,仅写入自己的 review.json / review.md。", + "assignee": "agent-3", + "depends_on": ["TASK-002"], + "expected_output": "outputs/TASK-004/result.json", + }, + { + "task_code": "TASK-005", + "task_name": "评审决策与 PR 提交", + "description": "只读取当前轮次两份评审结果,决定提交 PR 或解锁下一轮修复,并更新 flow-state.json。", + "assignee": "agent-1", + "depends_on": ["TASK-003", "TASK-004"], + "expected_output": "outputs/TASK-005/result.json", + }, + ], + } + + +def issue_review_loop_required_inputs() -> list[dict[str, object]]: + return [ + {"key": "issue_url", "label": "Issue URL", "required": True, "sensitive": False}, + {"key": "review_prompt", "label": "评审提示词", "required": True, "sensitive": False}, + {"key": "test_command", "label": "测试命令", "required": False, "sensitive": False}, + {"key": "max_review_rounds", "label": "最大评审轮次", "required": True, "sensitive": False}, + ] + + +def ensure_issue_review_loop_template(db: Session, admin) -> None: + existing = db.query(ProcessTemplate).filter(ProcessTemplate.name == TEMPLATE_NAME).first() + now = datetime.now(timezone.utc) + agent_slots_json = json.dumps(["agent-1", "agent-2", "agent-3"], ensure_ascii=False) + agent_roles_description_json = json.dumps( + { + "agent-1": "编码与决策 Agent,负责实现、测试、修复、推送工作分支和最终提交 PR。", + "agent-2": "评审 Agent A,负责独立代码评审并写入结构化评审结果。", + "agent-3": "评审 Agent B,负责独立代码评审并写入结构化评审结果。", + }, + ensure_ascii=False, + ) + required_inputs_json = json.dumps(issue_review_loop_required_inputs(), ensure_ascii=False) + template_json = json.dumps(issue_review_loop_template_json(), ensure_ascii=False) + if existing is not None: + existing.description = "输入 issue URL 后,固定使用编码、双评审、决策 5 个 Task 完成编码评审闭环。" + existing.prompt_source_text = "MVP 内置模板:固定 5 个 Task,运行状态由协作仓库 flow-state.json 与当前轮次产物派生。" + existing.agent_count = 3 + existing.agent_slots_json = agent_slots_json + existing.agent_roles_description_json = agent_roles_description_json + existing.required_inputs_json = required_inputs_json + existing.template_json = template_json + existing.updated_by = admin.id + existing.updated_at = now + db.commit() + return + + template = ProcessTemplate( + name=TEMPLATE_NAME, + description="输入 issue URL 后,固定使用编码、双评审、决策 5 个 Task 完成编码评审闭环。", + prompt_source_text="MVP 内置模板:固定 5 个 Task,运行状态由协作仓库 flow-state.json 与当前轮次产物派生。", + agent_count=3, + agent_slots_json=agent_slots_json, + agent_roles_description_json=agent_roles_description_json, + required_inputs_json=required_inputs_json, + template_json=template_json, + created_by=admin.id, + updated_by=admin.id, + created_at=now, + updated_at=now, + ) + db.add(template) + db.commit() + + +def _parse_json_object(value: str | None) -> dict[str, Any]: + if not value: + return {} + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + + +def is_issue_review_loop_plan(plan: ProjectPlan | None) -> bool: + if not plan: + return False + data = _parse_json_object(plan.plan_json) + return data.get("flow_type") == FLOW_TYPE + + +def get_issue_review_loop_plan(db: Session, project: Project) -> ProjectPlan | None: + plans = ( + db.query(ProjectPlan) + .filter(ProjectPlan.project_id == project.id, ProjectPlan.is_selected == True) # noqa: E712 + .order_by(ProjectPlan.id.desc()) + .all() + ) + for plan in plans: + if is_issue_review_loop_plan(plan): + return plan + return None + + +def project_uses_issue_review_loop(db: Session, project: Project) -> bool: + return get_issue_review_loop_plan(db, project) is not None + + +def _collab_dir(project: Project) -> str: + return (project.collaboration_dir or "").strip("/") + + +def _flow_state_path(project: Project) -> str: + base = _collab_dir(project) + return f"{base}/flow-state.json" if base else "flow-state.json" + + +def _round_dir(current_round: int) -> str: + return f"round-{current_round:03d}" + + +def _review_path(project: Project, task_code: str, current_round: int) -> str: + base = _collab_dir(project) + path = f"{task_code}/reviews/{_round_dir(current_round)}/review.json" + return f"{base}/{path}" if base else path + + +def _empty_response(enabled: bool) -> dict[str, Any]: + return { + "enabled": enabled, + "exists": False, + "valid": False, + "flow_type": None, + "phase": None, + "derived_phase": None, + "current_round": None, + "round_id": None, + "work_branch": None, + "head_commit": None, + "max_review_rounds": None, + "task_states": {}, + "effective_task_states": {}, + "reviews": {}, + "decision": {}, + "pr": {}, + "errors": [], + } + + +def _validate_review( + project: Project, + task_code: str, + flow_state: dict[str, Any], + errors: list[str], +) -> dict[str, Any]: + current_round = flow_state.get("current_round") + if not isinstance(current_round, int): + return {"status": "pending", "approve_merge": None, "review_path": None} + + path = _review_path(project, task_code, current_round) + content = git_service.read_file( + project.id, + path, + git_repo_url=project.git_repo_url, + prefer_remote=True, + ) + if content is None: + return {"status": "pending", "approve_merge": None, "review_path": path} + + try: + review = json.loads(content) + except json.JSONDecodeError: + errors.append(f"{task_code} review.json is not valid JSON: {path}") + return {"status": "needs_attention", "approve_merge": None, "review_path": path} + + if not isinstance(review, dict): + errors.append(f"{task_code} review.json must be an object: {path}") + return {"status": "needs_attention", "approve_merge": None, "review_path": path} + + for key, expected in ( + ("round", flow_state.get("current_round")), + ("round_id", flow_state.get("round_id")), + ("work_branch", flow_state.get("work_branch")), + ("head_commit", flow_state.get("head_commit")), + ): + if review.get(key) != expected: + errors.append(f"{task_code} review.json {key} does not match current flow-state: {path}") + return {"status": "needs_attention", "approve_merge": None, "review_path": path} + + approve_merge = review.get("approve_merge") + if type(approve_merge) is not bool: + errors.append(f"{task_code} review.json approve_merge must be boolean: {path}") + return {"status": "needs_attention", "approve_merge": None, "review_path": path} + + return {"status": "submitted", "approve_merge": approve_merge, "review_path": path} + + +def get_issue_review_flow_state(db: Session, project: Project) -> dict[str, Any]: + if not project_uses_issue_review_loop(db, project): + return _empty_response(enabled=False) + + path = _flow_state_path(project) + content = git_service.read_file( + project.id, + path, + git_repo_url=project.git_repo_url, + prefer_remote=True, + ) + if content is None: + response = _empty_response(enabled=True) + response["effective_task_states"] = {code: ("unlocked" if code == "TASK-001" else "frozen") for code in TASK_CODES} + response["errors"] = [f"flow-state.json not found: {path}"] + return response + + response = _empty_response(enabled=True) + response["exists"] = True + try: + flow_state = json.loads(content) + except json.JSONDecodeError: + response["errors"] = [f"flow-state.json is not valid JSON: {path}"] + response["effective_task_states"] = {code: "frozen" for code in TASK_CODES} + return response + + if not isinstance(flow_state, dict) or flow_state.get("flow_type") != FLOW_TYPE: + response["errors"] = [f"flow-state.json flow_type must be {FLOW_TYPE}: {path}"] + response["effective_task_states"] = {code: "frozen" for code in TASK_CODES} + return response + + required = ("current_round", "round_id", "phase", "task_states") + missing = [key for key in required if key not in flow_state] + if missing: + response["errors"] = [f"flow-state.json missing required fields: {', '.join(missing)}"] + response["effective_task_states"] = {code: "frozen" for code in TASK_CODES} + return response + + task_states = flow_state.get("task_states") if isinstance(flow_state.get("task_states"), dict) else {} + effective = {code: str(task_states.get(code) or "frozen") for code in TASK_CODES} + errors: list[str] = [] + reviews = { + "TASK-003": _validate_review(project, "TASK-003", flow_state, errors), + "TASK-004": _validate_review(project, "TASK-004", flow_state, errors), + } + both_reviews_submitted = all(item.get("status") == "submitted" for item in reviews.values()) + derived_phase = str(flow_state.get("phase") or "") + + if both_reviews_submitted: + effective["TASK-003"] = "frozen" + effective["TASK-004"] = "frozen" + effective["TASK-005"] = "unlocked" + derived_phase = "awaiting_decision" + elif errors: + for task_code, item in reviews.items(): + if item.get("status") == "needs_attention": + effective[task_code] = "needs_attention" + + response.update({ + "valid": True, + "flow_type": flow_state.get("flow_type"), + "phase": flow_state.get("phase"), + "derived_phase": derived_phase, + "current_round": flow_state.get("current_round"), + "round_id": flow_state.get("round_id"), + "work_branch": flow_state.get("work_branch"), + "head_commit": flow_state.get("head_commit"), + "max_review_rounds": flow_state.get("max_review_rounds"), + "task_states": task_states, + "effective_task_states": effective, + "reviews": reviews, + "decision": flow_state.get("decision") if isinstance(flow_state.get("decision"), dict) else {}, + "pr": flow_state.get("pr") if isinstance(flow_state.get("pr"), dict) else {}, + "errors": errors, + }) + return response + + +def get_effective_business_state(db: Session, project: Project, task_code: str) -> str | None: + state = get_issue_review_flow_state(db, project) + if not state.get("enabled"): + return None + effective = state.get("effective_task_states") if isinstance(state.get("effective_task_states"), dict) else {} + value = effective.get(task_code) + return str(value) if value is not None else "frozen" + + +def is_business_dispatch_allowed(state: str | None) -> bool: + return state in BUSINESS_DISPATCHABLE_STATES diff --git a/src/backend/services/polling_service.py b/src/backend/services/polling_service.py index a212567..978cbd1 100644 --- a/src/backend/services/polling_service.py +++ b/src/backend/services/polling_service.py @@ -16,9 +16,12 @@ from services import feishu_service from services.feishu_service import NotificationEvent from validators.result_json import validate_result_json_content +from services.issue_review_loop import get_issue_review_flow_state, project_uses_issue_review_loop logger = logging.getLogger("half.poller") +ISSUE_REVIEW_LOOP_INTERMEDIATE_TASK_CODES = {"TASK-002", "TASK-003", "TASK-004"} + GIT_REPO_ACCESS_ERROR_MESSAGE = ( "无法访问 Git 仓库。请检查仓库是否存在、仓库地址是否正确," "是否有访问该仓库的权限。HALF 会自动重试。" @@ -103,6 +106,19 @@ def _set_task_runtime_error(db: Session, task: Task, now: datetime, message: str )) +def _mark_task_completed(db: Session, task: Task, now: datetime, result_path: str, detail: str) -> None: + task.status = "completed" + task.completed_at = now + task.result_file_path = result_path + task.last_error = None + task.updated_at = now + db.add(TaskEvent( + task_id=task.id, + event_type="completed", + detail=detail, + )) + + def _set_plan_runtime_error(plan: ProjectPlan, now: datetime, message: str, *, needs_attention: bool) -> None: plan.last_error = message plan.updated_at = now @@ -204,6 +220,13 @@ def _delay_satisfied(dispatched_at) -> bool: plan.last_error = f"Plan JSON not found at {source_path} after {elapsed_minutes:.1f} minutes" plan.updated_at = now + issue_review_loop_enabled = project_uses_issue_review_loop(db, project) + issue_review_flow_state = ( + get_issue_review_flow_state(db, project) + if issue_review_loop_enabled + else None + ) + for task in running_tasks: # Skip polling this task if start delay has not elapsed yet if not _delay_satisfied(task.dispatched_at): @@ -218,21 +241,44 @@ def _delay_satisfied(dispatched_at) -> bool: if result.found and result.validation_error: _set_task_runtime_error(db, task, now, result.validation_error, needs_attention=True) elif result.found: - task.status = "completed" - task.completed_at = now - task.result_file_path = result_path - task.last_error = None - task.updated_at = now - db.add(TaskEvent( - task_id=task.id, - event_type="completed", - detail=f"Result validated at {result_path}", - )) + _mark_task_completed(db, task, now, result_path, f"Result validated at {result_path}") pending_notifications.append(NotificationEvent( event_type="completed", project_name=project.name, task_name=task.task_name, )) + elif issue_review_loop_enabled and task.task_code in ISSUE_REVIEW_LOOP_INTERMEDIATE_TASK_CODES: + task_states = ( + issue_review_flow_state.get("task_states", {}) + if isinstance(issue_review_flow_state, dict) + else {} + ) + reviews = ( + issue_review_flow_state.get("reviews", {}) + if isinstance(issue_review_flow_state, dict) + else {} + ) + flow_result_path = "" + if task.task_code == "TASK-002" and task_states.get("TASK-002") in ("waiting_review", "approved"): + flow_result_path = result_path + elif task.task_code in ("TASK-003", "TASK-004"): + review_state = reviews.get(task.task_code) + if isinstance(review_state, dict) and review_state.get("status") == "submitted": + flow_result_path = str(review_state.get("review_path") or result_path) + + if flow_result_path: + _mark_task_completed( + db, + task, + now, + flow_result_path, + f"Issue review loop state advanced at {flow_result_path}", + ) + pending_notifications.append(NotificationEvent( + event_type="completed", + project_name=project.name, + task_name=task.task_name, + )) elif task.dispatched_at: elapsed_minutes = (now - task.dispatched_at.replace(tzinfo=timezone.utc)).total_seconds() / 60 timeout_limit = get_effective_task_timeout_minutes(db, project, task) diff --git a/src/backend/services/prompt_service.py b/src/backend/services/prompt_service.py index f9a3cad..c67f754 100644 --- a/src/backend/services/prompt_service.py +++ b/src/backend/services/prompt_service.py @@ -4,6 +4,7 @@ from sqlalchemy.orm import Session from models import Agent, ProcessTemplate, Project, ProjectPlan, Task +from services.issue_review_loop import FLOW_TYPE from services.project_agents import parse_agent_assignments_json from services.prompt_settings import normalize_plan_co_location_guidance @@ -259,6 +260,91 @@ def _build_template_inputs_section(db: Session, project: Project, task: Task) -> return "## 模版所需信息\n" + "\n".join(lines) +def _task_uses_issue_review_loop(db: Session, task: Task) -> bool: + if not getattr(task, "plan_id", None): + return False + plan = db.query(ProjectPlan).filter(ProjectPlan.id == task.plan_id).first() + data = _parse_json_object(getattr(plan, "plan_json", None) if plan else None) + return data.get("flow_type") == FLOW_TYPE + + +def _issue_review_loop_task_section(project: Project, task: Task) -> str: + collab = (project.collaboration_dir or "").strip("/") + flow_state_path = f"{collab}/flow-state.json" if collab else "flow-state.json" + project_repo_url = _project_repo_url(project) or "未提供" + collaboration_repo_url = _collaboration_repo_url(project) or "未提供" + common = f"""## Issue 编码与双 Agent 评审循环规则 +- 本流程固定使用 `TASK-001` 到 `TASK-005` 作为角色槽位,真实轮次记录在 `{flow_state_path}` 和各 Task 的轮次目录中。 +- HALF 协作仓库地址:{collaboration_repo_url} +- 项目代码仓库地址:{project_repo_url} +- 协作分支固定为 `main`:所有协作产物、`flow-state.json`、`branch.json`、`review.json`、`decision.json` 都必须提交并 push 到 HALF 协作仓库的 `main` 分支。 +- 项目代码分支与协作分支必须分开处理:代码改动 push 到项目工作分支;协作产物 push 到协作仓库 `main`。即使两个仓库地址相同,也不能把协作产物提交到项目工作分支。 +- 后端和前端只按当前轮次目录派生业务状态;不要扫描历史轮次目录寻找“最新”评审。 +- 轮次目录使用 `round-XXX` 三位补零格式,例如 `round-001`。 +- 不要把密钥、令牌、私有凭据写入协作产物。""" + + task_code = task.task_code + if task_code == "TASK-001": + specific = f"""## 本任务职责 +1. 从 `issue_url` 获取 issue 内容,写入 `TASK-001/issue-summary.md`。 +2. 生成实现计划,写入 `TASK-001/implementation-plan.md`。 +3. 初始化 `{flow_state_path}`,必须使用 HALF 后端可识别的顶层字段;不要写旧格式 `tasks.*.status`。 +4. `flow-state.json` 初始结构必须包含: +```json +{{ + "schema_version": 1, + "flow_type": "{FLOW_TYPE}", + "current_round": 1, + "round_id": "round-001", + "phase": "coding", + "work_branch": null, + "head_commit": null, + "max_review_rounds": 3, + "task_states": {{ + "TASK-001": "completed", + "TASK-002": "unlocked", + "TASK-003": "frozen", + "TASK-004": "frozen", + "TASK-005": "frozen" + }} +}} +``` +其中 `max_review_rounds` 必须使用模板输入 `max_review_rounds` 的数字值。 +5. 最后生成 `TASK-001/result.json` 作为计划初始化完成哨兵。""" + elif task_code == "TASK-002": + specific = f"""## 本任务职责 +1. 开始前读取 `{flow_state_path}`;只有 `TASK-002` 的业务状态为 `unlocked` 或 `needs_fix` 时才继续。 +2. 固定以项目仓库 `main` 分支作为基准分支创建或更新工作分支;工作分支名由你根据 issue 编号和时间自动生成,不要要求用户提供分支名。 +3. 若是修复轮次,只读取当前轮次两份 review,并逐条回应合理性;拒绝采纳的意见必须说明理由。 +4. 完成代码修改和必要测试后,把项目代码 commit 并 push 到项目仓库工作分支,不要把代码改动直接提交到 `main`。 +5. 代码分支 push 成功后,切换到 HALF 协作仓库 `main` 分支并拉取最新状态;如果项目代码仓库和 HALF 协作仓库是同一个仓库,也必须先切回 `main` 再写协作产物。 +6. 在协作仓库 `main` 写入 `TASK-002/rounds/round-XXX/branch.json`、实现或修复摘要、测试报告;`branch.json` 中的 `base_branch` 必须为 `main`。 +7. 在协作仓库 `main` 更新 `{flow_state_path}` 顶层字段:设置最新 `current_round`、`round_id`、`work_branch`、`head_commit`,在顶层 `task_states` 中将 `TASK-002` 置为 `waiting_review`,`TASK-003` / `TASK-004` 置为 `unlocked`,`TASK-005` 置为 `frozen`,`phase` 置为 `awaiting_review`。 +8. 将上述协作产物 commit 并 push 到 HALF 协作仓库 `origin/main`;不得把 `{flow_state_path}` 或 `TASK-002/rounds/` 只提交到项目工作分支。 +9. 不得在两个评审都同意合并前提交 PR;中间轮次不要用 `result.json` 结束整个角色槽位。""" + elif task_code in ("TASK-003", "TASK-004"): + specific = f"""## 本任务职责 +1. 开始前读取 `{flow_state_path}`;只有 `{task_code}` 的业务状态为 `unlocked` 时才继续。 +2. 读取当前轮次的 `TASK-002/rounds/round-XXX/branch.json`,并用其中 `round`、`round_id`、`work_branch`、`head_commit` 作为评审锚点。 +3. 基于用户填写的 `review_prompt` 独立评审代码正确性、测试覆盖、回归风险、可维护性和需求匹配度。 +4. 只在 HALF 协作仓库 `main` 分支写入 `{task_code}/reviews/round-XXX/review.json` 和 `{task_code}/reviews/round-XXX/review.md`,并 push 到 `origin/main`。 +5. `review.json` 必须包含布尔字段 `approve_merge`,并且 `round`、`round_id`、`work_branch`、`head_commit` 必须与当前 flow-state 一致。 +6. 评审 Agent 不得修改 `{flow_state_path}`,不得依赖另一名评审 Agent 的结论。""" + elif task_code == "TASK-005": + specific = f"""## 本任务职责 +1. 开始前读取 `{flow_state_path}` 和当前轮次两份 review;HALF 派发本任务代表后端已根据 review 文件派生 `TASK-005 = unlocked`,原始 `flow-state.json.task_states.TASK-005` 可能仍是 `frozen`,不要因此停止。 +2. 只读取当前轮次 `TASK-003/reviews/round-XXX/review.json` 和 `TASK-004/reviews/round-XXX/review.json`;任一文件缺失、非法或锚点不匹配时必须停止并说明原因。 +3. 两份 review 都必须包含布尔 `approve_merge`,并且 `round`、`round_id`、`work_branch`、`head_commit` 必须与当前 `{flow_state_path}` 一致。 +4. 在 HALF 协作仓库 `main` 分支写入 `TASK-005/decisions/round-XXX/decision.json` 和 `decision.md`。 +5. 若任一评审不同意合并,更新 `{flow_state_path}` 顶层 `task_states`:`TASK-002` 为 `needs_fix`,`TASK-003` / `TASK-004` / `TASK-005` 为 `frozen`,并更新 `phase`。 +6. 若达到 `max_review_rounds` 且仍未通过,写入人工处理报告并将流程标记为 `needs_attention`。 +7. 只有两份评审都同意合并时,先在顶层 `task_states` 把 `TASK-002` 标记为 `approved`,再以 `main` 作为目标分支提交 PR,写入 `TASK-005/pr.json` / `pr.md`,最后将流程标记为 `completed` 并生成最终 `result.json`。 +8. 将决策、PR 记录、`flow-state.json` 和最终 `result.json` commit 并 push 到 HALF 协作仓库 `origin/main`。""" + else: + specific = "" + return common + "\n\n" + specific + + def generate_task_prompt( db: Session, project: Project, @@ -269,6 +355,7 @@ def generate_task_prompt( task_dir = f"{collab}/{task.task_code}" if collab else task.task_code goal_text = (project.goal or "").strip() template_inputs_section = _build_template_inputs_section(db, project, task) + issue_review_loop_task = _task_uses_issue_review_loop(db, task) project_repo_url = _project_repo_url(project) collaboration_repo_url = _collaboration_repo_url(project) @@ -303,9 +390,35 @@ def generate_task_prompt( - 任务产出、`result.json`、`usage.json` 写入 HALF 协作仓库的协作目录;HALF 只轮询该协作仓库。""") if template_inputs_section: sections.append(template_inputs_section) + if issue_review_loop_task: + sections.append(_issue_review_loop_task_section(project, task)) + + sentinel_rules = """2. 本流程的中间轮次状态由 `flow-state.json`、`branch.json`、`review.json`、`decision.json` 表达;不要为了让 HALF 结束角色槽位而提前生成 `result.json` +3. 只有 `TASK-001` 初始化完成、`TASK-005` 提交 PR 成功或流程进入终止状态时,才生成对应任务的 `result.json` +4. 需要生成 `result.json` 时,先写入临时文件 `result.json.tmp`,确认写完并 flush 后,再原子重命名为 `result.json` +5. `result.json` 必须是合法 JSON 对象,包含 `task_code`、`summary`、`artifacts`;`task_code` 必须为 `{task_code}`,`summary` 必须为非空字符串,`artifacts` 必须是仓库根相对路径字符串数组,不得使用绝对路径、反斜杠或 `..` 越界路径 +6. 代码修改在项目代码仓库工作分支执行 git add、git commit、git push;协作产物在 HALF 协作仓库 `main` 分支执行 git add、git commit、git push origin main。""".format(task_code=task.task_code) if issue_review_loop_task else """2. 所有产出文件写完后,最后生成 `result.json`,它是完成哨兵,不是中间过程文件 +3. 先写入临时文件 `result.json.tmp`,确认写完并 flush 后,再原子重命名为 `result.json` +4. `result.json` 必须是合法 JSON 对象,包含 `task_code`、`summary`、`artifacts`;`task_code` 必须为 `{task_code}`,`summary` 必须为非空字符串,`artifacts` 必须是仓库根相对路径字符串数组,不得使用绝对路径、反斜杠或 `..` 越界路径 +5. 后续任务默认从前序任务目录及其中的 `result.json` 读取成果,不要依赖旧的单文件输出路径约定 +6. 代码修改在项目代码仓库执行 git add、git commit、git push;协作产物在 HALF 协作仓库执行 git add、git commit、git push。""".format(task_code=task.task_code) + predecessor_check = ( + "2. 按本流程规则读取前序任务目录、`flow-state.json` 和当前轮次产物;不要要求中间轮次一定存在前序 `result.json`。" + if issue_review_loop_task + else "2. 确认上述前序任务目录及其中的 `result.json` 已经存在;若仍缺失,请等待或与项目负责人沟通,不要凭空创作前序内容。" + ) + completion_sentinel = ( + """## 完成哨兵约束 +- 本流程的中间任务不要提前生成 `result.json`;按上方专用规则在允许的阶段生成。 +- 如果生成 `result.json` 时没有代码改动,必须在报告和 `result.json` 中明确说明 `no_code_changes: true` 以及验证依据。""" + if issue_review_loop_task + else """## 完成哨兵约束 +- 只有项目代码仓库的代码修改已经提交并 push 成功后,才允许生成 `result.json`。 +- 如果本任务没有代码改动,必须在报告和 `result.json` 中明确说明 `no_code_changes: true` 以及验证依据;不得只生成 `result.json` 冒充完成。""" + ) sections.append(f"""## 执行前置步骤(必须先做) 1. 在开始本任务前,必须先在项目代码仓库目录执行 `git pull`;若 HALF 协作仓库与项目代码仓库不同,也必须在 HALF 协作仓库目录执行 `git pull`,确保拿到最新的远端状态,否则可能读不到前序任务输出。 -2. 确认上述前序任务目录及其中的 `result.json` 已经存在;若仍缺失,请等待或与项目负责人沟通,不要凭空创作前序内容。 +{predecessor_check} ## 任务信息 - 任务码:{task.task_code} @@ -317,14 +430,8 @@ def generate_task_prompt( ## 输出要求 1. 将所有协作产出文件写入 HALF 协作仓库目录:{task_dir}/ -2. 所有产出文件写完后,最后生成 `result.json`,它是完成哨兵,不是中间过程文件 -3. 先写入临时文件 `result.json.tmp`,确认写完并 flush 后,再原子重命名为 `result.json` -4. `result.json` 必须是合法 JSON 对象,包含 `task_code`、`summary`、`artifacts`;`task_code` 必须为 `{task.task_code}`,`summary` 必须为非空字符串,`artifacts` 必须是仓库根相对路径字符串数组,不得使用绝对路径、反斜杠或 `..` 越界路径 -5. 后续任务默认从前序任务目录及其中的 `result.json` 读取成果,不要依赖旧的单文件输出路径约定 -6. 代码修改在项目代码仓库执行 git add、git commit、git push;协作产物在 HALF 协作仓库执行 git add、git commit、git push。 +{sentinel_rules} -## 完成哨兵约束 -- 只有项目代码仓库的代码修改已经提交并 push 成功后,才允许生成 `result.json`。 -- 如果本任务没有代码改动,必须在报告和 `result.json` 中明确说明 `no_code_changes: true` 以及验证依据;不得只生成 `result.json` 冒充完成。""") +{completion_sentinel}""") return "\n\n".join(sections) diff --git a/src/backend/tests/test_issue_review_loop.py b/src/backend/tests/test_issue_review_loop.py new file mode 100644 index 0000000..7696515 --- /dev/null +++ b/src/backend/tests/test_issue_review_loop.py @@ -0,0 +1,197 @@ +import json +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +BACKEND_DIR = Path(__file__).resolve().parents[1] +if str(BACKEND_DIR) not in sys.path: + sys.path.insert(0, str(BACKEND_DIR)) + +from auth import hash_password +from database import Base +from models import ProcessTemplate, Project, ProjectPlan, Task, User +from routers.tasks import TaskDispatchRequest, dispatch_task +from services.issue_review_loop import ( + FLOW_TYPE, + TEMPLATE_NAME, + ensure_issue_review_loop_template, + get_issue_review_flow_state, + issue_review_loop_required_inputs, +) + + +class IssueReviewLoopTests(unittest.TestCase): + def setUp(self): + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + Base.metadata.create_all(bind=engine) + self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + self.db = self.SessionLocal() + self.user = User(id=1, username="owner", password_hash=hash_password("Owner123")) + self.project = Project( + id=10, + name="Loop", + git_repo_url="git@github.com:example-org/half-collab.git", + project_repo_url="git@github.com:example-org/app.git", + collaboration_dir="outputs/proj-10", + status="executing", + created_by=self.user.id, + default_max_review_rounds=3, + ) + self.plan = ProjectPlan( + id=20, + project_id=10, + plan_type="final", + status="final", + is_selected=True, + plan_json=json.dumps({"flow_type": FLOW_TYPE, "tasks": []}), + ) + self.tasks = [ + Task( + id=index, + project_id=10, + plan_id=20, + task_code=f"TASK-00{index}", + task_name=f"Task {index}", + status="pending", + depends_on_json="[]", + ) + for index in range(1, 6) + ] + self.db.add_all([self.user, self.project, self.plan, *self.tasks]) + self.db.commit() + self.addCleanup(self.db.close) + + def _flow_state(self) -> dict: + return { + "schema_version": 1, + "flow_type": FLOW_TYPE, + "current_round": 1, + "round_id": "round-001-abc123", + "phase": "awaiting_review", + "work_branch": "issue-123", + "head_commit": "abc123", + "max_review_rounds": 3, + "task_states": { + "TASK-001": "completed", + "TASK-002": "waiting_review", + "TASK-003": "unlocked", + "TASK-004": "unlocked", + "TASK-005": "frozen", + }, + } + + def _review(self, approve_merge: bool) -> dict: + return { + "round": 1, + "round_id": "round-001-abc123", + "work_branch": "issue-123", + "head_commit": "abc123", + "approve_merge": approve_merge, + } + + def test_builtin_required_inputs_do_not_include_branch_fields(self): + keys = {item["key"] for item in issue_review_loop_required_inputs()} + + self.assertNotIn("base_branch", keys) + self.assertNotIn("work_branch_name", keys) + self.assertNotIn("pr_target_branch", keys) + self.assertEqual(keys, {"issue_url", "review_prompt", "test_command", "max_review_rounds"}) + + def test_ensure_builtin_template_refreshes_existing_branch_inputs(self): + old_required_inputs = [ + {"key": "issue_url", "label": "Issue URL", "required": True, "sensitive": False}, + {"key": "base_branch", "label": "基准分支", "required": True, "sensitive": False}, + {"key": "work_branch_name", "label": "工作分支名", "required": False, "sensitive": False}, + {"key": "pr_target_branch", "label": "PR 目标分支", "required": False, "sensitive": False}, + ] + template = ProcessTemplate( + name=TEMPLATE_NAME, + description="old", + prompt_source_text="old", + agent_count=1, + agent_slots_json=json.dumps(["agent-1"]), + agent_roles_description_json="{}", + required_inputs_json=json.dumps(old_required_inputs, ensure_ascii=False), + template_json=json.dumps({"tasks": [], "flow_type": FLOW_TYPE}, ensure_ascii=False), + created_by=self.user.id, + updated_by=self.user.id, + ) + self.db.add(template) + self.db.commit() + + ensure_issue_review_loop_template(self.db, self.user) + self.db.refresh(template) + keys = {item["key"] for item in json.loads(template.required_inputs_json)} + + self.assertNotIn("base_branch", keys) + self.assertNotIn("work_branch_name", keys) + self.assertNotIn("pr_target_branch", keys) + self.assertEqual(template.agent_count, 3) + self.assertEqual(template.updated_by, self.user.id) + + def test_missing_flow_state_only_unlocks_task_001(self): + with patch("services.issue_review_loop.git_service.read_file", return_value=None): + state = get_issue_review_flow_state(self.db, self.project) + + self.assertTrue(state["enabled"]) + self.assertFalse(state["exists"]) + self.assertEqual(state["effective_task_states"]["TASK-001"], "unlocked") + self.assertEqual(state["effective_task_states"]["TASK-002"], "frozen") + + def test_two_valid_reviews_unlock_decision_task(self): + files = { + "outputs/proj-10/flow-state.json": json.dumps(self._flow_state()), + "outputs/proj-10/TASK-003/reviews/round-001/review.json": json.dumps(self._review(False)), + "outputs/proj-10/TASK-004/reviews/round-001/review.json": json.dumps(self._review(True)), + } + + with patch("services.issue_review_loop.git_service.read_file", side_effect=lambda _project_id, path, **_kw: files.get(path)): + state = get_issue_review_flow_state(self.db, self.project) + + self.assertTrue(state["valid"]) + self.assertEqual(state["derived_phase"], "awaiting_decision") + self.assertEqual(state["effective_task_states"]["TASK-005"], "unlocked") + self.assertEqual(state["reviews"]["TASK-003"]["approve_merge"], False) + self.assertEqual(state["reviews"]["TASK-004"]["approve_merge"], True) + + def test_mismatched_review_does_not_unlock_decision_task(self): + bad_review = self._review(True) + bad_review["head_commit"] = "old" + files = { + "outputs/proj-10/flow-state.json": json.dumps(self._flow_state()), + "outputs/proj-10/TASK-003/reviews/round-001/review.json": json.dumps(bad_review), + "outputs/proj-10/TASK-004/reviews/round-001/review.json": json.dumps(self._review(True)), + } + + with patch("services.issue_review_loop.git_service.read_file", side_effect=lambda _project_id, path, **_kw: files.get(path)): + state = get_issue_review_flow_state(self.db, self.project) + + self.assertEqual(state["effective_task_states"]["TASK-005"], "frozen") + self.assertEqual(state["effective_task_states"]["TASK-003"], "needs_attention") + self.assertIn("head_commit", state["errors"][0]) + + def test_dispatch_uses_loop_business_state_instead_of_db_predecessors(self): + flow = self._flow_state() + flow["task_states"]["TASK-003"] = "frozen" + files = {"outputs/proj-10/flow-state.json": json.dumps(flow)} + task_3 = self.tasks[2] + + with patch("services.issue_review_loop.git_service.read_file", side_effect=lambda _project_id, path, **_kw: files.get(path)): + with self.assertRaises(Exception) as ctx: + dispatch_task(task_3.id, TaskDispatchRequest(), self.db, self.user) + self.assertIn("issue review loop state is: frozen", str(ctx.exception)) + + flow["task_states"]["TASK-003"] = "unlocked" + files["outputs/proj-10/flow-state.json"] = json.dumps(flow) + with patch("services.issue_review_loop.git_service.read_file", side_effect=lambda _project_id, path, **_kw: files.get(path)): + updated = dispatch_task(task_3.id, TaskDispatchRequest(), self.db, self.user) + + self.assertEqual(updated.status, "running") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/backend/tests/test_polling_service.py b/src/backend/tests/test_polling_service.py index 8c1845a..0f1b137 100644 --- a/src/backend/tests/test_polling_service.py +++ b/src/backend/tests/test_polling_service.py @@ -16,6 +16,7 @@ from database import Base from models import Project, Task, ProjectPlan, TaskEvent from services.git_service import RepoSyncStatus +from services.issue_review_loop import FLOW_TYPE from services.polling_service import ( GIT_REPO_ACCESS_ERROR_MESSAGE, _task_usage_path, @@ -123,6 +124,90 @@ def _seed_running_plan(self, *, dispatched_minutes_ago: int) -> tuple[Project, P db.refresh(plan) return project, plan + def _seed_issue_review_loop_task(self, task_code: str) -> tuple[Project, Task]: + db = self.SessionLocal() + self.addCleanup(db.close) + + project = Project( + id=71, + name="Issue Review Loop", + git_repo_url="git@github.com:example-org/example-repo.git", + collaboration_dir="outputs/proj-71-loop", + status="executing", + task_timeout_minutes=20, + ) + plan = ProjectPlan( + id=72, + project_id=71, + status="final", + is_selected=True, + plan_json=json.dumps({"flow_type": FLOW_TYPE, "tasks": []}), + ) + task = Task( + id=73, + project_id=71, + plan_id=72, + task_code=task_code, + task_name=f"{task_code} loop task", + status="running", + expected_output_path=f"outputs/proj-71-loop/{task_code}/result.json", + dispatched_at=datetime.now(timezone.utc) - timedelta(minutes=11), + timeout_minutes=10, + ) + db.add_all([project, plan, task]) + db.commit() + db.refresh(project) + db.refresh(task) + return project, task + + def _loop_flow_state(self, task_002_state: str = "waiting_review") -> dict: + return { + "schema_version": 1, + "flow_type": FLOW_TYPE, + "current_round": 1, + "round_id": "round-001-abc123", + "phase": "awaiting_review", + "work_branch": "issue-123", + "head_commit": "abc123", + "max_review_rounds": 3, + "task_states": { + "TASK-001": "completed", + "TASK-002": task_002_state, + "TASK-003": "unlocked", + "TASK-004": "unlocked", + "TASK-005": "frozen", + }, + } + + def _loop_review(self, approve_merge: bool = True) -> dict: + return { + "round": 1, + "round_id": "round-001-abc123", + "work_branch": "issue-123", + "head_commit": "abc123", + "approve_merge": approve_merge, + } + + def _poll_loop_task_with_files(self, project: Project, files: dict[str, str | None]) -> Task: + def read_file(_project_id, path, **_kwargs): + return files.get(path) + + with patch( + "services.polling_service.git_service.ensure_repo_sync", + return_value=RepoSyncStatus(repo_dir="/tmp/repo", fetched=True, pulled=True, remote_ready=True), + ), patch( + "services.polling_service.git_service.read_file", + side_effect=read_file, + ), patch( + "services.polling_service.git_service.file_exists", + return_value=False, + ): + poll_project(self.SessionLocal(), project) + + verify_db = self.SessionLocal() + self.addCleanup(verify_db.close) + return verify_db.query(Task).filter(Task.project_id == project.id).first() + def test_poll_project_marks_task_completed_when_valid_result_json_exists(self): project, task = self._seed_running_task("outputs/proj-7-7b145d/TASK-001/requirements.md") result_path = "outputs/proj-7-7b145d/TASK-001/result.json" @@ -146,6 +231,65 @@ def test_poll_project_marks_task_completed_when_valid_result_json_exists(self): self.assertEqual(refreshed.result_file_path, result_path) self.assertIsNotNone(refreshed.completed_at) + def test_poll_project_marks_loop_task_002_completed_when_awaiting_review(self): + project, task = self._seed_issue_review_loop_task("TASK-002") + files = { + "outputs/proj-71-loop/TASK-002/result.json": None, + "outputs/proj-71-loop/flow-state.json": json.dumps(self._loop_flow_state("waiting_review")), + } + + refreshed = self._poll_loop_task_with_files(project, files) + + self.assertEqual(refreshed.id, task.id) + self.assertEqual(refreshed.status, "completed") + self.assertEqual(refreshed.result_file_path, "outputs/proj-71-loop/TASK-002/result.json") + self.assertIsNotNone(refreshed.completed_at) + + def test_poll_project_does_not_complete_loop_task_002_while_still_unlocked(self): + project, task = self._seed_issue_review_loop_task("TASK-002") + files = { + "outputs/proj-71-loop/TASK-002/result.json": None, + "outputs/proj-71-loop/flow-state.json": json.dumps(self._loop_flow_state("unlocked")), + } + + refreshed = self._poll_loop_task_with_files(project, files) + + self.assertEqual(refreshed.id, task.id) + self.assertEqual(refreshed.status, "running") + self.assertIsNone(refreshed.result_file_path) + + def test_poll_project_marks_loop_review_task_completed_when_review_submitted(self): + project, task = self._seed_issue_review_loop_task("TASK-003") + review_path = "outputs/proj-71-loop/TASK-003/reviews/round-001/review.json" + files = { + "outputs/proj-71-loop/TASK-003/result.json": None, + "outputs/proj-71-loop/flow-state.json": json.dumps(self._loop_flow_state("waiting_review")), + review_path: json.dumps(self._loop_review()), + } + + refreshed = self._poll_loop_task_with_files(project, files) + + self.assertEqual(refreshed.id, task.id) + self.assertEqual(refreshed.status, "completed") + self.assertEqual(refreshed.result_file_path, review_path) + self.assertIsNotNone(refreshed.completed_at) + + def test_poll_project_does_not_complete_loop_review_task_when_review_mismatches_flow_state(self): + project, task = self._seed_issue_review_loop_task("TASK-003") + bad_review = self._loop_review() + bad_review["head_commit"] = "old" + files = { + "outputs/proj-71-loop/TASK-003/result.json": None, + "outputs/proj-71-loop/flow-state.json": json.dumps(self._loop_flow_state("waiting_review")), + "outputs/proj-71-loop/TASK-003/reviews/round-001/review.json": json.dumps(bad_review), + } + + refreshed = self._poll_loop_task_with_files(project, files) + + self.assertEqual(refreshed.id, task.id) + self.assertEqual(refreshed.status, "running") + self.assertIsNone(refreshed.result_file_path) + def test_poll_project_rejects_malformed_result_json(self): refreshed, error_events = self._poll_running_task_with_result_content("{not-json") diff --git a/src/backend/tests/test_prompt_service.py b/src/backend/tests/test_prompt_service.py index bcbaf92..4c0e0c6 100644 --- a/src/backend/tests/test_prompt_service.py +++ b/src/backend/tests/test_prompt_service.py @@ -7,6 +7,7 @@ sys.path.insert(0, str(BACKEND_DIR)) from models import Agent, ProcessTemplate, Project, ProjectPlan, Task +from services.issue_review_loop import FLOW_TYPE from services.prompt_service import generate_plan_prompt, generate_task_prompt, resolve_selected_agent_models from services.prompt_settings import DEFAULT_PLAN_CO_LOCATION_GUIDANCE @@ -303,6 +304,104 @@ def test_generate_task_prompt_omits_template_inputs_without_template_source(self prompt = generate_task_prompt(FakeTemplateSession(plan, template), project, task) self.assertNotIn("## 模版所需信息", prompt) + def test_issue_review_task_001_prompt_uses_backend_flow_state_contract(self): + project = Project(id=4, name="Loop", collaboration_dir="outputs/proj-4", template_inputs_json='{"max_review_rounds":"5"}') + task = Task( + project_id=4, + plan_id=21, + task_code="TASK-001", + task_name="初始化", + description="初始化评审循环", + depends_on_json="[]", + ) + plan = ProjectPlan(id=21, plan_json=f'{{"flow_type":"{FLOW_TYPE}"}}') + + prompt = generate_task_prompt(FakeTemplateSession(plan), project, task) + + self.assertIn('"flow_type": "issue_code_review_loop"', prompt) + self.assertIn('"schema_version": 1', prompt) + self.assertIn('"round_id": "round-001"', prompt) + self.assertIn('"phase": "coding"', prompt) + self.assertIn('"task_states": {', prompt) + self.assertIn('"TASK-001": "completed"', prompt) + self.assertIn('"TASK-002": "unlocked"', prompt) + self.assertIn('"TASK-005": "frozen"', prompt) + self.assertIn("不要写旧格式 `tasks.*.status`", prompt) + self.assertIn("`max_review_rounds` 的数字值", prompt) + + def test_issue_review_task_002_prompt_updates_top_level_task_states(self): + project = Project(id=4, name="Loop", collaboration_dir="outputs/proj-4") + task = Task( + project_id=4, + plan_id=21, + task_code="TASK-002", + task_name="编码", + description="编码并推送", + depends_on_json="[]", + ) + plan = ProjectPlan(id=21, plan_json=f'{{"flow_type":"{FLOW_TYPE}"}}') + + prompt = generate_task_prompt(FakeTemplateSession(plan), project, task) + + self.assertIn("协作分支固定为 `main`", prompt) + self.assertIn("项目代码分支与协作分支必须分开处理", prompt) + self.assertIn("即使两个仓库地址相同,也不能把协作产物提交到项目工作分支", prompt) + self.assertIn("固定以项目仓库 `main` 分支作为基准分支", prompt) + self.assertIn("工作分支名由你根据 issue 编号和时间自动生成", prompt) + self.assertIn("把项目代码 commit 并 push 到项目仓库工作分支", prompt) + self.assertIn("切换到 HALF 协作仓库 `main` 分支", prompt) + self.assertIn("push 到 HALF 协作仓库 `origin/main`", prompt) + self.assertIn("不得把 `outputs/proj-4/flow-state.json` 或 `TASK-002/rounds/` 只提交到项目工作分支", prompt) + self.assertIn("`base_branch` 必须为 `main`", prompt) + self.assertNotIn("`base_branch` 创建或更新工作分支", prompt) + self.assertNotIn("`work_branch_name`", prompt) + self.assertIn("更新 `outputs/proj-4/flow-state.json` 顶层字段", prompt) + self.assertIn("在顶层 `task_states` 中将 `TASK-002` 置为 `waiting_review`", prompt) + self.assertIn("`TASK-003` / `TASK-004` 置为 `unlocked`", prompt) + self.assertIn("`phase` 置为 `awaiting_review`", prompt) + + def test_issue_review_task_003_prompt_pushes_review_to_collaboration_main(self): + project = Project(id=4, name="Loop", collaboration_dir="outputs/proj-4") + task = Task( + project_id=4, + plan_id=21, + task_code="TASK-003", + task_name="评审 A", + description="评审", + depends_on_json="[]", + ) + plan = ProjectPlan(id=21, plan_json=f'{{"flow_type":"{FLOW_TYPE}"}}') + + prompt = generate_task_prompt(FakeTemplateSession(plan), project, task) + + self.assertIn("只在 HALF 协作仓库 `main` 分支写入 `TASK-003/reviews/round-XXX/review.json`", prompt) + self.assertIn("push 到 `origin/main`", prompt) + self.assertIn("评审 Agent 不得修改 `outputs/proj-4/flow-state.json`", prompt) + + def test_issue_review_task_005_prompt_updates_top_level_task_states(self): + project = Project(id=4, name="Loop", collaboration_dir="outputs/proj-4") + task = Task( + project_id=4, + plan_id=21, + task_code="TASK-005", + task_name="决策", + description="评审决策", + depends_on_json="[]", + ) + plan = ProjectPlan(id=21, plan_json=f'{{"flow_type":"{FLOW_TYPE}"}}') + + prompt = generate_task_prompt(FakeTemplateSession(plan), project, task) + + self.assertIn("HALF 派发本任务代表后端已根据 review 文件派生 `TASK-005 = unlocked`", prompt) + self.assertIn("原始 `flow-state.json.task_states.TASK-005` 可能仍是 `frozen`,不要因此停止", prompt) + self.assertIn("两份 review 都必须包含布尔 `approve_merge`", prompt) + self.assertIn("`round`、`round_id`、`work_branch`、`head_commit` 必须与当前 `outputs/proj-4/flow-state.json` 一致", prompt) + self.assertIn("更新 `outputs/proj-4/flow-state.json` 顶层 `task_states`", prompt) + self.assertIn("`TASK-002` 为 `needs_fix`", prompt) + self.assertIn("在顶层 `task_states` 把 `TASK-002` 标记为 `approved`", prompt) + self.assertIn("以 `main` 作为目标分支提交 PR", prompt) + self.assertIn("将决策、PR 记录、`flow-state.json` 和最终 `result.json` commit 并 push 到 HALF 协作仓库 `origin/main`", prompt) + if __name__ == "__main__": unittest.main() diff --git a/src/frontend/src/components/DagView.tsx b/src/frontend/src/components/DagView.tsx index ad5da1c..4d4ecfd 100644 --- a/src/frontend/src/components/DagView.tsx +++ b/src/frontend/src/components/DagView.tsx @@ -17,6 +17,12 @@ const STATUS_COLORS: Record = { completed: '#22c55e', needs_attention: '#ef4444', abandoned: '#64748b', + frozen: '#94a3b8', + unlocked: '#eab308', + waiting_review: '#3b82f6', + waiting_decision: '#3b82f6', + needs_fix: '#ef4444', + approved: '#22c55e', }; const STATUS_BACKGROUNDS: Record = { @@ -26,6 +32,12 @@ const STATUS_BACKGROUNDS: Record = { completed: '#ecfdf5', needs_attention: '#fef2f2', abandoned: '#e2e8f0', + frozen: '#f1f5f9', + unlocked: '#fef9c3', + waiting_review: '#eff6ff', + waiting_decision: '#eff6ff', + needs_fix: '#fef2f2', + approved: '#ecfdf5', }; interface Props { @@ -33,6 +45,7 @@ interface Props { selectedTaskId?: number | null; onSelectTask: (taskId: number) => void; missingPredecessorIds?: Set; + showIssueReviewLoopEdge?: boolean; } function computeLayout(tasks: Task[]): Map { @@ -90,6 +103,11 @@ function computeLayout(tasks: Task[]): Map { } function getVisualStatus(task: Task, tasks: Task[]): string { + const businessStatus = (task as Task & { business_status?: string | null }).business_status; + if (businessStatus) { + return businessStatus; + } + if (task.status !== 'pending') { return task.status; } @@ -113,7 +131,7 @@ function getVisualStatus(task: Task, tasks: Task[]): string { return isReady ? 'pending_ready' : 'pending_blocked'; } -export default function DagView({ tasks, selectedTaskId, onSelectTask, missingPredecessorIds }: Props) { +export default function DagView({ tasks, selectedTaskId, onSelectTask, missingPredecessorIds, showIssueReviewLoopEdge }: Props) { const { initialNodes, initialEdges } = useMemo(() => { const positions = computeLayout(tasks); @@ -180,8 +198,22 @@ export default function DagView({ tasks, selectedTaskId, onSelectTask, missingPr }); }); + if (showIssueReviewLoopEdge) { + const decisionTask = tasks.find((task) => task.task_code === 'TASK-005'); + const codingTask = tasks.find((task) => task.task_code === 'TASK-002'); + if (decisionTask && codingTask) { + edges.push({ + id: `${decisionTask.id}-${codingTask.id}-review-loop`, + source: String(decisionTask.id), + target: String(codingTask.id), + markerEnd: { type: MarkerType.ArrowClosed }, + style: { stroke: '#ef4444', strokeDasharray: '6 4' }, + }); + } + } + return { initialNodes: nodes, initialEdges: edges }; - }, [tasks, selectedTaskId, missingPredecessorIds]); + }, [tasks, selectedTaskId, missingPredecessorIds, showIssueReviewLoopEdge]); const onNodeClick = useCallback( (_: React.MouseEvent, node: Node) => { diff --git a/src/frontend/src/components/StatusBadge.tsx b/src/frontend/src/components/StatusBadge.tsx index bca1da7..9a3c391 100644 --- a/src/frontend/src/components/StatusBadge.tsx +++ b/src/frontend/src/components/StatusBadge.tsx @@ -7,6 +7,12 @@ const STATUS_COLORS: Record = { completed: '#22c55e', needs_attention: '#eab308', abandoned: '#9ca3af', + frozen: '#94a3b8', + unlocked: '#eab308', + waiting_review: '#3b82f6', + waiting_decision: '#3b82f6', + needs_fix: '#ef4444', + approved: '#22c55e', draft: '#9ca3af', planning: '#3b82f6', executing: '#ef4444', @@ -29,6 +35,12 @@ const STATUS_LABELS: Record = { completed: '已完成', needs_attention: '需关注', abandoned: '已放弃', + frozen: '冻结', + unlocked: '可派发', + waiting_review: '等待评审', + waiting_decision: '等待决策', + needs_fix: '需修复', + approved: '评审通过', draft: '草稿', planning: '规划中', executing: '执行中', diff --git a/src/frontend/src/components/TaskDetailPanel.tsx b/src/frontend/src/components/TaskDetailPanel.tsx index f073852..0594fa0 100644 --- a/src/frontend/src/components/TaskDetailPanel.tsx +++ b/src/frontend/src/components/TaskDetailPanel.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { Task, Agent } from '../types'; +import { Task, Agent, FlowState } from '../types'; import { api } from '../api/client'; import StatusBadge from './StatusBadge'; import { copyText } from '../contracts'; @@ -9,10 +9,11 @@ interface Props { task: Task; agents: Agent[]; allTasks: Task[]; + flowState?: FlowState | null; onRefresh: () => void; } -export default function TaskDetailPanel({ task, agents, allTasks, onRefresh }: Props) { +export default function TaskDetailPanel({ task, agents, allTasks, flowState, onRefresh }: Props) { const [loading, setLoading] = useState(''); const [copied, setCopied] = useState(false); const [showDispatchReminder, setShowDispatchReminder] = useState(false); @@ -41,8 +42,10 @@ export default function TaskDetailPanel({ task, agents, allTasks, onRefresh }: P const blockedPredecessors = predecessorTasks.filter( (predecessorTask) => predecessorTask.status !== 'completed' && predecessorTask.status !== 'abandoned' ); - const canOperate = blockedPredecessors.length === 0; - const canEdit = task.status === 'pending' && canOperate; + const businessState = flowState?.enabled ? (flowState.effective_task_states?.[task.task_code] || 'frozen') : null; + const businessDispatchable = businessState === 'unlocked' || businessState === 'needs_fix'; + const canOperate = flowState?.enabled ? businessDispatchable : blockedPredecessors.length === 0; + const canEdit = !flowState?.enabled && task.status === 'pending' && canOperate; useEffect(() => { setDraftTaskName(task.task_name); @@ -108,7 +111,11 @@ export default function TaskDetailPanel({ task, agents, allTasks, onRefresh }: P setPromptError(null); if (!canOperate) return undefined; if (hasDraftChanges) return undefined; - if (!['pending', 'needs_attention', 'running'].includes(task.status)) return undefined; + if (flowState?.enabled) { + if (!businessDispatchable) return undefined; + } else if (!['pending', 'needs_attention', 'running'].includes(task.status)) { + return undefined; + } let cancelled = false; api.post<{ prompt: string }>( @@ -126,7 +133,7 @@ export default function TaskDetailPanel({ task, agents, allTasks, onRefresh }: P return () => { cancelled = true; }; - }, [task.id, task.status, task.task_name, task.description, task.expected_output_path, canOperate, hasDraftChanges]); + }, [task.id, task.status, task.task_name, task.description, task.expected_output_path, canOperate, hasDraftChanges, flowState?.enabled, businessDispatchable]); async function performDispatch(action: 'dispatch' | 'redispatch') { if (!canOperate) { @@ -216,6 +223,12 @@ export default function TaskDetailPanel({ task, agents, allTasks, onRefresh }: P

{task.task_code}: {task.task_name}

+ {businessState && ( +
+ +

+
+ )}
@@ -343,7 +356,7 @@ export default function TaskDetailPanel({ task, agents, allTasks, onRefresh }: P )}
- {(task.status === 'pending' || task.status === 'needs_attention') && ( + {((flowState?.enabled && businessDispatchable) || (!flowState?.enabled && (task.status === 'pending' || task.status === 'needs_attention'))) && (
+
+ + setDefaultMaxReviewRounds(parseInt(e.target.value) || 3)} + placeholder="默认 3" + /> +
Issue 编码评审循环模板会默认使用该值。
+
diff --git a/src/frontend/src/pages/TasksPage.tsx b/src/frontend/src/pages/TasksPage.tsx index e8cbe05..d91c73a 100644 --- a/src/frontend/src/pages/TasksPage.tsx +++ b/src/frontend/src/pages/TasksPage.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { api } from '../api/client'; -import { Task, Agent, Project } from '../types'; +import { Task, Agent, Project, FlowState } from '../types'; import DagView from '../components/DagView'; import TaskDetailPanel from '../components/TaskDetailPanel'; import { getNextStepText } from '../contracts'; @@ -12,6 +12,7 @@ export default function TasksPage() { const [project, setProject] = useState(null); const [tasks, setTasks] = useState([]); const [agents, setAgents] = useState([]); + const [flowState, setFlowState] = useState(null); const [selectedTaskId, setSelectedTaskId] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -27,8 +28,12 @@ export default function TasksPage() { setLoading(false); }); try { - const taskList = await api.get(`/api/projects/${id}/tasks`); + const [taskList, flowStateData] = await Promise.all([ + api.get(`/api/projects/${id}/tasks`), + api.get(`/api/projects/${id}/flow-state`).catch(() => null), + ]); setTasks(taskList); + setFlowState(flowStateData); } catch { // ignore } finally { @@ -95,6 +100,7 @@ export default function TasksPage() { return { ...task, assignee_label: assignee ? assignee.name : null, + business_status: flowState?.enabled ? flowState.effective_task_states?.[task.task_code] : null, }; }); @@ -125,6 +131,31 @@ export default function TasksPage() {
)} + {flowState?.enabled && ( +
+ 评审循环: + {' '} + 阶段 {flowState.derived_phase || flowState.phase || '-'}, + 轮次 {flowState.current_round ?? '-'}, + 分支 {flowState.work_branch || '-'}, + commit {flowState.head_commit || '-'} + {flowState.pr?.url && ( + <> + {' '},PR {flowState.pr.url} + + )} + {flowState.reviews?.['TASK-003'] && ( + <>,评审 A {flowState.reviews['TASK-003'].status}{typeof flowState.reviews['TASK-003'].approve_merge === 'boolean' ? ` / ${flowState.reviews['TASK-003'].approve_merge ? '同意' : '不同意'}` : ''} + )} + {flowState.reviews?.['TASK-004'] && ( + <>,评审 B {flowState.reviews['TASK-004'].status}{typeof flowState.reviews['TASK-004'].approve_merge === 'boolean' ? ` / ${flowState.reviews['TASK-004'].approve_merge ? '同意' : '不同意'}` : ''} + )} + {flowState.errors?.length > 0 && ( +
{flowState.errors.join(';')}
+ )} +
+ )} +
@@ -140,6 +172,7 @@ export default function TasksPage() { task={selectedTask} agents={agents} allTasks={tasks} + flowState={flowState} onRefresh={fetchData} /> ) : ( diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index 9efe471..938f3b3 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -83,6 +83,7 @@ export interface Project { polling_start_delay_minutes?: number | null; polling_start_delay_seconds?: number | null; task_timeout_minutes?: number | null; + default_max_review_rounds?: number; planning_mode?: string; template_inputs?: Record; inactive_agent_ids?: number[]; @@ -163,6 +164,35 @@ export interface Task { completed_at: string | null; } +export interface IssueReviewLoopReviewState { + status: string; + approve_merge: boolean | null; + review_path: string | null; +} + +export interface FlowState { + enabled: boolean; + exists: boolean; + valid: boolean; + flow_type: string | null; + phase: string | null; + derived_phase: string | null; + current_round: number | null; + round_id: string | null; + work_branch: string | null; + head_commit: string | null; + max_review_rounds: number | null; + task_states: Record; + effective_task_states: Record; + reviews: Record; + decision: Record; + pr: { + status?: string | null; + url?: string | null; + }; + errors: string[]; +} + export interface TaskEvent { id: number; task_id: number; diff --git a/src/frontend/src/utils/applyTemplatePlan.test.ts b/src/frontend/src/utils/applyTemplatePlan.test.ts index 2b0e4b4..1ff3b11 100644 --- a/src/frontend/src/utils/applyTemplatePlan.test.ts +++ b/src/frontend/src/utils/applyTemplatePlan.test.ts @@ -111,6 +111,45 @@ describe('applyTemplatePlan', () => { }); }); + it('does not submit removed issue-review branch inputs', async () => { + const api = createApi(); + const requiredInputs = [ + { key: 'issue_url', label: 'Issue URL', required: true, sensitive: false }, + { key: 'review_prompt', label: '评审提示词', required: true, sensitive: false }, + { key: 'test_command', label: '测试命令', required: false, sensitive: false }, + { key: 'max_review_rounds', label: '最大评审轮次', required: true, sensitive: false }, + ]; + + await applyTemplatePlan({ + api, + projectId: 8, + templateId: 5, + planningBrief: '实现 issue', + slotAgentIds: { 'agent-1': 1, 'agent-2': 2, 'agent-3': 3 }, + templateMappingComplete: true, + requiredInputs, + templateInputs: { + issue_url: 'https://github.com/org/repo/issues/1', + review_prompt: '严格评审', + test_command: 'npm test', + max_review_rounds: '3', + base_branch: 'develop', + work_branch_name: 'custom-work', + pr_target_branch: 'release', + }, + }); + + expect(api.put).toHaveBeenCalledWith('/api/projects/8', { + goal: '实现 issue', + template_inputs: { + issue_url: 'https://github.com/org/repo/issues/1', + review_prompt: '严格评审', + test_command: 'npm test', + max_review_rounds: '3', + }, + }); + }); + it('does not apply the template when saving goal fails', async () => { const api: TemplateApplyApi = { put: vi.fn(async () => { From 9d97c554c64e1c6ed5234fd94c1beb6bb035a7f5 Mon Sep 17 00:00:00 2001 From: YXW Date: Mon, 18 May 2026 22:55:54 +0800 Subject: [PATCH 3/8] fix: preserve completed decision task state --- src/backend/services/issue_review_loop.py | 4 +++- src/backend/tests/test_issue_review_loop.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/backend/services/issue_review_loop.py b/src/backend/services/issue_review_loop.py index 05e28d5..fd968b9 100644 --- a/src/backend/services/issue_review_loop.py +++ b/src/backend/services/issue_review_loop.py @@ -303,7 +303,9 @@ def get_issue_review_flow_state(db: Session, project: Project) -> dict[str, Any] both_reviews_submitted = all(item.get("status") == "submitted" for item in reviews.values()) derived_phase = str(flow_state.get("phase") or "") - if both_reviews_submitted: + task_005_state = effective.get("TASK-005") + + if both_reviews_submitted and task_005_state not in {"completed", "approved", "abandoned"}: effective["TASK-003"] = "frozen" effective["TASK-004"] = "frozen" effective["TASK-005"] = "unlocked" diff --git a/src/backend/tests/test_issue_review_loop.py b/src/backend/tests/test_issue_review_loop.py index 7696515..6b7f144 100644 --- a/src/backend/tests/test_issue_review_loop.py +++ b/src/backend/tests/test_issue_review_loop.py @@ -174,6 +174,22 @@ def test_mismatched_review_does_not_unlock_decision_task(self): self.assertEqual(state["effective_task_states"]["TASK-003"], "needs_attention") self.assertIn("head_commit", state["errors"][0]) + def test_completed_decision_task_stays_completed_after_reviews_exist(self): + flow = self._flow_state() + flow["phase"] = "completed" + flow["task_states"]["TASK-005"] = "completed" + files = { + "outputs/proj-10/flow-state.json": json.dumps(flow), + "outputs/proj-10/TASK-003/reviews/round-001/review.json": json.dumps(self._review(True)), + "outputs/proj-10/TASK-004/reviews/round-001/review.json": json.dumps(self._review(True)), + } + + with patch("services.issue_review_loop.git_service.read_file", side_effect=lambda _project_id, path, **_kw: files.get(path)): + state = get_issue_review_flow_state(self.db, self.project) + + self.assertEqual(state["derived_phase"], "completed") + self.assertEqual(state["effective_task_states"]["TASK-005"], "completed") + def test_dispatch_uses_loop_business_state_instead_of_db_predecessors(self): flow = self._flow_state() flow["task_states"]["TASK-003"] = "frozen" From db329bbc90320c39175e512327db147949106420 Mon Sep 17 00:00:00 2001 From: YXW Date: Tue, 19 May 2026 11:06:33 +0800 Subject: [PATCH 4/8] Fix issue review loop diagram edge --- src/frontend/src/components/DagView.tsx | 41 ++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/components/DagView.tsx b/src/frontend/src/components/DagView.tsx index 4d4ecfd..ae82041 100644 --- a/src/frontend/src/components/DagView.tsx +++ b/src/frontend/src/components/DagView.tsx @@ -4,12 +4,39 @@ import ReactFlow, { Controls, Node, Edge, + Handle, Position, MarkerType, } from 'reactflow'; import 'reactflow/dist/style.css'; import { Task } from '../types'; +const EDGE_COLOR = '#94a3b8'; + +const HIDDEN_HANDLE_STYLE: React.CSSProperties = { + width: 1, + height: 1, + minWidth: 1, + minHeight: 1, + opacity: 0, + border: 0, + pointerEvents: 'none', +}; + +function TaskNode({ data }: { data: { label: React.ReactNode } }) { + return ( + <> + + + + + {data.label} + + ); +} + +const nodeTypes = { task: TaskNode }; + const STATUS_COLORS: Record = { pending_blocked: '#94a3b8', pending_ready: '#eab308', @@ -144,6 +171,7 @@ export default function DagView({ tasks, selectedTaskId, onSelectTask, missingPr return { id: String(task.id), + type: 'task', position: pos, data: { label: ( @@ -191,8 +219,10 @@ export default function DagView({ tasks, selectedTaskId, onSelectTask, missingPr id: `${depTask.id}-${task.id}`, source: String(depTask.id), target: String(task.id), - markerEnd: { type: MarkerType.ArrowClosed }, - style: { stroke: '#94a3b8' }, + sourceHandle: 'bottom-source', + targetHandle: 'top-target', + markerEnd: { type: MarkerType.ArrowClosed, color: EDGE_COLOR }, + style: { stroke: EDGE_COLOR }, }); } }); @@ -206,8 +236,10 @@ export default function DagView({ tasks, selectedTaskId, onSelectTask, missingPr id: `${decisionTask.id}-${codingTask.id}-review-loop`, source: String(decisionTask.id), target: String(codingTask.id), - markerEnd: { type: MarkerType.ArrowClosed }, - style: { stroke: '#ef4444', strokeDasharray: '6 4' }, + sourceHandle: 'top-source', + targetHandle: 'bottom-target', + markerEnd: { type: MarkerType.ArrowClosed, color: EDGE_COLOR }, + style: { stroke: EDGE_COLOR, strokeDasharray: '6 4' }, }); } } @@ -227,6 +259,7 @@ export default function DagView({ tasks, selectedTaskId, onSelectTask, missingPr Date: Wed, 20 May 2026 11:18:33 +0800 Subject: [PATCH 5/8] fix: harden issue review loop completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This update fixes several edge cases in the built-in Issue 编码与双 Agent 评审循环 workflow where task state could be derived from stale or incomplete loop artifacts. The decision task now validates TASK-005 decision.json against the current flow-state round, round_id, branch, and commit before treating the decision as submitted. This prevents old review or decision files from unlocking or completing the current round incorrectly, especially after a needs_fix transition. Polling now completes loop tasks from their real business artifacts instead of relying only on result.json: TASK-002 records branch.json, TASK-003 and TASK-004 record review.json, and TASK-005 records decision.json once the decision reaches a terminal loop phase. Running loop tasks also require redispatch instead of a duplicate dispatch, matching the frontend controls. The default max review round value is centralized across backend and frontend, template application preserves the same default, and the built-in template no longer updates updated_at/updated_by when its content is unchanged. The PRD is aligned with the current fixed-main-branch behavior, and AGENTS.md documents the repository testing rule. Tests: docker compose run --rm backend uv run --with pytest pytest tests/test_issue_review_loop.py tests/test_polling_service.py; docker build --target build -t half-frontend-test ./frontend; docker run --rm half-frontend-test npm test -- ProjectNewPage --- AGENTS.md | 10 ++ docs/prd/issue-code-review-loop.md | 30 +++-- src/backend/config.py | 2 + src/backend/main.py | 4 +- src/backend/models.py | 3 +- src/backend/routers/process_templates.py | 5 +- src/backend/routers/projects.py | 7 +- src/backend/routers/tasks.py | 8 +- src/backend/services/issue_review_loop.py | 118 +++++++++++++++--- src/backend/services/polling_service.py | 65 +++++++++- src/backend/tests/test_issue_review_loop.py | 53 +++++++- src/backend/tests/test_polling_service.py | 28 ++++- .../src/components/TaskDetailPanel.tsx | 18 +-- src/frontend/src/constants.ts | 1 + src/frontend/src/pages/PlanPage.tsx | 3 +- src/frontend/src/pages/ProjectNewPage.test.ts | 5 +- src/frontend/src/pages/ProjectNewPage.tsx | 13 +- 17 files changed, 307 insertions(+), 66 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/frontend/src/constants.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6ac29cb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +# AGENTS.md + +本文件为 Codex 在本仓库内工作的项目规则,作用域为当前目录及其所有子目录。 + +## 测试规则 + +- 需要运行测试时,不要在本地宿主环境中直接安装依赖或直接执行测试命令。 +- 测试必须在新建的测试容器中执行,避免污染本地环境。 +- 如需新增或调整测试运行方式,优先使用项目现有的 Docker / docker compose 约定。 +- 测试完成后,应清理本次创建的临时测试容器,除非用户明确要求保留。 diff --git a/docs/prd/issue-code-review-loop.md b/docs/prd/issue-code-review-loop.md index f75dbe6..3776b7e 100644 --- a/docs/prd/issue-code-review-loop.md +++ b/docs/prd/issue-code-review-loop.md @@ -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` 和轮次产物文件中。 @@ -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`。 项目必须配置: @@ -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`。 @@ -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`。 @@ -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`。 --- @@ -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. 后端能读取 `/flow-state.json` 并提供给前端。 @@ -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`。 @@ -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` 是否需要支持人工修正操作,以及该操作是否仅限管理员。 diff --git a/src/backend/config.py b/src/backend/config.py index 9f383e6..e3b1727 100644 --- a/src/backend/config.py +++ b/src/backend/config.py @@ -6,6 +6,8 @@ logger = logging.getLogger("half.config") +DEFAULT_MAX_REVIEW_ROUNDS = 3 + _DEFAULT_INSECURE_SECRETS = { "example-insecure-secret-placeholder", diff --git a/src/backend/main.py b/src/backend/main.py index 12f5760..81ef3bf 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -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 @@ -123,7 +123,7 @@ def ensure_schema_updates(): "polling_start_delay_minutes": "INTEGER", "polling_start_delay_seconds": "INTEGER", "task_timeout_minutes": "INTEGER", - "default_max_review_rounds": "INTEGER DEFAULT 3", + "default_max_review_rounds": f"INTEGER DEFAULT {DEFAULT_MAX_REVIEW_ROUNDS}", "planning_mode": "TEXT DEFAULT 'balanced'", "template_inputs_json": "TEXT DEFAULT '{}'", }, diff --git a/src/backend/models.py b/src/backend/models.py index ddc2308..5882a48 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -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 @@ -81,7 +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=3) + 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")) diff --git a/src/backend/routers/process_templates.py b/src/backend/routers/process_templates.py index f2f6bdd..70a019d 100644 --- a/src/backend/routers/process_templates.py +++ b/src/backend/routers/process_templates.py @@ -9,6 +9,7 @@ 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 @@ -509,7 +510,9 @@ def apply_template( if not isinstance(template_inputs, dict): template_inputs = {} 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 3) + template_inputs["max_review_rounds"] = str( + getattr(project, "default_max_review_rounds", None) or DEFAULT_MAX_REVIEW_ROUNDS + ) project.template_inputs_json = json.dumps(template_inputs, ensure_ascii=False) now = datetime.now(timezone.utc) diff --git a/src/backend/routers/projects.py b/src/backend/routers/projects.py index e33f99d..0c4b1d4 100644 --- a/src/backend/routers/projects.py +++ b/src/backend/routers/projects.py @@ -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 ( @@ -67,7 +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] = 3 + default_max_review_rounds: Optional[int] = DEFAULT_MAX_REVIEW_ROUNDS planning_mode: str = DEFAULT_PLANNING_MODE template_inputs: Optional[object] = None @@ -191,7 +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 3, + '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), @@ -391,7 +392,7 @@ def _validate_polling_params( def _validate_default_max_review_rounds(value: Optional[int]) -> int: if value is None: - return 3 + 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 diff --git a/src/backend/routers/tasks.py b/src/backend/routers/tasks.py index 091569d..f773e7d 100644 --- a/src/backend/routers/tasks.py +++ b/src/backend/routers/tasks.py @@ -73,9 +73,11 @@ def _load_task_project(db: Session, task: Task) -> Project: return project -def _validate_loop_dispatch_state(db: Session, task: Task, project: Project) -> bool: +def _validate_loop_dispatch_state(db: Session, task: Task, project: Project, *, action: str) -> bool: if not project_uses_issue_review_loop(db, project): return False + if action == "dispatch" and task.status == "running": + raise HTTPException(status_code=400, detail=f"Cannot dispatch running task in issue review loop: {task.task_code}") business_state = get_effective_business_state(db, project, task.task_code) if not is_business_dispatch_allowed(business_state): raise HTTPException( @@ -373,7 +375,7 @@ def dispatch_task( ): task = get_owned_task(db, task_id, user) project = _load_task_project(db, task) - is_loop_dispatch = _validate_loop_dispatch_state(db, task, project) + is_loop_dispatch = _validate_loop_dispatch_state(db, task, project, action="dispatch") if not is_loop_dispatch and task.status not in ("pending", "needs_attention"): raise HTTPException(status_code=400, detail=f"Cannot dispatch task in status: {task.status}") @@ -468,7 +470,7 @@ def redispatch_task( ): task = get_owned_task(db, task_id, user) project = _load_task_project(db, task) - is_loop_dispatch = _validate_loop_dispatch_state(db, task, project) + is_loop_dispatch = _validate_loop_dispatch_state(db, task, project, action="redispatch") if not is_loop_dispatch and task.status not in ("needs_attention", "running", "abandoned"): raise HTTPException(status_code=400, detail=f"Cannot redispatch task in status: {task.status}") diff --git a/src/backend/services/issue_review_loop.py b/src/backend/services/issue_review_loop.py index fd968b9..ff88b4c 100644 --- a/src/backend/services/issue_review_loop.py +++ b/src/backend/services/issue_review_loop.py @@ -101,28 +101,30 @@ def ensure_issue_review_loop_template(db: Session, admin) -> None: ) required_inputs_json = json.dumps(issue_review_loop_required_inputs(), ensure_ascii=False) template_json = json.dumps(issue_review_loop_template_json(), ensure_ascii=False) + desired = { + "description": "输入 issue URL 后,固定使用编码、双评审、决策 5 个 Task 完成编码评审闭环。", + "prompt_source_text": "MVP 内置模板:固定 5 个 Task,运行状态由协作仓库 flow-state.json 与当前轮次产物派生。", + "agent_count": 3, + "agent_slots_json": agent_slots_json, + "agent_roles_description_json": agent_roles_description_json, + "required_inputs_json": required_inputs_json, + "template_json": template_json, + } if existing is not None: - existing.description = "输入 issue URL 后,固定使用编码、双评审、决策 5 个 Task 完成编码评审闭环。" - existing.prompt_source_text = "MVP 内置模板:固定 5 个 Task,运行状态由协作仓库 flow-state.json 与当前轮次产物派生。" - existing.agent_count = 3 - existing.agent_slots_json = agent_slots_json - existing.agent_roles_description_json = agent_roles_description_json - existing.required_inputs_json = required_inputs_json - existing.template_json = template_json - existing.updated_by = admin.id - existing.updated_at = now - db.commit() + changed = False + for key, value in desired.items(): + if getattr(existing, key) != value: + setattr(existing, key, value) + changed = True + if changed: + existing.updated_by = admin.id + existing.updated_at = now + db.commit() return template = ProcessTemplate( name=TEMPLATE_NAME, - description="输入 issue URL 后,固定使用编码、双评审、决策 5 个 Task 完成编码评审闭环。", - prompt_source_text="MVP 内置模板:固定 5 个 Task,运行状态由协作仓库 flow-state.json 与当前轮次产物派生。", - agent_count=3, - agent_slots_json=agent_slots_json, - agent_roles_description_json=agent_roles_description_json, - required_inputs_json=required_inputs_json, - template_json=template_json, + **desired, created_by=admin.id, updated_by=admin.id, created_at=now, @@ -185,6 +187,26 @@ def _review_path(project: Project, task_code: str, current_round: int) -> str: return f"{base}/{path}" if base else path +def _decision_path(project: Project, current_round: int) -> str: + base = _collab_dir(project) + path = f"TASK-005/decisions/{_round_dir(current_round)}/decision.json" + return f"{base}/{path}" if base else path + + +def _branch_path(project: Project, current_round: int) -> str: + base = _collab_dir(project) + path = f"TASK-002/rounds/{_round_dir(current_round)}/branch.json" + return f"{base}/{path}" if base else path + + +def get_issue_review_branch_path(project: Project, current_round: int) -> str: + return _branch_path(project, current_round) + + +def get_issue_review_decision_path(project: Project, current_round: int) -> str: + return _decision_path(project, current_round) + + def _empty_response(enabled: bool) -> dict[str, Any]: return { "enabled": enabled, @@ -255,6 +277,56 @@ def _validate_review( return {"status": "submitted", "approve_merge": approve_merge, "review_path": path} +def _validate_decision( + project: Project, + flow_state: dict[str, Any], + errors: list[str], +) -> dict[str, Any]: + current_round = flow_state.get("current_round") + if not isinstance(current_round, int): + return {"status": "pending", "decision_path": None} + + path = _decision_path(project, current_round) + content = git_service.read_file( + project.id, + path, + git_repo_url=project.git_repo_url, + prefer_remote=True, + ) + if content is None: + return {"status": "pending", "decision_path": path} + + try: + decision = json.loads(content) + except json.JSONDecodeError: + errors.append(f"TASK-005 decision.json is not valid JSON: {path}") + return {"status": "needs_attention", "decision_path": path} + + if not isinstance(decision, dict): + errors.append(f"TASK-005 decision.json must be an object: {path}") + return {"status": "needs_attention", "decision_path": path} + + for key, expected in ( + ("round", flow_state.get("current_round")), + ("round_id", flow_state.get("round_id")), + ): + if decision.get(key) != expected: + errors.append(f"TASK-005 decision.json {key} does not match current flow-state: {path}") + return {"status": "needs_attention", "decision_path": path} + + for key in ("work_branch", "head_commit"): + if key in decision and decision.get(key) != flow_state.get(key): + errors.append(f"TASK-005 decision.json {key} does not match current flow-state: {path}") + return {"status": "needs_attention", "decision_path": path} + + return { + "status": "submitted", + "decision_path": path, + "approved": decision.get("approved") if type(decision.get("approved")) is bool else None, + "next_action": decision.get("next_action") if isinstance(decision.get("next_action"), str) else None, + } + + def get_issue_review_flow_state(db: Session, project: Project) -> dict[str, Any]: if not project_uses_issue_review_loop(db, project): return _empty_response(enabled=False) @@ -300,12 +372,20 @@ def get_issue_review_flow_state(db: Session, project: Project) -> dict[str, Any] "TASK-003": _validate_review(project, "TASK-003", flow_state, errors), "TASK-004": _validate_review(project, "TASK-004", flow_state, errors), } + decision = _validate_decision(project, flow_state, errors) both_reviews_submitted = all(item.get("status") == "submitted" for item in reviews.values()) derived_phase = str(flow_state.get("phase") or "") task_005_state = effective.get("TASK-005") + task_002_state = effective.get("TASK-002") + can_derive_decision_unlock = ( + both_reviews_submitted + and derived_phase in {"awaiting_review", "reviewing", "awaiting_decision"} + and task_002_state == "waiting_review" + and task_005_state not in {"completed", "approved", "abandoned", "running"} + ) - if both_reviews_submitted and task_005_state not in {"completed", "approved", "abandoned"}: + if can_derive_decision_unlock: effective["TASK-003"] = "frozen" effective["TASK-004"] = "frozen" effective["TASK-005"] = "unlocked" @@ -328,7 +408,7 @@ def get_issue_review_flow_state(db: Session, project: Project) -> dict[str, Any] "task_states": task_states, "effective_task_states": effective, "reviews": reviews, - "decision": flow_state.get("decision") if isinstance(flow_state.get("decision"), dict) else {}, + "decision": decision, "pr": flow_state.get("pr") if isinstance(flow_state.get("pr"), dict) else {}, "errors": errors, }) diff --git a/src/backend/services/polling_service.py b/src/backend/services/polling_service.py index 405b409..e5d203b 100644 --- a/src/backend/services/polling_service.py +++ b/src/backend/services/polling_service.py @@ -16,11 +16,16 @@ from services import feishu_service from services.feishu_service import NotificationEvent from validators.result_json import validate_result_json_content -from services.issue_review_loop import get_issue_review_flow_state, project_uses_issue_review_loop +from services.issue_review_loop import ( + get_issue_review_branch_path, + get_issue_review_decision_path, + get_issue_review_flow_state, + project_uses_issue_review_loop, +) logger = logging.getLogger("half.poller") -ISSUE_REVIEW_LOOP_INTERMEDIATE_TASK_CODES = {"TASK-002", "TASK-003", "TASK-004"} +ISSUE_REVIEW_LOOP_INTERMEDIATE_TASK_CODES = {"TASK-002", "TASK-003", "TASK-004", "TASK-005"} GIT_REPO_ACCESS_ERROR_MESSAGE = ( "无法访问 Git 仓库。请检查仓库是否存在、仓库地址是否正确," @@ -299,18 +304,72 @@ def _delay_satisfied(dispatched_at) -> bool: if isinstance(issue_review_flow_state, dict) else {} ) + effective_task_states = ( + issue_review_flow_state.get("effective_task_states", {}) + if isinstance(issue_review_flow_state, dict) + else {} + ) reviews = ( issue_review_flow_state.get("reviews", {}) if isinstance(issue_review_flow_state, dict) else {} ) + decision = ( + issue_review_flow_state.get("decision", {}) + if isinstance(issue_review_flow_state, dict) + else {} + ) flow_result_path = "" if task.task_code == "TASK-002" and task_states.get("TASK-002") in ("waiting_review", "approved"): - flow_result_path = result_path + current_round = ( + issue_review_flow_state.get("current_round") + if isinstance(issue_review_flow_state, dict) + else None + ) + if isinstance(current_round, int): + branch_path = get_issue_review_branch_path(project, current_round) + if git_service.read_file( + project.id, + branch_path, + git_repo_url=project.git_repo_url, + prefer_remote=True, + ) is not None: + flow_result_path = branch_path + if not flow_result_path: + base = _normalize_collab_dir(project) + flow_state_path = f"{base}/flow-state.json" if base else "flow-state.json" + if git_service.read_file( + project.id, + flow_state_path, + git_repo_url=project.git_repo_url, + prefer_remote=True, + ) is not None: + flow_result_path = flow_state_path elif task.task_code in ("TASK-003", "TASK-004"): review_state = reviews.get(task.task_code) if isinstance(review_state, dict) and review_state.get("status") == "submitted": flow_result_path = str(review_state.get("review_path") or result_path) + elif task.task_code == "TASK-005": + current_round = ( + issue_review_flow_state.get("current_round") + if isinstance(issue_review_flow_state, dict) + else None + ) + terminal_phase = ( + issue_review_flow_state.get("derived_phase") or issue_review_flow_state.get("phase") + if isinstance(issue_review_flow_state, dict) + else None + ) + if ( + isinstance(current_round, int) + and isinstance(decision, dict) + and decision.get("status") == "submitted" + and ( + terminal_phase in {"needs_fix", "approved", "completed", "needs_attention"} + or effective_task_states.get("TASK-005") in {"completed", "needs_attention"} + ) + ): + flow_result_path = str(decision.get("decision_path") or get_issue_review_decision_path(project, current_round)) if flow_result_path: _mark_task_completed( diff --git a/src/backend/tests/test_issue_review_loop.py b/src/backend/tests/test_issue_review_loop.py index 6b7f144..10e8623 100644 --- a/src/backend/tests/test_issue_review_loop.py +++ b/src/backend/tests/test_issue_review_loop.py @@ -14,7 +14,7 @@ from auth import hash_password from database import Base from models import ProcessTemplate, Project, ProjectPlan, Task, User -from routers.tasks import TaskDispatchRequest, dispatch_task +from routers.tasks import TaskDispatchRequest, dispatch_task, redispatch_task from services.issue_review_loop import ( FLOW_TYPE, TEMPLATE_NAME, @@ -133,6 +133,21 @@ def test_ensure_builtin_template_refreshes_existing_branch_inputs(self): self.assertEqual(template.agent_count, 3) self.assertEqual(template.updated_by, self.user.id) + def test_ensure_builtin_template_does_not_touch_unchanged_template(self): + ensure_issue_review_loop_template(self.db, self.user) + template = self.db.query(ProcessTemplate).filter(ProcessTemplate.name == TEMPLATE_NAME).one() + original_updated_at = template.updated_at + original_updated_by = template.updated_by + other_admin = User(id=2, username="admin2", password_hash=hash_password("Admin234")) + self.db.add(other_admin) + self.db.commit() + + ensure_issue_review_loop_template(self.db, other_admin) + self.db.refresh(template) + + self.assertEqual(template.updated_at, original_updated_at) + self.assertEqual(template.updated_by, original_updated_by) + def test_missing_flow_state_only_unlocks_task_001(self): with patch("services.issue_review_loop.git_service.read_file", return_value=None): state = get_issue_review_flow_state(self.db, self.project) @@ -190,6 +205,24 @@ def test_completed_decision_task_stays_completed_after_reviews_exist(self): self.assertEqual(state["derived_phase"], "completed") self.assertEqual(state["effective_task_states"]["TASK-005"], "completed") + def test_needs_fix_does_not_reunlock_decision_task_from_old_reviews(self): + flow = self._flow_state() + flow["phase"] = "needs_fix" + flow["task_states"]["TASK-002"] = "needs_fix" + flow["task_states"]["TASK-005"] = "frozen" + files = { + "outputs/proj-10/flow-state.json": json.dumps(flow), + "outputs/proj-10/TASK-003/reviews/round-001/review.json": json.dumps(self._review(False)), + "outputs/proj-10/TASK-004/reviews/round-001/review.json": json.dumps(self._review(True)), + } + + with patch("services.issue_review_loop.git_service.read_file", side_effect=lambda _project_id, path, **_kw: files.get(path)): + state = get_issue_review_flow_state(self.db, self.project) + + self.assertEqual(state["derived_phase"], "needs_fix") + self.assertEqual(state["effective_task_states"]["TASK-002"], "needs_fix") + self.assertEqual(state["effective_task_states"]["TASK-005"], "frozen") + def test_dispatch_uses_loop_business_state_instead_of_db_predecessors(self): flow = self._flow_state() flow["task_states"]["TASK-003"] = "frozen" @@ -208,6 +241,24 @@ def test_dispatch_uses_loop_business_state_instead_of_db_predecessors(self): self.assertEqual(updated.status, "running") + def test_running_loop_task_requires_redispatch(self): + flow = self._flow_state() + flow["task_states"]["TASK-003"] = "unlocked" + files = {"outputs/proj-10/flow-state.json": json.dumps(flow)} + task_3 = self.tasks[2] + task_3.status = "running" + self.db.commit() + + with patch("services.issue_review_loop.git_service.read_file", side_effect=lambda _project_id, path, **_kw: files.get(path)): + with self.assertRaises(Exception) as ctx: + dispatch_task(task_3.id, TaskDispatchRequest(), self.db, self.user) + self.assertIn("Cannot dispatch running task", str(ctx.exception)) + + with patch("services.issue_review_loop.git_service.read_file", side_effect=lambda _project_id, path, **_kw: files.get(path)): + updated = redispatch_task(task_3.id, TaskDispatchRequest(), self.db, self.user) + + self.assertEqual(updated.status, "running") + if __name__ == "__main__": unittest.main() diff --git a/src/backend/tests/test_polling_service.py b/src/backend/tests/test_polling_service.py index 97c09f2..7cdc18f 100644 --- a/src/backend/tests/test_polling_service.py +++ b/src/backend/tests/test_polling_service.py @@ -237,16 +237,18 @@ def test_poll_project_marks_task_completed_when_valid_result_json_exists(self): def test_poll_project_marks_loop_task_002_completed_when_awaiting_review(self): project, task = self._seed_issue_review_loop_task("TASK-002") + branch_path = "outputs/proj-71-loop/TASK-002/rounds/round-001/branch.json" files = { "outputs/proj-71-loop/TASK-002/result.json": None, "outputs/proj-71-loop/flow-state.json": json.dumps(self._loop_flow_state("waiting_review")), + branch_path: json.dumps({"work_branch": "issue-123", "head_commit": "abc123"}), } refreshed = self._poll_loop_task_with_files(project, files) self.assertEqual(refreshed.id, task.id) self.assertEqual(refreshed.status, "completed") - self.assertEqual(refreshed.result_file_path, "outputs/proj-71-loop/TASK-002/result.json") + self.assertEqual(refreshed.result_file_path, branch_path) self.assertIsNotNone(refreshed.completed_at) def test_poll_project_does_not_complete_loop_task_002_while_still_unlocked(self): @@ -278,6 +280,30 @@ def test_poll_project_marks_loop_review_task_completed_when_review_submitted(sel self.assertEqual(refreshed.result_file_path, review_path) self.assertIsNotNone(refreshed.completed_at) + def test_poll_project_marks_loop_decision_task_completed_when_decision_submitted(self): + project, task = self._seed_issue_review_loop_task("TASK-005") + flow = self._loop_flow_state("needs_fix") + flow["phase"] = "needs_fix" + flow["task_states"]["TASK-005"] = "frozen" + decision_path = "outputs/proj-71-loop/TASK-005/decisions/round-001/decision.json" + files = { + "outputs/proj-71-loop/TASK-005/result.json": None, + "outputs/proj-71-loop/flow-state.json": json.dumps(flow), + decision_path: json.dumps({ + "round": 1, + "round_id": "round-001-abc123", + "approved": False, + "next_action": "fix", + }), + } + + refreshed = self._poll_loop_task_with_files(project, files) + + self.assertEqual(refreshed.id, task.id) + self.assertEqual(refreshed.status, "completed") + self.assertEqual(refreshed.result_file_path, decision_path) + self.assertIsNotNone(refreshed.completed_at) + def test_poll_project_does_not_complete_loop_review_task_when_review_mismatches_flow_state(self): project, task = self._seed_issue_review_loop_task("TASK-003") bad_review = self._loop_review() diff --git a/src/frontend/src/components/TaskDetailPanel.tsx b/src/frontend/src/components/TaskDetailPanel.tsx index 0594fa0..30f01fb 100644 --- a/src/frontend/src/components/TaskDetailPanel.tsx +++ b/src/frontend/src/components/TaskDetailPanel.tsx @@ -44,7 +44,10 @@ export default function TaskDetailPanel({ task, agents, allTasks, flowState, onR ); const businessState = flowState?.enabled ? (flowState.effective_task_states?.[task.task_code] || 'frozen') : null; const businessDispatchable = businessState === 'unlocked' || businessState === 'needs_fix'; - const canOperate = flowState?.enabled ? businessDispatchable : blockedPredecessors.length === 0; + const loopTaskRunning = Boolean(flowState?.enabled && task.status === 'running'); + const canLoopRedispatch = Boolean(flowState?.enabled && businessDispatchable && task.status === 'running'); + const canOperate = flowState?.enabled ? (businessDispatchable && !loopTaskRunning) : blockedPredecessors.length === 0; + const canPreparePrompt = flowState?.enabled ? (businessDispatchable || canLoopRedispatch) : blockedPredecessors.length === 0; const canEdit = !flowState?.enabled && task.status === 'pending' && canOperate; useEffect(() => { @@ -109,7 +112,7 @@ export default function TaskDetailPanel({ task, agents, allTasks, flowState, onR useEffect(() => { setCachedPrompt(null); setPromptError(null); - if (!canOperate) return undefined; + if (!canPreparePrompt) return undefined; if (hasDraftChanges) return undefined; if (flowState?.enabled) { if (!businessDispatchable) return undefined; @@ -133,10 +136,11 @@ export default function TaskDetailPanel({ task, agents, allTasks, flowState, onR return () => { cancelled = true; }; - }, [task.id, task.status, task.task_name, task.description, task.expected_output_path, canOperate, hasDraftChanges, flowState?.enabled, businessDispatchable]); + }, [task.id, task.status, task.task_name, task.description, task.expected_output_path, canPreparePrompt, hasDraftChanges, flowState?.enabled, businessDispatchable]); async function performDispatch(action: 'dispatch' | 'redispatch') { - if (!canOperate) { + const actionAllowed = action === 'redispatch' && flowState?.enabled ? canLoopRedispatch : canOperate; + if (!actionAllowed) { alert(`前序任务尚未全部完成,无法派发:${blockedPredecessors.map((taskItem) => taskItem.task_code).join(', ')}`); return; } @@ -356,7 +360,7 @@ export default function TaskDetailPanel({ task, agents, allTasks, flowState, onR )}
- {((flowState?.enabled && businessDispatchable) || (!flowState?.enabled && (task.status === 'pending' || task.status === 'needs_attention'))) && ( + {((flowState?.enabled && businessDispatchable && task.status !== 'running') || (!flowState?.enabled && (task.status === 'pending' || task.status === 'needs_attention'))) && (
From 8fd844ea601cdab4b57390ffe601160bc06845cb Mon Sep 17 00:00:00 2001 From: YXW Date: Wed, 20 May 2026 15:56:38 +0800 Subject: [PATCH 6/8] Fix review loop attention state --- src/backend/services/issue_review_loop.py | 2 + src/backend/services/prompt_service.py | 2 +- src/backend/tests/test_issue_review_loop.py | 43 +++++++++++ src/backend/tests/test_prompt_service.py | 2 + .../src/components/TaskDetailPanel.tsx | 8 +++ src/frontend/src/pages/TasksPage.tsx | 5 ++ .../src/utils/issueReviewLoop.test.ts | 71 +++++++++++++++++++ src/frontend/src/utils/issueReviewLoop.ts | 14 ++++ 8 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 src/frontend/src/utils/issueReviewLoop.test.ts create mode 100644 src/frontend/src/utils/issueReviewLoop.ts diff --git a/src/backend/services/issue_review_loop.py b/src/backend/services/issue_review_loop.py index ff88b4c..3aeb390 100644 --- a/src/backend/services/issue_review_loop.py +++ b/src/backend/services/issue_review_loop.py @@ -390,6 +390,8 @@ def get_issue_review_flow_state(db: Session, project: Project) -> dict[str, Any] effective["TASK-004"] = "frozen" effective["TASK-005"] = "unlocked" derived_phase = "awaiting_decision" + elif derived_phase == "needs_attention" and decision.get("status") == "submitted": + effective["TASK-005"] = "completed" elif errors: for task_code, item in reviews.items(): if item.get("status") == "needs_attention": diff --git a/src/backend/services/prompt_service.py b/src/backend/services/prompt_service.py index c67f754..ea88343 100644 --- a/src/backend/services/prompt_service.py +++ b/src/backend/services/prompt_service.py @@ -337,7 +337,7 @@ def _issue_review_loop_task_section(project: Project, task: Task) -> str: 3. 两份 review 都必须包含布尔 `approve_merge`,并且 `round`、`round_id`、`work_branch`、`head_commit` 必须与当前 `{flow_state_path}` 一致。 4. 在 HALF 协作仓库 `main` 分支写入 `TASK-005/decisions/round-XXX/decision.json` 和 `decision.md`。 5. 若任一评审不同意合并,更新 `{flow_state_path}` 顶层 `task_states`:`TASK-002` 为 `needs_fix`,`TASK-003` / `TASK-004` / `TASK-005` 为 `frozen`,并更新 `phase`。 -6. 若达到 `max_review_rounds` 且仍未通过,写入人工处理报告并将流程标记为 `needs_attention`。 +6. 若达到 `max_review_rounds` 且仍未通过,必须写入本轮 `decision.json` / `decision.md` 人工处理报告,并将流程 `phase` 标记为 `needs_attention`;HALF 后端会据此将 `TASK-005` 派生为已完成并提示人工介入。 7. 只有两份评审都同意合并时,先在顶层 `task_states` 把 `TASK-002` 标记为 `approved`,再以 `main` 作为目标分支提交 PR,写入 `TASK-005/pr.json` / `pr.md`,最后将流程标记为 `completed` 并生成最终 `result.json`。 8. 将决策、PR 记录、`flow-state.json` 和最终 `result.json` commit 并 push 到 HALF 协作仓库 `origin/main`。""" else: diff --git a/src/backend/tests/test_issue_review_loop.py b/src/backend/tests/test_issue_review_loop.py index 10e8623..74a1e40 100644 --- a/src/backend/tests/test_issue_review_loop.py +++ b/src/backend/tests/test_issue_review_loop.py @@ -93,6 +93,16 @@ def _review(self, approve_merge: bool) -> dict: "approve_merge": approve_merge, } + def _decision(self, approved: bool = False, next_action: str = "manual_intervention") -> dict: + return { + "round": 1, + "round_id": "round-001-abc123", + "work_branch": "issue-123", + "head_commit": "abc123", + "approved": approved, + "next_action": next_action, + } + def test_builtin_required_inputs_do_not_include_branch_fields(self): keys = {item["key"] for item in issue_review_loop_required_inputs()} @@ -223,6 +233,39 @@ def test_needs_fix_does_not_reunlock_decision_task_from_old_reviews(self): self.assertEqual(state["effective_task_states"]["TASK-002"], "needs_fix") self.assertEqual(state["effective_task_states"]["TASK-005"], "frozen") + def test_needs_attention_with_submitted_decision_marks_decision_task_completed(self): + flow = self._flow_state() + flow["phase"] = "needs_attention" + flow["task_states"]["TASK-005"] = "frozen" + files = { + "outputs/proj-10/flow-state.json": json.dumps(flow), + "outputs/proj-10/TASK-003/reviews/round-001/review.json": json.dumps(self._review(False)), + "outputs/proj-10/TASK-004/reviews/round-001/review.json": json.dumps(self._review(True)), + "outputs/proj-10/TASK-005/decisions/round-001/decision.json": json.dumps(self._decision()), + } + + with patch("services.issue_review_loop.git_service.read_file", side_effect=lambda _project_id, path, **_kw: files.get(path)): + state = get_issue_review_flow_state(self.db, self.project) + + self.assertEqual(state["derived_phase"], "needs_attention") + self.assertEqual(state["decision"]["status"], "submitted") + self.assertEqual(state["effective_task_states"]["TASK-005"], "completed") + + def test_needs_attention_without_valid_decision_does_not_complete_decision_task(self): + flow = self._flow_state() + flow["phase"] = "needs_attention" + flow["task_states"]["TASK-005"] = "frozen" + files = { + "outputs/proj-10/flow-state.json": json.dumps(flow), + } + + with patch("services.issue_review_loop.git_service.read_file", side_effect=lambda _project_id, path, **_kw: files.get(path)): + state = get_issue_review_flow_state(self.db, self.project) + + self.assertEqual(state["derived_phase"], "needs_attention") + self.assertEqual(state["decision"]["status"], "pending") + self.assertEqual(state["effective_task_states"]["TASK-005"], "frozen") + def test_dispatch_uses_loop_business_state_instead_of_db_predecessors(self): flow = self._flow_state() flow["task_states"]["TASK-003"] = "frozen" diff --git a/src/backend/tests/test_prompt_service.py b/src/backend/tests/test_prompt_service.py index 4c0e0c6..37ca0a8 100644 --- a/src/backend/tests/test_prompt_service.py +++ b/src/backend/tests/test_prompt_service.py @@ -398,6 +398,8 @@ def test_issue_review_task_005_prompt_updates_top_level_task_states(self): self.assertIn("`round`、`round_id`、`work_branch`、`head_commit` 必须与当前 `outputs/proj-4/flow-state.json` 一致", prompt) self.assertIn("更新 `outputs/proj-4/flow-state.json` 顶层 `task_states`", prompt) self.assertIn("`TASK-002` 为 `needs_fix`", prompt) + self.assertIn("必须写入本轮 `decision.json` / `decision.md` 人工处理报告", prompt) + self.assertIn("HALF 后端会据此将 `TASK-005` 派生为已完成并提示人工介入", prompt) self.assertIn("在顶层 `task_states` 把 `TASK-002` 标记为 `approved`", prompt) self.assertIn("以 `main` 作为目标分支提交 PR", prompt) self.assertIn("将决策、PR 记录、`flow-state.json` 和最终 `result.json` commit 并 push 到 HALF 协作仓库 `origin/main`", prompt) diff --git a/src/frontend/src/components/TaskDetailPanel.tsx b/src/frontend/src/components/TaskDetailPanel.tsx index 30f01fb..6d93958 100644 --- a/src/frontend/src/components/TaskDetailPanel.tsx +++ b/src/frontend/src/components/TaskDetailPanel.tsx @@ -4,6 +4,7 @@ import { api } from '../api/client'; import StatusBadge from './StatusBadge'; import { copyText } from '../contracts'; import { formatDateTime } from '../utils/datetime'; +import { ISSUE_REVIEW_LOOP_ATTENTION_MESSAGE, isIssueReviewLoopAttentionTask } from '../utils/issueReviewLoop'; interface Props { task: Task; @@ -49,6 +50,7 @@ export default function TaskDetailPanel({ task, agents, allTasks, flowState, onR const canOperate = flowState?.enabled ? (businessDispatchable && !loopTaskRunning) : blockedPredecessors.length === 0; const canPreparePrompt = flowState?.enabled ? (businessDispatchable || canLoopRedispatch) : blockedPredecessors.length === 0; const canEdit = !flowState?.enabled && task.status === 'pending' && canOperate; + const showIssueReviewLoopAttention = isIssueReviewLoopAttentionTask(task, flowState); useEffect(() => { setDraftTaskName(task.task_name); @@ -234,6 +236,12 @@ export default function TaskDetailPanel({ task, agents, allTasks, flowState, onR
)} + {showIssueReviewLoopAttention && ( +
+ {ISSUE_REVIEW_LOOP_ATTENTION_MESSAGE} +
+ )} +

{assignee ? `${assignee.name} (${assignee.agent_type}${assignee.model_name ? ` / ${assignee.model_name}` : ''})` : (task.assignee_agent_id ? `Agent #${task.assignee_agent_id}` : '未指派')}

diff --git a/src/frontend/src/pages/TasksPage.tsx b/src/frontend/src/pages/TasksPage.tsx index d91c73a..1a0ee8e 100644 --- a/src/frontend/src/pages/TasksPage.tsx +++ b/src/frontend/src/pages/TasksPage.tsx @@ -5,6 +5,7 @@ import { Task, Agent, Project, FlowState } from '../types'; import DagView from '../components/DagView'; import TaskDetailPanel from '../components/TaskDetailPanel'; import { getNextStepText } from '../contracts'; +import { ISSUE_REVIEW_LOOP_ATTENTION_MESSAGE, hasIssueReviewLoopAttention } from '../utils/issueReviewLoop'; export default function TasksPage() { const { id } = useParams<{ id: string }>(); @@ -95,6 +96,7 @@ export default function TasksPage() { const selectedTask = tasks.find((t) => t.id === selectedTaskId) || null; const nextStepText = getNextStepText(project?.next_step); + const showIssueReviewLoopAttention = hasIssueReviewLoopAttention(flowState); const tasksWithAgentLabels = tasks.map((task) => { const assignee = agents.find((agent) => agent.id === task.assignee_agent_id); return { @@ -153,6 +155,9 @@ export default function TasksPage() { {flowState.errors?.length > 0 && (
{flowState.errors.join(';')}
)} + {showIssueReviewLoopAttention && ( +
{ISSUE_REVIEW_LOOP_ATTENTION_MESSAGE}
+ )}
)} diff --git a/src/frontend/src/utils/issueReviewLoop.test.ts b/src/frontend/src/utils/issueReviewLoop.test.ts new file mode 100644 index 0000000..4c2f0fd --- /dev/null +++ b/src/frontend/src/utils/issueReviewLoop.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import type { FlowState, Task } from '../types'; +import { + ISSUE_REVIEW_LOOP_ATTENTION_MESSAGE, + hasIssueReviewLoopAttention, + isIssueReviewLoopAttentionTask, +} from './issueReviewLoop'; + +function flowState(overrides: Partial = {}): FlowState { + return { + enabled: true, + exists: true, + valid: true, + flow_type: 'issue_code_review_loop', + phase: 'awaiting_review', + derived_phase: 'awaiting_review', + current_round: 3, + round_id: 'round-003', + work_branch: 'issue-123', + head_commit: 'abc123', + max_review_rounds: 3, + task_states: {}, + effective_task_states: {}, + reviews: {}, + decision: {}, + pr: {}, + errors: [], + ...overrides, + }; +} + +function task(taskCode: string): Task { + return { + id: 5, + project_id: 1, + task_code: taskCode, + task_name: taskCode, + description: '', + assignee_agent_id: null, + status: 'completed', + depends_on_json: '[]', + expected_output_path: '', + result_file_path: null, + usage_file_path: null, + last_error: null, + timeout_minutes: 10, + dispatched_at: null, + completed_at: null, + }; +} + +describe('issue review loop UI helpers', () => { + it('detects the manual intervention state from phase or derived phase', () => { + expect(hasIssueReviewLoopAttention(flowState({ derived_phase: 'needs_attention' }))).toBe(true); + expect(hasIssueReviewLoopAttention(flowState({ phase: 'needs_attention', derived_phase: 'awaiting_review' }))).toBe(true); + expect(hasIssueReviewLoopAttention(flowState({ enabled: false, derived_phase: 'needs_attention' }))).toBe(false); + expect(hasIssueReviewLoopAttention(flowState())).toBe(false); + }); + + it('shows the attention hint only for TASK-005', () => { + const attentionState = flowState({ + derived_phase: 'needs_attention', + effective_task_states: { 'TASK-005': 'completed' }, + }); + + expect(ISSUE_REVIEW_LOOP_ATTENTION_MESSAGE).toContain('需要人工介入'); + expect(isIssueReviewLoopAttentionTask(task('TASK-005'), attentionState)).toBe(true); + expect(isIssueReviewLoopAttentionTask(task('TASK-004'), attentionState)).toBe(false); + }); +}); diff --git a/src/frontend/src/utils/issueReviewLoop.ts b/src/frontend/src/utils/issueReviewLoop.ts new file mode 100644 index 0000000..3909626 --- /dev/null +++ b/src/frontend/src/utils/issueReviewLoop.ts @@ -0,0 +1,14 @@ +import type { FlowState, Task } from '../types'; + +export const ISSUE_REVIEW_LOOP_ATTENTION_MESSAGE = '评审仍有冲突,已达到最大评审轮次,需要人工介入。'; + +export function hasIssueReviewLoopAttention(flowState?: FlowState | null): boolean { + return Boolean( + flowState?.enabled + && (flowState.derived_phase === 'needs_attention' || flowState.phase === 'needs_attention') + ); +} + +export function isIssueReviewLoopAttentionTask(task: Task, flowState?: FlowState | null): boolean { + return task.task_code === 'TASK-005' && hasIssueReviewLoopAttention(flowState); +} From 77890ef2bafc22052a6a7287404fe7cefef1d077 Mon Sep 17 00:00:00 2001 From: YXW Date: Wed, 20 May 2026 15:58:54 +0800 Subject: [PATCH 7/8] Stop tracking AGENTS.md --- .gitignore | 1 + AGENTS.md | 10 ---------- 2 files changed, 1 insertion(+), 10 deletions(-) delete mode 100644 AGENTS.md diff --git a/.gitignore b/.gitignore index b2cc0b4..864d00c 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,5 @@ src/backend/half.db src/backend/repos/ # Optional local-only operator notes +/AGENTS.md /LOCAL.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 6ac29cb..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,10 +0,0 @@ -# AGENTS.md - -本文件为 Codex 在本仓库内工作的项目规则,作用域为当前目录及其所有子目录。 - -## 测试规则 - -- 需要运行测试时,不要在本地宿主环境中直接安装依赖或直接执行测试命令。 -- 测试必须在新建的测试容器中执行,避免污染本地环境。 -- 如需新增或调整测试运行方式,优先使用项目现有的 Docker / docker compose 约定。 -- 测试完成后,应清理本次创建的临时测试容器,除非用户明确要求保留。 From 2093d9f120546424d418db94bf659949a0c2ad3a Mon Sep 17 00:00:00 2001 From: YXW Date: Wed, 20 May 2026 16:16:26 +0800 Subject: [PATCH 8/8] Add default review prompt --- src/backend/routers/process_templates.py | 9 +- src/backend/services/issue_review_loop.py | 412 +++++++++++++++++- src/backend/tests/test_issue_review_loop.py | 6 + src/backend/tests/test_process_templates.py | 36 ++ src/frontend/src/pages/PlanPage.tsx | 32 +- src/frontend/src/types/index.ts | 1 + .../src/utils/applyTemplatePlan.test.ts | 21 + src/frontend/src/utils/applyTemplatePlan.ts | 4 +- 8 files changed, 509 insertions(+), 12 deletions(-) diff --git a/src/backend/routers/process_templates.py b/src/backend/routers/process_templates.py index 70a019d..5c091c6 100644 --- a/src/backend/routers/process_templates.py +++ b/src/backend/routers/process_templates.py @@ -16,7 +16,7 @@ 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 FLOW_TYPE +from services.issue_review_loop import DEFAULT_REVIEW_PROMPT, FLOW_TYPE router = APIRouter(prefix="/api/process-templates", tags=["process_templates"]) @@ -259,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 @@ -509,10 +510,16 @@ def apply_template( 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) diff --git a/src/backend/services/issue_review_loop.py b/src/backend/services/issue_review_loop.py index 3aeb390..e55e027 100644 --- a/src/backend/services/issue_review_loop.py +++ b/src/backend/services/issue_review_loop.py @@ -12,6 +12,410 @@ TEMPLATE_NAME = "Issue 编码与双 Agent 评审循环" TASK_CODES = ["TASK-001", "TASK-002", "TASK-003", "TASK-004", "TASK-005"] BUSINESS_DISPATCHABLE_STATES = {"unlocked", "needs_fix"} +DEFAULT_REVIEW_PROMPT = """# 任务 + +你是一名严格但务实的开源项目 PR reviewer。 +请评审 PR_URL 指向的 PR 是否可以接受。 + +重点判断它是否真正解决了 ISSUE_ID 对应的 issue, +并检查代码、文档、测试和仓库规范是否存在问题。 + +如果 ISSUE_ID 为空,请跳过 issue 对齐检查, +改为根据 PR 标题、描述和实际 diff 判断是否适合合并。 + +# 评审目标 + +请给出明确结论: + +- 可以接受并合并; +- 需要小修改后再合并; +- 需要较大修改,不建议当前合并; +- 不应合并。 + +不要只做泛泛评价,要指出具体问题、风险和建议修改点。 + +# 必须执行的验证步骤(在定级前) + +以下步骤是评审质量的硬性前提,不能用"通读 diff"替代。 + +**适用条件**: + +- 若 PR 涉及代码、契约、测试、运行路径、迁移、配置 —— 适用,必须按顺序执行。 +- 若 PR 是纯文档 / 纯说明性注释 / 版式调整,且不影响生成产物、示例代码、 + API 文档、配置或运行行为 —— 在评审中明确说明"不适用", + 跳过步骤 1-3,仅执行步骤 0 和步骤 4。 +- 若某一步因环境 / 权限 / 工具不可用而无法执行 —— 必须在评审报告中明确说明 + "未执行 + 原因 + 替代验证方式",不要假设"作者已确认"或"应该没问题"。 + +## 步骤 0:读项目背景,校准严重性 + +在对问题严重性定级之前,必须先读取以下信息以理解项目当前阶段: + +- 仓库根目录的 `CLAUDE.md` / `AGENTS.md` / `README.md` +- 项目状态相关的 docs(是否上线?是否有真实用户?是否有历史数据?目标规模?) +- 若 agent 有项目 memory 工具,读取相关项目 memory; + 无 memory 工具或没找到状态描述时,仅以仓库内文件为准,并在评审中说明背景信息来源。 + +**这一步直接影响严重性判断。**未上线 MVP 项目和成熟生产系统的"够用"标准不同, +不要拿生产标准评 MVP,也不要拿 MVP 标准放过生产系统的真实风险。 +具体规则见"严重性分级标准"和"不应纳入评审的事项"。 + +## 步骤 1:识别 PR 的"访问模式 / 契约 / 签名"变更 + +从 diff 中提取所有以下类型的变更: + +- 函数 / 方法的签名(参数增删、关键字参数改名、位置参数顺序变) +- 共享 helper 的语义(比如 `get_owned_X` → `get_visible_X`) +- 数据访问范式(比如 `Filter.created_by == user.id` → 新的可见性 filter) +- API 路由的请求体 / 响应字段、错误码、状态码 +- 数据库字段、约束、索引、迁移 +- 鉴权 / 授权 / 资源归属判定逻辑 +- 前后端共享类型 / OpenAPI / SDK 契约 + +对**每一项**变更,必须执行步骤 2、3。 + +## 步骤 2:搜全仓库找旧范式的所有调用方与依赖点 + +对步骤 1 中的每一项契约变更,用 `rg`(首选)或 `grep -rn` 在整个仓库 +(不只是 PR 改动的文件)搜索: + +- 旧函数名 / 旧方法名 / 旧 helper 名 +- 旧字段名 / 旧关键字参数名 +- 旧导入路径 / 旧模块路径 +- 旧路由路径 / 旧 API 端点 +- 测试 fixture、mock、stub 中的旧签名引用 +- 前后端共享类型定义、OpenAPI 描述、SDK 客户端 + +列出所有命中位置,逐一判断:是被 PR 修改了?是应该修改但被遗漏了?是应保留的特殊情况? + +**关键认知**:issue body 里的"预计影响范围"只是作者的估计,**不是清单**。 +PR 实际需要触达的文件可能比 issue 列出的多。漏改一个调用方,等价于一个潜在的功能性 broken。 + +## 步骤 3:实际运行受影响的测试 / 类型检查 / 构建 / lint + +不要只看 PR 描述里作者打勾的 testing checklist。作者宣称"测试通过"经常并不可信。 + +**先发现项目标准命令**: + +- 读 `README.md` / `CLAUDE.md` / `AGENTS.md` / `package.json` / `pyproject.toml` + / `Makefile` / `pre-commit` 配置,找出项目的标准测试 / typecheck / lint / build 命令。 +- 不要凭印象拼 `pytest` / `npm test`;仓库可能用 `uv run pytest` / `pnpm test:unit` + / `cargo test` 等。 + +**然后执行**: + +- 找出 PR 修改 / 新增的所有测试文件 +- 加上调用了被 PR 修改的函数 / 模块 / 字段 / 路由的所有现有测试文件 +- 按项目标准命令运行:测试 + 受影响范围的 typecheck + lint; + 涉及前端 / 编译型语言时,运行 build。 +- 如运行不了(环境 / 依赖原因),必须在评审报告中标注"未运行 + 原因 + 替代验证方式", + 并至少做静态对照(比对调用签名、字段引用)。 + +**特别警示**:如果 PR 改了某函数签名 / 字段,但仓库里还有用旧签名 / 旧字段引用的 +测试或代码 —— 这是 Blocker,是单凭通读 diff 最容易漏的一类问题。 + +## 步骤 4:核对 issue 验收清单的每条对应实现 + +若 ISSUE_ID 非空,把 issue 的"验收标准 / 已确认规则 / 期望行为"逐条列出, +对**每一条**到 PR diff(含未改动的相关文件)里找对应实现位置。 +若 ISSUE_ID 为空,则根据 PR 标题、描述和实际 diff 推断 PR 的验收点,并逐条核对。 + +特别注意"任务执行 / 流程模板 / 计划生成 / 项目编辑"等容易被遗忘的入口路径, +不要只看 issue 显式提到的几个 router。 + +# 背景 + +如果 ISSUE_ID 非空,本 PR 目标是解决 REPO_NAME 仓库中的 issue ISSUE_ID。 +请先阅读 issue ISSUE_ID 的描述,再对照 PR 的实际改动判断是否完整解决。 +如果 ISSUE_ID 为空,请改为根据 PR 标题、描述和 diff 判断 PR 目标。 + +重点关注: + +1. 是否真正解决了 ISSUE_ID(包括 issue 显式列出的、和隐含的所有受影响入口); +2. 是否引入不必要改动; +3. 文档修改是否必要、位置是否合理、命名是否规范; +4. 代码实现是否符合当前项目结构和风格; +5. 是否需要测试,现有测试是否足够、是否被 PR 改挂; +6. 是否存在边界情况遗漏; +7. 是否会影响现有功能或用户体验; +8. 是否适合直接合并到 main。 + +# 具体检查项 + +以下检查项是步骤 0-4 的补充,不是替代。 +凡能通过验证步骤直接覆盖的事项(测试运行结果、契约 grep、issue 验收对齐) +不要在这里重复列出,只列**只能在阅读代码 / 文档时发现**的独有点。 + +## 1. Issue 对齐与范围控制 + +- ISSUE_ID 的核心问题是什么?步骤 4 中是否每条验收标准都找到对应实现? +- 是否存在误解 issue 目标的情况? +- 是否有超出 issue 范围的额外改动?是否应拆分? + +**重要**:issue 里的"预计影响范围 / 推荐实现方向"是作者的估计性提示, +不要把它当成 PR 必须严格符合的清单 —— 真实需要修改的文件应通过步骤 2 独立确定。 + +## 2. 代码实现专项 + +通用检查: + +- 实现逻辑是否正确,边界情况是否遗漏(空输入、单元素、最大值、null); +- 错误信息是否清晰且面向调用方; +- 是否符合项目现有代码风格(**改风格前必须先 grep 仓库其它地方的写法**: + 若仓库已稳定使用 X 写法,不要单独要求 PR 改成 Y;风格统一性优先于个人偏好)。 +- 若 issue 或 PR 声称某路径应被禁止 / 拒绝 / 不可见 / 不可编辑,是否验证了旧允许路径现在失败? + +**安全 / 权限专项**(PR 改动了鉴权、授权、资源归属、可见性 filter、权限 helper 时强制): + +- 列出所有受新规则影响的入口(router / view / service / SDK),按步骤 2 grep 旧权限范式; +- 跨用户 / 跨租户 / 跨项目的可见性、可修改性是否在每个入口一致? +- 是否存在 IDOR(依赖前端隐藏而后端不校验)? +- 错误码语义是否合理(资源不存在返回 404、有权限读但无权限写返回 403)。 + +**数据库 schema / 迁移专项**(PR 改动了模型字段、约束、索引时强制): + +- 是否提供 migration 脚本?是否符合仓库迁移惯例,并有前滚 / 回滚 / 失败恢复策略? +- 新字段的默认值、nullable、唯一约束、索引是否合理? +- 现有数据兼容性如何处理(是否需要回填、是否会引发空值崩溃)? +- 数据库类型差异(SQLite vs PostgreSQL)是否考虑? + +## 3. 文档与命名专项 + +- 文档位置是否合适:产品 / 架构文档通常在 `docs/`,提案 / 草稿 / ADR 通常在 + `proposals/` 或 `adr/`,二者不要混; +- 文档命名是否符合仓库已有惯例; +- **是否出现"当前分支未找到 X"、"建议实施顺序"、"实现前状态"等过程性内容** —— + 这类内容属于 PR 评审消息或方案草稿,不应进入 main 的稳定文档; +- 是否和 README、docs、roadmap、issue 描述存在冲突; +- 中英文文档是否需要同步。 + +## 4. UI / UX 与仓库规范专项 + +UI / UX(PR 涉及前端时): + +- 错误信息、表单校验是否清楚 / 及时 / 友好; +- 多用户共享资源的操作是否提示影响范围; +- 是否影响现有操作流程或文案 / 样式一致性。 + +仓库规范: + +- 新增配置项 / 环境变量 / feature flag 时,是否同步默认值、示例 env、文档、CI / 容器 / 部署配置? +- 新增依赖是否必要?lockfile 是否同步?是否引入明显许可证、体积或供应链风险? +- `.gitignore` 中是否夹带与本 issue 无关的本地调试条目; +- 是否引入无关文件、临时文件、构建产物或调试日志; +- 是否需要更新 changelog 或相关索引文档。 + +# 严重性分级标准 + +严重性分级要严格按以下标准,**不要凭感觉打级**。 +打级前对每条问题问自己:"如果不修,会发生什么?" + +## Blocker(必须修,否则不能合并) + +- 现有测试被 PR 改挂(实际运行后报错); +- issue 验收清单的硬性要求功能性 broken; +- 引入安全漏洞、数据丢失、接口破坏; +- 引入回归(破坏现有功能)。 + +反面例子(不应作为 Blocker):性能可优化点、风格不一致、文档可改进、测试覆盖不全。 + +## Major(必须修,否则有显著质量或维护风险) + +- 测试覆盖远不足以验证 issue 关键路径(不是某一两个 case 缺,是整片路径缺); +- 文档明显不适合进入 main(含过程性自述、严重位置不当); +- 引入了与本 PR 无关的明显越界改动且范围较大; +- **当前数据规模 / 循环路径 / 用户操作已能触发明显性能退化**(页面卡死、超时、 + 显著 O(N²) 路径),不是"未来规模上来后可能慢"的推测。 + +反面例子(应判为 Minor,不是 Major):单条 case 覆盖不足、UX 微调建议、 +API 错误码精度问题、未影响验收路径和现有调用的 API 语义可改进。 + +## Minor(小问题,不阻塞合并) + +- API 错误码 / 错误信息精度问题; +- 缺少对多用户共享操作的 UI 提示; +- `.gitignore` 等夹带与 issue 无关的少量改动; +- 建议性的语义改进(比如返回 403 vs 404,前提是不影响现有调用的功能)。 + +## Nit(细节建议) + +- 命名、格式、表述细节; +- **仅当 PR 引入的写法偏离仓库已有稳定惯例时才提**; +- 不要提"和仓库已有写法冲突的标准化建议"(比如仓库已有 N 处 `X == True # noqa` + 写法时,单独要求 PR 改成 `.is_(True)`)。 + +# 不应纳入评审的事项 + +以下类别在大多数情况下**不应**出现在评审报告里。 +即便你"看到"了,也要主动过滤掉: + +## 1. PR 元数据 / 流程纪律 + +以下事项**原则上不进入代码问题清单**: + +- PR 标题格式(是否带 "Closes #X"、用词风格); +- PR body 格式(checkbox 用 `[x]` 还是 `[√]`、是否有残留占位符); +- Commit author 是否绑到 GitHub 账号; +- 分支命名(headRefName 是不是 feature 分支); +- Commit message 风格。 + +**例外**:仓库明确规定且会阻塞合并的流程要求(DCO / CLA / signoff / +release note policy / changelog 必填等),可以放到"仓库规范"里做人工确认提示, +但仍不混入代码问题清单的 Blocker / Major / Minor / Nit。 + +## 2. 过早的性能 / 并发优化建议(特别针对未上线 / MVP 项目) + +未上线 MVP / 无真实用户 / 无历史数据时: + +- **未观察到当前可复现的性能退化时**,性能优化建议(N+1、全表扫描、批量预取) + 不入问题清单,可作为脚注"未来如成长到 X 规模再处理"列出; +- 单实例 + SQLite 等串行化场景下,TOCTOU / 并发竞态建议不入问题清单; +- 不要为"未来规模可能"提前重构。 + +**反向例外**:若当前数据规模 / 循环路径 / 用户操作已能触发明显退化 +(页面卡死、超时、明显 O(N²))—— 按"严重性分级标准"中 Major 的定义实事求是定级。 + +## 3. 为不存在的场景写防御代码 + +提防御性建议("宽容解析"、"兼容其它类型"、"补 fallback")前,必须先回答: + +- 该输入 / 状态是否来自**真实系统边界**(外部 API、用户输入、第三方回调)? +- 该格式是否来自**真实历史数据**(项目已上线产生的数据)? +- 是否对应**真实存在的客户端**(已发布的 SDK / 移动端旧版本)? +- 是否被 issue 验收标准明确要求兼容? + +以上四问全为否时,不要把防御性建议放入问题清单。 +未上线 / 无历史数据 / 单一现网客户端时,绝大多数兼容性建议都属于此类。 + +**例外**:本 PR 自己引入的字段就有多种合法形态时(产品需求决定),需要正常防御。 + +## 4. 与仓库已有惯例冲突的风格 nit + +- 标 nit 之前先 grep 仓库其它地方是不是同样写法; +- 风格统一性优先于个人偏好; +- 详见"严重性分级标准 - Nit"。 + +## 5. 重复 issue 已解释清楚的设计决策 + +- 如果 issue 已经明确"采用方案 A,不要方案 B",不要在评审里要求作者改成方案 B; +- 不要质疑 issue 已经定下的产品语义。 + +# 输出格式 + +## 1. 总体结论 + +结论只能选一个: + +- 可以接受并合并; +- 需要小修改后再合并; +- 需要较大修改,不建议当前合并; +- 不应合并。 + +并用 2-4 句话说明主要理由。 + +**严重性与结论的对应关系**: + +- 任何 Blocker 存在 → 至少"需要较大修改,不建议当前合并"; +- 多个 Blocker 或核心路径 broken → "不应合并 / 暂缓合并"; +- 只有 Minor/Nit → "可以接受并合并"或"需要小修改后再合并"。 + +## 2. 是否解决 issue ISSUE_ID + +请明确说明: + +- ISSUE_ID 的核心要求 / 验收清单逐条列出; +- PR 已完成的部分(每条对应到具体文件/函数); +- PR 未完成或可疑的部分; +- 是否存在偏离 issue 的改动。 + +## 3. 主要问题清单 + +按严重程度列出,遵循"严重性分级标准"。 + +每条问题必须包含: + +- 具体定位(文件:行号 或函数名); +- 触发条件 / 影响; +- 修复建议(一句话即可)。 + +如果某类没有问题,请写"无"。 + +### Blocker + +### Major + +### Minor + +### Nit + +## 4. 文档与命名检查 + +单独说明: + +- 文档修改是否必要; +- 文档位置是否合理(产品文档 vs 提案文档区分); +- 文档命名是否规范; +- 是否含过程性自述等不应进入 main 的内容; +- 是否建议调整、合并、删除或移动文档。 + +## 5. 测试与验证建议 + +请给出: + +- **实际运行结果**:哪些测试跑了,结果如何(如未运行需说明原因); +- 已有测试是否足够; +- 建议补充哪些测试(按 issue 验收清单覆盖优先级排序); +- 建议手工验证哪些场景。 + +## 6. 建议给 PR 作者的修改意见 + +请整理成可以直接发在 PR review comment 里的文字, +语气专业、具体、可执行。优先列 Blocker 修复指引,再列 Major / Minor。 + +## 7. 最终建议 + +请给出一句话最终建议: + +> 建议合并 / 修改后合并 / 暂缓合并 / 关闭 PR。 + +# 提交评审前的自检清单 + +写完评审报告后、提交给用户前,**逐条核对**: + +1. 我是否真的执行了"必须执行的验证步骤"中适用的所有步骤?哪些没做、原因是什么? + 是否在报告中如实标注? +2. 每个 Blocker 是否有"已实际验证"的证据(测试 / typecheck / build 运行结果、 + grep 命中位置、复现步骤)?还是只是推测? +3. 报告中每一条问题,撤掉它对作者有损失吗?没损失就撤掉(不要为凑数堆 Nit)。 +4. 我是否打了与仓库已有惯例冲突的风格 nit? +5. 我对未上线 / MVP 项目报的性能 / 并发 / 防御性编码建议, + 是否对应"当前可复现的真实退化 / 真实系统边界 / 真实历史数据 / 真实客户端"? + 都不是的话应该撤掉。 +6. 我是否包含了 PR 元数据、commit author、分支命名等流程纪律事项?这些应该撤掉 + (除非是仓库明确规定的合并阻塞规则)。 +7. 我对 issue 的"预计影响范围"是当成清单了,还是当成提示了? + 是否独立 grep 了所有需修改入口(含权限 helper、字段、路由、测试 fixture)? +8. 若 PR 改动了鉴权 / 权限 / 资源归属 / 可见性 filter, + 我是否核对了所有受影响入口?是否检查了 IDOR 类问题? +9. 若 PR 声称某路径应被禁止 / 拒绝 / 不可见 / 不可编辑,我是否做了负面验证? +10. 若 PR 改动了 schema / 字段,我是否核对了 migration、默认值、前滚 / 回滚 / 失败恢复策略? +11. 若 PR 新增配置、环境变量或依赖,我是否核对了文档、示例、部署配置和 lockfile? +12. 严重性分级是否符合"严重性分级标准"?是否有把 Minor 当 Major 报、或反过来? +13. 总体结论是否与 Blocker / Major 数量一致? + +# 注意事项 + +- 不要因为 PR 解决了部分问题就轻易建议合并。 +- 不要只看 diff 表面,要按"必须执行的验证步骤"做交叉验证。 +- 不要信任作者的 testing checklist,自己跑测试 / typecheck / build。 +- 不要把 issue 的"预计影响范围"当成清单,要独立 grep。 +- 如果 PR 改了权限 / 可见性逻辑,请专门做安全 / 越权检查。 +- 如果 PR 改了 schema,请专门做 migration / 兼容性检查。 +- 如果 PR 添加了文档,要特别关注文档位置、命名、内容边界和是否适合 main。 +- 如果 PR 修改了校验逻辑,要特别关注边界输入和错误提示。 +- 如果发现 PR 做了和 ISSUE_ID 无关的大量改动,请明确指出是否应拆分。 +- 评审的目的是帮 reviewer 节省判断成本,不是堆砌看上去全面的 checklist。 + 少而准 > 多而散。""".strip() def issue_review_loop_template_json() -> dict[str, Any]: @@ -81,7 +485,13 @@ def issue_review_loop_template_json() -> dict[str, Any]: def issue_review_loop_required_inputs() -> list[dict[str, object]]: return [ {"key": "issue_url", "label": "Issue URL", "required": True, "sensitive": False}, - {"key": "review_prompt", "label": "评审提示词", "required": True, "sensitive": False}, + { + "key": "review_prompt", + "label": "评审提示词", + "required": True, + "sensitive": False, + "default_value": DEFAULT_REVIEW_PROMPT, + }, {"key": "test_command", "label": "测试命令", "required": False, "sensitive": False}, {"key": "max_review_rounds", "label": "最大评审轮次", "required": True, "sensitive": False}, ] diff --git a/src/backend/tests/test_issue_review_loop.py b/src/backend/tests/test_issue_review_loop.py index 74a1e40..1f4e773 100644 --- a/src/backend/tests/test_issue_review_loop.py +++ b/src/backend/tests/test_issue_review_loop.py @@ -16,6 +16,7 @@ from models import ProcessTemplate, Project, ProjectPlan, Task, User from routers.tasks import TaskDispatchRequest, dispatch_task, redispatch_task from services.issue_review_loop import ( + DEFAULT_REVIEW_PROMPT, FLOW_TYPE, TEMPLATE_NAME, ensure_issue_review_loop_template, @@ -110,6 +111,8 @@ def test_builtin_required_inputs_do_not_include_branch_fields(self): self.assertNotIn("work_branch_name", keys) self.assertNotIn("pr_target_branch", keys) self.assertEqual(keys, {"issue_url", "review_prompt", "test_command", "max_review_rounds"}) + review_prompt = next(item for item in issue_review_loop_required_inputs() if item["key"] == "review_prompt") + self.assertEqual(review_prompt["default_value"], DEFAULT_REVIEW_PROMPT) def test_ensure_builtin_template_refreshes_existing_branch_inputs(self): old_required_inputs = [ @@ -140,6 +143,9 @@ def test_ensure_builtin_template_refreshes_existing_branch_inputs(self): self.assertNotIn("base_branch", keys) self.assertNotIn("work_branch_name", keys) self.assertNotIn("pr_target_branch", keys) + required_inputs = json.loads(template.required_inputs_json) + review_prompt = next(item for item in required_inputs if item["key"] == "review_prompt") + self.assertEqual(review_prompt["default_value"], DEFAULT_REVIEW_PROMPT) self.assertEqual(template.agent_count, 3) self.assertEqual(template.updated_by, self.user.id) diff --git a/src/backend/tests/test_process_templates.py b/src/backend/tests/test_process_templates.py index 38f4332..4d74385 100644 --- a/src/backend/tests/test_process_templates.py +++ b/src/backend/tests/test_process_templates.py @@ -30,6 +30,7 @@ validate_template_json, ) from routers.projects import ProjectUpdate, get_project, update_project +from services.issue_review_loop import DEFAULT_REVIEW_PROMPT, FLOW_TYPE class ProcessTemplateTests(unittest.TestCase): @@ -176,6 +177,19 @@ def test_validate_required_inputs_rejects_invalid_structure(self): self.assertEqual(ctx.exception.status_code, 400) self.assertIn(expected, ctx.exception.detail) + def test_validate_required_inputs_preserves_default_value(self): + normalized = validate_required_inputs([ + { + "key": "review_prompt", + "label": "评审提示词", + "required": True, + "sensitive": False, + "default_value": "默认提示词", + } + ]) + + self.assertEqual(normalized[0]["default_value"], "默认提示词") + def test_project_update_saves_template_inputs_as_flat_strings(self): response = update_project( 20, @@ -548,6 +562,28 @@ def test_apply_template_replaces_slots_and_creates_tasks(self): self.assertEqual(tasks[0].expected_output_path, "outputs/proj-20/T1/result.json") self.assertEqual(tasks[0].timeout_minutes, 33) + def test_apply_issue_review_template_defaults_review_prompt(self): + template_json = self._template_json() + template_json["flow_type"] = FLOW_TYPE + template = create_template( + ProcessTemplateCreate(name="", description="", template_json=template_json), + self.db, + self.user, + ) + + apply_template( + template.id, + 20, + TemplateApplyRequest(slot_agent_ids={"agent-1": 10, "agent-2": 11}), + self.db, + self.user, + ) + + project = self.db.query(Project).filter(Project.id == 20).one() + template_inputs = json.loads(project.template_inputs_json) + self.assertEqual(template_inputs["review_prompt"], DEFAULT_REVIEW_PROMPT) + self.assertEqual(template_inputs["max_review_rounds"], "3") + def test_apply_template_accepts_project_bound_public_agent(self): project = self.db.query(Project).filter(Project.id == 20).one() project.agent_ids_json = json.dumps([ diff --git a/src/frontend/src/pages/PlanPage.tsx b/src/frontend/src/pages/PlanPage.tsx index d92cbaf..165f9c5 100644 --- a/src/frontend/src/pages/PlanPage.tsx +++ b/src/frontend/src/pages/PlanPage.tsx @@ -242,6 +242,11 @@ export default function PlanPage() { setSlotAgentIds(Object.fromEntries((template?.agent_slots || []).map((slot) => [slot, null]))); setTemplateInputs((current) => { const next = filterTemplateInputs(template?.required_inputs || [], current); + (template?.required_inputs || []).forEach((input) => { + if (input.default_value && !String(next[input.key] || '').trim()) { + next[input.key] = input.default_value; + } + }); if ( (template?.required_inputs || []).some((input) => input.key === 'max_review_rounds') && !String(next.max_review_rounds || '').trim() @@ -658,7 +663,7 @@ export default function PlanPage() {
{(selectedTemplate.required_inputs || []).map((input) => { - const value = templateInputs[input.key] || ''; + const value = templateInputs[input.key] ?? input.default_value ?? ''; const missing = input.required && !value.trim(); return (
@@ -666,13 +671,24 @@ export default function PlanPage() { - updateTemplateInput(input.key, event.target.value)} - placeholder={input.label} - /> + {input.key === 'review_prompt' ? ( +