🐛 fix(main_loop): enforce plan lifecycle ordering
Claim the first executable plan in plan-file order after resuming any in-progress plan, preserve skipped as its own workflow phase, and document the required post-done commit gate in the agent rules.
This commit is contained in:
+49
-21
@@ -31,7 +31,14 @@ PLAN_LINE_RE = re.compile(
|
||||
)
|
||||
FINISH_STATUSES = {"done", "blocked", "skipped"}
|
||||
ENV_BLOCKED_RE = re.compile(r"^env:([^:]+):(.+)$")
|
||||
WORKFLOW_PHASES = {"brainstorming", "planning", "executing", "done", "blocked"}
|
||||
WORKFLOW_PHASES = {
|
||||
"brainstorming",
|
||||
"planning",
|
||||
"executing",
|
||||
"done",
|
||||
"blocked",
|
||||
"skipped",
|
||||
}
|
||||
THREAD_LOCKS: dict[str, threading.Lock] = {}
|
||||
THREAD_LOCKS_GUARD = threading.Lock()
|
||||
|
||||
@@ -51,7 +58,7 @@ def usage() -> str:
|
||||
" -plan PATH\n"
|
||||
" -status done|blocked|skipped\n"
|
||||
" -progress FILE\n"
|
||||
" -phase brainstorming|planning|executing|done|blocked\n"
|
||||
" -phase brainstorming|planning|executing|done|blocked|skipped\n"
|
||||
" -spec PATH\n"
|
||||
" -executor NAME\n"
|
||||
" -constraints CSV\n"
|
||||
@@ -365,13 +372,19 @@ def update_workflow_state(
|
||||
|
||||
|
||||
def ensure_all_plans_present(
|
||||
lines: list[str], start_idx: int, end_idx: int, progress_path: Path, plan_keys: list[str]
|
||||
lines: list[str],
|
||||
start_idx: int,
|
||||
end_idx: int,
|
||||
progress_path: Path,
|
||||
plan_keys: list[str],
|
||||
) -> list[tuple[str, str, Optional[str], int]]:
|
||||
entries = parse_entries(lines, start_idx, end_idx)
|
||||
existing = {plan_key for plan_key, _, _, _ in entries}
|
||||
missing = [plan_key for plan_key in plan_keys if plan_key not in existing]
|
||||
if missing:
|
||||
insert_lines = [render_plan_line(plan_key, "pending", None) for plan_key in missing]
|
||||
insert_lines = [
|
||||
render_plan_line(plan_key, "pending", None) for plan_key in missing
|
||||
]
|
||||
lines[end_idx:end_idx] = insert_lines
|
||||
write_progress_lines(progress_path, lines)
|
||||
end_idx += len(insert_lines)
|
||||
@@ -387,23 +400,29 @@ def filter_existing_entries(
|
||||
|
||||
|
||||
def choose_claim_entry(
|
||||
entries: list[tuple[str, str, Optional[str], int]], current_env: Optional[str]
|
||||
entries: list[tuple[str, str, Optional[str], int]],
|
||||
current_env: Optional[str],
|
||||
plan_keys: list[str],
|
||||
) -> Optional[tuple[str, Optional[str], int]]:
|
||||
for plan_key, status, note, idx in entries:
|
||||
entry_by_plan: dict[str, tuple[str, str, Optional[str], int]] = {}
|
||||
for entry in entries:
|
||||
entry_by_plan.setdefault(entry[0], entry)
|
||||
ordered_entries = [
|
||||
entry_by_plan[plan_key] for plan_key in plan_keys if plan_key in entry_by_plan
|
||||
]
|
||||
|
||||
for plan_key, status, note, idx in ordered_entries:
|
||||
if status == "in-progress":
|
||||
return plan_key, note, idx
|
||||
|
||||
for plan_key, status, note, idx in entries:
|
||||
for plan_key, status, note, idx in ordered_entries:
|
||||
if status == "pending":
|
||||
return plan_key, note, idx
|
||||
|
||||
if current_env:
|
||||
for plan_key, status, note, idx in entries:
|
||||
if status != "blocked":
|
||||
continue
|
||||
env_info = parse_env_blocked_note(note)
|
||||
if env_info and env_info[0] == current_env:
|
||||
return plan_key, note, idx
|
||||
if status != "blocked" or not current_env:
|
||||
continue
|
||||
env_info = parse_env_blocked_note(note)
|
||||
if env_info and env_info[0] == current_env:
|
||||
return plan_key, note, idx
|
||||
|
||||
return None
|
||||
|
||||
@@ -419,13 +438,17 @@ def claim_plan(plans_dir: Path, progress_path: Path) -> tuple[int, str]:
|
||||
with locked_progress(progress_path):
|
||||
lines = load_progress_lines(progress_path)
|
||||
try:
|
||||
lines, start_idx, end_idx = ensure_plan_block(lines, progress_path, plan_keys)
|
||||
lines, start_idx, end_idx = ensure_plan_block(
|
||||
lines, progress_path, plan_keys
|
||||
)
|
||||
except ValueError as exc:
|
||||
return 2, f"ERROR: {exc}"
|
||||
|
||||
entries = ensure_all_plans_present(lines, start_idx, end_idx, progress_path, plan_keys)
|
||||
entries = ensure_all_plans_present(
|
||||
lines, start_idx, end_idx, progress_path, plan_keys
|
||||
)
|
||||
entries = filter_existing_entries(entries, plan_keys)
|
||||
chosen = choose_claim_entry(entries, detect_env())
|
||||
chosen = choose_claim_entry(entries, detect_env(), plan_keys)
|
||||
if not chosen:
|
||||
return 0, "NOOP: no claimable plans"
|
||||
|
||||
@@ -457,18 +480,24 @@ def finish_plan(
|
||||
lines = load_progress_lines(progress_path)
|
||||
|
||||
try:
|
||||
lines, start_idx, end_idx = ensure_plan_block(lines, progress_path, [plan_key])
|
||||
lines, start_idx, end_idx = ensure_plan_block(
|
||||
lines, progress_path, [plan_key]
|
||||
)
|
||||
except ValueError as exc:
|
||||
return 2, f"ERROR: {exc}"
|
||||
|
||||
entries = parse_entries(lines, start_idx, end_idx)
|
||||
rendered_note = normalize_note(note) if note else None
|
||||
updated_line = render_plan_line(plan_key, status, rendered_note)
|
||||
workflow_phase = {
|
||||
"done": "done",
|
||||
"blocked": "blocked",
|
||||
"skipped": "skipped",
|
||||
}[status]
|
||||
|
||||
for entry_plan, _, _, idx in entries:
|
||||
if entry_plan == plan_key:
|
||||
lines[idx] = updated_line
|
||||
workflow_phase = "done" if status == "done" else "blocked"
|
||||
lines = update_workflow_state(
|
||||
lines,
|
||||
phase=workflow_phase,
|
||||
@@ -478,7 +507,6 @@ def finish_plan(
|
||||
return 0, updated_line
|
||||
|
||||
lines[end_idx:end_idx] = [updated_line]
|
||||
workflow_phase = "done" if status == "done" else "blocked"
|
||||
lines = update_workflow_state(
|
||||
lines,
|
||||
phase=workflow_phase,
|
||||
|
||||
Reference in New Issue
Block a user