playbook/scripts/main_loop.py

788 lines
24 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 = (
"Plan Group",
"Parent Plan",
"Verification Scope",
"Verification Gate",
)
WORKFLOW_STATE_KEYS = {
"phase",
"spec",
"plan",
"executor",
"constraints",
"claimed_by",
"claimed_at",
"verification",
}
WORKFLOW_PHASES = {
"brainstorming",
"planning",
"executing",
"done",
"blocked",
"skipped",
}
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 record -progress <file> -phase <phase> "
"[-spec <path>] [-plan <path>] [-executor <name>] "
"[-constraints <csv>]\n"
" python scripts/main_loop.py -h\n"
"Options:\n"
" -plans DIR\n"
" -plan PATH\n"
" -status done|blocked|skipped\n"
" -progress FILE\n"
" -phase brainstorming|planning|executing|done|blocked|skipped\n"
" -spec PATH\n"
" -executor NAME\n"
" -constraints CSV\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 = [
"# 当前进展",
"",
"## Workflow State",
"",
WORKFLOW_STATE_START,
WORKFLOW_STATE_END,
"",
"## 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 render_workflow_state_lines(
phase: Optional[str] = None,
spec: Optional[str] = None,
plan: Optional[str] = None,
executor: Optional[str] = None,
constraints: Optional[str] = None,
claimed_by: Optional[str] = None,
claimed_at: Optional[str] = None,
verification: Optional[str] = None,
) -> list[str]:
lines = [WORKFLOW_STATE_START]
for key, value in (
("phase", phase),
("spec", spec),
("plan", plan),
("executor", executor),
("constraints", constraints),
("claimed_by", claimed_by),
("claimed_at", claimed_at),
("verification", verification),
):
if value:
lines.append(f"{key}: {value}")
lines.append(WORKFLOW_STATE_END)
return lines
def parse_workflow_state(
lines: list[str], start_idx: int, end_idx: int
) -> dict[str, str]:
state: dict[str, str] = {}
for idx in range(start_idx + 1, end_idx):
line = lines[idx].strip()
if ": " not in line:
continue
key, value = line.split(": ", 1)
if key in WORKFLOW_STATE_KEYS:
state[key] = value
return state
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")
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, _, _ = ensure_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 ensure_workflow_state_block(
lines: list[str],
) -> tuple[list[str], int, int]:
return ensure_block_with_lines(
lines,
WORKFLOW_STATE_START,
WORKFLOW_STATE_END,
[WORKFLOW_STATE_START, WORKFLOW_STATE_END],
"## Workflow State",
)
def update_workflow_state(
lines: list[str],
phase: Optional[str] = None,
spec: Optional[str] = None,
plan: Optional[str] = None,
executor: Optional[str] = None,
constraints: Optional[str] = None,
claimed_by: Optional[str] = None,
claimed_at: Optional[str] = None,
verification: Optional[str] = None,
clear_keys: tuple[str, ...] = (),
) -> list[str]:
lines, start_idx, end_idx = ensure_workflow_state_block(lines)
state = parse_workflow_state(lines, start_idx, end_idx)
for key in clear_keys:
state.pop(key, None)
if phase is not None:
state["phase"] = phase
if spec is not None:
state["spec"] = spec
if plan is not None:
state["plan"] = plan
if executor is not None:
state["executor"] = executor
if constraints is not None:
state["constraints"] = constraints
if claimed_by is not None:
state["claimed_by"] = claimed_by
if claimed_at is not None:
state["claimed_at"] = claimed_at
if verification is not None:
state["verification"] = verification
lines[start_idx : end_idx + 1] = render_workflow_state_lines(
state.get("phase"),
state.get("spec"),
state.get("plan"),
state.get("executor"),
state.get("constraints"),
state.get("claimed_by"),
state.get("claimed_at"),
state.get("verification"),
)
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 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]]:
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 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
lines[idx] = render_plan_line(plan_key, "in-progress", note)
lines = update_workflow_state(
lines,
phase="executing",
plan=(plans_dir / plan_key).as_posix(),
claimed_by=normalize_note(owner) if owner else default_claim_owner(),
claimed_at=now_utc(),
clear_keys=("verification",),
)
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
verification_clear_keys = () if rendered_verified else ("verification",)
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)
workflow_phase = {
"done": "done",
"blocked": "blocked",
"skipped": "skipped",
}[status]
for entry_plan, _, _, idx in entries:
if entry_plan == plan_key:
lines[idx] = updated_line
lines = update_workflow_state(
lines,
phase=workflow_phase,
plan=f"docs/superpowers/plans/{plan_key}",
verification=rendered_verified,
clear_keys=verification_clear_keys,
)
write_progress_lines(progress_path, lines)
return 0, updated_line
lines[end_idx:end_idx] = [updated_line]
lines = update_workflow_state(
lines,
phase=workflow_phase,
plan=f"docs/superpowers/plans/{plan_key}",
verification=rendered_verified,
clear_keys=verification_clear_keys,
)
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}")
state: dict[str, str] = {}
workflow_block = find_named_block(lines, WORKFLOW_STATE_START, WORKFLOW_STATE_END)
if workflow_block:
state = parse_workflow_state(lines, *workflow_block)
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']}"
]
current_parts = [
f"{key}={state[key]}"
for key in (
"phase",
"plan",
"claimed_by",
"claimed_at",
"verification",
)
if key in state
]
if current_parts:
output.append("CURRENT " + " ".join(current_parts))
output.extend(rows)
return 0, "\n".join(output)
def record_workflow_state(
progress_path: Path,
phase: str,
spec: Optional[str],
plan: Optional[str],
executor: Optional[str],
constraints: Optional[str],
) -> tuple[int, str]:
if phase not in WORKFLOW_PHASES:
return 2, f"ERROR: invalid phase: {phase}"
with locked_progress(progress_path):
lines = load_progress_lines(progress_path)
lines = update_workflow_state(lines, phase, spec, plan, executor, constraints)
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", "record", "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
if mode == "record":
progress = flags.get("-progress")
phase = flags.get("-phase")
spec = flags.get("-spec")
plan = flags.get("-plan")
executor = flags.get("-executor")
constraints = flags.get("-constraints")
if not progress or not phase:
print("ERROR: -progress and -phase are required", file=sys.stderr)
print(usage(), file=sys.stderr)
return 2
code, message = record_workflow_state(
Path(progress), phase, spec, plan, executor, constraints
)
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:]))