Files
playbook/scripts/main_loop.py
T
csh 1911d2dda3 🗑️ remove(progress): drop workflow state tracking
Use plan-status as the only machine state source for plan queueing, claiming, and finishing.

Route record-plan through queue insertion and update templates/prompts to match the single-state model.

BREAKING CHANGE: playbook.py -record-spec and main_loop.py record are removed.
2026-06-26 17:10:09 +08:00

621 lines
20 KiB
Python

#!/usr/bin/env python3
from contextlib import contextmanager
from datetime import datetime, timezone
import getpass
import os
import platform
import re
import socket
import sys
import threading
import time
from pathlib import Path
from typing import Optional
try:
import fcntl
except ImportError: # pragma: no cover
fcntl = None
try:
import msvcrt
except ImportError: # pragma: no cover
msvcrt = None
PLAN_STATUS_START = "<!-- plan-status:start -->"
PLAN_STATUS_END = "<!-- plan-status:end -->"
WORKFLOW_STATE_START = "<!-- workflow-state:start -->"
WORKFLOW_STATE_END = "<!-- workflow-state:end -->"
PLAN_FILE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})-.+\.md$")
PLAN_LINE_RE = re.compile(
r"^- \[(?P<check>[ xX])\] `(?P<plan>[^`]+)` "
r"(?P<status>done|blocked|pending|in-progress|skipped)"
r"(?:: (?P<note>.*))?$"
)
FINISH_STATUSES = {"done", "blocked", "skipped"}
ENV_BLOCKED_RE = re.compile(r"^env:([^:]+):(.+)$")
PLAN_META_REQUIRED_FIELDS = (
"Verification Scope",
"Verification Gate",
)
THREAD_LOCKS: dict[str, threading.Lock] = {}
THREAD_LOCKS_GUARD = threading.Lock()
def usage() -> str:
return (
"Usage:\n"
" python scripts/main_loop.py claim -plans <dir> -progress <file>\n"
" python scripts/main_loop.py finish -plan <path> -status <status> "
"-progress <file> [-note <text>] [-verified <text>]\n"
" python scripts/main_loop.py status -plans <dir> -progress <file>\n"
" python scripts/main_loop.py -h\n"
"Options:\n"
" -plans DIR\n"
" -plan PATH\n"
" -status done|blocked|skipped\n"
" -progress FILE\n"
" -note TEXT\n"
" -owner NAME\n"
" -verified TEXT\n"
" -h, -help Show this help.\n"
)
def parse_flags(args: list[str]) -> dict[str, str]:
flags: dict[str, str] = {}
idx = 0
while idx < len(args):
arg = args[idx]
if arg in ("-h", "-help"):
raise ValueError("help")
if not arg.startswith("-"):
raise ValueError(f"unexpected arg: {arg}")
if idx + 1 >= len(args):
raise ValueError(f"missing value for {arg}")
flags[arg] = args[idx + 1]
idx += 2
return flags
def normalize_plan_key(plan_value: str) -> str:
raw = plan_value.strip().replace("\\", "/")
raw = raw.lstrip("./")
if raw.startswith("docs/superpowers/plans/"):
return raw[len("docs/superpowers/plans/") :]
marker = "/docs/superpowers/plans/"
if marker in raw:
return raw.split(marker, 1)[1]
return raw
def normalize_note(note: str) -> str:
return note.replace("\n", " ").replace("\r", " ").replace("`", "'").strip()
def now_utc() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace(
"+00:00", "Z"
)
def default_claim_owner() -> str:
owner = os.environ.get("PLAYBOOK_MAIN_LOOP_OWNER")
if owner:
return normalize_note(owner)
host = socket.gethostname() or "unknown-host"
try:
user = getpass.getuser()
except Exception: # pragma: no cover
user = "unknown-user"
return f"{user}@{host}:{os.getpid()}"
def render_plan_line(plan_key: str, status: str, note: Optional[str]) -> str:
checked = "x" if status == "done" else " "
suffix = status
if note:
suffix += f": {note}"
return f"- [{checked}] `{plan_key}` {suffix}"
def list_plan_files(plans_dir: Path) -> list[str]:
entries: list[str] = []
for path in plans_dir.iterdir():
if not path.is_file():
continue
if not PLAN_FILE_RE.match(path.name):
continue
entries.append(path.name)
return sorted(entries)
def find_block(lines: list[str]) -> Optional[tuple[int, int]]:
start_idx = None
for idx, line in enumerate(lines):
if line.strip() == PLAN_STATUS_START:
start_idx = idx
break
if start_idx is None:
return None
for idx in range(start_idx + 1, len(lines)):
if lines[idx].strip() == PLAN_STATUS_END:
return start_idx, idx
return None
def find_named_block(
lines: list[str], start_marker: str, end_marker: str
) -> Optional[tuple[int, int]]:
start_idx = None
for idx, line in enumerate(lines):
if line.strip() == start_marker:
start_idx = idx
break
if start_idx is None:
return None
for idx in range(start_idx + 1, len(lines)):
if lines[idx].strip() == end_marker:
return start_idx, idx
return None
def parse_entries(
lines: list[str], start_idx: int, end_idx: int
) -> list[tuple[str, str, Optional[str], int]]:
entries: list[tuple[str, str, Optional[str], int]] = []
for idx in range(start_idx + 1, end_idx):
line = lines[idx].strip()
match = PLAN_LINE_RE.match(line)
if not match:
continue
plan_key = normalize_plan_key(match.group("plan"))
status = match.group("status")
note = match.group("note")
entries.append((plan_key, status, note, idx))
return entries
def render_progress_lines(plans: list[str]) -> list[str]:
lines = [
"# 当前进展",
"",
"## Plan Status",
"",
PLAN_STATUS_START,
]
for plan_key in plans:
lines.append(render_plan_line(plan_key, "pending", None))
lines.append(PLAN_STATUS_END)
return lines
def parse_env_blocked_note(note: Optional[str]) -> Optional[tuple[str, str]]:
if not note:
return None
match = ENV_BLOCKED_RE.match(note)
if not match:
return None
return match.group(1), match.group(2)
def detect_env() -> Optional[str]:
mapping = {"windows": "windows", "linux": "linux", "darwin": "darwin"}
return mapping.get(platform.system().lower())
def load_progress_lines(progress_path: Path) -> list[str]:
progress_path.parent.mkdir(parents=True, exist_ok=True)
if progress_path.exists():
return progress_path.read_text(encoding="utf-8").splitlines()
return []
def write_progress_lines(progress_path: Path, lines: list[str]) -> None:
progress_path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
def get_thread_lock(lock_path: Path) -> threading.Lock:
key = str(lock_path.resolve())
with THREAD_LOCKS_GUARD:
lock = THREAD_LOCKS.get(key)
if lock is None:
lock = threading.Lock()
THREAD_LOCKS[key] = lock
return lock
@contextmanager
def locked_progress(progress_path: Path):
progress_path.parent.mkdir(parents=True, exist_ok=True)
lock_path = progress_path.with_name(f"{progress_path.name}.lock")
thread_lock = get_thread_lock(lock_path)
with thread_lock:
with lock_path.open("a+b") as lock_file:
if fcntl is not None:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
elif msvcrt is not None: # pragma: no cover
while True:
try:
lock_file.seek(0)
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
break
except OSError:
time.sleep(0.05)
try:
hold_ms = os.environ.get("PLAYBOOK_MAIN_LOOP_HOLD_LOCK_MS")
if hold_ms:
time.sleep(max(0.0, float(hold_ms) / 1000.0))
yield
finally:
if fcntl is not None:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
elif msvcrt is not None: # pragma: no cover
lock_file.seek(0)
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
def ensure_section(lines: list[str], heading: str) -> list[str]:
if any(line.strip() == heading for line in lines):
return lines
if lines and lines[-1] != "":
lines.append("")
lines.extend([heading, ""])
return lines
def ensure_block_with_lines(
lines: list[str],
start_marker: str,
end_marker: str,
default_lines: list[str],
heading: Optional[str] = None,
) -> tuple[list[str], int, int]:
block = find_named_block(lines, start_marker, end_marker)
if block:
return lines, block[0], block[1]
if not lines:
lines = ["# 当前进展", ""]
if heading:
lines = ensure_section(lines, heading)
if lines and lines[-1] != "":
lines.append("")
insert_at = len(lines)
lines[insert_at:insert_at] = default_lines
return lines, insert_at, insert_at + len(default_lines) - 1
def ensure_plan_block(
lines: list[str], progress_path: Path, plan_keys: list[str]
) -> tuple[list[str], int, int]:
lines = remove_workflow_state_block(lines)
lines, start_idx, end_idx = ensure_block_with_lines(
lines,
PLAN_STATUS_START,
PLAN_STATUS_END,
[PLAN_STATUS_START, PLAN_STATUS_END],
"## Plan Status",
)
write_progress_lines(progress_path, lines)
return lines, start_idx, end_idx
def remove_workflow_state_block(lines: list[str]) -> list[str]:
block = find_named_block(lines, WORKFLOW_STATE_START, WORKFLOW_STATE_END)
if not block:
return lines
start_idx, end_idx = block
remove_start = start_idx
if (
remove_start >= 2
and lines[remove_start - 1].strip() == ""
and lines[remove_start - 2].strip() == "## Workflow State"
):
remove_start -= 2
if remove_start > 0 and lines[remove_start - 1].strip() == "":
remove_start -= 1
remove_end = end_idx + 1
if remove_end < len(lines) and lines[remove_end].strip() == "":
remove_end += 1
del lines[remove_start:remove_end]
return lines
def ensure_all_plans_present(
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
]
lines[end_idx:end_idx] = insert_lines
write_progress_lines(progress_path, lines)
end_idx += len(insert_lines)
entries = parse_entries(lines, start_idx, end_idx)
return entries
def ensure_plan_status_entry(lines: list[str], plan_key: str) -> list[str]:
lines = remove_workflow_state_block(lines)
lines, start_idx, end_idx = ensure_block_with_lines(
lines,
PLAN_STATUS_START,
PLAN_STATUS_END,
[PLAN_STATUS_START, PLAN_STATUS_END],
"## Plan Status",
)
entries = parse_entries(lines, start_idx, end_idx)
if plan_key not in {entry_plan for entry_plan, _, _, _ in entries}:
lines[end_idx:end_idx] = [render_plan_line(plan_key, "pending", None)]
return lines
def filter_existing_entries(
entries: list[tuple[str, str, Optional[str], int]], plan_keys: list[str]
) -> list[tuple[str, str, Optional[str], int]]:
available = set(plan_keys)
return [entry for entry in entries if entry[0] in available]
def validate_plan_meta(plan_path: Path) -> list[str]:
text = plan_path.read_text(encoding="utf-8")
missing: list[str] = []
if not re.search(r"(?im)^##\s+Plan Meta\s*$", text):
missing.append("Plan Meta")
for field in PLAN_META_REQUIRED_FIELDS:
pattern = rf"(?im)^\s*[-*]\s+(?:\*\*)?{re.escape(field)}(?:\*\*)?\s*:"
if not re.search(pattern, text):
missing.append(field)
return missing
def validate_plan_files(plans_dir: Path, plan_keys: list[str]) -> Optional[str]:
for plan_key in plan_keys:
missing = validate_plan_meta(plans_dir / plan_key)
if missing:
fields = ", ".join(missing)
return f"ERROR: {plan_key} missing required Plan Meta fields: {fields}"
return None
def choose_claim_entry(
entries: list[tuple[str, str, Optional[str], int]],
current_env: Optional[str],
plan_keys: list[str],
) -> Optional[tuple[str, Optional[str], int]]:
available = set(plan_keys)
ordered_entries = [entry for entry in entries if entry[0] in available]
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 ordered_entries:
if status == "pending":
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
def claim_plan(
plans_dir: Path, progress_path: Path, owner: Optional[str] = None
) -> tuple[int, str]:
if not plans_dir.is_dir():
return 2, f"ERROR: plans dir not found: {plans_dir}"
plan_keys = list_plan_files(plans_dir)
if not plan_keys:
return 2, "ERROR: no plan files found"
plan_error = validate_plan_files(plans_dir, plan_keys)
if plan_error:
return 2, plan_error
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
)
except ValueError as exc:
return 2, f"ERROR: {exc}"
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(), plan_keys)
if not chosen:
return 0, "NOOP: no claimable plans"
plan_key, note, idx = chosen
claim_note = (
f"claimed_by: {normalize_note(owner) if owner else default_claim_owner()}; "
f"claimed_at: {now_utc()}"
)
updated_note = f"{note}; {claim_note}" if note else claim_note
lines[idx] = render_plan_line(plan_key, "in-progress", updated_note)
write_progress_lines(progress_path, lines)
output = [f"PLAN={(plans_dir / plan_key).as_posix()}"]
if note:
output.append(f"NOTE={note}")
return 0, "\n".join(output)
def finish_plan(
plan: str,
status: str,
progress_path: Path,
note: Optional[str],
verified: Optional[str] = None,
) -> tuple[int, str]:
if status not in FINISH_STATUSES:
return 2, f"ERROR: invalid status: {status}"
if not plan:
return 2, "ERROR: plan is required"
if verified and status != "done":
return 2, "ERROR: -verified is only valid with -status done"
plan_key = normalize_plan_key(plan)
with locked_progress(progress_path):
lines = load_progress_lines(progress_path)
try:
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
rendered_verified = normalize_note(verified) if verified else None
if rendered_verified:
verified_note = f"verified: {rendered_verified}"
rendered_note = (
f"{verified_note}; note: {rendered_note}"
if rendered_note
else verified_note
)
updated_line = render_plan_line(plan_key, status, rendered_note)
for entry_plan, _, _, idx in entries:
if entry_plan == plan_key:
lines[idx] = updated_line
write_progress_lines(progress_path, lines)
return 0, updated_line
lines[end_idx:end_idx] = [updated_line]
write_progress_lines(progress_path, lines)
return 0, updated_line
def status_report(plans_dir: Path, progress_path: Path) -> tuple[int, str]:
if not plans_dir.is_dir():
return 2, f"ERROR: plans dir not found: {plans_dir}"
plan_keys = list_plan_files(plans_dir)
lines = load_progress_lines(progress_path)
block = find_block(lines)
entries: list[tuple[str, str, Optional[str], int]] = []
if block:
entries = filter_existing_entries(parse_entries(lines, *block), plan_keys)
entry_by_plan = {plan_key: (status, note) for plan_key, status, note, _ in entries}
counts = {status: 0 for status in ("pending", "in-progress", "done", "blocked", "skipped")}
rows: list[str] = []
for plan_key in plan_keys:
status, note = entry_by_plan.get(plan_key, ("pending", None))
counts[status] += 1
suffix = f": {note}" if note else ""
rows.append(f"PLAN {plan_key} {status}{suffix}")
output = [
"STATUS "
f"total={len(plan_keys)} "
f"pending={counts['pending']} "
f"in-progress={counts['in-progress']} "
f"done={counts['done']} "
f"blocked={counts['blocked']} "
f"skipped={counts['skipped']}"
]
output.extend(rows)
return 0, "\n".join(output)
def record_plan(progress_path: Path, plan: str) -> tuple[int, str]:
with locked_progress(progress_path):
lines = load_progress_lines(progress_path)
lines = ensure_plan_status_entry(lines, normalize_plan_key(plan))
write_progress_lines(progress_path, lines)
return 0, "OK"
def main(argv: list[str]) -> int:
if not argv:
print(usage(), file=sys.stderr)
return 2
if argv[0] in ("-h", "-help"):
print(usage())
return 0
mode = argv[0]
if mode not in {"claim", "finish", "status"}:
print(f"ERROR: unknown mode: {mode}", file=sys.stderr)
print(usage(), file=sys.stderr)
return 2
try:
flags = parse_flags(argv[1:])
except ValueError as exc:
if str(exc) == "help":
print(usage())
return 0
print(f"ERROR: {exc}", file=sys.stderr)
print(usage(), file=sys.stderr)
return 2
if mode == "claim":
plans = flags.get("-plans")
progress = flags.get("-progress")
owner = flags.get("-owner")
if not plans or not progress:
print("ERROR: -plans and -progress are required", file=sys.stderr)
print(usage(), file=sys.stderr)
return 2
code, message = claim_plan(Path(plans), Path(progress), owner)
if code != 0:
print(message, file=sys.stderr)
return code
print(message)
return 0
if mode == "status":
plans = flags.get("-plans")
progress = flags.get("-progress")
if not plans or not progress:
print("ERROR: -plans and -progress are required", file=sys.stderr)
print(usage(), file=sys.stderr)
return 2
code, message = status_report(Path(plans), Path(progress))
if code != 0:
print(message, file=sys.stderr)
return code
print(message)
return 0
plan = flags.get("-plan")
status = flags.get("-status")
progress = flags.get("-progress")
note = flags.get("-note")
verified = flags.get("-verified")
if not plan or not status or not progress:
print("ERROR: -plan, -status, and -progress are required", file=sys.stderr)
print(usage(), file=sys.stderr)
return 2
code, message = finish_plan(plan, status, Path(progress), note, verified)
if code != 0:
print(message, file=sys.stderr)
return code
print(message)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))