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>
This commit is contained in:
1 parent
6fc9194f89
commit
a28711cccf
2 files changed
+67
-18
No files matched your search
@@ -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,28 @@ 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)
|
||||
resolved = 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 +295,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)
|
||||
@@ -1002,11 +1046,15 @@ class LoopRunner:
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user