Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e826515cc | ||
|
|
5fa592347c | ||
|
|
a334083760 | ||
|
|
e551e3baf1 | ||
|
|
a28711cccf |
No files matched your search
@@ -9,7 +9,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import atexit
|
import atexit
|
||||||
import glob
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -98,6 +97,7 @@ class LoopRunner:
|
|||||||
self.creator: str = ""
|
self.creator: str = ""
|
||||||
self.task: str = ""
|
self.task: str = ""
|
||||||
self.plan_mode: bool = False
|
self.plan_mode: bool = False
|
||||||
|
self.plan_file: Optional[str] = None
|
||||||
self.planner_override: Optional[str] = None
|
self.planner_override: Optional[str] = None
|
||||||
self.plan_talk_turns: int = 1
|
self.plan_talk_turns: int = 1
|
||||||
self.reviewer_list: Optional[str] = None
|
self.reviewer_list: Optional[str] = None
|
||||||
@@ -116,6 +116,7 @@ class LoopRunner:
|
|||||||
" --plan Enable Planner agent intervention & design phase",
|
" --plan Enable Planner agent intervention & design phase",
|
||||||
" --planner <name> Planner session (requires --plan; default: auto-resolve)",
|
" --planner <name> Planner session (requires --plan; default: auto-resolve)",
|
||||||
" --plan-talk N Planner-Creator discussion limit turns (default: 1)",
|
" --plan-talk N Planner-Creator discussion limit turns (default: 1)",
|
||||||
|
" --plan-file <path> Reuse an existing plan file, relative to the control root (not with --plan)",
|
||||||
" --reviewer \"A,B\" Targeted reviewer session name list (comma-separated)",
|
" --reviewer \"A,B\" Targeted reviewer session name list (comma-separated)",
|
||||||
" --all-reviewer Enforce PASS verdict from all active reviewer sessions",
|
" --all-reviewer Enforce PASS verdict from all active reviewer sessions",
|
||||||
" --max-loop N Max execution-review corrective loop runs (default: 3)",
|
" --max-loop N Max execution-review corrective loop runs (default: 3)",
|
||||||
@@ -162,6 +163,12 @@ class LoopRunner:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
self.planner_override = args[i + 1]
|
self.planner_override = args[i + 1]
|
||||||
i += 2
|
i += 2
|
||||||
|
elif arg == "--plan-file":
|
||||||
|
if i + 1 >= len(args):
|
||||||
|
self.print_usage()
|
||||||
|
sys.exit(1)
|
||||||
|
self.plan_file = args[i + 1]
|
||||||
|
i += 2
|
||||||
elif arg == "--plan-talk":
|
elif arg == "--plan-talk":
|
||||||
if i + 1 >= len(args) or not re.match(r"^[0-9]+$", args[i + 1]):
|
if i + 1 >= len(args) or not re.match(r"^[0-9]+$", args[i + 1]):
|
||||||
print("ERROR: --plan-talk requires a positive integer.", file=sys.stderr)
|
print("ERROR: --plan-talk requires a positive integer.", file=sys.stderr)
|
||||||
@@ -240,6 +247,33 @@ class LoopRunner:
|
|||||||
print(f' run_loop.sh --creator <creator> --plan --planner <planner> --task "..."', file=sys.stderr)
|
print(f' run_loop.sh --creator <creator> --plan --planner <planner> --task "..."', file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Issue #25: a promoted plan is reused only when it is named (D2). The
|
||||||
|
# file is read here, before a single job exists, so a bad path cannot
|
||||||
|
# surface halfway through a run (D3).
|
||||||
|
if self.plan_file is not None:
|
||||||
|
if self.plan_mode:
|
||||||
|
print("ERROR: --plan-file cannot be combined with --plan.", file=sys.stderr)
|
||||||
|
print("Use --plan to have the Planner write a new plan, or --plan-file to reuse one:", file=sys.stderr)
|
||||||
|
print(' run_loop.sh --creator <creator> --plan-file <path> --task "..."', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
# A relative path is taken against the control root, as the usage
|
||||||
|
# line says and as jobs_dir already does (Issue #21 R2): the runner
|
||||||
|
# never chdirs to workspace_root, so resolving against the caller's
|
||||||
|
# cwd would look somewhere else entirely - and could find a
|
||||||
|
# same-named file there. `/` keeps an absolute path as it is.
|
||||||
|
resolved = (self.workspace_root / Path(self.plan_file).expanduser()).resolve()
|
||||||
|
text = self._plan_text(resolved)
|
||||||
|
if text is None:
|
||||||
|
log_error(f"--plan-file {resolved}: not a readable file with plan text in it.")
|
||||||
|
sys.exit(1)
|
||||||
|
self.plan_file = str(resolved)
|
||||||
|
if self.review_only:
|
||||||
|
# D6: --review-only delegates no implementation job, so there is
|
||||||
|
# nothing for a plan to steer.
|
||||||
|
log_warn(f"--plan-file is ignored with --review-only: {resolved}")
|
||||||
|
else:
|
||||||
|
self.current_plan = text
|
||||||
|
|
||||||
if self.all_reviewers and self.reviewer_list:
|
if self.all_reviewers and self.reviewer_list:
|
||||||
log_warn(f"--all-reviewer takes precedence; ignoring --reviewer list ('{self.reviewer_list}').")
|
log_warn(f"--all-reviewer takes precedence; ignoring --reviewer list ('{self.reviewer_list}').")
|
||||||
|
|
||||||
@@ -266,6 +300,21 @@ class LoopRunner:
|
|||||||
if not self._context_active() and any(a in self.raw_argv for a in ("--review-isolation", "--remote-review")):
|
if not self._context_active() and any(a in self.raw_argv for a in ("--review-isolation", "--remote-review")):
|
||||||
log_warn("--review-isolation/--remote-review only apply with --worktree or --base; ignored.")
|
log_warn("--review-isolation/--remote-review only apply with --worktree or --base; ignored.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _plan_text(path: Path) -> Optional[str]:
|
||||||
|
"""Issue #25 (D3): the one check every plan load shares - a plan is a
|
||||||
|
readable regular file that has text in it. None means 'no plan here';
|
||||||
|
each caller decides what that means (D13 stops the run, D14 keeps the
|
||||||
|
plan it already has). A file that cannot be read is not told apart from
|
||||||
|
an empty one: either way the loop would carry no plan text."""
|
||||||
|
try:
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
text = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return text if text.strip() else None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
||||||
return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True)
|
return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True)
|
||||||
@@ -973,7 +1022,13 @@ class LoopRunner:
|
|||||||
if not p_file or not p_file.exists():
|
if not p_file or not p_file.exists():
|
||||||
log_error("Final plan file not found.")
|
log_error("Final plan file not found.")
|
||||||
return 1
|
return 1
|
||||||
self.current_plan = p_file.read_text(encoding="utf-8", errors="replace")
|
plan_text = self._plan_text(p_file)
|
||||||
|
if plan_text is None:
|
||||||
|
# D13: the run paid for a planning phase and got nothing back.
|
||||||
|
# Falling through would silently demote it to self-planning.
|
||||||
|
log_error(f"the planner's report has no plan text: {p_file}")
|
||||||
|
return 1
|
||||||
|
self.current_plan = plan_text
|
||||||
|
|
||||||
# Interactive turns
|
# Interactive turns
|
||||||
creator_type = self.resolve_agent_type(self.creator, state)
|
creator_type = self.resolve_agent_type(self.creator, state)
|
||||||
@@ -996,17 +1051,26 @@ class LoopRunner:
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
ref_file = self.find_report(ref_jid)
|
ref_file = self.find_report(ref_jid)
|
||||||
if ref_file and ref_file.exists():
|
refined = self._plan_text(ref_file) if ref_file else None
|
||||||
self.current_plan = ref_file.read_text(encoding="utf-8", errors="replace")
|
if refined is None:
|
||||||
|
# D14: overwriting with an empty report loses a plan that is
|
||||||
|
# still good, and Phase 2 would quietly switch prompts.
|
||||||
|
log_warn(f"the refine report has no plan text (job {ref_jid}); keeping the previous plan.")
|
||||||
|
else:
|
||||||
|
self.current_plan = refined
|
||||||
|
|
||||||
log_success("Interactive planning completed. Plan finalized.")
|
log_success("Interactive planning completed. Plan finalized.")
|
||||||
else:
|
else:
|
||||||
log_info("=== Phase 1: Self-Planning Mode (Direct Execution) ===")
|
log_info("=== Phase 1: Self-Planning Mode (Direct Execution) ===")
|
||||||
if planner_session:
|
# Issue #25 (D1): no plan is loaded unless one was named. This used
|
||||||
plan_files = sorted(glob.glob(str(self.workspace_root / ".agents" / "reports" / planner_session / "plan-*.md")), key=os.path.getmtime, reverse=True)
|
# to take the newest promoted plan in the planner's report dir,
|
||||||
if plan_files:
|
# keyed on mtime (checkout order) and on a planner session that says
|
||||||
log_info(f"Found existing promoted plan at '{plan_files[0]}'. Loading plan...")
|
# nothing about this task - so it injected other issues' plans.
|
||||||
self.current_plan = Path(plan_files[0]).read_text(encoding="utf-8", errors="replace")
|
if self.current_plan:
|
||||||
|
mtime = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(os.path.getmtime(self.plan_file)))
|
||||||
|
log_info(f"Using the plan given with --plan-file: {self.plan_file} (modified {mtime} UTC)")
|
||||||
|
else:
|
||||||
|
log_info("No plan loaded; the Creator plans by itself (reuse one with --plan-file <path>).")
|
||||||
|
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
# PHASE 2: IMPLEMENTATION (CREATION)
|
# PHASE 2: IMPLEMENTATION (CREATION)
|
||||||
@@ -1217,8 +1281,14 @@ class LoopRunner:
|
|||||||
log_error("Failed to register plan refinement job.")
|
log_error("Failed to register plan refinement job.")
|
||||||
return 1
|
return 1
|
||||||
ref_file = self.find_report(ref_jid)
|
ref_file = self.find_report(ref_jid)
|
||||||
if ref_file and ref_file.exists():
|
refined = self._plan_text(ref_file) if ref_file else None
|
||||||
self.current_plan = ref_file.read_text(encoding="utf-8", errors="replace")
|
if refined is None:
|
||||||
|
log_warn(f"the refine report has no plan text (job {ref_jid}); keeping the previous plan.")
|
||||||
|
else:
|
||||||
|
self.current_plan = refined
|
||||||
|
# D13 stops a plan-mode run whose initial plan is empty and D14
|
||||||
|
# never overwrites with an empty one, so current_plan is not
|
||||||
|
# empty here: this prompt cannot claim a plan it does not carry.
|
||||||
correction_prompt = f"갱신된 다음 계획서에 입각하여 지적된 오류들을 수정하고 코드를 다시 구현해주세요. 계획서:\n{self.current_plan}\n피드백 상세:\n{feedback_aggregate}"
|
correction_prompt = f"갱신된 다음 계획서에 입각하여 지적된 오류들을 수정하고 코드를 다시 구현해주세요. 계획서:\n{self.current_plan}\n피드백 상세:\n{feedback_aggregate}"
|
||||||
else:
|
else:
|
||||||
log_info("Applying straight bugfixes based on reviewer feedback...")
|
log_info("Applying straight bugfixes based on reviewer feedback...")
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ metadata:
|
|||||||
|
|
||||||
Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. It supports:
|
Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. It supports:
|
||||||
- **Collaborative Planning** (`--plan`, optional `--planner <session>`, and `--plan-talk N`): Planner designs the solution, Creator challenges the plan for N turns to resolve edge cases, then implementation starts. `--planner` selects the planner session; omit it to auto-resolve the first running planner.
|
- **Collaborative Planning** (`--plan`, optional `--planner <session>`, and `--plan-talk N`): Planner designs the solution, Creator challenges the plan for N turns to resolve edge cases, then implementation starts. `--planner` selects the planner session; omit it to auto-resolve the first running planner.
|
||||||
- **Creator Self-Planning & Development** (default without `--plan`): Planner 에이전트에게 계획 작성을 위임하지 않고, 기존에 승격된 계획서가 있다면 이를 로드하여 코드를 구현하며, 계획서가 존재하지 않는 경우 작업자(Creator: developer/writer)가 스스로 구현 계획 및 설계 수립을 포함한 개발 전 과정을 직접 진행합니다.
|
- **Creator Self-Planning & Development** (default without `--plan`): Planner 에이전트에게 계획 작성을 위임하지 않고, 작업자(Creator: developer/writer)가 스스로 구현 계획 및 설계 수립을 포함한 개발 전 과정을 직접 진행합니다. **어떤 계획서도 자동으로 로드되지 않습니다.**
|
||||||
|
- **Explicit Plan Reuse** (`--plan-file <path>`): 기존 계획서를 재사용하는 유일한 경로입니다. 경로는 제어 루트 기준이며, 읽을 수 없거나 내용이 빈 파일이면 잡을 하나도 만들기 전에 종료합니다(exit 1). 로드된 계획서는 **최초 구현 프롬프트 1회**에만 실립니다 — 리뷰 이후의 수정 라운드에는 리뷰 피드백만 전달됩니다. `--plan` 과는 함께 쓸 수 없습니다.
|
||||||
- **Targeted Peer-Review** (`--reviewer`): Runs custom-selected reviewer agents to verify code changes.
|
- **Targeted Peer-Review** (`--reviewer`): Runs custom-selected reviewer agents to verify code changes.
|
||||||
- **Total Peer-Review** (`--all-reviewer`): Enforces a unanimous PASS verdict from all registered reviewer sessions.
|
- **Total Peer-Review** (`--all-reviewer`): Enforces a unanimous PASS verdict from all registered reviewer sessions.
|
||||||
- **Rebuttal & Adjudication Protocol** (`--max-rebut N`): Allows Creator to reject invalid reviewer feedback using `[REBUT:]` tags and triggers Reviewer re-adjudication (`[ADJUDICATION:]`) or Planner arbitration (`[ARBITRATION:]`).
|
- **Rebuttal & Adjudication Protocol** (`--max-rebut N`): Allows Creator to reject invalid reviewer feedback using `[REBUT:]` tags and triggers Reviewer re-adjudication (`[ADJUDICATION:]`) or Planner arbitration (`[ARBITRATION:]`).
|
||||||
@@ -135,10 +136,10 @@ sequenceDiagram
|
|||||||
Dev->>Plan: send critiques (Discussion)
|
Dev->>Plan: send critiques (Discussion)
|
||||||
Plan-->>Dev: update plan & reach consensus
|
Plan-->>Dev: update plan & reach consensus
|
||||||
end
|
end
|
||||||
else Use Existing Plan or Creator Self-Plan (No --plan)
|
else Creator Self-Plan (No --plan)
|
||||||
alt Existing Plan Found
|
alt Plan named with --plan-file
|
||||||
Loop->>Dev: notify task execution using existing plan
|
Loop->>Dev: notify task execution using the named plan
|
||||||
else No Plan Found
|
else No --plan-file
|
||||||
Loop->>Dev: request Creator self-planning and code execution
|
Loop->>Dev: request Creator self-planning and code execution
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -172,7 +173,7 @@ sequenceDiagram
|
|||||||
|
|
||||||
1. **Planning Phase**:
|
1. **Planning Phase**:
|
||||||
- **Collaborative Planning (`--plan`)**: Planner가 프로젝트 구조를 파악하고 `implementation_plan.md`/`task.md`로 로드맵을 제공하며, Creator와의 피드백 루프를 통해 정제됩니다.
|
- **Collaborative Planning (`--plan`)**: Planner가 프로젝트 구조를 파악하고 `implementation_plan.md`/`task.md`로 로드맵을 제공하며, Creator와의 피드백 루프를 통해 정제됩니다.
|
||||||
- **Creator Self-Planning (No `--plan`)**: Planner의 개입 없이, 기존 계획서가 있다면 이를 기반으로 하고, 그렇지 않다면 Creator가 독자적으로 설계 및 태스크 단위를 구상한 후 구현에 착수합니다.
|
- **Creator Self-Planning (No `--plan`)**: Planner의 개입 없이 Creator가 독자적으로 설계 및 태스크 단위를 구상한 후 구현에 착수합니다. `--plan-file <path>` 로 계획서를 명시한 경우에만 그 계획서를 기반으로 합니다.
|
||||||
2. **Execution Phase**: Creator가 배정된 태스크의 코드를 수정합니다. `--plan` 모드 진행 중 예상치 못한 설계 변경 필요성이 감지되면 작업을 멈추고 Planner에게 계획 수정을 먼저 위임합니다. (Creator 자율 계획 모드에서는 Creator가 직접 설계를 변경하며 진행합니다.) 구현 완료 후 DoD(타입 매핑, 공유 자원 사이드 이펙트 방지, 문서-코드 정합성)를 자체 검증한 뒤 단일 커밋을 작성합니다.
|
2. **Execution Phase**: Creator가 배정된 태스크의 코드를 수정합니다. `--plan` 모드 진행 중 예상치 못한 설계 변경 필요성이 감지되면 작업을 멈추고 Planner에게 계획 수정을 먼저 위임합니다. (Creator 자율 계획 모드에서는 Creator가 직접 설계를 변경하며 진행합니다.) 구현 완료 후 DoD(타입 매핑, 공유 자원 사이드 이펙트 방지, 문서-코드 정합성)를 자체 검증한 뒤 단일 커밋을 작성합니다.
|
||||||
3. **Review Phase**: Creator가 리뷰어 세션에 작업 완료 사실과 변경 범위(`git diff`)를 전달합니다. 리뷰어는 검증 후 리포트 **마지막에 단독 행**으로 판정을 남깁니다:
|
3. **Review Phase**: Creator가 리뷰어 세션에 작업 완료 사실과 변경 범위(`git diff`)를 전달합니다. 리뷰어는 검증 후 리포트 **마지막에 단독 행**으로 판정을 남깁니다:
|
||||||
- **반려 (`[VERDICT: NOT PASS]`)** → 피드백 요약을 Planner에게 전송하여 상위 레벨 계획(Rev.n)을 개시합니다.
|
- **반려 (`[VERDICT: NOT PASS]`)** → 피드백 요약을 Planner에게 전송하여 상위 레벨 계획(Rev.n)을 개시합니다.
|
||||||
@@ -180,6 +181,14 @@ sequenceDiagram
|
|||||||
4. 지정되거나 자동 수집된 리뷰어 전원이 PASS를 발행해야 완결되며, `--max-loop N`회 내에 도달하지 못하면 안전을 위해 루프를 중단합니다.
|
4. 지정되거나 자동 수집된 리뷰어 전원이 PASS를 발행해야 완결되며, `--max-loop N`회 내에 도달하지 못하면 안전을 위해 루프를 중단합니다.
|
||||||
5. 완결 후에도 에이전트 세션은 종료하지 않고, 다음 태스크 지시가 있을 때까지 프롬프트 대기 상태(Standby)로 유지됩니다.
|
5. 완결 후에도 에이전트 세션은 종료하지 않고, 다음 태스크 지시가 있을 때까지 프롬프트 대기 상태(Standby)로 유지됩니다.
|
||||||
|
|
||||||
|
### 계획서가 비어 있을 때 (Empty plan reports)
|
||||||
|
|
||||||
|
계획서를 읽는 네 지점(`--plan-file`, `--plan` 초기 계획서, 논의 턴 refine, 리뷰 에스컬레이션 refine)은 같은 판정을 씁니다: **읽을 수 있는 일반 파일이고 공백을 제외한 내용이 있어야** 계획서입니다. 리포트 파일이 존재하기만 하면 통과시키던 과거 동작에서는 빈 리포트가 계획서를 조용히 지웠고, 프롬프트가 자기계획 문구로 바뀐 채 루프가 정상 종료(rc 0)했습니다.
|
||||||
|
|
||||||
|
- **`--plan-file` 이 비어 있으면**: 잡을 하나도 만들기 전에 `exit 1`.
|
||||||
|
- **`--plan` 의 초기 계획서가 비어 있으면**: `exit 1`. 계획 단계를 지불하고 아무것도 받지 못한 실행이 자기계획으로 격하되지 않습니다.
|
||||||
|
- **refine 결과가 비어 있으면**(논의 턴·리뷰 에스컬레이션 모두): 경고를 남기고 **이전 계획서를 유지**합니다. 덮어쓰지 않으므로, "갱신된 다음 계획서에 입각하여…" 로 시작하는 수정 프롬프트가 빈 계획서를 싣는 일이 없습니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## CLI Option ↔ Workflow Phase Mapping
|
## CLI Option ↔ Workflow Phase Mapping
|
||||||
@@ -188,7 +197,8 @@ sequenceDiagram
|
|||||||
|
|
||||||
| 워크플로우 단계 | 해당 CLI 옵션 | 설명 |
|
| 워크플로우 단계 | 해당 CLI 옵션 | 설명 |
|
||||||
| :--- | :--- | :--- |
|
| :--- | :--- | :--- |
|
||||||
| **Phase 1: Planning** | `--plan` | Planner 에이전트를 기동하여 최초 계획 작성을 강제합니다. (옵션을 지정하지 않을 경우 새 계획서 작성을 생략하며, 기존 계획서가 있는 경우 이를 로드하고, 없는 경우 Creator가 직접 계획 및 설계를 수립하여 즉시 구현에 착수합니다.) |
|
| **Phase 1: Planning** | `--plan` | Planner 에이전트를 기동하여 최초 계획 작성을 강제합니다. (옵션을 지정하지 않을 경우 새 계획서 작성을 생략하고, Creator가 직접 계획 및 설계를 수립하여 즉시 구현에 착수합니다.) |
|
||||||
|
| **Phase 1: Plan reuse** | `--plan-file <path>` | 기존 계획서를 재사용합니다. 제어 루트 기준 경로이며 **`--plan` 과 상호 배타**입니다. 지정하지 않으면 어떤 계획서도 로드되지 않습니다. |
|
||||||
| **Phase 1: Planner session** | `--planner <name>` | 계획 단계를 수행할 세션을 명시합니다. **`--plan`과 함께만** 사용합니다. 생략 시 running planner를 자동 탐색합니다. |
|
| **Phase 1: Planner session** | `--planner <name>` | 계획 단계를 수행할 세션을 명시합니다. **`--plan`과 함께만** 사용합니다. 생략 시 running planner를 자동 탐색합니다. |
|
||||||
| **Phase 1: Debate** | `--plan-talk N` | Planner와 Creator가 상호 대화식 챌린지 루프를 `N`회 돌며 계획을 교차 정제합니다. |
|
| **Phase 1: Debate** | `--plan-talk N` | Planner와 Creator가 상호 대화식 챌린지 루프를 `N`회 돌며 계획을 교차 정제합니다. |
|
||||||
| **Phase 2: Execution** | `--creator <name>` | 코드를 구현할 Creator 세션에 코딩 태스크를 주입합니다. (필수) |
|
| **Phase 2: Execution** | `--creator <name>` | 코드를 구현할 Creator 세션에 코딩 태스크를 주입합니다. (필수) |
|
||||||
@@ -222,7 +232,7 @@ bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
|
|||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Creator Self-Planning & Development + Self-review (direct task execution using existing promoted plan or Creator's own self-plan)
|
# 1. Creator Self-Planning & Development + Self-review (no plan is loaded; add --plan-file <path> to reuse one)
|
||||||
bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
|
bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
|
||||||
--creator "<creator-session-name>" \
|
--creator "<creator-session-name>" \
|
||||||
--task "Fix typo in deploy/README.md"
|
--task "Fix typo in deploy/README.md"
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ TARGET_AGENT=""
|
|||||||
TASK=""
|
TASK=""
|
||||||
REVIEWER_LIST=""
|
REVIEWER_LIST=""
|
||||||
PLANNER_SESSION_OVERRIDE=""
|
PLANNER_SESSION_OVERRIDE=""
|
||||||
|
LOOP_PLAN_FILE=""
|
||||||
LOOP_WORKTREE=""
|
LOOP_WORKTREE=""
|
||||||
LOOP_BASE=""
|
LOOP_BASE=""
|
||||||
LOOP_REVIEW_ONLY=0
|
LOOP_REVIEW_ONLY=0
|
||||||
@@ -46,6 +47,7 @@ usage() {
|
|||||||
echo " --plan Enable Planner agent intervention & design phase"
|
echo " --plan Enable Planner agent intervention & design phase"
|
||||||
echo " --planner <name> Planner session (requires --plan; default: auto-resolve)"
|
echo " --planner <name> Planner session (requires --plan; default: auto-resolve)"
|
||||||
echo " --plan-talk N Planner-Creator discussion limit turns (default: 1)"
|
echo " --plan-talk N Planner-Creator discussion limit turns (default: 1)"
|
||||||
|
echo " --plan-file <path> Reuse an existing plan file, relative to the control root (not with --plan)"
|
||||||
echo " --reviewer \"A,B\" Targeted reviewer session name list (comma-separated)"
|
echo " --reviewer \"A,B\" Targeted reviewer session name list (comma-separated)"
|
||||||
echo " --all-reviewer Enforce PASS verdict from all active reviewer sessions"
|
echo " --all-reviewer Enforce PASS verdict from all active reviewer sessions"
|
||||||
echo " --max-loop N Max execution-review corrective loop runs (default: 3)"
|
echo " --max-loop N Max execution-review corrective loop runs (default: 3)"
|
||||||
@@ -92,6 +94,7 @@ while [[ "$#" -gt 0 ]]; do
|
|||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
--planner) PLANNER_SESSION_OVERRIDE="$2"; shift 2 ;;
|
--planner) PLANNER_SESSION_OVERRIDE="$2"; shift 2 ;;
|
||||||
|
--plan-file) LOOP_PLAN_FILE="$2"; shift 2 ;;
|
||||||
--task) TASK="$2"; shift 2 ;;
|
--task) TASK="$2"; shift 2 ;;
|
||||||
--worktree) LOOP_WORKTREE="$2"; shift 2 ;;
|
--worktree) LOOP_WORKTREE="$2"; shift 2 ;;
|
||||||
--base) LOOP_BASE="$2"; shift 2 ;;
|
--base) LOOP_BASE="$2"; shift 2 ;;
|
||||||
@@ -121,11 +124,14 @@ fi
|
|||||||
|
|
||||||
# Issue #21 (D12): the legacy shell loop assumes a cwd-relative .mam/jobs and
|
# Issue #21 (D12): the legacy shell loop assumes a cwd-relative .mam/jobs and
|
||||||
# diffs the checkout it runs in; the worktree/base options are Python-only.
|
# diffs the checkout it runs in; the worktree/base options are Python-only.
|
||||||
|
# Issue #25 (D9): --plan-file is too - its fail-closed check lives in the
|
||||||
|
# runner and is not duplicated in a path that is on its way out.
|
||||||
# Checked before the freeze and the loop lock (log_* are not defined yet).
|
# Checked before the freeze and the loop lock (log_* are not defined yet).
|
||||||
if [ "${MAM_LOOP_PY:-1}" = "0" ] && { [ -n "$LOOP_WORKTREE" ] || [ -n "$LOOP_BASE" ] \
|
if [ "${MAM_LOOP_PY:-1}" = "0" ] && { [ -n "$LOOP_WORKTREE" ] || [ -n "$LOOP_BASE" ] \
|
||||||
|| [ "$LOOP_REVIEW_ONLY" = "1" ] || [ -n "${MAM_LOOP_WORKTREE:-}" ] \
|
|| [ "$LOOP_REVIEW_ONLY" = "1" ] || [ -n "${MAM_LOOP_WORKTREE:-}" ] \
|
||||||
|| [ -n "$LOOP_REVIEW_ISOLATION" ] || [ -n "$LOOP_REMOTE_REVIEW" ]; }; then
|
|| [ -n "$LOOP_REVIEW_ISOLATION" ] || [ -n "$LOOP_REMOTE_REVIEW" ] \
|
||||||
echo "ERROR: --worktree/--base/--review-only/--review-isolation/--remote-review require the Python loop (MAM_LOOP_PY=1)" >&2
|
|| [ -n "$LOOP_PLAN_FILE" ]; }; then
|
||||||
|
echo "ERROR: --worktree/--base/--review-only/--review-isolation/--remote-review/--plan-file require the Python loop (MAM_LOOP_PY=1)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -560,16 +566,11 @@ if [ "$PLAN_MODE" = true ]; then
|
|||||||
log_success "Interactive planning completed. Plan finalized."
|
log_success "Interactive planning completed. Plan finalized."
|
||||||
else
|
else
|
||||||
log_info "=== Phase 1: Self-Planning Mode (Direct Execution) ==="
|
log_info "=== Phase 1: Self-Planning Mode (Direct Execution) ==="
|
||||||
EXISTING_PLAN_FILE=""
|
# Issue #25 (D1/D9): no plan is loaded here. This used to take the newest
|
||||||
if [ -n "$PLANNER_SESSION" ]; then
|
# promoted plan of the auto-resolved planner, keyed on mtime and on a session
|
||||||
# Promoted plans are saved as plan-<job-id>.md by the Phase 3 promotion
|
# that says nothing about this task. Reuse is explicit and Python-only now
|
||||||
# step below, not report-final.md; pick the most recently promoted one.
|
# (--plan-file), and this path rejects that option above.
|
||||||
EXISTING_PLAN_FILE=$(ls -t ".agents/reports/$PLANNER_SESSION"/plan-*.md 2>/dev/null | head -n 1 || true)
|
log_info "No plan loaded; the Creator plans by itself."
|
||||||
fi
|
|
||||||
if [ -n "$EXISTING_PLAN_FILE" ] && [ -f "$EXISTING_PLAN_FILE" ]; then
|
|
||||||
log_info "Found existing promoted plan at '$EXISTING_PLAN_FILE'. Loading plan..."
|
|
||||||
CURRENT_PLAN=$(cat "$EXISTING_PLAN_FILE")
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
"""Issue #25: a promoted plan is reused only when it is named, and an empty
|
||||||
|
plan report is never silent.
|
||||||
|
|
||||||
|
Without --plan the runner used to glob the auto-resolved planner's report
|
||||||
|
directory for plan-*.md and feed the newest one to the Creator as "the plan
|
||||||
|
already agreed on". Both keys are wrong: the directory belongs to a session,
|
||||||
|
not to this task, and mtime is checkout order. 47 unrelated plans are committed
|
||||||
|
in this repository (P1).
|
||||||
|
|
||||||
|
The same review also found that the three --plan-mode reads had no content
|
||||||
|
check at all, so an empty planner or refine report silently blanked the plan
|
||||||
|
and switched the Creator's prompt (P9-P11).
|
||||||
|
|
||||||
|
Every test writes only under tmp_path (Issue #20).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
RUNNER_PY = REPO_ROOT / ".agents" / "skills" / "lib_py" / "loop" / "runner.py"
|
||||||
|
RUN_LOOP_SH = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
|
||||||
|
|
||||||
|
GIT_ID = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.invalid",
|
||||||
|
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.invalid"}
|
||||||
|
|
||||||
|
PLANNER = "planner-claude-01"
|
||||||
|
CREATOR = "creator-claude-01"
|
||||||
|
REVIEWER = "reviewer-claude-01"
|
||||||
|
|
||||||
|
OLD_PLAN = "# plan for a completely different issue\n\nOLD-PLAN-MARKER\n"
|
||||||
|
NEW_PLAN = "# the plan I actually mean\n\nNAMED-PLAN-MARKER\n"
|
||||||
|
PASS_REPORT = "looks fine\n\n[VERDICT: PASS]\n"
|
||||||
|
FAIL_REPORT = "line 3 is wrong\n\n[VERDICT: NOT PASS]\n"
|
||||||
|
FAIL_ESCALATE = "needs a redesign\n\n[ESCALATE: PLANNER]\n\n[VERDICT: NOT PASS]\n"
|
||||||
|
|
||||||
|
SELF_PLAN_PROMPT = "계획서가 존재하지 않으므로"
|
||||||
|
EXISTING_PLAN_PROMPT = "이미 수립된"
|
||||||
|
CORRECTION_WITH_PLAN = "갱신된 다음 계획서에 입각하여"
|
||||||
|
|
||||||
|
|
||||||
|
def _git(cwd: Path, *args: str) -> str:
|
||||||
|
return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True,
|
||||||
|
check=True, env={**os.environ, **GIT_ID}).stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ctl(tmp_path, monkeypatch):
|
||||||
|
"""A control root whose planner report directory already holds an unrelated
|
||||||
|
promoted plan - the condition that used to inject it (F1/F3)."""
|
||||||
|
main = tmp_path / "main"
|
||||||
|
main.mkdir()
|
||||||
|
_git(main, "init", "-q")
|
||||||
|
(main / "a.txt").write_text("a\n")
|
||||||
|
_git(main, "add", "a.txt")
|
||||||
|
_git(main, "commit", "-q", "-m", "init")
|
||||||
|
|
||||||
|
stale = main / ".agents" / "reports" / PLANNER / "plan-OLD.md"
|
||||||
|
stale.parent.mkdir(parents=True)
|
||||||
|
stale.write_text(OLD_PLAN, encoding="utf-8")
|
||||||
|
os.utime(stale, (2_000_000_000, 2_000_000_000)) # newest by mtime, by a mile
|
||||||
|
|
||||||
|
for k, v in GIT_ID.items():
|
||||||
|
monkeypatch.setenv(k, v)
|
||||||
|
monkeypatch.setenv("WORKSPACE_ROOT", str(main))
|
||||||
|
monkeypatch.delenv("MAM_REAL_ROOT", raising=False)
|
||||||
|
monkeypatch.setenv("MAM_JOBS_DIR", str(main / ".mam" / "jobs"))
|
||||||
|
monkeypatch.delenv("MAM_LOOP_WORKTREE", raising=False)
|
||||||
|
monkeypatch.delenv("MAM_LOOP_BASE", raising=False)
|
||||||
|
# the planner row is what makes the old glob fire at all (F7)
|
||||||
|
monkeypatch.setenv("MAM_STATE_JSON", json.dumps({"herdr_sessions": [
|
||||||
|
{"name": CREATOR, "agent": "claude", "role": "creator", "status": "running"},
|
||||||
|
{"name": REVIEWER, "agent": "claude", "role": "reviewer", "status": "running"},
|
||||||
|
{"name": PLANNER, "agent": "claude", "role": "planner", "status": "running"},
|
||||||
|
]}))
|
||||||
|
return main
|
||||||
|
|
||||||
|
|
||||||
|
def _runner(*extra):
|
||||||
|
from lib_py.loop import runner as lr
|
||||||
|
return lr.LoopRunner(["--creator", CREATOR, "--task", "t25", *extra])
|
||||||
|
|
||||||
|
|
||||||
|
def _drive(monkeypatch, tmp_path, argv, reports=None, on_worker=None, max_loop="1"):
|
||||||
|
"""run() end to end with the delegation stubbed.
|
||||||
|
|
||||||
|
`reports` maps (role, nth-call-of-that-role) to the report body; a body of
|
||||||
|
None means the report file is missing. Everything else gets a PASS for a
|
||||||
|
Reviewer and a one-line report otherwise. The existing _drive helpers hand
|
||||||
|
every job the same non-empty PASS report, which cannot reach the empty-plan
|
||||||
|
paths at all (F13).
|
||||||
|
|
||||||
|
Returns (rc, [(role, prompt)], runner).
|
||||||
|
"""
|
||||||
|
from lib_py.loop import runner as lr
|
||||||
|
monkeypatch.setattr(lr.signal, "signal", lambda *a, **k: None)
|
||||||
|
r = lr.LoopRunner(["--creator", CREATOR, "--task", "t25",
|
||||||
|
"--reviewer", REVIEWER, "--max-loop", max_loop, *argv])
|
||||||
|
reports = reports or {}
|
||||||
|
calls, files, nth = [], {}, {}
|
||||||
|
stub_dir = tmp_path / "stub-reports"
|
||||||
|
stub_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
def fake_delegate(session, agent_type, role, prompt):
|
||||||
|
calls.append((role, prompt))
|
||||||
|
jid = f"j{len(calls)}"
|
||||||
|
n = nth[role] = nth.get(role, 0) + 1
|
||||||
|
body = reports.get((role, n), PASS_REPORT if role == "Reviewer" else "done\n")
|
||||||
|
if body is None:
|
||||||
|
files[jid] = None
|
||||||
|
else:
|
||||||
|
f = stub_dir / f"{jid}.md"
|
||||||
|
f.write_text(body, encoding="utf-8")
|
||||||
|
files[jid] = f
|
||||||
|
if role == "Worker" and on_worker:
|
||||||
|
on_worker(r)
|
||||||
|
return jid
|
||||||
|
|
||||||
|
monkeypatch.setattr(r, "delegate_job", fake_delegate)
|
||||||
|
monkeypatch.setattr(r, "wait_for_job", lambda *a, **k: True)
|
||||||
|
monkeypatch.setattr(r, "find_report", lambda jid: files.get(jid))
|
||||||
|
return r.run(), calls, r
|
||||||
|
|
||||||
|
|
||||||
|
def _roles(calls, role):
|
||||||
|
return [p for (ro, p) in calls if ro == role]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# P1-P2 — reuse is explicit
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_p1_no_options_does_not_inject_an_unrelated_plan(ctl, monkeypatch, tmp_path, capsys):
|
||||||
|
"""The #25 reproduction: a running planner plus a promoted plan in its
|
||||||
|
report directory must not reach the Creator. Fails before the fix."""
|
||||||
|
rc, calls, _ = _drive(monkeypatch, tmp_path, [])
|
||||||
|
assert rc == 0
|
||||||
|
worker = _roles(calls, "Worker")[0]
|
||||||
|
assert worker.startswith(SELF_PLAN_PROMPT)
|
||||||
|
assert "OLD-PLAN-MARKER" not in worker
|
||||||
|
assert EXISTING_PLAN_PROMPT not in worker
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Found existing promoted plan" not in out
|
||||||
|
assert "No plan loaded" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_p2_plan_file_is_the_only_plan_and_lives_one_round(ctl, monkeypatch, tmp_path):
|
||||||
|
"""The named plan is used, the newer unrelated one is not, and the plan is
|
||||||
|
not repeated in the correction round (N1/D16 - the behaviour the implicit
|
||||||
|
load had too)."""
|
||||||
|
named = tmp_path / "my-plan.md"
|
||||||
|
named.write_text(NEW_PLAN, encoding="utf-8")
|
||||||
|
rc, calls, _ = _drive(monkeypatch, tmp_path, ["--plan-file", str(named)],
|
||||||
|
reports={("Reviewer", 1): FAIL_REPORT}, max_loop="2")
|
||||||
|
assert rc == 0
|
||||||
|
workers = _roles(calls, "Worker")
|
||||||
|
assert workers[0].startswith(EXISTING_PLAN_PROMPT)
|
||||||
|
assert "NAMED-PLAN-MARKER" in workers[0]
|
||||||
|
assert "OLD-PLAN-MARKER" not in workers[0]
|
||||||
|
|
||||||
|
assert len(workers) == 2, "a correction round ran"
|
||||||
|
assert "NAMED-PLAN-MARKER" not in workers[1]
|
||||||
|
assert workers[1].startswith("리뷰어들이 지적한 다음 피드백")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# P3-P5 — the option is checked before any job exists
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_p3_unusable_plan_files_are_refused_before_any_job(ctl, tmp_path, monkeypatch):
|
||||||
|
empty, ws, sub = tmp_path / "empty.md", tmp_path / "ws.md", tmp_path / "sub"
|
||||||
|
empty.write_text("")
|
||||||
|
ws.write_text(" \n\t\n")
|
||||||
|
sub.mkdir()
|
||||||
|
cases = [tmp_path / "nope.md", sub, empty, ws]
|
||||||
|
|
||||||
|
if os.getuid() != 0:
|
||||||
|
locked = tmp_path / "locked.md"
|
||||||
|
locked.write_text(NEW_PLAN, encoding="utf-8")
|
||||||
|
locked.chmod(0o000)
|
||||||
|
cases.append(locked)
|
||||||
|
|
||||||
|
from lib_py.loop import runner as lr
|
||||||
|
for path in cases:
|
||||||
|
r = lr.LoopRunner(["--creator", CREATOR, "--task", "t25", "--plan-file", str(path)])
|
||||||
|
monkeypatch.setattr(r, "delegate_job", lambda *a, **k: pytest.fail("a job was delegated"))
|
||||||
|
with pytest.raises(SystemExit) as e:
|
||||||
|
r.parse_args()
|
||||||
|
assert e.value.code == 1, path
|
||||||
|
|
||||||
|
|
||||||
|
def test_p3b_the_refusal_names_the_resolved_path(ctl, tmp_path, capsys):
|
||||||
|
missing = tmp_path / "nope.md"
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
_runner("--plan-file", str(missing)).parse_args()
|
||||||
|
assert str(missing.resolve()) in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_p3c_a_relative_plan_file_is_taken_against_the_control_root(ctl, tmp_path, monkeypatch):
|
||||||
|
"""R1/N1: the usage line promises control-root-relative, and the runner
|
||||||
|
never chdirs to the control root. Resolving against the caller's cwd looked
|
||||||
|
somewhere else - and a same-named file there won silently, which is the
|
||||||
|
very failure #25 is about."""
|
||||||
|
(ctl / "plans").mkdir()
|
||||||
|
(ctl / "plans" / "named.md").write_text(NEW_PLAN, encoding="utf-8")
|
||||||
|
caller = tmp_path / "caller"
|
||||||
|
(caller / "plans").mkdir(parents=True)
|
||||||
|
monkeypatch.chdir(caller)
|
||||||
|
|
||||||
|
r = _runner("--plan-file", "plans/named.md")
|
||||||
|
r.parse_args()
|
||||||
|
assert Path(r.plan_file) == (ctl / "plans" / "named.md").resolve()
|
||||||
|
assert "NAMED-PLAN-MARKER" in r.current_plan
|
||||||
|
|
||||||
|
# the same name in the caller's cwd must not win
|
||||||
|
(caller / "plans" / "named.md").write_text(OLD_PLAN, encoding="utf-8")
|
||||||
|
r = _runner("--plan-file", "plans/named.md")
|
||||||
|
r.parse_args()
|
||||||
|
assert Path(r.plan_file) == (ctl / "plans" / "named.md").resolve()
|
||||||
|
assert "NAMED-PLAN-MARKER" in r.current_plan
|
||||||
|
assert "OLD-PLAN-MARKER" not in r.current_plan
|
||||||
|
|
||||||
|
|
||||||
|
def test_p3d_an_absolute_plan_file_is_left_alone(ctl, tmp_path, monkeypatch):
|
||||||
|
"""Anchoring the relative case must not rewrite absolute or ~ paths."""
|
||||||
|
named = tmp_path / "abs-plan.md"
|
||||||
|
named.write_text(NEW_PLAN, encoding="utf-8")
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
r = _runner("--plan-file", str(named))
|
||||||
|
r.parse_args()
|
||||||
|
assert Path(r.plan_file) == named.resolve()
|
||||||
|
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
home_plan = tmp_path / "home-plan.md"
|
||||||
|
home_plan.write_text(NEW_PLAN, encoding="utf-8")
|
||||||
|
r = _runner("--plan-file", "~/home-plan.md")
|
||||||
|
r.parse_args()
|
||||||
|
assert Path(r.plan_file) == home_plan.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def test_p4_plan_file_conflicts_with_plan_and_needs_a_value(ctl, tmp_path, monkeypatch):
|
||||||
|
named = tmp_path / "my-plan.md"
|
||||||
|
named.write_text(NEW_PLAN, encoding="utf-8")
|
||||||
|
from lib_py.loop import runner as lr
|
||||||
|
for argv in (["--plan-file", str(named), "--plan"], ["--plan", "--plan-file", str(named)],
|
||||||
|
["--plan-file"]):
|
||||||
|
r = lr.LoopRunner(["--creator", CREATOR, "--task", "t25", *argv])
|
||||||
|
monkeypatch.setattr(r, "delegate_job", lambda *a, **k: pytest.fail("a job was delegated"))
|
||||||
|
with pytest.raises(SystemExit) as e:
|
||||||
|
r.parse_args()
|
||||||
|
assert e.value.code == 1, argv
|
||||||
|
|
||||||
|
|
||||||
|
def _reviewable_worktree(ctl, tmp_path):
|
||||||
|
"""A linked worktree with one commit to review. The source root has to be
|
||||||
|
separate from the control root here: --base turns review identity on, and
|
||||||
|
the loop's own checkpoint under the control root's .mam would otherwise
|
||||||
|
count as the source changing mid-review."""
|
||||||
|
wt = tmp_path / "wt"
|
||||||
|
_git(ctl, "worktree", "add", "-q", "-b", "feat", str(wt))
|
||||||
|
base = _git(wt, "rev-parse", "HEAD")
|
||||||
|
(wt / "a.txt").write_text("a\nimplemented\n")
|
||||||
|
_git(wt, "commit", "-qam", "impl")
|
||||||
|
return ["--worktree", str(wt), "--base", base, "--review-only"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_p5_plan_file_is_warned_about_and_ignored_with_review_only(ctl, monkeypatch, tmp_path, capsys):
|
||||||
|
named = tmp_path / "my-plan.md"
|
||||||
|
named.write_text(NEW_PLAN, encoding="utf-8")
|
||||||
|
rc, calls, r = _drive(monkeypatch, tmp_path,
|
||||||
|
["--plan-file", str(named), *_reviewable_worktree(ctl, tmp_path)])
|
||||||
|
assert rc == 0
|
||||||
|
assert _roles(calls, "Worker") == [], "Phase 2 is skipped"
|
||||||
|
assert "NAMED-PLAN-MARKER" not in _roles(calls, "Reviewer")[0]
|
||||||
|
assert r.current_plan == ""
|
||||||
|
assert "--plan-file is ignored with --review-only" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_p5b_the_ignore_warning_needs_the_option(ctl, monkeypatch, tmp_path, capsys):
|
||||||
|
"""The warning is keyed on the option being given, not on a default value -
|
||||||
|
unlike the --plan-talk warning it sits next to (N2, runner.py:246)."""
|
||||||
|
rc, _, _ = _drive(monkeypatch, tmp_path, _reviewable_worktree(ctl, tmp_path))
|
||||||
|
assert rc == 0
|
||||||
|
assert "is ignored with --review-only" not in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# P6, P9-P11 — --plan mode: compatible, and never silently planless
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_p6_plan_mode_still_plans_and_promotes(ctl, monkeypatch, tmp_path):
|
||||||
|
rc, calls, r = _drive(monkeypatch, tmp_path, ["--plan", "--plan-talk", "0"],
|
||||||
|
reports={("Planner", 1): NEW_PLAN})
|
||||||
|
assert rc == 0
|
||||||
|
worker = _roles(calls, "Worker")[0]
|
||||||
|
assert worker.startswith(EXISTING_PLAN_PROMPT)
|
||||||
|
assert "NAMED-PLAN-MARKER" in worker
|
||||||
|
promoted = ctl / ".agents" / "reports" / PLANNER / f"plan-{r.plan_job_id}.md"
|
||||||
|
assert promoted.is_file() and "NAMED-PLAN-MARKER" in promoted.read_text()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("body", ["", " \n\t\n"], ids=["empty", "whitespace"])
|
||||||
|
def test_p9_an_empty_initial_plan_stops_the_run(ctl, monkeypatch, tmp_path, capsys, body):
|
||||||
|
"""D13: paying for a planning phase and getting nothing back must not be a
|
||||||
|
quiet demotion to self-planning."""
|
||||||
|
rc, calls, _ = _drive(monkeypatch, tmp_path, ["--plan", "--plan-talk", "0"],
|
||||||
|
reports={("Planner", 1): body})
|
||||||
|
assert rc == 1
|
||||||
|
assert _roles(calls, "Worker") == [], "no implementation was delegated"
|
||||||
|
assert "has no plan text" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_p10_an_empty_refine_keeps_the_previous_plan(ctl, monkeypatch, tmp_path, capsys):
|
||||||
|
"""D14: the discussion turn must not blank a plan that is still good."""
|
||||||
|
rc, calls, _ = _drive(monkeypatch, tmp_path, ["--plan", "--plan-talk", "1"],
|
||||||
|
reports={("Planner", 1): NEW_PLAN, ("Planner", 2): ""})
|
||||||
|
assert rc == 0
|
||||||
|
worker = _roles(calls, "Worker")[1] # [0] is the Creator's challenge turn
|
||||||
|
assert worker.startswith(EXISTING_PLAN_PROMPT)
|
||||||
|
assert "NAMED-PLAN-MARKER" in worker
|
||||||
|
assert "keeping the previous plan" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
def test_p11_an_empty_escalation_refine_never_claims_an_empty_plan(ctl, monkeypatch, tmp_path, capsys):
|
||||||
|
"""D14/D15: the correction prompt asserts "follow the updated plan below",
|
||||||
|
so the plan it carries must never be blank."""
|
||||||
|
rc, calls, _ = _drive(monkeypatch, tmp_path, ["--plan", "--plan-talk", "0"],
|
||||||
|
reports={("Planner", 1): NEW_PLAN, ("Planner", 2): "",
|
||||||
|
("Reviewer", 1): FAIL_ESCALATE},
|
||||||
|
max_loop="2")
|
||||||
|
assert rc == 0
|
||||||
|
correction = _roles(calls, "Worker")[1]
|
||||||
|
assert CORRECTION_WITH_PLAN in correction
|
||||||
|
assert "NAMED-PLAN-MARKER" in correction, "the prompt claims a plan; it must carry one"
|
||||||
|
assert "keeping the previous plan" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# P7-P8 — wrapper parity and the legacy shell path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_p7_wrapper_and_runner_agree_on_the_option(tmp_path):
|
||||||
|
line = " --plan-file <path> Reuse an existing plan file, relative to the control root (not with --plan)"
|
||||||
|
sh = subprocess.run(["bash", str(RUN_LOOP_SH), "--help"], capture_output=True, text=True)
|
||||||
|
assert line in sh.stdout.splitlines()
|
||||||
|
|
||||||
|
code = ("import sys; sys.path.insert(0, %r)\n"
|
||||||
|
"from lib_py.loop import runner as lr\n"
|
||||||
|
"lr.LoopRunner([]).print_usage()\n" % str(REPO_ROOT / ".agents" / "skills"))
|
||||||
|
py = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
|
||||||
|
assert line in py.stdout.splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
def test_p7b_the_legacy_shell_loop_refuses_the_option(tmp_path):
|
||||||
|
marker = tmp_path / "loop.marker"
|
||||||
|
res = subprocess.run(["bash", str(RUN_LOOP_SH), "--creator", CREATOR, "--task", "t25",
|
||||||
|
"--plan-file", str(tmp_path / "x.md")],
|
||||||
|
capture_output=True, text=True, cwd=str(tmp_path),
|
||||||
|
env={**os.environ, "MAM_LOOP_PY": "0", "MAM_LOOP_MARKER": str(marker)})
|
||||||
|
assert res.returncode == 1
|
||||||
|
assert "--plan-file" in res.stderr
|
||||||
|
assert not marker.exists(), "refused before the loop lock"
|
||||||
|
|
||||||
|
|
||||||
|
def test_p8_neither_loop_implementation_globs_for_a_plan_any_more():
|
||||||
|
for path in (RUN_LOOP_SH, RUNNER_PY):
|
||||||
|
assert "plan-*.md" not in path.read_text(encoding="utf-8"), path
|
||||||
Reference in new issue
Block a user