Compare commits

..
5 Commits
Author SHA1 Message Date
GodopuandClaude Opus 5 1e826515cc fix(issue-25): a relative --plan-file is taken against the control root, as its usage line says
Review R1/N1 (reviewer-grok-01, planner-reviewer-codex-01), reproduced before
fixing. The usage line and SKILL.md both promise "relative to the control
root", but parse_args did Path(...).expanduser().resolve(), which is relative
to the caller's cwd. The runner never chdirs to workspace_root, so the two are
routinely different - the documented recipe runs the loop from the control root
while the source root is a worktree.

Two ways it went wrong: a plan that exists only under the control root was not
found at all (exit 1), and a same-named file in the caller's cwd was loaded
instead, quietly putting an unrelated plan into the first implementation
prompt. That second one is the very failure #25 is about, reintroduced through
the option meant to replace it.

Resolve against workspace_root the way jobs_dir already does (Issue #21 R2);
pathlib's `/` leaves an absolute or expanded-home path untouched. Every earlier
test passed an absolute tmp_path, which is why none of them caught this: P3c
covers both failure modes with the control root away from the caller's cwd, and
P3d pins that absolute and ~ paths are not rewritten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 05:17:50 +00:00
GodopuandClaude Opus 5 5fa592347c docs(issue-25): the loop skill documents --plan-file and the empty-report handling
The skill described the implicit load as a feature in five places. Replace them
with what the loop does now: nothing is loaded without --plan-file, the path is
relative to the control root, and the plan rides the first implementation
prompt only. Adds the CLI table row and a section on what an empty plan report
means at each of the four load points.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 03:26:44 +00:00
GodopuandClaude Opus 5 a334083760 test(issue-25): plan reuse is explicit only and empty plan reports are not silent (P1-P11)
P1 is the reproduction: a running planner plus a promoted plan in its report
directory must not reach the Creator. P2 pins that a named plan is used, that
the newer unrelated one is not, and that the plan rides only the first
implementation prompt - the correction round carries feedback alone, as the
implicit load did too. P3-P5 keep the option's checks in front of any job.
P6 keeps --plan mode and its promotion working.

P9-P11 cover the loads the review widened: an empty initial plan stops the run,
an empty discussion refine keeps the previous plan, and an empty escalation
refine leaves the "follow the updated plan below" prompt carrying the real one.
The existing _drive helpers hand every job the same non-empty PASS report and
so cannot reach these paths at all, so this module drives the loop with its own
stub that scripts a report per job. P7-P8 pin wrapper/runner parity, the legacy
shell refusal, and that neither implementation globs for a plan any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 03:26:44 +00:00
GodopuandClaude Opus 5 e551e3baf1 fix(issue-25): one plan-text check for every load; an empty planner report stops the loop, an empty refine keeps the plan
The --plan-file check landed on the one path a human is watching. The three
--plan-mode loads read a report written by another agent with nobody watching,
and had no content check at all: find_report only tests that the path exists.

An empty or whitespace-only report therefore blanked current_plan in silence.
Phase 2's `if self.current_plan` then flipped the prompt to self-planning and
the run finished rc 0, having paid for a planner job, a Creator challenge and a
refine job and thrown the result away. The escalation refine was worse: it has
no guard, so the correction prompt asserted "follow the updated plan below" and
carried nothing.

All four loads share _plan_text() now - a plan is a readable regular file with
text in it - and each caller says what None means. An empty initial plan stops
the run instead of demoting it, and an empty refine warns and keeps the plan it
already has, which is still good. Those two together leave the escalation
prompt unable to claim a plan it does not carry, so it needs no new branch.

Found by the Creator's challenge to the plan (job 0f63ac1d), sustained by the
Planner as revision 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 03:26:34 +00:00
GodopuandClaude Opus 5 a28711cccf fix(issue-25): reuse a promoted plan only with --plan-file; drop the implicit newest-plan load
Without --plan, Phase 1 resolved a planner session automatically and globbed
<workspace_root>/.agents/reports/<planner>/plan-*.md for the newest file by
mtime, handing it to the Creator as "the plan already agreed on". Both keys are
wrong: the report directory belongs to a session, not to this task, so other
issues' plans pile up in it, and right after a clone or checkout mtime is
checkout order. 47 such plans are committed in this repository, so an ordinary
run could silently implement against an unrelated design.

Reuse is explicit now. --plan-file <path> is the only way to load an existing
plan; it is resolved against the control root, checked before a single job
exists, and refused if the file is missing, not a regular file, unreadable or
empty (an empty file would quietly turn into a "no plan" prompt). It cannot be
combined with --plan, and --review-only warns and ignores it because no
implementation job is delegated there. The implementation prompt itself is
unchanged, so the Creator's contract does not move.

Phase 1 now says which plan it loaded and where from, or that it loaded none.
The legacy shell loop (MAM_LOOP_PY=0) loses the same auto-load and rejects
--plan-file rather than duplicating the check on a path that is on its way out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 03:26:24 +00:00
4 changed files with 486 additions and 31 deletions

No files matched your search

+81 -11
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
import argparse
import atexit
import glob
import json
import os
import re
@@ -98,6 +97,7 @@ class LoopRunner:
self.creator: str = ""
self.task: str = ""
self.plan_mode: bool = False
self.plan_file: Optional[str] = None
self.planner_override: Optional[str] = None
self.plan_talk_turns: int = 1
self.reviewer_list: Optional[str] = None
@@ -116,6 +116,7 @@ class LoopRunner:
" --plan Enable Planner agent intervention & design phase",
" --planner <name> Planner session (requires --plan; default: auto-resolve)",
" --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)",
" --all-reviewer Enforce PASS verdict from all active reviewer sessions",
" --max-loop N Max execution-review corrective loop runs (default: 3)",
@@ -162,6 +163,12 @@ class LoopRunner:
sys.exit(1)
self.planner_override = args[i + 1]
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":
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)
@@ -240,6 +247,33 @@ class LoopRunner:
print(f' run_loop.sh --creator <creator> --plan --planner <planner> --task "..."', file=sys.stderr)
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:
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")):
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
def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
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():
log_error("Final plan file not found.")
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
creator_type = self.resolve_agent_type(self.creator, state)
@@ -996,17 +1051,26 @@ class LoopRunner:
return 1
ref_file = self.find_report(ref_jid)
if ref_file and ref_file.exists():
self.current_plan = ref_file.read_text(encoding="utf-8", errors="replace")
refined = self._plan_text(ref_file) if ref_file else None
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.")
else:
log_info("=== Phase 1: Self-Planning Mode (Direct Execution) ===")
if planner_session:
plan_files = sorted(glob.glob(str(self.workspace_root / ".agents" / "reports" / planner_session / "plan-*.md")), key=os.path.getmtime, reverse=True)
if plan_files:
log_info(f"Found existing promoted plan at '{plan_files[0]}'. Loading plan...")
self.current_plan = Path(plan_files[0]).read_text(encoding="utf-8", errors="replace")
# Issue #25 (D1): no plan is loaded unless one was named. This used
# to take the newest promoted plan in the planner's report dir,
# keyed on mtime (checkout order) and on a planner session that says
# nothing about this task - so it injected other issues' plans.
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)
@@ -1217,8 +1281,14 @@ class LoopRunner:
log_error("Failed to register plan refinement job.")
return 1
ref_file = self.find_report(ref_jid)
if ref_file and ref_file.exists():
self.current_plan = ref_file.read_text(encoding="utf-8", errors="replace")
refined = self._plan_text(ref_file) if ref_file else None
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}"
else:
log_info("Applying straight bugfixes based on reviewer feedback...")
+18 -8
View File
@@ -27,7 +27,8 @@ metadata:
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.
- **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.
- **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:]`).
@@ -135,10 +136,10 @@ sequenceDiagram
Dev->>Plan: send critiques (Discussion)
Plan-->>Dev: update plan & reach consensus
end
else Use Existing Plan or Creator Self-Plan (No --plan)
alt Existing Plan Found
Loop->>Dev: notify task execution using existing plan
else No Plan Found
else Creator Self-Plan (No --plan)
alt Plan named with --plan-file
Loop->>Dev: notify task execution using the named plan
else No --plan-file
Loop->>Dev: request Creator self-planning and code execution
end
end
@@ -172,7 +173,7 @@ sequenceDiagram
1. **Planning Phase**:
- **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(타입 매핑, 공유 자원 사이드 이펙트 방지, 문서-코드 정합성)를 자체 검증한 뒤 단일 커밋을 작성합니다.
3. **Review Phase**: Creator가 리뷰어 세션에 작업 완료 사실과 변경 범위(`git diff`)를 전달합니다. 리뷰어는 검증 후 리포트 **마지막에 단독 행**으로 판정을 남깁니다:
- **반려 (`[VERDICT: NOT PASS]`)** → 피드백 요약을 Planner에게 전송하여 상위 레벨 계획(Rev.n)을 개시합니다.
@@ -180,6 +181,14 @@ sequenceDiagram
4. 지정되거나 자동 수집된 리뷰어 전원이 PASS를 발행해야 완결되며, `--max-loop N`회 내에 도달하지 못하면 안전을 위해 루프를 중단합니다.
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
@@ -188,7 +197,8 @@ sequenceDiagram
| 워크플로우 단계 | 해당 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: Debate** | `--plan-talk N` | Planner와 Creator가 상호 대화식 챌린지 루프를 `N`회 돌며 계획을 교차 정제합니다. |
| **Phase 2: Execution** | `--creator <name>` | 코드를 구현할 Creator 세션에 코딩 태스크를 주입합니다. (필수) |
@@ -222,7 +232,7 @@ bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
## Workflow
```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 \
--creator "<creator-session-name>" \
--task "Fix typo in deploy/README.md"
@@ -32,6 +32,7 @@ TARGET_AGENT=""
TASK=""
REVIEWER_LIST=""
PLANNER_SESSION_OVERRIDE=""
LOOP_PLAN_FILE=""
LOOP_WORKTREE=""
LOOP_BASE=""
LOOP_REVIEW_ONLY=0
@@ -46,6 +47,7 @@ usage() {
echo " --plan Enable Planner agent intervention & design phase"
echo " --planner <name> Planner session (requires --plan; default: auto-resolve)"
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 " --all-reviewer Enforce PASS verdict from all active reviewer sessions"
echo " --max-loop N Max execution-review corrective loop runs (default: 3)"
@@ -92,6 +94,7 @@ while [[ "$#" -gt 0 ]]; do
exit 1
;;
--planner) PLANNER_SESSION_OVERRIDE="$2"; shift 2 ;;
--plan-file) LOOP_PLAN_FILE="$2"; shift 2 ;;
--task) TASK="$2"; shift 2 ;;
--worktree) LOOP_WORKTREE="$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
# 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).
if [ "${MAM_LOOP_PY:-1}" = "0" ] && { [ -n "$LOOP_WORKTREE" ] || [ -n "$LOOP_BASE" ] \
|| [ "$LOOP_REVIEW_ONLY" = "1" ] || [ -n "${MAM_LOOP_WORKTREE:-}" ] \
|| [ -n "$LOOP_REVIEW_ISOLATION" ] || [ -n "$LOOP_REMOTE_REVIEW" ]; }; then
echo "ERROR: --worktree/--base/--review-only/--review-isolation/--remote-review require the Python loop (MAM_LOOP_PY=1)" >&2
|| [ -n "$LOOP_REVIEW_ISOLATION" ] || [ -n "$LOOP_REMOTE_REVIEW" ] \
|| [ -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
fi
@@ -560,16 +566,11 @@ if [ "$PLAN_MODE" = true ]; then
log_success "Interactive planning completed. Plan finalized."
else
log_info "=== Phase 1: Self-Planning Mode (Direct Execution) ==="
EXISTING_PLAN_FILE=""
if [ -n "$PLANNER_SESSION" ]; then
# Promoted plans are saved as plan-<job-id>.md by the Phase 3 promotion
# step below, not report-final.md; pick the most recently promoted one.
EXISTING_PLAN_FILE=$(ls -t ".agents/reports/$PLANNER_SESSION"/plan-*.md 2>/dev/null | head -n 1 || true)
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
# Issue #25 (D1/D9): no plan is loaded here. This used to take the newest
# promoted plan of the auto-resolved planner, keyed on mtime and on a session
# that says nothing about this task. Reuse is explicit and Python-only now
# (--plan-file), and this path rejects that option above.
log_info "No plan loaded; the Creator plans by itself."
fi
# ===========================================================================
+374
View File
@@ -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