🐛 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:
csh
2026-05-27 14:07:09 +08:00
parent 61afff9d48
commit 1a3ea1f425
6 changed files with 221 additions and 48 deletions
+49 -21
View File
@@ -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,